[
  {
    "path": ".codeql/codeql-config.yml",
    "content": "name: \"Bambuddy CodeQL Configuration\"\n\n# Uses the default query suite with accepted-risk exclusions.\n# Each exclusion is reviewed and documented below.\n\nquery-filters:\n  # ── Python Accepted Risk ─────────────────────────────────────\n\n  # Log injection: All logging uses %s parameterized style.\n  # Remaining findings are CodeQL taint-tracking printer/device data\n  # to parameterized log args. Accepted risk for local network tool.\n  - exclude:\n      id: py/log-injection\n\n  # Cyclic imports: SQLAlchemy ORM pattern — models import\n  # database base class, database imports models for migrations.\n  - exclude:\n      id: py/cyclic-import\n  - exclude:\n      id: py/unsafe-cyclic-import\n\n  # Unused local variables: Python _ prefix convention for\n  # intentional discards (tuple unpacking, test fixture side effects).\n  - exclude:\n      id: py/unused-local-variable\n\n  # Path injection: All paths validated — extension whitelists,\n  # traversal checks (rejects .. / \\), UUID-based naming, or\n  # constructed from integer IDs in controlled base directories.\n  - exclude:\n      id: py/path-injection\n\n  # Stack trace exposure: str(e) replaced with generic messages\n  # in HTTP responses. Remaining findings are CodeQL tracing through\n  # _update_status dict returns, not actual exposures.\n  - exclude:\n      id: py/stack-trace-exposure\n\n  # Socket bind to 0.0.0.0: Virtual printer SSDP/discovery\n  # services must bind all interfaces for LAN discoverability.\n  - exclude:\n      id: py/bind-socket-all-network-interfaces\n\n  # SSRF: URLs come from admin-configured settings (external\n  # cameras, Home Assistant, Tasmota). Validation added for scheme,\n  # hostname, and metadata-service blocking.\n  - exclude:\n      id: py/partial-ssrf\n  - exclude:\n      id: py/full-ssrf\n\n  # Unused global variables: False positives — module-level\n  # cache variables written via `global` in one function, read in another.\n  - exclude:\n      id: py/unused-global-variable\n\n  # Clear-text logging sensitive data: False positive —\n  # `api_key` in firmware_check.py is a printer model identifier\n  # string (\"x1\", \"p1\", \"a1-mini\"), not a secret.\n  - exclude:\n      id: py/clear-text-logging-sensitive-data\n\n  # Clear-text storage sensitive data: JWT secret stored in\n  # file with 0600 permissions. Standard for single-host deployment.\n  - exclude:\n      id: py/clear-text-storage-sensitive-data\n\n  # Weak hashing on sensitive data: MD5 used with\n  # usedforsecurity=False for AMS tray fingerprinting, not security.\n  - exclude:\n      id: py/weak-sensitive-data-hashing\n\n  # Catch base exception: In frontend/node_modules third-party\n  # code (flatted/python/flatted.py), outside our control.\n  - exclude:\n      id: py/catch-base-exception\n\n  # LDAP injection: All user input is RFC 4515 escaped via\n  # _ldap_escape() (ldap_service.py:282) before interpolation\n  # into search filters. CodeQL does not trace through the\n  # escape replace-loop and reports false positives on lines\n  # 131 / 183 / 198 where escaped values are reused.\n  - exclude:\n      id: py/ldap-injection\n\n  # Incomplete URL substring sanitization: Only triggers in\n  # test assertions (test_cloud_auth.py) that verify the\n  # mocked HTTP client saw the right hostname\n  # (e.g. `\"api.bambulab.cn\" in captured_url`). URLs come\n  # from a mock's captured_urls list, not user input.\n  - exclude:\n      id: py/incomplete-url-substring-sanitization\n\n  # ── JavaScript Accepted Risk ─────────────────────────────────\n\n  # XSS through DOM: False positives —\n  # 1. coverage/sorter.js: generated Istanbul coverage report\n  # 2. TimelapseEditorModal.tsx: URL.createObjectURL(file) creates\n  #    a safe blob: URL used as <audio src>, not HTML injection.\n  - exclude:\n      id: js/xss-through-dom\n"
  },
  {
    "path": ".codeql/javascript-bambuddy.qls",
    "content": "# Bambuddy JavaScript Security & Quality Suite\n#\n# Extends the standard javascript-security-and-quality suite,\n# excluding false positives documented below.\n\n- description: \"Bambuddy JavaScript security and quality\"\n\n- import: codeql-suites/javascript-security-and-quality.qls\n  from: codeql/javascript-queries\n\n# XSS through DOM (2): False positives —\n# 1. coverage/sorter.js: generated Istanbul coverage report, not our code\n# 2. TimelapseEditorModal.tsx: URL.createObjectURL(file) creates a safe\n#    blob: URL used as <audio src>, not HTML content injection\n- exclude:\n    id: js/xss-through-dom\n"
  },
  {
    "path": ".codeql/python-bambuddy.qls",
    "content": "# Bambuddy Python Security & Quality Suite\n#\n# Extends the standard python-security-and-quality suite, excluding\n# accepted-risk findings documented below.\n#\n# All excluded findings have been reviewed and either:\n#   - Fixed in code (validation added) but CodeQL still traces taint\n#   - Confirmed false positive after code inspection\n#   - Accepted risk for a local-network admin tool\n\n- description: \"Bambuddy Python security and quality\"\n\n- import: codeql-suites/python-security-and-quality.qls\n  from: codeql/python-queries\n\n# ── Accepted Risk ─────────────────────────────────────────────\n\n# Log injection (131): All logging uses %s parameterized style.\n# Remaining findings are CodeQL taint-tracking printer/device data\n# to parameterized log args. Accepted risk for local network tool.\n- exclude:\n    id: py/log-injection\n\n# Cyclic imports (70+2): SQLAlchemy ORM pattern — models import\n# database base class, database imports models for migrations.\n- exclude:\n    id: py/cyclic-import\n- exclude:\n    id: py/unsafe-cyclic-import\n\n# Unused local variables (11): Python _ prefix convention for\n# intentional discards (tuple unpacking, test fixture side effects).\n- exclude:\n    id: py/unused-local-variable\n\n# Path injection (11): All paths validated — extension whitelists,\n# traversal checks (rejects .. / \\), UUID-based naming, or\n# constructed from integer IDs in controlled base directories.\n- exclude:\n    id: py/path-injection\n\n# Stack trace exposure (5): str(e) replaced with generic messages\n# in HTTP responses. Remaining findings are CodeQL tracing through\n# _update_status dict returns, not actual new exposures.\n- exclude:\n    id: py/stack-trace-exposure\n\n# Socket bind to 0.0.0.0 (4): Virtual printer SSDP/discovery\n# services must bind all interfaces for LAN discoverability.\n- exclude:\n    id: py/bind-socket-all-network-interfaces\n\n# SSRF (3+1): URLs come from admin-configured settings (external\n# cameras, Home Assistant, Tasmota). Validation added for scheme,\n# hostname, and metadata-service blocking. CodeQL still traces\n# taint through the validated URLs.\n- exclude:\n    id: py/partial-ssrf\n- exclude:\n    id: py/full-ssrf\n\n# Unused global variables (2): False positives — module-level\n# cache variables written via `global` in one function, read in\n# another. CodeQL doesn't track cross-function global reads.\n- exclude:\n    id: py/unused-global-variable\n\n# Clear-text logging sensitive data (2): False positive —\n# `api_key` in firmware_check.py is a printer model identifier\n# string (\"x1\", \"p1\", \"a1-mini\"), not a secret.\n- exclude:\n    id: py/clear-text-logging-sensitive-data\n\n# Clear-text storage sensitive data (1): JWT secret stored in\n# SQLite config with 0600 file permissions. Standard approach\n# for single-host deployment.\n- exclude:\n    id: py/clear-text-storage-sensitive-data\n\n# Weak hashing on sensitive data (1): MD5 in bambu_mqtt.py used\n# with usedforsecurity=False for AMS tray fingerprinting, not\n# for security purposes.\n- exclude:\n    id: py/weak-sensitive-data-hashing\n\n# Catch base exception (1): In frontend/node_modules third-party\n# code (flatted/python/flatted.py), outside our control.\n- exclude:\n    id: py/catch-base-exception\n"
  },
  {
    "path": ".dockerignore",
    "content": "# Git\n# Exclude all .git contents EXCEPT HEAD. HEAD is a tiny text file (under\n# 100 bytes) containing e.g. `ref: refs/heads/dev`, which the Dockerfile\n# copies into the image so detect_current_branch() in spoolbuddy_ssh.py\n# can report the correct branch for SpoolBuddy remote updates. Without\n# this, the production image has no git metadata at all and always falls\n# back to \"main\" regardless of which branch the operator built from.\n.git/*\n!.git/HEAD\n.gitignore\n\n# Python\n__pycache__\n*.py[cod]\n*$py.class\n*.so\n.Python\nvenv/\n.venv/\nENV/\nenv/\n.env\n*.egg-info/\n.eggs/\ndist/\nbuild/\n\n# Node\nfrontend/node_modules/\nfrontend/.npm\n\n# IDE\n.idea/\n.vscode/\n*.swp\n*.swo\n\n# Testing\n.pytest_cache/\n.coverage\nhtmlcov/\n\n# Logs and data (will be mounted as volumes)\nlogs/\ndata/\n*.log\n*.db\n\n# Build artifacts\nstatic/\n\n# Documentation\ndocs/\n*.md\n!requirements.txt\n\n# Docker\nDockerfile\ndocker-compose*.yml\n.dockerignore\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Force CRLF line endings for Windows batch files so cmd.exe can parse them\n# regardless of the user's core.autocrlf setting.\n*.bat text eol=crlf\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "# CODEOWNERS - Defines code owners who will be requested for review\n# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners\n\n# Default owner for everything\n* @maziggy\n\n# Backend code\n/backend/ @maziggy\n\n# Frontend code\n/frontend/ @maziggy\n\n# Infrastructure and deployment\n/Dockerfile* @maziggy\n/docker-compose*.yml @maziggy\n/.github/ @maziggy\n\n# Documentation\n/*.md @maziggy\n/docs/ @maziggy\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: maziggy\nko_fi: maziggy\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: Report a bug or unexpected behavior\ntitle: \"[Bug]: \"\nlabels: [\"bug\", \"triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to report a bug! Please fill out the form below.\n\n  - type: dropdown\n    id: component\n    attributes:\n      label: Component\n      description: Which part of the project is affected?\n      options:\n        - Bambuddy\n        - SpoolBuddy\n        - Both\n    validations:\n      required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Bug Description\n      description: A clear and concise description of what the bug is.\n      placeholder: Describe what happened...\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected Behavior\n      description: What did you expect to happen?\n      placeholder: Describe what you expected...\n    validations:\n      required: true\n\n  - type: textarea\n    id: steps\n    attributes:\n      label: Steps to Reproduce\n      description: How can we reproduce this issue?\n      placeholder: |\n        1. Go to '...'\n        2. Click on '...'\n        3. See error\n    validations:\n      required: true\n\n  - type: dropdown\n    id: printer\n    attributes:\n      label: Printer Model\n      description: Which printer model are you using?\n      options:\n        - X1 Carbon\n        - X1\n        - X1E\n        - X2D\n        - P1S\n        - P1P\n        - P2S\n        - A1\n        - A1 Mini\n        - H2D\n        - H2D Pro\n        - H2C\n        - H2S\n        - Multiple printers\n        - Not printer-related\n    validations:\n      required: false\n\n  - type: input\n    id: version\n    attributes:\n      label: Bambuddy Version\n      description: Which version of Bambuddy are you running? (Check Settings page)\n      placeholder: e.g., 0.1.5\n    validations:\n      required: true\n\n  - type: input\n    id: spoolbuddy_version\n    attributes:\n      label: SpoolBuddy Version\n      description: If SpoolBuddy-related, which version is running? (Check SpoolBuddy Settings → Updates)\n      placeholder: e.g., 0.1.0\n\n  - type: input\n    id: firmware\n    attributes:\n      label: Printer Firmware Version\n      description: Which firmware version is your printer running? (Check printer screen or Bambu Handy app). Leave blank if not printer-related.\n      placeholder: e.g., 01.08.00.00\n\n  - type: dropdown\n    id: installation\n    attributes:\n      label: Installation Method\n      description: How did you install Bambuddy?\n      options:\n        - Manual (git clone)\n        - Docker\n        - Other\n    validations:\n      required: true\n\n  - type: dropdown\n    id: os\n    attributes:\n      label: Operating System\n      description: What OS is Bambuddy running on?\n      options:\n        - Linux (Ubuntu/Debian)\n        - Linux (Other)\n        - macOS\n        - Windows\n        - Docker\n        - Other\n    validations:\n      required: true\n\n  - type: markdown\n    attributes:\n      value: |\n        ---\n        ### 📦 Support Package\n\n        For faster debugging, please create and attach a **Support Package** from **Settings → System Info → Download Support Package**.\n        This includes logs, system info, and configuration (with sensitive data redacted).\n\n        For detailed instructions on enabling debug logging, see: [Debug Logging Guide](https://wiki.bambuddy.cool/features/system-info/?h=debug#enable-debug-logging)\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: Relevant Logs / Support Package\n      description: |\n        Attach a support package (.zip) or paste relevant logs here. Enable DEBUG mode for verbose logging.\n        💡 Tip: You can drag and drop files directly into this text box.\n      placeholder: |\n        Drag and drop your support package .zip file here, or paste logs...\n\n  - type: textarea\n    id: screenshots\n    attributes:\n      label: Screenshots\n      description: |\n        If applicable, add screenshots to help explain your problem.\n        💡 Tip: You can drag and drop images directly into this text box.\n\n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional Context\n      description: Add any other context about the problem here.\n\n  - type: checkboxes\n    id: checklist\n    attributes:\n      label: Checklist\n      options:\n        - label: I have searched existing issues to ensure this bug hasn't already been reported\n          required: true\n        - label: I am using the latest version of Bambuddy\n          required: true\n        - label: My printer is set to LAN Only mode\n          required: true\n        - label: My printer has Developer Mode enabled\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Documentation\n    url: https://github.com/maziggy/bambuddy-wiki\n    about: Check the documentation for guides and troubleshooting\n  - name: Community Forum\n    url: https://forum.bambuddy.cool\n    about: Ask questions and share ideas with the community\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Suggest a new feature or enhancement\ntitle: \"[Feature]: \"\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for suggesting a feature! Please fill out the form below.\n\n  - type: textarea\n    id: problem\n    attributes:\n      label: Problem or Use Case\n      description: Is your feature request related to a problem? Please describe the use case.\n      placeholder: I'm always frustrated when...\n    validations:\n      required: true\n\n  - type: textarea\n    id: solution\n    attributes:\n      label: Proposed Solution\n      description: Describe the solution you'd like to see.\n      placeholder: It would be great if...\n    validations:\n      required: true\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternatives Considered\n      description: Have you considered any alternative solutions or workarounds?\n      placeholder: I've tried...\n\n  - type: dropdown\n    id: category\n    attributes:\n      label: Feature Category\n      description: What area does this feature relate to?\n      options:\n        - Print Archiving\n        - Monitoring & Stats\n        - Print Queue & Scheduling\n        - Smart Plugs\n        - Notifications\n        - Spool Inventory\n        - Spoolman Integration\n        - Cloud Profiles\n        - K-Profiles\n        - Maintenance Tracking\n        - File Manager\n        - SpoolBuddy - Dashboard\n        - SpoolBuddy - AMS Management\n        - SpoolBuddy - NFC / Tag Writing\n        - SpoolBuddy - Scale / Calibration\n        - SpoolBuddy - Inventory\n        - SpoolBuddy - Settings / Updates\n        - SpoolBuddy - Hardware / Installation\n        - UI/UX\n        - API\n        - Other\n    validations:\n      required: true\n\n  - type: dropdown\n    id: priority\n    attributes:\n      label: Priority\n      description: How important is this feature to you?\n      options:\n        - Nice to have\n        - Would improve my workflow\n        - Critical for my use case\n    validations:\n      required: true\n\n  - type: textarea\n    id: mockups\n    attributes:\n      label: Mockups or Examples\n      description: |\n        If you have any mockups, screenshots, or examples from other software, please share them.\n        💡 Tip: You can drag and drop images directly into this text box.\n\n  - type: checkboxes\n    id: contribution\n    attributes:\n      label: Contribution\n      options:\n        - label: I would be willing to help implement this feature\n          required: false\n\n  - type: checkboxes\n    id: checklist\n    attributes:\n      label: Checklist\n      options:\n        - label: I have searched existing issues to ensure this feature hasn't already been requested\n          required: true\n"
  },
  {
    "path": ".github/MAINTAINERS.md",
    "content": "# Maintainer Guide\n\nThis document provides setup instructions for repository maintainers.\n\n## Branch Protection Setup\n\nTo protect the `main` branch, go to **Settings > Rules > Rulesets > New ruleset > New branch ruleset**.\n\n### Step 1: Basic Settings\n\n| Field | Value |\n|-------|-------|\n| Ruleset name | `Protect main` |\n| Enforcement status | `Active` |\n\n### Step 2: Bypass List (optional)\n\nAdd yourself (`@maziggy`) to bypass if you want to push directly in emergencies.\nSet \"Always\" or \"Pull requests only\" based on preference.\n\n### Step 3: Target Branches\n\nClick **Add target** > **Include by pattern** and enter: `main`\n\n### Step 4: Branch Rules\n\nEnable these rules:\n\n**Restrict deletions** - Prevents branch deletion\n\n**Require a pull request before merging**\n- Required approvals: `1`\n- [x] Dismiss stale pull request approvals when new commits are pushed\n- [ ] Require review from Code Owners (optional)\n- [x] Require approval of the most recent reviewable push\n\n**Require status checks to pass**\n- [x] Require branches to be up to date before merging\n- Add these status checks (they appear after CI runs once):\n  - `Backend Lint`\n  - `Backend Tests`\n  - `Frontend Lint`\n  - `Frontend Type Check`\n  - `Frontend Tests`\n  - `Frontend Build`\n  - `Docker Build`\n\n**Block force pushes** - Prevents history rewriting\n\n### Optional (stricter)\n\n- [ ] Require conversation resolution before merging\n- [ ] Require signed commits\n- [ ] Require linear history\n\n## CI Workflow\n\nThe CI workflow (`.github/workflows/ci.yml`) runs on:\n- All pull requests to `main`\n- All pushes to `main`\n\n### Jobs\n\n| Job | Purpose | Required for PR |\n|-----|---------|-----------------|\n| `backend-lint` | Ruff linting + format check | Yes |\n| `backend-tests` | Unit tests | Yes |\n| `frontend-lint` | ESLint | Yes |\n| `frontend-typecheck` | TypeScript compilation | Yes |\n| `frontend-tests` | Vitest unit tests | Yes |\n| `frontend-build` | Vite production build | Yes |\n| `docker-build` | Docker image builds | Yes |\n\n### Fixing CI Failures\n\n**Backend lint failures:**\n```bash\nruff check --fix backend/\nruff format backend/\n```\n\n**Frontend lint failures:**\n```bash\ncd frontend\nnpm run lint -- --fix\n```\n\n**Frontend type errors:**\n```bash\ncd frontend\nnpx tsc --noEmit\n# Fix the errors shown\n```\n\n**Frontend test failures:**\n```bash\ncd frontend\nnpm run test:run\n# Fix failing tests\n```\n\n## CODEOWNERS\n\nThe `CODEOWNERS` file automatically requests reviews from `@maziggy` for all changes.\n\nTo add more code owners:\n1. Edit `.github/CODEOWNERS`\n2. Add GitHub usernames with `@` prefix\n3. Assign specific paths to specific owners\n\nExample:\n```\n/backend/ @maziggy @backend-contributor\n/frontend/ @maziggy @frontend-contributor\n```\n\n## Release Process\n\n1. Update version in `pyproject.toml`\n2. Update `CHANGELOG.md`\n3. Create a PR with these changes\n4. After merge, tag the release:\n   ```bash\n   git tag v0.1.x\n   git push origin v0.1.x\n   ```\n5. Run `docker-publish.sh` to publish Docker image\n\n## Dependabot (Optional)\n\nTo enable automated dependency updates, create `.github/dependabot.yml`:\n\n```yaml\nversion: 2\nupdates:\n  - package-ecosystem: \"pip\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    groups:\n      python-dependencies:\n        patterns:\n          - \"*\"\n\n  - package-ecosystem: \"npm\"\n    directory: \"/frontend\"\n    schedule:\n      interval: \"weekly\"\n    groups:\n      npm-dependencies:\n        patterns:\n          - \"*\"\n\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n```\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Description\n\n<!-- Provide a brief description of your changes -->\n\n## Related Issue\n\n<!-- Link to the issue this PR addresses (if applicable) -->\nFixes #\n\n## Documentation\n\n<!--\nIf this PR changes user-visible behavior, config keys, ports, CLI flags,\nURLs, or installation steps, link matching PRs in the docs repos below.\nInternal refactors, bug fixes with no observable change, and test-only\nchanges are exempt — just check the \"not required\" box and say why.\n\nSee CONTRIBUTING.md → Documentation Requirements for the full rules.\n-->\n\n**Companion docs PRs** (delete lines that don't apply):\n- Wiki: maziggy/bambuddy-wiki#___\n- Website: maziggy/bambuddy-website#___\n\n**Pick one**:\n- [ ] Docs PR(s) linked above\n- [ ] No docs update required — reason: ___\n\n## Type of Change\n\n<!-- Mark the relevant option with an \"x\" -->\n\n- [ ] Bug fix (non-breaking change that fixes an issue)\n- [ ] New feature (non-breaking change that adds functionality)\n- [ ] Breaking change (fix or feature that would cause existing functionality to change)\n- [ ] Documentation update\n- [ ] Code refactoring\n- [ ] Performance improvement\n- [ ] Test addition or update\n\n## Changes Made\n\n<!-- List the specific changes made in this PR -->\n\n-\n-\n-\n\n## Screenshots\n\n<!-- If applicable, add screenshots to demonstrate your changes -->\n\n## Testing\n\n<!-- Describe how you tested your changes -->\n\n- [ ] I have tested this on my local machine\n- [ ] I have tested with my printer model: <!-- e.g., X1C, P1S, A1 -->\n\n## Checklist\n\n- [ ] My code follows the project's coding style\n- [ ] I have commented my code where necessary\n- [ ] My changes generate no new warnings\n- [ ] I have tested my changes thoroughly\n\n## Additional Notes\n\n<!-- Add any additional information that reviewers should know -->\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n  workflow_dispatch:\n    # Run on PRs targeting main, but skip for repo owner (runs local tests)\n\n# Skip CI for PRs authored by repo owner (they run tests locally)\n# Uses PR author instead of triggering actor so rebasing by owner doesn't skip CI\n\nenv:\n  PYTHON_VERSION: '3.11'\n  NODE_VERSION: '22'\n\n# Cancel in-progress runs for the same branch\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\n# Minimum permissions for all jobs\npermissions:\n  contents: read\n\njobs:\n  # ============================================================================\n  # Backend Checks\n  # ============================================================================\n\n  backend-lint:\n    name: Backend Lint\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Install ruff\n        run: pip install ruff\n\n      - name: Run ruff check\n        run: ruff check backend/\n\n      - name: Run ruff format check\n        run: ruff format --check backend/\n\n  backend-security:\n    name: Backend Security\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    continue-on-error: true\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n          pip install pip-audit\n\n      - name: Run pip-audit\n        run: |\n          # CVE-2026-4539: low-severity ReDoS in Pygments AdlLexer (indirect dep via mkdocs-material/pytest/rich).\n          # No fix available yet. Remove --ignore-vuln once Pygments releases a patched version.\n          pip-audit --desc on --ignore-vuln CVE-2026-4539\n\n  backend-tests:\n    name: Backend Tests\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    needs: backend-lint\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Cache pip\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/pip\n          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}\n          restore-keys: |\n            ${{ runner.os }}-pip-\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n          pip install -r requirements-dev.txt\n\n      - name: Run tests\n        timeout-minutes: 10\n        run: |\n          cd backend\n          python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread -n auto\n\n  # ============================================================================\n  # Frontend Checks\n  # ============================================================================\n\n  frontend-lint:\n    name: Frontend Lint\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Run ESLint\n        working-directory: frontend\n        run: npm run lint\n\n  frontend-security:\n    name: Frontend Security\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    continue-on-error: true\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Run npm audit\n        working-directory: frontend\n        run: |\n          # Only audit production dependencies and filter out npm-internal packages.\n          # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)\n          # so we parse package-lock.json directly to get the real prod dep list.\n          npm audit --omit=dev --json > /tmp/audit.json 2>/dev/null || true\n          python3 -c \"\n          import json, sys\n          data = json.load(open('/tmp/audit.json'))\n          lock = json.load(open('package-lock.json'))\n          prod = set()\n          for path, info in lock.get('packages', {}).items():\n              if path and not info.get('dev') and not info.get('devOptional'):\n                  prod.add(path.split('node_modules/')[-1])\n          vulns = data.get('vulnerabilities', {})\n          fixable = {n: v for n, v in vulns.items()\n                     if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}\n          skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})\n          if fixable:\n              for name, v in fixable.items():\n                  print(f'FIXABLE {v[\\\"severity\\\"].upper()}: {name}')\n              sys.exit(1)\n          total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))\n          print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')\n          \"\n\n  frontend-typecheck:\n    name: Frontend Type Check\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Run TypeScript check\n        working-directory: frontend\n        run: npx tsc --noEmit\n\n  frontend-tests:\n    name: Frontend Tests\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    needs: [frontend-lint, frontend-typecheck]\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Run tests\n        timeout-minutes: 10\n        working-directory: frontend\n        run: npm run test:run\n\n  frontend-build:\n    name: Frontend Build\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    needs: [frontend-tests]\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Build\n        working-directory: frontend\n        run: npm run build\n\n  # ============================================================================\n  # Docker Tests (matches test_docker.sh)\n  # ============================================================================\n\n  docker-test:\n    name: Docker Build\n    runs-on: ubuntu-latest\n    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner\n    timeout-minutes: 20\n    needs: [backend-tests, frontend-build]\n    steps:\n      - uses: actions/checkout@v4\n\n      # Test 1: Docker Build\n      - name: Build production image\n        run: docker build -t bambuddy:test .\n\n      - name: Verify backend imports\n        run: docker run --rm bambuddy:test python -c \"import backend.app.main; print('Backend imports OK')\"\n\n      - name: Verify static files exist\n        run: docker run --rm bambuddy:test test -d /app/static\n\n      # Test 2: Backend Unit Tests in Docker\n      - name: Build backend test image\n        run: docker compose -f docker-compose.test.yml build backend-test\n\n      - name: Run backend tests in Docker\n        run: docker compose -f docker-compose.test.yml run --rm backend-test\n\n      # Test 3: Frontend Unit Tests in Docker\n      - name: Build frontend test image\n        run: docker compose -f docker-compose.test.yml build frontend-test\n\n      - name: Run frontend tests in Docker\n        run: docker compose -f docker-compose.test.yml run --rm frontend-test\n\n      # Test 4: Integration Tests\n      - name: Build integration container\n        run: docker compose -f docker-compose.test.yml build integration\n\n      - name: Start integration container\n        run: |\n          docker compose -f docker-compose.test.yml up -d integration\n          echo \"Waiting for container to be healthy...\"\n          for i in {1..30}; do\n            if docker compose -f docker-compose.test.yml ps integration | grep -q \"healthy\"; then\n              echo \"Container is healthy\"\n              break\n            fi\n            sleep 2\n          done\n\n      - name: Test health endpoint\n        run: |\n          HEALTH=$(docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)\n          echo \"$HEALTH\"\n          echo \"$HEALTH\" | grep -q \"healthy\"\n\n      - name: Test API endpoint\n        run: |\n          docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings\n\n      - name: Test static files served\n        run: |\n          STATUS=$(docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8000/)\n          echo \"Static files HTTP status: $STATUS\"\n          [ \"$STATUS\" = \"200\" ]\n\n      # Test 5: Integration Test Suite (pytest)\n      - name: Build integration test runner\n        run: docker compose -f docker-compose.test.yml build integration-test-runner\n\n      - name: Run integration test suite\n        run: docker compose -f docker-compose.test.yml run --rm integration-test-runner\n\n      - name: Show logs on failure\n        if: failure()\n        run: docker compose -f docker-compose.test.yml logs\n\n      - name: Cleanup\n        if: always()\n        run: docker compose -f docker-compose.test.yml down -v --remove-orphans\n"
  },
  {
    "path": ".github/workflows/cleanup-ghcr.yml",
    "content": "name: Cleanup GHCR untagged images\n\n# Deletes untagged (orphan) container versions from GHCR while preserving\n# any digest that is still referenced by a tagged multi-arch manifest list.\n# Without that cross-reference, off-the-shelf cleanup actions can silently\n# break multi-arch tags by deleting referenced platform manifests.\n#\n# Requires a repo secret `GHCR_CLEANUP_TOKEN` — a classic PAT with\n# `read:packages` + `delete:packages` scope. GITHUB_TOKEN cannot delete\n# versions of user-owned packages (only org-owned).\n\non:\n  schedule:\n    - cron: '0 3 * * 0'  # Sundays at 03:00 UTC\n  workflow_dispatch:\n    inputs:\n      dry_run:\n        description: 'List orphans without deleting'\n        type: boolean\n        default: false\n\n# This workflow authenticates exclusively via GHCR_CLEANUP_TOKEN (a classic PAT)\n# and never reads/writes via the default GITHUB_TOKEN. Strip every permission\n# from the GITHUB_TOKEN so a stolen workflow run can't reach the repo at all\n# — least privilege per CodeQL `actions/missing-workflow-permissions`.\npermissions: {}\n\njobs:\n  cleanup:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        package: [bambuddy, bambuddy-beta]\n    steps:\n      - name: Cleanup ${{ matrix.package }}\n        env:\n          GH_TOKEN: ${{ secrets.GHCR_CLEANUP_TOKEN }}\n          OWNER: ${{ github.repository_owner }}\n          PACKAGE: ${{ matrix.package }}\n          DRY_RUN: ${{ inputs.dry_run }}\n        run: |\n          set -euo pipefail\n\n          # 1. Fetch all versions for the package.\n          gh api \"users/${OWNER}/packages/container/${PACKAGE}/versions?per_page=100\" \\\n            --paginate --slurp > all.json\n          total=$(jq 'add | length' all.json)\n          echo \"Total versions: $total\"\n\n          # 2. Build the live-digest set: digests referenced by any tagged\n          #    multi-arch manifest list. Use the registry token (separate from\n          #    GH_TOKEN) for ghcr.io manifest reads.\n          REG_TOKEN=$(curl -sS \\\n            \"https://ghcr.io/token?scope=repository:${OWNER}/${PACKAGE}:pull&service=ghcr.io\" \\\n            | jq -r .token)\n\n          jq -r 'add | map(select(.metadata.container.tags | length > 0))\n                 | .[] | .metadata.container.tags[0]' all.json > tags.txt\n\n          : > live.txt\n          while IFS= read -r tag; do\n            curl -sS \\\n              -H \"Authorization: Bearer $REG_TOKEN\" \\\n              -H \"Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json\" \\\n              \"https://ghcr.io/v2/${OWNER}/${PACKAGE}/manifests/${tag}\" \\\n            | jq -r '(.manifests // []) | .[].digest' >> live.txt 2>/dev/null || true\n          done < tags.txt\n          sort -u live.txt -o live.txt\n          echo \"Live digests referenced by manifest lists: $(wc -l < live.txt)\"\n\n          # 3. Untagged digests minus live = true orphans.\n          jq -r 'add | map(select(.metadata.container.tags | length == 0))\n                 | .[] | .name' all.json | sort -u > untagged.txt\n          comm -23 untagged.txt live.txt > orphan_digests.txt\n          orphan_count=$(wc -l < orphan_digests.txt)\n          echo \"Orphan digests safe to delete: $orphan_count\"\n\n          if [ \"$orphan_count\" -eq 0 ]; then\n            echo \"Nothing to delete.\"\n            exit 0\n          fi\n\n          # 4. Map orphan digests -> version IDs.\n          jq -r --slurpfile orphans <(jq -R . orphan_digests.txt) '\n            add\n            | map(select(.name as $n | ($orphans | flatten | index($n))))\n            | .[] | .id\n          ' all.json > delete_ids.txt\n          echo \"Version IDs queued for deletion: $(wc -l < delete_ids.txt)\"\n\n          if [ \"${DRY_RUN:-false}\" = \"true\" ]; then\n            echo \"Dry run — not deleting.\"\n            head -20 delete_ids.txt\n            exit 0\n          fi\n\n          # 5. Delete.\n          deleted=0\n          failed=0\n          while IFS= read -r id; do\n            if gh api -X DELETE \"users/${OWNER}/packages/container/${PACKAGE}/versions/${id}\" --silent; then\n              deleted=$((deleted + 1))\n            else\n              failed=$((failed + 1))\n            fi\n          done < delete_ids.txt\n          echo \"Deleted: $deleted  Failed: $failed\"\n          [ \"$failed\" -eq 0 ]\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "name: \"CodeQL\"\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n  schedule:\n    - cron: '0 6 * * 1'\n\npermissions:\n  contents: read\n\njobs:\n  analyze:\n    name: Analyze (${{ matrix.language }})\n    runs-on: ubuntu-latest\n    permissions:\n      security-events: write\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [python, javascript-typescript, actions]\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Initialize CodeQL\n        uses: github/codeql-action/init@v4\n        with:\n          languages: ${{ matrix.language }}\n          config-file: ./.codeql/codeql-config.yml\n\n      - name: Autobuild\n        uses: github/codeql-action/autobuild@v4\n\n      - name: Perform CodeQL Analysis\n        uses: github/codeql-action/analyze@v4\n        with:\n          category: \"/language:${{ matrix.language }}\"\n"
  },
  {
    "path": ".github/workflows/issue-closed.yml",
    "content": "name: Clean up closed issues\n\non:\n  issues:\n    types: [closed]\n\npermissions:\n  issues: write\n\njobs:\n  remove-labels:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Remove feedback label\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const issue = context.payload.issue;\n            const hasLabel = issue.labels.some(l => l.name === 'feedback');\n\n            if (hasLabel) {\n              try {\n                await github.rest.issues.removeLabel({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: issue.number,\n                  name: 'feedback'\n                });\n                console.log(`Removed 'feedback' label from issue #${issue.number}`);\n              } catch (error) {\n                if (error.status === 404) {\n                  console.log(`Label 'feedback' already removed from issue #${issue.number}`);\n                } else {\n                  throw error;\n                }\n              }\n            }\n"
  },
  {
    "path": ".github/workflows/repo-stats.yml",
    "content": "name: GitHub Repo Stats\n\non:\n  schedule:\n    - cron: \"0 23 * * *\"\n  workflow_dispatch:\n\npermissions:\n  contents: write\n\njobs:\n  repo-stats:\n    name: repo-stats\n    runs-on: ubuntu-latest\n    steps:\n      - name: run-ghrs\n        uses: jgehrcke/github-repo-stats@v1.4.2\n        with:\n          repository: maziggy/bambuddy\n          ghtoken: ${{ secrets.GHRS_GITHUB_API_TOKEN }}\n          ghpagesprefix: https://maziggy.github.io/bambuddy\n"
  },
  {
    "path": ".github/workflows/security.yml",
    "content": "name: Security Audit\n\non:\n  schedule:\n    # Run weekly on Monday at 6:00 UTC\n    - cron: '0 6 * * 1'\n  push:\n    paths:\n      - 'backend/**'\n      - 'frontend/**'\n      - 'spoolbuddy/**'\n      - 'Dockerfile'\n      - 'docker-compose*.yml'\n      - 'requirements.txt'\n      - 'frontend/package*.json'\n      - '.github/workflows/security.yml'\n  pull_request:\n    paths:\n      - 'backend/**'\n      - 'frontend/**'\n      - 'spoolbuddy/**'\n      - 'Dockerfile'\n      - 'docker-compose*.yml'\n      - 'requirements.txt'\n      - 'frontend/package*.json'\n      - '.github/workflows/security.yml'\n  workflow_dispatch:\n    # Allow manual trigger\n\nenv:\n  PYTHON_VERSION: '3.11'\n  NODE_VERSION: '22'\n\n# Default permissions for all jobs\npermissions:\n  contents: read\n\njobs:\n  bandit:\n    name: Python Security Analysis (Bandit)\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      security-events: write\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Install Bandit\n        run: pip install bandit[sarif]\n\n      - name: Run Bandit\n        run: |\n          bandit -r backend/ -f sarif -o bandit-results.sarif --severity-level medium || true\n\n      - name: Upload Bandit results to GitHub Security\n        uses: github/codeql-action/upload-sarif@v4\n        if: always()\n        with:\n          sarif_file: bandit-results.sarif\n          category: bandit\n\n  trivy:\n    name: Container Security Scan (Trivy)\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      security-events: write\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Build Docker image\n        run: docker build -t bambuddy:security-scan .\n\n      - name: Run Trivy vulnerability scanner\n        uses: aquasecurity/trivy-action@v0.35.0\n        with:\n          image-ref: 'bambuddy:security-scan'\n          format: 'sarif'\n          output: 'trivy-results.sarif'\n          severity: 'CRITICAL,HIGH,MEDIUM'\n          version: 'v0.69.1'\n\n      - name: Upload Trivy results to GitHub Security\n        uses: github/codeql-action/upload-sarif@v4\n        if: always() && hashFiles('trivy-results.sarif') != ''\n        with:\n          sarif_file: trivy-results.sarif\n          category: trivy\n\n      - name: Run Trivy for Dockerfile/IaC\n        uses: aquasecurity/trivy-action@v0.35.0\n        with:\n          scan-type: 'config'\n          scan-ref: '.'\n          format: 'sarif'\n          output: 'trivy-config-results.sarif'\n          severity: 'CRITICAL,HIGH,MEDIUM'\n          version: 'v0.69.1'\n\n      - name: Upload Trivy config results\n        uses: github/codeql-action/upload-sarif@v4\n        if: always() && hashFiles('trivy-config-results.sarif') != ''\n        with:\n          sarif_file: trivy-config-results.sarif\n          category: trivy-config\n\n  backend-audit:\n    name: Backend Security Audit\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      issues: write\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n          pip install pip-audit\n\n      - name: Run pip-audit\n        id: pip-audit\n        run: |\n          # CVE-2026-4539: low-severity ReDoS in Pygments AdlLexer (indirect dep via mkdocs-material/pytest/rich).\n          # No fix available yet. Remove --ignore-vuln once Pygments releases a patched version.\n          pip-audit --desc on --format json --output pip-audit-results.json --ignore-vuln CVE-2026-4539 || echo \"vulnerabilities_found=true\" >> $GITHUB_OUTPUT\n          pip-audit --desc on --ignore-vuln CVE-2026-4539 || true\n\n      - name: Upload audit results\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: pip-audit-results\n          path: pip-audit-results.json\n          retention-days: 30\n\n      - name: Create or close pip security issue\n        if: always()\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const fs = require('fs');\n\n            // Check for existing open issue\n            const existingIssues = await github.rest.issues.listForRepo({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              state: 'open',\n              labels: 'security,automated'\n            });\n            const existingIssue = existingIssues.data.find(i => i.title.startsWith('Security Alert:') && i.title.includes('Python'));\n\n            // If no vulnerabilities found, auto-close any stale issue\n            if ('${{ steps.pip-audit.outputs.vulnerabilities_found }}' !== 'true') {\n              if (existingIssue) {\n                await github.rest.issues.createComment({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: existingIssue.number,\n                  body: 'All Python vulnerabilities have been resolved. Closing automatically.'\n                });\n                await github.rest.issues.update({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: existingIssue.number,\n                  state: 'closed'\n                });\n                console.log(`Auto-closed resolved issue #${existingIssue.number}`);\n              }\n              return;\n            }\n\n            let results;\n            try {\n              results = JSON.parse(fs.readFileSync('pip-audit-results.json', 'utf8'));\n            } catch {\n              console.log('Could not read audit results');\n              return;\n            }\n\n            // Build vulnerability table\n            let table = '| Package | Version | Vulnerability | Fix Version |\\n';\n            table += '|---------|---------|---------------|-------------|\\n';\n\n            for (const vuln of results.dependencies || []) {\n              for (const v of vuln.vulns || []) {\n                table += `| ${vuln.name} | ${vuln.version} | ${v.id} | ${v.fix_versions?.join(', ') || 'N/A'} |\\n`;\n              }\n            }\n\n            const vulnCount = results.dependencies?.reduce((acc, d) => acc + (d.vulns?.length || 0), 0) || 0;\n\n            if (vulnCount === 0) {\n              console.log('No vulnerabilities to report');\n              if (existingIssue) {\n                await github.rest.issues.createComment({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: existingIssue.number,\n                  body: 'All Python vulnerabilities have been resolved. Closing automatically.'\n                });\n                await github.rest.issues.update({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: existingIssue.number,\n                  state: 'closed'\n                });\n                console.log(`Auto-closed resolved issue #${existingIssue.number}`);\n              }\n              return;\n            }\n\n            const title = `Security Alert: ${vulnCount} Python vulnerabilities found`;\n\n            const body = `## Automated Security Audit Results\n\n            The weekly security audit found vulnerabilities in Python dependencies.\n\n            ${table}\n\n            ### Recommended Actions\n\n            1. Review each vulnerability\n            2. Update affected packages: \\`pip install --upgrade <package>\\`\n            3. Run \\`pip-audit\\` locally to verify fixes\n\n            ---\n            *This issue was automatically created by the security audit workflow.*`;\n\n            if (existingIssue) {\n              await github.rest.issues.update({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: existingIssue.number,\n                body: body\n              });\n              console.log(`Updated existing issue #${existingIssue.number}`);\n            } else {\n              await github.rest.issues.create({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                title: title,\n                body: body,\n                labels: ['security', 'automated', 'dependencies']\n              });\n              console.log('Created new security issue');\n            }\n\n  frontend-audit:\n    name: Frontend Security Audit\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      issues: write\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Run npm audit\n        id: npm-audit\n        working-directory: frontend\n        run: |\n          npm audit --omit=dev --json > npm-audit-raw.json 2>/dev/null || true\n          # Filter audit results to only include actual project dependencies.\n          # npm 10.x audit/ls reports vulns in its own bundled deps (npm, tar, minimatch)\n          # so we parse package-lock.json directly to get the real prod dep list.\n          node -e \"\n            const fs = require('fs');\n            const raw = fs.readFileSync('npm-audit-raw.json', 'utf8');\n            let results;\n            try { results = JSON.parse(raw); } catch { results = { vulnerabilities: {} }; }\n            const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));\n            const prodDeps = new Set();\n            for (const [path, info] of Object.entries(lock.packages || {})) {\n              if (path && !info.dev && !info.devOptional) {\n                prodDeps.add(path.split('node_modules/').pop());\n              }\n            }\n            const vulns = results.vulnerabilities || {};\n            const filtered = {};\n            for (const [name, info] of Object.entries(vulns)) {\n              if (prodDeps.has(name)) filtered[name] = info;\n            }\n            results.vulnerabilities = filtered;\n            fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));\n            const count = Object.keys(filtered).length;\n            console.log(count > 0\n              ? count + ' production vulnerabilities found'\n              : 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');\n            if (count > 0) process.exit(1);\n          \" || echo \"vulnerabilities_found=true\" >> $GITHUB_OUTPUT\n          npm audit --omit=dev --audit-level=high || true\n\n      - name: Upload audit results\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: npm-audit-results\n          path: frontend/npm-audit-results.json\n          retention-days: 30\n\n      - name: Create or close npm security issue\n        if: always()\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const fs = require('fs');\n\n            // Check for existing open issue\n            const existingIssues = await github.rest.issues.listForRepo({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              state: 'open',\n              labels: 'security,automated'\n            });\n            const existingIssue = existingIssues.data.find(i => i.title.startsWith('Security Alert:') && i.title.includes('npm'));\n\n            // If filter didn't flag vulnerabilities, auto-close any stale issue\n            if ('${{ steps.npm-audit.outputs.vulnerabilities_found }}' !== 'true') {\n              if (existingIssue) {\n                await github.rest.issues.createComment({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: existingIssue.number,\n                  body: 'All npm production vulnerabilities have been resolved. Closing automatically.'\n                });\n                await github.rest.issues.update({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: existingIssue.number,\n                  state: 'closed'\n                });\n                console.log(`Auto-closed resolved issue #${existingIssue.number}`);\n              }\n              return;\n            }\n\n            let results;\n            try {\n              results = JSON.parse(fs.readFileSync('frontend/npm-audit-results.json', 'utf8'));\n            } catch {\n              console.log('Could not read filtered audit results');\n              return;\n            }\n\n            const vulns = results.vulnerabilities || {};\n            const vulnCount = Object.keys(vulns).length;\n\n            if (vulnCount === 0) {\n              console.log('No vulnerabilities to report');\n              if (existingIssue) {\n                await github.rest.issues.createComment({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: existingIssue.number,\n                  body: 'All npm production vulnerabilities have been resolved. Closing automatically.'\n                });\n                await github.rest.issues.update({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: existingIssue.number,\n                  state: 'closed'\n                });\n                console.log(`Auto-closed resolved issue #${existingIssue.number}`);\n              }\n              return;\n            }\n\n            // Build vulnerability table\n            let table = '| Package | Severity | Via | Fix |\\n';\n            table += '|---------|----------|-----|-----|\\n';\n\n            for (const [name, info] of Object.entries(vulns)) {\n              const via = Array.isArray(info.via) ? info.via.map(v => typeof v === 'string' ? v : v.name).join(', ') : info.via;\n              table += `| ${name} | ${info.severity} | ${via} | ${info.fixAvailable ? 'Yes' : 'No'} |\\n`;\n            }\n\n            const title = `Security Alert: ${vulnCount} npm vulnerabilities found`;\n\n            const body = `## Automated Security Audit Results\n\n            The weekly security audit found vulnerabilities in npm dependencies.\n\n            ${table}\n\n            ### Recommended Actions\n\n            1. Review each vulnerability: \\`npm audit\\`\n            2. Auto-fix if possible: \\`npm audit fix\\`\n            3. Manual fix for breaking changes: \\`npm audit fix --force\\` (review changes!)\n\n            ---\n            *This issue was automatically created by the security audit workflow.*`;\n\n            if (existingIssue) {\n              await github.rest.issues.update({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: existingIssue.number,\n                body: body\n              });\n              console.log(`Updated existing issue #${existingIssue.number}`);\n            } else {\n              await github.rest.issues.create({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                title: title,\n                body: body,\n                labels: ['security', 'automated', 'dependencies']\n              });\n              console.log('Created new security issue');\n            }\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: Close stale issues\n\non:\n  schedule:\n    - cron: '0 0 * * *'  # Run daily at midnight UTC\n\npermissions:\n  issues: write\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v9\n        with:\n          stale-issue-message: 'This issue has been marked as stale due to inactivity. It will be closed in 7 days if there is no further activity.'\n          close-issue-message: 'Closed due to inactivity. Feel free to reopen if this is still relevant.'\n          days-before-stale: 21\n          days-before-close: 7\n          stale-issue-label: 'stale'\n          only-labels: 'feedback'\n"
  },
  {
    "path": ".github/workflows.disabled/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main, develop]\n  pull_request:\n    branches: [main, develop]\n\nenv:\n  PYTHON_VERSION: '3.11'\n  NODE_VERSION: '20'\n\njobs:\n  # ============================================================================\n  # Backend Jobs\n  # ============================================================================\n\n  backend-lint:\n    name: Backend Lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Install ruff\n        run: pip install ruff\n\n      - name: Run ruff check\n        run: ruff check backend/\n\n  backend-unit-tests:\n    name: Backend Unit Tests\n    runs-on: ubuntu-latest\n    needs: backend-lint\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Cache pip\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/pip\n          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}\n          restore-keys: |\n            ${{ runner.os }}-pip-\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n          pip install pytest-cov\n\n      - name: Run unit tests\n        run: |\n          cd backend\n          python -m pytest tests/unit/ -v --cov=app --cov-report=xml -m \"not slow\"\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v4\n        if: always()\n        with:\n          files: backend/coverage.xml\n          flags: backend-unit\n          fail_ci_if_error: false\n\n  backend-integration-tests:\n    name: Backend Integration Tests\n    runs-on: ubuntu-latest\n    needs: backend-unit-tests\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Cache pip\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/pip\n          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}\n          restore-keys: |\n            ${{ runner.os }}-pip-\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n          pip install pytest-cov\n\n      - name: Run integration tests\n        run: |\n          cd backend\n          python -m pytest tests/integration/ -v --cov=app --cov-report=xml\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v4\n        if: always()\n        with:\n          files: backend/coverage.xml\n          flags: backend-integration\n          fail_ci_if_error: false\n\n  # ============================================================================\n  # Frontend Jobs\n  # ============================================================================\n\n  frontend-lint:\n    name: Frontend Lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Run ESLint\n        working-directory: frontend\n        run: npm run lint\n\n  frontend-type-check:\n    name: Frontend Type Check\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Run TypeScript check\n        working-directory: frontend\n        run: npx tsc --noEmit\n\n  frontend-unit-tests:\n    name: Frontend Unit Tests\n    runs-on: ubuntu-latest\n    needs: [frontend-lint, frontend-type-check]\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Run tests with coverage\n        working-directory: frontend\n        run: npm run test:coverage\n\n      - name: Upload coverage\n        uses: codecov/codecov-action@v4\n        if: always()\n        with:\n          files: frontend/coverage/coverage-final.json\n          flags: frontend-unit\n          fail_ci_if_error: false\n\n  frontend-build:\n    name: Frontend Build\n    runs-on: ubuntu-latest\n    needs: frontend-unit-tests\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'npm'\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        working-directory: frontend\n        run: npm ci\n\n      - name: Build\n        working-directory: frontend\n        run: npm run build\n\n      - name: Upload build artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: frontend-build\n          path: static/\n\n  # ============================================================================\n  # E2E Tests\n  # ============================================================================\n\n  e2e-tests:\n    name: E2E Tests\n    runs-on: ubuntu-latest\n    needs: [backend-integration-tests, frontend-build]\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n\n      - name: Install backend dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n          pip install playwright\n          playwright install chromium --with-deps\n\n      - name: Download frontend build\n        uses: actions/download-artifact@v4\n        with:\n          name: frontend-build\n          path: static/\n\n      - name: Start backend server\n        run: |\n          python -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 &\n          sleep 15\n        env:\n          DEBUG: 'false'\n\n      - name: Run E2E tests\n        run: |\n          python tests/e2e_comprehensive_test.py || true\n          python tests/e2e_toggle_persistence_test.py\n\n      - name: Upload test screenshots\n        uses: actions/upload-artifact@v4\n        if: always()\n        with:\n          name: e2e-screenshots\n          path: /tmp/bambuddy_*.png\n          if-no-files-found: ignore\n\n  # ============================================================================\n  # Summary Job\n  # ============================================================================\n\n  ci-summary:\n    name: CI Summary\n    runs-on: ubuntu-latest\n    needs: [backend-integration-tests, frontend-build, e2e-tests]\n    if: always()\n    steps:\n      - name: Check results\n        run: |\n          echo \"Backend Integration Tests: ${{ needs.backend-integration-tests.result }}\"\n          echo \"Frontend Build: ${{ needs.frontend-build.result }}\"\n          echo \"E2E Tests: ${{ needs.e2e-tests.result }}\"\n\n          if [[ \"${{ needs.backend-integration-tests.result }}\" == \"failure\" ]] || \\\n             [[ \"${{ needs.frontend-build.result }}\" == \"failure\" ]]; then\n            echo \"CI failed!\"\n            exit 1\n          fi\n\n          echo \"CI passed!\"\n"
  },
  {
    "path": ".gitignore",
    "content": "# Claude\n.claude/\nCLAUDE.md\n\n# macOS\n.DS_Store\n**/.DS_Store\n**/._.DS_Store\n\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\nvenv/\n.venv/\nenv/\n.env\ndocker-compose.override.yml\n*.egg-info/\ndist/\nbuild/\n\n# Node\nfrontend/node_modules/\nfrontend/coverage/\nnpm-debug.log*\n\n# Database\n*.db\n*.db-journal\n*.db-wal\n*.db-shm\n\n# Archive files (user data)\narchive/\n\n# Firmware cache (downloaded firmware files)\nfirmware/\n\n# Virtual printer (auto-generated certs and uploads at repo root)\n/virtual_printer/\n\n# IDE\n.idea/\n.vscode/\n*.swp\n*.swo\n\n# Screenshots (development - root folder only)\n/screenshots/\n\n# Logs\n*.log\nlogs/\n*.log*\nbambutrack.log.*\nfirmware/\n\n# Node modules\nnode_modules/\n\ndata/\n\n# JWT secret file (should be in data dir, but protect project root too)\n.jwt_secret\n\n# SpoolBuddy SSH keys (generated at runtime for remote updates)\nspoolbuddy/ssh/\n\n# Security scan output\n*.sarif\n\ndebug_logs/\ndb_backup/\nsupport-packages/\nbackups/\nbin/\nadvertisements/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "# Pre-commit hooks for BamBuddy\n# Install with: pip install pre-commit && pre-commit install\n\nrepos:\n  # Ruff - Fast Python linter and formatter\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.14.11\n    hooks:\n      # Linter\n      - id: ruff\n        args: [--fix, --exit-non-zero-on-fix]\n        types_or: [python, pyi]\n      # Formatter\n      - id: ruff-format\n        types_or: [python, pyi]\n\n  # Standard pre-commit hooks\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n      - id: trailing-whitespace\n        exclude: ^static/\n      - id: end-of-file-fixer\n        exclude: ^static/\n      - id: check-yaml\n      - id: check-json\n        exclude: ^(static/|frontend/tsconfig\\.)\n      - id: check-added-large-files\n        args: ['--maxkb=1000']\n        exclude: ^static/assets/\n      - id: check-merge-conflict\n      - id: debug-statements\n      - id: detect-private-key\n\n  # Check for import shadowing (custom)\n  - repo: local\n    hooks:\n      - id: check-import-shadowing\n        name: Check for dangerous import shadowing\n        entry: python -m pytest backend/tests/unit/test_code_quality.py::TestImportShadowing -v --tb=short\n        language: system\n        pass_filenames: false\n        types: [python]\n        files: ^backend/app/\n      - id: frontend-typecheck\n        name: TypeScript type check\n        entry: bash -c 'cd frontend && npx tsc --noEmit'\n        language: system\n        pass_filenames: false\n        files: ^frontend/src/\n        types_or: [ts, tsx]\n"
  },
  {
    "path": ".trivyignore",
    "content": "# Dockerfile USER directive (DS-0002): Bambuddy runs as a single-host\n# Docker container where root is needed for device access and FFmpeg.\nDS-0002\n\n# util-linux hostname canonicalization (LOW, no fix available in Debian bookworm).\n# Affects mount, login, libuuid1, libsmartcols1, etc. — not exploitable in container context.\nCVE-2026-3184\n\n# libtiff denial-of-service bugs (pulled in by ffmpeg, not directly used).\n# No fix available in Debian bookworm.\nCVE-2025-61143\nCVE-2025-61144\nCVE-2025-61145\n\n# iptables --syn flag bypass (LOW, no fix available, not relevant — container doesn't use iptables).\nCVE-2012-2663\n\n# ffmpeg DVD subtitle parser heap OOB write (MEDIUM). Debian Security Tracker\n# marks it \"postponed\" for both bookworm and trixie; no upstream fix yet.\n# Not reachable in Bambuddy — ffmpeg here only ingests printer-camera RTSP\n# and MJPEG/H.264/H.265 streams, never DVD/VOB files with subtitle tracks.\nCVE-2026-6385\n\n# ffmpeg AV1 decoder OOB read → DoS (MEDIUM, \"minor issue\" per Debian).\n# Same \"postponed\" status in bookworm and trixie; no upstream fix yet.\n# Not reachable — Bambu printer cameras emit H.264/H.265/MJPEG, not AV1.\nCVE-2026-30997\n\n# openjpeg JPEG 2000 integer overflow (LOW). No Debian fix available.\n# libopenjp2-7 is pulled in transitively by ffmpeg but Bambuddy never\n# decodes JPEG 2000 files (printer thumbnails are PNG, camera is MJPEG/H.264).\nCVE-2026-6192\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to Bambuddy will be documented in this file.\n\n## [0.2.3.2] - 2020-04-22\n\n### Improved\n- **GCode Viewer Reshaped as an Archive Preview Tool** ([#963](https://github.com/maziggy/bambuddy/pull/963) follow-up) — PR #963 landed the embedded PrettyGCode viewer with a library file picker, a connected-printer selector with live WebSocket status, and auto-load of the currently-printing file. In practice those three didn't match Bambuddy's data model: the library file picker only listed `.gcode` files (Bambuddy stores `.gcode.3mf`), the printer selector wasn't useful when the real goal is previewing an existing archive, and the auto-load path had the same `.gcode`-filter gap as the picker. The viewer is now scoped to a single focused workflow — \"show me the G-code for this archive\" — reached from the Archives page 3D-preview button (menu item + the card-corner badge + list-row menu, all three paths navigate the same way). Entry URL is `/gcode-viewer?archive=<id>[&plate=<N>]`; the route falls through to the SPA catch-all so a full-page reload keeps the Bambuddy layout shell, with the iframe at `/gcode-viewer/?archive=<id>…` serving the raw viewer. Bed size is fetched from `GET /archives/{id}/capabilities.build_volume` (already parsing `printable_area` + `printable_height` from the 3MF's `Metadata/project_settings.config`) so any printer model renders the correct bed — 350×320×325 for H2D etc. — with no hardcoded per-model map to maintain. Multi-plate archives now surface a dedicated plate picker modal (`components/PlatePickerModal.tsx`) with thumbnails and object lists matching the existing Re-print modal's visual language; source-only 3MFs (no sliced gcode) show a `archives.platePicker.noGcode` toast instead of sending the user to an empty viewer. Behind the scenes: `GET /archives/{id}/gcode` accepts `?plate=N` and resolves the filename by integer-matching the suffix (zero-padded names like `Metadata/plate_01.gcode` now resolve as plate 1, fixing a class of picker-claimed-but-404 archives); `GET /archives/{id}/plates` gained a top-level `has_gcode: bool` flag so the frontend can suppress the picker when the archive is source-only; `printer_state_to_dict` now injects `name` and `model` into every WebSocket snapshot so consumers don't race a separate `/printers` fetch for proper labels. Removed from the viewer: printer selector + WS subscription, library file picker, `BAMBU_BED_SIZES` hardcoded map, auto-load-currently-printing, sidebar nav entry, 32 orphaned `gcodeViewer` locale keys, and the unreachable `ModelViewerModal` render paths on archive cards (the File Manager still uses `ModelViewerModal` for library file previews — scope preserved). Added test coverage: `?plate=N` happy path, zero-padded filename resolution, missing-plate 404, no-plate fallback to first, `?plate=0` 400 rejection, `has_gcode=true/false` branch, plus `PlatePickerModal.test.tsx` (6 tests covering render, plate-name label, onSelect payload, backdrop close, thumbnail fallback) and `printer_state_to_dict` name/model surfacing tests. A toast replaces the old silent empty viewer for source-only archives; reload stays in the Bambuddy layout; H2D previews no longer overflow the bed.\n>>>>>>> d4533c38 (  chore(deps): bump postcss to 8.5.12 to clear GHSA-qx2v-qp2m-jg93)\n\n### Improved\n- **Printer Card Shows Plate Name on Multi-Plate Prints** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — When two printers were running different plates of the same multi-plate 3MF, the Printers page cards displayed the same file name on both and gave no visual way to tell them apart. The Queue view already showed the plate name by querying the archive's plate list; the Printers page didn't have that linkage. The `GET /printers/{id}/status` endpoint now returns `current_archive_id` (resolved by matching the MQTT `subtask_id` against `PrintArchive.subtask_id`, the same bridge introduced in #972 for restart-resume) and `current_plate_id` (parsed from the MQTT `gcode_file` path by a new shared `parse_plate_id` helper that's also used by the WebSocket push path, so plate transitions within a running print reflect immediately instead of waiting 30 s for the next REST poll). The card fetches plate metadata via the same `api.getArchivePlates()` call the Queue page uses — shared React Query cache keeps it cheap across polls — and renders the actual plate name (or a \"Plate N\" fallback) only when the source 3MF is multi-plate, so single-plate prints stay noise-free. Falls back to the previous `plate_(\\d+).gcode` regex when there's no archive linkage (e.g. prints started directly from the printer LCD). Regression tests cover the plate-id extraction across Bambu Studio path shapes and the label-override precedence in `formatPrintName`. Thanks to @stringham for the follow-up and screenshot.\n- **Printer Card: Remove Redundant In-Widget \"Clear Plate & Start Next\" Button** — In expanded view, the \"Next in queue\" widget rendered its own `Clear Plate & Start Next` button inside a yellow-bordered card (`PrinterQueueWidget.tsx`) whenever the plate-clear gate was up and an auto-dispatch item was queued — on top of the card-level \"Mark plate as cleared\" button introduced by #939. Both POSTed to the exact same `/printers/{id}/clear-plate` endpoint with identical optimistic-update semantics, so in that one state combination users saw two visually distinct affordances doing the same thing. Removed the widget's button and its entire `needsClearPlate` render branch; the card-level button (which is unconditional when plate-clear is required, and therefore already handles the staged-only and empty-queue cases that the widget couldn't) is now the single entry point. The widget becomes a pure passive \"Next in queue\" preview linking to `/queue`. No backend change, no change to the plate-status pill placement inside the Status box (deliberately kept where it is), and no change to compact-view (Size S) behaviour — the `plateStatusPill` at `PrintersPage.tsx:2664/2671` and the icon-only round clear-plate button at `:2673` are untouched. Also dropped the now-dead `awaitingPlateClear` / `requirePlateClear` / `printerState` props from `PrinterQueueWidgetProps` and the matching call site at `PrintersPage.tsx:2810`, and the orphaned `queue.clearPlate` / `queue.plateReady` translations from all eight locale files (`queue.clearPlateSuccess` is retained — still used by the card-level button's success toast). The dedicated `PrinterQueueWidgetClearPlate.test.tsx` suite (654 lines) was removed since every test asserted the behaviour of the now-gone button; `PrinterQueueWidget.test.tsx` continues to cover the passive-link path. Thanks to @EdwardChamberlain for flagging the duplication in #1079.\n\n### Fixed\n- **Print Scheduler Reprints the Just-Finished Job When Queue Has One Item Left (H2D)** ([#1078](https://github.com/maziggy/bambuddy/issues/1078)) — On H2D, clearing the plate and starting the next (and only) queued item caused the printer to re-run the job it had just finished while the UI reported the queued one as started. With multiple items left the symptom was hidden by forward progress. Root cause: `_watchdog_print_start` in `print_scheduler.py` gives up at 45 s and reverts the queue item to `pending` if `gcode_state` hasn't flipped away from `pre_state`, on the assumption that a non-transitioning printer means the MQTT `project_file` publish was swallowed by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00) routinely keeps `gcode_state=FINISH` for 48–55 s after actually accepting the command before transitioning to `PREPARE` — logs from the reporter show the revert firing at +45 s and a legitimate `PRINT START detected` arriving just ~3 s later — so the watchdog reverted an item that the printer *had* already started physically printing. The physical print ran to completion and updated the linked archive (via `register_expected_print`), but the queue item was now `pending` again; on the next scheduler tick after the user cleared the plate, the same item was re-dispatched as if it had never run. With multiple items queued, item N+1 getting dispatched during the 45 s race window looked like forward progress to the user and masked the duplicate revert/re-dispatch of item N. Fixed in `_watchdog_print_start` by adding a second \"command landed\" signal: `subtask_id` changing past the pre-dispatch value. Bambuddy already mints a unique `submission_id` per `project_file` publish (capped at int32 post-#1042) and assigns it to `subtask_id` / `task_id` in the command payload; the printer echoes this back on the next `push_status` as soon as it starts processing — well before `gcode_state` transitions on slow-transition models. `_start_print` now captures `pre_subtask_id` alongside `pre_state` and passes both to the watchdog, which treats *either* a state change *or* a `subtask_id` advance as proof the command landed. Timeout raised 45 s → 90 s as belt-and-braces for printers that neither transition state nor echo `subtask_id` inside the polling window. None of the earlier exit paths are weakened — genuine half-broken sessions (state *and* `subtask_id` both unchanged across the full window) still revert, still force the MQTT reconnect, and are still recoverable without a power cycle. Added eight regression tests in `test_scheduler_watchdog.py` covering: pickup via state change, pickup via `subtask_id` change while state stays at `FINISH` (the exact #1078 case), revert when neither signal changes, default timeout of 90 s, `pre_subtask_id=None` fallback to state-only, `status.subtask_id=None` not mis-detected as a change, printer disconnect mid-watchdog (no DB write), and the `#967` race where the item already moved on (`completed`). No frontend or MQTT changes — purely tightens the \"did the printer accept?\" decision. Thanks to @VREmma for the clear reproduction and the full support bundle that made pinpointing the H2D state-lag behaviour possible.\n- **Printers-Page \"Clear Plate\" Button Takes 30–300+ s to Appear After Print Completes** ([#939](https://github.com/maziggy/bambuddy/pull/939) follow-up) — A trusted user reported that on every printer (A1, H2D, X1C), the \"Clear Plate & Start Next\" button didn't show for 60+ seconds after a print finished; refreshing didn't help; one H2D sat in the \"Finished\" state for 5 minutes without the button ever appearing. Root cause: PR #939 added the `awaiting_plate_clear` gate but stored it on `PrinterManager._awaiting_plate_clear` (a per-process set, persisted to `printers.awaiting_plate_clear` via #961), not on `PrinterState` — and `printer_state_to_dict()` in `printer_manager.py`, which builds every WebSocket `printer_status` payload, was never updated to emit it. Only the HTTP endpoint `GET /printers/{id}/status` (line 634) surfaced the flag. That left the frontend in a deadlock: when `print_complete` arrived over the WebSocket, `useWebSocket.ts` intentionally *didn't* invalidate `['printerStatus']` (avoiding the render-cascade freeze the comment at line 235 warns about), expecting the subsequent `printer_status` WS messages to \"naturally update the status\" — but those messages carried no `awaiting_plate_clear` field, so the merge at line 146 preserved the stale `false`. The only path that ever surfaced `true` was the 30 s HTTP fallback poll at `PrintersPage.tsx:1430`, and on a chatty printer each incoming WS tick's `setQueryData` bumped React Query's `dataUpdatedAt`, pushing the next fetch further out — which is why the delay varied from ~30 s to several minutes. The plate-status pill at `PrintersPage.tsx:1672-1675` rendered \"Plate Clear\" (the fallback label for falsy `awaiting_plate_clear`) during the entire stale window, compounding the confusion. Fixed by emitting `awaiting_plate_clear` from `printer_state_to_dict`: the function already has `printer_id`, so it reads `printer_manager.is_awaiting_plate_clear(printer_id)` directly and returns `False` when no id is passed (for the few callsites that don't have one). No frontend change needed — the existing WS merge path now carries the flag end-to-end, the \"Clear Plate\" button appears instantly on completion, and the queue-dispatch side of the gate (which already reads the in-memory set directly via `print_scheduler.py:1125`) is unaffected. Regression tests in `test_printer_manager.py` assert the WS dict always contains the key and that it surfaces `True` when the manager has the flag set for that printer_id. Affects every printer equally because the path is transport-agnostic — not an H2D- or A1-specific problem, just more visible on H2D because its longer finish sequence gave the poll slip more opportunities to miss.\n- **Printers-Page Search Turns Into a Password Field After Opening Change-Password Modal** — On the Printers page, clicking the key icon in the sidebar to open the Change Password modal caused the \"Search printers\" input to render as a password field (masked dots); closing the modal didn't restore it, requiring a full reload. Root cause: the Change Password modal has three `<input type=\"password\">` fields but no accompanying username input, so password-manager browser extensions (1Password, Bitwarden, Chrome/Safari built-in) scanned the current DOM for a matching username anchor and latched onto the nearest `type=\"text\"` input with no `name`/`autoComplete` — which happened to be the Printers-page search bar — and overrode its rendering. Fixed on two levels: (1) added a hidden `<input type=\"text\" name=\"username\" autoComplete=\"username\" value={user.username} readOnly hidden>` at the top of the Change Password modal so password managers have a proper anchor and stop hunting elsewhere — as a bonus, saved new passwords are now correctly keyed to the logged-in user; (2) hardened the Printers-page search input with `type=\"search\"`, `name=\"printer-search\"`, `autoComplete=\"off\"`, and `data-1p-ignore` / `data-lpignore=\"true\"` so any future heuristic-based autofill also skips it.\n- **AMS Slot Configure: Custom Cloud Preset Resolves to \"Generic\" in Slicer & Printer LCD** ([#1053](https://github.com/maziggy/bambuddy/issues/1053) follow-up) — After configuring any AMS slot (HT or regular) with a user custom Bambu Cloud preset built on top of a Bambu base profile (e.g. \"Sting3D ABS\" inheriting from \"Generic ABS @BBL H2D\"), OrcaSlicer's *Sync Filaments* continued to resolve the slot to \"Generic ABS\" and the custom preset never appeared on the printer's own LCD — independent of the earlier UI fix (commit `87a5aa36`) which only corrected Bambuddy's own modal. Root cause: when Bambu Cloud's `GET /cloud/settings/{setting_id}` returns a user preset with `filament_id: null` and `base_id: \"GFSB99_07\"` (cloud doesn't mint a distinct filament_id for presets that only override fields of a generic base), `ConfigureAmsSlotModal.tsx:382-384` fell back to `convertToTrayInfoIdx(base_id)` which strips the version suffix and the `S` prefix → `\"GFB99\"` — Generic ABS's filament_id. The printer accepted and reported back `GFB99`, so both the LCD and OrcaSlicer correctly resolved the slot to Generic ABS. The fallback was never right: the preceding default already set `tray_info_idx = convertToTrayInfoIdx(selectedPresetId)` which for any `PFUS*`/`PFSP*` setting_id returns the base setting_id itself (via the helper's `startsWith('PFUS')` branch added earlier), and the printer + both slicers round-trip that format unchanged — confirmed by existing backend integration tests (`test_configure_pfus_sent_directly`, `test_pfus_slicer_filament_used_directly`), by the print scheduler's slot-matching which already expects `P*` short-form IDs in the printer's reported `tray_info_idx` (`print_scheduler.py:910`), and by the inventory Assign Spool flow which has been sending `PFUS*` preset IDs to the printer for months. The buggy fallback *overwrote* the correct default with a generic mapping. Fixed by removing the base_id branch: when cloud detail carries a distinct `filament_id` we still prefer it, otherwise we keep the setting_id-derived default. BambuStudio Sync now resolves the custom preset cleanly; OrcaSlicer (whose user presets don't carry a `filament_id` field at all, only `inherits`) will continue to fall back to the inherited generic — that's an OrcaSlicer preset-format limitation, not something Bambuddy can fix on its side, and the behaviour is strictly not worse than before. Regression tests in `ConfigureAmsSlotModal.test.tsx` pin four paths: (1) cloud detail with `filament_id: null` → `tray_info_idx` is the `PFUS*` setting_id, (2) cloud detail with a concrete `filament_id` → that filament_id wins over the default, (3) GFS* Bambu presets skip the cloud-detail fetch entirely and still map to the short `GF*` filament_id, and (4) a 5xx / network error on the cloud-detail fetch degrades gracefully to the `PFUS*` default instead of aborting the configure flow. An end-to-end backend test (`test_configure_pfus_preserves_setting_id_pair`) locks in that both `tray_info_idx=PFUS…` and `setting_id=PFUS…` survive the HT-slot `POST /slots/{ams}/{tray}/configure` path untouched. Thanks to @mrnoisytiger for the detailed browser-console / network / backend-log diagnostic data that isolated the fallback path, and for sharing the OrcaSlicer preset JSON that showed the missing `filament_id` field.\n- **Single Malformed `rgba` Bricks the Entire Filaments Inventory Page** ([#1055](https://github.com/maziggy/bambuddy/issues/1055)) — A user's Filaments page went blank and \"Add Spool\" became a no-op with no visible error. The backend was returning HTTP 500 from `GET /api/v1/inventory/spools` with `fastapi.exceptions.ResponseValidationError: rgba → 'FFFFFFF' should match pattern '^[0-9A-Fa-f]{8}$'` — a single legacy spool row had a 7-char rgba (missing one trailing `F`) and Pydantic's strict pattern on `SpoolResponse` refused to serialize the whole list because of it. Root cause spans three layers: (1) `SpoolUpdate` had no rgba pattern constraint, so PATCH calls could plant malformed values straight into the DB (`SpoolCreate` did validate, but only on initial create); (2) the `ColorSection` hex input's onChange ternary `val.length <= 6 ? 'FF' : ''` silently emitted 7-char strings for 5-char or 7-char typed input (5 chars + `FF` alpha = 7 chars; 7 chars got no alpha appended at all), which then flowed to the unvalidated PATCH endpoint; (3) `SpoolResponse` inherited the same pattern as `SpoolCreate`, so any malformed row already in the DB exploded the entire list endpoint on serialize even though write-side validation was the right place for the check. Fixed on all three layers: `SpoolUpdate.rgba` now carries the same `^[0-9A-Fa-f]{8}$` pattern as `SpoolCreate`, so PATCH requests with malformed rgba are rejected with 422 at the boundary. The hex input always emits a fully-formed 8-char RRGGBBAA on every keystroke — 8-char paste passes through, 7-char drops the stray char, shorter input is right-padded with `'0'` and given FF alpha. `SpoolResponse.rgba` is now an unconstrained `Optional[str]`: the pattern belongs on request schemas where Pydantic can reject bad input, not on responses where it turns a single bad row into a total page failure. A legacy malformed row still appears in the UI (the color just renders as whatever browser default applies) but the user can see, edit, and delete it instead of having to hand-edit SQLite. Backend tests cover all three schema contracts (16 cases across `SpoolCreate` accept/reject, `SpoolUpdate` accept/reject, `SpoolResponse` lenient-tolerance on 7-char / null / garbage). Frontend tests cover the hex-input normalization for every input length 0–8 plus non-hex strip-and-pad. Thanks to @fdsghy4a for the end-to-end debugging and for locating the exact malformed row in their DB.\n- **Printer-Card \"Print\" Button Leaves Transient Copy in File Manager** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — The \"Print\" button on a printer card (and the equivalent drag-drop-onto-card flow) was silently uploading the chosen file into the Library file manager as a side effect before printing. Root cause is structural: the frontend opened `FileUploadModal` to persist the file as a `LibraryFile`, then `PrintModal` dispatched a library print through `POST /library/files/{id}/print`, which uses the LibraryFile as the source for both the archive copy and the FTP upload to the printer. When the dispatch finished, both the `LibraryFile` row and its disk file in `data/library/` were left behind, so every one-off Direct-Print accumulated an unwanted File Manager entry that the user had to find and delete manually. The other three print entry points are untouched: Archive \"Reprint\" never involved the library, and File Manager \"Print\" / Project Detail \"Print\" are paths where the user deliberately put the file in the library, so their entries are preserved. `POST /library/files/{id}/print` now accepts an optional `cleanup_library_after_dispatch` boolean. When true, `_run_print_library_file` stages the LibraryFile row for deletion in the same transaction as the archive insert (so a mid-flight FTP or `start_print` failure rolls back both at once, leaving no orphan), commits together, then unlinks the library disk file and thumbnail from disk after commit succeeds. External library files (`is_external = True`, pointing at user-managed folders outside Bambuddy's control) are never touched regardless of the flag. The Printers-page Direct-Print flow is the only caller that sends `true`; every other `api.printLibraryFile` call site leaves the flag unset so default-False preserves their library entries. Added two unit tests at the enqueue level (default-false + flag-propagates-true), two integration tests at the endpoint level (default-false + forwards-true + cleanup flag never leaks into the MQTT options dict), and two frontend tests on `PrintModal` guarding that `cleanupLibraryAfterDispatch` only forwards when explicitly set — so future File Manager / Project Detail entry points can't accidentally inherit the Direct-Print semantics. Thanks to @3823u44238 for flagging the surprising side effect.\n- **Direct / File Manager / Library Prints Still Unattributed to User** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — The 0.2.3.1 fix (commit `f03d0c4c`) plumbed the authenticated user from `POST /library/files/{id}/print` into the background-dispatch job object, but the dispatcher itself never read it back out: `_run_print_library_file` called `ArchiveService.archive_print()` without the `created_by_id` parameter and never called `printer_manager.set_current_print_user()`. Net effect: direct prints from the printer-card \"Print\" button, File Manager prints, and Library prints all continued to land archives with `created_by_id = NULL` (invisible to the per-user stats filter), and the post-print email notification had no user to target. The dispatcher now forwards `job.requested_by_user_id` to the archive at creation time and registers the current-print user after `start_print` succeeds — matching the reprint path's behaviour. Reprint-from-Archive attribution is a separate bug (the reprint reuses the source archive row as-is, so a NULL `created_by_id` stays NULL) and is tracked on #730. Thanks to @3823u44238 for the thorough end-to-end retest.\n- **Spoolman Iframe Blocked by CSP on HTTP Instances** ([#1054](https://github.com/maziggy/bambuddy/issues/1054)) — The Filament tab showed a blank page with a brief Spoolman flash on reload. Browser console reported `Content-Security-Policy: The page's settings blocked the loading of a resource (frame-src) at http://<host>:7912/spool because it violates the following directive: \"frame-src 'self' https:\"`. Root cause: commit `53a70e37` (#995) tightened the CSP to allow external sidebar iframes but only whitelisted `https:`, overlooking that self-hosted services on LANs — Spoolman, OctoPrint, etc. — almost always run over plain HTTP. The `frame-src` directive now allows `http:` as well (`frame-src 'self' http: https:`), matching the `connect-src 'self' ws: wss:` pattern already used for WebSockets. `frame-ancestors 'none'` still prevents Bambuddy itself from being framed cross-origin. Thanks to @saint-hh for reporting.\n- **AMS-HT: Custom Filament Preset Reverts to \"Generic\" in UI After Configure** ([#1053](https://github.com/maziggy/bambuddy/issues/1053)) — After configuring an AMS-HT slot (HT-A/HT-B) with a custom Bambu Cloud preset (e.g. \"Devil Design PLA Basic\"), the slot card and Configure modal kept showing \"Generic PLA\" even though the `ams_filament_setting` command succeeded and BambuStudio / the printer's LCD both rendered the correct custom preset. Root cause: the `GET /api/v1/printers/{id}/slot-presets` endpoint keyed its response dict by `ams_id * 4 + tray_id`, which collapses cleanly to the same integer the frontend uses for regular AMS slots (0 through 15) but produces `128 * 4 + 0 = 512` for HT-A — a key nothing looks up. The frontend's PrintersPage HT render path calls `getGlobalTrayId(ams.id, …, false)` which returns the ams_id itself (`128` for HT-A), and SpoolBuddy's AMS page used a third, unrelated formula (`(amsId - 128) * 4 + trayId + 64 = 64`). All three agreed for regular AMS so the mismatch only surfaced on HT, where the saved preset name never reached the UI and the render fell through to `tray.tray_type` → rendered as \"Generic PLA\". Backend now keys the response via a `_slot_preset_key` helper that mirrors frontend `getGlobalTrayId` (HT → `ams_id`, regular/external → `ams_id * 4 + tray_id`), and SpoolBuddyAmsPage uses the shared `getGlobalTrayId` helper instead of its home-grown formula. Regression test covers the key scheme for regular, HT, and external slots. Thanks to @mrnoisytiger for the detailed reproduction.\n- **AMS: Configure / Assign Spool Hidden on Reset Slots, and Assign Spool Missing Matching-Material Inventory** ([#1047](https://github.com/maziggy/bambuddy/issues/1047)) — Two separate symptoms from the same report. (1) After resetting an AMS slot from the printer UI, the Bambuddy printer card showed \"Empty Slot\" with no Configure or Assign Spool actions on hover, while the same slot in SpoolBuddy's AMS page still let the user re-configure it. Root cause: commit `c9efa4b8` (#784) added a `tray?.state === 10` gate to the `EmptySlotHoverCard` actions, intended to show the buttons only when a spool was physically present but not loaded (state=10) and hide them on truly empty slots (state=9). In practice, firmware often reports `state=9` (or no `state` field at all) after a user-initiated reset — even when a spool is still physically in the slot — so the actions disappeared exactly when the user needed them. The gate is redundant anyway (`EmptySlotHoverCard` is only rendered when the slot has no `tray_type`, so it's definitionally empty from Bambuddy's perspective), and configuring an empty slot is a valid \"tell the printer what will be loaded here\" operation. The gate is now removed at both the standard-AMS and AMS-HT render paths. (2) After configuring a slot with a Generic profile (e.g. \"Devil Design PLA Basic Red\"), the Assign Spool modal didn't list the matching inventory spool unless the user enabled the \"Show all spools\" toggle. Root cause: the filter at `AssignSpoolModal.tsx:144` required `normalizeValue(spool.slicer_filament_name) === normalizeValue(trayInfo.profile)` — manually-added inventory spools typically don't have `slicer_filament_name` populated, so they failed the exact-profile check even when the material matched. The filter now prefers an exact slicer-profile match when both sides advertise one, and falls back to partial material match in either direction (so e.g. a spool with `material=\"PLA\"` is selectable for a slot reporting `\"PLA Basic\"`) when profile info is missing. (3) Once the matching spool was assignable, a \"profile mismatch\" confirmation dialog still warned on every assignment because Bambu Studio / OrcaSlicer slicer-profile names carry a printer/nozzle/variant qualifier after `@` (e.g. `\"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)\"`) while the tray stores only the bare base name (`\"Devil Design PLA Basic\"`), and `checkProfileMatch` compared the full strings. Both the filter and the mismatch check now strip the `@…` qualifier before comparing, so identical base profiles are treated as a match. Regression test covers a spool with no slicer profile being surfaced for a slot whose profile + material are both set. Thanks to @TravisWilder for the report.\n\n\n## [0.2.3.1] - 2026-04-20\n\n### Fixed\n- **⚠️ Bed-Jog \"Home Z\" Could Crash the Bed Into the Toolhead** ([#1052](https://github.com/maziggy/bambuddy/issues/1052)) — **Critical safety fix.** On H2C (and by extension any Bambu printer where Z-home moves the bed UP toward an endstop — H2D, H2S, and X1 family all share this kinematics) the bed-jog modal's \"Home Z\" button sent a raw `G28 Z` over the `gcode_line` MQTT command. Bare `G28 Z` skips the toolhead-park step that a full `G28` runs first, so the bed raised without stopping at a safe height — in the reporter's case the toolhead happened to be parked on the purge chute and no damage was caused, but hitting the button with a toolhead anywhere else would have driven the bed into it at full Z speed. Root cause was the `/api/v1/printers/{id}/home-axes` endpoint's per-axis gcode mapping (`\"z\" → \"G28 Z\"`, `\"xy\" → \"G28 X Y\"`, `\"all\" → \"G28\"`). The endpoint now ignores the `axes` argument entirely and always sends a bare `G28`, which Bambu firmware expands into the safe multi-step sequence (park toolhead → home XY → home Z). The MQTT client helper `BambuClient.home_axes()` has the same change. The bed-jog modal is retitled \"Auto Home\" and its copy now says \"parks the toolhead, then homes X, Y, and Z\" so users aren't surprised when X/Y motion happens first. After a successful Auto Home click, the modal no longer re-prompts on the next jog in the same session — the \"not homed\" warning is gated on a session-scoped acknowledgement flag that was only being set by \"Move anyway\" and now also fires on successful Auto Home. Regression test covers all three axes arguments producing the same bare `G28`. Thanks to @mikefromdot for catching this with an undamaged retest.\n- **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-preview thumbnail inside the Skip Objects modal opened a lightbox that showed a broken-image icon instead of the full-size plate preview. The thumbnail `<img>` wrapped its `src` with `withStreamToken()` (which appends the short-lived camera-stream token to `/api/v1/` URLs that `<img>` tags can't attach an `Authorization` header to), but the enlarged lightbox `<img>` used a bare `${status.cover_url}?view=top` so the browser's unauthenticated request was rejected by the backend. Both images now go through `withStreamToken()`. Thanks to @elit3ge for the report and screenshot.\n- **P1S Print Dispatches Stuck at IDLE Due to task_id Int32 Overflow** ([#1042](https://github.com/maziggy/bambuddy/issues/1042)) — Since the #1011 fix switched `project_id` / `subtask_id` / `task_id` from hardcoded `\"0\"` to `str(int(time.time() * 1000))`, each submission sent a 13-digit epoch-millisecond value (~1.7×10¹²). P1S firmware (observed on 01.10.00.00) clamps oversized task identity fields to signed int32 max (`2147483647`), so every dispatch looked identical from the printer's perspective — it treated a fresh print as a continuation of the prior FAILED job, returned `result: success` for `project_file` (command accepted), but then sat at `gcode_state: IDLE` with an empty `gcode_file` instead of transitioning to `PREPARE`/`RUNNING`. Thanks to @EdwardChamberlain for pinpointing the exact line and suggesting the mod fix. The three identity fields are now set to `str(int(time.time() * 1000) % 2_147_483_647 or 1)`: modulo keeps values inside the signed-int31 window with a ~24-day uniqueness cycle (more than enough for reprint deduplication), and `or 1` guards against the astronomically unlikely zero case (the printer rejects `task_id=0`). Regression test `test_submission_id_fits_signed_int32` asserts all three IDs are `< 2**31`. Two of @EdwardChamberlain's other suggestions — resolving `bed_type` from the sliced 3MF's per-plate JSON instead of hardcoding `\"auto\"`, and gating dispatch success on an actual state transition to `PREPARE`/`RUNNING` rather than on `project_file`'s `result: success` — are larger changes tracked separately.\n- **FTP Download Zombie-Thread Race on Slow WiFi** ([#1014](https://github.com/maziggy/bambuddy/issues/1014)) — Users on 2.4 GHz WiFi with heavy neighborhood interference saw \"Successfully downloaded\" log lines for queued prints that Bambuddy nonetheless reported as failed, and the slicer file landed in `/app/data/archives/temp/` with the File Manager unable to find it. Root cause: `download_file_async` wrapped the blocking FTP `RETR` in `asyncio.wait_for` with a 30–60 s timeout (user-configurable via `ftp_timeout`), but the wrapped thread couldn't be cancelled. On a slow link the download would overshoot the timeout by 15–30 s, at which point `_run()` waited a hard-coded 0.5 s for the zombie to finish, gave up, and returned failure — which triggered `with_ftp_retry` attempt 2, whose `_download` spawned a brand-new FTP session that contended with attempt 1's still-running transfer. Attempt 1's zombie eventually completed and wrote the file to disk, but by then attempt 2 (and 3, 4) had long since run out their own timeouts with their own fresh `completion` dicts and reported failure; the archive pipeline saw only the final `None` from `with_ftp_retry` and created a fallback archive row with no 3MF data, which is why Skip-Object couldn't find the plate's objects even though the 3MF was on disk. Two fixes: the 0.5 s post-timeout sleep is replaced with a `threading.Event` the worker sets in its `finally` block, and `_run()` waits for that event with a bounded grace of `max(min(ftp_timeout, 30), 0.5)` s — covering the slow-WiFi overshoot case without extending a genuinely stuck connection indefinitely. The log line now includes the grace window (`timed out after Xs (plus Ys grace)`). Regression test `test_download_file_async_timeout_waits_for_slow_zombie` simulates a 1.5 s zombie with a 1.0 s wait_for timeout; old 0.5 s sleep would give up, new 1.0 s grace salvages. The existing `test_download_file_async_timeout_no_salvage_when_incomplete` still passes — a thread that never completes within the grace window still returns failure. Thanks to @heffe2001 for the detailed reproduction and support logs.\n- **Obico: Cold-Start Capture Timeout Sticks in Status Banner** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — On the very first detection poll after a restart, the initial RTSP snapshot capture occasionally exceeded the 20 s `SNAPSHOT_CAPTURE_TIMEOUT` (the first keyframe from the printer's camera can take a while on a cold RTSP connection). Subsequent polls every ~8 s recovered and captured in ~1.2 s, but the red `× Failed to capture snapshot for printer N` banner in Settings → Failure Detection → Status stayed up forever because `ObicoDetectionService._last_error` was written on failure and never cleared on the next successful poll. The successful branch in `_check_printer` now clears `_last_error` to `None` once a capture + ML call + classification complete, so the banner reflects only errors from recent cycles. Configuration-level errors (missing `external_url`, missing `ml_url`) still persist because they return before the clearing line — users still see them until they fix the setting. Regression test covers: seed `_last_error`, run one successful `_check_printer`, assert `_last_error is None`. Thanks to @fblix for the reproduction and screenshot.\n- **Printer Card Controls Row Overflows in Chrome** — At Medium card size on a wide viewport, the printer-card controls row (fan badges, airduct mode, print speed, bed jog, then Stop / Pause on the right) visibly overlapped in Chrome while rendering fine in Firefox and Safari. The controls-row layout had a `max-[550px]:flex-wrap` rule on the left badge group that only fires below 550 **viewport** pixels, so on a wide viewport with a narrow card the left group never wrapped — and since its badges don't truncate, Chrome painted the overflowing speed/bed-jog badges on top of the right-pinned Stop/Pause buttons. German locales made it obvious (\"Pausieren\" is 9 characters). The left group now uses unconditional `flex-wrap`, so when badges don't all fit on one line they wrap inside the left cell instead of colliding with the right cell; the parent row also wraps `gap-y` so Stop/Pause drops to a new line in the worst case. Pre-existing (commit `4ff3e2a6`, Feb 2026), surfaced while testing #939.\n- **MQTT Smart Plug Subscription Lost After Every Restart** ([#1010](https://github.com/maziggy/bambuddy/issues/1010)) — Users integrating a Shelly (or any other) plug through an external MQTT broker (e.g. ioBroker, Zigbee2MQTT, Home Assistant's MQTT broker) saw the plug's power / state / energy readings go dark after every Bambuddy restart, and the only fix was to open Settings → Smart Plugs, rename the topic to a dummy value, save, rename it back and save again. Root cause: the startup restore path in `main.py` (~line 4120) still used the legacy single-topic model (`mqtt_topic` plus `*_path` kwargs), while the Settings UI save path had been upgraded to the newer per-type model (`mqtt_power_topic` / `mqtt_energy_topic` / `mqtt_state_topic` each with their own paths, multipliers and `mqtt_state_on_value`). Plugs configured entirely with the new per-type fields got skipped at startup because the `if plug.mqtt_topic:` guard short-circuited — which is exactly what a Shelly-via-ioBroker setup looks like, since those publish power and state on separate topics. The \"rename, save, rename back\" workaround triggered the update endpoint, which was using the correct per-type code and re-established the subscription. Fix: extracted the topic-resolution + `service.subscribe()` call into a single `subscribe_plug_to_mqtt(service, plug)` helper in `backend/app/services/mqtt_smart_plug.py` that preserves legacy fallback, and routed the startup restore, create, and update routes all through it so future schema changes can't cause the three paths to drift again. Regression tests cover: per-type topics restored without a legacy topic set, legacy single-topic backward compat, per-type multipliers overriding legacy, per-type winning when both are set, the empty-config skip case, and topic-list de-duplication. Thanks to @saint-hh for the clear repro steps.\n- **Large 3MF Uploads Archived as Corrupted ZIPs** ([#1032](https://github.com/maziggy/bambuddy/issues/1032)) — On bare-metal Raspberry Pi installs (armv7l / Python 3.11 / Bookworm), 3MF files larger than a few MB arrived complete via the virtual-printer FTP server but the copy into `data/archives/` ended up not being a valid ZIP. The archive row was still written, the printer card looked fine, and the problem only surfaced later when opening the archive in the UI, where `GET /archives/{id}/plates` logged `Failed to parse plates from archive N: File is not a zip file` and the thumbnail / plate / filament panels came up blank. Two things conspired: `shutil.copy2` takes the Linux `sendfile()` fast path on Python ≥ 3.8, and a partial-return from that syscall silently truncated the destination for the upload sizes users hit; and `ThreeMFParser.parse()` had a bare `except: pass` around its `zipfile.ZipFile` open, so the archive pipeline kept going with empty metadata and left the bad file on disk. The copy is now an explicit chunked read/write with `fsync()` — no sendfile involved — with a post-condition `zipfile.is_zipfile()` check that refuses to create the archive row (and cleans up the archive directory) when the source was a valid ZIP and the destination isn't, logging both sizes at `ERROR`. The parser's silent catch now logs at `WARNING` so corrupted 3MFs are visible in support bundles instead of disappearing into empty metadata. Regression tests cover small / multi-chunk copies, ZIP roundtrips, the post-copy `is_zipfile` sentinel on a truncated file, and the new parser WARNING. Thanks to @saint-hh for the detailed diagnosis.\n- **Thumbnails Blank Until Reload After Sign-In** — On auth-enabled instances, signing out and back in left the File Manager (and occasionally the Archives page) full of broken thumbnails until the page was manually reloaded. Thumbnail URLs are gated by a short-lived camera-stream token that `<img>` tags can't send via `Authorization` headers, so the token is appended as `?token=…` at render time. Two race conditions conspired to break this: (1) the token query was keyed only on `['camera-stream-token']` and fired while the user was still on the login page, 401'd, and stayed cached — after sign-in nothing invalidated it; (2) when the token did eventually arrive, the global variable holding it was not reactive, so any File Manager / Archives page that had already rendered kept serving image URLs with no token. The token query now includes the user id in its key and is gated on `!!user`, so a new login always triggers a fresh fetch; and when the token transitions from null to a value, `useStreamTokenSync` walks the DOM once and updates `src` on every already-rendered `<img>`/`<video>` pointing at `/api/v1/` without the current token, reloading them in place.\n- **P2S Firmware Check Shows Stale \"Latest\" Version** ([#1030](https://github.com/maziggy/bambuddy/issues/1030)) — On P2S (and X2D) the Firmware Info modal reported `01.01.01.00` as the newest available release even though `01.02.00.00` had shipped on the Bambu Lab wiki weeks earlier, so the \"update available\" badge never appeared. Two silent regex mismatches in the wiki scraper caused `_fetch_all_versions_from_wiki()` to return an empty list: (1) the section-heading anchor parser required a dash between the version bytes and the release date (`id=\"h-01020000-20260409\"`), but P2S and X2D publish anchors without the dash (`id=\"h-0102000020260409\"`); (2) the text-based fallback only accepted ASCII parens around the date, while P2S, X2D, A1 and A1-mini headings render dates in full-width `（YYYYMMDD）` (U+FF08/U+FF09). When both paths failed, the code silently fell back to the Bambu Lab download page, which still lagged at `01.01.01.00`. The anchor regex now accepts an optional dash and the fallback accepts both paren styles; added regression tests for the no-dash anchor and full-width paren shapes. Thanks to @Minebuddy for reporting.\n- **Library File Print-Usage Tracking** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — `LibraryFile.print_count` and `last_printed_at` are now updated on every successful queued print completion. Previously both fields were defined on the model and displayed in the File Manager, but nothing ever wrote to them — every file in every library showed as never printed. Now counts increment cumulatively and `last_printed_at` stamps the completion timestamp (UTC). Failed, cancelled and user-aborted prints are intentionally excluded, so the fields represent \"successful usage\" rather than \"attempted usage.\" This unblocks sorting the File Manager by last-printed date and is a prerequisite for the scheduled-purge feature requested in #1008. Thanks to @cadtoolbox for the report.\n\n### Improved\n- **Color Catalog Default Filter Set to \"All Manufacturers\"** ([#1039](https://github.com/maziggy/bambuddy/issues/1039)) — Settings → Color Catalog opened with the manufacturer dropdown pre-filtered to *Bambu Lab*, so users searching for a third-party color had to change the dropdown to *All Manufacturers* on every visit. The page now defaults to *All Manufacturers* and lets you narrow down from there. Thanks to @VID-PRO for the suggestion.\n- **File Manager: Collapse Folders by Default** ([#996](https://github.com/maziggy/bambuddy/issues/996)) — Added a **Collapse** toggle next to **Wrap** in the File Manager sidebar header. When enabled, the folder tree opens with only top-level folders visible on every page load; disabling it restores the previous fully-expanded default. Toggling the preference also immediately re-collapses/re-expands the current tree — no reload required. Persisted to localStorage under `library-collapse-folders`, matching the existing `library-*` preference pattern. Thanks to @AshieTashi for the request.\n\n### Changed\n- **Docker runtime image on Debian Trixie** — The production Docker image now builds on `python:3.13-slim-trixie` instead of the Bookworm-based `python:3.13-slim`. Picks up ffmpeg 5 → 7 (HEVC/AV1 improvements for camera capture), OpenSSL 3.0 → 3.3, and two more years of APT package freshness. Frontend-builder stays on Bookworm until the Node.js image team publishes Trixie variants — users never see that stage.\n\n## [0.2.3] - 2026-04-19\n\n### New Features\n- **Move Build Plate from Printer Card** ([#791](https://github.com/maziggy/bambuddy/issues/791)) — The printer card controls row now has a Z-jog badge between the speed control and the stop/pause buttons. Click the up/down arrows to move the build plate; click the middle label to switch the step size (1 / 10 / 50 mm). When the printer is not homed (typical right after a print finishes), the first jog opens a Bambu Studio-style warning modal with **Home Z**, **Move anyway** (bypasses soft endstops for this move), or **Cancel**. After the first \"Move anyway\" in a session, subsequent jogs skip the dialog. Disabled while a print is running. Backed by new `POST /printers/{id}/bed-jog` and `POST /printers/{id}/home-axes` endpoints, both gated behind `printers:control`. Thanks to @cadtoolbox for the request.\n- **Printer Card Status Badges & Quick Controls** — The Printers page printer card now exposes new at-a-glance controls inspired by the Home Assistant Bambu Lab integration:\n  - **Enclosure Door badge** in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family — `home_flag` bit 23 on X1/X1C/X1E and the top-level `stat` hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).\n  - **Airduct Mode badge** beside the print speed control (Snowflake/Flame icons, sky for Cooling and orange for Heating). One-click dropdown switches the printer between cooling and heating via the existing `set_airduct` MQTT command. Gated to P2S/H2D/H2C/H2S.\n  - **Force Refresh** menu entry in the printer card kebab menu (RotateCw icon) that re-requests a full `pushall` MQTT status report from the printer without forcing a reconnect.\n- **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Snapshots are captured locally with a 20 s timeout we control and stashed under a one-shot 32-byte nonce; the ML API fetches them via an unauthenticated `/api/v1/obico/cached-frame/{nonce}` URL that sidesteps Obico's hardcoded 5 s read timeout.\n\n### Improved\n- **Firmware Update Modal Shows All Announced Versions** ([#568](https://github.com/maziggy/bambuddy/issues/568)) — The firmware update dialog now lists every version announced on Bambu Lab's wiki release history, not just the single newest one. Each row shows whether an offline firmware file is actually available for that version — rows marked **Usable** (green) can be installed, rows marked **Unavailable** (gray) are announced but have no downloadable package yet (common for hot-fix releases like `01.01.03.00` which Bambu only ships as OTA). The currently installed version is highlighted with a blue **Installed** badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g. `id=\"h-01030000-20260303\"`) so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request.\n- **Spoolbuddy Device Controls in Settings** ([#962](https://github.com/maziggy/bambuddy/issues/962)) — Each Spoolbuddy device card in Settings → Spoolbuddy now exposes five one-click actions alongside the existing Unregister button: Update (trigger daemon software update), Restart Browser (kiosk UI), Restart Daemon, Reboot (device), and Shutdown. Each action shows a confirmation dialog before queueing the command; buttons are disabled when the device is offline. Uses the existing `/spoolbuddy/devices/{id}/update` and `/spoolbuddy/devices/{id}/system/command` endpoints — no new backend work needed. Thanks to @TravisWilder for the request.\n- **Support Bundle Covers All Settings & SpoolBuddy** — The support bundle / bug-report payload now dumps every row in the `Settings` table instead of filtering by a hard-coded allowlist: sensitive keys (tokens, passwords, URLs, paths, emails, etc.) have their values replaced with `[REDACTED]` but the key itself is kept, so new config flags automatically show up in future bundles without a code change. Also adds an `integrations.spoolbuddy` section listing registered SpoolBuddy devices (firmware version, NFC/scale hardware, calibration, online state, uptime) — anonymized, no hostnames/IPs/device IDs.\n- **Settings Search Finds More Cards** — The cross-tab search field at the top of Settings now finds Sidebar Links, Spoolman, Spool Catalog, Color Catalog, all four Failure Detection sections, Advanced Email Authentication, SMTP Test, Authenticator App (TOTP), Email OTP, 2FA Linked Accounts, Single Sign-On (OIDC), LDAP Server Configuration, and the four Backup sub-cards (GitHub, History, Local, Scheduled). Powered by a new module-level registry (`frontend/src/lib/settingsSearch.ts`) so future settings register themselves next to their component instead of being forgotten in a central array.\n\n### Changed\n- **Plate-Clear Confirmation Disabled by Default** — New installs ship with Settings → Workflow → \"Require Plate-Clear Confirmation\" off. Multiple new users reported queued prints appearing to not start because the prompt was waiting for acknowledgement; opt in from Workflow if you want the confirmation gate.\n\n### Security\n- **Dependency Updates for Published Advisories** — Bumped two dependencies flagged by vulnerability scanners. `python-multipart` 0.0.22 → 0.0.26 closes CVE-2026-40347 (GHSA-mj87-hwqh-73pj), a denial-of-service triggered by large preamble or epilogue data around a multipart boundary — the 0.0.26 release now skips the preamble before the first boundary and silently discards the epilogue after the closing one. Bambuddy uses `python-multipart` transitively through FastAPI/Starlette for form and file-upload parsing, so any authenticated endpoint accepting `multipart/form-data` (e.g. backup restore, project thumbnail upload) was exposed. `dompurify` 3.3.3 → 3.4.0 picks up the fix for GHSA-39q2-94rc-95cp (the function-form `ADD_TAGS` could bypass `FORBID_TAGS`); Bambuddy's two call sites (`ProjectDetailPage`, `ProjectPageModal`) only use array-form `ALLOWED_TAGS`/`ALLOWED_ATTR`, so the specific bypass was not reachable, but the bump still hardens the sanitizer against future misconfiguration and clears the audit warning.\n\n### Fixed\n- **Virtual Printer \"Synchronizing device information\" Timeout with OrcaSlicer on Linux** ([#927](https://github.com/maziggy/bambuddy/issues/927)) — Follow-up to the b069b521 serial-adaptation fix. OrcaSlicer's Linux builds publish MQTT payloads with the C-string null terminator included in the length (same pattern as [paho.mqtt.c #1198](https://github.com/eclipse-paho/paho.mqtt.c/issues/1198)), so every decoded message arrived as `{…}\\x00`. The virtual printer's strict `json.loads()` raised `JSONDecodeError: Extra data` and the handler silently returned — no pushall, get_version, or project_file was ever answered, so the slicer hit its 60 s sync timeout and reconnected in a loop. Real Bambu firmware's mosquitto passed the trailing byte through, which is why direct LAN connections worked, and why print_queue mode was the only affected path (proxy mode tunnels MQTT to the real printer instead of running the VP broker). The handler now strips trailing `\\x00`/whitespace before parsing and logs the raw payload on any remaining decode failure so future silent variants are visible in support bundles. Thanks to @EdwardChamberlain for the debug-enabled support log that made the null byte visible in the raw bytes.\n- **SpoolBuddy Kiosk Unusable After Full-Mode Install** — A bundled Bambuddy + SpoolBuddy install via `spoolbuddy/install/install.sh --mode full` produced an unusable kiosk on first boot: Chromium raced ahead of uvicorn and showed \"can't connect to localhost\"; after a manual reload the kiosk URL `/spoolbuddy?token=…` was hijacked by Bambuddy's first-run wizard (`AuthContext` force-redirects to `/setup` whenever `requires_setup=true`, regardless of the target path); the wizard asks for admin credentials, but a touch-only Pi has no on-screen keyboard; if the user skipped auth the browser landed at `/` instead of the kiosk, and if they tried to enable auth they were stranded. Standalone mode was unaffected because it runs against an already-configured remote Bambuddy. Fixed in three parts: (a) new `backend/app/cli.py` with a `kiosk-bootstrap` subcommand that in a single DB transaction creates a scoped API key (`can_read_status=True`, `can_queue=False`, `can_control_printer=False`) and upserts `setup_completed=true`, so the first-run wizard never triggers and the kiosk URL loads the SpoolBuddy page directly; users can still enable authentication later from the admin UI and the pre-provisioned key keeps working. (b) `install.sh` full-mode now runs the CLI as the bambuddy service user immediately after `create_bambuddy_service` and `sed`-replaces the `CHANGE_ME_AFTER_SETUP` placeholder in `spoolbuddy/.env`. (c) The generated `spoolbuddy-kiosk-launch` now polls `${backend_url}/health` with a 60 s timeout before exec'ing Chromium, so cold boots wait for uvicorn instead of flashing the connection-refused error. The CLI is idempotent with `--force` for re-installs.\n- **Bambu Lab X2D Support** ([#988](https://github.com/maziggy/bambuddy/issues/988)) — Added X2D to the Add Printer and Edit Printer model dropdowns (both were missing the new model, so manual printer setup had no X2D option — auto-discovery via SSDP was unaffected). The newly released X2D (dual-nozzle, enclosed, hardened steel rod gantry, AMS 2 Pro compatible) identifies itself as internal model code `N6` via SSDP/MQTT, and serials begin with `20P9`. Because neither the code nor the prefix existed in any of Bambuddy's model tables, multiple paths silently fell back to wrong defaults: the camera service routed to the chamber-image protocol on port 6000 (which the X2D doesn't speak) instead of RTSP on port 322 — the reporter saw `Chamber image: data is not a valid JPEG` spam and no stream; the K-profile edit/delete path conditioned its in-place `cali_idx` write on the H2D serial prefix `094` and would therefore have treated X2D as a single-nozzle printer even though its dual-extruder layout matches H2D; the firmware-update check logged `Unknown printer model: N6`; and the virtual-printer model registry had no way to emulate X2D. Added the `N6 → X2D` mapping across every registry (`PRINTER_MODEL_ID_MAP`, `PRINTER_MODEL_MAP`, `ETHERNET_MODELS`, `STEEL_ROD_MODELS`, `CHAMBER_TEMP_SUPPORTED_MODELS`, firmware-check API keys and wiki path, virtual-printer SSDP product names and serial prefix, DB migration `vp_model_fixes`), extended `supports_rtsp()` to match `X2` display names and the `N6` internal code (camera now goes to port 322), expanded the dual-nozzle serial prefix check in `kprofiles.py` and the K-profile delete command in `bambu_mqtt.py` to also accept `20P9` so the H2D-style `cali_idx` in-place edit path runs on X2D, added X2D to the `is_h2d` model-family gate that selects the integer-format `timelapse`/`bed_leveling`/`flow_cali`/`vibration_cali`/`layer_inspect` fields in the MQTT print command, and added X2D to the frontend's door-badge and airduct-mode whitelists, `mapModelCode` lookups on both the Printers page and Spoolbuddy AMS page, and the MaintenancePage wiki-URL resolver (X2D inherits P2S's steel-rod lubrication, belt-tension, nozzle cold-pull and PTFE wiki pages, since its hardware is closer to P2S than to H2). Credit to @krautech for the report and the debug bundle, and to @legend813 for the initial PR (#989) that seeded most of the registry changes — the classification was corrected (X2D uses hardened steel rods like P2S, not carbon rods) and the dual-nozzle/K-profile gaps were added on top.\n- **Print Speed Icon Not Updating Live When Changed on Printer** ([#993](https://github.com/maziggy/bambuddy/issues/993)) — Changing the print speed mode from the printer's own panel (instead of from Bambuddy) did not update the speed icon on the Printers page card; the new value only appeared after a full page reload. The MQTT parser was already tracking `spd_lvl` and updating `state.speed_level` correctly, but the WebSocket serializer (`printer_state_to_dict`) was missing the field — so live status pushes never carried `speed_level`, and the frontend's merge-over-old-cache update left the icon stuck on its previous value. The REST `/status` endpoint used on initial page load already included it, which is why reloads worked. Added `speed_level` to the WebSocket payload. Thanks to @chesterakl for reporting.\n- **Camera Popup Shows \"Valid camera stream token required\" With Auth Enabled** ([#979](https://github.com/maziggy/bambuddy/issues/979)) — When Camera View Mode was set to \"Window\" and authentication was enabled, clicking the camera button opened a popup that immediately failed with `\"Valid camera stream token required\"`, while the embedded overlay kept working. Two root causes: (1) `window.open(...)` passed `noopener` in the popup features, which severed the opener link and prevented the browser from copying sessionStorage (where the auth token lives) into the popup — so the new window booted unauthenticated and the `POST /printers/camera/stream-token` fetch returned 401, leaving the `<img>` src without the required `?token=` query param; (2) even once the token arrived, `CameraPage` computed its URL from the module-level stream-token cache on render and never re-rendered when the cache was updated in a `useEffect`, so the first paint locked in a tokenless URL that the backend kept rejecting. Fixed by dropping `noopener` from the camera popup features (same-origin, trusted window) so sessionStorage is inherited, subscribing `CameraPage` to the `camera-stream-token` React Query so it re-renders the moment the token resolves, and appending the token directly from the reactive query value instead of the effect-synced module cache — the `<img>` src stays empty until the token is ready, so no tokenless request ever leaves the popup. Embedded-overlay mode was unaffected. Thanks to @VREmma for the reproducer.\n- **AMS Slot Changes Stop Reaching Printer After Long Idle** ([#887](https://github.com/maziggy/bambuddy/issues/887)) — After printers sat idle for several hours, spool changes published by Bambuddy silently stopped reaching the printer — the UI updated but the printer ignored the command, and only a manual reconnect restored functionality. Root cause: the MQTT connection degraded into a zombie state where the receive path still worked (push_status telemetry kept flowing, so Bambuddy considered the connection alive) but the publish path was dead. The existing zombie detector — the developer mode probe — only ran on first connect when `developer_mode` was unknown; after the initial probe cached the value, subsequent zombie states went undetected because neither the staleness timer nor the keepalive could distinguish a half-open connection from a healthy one. The MQTT client now tracks `ams_filament_setting` command/response pairs: when a published command receives no response within 10 seconds, it's counted as unanswered. After two consecutive unanswered commands, the session is force-reconnected using the same `force_reconnect_stale_session()` mechanism. This catches zombie sessions at the moment the user encounters them — on their second failed spool change — rather than requiring a manual reconnect. Thanks to @RosdasHH for the detailed support bundles that made the diagnosis possible.\n- **Obico Detection ML API Call Fails Silently With Empty Error** ([#172](https://github.com/maziggy/bambuddy/issues/172), [#1003](https://github.com/maziggy/bambuddy/issues/1003)) — The previous attempt at #1003 (0.2.3b4 dev) switched Bambuddy to **POST the JPEG bytes directly** to Obico's ML API as multipart form data, hoping to eliminate the callback-URL dependency for users behind reverse proxies with external auth. That approach cannot work: Obico's `/p/` endpoint is declared `methods=['GET']` upstream and only reads `?img=URL` as a query string (verified against `obico-server/ml_api/server.py`). Flask's router rejected every POST with 405 Method Not Allowed before any handler ran, which is why the Obico container logs showed zero activity while Bambuddy kept reporting `ML API call failed for printer N:` with a blank suffix — `raise_for_status()` on the 405 response produced an exception whose `str()` rendered empty in this path. Reverted to the pre-#1003 nonce-URL approach: the detection loop captures the JPEG locally with a 20 s timeout, stashes it under a 32-byte single-use nonce, and hands Obico a `GET /api/v1/obico/cached-frame/{nonce}` URL that resolves in <50 ms (so Obico's hardcoded 5 s read timeout never races our RTSP keyframe wait). The cached-frame route is un-authenticated at the Bambuddy layer — the unguessable 32-byte nonce with ~30 s TTL IS the credential. The warning log now also falls back to `type(exc).__name__` when `str(exc)` is empty, so future silent exceptions can never produce a blank error again. **For users behind reverse-proxy external auth (Authelia/Authentik/Cloudflare Access)**: the `/api/v1/obico/cached-frame/` path must be whitelisted from external auth — it's already public on Bambuddy's side. Thanks to @fblix for the ml-api-shows-zero-logs clue that pinpointed the 405 root cause.\n- **Obico Detection Snapshot Killed by Stream Cleanup** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — Third wave of #172 — once the cached-frame fix landed, `fblix` reported a permanent \"Failed to capture snapshot\" warning in the UI. The periodic camera stream cleanup task scans `/proc` for ffmpeg processes with Bambu RTSP URLs and kills any that aren't in the active-streams registry. The Obico detection service's `capture_camera_frame_bytes()` spawns its own short-lived ffmpeg process to grab a single JPEG frame, but that process was never registered with the stream cleanup — so when the 60-second cleanup cycle happened to run during the 5–10 s capture window, it killed the ffmpeg as \"orphaned\" (exit code -9). The detection service recovered on the next poll, but the kill produced unnecessary error logs and a missed detection frame. Fixed by tracking capture PIDs in a module-level set (`_active_capture_pids`) and excluding them from the `/proc`-scan kill list. Thanks to @fblix for the detailed timing analysis.\n- **Direct Print from Library Not Attributed to User** — Clicking the Print button on a library file dispatched the job with no `created_by_id`, so the resulting archive had no owner and the print didn't show up in per-user statistics. The Queue and Reprint paths already forwarded the authenticated user; the library `POST /files/{file_id}/print` endpoint now does the same, reading the user from the JWT and passing it through to the dispatcher so direct prints are attributed like queued and reprinted ones.\n- **Add/Edit Printer Modal Clipped on Short Viewports** ([#964](https://github.com/maziggy/bambuddy/issues/964)) — On short or zoomed-in browser windows, the Add Printer and Edit Printer dialogs exceeded the viewport height with no scroll, hiding the lower fields (Access Code, Model, Location) and the Save button. Users had to zoom the browser out to complete the form. The modal overlay now scrolls and the card caps at `calc(100vh - 2rem)` with internal overflow so every field stays reachable regardless of viewport height. Thanks to @MartinNYHC for reporting.\n- **AMS Drying Silently Does Nothing** ([#971](https://github.com/maziggy/bambuddy/issues/971)) — Clicking Start Drying on a supported printer (e.g. P1S with AMS 2 Pro) could publish the MQTT command successfully but leave the AMS idle with no UI feedback. Two issues: (1) the firmware rejects the command when `dry_sf_reason` reports a blocking state (most commonly code 8 — AMS 2 Pro external power adapter not plugged in — but also \"AMS busy\", \"already drying\", etc.), and Bambuddy parsed that array but never surfaced it to the user; (2) the payload sent `filament: \"\"`, which some firmwares treat as an invalid-field refusal. The `/drying/start` endpoint now inspects the live `dry_sf_reason` for the target AMS unit and returns a descriptive 409 (e.g. \"Plug in the external AMS power adapter to start drying\") instead of silently publishing, and backfills an empty `filament` from the first loaded tray's type (defaulting to `PLA`) so the printer never rejects the command for a missing field. Thanks to @MartinNYHC for reporting.\n- **Webhook Tokens Leaked into Logs When Debug Logging Enabled (Security)** — Turning on Settings → Support → Debug Logging elevated the `httpx` and `httpcore` loggers to DEBUG, which caused httpx to log the full URL of every outbound HTTP request. For Discord notifications and generic webhook notifications, the URL *is* the secret — the bearer token is embedded in the path — so any user who enabled debug logging (typically to capture logs for a bug report) was writing their Discord webhook token to `bambuddy.log` and then pasting it into GitHub issues or support bundles. `httpx`/`httpcore` are now pinned to `WARNING` regardless of the debug toggle; `paho.mqtt` still honours debug. If you enabled debug logging while notifications were sending, rotate any exposed Discord/webhook URLs — the token is in the path, so the whole URL must be regenerated in the provider's UI.\n- **Queue Item Stuck in \"Printing\" When Start Command is Dropped** ([#967](https://github.com/maziggy/bambuddy/issues/967)) — If the physical printer dropped or ignored the MQTT `project_file` start command (same half-broken-session shape as #887/#936), the queue item was permanently orphaned in the `printing` status at 100% because the scheduler optimistically flipped the DB row to `printing` right after the publish succeeded locally and had no watchdog to revert it. Recovery required manually editing the SQLite `print_queue` table. A new watchdog now captures the printer's pre-dispatch state and polls for up to 45 s after `start_print()` returns; if the printer never transitions, the item is reverted to `pending` so the scheduler picks it up again, and the MQTT session is force-reconnected so the retry lands without a printer reboot. Thanks to @stringham for reporting.\n- **Queued Prints Require Printer Reboot to Start** ([#936](https://github.com/maziggy/bambuddy/issues/936)) — On some printers, a queued print would be uploaded via FTP and the `project_file` MQTT command would be sent, but the printer never transitioned out of `FINISH`/`IDLE` and required a power cycle to unstick — after which it often started a previously cancelled print rather than the intended one. Root cause is a half-broken MQTT session (same shape as #887): the printer keeps publishing telemetry so Bambuddy reports it as connected, but our publishes on the command topic never reach the firmware. Existing recovery only triggered via the developer-mode probe path, which skips printers that already have a known `developer_mode` value. The print-dispatch verifier now treats an unacknowledged `project_file` (state unchanged after 15 s) as the same \"commands not reaching printer\" signal and forces a fresh MQTT session so the next dispatch can land without a printer reboot. The existing dev-mode probe path is refactored to share the same helper.\n- **Clear Plate Confirmation Bypassed on Power Cycle** ([#961](https://github.com/maziggy/bambuddy/issues/961)) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into `IDLE` and the next job auto-dispatched without the \"Clear Plate & Start Next\" confirmation. Root cause: the plate-cleared gate lived only in the in-memory `PrinterManager._plate_cleared` set, and the scheduler's idle check treated `IDLE` as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an `awaiting_plate_clear` column on the `printers` table, set by `on_print_complete` when a print finishes or fails, cleared by the `/printers/{id}/clear-plate` endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into `PrinterManager` on startup. `_is_printer_idle` now short-circuits to not-idle whenever `require_plate_clear` is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into `IDLE`. The clear-plate endpoint no longer requires the printer to currently report `FINISH`/`FAILED` (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting.\n- **Insecure Temp File Creation in Backup Export** — The manual backup download endpoint used `tempfile.mktemp()`, which is vulnerable to a symlink race condition (CWE-377). Replaced with `tempfile.mkstemp()` which atomically creates the file, eliminating the TOCTOU window.\n- **Spoolman Iframe Blocked After 0.2.3b4 Security Headers** — The Spoolman page (Inventory → Spoolman iframe) failed to load when Spoolman was served from the same host as Bambuddy via a reverse proxy. The security-headers middleware added in 0.2.3b4 set `X-Frame-Options: DENY` on every response, which blocked even same-origin iframing. Relaxed to `SAMEORIGIN` so Spoolman (and any other same-origin tool behind the same reverse proxy) can be embedded again, while still preventing cross-origin clickjacking.\n- **Large 3MF Print Restart Mid-Job Kept Duplicate Archive With Wrong Duration** ([#972](https://github.com/maziggy/bambuddy/issues/972)) — Second wave of #972 reports — a reproducer on a 37.5 MB BambuStudio-pushed print to an A1 surfaced three distinct problems that compounded across a Bambuddy container restart mid-print. (1) *Archive start_time lost*: the print-start handler only deduped existing `printing` archives by filename and marked them cancelled once older than 4 h — so a 13 h print that had a restart 10 h in got its archive cancelled, a brand-new archive created with `started_at = now()`, and the final duration displayed as ~1.5 h for a job that actually ran 13 h. Fixed by persisting the MQTT-provided `subtask_id` on every archive row (new `subtask_id` column, auto-added via the existing inline migration runner) and matching on that id first, regardless of age. Same id means same print; the row is resumed in place with its original `started_at`. Also revives `Stale`-cancelled rows from the legacy path if an earlier Bambuddy version already ran the old cancel-then-recreate logic. (2) *3MF search retried non-existent paths for ~48 min*: the path order was `/cache/ → /model/ → /data/ → /data/Metadata/ → /`, and every missing path burned the full retry budget (user had `ftp_retry_count = 10` with 30 s delay ⇒ 11 × 30 s × 4 missing paths ≈ 22 min before the real `/` root path was even tried). BambuStudio/OrcaSlicer actually push to `/` on A1-family printers, so the \"most likely\" path was tested last. Fixed by reordering to try `/` first, and by raising a new `FileNotOnPrinterError` sentinel from `download_to_file` when the FTP response is a 550 (file not found) so `with_ftp_retry`'s `non_retry_exceptions` short-circuits instead of waiting out the full delay ×11 retries against a path that will never have the file. Transient errors (425 \"can't open data connection\", SSL EOF, connection resets) still retry as before. (3) *Same 36 MB downloaded twice* — the cover-thumbnail endpoint and the archive-metadata handler each opened their own FTP session for the same file during the print, and the second session often hit 425 because the first was still using the printer's single FTP socket. Added a small in-memory `_threemf_path_cache` keyed on (printer_id, normalized filename): whichever flow fetches the 3MF first populates the cache, the other flow reuses the file read-only, and `on_print_complete` evicts the entry + deletes the temp file. Normalization collapses `Broly_X`, `Broly_X.3mf`, `Broly_X.gcode.3mf`, `Broly X`, and case variants to the same slot so both flows agree on the key. Net effect for the reproducer: what took ~48 min with a lost start time now takes seconds and the archive keeps its original row + timestamps. Thanks to @mstko for the reproducer and support bundles.\n- **Large 3MF Files Silently Dropped After Print Finish** ([#972](https://github.com/maziggy/bambuddy/issues/972)) — After large prints, the Files tab rows arrived with no thumbnail, no filament breakdown and no cost — the archive row got created as a fallback with no 3MF even when the file was sittable on disk. Two root causes in the 3MF-fetch path. (1) The configured `ftp_timeout` setting (default 30 s, reporter had raised it to 300 s) was only plumbed through as the FTP *socket* timeout; the outer `asyncio.wait_for` wrapping `run_in_executor` was stuck on the hardcoded 60 s default, so the user's 300 s value never applied — every 3MF download was capped at 60 s regardless. (2) `asyncio.wait_for` cannot cancel `run_in_executor` threads: when the 60 s outer timeout fired, the executor thread kept running `ftplib.retrbinary` and frequently completed the download successfully ~30–60 s later — logging `\"Successfully downloaded … N bytes\"` and caching the working FTP mode — but by then the async wrapper had already returned `False`, so the retry loop kept re-attempting the same path, each attempt truncating the file the zombie thread had just written. After all 4 attempts the wrapper reported `failed after 4 attempts` and the archive was persisted as a fallback (no 3MF, empty `file_path`). The async wrapper now (a) accepts and uses `timeout` at each call site so `ftp_timeout` controls both the asyncio deadline and the socket deadline, and (b) salvages a post-timeout success: when the executor thread has set an explicit completion flag and the file is on disk, the wrapper returns `True` instead of discarding the result. Also fixes a cosmetic `//` prefix in the directory-search download path (`posixpath.join` replaces string concatenation that produced `\"//file.3mf\"` when the search dir was `\"/\"`). Thanks to @MartinNYHC for the report and @PurseChicken for the P1S support bundle.\n- **SD Card Badge Removed** — After four rounds of fixes the printer-card SD status badge still flipped red on H2D when unrelated activity happened on the network (e.g. powering on an A1 caused every H2D to go red simultaneously). The underlying problem is that Bambu firmware SD-state signaling is not reliably derivable from MQTT: the legacy top-level `sdcard` field is only sent on some pushes with inconsistent typing, and `home_flag` bits 8-9 are cleared on heartbeat pushes even when a card is inserted, with no reliable way to distinguish heartbeats from full status reports. The badge has been removed entirely from the Printers page card and the Printer Info modal. Underlying `state.sdcard` parsing is retained (simplified to a plain truthy read of the `sdcard` field only, no more `home_flag` derivation, no heartbeat latches) because the firmware-update precondition check still needs to know whether a card is inserted before starting an update. Thanks to @MartinNYHC for the extensive reporting across all four rounds. Previously, this entry described the H2D badge flap and its three attempted fixes — kept here for history: The original bug toggled between \"inserted\" (green) and \"not inserted\" (red) every few seconds on H2D. Root cause: the MQTT parser used a strict identity check (`data[\"sdcard\"] is True`) on the top-level `sdcard` field, but real firmware ships that field inconsistently — bool on some models, int `1`, or a string enum like `\"HAS_SDCARD_NORMAL\"` on others — so any message carrying a non-bool value flipped the state to `False`. Fixed by deriving the badge from `home_flag` bits 8–9 (`HAS_SDCARD_NORMAL` / `HAS_SDCARD_ABNORMAL`) when present — the canonical firmware source, same as door and store-to-SD parsing — and falling back to a truthy check on the top-level field for firmwares that only send that. Follow-up: the badge was still flapping because Bambu firmwares send partial MQTT pushes that carry the legacy `sdcard` field alone (without `home_flag`), and the fallback was re-engaging on every such push. The parser now latches `home_flag` as the canonical source for the session once seen, so partial pushes carrying only `sdcard` can no longer flip the badge; the latch resets on reconnect so a firmware change still re-learns. Second follow-up: on H2D the badge still showed red on initial Printers-page navigation and flipped to green on reload, because H2D also sends heartbeat-style `home_flag` pushes where bits 8–9 are clear even when a card is inserted. Downgrades from true→false now require three consecutive clear reads (upgrades false→true still apply immediately), so a single heartbeat no longer turns the badge red. Third follow-up: the three-strike counter still lost the race on idle printers — once an A1 or other printer connecting nearby triggered a burst of MQTT activity, idle H2Ds could accumulate ≥3 heartbeat pushes before the next full status report and all flip to red simultaneously. Reworked the derivation: the legacy top-level `sdcard` field is now authoritative when present (truthy check covers bool/int/string firmware variants), `home_flag` bits 8–9 are only consulted on full `push_status` reports (identified by the presence of multiple state markers like `gcode_state`, `mc_percent`, `nozzle_temper`, `print_type`, `stg_cur`, or `ams`), and bare heartbeat pushes carrying `home_flag` alone no longer affect SD state at all. Thanks to @MartinNYHC for reporting.\n- **Archive Reprints Show Wrong Duration in Third-Party MQTT Monitors** ([#1011](https://github.com/maziggy/bambuddy/issues/1011)) — Re-printing a file from Bambuddy's archive caused external MQTT observers like OctoEverywhere to report wildly wrong durations: a 40 min job first reprint would show ~1 h 40 min, and a second reprint of the same file would compound further (~4 h for a ~45 min print), with the excess roughly matching the wall-clock gap since the previous archive replay. The same file printed via BambuStudio → Bambuddy proxy → printer reported correct durations every time. Root cause: the archive-reprint path built the MQTT `project_file` command with hardcoded `project_id=\"0\"`, `subtask_id=\"0\"`, `task_id=\"0\"`, and `md5=\"\"`, while BambuStudio mints unique identity fields per submission. The printer uses those IDs to key per-job state (including `gcode_start_time`), so when every reprint arrived under the same `task_id=0`, the printer reused the prior job's start timestamp instead of emitting a fresh state-transition event — third-party tools that derive duration from that timestamp latched onto a stale value, and successive replays compounded the error. `bambu_mqtt.start_print()` now generates a per-submission millisecond timestamp for `project_id`/`subtask_id`/`task_id` and a unique `md5` derived from the filename + timestamp, matching BambuStudio's per-submission-unique-ID behavior. Covers both archive reprints and direct prints from the Library. Thanks to @PurseChicken for the controlled A/B reproducer (Studio vs archive reprint) that pinpointed the divergence to the print-start command payload.\n- **CSP Blocked Sidebar Iframes, Service-Worker Registration, and Google Fonts** — The strict `Content-Security-Policy` header added in 0.2.3b4 broke three things at once: (1) custom sidebar links pointing at external HTTPS URLs (e.g. a Grafana/telemetry dashboard) rendered in `ExternalLinkPage` were blocked because no `frame-src` was declared and iframes fell back to `default-src 'self'`; (2) the inline service-worker registration `<script>` at the bottom of `index.html` was blocked by `script-src 'self'`, silently preventing the PWA service worker from installing; (3) the `@import` of Google Fonts' Inter from `index.css` was blocked by `style-src` and `font-src`. Fixed by adding `frame-src 'self' https:` for user-configured HTTPS iframe targets, moving the inline SW-registration script into `/sw-register.js` so `script-src 'self'` covers it without needing `'unsafe-inline'` or per-build hashes, and allowing `https://fonts.googleapis.com` in `style-src` and `https://fonts.gstatic.com` in `font-src`. `frame-ancestors 'none'` is preserved so Bambuddy itself still cannot be framed cross-origin.\n\n\n## [0.2.3b3] - 2026-04-12\n\n### Improved\n- **AMS Drying Support for P2S** — Remote AMS drying and queue auto-drying now work on P2S printers with firmware 01.02.00.00 or later. Previously P2S was hard-blocked from the drying feature.\n\n### New Features\n- **Scheduled Local Backups** ([#884](https://github.com/maziggy/bambuddy/issues/884)) — Settings → Backup now includes a \"Scheduled Backups\" card that automatically creates complete backup snapshots (database + all data directories) on an hourly, daily, or weekly schedule with configurable time-of-day and retention count. Backups are written as ZIP files to a configurable output directory (defaults to `DATA_DIR/backups/`), which Docker users can mount as a volume to their NAS or external storage. Each backup in the list can be downloaded, restored directly from the UI, or deleted individually. The manual backup download endpoint has also been optimized to stream directly from disk instead of loading the entire ZIP into memory, significantly reducing download wait times for large backups. Works with both SQLite and PostgreSQL installs. Fully localized across all 7 UI languages.\n- **SpoolBuddy Device Management Tab** — Settings → SpoolBuddy now lists every registered SpoolBuddy device with live connection status, system details (firmware, IP, CPU temperature, memory, disk, OS, daemon and system uptime), hardware health flags (NFC / scale OK), and an Unregister button gated by a confirm modal. Previously, when a daemon crash caused SpoolBuddy to register itself twice, the kiosk UI silently used only the first device and there was no UI path to delete the orphaned duplicate — administrators had to delete the row directly in the database. A new `DELETE /spoolbuddy/devices/{device_id}` endpoint (gated by `inventory:delete`) handles the removal and broadcasts a `spoolbuddy_unregistered` websocket event so other tabs refresh immediately. A yellow warning banner appears when more than one device is registered to flag likely crash-duplicates. If an online device is accidentally unregistered, it will re-register itself on its next heartbeat. The Settings tab header also shows a device-count badge and a green/gray bullet indicating whether at least one registered device is online. Fully localized in English, German, and Japanese.\n- **Print Files Directly from Project View** ([#930](https://github.com/maziggy/bambuddy/issues/930)) — The project detail page now lists the printable files from every linked library folder inline, with Play (Print Now) and CalendarPlus (Add to Queue) action buttons on each sliced file (`.gcode` and `.gcode.3mf`). No more round-tripping through File Manager to reprint project files. Prints triggered from the project view are automatically associated with the originating project, so the resulting archive shows up in that project's history without any manual assignment. Backend adds a `project_id` query parameter to `GET /library/files` that returns all files across linked folders in a single query (replacing the prior one-request-per-folder pattern) and validates `project_id` on both the direct-print and queue paths so a stale ID yields a 404 instead of a FK-constraint 500. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.\n- **Printers Page Search and Filters** ([#852](https://github.com/maziggy/bambuddy/issues/852)) — The Printers page now has a live search bar and two filter dropdowns (status and location) to make finding specific printers in large setups easier, especially on mobile where Ctrl+F is impractical. Search matches printer name, model, location, and serial number (case-insensitive, whitespace-trimmed) and has a clear button. The status filter covers All / Printing / Paused / Idle / Finished / Error / Offline and is reactive to WebSocket status updates via a React Query cache subscription — so a print finishing while \"Printing\" is selected immediately removes the printer from the filtered list. The location filter is only shown when at least one printer has a location configured. All three filters are combinable; the controls are hidden when no printers are configured yet; and an empty-state message appears when no printer matches the current search/filters. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.\n- **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a \"Default group\" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.\n\n### Changed\n- **SpoolBuddy Auto-Wake on NFC/Scale** ([#945](https://github.com/maziggy/bambuddy/issues/945)) — The SpoolBuddy kiosk display now wakes automatically when a spool is placed on the scale or an NFC tag is scanned, without requiring a touch first. The daemon discovers the Wayland session from the shared runtime directory and toggles HDMI power via `wlopm`, coexisting with `swayidle` which continues to handle touch-based wake independently. Gracefully degrades when `wlopm` is not installed or no Wayland session is available. Thanks to @TravisWilder for the suggestion.\n- **SpoolBuddy Kiosk LCD Now Powers Off on Idle** ([#937](https://github.com/maziggy/bambuddy/issues/937)) — The SpoolBuddy kiosk's \"screen blank timeout\" setting previously only painted a black CSS overlay over the browser window; the HDMI panel's backlight stayed on indefinitely, wasting power and letting OLED/LED panels burn in. The blanking path is now moved down to the OS layer: the install script installs `swayidle` and `wlopm`, and labwc's autostart launches a new watchdog (`spoolbuddy/install/spoolbuddy-idle.sh`) that queries the backend once on boot for the device's `display_blank_timeout` and hands it to `swayidle`, which powers HDMI off via `wlopm --off HDMI-A-1` after the configured idle period and powers it back on via `wlopm --on` when labwc delivers any input event (touch, keypress). The redundant CSS overlay and its pointer/keyboard listeners have been removed from `SpoolBuddyLayout` — one source of truth now. Screen blanking is opt-in: `display_blank_timeout=0` (the default) skips launching swayidle entirely and the display stays on forever, preserving current behavior for users who didn't pick a timeout. The default for users who newly enable blanking is 300 seconds. Changes made to the timeout in SpoolBuddy Settings → Display take effect on the next kiosk restart — tap Quick Menu → Restart Browser to apply without a full reboot. A new `GET /api/v1/spoolbuddy/devices/{device_id}/display` endpoint (gated on `inventory:update`, same as the existing `PUT` and heartbeat endpoints) is what the kiosk-side watchdog reads, so no new permissions are required on the device's API key. The watchdog also writes a full startup trace (env vars, resolved timeout, the exact `swayidle` command it execs) to `~/.cache/spoolbuddy-idle.log` so any future breakage on a different kiosk setup is trivially diagnosable, and auto-detects `WAYLAND_DISPLAY` from `XDG_RUNTIME_DIR` with a short retry loop in case labwc hasn't finished exporting its env by the time autostart runs. Thanks to @TravisWilder for reporting.\n\n### Fixed\n- **H2C Nozzle Rack Slot Numbering Off When Slot 1's Nozzle Is Mounted** ([#943](https://github.com/maziggy/bambuddy/issues/943)) — The H2C nozzle rack card on the Printers page rendered every rack slot shifted by one position whenever the lowest-numbered slot (rack ID 16, displayed as \"slot 1\") had its nozzle currently picked up into a hotend. In that state the printer firmware omits the mounted slot's ID from `device.nozzle.info` entirely instead of sending an empty placeholder, so the rack arrived with 5 entries (IDs 17..21) plus the 2 L/R hotends. The frontend was computing its rack base ID via `min(present_ids)`, which then became 17 instead of the fixed 16, and every remaining nozzle was rendered one position to the left — the nozzle physically in slot 2 appeared as \"slot 1\", slot 3 appeared as \"slot 2\", and so on, with the single empty placeholder falling off the right end as a phantom \"slot 6\" that should have been the actual empty \"slot 1\". The rack base is now hardcoded to 16 to match the fixed H2C rack ID layout (already encoded in the `test_h2c_nozzle_rack_populated_with_8_entries` backend test), so the empty slot stays anchored to its physical position regardless of which nozzle is currently in use. A frontend regression test exercises exactly this case (ID 16 missing, remaining slots in order) and asserts the rendered slot row reads `[—, 0.2, 0.6, 0.8, 1.0, 1.2]`. Thanks to @netscout2001 for reporting.\n- **Energy Snapshot Capture Crashes on PostgreSQL** — With an external PostgreSQL database configured, the hourly smart-plug energy snapshot loop (introduced with the #941 fix) logged `asyncpg.DataError: invalid input for query argument $2: ... can't subtract offset-naive and offset-aware datetimes` every hour and failed to persist any snapshots, so date-filtered energy statistics in total-consumption mode stayed empty on Postgres installs. The engine already had a `before_cursor_execute` hook that strips `tzinfo` from bound datetime parameters before they reach asyncpg (the `smart_plug_energy_snapshots.recorded_at` column is `TIMESTAMP WITHOUT TIME ZONE` to match the rest of the schema), but the hook only stripped datetimes one level deep — when SQLAlchemy's `insertmanyvalues` feature batched multiple snapshot rows into a single `INSERT ... SELECT FROM (VALUES ...)` statement, parameters arrived as nested containers (lists of tuples, or a list inside an outer container) and the inner datetimes slipped through untouched. The hook now recursively walks any nesting of dict/list/tuple and strips `tzinfo` at any depth, so every parameter shape SQLAlchemy may use is handled. SQLite installs were never affected (SQLite ignores tzinfo entirely).\n- **Wrong Filament Color Name Shown on Printer Tab AMS Popup** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — PLA Translucent Cherry Pink (and other colors outside a small hand-maintained list) appeared as \"Scarlet Red\" on the Printer tab AMS slot popup, and was also auto-provisioned into the inventory under the wrong name on the first RFID read. Root cause: both the backend spool auto-provisioner and the frontend AMS popup resolved color names by looking up the Bambu `tray_id_name` code (e.g. `A17-R1`) in a hardcoded table, and when the exact code wasn't listed they fell back to a suffix-only lookup (`R1 → Scarlet Red`). The suffix half of that code is **not** globally unique across material families — `A17-R1` is PLA Translucent Cherry Pink, while `A01-R1` is PLA Matte Scarlet Red — so the fallback was structurally guaranteed to produce wrong names for any color the hand-maintained list didn't happen to cover. The resolver has been rewritten to use the existing `color_catalog` table (seeded from `catalog_defaults.py` plus the FilamentColors.xyz sync) as the single source of truth. Backend lookup is now by hex color against the catalog; the frontend fetches a compact `{hex: name}` map once per session via a new `GET /api/inventory/colors/map` endpoint (available to any authenticated user, not gated on `inventory:read`), stores it in a `ColorCatalogProvider` context, and uses it for all `getColorName()` calls. The hardcoded tables in `backend/app/core/bambu_colors.py`, `frontend/src/utils/colors.ts`, and `frontend/src/pages/PrintersPage.tsx` have been removed entirely. Existing spools that were auto-created with a wrong name before this fix need to be renamed manually — the fix only affects new auto-provisioning and live display. Thanks to @lightmaster for reporting.\n- **LDAP Auto-Provisioning Fails on Upgraded SQLite Installs** ([#794](https://github.com/maziggy/bambuddy/issues/794)) — First LDAP login on an upgraded SQLite install hit `sqlite3.IntegrityError: NOT NULL constraint failed: users.password_hash` and fell through to a 500 response, because the `users` table on disk had been created before LDAP support landed with `password_hash VARCHAR(255) NOT NULL`. The model was already `nullable=True` and the migration to drop the constraint existed, but only ran on PostgreSQL — SQLite was skipped entirely because it has no `ALTER COLUMN ... DROP NOT NULL`. The migration now patches `sqlite_master` directly via `PRAGMA writable_schema` and bumps `PRAGMA schema_version` so the current connection reloads the table definition without requiring a restart. Fresh installs were never affected (they go through `Base.metadata.create_all` which uses the current nullable model). Thanks to @DylanBrass for reporting.\n- **Energy Statistics Empty for Week/Month/Day in Total Consumption Mode** ([#941](https://github.com/maziggy/bambuddy/issues/941)) — With \"Total consumption\" selected as the energy tracking mode, the Statistics page showed the correct kWh total for All Time but zero for every time-filtered range (Today, This Week, This Month, …). The backend fell back to summing per-print archive energy whenever a date filter was active, but in total-consumption mode the per-print column was often empty for two reasons: (1) the starting-kWh value was held in an in-memory dict (`_print_energy_start`) that was lost on any backend restart mid-print, so prints that spanned a restart never got an energy delta computed; (2) historical prints from before a smart plug was added had no value at all. The fix replaces the in-memory dict with a persisted `energy_start_kwh` column on the archive row, and adds an hourly snapshot loop (`smart_plug_energy_snapshots` table) that captures each plug's lifetime counter. The `/archives/stats` endpoint now computes date-range totals via per-plug `(last-in-range − baseline)` deltas from those snapshots, clamping counter resets to zero. A warming-up flag is returned (and rendered as a tooltip next to the Energy stats on StatsPage) when the query runs on incomplete snapshot history — e.g. right after upgrade, before the hourly loop has built up a baseline before the selected range — so the \"low\" values during the first hours after upgrading are explained in-product rather than misread as a bug. Fully localized across all 7 UI languages. Per-print energy tracking is now restart-resilient in all modes as a side-effect. Thanks to Mike (@TheMadMike23) for reporting.\n- **Virtual Printer \"Synchronizing device information\" Times Out in Orca** ([#927](https://github.com/maziggy/bambuddy/issues/927)) — OrcaSlicer's \"Send job\" flow sat on \"Synchronizing device information…\" until it gave up, even though the FTP upload itself worked when the user clicked \"Send job anyway\". The virtual printer's MQTT server gated all incoming command handling on `f\"device/{self.serial}/request\" in topic` — if the slicer's cached serial for the VP didn't exactly equal the VP's computed `self.serial` (which depends on model prefix + per-VP `serial_suffix`), every `get_version`, `pushall`, and `project_file` publish was silently dropped. Nothing was logged past the initial \"MQTT publish to …\" line, so the slicer never received a `push_status` or `get_version` response on its subscribed `device/{serial}/report` topic and hit its sync timeout. Status pushes, version responses, and project_file acknowledgments were *also* being published on `device/{self.serial}/report`, so even when the incoming check happened to pass, replies targeted a topic the slicer wasn't listening on if its serial had drifted. Both directions are now serial-adaptive: the handler accepts any authenticated publish on a `device/*/request` topic, extracts the serial the slicer is actually using from the topic, stores it per-connection, and uses it for every outgoing status report, version response, print acknowledgment, and periodic push so responses always land on the topic the slicer subscribed to. The client's serial is cleared when the connection closes and when the server stops. Regression tests cover the mismatched-serial publish path, the non-request-topic rejection path, the pushall→status_report routing, and the client-serial lifecycle.\n- **External Sidebar Link Icon Not Showing** ([#878](https://github.com/maziggy/bambuddy/issues/878)) — Custom icons uploaded for external sidebar links rendered correctly in the edit dialog but were missing from the sidebar itself, and opening the icon URL directly returned `{\"detail\":\"Valid camera stream token required...\"}`. The sidebar `<img>` tag in `Layout.tsx` used a raw `/api/v1/external-links/{id}/icon` URL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because `<img>` tags cannot send Authorization headers). The edit dialog already routed through `api.getExternalLinkIconUrl()`, which wraps the URL via `withStreamToken()`; the sidebar now does the same, so icons appear when auth is enabled.\n- **Shortest Job First Toggle Disappears After Clicking** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — The SJF toggle badge on the queue page was rendered inside the Pending Queue section header, which is only shown when there is at least one pending item and the list view is active. Clicking the toggle often coincided with the scheduler starting the only pending print, at which point the Pending section unmounted and the toggle vanished along with it — making it look like the button had disappeared after clicking. The toggle has been moved to the top of the queue page, next to the list/timeline view switcher, so it stays reachable regardless of pending-item count, active filters, or the selected view mode.\n- **SpoolBuddy Update Fails in Docker with \"no user exists for uid 1000/1001\"** — The SpoolBuddy remote-update flow shelled out to the OpenSSH `ssh-keygen` and `ssh` binaries for keypair creation and command execution. Both binaries call `getpwuid(getuid())` at startup and abort with `No user exists for uid <N>` when the container runs under an arbitrary PUID that is not listed in `/etc/passwd` (the stock `python:3.13-slim` image only has an entry for root, so running with `user: \"1000:1000\"`, `\"1001:1001\"`, or any non-root user tripped the same error). The entire SpoolBuddy update path is now subprocess-free: keypairs are generated in-process via the `cryptography` library (already a dependency), SSH commands run through the pure-Python `asyncssh` client, and git-branch detection reads `.git/HEAD` directly instead of shelling out to `git`. asyncssh also calls `getpass.getuser()` for local `~/.ssh/config` host matching, which hit the same passwd lookup failure; the Docker image now sets `LOGNAME=bambuddy`, `USER=bambuddy`, and `HOME=/app` so `getpass.getuser()` resolves via env vars before touching the passwd database, and `asyncssh.connect()` is called with `config=[]` so it does not attempt to load `~/.ssh/config` at all. Branch detection also now looks for `.git/HEAD` in the *application root* rather than `settings.base_dir` — in Docker the data directory is a separate volume (`DATA_DIR=/app/data`) that never contains `.git`. Finally, the Docker build now bakes `.git/HEAD` into the image (`.dockerignore` allows this single 20-byte file through the context filter) so the production image knows which branch it was built from; previously the `.git` directory was excluded from the build context entirely, leaving the container with no git metadata and causing the SpoolBuddy update flow to always pull `main` on the remote device regardless of which branch Bambuddy itself was built from. Native installs behave identically — they already worked because the running user was always in `/etc/passwd` and `.git/HEAD` was readable from the project root. Regression tests assert that neither keypair creation nor command execution spawns any subprocess, and that branch detection reads from the application root even when a decoy `.git` sits inside the data dir.\n- **Camera Stream \"6 of 5\" Reconnect Counter + ffmpeg Log Flood** ([#925](https://github.com/maziggy/bambuddy/issues/925)) — Two bugs surfaced while investigating camera reconnect behaviour. First, the camera page briefly displayed \"Reconnecting attempt 6 of 5\" before giving up, because the attempt counter could be incremented to the maximum while the reconnect banner was still rendering. The displayed value is now clamped to the configured maximum. Second, every failed ffmpeg spawn logged the full ~20-line ffmpeg version/configuration banner, producing hundreds of lines of noise per failed camera click (one reported click produced 555 log lines across 30 retries). A new stderr summarizer strips the ffmpeg banner before logging so only the actual error lines remain. The underlying \"camera service stops accepting new connections after prolonged uptime\" behaviour in the X1C firmware is still under investigation.\n- **LDAP POSIX Primary Group Ignored** — LDAP authentication only looked at groups that listed the user explicitly via `memberUid` (supplementary group membership). A user's POSIX primary group — referenced by the `gidNumber` attribute on the user object and matching the `gidNumber` on a `posixGroup` — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for `posixGroup` entries whose `gidNumber` matches the user's primary `gidNumber`, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).\n- **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.\n- **\"Build Plate Cleared\" Button Unclickable After Second Print** ([#912](https://github.com/maziggy/bambuddy/issues/912)) — After completing the first queued print and confirming the plate was cleared, the \"Build plate cleared — ready for next print\" button became unresponsive after the second print finished. The React Query mutation's `isSuccess` state persisted from the first plate-clear confirmation, causing the component to render the static \"Plate Ready\" confirmation instead of the clickable button. The mutation state is now reset when the printer leaves the FINISH/FAILED state, so the button works correctly on every print cycle.\n- **Spoolman Location Not Cleared When Spool Removed from AMS** ([#921](https://github.com/maziggy/bambuddy/issues/921)) — When Spoolman auto-sync was enabled and a spool was removed from an AMS slot, its location in Spoolman was never cleared, causing \"double-booked\" slots where multiple spools shared the same location. The auto-sync callback set locations for newly inserted spools but skipped the cleanup step that clears stale locations. The location clearing logic now runs after every auto-sync cycle. Also fixed the single-printer manual sync endpoint which didn't track synced spool IDs, risking incorrect location clearing for location-matched (non-RFID) spools.\n\n## [0.2.3b2] - 2026-04-08\n\n### New Features\n- **Optional PostgreSQL Database Support** — Bambuddy can now use an external PostgreSQL database instead of the built-in SQLite. Set the `DATABASE_URL` environment variable (e.g., `postgresql+asyncpg://user:pass@host:5432/bambuddy`) to connect to Postgres. SQLite remains the default when no `DATABASE_URL` is set. All features work with both backends including full-text archive search (FTS5 on SQLite, tsvector+GIN on PostgreSQL), backup/restore (file copy vs pg_dump/pg_restore), health diagnostics, and cross-database restore (import a SQLite backup into PostgreSQL with automatic type conversion and FK handling).\n- **Shortest Job First Queue Scheduling** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — New SJF toggle badge on the queue page header. When enabled, the scheduler starts shorter print jobs before longer ones instead of FIFO order. A starvation guard ensures long jobs that get skipped once are protected from being skipped again — they move to the front of the queue on the next cycle. The queue display automatically reorders to show the scheduler's actual execution order. Print duration is cached on queue items at creation time from the 3MF metadata.\n- **Auto-Print G-code Injection** ([#422](https://github.com/maziggy/bambuddy/issues/422)) — Configure custom start and end G-code snippets per printer model in Settings (Workflow tab) for bed-clearing systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. When adding a print to the queue, enable \"Inject G-code\" to have the scheduler inject the configured snippets into the 3MF before uploading to the printer. The original file is never modified — injection creates a temporary copy for upload only.\n- **External Folder Subfolder Preservation** ([#890](https://github.com/maziggy/bambuddy/issues/890)) — Scanning an external folder now mirrors the real directory structure into the file manager folder tree instead of flattening all files into the root. Subdirectories are created as child LibraryFolders with correct parent/child hierarchy, and files are assigned to their matching subfolder. Hidden directories are skipped when \"Show hidden files\" is disabled. Subfolders that are deleted from disk are automatically cleaned up on the next scan. Created subfolders inherit the parent's read-only and show-hidden settings.\n- **LDAP Authentication** ([#794](https://github.com/maziggy/bambuddy/issues/794)) — Users can now authenticate against an LDAP/Active Directory server. Configure the LDAP server URL, bind DN, search base, and user filter in Settings > Authentication > LDAP. Supports StartTLS, LDAPS (SSL), and plaintext connections. LDAP groups can be mapped to BamBuddy groups (Administrators, Operators, Viewers) for automatic role assignment. Auto-provisioning creates BamBuddy accounts on first LDAP login when enabled. Local admin accounts remain as fallback when the LDAP server is unreachable. Password management features (change password, forgot password, admin reset) are automatically disabled for LDAP users.\n- **SpoolBuddy Quick Menu** ([#893](https://github.com/maziggy/bambuddy/issues/893)) — Swipe down from the top of the SpoolBuddy display to open a quick-access control panel. Toggle printer power via smart plugs directly from the display, and manage the SpoolBuddy system with restart daemon, restart browser, reboot, and shutdown controls. All destructive actions require confirmation. The menu shows real-time smart plug state (ON/OFF) for each printer that has a linked power plug.\n\n### Improved\n- **Database Engine Info on System Page** — The System Information page now shows the active database engine (SQLite or PostgreSQL) and its version in the Database section, making it easy to verify which backend is in use.\n- **Plate Number in Printer View** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — Printer cards and the stream overlay now show the plate number alongside the filename when printing plate 2+ of a multi-plate 3MF file (e.g. \"MyModel — Plate 3\"). Single-plate prints are unchanged.\n- **Printer Name in Queue for Model-Based Jobs** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — Queue items assigned to a printer type (\"Any P1S\") now show the actual printer name once the scheduler assigns a specific printer, instead of continuing to display the generic model target while printing or in history.\n- **AMS Drying Support for H2S** ([#886](https://github.com/maziggy/bambuddy/issues/886)) — Remote AMS drying and queue auto-drying now work on H2S printers with firmware 01.02.00.00 or later.\n- **REST Smart Plug: Separate Power/Energy URLs and Unit Multipliers** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — REST/Webhook smart plugs can now use individual URLs for power and energy data instead of requiring all values in a single status response. Each value falls back to the shared Status URL when no separate URL is configured, so existing setups work without changes. Added power and energy multipliers for unit conversion (e.g., set energy multiplier to `0.001` to convert Wh to kWh). Useful for platforms like ioBroker that expose each data point as a separate API endpoint.\n\n### Security\n- **Path Traversal in File Upload Endpoints** — Archive upload endpoints (`/upload`, `/upload-bulk`, `/{id}/source`, `/source-by-name`, `/{id}/f3d`, `/{id}/timelapse`) used the client-supplied filename directly in file paths without stripping directory components. An authenticated attacker could write files outside the intended directory via directory traversal (e.g. `../../evil.3mf`). All upload endpoints now sanitize filenames by extracting only the basename before constructing paths. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.\n- **Unauthenticated Bug Report Endpoints** — The bug report endpoints (`/start-logging`, `/stop-logging`, `/submit`) had no authentication, allowing anyone on the network to enable debug logging, retrieve system logs, and trigger bug report submissions with system diagnostics when authentication was enabled. All three endpoints now require authentication — `start-logging` requires `settings:update` permission, `stop-logging` and `submit` require `settings:read`. Endpoints remain open when authentication is disabled (the default). Reported responsibly by Sacha Vaudey via security@bambuddy.cool.\n- **API Key Empty Printer List Grants Full Access** — An API key with an empty `printer_ids` list (`[]`) was treated identically to `null` (global access to all printers), granting full printer access instead of no access. Now `null` means global access (admin key) and `[]` means no printer access. Existing API keys with empty lists are automatically migrated to `null` on startup. Also fixed the webhook queue endpoint which used a falsy check that would bypass the filter for empty lists. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.\n- **Missing HTTP Security Headers** — API responses did not include standard security headers. Added a middleware that sets `X-Content-Type-Options: nosniff` (prevents MIME-sniffing), `X-Frame-Options: DENY` (prevents clickjacking via iframe embedding), and `Referrer-Policy: strict-origin-when-cross-origin` (limits URL leakage to external services) on every response. `Content-Security-Policy` was omitted because the React SPA uses inline styles extensively and a permissive CSP would provide no meaningful protection. `Strict-Transport-Security` was omitted because Bambuddy is a LAN application commonly accessed over HTTP — HSTS would lock users out. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.\n- **Camera Snapshot Temp Files World-Readable** — Camera snapshot and plate detection endpoints created temporary JPEG files in `/tmp` with default 0644 permissions, making them readable by any local user. Switched from `NamedTemporaryFile(delete=False)` to `mkstemp` with explicit 0600 permissions so only the application user can read them. Cleanup was already handled via `finally` blocks. Reported responsibly by Sacha Vaudey via security@bambuddy.cool.\n\n### Fixed\n- **Spool Weight Not Updated After Print** ([#839](https://github.com/maziggy/bambuddy/issues/839)) — Filament usage tracking failed silently in several scenarios: (1) when FTP download failed and a fallback archive was created without a 3MF file, the primary tracking path was skipped entirely — now falls back to matching the 3MF from the library or a previous archive of the same file; (2) external/VT tray spools were never tracked by the AMS remain% fallback because it only iterated AMS unit trays — now captures and tracks VT tray remain% deltas; (3) notifications showed \"Unknown\" for time and filament on fallback archives — now enriches notifications with usage tracker results and captures estimated print time from MQTT at archive creation; (4) when auto-archive was disabled, `archive_id` was None at print completion so the entire 3MF tracking path was skipped — now searches library files and previous archives by filename to find the 3MF even without an archive, and captures the AMS slot-to-tray mapping at print start so it's available at completion regardless of archive state; (5) when auto-archive was disabled but the print was dispatched by BamBuddy (queue/reprint), the on_print_start callback discarded the expected print entry and returned early — the archive was never promoted to `_active_prints`, so at completion `archive_id` and `ams_mapping` were both None, making all tracking paths fail. Now detects expected prints before the auto-archive early-return and falls through to the normal promotion path, also injecting the stored `ams_mapping` into the usage tracker session.\n- **File Manager Stale UI After Deleting Folders/Files** — Deleting a folder, file, or bulk-deleting items in the file manager appeared to succeed (toast shown) but the UI didn't update until a page reload. The delete endpoints (`delete_folder`, `delete_file`, `bulk_delete`) relied on FastAPI's dependency cleanup auto-commit which runs after the response is sent — the frontend received the success response, refetched the folder/file list, but the delete hadn't been committed yet. Added explicit `db.commit()` before returning in all three endpoints.\n- **Spool Manager Deducts Double the Filament Used** ([#880](https://github.com/maziggy/bambuddy/issues/880)) — After a print completed, the built-in spool manager subtracted twice the actual filament consumption. The printer's MQTT status message contains both updated AMS remain percentages and the `FINISH` state, which triggered two independent deduction paths in the same event loop cycle: the AMS weight sync (absolute SET from remain%) and the usage tracker (additive delta from 3MF data). The AMS weight sync now skips updates while a print session is active, letting the usage tracker handle deductions precisely via 3MF slicer data.\n- **Thumbnails Broken After Backend Restart** — Archive and library thumbnails returned 401 Unauthorized after a backend restart because stream tokens are stored in memory and lost on restart. The frontend now detects failed token-protected image loads and automatically refreshes the stream token, so thumbnails recover without a page reload.\n- **SpoolBuddy Kiosk Screen Blanks on Boot** — The touchscreen display would blank immediately after the RPi booted, requiring a touch to wake. Added `consoleblank=0` to the kernel cmdline to disable Linux console blanking during the Plymouth-to-labwc transition, and changed the `wlr-randr` anti-blank loop to fire immediately instead of sleeping 60 seconds first.\n- **Queue Widget Ignores Plate-Clear Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — The \"Clear Plate & Start Next\" button on printer cards appeared even when \"Require plate-clear confirmation\" was disabled in Settings → Queue. The backend correctly auto-dispatched without waiting, but the frontend widget always showed the prompt. The widget now respects the setting and shows a passive queue link instead when plate-clear confirmation is disabled.\n- **Ghost Jobs From SQLite Lock on Print Completion** ([#897](https://github.com/maziggy/bambuddy/issues/897)) — When a print finished, the queue status update (`printing` → `completed`) could fail silently if the SQLite database was locked by another writer (e.g. the runtime tracker). The failed commit left the job permanently stuck in `printing` status — a \"ghost job\" that caused the UI to show false double-assignments when the next job started. The critical queue status commit now retries up to 3 times with backoff on SQLite lock errors (PostgreSQL is unaffected — it uses row-level locking). Additionally, the runtime tracker was holding a single long transaction across all printers; it now commits per-printer to minimize lock hold time.\n- **Multi-Plug Automation Only Works for First Plug** ([#903](https://github.com/maziggy/bambuddy/issues/903)) — When multiple smart plugs were assigned to the same printer (e.g. a TUYA printer plug and a particle filter plug via Home Assistant), only the first plug's automation worked. The auto-on at print start, auto-off at print completion, and queue auto-off all queried for a single plug instead of iterating all plugs linked to the printer. All automation paths now control every assigned plug. Also fixed the queue auto-off path which was hardcoded to Tasmota instead of using the correct service for the plug type (HA, MQTT, REST).\n- **SpoolBuddy Inventory Not Updating on Spool Changes** — Adding, editing, deleting, archiving, or restoring a spool in the internal inventory did not update SpoolBuddy's frontend views until the next manual refresh or 30-second poll. The spool CRUD endpoints did not emit websocket events, and the SpoolBuddy Dashboard had no polling fallback. All inventory mutation endpoints now broadcast an `inventory_changed` websocket event, and the frontend invalidates the spool cache on receipt — so SpoolBuddy (and all other tabs) reflect changes instantly.\n- **AMS Slot Changes Fail Until Reconnect** ([#887](https://github.com/maziggy/bambuddy/issues/887)) — After a keep-alive timeout, paho-mqtt auto-reconnects but the new session can be half-broken: the printer continues sending status updates but silently ignores commands. The developer mode probe detected this (no response, leaving `developer_mode` as `null`), but had no timeout or recovery — one unanswered probe permanently blocked retries. Added a 10-second probe timeout with one retry; after two consecutive unanswered probes, Bambuddy force-closes the socket to trigger a clean reconnect with a fresh session. Additionally, the developer mode probe was firing on every auto-reconnect, which destabilized some firmware MQTT brokers (A1/P1 series) — causing a reconnect → probe → disconnect feedback loop. The probe result is now cached across reconnects and only runs once on the first connection, with a 5-second delay after connect to let the session stabilize.\n- **WebSocket Crash on Printers Without `fun` Field** ([#873](https://github.com/maziggy/bambuddy/issues/873)) — Connecting to printers that don't send the MQTT `fun` field (A1, P1 series, X1Plus firmware) caused a repeating `'str' object has no attribute 'get'` crash in the WebSocket handler, showing the printer as offline with missing AMS and SD card info. The developer mode probe introduced in 0.2.3b1 published an MQTT message inside `_update_state()` between overwriting `raw_data` with the full MQTT dict (where `vt_tray` is a raw dict) and restoring the previously normalized list — the `publish()` call released the GIL, letting the event loop read the un-normalized dict and iterate over string keys instead of spool dicts. Fixed by normalizing `vt_tray` dict→list in the MQTT data before assignment, and moving preserved field restoration before the probe. Added defensive normalization in `printer_state_to_dict` as a belt-and-suspenders guard.\n\n\n## [0.2.3b1] - 2026-04-02\n\n### New Features\n- **Queue Timeline View** ([#823](https://github.com/maziggy/bambuddy/issues/823)) — The queue page now has a production schedule view showing when each print is estimated to finish. Events are sorted chronologically and grouped by hour, with cards showing the file name, printer, estimated completion time, and time remaining. Active prints show a live progress bar. Filter by \"Show All\", \"Printing\", or \"Queued\", and navigate between days. Click any event to edit or stop it. Toggle between List and Timeline views with the button group above the queue.\n- **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable \"Stagger printer starts\" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. The stagger option is also available in the direct Print dialog when multiple printers are selected — prints are automatically queued with staggered start times, so you can close the browser and walk away.\n- **Plate-Clear Confirmation Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — New \"Require plate-clear confirmation\" toggle in Settings → Queue. When disabled, the scheduler starts queued prints automatically on printers with finished jobs without waiting for per-printer plate confirmation. Useful for farm workflows where plates are verified physically before starting a batch. Default is enabled (existing behavior preserved).\n- **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).\n- **Per-User Statistics Filtering** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new `stats:filter_by_user` permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select \"No User (System)\" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports.\n- **Bulk Printer Actions** ([#825](https://github.com/maziggy/bambuddy/issues/825)) — Select multiple printer cards and apply bulk actions from a floating toolbar. Toggle selection mode from the header, then click cards to select. Use \"Select All\", \"Select by State\" (printing, paused, finished, idle, error, offline), or \"Select by Location\" to quickly pick printers. Available actions: Stop, Pause, Resume, Clear Notifications, and Clear Bed — each button is smart-enabled based on the selected printers' current states. Confirmation modals for destructive actions (Stop, Pause, Clear Bed). The status summary bar now shows all printer states (printing, paused, finished, idle, error, offline).\n- **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default.\n- **REST/Webhook Smart Plug Type** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — New \"REST\" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts.\n- **Configurable Default Print Options** ([#858](https://github.com/maziggy/bambuddy/issues/858)) — Print options (bed levelling, flow calibration, vibration calibration, first layer inspection, timelapse) now have configurable defaults in Settings → Workflow. Set your preferred defaults once and every new print dialog starts with those values. Still overridable per print.\n- **Batch Print Quantity** ([#342](https://github.com/maziggy/bambuddy/issues/342)) — Print multiple copies of a file in one step. The print and schedule dialogs now have a quantity field — set it to any number and the system creates that many queue items automatically. When quantity is greater than one, items are grouped into a batch for tracking. In the direct print dialog, the first copy prints immediately while the remaining copies are queued. The queue page shows a batch badge on grouped items. Batch progress and cancellation are available via the API.\n- **GitHub Backup: Spool Inventory & Print Archives** ([#870](https://github.com/maziggy/bambuddy/issues/870)) — GitHub backup can now include spool inventory and print archive history as optional toggles alongside the existing K-profiles, cloud profiles, and settings. Spool backup exports all spools with their material, brand, color, weight, cost tracking, RFID tags, and full usage history. Archive backup exports print history metadata (filament, temperatures, times, costs, energy) — no gcode/3MF binary files. Both are off by default and can be enabled independently in Settings → Backup & Restore.\n\n### Improved\n- **Standardized Webhook Notification Payloads** ([#871](https://github.com/maziggy/bambuddy/issues/871)) — Custom webhook notifications now include structured event data fields (`event`, `printer`, `filename`, `duration`, etc.) alongside the existing `title`, `message`, `timestamp`, and `source` fields. Previously, only `title` and `message` were sent, requiring automation tools to parse the message text for event details. All event-specific template variables are now included as top-level JSON fields, making it easy for n8n, Node-RED, Home Assistant, and other automation platforms to route and process notifications based on structured data. Slack/Mattermost format is unchanged.\n- **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.\n- **Developer Mode Detection for A1/P1 Printers** — Printers that don't send the `fun` field in MQTT status (A1, P1 series) now have developer mode detected via a probe command. After receiving the first full status update, Bambuddy sends a no-op external slot configure and checks whether the printer accepts or rejects it (`mqtt message verify failed`). Printers that do send the `fun` field (X1C, H2D, etc.) continue to use the existing bit-based detection. Developer mode state is re-checked on every reconnect.\n\n### Fixed\n- **Bed Cooled Notification Never Firing** ([#872](https://github.com/maziggy/bambuddy/issues/872)) — Replaced the polling-based bed cooldown monitor with an event-driven approach. The old implementation polled cached bed temperature every 15 seconds for up to 30 minutes after print completion, but some printer firmware (e.g. P2S 01.00.05.00) stops including `bed_temper` in MQTT updates after a print finishes — even in response to pushall requests — causing the cached value to stay frozen at the end-of-print temperature until the monitor timed out. The new approach registers a waiter at print completion and reacts instantly when `bed_temper` data arrives via MQTT, whenever that may be. No timeout, no polling, no stale data — the notification fires as soon as the printer reports the bed is at or below the configured threshold.\n- **Filament Color and Subtype Inconsistencies** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — Fixed several filament identification issues: (1) AMS slot popup showed generic color names like \"Dark Gray\" instead of Bambu-specific names like \"Titan Gray\" because the fallback skipped the Bambu hex color database. (2) \"Silk+\" subtype was missing from the known variants list, so the Edit Spool dropdown showed \"Silk\" instead. Also added \"Tough+\". (3) Gradient and Dual Color filaments were misclassified — PLA Basic Gradient was detected as \"Basic\" and PLA Silk Dual Color as \"Silk\" because the firmware only sends the base material in `tray_sub_brands`. Now detects gradient/multi-color/tri-color variants from the `tray_id_name` color code pattern (M\\*/T\\* suffixes).\n- **External Spool Print Fails on Printers With AMS** ([#854](https://github.com/maziggy/bambuddy/issues/854), [#859](https://github.com/maziggy/bambuddy/issues/859)) — Two related issues with external spool printing: (1) Sending a print to a printer with no AMS units and only an external spool caused \"Failed to get AMS mapping table\" because the command was sent with `use_ams: true`. Now automatically sets `use_ams: false` when all filament slots map to external spools. (2) Printers with an AMS connected but empty (e.g. X1C with `ams_exist_bits=1, tray_exist_bits=0`) got stuck at heatbed heating or hit the same 07FF_8012 error because the print command used `ams_id: 254` in `ams_mapping2` instead of `255`. The firmware interpreted 254 as a physical AMS tray target instead of external spool. BambuStudio uses `ams_id: 255` (VIRTUAL_TRAY_MAIN_ID) for single-nozzle external spool. Fixed by mapping external spool to `ams_id: 255` on all non-H2D printers. H2D dual-nozzle printers retain 254 (deputy) / 255 (main) distinction.\n- **External Folder Scan 500 Error on 3MF Files** ([#846](https://github.com/maziggy/bambuddy/issues/846)) — Scanning an external folder containing .3mf files crashed with \"Object of type bytes is not JSON serializable\". The parsed 3MF metadata contained raw thumbnail bytes (`_thumbnail_data`) that were stored directly in the database JSON column without cleaning. Also removed a call to the non-existent `parser.extract_thumbnail()` method — thumbnail data is already available in the parsed metadata. Now uses the same `clean_metadata()` pattern as upload and zip extraction.\n- **Archives Capped at 50 Items** ([#843](https://github.com/maziggy/bambuddy/issues/843)) — The archives page only showed the 50 most recent prints due to a hardcoded API limit. Users with more than 50 archives could not see or access older entries. Fixed by fetching all archives and adding client-side pagination with configurable page sizes (25, 50, 100, 200, or All). Page size preference is persisted.\n- **Filament Usage Not Recorded When Auto-Archive Disabled** — When a printer had \"Auto-archive completed prints\" turned off, filament consumption was silently lost. The `on_print_complete` callback returned early before reaching the usage tracking code, so neither the internal inventory (AMS remain% deltas) nor Spoolman received usage data. Moved filament tracking to run before the archive check so usage is always recorded regardless of the auto-archive setting.\n- **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly.\n- **SpoolBuddy \"Add to Inventory\" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.\n- **SpoolBuddy Dashboard Crash on Null Spool Fields** — Viewing a spool with null `subtype`, `brand`, `rgba`, or `color_name` on the SpoolBuddy dashboard crashed the UI (black screen). The spool prop construction used `displayedSpool?.subtype ?? sbState.matchedSpool!.subtype` — when the field was `null`, the `??` operator fell through to `sbState.matchedSpool` which could also be null, causing a TypeError. Fixed by picking one source object instead of mixing per-field fallbacks. Added a global React error boundary so future crashes show the error instead of a black screen.\n- **Plate Thumbnails 401 in Print Modal** — Multi-plate 3MF plate thumbnails in the print modal returned 401 Unauthorized when authentication was enabled. The backend returns bare URL paths for plate thumbnails, but the `PlateSelector` component used them directly in `<img src>` without appending the stream token. Fixed by passing the URL through `withStreamToken()`.\n- **Schedule Calendar Picker Opens Off-Screen** — Clicking the calendar icon in the print modal's scheduled mode opened the native date picker at the bottom of the viewport instead of near the date field. The hidden `datetime-local` input used `sr-only` positioning which anchored the picker off-screen. Fixed by positioning the hidden input inside the date field's container.\n- **SpoolBuddy Kiosk Display Blanking and Crashes** — The kiosk Chromium flags added in 0.2.2.2 caused display instability: `--js-flags=--max-old-space-size=128` crashed the V8 renderer when heap exceeded 128 MB, `--enable-low-end-device-mode` aggressively killed GPU rendering surfaces, and resetting `CHROMIUM_FLAGS` discarded the Pi's GPU defaults (`--enable-gpu-rasterization`, ANGLE/GLES) creating an unstable mixed CPU/GPU rendering path. Fixed by removing both flags, appending kiosk flags to Pi defaults instead of replacing them, adding a `wlr-randr` keep-alive loop to prevent display blanking, and adding `<screenBlankTimeout>0</screenBlankTimeout>` to the labwc config.\n- **Sidebar Bottom Icons Cut Off With Smart Plugs** ([#862](https://github.com/maziggy/bambuddy/issues/862)) — Adding smart plug buttons to the sidebar caused the bottom icon row to overflow and get partially cut off. The footer section could be compressed by the flexbox layout when the navigation area grew. Fixed by preventing the footer from shrinking, allowing the expanded icon row to wrap, and adding scroll overflow to the collapsed sidebar icon stack.\n- **AMS History Cleanup Crash Every ~24 Hours** — The periodic cleanup of old AMS sensor history entries failed with \"can't compare offset-naive and offset-aware datetimes\". The cleanup cutoff used `datetime.now(timezone.utc)` (timezone-aware) but the `recorded_at` column stores naive datetimes via SQLite's `func.now()`. The mismatch caused a TypeError when SQLAlchemy processed the comparison. Fixed by using a naive UTC datetime for the cutoff. The error only appeared once per ~24h because the cleanup runs every 288 recording cycles (288 × 5 min = 24h).\n- **SpoolBuddy Status Bar Not Updating on Printer Switch** — The bottom status bar on SpoolBuddy kiosk pages showed stale warnings (e.g. low filament) from the previously selected printer after switching to a different printer via the dropdown or swipe gesture. Two issues: (1) the AMS data cache was a single ref shared across all printers, so switching to a printer whose status hadn't loaded yet fell back to the previous printer's cached AMS data; (2) the Layout's alert useEffect unconditionally cleared alerts to null when the device was online, which could overwrite printer-specific alerts set by child pages. Fixed by keying the AMS cache per printer ID and tracking Layout-owned alerts separately so child page alerts aren't clobbered.\n\n## [0.2.2.2] - 2026-03-27\n\n### New Features\n- **Persistent Auto-Off for Smart Plugs** ([#826](https://github.com/maziggy/bambuddy/issues/826)) — Smart plugs now have a \"Keep Enabled\" toggle under Auto Off settings. When enabled, auto-off stays active between prints instead of requiring manual re-enablement after each print (one-shot). Useful for accessories like BentoBox filters on Home Assistant switches that should always power off when a print completes. Default behavior (one-shot) is unchanged. Requested by @AeroMaestro.\n- **Missing Spool Assignment Notification** ([#763](https://github.com/maziggy/bambuddy/issues/763)) — When a print starts and the AMS mapping references tray slots without assigned spools, Bambuddy now shows a warning toast in the frontend and can send push notifications via any configured notification provider. The notification includes the printer name, missing slot labels (e.g. A2, Ext-L), and expected material profile. A new \"Missing Spool Assignment\" toggle is available under Print Events in notification provider settings (off by default). Fully integrated with i18n (all 7 locales). Contributed by @Keybored02.\n- **Mid-Print Spool Reassignment Tracking** ([#763](https://github.com/maziggy/bambuddy/issues/763)) — Usage tracking now correctly handles spool changes during a print. If a spool assignment is changed after a print starts, the system uses the live assignment for filament deduction; otherwise it falls back to the snapshot taken at print start. This ensures accurate filament tracking even when swapping spools mid-print. Contributed by @Keybored02.\n- **Auto-Link Untagged Inventory Spools on AMS Insert** ([#538](https://github.com/maziggy/bambuddy/issues/538)) — When a Bambu Lab spool is inserted into the AMS and no existing tag match is found, the system now checks if there is an untagged inventory spool with the same material, subtype, and color. If found, the RFID tag is automatically linked to that existing spool instead of creating a duplicate entry. Uses FIFO ordering (oldest spool first) so spools are consumed in purchase order. Matching is case-insensitive. Requested by @wreuel.\n- **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click \"Link External\" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, gcode, and image files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.\n\n### Improved\n- **SpoolBuddy Kiosk Performance Optimizations** — Reduced idle CPU load on Raspberry Pi from ~3.3 to ~0.9. Frontend: replaced expensive CSS animations on the idle dashboard (`animate-ping` with scale transforms, `blur-2xl` glow, continuous `animate-pulse` on status dots) with static elements and a slow color-cycling spool (5s interval). Chromium: added `--disable-extensions`, `--disable-background-timer-throttling`, `--disable-renderer-backgrounding`, and `--disable-crash-reporter` to `/etc/chromium.d/spoolbuddy-kiosk`. WebSocket: SpoolBuddy Dashboard and Layout pages now use React Query `select` to extract only `connected` status from printer queries, so temperature/fan/progress updates no longer trigger re-renders on every MQTT tick. Services: stripped services are now masked (not just disabled) to prevent socket/dbus reactivation; user-level services (xdg-desktop-portal, mpris-proxy, pipewire, etc.) are masked globally via `/etc/systemd/user/` overrides instead of unreliable `su -l systemctl --user`. Removed chromium and upower from `strip_packages` since the kiosk needs them — they were being uninstalled then immediately reinstalled on every run.\n- **SpoolBuddy AMS Slot Action Picker** — Clicking an AMS slot on the SpoolBuddy AMS page now shows a picker with contextual actions: Configure AMS Slot (set filament preset, K-profile, color), and either Assign Spool / Link to Spoolman (when no spool is mapped) or Unassign / Unlink (when one is). Works with both internal inventory and Spoolman. Previously the slot click went straight to the configure modal with no way to manage spool assignments.\n- **Unassign Button in Edit Spool Modal** — The edit spool modal now has an \"Unassign\" button next to \"Delete Tag\" that removes the spool's AMS slot assignment, clearing the location column in the inventory table.\n- **SpoolBuddy Settings Device Tab No Longer Scrolls** — Removed the branding card, folded Device ID into the Device Info card, placed Backend/Auth config and diagnostic buttons side by side in a 2-column layout, removed the redundant online/offline status row from Device Info, and tightened spacing throughout. The Device tab now fits on the small SpoolBuddy touchscreen without scrolling.\n- **Spool Notes in Assign Spool Modal** ([#793](https://github.com/maziggy/bambuddy/issues/793)) — Spool cards in the Assign Spool modal now show the spool's note as a hover tooltip, making it easier to identify spools by tracking IDs or other metadata stored in notes. Works with both internal inventory and Spoolman-synced spools. Requested by @LegionCanadian.\n- **WiFi Safeguard for SpoolBuddy Pi** — The install script now drops an APT hook (`/etc/apt/apt.conf.d/80-preserve-wifi`) that backs up NetworkManager WiFi connections before every `apt upgrade` and restores them if they get wiped. Prevents headless SpoolBuddy Pis from losing WiFi connectivity after Raspberry Pi OS package upgrades (observed with Bookworm kernel/raspi-config updates that clear `/etc/NetworkManager/system-connections/`).\n- **SpoolBuddy Install Script Now Upgrades System Packages** — The install script now runs `apt-get upgrade -y` after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.\n- **SpoolBuddy Assign-to-AMS Material Mismatch Warnings** — The SpoolBuddy \"Assign to AMS\" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global `disable_filament_warnings` setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal.\n- **Spool Assignment Changes Sync Across Tabs** — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.\n- **SpoolBuddy Inventory Page** — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a responsive catalog grid of spools with colored spool circles (matching AMS page style), material/subtype labels, color dots, fill level bars, remaining weight with percentage, and green AMS location badges (A1, B2, etc.) for assigned spools. Includes a search bar (filters by material, subtype, brand, color, notes) and touch-friendly inline filter pills (\"All\", \"In AMS\", per-material). Tapping a spool opens a full-screen detail view with spool icon, remaining bar, AMS assignment, weight breakdown, slicer filament, PA K-profiles (name and value), temperature range, cost, tag ID, and notes. Detail view updates live from query data. Assigned spools sort first. When Spoolman is enabled, the page shows the Spoolman UI instead.\n- **SpoolBuddy Auto-Navigate on Tag Scan** — When an NFC tag is detected while the SpoolBuddy UI is on a non-dashboard page (Settings, AMS, Write Tag, etc.), the frontend automatically navigates back to the main dashboard to show the scanned spool. Also wakes the screen if the display was blanked.\n- **SpoolBuddy Swipe to Switch Printers** — Swiping left/right on the SpoolBuddy touchscreen now cycles through online printers instead of triggering browser back/forward navigation. The selected printer updates in the top bar dropdown. Requires at least two online printers; single-printer setups are unaffected.\n- **SpoolBuddy Virtual Keyboard Layout Fix** — The virtual keyboard now participates in the flex layout instead of overlaying as a fixed element. When the keyboard opens, the bottom nav and status bar are hidden and the content area shrinks to fit, eliminating the dead space gap between content and keyboard on the Inventory page. Number inputs (e.g. Weight field on Write Tag) now accept virtual keyboard input.\n- **Removed Diagnostic Buttons from Write Tag Page** — Removed the \"NFC Diag\" and \"Scale Diag\" buttons from the NFC status panel on the Write Tag page. These diagnostics are accessible from the Settings page and don't belong on the tag writing flow.\n- **SpoolBuddy Assign Spool Modal No Longer Clips Display** — The shared Assign Spool modal overflowed off-screen on the small SpoolBuddy touchscreen, hiding the footer buttons. Added scoped CSS in the SpoolBuddy AMS page that caps the modal at 90vh with a scrollable spool list, without affecting the main Bambuddy frontend.\n- **SpoolBuddy System Tab** — Added a \"System\" tab to SpoolBuddy Settings showing live OS stats from the Raspberry Pi: CPU temperature, core count, load average, memory usage, disk usage, OS distro/kernel/architecture, Python version, and system uptime. Stats are collected by the daemon every heartbeat (10s) using stdlib-only reads from `/proc` and `/sys` — no additional dependencies required. Usage bars turn amber at 70% and red at 90%; CPU temperature is color-coded green/amber/red.\n- **SpoolBuddy Boot Splash Polished** — New splash image displays only the SpoolBuddy logo (removed Bambuddy branding) with green glow bloom, radial gradient background, light rays, and vignette. A generator script (`generate_splash.py`) is included for easy customization. Also reduced redundant initramfs rebuilds during install by deferring the rebuild until after the Plymouth theme is configured.\n\n### Security\n- **Token-Based Auth for Media Endpoints** — Camera streams, snapshots, thumbnails, timelapse videos, photos, QR codes, and cover images served via `<img>`/`<video>` tags now require a stream token query parameter (`?token=xxx`) when authentication is enabled. Previously these endpoints were unauthenticated because browser media elements cannot send `Authorization` headers. The frontend obtains a 60-minute reusable token via `POST /printers/camera/stream-token` (requires `CAMERA_VIEW` permission) and automatically appends it to all media URLs. Affects endpoints in camera, archives, library, printers, print-log, and external-links routes. When auth is disabled (default for local installs), behavior is unchanged — no token required.\n\n### Fixed\n- **Native Install Misdetected as Docker in LXC Containers** — The update check falsely identified native installs as Docker when running inside Proxmox LXC containers. The detection logic used `.git/` directory absence as a Docker fallback, but LXC containers may also lack `.git/` depending on how the install was deployed. Replaced the `.git/` fallback with a proper check of `/run/systemd/container` which only matches Docker/Podman/OCI runtimes, not LXC. Native installs in LXC containers now correctly show the in-app update button instead of Docker Compose instructions.\n- **Print Fails on Files With Spaces in Name** ([#824](https://github.com/maziggy/bambuddy/issues/824)) — Printing files with spaces in their filename (e.g. \"Junktion Box PRO 90.3mf\") caused the printer to silently ignore the print command and remain IDLE. The FTP upload succeeded, but the MQTT print command's `url` field (`ftp://file name.3mf`) contained unencoded spaces that the firmware couldn't parse. Fixed by replacing spaces with underscores in the remote filename before upload.\n- **SpoolBuddy Low Filament Warning Missing Slot Number** — The status bar low filament warning showed \"AMS B\" instead of the specific slot like \"B2\". Now uses `formatSlotLabel` to display the full slot label (e.g. \"Low Filament: PLA (B2) - 4% remaining\").\n- **SpoolBuddy Read Tag Diagnostic Fails on NTAG Tags** — The `read_tag.py` diagnostic script had five issues preventing NTAG reads: (1) SAK `0x04` (MIFARE Ultralight family) was rejected as \"unsupported tag type\" — now accepts both `0x00` and `0x04`. (2) `ntag_read_pages` had TX CRC off (should be on per NTAG spec), no Crypto1 clear, and no IDLE→TRANSCEIVE state reset. (3) The PN5180 enters an unrecoverable state after an NTAG READ command — added full GPIO hardware reset between each 4-page batch. (4) Reading past the end of smaller tags (MIFARE Ultralight has 16 pages vs NTAG's 44+) caused a hard failure — now returns partial data gracefully. (5) `ntag_write_page`/`ntag_write_pages` had the same stale CRC/state issues plus unreliable ACK checking and post-write verification — synced with daemon.\n- **Delete Tag Leaves Stale Tag Type** — The \"Delete Tag\" button in the spool edit modal only cleared `tag_uid` but left `tray_uuid`, `tag_type`, and `data_origin` intact. All tag-related fields are now cleared together.\n- **SpoolBuddy NFC Write Fails on NTAG Tags** — Multiple issues prevented writing to NTAG 213/215/216 tags. (1) Some chips report SAK `0x04` (MIFARE Ultralight family) instead of `0x00` during anticollision — both `0x00` and `0x04` are now accepted. (2) TX CRC was disabled for NTAG commands but the spec requires it — enabled for both WRITE and READ. (3) The PN5180 state machine needed IDLE→TRANSCEIVE resets (not just `set_transceive_mode()`) and Crypto1 cleared before NTAG operations. (4) The 4-bit WRITE ACK cannot be captured by the PN5180 (SOF detected but no RX_IRQ) — removed per-page ACK checking. (5) Post-write read-back verification also failed (second READ command gets no response from the PN5180) — removed verification since the tag reliably ACKs each write.\n- **Database Connection Pool Exhaustion on Large Printer Farms** — Users with 100+ printers connected simultaneously experienced `QueuePool limit of size 10 overflow 20 reached, connection timed out` errors. Increased the SQLAlchemy connection pool from 30 total (10 base + 20 overflow) to 220 (20 base + 200 overflow), and raised the SQLite busy_timeout from 5 to 15 seconds to reduce write contention under heavy concurrent MQTT updates.\n- **SpoolBuddy Update Check Always Shows \"Up to Date\"** — The SpoolBuddy daemon update check compared the device's firmware version against GitHub releases instead of the running Bambuddy backend version. This meant the check could incorrectly report \"up to date\" even when the daemon was behind. Fixed by comparing directly against `APP_VERSION` from the backend config.\n- **SpoolBuddy Updates Now Use SSH** — Replaced the fragile self-update mechanism (daemon pulls its own code via git, permission errors on `.git/`, hardcoded `main` branch) with SSH-based updates driven by the Bambuddy backend. Bambuddy now SSHes into the SpoolBuddy Pi and runs git fetch/checkout, pip install, systemctl restart, and kiosk browser restart remotely. Updates automatically use the same branch as Bambuddy. SSH key pairing is fully automatic — Bambuddy generates an ED25519 keypair and includes the public key in the device registration response; the daemon deploys it to `authorized_keys` on first connect. The install script creates the `spoolbuddy` user with a bash shell and sudoers entries for daemon and kiosk restart. A \"Force Update\" button allows re-deploying even when versions match. The SSH public key is also shown in SpoolBuddy Settings → Updates → SSH Setup for manual pairing if needed.\n- **Frontend Not Updating After Deploy** — The service worker used stale-while-revalidate for JS/CSS assets, serving the old cached bundle even after a new build was deployed. Changed to network-first for JS/CSS (Vite content-hashes filenames so cache-busting is built in), bumped SW cache version, and added `Cache-Control: no-cache` to the `sw.js` endpoint so browsers always pick up new service worker versions immediately. The SpoolBuddy kiosk now skips SW registration entirely and unregisters any existing SW — a touchscreen kiosk has no use for offline caching and it was the main source of stale frontend issues after updates.\n- **SpoolBuddy Kiosk Starts Before Network Is Ready** — On fresh installs, the kiosk browser launched before the network was fully up, showing a connection error for 10-15 seconds until connectivity was restored. The getty@tty1 autologin override now waits for `network-online.target` so Chromium has connectivity when it starts.\n- **SpoolBuddy Update UI Stale After Restart** — After a SpoolBuddy update, the UI permanently showed the old version and \"update available\" because: (1) the SSH update set status to `\"complete\"` after the daemon had already re-registered, overwriting the cleared state; (2) the kiosk restart navigated away from the updates page; (3) query cache served stale data. Fixed by letting daemon re-registration clear all update status, removing the kiosk restart in favor of a frontend-driven `window.location.reload()` triggered via WebSocket when the daemon comes back online, and adding proper loading states to Check/Force Update buttons.\n- **Virtual Printer Proxy A1 Printing Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — BambuStudio could not send prints to A1 (and potentially P1S) virtual printers in proxy mode. The slicer connects to undocumented proprietary ports 2024-2026 on these models, which the proxy was not forwarding, causing BambuStudio to show an access code dialog instead of printing. Added transparent TCP pass-through proxying for ports 2024-2026. These ports are silently ignored on models that don't use them (X1C, H2C, P2S). Also added ports 2024-2026 to the docker-compose.yml bridge-mode port mapping.\n- **Spool Assignment on Empty AMS Slots** ([#784](https://github.com/maziggy/bambuddy/issues/784)) — Empty AMS slots (no physical spool detected) showed \"Assign Spool\" and \"Configure\" buttons in the hover popup. Assigning a spool to an empty slot created a stuck state because no \"Unassign\" button is available for empty slots. Truly empty slots now hide both buttons, while slots with a spool inserted but filament not loaded still show configure/assign. Also fixed stale AMS slot data on H2D and other printers that only send `{id, state}` in incremental MQTT updates — filament load/unload transitions now update in real-time without requiring a reconnect.\n- **Spoolman Sidebar Opens Root URL Instead of Spool Page** — When Spoolman is enabled, clicking the Filament sidebar item embedded Spoolman at its root URL instead of the spool management page. The iframe now navigates to `<spoolman_url>/spool`.\n- **Log Flood: \"State is FINISH but completion NOT triggered\"** ([#790](https://github.com/maziggy/bambuddy/issues/790)) — A diagnostic log message introduced in 0.2.2.1 fired on every MQTT update while a printer sat in FINISH or FAILED state, flooding logs with thousands of lines per minute in printer farms. Fixed by only logging once on the initial state transition, and marking `_completion_triggered = True` when a terminal state is first seen without a prior RUNNING state so the flag is clean for the next print cycle.\n- **H2D External Spool Print Fails With \"Failed to get AMS mapping table\"** ([#797](https://github.com/maziggy/bambuddy/issues/797)) — Printing from an external spool on H2D (and H2D Pro) through Bambuddy failed with `0700_8012 \"Failed to get AMS mapping table\"`, while the same print worked fine from BambuStudio. Bambuddy was passing raw virtual tray IDs (254/255) in the flat `ams_mapping` array, but BambuStudio converts these to -1 and relies on `ams_mapping2` for external spool routing. The H2D firmware rejects raw 254/255 in the flat array. Also fixed the `ams_mapping2` format for external trays — each virtual tray is its own AMS unit with `slot_id: 0`, not a shared unit differentiated by slot.\n- **SpoolBuddy Scale First Reading Always Wrong** — The NAU7802 ADC always returns a stale max-scale value (`0x7FFFFF`) on its first conversion after power-up, which polluted the moving average and made the initial weight report wildly inaccurate. Fixed by flushing the first reading during `init()` so all subsequent reads return valid data. Also extracted both hardware drivers out of diagnostic scripts into proper modules — the NAU7802 scale driver from `scripts/scale_diag.py` into `daemon/nau7802.py`, and the PN5180 NFC driver from `scripts/read_tag.py` into `daemon/pn5180.py`. The production daemon was importing driver classes from test scripts since the original SpoolBuddy commit. Removed the now-unnecessary `sys.path` hack from `main.py`.\n- **ffmpeg Process Leak Causing Memory Growth** ([#776](https://github.com/maziggy/bambuddy/issues/776)) — Camera stream ffmpeg processes accumulated over time, consuming several GB of RAM. When a user closed the camera viewer, the frontend sent a stop signal that killed the ffmpeg process, but the backend stream generator interpreted the dead process as a dropped connection and respawned ffmpeg — up to 30 reconnection attempts per stream. The orphan cleanup couldn't catch these because they were tracked as \"active\". Fixed by signaling the generator's disconnect event from the stop endpoint before killing the process, checking for stream removal before reconnecting, and tracking frame timestamps per-stream instead of per-printer so stale detection works correctly when multiple streams exist. Reported by @ChrisTheDBA,\n\n## [0.2.2.1] - 2026-03-22\n\n### New Features\n- **SpoolBuddy OTA Updates** — SpoolBuddy devices can now be updated directly from the Settings → Updates tab without SSH access. Click \"Check for Updates\" to see if a newer version is available, then \"Apply Update\" to trigger the update. The daemon picks up the command via its heartbeat, pulls the latest code from GitHub, installs dependencies, and restarts automatically via systemd. Live progress is shown in the UI with status messages from the device. The status bar at the bottom automatically checks for updates every 5 minutes and shows a prominent message when one is available. Requires the device to be online.\n- **Select Plates to Queue** ([#777](https://github.com/maziggy/bambuddy/issues/777)) — Multi-plate 3MF files now support selecting a subset of plates to queue, instead of only \"one plate\" or \"all plates\". In add-to-queue mode, each plate has a checkbox for multi-select, with a \"Select All / Deselect All\" toggle. Reprint and edit modes remain single-select. Requested by @stringham.\n- **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.\n- **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new \"Notifications\" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the \"User Notifications\" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.\n\n### Fixed\n- **SpoolBuddy Daemon Reports Stale Version** — The SpoolBuddy daemon maintained its own hardcoded `__version__` that was never bumped to `0.2.3b1`, causing the update check to incorrectly show an update from `0.2.2b1` to the latest release. Fixed by reading the version at import time from the backend's `APP_VERSION` in `backend/app/core/config.py` — the single source of truth — so the daemon version is always in sync.\n- **SpoolBuddy Update Columns Missing from Database** — The OTA update feature added `update_status` and `update_message` to the device model but was missing the database migration, causing \"no such column\" errors on existing installations.\n- **Queue Print Command Not Reaching Printer** ([#778](https://github.com/maziggy/bambuddy/issues/778)) — When a queue item targeted a specific printer and the scheduler's power-on-wait loop triggered, each reconnection attempt created a new MQTT client that re-attempted subscribing to the request topic. On printers whose broker rejects this subscription (e.g. A1), this caused repeated connect/disconnect cycles for up to 170 seconds, leaving the MQTT connection in a fragile state where the print command could silently fail to reach the printer. Fixed by caching request topic support state per serial number at the class level, so new client instances skip the subscription immediately instead of rediscovering the rejection. Reported by @RubenKremer.\n- **Stale MQTT Connection Not Recovering** ([#813](https://github.com/maziggy/bambuddy/issues/813)) — When a printer's MQTT connection went stale (no messages for 60+ seconds), Bambuddy marked it as disconnected but did not force the underlying TCP socket closed, so paho-mqtt's auto-reconnect never triggered and print commands were silently published into a dead connection. Fixed by force-closing the socket on stale detection so paho's loop thread detects the break and auto-reconnects. The initial fix caused rapid connected/disconnected bouncing in the UI because frontend status polls triggered repeated socket force-closes before paho could finish reconnecting; added a 30-second cooldown between stale reconnect attempts so paho has time to re-establish the connection. Also uses a flag to suppress the redundant disconnect callback broadcast. Relaxed MQTT keepalive from 15s to 30s — the aggressive 15s keepalive caused spurious disconnects on transient network hiccups. Added reconnect backoff (1-30s) and unique-per-process MQTT client IDs to prevent broker session takeovers. Error disconnects (`rc.is_failure`) are never suppressed by the spurious-disconnect filter. The disconnect event used by `disconnect()` is fired unconditionally at the top of the callback so that no early-return filter can prevent it from unblocking callers. Reported by @inkdawgz.\n- **P1S/P1P Printer Card Shows \"Printing\" When Idle** ([#813](https://github.com/maziggy/bambuddy/issues/813)) — Some P1S and P1P firmware versions report `stg_cur=0` when idle, which maps to the \"Printing\" stage name and overrides the correct \"Idle\" gcode_state on the printer card. The System Info page was unaffected because it displays the raw gcode_state. Extended the existing A1/A1 Mini workaround for this firmware bug to also cover P1S and P1P models. Reported by @inkdawgz.\n- **AMS Slot Search Shows Unrelated Profiles** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Searching for a non-existent filament profile in the AMS slot configuration showed unrelated profiles instead of an empty result. The saved preset bypassed the search filter entirely, so stale mappings (e.g. a slot previously configured with \"Bambu PLA Matte\" that now holds a Silk spool) would always appear regardless of the search query. The saved preset now only bypasses the printer model filter, not the search filter. Reported by @RosdasHH.\n- **Virtual Printer FTP Routed to Wrong VP** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — When running multiple virtual printers with different access codes on separate bind IPs, FTP connections were routed to the wrong VP. Root cause: the iptables `REDIRECT` rule rewrites the destination IP to the incoming interface's primary address, so all FTP traffic went to the first VP regardless of the intended target. Fix: FTP server now binds directly to port 990 (standard implicit FTPS), eliminating the need for iptables redirect. Requires `CAP_NET_BIND_SERVICE` (already set in the systemd service and Docker image). Also removed a global `set_exception_handler()` in the MQTT server that caused spurious error messages when running multiple VPs. See `docs/migration-vp-ftp-port.md` for migration steps. Reported by @VREmma.\n- **X1C Virtual Printer Not Accepting Sends** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — X1C (and X1) virtual printers were advertised with legacy SSDP model codes (`3DPrinter-X1-Carbon` / `3DPrinter-X1`) that BambuStudio doesn't recognize, causing \"incompatible printer preset\" when sending. Fixed to use the correct codes (`BL-P001` / `BL-P002`). Also fixed proxy mode auto-inherit storing the printer's display name (e.g. `X1C`) instead of the SSDP code. Existing VPs are automatically migrated on startup. Reported by @RosdasHH.\n- **White Filament Color Swatches Invisible in Light Theme** ([#726](https://github.com/maziggy/bambuddy/issues/726)) — Filament color circles used a white border that was invisible against light theme backgrounds, making white spools indistinguishable. Changed to a dark border (`border-black/20`) across all views: Inventory, Archives, Assign Spool, Configure AMS Slot, Calendar, Projects, Filament Trends, Local Profiles, Link Spool, and Spoolman Settings. Reported by user.\n- **Camera Window Overlapping Modals** ([#738](https://github.com/maziggy/bambuddy/issues/738)) — Floating camera viewer rendered on top of modals (e.g. Assign Spool), making them unusable. Lowered camera z-index so modals always appear above it. Reported by @maziggy.\n- **Print Complete Notification Not Firing** ([#736](https://github.com/maziggy/bambuddy/issues/736)) — Print complete notifications could silently fail if the finish photo capture hung or timed out, because the notification was chained behind the photo task with no timeout. Added a 45-second timeout so notifications always send even if photo capture stalls. Also added diagnostic logging for MQTT state detection to trace completion triggers. Reported by @piatho.\n- **Webhook Notifications Missing Camera Snapshot** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Webhook notification providers did not include camera snapshots (e.g. from First Layer Complete notifications), even though providers like Telegram, Pushover, ntfy, and Discord already attached them. The webhook payload now includes a base64-encoded `image` field when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz.\n- **Mobile Sidebar Not Scrollable** — On mobile devices with many navigation items, the sidebar did not scroll, making bottom items unreachable. Added overflow scrolling to the nav section while keeping the logo and footer pinned.\n- **User Notification Ruff/Lint Fixes** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — Fixed missing `timezone` import in email timestamp, unused lambda argument, PEP 8 blank line spacing for `mark_printer_stopped_by_user`, and SQLAlchemy forward reference in `UserEmailPreference` model.\n- **Carbon Rod Lubrication Maintenance Task Incorrect** ([#755](https://github.com/maziggy/bambuddy/issues/755)) — X1/P1 series printers showed a \"Lubricate Carbon Rods\" maintenance task, but carbon rods use plain bearings and should never be lubricated — doing so degrades print quality. Removed the lubrication task; only \"Clean Carbon Rods\" remains. Existing \"Lubricate Carbon Rods\" entries are automatically removed on next startup. Reported by @RosdasHH.\n- **Ntfy Notifications Fail With Non-ASCII Characters** ([#742](https://github.com/maziggy/bambuddy/issues/742)) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The `Title` and `Message` HTTP headers were passed as Python strings, causing httpx to reject them with `UnicodeEncodeError`. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user.\n- **Virtual Printer Proxy Mode Printing Fails on Isolated Networks** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (`rtsp_url`, `net.info[].ip`), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding transparent TCP proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298.\n- **Virtual Printer Proxy Mode X1C/X1 Print Upload Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — X1C and X1 printers failed to upload prints through proxy mode. After FTP verify_job succeeded (226), BambuStudio's closed-source `bambu_networking` DLL silently refused to proceed with the actual 3MF upload, showing a login modal instead. Root cause: the DLL validates the TLS connection parameters and rejects connections where the certificate doesn't match the printer's real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's own \"Virtual Printer CA\" certificate, which the DLL rejected. Fixed by switching to transparent TCP proxying for FTP (port 990), FileTransfer (port 6000), Camera (port 322), and FTP passive data (ports 50000–50100) — raw bytes are forwarded without TLS termination, so the slicer gets end-to-end TLS directly with the printer's real certificate. Only MQTT (port 8883) remains TLS-terminated, which is required to rewrite the printer's real IP with the proxy's bind IP in MQTT payloads. Confirmed working on both H2D and X1C printers.\n- **UserEmailPreference Model Not Registered** — The `UserEmailPreference` SQLAlchemy model was not imported in `models/__init__.py`, causing mapper initialization failures when the `User` model's relationship resolved the string reference before the model class was registered with Base metadata.\n- **Native Install Missing CAP_NET_BIND_SERVICE** — The `install.sh` systemd service template was missing `AmbientCapabilities=CAP_NET_BIND_SERVICE`, causing Virtual Printer proxy mode to silently fail to bind privileged ports (322, 990) on native installations.\n- **Virtual Printer Proxy A1 Diagnostics** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — Added diagnostic port probing (ports 21, 80, 443) on proxy VP bind IPs to detect if BambuStudio tries to connect on ports the proxy doesn't handle. Logs a warning when an unexpected connection is detected. Helps diagnose A1/A1 Mini proxy issues where the slicer may use a different connection flow.\n- **File Rename Removes Extension** ([#751](https://github.com/maziggy/bambuddy/issues/751)) — Renaming a file in the File Manager included the file extension in the editable text, so users could accidentally remove it (e.g. renaming `bracket.gcode.3mf` to `bracket`), making the file unprintable. The rename modal now only lets users edit the base name, with the extension shown as a non-editable suffix. Reported by @fleishmaab, confirmed by @cadtoolbox.\n- **Spurious \"Job Waiting for Filament\" Notification** ([#753](https://github.com/maziggy/bambuddy/issues/753)) — When all printers of a model were busy and a job was queued with ASAP timing, a \"Job Waiting for Filament\" notification fired immediately even though no filament issue existed. The job was simply waiting for a printer to finish. The scheduler now skips the waiting notification when all matching printers are just busy, since the job will auto-start when one finishes. Also renamed the default notification title from \"Job Waiting for Filament\" to \"Queue Job Waiting\" to accurately reflect all waiting reasons. Reported by @maziggy.\n- **AMS Spools Removed After Printer Restart** ([#765](https://github.com/maziggy/bambuddy/issues/765)) — AMS spool assignments and slot configurations were lost after restarting the printer. When the printer shuts down, it sends a final MQTT message with `tray_exist_bits=0` and `power_on_flag=false`, which caused Bambuddy to clear all AMS slot data and auto-unlink every spool assignment. On reconnect, the assignments were gone. Fixed by skipping `tray_exist_bits` slot clearing when `power_on_flag` is `false` (shutdown message), preserving AMS data across printer restarts. Reported by @Woyteck1.\n\n### Community Contributions\n- **Admin Set Default Nav-Menu Order** ([#761](https://github.com/maziggy/bambuddy/pull/761)) — Admins with authentication enabled can now set their current sidebar menu order as the default for new users. New users inherit this layout on first login and can customize it afterward. Contributed by @cadtoolbox.\n- **Improve Home Assistant Notifications** ([#750](https://github.com/maziggy/bambuddy/pull/750)) — Added support for Home Assistant `notify` services in addition to the existing REST-based integration. Contributed by @mrtncode.\n- **Add Total Cost to Projects** ([#733](https://github.com/maziggy/bambuddy/pull/733)) — The Projects page now shows a total cost that sums material, energy, and BOM costs. Contributed by @Keybored02.\n- **Material Mismatch & Insufficient Filament Checks** ([#720](https://github.com/maziggy/bambuddy/pull/720)) — When assigning non-Bambu Lab spools, a warning prompts if the filament type or profile doesn't match. Pre-print checks now also warn when the spool has insufficient material. Both warnings are dismissible, with a toggle in Settings. Contributed by @Keybored02.\n- **Send Bambu RFID Tags to Spoolman & Manual Mode Unlink** ([#719](https://github.com/maziggy/bambuddy/pull/719)) — Bambu Lab spool RFID identifiers (tray UUID) are now sent to Spoolman instead of generic placeholder tags. An \"Unlink\" button appears on Bambu spools when Spoolman is in manual sync mode. Fixed location clearing for generic spools during sync. Contributed by @shrunbr.\n- **Rework Archive Duplicates Tagging** ([#718](https://github.com/maziggy/bambuddy/pull/718)) — Duplicate detection now requires both matching filename and SHA256 hash. The tag shows reprint count instead of \"Duplicate\" text, links back to the parent print, and a new \"Hide Duplicates\" filter is available. Contributed by @Keybored02.\n\n### Added\n- **Quick Print Speed Control** ([#256](https://github.com/maziggy/bambuddy/issues/256)) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper.\n- **Spool Rotation During AMS Drying** — Added a \"Rotate spool during drying\" checkbox to the manual drying popover for AMS 2 Pro and AMS-HT units. Rotates the spool for more even heat distribution. Off by default; resets when opening the popover for a different AMS unit. The firmware silently disables rotation if filament is currently loaded from the unit.\n- **Spool Name Column & Filter in Filament Inventory** ([#740](https://github.com/maziggy/bambuddy/issues/740)) — Added a \"Spool\" column to the filament inventory table that displays the spool catalog entry name (e.g. \"Bambu Lab AMS Tray\", \"Sunlu 1kg\"). Enable it via the column visibility menu. Sortable and hidden by default. Also added a spool name filter dropdown next to the brand filter for quick filtering by spool type. Requested by @DMoenning.\n\n### Changed\n- **Redesigned Bug Report Debug Log Flow** — Replaced the fixed 30-second debug log collection with an interactive 3-step flow: start debug logging, reproduce the issue at your own pace, then stop & submit. An elapsed timer shows recording duration with auto-stop at 5 minutes. Users now have full control over when to capture logs instead of racing a countdown. The backend splits log collection into separate start/stop endpoints, and the frontend shows a step progress indicator with pulsing active state.\n\n### Improved\n- **HMS Error Visibility on Printers Page** ([#772](https://github.com/maziggy/bambuddy/issues/772)) — Improved visibility of printers with HMS errors for large print farms. Added a red \"Problem\" counter to the status summary bar showing how many connected printers have active HMS errors. The compact-mode status pip (colored dot) now turns red for fatal/serious errors (severity ≤ 2) or amber for common warnings, instead of only showing connection status. Progress bars turn amber when a print is paused. Sorting by status now places printers with HMS errors at the top, above printing and idle printers. Requested by @jimmy-brightz.\n- **Print Command Response Verification** ([#737](https://github.com/maziggy/bambuddy/issues/737)) — After sending a print command, BambuBuddy now monitors whether the printer's state changes within 15 seconds. If the printer silently ignores the command (observed on some P1S firmware versions where the MQTT command handler becomes unresponsive), a warning is logged for diagnostics. This aids debugging when users report prints not starting despite BambuBuddy showing success.\n- **Compact Assign Spool Modal** ([#725](https://github.com/maziggy/bambuddy/issues/725)) — The \"Assign Spool\" modal now uses a compact 3-column grid layout instead of a vertical list, showing more spools at once without scrolling. Each card displays the spool name, color, and remaining/total weight. The modal is wider with a taller scroll area. Requested by @RosdasHH.\n- **Reformatted AMS Drying Presets Table** ([#732](https://github.com/maziggy/bambuddy/issues/732)) — The drying presets table in Settings now groups columns by AMS type (AMS 2 Pro, AMS-HT) with inline °C and h unit labels next to each input, replacing the previous flat column layout. Requested by @cadtoolbox.\n\n### Security\n- **Bump pyOpenSSL 25.3.0 → 26.0.0** — Fixes CVE-2026-27448 (exception swallowing in TLS servername callback) and CVE-2026-27459 (buffer overflow in DTLS cookie callback).\n- **Bump pyasn1 0.6.2 → 0.6.3** — Fixes CVE-2026-30922 (stack overflow from deeply nested ASN.1 structures).\n- **Bump flatted 3.4.1 → 3.4.2** — Fixes GHSA-rf6f-7fwh-wjgh (prototype pollution via `parse()`). Dev-only dependency (eslint).\n\n\n## [0.2.2] - 2026-03-16\n\n### New Features\n- **First Layer Complete Notification** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Get notified with a camera snapshot when the first layer finishes printing, so you can check adhesion remotely without watching the whole print. Enable the \"First Layer Complete\" toggle on any notification provider. Fires once per print when layer 2 begins (confirming layer 1 is done), with a guard against spurious triggers on printer reconnect. Requested by community.\n- **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. A flame icon appears on supported AMS cards; clicking it opens a popover to select filament type (PLA, PETG, TPU, ABS, ASA, PA, PC, PVA) with official BambuStudio temperature/duration presets, or set temperature manually. When drying is active, a status bar shows the time remaining with a live countdown and stop button. Supported on X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, and X1E. Not supported on P2S, A1, A1 Mini, H2S, or H2C. Requires `printers:control` permission when authentication is enabled.\n- **Queue Auto-Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically dry filament between scheduled queue prints. When enabled in Settings → Print Queue, the scheduler starts drying on idle printers that have upcoming scheduled prints and whose AMS humidity exceeds the configured threshold. Uses conservative parameters (lowest temperature, longest duration) when mixed filament types are loaded. Drying stops automatically when humidity drops below threshold (with a 30-minute minimum to prevent oscillation), when scheduled items are removed, or when the feature is disabled. Optional \"block queue\" mode delays the next print until drying completes.\n- **Configurable Drying Presets** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Customize temperature and duration for each filament type in Settings → Print Queue. Defaults match BambuStudio presets (PLA 55°C/8h, PETG 65°C/8h, etc.) and are used by both the manual drying popover and queue auto-drying. AMS 2 Pro and AMS-HT use separate presets reflecting their different heating capabilities.\n- **AMS PSU Detection** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — The drying button is disabled with a tooltip when the AMS lacks sufficient power for drying (e.g. not connected to the external PSU). Reads `dry_sf_reason` from printer firmware and surfaces HMS error codes for AMS 2 Pro and AMS-HT power issues.\n- **Ambient Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically keep filament dry on idle printers based on humidity, even without queued prints. Enable \"Ambient drying\" in Settings → Print Queue to have the scheduler start drying on any idle printer whose AMS humidity exceeds the configured threshold — no scheduled prints required. Uses the same humidity threshold, drying presets, and power constraint detection as queue auto-drying. Both modes can be enabled simultaneously. Requested by community.\n- **Assign Spool to Empty AMS Slot** ([#717](https://github.com/maziggy/bambuddy/issues/717)) — Previously, the \"Assign Spool\" button only appeared on AMS slots that already had a filament profile configured, requiring users to first configure the slot manually before assigning an inventory spool — even though the assignment auto-configures the slot anyway. The \"Assign Spool\" option now appears on empty (unconfigured) slots as well. Selecting a spool auto-configures the slot with the correct filament profile, color, and K-profile in one step. Also fixed the AMS slot profile label showing the generic material type (e.g. \"PLA\") instead of the spool's actual slicer preset name (e.g. \"PolyLite PLA Pro\") after assignment. Requested by @RosdasHH.\n- **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting \"Home Assistant\" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.\n- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an \"Auto-dispatch\" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.\n- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a \"Queue All N Plates\" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.\n- **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.\n- **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. \"15:53\" or \"3:53 PM\") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration (\"1h 23m\"). Requested by @SebSeifert.\n- **Bulk Delete Spool and Color Catalog Entries** ([#646](https://github.com/maziggy/bambuddy/issues/646)) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click \"Delete Selected\" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.\n- **Force Color Match** ([#625](https://github.com/maziggy/bambuddy/pull/625)) — Added a \"Force Color Match\" option for \"Print to Any\" queue scheduling. When enabled, the scheduler requires a strict color match when assigning prints to printers, preventing incorrect filament assignments when multiple candidates are close in color. Prints wait in the queue until a printer with the exact matching filament is available. Contributed by @cadtoolbox.\n- **Israeli New Shekel Currency** — Added ILS (₪) to the list of supported currencies for filament cost tracking.\n- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label (e.g. \"AMS-A\") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.\n- **In-App Bug Reporting** — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.\n- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new \"Write\" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.\n- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb=\"false\"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.\n- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for \"Assign to AMS\", \"Sync Weight\", and \"Close\". Unknown tags show the tag UID, scale weight, and offer \"Add to Inventory\" or \"Link to Spool\" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.\n- **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: \"Ext\", dual nozzle: \"Ext-L\"/\"Ext-R\") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.\n- **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).\n- **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.\n- **Daily Beta Builds** — Added a release script (`docker-publish-daily-beta.sh`) that reads the current `APP_VERSION` from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., `0.2.2b1`) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as `latest`. Fixed auto-generated \"Contributors\" section appearing in GitHub release notes by stripping `@mentions` from changelog text before creating the release.\n- **Inventory Scale Weight Check Column** — Added a \"Weight Check\" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores `last_scale_weight` and `last_weighed_at` on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.\n\n### Fixed\n- **Library Upload Doesn't Show New File Until Page Reload** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — After uploading a file in the Library file manager, the file list didn't update until the user reloaded the browser. The upload endpoint used `db.flush()` instead of `db.commit()`, so the new row was only written to the database *after* the response was sent to the client. The frontend immediately refetched the file list upon receiving the response, but a new database session couldn't see the uncommitted row — resulting in stale data. Fixed by committing before the response is returned. Also fixed the same race condition in folder create, folder update, and file update endpoints. Reported by @shadowjig.\n- **Printer File Manager Doesn't Auto-Refresh** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — The printer file manager (SD card browser) only fetched the file list once when opened. Files uploaded from BambuStudio/OrcaSlicer while the modal was open wouldn't appear until the user clicked the refresh button or reopened the modal. Now auto-refreshes every 30 seconds while open. Reported by @shadowjig.\n- **Database Connection Pool Exhaustion Under Load** ([#704](https://github.com/maziggy/bambuddy/issues/704)) — Background tasks (print scheduler FTP uploads, camera captures, notification sends, timelapse stitching) held database sessions open during slow network I/O, consuming connection pool slots for seconds at a time. With the default pool of 15 connections (size 5 + overflow 10), concurrent operations during print start/complete events could exhaust the pool, causing `QueuePool limit reached` errors and `greenlet_spawn` failures in RFID spool auto-assignment. Doubled the pool to 30 connections (size 10 + overflow 20). Reported by @shadowjig.\n- **Block Mode Skips Humidity Auto-Stop** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — When \"Wait for drying to complete\" was enabled and a printer had pending queue items, the scheduler skipped the humidity auto-stop check entirely. A drying session that reached its humidity target would continue indefinitely instead of stopping after the 30-minute minimum. Now, block mode only prevents starting new drying — already-drying printers still have their humidity checked and stopped when the threshold is met.\n- **AMS Fill Level Shows 0% for Non-Viewer Users** ([#676](https://github.com/maziggy/bambuddy/issues/676)) — When authentication was enabled with advanced permissions, users with `inventory:view_assignments` permission saw 0% fill level on AMS slots where inventory spool data had stale `weight_used` values. The fill level fallback chain (Spoolman → Inventory → AMS remain) used nullish coalescing (`??`), which doesn't fall through on `0` — so a stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value from the printer. Now, when inventory says 0% but the AMS hardware reports a positive remain, the inventory value is bypassed in favor of the live AMS data. Viewer users were unaffected because their group lacked `inventory:view_assignments`, so the inventory query never fired and the AMS remain was used directly. Reported by @cadtoolbox.\n- **Virtual Printer Proxy Mode Always Shows X1C Model** — Creating a virtual printer in Proxy mode always set the model to X1C regardless of the destination printer, because the frontend hides the model dropdown in proxy mode and the backend defaulted to X1C. Now auto-inherits the model from the target printer when creating or updating a proxy virtual printer (e.g. a proxy pointing at a P1S correctly presents itself as P1S to the slicer). The model also auto-updates when changing the target printer or switching to proxy mode.\n- **Cloud Profiles Shared Across All Users** ([#665](https://github.com/maziggy/bambuddy/issues/665)) — When authentication was enabled, Bambu Cloud credentials were stored globally — one account per Bambuddy instance. If User A logged into Cloud, every other user saw User A's account and profiles. User B logging in would overwrite User A's credentials. Cloud credentials are now stored per-user: each user logs into their own Bambu Cloud account independently. When auth is disabled (single-user mode), behavior is unchanged. Also fixed cloud data endpoints (`/cloud/settings`, `/cloud/fields`, preset CRUD) requiring `settings:read` / `settings:update` permissions instead of `cloud:auth` — users who had \"Cloud Auth\" enabled but \"Settings\" disabled couldn't load profiles after logging in. Reported by @cadtoolbox.\n- **Local Profiles Not Shown in AMS Slot Configuration** — Imported local filament profiles were hidden in the AMS slot configure modal when a printer model was set. The `compatible_printers` filter parsed the stored JSON array as a semicolon-delimited string, so the matching always failed and every local preset was silently skipped. Removed the filter entirely — user-imported profiles should be available on any printer.\n- **Interface Aliases Not Shown in Virtual Printer Interface Select** — Interface aliases (e.g. `eth0:1`) added for multi-virtual-printer setups were invisible in the bind IP dropdown. The Docker image didn't include `iproute2`, so the `ip` command wasn't available and the code fell back to ioctl-based enumeration which can only return one IP per interface. Added `iproute2` to the Docker image.\n- **P2S Camera Stream Disconnects After a Few Seconds** ([#661](https://github.com/maziggy/bambuddy/issues/661)) — The P2S firmware drops RTSP sessions after a few seconds with an I/O error. Root cause: ffmpeg in the Docker image uses GnuTLS for TLS, and Debian's hardened GnuTLS defaults reject TLS behaviors (renegotiation, legacy ciphers) that some printer firmwares rely on. Added a local TLS termination proxy that uses Python's ssl module (OpenSSL) to handle the TLS connection to the printer, exposing a plain RTSP port to ffmpeg. The proxy rewrites RTSP request-line URLs while preserving Digest auth headers. Also reduced RTSP reconnect delay from 1.0s to 0.2s, added ffmpeg fast-start flags for lower startup latency, and fixed external camera streams being choppy due to double rate-limiting in the proxy layer. Reported by @ddetton, confirmed by @DMoenning.\n- **iOS/iPadOS Cannot Reposition Floating Camera** ([#687](https://github.com/maziggy/bambuddy/issues/687)) — The floating camera viewer (embedded camera window on the dashboard) could not be dragged or resized on iOS/iPadOS because it only handled mouse events. Touch input scrolled the page underneath instead of moving the camera window. Added touch event support (`touchstart`/`touchmove`/`touchend`) to both the header drag handle and the resize handle, with `preventDefault` to stop page scrolling during drag. Reported by @dsmitty166.\n- **PA-CF / PA12-CF / PAHT-CF Not Treated as Compatible** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — Bambu Lab firmware treats PA-CF, PA12-CF, and PAHT-CF as interchangeable, but the print scheduler and filament override UI used exact string matching. If a 3MF required PA-CF but the AMS had PA12-CF loaded, the scheduler wouldn't assign the job and the filament override dropdown was empty/disabled. Added a filament type equivalence system so these PA variants are treated as compatible in scheduler assignment, AMS slot matching, force color match validation, and the filament override dropdown. Reported by @aneopsy.\n- **Force Color Match Toggle Click Target Too Large** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — In the Schedule Print modal, clicking anywhere on the \"Force color match\" row toggled the checkbox, not just the checkbox and its label. The click target now covers only the checkbox, icon, and label text. Reported by @aneopsy.\n- **HA Switch Badge Always Sends Turn On Instead of Toggle** — Clicking a non-script Home Assistant entity (switch, light, input_boolean) on the printer card always sent `turn_on`, which is a no-op when the switch is already on. Now sends `toggle` for non-script entities so the badge click actually toggles the switch state. Script entities still use `turn_on` (stateless trigger).\n- **Multiple Plugs Per Printer Crashes Auto-On/Off** — When multiple smart plugs were assigned to the same printer (e.g., a Tasmota plug + an HA switch), the auto-on/auto-off handler called `scalar_one_or_none()` which raises `MultipleResultsFound`. Now fetches all plugs and returns the main (non-script) power plug, matching the API route behavior.\n- **Multiple HA Switches Per Printer UNIQUE Constraint** — The migration that removes the UNIQUE constraint on `smart_plugs.printer_id` (to allow multiple HA switches per printer) used an exact string match to detect the constraint in the SQLite schema. Databases created with older SQLAlchemy versions expressed the constraint differently (e.g. quoted column names, table-level `UNIQUE(printer_id)`, or separate indexes), so the migration silently skipped them. Users hit `IntegrityError: UNIQUE constraint failed` when assigning a second HA switch to a printer. Now uses regex pattern matching and also checks for standalone UNIQUE indexes.\n- **HMS Notifications for Unknown/Phantom Error Codes** — Printers send many undocumented or phantom HMS error codes that don't correspond to real errors (e.g. calibration status codes after firmware updates). These triggered email/push notifications even though the printer card correctly filtered them out. Flipped the notification logic from \"notify all, suppress specific codes\" to \"only notify for errors with known descriptions\", matching the frontend behavior. Also fixed the log message reporting incorrect notification counts.\n- **Ethernet Badge Shown on WiFi Printers / MQTT Disconnecting** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Three bugs in the ethernet badge feature: (1) `home_flag` bit 18 is set on all printers regardless of connection type, so every ethernet-capable model showed the ethernet badge even when connected via WiFi. Replaced bit 18 detection with wifi_signal-based heuristic: printers on ethernet with WiFi disabled report a hardcoded `-90 dBm` sentinel, while real WiFi signals vary. (2) The lazy import used `from app.utils.printer_models` which crashes with `ModuleNotFoundError` in paho-mqtt's background thread (correct path is `backend.app.utils.printer_models`). This killed the MQTT thread entirely, causing all printers to go stale after 60s and repeatedly disconnect/reconnect. (3) WiFi-only models (A1, P1P, etc.) that don't have an ethernet port are excluded via model-based gating. Reported by @cadtoolbox.\n- **Inventory Usage Tracker Missing External Spool Mapping** ([#677](https://github.com/maziggy/bambuddy/issues/677)) — When all higher-priority slot-to-tray mapping methods failed (MQTT mapping, print command mapping, queue mapping, color matching), the internal inventory usage tracker fell back to `slot_id - 1` which can never reach external spool IDs (254/255) or AMS-HT IDs (128+). Added position-based resolution using sorted available tray IDs from the printer's AMS state, matching the fix applied to Spoolman tracking in #686. Contributed by @shrunbr.\n- **Spool Assignment Applies Wrong Filament Profile** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Assigning a spool with a specific filament variant (e.g. \"Generic PLA Silk\") to an AMS slot applied the base profile instead (e.g. \"Generic PLA\"). The Bambu Cloud API returns only the base `filament_id` for versioned setting IDs (`GFSL99` → `GFL99`), ignoring variant suffixes (`GFSL99_01`). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g. `GFL99` → `GFL96` for \"Generic PLA Silk\"). Also fixed the UI showing a stale preset name (e.g. \"Bambu PLA Matte\" instead of \"Bambu PLA Silk\") after assignment — the slot preset mapping was only saved when assigning via SpoolBuddy, not via the PrintersPage hover card. The backend now saves the slot preset mapping using the spool's authoritative `slicer_filament_name` after every successful MQTT configuration, regardless of which UI path triggered the assignment. Reported by @peter-k-de, @RosdasHH.\n- **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.\n- **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.\n- **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.\n- **Clear Plate Prompt Shown for Staged Queue Items** — The \"Clear Plate & Start Next\" button on the printer card appeared when all pending queue items were staged (`manual_start`/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.\n- **Ethernet Badge Shown on WiFi-Only Printers** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — The printer card network badge always showed \"Ethernet\" even on printers without an ethernet port. WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating. Reported by @cadtoolbox.\n- **GitHub Backup Required Cloud Login** ([#655](https://github.com/maziggy/bambuddy/issues/655)) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing \"Bambu Cloud login required\" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The \"Cloud Profiles\" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.\n- **GitHub Backup Log Timestamps Off by 1 Hour** — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local `formatDateTime` function didn't use `parseUTCDate`, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared `parseUTCDate` utility for correct UTC-to-local conversion.\n- **H2D AMS Units Shown on Wrong Nozzle** ([#659](https://github.com/maziggy/bambuddy/issues/659)) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS `info` field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses `std::stoull(str, nullptr, 16)`), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the `info` field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (`0xE`), and merges partial updates into the existing map. Reported by @cadtoolbox.\n- **SD Card Error After FTP Upload** ([#645](https://github.com/maziggy/bambuddy/issues/645)) — After printing one file, subsequent prints could fail with `0500-C010 \"MicroSD Card read/write exception\"` until Bambuddy was restarted. The FTP upload used `transfercmd()` for A1 compatibility but skipped reading the server's 226 \"Transfer complete\" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89.\n- **P2S Shows Carbon Rod Maintenance Tasks** ([#640](https://github.com/maziggy/bambuddy/issues/640)) — The P2S was incorrectly classified as a carbon rod printer, showing \"Lubricate Carbon Rods\" and \"Clean Carbon Rods\" maintenance tasks. The P2S uses hardened steel linear shafts, not carbon fiber rods. Added a new `steel_rod` motion system category and \"Lubricate Steel Rods\" / \"Clean Steel Rods\" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy.\n- **Dispatch Toast Stuck After Second Print** — The print dispatch progress toast (\"Starting prints…\") stayed visible forever after the second print dispatch in a session. The dedup guard (`lastDispatchSummaryRef`) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key (`\"first-complete:1:0\"`). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in \"Processing\" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.\n- **Archive Card Buttons Overlapping at Narrow Widths** ([#641](https://github.com/maziggy/bambuddy/issues/641)) — The \"Reprint\" and \"Schedule\" buttons at the bottom of archive cards overlapped when the browser window was narrower than the card grid expected (e.g. snapped to half-screen on a 2K monitor). The button text labels used a viewport-based `sm:` breakpoint that didn't account for actual card width. Added `overflow-hidden` to the flex buttons and `truncate` to the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166.\n- **Debug Logging Banner Timer Shows Negative Time** — When enabling debug logging, the banner showed a negative duration (e.g. \"-60m -59s\") equal to the server's UTC offset. The `enabled_at` timestamp was stored using `datetime.now()` (local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC.\n- **Non-Bambu Lab Spools Can't Link/Unlink to Spoolman** ([#653](https://github.com/maziggy/bambuddy/pull/653)) — The \"Link to Spoolman\" button was not shown for non-Bambu Lab spools (which lack RFID tag UIDs). Now generates a fallback tag from the printer ID, AMS ID, and tray ID for spools without RFID identifiers. Also added an \"Unlink from Spoolman\" button for non-Bambu spools that are already linked. Contributed by @shrunbr.\n- **Spoolman Location Not Updated on Link/Unlink** ([#669](https://github.com/maziggy/bambuddy/pull/669)) — Linking a spool to Spoolman did not set the spool's location field. Now sets the Spoolman location to the printer name, AMS name, and slot number (e.g. \"P2S-1 - AMS-A 3\") when linking, and clears it when unlinking. Contributed by @shrunbr.\n- **Print Dispatch Toast Disappears Instantly on Fast Uploads** ([#615](https://github.com/maziggy/bambuddy/issues/615)) — When sending a print job, the notification popup disappeared instantly for small files or closed immediately when the progress bar reached 100% for larger files, giving no confirmation that the job was submitted. The dispatch toast now stays visible for 3 seconds after completion, showing a success message (e.g. \"1 print started successfully\") before auto-dismissing. For very fast uploads where the progress toast was never shown, a fresh confirmation toast is created instead. Reported by @aneopsy.\n- **Print Modal Shows Busy Printers as Selectable** ([#622](https://github.com/maziggy/bambuddy/issues/622)) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. \"Select all\" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.\n- **PWA Install Not Available in Chrome** ([#629](https://github.com/maziggy/bambuddy/issues/629)) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the `screenshots` entries required for Chrome's richer install UI. Resized all three icons (`android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`) to their declared sizes, split the discouraged `\"any maskable\"` purpose into a dedicated `\"maskable\"` entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.\n- **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with `status=\"completed\"` (actually printed via a printer) now count toward completion stats. Files with `status=\"archived\"` (stored but not yet printed) are no longer included. Reported by @SebSeifert.\n- **Python 3.10 Compatibility** — Bambuddy failed to start on Python 3.10 with `ImportError: cannot import name 'StrEnum' from 'enum'` because `enum.StrEnum` was added in Python 3.11. Added a compatibility shim that falls back to `(str, Enum)` on Python < 3.11, matching the documented requirement of Python 3.10+.\n- **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.\n- **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.\n- **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.\n- **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.\n- **Filament Mapping Dropdowns Missing Subtypes** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — All filament mapping dropdowns (single-printer, multi-printer, and \"Print to Any\" model-based assignment) showed only the base material type (e.g., \"PLA\") without the subtype (e.g., \"PLA Basic\", \"PLA Matte\"). This made it impossible to distinguish between different filament variants of the same color. Now shows `tray_sub_brands` (e.g., \"PLA Basic\", \"PLA Matte\", \"PETG HF\") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes `tray_sub_brands` in the dedup key, so \"PLA Basic Black\" and \"PLA Matte Black\" appear as separate entries instead of collapsing into duplicate \"PLA (Black)\" rows. Reported by @cadtoolbox.\n- **Archive Card Shows \"Source\" Badge for Sliced .3mf Files** — Archive cards created from prints showed a \"SOURCE\" badge instead of \"GCODE\" when the filename was a plain `.3mf` (without `.gcode` in the name). The `isSlicedFile()` check only matched `.gcode` or `.gcode.3mf` extensions, but `.3mf` files can be either sliced (contains gcode) or raw source models. Now checks the archive's `total_layers` and `print_time_seconds` metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).\n- **AMS Slot Shows Wrong Material for \"Support for\" Profiles** — Configuring an AMS slot with a filament profile like \"PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle\" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match (\"PLA\"), ignoring that \"PLA Support for PETG\" means the filament type is PETG. Both the frontend `parsePresetName()` and backend `_parse_material_from_name()` now detect the \"X Support for Y\" naming pattern and extract the material after \"Support for\". The frontend also prefers the corrected parsed material over the stored `filament_type` (which may have been saved with the old parser during import).\n- **Firmware Check Shows Wrong Version for H2D Pro** ([#584](https://github.com/maziggy/bambuddy/issues/584)) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., \"H2D\", \"H2D Pro\") but not SSDP device codes (e.g., \"O1E\", \"O2D\"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.\n- **Spurious Error Notifications During Normal Printing (0300_0002)** — Some firmware versions send non-zero `print_error` values in MQTT during normal printing (e.g., `0x03000002` → short code `0300_0002`). The `print_error` parser treated any non-zero value as a real error, appending it to `hms_errors` and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= `0x4000` (`0x4xxx` = fatal, `0x8xxx` = warning/pause, `0xCxxx` = prompt). Values below `0x4000` are status/phase indicators, not faults. Now skips values where the error portion is below `0x4000` in both the `print_error` and `hms` array parsers.\n- **Spool Auto-Assign Fails With Greenlet Error** ([#612](https://github.com/maziggy/bambuddy/issues/612)) — RFID spool auto-assignment logged `WARNING greenlet_spawn has not been called; can't call await_only() here` and silently failed. The `Spool.assignments` relationship was never eagerly loaded: when `auto_assign_spool()` created a new `SpoolAssignment` and called `db.add()`, SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitialized `spool.assignments` collection. The previous fix only covered `spool.k_profiles`. Now also initializes `spool.assignments = []` on newly created spools in `create_spool_from_tray()`, and adds `selectinload(Spool.assignments)` to both queries in `get_spool_by_tag()` for existing spools. Added `exc_info=True` to the error handlers for full tracebacks in future logs.\n- **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's \"Link to Spool\" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.\n- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT `remain` field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from `(label_weight - weight_used) / label_weight`, falling back to AMS remain when no inventory assignment exists.\n- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has `id=255`, and the idle sentinel `tray_now=255` matched it via `trayNow === extTrayId`. The main printer card avoided this by clearing `effectiveTrayNow` to `undefined` when `tray_now=255`. Now guards against `tray_now=255` before any ext slot active check.\n- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle \"Ready to Print\" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads \"Paused\" instead of the hardcoded \"Printing\" fallback.\n- **SpoolBuddy \"Assign to AMS\" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's \"Assign to AMS\" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.\n- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an \"invalid frame\", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.\n- **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `\"aborted\"` on the queue item, but the response schema only accepts `\"pending\"`, `\"printing\"`, `\"completed\"`, `\"failed\"`, `\"skipped\"`, or `\"cancelled\"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. \"pending\") excluded the bad row and worked fine. Now normalises `\"aborted\"` to `\"cancelled\"` before storing. A startup fixup also converts any existing `\"aborted\"` rows.\n- **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status=\"completed\")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.\n- **Virtual Printer Config Changes Ignored Until Toggle Off/On** — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted. `sync_from_db()` skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.\n- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).\n- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.\n- **Windows Install Fails With \"Syntax of the Command Is Incorrect\"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` Python hash verification used a multi-line `for /f \"usebackq\"` with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited `for /f` commands, causing \"The syntax of the command is incorrect\" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but `verify_sha256` had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the `amd64` checksum even on `arm64` systems.\n- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for \"any [model]\", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows \"Clear Plate & Start\") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.\n- **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.\n- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said \"need cross-task communication\" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard.\n- **A1 Mini Shows \"Unknown\" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show \"unknown\", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors=\"replace\")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.\n- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.\n- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Three support package fixes. First, the network section included full subnet addresses (e.g., `192.168.192.0/24`); now masks the first two octets (`x.x.192.0/24`). Second, `network_mode_hint` used `len(interfaces) > 2` which always reported \"bridge\" on single-NIC hosts even with `network_mode: host`, because `get_network_interfaces()` excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (`docker0`, `br-*`, `veth*`) via `socket.if_nameindex()` — these are only visible when the container shares the host network namespace. Third, `developer_mode` was still null for most users because the MQTT `fun` field was only parsed inside the `print` key; some firmware versions send it at the top level of the payload. Now also checks top-level `fun`. Also added a `virtual_printers` section with mode, model, enabled/running status, and pending file count for each configured virtual printer.\n- **SpoolBuddy Scale Calibration Lost After Reboot** — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface `Path.iterdir()` returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick `eth0` (MAC ending `3100`) or `wlan0` (MAC ending `3102`), producing a different `device_id` each time. Since calibration values (`tare_offset`, `calibration_factor`) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.\n- **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false \"tag removed\" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.\n\n### Changed\n- **CI: Node.js 20 → 22** — Updated GitHub Actions workflows (`ci.yml`, `security.yml`) from Node.js 20 to Node.js 22 LTS ahead of [GitHub's Node 20 deprecation](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/).\n- **Daily Builds Falsely Trigger Update Notification** — The version parser misclassified daily build tags (e.g. `0.2.2b4-daily.20260313`) as full releases instead of betas, because the `-daily.YYYYMMDD` suffix pushed the last dot-segment to a pure number (`20260313`), bypassing the prerelease detection. Users running the same beta version saw a spurious \"update available\" notification after each daily build. Now strips the daily suffix before parsing.\n- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.\n- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.\n\n### Improved\n- **Shorter Inventory Location Labels** — The location column in the Inventory table now shows compact labels like \"H2D-1 B3\" instead of \"H2D-1 AMS-B Slot 3\". External spool holders show \"Ext\" instead of \"External\". AMS-HT labels remain unchanged (\"HT-A\").\n- **Higher FTP Timeout Options for Large Files** ([#660](https://github.com/maziggy/bambuddy/issues/660)) — Added 180s and 300s FTP timeout options in Settings. The previous maximum of 120s was insufficient for large 3MF files (e.g. 28 MB Hueforge models) which can't be downloaded from the printer's FTP server within 2 minutes, especially during active printing. Reported by @PasDoe.\n- **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. \"Update_Own\" → \"Update Own\"). Reported by @Minebuddy.\n- **Prometheus Build Info Metric** ([#633](https://github.com/maziggy/bambuddy/pull/633)) — Added a `bambuddy_build_info` gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus `_build_info` convention for dashboards and version-change alerting. Contributed by @sw1nn.\n- **i18n: Settings, Smart Plugs, Notifications, Backup/Restore** — Replaced all hardcoded English strings with translation keys (`t()` calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (`PROVIDER_LABELS`, `EVENT_LABELS`, `CATEGORY_LABELS`) in favor of dynamic translation key lookups with fallbacks.\n- **Install Script: Branch Selection** — The native install script (`install.sh`) now supports a `--branch` option and an interactive branch prompt (defaults to `main`). Previously the script hardcoded `origin/main`, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs use `git clone --branch`, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a \"(beta)\" label. Invalid branch names are caught early with an error message listing available branches.\n- **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged \"found N pending items\" with no visibility into why items were skipped.\n- **SpoolBuddy Settings Page Redesign** — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.\n- **SpoolBuddy Language & Time Format Support** — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a `language` field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs `i18n.changeLanguage()`. The top bar clock uses `formatTimeOnly()` with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).\n- **SpoolBuddy Kiosk Stability** — Disabled Chromium's swipe-to-navigate gesture (`--overscroll-history-navigation=0`) in the install script to prevent accidental back-navigation on the touchscreen. Added the `video` group to the SpoolBuddy system user for DSI backlight access.\n- **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.\n- **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers connected via ethernet now show a green \"Ethernet\" badge with a cable icon instead of the WiFi signal strength indicator. Detected via `home_flag` bit 18 from the printer's MQTT data. The printer info modal also shows \"Ethernet\" instead of WiFi signal details.\n- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.\n- **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.\n- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from \"Online\" to \"Backend\" for clarity.\n- **SpoolBuddy Assign to AMS Redesign** — The \"Assign to AMS\" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.\n- **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like \"GFL05\" or setting_id format like \"GFSL05_07\") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.\n- **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections (\"Printer Firmware\" and \"Bambuddy Software\") separated by a divider, making it clear which toggles control what.\n- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).\n- **Cleanup Obsolete Settings** — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g., `slicer_binary_path` from earlier slicer integration research).\n- **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.\n- **FTP Upload Progress & Speed** — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload `voidresp()` wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.\n- **Wider Print & Schedule Modals** — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., \"PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle\").\n\n### Security\n- **Stored XSS via Project Notes** — Project notes were rendered with `dangerouslySetInnerHTML` without sanitization, allowing injected `<script>` or event handler payloads to execute in any viewer's browser and steal JWT tokens from localStorage. Now sanitized with DOMPurify before rendering.\n- **Stored XSS via 3MF Description (Sanitizer Bypass)** — The hand-rolled HTML sanitizer in the Project Page modal reconstructed `<a>` tags by interpolating the `href` attribute without escaping embedded quotes. A crafted 3MF file with a single-quoted `href` containing a double-quote break-out could inject `onmouseover` event handlers through the sanitizer. Replaced the custom sanitizer with DOMPurify.\n- **Unauthenticated Auth Toggle via Setup Endpoint** — The `/api/v1/auth/setup` endpoint could be called without authentication even when auth was already enabled, allowing any network client to disable authentication entirely. Now returns 403 when auth is already enabled; use the authenticated admin panel to modify auth settings.\n- **PyJWT ≥2.12.0** — Bumped minimum version to address CVE-2026-32597.\n- **flatted ≥3.4.0** — Updated transitive ESLint dependency to address GHSA-25h7-pfq9-p65f (unbounded recursion DoS).\n- **Access Code Redacted from Support Logs** — Printer access codes embedded in RTSP stream URLs were not redacted in support bundles and bug report logs. Extended the URL credential sanitizer to cover `rtsps://` URLs and added access codes to the sensitive string collection for exact-match redaction.\n\n## [0.2.2b3] - 2026-03-12\n\n### New Features\n- **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting \"Home Assistant\" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.\n- **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an \"Auto-dispatch\" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.\n- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a \"Queue All N Plates\" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.\n- **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.\n- **ETA Variable in Notifications** ([#638](https://github.com/maziggy/bambuddy/issues/638)) — Added `{eta}` template variable to print start, print progress, and queue job started notifications. Shows the estimated wall-clock completion time (e.g. \"15:53\" or \"3:53 PM\") based on the user's configured time format (12h/24h). Existing `{estimated_time}` still shows duration (\"1h 23m\"). Requested by @SebSeifert.\n- **Bulk Delete Spool and Color Catalog Entries** ([#646](https://github.com/maziggy/bambuddy/issues/646)) — Added checkbox selection and bulk delete to both the Spool Catalog and Color Catalog in Settings > Filament. Select individual entries with checkboxes, use the header checkbox to select/deselect all visible entries, then click \"Delete Selected\" to remove them in one operation. Previously, entries could only be deleted one at a time. Requested by @SebSeifert.\n- **Force Color Match** ([#625](https://github.com/maziggy/bambuddy/pull/625)) — Added a \"Force Color Match\" option for \"Print to Any\" queue scheduling. When enabled, the scheduler requires a strict color match when assigning prints to printers, preventing incorrect filament assignments when multiple candidates are close in color. Prints wait in the queue until a printer with the exact matching filament is available. Contributed by @cadtoolbox.\n- **Israeli New Shekel Currency** — Added ILS (₪) to the list of supported currencies for filament cost tracking.\n\n### Changes\n- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.\n\n### Improved\n- **Shorter Inventory Location Labels** — The location column in the Inventory table now shows compact labels like \"H2D-1 B3\" instead of \"H2D-1 AMS-B Slot 3\". External spool holders show \"Ext\" instead of \"External\". AMS-HT labels remain unchanged (\"HT-A\").\n- **Higher FTP Timeout Options for Large Files** ([#660](https://github.com/maziggy/bambuddy/issues/660)) — Added 180s and 300s FTP timeout options in Settings. The previous maximum of 120s was insufficient for large 3MF files (e.g. 28 MB Hueforge models) which can't be downloaded from the printer's FTP server within 2 minutes, especially during active printing. Reported by @PasDoe.\n- **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. \"Update_Own\" → \"Update Own\"). Reported by @Minebuddy.\n- **Prometheus Build Info Metric** ([#633](https://github.com/maziggy/bambuddy/pull/633)) — Added a `bambuddy_build_info` gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus `_build_info` convention for dashboards and version-change alerting. Contributed by @sw1nn.\n\n### Fixed\n- **Beta Updates Shown When Disabled** ([#731](https://github.com/maziggy/bambuddy/issues/731)) — Daily beta builds (e.g. `v0.2.3b1-daily.20260316`) were offered as updates even with \"Include beta versions\" toggled off. The version parser only checked the last dot-separated segment for prerelease markers, but daily build tags put the beta indicator (`b1`) earlier with a numeric date suffix as the last segment. Now checks the entire version string. Reported by @Teolhyn.\n- **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.\n- **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.\n- **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.\n- **Clear Plate Prompt Shown for Staged Queue Items** — The \"Clear Plate & Start Next\" button on the printer card appeared when all pending queue items were staged (`manual_start`/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.\n- **Ethernet Badge Shown on WiFi-Only Printers** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — The printer card network badge always showed \"Ethernet\" even on printers without an ethernet port. WiFi-only models (A1, P1P, etc.) are now excluded via model-based gating. Reported by @cadtoolbox.\n- **GitHub Backup Required Cloud Login** ([#655](https://github.com/maziggy/bambuddy/issues/655)) — The GitHub backup settings card was completely blocked behind Bambu Cloud authentication, showing \"Bambu Cloud login required\" even though the backup feature works without it (K-profiles and app settings don't need cloud). Removed the cloud auth gate so GitHub backup can be configured and used without Bambu Cloud. The \"Cloud Profiles\" checkbox is disabled with a hint when not logged in. Reported by @TravisWilder.\n- **GitHub Backup Log Timestamps Off by 1 Hour** — Backup log timestamps in the history table were displayed in UTC instead of the user's local timezone. The local `formatDateTime` function didn't use `parseUTCDate`, so timezone-less timestamps from SQLite were interpreted as local time. Now uses the shared `parseUTCDate` utility for correct UTC-to-local conversion.\n- **H2D AMS Units Shown on Wrong Nozzle** ([#659](https://github.com/maziggy/bambuddy/issues/659)) — On the H2D dual-nozzle printer, AMS units were displayed on the wrong nozzle (e.g. both AMS-HT and AMS2 Pro shown on the left nozzle instead of their correct assignments). Three interrelated bugs in the AMS `info` field parsing: (1) the field was parsed as decimal instead of hexadecimal (BambuStudio uses `std::stoull(str, nullptr, 16)`), (2) the extruder ID was extracted as a single bit instead of a 4-bit field, and (3) partial MQTT updates overwrote the full extruder map instead of merging. Now correctly hex-parses the `info` field, extracts the 4-bit extruder ID from bits 8-11, skips uninitialized AMS units (`0xE`), and merges partial updates into the existing map. Reported by @cadtoolbox.\n- **SD Card Error After FTP Upload** ([#645](https://github.com/maziggy/bambuddy/issues/645)) — After printing one file, subsequent prints could fail with `0500-C010 \"MicroSD Card read/write exception\"` until Bambuddy was restarted. The FTP upload used `transfercmd()` for A1 compatibility but skipped reading the server's 226 \"Transfer complete\" response, leaving the SD card file write unconfirmed. The print command was sent via MQTT before the printer's FTP server had finished flushing the file to disk. Now waits for the 226 confirmation after each upload (with a 60-second timeout for slower models like H2D). Reported by @lanfi89, confirmed by @Bademeister89.\n- **P2S Shows Carbon Rod Maintenance Tasks** ([#640](https://github.com/maziggy/bambuddy/issues/640)) — The P2S was incorrectly classified as a carbon rod printer, showing \"Lubricate Carbon Rods\" and \"Clean Carbon Rods\" maintenance tasks. The P2S uses hardened steel linear shafts, not carbon fiber rods. Added a new `steel_rod` motion system category and \"Lubricate Steel Rods\" / \"Clean Steel Rods\" maintenance tasks specific to the P2S. X1/P1 series continue to show carbon rod tasks; A1/H2 series continue to show linear rail tasks. Reported by @maziggy.\n- **Dispatch Toast Stuck After Second Print** — The print dispatch progress toast (\"Starting prints…\") stayed visible forever after the second print dispatch in a session. The dedup guard (`lastDispatchSummaryRef`) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key (`\"first-complete:1:0\"`). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in \"Processing\" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.\n- **Archive Card Buttons Overlapping at Narrow Widths** ([#641](https://github.com/maziggy/bambuddy/issues/641)) — The \"Reprint\" and \"Schedule\" buttons at the bottom of archive cards overlapped when the browser window was narrower than the card grid expected (e.g. snapped to half-screen on a 2K monitor). The button text labels used a viewport-based `sm:` breakpoint that didn't account for actual card width. Added `overflow-hidden` to the flex buttons and `truncate` to the text spans so labels clip cleanly with ellipsis instead of bleeding into adjacent buttons. Reported by rsocko@outlook.com, confirmed by @dsmitty166.\n- **Debug Logging Banner Timer Shows Negative Time** — When enabling debug logging, the banner showed a negative duration (e.g. \"-60m -59s\") equal to the server's UTC offset. The `enabled_at` timestamp was stored using `datetime.now()` (local time, no timezone indicator), but the frontend interpreted it as UTC. Now stores and compares all debug logging timestamps in UTC.\n- **Non-Bambu Lab Spools Can't Link/Unlink to Spoolman** ([#653](https://github.com/maziggy/bambuddy/pull/653)) — The \"Link to Spoolman\" button was not shown for non-Bambu Lab spools (which lack RFID tag UIDs). Now generates a fallback tag from the printer ID, AMS ID, and tray ID for spools without RFID identifiers. Also added an \"Unlink from Spoolman\" button for non-Bambu spools that are already linked. Contributed by @shrunbr.\n- **Spoolman Location Not Updated on Link/Unlink** ([#669](https://github.com/maziggy/bambuddy/pull/669)) — Linking a spool to Spoolman did not set the spool's location field. Now sets the Spoolman location to the printer name, AMS name, and slot number (e.g. \"P2S-1 - AMS-A 3\") when linking, and clears it when unlinking. Contributed by @shrunbr.\n\n\n## [0.2.2b2] - 2026-03-06\n\n### New Features\n- **AMS Info Card & Custom Labels** ([#570](https://github.com/maziggy/bambuddy/pull/570)) — Hovering an AMS label (e.g. \"AMS-A\") on the Printers page now shows a popover with serial number, firmware version, and an editable friendly name. Custom labels are stored by AMS serial number so they persist when the unit is moved to a different printer. Slot numbers are now displayed inside each filament color circle with auto-inverted contrast for readability. Labels also appear in the Inventory page's location column. Contributed by @cadtoolbox.\n\n### Changes\n- **License changed from MIT to AGPL-3.0** — To prevent unauthorized redistribution of Bambuddy as a closed-source product. All existing contributions were made under MIT, which is forward-compatible with AGPL-3.0. Community contributions and usage are unaffected.\n\n### Improved\n- **i18n: Settings, Smart Plugs, Notifications, Backup/Restore** — Replaced all hardcoded English strings with translation keys (`t()` calls) across the Settings page, Smart Plug components (SmartPlugCard, AddSmartPlugModal, SwitchbarPopover), Notification components (NotificationProviderCard, AddNotificationModal, NotificationTemplateEditor, NotificationLogViewer), and Backup/Restore components (GitHubBackupSettings, RestoreModal). Added ~600 new translation keys to all 7 supported locales (en, de, ja, fr, it, pt-BR, zh-CN). Removed hardcoded label maps (`PROVIDER_LABELS`, `EVENT_LABELS`, `CATEGORY_LABELS`) in favor of dynamic translation key lookups with fallbacks.\n- **Install Script: Branch Selection** — The native install script (`install.sh`) now supports a `--branch` option and an interactive branch prompt (defaults to `main`). Previously the script hardcoded `origin/main`, so beta testers told to install from a beta branch would silently get the stable release instead. Fresh installs use `git clone --branch`, existing installs checkout and reset to the selected branch. The install summary highlights non-main branches in yellow with a \"(beta)\" label. Invalid branch names are caught early with an error message listing available branches.\n- **Print Queue Scheduler Diagnostics** ([#616](https://github.com/maziggy/bambuddy/issues/616)) — Added diagnostic logging to the print queue scheduler to help diagnose why queued prints aren't starting. After each queue check, the scheduler now logs a skip summary (how many items were skipped due to manual_start, scheduled_time, etc.) and for each busy printer, logs the exact state preventing it from being considered idle (connected status, printer state, plate_cleared flag). Previously the scheduler only logged \"found N pending items\" with no visibility into why items were skipped.\n\n### Fixed\n- **Print Dispatch Toast Disappears Instantly on Fast Uploads** ([#615](https://github.com/maziggy/bambuddy/issues/615)) — When sending a print job, the notification popup disappeared instantly for small files or closed immediately when the progress bar reached 100% for larger files, giving no confirmation that the job was submitted. The dispatch toast now stays visible for 3 seconds after completion, showing a success message (e.g. \"1 print started successfully\") before auto-dismissing. For very fast uploads where the progress toast was never shown, a fresh confirmation toast is created instead. Reported by @aneopsy.\n- **Print Modal Shows Busy Printers as Selectable** ([#622](https://github.com/maziggy/bambuddy/issues/622)) — When printing a file from the file manager, the print modal listed all printers including busy ones. Selecting a busy printer resulted in a failed send notification. The printer selector now fetches each printer's live status and shows a state badge (Idle, Printing, Paused, Preparing, Finished, Failed, Offline). In reprint mode, busy printers are grayed out and not selectable. \"Select all\" also skips busy printers. In queue mode, busy printers remain selectable since the job will wait. Reported by contact@aito3d.fr.\n- **PWA Install Not Available in Chrome** ([#629](https://github.com/maziggy/bambuddy/issues/629)) — Chrome did not show the PWA install prompt because the manifest icons had incorrect dimensions (e.g. 190px wide declared as 192px) and the manifest was missing the `screenshots` entries required for Chrome's richer install UI. Resized all three icons (`android-chrome-192x192.png`, `android-chrome-512x512.png`, `apple-touch-icon.png`) to their declared sizes, split the discouraged `\"any maskable\"` purpose into a dedicated `\"maskable\"` entry, and added mobile and desktop screenshots to the manifest. Reported by @SebSeifert.\n- **Project Statistics Count Archived Files as Printed** ([#630](https://github.com/maziggy/bambuddy/issues/630)) — Files added to a project from the archive were counted in project statistics (completed prints, parts progress) as if they had already been printed. Only files with `status=\"completed\"` (actually printed via a printer) now count toward completion stats. Files with `status=\"archived\"` (stored but not yet printed) are no longer included. Reported by @SebSeifert.\n- **Python 3.10 Compatibility** — Bambuddy failed to start on Python 3.10 with `ImportError: cannot import name 'StrEnum' from 'enum'` because `enum.StrEnum` was added in Python 3.11. Added a compatibility shim that falls back to `(str, Enum)` on Python < 3.11, matching the documented requirement of Python 3.10+.\n- **Bug Report Bubble Overlapping Toasts** — Moved toast notifications and upload progress up so they stack above the bug report bubble instead of overlapping on top of each other.\n- **Virtual Printer: Bind-TLS Proxy Handshake Failure on OpenSSL 3.x** — The TLS proxy connecting to the printer's bind port (3002) failed with `SSLV3_ALERT_HANDSHAKE_FAILURE` on systems with OpenSSL 3.x (e.g. Python 3.12+) because the default cipher set excludes plain RSA key exchange, which is the only mode Bambu printers support. Added `AES256-GCM-SHA384` and `AES128-GCM-SHA256` to the client SSL context's cipher list.\n- **Windows: Server Shuts Down After 60 Seconds** ([#605](https://github.com/maziggy/bambuddy/issues/605)) — On Windows, terminating orphaned ffmpeg camera processes broadcast `CTRL_C_EVENT` to the entire process group, causing uvicorn to interpret it as a user-initiated shutdown. ffmpeg is now spawned in its own process group (`CREATE_NEW_PROCESS_GROUP`) so cleanup no longer affects the server. Reported by @Reactantvr.\n- **Multi-Printer Filament Mapping Shows Wrong Nozzle Filaments on Dual-Nozzle Printers** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — When selecting multiple printers for a print job on dual-nozzle printers (H2D), the per-printer filament mapping override dropdown showed filaments from both nozzles instead of only the correct nozzle for each slot. The single-printer filament mapping (FilamentMapping.tsx) was fixed in v0.2.1 to filter by `nozzle_id`, but the multi-printer path (InlineMappingEditor in PrinterSelector.tsx) was missed. Both the auto-match logic and the dropdown options now filter by `nozzle_id`, matching the single-printer behavior. Reported by @cadtoolbox.\n- **Filament Mapping Dropdowns Missing Subtypes** ([#624](https://github.com/maziggy/bambuddy/issues/624)) — All filament mapping dropdowns (single-printer, multi-printer, and \"Print to Any\" model-based assignment) showed only the base material type (e.g., \"PLA\") without the subtype (e.g., \"PLA Basic\", \"PLA Matte\"). This made it impossible to distinguish between different filament variants of the same color. Now shows `tray_sub_brands` (e.g., \"PLA Basic\", \"PLA Matte\", \"PETG HF\") in all filament dropdowns, falling back to the base type when no subtype is set. The backend's available-filaments endpoint also includes `tray_sub_brands` in the dedup key, so \"PLA Basic Black\" and \"PLA Matte Black\" appear as separate entries instead of collapsing into duplicate \"PLA (Black)\" rows. Reported by @cadtoolbox.\n\n## [0.2.2b1] - 2026-03-03\n\n### Improved\n- **SpoolBuddy Settings Page Redesign** — Redesigned the SpoolBuddy settings page with a tabbed layout (Device, Display, Scale, Updates). The Device tab shows an About section, NFC reader info (type, connection, status), device info (host, IP, uptime, online status), and device ID. The Display tab has a brightness slider (CSS software filter for HDMI displays) and screen blank timeout selector (Off, 1m, 2m, 5m, 10m, 30m) — the screen blanks after user inactivity (no touch) and wakes on tap. The Scale tab shows live weight with a step-indicator calibration wizard (tare → place known weight → calibrate). The Updates tab shows the daemon version and checks for updates against GitHub releases with optional beta inclusion. Display settings (brightness + blank timeout) are stored per-device in the backend and applied instantly in the frontend layout via outlet context.\n- **SpoolBuddy Language & Time Format Support** — The SpoolBuddy kiosk now respects Bambuddy's configured UI language and time format. Added a `language` field to backend app settings so the UI language is persisted server-side (previously only stored in browser localStorage, inaccessible to the kiosk's separate Chromium instance). The SpoolBuddy layout fetches settings on load and syncs `i18n.changeLanguage()`. The top bar clock uses `formatTimeOnly()` with the user's time format setting (system/12h/24h). Added full SpoolBuddy settings translations for all 6 supported languages (English, German, French, Japanese, Italian, Portuguese).\n- **SpoolBuddy Kiosk Stability** — Disabled Chromium's swipe-to-navigate gesture (`--overscroll-history-navigation=0`) in the install script to prevent accidental back-navigation on the touchscreen. Added the `video` group to the SpoolBuddy system user for DSI backlight access.\n- **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.\n- **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers connected via ethernet now show a green \"Ethernet\" badge with a cable icon instead of the WiFi signal strength indicator. Detected via `home_flag` bit 18 from the printer's MQTT data. The printer info modal also shows \"Ethernet\" instead of WiFi signal details.\n\n### New Features\n- **In-App Bug Reporting** — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.\n- **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new \"Write\" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.\n- **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb=\"false\"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.\n- **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for \"Assign to AMS\", \"Sync Weight\", and \"Close\". Unknown tags show the tag UID, scale weight, and offer \"Add to Inventory\" or \"Link to Spool\" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.\n- **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: \"Ext\", dual nozzle: \"Ext-L\"/\"Ext-R\") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.\n- **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and printer status badges below (compact pills with green/gray dots for online/offline, wrapping to fit without scrolling); right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card. Unknown NFC tags now offer a quick-add modal that creates a basic PLA spool entry linked to the tag — with a hint recommending users add spools via the main Bambuddy UI first for full details. The separate SpoolBuddy inventory page was removed since inventory management belongs in the main Bambuddy frontend; the bottom nav now has three tabs (Dashboard, AMS, Settings).\n- **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.\n- **Daily Beta Builds** — Added a release script (`docker-publish-daily-beta.sh`) that reads the current `APP_VERSION` from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., `0.2.2b1`) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as `latest`.\n- **Inventory Scale Weight Check Column** — Added a \"Weight Check\" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores `last_scale_weight` and `last_weighed_at` on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.\n\n### Fixed\n- **Archive Card Shows \"Source\" Badge for Sliced .3mf Files** — Archive cards created from prints showed a \"SOURCE\" badge instead of \"GCODE\" when the filename was a plain `.3mf` (without `.gcode` in the name). The `isSlicedFile()` check only matched `.gcode` or `.gcode.3mf` extensions, but `.3mf` files can be either sliced (contains gcode) or raw source models. Now checks the archive's `total_layers` and `print_time_seconds` metadata — if either is present, the file is sliced. Also passes the original human-readable filename when creating archives from the file manager print flow (previously stored the UUID library filename).\n- **AMS Slot Shows Wrong Material for \"Support for\" Profiles** — Configuring an AMS slot with a filament profile like \"PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle\" set the slot material to PLA instead of PETG. The name parser iterated material types in order and returned the first match (\"PLA\"), ignoring that \"PLA Support for PETG\" means the filament type is PETG. Both the frontend `parsePresetName()` and backend `_parse_material_from_name()` now detect the \"X Support for Y\" naming pattern and extract the material after \"Support for\". The frontend also prefers the corrected parsed material over the stored `filament_type` (which may have been saved with the old parser during import).\n- **Firmware Check Shows Wrong Version for H2D Pro** ([#584](https://github.com/maziggy/bambuddy/issues/584)) — H2D Pro printers showed firmware as out of date because the firmware check matched against the H2D firmware track instead of the H2D Pro track. The firmware check's model-to-API-key mapping only had display names (e.g., \"H2D\", \"H2D Pro\") but not SSDP device codes (e.g., \"O1E\", \"O2D\"). Added all known SSDP model codes to the firmware check mapping so raw device codes resolve to the correct firmware track.\n- **Spurious Error Notifications During Normal Printing (0300_0002)** — Some firmware versions send non-zero `print_error` values in MQTT during normal printing (e.g., `0x03000002` → short code `0300_0002`). The `print_error` parser treated any non-zero value as a real error, appending it to `hms_errors` and triggering notifications — even though the printer was printing fine. All known real HMS error codes have their low 16 bits >= `0x4000` (`0x4xxx` = fatal, `0x8xxx` = warning/pause, `0xCxxx` = prompt). Values below `0x4000` are status/phase indicators, not faults. Now skips values where the error portion is below `0x4000` in both the `print_error` and `hms` array parsers.\n- **Spool Auto-Assign Fails With Greenlet Error** ([#612](https://github.com/maziggy/bambuddy/issues/612)) — RFID spool auto-assignment logged `WARNING greenlet_spawn has not been called; can't call await_only() here` and silently failed. The `Spool.assignments` relationship was never eagerly loaded: when `auto_assign_spool()` created a new `SpoolAssignment` and called `db.add()`, SQLAlchemy resolved the FK back-populates synchronously (outside the async greenlet), triggering a lazy load on the uninitialized `spool.assignments` collection. The previous fix only covered `spool.k_profiles`. Now also initializes `spool.assignments = []` on newly created spools in `create_spool_from_tray()`, and adds `selectinload(Spool.assignments)` to both queries in `get_spool_by_tag()` for existing spools. Added `exc_info=True` to the error handlers for full tracebacks in future logs.\n- **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's \"Link to Spool\" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.\n- **SpoolBuddy AMS Page Missing Fill Levels for Non-BL Spools** — AMS slots with non-Bambu Lab spools assigned to inventory didn't show fill level bars on the SpoolBuddy AMS page, even though the main printer card displayed them correctly. The SpoolBuddy AMS page only used the MQTT `remain` field (which is -1/unknown for non-BL spools), while the printer card had a fallback chain: Spoolman → inventory → AMS remain. Now fetches inventory spool assignments and computes fill levels from `(label_weight - weight_used) / label_weight`, falling back to AMS remain when no inventory assignment exists.\n- **SpoolBuddy AMS Page Ext-R Slot Falsely Shown as Active When Idle** — On dual-nozzle printers (H2D), the Ext-R slot was incorrectly highlighted as active when the printer was idle. The ext-R tray has `id=255`, and the idle sentinel `tray_now=255` matched it via `trayNow === extTrayId`. The main printer card avoided this by clearing `effectiveTrayNow` to `undefined` when `tray_now=255`. Now guards against `tray_now=255` before any ext slot active check.\n- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle \"Ready to Print\" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads \"Paused\" instead of the hardcoded \"Printing\" fallback.\n- **SpoolBuddy \"Assign to AMS\" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's \"Assign to AMS\" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.\n- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an \"invalid frame\", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.\n- **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `\"aborted\"` on the queue item, but the response schema only accepts `\"pending\"`, `\"printing\"`, `\"completed\"`, `\"failed\"`, `\"skipped\"`, or `\"cancelled\"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. \"pending\") excluded the bad row and worked fine. Now normalises `\"aborted\"` to `\"cancelled\"` before storing. A startup fixup also converts any existing `\"aborted\"` rows.\n- **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status=\"completed\")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.\n- **Virtual Printer Config Changes Ignored Until Toggle Off/On** — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted. `sync_from_db()` skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.\n- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).\n- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.\n- **Windows Install Fails With \"Syntax of the Command Is Incorrect\"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` Python hash verification used a multi-line `for /f \"usebackq\"` with a backtick-delimited command split across lines. Windows CMD cannot parse line breaks inside backtick-delimited `for /f` commands, causing \"The syntax of the command is incorrect\" immediately after downloading Python. The entire block was also redundant — it downloaded a separate checksum file from python.org and re-verified the hash, but `verify_sha256` had already checked the archive against the pinned hash on the previous line. Removed the duplicate verification block. Also had a secondary bug: always downloaded the `amd64` checksum even on `arm64` systems.\n- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for \"any [model]\", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows \"Clear Plate & Start\") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.\n- **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.\n- **SpoolBuddy Scale Tare & Calibration Not Applied** — The SpoolBuddy scale tare and calibrate buttons on the Settings page queued commands but never executed them. Five bugs in the chain: (1) the daemon received the `tare` command via heartbeat but never called `scale.tare()` — a comment said \"need cross-task communication\" but the ScaleReader was already available in the shared dict; (2) no API endpoint existed for the daemon to report the new tare offset back to the backend database, so tare results were lost; (3) when calibration values changed in heartbeat responses, the daemon updated its config object but never called `scale.update_calibration()`, so the ScaleReader kept using its initial values forever; (4) the heartbeat response that delivered the tare command still contained pre-tare calibration values, which immediately overwrote the new tare offset back to zero; (5) the `set-factor` endpoint computed `calibration_factor` using the DB `tare_offset`, which could be stale or zero if the tare hadn't persisted yet — producing a wildly wrong factor (e.g., 5000g displayed with empty scale). Added a `POST /devices/{device_id}/calibration/set-tare` endpoint and `update_tare()` API client method. The heartbeat loop now executes `scale.tare()` when the tare command is received, persists the result via the new endpoint, propagates calibration changes to the ScaleReader instance, and skips calibration sync on the heartbeat cycle that delivers a tare command. The calibration flow now captures the raw ADC at tare time and sends it alongside the loaded-weight ADC in step 2, so the factor is computed from the actual tare reference rather than the DB value — making calibration self-contained and independent of the tare persistence round-trip. The calibration weight input uses a compact touch-friendly numpad since the RPi kiosk has no physical keyboard.\n- **A1 Mini Shows \"Unknown\" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show \"unknown\", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors=\"replace\")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.\n- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.\n- **Support Package Leaks Full Subnet IPs and Misdetects Docker Network Mode** — Three support package fixes. First, the network section included full subnet addresses (e.g., `192.168.192.0/24`); now masks the first two octets (`x.x.192.0/24`). Second, `network_mode_hint` used `len(interfaces) > 2` which always reported \"bridge\" on single-NIC hosts even with `network_mode: host`, because `get_network_interfaces()` excludes Docker infrastructure interfaces. Now checks for the presence of Docker interfaces (`docker0`, `br-*`, `veth*`) via `socket.if_nameindex()` — these are only visible when the container shares the host network namespace. Third, `developer_mode` was still null for most users because the MQTT `fun` field was only parsed inside the `print` key; some firmware versions send it at the top level of the payload. Now also checks top-level `fun`. Also added a `virtual_printers` section with mode, model, enabled/running status, and pending file count for each configured virtual printer.\n- **SpoolBuddy Scale Calibration Lost After Reboot** — The SpoolBuddy daemon generated its device ID from the MAC address of whichever network interface `Path.iterdir()` returned first, but filesystem iteration order is non-deterministic. On different boots, the daemon could pick `eth0` (MAC ending `3100`) or `wlan0` (MAC ending `3102`), producing a different `device_id` each time. Since calibration values (`tare_offset`, `calibration_factor`) are stored per device ID in the backend database, a new ID meant registering as a brand-new uncalibrated device. Fixed by sorting network interfaces alphabetically before selection, ensuring the same interface (and thus the same device ID) is always chosen.\n- **SpoolBuddy NFC Reader Fails to Detect Tags** — The PN5180 NFC reader had two polling issues. First, each `activate_type_a()` call that returned `None` (no tag) corrupted the PN5180 transceive state — subsequent calls silently failed even when a tag was physically present, making it impossible to detect tags placed after startup (only tags already on the reader during init were detected). Fixed by performing a full hardware reset (RST pin toggle + RF re-init, ~240ms) before every idle poll, giving a ~1.8 Hz effective poll rate. Second, after a successful SELECT the card stayed in ACTIVE state and ignored subsequent WUPA/REQA, causing false \"tag removed\" events after ~1 second. Fixed with a light RF off/on cycle (13ms) before each poll when a tag is present, resetting the card to IDLE for re-selection. Also added error-based auto-recovery (full hardware reset after 10 consecutive poll exceptions), periodic status logging every 60 seconds, and accurate heartbeat reporting of NFC/scale health.\n\n### Improved\n- **SpoolBuddy AMS Page Single-Slot Card Layout** — AMS-HT and external spool cards on the SpoolBuddy AMS page now use a responsive grid (2 cards per AMS card width) instead of auto-sized flex items, so they align with the regular AMS card columns above. Regular AMS cards no longer stretch vertically to fill available space on printers with fewer AMS units.\n- **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.\n- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo. Renamed the connection status label from \"Online\" to \"Backend\" for clarity.\n- **SpoolBuddy Assign to AMS Redesign** — The \"Assign to AMS\" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.\n- **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like \"GFL05\" or setting_id format like \"GFSL05_07\") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.\n- **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections (\"Printer Firmware\" and \"Bambuddy Software\") separated by a divider, making it clear which toggles control what.\n- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).\n- **Cleanup Obsolete Settings** — The startup migration now deletes orphaned settings keys from the database that are no longer used by the application (e.g., `slicer_binary_path` from earlier slicer integration research).\n- **Added HUF Currency** ([#579](https://github.com/maziggy/bambuddy/issues/579)) — Added Hungarian Forint (HUF, Ft) to the supported currencies list for filament cost tracking.\n- **FTP Upload Progress & Speed** — Reduced FTP upload chunk size from 1MB to 64KB for smoother progress reporting — at typical printer FTP speeds (~50-100KB/s) the progress bar now updates roughly every second instead of appearing stuck for 20+ seconds between jumps. Removed the post-upload `voidresp()` wait for all printer models (previously only skipped for A1); H2D printers delay the FTP 226 acknowledgment by 30+ seconds after data transfer completes, causing a long hang at 100%. The data is already on the SD card once the transfer finishes. Also added transfer speed logging (KB/s) and PASV+TLS handshake timing to help diagnose slow connections.\n- **Wider Print & Schedule Modals** — Increased the Print and Schedule Print modal width from 512px to 672px to better accommodate long filament profile names (e.g., \"PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle\").\n\n## [0.2.1.1] - 2026-02-28\n\n\n### Fixed\n- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.\n- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).\n- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.\n- **Windows Install Fails With \"Syntax of the Command Is Incorrect\"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has `core.autocrlf=false` or `input`, the file is checked out with LF endings and `cmd.exe` cannot parse it. Added a `.gitattributes` file that forces CRLF for all `.bat` files regardless of git config.\n- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for \"any [model]\", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows \"Clear Plate & Start\") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.\n- **A1 Mini Shows \"Unknown\" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show \"unknown\", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors=\"replace\")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.\n\n## [0.2.1] - 2026-02-27\n\n### Fixed\n- **Timezone-Aware Datetime Comparisons Crash With SQLite** — The 0.2.1 timezone fix (`datetime.now(timezone.utc)`) produced aware datetimes, but SQLAlchemy's SQLite `DateTime` columns return naive datetimes on read. Any Python-side comparison between the two raised `TypeError: can't subtract offset-naive and offset-aware datetimes`, crashing the maintenance overview endpoint and potentially 7 other code paths (API key expiration, smart plug auto-off, power alert cooldown, runtime tracking, print scheduling, and timelapse matching). Added `tzinfo is None` guards before all database datetime comparisons.\n- **FTP Proxy Cannot Bind to Port 990 in Docker** — The `cap_add: NET_BIND_SERVICE` in docker-compose.yml didn't reliably propagate to the Python process when running as a non-root user (`user:` directive), depending on the container runtime's ambient capability support. Now sets the file capability directly on the Python binary in the Dockerfile via `setcap`, which the kernel honors regardless of runtime configuration.\n- **AMS History Chart Shows Wrong Time Range** ([#535](https://github.com/maziggy/bambuddy/issues/535)) — The AMS temperature/humidity chart X axis was fitted to only the data points present (`dataMin`/`dataMax`), not the selected time window. When the printer was offline for part of the period, shorter views (e.g., 6h) appeared compressed to only the portion with data (e.g., 1.5h). Now pins the X axis domain to the full requested time range (e.g., now−6h to now), pads the data edges so the line extends across the full window, and connects through null values so the chart always shows a continuous line.\n- **\"Clear Plate & Start Next\" Ignores Filament Override Color** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When a print was queued to \"any printer\" with a filament color override (e.g., white PETG), the \"Clear Plate & Start Next\" button appeared on all printers of the matching model that had the correct filament *type*, regardless of *color*. A printer with blue PETG would show the button for a white PETG job. The backend scheduler already correctly rejected color mismatches, but the frontend `PrinterQueueWidget` only checked `required_filament_types` (type only) and ignored `filament_overrides` (type + color). Now passes loaded filament type+color pairs from AMS/vt_tray status to the widget and filters queue items against override colors, mirroring the backend's `_count_override_color_matches()` logic.\n- **Queue Empty After Container Restart Due to Uncheckpointed WAL** ([#523](https://github.com/maziggy/bambuddy/issues/523)) — The print queue appeared empty after a Docker container restart until a filter was applied. SQLite WAL mode keeps uncommitted data in a separate `-wal` file, but the shutdown handler never checkpointed the WAL back into the main database or disposed of engine connections. If the container was stopped or crashed, the WAL could contain partial schema migrations or uncommitted data, causing inconsistent query results on restart. Deleting the `-wal` and `-shm` files was the only workaround. Now runs `PRAGMA wal_checkpoint(TRUNCATE)` and disposes the engine on shutdown, ensuring all data is flushed to the main database file before exit.\n- **Virtual Printer Queue Sends Wrong Plate ID and Ignores AMS Mapping** ([#529](https://github.com/maziggy/bambuddy/issues/529)) — Files sent to a virtual printer in queue mode had two issues. First, `plate_id` was always `1`, generating the wrong MQTT gcode path for multi-plate 3MF files (HMS error 0500_4003). Now extracts the plate index from the 3MF's `slice_info.config`. Second, `ams_mapping` was never computed for printer-specific queue items (VP assigned to a particular printer), so the printer always used the first AMS slot regardless of which filament the 3MF required. The scheduler now computes AMS mapping for all queue items that lack one, not just model-based assignments.\n- **Unnecessary Target Model Selector on \"Any\" Tab** ([#528](https://github.com/maziggy/bambuddy/issues/528)) — When scheduling a print to \"Any {model}\", a redundant \"Target Model\" dropdown appeared even though the G-code is already sliced for a specific printer model. Changing the target model would lead to print failures. The dropdown is now hidden when the sliced model is known (the tab label already shows \"Any {model}\"). It still appears as a fallback for legacy files without model metadata.\n- **\"Clear Plate & Start Next\" Button Shown on Printers Without Correct Filament** ([#527](https://github.com/maziggy/bambuddy/issues/527)) — When a print job was queued for \"any printer\" of a model (e.g., \"any H2S\"), the \"Clear Plate & Start Next\" button appeared on ALL printers of that model, including those without the required filament loaded. Clicking it on a printer without the right filament would start a print that fails. The `PrinterQueueWidget` now filters queue items by filament compatibility — it checks the printer's loaded filament types (from AMS and external spools) against the queue item's `required_filament_types` and only shows items the printer can actually print. If no compatible items exist, the widget is hidden.\n- **Manual Spool Weight Overwritten by AMS Auto-Sync** ([#525](https://github.com/maziggy/bambuddy/issues/525)) — When a user manually entered a spool weight (via UI or API), the value was overwritten by the automatic AMS remain% sync that runs on every MQTT update. The AMS remain% is integer-only (~10g resolution for 1kg spool) and can't match precise manual entries. Added a `weight_locked` flag that is automatically set when `weight_used` is explicitly updated via the API. Locked spools are skipped by both the automatic AMS remain% sync and the manual force-sync endpoint. The usage tracker (3MF/gcode delta tracking) is unaffected. Users can re-enable AMS sync by setting `weight_locked: false`.\n- **Inconsistent Print Cost on Reprints** ([#505](https://github.com/maziggy/bambuddy/issues/505)) — Reprinting the same model produced different costs each time (e.g., £0.77, £1.54, £2.03 for the same print). Three independent code paths wrote to `archive.cost` with conflicting strategies: the usage tracker summed ALL historical `SpoolUsageHistory` rows for the archive (including rows from previous reprints), and a separate `add_reprint_cost` method added yet another full print's cost on top. Removed the redundant `add_reprint_cost` path entirely and changed the usage tracker to compute cost only from the current print session's results instead of querying all historical rows. `archive.cost` now always reflects the cost of a single print.\n- **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.\n- **\"Power Off Printer\" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The \"Power off printer when done\" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.\n- **Created Admin Users Can't See Settings Button** ([#503](https://github.com/maziggy/bambuddy/issues/503)) — The sidebar hid the Settings link based on a hardcoded `role === 'user'` check instead of the actual `settings:read` permission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now uses `hasPermission('settings:read')` for the sidebar check and calls `checkAuthStatus()` after login to load the complete user state including permissions.\n- **\"Open in Slicer\" Fails for Filenames Containing Special Characters** — Filenames with `/`, `\\`, `?`, or `#` (e.g., `Abzweigdose/Verteilerdose 70mm`) caused the slicer protocol handler to fail. The filename is placed in the download URL path and `encodeURIComponent`-encoded, but BambuStudio and OrcaSlicer call `url_decode()` on the entire protocol handler URL before downloading. This decoded `%2F` back to `/`, creating extra path segments that resulted in a 404. The URL filename is purely cosmetic (the backend resolves files by archive ID, not filename), so now sanitizes `/`, `\\`, `?`, and `#` to `_` in slicer download URLs.\n- **\"Queue to Any Printer\" Ignores Filament Color Override** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When scheduling a print to \"any printer\" with a filament color override, the scheduler picked a printer with the correct filament type but wrong color. `_find_idle_printer_for_model()` validated only filament type (via `_get_missing_filament_types()`), while color matching (`_count_override_color_matches()`) was used only for ranking candidates, not filtering them. A printer with 0 color matches was still selected if it had the right types. Now requires at least 1 color match when filament overrides specify colors — printers with 0 matches are skipped and added to the \"waiting for filament\" reason instead of being treated as valid candidates.\n- **Virtual Printer Queue Mode Doesn't Assign Printer** ([#518](https://github.com/maziggy/bambuddy/issues/518)) — Files sent to a virtual printer in \"print queue\" mode were added to the queue with no printer assigned, requiring manual assignment. The `_add_to_print_queue()` method always created queue items with `printer_id=None` and no `target_model`. Now assigns the virtual printer's `target_printer_id` if configured, or falls back to the VP's model (e.g., P1S, X1C) as `target_model` for \"Any Printer\" scheduling.\n- **Settings Text Fields Reset While Typing** — Text input fields on the Settings page (MQTT broker hostname, HA URL, tokens, etc.) reset mid-typing because the auto-save `onSuccess` handler overwrote `localSettings` with the server response, discarding characters typed during the save request. Removed the stale state overwrite so in-progress user input is preserved.\n\n### Improved\n- **Queue API Returns More Print Metadata** ([#524](https://github.com/maziggy/bambuddy/issues/524)) — The `GET /api/v1/queue` and `GET /api/v1/queue/{id}` endpoints now include `filament_type`, `filament_color`, `layer_height`, `nozzle_diameter`, and `sliced_for_model` from the archive or library file. Previously these fields were only available via the archive endpoints, requiring an extra API call.\n- **Spool Form Profile Dropdown Truncates Long Names** ([#534](https://github.com/maziggy/bambuddy/issues/534)) — Long filament profile names (e.g., \"Polymaker Panchroma Matte PLA 0.4 nozzle P1S\") were truncated in the spool creation form's preset dropdown because filament ID codes displayed alongside each name consumed horizontal space. Removed the inline filament codes from dropdown items (the selected code is still shown below the input after selection) and widened the modal from `max-w-lg` to `max-w-xl` to give profile names more room.\n\n## [0.2.1b3] - 2026-02-23\n\n### Fixed\n- **Print Bed Cooled Notification Never Triggers** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor (which polls bed temperature after a print and sends a notification when it drops below the configured threshold) was defined at the end of the `on_print_complete` callback, after an early `return` that exits when no archive is found for the print. Prints started from BambuStudio or the printer's touchscreen typically have no archive in Bambuddy, so the function returned before the bed cooldown task was ever created. Moved the bed cooldown monitor to before the archive lookup early-return so it fires for all completed prints regardless of archive state. Also hardened the temperature dict check from truthiness (`if status.temperatures:`) to type check (`isinstance(status.temperatures, dict)`) to avoid false negatives on empty dicts.\n- **IP Addresses Not Redacted From Support Bundle Logs** — The `_sanitize_log_content()` function redacted emails, serials, and credentials but left raw IPv4 addresses in log output. Now adds known printer IPs to the sensitive string list for exact matching, and applies an IPv4 regex that replaces addresses with `[IP]` while preserving firmware version strings (which use leading-zero octets like `01.09.01.00`). Updated the system info page privacy disclaimer to list IP addresses as redacted.\n- **\"Unknown stage (74)\" on H2D During Print Preparation** — The H2D firmware reports `stg_cur=74` during print preparation, but this stage was not in the stage name lookup table (which went up to 66, sourced from BambuStudio). Now maps stage 74 to \"Preparing\". Also added stage 77 (\"Preparing AMS\") which was present in BambuStudio but missing from the lookup.\n- **Wrong Documentation Link for \"Lubricate Carbon Rods\" on P2S** ([#490](https://github.com/maziggy/bambuddy/issues/490)) — The \"Lubricate Carbon Rods\" maintenance task linked to the belt tension wiki page instead of the XYZ axis lubrication page for P2S printers.\n- **External Spool Mapping Inverted on H2C** ([#492](https://github.com/maziggy/bambuddy/issues/492)) — On H2C dual-nozzle printers, printing from the right nozzle's external spool (Ext-R) incorrectly highlighted the left external spool (Ext-L) as active. The H2C firmware reports `tray_now=254` generically for both external spools, so the frontend's direct ID comparison (`effectiveTrayNow === extTrayId`) always matched Ext-L (id=254). Now uses `active_extruder` on dual-nozzle printers to determine which external spool is active: extruder 1 (left) → Ext-L, extruder 0 (right) → Ext-R.\n- **External Spool Assignments Lost on Restart** ([#493](https://github.com/maziggy/bambuddy/issues/493)) — Filament spool assignments on external spool holders (Ext-L / Ext-R) were silently deleted every time AMS data changed, including on container restart. The `on_ams_change` stale-assignment cleanup searched only AMS unit data for matching trays, but external spools live in `vt_tray` (a separate MQTT field). Since `_find_tray_in_ams_data` never found them, external assignments were always marked as stale and removed. Now looks up external spool assignments (`ams_id=255`) in the printer's `vt_tray` data instead, and keeps the assignment if `vt_tray` data hasn't arrived yet.\n- **Developer Mode Detection Always Reports Null** — The MQTT `fun` field is an integer in the JSON payload, but the parser used `int(value, 16)` which requires a string argument. This raised `TypeError` on every message, silently caught by the exception handler, so `developer_mode` was never set. Now handles both integer and hex string formats.\n- **Filament Fill Level Wrong in Hover Card / Missing for External Spools** ([#496](https://github.com/maziggy/bambuddy/issues/496)) — Three related fill level display bugs on the printer card. First, external spool slots (vt_tray) were missing the AMS `remain` fallback entirely — `extEffectiveFill` only checked Spoolman and inventory, falling through to `null` even when the printer reported a valid fill percentage. Now includes the same AMS remain fallback as regular and AMS-HT slots. Second, when fill level was unknown (`null`), the AMS slot visual showed a full-width gray bar (appearing \"full\") while the hover card showed \"—\" (appearing \"empty\") — confusing users into thinking the printer card and hover card disagreed. Removed the misleading gray fallback bar from all three slot types; the empty fill bar track now consistently indicates \"unknown\" in both views. Third, the fill level priority chain always preferred AMS `remain` over Spoolman and inventory data, even when those sources were more accurate (e.g., spools migrated from Spoolman to internal inventory, or spools with accurate usage tracking). Reversed the priority to Spoolman → Inventory → AMS remain, and fixed `fillSource` to correctly reflect the actual data source used (was always reporting `'ams'` even when Spoolman or inventory provided the value via the fallback chain when `remain` was -1).\n- **File Manager Rename Doesn't Update Displayed Name** ([#460](https://github.com/maziggy/bambuddy/issues/460)) — Renaming a file in the File Manager updated the `filename` field but not `file_metadata.print_name`, which the UI uses as the primary display name. Since `print_name` is extracted from inside the 3MF at upload time, it always took precedence over the renamed `filename`. The rename endpoint now also updates `print_name` in the file metadata when present.\n- **Finish Photo Not Captured When Archive Has No Source 3MF** ([#484](https://github.com/maziggy/bambuddy/issues/484)) — When a print completed but the 3MF source file wasn't downloaded from the printer (e.g. FTP download failure), the archive's `file_path` was null. The finish photo capture silently skipped because it derived the save directory from `file_path`. Now falls back to `archive/{id}/` so the photo is captured regardless.\n\n### New Features\n- **Filament Override for Model-Based Queue** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When scheduling a print to \"any printer\" (model-based assignment), you can now override the 3MF's original filament choices. A new section in the print modal shows the filaments required by the sliced file and lets you swap each slot to any compatible filament loaded across printers of the selected model. The scheduler matches against the overridden type and color instead of the original 3MF values, preferring printers with exact color matches. On dual-nozzle printers (H2D), the override dropdown only shows filaments on the correct extruder for each slot. New `GET /printers/available-filaments` endpoint aggregates loaded filaments across all active printers of a given model. Backend stores overrides as a JSON column on the queue item and applies them at scheduling time by merging into filament requirements before AMS mapping. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).\n\n## [0.2.1b2] - 2026-02-21\n\n### Fixed\n- **Wrong AMS Unit Displayed With Dual AMS on P2S** ([#420](https://github.com/maziggy/bambuddy/issues/420)) — On P2S printers with two AMS units, the UI highlighted the wrong AMS when printing from the second unit (e.g., printing from AMS-B slot 2 but AMS-A slot 2 was shown as active). The P2S firmware sends local slot IDs (0-3) in `tray_now`, not global tray IDs — contrary to the previous assumption that all single-nozzle printers report global IDs. Filament usage tracking was unaffected because it uses the MQTT `mapping` field (snow-encoded with correct AMS hardware IDs). The display now cross-references `tray_now` with the MQTT mapping field to resolve the correct AMS unit when multiple AMS units are detected via `ams_exist_bits`. Falls back to the raw value when no mapping is available (e.g., manual filament load outside of a print) or when the mapping is ambiguous.\n- **PCTG Filament Misidentified as PC** ([#478](https://github.com/maziggy/bambuddy/issues/478)) — Selecting \"Generic PCTG\" as a filament profile defaulted to PC material. The spool form's material parser listed PC before PCTG and used substring matching (`indexOf`), so \"PCTG\" matched \"PC\" first. The AMS slot configuration and local profiles views were also missing PCTG from their known material types. Additionally, the temperature range logic used `includes('PC')` which matched PCTG and assigned PC temperatures (260-300°C) instead of PETG-range temperatures (220-260°C). Fixed by reordering PCTG before PC in the spool form parser, adding PCTG to all material type arrays, and adding an exact-match temperature case for PCTG.\n- **Phantom Prints From Lingering SD Card Files** ([#477](https://github.com/maziggy/bambuddy/issues/477)) — Prints could restart without user input hours after completing, because uploaded gcode files survived on the printer's SD card and were auto-started on firmware restart. Three bugs allowed files to linger. First, the post-print SD card cleanup retry loop always broke after the first attempt regardless of success, because `delete_file_async` catches errors internally and returns `False` instead of raising — the `except` retry branch never executed. Fixed by only breaking on successful delete and retrying with a 2-second delay on failure. Second, when `start_print()` failed after uploading a file (in both the background dispatcher and print scheduler), the uploaded file was never cleaned up since `on_print_complete` never fires for a print that never started. Now deletes the uploaded file on a best-effort basis when `start_print()` returns `False`. Third, cleanup failure logging was at `DEBUG` level, making failures invisible in normal operation — escalated to `WARNING`.\n- **Non-Actionable HMS Errors Triggering Notifications** ([#470](https://github.com/maziggy/bambuddy/issues/470)) — Infrastructure and auth-related HMS error codes (like `0500_0007` \"MQTT command verification failed\") were triggering printer error notifications even though they don't indicate actual print problems. For example, a device with incorrect bind settings sending unauthorized MQTT commands caused repeated false-alarm nozzle/extruder error notifications with camera snapshots of perfectly fine prints. Now suppresses notifications for known non-actionable error codes: `0500_0007` (MQTT auth failure), `0500_4001` (Bambu Cloud connection failure), and `0500_400E` (print cancelled by user).\n- **Support Bundle Leaking Personal Data** ([#473](https://github.com/maziggy/bambuddy/issues/473)) — The support bundle's log sanitizer only used regex patterns, which can't detect arbitrary user-chosen strings like printer names and usernames. Now queries the database for known sensitive values (printer names, serial numbers, auth usernames, Bambu Cloud email) and does exact-string replacement before the regex pass. Serial number regex no longer leaks the first 3 characters (was using a capture group for partial redaction). Tasmota smart plug credentials embedded in URLs (`http://user:pass@host`) were logged verbatim by httpx; now uses httpx's `auth` parameter for HTTP Basic auth so credentials never appear in the URL. Added `username` and `path` to the settings key filter to redact `smtp_username` and `slicer_binary_path` from the support info JSON. A URL credentials regex provides defense-in-depth for any remaining `user:pass@` patterns in logs. IP addresses are no longer redacted from the bundle as they are needed for connectivity debugging. Updated the frontend privacy disclaimer and wiki documentation to reflect the new behavior.\n- **Spool Usage Lost When Spool Runs Empty Mid-Print** ([#459](https://github.com/maziggy/bambuddy/issues/459)) — When a spool ran empty during a print and the AMS auto-switched to a backup spool, two problems caused incorrect tracking. First, the `on_ams_change` handler eagerly deleted the empty spool's `SpoolAssignment` record (fingerprint mismatch), so `on_print_complete` found nothing and silently dropped usage — fixed by snapshotting all spool assignments at print start into the `PrintSession`. Second, even with the snapshot fix, the entire print's filament weight was attributed to the original spool (100%/0% split) because `_track_from_3mf()` only knew about the tray loaded at print start. Now tracks tray changes during the print via `tray_change_log` on `PrinterState`, recording each tray switch with its layer number. At print completion, the usage tracker splits the 3MF weight across trays using per-layer gcode data for precise segment boundaries, with a linear layer-ratio fallback when gcode data isn't available. The last segment always receives the remainder to prevent rounding drift.\n- **K-Profile Response Race Condition Crash** ([#462](https://github.com/maziggy/bambuddy/issues/462)) — An unsolicited or late K-profile MQTT response could crash the MQTT handler with `AttributeError: 'NoneType' object has no attribute 'set'`. The MQTT callback thread checked `self._pending_kprofile_response` (not None) at line 2698, but between that check and the `.set()` call, the asyncio thread's `finally` block in `get_kprofiles()` could clear the attribute to `None` after a timeout — a classic TOCTOU race. Fixed by capturing the event reference in a local variable before the check.\n- **Queue Stuck on \"Busy\" for \"Any Model\" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with \"Any [Model]\" (e.g., \"Any P1S\"), it was created with `printer_id=NULL` and `target_model=\"P1S\"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the \"Clear Plate & Start Next\" button never appeared, leaving the scheduler stuck reporting \"Busy\". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide `target_model` (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.\n- **Queue \"Any Model\" Jobs Stuck in \"Waiting\" After Plate Clear** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — After the queue visibility fix above, \"Any Model\" jobs were correctly assigned to an idle printer but immediately crashed with `'>=' not supported between instances of 'str' and 'int'` when computing AMS filament mapping. MQTT raw data returns AMS unit and tray IDs as strings, but `_build_loaded_filaments()` compared them to integers without casting. The crash prevented the assignment from committing, so the scheduler retried every 30 seconds in an infinite loop. Cast `ams_id` and `tray_id` to `int()` to match the pattern already used for external spool IDs.\n- **SD Card Cleanup After Print Never Runs** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The post-print SD card cleanup (which deletes uploaded gcode from the printer root to prevent phantom prints on power cycle) used `printer_manager.get_printer()`, which returns a `PrinterInfo` with only `name` and `serial_number`. Accessing `.ip_address`, `.access_code`, and `.model` raised `AttributeError`, silently caught by the outer exception handler. Replaced with a DB query for the `Printer` model, matching the pattern used everywhere else in `on_print_complete()`.\n- **Finish Photo Not Shown on Archives for BambuStudio Prints** ([#474](https://github.com/maziggy/bambuddy/issues/474)) — When a print was started from BambuStudio (not Bambuddy), the auto-archive had an empty `file_path`. The finish photo was saved correctly to `data/photos/`, but the photo serving endpoint resolved the path as `(base_dir / \"\").parent / \"photos/\"` which evaluates to `base_dir.parent/photos/` — one directory level too high. The photo existed on disk but the API returned 404. Fixed the path resolution in `get_photo`, `upload_photo`, and `delete_photo` to use `base_dir / Path(file_path).parent` (same pattern as the save code), which correctly resolves to `base_dir/photos/` when `file_path` is empty.\n- **Archive Endpoints Crash With \"Is a directory\" for BambuStudio Prints** ([#475](https://github.com/maziggy/bambuddy/issues/475)) — When a print was started from BambuStudio (not Bambuddy), the 3MF file is transient on the printer and FTP download fails, creating a fallback archive with `file_path=\"\"`. The archive endpoints used `Path.exists()` to check if the 3MF file was available, but `settings.base_dir / \"\"` resolves to the base directory itself — which `exists()` reports as True. Subsequent `ZipFile()` calls then failed with `[Errno 21] Is a directory`. Replaced all `.exists()` checks on archive file paths with `.is_file()` across 15 locations in the archive routes and 1 in the main module. Also added a `file_path` truthiness guard for finish photo capture to prevent saving photos under the base directory when the archive has no file path.\n- **AMS Slot Auto-Configuration Falls Back to Generic Instead of Spool's Slicer Preset** ([#479](https://github.com/maziggy/bambuddy/issues/479)) — When assigning a spool with a custom slicer preset (e.g., PFUS* cloud-synced profiles from BambuStudio) to an AMS slot, the slot was always configured with a generic Bambu filament ID (e.g., \"Generic ABS\" / GFB99) instead of the spool's actual preset. Two bugs caused this. First, all PFUS* IDs were blanket-rejected as \"user-local IDs unknown to other slicers\" and replaced with generic IDs — but PFUS presets are cloud-synced custom profiles that the printer understands. Second, the slot-reuse logic preserved generic fallback IDs (GFB99, GFL99, etc.) as if they were specific presets: once a slot was set to generic, every subsequent same-material assignment reused it, making generic IDs \"sticky\". Fixed priority order: (1) spool's own `slicer_filament` if set (including PFUS*/P* custom presets), (2) reuse slot's existing preset only if it's a specific non-generic ID for the same material, (3) generic Bambu filament ID as last resort. Both `assign_spool` and `configure_ams_slot` code paths are fixed.\n- **ntfy Notifications Fail With \"Illegal header value\"** ([#466](https://github.com/maziggy/bambuddy/issues/466)) — When sending ntfy notifications with image attachments (progress, error events), the message body was placed in an HTTP `Message` header. Multi-line messages (e.g., printer name + remaining time) contain newline characters, which are illegal in HTTP headers. Test notifications worked because they are single-line with no image. Now escapes newlines to literal `\\n` in the header, which ntfy interprets and renders as actual line breaks. Additionally, ntfy servers with attachments disabled rejected thumbnail uploads with \"attachments not allowed\" (HTTP 400 / code 40014), causing the entire notification to fail. Now automatically retries without the image when the server doesn't support attachments.\n- **Inventory Date Format Ignores Settings** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory page used a local `formatDate()` that hardcoded the `en-GB` locale, always displaying dates in a fixed format regardless of the date format setting. Now fetches the `date_format` setting and uses the shared `formatDateInput()` utility which formats as MM/DD/YYYY, DD/MM/YYYY, YYYY-MM-DD, or browser locale based on the user's choice.\n- **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.\n\n### New Features\n- **Bulk Spool Addition & Stock Spools** ([#480](https://github.com/maziggy/bambuddy/issues/480)) — Inventory enhancements for managing large filament collections. **Quick Add mode**: a toggle on the spool form that shows only material (required), brand, subtype (both optional), color, label weight, and quantity — ideal for inventorying filament without a specific slicer profile (\"stock\" spools). The quantity field (1–100) only appears in Quick Add mode and creates multiple identical spools in one transaction via `POST /inventory/spools/bulk`. Stock spools are computed (no database migration) — any spool without a `slicer_filament` is displayed with an amber \"Stock\" badge. A new filter (All / Stock / Configured) on the inventory page lets you filter by stock status. **Group similar spools**: a \"Group\" toggle in the inventory toolbar visually collapses identical unused/unassigned spools into a single expandable row or card with a count badge (e.g., \"5 identical spools\"). Grouping key uses material, subtype, brand, color, and label weight. Used or AMS-assigned spools always appear individually. Group state persists to localStorage. The Stock column is available but hidden by default in column settings. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).\n- **Filament Cost Tracking** ([#454](https://github.com/maziggy/bambuddy/pull/454), [#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a `cost_per_kg` value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global `default_filament_cost` setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02.\n- **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).\n- **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.\n- **Developer LAN Mode Detection & Warning Banner** — Automatically detects whether connected printers have Developer LAN Mode enabled by parsing the MQTT `fun` field (bit `0x20000000`). When any connected printer lacks developer mode, a persistent orange warning banner appears at the top of the UI with the affected printer name(s) and a link to Bambu Lab's documentation on how to enable it. Without developer mode, MQTT write operations (start/stop/pause prints, AMS control, light/speed/gcode commands) are silently rejected by newer firmware. The `developer_mode` state is included in the support bundle for diagnostics. New `/printers/developer-mode-warnings` endpoint provides a lightweight polling summary. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).\n\n### Improved\n- **Clear Plate Dot Indicator on Sidebar** — When the print queue is active and a printer finishes or fails with a pending next job, a small yellow dot now appears on the Printers sidebar icon to signal that user action (clearing the build plate) is needed. The indicator reuses the existing WebSocket-driven printer status cache, so no additional API polling is required. The dot disappears once the plate is cleared or the queue empties.\n- **Inventory Sidebar Always Visible** — The Inventory sidebar item is no longer hidden when Spoolman is enabled. Instead, clicking it embeds the Spoolman web UI in the main content area via iframe (same approach as external links). When Spoolman is disabled, the internal inventory page is shown as before. Both modes use the same `/inventory` route and sidebar position.\n- **Filament Override Test Coverage** — Added 11 backend unit tests: 6 for `_count_override_color_matches` (no status, exact match, no match, partial match, color normalization, external spool) and 5 for override application in filament matching (color override, tray_info_idx clearing, type change, partial override, nozzle filtering with override). Added 12 frontend tests for the `FilamentOverride` component: 5 rendering tests (null guards, slot display, dropdown count), 2 type filtering tests (same-type only, all colors), 3 nozzle filtering tests (extruder_id matching, single-nozzle passthrough, null extruder_id inclusion), and 2 interaction tests (select override, reset to original).\n- **P2S Dual-AMS tray_now Test Coverage** — Added 14 integration tests for multi-AMS tray_now disambiguation on single-nozzle printers (resolving AMS-B slots via mapping field, AMS-A passthrough, multi-color mapping, ambiguous/missing mapping fallbacks, last_loaded_tray tracking). Added 9 unit tests for `_resolve_local_slot_from_mapping` (snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass.\n- **Bulk Spool, Stock & Grouping Test Coverage** — Added 13 backend unit tests covering `SpoolBulkCreate` schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 29 frontend tests: 13 for `SpoolFormModal` covering `validateForm` with `quickAdd` flag (6 tests), quick-add toggle visibility, PA Profile tab hiding, quantity field gating (hidden by default, visible only in quick-add, hidden in edit mode), and brand/subtype optional asterisk removal in quick-add; 16 for inventory grouping logic covering `spoolGroupKey` identity/differentiation (7 tests) and `computeDisplayItems` grouping rules (9 tests for identical/different/used/assigned/single/order/mixed/empty scenarios).\n- **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.\n- **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.\n- **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.\n- **Tray Change Split Test Coverage** — Added 8 MQTT unit tests for `tray_change_log` lifecycle (default empty, seed on print start, clear on new print, record during RUNNING/PAUSE, ignore during IDLE, deduplicate, multi-change history). Added 6 usage tracker unit tests for weight splitting (per-layer gcode split, linear fallback, no-change normal path, empty log recovery, missing spool skip, triple segment split).\n- **Developer Mode Detection Test Coverage** — Added 7 backend unit tests for MQTT `fun` field parsing (bit clear/set detection, exact bit check, invalid hex handling, state persistence across messages). Added 4 frontend tests for the warning banner (single/multiple printer names, hidden when empty, \"How to enable\" link).\n- **Frontend Pre-Commit Hooks** ([#458](https://github.com/maziggy/bambuddy/issues/458)) — Added `frontend-typecheck` (`tsc --noEmit`) and `frontend-lint` (`eslint .`) hooks to the pre-commit config. Both hooks only trigger when `frontend/src/**/*.{ts,tsx}` files are staged.\n\n## [0.2.1b] - 2026-02-19\n\n### Fixed\n- **PAUSED State Never Matched** ([#447](https://github.com/maziggy/bambuddy/issues/447)) — Removed dead `PAUSED` checks across frontend and backend. The printer only sends `PAUSE` via MQTT `gcode_state`, so `PAUSED` comparisons were unreachable code.\n- **Nozzle Mapping Uses Wrong Source in 3MF Files** — The `extract_nozzle_mapping_from_3mf()` function used `filament_nozzle_map` (user preference) as the primary source for nozzle assignments. BambuStudio's \"Auto For Flush\" mode overrides user preferences at slice time, so the actual assignment lives in the `group_id` attribute on `<filament>` elements in `slice_info.config`. Now uses `group_id` as the primary source and falls back to `filament_nozzle_map` only when `group_id` is not present.\n- **Print Scheduler Hard-Filters Nozzle When No Trays on Target Nozzle** — On dual-nozzle printers, the scheduler enforced a strict nozzle filter when matching filaments. If a slicer filament was assigned to a nozzle with no AMS trays (e.g., only external spool on left nozzle), the match failed even though the filament existed on the other nozzle. Now falls back to unfiltered matching when no trays exist on the target nozzle.\n- **Print Scheduler External Spool Ignores Nozzle Assignment** — The external spool fallback in the scheduler always mapped to extruder 0 (right), ignoring the slicer's nozzle assignment. Now uses the 3MF nozzle mapping to select the correct extruder for external spool matches.\n- **ams_extruder_map Race Condition on Printer Status API** — The `/printers/{id}/status` endpoint read `ams_extruder_map` from the MQTT state without checking if the AMS data had been received yet. On fresh connections before the first AMS push-all, this returned an empty map — causing the frontend nozzle filter to show all trays as unfiltered. Now returns an empty object gracefully and the frontend disables nozzle filtering until the map is populated.\n- **Filament Mapping Frontend Ignores Nozzle for External Spools** — The `useFilamentMapping` hook always set `extruder_id: 0` for external spool matches. Now uses the nozzle mapping from the 3MF file to determine the correct extruder.\n- **AMS-HT Global Tray ID Computed Wrong on Printer Card** — The PrintersPage computed AMS-HT tray IDs using `ams_id * 4 + slot` (giving 512+), but AMS-HT units use their raw `ams_id` (128-135) as the global tray ID. Now uses `ams_id` directly for AMS-HT units.\n- **Filament Mapping Dropdown Shows Wrong Nozzle Trays** — The FilamentMapping dropdown filtered by `extruder_id` using strict equality, but `extruder_id` could be `undefined` for printers that hadn't reported their AMS extruder map yet. This caused all trays to be hidden. Now skips nozzle filtering when `extruder_id` is undefined.\n- **Cancelled Print Usage Tracking Uses Stale Progress/Layer** — When a print was cancelled, the usage tracker read `mc_percent` and `layer_num` from the printer's MQTT state — but by the time the `on_print_complete` callback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage.\n- **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — The `tray_now <= 3` check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent `_is_dual_nozzle` flag detected from `device.extruder.info` (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.\n- **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a \"slot mismatch\" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.\n- **H2D Tray Disambiguation Produces Bogus tray_now for AMS-HT** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When the snow field hadn't arrived yet on H2D dual-nozzle printers, the `ams_extruder_map` fallback computed `ams_id * 4 + slot` for all AMS types — including AMS-HT units (IDs 128-135) which have a single slot and use their unit ID as the global tray ID. This produced bogus values like 512+ that briefly appeared in the UI and could pollute `last_loaded_tray`. Now correctly returns the AMS-HT unit ID for single-slot units, handles AMS-HT in multi-AMS matching, filters AMS-HT candidates when slot > 0, and tightens `last_loaded_tray` to only accept physically valid tray IDs (0-15, 128-135, 254).\n- **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.\n- **Print Queue Shows UUID Hash Instead of Filename** ([#438](https://github.com/maziggy/bambuddy/issues/438)) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g., `c65887535303404eba1525176a0f78dc`) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, but `archive_print()` used the disk path as the display name. Now passes the original `LibraryFile.filename` through to `archive_print()` from both the print scheduler and the direct-print-from-library flow, so the archive's `filename`, `print_name`, and directory name all use the human-readable name.\n\n- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable.\n- **AMS Slot Config: PFUS Preset IDs Cause Slicer to Reset Slots** — When assigning a spool with a user-local `PFUS*` preset ID (from BambuStudio's custom filament profiles), the slicer didn't recognize the ID and actively reset the AMS slot configuration. Now replaces `PFUS*` IDs with generic Bambu filament IDs (e.g., `GFL99` for PLA). When the slot already has a recognized cloud-synced preset for the same material (e.g., `P4d64437`), it is reused to preserve K-profile calibration associations. Applies to both the slot configure endpoint and the inventory spool assignment flow.\n- **Fill Level Bar Missing for Brand New Spools** — Spools with `weight_used = 0` (brand new, never printed) showed no fill level bar on the printer card. The condition checked `weight_used > 0` instead of `weight_used != null`, excluding zero-usage spools. Now correctly shows 100% fill for new spools while still hiding the bar when weight data is unavailable (`null`).\n- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.\n- **npm audit: fix minimatch ReDoS finding** — Added an npm override for `minimatch@^10.2.1` in `package.json` to resolve the high-severity ReDoS (GHSA-3ppc-4f35-3m26) affecting minimatch@3.x/9.x pulled in transitively by eslint@9, typescript-eslint, and @vitest/coverage-v8. Eslint@9 pins minimatch@3.x with no patched release; eslint@10 upgrades to minimatch@10 but is not yet available. The override forces the patched version across the tree. Verified lint, build, and all tests pass.\n- **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just \"PETG\" instead of \"PETG Basic\"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.\n- **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The \"Open in Slicer\" buttons for BambuStudio and OrcaSlicer failed with \"importing failed\" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.\n\n### New Features\n- **Multiple Virtual Printers** — Run multiple virtual printers per Bambuddy installation. Each virtual printer gets a dedicated bind IP address with completely independent FTP, MQTT, SSDP, and Bind servers — no shared services or SNI routing. Full CRUD API (`/api/virtual-printers`) and React UI for creating, editing, and deleting virtual printers. Each instance supports all four modes (Immediate, Review, Print Queue, Proxy), any of the 11 supported printer models, per-instance TLS certificates (shared CA), and individual network interface override. Database-backed with auto-incremented serial suffixes.\n- **Virtual Printer: Dual Bind/Detect Ports** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — The slicer bind/detect handshake now listens on both ports 3000 and 3002. Different BambuStudio/OrcaSlicer versions use different ports for this handshake, so Bambuddy accepts connections on either. Applies to both server mode (BindServer) and proxy mode (SlicerProxyManager).\n- **Clear Plate Permission** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — New `printers:clear_plate` permission allows admins to grant users the ability to confirm a plate is cleared for the next queued print without granting full `printers:control` (which also allows stopping prints, configuring AMS, toggling lights, etc.). Existing groups with `printers:control` automatically receive the new permission on startup. The Operators default group includes it by default.\n- **Full-Page Group Permission Editor** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — Replaced the cramped permission modal with a dedicated full-page editor at `/groups/:id/edit`. Features a responsive 2-column grid of always-expanded category cards, permission search/filtering, Select All / Clear All bulk actions, category-level checkboxes with partial state, and a fixed bottom action bar. The old `GroupsPage.tsx` dead code has been removed.\n\n### Changed\n- **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled \"Filament\" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.\n\n### Improved\n- **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).\n- **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.\n- **Tray Info Idx Resolution Test Coverage** — Added 12 backend integration tests for PFUS→generic tray_info_idx resolution across both the slot configure and inventory assignment endpoints, plus 10 frontend unit tests for the fill level calculation logic.\n\n\n## [0.2.0] - 2026-02-17\n\n### New Features\n- **Bed Cooled Notification** ([#378](https://github.com/maziggy/bambuddy/issues/378)) — New notification event that fires when the print bed cools below a configurable threshold (default 35°C) after a print completes. Useful for knowing when it's safe to remove parts. A background task polls the bed temperature every 15 seconds after print completion and sends a notification when it drops below the threshold. Automatically cancels if a new print starts or the printer disconnects. The threshold is configurable in Settings → Notifications. Includes a customizable notification template with printer name, bed temperature, and threshold variables.\n- **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.\n- **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new \"Remaining Weight\" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.\n- **Spool Inventory — Unified 3MF-Based Usage Tracking** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — All spools (Bambu Lab and third-party) now use 3MF slicer estimates as the primary tracking source. Per-filament `used_g` data from the archived 3MF file provides precise per-spool consumption. For failed or aborted prints, per-layer G-code analysis provides accurate partial usage up to the exact failure layer, with linear progress scaling as fallback. AMS remain% delta is the final fallback for G-code-only prints without an archived 3MF. Slot-to-tray mapping uses queue `ams_mapping` for queue-initiated prints and the printer's `tray_now` state for single-filament non-queue prints, ensuring the correct physical spool is always tracked.\n- **Notification Templates — Filament Usage Variables** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — `print_complete`, `print_failed`, and `print_stopped` notification events now expose `{filament_grams}` (total grams, scaled by progress for partial prints), `{filament_details}` (per-filament breakdown with AMS slot info, e.g. \"AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g\"), and `{progress}` (completion percentage for failed/stopped prints). The `{filament_details}` variable includes the AMS unit and tray position for each filament used, with \"Ext\" shown for external spool holders. Falls back to type-only format (e.g. \"PLA: 10.0g\") when usage tracking data is unavailable. Webhook payloads include `filament_used`, `filament_details`, and `progress` fields. Per-slot filament data is stored in archive `extra_data` for downstream use.\n- **Printer Status Summary Bar — Next Available & Availability Count** ([#354](https://github.com/maziggy/bambuddy/issues/354)) — The status bar on the Printers page now shows an availability count (\"X available\") alongside the printing/offline counts, and a \"Next available\" indicator showing which printing printer will finish soonest — with printer name, mini progress bar, completion percentage, and remaining time. Useful for print farms to quickly identify the next free printer. Updates in real-time via WebSocket. Translated in all 4 locales (en, de, ja, it).\n- **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).\n- **Dual External Spool Support for H2D** — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The `vt_tray` field is now an array across the entire stack (MQTT, API, WebSocket, frontend).\n- **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., \"@BBL X1C\" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja).\n- **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like \"GFU99\" or \"P4d64437\". Falls back to extracting names from the profile name field.\n- **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.\n- **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.\n- **Notification Thumbnails for Telegram & ntfy** ([#372](https://github.com/maziggy/bambuddy/issues/372)) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the `sendPhoto` API with the image as caption attachment. ntfy sends the image as a binary PUT with `Filename` and `Message` headers. No configuration needed — images are sent automatically when available.\n- **Clear HMS Errors** — New \"Clear Errors\" button in the HMS error modal sends a `clean_print_error` MQTT command to dismiss stale `print_error` values that persist after print cancellation or transient events. Locally clears the error list for immediate UI feedback. Permission-gated to `printers:control`. The button only appears when there are active errors.\n\n### Fixed\n- **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.\n- **Update Check Runs When Disabled** ([#367](https://github.com/maziggy/bambuddy/issues/367)) — The Settings page triggered an update check on every visit even when \"Check for updates\" was disabled, causing error popups on air-gapped systems with no internet. The backend `/updates/check` endpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects the `check_updates` flag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off.\n- **Stale Inventory Assignments Persist After Switching to Spoolman Mode** — When switching from built-in inventory to Spoolman mode, existing spool-to-AMS-slot assignments were not cleaned up. The printer card hover cards continued showing \"Assign Spool\" buttons that opened the internal inventory modal, and any prior assignments remained visible. Now bulk-deletes all `SpoolAssignment` records when enabling Spoolman, invalidates the frontend cache so printer cards update immediately, and hides the inventory assign/unassign UI on printer cards while in Spoolman mode.\n- **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.\n- **Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails** ([#351](https://github.com/maziggy/bambuddy/issues/351)) — Maintenance tasks \"Clean Carbon Rods\" and \"Lubricate Linear Rails\" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: \"Lubricate Carbon Rods\" and \"Clean Carbon Rods\" for X1/P1/P2S, \"Lubricate Linear Rails\" and \"Clean Linear Rails\" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales.\n- **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent.\n- **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., \"Generic PLA\"). The `tray_info_idx` field (e.g., \"GFA00\") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools.\n- **FTP Disconnect Raises EOFError When Server Dies** — `BambuFTPClient.disconnect()` only caught `OSError` and `ftplib.Error`, but `quit()` raises `EOFError` when the server has closed the connection mid-session. `EOFError` is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect.\n- **RFID Spool Data Erased by Periodic AMS Updates** — Periodic MQTT push-all responses cleared `tag_uid` and `tray_uuid` fields because they were included in the \"always update\" list. These fields are now preserved during updates and only cleared when a spool is physically removed (slot clearing detected by empty `tray_type`). This fixes the AMS \"eye\" icon disappearing for RFID spools after startup.\n- **AMS Slot Configuration Overwrites RFID Spool State** — Configuring an AMS slot for an RFID-detected Bambu Lab spool sent `ams_set_filament_setting`, which replaced the firmware's RFID-managed filament config with a manual one — causing the slicer's \"eye\" icon to change to a \"pen\" icon. Now detects RFID spools and skips the filament setting command, only sending K-profile selection.\n- **K-Profile Selection Corrupts Existing Profiles on X1C/P1S** — The `extrusion_cali_sel` command included a `setting_id` field that BambuStudio never sends, causing firmware to mislink calibration data. The `extrusion_cali_set` command was sent unconditionally, overwriting existing profile metadata. Now `setting_id` is removed from selection commands, and `extrusion_cali_set` is only sent when no existing profile is selected (`cali_idx < 0`).\n- **AMS Slot Configure — Black Filament Color Not Pre-Populated** — When re-opening the Configure AMS Slot modal for a slot with black filament, the color field was empty despite the preset and K-profile being correctly pre-selected. The color pre-population logic excluded hex `000000` (black) as a guard against empty slots, but empty slots already skip color data entirely. Removed the unnecessary check so black is now pre-populated like any other color.\n- **Archive List View Not Labeling Failed Prints** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — The archive grid view displayed a red \"Failed\" / \"Cancelled\" badge on failed and aborted prints, but the list view had no equivalent indicator. Now shows an inline status badge next to the print name in list view.\n- **Reprint Fails with SD Card Error for Archives Without 3MF File** ([#376](https://github.com/maziggy/bambuddy/issues/376)) — When a print was sent from an external slicer and Bambuddy couldn't download the 3MF from the printer during auto-archiving, the fallback archive had no file. Attempting to reprint such an archive tried to upload the data directory as a file, causing a confusing \"SD card error.\" The backend now returns a clear error for file-less archives, and the frontend disables Print/Schedule/Open in Slicer buttons with a tooltip explaining that the 3MF file is unavailable.\n- **Inventory Spool Weight Resets After Print Completes** — After a print, the usage tracker correctly updated `weight_used` (e.g., +1.6g), but periodic AMS status updates recalculated `weight_used` from the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resetting `weight_used` back to 0. The AMS weight sync now only increases `weight_used`, never decreases it, preserving precise values from the usage tracker.\n- **All Spool Fill Levels Drop to Zero When Printers Power Off** — When a printer powers off, the AMS sensor can report `remain=0` for all trays while `tray_type` is still populated. The weight sync treated 0% remain as \"100% consumed,\" computing `weight_used = label_weight` (e.g., 1000g). The \"only increase\" guard passed because `label_weight > current_used + 1`, marking every assigned spool as fully consumed. The AMS weight sync now skips `remain=0` entirely — a physically empty spool is tracked by the usage tracker during the print, not by a transient AMS sensor reading.\n- **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.\n- **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.\n- **Loose Archive Name Matching Could Cause Wrong Archive Reuse** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The `on_print_start` callback used `ilike('%{name}%')` to find existing \"printing\" archives, which meant a print named \"Clip\" could incorrectly match \"Cable Clip\" or \"Clip Stand\". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact `print_name` match or exact filename variants (`.3mf`, `.gcode.3mf`).\n- **Phantom Prints on Power Cycle** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The print queue uploaded `.3mf` files to the printer's SD card root (`/`) but never deleted them after the print finished. Some printers (e.g. P1S) auto-start files found in the root directory on power cycle, causing ghost prints on every reboot. Now deletes the uploaded file from the SD card after print completion (best-effort, non-blocking). The cleanup also tries `.gcode` files and retries up to 3 times with a 2-second delay to handle printers that briefly lock the filesystem after a print ends. Runs before the archive lookup so it works even when auto-archiving is disabled.\n- **Queue Items Stuck in \"Printing\" After Print Completes** — The queue item status update (from `printing` to `completed`/`failed`) was placed after an early return that exits when the archive record cannot be found. If the archive lookup failed (e.g. app restart mid-print, manual archive deletion), the function returned early and the queue item stayed in `printing` forever. Over multiple print cycles, stale items accumulated — causing the \"Printing\" count to show double the actual printers and completed prints to remain in the \"Currently Printing\" section. Moved the queue item status update (including MQTT relay notification, queue-completed notification, and auto-power-off) to before the archive lookup early return so it always runs.\n- **Spool Form Scrollbar Flicker in Edge** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The Add/Edit Spool modal's scrollable area used `overflow-y: auto`, which on Windows Edge (where scrollbars take layout space) caused the scrollbar to appear and disappear on hover — making the color picker unusable at certain zoom levels. Added `scrollbar-gutter: stable` to reserve scrollbar space and prevent layout thrashing.\n- **Archive Duplicate Badge Misses Name-Based Duplicates** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — The duplicate badge on archive cards only matched by file content hash, so re-sliced prints of the same model (different GCODE, same print name) were not flagged as duplicates. Now also matches by print name (case-insensitive), consistent with the detail view's duplicate detection.\n- **Schedule Print Allows No Plate Selected for Multi-Plate Files** ([#394](https://github.com/maziggy/bambuddy/issues/394)) — When scheduling a multi-plate file from the file manager, the modal showed a \"Selection required\" warning but still allowed submission without selecting a plate. The job defaulted to plate 1, but the queue item didn't indicate which plate, and editing showed no plate selected. Now auto-selects the first plate by default when plates load, and the submit button validation applies to both archive and library files.\n- **3MF Usage Tracking Broken for Queue Prints from File Manager** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When a print was queued from the file manager (library file), the scheduler did not create an archive or register the expected print. The `on_print_start` callback had to re-download the 3MF from the printer via FTP, and if that failed, a fallback archive was created without the 3MF file — making 3MF-based filament usage tracking impossible. The queue item's `archive_id` also remained NULL, so the usage tracker could not find the queue's AMS slot mapping for correct spool resolution. The scheduler now creates an archive from the library file before uploading, links it to the queue item, and registers it as an expected print — matching the behavior of the direct library print route.\n- **Printer Queue Widget Shows \"Archive #null\" for File Manager Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The \"Next in queue\" widget on the printer card only checked `archive_name` and `archive_id` when displaying the queued item name. Queue items from the file manager have `library_file_name` and `library_file_id` instead, so the widget displayed \"Archive #null\". Now falls back to `library_file_name` and `library_file_id`, matching the Queue page display logic.\n- **Inventory Usage Not Tracked for Remapped AMS Slots** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When reprinting an archive with a different AMS slot mapping (e.g. changing from slot A1 to C4 in the mapping modal), the usage tracker used the default 3MF slot-to-tray mapping instead of the actual mapping from the print command. The `ams_mapping` from reprint, library print, and queue print commands is now stored and used as the highest-priority mapping source for usage tracking.\n- **Inventory Usage Not Tracked for Slicer-Initiated Prints on H2D** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2D printers, the AMS `tray_now` field is always 255 in MQTT data. The actual tray is resolved via the snow field ~44 seconds after print start, but reverts to \"unloaded\" when the AMS retracts filament at completion. The usage tracker now tracks `last_loaded_tray` — the last valid tray seen during printing — as a fallback when both `tray_now` at start and at completion are invalid. Also captures `tray_now` at print start for printers that report a valid value before the RUNNING state.\n- **Inventory Usage Wrong Tray for Slicer-Initiated Prints** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — When a print was started from an external slicer (BambuStudio, OrcaSlicer, Bambu Handy), Bambuddy never saw the `ams_mapping` the slicer sent, because it only subscribed to the printer's report topic. The usage tracker fell back to `tray_now` which could resolve to the wrong AMS tray (e.g., Black PLA at A2 instead of Green PLA at A4 on H2D Pro). Now subscribes to the MQTT request topic to intercept print commands from any source, capturing the `ams_mapping` universally — regardless of who starts the print. The request topic subscription is fail-safe: if the printer's MQTT broker rejects it (e.g., P1S), Bambuddy detects the rejection via SUBACK or disconnect timing and gracefully disables the subscription for that printer, falling back to the existing `tray_now`-based tracking without breaking the MQTT connection.\n- **P1S Timelapse Not Detected — AVI Format Support** ([#405](https://github.com/maziggy/bambuddy/issues/405)) — P1-series printers save timelapse videos as `.avi` (MJPEG), but the timelapse scanner only looked for `.mp4` files — so P1S timelapses were never found or attached to archives. Now discovers both `.mp4` and `.avi` timelapse files across all FTP directories (`/timelapse`, `/timelapse/video`, `/record`, `/recording`). AVI files are saved immediately and converted to MP4 in a non-blocking background task using FFmpeg with `-threads 1` and `nice -n 19` to minimize CPU impact on Raspberry Pi. If FFmpeg is unavailable, the AVI is served as-is with the correct MIME type. The manual \"Scan for Timelapse\" route also searches the additional directories used by P1-series printers.\n- **Timelapse Upload & Remove** ([#406](https://github.com/maziggy/bambuddy/issues/406)) — When the auto-scan attaches the wrong timelapse (e.g., from a different print), there was no way to remove it or attach the correct one. Added \"Upload Timelapse\" and \"Remove Timelapse\" context menu items. Upload accepts `.mp4`, `.avi`, and `.mkv` files (non-MP4 auto-converted in background). Remove deletes the file and clears the database reference. Both actions are permission-gated and available in grid and list views.\n- **Spool Assignments Falsely Unlinked After Print Due to Color Variation** — The auto-unlink logic compared AMS tray colors against saved fingerprints using exact hex match. RFID sensors report slightly different color values across reads (e.g. `7CC4D5FF` vs `56B7E6FF` for the same spool, Euclidean distance ~43.6). Now uses a color similarity function with a tolerance threshold of 50, preventing false unlinks from minor RFID/firmware color variations while still detecting genuinely different spools.\n\n### Improved\n- **Virtual Printer: Dual Bind/Detect Ports 3000 + 3002** ([#445](https://github.com/maziggy/bambuddy/issues/445)) — BambuStudio/OrcaSlicer require a bind/detect handshake before connecting via MQTT/FTP. Different slicer versions use port 3000 or 3002, so the BindServer and proxy now listen on both ports for full compatibility. Docker users in bridge mode need to expose both (`-p 3000:3000 -p 3002:3002`).\n- **Usage Tracking Diagnostic Logging** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — Added INFO-level logging at print start and completion that dumps the printer's MQTT `mapping` field, `tray_now`, `last_loaded_tray`, all mapping-related raw data keys, and per-AMS-tray summaries (type, color, tray_now, tray_tar). Enables investigating the slot-to-tray mapping behavior across different printer models (X1E, H2D Pro, P1S, etc.) without requiring DEBUG mode.\n- **Skip Objects: Click-to-Enlarge Lightbox** ([#396](https://github.com/maziggy/bambuddy/issues/396)) — The skip objects modal's small 208px image panel made it difficult to distinguish object markers when parts are small or close together. Clicking the image now opens a fullscreen lightbox overlay with the same image and markers at a much larger size (up to 600px). The 24px marker circles are proportionally smaller relative to the enlarged image, solving the overlap problem. Close via X button, Escape key, or clicking the backdrop. Escape cascades correctly — closes lightbox first, then the modal.\n- **Phantom Print Investigation — Logging & Hardening** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — Added targeted logging and hardening to help diagnose reports of prints starting automatically without user input. Debug log volume reduced ~90% by suppressing `sqlalchemy.engine` (changed from INFO to WARNING) and `aiosqlite` (new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Every `start_print()` call now logs a `PRINT COMMAND` trace with the caller's file, line, and function name. The print scheduler logs pending queue items when found. `on_print_complete` warns when multiple queue items are in \"printing\" status for the same printer, which signals a state inconsistency.\n- **Reduce Log Noise from MQTT Diagnostics** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — Downgraded 58 high-frequency MQTT diagnostic messages from INFO to DEBUG level. Payload dumps, detector state changes, field discovery logs, H2D disambiguation, and periodic status updates no longer flood the log at the default INFO level. Also suppresses paho-mqtt library INFO messages in production. User-initiated actions (print start/stop, AMS load/unload, calibration) remain at INFO. All diagnostic detail is still available when debug logging is enabled.\n- **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing \"database is locked\" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.\n- **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.\n- **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white \"0.4\" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.\n- **File Downloads Show Generic Filenames** ([#334](https://github.com/maziggy/bambuddy/issues/334)) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic `file_1`, `file_2` instead of the original filename. The `Content-Disposition` header parser now handles RFC 5987 percent-encoded filenames (`filename*=utf-8''...`) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files).\n- **Printer Card Cover Image Not Updating Between Prints** — The cover image on the printer card only refreshed on page reload. The `<img>` URL was always the same (`/printers/{id}/cover`) regardless of which print was active, so the browser served its cached image. Now appends the print name as a cache-busting query parameter so the browser fetches the new cover when a different print starts.\n- **Telegram Bold Title Broken by Underscores in Message** ([#332](https://github.com/maziggy/bambuddy/issues/332)) — Telegram notifications showed literal `*Title*` asterisks instead of bold text when the message body contained underscores (e.g. job name `A1_plate_8`, error code `0300_0001`). The code was disabling Markdown parsing entirely when underscores were detected. Now escapes underscores in the body with `\\_` so Markdown rendering stays enabled.\n- **Queued Jobs Incorrectly Archived After Duplicate Execution Detection** ([#341](https://github.com/maziggy/bambuddy/issues/341)) — When the same file was added to the print queue multiple times, only the first job executed. All subsequent jobs were automatically skipped with \"already printed X hours ago\" because they shared the same archive reference, and a safety check incorrectly treated them as phantom reprints. The same issue also affected single queue items created from recently completed archives. Removed the overly broad 4-hour duplicate detection check — the crash recovery scenario it guarded against is already handled by the queue item status lifecycle.\n\n### New Features\n- **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing \"refused to connect\" errors. A new \"Open in new tab\" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.\n- **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a \"Clear Plate & Start Next\" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).\n- **Clear Plate State Persists Across Page Refresh** ([#410](https://github.com/maziggy/bambuddy/issues/410)) — After clicking \"Clear Plate & Start Next\", refreshing the page showed the Clear Plate button again because the frontend determined the state purely from the printer's FINISH/FAILED status. The `plate_cleared` flag is now included in the printer status API response, so the widget correctly shows the passive queue link instead of the Clear Plate button after acknowledgment — even after a page refresh.\n\n### Improved\n- **Skip Objects: Confirmation Dialog** ([#346](https://github.com/maziggy/bambuddy/issues/346)) — Added a warning confirmation modal before skipping an object during a print. Shows the object name and warns the action is irreversible. Prevents accidentally skipping the wrong object. Translated in all 4 locales (en, de, ja, it).\n- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.\n- **Move Email Settings Under Authentication Tab** — Renamed the settings \"Users\" tab to \"Authentication\" and moved the standalone \"Global Email\" tab into it as an \"Email Authentication\" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy `?tab=email` URLs are handled automatically.\n- **Inventory — Confirmation Modals for Delete & Archive** — The inventory page now uses the app's styled confirmation modal for both delete and archive actions. Previously, delete used the browser's native `confirm()` dialog and archive had no confirmation at all. Delete shows a danger-styled modal, archive shows a warning-styled modal. Translated in all 5 locales (en, de, fr, it, ja).\n- **Default Color Catalog Expanded to 638 Colors Across 20 Brands** — The built-in filament color catalog has been expanded from 258 entries (6 brands) to 638 entries (20 brands). Added Overture, Sunlu, Creality, Elegoo, Jayo, Inland, Eryone, ColorFabb, Fillamentum, FormFutura, Fiberlogy, MatterHackers, Protopasta, 3DXTECH, and Sakata3D. eSUN expanded from 10 generic placeholder entries to 79 measured colors across 10 material lines (PLA+, Pro PLA+, PLA, PLA Silk, PLA Metal, PLA-ST, PETG, PETG-HS, ABS, ABS+). All hex codes sourced from FilamentColors.xyz measured swatches.\n- **Settings — Built-in Inventory Feature Note** — Added a note in Settings > Filament > Built-in Inventory that third-party spools can be assigned to inventory spools for tracking.\n- **Catalog Settings Cards Taller** — Spool Catalog and Color Catalog settings panels increased from 400px to 600px max height for better browsability with the expanded default catalogs.\n\n## [0.1.9] - 2026-02-10\n\n### New Features\n- **Advanced Authentication via Email** ([#322](https://github.com/maziggy/bambuddy/pull/322)) — Optional SMTP-based email integration for streamlined user onboarding and self-service password management. Admins configure SMTP settings and create users with just a username and email — the system generates a secure random password and emails it directly to the new user. Admins can trigger one-click password resets from User Management. Users can reset their own forgotten password from the login screen without contacting an admin. Includes customizable email templates for welcome emails and password resets. Username and email login is case-insensitive. Can be enabled or disabled independently at any time without affecting existing accounts.\n- **Configurable Slicer Preference** ([#313](https://github.com/maziggy/bambuddy/issues/313)) — New \"Preferred Slicer\" setting in General settings to choose between Bambu Studio and OrcaSlicer. Controls the protocol used by all \"Open in Slicer\" buttons across Archives, 3D Preview, and context menus. OrcaSlicer uses the `orcaslicer://open?file=` protocol. Default remains Bambu Studio for backward compatibility.\n- **Local Profiles — OrcaSlicer Import** ([#310](https://github.com/maziggy/bambuddy/issues/310)) — Import slicer presets from OrcaSlicer without Bambu Cloud. Supports `.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, and `.json` exports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New \"Local Profiles\" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback.\n- **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.\n- **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.\n- **Per-Filament Spoolman Usage Tracking** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — Accurate per-filament usage tracking for Spoolman integration with G-code parsing. Parses 3MF files at print start to build per-layer, per-filament extrusion maps. Reports accurate partial usage when prints fail or are cancelled based on actual layer progress. Tracking data stored in database to survive server restarts. Uses Spoolman's filament density for mm-to-grams conversion. Prefers `tray_uuid` over `tag_uid` for spool identification.\n- **Disable AMS Weight Sync Setting** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — New toggle to prevent AMS percentage-based weight estimates from overwriting Spoolman's granular usage-based calculations. Includes conditional \"Report Partial Usage for Failed Prints\" toggle.\n- **Home Assistant Environment Variables** ([#283](https://github.com/maziggy/bambuddy/issues/283)) — Configure Home Assistant integration via `HA_URL` and `HA_TOKEN` environment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback.\n- **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate \"(Spoolman)\" when the data source is Spoolman rather than AMS.\n- **Extended Support Bundle Diagnostics** — Support bundle now collects comprehensive diagnostic data for faster issue resolution: printer connectivity and firmware versions, integration status (Spoolman, MQTT, Home Assistant), network interfaces (subnets only), Python package versions, database health checks, Docker environment details, WebSocket connections, and log file info. All data properly anonymized — no IPs, names, or serials included. Privacy disclosure updated on System Info page.\n\n### Improved\n- **H2C Nozzle Rack — 6-Slot Display With Empty Placeholders** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack card now always shows 6 rack positions (IDs 16–21), with filled slots showing diameter and empty slots showing placeholder dashes. L/R hotend nozzles (IDs 0, 1) are excluded from the rack card and shown in the dedicated L/R indicator instead.\n- **H2 Series — L/R Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New dual-nozzle hover card shows L and R nozzle details side by side (diameter, type, flow, status, wear, max temp, serial). Active nozzle highlighted in amber with Active/Idle status based on `active_extruder`, replacing the misleading \"Docked\" label.\n- **H2 Series — Single-Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — H2D/H2S printers with a single nozzle now show extended nozzle details (wear, serial, max temp) on hover over the temperature card. Backend changed from H2C-only (>2 nozzles) to all H2 series (any nozzle_info present).\n- **H2C Nozzle Rack — Translate Type Codes & Add Flow Info** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Raw nozzle type codes (e.g. \"HS\", \"HH01\") are now translated to human-readable names: material (Hardened Steel, Stainless Steel, Tungsten Carbide) and flow type (High Flow, Standard). New \"Flow\" row in the hover card. Translations added in all 4 locales (en, de, ja, it).\n- **H2C Nozzle Rack — Show Filament Material in Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle hover card now shows the loaded filament material type (e.g. \"PLA\", \"PETG\") alongside the color swatch, captured from MQTT nozzle info data.\n- **H2C Nozzle Rack — Resolve Filament Names From Cloud & Local Profiles** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack hover card previously showed raw filament IDs like \"GFU99\" instead of human-readable names. Now resolves filament names with a 4-tier fallback: Bambu Cloud preset lookup → local slicer profiles → built-in filament name table (86 known Bambu filament codes) → raw ID fallback. The built-in table resolves names like \"Bambu ASA\", \"Generic TPU\", \"Generic PLA\" when the cloud API returns 400 for certain filament IDs. Also benefits AMS tray tooltips.\n- **H2C Nozzle Rack Compact Layout** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Redesigned nozzle rack from a 2×3 grid to a compact single-row layout with bottom accent bars (green = mounted, gray = docked). Temperature cards are thinner, rack card is wider (flex-[2]), and all cards vertically centered.\n- **Firmware Version Badge on Printer Card** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printer cards now show a firmware version badge (when firmware checking is enabled). Green with checkmark when up to date, orange with download icon when an update is available. Clicking the badge opens a firmware info modal showing release notes (auto-expanded when up to date) or the existing update workflow. Badge and modal respect `firmware:read` and `firmware:update` permissions. Translations added in all 4 locales.\n- **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.\n- **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.\n\n### Fixed\n- **Nozzle Rack Hides 0% Wear** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New nozzles with 0% wear showed no wear info in the hover card because the condition treated 0 the same as \"not available.\" Now displays \"Wear: 0%\" correctly. The field is still hidden when the printer doesn't report wear data.\n- **Nozzle Rack Shows L/R Hotend Nozzles in Rack** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack card incorrectly included L/R hotend nozzles (IDs 0, 1) alongside the 6 rack slots. Now filters to IDs >= 2 (rack only) and always pads to 6 positions with empty placeholders.\n- **H2C Firmware Update Downloads Wrong Firmware** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — H2C printers were mapped to the H2D firmware API key (`h2d`), causing firmware checks to offer H2D firmware instead of H2C firmware. H2C has its own firmware track (01.01.x.x vs H2D's 01.02.x.x). Added separate `h2c` API key mapping. Also added missing H2C/H2S entries to printer model ID and 3MF model maps.\n- **Sidebar Links Custom Icons Have Inverted Colors** ([#308](https://github.com/maziggy/bambuddy/issues/308)) — Custom uploaded icons in sidebar links had their colors inverted in dark mode due to a CSS `invert()` filter. The filter was intended for monochrome preset icons but was incorrectly applied to user-uploaded images (e.g., full-color logos). Removed the invert filter from custom icon rendering in the sidebar and the add/edit link modal.\n- **Virtual Printer FTP Transfer Fails With Connection Reset** ([#58](https://github.com/maziggy/bambuddy/issues/58)) — Large 3MF uploads to the virtual printer intermittently failed with `[Errno 104] Connection reset by peer` while the small verify_job always succeeded. The `_handle_data_connection` callback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (`_transfer_done` event), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading.\n- **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).\n- **Wrong Thumbnail When Reprinting Same Project** ([#314](https://github.com/maziggy/bambuddy/issues/314)) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by `subtask_name` and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start.\n- **Wrong Timelapse Attached to Archive** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the \"most recent,\" but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.\n- **Timelapse Not Attached — Baseline Race Condition** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Follow-up to the snapshot-diff timelapse fix: the baseline of existing MP4 files was captured at print completion time inside a background task, but fast-encoding printers could finish writing the timelapse before the baseline was taken, causing the new file to appear in the baseline and never be detected as \"new.\" Moved baseline capture to print start time, when the timelapse file cannot possibly exist yet. Falls back to completion-time baseline if the app was restarted mid-print.\n- **Calibration Prints Archived** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (`/usr/etc/print/auto_cali_for_user.gcode`) and other internal printer files under `/usr/` are now detected and skipped during print start.\n- **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.\n- **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.\n- **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.\n- **Energy Cost Shows 0.00 in \"Total Consumption\" Mode** ([#284](https://github.com/maziggy/bambuddy/issues/284)) — Statistics Quick Stats showed 0.00 energy cost when Energy Display Mode was set to \"Total Consumption\" with Home Assistant smart plugs. The `homeassistant_service` was not configured with HA URL/token before querying plug energy data, causing it to silently return nothing.\n- **H2D Pro Prints Fail at ~75% With Extrusion Motor Overload** ([#245](https://github.com/maziggy/bambuddy/issues/245)) — H2D Pro firmware interprets `use_ams: 1` (integer) as a nozzle index, routing filament to the deputy nozzle instead of the main nozzle. Bambu Studio sends `use_ams: true` (boolean) while using integers for other fields. Fixed by keeping `use_ams` as boolean for all printers including H2D series.\n- **GitHub Backup Description Misleading** — The \"App Settings\" backup card said \"excludes sensitive data\" but the complete database is pushed. Updated description to \"complete database.\"\n- **Support Bundle Shows 0 AMS Units** — The support info always reported `ams_unit_count: 0` because it expected `raw_data[\"ams\"]` to be a nested dict (`{\"ams\": [...]}`) but the MQTT handler stores it as a flat list. Now handles both formats.\n- **Firmware Badge Shown for Models Without API Data** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printers whose model has no firmware data in Bambu Lab's API (e.g. H2C on public beta firmware) showed a misleading green \"up to date\" badge. The badge is now hidden when the API returns no `latest_version`, since there is nothing to compare against.\n- **AMS-HT Mapping Fails for Left Nozzle on H2D Pro** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — Printing with the left nozzle on dual-nozzle printers (H2D/H2D Pro) using AMS-HT failed with \"Failed to get AMS mapping table.\" The global tray ID for AMS-HT units (ams_id >= 128) was calculated as `ams_id * 4 + tray_id` (= 512), but AMS-HT uses the raw `ams_id` (128) since it has a single tray. The backend then misidentified 512 as an external spool. Fixed in frontend tray ID calculation, backend `ams_mapping2` builder, print scheduler, and Spoolman tracking.\n- **H2D Pro L/R Nozzle Hover Card Swapped** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The dual-nozzle hover card had left and right nozzles swapped: nozzle_rack id 0 (extruder 0 = right) was shown as left and vice versa. Serial number and max temp now correctly appear only on the right (removable) nozzle column.\n- **H2C Printer Card Shows H2D Image** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The H2C printer card displayed the H2D printer image because no dedicated H2C image existed in the frontend. Added H2C image and updated `getPrinterImage()` to return it for H2C models.\n- **H2C Nozzle Rack Shows Wrong Empty Slot and Missing Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Empty rack slots always appeared at position 6 instead of their actual position because nozzles were mapped by array index instead of by ID. Fixed by mapping each nozzle to its correct rack position (`id - 16`). Filament colors and materials were missing because the H2C uses different MQTT field names (`color_m`, `fila_id`, `sn`, `tm`) than the H2D (`filament_colour`, `filament_id`, `serial_number`, `max_temp`). Added fallback field name resolution. Also fixed nozzle rack layout breaking on medium card size by allowing the temperature row to wrap.\n\n### Documentation\n- **Advanced Auth via Email** — Updated README, website features page, and wiki authentication guide with SMTP setup, self-service password reset, admin password reset, email templates, and advanced auth overview.\n- **Supported Printers Updated** — Updated README, website, and wiki to list all 12 supported Bambu Lab printer models: X1, X1C, X1E, P1P, P1S, P2S, A1, A1 Mini, H2D, H2D Pro, H2C, H2S. Removed outdated \"Testers Needed\" messaging and Tested/Needs Testing distinctions — all models are now uniformly listed as supported. Added H2C printer image to website. Added H2D Pro, H2C columns to wiki feature comparison tables and new P2 Series section.\n- **CONTRIBUTING.md: i18n & Authentication Guides** — Added Internationalization (i18n) section with locale file conventions, code examples, and parity rules. Added Authentication & Permissions section covering the opt-in auth pattern, permission conventions, and default group structure.\n- **Proxy Mode Security Warning** — Added FTP data channel security warning to wiki, README, and website. Bambu Studio does not encrypt the FTP data channel despite negotiating PROT P; MQTT and FTP control channels are fully TLS-encrypted. VPN (Tailscale/WireGuard) recommended for full data encryption.\n- **Docker Proxy Mode Ports** — Documented FTP passive data ports 50000-50100 required for proxy mode in Docker bridge mode. Updated port mappings in wiki virtual-printer and docker guides.\n- **SSDP Discovery Limitations** — Added table showing when SSDP discovery works (same LAN, dual-homed, Docker host mode) vs when manual IP entry is required (VPN, Docker bridge, port forwarding). Updated wiki, README, and website.\n- **Firewall Rules Updated** — Added port 50000-50100/tcp to all UFW, firewalld, and iptables examples for proxy mode FTP passive data.\n\n### Testing\n- **Mock FTPS Server & Comprehensive FTP Test Suite** — Added 67 automated test cases against a real implicit FTPS mock server, covering every known FTP failure mode from 0.1.8+:\n  - Mock server (`mock_ftp_server.py`) implements implicit TLS, custom AVBL command, and per-command failure injection\n  - Connection tests: auth, SSL modes (prot_p/prot_c), timeout, cache, disconnect edge cases\n  - Upload tests: chunked transfer via `transfercmd()`, progress callbacks, 553/550/552 error handling\n  - Download tests: bytes, to-file, 0-byte regression, large files, missing file cleanup\n  - Model-specific tests: X1C session reuse, A1/A1 Mini prot_c fallback, P1S, unknown model defaults\n  - Async wrapper tests: upload/download/list/delete with A1 fallback and multi-path download\n  - Failure injection tests: regressions for `error_perm` hierarchy, `diagnose_storage` CWD propagation, injection count decrement\n  - Added `pyOpenSSL` to `requirements-dev.txt` for Docker test image compatibility\n- **Nozzle Rack Tests** — Backend: 7 tests for MQTT nozzle_info parsing (H2C 8-entry, H2D 2-entry, H2S single, empty, sorting, field mapping, nozzle state updates). Frontend: 3 tests for rack card rendering (H2C shows 6 slots, empty placeholders, hidden when no rack IDs).\n\n## [0.1.8.1] - 2026-02-07\n\n### Fixed\n- **FTP Upload Broken on All Printer Models** — Fixed critical bug where all FTP uploads failed with \"550 Failed to change directory\":\n  - `diagnose_storage()` was running before every upload, and its CWD failures (`ftplib.error_perm`) were not caught because `error_perm` is not a subclass of `error_reply`\n  - Removed `diagnose_storage()` from the upload hot path\n  - Changed all FTP exception handlers from `except (OSError, ftplib.error_reply)` to `except (OSError, ftplib.Error)` to catch all FTP error types\n- **HTTP 500 on Reprint and Print Endpoints** — Fixed 500 errors on `/api/v1/archives/{id}/reprint` and `/api/v1/library/files/{id}/print` caused by the FTP failure above\n- **Exception Handling Reverted** — Reverted overly-narrow exception handling introduced in 0.1.8 that could cause uncaught errors in archive parsing, HTTP clients, 3MF/ZIP processing, Home Assistant, and firmware checks\n- **HTTP 500 on Printer Cover Image** — Fixed 500 error on `/api/v1/printers/{id}/cover` when FTP download returned 0 bytes but reported success; now retries and falls back to 404\n- **4-Segment Version Support** — Version parser now supports patch releases like `0.1.8.1` for hotfixes without incrementing the minor version\n\n## [0.1.8] - 2026-02-06\n\n### Security\n- **XML External Entity (XXE) Prevention**:\n  - Replaced `xml.etree.ElementTree` with `defusedxml` across all 3MF parsing code\n  - Prevents XXE attacks through malicious 3MF files\n  - Detected by Bandit B314 security scanner\n- **Path Injection Vulnerabilities Fixed**:\n  - Added path traversal validation to project attachment endpoints\n  - Strengthened filename sanitization in timelapse processing\n  - Prevents directory traversal attacks via `../` sequences\n  - Detected by CodeQL security scanner\n- **Security Scanning in CI/CD**:\n  - Added Bandit (Python security analyzer) with SARIF upload to GitHub Security\n  - Added Trivy (container/IaC scanner) for Docker image and Dockerfile analysis\n  - Added pip-audit and npm-audit for dependency vulnerability scanning\n  - Automatic GitHub issue creation for detected vulnerabilities\n  - Security scan results visible in GitHub Security tab\n- **CodeQL Zero-Finding Baseline**:\n  - Reduced CodeQL findings from 591 to 0 across Python, JavaScript, and GitHub Actions\n  - Created custom query suites (`.codeql/python-bambuddy.qls`, `.codeql/javascript-bambuddy.qls`) with documented accepted-risk exclusions\n  - All exclusions reviewed and justified (log injection parameterized, cyclic imports from SQLAlchemy ORM, intentional 0.0.0.0 binds, etc.)\n- **Log Injection Prevention**:\n  - Converted ~700 f-string log calls to parameterized `%s` style across all backend files\n  - Prevents log injection via newlines or fake log entries in user-controlled data\n- **Exception Handling Hardened**:\n  - Narrowed ~265 bare `except Exception` blocks to specific types (`OSError`, `KeyError`, `ValueError`, `zipfile.BadZipFile`, `sqlalchemy.exc.OperationalError`, etc.)\n- **Stack Trace Exposure Fixed**:\n  - Replaced `str(e)` with generic error messages in HTTP responses (`updates.py`)\n  - Detailed errors still logged server-side for debugging\n- **SSRF Mitigations Added**:\n  - Home Assistant integration: URL scheme/hostname validation, metadata-service blocking (`homeassistant.py`)\n  - Tasmota integration: IP validation blocking loopback and link-local addresses (`tasmota.py`)\n- **Hashlib Security Annotations**:\n  - Added `usedforsecurity=False` to non-security hash calls (MD5 for AMS fingerprinting, SHA1 for git blob format)\n- **Unused Code Removal**:\n  - Removed ~30 redundant function-level imports, unused variables, dead code, and trivial conditions flagged by CodeQL\n- **Local Security Scanner Improvements**:\n  - `test_security.sh` uses `--threads=0` for all CodeQL commands (auto-detects CPU cores)\n  - Added `.trivyignore` to suppress accepted Dockerfile USER directive finding\n\n### Enhancements\n- **Per-Filament Spoolman Usage Tracking** (PR #277):\n  - Reports exact filament consumption per spool to Spoolman after each print\n  - Parses G-code from 3MF files for layer-by-layer extrusion data (multi-material support)\n  - New setting: \"Disable AMS Estimated Weight Sync\" to prefer Spoolman usage tracking over AMS weight estimates\n  - New setting: \"Report Partial Usage for Failed Prints\" estimates filament used up to the failure point based on layer progress\n  - Persists tracking data in SQLite for reliability across restarts\n  - Extracted Spoolman tracking into dedicated service module with DRY helpers\n- **3D Model Viewer Improvements** (PR #262):\n  - Added plate selector for multi-plate 3MF files with thumbnail previews\n  - Object count display shows number of objects per plate and total\n  - Fullscreen toggle for immersive model viewing\n  - Resizable split view between plate selector and 3D viewer in fullscreen mode\n  - Pagination support for files with many plates (e.g., 50+ plates)\n  - Added i18n translations for all model viewer strings (English, German, Japanese)\n- **Virtual Printer Proxy Mode Improvements**:\n  - SSDP proxy for cross-network setups: select slicer network interface for automatic printer discovery via SSDP relay\n  - FTP proxy now listens on privileged port 990 (matching Bambu Studio expectations) instead of 9990\n  - For systemd: requires `AmbientCapabilities=CAP_NET_BIND_SERVICE` capability\n  - Automatic directory permission checking at startup with clear error messages for Docker/bare metal\n  - Updated translations for proxy mode steps in English, German, and Japanese\n\n### Fixed\n- **Authentication Required Error After Initial Setup** (Issue #257):\n  - Fixed \"Authentication required\" error when using printer controls after fresh install with auth enabled\n  - Token clearing on 401 responses is now more selective - only clears on invalid token messages\n  - Generic \"Authentication required\" errors (which may be timing issues) no longer clear the token\n  - Also fixed smart plug discovery scan endpoints missing auth headers\n- **Filament Hover Card Overlapping Navigation Bar** (Issue #259):\n  - Fixed filament info popup being partially covered by the navigation bar\n  - Hover card positioning now accounts for the fixed 56px header\n  - Cards near the top of the page now correctly flip to show below the slot\n- **Filament Statistics Incorrectly Multiplied by Quantity** (Issue #229):\n  - Fixed filament totals being inflated by incorrectly multiplying by quantity\n  - The `filament_used_grams` field already contains the total for the entire print job\n  - Removed incorrect `* quantity` multiplication from archive stats, Prometheus metrics, and FilamentTrends chart\n  - Example: A print with 26 objects using 126g was incorrectly shown as 3,276g\n- **Print Queue Status Does Not Match Printer Status** (Issue #249):\n  - Queue now shows \"Paused\" when the printer is paused instead of \"Printing\"\n  - Fetches real-time printer state for actively printing queue items\n  - Added translations for paused status in English, German, and Japanese\n- **Queue Scheduled Time Displayed in Wrong Timezone** (Issue #233):\n  - Fixed scheduled time being displayed in UTC instead of local timezone when editing queue items\n  - The datetime picker now correctly shows and saves times in the user's local timezone\n- **Mobile Layout Issues on Archives and Statistics Pages** (Issue #255):\n  - Fixed header buttons overflowing outside the screen on iPhone/mobile devices\n  - Headers now stack vertically on small screens with proper wrapping\n  - Applied consistent responsive pattern from PrintersPage\n- **AMS Auto-Matching Selects Wrong Slot** (Issue #245):\n  - Fixed AMS slot mapping when multiple trays have the same `tray_info_idx` (filament type identifier)\n  - `tray_info_idx` (e.g., \"GFA00\" for generic PLA) identifies filament TYPE, not unique spools\n  - When multiple trays match the same type, color is now used as a tiebreaker\n  - Previously used `find()` which always returned the first match regardless of color\n  - Fixed in both backend (print_scheduler.py) and frontend (useFilamentMapping.ts)\n  - Resolves wrong tray selection (e.g., A4 instead of B1) when multiple AMS units have same filament type\n- **A1/A1 Mini FTP Upload Failures** (Issue #271):\n  - Fixed FTP uploads hanging/timing out on A1 and A1 Mini printers\n  - Replaced `storbinary()` with manual chunked transfer using `transfercmd()`\n  - A1's FTP server has issues with Python's `storbinary()` waiting for completion response\n  - Uses 1MB chunks with explicit 120s socket timeout for reliable transfers\n  - Works for all printer models (X1C, P1S, P1P, A1, A1 Mini)\n- **P1S/P1P FTP Upload Failures**:\n  - Fixed FTP uploads failing with EOFError on P1S and P1P printers\n  - These printers use vsFTPd which requires SSL session reuse on data channel\n  - Removed P1S/P1P from skip-session-reuse list (they were incorrectly added)\n- **FTP Auto-Detection for A1 Printers**:\n  - Automatically detects working FTP mode (prot_p vs prot_c) for A1/A1 Mini\n  - Tries encrypted data channel first, falls back to clear if needed\n  - Caches working mode per printer IP to avoid repeated detection\n- **Safari Camera Stream Failing**:\n  - Fixed camera streams not loading in Safari due to Service Worker error\n  - Safari has stricter Service Worker scope requirements\n- **Queue Print Time for Multi-Plate Files** (PR #274):\n  - Fixed print time showing total for all plates instead of selected plate\n  - Now extracts per-plate print time from 3MF slice_info.config\n  - Contributed by MisterBeardy\n- **Docker Permissions**:\n  - Added user directive to docker-compose.yml using PUID/PGID environment variables\n  - Allows container to run as host user, fixing permission issues with bind-mounted volumes\n  - Usage: `PUID=$(id -u) PGID=$(id -g) docker compose up -d`\n\n### Added\n- **Windows Portable Launcher** (contributed by nmori):\n  - New `start_bambuddy.bat` for Windows users - double-click to run, no installation required\n  - Automatically downloads Python 3.13 and Node.js 22 on first run (portable, no system changes)\n  - Everything stored in `.portable\\` folder for easy cleanup\n  - Commands: `start_bambuddy.bat` (launch), `start_bambuddy.bat update` (update deps), `start_bambuddy.bat reset` (clean start)\n  - Custom port via `set PORT=9000 & start_bambuddy.bat`\n  - Verifies all downloads with SHA256 checksums for security\n  - Supports both x64 and ARM64 Windows systems\n\n## [0.1.7] - 2026-02-03\n\n### Security\n- **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):\n  - Added authentication to 200+ API endpoints that were previously unprotected\n  - All route files now use `RequirePermissionIfAuthEnabled()` for permission checks\n  - Protected endpoints: archives, projects, settings, API keys, groups, cloud, notifications, maintenance, filaments, external links, smart plugs, discovery, firmware, camera, k-profiles, AMS history, pending uploads, updates, spoolman, system, print queue, printers\n  - Image-serving endpoints (thumbnails, timelapse, photos, camera streams) remain public as they require knowing the resource ID and are loaded via `<img>` tags which cannot send Authorization headers\n  - Backend integration tests added to verify endpoint authentication enforcement\n\n### Enhancements\n- **TOTP Authenticator Support for Bambu Cloud** (Issue #182):\n  - Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud\n  - Accounts with authenticator apps (Google Authenticator, Authy, etc.) now work correctly\n  - Proper detection of verification type: email code vs TOTP code\n  - Uses browser-like headers to bypass Cloudflare protection on TFA endpoint\n  - Frontend shows appropriate message for each verification type\n  - Added translations for TOTP UI in English, German, and Japanese\n- **Spoolman: Open in Spoolman Button** (Issue #210):\n  - FilamentHoverCard now shows \"Open in Spoolman\" button when spool is already linked in Spoolman\n  - Button links directly to the spool's page in Spoolman for quick editing\n  - \"Link to Spoolman\" button now only shows when spool is not yet linked\n  - Link button correctly disabled when no unlinked spools are available in Spoolman\n  - Toast notification shown on successful/failed spool linking\n  - Added `/api/v1/spoolman/spools/linked` endpoint returning map of linked spool tags to IDs\n- **Complete German Translations**:\n  - All UI strings now fully translated to German (1800+ translation keys)\n  - Pages translated: Settings, Archives, File Manager, Queue, Printers, Profiles, Projects, Stats, Maintenance, Camera, Groups, Users, Login, Setup, Stream Overlay\n  - Components translated: ConfirmModal, LinkSpoolModal, FilamentHoverCard, Layout\n  - Added locale parity test to ensure English and German stay in sync\n- **Virtual Printer Proxy Mode**:\n  - New \"Proxy\" mode allows remote printing over any network by relaying slicer traffic to a real printer\n  - Configure a target printer and Bambuddy acts as a TLS proxy between your slicer and the printer\n  - Supports both FTP (port 990) and MQTT (port 8883) protocols with full TLS encryption\n  - Slicer connects to Bambuddy using the real printer's access code\n  - Real-time status display showing active FTP/MQTT connections\n  - Target printer selector with validation (must be configured in Bambuddy)\n  - Proxy mode bypasses the access code requirement (uses the real printer's credentials)\n  - Full i18n support for all proxy mode UI strings (English, German, Japanese)\n\n### Fixed\n- **Cannot Link Multiple HA Entities to Same Printer** (Issue #214):\n  - Fixed Home Assistant entities being limited to one per printer\n  - Both frontend and backend were blocking printers that already had any smart plug linked\n  - Now only Tasmota plugs are limited to one per printer (physical device constraint)\n  - Multiple HA entities (switches, scripts, lights, etc.) can be linked to the same printer\n  - Restored \"Show on Printer Card\" toggle for HA entities to control visibility on printer cards\n  - Fixed printer card only showing `script.*` entities; now shows all HA entities with toggle enabled\n  - HA entities now default to auto_on=False and auto_off=False (appropriate for automations)\n  - Printer cards now update immediately when HA entities are added/modified/deleted\n- **Monthly Comparison Calculation Off** (Issue #229):\n  - Fixed filament statistics not accounting for quantity multiplier\n  - Monthly comparison chart now correctly multiplies `filament_used_grams` by `quantity`\n  - Daily and weekly charts also now account for quantity\n  - Filament type breakdown includes quantity in calculations\n  - Backend stats endpoint (`/archives/stats`) and Prometheus metrics also fixed\n  - Prints count now shows total items (sum of quantities) instead of archive count\n- **Authentication Required for Downloads** (Issue #231):\n  - Fixed support bundle download returning 401 Unauthorized when auth is enabled\n  - Fixed archive export (CSV/XLSX) failing with authentication enabled\n  - Fixed statistics export failing with authentication enabled\n  - Fixed printer file ZIP download failing with authentication enabled\n  - Root cause: These endpoints used raw `fetch()` without Authorization header\n- **Queue Schedule Date Picker Ignores User Format Settings** (Issue #233):\n  - Replaced native datetime picker with custom date/time inputs respecting user settings\n  - Date input shows in user's format (DD/MM/YYYY for EU, MM/DD/YYYY for US, YYYY-MM-DD for ISO)\n  - Time input shows in user's format (24H or 12H with AM/PM)\n  - Calendar button opens native picker for convenience; selection is formatted to user's preference\n  - Placeholder text shows expected format (e.g., \"DD/MM/YYYY\" or \"HH:MM AM/PM\")\n  - Added date utilities: `formatDateInput`, `parseDateInput`, `getDatePlaceholder`\n  - Added time utilities: `formatTimeInput`, `parseTimeInput`, `getTimePlaceholder`\n- **500 Error on Archive Detail Page**:\n  - Fixed internal server error when viewing individual archive details\n  - Root cause: `project` relationship not eagerly loaded in `get_archive()` service method\n  - Async SQLAlchemy requires explicit eager loading; lazy loading is not supported\n\n## [0.1.6.2] - 2026-02-02\n\n> **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.\n\n### Security\n- **Critical: Hardcoded JWT Secret Key** (GHSA-gc24-px2r-5qmf, CWE-321) - Fixed hardcoded JWT secret key that could allow attackers to forge authentication tokens:\n  - JWT secret now loaded from `JWT_SECRET_KEY` environment variable (recommended for production)\n  - Falls back to auto-generated `.jwt_secret` file in data directory with secure permissions (0600)\n  - Generates cryptographically secure 64-byte random secret if neither exists\n  - **Action Required**: Existing users will need to re-login after upgrading\n- **Critical: Missing API Authentication** (GHSA-gc24-px2r-5qmf, CWE-306) - Fixed 77+ API endpoints that lacked authentication checks:\n  - Added HTTP middleware enforcing authentication on ALL `/api/` routes when auth is enabled\n  - Only essential public endpoints are exempt (login, auth status, version check, WebSocket)\n  - All other API calls now require valid JWT token or API key\n\n### Enhancements\n- **Location Filter for Queue** (Issue #220):\n  - Filter queue jobs by printer location in the Queue page\n  - \"Any {Model}\" queue assignments can now specify a target location (e.g., \"Any X1C in Workshop\")\n  - Location filter dropdown shows all unique locations from printers and queue items\n  - Location is saved with queue items and displayed in the queue list\n- **Ownership-Based Permissions** (Issue #205):\n  - Users can now only update/delete their own items unless they have elevated permissions\n  - Update/delete permissions split into `*_own` and `*_all` variants:\n    - `queue:update_own` / `queue:update_all`\n    - `queue:delete_own` / `queue:delete_all`\n    - `archives:update_own` / `archives:update_all`\n    - `archives:delete_own` / `archives:delete_all`\n    - `archives:reprint_own` / `archives:reprint_all`\n    - `library:update_own` / `library:update_all`\n    - `library:delete_own` / `library:delete_all`\n  - Administrators group gets `*_all` permissions (can modify any items)\n  - Operators group gets `*_own` permissions (can only modify their own items)\n  - Ownerless items (legacy data without creator) require `*_all` permission\n  - Bulk operations skip items user doesn't have permission to modify\n  - User deletion now offers choice: delete user's items or keep them (become ownerless)\n  - Backend enforces permissions on all API endpoints (not just frontend UI)\n  - Automatic migration upgrades existing groups to new permission model\n- **User Tracking for Archives, Library & Queue** (Issue #206):\n  - Track and display who uploaded each archive file\n  - Track and display who uploaded each library file (File Manager)\n  - Track and display who added each print job to the queue\n  - Shows username on archive cards, library files, queue items, and printer cards (while printing)\n  - Works when authentication is enabled; gracefully hidden when auth is disabled\n  - Database migration adds `created_by_id` columns to `print_archives`, `library_files`, and `print_queue` tables\n- **Separate AMS RFID Permission** (Issue #204):\n  - Added new `printers:ams_rfid` permission for re-reading AMS RFID tags\n  - Allows granting RFID re-read access without full printer control permissions\n  - Operators group includes this permission by default\n  - Available in Settings > Users > Group Editor as a toggleable permission\n- **Schedule Button on Archive Cards** (Issue #208):\n  - Added \"Schedule\" button next to \"Reprint\" on archive cards for quick access to print scheduling\n  - Previously only available in the context menu (right-click)\n  - Respects `queue:create` permission for users with restricted access\n- **Streaming Overlay Improvements** (Issue #164):\n  - **Configurable FPS**: Add `?fps=30` parameter to control camera frame rate (1-30, default 15)\n  - **Status-only mode**: Add `?camera=false` parameter to hide camera and show only status overlay on black background\n  - Increased default camera FPS from 10 to 15 for smoother video across all camera views\n- **Simplified Backup/Restore System**:\n  - Complete backup now creates a single ZIP file containing the entire database and all data directories\n  - Includes: database, archives, library files, thumbnails, timelapses, icons, projects, and plate calibration data\n  - Portable backups: works across different installations and data directories\n  - Faster backup/restore: direct file copy instead of JSON export/import\n  - Progress indicator and navigation blocking during backup/restore operations\n  - Removed ~2000 lines of legacy JSON-based backup/restore code\n\n### Fixes\n- **File Manager permissions not enforced** (Issue #224) - Fixed backend not checking `library:read` permission for File Manager endpoints:\n  - Added `library:read` permission check to all list/view endpoints (files, folders, stats)\n  - Added `library:upload` permission check to upload and folder creation endpoints\n  - Added `queue:create` permission check to add-to-queue endpoint\n  - Added `printers:control` permission check to direct print endpoint\n  - Added ownership-based permission checks to file move operation\n  - Users without `library:read` permission can no longer view files in the File Manager\n  - Users can now only delete/update their own files unless they have `*_all` permissions\n- **JWT secret key not persistent across restarts** - Fixed JWT secret key generation to properly use data directory, ensuring tokens remain valid across container restarts\n- **Images/thumbnails returning 401 when auth enabled** - Fixed auth middleware to allow public access to image/media endpoints (thumbnails, photos, QR codes, timelapses, camera streams) since browser elements like `<img>` don't send Authorization headers\n- **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems:\n  - Library now stores relative paths in database for portability\n  - Automatic migration converts existing absolute paths to relative on startup\n  - Thumbnails and files now display correctly after restoring backups\n- **File uploads failing with authentication enabled** - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled\n- **External spool AMS mapping causing \"Failed to get AMS mapping table\"** (Issue #213) - Fixed external spool `ams_mapping2` slot_id handling that caused AMS mapping failures\n- **Filename matching for files with spaces** (Issue #218) - Fixed file detection when filenames contain spaces\n- **P2S FTP upload failure** (Issue #218) - Fixed FTP uploads to P2S printers by passing `skip_session_reuse` to ImplicitFTP_TLS\n- **Printer deletion freeze** (Issue #214) - Fixed UI freeze when deleting printers, and now allows multiple smart plugs per printer\n- **Stack trace exposure in error responses** (CodeQL Alert #68) - Fixed stack traces being exposed in API error responses in archives.py\n- **Printer serial numbers exposed in support bundle** (Issue #216) - Sanitized printer serial numbers in support bundle logs for privacy\n- **Missing sliced_for_model migration** (Issue #211) - Fixed database migration for `sliced_for_model` column that was missing in some upgrade paths\n\n## [0.1.6-final] - 2026-01-31\n\n### New Features\n- **Group-Based Permissions** - Granular access control with user groups:\n  - Create custom groups with specific permissions (50+ granular permissions)\n  - Default system groups: Administrators (full access), Operators (control printers), Viewers (read-only)\n  - Users can belong to multiple groups with additive permissions\n  - Permission-based UI: buttons/features disabled when user lacks permission\n  - Groups management page in Settings → Users → Groups tab\n  - Change password: users can change their own password from sidebar\n  - Included in backup/restore\n- **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):\n  - Checkbox option when uploading STL files to generate thumbnails automatically\n  - Batch generate thumbnails for existing STL files via \"Generate Thumbnails\" button\n  - Individual file thumbnail generation via context menu (three-dot menu)\n  - Works with ZIP extraction (generates thumbnails for all STL files in archive)\n  - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme\n  - Thumbnails auto-refresh in UI after generation\n  - Graceful handling of complex/invalid STL files\n- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):\n  - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay\n  - Real-time print progress, ETA, layer count, and filename display\n  - Bambuddy logo branding (links to GitHub)\n  - Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`\n  - No authentication required - designed for OBS browser source embedding\n  - Gradient overlay at bottom for readable text over camera feed\n  - Auto-reconnect on camera stream errors\n- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173):\n  - New \"MQTT\" plug type alongside Tasmota and Home Assistant\n  - Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.)\n  - **Separate topics per data type**: Configure different MQTT topics for power, energy, and state\n  - Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`)\n  - **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh)\n  - **Custom ON value**: Configure what value means \"ON\" for state (e.g., \"ON\", \"true\", \"1\")\n  - Monitor-only: displays power/energy data without control capabilities\n  - Reuses existing MQTT broker settings from Settings → Network\n  - Energy data included in statistics and per-print tracking\n  - Full backup/restore support for MQTT plug configurations\n- **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:\n  - Prevents Bambuddy from checking Bambu Lab servers for firmware updates\n  - Useful for users who prefer to manage firmware manually or have network restrictions\n- **Archive Plate Browsing** - Browse plate thumbnails directly in archive cards (Issue #166):\n  - Hover over archive card to reveal plate navigation for multi-plate files\n  - Left/right arrows to cycle through plate thumbnails\n  - Dot indicators show current plate (clickable to jump to specific plate)\n  - Lazy-loads plate data only when user hovers\n- **GitHub Profile Backup** - Automatically backup your Cloud profiles, K-profiles and settings to a GitHub repository:\n  - Configure GitHub repository URL and Personal Access Token\n  - Schedule backups hourly, daily, or weekly\n  - Manual on-demand backup trigger\n  - Backs up K-profiles (per-printer), cloud profiles, and app settings\n  - Skip unchanged commits (only creates commit when data changes)\n  - Real-time progress tracking during backup\n  - Backup history log with status and commit links\n  - Requires Bambu Cloud login for full profile access\n  - New Settings → Backup & Restore tab (local backup/restore moved here)\n  - Included in local backup/restore (except PAT for security)\n- **Plate Not Empty Notification** - Dedicated notification category for build plate detection:\n  - New toggle in notification provider settings (enabled by default)\n  - Sends immediately (bypasses quiet hours and digest mode)\n  - Separate from general printer errors for granular control\n- **USB Camera Support** - Connect USB webcams directly to your Bambuddy host:\n  - New \"USB Camera (V4L2)\" option in external camera settings\n  - Auto-detection of available USB cameras via V4L2\n  - API endpoint to list connected USB cameras (`GET /api/v1/printers/usb-cameras`)\n  - Works with any V4L2-compatible camera on Linux\n  - Uses ffmpeg for frame capture and streaming\n- **Build Plate Empty Detection** - Automatically detect if objects are on the build plate before printing:\n  - Per-printer toggle to enable/disable plate detection\n  - Multi-reference calibration: Store up to 5 reference images of empty plates (different plate types)\n  - Automatic print pause when objects detected on plate at print start\n  - Push notification and WebSocket alert when print is paused due to plate detection\n  - ROI (Region of Interest) calibration UI with sliders to focus detection on build plate area\n  - Reference management: View thumbnails, add labels, delete references\n  - Works with both built-in and external cameras\n  - Uses buffered camera frames when stream is active (no blocking)\n  - Split button UI: Main button toggles detection on/off, chevron opens calibration modal\n  - Green visual indicator when plate detection is enabled\n  - Included in backup/restore\n- **Project Import/Export** - Export and import projects with full file support (Issue #152):\n  - Export single project as ZIP (includes project settings, BOM, and all files from linked library folders)\n  - Export all projects as JSON for metadata-only backup\n  - Import from ZIP (with files) or JSON (metadata only)\n  - Linked folders and files are automatically created on import\n  - Useful for sharing complete project bundles or migrating between instances\n- **BOM Item Editing** - Bill of Materials items are now fully editable:\n  - Edit name, quantity, price, URL, and remarks after creation\n  - Pencil icon on each BOM item to enter edit mode\n- **Prometheus Metrics Endpoint** - Export printer telemetry for external monitoring systems (Issue #161):\n  - Enable via Settings → Network → Prometheus Metrics\n  - Endpoint: `GET /api/v1/metrics` (Prometheus text format)\n  - Optional bearer token authentication for security\n  - Printer metrics: connection status, state, temperatures (bed, nozzle, chamber), fans, WiFi signal\n  - Print metrics: progress, remaining time, layer count\n  - Statistics: total prints by status, filament used, print time\n  - Queue metrics: pending and active jobs\n  - System metrics: connected printers count\n  - Labels include printer_id, printer_name, serial for filtering\n  - Ready for Grafana dashboards\n- **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):\n  - Link archives to Printables, Thingiverse, or any other URL\n  - Globe button opens external link when set, falls back to auto-detected MakerWorld URL\n  - Edit via archive edit modal\n  - Included in backup/restore\n- **External Network Camera Support** - Add external cameras (MJPEG, RTSP, HTTP snapshot) to replace built-in printer cameras (Issue #143):\n  - Configure per-printer external camera URL and type in Settings → Camera\n  - Live streaming uses external camera when enabled\n  - Finish photo capture uses external camera\n  - Layer-based timelapse: captures frame on each layer change, stitches to MP4 on print completion\n  - Test connection button to verify camera accessibility\n- **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)\n- **Create Folder from ZIP** - New option in File Manager upload to automatically create a folder named after the ZIP file (Issue #121)\n- **Multi-File Selection in Printer Files** - Printer card file browser now supports multiple file selection (Issue #144):\n  - Checkbox selection for individual files\n  - Select All / Deselect All buttons\n  - Bulk download as ZIP when multiple files selected\n  - Bulk delete for multiple files at once\n- **Queue Bulk Edit** - Select and edit multiple queue items at once (Issue #159):\n  - Checkbox selection for pending queue items\n  - Select All / Deselect All in toolbar\n  - Bulk edit: printer assignment, print options, queue options\n  - Bulk cancel selected items\n  - Tri-state toggles: unchanged / on / off for each setting\n\n### Fixes\n- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):\n  - Queue now displays the correct plate thumbnail based on selected plate\n  - Previously always showed plate 1 thumbnail regardless of selection\n- **A1/A1 Mini Shows Printing Instead of Idle** - Fixed incorrect status display for A1 series printers (Issue #168):\n  - Some A1/A1 Mini firmware versions incorrectly report stage 0 (\"Printing\") when idle\n  - Now checks gcode_state to correctly display \"Idle\" for affected printers\n  - Fix only applies to A1 models with the specific buggy condition\n- **HMS Error Notifications** - Get notified when printer errors occur (Issue #84):\n  - Automatic notifications for HMS errors (AMS issues, nozzle problems, etc.)\n  - Human-readable error messages (853 error codes translated)\n  - Friendly error type names (Print/Task, AMS/Filament, Nozzle/Extruder, Motion Controller, Chamber)\n  - Deduplication prevents spam from repeated error messages\n  - Publishes to MQTT relay for home automation integrations\n  - New \"Printer Error\" toggle in notification provider settings\n- **Plate Calibration Persistence** - Fixed plate detection reference images not persisting after restart in Docker deployments\n- **Telegram Notification Parsing** - Fixed Telegram markdown parsing errors when messages contain underscores (e.g., error codes)\n- **Settings API PATCH Method** - Added PATCH support to `/api/settings` for Home Assistant rest_command compatibility (Issue #152)\n- **P2S Empty Archive Tiles** - Fixed FTP file search for printers without SD card (Issue #146):\n  - Added root folder `/` to search paths when looking for 3MF files\n  - Printers without SD card store files in root instead of `/cache`\n- **Empty AMS Slot Not Recognized** - Fixed bug where removed spools still appeared in Bambuddy (Issue #147):\n  - Old AMS: Now properly applies empty values from tray data updates\n  - New AMS (AMS 2 Pro): Now checks `tray_exist_bits` bitmask to detect and clear empty slots\n- **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints\n- **HA Energy Sensors Not Detected** - Home Assistant energy sensors with lowercase units (w, kwh) are now properly detected; unit matching is now case-insensitive (Issue #119)\n- **File Manager Upload** - Upload modal now accepts all file types, not just ZIP files\n- **Camera Zoom & Pan Improvements** - Enhanced camera viewer zoom/pan functionality (Issue #132):\n  - Pan range now based on actual container size, allowing full navigation of zoomed image\n  - Added pinch-to-zoom support for mobile/touch devices\n  - Added touch-based panning when zoomed in\n  - Both embedded camera viewer and standalone camera page updated\n- **Progress Milestone Time** - Fixed milestone notifications showing wrong time (e.g., \"17m\" instead of \"17h 47m\") by converting remaining_time from minutes to seconds (Issue #157)\n- **File Manager Folder Navigation** - Improved handling of long folder names (Issue #160):\n  - Resizable sidebar: Drag the edge to adjust width (200-500px), double-click to reset\n  - Text wrap toggle: \"Wrap\" button in header to wrap long names instead of truncating\n  - Both settings persist in localStorage\n  - Tooltip shows full name on hover\n- **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\n- **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes\n- **Model-Based Queue AMS Mapping** - Fixed \"Any [Model]\" queue jobs failing at filament loading on H2D Pro and other printers (Issue #192):\n  - Scheduler now computes AMS mapping after printer assignment for model-based jobs\n  - Previously, no AMS mapping was sent because the specific printer wasn't known at queue time\n  - Auto-matches required filaments to available AMS slots by type and color\n\n### Maintenance\n- Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies\n\n## [0.1.6b11] - 2026-01-22\n\n### New Features\n- **Camera Zoom & Fullscreen** - Enhanced camera viewer controls:\n  - Fullscreen mode for embedded camera viewer (new button in header)\n  - Zoom controls (100%-400%) for both embedded and window modes\n  - Pan support when zoomed in (click and drag)\n  - Mouse wheel zoom support\n  - Zoom resets on mode switch, refresh, or fullscreen toggle\n- **Searchable HA Entity Selection** - Improved Home Assistant smart plug configuration:\n  - Entity dropdown replaced with searchable combobox\n  - Type to search across all HA entities (not just switch/light/input_boolean)\n  - Energy sensor dropdowns (Power, Energy Today, Total) are now searchable\n  - Find sensors with non-standard naming that don't match the switch entity name\n- **Home Assistant Energy Sensor Support** - HA smart plugs can now use separate sensor entities for energy monitoring:\n  - Configure dedicated power sensor (W), today's energy (kWh), and total energy (kWh) sensors\n  - Supports plugs where energy data is exposed as separate sensor entities (common with Tapo, IKEA Zigbee2mqtt, etc.)\n  - Energy sensors are selectable from all available HA sensors with power/energy units\n  - Falls back to switch entity attributes if no sensors configured\n  - Print energy tracking now works correctly for HA plugs (not just Tasmota)\n  - New API endpoint: `GET /api/v1/smart-plugs/ha/sensors` to list available energy sensors\n- **Finish Photo in Notifications** - Camera snapshot URL available in notification templates (Issue #126):\n  - New `{finish_photo_url}` template variable for print_complete, print_failed, print_stopped events\n  - Photo is captured before notification is sent (ensures image is available)\n  - New \"External URL\" setting in Settings → Network (auto-detects from browser)\n  - Full URL constructed for external notification services (Telegram, Email, Discord, etc.)\n- **ZIP File Support in File Manager** - Upload and extract ZIP files directly in the library (Issue #121):\n  - Drop or select ZIP files to automatically extract contents\n  - Option to preserve folder structure from ZIP or extract flat\n  - Extracts thumbnails and metadata from 3MF/gcode files inside ZIP\n  - Progress indicator shows number of files extracted\n\n### Fixed\n- **Print time stats using slicer estimates** - Quick Stats \"Print Time\" now uses actual elapsed time (`completed_at - started_at`) instead of slicer estimates; cancelled prints only count time actually printed (Issue #137)\n- **Skip objects modal overflow** - Modal now has max height (85vh) with scrollable object list when printing many items on the bed (Issue #134)\n- **Filament cost using wrong default** - Statistics now correctly uses the \"Default filament cost (per kg)\" setting instead of hardcoded €25 value (Issue #120)\n- **Spoolman tag field not auto-created** - The required \"tag\" extra field is now automatically created in Spoolman on first connect, fixing sync failures for fresh Spoolman installs (Issue #123)\n- **P2S/X1E/H2 completion photo not captured** - Internal model codes (N7, C13, O1D, etc.) from MQTT/SSDP are now recognized for RTSP camera support (Issue #127)\n- **Mattermost/Slack webhook 400 error** - Added \"Slack / Mattermost\" payload format option that sends `{\"text\": \"...\"}` instead of custom fields (Issue #133)\n- **Subnet scan serial number** - Fixed A1 Mini subnet discovery showing \"unknown-*\" placeholder; serial field is now cleared so users know to enter it manually (Issue #140)\n\n## [0.1.6b10] - 2026-01-21\n\n### New Features\n- **Unified Print Modal** - Consolidated three separate modals into one unified component:\n  - Single modal handles reprint, add-to-queue, and edit-queue-item operations\n  - Consistent UI/UX across all print operations\n  - Reduced code duplication (~1300 LOC removed)\n- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:\n  - Checkbox selection for multiple printers in reprint and add-to-queue modes\n  - \"Select all\" / \"Clear\" buttons for quick selection\n  - Progress indicator during multi-printer submission\n  - Ideal for print farms with identical filament configurations\n- **Per-Printer AMS Mapping** - Configure filament slot mapping individually for each printer:\n  - Enable \"Custom mapping\" checkbox under each selected printer\n  - Auto-configure uses RFID data to match filaments automatically\n  - Manual override for specific slot assignments\n  - Match status indicator shows exact/partial/missing matches\n  - Re-read button to refresh printer's loaded filaments\n  - New setting in Settings → Filament to expand custom mapping by default\n- **Enhanced Add-to-Queue** - Now includes plate selection and print options:\n  - Configure all print settings upfront instead of editing afterward\n  - Filament mapping with manual override capability\n- **Print from File Manager** - Full print configuration when printing from library files:\n  - Plate selection for multi-plate 3MF files with thumbnails\n  - Filament slot mapping with comparison to loaded filaments\n  - All print options (bed levelling, flow calibration, etc.)\n- **File Manager Print Button** - Print directly from multi-selection toolbar:\n  - \"Print\" button appears when exactly one sliced file is selected\n  - Opens full PrintModal with plate selection and print options\n  - \"Add to Queue\" button now uses Clock icon for clarity\n- **Multiple Embedded Camera Viewers** - Open camera streams for multiple printers simultaneously in embedded mode:\n  - Each viewer has its own remembered position and size\n  - New viewers are automatically offset to prevent stacking\n  - Printer-specific persistence in localStorage\n  - **Navigation persistence** - Open cameras stay open when navigating away and back to Printers page\n- **Application Log Viewer** - View and filter application logs in real-time from System Information page:\n  - Start/Stop live streaming with 2-second auto-refresh\n  - Filter by log level (DEBUG, INFO, WARNING, ERROR)\n  - Text search across messages and logger names\n  - Clear logs with one click\n  - Expandable multi-line log entries (stack traces, etc.)\n  - Auto-scroll to follow new entries\n- **Deferred archive creation** - Queue items from File Manager no longer create archives upfront:\n  - Queue items store `library_file_id` directly\n  - Archives are created automatically when prints start\n  - Reduces clutter in Archives from unprinted queued files\n  - Queue displays library file name, thumbnail, and print time\n- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:\n  - 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)\n  - Click \"+\" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)\n  - Click \"-\" to collapse back to basic colors\n- **File Manager Sorting** - Printer file manager now has sorting options:\n  - Sort by name (A-Z or Z-A)\n  - Sort by size (smallest or largest first)\n  - Sort by date (oldest or newest first)\n  - Directories always sorted first\n- **Camera View Mode Setting** - Choose how camera streams open:\n  - \"New Window\" (default): Opens camera in a separate browser window\n  - \"Embedded\": Shows camera as a floating overlay on the main screen\n  - Embedded viewer is draggable and resizable with persistent position/size\n  - Configure in Settings → General → Camera section\n- **File Manager Rename** - Rename files and folders directly in File Manager:\n  - Right-click context menu \"Rename\" option for files and folders\n  - Inline rename button in list view\n  - Validates filenames (no path separators allowed)\n- **File Manager Mobile Accessibility** - Improved touch device support:\n  - Three-dot menu button always visible on mobile (hover-only on desktop)\n  - Selection checkbox always visible on mobile devices\n  - Better PWA experience for file management\n- **Optional Authentication** - Secure your Bambuddy instance with user authentication:\n  - Enable/disable authentication via Setup page or Settings → Users\n  - Role-based access control: Admin and User roles\n  - Admins have full access; Users can manage prints but not settings\n  - JWT-based authentication with 7-day token expiration\n  - User management page for creating, editing, and deleting users\n  - Backward compatible: existing installations work without authentication\n  - Settings page restricted to admin users when auth is enabled\n\n### Changed\n- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)\n- **Edit Queue Item button** - Changed from \"Print to X Printers\" to \"Save\"\n\n### Fixed\n- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:\n  - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop\n  - Folder navigation now works correctly without resetting\n- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager\n- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:\n  - Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null\n  - User presets that inherit from Bambu presets (e.g., \"# Overture Matte PLA @BBL H2D\") now work correctly\n- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots:\n  - Added WebSocket broadcast to AMS change callback for instant UI updates\n  - Removed unnecessary delayed refetch that was causing slow updates\n\n## [0.1.6b9] - 2026-01-19\n\n### New Features\n- **Add to Queue from File Manager** - Queue sliced files directly from File Manager:\n  - New \"Add to Queue\" toolbar button appears when sliced files are selected\n  - Context menu and list view button options for individual files\n  - Supports multiple file selection for batch queueing\n  - Only accepts sliced files (.gcode or .gcode.3mf)\n  - Creates archive and queue item in one action\n- **Print Queue plate selection and options** - Full print configuration in queue edit modal:\n  - Plate selection grid with thumbnails for multi-plate 3MF files\n  - Print options section (bed levelling, flow calibration, vibration calibration, layer inspect, timelapse, use AMS)\n  - Options saved with queue item and used when print starts\n- **Multi-plate 3MF plate selection** - When reprinting multi-plate 3MF files (exported with \"All sliced file\"), users can now select which plate to print:\n  - Plate selection grid with thumbnails, names, and print times\n  - Filament requirements filtered to show only selected plate's filaments\n  - Prevents incorrect filament mapping across plates\n  - Closes [#93](https://github.com/maziggy/bambuddy/issues/93)\n- **Home Assistant smart plug integration** - Control any Home Assistant switch/light entity as a smart plug:\n  - Configure HA connection (URL + Long-Lived Access Token) in Settings → Network\n  - Add HA-controlled plugs via Settings → Plugs → Add Smart Plug → Home Assistant tab\n  - Entity dropdown shows all available switch/light/input_boolean entities\n  - Full automation support: auto-on, auto-off, scheduling, power alerts\n  - Works alongside existing Tasmota plugs\n  - Closes [#91](https://github.com/maziggy/bambuddy/issues/91)\n- **Fusion 360 design file attachments** - Attach F3D files to archives for complete design tracking:\n  - Upload F3D files via archive context menu (\"Upload F3D\" / \"Replace F3D\")\n  - Cyan badge on archive card indicates attached F3D file (next to source 3MF badge)\n  - Click badge to download, or use \"Download F3D\" in context menu\n  - F3D files included in backup/restore\n  - API tests for F3D endpoints\n\n### Fixed\n- **Multi-plate 3MF metadata extraction** - Single-plate exports from multi-plate projects now show correct thumbnail and name:\n  - Extracts plate index from slice_info.config metadata\n  - Uses correct plate thumbnail (e.g., plate_5.png instead of plate_1.png)\n  - Appends \"Plate N\" to print name for plates > 1\n  - Closes [#92](https://github.com/maziggy/bambuddy/issues/92)\n\n## [0.1.6b8] - 2026-01-17\n\n### Added\n- **MQTT Publishing** - Publish BamBuddy events to external MQTT brokers for integration with Home Assistant, Node-RED, and other automation platforms:\n  - New \"Network\" tab in Settings for MQTT configuration\n  - Configure broker, port, credentials, TLS, and topic prefix\n  - Real-time connection status indicator\n  - Topics: printer status, print lifecycle, AMS changes, queue events, maintenance alerts, smart plug states, archive events\n- **Virtual Printer Queue Mode** - New mode that archives files and adds them directly to the print queue:\n  - Three modes: Archive (immediate), Review (pending list), Queue (print queue)\n  - Queue mode creates unassigned items that can be assigned to a printer later\n- **Unassigned Queue Items** - Print queue now supports items without an assigned printer:\n  - \"Unassigned\" filter option on Queue page\n  - Unassigned items highlighted in orange\n  - Assign printer via edit modal\n- **Sidebar Badge Indicators** - Visual indicators on sidebar icons:\n  - Queue icon: yellow badge with pending item count\n  - Archive icon: blue badge with pending uploads count\n  - Auto-updates every 5 seconds and on window focus\n- **Project Parts Tracking** - Track individual parts/objects separately from print plates:\n  - \"Target Parts\" field alongside \"Target Plates\"\n  - Separate progress bars for plates vs parts\n  - Parts count auto-detected from 3MF files\n\n### Fixed\n- Chamber temp on A1/P1S - Fixed regression where chamber temperature appeared on printers without sensors in multi-printer setups\n- Queue prints on A1 - Fixed \"MicroSD Card read/write exception error\" when starting prints from queue\n- Spoolman sync - Fixed Bambu Lab spool detection and AMS tray data persistence\n- FTP downloads - Fixed downloads failing for .3mf files without .gcode extension\n- Project statistics - Fixed inconsistent display between project list and detail views\n- Chamber light state - Fixed WebSocket broadcasts not including light state changes\n- Backup/restore - Improved handling of nullable fields and AMS mapping data\n\n## [0.1.6b7] - 2026-01-12\n\n### Added\n- **AMS Color Mapping** - Manual AMS slot selection in ReprintModal, AddToQueueModal, EditQueueItemModal:\n  - Dropdown to override auto-matched AMS slots with any loaded filament\n  - Blue ring indicator distinguishes manual selections from auto-matches\n  - Status indicators: green (match), yellow (type only), orange (not found)\n  - Shared color utility with ~200 Bambu color mappings\n  - Fixed AMS mapping format to match Bambu Studio exactly\n- **Print Options in Reprint Modal** - Bed leveling, flow calibration, vibration calibration, first layer inspection, timelapse toggles\n- **Time Format Setting** - New date utilities applied to 12 components, fixes archive times showing in UTC\n- **Statistics Dashboard Improvements** - Size-aware rendering for PrintCalendar, SuccessRateWidget, TimeAccuracyWidget, FilamentTypesWidget, FailureAnalysisWidget\n- **Firmware Update Helper** - Check firmware versions against Bambu Lab servers for LAN-only printers with one-click upload\n- **FTP Reliability** - Configurable retry (1-10 attempts, 1-30s delay), A1/A1 Mini SSL fix, configurable timeout\n- **Bulk Project Assignment** - Assign multiple archives to a project at once from multi-select toolbar\n- **Chamber Light Control** - Light toggle button on printer cards\n- **Support Bundle Feature** - Debug logging toggle with ZIP generation for issue reporting\n- **Archive Improvements** - List view with full parity, object count display, cross-view highlighting, context menu button\n- **Maintenance Improvements** - wiki_url field for documentation links, model-specific Bambu Lab wiki URLs\n- **Spoolman Integration** - Clear location when spools removed from AMS during sync\n\n### Fixed\n- Browser freeze from CameraPage WebSocket\n- Project card filament badges showing duplicates and raw color codes\n- Print object label positioning in skip objects modal\n- Printer hour counter not updated on backend restart\n- Virtual printer excluded from discovery\n- Print cover fetch in Docker environments\n- Archive delete safety checks prevent deleting parent dirs\n\n## [0.1.6b6] - 2026-01-04\n\n### Added\n- **Resizable Printer Cards** - Four sizes (S/M/L/XL) with +/- buttons in toolbar\n- **Queue Only Mode** - Stage prints without auto-start, release when ready with purple \"Staged\" badge\n- **Virtual Printer Model Selection** - Choose which Bambu printer model to emulate\n- **Tasmota Admin Link** - Quick access to smart plug web interface with auto-login\n- **Pending Upload Delete Confirmation** - Confirmation modal when discarding pending uploads\n\n### Fixed\n- Camera stream reconnection with automatic recovery from stalled streams\n- Active AMS slot display for H2D printers with multiple AMS units\n- Spoolman sync matching only Bambu Lab vendor filaments\n- Skip objects modal object ID markers positioning\n- Virtual printer model codes, serial prefixes, startup model, certificate persistence\n- Archive card context menu positioning\n\n## [0.1.6b5] - 2026-01-02\n\n### Added\n- **Pre-built Docker Images** - Pull directly from GitHub Container Registry (ghcr.io)\n- **Printer Controls** - Stop and Pause/Resume buttons on printer cards with confirmation modals\n- **Skip Objects** - Skip individual objects during print without canceling entire job\n- **Spoolman Improvements** - Link Spool, UUID Display, Sync Feedback\n- **AMS Slot RFID Re-read** - Re-read filament info via hover menu\n- **Print Quantity Tracking** - Track items per print for project progress\n\n### Fixed\n- Spoolman 400 Bad Request when creating spools\n- Update module for Docker based installations\n\n## [0.1.6b4] - 2026-01-01\n\n### Changed\n- Refactored AMS section for better visual grouping and spacing\n\n### Fixed\n- Printer hour counter not incrementing during prints\n- Slicer protocol OS detection (Windows: bambustudio://, macOS/Linux: bambustudioopen://)\n- Camera popup window auto-resize and position persistence\n- Maintenance page duration display with better precision\n- Docker update detection for in-app updates\n\n## [0.1.6b3] - 2025-12-31\n\n### Added\n- Confirmation modal for quick power switch in sidebar\n\n### Fixed\n- Printer hour counter inconsistency between card and maintenance page\n- Improved printer hour tracking accuracy with real-time runtime counter\n- Add Smart Plug modal scrolling on lower resolution screens\n- Excluded virtual printer from discovery results\n- Bottom sidebar layout\n\n## [0.1.6b2] - 2025-12-29\n\n### Added\n- **Virtual Printer** - Emulates a Bambu Lab printer on your network:\n  - Auto-discovery via SSDP protocol\n  - Send prints directly from Bambu Studio/Orca Slicer\n  - Queue mode or Auto-start mode\n  - TLS 1.3 encrypted MQTT + FTPS with auto-generated certificates\n- Persistent archive page filters\n\n### Fixed\n- AMS filament matching in reprint modal\n- Archive card cache bug with wrong cover image\n- Queueing module re-queue modal\n\n## [0.1.6b] - 2025-12-28\n\n### Added\n- **Smart Plugs** - Tasmota device discovery and Switchbar quick access widget\n- **Timelapse Editor** - Trim, speed adjustment (0.25x-4x), and music overlay\n- **Printer Discovery** - Docker subnet scanning, printer model mapping, detailed status stages\n- **Archives & Projects** - AMS filament preview, file type badges, project filament colors, BOM filter\n- **Maintenance** - Custom maintenance types with manual per-printer assignment\n- Delete printer options to keep or delete archives\n\n### Fixed\n- Notifications sent when printer offline\n- Camera stream stopping with auto-reconnection\n- A1/P1 camera streaming with extended timeouts\n- Attachment uploads not persisting\n- Total print hours calculation\n\n## [0.1.5] - 2025-12-19\n\n### Added\n- **Docker Support** - One-command deployment with docker compose\n- **Mobile PWA** - Full mobile support with responsive navigation and touch gestures\n- **Projects** - Group related prints with progress tracking\n- **Archive Comparison** - Compare 2-5 archives side-by-side\n- **Smart Plug Automation** - Tasmota integration with auto power-on/off\n- **Telemetry Dashboard** - Anonymous usage statistics (opt-out available)\n- **Full-Text Search** - Efficient search across print names, filenames, tags, notes, designer, filament type\n- **Failure Analysis** - Dashboard widget showing failure rate with correlations and trends\n- **CSV/Excel Export** - Export archives and statistics with current filters\n- **AMS Humidity/Temperature History** - Clickable indicators with charts and statistics\n- **Daily Digest Notifications** - Consolidated daily summary\n- **Notification Template System** - Customizable message templates\n- **Webhooks & API Keys** - API key authentication with granular permissions\n- **System Info Page** - Database and resource statistics\n- **Comprehensive Backup/Restore** - Including user options and external links\n\n### Changed\n- Redesigned AMS section with BambuStudio-style device icons\n- Tabbed design and auto-save for settings page\n- Improved archive card context menu with submenu support\n- WebSocket throttle reduced to 100ms for smoother updates\n\n### Fixed\n- Browser freeze on print completion when camera stream was open\n- Printer status \"timelapse\" effect after print completion\n- Complete rewrite of timelapse auto-download with retry mechanism\n- Reprint from archive sending slicer source file instead of sliced gcode\n- Import shadowing bugs causing \"cannot access local variable\" error\n- Archive PATCH 500 error\n- ffmpeg processes not killed when closing webcam window\n\n### Removed\n- Control page\n- PWA push notifications (replaced with standard notification providers)\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Code of Conduct\n\n## Our Commitment\n\nThe Bambuddy community is dedicated to providing a welcoming and supportive environment for everyone. We value respectful collaboration and constructive dialogue.\n\n## Expected Behavior\n\n- **Be Respectful**: Treat others with kindness and consideration. Disagreements are fine; personal attacks are not.\n- **Be Inclusive**: Welcome people of all backgrounds and experience levels. Avoid exclusionary language or behavior.\n- **Be Constructive**: Offer helpful feedback. Focus on ideas, not individuals.\n- **Be Patient**: Remember that contributors have varying levels of experience and availability.\n\n## Unacceptable Behavior\n\n- Harassment, insults, or discriminatory remarks\n- Personal attacks or inflammatory comments\n- Publishing others' private information without consent\n- Trolling or deliberately disruptive behavior\n- Any conduct that would be inappropriate in a professional setting\n\n## Reporting Issues\n\nIf you experience or witness unacceptable behavior:\n\n1. **Contact the maintainers** via email or GitHub\n2. **Provide details** about what happened and when\n3. **All reports will be handled confidentially**\n\nWe will review and respond to all reports promptly.\n\n## Enforcement\n\nMaintainers may take any action they deem appropriate, including:\n\n- Requesting a change in behavior\n- Temporary or permanent bans from community spaces\n- Removal of contributions that violate this code\n\n## Scope\n\nThis code of conduct applies to all Bambuddy community spaces, including GitHub issues, pull requests, discussions, and any other communication channels.\n\n---\n\nThank you for helping make Bambuddy a welcoming community!\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Bambuddy\n\nThank you for your interest in contributing to Bambuddy! This document provides guidelines and instructions for contributing.\n\n## Table of Contents\n\n- [Code of Conduct](#code-of-conduct)\n- [Before You Start](#before-you-start)\n- [Documentation Requirements](#documentation-requirements)\n- [Getting Started](#getting-started)\n- [Development Setup](#development-setup)\n- [Making Changes](#making-changes)\n- [Code Style](#code-style)\n- [Internationalization (i18n)](#internationalization-i18n)\n- [Authentication & Permissions](#authentication--permissions)\n- [Testing](#testing)\n- [CI Pipeline](#ci-pipeline)\n- [Submitting Changes](#submitting-changes)\n- [Reporting Bugs](#reporting-bugs)\n- [Requesting Features](#requesting-features)\n\n## Code of Conduct\n\nPlease read and follow our [Code of Conduct](CODE_OF_CONDUCT.md) to keep our community welcoming and respectful.\n\n## Before You Start\n\n**Every contribution starts with an issue.** Before writing any code or opening a pull request:\n\n1. **Open a new issue** or **comment on an existing one** describing what you'd like to work on\n2. **Wait for agreement** — discuss the approach with a maintainer so we're aligned on scope and direction\n3. **Get assigned** — once we agree, a maintainer will assign the issue to you\n4. **Then start coding** — only open a PR for an issue that is assigned to you\n\n**No assigned issue = no PR.** Pull requests without a corresponding assigned issue will be closed.\n\nThis keeps everyone on the same page, avoids wasted effort on changes that may not fit the project's direction, and prevents multiple contributors from working on the same thing.\n\n## Documentation Requirements\n\nFeatures and user-visible behavior changes **must** include matching documentation updates in the docs repos:\n\n- **[bambuddy-wiki](https://github.com/maziggy/bambuddy-wiki)** — end-user guide (installation, configuration, feature walkthroughs, reference)\n- **[bambuddy-website](https://github.com/maziggy/bambuddy-website)** — marketing site (updated only when the change affects public claims or feature lists)\n\n### When docs updates are required\n\n| Change | Needs wiki? | Needs website? |\n|---|---|---|\n| New feature | ✅ | Maybe (if in the feature list) |\n| New config key / setting | ✅ | ❌ |\n| New port, URL, API endpoint | ✅ | ❌ |\n| Installation or upgrade steps change | ✅ | ✅ |\n| UI change that affects screenshots | ✅ | ❌ |\n| Bug fix with no observable behavior change | ❌ | ❌ |\n| Internal refactor | ❌ | ❌ |\n| Test-only change | ❌ | ❌ |\n\n### Workflow\n\n1. Open your code PR here in `bambuddy`\n2. Open companion PR(s) in `bambuddy-wiki` and/or `bambuddy-website`\n3. **Link the companion PR(s) in the code PR description** (the PR template has a dedicated section)\n4. Merge the PRs together — usually code first, then docs, unless the docs reference new things that don't exist yet\n\nIf your change truly doesn't need docs (internal refactor, silent bug fix), say so in the PR description and give a one-line reason.\n\n### Previews before you merge\n\nClone the docs repo and run it locally to see your changes rendered with the real theme before opening the PR:\n\n- **Wiki** (`bambuddy-wiki`) — `pip install -r requirements.txt && mkdocs serve` — live-reload on `http://localhost:8000`\n- **Website** (`bambuddy-website`) — static HTML/CSS, open the changed file directly or serve with `python -m http.server`\n\nReview like you would the production site. Catch broken links, layout regressions, typos, missing images. If it looks right, open the PR.\n\n### Editing docs without a local clone\n\nBoth docs repos can be edited directly in the browser, no `git clone` required:\n\n- **GitHub web editor** — click the pencil icon on any file in the repo\n- **github.dev** — press `.` (period) on any repo page to open VS Code in your browser, with multi-file editing and syntax highlighting\n\n## Getting Started\n\n1. **Fork the repository** on GitHub\n2. **Clone your fork** locally:\n   ```bash\n   git clone https://github.com/YOUR_USERNAME/bambuddy.git\n   cd bambuddy\n   ```\n3. **Add the upstream remote**:\n   ```bash\n   git remote add upstream https://github.com/maziggy/bambuddy.git\n   ```\n\n## Development Setup\n\n### Prerequisites\n\n- Python 3.11+\n- Node.js 20+\n- npm\n\n### Backend Setup\n\n```bash\n# Create virtual environment\npython3 -m venv venv\nsource venv/bin/activate  # On Windows: venv\\Scripts\\activate\n\n# Install dependencies\npip install -r requirements.txt\npip install -r requirements-dev.txt  # Dev/test dependencies (pytest, ruff, bandit, etc.)\n\n# Install pre-commit hooks\npip install pre-commit\npre-commit install\n\n# Run backend\nDEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000\n```\n\n### Frontend Setup\n\n```bash\ncd frontend\n\n# Install dependencies\nnpm install\n\n# Run development server\nnpm run dev\n```\n\nThe frontend will be available at `http://localhost:5173` and will proxy API requests to the backend.\n\n### Running with Docker\n\n```bash\n# Run the full application\ndocker compose up -d --build\n\n# Run tests in Docker (mirrors CI)\ndocker compose -f docker-compose.test.yml run --rm backend-test\ndocker compose -f docker-compose.test.yml run --rm frontend-test\n```\n\n## Making Changes\n\n1. **Create a branch** from `dev` for your changes:\n   ```bash\n   git checkout dev\n   git pull upstream dev\n   git checkout -b feature/your-feature-name\n   # or\n   git checkout -b fix/your-bug-fix\n   ```\n\n2. **Make your changes** following our code style guidelines\n\n3. **Test your changes** thoroughly\n\n4. **Commit your changes** with clear, descriptive messages:\n   ```bash\n   git commit -m \"Add feature: description of what you added\"\n   ```\n\n### Branch Naming\n\n- `feature/` - New features\n- `fix/` - Bug fixes\n- `docs/` - Documentation changes\n- `refactor/` - Code refactoring\n- `test/` - Test additions or fixes\n\n## Code Style\n\n### Backend (Python)\n\nWe use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting. Configuration is in `pyproject.toml`.\n\n```bash\n# Check linting\nruff check backend/\n\n# Auto-fix issues\nruff check --fix backend/\n\n# Format code\nruff format backend/\n\n# Check formatting without changes\nruff format --check backend/\n```\n\n### Frontend (TypeScript/React)\n\nWe use ESLint for linting and TypeScript for type checking:\n\n```bash\ncd frontend\n\n# Lint\nnpm run lint\n\n# Type check\nnpx tsc --noEmit\n```\n\n### Pre-commit Hooks\n\nPre-commit hooks run automatically on `git commit` and include Ruff linting/formatting, trailing whitespace fixes, YAML/JSON validation, and import shadowing checks. To run manually:\n\n```bash\npre-commit run --all-files\n```\n\n## Internationalization (i18n)\n\nThe frontend uses [react-i18next](https://react.i18next.com/) for all user-facing text. **Never hardcode user-visible strings** — always use translation keys.\n\n### Locale Files\n\nTranslations live in `frontend/src/i18n/locales/`:\n\n| File | Language |\n|------|----------|\n| `en.ts` | English (primary) |\n| `de.ts` | German |\n| `fr.ts` | French |\n| `ja.ts` | Japanese |\n| `pt-BR.ts` | Brazilian Portuguese |\n[...]\ncheck for possibly more files!!!\n\n### Adding New Strings\n\n1. Add the key to the appropriate section in **all three** locale files\n2. Use the `useTranslation` hook in your component:\n\n```tsx\nimport { useTranslation } from 'react-i18next';\n\nfunction MyComponent() {\n  const { t } = useTranslation();\n  return <span>{t('section.myNewKey')}</span>;\n}\n```\n\n3. Keys are organized by feature (e.g., `spoolman.`, `nav.`, `common.`)\n\n### Important Notes\n\n- All three locale files must use the **same key structure** — same nesting, same key paths\n- Always add keys to all three locales to maintain parity\n- Run frontend tests after changes — locale parity is validated\n- If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English\n\n## Authentication & Permissions\n\nBambuddy has an optional authentication system. When auth is enabled, API endpoints are protected by granular permissions.\n\n### How It Works\n\nAuthentication is **opt-in** — when disabled, all endpoints are open. The system uses `RequirePermissionIfAuthEnabled` which:\n\n- Checks if auth is enabled in settings\n- If disabled: allows the request through (no-op)\n- If enabled: validates JWT token/API key and checks the user has the required permission\n\n### Adding Auth to New Endpoints\n\nUse the `RequirePermissionIfAuthEnabled` dependency in your route:\n\n```python\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.permissions import Permission\n\n@router.get(\"/my-resource\")\nasync def get_my_resource(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.RESOURCE_READ),\n):\n    ...\n```\n\n### Permission Convention\n\nPermissions follow the `resource:action` pattern (e.g., `filaments:read`, `printers:control`). Standard actions:\n\n| Action | Usage |\n|--------|-------|\n| `read` | View/list resources |\n| `create` | Create new resources |\n| `update` | Modify existing resources |\n| `delete` | Remove resources |\n\nSome resources have additional actions (e.g., `printers:control` for start/stop, `printers:files` for file transfer).\n\n### Adding New Permissions\n\n1. Add the permission to the `Permission` enum in `backend/app/core/permissions.py`\n2. Add it to the appropriate category in `PERMISSION_CATEGORIES`\n3. Add it to the relevant default groups (`Administrators` gets all, `Operators` and `Viewers` as appropriate)\n4. Use it in your route with `RequirePermissionIfAuthEnabled`\n\n### Default Groups\n\n| Group | Access Level |\n|-------|-------------|\n| **Administrators** | All permissions |\n| **Operators** | Full control of printers, own items in archives/queue, read-only settings |\n| **Viewers** | Read-only access to all resources |\n\n## Testing\n\nThe easiest way to run tests is with the provided scripts in the project root:\n\n```bash\n./test_frontend.sh    # TypeScript check + ESLint + Vitest\n./test_backend.sh     # Ruff lint/format + pytest (parallel)\n./test_docker.sh      # Full Docker build, unit tests, and integration tests\n./test_all.sh         # All of the above (frontend → backend → docker)\n./test_security.sh    # Security scans (bandit, pip-audit, npm-audit)\n```\n\n`test_docker.sh` supports flags like `--backend-only`, `--skip-integration`, `--fresh` — run with `--help` for details.\n\n`test_security.sh` runs fast scans by default. Use `--full` for the complete suite (CodeQL, Trivy, etc.) or specify individual scans like `./test_security.sh bandit codeql`.\n\n### Running Tests Individually\n\n**Backend** — tests are in `backend/tests/` with `unit/` and `integration/` subdirectories:\n\n```bash\npytest backend/tests/ -v           # All tests\npytest backend/tests/unit/         # Unit tests only\npytest backend/tests/ --cov=backend  # With coverage\n```\n\n**Frontend** — tests use [Vitest](https://vitest.dev/) and are in `frontend/src/__tests__/`:\n\n```bash\ncd frontend\nnpm run test:run       # Single run\nnpm test               # Watch mode\nnpm run test:coverage  # With coverage\n```\n\n## CI Pipeline\n\nPull requests trigger automated CI checks via GitHub Actions (`.github/workflows/ci.yml`):\n\n- **Backend**: Ruff lint + format check, unit/integration tests, pip-audit\n- **Frontend**: ESLint, TypeScript type check, Vitest tests, production build\n- **Docker**: Full image build, backend/frontend tests in Docker, integration health checks\n- **Security**: CodeQL analysis, dependency audits\n\nAll checks must pass before merging. Run `./test_all.sh` locally before pushing to catch issues early.\n\n## Submitting Changes\n\n1. **Push your branch** to your fork:\n   ```bash\n   git push origin feature/your-feature-name\n   ```\n\n2. **Create a Pull Request** on GitHub:\n   - **Always target the `dev` branch** as the base branch (not `main`)\n   - Use a clear, descriptive title\n   - Fill out the PR template completely\n   - Link any related issues\n   - Include before/after screenshots for any visual changes\n\n3. **Wait for review** - maintainers will review your PR and may request changes\n\n### PR Guidelines\n\n- Keep PRs focused and reasonably sized\n- One feature or fix per PR\n- Update documentation if needed\n- Add tests for new functionality\n- Ensure all tests pass\n- Follow the existing code style\n- **Visual changes require screenshots** — if your PR changes any frontend UI, include before/after screenshots showing the old and new appearance\n\n## Reporting Bugs\n\nUse the [Bug Report template](https://github.com/maziggy/bambuddy/issues/new?template=bug_report.yml) and include:\n\n- Clear description of the bug\n- Steps to reproduce\n- Expected vs actual behavior\n- Your environment (OS, Python version, browser)\n- Printer model and firmware version\n- Relevant logs\n\n## Requesting Features\n\nUse the [Feature Request template](https://github.com/maziggy/bambuddy/issues/new?template=feature_request.yml) and include:\n\n- Clear description of the feature\n- Use case / problem it solves\n- Proposed solution\n- Alternatives considered\n\n## Questions?\n\n- Check the [Documentation](http://wiki.bambuddy.cool)\n- Open a [Discussion](https://github.com/maziggy/bambuddy/discussions)\n- Review existing [Issues](https://github.com/maziggy/bambuddy/issues)\n\n---\n\nThank you for contributing to Bambuddy!\n"
  },
  {
    "path": "DOCKERHUB.md",
    "content": "# Bambuddy\n\n**Self-hosted print archive and management system for Bambu Lab 3D printers.**\n\nNo cloud dependency. Complete privacy. Full control.\n\n[![GitHub](https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square&label=GitHub)](https://github.com/maziggy/bambuddy)\n[![License](https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square)](https://github.com/maziggy/bambuddy/blob/main/LICENSE)\n[![Discord](https://img.shields.io/discord/1461241694715645994?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/aFS3ZfScHM)\n\n## Quick Start\n\n```bash\nmkdir bambuddy && cd bambuddy\ncurl -O https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml\ndocker compose up -d\n```\n\nOpen **http://localhost:8000** and add your printer.\n\n> **Requirements:** Bambu Lab printer with Developer Mode enabled, on the same local network.\n\n## Supported Architectures\n\n| Architecture | Tag |\n|---|---|\n| x86-64 (Intel/AMD) | `amd64` |\n| arm64 (Raspberry Pi 4/5) | `arm64` |\n\n## Features\n\n- **Real-Time Monitoring** — Live printer status, camera streaming, HMS error tracking (853 codes translated), resizable multi-printer dashboard\n- **Print Archive** — Automatic 3MF archiving with metadata, interactive 3D model viewer (Three.js), photo attachments, failure analysis, side-by-side comparison\n- **Print Scheduling** — Drag-and-drop queue, multi-printer assignment by model or location, time-based scheduling, re-print with AMS mapping\n- **Smart Automation** — Smart plug control (Tasmota, Home Assistant, MQTT), auto power-on/off, energy monitoring, maintenance reminders\n- **Proxy Mode** — Print remotely from Bambu Studio/OrcaSlicer without VPN or port forwarding, end-to-end TLS encrypted\n- **Notifications** — WhatsApp, Telegram, Discord, Email, Pushover, ntfy with customizable templates and quiet hours\n- **Projects** — Group related prints, track parts and plates, bill of materials, cost tracking, export as ZIP/JSON\n- **File Manager** — Upload and organize sliced files, folder structure, print directly to any printer\n- **Integrations** — Spoolman filament sync, MQTT publishing, Prometheus metrics, Bambu Cloud profiles, REST API, Home Assistant\n- **Virtual Printer** — Appears in your slicer via SSDP discovery, multiple operating modes (archive, review, queue, proxy)\n- **Security** — Optional authentication with group-based permissions (50+ granular), JWT tokens, API key support\n\n## Configuration\n\n| Variable | Default | Description |\n|---|---|---|\n| `TZ` | `UTC` | Timezone (e.g. `America/New_York`, `Europe/Berlin`) |\n| `PORT` | `8000` | Web UI port |\n| `PUID` | `1000` | User ID for file permissions |\n| `PGID` | `1000` | Group ID for file permissions |\n| `DEBUG` | `false` | Enable debug logging |\n\n## Volumes\n\n| Path | Purpose |\n|---|---|\n| `/app/data` | Database, archived prints, thumbnails |\n| `/app/logs` | Application logs |\n\n## Docker Compose\n\n```yaml\nservices:\n  bambuddy:\n    image: maziggy/bambuddy:latest\n    container_name: bambuddy\n    network_mode: host\n    environment:\n      - TZ=America/New_York\n      - PUID=1000\n      - PGID=1000\n    volumes:\n      - bambuddy_data:/app/data\n      - bambuddy_logs:/app/logs\n    restart: unless-stopped\n\nvolumes:\n  bambuddy_data:\n  bambuddy_logs:\n```\n\n> **macOS/Windows:** Docker Desktop doesn't support `network_mode: host`. Replace it with `ports: [\"8000:8000\"]` and add printers manually by IP.\n\n## Updating\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\n## Daily Beta Builds\n\nBeta builds with the latest fixes are pushed regularly to the same beta version tag:\n\n```bash\n# Pull the current beta\ndocker pull maziggy/bambuddy:0.2.2b1\n```\n\nUse [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.\n\n> **Note:** Beta builds use version tags like `0.2.2b1` — they are never tagged as `latest`. Your stable installation won't auto-update to a beta unless you explicitly pull a beta tag.\n\n## Supported Printers\n\n| Series | Models | Status |\n|---|---|---|\n| H2 | H2C, H2D, H2D Pro, H2S | Tested |\n| X1 | X1 Carbon, X1E | Tested |\n| P1 | P1P, P1S | Compatible |\n| P2 | P2S | Compatible |\n| A1 | A1, A1 Mini | Compatible |\n\nAll printers require **Developer Mode** enabled for LAN access.\n\n## Links\n\n- **Website:** [bambuddy.cool](https://bambuddy.cool)\n- **Documentation:** [wiki.bambuddy.cool](http://wiki.bambuddy.cool)\n- **GitHub:** [github.com/maziggy/bambuddy](https://github.com/maziggy/bambuddy)\n- **Discord:** [discord.gg/aFS3ZfScHM](https://discord.gg/aFS3ZfScHM)\n- **Issues:** [GitHub Issues](https://github.com/maziggy/bambuddy/issues)\n\n## License\n\nMIT License - see [LICENSE](https://github.com/maziggy/bambuddy/blob/main/LICENSE) for details.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Build frontend\nFROM node:22-bookworm-slim AS frontend-builder\n\nWORKDIR /app/frontend\n\n# Copy package files first for better caching\nCOPY frontend/package*.json ./\n\n# Use cache mount for npm\nRUN --mount=type=cache,target=/root/.npm \\\n    npm ci\n\nCOPY frontend/ ./\nRUN npm run build\n\n# Production image\nFROM python:3.13-slim-trixie\n\nWORKDIR /app\n\n# Install system dependencies\nENV DEBIAN_FRONTEND=noninteractive\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    curl \\\n    ffmpeg \\\n    iproute2 \\\n    libcap2-bin \\\n    openssh-client \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Allow binding to privileged ports (e.g. 990/FTPS) as non-root user.\n# File capabilities are more reliable than Docker cap_add with user: directive,\n# which depends on ambient capability support in the container runtime.\nRUN setcap cap_net_bind_service=+ep \"$(readlink -f /usr/local/bin/python3)\"\n\n# Install Python dependencies with cache mount\nCOPY requirements.txt ./\nRUN --mount=type=cache,target=/root/.cache/pip \\\n    pip install --root-user-action=ignore -r requirements.txt\n\n# Copy backend\nCOPY backend/ ./backend/\n\n# Capture the current git branch at build time. `.git/HEAD` is the only\n# .git metadata the build context lets through (see .dockerignore); it\n# contains `ref: refs/heads/<branch>`, which the SpoolBuddy remote-update\n# flow reads at runtime via detect_current_branch() in spoolbuddy_ssh.py.\n# Without this, the production image has no git metadata at all and would\n# always pull `main` on the remote device regardless of which branch\n# Bambuddy itself was built from.\nCOPY .git/HEAD ./.git/HEAD\n\n# Copy built frontend from builder stage\nCOPY --from=frontend-builder /app/static ./static\n\n# Create data directory for persistent storage\n# chmod 777 allows running as non-root user (e.g., with docker compose user: directive)\nRUN mkdir -p /app/data /app/logs && chmod 777 /app/data /app/logs\n\n# Environment variables\nENV PYTHONUNBUFFERED=1\nENV DATA_DIR=/app/data\nENV LOG_DIR=/app/logs\nENV PORT=8000\n# Provide a local username + home for tools that call getpass.getuser() /\n# os.path.expanduser() under arbitrary PUIDs. With `user: \"1001:1001\"` the\n# stock python:3.13-slim image has no /etc/passwd entry for that UID, so\n# pwd.getpwuid() raises and breaks libraries that do host-level user lookups\n# (notably asyncssh, which uses the local username for ~/.ssh/config host\n# matching during the SpoolBuddy remote-update flow). Setting LOGNAME/USER\n# makes getpass.getuser() resolve via env vars instead of the passwd db;\n# HOME=/app gives a writable home that is guaranteed to exist.\nENV HOME=/app\nENV USER=bambuddy\nENV LOGNAME=bambuddy\n\nEXPOSE 322\nEXPOSE 990\nEXPOSE 3000\nEXPOSE 3002\nEXPOSE 6000\nEXPOSE 8000\nEXPOSE 8883\nEXPOSE 50000-50100\n\n# Health check (uses PORT env var via shell)\nHEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \\\n    CMD python -c \"import urllib.request, os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\\\"PORT\\\", \\\"8000\\\")}/health')\" || exit 1\n\n# Run the application\n# Use standard asyncio loop (uvloop has permission issues in some Docker environments)\n# Port is configurable via PORT environment variable (default: 8000)\nCMD [\"sh\", \"-c\", \"uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio\"]\n"
  },
  {
    "path": "Dockerfile.test",
    "content": "# Test image for running backend and frontend tests\nFROM python:3.13-slim AS backend-test\n\nWORKDIR /app\n\n# Install system dependencies for testing\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    curl \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Install Python dependencies including test dependencies\nCOPY requirements.txt ./\nCOPY requirements-dev.txt ./\nRUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt\n\n# Copy backend code\nCOPY backend/ ./backend/\nCOPY pyproject.toml ./\n\n# Create necessary directories\nRUN mkdir -p /app/data /app/logs /app/archive\n\n# Environment variables for testing\nENV PYTHONUNBUFFERED=1\nENV DATA_DIR=/app/data\nENV TESTING=1\n\n# Default command runs pytest (excluding docker integration tests)\n# Use -n auto for parallel execution (auto-detects available CPUs)\nCMD [\"pytest\", \"backend/tests/\", \"-v\", \"--tb=short\", \"-p\", \"no:cacheprovider\", \"-n\", \"auto\"]\n\n# -------------------------------------------\n# Frontend test stage\nFROM node:22-bookworm-slim AS frontend-test\n\nWORKDIR /app/frontend\n\n# Copy package files and install\nCOPY frontend/package*.json ./\nRUN npm ci\n\n# Copy frontend source\nCOPY frontend/ ./\n\n# Default command runs tests\nCMD [\"npm\", \"test\", \"--\", \"--run\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <img src=\"static/img/bambuddy_logo_dark.png\" alt=\"Bambuddy Logo\" width=\"300\">\n</p>\n\n<h1 align=\"center\">Bambuddy</h1>\n\n<p align=\"center\">\n  <strong>Self-hosted print archive and management system for Bambu Lab 3D printers</strong>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/maziggy/bambuddy/releases\"><img src=\"https://img.shields.io/github/v/release/maziggy/bambuddy?style=flat-square&color=blue\" alt=\"Release\"></a>\n  <img src=\"https://github.com/maziggy/bambuddy/actions/workflows/ci.yml/badge.svg?branch=main\">\n  <img src=\"https://github.com/maziggy/bambuddy/actions/workflows/github-code-scanning/codeql/badge.svg\">\n  <img src=\"https://github.com/maziggy/bambuddy/actions/workflows/security.yml/badge.svg\">\n  <a href=\"https://github.com/maziggy/bambuddy/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square\" alt=\"License\"></a>\n  <a href=\"https://github.com/maziggy/bambuddy/stargazers\"><img src=\"https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square\" alt=\"Stars\"></a>\n  <a href=\"https://github.com/maziggy/bambuddy/issues\"><img src=\"https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square\" alt=\"Issues\"></a>\n  <a href=\"https://discord.gg/aFS3ZfScHM\"><img src=\"https://img.shields.io/discord/1461241694715645994?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2\" alt=\"Discord\"></a>\n  <a href=\"https://forum.bambuddy.cool\"><img src=\"https://img.shields.io/badge/Forum-bambuddy.cool-00adef?style=flat-square&logo=discourse&logoColor=white\" alt=\"Forum\"></a>\n  <a href=\"https://ko-fi.com/maziggy\"><img src=\"https://img.shields.io/badge/Ko--fi-Support-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white\" alt=\"Ko-fi\" target=_blank></a>\n</p>\n\n<p align=\"center\">\n  <a href=\"#-features\">Features</a> •\n  <a href=\"#-screenshots\">Screenshots</a> •\n  <a href=\"#-quick-start\">Quick Start</a> •\n  <a href=\"http://wiki.bambuddy.cool\">Documentation</a> •\n  <a href=\"https://forum.bambuddy.cool\">Forum</a> •\n  <a href=\"https://discord.gg/aFS3ZfScHM\">Discord</a> •\n  <a href=\"#-contributing\">Contributing</a>\n</p>\n\n---\n\n## 📣 Contributors Wanted — Help Shape Bambuddy\n\nBambuddy is a community-driven project and I'm **actively looking for contributors** — especially for two areas I can't cover alone:\n\n- 📝 **Documentation writers** — help improve the wiki, guides, and feature docs so new users have a smooth onboarding\n- ⚙️ **Discourse admin** — our **Discourse forum** is now live at [forum.bambuddy.cool](https://forum.bambuddy.cool) but still needs to be configured, themed, and tuned (categories, permissions, SSO, email, plugins, backups). If you know Discourse or want to dig in, I'd love your help.\n- 💬 **Forum moderators** — help welcome newcomers, answer questions, and keep discussions healthy on the new forum\n\nYou don't need to be a developer for the docs or moderator roles. If you enjoy writing, helping others, or keeping a community friendly, you're exactly who we're looking for.\n\n**Get in touch:**\n- 🗣️ [Forum](https://forum.bambuddy.cool) — chats, longer discussions, guides, and community Q&A\n- 💬 [Discord](https://discord.gg/aFS3ZfScHM) — fastest way to chat\n- 🐙 [GitHub Discussions](https://github.com/maziggy/bambuddy/discussions) — open a thread\n- 📧 **martin@bambuddy.cool** — email Martin directly (no GitHub or Discord needed)\n\n---\n\n## 🌐 NEW: Remote Printing with Proxy Mode\n\n<p align=\"center\">\n  <img src=\"docs/images/proxy-mode-diagram.png\" alt=\"Proxy Mode Architecture\" width=\"800\">\n</p>\n\n**Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:\n\n- 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate\n- 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/))\n- 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server\n- 🔑 **Uses printer's access code** — No additional credentials needed\n- ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting\n\nPerfect for remote print farms, traveling makers, or accessing your home printer from work.\n\n👉 **[Setup Guide →](https://wiki.bambuddy.cool/features/virtual-printer/#proxy-mode-new-in-017)**\n\n---\n\n## Why Bambuddy?\n\n- **Own your data** — All print history stored locally, no cloud dependency\n- **Works offline** — Uses Developer Mode for direct printer control via local network\n- **Full automation** — Schedule prints, auto power-off, get notified when done\n- **Multi-printer support** — Manage your entire print farm from one interface\n\n---\n\n## ✨ Features\n\n<table>\n<tr>\n<td width=\"50%\" valign=\"top\">\n\n### 📦 Print Archive\n- Automatic 3MF archiving with metadata\n- 3D model preview (Three.js)\n- Duplicate detection & full-text search\n- Photo attachments & failure analysis\n- Timelapse editor (trim, speed, music) with automatic AVI-to-MP4 conversion for P1-series printers, manual upload & remove\n- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)\n- Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)\n- Archive comparison (side-by-side diff)\n- Tag management (rename/delete across all archives)\n- **Print Log** — Chronological table view of all print activity with columns for date/time, print name, printer, user, status, duration, and filament. Filterable by search, printer, user, status, and date range. Pagination with configurable page size. Clear button removes log entries without affecting archives.\n\n### 📊 Monitoring & Control\n- Real-time printer status via WebSocket\n- Live camera streaming (MJPEG) & snapshots with multi-viewer support\n- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`)\n- External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse\n- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)\n- Fan status monitoring (part cooling, auxiliary, chamber)\n- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **build-plate Z-jog** with Studio-style not-homed warning)\n- **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating)\n- **Force Refresh** menu item — request a full status push from the printer without reconnecting\n- Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)\n- Printer search and filters — live search by name/model/location/serial plus status and location dropdown filters (WebSocket-reactive, mobile-friendly)\n- Resizable printer cards (S/M/L/XL)\n- Skip objects during print\n- AMS slot RFID re-read\n- AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)\n- AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers\n- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting\n- **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode\n- **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued\n- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)\n- Dual external spool support for H2D (Ext-L / Ext-R)\n- HMS error monitoring with history and clear errors\n- Print success rates & trends\n- Filament usage tracking\n- Cost analytics & failure analysis\n- **AI print-failure detection** — Optional integration with a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) ML API: watches each running print's camera feed, smooths scores over time (30-frame warmup + EWM + rolling means), and fires a configurable action once per print (notify / pause / pause-and-off)\n- Per-user statistics filtering (admin permission gated)\n- CSV/Excel export\n\n### ⏰ Scheduling & Automation\n- **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)\n- Print queue with drag-and-drop and timeline schedule view\n- Multi-printer selection (send to multiple printers at once)\n- Batch print quantity (print multiple copies — set quantity in the print/schedule dialog, first copy prints immediately, rest are queued)\n- Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs)\n- Configurable default print options (bed levelling, flow/vibration calibration, first layer inspection, timelapse) in Settings → Workflow\n- Model-based queue assignment (send to \"any X1C\" for load balancing) with location filtering\n- Filament override for model-based queue (swap filament colors/types before scheduling)\n- Filament validation (only assign to printers with required filaments)\n- Prefer lowest remaining filament (consume partial spools first when multiple match)\n- Per-printer AMS mapping (individual slot configuration for print farms)\n- Scheduled prints (date/time)\n- Shortest Job First scheduling (SJF toggle on queue page — scheduler picks shorter prints first, with starvation guard)\n- Queue Only mode (stage without auto-start)\n- Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)\n- Auto-print G-code injection (per-model start/end snippets for Farmloop, SwapMod, AutoClear, Printflow 3D — toggle per queue item)\n- Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)\n- REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers\n- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring\n- Energy consumption tracking (per-print kWh and cost) — restart-resilient: mid-print backend restarts no longer lose per-print energy\n- Energy statistics by date range (Today / Week / Month / …) in total-consumption mode via hourly lifetime-counter snapshots\n- HA energy sensor support (for plugs with separate power/energy sensors)\n- Auto power-on before print\n- Auto power-off after cooldown\n\n### 📁 File Manager (Library)\n- Upload and organize sliced files (3MF, gcode, STL)\n- **External folder mounting** - Mount host directories (NAS, USB, network shares) without copying files\n- **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files\n- ZIP file extraction with folder structure preservation\n- Option to create folder from ZIP filename\n- Folder structure with drag-and-drop\n- Rename files and folders via context menu\n- Print directly to any printer with full options\n- Add to queue without creating archive upfront\n- Plate selection for multi-plate 3MF files\n- Duplicate detection via file hash\n- Mobile-friendly with always-visible action buttons\n\n### 📁 Projects\n- Group related prints (e.g., \"Voron Build\")\n- Track plates (print jobs) and parts separately\n- Auto-detect parts count from 3MF files\n- Color-coded project badges\n- Bulk assign archives via multi-select toolbar\n- Import/Export projects as ZIP (includes files) or JSON\n- Print or queue files from linked library folders directly in the project view (resulting archive auto-linked to the project)\n\n</td>\n<td width=\"50%\" valign=\"top\">\n\n### 🔔 Notifications\n- WhatsApp, Telegram, Discord\n- Email, Pushover, ntfy\n- Home Assistant persistent notifications\n- Custom webhooks\n- Quiet hours & daily digest\n- Customizable message templates with per-filament usage details\n- Print finish photo URL in notifications\n- Filament usage and progress in failed/cancelled print notifications\n- **Missing spool assignment warning** — Toast and push notification when a print starts with unassigned AMS trays\n- HMS error alerts (AMS, nozzle, etc.)\n- Build plate detection alerts\n- First layer complete alert (with camera snapshot)\n- Bed cooled alerts (configurable threshold)\n- Queue events (waiting, skipped, failed)\n\n### 🧵 Spool Inventory\n- Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management\n- Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback\n- Mid-print spool reassignment support: uses live assignment if changed during print, snapshot otherwise\n- Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback\n- **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.\n- **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.\n- Spool catalog, color catalog, PA profile matching, and low-stock alerts\n\n### 🔧 Integrations\n- [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display\n- MQTT publishing for Home Assistant, Node-RED, etc.\n- **Prometheus metrics** - Export printer telemetry for Grafana dashboards\n- Bambu Cloud profile management\n- **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud\n- K-profiles (pressure advance)\n- **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub\n- **Scheduled local backups** - Automatic backup snapshots on hourly/daily/weekly schedule with retention management and NAS-mountable output\n- External sidebar links\n- Webhooks & API keys\n- Interactive API browser with live testing\n\n### 🖨️ Virtual Printer & Remote Printing\n- **🌐 Proxy Mode (NEW!)** — Print remotely from anywhere via secure TLS relay\n- Emulates a Bambu Lab printer on your network\n- Send prints directly from Bambu Studio/Orca Slicer\n- Configurable printer model (X1C, P1S, A1, H2D, etc.)\n- Archive mode, Review mode, Queue mode, or Proxy mode\n- SSDP discovery (same LAN) or manual IP entry (VPN/remote)\n- Network interface override for multi-NIC/Docker/VPN setups\n- Secure TLS/MQTT/FTP communication\n\n### 🛠️ Maintenance & Support\n- Maintenance scheduling & tracking\n- Interval reminders (hours/days)\n- Print time accuracy stats\n- File manager for printer storage\n- Firmware update helper with version badge (LAN-only printers) — lists all announced versions with Usable/Unavailable/Installed badges and supports rollback to older firmware\n- Debug logging toggle with live indicator\n- Live application log viewer with filtering\n- Support bundle generator with comprehensive diagnostics (privacy-filtered)\n- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), interactive debug log capture (start logging, reproduce at your own pace, stop & submit), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.\n\n### 🔒 Optional Authentication\n- Enable/disable authentication any time\n- Group-based permissions (80+ granular permissions)\n- Default groups: Administrators, Operators, Viewers\n- JWT tokens with secure password hashing\n- Comprehensive API protection (200+ endpoints secured)\n- User management (create, edit, delete, groups)\n- User activity tracking (who uploaded archives, library files, queued prints, started prints)\n- **Per-user Bambu Cloud accounts** — Each user has their own independent Cloud login for profiles\n- **Advanced Auth via Email** — SMTP integration for automated user onboarding and self-service password resets\n- Admin creates users with email — system sends secure random password automatically\n- Users can reset their own password from the login screen (no admin needed)\n- Customizable email templates (welcome email, password reset)\n- **Two-Factor Authentication (TOTP + Email OTP)** — Per-user opt-in 2FA compatible with Google Authenticator, Authy, 2FAS and any standard TOTP app, or a 6-digit code delivered by email. Each user gets 10 single-use backup codes. Brute-force-protected (per-user + per-IP rate limits), replay-protected (same code cannot be accepted twice in the same 30 s window), and the pre-auth token is a single-use DB-backed challenge bound to the browser session via an HttpOnly cookie.\n- **Single Sign-On (OIDC / SSO)** — Log in via PocketID, Authentik, Keycloak, or any standards-compliant OIDC provider. PKCE (S256) for public clients, `email_verified` gating, issuer & `aud`/`nonce` validation, opt-in account linking via verified email, optional auto-provisioning of new BamBuddy accounts, and strict SSRF hardening on every URL pulled from the OIDC discovery document (scheme + private/loopback/link-local IP checks).\n- **Per-user email notifications** — Users receive email alerts for their own print jobs (start, complete, failed, stopped) with individual toggle controls\n\n</td>\n</tr>\n</table>\n\n**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE/JA/IT) • Auto updates • Database backup/restore • System info dashboard\n\n---\n\n## 🎬 Demo\n\n<p align=\"center\">\n  <a href=\"https://youtu.be/bmq2Z0lEXeo\">\n    <img src=\"https://img.youtube.com/vi/bmq2Z0lEXeo/maxresdefault.jpg\" alt=\"Bambuddy Demo Video\" width=\"800\">\n  </a>\n  <br><em>Click to watch the demo on YouTube</em>\n</p>\n\n---\n\n## 📸 Screenshots\n\n<details>\n<summary><strong>Click to expand screenshots</strong></summary>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/printers.png\" alt=\"Printers\" width=\"800\">\n  <br><em>Real-time printer monitoring with AMS status</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/archives.png\" alt=\"Archives\" width=\"800\">\n  <br><em>Print archive with 3D preview and project assignment</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/reprint_ams_mapping.png\" alt=\"Reprint AMS Mapping\" width=\"800\">\n  <br><em>Re-print with AMS filament mapping preview</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/edit-timelapse.png\" alt=\"Timelapse Editor\" width=\"800\">\n  <br><em>Built-in timelapse editor with trim, speed, and music</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/projects.png\" alt=\"Projects\" width=\"800\">\n  <br><em>Group related prints into projects</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/project-detail-1.png\" alt=\"Project Detail\" width=\"800\">\n  <br><em>Project detail view with assigned archives</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/project-detail-2.png\" alt=\"Project Detail Timeline\" width=\"800\">\n  <br><em>Project timeline and print history</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/print-queue.png\" alt=\"Queue\" width=\"800\">\n  <br><em>Print scheduling and queue management</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/schedule-print.png\" alt=\"Schedule Print\" width=\"800\">\n  <br><em>Schedule prints for specific date and time</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/statistics.png\" alt=\"Statistics\" width=\"800\">\n  <br><em>Customizable statistics dashboard</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/maintenance-1.png\" alt=\"Maintenance\" width=\"800\">\n  <br><em>Maintenance tracking per printer</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/maintenance-2.png\" alt=\"Maintenance Settings\" width=\"800\">\n  <br><em>Configure maintenance types and intervals</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/cloud_profiles-1.png\" alt=\"Cloud Profiles\" width=\"800\">\n  <br><em>Bambu Cloud filament profiles</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/cloud_profiles-2.png\" alt=\"Cloud Profiles Edit\" width=\"800\">\n  <br><em>Edit filament preset settings</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/k_profiles-1.png\" alt=\"K-Profiles\" width=\"800\">\n  <br><em>Pressure advance (K-factor) profiles</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/k_profiles-2.png\" alt=\"K-Profiles Edit\" width=\"800\">\n  <br><em>Edit K-factor profile settings</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/settings-general.png\" alt=\"Settings\" width=\"800\">\n  <br><em>General configuration and integrations</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/settings-powerplugs.png\" alt=\"Smart Plugs\" width=\"800\">\n  <br><em>Smart plug control and energy monitoring</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/settings_notifications.png\" alt=\"Notifications\" width=\"800\">\n  <br><em>Multi-provider notification system</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/settings_api_keys.png\" alt=\"API Keys\" width=\"800\">\n  <br><em>API keys and webhook endpoints</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/settings-virtual-printer.png\" alt=\"Virtual Printer Settings\" width=\"800\">\n  <br><em>Virtual printer configuration</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/slicer-virtual-printer.png\" alt=\"Slicer Virtual Printer\" width=\"800\">\n  <br><em>Virtual printer appears in Bambu Studio/Orca Slicer</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/mqtt-debug-log.png\" alt=\"MQTT Debug Log\" width=\"800\">\n  <br><em>MQTT debug logging for troubleshooting</em>\n</p>\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/quick_power_plug_sidebar.png\" alt=\"Quick Power Plug\" width=\"400\">\n  <br><em>Quick power plug control in sidebar</em>\n</p>\n\n</details>\n\n---\n\n## 🚀 Quick Start\n\n### Requirements\n- Python 3.10+ (3.11/3.12 recommended)\n- Bambu Lab printer with **Developer Mode** enabled (see below)\n- **\"Store sent files on external storage\"** enabled in Bambu Studio/OrcaSlicer\n- Same local network as printer\n\n### Installation\n\n#### Docker (Recommended)\n\n**Option A: Pre-built image (fastest)**\n```bash\nmkdir bambuddy && cd bambuddy\ncurl -O https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml\ndocker compose up -d\n```\n\n**Option B: Build from source**\n```bash\ngit clone https://github.com/maziggy/bambuddy.git\ncd bambuddy\ndocker compose up -d --build\n```\n\nOpen **http://localhost:8000** in your browser.\n\n> **Multi-architecture support:** Pre-built images are available for `linux/amd64` and `linux/arm64` (Raspberry Pi 4/5).\n\n> **macOS/Windows users:** Docker Desktop doesn't support `network_mode: host`. Edit docker-compose.yml: comment out `network_mode: host` and uncomment the `ports:` section. Printer discovery won't work - add printers manually by IP.\n\n> **Linux users:** If you get \"permission denied\" errors, either prefix commands with `sudo` (e.g., `sudo docker compose up -d`) or [add your user to the docker group](https://docs.docker.com/engine/install/linux-postinstall/).\n\n<details>\n<summary><strong>Docker Configuration & Commands</strong></summary>\n\n**Environment Variables:**\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `TZ` | `UTC` | Your timezone (e.g., `America/New_York`, `Europe/Berlin`) |\n| `PORT` | `8000` | Port BamBuddy runs on (with host networking mode) |\n| `DEBUG` | `false` | Enable debug logging |\n| `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` |\n\n**Data Persistence:**\n\n| Volume | Purpose |\n|--------|---------|\n| `bambuddy.db` | SQLite database with all your print data (not used with PostgreSQL) |\n| `archive/` | Archived 3MF files and thumbnails |\n| `logs/` | Application logs |\n\n**Updating:**\n\n```bash\n# Pre-built image: just pull the latest\ndocker compose pull && docker compose up -d\n\n# From source: rebuild after pulling changes\ncd bambuddy && git pull && docker compose up -d --build\n```\n\n**Daily Beta Builds:**\n\nBeta builds with the latest fixes are pushed regularly to the same beta version tag:\n\n```bash\n# Pull the current beta\ndocker pull ghcr.io/maziggy/bambuddy:0.2.2b1\n# or from Docker Hub\ndocker pull maziggy/bambuddy:0.2.2b1\n```\n\nUse [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.\n\n> **Note:** Beta builds use version tags like `0.2.2b1` — they are never tagged as `latest`. Your stable installation won't auto-update to a beta unless you explicitly pull a beta tag.\n\n**Useful Commands:**\n\n```bash\n# View logs\ndocker compose logs -f\n\n# Stop/Start\ndocker compose down\ndocker compose up -d\n\n# Shell access\ndocker compose exec bambuddy /bin/bash\n```\n\n**Custom Port:**\n\n```yaml\nports:\n  - \"3000:8000\"  # Access on port 3000\n```\n\n**Reverse Proxy (Nginx):**\n\n```nginx\nserver {\n    listen 443 ssl http2;\n    server_name bambuddy.yourdomain.com;\n\n    ssl_certificate /path/to/cert.pem;\n    ssl_certificate_key /path/to/key.pem;\n\n    location / {\n        proxy_pass http://localhost:8000;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_read_timeout 86400;\n    }\n}\n```\n\n> **Note:** WebSocket support is required for real-time printer updates.\n\n**Network Mode Host** (required for printer discovery and camera streaming):\n\n```yaml\nservices:\n  bambuddy:\n    build: .\n    network_mode: host\n```\n\n> **Note:** Docker's default bridge networking cannot receive SSDP multicast packets for automatic printer discovery. When using `network_mode: host`, Bambuddy auto-detects your network subnet and can discover printers via subnet scanning in the Add Printer dialog.\n\n</details>\n\n#### Manual Installation (Linux/macOS)\n\n```bash\n# Clone and setup\ngit clone https://github.com/maziggy/bambuddy.git\ncd bambuddy\npython3 -m venv venv\nsource venv/bin/activate\npip install -r requirements.txt\n\n# Run\nuvicorn backend.app.main:app --host 0.0.0.0 --port 8000\n```\n\nOpen **http://localhost:8000** and add your printer!\n\n> **Need detailed instructions?** See the [Installation Guide](http://wiki.bambuddy.cool/getting-started/installation/)\n\n### Enabling Developer Mode\n\nDeveloper Mode allows third-party software like Bambuddy to control your printer over the local network.\n\n1. On printer: **Settings** → **Network** → **LAN Only Mode** → Enable\n2. Enable **Developer Mode** (appears after LAN Only Mode is enabled)\n3. Note the **Access Code** displayed\n4. Find IP address in network settings\n5. Find Serial Number in device info\n\n> **Note:** Developer Mode disables cloud features but provides full local control. Standard LAN Mode (without Developer Mode) only allows read-only monitoring.\n\n### Slicer Settings\n\nIn Bambu Studio or OrcaSlicer, enable **\"Store sent files on external storage\"** so that print files (3MF) are saved to the printer's SD card. Bambuddy needs these files to extract thumbnails and 3D model previews.\n\n1. Open **Bambu Studio** or **OrcaSlicer**\n2. Go to the **Device** tab for your printer\n3. In **Print Options**, enable **Store Sent Files on External Storage**\n\n---\n\n## 📚 Documentation\n\nFull documentation available at **[wiki.bambuddy.cool](http://wiki.bambuddy.cool)**:\n\n- [Installation](http://wiki.bambuddy.cool/getting-started/installation/) — All installation methods\n- [Getting Started](http://wiki.bambuddy.cool/getting-started/) — First printer setup\n- [Features](http://wiki.bambuddy.cool/features/) — Detailed feature guides\n- [Troubleshooting](http://wiki.bambuddy.cool/reference/troubleshooting/) — Common issues & solutions\n- [API Reference](http://wiki.bambuddy.cool/reference/api/) — REST API documentation\n\n---\n\n## 🖨️ Supported Printers\n\n| Series | Models |\n|--------|--------|\n| X1 | X1, X1 Carbon, X1E |\n| X2 | X2D |\n| H2 | H2D, H2D Pro, H2C, H2S |\n| P1 | P1P, P1S |\n| P2 | P2S |\n| A1 | A1, A1 Mini |\n\n---\n\n## 🛠️ Tech Stack\n\n| Component | Technology |\n|-----------|------------|\n| Backend | Python, FastAPI, SQLAlchemy |\n| Frontend | React, TypeScript, Tailwind CSS |\n| Database | SQLite (default) or PostgreSQL |\n| 3D Viewer | Three.js |\n| Communication | MQTT (TLS), FTPS |\n\n---\n\n## 🤝 Contributing\n\nContributions welcome! **I'm especially looking for help with documentation and our new [Discourse forum](https://forum.bambuddy.cool)** — see [Contributors Wanted](#-contributors-wanted--help-shape-bambuddy) above. Other ways to help:\n\n1. **📝 Document** — Improve the wiki and guides *(urgently needed!)*\n2. **⚙️ Admin Discourse** — Help configure and tune the [forum](https://forum.bambuddy.cool) *(urgently needed!)*\n3. **💬 Moderate** — Welcome newcomers and keep [forum](https://forum.bambuddy.cool) discussions healthy *(urgently needed!)*\n4. **Test** — Report issues with your printer model\n5. **Translate** — Add new languages\n6. **Code** — Submit PRs for bugs or features\n\nNot sure where to start? Reach out on [Discord](https://discord.gg/aFS3ZfScHM), post on the [forum](https://forum.bambuddy.cool), or email **martin@bambuddy.cool** — I'll help you find something that fits.\n\n```bash\n# Development setup\ngit clone https://github.com/maziggy/bambuddy.git\ncd bambuddy\n\n# Backend\npython3 -m venv venv && source venv/bin/activate\npip install -r requirements.txt\nDEBUG=true uvicorn backend.app.main:app --reload\n\n# Frontend (separate terminal)\ncd frontend && npm install && npm run dev\n```\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n---\n\n## 📄 License\n\nAGPL-3.0 License — see [LICENSE](LICENSE) for details.\n\n---\n\n## 🙏 Acknowledgments\n\n- [SpoolEase](https://github.com/yanshay/SpoolEase) by yanshay — early inspiration for NFC-based spool tracking and AMS inventory concepts\n- [Bambu Lab](https://bambulab.com/) for amazing printers\n- The reverse engineering community for protocol documentation\n- All testers and contributors\n\n---\n\nIf you like Bambuddy and want to support it, you can <a href=\"https://ko-fi.com/maziggy\" target=_blank>buy Martin a coffee</a>.\n\n---\n\n<p align=\"center\">\n  Made with ❤️ for the 3D printing community\n  <br><br>\n  <a href=\"https://forum.bambuddy.cool\">Forum</a> •\n  <a href=\"https://discord.gg/aFS3ZfScHM\">Join our Discord</a> •\n  <a href=\"https://github.com/maziggy/bambuddy/issues\">Report Bug</a> •\n  <a href=\"https://github.com/maziggy/bambuddy/issues\">Request Feature</a> •\n  <a href=\"http://wiki.bambuddy.cool\">Documentation</a>\n</p>\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nThe Bambuddy team takes security seriously. We appreciate your efforts to responsibly disclose your findings.\n\n### How to Report\n\n**Please DO NOT report security vulnerabilities through public GitHub issues.**\n\nInstead, please report them via email to:\n\n**security@bambuddy.cool**\n\nOr use GitHub's private vulnerability reporting feature:\n1. Go to the [Security tab](https://github.com/maziggy/bambuddy/security)\n2. Click \"Report a vulnerability\"\n3. Fill out the form with details\n\n### What to Include\n\nPlease include the following information in your report:\n\n- **Description** of the vulnerability\n- **Steps to reproduce** the issue\n- **Affected versions** of Bambuddy\n- **Potential impact** of the vulnerability\n- **Any suggested fixes** (if you have them)\n\n### What to Expect\n\n- **Acknowledgment**: We will acknowledge receipt of your report within 48 hours\n- **Assessment**: We will investigate and validate the issue within 7 days\n- **Updates**: We will keep you informed of our progress\n- **Resolution**: We aim to release a fix within 30 days for critical issues\n- **Credit**: We will credit you in our release notes (unless you prefer to remain anonymous)\n\n## Supported Versions\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 0.1.x   | :white_check_mark: |\n| 0.2.x   | :white_check_mark: |\n\n## Security Considerations\n\n### Network Security\n\nBambuddy communicates with your printers over your local network using:\n\n- **MQTT over TLS** (port 8883) - Encrypted printer communication\n- **FTPS** (port 990) - Encrypted file transfers\n\n### Recommendations\n\n1. **Run on trusted network**: Bambuddy should only be accessible on your local network\n2. **Use reverse proxy**: If exposing to the internet, use a reverse proxy with HTTPS\n3. **Keep updated**: Always run the latest version for security patches\n4. **Secure API keys**: Treat API keys like passwords; don't share them publicly\n5. **Developer Mode**: Use your printer's Developer Mode access code; don't share it\n\n### Known Security Features\n\n- API key authentication for external access\n- No default credentials\n- Local-only by default (no cloud dependency)\n- TLS encryption for printer communication\n\n## Scope\n\nThe following are **in scope** for security reports:\n\n- Authentication/authorization bypasses\n- Remote code execution\n- SQL injection\n- Cross-site scripting (XSS)\n- Cross-site request forgery (CSRF)\n- Sensitive data exposure\n- Insecure direct object references\n\nThe following are **out of scope**:\n\n- Issues in dependencies (report to the upstream project)\n- Social engineering attacks\n- Physical attacks\n- Denial of service (DoS) attacks\n- Issues requiring physical access to the server\n\n## Acknowledgments\n\nWe thank the following individuals for responsibly disclosing security issues:\n\n*No security issues have been reported yet.*\n\n---\n\nThank you for helping keep Bambuddy and its users safe!\n"
  },
  {
    "path": "UPDATING.md",
    "content": "# Updating Bambuddy\n\n> **One-time note for 0.2.2.x → 0.2.3:** the in-app **Update** button does not\n> reliably perform this specific migration. Do this one upgrade from the\n> command line using the steps below. Once you're on 0.2.3, the in-app Update\n> button works normally again for all future releases.\n\nPick the section that matches how Bambuddy was installed.\n\n---\n\n## Docker\n\n```bash\n# 1. Make sure your compose file isn't pinned to an old version.\n#    The image line should read one of:\n#      image: ghcr.io/maziggy/bambuddy:latest\n#      image: ghcr.io/maziggy/bambuddy:0.2.3\n#    If it pins an older tag (e.g. :0.2.2.2), edit it first.\n\n# 2. Pull and restart\ndocker compose pull\ndocker compose up -d\n```\n\n**If your `docker-compose.yml` is older than 0.2.3,** also refresh it from the\nrepo — recent releases added `cap_add: NET_BIND_SERVICE`, extra virtual-printer\nports for bridge mode, and an optional Postgres block:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml \\\n  -o docker-compose.yml.new\n# Diff against yours, merge by hand, then:\ndocker compose up -d\n```\n\n---\n\n## Native install (`install.sh` or manual `git clone`)\n\nBoth paths produce a git working tree at the install directory, so the update\nis the same. Preferred:\n\n```bash\nsudo /opt/bambuddy/install/update.sh\n```\n\n`update.sh` stops the service, snapshots the database via the built-in backup\nAPI, fast-forwards to `origin/main`, installs Python deps, rebuilds the\nfrontend, and restarts the service. It rolls back automatically if any step\nfails.\n\n### Manual equivalent\n\nIf you'd rather run the steps yourself:\n\n```bash\ncd /opt/bambuddy\nsudo systemctl stop bambuddy\nsudo -u bambuddy git fetch origin\nsudo -u bambuddy git reset --hard origin/main\nsudo -u bambuddy venv/bin/pip install -r requirements.txt\nsudo systemctl start bambuddy\n```\n\nReplace `/opt/bambuddy` with your install path if different. Database schema\nmigrations run automatically on startup — no Alembic step is required.\n\n---\n\n## Installed from a GitHub ZIP or tarball download\n\nThese installs have no `.git` directory, so neither `update.sh` nor a plain\n`git pull` will work. Reinstall cleanly:\n\n```bash\n# 1. Back up your stateful data\nCreate and download a backup via Bambuddy Settings -> Backup -> Local Backup\n\n# 2. Remove the old install and reinstall via install.sh\nsudo rm -rf /opt/bambuddy\ncurl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh \\\n  -o /tmp/install.sh && sudo bash /tmp/install.sh --path /opt/bambuddy\n\n# 3. Restore your data\nRestore your backup via Bambuddy -> Settings -> Backup -> Local Backup\n\n# 4. Restart Bambuddy\nsudo systemctl restart bambuddy\n```\n\n---\n\n## Before you upgrade\n\nTake a backup. Settings → Backup → **Create Backup** downloads a ZIP containing\nthe database and all stateful directories. Any bare-metal update via\n`update.sh` does this automatically; Docker and manual upgrades do not.\n"
  },
  {
    "path": "backend/__init__.py",
    "content": ""
  },
  {
    "path": "backend/app/__init__.py",
    "content": ""
  },
  {
    "path": "backend/app/api/__init__.py",
    "content": ""
  },
  {
    "path": "backend/app/api/routes/__init__.py",
    "content": ""
  },
  {
    "path": "backend/app/api/routes/ams_history.py",
    "content": "\"\"\"API routes for AMS sensor history.\"\"\"\n\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi import APIRouter, Depends, Query\nfrom pydantic import BaseModel\nfrom sqlalchemy import and_, func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.ams_history import AMSSensorHistory\nfrom backend.app.models.user import User\n\nrouter = APIRouter(prefix=\"/ams-history\", tags=[\"ams-history\"])\n\n\nclass AMSHistoryPoint(BaseModel):\n    recorded_at: datetime\n    humidity: float | None\n    humidity_raw: float | None\n    temperature: float | None\n\n\nclass AMSHistoryResponse(BaseModel):\n    printer_id: int\n    ams_id: int\n    data: list[AMSHistoryPoint]\n    min_humidity: float | None\n    max_humidity: float | None\n    avg_humidity: float | None\n    min_temperature: float | None\n    max_temperature: float | None\n    avg_temperature: float | None\n\n\n@router.get(\"/{printer_id}/{ams_id}\", response_model=AMSHistoryResponse)\nasync def get_ams_history(\n    printer_id: int,\n    ams_id: int,\n    hours: int = Query(default=24, ge=1, le=168, description=\"Hours of history (1-168)\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),\n):\n    \"\"\"Get AMS sensor history for a specific printer and AMS unit.\"\"\"\n    since = datetime.now(timezone.utc) - timedelta(hours=hours)\n\n    # Get data points\n    result = await db.execute(\n        select(AMSSensorHistory)\n        .where(\n            and_(\n                AMSSensorHistory.printer_id == printer_id,\n                AMSSensorHistory.ams_id == ams_id,\n                AMSSensorHistory.recorded_at >= since,\n            )\n        )\n        .order_by(AMSSensorHistory.recorded_at)\n    )\n    records = result.scalars().all()\n\n    # Calculate stats\n    stats_result = await db.execute(\n        select(\n            func.min(AMSSensorHistory.humidity).label(\"min_humidity\"),\n            func.max(AMSSensorHistory.humidity).label(\"max_humidity\"),\n            func.avg(AMSSensorHistory.humidity).label(\"avg_humidity\"),\n            func.min(AMSSensorHistory.temperature).label(\"min_temp\"),\n            func.max(AMSSensorHistory.temperature).label(\"max_temp\"),\n            func.avg(AMSSensorHistory.temperature).label(\"avg_temp\"),\n        ).where(\n            and_(\n                AMSSensorHistory.printer_id == printer_id,\n                AMSSensorHistory.ams_id == ams_id,\n                AMSSensorHistory.recorded_at >= since,\n            )\n        )\n    )\n    stats = stats_result.one()\n\n    return AMSHistoryResponse(\n        printer_id=printer_id,\n        ams_id=ams_id,\n        data=[\n            AMSHistoryPoint(\n                recorded_at=r.recorded_at,\n                humidity=r.humidity,\n                humidity_raw=r.humidity_raw,\n                temperature=r.temperature,\n            )\n            for r in records\n        ],\n        min_humidity=stats.min_humidity,\n        max_humidity=stats.max_humidity,\n        avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity else None,\n        min_temperature=stats.min_temp,\n        max_temperature=stats.max_temp,\n        avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp else None,\n    )\n\n\n@router.delete(\"/{printer_id}\")\nasync def delete_old_history(\n    printer_id: int,\n    days: int = Query(default=30, ge=1, le=365, description=\"Delete data older than X days\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),\n):\n    \"\"\"Delete old AMS history data for a printer.\"\"\"\n    cutoff = datetime.now(timezone.utc) - timedelta(days=days)\n\n    result = await db.execute(\n        select(func.count(AMSSensorHistory.id)).where(\n            and_(\n                AMSSensorHistory.printer_id == printer_id,\n                AMSSensorHistory.recorded_at < cutoff,\n            )\n        )\n    )\n    count = result.scalar()\n\n    await db.execute(\n        AMSSensorHistory.__table__.delete().where(\n            and_(\n                AMSSensorHistory.printer_id == printer_id,\n                AMSSensorHistory.recorded_at < cutoff,\n            )\n        )\n    )\n    await db.commit()\n\n    return {\"deleted\": count, \"message\": f\"Deleted {count} records older than {days} days\"}\n"
  },
  {
    "path": "backend/app/api/routes/api_keys.py",
    "content": "import logging\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.api_key import APIKey\nfrom backend.app.models.user import User\nfrom backend.app.schemas.api_key import (\n    APIKeyCreate,\n    APIKeyCreateResponse,\n    APIKeyResponse,\n    APIKeyUpdate,\n)\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/api-keys\", tags=[\"api-keys\"])\n\n\n@router.get(\"/\", response_model=list[APIKeyResponse])\nasync def list_api_keys(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),\n):\n    \"\"\"List all API keys (without full key values).\"\"\"\n    result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))\n    return list(result.scalars().all())\n\n\n@router.post(\"/\", response_model=APIKeyCreateResponse)\nasync def create_api_key(\n    data: APIKeyCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),\n):\n    \"\"\"Create a new API key.\n\n    IMPORTANT: The full API key is only returned in this response.\n    Store it securely - it cannot be retrieved again.\n    \"\"\"\n    # Generate the key\n    full_key, key_hash, key_prefix = generate_api_key()\n\n    api_key = APIKey(\n        name=data.name,\n        key_hash=key_hash,\n        key_prefix=key_prefix,\n        can_queue=data.can_queue,\n        can_control_printer=data.can_control_printer,\n        can_read_status=data.can_read_status,\n        printer_ids=data.printer_ids,\n        expires_at=data.expires_at,\n    )\n    db.add(api_key)\n    await db.flush()\n    await db.refresh(api_key)\n\n    # Return with full key (only time it's shown)\n    return APIKeyCreateResponse(\n        id=api_key.id,\n        name=api_key.name,\n        key_prefix=api_key.key_prefix,\n        key=full_key,  # Only returned on creation\n        can_queue=api_key.can_queue,\n        can_control_printer=api_key.can_control_printer,\n        can_read_status=api_key.can_read_status,\n        printer_ids=api_key.printer_ids,\n        enabled=api_key.enabled,\n        last_used=api_key.last_used,\n        created_at=api_key.created_at,\n        expires_at=api_key.expires_at,\n    )\n\n\n@router.get(\"/{key_id}\", response_model=APIKeyResponse)\nasync def get_api_key(\n    key_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),\n):\n    \"\"\"Get an API key by ID.\"\"\"\n    result = await db.execute(select(APIKey).where(APIKey.id == key_id))\n    api_key = result.scalar_one_or_none()\n\n    if not api_key:\n        raise HTTPException(status_code=404, detail=\"API key not found\")\n\n    return api_key\n\n\n@router.patch(\"/{key_id}\", response_model=APIKeyResponse)\nasync def update_api_key(\n    key_id: int,\n    data: APIKeyUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),\n):\n    \"\"\"Update an API key.\"\"\"\n    result = await db.execute(select(APIKey).where(APIKey.id == key_id))\n    api_key = result.scalar_one_or_none()\n\n    if not api_key:\n        raise HTTPException(status_code=404, detail=\"API key not found\")\n\n    # Update fields if provided\n    if data.name is not None:\n        api_key.name = data.name\n    if data.can_queue is not None:\n        api_key.can_queue = data.can_queue\n    if data.can_control_printer is not None:\n        api_key.can_control_printer = data.can_control_printer\n    if data.can_read_status is not None:\n        api_key.can_read_status = data.can_read_status\n    if data.printer_ids is not None:\n        api_key.printer_ids = data.printer_ids\n    if data.enabled is not None:\n        api_key.enabled = data.enabled\n    if data.expires_at is not None:\n        api_key.expires_at = data.expires_at\n\n    await db.flush()\n    await db.refresh(api_key)\n\n    return api_key\n\n\n@router.delete(\"/{key_id}\")\nasync def delete_api_key(\n    key_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),\n):\n    \"\"\"Delete (revoke) an API key.\"\"\"\n    result = await db.execute(select(APIKey).where(APIKey.id == key_id))\n    api_key = result.scalar_one_or_none()\n\n    if not api_key:\n        raise HTTPException(status_code=404, detail=\"API key not found\")\n\n    await db.delete(api_key)\n\n    return {\"message\": \"API key deleted\"}\n"
  },
  {
    "path": "backend/app/api/routes/archives.py",
    "content": "import io\nimport json\nimport logging\nimport zipfile\nfrom collections import defaultdict\nfrom datetime import date, datetime, time, timezone\nfrom decimal import ROUND_HALF_UP, Decimal\nfrom pathlib import Path\n\nfrom fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile\nfrom fastapi.responses import FileResponse, Response\nfrom sqlalchemy import and_, func, or_, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import (\n    RequireCameraStreamTokenIfAuthEnabled,\n    RequirePermissionIfAuthEnabled,\n    require_ownership_permission,\n)\nfrom backend.app.core.config import settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.filament import Filament\nfrom backend.app.models.spool_usage_history import SpoolUsageHistory\nfrom backend.app.models.user import User\nfrom backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest\nfrom backend.app.services.archive import ArchiveService\nfrom backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/archives\", tags=[\"archives\"])\n\n\ndef _safe_filename(filename: str) -> str:\n    \"\"\"Extract basename from a client-supplied filename, preventing path traversal.\n\n    Normalizes backslashes (Windows paths) before extracting so that\n    '..\\\\\\\\..\\\\\\\\evil.3mf' is correctly stripped to 'evil.3mf' on Linux.\n    \"\"\"\n    return Path(filename.replace(\"\\\\\", \"/\")).name\n\n\ndef _validate_user_filter_permission(current_user: User | None, created_by_id: int | None):\n    \"\"\"Raise 403 if created_by_id filter is used without stats:filter_by_user permission.\"\"\"\n    if created_by_id is None or current_user is None:\n        return\n    if current_user.is_admin:\n        return\n    if not current_user.has_permission(Permission.STATS_FILTER_BY_USER.value):\n        raise HTTPException(status_code=403, detail=\"Permission stats:filter_by_user required\")\n\n\ndef _apply_user_filter(conditions: list, created_by_id: int | None):\n    \"\"\"Append created_by_id filter to conditions list if specified.\"\"\"\n    if created_by_id is not None:\n        if created_by_id == -1:\n            conditions.append(PrintArchive.created_by_id.is_(None))\n        else:\n            conditions.append(PrintArchive.created_by_id == created_by_id)\n\n\ndef compute_time_accuracy(archive: PrintArchive) -> dict:\n    \"\"\"Compute actual print time and accuracy for an archive.\n\n    Returns dict with actual_time_seconds and time_accuracy.\n    time_accuracy = (estimated / actual) * 100\n    - 100% = perfect estimate\n    - >100% = print was faster than estimated\n    - <100% = print took longer than estimated\n    \"\"\"\n    result = {\"actual_time_seconds\": None, \"time_accuracy\": None}\n\n    if archive.started_at and archive.completed_at and archive.status == \"completed\":\n        actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())\n        if actual_seconds > 0:\n            result[\"actual_time_seconds\"] = actual_seconds\n\n            if archive.print_time_seconds and archive.print_time_seconds > 0:\n                # Calculate accuracy as percentage\n                accuracy = (archive.print_time_seconds / actual_seconds) * 100\n                # Sanity check: skip unreasonable values (e.g., manually changed status)\n                # Valid range: 5% to 500% (print took 20x longer to 5x faster than estimated)\n                if 5 <= accuracy <= 500:\n                    result[\"time_accuracy\"] = round(accuracy, 1)\n\n    return result\n\n\ndef archive_to_response(\n    archive: PrintArchive,\n    duplicates: list[dict] | None = None,\n    duplicate_count: int = 0,\n    duplicate_sequence: int = 0,\n    original_archive_id: int | None = None,\n) -> dict:\n    \"\"\"Convert archive model to response dict with computed fields.\"\"\"\n    data = {\n        \"id\": archive.id,\n        \"printer_id\": archive.printer_id,\n        \"project_id\": archive.project_id,\n        \"project_name\": archive.project.name if archive.project else None,\n        \"filename\": archive.filename,\n        \"file_path\": archive.file_path,\n        \"file_size\": archive.file_size,\n        \"content_hash\": archive.content_hash,\n        \"thumbnail_path\": archive.thumbnail_path,\n        \"timelapse_path\": archive.timelapse_path,\n        \"source_3mf_path\": archive.source_3mf_path,\n        \"f3d_path\": archive.f3d_path,\n        \"duplicates\": duplicates,\n        \"duplicate_count\": duplicate_count if duplicates is None else len(duplicates),\n        \"duplicate_sequence\": duplicate_sequence,\n        \"original_archive_id\": original_archive_id,\n        \"print_name\": archive.print_name,\n        \"print_time_seconds\": archive.print_time_seconds,\n        \"filament_used_grams\": archive.filament_used_grams,\n        \"filament_type\": archive.filament_type,\n        \"filament_color\": archive.filament_color,\n        \"layer_height\": archive.layer_height,\n        \"total_layers\": archive.total_layers,\n        \"nozzle_diameter\": archive.nozzle_diameter,\n        \"bed_temperature\": archive.bed_temperature,\n        \"nozzle_temperature\": archive.nozzle_temperature,\n        \"sliced_for_model\": archive.sliced_for_model,\n        \"status\": archive.status,\n        \"started_at\": archive.started_at,\n        \"completed_at\": archive.completed_at,\n        \"extra_data\": archive.extra_data,\n        \"makerworld_url\": archive.makerworld_url,\n        \"designer\": archive.designer,\n        \"external_url\": archive.external_url,\n        \"is_favorite\": archive.is_favorite,\n        \"tags\": archive.tags,\n        \"notes\": archive.notes,\n        \"cost\": archive.cost,\n        \"photos\": archive.photos,\n        \"failure_reason\": archive.failure_reason,\n        \"quantity\": archive.quantity,\n        \"energy_kwh\": archive.energy_kwh,\n        \"energy_cost\": archive.energy_cost,\n        \"created_at\": archive.created_at,\n        # User tracking (Issue #206)\n        \"created_by_id\": archive.created_by_id,\n        \"created_by_username\": archive.created_by.username if archive.created_by else None,\n    }\n\n    # Add computed time accuracy fields\n    accuracy_data = compute_time_accuracy(archive)\n    data.update(accuracy_data)\n\n    return data\n\n\n@router.get(\"/\", response_model=list[ArchiveResponse])\nasync def list_archives(\n    printer_id: int | None = None,\n    project_id: int | None = None,\n    date_from: date | None = Query(None),\n    date_to: date | None = Query(None),\n    limit: int = 50,\n    offset: int = 0,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"List archived prints.\"\"\"\n    service = ArchiveService(db)\n    archives = await service.list_archives(\n        printer_id=printer_id,\n        project_id=project_id,\n        date_from=date_from,\n        date_to=date_to,\n        limit=limit,\n        offset=offset,\n    )\n\n    # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)\n    duplicate_hashes, duplicate_name_hash_pairs = await service.get_duplicate_hashes_and_names()\n\n    # Batch-load duplicate groups once for the current page keys.\n    duplicate_hashes_in_page = {\n        a.content_hash for a in archives if a.content_hash and a.content_hash in duplicate_hashes\n    }\n    duplicate_name_hash_keys_in_page = {\n        (a.print_name.lower(), a.content_hash)\n        for a in archives\n        if a.print_name and a.content_hash and (a.print_name.lower(), a.content_hash) in duplicate_name_hash_pairs\n    }\n\n    duplicate_meta_by_archive_id: dict[int, tuple[int, int, int]] = {}\n\n    if duplicate_hashes_in_page or duplicate_name_hash_keys_in_page:\n        duplicate_group_conditions = []\n        if duplicate_hashes_in_page:\n            duplicate_group_conditions.append(PrintArchive.content_hash.in_(duplicate_hashes_in_page))\n        if duplicate_name_hash_keys_in_page:\n            name_hash_conditions = [\n                and_(func.lower(PrintArchive.print_name) == name, PrintArchive.content_hash == hash_)\n                for name, hash_ in duplicate_name_hash_keys_in_page\n            ]\n            duplicate_group_conditions.extend(name_hash_conditions)\n\n        duplicate_group_rows = await db.execute(\n            select(\n                PrintArchive.id,\n                PrintArchive.created_at,\n                PrintArchive.content_hash,\n                func.lower(PrintArchive.print_name).label(\"print_name_lower\"),\n            ).where(or_(*duplicate_group_conditions))\n        )\n\n        duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)\n        duplicate_groups_by_name_hash: dict[tuple[str, str], list[tuple[int, datetime]]] = defaultdict(list)\n\n        for archive_id, created_at, content_hash, print_name_lower in duplicate_group_rows.all():\n            if content_hash and content_hash in duplicate_hashes_in_page:\n                duplicate_groups_by_hash[content_hash].append((archive_id, created_at))\n            if (\n                print_name_lower\n                and content_hash\n                and (print_name_lower, content_hash) in duplicate_name_hash_keys_in_page\n            ):\n                duplicate_groups_by_name_hash[(print_name_lower, content_hash)].append((archive_id, created_at))\n\n        for group in duplicate_groups_by_hash.values():\n            if len(group) < 2:\n                continue\n            group.sort(key=lambda x: x[1])\n            original_id = group[0][0]\n            duplicate_count = len(group) - 1\n            for sequence, (archive_id, _) in enumerate(group):\n                duplicate_meta_by_archive_id[archive_id] = (sequence, original_id, duplicate_count)\n\n        # Keep hash-based grouping precedence; name/hash groups only fill missing items.\n        for group in duplicate_groups_by_name_hash.values():\n            if len(group) < 2:\n                continue\n            group.sort(key=lambda x: x[1])\n            original_id = group[0][0]\n            duplicate_count = len(group) - 1\n            for sequence, (archive_id, _) in enumerate(group):\n                duplicate_meta_by_archive_id.setdefault(archive_id, (sequence, original_id, duplicate_count))\n\n    # Build response with duplicate sequence and original archive ID pre-computed\n    result = []\n    for a in archives:\n        has_hash_dup = a.content_hash in duplicate_hashes if a.content_hash else False\n        has_name_dup = (\n            bool(a.print_name and a.content_hash)\n            and (a.print_name.lower(), a.content_hash) in duplicate_name_hash_pairs\n        )\n        has_duplicate = has_hash_dup or has_name_dup\n\n        # Pre-compute duplicate sequence and original archive ID\n        duplicate_sequence = 0\n        original_archive_id: int | None = None\n        duplicate_count = 1 if has_duplicate else 0\n\n        if has_duplicate and a.id in duplicate_meta_by_archive_id:\n            duplicate_sequence, original_archive_id, duplicate_count = duplicate_meta_by_archive_id[a.id]\n\n        result.append(\n            archive_to_response(\n                a,\n                duplicate_count=duplicate_count,\n                duplicate_sequence=duplicate_sequence,\n                original_archive_id=original_archive_id,\n            )\n        )\n    return result\n\n\n@router.get(\"/slim\", response_model=list[ArchiveSlim])\nasync def list_archives_slim(\n    date_from: date | None = Query(None),\n    date_to: date | None = Query(None),\n    created_by_id: int | None = Query(None, description=\"Filter by user who created the print (-1 for no user)\"),\n    limit: int = Query(default=10000, le=50000),\n    offset: int = 0,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Lightweight archive listing for stats/dashboard widgets.\n\n    Returns only the fields needed for client-side aggregation,\n    skipping duplicate detection, file paths, and extra_data.\n    \"\"\"\n    _validate_user_filter_permission(current_user, created_by_id)\n    filters = []\n    if date_from:\n        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)\n        filters.append(PrintArchive.created_at >= dt_from)\n    if date_to:\n        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)\n        filters.append(PrintArchive.created_at <= dt_to)\n    _apply_user_filter(filters, created_by_id)\n\n    query = (\n        select(\n            PrintArchive.printer_id,\n            PrintArchive.print_name,\n            PrintArchive.print_time_seconds,\n            PrintArchive.started_at,\n            PrintArchive.completed_at,\n            PrintArchive.filament_used_grams,\n            PrintArchive.filament_type,\n            PrintArchive.filament_color,\n            PrintArchive.status,\n            PrintArchive.cost,\n            PrintArchive.quantity,\n            PrintArchive.created_at,\n        )\n        .where(*filters)\n        .order_by(PrintArchive.created_at.desc())\n        .limit(limit)\n        .offset(offset)\n    )\n    result = await db.execute(query)\n    rows = result.all()\n\n    return [\n        {\n            \"printer_id\": r.printer_id,\n            \"print_name\": r.print_name,\n            \"print_time_seconds\": r.print_time_seconds,\n            \"actual_time_seconds\": (\n                int((r.completed_at - r.started_at).total_seconds())\n                if r.started_at\n                and r.completed_at\n                and r.status == \"completed\"\n                and (r.completed_at - r.started_at).total_seconds() > 0\n                else None\n            ),\n            \"filament_used_grams\": r.filament_used_grams,\n            \"filament_type\": r.filament_type,\n            \"filament_color\": r.filament_color,\n            \"status\": r.status,\n            \"started_at\": r.started_at,\n            \"completed_at\": r.completed_at,\n            \"cost\": r.cost,\n            \"quantity\": r.quantity,\n            \"created_at\": r.created_at,\n        }\n        for r in rows\n    ]\n\n\n@router.get(\"/search\", response_model=list[ArchiveResponse])\nasync def search_archives(\n    q: str = Query(..., min_length=2, description=\"Search query\"),\n    printer_id: int | None = None,\n    project_id: int | None = None,\n    status: str | None = None,\n    limit: int = 50,\n    offset: int = 0,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Full-text search across archives.\n\n    Searches print_name, filename, tags, notes, designer, and filament_type fields.\n    Supports partial matches with wildcards (e.g., 'vor*' matches 'voron').\n    \"\"\"\n    from sqlalchemy import text\n    from sqlalchemy.orm import selectinload\n\n    from backend.app.core.db_dialect import is_sqlite\n\n    search_term = q.strip()\n\n    # Build dialect-specific full-text search query\n    if is_sqlite():\n        # SQLite FTS5: wildcard suffix for partial matches\n        if not search_term.endswith(\"*\"):\n            search_term = f\"{search_term}*\"\n        fts_query = text(\"\"\"\n            SELECT rowid FROM archive_fts\n            WHERE archive_fts MATCH :search_term\n            ORDER BY rank\n            LIMIT :limit OFFSET :offset\n        \"\"\")\n    else:\n        # PostgreSQL: tsvector + plainto_tsquery with prefix matching\n        fts_query = text(\"\"\"\n            SELECT id FROM print_archives\n            WHERE to_tsvector('simple',\n                COALESCE(print_name, '') || ' ' ||\n                COALESCE(filename, '') || ' ' ||\n                COALESCE(tags, '') || ' ' ||\n                COALESCE(notes, '') || ' ' ||\n                COALESCE(designer, '') || ' ' ||\n                COALESCE(filament_type, '')\n            ) @@ to_tsquery('simple', :search_term)\n            LIMIT :limit OFFSET :offset\n        \"\"\")\n        # Convert \"benchy\" to \"benchy:*\" for prefix matching in tsquery\n        search_term = \" & \".join(f\"{word}:*\" for word in search_term.split() if word)\n\n    try:\n        result = await db.execute(fts_query, {\"search_term\": search_term, \"limit\": limit + 100, \"offset\": 0})\n        matched_ids = [row[0] for row in result.fetchall()]\n    except Exception as e:\n        logger.warning(\"FTS search failed, falling back to LIKE search: %s\", e)\n        # Fallback to LIKE search if FTS fails\n        like_pattern = f\"%{q}%\"\n        query = (\n            select(PrintArchive)\n            .options(selectinload(PrintArchive.project))\n            .where(\n                (PrintArchive.print_name.ilike(like_pattern))\n                | (PrintArchive.filename.ilike(like_pattern))\n                | (PrintArchive.tags.ilike(like_pattern))\n                | (PrintArchive.notes.ilike(like_pattern))\n                | (PrintArchive.designer.ilike(like_pattern))\n                | (PrintArchive.filament_type.ilike(like_pattern))\n            )\n            .order_by(PrintArchive.created_at.desc())\n        )\n\n        if printer_id:\n            query = query.where(PrintArchive.printer_id == printer_id)\n        if project_id:\n            query = query.where(PrintArchive.project_id == project_id)\n        if status:\n            query = query.where(PrintArchive.status == status)\n\n        query = query.limit(limit).offset(offset)\n        result = await db.execute(query)\n        archives = result.scalars().all()\n        return [archive_to_response(a) for a in archives]\n\n    if not matched_ids:\n        return []\n\n    # Fetch full archive records for matched IDs\n    query = select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(matched_ids))\n\n    # Apply additional filters\n    if printer_id:\n        query = query.where(PrintArchive.printer_id == printer_id)\n    if project_id:\n        query = query.where(PrintArchive.project_id == project_id)\n    if status:\n        query = query.where(PrintArchive.status == status)\n\n    result = await db.execute(query)\n    archives_dict = {a.id: a for a in result.scalars().all()}\n\n    # Preserve FTS ranking order and apply pagination\n    ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict]\n    paginated = ordered_archives[offset : offset + limit]\n\n    return [archive_to_response(a) for a in paginated]\n\n\n@router.post(\"/search/rebuild-index\")\nasync def rebuild_search_index(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Rebuild the full-text search index from existing archives.\n\n    Use this if search results seem incomplete or incorrect.\n    \"\"\"\n    from sqlalchemy import text\n\n    from backend.app.core.db_dialect import is_sqlite\n\n    try:\n        if is_sqlite():\n            # SQLite: rebuild FTS5 virtual table\n            await db.execute(text(\"DELETE FROM archive_fts\"))\n            await db.execute(\n                text(\"\"\"\n                INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)\n                SELECT id, print_name, filename, tags, notes, designer, filament_type\n                FROM print_archives\n            \"\"\")\n            )\n            await db.commit()\n\n            result = await db.execute(text(\"SELECT COUNT(*) FROM archive_fts\"))\n            count = result.scalar() or 0\n        else:\n            # PostgreSQL: GIN index is auto-maintained, just reindex\n            await db.execute(text(\"REINDEX INDEX idx_archives_fulltext\"))\n            await db.commit()\n\n            result = await db.execute(text(\"SELECT COUNT(*) FROM print_archives\"))\n            count = result.scalar() or 0\n\n        return {\"message\": f\"Search index rebuilt with {count} entries\"}\n    except Exception as e:\n        logger.error(\"Failed to rebuild search index: %s\", e)\n        raise HTTPException(status_code=500, detail=f\"Failed to rebuild index: {str(e)}\")\n\n\n@router.get(\"/analysis/failures\")\nasync def analyze_failures(\n    days: int | None = None,\n    date_from: date | None = Query(None),\n    date_to: date | None = Query(None),\n    printer_id: int | None = None,\n    project_id: int | None = None,\n    created_by_id: int | None = Query(None, description=\"Filter by user who created the print (-1 for no user)\"),\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Analyze failure patterns across prints.\n\n    Returns failure statistics including:\n    - Overall failure rate\n    - Failures by reason, filament type, printer\n    - Time of day distribution\n    - Recent failures\n    - Weekly trend\n    \"\"\"\n    _validate_user_filter_permission(current_user, created_by_id)\n\n    from backend.app.services.failure_analysis import FailureAnalysisService\n\n    service = FailureAnalysisService(db)\n    return await service.analyze_failures(\n        days=days,\n        date_from=date_from,\n        date_to=date_to,\n        printer_id=printer_id,\n        project_id=project_id,\n        created_by_id=created_by_id,\n    )\n\n\n@router.get(\"/compare\")\nasync def compare_archives(\n    archive_ids: str = Query(..., description=\"Comma-separated archive IDs (2-5)\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Compare multiple archives side by side.\n\n    Compares print settings, filament usage, and print times.\n    Also analyzes correlation between settings and success/failure.\n\n    Args:\n        archive_ids: Comma-separated list of 2-5 archive IDs to compare\n    \"\"\"\n    from backend.app.services.archive_comparison import ArchiveComparisonService\n\n    # Parse and validate archive IDs\n    try:\n        ids = [int(id.strip()) for id in archive_ids.split(\",\")]\n    except ValueError:\n        raise HTTPException(400, \"Invalid archive IDs format\")\n\n    if len(ids) < 2:\n        raise HTTPException(400, \"At least 2 archives required for comparison\")\n    if len(ids) > 5:\n        raise HTTPException(400, \"Maximum 5 archives can be compared at once\")\n\n    service = ArchiveComparisonService(db)\n    try:\n        return await service.compare_archives(ids)\n    except ValueError as e:\n        raise HTTPException(400, str(e))\n\n\n@router.get(\"/export\")\nasync def export_archives(\n    format: str = Query(\"csv\", description=\"Export format: csv or xlsx\"),\n    fields: str | None = Query(None, description=\"Comma-separated field names\"),\n    printer_id: int | None = None,\n    project_id: int | None = None,\n    status: str | None = None,\n    date_from: str | None = Query(None, description=\"Start date (ISO format)\"),\n    date_to: str | None = Query(None, description=\"End date (ISO format)\"),\n    search: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Export archives to CSV or Excel format.\n\n    Returns a downloadable file with archive data.\n    \"\"\"\n    from datetime import datetime\n\n    from fastapi.responses import StreamingResponse\n\n    from backend.app.services.export import ExportService\n\n    if format not in (\"csv\", \"xlsx\"):\n        raise HTTPException(400, \"Format must be 'csv' or 'xlsx'\")\n\n    # Parse fields\n    field_list = None\n    if fields:\n        field_list = [f.strip() for f in fields.split(\",\")]\n\n    # Parse dates\n    date_from_dt = None\n    date_to_dt = None\n    if date_from:\n        try:\n            date_from_dt = datetime.fromisoformat(date_from)\n        except ValueError:\n            raise HTTPException(400, \"Invalid date_from format\")\n    if date_to:\n        try:\n            date_to_dt = datetime.fromisoformat(date_to)\n        except ValueError:\n            raise HTTPException(400, \"Invalid date_to format\")\n\n    service = ExportService(db)\n    try:\n        file_bytes, filename, content_type = await service.export_archives(\n            format=format,\n            fields=field_list,\n            printer_id=printer_id,\n            project_id=project_id,\n            status=status,\n            date_from=date_from_dt,\n            date_to=date_to_dt,\n            search=search,\n        )\n    except ImportError as e:\n        raise HTTPException(500, str(e))\n\n    return StreamingResponse(\n        io.BytesIO(file_bytes),\n        media_type=content_type,\n        headers={\"Content-Disposition\": f'attachment; filename=\"{filename}\"'},\n    )\n\n\n@router.get(\"/stats/export\")\nasync def export_stats(\n    format: str = Query(\"csv\", description=\"Export format: csv or xlsx\"),\n    days: int = 30,\n    printer_id: int | None = None,\n    project_id: int | None = None,\n    created_by_id: int | None = Query(None, description=\"Filter by user who created the print (-1 for no user)\"),\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),\n):\n    \"\"\"Export statistics summary to CSV or Excel format.\"\"\"\n    _validate_user_filter_permission(current_user, created_by_id)\n\n    from fastapi.responses import StreamingResponse\n\n    from backend.app.services.export import ExportService\n\n    if format not in (\"csv\", \"xlsx\"):\n        raise HTTPException(400, \"Format must be 'csv' or 'xlsx'\")\n\n    service = ExportService(db)\n    try:\n        file_bytes, filename, content_type = await service.export_stats(\n            format=format,\n            days=days,\n            printer_id=printer_id,\n            project_id=project_id,\n            created_by_id=created_by_id,\n        )\n    except ImportError as e:\n        raise HTTPException(500, str(e))\n\n    return StreamingResponse(\n        io.BytesIO(file_bytes),\n        media_type=content_type,\n        headers={\"Content-Disposition\": f'attachment; filename=\"{filename}\"'},\n    )\n\n\n@router.get(\"/stats\", response_model=ArchiveStats)\nasync def get_archive_stats(\n    date_from: date | None = Query(None, description=\"Start date (inclusive), YYYY-MM-DD\"),\n    date_to: date | None = Query(None, description=\"End date (inclusive), YYYY-MM-DD\"),\n    created_by_id: int | None = Query(None, description=\"Filter by user who created the print (-1 for no user)\"),\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),\n):\n    \"\"\"Get statistics across all archives.\"\"\"\n    _validate_user_filter_permission(current_user, created_by_id)\n\n    # Build date filter conditions\n    base_conditions = []\n    if date_from:\n        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)\n        base_conditions.append(PrintArchive.created_at >= dt_from)\n    if date_to:\n        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)\n        base_conditions.append(PrintArchive.created_at <= dt_to)\n    _apply_user_filter(base_conditions, created_by_id)\n\n    # Total counts\n    total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))\n    total_prints = total_result.scalar() or 0\n\n    successful_result = await db.execute(\n        select(func.count(PrintArchive.id)).where(PrintArchive.status == \"completed\", *base_conditions)\n    )\n    successful_prints = successful_result.scalar() or 0\n\n    failed_result = await db.execute(\n        select(func.count(PrintArchive.id)).where(PrintArchive.status == \"failed\", *base_conditions)\n    )\n    failed_prints = failed_result.scalar() or 0\n\n    # Totals - use actual print time from timestamps (not slicer estimates)\n    # For archives with both started_at and completed_at, calculate actual duration\n    # Fall back to print_time_seconds only for archives without timestamps\n    archives_for_time = await db.execute(\n        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(\n            *base_conditions\n        )\n    )\n    total_seconds = 0\n    for started_at, completed_at, print_time_seconds in archives_for_time.all():\n        if started_at and completed_at:\n            # Use actual elapsed time\n            actual_seconds = (completed_at - started_at).total_seconds()\n            if actual_seconds > 0:\n                total_seconds += actual_seconds\n        elif print_time_seconds:\n            # Fallback to estimate only if no timestamps\n            total_seconds += print_time_seconds\n    total_time = total_seconds / 3600  # Convert to hours\n\n    # Sum filament directly - filament_used_grams already contains the total for the print job\n    filament_result = await db.execute(\n        select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)\n    )\n    total_filament = filament_result.scalar() or 0\n\n    cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))\n    total_cost = cost_result.scalar() or 0\n\n    # By filament type (split comma-separated values for multi-material prints)\n    filament_type_result = await db.execute(\n        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)\n    )\n    prints_by_filament: dict[str, int] = {}\n    for (filament_types,) in filament_type_result.all():\n        # Split by comma and count each type\n        for ftype in filament_types.split(\",\"):\n            ftype = ftype.strip()\n            if ftype:\n                prints_by_filament[ftype] = prints_by_filament.get(ftype, 0) + 1\n\n    # By printer\n    printer_result = await db.execute(\n        select(PrintArchive.printer_id, func.count(PrintArchive.id))\n        .where(*base_conditions)\n        .group_by(PrintArchive.printer_id)\n    )\n    prints_by_printer = {str(k): v for k, v in printer_result.all()}\n\n    # Time accuracy statistics\n    # Get all completed archives with both estimated and actual times\n    accuracy_result = await db.execute(\n        select(PrintArchive)\n        .where(PrintArchive.status == \"completed\", *base_conditions)\n        .where(PrintArchive.print_time_seconds.isnot(None))\n        .where(PrintArchive.started_at.isnot(None))\n        .where(PrintArchive.completed_at.isnot(None))\n    )\n    archives_with_times = list(accuracy_result.scalars().all())\n\n    average_accuracy = None\n    accuracy_by_printer: dict[str, float] = {}\n\n    if archives_with_times:\n        accuracies = []\n        printer_accuracies: dict[str, list[float]] = {}\n\n        for archive in archives_with_times:\n            acc_data = compute_time_accuracy(archive)\n            if acc_data[\"time_accuracy\"] is not None:\n                accuracies.append(acc_data[\"time_accuracy\"])\n\n                # Group by printer\n                printer_key = str(archive.printer_id) if archive.printer_id else \"unknown\"\n                if printer_key not in printer_accuracies:\n                    printer_accuracies[printer_key] = []\n                printer_accuracies[printer_key].append(acc_data[\"time_accuracy\"])\n\n        if accuracies:\n            average_accuracy = round(sum(accuracies) / len(accuracies), 1)\n\n        # Calculate per-printer averages\n        for printer_key, accs in printer_accuracies.items():\n            accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)\n\n    # Energy totals - check which mode to use\n    from backend.app.api.routes.settings import get_setting\n\n    energy_tracking_mode = await get_setting(db, \"energy_tracking_mode\") or \"total\"\n    energy_cost_per_kwh_str = await get_setting(db, \"energy_cost_per_kwh\")\n    energy_cost_per_kwh = float(energy_cost_per_kwh_str) if energy_cost_per_kwh_str else 0.15\n\n    total_energy_kwh: float = 0.0\n    total_energy_cost: float = 0.0\n    energy_data_warming_up = False\n\n    if energy_tracking_mode == \"total\" and not date_from and not date_to:\n        # All-time total consumption — read live lifetime counters.\n        total_energy_kwh = await _sum_live_plug_totals(db)\n        total_energy_cost = total_energy_kwh * energy_cost_per_kwh\n    elif energy_tracking_mode == \"total\":\n        # Total consumption mode with a date filter (#941): use hourly snapshots\n        # to compute per-plug (endpoint - baseline) deltas.\n        total_energy_kwh, energy_data_warming_up = await _sum_snapshot_deltas(\n            db,\n            dt_from=(datetime.combine(date_from, time.min, tzinfo=timezone.utc) if date_from else None),\n            dt_to=(datetime.combine(date_to, time.max, tzinfo=timezone.utc) if date_to else None),\n        )\n        total_energy_cost = total_energy_kwh * energy_cost_per_kwh\n    else:\n        # Per-print mode: sum the per-print energy column directly.\n        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))\n        total_energy_kwh = energy_kwh_result.scalar() or 0\n\n        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))\n        total_energy_cost = energy_cost_result.scalar() or 0\n\n    return ArchiveStats(\n        total_prints=total_prints,\n        successful_prints=successful_prints,\n        failed_prints=failed_prints,\n        total_print_time_hours=round(total_time, 1),\n        total_filament_grams=round(total_filament, 1),\n        total_cost=round(total_cost, 2),\n        prints_by_filament_type=prints_by_filament,\n        prints_by_printer=prints_by_printer,\n        average_time_accuracy=average_accuracy,\n        time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,\n        total_energy_kwh=round(total_energy_kwh, 3),\n        total_energy_cost=round(total_energy_cost, 3),\n        energy_data_warming_up=energy_data_warming_up,\n    )\n\n\nasync def _sum_live_plug_totals(db: AsyncSession) -> float:\n    \"\"\"Sum the live lifetime counter from every smart plug.\n\n    Used for all-time \"total consumption\" mode. Only the current value is\n    available so this can't be date-filtered — use `_sum_snapshot_deltas` for\n    that case.\n    \"\"\"\n    from backend.app.api.routes.settings import get_setting\n    from backend.app.models.smart_plug import SmartPlug\n    from backend.app.services.homeassistant import homeassistant_service\n    from backend.app.services.mqtt_relay import mqtt_relay\n    from backend.app.services.rest_smart_plug import rest_smart_plug_service\n    from backend.app.services.tasmota import tasmota_service\n\n    plugs_result = await db.execute(select(SmartPlug))\n    plugs = list(plugs_result.scalars().all())\n\n    ha_url = await get_setting(db, \"ha_url\") or \"\"\n    ha_token = await get_setting(db, \"ha_token\") or \"\"\n    homeassistant_service.configure(ha_url, ha_token)\n\n    total = 0.0\n    for plug in plugs:\n        if plug.plug_type == \"tasmota\":\n            energy = await tasmota_service.get_energy(plug)\n            if energy and energy.get(\"total\") is not None:\n                total += energy[\"total\"]\n        elif plug.plug_type == \"homeassistant\":\n            energy = await homeassistant_service.get_energy(plug)\n            if energy and energy.get(\"total\") is not None:\n                total += energy[\"total\"]\n        elif plug.plug_type == \"mqtt\":\n            # MQTT plugs only expose today's counter, not lifetime.\n            mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)\n            if mqtt_data and mqtt_data.energy is not None:\n                total += mqtt_data.energy\n        elif plug.plug_type == \"rest\":\n            energy = await rest_smart_plug_service.get_energy(plug)\n            if energy and energy.get(\"today\") is not None:\n                total += energy[\"today\"]\n    return total\n\n\nasync def _sum_snapshot_deltas(\n    db: AsyncSession,\n    *,\n    dt_from: datetime | None,\n    dt_to: datetime | None,\n) -> tuple[float, bool]:\n    \"\"\"Sum per-plug energy consumption over a date range using hourly snapshots.\n\n    For each plug:\n      * baseline  = last snapshot at or before `dt_from` (ideal)\n                    — if missing, fall back to the earliest snapshot ever\n                      recorded for the plug and flag the result as warming up.\n      * endpoint  = last snapshot at or before `dt_to` (or most recent overall)\n      * delta     = max(0, endpoint - baseline)  — clamp counter resets to 0.\n\n    Returns (total_kwh, warming_up). `warming_up = True` means at least one plug\n    had no baseline before `dt_from` (fresh install or fresh upgrade), so the\n    result undercounts the beginning of the range.\n    \"\"\"\n    from backend.app.models.smart_plug import SmartPlug\n    from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot\n\n    plug_ids_result = await db.execute(select(SmartPlug.id))\n    plug_ids = [row[0] for row in plug_ids_result.all()]\n    if not plug_ids:\n        return 0.0, False\n\n    total = 0.0\n    warming_up = False\n    for plug_id in plug_ids:\n        baseline: float | None = None\n        if dt_from is not None:\n            baseline_q = await db.execute(\n                select(SmartPlugEnergySnapshot.lifetime_kwh)\n                .where(\n                    SmartPlugEnergySnapshot.plug_id == plug_id,\n                    SmartPlugEnergySnapshot.recorded_at <= dt_from,\n                )\n                .order_by(SmartPlugEnergySnapshot.recorded_at.desc())\n                .limit(1)\n            )\n            baseline = baseline_q.scalar()\n        if baseline is None:\n            # No snapshot before range start — fall back to the earliest\n            # snapshot ever recorded. Result undercounts the pre-first-snapshot\n            # portion of the range; signal that to the frontend.\n            earliest_q = await db.execute(\n                select(SmartPlugEnergySnapshot.lifetime_kwh)\n                .where(SmartPlugEnergySnapshot.plug_id == plug_id)\n                .order_by(SmartPlugEnergySnapshot.recorded_at.asc())\n                .limit(1)\n            )\n            baseline = earliest_q.scalar()\n            if baseline is None:\n                # No snapshots at all for this plug yet.\n                warming_up = True\n                continue\n            warming_up = True\n\n        endpoint_conditions = [SmartPlugEnergySnapshot.plug_id == plug_id]\n        if dt_to is not None:\n            endpoint_conditions.append(SmartPlugEnergySnapshot.recorded_at <= dt_to)\n        endpoint_q = await db.execute(\n            select(SmartPlugEnergySnapshot.lifetime_kwh)\n            .where(*endpoint_conditions)\n            .order_by(SmartPlugEnergySnapshot.recorded_at.desc())\n            .limit(1)\n        )\n        endpoint = endpoint_q.scalar()\n        if endpoint is None:\n            continue\n\n        total += max(0.0, endpoint - baseline)\n\n    return total, warming_up\n\n\n@router.get(\"/tags\")\nasync def get_all_tags(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"List all unique tags with usage counts.\n\n    Returns a list of tags sorted by count (descending), then by name.\n    \"\"\"\n    # Query all archives with non-null tags\n    result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))\n    all_tags_rows = result.all()\n\n    # Count occurrences of each tag\n    tag_counts: dict[str, int] = {}\n    for (tags_str,) in all_tags_rows:\n        if tags_str:\n            for tag in tags_str.split(\",\"):\n                tag = tag.strip()\n                if tag:\n                    tag_counts[tag] = tag_counts.get(tag, 0) + 1\n\n    # Convert to list and sort by count (desc), then name (asc)\n    tags_list = [{\"name\": name, \"count\": count} for name, count in tag_counts.items()]\n    tags_list.sort(key=lambda x: (-x[\"count\"], x[\"name\"].lower()))\n\n    return tags_list\n\n\n@router.put(\"/tags/{tag_name}\")\nasync def rename_tag(\n    tag_name: str,\n    request: Request,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Rename a tag across all archives.\n\n    Request body should contain {\"new_name\": \"new tag name\"}.\n    Returns the count of affected archives.\n    \"\"\"\n    body = await request.json()\n    new_name = body.get(\"new_name\", \"\").strip()\n\n    if not new_name:\n        raise HTTPException(400, \"new_name is required\")\n\n    if new_name == tag_name:\n        return {\"affected\": 0}\n\n    # Find all archives containing the old tag\n    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))\n    archives = list(result.scalars().all())\n\n    affected = 0\n    for archive in archives:\n        if not archive.tags:\n            continue\n        tags = [t.strip() for t in archive.tags.split(\",\")]\n        if tag_name in tags:\n            # Replace old tag with new tag\n            new_tags = [new_name if t == tag_name else t for t in tags]\n            # Remove duplicates while preserving order\n            seen = set()\n            unique_tags = []\n            for t in new_tags:\n                if t not in seen:\n                    seen.add(t)\n                    unique_tags.append(t)\n            archive.tags = \", \".join(unique_tags)\n            affected += 1\n\n    await db.commit()\n    return {\"affected\": affected}\n\n\n@router.delete(\"/tags/{tag_name}\")\nasync def delete_tag(\n    tag_name: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Delete a tag from all archives.\n\n    Returns the count of affected archives.\n    \"\"\"\n    # Find all archives containing the tag\n    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))\n    archives = list(result.scalars().all())\n\n    affected = 0\n    for archive in archives:\n        if not archive.tags:\n            continue\n        tags = [t.strip() for t in archive.tags.split(\",\")]\n        if tag_name in tags:\n            # Remove the tag\n            new_tags = [t for t in tags if t != tag_name]\n            archive.tags = \", \".join(new_tags) if new_tags else None\n            affected += 1\n\n    await db.commit()\n    return {\"affected\": affected}\n\n\n@router.get(\"/{archive_id}\", response_model=ArchiveResponse)\nasync def get_archive(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Get a specific archive.\"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    # Find duplicates\n    makerworld_id = archive.extra_data.get(\"makerworld_model_id\") if archive.extra_data else None\n    duplicates = await service.find_duplicates(\n        archive_id=archive.id,\n        content_hash=archive.content_hash,\n        print_name=archive.print_name,\n        makerworld_model_id=makerworld_id,\n    )\n    return archive_to_response(archive, duplicates)\n\n\n@router.get(\"/{archive_id}/similar\")\nasync def find_similar_archives(\n    archive_id: int,\n    limit: int = 10,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Find archives with similar settings for comparison.\n\n    Returns archives that match by:\n    - Same print name (highest priority)\n    - Same file content hash\n    - Same filament type\n    \"\"\"\n    from backend.app.services.archive_comparison import ArchiveComparisonService\n\n    service = ArchiveComparisonService(db)\n    try:\n        return await service.find_similar_archives(archive_id, limit=limit)\n    except ValueError as e:\n        raise HTTPException(404, str(e))\n\n\n@router.patch(\"/{archive_id}\", response_model=ArchiveResponse)\nasync def update_archive(\n    archive_id: int,\n    update_data: ArchiveUpdate,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.ARCHIVES_UPDATE_ALL,\n            Permission.ARCHIVES_UPDATE_OWN,\n        )\n    ),\n):\n    \"\"\"Update archive metadata (tags, notes, cost, is_favorite, project_id).\"\"\"\n    from sqlalchemy.orm import selectinload\n\n    user, can_modify_all = auth_result\n\n    result = await db.execute(\n        select(PrintArchive)\n        .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))\n        .where(PrintArchive.id == archive_id)\n    )\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    # Ownership check\n    if not can_modify_all:\n        if archive.created_by_id != user.id:\n            raise HTTPException(403, \"You can only update your own archives\")\n\n    for field, value in update_data.model_dump(exclude_unset=True).items():\n        setattr(archive, field, value)\n\n    await db.commit()\n\n    # Re-fetch with relationships loaded after commit\n    result = await db.execute(\n        select(PrintArchive)\n        .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))\n        .where(PrintArchive.id == archive_id)\n    )\n    archive = result.scalar_one_or_none()\n\n    return archive_to_response(archive)\n\n\n@router.post(\"/{archive_id}/favorite\", response_model=ArchiveResponse)\nasync def toggle_favorite(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),\n):\n    \"\"\"Toggle favorite status for an archive.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    archive.is_favorite = not archive.is_favorite\n    await db.commit()\n    await db.refresh(archive)\n    return archive\n\n\n@router.post(\"/{archive_id}/rescan\", response_model=ArchiveResponse)\nasync def rescan_archive(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Rescan the 3MF file and update metadata.\"\"\"\n    from backend.app.api.routes.settings import get_setting\n    from backend.app.services.archive import ThreeMFParser\n\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"Archive file not found\")\n\n    # Parse the 3MF file\n    parser = ThreeMFParser(file_path)\n    metadata = parser.parse()\n\n    # Update fields from metadata\n    if metadata.get(\"filament_type\"):\n        archive.filament_type = metadata[\"filament_type\"]\n    if metadata.get(\"filament_color\"):\n        archive.filament_color = metadata[\"filament_color\"]\n    if metadata.get(\"print_time_seconds\"):\n        archive.print_time_seconds = metadata[\"print_time_seconds\"]\n    if metadata.get(\"filament_used_grams\"):\n        archive.filament_used_grams = metadata[\"filament_used_grams\"]\n    if metadata.get(\"layer_height\"):\n        archive.layer_height = metadata[\"layer_height\"]\n    if metadata.get(\"nozzle_diameter\"):\n        archive.nozzle_diameter = metadata[\"nozzle_diameter\"]\n    if metadata.get(\"bed_temperature\"):\n        archive.bed_temperature = metadata[\"bed_temperature\"]\n    if metadata.get(\"nozzle_temperature\"):\n        archive.nozzle_temperature = metadata[\"nozzle_temperature\"]\n    if metadata.get(\"makerworld_url\"):\n        archive.makerworld_url = metadata[\"makerworld_url\"]\n    if metadata.get(\"designer\"):\n        archive.designer = metadata[\"designer\"]\n\n    # Calculate cost: prefer spool-based cost if available, else catalog-based\n\n    if archive.filament_used_grams and archive.filament_type:\n        usage_result = await db.execute(\n            select(func.sum(SpoolUsageHistory.cost)).where(SpoolUsageHistory.archive_id == archive.id)\n        )\n        usage_cost = usage_result.scalar()\n        if usage_cost is not None and usage_cost > 0:\n            archive.cost = float(Decimal(str(usage_cost)).quantize(Decimal(\"0.01\"), rounding=ROUND_HALF_UP))\n        else:\n            primary_type = archive.filament_type.split(\",\")[0].strip()\n            filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))\n            filament = filament_result.scalar_one_or_none()\n            if filament:\n                archive.cost = float(\n                    Decimal(str((archive.filament_used_grams / 1000) * filament.cost_per_kg)).quantize(\n                        Decimal(\"0.01\"), rounding=ROUND_HALF_UP\n                    )\n                )\n            else:\n                # Use default filament cost from settings\n                default_cost_setting = await get_setting(db, \"default_filament_cost\")\n                default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0\n                archive.cost = float(\n                    Decimal(str((archive.filament_used_grams / 1000) * default_cost_per_kg)).quantize(\n                        Decimal(\"0.01\"), rounding=ROUND_HALF_UP\n                    )\n                )\n\n    await db.commit()\n    await db.refresh(archive)\n    return archive\n\n\n@router.post(\"/recalculate-costs\")\nasync def recalculate_all_costs(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Recalculate costs for all archives based on filament usage and prices.\"\"\"\n\n    from backend.app.api.routes.settings import get_setting\n\n    result = await db.execute(select(PrintArchive))\n    archives = list(result.scalars().all())\n\n    # Load all filaments for lookup\n    filament_result = await db.execute(select(Filament))\n    filaments = {f.type: f.cost_per_kg for f in filament_result.scalars().all()}\n\n    # Get default filament cost from settings\n    default_cost_setting = await get_setting(db, \"default_filament_cost\")\n    default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0\n\n    # Pre-fetch all usage costs by archive_id\n    usage_costs_result = await db.execute(\n        select(SpoolUsageHistory.archive_id, func.sum(SpoolUsageHistory.cost)).group_by(SpoolUsageHistory.archive_id)\n    )\n    usage_costs = usage_costs_result.fetchall()\n    cost_map = {row[0]: row[1] for row in usage_costs if row[0] is not None and row[1] is not None and row[1] > 0}\n\n    updated = 0\n    for archive in archives:\n        usage_cost = cost_map.get(archive.id)\n        if usage_cost is not None:\n            new_cost = round(usage_cost, 2)\n        else:\n            # Fallback: sum costs for old records by print_name\n            usage_result = await db.execute(\n                select(func.sum(SpoolUsageHistory.cost)).where(\n                    SpoolUsageHistory.print_name == archive.print_name,\n                    SpoolUsageHistory.archive_id.is_(None),\n                )\n            )\n            fallback_cost = usage_result.scalar()\n            if fallback_cost is not None and fallback_cost > 0:\n                new_cost = round(fallback_cost, 2)\n            elif archive.filament_used_grams and archive.filament_type:\n                primary_type = archive.filament_type.split(\",\")[0].strip()\n                cost_per_kg = filaments.get(primary_type, default_cost_per_kg)\n                new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)\n            else:\n                new_cost = None\n        if new_cost is not None and archive.cost != new_cost:\n            archive.cost = new_cost\n            updated += 1\n\n    await db.commit()\n    return {\"message\": f\"Recalculated costs for {updated} archives\", \"updated\": updated}\n\n\n@router.post(\"/rescan-all\")\nasync def rescan_all_archives(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Rescan all archives and update their metadata.\"\"\"\n    from backend.app.services.archive import ThreeMFParser\n\n    result = await db.execute(select(PrintArchive))\n    archives = list(result.scalars().all())\n\n    updated = 0\n    errors = []\n\n    for archive in archives:\n        try:\n            file_path = settings.base_dir / archive.file_path\n            if not file_path.is_file():\n                errors.append({\"id\": archive.id, \"error\": \"File not found\"})\n                continue\n\n            parser = ThreeMFParser(file_path)\n            metadata = parser.parse()\n\n            if metadata.get(\"filament_type\"):\n                archive.filament_type = metadata[\"filament_type\"]\n            if metadata.get(\"filament_color\"):\n                archive.filament_color = metadata[\"filament_color\"]\n            if metadata.get(\"print_time_seconds\"):\n                archive.print_time_seconds = metadata[\"print_time_seconds\"]\n            if metadata.get(\"filament_used_grams\"):\n                archive.filament_used_grams = metadata[\"filament_used_grams\"]\n            if metadata.get(\"layer_height\"):\n                archive.layer_height = metadata[\"layer_height\"]\n            if metadata.get(\"nozzle_diameter\"):\n                archive.nozzle_diameter = metadata[\"nozzle_diameter\"]\n            if metadata.get(\"makerworld_url\"):\n                archive.makerworld_url = metadata[\"makerworld_url\"]\n            if metadata.get(\"designer\"):\n                archive.designer = metadata[\"designer\"]\n\n            updated += 1\n        except Exception as e:\n            logger.exception(\"Failed to rescan archive %s: %s\", archive.id, e)\n            errors.append({\"id\": archive.id, \"error\": \"Failed to parse 3MF file\"})\n\n    await db.commit()\n    return {\"updated\": updated, \"errors\": errors}\n\n\n@router.get(\"/{archive_id}/duplicates\")\nasync def get_archive_duplicates(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Get duplicates for a specific archive.\"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    makerworld_id = archive.extra_data.get(\"makerworld_model_id\") if archive.extra_data else None\n    duplicates = await service.find_duplicates(\n        archive_id=archive.id,\n        content_hash=archive.content_hash,\n        print_name=archive.print_name,\n        makerworld_model_id=makerworld_id,\n    )\n    return {\"duplicates\": duplicates, \"count\": len(duplicates)}\n\n\n@router.post(\"/backfill-hashes\")\nasync def backfill_content_hashes(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Compute and store content hashes for all archives missing them.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))\n    archives = list(result.scalars().all())\n\n    updated = 0\n    errors = []\n\n    for archive in archives:\n        try:\n            file_path = settings.base_dir / archive.file_path\n            if not file_path.is_file():\n                errors.append({\"id\": archive.id, \"error\": \"File not found\"})\n                continue\n\n            archive.content_hash = ArchiveService.compute_file_hash(file_path)\n            updated += 1\n        except Exception as e:\n            logger.exception(\"Failed to compute hash for archive %s: %s\", archive.id, e)\n            errors.append({\"id\": archive.id, \"error\": \"Failed to compute hash\"})\n\n    await db.commit()\n    return {\"updated\": updated, \"errors\": errors}\n\n\n@router.delete(\"/{archive_id}\")\nasync def delete_archive(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.ARCHIVES_DELETE_ALL,\n            Permission.ARCHIVES_DELETE_OWN,\n        )\n    ),\n):\n    \"\"\"Delete an archive.\"\"\"\n    user, can_modify_all = auth_result\n\n    # Get archive first to check ownership\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    # Ownership check\n    if not can_modify_all:\n        if archive.created_by_id != user.id:\n            raise HTTPException(403, \"You can only delete your own archives\")\n\n    service = ArchiveService(db)\n    if not await service.delete_archive(archive_id):\n        raise HTTPException(404, \"Archive not found\")\n    return {\"status\": \"deleted\"}\n\n\n@router.get(\"/{archive_id}/download\")\nasync def download_archive(\n    archive_id: int,\n    inline: bool = False,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Download the 3MF file.\"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"File not found\")\n\n    # Use inline disposition to let browser/OS handle file association\n    content_disposition = \"inline\" if inline else \"attachment\"\n\n    return FileResponse(\n        path=file_path,\n        filename=archive.filename,\n        media_type=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\",\n        content_disposition_type=content_disposition,\n    )\n\n\n@router.get(\"/{archive_id}/file/{filename}\")\nasync def download_archive_with_filename(\n    archive_id: int,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Download the 3MF file with filename in URL.\"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"File not found\")\n\n    return FileResponse(\n        path=file_path,\n        filename=archive.filename,\n        media_type=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\",\n    )\n\n\n@router.post(\"/{archive_id}/slicer-token\")\nasync def create_archive_slicer_token(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Create a short-lived download token for opening files in slicer applications.\n\n    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send\n    auth headers, so they use this token in the URL path instead.\n    \"\"\"\n    from backend.app.core.auth import create_slicer_download_token\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    token = await create_slicer_download_token(\"archive\", archive_id)\n    return {\"token\": token}\n\n\n@router.get(\"/{archive_id}/dl/{token}/{filename}\")\nasync def download_archive_for_slicer(\n    archive_id: int,\n    token: str,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Download 3MF file using a slicer download token.\n\n    Token-authenticated (no auth headers needed). The token is short-lived\n    and single-use, created by POST /{archive_id}/slicer-token.\n    Filename is at the end of the URL so slicers can detect the file format.\n    \"\"\"\n    from backend.app.core.auth import verify_slicer_download_token\n\n    if not await verify_slicer_download_token(token, \"archive\", archive_id):\n        raise HTTPException(403, \"Invalid or expired download token\")\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"File not found\")\n\n    return FileResponse(\n        path=file_path,\n        filename=archive.filename,\n        media_type=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\",\n    )\n\n\n@router.get(\"/{archive_id}/thumbnail\")\nasync def get_thumbnail(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get the thumbnail image.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive or not archive.thumbnail_path:\n        raise HTTPException(404, \"Thumbnail not found\")\n\n    thumb_path = settings.base_dir / archive.thumbnail_path\n    if not thumb_path.exists():\n        raise HTTPException(404, \"Thumbnail file not found\")\n\n    # Use file modification time as ETag to bust cache\n    mtime = int(thumb_path.stat().st_mtime)\n\n    return FileResponse(\n        path=thumb_path,\n        media_type=\"image/png\",\n        headers={\n            \"Cache-Control\": \"no-cache, must-revalidate\",\n            \"ETag\": f'\"{mtime}\"',\n        },\n    )\n\n\n@router.get(\"/{archive_id}/timelapse\")\nasync def get_timelapse(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get the timelapse video.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive or not archive.timelapse_path:\n        raise HTTPException(404, \"Timelapse not found\")\n\n    timelapse_path = settings.base_dir / archive.timelapse_path\n    if not timelapse_path.exists():\n        raise HTTPException(404, \"Timelapse file not found\")\n\n    # Use file modification time as ETag to bust cache after processing\n    mtime = int(timelapse_path.stat().st_mtime)\n\n    # Detect media type from file extension (AVI from P1S before background conversion)\n    suffix = timelapse_path.suffix.lower()\n    media_type = {\".mp4\": \"video/mp4\", \".avi\": \"video/x-msvideo\", \".mkv\": \"video/x-matroska\"}.get(suffix, \"video/mp4\")\n    ext = suffix if suffix in (\".mp4\", \".avi\", \".mkv\") else \".mp4\"\n\n    return FileResponse(\n        path=timelapse_path,\n        media_type=media_type,\n        filename=f\"{archive.print_name or 'timelapse'}{ext}\",\n        headers={\n            \"Cache-Control\": \"no-cache, must-revalidate\",\n            \"ETag\": f'\"{mtime}\"',\n        },\n    )\n\n\n@router.delete(\"/{archive_id}/timelapse\")\nasync def delete_timelapse(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),\n):\n    \"\"\"Remove the timelapse video from an archive.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.timelapse_path:\n        raise HTTPException(404, \"No timelapse attached to this archive\")\n\n    # Delete the file\n    timelapse_path = settings.base_dir / archive.timelapse_path\n    if timelapse_path.exists():\n        timelapse_path.unlink()\n\n    # Clear the path in database\n    archive.timelapse_path = None\n    await db.commit()\n\n    return {\"status\": \"deleted\"}\n\n\n@router.post(\"/{archive_id}/timelapse/scan\")\nasync def scan_timelapse(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Scan printer for timelapse matching this archive and attach it.\"\"\"\n    from backend.app.models.printer import Printer\n    from backend.app.services.bambu_ftp import (\n        download_file_bytes_async,\n        get_ftp_retry_settings,\n        list_files_async,\n        with_ftp_retry,\n    )\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if archive.timelapse_path:\n        return {\"status\": \"exists\", \"message\": \"Timelapse already attached\"}\n\n    if not archive.printer_id:\n        raise HTTPException(400, \"Archive has no associated printer\")\n\n    # Get printer\n    result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Get base name from archive filename (without .3mf extension)\n    base_name = Path(archive.filename).stem\n\n    # Scan timelapse directory on printer\n    # Different printer models use different paths\n    files = []\n    for timelapse_path in [\"/timelapse\", \"/timelapse/video\", \"/record\", \"/recording\"]:\n        try:\n            files = await list_files_async(\n                printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model\n            )\n            if files:\n                break\n        except Exception:\n            continue\n    if not files:\n        raise HTTPException(500, \"Failed to connect to printer or no timelapse directory found\")\n\n    # Look for matching timelapse\n    matching_file = None\n    video_files = [\n        f for f in files if not f.get(\"is_directory\") and f.get(\"name\", \"\").lower().endswith((\".mp4\", \".avi\"))\n    ]\n\n    # Strategy 1: Match by print name in filename\n    for f in video_files:\n        fname = f.get(\"name\", \"\")\n        if base_name.lower() in fname.lower():\n            matching_file = f\n            break\n\n    # Strategy 2: Match by timestamp proximity\n    # Bambu timelapse filename uses the print START time (when recording began)\n    if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):\n        import re\n        from datetime import datetime, timedelta\n\n        # Prefer started_at since video filename is the print start time\n        # Fall back to completed_at or created_at if started_at is not available\n        archive_start = archive.started_at\n        archive_end = archive.completed_at or archive.created_at\n        best_match = None\n        best_diff = timedelta(hours=24)  # Max 24 hour difference\n\n        for f in video_files:\n            fname = f.get(\"name\", \"\")\n            # Parse timestamp from filename like \"video_2025-11-24_03-17-40.mp4\"\n            match = re.search(r\"(\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2})\", fname)\n            if match:\n                try:\n                    file_time = datetime.strptime(match.group(1), \"%Y-%m-%d_%H-%M-%S\")\n\n                    # Try multiple timezone offsets since printer timezone can vary\n                    # Common cases: local time (0), CST/UTC+8 (+8), or UTC (-local offset)\n                    for hour_offset in [0, 8, -8, 7, -7, 1, -1]:\n                        adjusted_file_time = file_time - timedelta(hours=hour_offset)\n\n                        # Check against start time (video filename = print start)\n                        if archive_start:\n                            diff = abs(adjusted_file_time - archive_start)\n                            if diff < best_diff:\n                                best_diff = diff\n                                best_match = f\n                                logger.debug(\n                                    f\"Timelapse match candidate: {fname} with offset {hour_offset}h, \"\n                                    f\"diff from start: {diff}\"\n                                )\n\n                        # Also check against end time with a buffer\n                        # (video timestamp should be BEFORE completion time)\n                        if archive_end:\n                            # The video timestamp should be within the print duration before completion\n                            if adjusted_file_time < archive_end:\n                                diff = archive_end - adjusted_file_time\n                                # Reasonable print duration: up to 48 hours\n                                if diff < timedelta(hours=48) and diff < best_diff:\n                                    best_diff = diff\n                                    best_match = f\n                                    logger.debug(\n                                        f\"Timelapse match candidate (from end): {fname} with offset {hour_offset}h, \"\n                                        f\"diff: {diff}\"\n                                    )\n\n                except ValueError:\n                    continue\n\n        # Accept match within 4 hours (more lenient for timezone issues)\n        if best_match and best_diff < timedelta(hours=4):\n            matching_file = best_match\n            logger.info(\"Matched timelapse by timestamp: %s (diff: %s)\", best_match.get(\"name\"), best_diff)\n\n    # Strategy 3: Use file modification time from FTP listing\n    # This handles cases where printer's filename timestamp is wrong but file mtime is correct\n    if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):\n        from datetime import datetime, timedelta\n\n        _archive_start = archive.started_at\n        archive_end = archive.completed_at or archive.created_at\n        best_match = None\n        best_diff = timedelta(hours=24)\n\n        for f in video_files:\n            mtime = f.get(\"mtime\")\n            if mtime:\n                # Timelapse file should be modified during or shortly after the print\n                # The mtime should be close to completion time (video finishes when print ends)\n                if archive_end:\n                    diff = abs(mtime - archive_end)\n                    if diff < best_diff:\n                        best_diff = diff\n                        best_match = f\n                        logger.debug(\n                            f\"Timelapse mtime match candidate: {f.get('name')}, mtime: {mtime}, diff from end: {diff}\"\n                        )\n\n        if best_match and best_diff < timedelta(hours=2):\n            matching_file = best_match\n            logger.info(\"Matched timelapse by file mtime: %s (diff: %s)\", best_match.get(\"name\"), best_diff)\n\n    # Strategy 4: If only one timelapse exists and archive was recently completed, use it\n    # This handles cases where printer clock is wrong or timezone issues exist\n    if not matching_file and len(video_files) == 1:\n        from datetime import datetime, timedelta, timezone\n\n        archive_completed = archive.completed_at or archive.created_at\n        if archive_completed:\n            if archive_completed.tzinfo is None:\n                archive_completed = archive_completed.replace(tzinfo=timezone.utc)\n            time_since_completion = datetime.now(timezone.utc) - archive_completed\n            # If archive was completed within the last hour, assume the single timelapse is for it\n            if time_since_completion < timedelta(hours=1):\n                matching_file = video_files[0]\n                logger.info(\"Using single timelapse file as fallback: %s\", video_files[0].get(\"name\"))\n\n    # Note: We intentionally don't use a \"most recent file\" fallback because\n    # we can't verify if timelapse was actually enabled for this print.\n    # Instead, return the list of available files for manual selection.\n\n    if not matching_file:\n        # Return available files for manual selection\n        available_files = [\n            {\n                \"name\": f.get(\"name\"),\n                \"path\": f.get(\"path\"),\n                \"size\": f.get(\"size\"),\n                \"mtime\": f.get(\"mtime\").isoformat() if f.get(\"mtime\") else None,\n            }\n            for f in video_files\n        ]\n        # Sort by mtime descending (most recent first)\n        available_files.sort(key=lambda x: x.get(\"mtime\") or \"\", reverse=True)\n        return {\n            \"status\": \"not_found\",\n            \"message\": \"No matching timelapse found - please select manually\",\n            \"available_files\": available_files,\n        }\n\n    # Download the timelapse - use the full path from the file listing\n    remote_path = matching_file.get(\"path\") or f\"/timelapse/{matching_file['name']}\"\n\n    # Get FTP retry settings\n    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()\n\n    if ftp_retry_enabled:\n        timelapse_data = await with_ftp_retry(\n            download_file_bytes_async,\n            printer.ip_address,\n            printer.access_code,\n            remote_path,\n            socket_timeout=ftp_timeout,\n            printer_model=printer.model,\n            max_retries=ftp_retry_count,\n            retry_delay=ftp_retry_delay,\n            operation_name=f\"Download timelapse {matching_file['name']}\",\n        )\n    else:\n        timelapse_data = await download_file_bytes_async(\n            printer.ip_address,\n            printer.access_code,\n            remote_path,\n            socket_timeout=ftp_timeout,\n            printer_model=printer.model,\n        )\n\n    if not timelapse_data:\n        raise HTTPException(500, \"Failed to download timelapse\")\n\n    # Attach timelapse to archive\n    success = await service.attach_timelapse(archive_id, timelapse_data, matching_file[\"name\"])\n\n    if not success:\n        raise HTTPException(500, \"Failed to attach timelapse\")\n\n    return {\n        \"status\": \"attached\",\n        \"message\": f\"Timelapse '{matching_file['name']}' attached successfully\",\n        \"filename\": matching_file[\"name\"],\n    }\n\n\n@router.post(\"/{archive_id}/timelapse/select\")\nasync def select_timelapse(\n    archive_id: int,\n    filename: str = Query(..., description=\"Timelapse filename to attach\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Manually select a timelapse from the printer to attach.\"\"\"\n    from backend.app.models.printer import Printer\n    from backend.app.services.bambu_ftp import (\n        download_file_bytes_async,\n        get_ftp_retry_settings,\n        list_files_async,\n        with_ftp_retry,\n    )\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.printer_id:\n        raise HTTPException(400, \"Archive has no associated printer\")\n\n    result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Find the file on the printer\n    files = []\n    remote_path = None\n    for timelapse_dir in [\"/timelapse\", \"/timelapse/video\", \"/record\", \"/recording\"]:\n        try:\n            files = await list_files_async(\n                printer.ip_address, printer.access_code, timelapse_dir, printer_model=printer.model\n            )\n            for f in files:\n                if f.get(\"name\") == filename:\n                    remote_path = f.get(\"path\") or f\"{timelapse_dir}/{filename}\"\n                    break\n            if remote_path:\n                break\n        except Exception:\n            continue\n\n    if not remote_path:\n        raise HTTPException(404, f\"Timelapse '{filename}' not found on printer\")\n\n    # Download and attach\n    ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()\n\n    if ftp_retry_enabled:\n        timelapse_data = await with_ftp_retry(\n            download_file_bytes_async,\n            printer.ip_address,\n            printer.access_code,\n            remote_path,\n            socket_timeout=ftp_timeout,\n            printer_model=printer.model,\n            max_retries=ftp_retry_count,\n            retry_delay=ftp_retry_delay,\n            operation_name=f\"Download timelapse {filename}\",\n        )\n    else:\n        timelapse_data = await download_file_bytes_async(\n            printer.ip_address,\n            printer.access_code,\n            remote_path,\n            socket_timeout=ftp_timeout,\n            printer_model=printer.model,\n        )\n\n    if not timelapse_data:\n        raise HTTPException(500, \"Failed to download timelapse\")\n\n    success = await service.attach_timelapse(archive_id, timelapse_data, filename)\n    if not success:\n        raise HTTPException(500, \"Failed to attach timelapse\")\n\n    return {\n        \"status\": \"attached\",\n        \"message\": f\"Timelapse '{filename}' attached successfully\",\n        \"filename\": filename,\n    }\n\n\n@router.post(\"/{archive_id}/timelapse/upload\")\nasync def upload_timelapse(\n    archive_id: int,\n    file: UploadFile = File(...),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Manually upload a timelapse video to an archive.\"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not file.filename or not file.filename.endswith((\".mp4\", \".avi\", \".mkv\")):\n        raise HTTPException(400, \"File must be a video file (.mp4, .avi, .mkv)\")\n\n    content = await file.read()\n    safe_filename = _safe_filename(file.filename)\n    success = await service.attach_timelapse(archive_id, content, safe_filename)\n\n    if not success:\n        raise HTTPException(500, \"Failed to attach timelapse\")\n\n    return {\"status\": \"attached\", \"filename\": safe_filename}\n\n\n@router.get(\"/{archive_id}/timelapse/info\")\nasync def get_timelapse_info(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Get timelapse video metadata for editor.\"\"\"\n    from backend.app.schemas.timelapse import TimelapseInfoResponse\n    from backend.app.services.timelapse_processor import TimelapseProcessor\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive or not archive.timelapse_path:\n        raise HTTPException(404, \"Timelapse not found\")\n\n    timelapse_path = settings.base_dir / archive.timelapse_path\n    if not timelapse_path.exists():\n        raise HTTPException(404, \"Timelapse file not found\")\n\n    try:\n        processor = TimelapseProcessor(timelapse_path)\n        info = await processor.get_info()\n        return TimelapseInfoResponse(**info)\n    except Exception as e:\n        logger.error(\"Failed to get timelapse info: %s\", e)\n        raise HTTPException(500, f\"Failed to get video info: {str(e)}\")\n\n\n@router.get(\"/{archive_id}/timelapse/thumbnails\")\nasync def get_timelapse_thumbnails(\n    archive_id: int,\n    count: int = Query(10, ge=1, le=30),\n    width: int = Query(160, ge=80, le=320),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Generate timeline thumbnail frames for visual scrubbing.\"\"\"\n    import base64\n\n    from backend.app.schemas.timelapse import ThumbnailResponse\n    from backend.app.services.timelapse_processor import TimelapseProcessor\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive or not archive.timelapse_path:\n        raise HTTPException(404, \"Timelapse not found\")\n\n    timelapse_path = settings.base_dir / archive.timelapse_path\n    if not timelapse_path.exists():\n        raise HTTPException(404, \"Timelapse file not found\")\n\n    try:\n        processor = TimelapseProcessor(timelapse_path)\n        thumbnails = await processor.generate_thumbnails(count, width)\n\n        return ThumbnailResponse(\n            thumbnails=[base64.b64encode(data).decode() for _, data in thumbnails],\n            timestamps=[ts for ts, _ in thumbnails],\n        )\n    except Exception as e:\n        logger.error(\"Failed to generate thumbnails: %s\", e)\n        raise HTTPException(500, f\"Failed to generate thumbnails: {str(e)}\")\n\n\n@router.post(\"/{archive_id}/timelapse/process\")\nasync def process_timelapse(\n    archive_id: int,\n    trim_start: float = Form(0),\n    trim_end: float = Form(None),\n    speed: float = Form(1.0),\n    save_mode: str = Form(\"new\"),\n    output_filename: str = Form(None),\n    audio: UploadFile = File(None),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Process timelapse with trim, speed, and optional audio overlay.\"\"\"\n    import shutil\n    import tempfile\n\n    from backend.app.schemas.timelapse import ProcessResponse\n    from backend.app.services.timelapse_processor import TimelapseProcessor\n\n    # Validate speed\n    if not 0.25 <= speed <= 4.0:\n        raise HTTPException(400, \"Speed must be between 0.25 and 4.0\")\n\n    if save_mode not in (\"replace\", \"new\"):\n        raise HTTPException(400, \"save_mode must be 'replace' or 'new'\")\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive or not archive.timelapse_path:\n        raise HTTPException(404, \"Timelapse not found\")\n\n    timelapse_path = settings.base_dir / archive.timelapse_path\n    if not timelapse_path.exists():\n        raise HTTPException(404, \"Timelapse file not found\")\n\n    archive_dir = timelapse_path.parent\n\n    # Handle audio file\n    audio_temp_path = None\n    if audio and audio.filename:\n        # Validate audio file extension\n        if not audio.filename.lower().endswith((\".mp3\", \".wav\", \".m4a\", \".aac\", \".ogg\")):\n            raise HTTPException(400, \"Audio must be .mp3, .wav, .m4a, .aac, or .ogg\")\n\n        audio_content = await audio.read()\n        # Extract and validate suffix to prevent path injection\n        suffix = Path(audio.filename).suffix.lower()\n        if suffix not in (\".mp3\", \".wav\", \".m4a\", \".aac\", \".ogg\"):\n            raise HTTPException(400, \"Invalid audio file extension\")\n        audio_temp_path = Path(tempfile.gettempdir()) / f\"audio_{archive_id}{suffix}\"\n        audio_temp_path.write_bytes(audio_content)\n\n    try:\n        processor = TimelapseProcessor(timelapse_path)\n\n        # Determine output path\n        if save_mode == \"replace\":\n            # Process to temp file first, then replace\n            temp_output = Path(tempfile.gettempdir()) / f\"processed_{archive_id}.mp4\"\n            output_path = temp_output\n        else:\n            # Save as new file alongside original\n            filename = output_filename or f\"{archive.print_name or 'timelapse'}_edited.mp4\"\n            # Sanitize filename - remove path separators and traversal sequences\n            filename = \"\".join(c for c in filename if c.isalnum() or c in \"._- \")\n            # Prevent path traversal\n            if \"..\" in filename or not filename or filename.startswith(\".\"):\n                filename = f\"timelapse_{archive_id}_edited\"\n            if not filename.endswith(\".mp4\"):\n                filename += \".mp4\"\n            output_path = archive_dir / filename\n\n        success = await processor.process(\n            output_path=output_path,\n            trim_start=trim_start,\n            trim_end=trim_end,\n            speed=speed,\n            audio_path=audio_temp_path,\n        )\n\n        if not success:\n            raise HTTPException(500, \"Video processing failed\")\n\n        # Handle save mode\n        if save_mode == \"replace\":\n            # Replace original file\n            shutil.move(str(output_path), str(timelapse_path))\n            final_path = archive.timelapse_path\n            message = \"Timelapse replaced successfully\"\n        else:\n            final_path = str(output_path.relative_to(settings.base_dir))\n            message = f\"Saved as {output_path.name}\"\n\n        return ProcessResponse(\n            status=\"completed\",\n            output_path=final_path,\n            message=message,\n        )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(\"Timelapse processing failed: %s\", e)\n        raise HTTPException(500, f\"Processing failed: {str(e)}\")\n    finally:\n        # Cleanup temp audio file\n        if audio_temp_path and audio_temp_path.exists():\n            audio_temp_path.unlink()\n\n\n# ============================================\n# Photo Endpoints\n# ============================================\n\n\n@router.post(\"/{archive_id}/photos\")\nasync def upload_photo(\n    archive_id: int,\n    file: UploadFile = File(...),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),\n):\n    \"\"\"Upload a photo of the printed result.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not file.filename or not file.filename.lower().endswith((\".jpg\", \".jpeg\", \".png\", \".webp\")):\n        raise HTTPException(400, \"File must be an image (.jpg, .jpeg, .png, .webp)\")\n\n    # Get archive directory\n    archive_dir = settings.base_dir / Path(archive.file_path).parent\n    photos_dir = archive_dir / \"photos\"\n    photos_dir.mkdir(exist_ok=True)\n\n    # Generate unique filename\n    import uuid\n\n    ext = Path(file.filename).suffix.lower()\n    photo_filename = f\"{uuid.uuid4().hex[:8]}{ext}\"\n    photo_path = photos_dir / photo_filename\n\n    # Save file\n    content = await file.read()\n    photo_path.write_bytes(content)\n\n    # Update archive photos list (create new list to trigger SQLAlchemy change detection)\n    photos = list(archive.photos or [])\n    photos.append(photo_filename)\n    archive.photos = photos\n\n    await db.commit()\n    await db.refresh(archive)\n\n    return {\"status\": \"uploaded\", \"filename\": photo_filename, \"photos\": archive.photos}\n\n\n@router.get(\"/{archive_id}/photos/{filename}\")\nasync def get_photo(\n    archive_id: int,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get a specific photo.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    archive_dir = settings.base_dir / Path(archive.file_path).parent\n    photo_path = archive_dir / \"photos\" / filename\n\n    if not photo_path.exists():\n        raise HTTPException(404, \"Photo not found\")\n\n    # Determine media type\n    ext = Path(filename).suffix.lower()\n    media_types = {\n        \".jpg\": \"image/jpeg\",\n        \".jpeg\": \"image/jpeg\",\n        \".png\": \"image/png\",\n        \".webp\": \"image/webp\",\n    }\n    media_type = media_types.get(ext, \"image/jpeg\")\n\n    return FileResponse(path=photo_path, media_type=media_type)\n\n\n@router.delete(\"/{archive_id}/photos/{filename}\")\nasync def delete_photo(\n    archive_id: int,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),\n):\n    \"\"\"Delete a photo.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.photos or filename not in archive.photos:\n        raise HTTPException(404, \"Photo not found\")\n\n    # Delete file\n    archive_dir = settings.base_dir / Path(archive.file_path).parent\n    photo_path = archive_dir / \"photos\" / filename\n    if photo_path.exists():\n        photo_path.unlink()\n\n    # Update archive photos list\n    photos = [p for p in archive.photos if p != filename]\n    archive.photos = photos if photos else None\n\n    await db.commit()\n\n    return {\"status\": \"deleted\", \"photos\": archive.photos}\n\n\n# ============================================\n# QR Code Endpoint\n# ============================================\n\n\n@router.get(\"/{archive_id}/qrcode\")\nasync def get_qrcode(\n    archive_id: int,\n    request: Request,\n    size: int = 200,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Generate a QR code that links to this archive.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    try:\n        import qrcode\n        from PIL import Image as PILImage\n    except ImportError:\n        raise HTTPException(500, \"QR code generation not available - qrcode package not installed\")\n\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    # Build URL to archive download\n    base_url = str(request.base_url).rstrip(\"/\")\n    archive_url = f\"{base_url}/api/v1/archives/{archive_id}/download\"\n\n    # Generate QR code\n    qr = qrcode.QRCode(\n        version=1,\n        error_correction=qrcode.constants.ERROR_CORRECT_M,\n        box_size=10,\n        border=2,\n    )\n    qr.add_data(archive_url)\n    qr.make(fit=True)\n\n    img = qr.make_image(fill_color=\"black\", back_color=\"white\")\n\n    # Convert to PIL Image for resizing\n    pil_img = img.get_image()\n\n    # Resize if needed\n    if size != 200:\n        pil_img = pil_img.resize((size, size), PILImage.Resampling.LANCZOS)\n\n    # Convert to bytes\n    buffer = io.BytesIO()\n    pil_img.save(buffer, format=\"PNG\")\n    buffer.seek(0)\n\n    return Response(\n        content=buffer.getvalue(),\n        media_type=\"image/png\",\n        headers={\"Content-Disposition\": f'inline; filename=\"qr_{archive.print_name or archive_id}.png\"'},\n    )\n\n\n@router.get(\"/{archive_id}/capabilities\")\nasync def get_archive_capabilities(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Check what viewing capabilities are available for this 3MF file.\"\"\"\n    import defusedxml.ElementTree as ET\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"File not found\")\n\n    has_model = False\n    has_gcode = False\n    has_source = False\n    build_volume = {\"x\": 256, \"y\": 256, \"z\": 256}  # Default to X1/P1 size\n    filament_colors: list[str] = []\n\n    # Check if source 3MF exists - this is where actual mesh data typically lives\n    source_path = None\n    if archive.source_3mf_path:\n        source_path = settings.base_dir / archive.source_3mf_path\n        if source_path.exists():\n            has_source = True\n\n    # Helper function to check for mesh data and extract colors from a 3MF file\n    def extract_3mf_info(zf_path: Path) -> tuple[bool, list[str], dict]:\n        \"\"\"Extract mesh presence, colors, and build volume from a 3MF file.\"\"\"\n        found_mesh = False\n        colors: list[str] = []\n        volume = {\"x\": 256, \"y\": 256, \"z\": 256}\n\n        try:\n            with zipfile.ZipFile(zf_path, \"r\") as zf:\n                names = zf.namelist()\n\n                # Check for 3D model - look for actual mesh data\n                for name in names:\n                    if name.endswith(\".model\"):\n                        try:\n                            content = zf.read(name).decode(\"utf-8\")\n                            if \"<vertex\" in content or \"<mesh\" in content:\n                                found_mesh = True\n                                break\n                        except Exception:\n                            pass  # Skip unreadable .model entries in archive\n\n                # Extract filament colors from project_settings.config\n                if \"Metadata/project_settings.config\" in names:\n                    try:\n                        config_content = zf.read(\"Metadata/project_settings.config\").decode(\"utf-8\")\n                        config_data = json.loads(config_content)\n\n                        # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']\n                        printable_area = config_data.get(\"printable_area\", [])\n                        if printable_area and len(printable_area) >= 3:\n                            max_x = 0\n                            max_y = 0\n                            for coord in printable_area:\n                                if \"x\" in coord:\n                                    parts = coord.split(\"x\")\n                                    if len(parts) == 2:\n                                        try:\n                                            x, y = int(parts[0]), int(parts[1])\n                                            max_x = max(max_x, x)\n                                            max_y = max(max_y, y)\n                                        except ValueError:\n                                            pass  # Skip non-numeric printable_area coordinate\n                            if max_x > 0 and max_y > 0:\n                                volume[\"x\"] = max_x\n                                volume[\"y\"] = max_y\n\n                        # Parse printable_height\n                        printable_height = config_data.get(\"printable_height\")\n                        if printable_height:\n                            try:\n                                volume[\"z\"] = int(printable_height)\n                            except (ValueError, TypeError):\n                                pass  # Skip unparseable printable_height value\n\n                        # Extract filament colors\n                        raw_colors = config_data.get(\"filament_colour\", [])\n                        if raw_colors:\n                            for color in raw_colors:\n                                if color and isinstance(color, str):\n                                    colors.append(color)\n                    except Exception:\n                        pass  # Skip malformed project_settings.config\n        except zipfile.BadZipFile:\n            pass  # File is not a valid zip/3MF archive\n\n        return found_mesh, colors, volume\n\n    # First check source 3MF for mesh data and colors (preferred for 3D model viewing)\n    if has_source and source_path:\n        source_has_mesh, source_colors, source_volume = extract_3mf_info(source_path)\n        if source_has_mesh:\n            has_model = True\n        if source_colors:\n            filament_colors = source_colors\n        if source_volume[\"x\"] != 256 or source_volume[\"y\"] != 256 or source_volume[\"z\"] != 256:\n            build_volume = source_volume\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            names = zf.namelist()\n\n            # Check for G-code in the sliced file\n            has_gcode = any(n.startswith(\"Metadata/\") and n.endswith(\".gcode\") for n in names)\n\n            # Check for 3D model in sliced file (fallback if no source)\n            if not has_model:\n                for name in names:\n                    if name.endswith(\".model\"):\n                        try:\n                            content = zf.read(name).decode(\"utf-8\")\n                            if \"<vertex\" in content or \"<mesh\" in content:\n                                has_model = True\n                                break\n                        except Exception:\n                            pass  # Skip unreadable .model entries in archive\n\n            # Extract filament colors from slice_info.config (for gcode preview)\n            # These are the actual filaments used in the print, indexed by tool/extruder\n            slice_colors: list[str] = []\n            if \"Metadata/slice_info.config\" in names:\n                try:\n                    slice_content = zf.read(\"Metadata/slice_info.config\").decode(\"utf-8\")\n                    root = ET.fromstring(slice_content)\n\n                    filaments = root.findall(\".//filament\")\n                    filament_map: dict[int, str] = {}\n                    for f in filaments:\n                        fid = f.get(\"id\")\n                        fcolor = f.get(\"color\")\n                        used_g = f.get(\"used_g\", \"0\")\n                        try:\n                            used_amount = float(used_g)\n                        except (ValueError, TypeError):\n                            used_amount = 0\n\n                        if fid is not None and fcolor:\n                            try:\n                                tool_id = int(fid) - 1\n                                if tool_id >= 0 and used_amount > 0:\n                                    filament_map[tool_id] = fcolor\n                            except ValueError:\n                                pass  # Skip filament entry with non-numeric ID\n\n                    if filament_map:\n                        max_tool = max(filament_map.keys())\n                        for i in range(max_tool + 1):\n                            slice_colors.append(filament_map.get(i, \"#00AE42\"))\n                except Exception:\n                    pass  # Skip malformed slice_info.config XML\n\n            # Use slice_info colors if we don't have colors from source yet\n            if not filament_colors and slice_colors:\n                filament_colors = slice_colors\n\n            # Extract build volume from sliced file if not already set from source\n            if build_volume[\"x\"] == 256 and build_volume[\"y\"] == 256:\n                if \"Metadata/project_settings.config\" in names:\n                    try:\n                        config_content = zf.read(\"Metadata/project_settings.config\").decode(\"utf-8\")\n                        config_data = json.loads(config_content)\n\n                        printable_area = config_data.get(\"printable_area\", [])\n                        if printable_area and len(printable_area) >= 3:\n                            max_x = 0\n                            max_y = 0\n                            for coord in printable_area:\n                                if \"x\" in coord:\n                                    parts = coord.split(\"x\")\n                                    if len(parts) == 2:\n                                        try:\n                                            x, y = int(parts[0]), int(parts[1])\n                                            max_x = max(max_x, x)\n                                            max_y = max(max_y, y)\n                                        except ValueError:\n                                            pass  # Skip non-numeric printable_area coordinate\n                            if max_x > 0 and max_y > 0:\n                                build_volume[\"x\"] = max_x\n                                build_volume[\"y\"] = max_y\n\n                        printable_height = config_data.get(\"printable_height\")\n                        if printable_height:\n                            try:\n                                build_volume[\"z\"] = int(printable_height)\n                            except (ValueError, TypeError):\n                                pass  # Skip unparseable printable_height value\n\n                        # Fallback colors from project_settings if still empty\n                        if not filament_colors:\n                            raw_colors = config_data.get(\"filament_colour\", [])\n                            if raw_colors:\n                                for color in raw_colors:\n                                    if color and isinstance(color, str):\n                                        filament_colors.append(color)\n                    except Exception:\n                        pass  # Skip malformed project_settings.config\n\n    except zipfile.BadZipFile:\n        raise HTTPException(400, \"Invalid 3MF file\")\n\n    return {\n        \"has_model\": has_model,\n        \"has_gcode\": has_gcode,\n        \"has_source\": has_source,\n        \"build_volume\": build_volume,\n        \"filament_colors\": filament_colors,\n    }\n\n\n@router.get(\"/{archive_id}/gcode\")\nasync def get_gcode(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Extract and return G-code from the 3MF file.\"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"File not found\")\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            # Bambu 3MF files store G-code in Metadata/plate_X.gcode\n            gcode_files = [n for n in zf.namelist() if n.startswith(\"Metadata/\") and n.endswith(\".gcode\")]\n            if not gcode_files:\n                raise HTTPException(\n                    404,\n                    \"No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio.\",\n                )\n\n            # Get the first plate's G-code (usually plate_1.gcode)\n            gcode_content = zf.read(gcode_files[0]).decode(\"utf-8\")\n            return Response(content=gcode_content, media_type=\"text/plain\")\n    except zipfile.BadZipFile:\n        raise HTTPException(400, \"Invalid 3MF file\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(500, f\"Error extracting G-code: {str(e)}\")\n\n\n@router.get(\"/{archive_id}/plate-preview\")\nasync def get_plate_preview(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get the plate preview image from the 3MF file.\n\n    Returns the slicer-generated plate thumbnail which shows the model\n    with correct colors and positioning.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"File not found\")\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            names = zf.namelist()\n\n            # Try to find plate preview images in order of preference\n            # First look for the specific plate being printed (check slice_info for plate index)\n            plate_num = 1\n            if \"Metadata/slice_info.config\" in names:\n                try:\n                    import defusedxml.ElementTree as ET\n\n                    slice_content = zf.read(\"Metadata/slice_info.config\").decode(\"utf-8\")\n                    root = ET.fromstring(slice_content)\n                    plate_elem = root.find(\".//plate/metadata[@key='index']\")\n                    if plate_elem is not None:\n                        plate_num = int(plate_elem.get(\"value\", \"1\"))\n                except Exception:\n                    pass  # Default plate_num=1 if slice_info is missing or malformed\n\n            # Try plate-specific image first, then fall back to plate_1\n            preview_paths = [\n                f\"Metadata/plate_{plate_num}.png\",\n                \"Metadata/plate_1.png\",\n                \"Metadata/thumbnail.png\",\n            ]\n\n            for preview_path in preview_paths:\n                if preview_path in names:\n                    image_data = zf.read(preview_path)\n                    return Response(content=image_data, media_type=\"image/png\")\n\n            # If no plate image, try any PNG in Metadata\n            for name in names:\n                if name.startswith(\"Metadata/plate_\") and name.endswith(\".png\") and \"_small\" not in name:\n                    image_data = zf.read(name)\n                    return Response(content=image_data, media_type=\"image/png\")\n\n            raise HTTPException(404, \"No plate preview found in 3MF file\")\n\n    except zipfile.BadZipFile:\n        raise HTTPException(400, \"Invalid 3MF file\")\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(500, f\"Error extracting plate preview: {str(e)}\")\n\n\n@router.post(\"/upload\")\nasync def upload_archive(\n    file: UploadFile = File(...),\n    printer_id: int | None = None,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),\n):\n    \"\"\"Manually upload a 3MF file to archive.\"\"\"\n    if not file.filename or not file.filename.endswith(\".3mf\"):\n        raise HTTPException(400, \"File must be a .3mf file\")\n\n    # Save uploaded file temporarily — strip directory components to prevent path traversal\n    safe_filename = _safe_filename(file.filename)\n    temp_path = settings.archive_dir / \"temp\" / safe_filename\n    temp_path.parent.mkdir(parents=True, exist_ok=True)\n\n    try:\n        content = await file.read()\n        temp_path.write_bytes(content)\n\n        service = ArchiveService(db)\n        archive = await service.archive_print(\n            printer_id=printer_id,\n            source_file=temp_path,\n            created_by_id=current_user.id if current_user else None,\n        )\n\n        if not archive:\n            raise HTTPException(400, \"Failed to archive file\")\n\n        return ArchiveResponse.model_validate(archive)\n    finally:\n        if temp_path.exists():\n            temp_path.unlink()\n\n\n@router.post(\"/upload-bulk\")\nasync def upload_archives_bulk(\n    files: list[UploadFile] = File(...),\n    printer_id: int | None = None,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),\n):\n    \"\"\"Bulk upload multiple 3MF files to archive.\"\"\"\n    results = []\n    errors = []\n\n    for file in files:\n        if not file.filename or not file.filename.endswith(\".3mf\"):\n            errors.append({\"filename\": file.filename or \"unknown\", \"error\": \"Not a .3mf file\"})\n            continue\n\n        safe_filename = _safe_filename(file.filename)\n        temp_path = settings.archive_dir / \"temp\" / safe_filename\n        temp_path.parent.mkdir(parents=True, exist_ok=True)\n\n        try:\n            content = await file.read()\n            temp_path.write_bytes(content)\n\n            service = ArchiveService(db)\n            archive = await service.archive_print(\n                printer_id=printer_id,\n                source_file=temp_path,\n                created_by_id=current_user.id if current_user else None,\n            )\n\n            if archive:\n                results.append(\n                    {\n                        \"filename\": file.filename,\n                        \"id\": archive.id,\n                        \"status\": \"success\",\n                    }\n                )\n            else:\n                errors.append({\"filename\": file.filename, \"error\": \"Failed to process\"})\n        except Exception as e:\n            logger.exception(\"Failed to upload archive %s: %s\", file.filename, e)\n            errors.append({\"filename\": file.filename, \"error\": \"Failed to process file\"})\n        finally:\n            if temp_path.exists():\n                temp_path.unlink()\n\n    return {\n        \"uploaded\": len(results),\n        \"failed\": len(errors),\n        \"results\": results,\n        \"errors\": errors,\n    }\n\n\n@router.get(\"/{archive_id}/plates\")\nasync def get_archive_plates(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Get available plates from a multi-plate 3MF archive.\n\n    Returns a list of plates with their index, name, thumbnail availability,\n    and filament requirements. For single-plate exports, returns a single plate.\n    \"\"\"\n    import re\n\n    import defusedxml.ElementTree as ET\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"Archive file not found\")\n\n    plates = []\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            namelist = zf.namelist()\n\n            # Find all plate gcode files to determine available plates\n            gcode_files = [n for n in namelist if n.startswith(\"Metadata/plate_\") and n.endswith(\".gcode\")]\n\n            # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG\n            plate_indices: list[int] = []\n            if gcode_files:\n                # Extract plate indices from gcode filenames\n                for gf in gcode_files:\n                    # \"Metadata/plate_5.gcode\" -> 5\n                    try:\n                        # Remove \"Metadata/plate_\" and \".gcode\"\n                        plate_str = gf[15:-6]\n                        plate_indices.append(int(plate_str))\n                    except ValueError:\n                        pass  # Skip gcode file with non-numeric plate index\n            else:\n                plate_json_files = [n for n in namelist if n.startswith(\"Metadata/plate_\") and n.endswith(\".json\")]\n                plate_png_files = [\n                    n\n                    for n in namelist\n                    if n.startswith(\"Metadata/plate_\")\n                    and n.endswith(\".png\")\n                    and \"_small\" not in n\n                    and \"no_light\" not in n\n                ]\n                plate_name_candidates = plate_json_files + plate_png_files\n                plate_re = re.compile(r\"^Metadata/plate_(\\d+)\\.(json|png)$\")\n                seen_indices: set[int] = set()\n                for name in plate_name_candidates:\n                    match = plate_re.match(name)\n                    if match:\n                        try:\n                            index = int(match.group(1))\n                        except ValueError:\n                            continue\n                        if index in seen_indices:\n                            continue\n                        seen_indices.add(index)\n                        plate_indices.append(index)\n\n            if not plate_indices:\n                # No plate metadata found\n                return {\n                    \"archive_id\": archive_id,\n                    \"filename\": archive.filename,\n                    \"plates\": [],\n                    \"is_multi_plate\": False,\n                }\n\n            plate_indices.sort()\n\n            # Parse model_settings.config for plate names + object assignments\n            # Plate names are stored with plater_id and plater_name keys\n            plate_names = {}  # plater_id -> name\n            plate_object_ids: dict[int, list[str]] = {}\n            object_names_by_id: dict[str, str] = {}\n            if \"Metadata/model_settings.config\" in namelist:\n                try:\n                    model_content = zf.read(\"Metadata/model_settings.config\").decode()\n                    model_root = ET.fromstring(model_content)\n                    # Build object ID -> name map\n                    for obj_elem in model_root.findall(\".//object\"):\n                        obj_id = obj_elem.get(\"id\")\n                        if not obj_id:\n                            continue\n                        name_meta = obj_elem.find(\"metadata[@key='name']\")\n                        obj_name = name_meta.get(\"value\") if name_meta is not None else None\n                        if obj_name:\n                            object_names_by_id[obj_id] = obj_name\n\n                    for plate_elem in model_root.findall(\".//plate\"):\n                        plater_id = None\n                        plater_name = None\n                        for meta in plate_elem.findall(\"metadata\"):\n                            key = meta.get(\"key\")\n                            value = meta.get(\"value\")\n                            if key == \"plater_id\" and value:\n                                try:\n                                    plater_id = int(value)\n                                except ValueError:\n                                    pass  # Skip plate with non-numeric plater_id\n                            elif key == \"plater_name\" and value:\n                                plater_name = value.strip()\n                        if plater_id is not None and plater_name:\n                            plate_names[plater_id] = plater_name\n\n                        if plater_id is not None:\n                            for instance_elem in plate_elem.findall(\"model_instance\"):\n                                for inst_meta in instance_elem.findall(\"metadata\"):\n                                    if inst_meta.get(\"key\") == \"object_id\":\n                                        obj_id = inst_meta.get(\"value\")\n                                        if not obj_id:\n                                            continue\n                                        plate_object_ids.setdefault(plater_id, [])\n                                        if obj_id not in plate_object_ids[plater_id]:\n                                            plate_object_ids[plater_id].append(obj_id)\n                except Exception:\n                    pass  # model_settings.config parsing is optional\n\n            # Parse slice_info.config for plate metadata\n            plate_metadata = {}  # plate_index -> {filaments, prediction, weight, name, objects}\n            if \"Metadata/slice_info.config\" in namelist:\n                content = zf.read(\"Metadata/slice_info.config\").decode()\n                root = ET.fromstring(content)\n\n                for plate_elem in root.findall(\".//plate\"):\n                    plate_info = {\"filaments\": [], \"prediction\": None, \"weight\": None, \"name\": None, \"objects\": []}\n\n                    # Get plate index from metadata\n                    plate_index = None\n                    for meta in plate_elem.findall(\"metadata\"):\n                        key = meta.get(\"key\")\n                        value = meta.get(\"value\")\n                        if key == \"index\" and value:\n                            try:\n                                plate_index = int(value)\n                            except ValueError:\n                                pass  # Skip plate with non-numeric index\n                        elif key == \"prediction\" and value:\n                            try:\n                                plate_info[\"prediction\"] = int(value)\n                            except ValueError:\n                                pass  # Skip non-numeric print time prediction\n                        elif key == \"weight\" and value:\n                            try:\n                                plate_info[\"weight\"] = float(value)\n                            except ValueError:\n                                pass  # Skip non-numeric filament weight\n\n                    # Get filaments used in this plate\n                    for filament_elem in plate_elem.findall(\"filament\"):\n                        filament_id = filament_elem.get(\"id\")\n                        filament_type = filament_elem.get(\"type\", \"\")\n                        filament_color = filament_elem.get(\"color\", \"\")\n                        used_g = filament_elem.get(\"used_g\", \"0\")\n                        used_m = filament_elem.get(\"used_m\", \"0\")\n\n                        try:\n                            used_grams = float(used_g)\n                        except (ValueError, TypeError):\n                            used_grams = 0\n\n                        if used_grams > 0 and filament_id:\n                            plate_info[\"filaments\"].append(\n                                {\n                                    \"slot_id\": int(filament_id),\n                                    \"type\": filament_type,\n                                    \"color\": filament_color,\n                                    \"used_grams\": round(used_grams, 1),\n                                    \"used_meters\": float(used_m) if used_m else 0,\n                                }\n                            )\n\n                    # Sort filaments by slot ID\n                    plate_info[\"filaments\"].sort(key=lambda x: x[\"slot_id\"])\n\n                    # Collect all object names on this plate\n                    for obj_elem in plate_elem.findall(\"object\"):\n                        obj_name = obj_elem.get(\"name\")\n                        if obj_name and obj_name not in plate_info[\"objects\"]:\n                            plate_info[\"objects\"].append(obj_name)\n\n                    # Set plate name: prefer custom name from model_settings.config,\n                    # fall back to first object name if no custom name was set\n                    if plate_index is not None:\n                        custom_name = plate_names.get(plate_index)\n                        if custom_name:\n                            plate_info[\"name\"] = custom_name\n                        else:\n                            # Fall back to first object name as hint\n                            if plate_info[\"objects\"]:\n                                plate_info[\"name\"] = plate_info[\"objects\"][0]\n\n                        plate_metadata[plate_index] = plate_info\n\n            # Parse plate_*.json for object lists when slice_info is missing\n            plate_json_objects: dict[int, list[str]] = {}\n            for name in namelist:\n                match = re.match(r\"^Metadata/plate_(\\d+)\\.json$\", name)\n                if not match:\n                    continue\n                try:\n                    plate_index = int(match.group(1))\n                except ValueError:\n                    continue\n                try:\n                    payload = json.loads(zf.read(name).decode())\n                    bbox_objects = payload.get(\"bbox_objects\", [])\n                    names = []\n                    for obj in bbox_objects:\n                        obj_name = obj.get(\"name\") if isinstance(obj, dict) else None\n                        if obj_name and obj_name not in names:\n                            names.append(obj_name)\n                    if names:\n                        plate_json_objects[plate_index] = names\n                except Exception:\n                    continue\n\n            # Build plate list\n            for idx in plate_indices:\n                meta = plate_metadata.get(idx, {})\n                has_thumbnail = f\"Metadata/plate_{idx}.png\" in namelist\n                objects = meta.get(\"objects\", [])\n                if not objects:\n                    objects = plate_json_objects.get(idx, [])\n                if not objects and plate_object_ids.get(idx):\n                    objects = [\n                        object_names_by_id.get(obj_id, f\"Object {obj_id}\") for obj_id in plate_object_ids.get(idx, [])\n                    ]\n\n                plate_name = meta.get(\"name\")\n                if not plate_name:\n                    plate_name = plate_names.get(idx)\n                if not plate_name and objects:\n                    plate_name = objects[0]\n\n                plates.append(\n                    {\n                        \"index\": idx,\n                        \"name\": plate_name,\n                        \"objects\": objects,\n                        \"object_count\": len(objects),\n                        \"has_thumbnail\": has_thumbnail,\n                        \"thumbnail_url\": f\"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}\"\n                        if has_thumbnail\n                        else None,\n                        \"print_time_seconds\": meta.get(\"prediction\"),\n                        \"filament_used_grams\": meta.get(\"weight\"),\n                        \"filaments\": meta.get(\"filaments\", []),\n                    }\n                )\n\n    except Exception as e:\n        logger.warning(\"Failed to parse plates from archive %s: %s\", archive_id, e)\n\n    return {\n        \"archive_id\": archive_id,\n        \"filename\": archive.filename,\n        \"plates\": plates,\n        \"is_multi_plate\": len(plates) > 1,\n    }\n\n\n@router.get(\"/{archive_id}/plate-thumbnail/{plate_index}\")\nasync def get_plate_thumbnail(\n    archive_id: int,\n    plate_index: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get the thumbnail image for a specific plate.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"Archive file not found\")\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            thumb_path = f\"Metadata/plate_{plate_index}.png\"\n            if thumb_path in zf.namelist():\n                data = zf.read(thumb_path)\n                return Response(content=data, media_type=\"image/png\")\n    except Exception:\n        pass  # Fall through to 404 if archive is unreadable or thumbnail missing\n\n    raise HTTPException(404, f\"Thumbnail for plate {plate_index} not found\")\n\n\n@router.get(\"/{archive_id}/filament-requirements\")\nasync def get_filament_requirements(\n    archive_id: int,\n    plate_id: int | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Get filament requirements from the archived 3MF file.\n\n    Returns the filaments used in this print with their slot IDs, types, colors,\n    and usage amounts. This can be compared with current AMS state before reprinting.\n\n    Args:\n        archive_id: The archive ID\n        plate_id: Optional plate index to filter filaments for (for multi-plate files)\n    \"\"\"\n    import defusedxml.ElementTree as ET\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"Archive file not found\")\n\n    filaments = []\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            # Parse slice_info.config for filament requirements\n            if \"Metadata/slice_info.config\" in zf.namelist():\n                content = zf.read(\"Metadata/slice_info.config\").decode()\n                root = ET.fromstring(content)\n\n                # If plate_id is specified, find filaments for that specific plate\n                if plate_id is not None:\n                    # Find the plate element with matching index\n                    for plate_elem in root.findall(\".//plate\"):\n                        plate_index = None\n                        for meta in plate_elem.findall(\"metadata\"):\n                            if meta.get(\"key\") == \"index\":\n                                try:\n                                    plate_index = int(meta.get(\"value\", \"0\"))\n                                except ValueError:\n                                    pass  # Skip plate with non-numeric index metadata\n                                break\n\n                        if plate_index == plate_id:\n                            # Extract filaments from this plate element\n                            for filament_elem in plate_elem.findall(\"filament\"):\n                                filament_id = filament_elem.get(\"id\")\n                                filament_type = filament_elem.get(\"type\", \"\")\n                                filament_color = filament_elem.get(\"color\", \"\")\n                                used_g = filament_elem.get(\"used_g\", \"0\")\n                                used_m = filament_elem.get(\"used_m\", \"0\")\n\n                                tray_info_idx = filament_elem.get(\"tray_info_idx\", \"\")\n\n                                try:\n                                    used_grams = float(used_g)\n                                except (ValueError, TypeError):\n                                    used_grams = 0\n\n                                if used_grams > 0 and filament_id:\n                                    filaments.append(\n                                        {\n                                            \"slot_id\": int(filament_id),\n                                            \"type\": filament_type,\n                                            \"color\": filament_color,\n                                            \"used_grams\": round(used_grams, 1),\n                                            \"used_meters\": float(used_m) if used_m else 0,\n                                            \"tray_info_idx\": tray_info_idx,\n                                        }\n                                    )\n                            break\n                else:\n                    # No plate_id specified - extract all filaments with used_g > 0\n                    # This is the legacy behavior for single-plate files\n                    for filament_elem in root.findall(\".//filament\"):\n                        filament_id = filament_elem.get(\"id\")\n                        filament_type = filament_elem.get(\"type\", \"\")\n                        filament_color = filament_elem.get(\"color\", \"\")\n                        used_g = filament_elem.get(\"used_g\", \"0\")\n                        used_m = filament_elem.get(\"used_m\", \"0\")\n\n                        tray_info_idx = filament_elem.get(\"tray_info_idx\", \"\")\n\n                        # Only include filaments that are actually used\n                        try:\n                            used_grams = float(used_g)\n                        except (ValueError, TypeError):\n                            used_grams = 0\n\n                        if used_grams > 0 and filament_id:\n                            filaments.append(\n                                {\n                                    \"slot_id\": int(filament_id),\n                                    \"type\": filament_type,\n                                    \"color\": filament_color,\n                                    \"used_grams\": round(used_grams, 1),\n                                    \"used_meters\": float(used_m) if used_m else 0,\n                                    \"tray_info_idx\": tray_info_idx,\n                                }\n                            )\n\n            # Sort by slot ID\n            filaments.sort(key=lambda x: x[\"slot_id\"])\n\n            # Enrich with nozzle mapping for dual-nozzle printers\n            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)\n            if nozzle_mapping:\n                for filament in filaments:\n                    filament[\"nozzle_id\"] = nozzle_mapping.get(filament[\"slot_id\"])\n\n    except Exception as e:\n        logger.warning(\"Failed to parse filament requirements from archive %s: %s\", archive_id, e)\n\n    return {\n        \"archive_id\": archive_id,\n        \"filename\": archive.filename,\n        \"plate_id\": plate_id,\n        \"filaments\": filaments,\n    }\n\n\n@router.post(\"/{archive_id}/reprint\")\nasync def reprint_archive(\n    archive_id: int,\n    printer_id: int,\n    body: ReprintRequest | None = None,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.ARCHIVES_REPRINT_ALL,\n            Permission.ARCHIVES_REPRINT_OWN,\n        )\n    ),\n):\n    \"\"\"Dispatch an archived 3MF file for send/start on a printer.\"\"\"\n    from backend.app.models.printer import Printer\n    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch\n    from backend.app.services.printer_manager import printer_manager\n\n    user, can_modify_all = auth_result\n\n    # Use defaults if no body provided\n    if body is None:\n        body = ReprintRequest()\n\n    # Get archive\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    # Ownership check\n    if not can_modify_all:\n        if archive.created_by_id != user.id:\n            raise HTTPException(403, \"You can only reprint your own archives\")\n\n    # Get printer\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Check printer is connected\n    if not printer_manager.is_connected(printer_id):\n        raise HTTPException(400, \"Printer is not connected\")\n\n    if not archive.file_path:\n        raise HTTPException(\n            404,\n            \"No 3MF file available for this archive. \"\n            \"The file could not be downloaded from the printer when the print was recorded.\",\n        )\n\n    # Validate archive file exists\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"Archive file not found\")\n\n    plate_name = body.plate_name\n    if not plate_name and body.plate_id is not None:\n        plate_name = f\"Plate {body.plate_id}\"\n\n    dispatch_source_name = archive.filename\n    if plate_name:\n        dispatch_source_name = f\"{archive.filename} • {plate_name}\"\n\n    try:\n        dispatch_result = await background_dispatch.dispatch_reprint_archive(\n            archive_id=archive_id,\n            archive_name=dispatch_source_name,\n            printer_id=printer_id,\n            printer_name=printer.name,\n            options=body.model_dump(exclude_none=True),\n            requested_by_user_id=user.id if user else None,\n            requested_by_username=user.username if user else None,\n        )\n    except DispatchEnqueueRejected as e:\n        raise HTTPException(status_code=409, detail=str(e)) from e\n\n    logger.info(\n        \"Dispatched reprint archive %s for printer %s (dispatch_job_id=%s, dispatch_position=%s)\",\n        archive_id,\n        printer_id,\n        dispatch_result[\"dispatch_job_id\"],\n        dispatch_result[\"dispatch_position\"],\n    )\n\n    return {\n        \"status\": \"dispatched\",\n        \"printer_id\": printer_id,\n        \"archive_id\": archive_id,\n        \"filename\": archive.filename,\n        \"dispatch_job_id\": dispatch_result[\"dispatch_job_id\"],\n        \"dispatch_position\": dispatch_result[\"dispatch_position\"],\n    }\n\n\n# =============================================================================\n# Project Page API\n# =============================================================================\n\n\n@router.get(\"/{archive_id}/project-page\")\nasync def get_project_page(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Get the project page data from the 3MF file.\"\"\"\n    from backend.app.schemas.archive import ProjectPageResponse\n    from backend.app.services.archive import ProjectPageParser\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"Archive file not found\")\n\n    parser = ProjectPageParser(file_path)\n    data = parser.parse(archive_id)\n\n    return ProjectPageResponse(**data)\n\n\n@router.patch(\"/{archive_id}/project-page\")\nasync def update_project_page(\n    archive_id: int,\n    update_data: dict,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),\n):\n    \"\"\"Update project page metadata in the 3MF file.\"\"\"\n    from backend.app.services.archive import ProjectPageParser\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"Archive file not found\")\n\n    parser = ProjectPageParser(file_path)\n    success = parser.update_metadata(update_data)\n\n    if not success:\n        raise HTTPException(500, \"Failed to update project page\")\n\n    # Return updated data\n    data = parser.parse(archive_id)\n    return data\n\n\n@router.get(\"/{archive_id}/project-image/{image_path:path}\")\nasync def get_project_image(\n    archive_id: int,\n    image_path: str,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get an image from the 3MF project page.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    from backend.app.services.archive import ProjectPageParser\n\n    service = ArchiveService(db)\n    archive = await service.get_archive(archive_id)\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    file_path = settings.base_dir / archive.file_path\n    if not file_path.is_file():\n        raise HTTPException(404, \"Archive file not found\")\n\n    parser = ProjectPageParser(file_path)\n    result = parser.get_image(image_path)\n\n    if not result:\n        raise HTTPException(404, \"Image not found in 3MF file\")\n\n    image_data, content_type = result\n    return Response(\n        content=image_data,\n        media_type=content_type,\n        headers={\"Cache-Control\": \"max-age=3600\"},\n    )\n\n\n# =============================================================================\n# Source 3MF API (Original Project Files)\n# =============================================================================\n\n\n@router.post(\"/{archive_id}/source\")\nasync def upload_source_3mf(\n    archive_id: int,\n    file: UploadFile = File(...),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),\n):\n    \"\"\"Upload the original source 3MF project file for an archive.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not file.filename or not file.filename.endswith(\".3mf\"):\n        raise HTTPException(400, \"File must be a .3mf file\")\n\n    # Get archive directory and create source subdirectory\n    file_path = settings.base_dir / archive.file_path\n    archive_dir = file_path.parent\n    source_dir = archive_dir / \"source\"\n    source_dir.mkdir(exist_ok=True)\n\n    # Delete old source file if exists\n    if archive.source_3mf_path:\n        old_source_path = settings.base_dir / archive.source_3mf_path\n        if old_source_path.exists():\n            old_source_path.unlink()\n\n    # Save the source 3MF file - preserve original filename, strip directory components\n    source_filename = _safe_filename(file.filename)\n    source_path = source_dir / source_filename\n\n    content = await file.read()\n    source_path.write_bytes(content)\n\n    # Update archive with source path (relative to base_dir)\n    archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))\n\n    await db.commit()\n    await db.refresh(archive)\n\n    return {\n        \"status\": \"uploaded\",\n        \"source_3mf_path\": archive.source_3mf_path,\n        \"filename\": source_filename,\n    }\n\n\n@router.get(\"/{archive_id}/source\")\nasync def download_source_3mf(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Download the source 3MF project file.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.source_3mf_path:\n        raise HTTPException(404, \"No source 3MF attached to this archive\")\n\n    source_path = settings.base_dir / archive.source_3mf_path\n    if not source_path.exists():\n        raise HTTPException(404, \"Source 3MF file not found on disk\")\n\n    # Use the actual filename from the path\n    filename = source_path.name\n\n    return FileResponse(\n        path=source_path,\n        filename=filename,\n        media_type=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\",\n    )\n\n\n@router.get(\"/{archive_id}/source/{filename}\")\nasync def download_source_3mf_for_slicer(\n    archive_id: int,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Download source 3MF with filename in URL.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.source_3mf_path:\n        raise HTTPException(404, \"No source 3MF attached to this archive\")\n\n    source_path = settings.base_dir / archive.source_3mf_path\n    if not source_path.exists():\n        raise HTTPException(404, \"Source 3MF file not found on disk\")\n\n    return FileResponse(\n        path=source_path,\n        filename=filename if filename.endswith(\".3mf\") else f\"{filename}.3mf\",\n        media_type=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\",\n    )\n\n\n@router.post(\"/{archive_id}/source-slicer-token\")\nasync def create_source_slicer_token(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Create a short-lived download token for opening source 3MF in slicer.\"\"\"\n    from backend.app.core.auth import create_slicer_download_token\n\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n    if not archive.source_3mf_path:\n        raise HTTPException(404, \"No source 3MF attached to this archive\")\n\n    token = await create_slicer_download_token(\"source\", archive_id)\n    return {\"token\": token}\n\n\n@router.get(\"/{archive_id}/source-dl/{token}/{filename}\")\nasync def download_source_3mf_for_slicer_with_token(\n    archive_id: int,\n    token: str,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Download source 3MF using a slicer download token.\n\n    Token-authenticated (no auth headers needed). The token is short-lived\n    and single-use, created by POST /{archive_id}/source-slicer-token.\n    \"\"\"\n    from backend.app.core.auth import verify_slicer_download_token\n\n    if not await verify_slicer_download_token(token, \"source\", archive_id):\n        raise HTTPException(403, \"Invalid or expired download token\")\n\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.source_3mf_path:\n        raise HTTPException(404, \"No source 3MF attached to this archive\")\n\n    source_path = settings.base_dir / archive.source_3mf_path\n    if not source_path.exists():\n        raise HTTPException(404, \"Source 3MF file not found on disk\")\n\n    return FileResponse(\n        path=source_path,\n        filename=filename if filename.endswith(\".3mf\") else f\"{filename}.3mf\",\n        media_type=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\",\n    )\n\n\n@router.post(\"/upload-source\")\nasync def upload_source_3mf_by_name(\n    file: UploadFile = File(...),\n    print_name: str = Query(None, description=\"Match archive by print name\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),\n):\n    \"\"\"Upload source 3MF and match to archive by print name.\n\n    This endpoint is designed for slicer post-processing scripts.\n    It finds the most recent archive matching the print name and attaches the source.\n    \"\"\"\n    if not file.filename or not file.filename.endswith(\".3mf\"):\n        raise HTTPException(400, \"File must be a .3mf file\")\n\n    safe_filename = _safe_filename(file.filename)\n\n    # Derive print name from filename if not provided\n    if not print_name:\n        # Remove .3mf extension and common suffixes\n        print_name = safe_filename.rsplit(\".3mf\", 1)[0]\n        # Remove _source suffix if present\n        if print_name.endswith(\"_source\"):\n            print_name = print_name[:-7]\n\n    # Find matching archive - try exact match first, then fuzzy\n    result = await db.execute(\n        select(PrintArchive)\n        .where(PrintArchive.print_name == print_name)\n        .order_by(PrintArchive.created_at.desc())\n        .limit(1)\n    )\n    archive = result.scalar_one_or_none()\n\n    if not archive:\n        # Try matching filename without .gcode.3mf\n        result = await db.execute(\n            select(PrintArchive)\n            .where(PrintArchive.filename.like(f\"{print_name}%\"))\n            .order_by(PrintArchive.created_at.desc())\n            .limit(1)\n        )\n        archive = result.scalar_one_or_none()\n\n    if not archive:\n        # Try case-insensitive partial match on print_name\n        result = await db.execute(\n            select(PrintArchive)\n            .where(PrintArchive.print_name.ilike(f\"%{print_name}%\"))\n            .order_by(PrintArchive.created_at.desc())\n            .limit(1)\n        )\n        archive = result.scalar_one_or_none()\n\n    if not archive:\n        raise HTTPException(404, f\"No archive found matching '{print_name}'\")\n\n    # Get archive directory and create source subdirectory\n    file_path = settings.base_dir / archive.file_path\n    archive_dir = file_path.parent\n    source_dir = archive_dir / \"source\"\n    source_dir.mkdir(exist_ok=True)\n\n    # Delete old source file if exists\n    if archive.source_3mf_path:\n        old_source_path = settings.base_dir / archive.source_3mf_path\n        if old_source_path.exists():\n            old_source_path.unlink()\n\n    # Save the source 3MF file - preserve original filename, strip directory components\n    source_filename = safe_filename\n    source_path = source_dir / source_filename\n\n    content = await file.read()\n    source_path.write_bytes(content)\n\n    # Update archive with source path\n    archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))\n    await db.commit()\n    await db.refresh(archive)\n\n    return {\n        \"status\": \"uploaded\",\n        \"archive_id\": archive.id,\n        \"archive_name\": archive.print_name or archive.filename,\n        \"source_3mf_path\": archive.source_3mf_path,\n        \"filename\": source_filename,\n    }\n\n\n@router.delete(\"/{archive_id}/source\")\nasync def delete_source_3mf(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),\n):\n    \"\"\"Delete the source 3MF project file from an archive.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.source_3mf_path:\n        raise HTTPException(404, \"No source 3MF attached to this archive\")\n\n    # Delete the file\n    source_path = settings.base_dir / archive.source_3mf_path\n    if source_path.exists():\n        source_path.unlink()\n\n    # Clear the path in database\n    archive.source_3mf_path = None\n    await db.commit()\n\n    return {\"status\": \"deleted\"}\n\n\n# =============================================================================\n# F3D API (Fusion 360 Design Files)\n# =============================================================================\n\n\n@router.post(\"/{archive_id}/f3d\")\nasync def upload_f3d(\n    archive_id: int,\n    file: UploadFile = File(...),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),\n):\n    \"\"\"Upload a Fusion 360 design file for an archive.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not file.filename or not file.filename.endswith(\".f3d\"):\n        raise HTTPException(400, \"File must be a .f3d file\")\n\n    # Get archive directory and create f3d subdirectory\n    file_path = settings.base_dir / archive.file_path\n    archive_dir = file_path.parent\n    f3d_dir = archive_dir / \"f3d\"\n    f3d_dir.mkdir(exist_ok=True)\n\n    # Delete old F3D file if exists\n    if archive.f3d_path:\n        old_f3d_path = settings.base_dir / archive.f3d_path\n        if old_f3d_path.exists():\n            old_f3d_path.unlink()\n\n    # Save the F3D file - preserve original filename, strip directory components\n    f3d_filename = _safe_filename(file.filename)\n    f3d_path = f3d_dir / f3d_filename\n\n    content = await file.read()\n    f3d_path.write_bytes(content)\n\n    # Update archive with F3D path (relative to base_dir)\n    archive.f3d_path = str(f3d_path.relative_to(settings.base_dir))\n\n    await db.commit()\n    await db.refresh(archive)\n\n    return {\n        \"status\": \"uploaded\",\n        \"f3d_path\": archive.f3d_path,\n        \"filename\": f3d_filename,\n    }\n\n\n@router.get(\"/{archive_id}/f3d\")\nasync def download_f3d(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Download the Fusion 360 design file.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.f3d_path:\n        raise HTTPException(404, \"No F3D file attached to this archive\")\n\n    f3d_path = settings.base_dir / archive.f3d_path\n    if not f3d_path.exists():\n        raise HTTPException(404, \"F3D file not found on disk\")\n\n    # Use the actual filename from the path\n    filename = f3d_path.name\n\n    return FileResponse(\n        path=f3d_path,\n        filename=filename,\n        media_type=\"application/octet-stream\",\n    )\n\n\n@router.delete(\"/{archive_id}/f3d\")\nasync def delete_f3d(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),\n):\n    \"\"\"Delete the Fusion 360 design file from an archive.\"\"\"\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(404, \"Archive not found\")\n\n    if not archive.f3d_path:\n        raise HTTPException(404, \"No F3D file attached to this archive\")\n\n    # Delete the file\n    f3d_path = settings.base_dir / archive.f3d_path\n    if f3d_path.exists():\n        f3d_path.unlink()\n\n    # Clear the path in database\n    archive.f3d_path = None\n    await db.commit()\n\n    return {\"status\": \"deleted\"}\n"
  },
  {
    "path": "backend/app/api/routes/auth.py",
    "content": "import logging\nimport os\nimport secrets\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Annotated\n\nimport jwt as _jwt\nfrom fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status\nfrom fastapi.security import HTTPAuthorizationCredentials\nfrom jwt.exceptions import PyJWTError\nfrom sqlalchemy import delete, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.api.routes.settings import get_external_login_url\nfrom backend.app.core.auth import (\n    ACCESS_TOKEN_EXPIRE_MINUTES,\n    ALGORITHM,\n    SECRET_KEY,\n    Permission,\n    RequirePermissionIfAuthEnabled,\n    _is_token_fresh,\n    _validate_api_key,\n    authenticate_user,\n    authenticate_user_by_email,\n    create_access_token,\n    get_current_active_user,\n    get_password_hash,\n    get_user_by_email,\n    get_user_by_username,\n    is_jti_revoked,\n    revoke_jti,\n    security,\n)\nfrom backend.app.core.database import async_session, get_db\nfrom backend.app.core.permissions import ALL_PERMISSIONS\nfrom backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType\nfrom backend.app.models.group import Group\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.user import User\nfrom backend.app.schemas.auth import (\n    ForgotPasswordConfirmRequest,\n    ForgotPasswordRequest,\n    ForgotPasswordResponse,\n    GroupBrief,\n    LoginRequest,\n    LoginResponse,\n    ResetPasswordRequest,\n    ResetPasswordResponse,\n    SetupRequest,\n    SetupResponse,\n    SMTPSettings,\n    TestSMTPRequest,\n    TestSMTPResponse,\n    UserResponse,\n)\nfrom backend.app.services.email_service import (\n    create_password_reset_link_email_from_template,\n    get_smtp_settings,\n    save_smtp_settings,\n    send_email,\n)\n\n_logger = logging.getLogger(__name__)\n\n\ndef _user_to_response(user: User) -> UserResponse:\n    \"\"\"Convert a User model to UserResponse schema.\"\"\"\n    return UserResponse(\n        id=user.id,\n        username=user.username,\n        email=user.email,\n        role=user.role,\n        is_active=user.is_active,\n        is_admin=user.is_admin,\n        auth_source=getattr(user, \"auth_source\", \"local\"),\n        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],\n        permissions=sorted(user.get_permissions()),\n        created_at=user.created_at.isoformat(),\n    )\n\n\ndef _api_key_to_user_response(api_key) -> UserResponse:\n    \"\"\"Create a synthetic admin UserResponse for a valid API key.\"\"\"\n    return UserResponse(\n        id=0,\n        username=f\"api-key:{api_key.key_prefix}\",\n        email=None,\n        role=\"admin\",\n        is_active=True,\n        is_admin=True,\n        groups=[],\n        permissions=sorted(ALL_PERMISSIONS),\n        created_at=api_key.created_at.isoformat(),\n    )\n\n\n# ---------------------------------------------------------------------------\n# M-R9-A: Real client IP resolution for rate limiting behind reverse proxies.\n# Set TRUSTED_PROXY_IPS (comma-separated) to enable X-Forwarded-For trust.\n# Without this env var client.host is used directly (safe default).\n# ---------------------------------------------------------------------------\n_TRUSTED_PROXY_IPS: frozenset[str] = frozenset(\n    ip.strip() for ip in os.environ.get(\"TRUSTED_PROXY_IPS\", \"\").split(\",\") if ip.strip()\n)\n\n\ndef _get_client_ip(request: Request) -> str:\n    \"\"\"Return the real client IP for rate-limiting purposes.\n\n    When TRUSTED_PROXY_IPS is configured and the direct TCP peer is a trusted\n    proxy, X-Forwarded-For is evaluated right-to-left: the rightmost IP that is\n    NOT itself a trusted proxy is the true client address (M-R10-A fix).\n\n    Standard nginx with proxy_add_x_forwarded_for *appends* the client IP, so\n    the rightmost entry is always the one added by the last trusted proxy —\n    i.e. the real client. Walking right-to-left and skipping known proxies is\n    safe for multi-hop chains as well.\n\n    Falls back to request.client.host when TRUSTED_PROXY_IPS is unset (direct\n    deployment without a reverse proxy).\n    \"\"\"\n    # I5: Use a per-request unique token instead of \"unknown\" when the transport\n    # layer provides no client address.  This prevents all such requests from\n    # sharing one rate-limit bucket, and avoids collision with a literal username\n    # \"unknown\".  The token is not stable across requests, which is intentional:\n    # we cannot track the IP so we also cannot rate-limit by it meaningfully.\n    direct_ip = request.client.host if request.client else f\"__no_ip_{secrets.token_hex(8)}__\"\n    if _TRUSTED_PROXY_IPS and direct_ip in _TRUSTED_PROXY_IPS:\n        forwarded_for = request.headers.get(\"X-Forwarded-For\", \"\")\n        ips = [ip.strip() for ip in forwarded_for.split(\",\") if ip.strip()]\n        # Walk right-to-left; skip IPs that belong to trusted proxies.\n        for ip in reversed(ips):\n            if ip not in _TRUSTED_PROXY_IPS:\n                return ip\n        # Edge case: every entry is a trusted proxy — fall back to leftmost.\n        if ips:\n            return ips[0]\n    return direct_ip\n\n\nrouter = APIRouter(prefix=\"/auth\", tags=[\"authentication\"])\n\n\nasync def is_auth_enabled(db: AsyncSession) -> bool:\n    \"\"\"Check if authentication is enabled.\"\"\"\n    result = await db.execute(select(Settings).where(Settings.key == \"auth_enabled\"))\n    setting = result.scalar_one_or_none()\n    if setting is None:\n        return False\n    return setting.value.lower() == \"true\"\n\n\nasync def is_advanced_auth_enabled(db: AsyncSession) -> bool:\n    \"\"\"Check if advanced authentication is enabled.\"\"\"\n    result = await db.execute(select(Settings).where(Settings.key == \"advanced_auth_enabled\"))\n    setting = result.scalar_one_or_none()\n    if setting is None:\n        return False\n    return setting.value.lower() == \"true\"\n\n\nasync def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:\n    \"\"\"Set advanced authentication enabled status.\"\"\"\n    from backend.app.core.db_dialect import upsert_setting\n\n    await upsert_setting(db, Settings, \"advanced_auth_enabled\", \"true\" if enabled else \"false\")\n\n\nasync def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:\n    \"\"\"Set authentication enabled status.\"\"\"\n    from backend.app.core.db_dialect import upsert_setting\n\n    await upsert_setting(db, Settings, \"auth_enabled\", \"true\" if enabled else \"false\")\n    # Note: Don't commit here - let get_db handle it or commit explicitly in the route\n\n\nasync def is_setup_completed(db: AsyncSession) -> bool:\n    \"\"\"Check if setup has been completed.\"\"\"\n    result = await db.execute(select(Settings).where(Settings.key == \"setup_completed\"))\n    setting = result.scalar_one_or_none()\n    return setting and setting.value.lower() == \"true\"\n\n\nasync def set_setup_completed(db: AsyncSession, completed: bool) -> None:\n    \"\"\"Set setup completed status.\"\"\"\n    from backend.app.core.db_dialect import upsert_setting\n\n    await upsert_setting(db, Settings, \"setup_completed\", \"true\" if completed else \"false\")\n    # Note: Don't commit here - let get_db handle it or commit explicitly in the route\n\n\n@router.post(\"/setup\", response_model=SetupResponse)\nasync def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):\n    \"\"\"First-time setup: enable/disable authentication and create admin user.\"\"\"\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    try:\n        # If auth is currently enabled, block unauthenticated setup changes.\n        # Use the admin panel (/disable endpoint) to modify auth when it's already on.\n        if await is_auth_enabled(db):\n            raise HTTPException(\n                status_code=status.HTTP_403_FORBIDDEN,\n                detail=\"Authentication is already configured. Use the admin panel to modify auth settings.\",\n            )\n\n        admin_created = False\n\n        if request.auth_enabled:\n            # Check if admin users already exist\n            admin_users_result = await db.execute(select(User).where(User.role == \"admin\"))\n            existing_admin_users = list(admin_users_result.scalars().all())\n            has_admin_users = len(existing_admin_users) > 0\n\n            if has_admin_users:\n                # Admin users already exist, just enable auth (don't create new admin)\n                logger.info(\n                    f\"Admin users already exist ({len(existing_admin_users)} found), enabling authentication without creating new admin\"\n                )\n                admin_created = False\n            else:\n                # No admin users exist, require admin credentials to create first admin\n                if not request.admin_username or not request.admin_password:\n                    raise HTTPException(\n                        status_code=status.HTTP_400_BAD_REQUEST,\n                        detail=\"Admin username and password are required when enabling authentication (no admin users exist)\",\n                    )\n\n                # Check if username already exists (shouldn't happen if no admin users exist, but check anyway)\n                existing_user = await get_user_by_username(db, request.admin_username)\n                if existing_user:\n                    raise HTTPException(\n                        status_code=status.HTTP_400_BAD_REQUEST,\n                        detail=\"User with this username already exists\",\n                    )\n\n                # Create admin user FIRST (before enabling auth)\n                try:\n                    logger.info(\"Creating admin user: %s\", request.admin_username)\n                    admin_user = User(\n                        username=request.admin_username,\n                        password_hash=get_password_hash(request.admin_password),\n                        role=\"admin\",\n                        is_active=True,\n                    )\n\n                    # Try to add user to Administrators group if it exists\n                    admin_group_result = await db.execute(select(Group).where(Group.name == \"Administrators\"))\n                    admin_group = admin_group_result.scalar_one_or_none()\n                    if admin_group:\n                        admin_user.groups.append(admin_group)\n                        logger.info(\"Added new admin user to Administrators group\")\n\n                    db.add(admin_user)\n                    logger.info(\"Admin user added to session: %s\", request.admin_username)\n                    admin_created = True\n                except Exception as e:\n                    await db.rollback()\n                    logger.error(\"Failed to create admin user: %s\", e, exc_info=True)\n                    raise HTTPException(\n                        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n                        detail=\"Failed to create admin user\",\n                    )\n\n        # Set auth enabled and mark setup as completed\n        await set_auth_enabled(db, request.auth_enabled)\n        await set_setup_completed(db, True)\n        await db.commit()\n\n        if admin_created:\n            await db.refresh(admin_user)\n            logger.info(\"Admin user created successfully: %s\", admin_user.id)\n\n        logger.info(\"Setup completed: auth_enabled=%s, admin_created=%s\", request.auth_enabled, admin_created)\n        return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(\"Setup error: %s\", e, exc_info=True)\n        await db.rollback()\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Setup failed\",\n        )\n\n\n@router.get(\"/status\")\nasync def get_auth_status(db: AsyncSession = Depends(get_db)):\n    \"\"\"Get authentication status (public endpoint).\"\"\"\n    auth_enabled = await is_auth_enabled(db)\n    setup_completed = await is_setup_completed(db)\n    # Only require setup if it hasn't been completed yet\n    requires_setup = not setup_completed\n    return {\"auth_enabled\": auth_enabled, \"requires_setup\": requires_setup}\n\n\n@router.post(\"/disable\", response_model=dict)\nasync def disable_auth(\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Disable authentication (admin only).\"\"\"\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    # Reload user with groups for proper is_admin check\n    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))\n    user = result.scalar_one()\n\n    # Only admins can disable authentication\n    if not user.is_admin:\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=\"Only admins can disable authentication\",\n        )\n\n    try:\n        await set_auth_enabled(db, False)\n        await db.commit()\n        logger.info(\"Authentication disabled by admin user: %s\", user.username)\n        return {\"message\": \"Authentication disabled successfully\", \"auth_enabled\": False}\n    except Exception as e:\n        await db.rollback()\n        logger.error(\"Failed to disable authentication: %s\", e, exc_info=True)\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Failed to disable authentication\",\n        )\n\n\n@router.post(\"/login\", response_model=LoginResponse)\nasync def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):\n    \"\"\"Login and get access token.\n\n    Supports username or email-based login. Username lookup is case-insensitive.\n\n    When 2FA is enabled for the user the response contains ``requires_2fa=True``\n    and a short-lived ``pre_auth_token`` instead of the final JWT.  The client\n    must then call ``POST /auth/2fa/verify`` (or first ``POST /auth/2fa/email/send``\n    to trigger an email OTP) to obtain the real access token.\n    \"\"\"\n    # Check if auth is enabled\n    auth_enabled = await is_auth_enabled(db)\n    if not auth_enabled:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Authentication is not enabled\",\n        )\n\n    # Rate-limit repeated login failures — two independent buckets (M-R5-B / M-R6-A):\n    #   1. Per-username (10/15 min): prevents password brute-force on a known account.\n    #   2. Per-IP     (20/15 min): prevents an attacker from locking out arbitrary accounts\n    #      (DoS) by sending failures for many usernames from a single address.\n    from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS, check_rate_limit, record_failed_attempt\n\n    await check_rate_limit(db, request.username, event_type=EventType.LOGIN_ATTEMPT, max_attempts=MAX_LOGIN_ATTEMPTS)\n    client_ip = _get_client_ip(raw_request)\n    await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)\n\n    # Check if LDAP is enabled\n    ldap_user = None\n    ldap_settings = await _get_ldap_settings(db)\n    if ldap_settings:\n        try:\n            from backend.app.services.ldap_service import (\n                authenticate_ldap_user,\n                parse_ldap_config,\n            )\n\n            ldap_config = parse_ldap_config(ldap_settings)\n            if ldap_config:\n                ldap_user = authenticate_ldap_user(ldap_config, request.username, request.password)\n                if ldap_user:\n                    # LDAP auth succeeded — find or create local user\n                    user = await get_user_by_username(db, ldap_user.username)\n                    if user and user.auth_source != \"ldap\":\n                        # Username exists as local user — don't override\n                        user = None\n                        ldap_user = None\n                    elif not user:\n                        if not ldap_config.auto_provision:\n                            # User doesn't exist and auto-provision is off\n                            ldap_user = None\n                        else:\n                            # Auto-provision LDAP user\n                            user = await _provision_ldap_user(db, ldap_user, ldap_config)\n\n                    if user and ldap_user:\n                        # Update email and group mappings on each login\n                        await _sync_ldap_user(db, user, ldap_user, ldap_config)\n        except Exception as e:\n            import logging\n\n            logging.getLogger(__name__).warning(\"LDAP authentication error, falling back to local: %s\", e)\n            ldap_user = None\n\n    # Try username-based authentication (skip if already authenticated via LDAP)\n    if not ldap_user:\n        user = await authenticate_user(db, request.username, request.password)\n\n    # If username auth failed and advanced auth is enabled, try email-based authentication\n    if not user and not ldap_user:\n        advanced_auth = await is_advanced_auth_enabled(db)\n        if advanced_auth:\n            user = await authenticate_user_by_email(db, request.username, request.password)\n\n    if not user:\n        await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)\n        await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Incorrect username or password\",\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n    # Reload user with groups for proper permission calculation\n    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))\n    user = result.scalar_one()\n\n    # L-R6-A: Password was correct — reset login failure counters for both buckets\n    from backend.app.api.routes.mfa import clear_failed_attempts\n\n    await clear_failed_attempts(db, user.username, event_type=EventType.LOGIN_ATTEMPT)\n    await clear_failed_attempts(db, client_ip, event_type=EventType.LOGIN_IP)\n\n    # --- 2FA check ---\n    # Determine which 2FA methods are active for this user.\n\n    from backend.app.models.settings import Settings as _Settings\n    from backend.app.models.user_totp import UserTOTP\n\n    totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))\n    user_totp = totp_result.scalar_one_or_none()\n    totp_enabled = user_totp is not None and user_totp.is_enabled\n\n    email_2fa_result = await db.execute(select(_Settings).where(_Settings.key == f\"user_{user.id}_email_2fa_enabled\"))\n    email_2fa_setting = email_2fa_result.scalar_one_or_none()\n    email_otp_enabled = (\n        email_2fa_setting is not None and email_2fa_setting.value.lower() == \"true\" and user.email is not None\n    )\n\n    if totp_enabled or email_otp_enabled:\n        # Import here to avoid circular imports\n        from backend.app.api.routes.mfa import create_pre_auth_token\n\n        # Bind the pre_auth_token to an HttpOnly cookie so XSS cannot steal the\n        # token from JS memory and complete 2FA from a different client.\n        challenge_id = secrets.token_urlsafe(32)\n        pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)\n        response.set_cookie(\n            key=\"2fa_challenge\",\n            value=challenge_id,\n            httponly=True,\n            # H-1: only transmit over HTTPS so the binding cookie can't be intercepted\n            # on mixed-content deployments.  Falls back to False on plain HTTP so tests\n            # and local development still work (the client wouldn't send it otherwise).\n            secure=raw_request.url.scheme == \"https\",\n            samesite=\"lax\",\n            max_age=300,\n            path=\"/api/v1/auth/2fa\",\n        )\n        methods: list[str] = []\n        if totp_enabled:\n            methods.append(\"totp\")\n        if email_otp_enabled:\n            methods.append(\"email\")\n        # Backup codes are always available when TOTP is set up\n        if totp_enabled:\n            methods.append(\"backup\")\n\n        return LoginResponse(\n            requires_2fa=True,\n            pre_auth_token=pre_auth_token,\n            two_fa_methods=methods,\n        )\n\n    # No 2FA — issue full token immediately\n    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)\n    access_token = create_access_token(data={\"sub\": user.username}, expires_delta=access_token_expires)\n\n    return LoginResponse(\n        access_token=access_token,\n        token_type=\"bearer\",\n        user=_user_to_response(user),\n    )\n\n\n@router.get(\"/me\", response_model=UserResponse)\nasync def get_current_user_info(\n    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n    x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get current user information.\n\n    Accepts JWT tokens (via Authorization: Bearer header) and API keys\n    (via X-API-Key header or Authorization: Bearer bb_xxx).\n    API keys return a synthetic admin user with all permissions.\n    \"\"\"\n    import jwt\n    from jwt.exceptions import PyJWTError as JWTError\n\n    # Check for API key via X-API-Key header\n    if x_api_key:\n        api_key = await _validate_api_key(db, x_api_key)\n        if api_key:\n            return _api_key_to_user_response(api_key)\n\n    # Check for Bearer token (could be JWT or API key)\n    if credentials is not None:\n        token = credentials.credentials\n        # Check if it's an API key (starts with bb_)\n        if token.startswith(\"bb_\"):\n            api_key = await _validate_api_key(db, token)\n            if api_key:\n                return _api_key_to_user_response(api_key)\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Invalid API key\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n        # Otherwise treat as JWT\n        try:\n            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n            username: str = payload.get(\"sub\")\n            if username is None:\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED,\n                    detail=\"Could not validate credentials\",\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n            jti: str | None = payload.get(\"jti\")\n            if not jti or await is_jti_revoked(jti):  # B1: logout bypass fix\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED,\n                    detail=\"Could not validate credentials\",\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n            iat: int | float | None = payload.get(\"iat\")\n        except JWTError:\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Could not validate credentials\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n        user = await get_user_by_username(db, username)\n        if user is None or not user.is_active:\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Could not validate credentials\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n        # Reload with groups for proper permission calculation\n        result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))\n        user = result.scalar_one()\n        # L-R8-A: reject tokens issued before the last password change\n        if not _is_token_fresh(iat, user):\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Could not validate credentials\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n        return _user_to_response(user)\n\n    # No credentials provided\n    raise HTTPException(\n        status_code=status.HTTP_401_UNAUTHORIZED,\n        detail=\"Authentication required\",\n        headers={\"WWW-Authenticate\": \"Bearer\"},\n    )\n\n\n@router.post(\"/logout\")\nasync def logout(\n    raw_request: Request,\n    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n):\n    \"\"\"Logout — revokes the current JWT so it cannot be reused after logout.\"\"\"\n    if credentials is not None:\n        raw_token = credentials.credentials\n        # Nit2: Verify signature before revoking to prevent DoS-revoke attacks\n        # (an attacker crafting a token with an arbitrary jti cannot force\n        # revocation of a legitimate token because the signature check rejects it).\n        # Expired tokens are still accepted — the user is logging out and their\n        # token may have just expired; we still want to record the revocation.\n        try:\n            verified = _jwt.decode(\n                raw_token,\n                SECRET_KEY,\n                algorithms=[ALGORITHM],\n                options={\"verify_exp\": False},  # allow expired tokens at logout\n            )\n            jti: str | None = verified.get(\"jti\")\n            exp = verified.get(\"exp\")\n            username: str | None = verified.get(\"sub\")\n            if jti and exp:\n                expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)\n                try:\n                    await revoke_jti(jti, expires_at, username)\n                except Exception as exc:\n                    _logger.error(\"Failed to revoke JTI on logout for user %s: %s\", username, exc)\n        except PyJWTError:\n            client_ip = _get_client_ip(raw_request)\n            ua = raw_request.headers.get(\"user-agent\", \"<unknown>\")\n            _logger.error(\n                \"Logout received token that failed signature verification — skipping revocation \"\n                \"(possible tamper attempt; ip=%s ua=%s)\",\n                client_ip,\n                ua,\n            )\n\n    return {\"message\": \"Logged out successfully\"}\n\n\n# Advanced Authentication Endpoints\n\n\n@router.post(\"/smtp/test\", response_model=TestSMTPResponse)\nasync def test_smtp_connection(\n    test_request: TestSMTPRequest,\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Test SMTP connection using saved settings (admin only when auth enabled).\"\"\"\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    try:\n        smtp_settings = await get_smtp_settings(db)\n        if not smtp_settings:\n            return TestSMTPResponse(success=False, message=\"SMTP settings not configured. Save SMTP settings first.\")\n\n        # Send test email\n        send_email(\n            smtp_settings=smtp_settings,\n            to_email=test_request.test_recipient,\n            subject=\"BamBuddy SMTP Test\",\n            body_text=\"This is a test email from BamBuddy. If you received this, your SMTP settings are working correctly!\",\n            body_html=\"<p>This is a test email from <strong>BamBuddy</strong>.</p><p>If you received this, your SMTP settings are working correctly!</p>\",\n        )\n\n        logger.info(f\"Test email sent successfully to {test_request.test_recipient}\")\n        return TestSMTPResponse(success=True, message=\"Test email sent successfully\")\n    except Exception as e:\n        logger.error(\"Failed to send test email: %s\", e)\n        return TestSMTPResponse(success=False, message=\"Failed to send test email\")\n\n\n@router.get(\"/smtp\", response_model=SMTPSettings | None)\nasync def get_smtp_config(\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get SMTP settings (admin only when auth enabled). Password is not returned.\"\"\"\n    smtp_settings = await get_smtp_settings(db)\n    if smtp_settings:\n        # Don't return password in response\n        smtp_settings.smtp_password = None\n    return smtp_settings\n\n\n@router.post(\"/smtp\", response_model=dict)\nasync def save_smtp_config(\n    smtp_settings: SMTPSettings,\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Save SMTP settings (admin only when auth enabled).\"\"\"\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    try:\n        await save_smtp_settings(db, smtp_settings)\n        await db.commit()\n        logger.info(f\"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}\")\n        return {\"message\": \"SMTP settings saved successfully\"}\n    except Exception as e:\n        await db.rollback()\n        logger.error(\"Failed to save SMTP settings: %s\", e)\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Failed to save SMTP settings\",\n        )\n\n\n@router.post(\"/advanced-auth/enable\", response_model=dict)\nasync def enable_advanced_auth(\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Enable advanced authentication (admin only).\n\n    Requires SMTP settings to be configured and tested first.\n    \"\"\"\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    # Reload user with groups for proper is_admin check\n    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))\n    user = result.scalar_one()\n\n    if not user.is_admin:\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=\"Only admins can enable advanced authentication\",\n        )\n\n    # Verify SMTP settings are configured\n    smtp_settings = await get_smtp_settings(db)\n    if not smtp_settings:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"SMTP settings must be configured before enabling advanced authentication\",\n        )\n\n    try:\n        await set_advanced_auth_enabled(db, True)\n        await db.commit()\n        logger.info(f\"Advanced authentication enabled by admin user: {user.username}\")\n        return {\"message\": \"Advanced authentication enabled successfully\", \"advanced_auth_enabled\": True}\n    except Exception as e:\n        await db.rollback()\n        logger.error(\"Failed to enable advanced authentication: %s\", e)\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Failed to enable advanced authentication\",\n        )\n\n\n@router.post(\"/advanced-auth/disable\", response_model=dict)\nasync def disable_advanced_auth(\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Disable advanced authentication (admin only).\"\"\"\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    # Reload user with groups for proper is_admin check\n    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))\n    user = result.scalar_one()\n\n    if not user.is_admin:\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=\"Only admins can disable advanced authentication\",\n        )\n\n    try:\n        await set_advanced_auth_enabled(db, False)\n        await db.commit()\n        logger.info(f\"Advanced authentication disabled by admin user: {user.username}\")\n        return {\"message\": \"Advanced authentication disabled successfully\", \"advanced_auth_enabled\": False}\n    except Exception as e:\n        await db.rollback()\n        logger.error(\"Failed to disable advanced authentication: %s\", e)\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Failed to disable advanced authentication\",\n        )\n\n\n@router.get(\"/advanced-auth/status\")\nasync def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):\n    \"\"\"Get advanced authentication status.\"\"\"\n    advanced_auth_enabled = await is_advanced_auth_enabled(db)\n    smtp_configured = await get_smtp_settings(db) is not None\n    return {\n        \"advanced_auth_enabled\": advanced_auth_enabled,\n        \"smtp_configured\": smtp_configured,\n    }\n\n\n# TTL for password-reset tokens (H-6)\n_RESET_TOKEN_TTL = timedelta(hours=1)\n\n# Rate-limit for password-reset email sends per identifier (M-A)\n_MAX_PWD_RESET_SENDS = 3\n_PWD_RESET_SEND_WINDOW = timedelta(minutes=15)\n# L-NEW-6: per-IP cap to prevent mass-reset flooding across many addresses\n_MAX_PWD_RESET_SENDS_PER_IP = 10\n\n\nasync def _send_reset_email_or_delete_token(\n    reset_token: str,\n    smtp_settings,\n    to_email: str,\n    subject: str,\n    text_body: str,\n    html_body: str,\n    log_label: str,\n) -> None:\n    \"\"\"Background task: send a password-reset email and delete the token on failure.\n\n    C1: FastAPI silently swallows BackgroundTask exceptions.  This wrapper\n    catches send failures, deletes the single-use token so it cannot be used\n    (user is not locked out forever — they can request a new link), and logs at\n    ERROR so operators are alerted without leaking details to the caller.\n    \"\"\"\n    try:\n        send_email(smtp_settings, to_email, subject, text_body, html_body)\n        _logger.info(\"Password reset email sent (%s) to %s\", log_label, to_email)\n    except Exception as exc:\n        _logger.error(\n            \"Password reset email failed (%s) to %s — deleting token to unblock re-request: %s\",\n            log_label,\n            to_email,\n            exc,\n        )\n        try:\n            async with async_session() as db:\n                await db.execute(\n                    delete(AuthEphemeralToken).where(\n                        AuthEphemeralToken.token == reset_token,\n                        AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,\n                    )\n                )\n                await db.commit()\n        except Exception as db_exc:\n            _logger.error(\"Failed to delete reset token after send failure: %s\", db_exc)\n\n\n@router.post(\"/forgot-password\", response_model=ForgotPasswordResponse)\nasync def forgot_password(\n    request: ForgotPasswordRequest,\n    background_tasks: BackgroundTasks,\n    raw_request: Request,\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Request password reset via email (advanced auth only).\n\n    H-6: Issues a short-lived single-use reset token and emails the user a\n    secure link instead of a plaintext temporary password.  The new password is\n    set only when the user clicks the link and POSTs to /forgot-password/confirm.\n    \"\"\"\n    # Check if advanced auth is enabled\n    advanced_auth = await is_advanced_auth_enabled(db)\n    if not advanced_auth:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Advanced authentication is not enabled\",\n        )\n\n    # M-A: Rate-limit by normalised email to prevent reset-email flooding.\n    # Apply unconditionally (before the user lookup) so unknown emails are also\n    # throttled — this prevents both flooding and timing-based enumeration.\n    identifier = request.email.lower()\n    cutoff = datetime.now(timezone.utc) - _PWD_RESET_SEND_WINDOW\n    rate_result = await db.execute(\n        select(AuthRateLimitEvent).where(\n            AuthRateLimitEvent.username == identifier,\n            AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_SEND,\n            AuthRateLimitEvent.occurred_at > cutoff,\n        )\n    )\n    if len(rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS:\n        raise HTTPException(\n            status_code=status.HTTP_429_TOO_MANY_REQUESTS,\n            detail=f\"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.\",\n        )\n\n    # L-NEW-6: per-IP rate limit — prevents mass-reset flooding across many\n    # different email addresses from a single source IP.\n    client_ip = _get_client_ip(raw_request)\n    ip_rate_result = await db.execute(\n        select(AuthRateLimitEvent).where(\n            AuthRateLimitEvent.username == client_ip,\n            AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_IP,\n            AuthRateLimitEvent.occurred_at > cutoff,\n        )\n    )\n    if len(ip_rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS_PER_IP:\n        raise HTTPException(\n            status_code=status.HTTP_429_TOO_MANY_REQUESTS,\n            detail=f\"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.\",\n        )\n\n    # Nit7: Always record the IP-level event (prevents spray attacks across many\n    # different email addresses from one IP).  The email-level event is only\n    # recorded when we actually send an email to a local user — LDAP/OIDC users\n    # do not consume a slot because this flow is a no-op for them.\n    db.add(AuthRateLimitEvent(username=client_ip, event_type=EventType.PASSWORD_RESET_IP))\n    await db.commit()\n\n    # Get SMTP settings\n    smtp_settings = await get_smtp_settings(db)\n    if not smtp_settings:\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Email service is not configured\",\n        )\n\n    # Find user by email — always return success to prevent email enumeration.\n    user = await get_user_by_email(db, request.email)\n\n    # M-1: exclude LDAP and OIDC users — they must use their respective provider.\n    if user and user.is_active and user.auth_source not in (\"ldap\", \"oidc\"):\n        try:\n            # Record email-level slot only for local users who will actually receive\n            # the reset email (Nit7: don't waste the user's quota for LDAP/OIDC no-ops).\n            db.add(AuthRateLimitEvent(username=identifier, event_type=EventType.PASSWORD_RESET_SEND))\n\n            now = datetime.now(timezone.utc)\n            # Prune any outstanding reset tokens for this user before issuing a new one.\n            await db.execute(\n                delete(AuthEphemeralToken).where(\n                    AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,\n                    AuthEphemeralToken.username == user.username,\n                )\n            )\n            reset_token = secrets.token_urlsafe(32)\n            db.add(\n                AuthEphemeralToken(\n                    token=reset_token,\n                    token_type=TokenType.PASSWORD_RESET,\n                    username=user.username,\n                    expires_at=now + _RESET_TOKEN_TTL,\n                )\n            )\n            await db.commit()\n\n            login_url = await get_external_login_url(db)\n            # M-B: Deliver token in the URL fragment so it never reaches the server\n            # in access-logs or Referer headers (mirrors H-4 for the OIDC token).\n            reset_url = f\"{login_url}#reset_token={reset_token}\"\n\n            subject, text_body, html_body = await create_password_reset_link_email_from_template(\n                db, user.username, reset_url\n            )\n            # L-R9-B: send asynchronously so response time is independent of\n            # whether the user exists (prevents email-existence timing oracle).\n            # C1: wrapper deletes the token if SMTP fails so the user can re-request.\n            background_tasks.add_task(\n                _send_reset_email_or_delete_token,\n                reset_token,\n                smtp_settings,\n                user.email,\n                subject,\n                text_body,\n                html_body,\n                \"forgot_password\",\n            )\n            _logger.info(\"Password reset email queued for %s\", user.email)\n        except Exception as e:\n            _logger.error(\"Failed to send password reset email: %s\", e)\n            # Don't reveal error to caller for security\n\n    return ForgotPasswordResponse(\n        message=\"If the email address is associated with an account, a password reset email has been sent.\"\n    )\n\n\n@router.post(\"/forgot-password/confirm\", response_model=ForgotPasswordResponse)\nasync def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db)):\n    \"\"\"Complete a password reset by supplying the token from the reset email.\n\n    H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets\n    the new password.  Expired or already-used tokens are silently rejected with\n    the same response to prevent oracle attacks.\n    \"\"\"\n    now = datetime.now(timezone.utc)\n    result = await db.execute(\n        delete(AuthEphemeralToken)\n        .where(\n            AuthEphemeralToken.token == request.token,\n            AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,\n        )\n        .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at)\n    )\n    row = result.one_or_none()\n    await db.commit()\n    if row is None:\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid or expired password reset token\")\n\n    username, expires_at = row\n    # SQLite returns naive datetimes; treat them as UTC.\n    if expires_at.tzinfo is None:\n        expires_at = expires_at.replace(tzinfo=timezone.utc)\n    if now > expires_at:\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid or expired password reset token\")\n\n    user = await get_user_by_username(db, username)\n    # M-1: block LDAP/OIDC users — they authenticate via their provider, not local password.\n    if not user or not user.is_active or user.auth_source in (\"ldap\", \"oidc\"):\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid or expired password reset token\")\n\n    user.password_hash = get_password_hash(request.new_password)\n    user.password_changed_at = now  # M-R7-B: invalidate all prior JWTs\n    await db.commit()\n    _logger.info(\"Password reset completed for user '%s'\", username)\n\n    return ForgotPasswordResponse(message=\"Password has been reset successfully.\")\n\n\n@router.post(\"/reset-password\", response_model=ResetPasswordResponse)\nasync def reset_user_password(\n    request: ResetPasswordRequest,\n    background_tasks: BackgroundTasks,\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Reset a user's password and send them an email (admin only, advanced auth only).\"\"\"\n    # Reload user with groups for proper is_admin check\n    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))\n    admin_user = result.scalar_one()\n\n    if not admin_user.is_admin:\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=\"Only admins can reset user passwords\",\n        )\n\n    # Check if advanced auth is enabled\n    advanced_auth = await is_advanced_auth_enabled(db)\n    if not advanced_auth:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Advanced authentication is not enabled\",\n        )\n\n    # Get SMTP settings\n    smtp_settings = await get_smtp_settings(db)\n    if not smtp_settings:\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Email service is not configured\",\n        )\n\n    # Find user to reset\n    result = await db.execute(select(User).where(User.id == request.user_id))\n    user = result.scalar_one_or_none()\n    if not user:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"User not found\",\n        )\n\n    # M-1: block LDAP/OIDC users — passwords are managed by their respective providers.\n    if user.auth_source in (\"ldap\", \"oidc\"):\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Cannot reset password for LDAP/OIDC users — authentication is managed by their provider\",\n        )\n\n    if not user.email:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"User does not have an email address configured\",\n        )\n\n    try:\n        # H-B: Issue a single-use reset link instead of generating a plaintext password.\n        # The admin never sees the credential — the user sets their own password.\n        now = datetime.now(timezone.utc)\n        await db.execute(\n            delete(AuthEphemeralToken).where(\n                AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,\n                AuthEphemeralToken.username == user.username,\n            )\n        )\n        reset_token = secrets.token_urlsafe(32)\n        db.add(\n            AuthEphemeralToken(\n                token=reset_token,\n                token_type=TokenType.PASSWORD_RESET,\n                username=user.username,\n                expires_at=now + _RESET_TOKEN_TTL,\n            )\n        )\n        await db.commit()\n\n        login_url = await get_external_login_url(db)\n        reset_url = f\"{login_url}#reset_token={reset_token}\"\n\n        subject, text_body, html_body = await create_password_reset_link_email_from_template(\n            db, user.username, reset_url\n        )\n        background_tasks.add_task(\n            _send_reset_email_or_delete_token,\n            reset_token,\n            smtp_settings,\n            user.email,\n            subject,\n            text_body,\n            html_body,\n            \"admin_reset\",\n        )\n\n        _logger.info(\"Admin password reset link queued for user '%s' by admin '%s'\", user.username, admin_user.username)\n        return ResetPasswordResponse(message=f\"Password reset link sent to {user.email}\")\n    except Exception as e:\n        await db.rollback()\n        _logger.error(\"Failed to send admin password reset for user '%s': %s\", user.username, e)\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=\"Failed to send password reset link. Check server logs.\",  # L-R7-B: no internal details\n        )\n\n\n# LDAP Authentication Helpers\n\n\nasync def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:\n    \"\"\"Get LDAP settings from the database. Returns None if LDAP is not enabled.\"\"\"\n    ldap_keys = [\n        \"ldap_enabled\",\n        \"ldap_server_url\",\n        \"ldap_bind_dn\",\n        \"ldap_bind_password\",\n        \"ldap_search_base\",\n        \"ldap_user_filter\",\n        \"ldap_security\",\n        \"ldap_group_mapping\",\n        \"ldap_auto_provision\",\n        \"ldap_ca_cert_path\",\n        \"ldap_default_group\",\n    ]\n    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))\n    settings = {s.key: s.value for s in result.scalars().all()}\n    if settings.get(\"ldap_enabled\", \"false\").lower() != \"true\":\n        return None\n    return settings\n\n\nasync def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User:\n    \"\"\"Create a new local user from LDAP authentication.\"\"\"\n    import logging\n\n    from backend.app.services.ldap_service import resolve_group_mapping\n\n    logger = logging.getLogger(__name__)\n\n    new_user = User(\n        username=ldap_user.username,\n        email=ldap_user.email,\n        password_hash=None,\n        role=\"user\",\n        auth_source=\"ldap\",\n        is_active=True,\n    )\n\n    # Map LDAP groups to BamBuddy groups, falling back to the configured default group\n    # when the user is authenticated but has no matching group mapping (#921-follow-up).\n    mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)\n    if not mapped_group_names and ldap_config.default_group:\n        mapped_group_names = [ldap_config.default_group]\n        logger.warning(\n            \"LDAP user %s has no mapped groups — assigning configured default group '%s'\",\n            ldap_user.username,\n            ldap_config.default_group,\n        )\n    if mapped_group_names:\n        groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))\n        new_user.groups = list(groups_result.scalars().all())\n\n    db.add(new_user)\n    await db.commit()\n    await db.refresh(new_user)\n    logger.info(\"Auto-provisioned LDAP user: %s (groups: %s)\", new_user.username, mapped_group_names)\n    return new_user\n\n\nasync def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config) -> None:\n    \"\"\"Sync LDAP user attributes (email, groups) on each login.\"\"\"\n    import logging\n\n    from backend.app.services.ldap_service import resolve_group_mapping\n\n    logger = logging.getLogger(__name__)\n\n    changed = False\n\n    # Update email if changed\n    if ldap_user.email and ldap_user.email != user.email:\n        user.email = ldap_user.email\n        changed = True\n\n    # Sync group mappings — always update to match LDAP state (including revocation).\n    # Fall back to the configured default group when the user has no mapped groups,\n    # so authenticated LDAP users are never left permission-less.\n    mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)\n    if not mapped_group_names and ldap_config.default_group:\n        mapped_group_names = [ldap_config.default_group]\n        logger.warning(\n            \"LDAP user %s has no mapped groups — assigning configured default group '%s'\",\n            user.username,\n            ldap_config.default_group,\n        )\n    if mapped_group_names:\n        groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))\n        new_groups = list(groups_result.scalars().all())\n    else:\n        new_groups = []\n    current_group_ids = {g.id for g in user.groups}\n    new_group_ids = {g.id for g in new_groups}\n    if current_group_ids != new_group_ids:\n        user.groups = new_groups\n        changed = True\n\n    if changed:\n        await db.commit()\n        logger.info(\"Synced LDAP user attributes: %s\", user.username)\n\n\n@router.post(\"/ldap/test\")\nasync def test_ldap(\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Test LDAP connection using saved settings (admin only when auth enabled).\"\"\"\n    import logging\n\n    from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection\n\n    logger = logging.getLogger(__name__)\n\n    ldap_settings = await _get_ldap_settings(db)\n    if not ldap_settings:\n        # LDAP might not be enabled yet but settings might still exist — read all keys\n        ldap_keys = [\n            \"ldap_enabled\",\n            \"ldap_server_url\",\n            \"ldap_bind_dn\",\n            \"ldap_bind_password\",\n            \"ldap_search_base\",\n            \"ldap_user_filter\",\n            \"ldap_security\",\n            \"ldap_group_mapping\",\n            \"ldap_auto_provision\",\n        ]\n        result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))\n        ldap_settings = {s.key: s.value for s in result.scalars().all()}\n        # Force enabled for test\n        ldap_settings[\"ldap_enabled\"] = \"true\"\n\n    config = parse_ldap_config(ldap_settings)\n    if not config:\n        return {\"success\": False, \"message\": \"LDAP server URL is not configured\"}\n\n    success, message = test_ldap_connection(config)\n    if success:\n        logger.info(\"LDAP connection test successful\")\n    else:\n        logger.warning(\"LDAP connection test failed: %s\", message)\n    return {\"success\": success, \"message\": message}\n\n\n@router.get(\"/ldap/status\")\nasync def get_ldap_status(db: AsyncSession = Depends(get_db)):\n    \"\"\"Get LDAP authentication status.\"\"\"\n    # Only fetch the minimum keys needed — never load secrets\n    ldap_keys = [\"ldap_enabled\", \"ldap_server_url\"]\n    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))\n    settings = {s.key: s.value for s in result.scalars().all()}\n    return {\n        \"ldap_enabled\": settings.get(\"ldap_enabled\", \"false\").lower() == \"true\",\n        \"ldap_configured\": bool(settings.get(\"ldap_server_url\")),\n    }\n"
  },
  {
    "path": "backend/app/api/routes/background_dispatch.py",
    "content": "from fastapi import APIRouter, HTTPException\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.user import User\nfrom backend.app.services.background_dispatch import background_dispatch\n\nrouter = APIRouter(prefix=\"/background-dispatch\", tags=[\"background-dispatch\"])\n\n\n@router.delete(\"/{job_id}\")\nasync def cancel_dispatch_job(\n    job_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n):\n    \"\"\"Cancel a background-dispatch job.\n\n    Queued jobs are cancelled immediately. Active jobs are marked for\n    cooperative cancellation and will stop at the next cancellation checkpoint.\n    \"\"\"\n    result = await background_dispatch.cancel_job(job_id)\n\n    if not result[\"cancelled\"]:\n        raise HTTPException(status_code=404, detail=\"Dispatch job not found\")\n\n    return {\n        \"status\": \"cancelling\" if result.get(\"pending\") else \"cancelled\",\n        \"job_id\": result[\"job_id\"],\n        \"source_name\": result[\"source_name\"],\n        \"printer_id\": result[\"printer_id\"],\n        \"printer_name\": result[\"printer_name\"],\n    }\n"
  },
  {
    "path": "backend/app/api/routes/bug_report.py",
    "content": "\"\"\"Bug report endpoint for submitting user bug reports to GitHub.\"\"\"\n\nimport logging\n\nfrom fastapi import APIRouter, Query\nfrom pydantic import BaseModel\n\nfrom backend.app.api.routes.support import (\n    _apply_log_level,\n    _collect_support_info,\n    _get_debug_setting,\n    _get_recent_sanitized_logs,\n    _set_debug_setting,\n)\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import async_session\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.user import User\nfrom backend.app.services.bug_report import submit_report\nfrom backend.app.services.printer_manager import printer_manager\n\nrouter = APIRouter(prefix=\"/bug-report\", tags=[\"bug-report\"])\nlogger = logging.getLogger(__name__)\n\n\nclass BugReportRequest(BaseModel):\n    description: str\n    email: str | None = None\n    screenshot_base64: str | None = None\n    include_support_info: bool = True\n    debug_logs: str | None = None\n\n\nclass BugReportResponse(BaseModel):\n    success: bool\n    message: str\n    issue_url: str | None = None\n    issue_number: int | None = None\n\n\nclass StartLoggingResponse(BaseModel):\n    started: bool\n    was_debug: bool\n\n\nclass StopLoggingResponse(BaseModel):\n    logs: str\n\n\n@router.post(\"/start-logging\", response_model=StartLoggingResponse)\nasync def start_logging(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Enable debug logging and push all printers for fresh data.\"\"\"\n    async with async_session() as db:\n        was_debug, _ = await _get_debug_setting(db)\n\n    if not was_debug:\n        async with async_session() as db:\n            await _set_debug_setting(db, True)\n        _apply_log_level(True)\n        logger.info(\"Bug report: enabled debug logging\")\n\n    for printer_id in list(printer_manager._clients.keys()):\n        try:\n            printer_manager.request_status_update(printer_id)\n        except Exception:\n            logger.debug(\"Failed to push_all for printer %s\", printer_id)\n\n    return StartLoggingResponse(started=True, was_debug=was_debug)\n\n\n@router.post(\"/stop-logging\", response_model=StopLoggingResponse)\nasync def stop_logging(\n    was_debug: bool = Query(default=False),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Collect logs and restore previous log level.\"\"\"\n    logs = await _get_recent_sanitized_logs()\n\n    if not was_debug:\n        async with async_session() as db:\n            await _set_debug_setting(db, False)\n        _apply_log_level(False)\n        logger.info(\"Bug report: restored normal logging\")\n\n    return StopLoggingResponse(logs=logs)\n\n\n@router.post(\"/submit\", response_model=BugReportResponse)\nasync def submit_bug_report(\n    report: BugReportRequest,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Submit a bug report. Requires auth when authentication is enabled.\"\"\"\n    support_info = None\n    if report.include_support_info:\n        try:\n            support_info = await _collect_support_info()\n            if report.debug_logs:\n                support_info[\"recent_logs\"] = report.debug_logs\n        except Exception:\n            logger.exception(\"Failed to collect support info for bug report\")\n\n    result = await submit_report(\n        description=report.description,\n        reporter_email=report.email,\n        screenshot_base64=report.screenshot_base64,\n        support_info=support_info,\n    )\n    return BugReportResponse(**result)\n"
  },
  {
    "path": "backend/app/api/routes/camera.py",
    "content": "\"\"\"Camera streaming API endpoints for Bambu Lab printers.\"\"\"\n\nimport asyncio\nimport logging\nimport os\nimport subprocess\nimport sys\nfrom collections.abc import AsyncGenerator\n\nfrom fastapi import APIRouter, Depends, HTTPException, Request\nfrom fastapi.responses import Response, StreamingResponse\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import (\n    RequireCameraStreamTokenIfAuthEnabled,\n    RequirePermissionIfAuthEnabled,\n    create_camera_stream_token,\n)\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.user import User\nfrom backend.app.services.camera import (\n    capture_camera_frame,\n    create_tls_proxy,\n    generate_chamber_image_stream,\n    get_camera_port,\n    get_ffmpeg_path,\n    is_chamber_image_model,\n    read_next_chamber_frame,\n    test_camera_connection,\n)\n\nlogger = logging.getLogger(__name__)\nrouter = APIRouter(prefix=\"/printers\", tags=[\"camera\"])\n\n# Track active ffmpeg processes for cleanup\n_active_streams: dict[str, asyncio.subprocess.Process] = {}\n\n# Track active chamber image connections for cleanup\n_active_chamber_streams: dict[str, tuple] = {}\n\n# Store last frame for each printer (for photo capture from active stream)\n_last_frames: dict[int, bytes] = {}\n\n# Track last frame timestamp for each printer (for stall detection)\n_last_frame_times: dict[int, float] = {}\n\n# Track stream start times for each printer\n_stream_start_times: dict[int, float] = {}\n\n# Track active external camera streams by printer ID\n_active_external_streams: set[int] = set()\n\n# Track ALL spawned ffmpeg PIDs (persists even if _active_streams entries are removed)\n# Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes\n_spawned_ffmpeg_pids: dict[int, float] = {}\n\n# Track disconnect events per stream_id — allows stop endpoint and cleanup\n# to signal generators to stop reconnecting instead of just killing the process\n_disconnect_events: dict[str, asyncio.Event] = {}\n\n# Track last frame time per stream_id (not just per printer_id) for stale detection\n_stream_last_frame_times: dict[str, float] = {}\n\n\ndef get_buffered_frame(printer_id: int) -> bytes | None:\n    \"\"\"Get the last buffered frame for a printer from an active stream.\n\n    Returns the JPEG frame data if available, or None if no active stream.\n    \"\"\"\n    return _last_frames.get(printer_id)\n\n\nasync def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:\n    \"\"\"Get printer by ID or raise 404.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n    return printer\n\n\nasync def generate_chamber_mjpeg_stream(\n    ip_address: str,\n    access_code: str,\n    model: str | None,\n    fps: int = 5,\n    stream_id: str | None = None,\n    disconnect_event: asyncio.Event | None = None,\n    printer_id: int | None = None,\n) -> AsyncGenerator[bytes, None]:\n    \"\"\"Generate MJPEG stream from A1/P1 printer using chamber image protocol.\n\n    This connects to port 6000 and reads JPEG frames using the Bambu binary protocol.\n    \"\"\"\n    logger.info(\"Starting chamber image stream for %s (stream_id=%s, model=%s)\", ip_address, stream_id, model)\n\n    # Register disconnect event so stop endpoint can signal us\n    if stream_id and disconnect_event:\n        _disconnect_events[stream_id] = disconnect_event\n\n    connection = await generate_chamber_image_stream(ip_address, access_code, fps)\n    if connection is None:\n        logger.error(\"Failed to connect to chamber image stream for %s\", ip_address)\n        yield (\n            b\"--frame\\r\\n\"\n            b\"Content-Type: text/plain\\r\\n\\r\\n\"\n            b\"Error: Camera connection failed. Check printer is on and camera is enabled.\\r\\n\"\n        )\n        return\n\n    reader, writer = connection\n\n    # Track active connection for cleanup\n    if stream_id:\n        _active_chamber_streams[stream_id] = (reader, writer)\n\n    try:\n        frame_interval = 1.0 / fps if fps > 0 else 0.2\n        last_frame_time = 0.0\n\n        while True:\n            # Check if client disconnected\n            if disconnect_event and disconnect_event.is_set():\n                logger.info(\"Client disconnected, stopping chamber stream %s\", stream_id)\n                break\n\n            # Read next frame\n            frame = await read_next_chamber_frame(reader, timeout=30.0)\n            if frame is None:\n                logger.warning(\"Chamber image stream ended for %s\", stream_id)\n                break\n\n            # Save frame to buffer for photo capture and track timestamp\n            if printer_id is not None:\n                import time\n\n                _last_frames[printer_id] = frame\n                _last_frame_times[printer_id] = time.time()\n\n            # Rate limiting - skip frames if needed to maintain target FPS\n            current_time = asyncio.get_event_loop().time()\n            if current_time - last_frame_time < frame_interval:\n                continue\n            last_frame_time = current_time\n\n            # Yield frame in MJPEG format\n            yield (\n                b\"--frame\\r\\n\"\n                b\"Content-Type: image/jpeg\\r\\n\"\n                b\"Content-Length: \" + str(len(frame)).encode() + b\"\\r\\n\"\n                b\"\\r\\n\" + frame + b\"\\r\\n\"\n            )\n\n    except asyncio.CancelledError:\n        logger.info(\"Chamber image stream cancelled (stream_id=%s)\", stream_id)\n    except GeneratorExit:\n        logger.info(\"Chamber image stream generator exit (stream_id=%s)\", stream_id)\n    except Exception as e:\n        logger.exception(\"Chamber image stream error: %s\", e)\n    finally:\n        # Remove from active streams and disconnect events\n        if stream_id:\n            _active_chamber_streams.pop(stream_id, None)\n            _disconnect_events.pop(stream_id, None)\n            _stream_last_frame_times.pop(stream_id, None)\n\n        # Clean up frame buffer and timestamps\n        if printer_id is not None:\n            _last_frames.pop(printer_id, None)\n            _last_frame_times.pop(printer_id, None)\n            _stream_start_times.pop(printer_id, None)\n\n        # Close the connection\n        try:\n            writer.close()\n            await writer.wait_closed()\n        except OSError:\n            pass  # Connection already closed or broken; cleanup is best-effort\n        logger.info(\"Chamber image stream stopped for %s (stream_id=%s)\", ip_address, stream_id)\n\n\nasync def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str | None = None) -> None:\n    \"\"\"Terminate an ffmpeg process gracefully, then kill if needed.\"\"\"\n    if process.returncode is not None:\n        return  # Already dead\n    try:\n        process.terminate()\n        try:\n            await asyncio.wait_for(process.wait(), timeout=2.0)\n        except TimeoutError:\n            logger.warning(\"ffmpeg didn't terminate gracefully, killing (stream_id=%s)\", stream_id)\n            process.kill()\n            await process.wait()\n    except ProcessLookupError:\n        pass  # Already dead\n    except OSError as e:\n        logger.warning(\"Error terminating ffmpeg: %s\", e)\n    _spawned_ffmpeg_pids.pop(process.pid, None)\n\n\ndef _summarize_ffmpeg_stderr(text: str | None) -> str:\n    \"\"\"Strip ffmpeg's boilerplate banner and keep only actionable lines.\n\n    ffmpeg prints ~20 lines of version/build/configuration/lib headers before\n    any actual error message. Logging the full banner on every retry floods\n    the log (hundreds of lines per failed stream). This filter drops the\n    banner and caps output at the last 10 meaningful lines.\n    \"\"\"\n    if not text:\n        return \"\"\n    banner_prefixes = (\n        \"ffmpeg version \",\n        \"  built with \",\n        \"  configuration:\",\n        \"  libavutil \",\n        \"  libavcodec \",\n        \"  libavformat \",\n        \"  libavdevice \",\n        \"  libavfilter \",\n        \"  libswscale \",\n        \"  libswresample \",\n        \"  libpostproc \",\n    )\n    meaningful = [ln for ln in text.splitlines() if ln.strip() and not ln.startswith(banner_prefixes)]\n    return \"\\n\".join(meaningful[-10:])\n\n\nasync def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:\n    \"\"\"Read ffmpeg stderr for diagnostics (best-effort, non-blocking).\n\n    Returns the stderr content with ffmpeg's boilerplate banner stripped,\n    so log output stays focused on the actual error.\n    \"\"\"\n    if not process or not process.stderr:\n        return None\n    try:\n        data = await asyncio.wait_for(process.stderr.read(), timeout=2.0)\n        if not data:\n            return None\n        return _summarize_ffmpeg_stderr(data.decode(errors=\"replace\")) or None\n    except (TimeoutError, Exception):\n        return None\n\n\n# Max consecutive RTSP reconnections before giving up.\n# Some printer firmwares (notably P2S) drop RTSP sessions after a few seconds,\n# so we transparently respawn ffmpeg to keep the MJPEG stream alive.\n_RTSP_MAX_RECONNECTS = 30\n_RTSP_RECONNECT_DELAY = 0.2  # seconds between respawns\n\n\nasync def generate_rtsp_mjpeg_stream(\n    ip_address: str,\n    access_code: str,\n    model: str | None,\n    fps: int = 10,\n    stream_id: str | None = None,\n    disconnect_event: asyncio.Event | None = None,\n    printer_id: int | None = None,\n) -> AsyncGenerator[bytes, None]:\n    \"\"\"Generate MJPEG stream from printer camera using ffmpeg/RTSP.\n\n    This is for X1/H2/P2 models that support RTSP streaming.\n    Auto-reconnects when the printer drops the RTSP session (common on P2S).\n    \"\"\"\n    ffmpeg = get_ffmpeg_path()\n    if not ffmpeg:\n        logger.error(\"ffmpeg not found - camera streaming requires ffmpeg\")\n        yield (b\"--frame\\r\\nContent-Type: text/plain\\r\\n\\r\\nError: ffmpeg not installed\\r\\n\")\n        return\n\n    port = get_camera_port(model)\n\n    # Use a local TLS proxy so Python's OpenSSL handles TLS instead of\n    # ffmpeg's GnuTLS.  This fixes P2S (and potentially other models)\n    # dropping the RTSP session after a few seconds due to GnuTLS's\n    # hardened Debian defaults rejecting TLS renegotiation.\n    proxy_port, proxy_server = await create_tls_proxy(ip_address, port)\n    camera_url = f\"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1\"\n\n    # ffmpeg command to output MJPEG stream to stdout\n    cmd = [\n        ffmpeg,\n        \"-rtsp_transport\",\n        \"tcp\",\n        \"-rtsp_flags\",\n        \"prefer_tcp\",\n        \"-timeout\",\n        \"30000000\",  # 30 seconds in microseconds\n        \"-buffer_size\",\n        \"1024000\",  # 1MB buffer\n        \"-max_delay\",\n        \"500000\",  # 0.5 seconds max delay\n        \"-probesize\",\n        \"32\",  # Minimal probing for faster startup\n        \"-analyzeduration\",\n        \"0\",  # Skip format analysis for faster startup\n        \"-fflags\",\n        \"nobuffer\",  # Reduce internal buffering\n        \"-flags\",\n        \"low_delay\",  # Minimize decode latency\n        \"-i\",\n        camera_url,\n        \"-f\",\n        \"mjpeg\",\n        \"-q:v\",\n        \"5\",\n        \"-r\",\n        str(fps),\n        \"-an\",  # No audio\n        \"-\",  # Output to stdout\n    ]\n\n    # Register disconnect event so stop endpoint can signal us\n    if stream_id and disconnect_event:\n        _disconnect_events[stream_id] = disconnect_event\n\n    logger.info(\n        \"Starting RTSP camera stream for %s (stream_id=%s, model=%s, fps=%s)\", ip_address, stream_id, model, fps\n    )\n    logger.debug(\"ffmpeg command: %s ... (url hidden)\", ffmpeg)\n\n    # On Windows, spawn ffmpeg in its own process group so that\n    # terminate() doesn't broadcast CTRL_C_EVENT to uvicorn (#605).\n    spawn_kwargs: dict = {}\n    if sys.platform == \"win32\":\n        spawn_kwargs[\"creationflags\"] = subprocess.CREATE_NEW_PROCESS_GROUP\n\n    jpeg_start = b\"\\xff\\xd8\"\n    jpeg_end = b\"\\xff\\xd9\"\n    reconnect_count = 0\n    process = None\n    got_any_frames = False\n\n    try:\n        while reconnect_count <= _RTSP_MAX_RECONNECTS:\n            # Check for client disconnect before (re)connecting\n            if disconnect_event and disconnect_event.is_set():\n                break\n\n            if reconnect_count > 0:\n                logger.info(\n                    \"RTSP reconnecting (%d/%d) for %s (stream_id=%s)\",\n                    reconnect_count,\n                    _RTSP_MAX_RECONNECTS,\n                    ip_address,\n                    stream_id,\n                )\n                await asyncio.sleep(_RTSP_RECONNECT_DELAY)\n                if disconnect_event and disconnect_event.is_set():\n                    break\n\n            # Spawn ffmpeg\n            process = await asyncio.create_subprocess_exec(\n                *cmd,\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n                **spawn_kwargs,\n            )\n\n            if stream_id:\n                _active_streams[stream_id] = process\n            import time as _time\n\n            _spawned_ffmpeg_pids[process.pid] = _time.time()\n\n            # Brief check for immediate startup failures\n            await asyncio.sleep(0.1)\n            if process.returncode is not None:\n                stderr = await process.stderr.read()\n                stderr_text = _summarize_ffmpeg_stderr(stderr.decode(errors=\"replace\"))\n                logger.error(\"ffmpeg failed immediately (attempt %d): %s\", reconnect_count + 1, stderr_text)\n                _spawned_ffmpeg_pids.pop(process.pid, None)\n                if not got_any_frames and reconnect_count == 0:\n                    # First attempt failed immediately — camera is likely unreachable\n                    yield (\n                        b\"--frame\\r\\n\"\n                        b\"Content-Type: text/plain\\r\\n\\r\\n\"\n                        b\"Error: Camera connection failed. Check printer is on and camera is enabled.\\r\\n\"\n                    )\n                    return\n                reconnect_count += 1\n                continue\n\n            # Read JPEG frames from ffmpeg stdout\n            buffer = b\"\"\n            stream_ended = False\n            client_gone = False\n\n            while True:\n                if disconnect_event and disconnect_event.is_set():\n                    client_gone = True\n                    break\n\n                try:\n                    chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)\n\n                    if not chunk:\n                        # ffmpeg exited — log stderr and break to reconnect\n                        stderr_text = await _read_ffmpeg_stderr(process)\n                        if stderr_text:\n                            logger.warning(\"ffmpeg stderr (stream_id=%s): %s\", stream_id, stderr_text)\n                        logger.warning(\"RTSP stream ended for %s (stream_id=%s), will reconnect\", ip_address, stream_id)\n                        stream_ended = True\n                        break\n\n                    buffer += chunk\n\n                    # Extract complete JPEG frames from buffer\n                    while True:\n                        start_idx = buffer.find(jpeg_start)\n                        if start_idx == -1:\n                            buffer = buffer[-2:] if len(buffer) > 2 else buffer\n                            break\n\n                        if start_idx > 0:\n                            buffer = buffer[start_idx:]\n\n                        end_idx = buffer.find(jpeg_end, 2)\n                        if end_idx == -1:\n                            break\n\n                        frame = buffer[: end_idx + 2]\n                        buffer = buffer[end_idx + 2 :]\n                        got_any_frames = True\n\n                        if printer_id is not None:\n                            import time\n\n                            _last_frames[printer_id] = frame\n                            _last_frame_times[printer_id] = time.time()\n                            if stream_id:\n                                _stream_last_frame_times[stream_id] = time.time()\n\n                        yield (\n                            b\"--frame\\r\\n\"\n                            b\"Content-Type: image/jpeg\\r\\n\"\n                            b\"Content-Length: \" + str(len(frame)).encode() + b\"\\r\\n\"\n                            b\"\\r\\n\" + frame + b\"\\r\\n\"\n                        )\n\n                except TimeoutError:\n                    stderr_text = await _read_ffmpeg_stderr(process)\n                    if stderr_text:\n                        logger.warning(\"ffmpeg stderr on timeout: %s\", stderr_text)\n                    logger.warning(\"RTSP read timeout for %s (stream_id=%s)\", ip_address, stream_id)\n                    stream_ended = True\n                    break\n                except asyncio.CancelledError:\n                    logger.info(\"Camera stream cancelled (stream_id=%s)\", stream_id)\n                    client_gone = True\n                    break\n                except GeneratorExit:\n                    logger.info(\"Camera stream generator exit (stream_id=%s)\", stream_id)\n                    client_gone = True\n                    break\n\n            # Clean up this ffmpeg process before reconnecting or exiting\n            await _terminate_ffmpeg(process, stream_id)\n            process = None\n\n            if client_gone:\n                break\n\n            # Check if stream was explicitly stopped (e.g., by stop endpoint)\n            if stream_id and stream_id not in _active_streams:\n                logger.info(\"Stream %s removed from active streams, stopping reconnect\", stream_id)\n                break\n\n            if stream_ended:\n                reconnect_count += 1\n                continue\n\n            # Normal exit (shouldn't reach here, but be safe)\n            break\n\n        if reconnect_count > _RTSP_MAX_RECONNECTS:\n            logger.error(\n                \"RTSP max reconnects (%d) reached for %s (stream_id=%s)\",\n                _RTSP_MAX_RECONNECTS,\n                ip_address,\n                stream_id,\n            )\n\n    except FileNotFoundError:\n        logger.error(\"ffmpeg not found - camera streaming requires ffmpeg\")\n        yield (b\"--frame\\r\\nContent-Type: text/plain\\r\\n\\r\\nError: ffmpeg not installed\\r\\n\")\n    except asyncio.CancelledError:\n        logger.info(\"Camera stream task cancelled (stream_id=%s)\", stream_id)\n    except GeneratorExit:\n        logger.info(\"Camera stream generator closed (stream_id=%s)\", stream_id)\n    except Exception as e:\n        logger.exception(\"Camera stream error: %s\", e)\n    finally:\n        # Remove from active streams and disconnect events\n        if stream_id:\n            _active_streams.pop(stream_id, None)\n            _disconnect_events.pop(stream_id, None)\n            _stream_last_frame_times.pop(stream_id, None)\n\n        # Clean up frame buffer and timestamps\n        if printer_id is not None:\n            _last_frames.pop(printer_id, None)\n            _last_frame_times.pop(printer_id, None)\n            _stream_start_times.pop(printer_id, None)\n\n        if process:\n            await _terminate_ffmpeg(process, stream_id)\n            logger.info(\"Camera stream stopped for %s (stream_id=%s)\", ip_address, stream_id)\n\n        # Shut down the TLS proxy\n        proxy_server.close()\n        await proxy_server.wait_closed()\n\n\n@router.post(\"/camera/stream-token\")\nasync def create_stream_token(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Create a reusable token for camera stream/snapshot access.\n\n    Returns a token valid for 60 minutes that can be appended as ?token=xxx\n    to camera stream/snapshot URLs loaded via <img> tags.\n    \"\"\"\n    return {\"token\": await create_camera_stream_token()}\n\n\n@router.get(\"/{printer_id}/camera/stream\")\nasync def camera_stream(\n    printer_id: int,\n    request: Request,\n    fps: int = 10,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Stream live video from printer camera as MJPEG.\n\n    This endpoint returns a multipart MJPEG stream that can be used directly\n    in an <img> tag or video player.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n\n    Uses external camera if configured, otherwise uses built-in camera:\n    - External: MJPEG, RTSP, or HTTP snapshot\n    - A1/P1: Chamber image protocol (port 6000)\n    - X1/H2/P2: RTSP via ffmpeg (port 322)\n\n    Args:\n        printer_id: Printer ID\n        fps: Target frames per second (default: 10, max: 30)\n    \"\"\"\n    import uuid\n\n    printer = await get_printer_or_404(printer_id, db)\n\n    # Check for external camera first\n    if printer.external_camera_enabled and printer.external_camera_url:\n        import time\n\n        from backend.app.services.external_camera import generate_mjpeg_stream\n\n        # Limit external camera FPS to reduce browser load\n        fps = min(max(fps, 1), 15)\n        logger.info(\n            \"Using external camera (%s) for printer %s at %s fps\", printer.external_camera_type, printer_id, fps\n        )\n\n        # Track stream start\n        _stream_start_times[printer_id] = time.time()\n        _active_external_streams.add(printer_id)\n\n        async def external_stream_wrapper():\n            \"\"\"Wrap external stream to track start/stop and update frame times.\"\"\"\n            try:\n                async for frame in generate_mjpeg_stream(\n                    printer.external_camera_url, printer.external_camera_type, fps\n                ):\n                    # generate_mjpeg_stream already handles rate limiting;\n                    # just track frame times for stall detection\n                    _last_frame_times[printer_id] = time.time()\n                    yield frame\n            finally:\n                _active_external_streams.discard(printer_id)\n                logger.info(\"External camera stream ended for printer %s\", printer_id)\n\n        return StreamingResponse(\n            external_stream_wrapper(),\n            media_type=\"multipart/x-mixed-replace; boundary=frame\",\n            headers={\n                \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\n                \"Pragma\": \"no-cache\",\n                \"Expires\": \"0\",\n            },\n        )\n\n    # Validate FPS - A1/P1 models max out at ~5 FPS\n    if is_chamber_image_model(printer.model):\n        fps = min(max(fps, 1), 5)\n    else:\n        fps = min(max(fps, 1), 30)\n\n    # Generate unique stream ID for tracking\n    stream_id = f\"{printer_id}-{uuid.uuid4().hex[:8]}\"\n\n    # Create disconnect event that will be set when client disconnects\n    disconnect_event = asyncio.Event()\n\n    # Choose the appropriate stream generator based on model\n    if is_chamber_image_model(printer.model):\n        stream_generator = generate_chamber_mjpeg_stream\n        logger.info(\"Using chamber image protocol for %s\", printer.model)\n    else:\n        stream_generator = generate_rtsp_mjpeg_stream\n        logger.info(\"Using RTSP protocol for %s\", printer.model)\n\n    # Track stream start time\n    import time\n\n    _stream_start_times[printer_id] = time.time()\n\n    async def _kill_stream_process(sid: str):\n        \"\"\"Terminate+kill the ffmpeg process for a stream ID.\"\"\"\n        proc = _active_streams.get(sid)\n        if proc and proc.returncode is None:\n            try:\n                proc.terminate()\n                try:\n                    await asyncio.wait_for(proc.wait(), timeout=2.0)\n                except TimeoutError:\n                    proc.kill()\n                    await proc.wait()\n            except (ProcessLookupError, OSError):\n                pass\n\n    async def _monitor_disconnect():\n        \"\"\"Background task: poll for client disconnect independently of frame loop.\"\"\"\n        try:\n            while not disconnect_event.is_set():\n                await asyncio.sleep(2)\n                if await request.is_disconnected():\n                    logger.info(\"Disconnect monitor: client gone (stream %s)\", stream_id)\n                    disconnect_event.set()\n                    # Kill ffmpeg process (RTSP streams)\n                    await _kill_stream_process(stream_id)\n                    # Close chamber stream connection if applicable\n                    chamber = _active_chamber_streams.get(stream_id)\n                    if chamber:\n                        try:\n                            chamber[1].close()\n                        except OSError:\n                            pass\n                    break\n        except asyncio.CancelledError:\n            pass\n\n    monitor_task = asyncio.create_task(_monitor_disconnect())\n\n    async def stream_with_disconnect_check():\n        \"\"\"Wrapper generator that monitors for client disconnect.\"\"\"\n        try:\n            async for chunk in stream_generator(\n                ip_address=printer.ip_address,\n                access_code=printer.access_code,\n                model=printer.model,\n                fps=fps,\n                stream_id=stream_id,\n                disconnect_event=disconnect_event,\n                printer_id=printer_id,\n            ):\n                # Check if client is still connected\n                if disconnect_event.is_set() or await request.is_disconnected():\n                    logger.info(\"Client disconnected detected for stream %s\", stream_id)\n                    disconnect_event.set()\n                    break\n                yield chunk\n        except asyncio.CancelledError:\n            logger.info(\"Stream %s cancelled\", stream_id)\n            disconnect_event.set()\n        except GeneratorExit:\n            logger.info(\"Stream %s generator closed\", stream_id)\n            disconnect_event.set()\n        finally:\n            disconnect_event.set()\n            monitor_task.cancel()\n            # Give a moment for the inner generator to clean up\n            await asyncio.sleep(0.1)\n\n    return StreamingResponse(\n        stream_with_disconnect_check(),\n        media_type=\"multipart/x-mixed-replace; boundary=frame\",\n        headers={\n            \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\n            \"Pragma\": \"no-cache\",\n            \"Expires\": \"0\",\n        },\n    )\n\n\n@router.api_route(\"/{printer_id}/camera/stop\", methods=[\"GET\", \"POST\"])\nasync def stop_camera_stream(\n    printer_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Stop all active camera streams for a printer.\n\n    This can be called by the frontend when the camera window is closed.\n    Accepts both GET and POST (POST for sendBeacon compatibility).\n    \"\"\"\n    stopped = 0\n\n    # Stop ffmpeg/RTSP streams\n    to_remove = []\n    for stream_id, process in list(_active_streams.items()):\n        if stream_id.startswith(f\"{printer_id}-\"):\n            to_remove.append(stream_id)\n            # Signal the generator to stop reconnecting BEFORE killing the process\n            event = _disconnect_events.get(stream_id)\n            if event:\n                event.set()\n            if process.returncode is None:\n                try:\n                    process.terminate()\n                    try:\n                        await asyncio.wait_for(process.wait(), timeout=2.0)\n                    except TimeoutError:\n                        logger.warning(\"ffmpeg didn't terminate gracefully, killing (stream_id=%s)\", stream_id)\n                        process.kill()\n                        await process.wait()\n                    stopped += 1\n                    logger.info(\"Terminated ffmpeg process for stream %s\", stream_id)\n                except ProcessLookupError:\n                    pass  # Process already dead\n                except OSError as e:\n                    logger.warning(\"Error stopping stream %s: %s\", stream_id, e)\n            _spawned_ffmpeg_pids.pop(process.pid, None)\n\n    for stream_id in to_remove:\n        _active_streams.pop(stream_id, None)\n        _disconnect_events.pop(stream_id, None)\n        _stream_last_frame_times.pop(stream_id, None)\n\n    # Stop chamber image streams\n    to_remove_chamber = []\n    for stream_id, (_reader, writer) in list(_active_chamber_streams.items()):\n        if stream_id.startswith(f\"{printer_id}-\"):\n            to_remove_chamber.append(stream_id)\n            # Signal the generator to stop\n            event = _disconnect_events.get(stream_id)\n            if event:\n                event.set()\n            try:\n                writer.close()\n                stopped += 1\n                logger.info(\"Closed chamber image connection for stream %s\", stream_id)\n            except OSError as e:\n                logger.warning(\"Error stopping chamber stream %s: %s\", stream_id, e)\n\n    for stream_id in to_remove_chamber:\n        _active_chamber_streams.pop(stream_id, None)\n        _disconnect_events.pop(stream_id, None)\n        _stream_last_frame_times.pop(stream_id, None)\n\n    logger.info(\"Stopped %s camera stream(s) for printer %s\", stopped, printer_id)\n    return {\"stopped\": stopped}\n\n\n@router.get(\"/{printer_id}/camera/snapshot\")\nasync def camera_snapshot(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Capture a single frame from the printer camera.\n\n    Returns a JPEG image.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    import tempfile\n    from pathlib import Path\n\n    printer = await get_printer_or_404(printer_id, db)\n\n    # Check for external camera first\n    if printer.external_camera_enabled and printer.external_camera_url:\n        from backend.app.services.external_camera import capture_frame\n\n        frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type, timeout=15)\n        if not frame_data:\n            raise HTTPException(\n                status_code=503,\n                detail=\"Failed to capture frame from external camera.\",\n            )\n        return Response(\n            content=frame_data,\n            media_type=\"image/jpeg\",\n            headers={\n                \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\n                \"Content-Disposition\": f'inline; filename=\"snapshot_{printer_id}.jpg\"',\n            },\n        )\n\n    # Create temporary file for the snapshot (0600 so only the app user can read it)\n    fd, tmp_name = tempfile.mkstemp(suffix=\".jpg\")\n    os.close(fd)\n    temp_path = Path(tmp_name)\n    temp_path.chmod(0o600)\n\n    try:\n        success = await capture_camera_frame(\n            ip_address=printer.ip_address,\n            access_code=printer.access_code,\n            model=printer.model,\n            output_path=temp_path,\n            timeout=15,\n        )\n\n        if not success:\n            raise HTTPException(\n                status_code=503,\n                detail=\"Failed to capture camera frame. Ensure printer is on and camera is enabled.\",\n            )\n\n        # Read and return the image\n        with open(temp_path, \"rb\") as f:\n            image_data = f.read()\n\n        return Response(\n            content=image_data,\n            media_type=\"image/jpeg\",\n            headers={\n                \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\n                \"Content-Disposition\": f'inline; filename=\"snapshot_{printer_id}.jpg\"',\n            },\n        )\n    finally:\n        # Clean up temp file\n        if temp_path.exists():\n            temp_path.unlink()\n\n\n@router.get(\"/{printer_id}/camera/test\")\nasync def test_camera(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Test camera connection for a printer.\n\n    Returns success status and any error message.\n    \"\"\"\n    printer = await get_printer_or_404(printer_id, db)\n\n    result = await test_camera_connection(\n        ip_address=printer.ip_address,\n        access_code=printer.access_code,\n        model=printer.model,\n    )\n\n    return result\n\n\n@router.get(\"/{printer_id}/camera/status\")\nasync def camera_status(\n    printer_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Get the status of an active camera stream.\n\n    Returns whether a stream is active and when the last frame was received.\n    Used by the frontend to detect stalled streams and auto-reconnect.\n    \"\"\"\n    import time\n\n    # Check if there's an active stream for this printer\n    has_active_stream = False\n\n    # Check external camera streams\n    if printer_id in _active_external_streams:\n        has_active_stream = True\n\n    # Check ffmpeg/RTSP streams\n    if not has_active_stream:\n        for stream_id in _active_streams:\n            if stream_id.startswith(f\"{printer_id}-\"):\n                process = _active_streams[stream_id]\n                if process.returncode is None:\n                    has_active_stream = True\n                    break\n\n    # Check chamber image streams\n    if not has_active_stream:\n        for stream_id in _active_chamber_streams:\n            if stream_id.startswith(f\"{printer_id}-\"):\n                has_active_stream = True\n                break\n\n    # Get timing information\n    current_time = time.time()\n    last_frame_time = _last_frame_times.get(printer_id)\n    stream_start_time = _stream_start_times.get(printer_id)\n\n    # Calculate seconds since last frame\n    seconds_since_frame = None\n    if last_frame_time is not None:\n        seconds_since_frame = current_time - last_frame_time\n\n    # Calculate stream uptime\n    stream_uptime = None\n    if stream_start_time is not None:\n        stream_uptime = current_time - stream_start_time\n\n    return {\n        \"active\": has_active_stream,\n        \"has_frames\": printer_id in _last_frames,\n        \"seconds_since_frame\": seconds_since_frame,\n        \"stream_uptime\": stream_uptime,\n        # Consider stalled if no frame for more than 10 seconds after stream started\n        \"stalled\": (\n            has_active_stream\n            and stream_uptime is not None\n            and stream_uptime > 5  # Give 5 seconds for stream to start\n            and (seconds_since_frame is None or seconds_since_frame > 10)\n        ),\n    }\n\n\n@router.post(\"/{printer_id}/camera/external/test\")\nasync def test_external_camera(\n    printer_id: int,\n    url: str,\n    camera_type: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Test external camera connection.\n\n    Args:\n        printer_id: Printer ID (for authorization)\n        url: Camera URL or USB device path to test\n        camera_type: Camera type (\"mjpeg\", \"rtsp\", \"snapshot\", \"usb\")\n\n    Returns:\n        Dict with {success: bool, error?: str, resolution?: str}\n    \"\"\"\n    # Verify printer exists (for authorization)\n    await get_printer_or_404(printer_id, db)\n\n    from backend.app.services.external_camera import test_connection\n\n    return await test_connection(url, camera_type)\n\n\n@router.get(\"/{printer_id}/camera/check-plate\")\nasync def check_plate_empty(\n    printer_id: int,\n    plate_type: str | None = None,\n    use_external: bool = False,\n    include_debug_image: bool = False,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Check if the build plate is empty using camera vision.\n\n    Uses calibration-based difference detection - compares current frame\n    to a reference image of the empty plate.\n\n    IMPORTANT: Chamber light must be ON for reliable detection.\n\n    Args:\n        printer_id: Printer ID\n        plate_type: Type of build plate (e.g., \"High Temp Plate\") for calibration lookup\n        use_external: If True, prefer external camera over built-in\n        include_debug_image: If True, return URL to annotated debug image\n\n    Returns:\n        Dict with detection results:\n        - is_empty: bool - Whether plate appears empty\n        - confidence: float - Confidence level (0.0 to 1.0)\n        - difference_percent: float - How different from calibration reference\n        - message: str - Human-readable result message\n        - needs_calibration: bool - True if calibration is required\n        - light_warning: bool - True if chamber light is off\n    \"\"\"\n    from backend.app.services.plate_detection import (\n        check_plate_empty as do_check,\n        is_plate_detection_available,\n    )\n    from backend.app.services.printer_manager import printer_manager\n\n    # Check printer exists first (before OpenCV check)\n    printer = await get_printer_or_404(printer_id, db)\n\n    if not is_plate_detection_available():\n        raise HTTPException(\n            status_code=503,\n            detail=\"Plate detection not available. Install opencv-python-headless to enable.\",\n        )\n\n    # Check chamber light status\n    light_warning = False\n    state = printer_manager.get_status(printer_id)\n    if state and not state.chamber_light:\n        light_warning = True\n\n    from backend.app.services.plate_detection import PlateDetector\n\n    # Build ROI tuple from printer settings if available\n    roi = None\n    if all(\n        [\n            printer.plate_detection_roi_x is not None,\n            printer.plate_detection_roi_y is not None,\n            printer.plate_detection_roi_w is not None,\n            printer.plate_detection_roi_h is not None,\n        ]\n    ):\n        roi = (\n            printer.plate_detection_roi_x,\n            printer.plate_detection_roi_y,\n            printer.plate_detection_roi_w,\n            printer.plate_detection_roi_h,\n        )\n\n    result = await do_check(\n        printer_id=printer.id,\n        ip_address=printer.ip_address,\n        access_code=printer.access_code,\n        model=printer.model,\n        plate_type=plate_type,\n        include_debug_image=include_debug_image,\n        external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None,\n        external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,\n        use_external=use_external,\n        roi=roi,\n    )\n\n    # Get reference count for the response\n    detector = PlateDetector()\n    ref_count = detector.get_calibration_count(printer.id)\n\n    response = result.to_dict()\n    response[\"light_warning\"] = light_warning\n    response[\"reference_count\"] = ref_count\n    response[\"max_references\"] = detector.MAX_REFERENCES\n    # Include current ROI in response\n    if roi:\n        response[\"roi\"] = {\"x\": roi[0], \"y\": roi[1], \"w\": roi[2], \"h\": roi[3]}\n    else:\n        # Return default ROI\n        response[\"roi\"] = {\"x\": 0.15, \"y\": 0.35, \"w\": 0.70, \"h\": 0.55}\n\n    # If debug image requested and available, encode as base64 data URL\n    if include_debug_image and result.debug_image:\n        import base64\n\n        b64_image = base64.b64encode(result.debug_image).decode(\"utf-8\")\n        response[\"debug_image_url\"] = f\"data:image/jpeg;base64,{b64_image}\"\n\n    return response\n\n\n@router.post(\"/{printer_id}/camera/plate-detection/calibrate\")\nasync def calibrate_plate_detection(\n    printer_id: int,\n    label: str | None = None,\n    use_external: bool = False,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Calibrate plate detection by capturing a reference image of the empty plate.\n\n    The plate MUST be empty when calling this endpoint. The captured image\n    will be used as the reference for future detection comparisons.\n\n    Supports up to 5 reference images per printer. When adding a 6th, the oldest\n    is automatically removed.\n\n    IMPORTANT: Chamber light should be ON for calibration.\n\n    Args:\n        printer_id: Printer ID\n        label: Optional label for this reference (e.g., \"High Temp Plate\", \"Wham Bam\")\n        use_external: If True, prefer external camera over built-in\n\n    Returns:\n        Dict with:\n        - success: bool - Whether calibration succeeded\n        - message: str - Status message\n        - index: int - The reference slot used (0-4)\n    \"\"\"\n    from backend.app.services.plate_detection import (\n        calibrate_plate,\n        is_plate_detection_available,\n    )\n    from backend.app.services.printer_manager import printer_manager\n\n    # Check printer exists first (before OpenCV check)\n    printer = await get_printer_or_404(printer_id, db)\n\n    if not is_plate_detection_available():\n        raise HTTPException(\n            status_code=503,\n            detail=\"Plate detection not available. Install opencv-python-headless to enable.\",\n        )\n\n    # Check chamber light - warn but don't block\n    state = printer_manager.get_status(printer_id)\n    light_warning = state and not state.chamber_light\n\n    success, message, index = await calibrate_plate(\n        printer_id=printer.id,\n        ip_address=printer.ip_address,\n        access_code=printer.access_code,\n        model=printer.model,\n        label=label,\n        external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None,\n        external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,\n        use_external=use_external,\n    )\n\n    if light_warning and success:\n        message += \" (Warning: Chamber light was off)\"\n\n    return {\"success\": success, \"message\": message, \"index\": index}\n\n\n@router.delete(\"/{printer_id}/camera/plate-detection/calibrate\")\nasync def delete_plate_calibration(\n    printer_id: int,\n    plate_type: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Delete the plate detection calibration for a printer and plate type.\n\n    Args:\n        printer_id: Printer ID\n        plate_type: Type of build plate (if None, deletes legacy non-plate-specific calibration)\n\n    Returns:\n        Dict with:\n        - success: bool - Whether deletion succeeded\n        - message: str - Status message\n    \"\"\"\n    from backend.app.services.plate_detection import (\n        delete_calibration,\n        is_plate_detection_available,\n    )\n\n    # Verify printer exists first (before OpenCV check)\n    await get_printer_or_404(printer_id, db)\n\n    if not is_plate_detection_available():\n        raise HTTPException(\n            status_code=503,\n            detail=\"Plate detection not available. Install opencv-python-headless to enable.\",\n        )\n\n    deleted = delete_calibration(printer_id, plate_type)\n    plate_msg = f\" for '{plate_type}'\" if plate_type else \"\"\n\n    return {\n        \"success\": deleted,\n        \"message\": f\"Calibration deleted{plate_msg}\" if deleted else f\"No calibration found{plate_msg}\",\n    }\n\n\n@router.get(\"/{printer_id}/camera/plate-detection/status\")\nasync def get_plate_detection_status(\n    printer_id: int,\n    plate_type: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Check plate detection status for a printer and plate type.\n\n    Returns:\n        Dict with:\n        - available: bool - Whether OpenCV is installed\n        - calibrated: bool - Whether printer has calibration for this plate type\n        - plate_type: str - The plate type queried\n        - chamber_light: bool - Whether chamber light is on\n        - message: str - Status message\n    \"\"\"\n    from backend.app.services.plate_detection import (\n        get_calibration_status,\n        is_plate_detection_available,\n    )\n    from backend.app.services.printer_manager import printer_manager\n\n    # Verify printer exists first (before OpenCV check)\n    await get_printer_or_404(printer_id, db)\n\n    if not is_plate_detection_available():\n        return {\n            \"available\": False,\n            \"calibrated\": False,\n            \"plate_type\": plate_type,\n            \"chamber_light\": False,\n            \"message\": \"OpenCV not installed\",\n        }\n\n    # Get chamber light status\n    state = printer_manager.get_status(printer_id)\n    chamber_light = state.chamber_light if state else False\n\n    status = get_calibration_status(printer_id, plate_type)\n    status[\"chamber_light\"] = chamber_light\n\n    return status\n\n\n@router.get(\"/{printer_id}/camera/plate-detection/references\")\nasync def get_plate_references(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Get all calibration references for a printer with metadata.\n\n    Returns list of references with index, label, timestamp, and thumbnail URL.\n    \"\"\"\n    from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available\n\n    # Verify printer exists first (before OpenCV check)\n    await get_printer_or_404(printer_id, db)\n\n    if not is_plate_detection_available():\n        raise HTTPException(503, \"Plate detection not available\")\n\n    detector = PlateDetector()\n    references = detector.get_references(printer_id)\n\n    # Add thumbnail URLs\n    for ref in references:\n        ref[\"thumbnail_url\"] = (\n            f\"/api/v1/printers/{printer_id}/camera/plate-detection/references/{ref['index']}/thumbnail\"\n        )\n\n    return {\n        \"references\": references,\n        \"max_references\": detector.MAX_REFERENCES,\n    }\n\n\n@router.get(\"/{printer_id}/camera/plate-detection/references/{index}/thumbnail\")\nasync def get_reference_thumbnail(\n    printer_id: int,\n    index: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get thumbnail image for a calibration reference.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    from fastapi.responses import Response\n\n    from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available\n\n    # Verify printer exists first (before OpenCV check)\n    await get_printer_or_404(printer_id, db)\n\n    if not is_plate_detection_available():\n        raise HTTPException(503, \"Plate detection not available\")\n\n    detector = PlateDetector()\n    thumbnail = detector.get_reference_thumbnail(printer_id, index)\n\n    if thumbnail is None:\n        raise HTTPException(404, \"Reference not found\")\n\n    return Response(content=thumbnail, media_type=\"image/jpeg\")\n\n\n@router.put(\"/{printer_id}/camera/plate-detection/references/{index}\")\nasync def update_reference_label(\n    printer_id: int,\n    index: int,\n    label: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Update the label for a calibration reference.\"\"\"\n    from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available\n\n    # Verify printer exists first (before OpenCV check)\n    await get_printer_or_404(printer_id, db)\n\n    if not is_plate_detection_available():\n        raise HTTPException(503, \"Plate detection not available\")\n\n    detector = PlateDetector()\n    success = detector.update_reference_label(printer_id, index, label)\n\n    if not success:\n        raise HTTPException(404, \"Reference not found\")\n\n    return {\"success\": True, \"index\": index, \"label\": label}\n\n\n@router.delete(\"/{printer_id}/camera/plate-detection/references/{index}\")\nasync def delete_reference(\n    printer_id: int,\n    index: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),\n):\n    \"\"\"Delete a specific calibration reference.\"\"\"\n    from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available\n\n    # Verify printer exists first (before OpenCV check)\n    await get_printer_or_404(printer_id, db)\n\n    if not is_plate_detection_available():\n        raise HTTPException(503, \"Plate detection not available\")\n\n    detector = PlateDetector()\n    success = detector.delete_reference(printer_id, index)\n\n    if not success:\n        raise HTTPException(404, \"Reference not found\")\n\n    return {\"success\": True, \"message\": \"Reference deleted\"}\n\n\ndef _scan_bambu_ffmpeg_pids() -> list[int]:\n    \"\"\"Scan /proc for ffmpeg processes with Bambu RTSP URLs.\n\n    These are definitely ours — no other software connects to rtsp(s)://bblp:.\n    This catches orphans that survive app restarts and are not in any tracking dict.\n    \"\"\"\n    import os\n\n    pids = []\n    try:\n        for entry in os.listdir(\"/proc\"):\n            if not entry.isdigit():\n                continue\n            try:\n                with open(f\"/proc/{entry}/cmdline\", \"rb\") as f:\n                    cmdline = f.read()\n                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct)\n                if b\"ffmpeg\" in cmdline and (b\"rtsp://bblp:\" in cmdline or b\"rtsps://bblp:\" in cmdline):\n                    pids.append(int(entry))\n            except (OSError, PermissionError, ValueError):\n                continue\n    except OSError:\n        pass\n    return pids\n\n\nasync def cleanup_orphaned_streams():\n    \"\"\"Clean up orphaned ffmpeg processes and stale stream entries.\n\n    Called periodically from the background task loop in main.py.\n\n    Three-layer cleanup:\n    1. /proc scan — finds ALL Bambu ffmpeg processes on the system, even those\n       from previous app sessions. This is the nuclear safety net.\n    2. _spawned_ffmpeg_pids — tracks PIDs spawned this session, catches orphans\n       that were removed from _active_streams but not killed.\n    3. _active_streams — kills stale entries with no recent frames.\n    \"\"\"\n    import os\n    import signal\n    import time\n\n    cleaned = 0\n    now = time.time()\n\n    # Collect PIDs that are legitimately in-use (active stream, process alive)\n    active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}\n\n    # Also exclude PIDs from one-shot snapshot captures (Obico detection, finish photos, etc.)\n    from backend.app.services.camera import _active_capture_pids\n\n    active_pids |= _active_capture_pids\n\n    # 1. /proc scan — catch ALL orphaned Bambu ffmpeg processes on the system.\n    #    Any ffmpeg with rtsp(s)://bblp: that is NOT in an active stream is orphaned.\n    for pid in _scan_bambu_ffmpeg_pids():\n        if pid in active_pids:\n            continue\n        logger.info(\"Killing orphaned ffmpeg process found via /proc (pid=%d)\", pid)\n        try:\n            os.kill(pid, signal.SIGKILL)\n        except (ProcessLookupError, OSError):\n            pass\n        _spawned_ffmpeg_pids.pop(pid, None)\n        cleaned += 1\n\n    # 2. Clean up _spawned_ffmpeg_pids entries for dead processes\n    for pid in list(_spawned_ffmpeg_pids):\n        try:\n            os.kill(pid, 0)  # existence check\n        except (ProcessLookupError, OSError):\n            _spawned_ffmpeg_pids.pop(pid, None)\n\n    # 3. Clean up _active_streams entries with dead processes\n    dead_streams = [sid for sid, proc in _active_streams.items() if proc.returncode is not None]\n    for sid in dead_streams:\n        proc = _active_streams.pop(sid, None)\n        if proc:\n            _spawned_ffmpeg_pids.pop(proc.pid, None)\n        cleaned += 1\n\n    # 4. Kill stale active streams (alive but no frames for >30s)\n    # Uses per-stream timestamps to avoid false \"fresh\" readings from newer streams\n    for sid, proc in list(_active_streams.items()):\n        if proc.returncode is not None:\n            continue\n        # Per-stream frame time is authoritative; fall back to per-printer\n        stream_last_frame = _stream_last_frame_times.get(sid)\n        if stream_last_frame is None:\n            try:\n                printer_id = int(sid.split(\"-\", 1)[0])\n            except (ValueError, IndexError):\n                continue\n            stream_last_frame = _last_frame_times.get(printer_id)\n        spawn_time = _spawned_ffmpeg_pids.get(proc.pid, now)\n        if stream_last_frame is None:\n            stream_last_frame = spawn_time\n        if now - spawn_time > 60 and now - stream_last_frame > 30:\n            logger.info(\"Killing stale ffmpeg stream %s (no frames for %.0fs)\", sid, now - stream_last_frame)\n            # Signal the generator to stop reconnecting\n            event = _disconnect_events.get(sid)\n            if event:\n                event.set()\n            try:\n                proc.kill()\n                await proc.wait()\n            except (ProcessLookupError, OSError):\n                pass\n            _active_streams.pop(sid, None)\n            _disconnect_events.pop(sid, None)\n            _stream_last_frame_times.pop(sid, None)\n            _spawned_ffmpeg_pids.pop(proc.pid, None)\n            cleaned += 1\n\n    # 4. Clean stale chamber stream entries\n    dead_chamber = [sid for sid, (_reader, writer) in _active_chamber_streams.items() if writer.is_closing()]\n    for sid in dead_chamber:\n        _active_chamber_streams.pop(sid, None)\n        cleaned += 1\n\n    if cleaned:\n        logger.info(\"Cleaned up %d orphaned camera stream(s)\", cleaned)\n"
  },
  {
    "path": "backend/app/api/routes/cloud.py",
    "content": "\"\"\"\nBambu Lab Cloud API Routes\n\nHandles authentication and profile management with Bambu Cloud.\n\"\"\"\n\nimport json\nimport logging\nfrom pathlib import Path\nfrom typing import Literal\n\nfrom fastapi import APIRouter, Body, Depends, HTTPException\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.user import User\nfrom backend.app.schemas.cloud import (\n    CloudAuthStatus,\n    CloudDevice,\n    CloudLoginRequest,\n    CloudLoginResponse,\n    CloudTokenRequest,\n    CloudVerifyRequest,\n    FirmwareUpdateInfo,\n    FirmwareUpdatesResponse,\n    SlicerSetting,\n    SlicerSettingCreate,\n    SlicerSettingDeleteResponse,\n    SlicerSettingsResponse,\n    SlicerSettingUpdate,\n)\nfrom backend.app.services.bambu_cloud import (\n    BambuCloudAuthError,\n    BambuCloudError,\n    BambuCloudService,\n)\nfrom backend.app.utils.filament_ids import filament_id_to_setting_id\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/cloud\", tags=[\"cloud\"])\n\n\n# Keys for storing cloud credentials in settings\nCLOUD_TOKEN_KEY = \"bambu_cloud_token\"\nCLOUD_EMAIL_KEY = \"bambu_cloud_email\"\nCLOUD_REGION_KEY = \"bambu_cloud_region\"\n\n\ndef _normalise_region(region: str | None) -> str:\n    \"\"\"Treat NULL/empty as 'global' for legacy rows that predate the region column.\"\"\"\n    return region if region in (\"global\", \"china\") else \"global\"\n\n\nasync def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:\n    \"\"\"Get stored cloud token, email, and region.\n\n    When a user is provided (auth enabled), returns that user's per-user credentials.\n    When user is None (auth disabled), falls back to global Settings table.\n    Region defaults to ``\"global\"`` when unset (including for rows that predate\n    the ``cloud_region`` column).\n    \"\"\"\n    if user is not None:\n        return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)\n\n    # Fallback: global storage (auth disabled)\n    result = await db.execute(\n        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))\n    )\n    settings = {s.key: s.value for s in result.scalars().all()}\n    return (\n        settings.get(CLOUD_TOKEN_KEY),\n        settings.get(CLOUD_EMAIL_KEY),\n        _normalise_region(settings.get(CLOUD_REGION_KEY)),\n    )\n\n\nasync def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:\n    \"\"\"Store cloud token, email, and region.\n\n    When a user is provided (auth enabled), stores on the user record.\n    When user is None (auth disabled), stores in global Settings table.\n    \"\"\"\n    region = _normalise_region(region)\n    if user is not None:\n        # User object is from the auth dependency's session (detached),\n        # so use a direct UPDATE via the route's db session.\n        from sqlalchemy import update\n\n        await db.execute(\n            update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email, cloud_region=region)\n        )\n        await db.commit()\n        return\n\n    # Fallback: global storage (auth disabled)\n    for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email), (CLOUD_REGION_KEY, region)]:\n        result = await db.execute(select(Settings).where(Settings.key == key))\n        setting = result.scalar_one_or_none()\n        if setting:\n            setting.value = value\n        else:\n            db.add(Settings(key=key, value=value))\n    await db.commit()\n\n\nasync def clear_token(db: AsyncSession, user: User | None = None) -> None:\n    \"\"\"Clear stored cloud token, email, and region.\n\n    When a user is provided (auth enabled), clears that user's credentials.\n    When user is None (auth disabled), clears from global Settings table.\n    \"\"\"\n    if user is not None:\n        from sqlalchemy import update\n\n        await db.execute(\n            update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None, cloud_region=None)\n        )\n        await db.commit()\n        return\n\n    # Fallback: global storage (auth disabled)\n    result = await db.execute(\n        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))\n    )\n    for setting in result.scalars().all():\n        await db.delete(setting)\n    await db.commit()\n\n\nasync def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:\n    \"\"\"Build a per-request cloud service seeded with the caller's stored token + region.\n\n    Returns ``None`` when no token is stored, so callers can 401 without constructing\n    (and then closing) a useless client. Caller is responsible for ``await cloud.close()``.\n    \"\"\"\n    token, _email, region = await get_stored_token(db, user)\n    if not token:\n        return None\n    cloud = BambuCloudService(region=region)\n    cloud.set_token(token)\n    return cloud\n\n\n@router.get(\"/status\", response_model=CloudAuthStatus)\nasync def get_auth_status(\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"Get current cloud authentication status.\n\n    Reads the stored credentials in one DB round-trip (we used to call\n    ``get_stored_token`` twice — once here and once inside\n    ``build_authenticated_cloud``). ``region`` is exposed so the frontend can\n    show \"Connected (China)\" after a reload without relying on local state.\n    \"\"\"\n    token, email, region = await get_stored_token(db, current_user)\n    if not token:\n        return CloudAuthStatus(is_authenticated=False, email=None, region=None)\n\n    cloud = BambuCloudService(region=region)\n    cloud.set_token(token)\n    try:\n        authenticated = cloud.is_authenticated\n        return CloudAuthStatus(\n            is_authenticated=authenticated,\n            email=email if authenticated else None,\n            region=region if authenticated else None,\n        )\n    finally:\n        await cloud.close()\n\n\n@router.post(\"/login\", response_model=CloudLoginResponse)\nasync def login(\n    request: CloudLoginRequest,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Initiate login to Bambu Cloud.\n\n    This will trigger either:\n    - Email verification: A code is sent to the user's email\n    - TOTP verification: User enters code from their authenticator app\n\n    After receiving/generating the code, call /cloud/verify to complete the login.\n    For TOTP, include the tfa_key from this response in the verify request.\n    \"\"\"\n    cloud = BambuCloudService(region=request.region)\n\n    try:\n        result = await cloud.login_request(request.email, request.password)\n\n        if result.get(\"success\") and cloud.access_token:\n            # Direct login succeeded (rare)\n            await store_token(db, cloud.access_token, request.email, request.region, current_user)\n\n        return CloudLoginResponse(\n            success=result.get(\"success\", False),\n            needs_verification=result.get(\"needs_verification\", False),\n            message=result.get(\"message\", \"Unknown error\"),\n            verification_type=result.get(\"verification_type\"),\n            tfa_key=result.get(\"tfa_key\"),\n        )\n    except BambuCloudAuthError as e:\n        raise HTTPException(status_code=401, detail=str(e))\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n@router.post(\"/verify\", response_model=CloudLoginResponse)\nasync def verify_code(\n    request: CloudVerifyRequest,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Complete login with verification code (email or TOTP).\n\n    For email verification:\n    - After calling /cloud/login, the user receives an email with a 6-digit code\n    - Submit the code with email address\n\n    For TOTP verification:\n    - The user enters the 6-digit code from their authenticator app\n    - Include the tfa_key from the /cloud/login response\n\n    ``request.region`` must match the region used in /cloud/login so that the\n    TOTP call hits the correct TFA endpoint (bambulab.com vs bambulab.cn).\n    \"\"\"\n    cloud = BambuCloudService(region=request.region)\n\n    try:\n        # Use TOTP verification if tfa_key is provided\n        if request.tfa_key:\n            result = await cloud.verify_totp(request.tfa_key, request.code)\n        else:\n            result = await cloud.verify_code(request.email, request.code)\n\n        if result.get(\"success\") and cloud.access_token:\n            await store_token(db, cloud.access_token, request.email, request.region, current_user)\n\n        return CloudLoginResponse(\n            success=result.get(\"success\", False),\n            needs_verification=False,\n            message=result.get(\"message\", \"Unknown error\"),\n        )\n    except BambuCloudAuthError as e:\n        raise HTTPException(status_code=401, detail=str(e))\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n@router.post(\"/token\", response_model=CloudAuthStatus)\nasync def set_token(\n    request: CloudTokenRequest,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Set access token directly.\n\n    For users who already have a token (e.g., from Bambu Studio). The\n    selected ``region`` is persisted alongside the token so every subsequent\n    request hits the right Bambu API endpoint, including after a restart.\n    \"\"\"\n    cloud = BambuCloudService(region=request.region)\n    cloud.set_token(request.access_token)\n\n    try:\n        # Verify token works by trying to get profile\n        await cloud.get_user_profile()\n        await store_token(db, request.access_token, \"token-auth\", request.region, current_user)\n        return CloudAuthStatus(is_authenticated=True, email=\"token-auth\")\n    except BambuCloudError:\n        raise HTTPException(status_code=401, detail=\"Invalid token\")\n    finally:\n        await cloud.close()\n\n\n@router.post(\"/logout\")\nasync def logout(\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"Log out of Bambu Cloud.\"\"\"\n    await clear_token(db, current_user)\n    return {\"success\": True}\n\n\n@router.get(\"/settings\", response_model=SlicerSettingsResponse)\nasync def get_slicer_settings(\n    version: str = \"02.04.00.70\",\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Get all slicer settings (filament, printer, process presets).\n\n    Requires authentication.\n    \"\"\"\n    cloud = await build_authenticated_cloud(db, current_user)\n    if cloud is None or not cloud.is_authenticated:\n        raise HTTPException(status_code=401, detail=\"Not authenticated\")\n\n    try:\n        data = await cloud.get_slicer_settings(version)\n\n        result = SlicerSettingsResponse()\n\n        # Map API keys to our types (API uses 'print' for process presets)\n        type_mapping = {\n            \"filament\": \"filament\",\n            \"printer\": \"printer\",\n            \"print\": \"process\",  # API calls it 'print', we call it 'process'\n        }\n\n        for api_key, our_type in type_mapping.items():\n            type_data = data.get(api_key, {})\n            private_settings = type_data.get(\"private\", [])\n            public_settings = type_data.get(\"public\", [])\n\n            parsed = []\n            # Private (custom) presets first\n            for s in private_settings:\n                parsed.append(\n                    SlicerSetting(\n                        setting_id=s.get(\"setting_id\", s.get(\"id\", \"\")),\n                        name=s.get(\"name\", \"Unknown\"),\n                        type=our_type,\n                        version=s.get(\"version\"),\n                        user_id=s.get(\"user_id\"),\n                        updated_time=s.get(\"updated_time\"),\n                        is_custom=True,\n                    )\n                )\n            # Public (default) presets\n            for s in public_settings:\n                parsed.append(\n                    SlicerSetting(\n                        setting_id=s.get(\"setting_id\", s.get(\"id\", \"\")),\n                        name=s.get(\"name\", \"Unknown\"),\n                        type=our_type,\n                        version=s.get(\"version\"),\n                        user_id=s.get(\"user_id\"),\n                        updated_time=s.get(\"updated_time\"),\n                        is_custom=False,\n                    )\n                )\n            setattr(result, our_type, parsed)\n\n        return result\n    except BambuCloudAuthError:\n        await clear_token(db, current_user)\n        raise HTTPException(status_code=401, detail=\"Authentication expired\")\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n@router.get(\"/settings/{setting_id}\")\nasync def get_setting_detail(\n    setting_id: str,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Get detailed information for a specific setting/preset.\n\n    Returns the full preset configuration.\n    \"\"\"\n    cloud = await build_authenticated_cloud(db, current_user)\n    if cloud is None or not cloud.is_authenticated:\n        raise HTTPException(status_code=401, detail=\"Not authenticated\")\n\n    try:\n        data = await cloud.get_setting_detail(setting_id)\n        return data\n    except BambuCloudAuthError:\n        await clear_token(db, current_user)\n        raise HTTPException(status_code=401, detail=\"Authentication expired\")\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n@router.get(\"/filaments\", response_model=list[SlicerSetting])\nasync def get_filament_presets(\n    version: str = \"02.04.00.70\",\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"\n    Get just filament presets (convenience endpoint).\n\n    Returns all filament presets with custom presets first.\n    Uses the same cache as get_slicer_settings.\n    \"\"\"\n    settings = await get_slicer_settings(version=version, db=db, current_user=current_user)\n    return settings.filament\n\n\n# Cache for filament preset info (setting_id -> {name, k})\n_filament_cache: dict[str, dict] = {}\n_filament_cache_time: float = 0\nFILAMENT_CACHE_TTL = 300  # 5 minutes\n\n# Built-in filament ID → name mapping (fallback when cloud API and local profiles\n# don't have the entry). Based on Bambu Lab's known filament catalogue.\n_BUILTIN_FILAMENT_NAMES: dict[str, str] = {\n    \"GFA00\": \"Bambu PLA Basic\",\n    \"GFA01\": \"Bambu PLA Matte\",\n    \"GFA02\": \"Bambu PLA Metal\",\n    \"GFA05\": \"Bambu PLA Silk\",\n    \"GFA06\": \"Bambu PLA Silk+\",\n    \"GFA07\": \"Bambu PLA Marble\",\n    \"GFA08\": \"Bambu PLA Sparkle\",\n    \"GFA09\": \"Bambu PLA Tough\",\n    \"GFA11\": \"Bambu PLA Aero\",\n    \"GFA12\": \"Bambu PLA Glow\",\n    \"GFA13\": \"Bambu PLA Dynamic\",\n    \"GFA15\": \"Bambu PLA Galaxy\",\n    \"GFA16\": \"Bambu PLA Wood\",\n    \"GFA50\": \"Bambu PLA-CF\",\n    \"GFB00\": \"Bambu ABS\",\n    \"GFB01\": \"Bambu ASA\",\n    \"GFB02\": \"Bambu ASA-Aero\",\n    \"GFB50\": \"Bambu ABS-GF\",\n    \"GFB51\": \"Bambu ASA-CF\",\n    \"GFB60\": \"PolyLite ABS\",\n    \"GFB61\": \"PolyLite ASA\",\n    \"GFB98\": \"Generic ASA\",\n    \"GFB99\": \"Generic ABS\",\n    \"GFC00\": \"Bambu PC\",\n    \"GFC01\": \"Bambu PC FR\",\n    \"GFC99\": \"Generic PC\",\n    \"GFG00\": \"Bambu PETG Basic\",\n    \"GFG01\": \"Bambu PETG Translucent\",\n    \"GFG02\": \"Bambu PETG HF\",\n    \"GFG50\": \"Bambu PETG-CF\",\n    \"GFG60\": \"PolyLite PETG\",\n    \"GFG96\": \"Generic PETG HF\",\n    \"GFG97\": \"Generic PCTG\",\n    \"GFG98\": \"Generic PETG-CF\",\n    \"GFG99\": \"Generic PETG\",\n    \"GFL00\": \"PolyLite PLA\",\n    \"GFL01\": \"PolyTerra PLA\",\n    \"GFL03\": \"eSUN PLA+\",\n    \"GFL04\": \"Overture PLA\",\n    \"GFL05\": \"Overture Matte PLA\",\n    \"GFL06\": \"Fiberon PETG-ESD\",\n    \"GFL50\": \"Fiberon PA6-CF\",\n    \"GFL51\": \"Fiberon PA6-GF\",\n    \"GFL52\": \"Fiberon PA12-CF\",\n    \"GFL53\": \"Fiberon PA612-CF\",\n    \"GFL54\": \"Fiberon PET-CF\",\n    \"GFL55\": \"Fiberon PETG-rCF\",\n    \"GFL95\": \"Generic PLA High Speed\",\n    \"GFL96\": \"Generic PLA Silk\",\n    \"GFL98\": \"Generic PLA-CF\",\n    \"GFL99\": \"Generic PLA\",\n    \"GFN03\": \"Bambu PA-CF\",\n    \"GFN04\": \"Bambu PAHT-CF\",\n    \"GFN05\": \"Bambu PA6-CF\",\n    \"GFN06\": \"Bambu PPA-CF\",\n    \"GFN08\": \"Bambu PA6-GF\",\n    \"GFN96\": \"Generic PPA-GF\",\n    \"GFN97\": \"Generic PPA-CF\",\n    \"GFN98\": \"Generic PA-CF\",\n    \"GFN99\": \"Generic PA\",\n    \"GFP95\": \"Generic PP-GF\",\n    \"GFP96\": \"Generic PP-CF\",\n    \"GFP97\": \"Generic PP\",\n    \"GFP98\": \"Generic PE-CF\",\n    \"GFP99\": \"Generic PE\",\n    \"GFR98\": \"Generic PHA\",\n    \"GFR99\": \"Generic EVA\",\n    \"GFS00\": \"Bambu Support W\",\n    \"GFS01\": \"Bambu Support G\",\n    \"GFS02\": \"Bambu Support For PLA\",\n    \"GFS03\": \"Bambu Support For PA/PET\",\n    \"GFS04\": \"Bambu PVA\",\n    \"GFS05\": \"Bambu Support For PLA/PETG\",\n    \"GFS06\": \"Bambu Support for ABS\",\n    \"GFS97\": \"Generic BVOH\",\n    \"GFS98\": \"Generic HIPS\",\n    \"GFS99\": \"Generic PVA\",\n    \"GFT01\": \"Bambu PET-CF\",\n    \"GFT02\": \"Bambu PPS-CF\",\n    \"GFT97\": \"Generic PPS\",\n    \"GFT98\": \"Generic PPS-CF\",\n    \"GFU00\": \"Bambu TPU 95A HF\",\n    \"GFU01\": \"Bambu TPU 95A\",\n    \"GFU02\": \"Bambu TPU for AMS\",\n    \"GFU98\": \"Generic TPU for AMS\",\n    \"GFU99\": \"Generic TPU\",\n}\n\n\nasync def _enrich_from_local_presets(\n    unresolved_ids: list[str],\n    result: dict,\n    db: AsyncSession,\n) -> dict:\n    \"\"\"Fall back to local profiles for filament IDs not resolved by cloud.\n\n    Matches by checking the setting_id field inside the local preset's\n    resolved JSON blob (stored in the 'setting' column).\n    \"\"\"\n    from sqlalchemy import text\n\n    from backend.app.models.local_preset import LocalPreset\n\n    # Build lookup: converted setting_id -> original filament_id\n    id_map: dict[str, str] = {}\n    for fid in unresolved_ids:\n        converted = _filament_id_to_setting_id(fid)\n        id_map[converted] = fid\n        # Also map the original in case the JSON uses that form\n        id_map[fid] = fid\n\n    try:\n        # Query filament presets that have a setting_id matching any of our IDs\n        from backend.app.core.db_dialect import is_sqlite\n\n        if is_sqlite():\n            json_filter = text(\"json_extract(setting, '$.setting_id') IS NOT NULL\")\n        else:\n            json_filter = text(\"(setting::jsonb->>'setting_id') IS NOT NULL\")\n        candidates = await db.execute(\n            select(LocalPreset).where(\n                LocalPreset.preset_type == \"filament\",\n                json_filter,\n            )\n        )\n        for preset in candidates.scalars().all():\n            try:\n                setting_data = json.loads(preset.setting) if isinstance(preset.setting, str) else preset.setting\n                preset_setting_id = setting_data.get(\"setting_id\", \"\")\n                if preset_setting_id in id_map:\n                    original_id = id_map[preset_setting_id]\n                    info = {\"name\": preset.name, \"k\": None}\n                    # Try to extract K value from the local preset\n                    pa = setting_data.get(\"pressure_advance\")\n                    if pa is not None:\n                        try:\n                            k_val = float(pa[0]) if isinstance(pa, list) else float(pa)\n                            info[\"k\"] = k_val\n                        except (ValueError, TypeError, IndexError):\n                            pass\n                    _filament_cache[original_id] = info\n                    result[original_id] = info\n            except Exception:\n                continue\n    except Exception as e:\n        logger.warning(\"Failed to search local presets for filament info: %s\", e)\n\n    # Phase 4: Fall back to built-in filament name table for any still without a name\n    for fid in unresolved_ids:\n        if fid not in result or not result[fid].get(\"name\"):\n            name = _BUILTIN_FILAMENT_NAMES.get(fid, \"\")\n            if name:\n                # Preserve K value from earlier phases if available\n                existing_k = result.get(fid, {}).get(\"k\")\n                info = {\"name\": name, \"k\": existing_k}\n                _filament_cache[fid] = info\n                result[fid] = info\n\n    # Fill remaining unresolved with empty entries\n    for fid in unresolved_ids:\n        if fid not in result:\n            _filament_cache[fid] = {\"name\": \"\", \"k\": None}\n            result[fid] = {\"name\": \"\", \"k\": None}\n\n    return result\n\n\n# _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids\n_filament_id_to_setting_id = filament_id_to_setting_id\n\n\n@router.post(\"/filament-info\")\nasync def get_filament_info(\n    setting_ids: list[str] = Body(...),\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"\n    Get filament preset info (name and K value) for multiple setting IDs.\n\n    Used to enrich AMS tray and nozzle rack tooltips with preset data.\n    Lookup order: cache → cloud → local profiles → built-in table → empty fallback.\n    \"\"\"\n    import time\n\n    logger.info(\"get_filament_info called with %s IDs: %s\", len(setting_ids), setting_ids)\n\n    global _filament_cache, _filament_cache_time\n\n    # Clear stale cache\n    if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:\n        _filament_cache = {}\n        _filament_cache_time = time.time()\n\n    result = {}\n    unresolved_ids: list[str] = []\n\n    # Phase 1: Check cache\n    for setting_id in setting_ids:\n        if not setting_id:\n            continue\n        if setting_id in _filament_cache:\n            result[setting_id] = _filament_cache[setting_id]\n        else:\n            unresolved_ids.append(setting_id)\n\n    # Phase 2: Try cloud for uncached IDs\n    if unresolved_ids:\n        cloud = await build_authenticated_cloud(db, current_user)\n        if cloud is not None and cloud.is_authenticated:\n            try:\n                still_unresolved: list[str] = []\n                for setting_id in unresolved_ids:\n                    try:\n                        api_setting_id = _filament_id_to_setting_id(setting_id)\n                        data = await cloud.get_setting_detail(api_setting_id)\n                        setting = data.get(\"setting\", {})\n                        name = data.get(\"name\", \"\")\n                        k_value = setting.get(\"pressure_advance\")\n                        if k_value is not None:\n                            try:\n                                k_value = float(k_value)\n                            except (ValueError, TypeError):\n                                k_value = None\n\n                        info = {\"name\": name, \"k\": k_value}\n                        _filament_cache[setting_id] = info\n                        result[setting_id] = info\n\n                        if not name:\n                            still_unresolved.append(setting_id)\n                    except Exception as e:\n                        logger.warning(\n                            f\"Failed to get cloud preset {setting_id} \"\n                            f\"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}\"\n                        )\n                        still_unresolved.append(setting_id)\n\n                unresolved_ids = still_unresolved\n            finally:\n                await cloud.close()\n        elif cloud is not None:\n            await cloud.close()\n\n    # Phase 3: Try local profiles for any IDs still without a name\n    if unresolved_ids:\n        result = await _enrich_from_local_presets(unresolved_ids, result, db)\n\n    return result\n\n\n@router.get(\"/devices\", response_model=list[CloudDevice])\nasync def get_devices(\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n):\n    \"\"\"\n    Get list of bound printer devices.\n\n    Returns printers registered to the user's Bambu account.\n    \"\"\"\n    cloud = await build_authenticated_cloud(db, current_user)\n    if cloud is None or not cloud.is_authenticated:\n        raise HTTPException(status_code=401, detail=\"Not authenticated\")\n\n    try:\n        data = await cloud.get_devices()\n        devices = data.get(\"devices\", [])\n\n        return [\n            CloudDevice(\n                dev_id=d.get(\"dev_id\", \"\"),\n                name=d.get(\"name\", \"Unknown\"),\n                dev_model_name=d.get(\"dev_model_name\"),\n                dev_product_name=d.get(\"dev_product_name\"),\n                online=d.get(\"online\", False),\n            )\n            for d in devices\n        ]\n    except BambuCloudAuthError:\n        await clear_token(db, current_user)\n        raise HTTPException(status_code=401, detail=\"Authentication expired\")\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n@router.get(\"/firmware-updates\", response_model=FirmwareUpdatesResponse)\nasync def get_firmware_updates(\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),\n):\n    \"\"\"\n    Check for firmware updates for all bound devices.\n\n    Returns firmware version info for each device including:\n    - Current installed version\n    - Latest available version\n    - Whether an update is available\n    - Release notes for the latest version\n\n    Requires cloud authentication.\n    \"\"\"\n    cloud = await build_authenticated_cloud(db, current_user)\n    if cloud is None or not cloud.is_authenticated:\n        raise HTTPException(status_code=401, detail=\"Not authenticated\")\n\n    try:\n        # First get list of bound devices\n        devices_data = await cloud.get_devices()\n        devices = devices_data.get(\"devices\", [])\n\n        updates = []\n        updates_available = 0\n\n        # Check firmware for each device\n        for device in devices:\n            device_id = device.get(\"dev_id\", \"\")\n            device_name = device.get(\"name\", \"Unknown\")\n\n            try:\n                firmware_info = await cloud.get_firmware_version(device_id)\n                update_available = firmware_info.get(\"update_available\", False)\n\n                if update_available:\n                    updates_available += 1\n\n                updates.append(\n                    FirmwareUpdateInfo(\n                        device_id=device_id,\n                        device_name=device_name,\n                        current_version=firmware_info.get(\"current_version\"),\n                        latest_version=firmware_info.get(\"latest_version\"),\n                        update_available=update_available,\n                        release_notes=firmware_info.get(\"release_notes\"),\n                    )\n                )\n            except BambuCloudError as e:\n                logger.warning(\"Failed to get firmware info for %s: %s\", device_name, e)\n                # Still include device but with unknown firmware status\n                updates.append(\n                    FirmwareUpdateInfo(\n                        device_id=device_id,\n                        device_name=device_name,\n                        current_version=None,\n                        latest_version=None,\n                        update_available=False,\n                        release_notes=None,\n                    )\n                )\n\n        return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)\n\n    except BambuCloudAuthError:\n        await clear_token(db, current_user)\n        raise HTTPException(status_code=401, detail=\"Authentication expired\")\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n@router.post(\"/settings\")\nasync def create_setting(\n    request: SlicerSettingCreate,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Create a new slicer preset/setting.\n\n    Creates a new preset on Bambu Cloud. The preset inherits from a base preset\n    and only stores the delta (modified values).\n\n    Type should be: 'filament', 'print', or 'printer'\n    \"\"\"\n    cloud = await build_authenticated_cloud(db, current_user)\n    if cloud is None or not cloud.is_authenticated:\n        raise HTTPException(status_code=401, detail=\"Not authenticated\")\n\n    try:\n        data = await cloud.create_setting(\n            preset_type=request.type,\n            name=request.name,\n            base_id=request.base_id,\n            setting=request.setting,\n            version=request.version,\n        )\n        return data\n    except BambuCloudAuthError:\n        await clear_token(db, current_user)\n        raise HTTPException(status_code=401, detail=\"Authentication expired\")\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n@router.put(\"/settings/{setting_id}\")\nasync def update_setting(\n    setting_id: str,\n    request: SlicerSettingUpdate,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Update an existing slicer preset/setting.\n\n    Updates the preset's name and/or settings on Bambu Cloud.\n    \"\"\"\n    cloud = await build_authenticated_cloud(db, current_user)\n    if cloud is None or not cloud.is_authenticated:\n        raise HTTPException(status_code=401, detail=\"Not authenticated\")\n\n    try:\n        data = await cloud.update_setting(\n            setting_id=setting_id,\n            name=request.name,\n            setting=request.setting,\n        )\n        return data\n    except BambuCloudAuthError:\n        await clear_token(db, current_user)\n        raise HTTPException(status_code=401, detail=\"Authentication expired\")\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n@router.delete(\"/settings/{setting_id}\", response_model=SlicerSettingDeleteResponse)\nasync def delete_setting(\n    setting_id: str,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Delete a slicer preset/setting.\n\n    Removes the preset from Bambu Cloud. This cannot be undone.\n    \"\"\"\n    cloud = await build_authenticated_cloud(db, current_user)\n    if cloud is None or not cloud.is_authenticated:\n        raise HTTPException(status_code=401, detail=\"Not authenticated\")\n\n    try:\n        result = await cloud.delete_setting(setting_id)\n        return SlicerSettingDeleteResponse(\n            success=result.get(\"success\", True),\n            message=result.get(\"message\", \"Setting deleted\"),\n        )\n    except BambuCloudAuthError:\n        await clear_token(db, current_user)\n        raise HTTPException(status_code=401, detail=\"Authentication expired\")\n    except BambuCloudError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n    finally:\n        await cloud.close()\n\n\n# Path to field definition files\nFIELDS_DATA_DIR = Path(__file__).parent.parent.parent / \"data\"\n\n# Cache for field definitions (loaded once)\n_fields_cache: dict[str, dict] = {}\n\n\ndef _load_fields(preset_type: str) -> dict:\n    \"\"\"Load field definitions from JSON file.\"\"\"\n    if preset_type in _fields_cache:\n        return _fields_cache[preset_type]\n\n    # Map API type names to file names\n    file_map = {\n        \"filament\": \"filament_fields.json\",\n        \"print\": \"process_fields.json\",\n        \"process\": \"process_fields.json\",\n        \"printer\": \"printer_fields.json\",\n    }\n\n    filename = file_map.get(preset_type)\n    if not filename:\n        raise HTTPException(status_code=400, detail=f\"Unknown preset type: {preset_type}\")\n\n    file_path = FIELDS_DATA_DIR / filename\n    if not file_path.exists():\n        raise HTTPException(status_code=404, detail=f\"Field definitions not found for: {preset_type}\")\n\n    with open(file_path) as f:\n        data = json.load(f)\n\n    _fields_cache[preset_type] = data\n    return data\n\n\n@router.get(\"/builtin-filaments\")\nasync def get_builtin_filaments(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"\n    Get built-in filament names as a fallback source.\n\n    Returns the static _BUILTIN_FILAMENT_NAMES table as a list of\n    {filament_id, name} objects.  Used by the frontend when cloud\n    and local profiles are unavailable.\n    \"\"\"\n    return [{\"filament_id\": fid, \"name\": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]\n\n\n# Cache for filament_id → name mapping (resolved from cloud preset details)\n_filament_id_name_cache: dict[str, str] = {}\n_filament_id_name_cache_time: float = 0\n\n\n@router.get(\"/filament-id-map\")\nasync def get_filament_id_map(\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"\n    Get filament_id → name mapping for user cloud presets.\n\n    K-profiles store a filament_id (e.g., \"P4d64437\") which is different from\n    the cloud preset setting_id (e.g., \"PFUS9ac902733670a9\"). This endpoint\n    fetches details for all custom presets and returns the mapping.\n    Cached for 5 minutes.\n    \"\"\"\n    import time\n\n    global _filament_id_name_cache, _filament_id_name_cache_time\n\n    if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:\n        return _filament_id_name_cache\n\n    cloud = await build_authenticated_cloud(db, current_user)\n    if cloud is None or not cloud.is_authenticated:\n        if cloud is not None:\n            await cloud.close()\n        return _filament_id_name_cache or {}\n\n    try:\n        data = await cloud.get_slicer_settings()\n        custom_presets = data.get(\"filament\", {}).get(\"private\", [])\n\n        result: dict[str, str] = {}\n        for preset in custom_presets:\n            setting_id = preset.get(\"setting_id\", \"\")\n            if not setting_id:\n                continue\n            try:\n                detail = await cloud.get_setting_detail(setting_id)\n                fid = detail.get(\"filament_id\", \"\")\n                name = detail.get(\"name\", \"\")\n                if fid and name:\n                    # Strip printer/nozzle suffix: \"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle\" → \"Devil Design PLA Basic\"\n                    clean_name = name.split(\" @\")[0].strip() if \" @\" in name else name\n                    result[fid] = clean_name\n            except Exception:\n                pass\n\n        _filament_id_name_cache = result\n        _filament_id_name_cache_time = time.time()\n        return result\n    except Exception:\n        return _filament_id_name_cache or {}\n    finally:\n        await cloud.close()\n\n\n@router.get(\"/fields/{preset_type}\")\nasync def get_preset_fields(\n    preset_type: Literal[\"filament\", \"print\", \"process\", \"printer\"],\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Get field definitions for a preset type.\n\n    Returns a list of field definitions including:\n    - key: The setting key name\n    - label: Human-readable label\n    - type: Field type (text, number, boolean, select)\n    - category: Grouping category\n    - description: Field description\n    - options: For select fields, available options\n    - unit: Unit of measurement (if applicable)\n    - min/max/step: For number fields, validation constraints\n    \"\"\"\n    data = _load_fields(preset_type)\n    return data\n\n\n@router.get(\"/fields\")\nasync def get_all_preset_fields(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),\n):\n    \"\"\"\n    Get all field definitions for all preset types.\n\n    Returns field definitions organized by type.\n    \"\"\"\n    return {\n        \"filament\": _load_fields(\"filament\"),\n        \"process\": _load_fields(\"process\"),\n        \"printer\": _load_fields(\"printer\"),\n    }\n"
  },
  {
    "path": "backend/app/api/routes/discovery.py",
    "content": "\"\"\"\nPrinter discovery API endpoints.\n\nProvides endpoints for discovering Bambu Lab printers on the local network.\nSupports both SSDP discovery (for native installs) and subnet scanning (for Docker).\n\"\"\"\n\nimport logging\n\nfrom fastapi import APIRouter\nfrom pydantic import BaseModel\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.user import User\nfrom backend.app.services.discovery import (\n    discovery_service,\n    is_running_in_docker,\n    subnet_scanner,\n)\nfrom backend.app.services.network_utils import get_network_interfaces\n\nlogger = logging.getLogger(__name__)\nrouter = APIRouter(prefix=\"/discovery\", tags=[\"discovery\"])\n\n\nclass DiscoveryStatus(BaseModel):\n    \"\"\"Discovery status response.\"\"\"\n\n    running: bool\n\n\nclass DiscoveryInfo(BaseModel):\n    \"\"\"Discovery environment info.\"\"\"\n\n    is_docker: bool\n    ssdp_running: bool\n    scan_running: bool\n    subnets: list[str] = []\n\n\nclass SubnetScanRequest(BaseModel):\n    \"\"\"Request to scan a subnet.\"\"\"\n\n    subnet: str  # CIDR notation, e.g., \"192.168.1.0/24\"\n    timeout: float = 1.0  # Connection timeout per host\n\n\nclass SubnetScanStatus(BaseModel):\n    \"\"\"Subnet scan status response.\"\"\"\n\n    running: bool\n    scanned: int\n    total: int\n\n\nclass DiscoveredPrinterResponse(BaseModel):\n    \"\"\"Discovered printer response.\"\"\"\n\n    serial: str\n    name: str\n    ip_address: str\n    model: str | None = None\n    discovered_at: str | None = None\n\n\n@router.get(\"/info\", response_model=DiscoveryInfo)\nasync def get_discovery_info(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),\n):\n    \"\"\"Get discovery environment info (Docker detection, etc.).\"\"\"\n    subnets = [iface[\"subnet\"] for iface in get_network_interfaces()]\n    return DiscoveryInfo(\n        is_docker=is_running_in_docker(),\n        ssdp_running=discovery_service.is_running,\n        scan_running=subnet_scanner.is_running,\n        subnets=subnets,\n    )\n\n\n@router.get(\"/status\", response_model=DiscoveryStatus)\nasync def get_discovery_status(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),\n):\n    \"\"\"Get the current SSDP discovery status.\"\"\"\n    return DiscoveryStatus(running=discovery_service.is_running)\n\n\n@router.post(\"/start\", response_model=DiscoveryStatus)\nasync def start_discovery(\n    duration: float = 10.0,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),\n):\n    \"\"\"Start SSDP printer discovery.\n\n    Args:\n        duration: Discovery duration in seconds (default 10)\n    \"\"\"\n    await discovery_service.start(duration=duration)\n    return DiscoveryStatus(running=discovery_service.is_running)\n\n\n@router.post(\"/stop\", response_model=DiscoveryStatus)\nasync def stop_discovery(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),\n):\n    \"\"\"Stop SSDP printer discovery.\"\"\"\n    await discovery_service.stop()\n    return DiscoveryStatus(running=discovery_service.is_running)\n\n\n@router.get(\"/printers\", response_model=list[DiscoveredPrinterResponse])\nasync def get_discovered_printers(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),\n):\n    \"\"\"Get list of discovered printers (from both SSDP and subnet scan).\"\"\"\n    # Combine results from both discovery methods\n    printers = {}\n\n    # Add SSDP discovered printers\n    for p in discovery_service.discovered_printers:\n        printers[p.ip_address] = p\n\n    # Add subnet scan discovered printers (may override if same IP)\n    for p in subnet_scanner.discovered_printers:\n        if p.ip_address not in printers:\n            printers[p.ip_address] = p\n\n    return [\n        DiscoveredPrinterResponse(\n            serial=p.serial,\n            name=p.name,\n            ip_address=p.ip_address,\n            model=p.model,\n            discovered_at=p.discovered_at,\n        )\n        for p in printers.values()\n    ]\n\n\n# Subnet scanning endpoints (for Docker environments)\n\n\n@router.post(\"/scan\", response_model=SubnetScanStatus)\nasync def start_subnet_scan(\n    request: SubnetScanRequest,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),\n):\n    \"\"\"Start a subnet scan for Bambu printers.\n\n    Use this when running in Docker where SSDP multicast doesn't work.\n\n    Args:\n        request: Subnet to scan in CIDR notation (e.g., \"192.168.1.0/24\")\n    \"\"\"\n    # Start scan in background\n    import asyncio\n\n    asyncio.create_task(subnet_scanner.scan_subnet(request.subnet, request.timeout))\n\n    # Return immediate status\n    scanned, total = subnet_scanner.progress\n    return SubnetScanStatus(\n        running=subnet_scanner.is_running,\n        scanned=scanned,\n        total=total,\n    )\n\n\n@router.get(\"/scan/status\", response_model=SubnetScanStatus)\nasync def get_scan_status(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),\n):\n    \"\"\"Get the current subnet scan status.\"\"\"\n    scanned, total = subnet_scanner.progress\n    return SubnetScanStatus(\n        running=subnet_scanner.is_running,\n        scanned=scanned,\n        total=total,\n    )\n\n\n@router.post(\"/scan/stop\", response_model=SubnetScanStatus)\nasync def stop_subnet_scan(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),\n):\n    \"\"\"Stop the current subnet scan.\"\"\"\n    subnet_scanner.stop()\n    scanned, total = subnet_scanner.progress\n    return SubnetScanStatus(\n        running=subnet_scanner.is_running,\n        scanned=scanned,\n        total=total,\n    )\n"
  },
  {
    "path": "backend/app/api/routes/external_links.py",
    "content": "\"\"\"API routes for external sidebar links.\"\"\"\n\nimport logging\nimport uuid\nfrom pathlib import Path\n\nfrom fastapi import APIRouter, Depends, File, HTTPException, UploadFile\nfrom fastapi.responses import FileResponse\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled\nfrom backend.app.core.config import settings as app_settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.external_link import ExternalLink\nfrom backend.app.models.user import User\nfrom backend.app.schemas.external_link import (\n    ExternalLinkCreate,\n    ExternalLinkReorder,\n    ExternalLinkResponse,\n    ExternalLinkUpdate,\n)\n\n# Directory for storing custom icons\nICONS_DIR = app_settings.base_dir / \"icons\"\nALLOWED_EXTENSIONS = {\".png\", \".jpg\", \".jpeg\", \".gif\", \".svg\", \".webp\", \".ico\"}\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/external-links\", tags=[\"external-links\"])\n\n\n@router.get(\"/\", response_model=list[ExternalLinkResponse])\nasync def list_external_links(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),\n):\n    \"\"\"List all external links ordered by sort_order.\"\"\"\n    result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))\n    links = result.scalars().all()\n    return links\n\n\n@router.post(\"/\", response_model=ExternalLinkResponse)\nasync def create_external_link(\n    link_data: ExternalLinkCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),\n):\n    \"\"\"Create a new external link.\"\"\"\n    # Get the highest sort_order to place new link at end\n    result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1))\n    last_link = result.scalar_one_or_none()\n    next_order = (last_link.sort_order + 1) if last_link else 0\n\n    link = ExternalLink(\n        name=link_data.name,\n        url=link_data.url,\n        icon=link_data.icon,\n        sort_order=next_order,\n    )\n\n    db.add(link)\n    await db.commit()\n    await db.refresh(link)\n\n    logger.info(\"Created external link: %s -> %s\", link.name, link.url)\n\n    return link\n\n\n@router.get(\"/{link_id}\", response_model=ExternalLinkResponse)\nasync def get_external_link(\n    link_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),\n):\n    \"\"\"Get a specific external link.\"\"\"\n    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))\n    link = result.scalar_one_or_none()\n\n    if not link:\n        raise HTTPException(status_code=404, detail=\"External link not found\")\n\n    return link\n\n\n@router.patch(\"/{link_id}\", response_model=ExternalLinkResponse)\nasync def update_external_link(\n    link_id: int,\n    update_data: ExternalLinkUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),\n):\n    \"\"\"Update an external link.\"\"\"\n    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))\n    link = result.scalar_one_or_none()\n\n    if not link:\n        raise HTTPException(status_code=404, detail=\"External link not found\")\n\n    # Update only provided fields\n    update_dict = update_data.model_dump(exclude_unset=True)\n    for key, value in update_dict.items():\n        setattr(link, key, value)\n\n    await db.commit()\n    await db.refresh(link)\n\n    logger.info(\"Updated external link: %s\", link.name)\n\n    return link\n\n\n@router.delete(\"/{link_id}\")\nasync def delete_external_link(\n    link_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),\n):\n    \"\"\"Delete an external link.\"\"\"\n    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))\n    link = result.scalar_one_or_none()\n\n    if not link:\n        raise HTTPException(status_code=404, detail=\"External link not found\")\n\n    name = link.name\n    await db.delete(link)\n    await db.commit()\n\n    logger.info(\"Deleted external link: %s\", name)\n\n    return {\"message\": f\"External link '{name}' deleted\"}\n\n\n@router.put(\"/reorder\", response_model=list[ExternalLinkResponse])\nasync def reorder_external_links(\n    reorder_data: ExternalLinkReorder,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),\n):\n    \"\"\"Update the sort order of external links.\"\"\"\n    # Update sort_order for each link based on position in the list\n    for index, link_id in enumerate(reorder_data.ids):\n        result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))\n        link = result.scalar_one_or_none()\n        if link:\n            link.sort_order = index\n\n    await db.commit()\n\n    # Return updated list\n    result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))\n    links = result.scalars().all()\n\n    logger.info(\"Reordered %s external links\", len(reorder_data.ids))\n\n    return links\n\n\n@router.post(\"/{link_id}/icon\", response_model=ExternalLinkResponse)\nasync def upload_icon(\n    link_id: int,\n    file: UploadFile = File(...),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),\n):\n    \"\"\"Upload a custom icon for an external link.\"\"\"\n    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))\n    link = result.scalar_one_or_none()\n\n    if not link:\n        raise HTTPException(status_code=404, detail=\"External link not found\")\n\n    # Validate file extension\n    if not file.filename:\n        raise HTTPException(status_code=400, detail=\"No filename provided\")\n\n    ext = Path(file.filename).suffix.lower()\n    if ext not in ALLOWED_EXTENSIONS:\n        raise HTTPException(status_code=400, detail=f\"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}\")\n\n    # Create icons directory if it doesn't exist\n    ICONS_DIR.mkdir(parents=True, exist_ok=True)\n\n    # Delete old custom icon if exists\n    if link.custom_icon:\n        old_path = ICONS_DIR / link.custom_icon\n        if old_path.exists():\n            old_path.unlink()\n\n    # Generate unique filename\n    filename = f\"{uuid.uuid4().hex}{ext}\"\n    filepath = ICONS_DIR / filename\n\n    # Save file\n    content = await file.read()\n    with open(filepath, \"wb\") as f:\n        f.write(content)\n\n    # Update link\n    link.custom_icon = filename\n    await db.commit()\n    await db.refresh(link)\n\n    logger.info(\"Uploaded custom icon for link %s: %s\", link.name, filename)\n\n    return link\n\n\n@router.delete(\"/{link_id}/icon\", response_model=ExternalLinkResponse)\nasync def delete_icon(\n    link_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),\n):\n    \"\"\"Delete the custom icon for an external link.\"\"\"\n    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))\n    link = result.scalar_one_or_none()\n\n    if not link:\n        raise HTTPException(status_code=404, detail=\"External link not found\")\n\n    if link.custom_icon:\n        filepath = ICONS_DIR / link.custom_icon\n        if filepath.exists():\n            filepath.unlink()\n        link.custom_icon = None\n        await db.commit()\n        await db.refresh(link)\n        logger.info(\"Deleted custom icon for link %s\", link.name)\n\n    return link\n\n\n@router.get(\"/{link_id}/icon\")\nasync def get_icon(\n    link_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get the custom icon for an external link.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))\n    link = result.scalar_one_or_none()\n\n    if not link:\n        raise HTTPException(status_code=404, detail=\"External link not found\")\n\n    if not link.custom_icon:\n        raise HTTPException(status_code=404, detail=\"No custom icon set\")\n\n    filepath = ICONS_DIR / link.custom_icon\n    if not filepath.exists():\n        raise HTTPException(status_code=404, detail=\"Icon file not found\")\n\n    return FileResponse(filepath)\n"
  },
  {
    "path": "backend/app/api/routes/filaments.py",
    "content": "from fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.filament import Filament\nfrom backend.app.models.user import User\nfrom backend.app.schemas.filament import (\n    FilamentCostCalculation,\n    FilamentCreate,\n    FilamentResponse,\n    FilamentUpdate,\n)\n\nrouter = APIRouter(prefix=\"/filament-catalog\", tags=[\"filament-catalog\"])\n\n\n@router.get(\"/\", response_model=list[FilamentResponse])\nasync def list_filaments(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"List all filaments.\"\"\"\n    result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))\n    return list(result.scalars().all())\n\n\n@router.post(\"/\", response_model=FilamentResponse)\nasync def create_filament(\n    filament_data: FilamentCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),\n):\n    \"\"\"Create a new filament entry.\"\"\"\n    filament = Filament(**filament_data.model_dump())\n    db.add(filament)\n    await db.commit()\n    await db.refresh(filament)\n    return filament\n\n\n@router.get(\"/{filament_id}\", response_model=FilamentResponse)\nasync def get_filament(\n    filament_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"Get a specific filament.\"\"\"\n    result = await db.execute(select(Filament).where(Filament.id == filament_id))\n    filament = result.scalar_one_or_none()\n    if not filament:\n        raise HTTPException(404, \"Filament not found\")\n    return filament\n\n\n@router.patch(\"/{filament_id}\", response_model=FilamentResponse)\nasync def update_filament(\n    filament_id: int,\n    filament_data: FilamentUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),\n):\n    \"\"\"Update a filament.\"\"\"\n    result = await db.execute(select(Filament).where(Filament.id == filament_id))\n    filament = result.scalar_one_or_none()\n    if not filament:\n        raise HTTPException(404, \"Filament not found\")\n\n    for field, value in filament_data.model_dump(exclude_unset=True).items():\n        setattr(filament, field, value)\n\n    await db.commit()\n    await db.refresh(filament)\n    return filament\n\n\n@router.delete(\"/{filament_id}\")\nasync def delete_filament(\n    filament_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),\n):\n    \"\"\"Delete a filament.\"\"\"\n    result = await db.execute(select(Filament).where(Filament.id == filament_id))\n    filament = result.scalar_one_or_none()\n    if not filament:\n        raise HTTPException(404, \"Filament not found\")\n\n    await db.delete(filament)\n    await db.commit()\n    return {\"status\": \"deleted\"}\n\n\n@router.post(\"/calculate-cost\", response_model=FilamentCostCalculation)\nasync def calculate_cost(\n    filament_id: int,\n    weight_grams: float,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"Calculate the cost for a given weight of filament.\"\"\"\n    result = await db.execute(select(Filament).where(Filament.id == filament_id))\n    filament = result.scalar_one_or_none()\n    if not filament:\n        raise HTTPException(404, \"Filament not found\")\n\n    cost = (weight_grams / 1000) * filament.cost_per_kg\n\n    return FilamentCostCalculation(\n        filament_id=filament.id,\n        filament_name=filament.name,\n        weight_grams=weight_grams,\n        cost=round(cost, 2),\n        currency=filament.currency,\n    )\n\n\n@router.get(\"/by-type/{filament_type}\", response_model=list[FilamentResponse])\nasync def get_filaments_by_type(\n    filament_type: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"Get all filaments of a specific type.\"\"\"\n    result = await db.execute(select(Filament).where(Filament.type.ilike(f\"%{filament_type}%\")).order_by(Filament.name))\n    return list(result.scalars().all())\n\n\n@router.post(\"/seed-defaults\")\nasync def seed_default_filaments(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),\n):\n    \"\"\"Seed the database with common filament types.\"\"\"\n    defaults = [\n        {\n            \"name\": \"Generic PLA\",\n            \"type\": \"PLA\",\n            \"cost_per_kg\": 20.0,\n            \"print_temp_min\": 190,\n            \"print_temp_max\": 220,\n            \"bed_temp_min\": 50,\n            \"bed_temp_max\": 60,\n            \"density\": 1.24,\n        },\n        {\n            \"name\": \"Generic PETG\",\n            \"type\": \"PETG\",\n            \"cost_per_kg\": 25.0,\n            \"print_temp_min\": 230,\n            \"print_temp_max\": 250,\n            \"bed_temp_min\": 70,\n            \"bed_temp_max\": 80,\n            \"density\": 1.27,\n        },\n        {\n            \"name\": \"Generic ABS\",\n            \"type\": \"ABS\",\n            \"cost_per_kg\": 22.0,\n            \"print_temp_min\": 230,\n            \"print_temp_max\": 260,\n            \"bed_temp_min\": 90,\n            \"bed_temp_max\": 110,\n            \"density\": 1.04,\n        },\n        {\n            \"name\": \"Generic TPU\",\n            \"type\": \"TPU\",\n            \"cost_per_kg\": 35.0,\n            \"print_temp_min\": 220,\n            \"print_temp_max\": 250,\n            \"bed_temp_min\": 40,\n            \"bed_temp_max\": 60,\n            \"density\": 1.21,\n        },\n        {\n            \"name\": \"Generic ASA\",\n            \"type\": \"ASA\",\n            \"cost_per_kg\": 28.0,\n            \"print_temp_min\": 240,\n            \"print_temp_max\": 260,\n            \"bed_temp_min\": 90,\n            \"bed_temp_max\": 110,\n            \"density\": 1.07,\n        },\n        {\n            \"name\": \"Bambu PLA Basic\",\n            \"type\": \"PLA\",\n            \"brand\": \"Bambu Lab\",\n            \"cost_per_kg\": 20.0,\n            \"print_temp_min\": 190,\n            \"print_temp_max\": 220,\n            \"bed_temp_min\": 35,\n            \"bed_temp_max\": 55,\n            \"density\": 1.24,\n        },\n        {\n            \"name\": \"Bambu PLA Matte\",\n            \"type\": \"PLA\",\n            \"brand\": \"Bambu Lab\",\n            \"cost_per_kg\": 25.0,\n            \"print_temp_min\": 190,\n            \"print_temp_max\": 220,\n            \"bed_temp_min\": 35,\n            \"bed_temp_max\": 55,\n            \"density\": 1.24,\n        },\n        {\n            \"name\": \"Bambu PETG Basic\",\n            \"type\": \"PETG\",\n            \"brand\": \"Bambu Lab\",\n            \"cost_per_kg\": 25.0,\n            \"print_temp_min\": 250,\n            \"print_temp_max\": 270,\n            \"bed_temp_min\": 70,\n            \"bed_temp_max\": 80,\n            \"density\": 1.27,\n        },\n        {\n            \"name\": \"Bambu ABS\",\n            \"type\": \"ABS\",\n            \"brand\": \"Bambu Lab\",\n            \"cost_per_kg\": 30.0,\n            \"print_temp_min\": 260,\n            \"print_temp_max\": 280,\n            \"bed_temp_min\": 90,\n            \"bed_temp_max\": 100,\n            \"density\": 1.04,\n        },\n    ]\n\n    created = 0\n    for filament_data in defaults:\n        # Check if already exists\n        result = await db.execute(\n            select(Filament).where(\n                Filament.name == filament_data[\"name\"],\n                Filament.type == filament_data[\"type\"],\n            )\n        )\n        if result.scalar_one_or_none():\n            continue\n\n        filament = Filament(**filament_data)\n        db.add(filament)\n        created += 1\n\n    await db.commit()\n    return {\"created\": created, \"message\": f\"Created {created} default filaments\"}\n"
  },
  {
    "path": "backend/app/api/routes/firmware.py",
    "content": "\"\"\"\nFirmware Update API Routes\n\nCheck for firmware updates from Bambu Lab.\nAlso provides endpoints for uploading firmware to printers via SD card.\n\"\"\"\n\nimport logging\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel, Field\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.user import User\nfrom backend.app.services.firmware_check import get_firmware_service\nfrom backend.app.services.firmware_update import (\n    FirmwareUploadStatus,\n    get_firmware_update_service,\n    get_upload_state,\n)\nfrom backend.app.services.printer_manager import printer_manager\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/firmware\", tags=[\"firmware\"])\n\n\nclass AvailableFirmwareVersion(BaseModel):\n    \"\"\"A single firmware version announced by Bambu Lab.\"\"\"\n\n    version: str\n    file_available: bool\n    download_url: str | None = None\n    release_notes: str | None = None\n    release_time: str | None = None\n\n\nclass FirmwareUpdateInfo(BaseModel):\n    \"\"\"Firmware update information for a printer.\"\"\"\n\n    printer_id: int\n    printer_name: str\n    model: str | None\n    current_version: str | None\n    latest_version: str | None\n    update_available: bool\n    download_url: str | None = None\n    release_notes: str | None = None\n    available_versions: list[AvailableFirmwareVersion] = Field(default_factory=list)\n\n\nclass FirmwareUpdatesResponse(BaseModel):\n    \"\"\"Response containing firmware updates for all printers.\"\"\"\n\n    updates: list[FirmwareUpdateInfo] = Field(default_factory=list)\n    updates_available: int = Field(0, description=\"Number of printers with updates available\")\n\n\nclass LatestFirmwareInfo(BaseModel):\n    \"\"\"Latest firmware version info for a model.\"\"\"\n\n    model_key: str\n    version: str\n    download_url: str\n    release_notes: str | None = None\n\n\n@router.get(\"/updates\", response_model=FirmwareUpdatesResponse)\nasync def check_firmware_updates(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),\n):\n    \"\"\"\n    Check for firmware updates for all connected printers.\n\n    Compares each printer's current firmware version against the latest\n    available version from Bambu Lab's official firmware download page.\n\n    Note: This does not require cloud authentication - it uses public\n    firmware information from bambulab.com.\n    \"\"\"\n    firmware_service = get_firmware_service()\n\n    # Get all printers from database\n    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))\n    printers = result.scalars().all()\n\n    updates = []\n    updates_available = 0\n\n    for printer in printers:\n        # Get current firmware version from MQTT state\n        current_version = None\n        mqtt_client = printer_manager.get_client(printer.id)\n        if mqtt_client and mqtt_client.state:\n            current_version = mqtt_client.state.firmware_version\n\n        # Check for update\n        model = printer.model or \"Unknown\"\n        update_info = await firmware_service.check_for_update(model, current_version or \"\")\n\n        if update_info[\"update_available\"]:\n            updates_available += 1\n\n        updates.append(\n            FirmwareUpdateInfo(\n                printer_id=printer.id,\n                printer_name=printer.name,\n                model=model,\n                current_version=current_version,\n                latest_version=update_info[\"latest_version\"],\n                update_available=update_info[\"update_available\"],\n                download_url=update_info[\"download_url\"],\n                release_notes=update_info[\"release_notes\"],\n                available_versions=[AvailableFirmwareVersion(**v) for v in update_info.get(\"available_versions\", [])],\n            )\n        )\n\n    return FirmwareUpdatesResponse(updates=updates, updates_available=updates_available)\n\n\n@router.get(\"/updates/{printer_id}\", response_model=FirmwareUpdateInfo)\nasync def check_printer_firmware(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),\n):\n    \"\"\"\n    Check for firmware update for a specific printer.\n    \"\"\"\n    firmware_service = get_firmware_service()\n\n    # Get printer from database\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    # Get current firmware version from MQTT state\n    current_version = None\n    mqtt_client = printer_manager.get_client(printer.id)\n    if mqtt_client and mqtt_client.state:\n        current_version = mqtt_client.state.firmware_version\n\n    # Check for update\n    model = printer.model or \"Unknown\"\n    update_info = await firmware_service.check_for_update(model, current_version or \"\")\n\n    return FirmwareUpdateInfo(\n        printer_id=printer.id,\n        printer_name=printer.name,\n        model=model,\n        current_version=current_version,\n        latest_version=update_info[\"latest_version\"],\n        update_available=update_info[\"update_available\"],\n        download_url=update_info[\"download_url\"],\n        release_notes=update_info[\"release_notes\"],\n        available_versions=[AvailableFirmwareVersion(**v) for v in update_info.get(\"available_versions\", [])],\n    )\n\n\n@router.get(\"/latest\", response_model=list[LatestFirmwareInfo])\nasync def get_all_latest_firmware(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),\n):\n    \"\"\"\n    Get the latest firmware versions for all Bambu Lab printer models.\n\n    This endpoint fetches the latest available firmware versions from\n    Bambu Lab's official firmware download page.\n    \"\"\"\n    firmware_service = get_firmware_service()\n    versions = await firmware_service.get_all_latest_versions()\n\n    return [\n        LatestFirmwareInfo(\n            model_key=key,\n            version=info.version,\n            download_url=info.download_url,\n            release_notes=info.release_notes,\n        )\n        for key, info in versions.items()\n    ]\n\n\n# ============================================================================\n# Firmware Upload Endpoints (for LAN-only firmware updates)\n# ============================================================================\n\n\nclass FirmwareUploadPrepareResponse(BaseModel):\n    \"\"\"Response from firmware upload preparation check.\"\"\"\n\n    can_proceed: bool\n    sd_card_present: bool\n    sd_card_free_space: int = Field(-1, description=\"Free space in bytes, -1 if unknown\")\n    firmware_size: int = Field(0, description=\"Estimated firmware size in bytes\")\n    space_sufficient: bool\n    update_available: bool\n    current_version: str | None = None\n    latest_version: str | None = None\n    target_version: str | None = None\n    firmware_filename: str | None = None\n    errors: list[str] = Field(default_factory=list)\n\n\nclass FirmwareUploadStatusResponse(BaseModel):\n    \"\"\"Response containing firmware upload status.\"\"\"\n\n    status: str  # idle, preparing, downloading, uploading, complete, error\n    progress: int = Field(0, ge=0, le=100)\n    message: str = \"\"\n    error: str | None = None\n    firmware_filename: str | None = None\n    firmware_version: str | None = None\n\n\nclass FirmwareUploadStartResponse(BaseModel):\n    \"\"\"Response when starting a firmware upload.\"\"\"\n\n    started: bool\n    message: str\n\n\n@router.get(\"/updates/{printer_id}/prepare\", response_model=FirmwareUploadPrepareResponse)\nasync def prepare_firmware_upload(\n    printer_id: int,\n    version: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),\n):\n    \"\"\"\n    Check prerequisites for uploading firmware to a printer.\n\n    This performs pre-flight checks including:\n    - SD card presence\n    - Available storage space\n    - Update availability\n\n    Call this before starting a firmware upload to ensure the operation\n    can succeed.\n    \"\"\"\n    update_service = get_firmware_update_service()\n    result = await update_service.prepare_update(printer_id, db, target_version=version)\n    return FirmwareUploadPrepareResponse(**result)\n\n\n@router.post(\"/updates/{printer_id}/upload\", response_model=FirmwareUploadStartResponse)\nasync def start_firmware_upload(\n    printer_id: int,\n    version: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),\n):\n    \"\"\"\n    Start uploading firmware to a printer's SD card.\n\n    This initiates a background process that:\n    1. Downloads the firmware from Bambu Lab\n    2. Uploads it to the printer's SD card via FTP\n\n    Progress is broadcast via WebSocket with type \"firmware_upload_progress\".\n    Use GET /firmware/updates/{printer_id}/upload/status for polling fallback.\n\n    After upload completes, the user must trigger the update from the\n    printer's screen (Settings > Firmware).\n    \"\"\"\n    # First check prerequisites\n    update_service = get_firmware_update_service()\n    prepare_result = await update_service.prepare_update(printer_id, db, target_version=version)\n\n    if not prepare_result[\"can_proceed\"]:\n        errors = prepare_result.get(\"errors\", [\"Cannot proceed with firmware upload\"])\n        raise HTTPException(\n            status_code=400,\n            detail=\"; \".join(errors),\n        )\n\n    # Start the upload\n    started = await update_service.start_upload(printer_id, db, target_version=version)\n\n    if not started:\n        state = get_upload_state(printer_id)\n        if state.status == FirmwareUploadStatus.DOWNLOADING:\n            return FirmwareUploadStartResponse(\n                started=False,\n                message=\"Firmware upload already in progress\",\n            )\n        raise HTTPException(\n            status_code=500,\n            detail=state.error or \"Failed to start firmware upload\",\n        )\n\n    return FirmwareUploadStartResponse(\n        started=True,\n        message=\"Firmware upload started. Progress will be broadcast via WebSocket.\",\n    )\n\n\n@router.get(\"/updates/{printer_id}/upload/status\", response_model=FirmwareUploadStatusResponse)\nasync def get_firmware_upload_status(\n    printer_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),\n):\n    \"\"\"\n    Get the current status of a firmware upload operation.\n\n    This is a polling fallback for clients that don't use WebSocket.\n    For real-time updates, connect to WebSocket and listen for\n    \"firmware_upload_progress\" messages.\n    \"\"\"\n    state = get_upload_state(printer_id)\n    return FirmwareUploadStatusResponse(\n        status=state.status.value,\n        progress=state.progress,\n        message=state.message,\n        error=state.error,\n        firmware_filename=state.firmware_filename,\n        firmware_version=state.firmware_version,\n    )\n"
  },
  {
    "path": "backend/app/api/routes/github_backup.py",
    "content": "\"\"\"API routes for GitHub profile backup.\"\"\"\n\nimport logging\n\nfrom fastapi import APIRouter, Depends, HTTPException, Query\nfrom sqlalchemy import delete, desc, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog\nfrom backend.app.models.user import User\nfrom backend.app.schemas.github_backup import (\n    GitHubBackupConfigCreate,\n    GitHubBackupConfigResponse,\n    GitHubBackupConfigUpdate,\n    GitHubBackupLogResponse,\n    GitHubBackupStatus,\n    GitHubBackupTriggerResponse,\n    GitHubTestConnectionResponse,\n)\nfrom backend.app.services.github_backup import github_backup_service\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/github-backup\", tags=[\"github-backup\"])\n\n\ndef _config_to_response(config: GitHubBackupConfig) -> dict:\n    \"\"\"Convert config model to response dict.\"\"\"\n    return {\n        \"id\": config.id,\n        \"repository_url\": config.repository_url,\n        \"has_token\": bool(config.access_token),\n        \"branch\": config.branch,\n        \"schedule_enabled\": config.schedule_enabled,\n        \"schedule_type\": config.schedule_type,\n        \"backup_kprofiles\": config.backup_kprofiles,\n        \"backup_cloud_profiles\": config.backup_cloud_profiles,\n        \"backup_settings\": config.backup_settings,\n        \"backup_spools\": config.backup_spools,\n        \"backup_archives\": config.backup_archives,\n        \"enabled\": config.enabled,\n        \"last_backup_at\": config.last_backup_at,\n        \"last_backup_status\": config.last_backup_status,\n        \"last_backup_message\": config.last_backup_message,\n        \"last_backup_commit_sha\": config.last_backup_commit_sha,\n        \"next_scheduled_run\": config.next_scheduled_run,\n        \"created_at\": config.created_at,\n        \"updated_at\": config.updated_at,\n    }\n\n\n@router.get(\"/config\", response_model=GitHubBackupConfigResponse | None)\nasync def get_config(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Get the current GitHub backup configuration.\"\"\"\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if not config:\n        return None\n\n    return _config_to_response(config)\n\n\n@router.post(\"/config\", response_model=GitHubBackupConfigResponse)\nasync def save_config(\n    config_data: GitHubBackupConfigCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Create or update GitHub backup configuration.\n\n    Only one configuration is supported. If one exists, it will be updated.\n    \"\"\"\n    # Check for existing config\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if config:\n        # Update existing\n        config.repository_url = config_data.repository_url\n        config.access_token = config_data.access_token\n        config.branch = config_data.branch\n        config.schedule_enabled = config_data.schedule_enabled\n        config.schedule_type = config_data.schedule_type.value\n        config.backup_kprofiles = config_data.backup_kprofiles\n        config.backup_cloud_profiles = config_data.backup_cloud_profiles\n        config.backup_settings = config_data.backup_settings\n        config.backup_spools = config_data.backup_spools\n        config.backup_archives = config_data.backup_archives\n        config.enabled = config_data.enabled\n\n        # Calculate next scheduled run if enabled\n        if config.schedule_enabled:\n            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)\n        else:\n            config.next_scheduled_run = None\n\n        logger.info(\"Updated GitHub backup config: %s\", config.repository_url)\n    else:\n        # Create new\n        config = GitHubBackupConfig(\n            repository_url=config_data.repository_url,\n            access_token=config_data.access_token,\n            branch=config_data.branch,\n            schedule_enabled=config_data.schedule_enabled,\n            schedule_type=config_data.schedule_type.value,\n            backup_kprofiles=config_data.backup_kprofiles,\n            backup_cloud_profiles=config_data.backup_cloud_profiles,\n            backup_settings=config_data.backup_settings,\n            backup_spools=config_data.backup_spools,\n            backup_archives=config_data.backup_archives,\n            enabled=config_data.enabled,\n        )\n\n        if config.schedule_enabled:\n            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)\n\n        db.add(config)\n        logger.info(\"Created GitHub backup config: %s\", config.repository_url)\n\n    await db.commit()\n    await db.refresh(config)\n\n    return _config_to_response(config)\n\n\n@router.patch(\"/config\", response_model=GitHubBackupConfigResponse)\nasync def update_config(\n    update_data: GitHubBackupConfigUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Partially update GitHub backup configuration.\"\"\"\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if not config:\n        raise HTTPException(status_code=404, detail=\"No configuration found\")\n\n    update_dict = update_data.model_dump(exclude_unset=True)\n\n    for key, value in update_dict.items():\n        if key == \"schedule_type\" and value is not None:\n            setattr(config, key, value.value)\n        else:\n            setattr(config, key, value)\n\n    # Recalculate next scheduled run if schedule settings changed\n    if \"schedule_enabled\" in update_dict or \"schedule_type\" in update_dict:\n        if config.schedule_enabled:\n            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)\n        else:\n            config.next_scheduled_run = None\n\n    await db.commit()\n    await db.refresh(config)\n\n    logger.info(\"Updated GitHub backup config: %s\", config.repository_url)\n\n    return _config_to_response(config)\n\n\n@router.delete(\"/config\")\nasync def delete_config(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Delete the GitHub backup configuration and all logs.\"\"\"\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if not config:\n        raise HTTPException(status_code=404, detail=\"No configuration found\")\n\n    await db.delete(config)\n    await db.commit()\n\n    logger.info(\"Deleted GitHub backup config\")\n\n    return {\"message\": \"Configuration deleted\"}\n\n\n@router.post(\"/test\", response_model=GitHubTestConnectionResponse)\nasync def test_connection(\n    repo_url: str = Query(..., description=\"GitHub repository URL\"),\n    token: str = Query(..., description=\"Personal Access Token\"),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Test GitHub connection with provided credentials.\"\"\"\n    result = await github_backup_service.test_connection(repo_url, token)\n    return GitHubTestConnectionResponse(**result)\n\n\n@router.post(\"/test-stored\", response_model=GitHubTestConnectionResponse)\nasync def test_stored_connection(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Test GitHub connection using stored configuration.\"\"\"\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if not config:\n        raise HTTPException(status_code=404, detail=\"No configuration found\")\n\n    if not config.access_token:\n        raise HTTPException(status_code=400, detail=\"No access token configured\")\n\n    test_result = await github_backup_service.test_connection(config.repository_url, config.access_token)\n    return GitHubTestConnectionResponse(**test_result)\n\n\n@router.post(\"/run\", response_model=GitHubBackupTriggerResponse)\nasync def trigger_backup(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Manually trigger a backup.\"\"\"\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if not config:\n        raise HTTPException(status_code=404, detail=\"No configuration found. Configure backup first.\")\n\n    if not config.enabled:\n        raise HTTPException(status_code=400, detail=\"Backup is disabled\")\n\n    backup_result = await github_backup_service.run_backup(config.id, trigger=\"manual\")\n\n    return GitHubBackupTriggerResponse(**backup_result)\n\n\n@router.get(\"/status\", response_model=GitHubBackupStatus)\nasync def get_status(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Get current backup status.\"\"\"\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if not config:\n        return GitHubBackupStatus(\n            configured=False,\n            enabled=False,\n            is_running=False,\n            progress=None,\n            last_backup_at=None,\n            last_backup_status=None,\n            next_scheduled_run=None,\n        )\n\n    return GitHubBackupStatus(\n        configured=True,\n        enabled=config.enabled,\n        is_running=github_backup_service.is_running,\n        progress=github_backup_service.progress,\n        last_backup_at=config.last_backup_at,\n        last_backup_status=config.last_backup_status,\n        next_scheduled_run=config.next_scheduled_run,\n    )\n\n\n@router.get(\"/logs\", response_model=list[GitHubBackupLogResponse])\nasync def get_logs(\n    limit: int = Query(default=50, ge=1, le=200),\n    offset: int = Query(default=0, ge=0),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Get backup logs.\"\"\"\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if not config:\n        return []\n\n    logs_result = await db.execute(\n        select(GitHubBackupLog)\n        .where(GitHubBackupLog.config_id == config.id)\n        .order_by(desc(GitHubBackupLog.started_at))\n        .offset(offset)\n        .limit(limit)\n    )\n    logs = logs_result.scalars().all()\n\n    return [\n        GitHubBackupLogResponse(\n            id=log.id,\n            config_id=log.config_id,\n            started_at=log.started_at,\n            completed_at=log.completed_at,\n            status=log.status,\n            trigger=log.trigger,\n            commit_sha=log.commit_sha,\n            files_changed=log.files_changed,\n            error_message=log.error_message,\n        )\n        for log in logs\n    ]\n\n\n@router.delete(\"/logs\")\nasync def clear_logs(\n    keep_last: int = Query(default=10, ge=0, le=100, description=\"Number of recent logs to keep\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),\n):\n    \"\"\"Clear backup logs, optionally keeping the most recent entries.\"\"\"\n    result = await db.execute(select(GitHubBackupConfig).limit(1))\n    config = result.scalar_one_or_none()\n\n    if not config:\n        return {\"deleted\": 0, \"message\": \"No configuration found\"}\n\n    if keep_last > 0:\n        # Get IDs to keep\n        keep_result = await db.execute(\n            select(GitHubBackupLog.id)\n            .where(GitHubBackupLog.config_id == config.id)\n            .order_by(desc(GitHubBackupLog.started_at))\n            .limit(keep_last)\n        )\n        keep_ids = [row[0] for row in keep_result.fetchall()]\n\n        if keep_ids:\n            delete_result = await db.execute(\n                delete(GitHubBackupLog).where(\n                    GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)\n                )\n            )\n        else:\n            delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))\n    else:\n        delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))\n\n    await db.commit()\n\n    deleted_count = delete_result.rowcount\n    logger.info(\"Deleted %s GitHub backup logs (kept %s)\", deleted_count, keep_last)\n\n    return {\"deleted\": deleted_count, \"message\": f\"Deleted {deleted_count} logs\"}\n"
  },
  {
    "path": "backend/app/api/routes/groups.py",
    "content": "\"\"\"Group management API routes.\"\"\"\n\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import (\n    ALL_PERMISSIONS,\n    PERMISSION_CATEGORIES,\n    Permission,\n)\nfrom backend.app.models.group import Group\nfrom backend.app.models.user import User\nfrom backend.app.schemas.group import (\n    GroupCreate,\n    GroupDetailResponse,\n    GroupResponse,\n    GroupUpdate,\n    PermissionCategory,\n    PermissionInfo,\n    PermissionsListResponse,\n    UserBrief,\n)\n\nrouter = APIRouter(prefix=\"/groups\", tags=[\"groups\"])\n\n\ndef _permission_label(perm: Permission) -> str:\n    \"\"\"Convert permission enum to human-readable label.\"\"\"\n    # e.g., \"printers:read\" -> \"Read Printers\"\n    parts = perm.value.split(\":\")\n    if len(parts) == 2:\n        resource, action = parts\n        resource = resource.replace(\"_\", \" \").title()\n        action = action.replace(\"_\", \" \").title()\n        return f\"{action} {resource}\"\n    return perm.value\n\n\n@router.get(\"/permissions\", response_model=PermissionsListResponse)\nasync def list_permissions(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),\n):\n    \"\"\"List all available permissions organized by category.\"\"\"\n    categories = []\n    for name, perms in PERMISSION_CATEGORIES.items():\n        categories.append(\n            PermissionCategory(\n                name=name,\n                permissions=[PermissionInfo(value=p.value, label=_permission_label(p)) for p in perms],\n            )\n        )\n    return PermissionsListResponse(\n        categories=categories,\n        all_permissions=ALL_PERMISSIONS,\n    )\n\n\n@router.get(\"\", response_model=list[GroupResponse])\n@router.get(\"/\", response_model=list[GroupResponse])\nasync def list_groups(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"List all groups.\"\"\"\n    result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))\n    groups = result.scalars().all()\n    return [\n        GroupResponse(\n            id=group.id,\n            name=group.name,\n            description=group.description,\n            permissions=group.permissions or [],\n            is_system=group.is_system,\n            user_count=len(group.users),\n            created_at=group.created_at,\n            updated_at=group.updated_at,\n        )\n        for group in groups\n    ]\n\n\n@router.post(\"\", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)\n@router.post(\"/\", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)\nasync def create_group(\n    group_data: GroupCreate,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Create a new group.\"\"\"\n    # Check if group name already exists\n    existing = await db.execute(select(Group).where(Group.name == group_data.name))\n    if existing.scalar_one_or_none():\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Group name already exists\",\n        )\n\n    # Validate permissions\n    invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]\n    if invalid_perms:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=f\"Invalid permissions: {', '.join(invalid_perms)}\",\n        )\n\n    group = Group(\n        name=group_data.name,\n        description=group_data.description,\n        permissions=group_data.permissions,\n        is_system=False,  # User-created groups are not system groups\n    )\n    db.add(group)\n    await db.commit()\n    await db.refresh(group)\n\n    return GroupResponse(\n        id=group.id,\n        name=group.name,\n        description=group.description,\n        permissions=group.permissions or [],\n        is_system=group.is_system,\n        user_count=0,\n        created_at=group.created_at,\n        updated_at=group.updated_at,\n    )\n\n\n@router.get(\"/{group_id}\", response_model=GroupDetailResponse)\nasync def get_group(\n    group_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get a group by ID with user list.\"\"\"\n    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))\n    group = result.scalar_one_or_none()\n    if not group:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Group not found\",\n        )\n\n    return GroupDetailResponse(\n        id=group.id,\n        name=group.name,\n        description=group.description,\n        permissions=group.permissions or [],\n        is_system=group.is_system,\n        user_count=len(group.users),\n        created_at=group.created_at,\n        updated_at=group.updated_at,\n        users=[UserBrief(id=u.id, username=u.username, is_active=u.is_active) for u in group.users],\n    )\n\n\n@router.patch(\"/{group_id}\", response_model=GroupResponse)\nasync def update_group(\n    group_id: int,\n    group_data: GroupUpdate,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Update a group.\"\"\"\n    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))\n    group = result.scalar_one_or_none()\n    if not group:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Group not found\",\n        )\n\n    # Check if updating name to one that already exists\n    if group_data.name is not None and group_data.name != group.name:\n        existing = await db.execute(select(Group).where(Group.name == group_data.name, Group.id != group_id))\n        if existing.scalar_one_or_none():\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Group name already exists\",\n            )\n        # System groups cannot have their name changed\n        if group.is_system:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Cannot rename system groups\",\n            )\n        group.name = group_data.name\n\n    if group_data.description is not None:\n        group.description = group_data.description\n\n    if group_data.permissions is not None:\n        # Validate permissions\n        invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]\n        if invalid_perms:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=f\"Invalid permissions: {', '.join(invalid_perms)}\",\n            )\n        group.permissions = group_data.permissions\n\n    await db.commit()\n    await db.refresh(group)\n\n    return GroupResponse(\n        id=group.id,\n        name=group.name,\n        description=group.description,\n        permissions=group.permissions or [],\n        is_system=group.is_system,\n        user_count=len(group.users),\n        created_at=group.created_at,\n        updated_at=group.updated_at,\n    )\n\n\n@router.delete(\"/{group_id}\", status_code=status.HTTP_204_NO_CONTENT)\nasync def delete_group(\n    group_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Delete a group (non-system groups only).\"\"\"\n    result = await db.execute(select(Group).where(Group.id == group_id))\n    group = result.scalar_one_or_none()\n    if not group:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Group not found\",\n        )\n\n    if group.is_system:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Cannot delete system groups\",\n        )\n\n    await db.delete(group)\n    await db.commit()\n\n\n@router.post(\"/{group_id}/users/{user_id}\", status_code=status.HTTP_204_NO_CONTENT)\nasync def add_user_to_group(\n    group_id: int,\n    user_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Add a user to a group.\"\"\"\n    # Get group with users\n    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))\n    group = result.scalar_one_or_none()\n    if not group:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Group not found\",\n        )\n\n    # Get user\n    user_result = await db.execute(select(User).where(User.id == user_id))\n    user = user_result.scalar_one_or_none()\n    if not user:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"User not found\",\n        )\n\n    # Check if user is already in group\n    if user in group.users:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"User is already in this group\",\n        )\n\n    group.users.append(user)\n    await db.commit()\n\n\n@router.delete(\"/{group_id}/users/{user_id}\", status_code=status.HTTP_204_NO_CONTENT)\nasync def remove_user_from_group(\n    group_id: int,\n    user_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Remove a user from a group.\"\"\"\n    # Get group with users\n    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))\n    group = result.scalar_one_or_none()\n    if not group:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"Group not found\",\n        )\n\n    # Get user\n    user_result = await db.execute(select(User).where(User.id == user_id))\n    user = user_result.scalar_one_or_none()\n    if not user:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"User not found\",\n        )\n\n    # Check if user is in group\n    if user not in group.users:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"User is not in this group\",\n        )\n\n    group.users.remove(user)\n    await db.commit()\n"
  },
  {
    "path": "backend/app/api/routes/inventory.py",
    "content": "import json\nimport logging\n\nimport httpx\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom fastapi.responses import StreamingResponse\nfrom pydantic import BaseModel\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled\nfrom backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.core.websocket import ws_manager\nfrom backend.app.models.ams_label import AmsLabel\nfrom backend.app.models.color_catalog import ColorCatalogEntry\nfrom backend.app.models.spool import Spool\nfrom backend.app.models.spool_assignment import SpoolAssignment\nfrom backend.app.models.spool_catalog import SpoolCatalogEntry\nfrom backend.app.models.spool_k_profile import SpoolKProfile\nfrom backend.app.models.user import User\nfrom backend.app.schemas.spool import (\n    SpoolAssignmentCreate,\n    SpoolAssignmentResponse,\n    SpoolBulkCreate,\n    SpoolCreate,\n    SpoolKProfileBase,\n    SpoolKProfileResponse,\n    SpoolResponse,\n    SpoolUpdate,\n)\nfrom backend.app.schemas.spool_usage import SpoolUsageHistoryResponse\nfrom backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament\nfrom backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/inventory\", tags=[\"inventory\"])\n\n# Material temperature defaults (nozzle min/max)\nMATERIAL_TEMPS: dict[str, tuple[int, int]] = {\n    \"PLA\": (190, 230),\n    \"PETG\": (220, 260),\n    \"ABS\": (240, 270),\n    \"ASA\": (240, 270),\n    \"TPU\": (200, 240),\n    \"PA\": (260, 290),\n    \"PC\": (250, 280),\n    \"PVA\": (190, 210),\n    \"PLA-CF\": (210, 240),\n    \"PETG-CF\": (240, 270),\n    \"PA-CF\": (270, 300),\n}\n\n# FilamentColors.xyz API\nFILAMENT_COLORS_API = \"https://filamentcolors.xyz/api\"\n\n\n# ── Spool Catalog Schemas ──────────────────────────────────────────────────\n\n\nclass CatalogEntryResponse(BaseModel):\n    id: int\n    name: str\n    weight: int\n    is_default: bool\n\n    class Config:\n        from_attributes = True\n\n\nclass CatalogEntryCreate(BaseModel):\n    name: str\n    weight: int\n\n\nclass CatalogEntryUpdate(BaseModel):\n    name: str\n    weight: int\n\n\nclass BulkDeleteIdsRequest(BaseModel):\n    ids: list[int]\n\n\n# ── Color Catalog Schemas ──────────────────────────────────────────────────\n\n\nclass ColorEntryResponse(BaseModel):\n    id: int\n    manufacturer: str\n    color_name: str\n    hex_color: str\n    material: str | None\n    is_default: bool\n\n    class Config:\n        from_attributes = True\n\n\nclass ColorEntryCreate(BaseModel):\n    manufacturer: str\n    color_name: str\n    hex_color: str\n    material: str | None = None\n\n\nclass ColorEntryUpdate(BaseModel):\n    manufacturer: str\n    color_name: str\n    hex_color: str\n    material: str | None = None\n\n\nclass ColorLookupResult(BaseModel):\n    found: bool\n    hex_color: str | None = None\n    material: str | None = None\n\n\n# ── Spool Catalog CRUD ─────────────────────────────────────────────────────\n\n\n@router.get(\"/catalog\", response_model=list[CatalogEntryResponse])\nasync def get_spool_catalog(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Get all spool catalog entries.\"\"\"\n    result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))\n    return list(result.scalars().all())\n\n\n@router.post(\"/catalog\", response_model=CatalogEntryResponse)\nasync def add_catalog_entry(\n    entry: CatalogEntryCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Add a new spool catalog entry.\"\"\"\n    row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)\n    db.add(row)\n    await db.commit()\n    await db.refresh(row)\n    return row\n\n\n@router.put(\"/catalog/{entry_id}\", response_model=CatalogEntryResponse)\nasync def update_catalog_entry(\n    entry_id: int,\n    entry: CatalogEntryUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Update a spool catalog entry.\"\"\"\n    result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))\n    row = result.scalar_one_or_none()\n    if not row:\n        raise HTTPException(404, \"Entry not found\")\n    row.name = entry.name\n    row.weight = entry.weight\n    await db.commit()\n    await db.refresh(row)\n    return row\n\n\n@router.delete(\"/catalog/{entry_id}\")\nasync def delete_catalog_entry(\n    entry_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Delete a spool catalog entry.\"\"\"\n    result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))\n    row = result.scalar_one_or_none()\n    if not row:\n        raise HTTPException(404, \"Entry not found\")\n    await db.delete(row)\n    await db.commit()\n    return {\"status\": \"deleted\"}\n\n\n@router.post(\"/catalog/bulk-delete\")\nasync def bulk_delete_catalog_entries(\n    data: BulkDeleteIdsRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Delete multiple spool catalog entries by ID.\"\"\"\n    if not data.ids:\n        return {\"deleted\": 0}\n    result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))\n    rows = result.scalars().all()\n    for row in rows:\n        await db.delete(row)\n    await db.commit()\n    return {\"deleted\": len(rows)}\n\n\n@router.post(\"/catalog/reset\")\nasync def reset_spool_catalog(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Reset spool catalog to defaults.\"\"\"\n    await db.execute(select(SpoolCatalogEntry))  # ensure table loaded\n    # Delete all\n    result = await db.execute(select(SpoolCatalogEntry))\n    for row in result.scalars().all():\n        await db.delete(row)\n    # Re-seed defaults\n    for name, weight in DEFAULT_SPOOL_CATALOG:\n        db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))\n    await db.commit()\n    return {\"status\": \"reset\"}\n\n\n# ── Color Catalog CRUD ─────────────────────────────────────────────────────\n\n\n@router.get(\"/colors\", response_model=list[ColorEntryResponse])\nasync def get_color_catalog(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Get all color catalog entries.\"\"\"\n    result = await db.execute(\n        select(ColorCatalogEntry).order_by(\n            ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name\n        )\n    )\n    return list(result.scalars().all())\n\n\n@router.get(\"/colors/map\")\nasync def get_color_name_map(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_auth_if_enabled),\n):\n    \"\"\"Compact {hex: name} map for frontend color-name resolution.\n\n    Not gated on INVENTORY_READ — every page that renders a spool color needs\n    this, including read-only views available to users without inventory access.\n    Normalized to lowercase 6-char hex without '#'. When multiple catalog entries\n    share the same hex (different materials or manufacturers), Bambu Lab wins,\n    then default entries, then the first encountered.\n    \"\"\"\n    result = await db.execute(\n        select(\n            ColorCatalogEntry.hex_color,\n            ColorCatalogEntry.color_name,\n            ColorCatalogEntry.manufacturer,\n            ColorCatalogEntry.is_default,\n        )\n    )\n    mapping: dict[str, tuple[str, int]] = {}  # hex → (name, priority); higher priority wins\n    for hex_color, color_name, manufacturer, is_default in result.all():\n        if not hex_color or not color_name:\n            continue\n        key = hex_color.lstrip(\"#\").lower()[:6]\n        if len(key) != 6:\n            continue\n        priority = 0\n        if manufacturer and manufacturer.strip().lower() == \"bambu lab\":\n            priority += 2\n        if is_default:\n            priority += 1\n        existing = mapping.get(key)\n        if existing is None or priority > existing[1]:\n            mapping[key] = (color_name, priority)\n    return {\"colors\": {k: v[0] for k, v in mapping.items()}}\n\n\n@router.post(\"/colors\", response_model=ColorEntryResponse)\nasync def add_color_entry(\n    entry: ColorEntryCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Add a new color catalog entry.\"\"\"\n    row = ColorCatalogEntry(\n        manufacturer=entry.manufacturer,\n        color_name=entry.color_name,\n        hex_color=entry.hex_color,\n        material=entry.material,\n        is_default=False,\n    )\n    db.add(row)\n    await db.commit()\n    await db.refresh(row)\n    return row\n\n\n@router.put(\"/colors/{entry_id}\", response_model=ColorEntryResponse)\nasync def update_color_entry(\n    entry_id: int,\n    entry: ColorEntryUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Update a color catalog entry.\"\"\"\n    result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))\n    row = result.scalar_one_or_none()\n    if not row:\n        raise HTTPException(404, \"Entry not found\")\n    row.manufacturer = entry.manufacturer\n    row.color_name = entry.color_name\n    row.hex_color = entry.hex_color\n    row.material = entry.material\n    await db.commit()\n    await db.refresh(row)\n    return row\n\n\n@router.delete(\"/colors/{entry_id}\")\nasync def delete_color_entry(\n    entry_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Delete a color catalog entry.\"\"\"\n    result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))\n    row = result.scalar_one_or_none()\n    if not row:\n        raise HTTPException(404, \"Entry not found\")\n    await db.delete(row)\n    await db.commit()\n    return {\"status\": \"deleted\"}\n\n\n@router.post(\"/colors/bulk-delete\")\nasync def bulk_delete_color_entries(\n    data: BulkDeleteIdsRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Delete multiple color catalog entries by ID.\"\"\"\n    if not data.ids:\n        return {\"deleted\": 0}\n    result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))\n    rows = result.scalars().all()\n    for row in rows:\n        await db.delete(row)\n    await db.commit()\n    return {\"deleted\": len(rows)}\n\n\n@router.post(\"/colors/reset\")\nasync def reset_color_catalog(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Reset color catalog to defaults.\"\"\"\n    result = await db.execute(select(ColorCatalogEntry))\n    for row in result.scalars().all():\n        await db.delete(row)\n    for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:\n        db.add(\n            ColorCatalogEntry(\n                manufacturer=manufacturer,\n                color_name=color_name,\n                hex_color=hex_color,\n                material=material,\n                is_default=True,\n            )\n        )\n    await db.commit()\n    return {\"status\": \"reset\"}\n\n\n@router.get(\"/colors/lookup\", response_model=ColorLookupResult)\nasync def lookup_color(\n    manufacturer: str,\n    color_name: str,\n    material: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Look up a color by manufacturer and color name.\"\"\"\n    query = select(ColorCatalogEntry).where(\n        ColorCatalogEntry.manufacturer == manufacturer,\n        ColorCatalogEntry.color_name == color_name,\n    )\n    if material:\n        query = query.where(ColorCatalogEntry.material == material)\n    query = query.limit(1)\n    result = await db.execute(query)\n    row = result.scalar_one_or_none()\n    if row:\n        return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)\n    return ColorLookupResult(found=False)\n\n\n@router.get(\"/colors/search\", response_model=list[ColorEntryResponse])\nasync def search_colors(\n    manufacturer: str | None = None,\n    material: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Search colors by manufacturer and/or material.\"\"\"\n    query = select(ColorCatalogEntry)\n    if manufacturer:\n        query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))\n    if material:\n        query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))\n    query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)\n    result = await db.execute(query)\n    return list(result.scalars().all())\n\n\n@router.post(\"/colors/sync\")\nasync def sync_from_filamentcolors(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Sync colors from FilamentColors.xyz API with progress streaming.\"\"\"\n\n    async def generate():\n        from backend.app.core.database import async_session\n\n        added = 0\n        skipped = 0\n        total_fetched = 0\n        total_available = 0\n\n        try:\n            async with httpx.AsyncClient(timeout=120.0) as client:\n                page = 1\n                while True:\n                    response = await client.get(\n                        f\"{FILAMENT_COLORS_API}/swatch/\",\n                        params={\"page\": page},\n                    )\n                    response.raise_for_status()\n                    data = response.json()\n                    total_available = data.get(\"count\", total_available)\n                    results = data.get(\"results\", [])\n                    if not results:\n                        break\n\n                    async with async_session() as db:\n                        for swatch in results:\n                            total_fetched += 1\n                            manufacturer_data = swatch.get(\"manufacturer\")\n                            manufacturer_name = (\n                                manufacturer_data.get(\"name\", \"\") if isinstance(manufacturer_data, dict) else \"\"\n                            )\n                            filament_type_data = swatch.get(\"filament_type\")\n                            mat = filament_type_data.get(\"name\", \"\") if isinstance(filament_type_data, dict) else None\n                            color_name_val = swatch.get(\"color_name\", \"\")\n                            hex_color_val = swatch.get(\"hex_color\", \"\")\n\n                            if not manufacturer_name or not color_name_val or not hex_color_val:\n                                skipped += 1\n                                continue\n\n                            if not hex_color_val.startswith(\"#\"):\n                                hex_color_val = f\"#{hex_color_val}\"\n\n                            # Check if entry already exists\n                            existing = await db.execute(\n                                select(ColorCatalogEntry)\n                                .where(\n                                    ColorCatalogEntry.manufacturer == manufacturer_name,\n                                    ColorCatalogEntry.color_name == color_name_val,\n                                    ColorCatalogEntry.material == mat,\n                                )\n                                .limit(1)\n                            )\n                            if existing.scalar_one_or_none():\n                                skipped += 1\n                            else:\n                                db.add(\n                                    ColorCatalogEntry(\n                                        manufacturer=manufacturer_name,\n                                        color_name=color_name_val,\n                                        hex_color=hex_color_val.upper(),\n                                        material=mat,\n                                        is_default=False,\n                                    )\n                                )\n                                added += 1\n\n                        await db.commit()\n\n                    progress = {\n                        \"type\": \"progress\",\n                        \"added\": added,\n                        \"skipped\": skipped,\n                        \"total_fetched\": total_fetched,\n                        \"total_available\": total_available,\n                    }\n                    yield f\"data: {json.dumps(progress)}\\n\\n\"\n\n                    if not data.get(\"next\") or total_fetched >= total_available:\n                        break\n                    page += 1\n\n            result = {\n                \"type\": \"complete\",\n                \"added\": added,\n                \"skipped\": skipped,\n                \"total_fetched\": total_fetched,\n                \"total_available\": total_available,\n            }\n            yield f\"data: {json.dumps(result)}\\n\\n\"\n\n        except httpx.HTTPError as e:\n            logger.error(\"HTTP error syncing from FilamentColors.xyz: %s\", e)\n            yield f\"data: {json.dumps({'type': 'error', 'error': str(e)})}\\n\\n\"\n        except Exception as e:\n            logger.error(\"Error syncing from FilamentColors.xyz: %s\", e)\n            yield f\"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\\n\\n\"\n\n    return StreamingResponse(generate(), media_type=\"text/event-stream\")\n\n\n# ── Spool CRUD ───────────────────────────────────────────────────────────────\n\n\n@router.get(\"/spools\", response_model=list[SpoolResponse])\nasync def list_spools(\n    include_archived: bool = False,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"List all spools, excluding archived by default.\"\"\"\n    query = select(Spool).options(selectinload(Spool.k_profiles))\n    if not include_archived:\n        query = query.where(Spool.archived_at.is_(None))\n    query = query.order_by(Spool.material, Spool.brand, Spool.color_name)\n    result = await db.execute(query)\n    return list(result.scalars().all())\n\n\n@router.get(\"/spools/{spool_id}\", response_model=SpoolResponse)\nasync def get_spool(\n    spool_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Get a single spool with k_profiles.\"\"\"\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(404, \"Spool not found\")\n    return spool\n\n\n@router.post(\"/spools\", response_model=SpoolResponse)\nasync def create_spool(\n    spool_data: SpoolCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Create a new spool.\"\"\"\n    spool = Spool(**spool_data.model_dump())\n    db.add(spool)\n    await db.commit()\n    await db.refresh(spool)\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))\n    await ws_manager.broadcast({\"type\": \"inventory_changed\"})\n    return result.scalar_one()\n\n\n@router.post(\"/spools/bulk\", response_model=list[SpoolResponse])\nasync def bulk_create_spools(\n    data: SpoolBulkCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Create multiple identical spools.\"\"\"\n    spools = []\n    for _ in range(data.quantity):\n        spool = Spool(**data.spool.model_dump())\n        db.add(spool)\n        spools.append(spool)\n    await db.commit()\n    ids = [s.id for s in spools]\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))\n    await ws_manager.broadcast({\"type\": \"inventory_changed\"})\n    return list(result.scalars().all())\n\n\n@router.patch(\"/spools/{spool_id}\", response_model=SpoolResponse)\nasync def update_spool(\n    spool_id: int,\n    spool_data: SpoolUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Update a spool.\"\"\"\n    result = await db.execute(select(Spool).where(Spool.id == spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(404, \"Spool not found\")\n\n    update_data = spool_data.model_dump(exclude_unset=True)\n    # Auto-lock weight when user explicitly sets weight_used\n    if \"weight_used\" in update_data and \"weight_locked\" not in update_data:\n        update_data[\"weight_locked\"] = True\n\n    for field, value in update_data.items():\n        setattr(spool, field, value)\n\n    await db.commit()\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))\n    await ws_manager.broadcast({\"type\": \"inventory_changed\"})\n    return result.scalar_one()\n\n\n@router.delete(\"/spools/{spool_id}\")\nasync def delete_spool(\n    spool_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Hard delete a spool.\"\"\"\n    result = await db.execute(select(Spool).where(Spool.id == spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(404, \"Spool not found\")\n\n    await db.delete(spool)\n    await db.commit()\n    await ws_manager.broadcast({\"type\": \"inventory_changed\"})\n    return {\"status\": \"deleted\"}\n\n\n@router.post(\"/spools/{spool_id}/archive\", response_model=SpoolResponse)\nasync def archive_spool(\n    spool_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Soft-delete a spool by setting archived_at.\"\"\"\n    from datetime import datetime, timezone\n\n    result = await db.execute(select(Spool).where(Spool.id == spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(404, \"Spool not found\")\n\n    spool.archived_at = datetime.now(timezone.utc)\n    await db.commit()\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))\n    await ws_manager.broadcast({\"type\": \"inventory_changed\"})\n    return result.scalar_one()\n\n\n@router.post(\"/spools/{spool_id}/restore\", response_model=SpoolResponse)\nasync def restore_spool(\n    spool_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Restore an archived spool.\"\"\"\n    result = await db.execute(select(Spool).where(Spool.id == spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(404, \"Spool not found\")\n\n    spool.archived_at = None\n    await db.commit()\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))\n    await ws_manager.broadcast({\"type\": \"inventory_changed\"})\n    return result.scalar_one()\n\n\n# ── K-Profiles ───────────────────────────────────────────────────────────────\n\n\n@router.get(\"/spools/{spool_id}/k-profiles\", response_model=list[SpoolKProfileResponse])\nasync def list_k_profiles(\n    spool_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"List K-profiles for a spool.\"\"\"\n    result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))\n    return list(result.scalars().all())\n\n\n@router.put(\"/spools/{spool_id}/k-profiles\", response_model=list[SpoolKProfileResponse])\nasync def replace_k_profiles(\n    spool_id: int,\n    profiles: list[SpoolKProfileBase],\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Replace all K-profiles for a spool (batch save).\"\"\"\n    # Verify spool exists\n    result = await db.execute(select(Spool).where(Spool.id == spool_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(404, \"Spool not found\")\n\n    # Delete existing\n    existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))\n    for old in existing.scalars().all():\n        await db.delete(old)\n\n    # Create new\n    new_profiles = []\n    for p in profiles:\n        kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())\n        db.add(kp)\n        new_profiles.append(kp)\n\n    await db.commit()\n    for kp in new_profiles:\n        await db.refresh(kp)\n    return new_profiles\n\n\n# ── Spool Assignments ────────────────────────────────────────────────────────\n\n\n@router.get(\"/assignments\", response_model=list[SpoolAssignmentResponse])\nasync def list_assignments(\n    printer_id: int | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),\n):\n    \"\"\"List spool assignments, optionally filtered by printer.\"\"\"\n    from backend.app.services.printer_manager import printer_manager\n\n    query = select(SpoolAssignment).options(\n        selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),\n        selectinload(SpoolAssignment.printer),\n    )\n    if printer_id is not None:\n        query = query.where(SpoolAssignment.printer_id == printer_id)\n    result = await db.execute(query)\n    assignments = list(result.scalars().all())\n\n    # Build (printer_id, ams_id) -> ams_serial map from live printer states.\n    # Fetch all statuses in one call rather than one get_status() call per printer.\n    serial_map: dict[tuple[int, int], str] = {}\n    seen_printer_ids: set[int] = {a.printer_id for a in assignments}\n    all_statuses = printer_manager.get_all_statuses()\n    for pid in seen_printer_ids:\n        state = all_statuses.get(pid)\n        if state and state.raw_data:\n            for ams_unit in state.raw_data.get(\"ams\", []):\n                sn = str(ams_unit.get(\"sn\") or ams_unit.get(\"serial_number\") or \"\")\n                if sn:\n                    try:\n                        serial_map[(pid, int(ams_unit.get(\"id\", 0)))] = sn\n                    except (ValueError, TypeError):\n                        continue\n\n    # Fetch all relevant AMS labels keyed by serial number\n    all_serials = set(serial_map.values())\n    # Also include synthetic fallback keys for assignments without a known serial\n    synthetic_keys: dict[str, tuple[int, int]] = {}\n    for a in assignments:\n        if (a.printer_id, a.ams_id) not in serial_map:\n            synthetic = f\"p{a.printer_id}a{a.ams_id}\"\n            synthetic_keys[synthetic] = (a.printer_id, a.ams_id)\n            all_serials.add(synthetic)\n\n    label_by_serial: dict[str, str] = {}\n    if all_serials:\n        lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))\n        for lbl in lbl_result.scalars().all():\n            label_by_serial[lbl.ams_serial_number] = lbl.label\n\n    # Build response objects, attaching ams_label where available\n    responses: list[SpoolAssignmentResponse] = []\n    for a in assignments:\n        resp = SpoolAssignmentResponse.model_validate(a)\n        sn = serial_map.get((a.printer_id, a.ams_id))\n        if sn and sn in label_by_serial:\n            resp.ams_label = label_by_serial[sn]\n        elif not sn:\n            synthetic = f\"p{a.printer_id}a{a.ams_id}\"\n            resp.ams_label = label_by_serial.get(synthetic)\n        responses.append(resp)\n\n    return responses\n\n\n@router.post(\"/assignments\", response_model=SpoolAssignmentResponse)\nasync def assign_spool(\n    data: SpoolAssignmentCreate,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Assign a spool to an AMS slot and auto-configure via MQTT.\"\"\"\n    from backend.app.services.printer_manager import printer_manager\n\n    # 1. Validate spool exists and is not archived\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(404, \"Spool not found\")\n    if spool.archived_at:\n        raise HTTPException(400, \"Cannot assign an archived spool\")\n\n    # 2. Get current AMS tray state for fingerprint + existing filament ID\n    fingerprint_color = None\n    fingerprint_type = None\n    current_tray_info_idx = \"\"\n    state = printer_manager.get_status(data.printer_id)\n    if state and state.raw_data:\n        if data.ams_id == 255:\n            # External slot: look up tray from vt_tray by global ID\n            vt_tray = state.raw_data.get(\"vt_tray\") or []\n            ext_id = data.tray_id + 254  # 0→254, 1→255\n            for vt in vt_tray:\n                if isinstance(vt, dict) and int(vt.get(\"id\", 254)) == ext_id:\n                    fingerprint_color = vt.get(\"tray_color\", \"\")\n                    fingerprint_type = vt.get(\"tray_type\", \"\")\n                    current_tray_info_idx = vt.get(\"tray_info_idx\", \"\")\n                    break\n        else:\n            ams_data = state.raw_data.get(\"ams\", {})\n            ams_list = (\n                ams_data.get(\"ams\", [])\n                if isinstance(ams_data, dict)\n                else ams_data\n                if isinstance(ams_data, list)\n                else []\n            )\n            tray = _find_tray_in_ams_data(\n                ams_list,\n                data.ams_id,\n                data.tray_id,\n            )\n            if tray:\n                fingerprint_color = tray.get(\"tray_color\", \"\")\n                fingerprint_type = tray.get(\"tray_type\", \"\")\n                current_tray_info_idx = tray.get(\"tray_info_idx\", \"\")\n\n    # 3. Upsert assignment (replace if same printer+ams+tray)\n    existing = await db.execute(\n        select(SpoolAssignment).where(\n            SpoolAssignment.printer_id == data.printer_id,\n            SpoolAssignment.ams_id == data.ams_id,\n            SpoolAssignment.tray_id == data.tray_id,\n        )\n    )\n    old = existing.scalar_one_or_none()\n    if old:\n        await db.delete(old)\n        await db.flush()\n\n    assignment = SpoolAssignment(\n        spool_id=data.spool_id,\n        printer_id=data.printer_id,\n        ams_id=data.ams_id,\n        tray_id=data.tray_id,\n        fingerprint_color=fingerprint_color,\n        fingerprint_type=fingerprint_type,\n    )\n    db.add(assignment)\n    await db.commit()\n    await db.refresh(assignment)\n\n    # 4. Auto-configure AMS slot via MQTT\n    configured = False\n    try:\n        client = printer_manager.get_client(data.printer_id)\n        if client:\n            # Build filament setting from spool data\n            tray_type = spool.material\n            tray_sub_brands = (\n                f\"{spool.brand} {spool.material} {spool.subtype}\".strip()\n                if spool.brand\n                else f\"{spool.material} {spool.subtype}\"\n                if spool.subtype\n                else spool.material\n            )\n            tray_color = spool.rgba or \"FFFFFFFF\"\n\n            _GENERIC_IDS = {\n                \"PLA\": \"GFL99\",\n                \"PETG\": \"GFG99\",\n                \"ABS\": \"GFB99\",\n                \"ASA\": \"GFB98\",\n                \"PC\": \"GFC99\",\n                \"PA\": \"GFN99\",\n                \"NYLON\": \"GFN99\",\n                \"TPU\": \"GFU99\",\n                \"PVA\": \"GFS99\",\n                \"HIPS\": \"GFS98\",\n                \"PLA-CF\": \"GFL98\",\n                \"PETG-CF\": \"GFG98\",\n                \"PA-CF\": \"GFN98\",\n                \"PETG HF\": \"GFG96\",\n            }\n            _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())\n\n            # Resolve tray_info_idx + setting_id for the MQTT command.\n            # Three sources in priority order:\n            #   1. Cloud profile (if cloud connected) — resolve filament_id\n            #      from setting_id via cloud API\n            #   2. Local profile — use generic filament ID for material\n            #   3. Hard-coded fallback — generic Bambu filament IDs\n            tray_info_idx = \"\"\n            setting_id = \"\"\n            sf = spool.slicer_filament or \"\"\n\n            if sf:\n                # Check if it's a cloud preset (GFS*, PFUS*, or GF* official)\n                base_sf = sf.split(\"_\")[0] if \"_\" in sf else sf\n                if base_sf.startswith(\"GFS\") or base_sf.startswith(\"PFUS\"):\n                    # Cloud setting_id — need to resolve real filament_id\n                    # Use base_sf (version suffix stripped) for cloud API + MQTT\n                    setting_id = base_sf\n                    try:\n                        from backend.app.api.routes.cloud import build_authenticated_cloud\n\n                        cloud = await build_authenticated_cloud(db, current_user)\n                        if cloud is not None and cloud.is_authenticated:\n                            try:\n                                detail = await cloud.get_setting_detail(base_sf)\n                                if detail.get(\"filament_id\"):\n                                    tray_info_idx = detail[\"filament_id\"]\n                                    logger.info(\n                                        \"Spool assign: resolved filament_id=%r from cloud for setting_id=%r\",\n                                        tray_info_idx,\n                                        sf,\n                                    )\n                                    # Use cloud preset name for tray_sub_brands if available\n                                    cloud_name = detail.get(\"name\", \"\")\n                                    if cloud_name:\n                                        tray_sub_brands = cloud_name.replace(r\"@.*$\", \"\").split(\"@\")[0].strip()\n                                elif detail.get(\"base_id\"):\n                                    # Derive from base_id (e.g. \"GFSL05\" → \"GFL05\")\n                                    bid = detail[\"base_id\"].split(\"_\")[0]\n                                    if bid.startswith(\"GFS\") and len(bid) >= 5:\n                                        tray_info_idx = f\"GF{bid[3:]}\"\n                                    else:\n                                        tray_info_idx = bid\n                                    logger.info(\n                                        \"Spool assign: derived filament_id=%r from base_id=%r\",\n                                        tray_info_idx,\n                                        detail[\"base_id\"],\n                                    )\n                            finally:\n                                await cloud.close()\n                        elif cloud is not None:\n                            await cloud.close()\n                    except Exception as e:\n                        logger.warning(\"Spool assign: cloud lookup failed for %r: %s\", sf, e)\n\n                    if not tray_info_idx:\n                        # Cloud lookup failed — use normalize as fallback\n                        tray_info_idx, setting_id = normalize_slicer_filament(sf)\n                elif base_sf.startswith(\"GF\"):\n                    # Official Bambu filament_id (e.g. \"GFL05\")\n                    tray_info_idx, setting_id = normalize_slicer_filament(sf)\n                    logger.info(\"Spool assign: using official filament_id=%r\", tray_info_idx)\n\n                else:\n                    # Could be a local preset ID or material type — try local DB\n                    try:\n                        local_id = int(sf)\n                        from backend.app.models.local_preset import LocalPreset as LP\n\n                        lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == \"filament\"))\n                        lp = lp_result.scalar_one_or_none()\n                        if lp:\n                            mat = (spool.material or lp.filament_type or \"\").upper().strip()\n                            tray_info_idx = (\n                                _GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split(\"-\")[0].split(\" \")[0]) or \"\"\n                            )\n                            # Use local preset name for tray_sub_brands\n                            if lp.name:\n                                tray_sub_brands = lp.name.split(\"@\")[0].strip()\n                            logger.info(\n                                \"Spool assign: local preset %d, material=%r, tray_info_idx=%r\",\n                                local_id,\n                                mat,\n                                tray_info_idx,\n                            )\n                    except (ValueError, TypeError):\n                        # Not a numeric ID — treat as material type string\n                        tray_info_idx, setting_id = normalize_slicer_filament(sf)\n\n            # Cross-check: the cloud API returns the base filament_id for\n            # versioned setting_ids (e.g. GFSL99 → GFL99 for all PLA variants).\n            # If the spool has a specific preset name (e.g. \"Generic PLA Silk\"),\n            # reverse-lookup the correct filament_id from the built-in table.\n            if tray_info_idx and spool.slicer_filament_name:\n                from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES\n\n                expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, \"\")\n                if expected_name and expected_name != spool.slicer_filament_name:\n                    for fid, fname in _BUILTIN_FILAMENT_NAMES.items():\n                        if fname == spool.slicer_filament_name:\n                            logger.info(\n                                \"Spool assign: corrected filament_id %r→%r (name=%r)\",\n                                tray_info_idx,\n                                fid,\n                                spool.slicer_filament_name,\n                            )\n                            tray_info_idx = fid\n                            setting_id = filament_id_to_setting_id(fid)\n                            break\n\n            if not tray_info_idx:\n                # Fallback: reuse slot's existing tray_info_idx or generic ID\n                if (\n                    current_tray_info_idx\n                    and current_tray_info_idx not in _GENERIC_ID_VALUES\n                    and fingerprint_type\n                    and fingerprint_type.upper() == tray_type.upper()\n                ):\n                    logger.info(\n                        \"Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)\",\n                        current_tray_info_idx,\n                        tray_type,\n                    )\n                    tray_info_idx = current_tray_info_idx\n                elif tray_type:\n                    material = tray_type.upper().strip()\n                    generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split(\"-\")[0].split(\" \")[0]) or \"\"\n                    if generic:\n                        logger.info(\"Spool assign: falling back to generic %r for material %r\", generic, tray_type)\n                        tray_info_idx = generic\n\n            # Temperature: use spool overrides if set, else material defaults\n            temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))\n            if spool.nozzle_temp_min is not None:\n                temp_min = spool.nozzle_temp_min\n            if spool.nozzle_temp_max is not None:\n                temp_max = spool.nozzle_temp_max\n\n            # a. Set filament setting\n            client.ams_set_filament_setting(\n                ams_id=data.ams_id,\n                tray_id=data.tray_id,\n                tray_info_idx=tray_info_idx,\n                tray_type=tray_type,\n                tray_sub_brands=tray_sub_brands,\n                tray_color=tray_color,\n                nozzle_temp_min=temp_min,\n                nozzle_temp_max=temp_max,\n                setting_id=setting_id,\n            )\n\n            # b. Look up K-profile for this spool + printer + nozzle + extruder\n            nozzle_diameter = \"0.4\"\n            if state and state.nozzles:\n                nd = state.nozzles[0].nozzle_diameter\n                if nd:\n                    nozzle_diameter = nd\n\n            # Determine slot's extruder from ams_extruder_map\n            slot_extruder = None\n            if state and state.ams_extruder_map:\n                if data.ams_id == 255:\n                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0\n                    slot_extruder = 1 - data.tray_id  # 0→1, 1→0\n                else:\n                    slot_extruder = state.ams_extruder_map.get(str(data.ams_id))\n\n            matching_kp = None\n            for kp in spool.k_profiles:\n                if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:\n                    if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:\n                        continue\n                    matching_kp = kp\n                    break\n\n            if matching_kp and matching_kp.cali_idx is not None:\n                client.extrusion_cali_sel(\n                    ams_id=data.ams_id,\n                    tray_id=data.tray_id,\n                    cali_idx=matching_kp.cali_idx,\n                    filament_id=tray_info_idx,\n                    nozzle_diameter=nozzle_diameter,\n                )\n\n            configured = True\n            logger.info(\n                \"Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d\",\n                data.ams_id,\n                data.tray_id,\n                spool.id,\n                data.printer_id,\n            )\n\n            # Save slot preset mapping so the UI shows the correct preset name.\n            # Use slicer_filament_name (authoritative) with fallback to tray_sub_brands.\n            try:\n                from backend.app.models.slot_preset import SlotPresetMapping\n\n                preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type\n                preset_source = \"cloud\"\n                if sf:\n                    base_sf_mapping = sf.split(\"_\")[0] if \"_\" in sf else sf\n                    try:\n                        local_id = int(base_sf_mapping)\n                        preset_id_to_save = f\"local_{local_id}\"\n                        preset_source = \"local\"\n                    except (ValueError, TypeError):\n                        # Cloud or builtin preset — convert filament_id to setting_id\n                        preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id\n                else:\n                    preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else \"\"\n\n                if preset_id_to_save:\n                    existing_mapping = await db.execute(\n                        select(SlotPresetMapping).where(\n                            SlotPresetMapping.printer_id == data.printer_id,\n                            SlotPresetMapping.ams_id == data.ams_id,\n                            SlotPresetMapping.tray_id == data.tray_id,\n                        )\n                    )\n                    mapping = existing_mapping.scalar_one_or_none()\n                    if mapping:\n                        mapping.preset_id = preset_id_to_save\n                        mapping.preset_name = preset_name\n                        mapping.preset_source = preset_source\n                    else:\n                        mapping = SlotPresetMapping(\n                            printer_id=data.printer_id,\n                            ams_id=data.ams_id,\n                            tray_id=data.tray_id,\n                            preset_id=preset_id_to_save,\n                            preset_name=preset_name,\n                            preset_source=preset_source,\n                        )\n                        db.add(mapping)\n                    await db.commit()\n                    logger.info(\n                        \"Saved slot preset mapping: preset_id=%r, preset_name=%r\",\n                        preset_id_to_save,\n                        preset_name,\n                    )\n            except Exception as e:\n                logger.warning(\"Failed to save slot preset mapping: %s\", e)\n\n    except Exception as e:\n        logger.warning(\"MQTT auto-configure failed for spool %d: %s\", spool.id, e)\n\n    # Return assignment with spool data\n    result = await db.execute(\n        select(SpoolAssignment)\n        .options(\n            selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),\n            selectinload(SpoolAssignment.printer),\n        )\n        .where(SpoolAssignment.id == assignment.id)\n    )\n    resp = result.scalar_one()\n    response = SpoolAssignmentResponse.model_validate(resp)\n    response.configured = configured\n\n    await ws_manager.broadcast(\n        {\n            \"type\": \"spool_assignment_changed\",\n            \"printer_id\": data.printer_id,\n            \"ams_id\": data.ams_id,\n            \"tray_id\": data.tray_id,\n        }\n    )\n\n    return response\n\n\n@router.delete(\"/assignments/{printer_id}/{ams_id}/{tray_id}\")\nasync def unassign_spool(\n    printer_id: int,\n    ams_id: int,\n    tray_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Unassign a spool from an AMS slot.\"\"\"\n    result = await db.execute(\n        select(SpoolAssignment).where(\n            SpoolAssignment.printer_id == printer_id,\n            SpoolAssignment.ams_id == ams_id,\n            SpoolAssignment.tray_id == tray_id,\n        )\n    )\n    assignment = result.scalar_one_or_none()\n    if not assignment:\n        raise HTTPException(404, \"Assignment not found\")\n\n    await db.delete(assignment)\n    await db.commit()\n\n    await ws_manager.broadcast(\n        {\n            \"type\": \"spool_assignment_changed\",\n            \"printer_id\": printer_id,\n            \"ams_id\": ams_id,\n            \"tray_id\": tray_id,\n        }\n    )\n\n    return {\"status\": \"deleted\"}\n\n\n# ── Tag Linking ───────────────────────────────────────────────────────────────\n\n\nclass LinkTagRequest(BaseModel):\n    tag_uid: str | None = None\n    tray_uuid: str | None = None\n    tag_type: str | None = None\n    data_origin: str | None = \"nfc_link\"\n\n\ndef _validate_tag_input(\n    raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None\n) -> None:\n    if raw_value is None:\n        return\n    raw = str(raw_value).strip()\n    if not raw:\n        return\n    if normalized_value is None:\n        raise HTTPException(422, f\"{field_name} must contain hexadecimal characters\")\n    if len(normalized_value) % 2 != 0:\n        raise HTTPException(422, f\"{field_name} must have an even number of hex characters\")\n    if exact_len is not None and len(normalized_value) != exact_len:\n        raise HTTPException(422, f\"{field_name} must be exactly {exact_len} hex characters\")\n\n\n@router.patch(\"/spools/{spool_id}/link-tag\", response_model=SpoolResponse)\nasync def link_tag_to_spool(\n    spool_id: int,\n    data: LinkTagRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Link an RFID tag_uid/tray_uuid to an existing spool.\"\"\"\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(404, \"Spool not found\")\n    if spool.archived_at:\n        raise HTTPException(400, \"Cannot link tag to archived spool\")\n\n    normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None\n    normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None\n\n    _validate_tag_input(data.tag_uid, normalized_tag_uid, \"tag_uid\")\n    _validate_tag_input(data.tray_uuid, normalized_tray_uuid, \"tray_uuid\", exact_len=32)\n\n    # Check for conflicts: tag already linked to another active spool\n    if normalized_tag_uid:\n        conflict = await db.execute(\n            select(Spool).where(\n                func.upper(Spool.tag_uid) == normalized_tag_uid,\n                Spool.id != spool_id,\n                Spool.archived_at.is_(None),\n            )\n        )\n        if conflict.scalar_one_or_none():\n            raise HTTPException(409, \"Tag UID already linked to another active spool\")\n        # Auto-clear from archived spools (tag recycling)\n        archived_with_tag = await db.execute(\n            select(Spool).where(\n                func.upper(Spool.tag_uid) == normalized_tag_uid,\n                Spool.id != spool_id,\n                Spool.archived_at.is_not(None),\n            )\n        )\n        for old_spool in archived_with_tag.scalars().all():\n            old_spool.tag_uid = None\n\n    if normalized_tray_uuid:\n        conflict = await db.execute(\n            select(Spool).where(\n                func.upper(Spool.tray_uuid) == normalized_tray_uuid,\n                Spool.id != spool_id,\n                Spool.archived_at.is_(None),\n            )\n        )\n        if conflict.scalar_one_or_none():\n            raise HTTPException(409, \"Tray UUID already linked to another active spool\")\n        archived_with_uuid = await db.execute(\n            select(Spool).where(\n                func.upper(Spool.tray_uuid) == normalized_tray_uuid,\n                Spool.id != spool_id,\n                Spool.archived_at.is_not(None),\n            )\n        )\n        for old_spool in archived_with_uuid.scalars().all():\n            old_spool.tray_uuid = None\n\n    if data.tag_uid is not None:\n        spool.tag_uid = normalized_tag_uid\n    if data.tray_uuid is not None:\n        spool.tray_uuid = normalized_tray_uuid\n    if data.tag_type is not None:\n        spool.tag_type = data.tag_type\n    if data.data_origin is not None:\n        spool.data_origin = data.data_origin\n\n    await db.commit()\n    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))\n    return result.scalar_one()\n\n\n# ── Usage History ─────────────────────────────────────────────────────────────\n\n\n@router.get(\"/spools/{spool_id}/usage\", response_model=list[SpoolUsageHistoryResponse])\nasync def get_spool_usage_history(\n    spool_id: int,\n    limit: int = 50,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Get usage history for a specific spool.\"\"\"\n    from backend.app.models.spool_usage_history import SpoolUsageHistory\n\n    # Verify spool exists\n    spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))\n    if not spool_result.scalar_one_or_none():\n        raise HTTPException(404, \"Spool not found\")\n\n    result = await db.execute(\n        select(SpoolUsageHistory)\n        .where(SpoolUsageHistory.spool_id == spool_id)\n        .order_by(SpoolUsageHistory.created_at.desc())\n        .limit(limit)\n    )\n    return list(result.scalars().all())\n\n\n@router.get(\"/usage\", response_model=list[SpoolUsageHistoryResponse])\nasync def get_all_usage_history(\n    limit: int = 100,\n    printer_id: int | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Get global usage history, optionally filtered by printer.\"\"\"\n    from backend.app.models.spool_usage_history import SpoolUsageHistory\n\n    query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)\n    if printer_id is not None:\n        query = query.where(SpoolUsageHistory.printer_id == printer_id)\n    result = await db.execute(query)\n    return list(result.scalars().all())\n\n\n@router.delete(\"/spools/{spool_id}/usage\")\nasync def clear_spool_usage_history(\n    spool_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Clear usage history for a spool.\"\"\"\n    from backend.app.models.spool_usage_history import SpoolUsageHistory\n\n    result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))\n    for row in result.scalars().all():\n        await db.delete(row)\n    await db.commit()\n    return {\"status\": \"cleared\"}\n\n\n# ── AMS Weight Sync ──────────────────────────────────────────────────────────\n\n\n@router.post(\"/sync-ams-weights\")\nasync def sync_weights_from_ams(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Force-sync spool weight_used from live AMS remain% data.\n\n    Overwrites the database weight_used for every assigned spool using the\n    current AMS remain% from connected printers.  This is a manual recovery\n    tool — it bypasses the normal \"only increase\" guard.\n    \"\"\"\n    from backend.app.services.printer_manager import printer_manager\n\n    result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))\n    assignments = list(result.scalars().all())\n    logger.info(\"AMS weight sync: found %d assignments\", len(assignments))\n\n    synced = 0\n    skipped = 0\n\n    for assignment in assignments:\n        spool = assignment.spool\n        if not spool:\n            logger.debug(\"AMS weight sync: assignment %d has no spool\", assignment.id)\n            skipped += 1\n            continue\n\n        if spool.weight_locked:\n            logger.debug(\"AMS weight sync: spool %d is weight-locked, skipping\", spool.id)\n            skipped += 1\n            continue\n\n        state = printer_manager.get_status(assignment.printer_id)\n        if not state or not state.raw_data:\n            logger.info(\n                \"AMS weight sync: printer %d not connected, skipping spool %d\",\n                assignment.printer_id,\n                spool.id,\n            )\n            skipped += 1\n            continue\n\n        ams_raw = state.raw_data.get(\"ams\", [])\n        if isinstance(ams_raw, dict):\n            ams_raw = ams_raw.get(\"ams\", [])\n        tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)\n        if not tray:\n            logger.info(\n                \"AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)\",\n                spool.id,\n                assignment.printer_id,\n                assignment.ams_id,\n                assignment.tray_id,\n            )\n            skipped += 1\n            continue\n\n        remain_raw = tray.get(\"remain\")\n        if remain_raw is None:\n            logger.debug(\"AMS weight sync: no remain value for spool %d\", spool.id)\n            skipped += 1\n            continue\n\n        try:\n            remain_val = int(remain_raw)\n        except (TypeError, ValueError):\n            skipped += 1\n            continue\n\n        if remain_val < 0 or remain_val > 100:\n            logger.debug(\"AMS weight sync: invalid remain=%s for spool %d\", remain_raw, spool.id)\n            skipped += 1\n            continue\n\n        lw = spool.label_weight or 1000\n        new_used = round(lw * (100 - remain_val) / 100.0, 1)\n        old_used = spool.weight_used or 0\n\n        if round(old_used, 1) != new_used:\n            logger.info(\n                \"AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)\",\n                spool.id,\n                old_used,\n                new_used,\n                remain_val,\n            )\n            spool.weight_used = new_used\n            synced += 1\n        else:\n            skipped += 1\n\n    await db.commit()\n    return {\"synced\": synced, \"skipped\": skipped}\n\n\n# ── Helpers ──────────────────────────────────────────────────────────────────\n\n\ndef _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:\n    \"\"\"Find a specific tray in the AMS data structure.\"\"\"\n    if not ams_data:\n        return None\n    for ams_unit in ams_data:\n        if int(ams_unit.get(\"id\", -1)) != ams_id:\n            continue\n        for tray in ams_unit.get(\"tray\", []):\n            if int(tray.get(\"id\", -1)) == tray_id:\n                return tray\n    return None\n"
  },
  {
    "path": "backend/app/api/routes/kprofiles.py",
    "content": "\"\"\"API routes for K-profile (pressure advance) management.\"\"\"\n\nimport asyncio\nimport logging\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.user import User\nfrom backend.app.schemas.kprofile import (\n    KProfile,\n    KProfileCreate,\n    KProfileDelete,\n    KProfileNote,\n    KProfileNoteResponse,\n    KProfilesResponse,\n)\nfrom backend.app.services.printer_manager import printer_manager\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/printers/{printer_id}/kprofiles\", tags=[\"kprofiles\"])\n\n\n@router.get(\"/\", response_model=KProfilesResponse)\nasync def get_kprofiles(\n    printer_id: int,\n    nozzle_diameter: str = \"0.4\",\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),\n):\n    \"\"\"Get K-profiles from a printer.\n\n    Args:\n        printer_id: ID of the printer\n        nozzle_diameter: Filter by nozzle diameter (default: \"0.4\")\n    \"\"\"\n    # Check printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Get MQTT client for printer\n    client = printer_manager.get_client(printer_id)\n    if not client or not client.state.connected:\n        raise HTTPException(400, \"Printer not connected\")\n\n    # Request K-profiles from printer\n    profiles = await client.get_kprofiles(nozzle_diameter=nozzle_diameter)\n\n    # Convert from MQTT dataclass to Pydantic schema\n    return KProfilesResponse(\n        profiles=[\n            KProfile(\n                slot_id=p.slot_id,\n                extruder_id=p.extruder_id,\n                nozzle_id=p.nozzle_id,\n                nozzle_diameter=p.nozzle_diameter,\n                filament_id=p.filament_id,\n                name=p.name,\n                k_value=p.k_value,\n                n_coef=p.n_coef,\n                ams_id=p.ams_id,\n                tray_id=p.tray_id,\n                setting_id=p.setting_id,\n            )\n            for p in profiles\n        ],\n        nozzle_diameter=nozzle_diameter,\n    )\n\n\n@router.post(\"/\", response_model=dict)\nasync def set_kprofile(\n    printer_id: int,\n    profile: KProfileCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),\n):\n    \"\"\"Create or update a K-profile on the printer.\n\n    For H2D edits (slot_id > 0), this performs an in-place edit using cali_idx.\n    For other printers or new profiles, this adds a new profile.\n\n    Args:\n        printer_id: ID of the printer\n        profile: K-profile data to set\n    \"\"\"\n    is_edit = profile.slot_id > 0\n    operation = \"edit\" if is_edit else \"add\"\n\n    logger.info(\n        f\"[API] set_kprofile ({operation}): printer={printer_id}, slot_id={profile.slot_id}, \"\n        f\"extruder_id={profile.extruder_id}, nozzle_id={profile.nozzle_id}, \"\n        f\"name={profile.name}, filament_id={profile.filament_id}, k_value={profile.k_value}\"\n    )\n\n    # Check printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Get MQTT client for printer\n    client = printer_manager.get_client(printer_id)\n    if not client or not client.state.connected:\n        raise HTTPException(400, \"Printer not connected\")\n\n    # Detect dual-nozzle families by serial number prefix\n    # H2D series: \"094\"; X2D series: \"20P9\"\n    is_h2d = printer.serial_number.startswith((\"094\", \"20P9\"))\n\n    if is_edit and is_h2d:\n        # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id\n        logger.info(\"[API] H2D in-place edit: cali_idx=%s\", profile.slot_id)\n        success = client.set_kprofile(\n            filament_id=profile.filament_id,\n            name=profile.name,\n            k_value=profile.k_value,\n            nozzle_diameter=profile.nozzle_diameter,\n            nozzle_id=profile.nozzle_id,\n            extruder_id=profile.extruder_id,\n            setting_id=None,\n            slot_id=0,\n            cali_idx=profile.slot_id,  # Pass the original slot for in-place edit\n        )\n    elif is_edit:\n        # Non-H2D edit: use delete + add approach\n        logger.info(\"[API] Edit: deleting existing profile slot_id=%s\", profile.slot_id)\n        delete_success = client.delete_kprofile(\n            cali_idx=profile.slot_id,\n            filament_id=profile.filament_id,\n            nozzle_id=profile.nozzle_id,\n            nozzle_diameter=profile.nozzle_diameter,\n            extruder_id=profile.extruder_id,\n            setting_id=profile.setting_id,\n        )\n        if not delete_success:\n            raise HTTPException(500, \"Failed to delete existing K-profile for edit\")\n\n        # Wait for printer to process the delete before adding\n        await asyncio.sleep(0.5)\n        logger.info(\"[API] Edit: delete complete, now adding updated profile\")\n\n        success = client.set_kprofile(\n            filament_id=profile.filament_id,\n            name=profile.name,\n            k_value=profile.k_value,\n            nozzle_diameter=profile.nozzle_diameter,\n            nozzle_id=profile.nozzle_id,\n            extruder_id=profile.extruder_id,\n            setting_id=None,  # Generate new setting_id for add\n            slot_id=0,  # Always 0 for add (new profile)\n        )\n    else:\n        # New profile: add with slot_id=0\n        success = client.set_kprofile(\n            filament_id=profile.filament_id,\n            name=profile.name,\n            k_value=profile.k_value,\n            nozzle_diameter=profile.nozzle_diameter,\n            nozzle_id=profile.nozzle_id,\n            extruder_id=profile.extruder_id,\n            setting_id=None,  # Generate new setting_id for add\n            slot_id=0,  # Always 0 for add (new profile)\n        )\n\n    if not success:\n        raise HTTPException(500, \"Failed to send K-profile command\")\n\n    message = \"K-profile updated successfully\" if is_edit else \"K-profile added successfully\"\n    return {\"success\": True, \"message\": message}\n\n\n@router.post(\"/batch\", response_model=dict)\nasync def set_kprofiles_batch(\n    printer_id: int,\n    profiles: list[KProfileCreate],\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),\n):\n    \"\"\"Create multiple K-profiles in a single command (for dual-nozzle).\n\n    This sends all profiles in one MQTT command, which is more reliable\n    for dual-nozzle printers that may not handle sequential commands well.\n\n    Args:\n        printer_id: ID of the printer\n        profiles: List of K-profiles to set\n    \"\"\"\n    if not profiles:\n        raise HTTPException(400, \"No profiles provided\")\n\n    logger.info(\"[API] set_kprofiles_batch: printer=%s, %s profiles\", printer_id, len(profiles))\n    for p in profiles:\n        logger.info(\"  - extruder_id=%s, name=%s, k_value=%s\", p.extruder_id, p.name, p.k_value)\n\n    # Check printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Get MQTT client for printer\n    client = printer_manager.get_client(printer_id)\n    if not client or not client.state.connected:\n        raise HTTPException(400, \"Printer not connected\")\n\n    # Build list of profile dicts for batch command\n    profile_dicts = [\n        {\n            \"filament_id\": p.filament_id,\n            \"name\": p.name,\n            \"k_value\": p.k_value,\n            \"nozzle_id\": p.nozzle_id,\n            \"extruder_id\": p.extruder_id,\n            \"setting_id\": p.setting_id,\n            \"slot_id\": p.slot_id,\n        }\n        for p in profiles\n    ]\n\n    # Get nozzle_diameter from first profile (all should have same)\n    nozzle_diameter = profiles[0].nozzle_diameter\n\n    success = client.set_kprofiles_batch(profile_dicts, nozzle_diameter)\n\n    if not success:\n        raise HTTPException(500, \"Failed to send K-profiles batch command\")\n\n    return {\"success\": True, \"message\": f\"Added {len(profiles)} K-profiles\"}\n\n\n@router.delete(\"/\", response_model=dict)\nasync def delete_kprofile(\n    printer_id: int,\n    profile: KProfileDelete,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),\n):\n    \"\"\"Delete a K-profile from the printer.\n\n    Args:\n        printer_id: ID of the printer\n        profile: K-profile identification data for deletion\n    \"\"\"\n    # Check printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Get MQTT client for printer\n    client = printer_manager.get_client(printer_id)\n    if not client or not client.state.connected:\n        raise HTTPException(400, \"Printer not connected\")\n\n    # Send the delete command to printer\n    logger.info(\n        f\"[API] delete_kprofile: printer={printer_id}, slot_id={profile.slot_id}, \"\n        f\"setting_id={profile.setting_id}, filament_id={profile.filament_id}\"\n    )\n    success = client.delete_kprofile(\n        cali_idx=profile.slot_id,\n        filament_id=profile.filament_id,\n        nozzle_id=profile.nozzle_id,\n        nozzle_diameter=profile.nozzle_diameter,\n        extruder_id=profile.extruder_id,\n        setting_id=profile.setting_id,\n    )\n\n    if not success:\n        raise HTTPException(500, \"Failed to send K-profile delete command\")\n\n    # Wait for printer to process the delete before frontend refetches\n    await asyncio.sleep(0.5)\n\n    return {\"success\": True, \"message\": \"K-profile deleted successfully\"}\n\n\n@router.get(\"/notes\", response_model=KProfileNoteResponse)\nasync def get_kprofile_notes(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),\n):\n    \"\"\"Get all K-profile notes for a printer.\n\n    Notes are stored locally since printers don't support notes.\n\n    Args:\n        printer_id: ID of the printer\n    \"\"\"\n    # Check printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Get all notes for this printer\n    result = await db.execute(select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id))\n    notes = result.scalars().all()\n\n    # Return as a dictionary mapping setting_id -> note\n    return KProfileNoteResponse(notes={note.setting_id: note.note for note in notes})\n\n\n@router.put(\"/notes\", response_model=dict)\nasync def set_kprofile_note(\n    printer_id: int,\n    note_data: KProfileNote,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),\n):\n    \"\"\"Set or update a note for a K-profile.\n\n    Args:\n        printer_id: ID of the printer\n        note_data: The note data (setting_id and note content)\n    \"\"\"\n    # Check printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Find existing note or create new one\n    result = await db.execute(\n        select(KProfileNoteModel).where(\n            KProfileNoteModel.printer_id == printer_id,\n            KProfileNoteModel.setting_id == note_data.setting_id,\n        )\n    )\n    existing_note = result.scalar_one_or_none()\n\n    if note_data.note.strip():\n        # Save or update note\n        if existing_note:\n            existing_note.note = note_data.note\n        else:\n            new_note = KProfileNoteModel(\n                printer_id=printer_id,\n                setting_id=note_data.setting_id,\n                note=note_data.note,\n            )\n            db.add(new_note)\n        await db.commit()\n        return {\"success\": True, \"message\": \"Note saved\"}\n    else:\n        # Delete note if empty\n        if existing_note:\n            await db.delete(existing_note)\n            await db.commit()\n        return {\"success\": True, \"message\": \"Note deleted\"}\n\n\n@router.delete(\"/notes/{setting_id}\", response_model=dict)\nasync def delete_kprofile_note(\n    printer_id: int,\n    setting_id: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),\n):\n    \"\"\"Delete a note for a K-profile.\n\n    Args:\n        printer_id: ID of the printer\n        setting_id: The setting_id of the K-profile\n    \"\"\"\n    # Check printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Find and delete the note\n    result = await db.execute(\n        select(KProfileNoteModel).where(\n            KProfileNoteModel.printer_id == printer_id,\n            KProfileNoteModel.setting_id == setting_id,\n        )\n    )\n    existing_note = result.scalar_one_or_none()\n\n    if existing_note:\n        await db.delete(existing_note)\n        await db.commit()\n\n    return {\"success\": True, \"message\": \"Note deleted\"}\n"
  },
  {
    "path": "backend/app/api/routes/library.py",
    "content": "\"\"\"API routes for File Manager (Library) functionality.\"\"\"\n\nimport base64\nimport binascii\nimport hashlib\nimport logging\nimport os\nimport re\nimport shutil\nimport uuid\nimport zipfile\nfrom pathlib import Path\n\nfrom fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile\nfrom fastapi.responses import FileResponse as FastAPIFileResponse\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.core.auth import (\n    RequireCameraStreamTokenIfAuthEnabled,\n    require_ownership_permission,\n    require_permission_if_auth_enabled,\n)\nfrom backend.app.core.config import settings as app_settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.library import LibraryFile, LibraryFolder\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.models.project import Project\nfrom backend.app.models.user import User\nfrom backend.app.schemas.library import (\n    AddToQueueError,\n    AddToQueueRequest,\n    AddToQueueResponse,\n    AddToQueueResult,\n    BatchThumbnailRequest,\n    BatchThumbnailResponse,\n    BatchThumbnailResult,\n    BulkDeleteRequest,\n    BulkDeleteResponse,\n    ExternalFolderCreate,\n    FileDuplicate,\n    FileListResponse,\n    FileMoveRequest,\n    FilePrintRequest,\n    FileResponse as FileResponseSchema,\n    FileUpdate,\n    FileUploadResponse,\n    FolderCreate,\n    FolderResponse,\n    FolderTreeItem,\n    FolderUpdate,\n    ZipExtractError,\n    ZipExtractResponse,\n    ZipExtractResult,\n)\nfrom backend.app.services.archive import ThreeMFParser\nfrom backend.app.services.stl_thumbnail import generate_stl_thumbnail\nfrom backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/library\", tags=[\"library\"])\n\n\ndef get_library_dir() -> Path:\n    \"\"\"Get the library storage directory.\"\"\"\n    base_dir = Path(app_settings.archive_dir)\n    library_dir = base_dir / \"library\"\n    library_dir.mkdir(parents=True, exist_ok=True)\n    return library_dir\n\n\ndef get_library_files_dir() -> Path:\n    \"\"\"Get the directory for library files.\"\"\"\n    files_dir = get_library_dir() / \"files\"\n    files_dir.mkdir(parents=True, exist_ok=True)\n    return files_dir\n\n\ndef get_library_thumbnails_dir() -> Path:\n    \"\"\"Get the directory for library thumbnails.\"\"\"\n    thumbnails_dir = get_library_dir() / \"thumbnails\"\n    thumbnails_dir.mkdir(parents=True, exist_ok=True)\n    return thumbnails_dir\n\n\ndef to_relative_path(absolute_path: Path | str) -> str:\n    \"\"\"Convert an absolute path to a path relative to base_dir for storage.\"\"\"\n    if not absolute_path:\n        return \"\"\n    abs_path = Path(absolute_path)\n    base_dir = Path(app_settings.base_dir)\n    try:\n        return str(abs_path.relative_to(base_dir))\n    except ValueError:\n        # Path is not under base_dir, return as-is (shouldn't happen normally)\n        return str(abs_path)\n\n\ndef to_absolute_path(relative_path: str | None) -> Path | None:\n    \"\"\"Convert a relative path (from database) to an absolute path for file operations.\"\"\"\n    if not relative_path:\n        return None\n    # Handle already-absolute paths (for backwards compatibility during migration)\n    path = Path(relative_path)\n    if path.is_absolute():\n        return path\n    return Path(app_settings.base_dir) / relative_path\n\n\ndef calculate_file_hash(file_path: Path) -> str:\n    \"\"\"Calculate SHA256 hash of a file.\"\"\"\n    sha256_hash = hashlib.sha256()\n    with open(file_path, \"rb\") as f:\n        for byte_block in iter(lambda: f.read(4096), b\"\"):\n            sha256_hash.update(byte_block)\n    return sha256_hash.hexdigest()\n\n\ndef extract_gcode_thumbnail(file_path: Path) -> bytes | None:\n    \"\"\"Extract embedded thumbnail from gcode file.\n\n    Supports PrusaSlicer/BambuStudio format:\n    ; thumbnail begin WxH SIZE\n    ; base64data...\n    ; thumbnail end\n    \"\"\"\n    try:\n        thumbnail_data = None\n        in_thumbnail = False\n        thumbnail_lines = []\n        best_size = 0\n\n        with open(file_path, errors=\"ignore\") as f:\n            # Only read first 50KB for performance (thumbnails are at the start)\n            content = f.read(50000)\n\n        for line in content.split(\"\\n\"):\n            line = line.strip()\n\n            # Check for thumbnail start\n            if line.startswith(\"; thumbnail begin\"):\n                in_thumbnail = True\n                thumbnail_lines = []\n                # Parse dimensions: \"; thumbnail begin 300x300 12345\"\n                match = re.search(r\"(\\d+)x(\\d+)\", line)\n                if match:\n                    width = int(match.group(1))\n                    # Prefer larger thumbnails (up to 300px)\n                    if width > best_size and width <= 300:\n                        best_size = width\n                continue\n\n            # Check for thumbnail end\n            if line.startswith(\"; thumbnail end\"):\n                if in_thumbnail and thumbnail_lines:\n                    try:\n                        # Decode the base64 data\n                        b64_data = \"\".join(thumbnail_lines)\n                        decoded = base64.b64decode(b64_data)\n                        # Only keep if this is the best size or first valid thumbnail\n                        if thumbnail_data is None or best_size > 0:\n                            thumbnail_data = decoded\n                    except (binascii.Error, ValueError):\n                        pass  # Skip thumbnail with invalid base64 data\n                in_thumbnail = False\n                thumbnail_lines = []\n                continue\n\n            # Collect thumbnail data\n            if in_thumbnail and line.startswith(\";\"):\n                # Remove the leading \"; \" or \";\"\n                data_line = line[1:].strip()\n                if data_line:\n                    thumbnail_lines.append(data_line)\n\n        return thumbnail_data\n    except Exception as e:\n        logger.warning(\"Failed to extract gcode thumbnail: %s\", e)\n        return None\n\n\ndef create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int = 256) -> str | None:\n    \"\"\"Create a thumbnail from an image file.\n\n    For small images, copies directly. For larger images, resizes.\n    Returns the thumbnail path or None on failure.\n    \"\"\"\n    try:\n        from PIL import Image\n\n        thumb_filename = f\"{uuid.uuid4().hex}.png\"\n        thumb_path = thumbnails_dir / thumb_filename\n\n        with Image.open(file_path) as img:\n            # Convert to RGB if necessary (for PNG with transparency, etc.)\n            if img.mode in (\"RGBA\", \"LA\", \"P\"):\n                # Create white background for transparency\n                background = Image.new(\"RGB\", img.size, (255, 255, 255))\n                if img.mode == \"P\":\n                    img = img.convert(\"RGBA\")\n                background.paste(img, mask=img.split()[-1] if img.mode == \"RGBA\" else None)\n                img = background\n            elif img.mode != \"RGB\":\n                img = img.convert(\"RGB\")\n\n            # Resize if larger than max_size\n            if img.width > max_size or img.height > max_size:\n                img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)\n\n            img.save(thumb_path, \"PNG\", optimize=True)\n\n        return str(thumb_path)\n    except ImportError:\n        # PIL not installed, just copy the file if it's small enough\n        logger.warning(\"PIL not installed, copying image as thumbnail\")\n        try:\n            file_size = file_path.stat().st_size\n            if file_size < 500000:  # Less than 500KB\n                thumb_filename = f\"{uuid.uuid4().hex}{file_path.suffix}\"\n                thumb_path = thumbnails_dir / thumb_filename\n                shutil.copy2(file_path, thumb_path)\n                return str(thumb_path)\n        except OSError:\n            pass  # File inaccessible; fall through to return None\n        return None\n    except Exception as e:\n        logger.warning(\"Failed to create image thumbnail: %s\", e)\n        return None\n\n\n# Supported image extensions for thumbnails\nIMAGE_EXTENSIONS = {\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".bmp\", \".tiff\", \".tif\"}\n\n\n# ============ Folder Endpoints ============\n\n\n@router.get(\"/folders\", response_model=list[FolderTreeItem])\n@router.get(\"/folders/\", response_model=list[FolderTreeItem])\nasync def list_folders(\n    response: Response,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get all folders as a tree structure.\"\"\"\n    # Prevent browser caching of folder list\n    response.headers[\"Cache-Control\"] = \"no-cache, no-store, must-revalidate\"\n\n    # Get all folders with project and archive joins\n    result = await db.execute(\n        select(LibraryFolder, Project.name, PrintArchive.print_name)\n        .outerjoin(Project, LibraryFolder.project_id == Project.id)\n        .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)\n        .order_by(LibraryFolder.name)\n    )\n    rows = result.all()\n\n    # Get file counts per folder\n    file_counts_result = await db.execute(\n        select(LibraryFile.folder_id, func.count(LibraryFile.id))\n        .where(LibraryFile.folder_id.isnot(None))\n        .group_by(LibraryFile.folder_id)\n    )\n    file_counts = dict(file_counts_result.all())\n\n    # Build tree structure\n    folder_map = {}\n    root_folders = []\n\n    for folder, project_name, archive_name in rows:\n        folder_item = FolderTreeItem(\n            id=folder.id,\n            name=folder.name,\n            parent_id=folder.parent_id,\n            project_id=folder.project_id,\n            archive_id=folder.archive_id,\n            project_name=project_name,\n            archive_name=archive_name,\n            is_external=folder.is_external,\n            external_path=folder.external_path,\n            external_readonly=folder.external_readonly,\n            file_count=file_counts.get(folder.id, 0),\n            children=[],\n        )\n        folder_map[folder.id] = folder_item\n\n    # Link children to parents\n    for folder, _, _ in rows:\n        folder_item = folder_map[folder.id]\n        if folder.parent_id is None:\n            root_folders.append(folder_item)\n        elif folder.parent_id in folder_map:\n            folder_map[folder.parent_id].children.append(folder_item)\n\n    return root_folders\n\n\n@router.get(\"/folders/by-project/{project_id}\", response_model=list[FolderResponse])\nasync def get_folders_by_project(\n    project_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get all folders linked to a specific project.\"\"\"\n    result = await db.execute(\n        select(LibraryFolder, Project.name)\n        .outerjoin(Project, LibraryFolder.project_id == Project.id)\n        .where(LibraryFolder.project_id == project_id)\n        .order_by(LibraryFolder.name)\n    )\n    rows = result.all()\n\n    folders = []\n    for folder, project_name in rows:\n        # Get file count\n        file_count_result = await db.execute(\n            select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id)\n        )\n        file_count = file_count_result.scalar() or 0\n\n        folders.append(\n            FolderResponse(\n                id=folder.id,\n                name=folder.name,\n                parent_id=folder.parent_id,\n                project_id=folder.project_id,\n                archive_id=folder.archive_id,\n                project_name=project_name,\n                archive_name=None,\n                is_external=folder.is_external,\n                external_path=folder.external_path,\n                external_readonly=folder.external_readonly,\n                external_show_hidden=folder.external_show_hidden,\n                file_count=file_count,\n                created_at=folder.created_at,\n                updated_at=folder.updated_at,\n            )\n        )\n\n    return folders\n\n\n@router.get(\"/folders/by-archive/{archive_id}\", response_model=list[FolderResponse])\nasync def get_folders_by_archive(\n    archive_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get all folders linked to a specific archive.\"\"\"\n    result = await db.execute(\n        select(LibraryFolder, PrintArchive.print_name)\n        .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)\n        .where(LibraryFolder.archive_id == archive_id)\n        .order_by(LibraryFolder.name)\n    )\n    rows = result.all()\n\n    folders = []\n    for folder, archive_name in rows:\n        # Get file count\n        file_count_result = await db.execute(\n            select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id)\n        )\n        file_count = file_count_result.scalar() or 0\n\n        folders.append(\n            FolderResponse(\n                id=folder.id,\n                name=folder.name,\n                parent_id=folder.parent_id,\n                project_id=folder.project_id,\n                archive_id=folder.archive_id,\n                project_name=None,\n                archive_name=archive_name,\n                is_external=folder.is_external,\n                external_path=folder.external_path,\n                external_readonly=folder.external_readonly,\n                external_show_hidden=folder.external_show_hidden,\n                file_count=file_count,\n                created_at=folder.created_at,\n                updated_at=folder.updated_at,\n            )\n        )\n\n    return folders\n\n\n@router.post(\"/folders\", response_model=FolderResponse)\n@router.post(\"/folders/\", response_model=FolderResponse)\nasync def create_folder(\n    data: FolderCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),\n):\n    \"\"\"Create a new folder.\"\"\"\n    # Verify parent exists if specified\n    if data.parent_id is not None:\n        parent_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.parent_id))\n        if not parent_result.scalar_one_or_none():\n            raise HTTPException(status_code=404, detail=\"Parent folder not found\")\n\n    # Verify project exists if specified\n    project_name = None\n    if data.project_id is not None:\n        project_result = await db.execute(select(Project).where(Project.id == data.project_id))\n        project = project_result.scalar_one_or_none()\n        if not project:\n            raise HTTPException(status_code=404, detail=\"Project not found\")\n        project_name = project.name\n\n    # Verify archive exists if specified\n    archive_name = None\n    if data.archive_id is not None:\n        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))\n        archive = archive_result.scalar_one_or_none()\n        if not archive:\n            raise HTTPException(status_code=404, detail=\"Archive not found\")\n        archive_name = archive.print_name\n\n    folder = LibraryFolder(\n        name=data.name,\n        parent_id=data.parent_id,\n        project_id=data.project_id,\n        archive_id=data.archive_id,\n    )\n    db.add(folder)\n    await db.commit()\n    await db.refresh(folder)\n\n    return FolderResponse(\n        id=folder.id,\n        name=folder.name,\n        parent_id=folder.parent_id,\n        project_id=folder.project_id,\n        archive_id=folder.archive_id,\n        project_name=project_name,\n        archive_name=archive_name,\n        is_external=folder.is_external,\n        external_path=folder.external_path,\n        external_readonly=folder.external_readonly,\n        external_show_hidden=folder.external_show_hidden,\n        file_count=0,\n        created_at=folder.created_at,\n        updated_at=folder.updated_at,\n    )\n\n\n@router.get(\"/folders/{folder_id}\", response_model=FolderResponse)\nasync def get_folder(\n    folder_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get a folder by ID.\"\"\"\n    result = await db.execute(\n        select(LibraryFolder, Project.name, PrintArchive.print_name)\n        .outerjoin(Project, LibraryFolder.project_id == Project.id)\n        .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)\n        .where(LibraryFolder.id == folder_id)\n    )\n    row = result.one_or_none()\n\n    if not row:\n        raise HTTPException(status_code=404, detail=\"Folder not found\")\n\n    folder, project_name, archive_name = row\n\n    # Get file count\n    file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id))\n    file_count = file_count_result.scalar() or 0\n\n    return FolderResponse(\n        id=folder.id,\n        name=folder.name,\n        parent_id=folder.parent_id,\n        project_id=folder.project_id,\n        archive_id=folder.archive_id,\n        project_name=project_name,\n        archive_name=archive_name,\n        is_external=folder.is_external,\n        external_path=folder.external_path,\n        external_readonly=folder.external_readonly,\n        external_show_hidden=folder.external_show_hidden,\n        file_count=file_count,\n        created_at=folder.created_at,\n        updated_at=folder.updated_at,\n    )\n\n\n@router.put(\"/folders/{folder_id}\", response_model=FolderResponse)\nasync def update_folder(\n    folder_id: int,\n    data: FolderUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),\n):\n    \"\"\"Update a folder.\n\n    Note: Folders require library:update_all permission since they don't have\n    ownership tracking.\n    \"\"\"\n    result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))\n    folder = result.scalar_one_or_none()\n\n    if not folder:\n        raise HTTPException(status_code=404, detail=\"Folder not found\")\n\n    if data.name is not None:\n        folder.name = data.name\n\n    if data.parent_id is not None:\n        # Prevent circular reference\n        if data.parent_id == folder_id:\n            raise HTTPException(status_code=400, detail=\"Folder cannot be its own parent\")\n\n        # Check for circular reference in ancestors\n        if data.parent_id != 0:  # 0 means move to root\n            current_id = data.parent_id\n            while current_id is not None:\n                if current_id == folder_id:\n                    raise HTTPException(status_code=400, detail=\"Cannot move folder into its own subtree\")\n                parent_result = await db.execute(select(LibraryFolder.parent_id).where(LibraryFolder.id == current_id))\n                current_id = parent_result.scalar()\n\n            folder.parent_id = data.parent_id\n        else:\n            folder.parent_id = None\n\n    # Update project_id (0 to unlink)\n    if data.project_id is not None:\n        if data.project_id == 0:\n            folder.project_id = None\n        else:\n            # Verify project exists\n            project_result = await db.execute(select(Project).where(Project.id == data.project_id))\n            if not project_result.scalar_one_or_none():\n                raise HTTPException(status_code=404, detail=\"Project not found\")\n            folder.project_id = data.project_id\n\n    # Update archive_id (0 to unlink)\n    if data.archive_id is not None:\n        if data.archive_id == 0:\n            folder.archive_id = None\n        else:\n            # Verify archive exists\n            archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))\n            if not archive_result.scalar_one_or_none():\n                raise HTTPException(status_code=404, detail=\"Archive not found\")\n            folder.archive_id = data.archive_id\n\n    await db.commit()\n    await db.refresh(folder)\n\n    # Get file count and names\n    file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id))\n    file_count = file_count_result.scalar() or 0\n\n    # Get project and archive names\n    project_name = None\n    archive_name = None\n    if folder.project_id:\n        project_result = await db.execute(select(Project.name).where(Project.id == folder.project_id))\n        project_name = project_result.scalar()\n    if folder.archive_id:\n        archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == folder.archive_id))\n        archive_name = archive_result.scalar()\n\n    return FolderResponse(\n        id=folder.id,\n        name=folder.name,\n        parent_id=folder.parent_id,\n        project_id=folder.project_id,\n        archive_id=folder.archive_id,\n        project_name=project_name,\n        archive_name=archive_name,\n        is_external=folder.is_external,\n        external_path=folder.external_path,\n        external_readonly=folder.external_readonly,\n        external_show_hidden=folder.external_show_hidden,\n        file_count=file_count,\n        created_at=folder.created_at,\n        updated_at=folder.updated_at,\n    )\n\n\n@router.delete(\"/folders/{folder_id}\")\nasync def delete_folder(\n    folder_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)),\n):\n    \"\"\"Delete a folder and all its contents (cascade).\n\n    Note: Folders require library:delete_all permission since they don't have\n    ownership tracking.\n    \"\"\"\n    result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))\n    folder = result.scalar_one_or_none()\n\n    if not folder:\n        raise HTTPException(status_code=404, detail=\"Folder not found\")\n\n    # External folders: only remove DB records, never delete files from external path\n    is_ext = folder.is_external\n\n    # Get all files in this folder and subfolders to delete from disk\n    async def get_all_file_ids(fid: int) -> list[int]:\n        \"\"\"Recursively get all file IDs in a folder tree.\"\"\"\n        file_ids = []\n\n        # Get files in this folder\n        files_result = await db.execute(\n            select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path, LibraryFile.is_external).where(\n                LibraryFile.folder_id == fid\n            )\n        )\n        for fid_val, file_path, thumb_path, file_is_ext in files_result.all():\n            file_ids.append(fid_val)\n            # Only delete non-external files from disk\n            if not is_ext and not file_is_ext:\n                try:\n                    if file_path and os.path.exists(file_path):\n                        os.remove(file_path)\n                    if thumb_path and os.path.exists(thumb_path):\n                        os.remove(thumb_path)\n                except OSError as e:\n                    logger.warning(\"Failed to delete file: %s\", e)\n\n        # Get child folders and recurse\n        children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))\n        for (child_id,) in children_result.all():\n            file_ids.extend(await get_all_file_ids(child_id))\n\n        return file_ids\n\n    await get_all_file_ids(folder_id)\n\n    # Delete folder (cascade will handle files and subfolders)\n    await db.delete(folder)\n    await db.commit()\n\n    return {\"status\": \"success\", \"message\": \"Folder deleted\"}\n\n\n# ============ External Folder Endpoints ============\n\n# Blocked system directories that cannot be mounted\n_BLOCKED_PREFIXES = (\n    \"/proc\",\n    \"/sys\",\n    \"/dev\",\n    \"/run\",\n    \"/boot\",\n    \"/sbin\",\n    \"/bin\",\n    \"/usr/sbin\",\n    \"/usr/bin\",\n    \"/lib\",\n    \"/etc\",\n)\n\n# Supported file extensions for external folder scanning\n_SCANNABLE_EXTENSIONS = {\n    \".3mf\",\n    \".gcode\",\n    \".gcode.3mf\",\n    \".stl\",\n    \".obj\",\n    \".step\",\n    \".stp\",\n    \".png\",\n    \".jpg\",\n    \".jpeg\",\n    \".gif\",\n    \".webp\",\n    \".svg\",\n}\n\n\ndef _validate_external_path(path_str: str) -> Path:\n    \"\"\"Validate an external path is safe to mount.\"\"\"\n    path = Path(path_str).resolve()\n\n    if not path.is_absolute():\n        raise HTTPException(status_code=400, detail=\"Path must be absolute\")\n\n    for prefix in _BLOCKED_PREFIXES:\n        if str(path).startswith(prefix):\n            raise HTTPException(status_code=400, detail=f\"Cannot mount system directory: {prefix}\")\n\n    if not path.exists():\n        raise HTTPException(status_code=400, detail=f\"Path does not exist: {path}\")\n\n    if not path.is_dir():\n        raise HTTPException(status_code=400, detail=f\"Path is not a directory: {path}\")\n\n    # Check readability\n    if not os.access(path, os.R_OK):\n        raise HTTPException(status_code=400, detail=f\"Path is not readable: {path}\")\n\n    return path\n\n\n@router.post(\"/folders/external\", response_model=FolderResponse)\nasync def create_external_folder(\n    data: ExternalFolderCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),\n):\n    \"\"\"Create an external folder that points to a host directory.\"\"\"\n    resolved = _validate_external_path(data.external_path)\n\n    # Check no other external folder already points to this path\n    existing = await db.execute(\n        select(LibraryFolder).where(\n            LibraryFolder.is_external.is_(True),\n            LibraryFolder.external_path == str(resolved),\n        )\n    )\n    if existing.scalar_one_or_none():\n        raise HTTPException(status_code=409, detail=\"An external folder already exists for this path\")\n\n    # Verify parent exists if specified\n    if data.parent_id is not None:\n        parent_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.parent_id))\n        if not parent_result.scalar_one_or_none():\n            raise HTTPException(status_code=404, detail=\"Parent folder not found\")\n\n    folder = LibraryFolder(\n        name=data.name,\n        parent_id=data.parent_id,\n        is_external=True,\n        external_path=str(resolved),\n        external_readonly=data.readonly,\n        external_show_hidden=data.show_hidden,\n    )\n    db.add(folder)\n    await db.commit()\n    await db.refresh(folder)\n\n    return FolderResponse(\n        id=folder.id,\n        name=folder.name,\n        parent_id=folder.parent_id,\n        project_id=None,\n        archive_id=None,\n        is_external=True,\n        external_path=folder.external_path,\n        external_readonly=folder.external_readonly,\n        external_show_hidden=folder.external_show_hidden,\n        file_count=0,\n        created_at=folder.created_at,\n        updated_at=folder.updated_at,\n    )\n\n\n@router.post(\"/folders/{folder_id}/scan\")\nasync def scan_external_folder(\n    folder_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),\n):\n    \"\"\"Scan an external folder and sync files to the database.\n\n    Discovers new files, removes DB entries for deleted files.\n    Does not copy files — stores the external path directly.\n    \"\"\"\n    result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))\n    folder = result.scalar_one_or_none()\n\n    if not folder:\n        raise HTTPException(status_code=404, detail=\"Folder not found\")\n    if not folder.is_external or not folder.external_path:\n        raise HTTPException(status_code=400, detail=\"Not an external folder\")\n\n    ext_path = Path(folder.external_path)\n    if not ext_path.exists() or not ext_path.is_dir():\n        raise HTTPException(status_code=400, detail=f\"External path is not accessible: {folder.external_path}\")\n\n    # Collect all existing child external subfolder IDs (single query)\n    all_folder_ids = [folder_id]\n    child_result = await db.execute(\n        select(LibraryFolder).where(\n            LibraryFolder.is_external.is_(True),\n            LibraryFolder.parent_id.isnot(None),\n        )\n    )\n    all_child_folders = child_result.scalars().all()\n\n    # Walk the parent chain to find all descendants of folder_id\n    parent_to_children: dict[int, list] = {}\n    for cf in all_child_folders:\n        parent_to_children.setdefault(cf.parent_id, []).append(cf)\n\n    queue = [folder_id]\n    while queue:\n        pid = queue.pop()\n        for child in parent_to_children.get(pid, []):\n            all_folder_ids.append(child.id)\n            queue.append(child.id)\n\n    # Get existing DB files across root and all subfolders\n    existing_result = await db.execute(\n        select(LibraryFile).where(\n            LibraryFile.folder_id.in_(all_folder_ids),\n            LibraryFile.is_external.is_(True),\n        )\n    )\n    existing_files = {f.file_path: f for f in existing_result.scalars().all()}\n\n    # Build folder cache: relative path -> folder_id (for resolving subfolders)\n    # Pre-populate with existing child folders keyed by their external_path\n    folder_cache: dict[str, int] = {\"\": folder_id}\n    for fid in all_folder_ids:\n        if fid == folder_id:\n            continue\n        # Find the child folder object\n        for cf in all_child_folders:\n            if cf.id == fid and cf.external_path:\n                try:\n                    rel = str(Path(cf.external_path).relative_to(ext_path))\n                    if rel != \".\":\n                        folder_cache[rel] = cf.id\n                except ValueError:\n                    pass\n\n    # Scan the directory\n    added = 0\n    removed = 0\n    found_paths: set[str] = set()\n    seen_rel_dirs: set[str] = set()\n\n    for dirpath, dirnames, filenames in os.walk(ext_path):\n        # Filter hidden directories unless configured\n        if not folder.external_show_hidden:\n            dirnames[:] = [d for d in dirnames if not d.startswith(\".\")]\n\n        rel_dir = str(Path(dirpath).relative_to(ext_path))\n        if rel_dir == \".\":\n            rel_dir = \"\"\n        seen_rel_dirs.add(rel_dir)\n\n        # Resolve or create subfolder chain for this directory\n        if rel_dir and rel_dir not in folder_cache:\n            parts = Path(rel_dir).parts\n            current_path = \"\"\n            current_parent = folder_id\n            for part in parts:\n                current_path = f\"{current_path}/{part}\".lstrip(\"/\")\n                if current_path in folder_cache:\n                    current_parent = folder_cache[current_path]\n                else:\n                    existing_sub = await db.execute(\n                        select(LibraryFolder).where(\n                            LibraryFolder.name == part,\n                            LibraryFolder.parent_id == current_parent,\n                            LibraryFolder.is_external.is_(True),\n                        )\n                    )\n                    existing_folder = existing_sub.scalar_one_or_none()\n                    if existing_folder:\n                        current_parent = existing_folder.id\n                    else:\n                        new_folder = LibraryFolder(\n                            name=part,\n                            parent_id=current_parent,\n                            is_external=True,\n                            external_path=str(ext_path / current_path),\n                            external_readonly=folder.external_readonly,\n                            external_show_hidden=folder.external_show_hidden,\n                        )\n                        db.add(new_folder)\n                        await db.flush()\n                        current_parent = new_folder.id\n                    folder_cache[current_path] = current_parent\n\n        target_folder_id = folder_cache.get(rel_dir, folder_id)\n\n        for filename in filenames:\n            # Skip hidden files unless configured\n            if not folder.external_show_hidden and filename.startswith(\".\"):\n                continue\n\n            filepath = Path(dirpath) / filename\n            ext = filepath.suffix.lower()\n\n            # Check for compound extensions like .gcode.3mf\n            if ext not in _SCANNABLE_EXTENSIONS:\n                # Check compound\n                compound = \"\".join(filepath.suffixes[-2:]).lower() if len(filepath.suffixes) >= 2 else \"\"\n                if compound not in _SCANNABLE_EXTENSIONS:\n                    continue\n\n            # Resolve symlinks and ensure still under external_path\n            try:\n                real_path = filepath.resolve()\n                real_path.relative_to(ext_path.resolve())\n            except (ValueError, OSError):\n                continue  # Symlink escapes the external dir\n\n            file_path_str = str(filepath)\n            found_paths.add(file_path_str)\n\n            if file_path_str in existing_files:\n                continue  # Already tracked\n\n            # Get file info\n            try:\n                stat = filepath.stat()\n            except OSError:\n                continue\n\n            file_type = ext[1:] if ext else \"unknown\"\n            # For compound extensions, use the meaningful part\n            if file_type in (\"3mf\",) and len(filepath.suffixes) >= 2:\n                inner = filepath.suffixes[-2].lower()\n                if inner == \".gcode\":\n                    file_type = \"gcode.3mf\"\n\n            # Extract thumbnail for 3mf files\n            thumbnail_path = None\n            file_metadata = None\n            if file_type == \"3mf\":\n                try:\n                    parser = ThreeMFParser(str(filepath))\n                    raw_metadata = parser.parse()\n                    if raw_metadata:\n                        # Extract thumbnail before cleaning metadata\n                        thumb_data = raw_metadata.get(\"_thumbnail_data\")\n                        thumbnail_ext = raw_metadata.get(\"_thumbnail_ext\", \".png\")\n                        if thumb_data:\n                            thumb_dir = get_library_thumbnails_dir()\n                            thumb_filename = f\"{uuid.uuid4().hex}{thumbnail_ext}\"\n                            thumb_full = thumb_dir / thumb_filename\n                            thumb_full.write_bytes(thumb_data)\n                            thumbnail_path = to_relative_path(thumb_full)\n\n                        # Clean metadata - remove non-JSON-serializable data (bytes, etc.)\n                        def clean_metadata(obj):\n                            if isinstance(obj, dict):\n                                return {\n                                    k: clean_metadata(v)\n                                    for k, v in obj.items()\n                                    if not isinstance(v, bytes) and k not in (\"_thumbnail_data\", \"_thumbnail_ext\")\n                                }\n                            elif isinstance(obj, list):\n                                return [clean_metadata(i) for i in obj if not isinstance(i, bytes)]\n                            elif isinstance(obj, bytes):\n                                return None\n                            return obj\n\n                        file_metadata = clean_metadata(raw_metadata)\n                except Exception as e:\n                    logger.debug(\"Failed to extract metadata from external 3mf %s: %s\", filepath, e)\n\n            # Generate thumbnail for STL files\n            if file_type == \"stl\" and thumbnail_path is None:\n                try:\n                    thumb_dir = get_library_thumbnails_dir()\n                    thumb_result = generate_stl_thumbnail(str(filepath), str(thumb_dir))\n                    if thumb_result:\n                        thumbnail_path = to_relative_path(Path(thumb_result))\n                except Exception as e:\n                    logger.debug(\"Failed to generate STL thumbnail for external %s: %s\", filepath, e)\n\n            # Extract gcode thumbnail\n            if file_type == \"gcode\" and thumbnail_path is None:\n                thumb_data = extract_gcode_thumbnail(filepath)\n                if thumb_data:\n                    thumb_dir = get_library_thumbnails_dir()\n                    thumb_filename = f\"{uuid.uuid4().hex}.png\"\n                    thumb_full = thumb_dir / thumb_filename\n                    thumb_full.write_bytes(thumb_data)\n                    thumbnail_path = to_relative_path(thumb_full)\n\n            # Create thumbnail for image files\n            if ext.lower() in IMAGE_EXTENSIONS and thumbnail_path is None:\n                thumbnail_path_str = create_image_thumbnail(filepath, get_library_thumbnails_dir())\n                if thumbnail_path_str:\n                    thumbnail_path = to_relative_path(Path(thumbnail_path_str))\n\n            db_file = LibraryFile(\n                folder_id=target_folder_id,\n                is_external=True,\n                filename=filename,\n                file_path=file_path_str,\n                file_type=file_type,\n                file_size=stat.st_size,\n                file_hash=None,  # Skip hashing external files for performance\n                thumbnail_path=thumbnail_path,\n                file_metadata=file_metadata,\n            )\n            db.add(db_file)\n            added += 1\n\n    # Remove DB entries for files that no longer exist on disk\n    for path_str, db_file in existing_files.items():\n        if path_str not in found_paths:\n            # Clean up thumbnail if we generated one\n            if db_file.thumbnail_path:\n                try:\n                    abs_thumb = to_absolute_path(db_file.thumbnail_path)\n                    if abs_thumb and abs_thumb.exists():\n                        abs_thumb.unlink()\n                except OSError:\n                    pass\n            await db.delete(db_file)\n            removed += 1\n\n    # Remove empty subfolders whose directories no longer exist on disk\n    # Process deepest-first by sorting on path depth (descending)\n    subfolder_entries = [(rel, fid) for rel, fid in folder_cache.items() if rel and fid != folder_id]\n    subfolder_entries.sort(key=lambda x: x[0].count(\"/\"), reverse=True)\n    for rel_path, sub_fid in subfolder_entries:\n        if rel_path in seen_rel_dirs:\n            continue  # Directory still exists on disk\n        # Check if subfolder has any remaining files\n        file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == sub_fid))\n        if (file_count_result.scalar() or 0) == 0:\n            # Check if it has any remaining child folders\n            child_count_result = await db.execute(\n                select(func.count(LibraryFolder.id)).where(LibraryFolder.parent_id == sub_fid)\n            )\n            if (child_count_result.scalar() or 0) == 0:\n                sub_folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == sub_fid))\n                sub_folder_obj = sub_folder_result.scalar_one_or_none()\n                if sub_folder_obj:\n                    await db.delete(sub_folder_obj)\n\n    await db.commit()\n\n    return {\"status\": \"success\", \"added\": added, \"removed\": removed}\n\n\n# ============ File Endpoints ============\n\n\n@router.get(\"/files\", response_model=list[FileListResponse])\n@router.get(\"/files/\", response_model=list[FileListResponse])\nasync def list_files(\n    response: Response,\n    folder_id: int | None = None,\n    project_id: int | None = None,\n    include_root: bool = True,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"List files, optionally filtered by folder or project.\n\n    Args:\n        folder_id: Filter by folder ID. If None and include_root=True, returns root files.\n        project_id: Return all files across folders linked to this project (bulk fetch, avoids N+1).\n        include_root: If True and folder_id is None, returns files at root level.\n                     If False and folder_id is None, returns all files.\n    \"\"\"\n    query = select(LibraryFile).options(selectinload(LibraryFile.created_by))\n\n    if folder_id is not None:\n        query = query.where(LibraryFile.folder_id == folder_id)\n    elif project_id is not None:\n        # Single join instead of one query per folder (avoids N+1 pattern)\n        query = query.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)\n        query = query.where(LibraryFolder.project_id == project_id)\n    elif include_root:\n        query = query.where(LibraryFile.folder_id.is_(None))\n\n    query = query.order_by(LibraryFile.filename)\n    result = await db.execute(query)\n    files = result.scalars().all()\n\n    # Get duplicate counts\n    hash_counts = {}\n    if files:\n        hashes = [f.file_hash for f in files if f.file_hash]\n        if hashes:\n            dup_result = await db.execute(\n                select(LibraryFile.file_hash, func.count(LibraryFile.id))\n                .where(LibraryFile.file_hash.in_(hashes))\n                .group_by(LibraryFile.file_hash)\n            )\n            hash_counts = {h: c - 1 for h, c in dup_result.all()}  # -1 to exclude self\n\n    # Prevent browser caching of file list\n    response.headers[\"Cache-Control\"] = \"no-cache, no-store, must-revalidate\"\n\n    file_list = []\n    for f in files:\n        # Extract key metadata for display\n        print_name = None\n        print_time = None\n        filament_grams = None\n        sliced_for_model = None\n        if f.file_metadata:\n            print_name = f.file_metadata.get(\"print_name\")\n            print_time = f.file_metadata.get(\"print_time_seconds\")\n            filament_grams = f.file_metadata.get(\"filament_used_grams\")\n            sliced_for_model = f.file_metadata.get(\"sliced_for_model\")\n\n        file_list.append(\n            FileListResponse(\n                id=f.id,\n                folder_id=f.folder_id,\n                is_external=f.is_external,\n                filename=f.filename,\n                file_type=f.file_type,\n                file_size=f.file_size,\n                thumbnail_path=f.thumbnail_path,\n                print_count=f.print_count,\n                duplicate_count=hash_counts.get(f.file_hash, 0) if f.file_hash else 0,\n                created_by_id=f.created_by_id,\n                created_by_username=f.created_by.username if f.created_by else None,\n                created_at=f.created_at,\n                print_name=print_name,\n                print_time_seconds=print_time,\n                filament_used_grams=filament_grams,\n                sliced_for_model=sliced_for_model,\n            )\n        )\n\n    return file_list\n\n\n@router.post(\"/files\", response_model=FileUploadResponse)\n@router.post(\"/files/\", response_model=FileUploadResponse)\nasync def upload_file(\n    file: UploadFile = File(...),\n    folder_id: int | None = None,\n    generate_stl_thumbnails: bool = Query(default=True),\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),\n):\n    \"\"\"Upload a file to the library.\"\"\"\n    try:\n        if not file.filename:\n            raise HTTPException(status_code=400, detail=\"Filename is required\")\n\n        filename = file.filename\n        ext = os.path.splitext(filename)[1].lower()\n        # Handle files without extension\n        file_type = ext[1:] if ext else \"unknown\"\n\n        # Verify folder exists if specified\n        if folder_id is not None:\n            folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))\n            target_folder = folder_result.scalar_one_or_none()\n            if not target_folder:\n                raise HTTPException(status_code=404, detail=\"Folder not found\")\n            if target_folder.is_external and target_folder.external_readonly:\n                raise HTTPException(status_code=403, detail=\"Cannot upload to a read-only external folder\")\n\n        # Generate unique filename for storage\n        unique_filename = f\"{uuid.uuid4().hex}{ext}\"\n        file_path = get_library_files_dir() / unique_filename\n\n        # Save file\n        content = await file.read()\n        with open(file_path, \"wb\") as f:\n            f.write(content)\n\n        # Calculate hash\n        file_hash = calculate_file_hash(file_path)\n\n        # Check for duplicates\n        dup_result = await db.execute(select(LibraryFile.id).where(LibraryFile.file_hash == file_hash).limit(1))\n        duplicate_of = dup_result.scalar()\n\n        # Extract metadata and thumbnail\n        metadata = {}\n        thumbnail_path = None\n        thumbnails_dir = get_library_thumbnails_dir()\n\n        if ext == \".3mf\":\n            try:\n                parser = ThreeMFParser(str(file_path))\n                raw_metadata = parser.parse()\n\n                # Extract thumbnail before cleaning metadata\n                thumbnail_data = raw_metadata.get(\"_thumbnail_data\")\n                thumbnail_ext = raw_metadata.get(\"_thumbnail_ext\", \".png\")\n\n                # Save thumbnail if extracted\n                if thumbnail_data:\n                    thumb_filename = f\"{uuid.uuid4().hex}{thumbnail_ext}\"\n                    thumb_path = thumbnails_dir / thumb_filename\n                    with open(thumb_path, \"wb\") as f:\n                        f.write(thumbnail_data)\n                    thumbnail_path = str(thumb_path)\n\n                # Clean metadata - remove non-JSON-serializable data (bytes, etc.)\n                def clean_metadata(obj):\n                    if isinstance(obj, dict):\n                        return {\n                            k: clean_metadata(v)\n                            for k, v in obj.items()\n                            if not isinstance(v, bytes) and k not in (\"_thumbnail_data\", \"_thumbnail_ext\")\n                        }\n                    elif isinstance(obj, list):\n                        return [clean_metadata(i) for i in obj if not isinstance(i, bytes)]\n                    elif isinstance(obj, bytes):\n                        return None\n                    return obj\n\n                metadata = clean_metadata(raw_metadata)\n            except Exception as e:\n                logger.warning(\"Failed to parse 3MF: %s\", e)\n\n        elif ext == \".gcode\":\n            # Extract embedded thumbnail from gcode\n            try:\n                thumbnail_data = extract_gcode_thumbnail(file_path)\n                if thumbnail_data:\n                    thumb_filename = f\"{uuid.uuid4().hex}.png\"\n                    thumb_path = thumbnails_dir / thumb_filename\n                    with open(thumb_path, \"wb\") as f:\n                        f.write(thumbnail_data)\n                    thumbnail_path = str(thumb_path)\n            except Exception as e:\n                logger.warning(\"Failed to extract gcode thumbnail: %s\", e)\n\n        elif ext.lower() in IMAGE_EXTENSIONS:\n            # For image files, create a thumbnail from the image itself\n            thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)\n\n        elif ext == \".stl\":\n            # Generate STL thumbnail if enabled\n            if generate_stl_thumbnails:\n                thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)\n\n        # Create database entry (store relative paths for portability)\n        library_file = LibraryFile(\n            folder_id=folder_id,\n            filename=filename,\n            file_path=to_relative_path(file_path),\n            file_type=file_type,\n            file_size=len(content),\n            file_hash=file_hash,\n            thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,\n            file_metadata=metadata if metadata else None,\n            created_by_id=current_user.id if current_user else None,\n        )\n        db.add(library_file)\n        await db.commit()\n        await db.refresh(library_file)\n\n        return FileUploadResponse(\n            id=library_file.id,\n            filename=library_file.filename,\n            file_type=library_file.file_type,\n            file_size=library_file.file_size,\n            thumbnail_path=library_file.thumbnail_path,\n            duplicate_of=duplicate_of,\n            metadata=library_file.file_metadata,\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(\"Upload failed for %s: %s\", file.filename, e, exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"Upload failed: {str(e)}\")\n\n\n@router.post(\"/files/extract-zip\", response_model=ZipExtractResponse)\nasync def extract_zip_file(\n    file: UploadFile = File(...),\n    folder_id: int | None = Query(default=None),\n    preserve_structure: bool = Query(default=True),\n    create_folder_from_zip: bool = Query(default=False),\n    generate_stl_thumbnails: bool = Query(default=True),\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),\n):\n    \"\"\"Upload and extract a ZIP file to the library.\n\n    Args:\n        file: The ZIP file to extract\n        folder_id: Target folder ID (None = root)\n        preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat\n        create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it\n        generate_stl_thumbnails: If True, generate thumbnails for STL files\n    \"\"\"\n    import tempfile\n\n    if not file.filename or not file.filename.lower().endswith(\".zip\"):\n        raise HTTPException(status_code=400, detail=\"Only ZIP files are supported\")\n\n    # Verify target folder exists if specified\n    if folder_id is not None:\n        folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))\n        target_folder = folder_result.scalar_one_or_none()\n        if not target_folder:\n            raise HTTPException(status_code=404, detail=\"Target folder not found\")\n        if target_folder.is_external and target_folder.external_readonly:\n            raise HTTPException(status_code=403, detail=\"Cannot extract ZIP to a read-only external folder\")\n\n    # Save ZIP to temp file\n    try:\n        with tempfile.NamedTemporaryFile(delete=False, suffix=\".zip\") as tmp:\n            content = await file.read()\n            tmp.write(content)\n            tmp_path = tmp.name\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to save ZIP file: {str(e)}\")\n\n    extracted_files: list[ZipExtractResult] = []\n    errors: list[ZipExtractError] = []\n    folders_created = 0\n    folder_cache: dict[str, int] = {}  # path -> folder_id\n\n    # If create_folder_from_zip is True, create a folder named after the ZIP file\n    zip_folder_id = folder_id\n    logger.info(\n        f\"ZIP extraction: create_folder_from_zip={create_folder_from_zip}, folder_id={folder_id}, filename={file.filename}\"\n    )\n    if create_folder_from_zip and file.filename:\n        # Remove .zip extension to get folder name\n        zip_folder_name = file.filename[:-4] if file.filename.lower().endswith(\".zip\") else file.filename\n        # Check if folder already exists\n        existing = await db.execute(\n            select(LibraryFolder).where(\n                LibraryFolder.name == zip_folder_name,\n                LibraryFolder.parent_id == folder_id if folder_id else LibraryFolder.parent_id.is_(None),\n            )\n        )\n        existing_folder = existing.scalar_one_or_none()\n        if existing_folder:\n            zip_folder_id = existing_folder.id\n            logger.info(\"Reusing existing folder '%s' with id=%s\", zip_folder_name, zip_folder_id)\n        else:\n            # Create folder\n            new_folder = LibraryFolder(name=zip_folder_name, parent_id=folder_id)\n            db.add(new_folder)\n            await db.flush()\n            await db.commit()  # Commit folder creation immediately\n            zip_folder_id = new_folder.id\n            folders_created += 1\n            logger.info(\"Created new folder '%s' with id=%s\", zip_folder_name, zip_folder_id)\n\n    try:\n        with zipfile.ZipFile(tmp_path, \"r\") as zf:\n            # Filter out directories and hidden/system files\n            file_list = [\n                name\n                for name in zf.namelist()\n                if not name.endswith(\"/\")\n                and not name.startswith(\"__MACOSX\")\n                and not os.path.basename(name).startswith(\".\")\n            ]\n\n            for zip_path in file_list:\n                try:\n                    # Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)\n                    target_folder_id = zip_folder_id\n\n                    if preserve_structure:\n                        # Get directory path from ZIP\n                        dir_path = os.path.dirname(zip_path)\n                        if dir_path:\n                            # Create folder structure\n                            parts = dir_path.split(\"/\")\n                            current_parent = zip_folder_id\n                            current_path = \"\"\n\n                            for part in parts:\n                                if not part:\n                                    continue\n                                current_path = f\"{current_path}/{part}\" if current_path else part\n\n                                if current_path in folder_cache:\n                                    current_parent = folder_cache[current_path]\n                                else:\n                                    # Check if folder exists\n                                    existing = await db.execute(\n                                        select(LibraryFolder).where(\n                                            LibraryFolder.name == part,\n                                            LibraryFolder.parent_id == current_parent\n                                            if current_parent\n                                            else LibraryFolder.parent_id.is_(None),\n                                        )\n                                    )\n                                    existing_folder = existing.scalar_one_or_none()\n\n                                    if existing_folder:\n                                        current_parent = existing_folder.id\n                                    else:\n                                        # Create folder\n                                        new_folder = LibraryFolder(name=part, parent_id=current_parent)\n                                        db.add(new_folder)\n                                        await db.flush()\n                                        current_parent = new_folder.id\n                                        folders_created += 1\n\n                                    folder_cache[current_path] = current_parent\n\n                            target_folder_id = current_parent\n\n                    # Extract file\n                    filename = os.path.basename(zip_path)\n                    ext = os.path.splitext(filename)[1].lower()\n                    file_type = ext[1:] if ext else \"unknown\"\n\n                    # Generate unique filename for storage\n                    unique_filename = f\"{uuid.uuid4().hex}{ext}\"\n                    file_path = get_library_files_dir() / unique_filename\n\n                    # Extract and save file\n                    file_content = zf.read(zip_path)\n                    with open(file_path, \"wb\") as f:\n                        f.write(file_content)\n\n                    # Calculate hash\n                    file_hash = calculate_file_hash(file_path)\n\n                    # Extract metadata and thumbnail for 3MF files\n                    metadata = {}\n                    thumbnail_path = None\n                    thumbnails_dir = get_library_thumbnails_dir()\n\n                    if ext == \".3mf\":\n                        try:\n                            parser = ThreeMFParser(str(file_path))\n                            raw_metadata = parser.parse()\n\n                            thumbnail_data = raw_metadata.get(\"_thumbnail_data\")\n                            thumbnail_ext = raw_metadata.get(\"_thumbnail_ext\", \".png\")\n\n                            if thumbnail_data:\n                                thumb_filename = f\"{uuid.uuid4().hex}{thumbnail_ext}\"\n                                thumb_path = thumbnails_dir / thumb_filename\n                                with open(thumb_path, \"wb\") as f:\n                                    f.write(thumbnail_data)\n                                thumbnail_path = str(thumb_path)\n\n                            def clean_metadata(obj):\n                                if isinstance(obj, dict):\n                                    return {\n                                        k: clean_metadata(v)\n                                        for k, v in obj.items()\n                                        if not isinstance(v, bytes) and k not in (\"_thumbnail_data\", \"_thumbnail_ext\")\n                                    }\n                                elif isinstance(obj, list):\n                                    return [clean_metadata(i) for i in obj if not isinstance(i, bytes)]\n                                elif isinstance(obj, bytes):\n                                    return None\n                                return obj\n\n                            metadata = clean_metadata(raw_metadata)\n                        except Exception as e:\n                            logger.warning(\"Failed to parse 3MF from ZIP: %s\", e)\n\n                    elif ext == \".gcode\":\n                        try:\n                            thumbnail_data = extract_gcode_thumbnail(file_path)\n                            if thumbnail_data:\n                                thumb_filename = f\"{uuid.uuid4().hex}.png\"\n                                thumb_path = thumbnails_dir / thumb_filename\n                                with open(thumb_path, \"wb\") as f:\n                                    f.write(thumbnail_data)\n                                thumbnail_path = str(thumb_path)\n                        except Exception as e:\n                            logger.warning(\"Failed to extract gcode thumbnail from ZIP: %s\", e)\n\n                    elif ext.lower() in IMAGE_EXTENSIONS:\n                        thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)\n\n                    elif ext == \".stl\":\n                        # Generate STL thumbnail if enabled\n                        if generate_stl_thumbnails:\n                            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)\n\n                    # Create database entry (store relative paths for portability)\n                    library_file = LibraryFile(\n                        folder_id=target_folder_id,\n                        filename=filename,\n                        file_path=to_relative_path(file_path),\n                        file_type=file_type,\n                        file_size=len(file_content),\n                        file_hash=file_hash,\n                        thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,\n                        file_metadata=metadata if metadata else None,\n                        created_by_id=current_user.id if current_user else None,\n                    )\n                    db.add(library_file)\n                    await db.flush()\n                    await db.refresh(library_file)\n\n                    extracted_files.append(\n                        ZipExtractResult(\n                            filename=filename,\n                            file_id=library_file.id,\n                            folder_id=target_folder_id,\n                        )\n                    )\n\n                    # Commit after each file to release database lock\n                    # This prevents long-running transactions from blocking other requests\n                    await db.commit()\n\n                except Exception as e:\n                    logger.error(\"Failed to extract %s: %s\", zip_path, e)\n                    errors.append(ZipExtractError(filename=os.path.basename(zip_path), error=str(e)))\n                    # Rollback the failed file but continue with others\n                    await db.rollback()\n\n        return ZipExtractResponse(\n            extracted=len(extracted_files),\n            folders_created=folders_created,\n            files=extracted_files,\n            errors=errors,\n        )\n\n    except zipfile.BadZipFile:\n        raise HTTPException(status_code=400, detail=\"Invalid or corrupted ZIP file\")\n    except Exception as e:\n        logger.error(\"ZIP extraction failed: %s\", e, exc_info=True)\n        raise HTTPException(status_code=500, detail=f\"ZIP extraction failed: {str(e)}\")\n    finally:\n        # Clean up temp file\n        try:\n            os.unlink(tmp_path)\n        except OSError:\n            pass  # Best-effort temp file cleanup; ignore if already removed\n\n\n# ============ STL Thumbnail Batch Generation ============\n\n\n@router.post(\"/generate-stl-thumbnails\", response_model=BatchThumbnailResponse)\nasync def batch_generate_stl_thumbnails(\n    request: BatchThumbnailRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),\n):\n    \"\"\"Generate thumbnails for STL files in batch.\n\n    Note: Requires library:update_all permission since this is a batch operation\n    that may affect files owned by different users.\n\n    Can generate thumbnails for:\n    - Specific file IDs (file_ids)\n    - All STL files in a folder (folder_id)\n    - All STL files missing thumbnails (all_missing=True)\n    \"\"\"\n    thumbnails_dir = get_library_thumbnails_dir()\n    results: list[BatchThumbnailResult] = []\n\n    # Build query based on request\n    query = select(LibraryFile).where(LibraryFile.file_type == \"stl\")\n\n    if request.file_ids:\n        # Specific files\n        query = query.where(LibraryFile.id.in_(request.file_ids))\n    elif request.folder_id is not None:\n        # All STL files in a specific folder\n        query = query.where(LibraryFile.folder_id == request.folder_id)\n        if not request.all_missing:\n            # If not specifically asking for missing thumbnails, get all\n            pass\n        else:\n            query = query.where(LibraryFile.thumbnail_path.is_(None))\n    elif request.all_missing:\n        # All STL files without thumbnails\n        query = query.where(LibraryFile.thumbnail_path.is_(None))\n    else:\n        # No criteria specified - return empty\n        return BatchThumbnailResponse(\n            processed=0,\n            succeeded=0,\n            failed=0,\n            results=[],\n        )\n\n    result = await db.execute(query)\n    stl_files = result.scalars().all()\n\n    succeeded = 0\n    failed = 0\n\n    for stl_file in stl_files:\n        file_path = to_absolute_path(stl_file.file_path)\n\n        if not file_path or not file_path.exists():\n            results.append(\n                BatchThumbnailResult(\n                    file_id=stl_file.id,\n                    filename=stl_file.filename,\n                    success=False,\n                    error=\"File not found on disk\",\n                )\n            )\n            failed += 1\n            continue\n\n        try:\n            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)\n\n            if thumbnail_path:\n                # Update database with relative path\n                stl_file.thumbnail_path = to_relative_path(thumbnail_path)\n                await db.flush()\n                results.append(\n                    BatchThumbnailResult(\n                        file_id=stl_file.id,\n                        filename=stl_file.filename,\n                        success=True,\n                    )\n                )\n                succeeded += 1\n            else:\n                results.append(\n                    BatchThumbnailResult(\n                        file_id=stl_file.id,\n                        filename=stl_file.filename,\n                        success=False,\n                        error=\"Thumbnail generation failed\",\n                    )\n                )\n                failed += 1\n        except Exception as e:\n            logger.error(\"Failed to generate thumbnail for %s: %s\", stl_file.filename, e)\n            results.append(\n                BatchThumbnailResult(\n                    file_id=stl_file.id,\n                    filename=stl_file.filename,\n                    success=False,\n                    error=str(e),\n                )\n            )\n            failed += 1\n\n    await db.commit()\n\n    return BatchThumbnailResponse(\n        processed=len(stl_files),\n        succeeded=succeeded,\n        failed=failed,\n        results=results,\n    )\n\n\n# ============ Queue Operations ============\n# NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts\n\n\ndef is_sliced_file(filename: str) -> bool:\n    \"\"\"Check if a file is a sliced (printable) file.\n\n    Sliced files are:\n    - .gcode files\n    - .3mf files that contain '.gcode.' in the name (e.g., filename.gcode.3mf)\n    \"\"\"\n    lower = filename.lower()\n    return lower.endswith(\".gcode\") or \".gcode.\" in lower\n\n\n@router.post(\"/files/add-to-queue\", response_model=AddToQueueResponse)\nasync def add_files_to_queue(\n    request: AddToQueueRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),\n):\n    \"\"\"Add library files to the print queue.\n\n    Only sliced files (.gcode or .gcode.3mf) can be added to the queue.\n    The archive will be created automatically when the print starts.\n    \"\"\"\n    added: list[AddToQueueResult] = []\n    errors: list[AddToQueueError] = []\n\n    # Get all requested files\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id.in_(request.file_ids)))\n    files = {f.id: f for f in result.scalars().all()}\n\n    # Get max position for queue ordering\n    pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))\n    max_position = pos_result.scalar() or 0\n\n    for file_id in request.file_ids:\n        lib_file = files.get(file_id)\n\n        if not lib_file:\n            errors.append(AddToQueueError(file_id=file_id, filename=\"(not found)\", error=\"File not found\"))\n            continue\n\n        # Validate file is sliced\n        if not is_sliced_file(lib_file.filename):\n            errors.append(\n                AddToQueueError(\n                    file_id=file_id,\n                    filename=lib_file.filename,\n                    error=\"Not a sliced file. Only .gcode or .gcode.3mf files can be printed.\",\n                )\n            )\n            continue\n\n        try:\n            # Verify file exists on disk\n            file_path = Path(app_settings.base_dir) / lib_file.file_path\n\n            if not file_path.exists():\n                errors.append(\n                    AddToQueueError(file_id=file_id, filename=lib_file.filename, error=\"File not found on disk\")\n                )\n                continue\n\n            # Create queue item referencing library file (archive created at print start)\n            max_position += 1\n            queue_item = PrintQueueItem(\n                printer_id=None,  # Unassigned\n                library_file_id=file_id,\n                position=max_position,\n                status=\"pending\",\n            )\n            db.add(queue_item)\n\n            await db.flush()  # Get queue_item.id\n\n            added.append(\n                AddToQueueResult(\n                    file_id=file_id,\n                    filename=lib_file.filename,\n                    queue_item_id=queue_item.id,\n                )\n            )\n\n        except Exception as e:\n            logger.exception(\"Error adding file %s to queue\", file_id)\n            errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e)))\n\n    await db.commit()\n\n    return AddToQueueResponse(added=added, errors=errors)\n\n\n@router.get(\"/files/{file_id}/plates\")\nasync def get_library_file_plates(\n    file_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get available plates from a multi-plate 3MF library file.\n\n    Returns a list of plates with their index, name, thumbnail availability,\n    and filament requirements. For single-plate exports, returns a single plate.\n    \"\"\"\n    import json\n\n    import defusedxml.ElementTree as ET\n\n    # Get the library file\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    lib_file = result.scalar_one_or_none()\n\n    if not lib_file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    file_path = Path(app_settings.base_dir) / lib_file.file_path\n    if not file_path.exists():\n        raise HTTPException(status_code=404, detail=\"File not found on disk\")\n\n    # Only 3MF files have plates\n    if not lib_file.filename.lower().endswith(\".3mf\"):\n        return {\"file_id\": file_id, \"filename\": lib_file.filename, \"plates\": [], \"is_multi_plate\": False}\n\n    plates = []\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            namelist = zf.namelist()\n\n            # Find all plate gcode files to determine available plates\n            gcode_files = [n for n in namelist if n.startswith(\"Metadata/plate_\") and n.endswith(\".gcode\")]\n\n            # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG\n            plate_indices: list[int] = []\n            if gcode_files:\n                # Extract plate indices from gcode filenames\n                for gf in gcode_files:\n                    try:\n                        plate_str = gf[15:-6]  # Remove \"Metadata/plate_\" and \".gcode\"\n                        plate_indices.append(int(plate_str))\n                    except ValueError:\n                        pass  # Skip gcode file with non-numeric plate index\n            else:\n                plate_json_files = [n for n in namelist if n.startswith(\"Metadata/plate_\") and n.endswith(\".json\")]\n                plate_png_files = [\n                    n\n                    for n in namelist\n                    if n.startswith(\"Metadata/plate_\")\n                    and n.endswith(\".png\")\n                    and \"_small\" not in n\n                    and \"no_light\" not in n\n                ]\n                plate_name_candidates = plate_json_files + plate_png_files\n                plate_re = re.compile(r\"^Metadata/plate_(\\d+)\\.(json|png)$\")\n                seen_indices: set[int] = set()\n                for name in plate_name_candidates:\n                    match = plate_re.match(name)\n                    if match:\n                        try:\n                            index = int(match.group(1))\n                        except ValueError:\n                            continue\n                        if index in seen_indices:\n                            continue\n                        seen_indices.add(index)\n                        plate_indices.append(index)\n\n            if not plate_indices:\n                # No plate metadata found\n                return {\"file_id\": file_id, \"filename\": lib_file.filename, \"plates\": [], \"is_multi_plate\": False}\n\n            plate_indices.sort()\n\n            # Parse model_settings.config for plate names + object assignments\n            plate_names = {}\n            plate_object_ids: dict[int, list[str]] = {}\n            object_names_by_id: dict[str, str] = {}\n            if \"Metadata/model_settings.config\" in namelist:\n                try:\n                    model_content = zf.read(\"Metadata/model_settings.config\").decode()\n                    model_root = ET.fromstring(model_content)\n                    for obj_elem in model_root.findall(\".//object\"):\n                        obj_id = obj_elem.get(\"id\")\n                        if not obj_id:\n                            continue\n                        name_meta = obj_elem.find(\"metadata[@key='name']\")\n                        obj_name = name_meta.get(\"value\") if name_meta is not None else None\n                        if obj_name:\n                            object_names_by_id[obj_id] = obj_name\n\n                    for plate_elem in model_root.findall(\".//plate\"):\n                        plater_id = None\n                        plater_name = None\n                        for meta in plate_elem.findall(\"metadata\"):\n                            key = meta.get(\"key\")\n                            value = meta.get(\"value\")\n                            if key == \"plater_id\" and value:\n                                try:\n                                    plater_id = int(value)\n                                except ValueError:\n                                    pass  # Ignore plate with non-numeric plater_id\n                            elif key == \"plater_name\" and value:\n                                plater_name = value.strip()\n                        if plater_id is not None and plater_name:\n                            plate_names[plater_id] = plater_name\n\n                        if plater_id is not None:\n                            for instance_elem in plate_elem.findall(\"model_instance\"):\n                                for inst_meta in instance_elem.findall(\"metadata\"):\n                                    if inst_meta.get(\"key\") == \"object_id\":\n                                        obj_id = inst_meta.get(\"value\")\n                                        if not obj_id:\n                                            continue\n                                        plate_object_ids.setdefault(plater_id, [])\n                                        if obj_id not in plate_object_ids[plater_id]:\n                                            plate_object_ids[plater_id].append(obj_id)\n                except Exception:\n                    pass  # model_settings.config is optional; skip if missing or malformed\n\n            # Parse slice_info.config for plate metadata\n            plate_metadata = {}\n            if \"Metadata/slice_info.config\" in namelist:\n                content = zf.read(\"Metadata/slice_info.config\").decode()\n                root = ET.fromstring(content)\n\n                for plate_elem in root.findall(\".//plate\"):\n                    plate_info = {\"filaments\": [], \"prediction\": None, \"weight\": None, \"name\": None, \"objects\": []}\n\n                    plate_index = None\n                    for meta in plate_elem.findall(\"metadata\"):\n                        key = meta.get(\"key\")\n                        value = meta.get(\"value\")\n                        if key == \"index\" and value:\n                            try:\n                                plate_index = int(value)\n                            except ValueError:\n                                pass  # Ignore plate with non-numeric index\n                        elif key == \"prediction\" and value:\n                            try:\n                                plate_info[\"prediction\"] = int(value)\n                            except ValueError:\n                                pass  # Leave prediction as None if not a valid integer\n                        elif key == \"weight\" and value:\n                            try:\n                                plate_info[\"weight\"] = float(value)\n                            except ValueError:\n                                pass  # Leave weight as None if not a valid number\n\n                    # Get filaments used in this plate\n                    for filament_elem in plate_elem.findall(\"filament\"):\n                        filament_id = filament_elem.get(\"id\")\n                        filament_type = filament_elem.get(\"type\", \"\")\n                        filament_color = filament_elem.get(\"color\", \"\")\n                        used_g = filament_elem.get(\"used_g\", \"0\")\n                        used_m = filament_elem.get(\"used_m\", \"0\")\n\n                        try:\n                            used_grams = float(used_g)\n                        except (ValueError, TypeError):\n                            used_grams = 0\n\n                        if used_grams > 0 and filament_id:\n                            plate_info[\"filaments\"].append(\n                                {\n                                    \"slot_id\": int(filament_id),\n                                    \"type\": filament_type,\n                                    \"color\": filament_color,\n                                    \"used_grams\": round(used_grams, 1),\n                                    \"used_meters\": float(used_m) if used_m else 0,\n                                }\n                            )\n\n                    plate_info[\"filaments\"].sort(key=lambda x: x[\"slot_id\"])\n\n                    # Collect object names\n                    for obj_elem in plate_elem.findall(\"object\"):\n                        obj_name = obj_elem.get(\"name\")\n                        if obj_name and obj_name not in plate_info[\"objects\"]:\n                            plate_info[\"objects\"].append(obj_name)\n\n                    # Set plate name\n                    if plate_index is not None:\n                        custom_name = plate_names.get(plate_index)\n                        if custom_name:\n                            plate_info[\"name\"] = custom_name\n                        elif plate_info[\"objects\"]:\n                            plate_info[\"name\"] = plate_info[\"objects\"][0]\n                        plate_metadata[plate_index] = plate_info\n\n            # Parse plate_*.json for object lists when slice_info is missing\n            plate_json_objects: dict[int, list[str]] = {}\n            for name in namelist:\n                match = re.match(r\"^Metadata/plate_(\\d+)\\.json$\", name)\n                if not match:\n                    continue\n                try:\n                    plate_index = int(match.group(1))\n                except ValueError:\n                    continue\n                try:\n                    payload = json.loads(zf.read(name).decode())\n                    bbox_objects = payload.get(\"bbox_objects\", [])\n                    names: list[str] = []\n                    for obj in bbox_objects:\n                        obj_name = obj.get(\"name\") if isinstance(obj, dict) else None\n                        if obj_name and obj_name not in names:\n                            names.append(obj_name)\n                    if names:\n                        plate_json_objects[plate_index] = names\n                except Exception:\n                    continue\n\n            # Build plate list\n            for idx in plate_indices:\n                meta = plate_metadata.get(idx, {})\n                has_thumbnail = f\"Metadata/plate_{idx}.png\" in namelist\n                objects = meta.get(\"objects\", [])\n                if not objects:\n                    objects = plate_json_objects.get(idx, [])\n                if not objects and plate_object_ids.get(idx):\n                    objects = [\n                        object_names_by_id.get(obj_id, f\"Object {obj_id}\") for obj_id in plate_object_ids.get(idx, [])\n                    ]\n\n                plate_name = meta.get(\"name\")\n                if not plate_name:\n                    plate_name = plate_names.get(idx)\n                if not plate_name and objects:\n                    plate_name = objects[0]\n\n                plates.append(\n                    {\n                        \"index\": idx,\n                        \"name\": plate_name,\n                        \"objects\": objects,\n                        \"object_count\": len(objects),\n                        \"has_thumbnail\": has_thumbnail,\n                        \"thumbnail_url\": f\"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}\"\n                        if has_thumbnail\n                        else None,\n                        \"print_time_seconds\": meta.get(\"prediction\"),\n                        \"filament_used_grams\": meta.get(\"weight\"),\n                        \"filaments\": meta.get(\"filaments\", []),\n                    }\n                )\n\n    except Exception as e:\n        logger.warning(\"Failed to parse plates from library file %s: %s\", file_id, e)\n\n    return {\n        \"file_id\": file_id,\n        \"filename\": lib_file.filename,\n        \"plates\": plates,\n        \"is_multi_plate\": len(plates) > 1,\n    }\n\n\n@router.get(\"/files/{file_id}/plate-thumbnail/{plate_index}\")\nasync def get_library_file_plate_thumbnail(\n    file_id: int,\n    plate_index: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get the thumbnail image for a specific plate from a library file.\"\"\"\n    from starlette.responses import Response\n\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    lib_file = result.scalar_one_or_none()\n\n    if not lib_file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    file_path = Path(app_settings.base_dir) / lib_file.file_path\n    if not file_path.exists():\n        raise HTTPException(status_code=404, detail=\"File not found on disk\")\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            thumb_path = f\"Metadata/plate_{plate_index}.png\"\n            if thumb_path in zf.namelist():\n                data = zf.read(thumb_path)\n                return Response(content=data, media_type=\"image/png\")\n    except Exception:\n        pass  # Archive unreadable or thumbnail missing; fall through to 404\n\n    raise HTTPException(status_code=404, detail=f\"Thumbnail for plate {plate_index} not found\")\n\n\n@router.get(\"/files/{file_id}/filament-requirements\")\nasync def get_library_file_filament_requirements(\n    file_id: int,\n    plate_id: int | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get filament requirements from a library file.\n\n    Parses the 3MF file to extract filament slot IDs, types, colors, and usage.\n    This enables AMS slot assignment when printing from the file manager.\n\n    Args:\n        file_id: The library file ID\n        plate_id: Optional plate index to get filaments for a specific plate\n    \"\"\"\n    import defusedxml.ElementTree as ET\n\n    # Get the library file\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    lib_file = result.scalar_one_or_none()\n\n    if not lib_file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    # Get the full file path\n    file_path = Path(app_settings.base_dir) / lib_file.file_path\n\n    if not file_path.exists():\n        raise HTTPException(status_code=404, detail=\"File not found on disk\")\n\n    # Only 3MF files have parseable filament info\n    if not lib_file.filename.lower().endswith(\".3mf\"):\n        return {\"file_id\": file_id, \"filename\": lib_file.filename, \"plate_id\": plate_id, \"filaments\": []}\n\n    filaments = []\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            # Parse slice_info.config for filament requirements\n            if \"Metadata/slice_info.config\" in zf.namelist():\n                content = zf.read(\"Metadata/slice_info.config\").decode()\n                root = ET.fromstring(content)\n\n                if plate_id is not None:\n                    # Find filaments for specific plate\n                    for plate_elem in root.findall(\".//plate\"):\n                        # Check if this is the requested plate\n                        plate_index = None\n                        for meta in plate_elem.findall(\"metadata\"):\n                            if meta.get(\"key\") == \"index\":\n                                try:\n                                    plate_index = int(meta.get(\"value\", \"\"))\n                                except ValueError:\n                                    pass  # Skip plate with non-numeric index value\n                                break\n\n                        if plate_index == plate_id:\n                            # Extract filaments from this plate\n                            for filament_elem in plate_elem.findall(\"filament\"):\n                                filament_id = filament_elem.get(\"id\")\n                                filament_type = filament_elem.get(\"type\", \"\")\n                                filament_color = filament_elem.get(\"color\", \"\")\n                                used_g = filament_elem.get(\"used_g\", \"0\")\n                                used_m = filament_elem.get(\"used_m\", \"0\")\n\n                                tray_info_idx = filament_elem.get(\"tray_info_idx\", \"\")\n\n                                try:\n                                    used_grams = float(used_g)\n                                except (ValueError, TypeError):\n                                    used_grams = 0\n\n                                if used_grams > 0 and filament_id:\n                                    filaments.append(\n                                        {\n                                            \"slot_id\": int(filament_id),\n                                            \"type\": filament_type,\n                                            \"color\": filament_color,\n                                            \"used_grams\": round(used_grams, 1),\n                                            \"used_meters\": float(used_m) if used_m else 0,\n                                            \"tray_info_idx\": tray_info_idx,\n                                        }\n                                    )\n                            break\n                else:\n                    # Extract all filaments with used_g > 0 (for single-plate or overview)\n                    for filament_elem in root.findall(\".//filament\"):\n                        filament_id = filament_elem.get(\"id\")\n                        filament_type = filament_elem.get(\"type\", \"\")\n                        filament_color = filament_elem.get(\"color\", \"\")\n                        used_g = filament_elem.get(\"used_g\", \"0\")\n                        used_m = filament_elem.get(\"used_m\", \"0\")\n\n                        tray_info_idx = filament_elem.get(\"tray_info_idx\", \"\")\n\n                        try:\n                            used_grams = float(used_g)\n                        except (ValueError, TypeError):\n                            used_grams = 0\n\n                        if used_grams > 0 and filament_id:\n                            filaments.append(\n                                {\n                                    \"slot_id\": int(filament_id),\n                                    \"type\": filament_type,\n                                    \"color\": filament_color,\n                                    \"used_grams\": round(used_grams, 1),\n                                    \"used_meters\": float(used_m) if used_m else 0,\n                                    \"tray_info_idx\": tray_info_idx,\n                                }\n                            )\n\n            # Sort by slot ID\n            filaments.sort(key=lambda x: x[\"slot_id\"])\n\n            # Enrich with nozzle mapping for dual-nozzle printers\n            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)\n            if nozzle_mapping:\n                for filament in filaments:\n                    filament[\"nozzle_id\"] = nozzle_mapping.get(filament[\"slot_id\"])\n\n    except Exception as e:\n        logger.warning(\"Failed to parse filament requirements from library file %s: %s\", file_id, e)\n\n    return {\n        \"file_id\": file_id,\n        \"filename\": lib_file.filename,\n        \"plate_id\": plate_id,\n        \"filaments\": filaments,\n    }\n\n\n@router.post(\"/files/{file_id}/print\")\nasync def print_library_file(\n    file_id: int,\n    printer_id: int,\n    body: FilePrintRequest | None = None,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),\n):\n    \"\"\"Dispatch a library file for send/start on a printer.\n\n    The actual send/start work is handled asynchronously by background\n    dispatch so the UI can continue immediately.\n\n    Only sliced files (.gcode or .gcode.3mf) can be printed.\n    \"\"\"\n    from backend.app.models.printer import Printer\n    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch\n    from backend.app.services.printer_manager import printer_manager\n\n    # Use defaults if no body provided\n    if body is None:\n        body = FilePrintRequest()\n\n    # Get the library file\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    lib_file = result.scalar_one_or_none()\n\n    if not lib_file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    # Validate file is sliced\n    if not is_sliced_file(lib_file.filename):\n        raise HTTPException(\n            status_code=400,\n            detail=\"Not a sliced file. Only .gcode or .gcode.3mf files can be printed.\",\n        )\n\n    # Get the full file path\n    file_path = Path(app_settings.base_dir) / lib_file.file_path\n\n    if not file_path.exists():\n        raise HTTPException(status_code=404, detail=\"File not found on disk\")\n\n    # Get printer\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    # Check printer is connected\n    if not printer_manager.is_connected(printer_id):\n        raise HTTPException(status_code=400, detail=\"Printer is not connected\")\n\n    # Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500\n    if body.project_id is not None:\n        project_result = await db.execute(select(Project).where(Project.id == body.project_id))\n        if not project_result.scalar_one_or_none():\n            raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    plate_name = body.plate_name\n    if not plate_name and body.plate_id is not None:\n        plate_name = f\"Plate {body.plate_id}\"\n\n    dispatch_source_name = lib_file.filename\n    if plate_name:\n        dispatch_source_name = f\"{lib_file.filename} • {plate_name}\"\n\n    try:\n        dispatch_result = await background_dispatch.dispatch_print_library_file(\n            file_id=file_id,\n            filename=dispatch_source_name,\n            printer_id=printer_id,\n            printer_name=printer.name,\n            options=body.model_dump(exclude_none=True, exclude={\"cleanup_library_after_dispatch\"}),\n            project_id=body.project_id,\n            requested_by_user_id=current_user.id if current_user else None,\n            requested_by_username=current_user.username if current_user else None,\n            cleanup_library_after_dispatch=body.cleanup_library_after_dispatch,\n        )\n    except DispatchEnqueueRejected as e:\n        raise HTTPException(status_code=409, detail=str(e)) from e\n\n    return {\n        \"status\": \"dispatched\",\n        \"printer_id\": printer_id,\n        \"archive_id\": None,\n        \"filename\": lib_file.filename,\n        \"dispatch_job_id\": dispatch_result[\"dispatch_job_id\"],\n        \"dispatch_position\": dispatch_result[\"dispatch_position\"],\n    }\n\n\n# ============ File Detail Endpoints ============\n\n\n@router.get(\"/files/{file_id}\", response_model=FileResponseSchema)\nasync def get_file(\n    file_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get a file by ID with full details.\"\"\"\n    result = await db.execute(\n        select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)\n    )\n    file = result.scalar_one_or_none()\n\n    if not file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    # Get folder name\n    folder_name = None\n    if file.folder_id:\n        folder_result = await db.execute(select(LibraryFolder.name).where(LibraryFolder.id == file.folder_id))\n        folder_name = folder_result.scalar()\n\n    # Get project name\n    project_name = None\n    if file.project_id:\n        project_result = await db.execute(select(Project.name).where(Project.id == file.project_id))\n        project_name = project_result.scalar()\n\n    # Get duplicates\n    duplicates = []\n    duplicate_count = 0\n    if file.file_hash:\n        dup_result = await db.execute(\n            select(LibraryFile, LibraryFolder.name)\n            .outerjoin(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)\n            .where(LibraryFile.file_hash == file.file_hash, LibraryFile.id != file.id)\n        )\n        for dup_file, dup_folder_name in dup_result.all():\n            duplicates.append(\n                FileDuplicate(\n                    id=dup_file.id,\n                    filename=dup_file.filename,\n                    folder_id=dup_file.folder_id,\n                    folder_name=dup_folder_name,\n                    created_at=dup_file.created_at,\n                )\n            )\n        duplicate_count = len(duplicates)\n\n    # Extract key metadata fields\n    print_name = None\n    print_time = None\n    filament_grams = None\n    sliced_for_model = None\n    if file.file_metadata:\n        print_name = file.file_metadata.get(\"print_name\")\n        print_time = file.file_metadata.get(\"print_time_seconds\")\n        filament_grams = file.file_metadata.get(\"filament_used_grams\")\n        sliced_for_model = file.file_metadata.get(\"sliced_for_model\")\n\n    return FileResponseSchema(\n        id=file.id,\n        folder_id=file.folder_id,\n        folder_name=folder_name,\n        project_id=file.project_id,\n        project_name=project_name,\n        filename=file.filename,\n        file_path=file.file_path,\n        file_type=file.file_type,\n        file_size=file.file_size,\n        file_hash=file.file_hash,\n        thumbnail_path=file.thumbnail_path,\n        metadata=file.file_metadata,\n        print_count=file.print_count,\n        last_printed_at=file.last_printed_at,\n        notes=file.notes,\n        duplicates=duplicates if duplicates else None,\n        duplicate_count=duplicate_count,\n        created_by_id=file.created_by_id,\n        created_by_username=file.created_by.username if file.created_by else None,\n        created_at=file.created_at,\n        updated_at=file.updated_at,\n        print_name=print_name,\n        print_time_seconds=print_time,\n        filament_used_grams=filament_grams,\n        sliced_for_model=sliced_for_model,\n    )\n\n\n@router.put(\"/files/{file_id}\", response_model=FileResponseSchema)\nasync def update_file(\n    file_id: int,\n    data: FileUpdate,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.LIBRARY_UPDATE_ALL,\n            Permission.LIBRARY_UPDATE_OWN,\n        )\n    ),\n):\n    \"\"\"Update a file's metadata.\"\"\"\n    user, can_modify_all = auth_result\n\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    file = result.scalar_one_or_none()\n\n    if not file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    # Ownership check\n    if not can_modify_all:\n        if file.created_by_id != user.id:\n            raise HTTPException(status_code=403, detail=\"You can only update your own files\")\n\n    if data.filename is not None:\n        # Validate filename doesn't contain path separators\n        if \"/\" in data.filename or \"\\\\\" in data.filename:\n            raise HTTPException(status_code=400, detail=\"Filename cannot contain path separators\")\n        file.filename = data.filename\n        # Also update print_name in file_metadata so the display name matches\n        if file.file_metadata and \"print_name\" in file.file_metadata:\n            file.file_metadata = {**file.file_metadata, \"print_name\": data.filename}\n\n    if data.folder_id is not None:\n        if data.folder_id == 0:\n            file.folder_id = None\n        else:\n            # Verify folder exists\n            folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))\n            if not folder_result.scalar_one_or_none():\n                raise HTTPException(status_code=404, detail=\"Folder not found\")\n            file.folder_id = data.folder_id\n\n    if data.project_id is not None:\n        if data.project_id == 0:\n            file.project_id = None\n        else:\n            # Verify project exists\n            project_result = await db.execute(select(Project).where(Project.id == data.project_id))\n            if not project_result.scalar_one_or_none():\n                raise HTTPException(status_code=404, detail=\"Project not found\")\n            file.project_id = data.project_id\n\n    if data.notes is not None:\n        file.notes = data.notes if data.notes else None\n\n    await db.commit()\n    await db.refresh(file)\n\n    # Return full response (reuse get_file logic)\n    return await get_file(file_id, db)\n\n\n@router.delete(\"/files/{file_id}\")\nasync def delete_file(\n    file_id: int,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.LIBRARY_DELETE_ALL,\n            Permission.LIBRARY_DELETE_OWN,\n        )\n    ),\n):\n    \"\"\"Delete a file.\"\"\"\n    user, can_modify_all = auth_result\n\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    file = result.scalar_one_or_none()\n\n    if not file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    # Ownership check\n    if not can_modify_all:\n        if file.created_by_id != user.id:\n            raise HTTPException(status_code=403, detail=\"You can only delete your own files\")\n\n    # External files: only remove DB entry and thumbnail, never delete the actual file\n    try:\n        if not file.is_external:\n            abs_file_path = to_absolute_path(file.file_path)\n            if abs_file_path and abs_file_path.exists():\n                abs_file_path.unlink()\n        # Always clean up thumbnails we generated\n        abs_thumb_path = to_absolute_path(file.thumbnail_path)\n        if abs_thumb_path and abs_thumb_path.exists():\n            abs_thumb_path.unlink()\n    except OSError as e:\n        logger.warning(\"Failed to delete file from disk: %s\", e)\n\n    await db.delete(file)\n    await db.commit()\n\n    return {\"status\": \"success\", \"message\": \"File deleted\"}\n\n\n# ============ File Content Endpoints ============\n\n\n@router.get(\"/files/{file_id}/download\")\nasync def download_file(\n    file_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Download a file.\"\"\"\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    file = result.scalar_one_or_none()\n\n    if not file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    abs_path = to_absolute_path(file.file_path)\n    if not abs_path or not abs_path.exists():\n        raise HTTPException(status_code=404, detail=\"File not found on disk\")\n\n    return FastAPIFileResponse(\n        str(abs_path),\n        filename=file.filename,\n        media_type=\"application/octet-stream\",\n    )\n\n\n@router.post(\"/files/{file_id}/slicer-token\")\nasync def create_library_slicer_token(\n    file_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Create a short-lived download token for opening files in slicer applications.\n\n    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send\n    auth headers, so they use this token in the URL path instead.\n    \"\"\"\n    from backend.app.core.auth import create_slicer_download_token\n\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    file = result.scalar_one_or_none()\n    if not file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    token = await create_slicer_download_token(\"library\", file_id)\n    return {\"token\": token}\n\n\n@router.get(\"/files/{file_id}/dl/{token}/{filename}\")\nasync def download_library_file_for_slicer(\n    file_id: int,\n    token: str,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Download a library file using a slicer download token.\n\n    Token-authenticated (no auth headers needed). The token is short-lived\n    and single-use, created by POST /files/{file_id}/slicer-token.\n    Filename is at the end of the URL so slicers can detect the file format.\n    \"\"\"\n    from backend.app.core.auth import verify_slicer_download_token\n\n    if not await verify_slicer_download_token(token, \"library\", file_id):\n        raise HTTPException(status_code=403, detail=\"Invalid or expired download token\")\n\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    file = result.scalar_one_or_none()\n    if not file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    abs_path = to_absolute_path(file.file_path)\n    if not abs_path or not abs_path.exists():\n        raise HTTPException(status_code=404, detail=\"File not found on disk\")\n\n    return FastAPIFileResponse(\n        str(abs_path),\n        filename=file.filename,\n        media_type=\"application/octet-stream\",\n    )\n\n\n@router.get(\"/files/{file_id}/thumbnail\")\nasync def get_thumbnail(\n    file_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get a file's thumbnail.\"\"\"\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    file = result.scalar_one_or_none()\n\n    if not file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    abs_thumb_path = to_absolute_path(file.thumbnail_path)\n    if not abs_thumb_path or not abs_thumb_path.exists():\n        raise HTTPException(status_code=404, detail=\"Thumbnail not found\")\n\n    # Detect media type from extension\n    thumb_ext = abs_thumb_path.suffix.lower()\n    media_types = {\n        \".png\": \"image/png\",\n        \".jpg\": \"image/jpeg\",\n        \".jpeg\": \"image/jpeg\",\n        \".gif\": \"image/gif\",\n        \".webp\": \"image/webp\",\n    }\n    media_type = media_types.get(thumb_ext, \"image/png\")\n\n    return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type)\n\n\n@router.get(\"/files/{file_id}/gcode\")\nasync def get_gcode(\n    file_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get gcode for a file (for preview).\"\"\"\n    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n    file = result.scalar_one_or_none()\n\n    if not file:\n        raise HTTPException(status_code=404, detail=\"File not found\")\n\n    abs_path = to_absolute_path(file.file_path)\n    if not abs_path or not abs_path.exists():\n        raise HTTPException(status_code=404, detail=\"File not found on disk\")\n\n    if file.file_type == \"gcode\":\n        return FastAPIFileResponse(str(abs_path), media_type=\"text/plain\")\n    elif file.file_type == \"3mf\":\n        # Extract gcode from 3mf\n        try:\n            with zipfile.ZipFile(str(abs_path), \"r\") as zf:\n                # Find gcode file\n                gcode_files = [n for n in zf.namelist() if n.endswith(\".gcode\")]\n                if not gcode_files:\n                    raise HTTPException(status_code=404, detail=\"No gcode found in 3MF file\")\n                gcode_content = zf.read(gcode_files[0])\n                from fastapi.responses import Response\n\n                return Response(content=gcode_content, media_type=\"text/plain\")\n        except zipfile.BadZipFile:\n            raise HTTPException(status_code=400, detail=\"Invalid 3MF file\")\n    else:\n        raise HTTPException(status_code=400, detail=\"Unsupported file type\")\n\n\n# ============ Bulk Operations ============\n\n\n@router.post(\"/files/move\")\nasync def move_files(\n    data: FileMoveRequest,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.LIBRARY_UPDATE_ALL,\n            Permission.LIBRARY_UPDATE_OWN,\n        )\n    ),\n):\n    \"\"\"Move multiple files to a folder.\n\n    Files not owned by the user are skipped (unless user has *_all permission).\n    \"\"\"\n    user, can_modify_all = auth_result\n\n    # Verify folder exists if specified\n    if data.folder_id is not None:\n        folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))\n        target_folder = folder_result.scalar_one_or_none()\n        if not target_folder:\n            raise HTTPException(status_code=404, detail=\"Folder not found\")\n        if target_folder.is_external and target_folder.external_readonly:\n            raise HTTPException(status_code=403, detail=\"Cannot move files to a read-only external folder\")\n\n    # Update files\n    moved = 0\n    skipped = 0\n    for file_id in data.file_ids:\n        result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n        file = result.scalar_one_or_none()\n        if file:\n            # Ownership check\n            if not can_modify_all and file.created_by_id != user.id:\n                skipped += 1\n                continue\n            # Cannot move external files out of their folder\n            if file.is_external:\n                skipped += 1\n                continue\n            file.folder_id = data.folder_id\n            moved += 1\n\n    return {\"status\": \"success\", \"moved\": moved, \"skipped\": skipped}\n\n\n@router.post(\"/bulk-delete\", response_model=BulkDeleteResponse)\nasync def bulk_delete(\n    data: BulkDeleteRequest,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.LIBRARY_DELETE_ALL,\n            Permission.LIBRARY_DELETE_OWN,\n        )\n    ),\n):\n    \"\"\"Delete multiple files and/or folders.\n\n    Files not owned by the user are skipped (unless user has *_all permission).\n    \"\"\"\n    user, can_modify_all = auth_result\n    deleted_files = 0\n    deleted_folders = 0\n    skipped_files = 0\n\n    # Delete files first\n    for file_id in data.file_ids:\n        result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))\n        file = result.scalar_one_or_none()\n        if file:\n            # Ownership check\n            if not can_modify_all and file.created_by_id != user.id:\n                skipped_files += 1\n                continue\n\n            try:\n                if not file.is_external:\n                    abs_file_path = to_absolute_path(file.file_path)\n                    if abs_file_path and abs_file_path.exists():\n                        abs_file_path.unlink()\n                abs_thumb_path = to_absolute_path(file.thumbnail_path)\n                if abs_thumb_path and abs_thumb_path.exists():\n                    abs_thumb_path.unlink()\n            except OSError as e:\n                logger.warning(\"Failed to delete file from disk: %s\", e)\n            await db.delete(file)\n            deleted_files += 1\n\n    # Delete folders (cascade will handle contents)\n    # Note: Folders don't have ownership tracking currently, require *_all permission\n    for folder_id in data.folder_ids:\n        if not can_modify_all:\n            # Users without *_all permission cannot delete folders\n            continue\n\n        result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))\n        folder = result.scalar_one_or_none()\n        if folder:\n            # Count files that will be deleted\n            file_count_result = await db.execute(\n                select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id)\n            )\n            deleted_files += file_count_result.scalar() or 0\n            await db.delete(folder)\n            deleted_folders += 1\n\n    await db.commit()\n\n    return BulkDeleteResponse(deleted_files=deleted_files, deleted_folders=deleted_folders)\n\n\n# ============ Stats Endpoint ============\n\n\n@router.get(\"/stats\")\nasync def get_library_stats(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),\n):\n    \"\"\"Get library statistics.\"\"\"\n    # Total files\n    total_files_result = await db.execute(select(func.count(LibraryFile.id)))\n    total_files = total_files_result.scalar() or 0\n\n    # Total folders\n    total_folders_result = await db.execute(select(func.count(LibraryFolder.id)))\n    total_folders = total_folders_result.scalar() or 0\n\n    # Total size\n    total_size_result = await db.execute(select(func.sum(LibraryFile.file_size)))\n    total_size = total_size_result.scalar() or 0\n\n    # Files by type\n    type_result = await db.execute(\n        select(LibraryFile.file_type, func.count(LibraryFile.id)).group_by(LibraryFile.file_type)\n    )\n    files_by_type = dict(type_result.all())\n\n    # Total prints\n    total_prints_result = await db.execute(select(func.sum(LibraryFile.print_count)))\n    total_prints = total_prints_result.scalar() or 0\n\n    # Disk space info\n    library_dir = get_library_dir()\n    try:\n        disk_stat = shutil.disk_usage(library_dir)\n        disk_free_bytes = disk_stat.free\n        disk_total_bytes = disk_stat.total\n        disk_used_bytes = disk_stat.used\n    except OSError:\n        disk_free_bytes = 0\n        disk_total_bytes = 0\n        disk_used_bytes = 0\n\n    return {\n        \"total_files\": total_files,\n        \"total_folders\": total_folders,\n        \"total_size_bytes\": total_size,\n        \"files_by_type\": files_by_type,\n        \"total_prints\": total_prints,\n        \"disk_free_bytes\": disk_free_bytes,\n        \"disk_total_bytes\": disk_total_bytes,\n        \"disk_used_bytes\": disk_used_bytes,\n    }\n"
  },
  {
    "path": "backend/app/api/routes/local_backup.py",
    "content": "\"\"\"API routes for scheduled local backups.\"\"\"\n\nimport logging\n\nfrom fastapi import APIRouter, Path\nfrom fastapi.responses import FileResponse, JSONResponse\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.user import User\nfrom backend.app.services.local_backup import local_backup_service\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/local-backup\", tags=[\"local-backup\"])\n\n\n@router.get(\"/status\")\nasync def get_status(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),\n):\n    \"\"\"Get local backup scheduler status and configuration.\"\"\"\n    settings = await local_backup_service._load_settings()\n    status = local_backup_service.get_status()\n    return {\n        **status,\n        \"enabled\": settings[\"enabled\"],\n        \"schedule\": settings[\"schedule\"],\n        \"time\": settings[\"time\"],\n        \"retention\": settings[\"retention\"],\n        \"path\": settings[\"path\"],\n        \"default_path\": str(local_backup_service._resolve_backup_dir(\"\")),\n    }\n\n\n@router.post(\"/run\")\nasync def trigger_backup(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),\n):\n    \"\"\"Trigger a local backup immediately.\"\"\"\n    result = await local_backup_service.run_backup()\n    return result\n\n\n@router.get(\"/backups\")\nasync def list_backups(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),\n):\n    \"\"\"List existing backup files.\"\"\"\n    settings = await local_backup_service._load_settings()\n    return local_backup_service.list_backups(settings[\"path\"])\n\n\n@router.get(\"/backups/{filename}/download\")\nasync def download_backup(\n    filename: str = Path(..., description=\"Backup filename to download\"),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),\n):\n    \"\"\"Download a specific backup file.\"\"\"\n    settings = await local_backup_service._load_settings()\n    file_path = local_backup_service.resolve_backup_file(settings[\"path\"], filename)\n    if file_path is None:\n        return JSONResponse(status_code=404, content={\"success\": False, \"message\": \"Backup not found\"})\n    return FileResponse(\n        path=file_path,\n        filename=filename,\n        media_type=\"application/zip\",\n    )\n\n\n@router.post(\"/backups/{filename}/restore\")\nasync def restore_backup(\n    filename: str = Path(..., description=\"Backup filename to restore\"),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),\n):\n    \"\"\"Restore from a scheduled backup file on the server.\"\"\"\n    import io\n\n    from fastapi import UploadFile\n    from fastapi.responses import JSONResponse\n\n    settings = await local_backup_service._load_settings()\n    file_path = local_backup_service.resolve_backup_file(settings[\"path\"], filename)\n    if file_path is None:\n        return JSONResponse(status_code=404, content={\"success\": False, \"message\": \"Backup not found\"})\n\n    from backend.app.api.routes.settings import restore_backup as settings_restore_backup\n    from backend.app.core.database import async_session\n\n    content = file_path.read_bytes()\n    upload = UploadFile(filename=filename, file=io.BytesIO(content))\n\n    async with async_session() as db:\n        return await settings_restore_backup(file=upload, db=db)\n\n\n@router.delete(\"/backups/{filename}\")\nasync def delete_backup(\n    filename: str = Path(..., description=\"Backup filename to delete\"),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),\n):\n    \"\"\"Delete a specific backup file.\"\"\"\n    settings = await local_backup_service._load_settings()\n    return local_backup_service.delete_backup(settings[\"path\"], filename)\n"
  },
  {
    "path": "backend/app/api/routes/local_presets.py",
    "content": "\"\"\"API routes for local slicer presets (imported from OrcaSlicer, etc.).\"\"\"\n\nimport json\nimport logging\n\nfrom fastapi import APIRouter, Depends, HTTPException, UploadFile\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.local_preset import LocalPreset\nfrom backend.app.models.user import User\nfrom backend.app.schemas.local_preset import (\n    ImportResponse,\n    LocalPresetCreate,\n    LocalPresetDetail,\n    LocalPresetResponse,\n    LocalPresetsResponse,\n    LocalPresetUpdate,\n)\nfrom backend.app.services.orca_profiles import (\n    extract_core_fields,\n    get_cache_status,\n    import_orca_file,\n    reclassify_presets,\n    refresh_base_cache,\n    resolve_preset,\n)\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/local-presets\", tags=[\"Local Presets\"])\n\n\n@router.get(\"/\", response_model=LocalPresetsResponse)\nasync def list_local_presets(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"List all local presets grouped by type.\"\"\"\n    result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))\n    presets = result.scalars().all()\n\n    grouped = LocalPresetsResponse()\n    for p in presets:\n        resp = LocalPresetResponse.model_validate(p)\n        if p.preset_type == \"filament\":\n            grouped.filament.append(resp)\n        elif p.preset_type == \"printer\":\n            grouped.printer.append(resp)\n        elif p.preset_type == \"process\":\n            grouped.process.append(resp)\n\n    return grouped\n\n\n@router.get(\"/{preset_id}\", response_model=LocalPresetDetail)\nasync def get_local_preset(\n    preset_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get full detail for a local preset including the setting JSON.\"\"\"\n    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))\n    preset = result.scalar_one_or_none()\n    if not preset:\n        raise HTTPException(404, \"Local preset not found\")\n\n    data = LocalPresetResponse.model_validate(preset).model_dump()\n    try:\n        data[\"setting\"] = json.loads(preset.setting)\n    except Exception:\n        data[\"setting\"] = {}\n\n    return LocalPresetDetail(**data)\n\n\n@router.post(\"/import\", response_model=ImportResponse)\nasync def import_presets(\n    file: UploadFile,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Import presets from an OrcaSlicer export file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip).\"\"\"\n    if not file.filename:\n        raise HTTPException(400, \"No filename provided\")\n\n    content = await file.read()\n    if not content:\n        raise HTTPException(400, \"Empty file\")\n\n    result = await import_orca_file(file.filename, content, db)\n    return ImportResponse(**result)\n\n\n@router.post(\"/\", response_model=LocalPresetResponse)\nasync def create_local_preset(\n    data: LocalPresetCreate,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Manually create a local preset.\"\"\"\n    if data.preset_type not in (\"filament\", \"printer\", \"process\"):\n        raise HTTPException(400, \"preset_type must be filament, printer, or process\")\n\n    # Extract core fields\n    core = extract_core_fields(data.setting)\n\n    preset = LocalPreset(\n        name=data.name,\n        preset_type=data.preset_type,\n        source=\"manual\",\n        setting=json.dumps(data.setting),\n        **core,\n    )\n    db.add(preset)\n    await db.flush()\n    await db.refresh(preset)\n    return LocalPresetResponse.model_validate(preset)\n\n\n@router.put(\"/{preset_id}\", response_model=LocalPresetResponse)\nasync def update_local_preset(\n    preset_id: int,\n    data: LocalPresetUpdate,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Update a local preset's name or settings.\"\"\"\n    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))\n    preset = result.scalar_one_or_none()\n    if not preset:\n        raise HTTPException(404, \"Local preset not found\")\n\n    if data.name is not None:\n        preset.name = data.name\n\n    if data.setting is not None:\n        # Re-resolve and extract core fields\n        resolved = await resolve_preset(data.setting, preset.preset_type, db)\n        core = extract_core_fields(resolved)\n        preset.setting = json.dumps(resolved)\n        preset.filament_type = core.get(\"filament_type\")\n        preset.filament_vendor = core.get(\"filament_vendor\")\n        preset.nozzle_temp_min = core.get(\"nozzle_temp_min\")\n        preset.nozzle_temp_max = core.get(\"nozzle_temp_max\")\n        preset.pressure_advance = core.get(\"pressure_advance\")\n        preset.default_filament_colour = core.get(\"default_filament_colour\")\n        preset.filament_cost = core.get(\"filament_cost\")\n        preset.filament_density = core.get(\"filament_density\")\n        preset.compatible_printers = core.get(\"compatible_printers\")\n\n    await db.flush()\n    await db.refresh(preset)\n    return LocalPresetResponse.model_validate(preset)\n\n\n@router.delete(\"/{preset_id}\")\nasync def delete_local_preset(\n    preset_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Delete a local preset.\"\"\"\n    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))\n    preset = result.scalar_one_or_none()\n    if not preset:\n        raise HTTPException(404, \"Local preset not found\")\n\n    await db.delete(preset)\n    return {\"success\": True}\n\n\n@router.get(\"/base-cache/status\")\nasync def base_cache_status(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get the status of the OrcaSlicer base profile cache.\"\"\"\n    return await get_cache_status(db)\n\n\n@router.post(\"/base-cache/refresh\")\nasync def refresh_cache(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Force refresh all cached base profiles from GitHub.\"\"\"\n    return await refresh_base_cache(db)\n\n\n@router.post(\"/reclassify\")\nasync def reclassify(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Re-evaluate preset types for all local presets using the improved heuristic.\"\"\"\n    return await reclassify_presets(db)\n"
  },
  {
    "path": "backend/app/api/routes/maintenance.py",
    "content": "\"\"\"Maintenance tracking API routes.\"\"\"\n\nimport logging\nfrom datetime import datetime, timezone\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.user import User\nfrom backend.app.schemas.maintenance import (\n    MaintenanceHistoryResponse,\n    MaintenanceStatus,\n    MaintenanceTypeCreate,\n    MaintenanceTypeResponse,\n    MaintenanceTypeUpdate,\n    PerformMaintenanceRequest,\n    PrinterMaintenanceOverview,\n    PrinterMaintenanceResponse,\n    PrinterMaintenanceUpdate,\n)\nfrom backend.app.services.notification_service import notification_service\nfrom backend.app.utils.printer_models import get_rod_type\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/maintenance\", tags=[\"maintenance\"])\n\n# Default maintenance types\nDEFAULT_MAINTENANCE_TYPES = [\n    # Carbon rod models only (X1/P1)\n    # Note: carbon rods must NOT be lubricated — they use plain bearings\n    # and lubrication degrades print quality. Only cleaning is offered.\n    {\n        \"name\": \"Clean Carbon Rods\",\n        \"description\": \"Wipe carbon rods with a dry cloth\",\n        \"default_interval_hours\": 100.0,\n        \"icon\": \"Sparkles\",\n    },\n    # Steel rod models only (P2S)\n    {\n        \"name\": \"Lubricate Steel Rods\",\n        \"description\": \"Apply lubricant to steel rods for smooth motion\",\n        \"default_interval_hours\": 50.0,\n        \"icon\": \"Droplet\",\n    },\n    {\n        \"name\": \"Clean Steel Rods\",\n        \"description\": \"Wipe steel rods with a dry cloth\",\n        \"default_interval_hours\": 100.0,\n        \"icon\": \"Sparkles\",\n    },\n    # Linear rail models only (A1/H2)\n    {\n        \"name\": \"Lubricate Linear Rails\",\n        \"description\": \"Apply lubricant to linear rails for smooth motion\",\n        \"default_interval_hours\": 50.0,\n        \"icon\": \"Droplet\",\n    },\n    {\n        \"name\": \"Clean Linear Rails\",\n        \"description\": \"Wipe linear rails with a dry cloth to remove dust and debris\",\n        \"default_interval_hours\": 100.0,\n        \"icon\": \"Sparkles\",\n    },\n    # Universal (all models)\n    {\n        \"name\": \"Clean Nozzle/Hotend\",\n        \"description\": \"Clean nozzle exterior and perform cold pull if needed\",\n        \"default_interval_hours\": 100.0,\n        \"icon\": \"Flame\",\n    },\n    {\n        \"name\": \"Check Belt Tension\",\n        \"description\": \"Verify and adjust belt tension for X/Y axes\",\n        \"default_interval_hours\": 200.0,\n        \"icon\": \"Ruler\",\n    },\n    {\n        \"name\": \"Clean Build Plate\",\n        \"description\": \"Deep clean build plate with IPA or soap\",\n        \"default_interval_hours\": 25.0,\n        \"icon\": \"Square\",\n    },\n    {\n        \"name\": \"Check PTFE Tube\",\n        \"description\": \"Inspect PTFE tube for wear or discoloration\",\n        \"default_interval_hours\": 500.0,\n        \"icon\": \"Cable\",\n    },\n]\n\n# System types that only apply to printers with a specific rod/rail type.\n# \"carbon\" = X1/P1 series (carbon rods), \"steel_rod\" = P2S (steel rods),\n# \"linear_rail\" = A1/H2 series. Types not listed here apply to all printers.\n_ROD_TYPE_REQUIREMENTS: dict[str, str] = {\n    \"Clean Carbon Rods\": \"carbon\",\n    \"Lubricate Steel Rods\": \"steel_rod\",\n    \"Clean Steel Rods\": \"steel_rod\",\n    \"Lubricate Linear Rails\": \"linear_rail\",\n    \"Clean Linear Rails\": \"linear_rail\",\n}\n\n\ndef _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool:\n    \"\"\"Check if a system maintenance type should apply to a given printer model.\"\"\"\n    rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)\n    if rod_requirement is None:\n        return True  # Not model-specific, applies to all\n\n    rod_type = get_rod_type(printer_model)\n    if rod_type is None:\n        # Unknown model — default to carbon rods (legacy behavior)\n        return rod_requirement == \"carbon\"\n\n    return rod_type == rod_requirement\n\n\nasync def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:\n    \"\"\"Calculate total active hours for a printer from runtime counter plus offset.\n\n    Uses the runtime_seconds counter which tracks actual machine active time\n    (RUNNING and PAUSE states), including calibration, heating, and printing.\n    \"\"\"\n    # Get printer runtime and offset\n    result = await db.execute(\n        select(Printer.runtime_seconds, Printer.print_hours_offset).where(Printer.id == printer_id)\n    )\n    row = result.one_or_none()\n    if not row:\n        return 0.0\n\n    runtime_seconds = row[0] or 0\n    offset = row[1] or 0.0\n\n    runtime_hours = runtime_seconds / 3600.0\n    return runtime_hours + offset\n\n\nasync def ensure_default_types(db: AsyncSession) -> None:\n    \"\"\"Ensure default maintenance types exist, remove stale/duplicate ones.\"\"\"\n    result = await db.execute(\n        select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).order_by(MaintenanceType.id)\n    )\n    existing = result.scalars().all()\n\n    default_names = {t[\"name\"] for t in DEFAULT_MAINTENANCE_TYPES}\n\n    # Remove stale system types no longer in defaults (e.g. renamed types)\n    # and deduplicate: if concurrent requests created the same type twice,\n    # keep only the first (lowest id) and delete the rest.\n    seen_names: set[str] = set()\n    for t in existing:\n        if t.name not in default_names or t.name in seen_names:\n            await db.delete(t)\n        else:\n            seen_names.add(t.name)\n\n    # Create any missing default types\n    for type_def in DEFAULT_MAINTENANCE_TYPES:\n        if type_def[\"name\"] not in seen_names:\n            new_type = MaintenanceType(\n                name=type_def[\"name\"],\n                description=type_def[\"description\"],\n                default_interval_hours=type_def[\"default_interval_hours\"],\n                icon=type_def[\"icon\"],\n                is_system=True,\n            )\n            db.add(new_type)\n\n    await db.commit()\n\n\n# ============== Maintenance Types ==============\n\n\n@router.get(\"/types\", response_model=list[MaintenanceTypeResponse])\nasync def get_maintenance_types(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),\n):\n    \"\"\"Get all maintenance types.\"\"\"\n    await ensure_default_types(db)\n    result = await db.execute(\n        select(MaintenanceType)\n        .where(MaintenanceType.is_deleted.is_(False))\n        .order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)\n    )\n    return result.scalars().all()\n\n\n@router.post(\"/types\", response_model=MaintenanceTypeResponse)\nasync def create_maintenance_type(\n    data: MaintenanceTypeCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),\n):\n    \"\"\"Create a custom maintenance type.\"\"\"\n    new_type = MaintenanceType(\n        name=data.name,\n        description=data.description,\n        default_interval_hours=data.default_interval_hours,\n        interval_type=data.interval_type,\n        icon=data.icon,\n        is_system=False,\n    )\n    db.add(new_type)\n    await db.commit()\n    await db.refresh(new_type)\n    return new_type\n\n\n@router.patch(\"/types/{type_id}\", response_model=MaintenanceTypeResponse)\nasync def update_maintenance_type(\n    type_id: int,\n    data: MaintenanceTypeUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),\n):\n    \"\"\"Update a maintenance type.\"\"\"\n    result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))\n    maint_type = result.scalar_one_or_none()\n    if not maint_type:\n        raise HTTPException(status_code=404, detail=\"Maintenance type not found\")\n\n    update_data = data.model_dump(exclude_unset=True)\n    for key, value in update_data.items():\n        setattr(maint_type, key, value)\n\n    await db.commit()\n    await db.refresh(maint_type)\n    return maint_type\n\n\n@router.delete(\"/types/{type_id}\")\nasync def delete_maintenance_type(\n    type_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),\n):\n    \"\"\"Delete a maintenance type.\"\"\"\n    result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))\n    maint_type = result.scalar_one_or_none()\n    if not maint_type:\n        raise HTTPException(status_code=404, detail=\"Maintenance type not found\")\n\n    if maint_type.is_system:\n        maint_type.is_deleted = True\n        await db.commit()\n        return {\"status\": \"deleted\"}\n\n    await db.delete(maint_type)\n    await db.commit()\n    return {\"status\": \"deleted\"}\n\n\n@router.post(\"/types/restore-defaults\")\nasync def restore_default_maintenance_types(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),\n):\n    \"\"\"Restore deleted default maintenance types.\"\"\"\n    await ensure_default_types(db)\n    result = await db.execute(\n        select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).where(MaintenanceType.is_deleted.is_(True))\n    )\n    deleted_types = result.scalars().all()\n    for maint_type in deleted_types:\n        maint_type.is_deleted = False\n\n    await db.commit()\n    return {\"restored\": len(deleted_types)}\n\n\n# ============== Printer Maintenance ==============\n\n\nasync def _get_printer_maintenance_internal(\n    printer_id: int,\n    db: AsyncSession,\n    commit: bool = True,\n) -> PrinterMaintenanceOverview:\n    \"\"\"Internal helper to get maintenance overview for a specific printer.\"\"\"\n    await ensure_default_types(db)\n\n    # Get printer\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    total_hours = await get_printer_total_hours(db, printer_id)\n\n    # Get all maintenance types\n    result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_deleted.is_(False)))\n    all_types = result.scalars().all()\n\n    # Get printer's maintenance items\n    result = await db.execute(\n        select(PrinterMaintenance)\n        .where(PrinterMaintenance.printer_id == printer_id)\n        .options(selectinload(PrinterMaintenance.maintenance_type))\n    )\n    existing_items = {item.maintenance_type_id: item for item in result.scalars().all()}\n\n    maintenance_items = []\n    due_count = 0\n    warning_count = 0\n\n    now = datetime.now(timezone.utc)\n\n    for maint_type in all_types:\n        # Skip system types that don't apply to this printer model\n        # (e.g., \"Clean Carbon Rods\" for H2D which has steel rods)\n        if maint_type.is_system and not _should_apply_to_printer(maint_type.name, printer.model):\n            continue\n\n        item = existing_items.get(maint_type.id)\n        default_interval_type = getattr(maint_type, \"interval_type\", \"hours\") or \"hours\"\n\n        if item:\n            interval = item.custom_interval_hours or maint_type.default_interval_hours\n            # Use custom interval type if set, otherwise use type's default\n            interval_type = getattr(item, \"custom_interval_type\", None) or default_interval_type\n            enabled = item.enabled\n            last_performed_hours = item.last_performed_hours\n            last_performed_at = item.last_performed_at\n            item_id = item.id\n        else:\n            # Only auto-create maintenance items for system types\n            # Custom types need to be manually assigned per printer\n            if not maint_type.is_system:\n                continue\n\n            # Create default entry for this printer/type\n            item = PrinterMaintenance(\n                printer_id=printer_id,\n                maintenance_type_id=maint_type.id,\n                enabled=True,\n                last_performed_hours=0.0,\n            )\n            db.add(item)\n            await db.flush()\n\n            interval = maint_type.default_interval_hours\n            interval_type = default_interval_type\n            enabled = True\n            last_performed_hours = 0.0\n            last_performed_at = None\n            item_id = item.id\n\n        # Calculate status based on interval type\n        if interval_type == \"days\":\n            # Time-based: calculate days since last performed\n            if last_performed_at:\n                # DB stores naive datetimes; treat as UTC for comparison\n                if last_performed_at.tzinfo is None:\n                    last_performed_at = last_performed_at.replace(tzinfo=timezone.utc)\n                days_since = (now - last_performed_at).total_seconds() / 86400.0\n            else:\n                # Never performed - consider it due\n                days_since = interval + 1\n\n            days_until = interval - days_since\n            is_due = days_until <= 0\n            is_warning = days_until <= (interval * 0.1) and not is_due\n\n            # For compatibility, also set hours values (but they won't be primary)\n            hours_since = total_hours - last_performed_hours\n            hours_until = 0  # Not applicable for time-based\n        else:\n            # Print-hours based (default)\n            hours_since = total_hours - last_performed_hours\n            hours_until = interval - hours_since\n            is_due = hours_until <= 0\n            is_warning = hours_until <= (interval * 0.1) and not is_due\n\n            # Calculate days for reference\n            if last_performed_at:\n                if last_performed_at.tzinfo is None:\n                    last_performed_at = last_performed_at.replace(tzinfo=timezone.utc)\n                days_since = (now - last_performed_at).total_seconds() / 86400.0\n            else:\n                days_since = None\n            days_until = None\n\n        if enabled:\n            if is_due:\n                due_count += 1\n            elif is_warning:\n                warning_count += 1\n\n        maintenance_items.append(\n            MaintenanceStatus(\n                id=item_id,\n                printer_id=printer_id,\n                printer_name=printer.name,\n                printer_model=printer.model,\n                maintenance_type_id=maint_type.id,\n                maintenance_type_name=maint_type.name,\n                maintenance_type_icon=maint_type.icon,\n                maintenance_type_wiki_url=getattr(maint_type, \"wiki_url\", None),\n                enabled=enabled,\n                interval_hours=interval,\n                interval_type=interval_type,\n                current_hours=total_hours,\n                hours_since_maintenance=hours_since,\n                hours_until_due=hours_until,\n                days_since_maintenance=days_since if interval_type == \"days\" else None,\n                days_until_due=days_until if interval_type == \"days\" else None,\n                is_due=is_due,\n                is_warning=is_warning,\n                last_performed_at=last_performed_at,\n            )\n        )\n\n    if commit:\n        await db.commit()\n\n    return PrinterMaintenanceOverview(\n        printer_id=printer_id,\n        printer_name=printer.name,\n        printer_model=printer.model,\n        total_print_hours=total_hours,\n        maintenance_items=maintenance_items,\n        due_count=due_count,\n        warning_count=warning_count,\n    )\n\n\n@router.get(\"/printers/{printer_id}\", response_model=PrinterMaintenanceOverview)\nasync def get_printer_maintenance(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),\n):\n    \"\"\"Get maintenance overview for a specific printer.\"\"\"\n    return await _get_printer_maintenance_internal(printer_id, db, commit=True)\n\n\n@router.get(\"/overview\", response_model=list[PrinterMaintenanceOverview])\nasync def get_all_maintenance_overview(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),\n):\n    \"\"\"Get maintenance overview for all active printers.\"\"\"\n    await ensure_default_types(db)\n\n    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))\n    printers = result.scalars().all()\n\n    overviews = []\n    for printer in printers:\n        # Don't commit after each printer, commit once at the end\n        overview = await _get_printer_maintenance_internal(printer.id, db, commit=False)\n        overviews.append(overview)\n\n    # Commit any new maintenance items created\n    await db.commit()\n\n    return overviews\n\n\n@router.patch(\"/items/{item_id}\", response_model=PrinterMaintenanceResponse)\nasync def update_printer_maintenance(\n    item_id: int,\n    data: PrinterMaintenanceUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),\n):\n    \"\"\"Update a printer maintenance item (e.g., custom interval, enabled).\"\"\"\n    result = await db.execute(\n        select(PrinterMaintenance)\n        .where(PrinterMaintenance.id == item_id)\n        .options(selectinload(PrinterMaintenance.maintenance_type))\n    )\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(status_code=404, detail=\"Maintenance item not found\")\n\n    update_data = data.model_dump(exclude_unset=True)\n    for key, value in update_data.items():\n        setattr(item, key, value)\n\n    await db.commit()\n    await db.refresh(item)\n    return item\n\n\n@router.post(\"/printers/{printer_id}/assign/{type_id}\", response_model=PrinterMaintenanceResponse)\nasync def assign_maintenance_type(\n    printer_id: int,\n    type_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),\n):\n    \"\"\"Assign a maintenance type to a specific printer (for custom types).\"\"\"\n    # Verify printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    # Verify maintenance type exists\n    result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))\n    maint_type = result.scalar_one_or_none()\n    if not maint_type:\n        raise HTTPException(status_code=404, detail=\"Maintenance type not found\")\n\n    # Check if already assigned\n    result = await db.execute(\n        select(PrinterMaintenance).where(\n            PrinterMaintenance.printer_id == printer_id,\n            PrinterMaintenance.maintenance_type_id == type_id,\n        )\n    )\n    existing = result.scalar_one_or_none()\n    if existing:\n        raise HTTPException(status_code=400, detail=\"Maintenance type already assigned to this printer\")\n\n    # Create the assignment\n    item = PrinterMaintenance(\n        printer_id=printer_id,\n        maintenance_type_id=type_id,\n        enabled=True,\n        last_performed_hours=0.0,\n    )\n    db.add(item)\n    await db.commit()\n\n    # Re-fetch with relationship loaded for response serialization\n    from sqlalchemy.orm import selectinload\n\n    result = await db.execute(\n        select(PrinterMaintenance)\n        .options(selectinload(PrinterMaintenance.maintenance_type))\n        .where(PrinterMaintenance.id == item.id)\n    )\n    item = result.scalar_one()\n\n    return item\n\n\n@router.delete(\"/items/{item_id}\")\nasync def remove_maintenance_item(\n    item_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),\n):\n    \"\"\"Remove a maintenance item (unassign a custom type from a printer).\"\"\"\n    result = await db.execute(\n        select(PrinterMaintenance)\n        .where(PrinterMaintenance.id == item_id)\n        .options(selectinload(PrinterMaintenance.maintenance_type))\n    )\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(status_code=404, detail=\"Maintenance item not found\")\n\n    # Only allow removing custom (non-system) types\n    if item.maintenance_type.is_system:\n        raise HTTPException(status_code=400, detail=\"Cannot remove system maintenance types\")\n\n    await db.delete(item)\n    await db.commit()\n\n    return {\"status\": \"removed\"}\n\n\n@router.post(\"/items/{item_id}/perform\", response_model=MaintenanceStatus)\nasync def perform_maintenance(\n    item_id: int,\n    data: PerformMaintenanceRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),\n):\n    \"\"\"Mark maintenance as performed (reset the counter).\"\"\"\n    result = await db.execute(\n        select(PrinterMaintenance)\n        .where(PrinterMaintenance.id == item_id)\n        .options(selectinload(PrinterMaintenance.maintenance_type))\n    )\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(status_code=404, detail=\"Maintenance item not found\")\n\n    # Get printer for name\n    result = await db.execute(select(Printer).where(Printer.id == item.printer_id))\n    printer = result.scalar_one()\n\n    # Get current hours\n    current_hours = await get_printer_total_hours(db, item.printer_id)\n\n    # Create history entry\n    history = MaintenanceHistory(\n        printer_maintenance_id=item.id,\n        hours_at_maintenance=current_hours,\n        notes=data.notes,\n    )\n    db.add(history)\n\n    # Update item\n    item.last_performed_at = datetime.now(timezone.utc)\n    item.last_performed_hours = current_hours\n\n    await db.commit()\n\n    # MQTT relay - publish maintenance reset\n    try:\n        from backend.app.services.mqtt_relay import mqtt_relay\n\n        await mqtt_relay.on_maintenance_reset(\n            printer_id=item.printer_id,\n            printer_name=printer.name,\n            maintenance_type=item.maintenance_type.name,\n        )\n    except Exception:\n        pass  # Don't fail if MQTT fails\n\n    # Calculate status\n    interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours\n    interval_type = getattr(item.maintenance_type, \"interval_type\", \"hours\") or \"hours\"\n    hours_since = current_hours - item.last_performed_hours\n    hours_until = interval - hours_since\n\n    return MaintenanceStatus(\n        id=item.id,\n        printer_id=item.printer_id,\n        printer_name=printer.name,\n        printer_model=printer.model,\n        maintenance_type_id=item.maintenance_type_id,\n        maintenance_type_name=item.maintenance_type.name,\n        maintenance_type_icon=item.maintenance_type.icon,\n        maintenance_type_wiki_url=getattr(item.maintenance_type, \"wiki_url\", None),\n        enabled=item.enabled,\n        interval_hours=interval,\n        interval_type=interval_type,\n        current_hours=current_hours,\n        hours_since_maintenance=hours_since,\n        hours_until_due=hours_until if interval_type == \"hours\" else 0,\n        days_since_maintenance=0 if interval_type == \"days\" else None,\n        days_until_due=interval if interval_type == \"days\" else None,\n        is_due=False,\n        is_warning=False,\n        last_performed_at=item.last_performed_at,\n    )\n\n\n@router.get(\"/items/{item_id}/history\", response_model=list[MaintenanceHistoryResponse])\nasync def get_maintenance_history(\n    item_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),\n):\n    \"\"\"Get maintenance history for a specific item.\"\"\"\n    result = await db.execute(\n        select(MaintenanceHistory)\n        .where(MaintenanceHistory.printer_maintenance_id == item_id)\n        .order_by(MaintenanceHistory.performed_at.desc())\n    )\n    return result.scalars().all()\n\n\n@router.get(\"/summary\")\nasync def get_maintenance_summary(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),\n):\n    \"\"\"Get a summary of maintenance status across all printers.\"\"\"\n    await ensure_default_types(db)\n\n    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))\n    printers = result.scalars().all()\n\n    total_due = 0\n    total_warning = 0\n    printers_with_issues = []\n\n    for printer in printers:\n        overview = await get_printer_maintenance(printer.id, db)\n        total_due += overview.due_count\n        total_warning += overview.warning_count\n        if overview.due_count > 0 or overview.warning_count > 0:\n            printers_with_issues.append(\n                {\n                    \"printer_id\": printer.id,\n                    \"printer_name\": printer.name,\n                    \"due_count\": overview.due_count,\n                    \"warning_count\": overview.warning_count,\n                }\n            )\n\n    return {\n        \"total_due\": total_due,\n        \"total_warning\": total_warning,\n        \"printers_with_issues\": printers_with_issues,\n    }\n\n\n@router.patch(\"/printers/{printer_id}/hours\")\nasync def set_printer_hours(\n    printer_id: int,\n    total_hours: float,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),\n):\n    \"\"\"Set the total print hours for a printer (adjusts offset to match).\n\n    The offset is calculated as: offset = total_hours - runtime_hours\n    Where runtime_hours comes from the runtime_seconds counter that tracks\n    actual machine active time (RUNNING/PAUSE states).\n    \"\"\"\n    # Get printer\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    # Get current runtime hours\n    runtime_hours = (printer.runtime_seconds or 0) / 3600.0\n\n    # Calculate needed offset\n    printer.print_hours_offset = max(0, total_hours - runtime_hours)\n\n    await db.commit()\n\n    # Check for maintenance items that need attention and send notification\n    try:\n        await ensure_default_types(db)\n        overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)\n\n        items_needing_attention = [\n            {\n                \"name\": item.maintenance_type_name,\n                \"is_due\": item.is_due,\n                \"is_warning\": item.is_warning,\n            }\n            for item in overview.maintenance_items\n            if item.enabled and (item.is_due or item.is_warning)\n        ]\n\n        if items_needing_attention:\n            await notification_service.on_maintenance_due(printer_id, printer.name, items_needing_attention, db)\n            logger.info(\n                f\"Sent maintenance notification for printer {printer_id}: \"\n                f\"{len(items_needing_attention)} items need attention\"\n            )\n    except Exception as e:\n        logger.warning(\"Failed to send maintenance notification: %s\", e)\n\n    return {\n        \"printer_id\": printer_id,\n        \"total_hours\": total_hours,\n        \"runtime_hours\": runtime_hours,\n        \"offset_hours\": printer.print_hours_offset,\n    }\n"
  },
  {
    "path": "backend/app/api/routes/metrics.py",
    "content": "\"\"\"Prometheus metrics endpoint for external monitoring.\"\"\"\n\nimport platform\n\nfrom fastapi import APIRouter, Depends, Header, HTTPException, Response\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.config import APP_VERSION\nfrom backend.app.core.database import get_db\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.settings import Settings\nfrom backend.app.services.printer_manager import printer_manager, supports_chamber_temp\n\nrouter = APIRouter(tags=[\"metrics\"])\n\n\nasync def get_prometheus_settings(db: AsyncSession) -> tuple[bool, str]:\n    \"\"\"Get Prometheus settings from database.\"\"\"\n    result = await db.execute(select(Settings).where(Settings.key.in_([\"prometheus_enabled\", \"prometheus_token\"])))\n    settings_dict = {s.key: s.value for s in result.scalars().all()}\n\n    enabled = settings_dict.get(\"prometheus_enabled\", \"false\").lower() == \"true\"\n    token = settings_dict.get(\"prometheus_token\", \"\")\n    return enabled, token\n\n\ndef format_labels(**labels: str) -> str:\n    \"\"\"Format label key-value pairs for Prometheus.\"\"\"\n    if not labels:\n        return \"\"\n    pairs = [f'{k}=\"{v}\"' for k, v in labels.items() if v is not None]\n    return \"{\" + \",\".join(pairs) + \"}\"\n\n\ndef state_to_numeric(state: str) -> int:\n    \"\"\"Convert printer state string to numeric value.\"\"\"\n    state_map = {\n        \"unknown\": 0,\n        \"IDLE\": 1,\n        \"RUNNING\": 2,\n        \"PAUSE\": 3,\n        \"FINISH\": 4,\n        \"FAILED\": 5,\n        \"PREPARE\": 6,\n        \"SLICING\": 7,\n    }\n    return state_map.get(state, 0)\n\n\n@router.get(\"/metrics\", response_class=Response)\nasync def get_metrics(\n    db: AsyncSession = Depends(get_db),\n    authorization: str | None = Header(None),\n):\n    \"\"\"\n    Prometheus metrics endpoint.\n\n    Returns metrics in Prometheus text exposition format.\n    Requires prometheus_enabled setting to be true.\n    If prometheus_token is set, requires Bearer token authentication.\n    \"\"\"\n    # Check if enabled\n    enabled, token = await get_prometheus_settings(db)\n\n    if not enabled:\n        raise HTTPException(status_code=404, detail=\"Prometheus metrics not enabled\")\n\n    # Check authentication if token is set\n    if token:\n        if not authorization:\n            raise HTTPException(status_code=401, detail=\"Authorization required\")\n        if not authorization.startswith(\"Bearer \"):\n            raise HTTPException(status_code=401, detail=\"Bearer token required\")\n        provided_token = authorization[7:]  # Remove \"Bearer \" prefix\n        if provided_token != token:\n            raise HTTPException(status_code=401, detail=\"Invalid token\")\n\n    lines: list[str] = []\n\n    # =========================================================================\n    # Build info\n    # =========================================================================\n\n    lines.append(\"# HELP bambuddy_build_info Build and version information\")\n    lines.append(\"# TYPE bambuddy_build_info gauge\")\n    build_labels = format_labels(\n        version=APP_VERSION,\n        python_version=platform.python_version(),\n        platform=platform.system(),\n        architecture=platform.machine(),\n    )\n    lines.append(f\"bambuddy_build_info{build_labels} 1\")\n\n    # =========================================================================\n    # Printer metrics\n    # =========================================================================\n\n    # Get all printers from DB\n    result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712\n    printers = list(result.scalars().all())\n\n    # Build lookup for printer info\n    printer_info = {p.id: p for p in printers}\n\n    # Get all connected printer statuses\n    all_statuses = printer_manager.get_all_statuses()\n\n    # Printer connection status\n    lines.append(\"# HELP bambuddy_printer_connected Printer connection status (1=connected, 0=disconnected)\")\n    lines.append(\"# TYPE bambuddy_printer_connected gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        connected = 1 if status and status.connected else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n            model=printer.model or \"unknown\",\n        )\n        lines.append(f\"bambuddy_printer_connected{labels} {connected}\")\n\n    # Printer state\n    lines.append(\"\")\n    lines.append(\n        \"# HELP bambuddy_printer_state Printer state (0=unknown, 1=idle, 2=running, 3=pause, 4=finish, 5=failed, 6=prepare, 7=slicing)\"\n    )\n    lines.append(\"# TYPE bambuddy_printer_state gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        state_val = state_to_numeric(status.state) if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n        )\n        lines.append(f\"bambuddy_printer_state{labels} {state_val}\")\n\n    # Print progress\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_print_progress Current print progress (0-100)\")\n    lines.append(\"# TYPE bambuddy_print_progress gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        progress = status.progress if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n        )\n        lines.append(f\"bambuddy_print_progress{labels} {progress:.1f}\")\n\n    # Remaining time\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_print_remaining_seconds Estimated remaining print time in seconds\")\n    lines.append(\"# TYPE bambuddy_print_remaining_seconds gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        remaining = status.remaining_time * 60 if status else 0  # Convert minutes to seconds\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n        )\n        lines.append(f\"bambuddy_print_remaining_seconds{labels} {remaining}\")\n\n    # Layer progress\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_print_layer_current Current layer number\")\n    lines.append(\"# TYPE bambuddy_print_layer_current gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        layer = status.layer_num if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n        )\n        lines.append(f\"bambuddy_print_layer_current{labels} {layer}\")\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_print_layer_total Total layers in current print\")\n    lines.append(\"# TYPE bambuddy_print_layer_total gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        total = status.total_layers if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n        )\n        lines.append(f\"bambuddy_print_layer_total{labels} {total}\")\n\n    # =========================================================================\n    # Temperature metrics\n    # =========================================================================\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_bed_temp_celsius Current bed temperature\")\n    lines.append(\"# TYPE bambuddy_bed_temp_celsius gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        temp = status.temperatures.get(\"bed\", 0) if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n        )\n        lines.append(f\"bambuddy_bed_temp_celsius{labels} {temp:.1f}\")\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_bed_target_celsius Target bed temperature\")\n    lines.append(\"# TYPE bambuddy_bed_target_celsius gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        temp = status.temperatures.get(\"bed_target\", 0) if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n        )\n        lines.append(f\"bambuddy_bed_target_celsius{labels} {temp:.1f}\")\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_nozzle_temp_celsius Current nozzle temperature\")\n    lines.append(\"# TYPE bambuddy_nozzle_temp_celsius gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        # Primary nozzle\n        temp = status.temperatures.get(\"nozzle\", 0) if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n            nozzle=\"0\",\n        )\n        lines.append(f\"bambuddy_nozzle_temp_celsius{labels} {temp:.1f}\")\n        # Second nozzle if present\n        if status and \"nozzle_2\" in status.temperatures:\n            temp2 = status.temperatures.get(\"nozzle_2\", 0)\n            labels2 = format_labels(\n                printer_id=str(printer.id),\n                printer_name=printer.name,\n                serial=printer.serial_number,\n                nozzle=\"1\",\n            )\n            lines.append(f\"bambuddy_nozzle_temp_celsius{labels2} {temp2:.1f}\")\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_nozzle_target_celsius Target nozzle temperature\")\n    lines.append(\"# TYPE bambuddy_nozzle_target_celsius gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        temp = status.temperatures.get(\"nozzle_target\", 0) if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n            nozzle=\"0\",\n        )\n        lines.append(f\"bambuddy_nozzle_target_celsius{labels} {temp:.1f}\")\n        if status and \"nozzle_2_target\" in status.temperatures:\n            temp2 = status.temperatures.get(\"nozzle_2_target\", 0)\n            labels2 = format_labels(\n                printer_id=str(printer.id),\n                printer_name=printer.name,\n                serial=printer.serial_number,\n                nozzle=\"1\",\n            )\n            lines.append(f\"bambuddy_nozzle_target_celsius{labels2} {temp2:.1f}\")\n\n    lines.append(\"\")\n    lines.append(\n        \"# HELP bambuddy_chamber_temp_celsius Current chamber temperature (only for models with chamber sensor)\"\n    )\n    lines.append(\"# TYPE bambuddy_chamber_temp_celsius gauge\")\n    for printer in printers:\n        # Only report chamber temp for models that have a real sensor\n        if not supports_chamber_temp(printer.model):\n            continue\n        status = all_statuses.get(printer.id)\n        temp = status.temperatures.get(\"chamber\", 0) if status else 0\n        labels = format_labels(\n            printer_id=str(printer.id),\n            printer_name=printer.name,\n            serial=printer.serial_number,\n        )\n        lines.append(f\"bambuddy_chamber_temp_celsius{labels} {temp:.1f}\")\n\n    # =========================================================================\n    # Fan speeds\n    # =========================================================================\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_fan_speed_percent Fan speed percentage\")\n    lines.append(\"# TYPE bambuddy_fan_speed_percent gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        if not status:\n            continue\n        # Part cooling fan\n        if \"part_fan\" in status.temperatures:\n            val = status.temperatures[\"part_fan\"]\n            labels = format_labels(\n                printer_id=str(printer.id),\n                printer_name=printer.name,\n                serial=printer.serial_number,\n                fan=\"part\",\n            )\n            lines.append(f\"bambuddy_fan_speed_percent{labels} {val:.1f}\")\n        # Aux fan\n        if \"aux_fan\" in status.temperatures:\n            val = status.temperatures[\"aux_fan\"]\n            labels = format_labels(\n                printer_id=str(printer.id),\n                printer_name=printer.name,\n                serial=printer.serial_number,\n                fan=\"aux\",\n            )\n            lines.append(f\"bambuddy_fan_speed_percent{labels} {val:.1f}\")\n        # Chamber fan\n        if \"chamber_fan\" in status.temperatures:\n            val = status.temperatures[\"chamber_fan\"]\n            labels = format_labels(\n                printer_id=str(printer.id),\n                printer_name=printer.name,\n                serial=printer.serial_number,\n                fan=\"chamber\",\n            )\n            lines.append(f\"bambuddy_fan_speed_percent{labels} {val:.1f}\")\n\n    # =========================================================================\n    # WiFi signal\n    # =========================================================================\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_wifi_signal_dbm WiFi signal strength in dBm\")\n    lines.append(\"# TYPE bambuddy_wifi_signal_dbm gauge\")\n    for printer in printers:\n        status = all_statuses.get(printer.id)\n        if status and status.wifi_signal is not None:\n            labels = format_labels(\n                printer_id=str(printer.id),\n                printer_name=printer.name,\n                serial=printer.serial_number,\n            )\n            lines.append(f\"bambuddy_wifi_signal_dbm{labels} {status.wifi_signal}\")\n\n    # =========================================================================\n    # Print statistics (from database)\n    # =========================================================================\n\n    # Total prints by status\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_prints_total Total number of prints by result\")\n    lines.append(\"# TYPE bambuddy_prints_total counter\")\n    result = await db.execute(select(PrintArchive.status, func.count(PrintArchive.id)).group_by(PrintArchive.status))\n    for print_result, count in result.all():\n        result_label = print_result or \"unknown\"\n        labels = format_labels(result=result_label)\n        lines.append(f\"bambuddy_prints_total{labels} {count}\")\n\n    # Total prints per printer\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_printer_prints_total Total prints per printer\")\n    lines.append(\"# TYPE bambuddy_printer_prints_total counter\")\n    result = await db.execute(\n        select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)\n    )\n    for printer_id, count in result.all():\n        if printer_id and printer_id in printer_info:\n            p = printer_info[printer_id]\n            labels = format_labels(\n                printer_id=str(printer_id),\n                printer_name=p.name,\n                serial=p.serial_number,\n            )\n            lines.append(f\"bambuddy_printer_prints_total{labels} {count}\")\n\n    # Total filament used - filament_used_grams already contains the total for each print job\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_filament_used_grams Total filament used in grams\")\n    lines.append(\"# TYPE bambuddy_filament_used_grams counter\")\n    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))\n    total_filament = result.scalar() or 0\n    lines.append(f\"bambuddy_filament_used_grams {total_filament:.1f}\")\n\n    # Total print time\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_print_time_seconds Total print time in seconds\")\n    lines.append(\"# TYPE bambuddy_print_time_seconds counter\")\n    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.print_time_seconds), 0)))\n    total_time = result.scalar() or 0\n    lines.append(f\"bambuddy_print_time_seconds {total_time}\")\n\n    # =========================================================================\n    # Queue metrics\n    # =========================================================================\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_queue_pending Number of pending queue items\")\n    lines.append(\"# TYPE bambuddy_queue_pending gauge\")\n    result = await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == \"pending\"))\n    pending_count = result.scalar() or 0\n    lines.append(f\"bambuddy_queue_pending {pending_count}\")\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_queue_printing Number of currently printing queue items\")\n    lines.append(\"# TYPE bambuddy_queue_printing gauge\")\n    result = await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == \"printing\"))\n    printing_count = result.scalar() or 0\n    lines.append(f\"bambuddy_queue_printing {printing_count}\")\n\n    # =========================================================================\n    # System metrics\n    # =========================================================================\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_printers_connected Number of connected printers\")\n    lines.append(\"# TYPE bambuddy_printers_connected gauge\")\n    connected_count = sum(1 for s in all_statuses.values() if s.connected)\n    lines.append(f\"bambuddy_printers_connected {connected_count}\")\n\n    lines.append(\"\")\n    lines.append(\"# HELP bambuddy_printers_total Total number of configured printers\")\n    lines.append(\"# TYPE bambuddy_printers_total gauge\")\n    lines.append(f\"bambuddy_printers_total {len(printers)}\")\n\n    # Add trailing newline\n    lines.append(\"\")\n\n    content = \"\\n\".join(lines)\n    return Response(content=content, media_type=\"text/plain; version=0.0.4; charset=utf-8\")\n"
  },
  {
    "path": "backend/app/api/routes/mfa.py",
    "content": "\"\"\"2FA (TOTP + Email OTP) and OIDC authentication routes.\n\nSecurity model\n--------------\n* Pre-auth tokens  : secrets.token_urlsafe(32) stored in-memory with a 5-minute TTL.\n  They are single-use and do NOT grant access to any protected resource.\n* TOTP codes       : verified with pyotp (30-second window, ±1 step tolerance).\n* Email OTP codes  : 6-digit numeric, hashed with pbkdf2_sha256, 10-minute TTL,\n  max 5 failed attempts per code before invalidation.\n* Backup codes     : 10 × 8-char alphanumeric codes, each stored as pbkdf2_sha256 hash,\n  single-use.\n* OIDC state       : secrets.token_urlsafe(32) bound to provider_id + nonce, 10-minute TTL.\n* OIDC exchange    : secrets.token_urlsafe(32), 2-minute TTL, single-use.\n* Rate limiting    : max 5 failed 2FA verification attempts per user within 15 minutes.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\nimport hashlib\nimport io\nimport logging\nimport os\nimport re\nimport secrets\nimport string\nimport urllib.parse\nfrom datetime import datetime, timedelta, timezone\n\nimport httpx\nimport jwt\nimport pyotp\nfrom fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status\nfrom fastapi.responses import RedirectResponse\nfrom jwt import PyJWKClient\nfrom passlib.context import CryptContext\nfrom sqlalchemy import delete, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.api.routes.settings import get_setting, set_setting\nfrom backend.app.core.auth import (\n    ACCESS_TOKEN_EXPIRE_MINUTES,\n    RequirePermissionIfAuthEnabled,\n    create_access_token,\n    get_current_active_user,\n    get_user_by_email,\n    get_user_by_username,\n    is_auth_enabled,\n    verify_password,\n)\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType\nfrom backend.app.models.group import Group\nfrom backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink\nfrom backend.app.models.user import User\nfrom backend.app.models.user_otp_code import UserOTPCode\nfrom backend.app.models.user_totp import UserTOTP\nfrom backend.app.schemas.auth import (\n    AdminDisable2FARequest,\n    BackupCodesResponse,\n    EmailOTPDisableRequest,\n    EmailOTPEnableConfirmRequest,\n    EmailOTPSendRequest,\n    GroupBrief,\n    LoginResponse,\n    OIDCAuthorizeResponse,\n    OIDCExchangeRequest,\n    OIDCLinkResponse,\n    OIDCProviderCreate,\n    OIDCProviderResponse,\n    OIDCProviderUpdate,\n    TOTPDisableRequest,\n    TOTPEnableRequest,\n    TOTPEnableResponse,\n    TOTPSetupRequest,\n    TOTPSetupResponse,\n    TwoFAStatusResponse,\n    TwoFAVerifyRequest,\n    TwoFAVerifyResponse,\n    UserResponse,\n)\nfrom backend.app.services.email_service import get_smtp_settings, send_email\n\nlogger = logging.getLogger(__name__)\n\n\ndef _as_utc(dt: datetime) -> datetime:\n    \"\"\"Return *dt* with UTC timezone attached.\n\n    SQLite/aiosqlite strips timezone info when reading DateTime(timezone=True)\n    columns back – the stored value is always UTC, so we just re-attach the\n    info when doing Python-level comparisons.\n    \"\"\"\n    return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)\n\n\n# ---------------------------------------------------------------------------\n# Passlib context (same scheme as auth.py)\n# ---------------------------------------------------------------------------\npwd_context = CryptContext(schemes=[\"pbkdf2_sha256\"], deprecated=\"auto\")\n\n# ---------------------------------------------------------------------------\n# TTL / rate-limit constants\n# ---------------------------------------------------------------------------\nMAX_2FA_ATTEMPTS = 5\nMAX_LOGIN_ATTEMPTS = 10\nLOCKOUT_WINDOW = timedelta(minutes=15)\nMAX_EMAIL_OTP_SENDS = 3\nEMAIL_OTP_SEND_WINDOW = timedelta(minutes=10)\nPRE_AUTH_TOKEN_TTL = timedelta(minutes=5)\nOIDC_STATE_TTL = timedelta(minutes=10)\nOIDC_EXCHANGE_TTL = timedelta(minutes=2)\n\n# ---------------------------------------------------------------------------\n# Router\n# ---------------------------------------------------------------------------\nrouter = APIRouter(prefix=\"/auth\", tags=[\"2fa\", \"oidc\"])\n\n\n# ---------------------------------------------------------------------------\n# Helper: user response\n# ---------------------------------------------------------------------------\ndef _user_to_response(user: User) -> UserResponse:\n    return UserResponse(\n        id=user.id,\n        username=user.username,\n        email=user.email,\n        role=user.role,\n        is_active=user.is_active,\n        is_admin=user.is_admin,\n        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],\n        permissions=sorted(user.get_permissions()),\n        created_at=user.created_at.isoformat(),\n    )\n\n\n# ---------------------------------------------------------------------------\n# Helper: QR code generation\n# ---------------------------------------------------------------------------\ndef _generate_totp_qr_b64(provisioning_uri: str) -> str:\n    \"\"\"Generate a base64-encoded PNG QR code for the given TOTP provisioning URI.\"\"\"\n    import qrcode  # type: ignore\n\n    qr = qrcode.QRCode(box_size=6, border=2)\n    qr.add_data(provisioning_uri)\n    qr.make(fit=True)\n    img = qr.make_image(fill_color=\"black\", back_color=\"white\")\n    buf = io.BytesIO()\n    img.save(buf, format=\"PNG\")\n    return base64.b64encode(buf.getvalue()).decode()\n\n\n# ---------------------------------------------------------------------------\n# Helper: backup code generation\n# ---------------------------------------------------------------------------\ndef _generate_backup_codes() -> tuple[list[str], list[str]]:\n    \"\"\"Return (plain_codes, hashed_codes) — 10 codes of 8 alphanumeric chars each.\"\"\"\n    alphabet = string.ascii_uppercase + string.digits\n    plain = [\"\".join(secrets.choice(alphabet) for _ in range(8)) for _ in range(10)]\n    hashed = [pwd_context.hash(c) for c in plain]\n    return plain, hashed\n\n\n# ---------------------------------------------------------------------------\n# DB-backed pre-auth token helpers\n# ---------------------------------------------------------------------------\nasync def create_pre_auth_token(db: AsyncSession, username: str, challenge_id: str | None = None) -> str:\n    \"\"\"Create a single-use pre-auth token stored in the DB.\n\n    Pass ``challenge_id`` (from the HttpOnly 2fa_challenge cookie) to bind the\n    token to the originating browser session.  The same value must be present as\n    a cookie on every subsequent call that consumes this token.\n    \"\"\"\n    now = datetime.now(timezone.utc)\n    # Prune expired tokens opportunistically (keep table small)\n    await db.execute(\n        delete(AuthEphemeralToken).where(\n            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,\n            AuthEphemeralToken.expires_at < now,\n        )\n    )\n    token = secrets.token_urlsafe(32)\n    db.add(\n        AuthEphemeralToken(\n            token=token,\n            token_type=TokenType.PRE_AUTH,\n            username=username,\n            challenge_id=challenge_id,\n            expires_at=now + PRE_AUTH_TOKEN_TTL,\n        )\n    )\n    await db.commit()\n    return token\n\n\nasync def consume_pre_auth_token(db: AsyncSession, token: str, challenge_id: str | None = None) -> str | None:\n    \"\"\"Atomically validate and consume a pre-auth token. Returns username or None.\n\n    Uses DELETE...RETURNING so two concurrent requests with the same token cannot\n    both succeed — only the first DELETE finds the row.\n\n    M5: When challenge_id is provided, also enforces the cookie-binding constraint\n    so a stolen token cannot be replayed from a different browser session.\n    \"\"\"\n    now = datetime.now(timezone.utc)\n    result = await db.execute(\n        delete(AuthEphemeralToken)\n        .where(\n            AuthEphemeralToken.token == token,\n            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,\n            AuthEphemeralToken.expires_at > now,\n        )\n        .returning(AuthEphemeralToken.username, AuthEphemeralToken.challenge_id)\n    )\n    row = result.one_or_none()\n    if row is None:\n        return None\n    username, stored_challenge_id = row\n    # Enforce client binding: if the token was issued with a challenge_id,\n    # the caller must supply the matching value.\n    if stored_challenge_id is not None and stored_challenge_id != challenge_id:\n        await db.rollback()\n        return None\n    await db.commit()\n    return username\n\n\nasync def peek_pre_auth_token(db: AsyncSession, token: str, challenge_id: str | None = None) -> str | None:\n    \"\"\"Validate a pre-auth token and return the username WITHOUT consuming it.\n\n    When the stored token has a ``challenge_id`` (client-binding cookie), the\n    caller must supply the matching value.  A mismatch is treated as an invalid\n    token — no information leakage about whether the token itself exists.\n    \"\"\"\n    now = datetime.now(timezone.utc)\n    result = await db.execute(\n        select(AuthEphemeralToken).where(\n            AuthEphemeralToken.token == token,\n            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,\n            AuthEphemeralToken.expires_at > now,\n        )\n    )\n    eph = result.scalar_one_or_none()\n    if eph is None:\n        return None\n    # Enforce client binding: if the token was issued with a challenge_id the\n    # cookie must match.  Treat a mismatch as if the token doesn't exist.\n    if eph.challenge_id is not None and eph.challenge_id != challenge_id:\n        return None\n    return eph.username\n\n\n# ---------------------------------------------------------------------------\n# DB-backed rate-limiting helpers\n# ---------------------------------------------------------------------------\nasync def check_rate_limit(\n    db: AsyncSession,\n    username: str,\n    event_type: str = EventType.TWO_FA_ATTEMPT,\n    max_attempts: int = MAX_2FA_ATTEMPTS,\n) -> None:\n    \"\"\"Raise HTTP 429 if the user has exceeded the failed attempt limit.\n\n    The username is normalised to lower-case so case-variant attempts\n    (which all resolve to the same user) share the same rate-limit bucket.\n\n    L-2: Known TOCTOU — the SELECT (count) and the subsequent INSERT\n    (record_failed_attempt) are not atomic.  Two concurrent requests can both\n    read a count below the threshold and both proceed.  This is an inherent\n    trade-off of the event-log rate-limit pattern: fixing it would require\n    a serialising lock (SELECT FOR UPDATE on a dedicated counter row), which\n    adds contention and is not worth it for a soft rate-limit whose window is\n    already measured in minutes.  In practice the race window is microseconds\n    and the limit can be slightly exceeded only under precise concurrent timing.\n    \"\"\"\n    username_key = username.lower()\n    now = datetime.now(timezone.utc)\n    cutoff = now - LOCKOUT_WINDOW\n    result = await db.execute(\n        select(AuthRateLimitEvent).where(\n            AuthRateLimitEvent.username == username_key,\n            AuthRateLimitEvent.event_type == event_type,\n            AuthRateLimitEvent.occurred_at > cutoff,\n        )\n    )\n    recent_count = len(result.scalars().all())\n    if recent_count >= max_attempts:\n        raise HTTPException(\n            status_code=status.HTTP_429_TOO_MANY_REQUESTS,\n            detail=\"Too many failed attempts. Please try again later.\",\n        )\n\n\nasync def record_failed_attempt(db: AsyncSession, username: str, event_type: str = EventType.TWO_FA_ATTEMPT) -> None:\n    \"\"\"Record a failed attempt for rate-limiting purposes.\"\"\"\n    db.add(AuthRateLimitEvent(username=username.lower(), event_type=event_type))\n    await db.commit()\n\n\nasync def clear_failed_attempts(db: AsyncSession, username: str, event_type: str = EventType.TWO_FA_ATTEMPT) -> None:\n    \"\"\"Delete all recorded failed attempts for a user on successful verification.\"\"\"\n    await db.execute(\n        delete(AuthRateLimitEvent).where(\n            AuthRateLimitEvent.username == username.lower(),\n            AuthRateLimitEvent.event_type == event_type,\n        )\n    )\n    await db.commit()\n\n\nasync def check_email_otp_send_rate(db: AsyncSession, username: str) -> None:\n    \"\"\"Raise HTTP 429 if the user has requested too many OTP emails recently.\n\n    I1: This function only *checks* the limit.  The caller is responsible for\n    recording the slot via ``record_email_otp_send`` **after** the email has\n    been sent successfully.  This prevents failed sends from consuming a slot\n    (wasting the user's quota) and makes it impossible to farm rate-limit events\n    without actually triggering a send.\n    \"\"\"\n    username_key = username.lower()\n    now = datetime.now(timezone.utc)\n    cutoff = now - EMAIL_OTP_SEND_WINDOW\n    result = await db.execute(\n        select(AuthRateLimitEvent).where(\n            AuthRateLimitEvent.username == username_key,\n            AuthRateLimitEvent.event_type == EventType.EMAIL_SEND,\n            AuthRateLimitEvent.occurred_at > cutoff,\n        )\n    )\n    recent_count = len(result.scalars().all())\n    if recent_count >= MAX_EMAIL_OTP_SENDS:\n        raise HTTPException(\n            status_code=status.HTTP_429_TOO_MANY_REQUESTS,\n            detail=f\"Too many OTP email requests. Please wait {EMAIL_OTP_SEND_WINDOW.seconds // 60} minutes.\",\n        )\n\n\nasync def record_email_otp_send(db: AsyncSession, username: str) -> None:\n    \"\"\"Record a successful OTP email send for rate-limiting purposes (I1).\n\n    Must be called *after* the email has been sent successfully so that failed\n    sends do not consume a slot from the user's quota.\n    \"\"\"\n    db.add(AuthRateLimitEvent(username=username.lower(), event_type=EventType.EMAIL_SEND))\n    await db.commit()\n\n\n# ---------------------------------------------------------------------------\n# TOTP replay-protection helper\n# ---------------------------------------------------------------------------\ndef _assert_totp_not_replayed(totp_obj: pyotp.TOTP, totp_record: UserTOTP, code: str) -> None:\n    \"\"\"Raise HTTP 400 if this TOTP code was already accepted in its time window.\n\n    M3 fix: store the counter of the *accepted* code rather than the current\n    wall-clock counter.  With valid_window=1, pyotp accepts codes from the\n    previous 30-second step.  Using timecode(now) would store the wrong counter\n    when the previous-window code is accepted, allowing immediate replay.\n    \"\"\"\n    # Determine which time-step the accepted code belongs to.\n    now = datetime.now(timezone.utc)\n    accepted_counter: int | None = None\n    for offset in (0, -1):  # current window first, then previous\n        candidate_time = now.timestamp() + offset * totp_obj.interval\n        candidate_counter = totp_obj.timecode(datetime.fromtimestamp(candidate_time, tz=timezone.utc))\n        if totp_obj.at(candidate_counter) == code:\n            accepted_counter = candidate_counter\n            break\n    if accepted_counter is None:\n        accepted_counter = totp_obj.timecode(now)  # fallback (should not happen after verify())\n\n    totp_record.accept_counter(accepted_counter)\n\n\n# ---------------------------------------------------------------------------\n# Settings helpers (email 2FA flag)\n# ---------------------------------------------------------------------------\nasync def _get_email_2fa_enabled(db: AsyncSession, user_id: int) -> bool:\n    val = await get_setting(db, f\"user_{user_id}_email_2fa_enabled\")\n    return val == \"true\"\n\n\nasync def _set_email_2fa_enabled(db: AsyncSession, user_id: int, enabled: bool) -> None:\n    await set_setting(db, f\"user_{user_id}_email_2fa_enabled\", \"true\" if enabled else \"false\")\n\n\n# ===========================================================================\n# 2FA Endpoints\n# ===========================================================================\n\n\n@router.get(\"/2fa/status\", response_model=TwoFAStatusResponse)\nasync def get_2fa_status(\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> TwoFAStatusResponse:\n    \"\"\"Return the current 2FA configuration for the authenticated user.\"\"\"\n    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))\n    totp_record = result.scalar_one_or_none()\n\n    totp_enabled = totp_record is not None and totp_record.is_enabled\n    backup_codes_remaining = len(totp_record.backup_code_hashes) if totp_record else 0\n    email_otp_enabled = await _get_email_2fa_enabled(db, current_user.id)\n\n    return TwoFAStatusResponse(\n        totp_enabled=totp_enabled,\n        email_otp_enabled=email_otp_enabled,\n        backup_codes_remaining=backup_codes_remaining,\n    )\n\n\n@router.post(\"/2fa/totp/setup\", response_model=TOTPSetupResponse)\nasync def setup_totp(\n    body: TOTPSetupRequest | None = Body(default=None),\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> TOTPSetupResponse:\n    \"\"\"Initiate TOTP setup: generates a new secret and QR code.\n\n    Creates (or replaces) a pending UserTOTP record with is_enabled=False.\n    The caller must confirm with POST /auth/2fa/totp/enable.\n\n    M-R7-A: If an *active* TOTP is already configured, the caller must supply\n    the current TOTP code in the request body to confirm intent before the\n    secret is overwritten (prevents silently locking out the real user).\n    \"\"\"\n    if not await is_auth_enabled(db):\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Authentication is not enabled\")\n\n    # Upsert a pending TOTP record (is_enabled=False)\n    existing = (await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))).scalar_one_or_none()\n\n    # M-R7-A: Guard against silent TOTP replacement when one is already active.\n    if existing and existing.is_enabled:\n        await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n        supplied_code = (body.code if body else None) or \"\"\n        if not pyotp.TOTP(existing.secret).verify(supplied_code, valid_window=1):\n            await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Current TOTP code required to replace an active authenticator\",\n            )\n        await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n        _assert_totp_not_replayed(pyotp.TOTP(existing.secret), existing, supplied_code)\n        await db.flush()  # L-3: persist last_totp_counter immediately to block replay\n\n    secret = pyotp.random_base32()\n    totp = pyotp.TOTP(secret)\n    provisioning_uri = totp.provisioning_uri(name=current_user.username, issuer_name=\"Bambuddy\")\n    qr_b64 = _generate_totp_qr_b64(provisioning_uri)\n\n    if existing:\n        existing.secret = secret\n        existing.is_enabled = False\n        existing.backup_code_hashes = []\n    else:\n        db.add(UserTOTP(user_id=current_user.id, secret=secret, is_enabled=False))\n\n    await db.commit()\n\n    return TOTPSetupResponse(secret=secret, qr_code_b64=qr_b64, issuer=\"Bambuddy\")\n\n\n@router.post(\"/2fa/totp/enable\", response_model=TOTPEnableResponse)\nasync def enable_totp(\n    body: TOTPEnableRequest,\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> TOTPEnableResponse:\n    \"\"\"Confirm TOTP setup by verifying a code from the authenticator app.\n\n    On success, enables TOTP and returns 10 single-use backup codes (shown once).\n    L-R7-A: Rate-limited to prevent brute-forcing the 6-digit confirmation code.\n    \"\"\"\n    # L-R7-A: Rate-limit the enable step to prevent brute-forcing the 6-digit code.\n    await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n\n    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))\n    totp_record = result.scalar_one_or_none()\n\n    if not totp_record:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST, detail=\"TOTP setup not initiated. Call /auth/2fa/totp/setup first.\"\n        )\n\n    if not pyotp.TOTP(totp_record.secret).verify(body.code, valid_window=1):\n        await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid TOTP code\")\n\n    await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n    plain_codes, hashed_codes = _generate_backup_codes()\n    totp_record.is_enabled = True\n    totp_record.backup_code_hashes = hashed_codes\n    await db.commit()\n\n    return TOTPEnableResponse(\n        message=\"TOTP enabled successfully. Store your backup codes in a safe place.\",\n        backup_codes=plain_codes,\n    )\n\n\n@router.post(\"/2fa/totp/disable\")\nasync def disable_totp(\n    body: TOTPDisableRequest,\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> dict:\n    \"\"\"Disable TOTP by verifying a valid TOTP code or a backup code.\n\n    I10: Rate-limited to prevent backup-code brute-forcing from a hijacked session.\n    \"\"\"\n    await check_rate_limit(db, current_user.username)\n\n    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))\n    totp_record = result.scalar_one_or_none()\n\n    if not totp_record or not totp_record.is_enabled:\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"TOTP is not enabled\")\n\n    # Accept either a valid TOTP code or a valid backup code\n    totp_obj = pyotp.TOTP(totp_record.secret)\n    code_valid = totp_obj.verify(body.code, valid_window=1)\n    if code_valid:\n        _assert_totp_not_replayed(totp_obj, totp_record, body.code)\n        await db.flush()  # L-3: persist last_totp_counter immediately to block replay\n    else:\n        # Check backup codes — always iterate all entries (L-R9-A: no early break\n        # to avoid timing oracle based on code position in the list).\n        for hashed in totp_record.backup_code_hashes:\n            if pwd_context.verify(body.code, hashed):\n                code_valid = True\n\n    if not code_valid:\n        await record_failed_attempt(db, current_user.username)\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid code\")\n\n    await db.execute(delete(UserTOTP).where(UserTOTP.user_id == current_user.id))\n    await db.commit()\n    return {\"message\": \"TOTP disabled\"}\n\n\n@router.post(\"/2fa/totp/regenerate-backup-codes\", response_model=BackupCodesResponse)\nasync def regenerate_backup_codes(\n    body: TOTPDisableRequest,\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> BackupCodesResponse:\n    \"\"\"Generate 10 new backup codes. Requires a valid TOTP code OR a backup code.\n\n    M10: Accepts backup codes for consistency with disable_totp — users who have\n    lost their authenticator app but still have backup codes can regenerate.\n    Rate-limited to prevent brute-forcing from a hijacked session.\n    \"\"\"\n    await check_rate_limit(db, current_user.username)\n\n    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))\n    totp_record = result.scalar_one_or_none()\n\n    if not totp_record or not totp_record.is_enabled:\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"TOTP is not enabled\")\n\n    totp_obj = pyotp.TOTP(totp_record.secret)\n    code_valid = totp_obj.verify(body.code, valid_window=1)\n    if code_valid:\n        _assert_totp_not_replayed(totp_obj, totp_record, body.code)\n        await db.flush()  # L-3: persist last_totp_counter immediately to block replay\n    else:\n        # Accept a backup code as an alternative (M10)\n        matched_index: int | None = None\n        for idx, hashed in enumerate(totp_record.backup_code_hashes):\n            if pwd_context.verify(body.code, hashed) and matched_index is None:\n                matched_index = idx\n        if matched_index is None:\n            await record_failed_attempt(db, current_user.username)\n            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid TOTP or backup code\")\n        # Remove the used backup code\n        totp_record.backup_code_hashes = [c for i, c in enumerate(totp_record.backup_code_hashes) if i != matched_index]\n\n    plain_codes, hashed_codes = _generate_backup_codes()\n    totp_record.backup_code_hashes = hashed_codes\n    await db.commit()\n\n    return BackupCodesResponse(\n        backup_codes=plain_codes,\n        message=\"Backup codes regenerated. Store them safely — they will not be shown again.\",\n    )\n\n\n@router.post(\"/2fa/email/enable\")\nasync def enable_email_otp(\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> dict:\n    \"\"\"Step 1 of email OTP enable: send a verification code to the user's email.\n\n    C5: Proof of possession — the user must prove they control the registered email\n    address before email 2FA is activated.  Returns a ``setup_token`` that must be\n    passed to POST /auth/2fa/email/enable/confirm together with the received code.\n    H-3: Rate-limited to prevent email flooding via repeated calls to this endpoint.\n    \"\"\"\n    await check_email_otp_send_rate(db, current_user.username)\n    if not current_user.email:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"You must have an email address configured to enable email OTP 2FA\",\n        )\n\n    smtp_settings = await get_smtp_settings(db)\n    if not smtp_settings:\n        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=\"Email service is not configured\")\n\n    # Generate and store the setup token (reuse AuthEphemeralToken with type \"email_otp_setup\")\n    now = datetime.now(timezone.utc)\n    # Prune any existing pending setup tokens for this user\n    await db.execute(\n        delete(AuthEphemeralToken).where(\n            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,\n            AuthEphemeralToken.username == current_user.username,\n        )\n    )\n\n    code = str(secrets.randbelow(1_000_000)).zfill(6)\n    code_hash = pwd_context.hash(code)\n    setup_token = secrets.token_urlsafe(32)\n\n    db.add(\n        AuthEphemeralToken(\n            token=setup_token,\n            token_type=TokenType.EMAIL_OTP_SETUP,\n            username=current_user.username,\n            # Reuse the nonce field to store the code hash\n            nonce=code_hash,\n            expires_at=now + timedelta(minutes=10),\n        )\n    )\n    await db.commit()\n\n    try:\n        send_email(\n            smtp_settings=smtp_settings,\n            to_email=current_user.email,\n            subject=\"Verify your Bambuddy email address for 2FA\",\n            body_text=(\n                f\"Your Bambuddy email 2FA setup code is: {code}\\n\\n\"\n                \"Enter this code to confirm email-based two-factor authentication.\\n\"\n                \"The code expires in 10 minutes.\"\n            ),\n            body_html=(\n                \"<p>To enable <strong>email-based two-factor authentication</strong> on your Bambuddy account, \"\n                \"enter the code below:</p>\"\n                f\"<h2 style='letter-spacing:4px'>{code}</h2>\"\n                \"<p>The code expires in <strong>10 minutes</strong>. \"\n                \"If you did not request this, you can safely ignore this email.</p>\"\n            ),\n        )\n        await record_email_otp_send(db, current_user.username)\n    except Exception as exc:\n        logger.error(\"Failed to send email OTP setup code to user_id=%d: %s\", current_user.id, exc)\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=\"Failed to send verification email\"\n        )\n\n    return {\"message\": \"Verification code sent to your email address\", \"setup_token\": setup_token}\n\n\n@router.post(\"/2fa/email/enable/confirm\")\nasync def confirm_enable_email_otp(\n    body: EmailOTPEnableConfirmRequest,\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> dict:\n    \"\"\"Step 2 of email OTP enable: verify the code and activate email 2FA.\n\n    H-2 fix: Uses peek-then-consume so a wrong code does NOT burn the setup token.\n    The token is only deleted after successful code verification, allowing retries\n    up to the rate limit (5 attempts / 15 min).\n    M4: Rate-limited to prevent brute-forcing the 6-digit setup code.\n    \"\"\"\n    await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n    now = datetime.now(timezone.utc)\n\n    # --- Peek: validate token without consuming ---\n    peek_result = await db.execute(\n        select(AuthEphemeralToken).where(\n            AuthEphemeralToken.token == body.setup_token,\n            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,\n            AuthEphemeralToken.username == current_user.username,\n            AuthEphemeralToken.expires_at > now,\n        )\n    )\n    eph = peek_result.scalar_one_or_none()\n    if eph is None:\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid or expired setup token\")\n\n    code_hash = eph.nonce  # code hash stored in the nonce field\n\n    # --- Verify code before consuming the token ---\n    if not pwd_context.verify(body.code, code_hash):\n        await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid verification code\")\n\n    # --- Atomically consume the token now that the code is correct ---\n    # DELETE...RETURNING prevents a concurrent request from using the same token.\n    del_result = await db.execute(\n        delete(AuthEphemeralToken)\n        .where(\n            AuthEphemeralToken.token == body.setup_token,\n            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,\n            AuthEphemeralToken.username == current_user.username,\n        )\n        .returning(AuthEphemeralToken.id)\n    )\n    if del_result.one_or_none() is None:\n        # Concurrent request consumed it between peek and delete — treat as invalid.\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"Invalid or expired setup token\")\n\n    await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)\n    await _set_email_2fa_enabled(db, current_user.id, True)\n    await db.commit()\n    return {\"message\": \"Email OTP 2FA enabled\"}\n\n\n@router.post(\"/2fa/email/disable\")\nasync def disable_email_otp(\n    body: EmailOTPDisableRequest,\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> dict:\n    \"\"\"Disable email-based OTP 2FA for the current user.\n\n    C6: Re-authentication required — the caller must supply their account password\n    to prevent a hijacked session from silently removing a second factor.\n    LDAP/OIDC-only users (no local password) are exempt from this check.\n    H-2: Rate-limited to prevent brute-forcing the password via this endpoint.\n    \"\"\"\n    await check_rate_limit(db, current_user.username)\n    if current_user.password_hash:\n        if not verify_password(body.password, current_user.password_hash):\n            await record_failed_attempt(db, current_user.username)\n            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid password\")\n    await _set_email_2fa_enabled(db, current_user.id, False)\n    await db.commit()\n    return {\"message\": \"Email OTP 2FA disabled\"}\n\n\n@router.post(\"/2fa/email/send\")\nasync def send_email_otp(\n    request: Request,\n    body: EmailOTPSendRequest,\n    db: AsyncSession = Depends(get_db),\n) -> dict:\n    \"\"\"Send a 6-digit OTP code to the user's email address.\n\n    Requires a valid pre_auth_token obtained during the login flow.\n    \"\"\"\n    # Peek (validate without consuming) first so a rate-limit rejection does not\n    # permanently burn the caller's pre-auth token.\n    challenge_id = request.cookies.get(\"2fa_challenge\")\n    username = await peek_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)\n    if not username:\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid or expired pre-auth token\")\n\n    # Enforce rate limit BEFORE consuming the token to prevent OTP email flooding.\n    await check_email_otp_send_rate(db, username)\n\n    user = await get_user_by_username(db, username)\n    if not user or not user.is_active:\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"User not found or inactive\")\n\n    if not user.email:\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"User has no email address configured\")\n\n    smtp_settings = await get_smtp_settings(db)\n    if not smtp_settings:\n        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=\"Email service is not configured\")\n\n    # Invalidate all existing unused OTP codes for this user (staged, not yet committed)\n    await db.execute(\n        UserOTPCode.__table__.update()  # type: ignore[attr-defined]\n        .where(UserOTPCode.user_id == user.id)\n        .where(UserOTPCode.used.is_(False))\n        .values(used=True)\n    )\n\n    # Generate a 6-digit code and stage the record (not committed yet)\n    code = str(secrets.randbelow(1_000_000)).zfill(6)\n    code_hash = pwd_context.hash(code)\n    expires_at = datetime.now(timezone.utc) + timedelta(minutes=UserOTPCode.OTP_TTL_MINUTES)\n\n    otp_record = UserOTPCode(\n        user_id=user.id,\n        code_hash=code_hash,\n        attempts=0,\n        used=False,\n        expires_at=expires_at,\n    )\n    db.add(otp_record)\n\n    # M2: Send the email BEFORE consuming the pre-auth token.\n    # If the send fails we raise an exception here; the session is uncommitted so\n    # the OTP record is discarded and the original token remains valid for retry.\n    try:\n        send_email(\n            smtp_settings=smtp_settings,\n            to_email=user.email,\n            subject=\"Your Bambuddy verification code\",\n            body_text=f\"Your Bambuddy login code is: {code}\\n\\nThis code expires in {UserOTPCode.OTP_TTL_MINUTES} minutes and can only be used once.\",\n            body_html=(\n                f\"<p>Your <strong>Bambuddy</strong> login verification code is:</p>\"\n                f\"<h2 style='letter-spacing:4px'>{code}</h2>\"\n                f\"<p>This code expires in <strong>{UserOTPCode.OTP_TTL_MINUTES} minutes</strong> and can only be used once.</p>\"\n                f\"<p>If you did not request this code, you can safely ignore this email.</p>\"\n            ),\n        )\n        await record_email_otp_send(db, username)\n    except Exception as exc:\n        logger.error(\"Failed to send OTP email to user_id=%d: %s\", user.id, exc)\n        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=\"Failed to send OTP email\")\n\n    # Email sent — now atomically consume the old token (this also commits the\n    # staged OTP record) and issue a fresh token for the verify step.\n    consumed = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)\n    if not consumed:\n        # Raced with another request or token just expired — treat as invalid.\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid or expired pre-auth token\")\n\n    # Re-issue a fresh pre-auth token bound to the same cookie so the binding\n    # carries forward through the email → verify step.\n    fresh_token = await create_pre_auth_token(db, username, challenge_id=challenge_id)\n\n    # Return the fresh pre-auth token so the frontend can proceed to verify\n    return {\"message\": \"Code sent to your email address\", \"pre_auth_token\": fresh_token}\n\n\n@router.post(\"/2fa/verify\", response_model=TwoFAVerifyResponse)\nasync def verify_2fa(\n    request: Request,\n    body: TwoFAVerifyRequest,\n    db: AsyncSession = Depends(get_db),\n) -> TwoFAVerifyResponse:\n    \"\"\"Verify a 2FA code and exchange the pre_auth_token for a full JWT.\n\n    Accepted methods: ``totp``, ``email``, ``backup``.\n\n    The pre_auth_token is NOT consumed on failed verification attempts so the\n    user can retry without restarting the login flow.  It is only consumed once\n    verification succeeds, preventing token replay after success.\n    \"\"\"\n    # Peek without consuming — bad codes must not burn the session token.\n    # Pass the HttpOnly challenge cookie so the binding check is enforced.\n    challenge_id = request.cookies.get(\"2fa_challenge\")\n    username = await peek_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)\n    if not username:\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid or expired pre-auth token\")\n\n    await check_rate_limit(db, username)\n\n    user = await get_user_by_username(db, username)\n    if not user or not user.is_active:\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"User not found or inactive\")\n\n    method = body.method\n\n    if method == \"totp\":\n        result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))\n        totp_record = result.scalar_one_or_none()\n        if not totp_record or not totp_record.is_enabled:\n            await record_failed_attempt(db, username)\n            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"TOTP is not enabled for this user\")\n        totp_obj = pyotp.TOTP(totp_record.secret)\n        if not totp_obj.verify(body.code, valid_window=1):\n            await record_failed_attempt(db, username)\n            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid TOTP code\")\n        _assert_totp_not_replayed(totp_obj, totp_record, body.code)\n        await db.flush()  # L-3: persist last_totp_counter immediately to block replay\n\n    elif method == \"email\":\n        now = datetime.now(timezone.utc)\n        result = await db.execute(\n            select(UserOTPCode)\n            .where(UserOTPCode.user_id == user.id)\n            .where(UserOTPCode.used.is_(False))\n            .where(UserOTPCode.expires_at > now)\n            .order_by(UserOTPCode.created_at.desc())\n        )\n        otp_record = result.scalar_one_or_none()\n        if not otp_record:\n            await record_failed_attempt(db, username)\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED, detail=\"No valid OTP code found. Request a new one.\"\n            )\n\n        if otp_record.attempts >= UserOTPCode.MAX_ATTEMPTS:\n            otp_record.consume()\n            await db.commit()\n            await record_failed_attempt(db, username)\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED, detail=\"OTP code has been invalidated after too many attempts\"\n            )\n\n        if not pwd_context.verify(body.code, otp_record.code_hash):\n            otp_record.attempts += 1\n            await db.commit()\n            await record_failed_attempt(db, username)\n            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid OTP code\")\n\n        otp_record.consume()\n        await db.commit()\n\n    else:  # method == \"backup\"\n        result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))\n        totp_record = result.scalar_one_or_none()\n        if not totp_record or not totp_record.is_enabled:\n            await record_failed_attempt(db, username)\n            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=\"TOTP is not enabled for this user\")\n\n        # Always iterate all codes — no early break (L-R9-A: constant iteration\n        # count prevents timing oracle based on used-code position in the list).\n        matched_index: int | None = None\n        for idx, hashed in enumerate(totp_record.backup_code_hashes):\n            if pwd_context.verify(body.code, hashed) and matched_index is None:\n                matched_index = idx\n\n        if matched_index is None:\n            await record_failed_attempt(db, username)\n            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid backup code\")\n\n        # M1: Consume the pre-auth token FIRST (atomic single-use enforcement).\n        # Only if that succeeds do we remove the backup code — this prevents a race\n        # where two concurrent requests both pass code verification but only one\n        # should be granted a session.\n        consumed_username = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)\n        if not consumed_username:\n            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid or expired pre-auth token\")\n\n        # Remove the used backup code now that the token is atomically consumed.\n        updated_codes = [c for i, c in enumerate(totp_record.backup_code_hashes) if i != matched_index]\n        totp_record.backup_code_hashes = updated_codes\n        await db.commit()\n        await clear_failed_attempts(db, username)\n\n        access_token = create_access_token(\n            data={\"sub\": user.username},\n            expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),\n        )\n        result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))\n        user = result.scalar_one()\n        return TwoFAVerifyResponse(access_token=access_token, token_type=\"bearer\", user=_user_to_response(user))\n\n    # Verification succeeded (TOTP or email) — consume the pre-auth token.\n    # C-1: Check the return value; if None the token was already consumed by a\n    # concurrent request (race condition) — reject to prevent double-use.\n    consumed_username = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)\n    if not consumed_username:\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid or expired pre-auth token\")\n    await clear_failed_attempts(db, username)\n\n    access_token = create_access_token(\n        data={\"sub\": user.username},\n        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),\n    )\n\n    # Reload with groups for permission calculation\n    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))\n    user = result.scalar_one()\n\n    return TwoFAVerifyResponse(\n        access_token=access_token,\n        token_type=\"bearer\",\n        user=_user_to_response(user),\n    )\n\n\n@router.delete(\"/2fa/admin/{user_id}\")\nasync def admin_disable_2fa(\n    user_id: int,\n    body: AdminDisable2FARequest = Body(default_factory=AdminDisable2FARequest),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n) -> dict:\n    \"\"\"Admin endpoint: disable all 2FA for a given user.\n\n    Nit 3: Requires the admin's own password as a re-auth step (matching how\n    disable_email_otp protects a user's own 2FA removal). OIDC/LDAP-only admins\n    (no local password_hash) are exempt.\n    \"\"\"\n    # Nit 3: Re-auth — admin must supply their own password.\n    if current_user and current_user.password_hash:\n        if not body.admin_password or not verify_password(body.admin_password, current_user.password_hash):\n            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Admin password required\")\n\n    # Delete TOTP record\n    await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))\n\n    # Disable email 2FA setting\n    await _set_email_2fa_enabled(db, user_id, False)\n\n    # Invalidate all OTP codes\n    await db.execute(\n        UserOTPCode.__table__.update()  # type: ignore[attr-defined]\n        .where(UserOTPCode.user_id == user_id)\n        .values(used=True)\n    )\n\n    # I2: Invalidate existing JWTs for the target user by bumping password_changed_at.\n    # Without this, a stolen token remains valid after 2FA removal.\n    target_user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()\n    if target_user:\n        target_user.password_changed_at = datetime.now(timezone.utc)\n\n    await db.commit()\n    actor = current_user.username if current_user else \"anonymous\"\n    logger.info(\"Admin %s disabled all 2FA for user_id=%d\", actor, user_id)\n    return {\"message\": \"2FA disabled for user\"}\n\n\n# ===========================================================================\n# OIDC Endpoints\n# ===========================================================================\n\n\n@router.get(\"/oidc/providers\", response_model=list[OIDCProviderResponse])\nasync def list_oidc_providers(\n    db: AsyncSession = Depends(get_db),\n) -> list[OIDCProviderResponse]:\n    \"\"\"List all enabled OIDC providers (public).\"\"\"\n    result = await db.execute(select(OIDCProvider).where(OIDCProvider.is_enabled.is_(True)))\n    providers = result.scalars().all()\n    return [OIDCProviderResponse.model_validate(p) for p in providers]\n\n\n@router.get(\"/oidc/providers/all\", response_model=list[OIDCProviderResponse])\nasync def list_all_oidc_providers(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n    db: AsyncSession = Depends(get_db),\n) -> list[OIDCProviderResponse]:\n    \"\"\"List ALL OIDC providers including disabled ones (admin only).\"\"\"\n    result2 = await db.execute(select(OIDCProvider))\n    providers = result2.scalars().all()\n    return [OIDCProviderResponse.model_validate(p) for p in providers]\n\n\n@router.post(\"/oidc/providers\", response_model=OIDCProviderResponse, status_code=status.HTTP_201_CREATED)\nasync def create_oidc_provider(\n    body: OIDCProviderCreate,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n) -> OIDCProviderResponse:\n    \"\"\"Create a new OIDC provider (admin only).\"\"\"\n    provider = OIDCProvider(\n        name=body.name,\n        issuer_url=body.issuer_url.rstrip(\"/\"),\n        client_id=body.client_id,\n        client_secret=body.client_secret,\n        scopes=body.scopes,\n        is_enabled=body.is_enabled,\n        auto_create_users=body.auto_create_users,\n        icon_url=body.icon_url,\n    )\n    db.add(provider)\n    await db.commit()\n    await db.refresh(provider)\n    return OIDCProviderResponse.model_validate(provider)\n\n\n@router.put(\"/oidc/providers/{provider_id}\", response_model=OIDCProviderResponse)\nasync def update_oidc_provider(\n    provider_id: int,\n    body: OIDCProviderUpdate,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n) -> OIDCProviderResponse:\n    \"\"\"Update an existing OIDC provider (admin only).\"\"\"\n    result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))\n    provider = result2.scalar_one_or_none()\n    if not provider:\n        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=\"Provider not found\")\n\n    for field, value in body.model_dump(exclude_none=True).items():\n        if field == \"issuer_url\" and value:\n            value = value.rstrip(\"/\")\n        setattr(provider, field, value)\n\n    await db.commit()\n    await db.refresh(provider)\n    return OIDCProviderResponse.model_validate(provider)\n\n\n@router.delete(\"/oidc/providers/{provider_id}\")\nasync def delete_oidc_provider(\n    provider_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n) -> dict:\n    \"\"\"Delete an OIDC provider and all its user links (admin only).\"\"\"\n    result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))\n    provider = result2.scalar_one_or_none()\n    if not provider:\n        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=\"Provider not found\")\n\n    await db.delete(provider)\n    await db.commit()\n    return {\"message\": \"Provider deleted\"}\n\n\n@router.get(\"/oidc/authorize/{provider_id}\", response_model=OIDCAuthorizeResponse)\nasync def oidc_authorize(\n    provider_id: int,\n    db: AsyncSession = Depends(get_db),\n) -> OIDCAuthorizeResponse:\n    \"\"\"Return the OIDC authorization URL for the given provider.\"\"\"\n    result = await db.execute(\n        select(OIDCProvider).where(OIDCProvider.id == provider_id).where(OIDCProvider.is_enabled.is_(True))\n    )\n    provider = result.scalar_one_or_none()\n    if not provider:\n        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=\"Provider not found or not enabled\")\n\n    # Fetch discovery document\n    discovery_url = f\"{provider.issuer_url.rstrip('/')}/.well-known/openid-configuration\"\n    try:\n        async with httpx.AsyncClient(timeout=10) as client:\n            resp = await client.get(discovery_url)\n            resp.raise_for_status()\n            discovery = resp.json()\n    except Exception as exc:\n        logger.error(\"Failed to fetch OIDC discovery for provider %d: %s\", provider_id, exc)\n        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=\"Failed to fetch OIDC discovery document\")\n\n    authorization_endpoint = discovery.get(\"authorization_endpoint\")\n    if not authorization_endpoint:\n        raise HTTPException(\n            status_code=status.HTTP_502_BAD_GATEWAY, detail=\"OIDC discovery document missing authorization_endpoint\"\n        )\n    # B2: SSRF guard — reject non-HTTP(S) schemes in the authorization endpoint\n    if not authorization_endpoint.startswith((\"https://\", \"http://\")):\n        logger.warning(\"OIDC discovery authorization_endpoint has invalid scheme: %s\", authorization_endpoint)\n        raise HTTPException(\n            status_code=status.HTTP_502_BAD_GATEWAY,\n            detail=\"OIDC discovery document contains invalid authorization_endpoint\",\n        )\n\n    external_url = await _get_base_external_url(db)\n    redirect_uri = f\"{external_url}/api/v1/auth/oidc/callback\"\n\n    now = datetime.now(timezone.utc)\n    # Prune expired OIDC states from the DB\n    await db.execute(\n        delete(AuthEphemeralToken).where(\n            AuthEphemeralToken.token_type == TokenType.OIDC_STATE,\n            AuthEphemeralToken.expires_at < now,\n        )\n    )\n    state = secrets.token_urlsafe(32)\n    nonce = secrets.token_urlsafe(32)\n\n    # PKCE (S256) – required by PocketID and recommended for all OIDC flows\n    code_verifier = secrets.token_urlsafe(48)  # 64-char URL-safe string\n    code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b\"=\").decode()\n\n    db.add(\n        AuthEphemeralToken(\n            token=state,\n            token_type=TokenType.OIDC_STATE,\n            provider_id=provider_id,\n            nonce=nonce,\n            code_verifier=code_verifier,\n            expires_at=now + OIDC_STATE_TTL,\n        )\n    )\n    await db.commit()\n\n    params = urllib.parse.urlencode(\n        {\n            \"response_type\": \"code\",\n            \"client_id\": provider.client_id,\n            \"redirect_uri\": redirect_uri,\n            \"scope\": provider.scopes,\n            \"state\": state,\n            \"nonce\": nonce,\n            \"code_challenge\": code_challenge,\n            \"code_challenge_method\": \"S256\",\n        }\n    )\n    auth_url = f\"{authorization_endpoint}?{params}\"\n    return OIDCAuthorizeResponse(auth_url=auth_url)\n\n\n@router.get(\"/oidc/callback\")\nasync def oidc_callback(\n    code: str | None = Query(default=None, max_length=2048),\n    state: str | None = Query(default=None, max_length=2048),\n    error: str | None = Query(default=None, max_length=256),\n    db: AsyncSession = Depends(get_db),\n) -> RedirectResponse:\n    \"\"\"Handle the OIDC authorization code callback from the identity provider.\"\"\"\n    external_url = await _get_base_external_url(db)\n    frontend_error_url = f\"{external_url}/?oidc_error=\"\n\n    try:\n        if error:\n            logger.warning(\"OIDC callback received error: %s\", error)\n            return RedirectResponse(url=f\"{frontend_error_url}oidc_provider_error\", status_code=302)\n\n        if not code or not state:\n            return RedirectResponse(url=f\"{frontend_error_url}missing_parameters\", status_code=302)\n\n        # Atomically validate and consume OIDC state from DB (I6: single-use enforcement).\n        # DELETE...RETURNING ensures concurrent callbacks with the same state token\n        # cannot both succeed — only the first DELETE finds the row.\n        now = datetime.now(timezone.utc)\n        state_del = await db.execute(\n            delete(AuthEphemeralToken)\n            .where(\n                AuthEphemeralToken.token == state,\n                AuthEphemeralToken.token_type == TokenType.OIDC_STATE,\n                AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically\n            )\n            .returning(\n                AuthEphemeralToken.provider_id,\n                AuthEphemeralToken.nonce,\n                AuthEphemeralToken.code_verifier,\n            )\n        )\n        state_row = state_del.one_or_none()\n        if state_row is None:\n            await db.rollback()\n            return RedirectResponse(url=f\"{frontend_error_url}invalid_state\", status_code=302)\n\n        provider_id, nonce, code_verifier = state_row\n        await db.commit()\n\n        # Load provider\n        result = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))\n        provider = result.scalar_one_or_none()\n        if not provider:\n            return RedirectResponse(url=f\"{frontend_error_url}provider_not_found\", status_code=302)\n\n        redirect_uri = f\"{external_url}/api/v1/auth/oidc/callback\"\n\n        # ── Step 1: Fetch discovery document ────────────────────────────────\n        discovery_url = f\"{provider.issuer_url.rstrip('/')}/.well-known/openid-configuration\"\n        try:\n            async with httpx.AsyncClient(timeout=10) as client:\n                disc_resp = await client.get(discovery_url)\n                disc_resp.raise_for_status()\n                discovery = disc_resp.json()\n        except Exception as exc:\n            logger.error(\"OIDC discovery fetch failed for provider %d: %s\", provider_id, exc)\n            return RedirectResponse(url=f\"{frontend_error_url}discovery_failed\", status_code=302)\n\n        token_endpoint = discovery.get(\"token_endpoint\")\n        jwks_uri = discovery.get(\"jwks_uri\")\n        if not token_endpoint or not jwks_uri:\n            return RedirectResponse(url=f\"{frontend_error_url}invalid_discovery_document\", status_code=302)\n        # L-R7-C: Reject non-HTTP(S) URLs in the discovery document to prevent\n        # SSRF via crafted responses (e.g. file://, gopher://, internal schemes).\n        if not token_endpoint.startswith((\"https://\", \"http://\")) or not jwks_uri.startswith((\"https://\", \"http://\")):\n            logger.warning(\n                \"OIDC discovery document contains non-HTTP URL(s): token=%s jwks=%s\", token_endpoint, jwks_uri\n            )\n            return RedirectResponse(url=f\"{frontend_error_url}invalid_discovery_document\", status_code=302)\n\n        # ── Step 2: Exchange authorization code for tokens ───────────────────\n        token_form: dict[str, str] = {\n            \"grant_type\": \"authorization_code\",\n            \"code\": code,\n            \"redirect_uri\": redirect_uri,\n            \"client_id\": provider.client_id,\n        }\n        if provider.client_secret:\n            token_form[\"client_secret\"] = provider.client_secret\n        if code_verifier:\n            token_form[\"code_verifier\"] = code_verifier\n\n        try:\n            async with httpx.AsyncClient(timeout=15) as client:\n                token_resp = await client.post(\n                    token_endpoint,\n                    data=token_form,\n                    headers={\"Accept\": \"application/json\"},\n                )\n        except Exception as exc:\n            logger.error(\"OIDC token exchange request failed for provider %d: %s\", provider_id, exc)\n            return RedirectResponse(url=f\"{frontend_error_url}token_exchange_network_error\", status_code=302)\n\n        if not token_resp.is_success:\n            try:\n                err_body = token_resp.json()\n                oidc_err = err_body.get(\"error\", \"\")\n                oidc_desc = err_body.get(\"error_description\", \"\")\n            except Exception:\n                oidc_err = \"\"\n                oidc_desc = token_resp.text[:200]\n            logger.error(\n                \"OIDC token exchange HTTP %d for provider %d. redirect_uri=%r error=%r desc=%r\",\n                token_resp.status_code,\n                provider_id,\n                redirect_uri,\n                oidc_err,\n                oidc_desc,\n            )\n            # Encode the OIDC error code into the redirect so the user sees it in the toast.\n            # URL-encode the value to prevent query-parameter injection from provider responses.\n            raw_err = oidc_err[:40] if oidc_err else str(token_resp.status_code)\n            safe_err = urllib.parse.quote(raw_err, safe=\"\")\n            return RedirectResponse(\n                url=f\"{frontend_error_url}token_exchange_{safe_err}\",\n                status_code=302,\n            )\n\n        try:\n            token_data = token_resp.json()\n        except Exception as exc:\n            logger.error(\"OIDC token exchange non-JSON response for provider %d: %s\", provider_id, exc)\n            return RedirectResponse(url=f\"{frontend_error_url}token_exchange_bad_response\", status_code=302)\n\n        id_token = token_data.get(\"id_token\")\n        if not id_token:\n            # Only log the keys present — values may contain secrets (access_token, etc.)\n            logger.error(\n                \"OIDC token response missing id_token for provider %d; keys present: %s\",\n                provider_id,\n                list(token_data.keys()),\n            )\n            return RedirectResponse(url=f\"{frontend_error_url}no_id_token\", status_code=302)\n\n        # ── Step 3: Fetch JWKS and validate ID token ─────────────────────────\n        # Use the issuer from the discovery document as the canonical value (OIDC Core\n        # §3.1.3.7 requires iss == discovery issuer exactly).  We strip trailing slashes\n        # from both sides because some providers (e.g. Authentik, older PocketID versions)\n        # are inconsistent between the discovery issuer and the JWT iss claim.\n        discovery_issuer: str = discovery.get(\"issuer\", provider.issuer_url).rstrip(\"/\")\n        try:\n            async with httpx.AsyncClient(timeout=10) as jwks_http:\n                jwks_resp = await jwks_http.get(jwks_uri)\n                jwks_resp.raise_for_status()\n                jwks_data = jwks_resp.json()\n\n            jwks_client = PyJWKClient(jwks_uri)\n            jwks_client.fetch_data = lambda: jwks_data  # type: ignore[method-assign]\n            signing_key = jwks_client.get_signing_key_from_jwt(id_token)\n\n            # M-3: Decode without built-in issuer check, then compare normalised\n            # (both sides rstrip(\"/\")) to handle providers like Authentik that include\n            # a trailing slash in iss but not in the discovery issuer, or vice-versa.\n            claims = jwt.decode(\n                id_token,\n                signing_key.key,\n                algorithms=[\"RS256\", \"ES256\", \"RS384\", \"ES384\", \"RS512\"],\n                audience=provider.client_id,\n                options={\"verify_iss\": False},\n            )\n            token_iss = claims.get(\"iss\", \"\").rstrip(\"/\")\n            if token_iss != discovery_issuer:\n                raise jwt.exceptions.InvalidIssuerError(\"Invalid issuer\")\n        except Exception as exc:\n            logger.error(\"OIDC JWT validation failed for provider %d: %s\", provider_id, exc, exc_info=True)\n            return RedirectResponse(url=f\"{frontend_error_url}token_validation_failed\", status_code=302)\n\n        # Verify nonce — fail closed: we always send a nonce, so the provider must echo it.\n        # Skipping the check when nonce is absent would allow CSRF on non-nonce providers.\n        token_nonce = claims.get(\"nonce\")\n        if token_nonce is None or token_nonce != nonce:\n            logger.warning(\"OIDC nonce mismatch for provider %d (present=%r)\", provider_id, token_nonce is not None)\n            return RedirectResponse(url=f\"{frontend_error_url}nonce_mismatch\", status_code=302)\n\n        provider_sub: str = claims.get(\"sub\", \"\")\n        if not provider_sub:\n            return RedirectResponse(url=f\"{frontend_error_url}missing_sub_claim\", status_code=302)\n\n        # C1: Only trust the email claim when the provider explicitly marks it verified.\n        # Treating absent email_verified as verified enables account-takeover: an attacker\n        # could register an unverified email with an IdP and auto-link to an existing account.\n        # Fail closed: require email_verified == True; absent/False both drop the email.\n        raw_email: str | None = claims.get(\"email\")\n        email_verified = claims.get(\"email_verified\")\n        if email_verified is not True:\n            if raw_email:\n                logger.info(\n                    \"OIDC provider %d: ignoring email for sub=%r because email_verified=%r\",\n                    provider_id,\n                    provider_sub,\n                    email_verified,\n                )\n            provider_email: str | None = None\n        else:\n            provider_email = raw_email\n\n        # ── Step 4: Resolve / create user ────────────────────────────────────\n        try:\n            # 1. Look up existing OIDC link\n            link_result = await db.execute(\n                select(UserOIDCLink)\n                .where(UserOIDCLink.provider_id == provider_id)\n                .where(UserOIDCLink.provider_user_id == provider_sub)\n            )\n            link = link_result.scalar_one_or_none()\n\n            user: User | None = None\n\n            if link:\n                # Existing link → load the linked user\n                user_result = await db.execute(\n                    select(User).where(User.id == link.user_id).options(selectinload(User.groups))\n                )\n                user = user_result.scalar_one_or_none()\n            else:\n                # 2. No OIDC link yet — check for an existing user with the same email.\n                # Use case-insensitive matching (func.lower) so that \"User@Example.com\"\n                # and \"user@example.com\" are treated as the same identity, preventing\n                # an attacker-controlled IdP from bypassing the auto-link guard by\n                # registering the target email with different casing.\n                email_user: User | None = None\n                if provider_email:\n                    email_user = await get_user_by_email(db, provider_email)\n\n                if email_user and provider.auto_link_existing_accounts:\n                    # M-4: Only auto-link when the provider has auto_link_existing_accounts\n                    # enabled.  Operators can disable this to require explicit account linking,\n                    # preventing an attacker-controlled IdP from hijacking local accounts.\n                    #\n                    # M-NEW-6: Refuse auto-link if the target user already has any OIDC\n                    # link (to any provider).  Without this guard an attacker who controls\n                    # a second OIDC provider with auto_link enabled could add themselves as\n                    # a second IdP for a user that already authenticates via a legitimate\n                    # provider, effectively taking over the account.\n                    existing_links_result = await db.execute(\n                        select(UserOIDCLink).where(UserOIDCLink.user_id == email_user.id)\n                    )\n                    has_existing_oidc_link = existing_links_result.scalar_one_or_none() is not None\n                    if has_existing_oidc_link:\n                        logger.warning(\n                            \"Auto-link rejected for user '%s': already linked to another OIDC provider\",\n                            email_user.username,\n                        )\n                        return RedirectResponse(url=f\"{frontend_error_url}no_linked_account\", status_code=302)\n                    db.add(\n                        UserOIDCLink(\n                            user_id=email_user.id,\n                            provider_id=provider_id,\n                            provider_user_id=provider_sub,\n                            provider_email=provider_email,\n                        )\n                    )\n                    await db.commit()\n                    user = email_user\n                    logger.info(\n                        \"Auto-linked existing user '%s' to OIDC provider %d via email match\",\n                        email_user.username,\n                        provider_id,\n                    )\n                elif provider.auto_create_users:\n                    # 3. No existing user — create one\n                    if provider_email:\n                        raw = provider_email.split(\"@\")[0]\n                    else:\n                        raw = provider_sub[:30]\n                    candidate = re.sub(r\"[^a-zA-Z0-9._-]\", \"\", raw)[:30] or \"oidcuser\"\n\n                    username = candidate\n                    counter = 1\n                    while True:\n                        existing = await get_user_by_username(db, username)\n                        if not existing:\n                            break\n                        username = f\"{candidate}{counter}\"\n                        counter += 1\n\n                    # I9: Assign new OIDC users to the default \"Viewers\" group so they\n                    # have read-only access rather than starting with no permissions.\n                    # Fetch the group BEFORE creating the user so we can set the\n                    # relationship before flush — accessing new_user.groups after a\n                    # flush triggers a lazy-load which fails in async context.\n                    viewers_result = await db.execute(select(Group).where(Group.name == \"Viewers\"))\n                    viewers_group = viewers_result.scalar_one_or_none()\n\n                    new_user = User(\n                        username=username,\n                        email=provider_email,\n                        # M-1: auth_source=\"oidc\" prevents local password-reset flow\n                        # for users who should only authenticate via OIDC.\n                        auth_source=\"oidc\",\n                        password_hash=None,  # OIDC users never use password auth\n                        role=\"user\",\n                        is_active=True,\n                        groups=[viewers_group] if viewers_group else [],\n                    )\n                    db.add(new_user)\n                    await db.flush()\n\n                    db.add(\n                        UserOIDCLink(\n                            user_id=new_user.id,\n                            provider_id=provider_id,\n                            provider_user_id=provider_sub,\n                            provider_email=provider_email,\n                        )\n                    )\n                    await db.commit()\n\n                    user_result = await db.execute(\n                        select(User).where(User.id == new_user.id).options(selectinload(User.groups))\n                    )\n                    user = user_result.scalar_one()\n                    logger.info(\"Auto-created user '%s' via OIDC provider %d\", username, provider_id)\n                else:\n                    return RedirectResponse(url=f\"{frontend_error_url}no_linked_account\", status_code=302)\n\n            if not user or not user.is_active:\n                return RedirectResponse(url=f\"{frontend_error_url}account_inactive\", status_code=302)\n\n            # Issue an OIDC exchange token (short-lived, single-use) stored in DB.\n            # I7: Opportunistically prune expired exchange tokens to keep the table small.\n            now2 = datetime.now(timezone.utc)\n            await db.execute(\n                delete(AuthEphemeralToken).where(\n                    AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,\n                    AuthEphemeralToken.expires_at < now2,\n                )\n            )\n            exchange_token = secrets.token_urlsafe(32)\n            db.add(\n                AuthEphemeralToken(\n                    token=exchange_token,\n                    token_type=TokenType.OIDC_EXCHANGE,\n                    username=user.username,\n                    expires_at=now2 + OIDC_EXCHANGE_TTL,\n                )\n            )\n            await db.commit()\n\n            # H-4: Use a URL fragment (#) instead of a query parameter so the exchange\n            # token is never sent to the server in the Referer header or server logs.\n            return RedirectResponse(url=f\"{external_url}/login#oidc_token={exchange_token}\", status_code=302)\n\n        except Exception as exc:\n            logger.error(\"OIDC user resolution failed for provider %d: %s\", provider_id, exc, exc_info=True)\n            try:\n                await db.rollback()\n            except Exception as rb_exc:\n                logger.error(\"DB rollback failed after OIDC user-resolution error: %s\", rb_exc, exc_info=True)\n            return RedirectResponse(url=f\"{frontend_error_url}user_resolution_failed\", status_code=302)\n\n    except Exception as exc:\n        # L-1: Log the exception class name internally but never expose it in the\n        # redirect URL — leaking exception names aids attacker reconnaissance.\n        logger.error(\"Unexpected error in OIDC callback (%s): %s\", type(exc).__name__, exc, exc_info=True)\n        try:\n            return RedirectResponse(url=f\"{frontend_error_url}internal_error\", status_code=302)\n        except Exception:\n            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=\"OIDC callback failed\")\n\n\n@router.post(\"/oidc/exchange\", response_model=LoginResponse)\nasync def oidc_exchange(\n    body: OIDCExchangeRequest,\n    raw_request: Request,\n    response: Response,\n    db: AsyncSession = Depends(get_db),\n) -> LoginResponse:\n    \"\"\"Exchange an OIDC exchange token (from the callback redirect) for a full JWT.\n\n    C4: If the resolved user has 2FA enabled the exchange returns a pre_auth_token\n    (requires_2fa=True) instead of a full JWT.  The frontend must then complete the\n    2FA step exactly as it would after a password-based login.\n    \"\"\"\n    now = datetime.now(timezone.utc)\n    # Atomically consume the exchange token (DELETE...RETURNING prevents replay).\n    consume_result = await db.execute(\n        delete(AuthEphemeralToken)\n        .where(\n            AuthEphemeralToken.token == body.oidc_token,\n            AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,\n            AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically\n        )\n        .returning(AuthEphemeralToken.username)\n    )\n    row = consume_result.one_or_none()\n    if row is None:\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Invalid or expired OIDC exchange token\")\n\n    (username,) = row\n    await db.commit()\n\n    user = await get_user_by_username(db, username)\n    if not user or not user.is_active:\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"User not found or inactive\")\n\n    # Reload with groups\n    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))\n    user = result.scalar_one()\n\n    # C4: Check whether the user has any 2FA method enabled.\n    totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))\n    totp_record = totp_result.scalar_one_or_none()\n    totp_enabled = totp_record is not None and totp_record.is_enabled\n    email_2fa_enabled = await _get_email_2fa_enabled(db, user.id)\n\n    if totp_enabled or email_2fa_enabled:\n        # User has 2FA — issue a pre_auth_token bound to this browser session via\n        # an HttpOnly cookie (H-A: mirrors the cookie-binding done in auth.py:login).\n        two_fa_methods: list[str] = []\n        if totp_enabled:\n            two_fa_methods.append(\"totp\")\n        if email_2fa_enabled:\n            two_fa_methods.append(\"email\")\n        if totp_enabled:\n            two_fa_methods.append(\"backup\")\n        challenge_id = secrets.token_urlsafe(32)\n        pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)\n        response.set_cookie(\n            key=\"2fa_challenge\",\n            value=challenge_id,\n            httponly=True,\n            secure=raw_request.url.scheme == \"https\",\n            samesite=\"lax\",\n            max_age=300,\n            path=\"/api/v1/auth/2fa\",\n        )\n        return LoginResponse(\n            requires_2fa=True,\n            pre_auth_token=pre_auth_token,\n            two_fa_methods=two_fa_methods,\n            user=_user_to_response(user),\n        )\n\n    access_token = create_access_token(\n        data={\"sub\": user.username},\n        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),\n    )\n\n    return LoginResponse(\n        access_token=access_token,\n        token_type=\"bearer\",\n        user=_user_to_response(user),\n        requires_2fa=False,\n    )\n\n\n@router.get(\"/oidc/links\", response_model=list[OIDCLinkResponse])\nasync def list_oidc_links(\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> list[OIDCLinkResponse]:\n    \"\"\"List all OIDC provider links for the current user.\"\"\"\n    result = await db.execute(\n        select(UserOIDCLink).where(UserOIDCLink.user_id == current_user.id).options(selectinload(UserOIDCLink.provider))\n    )\n    links = result.scalars().all()\n    return [\n        OIDCLinkResponse(\n            id=link.id,\n            provider_id=link.provider_id,\n            provider_name=link.provider.name,\n            provider_email=link.provider_email,\n            created_at=link.created_at.isoformat(),\n        )\n        for link in links\n    ]\n\n\n@router.delete(\"/oidc/links/{provider_id}\")\nasync def remove_oidc_link(\n    provider_id: int,\n    current_user: User = Depends(get_current_active_user),\n    db: AsyncSession = Depends(get_db),\n) -> dict:\n    \"\"\"Remove the OIDC link between the current user and a provider.\"\"\"\n    result = await db.execute(\n        select(UserOIDCLink)\n        .where(UserOIDCLink.user_id == current_user.id)\n        .where(UserOIDCLink.provider_id == provider_id)\n    )\n    link = result.scalar_one_or_none()\n    if not link:\n        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=\"OIDC link not found\")\n\n    await db.delete(link)\n    await db.commit()\n    return {\"message\": \"OIDC link removed\"}\n\n\n# ---------------------------------------------------------------------------\n# Internal helpers\n# ---------------------------------------------------------------------------\nasync def _get_base_external_url(db: AsyncSession) -> str:\n    \"\"\"Return the base external URL (no trailing slash, no /login suffix).\"\"\"\n    external_url = await get_setting(db, \"external_url\")\n    if external_url:\n        return external_url.rstrip(\"/\")\n    return os.environ.get(\"APP_URL\", \"http://localhost:5173\").rstrip(\"/\")\n"
  },
  {
    "path": "backend/app/api/routes/notification_templates.py",
    "content": "\"\"\"API routes for notification template management.\"\"\"\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate\nfrom backend.app.models.user import User\nfrom backend.app.schemas.notification_template import (\n    EVENT_VARIABLES,\n    SAMPLE_DATA,\n    EventVariablesResponse,\n    NotificationTemplateResponse,\n    NotificationTemplateUpdate,\n    TemplatePreviewRequest,\n    TemplatePreviewResponse,\n)\nfrom backend.app.services.notification_service import notification_service\n\nrouter = APIRouter(prefix=\"/notification-templates\", tags=[\"notification-templates\"])\n\n\n# Event type display names\nEVENT_NAMES = {\n    \"print_start\": \"Print Started\",\n    \"print_complete\": \"Print Completed\",\n    \"print_failed\": \"Print Failed\",\n    \"print_stopped\": \"Print Stopped\",\n    \"print_progress\": \"Print Progress\",\n    \"printer_offline\": \"Printer Offline\",\n    \"printer_error\": \"Printer Error\",\n    \"filament_low\": \"Filament Low\",\n    \"maintenance_due\": \"Maintenance Due\",\n    \"test\": \"Test Notification\",\n    # Queue notifications\n    \"queue_job_added\": \"Queue Job Added\",\n    \"queue_job_assigned\": \"Queue Job Assigned\",\n    \"queue_job_started\": \"Queue Job Started\",\n    \"queue_job_waiting\": \"Queue Job Waiting\",\n    \"queue_job_skipped\": \"Queue Job Skipped\",\n    \"queue_job_failed\": \"Queue Job Failed\",\n    \"queue_completed\": \"Queue Completed\",\n    # User management\n    \"user_created\": \"Welcome Email\",\n    \"password_reset\": \"Password Reset\",\n    # User email print notifications\n    \"user_print_start\": \"User Print Started Email\",\n    \"user_print_complete\": \"User Print Completed Email\",\n    \"user_print_failed\": \"User Print Failed Email\",\n    \"user_print_stopped\": \"User Print Stopped Email\",\n}\n\n\n@router.get(\"\", response_model=list[NotificationTemplateResponse])\n@router.get(\"/\", response_model=list[NotificationTemplateResponse])\nasync def get_templates(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),\n):\n    \"\"\"Get all notification templates.\"\"\"\n    result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))\n    return result.scalars().all()\n\n\n@router.get(\"/variables\", response_model=list[EventVariablesResponse])\nasync def get_variables(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),\n):\n    \"\"\"Get available variables for each event type.\"\"\"\n    return [\n        EventVariablesResponse(\n            event_type=event_type,\n            event_name=EVENT_NAMES.get(event_type, event_type),\n            variables=variables,\n        )\n        for event_type, variables in EVENT_VARIABLES.items()\n    ]\n\n\n@router.get(\"/{template_id}\", response_model=NotificationTemplateResponse)\nasync def get_template(\n    template_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),\n):\n    \"\"\"Get a single notification template.\"\"\"\n    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))\n    template = result.scalar_one_or_none()\n    if not template:\n        raise HTTPException(status_code=404, detail=\"Template not found\")\n    return template\n\n\n@router.put(\"/{template_id}\", response_model=NotificationTemplateResponse)\nasync def update_template(\n    template_id: int,\n    update: NotificationTemplateUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),\n):\n    \"\"\"Update a notification template.\"\"\"\n    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))\n    template = result.scalar_one_or_none()\n    if not template:\n        raise HTTPException(status_code=404, detail=\"Template not found\")\n\n    if update.title_template is not None:\n        template.title_template = update.title_template\n    if update.body_template is not None:\n        template.body_template = update.body_template\n\n    await db.commit()\n    await db.refresh(template)\n\n    # Clear template cache so changes take effect immediately\n    notification_service.clear_template_cache()\n\n    return template\n\n\n@router.post(\"/{template_id}/reset\", response_model=NotificationTemplateResponse)\nasync def reset_template(\n    template_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),\n):\n    \"\"\"Reset a notification template to its default values.\"\"\"\n    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))\n    template = result.scalar_one_or_none()\n    if not template:\n        raise HTTPException(status_code=404, detail=\"Template not found\")\n\n    # Find the default template\n    default = next(\n        (t for t in DEFAULT_TEMPLATES if t[\"event_type\"] == template.event_type),\n        None,\n    )\n    if not default:\n        raise HTTPException(status_code=500, detail=\"Default template not found\")\n\n    template.title_template = default[\"title_template\"]\n    template.body_template = default[\"body_template\"]\n\n    await db.commit()\n    await db.refresh(template)\n\n    # Clear template cache so changes take effect immediately\n    notification_service.clear_template_cache()\n\n    return template\n\n\n@router.post(\"/preview\", response_model=TemplatePreviewResponse)\nasync def preview_template(\n    request: TemplatePreviewRequest,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),\n):\n    \"\"\"Preview a template with sample data.\"\"\"\n    sample = SAMPLE_DATA.get(request.event_type, {})\n\n    # Safe template rendering - replace missing vars with empty string\n    def safe_format(template: str, data: dict) -> str:\n        result = template\n        for key, value in data.items():\n            result = result.replace(\"{\" + key + \"}\", str(value))\n        # Remove any remaining unreplaced placeholders\n        import re\n\n        result = re.sub(r\"\\{[a-z_]+\\}\", \"\", result)\n        return result\n\n    return TemplatePreviewResponse(\n        title=safe_format(request.title_template, sample),\n        body=safe_format(request.body_template, sample),\n    )\n"
  },
  {
    "path": "backend/app/api/routes/notifications.py",
    "content": "\"\"\"API routes for notification providers.\"\"\"\n\nimport json\nimport logging\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi import APIRouter, Depends, HTTPException, Query\nfrom sqlalchemy import delete, desc, func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.notification import NotificationLog, NotificationProvider\nfrom backend.app.models.user import User\nfrom backend.app.schemas.notification import (\n    NotificationLogResponse,\n    NotificationLogStats,\n    NotificationProviderCreate,\n    NotificationProviderResponse,\n    NotificationProviderUpdate,\n    NotificationTestRequest,\n    NotificationTestResponse,\n)\nfrom backend.app.services.notification_service import notification_service\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/notifications\", tags=[\"notifications\"])\n\n\ndef _provider_to_dict(provider: NotificationProvider) -> dict:\n    \"\"\"Convert a NotificationProvider model to a response dictionary.\"\"\"\n    return {\n        \"id\": provider.id,\n        \"name\": provider.name,\n        \"provider_type\": provider.provider_type,\n        \"enabled\": provider.enabled,\n        \"config\": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,\n        # Print lifecycle events\n        \"on_print_start\": provider.on_print_start,\n        \"on_print_complete\": provider.on_print_complete,\n        \"on_print_failed\": provider.on_print_failed,\n        \"on_print_stopped\": provider.on_print_stopped,\n        \"on_print_progress\": provider.on_print_progress,\n        \"on_print_missing_spool_assignment\": provider.on_print_missing_spool_assignment,\n        # Printer status events\n        \"on_printer_offline\": provider.on_printer_offline,\n        \"on_printer_error\": provider.on_printer_error,\n        \"on_filament_low\": provider.on_filament_low,\n        \"on_maintenance_due\": provider.on_maintenance_due,\n        # AMS environmental alarms (regular AMS)\n        \"on_ams_humidity_high\": provider.on_ams_humidity_high,\n        \"on_ams_temperature_high\": provider.on_ams_temperature_high,\n        # AMS-HT environmental alarms\n        \"on_ams_ht_humidity_high\": provider.on_ams_ht_humidity_high,\n        \"on_ams_ht_temperature_high\": provider.on_ams_ht_temperature_high,\n        # Build plate detection\n        \"on_plate_not_empty\": provider.on_plate_not_empty,\n        # Bed cooled\n        \"on_bed_cooled\": provider.on_bed_cooled,\n        # First layer complete\n        \"on_first_layer_complete\": provider.on_first_layer_complete,\n        # Print queue events\n        \"on_queue_job_added\": provider.on_queue_job_added,\n        \"on_queue_job_assigned\": provider.on_queue_job_assigned,\n        \"on_queue_job_started\": provider.on_queue_job_started,\n        \"on_queue_job_waiting\": provider.on_queue_job_waiting,\n        \"on_queue_job_skipped\": provider.on_queue_job_skipped,\n        \"on_queue_job_failed\": provider.on_queue_job_failed,\n        \"on_queue_completed\": provider.on_queue_completed,\n        # Quiet hours\n        \"quiet_hours_enabled\": provider.quiet_hours_enabled,\n        \"quiet_hours_start\": provider.quiet_hours_start,\n        \"quiet_hours_end\": provider.quiet_hours_end,\n        # Daily digest\n        \"daily_digest_enabled\": provider.daily_digest_enabled,\n        \"daily_digest_time\": provider.daily_digest_time,\n        # Printer filter\n        \"printer_id\": provider.printer_id,\n        # Status tracking\n        \"last_success\": provider.last_success,\n        \"last_error\": provider.last_error,\n        \"last_error_at\": provider.last_error_at,\n        # Timestamps\n        \"created_at\": provider.created_at,\n        \"updated_at\": provider.updated_at,\n    }\n\n\n# ============================================================================\n# Provider List/Create Routes (no path parameters)\n# ============================================================================\n\n\n@router.get(\"/\", response_model=list[NotificationProviderResponse])\nasync def list_notification_providers(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),\n):\n    \"\"\"List all notification providers.\"\"\"\n    result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))\n    providers = result.scalars().all()\n\n    return [_provider_to_dict(provider) for provider in providers]\n\n\n@router.post(\"/\", response_model=NotificationProviderResponse)\nasync def create_notification_provider(\n    provider_data: NotificationProviderCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),\n):\n    \"\"\"Create a new notification provider.\"\"\"\n    provider = NotificationProvider(\n        name=provider_data.name,\n        provider_type=provider_data.provider_type.value,\n        enabled=provider_data.enabled,\n        config=json.dumps(provider_data.config),\n        # Print lifecycle events\n        on_print_start=provider_data.on_print_start,\n        on_print_complete=provider_data.on_print_complete,\n        on_print_failed=provider_data.on_print_failed,\n        on_print_stopped=provider_data.on_print_stopped,\n        on_print_progress=provider_data.on_print_progress,\n        on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,\n        # Printer status events\n        on_printer_offline=provider_data.on_printer_offline,\n        on_printer_error=provider_data.on_printer_error,\n        on_filament_low=provider_data.on_filament_low,\n        on_maintenance_due=provider_data.on_maintenance_due,\n        # AMS environmental alarms (regular AMS)\n        on_ams_humidity_high=provider_data.on_ams_humidity_high,\n        on_ams_temperature_high=provider_data.on_ams_temperature_high,\n        # AMS-HT environmental alarms\n        on_ams_ht_humidity_high=provider_data.on_ams_ht_humidity_high,\n        on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,\n        # Build plate detection\n        on_plate_not_empty=provider_data.on_plate_not_empty,\n        # Bed cooled\n        on_bed_cooled=provider_data.on_bed_cooled,\n        # First layer complete\n        on_first_layer_complete=provider_data.on_first_layer_complete,\n        # Print queue events\n        on_queue_job_added=provider_data.on_queue_job_added,\n        on_queue_job_assigned=provider_data.on_queue_job_assigned,\n        on_queue_job_started=provider_data.on_queue_job_started,\n        on_queue_job_waiting=provider_data.on_queue_job_waiting,\n        on_queue_job_skipped=provider_data.on_queue_job_skipped,\n        on_queue_job_failed=provider_data.on_queue_job_failed,\n        on_queue_completed=provider_data.on_queue_completed,\n        # Quiet hours\n        quiet_hours_enabled=provider_data.quiet_hours_enabled,\n        quiet_hours_start=provider_data.quiet_hours_start,\n        quiet_hours_end=provider_data.quiet_hours_end,\n        # Daily digest\n        daily_digest_enabled=provider_data.daily_digest_enabled,\n        daily_digest_time=provider_data.daily_digest_time,\n        # Printer filter\n        printer_id=provider_data.printer_id,\n    )\n\n    db.add(provider)\n    await db.commit()\n    await db.refresh(provider)\n\n    logger.info(\"Created notification provider: %s (%s)\", provider.name, provider.provider_type)\n\n    return _provider_to_dict(provider)\n\n\n# ============================================================================\n# Static Path Routes (must come BEFORE parameterized routes)\n# ============================================================================\n\n\n@router.post(\"/test-config\", response_model=NotificationTestResponse)\nasync def test_notification_config(\n    test_request: NotificationTestRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),\n):\n    \"\"\"Test notification configuration before saving.\"\"\"\n    success, message = await notification_service.send_test_notification(\n        test_request.provider_type.value, test_request.config, db\n    )\n\n    return NotificationTestResponse(success=success, message=message)\n\n\n@router.post(\"/test-all\")\nasync def test_all_notification_providers(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),\n):\n    \"\"\"Send a test notification to all enabled providers.\"\"\"\n    result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))\n    providers = result.scalars().all()\n\n    if not providers:\n        return {\"tested\": 0, \"success\": 0, \"failed\": 0, \"results\": []}\n\n    results = []\n    success_count = 0\n    failed_count = 0\n\n    for provider in providers:\n        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config\n        success, message = await notification_service.send_test_notification(provider.provider_type, config, db)\n\n        # Update provider status\n        if success:\n            provider.last_success = datetime.now(timezone.utc)\n            success_count += 1\n        else:\n            provider.last_error = message\n            provider.last_error_at = datetime.now(timezone.utc)\n            failed_count += 1\n\n        results.append(\n            {\n                \"provider_id\": provider.id,\n                \"provider_name\": provider.name,\n                \"provider_type\": provider.provider_type,\n                \"success\": success,\n                \"message\": message,\n            }\n        )\n\n    await db.commit()\n\n    return {\n        \"tested\": len(providers),\n        \"success\": success_count,\n        \"failed\": failed_count,\n        \"results\": results,\n    }\n\n\n# ============================================================================\n# Notification Log Routes (must come BEFORE /{provider_id} routes)\n# ============================================================================\n\n\n@router.get(\"/logs\", response_model=list[NotificationLogResponse])\nasync def get_notification_logs(\n    limit: int = Query(default=100, ge=1, le=500),\n    offset: int = Query(default=0, ge=0),\n    provider_id: int | None = Query(default=None),\n    event_type: str | None = Query(default=None),\n    success: bool | None = Query(default=None),\n    days: int | None = Query(default=7, ge=1, le=90, description=\"Filter logs from the last N days\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),\n):\n    \"\"\"Get notification logs with optional filters.\"\"\"\n    query = select(NotificationLog).order_by(desc(NotificationLog.created_at))\n\n    # Apply filters\n    if provider_id is not None:\n        query = query.where(NotificationLog.provider_id == provider_id)\n    if event_type is not None:\n        query = query.where(NotificationLog.event_type == event_type)\n    if success is not None:\n        query = query.where(NotificationLog.success == success)\n    if days is not None:\n        cutoff = datetime.now(timezone.utc) - timedelta(days=days)\n        query = query.where(NotificationLog.created_at >= cutoff)\n\n    query = query.offset(offset).limit(limit)\n\n    result = await db.execute(query)\n    logs = result.scalars().all()\n\n    # Get provider info for each log\n    response = []\n    providers_cache: dict[int, NotificationProvider | None] = {}\n\n    for log in logs:\n        if log.provider_id not in providers_cache:\n            provider_result = await db.execute(\n                select(NotificationProvider).where(NotificationProvider.id == log.provider_id)\n            )\n            providers_cache[log.provider_id] = provider_result.scalar_one_or_none()\n\n        provider = providers_cache[log.provider_id]\n        response.append(\n            NotificationLogResponse(\n                id=log.id,\n                provider_id=log.provider_id,\n                provider_name=provider.name if provider else None,\n                provider_type=provider.provider_type if provider else None,\n                event_type=log.event_type,\n                title=log.title,\n                message=log.message,\n                success=log.success,\n                error_message=log.error_message,\n                printer_id=log.printer_id,\n                printer_name=log.printer_name,\n                created_at=log.created_at,\n            )\n        )\n\n    return response\n\n\n@router.get(\"/logs/stats\", response_model=NotificationLogStats)\nasync def get_notification_log_stats(\n    days: int = Query(default=7, ge=1, le=90, description=\"Statistics for the last N days\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),\n):\n    \"\"\"Get notification log statistics.\"\"\"\n    cutoff = datetime.now(timezone.utc) - timedelta(days=days)\n\n    # Total counts\n    total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))\n    total = total_result.scalar() or 0\n\n    success_result = await db.execute(\n        select(func.count(NotificationLog.id)).where(\n            NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)\n        )\n    )\n    success_count = success_result.scalar() or 0\n\n    # By event type\n    event_result = await db.execute(\n        select(NotificationLog.event_type, func.count(NotificationLog.id))\n        .where(NotificationLog.created_at >= cutoff)\n        .group_by(NotificationLog.event_type)\n    )\n    by_event_type = {row[0]: row[1] for row in event_result.fetchall()}\n\n    # By provider (need to join to get name)\n    provider_result = await db.execute(\n        select(NotificationProvider.name, func.count(NotificationLog.id))\n        .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)\n        .where(NotificationLog.created_at >= cutoff)\n        .group_by(NotificationProvider.name)\n    )\n    by_provider = {row[0]: row[1] for row in provider_result.fetchall()}\n\n    return NotificationLogStats(\n        total=total,\n        success_count=success_count,\n        failure_count=total - success_count,\n        by_event_type=by_event_type,\n        by_provider=by_provider,\n    )\n\n\n@router.delete(\"/logs\")\nasync def clear_notification_logs(\n    older_than_days: int = Query(default=30, ge=1, description=\"Delete logs older than N days\"),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),\n):\n    \"\"\"Clear old notification logs.\"\"\"\n    cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)\n\n    result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))\n    await db.commit()\n\n    deleted_count = result.rowcount\n    logger.info(\"Deleted %s notification logs older than %s days\", deleted_count, older_than_days)\n\n    return {\"deleted\": deleted_count, \"message\": f\"Deleted {deleted_count} logs older than {older_than_days} days\"}\n\n\n# ============================================================================\n# Provider Instance Routes (parameterized - must come LAST)\n# ============================================================================\n\n\n@router.get(\"/{provider_id}\", response_model=NotificationProviderResponse)\nasync def get_notification_provider(\n    provider_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),\n):\n    \"\"\"Get a specific notification provider.\"\"\"\n    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))\n    provider = result.scalar_one_or_none()\n\n    if not provider:\n        raise HTTPException(status_code=404, detail=\"Notification provider not found\")\n\n    return _provider_to_dict(provider)\n\n\n@router.patch(\"/{provider_id}\", response_model=NotificationProviderResponse)\nasync def update_notification_provider(\n    provider_id: int,\n    update_data: NotificationProviderUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),\n):\n    \"\"\"Update a notification provider.\"\"\"\n    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))\n    provider = result.scalar_one_or_none()\n\n    if not provider:\n        raise HTTPException(status_code=404, detail=\"Notification provider not found\")\n\n    # Update only provided fields\n    update_dict = update_data.model_dump(exclude_unset=True)\n\n    for key, value in update_dict.items():\n        if key == \"config\" and value is not None:\n            setattr(provider, key, json.dumps(value))\n        elif key == \"provider_type\" and value is not None:\n            setattr(provider, key, value.value)\n        else:\n            setattr(provider, key, value)\n\n    await db.commit()\n    await db.refresh(provider)\n\n    logger.info(\"Updated notification provider: %s\", provider.name)\n\n    return _provider_to_dict(provider)\n\n\n@router.delete(\"/{provider_id}\")\nasync def delete_notification_provider(\n    provider_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),\n):\n    \"\"\"Delete a notification provider.\"\"\"\n    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))\n    provider = result.scalar_one_or_none()\n\n    if not provider:\n        raise HTTPException(status_code=404, detail=\"Notification provider not found\")\n\n    name = provider.name\n    await db.delete(provider)\n    await db.commit()\n\n    logger.info(\"Deleted notification provider: %s\", name)\n\n    return {\"message\": f\"Notification provider '{name}' deleted\"}\n\n\n@router.post(\"/{provider_id}/test\", response_model=NotificationTestResponse)\nasync def test_notification_provider(\n    provider_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),\n):\n    \"\"\"Send a test notification using an existing provider.\"\"\"\n    result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))\n    provider = result.scalar_one_or_none()\n\n    if not provider:\n        raise HTTPException(status_code=404, detail=\"Notification provider not found\")\n\n    config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config\n    success, message = await notification_service.send_test_notification(provider.provider_type, config, db)\n\n    # Update provider status\n    if success:\n        provider.last_success = datetime.now(timezone.utc)\n    else:\n        provider.last_error = message\n        provider.last_error_at = datetime.now(timezone.utc)\n\n    await db.commit()\n\n    return NotificationTestResponse(success=success, message=message)\n"
  },
  {
    "path": "backend/app/api/routes/obico.py",
    "content": "\"\"\"API routes for Obico AI failure detection.\"\"\"\n\nimport logging\n\nfrom fastapi import APIRouter, HTTPException, Response\nfrom pydantic import BaseModel\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.user import User\nfrom backend.app.services.obico_detection import obico_detection_service, pop_frame\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/obico\", tags=[\"obico\"])\n\n\nclass TestConnectionRequest(BaseModel):\n    url: str\n\n\n@router.get(\"/status\")\nasync def get_status(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Scheduler status, per-printer classification, and recent detection history.\"\"\"\n    settings = await obico_detection_service._load_settings()\n    status = obico_detection_service.get_status()\n    return {\n        **status,\n        \"enabled\": settings[\"enabled\"],\n        \"ml_url\": settings[\"ml_url\"],\n        \"sensitivity\": settings[\"sensitivity\"],\n        \"action\": settings[\"action\"],\n        \"poll_interval\": settings[\"poll_interval\"],\n        \"external_url_configured\": bool(settings[\"external_url\"]),\n    }\n\n\n@router.post(\"/test-connection\")\nasync def test_connection(\n    req: TestConnectionRequest,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body.\"\"\"\n    if not req.url:\n        return {\"ok\": False, \"status_code\": None, \"body\": None, \"error\": \"URL is empty\"}\n    return await obico_detection_service.test_connection(req.url)\n\n\n@router.get(\"/cached-frame/{nonce}\")\nasync def cached_frame(nonce: str):\n    \"\"\"Serve a pre-captured JPEG to the Obico ML API.\n\n    The detection loop captures a snapshot locally (where we control the timeout),\n    stashes the bytes under a one-shot random nonce, then hands this URL to Obico's\n    ML API. Obico's hardcoded 5s read timeout never races our snapshot pipeline.\n\n    Unauthenticated: the unguessable 32-byte nonce is single-use and expires in\n    seconds, so exposing this path doesn't widen the camera access surface.\n    \"\"\"\n    data = await pop_frame(nonce)\n    if data is None:\n        raise HTTPException(status_code=404, detail=\"Frame not found or expired\")\n    return Response(\n        content=data,\n        media_type=\"image/jpeg\",\n        headers={\"Cache-Control\": \"no-store\"},\n    )\n"
  },
  {
    "path": "backend/app/api/routes/pending_uploads.py",
    "content": "\"\"\"API routes for pending uploads (virtual printer queue mode).\"\"\"\n\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.pending_upload import PendingUpload\nfrom backend.app.models.user import User\nfrom backend.app.services.archive import ArchiveService\n\nrouter = APIRouter(prefix=\"/pending-uploads\", tags=[\"pending-uploads\"])\n\n\nclass ArchiveRequest(BaseModel):\n    \"\"\"Request to archive a pending upload.\"\"\"\n\n    tags: str | None = None\n    notes: str | None = None\n    project_id: int | None = None\n\n\nclass PendingUploadResponse(BaseModel):\n    \"\"\"Response model for pending upload.\"\"\"\n\n    id: int\n    filename: str\n    file_size: int\n    source_ip: str | None\n    status: str\n    tags: str | None\n    notes: str | None\n    project_id: int | None\n    uploaded_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\n@router.get(\"/\", response_model=list[PendingUploadResponse])\nasync def list_pending_uploads(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),\n):\n    \"\"\"List all pending uploads.\"\"\"\n    result = await db.execute(\n        select(PendingUpload).where(PendingUpload.status == \"pending\").order_by(PendingUpload.uploaded_at.desc())\n    )\n\n    return result.scalars().all()\n\n\n@router.get(\"/count\")\nasync def get_pending_count(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),\n):\n    \"\"\"Get count of pending uploads.\"\"\"\n    result = await db.execute(select(PendingUpload).where(PendingUpload.status == \"pending\"))\n    count = len(result.scalars().all())\n\n    return {\"count\": count}\n\n\n# Note: Bulk operations must be defined BEFORE parameterized routes\n# to prevent FastAPI from matching /archive-all as /{upload_id}\n\n\n@router.post(\"/archive-all\")\nasync def archive_all_pending(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),\n):\n    \"\"\"Archive all pending uploads.\"\"\"\n    result = await db.execute(select(PendingUpload).where(PendingUpload.status == \"pending\"))\n    pending_uploads = result.scalars().all()\n\n    archived = 0\n    failed = 0\n\n    service = ArchiveService(db)\n\n    for pending in pending_uploads:\n        file_path = Path(pending.file_path)\n        if not file_path.exists():\n            pending.status = \"discarded\"\n            failed += 1\n            continue\n\n        try:\n            archive = await service.archive_print(\n                printer_id=None,\n                source_file=file_path,\n                print_data={\n                    \"status\": \"archived\",\n                    \"source\": \"virtual_printer\",\n                    \"source_ip\": pending.source_ip,\n                },\n            )\n\n            if archive:\n                pending.status = \"archived\"\n                pending.archived_id = archive.id\n                pending.archived_at = datetime.now(timezone.utc)\n                archived += 1\n\n                # Clean up temp file\n                try:\n                    file_path.unlink()\n                except OSError:\n                    pass  # Best-effort temp file cleanup after archiving\n            else:\n                failed += 1\n        except Exception:  # Mixed async DB + archive operations\n            failed += 1\n\n    await db.commit()\n\n    return {\n        \"archived\": archived,\n        \"failed\": failed,\n    }\n\n\n@router.delete(\"/discard-all\")\nasync def discard_all_pending(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),\n):\n    \"\"\"Discard all pending uploads.\"\"\"\n    result = await db.execute(select(PendingUpload).where(PendingUpload.status == \"pending\"))\n    pending_uploads = result.scalars().all()\n\n    discarded = 0\n\n    for pending in pending_uploads:\n        # Delete file from disk\n        try:\n            file_path = Path(pending.file_path)\n            file_path.unlink(missing_ok=True)\n        except OSError:\n            pass  # Best-effort file deletion; record is still marked discarded\n\n        pending.status = \"discarded\"\n        discarded += 1\n\n    await db.commit()\n\n    return {\"discarded\": discarded}\n\n\n@router.get(\"/{upload_id}\", response_model=PendingUploadResponse)\nasync def get_pending_upload(\n    upload_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),\n):\n    \"\"\"Get a specific pending upload.\"\"\"\n    result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))\n    pending = result.scalar_one_or_none()\n\n    if not pending:\n        raise HTTPException(status_code=404, detail=\"Upload not found\")\n\n    return pending\n\n\n@router.post(\"/{upload_id}/archive\")\nasync def archive_pending_upload(\n    upload_id: int,\n    request: ArchiveRequest = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),\n):\n    \"\"\"Archive a pending upload.\"\"\"\n    result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))\n    pending = result.scalar_one_or_none()\n\n    if not pending:\n        raise HTTPException(status_code=404, detail=\"Upload not found\")\n    if pending.status != \"pending\":\n        raise HTTPException(status_code=400, detail=\"Upload already processed\")\n\n    # Check file exists\n    file_path = Path(pending.file_path)\n    if not file_path.exists():\n        raise HTTPException(status_code=404, detail=\"Upload file not found on disk\")\n\n    # Archive the file\n    service = ArchiveService(db)\n    archive = await service.archive_print(\n        printer_id=None,\n        source_file=file_path,\n        print_data={\n            \"status\": \"archived\",\n            \"source\": \"virtual_printer\",\n            \"source_ip\": pending.source_ip,\n        },\n    )\n\n    if not archive:\n        raise HTTPException(status_code=500, detail=\"Failed to archive file\")\n\n    # Apply tags/notes/project from request\n    if request:\n        if request.tags:\n            archive.tags = request.tags\n        if request.notes:\n            archive.notes = request.notes\n        if request.project_id:\n            archive.project_id = request.project_id\n\n    # Update pending record\n    pending.status = \"archived\"\n    pending.archived_id = archive.id\n    pending.archived_at = datetime.now(timezone.utc)\n    if request:\n        pending.tags = request.tags\n        pending.notes = request.notes\n        pending.project_id = request.project_id\n\n    await db.commit()\n\n    # Clean up temp file\n    try:\n        file_path.unlink()\n    except OSError:\n        pass  # Best-effort temp file cleanup after successful archive\n\n    return {\n        \"id\": archive.id,\n        \"print_name\": archive.print_name,\n        \"filename\": archive.filename,\n    }\n\n\n@router.delete(\"/{upload_id}\")\nasync def discard_pending_upload(\n    upload_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),\n):\n    \"\"\"Discard a pending upload without archiving.\"\"\"\n    result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))\n    pending = result.scalar_one_or_none()\n\n    if not pending:\n        raise HTTPException(status_code=404, detail=\"Upload not found\")\n\n    # Delete file from disk\n    file_path = Path(pending.file_path)\n    try:\n        file_path.unlink(missing_ok=True)\n    except OSError:\n        pass  # Best-effort file deletion on discard\n\n    # Update status\n    pending.status = \"discarded\"\n    await db.commit()\n\n    return {\"success\": True}\n"
  },
  {
    "path": "backend/app/api/routes/print_log.py",
    "content": "import logging\nfrom datetime import datetime\n\nfrom fastapi import APIRouter, Depends, HTTPException, Query\nfrom fastapi.responses import FileResponse\nfrom sqlalchemy import delete, func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled\nfrom backend.app.core.config import settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.print_log import PrintLogEntry\nfrom backend.app.models.user import User\nfrom backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/print-log\", tags=[\"print-log\"])\n\n\n@router.get(\"/\", response_model=PrintLogResponse)\nasync def get_print_log(\n    search: str | None = None,\n    printer_id: int | None = None,\n    created_by_username: str | None = None,\n    status: str | None = None,\n    date_from: datetime | None = None,\n    date_to: datetime | None = None,\n    limit: int = Query(default=50, ge=1, le=500),\n    offset: int = Query(default=0, ge=0),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),\n):\n    \"\"\"Get the print log.\"\"\"\n    query = select(PrintLogEntry)\n    count_query = select(func.count(PrintLogEntry.id))\n\n    if printer_id is not None:\n        query = query.where(PrintLogEntry.printer_id == printer_id)\n        count_query = count_query.where(PrintLogEntry.printer_id == printer_id)\n    if created_by_username:\n        query = query.where(PrintLogEntry.created_by_username == created_by_username)\n        count_query = count_query.where(PrintLogEntry.created_by_username == created_by_username)\n    if status:\n        query = query.where(PrintLogEntry.status == status)\n        count_query = count_query.where(PrintLogEntry.status == status)\n    if search:\n        query = query.where(PrintLogEntry.print_name.ilike(f\"%{search}%\"))\n        count_query = count_query.where(PrintLogEntry.print_name.ilike(f\"%{search}%\"))\n    if date_from:\n        query = query.where(PrintLogEntry.created_at >= date_from)\n        count_query = count_query.where(PrintLogEntry.created_at >= date_from)\n    if date_to:\n        query = query.where(PrintLogEntry.created_at <= date_to)\n        count_query = count_query.where(PrintLogEntry.created_at <= date_to)\n\n    # Get total count\n    total_result = await db.execute(count_query)\n    total = total_result.scalar() or 0\n\n    # Get paginated results\n    query = query.order_by(PrintLogEntry.created_at.desc()).offset(offset).limit(limit)\n    result = await db.execute(query)\n    entries = result.scalars().all()\n\n    return PrintLogResponse(\n        items=[\n            PrintLogEntrySchema(\n                id=e.id,\n                print_name=e.print_name,\n                printer_name=e.printer_name,\n                printer_id=e.printer_id,\n                status=e.status,\n                started_at=e.started_at,\n                completed_at=e.completed_at,\n                duration_seconds=e.duration_seconds,\n                filament_type=e.filament_type,\n                filament_color=e.filament_color,\n                filament_used_grams=e.filament_used_grams,\n                thumbnail_path=e.thumbnail_path,\n                created_by_username=e.created_by_username,\n                created_at=e.created_at,\n            )\n            for e in entries\n        ],\n        total=total,\n    )\n\n\n@router.get(\"/{entry_id}/thumbnail\")\nasync def get_print_log_thumbnail(\n    entry_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get the thumbnail for a print log entry.\n\n    Requires a stream token query param (?token=xxx) when auth is enabled.\n    \"\"\"\n    entry = await db.get(PrintLogEntry, entry_id)\n    if not entry or not entry.thumbnail_path:\n        raise HTTPException(404, \"Thumbnail not found\")\n\n    thumb_path = settings.base_dir / entry.thumbnail_path\n    if not thumb_path.exists():\n        raise HTTPException(404, \"Thumbnail file not found\")\n\n    return FileResponse(\n        path=thumb_path,\n        media_type=\"image/png\",\n        headers={\"Cache-Control\": \"public, max-age=86400\"},\n    )\n\n\n@router.delete(\"/\")\nasync def clear_print_log(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),\n):\n    \"\"\"Clear the print log.\n\n    Only deletes log entries. Archives and queue items are never touched.\n    \"\"\"\n    result = await db.execute(delete(PrintLogEntry))\n    deleted = result.rowcount\n    await db.commit()\n\n    logger.info(\"Print log cleared: %d entries deleted\", deleted)\n    return {\"deleted\": deleted}\n"
  },
  {
    "path": "backend/app/api/routes/print_queue.py",
    "content": "\"\"\"API routes for print queue management.\"\"\"\n\nimport json\nimport logging\nimport zipfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nimport defusedxml.ElementTree as ET\nfrom fastapi import APIRouter, Depends, HTTPException, Query\nfrom sqlalchemy import and_, func, or_, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission\nfrom backend.app.core.config import settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.library import LibraryFile\nfrom backend.app.models.print_batch import PrintBatch\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.project import Project\nfrom backend.app.models.user import User\nfrom backend.app.schemas.print_queue import (\n    PrintBatchResponse,\n    PrintQueueBulkUpdate,\n    PrintQueueBulkUpdateResponse,\n    PrintQueueItemCreate,\n    PrintQueueItemResponse,\n    PrintQueueItemUpdate,\n    PrintQueueReorder,\n)\nfrom backend.app.services.notification_service import notification_service\nfrom backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id\nfrom backend.app.utils.threemf_tools import extract_filament_usage_from_3mf\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/queue\", tags=[\"queue\"])\n\n\ndef _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = None) -> list[str]:\n    \"\"\"Extract unique filament types from a 3MF file.\n\n    Args:\n        file_path: Path to the 3MF file\n        plate_id: Optional plate index to filter for (for multi-plate files)\n\n    Returns:\n        List of unique filament types (e.g., [\"PLA\", \"PETG\"])\n    \"\"\"\n    types: set[str] = set()\n\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            if \"Metadata/slice_info.config\" not in zf.namelist():\n                return []\n\n            content = zf.read(\"Metadata/slice_info.config\").decode()\n            root = ET.fromstring(content)\n\n            if plate_id is not None:\n                # Find the plate element with matching index\n                for plate_elem in root.findall(\".//plate\"):\n                    plate_index = None\n                    for meta in plate_elem.findall(\"metadata\"):\n                        if meta.get(\"key\") == \"index\":\n                            try:\n                                plate_index = int(meta.get(\"value\", \"0\"))\n                            except ValueError:\n                                pass  # Skip plate with unparseable index\n                            break\n\n                    if plate_index == plate_id:\n                        for filament_elem in plate_elem.findall(\"filament\"):\n                            filament_type = filament_elem.get(\"type\", \"\")\n                            used_g = filament_elem.get(\"used_g\", \"0\")\n                            try:\n                                used_grams = float(used_g)\n                            except (ValueError, TypeError):\n                                used_grams = 0\n                            if used_grams > 0 and filament_type:\n                                types.add(filament_type)\n                        break\n            else:\n                # No plate_id specified - extract all filaments with used_g > 0\n                for filament_elem in root.findall(\".//filament\"):\n                    filament_type = filament_elem.get(\"type\", \"\")\n                    used_g = filament_elem.get(\"used_g\", \"0\")\n                    try:\n                        used_grams = float(used_g)\n                    except (ValueError, TypeError):\n                        used_grams = 0\n                    if used_grams > 0 and filament_type:\n                        types.add(filament_type)\n\n    except Exception as e:\n        logger.warning(\"Failed to extract filament types from %s: %s\", file_path, e)\n\n    return sorted(types)\n\n\ndef _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:\n    \"\"\"Extract print time (prediction) from a 3MF file.\n\n    Args:\n        file_path: Path to the 3MF file\n        plate_id: Optional plate index to filter for (for multi-plate files)\n\n    Returns:\n        Print time in seconds, or None if not found\n    \"\"\"\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            if \"Metadata/slice_info.config\" not in zf.namelist():\n                return None\n\n            content = zf.read(\"Metadata/slice_info.config\").decode()\n            root = ET.fromstring(content)\n\n            if plate_id is not None:\n                for plate_elem in root.findall(\".//plate\"):\n                    plate_index = None\n                    for meta in plate_elem.findall(\"metadata\"):\n                        if meta.get(\"key\") == \"index\":\n                            try:\n                                plate_index = int(meta.get(\"value\", \"0\"))\n                            except ValueError:\n                                pass  # Skip plate with unparseable index\n                            break\n\n                    if plate_index == plate_id:\n                        for meta in plate_elem.findall(\"metadata\"):\n                            if meta.get(\"key\") == \"prediction\":\n                                try:\n                                    return int(meta.get(\"value\", \"0\"))\n                                except ValueError:\n                                    return None\n                        break\n            else:\n                plate_elem = root.find(\".//plate\")\n                if plate_elem is not None:\n                    for meta in plate_elem.findall(\"metadata\"):\n                        if meta.get(\"key\") == \"prediction\":\n                            try:\n                                return int(meta.get(\"value\", \"0\"))\n                            except ValueError:\n                                return None\n    except Exception as e:\n        logger.warning(\"Failed to extract print time from %s: %s\", file_path, e)\n\n    return None\n\n\ndef _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:\n    \"\"\"Add nested archive/printer/library_file info to response.\"\"\"\n    # Parse ams_mapping from JSON string BEFORE model_validate\n    ams_mapping_parsed = None\n    if item.ams_mapping:\n        try:\n            ams_mapping_parsed = json.loads(item.ams_mapping)\n        except json.JSONDecodeError:\n            ams_mapping_parsed = None\n\n    # Parse required_filament_types from JSON string\n    required_filament_types_parsed = None\n    if item.required_filament_types:\n        try:\n            required_filament_types_parsed = json.loads(item.required_filament_types)\n        except json.JSONDecodeError:\n            required_filament_types_parsed = None\n\n    # Parse filament_overrides from JSON string\n    filament_overrides_parsed = None\n    if item.filament_overrides:\n        try:\n            filament_overrides_parsed = json.loads(item.filament_overrides)\n        except json.JSONDecodeError:\n            filament_overrides_parsed = None\n\n    # Create response with parsed ams_mapping\n    item_dict = {\n        \"id\": item.id,\n        \"printer_id\": item.printer_id,\n        \"target_model\": item.target_model,\n        \"target_location\": item.target_location,\n        \"required_filament_types\": required_filament_types_parsed,\n        \"filament_overrides\": filament_overrides_parsed,\n        \"waiting_reason\": item.waiting_reason,\n        \"archive_id\": item.archive_id,\n        \"library_file_id\": item.library_file_id,\n        \"position\": item.position,\n        \"scheduled_time\": item.scheduled_time,\n        \"require_previous_success\": item.require_previous_success,\n        \"auto_off_after\": item.auto_off_after,\n        \"manual_start\": item.manual_start,\n        \"ams_mapping\": ams_mapping_parsed,\n        \"plate_id\": item.plate_id,\n        \"bed_levelling\": item.bed_levelling,\n        \"flow_cali\": item.flow_cali,\n        \"vibration_cali\": item.vibration_cali,\n        \"layer_inspect\": item.layer_inspect,\n        \"timelapse\": item.timelapse,\n        \"use_ams\": item.use_ams,\n        \"status\": item.status,\n        \"started_at\": item.started_at,\n        \"completed_at\": item.completed_at,\n        \"error_message\": item.error_message,\n        \"created_at\": item.created_at,\n        # User tracking (Issue #206)\n        \"created_by_id\": item.created_by_id,\n        \"created_by_username\": item.created_by.username if item.created_by else None,\n        # Batch grouping\n        \"batch_id\": item.batch_id,\n        \"batch_name\": item.batch.name if item.batch else None,\n        # SJF scheduling\n        \"been_jumped\": item.been_jumped,\n        # Auto-print G-code injection\n        \"gcode_injection\": item.gcode_injection,\n    }\n    response = PrintQueueItemResponse(**item_dict)\n    if item.archive:\n        response.archive_name = item.archive.print_name or item.archive.filename\n        response.archive_thumbnail = item.archive.thumbnail_path\n        response.print_time_seconds = item.archive.print_time_seconds\n        response.filament_used_grams = item.archive.filament_used_grams\n        response.filament_type = item.archive.filament_type\n        response.filament_color = item.archive.filament_color\n        response.layer_height = item.archive.layer_height\n        response.nozzle_diameter = item.archive.nozzle_diameter\n        response.sliced_for_model = item.archive.sliced_for_model\n        if item.plate_id:\n            archive_path = settings.base_dir / item.archive.file_path\n            if archive_path.exists():\n                plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)\n                plate_weight = sum(f[\"used_g\"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id))\n                if plate_time is not None:\n                    response.print_time_seconds = plate_time\n                if plate_weight > 0:\n                    response.filament_used_grams = plate_weight\n    if item.library_file:\n        response.library_file_name = (\n            item.library_file.file_metadata.get(\"print_name\") if item.library_file.file_metadata else None\n        )\n        if not response.library_file_name:\n            response.library_file_name = item.library_file.filename\n        response.library_file_thumbnail = item.library_file.thumbnail_path\n        # Get metadata from library file if no archive\n        if not item.archive and item.library_file.file_metadata:\n            response.print_time_seconds = item.library_file.file_metadata.get(\"print_time_seconds\")\n            response.filament_used_grams = item.library_file.file_metadata.get(\"filament_used_grams\")\n            response.filament_type = item.library_file.file_metadata.get(\"filament_type\")\n            response.filament_color = item.library_file.file_metadata.get(\"filament_color\")\n            response.layer_height = item.library_file.file_metadata.get(\"layer_height\")\n            response.nozzle_diameter = item.library_file.file_metadata.get(\"nozzle_diameter\")\n            response.sliced_for_model = item.library_file.file_metadata.get(\"sliced_for_model\")\n        if item.plate_id:\n            lib_path = Path(item.library_file.file_path)\n            library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path\n            if library_file_path.exists():\n                plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)\n                plate_weight = sum(\n                    f[\"used_g\"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)\n                )\n                if plate_time is not None:\n                    response.print_time_seconds = plate_time\n                if plate_weight > 0:\n                    response.filament_used_grams = plate_weight\n    if item.printer:\n        response.printer_name = item.printer.name\n    return response\n\n\n@router.get(\"/\", response_model=list[PrintQueueItemResponse])\nasync def list_queue(\n    printer_id: int | None = Query(None, description=\"Filter by printer (-1 for unassigned)\"),\n    status: str | None = Query(None, description=\"Filter by status\"),\n    target_model: str | None = Query(\n        None, description=\"Filter by target model (also includes model-based items when combined with printer_id)\"\n    ),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),\n):\n    \"\"\"List all queue items, optionally filtered by printer or status.\"\"\"\n    query = (\n        select(PrintQueueItem)\n        .options(\n            selectinload(PrintQueueItem.archive),\n            selectinload(PrintQueueItem.printer),\n            selectinload(PrintQueueItem.library_file),\n            selectinload(PrintQueueItem.created_by),\n            selectinload(PrintQueueItem.batch),\n        )\n        .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)\n    )\n\n    if printer_id is not None:\n        if printer_id == -1:\n            # Special value: filter for unassigned items\n            query = query.where(PrintQueueItem.printer_id.is_(None))\n        else:\n            # Resolve effective model: prefer explicit param, fall back to printer's DB model.\n            # This ensures model-based \"Any X\" items are returned even when the frontend\n            # doesn't send target_model (e.g. printer.model is NULL on the client side).\n            effective_model = target_model\n            if not effective_model:\n                printer_row = (\n                    await db.execute(select(Printer.model).where(Printer.id == printer_id))\n                ).scalar_one_or_none()\n                effective_model = printer_row\n\n            if effective_model:\n                # Include both printer-specific items AND model-based (unassigned) items\n                query = query.where(\n                    or_(\n                        PrintQueueItem.printer_id == printer_id,\n                        and_(\n                            PrintQueueItem.printer_id.is_(None),\n                            func.lower(PrintQueueItem.target_model) == effective_model.lower(),\n                        ),\n                    )\n                )\n            else:\n                query = query.where(PrintQueueItem.printer_id == printer_id)\n    elif target_model:\n        query = query.where(func.lower(PrintQueueItem.target_model) == target_model.lower())\n    if status:\n        query = query.where(PrintQueueItem.status == status)\n\n    result = await db.execute(query)\n    items = result.scalars().all()\n    return [_enrich_response(item) for item in items]\n\n\n@router.post(\"/\", response_model=PrintQueueItemResponse)\nasync def add_to_queue(\n    data: PrintQueueItemCreate,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),\n):\n    \"\"\"Add an item to the print queue.\"\"\"\n    # Normalize target_model (e.g., \"Bambu Lab X1E\" / \"C13\" -> \"X1E\")\n    target_model_norm = None\n    if data.target_model:\n        target_model_norm = (\n            normalize_printer_model(data.target_model)\n            or normalize_printer_model_id(data.target_model)\n            or data.target_model\n        )\n\n    # Validate that either archive_id or library_file_id is provided\n    if not data.archive_id and not data.library_file_id:\n        raise HTTPException(400, \"Either archive_id or library_file_id must be provided\")\n\n    # Cannot specify both printer_id and target_model\n    if data.printer_id and target_model_norm:\n        raise HTTPException(400, \"Cannot specify both printer_id and target_model\")\n\n    # Validate printer exists (if assigned)\n    if data.printer_id is not None:\n        result = await db.execute(select(Printer).where(Printer.id == data.printer_id))\n        if not result.scalar_one_or_none():\n            raise HTTPException(400, \"Printer not found\")\n\n    # Validate target_model has active printers\n    if target_model_norm:\n        result = await db.execute(\n            select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True)  # noqa: E712\n        )\n        if not result.scalars().first():\n            raise HTTPException(400, f\"No active printers for model: {target_model_norm}\")\n\n    # Validate archive exists (if provided) and get it for filament extraction\n    archive = None\n    if data.archive_id:\n        result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))\n        archive = result.scalar_one_or_none()\n        if not archive:\n            raise HTTPException(400, \"Archive not found\")\n\n    # Validate library file exists (if provided) and get it for filament extraction\n    library_file = None\n    if data.library_file_id:\n        result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))\n        library_file = result.scalar_one_or_none()\n        if not library_file:\n            raise HTTPException(400, \"Library file not found\")\n\n    # Extract filament types for model-based assignment (used by scheduler for validation)\n    required_filament_types = None\n    if target_model_norm:\n        # Get file path from archive or library file\n        file_path = None\n        if archive:\n            file_path = settings.base_dir / archive.file_path\n        elif library_file:\n            lib_path = Path(library_file.file_path)\n            file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path\n\n        if file_path and file_path.exists():\n            filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)\n            if filament_types:\n                required_filament_types = json.dumps(filament_types)\n                logger.info(\"Extracted filament types for model-based queue: %s\", filament_types)\n\n    # If filament overrides are provided, update required_filament_types to match override types\n    filament_overrides_json = None\n    if data.filament_overrides and target_model_norm:\n        filament_overrides_json = json.dumps(data.filament_overrides)\n        # Update required_filament_types from overrides so scheduler validates against overridden types\n        override_types = sorted({o[\"type\"] for o in data.filament_overrides if \"type\" in o})\n        if override_types:\n            # Merge with existing types (overrides may only cover some slots)\n            existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()\n            # Replace types for overridden slots, keep others\n            all_types = existing_types | set(override_types)\n            required_filament_types = json.dumps(sorted(all_types))\n\n    # Validate quantity\n    quantity = max(1, data.quantity)\n\n    # Create batch if quantity > 1\n    batch = None\n    batch_id = None\n    if quantity > 1:\n        # Derive batch name from source file\n        batch_name_base = \"Batch\"\n        if archive:\n            batch_name_base = archive.print_name or archive.filename or \"Batch\"\n        elif library_file:\n            if library_file.file_metadata:\n                batch_name_base = library_file.file_metadata.get(\"print_name\") or library_file.filename\n            else:\n                batch_name_base = library_file.filename\n        batch_name_base = batch_name_base.replace(\".gcode.3mf\", \"\").replace(\".3mf\", \"\")\n\n        batch = PrintBatch(\n            name=f\"{batch_name_base} ×{quantity}\",\n            archive_id=data.archive_id,\n            library_file_id=data.library_file_id,\n            quantity=quantity,\n            status=\"active\",\n            created_by_id=current_user.id if current_user else None,\n        )\n        db.add(batch)\n        await db.flush()  # Get batch.id before creating items\n        batch_id = batch.id\n\n    # Get next position for this printer (or for unassigned/model-based items)\n    if data.printer_id is not None:\n        result = await db.execute(\n            select(func.max(PrintQueueItem.position))\n            .where(PrintQueueItem.printer_id == data.printer_id)\n            .where(PrintQueueItem.status == \"pending\")\n        )\n    else:\n        # For unassigned/model-based items, get max position across all unassigned\n        result = await db.execute(\n            select(func.max(PrintQueueItem.position))\n            .where(PrintQueueItem.printer_id.is_(None))\n            .where(PrintQueueItem.status == \"pending\")\n        )\n    max_pos = result.scalar() or 0\n\n    # Resolve print_time_seconds for SJF scheduling (cache on item at creation)\n    cached_print_time = None\n    if archive:\n        cached_print_time = archive.print_time_seconds\n        if data.plate_id:\n            archive_path = settings.base_dir / archive.file_path\n            if archive_path.exists():\n                plate_time = _extract_print_time_from_3mf(archive_path, data.plate_id)\n                if plate_time is not None:\n                    cached_print_time = plate_time\n    elif library_file:\n        if library_file.file_metadata:\n            cached_print_time = library_file.file_metadata.get(\"print_time_seconds\")\n        if data.plate_id:\n            lib_path = Path(library_file.file_path)\n            library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path\n            if library_file_path.exists():\n                plate_time = _extract_print_time_from_3mf(library_file_path, data.plate_id)\n                if plate_time is not None:\n                    cached_print_time = plate_time\n\n    # Validate project exists before insert so a bogus ID yields 404, not an FK-constraint 500\n    if data.project_id is not None:\n        project_result = await db.execute(select(Project).where(Project.id == data.project_id))\n        if not project_result.scalar_one_or_none():\n            raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None\n    items = []\n    for i in range(quantity):\n        item = PrintQueueItem(\n            printer_id=data.printer_id,\n            target_model=target_model_norm,\n            target_location=data.target_location,\n            required_filament_types=required_filament_types,\n            filament_overrides=filament_overrides_json,\n            archive_id=data.archive_id,\n            library_file_id=data.library_file_id,\n            scheduled_time=data.scheduled_time,\n            require_previous_success=data.require_previous_success,\n            auto_off_after=data.auto_off_after,\n            manual_start=data.manual_start,\n            ams_mapping=ams_mapping_json,\n            plate_id=data.plate_id,\n            bed_levelling=data.bed_levelling,\n            flow_cali=data.flow_cali,\n            vibration_cali=data.vibration_cali,\n            layer_inspect=data.layer_inspect,\n            timelapse=data.timelapse,\n            use_ams=data.use_ams,\n            gcode_injection=data.gcode_injection,\n            project_id=data.project_id,\n            position=max_pos + 1 + i,\n            status=\"pending\",\n            created_by_id=current_user.id if current_user else None,\n            batch_id=batch_id,\n            print_time_seconds=cached_print_time,\n        )\n        db.add(item)\n        items.append(item)\n\n    await db.commit()\n\n    # Refresh the first item for the response\n    item = items[0]\n    await db.refresh(item)\n    await db.refresh(item, [\"archive\", \"printer\", \"library_file\", \"created_by\", \"batch\"])\n\n    source_name = f\"archive {data.archive_id}\" if data.archive_id else f\"library file {data.library_file_id}\"\n    target_desc = data.printer_id or (f\"model {target_model_norm}\" if target_model_norm else \"unassigned\")\n    qty_desc = f\" (×{quantity})\" if quantity > 1 else \"\"\n    logger.info(\"Added %s to queue for %s%s\", source_name, target_desc, qty_desc)\n\n    # MQTT relay - publish queue job added\n    try:\n        from backend.app.services.mqtt_relay import mqtt_relay\n\n        await mqtt_relay.on_queue_job_added(\n            job_id=item.id,\n            filename=item.archive.filename if item.archive else \"\",\n            printer_id=item.printer_id,\n            printer_name=item.printer.name if item.printer else None,\n        )\n    except Exception:\n        pass  # Don't fail queue add if MQTT fails\n\n    # Send notification for job added\n    try:\n        job_name = (\n            item.archive.filename\n            if item.archive\n            else item.library_file.filename\n            if item.library_file\n            else f\"Job #{item.id}\"\n        )\n        job_name = job_name.replace(\".gcode.3mf\", \"\").replace(\".3mf\", \"\")\n        if quantity > 1:\n            job_name = f\"{job_name} ×{quantity}\"\n        target = (\n            item.printer.name if item.printer else (f\"Any {item.target_model}\" if target_model_norm else \"Unassigned\")\n        )\n        await notification_service.on_queue_job_added(\n            job_name=job_name,\n            target=target,\n            db=db,\n            printer_id=item.printer_id,\n            printer_name=item.printer.name if item.printer else None,\n        )\n    except Exception:\n        pass  # Don't fail queue add if notification fails\n\n    return _enrich_response(item)\n\n\n@router.patch(\"/bulk\", response_model=PrintQueueBulkUpdateResponse)\nasync def bulk_update_queue_items(\n    data: PrintQueueBulkUpdate,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.QUEUE_UPDATE_ALL,\n            Permission.QUEUE_UPDATE_OWN,\n        )\n    ),\n):\n    \"\"\"Bulk update multiple queue items with the same values.\n\n    Only pending items can be updated. Non-pending items are skipped.\n    Items not owned by the user are also skipped (unless user has *_all permission).\n    \"\"\"\n    user, can_modify_all = auth_result\n\n    if not data.item_ids:\n        raise HTTPException(400, \"No item IDs provided\")\n\n    # Get fields to update (exclude item_ids and unset fields)\n    update_data = data.model_dump(exclude={\"item_ids\"}, exclude_unset=True)\n    if not update_data:\n        raise HTTPException(400, \"No fields to update\")\n\n    # Validate printer_id if being changed\n    if \"printer_id\" in update_data and update_data[\"printer_id\"] is not None:\n        result = await db.execute(select(Printer).where(Printer.id == update_data[\"printer_id\"]))\n        if not result.scalar_one_or_none():\n            raise HTTPException(400, \"Printer not found\")\n\n    # Fetch all items\n    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))\n    items = result.scalars().all()\n\n    updated_count = 0\n    skipped_count = 0\n\n    for item in items:\n        if item.status != \"pending\":\n            skipped_count += 1\n            continue\n\n        # Ownership check\n        if not can_modify_all and item.created_by_id != user.id:\n            skipped_count += 1\n            continue\n\n        for field, value in update_data.items():\n            setattr(item, field, value)\n        updated_count += 1\n\n    await db.commit()\n\n    logger.info(\"Bulk updated %s queue items, skipped %s\", updated_count, skipped_count)\n    return PrintQueueBulkUpdateResponse(\n        updated_count=updated_count,\n        skipped_count=skipped_count,\n        message=f\"Updated {updated_count} items\"\n        + (f\", skipped {skipped_count} non-pending/not-owned\" if skipped_count else \"\"),\n    )\n\n\n# --- Batch endpoints ---\n\n\n@router.get(\"/batches\", response_model=list[PrintBatchResponse])\nasync def list_batches(\n    status: str | None = Query(None, description=\"Filter by status (active, completed, cancelled)\"),\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),\n):\n    \"\"\"List all print batches with progress stats.\"\"\"\n    query = select(PrintBatch).order_by(PrintBatch.created_at.desc())\n    if status:\n        query = query.where(PrintBatch.status == status)\n    result = await db.execute(query)\n    batches = result.scalars().all()\n\n    responses = []\n    for batch in batches:\n        responses.append(await _build_batch_response(db, batch))\n    return responses\n\n\n@router.get(\"/batches/{batch_id}\", response_model=PrintBatchResponse)\nasync def get_batch(\n    batch_id: int,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),\n):\n    \"\"\"Get a print batch with progress stats.\"\"\"\n    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))\n    batch = result.scalar_one_or_none()\n    if not batch:\n        raise HTTPException(404, \"Batch not found\")\n    return await _build_batch_response(db, batch)\n\n\n@router.delete(\"/batches/{batch_id}\")\nasync def cancel_batch(\n    batch_id: int,\n    db: AsyncSession = Depends(get_db),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),\n):\n    \"\"\"Cancel all pending items in a batch and mark batch as cancelled.\"\"\"\n    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))\n    batch = result.scalar_one_or_none()\n    if not batch:\n        raise HTTPException(404, \"Batch not found\")\n\n    # Cancel all pending queue items in this batch\n    result = await db.execute(\n        select(PrintQueueItem).where(and_(PrintQueueItem.batch_id == batch_id, PrintQueueItem.status == \"pending\"))\n    )\n    pending_items = result.scalars().all()\n    cancelled_count = 0\n    for item in pending_items:\n        item.status = \"cancelled\"\n        cancelled_count += 1\n\n    batch.status = \"cancelled\"\n    await db.commit()\n\n    return {\"message\": f\"Batch cancelled, {cancelled_count} pending items cancelled\"}\n\n\nasync def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBatchResponse:\n    \"\"\"Build a batch response with derived counts from queue items.\"\"\"\n    # Count queue items by status\n    result = await db.execute(\n        select(PrintQueueItem.status, func.count(PrintQueueItem.id))\n        .where(PrintQueueItem.batch_id == batch.id)\n        .group_by(PrintQueueItem.status)\n    )\n    status_counts = {row[0]: row[1] for row in result.fetchall()}\n\n    # Load created_by for username\n    created_by_username = None\n    if batch.created_by_id:\n        result = await db.execute(select(User).where(User.id == batch.created_by_id))\n        user = result.scalar_one_or_none()\n        if user:\n            created_by_username = user.username\n\n    return PrintBatchResponse(\n        id=batch.id,\n        name=batch.name,\n        archive_id=batch.archive_id,\n        library_file_id=batch.library_file_id,\n        quantity=batch.quantity,\n        status=batch.status,\n        created_at=batch.created_at,\n        created_by_id=batch.created_by_id,\n        created_by_username=created_by_username,\n        pending_count=status_counts.get(\"pending\", 0),\n        printing_count=status_counts.get(\"printing\", 0),\n        completed_count=status_counts.get(\"completed\", 0),\n        failed_count=status_counts.get(\"failed\", 0),\n        cancelled_count=status_counts.get(\"cancelled\", 0),\n    )\n\n\n@router.get(\"/{item_id}\", response_model=PrintQueueItemResponse)\nasync def get_queue_item(\n    item_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),\n):\n    \"\"\"Get a specific queue item.\"\"\"\n    result = await db.execute(\n        select(PrintQueueItem)\n        .options(\n            selectinload(PrintQueueItem.archive),\n            selectinload(PrintQueueItem.printer),\n            selectinload(PrintQueueItem.library_file),\n            selectinload(PrintQueueItem.created_by),\n            selectinload(PrintQueueItem.batch),\n        )\n        .where(PrintQueueItem.id == item_id)\n    )\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(404, \"Queue item not found\")\n    return _enrich_response(item)\n\n\n@router.patch(\"/{item_id}\", response_model=PrintQueueItemResponse)\nasync def update_queue_item(\n    item_id: int,\n    data: PrintQueueItemUpdate,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.QUEUE_UPDATE_ALL,\n            Permission.QUEUE_UPDATE_OWN,\n        )\n    ),\n):\n    \"\"\"Update a queue item.\"\"\"\n    user, can_modify_all = auth_result\n\n    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(404, \"Queue item not found\")\n\n    # Ownership check\n    if not can_modify_all:\n        if item.created_by_id != user.id:\n            raise HTTPException(403, \"You can only update your own queue items\")\n\n    if item.status != \"pending\":\n        raise HTTPException(400, \"Can only update pending items\")\n\n    update_data = data.model_dump(exclude_unset=True)\n\n    # Normalize target_model if being updated\n    if \"target_model\" in update_data and update_data[\"target_model\"]:\n        update_data[\"target_model\"] = (\n            normalize_printer_model(update_data[\"target_model\"])\n            or normalize_printer_model_id(update_data[\"target_model\"])\n            or update_data[\"target_model\"]\n        )\n\n    # Cannot specify both printer_id and target_model\n    new_printer_id = update_data.get(\"printer_id\", item.printer_id)\n    new_target_model = update_data.get(\"target_model\", item.target_model)\n    if new_printer_id and new_target_model:\n        raise HTTPException(400, \"Cannot specify both printer_id and target_model\")\n\n    # Validate new printer_id if being changed (and not None)\n    if \"printer_id\" in update_data and update_data[\"printer_id\"] is not None:\n        result = await db.execute(select(Printer).where(Printer.id == update_data[\"printer_id\"]))\n        if not result.scalar_one_or_none():\n            raise HTTPException(400, \"Printer not found\")\n\n    # Validate target_model has active printers\n    if \"target_model\" in update_data and update_data[\"target_model\"]:\n        result = await db.execute(\n            select(Printer).where(Printer.model == update_data[\"target_model\"]).where(Printer.is_active == True)  # noqa: E712\n        )\n        if not result.scalars().first():\n            raise HTTPException(400, f\"No active printers for model: {update_data['target_model']}\")\n\n    # Serialize ams_mapping to JSON for TEXT column storage\n    if \"ams_mapping\" in update_data:\n        update_data[\"ams_mapping\"] = json.dumps(update_data[\"ams_mapping\"]) if update_data[\"ams_mapping\"] else None\n\n    # Serialize filament_overrides to JSON for TEXT column storage\n    if \"filament_overrides\" in update_data:\n        update_data[\"filament_overrides\"] = (\n            json.dumps(update_data[\"filament_overrides\"]) if update_data[\"filament_overrides\"] else None\n        )\n\n    for field, value in update_data.items():\n        setattr(item, field, value)\n\n    await db.commit()\n    await db.refresh(item, [\"archive\", \"printer\", \"library_file\", \"created_by\", \"batch\"])\n\n    logger.info(\"Updated queue item %s\", item_id)\n    return _enrich_response(item)\n\n\n@router.delete(\"/{item_id}\")\nasync def delete_queue_item(\n    item_id: int,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.QUEUE_DELETE_ALL,\n            Permission.QUEUE_DELETE_OWN,\n        )\n    ),\n):\n    \"\"\"Remove an item from the queue.\"\"\"\n    user, can_modify_all = auth_result\n\n    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(404, \"Queue item not found\")\n\n    # Ownership check\n    if not can_modify_all:\n        if item.created_by_id != user.id:\n            raise HTTPException(403, \"You can only delete your own queue items\")\n\n    if item.status == \"printing\":\n        raise HTTPException(400, \"Cannot delete item that is currently printing\")\n\n    await db.delete(item)\n    await db.commit()\n\n    logger.info(\"Deleted queue item %s\", item_id)\n    return {\"message\": \"Queue item deleted\"}\n\n\n@router.post(\"/reorder\")\nasync def reorder_queue(\n    data: PrintQueueReorder,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),\n):\n    \"\"\"Bulk update positions for queue items.\"\"\"\n    for reorder_item in data.items:\n        result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id))\n        item = result.scalar_one_or_none()\n        if item and item.status == \"pending\":\n            item.position = reorder_item.position\n\n    await db.commit()\n    logger.info(\"Reordered %s queue items\", len(data.items))\n    return {\"message\": f\"Reordered {len(data.items)} items\"}\n\n\n@router.post(\"/{item_id}/cancel\")\nasync def cancel_queue_item(\n    item_id: int,\n    db: AsyncSession = Depends(get_db),\n    auth_result: tuple[User | None, bool] = Depends(\n        require_ownership_permission(\n            Permission.QUEUE_UPDATE_ALL,\n            Permission.QUEUE_UPDATE_OWN,\n        )\n    ),\n):\n    \"\"\"Cancel a pending queue item.\"\"\"\n    user, can_modify_all = auth_result\n\n    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(404, \"Queue item not found\")\n\n    # Ownership check\n    if not can_modify_all:\n        if item.created_by_id != user.id:\n            raise HTTPException(403, \"You can only cancel your own queue items\")\n\n    if item.status not in (\"pending\",):\n        raise HTTPException(400, f\"Cannot cancel item with status '{item.status}'\")\n\n    item.status = \"cancelled\"\n    item.completed_at = datetime.now(timezone.utc)\n    await db.commit()\n\n    logger.info(\"Cancelled queue item %s\", item_id)\n    return {\"message\": \"Queue item cancelled\"}\n\n\n@router.post(\"/{item_id}/stop\")\nasync def stop_queue_item(\n    item_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),\n):\n    \"\"\"Stop an actively printing queue item.\"\"\"\n    import asyncio\n\n    from backend.app.models.smart_plug import SmartPlug\n    from backend.app.services.printer_manager import printer_manager\n    from backend.app.services.tasmota import tasmota_service\n\n    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(404, \"Queue item not found\")\n\n    if item.status != \"printing\":\n        raise HTTPException(400, f\"Can only stop items that are printing, current status: '{item.status}'\")\n\n    # Capture values we need for background task\n    printer_id = item.printer_id\n    auto_off_after = item.auto_off_after\n\n    # Try to send stop command to printer\n    stop_sent = False\n    try:\n        stop_sent = printer_manager.stop_print(printer_id)\n        if not stop_sent:\n            logger.warning(\"stop_print returned False for printer %s - printer may not be connected\", printer_id)\n    except Exception as e:\n        logger.error(\"Error sending stop command for queue item %s: %s\", item_id, e)\n\n    # Mark this printer as user-stopped BEFORE the first await so that if the\n    # MQTT on_print_complete callback fires during the db.commit() yield the flag\n    # is already set and the \"failed\" status will be correctly overridden to\n    # \"cancelled\" (preventing a spurious \"print failed\" notification).\n    try:\n        from backend.app.main import mark_printer_stopped_by_user\n\n        mark_printer_stopped_by_user(printer_id)\n    except Exception as _mark_err:\n        logger.warning(\"Failed to mark printer %s as user-stopped: %s\", printer_id, _mark_err)\n\n    # Update queue item status regardless - if printer is off, print is already stopped\n    item.status = \"cancelled\"\n    item.completed_at = datetime.now(timezone.utc)\n    item.error_message = \"Stopped by user\" if stop_sent else \"Stopped by user (printer was offline)\"\n    await db.commit()\n\n    # Get smart plug info if auto-off is enabled\n    plug_ip = None\n    if auto_off_after:\n        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n        plug = result.scalar_one_or_none()\n        if plug and plug.enabled:\n            plug_ip = plug.ip_address\n\n    logger.info(\"Stopped printing queue item %s (stop command sent: %s)\", item_id, stop_sent)\n\n    # Schedule background task for cooldown + power off\n    if plug_ip:\n\n        async def cooldown_and_poweroff():\n            logger.info(\"Auto-off: Waiting for printer %s to cool down before power off...\", printer_id)\n            await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)\n            # Re-fetch plug since we're in a new async context\n            from backend.app.core.database import async_session\n\n            async with async_session() as new_db:\n                result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n                plug = result.scalar_one_or_none()\n                if plug and plug.enabled:\n                    logger.info(\"Auto-off: Powering off printer %s\", printer_id)\n                    await tasmota_service.turn_off(plug)\n\n        asyncio.create_task(cooldown_and_poweroff())\n\n    return {\"message\": \"Print stopped\" if stop_sent else \"Queue item cancelled (printer was offline)\"}\n\n\n@router.post(\"/{item_id}/start\")\nasync def start_queue_item(\n    item_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),\n):\n    \"\"\"Manually start a staged (manual_start) queue item.\n\n    This clears the manual_start flag so the scheduler will pick it up,\n    or starts immediately if the printer is ready.\n    \"\"\"\n    result = await db.execute(\n        select(PrintQueueItem)\n        .options(\n            selectinload(PrintQueueItem.archive),\n            selectinload(PrintQueueItem.printer),\n            selectinload(PrintQueueItem.batch),\n        )\n        .where(PrintQueueItem.id == item_id)\n    )\n    item = result.scalar_one_or_none()\n    if not item:\n        raise HTTPException(404, \"Queue item not found\")\n\n    if item.status != \"pending\":\n        raise HTTPException(400, f\"Can only start pending items, current status: '{item.status}'\")\n\n    # Clear manual_start flag so scheduler picks it up\n    item.manual_start = False\n    await db.commit()\n    await db.refresh(item, [\"archive\", \"printer\", \"library_file\", \"created_by\", \"batch\"])\n\n    logger.info(\"Manually started queue item %s (cleared manual_start flag)\", item_id)\n    return _enrich_response(item)\n"
  },
  {
    "path": "backend/app/api/routes/printers.py",
    "content": "import asyncio\nimport logging\nimport re\nimport zipfile\n\nfrom fastapi import APIRouter, Depends, HTTPException, Query\nfrom fastapi.responses import Response\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled\nfrom backend.app.core.config import settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.ams_label import AmsLabel\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.slot_preset import SlotPresetMapping\nfrom backend.app.schemas.printer import (\n    AmsLabelBody,\n    AMSTray,\n    AMSUnit,\n    HMSErrorResponse,\n    NozzleInfoResponse,\n    NozzleRackSlot,\n    PrinterCreate,\n    PrinterResponse,\n    PrinterStatus,\n    PrinterUpdate,\n    PrintOptionsResponse,\n)\nfrom backend.app.services.bambu_ftp import (\n    cache_3mf_download,\n    delete_file_async,\n    download_file_bytes_async,\n    download_file_try_paths_async,\n    get_cached_3mf,\n    get_storage_info_async,\n    list_files_async,\n)\nfrom backend.app.services.printer_manager import (\n    get_derived_status_name,\n    parse_plate_id,\n    printer_manager,\n    supports_chamber_temp,\n    supports_drying,\n)\n\nlogger = logging.getLogger(__name__)\nrouter = APIRouter(prefix=\"/printers\", tags=[\"printers\"])\n\n\n@router.get(\"/\", response_model=list[PrinterResponse])\nasync def list_printers(\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"List all configured printers.\"\"\"\n    result = await db.execute(select(Printer).order_by(Printer.name))\n    return list(result.scalars().all())\n\n\n@router.post(\"/\", response_model=PrinterResponse)\nasync def create_printer(\n    printer_data: PrinterCreate,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Add a new printer.\"\"\"\n    # Check if serial number already exists\n    result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number))\n    if result.scalar_one_or_none():\n        raise HTTPException(400, \"Printer with this serial number already exists\")\n\n    printer = Printer(**printer_data.model_dump())\n    db.add(printer)\n    await db.commit()\n    await db.refresh(printer)\n\n    # Connect to the printer\n    if printer.is_active:\n        await printer_manager.connect_printer(printer)\n\n    return printer\n\n\n@router.get(\"/usb-cameras\")\nasync def list_usb_cameras(\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n):\n    \"\"\"List available USB cameras connected to the system.\n\n    Returns a list of detected V4L2 video devices with their info.\n    Only works on Linux systems with V4L2 support.\n\n    Returns:\n        List of dicts with {device: str, name: str, capabilities: list, formats?: list}\n    \"\"\"\n    from backend.app.services.external_camera import list_usb_cameras\n\n    cameras = list_usb_cameras()\n    return {\"cameras\": cameras}\n\n\n@router.get(\"/available-filaments\")\nasync def get_available_filaments(\n    model: str = Query(..., description=\"Target printer model\"),\n    location: str | None = Query(None, description=\"Optional location filter\"),\n    _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get deduplicated list of filaments loaded across all active printers of a given model.\n\n    Used by the frontend to offer filament override options for model-based queue assignment.\n    \"\"\"\n    from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id\n\n    # Normalize model name\n    normalized_model = normalize_printer_model(model) or normalize_printer_model_id(model) or model\n\n    query = (\n        select(Printer).where(func.lower(Printer.model) == normalized_model.lower()).where(Printer.is_active == True)  # noqa: E712\n    )\n    if location:\n        query = query.where(Printer.location == location)\n\n    result = await db.execute(query)\n    printers_list = list(result.scalars().all())\n\n    if not printers_list:\n        return []\n\n    # Collect filaments from all matching printers\n    # Dedup key includes extruder_id and tray_sub_brands so \"PLA Basic\" and \"PLA Matte\" appear separately\n    seen: set[tuple[str, str, str, int | None]] = set()  # (type_upper, color_normalized, sub_brands_upper, extruder_id)\n    filaments = []\n\n    for printer in printers_list:\n        status = printer_manager.get_status(printer.id)\n        if not status:\n            continue\n\n        # Get ams_extruder_map for dual-nozzle printers\n        ams_extruder_map = status.raw_data.get(\"ams_extruder_map\", {})\n\n        # AMS trays\n        for ams_unit in status.raw_data.get(\"ams\", []):\n            ams_id = str(ams_unit.get(\"id\", 0))\n            extruder_id = ams_extruder_map.get(ams_id)\n            for tray in ams_unit.get(\"tray\", []):\n                tray_type = tray.get(\"tray_type\")\n                if not tray_type:\n                    continue\n                tray_color = tray.get(\"tray_color\", \"\")\n                # Normalize color: remove alpha, add hash\n                hex_color = tray_color.replace(\"#\", \"\")[:6] if tray_color else \"808080\"\n                color = f\"#{hex_color}\"\n                tray_info_idx = tray.get(\"tray_info_idx\", \"\")\n                tray_sub_brands = tray.get(\"tray_sub_brands\", \"\") or \"\"\n\n                key = (tray_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)\n                if key not in seen:\n                    seen.add(key)\n                    filaments.append(\n                        {\n                            \"type\": tray_type,\n                            \"color\": color,\n                            \"tray_info_idx\": tray_info_idx,\n                            \"tray_sub_brands\": tray_sub_brands,\n                            \"extruder_id\": extruder_id,\n                        }\n                    )\n\n        # External spools (vt_tray)\n        for vt in status.raw_data.get(\"vt_tray\") or []:\n            vt_type = vt.get(\"tray_type\")\n            if not vt_type:\n                continue\n            vt_color = vt.get(\"tray_color\", \"\")\n            hex_color = vt_color.replace(\"#\", \"\")[:6] if vt_color else \"808080\"\n            color = f\"#{hex_color}\"\n            tray_info_idx = vt.get(\"tray_info_idx\", \"\")\n            tray_sub_brands = vt.get(\"tray_sub_brands\", \"\") or \"\"\n            vt_id = int(vt.get(\"id\", 254))\n            extruder_id = (255 - vt_id) if ams_extruder_map else None\n\n            key = (vt_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)\n            if key not in seen:\n                seen.add(key)\n                filaments.append(\n                    {\n                        \"type\": vt_type,\n                        \"color\": color,\n                        \"tray_info_idx\": tray_info_idx,\n                        \"tray_sub_brands\": tray_sub_brands,\n                        \"extruder_id\": extruder_id,\n                    }\n                )\n\n    return filaments\n\n\n@router.get(\"/developer-mode-warnings\")\nasync def get_developer_mode_warnings(\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Check if any connected printer lacks developer LAN mode.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712\n    printers = result.scalars().all()\n    statuses = printer_manager.get_all_statuses()\n\n    warnings = []\n    for printer in printers:\n        state = statuses.get(printer.id)\n        if state and state.connected and state.developer_mode is False:\n            warnings.append(\n                {\n                    \"printer_id\": printer.id,\n                    \"name\": printer.name,\n                }\n            )\n    return warnings\n\n\n@router.get(\"/{printer_id}\", response_model=PrinterResponse)\nasync def get_printer(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get a specific printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n    return printer\n\n\n@router.patch(\"/{printer_id}\", response_model=PrinterResponse)\nasync def update_printer(\n    printer_id: int,\n    printer_data: PrinterUpdate,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Update a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    update_data = printer_data.model_dump(exclude_unset=True)\n\n    # Handle nested ROI object - flatten to individual columns\n    if \"plate_detection_roi\" in update_data:\n        roi = update_data.pop(\"plate_detection_roi\")\n        if roi:\n            update_data[\"plate_detection_roi_x\"] = roi.get(\"x\")\n            update_data[\"plate_detection_roi_y\"] = roi.get(\"y\")\n            update_data[\"plate_detection_roi_w\"] = roi.get(\"w\")\n            update_data[\"plate_detection_roi_h\"] = roi.get(\"h\")\n        else:\n            # Clear ROI if set to null\n            update_data[\"plate_detection_roi_x\"] = None\n            update_data[\"plate_detection_roi_y\"] = None\n            update_data[\"plate_detection_roi_w\"] = None\n            update_data[\"plate_detection_roi_h\"] = None\n\n    for field, value in update_data.items():\n        setattr(printer, field, value)\n\n    await db.commit()\n    await db.refresh(printer)\n\n    # Reconnect if connection settings changed\n    if any(k in update_data for k in [\"ip_address\", \"access_code\", \"is_active\"]):\n        printer_manager.disconnect_printer(printer_id)\n        if printer.is_active:\n            await printer_manager.connect_printer(printer)\n\n    return printer\n\n\n@router.delete(\"/{printer_id}\")\nasync def delete_printer(\n    printer_id: int,\n    delete_archives: bool = True,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Delete a printer.\n\n    Args:\n        printer_id: ID of the printer to delete\n        delete_archives: If True (default), delete all print archives for this printer.\n                        If False, keep archives but remove their printer association.\n    \"\"\"\n    from sqlalchemy import delete as sql_delete\n\n    from backend.app.models.archive import PrintArchive\n    from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance\n\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    printer_manager.disconnect_printer(printer_id)\n\n    if delete_archives:\n        # Delete all archives for this printer\n        await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id))\n    else:\n        # Orphan the archives instead of deleting them\n        from sqlalchemy import update\n\n        await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))\n\n    # Delete maintenance history and items for this printer\n    # (SQLite doesn't enforce FK cascades, so do it explicitly)\n    maintenance_ids = (\n        (await db.execute(select(PrinterMaintenance.id).where(PrinterMaintenance.printer_id == printer_id)))\n        .scalars()\n        .all()\n    )\n    if maintenance_ids:\n        await db.execute(\n            sql_delete(MaintenanceHistory).where(MaintenanceHistory.printer_maintenance_id.in_(maintenance_ids))\n        )\n        await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))\n\n    await db.delete(printer)\n    await db.commit()\n\n    return {\"status\": \"deleted\", \"archives_deleted\": delete_archives}\n\n\n@router.get(\"/{printer_id}/status\", response_model=PrinterStatus)\nasync def get_printer_status(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get real-time status of a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    state = printer_manager.get_status(printer_id)\n    if not state:\n        return PrinterStatus(\n            id=printer_id,\n            name=printer.name,\n            connected=False,\n        )\n\n    # Determine cover URL if there's an active print (including paused)\n    cover_url = None\n    if state.state in (\"RUNNING\", \"PAUSE\") and state.gcode_file:\n        cover_url = f\"/api/v1/printers/{printer_id}/cover\"\n\n    # Convert HMS errors to response format\n    hms_errors = [\n        HMSErrorResponse(code=e.code, attr=e.attr, module=e.module, severity=e.severity)\n        for e in (state.hms_errors or [])\n    ]\n\n    # Parse AMS data from raw_data\n    ams_units = []\n    vt_tray = []\n    ams_exists = False\n    raw_data = state.raw_data or {}\n\n    # Build K-profile lookup map: cali_idx -> k_value\n    # This allows looking up the calibrated K value for each AMS slot\n    kprofile_map: dict[int, float] = {}\n    for kp in state.kprofiles or []:\n        if kp.slot_id is not None and kp.k_value:\n            try:\n                kprofile_map[kp.slot_id] = float(kp.k_value)\n            except (ValueError, TypeError):\n                pass  # Skip K-profile entries with unparseable values\n\n    if \"ams\" in raw_data and isinstance(raw_data[\"ams\"], list):\n        ams_exists = True\n        for ams_data in raw_data[\"ams\"]:\n            # Skip if ams_data is not a dict (defensive check)\n            if not isinstance(ams_data, dict):\n                continue\n            trays = []\n            for tray_data in ams_data.get(\"tray\", []):\n                # Filter out empty/invalid tag values\n                tag_uid = tray_data.get(\"tag_uid\", \"\")\n                if tag_uid in (\"\", \"0000000000000000\"):\n                    tag_uid = None\n                tray_uuid = tray_data.get(\"tray_uuid\", \"\")\n                if tray_uuid in (\"\", \"00000000000000000000000000000000\"):\n                    tray_uuid = None\n\n                # Get K value: first try tray's k field, then lookup from K-profiles\n                k_value = tray_data.get(\"k\")\n                cali_idx = tray_data.get(\"cali_idx\")\n                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:\n                    k_value = kprofile_map[cali_idx]\n\n                trays.append(\n                    AMSTray(\n                        id=tray_data.get(\"id\", 0),\n                        tray_color=tray_data.get(\"tray_color\"),\n                        tray_type=tray_data.get(\"tray_type\"),\n                        tray_sub_brands=tray_data.get(\"tray_sub_brands\"),\n                        tray_id_name=tray_data.get(\"tray_id_name\"),\n                        tray_info_idx=tray_data.get(\"tray_info_idx\"),\n                        remain=tray_data.get(\"remain\", 0),\n                        k=k_value,\n                        cali_idx=cali_idx,\n                        tag_uid=tag_uid,\n                        tray_uuid=tray_uuid,\n                        nozzle_temp_min=tray_data.get(\"nozzle_temp_min\"),\n                        nozzle_temp_max=tray_data.get(\"nozzle_temp_max\"),\n                        drying_temp=tray_data.get(\"drying_temp\"),\n                        drying_time=tray_data.get(\"drying_time\"),\n                        state=tray_data.get(\"state\"),\n                    )\n                )\n            # Prefer humidity_raw (percentage) over humidity (index 1-5)\n            # humidity_raw is the actual percentage value from the sensor\n            humidity_raw = ams_data.get(\"humidity_raw\")\n            humidity_idx = ams_data.get(\"humidity\")\n            humidity_value = None\n\n            if humidity_raw is not None:\n                try:\n                    humidity_value = int(humidity_raw)\n                except (ValueError, TypeError):\n                    pass  # Skip unparseable humidity; will try index fallback\n            if humidity_value is None and humidity_idx is not None:\n                try:\n                    humidity_value = int(humidity_idx)\n                except (ValueError, TypeError):\n                    pass  # Skip unparseable humidity index; humidity remains None\n            # AMS-HT has 1 tray, regular AMS has 4 trays\n            is_ams_ht = len(trays) == 1\n\n            ams_units.append(\n                AMSUnit(\n                    id=ams_data.get(\"id\", 0),\n                    humidity=humidity_value,\n                    temp=ams_data.get(\"temp\"),\n                    is_ams_ht=is_ams_ht,\n                    tray=trays,\n                    # Serial number: Bambu MQTT uses \"sn\" key on AMS unit objects\n                    serial_number=str(ams_data.get(\"sn\") or ams_data.get(\"serial_number\") or \"\"),\n                    # Firmware version: populated by _handle_version_info from info.module ams/* entries\n                    sw_ver=str(ams_data.get(\"sw_ver\") or \"\"),\n                    # Drying: dry_time > 0 means drying is active (minutes remaining)\n                    dry_time=int(ams_data.get(\"dry_time\") or 0),\n                    module_type=str(ams_data.get(\"module_type\") or \"\"),\n                )\n            )\n\n    # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)\n    if \"vt_tray\" in raw_data:\n        for vt_data in raw_data[\"vt_tray\"]:\n            # Filter out empty/invalid tag values for vt_tray\n            vt_tag_uid = vt_data.get(\"tag_uid\", \"\")\n            if vt_tag_uid in (\"\", \"0000000000000000\"):\n                vt_tag_uid = None\n            vt_tray_uuid = vt_data.get(\"tray_uuid\", \"\")\n            if vt_tray_uuid in (\"\", \"00000000000000000000000000000000\"):\n                vt_tray_uuid = None\n\n            # Get K value: first try tray's k field, then lookup from K-profiles\n            vt_k_value = vt_data.get(\"k\")\n            vt_cali_idx = vt_data.get(\"cali_idx\")\n            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:\n                vt_k_value = kprofile_map[vt_cali_idx]\n\n            tray_id = int(vt_data.get(\"id\", 254))\n            vt_tray.append(\n                AMSTray(\n                    id=tray_id,\n                    tray_color=vt_data.get(\"tray_color\"),\n                    tray_type=vt_data.get(\"tray_type\"),\n                    tray_sub_brands=vt_data.get(\"tray_sub_brands\"),\n                    tray_id_name=vt_data.get(\"tray_id_name\"),\n                    tray_info_idx=vt_data.get(\"tray_info_idx\"),\n                    remain=vt_data.get(\"remain\", 0),\n                    k=vt_k_value,\n                    cali_idx=vt_cali_idx,\n                    tag_uid=vt_tag_uid,\n                    tray_uuid=vt_tray_uuid,\n                    nozzle_temp_min=vt_data.get(\"nozzle_temp_min\"),\n                    nozzle_temp_max=vt_data.get(\"nozzle_temp_max\"),\n                )\n            )\n\n    # Convert nozzle info to response format\n    nozzles = [\n        NozzleInfoResponse(\n            nozzle_type=n.nozzle_type,\n            nozzle_diameter=n.nozzle_diameter,\n        )\n        for n in (state.nozzles or [])\n    ]\n\n    # H2C nozzle rack (tool-changer dock positions)\n    nozzle_rack = [\n        NozzleRackSlot(\n            id=n.get(\"id\", 0),\n            nozzle_type=n.get(\"type\", \"\"),\n            nozzle_diameter=n.get(\"diameter\", \"\"),\n            wear=n.get(\"wear\"),\n            stat=n.get(\"stat\"),\n            max_temp=n.get(\"max_temp\", 0),\n            serial_number=n.get(\"serial_number\", \"\"),\n            filament_color=n.get(\"filament_color\", \"\"),\n            filament_id=n.get(\"filament_id\", \"\"),\n            filament_type=n.get(\"filament_type\", \"\"),\n        )\n        for n in (state.nozzle_rack or [])\n    ]\n\n    # Convert print options to response format\n    print_options = PrintOptionsResponse(\n        spaghetti_detector=state.print_options.spaghetti_detector,\n        print_halt=state.print_options.print_halt,\n        halt_print_sensitivity=state.print_options.halt_print_sensitivity,\n        first_layer_inspector=state.print_options.first_layer_inspector,\n        printing_monitor=state.print_options.printing_monitor,\n        buildplate_marker_detector=state.print_options.buildplate_marker_detector,\n        allow_skip_parts=state.print_options.allow_skip_parts,\n        nozzle_clumping_detector=state.print_options.nozzle_clumping_detector,\n        nozzle_clumping_sensitivity=state.print_options.nozzle_clumping_sensitivity,\n        pileup_detector=state.print_options.pileup_detector,\n        pileup_sensitivity=state.print_options.pileup_sensitivity,\n        airprint_detector=state.print_options.airprint_detector,\n        airprint_sensitivity=state.print_options.airprint_sensitivity,\n        auto_recovery_step_loss=state.print_options.auto_recovery_step_loss,\n        filament_tangle_detect=state.print_options.filament_tangle_detect,\n    )\n\n    # Get AMS mapping from raw_data (which AMS is connected to which nozzle)\n    ams_mapping = raw_data.get(\"ams_mapping\", [])\n    # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition\n    # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)\n    ams_extruder_map = state.ams_extruder_map or {}\n    logger.debug(\"API returning ams_mapping: %s, ams_extruder_map: %s\", ams_mapping, ams_extruder_map)\n\n    # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id\n    # Per OpenBambuAPI docs: 254 = external spool, 255 = no filament, otherwise global tray ID\n    # No conversion needed - just use the raw value directly\n    tray_now = state.tray_now\n    logger.debug(\"Using tray_now directly as global ID: %s\", tray_now)\n\n    # Filter out chamber temp for models that don't have a real sensor\n    # P1P, P1S, A1, A1Mini report meaningless chamber_temper values\n    temperatures = state.temperatures\n    if not supports_chamber_temp(printer.model):\n        temperatures = {\n            k: v for k, v in temperatures.items() if k not in (\"chamber\", \"chamber_target\", \"chamber_heating\")\n        }\n\n    # Resolve the active print's archive + plate (#881 follow-up): lets the\n    # printer card show the actual plate name for multi-plate 3MFs instead of\n    # just the 3MF filename. Only attempted for active prints, since subtask_id\n    # is only meaningful then.\n    current_archive_id: int | None = None\n    current_plate_id: int | None = None\n    if state.state in (\"RUNNING\", \"PAUSE\"):\n        current_plate_id = parse_plate_id(state.gcode_file)\n        if state.subtask_id:\n            from backend.app.models.archive import PrintArchive\n\n            archive_row = await db.execute(\n                select(PrintArchive.id)\n                .where(PrintArchive.subtask_id == state.subtask_id)\n                .where(PrintArchive.printer_id == printer_id)\n                .order_by(PrintArchive.created_at.desc())\n                .limit(1)\n            )\n            current_archive_id = archive_row.scalar_one_or_none()\n\n    return PrinterStatus(\n        id=printer_id,\n        name=printer.name,\n        connected=state.connected,\n        state=state.state,\n        current_print=state.current_print,\n        subtask_name=state.subtask_name,\n        gcode_file=state.gcode_file,\n        progress=state.progress,\n        remaining_time=state.remaining_time,\n        layer_num=state.layer_num,\n        total_layers=state.total_layers,\n        temperatures=temperatures,\n        cover_url=cover_url,\n        hms_errors=hms_errors,\n        ams=ams_units,\n        ams_exists=ams_exists,\n        vt_tray=vt_tray,\n        sdcard=state.sdcard,\n        store_to_sdcard=state.store_to_sdcard,\n        timelapse=state.timelapse,\n        ipcam=state.ipcam,\n        wifi_signal=state.wifi_signal,\n        wired_network=state.wired_network,\n        door_open=state.door_open,\n        nozzles=nozzles,\n        nozzle_rack=nozzle_rack,\n        print_options=print_options,\n        stg_cur=state.stg_cur,\n        stg_cur_name=get_derived_status_name(state, printer.model),\n        stg=state.stg,\n        airduct_mode=state.airduct_mode,\n        speed_level=state.speed_level,\n        chamber_light=state.chamber_light,\n        active_extruder=state.active_extruder,\n        ams_mapping=ams_mapping,\n        ams_extruder_map=ams_extruder_map,\n        tray_now=tray_now,\n        ams_status_main=state.ams_status_main,\n        ams_status_sub=state.ams_status_sub,\n        mc_print_sub_stage=state.mc_print_sub_stage,\n        last_ams_update=state.last_ams_update,\n        printable_objects_count=len(state.printable_objects),\n        cooling_fan_speed=state.cooling_fan_speed,\n        big_fan1_speed=state.big_fan1_speed,\n        big_fan2_speed=state.big_fan2_speed,\n        heatbreak_fan_speed=state.heatbreak_fan_speed,\n        firmware_version=state.firmware_version,\n        developer_mode=state.developer_mode if state else None,\n        awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),\n        supports_drying=supports_drying(printer.model, state.firmware_version),\n        current_archive_id=current_archive_id,\n        current_plate_id=current_plate_id,\n    )\n\n\n@router.get(\"/{printer_id}/current-print-user\")\nasync def get_current_print_user(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get the user who started the current print (for reprint tracking).\n\n    Returns user info if available, empty object otherwise.\n    This tracks users for reprints (which bypass the queue).\n    For queue-based prints, use the queue item's created_by field instead.\n    \"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    user_info = printer_manager.get_current_print_user(printer_id)\n    return user_info or {}\n\n\n@router.post(\"/{printer_id}/refresh-status\")\nasync def refresh_printer_status(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Request a full status refresh from the printer (sends pushall command).\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    success = printer_manager.request_status_update(printer_id)\n    if not success:\n        raise HTTPException(400, \"Printer not connected\")\n\n    return {\"status\": \"refresh_requested\"}\n\n\n@router.post(\"/{printer_id}/connect\")\nasync def connect_printer(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Manually connect to a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    success = await printer_manager.connect_printer(printer)\n    return {\"connected\": success}\n\n\n@router.post(\"/{printer_id}/disconnect\")\nasync def disconnect_printer(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Manually disconnect from a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    printer_manager.disconnect_printer(printer_id)\n    return {\"connected\": False}\n\n\n@router.post(\"/test\")\nasync def test_printer_connection(\n    ip_address: str,\n    serial_number: str,\n    access_code: str,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),\n):\n    \"\"\"Test connection to a printer without saving.\"\"\"\n    result = await printer_manager.test_connection(\n        ip_address=ip_address,\n        serial_number=serial_number,\n        access_code=access_code,\n    )\n    return result\n\n\n# Cache for cover images (printer_id -> {(subtask_name, plate_num, view) -> image_bytes})\n_cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}\n\n\ndef clear_cover_cache(printer_id: int) -> None:\n    \"\"\"Clear cached cover images for a printer. Call on print start to avoid stale thumbnails.\"\"\"\n    _cover_cache.pop(printer_id, None)\n\n\n@router.get(\"/{printer_id}/cover\")\nasync def get_printer_cover(\n    printer_id: int,\n    view: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: None = RequireCameraStreamTokenIfAuthEnabled,\n):\n    \"\"\"Get the cover image for the current print job.\n\n    Args:\n        view: Optional view type. Use \"top\" for top-down build plate view (useful for skip objects).\n              Default returns angled 3D perspective view.\n    \"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    state = printer_manager.get_status(printer_id)\n    if not state:\n        raise HTTPException(404, \"Printer not connected\")\n\n    # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF)\n    subtask_name = state.subtask_name\n    if not subtask_name:\n        raise HTTPException(404, f\"No subtask_name in printer state (state={state.state})\")\n\n    # Extract plate number from gcode_file (e.g., \"/data/Metadata/plate_12.gcode\" -> 12)\n    plate_num = 1\n    gcode_file = state.gcode_file\n    if gcode_file:\n        match = re.search(r\"plate_(\\d+)\\.gcode\", gcode_file)\n        if match:\n            plate_num = int(match.group(1))\n            logger.info(\"Detected plate number %s from gcode_file: %s\", plate_num, gcode_file)\n\n    # Normalize view parameter\n    view_key = view or \"default\"\n\n    # Check cache - include plate_num in cache key for multi-plate projects\n    if printer_id in _cover_cache:\n        cache_key = (subtask_name, plate_num, view_key)\n        if cache_key in _cover_cache[printer_id]:\n            return Response(content=_cover_cache[printer_id][cache_key], media_type=\"image/png\")\n\n    # Build possible 3MF filenames from subtask_name\n    # Bambu printers may store files as \"name.gcode.3mf\" (sliced via Bambu Studio)\n    # or just \"name.3mf\" (uploaded directly)\n    possible_filenames = []\n    if subtask_name.endswith(\".3mf\"):\n        possible_filenames.append(subtask_name)\n    else:\n        # Try both naming patterns\n        possible_filenames.append(f\"{subtask_name}.gcode.3mf\")\n        possible_filenames.append(f\"{subtask_name}.3mf\")\n\n    # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)\n    if \" \" in subtask_name:\n        normalized = subtask_name.replace(\" \", \"_\")\n        if normalized.endswith(\".3mf\"):\n            possible_filenames.append(normalized)\n        else:\n            possible_filenames.append(f\"{normalized}.gcode.3mf\")\n            possible_filenames.append(f\"{normalized}.3mf\")\n\n    # Build list of all remote paths to try\n    remote_paths = []\n    for filename in possible_filenames:\n        remote_paths.extend(\n            [\n                f\"/{filename}\",  # Root directory (most common)\n                f\"/cache/{filename}\",\n                f\"/model/{filename}\",\n                f\"/data/{filename}\",\n            ]\n        )\n\n    # Use first filename for temp path (will be reused)\n    temp_filename = possible_filenames[0]\n    temp_path = settings.archive_dir / \"temp\" / f\"cover_{printer_id}_{temp_filename}\"\n    temp_path.parent.mkdir(parents=True, exist_ok=True)\n\n    # Cache check (#972): the archive-metadata flow in main.py may have already\n    # downloaded this 3MF during the print-start handler. Reusing that file\n    # avoids a second 36MB transfer competing with the printer's single FTP\n    # socket (which produces the 425 errors that feed the retry storm).\n    downloaded = False\n    using_cached = False\n    for candidate_name in possible_filenames:\n        cached = get_cached_3mf(printer_id, candidate_name)\n        if cached:\n            logger.info(\"Cover using cached 3MF from %s (avoided duplicate FTP)\", cached)\n            temp_path = cached\n            downloaded = True\n            using_cached = True\n            break\n\n    if not downloaded:\n        logger.info(\n            f\"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)\"\n        )\n\n        # Retry logic for transient FTP failures\n        max_retries = 2\n        last_error = None\n\n        for attempt in range(max_retries + 1):\n            try:\n                downloaded = await download_file_try_paths_async(\n                    printer.ip_address,\n                    printer.access_code,\n                    remote_paths,\n                    temp_path,\n                    printer_model=printer.model,\n                )\n                if downloaded:\n                    break\n            except Exception as e:\n                last_error = e\n                if attempt < max_retries:\n                    logger.warning(\"FTP download attempt %s failed: %s, retrying...\", attempt + 1, e)\n                    await asyncio.sleep(0.5 * (attempt + 1))  # Brief backoff\n                else:\n                    logger.error(\"FTP download failed after %s attempts: %s\", max_retries + 1, e)\n\n        if last_error and not downloaded:\n            raise HTTPException(503, f\"FTP download temporarily unavailable: {last_error}\")\n\n        if not downloaded:\n            raise HTTPException(\n                404,\n                f\"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}\",\n            )\n\n        # Share the fresh download with the archive flow.\n        cache_3mf_download(printer_id, temp_filename, temp_path)\n\n    # Verify file actually exists and has content\n    if not temp_path.exists():\n        raise HTTPException(500, f\"Download reported success but file not found: {temp_path}\")\n\n    file_size = temp_path.stat().st_size\n    logger.info(\"Downloaded file size: %s bytes\", file_size)\n\n    if file_size == 0:\n        if not using_cached:\n            temp_path.unlink()\n        raise HTTPException(500, f\"Downloaded file is empty for '{subtask_name}'\")\n\n    try:\n        # Extract thumbnail from 3MF (which is a ZIP file)\n        try:\n            zf = zipfile.ZipFile(temp_path, \"r\")\n        except zipfile.BadZipFile:\n            raise HTTPException(500, \"Downloaded file is not a valid 3MF/ZIP archive\")\n        except OSError as e:\n            logger.error(\"Failed to open 3MF file: %s\", e, exc_info=True)\n            raise HTTPException(500, \"Failed to open 3MF file. Check server logs for details.\")\n\n        try:\n            # Try common thumbnail paths in 3MF files\n            # Use plate_num to get the correct plate's thumbnail for multi-plate projects\n            # Use top-down view if requested (better for skip objects modal)\n            if view == \"top\":\n                thumbnail_paths = [\n                    f\"Metadata/top_{plate_num}.png\",\n                    # Fall back to plate 1 if specific plate not found\n                    \"Metadata/top_1.png\",\n                    f\"Metadata/plate_{plate_num}.png\",\n                    \"Metadata/plate_1.png\",\n                    \"Metadata/thumbnail.png\",\n                ]\n            else:\n                thumbnail_paths = [\n                    f\"Metadata/plate_{plate_num}.png\",\n                    # Fall back to plate 1 if specific plate not found\n                    \"Metadata/plate_1.png\",\n                    \"Metadata/thumbnail.png\",\n                    f\"Metadata/plate_{plate_num}_small.png\",\n                    \"Metadata/plate_1_small.png\",\n                    \"Thumbnails/thumbnail.png\",\n                    \"thumbnail.png\",\n                ]\n\n            for thumb_path in thumbnail_paths:\n                try:\n                    image_data = zf.read(thumb_path)\n                    # Cache the result - include plate_num in cache key\n                    if printer_id not in _cover_cache:\n                        _cover_cache[printer_id] = {}\n                    _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data\n                    return Response(content=image_data, media_type=\"image/png\")\n                except KeyError:\n                    continue\n\n            # If no specific thumbnail found, try any PNG in Metadata\n            for name in zf.namelist():\n                if name.startswith(\"Metadata/\") and name.endswith(\".png\"):\n                    image_data = zf.read(name)\n                    if printer_id not in _cover_cache:\n                        _cover_cache[printer_id] = {}\n                    _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data\n                    return Response(content=image_data, media_type=\"image/png\")\n\n            raise HTTPException(404, \"No thumbnail found in 3MF file\")\n        finally:\n            zf.close()\n\n    finally:\n        # Only delete when this invocation owns the file. A cached path is\n        # shared with the archive flow — removing it would force a refetch\n        # the next time either flow needs the 3MF.\n        if not using_cached and temp_path.exists():\n            temp_path.unlink()\n\n\n# ============================================\n# File Manager Endpoints\n# ============================================\n\n\n@router.get(\"/{printer_id}/files\")\nasync def list_printer_files(\n    printer_id: int,\n    path: str = \"/\",\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"List files on the printer at the specified path.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)\n\n    # Add full path to each file\n    for f in files:\n        f[\"path\"] = f\"{path.rstrip('/')}/{f['name']}\" if path != \"/\" else f\"/{f['name']}\"\n\n    return {\n        \"path\": path,\n        \"files\": files,\n    }\n\n\n@router.get(\"/{printer_id}/files/download\")\nasync def download_printer_file(\n    printer_id: int,\n    path: str,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Download a file from the printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)\n    if data is None:\n        raise HTTPException(404, f\"File not found: {path}\")\n\n    # Determine content type based on extension\n    filename = path.split(\"/\")[-1]\n    ext = filename.lower().split(\".\")[-1] if \".\" in filename else \"\"\n\n    content_types = {\n        \"3mf\": \"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\",\n        \"gcode\": \"text/plain\",\n        \"mp4\": \"video/mp4\",\n        \"avi\": \"video/x-msvideo\",\n        \"png\": \"image/png\",\n        \"jpg\": \"image/jpeg\",\n        \"jpeg\": \"image/jpeg\",\n        \"json\": \"application/json\",\n        \"txt\": \"text/plain\",\n    }\n    content_type = content_types.get(ext, \"application/octet-stream\")\n\n    return Response(\n        content=data,\n        media_type=content_type,\n        headers={\"Content-Disposition\": f'attachment; filename=\"{filename}\"'},\n    )\n\n\n@router.get(\"/{printer_id}/files/gcode\")\nasync def get_printer_file_gcode(\n    printer_id: int,\n    path: str,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get gcode for a file stored on a printer (for preview).\"\"\"\n    import io\n\n    # Validate printer\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)\n    if data is None:\n        raise HTTPException(404, f\"File not found: {path}\")\n\n    filename = path.split(\"/\")[-1]\n    lower = filename.lower()\n\n    if lower.endswith(\".gcode\"):\n        return Response(content=data, media_type=\"text/plain\")\n    if lower.endswith(\".3mf\"):\n        try:\n            with zipfile.ZipFile(io.BytesIO(data), \"r\") as zf:\n                gcode_files = [n for n in zf.namelist() if n.endswith(\".gcode\")]\n                if not gcode_files:\n                    raise HTTPException(status_code=404, detail=\"No gcode found in 3MF file\")\n                gcode_content = zf.read(gcode_files[0])\n                return Response(content=gcode_content, media_type=\"text/plain\")\n        except zipfile.BadZipFile:\n            raise HTTPException(status_code=400, detail=\"Invalid 3MF file\")\n\n    raise HTTPException(status_code=400, detail=\"Unsupported file type\")\n\n\n@router.get(\"/{printer_id}/files/plates\")\nasync def get_printer_file_plates(\n    printer_id: int,\n    path: str = Query(..., description=\"Full path to the 3MF file on the printer\"),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get available plates from a multi-plate 3MF file stored on a printer.\"\"\"\n    import io\n    import json\n\n    import defusedxml.ElementTree as ET\n\n    # Validate printer\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    filename = path.split(\"/\")[-1]\n    if not filename.lower().endswith(\".3mf\"):\n        return {\n            \"printer_id\": printer_id,\n            \"path\": path,\n            \"filename\": filename,\n            \"plates\": [],\n            \"is_multi_plate\": False,\n        }\n\n    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)\n    if data is None:\n        raise HTTPException(404, f\"File not found: {path}\")\n\n    plates = []\n\n    try:\n        with zipfile.ZipFile(io.BytesIO(data), \"r\") as zf:\n            namelist = zf.namelist()\n\n            # Find all plate gcode files to determine available plates\n            gcode_files = [n for n in namelist if n.startswith(\"Metadata/plate_\") and n.endswith(\".gcode\")]\n\n            # If no gcode is present (source-only or unsliced), fall back to plate JSON/PNG\n            plate_indices: list[int] = []\n            if gcode_files:\n                for gf in gcode_files:\n                    try:\n                        plate_str = gf[15:-6]  # Remove \"Metadata/plate_\" and \".gcode\"\n                        plate_indices.append(int(plate_str))\n                    except ValueError:\n                        pass  # Skip gcode files with non-numeric plate indices\n            else:\n                plate_json_files = [n for n in namelist if n.startswith(\"Metadata/plate_\") and n.endswith(\".json\")]\n                plate_png_files = [\n                    n\n                    for n in namelist\n                    if n.startswith(\"Metadata/plate_\")\n                    and n.endswith(\".png\")\n                    and \"_small\" not in n\n                    and \"no_light\" not in n\n                ]\n                plate_name_candidates = plate_json_files + plate_png_files\n                plate_re = re.compile(r\"^Metadata/plate_(\\d+)\\.(json|png)$\")\n                seen_indices: set[int] = set()\n                for name in plate_name_candidates:\n                    match = plate_re.match(name)\n                    if match:\n                        try:\n                            index = int(match.group(1))\n                        except ValueError:\n                            continue\n                        if index in seen_indices:\n                            continue\n                        seen_indices.add(index)\n                        plate_indices.append(index)\n\n            if not plate_indices:\n                return {\n                    \"printer_id\": printer_id,\n                    \"path\": path,\n                    \"filename\": filename,\n                    \"plates\": [],\n                    \"is_multi_plate\": False,\n                }\n\n            plate_indices.sort()\n\n            # Parse model_settings.config for plate names\n            plate_names = {}\n            if \"Metadata/model_settings.config\" in namelist:\n                try:\n                    model_content = zf.read(\"Metadata/model_settings.config\").decode()\n                    model_root = ET.fromstring(model_content)\n                    for plate_elem in model_root.findall(\".//plate\"):\n                        plater_id = None\n                        plater_name = None\n                        for meta in plate_elem.findall(\"metadata\"):\n                            key = meta.get(\"key\")\n                            value = meta.get(\"value\")\n                            if key == \"plater_id\" and value:\n                                try:\n                                    plater_id = int(value)\n                                except ValueError:\n                                    pass  # Skip plate with unparseable ID\n                            elif key == \"plater_name\" and value:\n                                plater_name = value.strip()\n                        if plater_id is not None and plater_name:\n                            plate_names[plater_id] = plater_name\n                except Exception:\n                    pass  # Plate names are optional; continue without them\n\n            # Parse slice_info.config for plate metadata\n            plate_metadata = {}\n            if \"Metadata/slice_info.config\" in namelist:\n                content = zf.read(\"Metadata/slice_info.config\").decode()\n                root = ET.fromstring(content)\n\n                for plate_elem in root.findall(\".//plate\"):\n                    plate_info = {\"filaments\": [], \"prediction\": None, \"weight\": None, \"name\": None, \"objects\": []}\n\n                    plate_index = None\n                    for meta in plate_elem.findall(\"metadata\"):\n                        key = meta.get(\"key\")\n                        value = meta.get(\"value\")\n                        if key == \"index\" and value:\n                            try:\n                                plate_index = int(value)\n                            except ValueError:\n                                pass  # Skip plate with unparseable index\n                        elif key == \"prediction\" and value:\n                            try:\n                                plate_info[\"prediction\"] = int(value)\n                            except ValueError:\n                                pass  # Skip unparseable prediction; leave as None\n                        elif key == \"weight\" and value:\n                            try:\n                                plate_info[\"weight\"] = float(value)\n                            except ValueError:\n                                pass  # Skip unparseable weight; leave as None\n\n                    # Get filaments used in this plate\n                    for filament_elem in plate_elem.findall(\"filament\"):\n                        filament_id = filament_elem.get(\"id\")\n                        filament_type = filament_elem.get(\"type\", \"\")\n                        filament_color = filament_elem.get(\"color\", \"\")\n                        used_g = filament_elem.get(\"used_g\", \"0\")\n                        used_m = filament_elem.get(\"used_m\", \"0\")\n\n                        try:\n                            used_grams = float(used_g)\n                        except (ValueError, TypeError):\n                            used_grams = 0\n\n                        if used_grams > 0 and filament_id:\n                            plate_info[\"filaments\"].append(\n                                {\n                                    \"slot_id\": int(filament_id),\n                                    \"type\": filament_type,\n                                    \"color\": filament_color,\n                                    \"used_grams\": round(used_grams, 1),\n                                    \"used_meters\": float(used_m) if used_m else 0,\n                                }\n                            )\n\n                    plate_info[\"filaments\"].sort(key=lambda x: x[\"slot_id\"])\n\n                    # Collect object names\n                    for obj_elem in plate_elem.findall(\"object\"):\n                        obj_name = obj_elem.get(\"name\")\n                        if obj_name and obj_name not in plate_info[\"objects\"]:\n                            plate_info[\"objects\"].append(obj_name)\n\n                    # Set plate name\n                    if plate_index is not None:\n                        custom_name = plate_names.get(plate_index)\n                        if custom_name:\n                            plate_info[\"name\"] = custom_name\n                        elif plate_info[\"objects\"]:\n                            plate_info[\"name\"] = plate_info[\"objects\"][0]\n                        plate_metadata[plate_index] = plate_info\n\n            # Parse plate_*.json for object lists when slice_info is missing\n            plate_json_objects: dict[int, list[str]] = {}\n            for name in namelist:\n                match = re.match(r\"^Metadata/plate_(\\d+)\\.json$\", name)\n                if not match:\n                    continue\n                try:\n                    plate_index = int(match.group(1))\n                except ValueError:\n                    continue\n                try:\n                    payload = json.loads(zf.read(name).decode())\n                    bbox_objects = payload.get(\"bbox_objects\", [])\n                    names: list[str] = []\n                    for obj in bbox_objects:\n                        obj_name = obj.get(\"name\") if isinstance(obj, dict) else None\n                        if obj_name and obj_name not in names:\n                            names.append(obj_name)\n                    if names:\n                        plate_json_objects[plate_index] = names\n                except Exception:\n                    continue\n\n            # Build plate list\n            for idx in plate_indices:\n                meta = plate_metadata.get(idx, {})\n                has_thumbnail = f\"Metadata/plate_{idx}.png\" in namelist\n                objects = meta.get(\"objects\", [])\n                if not objects:\n                    objects = plate_json_objects.get(idx, [])\n\n                plate_name = meta.get(\"name\")\n                if not plate_name:\n                    plate_name = plate_names.get(idx)\n                if not plate_name and objects:\n                    plate_name = objects[0]\n\n                plates.append(\n                    {\n                        \"index\": idx,\n                        \"name\": plate_name,\n                        \"objects\": objects,\n                        \"object_count\": len(objects),\n                        \"has_thumbnail\": has_thumbnail,\n                        \"thumbnail_url\": f\"/api/v1/printers/{printer_id}/files/plate-thumbnail/{idx}?path={path}\",\n                        \"print_time_seconds\": meta.get(\"prediction\"),\n                        \"filament_used_grams\": meta.get(\"weight\"),\n                        \"filaments\": meta.get(\"filaments\", []),\n                    }\n                )\n\n    except Exception as e:\n        logger.warning(\"Failed to parse plates from printer file %s: %s\", path, e)\n\n    return {\n        \"printer_id\": printer_id,\n        \"path\": path,\n        \"filename\": filename,\n        \"plates\": plates,\n        \"is_multi_plate\": len(plates) > 1,\n    }\n\n\n@router.get(\"/{printer_id}/files/plate-thumbnail/{plate_index}\")\nasync def get_printer_file_plate_thumbnail(\n    printer_id: int,\n    plate_index: int,\n    path: str = Query(..., description=\"Full path to the 3MF file on the printer\"),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get a plate thumbnail image from a printer-stored 3MF file.\"\"\"\n    import io\n\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)\n    if data is None:\n        raise HTTPException(404, f\"File not found: {path}\")\n\n    try:\n        with zipfile.ZipFile(io.BytesIO(data), \"r\") as zf:\n            thumb_path = f\"Metadata/plate_{plate_index}.png\"\n            if thumb_path in zf.namelist():\n                image_data = zf.read(thumb_path)\n                return Response(content=image_data, media_type=\"image/png\")\n    except Exception:\n        pass  # Corrupt or unreadable 3MF; fall through to 404\n\n    raise HTTPException(status_code=404, detail=f\"Thumbnail for plate {plate_index} not found\")\n\n\n@router.post(\"/{printer_id}/files/download-zip\")\nasync def download_printer_files_as_zip(\n    printer_id: int,\n    request: dict,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Download multiple files from the printer as a ZIP archive.\"\"\"\n    import io\n\n    paths = request.get(\"paths\", [])\n    if not paths:\n        raise HTTPException(400, \"No files specified\")\n\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Create ZIP in memory\n    zip_buffer = io.BytesIO()\n    with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        for path in paths:\n            try:\n                data = await download_file_bytes_async(\n                    printer.ip_address, printer.access_code, path, printer_model=printer.model\n                )\n                if data:\n                    filename = path.split(\"/\")[-1]\n                    zf.writestr(filename, data)\n            except Exception as e:\n                logging.warning(\"Failed to add %s to ZIP: %s\", path, e)\n                continue\n\n    zip_buffer.seek(0)\n    zip_data = zip_buffer.read()\n\n    if len(zip_data) == 0:\n        raise HTTPException(404, \"No files could be downloaded\")\n\n    return Response(\n        content=zip_data,\n        media_type=\"application/zip\",\n        headers={\"Content-Disposition\": 'attachment; filename=\"printer-files.zip\"'},\n    )\n\n\n@router.delete(\"/{printer_id}/files\")\nasync def delete_printer_file(\n    printer_id: int,\n    path: str,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Delete a file from the printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    success = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)\n    if not success:\n        raise HTTPException(500, f\"Failed to delete file: {path}\")\n\n    return {\"status\": \"deleted\", \"path\": path}\n\n\n@router.get(\"/{printer_id}/storage\")\nasync def get_printer_storage(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get storage information from the printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)\n\n    return storage_info or {\"used_bytes\": None, \"free_bytes\": None}\n\n\n# ============================================\n# MQTT Debug Logging Endpoints\n# ============================================\n\n\n@router.post(\"/{printer_id}/logging/enable\")\nasync def enable_mqtt_logging(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Enable MQTT message logging for a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    success = printer_manager.enable_logging(printer_id, True)\n    if not success:\n        raise HTTPException(400, \"Printer not connected\")\n\n    return {\"logging_enabled\": True}\n\n\n@router.post(\"/{printer_id}/logging/disable\")\nasync def disable_mqtt_logging(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Disable MQTT message logging for a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    success = printer_manager.enable_logging(printer_id, False)\n    if not success:\n        raise HTTPException(400, \"Printer not connected\")\n\n    return {\"logging_enabled\": False}\n\n\n@router.get(\"/{printer_id}/logging\")\nasync def get_mqtt_logs(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get MQTT message logs for a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    logs = printer_manager.get_logs(printer_id)\n    return {\n        \"logging_enabled\": printer_manager.is_logging_enabled(printer_id),\n        \"logs\": [\n            {\n                \"timestamp\": log.timestamp,\n                \"topic\": log.topic,\n                \"direction\": log.direction,\n                \"payload\": log.payload,\n            }\n            for log in logs\n        ],\n    }\n\n\n@router.delete(\"/{printer_id}/logging\")\nasync def clear_mqtt_logs(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Clear MQTT message logs for a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    printer_manager.clear_logs(printer_id)\n    return {\"status\": \"cleared\"}\n\n\n# ============================================\n# AMS Drying Endpoints\n# ============================================\n\n\n@router.post(\"/{printer_id}/drying/start\")\nasync def start_drying(\n    printer_id: int,\n    ams_id: int,\n    temp: int = 45,\n    duration: int = 4,\n    filament: str = \"\",\n    rotate_tray: bool = False,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Send AMS drying start command. temp=45-85, duration=hours.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    # Server-side guard: reject if this model/firmware doesn't support drying\n    live_state = printer_manager.get_status(printer_id)\n    firmware = live_state.firmware_version if live_state else None\n    if not supports_drying(printer.model, firmware):\n        raise HTTPException(400, \"Drying not supported for this printer model or firmware version\")\n\n    if temp < 45 or temp > 85:\n        raise HTTPException(400, \"Temperature must be 45-85°C\")\n    if duration < 1 or duration > 24:\n        raise HTTPException(400, \"Duration must be 1-24 hours\")\n\n    # Inspect the live AMS unit: surface blocking dry_sf_reasons (otherwise the\n    # firmware silently ignores the command — #971) and backfill an empty\n    # filament field from the first loaded tray so the printer doesn't reject\n    # the payload.\n    target_ams: dict | None = None\n    for unit in (live_state.raw_data.get(\"ams\") if live_state else None) or []:\n        try:\n            if int(unit.get(\"id\", -1)) == ams_id:\n                target_ams = unit\n                break\n        except (TypeError, ValueError):\n            continue\n\n    if target_ams is not None:\n        reason_messages = {\n            0: \"Printer is busy\",\n            1: \"Insufficient power — too many AMS drying or external PSU required\",\n            2: \"AMS is busy\",\n            3: \"Filament is at the AMS outlet — retract it first\",\n            4: \"AMS is already starting a drying cycle\",\n            5: \"Not supported in 2D mode\",\n            6: \"AMS is already drying\",\n            7: \"AMS firmware is upgrading\",\n            8: \"Plug in the external AMS power adapter to start drying\",\n        }\n        for code in target_ams.get(\"dry_sf_reason\") or []:\n            try:\n                code_int = int(code)\n            except (TypeError, ValueError):\n                continue\n            if code_int in reason_messages:\n                raise HTTPException(409, reason_messages[code_int])\n\n        if not filament:\n            for tray in target_ams.get(\"tray\") or []:\n                tray_type = tray.get(\"tray_type\")\n                if tray_type:\n                    filament = str(tray_type)\n                    break\n\n    if not filament:\n        filament = \"PLA\"\n\n    success = printer_manager.send_drying_command(\n        printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray\n    )\n    if not success:\n        raise HTTPException(400, \"Printer not connected\")\n    return {\"status\": \"drying_started\", \"ams_id\": ams_id, \"temp\": temp, \"duration\": duration}\n\n\n@router.post(\"/{printer_id}/drying/stop\")\nasync def stop_drying(\n    printer_id: int,\n    ams_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Send AMS drying stop command.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)\n    if not success:\n        raise HTTPException(400, \"Printer not connected\")\n    return {\"status\": \"drying_stopped\", \"ams_id\": ams_id}\n\n\n# ============================================\n# Print Options (AI Detection) Endpoints\n# ============================================\n\n\n@router.post(\"/{printer_id}/print-options\")\nasync def set_print_option(\n    printer_id: int,\n    module_name: str,\n    enabled: bool,\n    print_halt: bool = True,\n    sensitivity: str = \"medium\",\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Set an AI detection / print option on the printer.\n\n    Valid module_name values:\n    - spaghetti_detector: Spaghetti detection\n    - first_layer_inspector: First layer inspection\n    - printing_monitor: AI print quality monitoring\n    - buildplate_marker_detector: Build plate marker detection\n    - allow_skip_parts: Allow skipping failed parts\n    \"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client or not client.state.connected:\n        raise HTTPException(400, \"Printer not connected\")\n\n    # Validate module_name\n    valid_modules = [\n        \"spaghetti_detector\",\n        \"first_layer_inspector\",\n        \"printing_monitor\",\n        \"buildplate_marker_detector\",\n        \"allow_skip_parts\",\n        \"pileup_detector\",\n        \"clump_detector\",\n        \"airprint_detector\",\n        \"auto_recovery_step_loss\",\n    ]\n    if module_name not in valid_modules:\n        raise HTTPException(400, f\"Invalid module_name. Must be one of: {valid_modules}\")\n\n    # Validate sensitivity\n    valid_sensitivities = [\"low\", \"medium\", \"high\", \"never_halt\"]\n    if sensitivity not in valid_sensitivities:\n        raise HTTPException(400, f\"Invalid sensitivity. Must be one of: {valid_sensitivities}\")\n\n    success = client.set_xcam_option(\n        module_name=module_name,\n        enabled=enabled,\n        print_halt=print_halt,\n        sensitivity=sensitivity,\n    )\n\n    if not success:\n        raise HTTPException(500, \"Failed to send command to printer\")\n\n    return {\n        \"success\": True,\n        \"module_name\": module_name,\n        \"enabled\": enabled,\n        \"print_halt\": print_halt,\n        \"sensitivity\": sensitivity,\n    }\n\n\n# ============================================\n# Calibration\n# ============================================\n\n\n@router.post(\"/{printer_id}/calibration\")\nasync def start_calibration(\n    printer_id: int,\n    bed_leveling: bool = False,\n    vibration: bool = False,\n    motor_noise: bool = False,\n    nozzle_offset: bool = False,\n    high_temp_heatbed: bool = False,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Start printer calibration with selected options.\n\n    At least one option must be selected.\n\n    Options:\n    - bed_leveling: Run bed leveling calibration\n    - vibration: Run vibration compensation calibration\n    - motor_noise: Run motor noise cancellation calibration\n    - nozzle_offset: Run nozzle offset calibration (dual nozzle printers)\n    - high_temp_heatbed: Run high-temperature heatbed calibration\n    \"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client or not client.state.connected:\n        raise HTTPException(400, \"Printer not connected\")\n\n    # Check that at least one option is selected\n    if not any([bed_leveling, vibration, motor_noise, nozzle_offset, high_temp_heatbed]):\n        raise HTTPException(400, \"At least one calibration option must be selected\")\n\n    success = client.start_calibration(\n        bed_leveling=bed_leveling,\n        vibration=vibration,\n        motor_noise=motor_noise,\n        nozzle_offset=nozzle_offset,\n        high_temp_heatbed=high_temp_heatbed,\n    )\n\n    if not success:\n        raise HTTPException(500, \"Failed to send calibration command to printer\")\n\n    return {\n        \"success\": True,\n        \"bed_leveling\": bed_leveling,\n        \"vibration\": vibration,\n        \"motor_noise\": motor_noise,\n        \"nozzle_offset\": nozzle_offset,\n        \"high_temp_heatbed\": high_temp_heatbed,\n    }\n\n\n# ============================================================================\n# Slot Preset Mapping Endpoints\n# ============================================================================\n\n\ndef _slot_preset_key(ams_id: int, tray_id: int) -> int:\n    # Mirrors frontend getGlobalTrayId (amsHelpers.ts): AMS-HT (128-135) is keyed\n    # by ams_id since each unit has a single slot and shares its global ID with\n    # the unit itself. Regular AMS and external (255) use ams_id*4+tray_id.\n    if 128 <= ams_id <= 135:\n        return ams_id\n    return ams_id * 4 + tray_id\n\n\n@router.get(\"/{printer_id}/slot-presets\")\nasync def get_slot_presets(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get all saved slot-to-preset mappings for a printer.\"\"\"\n    result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id))\n    mappings = result.scalars().all()\n\n    return {\n        _slot_preset_key(mapping.ams_id, mapping.tray_id): {\n            \"ams_id\": mapping.ams_id,\n            \"tray_id\": mapping.tray_id,\n            \"preset_id\": mapping.preset_id,\n            \"preset_name\": mapping.preset_name,\n        }\n        for mapping in mappings\n    }\n\n\n@router.get(\"/{printer_id}/slot-presets/{ams_id}/{tray_id}\")\nasync def get_slot_preset(\n    printer_id: int,\n    ams_id: int,\n    tray_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get the saved preset for a specific slot.\"\"\"\n    result = await db.execute(\n        select(SlotPresetMapping).where(\n            SlotPresetMapping.printer_id == printer_id,\n            SlotPresetMapping.ams_id == ams_id,\n            SlotPresetMapping.tray_id == tray_id,\n        )\n    )\n    mapping = result.scalar_one_or_none()\n\n    if not mapping:\n        return None\n\n    return {\n        \"ams_id\": mapping.ams_id,\n        \"tray_id\": mapping.tray_id,\n        \"preset_id\": mapping.preset_id,\n        \"preset_name\": mapping.preset_name,\n    }\n\n\n@router.put(\"/{printer_id}/slot-presets/{ams_id}/{tray_id}\")\nasync def save_slot_preset(\n    printer_id: int,\n    ams_id: int,\n    tray_id: int,\n    preset_id: str,\n    preset_name: str,\n    preset_source: str = \"cloud\",\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Save a preset mapping for a specific slot.\"\"\"\n    # Check printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(404, \"Printer not found\")\n\n    # Check for existing mapping\n    result = await db.execute(\n        select(SlotPresetMapping).where(\n            SlotPresetMapping.printer_id == printer_id,\n            SlotPresetMapping.ams_id == ams_id,\n            SlotPresetMapping.tray_id == tray_id,\n        )\n    )\n    mapping = result.scalar_one_or_none()\n\n    if mapping:\n        # Update existing\n        mapping.preset_id = preset_id\n        mapping.preset_name = preset_name\n        mapping.preset_source = preset_source\n    else:\n        # Create new\n        mapping = SlotPresetMapping(\n            printer_id=printer_id,\n            ams_id=ams_id,\n            tray_id=tray_id,\n            preset_id=preset_id,\n            preset_name=preset_name,\n            preset_source=preset_source,\n        )\n        db.add(mapping)\n\n    await db.commit()\n    await db.refresh(mapping)\n\n    return {\n        \"ams_id\": mapping.ams_id,\n        \"tray_id\": mapping.tray_id,\n        \"preset_id\": mapping.preset_id,\n        \"preset_name\": mapping.preset_name,\n        \"preset_source\": mapping.preset_source,\n    }\n\n\n@router.delete(\"/{printer_id}/slot-presets/{ams_id}/{tray_id}\")\nasync def delete_slot_preset(\n    printer_id: int,\n    ams_id: int,\n    tray_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Delete a saved preset mapping for a slot.\"\"\"\n    result = await db.execute(\n        select(SlotPresetMapping).where(\n            SlotPresetMapping.printer_id == printer_id,\n            SlotPresetMapping.ams_id == ams_id,\n            SlotPresetMapping.tray_id == tray_id,\n        )\n    )\n    mapping = result.scalar_one_or_none()\n\n    if mapping:\n        await db.delete(mapping)\n        await db.commit()\n\n    return {\"success\": True}\n\n\n@router.post(\"/{printer_id}/slots/{ams_id}/{tray_id}/configure\")\nasync def configure_ams_slot(\n    printer_id: int,\n    ams_id: int,\n    tray_id: int,\n    tray_info_idx: str = Query(...),\n    tray_type: str = Query(...),\n    tray_sub_brands: str = Query(...),\n    tray_color: str = Query(...),\n    nozzle_temp_min: int = Query(...),\n    nozzle_temp_max: int = Query(...),\n    cali_idx: int = Query(-1),\n    nozzle_diameter: str = Query(\"0.4\"),\n    setting_id: str = Query(\"\"),\n    kprofile_filament_id: str = Query(\"\"),\n    kprofile_setting_id: str = Query(\"\"),\n    k_value: float = Query(0.0),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n):\n    \"\"\"Configure an AMS slot with a specific filament setting and K profile.\n\n    This sends two commands to the printer:\n    1. ams_filament_setting - sets filament type, color, temperature\n    2. extrusion_cali_sel - sets the K profile (pressure advance value)\n\n    Args:\n        printer_id: Database ID of the printer\n        ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)\n        tray_id: Tray ID within the AMS (0-3)\n        tray_info_idx: Filament ID short format (e.g., \"GFL05\") or user preset ID\n        tray_type: Filament type (e.g., \"PLA\", \"PETG\")\n        tray_sub_brands: Sub-brand/profile name (e.g., \"PLA Basic\", \"PETG HF\")\n        tray_color: Color in RRGGBBAA hex format (e.g., \"FFFF00FF\")\n        nozzle_temp_min: Minimum nozzle temperature\n        nozzle_temp_max: Maximum nozzle temperature\n        cali_idx: K profile calibration index (-1 for default 0.020)\n        nozzle_diameter: Nozzle diameter string (e.g., \"0.4\")\n        setting_id: Full setting ID with version (e.g., \"GFSL05_07\") - optional\n        kprofile_filament_id: K profile's filament_id for proper K profile linking\n        k_value: Direct K value to set (0.0 to skip direct K value setting)\n    \"\"\"\n    logger = logging.getLogger(__name__)\n    logger.info(\"[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s\", printer_id, ams_id, tray_id)\n    logger.info(\n        f\"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}\"\n    )\n    logger.info(\n        f\"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}\"\n    )\n\n    # Get MQTT client for this printer\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(status_code=400, detail=\"Printer not connected\")\n\n    # Resolve tray_info_idx for the MQTT command.\n    # Priority:\n    #   1. Use the provided tray_info_idx if set (including cloud-synced\n    #      custom presets like PFUS* / P*).\n    #   2. Reuse the slot's existing tray_info_idx if it's a specific\n    #      (non-generic) preset for the same material.\n    #   3. Fall back to a generic Bambu filament ID.\n    _GENERIC_FILAMENT_IDS = {\n        \"PLA\": \"GFL99\",\n        \"PETG\": \"GFG99\",\n        \"ABS\": \"GFB99\",\n        \"ASA\": \"GFB98\",\n        \"PC\": \"GFC99\",\n        \"PA\": \"GFN99\",\n        \"NYLON\": \"GFN99\",\n        \"TPU\": \"GFU99\",\n        \"PVA\": \"GFS99\",\n        \"HIPS\": \"GFS98\",\n        \"PLA-CF\": \"GFL98\",\n        \"PETG-CF\": \"GFG98\",\n        \"PA-CF\": \"GFN98\",\n        \"PETG HF\": \"GFG96\",\n    }\n    _GENERIC_ID_VALUES = set(_GENERIC_FILAMENT_IDS.values())\n    effective_tray_info_idx = tray_info_idx\n\n    if not tray_info_idx:\n        # No preset provided — try slot reuse or generic fallback\n        current_tray_info_idx = \"\"\n        current_tray_type = \"\"\n        state = printer_manager.get_status(printer_id)\n        if state and state.raw_data:\n            from backend.app.api.routes.inventory import _find_tray_in_ams_data\n\n            if ams_id == 255:\n                vt_tray = state.raw_data.get(\"vt_tray\") or []\n                ext_id = tray_id + 254\n                for vt in vt_tray:\n                    if isinstance(vt, dict) and int(vt.get(\"id\", 254)) == ext_id:\n                        current_tray_info_idx = vt.get(\"tray_info_idx\", \"\")\n                        current_tray_type = vt.get(\"tray_type\", \"\")\n                        break\n            else:\n                ams_data = state.raw_data.get(\"ams\", {})\n                ams_list = (\n                    ams_data.get(\"ams\", [])\n                    if isinstance(ams_data, dict)\n                    else ams_data\n                    if isinstance(ams_data, list)\n                    else []\n                )\n                cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)\n                if cur_tray:\n                    current_tray_info_idx = cur_tray.get(\"tray_info_idx\", \"\")\n                    current_tray_type = cur_tray.get(\"tray_type\", \"\")\n\n        if (\n            current_tray_info_idx\n            and current_tray_info_idx not in _GENERIC_ID_VALUES\n            and current_tray_type\n            and current_tray_type.upper() == tray_type.upper()\n        ):\n            logger.info(\n                \"[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)\",\n                current_tray_info_idx,\n                tray_type,\n            )\n            effective_tray_info_idx = current_tray_info_idx\n        elif tray_type:\n            material = tray_type.upper().strip()\n            generic = (\n                _GENERIC_FILAMENT_IDS.get(material)\n                or _GENERIC_FILAMENT_IDS.get(material.split(\"-\")[0].split(\" \")[0])\n                or \"\"\n            )\n            if generic:\n                logger.info(\"[configure_ams_slot] Falling back to generic %r for material %r\", generic, tray_type)\n                effective_tray_info_idx = generic\n\n    # Send filament setting + K-profile commands\n    filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx\n\n    # Always send ams_set_filament_setting — the user explicitly clicked\n    # \"Configure Slot\", so honor that.  Previous versions skipped this for\n    # RFID-tagged slots to preserve the slicer eye icon, but printers cache\n    # stale tag_uid/tray_uuid after a BL spool is removed, causing the check\n    # to false-positive on non-RFID slots and silently drop the command.\n    success = client.ams_set_filament_setting(\n        ams_id=ams_id,\n        tray_id=tray_id,\n        tray_info_idx=effective_tray_info_idx,\n        tray_type=tray_type,\n        tray_sub_brands=tray_sub_brands,\n        tray_color=tray_color,\n        nozzle_temp_min=nozzle_temp_min,\n        nozzle_temp_max=nozzle_temp_max,\n        setting_id=setting_id,\n    )\n\n    if not success:\n        raise HTTPException(status_code=500, detail=\"Failed to send filament configuration command\")\n\n    # Method 1: Select existing calibration profile by cali_idx\n    # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,\n    # and including it causes the firmware to mislink the profile on X1C/P1S.\n    client.extrusion_cali_sel(\n        ams_id=ams_id,\n        tray_id=tray_id,\n        cali_idx=cali_idx,\n        filament_id=filament_id_for_kprofile,\n        nozzle_diameter=nozzle_diameter,\n    )\n\n    # Method 2: Only send extrusion_cali_set when NO existing profile was selected\n    # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the\n    # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY\n    # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),\n    # corrupting it — e.g., overwriting a High Flow extruder 1 profile with\n    # hardcoded extruder_id=0 and nozzle_id=HS00.\n    if k_value > 0 and cali_idx < 0:\n        # Calculate global tray ID for extrusion_cali_set\n        if ams_id <= 3:\n            global_tray_id = ams_id * 4 + tray_id\n        elif ams_id >= 128 and ams_id <= 135:\n            global_tray_id = (ams_id - 128) * 4 + tray_id\n        else:\n            global_tray_id = tray_id\n\n        client.extrusion_cali_set(\n            tray_id=global_tray_id,\n            k_value=k_value,\n            nozzle_diameter=nozzle_diameter,\n            nozzle_temp=nozzle_temp_max,\n            filament_id=filament_id_for_kprofile,\n            setting_id=kprofile_setting_id or \"\",\n            name=tray_sub_brands or \"\",\n            cali_idx=cali_idx,\n        )\n\n    # Request fresh status push from printer so frontend gets updated data via WebSocket\n    logger.info(\"[configure_ams_slot] Requesting status update from printer\")\n    update_result = client.request_status_update()\n    logger.info(\"[configure_ams_slot] Status update request result: %s\", update_result)\n\n    return {\n        \"success\": True,\n        \"message\": f\"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}\",\n    }\n\n\n@router.post(\"/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset\")\nasync def reset_ams_slot(\n    printer_id: int,\n    ams_id: int,\n    tray_id: int,\n    db: AsyncSession = Depends(get_db),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n):\n    \"\"\"Reset an AMS slot to empty/unconfigured state.\n\n    This clears the filament configuration from the slot.\n    \"\"\"\n    # Get MQTT client for this printer\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(status_code=400, detail=\"Printer not connected\")\n\n    # Reset the slot\n    success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)\n\n    if not success:\n        raise HTTPException(status_code=500, detail=\"Failed to send reset command\")\n\n    # Also delete any saved slot preset mapping\n    result = await db.execute(\n        select(SlotPresetMapping).where(\n            SlotPresetMapping.printer_id == printer_id,\n            SlotPresetMapping.ams_id == ams_id,\n            SlotPresetMapping.tray_id == tray_id,\n        )\n    )\n    mapping = result.scalar_one_or_none()\n    if mapping:\n        await db.delete(mapping)\n        await db.commit()\n\n    # Request fresh status push from printer so frontend gets updated data via WebSocket\n    client.request_status_update()\n\n    return {\n        \"success\": True,\n        \"message\": f\"Reset AMS {ams_id} tray {tray_id}\",\n    }\n\n\n@router.get(\"/{printer_id}/ams-labels\")\nasync def get_ams_labels(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get all user-defined AMS labels for a printer, keyed by AMS unit ID.\n\n    Labels are stored by AMS serial number.  This endpoint resolves the current\n    serial-to-ams_id mapping from the live printer state so the response is still\n    keyed by ams_id for UI compatibility.\n    \"\"\"\n    # Build serial -> ams_id map from live printer state\n    serial_to_ams_id: dict[str, int] = {}\n    state = printer_manager.get_status(printer_id)\n    if state and state.raw_data:\n        for ams_unit in state.raw_data.get(\"ams\", []):\n            sn = str(ams_unit.get(\"sn\") or ams_unit.get(\"serial_number\") or \"\")\n            if sn:\n                serial_to_ams_id[sn] = int(ams_unit.get(\"id\", 0))\n\n    # Collect all known serials for this printer (live + synthetic fallback keys)\n    serials_to_query = set(serial_to_ams_id.keys())\n\n    # Fetch labels for all known serials\n    labels: dict[int, str] = {}\n    if serials_to_query:\n        result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query)))\n        for lbl in result.scalars().all():\n            aid = serial_to_ams_id.get(lbl.ams_serial_number)\n            if aid is not None:\n                labels[aid] = lbl.label\n\n    # Also fetch labels stored under synthetic keys for this printer (backward compat)\n    # Collect all synthetic keys first, then query with a single IN clause.\n    if state and state.raw_data:\n        synthetic_key_to_aid: dict[str, int] = {\n            f\"p{printer_id}a{int(ams_unit.get('id', 0))}\": int(ams_unit.get(\"id\", 0))\n            for ams_unit in state.raw_data.get(\"ams\", [])\n            if int(ams_unit.get(\"id\", 0)) not in labels\n        }\n        if synthetic_key_to_aid:\n            result = await db.execute(\n                select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys()))\n            )\n            for lbl in result.scalars().all():\n                aid = synthetic_key_to_aid.get(lbl.ams_serial_number)\n                if aid is not None:\n                    labels[aid] = lbl.label\n\n    return labels\n\n\n@router.put(\"/{printer_id}/ams-labels/{ams_id}\")\nasync def save_ams_label(\n    printer_id: int,\n    ams_id: int,\n    body: AmsLabelBody,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Create or update the friendly name for a specific AMS unit.\n\n    When ``ams_serial`` is provided the label is stored under that serial number so\n    it survives the AMS being moved to a different printer.  When it is absent (e.g.\n    older firmware that does not report a serial) a synthetic key based on the\n    printer_id and ams_id is used as a fallback.\n    \"\"\"\n    # Verify printer exists\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(404, \"Printer not found\")\n\n    # Determine the serial key to store under\n    stripped = body.ams_serial.strip() if body.ams_serial else \"\"\n    serial_key = stripped if stripped else f\"p{printer_id}a{ams_id}\"\n\n    result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))\n    existing = result.scalar_one_or_none()\n\n    if existing:\n        existing.label = body.label\n        existing.ams_id = ams_id\n    else:\n        db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label))\n\n    await db.commit()\n    return {\"ams_id\": ams_id, \"label\": body.label}\n\n\n@router.delete(\"/{printer_id}/ams-labels/{ams_id}\")\nasync def delete_ams_label(\n    printer_id: int,\n    ams_id: int,\n    ams_serial: str = Query(default=\"\", max_length=50),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Delete the friendly name for a specific AMS unit, reverting to the auto label.\"\"\"\n    stripped = ams_serial.strip() if ams_serial else \"\"\n    serial_key = stripped if stripped else f\"p{printer_id}a{ams_id}\"\n\n    result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key))\n    existing = result.scalar_one_or_none()\n\n    if existing:\n        await db.delete(existing)\n        await db.commit()\n\n    return {\"success\": True}\n\n\n@router.post(\"/{printer_id}/debug/simulate-print-complete\")\nasync def debug_simulate_print_complete(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n):\n    \"\"\"DEBUG: Simulate print completion to test freeze behavior.\n\n    This triggers the same code path as a real print completion,\n    without needing to wait for an actual print to finish.\n    \"\"\"\n    from backend.app.main import _active_prints, on_print_complete\n    from backend.app.models.archive import PrintArchive\n\n    # Get the most recent archive for this printer\n    result = await db.execute(\n        select(PrintArchive)\n        .where(PrintArchive.printer_id == printer_id)\n        .order_by(PrintArchive.created_at.desc())\n        .limit(1)\n    )\n    archive = result.scalar_one_or_none()\n\n    if not archive:\n        raise HTTPException(status_code=404, detail=\"No archives found for this printer\")\n\n    # Register this archive as \"active\" so on_print_complete can find it\n    filename = archive.file_path.split(\"/\")[-1] if archive.file_path else \"test.3mf\"\n    subtask_name = archive.print_name or \"Test Print\"\n    _active_prints[(printer_id, filename)] = archive.id\n    _active_prints[(printer_id, subtask_name)] = archive.id\n\n    # Simulate print completion data\n    data = {\n        \"status\": \"completed\",\n        \"filename\": filename,\n        \"subtask_name\": subtask_name,\n        \"timelapse_was_active\": False,\n    }\n\n    logger.info(\"Simulating print complete for printer %s, archive %s\", printer_id, archive.id)\n\n    # Call the actual on_print_complete handler\n    await on_print_complete(printer_id, data)\n\n    return {\"success\": True, \"archive_id\": archive.id, \"message\": \"Print completion simulated\"}\n\n\n# =============================================================================\n# Print Control Endpoints\n# =============================================================================\n\n\n@router.post(\"/{printer_id}/print/stop\")\nasync def stop_print(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Stop/cancel the current print job.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    success = client.stop_print()\n    if not success:\n        raise HTTPException(500, \"Failed to stop print\")\n\n    return {\"success\": True, \"message\": \"Print stop command sent\"}\n\n\n@router.post(\"/{printer_id}/clear-plate\")\nasync def clear_plate(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Acknowledge that the build plate has been cleared after a finished/failed print.\n\n    Sets a plate-cleared flag so the scheduler can start the next queued print.\n    No MQTT command is sent to the printer — the scheduler's start_print command\n    will override the FINISH/FAILED state when it sends the next job.\n    \"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    if not printer_manager.is_connected(printer_id):\n        raise HTTPException(400, \"Printer not connected\")\n\n    # Accept the acknowledgment whenever the printer is awaiting it — not only when the\n    # reported state is FINISH/FAILED. After a power cycle the printer boots into IDLE\n    # but the awaiting flag persists, and the user still needs a way to ack it (#961).\n    state = printer_manager.get_status(printer_id)\n    awaiting = printer_manager.is_awaiting_plate_clear(printer_id)\n    if not awaiting and (not state or state.state not in (\"FINISH\", \"FAILED\")):\n        raise HTTPException(\n            400,\n            f\"Printer is not awaiting plate-clear acknowledgment (state={state.state if state else 'unknown'})\",\n        )\n\n    printer_manager.set_awaiting_plate_clear(printer_id, False)\n\n    return {\"success\": True, \"message\": \"Plate cleared, next print will start shortly\"}\n\n\n@router.post(\"/{printer_id}/print/pause\")\nasync def pause_print(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Pause the current print job.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    success = client.pause_print()\n    if not success:\n        raise HTTPException(500, \"Failed to pause print\")\n\n    return {\"success\": True, \"message\": \"Print pause command sent\"}\n\n\n@router.post(\"/{printer_id}/print/resume\")\nasync def resume_print(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Resume a paused print job.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    success = client.resume_print()\n    if not success:\n        raise HTTPException(500, \"Failed to resume print\")\n\n    return {\"success\": True, \"message\": \"Print resume command sent\"}\n\n\n@router.post(\"/{printer_id}/print-speed\")\nasync def set_print_speed(\n    printer_id: int,\n    mode: int = Query(..., description=\"Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)\"),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Set the print speed mode.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    success = client.set_print_speed(mode)\n    if not success:\n        raise HTTPException(500, \"Failed to set print speed\")\n\n    speed_names = {1: \"Silent\", 2: \"Standard\", 3: \"Sport\", 4: \"Ludicrous\"}\n    return {\"success\": True, \"message\": f\"Print speed set to {speed_names.get(mode, 'Unknown')}\"}\n\n\n@router.post(\"/{printer_id}/airduct-mode\")\nasync def set_airduct_mode(\n    printer_id: int,\n    mode: str = Query(..., description=\"Airduct mode: 'cooling' or 'heating'\"),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Set the airduct mode (cooling/heating) on supported printers (P2S/H2*).\"\"\"\n    if mode not in (\"cooling\", \"heating\"):\n        raise HTTPException(400, \"Mode must be 'cooling' or 'heating'\")\n\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    success = client.set_airduct_mode(mode)\n    if not success:\n        raise HTTPException(500, \"Failed to set airduct mode\")\n\n    return {\"success\": True, \"message\": f\"Airduct mode set to {mode}\"}\n\n\n@router.post(\"/{printer_id}/chamber-light\")\nasync def set_chamber_light(\n    printer_id: int,\n    on: bool = Query(..., description=\"True to turn on, False to turn off\"),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Turn the chamber light on or off.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    success = client.set_chamber_light(on)\n    if not success:\n        raise HTTPException(500, \"Failed to control chamber light\")\n\n    return {\"success\": True, \"message\": f\"Chamber light {'on' if on else 'off'}\"}\n\n\n@router.post(\"/{printer_id}/bed-jog\")\nasync def bed_jog(\n    printer_id: int,\n    distance: float = Query(\n        ..., description=\"Relative Z distance in mm (positive = bed down / nozzle further away, negative = bed up)\"\n    ),\n    force: bool = Query(False, description=\"If true, bypass soft endstops via M211 (for use when Z is not homed)\"),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Move the build plate along the Z axis by a relative distance.\n\n    Emits a short G-code sequence via MQTT. When ``force`` is true the soft\n    endstops are disabled for the duration of the move, matching the\n    \"ignore and move anyway\" option Bambu Studio offers when the printer\n    is not homed.\n    \"\"\"\n    if distance == 0 or abs(distance) > 200:\n        raise HTTPException(400, \"Distance must be non-zero and ≤ 200 mm\")\n\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    lines = []\n    if force:\n        lines.append(\"M211 S0\")\n    lines += [\"G91\", f\"G1 Z{distance:.2f} F600\", \"G90\"]\n    if force:\n        lines.append(\"M211 S1\")\n\n    if not client.send_gcode(\"\\n\".join(lines)):\n        raise HTTPException(500, \"Failed to send bed-jog command\")\n\n    return {\"success\": True, \"message\": f\"Bed jog {distance:+.1f} mm sent\"}\n\n\n@router.post(\"/{printer_id}/home-axes\")\nasync def home_axes(\n    printer_id: int,\n    axes: str = Query(\n        \"all\",\n        description=\"Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.\",\n    ),\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Run the printer's full auto-home sequence via bare `G28`.\n\n    Bambu printers (H2C / H2D / H2S / X1 family) home the Z axis by moving\n    the BED UP toward an endstop at the top of travel. If the toolhead is\n    not already parked out of the way, a bare `G28 Z` will crash the bed\n    into the toolhead — #1052 reported exactly that on H2C: the bed rose\n    without stopping at a safe height because `G28 Z` skipped the\n    toolhead-park step that a full `G28` runs first.\n\n    The endpoint therefore ignores the `axes` argument and always sends a\n    bare `G28`, which the firmware expands into a safe multi-step sequence\n    (park toolhead → home XY → home Z). The argument is kept only for\n    backward-compat with existing clients; sending an invalid value still\n    returns 400 so typos surface instead of silently proceeding.\n    \"\"\"\n    axes = axes.lower()\n    if axes not in (\"z\", \"xy\", \"all\"):\n        raise HTTPException(400, \"axes must be 'z', 'xy', or 'all'\")\n\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    if not client.send_gcode(\"G28\"):\n        raise HTTPException(500, \"Failed to send home command\")\n\n    return {\"success\": True, \"message\": \"Full auto-home sequence sent\"}\n\n\n@router.post(\"/{printer_id}/hms/clear\")\nasync def clear_hms_errors(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Clear HMS/print errors on the printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    success = client.clear_hms_errors()\n    if not success:\n        raise HTTPException(500, \"Failed to clear HMS errors\")\n\n    return {\"success\": True, \"message\": \"HMS errors cleared\"}\n\n\n@router.get(\"/{printer_id}/print/objects\")\nasync def get_printable_objects(\n    printer_id: int,\n    reload: bool = False,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get the list of printable objects for the current print.\n\n    Returns a list of objects with id, name, position (if available), and skip status.\n    Objects that have already been skipped are marked in the skipped_objects list.\n\n    Args:\n        reload: If True, reload objects from the archive file (useful after restart)\n    \"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    # Reload objects from 3MF if requested or no objects loaded\n    if reload or not client.state.printable_objects:\n        subtask_name = client.state.subtask_name\n        if subtask_name:\n            from backend.app.services.archive import extract_printable_objects_from_3mf\n            from backend.app.services.bambu_ftp import download_file_try_paths_async\n\n            # Build possible 3MF filenames (try both .gcode.3mf and .3mf)\n            possible_filenames = []\n            if subtask_name.endswith(\".3mf\"):\n                possible_filenames.append(subtask_name)\n            else:\n                possible_filenames.append(f\"{subtask_name}.gcode.3mf\")\n                possible_filenames.append(f\"{subtask_name}.3mf\")\n\n            # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)\n            if \" \" in subtask_name:\n                normalized = subtask_name.replace(\" \", \"_\")\n                if normalized.endswith(\".3mf\"):\n                    possible_filenames.append(normalized)\n                else:\n                    possible_filenames.append(f\"{normalized}.gcode.3mf\")\n                    possible_filenames.append(f\"{normalized}.3mf\")\n\n            # Download 3MF from printer\n            temp_path = settings.archive_dir / \"temp\" / f\"objects_{printer_id}_{possible_filenames[0]}\"\n            temp_path.parent.mkdir(parents=True, exist_ok=True)\n\n            # Build list of all remote paths to try\n            remote_paths = []\n            for filename in possible_filenames:\n                remote_paths.extend([f\"/{filename}\", f\"/cache/{filename}\", f\"/model/{filename}\"])\n\n            try:\n                downloaded = await download_file_try_paths_async(\n                    printer.ip_address,\n                    printer.access_code,\n                    remote_paths,\n                    temp_path,\n                    printer_model=printer.model,\n                )\n                if downloaded and temp_path.exists():\n                    with open(temp_path, \"rb\") as f:\n                        data = f.read()\n                    objects, bbox_all = extract_printable_objects_from_3mf(data, include_positions=True)\n                    if objects:\n                        client.state.printable_objects = objects\n                        client.state.printable_objects_bbox_all = bbox_all\n                        logger.info(\"Reloaded %s objects for printer %s\", len(objects), printer_id)\n            except Exception as e:\n                logger.debug(\"Failed to reload objects from printer: %s\", e)\n            finally:\n                if temp_path.exists():\n                    temp_path.unlink()\n\n    # Return objects with their skip status and position data\n    objects = []\n    for obj_id, obj_data in client.state.printable_objects.items():\n        # Handle both old format (string name) and new format (dict with name, x, y)\n        if isinstance(obj_data, dict):\n            obj_entry = {\n                \"id\": obj_id,\n                \"name\": obj_data.get(\"name\", f\"Object {obj_id}\"),\n                \"x\": obj_data.get(\"x\"),\n                \"y\": obj_data.get(\"y\"),\n                \"skipped\": obj_id in client.state.skipped_objects,\n            }\n        else:\n            # Legacy format: obj_data is just the name string\n            obj_entry = {\n                \"id\": obj_id,\n                \"name\": obj_data,\n                \"x\": None,\n                \"y\": None,\n                \"skipped\": obj_id in client.state.skipped_objects,\n            }\n        objects.append(obj_entry)\n\n    return {\n        \"objects\": objects,\n        \"total\": len(objects),\n        \"skipped_count\": len(client.state.skipped_objects),\n        \"is_printing\": client.state.state in (\"RUNNING\", \"PAUSE\"),\n        \"bbox_all\": getattr(client.state, \"printable_objects_bbox_all\", None),\n    }\n\n\n@router.post(\"/{printer_id}/print/skip-objects\")\nasync def skip_objects(\n    printer_id: int,\n    object_ids: list[int],\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Skip specific objects during the current print.\n\n    Args:\n        object_ids: List of object identify_id values to skip\n    \"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    if not object_ids:\n        raise HTTPException(400, \"No object IDs provided\")\n\n    # Validate object IDs exist in printable_objects\n    invalid_ids = [oid for oid in object_ids if oid not in client.state.printable_objects]\n    if invalid_ids:\n        raise HTTPException(400, f\"Invalid object IDs: {invalid_ids}\")\n\n    success = client.skip_objects(object_ids)\n    if not success:\n        raise HTTPException(500, \"Failed to skip objects\")\n\n    # Get names of skipped objects for response (handle both old and new format)\n    skipped_names = []\n    for oid in object_ids:\n        obj_data = client.state.printable_objects.get(oid, str(oid))\n        if isinstance(obj_data, dict):\n            skipped_names.append(obj_data.get(\"name\", str(oid)))\n        else:\n            skipped_names.append(obj_data)\n\n    return {\n        \"success\": True,\n        \"message\": f\"Skipped {len(object_ids)} object(s): {', '.join(skipped_names)}\",\n        \"skipped_objects\": object_ids,\n    }\n\n\n# =============================================================================\n# AMS Control Endpoints\n# =============================================================================\n\n\n@router.post(\"/{printer_id}/ams/{ams_id}/slot/{slot_id}/refresh\")\nasync def refresh_ams_slot(\n    printer_id: int,\n    ams_id: int,\n    slot_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Re-read RFID for an AMS slot (triggers filament info refresh).\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        raise HTTPException(400, \"Printer not connected\")\n\n    success, message = client.ams_refresh_tray(ams_id, slot_id)\n    if not success:\n        raise HTTPException(400, message)\n\n    # Apply PA profile after delay (RFID re-read takes a few seconds)\n    asyncio.create_task(_apply_pa_after_refresh(printer_id, ams_id, slot_id))\n\n    return {\"success\": True, \"message\": message}\n\n\nasync def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):\n    \"\"\"Apply PA profile after RFID re-read completes.\n\n    Waits for the printer to finish processing the RFID data, then selects\n    the K-profile via extrusion_cali_sel.  Does NOT re-send ams_set_filament_setting\n    because that would overwrite the RFID-provided filament data.\n    \"\"\"\n    await asyncio.sleep(5)\n    try:\n        from backend.app.api.routes.inventory import _find_tray_in_ams_data\n        from backend.app.core.database import async_session\n        from backend.app.models.spool import Spool\n        from backend.app.models.spool_assignment import SpoolAssignment as SA\n        from backend.app.services.spool_tag_matcher import is_bambu_tag\n\n        client = printer_manager.get_client(printer_id)\n        if not client:\n            return\n\n        state = printer_manager.get_status(printer_id)\n        if not state or not state.raw_data:\n            return\n\n        # Find current tray data (should have RFID data by now)\n        ams_data = state.raw_data.get(\"ams\", {})\n        ams_list = (\n            ams_data.get(\"ams\", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []\n        )\n        tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id)\n        if not tray or not tray.get(\"tray_type\"):\n            logger.debug(\"PA re-apply: no tray data for AMS%d-T%d\", ams_id, slot_id)\n            return\n\n        tag_uid = tray.get(\"tag_uid\", \"\")\n        tray_uuid = tray.get(\"tray_uuid\", \"\")\n        tray_info_idx = tray.get(\"tray_info_idx\", \"\")\n        if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):\n            return\n\n        async with async_session() as db:\n            from sqlalchemy import select as sa_select\n            from sqlalchemy.orm import selectinload\n\n            result = await db.execute(\n                sa_select(SA)\n                .options(selectinload(SA.spool).selectinload(Spool.k_profiles))\n                .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)\n            )\n            assignment = result.scalar_one_or_none()\n            if not assignment or not assignment.spool or not assignment.spool.k_profiles:\n                return\n\n            spool = assignment.spool\n            nozzle_diameter = \"0.4\"\n            if state.nozzles:\n                nd = state.nozzles[0].nozzle_diameter\n                if nd:\n                    nozzle_diameter = nd\n\n            # Determine slot's extruder from ams_extruder_map\n            slot_extruder = None\n            if state.ams_extruder_map:\n                if ams_id == 255:\n                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0\n                    slot_extruder = 1 - slot_id  # 0→1, 1→0\n                else:\n                    slot_extruder = state.ams_extruder_map.get(str(ams_id))\n\n            matching_kp = None\n            for kp in spool.k_profiles:\n                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:\n                    if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:\n                        continue\n                    matching_kp = kp\n                    break\n\n            if not matching_kp or matching_kp.cali_idx is None:\n                return\n\n            # The filament_id in extrusion_cali_sel must match the filament preset\n            # under which the K-profile was calibrated. Use spool.slicer_filament\n            # (the preset assigned in inventory), falling back to tray's RFID value.\n            kp_filament_id = spool.slicer_filament or tray_info_idx\n\n            logger.info(\n                \"PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s\",\n                ams_id,\n                slot_id,\n                matching_kp.cali_idx,\n                kp_filament_id,\n            )\n\n            # 1. Select K-profile\n            # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware\n            # \"this is a manual config\" which destroys the RFID-detected spool state\n            # (changes eye icon to pen icon in slicer).\n            client.extrusion_cali_sel(\n                ams_id=ams_id,\n                tray_id=slot_id,\n                cali_idx=matching_kp.cali_idx,\n                filament_id=kp_filament_id,\n                nozzle_diameter=nozzle_diameter,\n            )\n\n            # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already\n            # selected the correct profile by cali_idx. Sending extrusion_cali_set with\n            # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,\n            # nozzle_id, name), corrupting it.\n\n            logger.info(\n                \"Applied PA profile cali_idx=%d k=%.3f to printer %d AMS%d-T%d\",\n                matching_kp.cali_idx,\n                matching_kp.k_value or 0,\n                printer_id,\n                ams_id,\n                slot_id,\n            )\n    except Exception as e:\n        logger.warning(\"Failed to apply PA profile after RFID re-read: %s\", e)\n\n\n@router.get(\"/{printer_id}/runtime-debug\")\nasync def get_runtime_debug(\n    printer_id: int,\n    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Debug endpoint: Get runtime tracking status for a printer.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(404, \"Printer not found\")\n\n    state = printer_manager.get_status(printer_id)\n\n    return {\n        \"printer_name\": printer.name,\n        \"runtime_seconds\": printer.runtime_seconds,\n        \"runtime_hours\": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,\n        \"print_hours_offset\": printer.print_hours_offset,\n        \"total_hours\": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)\n        + (printer.print_hours_offset or 0),\n        \"last_runtime_update\": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,\n        \"mqtt_state\": {\n            \"connected\": state.connected if state else False,\n            \"state\": state.state if state else None,\n            \"progress\": state.progress if state else None,\n            \"gcode_file\": state.gcode_file if state else None,\n        }\n        if state\n        else None,\n        \"is_active\": printer.is_active,\n    }\n"
  },
  {
    "path": "backend/app/api/routes/projects.py",
    "content": "import io\nimport json\nimport logging\nimport os\nimport uuid\nimport zipfile\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom fastapi import APIRouter, Depends, File, HTTPException, UploadFile\nfrom fastapi.responses import FileResponse, StreamingResponse\nfrom sqlalchemy import case, func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.api.routes.library import get_library_dir\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.config import settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.library import LibraryFile, LibraryFolder\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.models.project import Project\nfrom backend.app.models.project_bom import ProjectBOMItem\nfrom backend.app.models.user import User\nfrom backend.app.schemas.project import (\n    ArchivePreview,\n    BatchAddArchives,\n    BatchAddQueueItems,\n    BOMItemCreate,\n    BOMItemResponse,\n    BOMItemUpdate,\n    ProjectChildPreview,\n    ProjectCreate,\n    ProjectImport,\n    ProjectListResponse,\n    ProjectResponse,\n    ProjectStats,\n    ProjectUpdate,\n    TimelineEvent,\n)\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/projects\", tags=[\"projects\"])\n\n\nasync def compute_project_stats(\n    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None\n) -> ProjectStats:\n    \"\"\"Compute statistics for a project.\"\"\"\n    # Count total archives (distinct print jobs)\n    total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))\n    total_archives = total_result.scalar() or 0\n\n    # Sum total items (using quantity field)\n    total_items_result = await db.execute(\n        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)\n    )\n    total_items = total_items_result.scalar() or 0\n\n    # Count failed archives (number of print jobs) - includes all failure states\n    failed_result = await db.execute(\n        select(func.count(PrintArchive.id)).where(\n            PrintArchive.project_id == project_id,\n            PrintArchive.status.in_([\"failed\", \"aborted\", \"cancelled\", \"stopped\"]),\n        )\n    )\n    failed_prints = failed_result.scalar() or 0\n\n    # Sum print time, filament, and energy\n    sums_result = await db.execute(\n        select(\n            func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label(\"total_time\"),\n            func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label(\"total_filament\"),\n            func.coalesce(func.sum(PrintArchive.cost), 0).label(\"total_filament_cost\"),\n            func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label(\"total_energy\"),\n            func.coalesce(func.sum(PrintArchive.energy_cost), 0).label(\"total_energy_cost\"),\n        ).where(PrintArchive.project_id == project_id)\n    )\n    sums = sums_result.first()\n\n    # Count queued items\n    queued_result = await db.execute(\n        select(func.count(PrintQueueItem.id)).where(\n            PrintQueueItem.project_id == project_id, PrintQueueItem.status == \"pending\"\n        )\n    )\n    queued_prints = queued_result.scalar() or 0\n\n    # Count in-progress items\n    in_progress_result = await db.execute(\n        select(func.count(PrintQueueItem.id)).where(\n            PrintQueueItem.project_id == project_id, PrintQueueItem.status == \"printing\"\n        )\n    )\n    in_progress_prints = in_progress_result.scalar() or 0\n\n    # Sum completed items (parts) - sum of quantities for actually printed jobs\n    completed_items_result = await db.execute(\n        select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(\n            PrintArchive.project_id == project_id,\n            PrintArchive.status == \"completed\",\n        )\n    )\n    completed_items = int(completed_items_result.scalar() or 0)\n\n    # Calculate progress for plates (target_count vs total_archives)\n    progress_percent = None\n    remaining_prints = None\n    if target_count and target_count > 0:\n        progress_percent = round((total_archives / target_count) * 100, 1)\n        remaining_prints = max(0, target_count - total_archives)\n\n    # Calculate progress for parts (target_parts_count vs completed_items)\n    parts_progress_percent = None\n    remaining_parts = None\n    if target_parts_count and target_parts_count > 0:\n        parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)\n        remaining_parts = max(0, target_parts_count - completed_items)\n\n    # BOM stats\n    bom_result = await db.execute(\n        select(\n            func.count(ProjectBOMItem.id).label(\"total\"),\n            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(\n                \"completed\"\n            ),\n            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label(\"bom_cost\"),\n        ).where(ProjectBOMItem.project_id == project_id)\n    )\n    bom_stats = bom_result.first()\n\n    return ProjectStats(\n        total_archives=total_archives,\n        total_items=int(total_items),\n        completed_prints=completed_items,  # Now reflects sum of quantities for completed prints\n        failed_prints=int(failed_prints),\n        queued_prints=queued_prints,\n        in_progress_prints=in_progress_prints,\n        total_print_time_hours=round((sums.total_time or 0) / 3600, 2),\n        total_filament_grams=round(sums.total_filament or 0, 2),\n        progress_percent=progress_percent,\n        parts_progress_percent=parts_progress_percent,\n        estimated_cost=round((sums.total_filament_cost or 0), 2),\n        total_energy_kwh=round((sums.total_energy or 0), 3),\n        total_energy_cost=round((sums.total_energy_cost or 0), 3),\n        remaining_prints=remaining_prints,\n        remaining_parts=remaining_parts,\n        bom_total_items=bom_stats.total or 0,\n        bom_completed_items=int(bom_stats.completed or 0),\n        bom_cost=round(float(bom_stats.bom_cost or 0), 2),\n    )\n\n\n@router.get(\"\", response_model=list[ProjectListResponse])\n@router.get(\"/\", response_model=list[ProjectListResponse])\nasync def list_projects(\n    status: str | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"List all projects with basic stats.\"\"\"\n    query = select(Project)\n    if status:\n        query = query.where(Project.status == status)\n    query = query.order_by(Project.updated_at.desc())\n\n    result = await db.execute(query)\n    projects = result.scalars().all()\n\n    # Compute quick stats for each project\n    response = []\n    for project in projects:\n        # Get archive count (number of print jobs)\n        archive_count_result = await db.execute(\n            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)\n        )\n        archive_count = archive_count_result.scalar() or 0\n\n        # Get total items (sum of quantities)\n        total_items_result = await db.execute(\n            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)\n        )\n        total_items = int(total_items_result.scalar() or 0)\n\n        # Get queue count\n        queue_count_result = await db.execute(\n            select(func.count(PrintQueueItem.id)).where(\n                PrintQueueItem.project_id == project.id,\n                PrintQueueItem.status.in_([\"pending\", \"printing\"]),\n            )\n        )\n        queue_count = queue_count_result.scalar() or 0\n\n        # Sum completed parts (quantities) - only actually printed jobs\n        completed_result = await db.execute(\n            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(\n                PrintArchive.project_id == project.id,\n                PrintArchive.status == \"completed\",\n            )\n        )\n        completed_count = int(completed_result.scalar() or 0)\n\n        # Sum failed parts (quantities) - includes all failure states\n        failed_result = await db.execute(\n            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(\n                PrintArchive.project_id == project.id,\n                PrintArchive.status.in_([\"failed\", \"aborted\", \"cancelled\", \"stopped\"]),\n            )\n        )\n        failed_count = int(failed_result.scalar() or 0)\n\n        # Plates progress: archive_count / target_count\n        progress_percent = None\n        if project.target_count and project.target_count > 0:\n            progress_percent = round((archive_count / project.target_count) * 100, 1)\n\n        # Get archive previews (up to 6 most recent)\n        archives_result = await db.execute(\n            select(PrintArchive)\n            .where(PrintArchive.project_id == project.id)\n            .order_by(PrintArchive.created_at.desc())\n            .limit(6)\n        )\n        archives = archives_result.scalars().all()\n        archive_previews = [\n            ArchivePreview(\n                id=a.id,\n                print_name=a.print_name,\n                thumbnail_path=a.thumbnail_path,\n                status=a.status,\n                filament_type=a.filament_type,\n                filament_color=a.filament_color,\n            )\n            for a in archives\n        ]\n\n        response.append(\n            ProjectListResponse(\n                id=project.id,\n                name=project.name,\n                description=project.description,\n                color=project.color,\n                status=project.status,\n                target_count=project.target_count,\n                target_parts_count=project.target_parts_count,\n                budget=project.budget,\n                created_at=project.created_at,\n                archive_count=archive_count,\n                total_items=total_items,\n                completed_count=completed_count,\n                failed_count=failed_count,\n                queue_count=queue_count,\n                progress_percent=progress_percent,\n                archives=archive_previews,\n            )\n        )\n\n    return response\n\n\n@router.post(\"/\", response_model=ProjectResponse)\nasync def create_project(\n    data: ProjectCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),\n):\n    \"\"\"Create a new project.\"\"\"\n    # Verify parent exists if specified\n    parent_name = None\n    if data.parent_id:\n        parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))\n        parent = parent_result.scalar_one_or_none()\n        if not parent:\n            raise HTTPException(status_code=400, detail=\"Parent project not found\")\n        parent_name = parent.name\n\n    project = Project(\n        name=data.name,\n        description=data.description,\n        color=data.color,\n        target_count=data.target_count,\n        target_parts_count=data.target_parts_count,\n        notes=data.notes,\n        tags=data.tags,\n        due_date=data.due_date,\n        priority=data.priority,\n        budget=data.budget,\n        parent_id=data.parent_id,\n    )\n    db.add(project)\n    await db.flush()\n    await db.refresh(project)\n\n    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)\n\n    return ProjectResponse(\n        id=project.id,\n        name=project.name,\n        description=project.description,\n        color=project.color,\n        status=project.status,\n        target_count=project.target_count,\n        target_parts_count=project.target_parts_count,\n        notes=project.notes,\n        attachments=project.attachments,\n        tags=project.tags,\n        due_date=project.due_date,\n        priority=project.priority,\n        budget=project.budget,\n        is_template=project.is_template,\n        template_source_id=project.template_source_id,\n        parent_id=project.parent_id,\n        parent_name=parent_name,\n        children=[],\n        created_at=project.created_at,\n        updated_at=project.updated_at,\n        stats=stats,\n    )\n\n\n# ============ Phase 8: Template Endpoints (Static routes BEFORE dynamic {project_id}) ============\n\n\n@router.get(\"/templates\", response_model=list[ProjectListResponse])\nasync def list_templates(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"List all project templates.\"\"\"\n    result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))\n    templates = result.scalars().all()\n\n    response = []\n    for project in templates:\n        # Get archive count\n        archive_count_result = await db.execute(\n            select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)\n        )\n        archive_count = archive_count_result.scalar() or 0\n\n        response.append(\n            ProjectListResponse(\n                id=project.id,\n                name=project.name,\n                description=project.description,\n                color=project.color,\n                status=project.status,\n                target_count=project.target_count,\n                budget=project.budget,\n                created_at=project.created_at,\n                archive_count=archive_count,\n                queue_count=0,\n                progress_percent=None,\n                archives=[],\n            )\n        )\n\n    return response\n\n\n@router.post(\"/from-template/{template_id}\", response_model=ProjectResponse)\nasync def create_project_from_template(\n    template_id: int,\n    name: str = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),\n):\n    \"\"\"Create a new project from a template.\"\"\"\n    result = await db.execute(select(Project).where(Project.id == template_id))\n    template = result.scalar_one_or_none()\n\n    if not template:\n        raise HTTPException(status_code=404, detail=\"Template not found\")\n\n    if not template.is_template:\n        raise HTTPException(status_code=400, detail=\"Project is not a template\")\n\n    # Create new project\n    project = Project(\n        name=name or template.name.replace(\" (Template)\", \"\"),\n        description=template.description,\n        color=template.color,\n        target_count=template.target_count,\n        target_parts_count=template.target_parts_count,\n        notes=template.notes,\n        tags=template.tags,\n        priority=template.priority,\n        budget=template.budget,\n        is_template=False,\n        template_source_id=template.id,\n    )\n    db.add(project)\n    await db.flush()\n\n    # Copy BOM items\n    bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == template_id))\n    bom_items = bom_result.scalars().all()\n\n    for item in bom_items:\n        new_item = ProjectBOMItem(\n            project_id=project.id,\n            name=item.name,\n            quantity_needed=item.quantity_needed,\n            quantity_acquired=0,\n            unit_price=item.unit_price,\n            sourcing_url=item.sourcing_url,\n            stl_filename=item.stl_filename,\n            remarks=item.remarks,\n            sort_order=item.sort_order,\n        )\n        db.add(new_item)\n\n    await db.flush()\n    await db.refresh(project)\n\n    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)\n\n    return ProjectResponse(\n        id=project.id,\n        name=project.name,\n        description=project.description,\n        color=project.color,\n        status=project.status,\n        target_count=project.target_count,\n        target_parts_count=project.target_parts_count,\n        notes=project.notes,\n        attachments=project.attachments,\n        tags=project.tags,\n        due_date=project.due_date,\n        priority=project.priority,\n        budget=project.budget,\n        is_template=project.is_template,\n        template_source_id=project.template_source_id,\n        parent_id=project.parent_id,\n        parent_name=None,\n        children=[],\n        created_at=project.created_at,\n        updated_at=project.updated_at,\n        stats=stats,\n    )\n\n\n# ============ Dynamic {project_id} Routes ============\n\n\nasync def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:\n    \"\"\"Get preview info for child projects.\"\"\"\n    result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))\n    children = result.scalars().all()\n\n    previews = []\n    for child in children:\n        # Get completed count for progress (sum of quantities)\n        completed_result = await db.execute(\n            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(\n                PrintArchive.project_id == child.id,\n                PrintArchive.status == \"completed\",\n            )\n        )\n        completed_count = completed_result.scalar() or 0\n        progress = None\n        if child.target_count and child.target_count > 0:\n            progress = round((int(completed_count) / child.target_count) * 100, 1)\n\n        previews.append(\n            ProjectChildPreview(\n                id=child.id,\n                name=child.name,\n                color=child.color,\n                status=child.status,\n                progress_percent=progress,\n            )\n        )\n    return previews\n\n\n@router.get(\"/{project_id}\", response_model=ProjectResponse)\nasync def get_project(\n    project_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"Get a project by ID with detailed stats.\"\"\"\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    project = result.scalar_one_or_none()\n\n    if not project:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Get parent name\n    parent_name = None\n    if project.parent_id:\n        parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))\n        parent_name = parent_result.scalar()\n\n    # Get children\n    children = await get_child_previews(db, project.id)\n\n    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)\n\n    return ProjectResponse(\n        id=project.id,\n        name=project.name,\n        description=project.description,\n        color=project.color,\n        status=project.status,\n        target_count=project.target_count,\n        target_parts_count=project.target_parts_count,\n        notes=project.notes,\n        attachments=project.attachments,\n        tags=project.tags,\n        due_date=project.due_date,\n        priority=project.priority,\n        budget=project.budget,\n        is_template=project.is_template,\n        template_source_id=project.template_source_id,\n        parent_id=project.parent_id,\n        parent_name=parent_name,\n        children=children,\n        created_at=project.created_at,\n        updated_at=project.updated_at,\n        stats=stats,\n    )\n\n\n@router.patch(\"/{project_id}\", response_model=ProjectResponse)\nasync def update_project(\n    project_id: int,\n    data: ProjectUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Update a project.\"\"\"\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    project = result.scalar_one_or_none()\n\n    if not project:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Update fields if provided\n    if data.name is not None:\n        project.name = data.name\n    if data.description is not None:\n        project.description = data.description\n    if data.color is not None:\n        project.color = data.color\n    if data.status is not None:\n        if data.status not in [\"active\", \"completed\", \"archived\"]:\n            raise HTTPException(status_code=400, detail=\"Invalid status\")\n        project.status = data.status\n    if data.target_count is not None:\n        project.target_count = data.target_count\n    if data.target_parts_count is not None:\n        project.target_parts_count = data.target_parts_count\n    if data.notes is not None:\n        project.notes = data.notes\n    if data.tags is not None:\n        project.tags = data.tags\n    if data.due_date is not None:\n        project.due_date = data.due_date\n    if data.priority is not None:\n        if data.priority not in [\"low\", \"normal\", \"high\", \"urgent\"]:\n            raise HTTPException(status_code=400, detail=\"Invalid priority\")\n        project.priority = data.priority\n    if \"budget\" in data.model_fields_set:\n        project.budget = data.budget\n    if data.parent_id is not None:\n        # Verify parent exists and prevent circular reference\n        if data.parent_id == project_id:\n            raise HTTPException(status_code=400, detail=\"Project cannot be its own parent\")\n        if data.parent_id != 0:  # 0 means remove parent\n            parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))\n            if not parent_result.scalar_one_or_none():\n                raise HTTPException(status_code=400, detail=\"Parent project not found\")\n            project.parent_id = data.parent_id\n        else:\n            project.parent_id = None\n\n    await db.flush()\n    await db.refresh(project)\n\n    # Get parent name\n    parent_name = None\n    if project.parent_id:\n        parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))\n        parent_name = parent_result.scalar()\n\n    # Get children\n    children = await get_child_previews(db, project.id)\n\n    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)\n\n    return ProjectResponse(\n        id=project.id,\n        name=project.name,\n        description=project.description,\n        color=project.color,\n        status=project.status,\n        target_count=project.target_count,\n        target_parts_count=project.target_parts_count,\n        notes=project.notes,\n        attachments=project.attachments,\n        tags=project.tags,\n        due_date=project.due_date,\n        priority=project.priority,\n        budget=project.budget,\n        is_template=project.is_template,\n        template_source_id=project.template_source_id,\n        parent_id=project.parent_id,\n        parent_name=parent_name,\n        children=children,\n        created_at=project.created_at,\n        updated_at=project.updated_at,\n        stats=stats,\n    )\n\n\n@router.delete(\"/{project_id}\")\nasync def delete_project(\n    project_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),\n):\n    \"\"\"Delete a project. Archives and queue items will have project_id set to NULL.\"\"\"\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    project = result.scalar_one_or_none()\n\n    if not project:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    await db.delete(project)\n\n    return {\"message\": \"Project deleted\"}\n\n\n@router.get(\"/{project_id}/archives\")\nasync def list_project_archives(\n    project_id: int,\n    limit: int = 100,\n    offset: int = 0,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"List archives in a project.\"\"\"\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Get archives with project relationship eagerly loaded\n    query = (\n        select(PrintArchive)\n        .options(selectinload(PrintArchive.project))\n        .where(PrintArchive.project_id == project_id)\n        .order_by(PrintArchive.created_at.desc())\n        .limit(limit)\n        .offset(offset)\n    )\n    result = await db.execute(query)\n    archives = result.scalars().all()\n\n    # Import the response converter from archives module\n    from backend.app.api.routes.archives import archive_to_response\n\n    return [archive_to_response(a) for a in archives]\n\n\n@router.get(\"/{project_id}/queue\")\nasync def list_project_queue(\n    project_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"List queue items in a project.\"\"\"\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Get queue items\n    query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)\n    result = await db.execute(query)\n    items = result.scalars().all()\n\n    return items\n\n\n@router.post(\"/{project_id}/add-archives\")\nasync def add_archives_to_project(\n    project_id: int,\n    data: BatchAddArchives,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Batch add archives to a project.\"\"\"\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Update archives\n    updated = 0\n    for archive_id in data.archive_ids:\n        result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n        archive = result.scalar_one_or_none()\n        if archive:\n            archive.project_id = project_id\n            updated += 1\n\n    return {\"message\": f\"Added {updated} archives to project\"}\n\n\n@router.post(\"/{project_id}/add-queue\")\nasync def add_queue_items_to_project(\n    project_id: int,\n    data: BatchAddQueueItems,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Batch add queue items to a project.\"\"\"\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Update queue items\n    updated = 0\n    for item_id in data.queue_item_ids:\n        result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))\n        item = result.scalar_one_or_none()\n        if item:\n            item.project_id = project_id\n            updated += 1\n\n    return {\"message\": f\"Added {updated} queue items to project\"}\n\n\n@router.post(\"/{project_id}/remove-archives\")\nasync def remove_archives_from_project(\n    project_id: int,\n    data: BatchAddArchives,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Remove archives from a project (sets project_id to NULL).\"\"\"\n    updated = 0\n    for archive_id in data.archive_ids:\n        result = await db.execute(\n            select(PrintArchive).where(\n                PrintArchive.id == archive_id,\n                PrintArchive.project_id == project_id,\n            )\n        )\n        archive = result.scalar_one_or_none()\n        if archive:\n            archive.project_id = None\n            updated += 1\n\n    return {\"message\": f\"Removed {updated} archives from project\"}\n\n\ndef get_project_attachments_dir(project_id: int) -> Path:\n    \"\"\"Get the attachments directory for a project.\"\"\"\n    base_dir = Path(settings.archive_dir)\n    return base_dir / \"projects\" / str(project_id) / \"attachments\"\n\n\n# Allowed file extensions for attachments\nALLOWED_ATTACHMENT_EXTENSIONS = {\n    # Images\n    \".jpg\",\n    \".jpeg\",\n    \".png\",\n    \".gif\",\n    \".webp\",\n    \".svg\",\n    \".bmp\",\n    \".ico\",\n    # Documents\n    \".pdf\",\n    \".doc\",\n    \".docx\",\n    \".xls\",\n    \".xlsx\",\n    \".ppt\",\n    \".pptx\",\n    \".odt\",\n    \".ods\",\n    \".odp\",\n    \".txt\",\n    \".rtf\",\n    \".csv\",\n    \".md\",\n    # 3D/CAD files\n    \".stl\",\n    \".obj\",\n    \".3mf\",\n    \".step\",\n    \".stp\",\n    \".iges\",\n    \".igs\",\n    \".f3d\",\n    \".scad\",\n    # Archives\n    \".zip\",\n    \".rar\",\n    \".7z\",\n    \".tar\",\n    \".gz\",\n    # Code/scripts (for Klipper macros, scripts, etc.)\n    \".py\",\n    \".sh\",\n    \".cfg\",\n    \".conf\",\n    \".gcode\",\n    \".ini\",\n    # Other common formats\n    \".json\",\n    \".xml\",\n    \".yaml\",\n    \".yml\",\n}\n\n\n@router.post(\"/{project_id}/attachments\")\nasync def upload_attachment(\n    project_id: int,\n    file: UploadFile = File(...),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Upload an attachment to a project.\"\"\"\n    logger.info(\"=== UPLOAD START: %s for project %s ===\", file.filename, project_id)\n\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    project = result.scalar_one_or_none()\n    if not project:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Validate file extension\n    original_name = file.filename or \"unknown\"\n    ext = os.path.splitext(original_name)[1].lower()\n    if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:\n        raise HTTPException(\n            status_code=400,\n            detail=f\"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.\",\n        )\n\n    # Create attachments directory\n    attachments_dir = get_project_attachments_dir(project_id)\n    attachments_dir.mkdir(parents=True, exist_ok=True)\n\n    # Generate unique filename\n    unique_filename = f\"{uuid.uuid4().hex}{ext}\"\n    file_path = attachments_dir / unique_filename\n\n    # Save file\n    try:\n        with open(file_path, \"wb\") as f:\n            content = await file.read()\n            f.write(content)\n        logger.info(\"=== FILE SAVED: %s, size: %s ===\", file_path, len(content))\n    except Exception as e:\n        logger.error(\"Failed to save attachment: %s\", e)\n        raise HTTPException(status_code=500, detail=\"Failed to save attachment\")\n\n    # Update project attachments JSON\n    attachments = list(project.attachments or [])\n    new_attachment = {\n        \"filename\": unique_filename,\n        \"original_name\": original_name,\n        \"size\": len(content),\n        \"uploaded_at\": datetime.now().isoformat(),\n    }\n    attachments.append(new_attachment)\n\n    # Simple ORM update\n    project.attachments = attachments\n    db.add(project)  # Explicitly add to session\n\n    logger.info(\"=== BEFORE COMMIT: %s attachments ===\", len(attachments))\n\n    await db.flush()\n    await db.commit()\n\n    logger.info(\"=== AFTER COMMIT ===\")\n\n    # Verify by re-querying\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    fresh_project = result.scalar_one()\n\n    logger.info(\"=== VERIFIED: %s attachments ===\", len(fresh_project.attachments or []))\n\n    return {\n        \"status\": \"success\",\n        \"filename\": unique_filename,\n        \"original_name\": original_name,\n        \"attachments\": fresh_project.attachments,\n    }\n\n\n@router.get(\"/{project_id}/attachments/{filename}\")\nasync def download_attachment(\n    project_id: int,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"Download an attachment from a project.\"\"\"\n    # Validate filename to prevent path traversal\n    if \"/\" in filename or \"\\\\\" in filename or \"..\" in filename or not filename:\n        raise HTTPException(status_code=400, detail=\"Invalid filename\")\n\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    project = result.scalar_one_or_none()\n    if not project:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Verify attachment exists in project\n    attachments = project.attachments or []\n    attachment = next((a for a in attachments if a.get(\"filename\") == filename), None)\n    if not attachment:\n        raise HTTPException(status_code=404, detail=\"Attachment not found\")\n\n    # Check file exists\n    file_path = get_project_attachments_dir(project_id) / filename\n    if not file_path.exists():\n        raise HTTPException(status_code=404, detail=\"Attachment file not found\")\n\n    return FileResponse(\n        file_path,\n        filename=attachment.get(\"original_name\", filename),\n        media_type=\"application/octet-stream\",\n    )\n\n\n@router.delete(\"/{project_id}/attachments/{filename}\")\nasync def delete_attachment(\n    project_id: int,\n    filename: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Delete an attachment from a project.\"\"\"\n    # Validate filename to prevent path traversal\n    if \"/\" in filename or \"\\\\\" in filename or \"..\" in filename or not filename:\n        raise HTTPException(status_code=400, detail=\"Invalid filename\")\n\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    project = result.scalar_one_or_none()\n    if not project:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Find and remove attachment from list\n    attachments = project.attachments or []\n    attachment = next((a for a in attachments if a.get(\"filename\") == filename), None)\n    if not attachment:\n        raise HTTPException(status_code=404, detail=\"Attachment not found\")\n\n    # Remove from list\n    attachments = [a for a in attachments if a.get(\"filename\") != filename]\n    project.attachments = attachments if attachments else None\n\n    # Delete file\n    file_path = get_project_attachments_dir(project_id) / filename\n    if file_path.exists():\n        try:\n            os.remove(file_path)\n        except Exception as e:\n            logger.warning(\"Failed to delete attachment file: %s\", e)\n\n    await db.flush()\n    await db.refresh(project)\n\n    return {\n        \"status\": \"success\",\n        \"message\": \"Attachment deleted\",\n        \"attachments\": project.attachments,\n    }\n\n\n# ============ Phase 7: BOM Endpoints ============\n\n\n@router.get(\"/{project_id}/bom\", response_model=list[BOMItemResponse])\nasync def list_bom_items(\n    project_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"List all BOM items for a project.\"\"\"\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Get BOM items\n    result = await db.execute(\n        select(ProjectBOMItem)\n        .where(ProjectBOMItem.project_id == project_id)\n        .order_by(ProjectBOMItem.sort_order, ProjectBOMItem.id)\n    )\n    items = result.scalars().all()\n\n    response = []\n    for item in items:\n        # Get archive name if linked\n        archive_name = None\n        if item.archive_id:\n            archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))\n            archive_name = archive_result.scalar()\n\n        response.append(\n            BOMItemResponse(\n                id=item.id,\n                project_id=item.project_id,\n                name=item.name,\n                quantity_needed=item.quantity_needed,\n                quantity_acquired=item.quantity_acquired,\n                unit_price=item.unit_price,\n                sourcing_url=item.sourcing_url,\n                archive_id=item.archive_id,\n                archive_name=archive_name,\n                stl_filename=item.stl_filename,\n                remarks=item.remarks,\n                sort_order=item.sort_order,\n                is_complete=item.quantity_acquired >= item.quantity_needed,\n                created_at=item.created_at,\n                updated_at=item.updated_at,\n            )\n        )\n\n    return response\n\n\n@router.post(\"/{project_id}/bom\", response_model=BOMItemResponse)\nasync def create_bom_item(\n    project_id: int,\n    data: BOMItemCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Add a BOM item to a project.\"\"\"\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Get max sort order\n    max_order_result = await db.execute(\n        select(func.max(ProjectBOMItem.sort_order)).where(ProjectBOMItem.project_id == project_id)\n    )\n    max_order = max_order_result.scalar() or 0\n\n    item = ProjectBOMItem(\n        project_id=project_id,\n        name=data.name,\n        quantity_needed=data.quantity_needed,\n        unit_price=data.unit_price,\n        sourcing_url=data.sourcing_url,\n        archive_id=data.archive_id,\n        stl_filename=data.stl_filename,\n        remarks=data.remarks,\n        sort_order=max_order + 1,\n    )\n    db.add(item)\n    await db.flush()\n    await db.refresh(item)\n\n    # Get archive name if linked\n    archive_name = None\n    if item.archive_id:\n        archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))\n        archive_name = archive_result.scalar()\n\n    return BOMItemResponse(\n        id=item.id,\n        project_id=item.project_id,\n        name=item.name,\n        quantity_needed=item.quantity_needed,\n        quantity_acquired=item.quantity_acquired,\n        unit_price=item.unit_price,\n        sourcing_url=item.sourcing_url,\n        archive_id=item.archive_id,\n        archive_name=archive_name,\n        stl_filename=item.stl_filename,\n        remarks=item.remarks,\n        sort_order=item.sort_order,\n        is_complete=item.quantity_acquired >= item.quantity_needed,\n        created_at=item.created_at,\n        updated_at=item.updated_at,\n    )\n\n\n@router.patch(\"/{project_id}/bom/{item_id}\", response_model=BOMItemResponse)\nasync def update_bom_item(\n    project_id: int,\n    item_id: int,\n    data: BOMItemUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Update a BOM item.\"\"\"\n    result = await db.execute(\n        select(ProjectBOMItem).where(\n            ProjectBOMItem.id == item_id,\n            ProjectBOMItem.project_id == project_id,\n        )\n    )\n    item = result.scalar_one_or_none()\n\n    if not item:\n        raise HTTPException(status_code=404, detail=\"BOM item not found\")\n\n    if data.name is not None:\n        item.name = data.name\n    if data.quantity_needed is not None:\n        item.quantity_needed = data.quantity_needed\n    if data.quantity_acquired is not None:\n        item.quantity_acquired = data.quantity_acquired\n    if data.unit_price is not None:\n        item.unit_price = data.unit_price if data.unit_price != 0 else None\n    if data.sourcing_url is not None:\n        item.sourcing_url = data.sourcing_url if data.sourcing_url else None\n    if data.archive_id is not None:\n        item.archive_id = data.archive_id if data.archive_id != 0 else None\n    if data.stl_filename is not None:\n        item.stl_filename = data.stl_filename if data.stl_filename else None\n    if data.remarks is not None:\n        item.remarks = data.remarks if data.remarks else None\n\n    await db.flush()\n    await db.refresh(item)\n\n    # Get archive name if linked\n    archive_name = None\n    if item.archive_id:\n        archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))\n        archive_name = archive_result.scalar()\n\n    return BOMItemResponse(\n        id=item.id,\n        project_id=item.project_id,\n        name=item.name,\n        quantity_needed=item.quantity_needed,\n        quantity_acquired=item.quantity_acquired,\n        unit_price=item.unit_price,\n        sourcing_url=item.sourcing_url,\n        archive_id=item.archive_id,\n        archive_name=archive_name,\n        stl_filename=item.stl_filename,\n        remarks=item.remarks,\n        sort_order=item.sort_order,\n        is_complete=item.quantity_acquired >= item.quantity_needed,\n        created_at=item.created_at,\n        updated_at=item.updated_at,\n    )\n\n\n@router.delete(\"/{project_id}/bom/{item_id}\")\nasync def delete_bom_item(\n    project_id: int,\n    item_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),\n):\n    \"\"\"Delete a BOM item.\"\"\"\n    result = await db.execute(\n        select(ProjectBOMItem).where(\n            ProjectBOMItem.id == item_id,\n            ProjectBOMItem.project_id == project_id,\n        )\n    )\n    item = result.scalar_one_or_none()\n\n    if not item:\n        raise HTTPException(status_code=404, detail=\"BOM item not found\")\n\n    await db.delete(item)\n\n    return {\"status\": \"success\", \"message\": \"BOM item deleted\"}\n\n\n@router.post(\"/{project_id}/create-template\", response_model=ProjectResponse)\nasync def create_template_from_project(\n    project_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),\n):\n    \"\"\"Create a template from an existing project.\"\"\"\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    source = result.scalar_one_or_none()\n\n    if not source:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Create template\n    template = Project(\n        name=f\"{source.name} (Template)\",\n        description=source.description,\n        color=source.color,\n        target_count=source.target_count,\n        target_parts_count=source.target_parts_count,\n        notes=source.notes,\n        tags=source.tags,\n        priority=source.priority,\n        budget=source.budget,\n        is_template=True,\n        template_source_id=source.id,\n    )\n    db.add(template)\n    await db.flush()\n\n    # Copy BOM items\n    bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id))\n    bom_items = bom_result.scalars().all()\n\n    for item in bom_items:\n        new_item = ProjectBOMItem(\n            project_id=template.id,\n            name=item.name,\n            quantity_needed=item.quantity_needed,\n            quantity_acquired=0,\n            unit_price=item.unit_price,\n            sourcing_url=item.sourcing_url,\n            stl_filename=item.stl_filename,\n            remarks=item.remarks,\n            sort_order=item.sort_order,\n        )\n        db.add(new_item)\n\n    await db.flush()\n    await db.refresh(template)\n\n    stats = await compute_project_stats(db, template.id, template.target_count, template.target_parts_count)\n\n    return ProjectResponse(\n        id=template.id,\n        name=template.name,\n        description=template.description,\n        color=template.color,\n        status=template.status,\n        target_count=template.target_count,\n        target_parts_count=template.target_parts_count,\n        notes=template.notes,\n        attachments=template.attachments,\n        tags=template.tags,\n        due_date=template.due_date,\n        priority=template.priority,\n        budget=template.budget,\n        is_template=template.is_template,\n        template_source_id=template.template_source_id,\n        parent_id=template.parent_id,\n        parent_name=None,\n        children=[],\n        created_at=template.created_at,\n        updated_at=template.updated_at,\n        stats=stats,\n    )\n\n\n# ============ Phase 9: Timeline Endpoint ============\n\n\n@router.get(\"/{project_id}/timeline\", response_model=list[TimelineEvent])\nasync def get_project_timeline(\n    project_id: int,\n    limit: int = 50,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"Get timeline of events for a project.\"\"\"\n    # Verify project exists\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    project = result.scalar_one_or_none()\n    if not project:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    events = []\n\n    # Project creation event\n    events.append(\n        TimelineEvent(\n            event_type=\"project_created\",\n            timestamp=project.created_at,\n            title=\"Project created\",\n            description=f\"Project '{project.name}' was created\",\n        )\n    )\n\n    # Get archives and add events\n    archives_result = await db.execute(\n        select(PrintArchive)\n        .where(PrintArchive.project_id == project_id)\n        .order_by(PrintArchive.created_at.desc())\n        .limit(limit)\n    )\n    archives = archives_result.scalars().all()\n\n    for archive in archives:\n        if archive.status == \"completed\":\n            events.append(\n                TimelineEvent(\n                    event_type=\"print_completed\",\n                    timestamp=archive.completed_at or archive.created_at,\n                    title=\"Print completed\",\n                    description=archive.print_name,\n                    metadata={\n                        \"archive_id\": archive.id,\n                        \"print_time_hours\": round((archive.print_time_seconds or 0) / 3600, 2),\n                        \"filament_grams\": round(archive.filament_used_grams or 0, 1),\n                    },\n                )\n            )\n        elif archive.status == \"failed\":\n            events.append(\n                TimelineEvent(\n                    event_type=\"print_failed\",\n                    timestamp=archive.completed_at or archive.created_at,\n                    title=\"Print failed\",\n                    description=archive.print_name,\n                    metadata={\"archive_id\": archive.id},\n                )\n            )\n\n    # Get queue items\n    queue_result = await db.execute(\n        select(PrintQueueItem)\n        .where(PrintQueueItem.project_id == project_id)\n        .order_by(PrintQueueItem.created_at.desc())\n        .limit(limit)\n    )\n    queue_items = queue_result.scalars().all()\n\n    for item in queue_items:\n        if item.status == \"printing\":\n            events.append(\n                TimelineEvent(\n                    event_type=\"print_started\",\n                    timestamp=item.started_at or item.created_at,\n                    title=\"Print started\",\n                    description=item.print_name,\n                    metadata={\"queue_item_id\": item.id},\n                )\n            )\n        elif item.status == \"pending\":\n            events.append(\n                TimelineEvent(\n                    event_type=\"queued\",\n                    timestamp=item.created_at,\n                    title=\"Added to queue\",\n                    description=item.print_name,\n                    metadata={\"queue_item_id\": item.id},\n                )\n            )\n\n    # Sort by timestamp descending\n    events.sort(key=lambda e: e.timestamp, reverse=True)\n\n    return events[:limit]\n\n\n# ============ Phase 10: Import/Export Endpoints ============\n\n\n@router.get(\"/{project_id}/export\")\nasync def export_project(\n    project_id: int,\n    format: str = \"zip\",  # \"zip\" (with files) or \"json\" (metadata only)\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),\n):\n    \"\"\"Export a project. Use format=zip (default) for full export with files, or format=json for metadata only.\"\"\"\n    result = await db.execute(select(Project).where(Project.id == project_id))\n    project = result.scalar_one_or_none()\n\n    if not project:\n        raise HTTPException(status_code=404, detail=\"Project not found\")\n\n    # Get BOM items\n    bom_result = await db.execute(\n        select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id).order_by(ProjectBOMItem.sort_order)\n    )\n    bom_items = bom_result.scalars().all()\n\n    bom_export = [\n        {\n            \"name\": item.name,\n            \"quantity_needed\": item.quantity_needed,\n            \"quantity_acquired\": item.quantity_acquired,\n            \"unit_price\": item.unit_price,\n            \"sourcing_url\": item.sourcing_url,\n            \"stl_filename\": item.stl_filename,\n            \"remarks\": item.remarks,\n        }\n        for item in bom_items\n    ]\n\n    # Get linked folders and their files\n    folders_result = await db.execute(\n        select(LibraryFolder).where(LibraryFolder.project_id == project_id).order_by(LibraryFolder.name)\n    )\n    linked_folders = folders_result.scalars().all()\n\n    folders_export = []\n    files_to_include = []  # (archive_path, zip_path)\n\n    for folder in linked_folders:\n        # Get files in this folder\n        files_result = await db.execute(\n            select(LibraryFile).where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)\n        )\n        files = files_result.scalars().all()\n\n        folder_files = []\n        for f in files:\n            folder_files.append(\n                {\n                    \"filename\": f.filename,\n                    \"file_type\": f.file_type,\n                    \"notes\": f.notes,\n                }\n            )\n            # Add file to include in ZIP\n            library_dir = get_library_dir()\n            file_path = library_dir / f.file_path\n            if file_path.exists():\n                zip_path = f\"files/{folder.name}/{f.filename}\"\n                files_to_include.append((file_path, zip_path))\n                # Also include thumbnail if exists\n                if f.thumbnail_path:\n                    thumb_path = library_dir / f.thumbnail_path\n                    if thumb_path.exists():\n                        thumb_zip_path = f\"files/{folder.name}/.thumbnails/{f.filename}.png\"\n                        files_to_include.append((thumb_path, thumb_zip_path))\n\n        folders_export.append(\n            {\n                \"name\": folder.name,\n                \"files\": folder_files,\n            }\n        )\n\n    # Build project JSON\n    project_data = {\n        \"name\": project.name,\n        \"description\": project.description,\n        \"color\": project.color,\n        \"status\": project.status,\n        \"target_count\": project.target_count,\n        \"target_parts_count\": project.target_parts_count,\n        \"notes\": project.notes,\n        \"tags\": project.tags,\n        \"due_date\": project.due_date.isoformat() if project.due_date else None,\n        \"priority\": project.priority,\n        \"budget\": project.budget,\n        \"bom_items\": bom_export,\n        \"linked_folders\": folders_export,\n    }\n\n    # Return JSON if requested (for bulk export)\n    if format == \"json\":\n        return project_data\n\n    # Create ZIP in memory\n    zip_buffer = io.BytesIO()\n    with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        # Add project.json\n        zf.writestr(\"project.json\", json.dumps(project_data, indent=2))\n\n        # Add files\n        for file_path, zip_path in files_to_include:\n            zf.write(file_path, zip_path)\n\n    zip_buffer.seek(0)\n\n    # Generate filename\n    safe_name = \"\".join(c if c.isalnum() or c in \"-_ \" else \"_\" for c in project.name)\n    filename = f\"{safe_name}_{datetime.now().strftime('%Y-%m-%d')}.zip\"\n\n    return StreamingResponse(\n        zip_buffer,\n        media_type=\"application/zip\",\n        headers={\"Content-Disposition\": f'attachment; filename=\"{filename}\"'},\n    )\n\n\n@router.post(\"/import\", response_model=ProjectResponse)\nasync def import_project(\n    data: ProjectImport,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),\n):\n    \"\"\"Import a project with optional BOM items and linked folders.\"\"\"\n    # Create the project\n    project = Project(\n        name=data.name,\n        description=data.description,\n        color=data.color,\n        status=data.status,\n        target_count=data.target_count,\n        target_parts_count=data.target_parts_count,\n        notes=data.notes,\n        tags=data.tags,\n        due_date=data.due_date,\n        priority=data.priority,\n        budget=data.budget,\n    )\n    db.add(project)\n    await db.flush()\n\n    # Create BOM items\n    for idx, bom_data in enumerate(data.bom_items):\n        bom_item = ProjectBOMItem(\n            project_id=project.id,\n            name=bom_data.name,\n            quantity_needed=bom_data.quantity_needed,\n            quantity_acquired=bom_data.quantity_acquired,\n            unit_price=bom_data.unit_price,\n            sourcing_url=bom_data.sourcing_url,\n            stl_filename=bom_data.stl_filename,\n            remarks=bom_data.remarks,\n            sort_order=idx,\n        )\n        db.add(bom_item)\n\n    # Create linked folders in library\n    for folder_data in data.linked_folders:\n        # Check if folder with this name already exists at root level\n        existing_result = await db.execute(\n            select(LibraryFolder).where(\n                LibraryFolder.name == folder_data.name,\n                LibraryFolder.parent_id.is_(None),\n            )\n        )\n        existing_folder = existing_result.scalar_one_or_none()\n\n        if existing_folder:\n            # Link existing folder to project\n            existing_folder.project_id = project.id\n        else:\n            # Create new folder linked to project\n            new_folder = LibraryFolder(\n                name=folder_data.name,\n                project_id=project.id,\n                is_external=False,\n                external_readonly=False,\n                external_show_hidden=False,\n            )\n            db.add(new_folder)\n\n    await db.flush()\n    await db.refresh(project)\n\n    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)\n\n    return ProjectResponse(\n        id=project.id,\n        name=project.name,\n        description=project.description,\n        color=project.color,\n        status=project.status,\n        target_count=project.target_count,\n        target_parts_count=project.target_parts_count,\n        notes=project.notes,\n        attachments=project.attachments,\n        tags=project.tags,\n        due_date=project.due_date,\n        priority=project.priority,\n        budget=project.budget,\n        is_template=project.is_template,\n        template_source_id=project.template_source_id,\n        parent_id=project.parent_id,\n        parent_name=None,\n        children=[],\n        created_at=project.created_at,\n        updated_at=project.updated_at,\n        stats=stats,\n    )\n\n\n@router.post(\"/import/file\", response_model=ProjectResponse)\nasync def import_project_file(\n    file: UploadFile = File(...),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),\n):\n    \"\"\"Import a project from a ZIP or JSON file.\"\"\"\n    if not file.filename:\n        raise HTTPException(status_code=400, detail=\"No filename provided\")\n\n    # Determine file type\n    filename_lower = file.filename.lower()\n    content = await file.read()\n\n    if filename_lower.endswith(\".zip\"):\n        # Extract project.json from ZIP\n        try:\n            with zipfile.ZipFile(io.BytesIO(content)) as zf:\n                if \"project.json\" not in zf.namelist():\n                    raise HTTPException(status_code=400, detail=\"ZIP must contain project.json\")\n                project_json = zf.read(\"project.json\")\n                data = json.loads(project_json)\n\n                # Get list of files in the ZIP\n                zip_files = {name: zf.read(name) for name in zf.namelist() if name.startswith(\"files/\")}\n        except zipfile.BadZipFile:\n            raise HTTPException(status_code=400, detail=\"Invalid ZIP file\")\n    elif filename_lower.endswith(\".json\"):\n        try:\n            data = json.loads(content)\n            zip_files = {}\n        except json.JSONDecodeError:\n            raise HTTPException(status_code=400, detail=\"Invalid JSON file\")\n    else:\n        raise HTTPException(status_code=400, detail=\"File must be .zip or .json\")\n\n    # Create the project\n    project = Project(\n        name=data.get(\"name\", \"Imported Project\"),\n        description=data.get(\"description\"),\n        color=data.get(\"color\"),\n        status=data.get(\"status\", \"active\"),\n        target_count=data.get(\"target_count\"),\n        target_parts_count=data.get(\"target_parts_count\"),\n        notes=data.get(\"notes\"),\n        tags=data.get(\"tags\"),\n        due_date=datetime.fromisoformat(data[\"due_date\"]) if data.get(\"due_date\") else None,\n        priority=data.get(\"priority\", 0),\n        budget=data.get(\"budget\"),\n    )\n    db.add(project)\n    await db.flush()\n\n    # Create BOM items\n    for idx, bom_data in enumerate(data.get(\"bom_items\", [])):\n        bom_item = ProjectBOMItem(\n            project_id=project.id,\n            name=bom_data.get(\"name\", \"Unnamed\"),\n            quantity_needed=bom_data.get(\"quantity_needed\", 1),\n            quantity_acquired=bom_data.get(\"quantity_acquired\", 0),\n            unit_price=bom_data.get(\"unit_price\"),\n            sourcing_url=bom_data.get(\"sourcing_url\"),\n            stl_filename=bom_data.get(\"stl_filename\"),\n            remarks=bom_data.get(\"remarks\"),\n            sort_order=idx,\n        )\n        db.add(bom_item)\n\n    # Create linked folders and files\n    library_dir = get_library_dir()\n    for folder_data in data.get(\"linked_folders\", []):\n        folder_name = folder_data.get(\"name\")\n        if not folder_name:\n            continue\n\n        # Check if folder exists\n        existing_result = await db.execute(\n            select(LibraryFolder).where(\n                LibraryFolder.name == folder_name,\n                LibraryFolder.parent_id.is_(None),\n            )\n        )\n        existing_folder = existing_result.scalar_one_or_none()\n\n        if existing_folder:\n            # Link existing folder to project\n            existing_folder.project_id = project.id\n            folder = existing_folder\n        else:\n            # Create new folder\n            folder = LibraryFolder(\n                name=folder_name,\n                project_id=project.id,\n                is_external=False,\n                external_readonly=False,\n                external_show_hidden=False,\n            )\n            db.add(folder)\n            await db.flush()\n\n            # Create folder on disk\n            folder_path = library_dir / folder_name\n            folder_path.mkdir(parents=True, exist_ok=True)\n\n        # Import files for this folder from ZIP\n        folder_prefix = f\"files/{folder_name}/\"\n        for zip_path, file_content in zip_files.items():\n            if not zip_path.startswith(folder_prefix):\n                continue\n            if \"/.thumbnails/\" in zip_path:\n                continue  # Skip thumbnails, we'll regenerate them\n\n            relative_path = zip_path[len(folder_prefix) :]\n            if not relative_path:\n                continue\n\n            # Write file to disk\n            file_disk_path = library_dir / folder_name / relative_path\n            file_disk_path.parent.mkdir(parents=True, exist_ok=True)\n            file_disk_path.write_bytes(file_content)\n\n            # Determine file type\n            ext = Path(relative_path).suffix.lower()\n            if ext in [\".stl\", \".3mf\", \".obj\"]:\n                file_type = \"model\"\n            elif ext in [\".gcode\"]:\n                file_type = \"gcode\"\n            elif ext in [\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\"]:\n                file_type = \"image\"\n            else:\n                file_type = \"other\"\n\n            # Create library file record\n            lib_file = LibraryFile(\n                folder_id=folder.id,\n                filename=relative_path,\n                file_path=f\"{folder_name}/{relative_path}\",\n                file_type=file_type,\n                file_size=len(file_content),\n                is_external=False,\n            )\n            db.add(lib_file)\n\n    await db.flush()\n    await db.refresh(project)\n\n    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)\n\n    return ProjectResponse(\n        id=project.id,\n        name=project.name,\n        description=project.description,\n        color=project.color,\n        status=project.status,\n        target_count=project.target_count,\n        target_parts_count=project.target_parts_count,\n        notes=project.notes,\n        attachments=project.attachments,\n        tags=project.tags,\n        due_date=project.due_date,\n        priority=project.priority,\n        budget=project.budget,\n        is_template=project.is_template,\n        template_source_id=project.template_source_id,\n        parent_id=project.parent_id,\n        parent_name=None,\n        children=[],\n        created_at=project.created_at,\n        updated_at=project.updated_at,\n        stats=stats,\n    )\n"
  },
  {
    "path": "backend/app/api/routes/settings.py",
    "content": "import io\nimport logging\nimport os\nimport zipfile\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom fastapi import APIRouter, Depends, File, UploadFile\nfrom fastapi.responses import FileResponse, JSONResponse\nfrom sqlalchemy import delete, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.config import settings as app_settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.user import User\nfrom backend.app.schemas.settings import AppSettings, AppSettingsUpdate\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/settings\", tags=[\"settings\"])\n\n# Default settings\nDEFAULT_SETTINGS = AppSettings()\n\n\nasync def get_setting(db: AsyncSession, key: str) -> str | None:\n    \"\"\"Get a single setting value by key.\"\"\"\n    result = await db.execute(select(Settings).where(Settings.key == key))\n    setting = result.scalar_one_or_none()\n    return setting.value if setting else None\n\n\nasync def get_external_login_url(db: AsyncSession) -> str:\n    \"\"\"Get the external URL for the login page.\n\n    Uses external_url from settings if available, otherwise falls back to APP_URL env var.\n\n    Args:\n        db: Database session\n\n    Returns:\n        Full URL to the login page\n    \"\"\"\n    import os\n\n    external_url = await get_setting(db, \"external_url\")\n    if external_url:\n        external_url = external_url.rstrip(\"/\")\n    else:\n        external_url = os.environ.get(\"APP_URL\", \"http://localhost:5173\")\n    return external_url + \"/login\"\n\n\nasync def set_setting(db: AsyncSession, key: str, value: str) -> None:\n    \"\"\"Set a single setting value.\"\"\"\n    from backend.app.core.db_dialect import upsert_setting\n\n    await upsert_setting(db, Settings, key, value)\n\n\n@router.get(\"\", response_model=AppSettings)\n@router.get(\"/\", response_model=AppSettings)\nasync def get_settings(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get all application settings.\"\"\"\n    settings_dict = DEFAULT_SETTINGS.model_dump()\n\n    # Load saved settings from database\n    result = await db.execute(select(Settings))\n    db_settings = result.scalars().all()\n\n    for setting in db_settings:\n        if setting.key in settings_dict:\n            # Parse the value based on the expected type\n            if setting.key in [\n                \"auto_archive\",\n                \"save_thumbnails\",\n                \"capture_finish_photo\",\n                \"spoolman_enabled\",\n                \"spoolman_disable_weight_sync\",\n                \"spoolman_report_partial_usage\",\n                \"disable_filament_warnings\",\n                \"prefer_lowest_filament\",\n                \"check_updates\",\n                \"check_printer_firmware\",\n                \"include_beta_updates\",\n                \"virtual_printer_enabled\",\n                \"ftp_retry_enabled\",\n                \"mqtt_enabled\",\n                \"mqtt_use_tls\",\n                \"ha_enabled\",\n                \"per_printer_mapping_expanded\",\n                \"prometheus_enabled\",\n                \"user_notifications_enabled\",\n                \"queue_drying_enabled\",\n                \"queue_drying_block\",\n                \"ambient_drying_enabled\",\n                \"require_plate_clear\",\n                \"queue_shortest_first\",\n                \"default_bed_levelling\",\n                \"default_flow_cali\",\n                \"default_vibration_cali\",\n                \"default_layer_inspect\",\n                \"default_timelapse\",\n                \"ldap_enabled\",\n                \"ldap_auto_provision\",\n            ]:\n                settings_dict[setting.key] = setting.value.lower() == \"true\"\n            elif setting.key in [\n                \"default_filament_cost\",\n                \"energy_cost_per_kwh\",\n                \"ams_temp_good\",\n                \"ams_temp_fair\",\n                \"library_disk_warning_gb\",\n                \"low_stock_threshold\",\n            ]:\n                settings_dict[setting.key] = float(setting.value)\n            elif setting.key in [\n                \"ams_humidity_good\",\n                \"ams_humidity_fair\",\n                \"ams_history_retention_days\",\n                \"ftp_retry_count\",\n                \"ftp_retry_delay\",\n                \"ftp_timeout\",\n                \"mqtt_port\",\n                \"stagger_group_size\",\n                \"stagger_interval_minutes\",\n            ]:\n                settings_dict[setting.key] = int(setting.value)\n            elif setting.key == \"default_printer_id\":\n                # Handle nullable integer\n                settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != \"None\" else None\n            else:\n                settings_dict[setting.key] = setting.value\n\n    # Get Home Assistant settings (with environment variable overrides)\n    ha_settings = await get_homeassistant_settings(db)\n    settings_dict.update(ha_settings)\n\n    # Never return LDAP bind password in API responses\n    settings_dict[\"ldap_bind_password\"] = \"\"\n\n    return AppSettings(**settings_dict)\n\n\n@router.put(\"/\", response_model=AppSettings)\nasync def update_settings(\n    settings_update: AppSettingsUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Update application settings.\"\"\"\n    update_data = settings_update.model_dump(exclude_unset=True)\n\n    # Check if any MQTT settings are being updated\n    mqtt_keys = {\n        \"mqtt_enabled\",\n        \"mqtt_broker\",\n        \"mqtt_port\",\n        \"mqtt_username\",\n        \"mqtt_password\",\n        \"mqtt_topic_prefix\",\n        \"mqtt_use_tls\",\n    }\n    mqtt_updated = bool(mqtt_keys & set(update_data.keys()))\n\n    for key, value in update_data.items():\n        # Convert value to string for storage\n        if isinstance(value, bool):\n            str_value = \"true\" if value else \"false\"\n        elif value is None:\n            str_value = \"None\"\n        else:\n            str_value = str(value)\n        await set_setting(db, key, str_value)\n\n    await db.commit()\n    # Expire all objects to ensure fresh reads after commit\n    db.expire_all()\n\n    # Reconfigure MQTT relay if any MQTT settings changed\n    if mqtt_updated:\n        try:\n            from backend.app.services.mqtt_relay import mqtt_relay\n\n            mqtt_settings = {\n                \"mqtt_enabled\": (await get_setting(db, \"mqtt_enabled\") or \"false\") == \"true\",\n                \"mqtt_broker\": await get_setting(db, \"mqtt_broker\") or \"\",\n                \"mqtt_port\": int(await get_setting(db, \"mqtt_port\") or \"1883\"),\n                \"mqtt_username\": await get_setting(db, \"mqtt_username\") or \"\",\n                \"mqtt_password\": await get_setting(db, \"mqtt_password\") or \"\",\n                \"mqtt_topic_prefix\": await get_setting(db, \"mqtt_topic_prefix\") or \"bambuddy\",\n                \"mqtt_use_tls\": (await get_setting(db, \"mqtt_use_tls\") or \"false\") == \"true\",\n            }\n            await mqtt_relay.configure(mqtt_settings)\n        except Exception:\n            pass  # Don't fail the settings update if MQTT reconfiguration fails\n\n    # Return updated settings\n    return await get_settings(db)\n\n\n@router.patch(\"/\", response_model=AppSettings)\n@router.patch(\"\", response_model=AppSettings)\nasync def patch_settings(\n    settings_update: AppSettingsUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Partially update application settings (same as PUT, for REST compatibility).\"\"\"\n    return await update_settings(settings_update, db, _)\n\n\n@router.post(\"/reset\", response_model=AppSettings)\nasync def reset_settings(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Reset all settings to defaults.\"\"\"\n    # Delete all settings\n    result = await db.execute(select(Settings))\n    for setting in result.scalars().all():\n        await db.delete(setting)\n\n    await db.commit()\n\n    return DEFAULT_SETTINGS\n\n\n@router.get(\"/default-sidebar-order\")\nasync def get_default_sidebar_order(\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get the admin-set default sidebar order.\n\n    Intentionally unauthenticated: non-admin users need to read this value to apply\n    the default sidebar order, but may lack SETTINGS_READ permission.\n    The value is non-sensitive (sidebar item IDs only).\n    \"\"\"\n    value = await get_setting(db, \"default_sidebar_order\")\n    return {\"default_sidebar_order\": value or \"\"}\n\n\n@router.get(\"/check-ffmpeg\")\nasync def check_ffmpeg():\n    \"\"\"Check if ffmpeg is installed and available.\"\"\"\n    from backend.app.services.camera import get_ffmpeg_path\n\n    ffmpeg_path = get_ffmpeg_path()\n\n    return {\n        \"installed\": ffmpeg_path is not None,\n        \"path\": ffmpeg_path,\n    }\n\n\n@router.get(\"/spoolman\")\nasync def get_spoolman_settings(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get Spoolman integration settings.\"\"\"\n    spoolman_enabled = await get_setting(db, \"spoolman_enabled\") or \"false\"\n    spoolman_url = await get_setting(db, \"spoolman_url\") or \"\"\n    spoolman_sync_mode = await get_setting(db, \"spoolman_sync_mode\") or \"auto\"\n    spoolman_disable_weight_sync = await get_setting(db, \"spoolman_disable_weight_sync\") or \"false\"\n    spoolman_report_partial_usage = await get_setting(db, \"spoolman_report_partial_usage\") or \"true\"\n\n    return {\n        \"spoolman_enabled\": spoolman_enabled,\n        \"spoolman_url\": spoolman_url,\n        \"spoolman_sync_mode\": spoolman_sync_mode,\n        \"spoolman_disable_weight_sync\": spoolman_disable_weight_sync,\n        \"spoolman_report_partial_usage\": spoolman_report_partial_usage,\n    }\n\n\n@router.put(\"/spoolman\")\nasync def update_spoolman_settings(\n    settings: dict,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Update Spoolman integration settings.\"\"\"\n    if \"spoolman_enabled\" in settings:\n        old_val = await get_setting(db, \"spoolman_enabled\") or \"false\"\n        new_val = settings[\"spoolman_enabled\"]\n        await set_setting(db, \"spoolman_enabled\", new_val)\n\n        # Switching to Spoolman: clear built-in inventory slot assignments\n        if old_val.lower() != \"true\" and new_val.lower() == \"true\":\n            from backend.app.models.spool_assignment import SpoolAssignment\n\n            result = await db.execute(delete(SpoolAssignment))\n            logger.info(\"Cleared %d spool assignments on switch to Spoolman mode\", result.rowcount)\n    if \"spoolman_url\" in settings:\n        await set_setting(db, \"spoolman_url\", settings[\"spoolman_url\"])\n    if \"spoolman_sync_mode\" in settings:\n        await set_setting(db, \"spoolman_sync_mode\", settings[\"spoolman_sync_mode\"])\n    if \"spoolman_disable_weight_sync\" in settings:\n        await set_setting(db, \"spoolman_disable_weight_sync\", settings[\"spoolman_disable_weight_sync\"])\n    if \"spoolman_report_partial_usage\" in settings:\n        await set_setting(db, \"spoolman_report_partial_usage\", settings[\"spoolman_report_partial_usage\"])\n\n    await db.commit()\n    db.expire_all()\n\n    # Return updated settings\n    return await get_spoolman_settings(db)\n\n\nasync def get_homeassistant_settings(db: AsyncSession) -> dict:\n    \"\"\"\n    Get Home Assistant integration settings.\n    Environment variables (HA_URL, HA_TOKEN) take precedence over database settings.\n    \"\"\"\n    import os\n\n    # Check environment variables first\n    ha_url_env = os.environ.get(\"HA_URL\")\n    ha_token_env = os.environ.get(\"HA_TOKEN\")\n\n    # Fall back to database values\n    ha_url = ha_url_env or await get_setting(db, \"ha_url\") or \"\"\n    ha_token = ha_token_env or await get_setting(db, \"ha_token\") or \"\"\n    ha_enabled_db = await get_setting(db, \"ha_enabled\") or \"false\"\n\n    # Track which settings come from environment\n    ha_url_from_env = bool(ha_url_env)\n    ha_token_from_env = bool(ha_token_env)\n    ha_env_managed = ha_url_from_env and ha_token_from_env\n\n    # Auto-enable when both env vars are set, otherwise use database value\n    if ha_url_env and ha_token_env:\n        ha_enabled = True\n    else:\n        ha_enabled = ha_enabled_db.lower() == \"true\"\n\n    return {\n        \"ha_enabled\": ha_enabled,\n        \"ha_url\": ha_url,\n        \"ha_token\": ha_token,\n        \"ha_url_from_env\": ha_url_from_env,\n        \"ha_token_from_env\": ha_token_from_env,\n        \"ha_env_managed\": ha_env_managed,\n    }\n\n\nasync def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]:\n    \"\"\"Create a complete backup ZIP (database + all data directories).\n\n    If output_path is given, the ZIP is written there.\n    Otherwise a temporary file is created (caller must clean up).\n    Returns (zip_path, filename).\n    \"\"\"\n    import shutil\n    import tempfile\n\n    from backend.app.core.db_dialect import is_sqlite\n\n    base_dir = app_settings.base_dir\n    filename = f\"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip\"\n\n    with tempfile.TemporaryDirectory() as temp_dir:\n        temp_path = Path(temp_dir)\n\n        if is_sqlite():\n            from sqlalchemy import text\n\n            from backend.app.core.database import engine\n\n            db_path = Path(app_settings.database_url.replace(\"sqlite+aiosqlite:///\", \"\"))\n\n            # Checkpoint WAL to ensure all data is in main db file\n            async with engine.begin() as conn:\n                await conn.execute(text(\"PRAGMA wal_checkpoint(TRUNCATE)\"))\n\n            # Copy database file\n            shutil.copy2(db_path, temp_path / \"bambuddy.db\")\n        else:\n            # PostgreSQL: export to a portable SQLite file via SQLAlchemy.\n            # This makes backups restorable on both SQLite and Postgres installs.\n            import json\n            import sqlite3\n\n            from backend.app.core.database import Base, engine\n\n            backup_db_path = temp_path / \"bambuddy.db\"\n            dst = sqlite3.connect(str(backup_db_path))\n            metadata = Base.metadata\n\n            # Create tables in SQLite backup (simplified — just column names and types)\n            for table in metadata.sorted_tables:\n                cols = []\n                pk_cols = [col.name for col in table.columns if col.primary_key]\n                for col in table.columns:\n                    col_type = \"TEXT\"  # Default\n                    type_str = str(col.type).upper()\n                    if \"INT\" in type_str:\n                        col_type = \"INTEGER\"\n                    elif \"FLOAT\" in type_str or \"REAL\" in type_str or \"NUMERIC\" in type_str:\n                        col_type = \"REAL\"\n                    elif \"BOOL\" in type_str:\n                        col_type = \"BOOLEAN\"\n                    # Only inline PRIMARY KEY for single-column PKs\n                    pk = \" PRIMARY KEY\" if col.primary_key and len(pk_cols) == 1 else \"\"\n                    cols.append(f\"{col.name} {col_type}{pk}\")\n                # Add composite primary key constraint if needed\n                if len(pk_cols) > 1:\n                    cols.append(f\"PRIMARY KEY ({', '.join(pk_cols)})\")\n                dst.execute(f\"CREATE TABLE IF NOT EXISTS {table.name} ({', '.join(cols)})\")  # noqa: S608\n\n            # Export data from Postgres to SQLite\n            async with engine.connect() as conn:\n                for table in metadata.sorted_tables:\n                    result = await conn.execute(table.select())\n                    rows = result.fetchall()\n                    if not rows:\n                        continue\n                    columns = list(result.keys())\n                    placeholders = \", \".join([\"?\"] * len(columns))\n                    col_list = \", \".join(columns)\n                    insert_sql = f\"INSERT INTO {table.name} ({col_list}) VALUES ({placeholders})\"  # noqa: S608  # nosec B608 — table/column names from ORM metadata, not user input\n\n                    def _serialize_row(row):\n                        return tuple(json.dumps(v) if isinstance(v, (list, dict)) else v for v in row)\n\n                    dst.executemany(insert_sql, [_serialize_row(row) for row in rows])\n\n            dst.commit()\n            dst.close()\n            logger.info(\"PostgreSQL backup exported to portable SQLite format\")\n\n        # Copy data directories (if they exist)\n        dirs_to_backup = [\n            (\"archive\", base_dir / \"archive\"),\n            (\"virtual_printer\", base_dir / \"virtual_printer\"),\n            (\"plate_calibration\", app_settings.plate_calibration_dir),\n            (\"icons\", base_dir / \"icons\"),\n            (\"projects\", base_dir / \"projects\"),\n        ]\n\n        for name, src_dir in dirs_to_backup:\n            if src_dir.exists() and any(src_dir.iterdir()):\n                try:\n                    shutil.copytree(src_dir, temp_path / name)\n                except shutil.Error as e:\n                    logger.warning(\"Some files in %s could not be copied: %s\", name, e)\n                except PermissionError as e:\n                    logger.warning(\"Permission denied copying %s: %s\", name, e)\n\n        # Create ZIP\n        if output_path is not None:\n            zip_file = output_path / filename\n        else:\n            fd, tmp = tempfile.mkstemp(suffix=\".zip\")\n            os.close(fd)\n            zip_file = Path(tmp)\n\n        with zipfile.ZipFile(zip_file, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            for file_path in temp_path.rglob(\"*\"):\n                if file_path.is_file():\n                    arcname = file_path.relative_to(temp_path)\n                    zf.write(file_path, arcname)\n\n    return zip_file, filename\n\n\n@router.get(\"/backup\")\nasync def create_backup(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),\n):\n    \"\"\"Create a complete backup (database + all files) as a ZIP download.\"\"\"\n    from starlette.background import BackgroundTask\n\n    try:\n        zip_file, filename = await create_backup_zip()\n        return FileResponse(\n            path=zip_file,\n            filename=filename,\n            media_type=\"application/zip\",\n            background=BackgroundTask(lambda: zip_file.unlink(missing_ok=True)),\n        )\n    except Exception as e:\n        logger.error(\"Backup failed: %s\", e, exc_info=True)\n        return JSONResponse(\n            status_code=500,\n            content={\"success\": False, \"message\": \"Backup failed. Check server logs for details.\"},\n        )\n\n\nasync def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):\n    \"\"\"Import data from a SQLite database file into the current PostgreSQL database.\n\n    Used for cross-database restore (SQLite backup → PostgreSQL).\n    Reads all tables from the SQLite file and bulk-inserts into Postgres.\n    \"\"\"\n    import sqlite3\n\n    from sqlalchemy import text\n\n    from backend.app.core.database import Base, _create_engine\n\n    # Create a temporary engine for the import (current engine was disposed)\n    pg_engine = _create_engine()\n\n    try:\n        # Open SQLite file directly (sync — it's a local file read)\n        src = sqlite3.connect(str(sqlite_path))\n        src.row_factory = sqlite3.Row\n\n        # Get list of tables from SQLite (skip internal/FTS tables)\n        cursor = src.execute(\n            \"SELECT name FROM sqlite_master WHERE type='table' \"\n            \"AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'archive_fts%'\"\n        )\n        src_tables = {row[\"name\"] for row in cursor.fetchall()}\n\n        # Get Postgres tables from our ORM models\n        metadata = Base.metadata\n        pg_tables = set(metadata.tables.keys())\n\n        # Only import tables that exist in both source and destination\n        tables_to_import = src_tables & pg_tables\n        sorted_tables = [t.name for t in metadata.sorted_tables if t.name in tables_to_import]\n\n        # Phase 1: Drop all tables and recreate WITHOUT foreign keys.\n        # This avoids all FK ordering/orphan issues during import.\n        saved_fks = {}\n        for table in metadata.sorted_tables:\n            fks = list(table.foreign_key_constraints)\n            if fks:\n                saved_fks[table.name] = fks\n                for fk in fks:\n                    table.constraints.discard(fk)\n\n        async with pg_engine.begin() as conn:\n            await conn.run_sync(metadata.drop_all)\n            await conn.run_sync(metadata.create_all)\n\n        # Restore FK definitions in metadata (needed for re-adding later)\n        for table_name, fks in saved_fks.items():\n            table_obj = metadata.tables[table_name]\n            for fk in fks:\n                table_obj.constraints.add(fk)\n\n        # Phase 2: Import data (no FKs to worry about)\n        async with pg_engine.begin() as conn:\n            # Import each table in dependency order (parents before children)\n            for table_name in sorted_tables:\n                rows = src.execute(f\"SELECT * FROM {table_name}\").fetchall()  # noqa: S608  # nosec B608\n                if not rows:\n                    continue\n\n                # Filter to columns that exist in the Postgres table\n                src_columns = rows[0].keys()\n                pg_table = metadata.tables.get(table_name)\n                pg_columns = {c.name for c in pg_table.columns} if pg_table is not None else set()\n                columns = [c for c in src_columns if c in pg_columns]\n\n                if not columns:\n                    continue\n\n                col_list = \", \".join(columns)\n                param_list = \", \".join(f\":{c}\" for c in columns)\n                # ON CONFLICT DO NOTHING handles duplicate rows from SQLite (which doesn't enforce unique constraints)\n                insert_sql = text(f\"INSERT INTO {table_name} ({col_list}) VALUES ({param_list}) ON CONFLICT DO NOTHING\")  # noqa: S608  # nosec B608\n\n                # Identify columns that need type conversion (SQLite stores booleans\n                # as int and datetimes as str — asyncpg requires native Python types)\n                from datetime import datetime as dt\n\n                bool_columns = set()\n                datetime_columns = set()\n                not_null_defaults = {}  # col_name -> default value for NOT NULL columns\n                if pg_table is not None:\n                    for col in pg_table.columns:\n                        if col.name not in columns:\n                            continue\n                        col_type = str(col.type)\n                        if col_type == \"BOOLEAN\":\n                            bool_columns.add(col.name)\n                        elif col_type in (\"DATETIME\", \"TIMESTAMP WITHOUT TIME ZONE\", \"TIMESTAMP WITH TIME ZONE\"):\n                            datetime_columns.add(col.name)\n                        # Track NOT NULL columns with defaults — older backups may have NULL\n                        # for columns added after the backup was created\n                        if not col.nullable:\n                            if col.default is not None:\n                                default = col.default.arg\n                                if callable(default):\n                                    default = default(None)\n                                not_null_defaults[col.name] = default\n                            elif col.server_default is not None:\n                                # server_default=func.now() → use current timestamp\n                                if col.name in datetime_columns:\n                                    not_null_defaults[col.name] = \"__now__\"\n                                else:\n                                    # Try to extract literal server default\n                                    sd = str(col.server_default.arg) if hasattr(col.server_default, \"arg\") else None\n                                    if sd is not None:\n                                        not_null_defaults[col.name] = sd\n\n                now = dt.now()\n\n                def _convert_row(\n                    row, cols=columns, bools=bool_columns, dts=datetime_columns, nn_defaults=not_null_defaults, _now=now\n                ):\n                    result = {}\n                    for c in cols:\n                        val = row[c]\n                        if val is None and c in nn_defaults:\n                            val = _now if nn_defaults[c] == \"__now__\" else nn_defaults[c]\n                        if val is not None:\n                            if c in bools:\n                                val = bool(val)\n                            elif c in dts and isinstance(val, str):\n                                try:\n                                    val = dt.fromisoformat(val)\n                                except ValueError:\n                                    pass\n                        result[c] = val\n                    return result\n\n                batch = [_convert_row(row) for row in rows]\n                await conn.execute(insert_sql, batch)\n                logger.info(\"Imported %d rows into %s\", len(batch), table_name)\n\n            # Reset sequences to max(id) + 1 for each table with an id column\n            for table_name in sorted_tables:\n                try:\n                    async with conn.begin_nested():\n                        result = await conn.execute(text(f\"SELECT MAX(id) FROM {table_name}\"))  # noqa: S608  # nosec B608\n                        max_id = result.scalar()\n                        if max_id is not None:\n                            seq_name = f\"{table_name}_id_seq\"\n                            await conn.execute(text(f\"SELECT setval('{seq_name}', {max_id})\"))  # noqa: S608\n                except Exception:\n                    pass  # Table may not have an id column or sequence\n\n        src.close()\n        logger.info(\"Cross-database import complete: %d tables imported\", len(tables_to_import))\n\n        # Recreate FK constraints from ORM metadata (not from saved definitions).\n        # Use individual transactions so orphaned SQLite data doesn't block valid FKs.\n        from sqlalchemy.schema import AddConstraint\n\n        failed_fks = []\n        for table in metadata.sorted_tables:\n            for fk in table.foreign_key_constraints:\n                try:\n                    async with pg_engine.begin() as fk_conn:\n                        await fk_conn.execute(AddConstraint(fk))\n                except Exception:\n                    failed_fks.append(f\"{table.name}.{fk.name}\")\n        if failed_fks:\n            logger.warning(\n                \"Could not restore %d FK constraints (orphaned data in SQLite): %s\",\n                len(failed_fks),\n                \", \".join(failed_fks),\n            )\n\n    finally:\n        await pg_engine.dispose()\n\n\n@router.post(\"/restore\")\nasync def restore_backup(\n    file: UploadFile = File(...),\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),\n):\n    \"\"\"Restore from a complete backup ZIP.\n\n    Replaces the database and all data directories from the backup ZIP.\n    Requires a restart after restore.\n    \"\"\"\n    import shutil\n    import tempfile\n\n    from fastapi import HTTPException\n\n    from backend.app.core.database import close_all_connections, init_db, reinitialize_database\n    from backend.app.core.db_dialect import is_sqlite\n    from backend.app.services.virtual_printer import virtual_printer_manager\n\n    base_dir = app_settings.base_dir\n\n    with tempfile.TemporaryDirectory() as temp_dir:\n        temp_path = Path(temp_dir)\n\n        # 1. Read and extract ZIP\n        content = await file.read()\n\n        # Check if it's a valid ZIP\n        if not file.filename or not file.filename.endswith(\".zip\"):\n            raise HTTPException(400, \"Invalid backup file: must be a .zip file\")\n\n        try:\n            with zipfile.ZipFile(io.BytesIO(content), \"r\") as zf:\n                zf.extractall(temp_path)\n        except zipfile.BadZipFile:\n            raise HTTPException(400, \"Invalid backup file: not a valid ZIP\")\n\n        # 2. Validate backup\n        backup_db = temp_path / \"bambuddy.db\"\n        if not backup_db.exists():\n            raise HTTPException(400, \"Invalid backup: missing bambuddy.db\")\n\n        try:\n            import asyncio\n\n            # 3. Stop virtual printer if running (releases file locks)\n            try:\n                if virtual_printer_manager.is_enabled:\n                    logger.info(\"Stopping virtual printer for restore...\")\n                    await virtual_printer_manager.configure(enabled=False)\n                    await asyncio.sleep(1)\n            except Exception as e:\n                logger.warning(\"Failed to stop virtual printer: %s\", e)\n\n            # 4. Close current database connections\n            logger.info(\"Closing database connections...\")\n            await close_all_connections()\n\n            # 5. Replace database\n            logger.info(\"Restoring database from backup...\")\n            if is_sqlite():\n                db_path = Path(app_settings.database_url.replace(\"sqlite+aiosqlite:///\", \"\"))\n                shutil.copy2(backup_db, db_path)\n            else:\n                # Import SQLite backup into PostgreSQL\n                logger.info(\"Importing SQLite backup into PostgreSQL...\")\n                await _import_sqlite_to_postgres(backup_db, app_settings.database_url)\n\n            # 6. Replace data directories\n            # For Docker compatibility: clear contents then copy (don't delete mount points)\n            dirs_to_restore = [\n                (\"archive\", base_dir / \"archive\"),\n                (\"virtual_printer\", base_dir / \"virtual_printer\"),\n                (\"plate_calibration\", app_settings.plate_calibration_dir),\n                (\"icons\", base_dir / \"icons\"),\n                (\"projects\", base_dir / \"projects\"),\n            ]\n\n            skipped_dirs = []\n            for name, dest_dir in dirs_to_restore:\n                src_dir = temp_path / name\n                if src_dir.exists():\n                    logger.info(\"Restoring %s directory...\", name)\n                    try:\n                        # Clear destination contents (not the dir itself - may be Docker mount)\n                        if dest_dir.exists():\n                            for item in dest_dir.iterdir():\n                                try:\n                                    if item.is_dir():\n                                        shutil.rmtree(item)\n                                    else:\n                                        item.unlink()\n                                except OSError as e:\n                                    logger.warning(\"Could not delete %s: %s\", item, e)\n                        else:\n                            dest_dir.mkdir(parents=True, exist_ok=True)\n                        # Copy contents from backup\n                        for item in src_dir.iterdir():\n                            dest_item = dest_dir / item.name\n                            if item.is_dir():\n                                shutil.copytree(item, dest_item)\n                            else:\n                                shutil.copy2(item, dest_item)\n                    except OSError as e:\n                        logger.warning(\"Could not restore %s directory: %s\", name, e)\n                        skipped_dirs.append(name)\n\n            # 7. Reinitialize the database engine and apply schema migrations so that\n            # tables added after the backup was created (e.g. ams_labels) exist\n            # immediately, without requiring a manual restart.\n            await reinitialize_database()\n            await init_db()\n\n            logger.info(\"Restore complete - restart required\")\n            message = \"Backup restored successfully. Please restart Bambuddy for changes to take effect.\"\n            if skipped_dirs:\n                message += f\" Note: Some directories could not be restored ({', '.join(skipped_dirs)}).\"\n            return {\n                \"success\": True,\n                \"message\": message,\n            }\n\n        except Exception as e:\n            logger.error(\"Restore failed: %s\", e, exc_info=True)\n            return JSONResponse(\n                status_code=500,\n                content={\"success\": False, \"message\": \"Restore failed. Check server logs for details.\"},\n            )\n\n\n@router.get(\"/network-interfaces\")\nasync def get_network_interfaces(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get available network interfaces with all IPs (primary + aliases).\"\"\"\n    from backend.app.services.network_utils import get_all_interface_ips\n\n    interfaces = get_all_interface_ips()\n    return {\"interfaces\": interfaces}\n\n\n@router.get(\"/virtual-printer/models\")\nasync def get_virtual_printer_models(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get available virtual printer models.\"\"\"\n    from backend.app.services.virtual_printer import (\n        DEFAULT_VIRTUAL_PRINTER_MODEL,\n        VIRTUAL_PRINTER_MODELS,\n    )\n\n    return {\n        \"models\": VIRTUAL_PRINTER_MODELS,\n        \"default\": DEFAULT_VIRTUAL_PRINTER_MODEL,\n    }\n\n\n@router.get(\"/virtual-printer\")\nasync def get_virtual_printer_settings(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get virtual printer settings and status.\"\"\"\n    from backend.app.services.virtual_printer import (\n        DEFAULT_VIRTUAL_PRINTER_MODEL,\n        virtual_printer_manager,\n    )\n\n    enabled = await get_setting(db, \"virtual_printer_enabled\")\n    access_code = await get_setting(db, \"virtual_printer_access_code\")\n    mode = await get_setting(db, \"virtual_printer_mode\")\n    model = await get_setting(db, \"virtual_printer_model\")\n    target_printer_id = await get_setting(db, \"virtual_printer_target_printer_id\")\n    remote_interface_ip = await get_setting(db, \"virtual_printer_remote_interface_ip\")\n\n    return {\n        \"enabled\": enabled == \"true\" if enabled else False,\n        \"access_code_set\": bool(access_code),\n        \"mode\": mode or \"immediate\",\n        \"model\": model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n        \"target_printer_id\": int(target_printer_id) if target_printer_id else None,\n        \"remote_interface_ip\": remote_interface_ip or \"\",\n        \"status\": virtual_printer_manager.get_status(),\n    }\n\n\n@router.put(\"/virtual-printer\")\nasync def update_virtual_printer_settings(\n    enabled: bool = None,\n    access_code: str = None,\n    mode: str = None,\n    model: str = None,\n    target_printer_id: int = None,\n    remote_interface_ip: str = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Update virtual printer settings and restart services if needed.\n\n    For proxy mode with SSDP proxy (dual-homed setup):\n    - remote_interface_ip: IP of interface on slicer's network (LAN B)\n    - Local interface is auto-detected based on target printer IP\n    \"\"\"\n    from sqlalchemy import select\n\n    from backend.app.models.printer import Printer\n    from backend.app.services.virtual_printer import (\n        DEFAULT_VIRTUAL_PRINTER_MODEL,\n        VIRTUAL_PRINTER_MODELS,\n        virtual_printer_manager,\n    )\n\n    # Get current values\n    current_enabled = await get_setting(db, \"virtual_printer_enabled\") == \"true\"\n    current_access_code = await get_setting(db, \"virtual_printer_access_code\") or \"\"\n    current_mode = await get_setting(db, \"virtual_printer_mode\") or \"immediate\"\n    current_model = await get_setting(db, \"virtual_printer_model\") or DEFAULT_VIRTUAL_PRINTER_MODEL\n    current_target_id_str = await get_setting(db, \"virtual_printer_target_printer_id\")\n    current_target_id = int(current_target_id_str) if current_target_id_str else None\n    current_remote_iface = await get_setting(db, \"virtual_printer_remote_interface_ip\") or \"\"\n\n    # Apply updates\n    new_enabled = enabled if enabled is not None else current_enabled\n    new_access_code = access_code if access_code is not None else current_access_code\n    new_mode = mode if mode is not None else current_mode\n    new_model = model if model is not None else current_model\n    new_target_id = target_printer_id if target_printer_id is not None else current_target_id\n    new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface\n\n    # Validate mode\n    # \"review\" is the new name for \"queue\" (pending review before archiving)\n    # \"print_queue\" archives and adds to print queue (unassigned)\n    # \"proxy\" is transparent TCP proxy to a real printer\n    if new_mode not in (\"immediate\", \"queue\", \"review\", \"print_queue\", \"proxy\"):\n        return JSONResponse(\n            status_code=400,\n            content={\"detail\": \"Mode must be 'immediate', 'review', 'print_queue', or 'proxy'\"},\n        )\n    # Normalize legacy \"queue\" to \"review\" for storage\n    if new_mode == \"queue\":\n        new_mode = \"review\"\n\n    # Validate model\n    if model is not None and model not in VIRTUAL_PRINTER_MODELS:\n        return JSONResponse(\n            status_code=400,\n            content={\"detail\": f\"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}\"},\n        )\n\n    # Mode-specific validation and printer lookup\n    target_printer_ip = \"\"\n    target_printer_serial = \"\"\n    if new_mode == \"proxy\":\n        # Proxy mode requires target printer when enabling\n        if new_enabled and not new_target_id:\n            # If just switching to proxy mode (not explicitly enabling), auto-disable\n            if enabled is None:\n                new_enabled = False\n            else:\n                return JSONResponse(\n                    status_code=400,\n                    content={\"detail\": \"Target printer is required for proxy mode\"},\n                )\n\n        # Look up printer IP and serial if we have a target\n        if new_target_id:\n            result = await db.execute(select(Printer).where(Printer.id == new_target_id))\n            printer = result.scalar_one_or_none()\n            if not printer:\n                return JSONResponse(\n                    status_code=400,\n                    content={\"detail\": f\"Printer with ID {new_target_id} not found\"},\n                )\n            target_printer_ip = printer.ip_address\n            target_printer_serial = printer.serial_number\n        # Access code not required for proxy mode\n    else:\n        # Non-proxy modes require access code when enabling\n        if new_enabled and not new_access_code:\n            # If just switching modes (not explicitly enabling), auto-disable\n            if enabled is None:\n                new_enabled = False\n            else:\n                return JSONResponse(\n                    status_code=400,\n                    content={\"detail\": \"Access code is required when enabling virtual printer\"},\n                )\n\n        # Validate access code length (Bambu Studio requires exactly 8 characters)\n        if access_code is not None and access_code and len(access_code) != 8:\n            return JSONResponse(\n                status_code=400,\n                content={\"detail\": \"Access code must be exactly 8 characters\"},\n            )\n\n    # Save settings\n    await set_setting(db, \"virtual_printer_enabled\", \"true\" if new_enabled else \"false\")\n    if access_code is not None:\n        await set_setting(db, \"virtual_printer_access_code\", access_code)\n    await set_setting(db, \"virtual_printer_mode\", new_mode)\n    if model is not None:\n        await set_setting(db, \"virtual_printer_model\", model)\n    if target_printer_id is not None:\n        await set_setting(db, \"virtual_printer_target_printer_id\", str(target_printer_id))\n    if remote_interface_ip is not None:\n        await set_setting(db, \"virtual_printer_remote_interface_ip\", remote_interface_ip)\n    await db.commit()\n    db.expire_all()\n\n    # Reconfigure virtual printer\n    try:\n        await virtual_printer_manager.configure(\n            enabled=new_enabled,\n            access_code=new_access_code,\n            mode=new_mode,\n            model=new_model,\n            target_printer_ip=target_printer_ip,\n            target_printer_serial=target_printer_serial,\n            remote_interface_ip=new_remote_iface,\n        )\n    except ValueError as e:\n        logger.warning(\"Virtual printer configuration validation error: %s\", e)\n        return JSONResponse(\n            status_code=400,\n            content={\"detail\": \"Invalid virtual printer configuration. Check the provided values.\"},\n        )\n    except Exception as e:\n        logger.error(\"Failed to configure virtual printer: %s\", e, exc_info=True)\n        return JSONResponse(\n            status_code=500,\n            content={\"detail\": \"Failed to configure virtual printer. Check server logs for details.\"},\n        )\n\n    return await get_virtual_printer_settings(db)\n\n\n# =============================================================================\n# MQTT Relay Settings\n# =============================================================================\n\n\n@router.get(\"/mqtt/status\")\nasync def get_mqtt_status(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get MQTT relay connection status.\"\"\"\n    from backend.app.services.mqtt_relay import mqtt_relay\n\n    return mqtt_relay.get_status()\n"
  },
  {
    "path": "backend/app/api/routes/smart_plugs.py",
    "content": "\"\"\"API routes for smart plug management.\"\"\"\n\nimport logging\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi import APIRouter, Body, Depends, HTTPException\nfrom pydantic import BaseModel\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.api.routes.settings import get_setting\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.smart_plug import SmartPlug\nfrom backend.app.models.user import User\nfrom backend.app.schemas.smart_plug import (\n    HAEntity,\n    HASensorEntity,\n    HATestConnectionRequest,\n    HATestConnectionResponse,\n    RESTTestConnectionRequest,\n    RESTTestConnectionResponse,\n    SmartPlugControl,\n    SmartPlugCreate,\n    SmartPlugEnergy,\n    SmartPlugResponse,\n    SmartPlugStatus,\n    SmartPlugTestConnection,\n    SmartPlugUpdate,\n)\nfrom backend.app.services.discovery import tasmota_scanner\nfrom backend.app.services.homeassistant import homeassistant_service\nfrom backend.app.services.mqtt_relay import mqtt_relay\nfrom backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt\nfrom backend.app.services.notification_service import notification_service\nfrom backend.app.services.printer_manager import printer_manager\nfrom backend.app.services.rest_smart_plug import rest_smart_plug_service\nfrom backend.app.services.tasmota import tasmota_service\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/smart-plugs\", tags=[\"smart-plugs\"])\n\n\n@router.get(\"/\", response_model=list[SmartPlugResponse])\nasync def list_smart_plugs(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"List all smart plugs.\"\"\"\n    result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))\n    return list(result.scalars().all())\n\n\n@router.post(\"/\", response_model=SmartPlugResponse)\nasync def create_smart_plug(\n    data: SmartPlugCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),\n):\n    \"\"\"Create a new smart plug.\"\"\"\n    # Validate printer_id if provided\n    if data.printer_id:\n        result = await db.execute(select(Printer).where(Printer.id == data.printer_id))\n        if not result.scalar_one_or_none():\n            raise HTTPException(400, \"Printer not found\")\n\n        # Check if printer already has a plug assigned\n        # Tasmota plugs: only one per printer (physical power device)\n        # HA entities: allow multiple per printer (for different automations)\n        if data.plug_type == \"tasmota\":\n            result = await db.execute(\n                select(SmartPlug).where(\n                    SmartPlug.printer_id == data.printer_id,\n                    SmartPlug.plug_type == \"tasmota\",\n                )\n            )\n            if result.scalar_one_or_none():\n                raise HTTPException(400, \"This printer already has a Tasmota plug assigned\")\n\n    # For MQTT plugs, ensure MQTT broker is configured and service is connected\n    if data.plug_type == \"mqtt\":\n        # Try to configure the smart plug service if not already configured\n        if not mqtt_relay.smart_plug_service.is_configured():\n            # Get MQTT broker settings from database\n            mqtt_broker = await get_setting(db, \"mqtt_broker\") or \"\"\n            if not mqtt_broker:\n                raise HTTPException(\n                    400,\n                    \"MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.\",\n                )\n\n            # Configure the smart plug service with broker settings\n            mqtt_settings = {\n                \"mqtt_enabled\": True,  # Enable for smart plug subscription\n                \"mqtt_broker\": mqtt_broker,\n                \"mqtt_port\": int(await get_setting(db, \"mqtt_port\") or \"1883\"),\n                \"mqtt_username\": await get_setting(db, \"mqtt_username\") or \"\",\n                \"mqtt_password\": await get_setting(db, \"mqtt_password\") or \"\",\n                \"mqtt_use_tls\": (await get_setting(db, \"mqtt_use_tls\") or \"false\") == \"true\",\n            }\n            await mqtt_relay.smart_plug_service.configure(mqtt_settings)\n\n            # Check if connection succeeded\n            if not mqtt_relay.smart_plug_service.is_configured():\n                raise HTTPException(\n                    400,\n                    f\"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.\",\n                )\n\n    plug_data = data.model_dump()\n\n    # For HA entities, default auto_on and auto_off to False\n    # (they're for automations, not power control like Tasmota plugs)\n    if data.plug_type == \"homeassistant\":\n        plug_data[\"auto_on\"] = False\n        plug_data[\"auto_off\"] = False\n\n    plug = SmartPlug(**plug_data)\n    db.add(plug)\n    await db.commit()\n    await db.refresh(plug)\n\n    # Subscribe MQTT plugs to their topics\n    if plug.plug_type == \"mqtt\":\n        topics = subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug)\n        if topics:\n            logger.info(\"Created MQTT plug '%s' subscribed to %s\", plug.name, \", \".join(topics))\n    elif plug.plug_type == \"homeassistant\":\n        logger.info(\"Created Home Assistant plug '%s' (%s)\", plug.name, plug.ha_entity_id)\n    else:\n        logger.info(\"Created Tasmota plug '%s' at %s\", plug.name, plug.ip_address)\n    return plug\n\n\n@router.get(\"/by-printer/{printer_id}\", response_model=SmartPlugResponse | None)\nasync def get_smart_plug_by_printer(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"Get the main smart plug assigned to a printer.\n\n    When multiple plugs are assigned (e.g., a regular plug + script),\n    returns the main (non-script) plug for power control.\n    \"\"\"\n    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n    plugs = result.scalars().all()\n\n    if not plugs:\n        return None\n\n    # If multiple plugs, prefer the non-script one (main power plug)\n    for plug in plugs:\n        is_script = plug.plug_type == \"homeassistant\" and plug.ha_entity_id and plug.ha_entity_id.startswith(\"script.\")\n        if not is_script:\n            return plug\n\n    # All are scripts, return the first one\n    return plugs[0]\n\n\n@router.get(\"/by-printer/{printer_id}/scripts\", response_model=list[SmartPlugResponse])\nasync def get_script_plugs_by_printer(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"Get all HA entities assigned to a printer for display on printer card.\n\n    Returns HA entities (switches, scripts, lights, etc.) for the printer that have\n    show_on_printer_card enabled.\n    Used to display action buttons alongside the main power plug.\n    \"\"\"\n    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n    plugs = result.scalars().all()\n\n    # Filter to HA entities with show_on_printer_card enabled\n    ha_entities = [\n        plug for plug in plugs if plug.plug_type == \"homeassistant\" and plug.ha_entity_id and plug.show_on_printer_card\n    ]\n    return ha_entities\n\n\n# Tasmota Discovery Endpoints\n# NOTE: These must be defined BEFORE /{plug_id} routes to avoid path conflicts\n\n\nclass TasmotaScanRequest(BaseModel):\n    \"\"\"Request to scan for Tasmota devices.\"\"\"\n\n    from_ip: str | None = None  # Starting IP (auto-detected if not provided)\n    to_ip: str | None = None  # Ending IP (auto-detected if not provided)\n    timeout: float = 1.0  # Connection timeout per host\n\n\ndef get_local_network_range() -> tuple[str, str]:\n    \"\"\"Auto-detect local network and return IP range to scan.\"\"\"\n    import socket\n\n    try:\n        # Get local IP by connecting to a public DNS (doesn't actually send data)\n        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n        s.connect((\"8.8.8.8\", 80))\n        local_ip = s.getsockname()[0]\n        s.close()\n\n        # Parse IP and create range (assume /24 subnet)\n        parts = local_ip.split(\".\")\n        base = \".\".join(parts[:3])\n        from_ip = f\"{base}.1\"\n        to_ip = f\"{base}.254\"\n\n        logger.info(\"Auto-detected network: %s - %s (local IP: %s)\", from_ip, to_ip, local_ip)\n        return from_ip, to_ip\n\n    except OSError as e:\n        logger.error(\"Failed to detect local network: %s\", e)\n        # Fallback to common home network\n        return \"192.168.1.1\", \"192.168.1.254\"\n\n\nclass TasmotaScanStatus(BaseModel):\n    \"\"\"Tasmota scan status response.\"\"\"\n\n    running: bool\n    scanned: int\n    total: int\n\n\nclass DiscoveredTasmotaDevice(BaseModel):\n    \"\"\"Discovered Tasmota device.\"\"\"\n\n    ip_address: str\n    name: str\n    module: int | None = None\n    state: str | None = None\n    discovered_at: str | None = None\n\n\n@router.post(\"/discover/scan\", response_model=TasmotaScanStatus)\nasync def start_tasmota_scan(\n    request: TasmotaScanRequest | None = Body(default=None),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"Start an IP range scan for Tasmota devices.\n\n    Auto-detects local network if no IP range provided.\n    \"\"\"\n    import asyncio\n\n    # Auto-detect network\n    from_ip, to_ip = get_local_network_range()\n    timeout = request.timeout if request else 1.0\n\n    # Start scan in background\n    asyncio.create_task(tasmota_scanner.scan_range(from_ip, to_ip, timeout))\n\n    # Return immediate status\n    scanned, total = tasmota_scanner.progress\n    return TasmotaScanStatus(\n        running=tasmota_scanner.is_running,\n        scanned=scanned,\n        total=total,\n    )\n\n\n@router.get(\"/discover/status\", response_model=TasmotaScanStatus)\nasync def get_tasmota_scan_status(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"Get the current Tasmota scan status.\"\"\"\n    scanned, total = tasmota_scanner.progress\n    return TasmotaScanStatus(\n        running=tasmota_scanner.is_running,\n        scanned=scanned,\n        total=total,\n    )\n\n\n@router.post(\"/discover/stop\", response_model=TasmotaScanStatus)\nasync def stop_tasmota_scan(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"Stop the current Tasmota scan.\"\"\"\n    tasmota_scanner.stop()\n    scanned, total = tasmota_scanner.progress\n    return TasmotaScanStatus(\n        running=tasmota_scanner.is_running,\n        scanned=scanned,\n        total=total,\n    )\n\n\n@router.get(\"/discover/devices\", response_model=list[DiscoveredTasmotaDevice])\nasync def get_discovered_tasmota_devices(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"Get list of discovered Tasmota devices.\"\"\"\n    return [\n        DiscoveredTasmotaDevice(\n            ip_address=d[\"ip_address\"],\n            name=d[\"name\"],\n            module=d.get(\"module\"),\n            state=d.get(\"state\"),\n            discovered_at=d.get(\"discovered_at\"),\n        )\n        for d in tasmota_scanner.discovered_devices\n    ]\n\n\n# Home Assistant Discovery Endpoints\n\n\n@router.post(\"/ha/test-connection\", response_model=HATestConnectionResponse)\nasync def test_ha_connection(\n    request: HATestConnectionRequest,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),\n):\n    \"\"\"Test connection to Home Assistant.\"\"\"\n    result = await homeassistant_service.test_connection(request.url, request.token)\n    return HATestConnectionResponse(**result)\n\n\n@router.post(\"/rest/test-connection\", response_model=RESTTestConnectionResponse)\nasync def test_rest_connection(\n    request: RESTTestConnectionRequest,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),\n):\n    \"\"\"Test connection to a REST/HTTP endpoint.\"\"\"\n    result = await rest_smart_plug_service.test_connection(request.url, request.method, request.headers)\n    return RESTTestConnectionResponse(**result)\n\n\n@router.get(\"/ha/entities\", response_model=list[HAEntity])\nasync def list_ha_entities(\n    db: AsyncSession = Depends(get_db),\n    search: str | None = None,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"List available Home Assistant entities.\n\n    By default, returns switch/light/input_boolean entities.\n    When search is provided, searches ALL entities by entity_id or friendly_name.\n\n    Requires HA connection settings to be configured in Settings.\n    \"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    ha_settings = await get_homeassistant_settings(db)\n    ha_url = ha_settings[\"ha_url\"]\n    ha_token = ha_settings[\"ha_token\"]\n\n    if not ha_url or not ha_token:\n        raise HTTPException(\n            400, \"Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.\"\n        )\n\n    entities = await homeassistant_service.list_entities(ha_url, ha_token, search)\n    return [HAEntity(**e) for e in entities]\n\n\n@router.get(\"/ha/sensors\", response_model=list[HASensorEntity])\nasync def list_ha_sensor_entities(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"List available Home Assistant sensor entities for energy monitoring.\n\n    Returns sensors with power/energy units (W, kW, kWh, Wh).\n    Requires HA connection settings to be configured in Settings.\n    \"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    ha_settings = await get_homeassistant_settings(db)\n    ha_url = ha_settings[\"ha_url\"]\n    ha_token = ha_settings[\"ha_token\"]\n\n    if not ha_url or not ha_token:\n        raise HTTPException(\n            400, \"Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.\"\n        )\n\n    sensors = await homeassistant_service.list_sensor_entities(ha_url, ha_token)\n    return [HASensorEntity(**s) for s in sensors]\n\n\n@router.get(\"/{plug_id}\", response_model=SmartPlugResponse)\nasync def get_smart_plug(\n    plug_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"Get a specific smart plug.\"\"\"\n    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n    plug = result.scalar_one_or_none()\n    if not plug:\n        raise HTTPException(404, \"Smart plug not found\")\n    return plug\n\n\n@router.patch(\"/{plug_id}\", response_model=SmartPlugResponse)\nasync def update_smart_plug(\n    plug_id: int,\n    data: SmartPlugUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),\n):\n    \"\"\"Update a smart plug.\"\"\"\n    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n    plug = result.scalar_one_or_none()\n    if not plug:\n        raise HTTPException(404, \"Smart plug not found\")\n\n    update_data = data.model_dump(exclude_unset=True)\n\n    # Validate new printer_id if being changed\n    if \"printer_id\" in update_data and update_data[\"printer_id\"]:\n        new_printer_id = update_data[\"printer_id\"]\n\n        # Check printer exists\n        result = await db.execute(select(Printer).where(Printer.id == new_printer_id))\n        if not result.scalar_one_or_none():\n            raise HTTPException(400, \"Printer not found\")\n\n        # Check if that printer already has a different Tasmota plug assigned\n        # Tasmota plugs: only one per printer (physical power device)\n        # HA entities: allow multiple per printer (for different automations)\n        new_plug_type = update_data.get(\"plug_type\", plug.plug_type)\n        if new_plug_type == \"tasmota\":\n            result = await db.execute(\n                select(SmartPlug).where(\n                    SmartPlug.printer_id == new_printer_id,\n                    SmartPlug.id != plug_id,\n                    SmartPlug.plug_type == \"tasmota\",\n                )\n            )\n            if result.scalar_one_or_none():\n                raise HTTPException(400, \"This printer already has a Tasmota plug assigned\")\n\n    # Track old MQTT settings for comparison\n    old_plug_type = plug.plug_type\n    old_mqtt_config = {\n        \"power_topic\": plug.mqtt_power_topic or plug.mqtt_topic,\n        \"power_path\": plug.mqtt_power_path,\n        \"power_multiplier\": plug.mqtt_power_multiplier,\n        \"energy_topic\": plug.mqtt_energy_topic or plug.mqtt_topic,\n        \"energy_path\": plug.mqtt_energy_path,\n        \"energy_multiplier\": plug.mqtt_energy_multiplier,\n        \"state_topic\": plug.mqtt_state_topic or plug.mqtt_topic,\n        \"state_path\": plug.mqtt_state_path,\n        \"state_on_value\": plug.mqtt_state_on_value,\n    }\n\n    for field, value in update_data.items():\n        setattr(plug, field, value)\n\n    await db.commit()\n    await db.refresh(plug)\n\n    # Handle MQTT subscription changes\n    if old_plug_type == \"mqtt\" and plug.plug_type != \"mqtt\":\n        # Changed away from MQTT - unsubscribe\n        mqtt_relay.smart_plug_service.unsubscribe(plug.id)\n    elif plug.plug_type == \"mqtt\":\n        # Check if any MQTT config changed\n        new_mqtt_config = {\n            \"power_topic\": plug.mqtt_power_topic or plug.mqtt_topic,\n            \"power_path\": plug.mqtt_power_path,\n            \"power_multiplier\": plug.mqtt_power_multiplier,\n            \"energy_topic\": plug.mqtt_energy_topic or plug.mqtt_topic,\n            \"energy_path\": plug.mqtt_energy_path,\n            \"energy_multiplier\": plug.mqtt_energy_multiplier,\n            \"state_topic\": plug.mqtt_state_topic or plug.mqtt_topic,\n            \"state_path\": plug.mqtt_state_path,\n            \"state_on_value\": plug.mqtt_state_on_value,\n        }\n\n        mqtt_changed = old_plug_type != \"mqtt\" or old_mqtt_config != new_mqtt_config\n\n        if mqtt_changed:\n            # Unsubscribe from old topics first\n            if old_plug_type == \"mqtt\":\n                mqtt_relay.smart_plug_service.unsubscribe(plug.id)\n\n            # Subscribe via the shared helper (matches startup restore and\n            # create route) — keeps all three paths in lock-step.\n            subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug)\n\n    logger.info(\"Updated smart plug '%s'\", plug.name)\n    return plug\n\n\n@router.delete(\"/{plug_id}\")\nasync def delete_smart_plug(\n    plug_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),\n):\n    \"\"\"Delete a smart plug.\"\"\"\n    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n    plug = result.scalar_one_or_none()\n    if not plug:\n        raise HTTPException(404, \"Smart plug not found\")\n\n    plug_name = plug.name\n    plug_type = plug.plug_type\n\n    # Unsubscribe MQTT plug before deletion\n    if plug_type == \"mqtt\":\n        mqtt_relay.smart_plug_service.unsubscribe(plug_id)\n\n    await db.delete(plug)\n    await db.commit()\n\n    logger.info(\"Deleted smart plug '%s'\", plug_name)\n    return {\"message\": \"Smart plug deleted\"}\n\n\nasync def _get_service_for_plug(plug: SmartPlug, db: AsyncSession):\n    \"\"\"Get the appropriate service for the plug type.\n\n    For HA plugs, configures the service with current settings from DB.\n    \"\"\"\n    if plug.plug_type == \"homeassistant\":\n        # Configure HA service with current settings\n        from backend.app.api.routes.settings import get_homeassistant_settings\n\n        ha_settings = await get_homeassistant_settings(db)\n        homeassistant_service.configure(ha_settings[\"ha_url\"], ha_settings[\"ha_token\"])\n        return homeassistant_service\n    if plug.plug_type == \"rest\":\n        return rest_smart_plug_service\n    return tasmota_service\n\n\n@router.post(\"/{plug_id}/control\")\nasync def control_smart_plug(\n    plug_id: int,\n    control: SmartPlugControl,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),\n):\n    \"\"\"Manual control: on/off/toggle.\"\"\"\n    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n    plug = result.scalar_one_or_none()\n    if not plug:\n        raise HTTPException(404, \"Smart plug not found\")\n\n    # MQTT plugs are monitor-only - cannot control them\n    if plug.plug_type == \"mqtt\":\n        raise HTTPException(\n            400,\n            \"MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.\",\n        )\n\n    service = await _get_service_for_plug(plug, db)\n\n    if control.action == \"on\":\n        success = await service.turn_on(plug)\n        expected_state = \"ON\"\n    elif control.action == \"off\":\n        success = await service.turn_off(plug)\n        expected_state = \"OFF\"\n    elif control.action == \"toggle\":\n        success = await service.toggle(plug)\n        expected_state = None  # Unknown after toggle\n    else:\n        raise HTTPException(400, f\"Invalid action: {control.action}\")\n\n    if not success:\n        raise HTTPException(503, \"Failed to communicate with device\")\n\n    # Update last state and reset auto_off_executed when turning on\n    if expected_state:\n        plug.last_state = expected_state\n        if expected_state == \"ON\":\n            plug.auto_off_executed = False  # Reset flag when manually turning on\n        elif expected_state == \"OFF\" and plug.printer_id:\n            # Mark printer offline immediately for faster UI update\n            printer_manager.mark_printer_offline(plug.printer_id)\n    plug.last_checked = datetime.now(timezone.utc)\n    await db.commit()\n\n    # Trigger associated scripts if this is a main (non-script) plug\n    is_main_plug = not (\n        plug.plug_type == \"homeassistant\" and plug.ha_entity_id and plug.ha_entity_id.startswith(\"script.\")\n    )\n    if is_main_plug and plug.printer_id and expected_state:\n        await trigger_associated_scripts(plug.printer_id, expected_state, db)\n\n    # MQTT relay - publish smart plug state change\n    if expected_state:\n        try:\n            from backend.app.services.mqtt_relay import mqtt_relay\n\n            # Get printer name if linked\n            printer_name = None\n            if plug.printer_id:\n                result = await db.execute(select(Printer).where(Printer.id == plug.printer_id))\n                printer = result.scalar_one_or_none()\n                printer_name = printer.name if printer else None\n\n            await mqtt_relay.on_smart_plug_state(\n                plug_id=plug.id,\n                plug_name=plug.name,\n                state=\"on\" if expected_state == \"ON\" else \"off\",\n                printer_id=plug.printer_id,\n                printer_name=printer_name,\n            )\n        except Exception:\n            pass  # Don't fail if MQTT fails\n\n    return {\"success\": True, \"action\": control.action}\n\n\nasync def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):\n    \"\"\"Trigger scripts linked to a printer based on main plug state change.\n\n    When the main plug turns ON, triggers scripts with auto_on=True.\n    When the main plug turns OFF, triggers scripts with auto_off=True.\n    \"\"\"\n    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n    plugs = result.scalars().all()\n\n    # Find scripts that should be triggered\n    for plug in plugs:\n        is_script = plug.plug_type == \"homeassistant\" and plug.ha_entity_id and plug.ha_entity_id.startswith(\"script.\")\n        if not is_script:\n            continue\n\n        should_trigger = False\n        if plug_state == \"ON\" and plug.auto_on:\n            should_trigger = True\n            logger.info(\"Auto-triggering script '%s' on printer power-on\", plug.name)\n        elif plug_state == \"OFF\" and plug.auto_off:\n            should_trigger = True\n            logger.info(\"Auto-triggering script '%s' on printer power-off\", plug.name)\n\n        if should_trigger:\n            try:\n                service = await _get_service_for_plug(plug, db)\n                await service.turn_on(plug)  # Scripts are triggered by calling turn_on\n            except Exception as e:\n                logger.error(\"Failed to trigger script '%s': %s\", plug.name, e)\n\n\n@router.get(\"/{plug_id}/status\", response_model=SmartPlugStatus)\nasync def get_plug_status(\n    plug_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),\n):\n    \"\"\"Get current plug status from device including energy data.\"\"\"\n    result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n    plug = result.scalar_one_or_none()\n    if not plug:\n        raise HTTPException(404, \"Smart plug not found\")\n\n    # Handle MQTT plugs - get data from subscription service\n    if plug.plug_type == \"mqtt\":\n        data = mqtt_relay.smart_plug_service.get_plug_data(plug_id)\n        is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id)\n\n        if data:\n            # Update last state in database\n            if is_reachable and data.state:\n                plug.last_state = data.state\n                plug.last_checked = datetime.now(timezone.utc)\n                await db.commit()\n\n            energy_data = None\n            if data.power is not None or data.energy is not None:\n                energy_data = SmartPlugEnergy(\n                    power=data.power,\n                    today=data.energy,\n                )\n                # Check power alerts\n                if data.power is not None:\n                    await check_power_alerts(plug, data.power, db)\n\n            return SmartPlugStatus(\n                state=data.state,\n                reachable=is_reachable,\n                device_name=None,\n                energy=energy_data,\n            )\n\n        # No data received yet\n        return SmartPlugStatus(\n            state=None,\n            reachable=False,\n            device_name=None,\n            energy=None,\n        )\n\n    # Handle Tasmota/HomeAssistant plugs\n    service = await _get_service_for_plug(plug, db)\n    status = await service.get_status(plug)\n\n    # Update last state in database\n    if status[\"reachable\"]:\n        plug.last_state = status[\"state\"]\n        plug.last_checked = datetime.now(timezone.utc)\n        await db.commit()\n\n    # Fetch energy data if device is reachable\n    energy_data = None\n    if status[\"reachable\"]:\n        energy = await service.get_energy(plug)\n        if energy:\n            energy_data = SmartPlugEnergy(**energy)\n\n            # Check power alerts\n            await check_power_alerts(plug, energy.get(\"power\"), db)\n\n    return SmartPlugStatus(\n        state=status[\"state\"],\n        reachable=status[\"reachable\"],\n        device_name=status.get(\"device_name\"),\n        energy=energy_data,\n    )\n\n\nasync def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):\n    \"\"\"Check if power crosses alert thresholds and send notifications.\"\"\"\n    if not plug.power_alert_enabled or current_power is None:\n        return\n\n    # Cooldown: don't alert more than once per 5 minutes\n    cooldown_minutes = 5\n    if plug.power_alert_last_triggered:\n        last_triggered = plug.power_alert_last_triggered\n        if last_triggered.tzinfo is None:\n            last_triggered = last_triggered.replace(tzinfo=timezone.utc)\n        time_since_last = datetime.now(timezone.utc) - last_triggered\n        if time_since_last < timedelta(minutes=cooldown_minutes):\n            return\n\n    alert_triggered = False\n    alert_type = None\n    threshold = None\n\n    # Check high threshold\n    if plug.power_alert_high is not None and current_power > plug.power_alert_high:\n        alert_triggered = True\n        alert_type = \"high\"\n        threshold = plug.power_alert_high\n\n    # Check low threshold\n    if plug.power_alert_low is not None and current_power < plug.power_alert_low:\n        alert_triggered = True\n        alert_type = \"low\"\n        threshold = plug.power_alert_low\n\n    if alert_triggered:\n        plug.power_alert_last_triggered = datetime.now(timezone.utc)\n        await db.commit()\n\n        # Send notification\n        title = f\"Power Alert: {plug.name}\"\n        if alert_type == \"high\":\n            message = f\"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W\"\n        else:\n            message = f\"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W\"\n\n        logger.info(\"Power alert triggered for %s: %s\", plug.name, message)\n\n        # Use printer_error event type for power alerts (closest match)\n        await notification_service.send_notification(\n            event_type=\"printer_error\",\n            title=title,\n            message=message,\n            printer_id=plug.printer_id,\n            printer_name=plug.name,\n            context={\n                \"error_type\": f\"Power {alert_type.title()}\",\n                \"error_detail\": message,\n            },\n        )\n\n\n@router.post(\"/test-connection\")\nasync def test_connection(\n    data: SmartPlugTestConnection,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),\n):\n    \"\"\"Test connection to a Tasmota device.\"\"\"\n    result = await tasmota_service.test_connection(\n        data.ip_address,\n        data.username,\n        data.password,\n    )\n\n    if not result[\"success\"]:\n        raise HTTPException(503, result.get(\"error\", \"Failed to connect to device\"))\n\n    return {\n        \"success\": True,\n        \"state\": result[\"state\"],\n        \"device_name\": result.get(\"device_name\"),\n    }\n"
  },
  {
    "path": "backend/app/api/routes/spoolbuddy.py",
    "content": "\"\"\"SpoolBuddy device management API routes.\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport time\nfrom datetime import datetime, timedelta, timezone\nfrom urllib.parse import urlparse\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.core.websocket import ws_manager\nfrom backend.app.models.spoolbuddy_device import SpoolBuddyDevice\nfrom backend.app.models.user import User\nfrom backend.app.schemas.spoolbuddy import (\n    CalibrationResponse,\n    DeviceRegisterRequest,\n    DeviceResponse,\n    DiagnosticResultRequest,\n    DisplaySettingsRequest,\n    HeartbeatRequest,\n    HeartbeatResponse,\n    ScaleReadingRequest,\n    SetCalibrationFactorRequest,\n    SetTareRequest,\n    SystemCommandRequest,\n    SystemCommandResultRequest,\n    SystemConfigRequest,\n    TagRemovedRequest,\n    TagScannedRequest,\n    UpdateSpoolWeightRequest,\n    WriteTagRequest,\n    WriteTagResultRequest,\n)\nfrom backend.app.services.spool_tag_matcher import get_spool_by_tag\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/spoolbuddy\", tags=[\"spoolbuddy\"])\n\nOFFLINE_THRESHOLD_SECONDS = 30\nONLINE_BROADCAST_INTERVAL_SECONDS = 10\n_spoolbuddy_online_last_broadcast: dict[str, float] = {}\n_diagnostic_results: dict[tuple[str, str], dict] = {}\n\n\ndef _is_online(device: SpoolBuddyDevice) -> bool:\n    if not device.last_seen:\n        return False\n    return (\n        datetime.now(timezone.utc) - device.last_seen.replace(tzinfo=timezone.utc)\n    ).total_seconds() < OFFLINE_THRESHOLD_SECONDS\n\n\ndef _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:\n    return DeviceResponse(\n        id=device.id,\n        device_id=device.device_id,\n        hostname=device.hostname,\n        ip_address=device.ip_address,\n        firmware_version=device.firmware_version,\n        has_nfc=device.has_nfc,\n        has_scale=device.has_scale,\n        tare_offset=device.tare_offset,\n        calibration_factor=device.calibration_factor,\n        nfc_reader_type=device.nfc_reader_type,\n        nfc_connection=device.nfc_connection,\n        backend_url=device.backend_url,\n        display_brightness=device.display_brightness,\n        display_blank_timeout=device.display_blank_timeout,\n        has_backlight=device.has_backlight,\n        last_calibrated_at=device.last_calibrated_at,\n        last_seen=device.last_seen,\n        pending_command=device.pending_command,\n        nfc_ok=device.nfc_ok,\n        scale_ok=device.scale_ok,\n        uptime_s=device.uptime_s,\n        update_status=device.update_status,\n        update_message=device.update_message,\n        system_stats=json.loads(device.system_stats) if device.system_stats else None,\n        online=_is_online(device),\n        created_at=device.created_at,\n        updated_at=device.updated_at,\n    )\n\n\ndef _should_broadcast_online(device_id: str, force: bool = False) -> bool:\n    if force:\n        _spoolbuddy_online_last_broadcast[device_id] = time.time()\n        return True\n\n    now_ts = time.time()\n    last_ts = _spoolbuddy_online_last_broadcast.get(device_id, 0.0)\n    if now_ts - last_ts >= ONLINE_BROADCAST_INTERVAL_SECONDS:\n        _spoolbuddy_online_last_broadcast[device_id] = now_ts\n        return True\n    return False\n\n\n# --- Device endpoints ---\n\n\n@router.post(\"/devices/register\", response_model=DeviceResponse)\nasync def register_device(\n    req: DeviceRegisterRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Register or re-register a SpoolBuddy device.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))\n    device = result.scalar_one_or_none()\n\n    now = datetime.now(timezone.utc)\n    if device:\n        device.hostname = req.hostname\n        device.ip_address = req.ip_address\n        device.firmware_version = req.firmware_version\n        device.has_nfc = req.has_nfc\n        device.has_scale = req.has_scale\n        device.nfc_reader_type = req.nfc_reader_type\n        device.nfc_connection = req.nfc_connection\n        if req.backend_url:\n            device.backend_url = req.backend_url\n        device.has_backlight = req.has_backlight\n        device.last_seen = now\n        # Clear stale update status on re-registration (daemon restarted after update)\n        if device.update_status in (\"pending\", \"updating\", \"complete\", \"error\"):\n            device.update_status = None\n            device.update_message = None\n        logger.info(\"SpoolBuddy device re-registered: %s (%s)\", req.device_id, req.hostname)\n    else:\n        device = SpoolBuddyDevice(\n            device_id=req.device_id,\n            hostname=req.hostname,\n            ip_address=req.ip_address,\n            firmware_version=req.firmware_version,\n            has_nfc=req.has_nfc,\n            has_scale=req.has_scale,\n            tare_offset=req.tare_offset,\n            calibration_factor=req.calibration_factor,\n            nfc_reader_type=req.nfc_reader_type,\n            nfc_connection=req.nfc_connection,\n            has_backlight=req.has_backlight,\n            backend_url=req.backend_url,\n            last_seen=now,\n        )\n        db.add(device)\n        logger.info(\"SpoolBuddy device registered: %s (%s)\", req.device_id, req.hostname)\n\n    await db.commit()\n    await db.refresh(device)\n\n    _spoolbuddy_online_last_broadcast[device.device_id] = time.time()\n    await ws_manager.broadcast(\n        {\n            \"type\": \"spoolbuddy_online\",\n            \"device_id\": device.device_id,\n            \"hostname\": device.hostname,\n        }\n    )\n\n    response = _device_to_response(device)\n\n    # Include SSH public key so the daemon can auto-deploy it\n    try:\n        from backend.app.services.spoolbuddy_ssh import get_public_key\n\n        response.ssh_public_key = await get_public_key()\n    except Exception:\n        pass  # Key not generated yet — daemon can still work without it\n\n    return response\n\n\n@router.get(\"/devices\", response_model=list[DeviceResponse])\nasync def list_devices(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"List all registered SpoolBuddy devices.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).order_by(SpoolBuddyDevice.hostname))\n    devices = list(result.scalars().all())\n    return [_device_to_response(d) for d in devices]\n\n\n@router.delete(\"/devices/{device_id}\")\nasync def unregister_device(\n    device_id: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),\n):\n    \"\"\"Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    await db.delete(device)\n    await db.commit()\n    _spoolbuddy_online_last_broadcast.pop(device_id, None)\n    logger.info(\"SpoolBuddy device unregistered: %s (%s)\", device_id, device.hostname)\n    await ws_manager.broadcast({\"type\": \"spoolbuddy_unregistered\", \"device_id\": device_id})\n    return {\"status\": \"deleted\", \"device_id\": device_id}\n\n\n@router.post(\"/devices/{device_id}/heartbeat\", response_model=HeartbeatResponse)\nasync def device_heartbeat(\n    device_id: str,\n    req: HeartbeatRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Daemon heartbeat — updates status and returns pending commands.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    was_offline = not _is_online(device)\n    now = datetime.now(timezone.utc)\n\n    device.last_seen = now\n    device.nfc_ok = req.nfc_ok\n    device.scale_ok = req.scale_ok\n    device.uptime_s = req.uptime_s\n    if req.firmware_version:\n        device.firmware_version = req.firmware_version\n    if req.ip_address:\n        device.ip_address = req.ip_address\n    if req.nfc_reader_type:\n        device.nfc_reader_type = req.nfc_reader_type\n    if req.nfc_connection:\n        device.nfc_connection = req.nfc_connection\n    if req.backend_url:\n        device.backend_url = req.backend_url\n    if req.system_stats is not None:\n        device.system_stats = json.dumps(req.system_stats)\n\n    # Return and clear pending command\n    pending = device.pending_command\n    pending_write = None\n    pending_system = None\n    if pending == \"write_tag\" and device.pending_write_payload:\n        # Parse the stored JSON payload to include in response\n        try:\n            pending_write = json.loads(device.pending_write_payload)\n        except (json.JSONDecodeError, TypeError):\n            pending_write = None\n        # Don't clear write_tag command — it gets cleared by write-result\n    elif pending == \"apply_system_config\" and device.pending_system_payload:\n        try:\n            pending_system = json.loads(device.pending_system_payload)\n        except (json.JSONDecodeError, TypeError):\n            pending_system = None\n        # Don't clear config command — it gets cleared by daemon command-result callback\n    elif pending and pending.startswith(\"run_\") and pending.endswith(\"_diag\"):\n        # Don't clear diagnostic commands — they get cleared by the device reporting results\n        pass\n    else:\n        device.pending_command = None\n\n    await db.commit()\n\n    # Emit online presence on offline->online transitions immediately, and\n    # periodically while online so newly connected UIs can bootstrap state.\n    if _should_broadcast_online(device.device_id, force=was_offline):\n        await ws_manager.broadcast(\n            {\n                \"type\": \"spoolbuddy_online\",\n                \"device_id\": device.device_id,\n                \"hostname\": device.hostname,\n            }\n        )\n    if was_offline:\n        logger.info(\"SpoolBuddy device back online: %s\", device.device_id)\n\n    return HeartbeatResponse(\n        pending_command=pending,\n        pending_write_payload=pending_write,\n        pending_system_payload=pending_system,\n        tare_offset=device.tare_offset,\n        calibration_factor=device.calibration_factor,\n        display_brightness=device.display_brightness,\n        display_blank_timeout=device.display_blank_timeout,\n    )\n\n\n# --- NFC endpoints ---\n\n\n@router.post(\"/nfc/tag-scanned\")\nasync def nfc_tag_scanned(\n    req: TagScannedRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"RPi reports NFC tag detected — lookup spool and broadcast.\"\"\"\n    spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or \"\")\n\n    if spool:\n        await ws_manager.broadcast(\n            {\n                \"type\": \"spoolbuddy_tag_matched\",\n                \"device_id\": req.device_id,\n                \"tag_uid\": req.tag_uid,\n                \"spool\": {\n                    \"id\": spool.id,\n                    \"material\": spool.material,\n                    \"subtype\": spool.subtype,\n                    \"color_name\": spool.color_name,\n                    \"rgba\": spool.rgba,\n                    \"brand\": spool.brand,\n                    \"label_weight\": spool.label_weight,\n                    \"core_weight\": spool.core_weight,\n                    \"weight_used\": spool.weight_used,\n                },\n            }\n        )\n        logger.info(\"SpoolBuddy tag matched: %s -> spool %d\", req.tag_uid, spool.id)\n    else:\n        await ws_manager.broadcast(\n            {\n                \"type\": \"spoolbuddy_unknown_tag\",\n                \"device_id\": req.device_id,\n                \"tag_uid\": req.tag_uid,\n                \"sak\": req.sak,\n                \"tag_type\": req.tag_type,\n            }\n        )\n        logger.info(\n            \"SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s\",\n            req.tag_uid,\n            len(req.tag_uid or \"\"),\n            req.tray_uuid,\n            len(req.tray_uuid or \"\"),\n            req.tag_type,\n            req.sak,\n        )\n\n    return {\"status\": \"ok\", \"matched\": spool is not None, \"spool_id\": spool.id if spool else None}\n\n\n@router.post(\"/nfc/tag-removed\")\nasync def nfc_tag_removed(\n    req: TagRemovedRequest,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"RPi reports NFC tag removed — broadcast event.\"\"\"\n    await ws_manager.broadcast(\n        {\n            \"type\": \"spoolbuddy_tag_removed\",\n            \"device_id\": req.device_id,\n            \"tag_uid\": req.tag_uid,\n        }\n    )\n    return {\"status\": \"ok\"}\n\n\n@router.post(\"/nfc/write-tag\")\nasync def nfc_write_tag(\n    req: WriteTagRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Queue an NFC tag write command for a SpoolBuddy device.\"\"\"\n    import json\n\n    from backend.app.models.spool import Spool\n    from backend.app.services.opentag3d import encode_opentag3d\n\n    # Find the spool\n    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(status_code=404, detail=\"Spool not found\")\n\n    # Find the device\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    # Encode OpenTag3D NDEF data\n    ndef_data = encode_opentag3d(spool)\n\n    # Store write payload and set pending command\n    device.pending_write_payload = json.dumps(\n        {\n            \"spool_id\": spool.id,\n            \"ndef_data_hex\": ndef_data.hex(),\n        }\n    )\n    device.pending_command = \"write_tag\"\n    await db.commit()\n\n    logger.info(\"Write tag queued for device %s, spool %d (%d bytes)\", req.device_id, spool.id, len(ndef_data))\n    return {\"status\": \"queued\"}\n\n\n@router.post(\"/nfc/write-result\")\nasync def nfc_write_result(\n    req: WriteTagResultRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Handle NFC tag write result from SpoolBuddy daemon.\"\"\"\n    # Find the device and clear pending state\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    device.pending_command = None\n    device.pending_write_payload = None\n\n    if req.success:\n        # Link the tag to the spool\n        from backend.app.models.spool import Spool\n\n        result = await db.execute(select(Spool).where(Spool.id == req.spool_id))\n        spool = result.scalar_one_or_none()\n        if spool:\n            spool.tag_uid = req.tag_uid.upper()\n            spool.tag_type = \"ntag\"\n            spool.data_origin = \"opentag3d\"\n            spool.encode_time = datetime.now(timezone.utc)\n            logger.info(\"Tag written and linked: spool %d -> tag %s\", spool.id, req.tag_uid)\n\n        await db.commit()\n        await ws_manager.broadcast(\n            {\n                \"type\": \"spoolbuddy_tag_written\",\n                \"device_id\": req.device_id,\n                \"spool_id\": req.spool_id,\n                \"tag_uid\": req.tag_uid,\n            }\n        )\n    else:\n        await db.commit()\n        await ws_manager.broadcast(\n            {\n                \"type\": \"spoolbuddy_tag_write_failed\",\n                \"device_id\": req.device_id,\n                \"spool_id\": req.spool_id,\n                \"message\": req.message,\n            }\n        )\n        logger.warning(\"Tag write failed for device %s: %s\", req.device_id, req.message)\n\n    return {\"status\": \"ok\"}\n\n\n@router.post(\"/devices/{device_id}/cancel-write\")\nasync def cancel_write(\n    device_id: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Cancel a pending write-tag command.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    if device.pending_command == \"write_tag\":\n        device.pending_command = None\n        device.pending_write_payload = None\n        await db.commit()\n        logger.info(\"Write tag cancelled for device %s\", device_id)\n\n    return {\"status\": \"ok\"}\n\n\n# --- Scale endpoints ---\n\n\n@router.post(\"/scale/reading\")\nasync def scale_reading(\n    req: ScaleReadingRequest,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"RPi reports scale weight — broadcast to all clients.\"\"\"\n    await ws_manager.broadcast(\n        {\n            \"type\": \"spoolbuddy_weight\",\n            \"device_id\": req.device_id,\n            \"weight_grams\": req.weight_grams,\n            \"stable\": req.stable,\n            \"raw_adc\": req.raw_adc,\n        }\n    )\n    return {\"status\": \"ok\"}\n\n\n@router.post(\"/scale/update-spool-weight\")\nasync def update_spool_weight(\n    req: UpdateSpoolWeightRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Update spool's used weight from scale reading.\"\"\"\n    from backend.app.models.spool import Spool\n\n    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))\n    spool = result.scalar_one_or_none()\n    if not spool:\n        raise HTTPException(status_code=404, detail=\"Spool not found\")\n\n    # net weight = total on scale minus empty spool core\n    net_filament = max(0, req.weight_grams - spool.core_weight)\n    spool.weight_used = max(0, spool.label_weight - net_filament)\n    spool.last_scale_weight = req.weight_grams\n    spool.last_weighed_at = datetime.now(timezone.utc)\n    await db.commit()\n\n    logger.info(\n        \"SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used\",\n        spool.id,\n        req.weight_grams,\n        spool.weight_used,\n    )\n    return {\"status\": \"ok\", \"weight_used\": spool.weight_used}\n\n\n# --- Calibration endpoints ---\n\n\n@router.post(\"/devices/{device_id}/calibration/tare\")\nasync def tare_scale(\n    device_id: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Set pending tare command for the device to pick up.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    device.pending_command = \"tare\"\n    await db.commit()\n    return {\"status\": \"ok\", \"message\": \"Tare command queued\"}\n\n\n@router.post(\"/devices/{device_id}/calibration/set-tare\")\nasync def set_tare_offset(\n    device_id: str,\n    req: SetTareRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Store tare offset reported by the daemon after executing a tare.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    device.tare_offset = req.tare_offset\n    device.last_calibrated_at = datetime.now(timezone.utc)\n    await db.commit()\n\n    logger.info(\"SpoolBuddy %s tare offset set to %d\", device_id, req.tare_offset)\n    return CalibrationResponse(\n        tare_offset=device.tare_offset,\n        calibration_factor=device.calibration_factor,\n    )\n\n\n@router.post(\"/devices/{device_id}/calibration/set-factor\")\nasync def set_calibration_factor(\n    device_id: str,\n    req: SetCalibrationFactorRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Calculate and store calibration factor from a known weight.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset\n    raw_delta = req.raw_adc - tare\n    if raw_delta == 0:\n        raise HTTPException(status_code=400, detail=\"Raw ADC value equals tare offset — place weight on scale\")\n\n    device.calibration_factor = req.known_weight_grams / raw_delta\n    if req.tare_raw_adc is not None:\n        device.tare_offset = tare\n    device.last_calibrated_at = datetime.now(timezone.utc)\n    await db.commit()\n\n    logger.info(\n        \"SpoolBuddy %s calibration factor set to %.6f (known=%.1fg, raw=%d, tare=%d)\",\n        device_id,\n        device.calibration_factor,\n        req.known_weight_grams,\n        req.raw_adc,\n        tare,\n    )\n    return CalibrationResponse(\n        tare_offset=device.tare_offset,\n        calibration_factor=device.calibration_factor,\n    )\n\n\n@router.get(\"/devices/{device_id}/calibration\", response_model=CalibrationResponse)\nasync def get_calibration(\n    device_id: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Get current calibration values for a device.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    return CalibrationResponse(\n        tare_offset=device.tare_offset,\n        calibration_factor=device.calibration_factor,\n    )\n\n\n# --- Display settings ---\n\n\n@router.get(\"/devices/{device_id}/display\")\nasync def get_display_settings(\n    device_id: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Read current display brightness and screen blank timeout for a device.\n\n    Used by the SpoolBuddy kiosk idle watchdog on autostart to configure\n    swayidle with the same timeout the user picked in the UI, without having\n    to wait for the daemon heartbeat to arrive first.\n    \"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n    return {\n        \"brightness\": device.display_brightness,\n        \"blank_timeout\": device.display_blank_timeout,\n    }\n\n\n@router.put(\"/devices/{device_id}/display\")\nasync def update_display_settings(\n    device_id: str,\n    req: DisplaySettingsRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Update display brightness and screen blank timeout for a device.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    device.display_brightness = req.brightness\n    device.display_blank_timeout = req.blank_timeout\n    await db.commit()\n\n    logger.info(\n        \"SpoolBuddy %s display updated: brightness=%d%%, blank_timeout=%ds\",\n        device_id,\n        req.brightness,\n        req.blank_timeout,\n    )\n    return {\"status\": \"ok\", \"brightness\": req.brightness, \"blank_timeout\": req.blank_timeout}\n\n\n@router.post(\"/devices/{device_id}/system/config\")\nasync def queue_system_config_update(\n    device_id: str,\n    req: SystemConfigRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Queue update of SpoolBuddy .env config on the device.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    parsed = urlparse(req.backend_url.strip())\n    if parsed.scheme not in (\"http\", \"https\") or not parsed.netloc:\n        raise HTTPException(\n            status_code=400,\n            detail=\"backend_url must be a full URL with scheme, e.g. http://192.168.1.100:5000 or http://bambuddy.local\",\n        )\n\n    payload = {\n        \"backend_url\": req.backend_url.strip(),\n    }\n    if req.api_key is not None and req.api_key.strip():\n        payload[\"api_key\"] = req.api_key.strip()\n\n    device.pending_system_payload = json.dumps(payload)\n    device.pending_command = \"apply_system_config\"\n    await db.commit()\n\n    logger.info(\"Queued system config update for device %s\", device_id)\n    return {\"status\": \"queued\", \"message\": \"System config update queued\"}\n\n\nVALID_SYSTEM_COMMANDS = {\"reboot\", \"shutdown\", \"restart_daemon\", \"restart_browser\"}\n\n\n@router.post(\"/devices/{device_id}/system/command\")\nasync def queue_system_command(\n    device_id: str,\n    req: SystemCommandRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Queue a system command (reboot, shutdown, restart_daemon, restart_browser) for the SpoolBuddy device.\"\"\"\n    if req.command not in VALID_SYSTEM_COMMANDS:\n        raise HTTPException(\n            status_code=400,\n            detail=f\"Invalid command. Must be one of: {', '.join(sorted(VALID_SYSTEM_COMMANDS))}\",\n        )\n\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    if not _is_online(device):\n        raise HTTPException(status_code=409, detail=\"Device is offline\")\n\n    device.pending_command = req.command\n    await db.commit()\n\n    logger.info(\"System command queued for device %s: %s\", device_id, req.command)\n    return {\"status\": \"queued\", \"command\": req.command}\n\n\n@router.post(\"/devices/{device_id}/system/command-result\")\nasync def system_command_result(\n    device_id: str,\n    req: SystemCommandResultRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Receive completion status for queued system command from daemon.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    if not device.pending_command:\n        logger.info(\"System command result from %s with no pending command: %s\", device_id, req.command)\n        return {\"status\": \"ok\", \"message\": \"No pending command\"}\n\n    if req.command != device.pending_command:\n        raise HTTPException(\n            status_code=409,\n            detail=f\"Command mismatch: pending '{device.pending_command}', got '{req.command}'\",\n        )\n\n    if req.command == \"apply_system_config\":\n        device.pending_system_payload = None\n    device.pending_command = None\n    await db.commit()\n\n    logger.info(\n        \"System command result from %s: %s success=%s message=%s\",\n        device_id,\n        req.command,\n        req.success,\n        req.message,\n    )\n    return {\"status\": \"ok\"}\n\n\n# --- Diagnostics ---\n\n\n@router.post(\"/diagnostics/{device_id}/run\")\nasync def queue_diagnostic(\n    device_id: str,\n    diagnostic: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Queue a hardware diagnostic to run on the SpoolBuddy device.\n\n    Args:\n        device_id: The device ID\n        diagnostic: 'scale' or 'nfc' to select which diagnostic to run\n\n    Returns:\n        Status message indicating diagnostic was queued\n    \"\"\"\n    if diagnostic not in (\"scale\", \"nfc\", \"read_tag\"):\n        raise HTTPException(status_code=400, detail=\"Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'\")\n\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    device.pending_command = f\"run_{diagnostic}_diag\"\n    _diagnostic_results.pop((device_id, diagnostic), None)\n    await db.commit()\n\n    logger.info(\"Diagnostic queued for device %s: %s\", device_id, diagnostic)\n    return {\"status\": \"queued\", \"diagnostic\": diagnostic, \"message\": f\"Diagnostic '{diagnostic}' queued for device\"}\n\n\n@router.get(\"/diagnostics/{device_id}/result\")\nasync def get_diagnostic_result(\n    device_id: str,\n    diagnostic: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Get the latest diagnostic result for a device.\n\n    Args:\n        device_id: The device ID\n        diagnostic: 'scale' or 'nfc'\n\n    Returns:\n        Diagnostic result or 404 if not found\n    \"\"\"\n    if diagnostic not in (\"scale\", \"nfc\", \"read_tag\"):\n        raise HTTPException(status_code=400, detail=\"Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'\")\n\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    diag_result = _diagnostic_results.get((device_id, diagnostic))\n    if not diag_result:\n        raise HTTPException(status_code=404, detail=f\"No {diagnostic} diagnostic results available yet\")\n    return diag_result\n\n\n@router.post(\"/diagnostics/{device_id}/result\")\nasync def report_diagnostic_result(\n    device_id: str,\n    req: DiagnosticResultRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Report diagnostic result from SpoolBuddy device.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    if req.diagnostic not in (\"nfc\", \"scale\", \"read_tag\"):\n        raise HTTPException(status_code=400, detail=\"Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'\")\n\n    _diagnostic_results[(device_id, req.diagnostic)] = {\n        \"diagnostic\": req.diagnostic,\n        \"success\": req.success,\n        \"output\": req.output,\n        \"exit_code\": req.exit_code,\n    }\n\n    device.pending_command = None\n    await db.commit()\n\n    logger.info(\"Diagnostic result received for device %s: %s (success=%s)\", device_id, req.diagnostic, req.success)\n    return {\"status\": \"ok\", \"message\": \"Diagnostic result recorded\"}\n\n\n# --- Update check ---\n\n\n@router.get(\"/devices/{device_id}/update-check\")\nasync def check_daemon_update(\n    device_id: str,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),\n):\n    \"\"\"Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version.\"\"\"\n    from backend.app.api.routes.updates import is_newer_version\n    from backend.app.core.config import APP_VERSION\n\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    current = device.firmware_version or \"0.0.0\"\n\n    return {\n        \"current_version\": current,\n        \"latest_version\": APP_VERSION,\n        \"update_available\": is_newer_version(APP_VERSION, current),\n    }\n\n\n@router.post(\"/devices/{device_id}/update\")\nasync def trigger_daemon_update(\n    device_id: str,\n    req: dict | None = None,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Trigger a SpoolBuddy update over SSH.\n\n    Bambuddy SSHes into the device, pulls the matching branch, installs deps,\n    and restarts the daemon. Progress is broadcast via WebSocket.\n    \"\"\"\n    from backend.app.services.spoolbuddy_ssh import perform_ssh_update\n\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    if not _is_online(device):\n        raise HTTPException(status_code=409, detail=\"Device is offline\")\n\n    if device.update_status == \"updating\":\n        return {\"status\": \"already_updating\", \"message\": \"Update already in progress\"}\n\n    device.update_status = \"pending\"\n    device.update_message = \"Starting SSH update...\"\n    await db.commit()\n\n    logger.info(\"SpoolBuddy %s: SSH update triggered (ip=%s)\", device_id, device.ip_address)\n    await ws_manager.broadcast(\n        {\n            \"type\": \"spoolbuddy_update\",\n            \"device_id\": device_id,\n            \"update_status\": \"pending\",\n        }\n    )\n\n    # Run the SSH update in the background\n    asyncio.create_task(perform_ssh_update(device_id, device.ip_address))\n\n    return {\"status\": \"ok\", \"message\": \"SSH update started\"}\n\n\n@router.get(\"/ssh/public-key\")\nasync def get_ssh_public_key(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Return the SSH public key for SpoolBuddy pairing.\"\"\"\n    from backend.app.services.spoolbuddy_ssh import get_public_key\n\n    try:\n        key = await get_public_key()\n        return {\"public_key\": key}\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=f\"Failed to get SSH key: {e}\") from e\n\n\n@router.post(\"/devices/{device_id}/update-status\")\nasync def report_update_status(\n    device_id: str,\n    req: dict,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),\n):\n    \"\"\"Daemon reports update progress back to the backend.\"\"\"\n    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n    device = result.scalar_one_or_none()\n    if not device:\n        raise HTTPException(status_code=404, detail=\"Device not registered\")\n\n    status = req.get(\"status\", \"\")\n    message = req.get(\"message\", \"\")\n\n    if status in (\"updating\", \"complete\", \"error\"):\n        device.update_status = status\n        device.update_message = message[:255] if message else None\n        if status == \"complete\":\n            device.pending_command = None\n        await db.commit()\n\n        logger.info(\"SpoolBuddy %s: update status=%s msg=%s\", device_id, status, message)\n        await ws_manager.broadcast(\n            {\n                \"type\": \"spoolbuddy_update\",\n                \"device_id\": device_id,\n                \"update_status\": status,\n                \"update_message\": message,\n            }\n        )\n\n    return {\"status\": \"ok\"}\n\n\n# --- Background watchdog ---\n\n\nasync def spoolbuddy_watchdog():\n    \"\"\"Check for devices that have gone offline (no heartbeat for 30s).\n\n    Called periodically from the main app's background task loop.\n    \"\"\"\n    from backend.app.core.database import async_session\n\n    async with async_session() as db:\n        result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.last_seen.isnot(None)))\n        devices = list(result.scalars().all())\n\n        threshold = datetime.now(timezone.utc) - timedelta(seconds=OFFLINE_THRESHOLD_SECONDS)\n        for device in devices:\n            last_seen = device.last_seen.replace(tzinfo=timezone.utc) if device.last_seen else None\n            if last_seen and last_seen < threshold:\n                # Only broadcast once — clear last_seen after marking offline\n                await ws_manager.broadcast(\n                    {\n                        \"type\": \"spoolbuddy_offline\",\n                        \"device_id\": device.device_id,\n                    }\n                )\n                device.last_seen = None\n                logger.info(\"SpoolBuddy device offline: %s\", device.device_id)\n\n        await db.commit()\n"
  },
  {
    "path": "backend/app/api/routes/spoolman.py",
    "content": "\"\"\"Spoolman integration API routes.\"\"\"\n\nimport json\nimport logging\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.spool_assignment import SpoolAssignment\nfrom backend.app.models.user import User\nfrom backend.app.services.printer_manager import printer_manager\nfrom backend.app.services.spoolman import (\n    close_spoolman_client,\n    get_spoolman_client,\n    init_spoolman_client,\n)\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/spoolman\", tags=[\"spoolman\"])\n\n\nclass SpoolmanStatus(BaseModel):\n    \"\"\"Spoolman connection status.\"\"\"\n\n    enabled: bool\n    connected: bool\n    url: str | None\n\n\nclass SkippedSpool(BaseModel):\n    \"\"\"Information about a skipped spool during sync.\"\"\"\n\n    location: str  # e.g., \"AMS A1\" or \"External Spool\"\n    reason: str  # e.g., \"Not a Bambu Lab spool\", \"Empty tray\"\n    filament_type: str | None = None  # e.g., \"PLA\", \"PETG\"\n    color: str | None = None  # Hex color\n\n\nclass SyncResult(BaseModel):\n    \"\"\"Result of a Spoolman sync operation.\"\"\"\n\n    success: bool\n    synced_count: int\n    skipped_count: int = 0\n    skipped: list[SkippedSpool] = []\n    errors: list[str]\n\n\nasync def get_spoolman_settings(db: AsyncSession) -> dict:\n    \"\"\"Get Spoolman settings from database.\n\n    Returns:\n        Dict with keys: enabled, url, sync_mode, disable_weight_sync\n    \"\"\"\n    settings = {\n        \"enabled\": False,\n        \"url\": \"\",\n        \"sync_mode\": \"auto\",\n        \"disable_weight_sync\": False,\n    }\n\n    result = await db.execute(select(Settings))\n    for setting in result.scalars().all():\n        if setting.key == \"spoolman_enabled\":\n            settings[\"enabled\"] = setting.value.lower() == \"true\"\n        elif setting.key == \"spoolman_url\":\n            settings[\"url\"] = setting.value\n        elif setting.key == \"spoolman_sync_mode\":\n            settings[\"sync_mode\"] = setting.value\n        elif setting.key == \"spoolman_disable_weight_sync\":\n            settings[\"disable_weight_sync\"] = setting.value.lower() == \"true\"\n\n    return settings\n\n\n@router.get(\"/status\", response_model=SpoolmanStatus)\nasync def get_spoolman_status(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"Get Spoolman integration status.\"\"\"\n    sm = await get_spoolman_settings(db)\n    enabled, url = sm[\"enabled\"], sm[\"url\"]\n\n    client = await get_spoolman_client()\n    connected = False\n    if client:\n        connected = await client.health_check()\n\n    return SpoolmanStatus(\n        enabled=enabled,\n        connected=connected,\n        url=url if url else None,\n    )\n\n\n@router.post(\"/connect\")\nasync def connect_spoolman(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Connect to Spoolman server using configured URL.\"\"\"\n    sm = await get_spoolman_settings(db)\n    enabled, url = sm[\"enabled\"], sm[\"url\"]\n\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    if not url:\n        raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    try:\n        client = await init_spoolman_client(url)\n        connected = await client.health_check()\n\n        if not connected:\n            raise HTTPException(\n                status_code=503,\n                detail=f\"Could not connect to Spoolman at {url}\",\n            )\n\n        # Ensure the 'tag' extra field exists for RFID/UUID storage\n        await client.ensure_tag_extra_field()\n\n        return {\"success\": True, \"message\": f\"Connected to Spoolman at {url}\"}\n    except Exception as e:\n        logger.error(\"Failed to connect to Spoolman: %s\", e)\n        raise HTTPException(status_code=503, detail=str(e))\n\n\n@router.post(\"/disconnect\")\nasync def disconnect_spoolman(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Disconnect from Spoolman server.\"\"\"\n    await close_spoolman_client()\n    return {\"success\": True, \"message\": \"Disconnected from Spoolman\"}\n\n\n@router.post(\"/sync/{printer_id}\", response_model=SyncResult)\nasync def sync_printer_ams(\n    printer_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),\n):\n    \"\"\"Sync AMS data from a specific printer to Spoolman.\"\"\"\n    # Check if Spoolman is enabled and connected\n    sm = await get_spoolman_settings(db)\n    enabled, url, disable_weight_sync = sm[\"enabled\"], sm[\"url\"], sm[\"disable_weight_sync\"]\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    client = await get_spoolman_client()\n    if not client:\n        # Try to connect\n        if url:\n            client = await init_spoolman_client(url)\n        else:\n            raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    if not await client.health_check():\n        raise HTTPException(status_code=503, detail=\"Spoolman is not reachable\")\n\n    # Get printer info\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    # Get current printer state with AMS data\n    state = printer_manager.get_status(printer_id)\n    if not state:\n        raise HTTPException(status_code=404, detail=\"Printer not connected\")\n\n    if not state.raw_data:\n        raise HTTPException(status_code=400, detail=\"No AMS data available\")\n\n    ams_data = state.raw_data.get(\"ams\")\n    if not ams_data:\n        raise HTTPException(\n            status_code=400,\n            detail=\"No AMS data in printer state. Try triggering a slot re-read on the printer.\",\n        )\n\n    # Sync each AMS tray to Spoolman\n    synced = 0\n    skipped: list[SkippedSpool] = []\n    errors = []\n    # Track tray UUIDs currently in the AMS (for clearing removed spools)\n    current_tray_uuids: set[str] = set()\n    synced_spool_ids: set[int] = set()\n\n    # Handle different AMS data structures\n    # Traditional AMS: list of {\"id\": N, \"tray\": [...]} dicts\n    # H2D/newer printers: dict with different structure\n    ams_units = []\n    if isinstance(ams_data, list):\n        ams_units = ams_data\n    elif isinstance(ams_data, dict):\n        # H2D format: check for \"ams\" key containing list, or \"tray\" key directly\n        if \"ams\" in ams_data and isinstance(ams_data[\"ams\"], list):\n            ams_units = ams_data[\"ams\"]\n        elif \"tray\" in ams_data:\n            # Single AMS unit format - wrap in list\n            ams_units = [{\"id\": 0, \"tray\": ams_data.get(\"tray\", [])}]\n        else:\n            logger.info(\"AMS dict keys for debugging: %s\", list(ams_data.keys()))\n\n    if not ams_units:\n        raise HTTPException(\n            status_code=400,\n            detail=f\"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}\",\n        )\n\n    # OPTIMIZATION: Fetch all spools once before processing trays\n    # This eliminates redundant API calls (one per tray) when syncing multiple trays\n    logger.debug(\"[Printer %s] Fetching spools cache for sync...\", printer.name)\n    try:\n        cached_spools = await client.get_spools()\n        logger.debug(\"[Printer %s] Cached %d spools for batch sync\", printer.name, len(cached_spools))\n    except Exception as e:\n        logger.error(\"[Printer %s] Failed to fetch spools cache after retries: %s\", printer.name, e)\n        raise HTTPException(\n            status_code=503,\n            detail=f\"Failed to connect to Spoolman after multiple retries: {str(e)}\",\n        )\n\n    # Load inventory weights as fallback (when AMS MQTT data lacks remain values)\n    inv_weights: dict[tuple[int, int], float] = {}\n    try:\n        assign_result = await db.execute(\n            select(SpoolAssignment)\n            .options(selectinload(SpoolAssignment.spool))\n            .where(SpoolAssignment.printer_id == printer_id)\n        )\n        for assignment in assign_result.scalars().all():\n            spool = assignment.spool\n            if spool and spool.label_weight > 0:\n                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))\n                inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining\n    except Exception as e:\n        logger.debug(\"Could not load inventory weights for printer %s: %s\", printer_id, e)\n\n    for ams_unit in ams_units:\n        if not isinstance(ams_unit, dict):\n            continue\n\n        ams_id = int(ams_unit.get(\"id\", 0))\n        trays = ams_unit.get(\"tray\", [])\n\n        for tray_data in trays:\n            if not isinstance(tray_data, dict):\n                continue\n\n            tray = client.parse_ams_tray(ams_id, tray_data)\n            if not tray:\n                continue  # Empty tray - nothing to sync\n\n            # Build location string for reporting\n            location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)\n\n            # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped\n            if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):\n                skipped.append(\n                    SkippedSpool(\n                        location=location,\n                        reason=\"Non-Bambu Lab spool (no RFID tag)\",\n                        filament_type=tray.tray_type if tray.tray_type else None,\n                        color=tray.tray_color[:6] if tray.tray_color else None,\n                    )\n                )\n                continue\n\n            # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)\n            spool_tag = (\n                tray.tray_uuid\n                if tray.tray_uuid and tray.tray_uuid != \"00000000000000000000000000000000\"\n                else tray.tag_uid\n            )\n            if spool_tag:\n                current_tray_uuids.add(spool_tag.upper())\n\n            try:\n                inv_remaining = inv_weights.get((ams_id, tray.tray_id))\n                sync_result = await client.sync_ams_tray(\n                    tray,\n                    printer.name,\n                    disable_weight_sync=disable_weight_sync,\n                    cached_spools=cached_spools,\n                    inventory_remaining=inv_remaining,\n                )\n                if sync_result:\n                    synced += 1\n                    # Add newly created spool to cache and track synced ID\n                    if sync_result.get(\"id\"):\n                        synced_spool_ids.add(sync_result[\"id\"])\n                        spool_exists = any(s.get(\"id\") == sync_result[\"id\"] for s in cached_spools)\n                        if not spool_exists:\n                            cached_spools.append(sync_result)\n                            logger.debug(\"Added newly created spool %s to cache\", sync_result[\"id\"])\n                    logger.info(\n                        \"Synced %s from %s AMS %s tray %s\", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id\n                    )\n                else:\n                    # Bambu Lab spool that wasn't synced (not found in Spoolman)\n                    errors.append(f\"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}\")\n            except Exception as e:\n                error_msg = f\"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}\"\n                logger.error(error_msg)\n                errors.append(error_msg)\n\n    # Clear location for spools that were removed from this printer's AMS\n    try:\n        cleared = await client.clear_location_for_removed_spools(\n            printer.name, current_tray_uuids, cached_spools=cached_spools, synced_spool_ids=synced_spool_ids\n        )\n        if cleared > 0:\n            logger.info(\"Cleared location for %s spools removed from %s\", cleared, printer.name)\n    except Exception as e:\n        logger.error(\"Error clearing locations for removed spools: %s\", e)\n\n    return SyncResult(\n        success=len(errors) == 0,\n        synced_count=synced,\n        skipped_count=len(skipped),\n        skipped=skipped,\n        errors=errors,\n    )\n\n\n@router.post(\"/sync-all\", response_model=SyncResult)\nasync def sync_all_printers(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),\n):\n    \"\"\"Sync AMS data from all connected printers to Spoolman.\"\"\"\n    # Check if Spoolman is enabled\n    sm = await get_spoolman_settings(db)\n    enabled, url, disable_weight_sync = sm[\"enabled\"], sm[\"url\"], sm[\"disable_weight_sync\"]\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    client = await get_spoolman_client()\n    if not client:\n        if url:\n            client = await init_spoolman_client(url)\n        else:\n            raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    if not await client.health_check():\n        raise HTTPException(status_code=503, detail=\"Spoolman is not reachable\")\n\n    # Get all active printers\n    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))\n    printers = result.scalars().all()\n\n    total_synced = 0\n    all_skipped: list[SkippedSpool] = []\n    all_errors = []\n    # Track tray UUIDs per printer (for clearing removed spools)\n    printer_tray_uuids: dict[str, set[str]] = {}\n    # Track synced spool IDs per printer (for location-based cleanup when no UUIDs available)\n    printer_synced_ids: dict[str, set[int]] = {}\n\n    # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays\n    # This eliminates redundant API calls across all printers\n    logger.debug(\"Fetching spools cache for sync-all operation...\")\n    try:\n        cached_spools = await client.get_spools()\n        logger.debug(\"Cached %d spools for batch sync across %d printers\", len(cached_spools), len(printers))\n    except Exception as e:\n        logger.error(\"Failed to fetch spools cache after retries: %s\", e)\n        raise HTTPException(\n            status_code=503,\n            detail=f\"Failed to connect to Spoolman after multiple retries: {str(e)}\",\n        )\n\n    # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)\n    # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams\n    inventory_weights: dict[tuple[int, int, int], float] = {}\n    try:\n        assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))\n        for assignment in assign_result.scalars().all():\n            spool = assignment.spool\n            if spool and spool.label_weight > 0:\n                remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))\n                inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining\n    except Exception as e:\n        logger.debug(\"Could not load inventory assignments for weight fallback: %s\", e)\n\n    for printer in printers:\n        state = printer_manager.get_status(printer.id)\n        if not state or not state.raw_data:\n            continue\n\n        ams_data = state.raw_data.get(\"ams\")\n        if not ams_data:\n            continue\n\n        # Initialize tracking sets for this printer\n        printer_tray_uuids[printer.name] = set()\n        printer_synced_ids[printer.name] = set()\n\n        # Handle different AMS data structures\n        # Traditional AMS: list of {\"id\": N, \"tray\": [...]} dicts\n        # H2D/newer printers: dict with different structure\n        ams_units = []\n        if isinstance(ams_data, list):\n            ams_units = ams_data\n        elif isinstance(ams_data, dict):\n            # H2D format: check for \"ams\" key containing list, or \"tray\" key directly\n            if \"ams\" in ams_data and isinstance(ams_data[\"ams\"], list):\n                ams_units = ams_data[\"ams\"]\n            elif \"tray\" in ams_data:\n                # Single AMS unit format - wrap in list\n                ams_units = [{\"id\": 0, \"tray\": ams_data.get(\"tray\", [])}]\n            else:\n                logger.debug(\"Printer %s AMS dict keys: %s\", printer.name, list(ams_data.keys()))\n\n        if not ams_units:\n            logger.debug(\"Printer %s has no AMS units to sync (type: %s)\", printer.name, type(ams_data).__name__)\n            continue\n\n        for ams_unit in ams_units:\n            if not isinstance(ams_unit, dict):\n                logger.debug(\"Skipping non-dict AMS unit: %s\", type(ams_unit))\n                continue\n\n            ams_id = int(ams_unit.get(\"id\", 0))\n            trays = ams_unit.get(\"tray\", [])\n\n            for tray_data in trays:\n                if not isinstance(tray_data, dict):\n                    continue\n\n                tray = client.parse_ams_tray(ams_id, tray_data)\n                if not tray:\n                    continue\n\n                # Build location string for reporting\n                location = f\"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}\"\n\n                # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped\n                if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):\n                    all_skipped.append(\n                        SkippedSpool(\n                            location=location,\n                            reason=\"Non-Bambu Lab spool (no RFID tag)\",\n                            filament_type=tray.tray_type if tray.tray_type else None,\n                            color=tray.tray_color[:6] if tray.tray_color else None,\n                        )\n                    )\n                    continue\n\n                # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)\n                spool_tag = (\n                    tray.tray_uuid\n                    if tray.tray_uuid and tray.tray_uuid != \"00000000000000000000000000000000\"\n                    else tray.tag_uid\n                )\n                if spool_tag:\n                    printer_tray_uuids[printer.name].add(spool_tag.upper())\n\n                try:\n                    # Look up inventory weight as fallback when AMS data is invalid\n                    inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id))\n                    sync_result = await client.sync_ams_tray(\n                        tray,\n                        printer.name,\n                        disable_weight_sync=disable_weight_sync,\n                        cached_spools=cached_spools,\n                        inventory_remaining=inv_remaining,\n                    )\n                    if sync_result:\n                        total_synced += 1\n                        # Track synced spool ID for cleanup\n                        if sync_result.get(\"id\"):\n                            printer_synced_ids[printer.name].add(sync_result[\"id\"])\n                            # Add newly created spool to cache\n                            spool_exists = any(s.get(\"id\") == sync_result[\"id\"] for s in cached_spools)\n                            if not spool_exists:\n                                cached_spools.append(sync_result)\n                                logger.debug(\"Added newly created spool %s to cache\", sync_result[\"id\"])\n                except Exception as e:\n                    all_errors.append(f\"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}\")\n\n    # Clear location for spools that were removed from each printer's AMS\n    for printer_name, current_tray_uuids in printer_tray_uuids.items():\n        try:\n            cleared = await client.clear_location_for_removed_spools(\n                printer_name,\n                current_tray_uuids,\n                cached_spools=cached_spools,\n                synced_spool_ids=printer_synced_ids.get(printer_name, set()),\n            )\n            if cleared > 0:\n                logger.info(\"Cleared location for %s spools removed from %s\", cleared, printer_name)\n        except Exception as e:\n            logger.error(\"Error clearing locations for %s: %s\", printer_name, e)\n\n    return SyncResult(\n        success=len(all_errors) == 0,\n        synced_count=total_synced,\n        skipped_count=len(all_skipped),\n        skipped=all_skipped,\n        errors=all_errors,\n    )\n\n\n@router.get(\"/spools\")\nasync def get_spools(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"Get all spools from Spoolman.\"\"\"\n    sm = await get_spoolman_settings(db)\n    enabled, url = sm[\"enabled\"], sm[\"url\"]\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    client = await get_spoolman_client()\n    if not client:\n        if url:\n            client = await init_spoolman_client(url)\n        else:\n            raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    if not await client.health_check():\n        raise HTTPException(status_code=503, detail=\"Spoolman is not reachable\")\n\n    spools = await client.get_spools()\n    return {\"spools\": spools}\n\n\n@router.get(\"/filaments\")\nasync def get_filaments(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"Get all filaments from Spoolman.\"\"\"\n    sm = await get_spoolman_settings(db)\n    enabled, url = sm[\"enabled\"], sm[\"url\"]\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    client = await get_spoolman_client()\n    if not client:\n        if url:\n            client = await init_spoolman_client(url)\n        else:\n            raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    if not await client.health_check():\n        raise HTTPException(status_code=503, detail=\"Spoolman is not reachable\")\n\n    filaments = await client.get_filaments()\n    return {\"filaments\": filaments}\n\n\nclass UnlinkedSpool(BaseModel):\n    \"\"\"A Spoolman spool that is not linked to any AMS tray.\"\"\"\n\n    id: int\n    filament_name: str | None\n    filament_vendor: str | None\n    filament_material: str | None\n    filament_color_hex: str | None\n    remaining_weight: float | None\n    location: str | None\n\n\n@router.get(\"/spools/unlinked\", response_model=list[UnlinkedSpool])\nasync def get_unlinked_spools(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"Get all Spoolman spools that don't have a tag (not linked to AMS).\"\"\"\n    sm = await get_spoolman_settings(db)\n    enabled, url = sm[\"enabled\"], sm[\"url\"]\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    client = await get_spoolman_client()\n    if not client:\n        if url:\n            client = await init_spoolman_client(url)\n        else:\n            raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    if not await client.health_check():\n        raise HTTPException(status_code=503, detail=\"Spoolman is not reachable\")\n\n    spools = await client.get_spools()\n    unlinked = []\n\n    for spool in spools:\n        # Check if spool has a tag in extra field\n        extra = spool.get(\"extra\", {}) or {}\n        tag = extra.get(\"tag\", \"\")\n        # Remove quotes if present (JSON encoded string) and check if empty\n        clean_tag = tag.strip('\"') if tag else \"\"\n        if not clean_tag:\n            filament = spool.get(\"filament\", {}) or {}\n            unlinked.append(\n                UnlinkedSpool(\n                    id=spool[\"id\"],\n                    filament_name=filament.get(\"name\"),\n                    filament_vendor=(filament.get(\"vendor\") or {}).get(\"name\"),\n                    filament_material=filament.get(\"material\"),\n                    filament_color_hex=filament.get(\"color_hex\"),\n                    remaining_weight=spool.get(\"remaining_weight\"),\n                    location=spool.get(\"location\"),\n                )\n            )\n\n    return unlinked\n\n\n@router.get(\"/spools/linked\")\nasync def get_linked_spools(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),\n):\n    \"\"\"Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned.\"\"\"\n    sm = await get_spoolman_settings(db)\n    enabled, url = sm[\"enabled\"], sm[\"url\"]\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    client = await get_spoolman_client()\n    if not client:\n        if url:\n            client = await init_spoolman_client(url)\n        else:\n            raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    if not await client.health_check():\n        raise HTTPException(status_code=503, detail=\"Spoolman is not reachable\")\n\n    spools = await client.get_spools()\n    linked: dict[str, dict] = {}\n\n    for spool in spools:\n        # Check if spool has a tag in extra field\n        extra = spool.get(\"extra\", {}) or {}\n        tag = extra.get(\"tag\", \"\")\n        if tag:\n            # Remove quotes if present (JSON encoded string)\n            clean_tag = tag.strip('\"').upper()\n            if clean_tag:\n                filament = spool.get(\"filament\") or {}\n                linked[clean_tag] = {\n                    \"id\": spool[\"id\"],\n                    \"remaining_weight\": spool.get(\"remaining_weight\"),\n                    \"filament_weight\": filament.get(\"weight\"),\n                }\n\n    return {\"linked\": linked}\n\n\nclass LinkSpoolRequest(BaseModel):\n    \"\"\"Request to link a Spoolman spool to an AMS tag (tray_uuid or tag_uid).\"\"\"\n\n    spool_tag: str | None = None\n    tray_uuid: str | None = None\n    tag_uid: str | None = None\n    printer_id: int | None = None\n    ams_id: int | None = None\n    tray_id: int | None = None\n\n\n@router.post(\"/spools/{spool_id}/link\")\nasync def link_spool(\n    spool_id: int,\n    request: LinkSpoolRequest,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),\n):\n    \"\"\"Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag.\"\"\"\n    sm = await get_spoolman_settings(db)\n    enabled, url = sm[\"enabled\"], sm[\"url\"]\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    client = await get_spoolman_client()\n    if not client:\n        if url:\n            client = await init_spoolman_client(url)\n        else:\n            raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    if not await client.health_check():\n        raise HTTPException(status_code=503, detail=\"Spoolman is not reachable\")\n\n    # Resolve and validate spool tag (supports tray_uuid=32 hex and tag_uid=16 hex)\n    spool_tag = (request.spool_tag or request.tray_uuid or request.tag_uid or \"\").strip()\n    if not spool_tag:\n        raise HTTPException(status_code=400, detail=\"Missing spool tag (tray_uuid or tag_uid)\")\n    if len(spool_tag) not in (16, 32):\n        raise HTTPException(status_code=400, detail=\"Invalid spool tag format (must be 16 or 32 hex characters)\")\n    try:\n        int(spool_tag, 16)\n    except ValueError:\n        raise HTTPException(status_code=400, detail=\"Invalid spool tag format (must be hex)\")\n\n    if set(spool_tag) == {\"0\"}:\n        raise HTTPException(status_code=400, detail=\"Invalid spool tag format (all-zero tag is not linkable)\")\n\n    spool_tag = spool_tag.upper()\n\n    # Build location like: \"{Printer Name} - {AMS Name} {Slot Number}\"\n    location: str | None = None\n    if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:\n        printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))\n        printer = printer_result.scalar_one_or_none()\n        if not printer:\n            raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n        location = f\"{printer.name} - {client.convert_ams_slot_to_location(request.ams_id, request.tray_id)}\"\n\n    # Update spool with tag\n    # Note: Spoolman extra field values must be valid JSON, so we encode the string\n    result = await client.update_spool(\n        spool_id=spool_id,\n        location=location,\n        extra={\"tag\": json.dumps(spool_tag)},\n    )\n\n    if result:\n        logger.info(\"Linked Spoolman spool %s to tag %s\", spool_id, spool_tag)\n        return {\"success\": True, \"message\": f\"Spool {spool_id} linked to AMS tag\"}\n    else:\n        raise HTTPException(status_code=500, detail=\"Failed to update spool\")\n\n\n@router.post(\"/spools/{spool_id}/unlink\")\nasync def unlink_spool(\n    spool_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),\n):\n    \"\"\"Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag.\"\"\"\n    sm = await get_spoolman_settings(db)\n    enabled, url = sm[\"enabled\"], sm[\"url\"]\n    if not enabled:\n        raise HTTPException(status_code=400, detail=\"Spoolman integration is not enabled\")\n\n    client = await get_spoolman_client()\n    if not client:\n        if url:\n            client = await init_spoolman_client(url)\n        else:\n            raise HTTPException(status_code=400, detail=\"Spoolman URL is not configured\")\n\n    if not await client.health_check():\n        raise HTTPException(status_code=503, detail=\"Spoolman is not reachable\")\n\n    result = await client.update_spool(\n        spool_id=spool_id,\n        clear_location=True,\n        extra={\"tag\": json.dumps(\"\")},\n    )\n\n    if result:\n        logger.info(\"Unlinked Spoolman spool %s\", spool_id)\n        return {\"success\": True, \"message\": f\"Spool {spool_id} unlinked from AMS\"}\n    else:\n        raise HTTPException(status_code=500, detail=\"Failed to update spool\")\n"
  },
  {
    "path": "backend/app/api/routes/support.py",
    "content": "\"\"\"Support endpoints for debug logging and support bundle generation.\"\"\"\n\nimport asyncio\nimport importlib.metadata\nimport io\nimport ipaddress\nimport json\nimport logging\nimport os\nimport platform\nimport re\nimport zipfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom fastapi import APIRouter, HTTPException, Query\nfrom fastapi.responses import StreamingResponse\nfrom pydantic import BaseModel\nfrom sqlalchemy import func, select, text\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.config import APP_VERSION, settings\nfrom backend.app.core.database import async_session\nfrom backend.app.core.permissions import Permission\nfrom backend.app.core.websocket import ws_manager\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.filament import Filament\nfrom backend.app.models.notification import NotificationProvider\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.project import Project\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.smart_plug import SmartPlug\nfrom backend.app.models.user import User\nfrom backend.app.services.discovery import is_running_in_docker\nfrom backend.app.services.network_utils import get_network_interfaces\nfrom backend.app.services.printer_manager import printer_manager\n\nrouter = APIRouter(prefix=\"/support\", tags=[\"support\"])\nlogger = logging.getLogger(__name__)\n\n\nclass DebugLoggingState(BaseModel):\n    enabled: bool\n    enabled_at: str | None = None\n    duration_seconds: int | None = None\n\n\nclass DebugLoggingToggle(BaseModel):\n    enabled: bool\n\n\nasync def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:\n    \"\"\"Get debug logging state from database.\"\"\"\n    result = await db.execute(select(Settings).where(Settings.key == \"debug_logging_enabled\"))\n    enabled_setting = result.scalar_one_or_none()\n\n    result = await db.execute(select(Settings).where(Settings.key == \"debug_logging_enabled_at\"))\n    enabled_at_setting = result.scalar_one_or_none()\n\n    enabled = enabled_setting.value.lower() == \"true\" if enabled_setting else False\n    enabled_at = None\n    if enabled_at_setting and enabled_at_setting.value:\n        try:\n            enabled_at = datetime.fromisoformat(enabled_at_setting.value)\n            if enabled_at.tzinfo is None:\n                enabled_at = enabled_at.replace(tzinfo=timezone.utc)\n        except ValueError:\n            pass  # Ignore malformed timestamp; enabled_at stays None\n\n    return enabled, enabled_at\n\n\nasync def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:\n    \"\"\"Set debug logging state in database.\"\"\"\n    # Update or create enabled setting\n    result = await db.execute(select(Settings).where(Settings.key == \"debug_logging_enabled\"))\n    setting = result.scalar_one_or_none()\n    if setting:\n        setting.value = str(enabled).lower()\n    else:\n        db.add(Settings(key=\"debug_logging_enabled\", value=str(enabled).lower()))\n\n    # Update enabled_at timestamp\n    enabled_at = datetime.now(tz=timezone.utc) if enabled else None\n    result = await db.execute(select(Settings).where(Settings.key == \"debug_logging_enabled_at\"))\n    at_setting = result.scalar_one_or_none()\n    if at_setting:\n        at_setting.value = enabled_at.isoformat() if enabled_at else \"\"\n    else:\n        db.add(Settings(key=\"debug_logging_enabled_at\", value=enabled_at.isoformat() if enabled_at else \"\"))\n\n    await db.commit()\n    return enabled_at\n\n\ndef _apply_log_level(debug: bool):\n    \"\"\"Apply log level change to root logger.\"\"\"\n    root_logger = logging.getLogger()\n    new_level = logging.DEBUG if debug else logging.INFO\n\n    root_logger.setLevel(new_level)\n    for handler in root_logger.handlers:\n        handler.setLevel(new_level)\n\n    # Also adjust third-party loggers. httpx/httpcore stay pinned to WARNING\n    # even in debug mode — at INFO/DEBUG they log full request URLs, which\n    # leaks secrets embedded in webhook URLs (Discord, generic webhooks, etc.).\n    logging.getLogger(\"sqlalchemy.engine\").setLevel(logging.WARNING)\n    logging.getLogger(\"aiosqlite\").setLevel(logging.WARNING)\n    logging.getLogger(\"httpcore\").setLevel(logging.WARNING)\n    logging.getLogger(\"httpx\").setLevel(logging.WARNING)\n    logging.getLogger(\"paho.mqtt\").setLevel(logging.DEBUG if debug else logging.WARNING)\n\n    logger.info(\"Log level changed to %s\", \"DEBUG\" if debug else \"INFO\")\n\n\n@router.get(\"/debug-logging\", response_model=DebugLoggingState)\nasync def get_debug_logging_state(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get current debug logging state.\"\"\"\n    async with async_session() as db:\n        enabled, enabled_at = await _get_debug_setting(db)\n\n    duration = None\n    if enabled and enabled_at:\n        duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())\n\n    return DebugLoggingState(\n        enabled=enabled,\n        enabled_at=enabled_at.isoformat() if enabled_at else None,\n        duration_seconds=duration,\n    )\n\n\n@router.post(\"/debug-logging\", response_model=DebugLoggingState)\nasync def toggle_debug_logging(\n    toggle: DebugLoggingToggle,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Enable or disable debug logging.\"\"\"\n    async with async_session() as db:\n        enabled_at = await _set_debug_setting(db, toggle.enabled)\n\n    _apply_log_level(toggle.enabled)\n\n    duration = None\n    if toggle.enabled and enabled_at:\n        duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())\n\n    return DebugLoggingState(\n        enabled=toggle.enabled,\n        enabled_at=enabled_at.isoformat() if enabled_at else None,\n        duration_seconds=duration,\n    )\n\n\nclass LogEntry(BaseModel):\n    \"\"\"A single log entry.\"\"\"\n\n    timestamp: str\n    level: str\n    logger_name: str\n    message: str\n\n\nclass LogsResponse(BaseModel):\n    \"\"\"Response containing log entries.\"\"\"\n\n    entries: list[LogEntry]\n    total_in_file: int\n    filtered_count: int\n\n\n# Log line regex pattern: \"2024-01-15 10:30:45,123 INFO [module.name] Message here\"\nLOG_LINE_PATTERN = re.compile(r\"^(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2},\\d{3})\\s+(\\w+)\\s+\\[([^\\]]+)\\]\\s+(.*)$\")\n\n\ndef _parse_log_line(line: str) -> LogEntry | None:\n    \"\"\"Parse a single log line into a LogEntry.\"\"\"\n    match = LOG_LINE_PATTERN.match(line.strip())\n    if match:\n        return LogEntry(\n            timestamp=match.group(1),\n            level=match.group(2),\n            logger_name=match.group(3),\n            message=match.group(4),\n        )\n    return None\n\n\ndef _read_log_entries(\n    limit: int = 200,\n    level_filter: str | None = None,\n    search: str | None = None,\n) -> tuple[list[LogEntry], int]:\n    \"\"\"Read and parse log entries from file with optional filtering.\"\"\"\n    log_file = settings.log_dir / \"bambuddy.log\"\n    if not log_file.exists():\n        return [], 0\n\n    entries: list[LogEntry] = []\n    total_lines = 0\n\n    try:\n        with open(log_file, encoding=\"utf-8\", errors=\"replace\") as f:\n            # Read all lines and process\n            lines = f.readlines()\n            total_lines = len(lines)\n\n            # Parse lines in reverse order (newest first)\n            current_entry: LogEntry | None = None\n            multi_line_buffer: list[str] = []\n\n            for line in reversed(lines):\n                parsed = _parse_log_line(line)\n                if parsed:\n                    # Found a new log entry start\n                    if current_entry:\n                        # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)\n                        should_include = True\n\n                        # Level filter\n                        if level_filter and current_entry.level.upper() != level_filter.upper():\n                            should_include = False\n\n                        # Search filter (case-insensitive)\n                        if search and should_include:\n                            search_lower = search.lower()\n                            if not (\n                                search_lower in current_entry.message.lower()\n                                or search_lower in current_entry.logger_name.lower()\n                            ):\n                                should_include = False\n\n                        if should_include:\n                            entries.append(current_entry)\n\n                            if len(entries) >= limit:\n                                break\n\n                    # Set new entry and attach any accumulated multi-line content to it\n                    # (in reverse order, continuation lines come before their parent entry)\n                    current_entry = parsed\n                    if multi_line_buffer:\n                        current_entry.message += \"\\n\" + \"\\n\".join(reversed(multi_line_buffer))\n                    multi_line_buffer = []\n                elif line.strip():\n                    # Continuation of multi-line log entry (will be attached to next parsed entry)\n                    multi_line_buffer.append(line.rstrip())\n\n            # Don't forget the last (oldest) entry\n            # Note: any remaining multi_line_buffer would be orphaned lines before the first entry\n            if current_entry and len(entries) < limit:\n                should_include = True\n                if level_filter and current_entry.level.upper() != level_filter.upper():\n                    should_include = False\n                if search and should_include:\n                    search_lower = search.lower()\n                    if not (\n                        search_lower in current_entry.message.lower()\n                        or search_lower in current_entry.logger_name.lower()\n                    ):\n                        should_include = False\n                if should_include:\n                    entries.append(current_entry)\n\n    except Exception as e:\n        logger.error(\"Error reading log file: %s\", e)\n        return [], 0\n\n    # Entries are already in newest-first order\n    return entries, total_lines\n\n\n@router.get(\"/logs\", response_model=LogsResponse)\nasync def get_logs(\n    limit: int = Query(200, ge=1, le=1000, description=\"Maximum number of entries to return\"),\n    level: str | None = Query(None, description=\"Filter by log level (DEBUG, INFO, WARNING, ERROR)\"),\n    search: str | None = Query(None, description=\"Search in message or logger name\"),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get recent application log entries with optional filtering.\"\"\"\n    entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)\n\n    return LogsResponse(\n        entries=entries,\n        total_in_file=total_lines,\n        filtered_count=len(entries),\n    )\n\n\n@router.delete(\"/logs\")\nasync def clear_logs(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Clear the application log file.\"\"\"\n    log_file = settings.log_dir / \"bambuddy.log\"\n\n    if log_file.exists():\n        try:\n            # Truncate the file instead of deleting (keeps file handles valid)\n            with open(log_file, \"w\", encoding=\"utf-8\") as f:\n                f.write(\"\")\n            logger.info(\"Log file cleared by user\")\n            return {\"message\": \"Logs cleared successfully\"}\n        except Exception as e:\n            logger.error(\"Error clearing log file: %s\", e, exc_info=True)\n            raise HTTPException(status_code=500, detail=\"Failed to clear logs. Check server logs for details.\")\n\n    return {\"message\": \"Log file does not exist\"}\n\n\ndef _sanitize_path(path: str) -> str:\n    \"\"\"Remove username from paths for privacy.\"\"\"\n\n    # Replace /home/username/ or /Users/username/ with /home/[user]/\n    path = re.sub(r\"/home/[^/]+/\", \"/home/[user]/\", path)\n    path = re.sub(r\"/Users/[^/]+/\", \"/Users/[user]/\", path)\n    # Replace /opt/username/ patterns\n    path = re.sub(r\"/opt/[^/]+/\", \"/opt/[user]/\", path)\n    return path\n\n\ndef _detect_docker_network_mode() -> str:\n    \"\"\"Detect Docker network mode by checking for host-level interfaces.\n\n    In host mode the container shares the host network namespace, so Docker\n    infrastructure interfaces (docker0, br-*, veth*) are visible.  In bridge\n    mode the container is isolated and only sees its own veth (named eth0).\n    \"\"\"\n    try:\n        import socket\n\n        for _idx, name in socket.if_nameindex():\n            if name.startswith((\"docker\", \"br-\", \"veth\", \"virbr\")):\n                return \"host\"\n    except Exception:\n        pass\n    return \"bridge\"\n\n\ndef _mask_subnet(subnet: str) -> str:\n    \"\"\"Mask the first two octets of a subnet string. e.g. '192.168.1.0/24' -> 'x.x.1.0/24'.\"\"\"\n    try:\n        parts = subnet.split(\".\")\n        if len(parts) >= 4:\n            parts[0] = \"x\"\n            parts[1] = \"x\"\n            return \".\".join(parts)\n    except Exception:\n        pass\n    return subnet\n\n\ndef _anonymize_mqtt_broker(broker: str) -> str:\n    \"\"\"Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain.\"\"\"\n    if not broker:\n        return \"\"\n    try:\n        ipaddress.ip_address(broker)\n        return \"[IP]\"\n    except ValueError:\n        # It's a hostname — show *.domain pattern\n        parts = broker.split(\".\")\n        if len(parts) >= 2:\n            return \"*.\" + \".\".join(parts[-2:])\n        return broker\n\n\nasync def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:\n    \"\"\"Test TCP connectivity to ip:port. Returns True if reachable.\"\"\"\n    try:\n        _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)\n        writer.close()\n        await writer.wait_closed()\n        return True\n    except Exception:\n        return False\n\n\ndef _get_container_memory_limit() -> int | None:\n    \"\"\"Read cgroup memory limit. Returns bytes or None.\"\"\"\n    # cgroup v2\n    v2 = Path(\"/sys/fs/cgroup/memory.max\")\n    if v2.exists():\n        try:\n            val = v2.read_text().strip()\n            if val != \"max\":\n                return int(val)\n        except Exception:\n            pass\n    # cgroup v1\n    v1 = Path(\"/sys/fs/cgroup/memory/memory.limit_in_bytes\")\n    if v1.exists():\n        try:\n            val = int(v1.read_text().strip())\n            # Values near page-aligned max (2^63-4096) mean unlimited\n            if val < 2**62:\n                return val\n        except Exception:\n            pass\n    return None\n\n\ndef _format_bytes(size_bytes: int) -> str:\n    \"\"\"Format bytes into human-readable string.\"\"\"\n    if size_bytes < 1024:\n        return f\"{size_bytes} B\"\n    if size_bytes < 1024 * 1024:\n        return f\"{size_bytes / 1024:.1f} KB\"\n    if size_bytes < 1024 * 1024 * 1024:\n        return f\"{size_bytes / (1024 * 1024):.1f} MB\"\n    return f\"{size_bytes / (1024 * 1024 * 1024):.2f} GB\"\n\n\nasync def _collect_support_info() -> dict:\n    \"\"\"Collect all support information.\"\"\"\n    in_docker = is_running_in_docker()\n\n    info = {\n        \"generated_at\": datetime.now().isoformat(),\n        \"app\": {\n            \"version\": APP_VERSION,\n            \"debug_mode\": settings.debug,\n        },\n        \"system\": {\n            \"platform\": platform.system(),\n            \"platform_release\": platform.release(),\n            \"platform_version\": platform.version(),\n            \"architecture\": platform.machine(),\n            \"python_version\": platform.python_version(),\n        },\n        \"environment\": {\n            \"docker\": in_docker,\n            \"data_dir\": _sanitize_path(str(settings.base_dir)),\n            \"log_dir\": _sanitize_path(str(settings.log_dir)),\n            \"timezone\": os.environ.get(\"TZ\", \"\"),\n        },\n        \"database\": {},\n        \"printers\": [],\n        \"settings\": {},\n    }\n\n    # Docker-specific info\n    if in_docker:\n        try:\n            mem_limit = _get_container_memory_limit()\n            info[\"docker\"] = {\n                \"container_memory_limit_bytes\": mem_limit,\n                \"container_memory_limit_formatted\": _format_bytes(mem_limit) if mem_limit else None,\n                \"network_mode_hint\": _detect_docker_network_mode(),\n            }\n        except Exception:\n            logger.debug(\"Failed to collect Docker info\", exc_info=True)\n\n    async with async_session() as db:\n        # Database stats\n        result = await db.execute(select(func.count(PrintArchive.id)))\n        info[\"database\"][\"archives_total\"] = result.scalar() or 0\n\n        result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == \"completed\"))\n        info[\"database\"][\"archives_completed\"] = result.scalar() or 0\n\n        result = await db.execute(select(func.count(Printer.id)))\n        info[\"database\"][\"printers_total\"] = result.scalar() or 0\n\n        result = await db.execute(select(func.count(Filament.id)))\n        info[\"database\"][\"filaments_total\"] = result.scalar() or 0\n\n        result = await db.execute(select(func.count(Project.id)))\n        info[\"database\"][\"projects_total\"] = result.scalar() or 0\n\n        result = await db.execute(select(func.count(SmartPlug.id)))\n        info[\"database\"][\"smart_plugs_total\"] = result.scalar() or 0\n\n        # Printer info (anonymized - no names, IPs, or serials)\n        result = await db.execute(select(Printer))\n        printers = result.scalars().all()\n        statuses = printer_manager.get_all_statuses()\n\n        # Check reachability in parallel\n        reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]\n        reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True)\n\n        for i, printer in enumerate(printers):\n            state = statuses.get(printer.id)\n            reachable = reachable_results[i] if not isinstance(reachable_results[i], Exception) else False\n\n            # Count AMS units and trays from raw_data\n            ams_unit_count = 0\n            ams_tray_count = 0\n            has_vt_tray = False\n            if state:\n                ams_data = state.raw_data.get(\"ams\")\n                if isinstance(ams_data, list):\n                    ams_units = ams_data\n                elif isinstance(ams_data, dict) and \"ams\" in ams_data:\n                    ams_units = ams_data[\"ams\"] if isinstance(ams_data[\"ams\"], list) else []\n                else:\n                    ams_units = []\n                ams_unit_count = len(ams_units)\n                for unit in ams_units:\n                    trays = unit.get(\"tray\", [])\n                    ams_tray_count += len([t for t in trays if t.get(\"tray_type\")])\n                has_vt_tray = bool(state.raw_data.get(\"vt_tray\"))\n\n            info[\"printers\"].append(\n                {\n                    \"index\": i + 1,\n                    \"model\": printer.model or \"Unknown\",\n                    \"nozzle_count\": printer.nozzle_count,\n                    \"is_active\": printer.is_active,\n                    \"mqtt_connected\": state.connected if state else False,\n                    \"state\": state.state if state else \"unknown\",\n                    \"firmware_version\": state.firmware_version if state else None,\n                    \"wifi_signal\": state.wifi_signal if state else None,\n                    \"reachable\": bool(reachable),\n                    \"ams_unit_count\": ams_unit_count,\n                    \"ams_tray_count\": ams_tray_count,\n                    \"has_vt_tray\": has_vt_tray,\n                    \"external_camera_configured\": bool(printer.external_camera_url),\n                    \"plate_detection_enabled\": printer.plate_detection_enabled,\n                    \"hms_error_count\": len(state.hms_errors) if state else 0,\n                    \"developer_mode\": state.developer_mode if state else None,\n                    \"nozzle_rack_count\": len(state.nozzle_rack) if state else 0,\n                }\n            )\n\n        # Virtual printers\n        try:\n            from backend.app.models.virtual_printer import VirtualPrinter\n            from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager\n\n            result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.id))\n            vps = result.scalars().all()\n            info[\"virtual_printers\"] = []\n            for vp in vps:\n                instance = virtual_printer_manager.get_instance(vp.id)\n                status = instance.get_status() if instance else None\n                model_code = vp.model or \"C12\"\n                info[\"virtual_printers\"].append(\n                    {\n                        \"index\": vp.id,\n                        \"enabled\": vp.enabled,\n                        \"mode\": vp.mode,\n                        \"model\": model_code,\n                        \"model_name\": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),\n                        \"has_target_printer\": vp.target_printer_id is not None,\n                        \"has_bind_ip\": bool(vp.bind_ip),\n                        \"running\": status.get(\"running\", False) if status else False,\n                        \"pending_files\": status.get(\"pending_files\", 0) if status else 0,\n                    }\n                )\n        except Exception:\n            logger.debug(\"Failed to collect virtual printer info\", exc_info=True)\n\n        # All settings — sensitive values are redacted rather than dropped so\n        # new settings automatically show up in support bundles without a code\n        # change. The value is replaced with \"[REDACTED]\" but the key is kept\n        # so we can still see which integrations are configured.\n        result = await db.execute(select(Settings))\n        all_settings = result.scalars().all()\n        sensitive_keys = {\n            \"access_code\",\n            \"password\",\n            \"token\",\n            \"secret\",\n            \"api_key\",\n            \"installation_id\",\n            \"cloud_token\",\n            \"mqtt_password\",\n            \"email\",\n            \"username\",\n            \"vapid\",\n            \"private_key\",\n            \"public_key\",\n            \"webhook\",\n            \"url\",\n            \"path\",  # Filesystem paths may contain usernames\n            \"config\",  # URLs may contain IPs, configs may have embedded secrets\n            \"_ip\",  # IP address fields (e.g. virtual_printer_remote_interface_ip)\n            \"host\",\n            \"credential\",\n        }\n        for s in all_settings:\n            key_lower = s.key.lower()\n            if any(sensitive in key_lower for sensitive in sensitive_keys):\n                # Preserve shape: mark presence without leaking the value\n                info[\"settings\"][s.key] = \"[REDACTED]\" if s.value else \"\"\n            else:\n                info[\"settings\"][s.key] = s.value\n\n        # Notification providers (anonymized — type/enabled/error status only)\n        try:\n            result = await db.execute(select(NotificationProvider))\n            providers = result.scalars().all()\n            info[\"integrations\"] = info.get(\"integrations\", {})\n            info[\"integrations\"][\"notification_providers\"] = [\n                {\n                    \"type\": p.provider_type,\n                    \"enabled\": p.enabled,\n                    \"has_last_error\": bool(p.last_error),\n                }\n                for p in providers\n            ]\n        except Exception:\n            logger.debug(\"Failed to collect notification provider info\", exc_info=True)\n\n        # Database health\n        try:\n            from backend.app.core.db_dialect import is_sqlite\n\n            if is_sqlite():\n                result = await db.execute(text(\"PRAGMA journal_mode\"))\n                journal_mode = result.scalar()\n                result = await db.execute(text(\"PRAGMA quick_check\"))\n                quick_check = result.scalar()\n\n                db_path = settings.base_dir / \"bambuddy.db\"\n                db_size = db_path.stat().st_size if db_path.exists() else 0\n                wal_path = settings.base_dir / \"bambuddy.db-wal\"\n                wal_size = wal_path.stat().st_size if wal_path.exists() else 0\n\n                info[\"database_health\"] = {\n                    \"backend\": \"sqlite\",\n                    \"journal_mode\": journal_mode,\n                    \"quick_check\": quick_check,\n                    \"db_size_bytes\": db_size,\n                    \"wal_size_bytes\": wal_size,\n                }\n            else:\n                result = await db.execute(text(\"SELECT version()\"))\n                pg_version = result.scalar()\n                result = await db.execute(text(\"SELECT pg_database_size(current_database())\"))\n                db_size = result.scalar() or 0\n\n                info[\"database_health\"] = {\n                    \"backend\": \"postgresql\",\n                    \"version\": pg_version,\n                    \"db_size_bytes\": db_size,\n                }\n        except Exception:\n            logger.debug(\"Failed to collect database health info\", exc_info=True)\n\n    # Integrations (lazy imports to avoid circular dependencies)\n    info.setdefault(\"integrations\", {})\n\n    # Spoolman\n    try:\n        from backend.app.services.spoolman import get_spoolman_client\n\n        client = await get_spoolman_client()\n        if client:\n            reachable = await client.health_check()\n            info[\"integrations\"][\"spoolman\"] = {\"enabled\": True, \"reachable\": reachable}\n        else:\n            info[\"integrations\"][\"spoolman\"] = {\"enabled\": False, \"reachable\": False}\n    except Exception:\n        logger.debug(\"Failed to collect Spoolman info\", exc_info=True)\n\n    # MQTT relay\n    try:\n        from backend.app.services.mqtt_relay import mqtt_relay\n\n        status = mqtt_relay.get_status()\n        info[\"integrations\"][\"mqtt_relay\"] = {\n            \"enabled\": status.get(\"enabled\", False),\n            \"connected\": status.get(\"connected\", False),\n            \"broker\": _anonymize_mqtt_broker(status.get(\"broker\", \"\")),\n            \"port\": status.get(\"port\", 0),\n            \"topic_prefix\": status.get(\"topic_prefix\", \"\"),\n        }\n    except Exception:\n        logger.debug(\"Failed to collect MQTT relay info\", exc_info=True)\n\n    # SpoolBuddy devices (anonymized — no hostnames, IPs or device IDs)\n    try:\n        async with async_session() as db:\n            from backend.app.models.spoolbuddy_device import SpoolBuddyDevice\n\n            result = await db.execute(select(SpoolBuddyDevice))\n            devices = result.scalars().all()\n            info[\"integrations\"][\"spoolbuddy\"] = {\n                \"device_count\": len(devices),\n                \"online_count\": sum(\n                    1\n                    for d in devices\n                    if d.last_seen\n                    and (datetime.now(tz=timezone.utc) - d.last_seen.replace(tzinfo=timezone.utc)).total_seconds() < 30\n                ),\n                \"devices\": [\n                    {\n                        \"index\": i + 1,\n                        \"firmware_version\": d.firmware_version,\n                        \"has_nfc\": d.has_nfc,\n                        \"has_scale\": d.has_scale,\n                        \"nfc_reader_type\": d.nfc_reader_type,\n                        \"nfc_connection\": d.nfc_connection,\n                        \"has_backlight\": d.has_backlight,\n                        \"nfc_ok\": d.nfc_ok,\n                        \"scale_ok\": d.scale_ok,\n                        \"uptime_s\": d.uptime_s,\n                        \"calibration_factor\": d.calibration_factor,\n                        \"tare_offset\": d.tare_offset,\n                        \"last_calibrated_at\": d.last_calibrated_at.isoformat() if d.last_calibrated_at else None,\n                        \"update_status\": d.update_status,\n                    }\n                    for i, d in enumerate(devices)\n                ],\n            }\n    except Exception:\n        logger.debug(\"Failed to collect SpoolBuddy info\", exc_info=True)\n\n    # Home Assistant (check ha_enabled setting)\n    try:\n        info[\"integrations\"][\"homeassistant\"] = {\n            \"enabled\": info[\"settings\"].get(\"ha_enabled\", \"false\").lower() == \"true\",\n        }\n    except Exception:\n        logger.debug(\"Failed to collect Home Assistant info\", exc_info=True)\n\n    # Dependencies\n    try:\n        dep_packages = [\n            \"fastapi\",\n            \"uvicorn\",\n            \"pydantic\",\n            \"sqlalchemy\",\n            \"paho-mqtt\",\n            \"psutil\",\n            \"httpx\",\n            \"aiofiles\",\n            \"cryptography\",\n            \"opencv-python-headless\",\n            \"numpy\",\n        ]\n        info[\"dependencies\"] = {}\n        for pkg in dep_packages:\n            try:\n                info[\"dependencies\"][pkg] = importlib.metadata.version(pkg)\n            except importlib.metadata.PackageNotFoundError:\n                info[\"dependencies\"][pkg] = None\n    except Exception:\n        logger.debug(\"Failed to collect dependency info\", exc_info=True)\n\n    # Log file info\n    try:\n        log_file = settings.log_dir / \"bambuddy.log\"\n        if log_file.exists():\n            size = log_file.stat().st_size\n            info[\"log_file\"] = {\n                \"size_bytes\": size,\n                \"size_formatted\": _format_bytes(size),\n            }\n        else:\n            info[\"log_file\"] = {\"size_bytes\": 0, \"size_formatted\": \"0 B\"}\n    except Exception:\n        logger.debug(\"Failed to collect log file info\", exc_info=True)\n\n    # Network interfaces (subnets with first two octets masked)\n    try:\n        interfaces = get_network_interfaces()\n        info[\"network\"] = {\n            \"interface_count\": len(interfaces),\n            \"interfaces\": [{\"name\": iface[\"name\"], \"subnet\": _mask_subnet(iface[\"subnet\"])} for iface in interfaces],\n        }\n    except Exception:\n        logger.debug(\"Failed to collect network info\", exc_info=True)\n\n    # WebSocket connections\n    try:\n        info[\"websockets\"] = {\n            \"active_connections\": len(ws_manager.active_connections),\n        }\n    except Exception:\n        logger.debug(\"Failed to collect WebSocket info\", exc_info=True)\n\n    return info\n\n\ndef _sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:\n    \"\"\"Remove sensitive data from log content.\"\"\"\n    # First, replace known sensitive values (database-aware exact matching)\n    # This catches printer names, usernames, and other arbitrary user-chosen strings\n    # that regex patterns cannot detect\n    if sensitive_strings:\n        # Sort by length descending to avoid partial matches (e.g. \"My Printer 1\" before \"My Printer\")\n        for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):\n            if len(value) < 3:\n                continue  # Skip very short strings to prevent over-redaction\n            content = re.sub(re.escape(value), label, content)\n\n    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)\n    content = re.sub(r\"((?:https?|rtsps?)://)[^/:@\\s]+:[^/@\\s]+@\", r\"\\1[CREDENTIALS]@\", content)\n\n    # Replace email addresses\n    content = re.sub(r\"\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b\", \"[EMAIL]\", content)\n\n    # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)\n    content = re.sub(r\"\\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\\b\", \"[SERIAL]\", content, flags=re.IGNORECASE)\n\n    # Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros)\n    content = re.sub(\n        r\"\\b(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)\\b\",\n        \"[IP]\",\n        content,\n    )\n\n    # Replace paths with usernames\n    content = re.sub(r\"/home/[^/\\s]+/\", \"/home/[user]/\", content)\n    content = re.sub(r\"/Users/[^/\\s]+/\", \"/Users/[user]/\", content)\n    content = re.sub(r\"/opt/[^/\\s]+/\", \"/opt/[user]/\", content)\n\n    return content\n\n\ndef _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:\n    \"\"\"Get log file content, limited to max_bytes from the end.\"\"\"\n    log_file = settings.log_dir / \"bambuddy.log\"\n    if not log_file.exists():\n        return b\"Log file not found\"\n\n    file_size = log_file.stat().st_size\n    if file_size <= max_bytes:\n        content = log_file.read_text(encoding=\"utf-8\", errors=\"replace\")\n    else:\n        # Read last max_bytes\n        with open(log_file, \"rb\") as f:\n            f.seek(file_size - max_bytes)\n            # Skip partial line at start\n            f.readline()\n            content = f.read().decode(\"utf-8\", errors=\"replace\")\n\n    # Sanitize sensitive data\n    content = _sanitize_log_content(content, sensitive_strings)\n    return content.encode(\"utf-8\")\n\n\nasync def _get_recent_sanitized_logs(max_lines: int = 200) -> str:\n    \"\"\"Get recent log lines, sanitized for inclusion in bug reports.\"\"\"\n    # Collect sensitive strings from DB for redaction\n    sensitive_strings: dict[str, str] = {}\n    async with async_session() as db:\n        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))\n        for name, serial, ip_address, access_code in result.all():\n            if name:\n                sensitive_strings[name] = \"[PRINTER]\"\n            if serial:\n                sensitive_strings[serial] = \"[SERIAL]\"\n            if ip_address:\n                sensitive_strings[ip_address] = \"[IP]\"\n            if access_code:\n                sensitive_strings[access_code] = \"[ACCESS_CODE]\"\n\n        result = await db.execute(select(User.username))\n        for (username,) in result.all():\n            if username:\n                sensitive_strings[username] = \"[USER]\"\n\n        result = await db.execute(select(Settings.value).where(Settings.key == \"bambu_cloud_email\"))\n        cloud_email = result.scalar_one_or_none()\n        if cloud_email:\n            sensitive_strings[cloud_email] = \"[EMAIL]\"\n\n    log_file = settings.log_dir / \"bambuddy.log\"\n    if not log_file.exists():\n        return \"\"\n\n    # Read last portion of log file\n    try:\n        content = log_file.read_text(encoding=\"utf-8\", errors=\"replace\")\n        lines = content.splitlines()\n        recent = \"\\n\".join(lines[-max_lines:])\n        return _sanitize_log_content(recent, sensitive_strings)\n    except Exception:\n        logger.debug(\"Failed to read logs for bug report\", exc_info=True)\n        return \"\"\n\n\n@router.get(\"/bundle\")\nasync def generate_support_bundle(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Generate a support bundle ZIP file for issue reporting.\"\"\"\n    # Check if debug logging is enabled and collect sensitive values for redaction\n    async with async_session() as db:\n        enabled, _enabled_at = await _get_debug_setting(db)\n\n        if not enabled:\n            raise HTTPException(\n                status_code=400,\n                detail=\"Debug logging must be enabled before generating a support bundle. \"\n                \"Please enable debug logging, reproduce the issue, then generate the bundle.\",\n            )\n\n        # Collect known sensitive values for log redaction\n        sensitive_strings: dict[str, str] = {}\n\n        # Printer names, serial numbers, IP addresses, and access codes\n        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))\n        for name, serial, ip_address, access_code in result.all():\n            if name:\n                sensitive_strings[name] = \"[PRINTER]\"\n            if serial:\n                sensitive_strings[serial] = \"[SERIAL]\"\n            if ip_address:\n                sensitive_strings[ip_address] = \"[IP]\"\n            if access_code:\n                sensitive_strings[access_code] = \"[ACCESS_CODE]\"\n\n        # Auth usernames\n        result = await db.execute(select(User.username))\n        for (username,) in result.all():\n            if username:\n                sensitive_strings[username] = \"[USER]\"\n\n        # Bambu Cloud email\n        result = await db.execute(select(Settings.value).where(Settings.key == \"bambu_cloud_email\"))\n        cloud_email = result.scalar_one_or_none()\n        if cloud_email:\n            sensitive_strings[cloud_email] = \"[EMAIL]\"\n\n    # Collect support info\n    support_info = await _collect_support_info()\n\n    # Create ZIP in memory\n    zip_buffer = io.BytesIO()\n    timestamp = datetime.now().strftime(\"%Y%m%d-%H%M%S\")\n\n    with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        # Add support info JSON\n        zf.writestr(\"support-info.json\", json.dumps(support_info, indent=2, default=str))\n\n        # Add log file\n        log_content = _get_log_content(sensitive_strings=sensitive_strings)\n        zf.writestr(\"bambuddy.log\", log_content)\n\n    zip_buffer.seek(0)\n\n    filename = f\"bambuddy-support-{timestamp}.zip\"\n    logger.info(\"Generated support bundle: %s\", filename)\n\n    return StreamingResponse(\n        zip_buffer, media_type=\"application/zip\", headers={\"Content-Disposition\": f\"attachment; filename={filename}\"}\n    )\n\n\nasync def init_debug_logging():\n    \"\"\"Initialize debug logging state from database on startup.\"\"\"\n    try:\n        async with async_session() as db:\n            enabled, _ = await _get_debug_setting(db)\n\n            if enabled:\n                _apply_log_level(True)\n                logger.info(\"Debug logging restored from previous session\")\n    except Exception as e:\n        logger.warning(\"Could not restore debug logging state: %s\", e)\n"
  },
  {
    "path": "backend/app/api/routes/system.py",
    "content": "\"\"\"System information API routes.\"\"\"\n\nimport asyncio\nimport os\nimport platform\nimport time\nfrom collections.abc import Callable\nfrom datetime import datetime\nfrom pathlib import Path\n\nimport psutil\nfrom fastapi import APIRouter, Depends\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.config import APP_VERSION, settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.filament import Filament\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.project import Project\nfrom backend.app.models.smart_plug import SmartPlug\nfrom backend.app.models.user import User\nfrom backend.app.services.printer_manager import printer_manager\n\nrouter = APIRouter(prefix=\"/system\", tags=[\"system\"])\n\nSTORAGE_USAGE_CACHE_SECONDS = 300\n_storage_usage_cache: dict | None = None\n_storage_usage_cache_ts: float | None = None\n_storage_usage_lock = asyncio.Lock()\n\n\ndef get_directory_size(path: Path) -> int:\n    \"\"\"Calculate total size of a directory in bytes.\"\"\"\n    total = 0\n    try:\n        for entry in path.rglob(\"*\"):\n            if entry.is_file():\n                total += entry.stat().st_size\n    except (PermissionError, OSError):\n        pass  # Return partial total if directory traversal is interrupted\n    return total\n\n\ndef format_bytes(bytes_value: int) -> str:\n    \"\"\"Format bytes to human-readable string.\"\"\"\n    for unit in [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"]:\n        if bytes_value < 1024:\n            return f\"{bytes_value:.1f} {unit}\"\n        bytes_value /= 1024\n    return f\"{bytes_value:.1f} PB\"\n\n\ndef format_uptime(seconds: float) -> str:\n    \"\"\"Format uptime in seconds to human-readable string.\"\"\"\n    days = int(seconds // 86400)\n    hours = int((seconds % 86400) // 3600)\n    minutes = int((seconds % 3600) // 60)\n\n    parts = []\n    if days > 0:\n        parts.append(f\"{days}d\")\n    if hours > 0:\n        parts.append(f\"{hours}h\")\n    if minutes > 0:\n        parts.append(f\"{minutes}m\")\n\n    return \" \".join(parts) if parts else \"< 1m\"\n\n\ndef _is_under(path: Path, root: Path) -> bool:\n    try:\n        path.resolve().relative_to(root.resolve())\n        return True\n    except ValueError:\n        return False\n\n\ndef _get_database_paths() -> list[Path]:\n    from backend.app.core.db_dialect import is_sqlite\n\n    if not is_sqlite():\n        return []  # PostgreSQL — no local DB files\n    candidates = [settings.base_dir / \"bambuddy.db\", settings.base_dir / \"bambutrack.db\"]\n    return [path for path in candidates if path.exists()]\n\n\ndef _get_database_items() -> list[dict]:\n    items: list[dict] = []\n    for path in _get_database_paths():\n        try:\n            size = path.stat().st_size\n        except OSError:\n            continue\n        items.append(\n            {\n                \"name\": path.name,\n                \"path\": str(path),\n                \"bytes\": size,\n                \"formatted\": format_bytes(size),\n            }\n        )\n    items.sort(key=lambda item: item[\"bytes\"], reverse=True)\n    return items\n\n\ndef _get_app_dir() -> Path:\n    return settings.static_dir.parent\n\n\ndef _get_data_dirs() -> list[Path]:\n    return [\n        settings.archive_dir,\n        settings.log_dir,\n        settings.plate_calibration_dir,\n        settings.base_dir / \"virtual_printer\",\n        settings.base_dir / \"firmware\",\n    ]\n\n\ndef _is_system_path(path: Path) -> bool:\n    app_dir = _get_app_dir()\n    if not _is_under(path, app_dir):\n        return False\n    return all(not _is_under(path, data_dir) for data_dir in _get_data_dirs())\n\n\ndef _get_storage_rules() -> list[tuple[str, str, Callable]]:\n    base_dir = settings.base_dir\n    archive_dir = settings.archive_dir\n    library_dir = archive_dir / \"library\"\n    virtual_printer_dir = base_dir / \"virtual_printer\"\n    upload_dir = virtual_printer_dir / \"uploads\"\n\n    db_paths = set(_get_database_paths())\n\n    return [\n        (\n            \"database\",\n            \"Database\",\n            lambda path: path in db_paths,\n        ),\n        (\n            \"library_thumbnails\",\n            \"Library Thumbnails\",\n            lambda path: _is_under(path, library_dir / \"thumbnails\"),\n        ),\n        (\n            \"library_files\",\n            \"Library Files\",\n            lambda path: _is_under(path, library_dir / \"files\"),\n        ),\n        (\n            \"library_other\",\n            \"Library Other\",\n            lambda path: _is_under(path, library_dir),\n        ),\n        (\n            \"archive_timelapses\",\n            \"Timelapses\",\n            lambda path: _is_under(path, archive_dir) and \"timelapse\" in path.name.lower(),\n        ),\n        (\n            \"archive_thumbnails\",\n            \"Thumbnails\",\n            lambda path: _is_under(path, archive_dir) and path.name.lower().startswith(\"thumbnail\"),\n        ),\n        (\n            \"archive_files\",\n            \"Archives\",\n            lambda path: _is_under(path, archive_dir),\n        ),\n        (\n            \"virtual_printer_upload_cache\",\n            \"Virtual Printer Upload Cache\",\n            lambda path: _is_under(path, upload_dir / \"cache\"),\n        ),\n        (\n            \"virtual_printer_uploads\",\n            \"Virtual Printer Uploads\",\n            lambda path: _is_under(path, upload_dir),\n        ),\n        (\n            \"virtual_printer_certs\",\n            \"Virtual Printer Certs\",\n            lambda path: _is_under(path, virtual_printer_dir / \"certs\"),\n        ),\n        (\n            \"virtual_printer_other\",\n            \"Virtual Printer Other\",\n            lambda path: _is_under(path, virtual_printer_dir),\n        ),\n        (\n            \"downloads\",\n            \"Downloads\",\n            lambda path: _is_under(path, base_dir / \"firmware\"),\n        ),\n        (\n            \"plate_calibration\",\n            \"Plate Calibration\",\n            lambda path: _is_under(path, settings.plate_calibration_dir),\n        ),\n        (\n            \"logs\",\n            \"Logs\",\n            lambda path: _is_under(path, settings.log_dir),\n        ),\n    ]\n\n\ndef _classify_file(path: Path, rules: list[tuple[str, str, Callable]]) -> tuple[str, str]:\n    for key, label, matcher in rules:\n        try:\n            if matcher(path):\n                return key, label\n        except OSError:\n            continue\n    return \"other_data\", \"Other\"\n\n\ndef _format_percentage(part: int, total: int) -> float:\n    if total <= 0:\n        return 0.0\n    return round((part / total) * 100, 2)\n\n\ndef _get_other_bucket(path: Path, base_dir: Path) -> str:\n    try:\n        relative = path.resolve().relative_to(base_dir.resolve())\n    except ValueError:\n        return path.parent.name or path.name\n\n    parts = relative.parts\n    return parts[0] if parts else path.name\n\n\ndef _walk_files(roots: list[Path]) -> list[Path]:\n    files: list[Path] = []\n    stack = [root for root in roots if root.exists()]\n    while stack:\n        current = stack.pop()\n        try:\n            with os.scandir(current) as entries:\n                for entry in entries:\n                    try:\n                        if entry.is_symlink():\n                            continue\n                        if entry.is_dir(follow_symlinks=False):\n                            stack.append(Path(entry.path))\n                        elif entry.is_file(follow_symlinks=False):\n                            files.append(Path(entry.path))\n                    except OSError:\n                        continue\n        except OSError:\n            continue\n    return files\n\n\ndef _scan_storage_usage() -> dict:\n    base_dir = settings.base_dir\n    rules = _get_storage_rules()\n\n    roots = _get_data_dirs()\n\n    seen_roots = set()\n    unique_roots = []\n    for root in roots:\n        resolved = root.resolve()\n        if resolved not in seen_roots:\n            seen_roots.add(resolved)\n            unique_roots.append(root)\n\n    total_bytes = 0\n    error_count = 0\n    category_sizes: dict[str, dict] = {}\n    other_breakdown: dict[tuple[str, str], int] = {}\n    database_items = _get_database_items()\n\n    files = _walk_files(unique_roots)\n    for file_path in files:\n        try:\n            size = file_path.stat().st_size\n        except OSError:\n            error_count += 1\n            continue\n\n        total_bytes += size\n\n        key, label = _classify_file(file_path, rules)\n        if key not in category_sizes:\n            category_sizes[key] = {\"key\": key, \"label\": label, \"bytes\": 0}\n        category_sizes[key][\"bytes\"] += size\n\n        if key == \"other_data\":\n            bucket = _get_other_bucket(file_path, base_dir)\n            kind = \"system\" if _is_system_path(file_path) else \"data\"\n            other_breakdown[(bucket, kind)] = other_breakdown.get((bucket, kind), 0) + size\n\n    for item in database_items:\n        total_bytes += item[\"bytes\"]\n        key = \"database\"\n        label = \"Database\"\n        if key not in category_sizes:\n            category_sizes[key] = {\"key\": key, \"label\": label, \"bytes\": 0}\n        category_sizes[key][\"bytes\"] += item[\"bytes\"]\n\n    categories = []\n    for item in category_sizes.values():\n        bytes_value = item[\"bytes\"]\n        categories.append(\n            {\n                \"key\": item[\"key\"],\n                \"label\": item[\"label\"],\n                \"bytes\": bytes_value,\n                \"formatted\": format_bytes(bytes_value),\n                \"percent_of_total\": _format_percentage(bytes_value, total_bytes),\n            }\n        )\n\n    categories.sort(key=lambda entry: entry[\"bytes\"], reverse=True)\n\n    other_items = []\n    for (bucket, kind), size in other_breakdown.items():\n        other_items.append(\n            {\n                \"bucket\": bucket,\n                \"label\": bucket,\n                \"kind\": kind,\n                \"deletable\": kind != \"system\",\n                \"bytes\": size,\n                \"formatted\": format_bytes(size),\n                \"percent_of_total\": _format_percentage(size, total_bytes),\n            }\n        )\n\n    other_items.sort(key=lambda entry: entry[\"bytes\"], reverse=True)\n\n    return {\n        \"roots\": [str(root) for root in unique_roots],\n        \"total_bytes\": total_bytes,\n        \"total_formatted\": format_bytes(total_bytes),\n        \"categories\": categories,\n        \"other_breakdown\": other_items,\n        \"scan_errors\": error_count,\n    }\n\n\nasync def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict:\n    global _storage_usage_cache\n    global _storage_usage_cache_ts\n\n    now = time.time()\n    if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None:\n        age = now - _storage_usage_cache_ts\n        if age < max_age_seconds:\n            return {\n                **_storage_usage_cache,\n                \"cache\": {\n                    \"hit\": True,\n                    \"age_seconds\": round(age, 2),\n                    \"max_age_seconds\": max_age_seconds,\n                },\n            }\n\n    async with _storage_usage_lock:\n        now = time.time()\n        if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None:\n            age = now - _storage_usage_cache_ts\n            if age < max_age_seconds:\n                return {\n                    **_storage_usage_cache,\n                    \"cache\": {\n                        \"hit\": True,\n                        \"age_seconds\": round(age, 2),\n                        \"max_age_seconds\": max_age_seconds,\n                    },\n                }\n\n        snapshot = await asyncio.to_thread(_scan_storage_usage)\n        _storage_usage_cache = {\n            **snapshot,\n            \"generated_at\": datetime.now().isoformat(),\n        }\n        _storage_usage_cache_ts = time.time()\n        return {\n            **_storage_usage_cache,\n            \"cache\": {\n                \"hit\": False,\n                \"age_seconds\": 0,\n                \"max_age_seconds\": max_age_seconds,\n            },\n        }\n\n\n@router.get(\"/info\")\nasync def get_system_info(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),\n):\n    \"\"\"Get comprehensive system information.\"\"\"\n\n    # Database stats\n    archive_count = await db.scalar(select(func.count(PrintArchive.id)))\n    printer_count = await db.scalar(select(func.count(Printer.id)))\n    filament_count = await db.scalar(select(func.count(Filament.id)))\n    project_count = await db.scalar(select(func.count(Project.id)))\n    smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))\n\n    # Archive stats by status\n    completed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == \"completed\"))\n    failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == \"failed\"))\n    printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == \"printing\"))\n\n    # Total print time\n    total_print_time = (\n        await db.scalar(\n            select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.print_time_seconds.isnot(None))\n        )\n        or 0\n    )\n\n    # Total filament used\n    total_filament = (\n        await db.scalar(\n            select(func.sum(PrintArchive.filament_used_grams)).where(PrintArchive.filament_used_grams.isnot(None))\n        )\n        or 0\n    )\n\n    # Connected printers\n    connected_printers = []\n    for printer_id, client in printer_manager._clients.items():\n        state = client.state\n        if state and state.connected:\n            # Get printer name and model from database\n            result = await db.execute(select(Printer.name, Printer.model).where(Printer.id == printer_id))\n            row = result.first()\n            name = row[0] if row else f\"Printer {printer_id}\"\n            model = row[1] if row else \"unknown\"\n            connected_printers.append(\n                {\n                    \"id\": printer_id,\n                    \"name\": name,\n                    \"state\": state.state,\n                    \"model\": model,\n                }\n            )\n\n    # Storage info\n    archive_dir = settings.archive_dir\n    archive_size = get_directory_size(archive_dir) if archive_dir.exists() else 0\n\n    # Database info (engine type, version, size)\n    from backend.app.core.db_dialect import is_postgres, is_sqlite\n\n    db_engine_info: dict = {\"engine\": \"unknown\", \"version\": \"unknown\"}\n    db_size = 0\n    try:\n        if is_postgres():\n            from sqlalchemy import text\n\n            result = await db.execute(text(\"SELECT version()\"))\n            pg_version_full = result.scalar() or \"unknown\"\n            # e.g. \"PostgreSQL 16.2 on x86_64...\" → \"PostgreSQL 16.2\"\n            pg_version = \" \".join(pg_version_full.split()[:2])\n            result = await db.execute(text(\"SELECT pg_database_size(current_database())\"))\n            db_size = result.scalar() or 0\n            db_engine_info = {\n                \"engine\": \"PostgreSQL\",\n                \"version\": pg_version,\n            }\n        elif is_sqlite():\n            from sqlalchemy import text\n\n            result = await db.execute(text(\"SELECT sqlite_version()\"))\n            sqlite_ver = result.scalar() or \"unknown\"\n            db_path = settings.base_dir / \"bambuddy.db\"\n            db_size = db_path.stat().st_size if db_path.exists() else 0\n            db_engine_info = {\n                \"engine\": \"SQLite\",\n                \"version\": f\"SQLite {sqlite_ver}\",\n            }\n    except Exception:\n        pass\n\n    # Disk usage\n    disk = psutil.disk_usage(str(settings.base_dir))\n\n    # System info\n    memory = psutil.virtual_memory()\n    boot_time = datetime.fromtimestamp(psutil.boot_time())\n    uptime_seconds = (datetime.now() - boot_time).total_seconds()\n\n    # Python and system info\n    import sys\n\n    return {\n        \"app\": {\n            \"version\": APP_VERSION,\n            \"base_dir\": str(settings.base_dir),\n            \"archive_dir\": str(archive_dir),\n        },\n        \"database\": {\n            \"engine\": db_engine_info[\"engine\"],\n            \"version\": db_engine_info[\"version\"],\n            \"archives\": archive_count,\n            \"archives_completed\": completed_count,\n            \"archives_failed\": failed_count,\n            \"archives_printing\": printing_count,\n            \"printers\": printer_count,\n            \"filaments\": filament_count,\n            \"projects\": project_count,\n            \"smart_plugs\": smart_plug_count,\n            \"total_print_time_seconds\": total_print_time,\n            \"total_print_time_formatted\": format_uptime(total_print_time),\n            \"total_filament_grams\": round(total_filament, 1),\n            \"total_filament_kg\": round(total_filament / 1000, 2),\n        },\n        \"printers\": {\n            \"total\": printer_count,\n            \"connected\": len(connected_printers),\n            \"connected_list\": connected_printers,\n        },\n        \"storage\": {\n            \"archive_size_bytes\": archive_size,\n            \"archive_size_formatted\": format_bytes(archive_size),\n            \"database_size_bytes\": db_size,\n            \"database_size_formatted\": format_bytes(db_size),\n            \"disk_total_bytes\": disk.total,\n            \"disk_total_formatted\": format_bytes(disk.total),\n            \"disk_used_bytes\": disk.used,\n            \"disk_used_formatted\": format_bytes(disk.used),\n            \"disk_free_bytes\": disk.free,\n            \"disk_free_formatted\": format_bytes(disk.free),\n            \"disk_percent_used\": disk.percent,\n        },\n        \"system\": {\n            \"platform\": platform.system(),\n            \"platform_release\": platform.release(),\n            \"platform_version\": platform.version(),\n            \"architecture\": platform.machine(),\n            \"hostname\": platform.node(),\n            \"python_version\": sys.version.split()[0],\n            \"uptime_seconds\": uptime_seconds,\n            \"uptime_formatted\": format_uptime(uptime_seconds),\n            \"boot_time\": boot_time.isoformat(),\n        },\n        \"memory\": {\n            \"total_bytes\": memory.total,\n            \"total_formatted\": format_bytes(memory.total),\n            \"available_bytes\": memory.available,\n            \"available_formatted\": format_bytes(memory.available),\n            \"used_bytes\": memory.used,\n            \"used_formatted\": format_bytes(memory.used),\n            \"percent_used\": memory.percent,\n        },\n        \"cpu\": {\n            \"count\": psutil.cpu_count(),\n            \"count_logical\": psutil.cpu_count(logical=True),\n            \"percent\": psutil.cpu_percent(interval=0.1),\n        },\n    }\n\n\n@router.get(\"/storage-usage\")\nasync def get_storage_usage(\n    refresh: bool = False,\n    max_age_seconds: int = STORAGE_USAGE_CACHE_SECONDS,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),\n):\n    \"\"\"Get storage usage breakdown for Bambuddy data directories.\"\"\"\n    max_age_seconds = max(0, min(max_age_seconds, 3600))\n    return await _get_storage_usage_cached(refresh=refresh, max_age_seconds=max_age_seconds)\n"
  },
  {
    "path": "backend/app/api/routes/updates.py",
    "content": "\"\"\"Update checking and management routes.\"\"\"\n\nimport asyncio\nimport logging\nimport os\nimport re\nimport shutil\nimport sys\n\nimport httpx\nfrom fastapi import APIRouter, BackgroundTasks, Depends\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.config import APP_VERSION, GITHUB_REPO, settings\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.user import User\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/updates\", tags=[\"updates\"])\n\n# Global state for update progress\n_update_status = {\n    \"status\": \"idle\",  # idle, checking, downloading, installing, complete, error\n    \"progress\": 0,\n    \"message\": \"\",\n    \"error\": None,\n}\n\n\ndef _is_docker_environment() -> bool:\n    \"\"\"Detect if running inside a Docker container.\"\"\"\n    if os.path.exists(\"/.dockerenv\"):\n        return True\n    try:\n        with open(\"/proc/1/cgroup\") as f:\n            if \"docker\" in f.read():\n                return True\n    except (FileNotFoundError, PermissionError):\n        pass  # cgroup file unavailable; continue with other detection methods\n    # Check container runtime hint (systemd sets this for Docker/podman,\n    # but NOT for LXC/LXD — avoids false positives on Proxmox containers)\n    try:\n        with open(\"/run/systemd/container\") as f:\n            runtime = f.read().strip()\n            if runtime in (\"docker\", \"podman\", \"oci\"):\n                return True\n    except (FileNotFoundError, PermissionError):\n        pass\n    return False\n\n\ndef _find_executable(name: str) -> str | None:\n    \"\"\"Find an executable in PATH or common locations.\"\"\"\n    # Try standard PATH first\n    path = shutil.which(name)\n    if path:\n        return path\n\n    # Common locations for executables (useful when running as systemd service)\n    common_paths = [\n        f\"/usr/bin/{name}\",\n        f\"/usr/local/bin/{name}\",\n        f\"/opt/homebrew/bin/{name}\",\n        f\"/home/linuxbrew/.linuxbrew/bin/{name}\",\n        f\"{os.path.expanduser('~')}/.nvm/current/bin/{name}\",\n        f\"{os.path.expanduser('~')}/.local/bin/{name}\",\n    ]\n\n    for p in common_paths:\n        if os.path.isfile(p) and os.access(p, os.X_OK):\n            return p\n\n    return None\n\n\ndef parse_version(version: str) -> tuple:\n    \"\"\"Parse version string into tuple for comparison.\n\n    Returns (major, minor, patch, micro, is_prerelease, prerelease_num)\n    where is_prerelease is 0 for release, 1 for prerelease.\n    This ensures releases sort higher than prereleases of same version.\n\n    Examples:\n        \"0.1.5\"    -> (0, 1, 5, 0, 0, 0)   # release\n        \"0.1.5b7\"  -> (0, 1, 5, 0, 1, 7)   # beta 7\n        \"0.1.5b10\" -> (0, 1, 5, 0, 1, 10)  # beta 10\n        \"0.1.8.1\"  -> (0, 1, 8, 1, 0, 0)   # patch release\n    \"\"\"\n    # Remove 'v' prefix if present\n    version = version.lstrip(\"v\")\n\n    # Strip daily build suffix (e.g., \"0.2.2b4-daily.20260313\" -> \"0.2.2b4\")\n    version = re.sub(r\"-daily\\.\\d+$\", \"\", version)\n\n    # Match version pattern: major.minor.patch[.micro][b|beta|alpha|rc]N\n    match = re.match(r\"(\\d+)\\.(\\d+)\\.(\\d+)(?:\\.(\\d+))?(?:b|beta|alpha|rc)?(\\d+)?\", version)\n\n    if match:\n        major = int(match.group(1))\n        minor = int(match.group(2))\n        patch = int(match.group(3))\n        micro = int(match.group(4)) if match.group(4) else 0\n        prerelease_num = int(match.group(5)) if match.group(5) else 0\n\n        # Check if this is a prerelease (has b/beta/alpha/rc/daily suffix anywhere)\n        is_prerelease = 1 if re.search(r\"[a-zA-Z]\", version) else 0\n\n        return (major, minor, patch, micro, is_prerelease, prerelease_num)\n\n    # Fallback: try simple split\n    parts = []\n    for part in version.split(\".\"):\n        try:\n            parts.append(int(part))\n        except ValueError:\n            num = \"\".join(c for c in part if c.isdigit())\n            parts.append(int(num) if num else 0)\n\n    return tuple(parts) + (0, 0, 0)\n\n\ndef is_newer_version(latest: str, current: str) -> bool:\n    \"\"\"Check if latest version is newer than current.\n\n    Properly handles prerelease versions:\n    - 0.1.5 > 0.1.5b7 (release is newer than any beta)\n    - 0.1.5b8 > 0.1.5b7 (later beta is newer)\n    - 0.1.6b1 > 0.1.5 (next version beta is newer than current release)\n    \"\"\"\n    try:\n        latest_parsed = parse_version(latest)\n        current_parsed = parse_version(current)\n\n        # Compare (major, minor, patch, micro) first\n        latest_base = latest_parsed[:4]\n        current_base = current_parsed[:4]\n\n        if latest_base > current_base:\n            return True\n        elif latest_base < current_base:\n            return False\n\n        # Same base version - compare prerelease status\n        # is_prerelease: 0 = release, 1 = prerelease\n        # Release (0) should be \"greater\" than prerelease (1)\n        latest_is_prerelease = latest_parsed[4] if len(latest_parsed) > 4 else 0\n        current_is_prerelease = current_parsed[4] if len(current_parsed) > 4 else 0\n\n        if latest_is_prerelease < current_is_prerelease:\n            # latest is release, current is prerelease -> latest is newer\n            return True\n        elif latest_is_prerelease > current_is_prerelease:\n            # latest is prerelease, current is release -> latest is NOT newer\n            return False\n\n        # Both are same type (both release or both prerelease)\n        # Compare prerelease numbers\n        latest_prerelease_num = latest_parsed[5] if len(latest_parsed) > 5 else 0\n        current_prerelease_num = current_parsed[5] if len(current_parsed) > 5 else 0\n\n        return latest_prerelease_num > current_prerelease_num\n\n    except Exception:\n        return False\n\n\n@router.get(\"/version\")\nasync def get_version():\n    \"\"\"Get current application version.\n\n    Note: Unauthenticated - needed to display version in UI without login.\n    \"\"\"\n    return {\n        \"version\": APP_VERSION,\n        \"repo\": GITHUB_REPO,\n    }\n\n\n@router.get(\"/check\")\nasync def check_for_updates(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),\n):\n    \"\"\"Check GitHub for available updates.\"\"\"\n    global _update_status\n\n    # Respect the check_updates setting\n    result = await db.execute(select(Settings).where(Settings.key == \"check_updates\"))\n    setting = result.scalar_one_or_none()\n    if setting and setting.value.lower() == \"false\":\n        return {\n            \"update_available\": False,\n            \"current_version\": APP_VERSION,\n            \"latest_version\": None,\n            \"message\": \"Update checks are disabled\",\n        }\n\n    # Check if beta updates should be included\n    result = await db.execute(select(Settings).where(Settings.key == \"include_beta_updates\"))\n    beta_setting = result.scalar_one_or_none()\n    include_beta = beta_setting and beta_setting.value.lower() == \"true\"\n\n    _update_status = {\n        \"status\": \"checking\",\n        \"progress\": 0,\n        \"message\": \"Checking for updates...\",\n        \"error\": None,\n    }\n\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                f\"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20\",\n                headers={\"Accept\": \"application/vnd.github.v3+json\"},\n                timeout=10.0,\n            )\n\n            if response.status_code == 404:\n                # No releases yet\n                _update_status = {\n                    \"status\": \"idle\",\n                    \"progress\": 100,\n                    \"message\": \"No releases found\",\n                    \"error\": None,\n                }\n                return {\n                    \"update_available\": False,\n                    \"current_version\": APP_VERSION,\n                    \"latest_version\": None,\n                    \"message\": \"No releases found\",\n                }\n\n            response.raise_for_status()\n            releases = response.json()\n\n            # Find the appropriate release based on beta setting\n            release_data = None\n            for release in releases:\n                tag = release.get(\"tag_name\", \"\")\n                if include_beta:\n                    # Accept any release (first = newest)\n                    release_data = release\n                    break\n                else:\n                    # Skip prereleases (based on version parsing, not GitHub flag)\n                    parsed = parse_version(tag)\n                    if parsed[4] == 0:  # is_prerelease == 0\n                        release_data = release\n                        break\n\n            if not release_data:\n                _update_status = {\n                    \"status\": \"idle\",\n                    \"progress\": 100,\n                    \"message\": \"No releases found\",\n                    \"error\": None,\n                }\n                return {\n                    \"update_available\": False,\n                    \"current_version\": APP_VERSION,\n                    \"latest_version\": None,\n                    \"message\": \"No releases found\",\n                }\n\n            latest_version = release_data.get(\"tag_name\", \"\").lstrip(\"v\")\n            release_name = release_data.get(\"name\", latest_version)\n            release_notes = release_data.get(\"body\", \"\")\n            release_url = release_data.get(\"html_url\", \"\")\n            published_at = release_data.get(\"published_at\", \"\")\n\n            update_available = is_newer_version(latest_version, APP_VERSION)\n\n            _update_status = {\n                \"status\": \"idle\",\n                \"progress\": 100,\n                \"message\": \"Update available\" if update_available else \"Up to date\",\n                \"error\": None,\n            }\n\n            is_docker = _is_docker_environment()\n            return {\n                \"update_available\": update_available,\n                \"current_version\": APP_VERSION,\n                \"latest_version\": latest_version,\n                \"release_name\": release_name,\n                \"release_notes\": release_notes,\n                \"release_url\": release_url,\n                \"published_at\": published_at,\n                \"is_docker\": is_docker,\n                \"update_method\": \"docker\" if is_docker else \"git\",\n            }\n\n    except httpx.HTTPError as e:\n        logger.error(\"Failed to check for updates: %s\", e)\n        _update_status = {\n            \"status\": \"error\",\n            \"progress\": 0,\n            \"message\": \"Failed to check for updates\",\n            \"error\": \"Failed to check for updates\",\n        }\n        return {\n            \"update_available\": False,\n            \"current_version\": APP_VERSION,\n            \"latest_version\": None,\n            \"error\": \"Failed to check for updates\",\n        }\n\n\nasync def _perform_update():\n    \"\"\"Perform the actual update using git fetch and reset.\"\"\"\n    global _update_status\n\n    try:\n        base_dir = settings.base_dir\n\n        # Find git executable (may not be in PATH when running as systemd service)\n        git_path = _find_executable(\"git\")\n        if not git_path:\n            _update_status = {\n                \"status\": \"error\",\n                \"progress\": 0,\n                \"message\": \"Git not found\",\n                \"error\": \"Could not find git executable. Please ensure git is installed.\",\n            }\n            return\n\n        logger.info(\"Using git at: %s\", git_path)\n\n        # Git config to avoid safe.directory issues\n        git_config = [\"-c\", f\"safe.directory={base_dir}\"]\n\n        _update_status = {\n            \"status\": \"downloading\",\n            \"progress\": 10,\n            \"message\": \"Configuring git...\",\n            \"error\": None,\n        }\n\n        # Ensure remote uses HTTPS (SSH may not be available)\n        https_url = f\"https://github.com/{GITHUB_REPO}.git\"\n        process = await asyncio.create_subprocess_exec(\n            git_path,\n            *git_config,\n            \"remote\",\n            \"set-url\",\n            \"origin\",\n            https_url,\n            cwd=str(base_dir),\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        await process.communicate()\n\n        _update_status = {\n            \"status\": \"downloading\",\n            \"progress\": 20,\n            \"message\": \"Fetching latest changes...\",\n            \"error\": None,\n        }\n\n        # Fetch from origin\n        process = await asyncio.create_subprocess_exec(\n            git_path,\n            *git_config,\n            \"fetch\",\n            \"origin\",\n            \"main\",\n            cwd=str(base_dir),\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await process.communicate()\n\n        if process.returncode != 0:\n            error_msg = stderr.decode() if stderr else \"Git fetch failed\"\n            logger.error(\"Git fetch failed: %s\", error_msg)\n            _update_status = {\n                \"status\": \"error\",\n                \"progress\": 0,\n                \"message\": \"Failed to fetch updates\",\n                \"error\": error_msg,\n            }\n            return\n\n        _update_status = {\n            \"status\": \"downloading\",\n            \"progress\": 40,\n            \"message\": \"Applying updates...\",\n            \"error\": None,\n        }\n\n        # Hard reset to origin/main (clean update, no merge conflicts)\n        process = await asyncio.create_subprocess_exec(\n            git_path,\n            *git_config,\n            \"reset\",\n            \"--hard\",\n            \"origin/main\",\n            cwd=str(base_dir),\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await process.communicate()\n\n        if process.returncode != 0:\n            error_msg = stderr.decode() if stderr else \"Git reset failed\"\n            logger.error(\"Git reset failed: %s\", error_msg)\n            _update_status = {\n                \"status\": \"error\",\n                \"progress\": 0,\n                \"message\": \"Failed to apply updates\",\n                \"error\": error_msg,\n            }\n            return\n\n        _update_status = {\n            \"status\": \"installing\",\n            \"progress\": 50,\n            \"message\": \"Installing dependencies...\",\n            \"error\": None,\n        }\n\n        # Install Python dependencies\n        process = await asyncio.create_subprocess_exec(\n            sys.executable,\n            \"-m\",\n            \"pip\",\n            \"install\",\n            \"-r\",\n            \"requirements.txt\",\n            \"-q\",\n            cwd=str(base_dir),\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await process.communicate()\n\n        if process.returncode != 0:\n            logger.warning(\"pip install warning: %s\", stderr.decode() if stderr else \"unknown\")\n\n        # Try to build frontend if npm is available (optional - static files are pre-built)\n        npm_path = _find_executable(\"npm\")\n        frontend_dir = base_dir / \"frontend\"\n\n        if npm_path and frontend_dir.exists():\n            _update_status = {\n                \"status\": \"installing\",\n                \"progress\": 70,\n                \"message\": \"Building frontend...\",\n                \"error\": None,\n            }\n\n            # npm install\n            process = await asyncio.create_subprocess_exec(\n                npm_path,\n                \"install\",\n                cwd=str(frontend_dir),\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n            )\n            await process.communicate()\n\n            # npm run build\n            process = await asyncio.create_subprocess_exec(\n                npm_path,\n                \"run\",\n                \"build\",\n                cwd=str(frontend_dir),\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n            )\n            stdout, stderr = await process.communicate()\n\n            if process.returncode != 0:\n                logger.warning(\"Frontend build warning: %s\", stderr.decode() if stderr else \"unknown\")\n        else:\n            logger.info(\"npm not found or frontend dir missing - using pre-built static files\")\n\n        _update_status = {\n            \"status\": \"complete\",\n            \"progress\": 100,\n            \"message\": \"Update complete! Please restart the application.\",\n            \"error\": None,\n        }\n\n        logger.info(\"Update completed successfully\")\n\n    except Exception as e:\n        logger.error(\"Update failed: %s\", e)\n        _update_status = {\n            \"status\": \"error\",\n            \"progress\": 0,\n            \"message\": \"Update failed\",\n            \"error\": \"Update failed unexpectedly\",\n        }\n\n\n@router.post(\"/apply\")\nasync def apply_update(\n    background_tasks: BackgroundTasks,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Apply available update (git pull + rebuild).\"\"\"\n    global _update_status\n\n    if _update_status[\"status\"] in [\"downloading\", \"installing\"]:\n        return {\n            \"success\": False,\n            \"message\": \"Update already in progress\",\n            \"status\": _update_status,\n        }\n\n    # Check if running in Docker\n    if _is_docker_environment():\n        return {\n            \"success\": False,\n            \"is_docker\": True,\n            \"message\": (\n                \"Docker installations cannot be updated in-app. \"\n                \"Please update via Docker Compose: \"\n                \"git pull && docker compose build --pull && docker compose up -d\"\n            ),\n        }\n\n    # Start update in background\n    background_tasks.add_task(_perform_update)\n\n    _update_status = {\n        \"status\": \"downloading\",\n        \"progress\": 10,\n        \"message\": \"Starting update...\",\n        \"error\": None,\n    }\n\n    return {\n        \"success\": True,\n        \"message\": \"Update started\",\n        \"status\": _update_status,\n    }\n\n\n@router.get(\"/status\")\nasync def get_update_status(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),\n):\n    \"\"\"Get current update status.\"\"\"\n    return _update_status\n"
  },
  {
    "path": "backend/app/api/routes/user_notifications.py",
    "content": "\"\"\"API routes for user email notification preferences.\"\"\"\n\nimport logging\n\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.user import User\nfrom backend.app.models.user_email_pref import UserEmailPreference\nfrom backend.app.schemas.user_notifications import UserEmailPreferenceResponse, UserEmailPreferenceUpdate\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/user-notifications\", tags=[\"user-notifications\"])\n\n\n@router.get(\"/preferences\", response_model=UserEmailPreferenceResponse)\nasync def get_user_email_preferences(\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get the current user's email notification preferences.\n\n    Returns defaults (all enabled) if no preferences are saved yet.\n    \"\"\"\n    if current_user is None:\n        # Auth is disabled; no user context available, return defaults\n        return UserEmailPreferenceResponse(\n            notify_print_start=True,\n            notify_print_complete=True,\n            notify_print_failed=True,\n            notify_print_stopped=True,\n        )\n\n    result = await db.execute(select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id))\n    pref = result.scalar_one_or_none()\n\n    if pref is None:\n        # Return defaults\n        return UserEmailPreferenceResponse(\n            notify_print_start=True,\n            notify_print_complete=True,\n            notify_print_failed=True,\n            notify_print_stopped=True,\n        )\n\n    return pref\n\n\n@router.put(\"/preferences\", response_model=UserEmailPreferenceResponse)\nasync def update_user_email_preferences(\n    data: UserEmailPreferenceUpdate,\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Update the current user's email notification preferences.\"\"\"\n    if current_user is None:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Authentication must be enabled to save user notification preferences\",\n        )\n\n    if not current_user.email:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"User must have an email address to receive notifications\",\n        )\n\n    result = await db.execute(select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id))\n    pref = result.scalar_one_or_none()\n\n    if pref is None:\n        pref = UserEmailPreference(\n            user_id=current_user.id,\n            notify_print_start=data.notify_print_start,\n            notify_print_complete=data.notify_print_complete,\n            notify_print_failed=data.notify_print_failed,\n            notify_print_stopped=data.notify_print_stopped,\n        )\n        db.add(pref)\n    else:\n        pref.notify_print_start = data.notify_print_start\n        pref.notify_print_complete = data.notify_print_complete\n        pref.notify_print_failed = data.notify_print_failed\n        pref.notify_print_stopped = data.notify_print_stopped\n\n    await db.commit()\n    await db.refresh(pref)\n\n    logger.info(\n        \"Updated email notification preferences for user %s: start=%s, complete=%s, failed=%s, stopped=%s\",\n        current_user.username,\n        pref.notify_print_start,\n        pref.notify_print_complete,\n        pref.notify_print_failed,\n        pref.notify_print_stopped,\n    )\n\n    return pref\n"
  },
  {
    "path": "backend/app/api/routes/users.py",
    "content": "from datetime import datetime, timezone\nfrom typing import Annotated\n\nimport jwt as _jwt\nfrom fastapi import APIRouter, Depends, HTTPException, Query, status\nfrom fastapi.security import HTTPAuthorizationCredentials\nfrom sqlalchemy import delete, func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.api.routes.settings import get_external_login_url\nfrom backend.app.core.auth import (\n    ALGORITHM,\n    SECRET_KEY,\n    RequirePermissionIfAuthEnabled,\n    get_current_user_optional,\n    get_password_hash,\n    revoke_jti,\n    security,\n    verify_password,\n)\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.group import Group\nfrom backend.app.models.library import LibraryFile\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.user import User\nfrom backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate\nfrom backend.app.services.email_service import (\n    create_welcome_email_from_template,\n    generate_secure_password,\n    get_smtp_settings,\n    send_email,\n)\n\nrouter = APIRouter(prefix=\"/users\", tags=[\"users\"])\n\n\ndef _user_to_response(user: User) -> UserResponse:\n    \"\"\"Convert a User model to UserResponse schema.\"\"\"\n    return UserResponse(\n        id=user.id,\n        username=user.username,\n        email=user.email,\n        role=user.role,\n        is_active=user.is_active,\n        is_admin=user.is_admin,\n        auth_source=getattr(user, \"auth_source\", \"local\"),\n        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],\n        permissions=sorted(user.get_permissions()),\n        created_at=user.created_at.isoformat(),\n    )\n\n\n@router.get(\"\", response_model=list[UserResponse])\n@router.get(\"/\", response_model=list[UserResponse])\nasync def list_users(\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"List all users.\"\"\"\n    result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))\n    users = result.scalars().all()\n    return [_user_to_response(user) for user in users]\n\n\n@router.post(\"\", response_model=UserResponse, status_code=status.HTTP_201_CREATED)\n@router.post(\"/\", response_model=UserResponse, status_code=status.HTTP_201_CREATED)\nasync def create_user(\n    user_data: UserCreate,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Create a new user.\n\n    When advanced authentication is enabled:\n    - Email is required\n    - Password is auto-generated and emailed to user\n    - Admin cannot set or see the password\n    \"\"\"\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    # Check if advanced auth is enabled\n    result = await db.execute(select(Settings).where(Settings.key == \"advanced_auth_enabled\"))\n    advanced_auth_setting = result.scalar_one_or_none()\n    advanced_auth_enabled = advanced_auth_setting and advanced_auth_setting.value.lower() == \"true\"\n\n    # Check if username already exists (case-insensitive)\n    existing_user = await db.execute(select(User).where(func.lower(User.username) == func.lower(user_data.username)))\n    if existing_user.scalar_one_or_none():\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Username already exists\",\n        )\n\n    # Validate role\n    if user_data.role not in [\"admin\", \"user\"]:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Role must be 'admin' or 'user'\",\n        )\n\n    # Advanced auth validation\n    if advanced_auth_enabled:\n        if not user_data.email:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Email is required when advanced authentication is enabled\",\n            )\n        # Check if email already exists (case-insensitive)\n        existing_email = await db.execute(select(User).where(func.lower(User.email) == func.lower(user_data.email)))\n        if existing_email.scalar_one_or_none():\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Email already exists\",\n            )\n\n    # Generate password if advanced auth enabled, otherwise require password\n    if advanced_auth_enabled:\n        password = generate_secure_password()\n    else:\n        if not user_data.password:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Password is required when advanced authentication is disabled\",\n            )\n        password = user_data.password\n\n    new_user = User(\n        username=user_data.username,\n        email=user_data.email,\n        password_hash=get_password_hash(password),\n        role=user_data.role,\n        is_active=True,\n    )\n\n    # Handle group assignments\n    if user_data.group_ids:\n        groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))\n        groups = groups_result.scalars().all()\n        if len(groups) != len(user_data.group_ids):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"One or more group IDs are invalid\",\n            )\n        new_user.groups = list(groups)\n\n    db.add(new_user)\n    await db.commit()\n    await db.refresh(new_user)\n\n    # Send welcome email if advanced auth enabled\n    if advanced_auth_enabled and new_user.email:\n        try:\n            smtp_settings = await get_smtp_settings(db)\n            if smtp_settings:\n                login_url = await get_external_login_url(db)\n                subject, text_body, html_body = await create_welcome_email_from_template(\n                    db, new_user.username, password, login_url\n                )\n                send_email(smtp_settings, new_user.email, subject, text_body, html_body)\n                logger.info(f\"Welcome email sent to {new_user.email}\")\n            else:\n                logger.warning(f\"SMTP not configured, could not send welcome email to {new_user.email}\")\n        except Exception as e:\n            logger.error(f\"Failed to send welcome email: {e}\")\n            # Don't fail user creation if email fails\n\n    return _user_to_response(new_user)\n\n\n@router.get(\"/{user_id}\", response_model=UserResponse)\nasync def get_user(\n    user_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get a user by ID.\"\"\"\n    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))\n    user = result.scalar_one_or_none()\n    if not user:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"User not found\",\n        )\n\n    return _user_to_response(user)\n\n\n@router.patch(\"/{user_id}\", response_model=UserResponse)\nasync def update_user(\n    user_id: int,\n    user_data: UserUpdate,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Update a user.\"\"\"\n    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))\n    user = result.scalar_one_or_none()\n    if not user:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"User not found\",\n        )\n\n    # Prevent deactivating the last admin\n    if user_data.is_active is False and user.is_admin:\n        # Count admins by role or Administrators group membership\n        admin_count_result = await db.execute(select(User).where(User.role == \"admin\", User.is_active.is_(True)))\n        role_admins = admin_count_result.scalars().all()\n\n        # Also check for users in Administrators group\n        admin_group_result = await db.execute(\n            select(Group).where(Group.name == \"Administrators\").options(selectinload(Group.users))\n        )\n        admin_group = admin_group_result.scalar_one_or_none()\n        group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]\n\n        # Combine unique admins\n        all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}\n        if len(all_admins) <= 1 and user.id in all_admins:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Cannot deactivate the last admin user\",\n            )\n\n    # Prevent changing role of last admin\n    if user_data.role and user_data.role != \"admin\" and user.role == \"admin\":\n        admin_count_result = await db.execute(select(User).where(User.role == \"admin\", User.is_active.is_(True)))\n        admin_count = len(admin_count_result.scalars().all())\n        if admin_count <= 1:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Cannot change role of the last admin user\",\n            )\n\n    if user_data.username is not None:\n        # Check if new username already exists (case-insensitive)\n        existing_user = await db.execute(\n            select(User).where(func.lower(User.username) == func.lower(user_data.username), User.id != user_id)\n        )\n        if existing_user.scalar_one_or_none():\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Username already exists\",\n            )\n        user.username = user_data.username\n\n    if user_data.email is not None:\n        # Check if new email already exists (case-insensitive)\n        existing_email = await db.execute(\n            select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)\n        )\n        if existing_email.scalar_one_or_none():\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Email already exists\",\n            )\n        user.email = user_data.email\n\n    if user_data.password is not None:\n        if getattr(user, \"auth_source\", \"local\") == \"ldap\":\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Cannot set password for LDAP users\",\n            )\n        user.password_hash = get_password_hash(user_data.password)\n\n    if user_data.role is not None:\n        if user_data.role not in [\"admin\", \"user\"]:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Role must be 'admin' or 'user'\",\n            )\n        user.role = user_data.role\n\n    if user_data.is_active is not None:\n        user.is_active = user_data.is_active\n\n    # Handle group assignments\n    if user_data.group_ids is not None:\n        groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))\n        groups = groups_result.scalars().all()\n        if len(groups) != len(user_data.group_ids):\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"One or more group IDs are invalid\",\n            )\n        user.groups = list(groups)\n\n    await db.commit()\n    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))\n    user = result.scalar_one()\n\n    return _user_to_response(user)\n\n\n@router.get(\"/{user_id}/items-count\")\nasync def get_user_items_count(\n    user_id: int,\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get count of items created by this user.\"\"\"\n    # Verify user exists\n    result = await db.execute(select(User).where(User.id == user_id))\n    if not result.scalar_one_or_none():\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"User not found\",\n        )\n\n    # Count archives\n    archives_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.created_by_id == user_id))\n    archives_count = archives_result.scalar() or 0\n\n    # Count queue items\n    queue_result = await db.execute(\n        select(func.count(PrintQueueItem.id)).where(PrintQueueItem.created_by_id == user_id)\n    )\n    queue_items_count = queue_result.scalar() or 0\n\n    # Count library files\n    library_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.created_by_id == user_id))\n    library_files_count = library_result.scalar() or 0\n\n    return {\n        \"archives\": archives_count,\n        \"queue_items\": queue_items_count,\n        \"library_files\": library_files_count,\n    }\n\n\n@router.delete(\"/{user_id}\", status_code=status.HTTP_204_NO_CONTENT)\nasync def delete_user(\n    user_id: int,\n    delete_items: bool = Query(False, description=\"Delete all items created by this user\"),\n    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Delete a user.\n\n    If delete_items=True, all archives, queue items, and library files created by\n    this user will also be deleted. Otherwise, these items will become \"ownerless\"\n    (created_by_id set to NULL by the foreign key constraint).\n    \"\"\"\n    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))\n    user = result.scalar_one_or_none()\n    if not user:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"User not found\",\n        )\n\n    # Prevent deleting the last admin\n    if user.is_admin:\n        # Count admins by role or Administrators group membership\n        admin_count_result = await db.execute(select(User).where(User.role == \"admin\", User.id != user_id))\n        other_role_admins = admin_count_result.scalars().all()\n\n        # Also check for users in Administrators group\n        admin_group_result = await db.execute(\n            select(Group).where(Group.name == \"Administrators\").options(selectinload(Group.users))\n        )\n        admin_group = admin_group_result.scalar_one_or_none()\n        other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]\n\n        # Combine unique admins\n        all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}\n        if len(all_other_admins) == 0:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"Cannot delete the last admin user\",\n            )\n\n    # Prevent deleting yourself (only if auth is enabled and we have a current user)\n    if current_user and user.id == current_user.id:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Cannot delete your own account\",\n        )\n\n    if delete_items:\n        # Delete all items created by this user\n        await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))\n        await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))\n        await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))\n    else:\n        # Explicitly set created_by_id to NULL for all items (ensures consistent behavior\n        # across different database backends, including SQLite without foreign key support)\n        from sqlalchemy import update\n\n        await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))\n        await db.execute(\n            update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)\n        )\n        await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))\n\n    await db.delete(user)\n    await db.commit()\n\n\n@router.post(\"/me/change-password\", response_model=dict)\nasync def change_own_password(\n    password_data: ChangePasswordRequest,\n    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n    current_user: User | None = Depends(get_current_user_optional),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Change the current user's password. Requires current password verification.\"\"\"\n    if not current_user:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Authentication required to change password\",\n        )\n\n    # Block password change for LDAP users\n    if getattr(current_user, \"auth_source\", \"local\") == \"ldap\":\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Cannot change password for LDAP users — passwords are managed by the LDAP server\",\n        )\n\n    # Verify current password\n    if not current_user.password_hash:\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Account has no local password set\",\n        )\n\n    # Rate-limit failed password-change attempts (H-R5-A)\n    from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS, check_rate_limit, record_failed_attempt\n\n    await check_rate_limit(db, current_user.username, event_type=\"password_change\", max_attempts=MAX_2FA_ATTEMPTS)\n\n    if not verify_password(password_data.current_password, current_user.password_hash):\n        await record_failed_attempt(db, current_user.username, event_type=\"password_change\")\n        raise HTTPException(\n            status_code=status.HTTP_400_BAD_REQUEST,\n            detail=\"Current password is incorrect\",\n        )\n\n    # Fetch user from this session to ensure changes are persisted\n    result = await db.execute(select(User).where(User.id == current_user.id))\n    user = result.scalar_one_or_none()\n    if not user:\n        raise HTTPException(\n            status_code=status.HTTP_404_NOT_FOUND,\n            detail=\"User not found\",\n        )\n\n    # Update password\n    user.password_hash = get_password_hash(password_data.new_password)\n    user.password_changed_at = datetime.now(timezone.utc)  # M-R7-B: invalidate all prior JWTs\n    await db.commit()\n\n    # L-R6-A: Password verified successfully — reset the failure counter\n    from backend.app.api.routes.mfa import clear_failed_attempts\n\n    await clear_failed_attempts(db, user.username, event_type=\"password_change\")\n\n    # Revoke the current session token so the caller must re-authenticate (M-R5-A)\n    if credentials is not None:\n        try:\n            payload = _jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])\n            jti = payload.get(\"jti\")\n            exp = payload.get(\"exp\")\n            if jti and exp:\n                try:\n                    await revoke_jti(jti, datetime.fromtimestamp(exp, tz=timezone.utc), user.username)\n                except Exception as exc:\n                    # B4: log so operators know revocation is broken; password was\n                    # already changed so the token will fail freshness checks anyway.\n                    import logging\n\n                    logging.getLogger(__name__).error(\n                        \"Failed to revoke JTI after password change for user %s: %s\", user.username, exc\n                    )\n        except Exception:\n            pass  # Decode failure is harmless — token is already invalidated by password_changed_at\n\n    return {\"message\": \"Password changed successfully\"}\n"
  },
  {
    "path": "backend/app/api/routes/virtual_printers.py",
    "content": "import logging\n\nfrom fastapi import APIRouter, Depends\nfrom fastapi.responses import JSONResponse\nfrom pydantic import BaseModel\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import RequirePermissionIfAuthEnabled\nfrom backend.app.core.database import get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.user import User\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/virtual-printers\", tags=[\"virtual-printers\"])\n\n\nclass VirtualPrinterCreate(BaseModel):\n    name: str = \"Bambuddy\"\n    enabled: bool = False\n    mode: str = \"immediate\"\n    model: str | None = None\n    access_code: str | None = None\n    target_printer_id: int | None = None\n    auto_dispatch: bool = True\n    bind_ip: str | None = None\n    remote_interface_ip: str | None = None\n\n\nclass VirtualPrinterUpdate(BaseModel):\n    name: str | None = None\n    enabled: bool | None = None\n    mode: str | None = None\n    model: str | None = None\n    access_code: str | None = None\n    target_printer_id: int | None = None\n    auto_dispatch: bool | None = None\n    bind_ip: str | None = None\n    remote_interface_ip: str | None = None\n\n\ndef _resolve_printer_model(printer_model: str | None) -> str | None:\n    \"\"\"Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.\n\n    Printers store display names like 'X1C' while VPs need SSDP codes like 'BL-P001'.\n    \"\"\"\n    if not printer_model:\n        return None\n    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS\n    from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE\n\n    # Already a valid SSDP model code\n    if printer_model in VIRTUAL_PRINTER_MODELS:\n        return printer_model\n    # Map display name to SSDP code\n    return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)\n\n\ndef _vp_to_dict(vp, status: dict | None = None) -> dict:\n    \"\"\"Convert VirtualPrinter model to response dict.\"\"\"\n    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS\n    from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model\n\n    model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL\n    serial = _get_serial_for_model(model_code, vp.serial_suffix)\n\n    return {\n        \"id\": vp.id,\n        \"name\": vp.name,\n        \"enabled\": vp.enabled,\n        \"mode\": vp.mode,\n        \"model\": model_code,\n        \"model_name\": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),\n        \"access_code_set\": bool(vp.access_code),\n        \"serial\": serial,\n        \"target_printer_id\": vp.target_printer_id,\n        \"auto_dispatch\": vp.auto_dispatch,\n        \"bind_ip\": vp.bind_ip,\n        \"remote_interface_ip\": vp.remote_interface_ip,\n        \"position\": vp.position,\n        \"status\": status or {\"running\": False, \"pending_files\": 0},\n    }\n\n\n@router.get(\"\")\nasync def list_virtual_printers(\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"List all virtual printers with status.\"\"\"\n    from backend.app.models.virtual_printer import VirtualPrinter\n    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager\n\n    result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))\n    vps = result.scalars().all()\n\n    printers = []\n    for vp in vps:\n        instance = virtual_printer_manager.get_instance(vp.id)\n        status = instance.get_status() if instance else {\"running\": False, \"pending_files\": 0}\n        printers.append(_vp_to_dict(vp, status))\n\n    return {\n        \"printers\": printers,\n        \"models\": VIRTUAL_PRINTER_MODELS,\n    }\n\n\n@router.post(\"\")\nasync def create_virtual_printer(\n    body: VirtualPrinterCreate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Create a new virtual printer.\"\"\"\n    from backend.app.models.virtual_printer import VirtualPrinter\n    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager\n    from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL\n\n    # Validate mode\n    if body.mode not in (\"immediate\", \"review\", \"print_queue\", \"proxy\"):\n        return JSONResponse(status_code=400, content={\"detail\": \"Invalid mode\"})\n\n    # Validate model\n    if body.model and body.model not in VIRTUAL_PRINTER_MODELS:\n        return JSONResponse(\n            status_code=400,\n            content={\"detail\": f\"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}\"},\n        )\n\n    # Validate access code length\n    if body.access_code and len(body.access_code) != 8:\n        return JSONResponse(status_code=400, content={\"detail\": \"Access code must be exactly 8 characters\"})\n\n    # Validation when enabling\n    if body.enabled:\n        if not body.bind_ip:\n            return JSONResponse(status_code=400, content={\"detail\": \"Bind IP is required when enabling\"})\n        if body.mode == \"proxy\":\n            if not body.target_printer_id:\n                return JSONResponse(status_code=400, content={\"detail\": \"Target printer is required for proxy mode\"})\n        else:\n            if not body.access_code:\n                return JSONResponse(status_code=400, content={\"detail\": \"Access code is required when enabling\"})\n\n    # Validate proxy target printer exists\n    target_printer = None\n    if body.target_printer_id:\n        from backend.app.models.printer import Printer\n\n        result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))\n        target_printer = result.scalar_one_or_none()\n        if not target_printer:\n            return JSONResponse(\n                status_code=400, content={\"detail\": f\"Printer with ID {body.target_printer_id} not found\"}\n            )\n\n    # Validate bind_ip uniqueness (against all enabled VPs)\n    if body.bind_ip:\n        result = await db.execute(\n            select(VirtualPrinter).where(\n                VirtualPrinter.bind_ip == body.bind_ip,\n                VirtualPrinter.enabled == True,  # noqa: E712\n            )\n        )\n        if result.scalar_one_or_none():\n            return JSONResponse(status_code=400, content={\"detail\": f\"Bind IP {body.bind_ip} is already in use\"})\n\n    # Generate next serial suffix\n    result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))\n    last_suffix = result.scalar()\n    if last_suffix:\n        try:\n            next_num = int(last_suffix) + 1\n            new_suffix = str(next_num).zfill(9)\n        except ValueError:\n            new_suffix = \"391800002\"\n    else:\n        new_suffix = \"391800001\"\n\n    # Get next position\n    result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))\n    last_pos = result.scalar()\n    next_pos = (last_pos or 0) + 1\n\n    vp = VirtualPrinter(\n        name=body.name,\n        enabled=body.enabled,\n        mode=body.mode,\n        model=body.model\n        or _resolve_printer_model(target_printer.model if target_printer and body.mode == \"proxy\" else None)\n        or DEFAULT_VIRTUAL_PRINTER_MODEL,\n        access_code=body.access_code,\n        target_printer_id=body.target_printer_id,\n        auto_dispatch=body.auto_dispatch,\n        bind_ip=body.bind_ip,\n        remote_interface_ip=body.remote_interface_ip,\n        serial_suffix=new_suffix,\n        position=next_pos,\n    )\n    db.add(vp)\n    await db.commit()\n    await db.refresh(vp)\n\n    logger.info(\"Created virtual printer: %s (id=%d)\", vp.name, vp.id)\n\n    # Sync services if enabled\n    if body.enabled:\n        try:\n            await virtual_printer_manager.sync_from_db()\n        except Exception as e:\n            logger.error(\"Failed to start virtual printer after create: %s\", e)\n\n    return _vp_to_dict(vp)\n\n\n@router.get(\"/{vp_id}\")\nasync def get_virtual_printer(\n    vp_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),\n):\n    \"\"\"Get a single virtual printer with status.\"\"\"\n    from backend.app.models.virtual_printer import VirtualPrinter\n    from backend.app.services.virtual_printer import virtual_printer_manager\n\n    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))\n    vp = result.scalar_one_or_none()\n    if not vp:\n        return JSONResponse(status_code=404, content={\"detail\": \"Virtual printer not found\"})\n\n    instance = virtual_printer_manager.get_instance(vp.id)\n    status = instance.get_status() if instance else {\"running\": False, \"pending_files\": 0}\n\n    return _vp_to_dict(vp, status)\n\n\n@router.put(\"/{vp_id}\")\nasync def update_virtual_printer(\n    vp_id: int,\n    body: VirtualPrinterUpdate,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Update a virtual printer.\"\"\"\n    from backend.app.models.virtual_printer import VirtualPrinter\n    from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager\n\n    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))\n    vp = result.scalar_one_or_none()\n    if not vp:\n        return JSONResponse(status_code=404, content={\"detail\": \"Virtual printer not found\"})\n\n    logger.debug(\n        \"Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s\",\n        vp_id,\n        body.model_dump(exclude_unset=True),\n        vp.mode,\n        vp.enabled,\n        bool(vp.access_code),\n        vp.bind_ip,\n        vp.target_printer_id,\n    )\n\n    # Apply updates\n    if body.name is not None:\n        vp.name = body.name\n    if body.mode is not None:\n        if body.mode not in (\"immediate\", \"review\", \"print_queue\", \"proxy\"):\n            return JSONResponse(status_code=400, content={\"detail\": \"Invalid mode\"})\n        vp.mode = body.mode\n    if body.model is not None:\n        if body.model not in VIRTUAL_PRINTER_MODELS:\n            return JSONResponse(\n                status_code=400,\n                content={\"detail\": f\"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}\"},\n            )\n        vp.model = body.model\n    if body.access_code is not None:\n        if body.access_code and len(body.access_code) != 8:\n            return JSONResponse(status_code=400, content={\"detail\": \"Access code must be exactly 8 characters\"})\n        vp.access_code = body.access_code\n    if body.target_printer_id is not None:\n        from backend.app.models.printer import Printer\n\n        result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))\n        target_printer = result.scalar_one_or_none()\n        if not target_printer:\n            return JSONResponse(\n                status_code=400, content={\"detail\": f\"Printer with ID {body.target_printer_id} not found\"}\n            )\n        vp.target_printer_id = body.target_printer_id\n        # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)\n        if body.model is None and vp.mode == \"proxy\" and target_printer.model:\n            vp.model = _resolve_printer_model(target_printer.model) or target_printer.model\n    if body.auto_dispatch is not None:\n        vp.auto_dispatch = body.auto_dispatch\n    if body.bind_ip is not None:\n        vp.bind_ip = body.bind_ip\n    if body.remote_interface_ip is not None:\n        vp.remote_interface_ip = body.remote_interface_ip\n\n    # Auto-inherit model when switching to proxy mode with existing target printer\n    if body.mode == \"proxy\" and body.model is None and body.target_printer_id is None and vp.target_printer_id:\n        from backend.app.models.printer import Printer as PrinterModel\n\n        result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))\n        existing_target = result.scalar_one_or_none()\n        if existing_target and existing_target.model:\n            vp.model = _resolve_printer_model(existing_target.model) or existing_target.model\n\n    # Determine final enabled state\n    explicitly_enabling = body.enabled is True\n    new_enabled = body.enabled if body.enabled is not None else vp.enabled\n    effective_mode = vp.mode\n\n    if explicitly_enabling:\n        # User is explicitly toggling on — enforce all requirements\n        if not vp.bind_ip:\n            logger.warning(\"Update VP %d rejected: no bind_ip\", vp_id)\n            return JSONResponse(status_code=400, content={\"detail\": \"Bind IP is required when enabling\"})\n        # Validate bind_ip uniqueness (against all enabled VPs)\n        existing = await db.execute(\n            select(VirtualPrinter).where(\n                VirtualPrinter.bind_ip == vp.bind_ip,\n                VirtualPrinter.id != vp_id,\n                VirtualPrinter.enabled == True,  # noqa: E712\n            )\n        )\n        conflict = existing.scalar_one_or_none()\n        if conflict:\n            logger.warning(\n                \"Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)\",\n                vp_id,\n                vp.bind_ip,\n                conflict.id,\n                conflict.enabled,\n                conflict.mode,\n            )\n            return JSONResponse(\n                status_code=400,\n                content={\"detail\": f\"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'\"},\n            )\n        if effective_mode == \"proxy\":\n            if not vp.target_printer_id:\n                logger.warning(\"Update VP %d rejected: no target_printer_id for proxy mode\", vp_id)\n                return JSONResponse(status_code=400, content={\"detail\": \"Target printer is required for proxy mode\"})\n        else:\n            if not vp.access_code:\n                logger.warning(\n                    \"Update VP %d rejected: no access_code for non-proxy enable (mode=%s)\", vp_id, effective_mode\n                )\n                return JSONResponse(status_code=400, content={\"detail\": \"Access code is required when enabling\"})\n    elif new_enabled and body.enabled is None:\n        # VP is already enabled and user is changing other fields —\n        # auto-disable if new state doesn't meet requirements\n        if not vp.bind_ip:\n            new_enabled = False\n        elif effective_mode == \"proxy\":\n            if not vp.target_printer_id:\n                new_enabled = False\n        else:\n            if not vp.access_code:\n                new_enabled = False\n\n    vp.enabled = new_enabled\n\n    await db.commit()\n    await db.refresh(vp)\n\n    logger.info(\"Updated virtual printer: %s (id=%d)\", vp.name, vp.id)\n\n    # Sync services\n    try:\n        await virtual_printer_manager.sync_from_db()\n    except Exception as e:\n        logger.error(\"Failed to sync virtual printers after update: %s\", e)\n\n    instance = virtual_printer_manager.get_instance(vp.id)\n    status = instance.get_status() if instance else {\"running\": False, \"pending_files\": 0}\n\n    return _vp_to_dict(vp, status)\n\n\n@router.delete(\"/{vp_id}\")\nasync def delete_virtual_printer(\n    vp_id: int,\n    db: AsyncSession = Depends(get_db),\n    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),\n):\n    \"\"\"Delete a virtual printer.\"\"\"\n    from sqlalchemy import delete as sql_delete\n\n    from backend.app.models.virtual_printer import VirtualPrinter\n    from backend.app.services.virtual_printer import virtual_printer_manager\n\n    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))\n    vp = result.scalar_one_or_none()\n    if not vp:\n        return JSONResponse(status_code=404, content={\"detail\": \"Virtual printer not found\"})\n\n    vp_name = vp.name\n\n    # Stop instance if running\n    await virtual_printer_manager.remove_instance(vp_id)\n\n    # Delete from DB\n    await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))\n    await db.commit()\n\n    logger.info(\"Deleted virtual printer: %s (id=%d)\", vp_name, vp_id)\n\n    # Resync remaining services\n    try:\n        await virtual_printer_manager.sync_from_db()\n    except Exception as e:\n        logger.error(\"Failed to sync virtual printers after delete: %s\", e)\n\n    return {\"detail\": \"Deleted\", \"id\": vp_id}\n"
  },
  {
    "path": "backend/app/api/routes/webhook.py",
    "content": "import logging\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom pydantic import BaseModel\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.auth import check_permission, check_printer_access, get_api_key\nfrom backend.app.core.database import get_db\nfrom backend.app.models.api_key import APIKey\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.models.printer import Printer\nfrom backend.app.services.printer_manager import printer_manager\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/webhook\", tags=[\"webhook\"])\n\n\n# Request schemas\nclass QueueAddRequest(BaseModel):\n    archive_id: int\n    printer_id: int\n    project_id: int | None = None\n    scheduled_time: str | None = None  # ISO format datetime\n    require_previous_success: bool = False\n    auto_off_after: bool = False\n\n\nclass QueueAddResponse(BaseModel):\n    id: int\n    archive_id: int\n    printer_id: int\n    position: int\n    status: str\n    message: str\n\n\nclass PrinterStatusResponse(BaseModel):\n    id: int\n    name: str\n    connected: bool\n    state: str | None\n    current_print: str | None\n    progress: float | None\n    remaining_time: int | None\n\n\nclass QueueStatusResponse(BaseModel):\n    printer_id: int\n    printer_name: str\n    pending: int\n    printing: int\n    items: list[dict]\n\n\n# Webhook endpoints\n\n\n@router.post(\"/queue/add\", response_model=QueueAddResponse)\nasync def webhook_add_to_queue(\n    data: QueueAddRequest,\n    api_key: APIKey = Depends(get_api_key),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Add a print to the queue via webhook.\n\n    Requires 'can_queue' permission.\n    \"\"\"\n    check_permission(api_key, \"queue\")\n    check_printer_access(api_key, data.printer_id)\n\n    # Verify archive exists\n    result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))\n    archive = result.scalar_one_or_none()\n    if not archive:\n        raise HTTPException(status_code=404, detail=\"Archive not found\")\n\n    # Verify printer exists\n    result = await db.execute(select(Printer).where(Printer.id == data.printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    # Get next position\n    result = await db.execute(\n        select(PrintQueueItem.position)\n        .where(\n            PrintQueueItem.printer_id == data.printer_id,\n            PrintQueueItem.status == \"pending\",\n        )\n        .order_by(PrintQueueItem.position.desc())\n        .limit(1)\n    )\n    max_position = result.scalar()\n    next_position = (max_position or 0) + 1\n\n    # Parse scheduled time if provided\n    scheduled_time = None\n    if data.scheduled_time:\n        from datetime import datetime\n\n        try:\n            scheduled_time = datetime.fromisoformat(data.scheduled_time.replace(\"Z\", \"+00:00\"))\n        except ValueError:\n            raise HTTPException(status_code=400, detail=\"Invalid scheduled_time format\")\n\n    # Create queue item\n    queue_item = PrintQueueItem(\n        printer_id=data.printer_id,\n        archive_id=data.archive_id,\n        project_id=data.project_id,\n        position=next_position,\n        scheduled_time=scheduled_time,\n        require_previous_success=data.require_previous_success,\n        auto_off_after=data.auto_off_after,\n    )\n    db.add(queue_item)\n    await db.flush()\n    await db.refresh(queue_item)\n\n    return QueueAddResponse(\n        id=queue_item.id,\n        archive_id=queue_item.archive_id,\n        printer_id=queue_item.printer_id,\n        position=queue_item.position,\n        status=queue_item.status,\n        message=f\"Added to queue at position {queue_item.position}\",\n    )\n\n\n@router.post(\"/printer/{printer_id}/start\")\nasync def webhook_start_print(\n    printer_id: int,\n    api_key: APIKey = Depends(get_api_key),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Start the next queued print on a printer.\n\n    Requires 'can_control_printer' permission.\n    \"\"\"\n    check_permission(api_key, \"control_printer\")\n    check_printer_access(api_key, printer_id)\n\n    # Get printer\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    # Get next pending queue item\n    result = await db.execute(\n        select(PrintQueueItem)\n        .where(\n            PrintQueueItem.printer_id == printer_id,\n            PrintQueueItem.status == \"pending\",\n        )\n        .order_by(PrintQueueItem.position)\n        .limit(1)\n    )\n    queue_item = result.scalar_one_or_none()\n    if not queue_item:\n        raise HTTPException(status_code=404, detail=\"No pending prints in queue\")\n\n    # Check if printer is ready\n    status = printer_manager.get_status(printer_id)\n    if not status or not status.get(\"connected\"):\n        raise HTTPException(status_code=503, detail=\"Printer not connected\")\n\n    if status.get(\"state\") not in [\"IDLE\", \"FINISH\", \"FAILED\"]:\n        raise HTTPException(status_code=409, detail=f\"Printer is busy (state: {status.get('state')})\")\n\n    # Start the print with plate_id if available\n    try:\n        await printer_manager.start_print(\n            printer_id,\n            queue_item.archive_id,\n            plate_id=queue_item.plate_id or 1,\n        )\n    except Exception as e:\n        logger.error(\"Failed to start print: %s\", e)\n        raise HTTPException(status_code=500, detail=str(e))\n\n    return {\"message\": \"Print started\", \"queue_item_id\": queue_item.id}\n\n\n@router.post(\"/printer/{printer_id}/stop\")\nasync def webhook_stop_print(\n    printer_id: int,\n    api_key: APIKey = Depends(get_api_key),\n):\n    \"\"\"Stop the current print on a printer.\n\n    Requires 'can_control_printer' permission.\n    \"\"\"\n    check_permission(api_key, \"control_printer\")\n    check_printer_access(api_key, printer_id)\n\n    status = printer_manager.get_status(printer_id)\n    if not status or not status.get(\"connected\"):\n        raise HTTPException(status_code=503, detail=\"Printer not connected\")\n\n    if status.get(\"state\") != \"RUNNING\":\n        raise HTTPException(status_code=409, detail=\"No print in progress\")\n\n    try:\n        await printer_manager.stop_print(printer_id)\n    except Exception as e:\n        logger.error(\"Failed to stop print: %s\", e)\n        raise HTTPException(status_code=500, detail=str(e))\n\n    return {\"message\": \"Print stopped\"}\n\n\n@router.post(\"/printer/{printer_id}/cancel\")\nasync def webhook_cancel_print(\n    printer_id: int,\n    api_key: APIKey = Depends(get_api_key),\n):\n    \"\"\"Cancel the current print on a printer.\n\n    Requires 'can_control_printer' permission.\n    \"\"\"\n    check_permission(api_key, \"control_printer\")\n    check_printer_access(api_key, printer_id)\n\n    status = printer_manager.get_status(printer_id)\n    if not status or not status.get(\"connected\"):\n        raise HTTPException(status_code=503, detail=\"Printer not connected\")\n\n    if status.get(\"state\") not in [\"RUNNING\", \"PAUSE\"]:\n        raise HTTPException(status_code=409, detail=\"No print to cancel\")\n\n    try:\n        await printer_manager.cancel_print(printer_id)\n    except Exception as e:\n        logger.error(\"Failed to cancel print: %s\", e)\n        raise HTTPException(status_code=500, detail=str(e))\n\n    return {\"message\": \"Print cancelled\"}\n\n\n@router.get(\"/printer/{printer_id}/status\", response_model=PrinterStatusResponse)\nasync def webhook_get_printer_status(\n    printer_id: int,\n    api_key: APIKey = Depends(get_api_key),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get status of a printer.\n\n    Requires 'can_read_status' permission.\n    \"\"\"\n    check_permission(api_key, \"read_status\")\n    check_printer_access(api_key, printer_id)\n\n    # Get printer\n    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n    printer = result.scalar_one_or_none()\n    if not printer:\n        raise HTTPException(status_code=404, detail=\"Printer not found\")\n\n    status = printer_manager.get_status(printer_id)\n\n    return PrinterStatusResponse(\n        id=printer.id,\n        name=printer.name,\n        connected=status.get(\"connected\", False) if status else False,\n        state=status.get(\"state\") if status else None,\n        current_print=status.get(\"current_print\") if status else None,\n        progress=status.get(\"progress\") if status else None,\n        remaining_time=status.get(\"remaining_time\") if status else None,\n    )\n\n\n@router.get(\"/queue\", response_model=list[QueueStatusResponse])\nasync def webhook_get_queue_status(\n    printer_id: int | None = None,\n    api_key: APIKey = Depends(get_api_key),\n    db: AsyncSession = Depends(get_db),\n):\n    \"\"\"Get queue status for all printers or a specific printer.\n\n    Requires 'can_read_status' permission.\n    \"\"\"\n    check_permission(api_key, \"read_status\")\n\n    # Get printers\n    if printer_id:\n        check_printer_access(api_key, printer_id)\n        result = await db.execute(select(Printer).where(Printer.id == printer_id))\n        printers = result.scalars().all()\n    else:\n        result = await db.execute(select(Printer))\n        printers = result.scalars().all()\n        # Filter by allowed printers if limited\n        if api_key.printer_ids is not None:\n            printers = [p for p in printers if p.id in api_key.printer_ids]\n\n    response = []\n    for printer in printers:\n        # Get queue items\n        result = await db.execute(\n            select(PrintQueueItem)\n            .where(\n                PrintQueueItem.printer_id == printer.id,\n                PrintQueueItem.status.in_([\"pending\", \"printing\"]),\n            )\n            .order_by(PrintQueueItem.position)\n        )\n        items = result.scalars().all()\n\n        pending_count = sum(1 for i in items if i.status == \"pending\")\n        printing_count = sum(1 for i in items if i.status == \"printing\")\n\n        response.append(\n            QueueStatusResponse(\n                printer_id=printer.id,\n                printer_name=printer.name,\n                pending=pending_count,\n                printing=printing_count,\n                items=[\n                    {\n                        \"id\": item.id,\n                        \"archive_id\": item.archive_id,\n                        \"position\": item.position,\n                        \"status\": item.status,\n                    }\n                    for item in items\n                ],\n            )\n        )\n\n    return response\n"
  },
  {
    "path": "backend/app/api/routes/websocket.py",
    "content": "import logging\n\nfrom fastapi import APIRouter, WebSocket, WebSocketDisconnect\n\nfrom backend.app.core.websocket import ws_manager\nfrom backend.app.services.background_dispatch import background_dispatch\nfrom backend.app.services.printer_manager import printer_manager, printer_state_to_dict\n\nlogger = logging.getLogger(__name__)\nrouter = APIRouter()\n\n\n@router.websocket(\"/ws\")\nasync def websocket_endpoint(websocket: WebSocket):\n    \"\"\"WebSocket endpoint for real-time updates.\"\"\"\n    logger.info(\"WebSocket client connecting...\")\n    await ws_manager.connect(websocket)\n    logger.info(\"WebSocket client connected\")\n\n    try:\n        # Send initial status of all printers\n        statuses = printer_manager.get_all_statuses()\n        for printer_id, state in statuses.items():\n            await websocket.send_json(\n                {\n                    \"type\": \"printer_status\",\n                    \"printer_id\": printer_id,\n                    \"data\": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),\n                }\n            )\n\n        dispatch_state = await background_dispatch.get_state()\n        if (dispatch_state.get(\"dispatched\", 0) + dispatch_state.get(\"processing\", 0)) > 0:\n            await websocket.send_json(\n                {\n                    \"type\": \"background_dispatch\",\n                    \"data\": dispatch_state,\n                }\n            )\n        logger.info(\"Sent initial status for %s printers\", len(statuses))\n\n        # Keep connection alive and handle incoming messages\n        while True:\n            data = await websocket.receive_json()\n\n            # Handle ping/pong for keepalive\n            if data.get(\"type\") == \"ping\":\n                await websocket.send_json({\"type\": \"pong\"})\n\n            # Handle status request\n            elif data.get(\"type\") == \"get_status\":\n                printer_id = data.get(\"printer_id\")\n                if printer_id:\n                    state = printer_manager.get_status(printer_id)\n                    if state:\n                        await websocket.send_json(\n                            {\n                                \"type\": \"printer_status\",\n                                \"printer_id\": printer_id,\n                                \"data\": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),\n                            }\n                        )\n\n    except WebSocketDisconnect:\n        logger.info(\"WebSocket client disconnected normally\")\n        await ws_manager.disconnect(websocket)\n    except Exception as e:\n        logger.error(\"WebSocket error: %s\", e, exc_info=True)\n        await ws_manager.disconnect(websocket)\n"
  },
  {
    "path": "backend/app/cli.py",
    "content": "\"\"\"Bambuddy administrative CLI.\n\nInvoked via ``python -m backend.app.cli <subcommand>``.\n\nCurrently provides ``kiosk-bootstrap`` for creating the SpoolBuddy kiosk\nAPI key during install (see ``spoolbuddy/install/install.sh``).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport sys\n\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import async_sessionmaker\n\nfrom backend.app.core.auth import generate_api_key\nfrom backend.app.core.database import async_session as default_session_maker, init_db\nfrom backend.app.core.db_dialect import upsert_setting\nfrom backend.app.models.api_key import APIKey\nfrom backend.app.models.settings import Settings\n\nDEFAULT_KIOSK_KEY_NAME = \"spoolbuddy-kiosk\"\n\n\nclass KioskBootstrapError(RuntimeError):\n    \"\"\"Raised when an existing kiosk key would be silently overwritten.\"\"\"\n\n\nasync def kiosk_bootstrap(\n    name: str,\n    *,\n    force: bool,\n    session_maker: async_sessionmaker | None = None,\n    ensure_schema: bool = True,\n) -> str:\n    \"\"\"Create (or rotate) an API key for the SpoolBuddy kiosk and return it.\n\n    The returned value is the one-time full key string; callers are responsible\n    for writing it somewhere secure — it cannot be retrieved again.\n    \"\"\"\n    if ensure_schema and session_maker is None:\n        await init_db()\n\n    maker = session_maker or default_session_maker\n\n    async with maker() as db:\n        existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none()\n\n        if existing and not force:\n            raise KioskBootstrapError(\n                f\"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate.\"\n            )\n\n        if existing:\n            await db.delete(existing)\n            await db.flush()\n\n        full_key, key_hash, key_prefix = generate_api_key()\n        row = APIKey(\n            name=name,\n            key_hash=key_hash,\n            key_prefix=key_prefix,\n            can_queue=False,\n            can_control_printer=False,\n            can_read_status=True,\n            printer_ids=None,\n            enabled=True,\n            expires_at=None,\n        )\n        db.add(row)\n\n        # Mark first-run setup as completed so the kiosk URL loads directly\n        # instead of being force-redirected to /setup by AuthContext. Without\n        # this, a bundled SpoolBuddy/Bambuddy install boots into the Bambuddy\n        # first-run wizard (touch-only Pi has no keyboard to complete it).\n        # Users who want authentication enable it later from the admin UI; the\n        # API key we just created is already valid so the kiosk keeps working.\n        await upsert_setting(db, Settings, \"setup_completed\", \"true\")\n\n        await db.commit()\n        return full_key\n\n\ndef main(argv: list[str] | None = None) -> int:\n    parser = argparse.ArgumentParser(\n        prog=\"python -m backend.app.cli\",\n        description=\"Bambuddy administrative commands\",\n    )\n    sub = parser.add_subparsers(dest=\"command\", required=True)\n\n    kiosk = sub.add_parser(\n        \"kiosk-bootstrap\",\n        help=\"Create an API key for the SpoolBuddy kiosk\",\n        description=(\n            \"Create (or rotate with --force) an API key scoped for the SpoolBuddy \"\n            \"kiosk. The full key is printed to stdout — capture it into \"\n            \"spoolbuddy/.env as SPOOLBUDDY_API_KEY.\"\n        ),\n    )\n    kiosk.add_argument(\n        \"--name\",\n        default=DEFAULT_KIOSK_KEY_NAME,\n        help=f\"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})\",\n    )\n    kiosk.add_argument(\n        \"--force\",\n        action=\"store_true\",\n        help=\"Rotate an existing key with the same name (deletes the old one)\",\n    )\n\n    args = parser.parse_args(argv)\n\n    if args.command == \"kiosk-bootstrap\":\n        try:\n            key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))\n        except KioskBootstrapError as exc:\n            print(str(exc), file=sys.stderr)\n            return 1\n        print(key)\n        return 0\n\n    return 2\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n"
  },
  {
    "path": "backend/app/core/__init__.py",
    "content": ""
  },
  {
    "path": "backend/app/core/auth.py",
    "content": "from __future__ import annotations\n\nimport logging\nimport os\nimport secrets\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\nfrom typing import Annotated\n\nimport jwt\nfrom fastapi import Depends, Header, HTTPException, status\nfrom fastapi.security import HTTPAuthorizationCredentials, HTTPBearer\nfrom jwt.exceptions import PyJWTError as JWTError\nfrom passlib.context import CryptContext\nfrom sqlalchemy import delete, func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.core.database import async_session, get_db\nfrom backend.app.core.permissions import Permission\nfrom backend.app.models.api_key import APIKey\nfrom backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.user import User\n\nlogger = logging.getLogger(__name__)\n\n# Password hashing\n# Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues\n# pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations\npwd_context = CryptContext(schemes=[\"pbkdf2_sha256\"], deprecated=\"auto\")\n\n\ndef _get_jwt_secret() -> str:\n    \"\"\"Get the JWT secret key from environment, file, or generate a new one.\n\n    Priority:\n    1. JWT_SECRET_KEY environment variable\n    2. .jwt_secret file in data directory\n    3. Generate new random secret and save to file\n\n    Returns:\n        The JWT secret key\n    \"\"\"\n    # 1. Check environment variable first\n    env_secret = os.environ.get(\"JWT_SECRET_KEY\")\n    if env_secret:\n        logger.info(\"Using JWT secret from JWT_SECRET_KEY environment variable\")\n        return env_secret\n\n    # 2. Check for secret file in data directory\n    # Use DATA_DIR env var (same as rest of app), fallback to data/ subdirectory\n    data_dir_env = os.environ.get(\"DATA_DIR\")\n    if data_dir_env:\n        data_dir = Path(data_dir_env)\n    else:\n        # Fallback to data/ subdirectory under project root (not project root itself!)\n        data_dir = Path(__file__).parent.parent.parent.parent / \"data\"\n    secret_file = data_dir / \".jwt_secret\"\n\n    if secret_file.exists():\n        try:\n            secret = secret_file.read_text().strip()\n            if secret and len(secret) >= 32:\n                logger.info(\"Using JWT secret from %s\", secret_file)\n                return secret\n        except OSError as e:\n            logger.warning(\"Failed to read JWT secret file: %s\", e)\n\n    # 3. Generate new random secret\n    new_secret = secrets.token_urlsafe(64)\n\n    # Try to save it\n    try:\n        data_dir.mkdir(parents=True, exist_ok=True)\n        # Note: CodeQL flags this as \"clear-text storage of sensitive information\" but this is\n        # intentional and secure - JWT secrets must be readable by the app, we set 0600 permissions,\n        # and this is standard practice for self-hosted applications (same as .env files).\n        secret_file.write_text(new_secret)  # nosec B105\n        # Restrict permissions (owner read/write only)\n        secret_file.chmod(0o600)\n        logger.info(\"Generated new JWT secret and saved to %s\", secret_file)\n    except OSError as e:\n        logger.warning(\n            \"Could not save JWT secret to file (%s). \"\n            \"Secret will be regenerated on restart, invalidating existing tokens. \"\n            \"Set JWT_SECRET_KEY environment variable for persistence.\",\n            e,\n        )\n\n    return new_secret\n\n\n# JWT settings\nSECRET_KEY = _get_jwt_secret()\nALGORITHM = \"HS256\"\nACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24  # 24 hours (M-2: reduced from 7 days)\n\n# HTTP Bearer token\nsecurity = HTTPBearer(auto_error=False)\n\n# --- Slicer download tokens ---\n# Short-lived, single-use tokens for slicer protocol handlers that can't send\n# auth headers.  Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)\n# so they survive server restarts and work in multi-worker deployments (M-3).\nSLICER_TOKEN_EXPIRE_MINUTES = 5\n\n\nasync def create_slicer_download_token(resource_type: str, resource_id: int) -> str:\n    \"\"\"Create a short-lived, single-use download token for slicer protocol handlers.\"\"\"\n    now = datetime.now(timezone.utc)\n    expires_at = now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES)\n    token = secrets.token_urlsafe(24)\n    resource_key = f\"{resource_type}:{resource_id}\"\n    async with async_session() as db:\n        # Prune expired tokens opportunistically\n        await db.execute(\n            delete(AuthEphemeralToken).where(\n                AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,\n                AuthEphemeralToken.expires_at < now,\n            )\n        )\n        db.add(\n            AuthEphemeralToken(\n                token=token,\n                token_type=TokenType.SLICER_DOWNLOAD,\n                nonce=resource_key,\n                expires_at=expires_at,\n            )\n        )\n        await db.commit()\n    return token\n\n\nasync def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:\n    \"\"\"Verify and atomically consume a slicer download token.\n\n    Returns True only if the token is valid, unexpired, and bound to the given resource.\n    DELETE...RETURNING ensures the token is single-use even under concurrent requests.\n\n    M-NEW-1 fix: nonce (resource key) is included in the WHERE clause so the DELETE\n    only succeeds when the token is presented to the *correct* resource endpoint.\n    Previously the token was consumed (committed) even when stored_key != expected_key,\n    permanently invalidating it while returning False to the caller.\n    \"\"\"\n    expected_key = f\"{resource_type}:{resource_id}\"\n    now = datetime.now(timezone.utc)\n    async with async_session() as db:\n        result = await db.execute(\n            delete(AuthEphemeralToken)\n            .where(\n                AuthEphemeralToken.token == token,\n                AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,\n                AuthEphemeralToken.nonce == expected_key,\n                AuthEphemeralToken.expires_at > now,\n            )\n            .returning(AuthEphemeralToken.id)\n        )\n        if result.one_or_none() is None:\n            return False\n        await db.commit()\n        return True\n\n\n# --- Camera stream tokens ---\n# Reusable tokens for camera stream/snapshot endpoints loaded via <img>/<video>\n# tags (these cannot send Authorization headers).  Unlike slicer tokens they are\n# NOT single-use — streams reconnect on errors.  Stored in AuthEphemeralToken\n# (token_type=\"camera_stream\") for multi-worker compatibility (M-3).\nCAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60\n\n\nasync def create_camera_stream_token() -> str:\n    \"\"\"Create a reusable token for camera stream/snapshot access.\"\"\"\n    now = datetime.now(timezone.utc)\n    expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)\n    token = secrets.token_urlsafe(24)\n    async with async_session() as db:\n        # Prune expired tokens opportunistically\n        await db.execute(\n            delete(AuthEphemeralToken).where(\n                AuthEphemeralToken.token_type == \"camera_stream\",\n                AuthEphemeralToken.expires_at < now,\n            )\n        )\n        db.add(\n            AuthEphemeralToken(\n                token=token,\n                token_type=\"camera_stream\",\n                expires_at=expires_at,\n            )\n        )\n        await db.commit()\n    return token\n\n\nasync def verify_camera_stream_token(token: str) -> bool:\n    \"\"\"Verify a camera stream token is valid (reusable — does not consume it).\"\"\"\n    now = datetime.now(timezone.utc)\n    async with async_session() as db:\n        result = await db.execute(\n            select(AuthEphemeralToken).where(\n                AuthEphemeralToken.token == token,\n                AuthEphemeralToken.token_type == \"camera_stream\",\n                AuthEphemeralToken.expires_at > now,\n            )\n        )\n        return result.scalar_one_or_none() is not None\n\n\ndef verify_password(plain_password: str, hashed_password: str) -> bool:\n    \"\"\"Verify a password against a hash.\n\n    Uses pbkdf2_sha256 which handles long passwords automatically.\n    \"\"\"\n    return pwd_context.verify(plain_password, hashed_password)\n\n\ndef get_password_hash(password: str) -> str:\n    \"\"\"Hash a password.\n\n    Uses pbkdf2_sha256 which is secure and has no password length limit.\n    \"\"\"\n    return pwd_context.hash(password)\n\n\ndef create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:\n    \"\"\"Create a JWT access token with jti (revocation) and iat (freshness) claims.\"\"\"\n    to_encode = data.copy()\n    now = datetime.now(timezone.utc)\n    if expires_delta:\n        expire = now + expires_delta\n    else:\n        expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)\n    jti = secrets.token_hex(16)\n    to_encode.update({\"exp\": expire, \"jti\": jti, \"iat\": now})\n    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)\n    return encoded_jwt\n\n\ndef _is_token_fresh(iat: int | float | None, user: User) -> bool:\n    \"\"\"Return False if the token was issued before the user's last password change.\n\n    Used to invalidate all sessions after a password reset/change (M-R7-B).\n    All tokens without an iat claim are unconditionally rejected — every token\n    issued by this server carries iat, so absence means the token is forged or\n    from a pre-iat code path whose max TTL (24 h) has long since expired.\n    \"\"\"\n    if iat is None:\n        return False\n    if not hasattr(user, \"password_changed_at\") or user.password_changed_at is None:\n        return True  # No password change recorded yet (I2 migration handles this)\n    token_issued_at = datetime.fromtimestamp(iat, tz=timezone.utc)\n    pca = user.password_changed_at\n    if pca.tzinfo is None:\n        pca = pca.replace(tzinfo=timezone.utc)\n    # JWT iat is whole seconds; truncate pca so tokens issued in the same second pass.\n    pca = pca.replace(microsecond=0)\n    return token_issued_at >= pca\n\n\nasync def revoke_jti(jti: str, expires_at: datetime, username: str | None = None) -> None:\n    \"\"\"Store a revoked JWT jti so it is rejected on future requests.\n\n    Silently ignores duplicate inserts (e.g. double-logout with the same token).\n    \"\"\"\n    from sqlalchemy.exc import IntegrityError\n\n    async with async_session() as db:\n        revoked = AuthEphemeralToken(\n            token=jti,\n            token_type=\"revoked_jti\",\n            username=username,\n            expires_at=expires_at,\n        )\n        db.add(revoked)\n        try:\n            await db.commit()\n        except IntegrityError:\n            await db.rollback()  # jti already revoked — desired state, ignore\n\n\nasync def is_jti_revoked(jti: str) -> bool:\n    \"\"\"Return True if the given jti has been revoked.\"\"\"\n    async with async_session() as db:\n        result = await db.execute(\n            select(AuthEphemeralToken).where(\n                AuthEphemeralToken.token == jti,\n                AuthEphemeralToken.token_type == \"revoked_jti\",\n            )\n        )\n        return result.scalar_one_or_none() is not None\n\n\nasync def get_user_by_username(db: AsyncSession, username: str) -> User | None:\n    \"\"\"Get a user by username (case-insensitive) with groups loaded for permission checks.\"\"\"\n    result = await db.execute(\n        select(User).where(func.lower(User.username) == func.lower(username)).options(selectinload(User.groups))\n    )\n    return result.scalar_one_or_none()\n\n\nasync def get_user_by_email(db: AsyncSession, email: str) -> User | None:\n    \"\"\"Get a user by email (case-insensitive) with groups loaded for permission checks.\"\"\"\n    result = await db.execute(\n        select(User).where(func.lower(User.email) == func.lower(email)).options(selectinload(User.groups))\n    )\n    return result.scalar_one_or_none()\n\n\nasync def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:\n    \"\"\"Authenticate a user by username and password.\n\n    Username lookup is case-insensitive. Password is case-sensitive.\n    LDAP and OIDC users must authenticate via their respective providers.\n    \"\"\"\n    user = await get_user_by_username(db, username)\n    if not user:\n        return None\n    if getattr(user, \"auth_source\", \"local\") in (\"ldap\", \"oidc\"):\n        return None  # LDAP/OIDC users must authenticate via their provider\n    if not user.password_hash or not verify_password(password, user.password_hash):\n        return None\n    if not user.is_active:\n        return None\n    return user\n\n\nasync def authenticate_user_by_email(db: AsyncSession, email: str, password: str) -> User | None:\n    \"\"\"Authenticate a user by email and password.\n\n    Email lookup is case-insensitive. Password is case-sensitive.\n    LDAP and OIDC users must authenticate via their respective providers.\n    \"\"\"\n    user = await get_user_by_email(db, email)\n    if not user:\n        return None\n    if getattr(user, \"auth_source\", \"local\") in (\"ldap\", \"oidc\"):\n        return None  # LDAP/OIDC users must authenticate via their provider\n    if not user.password_hash or not verify_password(password, user.password_hash):\n        return None\n    if not user.is_active:\n        return None\n    return user\n\n\nasync def is_auth_enabled(db: AsyncSession) -> bool:\n    \"\"\"Check if authentication is enabled.\"\"\"\n    try:\n        result = await db.execute(select(Settings).where(Settings.key == \"auth_enabled\"))\n        setting = result.scalar_one_or_none()\n        if setting is None:\n            return False\n        return setting.value.lower() == \"true\"\n    except Exception:\n        # If settings table doesn't exist or query fails, assume auth is disabled\n        return False\n\n\nasync def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:\n    \"\"\"Validate an API key and return the APIKey object if valid, None otherwise.\n\n    L-1: Pre-filter by key_prefix (first 8 chars) before running pbkdf2 so only\n    O(1) candidate rows are hashed instead of the full key table.  The prefix is\n    not secret (it is shown in the admin UI), so this does not reduce security.\n    \"\"\"\n    try:\n        # key_prefix is stored as \"<first-8-chars>...\" (e.g. \"bb_Abc12...\").\n        # Matching on the first 8 chars of the submitted key reduces the scan to\n        # at most one row in practice (2^40 collision space for 5 base64 chars).\n        key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value\n        result = await db.execute(\n            select(APIKey).where(\n                APIKey.enabled.is_(True),\n                APIKey.key_prefix.like(\n                    key_lookup.replace(\"\\\\\", \"\\\\\\\\\").replace(\"%\", \"\\\\%\").replace(\"_\", \"\\\\_\") + \"%\", escape=\"\\\\\"\n                ),\n            )\n        )\n        api_keys = result.scalars().all()\n\n        for api_key in api_keys:\n            if verify_password(api_key_value, api_key.key_hash):\n                # Check expiration\n                if api_key.expires_at:\n                    expires = api_key.expires_at\n                    if expires.tzinfo is None:\n                        expires = expires.replace(tzinfo=timezone.utc)\n                    if expires < datetime.now(timezone.utc):\n                        return None  # Expired\n                # Update last_used timestamp\n                api_key.last_used = datetime.now(timezone.utc)\n                await db.commit()\n                return api_key\n    except Exception as e:\n        logger.warning(\"API key validation error: %s\", e)\n    return None\n\n\nasync def get_current_user_optional(\n    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n) -> User | None:\n    \"\"\"Get the current authenticated user from JWT token, or None if not authenticated.\n\n    Returns None only when NO credentials are supplied.  If a token is supplied\n    but invalid/revoked, raises 401 — a revoked token must not grant anonymous\n    access (I6).\n    \"\"\"\n    if credentials is None:\n        return None\n\n    _unauthorized = HTTPException(\n        status_code=status.HTTP_401_UNAUTHORIZED,\n        detail=\"Could not validate credentials\",\n        headers={\"WWW-Authenticate\": \"Bearer\"},\n    )\n\n    try:\n        token = credentials.credentials\n        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n        username: str = payload.get(\"sub\")\n        if username is None:\n            raise _unauthorized\n        jti: str | None = payload.get(\"jti\")\n        if not jti or await is_jti_revoked(jti):\n            raise _unauthorized  # I6: revoked token → 401, not anonymous\n        iat: int | float | None = payload.get(\"iat\")\n    except JWTError:\n        raise _unauthorized\n\n    async with async_session() as db:\n        user = await get_user_by_username(db, username)\n        if user is None or not user.is_active:\n            raise _unauthorized\n        if not _is_token_fresh(iat, user):\n            raise _unauthorized\n        return user\n\n\nasync def get_current_user(\n    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n) -> User:\n    \"\"\"Get the current authenticated user from JWT token.\"\"\"\n    credentials_exception = HTTPException(\n        status_code=status.HTTP_401_UNAUTHORIZED,\n        detail=\"Could not validate credentials\",\n        headers={\"WWW-Authenticate\": \"Bearer\"},\n    )\n    if credentials is None:\n        raise credentials_exception\n    try:\n        token = credentials.credentials\n        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n        username: str = payload.get(\"sub\")\n        if username is None:\n            raise credentials_exception\n        jti: str | None = payload.get(\"jti\")\n        if not jti or await is_jti_revoked(jti):\n            raise credentials_exception\n        iat: int | float | None = payload.get(\"iat\")\n    except JWTError:\n        raise credentials_exception\n\n    async with async_session() as db:\n        user = await get_user_by_username(db, username)\n        if user is None:\n            raise credentials_exception\n        if not user.is_active:\n            raise HTTPException(\n                status_code=status.HTTP_403_FORBIDDEN,\n                detail=\"User account is disabled\",\n            )\n        if not _is_token_fresh(iat, user):\n            raise credentials_exception\n        return user\n\n\nasync def get_current_active_user(current_user: Annotated[User, Depends(get_current_user)]) -> User:\n    \"\"\"Get the current active user (alias for clarity).\"\"\"\n    return current_user\n\n\nasync def require_auth_if_enabled(\n    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n    x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n) -> User | None:\n    \"\"\"Require authentication if auth is enabled, otherwise return None.\n\n    Accepts both JWT tokens (via Authorization: Bearer header) and API keys\n    (via X-API-Key header or Authorization: Bearer bb_xxx).\n    \"\"\"\n    async with async_session() as db:\n        auth_enabled = await is_auth_enabled(db)\n        if not auth_enabled:\n            return None\n\n        # Check for API key first (X-API-Key header)\n        if x_api_key:\n            api_key = await _validate_api_key(db, x_api_key)\n            if api_key:\n                return None  # API key valid, allow access\n\n        # Check for Bearer token (could be JWT or API key)\n        if credentials is not None:\n            token = credentials.credentials\n            # Check if it's an API key (starts with bb_)\n            if token.startswith(\"bb_\"):\n                api_key = await _validate_api_key(db, token)\n                if api_key:\n                    return None  # API key valid, allow access\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED,\n                    detail=\"Invalid API key\",\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n\n            # Otherwise treat as JWT\n            try:\n                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n                username: str = payload.get(\"sub\")\n                if username is None:\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Could not validate credentials\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n                jti: str | None = payload.get(\"jti\")\n                if not jti or await is_jti_revoked(jti):\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Could not validate credentials\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n                iat: int | float | None = payload.get(\"iat\")\n            except JWTError:\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED,\n                    detail=\"Could not validate credentials\",\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n\n            user = await get_user_by_username(db, username)\n            if user is None or not user.is_active:\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED,\n                    detail=\"Could not validate credentials\",\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n            if not _is_token_fresh(iat, user):\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED,\n                    detail=\"Could not validate credentials\",\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n            return user\n\n        # No credentials provided\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"Authentication required\",\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n\ndef require_role(required_role: str):\n    \"\"\"Dependency factory for role-based access control.\"\"\"\n\n    async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:\n        if current_user.role != required_role:\n            raise HTTPException(\n                status_code=status.HTTP_403_FORBIDDEN,\n                detail=f\"Requires {required_role} role\",\n            )\n        return current_user\n\n    return role_checker\n\n\ndef require_admin_if_auth_enabled():\n    \"\"\"Dependency factory that requires admin role if auth is enabled.\"\"\"\n\n    async def admin_checker(\n        current_user: Annotated[User | None, Depends(require_auth_if_enabled)] = None,\n    ) -> User | None:\n        if current_user is None:\n            return None  # Auth not enabled, allow access\n        if current_user.role != \"admin\":\n            raise HTTPException(\n                status_code=status.HTTP_403_FORBIDDEN,\n                detail=\"Requires admin role\",\n            )\n        return current_user\n\n    return admin_checker\n\n\ndef generate_api_key() -> tuple[str, str, str]:\n    \"\"\"Generate a new API key.\n\n    Returns:\n        tuple: (full_key, key_hash, key_prefix)\n            - full_key: The complete API key (only shown once on creation)\n            - key_hash: Hashed version for storage and verification\n            - key_prefix: First 8 characters for display purposes\n    \"\"\"\n    # Generate a secure random API key (32 bytes = 64 hex characters)\n    full_key = f\"bb_{secrets.token_urlsafe(32)}\"\n    key_hash = get_password_hash(full_key)\n    key_prefix = full_key[:8] + \"...\" if len(full_key) > 8 else full_key\n    return full_key, key_hash, key_prefix\n\n\nasync def get_api_key(\n    authorization: Annotated[str | None, Header(alias=\"Authorization\")] = None,\n    x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n    db: AsyncSession = Depends(get_db),\n) -> APIKey:\n    \"\"\"Get and validate API key from request headers.\n\n    Checks both 'Authorization: Bearer <key>' and 'X-API-Key: <key>' headers.\n    \"\"\"\n    api_key_value = None\n    if x_api_key:\n        api_key_value = x_api_key\n    elif authorization and authorization.startswith(\"Bearer \"):\n        api_key_value = authorization.replace(\"Bearer \", \"\")\n\n    if not api_key_value:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED,\n            detail=\"API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'\",\n        )\n\n    # M-NEW-2: Pre-filter by key_prefix (first 8 chars) to avoid O(n) pbkdf2 over all\n    # enabled keys — same fix as in _validate_api_key (L-1 from previous review).\n    key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value\n    result = await db.execute(\n        select(APIKey).where(\n            APIKey.enabled.is_(True),\n            APIKey.key_prefix.like(\n                key_lookup.replace(\"\\\\\", \"\\\\\\\\\").replace(\"%\", \"\\\\%\").replace(\"_\", \"\\\\_\") + \"%\",\n                escape=\"\\\\\",\n            ),\n        )\n    )\n    api_keys = result.scalars().all()\n\n    for api_key in api_keys:\n        # Check if key matches (verify against hash)\n        if verify_password(api_key_value, api_key.key_hash):\n            # Check expiration\n            if api_key.expires_at:\n                expires = api_key.expires_at\n                if expires.tzinfo is None:\n                    expires = expires.replace(tzinfo=timezone.utc)\n                if expires < datetime.now(timezone.utc):\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"API key has expired\",\n                    )\n            # Update last_used timestamp\n            api_key.last_used = datetime.now(timezone.utc)\n            await db.commit()\n            return api_key\n\n    raise HTTPException(\n        status_code=status.HTTP_401_UNAUTHORIZED,\n        detail=\"Invalid API key\",\n    )\n\n\ndef check_permission(api_key: APIKey, permission: str) -> None:\n    \"\"\"Check if API key has the required permission.\n\n    Args:\n        api_key: The API key object\n        permission: One of 'queue', 'control_printer', 'read_status'\n\n    Raises:\n        HTTPException: If permission is not granted\n    \"\"\"\n    permission_map = {\n        \"queue\": \"can_queue\",\n        \"control_printer\": \"can_control_printer\",\n        \"read_status\": \"can_read_status\",\n    }\n\n    if permission not in permission_map:\n        raise HTTPException(\n            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,\n            detail=f\"Unknown permission: {permission}\",\n        )\n\n    attr_name = permission_map[permission]\n    if not getattr(api_key, attr_name, False):\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=f\"API key does not have '{permission}' permission\",\n        )\n\n\ndef check_printer_access(api_key: APIKey, printer_id: int) -> None:\n    \"\"\"Check if API key has access to the specified printer.\n\n    Args:\n        api_key: The API key object\n        printer_id: The printer ID to check access for\n\n    Raises:\n        HTTPException: If access is denied\n    \"\"\"\n    # None = global key, access to all printers\n    if api_key.printer_ids is None:\n        return\n\n    # Empty list or printer not in allowed list = no access\n    if printer_id not in api_key.printer_ids:\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=f\"API key does not have access to printer {printer_id}\",\n        )\n\n\n# Convenience dependencies - these are functions that return Depends objects\ndef RequireAdmin():\n    \"\"\"Dependency that requires admin role.\"\"\"\n    return Depends(require_role(\"admin\"))\n\n\ndef RequireAdminIfAuthEnabled():\n    \"\"\"Dependency that requires admin role if auth is enabled.\"\"\"\n    return Depends(require_admin_if_auth_enabled())\n\n\ndef require_permission(*permissions: str | Permission):\n    \"\"\"Dependency factory that requires user to have ALL specified permissions.\n\n    Accepts both JWT tokens (via Authorization: Bearer header) and API keys\n    (via X-API-Key header or Authorization: Bearer bb_xxx).\n\n    Args:\n        *permissions: Permission strings or Permission enum values to require\n\n    Returns:\n        A dependency function that validates permissions\n    \"\"\"\n    # Convert Permission enums to strings\n    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]\n\n    async def permission_checker(\n        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n        x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n    ) -> User | None:\n        async with async_session() as db:\n            # Check for API key first (X-API-Key header)\n            if x_api_key:\n                api_key = await _validate_api_key(db, x_api_key)\n                if api_key:\n                    return None  # API key valid, allow access\n\n            credentials_exception = HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Could not validate credentials\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n            if credentials is None:\n                raise credentials_exception\n\n            token = credentials.credentials\n            # Check if it's an API key (starts with bb_)\n            if token.startswith(\"bb_\"):\n                api_key = await _validate_api_key(db, token)\n                if api_key:\n                    return None  # API key valid, allow access\n                raise HTTPException(\n                    status_code=status.HTTP_401_UNAUTHORIZED,\n                    detail=\"Invalid API key\",\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n\n            # Otherwise treat as JWT\n            try:\n                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n                username: str = payload.get(\"sub\")\n                if username is None:\n                    raise credentials_exception\n                jti: str | None = payload.get(\"jti\")\n                if not jti or await is_jti_revoked(jti):\n                    raise credentials_exception\n                iat: int | float | None = payload.get(\"iat\")\n            except JWTError:\n                raise credentials_exception\n\n            user = await get_user_by_username(db, username)\n            if user is None or not user.is_active:\n                raise credentials_exception\n            if not _is_token_fresh(iat, user):\n                raise credentials_exception\n\n            if not user.has_all_permissions(*perm_strings):\n                raise HTTPException(\n                    status_code=status.HTTP_403_FORBIDDEN,\n                    detail=f\"Missing required permissions: {', '.join(perm_strings)}\",\n                )\n            return user\n\n    return permission_checker\n\n\ndef require_permission_if_auth_enabled(*permissions: str | Permission):\n    \"\"\"Dependency factory that checks permissions only if auth is enabled.\n\n    This provides backward compatibility - when auth is disabled, all access is allowed.\n    Accepts both JWT tokens (via Authorization: Bearer header) and API keys\n    (via X-API-Key header or Authorization: Bearer bb_xxx).\n\n    Args:\n        *permissions: Permission strings or Permission enum values to require\n\n    Returns:\n        A dependency function that validates permissions if auth is enabled\n    \"\"\"\n    # Convert Permission enums to strings\n    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]\n\n    async def permission_checker(\n        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n        x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n    ) -> User | None:\n        async with async_session() as db:\n            auth_enabled = await is_auth_enabled(db)\n            if not auth_enabled:\n                return None  # Auth disabled, allow access\n\n            # Check for API key first (X-API-Key header)\n            if x_api_key:\n                api_key = await _validate_api_key(db, x_api_key)\n                if api_key:\n                    return None  # API key valid, allow access\n\n            # Check for Bearer token (could be JWT or API key)\n            if credentials is not None:\n                token = credentials.credentials\n                # Check if it's an API key (starts with bb_)\n                if token.startswith(\"bb_\"):\n                    api_key = await _validate_api_key(db, token)\n                    if api_key:\n                        return None  # API key valid, allow access\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Invalid API key\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n\n                # Otherwise treat as JWT\n                try:\n                    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n                    username: str = payload.get(\"sub\")\n                    if username is None:\n                        raise HTTPException(\n                            status_code=status.HTTP_401_UNAUTHORIZED,\n                            detail=\"Could not validate credentials\",\n                            headers={\"WWW-Authenticate\": \"Bearer\"},\n                        )\n                    jti: str | None = payload.get(\"jti\")\n                    if not jti or await is_jti_revoked(jti):\n                        raise HTTPException(\n                            status_code=status.HTTP_401_UNAUTHORIZED,\n                            detail=\"Could not validate credentials\",\n                            headers={\"WWW-Authenticate\": \"Bearer\"},\n                        )\n                    iat: int | float | None = payload.get(\"iat\")\n                except JWTError:\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Could not validate credentials\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n\n                user = await get_user_by_username(db, username)\n                if user is None or not user.is_active:\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Could not validate credentials\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n                if not _is_token_fresh(iat, user):\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Could not validate credentials\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n\n                if not user.has_all_permissions(*perm_strings):\n                    raise HTTPException(\n                        status_code=status.HTTP_403_FORBIDDEN,\n                        detail=f\"Missing required permissions: {', '.join(perm_strings)}\",\n                    )\n                return user\n\n            # No credentials provided\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Authentication required\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n    return permission_checker\n\n\ndef RequirePermission(*permissions: str | Permission):\n    \"\"\"Convenience dependency that requires ALL specified permissions.\"\"\"\n    return Depends(require_permission(*permissions))\n\n\ndef RequirePermissionIfAuthEnabled(*permissions: str | Permission):\n    \"\"\"Convenience dependency that requires permissions if auth is enabled.\"\"\"\n    return Depends(require_permission_if_auth_enabled(*permissions))\n\n\ndef require_camera_stream_token_if_auth_enabled():\n    \"\"\"Dependency that validates a camera stream token query param when auth is enabled.\n\n    Used for camera stream/snapshot endpoints that are loaded via <img> tags\n    which cannot send Authorization headers. The frontend obtains a token from\n    POST /printers/camera/stream-token and appends it as ?token=xxx.\n    \"\"\"\n\n    async def checker(token: str | None = None) -> None:\n        async with async_session() as db:\n            if not await is_auth_enabled(db):\n                return  # Auth disabled, allow access\n        if not token or not await verify_camera_stream_token(token):\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token\",\n            )\n\n    return checker\n\n\nRequireCameraStreamTokenIfAuthEnabled = Depends(require_camera_stream_token_if_auth_enabled())\n\n\ndef require_ownership_permission(\n    all_permission: str | Permission,\n    own_permission: str | Permission,\n):\n    \"\"\"Dependency factory for ownership-based permission checks.\n\n    - User with `all_permission` can modify any item\n    - User with `own_permission` can only modify items where created_by_id == user.id\n    - Ownerless items (created_by_id = null) require `all_permission`\n    - API keys (via X-API-Key header or Bearer bb_xxx) get full access (can_modify_all=True)\n\n    Returns:\n        A dependency function that returns (user, can_modify_all).\n        - can_modify_all=True: user can modify any item\n        - can_modify_all=False: user can only modify their own items\n    \"\"\"\n    all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission\n    own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission\n\n    async def checker(\n        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,\n        x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n    ) -> tuple[User | None, bool]:\n        \"\"\"Returns (user, can_modify_all).\n\n        - can_modify_all=True: user can modify any item\n        - can_modify_all=False: user can only modify their own items\n        \"\"\"\n        async with async_session() as db:\n            auth_enabled = await is_auth_enabled(db)\n            if not auth_enabled:\n                return None, True  # Auth disabled, allow all\n\n            # Check for API key first (X-API-Key header)\n            if x_api_key:\n                api_key = await _validate_api_key(db, x_api_key)\n                if api_key:\n                    return None, True  # API key valid, allow all\n\n            # Check for Bearer token (could be JWT or API key)\n            if credentials is not None:\n                token = credentials.credentials\n                # Check if it's an API key (starts with bb_)\n                if token.startswith(\"bb_\"):\n                    api_key = await _validate_api_key(db, token)\n                    if api_key:\n                        return None, True  # API key valid, allow all\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Invalid API key\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n\n                # Otherwise treat as JWT\n                try:\n                    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n                    username: str = payload.get(\"sub\")\n                    if username is None:\n                        raise HTTPException(\n                            status_code=status.HTTP_401_UNAUTHORIZED,\n                            detail=\"Could not validate credentials\",\n                            headers={\"WWW-Authenticate\": \"Bearer\"},\n                        )\n                    jti: str | None = payload.get(\"jti\")\n                    if not jti or await is_jti_revoked(jti):\n                        raise HTTPException(\n                            status_code=status.HTTP_401_UNAUTHORIZED,\n                            detail=\"Could not validate credentials\",\n                            headers={\"WWW-Authenticate\": \"Bearer\"},\n                        )\n                    iat: int | float | None = payload.get(\"iat\")\n                except JWTError:\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Could not validate credentials\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n\n                user = await get_user_by_username(db, username)\n                if user is None or not user.is_active:\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Could not validate credentials\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n                if not _is_token_fresh(iat, user):\n                    raise HTTPException(\n                        status_code=status.HTTP_401_UNAUTHORIZED,\n                        detail=\"Could not validate credentials\",\n                        headers={\"WWW-Authenticate\": \"Bearer\"},\n                    )\n\n                if user.has_permission(all_perm):\n                    return user, True\n                if user.has_permission(own_perm):\n                    return user, False\n\n                raise HTTPException(\n                    status_code=status.HTTP_403_FORBIDDEN,\n                    detail=f\"Missing permission: {own_perm} or {all_perm}\",\n                )\n\n            # No credentials provided\n            raise HTTPException(\n                status_code=status.HTTP_401_UNAUTHORIZED,\n                detail=\"Authentication required\",\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n    return checker\n"
  },
  {
    "path": "backend/app/core/catalog_defaults.py",
    "content": "\"\"\"Default spool and color catalog entries.\"\"\"\n\n# (name, weight_in_grams)\nDEFAULT_SPOOL_CATALOG: list[tuple[str, int]] = [\n    (\"3D FilaPrint - Cardboard\", 210),\n    (\"3D FilaPrint - Plastic\", 238),\n    (\"3D Fuel - Plastic\", 264),\n    (\"3D Power - Plastic\", 220),\n    (\"3D Solutech - Plastic\", 173),\n    (\"3DE - Cardboard\", 136),\n    (\"3DE - Plastic\", 181),\n    (\"3DHOJOR - Cardboard\", 157),\n    (\"3DJake - Cardboard\", 209),\n    (\"3DJake - Plastic\", 232),\n    (\"3DJake 250g - Plastic\", 91),\n    (\"3DJake ecoPLA - Plastic\", 210),\n    (\"3DXTech - Plastic\", 258),\n    (\"Acccreate - Plastic\", 161),\n    (\"Amazon Basics - Plastic\", 234),\n    (\"Amolen - Plastic\", 150),\n    (\"AMZ3D - Plastic\", 233),\n    (\"Anycubic - Cardboard\", 125),\n    (\"Anycubic - Plastic\", 127),\n    (\"Atomic Filament - Plastic\", 272),\n    (\"Aurapol - Plastic\", 220),\n    (\"Azure Film - Plastic\", 163),\n    (\"Bambu Lab - Plastic High Temp\", 216),\n    (\"Bambu Lab - Plastic Low Temp\", 250),\n    (\"Bambu Lab - Plastic White\", 253),\n    (\"BQ - Plastic\", 218),\n    (\"Colorfabb - Plastic\", 236),\n    (\"Colorfabb 750g - Cardboard\", 152),\n    (\"Colorfabb 750g - Plastic\", 254),\n    (\"Comgrow - Cardboard\", 166),\n    (\"Creality - Cardboard\", 180),\n    (\"Creality - Plastic\", 135),\n    (\"Das Filament - Plastic\", 211),\n    (\"Devil Design - Plastic\", 256),\n    (\"Duramic 3D - Cardboard\", 136),\n    (\"Elegoo - Cardboard\", 153),\n    (\"Elegoo - Plastic\", 111),\n    (\"Eryone - Cardboard\", 156),\n    (\"Eryone - Plastic\", 187),\n    (\"eSUN - Cardboard\", 147),\n    (\"eSUN - Plastic\", 240),\n    (\"eSUN 2.5kg - Plastic\", 634),\n    (\"Extrudr - Plastic\", 244),\n    (\"Fiberlogy - Plastic\", 260),\n    (\"Filament PM - Plastic\", 224),\n    (\"Fillamentum - Plastic\", 230),\n    (\"Flashforge - Plastic\", 167),\n    (\"FormFutura - Cardboard\", 155),\n    (\"FormFutura 750g - Plastic\", 212),\n    (\"Geeetech - Plastic\", 178),\n    (\"Gembird - Cardboard\", 143),\n    (\"Hatchbox - Plastic\", 225),\n    (\"Inland - Cardboard\", 142),\n    (\"Inland - Plastic\", 210),\n    (\"Jayo - Cardboard\", 120),\n    (\"Jayo - Plastic\", 126),\n    (\"Jayo 250g - Plastic\", 58),\n    (\"Kingroon - Cardboard\", 155),\n    (\"Kingroon - Plastic\", 156),\n    (\"KVP - Plastic\", 263),\n    (\"Matter Hackers - Plastic\", 215),\n    (\"MG Chemicals - Cardboard\", 150),\n    (\"MG Chemicals - Plastic\", 239),\n    (\"Mika3D - Plastic\", 175),\n    (\"MonoPrice - Plastic\", 221),\n    (\"Overture - Cardboard\", 150),\n    (\"Overture - Plastic\", 237),\n    (\"PolyMaker - Cardboard\", 137),\n    (\"PolyMaker - Plastic\", 220),\n    (\"PolyMaker 3kg - Cardboard\", 418),\n    (\"PolyTerra PLA - Cardboard\", 147),\n    (\"PrimaSelect - Plastic\", 222),\n    (\"ProtoPasta - Cardboard\", 80),\n    (\"Prusament - Plastic\", 201),\n    (\"Prusament - Plastic w/ Cardboard Core\", 196),\n    (\"Rosa3D - Plastic\", 245),\n    (\"Sakata3D - Plastic\", 205),\n    (\"Snapmaker - Cardboard\", 148),\n    (\"Sovol - Cardboard\", 145),\n    (\"Spectrum - Cardboard\", 180),\n    (\"Spectrum - Plastic\", 257),\n    (\"Sunlu - Plastic\", 117),\n    (\"Sunlu - Plastic V2\", 165),\n    (\"Sunlu - Plastic V3\", 179),\n    (\"Sunlu 250g - Plastic\", 55),\n    (\"UltiMaker - Plastic\", 235),\n    (\"Voolt3D - Plastic\", 190),\n    (\"Voxelab - Plastic\", 171),\n    (\"Wanhao - Plastic\", 267),\n    (\"Ziro - Plastic\", 166),\n    (\"ZYLtech - Plastic\", 179),\n]\n\n# (manufacturer, color_name, hex_color, material)\nDEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [\n    # Bambu Lab PLA Basic (from official hex code PDF)\n    (\"Bambu Lab\", \"Jade White\", \"#FFFFFF\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Black\", \"#000000\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Silver\", \"#A6A9AA\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Light Gray\", \"#D1D3D5\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Gray\", \"#8E9089\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Dark Gray\", \"#545454\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Red\", \"#C12E1F\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Maroon Red\", \"#9D2235\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Magenta\", \"#EC008C\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Hot Pink\", \"#F5547C\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Pink\", \"#F55A74\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Beige\", \"#F7E6DE\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Yellow\", \"#F4EE2A\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Sunflower Yellow\", \"#FEC600\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Gold\", \"#E4BD68\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Orange\", \"#FF6A13\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Pumpkin Orange\", \"#FF9016\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Bright Green\", \"#BECF00\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Bambu Green\", \"#00AE42\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Mistletoe Green\", \"#3F8E43\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Turquoise\", \"#00B1B7\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Cyan\", \"#0086D6\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Blue\", \"#0A2989\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Blue Grey\", \"#5B6579\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Cobalt Blue\", \"#0056B8\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Purple\", \"#5E43B7\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Indigo Purple\", \"#482960\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Brown\", \"#9D432C\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Cocoa Brown\", \"#6F5034\", \"PLA Basic\"),\n    (\"Bambu Lab\", \"Bronze\", \"#847D48\", \"PLA Basic\"),\n    # Bambu Lab PLA Matte (from official hex code PDF)\n    (\"Bambu Lab\", \"Ivory White\", \"#FFFFFF\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Bone White\", \"#CBC6B8\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Desert Tan\", \"#E8DBB7\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Latte Brown\", \"#D3B7A7\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Caramel\", \"#AE835B\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Terracotta\", \"#B15533\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Dark Brown\", \"#7D6556\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Dark Chocolate\", \"#4D3324\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Lilac Purple\", \"#AE96D4\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Sakura Pink\", \"#E8AFCF\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Mandarin Orange\", \"#F99963\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Lemon Yellow\", \"#F7D959\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Plum\", \"#950051\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Scarlet Red\", \"#DE4343\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Dark Red\", \"#BB3D43\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Dark Green\", \"#68724D\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Grass Green\", \"#61C680\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Apple Green\", \"#C2E189\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Ice Blue\", \"#A3D8E1\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Sky Blue\", \"#56B7E6\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Marine Blue\", \"#0078BF\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Dark Blue\", \"#042F56\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Ash Gray\", \"#9B9EA0\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Nardo Gray\", \"#757575\", \"PLA Matte\"),\n    (\"Bambu Lab\", \"Charcoal\", \"#000000\", \"PLA Matte\"),\n    # Bambu Lab PLA Silk+ (from store page)\n    (\"Bambu Lab\", \"Gold\", \"#F4A925\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Silver\", \"#C8C8C8\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Titan Gray\", \"#5F6367\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Blue\", \"#008BDA\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Purple\", \"#8671CB\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Candy Red\", \"#D02727\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Candy Green\", \"#018814\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Rose Gold\", \"#BA9594\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Baby Blue\", \"#A8C6EE\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Pink\", \"#F7ADA6\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Mint\", \"#96DCB9\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"Champagne\", \"#F3CFB2\", \"PLA Silk\"),\n    (\"Bambu Lab\", \"White\", \"#FFFFFF\", \"PLA Silk\"),\n    # Bambu Lab PLA Sparkle (from store page)\n    (\"Bambu Lab\", \"Classic Gold Sparkle\", \"#CEA629\", \"PLA Sparkle\"),\n    (\"Bambu Lab\", \"Slate Gray Sparkle\", \"#8E9089\", \"PLA Sparkle\"),\n    (\"Bambu Lab\", \"Crimson Red Sparkle\", \"#792B36\", \"PLA Sparkle\"),\n    (\"Bambu Lab\", \"Royal Purple Sparkle\", \"#483D8B\", \"PLA Sparkle\"),\n    (\"Bambu Lab\", \"Alpine Green Sparkle\", \"#3F5443\", \"PLA Sparkle\"),\n    (\"Bambu Lab\", \"Onyx Black Sparkle\", \"#2D2B28\", \"PLA Sparkle\"),\n    # Bambu Lab PLA Translucent (from official hex code PDF)\n    (\"Bambu Lab\", \"Teal\", \"#009FA1\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Light Jade\", \"#96D8AF\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Blue\", \"#0047BB\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Mellow Yellow\", \"#F5DBAB\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Purple\", \"#8344B0\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Cherry Pink\", \"#F5B6CD\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Orange\", \"#F74E02\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Ice Blue\", \"#B8CDE9\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Red\", \"#B50011\", \"PLA Translucent\"),\n    (\"Bambu Lab\", \"Lavender\", \"#B8ACD6\", \"PLA Translucent\"),\n    # Bambu Lab PLA Glow (from store page)\n    (\"Bambu Lab\", \"Glow Green\", \"#A1FFAC\", \"PLA Glow\"),\n    (\"Bambu Lab\", \"Glow Yellow\", \"#F8FF80\", \"PLA Glow\"),\n    (\"Bambu Lab\", \"Glow Pink\", \"#F17B8F\", \"PLA Glow\"),\n    (\"Bambu Lab\", \"Glow Blue\", \"#7AC0E9\", \"PLA Glow\"),\n    (\"Bambu Lab\", \"Glow Orange\", \"#FF9D5B\", \"PLA Glow\"),\n    # Bambu Lab PLA Galaxy (from store page)\n    (\"Bambu Lab\", \"Brown\", \"#684A43\", \"PLA Galaxy\"),\n    (\"Bambu Lab\", \"Green\", \"#3B665E\", \"PLA Galaxy\"),\n    (\"Bambu Lab\", \"Nebulae\", \"#424379\", \"PLA Galaxy\"),\n    (\"Bambu Lab\", \"Purple\", \"#594177\", \"PLA Galaxy\"),\n    # Bambu Lab PLA Metal (from store page)\n    (\"Bambu Lab\", \"Iridium Gold Metallic\", \"#B39B84\", \"PLA Metal\"),\n    (\"Bambu Lab\", \"Copper Brown Metallic\", \"#AA6443\", \"PLA Metal\"),\n    (\"Bambu Lab\", \"Oxide Green Metallic\", \"#1D7C6A\", \"PLA Metal\"),\n    (\"Bambu Lab\", \"Cobalt Blue Metallic\", \"#39699E\", \"PLA Metal\"),\n    (\"Bambu Lab\", \"Iron Gray Metallic\", \"#43403D\", \"PLA Metal\"),\n    # Bambu Lab PLA Marble (from store page)\n    (\"Bambu Lab\", \"White Marble\", \"#F7F3F0\", \"PLA Marble\"),\n    (\"Bambu Lab\", \"Red Granite\", \"#AD4E38\", \"PLA Marble\"),\n    # Bambu Lab PLA Wood (from store page)\n    (\"Bambu Lab\", \"Black Walnut\", \"#4F3F24\", \"PLA Wood\"),\n    (\"Bambu Lab\", \"Rosewood\", \"#4C241C\", \"PLA Wood\"),\n    (\"Bambu Lab\", \"Clay Brown\", \"#995F11\", \"PLA Wood\"),\n    (\"Bambu Lab\", \"Classic Birch\", \"#918669\", \"PLA Wood\"),\n    (\"Bambu Lab\", \"White Oak\", \"#D6CCA3\", \"PLA Wood\"),\n    (\"Bambu Lab\", \"Ochre Yellow\", \"#C98935\", \"PLA Wood\"),\n    # Bambu Lab PLA Tough+ (from official hex code PDF)\n    (\"Bambu Lab\", \"White\", \"#FFFFFF\", \"PLA Tough\"),\n    (\"Bambu Lab\", \"Gray\", \"#AFB1AE\", \"PLA Tough\"),\n    (\"Bambu Lab\", \"Black\", \"#000000\", \"PLA Tough\"),\n    (\"Bambu Lab\", \"Silver\", \"#959698\", \"PLA Tough\"),\n    (\"Bambu Lab\", \"Yellow\", \"#F4D53F\", \"PLA Tough\"),\n    (\"Bambu Lab\", \"Cyan\", \"#009BD8\", \"PLA Tough\"),\n    (\"Bambu Lab\", \"Orange\", \"#DC3A27\", \"PLA Tough\"),\n    # Bambu Lab PLA-CF (from official hex code PDF)\n    (\"Bambu Lab\", \"Burgundy Red\", \"#951E23\", \"PLA-CF\"),\n    (\"Bambu Lab\", \"Iris Purple\", \"#69398E\", \"PLA-CF\"),\n    (\"Bambu Lab\", \"Matcha Green\", \"#5C9748\", \"PLA-CF\"),\n    (\"Bambu Lab\", \"Jeans Blue\", \"#6E88BC\", \"PLA-CF\"),\n    (\"Bambu Lab\", \"Royal Blue\", \"#2842AD\", \"PLA-CF\"),\n    (\"Bambu Lab\", \"Lava Gray\", \"#4D5054\", \"PLA-CF\"),\n    (\"Bambu Lab\", \"Black\", \"#000000\", \"PLA-CF\"),\n    # Bambu Lab ABS (from official hex code PDF)\n    (\"Bambu Lab\", \"White\", \"#FFFFFF\", \"ABS\"),\n    (\"Bambu Lab\", \"Desert Tan\", \"#E8DBB7\", \"ABS\"),\n    (\"Bambu Lab\", \"Olive\", \"#789D4A\", \"ABS\"),\n    (\"Bambu Lab\", \"Azure\", \"#489FDF\", \"ABS\"),\n    (\"Bambu Lab\", \"Navy Blue\", \"#0C2340\", \"ABS\"),\n    (\"Bambu Lab\", \"Blue\", \"#0A2CA5\", \"ABS\"),\n    (\"Bambu Lab\", \"Tangerine Yellow\", \"#FFC72C\", \"ABS\"),\n    (\"Bambu Lab\", \"Orange\", \"#FF6A13\", \"ABS\"),\n    (\"Bambu Lab\", \"Red\", \"#D32941\", \"ABS\"),\n    (\"Bambu Lab\", \"Purple\", \"#AF1685\", \"ABS\"),\n    (\"Bambu Lab\", \"Silver\", \"#87909A\", \"ABS\"),\n    (\"Bambu Lab\", \"Black\", \"#000000\", \"ABS\"),\n    # Bambu Lab ASA (from store page)\n    (\"Bambu Lab\", \"White\", \"#FFFAF2\", \"ASA\"),\n    (\"Bambu Lab\", \"Gray\", \"#8A949E\", \"ASA\"),\n    (\"Bambu Lab\", \"Red\", \"#E02928\", \"ASA\"),\n    (\"Bambu Lab\", \"Green\", \"#00A6A0\", \"ASA\"),\n    (\"Bambu Lab\", \"Blue\", \"#2140B4\", \"ASA\"),\n    (\"Bambu Lab\", \"Black\", \"#000000\", \"ASA\"),\n    # Bambu Lab PETG HF (from store page)\n    (\"Bambu Lab\", \"Yellow\", \"#FFD00B\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Orange\", \"#F75403\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Green\", \"#00AE42\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Red\", \"#EB3A3A\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Blue\", \"#002E96\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Black\", \"#000000\", \"PETG HF\"),\n    (\"Bambu Lab\", \"White\", \"#FFFFFF\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Cream\", \"#F9DFB9\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Lime Green\", \"#6EE53C\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Forest Green\", \"#39541A\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Lake Blue\", \"#1F79E5\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Peanut Brown\", \"#875718\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Gray\", \"#ADB1B2\", \"PETG HF\"),\n    (\"Bambu Lab\", \"Dark Gray\", \"#515151\", \"PETG HF\"),\n    # Bambu Lab PETG Translucent (from store page)\n    (\"Bambu Lab\", \"Translucent Gray\", \"#8E8E8E\", \"PETG Translucent\"),\n    (\"Bambu Lab\", \"Translucent Light Blue\", \"#61B0FF\", \"PETG Translucent\"),\n    (\"Bambu Lab\", \"Translucent Olive\", \"#748C45\", \"PETG Translucent\"),\n    (\"Bambu Lab\", \"Translucent Brown\", \"#C9A381\", \"PETG Translucent\"),\n    (\"Bambu Lab\", \"Translucent Teal\", \"#77EDD7\", \"PETG Translucent\"),\n    (\"Bambu Lab\", \"Translucent Orange\", \"#FF911A\", \"PETG Translucent\"),\n    (\"Bambu Lab\", \"Translucent Purple\", \"#D6ABFF\", \"PETG Translucent\"),\n    (\"Bambu Lab\", \"Translucent Pink\", \"#F9C1BD\", \"PETG Translucent\"),\n    # Bambu Lab PETG-CF (from official hex code PDF)\n    (\"Bambu Lab\", \"Brick Red\", \"#9F332A\", \"PETG-CF\"),\n    (\"Bambu Lab\", \"Violet Purple\", \"#583061\", \"PETG-CF\"),\n    (\"Bambu Lab\", \"Indigo Blue\", \"#324585\", \"PETG-CF\"),\n    (\"Bambu Lab\", \"Malachite Green\", \"#16B08E\", \"PETG-CF\"),\n    (\"Bambu Lab\", \"Black\", \"#000000\", \"PETG-CF\"),\n    (\"Bambu Lab\", \"Titan Gray\", \"#565656\", \"PETG-CF\"),\n    # Bambu Lab TPU 95A HF (from store page)\n    (\"Bambu Lab\", \"White\", \"#FFFFFF\", \"TPU 95A\"),\n    (\"Bambu Lab\", \"Yellow\", \"#F3E600\", \"TPU 95A\"),\n    (\"Bambu Lab\", \"Blue\", \"#0072CE\", \"TPU 95A\"),\n    (\"Bambu Lab\", \"Red\", \"#C8102E\", \"TPU 95A\"),\n    (\"Bambu Lab\", \"Gray\", \"#898D8D\", \"TPU 95A\"),\n    (\"Bambu Lab\", \"Black\", \"#101820\", \"TPU 95A\"),\n    # Bambu Lab TPU 90A (from official hex code PDF)\n    (\"Bambu Lab\", \"Black\", \"#000000\", \"TPU 90A\"),\n    (\"Bambu Lab\", \"White\", \"#FFFFFF\", \"TPU 90A\"),\n    (\"Bambu Lab\", \"Grape Jelly\", \"#D6ABFF\", \"TPU 90A\"),\n    (\"Bambu Lab\", \"Crystal Blue\", \"#7EB4E1\", \"TPU 90A\"),\n    (\"Bambu Lab\", \"Cocoa Brown\", \"#5C4738\", \"TPU 90A\"),\n    # Bambu Lab PAHT-CF\n    (\"Bambu Lab\", \"Black\", \"#1A1A1A\", \"PAHT-CF\"),\n    # Bambu Lab Support Materials\n    (\"Bambu Lab\", \"Natural\", \"#F5F5DC\", \"PLA Support\"),\n    (\"Bambu Lab\", \"Natural\", \"#F5F5DC\", \"PVA Support\"),\n    # Polymaker PolyTerra PLA\n    (\"Polymaker\", \"Cotton White\", \"#F5F5F5\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Charcoal Black\", \"#2B2B2B\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Marble White\", \"#E8E8E8\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Fossil Grey\", \"#6B6B6B\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Shadow Black\", \"#1A1A1A\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Army Red\", \"#8B0000\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Lava Red\", \"#CF1020\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Sakura Pink\", \"#FFB7C5\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Rose\", \"#FF007F\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Peach\", \"#FFCBA4\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Banana\", \"#FFE135\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Savannah Yellow\", \"#F4C430\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Sunrise Orange\", \"#FF6600\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Muted Green\", \"#4F7942\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Forest Green\", \"#228B22\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Mint\", \"#98FF98\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Lavender Purple\", \"#B57EDC\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Sapphire Blue\", \"#0F52BA\", \"PolyTerra PLA\"),\n    (\"Polymaker\", \"Ice\", \"#D6ECEF\", \"PolyTerra PLA\"),\n    # Prusament PLA\n    (\"Prusament\", \"Jet Black\", \"#1A1A1A\", \"PLA\"),\n    (\"Prusament\", \"Galaxy Black\", \"#1F1F1F\", \"PLA\"),\n    (\"Prusament\", \"Pristine White\", \"#FFFFFF\", \"PLA\"),\n    (\"Prusament\", \"Gentleman's Grey\", \"#5A5A5A\", \"PLA\"),\n    (\"Prusament\", \"Lipstick Red\", \"#C21E1E\", \"PLA\"),\n    (\"Prusament\", \"Orange\", \"#FF6600\", \"PLA\"),\n    (\"Prusament\", \"Pineapple Yellow\", \"#FFD700\", \"PLA\"),\n    (\"Prusament\", \"Jungle Green\", \"#29AB87\", \"PLA\"),\n    (\"Prusament\", \"Azure Blue\", \"#007FFF\", \"PLA\"),\n    (\"Prusament\", \"Royal Blue\", \"#4169E1\", \"PLA\"),\n    (\"Prusament\", \"Mystic Purple\", \"#7B68EE\", \"PLA\"),\n    # eSUN PLA+ (from FilamentColors.xyz measured swatches)\n    (\"eSUN\", \"Beige\", \"#ECCAB0\", \"PLA+\"),\n    (\"eSUN\", \"Black\", \"#373838\", \"PLA+\"),\n    (\"eSUN\", \"Blue\", \"#054795\", \"PLA+\"),\n    (\"eSUN\", \"Bone White\", \"#C2BAA7\", \"PLA+\"),\n    (\"eSUN\", \"Brown\", \"#6F513C\", \"PLA+\"),\n    (\"eSUN\", \"Cool White\", \"#E1E4E5\", \"PLA+\"),\n    (\"eSUN\", \"Dark Blue\", \"#2F314D\", \"PLA+\"),\n    (\"eSUN\", \"Fire Engine Red\", \"#91202B\", \"PLA+\"),\n    (\"eSUN\", \"Gold\", \"#C99B26\", \"PLA+\"),\n    (\"eSUN\", \"Gray\", \"#697480\", \"PLA+\"),\n    (\"eSUN\", \"Green\", \"#015E58\", \"PLA+\"),\n    (\"eSUN\", \"Grey\", \"#5F6574\", \"PLA+\"),\n    (\"eSUN\", \"Light Blue\", \"#48BFD5\", \"PLA+\"),\n    (\"eSUN\", \"Light Brown\", \"#A27556\", \"PLA+\"),\n    (\"eSUN\", \"Luminous Blue\", \"#C8CAC8\", \"PLA+\"),\n    (\"eSUN\", \"Magenta\", \"#DA3B6C\", \"PLA+\"),\n    (\"eSUN\", \"Olive Green\", \"#555B45\", \"PLA+\"),\n    (\"eSUN\", \"Orange\", \"#EF7749\", \"PLA+\"),\n    (\"eSUN\", \"Peak Green\", \"#A1DA7C\", \"PLA+\"),\n    (\"eSUN\", \"Pink\", \"#E78397\", \"PLA+\"),\n    (\"eSUN\", \"Purple\", \"#8350A4\", \"PLA+\"),\n    (\"eSUN\", \"Red\", \"#C4402A\", \"PLA+\"),\n    (\"eSUN\", \"Silver\", \"#8B8889\", \"PLA+\"),\n    (\"eSUN\", \"Skin\", \"#E3C7AF\", \"PLA+\"),\n    (\"eSUN\", \"White\", \"#E1E9E9\", \"PLA+\"),\n    (\"eSUN\", \"Yellow\", \"#FBCE2B\", \"PLA+\"),\n    # eSUN Pro PLA+\n    (\"eSUN\", \"Blue\", \"#065AA1\", \"Pro PLA+\"),\n    # eSUN PLA\n    (\"eSUN\", \"Glow in the Dark\", \"#C5C2AB\", \"PLA\"),\n    (\"eSUN\", \"Marble White\", \"#B5BCC0\", \"PLA\"),\n    (\"eSUN\", \"Natural Wood\", \"#EBCFA6\", \"PLA\"),\n    (\"eSUN\", \"Pine Green\", \"#375C49\", \"PLA\"),\n    (\"eSUN\", \"UV Change Purple\", \"#CABBA9\", \"PLA\"),\n    (\"eSUN\", \"eTwinkling Blue\", \"#115CAF\", \"PLA\"),\n    (\"eSUN\", \"eStars Galaxy Black\", \"#403936\", \"PLA\"),\n    # eSUN PLA Silk\n    (\"eSUN\", \"Silk Blue\", \"#2275AA\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Bronze\", \"#829172\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Copper\", \"#AE6B2F\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Cyan\", \"#34A7CF\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Dark Yellow\", \"#D4A62E\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Gold\", \"#C48E2F\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Green\", \"#7FCB43\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Jacinth\", \"#DA8061\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Lime\", \"#C1D762\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Magic Green Blue\", \"#508669\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Purple\", \"#905295\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Red\", \"#C94830\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Rose Gold\", \"#C7886B\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Silver\", \"#B5C1C5\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Violet\", \"#B93CA1\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk White\", \"#E3E0DB\", \"PLA Silk\"),\n    (\"eSUN\", \"Silk Yellow\", \"#DED74B\", \"PLA Silk\"),\n    # eSUN PLA Metal\n    (\"eSUN\", \"Bronze\", \"#917F57\", \"PLA Metal\"),\n    # eSUN PLA-ST\n    (\"eSUN\", \"Grey\", \"#626C77\", \"PLA-ST\"),\n    # eSUN PETG\n    (\"eSUN\", \"Black\", \"#353434\", \"PETG\"),\n    (\"eSUN\", \"Magenta\", \"#E03E76\", \"PETG\"),\n    (\"eSUN\", \"Solid Blue\", \"#1A6FB4\", \"PETG\"),\n    (\"eSUN\", \"Solid Green\", \"#008A58\", \"PETG\"),\n    (\"eSUN\", \"Solid Purple\", \"#7A4795\", \"PETG\"),\n    (\"eSUN\", \"Solid White\", \"#F4F1F1\", \"PETG\"),\n    (\"eSUN\", \"Solid Yellow\", \"#F0CA41\", \"PETG\"),\n    (\"eSUN\", \"Translucent Green\", \"#378041\", \"PETG\"),\n    (\"eSUN\", \"Translucent Orange\", \"#DD7135\", \"PETG\"),\n    (\"eSUN\", \"White\", \"#E7EDED\", \"PETG\"),\n    # eSUN PETG-HS (High Speed)\n    (\"eSUN\", \"Black\", \"#424445\", \"PETG-HS\"),\n    (\"eSUN\", \"Solid Blue\", \"#1A6FB4\", \"PETG-HS\"),\n    # eSUN ABS\n    (\"eSUN\", \"Black\", \"#3F3A3F\", \"ABS\"),\n    (\"eSUN\", \"Brown\", \"#624741\", \"ABS\"),\n    (\"eSUN\", \"Natural\", \"#D9E3DD\", \"ABS\"),\n    (\"eSUN\", \"Pine Green\", \"#3C694E\", \"ABS\"),\n    (\"eSUN\", \"Pink\", \"#E86477\", \"ABS\"),\n    (\"eSUN\", \"Red\", \"#A74237\", \"ABS\"),\n    (\"eSUN\", \"Silver\", \"#838080\", \"ABS\"),\n    # eSUN ABS+\n    (\"eSUN\", \"Gray\", \"#616777\", \"ABS+\"),\n    (\"eSUN\", \"Green\", \"#018068\", \"ABS+\"),\n    (\"eSUN\", \"Natural\", \"#E4DEC9\", \"ABS+\"),\n    (\"eSUN\", \"Orange\", \"#EE7845\", \"ABS+\"),\n    (\"eSUN\", \"Silver\", \"#7F807E\", \"ABS+\"),\n    (\"eSUN\", \"White\", \"#E1E1DF\", \"ABS+\"),\n    (\"eSUN\", \"Yellow\", \"#D3BC0F\", \"ABS+\"),\n    # Hatchbox PLA\n    (\"Hatchbox\", \"White\", \"#FFFFFF\", \"PLA\"),\n    (\"Hatchbox\", \"Black\", \"#000000\", \"PLA\"),\n    (\"Hatchbox\", \"Gray\", \"#808080\", \"PLA\"),\n    (\"Hatchbox\", \"Red\", \"#FF0000\", \"PLA\"),\n    (\"Hatchbox\", \"Blue\", \"#0000FF\", \"PLA\"),\n    (\"Hatchbox\", \"Green\", \"#00FF00\", \"PLA\"),\n    (\"Hatchbox\", \"Yellow\", \"#FFFF00\", \"PLA\"),\n    (\"Hatchbox\", \"Orange\", \"#FFA500\", \"PLA\"),\n    (\"Hatchbox\", \"Purple\", \"#800080\", \"PLA\"),\n    (\"Hatchbox\", \"Pink\", \"#FFC0CB\", \"PLA\"),\n    (\"Hatchbox\", \"True Blue\", \"#0073CF\", \"PLA\"),\n    (\"Hatchbox\", \"True Green\", \"#008000\", \"PLA\"),\n    # Overture PLA (from FilamentColors.xyz measured swatches)\n    (\"Overture\", \"Black\", \"#2B292E\", \"PLA\"),\n    (\"Overture\", \"Blue\", \"#034070\", \"PLA\"),\n    (\"Overture\", \"Cement Gray\", \"#48494A\", \"PLA\"),\n    (\"Overture\", \"Dark Blue\", \"#124775\", \"PLA\"),\n    (\"Overture\", \"Fresh Red\", \"#C01F1D\", \"PLA\"),\n    (\"Overture\", \"Gray Blue\", \"#6D8790\", \"PLA\"),\n    (\"Overture\", \"Green\", \"#318C49\", \"PLA\"),\n    (\"Overture\", \"Highlight Yellow\", \"#FBF93C\", \"PLA\"),\n    (\"Overture\", \"Light Blue\", \"#7CC4D5\", \"PLA\"),\n    (\"Overture\", \"Light Gray\", \"#8F9694\", \"PLA\"),\n    (\"Overture\", \"Neon Green Air\", \"#C5ED33\", \"PLA\"),\n    (\"Overture\", \"Olive Green\", \"#8F843D\", \"PLA\"),\n    (\"Overture\", \"Pink\", \"#DC99B4\", \"PLA\"),\n    (\"Overture\", \"Red\", \"#C9341A\", \"PLA\"),\n    (\"Overture\", \"Royal Gold\", \"#C58F31\", \"PLA\"),\n    (\"Overture\", \"Space Grey\", \"#797779\", \"PLA\"),\n    (\"Overture\", \"White\", \"#E7EBE3\", \"PLA\"),\n    # Overture PLA Matte\n    (\"Overture\", \"Black\", \"#3F3E41\", \"PLA Matte\"),\n    (\"Overture\", \"Blue\", \"#277EAB\", \"PLA Matte\"),\n    (\"Overture\", \"Brick Red\", \"#AE4848\", \"PLA Matte\"),\n    (\"Overture\", \"Green\", \"#5EAE73\", \"PLA Matte\"),\n    (\"Overture\", \"Light Grey\", \"#919598\", \"PLA Matte\"),\n    (\"Overture\", \"Light Brown\", \"#BF9C80\", \"PLA Matte\"),\n    (\"Overture\", \"Light Green\", \"#A1C1A5\", \"PLA Matte\"),\n    (\"Overture\", \"Olive Green\", \"#B59837\", \"PLA Matte\"),\n    (\"Overture\", \"Orange\", \"#F59752\", \"PLA Matte\"),\n    (\"Overture\", \"Pink\", \"#EBBDCE\", \"PLA Matte\"),\n    (\"Overture\", \"Purple\", \"#978DC5\", \"PLA Matte\"),\n    (\"Overture\", \"White\", \"#E1E4DD\", \"PLA Matte\"),\n    (\"Overture\", \"Yellow\", \"#FFD359\", \"PLA Matte\"),\n    # Overture PLA Pro\n    (\"Overture\", \"Digital Blue\", \"#008FBE\", \"PLA Pro\"),\n    (\"Overture\", \"Light Blue\", \"#68C8DB\", \"PLA Pro\"),\n    (\"Overture\", \"Orange\", \"#F27C1B\", \"PLA Pro\"),\n    (\"Overture\", \"Purple\", \"#7B5DB0\", \"PLA Pro\"),\n    (\"Overture\", \"Red\", \"#E62F18\", \"PLA Pro\"),\n    (\"Overture\", \"Yellow\", \"#DFB233\", \"PLA Pro\"),\n    # Overture PETG\n    (\"Overture\", \"Black\", \"#2F2821\", \"PETG\"),\n    (\"Overture\", \"Blue\", \"#225291\", \"PETG\"),\n    (\"Overture\", \"Clear\", \"#BEC3C5\", \"PETG\"),\n    (\"Overture\", \"Pink\", \"#E0A1BA\", \"PETG\"),\n    (\"Overture\", \"Purple\", \"#67518F\", \"PETG\"),\n    (\"Overture\", \"Rock White\", \"#C2C8C9\", \"PETG\"),\n    (\"Overture\", \"Red\", \"#AB291B\", \"PETG\"),\n    (\"Overture\", \"Space Grey\", \"#80817E\", \"PETG\"),\n    (\"Overture\", \"Translucent Blue\", \"#38487B\", \"PETG\"),\n    (\"Overture\", \"White\", \"#E7E9E7\", \"PETG\"),\n    (\"Overture\", \"Yellow\", \"#E6B93C\", \"PETG\"),\n    # Overture ABS\n    (\"Overture\", \"Diamond Gray\", \"#5D5F5F\", \"ABS\"),\n    (\"Overture\", \"Diamond Purple\", \"#6B649D\", \"ABS\"),\n    # Overture Silk PLA\n    (\"Overture\", \"Gold\", \"#CA9B52\", \"Silk PLA\"),\n    (\"Overture\", \"Neon Green\", \"#C2D74D\", \"Silk PLA\"),\n    (\"Overture\", \"Copper\", \"#B27052\", \"Silk PLA\"),\n    # Overture Glow PLA\n    (\"Overture\", \"Glow Blue\", \"#4EA2AA\", \"Glow PLA\"),\n    (\"Overture\", \"Glow Orange\", \"#C2895E\", \"Glow PLA\"),\n    (\"Overture\", \"Glow Red\", \"#C27B7D\", \"Glow PLA\"),\n    (\"Overture\", \"Glow Yellow\", \"#E3F079\", \"Glow PLA\"),\n    # Sunlu PLA (from FilamentColors.xyz measured swatches)\n    (\"Sunlu\", \"Black\", \"#3C3C3C\", \"PLA\"),\n    (\"Sunlu\", \"Blue\", \"#006AB8\", \"PLA\"),\n    (\"Sunlu\", \"Cherry Red\", \"#EA4A5D\", \"PLA\"),\n    (\"Sunlu\", \"Glow in the Dark\", \"#CBCAB8\", \"PLA\"),\n    (\"Sunlu\", \"Green Mint\", \"#4CCB9A\", \"PLA\"),\n    (\"Sunlu\", \"Grey\", \"#6B6E6E\", \"PLA\"),\n    (\"Sunlu\", \"Orange\", \"#E77932\", \"PLA\"),\n    (\"Sunlu\", \"Red\", \"#AC3637\", \"PLA\"),\n    (\"Sunlu\", \"Sky Blue\", \"#0CB7CC\", \"PLA\"),\n    (\"Sunlu\", \"Sunny Orange\", \"#FF7235\", \"PLA\"),\n    (\"Sunlu\", \"Transparent\", \"#C8C7BF\", \"PLA\"),\n    (\"Sunlu\", \"Transparent Orange\", \"#DB7F42\", \"PLA\"),\n    (\"Sunlu\", \"White\", \"#DEDFD9\", \"PLA\"),\n    (\"Sunlu\", \"Wood\", \"#D5BA95\", \"PLA\"),\n    # Sunlu PLA Silk\n    (\"Sunlu\", \"Silk Black\", \"#737272\", \"PLA Silk\"),\n    (\"Sunlu\", \"Silk Green\", \"#34C0A5\", \"PLA Silk\"),\n    (\"Sunlu\", \"Silk Red\", \"#CD5C62\", \"PLA Silk\"),\n    (\"Sunlu\", \"Silky Silver\", \"#C6CBD0\", \"PLA Silk\"),\n    # Sunlu PLA Meta\n    (\"Sunlu\", \"Blue\", \"#00B2CC\", \"PLA Meta\"),\n    (\"Sunlu\", \"Mint Green\", \"#03A490\", \"PLA Meta\"),\n    (\"Sunlu\", \"Sakura Pink\", \"#F5B5C2\", \"PLA Meta\"),\n    (\"Sunlu\", \"Taro Purple\", \"#A69ED0\", \"PLA Meta\"),\n    # Sunlu PLA+\n    (\"Sunlu\", \"Beige\", \"#DDBCAC\", \"PLA+\"),\n    (\"Sunlu\", \"Black\", \"#3A3B3B\", \"PLA+\"),\n    (\"Sunlu\", \"Blue\", \"#0063A0\", \"PLA+\"),\n    (\"Sunlu\", \"Green\", \"#4EE349\", \"PLA+\"),\n    (\"Sunlu\", \"Light Gold\", \"#D3943D\", \"PLA+\"),\n    (\"Sunlu\", \"Mint Green\", \"#00B39A\", \"PLA+\"),\n    (\"Sunlu\", \"Orange\", \"#ED7432\", \"PLA+\"),\n    (\"Sunlu\", \"Pure Yellow\", \"#FFBD2C\", \"PLA+\"),\n    (\"Sunlu\", \"Purple\", \"#8887C5\", \"PLA+\"),\n    (\"Sunlu\", \"Red\", \"#B34044\", \"PLA+\"),\n    (\"Sunlu\", \"Silk Blue\", \"#33ACD4\", \"PLA+\"),\n    (\"Sunlu\", \"Silk Brass\", \"#F1A050\", \"PLA+\"),\n    (\"Sunlu\", \"Silk Pink\", \"#FFCAD9\", \"PLA+\"),\n    (\"Sunlu\", \"Silk White\", \"#EEEFE7\", \"PLA+\"),\n    (\"Sunlu\", \"Skin\", \"#F7BEA1\", \"PLA+\"),\n    (\"Sunlu\", \"White\", \"#E6E6E2\", \"PLA+\"),\n    # Sunlu PETG\n    (\"Sunlu\", \"Black\", \"#3F4141\", \"PETG\"),\n    (\"Sunlu\", \"Blue\", \"#0068AB\", \"PETG\"),\n    (\"Sunlu\", \"Green\", \"#67DB25\", \"PETG\"),\n    (\"Sunlu\", \"Olive Green\", \"#707D63\", \"PETG\"),\n    (\"Sunlu\", \"Transparent\", \"#BAB9B4\", \"PETG\"),\n    (\"Sunlu\", \"White\", \"#DBDDD9\", \"PETG\"),\n    # Sunlu ABS\n    (\"Sunlu\", \"Black\", \"#404142\", \"ABS\"),\n    # Creality Hyper PLA (from FilamentColors.xyz measured swatches)\n    (\"Creality\", \"Black\", \"#282C2C\", \"Hyper PLA\"),\n    (\"Creality\", \"Blue\", \"#0881BE\", \"Hyper PLA\"),\n    (\"Creality\", \"Grey\", \"#7A7C7C\", \"Hyper PLA\"),\n    (\"Creality\", \"Purple\", \"#B0347E\", \"Hyper PLA\"),\n    (\"Creality\", \"Red\", \"#C32E2F\", \"Hyper PLA\"),\n    (\"Creality\", \"White\", \"#DEE4E1\", \"Hyper PLA\"),\n    # Creality Hyper PLA-CF\n    (\"Creality\", \"Black\", \"#322F2D\", \"Hyper PLA-CF\"),\n    # Creality PLA\n    (\"Creality\", \"Gray\", \"#8F9395\", \"PLA\"),\n    (\"Creality\", \"White\", \"#E1DFD0\", \"PLA\"),\n    # Creality PETG\n    (\"Creality\", \"White\", \"#E3E5E1\", \"PETG\"),\n    # Creality Silk PLA\n    (\"Creality\", \"Blue-Green\", \"#479B7D\", \"Silk PLA\"),\n    # Elegoo PLA (from FilamentColors.xyz measured swatches)\n    (\"Elegoo\", \"Black\", \"#282929\", \"PLA\"),\n    (\"Elegoo\", \"Clear\", \"#BEBBBF\", \"PLA\"),\n    (\"Elegoo\", \"Galaxy Black\", \"#32464E\", \"PLA\"),\n    (\"Elegoo\", \"Galaxy Purple\", \"#3A2F6F\", \"PLA\"),\n    (\"Elegoo\", \"Grey\", \"#B5B7B7\", \"PLA\"),\n    (\"Elegoo\", \"Peacock Blue\", \"#21606B\", \"PLA\"),\n    (\"Elegoo\", \"Sky Blue\", \"#46C8D4\", \"PLA\"),\n    # Elegoo PLA+\n    (\"Elegoo\", \"Black\", \"#343132\", \"PLA+\"),\n    (\"Elegoo\", \"Orange\", \"#CC6A2F\", \"PLA+\"),\n    (\"Elegoo\", \"Purple\", \"#6E45A7\", \"PLA+\"),\n    # Elegoo Silk PLA\n    (\"Elegoo\", \"Coral Pink\", \"#DB6E6D\", \"Silk PLA\"),\n    (\"Elegoo\", \"Gold\", \"#E2AC00\", \"Silk PLA\"),\n    (\"Elegoo\", \"Silver\", \"#93969B\", \"Silk PLA\"),\n    # Jayo PLA+ (from FilamentColors.xyz measured swatches)\n    (\"Jayo\", \"Black\", \"#2F2E2D\", \"PLA+\"),\n    (\"Jayo\", \"Cherry Red\", \"#C43536\", \"PLA+\"),\n    (\"Jayo\", \"White\", \"#D9E0E7\", \"PLA+\"),\n    # Inland PLA (from FilamentColors.xyz measured swatches)\n    (\"Inland\", \"Black\", \"#27272C\", \"PLA\"),\n    (\"Inland\", \"Blue\", \"#044482\", \"PLA\"),\n    (\"Inland\", \"Coral\", \"#C16062\", \"PLA\"),\n    (\"Inland\", \"Egyptian Blue\", \"#075AAC\", \"PLA\"),\n    (\"Inland\", \"Gold\", \"#D7B536\", \"PLA\"),\n    (\"Inland\", \"Green\", \"#407166\", \"PLA\"),\n    (\"Inland\", \"Grey\", \"#6F7983\", \"PLA\"),\n    (\"Inland\", \"Light Blue\", \"#3CA4B8\", \"PLA\"),\n    (\"Inland\", \"Military Green\", \"#5B6D37\", \"PLA\"),\n    (\"Inland\", \"Pink\", \"#FC97AF\", \"PLA\"),\n    (\"Inland\", \"Red\", \"#C43220\", \"PLA\"),\n    (\"Inland\", \"Silver\", \"#8A8F92\", \"PLA\"),\n    (\"Inland\", \"True Red\", \"#B13137\", \"PLA\"),\n    (\"Inland\", \"White\", \"#E0E3E3\", \"PLA\"),\n    (\"Inland\", \"Wood\", \"#DEB98F\", \"PLA\"),\n    # Inland PLA+\n    (\"Inland\", \"Black\", \"#2B272B\", \"PLA+\"),\n    (\"Inland\", \"Blue\", \"#054990\", \"PLA+\"),\n    (\"Inland\", \"Bone White\", \"#ABA18F\", \"PLA+\"),\n    (\"Inland\", \"Dark Blue\", \"#2C3353\", \"PLA+\"),\n    (\"Inland\", \"Light Blue\", \"#079FBF\", \"PLA+\"),\n    (\"Inland\", \"Magenta\", \"#DE2B60\", \"PLA+\"),\n    (\"Inland\", \"Orange\", \"#FB8B5A\", \"PLA+\"),\n    (\"Inland\", \"Pink\", \"#F291A4\", \"PLA+\"),\n    (\"Inland\", \"Purple\", \"#744FA0\", \"PLA+\"),\n    (\"Inland\", \"Silver\", \"#868A8B\", \"PLA+\"),\n    (\"Inland\", \"White\", \"#E3E5E5\", \"PLA+\"),\n    (\"Inland\", \"Yellow\", \"#F8D008\", \"PLA+\"),\n    # Inland PETG\n    (\"Inland\", \"Blue\", \"#084480\", \"PETG\"),\n    (\"Inland\", \"Green\", \"#2B783E\", \"PETG\"),\n    (\"Inland\", \"Magenta\", \"#E14170\", \"PETG\"),\n    (\"Inland\", \"Transparent\", \"#D1D6D1\", \"PETG\"),\n    (\"Inland\", \"True Red\", \"#97392B\", \"PETG\"),\n    # Inland ABS\n    (\"Inland\", \"Grey\", \"#8A97A2\", \"ABS\"),\n    (\"Inland\", \"Light Blue\", \"#6CBECF\", \"ABS\"),\n    (\"Inland\", \"Orange\", \"#E8712F\", \"ABS\"),\n    # Inland Tough PLA\n    (\"Inland\", \"Light Gray\", \"#8D9497\", \"Tough PLA\"),\n    (\"Inland\", \"Yellow\", \"#FFBB3F\", \"Tough PLA\"),\n    # Eryone PLA (from FilamentColors.xyz measured swatches)\n    (\"Eryone\", \"Galaxy Purple\", \"#60617B\", \"PLA\"),\n    (\"Eryone\", \"Galaxy Red\", \"#8E3332\", \"PLA\"),\n    (\"Eryone\", \"Glow in the Dark\", \"#C2C1AF\", \"PLA\"),\n    (\"Eryone\", \"Ivory White\", \"#DCDCD3\", \"PLA\"),\n    (\"Eryone\", \"Silk Blue\", \"#64A9D3\", \"PLA\"),\n    (\"Eryone\", \"Silk Copper\", \"#B36A50\", \"PLA\"),\n    (\"Eryone\", \"Silk Gold\", \"#D5983D\", \"PLA\"),\n    (\"Eryone\", \"Silk Gold Copper\", \"#D69366\", \"PLA\"),\n    (\"Eryone\", \"Silk Gold Silver\", \"#ABA787\", \"PLA\"),\n    (\"Eryone\", \"Ultra Silk Black\", \"#5B6264\", \"PLA\"),\n    (\"Eryone\", \"Ultra Silk Copper\", \"#B46A4D\", \"PLA\"),\n    (\"Eryone\", \"Ultra Silk Silver\", \"#999BA5\", \"PLA\"),\n    # Eryone PLA+\n    (\"Eryone\", \"Army Green\", \"#5D644D\", \"PLA+\"),\n    # Eryone ASA\n    (\"Eryone\", \"Black\", \"#414446\", \"ASA\"),\n    # Eryone PLA Wood\n    (\"Eryone\", \"Light Wood\", \"#A5886E\", \"PLA Wood\"),\n    # ColorFabb PLA (from FilamentColors.xyz measured swatches)\n    (\"ColorFabb\", \"Stonefill Light Gray\", \"#A9B2B7\", \"PLA\"),\n    (\"ColorFabb\", \"WoodFill\", \"#B89775\", \"PLA\"),\n    # ColorFabb PLA/PHA\n    (\"ColorFabb\", \"CopperFill\", \"#9D7465\", \"PLA/PHA\"),\n    (\"ColorFabb\", \"CorkFill\", \"#7F6150\", \"PLA/PHA\"),\n    (\"ColorFabb\", \"Natural\", \"#CFCFC2\", \"PLA/PHA\"),\n    # ColorFabb XT\n    (\"ColorFabb\", \"Light Gray\", \"#BFC5BE\", \"XT\"),\n    (\"ColorFabb\", \"Black\", \"#3B3635\", \"XT\"),\n    # Fillamentum PLA Extrafill (from FilamentColors.xyz measured swatches)\n    (\"Fillamentum\", \"Baby Blue\", \"#B9D7DC\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Chocolate Brown\", \"#5B4A45\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Cobalt Blue\", \"#333D5C\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Crystal Clear Smaragd Green\", \"#028D77\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Everybody's Magenta\", \"#E1347D\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Gold Happens\", \"#BC994D\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Mukha\", \"#A88866\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Pearl Night Blue\", \"#045589\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Pearl Ruby Red\", \"#791F2A\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Rapunzel Silver\", \"#AFAFB0\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Vertigo Cherry\", \"#752F38\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Vertigo Galaxy\", \"#333928\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Vertigo Grey\", \"#5A5963\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Vertigo Starlight\", \"#343A4F\", \"PLA Extrafill\"),\n    (\"Fillamentum\", \"Wizard's Voodoo\", \"#3F465E\", \"PLA Extrafill\"),\n    # Fillamentum PLA (Crystal Clear / Timberfill / Vertigo lines)\n    (\"Fillamentum\", \"Crystal Clear\", \"#EBECF2\", \"PLA\"),\n    (\"Fillamentum\", \"Crystal Clear Amethyst Purple\", \"#9F99BC\", \"PLA\"),\n    (\"Fillamentum\", \"Crystal Clear Iceland Blue\", \"#82BBCD\", \"PLA\"),\n    (\"Fillamentum\", \"Crystal Clear Tangerine Orange\", \"#ECD082\", \"PLA\"),\n    (\"Fillamentum\", \"Lilac\", \"#A99FCF\", \"PLA\"),\n    (\"Fillamentum\", \"Timberfill Cinnamon\", \"#AC7C67\", \"PLA\"),\n    (\"Fillamentum\", \"Timberfill Rosewood\", \"#6A564E\", \"PLA\"),\n    (\"Fillamentum\", \"Vertigo Jade\", \"#217F60\", \"PLA\"),\n    # Fillamentum ASA Extrafill\n    (\"Fillamentum\", \"Anthracite Grey\", \"#4B4F50\", \"ASA Extrafill\"),\n    (\"Fillamentum\", \"Green Grass\", \"#678653\", \"ASA Extrafill\"),\n    (\"Fillamentum\", \"Grey Blue\", \"#495965\", \"ASA Extrafill\"),\n    (\"Fillamentum\", \"Metallic Grey\", \"#878A8C\", \"ASA Extrafill\"),\n    (\"Fillamentum\", \"Sky Blue\", \"#0783B6\", \"ASA Extrafill\"),\n    (\"Fillamentum\", \"Snow White\", \"#F3F3EF\", \"ASA Extrafill\"),\n    (\"Fillamentum\", \"Traffic Black\", \"#3B3B3F\", \"ASA Extrafill\"),\n    (\"Fillamentum\", \"Traffic White\", \"#E9E7DA\", \"ASA Extrafill\"),\n    (\"Fillamentum\", \"White Aluminium\", \"#9CA1A2\", \"ASA Extrafill\"),\n    # Fillamentum CPE HG100\n    (\"Fillamentum\", \"Black Soul\", \"#292B27\", \"CPE HG100\"),\n    (\"Fillamentum\", \"Ghost White\", \"#E5E8E7\", \"CPE HG100\"),\n    (\"Fillamentum\", \"Natural\", \"#DCE4DF\", \"CPE HG100\"),\n    # Fillamentum Flexfill TPU 98A\n    (\"Fillamentum\", \"Blue Transparent\", \"#047990\", \"Flexfill TPU 98A\"),\n    (\"Fillamentum\", \"Carrot Orange\", \"#EA6E21\", \"Flexfill TPU 98A\"),\n    (\"Fillamentum\", \"Metallic Grey\", \"#8F8E8F\", \"Flexfill TPU 98A\"),\n    (\"Fillamentum\", \"Pistachio Green\", \"#A7BE36\", \"Flexfill TPU 98A\"),\n    (\"Fillamentum\", \"Signal Red\", \"#9A2222\", \"Flexfill TPU 98A\"),\n    (\"Fillamentum\", \"Traffic Black\", \"#26262A\", \"Flexfill TPU 98A\"),\n    (\"Fillamentum\", \"Vertigo Grey\", \"#515150\", \"Flexfill TPU 98A\"),\n    # FormFutura PLA (from FilamentColors.xyz measured swatches)\n    (\"FormFutura\", \"Basalt Grey\", \"#5B5F61\", \"PLA\"),\n    (\"FormFutura\", \"Dark Blue\", \"#084B86\", \"PLA\"),\n    (\"FormFutura\", \"Galaxy Champagne Gold\", \"#AE9D83\", \"PLA\"),\n    (\"FormFutura\", \"Gold High Gloss\", \"#C89B4B\", \"PLA\"),\n    (\"FormFutura\", \"High Gloss White\", \"#D0D7D8\", \"PLA\"),\n    (\"FormFutura\", \"Magenta High Gloss\", \"#B94474\", \"PLA\"),\n    (\"FormFutura\", \"Stonefil Terracotta\", \"#BD634C\", \"PLA\"),\n    (\"FormFutura\", \"Yellow Green\", \"#7AA837\", \"PLA\"),\n    # FormFutura ePLA\n    (\"FormFutura\", \"Pure Orange\", \"#FA9145\", \"EasyFil PLA\"),\n    # FormFutura rPLA\n    (\"FormFutura\", \"ReForm Black\", \"#3A3B3B\", \"ReForm rPLA\"),\n    (\"FormFutura\", \"ReForm White\", \"#F3F3EC\", \"ReForm rPLA\"),\n    # Fiberlogy PLA (from FilamentColors.xyz measured swatches)\n    (\"Fiberlogy\", \"Mineral White\", \"#E2D9CD\", \"PLA\"),\n    (\"Fiberlogy\", \"Aurora\", \"#3C4452\", \"Easy PLA\"),\n    (\"Fiberlogy\", \"Army Green\", \"#535F4F\", \"Impact PLA\"),\n    # Fiberlogy ASA\n    (\"Fiberlogy\", \"Olive Green\", \"#61634B\", \"ASA\"),\n    # Fiberlogy Easy PETG\n    (\"Fiberlogy\", \"White\", \"#F2F2EE\", \"Easy PETG\"),\n    # Fiberlogy FiberSilk\n    (\"Fiberlogy\", \"Green\", \"#A2D780\", \"FiberSilk Metallic\"),\n    # MatterHackers Build PLA (from FilamentColors.xyz measured swatches)\n    (\"MatterHackers\", \"Blue\", \"#044786\", \"Build PLA\"),\n    (\"MatterHackers\", \"Magenta\", \"#CD4263\", \"Build PLA\"),\n    (\"MatterHackers\", \"Red\", \"#C4351B\", \"Build PLA\"),\n    (\"MatterHackers\", \"Shiny Gold\", \"#DFAC1E\", \"Build PLA\"),\n    (\"MatterHackers\", \"Silky Copper\", \"#C76F35\", \"Build PLA\"),\n    (\"MatterHackers\", \"Silky Silver\", \"#BDBDB8\", \"Build PLA\"),\n    (\"MatterHackers\", \"Silky Teal\", \"#078EBC\", \"Build PLA\"),\n    (\"MatterHackers\", \"Silky Yellow\", \"#EDB554\", \"Build PLA\"),\n    (\"MatterHackers\", \"Yellow\", \"#EBC100\", \"Build PLA\"),\n    # MatterHackers PLA\n    (\"MatterHackers\", \"Gold\", \"#E7AC37\", \"PLA\"),\n    (\"MatterHackers\", \"Lime Green\", \"#75BA52\", \"PLA\"),\n    (\"MatterHackers\", \"Pearl White\", \"#D4DCDD\", \"PLA\"),\n    (\"MatterHackers\", \"Red\", \"#E54931\", \"PLA\"),\n    # MatterHackers Pro PLA\n    (\"MatterHackers\", \"Electric Pink\", \"#F35886\", \"Pro PLA\"),\n    (\"MatterHackers\", \"Jet Gray\", \"#474B4C\", \"Pro PLA\"),\n    # MatterHackers PETG\n    (\"MatterHackers\", \"Clear\", \"#D5DDDA\", \"PETG\"),\n    (\"MatterHackers\", \"White\", \"#E9EBEF\", \"PETG\"),\n    # MatterHackers NylonX / NylonG\n    (\"MatterHackers\", \"Black\", \"#3D3C38\", \"NylonX\"),\n    (\"MatterHackers\", \"White\", \"#DCDED9\", \"NylonG\"),\n    # Protopasta HTPLA (from FilamentColors.xyz measured swatches)\n    (\"Protopasta\", \"Atikam Teal\", \"#135859\", \"HTPLA\"),\n    (\"Protopasta\", \"Blood of My Enemies\", \"#7A1A23\", \"HTPLA\"),\n    (\"Protopasta\", \"Blue Opaque\", \"#044A86\", \"HTPLA\"),\n    (\"Protopasta\", \"Blue Wonder Glitter Flake\", \"#20556F\", \"HTPLA\"),\n    (\"Protopasta\", \"Bobbi's Purple Iris\", \"#542B5C\", \"HTPLA\"),\n    (\"Protopasta\", \"Brass Composite\", \"#8C7A4F\", \"HTPLA\"),\n    (\"Protopasta\", \"Bronze Composite\", \"#635146\", \"HTPLA\"),\n    (\"Protopasta\", \"Candy Apple Metallic Red\", \"#A32423\", \"HTPLA\"),\n    (\"Protopasta\", \"Cloverleaf Metallic Green\", \"#245A3F\", \"HTPLA\"),\n    (\"Protopasta\", \"Copper Composite\", \"#976252\", \"HTPLA\"),\n    (\"Protopasta\", \"Cupid's Crush Metallic Pink\", \"#EA8699\", \"HTPLA\"),\n    (\"Protopasta\", \"Double Espresso Metallic Brown\", \"#6B473B\", \"HTPLA\"),\n    (\"Protopasta\", \"Dragon Fruit Smoothie\", \"#B3295F\", \"HTPLA\"),\n    (\"Protopasta\", \"Dragon Scale Purple\", \"#8A80AC\", \"HTPLA\"),\n    (\"Protopasta\", \"Dusty Smoke\", \"#8F9491\", \"HTPLA\"),\n    (\"Protopasta\", \"Electric Lemonade Metallic Yellow\", \"#E4CA6B\", \"HTPLA\"),\n    (\"Protopasta\", \"Empire Strikes Metallic Black\", \"#393B3B\", \"HTPLA\"),\n    (\"Protopasta\", \"Fluorescent Yellow\", \"#D4DC3A\", \"HTPLA\"),\n    (\"Protopasta\", \"Galactic Empire Metallic Purple\", \"#3B3F5D\", \"HTPLA\"),\n    (\"Protopasta\", \"Glitter's Mane\", \"#128C93\", \"HTPLA\"),\n    (\"Protopasta\", \"Gold Dust Glitter Flake\", \"#BFAE6D\", \"HTPLA\"),\n    (\"Protopasta\", \"Good as Gold\", \"#9A774B\", \"HTPLA\"),\n    (\"Protopasta\", \"Good Old Gray\", \"#6D737B\", \"HTPLA\"),\n    (\"Protopasta\", \"Green Glowing Natural\", \"#D4D3AD\", \"HTPLA\"),\n    (\"Protopasta\", \"Heartthrob Red Metallic\", \"#7E3030\", \"HTPLA\"),\n    (\"Protopasta\", \"Joel's Highfive Blue\", \"#056B9A\", \"HTPLA\"),\n    (\"Protopasta\", \"Lootsef Green\", \"#8FB841\", \"HTPLA\"),\n    (\"Protopasta\", \"Luke's Proton Purple\", \"#7D3F59\", \"HTPLA\"),\n    (\"Protopasta\", \"Mahogany\", \"#7F5D4F\", \"HTPLA\"),\n    (\"Protopasta\", \"Matte Fiber Black\", \"#3F3F3E\", \"HTPLA\"),\n    (\"Protopasta\", \"Matte Fiber Daffodil\", \"#B79868\", \"HTPLA\"),\n    (\"Protopasta\", \"Matte Fiber Gray\", \"#767A7D\", \"HTPLA\"),\n    (\"Protopasta\", \"Matte Fiber Walnut\", \"#6F5D4E\", \"HTPLA\"),\n    (\"Protopasta\", \"Matte Fiber White\", \"#F4EADB\", \"HTPLA\"),\n    (\"Protopasta\", \"Mermaid's Tale Metallic Teal\", \"#026768\", \"HTPLA\"),\n    (\"Protopasta\", \"Moonstruck White Satin\", \"#DCE4DD\", \"HTPLA\"),\n    (\"Protopasta\", \"Obsidian\", \"#474743\", \"HTPLA\"),\n    (\"Protopasta\", \"Opaque Black\", \"#312F30\", \"HTPLA\"),\n    (\"Protopasta\", \"Opaque Natural\", \"#CBD0D0\", \"HTPLA\"),\n    (\"Protopasta\", \"Opaque White\", \"#DFE4E2\", \"HTPLA\"),\n    (\"Protopasta\", \"Orange Papaya Smoothie\", \"#C57231\", \"HTPLA\"),\n    (\"Protopasta\", \"Out of Darts Orange\", \"#E58429\", \"HTPLA\"),\n    (\"Protopasta\", \"Pineapple Banana Smoothie\", \"#D0A645\", \"HTPLA\"),\n    (\"Protopasta\", \"Pretty in Pink Pearl\", \"#CE95AE\", \"HTPLA\"),\n    (\"Protopasta\", \"Red Hot Cinnamon\", \"#7C4448\", \"HTPLA\"),\n    (\"Protopasta\", \"Red Opaque\", \"#972425\", \"HTPLA\"),\n    (\"Protopasta\", \"Second to None Silver\", \"#B4B6B6\", \"HTPLA\"),\n    (\"Protopasta\", \"Sparkling Spruce\", \"#47614C\", \"HTPLA\"),\n    (\"Protopasta\", \"Stardust Glitter Flake\", \"#AAB1AE\", \"HTPLA\"),\n    (\"Protopasta\", \"Summertime Green\", \"#89A78A\", \"HTPLA\"),\n    (\"Protopasta\", \"Tangerine Orange Metallic Gold\", \"#C05834\", \"HTPLA\"),\n    (\"Protopasta\", \"Translucent Iridescent Ice\", \"#C4CBC8\", \"HTPLA\"),\n    (\"Protopasta\", \"Translucent Silver Smoke\", \"#A5ACA9\", \"HTPLA\"),\n    (\"Protopasta\", \"Unicorn Tears White Glitter\", \"#D7DEDE\", \"HTPLA\"),\n    (\"Protopasta\", \"What Karat? Smooth Gold\", \"#CD974B\", \"HTPLA\"),\n    (\"Protopasta\", \"White\", \"#F6F5F0\", \"HTPLA\"),\n    (\"Protopasta\", \"White Marble\", \"#C6CECF\", \"HTPLA\"),\n    (\"Protopasta\", \"Winter Blue Glitter Flake\", \"#056F9D\", \"HTPLA\"),\n    # Protopasta PLA\n    (\"Protopasta\", \"Black\", \"#323132\", \"PLA\"),\n    (\"Protopasta\", \"Conductive\", \"#373838\", \"PLA\"),\n    (\"Protopasta\", \"Iron Composite\", \"#555451\", \"PLA\"),\n    (\"Protopasta\", \"Natural\", \"#E1DFD6\", \"PLA\"),\n    (\"Protopasta\", \"Steel Composite\", \"#676561\", \"PLA\"),\n    # Protopasta Carbon Fiber PLA\n    (\"Protopasta\", \"Black\", \"#424140\", \"Carbon Fiber PLA\"),\n    # 3DXTECH (from FilamentColors.xyz measured swatches)\n    (\"3DXTECH\", \"Natural\", \"#DED7C6\", \"ASA\"),\n    (\"3DXTECH\", \"Black\", \"#444342\", \"Carbon Fiber PLA\"),\n    (\"3DXTECH\", \"Venom\", \"#CACC19\", \"ECOMAX PLA\"),\n    (\"3DXTECH\", \"Simubone\", \"#EAE0CB\", \"PLA\"),\n    (\"3DXTECH\", \"Blue Frost\", \"#B1C1C5\", \"rPETG\"),\n    # Sakata3D PLA (from FilamentColors.xyz measured swatches)\n    (\"Sakata3D\", \"Red\", \"#B63A32\", \"PLA\"),\n    (\"Sakata3D\", \"Silk Sunset\", \"#F49545\", \"PLA\"),\n    (\"Sakata3D\", \"Surf Green\", \"#00C1A8\", \"PLA\"),\n]\n"
  },
  {
    "path": "backend/app/core/compat.py",
    "content": "\"\"\"Compatibility shims for older Python versions.\"\"\"\n\nimport sys\n\nif sys.version_info >= (3, 11):\n    from enum import StrEnum\nelse:\n    from enum import Enum\n\n    class StrEnum(str, Enum):\n        \"\"\"Drop-in replacement for enum.StrEnum on Python < 3.11.\"\"\"\n"
  },
  {
    "path": "backend/app/core/config.py",
    "content": "import logging\nimport os\nfrom pathlib import Path\n\nfrom pydantic_settings import BaseSettings\n\n# Application version - single source of truth\nAPP_VERSION = \"0.2.3.2\"\nGITHUB_REPO = \"maziggy/bambuddy\"\nBUG_REPORT_RELAY_URL = os.environ.get(\"BUG_REPORT_RELAY_URL\", \"https://bambuddy.cool/api/bug-report\")\n\n# App directory - where the application is installed (for static files)\n_app_dir = Path(__file__).resolve().parent.parent.parent.parent\n\n# Data directory - for persistent data (database, archives)\n# Use DATA_DIR env var if set (Docker), otherwise use project root (local dev)\n_data_dir_env = os.environ.get(\"DATA_DIR\")\n_data_dir = Path(_data_dir_env) if _data_dir_env else _app_dir\n\n# Plate calibration directory - special handling to maintain backwards compatibility\n# Docker: DATA_DIR/plate_calibration (e.g., /data/plate_calibration)\n# Local dev: project_root/data/plate_calibration (original location)\n_plate_cal_dir = Path(_data_dir_env) / \"plate_calibration\" if _data_dir_env else _app_dir / \"data\" / \"plate_calibration\"\n\n# Log directory - use LOG_DIR env var if set, otherwise use app_dir/logs\n_log_dir_env = os.environ.get(\"LOG_DIR\")\n_log_dir = Path(_log_dir_env) if _log_dir_env else _app_dir / \"logs\"\n\n\ndef _migrate_database() -> Path:\n    \"\"\"Migrate database from old name to new name if needed.\"\"\"\n    old_db = _data_dir / \"bambutrack.db\"\n    new_db = _data_dir / \"bambuddy.db\"\n\n    # If old database exists and new one doesn't, rename it\n    if old_db.exists() and not new_db.exists():\n        try:\n            old_db.rename(new_db)\n            logging.info(\"Migrated database: %s -> %s\", old_db, new_db)\n        except Exception as e:\n            logging.warning(\"Could not migrate database: %s. Using old location.\", e)\n            return old_db\n\n    # If old database exists (and new one now exists too), it was migrated\n    # If only new exists, use new\n    # If neither exists, use new (will be created)\n    return new_db if new_db.exists() or not old_db.exists() else old_db\n\n\n# External DATABASE_URL takes priority (PostgreSQL support)\n_external_db_url = os.environ.get(\"DATABASE_URL\")\n\n# Determine database path (handles migration) — only used for SQLite\n_db_path = _migrate_database() if not _external_db_url else None\n\n\nclass Settings(BaseSettings):\n    app_name: str = \"Bambuddy\"\n    debug: bool = False  # Default to production mode\n\n    # Paths\n    base_dir: Path = _data_dir  # For backwards compatibility\n    archive_dir: Path = _data_dir / \"archive\"\n    plate_calibration_dir: Path = _plate_cal_dir  # Plate detection references\n    static_dir: Path = _app_dir / \"static\"  # Static files are part of app, not data\n    log_dir: Path = _log_dir\n    database_url: str = _external_db_url or f\"sqlite+aiosqlite:///{_db_path}\"\n\n    # Logging\n    log_level: str = \"INFO\"  # Override with LOG_LEVEL env var or DEBUG=true\n    log_to_file: bool = True  # Set to false to disable file logging\n\n    # API\n    api_prefix: str = \"/api/v1\"\n\n    class Config:\n        env_file = \".env\"\n        env_file_encoding = \"utf-8\"\n\n\nsettings = Settings()\n\n# Ensure directories exist\nsettings.archive_dir.mkdir(parents=True, exist_ok=True)\nsettings.plate_calibration_dir.mkdir(parents=True, exist_ok=True)\nsettings.static_dir.mkdir(exist_ok=True)\nif settings.log_to_file:\n    settings.log_dir.mkdir(exist_ok=True)\n"
  },
  {
    "path": "backend/app/core/database.py",
    "content": "import asyncio\nimport logging\n\nfrom sqlalchemy import event\nfrom sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError\nfrom sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\nfrom sqlalchemy.orm import DeclarativeBase\n\nfrom backend.app.core.config import settings\nfrom backend.app.core.db_dialect import is_sqlite\n\nlogger = logging.getLogger(__name__)\n\n\ndef _set_sqlite_pragmas(dbapi_conn, connection_record):\n    \"\"\"Set SQLite pragmas on each new connection for concurrency and performance.\"\"\"\n    cursor = dbapi_conn.cursor()\n    # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely)\n    cursor.execute(\"PRAGMA journal_mode = WAL\")\n    # Wait up to 15 seconds when the database is locked instead of failing immediately\n    cursor.execute(\"PRAGMA busy_timeout = 15000\")\n    cursor.execute(\"PRAGMA synchronous = NORMAL\")\n    cursor.close()\n\n\ndef _create_engine():\n    \"\"\"Create the async engine with dialect-appropriate settings.\"\"\"\n    if is_sqlite():\n        kwargs = {\"pool_size\": 20, \"max_overflow\": 200}\n    else:\n        kwargs = {\"pool_size\": 10, \"max_overflow\": 20}\n    eng = create_async_engine(\n        settings.database_url,\n        echo=settings.debug,\n        **kwargs,\n    )\n    if is_sqlite():\n        event.listen(eng.sync_engine, \"connect\", _set_sqlite_pragmas)\n    else:\n        # Strip timezone info from aware datetimes before they reach asyncpg.\n        # asyncpg rejects timezone-aware values for TIMESTAMP WITHOUT TIME ZONE columns.\n        # The codebase uses datetime.now(timezone.utc) in many places — this makes\n        # Postgres behave like SQLite which ignores timezone info entirely.\n        @event.listens_for(eng.sync_engine, \"before_cursor_execute\", retval=True)\n        def _strip_tz_from_params(conn, cursor, statement, parameters, context, executemany):\n            import datetime\n\n            if parameters is None:\n                return statement, parameters\n\n            # Recursive strip that walks any nesting of dict/list/tuple. Needed\n            # because SQLAlchemy passes parameters in several shapes depending\n            # on the path: a dict for named binds, a tuple for positional, a\n            # list of dicts/tuples for executemany, and for insertmanyvalues\n            # sometimes a list of tuples inside an outer list. The simplest\n            # correct answer is \"strip datetimes at any depth\".\n            def _strip(val):\n                if isinstance(val, datetime.datetime) and val.tzinfo is not None:\n                    return val.replace(tzinfo=None)\n                if isinstance(val, dict):\n                    return {k: _strip(v) for k, v in val.items()}\n                if isinstance(val, list):\n                    return [_strip(v) for v in val]\n                if isinstance(val, tuple):\n                    return tuple(_strip(v) for v in val)\n                return val\n\n            return statement, _strip(parameters)\n\n    return eng\n\n\nengine = _create_engine()\n\nasync_session = async_sessionmaker(\n    engine,\n    class_=AsyncSession,\n    expire_on_commit=False,\n)\n\n\nasync def run_with_retry(fn, *, max_attempts: int = 3, label: str = \"\"):\n    \"\"\"Run an async DB operation with retry for SQLite 'database is locked' errors.\n\n    ``fn`` is an async callable that receives an ``AsyncSession`` and performs\n    the full query-mutate-commit cycle.  On each retry a fresh session is used\n    so there are no stale-object / expired-attribute issues after rollback.\n\n    On PostgreSQL this calls ``fn`` once with no retry (Postgres uses row-level\n    locking and doesn't suffer from single-writer contention).\n    \"\"\"\n    if not is_sqlite():\n        async with async_session() as db:\n            return await fn(db)\n\n    last_exc: OperationalError | None = None\n    for attempt in range(1, max_attempts + 1):\n        try:\n            async with async_session() as db:\n                return await fn(db)\n        except OperationalError as exc:\n            last_exc = exc\n            if \"database is locked\" not in str(exc) or attempt == max_attempts:\n                raise\n            delay = 0.5 * attempt  # 0.5s, 1.0s\n            logger.warning(\n                \"SQLite locked%s (attempt %d/%d), retrying in %.1fs: %s\",\n                f\" ({label})\" if label else \"\",\n                attempt,\n                max_attempts,\n                delay,\n                exc,\n            )\n            await asyncio.sleep(delay)\n    raise last_exc  # unreachable, but keeps type checkers happy\n\n\nasync def close_all_connections():\n    \"\"\"Close all database connections for backup/restore operations.\"\"\"\n    global engine\n    await engine.dispose()\n\n\nasync def reinitialize_database():\n    \"\"\"Reinitialize database connection after restore.\"\"\"\n    global engine, async_session\n    engine = _create_engine()\n    async_session = async_sessionmaker(\n        engine,\n        class_=AsyncSession,\n        expire_on_commit=False,\n    )\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nasync def get_db() -> AsyncSession:\n    async with async_session() as session:\n        try:\n            yield session\n            await session.commit()\n        except Exception:\n            await session.rollback()\n            raise\n        finally:\n            await session.close()\n\n\nasync def init_db():\n    # Import models to register them with SQLAlchemy\n    from backend.app.models import (  # noqa: F401\n        active_print_spoolman,\n        ams_history,\n        ams_label,\n        api_key,\n        archive,\n        auth_ephemeral,\n        bug_report,\n        color_catalog,\n        external_link,\n        filament,\n        github_backup,\n        group,\n        kprofile_note,\n        library,\n        local_preset,\n        maintenance,\n        notification,\n        notification_template,\n        oidc_provider,\n        orca_base_cache,\n        pending_upload,\n        print_batch,\n        print_log,\n        print_queue,\n        printer,\n        project,\n        project_bom,\n        settings,\n        slot_preset,\n        smart_plug,\n        smart_plug_energy_snapshot,\n        spool,\n        spool_assignment,\n        spool_catalog,\n        spool_k_profile,\n        spool_usage_history,\n        spoolbuddy_device,\n        user,\n        user_email_pref,\n        user_otp_code,\n        user_totp,\n        virtual_printer,\n    )\n\n    async with engine.begin() as conn:\n        await conn.run_sync(Base.metadata.create_all)\n\n        # Run migrations for new columns (SQLite doesn't auto-add columns)\n        await run_migrations(conn)\n\n    # Seed default notification templates\n    await seed_notification_templates()\n\n    # Seed default groups and migrate existing users\n    await seed_default_groups()\n\n    # Seed default catalog entries\n    await seed_spool_catalog()\n    await seed_color_catalog()\n\n\nasync def _safe_execute(conn, sql):\n    \"\"\"Execute a migration statement, ignoring 'already exists' errors.\n\n    Uses a savepoint so that a failed statement doesn't poison the\n    surrounding transaction (required for PostgreSQL).\n    \"\"\"\n    from sqlalchemy import text\n\n    try:\n        async with conn.begin_nested():\n            await conn.execute(text(sql))\n    except (OperationalError, ProgrammingError):\n        pass\n\n\nasync def run_migrations(conn):\n    \"\"\"Add new columns to existing tables if they don't exist.\"\"\"\n    from sqlalchemy import text\n\n    # Migration: Add is_favorite column to print_archives\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0\")\n\n    # Migration: Add content_hash column to print_archives for duplicate detection\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)\")\n\n    # Migration: Add auto_off_executed column to smart_plugs\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0\")\n\n    # Migration: Add on_print_stopped column to notification_providers\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1\")\n\n    # Migration: Add source_3mf_path column to print_archives\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)\")\n\n    # Migration: Add f3d_path column to print_archives for Fusion 360 design files\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)\")\n\n    # Migration: Add on_maintenance_due column to notification_providers\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0\")\n\n    # Migration: Add location column to printers for grouping\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN location VARCHAR(100)\")\n\n    # Migration: Add interval_type column to maintenance_types\n    await _safe_execute(conn, \"ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'\")\n\n    # Migration: Add is_deleted column to maintenance_types for soft-deletes\n    await _safe_execute(conn, \"ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0\")\n\n    # Migration: Add custom_interval_type column to printer_maintenance\n    await _safe_execute(conn, \"ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)\")\n\n    # Migration: Add power alert columns to smart_plugs\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME\")\n\n    # Migration: Add schedule columns to smart_plugs\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)\")\n\n    # Migration: Add daily digest columns to notification_providers\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)\")\n\n    # Migration: Add missing-spool-assignment print-start notification toggle\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE notification_providers ADD COLUMN on_print_missing_spool_assignment BOOLEAN DEFAULT 0\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add project_id column to print_archives\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add project_id column to print_queue\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Enforce uniqueness on user_oidc_links for existing rows.\n    # create_all() is idempotent and does not add constraints to existing tables,\n    # so we create covering unique indexes explicitly here.\n    await _safe_execute(\n        conn,\n        \"CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_provider_sub\"\n        \" ON user_oidc_links (provider_id, provider_user_id)\",\n    )\n    await _safe_execute(\n        conn,\n        \"CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_user_provider ON user_oidc_links (user_id, provider_id)\",\n    )\n\n    # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)\n    # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)\n    if is_sqlite():\n        try:\n            await conn.execute(\n                text(\"\"\"\n                CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(\n                    print_name,\n                    filename,\n                    tags,\n                    notes,\n                    designer,\n                    filament_type,\n                    content='print_archives',\n                    content_rowid='id'\n                )\n            \"\"\")\n            )\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n        # Migration: Create triggers to keep FTS index in sync\n        try:\n            await conn.execute(\n                text(\"\"\"\n                CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN\n                    INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)\n                    VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);\n                END\n            \"\"\")\n            )\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n        try:\n            await conn.execute(\n                text(\"\"\"\n                CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN\n                    INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)\n                    VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);\n                END\n            \"\"\")\n            )\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n        try:\n            await conn.execute(\n                text(\"\"\"\n                CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN\n                    INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)\n                    VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);\n                    INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)\n                    VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);\n                END\n            \"\"\")\n            )\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n    # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME\")\n\n    # Migration: Add auto_off_persistent column to smart_plugs (keep auto-off enabled between prints)\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN auto_off_persistent BOOLEAN DEFAULT 0\")\n\n    # Migration: Add AMS alarm notification columns to notification_providers\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0\")\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add AMS-HT alarm notification columns to notification_providers\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add plate not empty notification column to notification_providers\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1\")\n\n    # Migration: Add notes column to projects (Phase 2)\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN notes TEXT\")\n\n    # Migration: Add attachments column to projects (Phase 3)\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN attachments JSON\")\n\n    # Migration: Add tags column to projects (Phase 4)\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN tags TEXT\")\n\n    # Migration: Add due_date column to projects (Phase 5)\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN due_date DATETIME\")\n\n    # Migration: Add priority column to projects (Phase 5)\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'\")\n\n    # Migration: Add budget column to projects (Phase 6)\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN budget REAL\")\n\n    # Migration: Add is_template column to projects (Phase 8)\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0\")\n\n    # Migration: Add template_source_id column to projects (Phase 8)\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN template_source_id INTEGER\")\n\n    # Migration: Add parent_id column to projects (Phase 10)\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Rename quantity_printed to quantity_acquired in project_bom_items\n    await _safe_execute(conn, \"ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired\")\n\n    # Migration: Add unit_price column to project_bom_items\n    await _safe_execute(conn, \"ALTER TABLE project_bom_items ADD COLUMN unit_price REAL\")\n\n    # Migration: Add sourcing_url column to project_bom_items\n    await _safe_execute(conn, \"ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)\")\n\n    # Migration: Rename notes to remarks in project_bom_items\n    await _safe_execute(conn, \"ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks\")\n\n    # Migration: Add show_in_switchbar column to smart_plugs\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0\")\n\n    # Migration: Add runtime tracking columns to printers\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME\")\n\n    # Migration: Add quantity column to print_archives for tracking item count\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1\")\n\n    # Migration: Add manual_start column to print_queue for staged prints\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0\")\n\n    # Migration: Add wiki_url column to maintenance_types for documentation links\n    await _safe_execute(conn, \"ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)\")\n\n    # Migration: Add ams_mapping column to print_queue for storing filament slot assignments\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT\")\n\n    # Migration: Add target_parts_count column to projects for tracking total parts needed\n    await _safe_execute(conn, \"ALTER TABLE projects ADD COLUMN target_parts_count INTEGER\")\n\n    # Migration: Make printer_id nullable in print_queue for unassigned queue items\n    # SQLite doesn't support ALTER COLUMN, so we need to recreate the table\n    # PostgreSQL gets the correct schema from create_all(), so skip this\n    if is_sqlite():\n        try:\n            result = await conn.execute(text(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'\"))\n            row = result.fetchone()\n            if row and \"printer_id INTEGER NOT NULL\" in (row[0] or \"\"):\n                await conn.execute(\n                    text(\"\"\"\n                    CREATE TABLE print_queue_new (\n                        id INTEGER PRIMARY KEY,\n                        printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,\n                        archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,\n                        project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,\n                        position INTEGER DEFAULT 0,\n                        scheduled_time DATETIME,\n                        manual_start BOOLEAN DEFAULT 0,\n                        require_previous_success BOOLEAN DEFAULT 0,\n                        auto_off_after BOOLEAN DEFAULT 0,\n                        ams_mapping TEXT,\n                        status VARCHAR(20) DEFAULT 'pending',\n                        started_at DATETIME,\n                        completed_at DATETIME,\n                        error_message TEXT,\n                        created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n                    )\n                \"\"\")\n                )\n                await conn.execute(\n                    text(\"\"\"\n                    INSERT INTO print_queue_new\n                    SELECT id, printer_id, archive_id, project_id, position, scheduled_time,\n                           manual_start, require_previous_success, auto_off_after, ams_mapping,\n                           status, started_at, completed_at, error_message, created_at\n                    FROM print_queue\n                \"\"\")\n                )\n                await conn.execute(text(\"DROP TABLE print_queue\"))\n                await conn.execute(text(\"ALTER TABLE print_queue_new RENAME TO print_queue\"))\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n    # Migration: Add plug_type column to smart_plugs for HA integration\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'\")\n\n    # Migration: Add ha_entity_id column to smart_plugs for HA integration\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)\")\n\n    # Migration: Add project_id column to library_folders for linking folders to projects\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add archive_id column to library_folders for linking folders to archives\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE library_folders ADD COLUMN archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation)\n    # PostgreSQL gets the correct schema from create_all(), so skip this\n    if is_sqlite():\n        try:\n            result = await conn.execute(text(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'\"))\n            row = result.fetchone()\n            if row and \"ip_address VARCHAR(45) NOT NULL\" in (row[0] or \"\"):\n                await conn.execute(\n                    text(\"\"\"\n                    CREATE TABLE smart_plugs_new (\n                        id INTEGER PRIMARY KEY,\n                        name VARCHAR(100) NOT NULL,\n                        ip_address VARCHAR(45),\n                        plug_type VARCHAR(20) DEFAULT 'tasmota',\n                        ha_entity_id VARCHAR(100),\n                        printer_id INTEGER UNIQUE REFERENCES printers(id) ON DELETE SET NULL,\n                        enabled BOOLEAN NOT NULL DEFAULT 1,\n                        auto_on BOOLEAN NOT NULL DEFAULT 1,\n                        auto_off BOOLEAN NOT NULL DEFAULT 1,\n                        auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,\n                        off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',\n                        off_delay_minutes INTEGER NOT NULL DEFAULT 5,\n                        off_temp_threshold INTEGER NOT NULL DEFAULT 70,\n                        username VARCHAR(50),\n                        password VARCHAR(100),\n                        power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,\n                        power_alert_high FLOAT,\n                        power_alert_low FLOAT,\n                        power_alert_last_triggered DATETIME,\n                        schedule_enabled BOOLEAN NOT NULL DEFAULT 0,\n                        schedule_on_time VARCHAR(5),\n                        schedule_off_time VARCHAR(5),\n                        show_in_switchbar BOOLEAN DEFAULT 0,\n                        last_state VARCHAR(10),\n                        last_checked DATETIME,\n                        auto_off_executed BOOLEAN NOT NULL DEFAULT 0,\n                        auto_off_pending BOOLEAN DEFAULT 0,\n                        auto_off_pending_since DATETIME,\n                        created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n                        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL\n                    )\n                \"\"\")\n                )\n                await conn.execute(\n                    text(\"\"\"\n                    INSERT INTO smart_plugs_new\n                    SELECT id, name, ip_address,\n                           COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,\n                           enabled, auto_on, auto_off, COALESCE(auto_off_persistent, 0),\n                           off_delay_mode, off_delay_minutes, off_temp_threshold,\n                           username, password, power_alert_enabled, power_alert_high, power_alert_low,\n                           power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,\n                           COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,\n                           COALESCE(auto_off_pending, 0), auto_off_pending_since, created_at, updated_at\n                    FROM smart_plugs\n                \"\"\")\n                )\n                await conn.execute(text(\"DROP TABLE smart_plugs\"))\n                await conn.execute(text(\"ALTER TABLE smart_plugs_new RENAME TO smart_plugs\"))\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n    # Migration: Add plate_id column to print_queue for multi-plate 3MF support\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN plate_id INTEGER\")\n\n    # Migration: Add print options columns to print_queue\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1\")\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1\")\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1\")\n\n    # Migration: Add library_file_id column to print_queue and make archive_id nullable\n    # This allows queue items to reference library files directly (archive created at print start)\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Check if archive_id needs to be made nullable (requires table recreation in SQLite)\n    # PostgreSQL gets the correct schema from create_all(), so skip this\n    if is_sqlite():\n        try:\n            result = await conn.execute(text(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'\"))\n            row = result.fetchone()\n            if row and \"archive_id INTEGER NOT NULL\" in (row[0] or \"\"):\n                await conn.execute(\n                    text(\"\"\"\n                    CREATE TABLE print_queue_new2 (\n                        id INTEGER PRIMARY KEY,\n                        printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,\n                        archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,\n                        library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,\n                        project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,\n                        position INTEGER DEFAULT 0,\n                        scheduled_time DATETIME,\n                        manual_start BOOLEAN DEFAULT 0,\n                        require_previous_success BOOLEAN DEFAULT 0,\n                        auto_off_after BOOLEAN DEFAULT 0,\n                        ams_mapping TEXT,\n                        plate_id INTEGER,\n                        bed_levelling BOOLEAN DEFAULT 1,\n                        flow_cali BOOLEAN DEFAULT 0,\n                        vibration_cali BOOLEAN DEFAULT 1,\n                        layer_inspect BOOLEAN DEFAULT 0,\n                        timelapse BOOLEAN DEFAULT 0,\n                        use_ams BOOLEAN DEFAULT 1,\n                        status VARCHAR(20) DEFAULT 'pending',\n                        started_at DATETIME,\n                        completed_at DATETIME,\n                        error_message TEXT,\n                        created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n                    )\n                \"\"\")\n                )\n                await conn.execute(\n                    text(\"\"\"\n                    INSERT INTO print_queue_new2\n                    SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,\n                           manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,\n                           COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),\n                           COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),\n                           status, started_at, completed_at, error_message, created_at\n                    FROM print_queue\n                \"\"\")\n                )\n                await conn.execute(text(\"DROP TABLE print_queue\"))\n                await conn.execute(text(\"ALTER TABLE print_queue_new2 RENAME TO print_queue\"))\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n    # Migration: Add HA energy sensor entity columns to smart_plugs\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)\")\n\n    # Migration: Create users table for authentication\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"\"\"\n                CREATE TABLE IF NOT EXISTS users (\n                    id INTEGER PRIMARY KEY,\n                    username VARCHAR(100) NOT NULL UNIQUE,\n                    password_hash VARCHAR(255) NOT NULL,\n                    role VARCHAR(20) NOT NULL DEFAULT 'user',\n                    is_active BOOLEAN NOT NULL DEFAULT 1,\n                    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n                    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n                )\n            \"\"\")\n            )\n            await conn.execute(text(\"CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)\"))\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add external camera columns to printers\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)\")\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)\")\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0\")\n\n    # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)\")\n\n    # Migration: Add sliced_for_model column to print_archives for model-based queue assignment\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)\")\n\n    # Migration: Add is_external column to library_files for external cloud files\n    await _safe_execute(conn, \"ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0\")\n\n    # Migration: Add project_id column to library_files\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add is_external column to library_folders for external cloud folders\n    await _safe_execute(conn, \"ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0\")\n\n    # Migration: Add external folder settings columns to library_folders\n    await _safe_execute(conn, \"ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)\")\n\n    # Migration: Add plate_detection_enabled column to printers\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0\")\n\n    # Migration: Add plate detection ROI columns to printers\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL\")\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL\")\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL\")\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL\")\n\n    # Migration: Remove UNIQUE constraint from smart_plugs.printer_id\n    # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)\n    # SQLite requires table recreation to drop constraints\n    # PostgreSQL gets the correct schema from create_all(), so skip this\n    if is_sqlite():\n        try:\n            needs_migration = False\n            result = await conn.execute(text(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'\"))\n            row = result.fetchone()\n            table_sql = (row[0] or \"\").upper() if row else \"\"\n            if \"PRINTER_ID\" in table_sql and \"UNIQUE\" in table_sql:\n                import re\n\n                if re.search(r'\"?PRINTER_ID\"?\\s+\\w+\\s+UNIQUE', table_sql) or re.search(\n                    r'UNIQUE\\s*\\([^)]*\"?PRINTER_ID\"?', table_sql\n                ):\n                    needs_migration = True\n            idx_result = await conn.execute(\n                text(\"SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='smart_plugs' AND sql IS NOT NULL\")\n            )\n            for idx_row in idx_result.fetchall():\n                idx_sql = (idx_row[0] or \"\").upper()\n                if \"UNIQUE\" in idx_sql and \"PRINTER_ID\" in idx_sql:\n                    needs_migration = True\n                    break\n            if needs_migration:\n                # Create new table without UNIQUE constraint on printer_id\n                await conn.execute(\n                    text(\"\"\"\n                    CREATE TABLE smart_plugs_temp (\n                        id INTEGER PRIMARY KEY,\n                        name VARCHAR(100) NOT NULL,\n                        ip_address VARCHAR(45),\n                        plug_type VARCHAR(20) DEFAULT 'tasmota',\n                        ha_entity_id VARCHAR(100),\n                        ha_power_entity VARCHAR(100),\n                        ha_energy_today_entity VARCHAR(100),\n                        ha_energy_total_entity VARCHAR(100),\n                        printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,\n                        enabled BOOLEAN NOT NULL DEFAULT 1,\n                        auto_on BOOLEAN NOT NULL DEFAULT 1,\n                        auto_off BOOLEAN NOT NULL DEFAULT 1,\n                        auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,\n                        off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',\n                        off_delay_minutes INTEGER NOT NULL DEFAULT 5,\n                        off_temp_threshold INTEGER NOT NULL DEFAULT 70,\n                        username VARCHAR(50),\n                        password VARCHAR(100),\n                        power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,\n                        power_alert_high FLOAT,\n                        power_alert_low FLOAT,\n                        power_alert_last_triggered DATETIME,\n                        schedule_enabled BOOLEAN NOT NULL DEFAULT 0,\n                        schedule_on_time VARCHAR(5),\n                        schedule_off_time VARCHAR(5),\n                        show_in_switchbar BOOLEAN DEFAULT 0,\n                        last_state VARCHAR(10),\n                        last_checked DATETIME,\n                        auto_off_executed BOOLEAN NOT NULL DEFAULT 0,\n                        auto_off_pending BOOLEAN DEFAULT 0,\n                        auto_off_pending_since DATETIME,\n                        created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n                        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL\n                    )\n                \"\"\")\n                )\n                # Copy data\n                await conn.execute(\n                    text(\"\"\"\n                    INSERT INTO smart_plugs_temp\n                    SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,\n                           ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,\n                           auto_on, auto_off, COALESCE(auto_off_persistent, 0),\n                           off_delay_mode, off_delay_minutes, off_temp_threshold,\n                           username, password, power_alert_enabled, power_alert_high, power_alert_low,\n                           power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,\n                           show_in_switchbar, last_state, last_checked, auto_off_executed,\n                           auto_off_pending, auto_off_pending_since, created_at, updated_at\n                    FROM smart_plugs\n                \"\"\")\n                )\n                # Drop old table and rename new one\n                await conn.execute(text(\"DROP TABLE smart_plugs\"))\n                await conn.execute(text(\"ALTER TABLE smart_plugs_temp RENAME TO smart_plugs\"))\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n    # Migration: Add show_on_printer_card column to smart_plugs\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1\")\n\n    # Migration: Add MQTT smart plug fields (legacy)\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0\")\n\n    # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)\")\n\n    # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"\"\"\n                UPDATE smart_plugs\n                SET mqtt_power_topic = mqtt_topic,\n                    mqtt_power_multiplier = mqtt_multiplier\n                WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL\n            \"\"\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Create groups table for permission-based access control\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"\"\"\n                CREATE TABLE IF NOT EXISTS groups (\n                    id INTEGER PRIMARY KEY,\n                    name VARCHAR(100) NOT NULL UNIQUE,\n                    description VARCHAR(500),\n                    permissions JSON,\n                    is_system BOOLEAN NOT NULL DEFAULT 0,\n                    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n                    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n                )\n            \"\"\")\n            )\n            await conn.execute(text(\"CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)\"))\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Create user_groups association table\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"\"\"\n                CREATE TABLE IF NOT EXISTS user_groups (\n                    user_id INTEGER NOT NULL,\n                    group_id INTEGER NOT NULL,\n                    PRIMARY KEY (user_id, group_id),\n                    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n                    FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE\n                )\n            \"\"\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add model-based queue assignment columns to print_queue\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)\")\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT\")\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT\")\n\n    # Migration: Add nozzle_count column to printers (for dual-extruder detection)\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN nozzle_count INTEGER DEFAULT 1\")\n\n    # Migration: Add print_hours_offset column to printers (baseline hours adjustment)\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN print_hours_offset REAL DEFAULT 0.0\")\n\n    # Migration: Add queue notification event columns to notification_providers\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_queue_job_added BOOLEAN DEFAULT 0\")\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE notification_providers ADD COLUMN on_queue_job_assigned BOOLEAN DEFAULT 0\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_queue_job_started BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_queue_job_waiting BOOLEAN DEFAULT 1\")\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_queue_job_skipped BOOLEAN DEFAULT 1\")\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_queue_job_failed BOOLEAN DEFAULT 1\")\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_queue_completed BOOLEAN DEFAULT 0\")\n\n    # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add created_by_id column to library_files for user tracking (Issue #206)\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)\")\n\n    # Migration: Convert absolute paths to relative paths in library_files table\n    # This ensures backup/restore portability across different installations\n    try:\n        async with conn.begin_nested():\n            base_dir_str = str(settings.base_dir)\n            # Ensure we have a trailing slash for clean replacement\n            if not base_dir_str.endswith(\"/\"):\n                base_dir_str += \"/\"\n\n            # Update file_path - remove base_dir prefix from absolute paths\n            await conn.execute(\n                text(\"\"\"\n                UPDATE library_files\n                SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1)\n                WHERE file_path LIKE :pattern\n            \"\"\"),\n                {\"base_dir\": base_dir_str, \"pattern\": base_dir_str + \"%\"},\n            )\n\n            # Update thumbnail_path - remove base_dir prefix from absolute paths\n            await conn.execute(\n                text(\"\"\"\n                UPDATE library_files\n                SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1)\n                WHERE thumbnail_path LIKE :pattern\n            \"\"\"),\n                {\"base_dir\": base_dir_str, \"pattern\": base_dir_str + \"%\"},\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Create active_print_spoolman table for Spoolman per-filament tracking\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"\"\"\n                CREATE TABLE IF NOT EXISTS active_print_spoolman (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,\n                    archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,\n                    filament_usage TEXT NOT NULL,\n                    ams_trays TEXT NOT NULL,\n                    slot_to_tray TEXT,\n                    layer_usage TEXT,\n                    filament_properties TEXT,\n                    UNIQUE(printer_id, archive_id)\n                )\n            \"\"\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add preset_source column to slot_preset_mappings for local preset support\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE slot_preset_mappings ADD COLUMN preset_source VARCHAR(20) DEFAULT 'cloud'\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add email column to users for Advanced Auth (PR #322)\n    await _safe_execute(conn, \"ALTER TABLE users ADD COLUMN email VARCHAR(255)\")\n\n    # Migration: Add inventory spool tracking columns\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN added_full BOOLEAN\")\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN last_used DATETIME\")\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN encode_time DATETIME\")\n\n    # Migration: Add RFID tag matching columns to spool\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN tag_uid VARCHAR(16)\")\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN tray_uuid VARCHAR(32)\")\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN data_origin VARCHAR(20)\")\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN tag_type VARCHAR(20)\")\n\n    # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER\")\n\n    # Migration: Create spool_usage_history table for filament consumption tracking\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"\"\"\n                CREATE TABLE IF NOT EXISTS spool_usage_history (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,\n                    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,\n                    print_name VARCHAR(500),\n                    weight_used REAL NOT NULL DEFAULT 0,\n                    percent_used INTEGER NOT NULL DEFAULT 0,\n                    status VARCHAR(20) NOT NULL DEFAULT 'completed',\n                    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n                )\n            \"\"\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add open_in_new_tab column to external_links\n    await _safe_execute(conn, \"ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0\")\n\n    # Migration: Add bed cooled notification column to notification_providers\n    await _safe_execute(conn, \"ALTER TABLE notification_providers ADD COLUMN on_bed_cooled BOOLEAN DEFAULT 0\")\n\n    # Migration: Add first layer complete notification column to notification_providers\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE notification_providers ADD COLUMN on_first_layer_complete BOOLEAN DEFAULT 0\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Add weight_locked flag to spool table (skip AMS auto-sync for manually-entered weights)\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN weight_locked BOOLEAN DEFAULT 0\")\n\n    # Migration: Add SpoolBuddy scale weight tracking columns to spool table\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN last_scale_weight INTEGER\")\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN last_weighed_at DATETIME\")\n\n    # Migration: Add cost tracking fields to spool table\n    await _safe_execute(conn, \"ALTER TABLE spool ADD COLUMN cost_per_kg REAL\")\n    # Migration: Add cost field to spool_usage_history table\n    await _safe_execute(conn, \"ALTER TABLE spool_usage_history ADD COLUMN cost REAL\")\n    # Migration: Add archive_id field to spool_usage_history table\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE spool_usage_history ADD COLUMN archive_id INTEGER REFERENCES print_archives(id)\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Migration: Migrate single virtual printer key-value settings to virtual_printers table\n    try:\n        async with conn.begin_nested():\n            result = await conn.execute(text(\"SELECT COUNT(*) FROM virtual_printers\"))\n            count = result.scalar() or 0\n\n            if count == 0:\n                result = await conn.execute(text(\"SELECT value FROM settings WHERE key = 'virtual_printer_enabled'\"))\n                row = result.fetchone()\n                if row:\n                    # Old settings exist — migrate to first virtual printer row\n                    old_enabled = row[0] == \"true\" if row[0] else False\n\n                    result = await conn.execute(\n                        text(\"SELECT value FROM settings WHERE key = 'virtual_printer_access_code'\")\n                    )\n                    row = result.fetchone()\n                    old_access_code = row[0] if row else None\n\n                    result = await conn.execute(text(\"SELECT value FROM settings WHERE key = 'virtual_printer_mode'\"))\n                    row = result.fetchone()\n                    old_mode = row[0] if row else \"immediate\"\n                    if old_mode == \"queue\":\n                        old_mode = \"review\"\n\n                    result = await conn.execute(text(\"SELECT value FROM settings WHERE key = 'virtual_printer_model'\"))\n                    row = result.fetchone()\n                    old_model = row[0] if row else \"BL-P001\"\n\n                    result = await conn.execute(\n                        text(\"SELECT value FROM settings WHERE key = 'virtual_printer_target_printer_id'\")\n                    )\n                    row = result.fetchone()\n                    old_target_id = int(row[0]) if row and row[0] else None\n\n                    result = await conn.execute(\n                        text(\"SELECT value FROM settings WHERE key = 'virtual_printer_remote_interface_ip'\")\n                    )\n                    row = result.fetchone()\n                    old_remote_iface = row[0] if row else None\n\n                    await conn.execute(\n                        text(\"\"\"\n                            INSERT INTO virtual_printers\n                                (name, enabled, mode, model, access_code, target_printer_id,\n                                 bind_ip, remote_interface_ip, serial_suffix, position)\n                            VALUES\n                                (:name, :enabled, :mode, :model, :access_code, :target_id,\n                                 NULL, :remote_iface, '391800001', 0)\n                        \"\"\"),\n                        {\n                            \"name\": \"Bambuddy\",\n                            \"enabled\": old_enabled,\n                            \"mode\": old_mode or \"immediate\",\n                            \"model\": old_model,\n                            \"access_code\": old_access_code,\n                            \"target_id\": old_target_id,\n                            \"remote_iface\": old_remote_iface,\n                        },\n                    )\n    except (OperationalError, ProgrammingError, IntegrityError):\n        pass  # Table may not exist yet on first run, or columns have different constraints\n\n    # Migration: Add filament_overrides column to print_queue for filament override in model-based assignment\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN filament_overrides TEXT\")\n\n    # Migration: Add NFC reader and display control columns to spoolbuddy_devices\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_reader_type VARCHAR(20)\")\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_connection VARCHAR(20)\")\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN display_brightness INTEGER DEFAULT 100\")\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN display_blank_timeout INTEGER DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN has_backlight BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN last_calibrated_at DATETIME\")\n\n    # Migration: Add NFC tag write payload column to spoolbuddy_devices\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN pending_write_payload TEXT\")\n\n    # Migration: Add OTA update tracking columns to spoolbuddy_devices\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN update_status VARCHAR(20)\")\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN update_message VARCHAR(255)\")\n\n    # Migration: Persist SpoolBuddy backend URL and queued system payload\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN backend_url VARCHAR(255)\")\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN pending_system_payload TEXT\")\n\n    # Migration: Add system_stats JSON blob column to spoolbuddy_devices\n    await _safe_execute(conn, \"ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT\")\n\n    # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key\n    # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.\n    # PostgreSQL gets the correct schema from create_all(), so skip this\n    if is_sqlite():\n        try:\n            await conn.execute(text(\"DROP TABLE IF EXISTS ams_labels_new\"))\n            result = await conn.execute(text(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='ams_labels'\"))\n            row = result.fetchone()\n            if row and \"printer_id\" in (row[0] or \"\"):\n                # Old schema: rebuild the table with ams_serial_number as the unique key.\n                # Existing rows get a synthetic serial \"p{printer_id}a{ams_id}\" so data is preserved.\n                await conn.execute(\n                    text(\"\"\"\n                    CREATE TABLE ams_labels_new (\n                        id INTEGER PRIMARY KEY,\n                        ams_serial_number VARCHAR(50) NOT NULL,\n                        ams_id INTEGER,\n                        label VARCHAR(100) NOT NULL,\n                        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n                        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n                        CONSTRAINT uq_ams_label_serial UNIQUE (ams_serial_number)\n                    )\n                \"\"\")\n                )\n                await conn.execute(\n                    text(\"\"\"\n                    INSERT INTO ams_labels_new (id, ams_serial_number, ams_id, label, created_at, updated_at)\n                    SELECT id,\n                           'p' || CAST(printer_id AS TEXT) || 'a' || CAST(ams_id AS TEXT),\n                           ams_id,\n                           label,\n                           created_at,\n                           updated_at\n                    FROM ams_labels\n                \"\"\")\n                )\n                await conn.execute(text(\"DROP TABLE ams_labels\"))\n                await conn.execute(text(\"ALTER TABLE ams_labels_new RENAME TO ams_labels\"))\n        except (OperationalError, ProgrammingError):\n            pass  # Already migrated or table does not exist yet\n\n    # Migration: Add auto_dispatch column to virtual_printers\n    await _safe_execute(conn, \"ALTER TABLE virtual_printers ADD COLUMN auto_dispatch BOOLEAN DEFAULT 1\")\n\n    # Migration: Fix VP model codes — convert legacy SSDP codes and display names to correct SSDP codes\n    # Legacy codes (from multi-VP refactor) and display names (from proxy auto-inherit)\n    vp_model_fixes = {\n        \"3DPrinter-X1-Carbon\": \"BL-P001\",\n        \"3DPrinter-X1\": \"BL-P002\",\n        \"X1C\": \"BL-P001\",\n        \"X1\": \"BL-P002\",\n        \"X1E\": \"C13\",\n        \"X2D\": \"N6\",\n        \"P1P\": \"C11\",\n        \"P1S\": \"C12\",\n        \"P2S\": \"N7\",\n        \"A1\": \"N2S\",\n        \"A1 Mini\": \"N1\",\n        \"H2D\": \"O1D\",\n        \"H2C\": \"O1C\",\n        \"H2S\": \"O1S\",\n    }\n    for old_val, new_val in vp_model_fixes.items():\n        await conn.execute(\n            text(\"UPDATE virtual_printers SET model = :new WHERE model = :old\"),\n            {\"old\": old_val, \"new\": new_val},\n        )\n        await conn.execute(\n            text(\"UPDATE settings SET value = :new WHERE key = 'virtual_printer_model' AND value = :old\"),\n            {\"old\": old_val, \"new\": new_val},\n        )\n\n    # Migration: Add per-user Bambu Cloud credential columns\n    await _safe_execute(conn, \"ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)\")\n    await _safe_execute(conn, \"ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)\")\n    await _safe_execute(conn, \"ALTER TABLE users ADD COLUMN cloud_region VARCHAR(10)\")\n\n    # Cleanup: Remove obsolete settings keys that are no longer used\n    obsolete_keys = [\"slicer_binary_path\"]\n    for key in obsolete_keys:\n        await conn.execute(text(\"DELETE FROM settings WHERE key = :key\"), {\"key\": key})\n\n    # Migration: Create user_email_preferences table for user-specific email notification settings\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"\"\"\n                CREATE TABLE IF NOT EXISTS user_email_preferences (\n                    id INTEGER PRIMARY KEY,\n                    user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,\n                    notify_print_start BOOLEAN NOT NULL DEFAULT 1,\n                    notify_print_complete BOOLEAN NOT NULL DEFAULT 1,\n                    notify_print_failed BOOLEAN NOT NULL DEFAULT 1,\n                    notify_print_stopped BOOLEAN NOT NULL DEFAULT 1,\n                    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n                    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n                )\n            \"\"\")\n            )\n            await conn.execute(\n                text(\"CREATE INDEX IF NOT EXISTS ix_user_email_preferences_user_id ON user_email_preferences(user_id)\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Already applied\n\n    # Legacy migration: Add notify_print_stopped column (for any existing partial tables)\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\"ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1\")\n            )\n    except (OperationalError, ProgrammingError):\n        pass  # Column already exists or table created with full schema\n\n    # Migration: Add camera_rotation column to printers\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN camera_rotation INTEGER DEFAULT 0\")\n\n    # Migration: Add awaiting_plate_clear column to printers (#961)\n    await _safe_execute(conn, \"ALTER TABLE printers ADD COLUMN awaiting_plate_clear BOOLEAN DEFAULT FALSE NOT NULL\")\n\n    # Migration: Add REST/Webhook smart plug fields\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_on_url VARCHAR(500)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_on_body TEXT\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_off_url VARCHAR(500)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_off_body TEXT\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_method VARCHAR(10)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_headers TEXT\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_status_url VARCHAR(500)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_status_path VARCHAR(200)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_status_on_value VARCHAR(50)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_power_path VARCHAR(200)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_energy_path VARCHAR(200)\")\n\n    # Migration: Add separate REST power/energy URLs and multipliers\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_power_url VARCHAR(500)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_power_multiplier REAL DEFAULT 1.0\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_energy_url VARCHAR(500)\")\n    await _safe_execute(conn, \"ALTER TABLE smart_plugs ADD COLUMN rest_energy_multiplier REAL DEFAULT 1.0\")\n\n    # Migration: Add batch_id column to print_queue for batch grouping\n    try:\n        async with conn.begin_nested():\n            await conn.execute(\n                text(\n                    \"ALTER TABLE print_queue ADD COLUMN batch_id INTEGER REFERENCES print_batches(id) ON DELETE SET NULL\"\n                )\n            )\n    except (OperationalError, ProgrammingError):\n        pass\n\n    # Migration: Shortest-job-first scheduling columns on print_queue\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER\")\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL\")\n\n    # Migration: Auto-print G-code injection (#422)\n    await _safe_execute(conn, \"ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL\")\n\n    # Migration: Add backup_spools and backup_archives columns to github_backup_config\n    await _safe_execute(conn, \"ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0\")\n    await _safe_execute(conn, \"ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0\")\n\n    # Migration: Widen columns where SQLite allowed data beyond the declared VARCHAR limit\n    if not is_sqlite():\n        await _safe_execute(conn, \"ALTER TABLE api_keys ALTER COLUMN key_hash TYPE VARCHAR(255)\")\n        await _safe_execute(conn, \"ALTER TABLE api_keys ALTER COLUMN key_prefix TYPE VARCHAR(20)\")\n        await _safe_execute(conn, \"ALTER TABLE print_archives ALTER COLUMN filament_color TYPE VARCHAR(200)\")\n\n    # Migration: Create GIN index for full-text search on PostgreSQL\n    # (SQLite uses FTS5 virtual table instead, set up above)\n    if not is_sqlite():\n        try:\n            await conn.execute(\n                text(\"\"\"\n                CREATE INDEX IF NOT EXISTS idx_archives_fulltext\n                ON print_archives\n                USING GIN (to_tsvector('simple',\n                    COALESCE(print_name, '') || ' ' ||\n                    COALESCE(filename, '') || ' ' ||\n                    COALESCE(tags, '') || ' ' ||\n                    COALESCE(notes, '') || ' ' ||\n                    COALESCE(designer, '') || ' ' ||\n                    COALESCE(filament_type, '')\n                ))\n            \"\"\")\n            )\n        except (OperationalError, ProgrammingError):\n            pass  # Already applied\n\n    # Migration: Normalize empty printer_ids [] to NULL (global access) on API keys\n    # Previously both None and [] meant \"all printers\"; now [] means \"no printers\"\n    await _safe_execute(conn, \"UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'\")\n\n    # Migration: Add auth_source column to users for LDAP support (#794)\n    await _safe_execute(conn, \"ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local' NOT NULL\")\n\n    # Migration: Make password_hash nullable for LDAP users (#794)\n    # LDAP users have no local password — the column must allow NULL so auto-provisioning\n    # doesn't hit a NOT NULL constraint failure on upgraded installs whose users table was\n    # originally created before LDAP support landed.\n    if is_sqlite():\n        # SQLite can't ALTER COLUMN; patch sqlite_master directly via writable_schema.\n        # Bump schema_version afterwards so SQLite reloads the table definition from disk —\n        # without that bump, the current connection keeps enforcing the old NOT NULL from\n        # its cached schema. Safe because row data is untouched and the replace() is a\n        # no-op if the constraint has already been removed.\n        try:\n            result = await conn.execute(text(\"SELECT sql FROM sqlite_master WHERE type='table' AND name='users'\"))\n            users_sql = result.scalar()\n            if users_sql and \"password_hash VARCHAR(255) NOT NULL\" in users_sql:\n                version_result = await conn.execute(text(\"PRAGMA schema_version\"))\n                schema_version = version_result.scalar() or 0\n                await conn.execute(text(\"PRAGMA writable_schema = ON\"))\n                await conn.execute(\n                    text(\n                        \"UPDATE sqlite_master \"\n                        \"SET sql = replace(sql, 'password_hash VARCHAR(255) NOT NULL', 'password_hash VARCHAR(255)') \"\n                        \"WHERE type = 'table' AND name = 'users'\"\n                    )\n                )\n                await conn.execute(text(f\"PRAGMA schema_version = {schema_version + 1}\"))\n                await conn.execute(text(\"PRAGMA writable_schema = OFF\"))\n        except (OperationalError, ProgrammingError):\n            pass\n    else:\n        await _safe_execute(conn, \"ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL\")\n\n    # Migration: Add energy_start_kwh to print_archives (#941)\n    # Persists the smart plug lifetime counter captured at print start, so per-print\n    # energy tracking survives a backend restart mid-print.\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN energy_start_kwh REAL\")\n\n    # Migration: Add subtask_id to print_archives (#972)\n    # MQTT-provided task identifier used to resume the same archive row across a\n    # backend restart mid-print. Without it, a long print (e.g. 13h) triggers\n    # stale-cancel + new-archive, losing started_at continuity.\n    await _safe_execute(conn, \"ALTER TABLE print_archives ADD COLUMN subtask_id VARCHAR(64)\")\n\n    # Migration: Create smart_plug_energy_snapshots table (#941)\n    # Hourly snapshots of each plug's lifetime counter, so date-range queries in\n    # \"total consumption\" energy mode can compute (last - first) deltas.\n    await _safe_execute(\n        conn,\n        \"\"\"\n        CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,\n            recorded_at DATETIME NOT NULL,\n            lifetime_kwh REAL NOT NULL\n        )\n        \"\"\"\n        if is_sqlite()\n        else \"\"\"\n        CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (\n            id SERIAL PRIMARY KEY,\n            plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,\n            recorded_at TIMESTAMP NOT NULL,\n            lifetime_kwh REAL NOT NULL\n        )\n        \"\"\",\n    )\n    await _safe_execute(\n        conn,\n        \"CREATE INDEX IF NOT EXISTS ix_plug_energy_snapshots_plug_time \"\n        \"ON smart_plug_energy_snapshots(plug_id, recorded_at)\",\n    )\n\n    # Migration: Add PKCE code_verifier column to auth_ephemeral_tokens\n    await _safe_execute(conn, \"ALTER TABLE auth_ephemeral_tokens ADD COLUMN code_verifier VARCHAR(128)\")\n\n    # Migration: Add TOTP replay-protection counter to user_totp\n    await _safe_execute(conn, \"ALTER TABLE user_totp ADD COLUMN last_totp_counter BIGINT\")\n\n    # Migration: Add challenge_id for pre-auth token client binding (HttpOnly cookie)\n    await _safe_execute(conn, \"ALTER TABLE auth_ephemeral_tokens ADD COLUMN challenge_id VARCHAR(128)\")\n\n    # Migration: Add auto_link_existing_accounts column to oidc_providers (M-4)\n    await _safe_execute(conn, \"ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 1\")\n\n    # Migration: Add password_changed_at to users (M-R7-B)\n    # Tracks the last time a user's password was changed/reset.  JWTs whose iat\n    # predates this timestamp are rejected in all six auth validation paths.\n    # R4 fix: TIMESTAMP is accepted by both SQLite and PostgreSQL; DATETIME\n    # is rejected by Postgres (\"type 'datetime' does not exist\"), which made\n    # _safe_execute swallow the error and leave existing Postgres installs\n    # without the column — causing UndefinedColumnError on every User query.\n    await _safe_execute(conn, \"ALTER TABLE users ADD COLUMN password_changed_at TIMESTAMP\")\n\n    # Migration: Back-fill password_changed_at = created_at for existing users (I2).\n    # Users who never changed their password would have NULL here, meaning old\n    # tokens could never be invalidated via the freshness check.  Setting it to\n    # created_at is conservative: any token issued before the account was created\n    # is always invalid, so this is a safe lower bound.\n    await _safe_execute(\n        conn,\n        \"UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL\",\n    )\n\n    # Seed default settings keys that must exist on fresh install\n    default_settings = [\n        (\"advanced_auth_enabled\", \"false\"),\n        (\"smtp_auth_enabled\", \"true\"),\n    ]\n    for key, value in default_settings:\n        try:\n            if is_sqlite():\n                await conn.execute(\n                    text(\"INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)\"),\n                    {\"key\": key, \"value\": value},\n                )\n            else:\n                await conn.execute(\n                    text(\"INSERT INTO settings (key, value) VALUES (:key, :value) ON CONFLICT (key) DO NOTHING\"),\n                    {\"key\": key, \"value\": value},\n                )\n        except (OperationalError, ProgrammingError):\n            pass\n\n\nasync def seed_notification_templates():\n    \"\"\"Seed default notification templates if they don't exist.\"\"\"\n    from sqlalchemy import select\n\n    from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate\n\n    async with async_session() as session:\n        # Get existing template event types\n        result = await session.execute(select(NotificationTemplate.event_type))\n        existing_types = {row[0] for row in result.fetchall()}\n\n        if not existing_types:\n            # No templates exist - insert all defaults\n            for template_data in DEFAULT_TEMPLATES:\n                template = NotificationTemplate(\n                    event_type=template_data[\"event_type\"],\n                    name=template_data[\"name\"],\n                    title_template=template_data[\"title_template\"],\n                    body_template=template_data[\"body_template\"],\n                    is_default=True,\n                )\n                session.add(template)\n        else:\n            # Templates exist - only add missing ones\n            for template_data in DEFAULT_TEMPLATES:\n                if template_data[\"event_type\"] not in existing_types:\n                    template = NotificationTemplate(\n                        event_type=template_data[\"event_type\"],\n                        name=template_data[\"name\"],\n                        title_template=template_data[\"title_template\"],\n                        body_template=template_data[\"body_template\"],\n                        is_default=True,\n                    )\n                    session.add(template)\n\n        await session.commit()\n\n\nasync def seed_default_groups():\n    \"\"\"Seed default groups and migrate existing users to appropriate groups.\n\n    Creates the default system groups (Administrators, Operators, Viewers) if they\n    don't exist, then migrates existing users:\n    - Users with role='admin' -> Administrators group\n    - Users with role='user' -> Operators group\n\n    Also migrates old permissions to new ownership-based permissions (Issue #205).\n    \"\"\"\n    import logging\n\n    from sqlalchemy import select\n\n    from backend.app.core.permissions import DEFAULT_GROUPS\n    from backend.app.models.group import Group\n    from backend.app.models.user import User\n\n    logger = logging.getLogger(__name__)\n\n    # Map old permissions to new ones for migration\n    # Administrators get *_all permissions, Operators get *_own permissions\n    PERMISSION_MIGRATION_ALL = {\n        \"queue:update\": \"queue:update_all\",\n        \"queue:delete\": \"queue:delete_all\",\n        \"archives:update\": \"archives:update_all\",\n        \"archives:delete\": \"archives:delete_all\",\n        \"archives:reprint\": \"archives:reprint_all\",\n        \"library:update\": \"library:update_all\",\n        \"library:delete\": \"library:delete_all\",\n    }\n\n    PERMISSION_MIGRATION_OWN = {\n        \"queue:update\": \"queue:update_own\",\n        \"queue:delete\": \"queue:delete_own\",\n        \"archives:update\": \"archives:update_own\",\n        \"archives:delete\": \"archives:delete_own\",\n        \"archives:reprint\": \"archives:reprint_own\",\n        \"library:update\": \"library:update_own\",\n        \"library:delete\": \"library:delete_own\",\n    }\n\n    async with async_session() as session:\n        # Get existing groups\n        result = await session.execute(select(Group))\n        existing_groups = {group.name: group for group in result.scalars().all()}\n\n        # Create default groups if they don't exist\n        groups_created = []\n        for group_name, group_config in DEFAULT_GROUPS.items():\n            if group_name not in existing_groups:\n                group = Group(\n                    name=group_name,\n                    description=group_config[\"description\"],\n                    permissions=group_config[\"permissions\"],\n                    is_system=group_config[\"is_system\"],\n                )\n                session.add(group)\n                groups_created.append(group_name)\n                logger.info(\"Created default group: %s\", group_name)\n            else:\n                # Migrate existing group's permissions from old to new format\n                group = existing_groups[group_name]\n                if group.permissions:\n                    updated = False\n                    new_permissions = list(group.permissions)\n\n                    # Determine which migration map to use based on group\n                    migration_map = (\n                        PERMISSION_MIGRATION_ALL if group_name == \"Administrators\" else PERMISSION_MIGRATION_OWN\n                    )\n\n                    for old_perm, new_perm in migration_map.items():\n                        if old_perm in new_permissions:\n                            new_permissions.remove(old_perm)\n                            if new_perm not in new_permissions:\n                                new_permissions.append(new_perm)\n                            updated = True\n                            logger.info(\n                                \"Migrated permission '%s' to '%s' in group '%s'\", old_perm, new_perm, group_name\n                            )\n\n                    # For Administrators, also ensure they get *_all permissions if they have any new *_own\n                    if group_name == \"Administrators\":\n                        for _own_perm, all_perm in [\n                            (\"queue:update_own\", \"queue:update_all\"),\n                            (\"queue:delete_own\", \"queue:delete_all\"),\n                            (\"archives:update_own\", \"archives:update_all\"),\n                            (\"archives:delete_own\", \"archives:delete_all\"),\n                            (\"archives:reprint_own\", \"archives:reprint_all\"),\n                            (\"library:update_own\", \"library:update_all\"),\n                            (\"library:delete_own\", \"library:delete_all\"),\n                        ]:\n                            # Add *_all if not present\n                            if all_perm not in new_permissions:\n                                new_permissions.append(all_perm)\n                                updated = True\n\n                    if updated:\n                        group.permissions = new_permissions\n\n        await session.commit()\n\n        # Migrate new permissions: grant printers:clear_plate to all groups with printers:control\n        result = await session.execute(select(Group))\n        all_groups = result.scalars().all()\n        for group in all_groups:\n            if (\n                group.permissions\n                and \"printers:control\" in group.permissions\n                and \"printers:clear_plate\" not in group.permissions\n            ):\n                group.permissions = [*group.permissions, \"printers:clear_plate\"]\n                logger.info(\"Added printers:clear_plate to group '%s' (has printers:control)\", group.name)\n        await session.commit()\n\n        # Migrate existing users to groups if they're not already in any group\n        if groups_created:\n            # Refresh to get newly created groups\n            admin_result = await session.execute(select(Group).where(Group.name == \"Administrators\"))\n            admin_group = admin_result.scalar_one_or_none()\n\n            operators_result = await session.execute(select(Group).where(Group.name == \"Operators\"))\n            operators_group = operators_result.scalar_one_or_none()\n\n            # Get all users\n            users_result = await session.execute(select(User))\n            users = users_result.scalars().all()\n\n            for user in users:\n                # Skip if user already has groups\n                if user.groups:\n                    continue\n\n                if user.role == \"admin\" and admin_group:\n                    user.groups.append(admin_group)\n                    logger.info(\"Migrated admin user '%s' to Administrators group\", user.username)\n                elif operators_group:\n                    user.groups.append(operators_group)\n                    logger.info(\"Migrated user '%s' to Operators group\", user.username)\n\n            await session.commit()\n\n\nasync def seed_spool_catalog():\n    \"\"\"Seed the spool catalog with default entries if empty.\"\"\"\n    import logging\n\n    from sqlalchemy import func, select\n\n    from backend.app.core.catalog_defaults import DEFAULT_SPOOL_CATALOG\n    from backend.app.models.spool_catalog import SpoolCatalogEntry\n\n    logger = logging.getLogger(__name__)\n\n    async with async_session() as session:\n        result = await session.execute(select(func.count()).select_from(SpoolCatalogEntry))\n        count = result.scalar() or 0\n        if count > 0:\n            return  # Already seeded\n\n        for name, weight in DEFAULT_SPOOL_CATALOG:\n            session.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))\n        await session.commit()\n        logger.info(\"Seeded %d default spool catalog entries\", len(DEFAULT_SPOOL_CATALOG))\n\n\nasync def seed_color_catalog():\n    \"\"\"Seed the color catalog with default entries if empty.\"\"\"\n    import logging\n\n    from sqlalchemy import func, select\n\n    from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG\n    from backend.app.models.color_catalog import ColorCatalogEntry\n\n    logger = logging.getLogger(__name__)\n\n    async with async_session() as session:\n        result = await session.execute(select(func.count()).select_from(ColorCatalogEntry))\n        count = result.scalar() or 0\n        if count > 0:\n            return  # Already seeded\n\n        for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:\n            session.add(\n                ColorCatalogEntry(\n                    manufacturer=manufacturer,\n                    color_name=color_name,\n                    hex_color=hex_color,\n                    material=material,\n                    is_default=True,\n                )\n            )\n        await session.commit()\n        logger.info(\"Seeded %d default color catalog entries\", len(DEFAULT_COLOR_CATALOG))\n"
  },
  {
    "path": "backend/app/core/db_dialect.py",
    "content": "\"\"\"Database dialect helpers for SQLite/PostgreSQL dual support.\n\nBambuddy defaults to SQLite (zero-config). When DATABASE_URL points to PostgreSQL,\nthese helpers ensure dialect-specific operations use the correct SQL.\n\"\"\"\n\nfrom sqlalchemy import func, text\n\n\ndef is_postgres() -> bool:\n    \"\"\"Check if using PostgreSQL based on DATABASE_URL.\"\"\"\n    from backend.app.core.config import settings\n\n    return settings.database_url.startswith(\"postgresql\")\n\n\ndef is_sqlite() -> bool:\n    \"\"\"Check if using SQLite based on DATABASE_URL.\"\"\"\n    from backend.app.core.config import settings\n\n    return settings.database_url.startswith(\"sqlite\")\n\n\nasync def upsert_setting(db, model, key: str, value: str):\n    \"\"\"Dialect-aware INSERT ... ON CONFLICT UPDATE for the Settings table.\"\"\"\n    if is_postgres():\n        from sqlalchemy.dialects.postgresql import insert as pg_insert\n\n        stmt = pg_insert(model).values(key=key, value=value)\n        stmt = stmt.on_conflict_do_update(\n            index_elements=[\"key\"],\n            set_={\"value\": value, \"updated_at\": func.now()},\n        )\n    else:\n        from sqlalchemy.dialects.sqlite import insert as sqlite_insert\n\n        stmt = sqlite_insert(model).values(key=key, value=value)\n        stmt = stmt.on_conflict_do_update(\n            index_elements=[\"key\"],\n            set_={\"value\": value, \"updated_at\": func.now()},\n        )\n    await db.execute(stmt)\n\n\nasync def run_pragma(conn, pragma_sql: str):\n    \"\"\"Run a PRAGMA statement only on SQLite (no-op on PostgreSQL).\"\"\"\n    if is_sqlite():\n        await conn.execute(text(pragma_sql))\n"
  },
  {
    "path": "backend/app/core/encryption.py",
    "content": "\"\"\"At-rest encryption for high-value secrets (TOTP keys, OIDC client_secret).\n\nSet the ``MFA_ENCRYPTION_KEY`` environment variable to a URL-safe base64-encoded\n32-byte key (generate with ``python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"``)\nto enable Fernet symmetric encryption.\n\nWhen the key is not set, values are stored as plaintext and a warning is emitted.\nExisting plaintext values are read back correctly even after the key is added\n(values without the ``fernet:`` prefix are treated as legacy plaintext).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport os\n\nlogger = logging.getLogger(__name__)\n\n_FERNET_PREFIX = \"fernet:\"\n_fernet_instance = None\n_warn_shown = False\n\n\ndef _get_fernet():\n    global _fernet_instance, _warn_shown\n\n    if _fernet_instance is not None:\n        return _fernet_instance\n\n    key = os.environ.get(\"MFA_ENCRYPTION_KEY\")\n    if key:\n        from cryptography.fernet import Fernet\n\n        _fernet_instance = Fernet(key.encode() if isinstance(key, str) else key)\n        return _fernet_instance\n\n    if not _warn_shown:\n        logger.warning(\n            \"MFA_ENCRYPTION_KEY is not set — TOTP secrets and OIDC client_secrets are \"\n            \"stored in plaintext. Generate a key with: \"\n            'python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"'\n        )\n        _warn_shown = True\n    return None\n\n\ndef mfa_encrypt(plaintext: str) -> str:\n    \"\"\"Encrypt a secret value. Returns the ciphertext with a ``fernet:`` prefix,\n    or the original plaintext if ``MFA_ENCRYPTION_KEY`` is not configured.\"\"\"\n    f = _get_fernet()\n    if f is None:\n        return plaintext\n    return _FERNET_PREFIX + f.encrypt(plaintext.encode()).decode()\n\n\ndef mfa_decrypt(value: str) -> str:\n    \"\"\"Decrypt a value previously encrypted with ``mfa_encrypt``.\n\n    Values without the ``fernet:`` prefix are returned as-is (legacy plaintext).\n    Raises ``RuntimeError`` if the prefix is present but no key is configured.\n    \"\"\"\n    if not value.startswith(_FERNET_PREFIX):\n        # Nit6: Warn when a key IS configured but the stored value is plaintext.\n        # This surfaces rows that were written before encryption was enabled so\n        # operators know they need a migration / re-enroll cycle.\n        if _get_fernet() is not None:\n            logger.warning(\n                \"mfa_decrypt: MFA_ENCRYPTION_KEY is set but the stored value has no \"\n                \"'fernet:' prefix — returning legacy plaintext. Consider re-enrolling \"\n                \"this secret to store it encrypted.\"\n            )\n        return value  # Legacy plaintext — backward compatible\n\n    f = _get_fernet()\n    if f is None:\n        raise RuntimeError(\n            \"MFA_ENCRYPTION_KEY must be set to decrypt MFA secrets that were stored with encryption enabled.\"\n        )\n    from cryptography.fernet import InvalidToken\n\n    try:\n        return f.decrypt(value[len(_FERNET_PREFIX) :].encode()).decode()\n    except InvalidToken:\n        raise RuntimeError(\n            \"MFA secret was encrypted under a different MFA_ENCRYPTION_KEY. \"\n            \"Key rotation is not currently supported — restore the previous key \"\n            \"or have users re-enroll.\"\n        )\n"
  },
  {
    "path": "backend/app/core/permissions.py",
    "content": "\"\"\"Permission definitions for the group-based access control system.\n\nThis module defines all permissions using a string enum with `resource:action` naming.\nPermissions are additive across groups - a user has all permissions from all their groups.\n\"\"\"\n\nfrom backend.app.core.compat import StrEnum\n\n\nclass Permission(StrEnum):\n    \"\"\"All available permissions in the system.\n\n    Permissions follow the pattern: resource:action\n    Actions typically include: read, create, update, delete, plus resource-specific actions.\n    \"\"\"\n\n    # Printers\n    PRINTERS_READ = \"printers:read\"\n    PRINTERS_CREATE = \"printers:create\"\n    PRINTERS_UPDATE = \"printers:update\"\n    PRINTERS_DELETE = \"printers:delete\"\n    PRINTERS_CONTROL = \"printers:control\"  # Start/stop/pause/resume prints\n    PRINTERS_FILES = \"printers:files\"  # Send files to printer\n    PRINTERS_AMS_RFID = \"printers:ams_rfid\"  # Re-read AMS RFID tags\n    PRINTERS_CLEAR_PLATE = \"printers:clear_plate\"  # Confirm plate cleared for next print\n\n    # Archives\n    ARCHIVES_READ = \"archives:read\"\n    ARCHIVES_CREATE = \"archives:create\"\n    ARCHIVES_UPDATE_OWN = \"archives:update_own\"\n    ARCHIVES_UPDATE_ALL = \"archives:update_all\"\n    ARCHIVES_DELETE_OWN = \"archives:delete_own\"\n    ARCHIVES_DELETE_ALL = \"archives:delete_all\"\n    ARCHIVES_REPRINT_OWN = \"archives:reprint_own\"\n    ARCHIVES_REPRINT_ALL = \"archives:reprint_all\"\n\n    # Queue\n    QUEUE_READ = \"queue:read\"\n    QUEUE_CREATE = \"queue:create\"\n    QUEUE_UPDATE_OWN = \"queue:update_own\"\n    QUEUE_UPDATE_ALL = \"queue:update_all\"\n    QUEUE_DELETE_OWN = \"queue:delete_own\"\n    QUEUE_DELETE_ALL = \"queue:delete_all\"\n    QUEUE_REORDER = \"queue:reorder\"\n\n    # Library\n    LIBRARY_READ = \"library:read\"\n    LIBRARY_UPLOAD = \"library:upload\"\n    LIBRARY_UPDATE_OWN = \"library:update_own\"\n    LIBRARY_UPDATE_ALL = \"library:update_all\"\n    LIBRARY_DELETE_OWN = \"library:delete_own\"\n    LIBRARY_DELETE_ALL = \"library:delete_all\"\n\n    # Projects\n    PROJECTS_READ = \"projects:read\"\n    PROJECTS_CREATE = \"projects:create\"\n    PROJECTS_UPDATE = \"projects:update\"\n    PROJECTS_DELETE = \"projects:delete\"\n\n    # Filaments\n    FILAMENTS_READ = \"filaments:read\"\n    FILAMENTS_CREATE = \"filaments:create\"\n    FILAMENTS_UPDATE = \"filaments:update\"\n    FILAMENTS_DELETE = \"filaments:delete\"\n\n    # Inventory (Spool Inventory, Spool Catalog, Color Catalog)\n    INVENTORY_READ = \"inventory:read\"\n    INVENTORY_CREATE = \"inventory:create\"\n    INVENTORY_UPDATE = \"inventory:update\"\n    INVENTORY_DELETE = \"inventory:delete\"\n    INVENTORY_VIEW_ASSIGNMENTS = \"inventory:view_assignments\"  # View spool-to-AMS assignments on printer cards\n\n    # Smart Plugs\n    SMART_PLUGS_READ = \"smart_plugs:read\"\n    SMART_PLUGS_CREATE = \"smart_plugs:create\"\n    SMART_PLUGS_UPDATE = \"smart_plugs:update\"\n    SMART_PLUGS_DELETE = \"smart_plugs:delete\"\n    SMART_PLUGS_CONTROL = \"smart_plugs:control\"  # Turn on/off\n\n    # Camera\n    CAMERA_VIEW = \"camera:view\"\n\n    # Maintenance\n    MAINTENANCE_READ = \"maintenance:read\"\n    MAINTENANCE_CREATE = \"maintenance:create\"\n    MAINTENANCE_UPDATE = \"maintenance:update\"\n    MAINTENANCE_DELETE = \"maintenance:delete\"\n\n    # K-Profiles\n    KPROFILES_READ = \"kprofiles:read\"\n    KPROFILES_CREATE = \"kprofiles:create\"\n    KPROFILES_UPDATE = \"kprofiles:update\"\n    KPROFILES_DELETE = \"kprofiles:delete\"\n\n    # Notifications\n    NOTIFICATIONS_READ = \"notifications:read\"\n    NOTIFICATIONS_CREATE = \"notifications:create\"\n    NOTIFICATIONS_UPDATE = \"notifications:update\"\n    NOTIFICATIONS_DELETE = \"notifications:delete\"\n    NOTIFICATIONS_USER_EMAIL = \"notifications:user_email\"  # Receive per-user print email notifications\n    # Notification Templates\n    NOTIFICATION_TEMPLATES_READ = \"notification_templates:read\"\n    NOTIFICATION_TEMPLATES_UPDATE = \"notification_templates:update\"\n\n    # External Links\n    EXTERNAL_LINKS_READ = \"external_links:read\"\n    EXTERNAL_LINKS_CREATE = \"external_links:create\"\n    EXTERNAL_LINKS_UPDATE = \"external_links:update\"\n    EXTERNAL_LINKS_DELETE = \"external_links:delete\"\n\n    # Discovery (network scanning)\n    DISCOVERY_SCAN = \"discovery:scan\"\n\n    # Firmware\n    FIRMWARE_READ = \"firmware:read\"\n    FIRMWARE_UPDATE = \"firmware:update\"\n\n    # AMS History\n    AMS_HISTORY_READ = \"ams_history:read\"\n\n    # Stats/Metrics\n    STATS_READ = \"stats:read\"\n    STATS_FILTER_BY_USER = \"stats:filter_by_user\"\n\n    # System Info\n    SYSTEM_READ = \"system:read\"\n\n    # Settings (admin-level)\n    SETTINGS_READ = \"settings:read\"\n    SETTINGS_UPDATE = \"settings:update\"\n    SETTINGS_BACKUP = \"settings:backup\"\n    SETTINGS_RESTORE = \"settings:restore\"\n\n    # GitHub Backup (admin-level)\n    GITHUB_BACKUP = \"github:backup\"\n    GITHUB_RESTORE = \"github:restore\"\n\n    # Cloud Auth (admin-level)\n    CLOUD_AUTH = \"cloud:auth\"\n\n    # API Keys (admin-level)\n    API_KEYS_READ = \"api_keys:read\"\n    API_KEYS_CREATE = \"api_keys:create\"\n    API_KEYS_UPDATE = \"api_keys:update\"\n    API_KEYS_DELETE = \"api_keys:delete\"\n\n    # Users (admin-level)\n    USERS_READ = \"users:read\"\n    USERS_CREATE = \"users:create\"\n    USERS_UPDATE = \"users:update\"\n    USERS_DELETE = \"users:delete\"\n\n    # Groups (admin-level)\n    GROUPS_READ = \"groups:read\"\n    GROUPS_CREATE = \"groups:create\"\n    GROUPS_UPDATE = \"groups:update\"\n    GROUPS_DELETE = \"groups:delete\"\n\n    # WebSocket connection\n    WEBSOCKET_CONNECT = \"websocket:connect\"\n\n\n# Permission categories for UI organization\nPERMISSION_CATEGORIES = {\n    \"Printers\": [\n        Permission.PRINTERS_READ,\n        Permission.PRINTERS_CREATE,\n        Permission.PRINTERS_UPDATE,\n        Permission.PRINTERS_DELETE,\n        Permission.PRINTERS_CONTROL,\n        Permission.PRINTERS_FILES,\n        Permission.PRINTERS_AMS_RFID,\n        Permission.PRINTERS_CLEAR_PLATE,\n    ],\n    \"Archives\": [\n        Permission.ARCHIVES_READ,\n        Permission.ARCHIVES_CREATE,\n        Permission.ARCHIVES_UPDATE_OWN,\n        Permission.ARCHIVES_UPDATE_ALL,\n        Permission.ARCHIVES_DELETE_OWN,\n        Permission.ARCHIVES_DELETE_ALL,\n        Permission.ARCHIVES_REPRINT_OWN,\n        Permission.ARCHIVES_REPRINT_ALL,\n    ],\n    \"Queue\": [\n        Permission.QUEUE_READ,\n        Permission.QUEUE_CREATE,\n        Permission.QUEUE_UPDATE_OWN,\n        Permission.QUEUE_UPDATE_ALL,\n        Permission.QUEUE_DELETE_OWN,\n        Permission.QUEUE_DELETE_ALL,\n        Permission.QUEUE_REORDER,\n    ],\n    \"Library\": [\n        Permission.LIBRARY_READ,\n        Permission.LIBRARY_UPLOAD,\n        Permission.LIBRARY_UPDATE_OWN,\n        Permission.LIBRARY_UPDATE_ALL,\n        Permission.LIBRARY_DELETE_OWN,\n        Permission.LIBRARY_DELETE_ALL,\n    ],\n    \"Projects\": [\n        Permission.PROJECTS_READ,\n        Permission.PROJECTS_CREATE,\n        Permission.PROJECTS_UPDATE,\n        Permission.PROJECTS_DELETE,\n    ],\n    \"Filaments\": [\n        Permission.FILAMENTS_READ,\n        Permission.FILAMENTS_CREATE,\n        Permission.FILAMENTS_UPDATE,\n        Permission.FILAMENTS_DELETE,\n    ],\n    \"Inventory\": [\n        Permission.INVENTORY_READ,\n        Permission.INVENTORY_CREATE,\n        Permission.INVENTORY_UPDATE,\n        Permission.INVENTORY_DELETE,\n        Permission.INVENTORY_VIEW_ASSIGNMENTS,\n    ],\n    \"Smart Plugs\": [\n        Permission.SMART_PLUGS_READ,\n        Permission.SMART_PLUGS_CREATE,\n        Permission.SMART_PLUGS_UPDATE,\n        Permission.SMART_PLUGS_DELETE,\n        Permission.SMART_PLUGS_CONTROL,\n    ],\n    \"Camera\": [\n        Permission.CAMERA_VIEW,\n    ],\n    \"Maintenance\": [\n        Permission.MAINTENANCE_READ,\n        Permission.MAINTENANCE_CREATE,\n        Permission.MAINTENANCE_UPDATE,\n        Permission.MAINTENANCE_DELETE,\n    ],\n    \"K-Profiles\": [\n        Permission.KPROFILES_READ,\n        Permission.KPROFILES_CREATE,\n        Permission.KPROFILES_UPDATE,\n        Permission.KPROFILES_DELETE,\n    ],\n    \"Notifications\": [\n        Permission.NOTIFICATIONS_READ,\n        Permission.NOTIFICATIONS_CREATE,\n        Permission.NOTIFICATIONS_UPDATE,\n        Permission.NOTIFICATIONS_DELETE,\n        Permission.NOTIFICATIONS_USER_EMAIL,\n        Permission.NOTIFICATION_TEMPLATES_READ,\n        Permission.NOTIFICATION_TEMPLATES_UPDATE,\n    ],\n    \"External Links\": [\n        Permission.EXTERNAL_LINKS_READ,\n        Permission.EXTERNAL_LINKS_CREATE,\n        Permission.EXTERNAL_LINKS_UPDATE,\n        Permission.EXTERNAL_LINKS_DELETE,\n    ],\n    \"Discovery\": [\n        Permission.DISCOVERY_SCAN,\n    ],\n    \"Firmware\": [\n        Permission.FIRMWARE_READ,\n        Permission.FIRMWARE_UPDATE,\n    ],\n    \"Stats & History\": [\n        Permission.AMS_HISTORY_READ,\n        Permission.STATS_READ,\n        Permission.STATS_FILTER_BY_USER,\n    ],\n    \"System\": [\n        Permission.SYSTEM_READ,\n    ],\n    \"Settings\": [\n        Permission.SETTINGS_READ,\n        Permission.SETTINGS_UPDATE,\n        Permission.SETTINGS_BACKUP,\n        Permission.SETTINGS_RESTORE,\n    ],\n    \"Backup\": [\n        Permission.GITHUB_BACKUP,\n        Permission.GITHUB_RESTORE,\n    ],\n    \"Cloud\": [\n        Permission.CLOUD_AUTH,\n    ],\n    \"API Keys\": [\n        Permission.API_KEYS_READ,\n        Permission.API_KEYS_CREATE,\n        Permission.API_KEYS_UPDATE,\n        Permission.API_KEYS_DELETE,\n    ],\n    \"User Management\": [\n        Permission.USERS_READ,\n        Permission.USERS_CREATE,\n        Permission.USERS_UPDATE,\n        Permission.USERS_DELETE,\n        Permission.GROUPS_READ,\n        Permission.GROUPS_CREATE,\n        Permission.GROUPS_UPDATE,\n        Permission.GROUPS_DELETE,\n    ],\n    \"WebSocket\": [\n        Permission.WEBSOCKET_CONNECT,\n    ],\n}\n\n\n# All permissions as a list\nALL_PERMISSIONS = [p.value for p in Permission]\n\n\n# Default group definitions\nDEFAULT_GROUPS = {\n    \"Administrators\": {\n        \"description\": \"Full access to all features and settings\",\n        \"permissions\": ALL_PERMISSIONS,  # All permissions\n        \"is_system\": True,\n    },\n    \"Operators\": {\n        \"description\": \"Can control printers, manage queue and archives, view settings\",\n        \"permissions\": [\n            # Printers - full control\n            Permission.PRINTERS_READ.value,\n            Permission.PRINTERS_CREATE.value,\n            Permission.PRINTERS_UPDATE.value,\n            Permission.PRINTERS_DELETE.value,\n            Permission.PRINTERS_CONTROL.value,\n            Permission.PRINTERS_FILES.value,\n            Permission.PRINTERS_AMS_RFID.value,\n            Permission.PRINTERS_CLEAR_PLATE.value,\n            # Archives - own items only\n            Permission.ARCHIVES_READ.value,\n            Permission.ARCHIVES_CREATE.value,\n            Permission.ARCHIVES_UPDATE_OWN.value,\n            Permission.ARCHIVES_DELETE_OWN.value,\n            Permission.ARCHIVES_REPRINT_OWN.value,\n            # Queue - own items only\n            Permission.QUEUE_READ.value,\n            Permission.QUEUE_CREATE.value,\n            Permission.QUEUE_UPDATE_OWN.value,\n            Permission.QUEUE_DELETE_OWN.value,\n            Permission.QUEUE_REORDER.value,\n            # Library - own items only\n            Permission.LIBRARY_READ.value,\n            Permission.LIBRARY_UPLOAD.value,\n            Permission.LIBRARY_UPDATE_OWN.value,\n            Permission.LIBRARY_DELETE_OWN.value,\n            # Projects - full access\n            Permission.PROJECTS_READ.value,\n            Permission.PROJECTS_CREATE.value,\n            Permission.PROJECTS_UPDATE.value,\n            Permission.PROJECTS_DELETE.value,\n            # Filaments - full access\n            Permission.FILAMENTS_READ.value,\n            Permission.FILAMENTS_CREATE.value,\n            Permission.FILAMENTS_UPDATE.value,\n            Permission.FILAMENTS_DELETE.value,\n            # Inventory - full access\n            Permission.INVENTORY_READ.value,\n            Permission.INVENTORY_CREATE.value,\n            Permission.INVENTORY_UPDATE.value,\n            Permission.INVENTORY_DELETE.value,\n            Permission.INVENTORY_VIEW_ASSIGNMENTS.value,\n            # Smart Plugs - full access\n            Permission.SMART_PLUGS_READ.value,\n            Permission.SMART_PLUGS_CREATE.value,\n            Permission.SMART_PLUGS_UPDATE.value,\n            Permission.SMART_PLUGS_DELETE.value,\n            Permission.SMART_PLUGS_CONTROL.value,\n            # Camera - view\n            Permission.CAMERA_VIEW.value,\n            # Maintenance - full access\n            Permission.MAINTENANCE_READ.value,\n            Permission.MAINTENANCE_CREATE.value,\n            Permission.MAINTENANCE_UPDATE.value,\n            Permission.MAINTENANCE_DELETE.value,\n            # K-Profiles - full access\n            Permission.KPROFILES_READ.value,\n            Permission.KPROFILES_CREATE.value,\n            Permission.KPROFILES_UPDATE.value,\n            Permission.KPROFILES_DELETE.value,\n            # Notifications - full access\n            Permission.NOTIFICATIONS_READ.value,\n            Permission.NOTIFICATIONS_CREATE.value,\n            Permission.NOTIFICATIONS_UPDATE.value,\n            Permission.NOTIFICATIONS_DELETE.value,\n            Permission.NOTIFICATIONS_USER_EMAIL.value,\n            Permission.NOTIFICATION_TEMPLATES_READ.value,\n            Permission.NOTIFICATION_TEMPLATES_UPDATE.value,\n            # External Links - full access\n            Permission.EXTERNAL_LINKS_READ.value,\n            Permission.EXTERNAL_LINKS_CREATE.value,\n            Permission.EXTERNAL_LINKS_UPDATE.value,\n            Permission.EXTERNAL_LINKS_DELETE.value,\n            # Discovery\n            Permission.DISCOVERY_SCAN.value,\n            # Firmware - read only\n            Permission.FIRMWARE_READ.value,\n            # Stats & History\n            Permission.AMS_HISTORY_READ.value,\n            Permission.STATS_READ.value,\n            Permission.SYSTEM_READ.value,\n            # Settings - read only\n            Permission.SETTINGS_READ.value,\n            # WebSocket\n            Permission.WEBSOCKET_CONNECT.value,\n        ],\n        \"is_system\": True,\n    },\n    \"Viewers\": {\n        \"description\": \"Read-only access to printers, archives, and queue\",\n        \"permissions\": [\n            # Read-only access\n            Permission.PRINTERS_READ.value,\n            Permission.ARCHIVES_READ.value,\n            Permission.QUEUE_READ.value,\n            Permission.LIBRARY_READ.value,\n            Permission.PROJECTS_READ.value,\n            Permission.FILAMENTS_READ.value,\n            Permission.INVENTORY_READ.value,\n            Permission.INVENTORY_VIEW_ASSIGNMENTS.value,\n            Permission.SMART_PLUGS_READ.value,\n            Permission.CAMERA_VIEW.value,\n            Permission.MAINTENANCE_READ.value,\n            Permission.KPROFILES_READ.value,\n            Permission.NOTIFICATIONS_READ.value,\n            Permission.NOTIFICATION_TEMPLATES_READ.value,\n            Permission.EXTERNAL_LINKS_READ.value,\n            Permission.FIRMWARE_READ.value,\n            Permission.AMS_HISTORY_READ.value,\n            Permission.STATS_READ.value,\n            Permission.SYSTEM_READ.value,\n            Permission.SETTINGS_READ.value,\n            Permission.WEBSOCKET_CONNECT.value,\n        ],\n        \"is_system\": True,\n    },\n}\n"
  },
  {
    "path": "backend/app/core/websocket.py",
    "content": "import asyncio\nimport json\nfrom typing import Any\n\nfrom fastapi import WebSocket\n\n\nclass ConnectionManager:\n    \"\"\"Manages WebSocket connections and broadcasts.\"\"\"\n\n    def __init__(self):\n        self.active_connections: list[WebSocket] = []\n        self._lock = asyncio.Lock()\n\n    async def connect(self, websocket: WebSocket):\n        \"\"\"Accept a new WebSocket connection.\"\"\"\n        await websocket.accept()\n        async with self._lock:\n            self.active_connections.append(websocket)\n\n    async def disconnect(self, websocket: WebSocket):\n        \"\"\"Remove a WebSocket connection.\"\"\"\n        async with self._lock:\n            if websocket in self.active_connections:\n                self.active_connections.remove(websocket)\n\n    async def broadcast(self, message: dict[str, Any]):\n        \"\"\"Broadcast a message to all connected clients.\"\"\"\n        if not self.active_connections:\n            return\n\n        data = json.dumps(message)\n        async with self._lock:\n            disconnected = []\n            for connection in self.active_connections:\n                try:\n                    await connection.send_text(data)\n                except Exception:\n                    disconnected.append(connection)\n\n            # Clean up disconnected clients\n            for conn in disconnected:\n                if conn in self.active_connections:\n                    self.active_connections.remove(conn)\n\n    async def send_printer_status(self, printer_id: int, status: dict):\n        \"\"\"Send printer status update to all clients.\"\"\"\n        await self.broadcast(\n            {\n                \"type\": \"printer_status\",\n                \"printer_id\": printer_id,\n                \"data\": status,\n            }\n        )\n\n    async def send_print_start(self, printer_id: int, data: dict):\n        \"\"\"Notify clients that a print has started.\"\"\"\n        await self.broadcast(\n            {\n                \"type\": \"print_start\",\n                \"printer_id\": printer_id,\n                \"data\": data,\n            }\n        )\n\n    async def send_print_complete(self, printer_id: int, data: dict):\n        \"\"\"Notify clients that a print has completed.\"\"\"\n        await self.broadcast(\n            {\n                \"type\": \"print_complete\",\n                \"printer_id\": printer_id,\n                \"data\": data,\n            }\n        )\n\n    async def send_archive_created(self, archive: dict):\n        \"\"\"Notify clients that a new archive was created.\"\"\"\n        await self.broadcast(\n            {\n                \"type\": \"archive_created\",\n                \"data\": archive,\n            }\n        )\n\n    async def send_archive_updated(self, archive: dict):\n        \"\"\"Notify clients that an archive was updated.\"\"\"\n        await self.broadcast(\n            {\n                \"type\": \"archive_updated\",\n                \"data\": archive,\n            }\n        )\n\n    async def send_missing_spool_assignment(\n        self,\n        printer_id: int,\n        printer_name: str,\n        missing_slots: list[dict[str, str]],\n    ):\n        \"\"\"Notify clients that a print started with missing spool assignments.\"\"\"\n        await self.broadcast(\n            {\n                \"type\": \"missing_spool_assignment\",\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"missing_slots\": missing_slots,\n            }\n        )\n\n\n# Global connection manager\nws_manager = ConnectionManager()\n"
  },
  {
    "path": "backend/app/i18n/__init__.py",
    "content": "\"\"\"Internationalization module for backend notifications.\"\"\"\n\nfrom typing import Any\n\n# English translations\nEN = {\n    \"notification\": {\n        # Print events\n        \"print_started\": \"Print Started\",\n        \"print_completed\": \"Print Completed\",\n        \"print_failed\": \"Print Failed\",\n        \"print_stopped\": \"Print Stopped\",\n        \"print_ended\": \"Print Ended\",\n        \"print_progress\": \"Print {progress}% Complete\",\n        \"estimated\": \"Estimated\",\n        \"time\": \"Time\",\n        \"filament\": \"Filament\",\n        \"reason\": \"Reason\",\n        \"unknown\": \"Unknown\",\n        # Printer events\n        \"printer_offline\": \"Printer Offline\",\n        \"printer_disconnected\": \"{printer} has disconnected\",\n        \"printer_error\": \"Printer Error: {error_type}\",\n        # Filament\n        \"filament_low\": \"Filament Low\",\n        \"slot_at_percent\": \"{printer}: Slot {slot} at {percent}%\",\n        # Maintenance\n        \"maintenance_due\": \"Maintenance Due\",\n        \"overdue\": \"OVERDUE\",\n        \"soon\": \"Soon\",\n        # Test notification\n        \"test_title\": \"Bambuddy Test\",\n        \"test_message\": \"This is a test notification from Bambuddy. If you see this, notifications are working correctly!\",\n    }\n}\n\n# German translations\nDE = {\n    \"notification\": {\n        # Print events\n        \"print_started\": \"Druck gestartet\",\n        \"print_completed\": \"Druck abgeschlossen\",\n        \"print_failed\": \"Druck fehlgeschlagen\",\n        \"print_stopped\": \"Druck gestoppt\",\n        \"print_ended\": \"Druck beendet\",\n        \"print_progress\": \"Druck {progress}% fertig\",\n        \"estimated\": \"Geschätzt\",\n        \"time\": \"Zeit\",\n        \"filament\": \"Filament\",\n        \"reason\": \"Grund\",\n        \"unknown\": \"Unbekannt\",\n        # Printer events\n        \"printer_offline\": \"Drucker offline\",\n        \"printer_disconnected\": \"{printer} wurde getrennt\",\n        \"printer_error\": \"Druckerfehler: {error_type}\",\n        # Filament\n        \"filament_low\": \"Wenig Filament\",\n        \"slot_at_percent\": \"{printer}: Slot {slot} bei {percent}%\",\n        # Maintenance\n        \"maintenance_due\": \"Wartung fällig\",\n        \"overdue\": \"ÜBERFÄLLIG\",\n        \"soon\": \"Bald\",\n        # Test notification\n        \"test_title\": \"Bambuddy Test\",\n        \"test_message\": \"Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!\",\n    }\n}\n\n# All available translations\nTRANSLATIONS = {\n    \"en\": EN,\n    \"de\": DE,\n}\n\n\ndef get_translation(lang: str, key: str, **kwargs: Any) -> str:\n    \"\"\"\n    Get a translation string by key with optional interpolation.\n\n    Args:\n        lang: Language code (e.g., 'en', 'de')\n        key: Dot-separated key path (e.g., 'notification.print_started')\n        **kwargs: Values to interpolate into the string\n\n    Returns:\n        Translated string, or the key if not found\n    \"\"\"\n    # Fall back to English if language not found\n    translations = TRANSLATIONS.get(lang, TRANSLATIONS[\"en\"])\n\n    # Navigate to the nested key\n    keys = key.split(\".\")\n    value = translations\n    for k in keys:\n        if isinstance(value, dict) and k in value:\n            value = value[k]\n        else:\n            # Key not found, fall back to English\n            value = TRANSLATIONS[\"en\"]\n            for k2 in keys:\n                if isinstance(value, dict) and k2 in value:\n                    value = value[k2]\n                else:\n                    return key  # Return key if not found in fallback either\n            break\n\n    if isinstance(value, str):\n        # Interpolate values\n        try:\n            return value.format(**kwargs)\n        except KeyError:\n            return value\n\n    return key\n\n\nclass Translator:\n    \"\"\"Helper class for translations with a specific language.\"\"\"\n\n    def __init__(self, lang: str = \"en\"):\n        self.lang = lang if lang in TRANSLATIONS else \"en\"\n\n    def t(self, key: str, **kwargs: Any) -> str:\n        \"\"\"Translate a key.\"\"\"\n        return get_translation(self.lang, key, **kwargs)\n"
  },
  {
    "path": "backend/app/main.py",
    "content": "import asyncio\nimport logging\nimport posixpath\nimport time\nfrom contextlib import asynccontextmanager\nfrom datetime import datetime, timedelta, timezone\nfrom logging.handlers import RotatingFileHandler\n\nfrom fastapi import FastAPI\nfrom fastapi.responses import FileResponse\nfrom fastapi.staticfiles import StaticFiles\nfrom sqlalchemy import delete, or_, select, text\n\nfrom backend.app.api.routes import (\n    ams_history,\n    api_keys,\n    archives,\n    auth,\n    background_dispatch as background_dispatch_routes,\n    bug_report,\n    camera,\n    cloud,\n    discovery,\n    external_links,\n    filaments,\n    firmware,\n    github_backup,\n    groups,\n    inventory,\n    kprofiles,\n    library,\n    local_backup,\n    local_presets,\n    maintenance,\n    metrics,\n    mfa,\n    notification_templates,\n    notifications,\n    obico,\n    pending_uploads,\n    print_log,\n    print_queue,\n    printers,\n    projects,\n    settings as settings_routes,\n    smart_plugs,\n    spoolbuddy,\n    spoolman,\n    support,\n    system,\n    updates,\n    user_notifications,\n    users,\n    virtual_printers,\n    webhook,\n    websocket,\n)\nfrom backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types\nfrom backend.app.api.routes.support import init_debug_logging\nfrom backend.app.core.config import APP_VERSION, settings as app_settings\nfrom backend.app.core.database import async_session, engine, init_db\nfrom backend.app.core.websocket import ws_manager\nfrom backend.app.models.smart_plug import SmartPlug\nfrom backend.app.services.archive import ArchiveService\nfrom backend.app.services.background_dispatch import background_dispatch\nfrom backend.app.services.bambu_ftp import (\n    FileNotOnPrinterError,\n    cache_3mf_download,\n    clear_3mf_cache,\n    download_file_async,\n    get_cached_3mf,\n    get_ftp_retry_settings,\n    with_ftp_retry,\n)\nfrom backend.app.services.bambu_mqtt import PrinterState\nfrom backend.app.services.github_backup import github_backup_service\nfrom backend.app.services.homeassistant import homeassistant_service\nfrom backend.app.services.local_backup import local_backup_service\nfrom backend.app.services.mqtt_relay import mqtt_relay\nfrom backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service\nfrom backend.app.services.notification_service import notification_service\nfrom backend.app.services.obico_detection import obico_detection_service\nfrom backend.app.services.print_scheduler import scheduler as print_scheduler\nfrom backend.app.services.printer_manager import (\n    init_printer_connections,\n    printer_manager,\n    printer_state_to_dict,\n)\nfrom backend.app.services.smart_plug_manager import smart_plug_manager\nfrom backend.app.services.spool_assignment_notifications import (\n    notify_missing_spool_assignments_on_print_start,\n)\nfrom backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client\nfrom backend.app.services.spoolman_tracking import (\n    cleanup_tracking as _cleanup_spoolman_tracking,\n    report_usage as _report_spoolman_usage,\n    store_print_data as _store_spoolman_print_data,\n)\nfrom backend.app.services.tasmota import tasmota_service\n\n\n# =============================================================================\n# Dependency Check - runs before other imports to give helpful error messages\n# =============================================================================\ndef _start_error_server(missing_packages: list):\n    \"\"\"Start a minimal HTTP server to display dependency errors in browser.\"\"\"\n    import os\n    import signal\n    from http.server import BaseHTTPRequestHandler, HTTPServer\n\n    packages_html = \"\".join(f\"<li><code>{p}</code></li>\" for p in missing_packages)\n\n    html = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <title>Bambuddy - Setup Required</title>\n    <style>\n        body {{\n            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n            background: #0f172a; color: #e2e8f0;\n            display: flex; justify-content: center; align-items: center;\n            min-height: 100vh; margin: 0; padding: 20px; box-sizing: border-box;\n        }}\n        .container {{\n            background: #1e293b; border-radius: 12px; padding: 40px;\n            max-width: 600px; text-align: center; box-shadow: 0 4px 20px rgba(0,0,0,0.3);\n        }}\n        h1 {{ color: #f87171; margin-bottom: 10px; }}\n        h2 {{ color: #94a3b8; font-weight: normal; margin-top: 0; }}\n        .packages {{\n            background: #0f172a; border-radius: 8px; padding: 20px;\n            margin: 20px 0; text-align: left;\n        }}\n        .packages ul {{ margin: 0; padding-left: 20px; }}\n        .packages li {{ color: #fbbf24; margin: 8px 0; }}\n        .command {{\n            background: #0f172a; border-radius: 8px; padding: 15px 20px;\n            margin: 15px 0; font-family: monospace; color: #4ade80;\n            text-align: left; overflow-x: auto;\n        }}\n        .note {{ color: #94a3b8; font-size: 14px; margin-top: 20px; }}\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <h1>Setup Required</h1>\n        <h2>Missing Python packages</h2>\n        <div class=\"packages\"><ul>{packages_html}</ul></div>\n        <p>To fix, run this command on your server:</p>\n        <div class=\"command\">pip install -r requirements.txt</div>\n        <p>Or if using a virtual environment:</p>\n        <div class=\"command\">./venv/bin/pip install -r requirements.txt</div>\n        <p class=\"note\">After installing, restart Bambuddy:<br>\n        <code>sudo systemctl restart bambuddy</code></p>\n    </div>\n</body>\n</html>\"\"\"\n\n    class ErrorHandler(BaseHTTPRequestHandler):\n        def do_GET(self):\n            self.send_response(503)\n            self.send_header(\"Content-type\", \"text/html\")\n            self.end_headers()\n            self.wfile.write(html.encode())\n\n        def log_message(self, format, *args):\n            print(f\"[Error Server] {args[0]}\")\n\n    port = int(os.environ.get(\"PORT\", 8000))\n    print(f\"\\nStarting error server on http://0.0.0.0:{port}\")\n    print(\"Visit this URL in your browser to see the error details.\\n\")\n\n    server = HTTPServer((\"0.0.0.0\", port), ErrorHandler)  # nosec B104\n\n    def shutdown(signum, frame):\n        print(\"\\nShutting down error server...\")\n        raise SystemExit(0)\n\n    signal.signal(signal.SIGTERM, shutdown)\n    signal.signal(signal.SIGINT, shutdown)\n\n    server.serve_forever()\n\n\ndef check_dependencies():\n    \"\"\"Check that all required packages are installed.\"\"\"\n    missing = []\n\n    # Map of import name -> package name (for pip install)\n    required = {\n        \"jwt\": \"PyJWT\",\n        \"fastapi\": \"fastapi\",\n        \"uvicorn\": \"uvicorn\",\n        \"sqlalchemy\": \"sqlalchemy\",\n        \"aiosqlite\": \"aiosqlite\",\n        \"pydantic\": \"pydantic\",\n        \"paho.mqtt\": \"paho-mqtt\",\n    }\n\n    for module, package in required.items():\n        try:\n            __import__(module)\n        except ImportError:\n            missing.append(package)\n\n    if missing:\n        print(\"\\n\" + \"=\" * 60)\n        print(\"ERROR: Missing required Python packages!\")\n        print(\"=\" * 60)\n        print(f\"\\nMissing packages: {', '.join(missing)}\")\n        print(\"\\nTo fix, run:\")\n        print(\"  pip install -r requirements.txt\")\n        print(\"\\nOr if using a virtual environment:\")\n        print(\"  ./venv/bin/pip install -r requirements.txt\")\n        print(\"=\" * 60 + \"\\n\")\n        _start_error_server(missing)\n\n\ncheck_dependencies()\n# =============================================================================\n\n\n# Import settings first for logging configuration\n\n# Configure logging based on settings\n# DEBUG=true -> DEBUG level, else use LOG_LEVEL setting\nlog_level_str = \"DEBUG\" if app_settings.debug else app_settings.log_level.upper()\nlog_level = getattr(logging, log_level_str, logging.INFO)\nlog_format = \"%(asctime)s %(levelname)s [%(name)s] %(message)s\"\n\n# Create root logger\nroot_logger = logging.getLogger()\nroot_logger.setLevel(log_level)\n\n# Console handler - always enabled\nconsole_handler = logging.StreamHandler()\nconsole_handler.setLevel(log_level)\nconsole_handler.setFormatter(logging.Formatter(log_format))\nroot_logger.addHandler(console_handler)\n\n# File handler - only in production or if explicitly enabled\nif app_settings.log_to_file:\n    log_file = app_settings.log_dir / \"bambuddy.log\"\n    file_handler = RotatingFileHandler(\n        log_file,\n        maxBytes=5 * 1024 * 1024,  # 5MB\n        backupCount=3,\n        encoding=\"utf-8\",\n    )\n    file_handler.setLevel(log_level)\n    file_handler.setFormatter(logging.Formatter(log_format))\n    root_logger.addHandler(file_handler)\n    logging.info(\"Logging to file: %s\", log_file)\n\n# Reduce noise from third-party libraries in production\nif not app_settings.debug:\n    logging.getLogger(\"sqlalchemy.engine\").setLevel(logging.WARNING)\n    logging.getLogger(\"httpcore\").setLevel(logging.WARNING)\n    logging.getLogger(\"httpx\").setLevel(logging.WARNING)\n    logging.getLogger(\"paho.mqtt\").setLevel(logging.WARNING)\n\nlogging.info(\"Bambuddy starting - debug=%s, log_level=%s\", app_settings.debug, log_level_str)\n\n\n# Track active prints: {(printer_id, filename): archive_id}\n_active_prints: dict[tuple[int, str], int] = {}\n\n# Track expected prints from reprint/scheduled (skip auto-archiving for these)\n# {(printer_id, filename): archive_id}\n_expected_prints: dict[tuple[int, str], int] = {}\n\n# Track AMS mapping for prints: {archive_id: [global_tray_id_per_slot]}\n# Used by usage tracker to map 3MF slots to physical AMS trays\n_print_ams_mappings: dict[int, list[int]] = {}\n\n# Track progress milestones for notifications: {printer_id: last_milestone_notified}\n# Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.\n_last_progress_milestone: dict[int, int] = {}\n\n# Track whether first layer complete notification has been sent for current print\n_first_layer_notified: dict[int, bool] = {}\n\n# Track HMS errors that have been notified: {printer_id: set of error codes}\n# This prevents sending duplicate notifications for the same error\n_notified_hms_errors: dict[int, set[str]] = {}\n# Track when HMS errors were last seen: {printer_id: timestamp}\n# Used to debounce clearing — prevents flapping errors from re-triggering notifications\n_hms_last_seen: dict[int, float] = {}\n_HMS_CLEAR_GRACE_SECONDS = 30.0\n\n# Track timelapse file baselines at print start: {printer_id: set of video filenames}\n# Used for snapshot-diff detection at print completion\n_timelapse_baselines: dict[int, set[str]] = {}\n\n# Track printers waiting for bed to cool after print completion.\n# Event-driven: fires when bed_temper arrives via MQTT below threshold.\n# {printer_id: {\"threshold\": float, \"filename\": str, \"registered_at\": float}}\n_bed_cool_waiters: dict[int, dict] = {}\n\n# Track printers where the user explicitly stopped the print from the queue UI.\n# When on_print_complete fires with status \"failed\" for these printers we treat it\n# as \"cancelled\" (stopped by user) so the correct notification email is sent.\n_user_stopped_printers: set[int] = set()\n\n# Track created_by_id for expected prints so the user email can be sent even when\n# the archive itself doesn't have created_by_id set (e.g. library-file-based prints).\n# {(printer_id, filename): created_by_id}\n_expected_print_creators: dict[tuple[int, str], int] = {}\n\n# TTL for expected-print entries: evict registrations older than this to prevent\n# unbounded growth when a print is registered but never starts (e.g. printer\n# disconnect, app restart, print started from the printer panel).\n_EXPECTED_PRINT_TTL_SECONDS: int = 2 * 60 * 60  # 2 hours\n\n# Registration timestamps used for TTL eviction: {(printer_id, filename): monotonic_time}\n_expected_print_registered_at: dict[tuple[int, str], float] = {}\n\n# Cleanup loop interval\n_EXPECTED_PRINT_CLEANUP_INTERVAL: int = 15 * 60  # 15 minutes\n_expected_prints_cleanup_task: asyncio.Task | None = None\n\n\nasync def _get_plug_energy(plug, db) -> dict | None:\n    \"\"\"Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).\n\n    For HA plugs, configures the service with current settings from DB.\n    For MQTT plugs, returns data from the subscription service.\n    For REST plugs, polls the status URL with JSON path extraction.\n    \"\"\"\n    if plug.plug_type == \"homeassistant\":\n        from backend.app.api.routes.settings import get_homeassistant_settings\n\n        ha_settings = await get_homeassistant_settings(db)\n        homeassistant_service.configure(ha_settings[\"ha_url\"], ha_settings[\"ha_token\"])\n        return await homeassistant_service.get_energy(plug)\n    elif plug.plug_type == \"mqtt\":\n        # MQTT plugs report \"today\" energy, not lifetime total\n        # For per-print tracking, we use \"today\" as the counter (resets at midnight)\n        mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)\n        if mqtt_data:\n            return {\n                \"power\": mqtt_data.power,\n                \"today\": mqtt_data.energy,\n                \"total\": mqtt_data.energy,  # Use today as total for per-print calculations\n            }\n        return None\n    elif plug.plug_type == \"rest\":\n        from backend.app.services.rest_smart_plug import rest_smart_plug_service\n\n        return await rest_smart_plug_service.get_energy(plug)\n    else:\n        return await tasmota_service.get_energy(plug)\n\n\nasync def _record_energy_start(archive, printer_id: int, db, *, context: str = \"\") -> bool:\n    \"\"\"Capture the smart plug lifetime counter on the archive at print start.\n\n    Persists `energy_start_kwh` on the archive row (#941) so per-print energy\n    tracking survives a backend restart mid-print. The print-end handler reads\n    this value back from the DB and computes the delta against the current\n    plug counter.\n    \"\"\"\n    _logger = logging.getLogger(__name__)\n    try:\n        plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n        plug = plug_result.scalar_one_or_none()\n        if not plug:\n            _logger.info(\"[ENERGY] No smart plug for printer %s (archive %s)\", printer_id, archive.id)\n            return False\n        energy = await _get_plug_energy(plug, db)\n        if not energy or energy.get(\"total\") is None:\n            _logger.warning(\"[ENERGY] No 'total' in energy response for archive %s\", archive.id)\n            return False\n        archive.energy_start_kwh = float(energy[\"total\"])\n        await db.commit()\n        _logger.info(\n            \"[ENERGY] Recorded starting energy%s for archive %s: %s kWh\",\n            f\" ({context})\" if context else \"\",\n            archive.id,\n            energy[\"total\"],\n        )\n        return True\n    except Exception as e:\n        _logger.warning(\"[ENERGY] Failed to record starting energy for archive %s: %s\", archive.id, e)\n        return False\n\n\ndef register_expected_print(\n    printer_id: int,\n    filename: str,\n    archive_id: int,\n    ams_mapping: list[int] | None = None,\n    created_by_id: int | None = None,\n):\n    \"\"\"Register an expected print from reprint/scheduled so we don't create duplicate archives.\"\"\"\n    # Store with multiple filename variations to catch different naming patterns\n    _expected_prints[(printer_id, filename)] = archive_id\n    # Also store without .3mf extension if present\n    if filename.endswith(\".3mf\"):\n        base = filename[:-4]\n        _expected_prints[(printer_id, base)] = archive_id\n        _expected_prints[(printer_id, f\"{base}.gcode\")] = archive_id\n    # Store AMS mapping for usage tracking at print completion\n    if ams_mapping is not None:\n        _print_ams_mappings[archive_id] = ams_mapping\n    # Store created_by_id so the user start email can be sent even when the archive\n    # itself has no created_by_id (e.g. library-file-based queue prints)\n    if created_by_id is not None:\n        _expected_print_creators[(printer_id, filename)] = created_by_id\n        if filename.endswith(\".3mf\"):\n            base = filename[:-4]\n            _expected_print_creators[(printer_id, base)] = created_by_id\n            _expected_print_creators[(printer_id, f\"{base}.gcode\")] = created_by_id\n    # Record registration time for TTL-based eviction\n    _registered_at = time.monotonic()\n    _expected_print_registered_at[(printer_id, filename)] = _registered_at\n    if filename.endswith(\".3mf\"):\n        base = filename[:-4]\n        _expected_print_registered_at[(printer_id, base)] = _registered_at\n        _expected_print_registered_at[(printer_id, f\"{base}.gcode\")] = _registered_at\n    logging.getLogger(__name__).info(\n        f\"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}\"\n    )\n\n\ndef _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:\n    \"\"\"Resolve AMS mapping for print start without consuming stored queue/reprint state.\"\"\"\n    stored_ams_mapping = data.get(\"ams_mapping\")\n    if not stored_ams_mapping and archive_id:\n        stored_ams_mapping = _print_ams_mappings.get(archive_id)\n    return stored_ams_mapping\n\n\nasync def _bump_library_file_usage_if_completed(db, item, queue_status: str) -> None:\n    \"\"\"Increment LibraryFile.print_count and stamp last_printed_at when a queued\n    print completes successfully. Gated to status=='completed': failed, cancelled\n    and aborted prints do not count as usage. Caller is responsible for committing\n    the session. No-op when the queue item has no linked library file (e.g. reprints\n    from an archive). See #1008.\"\"\"\n    if queue_status != \"completed\" or item.library_file_id is None:\n        return\n    from backend.app.models.library import LibraryFile\n\n    lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == item.library_file_id))\n    if lib_file is None:\n        return\n    lib_file.print_count = (lib_file.print_count or 0) + 1\n    lib_file.last_printed_at = datetime.now(timezone.utc)\n\n\ndef mark_printer_stopped_by_user(printer_id: int) -> None:\n    \"\"\"Mark that the active print on this printer was stopped by the user from the queue UI.\n\n    When on_print_complete fires with status 'failed' for a printer in this set we\n    reclassify it as 'cancelled' so the correct 'print stopped' notification is sent\n    rather than a 'print failed' notification.\n    \"\"\"\n    _user_stopped_printers.add(printer_id)\n    logging.getLogger(__name__).info(\"Marked printer %s as user-stopped from queue\", printer_id)\n\n\n_last_status_broadcast: dict[int, str] = {}\n# Track printers where we've updated nozzle_count\n_nozzle_count_updated: set[int] = set()\n\n\nasync def on_printer_status_change(printer_id: int, state: PrinterState):\n    \"\"\"Handle printer status changes - broadcast via WebSocket.\"\"\"\n    # Only broadcast if something meaningful changed (reduce WebSocket spam)\n    # Include rounded temperatures to detect meaningful temp changes (within 1 degree)\n    temps = state.temperatures or {}\n    nozzle_temp = round(temps.get(\"nozzle\", 0))\n    bed_temp = round(temps.get(\"bed\", 0))\n    nozzle_2_temp = round(temps.get(\"nozzle_2\", 0)) if \"nozzle_2\" in temps else \"\"\n    chamber_temp = round(temps.get(\"chamber\", 0)) if \"chamber\" in temps else \"\"\n\n    # Auto-detect dual-nozzle printers from MQTT temperature data\n    if \"nozzle_2\" in temps and printer_id not in _nozzle_count_updated:\n        _nozzle_count_updated.add(printer_id)\n        # Update nozzle_count in database\n        async with async_session() as db:\n            from backend.app.models.printer import Printer\n\n            result = await db.execute(select(Printer).where(Printer.id == printer_id))\n            printer = result.scalar_one_or_none()\n            if printer and printer.nozzle_count != 2:\n                printer.nozzle_count = 2\n                await db.commit()\n                logging.getLogger(__name__).info(\n                    f\"Auto-detected dual-nozzle printer {printer_id}, updated nozzle_count=2\"\n                )\n\n    # Include target temps for heating phase detection\n    bed_target = round(temps.get(\"bed_target\", 0))\n    nozzle_target = round(temps.get(\"nozzle_target\", 0))\n\n    # Include tray_now and vt_tray hash so external spool changes trigger broadcasts\n    vt_tray_key = hash(str(state.raw_data.get(\"vt_tray\", []))) if state.raw_data else 0\n    # Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts\n    ams_dry_key = tuple(a.get(\"dry_time\", 0) for a in (state.raw_data.get(\"ams\") or [])) if state.raw_data else ()\n    # Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784)\n    ams_tray_key = (\n        tuple(\n            (t.get(\"id\"), t.get(\"tray_type\", \"\"), t.get(\"state\"))\n            for a in (state.raw_data.get(\"ams\") or [])\n            for t in a.get(\"tray\", [])\n        )\n        if state.raw_data\n        else ()\n    )\n    status_key = (\n        f\"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:\"\n        f\"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:\"\n        f\"{state.stg_cur}:{bed_target}:{nozzle_target}:\"\n        f\"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:\"\n        f\"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:\"\n        f\"{ams_dry_key}:{ams_tray_key}:{state.door_open}\"\n    )\n\n    # MQTT relay - publish status (before dedup check - always publish to MQTT)\n    try:\n        printer_info = printer_manager.get_printer(printer_id)\n        if printer_info:\n            await mqtt_relay.on_printer_status(printer_id, state, printer_info.name, printer_info.serial_number)\n    except Exception:\n        pass  # Don't fail status callback if MQTT fails\n\n    if _last_status_broadcast.get(printer_id) == status_key:\n        return  # No change, skip WebSocket broadcast\n\n    _last_status_broadcast[printer_id] = status_key\n\n    # Check for progress milestone notifications (25%, 50%, 75%)\n    progress = state.progress or 0\n    is_printing = state.state in (\"RUNNING\", \"PRINTING\")\n\n    if is_printing and progress > 0:\n        # Determine which milestone we've reached\n        current_milestone = 0\n        if progress >= 75:\n            current_milestone = 75\n        elif progress >= 50:\n            current_milestone = 50\n        elif progress >= 25:\n            current_milestone = 25\n\n        last_milestone = _last_progress_milestone.get(printer_id, 0)\n\n        # If we've crossed a new milestone, send notification\n        if current_milestone > last_milestone:\n            _last_progress_milestone[printer_id] = current_milestone\n            try:\n                async with async_session() as db:\n                    from backend.app.models.printer import Printer\n\n                    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n                    printer = result.scalar_one_or_none()\n                    printer_name = printer.name if printer else f\"Printer {printer_id}\"\n                    filename = state.subtask_name or state.gcode_file or \"Unknown\"\n                    # remaining_time is in minutes, convert to seconds for notification\n                    remaining_time_seconds = state.remaining_time * 60 if state.remaining_time else None\n\n                    # Capture camera snapshot for notification image attachment\n                    image_data = await _capture_snapshot_for_notification(\n                        printer_id, printer, logging.getLogger(__name__)\n                    )\n\n                    await notification_service.on_print_progress(\n                        printer_id,\n                        printer_name,\n                        filename,\n                        current_milestone,\n                        db,\n                        remaining_time_seconds,\n                        image_data=image_data,\n                    )\n            except Exception as e:\n                logging.getLogger(__name__).warning(f\"Progress milestone notification failed: {e}\")\n    elif progress < 5:\n        # Reset milestone tracking when print restarts or new print begins\n        _last_progress_milestone[printer_id] = 0\n        _first_layer_notified[printer_id] = False\n\n    # HMS error codes that should not trigger notifications even though they\n    # have known descriptions (e.g. user-initiated actions, not real errors).\n    _HMS_NOTIFICATION_SUPPRESS = {\n        \"0500_400E\",  # Printing was cancelled (user action, not an error)\n    }\n\n    # Check for new HMS errors and send notifications\n    current_hms_errors = getattr(state, \"hms_errors\", []) or []\n    if current_hms_errors:\n        # Build set of current error codes (using attr for uniqueness)\n        current_error_codes = {f\"{e.attr:08x}\" for e in current_hms_errors}\n        previously_notified = _notified_hms_errors.get(printer_id, set())\n\n        # Find new errors that haven't been notified yet\n        new_error_codes = current_error_codes - previously_notified\n\n        # Update tracking immediately to prevent duplicate notifications from concurrent callbacks\n        _notified_hms_errors[printer_id] = current_error_codes\n        _hms_last_seen[printer_id] = time.time()\n\n        if new_error_codes:\n            # Get the actual new errors for the notification\n            # Filter to severity >= 2 (skip informational/status messages like H2D sends)\n            new_errors = [e for e in current_hms_errors if f\"{e.attr:08x}\" in new_error_codes and e.severity >= 2]\n\n            try:\n                async with async_session() as db:\n                    from backend.app.models.printer import Printer\n\n                    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n                    printer = result.scalar_one_or_none()\n                    printer_name = printer.name if printer else f\"Printer {printer_id}\"\n\n                    # Format error details for notification\n                    # Module 0x07 = AMS/Filament, 0x05 = Nozzle, 0x0C = Motion Controller, etc.\n                    module_names = {\n                        0x03: \"Print/Task\",\n                        0x05: \"Nozzle/Extruder\",\n                        0x07: \"AMS/Filament\",\n                        0x0C: \"Motion Controller\",\n                        0x12: \"Chamber\",\n                    }\n\n                    from backend.app.services.hms_errors import get_error_description\n\n                    # Capture camera snapshot once for all error notifications\n                    error_image_data = await _capture_snapshot_for_notification(\n                        printer_id, printer, logging.getLogger(__name__)\n                    )\n\n                    sent_count = 0\n                    for error in new_errors:\n                        module_name = module_names.get(error.module, f\"Module 0x{error.module:02X}\")\n                        # Build short code like \"0700_8010\"\n                        # Mask to 16 bits to handle printers that send larger values\n                        error_code_int = int(error.code.replace(\"0x\", \"\"), 16) if error.code else 0\n                        error_code_masked = error_code_int & 0xFFFF\n                        short_code = f\"{(error.attr >> 16) & 0xFFFF:04X}_{error_code_masked:04X}\"\n\n                        # Only notify for errors with known descriptions — printers\n                        # send many undocumented/phantom codes that aren't real errors.\n                        description = get_error_description(short_code)\n                        if not description or short_code in _HMS_NOTIFICATION_SUPPRESS:\n                            continue\n\n                        error_type = f\"{module_name} Error\"\n                        error_detail = description\n\n                        await notification_service.on_printer_error(\n                            printer_id, printer_name, error_type, db, error_detail, image_data=error_image_data\n                        )\n                        sent_count += 1\n\n                    if sent_count:\n                        logging.getLogger(__name__).info(\n                            f\"[HMS] Sent notification for {sent_count} error(s) on printer {printer_id}\"\n                        )\n\n                    # Also publish to MQTT relay\n                    printer_info = printer_manager.get_printer(printer_id)\n                    if printer_info:\n                        errors_data = [\n                            {\n                                \"code\": e.code,\n                                \"attr\": e.attr,\n                                \"module\": e.module,\n                                \"severity\": e.severity,\n                            }\n                            for e in new_errors\n                        ]\n                        await mqtt_relay.on_printer_error(\n                            printer_id, printer_info.name, printer_info.serial_number, errors_data\n                        )\n\n            except Exception as e:\n                logging.getLogger(__name__).warning(f\"HMS error notification failed: {e}\")\n\n    else:\n        # No HMS errors — only clear tracking after a grace period to prevent\n        # flapping errors (brief hms:[] gaps) from re-triggering notifications.\n        # Some HMS codes (e.g. chamber temp regulation during PETG prints) toggle\n        # on/off every few seconds as conditions fluctuate around thresholds.\n        if printer_id in _notified_hms_errors:\n            last_seen = _hms_last_seen.get(printer_id, 0)\n            if time.time() - last_seen >= _HMS_CLEAR_GRACE_SECONDS:\n                _notified_hms_errors.pop(printer_id, None)\n                _hms_last_seen.pop(printer_id, None)\n\n    await ws_manager.send_printer_status(\n        printer_id,\n        printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),\n    )\n\n\ndef _is_bambu_uuid(tray_uuid: str) -> bool:\n    \"\"\"Check if a tray UUID looks like a valid Bambu Lab RFID UUID (non-empty, non-zero).\"\"\"\n    return bool(tray_uuid) and tray_uuid not in (\"\", \"0\" * len(tray_uuid))\n\n\nasync def on_ams_change(printer_id: int, ams_data: list):\n    \"\"\"Handle AMS data changes - sync to Spoolman if enabled and auto mode.\"\"\"\n    logger = logging.getLogger(__name__)\n\n    # Snapshot BEFORE any await: if a print is active, skip weight sync later.\n    # on_print_complete may pop _active_sessions during our awaits (#880).\n    from backend.app.services.usage_tracker import _active_sessions\n\n    _print_active = printer_id in _active_sessions\n\n    # MQTT relay - publish AMS change\n    try:\n        printer_info = printer_manager.get_printer(printer_id)\n        if printer_info:\n            await mqtt_relay.on_ams_change(printer_id, printer_info.name, printer_info.serial_number, ams_data)\n    except Exception:\n        pass  # Don't fail AMS callback if MQTT fails\n\n    # Broadcast AMS change via WebSocket (bypasses status_key deduplication)\n    # This ensures frontend gets immediate updates when AMS slots are configured\n    try:\n        state = printer_manager.get_status(printer_id)\n        if state:\n            logger.info(\"[Printer %s] Broadcasting AMS change via WebSocket\", printer_id)\n            await ws_manager.send_printer_status(\n                printer_id,\n                printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),\n            )\n    except Exception as e:\n        logger.warning(\"Failed to broadcast AMS change for printer %s: %s\", printer_id, e)\n\n    from backend.app.utils.color_utils import colors_similar as _colors_similar\n\n    # Auto-unlink spool assignments with stale fingerprints\n    try:\n        async with async_session() as db:\n            from sqlalchemy.orm import selectinload\n\n            from backend.app.api.routes.inventory import _find_tray_in_ams_data\n            from backend.app.models.spool_assignment import SpoolAssignment as SA\n\n            result = await db.execute(select(SA).where(SA.printer_id == printer_id).options(selectinload(SA.spool)))\n            stale = []\n            for assignment in result.scalars().all():\n                # External spool assignments (ams_id=255) live in vt_tray, not AMS data\n                if assignment.ams_id == 255:\n                    ps = printer_manager.get_status(printer_id)\n                    vt_tray_raw = ps.raw_data.get(\"vt_tray\", []) if ps else []\n                    ext_id = assignment.tray_id + 254  # 0→254, 1→255\n                    current_tray = None\n                    for vt in vt_tray_raw:\n                        if isinstance(vt, dict) and int(vt.get(\"id\", 254)) == ext_id:\n                            current_tray = vt\n                            break\n                    if not current_tray:\n                        # vt_tray data may not have arrived yet — keep assignment\n                        continue\n                else:\n                    current_tray = _find_tray_in_ams_data(ams_data, assignment.ams_id, assignment.tray_id)\n                if not current_tray:\n                    logger.info(\n                        \"Auto-unlink: spool %d AMS%d-T%d — tray not found in AMS data (slot empty?)\",\n                        assignment.spool_id,\n                        assignment.ams_id,\n                        assignment.tray_id,\n                    )\n                    stale.append(assignment)  # Slot empty\n                elif _is_bambu_uuid(current_tray.get(\"tray_uuid\", \"\")):\n                    # A Bambu Lab spool is in this slot — check if it's the same spool\n                    # that's currently assigned. If yes, keep the assignment (avoids\n                    # unnecessary unlink/re-assign/ams_filament_setting cycle that clears\n                    # the printer's filament preset on every startup).\n                    tray_uuid = current_tray.get(\"tray_uuid\", \"\")\n                    tag_uid = current_tray.get(\"tag_uid\", \"\")\n                    spool = assignment.spool\n                    spool_matches = False\n                    if spool:\n                        if (spool.tray_uuid and spool.tray_uuid.upper() == tray_uuid.upper()) or (\n                            spool.tag_uid\n                            and tag_uid\n                            and tag_uid != \"0000000000000000\"\n                            and spool.tag_uid.upper() == tag_uid.upper()\n                        ):\n                            spool_matches = True\n                    if spool_matches:\n                        # Same BL spool still in slot — keep assignment, update fingerprint if needed\n                        cur_color = current_tray.get(\"tray_color\", \"\")\n                        cur_type = current_tray.get(\"tray_type\", \"\")\n                        fp_color = assignment.fingerprint_color or \"\"\n                        fp_type = assignment.fingerprint_type or \"\"\n                        if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():\n                            assignment.fingerprint_color = cur_color\n                            assignment.fingerprint_type = cur_type\n                            logger.debug(\n                                \"Auto-unlink: spool %d AMS%d-T%d — same BL spool, updated fingerprint\",\n                                assignment.spool_id,\n                                assignment.ams_id,\n                                assignment.tray_id,\n                            )\n                        continue\n                    # Different BL spool or unrecognized — unlink so auto-assign can match\n                    logger.info(\n                        \"Auto-unlink: spool %d AMS%d-T%d — different Bambu Lab spool detected (uuid=%s)\",\n                        assignment.spool_id,\n                        assignment.ams_id,\n                        assignment.tray_id,\n                        tray_uuid,\n                    )\n                    stale.append(assignment)\n                else:\n                    cur_color = current_tray.get(\"tray_color\", \"\")\n                    cur_type = current_tray.get(\"tray_type\", \"\")\n                    fp_color = assignment.fingerprint_color or \"\"\n                    fp_type = assignment.fingerprint_type or \"\"\n                    if not _colors_similar(cur_color, fp_color) or cur_type.upper() != fp_type.upper():\n                        # Fingerprint mismatch — but check if tray now matches the\n                        # assigned spool (e.g. auto-configure changed the tray).\n                        spool = assignment.spool\n                        if spool:\n                            spool_color = (spool.rgba or \"FFFFFFFF\").upper()\n                            spool_type = (spool.material or \"\").upper()\n                            if _colors_similar(cur_color, spool_color) and cur_type.upper() == spool_type:\n                                # Tray was reconfigured to match the spool — update fingerprint\n                                logger.info(\n                                    \"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp\",\n                                    assignment.spool_id,\n                                    assignment.ams_id,\n                                    assignment.tray_id,\n                                )\n                                assignment.fingerprint_color = cur_color\n                                assignment.fingerprint_type = cur_type\n                                continue\n                        logger.info(\n                            \"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch (cur=%s/%s fp=%s/%s spool=%s/%s)\",\n                            assignment.spool_id,\n                            assignment.ams_id,\n                            assignment.tray_id,\n                            cur_color,\n                            cur_type,\n                            fp_color,\n                            fp_type,\n                            spool.rgba if spool else \"?\",\n                            spool.material if spool else \"?\",\n                        )\n                        stale.append(assignment)  # Spool changed\n            for a in stale:\n                await db.delete(a)\n            if stale:\n                logger.info(\"Auto-unlinked %d stale spool assignments for printer %d\", len(stale), printer_id)\n            # Commit any changes (stale deletions and/or fingerprint updates)\n            await db.commit()\n    except Exception as e:\n        logger.warning(\"Spool assignment cleanup failed: %s\", e, exc_info=True)\n\n    # Auto-manage inventory spools from AMS tray data (skip if Spoolman manages AMS)\n    try:\n        async with async_session() as db:\n            from backend.app.api.routes.settings import get_setting\n            from backend.app.models.spool_assignment import SpoolAssignment as SA\n            from backend.app.services.spool_tag_matcher import (\n                auto_assign_spool,\n                create_spool_from_tray,\n                find_matching_untagged_spool,\n                get_spool_by_tag,\n                is_bambu_tag,\n                is_valid_tag,\n                link_tag_to_inventory_spool,\n            )\n\n            _spoolman_on = await get_setting(db, \"spoolman_enabled\")\n            if not _spoolman_on or _spoolman_on.lower() != \"true\":\n                for ams_unit in ams_data:\n                    if not isinstance(ams_unit, dict):\n                        continue\n                    ams_id = int(ams_unit.get(\"id\", 0))\n                    for tray in ams_unit.get(\"tray\", []):\n                        if not isinstance(tray, dict):\n                            continue\n                        tray_id = int(tray.get(\"id\", 0))\n                        tag_uid = tray.get(\"tag_uid\", \"\")\n                        tray_uuid = tray.get(\"tray_uuid\", \"\")\n                        tray_info_idx = tray.get(\"tray_info_idx\", \"\")\n                        if not tray.get(\"tray_type\"):\n                            continue  # Empty slot\n                        # Check if assignment already exists for this slot\n                        existing = await db.execute(\n                            select(SA)\n                            .options(selectinload(SA.spool))\n                            .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)\n                        )\n                        existing_assignment = existing.scalar_one_or_none()\n                        if existing_assignment:\n                            # Sync spool weight_used from AMS remain — only INCREASE, never decrease.\n                            # The AMS remain% is low-resolution (integer %, i.e. 10g steps for 1kg spool)\n                            # and must not overwrite precise values from the usage tracker (3MF/G-code).\n                            # Skip during active prints: the usage tracker handles deduction\n                            # precisely via 3MF data on print completion. Without this guard the\n                            # AMS remain% SET and the usage tracker ADD both fire from the same\n                            # MQTT message, doubling the deduction (#880).\n                            if _print_active:\n                                continue\n                            remain_raw = tray.get(\"remain\")\n                            if (\n                                remain_raw is not None\n                                and existing_assignment.spool\n                                and not existing_assignment.spool.weight_locked\n                            ):\n                                try:\n                                    remain_val = int(remain_raw)\n                                except (TypeError, ValueError):\n                                    remain_val = -1\n                                if 1 <= remain_val <= 100:\n                                    lw = existing_assignment.spool.label_weight or 1000\n                                    new_used = round(lw * (100 - remain_val) / 100.0, 1)\n                                    current_used = existing_assignment.spool.weight_used or 0\n                                    if new_used > current_used + 1:\n                                        logger.info(\n                                            \"Weight sync: spool %d weight_used %s -> %s (remain=%d)\",\n                                            existing_assignment.spool_id,\n                                            current_used,\n                                            new_used,\n                                            remain_val,\n                                        )\n                                        existing_assignment.spool.weight_used = new_used\n                                        await db.commit()\n                            continue\n\n                        if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):\n                            # BL spool with RFID tag: auto-match → inventory match → auto-create\n                            spool = await get_spool_by_tag(db, tag_uid, tray_uuid)\n                            if not spool:\n                                # Try matching an untagged inventory spool (same material/color)\n                                spool = await find_matching_untagged_spool(db, tray)\n                                if spool:\n                                    await link_tag_to_inventory_spool(db, spool, tray)\n                                else:\n                                    spool = await create_spool_from_tray(db, tray)\n                            await auto_assign_spool(\n                                printer_id,\n                                ams_id,\n                                tray_id,\n                                spool,\n                                printer_manager,\n                                db,\n                                tray_info_idx=tray_info_idx,\n                            )\n                            await db.commit()\n                            await ws_manager.broadcast(\n                                {\n                                    \"type\": \"spool_auto_assigned\",\n                                    \"printer_id\": printer_id,\n                                    \"ams_id\": ams_id,\n                                    \"tray_id\": tray_id,\n                                    \"spool_id\": spool.id,\n                                }\n                            )\n                            logger.info(\n                                \"RFID auto-assigned spool %d to printer %d AMS%d-T%d\",\n                                spool.id,\n                                printer_id,\n                                ams_id,\n                                tray_id,\n                            )\n                        elif is_valid_tag(tag_uid, tray_uuid):\n                            # Non-BL spool with some tag — let user choose\n                            await ws_manager.broadcast(\n                                {\n                                    \"type\": \"unknown_tag\",\n                                    \"printer_id\": printer_id,\n                                    \"ams_id\": ams_id,\n                                    \"tray_id\": tray_id,\n                                    \"tag_uid\": tag_uid,\n                                    \"tray_uuid\": tray_uuid,\n                                }\n                            )\n                        else:\n                            # No tag at all — let user choose from inventory\n                            await ws_manager.broadcast(\n                                {\n                                    \"type\": \"unknown_tag\",\n                                    \"printer_id\": printer_id,\n                                    \"ams_id\": ams_id,\n                                    \"tray_id\": tray_id,\n                                    \"tag_uid\": \"\",\n                                    \"tray_uuid\": \"\",\n                                }\n                            )\n    except Exception as e:\n        logger.warning(\"RFID spool auto-assign failed: %s\", e, exc_info=True)\n\n    try:\n        async with async_session() as db:\n            from backend.app.api.routes.settings import get_setting\n            from backend.app.models.printer import Printer\n\n            # Check if Spoolman is enabled\n            spoolman_enabled = await get_setting(db, \"spoolman_enabled\")\n            if not spoolman_enabled or spoolman_enabled.lower() != \"true\":\n                return\n\n            # Check sync mode\n            sync_mode = await get_setting(db, \"spoolman_sync_mode\")\n            if sync_mode and sync_mode != \"auto\":\n                return  # Only sync on auto mode\n\n            # Check if weight sync is disabled\n            disable_weight_sync_str = await get_setting(db, \"spoolman_disable_weight_sync\")\n            disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == \"true\"\n\n            # Get Spoolman URL\n            spoolman_url = await get_setting(db, \"spoolman_url\")\n            if not spoolman_url:\n                return\n\n            # Get or create Spoolman client\n            client = await get_spoolman_client()\n            if not client:\n                client = await init_spoolman_client(spoolman_url)\n\n            # Check if Spoolman is reachable\n            if not await client.health_check():\n                logger.warning(\"Spoolman not reachable at %s\", spoolman_url)\n                return\n\n            # Get printer name for location\n            result = await db.execute(select(Printer).where(Printer.id == printer_id))\n            printer = result.scalar_one_or_none()\n            printer_name = printer.name if printer else f\"Printer {printer_id}\"\n\n            # OPTIMIZATION: Fetch all spools once before processing trays\n            # This eliminates redundant API calls (one per tray) when syncing multiple trays\n            logger.debug(\"[Printer %s] Fetching spools cache for AMS sync...\", printer_id)\n            try:\n                cached_spools = await client.get_spools()\n                logger.debug(\"[Printer %s] Cached %d spools for batch sync\", printer_id, len(cached_spools))\n            except Exception as e:\n                logger.error(\n                    \"[Printer %s] Failed to fetch spools cache after retries, aborting AMS sync: %s\",\n                    printer_id,\n                    e,\n                )\n                return\n\n            # Load inventory weights as fallback (when AMS MQTT data lacks remain values)\n            from sqlalchemy.orm import selectinload\n\n            from backend.app.models.spool_assignment import SpoolAssignment\n\n            inventory_weights: dict[tuple[int, int], float] = {}\n            try:\n                assign_result = await db.execute(\n                    select(SpoolAssignment)\n                    .options(selectinload(SpoolAssignment.spool))\n                    .where(SpoolAssignment.printer_id == printer_id)\n                )\n                for assignment in assign_result.scalars().all():\n                    spool = assignment.spool\n                    if spool and spool.label_weight > 0:\n                        remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))\n                        inventory_weights[(assignment.ams_id, assignment.tray_id)] = remaining\n            except Exception as e:\n                logger.debug(\"Could not load inventory weights for printer %s: %s\", printer_id, e)\n\n            # Sync each AMS tray, tracking UUIDs and spool IDs for cleanup\n            synced = 0\n            current_tray_uuids: set[str] = set()\n            synced_spool_ids: set[int] = set()\n            for ams_unit in ams_data:\n                ams_id = int(ams_unit.get(\"id\", 0))\n                trays = ams_unit.get(\"tray\", [])\n\n                for tray_data in trays:\n                    tray = client.parse_ams_tray(ams_id, tray_data)\n                    if not tray:\n                        continue  # Empty tray\n\n                    # Track this spool's UUID as currently present in the AMS\n                    spool_tag = (\n                        tray.tray_uuid\n                        if tray.tray_uuid and tray.tray_uuid != \"00000000000000000000000000000000\"\n                        else tray.tag_uid\n                    )\n                    if spool_tag:\n                        current_tray_uuids.add(spool_tag.upper())\n\n                    try:\n                        inv_remaining = inventory_weights.get((ams_id, tray.tray_id))\n                        result = await client.sync_ams_tray(\n                            tray,\n                            printer_name,\n                            disable_weight_sync=disable_weight_sync,\n                            cached_spools=cached_spools,\n                            inventory_remaining=inv_remaining,\n                        )\n                        if result:\n                            synced += 1\n                            if result.get(\"id\"):\n                                synced_spool_ids.add(result[\"id\"])\n                                # If a new spool was created, add it to the cache\n                                # so subsequent trays can find it if they reference the same tag\n                                # Check if this spool already exists in cache\n                                spool_exists = any(s.get(\"id\") == result[\"id\"] for s in cached_spools)\n                                if not spool_exists:\n                                    cached_spools.append(result)\n                                    logger.debug(\n                                        \"[Printer %s] Added newly created spool %s to cache\",\n                                        printer_id,\n                                        result[\"id\"],\n                                    )\n                    except Exception as e:\n                        logger.error(\"Error syncing AMS %s tray %s: %s\", ams_id, tray.tray_id, e)\n\n            if synced > 0:\n                logger.info(\"Auto-synced %s AMS trays to Spoolman for printer %s\", synced, printer_id)\n\n            # Clear location for spools no longer in this printer's AMS\n            try:\n                cleared = await client.clear_location_for_removed_spools(\n                    printer_name, current_tray_uuids, cached_spools=cached_spools, synced_spool_ids=synced_spool_ids\n                )\n                if cleared > 0:\n                    logger.info(\"Auto-cleared location for %s spools removed from printer %s\", cleared, printer_id)\n            except Exception as e:\n                logger.error(\"Error clearing locations for removed spools on printer %s: %s\", printer_id, e)\n\n    except Exception as e:\n        logging.getLogger(__name__).warning(f\"Spoolman AMS sync failed: {e}\")\n\n\nasync def _capture_snapshot_for_notification(printer_id: int, printer, logger) -> bytes | None:\n    \"\"\"Capture a camera snapshot for notification image attachment.\n\n    Returns JPEG bytes (max 2.5MB) or None if capture fails or is unavailable.\n    Uses: external camera > buffered frame > fresh capture.\n    \"\"\"\n    if not printer:\n        return None\n\n    try:\n        from backend.app.api.routes.settings import get_setting\n\n        async with async_session() as db:\n            capture_enabled = await get_setting(db, \"capture_finish_photo\")\n\n        if capture_enabled is not None and capture_enabled.lower() != \"true\":\n            return None\n\n        # Try external camera first\n        if printer.external_camera_enabled and printer.external_camera_url:\n            logger.info(\"[SNAPSHOT] Capturing from external camera for printer %s\", printer_id)\n            from backend.app.services.external_camera import capture_frame\n\n            frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or \"mjpeg\")\n            if frame_data and len(frame_data) <= 2_500_000:\n                logger.info(\"[SNAPSHOT] External camera frame: %s bytes\", len(frame_data))\n                return _apply_camera_rotation(frame_data, printer, logger)\n\n        # Try buffered frame from active stream\n        from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame\n\n        active_for_printer = [k for k in _active_streams if k.startswith(f\"{printer_id}-\")]\n        active_chamber = [k for k in _active_chamber_streams if k.startswith(f\"{printer_id}-\")]\n        buffered_frame = get_buffered_frame(printer_id)\n\n        if (active_for_printer or active_chamber) and buffered_frame:\n            logger.info(\"[SNAPSHOT] Using buffered frame for printer %s: %s bytes\", printer_id, len(buffered_frame))\n            if len(buffered_frame) <= 2_500_000:\n                return _apply_camera_rotation(buffered_frame, printer, logger)\n\n        # Fresh capture from printer camera\n        logger.info(\"[SNAPSHOT] Capturing fresh frame for printer %s\", printer_id)\n        from backend.app.services.camera import capture_camera_frame_bytes\n\n        frame_data = await capture_camera_frame_bytes(\n            printer.ip_address, printer.access_code, printer.model, timeout=15\n        )\n        if frame_data and len(frame_data) <= 2_500_000:\n            logger.info(\"[SNAPSHOT] Fresh camera frame: %s bytes\", len(frame_data))\n            return _apply_camera_rotation(frame_data, printer, logger)\n\n    except Exception as e:\n        logger.warning(\"[SNAPSHOT] Failed to capture snapshot for printer %s: %s\", printer_id, e)\n\n    return None\n\n\ndef _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes:\n    \"\"\"Apply camera rotation to snapshot image if configured.\"\"\"\n    rotation = getattr(printer, \"camera_rotation\", 0)\n    if not rotation or rotation == 0:\n        return image_data\n\n    try:\n        from io import BytesIO\n\n        from PIL import Image\n\n        img = Image.open(BytesIO(image_data))\n        # PIL rotate is counter-clockwise, so negate for clockwise rotation\n        img = img.rotate(-rotation, expand=True)\n        buf = BytesIO()\n        img.save(buf, format=\"JPEG\", quality=90)\n        rotated = buf.getvalue()\n        logger.info(\"[SNAPSHOT] Applied %d° rotation: %s → %s bytes\", rotation, len(image_data), len(rotated))\n        return rotated\n    except Exception as e:\n        logger.warning(\"[SNAPSHOT] Failed to apply rotation: %s\", e)\n        return image_data\n\n\nasync def _send_print_start_notification(\n    printer_id: int,\n    data: dict,\n    archive_data: dict | None = None,\n    logger=None,\n):\n    \"\"\"Helper to send print start notification with optional archive data.\"\"\"\n    if logger is None:\n        logger = logging.getLogger(__name__)\n\n    try:\n        async with async_session() as db:\n            from backend.app.models.printer import Printer\n\n            result = await db.execute(select(Printer).where(Printer.id == printer_id))\n            printer = result.scalar_one_or_none()\n            printer_name = printer.name if printer else f\"Printer {printer_id}\"\n\n            # Capture camera snapshot for notification image attachment\n            image_data = await _capture_snapshot_for_notification(printer_id, printer, logger)\n            if image_data:\n                if archive_data is None:\n                    archive_data = {}\n                archive_data[\"image_data\"] = image_data\n\n            await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)\n\n            # Send user-specific email notification for print start\n            if archive_data and archive_data.get(\"created_by_id\"):\n                await notification_service.send_user_print_email(\n                    event_type=\"user_print_start\",\n                    created_by_id=archive_data[\"created_by_id\"],\n                    printer_name=printer_name,\n                    filename=data.get(\"subtask_name\") or data.get(\"filename\", \"Unknown\"),\n                    db=db,\n                )\n    except Exception as e:\n        logger.warning(\"Notification on_print_start failed: %s\", e)\n\n\nasync def _dispatch_user_print_email(\n    status: str,\n    created_by_id: int | None,\n    printer_name: str,\n    filename: str,\n    db,\n) -> None:\n    \"\"\"Send a user-specific print-completion email based on print status.\n\n    Maps the normalised print status to the correct event type and delegates\n    to :meth:`NotificationService.send_user_print_email`.  A single helper\n    avoids duplicating the ``if status == \"completed\" / elif \"failed\" / elif\n    \"stopped\"`` dispatch block at every call site.\n\n    Does nothing if *created_by_id* is ``None``.\n    \"\"\"\n    if created_by_id is None:\n        return\n    if status == \"completed\":\n        event_type = \"user_print_complete\"\n    elif status == \"failed\":\n        event_type = \"user_print_failed\"\n    elif status in (\"stopped\", \"aborted\", \"cancelled\"):\n        event_type = \"user_print_stopped\"\n    else:\n        return\n    await notification_service.send_user_print_email(\n        event_type=event_type,\n        created_by_id=created_by_id,\n        printer_name=printer_name,\n        filename=filename,\n        db=db,\n    )\n\n\ndef _load_objects_from_archive(archive, printer_id: int, logger) -> None:\n    \"\"\"Extract printable objects from an archive's 3MF file and store in printer state.\"\"\"\n    try:\n        from backend.app.services.archive import extract_printable_objects_from_3mf\n\n        file_path = app_settings.base_dir / archive.file_path\n        if file_path.is_file() and str(file_path).endswith(\".3mf\"):\n            with open(file_path, \"rb\") as f:\n                threemf_data = f.read()\n            # Extract with positions for UI overlay\n            printable_objects, bbox_all = extract_printable_objects_from_3mf(threemf_data, include_positions=True)\n            if printable_objects:\n                client = printer_manager.get_client(printer_id)\n                if client:\n                    client.state.printable_objects = printable_objects\n                    client.state.printable_objects_bbox_all = bbox_all\n                    client.state.skipped_objects = []\n                    logger.info(\"Loaded %s printable objects for printer %s\", len(printable_objects), printer_id)\n    except Exception as e:\n        logger.debug(\"Failed to extract printable objects from archive: %s\", e)\n\n\nasync def on_print_start(printer_id: int, data: dict):\n    \"\"\"Handle print start - archive the 3MF file immediately.\"\"\"\n    logger = logging.getLogger(__name__)\n\n    logger.info(\"[CALLBACK] on_print_start called for printer %s, data keys: %s\", printer_id, list(data.keys()))\n\n    # Clear any stale user-stopped flag from previous print cycles\n    _user_stopped_printers.discard(printer_id)\n\n    # Cancel any active bed cooldown waiter for this printer\n    if _bed_cool_waiters.pop(printer_id, None):\n        logger.info(\"[BED-COOL] Cancelled bed cooldown waiter for printer %s (new print started)\", printer_id)\n\n    # Clear cached cover images so the new print's thumbnail is fetched fresh\n    from backend.app.api.routes.printers import clear_cover_cache\n\n    clear_cover_cache(printer_id)\n\n    await ws_manager.send_print_start(printer_id, data)\n\n    # Notify when the print-start AMS mapping references tray slots without spool assignments.\n    await notify_missing_spool_assignments_on_print_start(printer_id, data, logger)\n\n    # MQTT relay - publish print start\n    try:\n        printer_info = printer_manager.get_printer(printer_id)\n        if printer_info:\n            await mqtt_relay.on_print_start(\n                printer_id,\n                printer_info.name,\n                printer_info.serial_number,\n                data.get(\"filename\", \"\"),\n                data.get(\"subtask_name\", \"\"),\n            )\n    except Exception:\n        pass  # Don't fail print start callback if MQTT fails\n\n    # Capture AMS tray remain% for filament consumption tracking (skip if Spoolman handles usage)\n    try:\n        async with async_session() as db:\n            from backend.app.api.routes.settings import get_setting\n\n            _spoolman_on = await get_setting(db, \"spoolman_enabled\")\n            if not _spoolman_on or _spoolman_on.lower() != \"true\":\n                from backend.app.services.usage_tracker import on_print_start as usage_on_print_start\n\n                await usage_on_print_start(printer_id, data, printer_manager, db=db)\n    except Exception as e:\n        logger.warning(\"Usage tracker on_print_start failed: %s\", e)\n\n    # Track if notification was sent (to avoid sending twice)\n    notification_sent = False\n\n    # Smart plug automation: turn on plug when print starts\n    try:\n        async with async_session() as db:\n            await smart_plug_manager.on_print_start(printer_id, db)\n    except Exception as e:\n        logger.warning(\"Smart plug on_print_start failed: %s\", e)\n\n    async with async_session() as db:\n        from backend.app.models.printer import Printer\n        from backend.app.services.bambu_ftp import list_files_async\n\n        result = await db.execute(select(Printer).where(Printer.id == printer_id))\n        printer = result.scalar_one_or_none()\n\n        # Plate detection check - pause if objects detected on build plate\n        logger.info(\n            f\"[PLATE CHECK] printer_id={printer_id}, plate_detection_enabled={printer.plate_detection_enabled if printer else 'NO PRINTER'}\"\n        )\n        if printer and printer.plate_detection_enabled:\n            logger.info(\"[PLATE CHECK] ENTERING plate detection code for printer %s\", printer_id)\n            try:\n                from backend.app.services.plate_detection import check_plate_empty\n\n                # Build ROI tuple from printer settings if available\n                roi = None\n                if all(\n                    [\n                        printer.plate_detection_roi_x is not None,\n                        printer.plate_detection_roi_y is not None,\n                        printer.plate_detection_roi_w is not None,\n                        printer.plate_detection_roi_h is not None,\n                    ]\n                ):\n                    roi = (\n                        printer.plate_detection_roi_x,\n                        printer.plate_detection_roi_y,\n                        printer.plate_detection_roi_w,\n                        printer.plate_detection_roi_h,\n                    )\n\n                # Auto-turn on chamber light if it's off for better detection\n                light_was_off = False\n                client = printer_manager.get_client(printer_id)\n                if client and client.state:\n                    light_was_off = not client.state.chamber_light\n                    if light_was_off:\n                        logger.info(\"[PLATE CHECK] Turning on chamber light for printer %s\", printer_id)\n                        client.set_chamber_light(True)\n                        # Wait for light to physically turn on and camera to adjust exposure\n                        await asyncio.sleep(2.5)\n\n                logger.info(\"[PLATE CHECK] Running plate detection for printer %s\", printer_id)\n                plate_result = await check_plate_empty(\n                    printer_id=printer_id,\n                    ip_address=printer.ip_address,\n                    access_code=printer.access_code,\n                    model=printer.model,\n                    include_debug_image=False,\n                    external_camera_url=printer.external_camera_url,\n                    external_camera_type=printer.external_camera_type,\n                    use_external=printer.external_camera_enabled,\n                    roi=roi,\n                )\n\n                # Restore chamber light to original state\n                if light_was_off and client:\n                    logger.info(\"[PLATE CHECK] Restoring chamber light to off for printer %s\", printer_id)\n                    client.set_chamber_light(False)\n\n                if not plate_result.needs_calibration and not plate_result.is_empty:\n                    # Objects detected - pause the print!\n                    logger.warning(\n                        f\"[PLATE CHECK] Objects detected on plate for printer {printer_id}! \"\n                        f\"Confidence: {plate_result.confidence:.0%}, Diff: {plate_result.difference_percent:.1f}%\"\n                    )\n                    client = printer_manager.get_client(printer_id)\n                    if client:\n                        client.pause_print()\n                        logger.info(\"[PLATE CHECK] Print paused for printer %s\", printer_id)\n\n                    # Send notification about plate not empty\n                    await ws_manager.broadcast(\n                        {\n                            \"type\": \"plate_not_empty\",\n                            \"printer_id\": printer_id,\n                            \"printer_name\": printer.name,\n                            \"message\": f\"Objects detected on build plate! Print paused. (Diff: {plate_result.difference_percent:.1f}%)\",\n                        }\n                    )\n\n                    # Also send push notification\n                    try:\n                        await notification_service.on_plate_not_empty(\n                            printer_id=printer_id,\n                            printer_name=printer.name,\n                            db=db,\n                            difference_percent=plate_result.difference_percent,\n                        )\n                    except Exception as notif_err:\n                        logger.warning(\"[PLATE CHECK] Failed to send notification: %s\", notif_err)\n                else:\n                    logger.info(\"[PLATE CHECK] Plate is empty for printer %s, proceeding with print\", printer_id)\n            except Exception as plate_err:\n                # Don't block print on plate detection errors\n                logger.warning(\"[PLATE CHECK] Plate detection failed for printer %s: %s\", printer_id, plate_err)\n\n        if not printer:\n            logger.info(\"[CALLBACK] Skipping archive - printer not found in database\")\n            if not notification_sent:\n                await _send_print_start_notification(printer_id, data, logger=logger)\n            return\n\n        if not printer.auto_archive:\n            # auto-archive disabled — check if there's an expected print (dispatched\n            # by BamBuddy via queue/reprint) that already has an archive to promote.\n            # If so, fall through to the expected-print handling below so the archive\n            # is tracked in _active_prints and usage tracking works at completion.\n            _fn = data.get(\"filename\", \"\")\n            _sn = data.get(\"subtask_name\", \"\")\n            _check_keys: list[tuple[int, str]] = []\n            if _sn:\n                _check_keys += [\n                    (printer_id, _sn),\n                    (printer_id, f\"{_sn}.3mf\"),\n                    (printer_id, f\"{_sn}.gcode.3mf\"),\n                ]\n            if _fn:\n                _base_fn = _fn.split(\"/\")[-1] if \"/\" in _fn else _fn\n                _check_keys.append((printer_id, _base_fn))\n                _no_archive_base = _base_fn.replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n                _check_keys += [\n                    (printer_id, _no_archive_base),\n                    (printer_id, f\"{_no_archive_base}.3mf\"),\n                ]\n\n            _has_expected = any(k in _expected_prints for k in _check_keys)\n\n            if not _has_expected:\n                # No expected print — truly external print (started from slicer/touchscreen)\n                logger.info(\"[CALLBACK] Skipping archive - auto_archive: False, no expected print\")\n                if not notification_sent:\n                    _no_archive_creator: int | None = None\n                    for _key in _check_keys:\n                        _expected_prints.pop(_key, None)\n                        _expected_print_registered_at.pop(_key, None)\n                        popped_creator = _expected_print_creators.pop(_key, None)\n                        if _no_archive_creator is None:\n                            _no_archive_creator = popped_creator\n                    _creator_data = {\"created_by_id\": _no_archive_creator} if _no_archive_creator else None\n                    await _send_print_start_notification(printer_id, data, _creator_data, logger)\n                return\n            else:\n                logger.info(\"[CALLBACK] auto_archive disabled but expected print found — promoting archive\")\n\n        # Get the filename and subtask_name\n        filename = data.get(\"filename\", \"\")\n        subtask_name = data.get(\"subtask_name\", \"\")\n\n        # MQTT subtask_id uniquely identifies a print job on the printer. When\n        # present, it lets us match an archive across a backend restart (#972):\n        # same id → same print → resume the existing row instead of cancelling\n        # it and recreating from scratch (which loses started_at). Treat \"0\"\n        # and \"\" as absent — Bambu reports \"0\" for non-cloud / local prints.\n        raw_mqtt = data.get(\"raw_data\") or {}\n        subtask_id = raw_mqtt.get(\"subtask_id\")\n        if subtask_id is not None:\n            subtask_id = str(subtask_id).strip()\n            if subtask_id in (\"\", \"0\"):\n                subtask_id = None\n\n        logger.info(\"[CALLBACK] Print start detected - filename: %s, subtask: %s\", filename, subtask_name)\n\n        # Skip calibration prints — internal printer files should not be archived\n        # Bambu calibration gcode lives under /usr/ (e.g. /usr/etc/print/auto_cali_for_user.gcode)\n        if filename and filename.startswith(\"/usr/\"):\n            logger.info(\"[CALLBACK] Skipping archive — internal printer file detected: %s\", filename)\n            if not notification_sent:\n                await _send_print_start_notification(printer_id, data, logger=logger)\n            return\n\n        if not filename and not subtask_name:\n            # Send notification without archive data (no filename)\n            logger.info(\"[CALLBACK] Skipping archive - no filename or subtask_name\")\n            if not notification_sent:\n                await _send_print_start_notification(printer_id, data, logger=logger)\n            return\n\n        # Check if this is an expected print from reprint/scheduled\n        # Build list of possible keys to check\n        expected_keys = []\n        if subtask_name:\n            expected_keys.append((printer_id, subtask_name))\n            expected_keys.append((printer_id, f\"{subtask_name}.3mf\"))\n            expected_keys.append((printer_id, f\"{subtask_name}.gcode.3mf\"))\n        if filename:\n            fname = filename.split(\"/\")[-1] if \"/\" in filename else filename\n            expected_keys.append((printer_id, fname))\n            # Strip extensions to match\n            base = fname.replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n            expected_keys.append((printer_id, base))\n            expected_keys.append((printer_id, f\"{base}.3mf\"))\n\n        expected_archive_id = None\n        for key in expected_keys:\n            expected_archive_id = _expected_prints.pop(key, None)\n            _expected_print_registered_at.pop(key, None)\n            if expected_archive_id:\n                # Clean up other possible keys for this print\n                for other_key in expected_keys:\n                    _expected_prints.pop(other_key, None)\n                    _expected_print_registered_at.pop(other_key, None)\n                break\n\n        if expected_archive_id:\n            # This is a reprint/scheduled print - use existing archive, don't create new one\n            logger.info(\"Using expected archive %s for print (skipping duplicate)\", expected_archive_id)\n            from backend.app.models.archive import PrintArchive\n\n            result = await db.execute(select(PrintArchive).where(PrintArchive.id == expected_archive_id))\n            archive = result.scalar_one_or_none()\n\n            if archive:\n                # Update archive status to printing\n                archive.status = \"printing\"\n                archive.started_at = datetime.now(timezone.utc)\n                if subtask_id and not archive.subtask_id:\n                    archive.subtask_id = subtask_id\n                await db.commit()\n\n                # Track as active print\n                _active_prints[(printer_id, archive.filename)] = archive.id\n                if subtask_name:\n                    _active_prints[(printer_id, f\"{subtask_name}.3mf\")] = archive.id\n\n                # Inject ams_mapping into usage tracker session — the session was created\n                # before expected-print promotion, so it may have ams_mapping=None when\n                # the MQTT request topic subscription failed (common on P1S/A1).\n                _stored_map = _print_ams_mappings.get(expected_archive_id)\n                if _stored_map:\n                    try:\n                        from backend.app.services.usage_tracker import _active_sessions\n\n                        _ut_session = _active_sessions.get(printer_id)\n                        if _ut_session and not _ut_session.ams_mapping:\n                            _ut_session.ams_mapping = _stored_map\n                            logger.info(\"[CALLBACK] Injected ams_mapping into usage tracker session: %s\", _stored_map)\n                    except Exception:\n                        pass\n\n                # Set up energy tracking (#941: persist start on archive row)\n                await _record_energy_start(archive, printer_id, db, context=\"expected-print\")\n\n                await ws_manager.send_archive_updated(\n                    {\n                        \"id\": archive.id,\n                        \"status\": \"printing\",\n                    }\n                )\n\n                # Send notification with archive data (reprint/scheduled)\n                if not notification_sent:\n                    # Use archive's created_by_id; fall back to the creator registered via\n                    # register_expected_print (handles library-file-based queue items where\n                    # the freshly-created archive has no created_by_id yet).\n                    # Pop ALL matching keys so no stale entries remain in the dict.\n                    fallback_creator = None\n                    for key in expected_keys:\n                        popped = _expected_print_creators.pop(key, None)\n                        if fallback_creator is None:\n                            fallback_creator = popped\n                    archive_data = {\n                        \"print_time_seconds\": archive.print_time_seconds,\n                        \"created_by_id\": archive.created_by_id or fallback_creator,\n                    }\n                    await _send_print_start_notification(printer_id, data, archive_data, logger)\n\n                # Extract printable objects from the archived 3MF file\n                _load_objects_from_archive(archive, printer_id, logger)\n\n                # Store Spoolman tracking data for per-filament usage reporting\n                try:\n                    await _store_spoolman_print_data(\n                        printer_id,\n                        archive.id,\n                        archive.file_path,\n                        db,\n                        printer_manager,\n                        ams_mapping=_get_start_ams_mapping(data, archive.id),\n                    )\n                except Exception as e:\n                    logger.warning(\"[SPOOLMAN] Failed to store tracking data: %s\", e)\n\n            return  # Skip creating a new archive\n\n        # Check if there's already a \"printing\" archive for this printer/file\n        # This prevents duplicates when backend restarts during an active print\n        from backend.app.models.archive import PrintArchive\n\n        existing_archive: PrintArchive | None = None\n\n        # Preferred match: subtask_id equality. MQTT reports the same subtask_id\n        # across a backend restart for the same print, so this is the most\n        # reliable way to reattach. We also accept a previously stale-cancelled\n        # archive here so users upgrading mid-print get revived when the row\n        # their earlier Bambuddy version wrongly cancelled reappears (#972).\n        if subtask_id:\n            by_id = await db.execute(\n                select(PrintArchive)\n                .where(PrintArchive.printer_id == printer_id)\n                .where(PrintArchive.subtask_id == subtask_id)\n                .where(PrintArchive.status.in_([\"printing\", \"cancelled\"]))\n                .order_by(PrintArchive.created_at.desc())\n                .limit(1)\n            )\n            candidate = by_id.scalar_one_or_none()\n            if candidate and (candidate.status == \"printing\" or (candidate.failure_reason or \"\").startswith(\"Stale\")):\n                existing_archive = candidate\n\n        # Fallback match: name-based lookup. Kept as-is for prints whose\n        # subtask_id is missing (\"0\" / local / non-cloud prints).\n        if existing_archive is None:\n            check_name = subtask_name or filename.split(\"/\")[-1].replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n            existing = await db.execute(\n                select(PrintArchive)\n                .where(PrintArchive.printer_id == printer_id)\n                .where(PrintArchive.status == \"printing\")\n                .where(\n                    or_(\n                        PrintArchive.print_name == check_name,\n                        PrintArchive.filename.in_(\n                            [\n                                f\"{check_name}.3mf\",\n                                f\"{check_name}.gcode.3mf\",\n                            ]\n                        ),\n                    )\n                )\n                .order_by(PrintArchive.created_at.desc())\n                .limit(1)\n            )\n            existing_archive = existing.scalar_one_or_none()\n\n        if existing_archive:\n            # subtask_id match → always resume, regardless of age. Same print,\n            # just a backend restart. Revive if it was previously stale-cancelled.\n            subtask_match = bool(subtask_id and existing_archive.subtask_id == subtask_id)\n\n            if subtask_match:\n                if existing_archive.status == \"cancelled\":\n                    logger.warning(\n                        \"Reviving stale-cancelled archive %s — matching subtask_id %s confirms same print (#972)\",\n                        existing_archive.id,\n                        subtask_id,\n                    )\n                    existing_archive.status = \"printing\"\n                    existing_archive.failure_reason = None\n                    await db.commit()\n                else:\n                    logger.info(\"Resuming archive %s on subtask_id match (%s)\", existing_archive.id, subtask_id)\n                _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id\n                if existing_archive.energy_start_kwh is None:\n                    await _record_energy_start(existing_archive, printer_id, db, context=\"subtask-resume\")\n                if not notification_sent:\n                    archive_data = {\n                        \"print_time_seconds\": existing_archive.print_time_seconds,\n                        \"created_by_id\": existing_archive.created_by_id,\n                    }\n                    await _send_print_start_notification(printer_id, data, archive_data, logger)\n                _load_objects_from_archive(existing_archive, printer_id, logger)\n                return\n\n            # Name-match only: fall back to the legacy 4h staleness heuristic.\n            archive_age = datetime.now(timezone.utc) - existing_archive.created_at.replace(tzinfo=timezone.utc)\n            if archive_age.total_seconds() > 4 * 60 * 60:  # 4 hours\n                logger.warning(\n                    f\"Found stale 'printing' archive {existing_archive.id} (age: {archive_age}), \"\n                    f\"marking as cancelled and creating new archive\"\n                )\n                existing_archive.status = \"cancelled\"\n                existing_archive.failure_reason = \"Stale - print likely cancelled or failed without status update\"\n                await db.commit()\n                # Fall through to create new archive (don't return)\n                _existing_archive = None  # Clear so we don't use stale archive\n            else:\n                logger.info(\n                    f\"Skipping duplicate - already have printing archive {existing_archive.id} for {check_name}\"\n                )\n                # Track this as the active print\n                _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id\n                # Attach subtask_id retroactively so future restarts can resume\n                if subtask_id and not existing_archive.subtask_id:\n                    existing_archive.subtask_id = subtask_id\n                    await db.commit()\n                # Also set up energy tracking if not already tracked (#941: persisted column)\n                if existing_archive.energy_start_kwh is None:\n                    await _record_energy_start(existing_archive, printer_id, db, context=\"existing-printing\")\n                # Send notification with archive data (existing archive)\n                if not notification_sent:\n                    archive_data = {\n                        \"print_time_seconds\": existing_archive.print_time_seconds,\n                        \"created_by_id\": existing_archive.created_by_id,\n                    }\n                    await _send_print_start_notification(printer_id, data, archive_data, logger)\n                # Extract printable objects from the archived 3MF file\n                _load_objects_from_archive(existing_archive, printer_id, logger)\n                return\n\n        # Build list of possible 3MF filenames to try\n        possible_names = []\n\n        # Bambu printers typically store files as \"Name.gcode.3mf\"\n        # The subtask_name is usually the best source for the filename\n        if subtask_name:\n            # Try common Bambu naming patterns\n            possible_names.append(f\"{subtask_name}.gcode.3mf\")\n            possible_names.append(f\"{subtask_name}.3mf\")\n\n        # Try original filename with .3mf extension\n        if filename:\n            # Extract just the filename part, not the full path\n            fname = filename.split(\"/\")[-1] if \"/\" in filename else filename\n            if fname.endswith(\".3mf\"):\n                possible_names.append(fname)\n            elif fname.endswith(\".gcode\"):\n                base = fname.rsplit(\".\", 1)[0]\n                possible_names.append(f\"{base}.gcode.3mf\")\n                possible_names.append(f\"{base}.3mf\")\n            else:\n                possible_names.append(f\"{fname}.gcode.3mf\")\n                possible_names.append(f\"{fname}.3mf\")\n\n        # Also try with spaces converted to underscores (Bambu Studio may normalize filenames)\n        space_variants = []\n        for name in possible_names:\n            if \" \" in name:\n                space_variants.append(name.replace(\" \", \"_\"))\n        possible_names.extend(space_variants)\n\n        # Remove duplicates while preserving order\n        seen = set()\n        possible_names = [x for x in possible_names if not (x in seen or seen.add(x))]\n\n        logger.info(\"Trying filenames: %s\", possible_names)\n\n        # Try to find and download the 3MF file\n        temp_path = None\n        downloaded_filename = None\n\n        # Cache check: cover endpoint may have already pulled this 3MF during\n        # the print (frontend opens the card and shows the thumbnail) — reuse\n        # that file instead of re-downloading 36MB over the same FTP link that\n        # just served it (#972). The cache keys on a normalized filename so\n        # variants like \"X\", \"X.3mf\", \"X.gcode.3mf\" all collapse to one entry.\n        for try_filename in possible_names:\n            if not try_filename.endswith(\".3mf\"):\n                continue\n            cached = get_cached_3mf(printer_id, try_filename)\n            if cached:\n                logger.info(\"Reusing cached 3MF from %s (avoided duplicate FTP)\", cached)\n                temp_path = cached\n                downloaded_filename = try_filename\n                break\n\n        # Get FTP retry settings\n        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()\n\n        for try_filename in possible_names if not downloaded_filename else []:\n            if not try_filename.endswith(\".3mf\"):\n                continue\n\n            # Root (/) is where BambuStudio/OrcaSlicer uploads land on A1/P1-series\n            # printers, so try it first — deferring it to last cost #972's reporter\n            # ~48 minutes of retries on /cache//model//data//data/Metadata before\n            # landing on the path that actually had the file.\n            remote_paths = [\n                f\"/{try_filename}\",\n                f\"/cache/{try_filename}\",\n                f\"/model/{try_filename}\",\n                f\"/data/{try_filename}\",\n                f\"/data/Metadata/{try_filename}\",\n            ]\n\n            temp_path = app_settings.archive_dir / \"temp\" / try_filename\n            temp_path.parent.mkdir(parents=True, exist_ok=True)\n\n            for remote_path in remote_paths:\n                logger.debug(\"Trying FTP download: %s\", remote_path)\n                try:\n                    if ftp_retry_enabled:\n                        downloaded = await with_ftp_retry(\n                            download_file_async,\n                            printer.ip_address,\n                            printer.access_code,\n                            remote_path,\n                            temp_path,\n                            timeout=ftp_timeout,\n                            socket_timeout=ftp_timeout,\n                            printer_model=printer.model,\n                            max_retries=ftp_retry_count,\n                            retry_delay=ftp_retry_delay,\n                            operation_name=f\"Download 3MF from {remote_path}\",\n                            non_retry_exceptions=(FileNotOnPrinterError,),\n                        )\n                    else:\n                        downloaded = await download_file_async(\n                            printer.ip_address,\n                            printer.access_code,\n                            remote_path,\n                            temp_path,\n                            timeout=ftp_timeout,\n                            socket_timeout=ftp_timeout,\n                            printer_model=printer.model,\n                        )\n                    if downloaded:\n                        downloaded_filename = try_filename\n                        logger.info(\"Downloaded: %s\", remote_path)\n                        # Populate shared cache so the cover endpoint (if it\n                        # runs next) doesn't refetch the same 36MB over FTP.\n                        cache_3mf_download(printer_id, try_filename, temp_path)\n                        break\n                except FileNotOnPrinterError:\n                    # 550 — file isn't at this path. Advance to next candidate\n                    # without burning the retry budget.\n                    logger.debug(\"3MF not at %s (550), trying next path\", remote_path)\n                except Exception as e:\n                    logger.debug(\"FTP download failed for %s: %s\", remote_path, e)\n\n            if downloaded_filename:\n                break\n\n        # If still not found, try listing directories to find matching file\n        # Different printer models use different directory structures\n        if not downloaded_filename and (filename or subtask_name):\n            search_term = (subtask_name or filename).lower().replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n            logger.info(\"Direct FTP download failed, searching directories for '%s'\", search_term)\n            search_dirs = [\"/cache\", \"/model\", \"/data\", \"/data/Metadata\", \"/\"]\n            for search_dir in search_dirs:\n                if downloaded_filename:\n                    break\n                try:\n                    dir_files = await list_files_async(\n                        printer.ip_address, printer.access_code, search_dir, printer_model=printer.model\n                    )\n                    threemf_files = [f.get(\"name\") for f in dir_files if f.get(\"name\", \"\").endswith(\".3mf\")]\n                    if threemf_files:\n                        logger.info(\n                            f\"Found {len(threemf_files)} 3MF files in {search_dir}: {threemf_files[:5]}{'...' if len(threemf_files) > 5 else ''}\"\n                        )\n                    for f in dir_files:\n                        if f.get(\"is_directory\"):\n                            continue\n                        fname = f.get(\"name\", \"\")\n                        # Normalize both for comparison (spaces and underscores are equivalent)\n                        fname_normalized = fname.lower().replace(\" \", \"_\")\n                        search_normalized = search_term.replace(\" \", \"_\")\n                        if fname.endswith(\".3mf\") and search_normalized in fname_normalized:\n                            logger.info(\"Found matching file in %s: %s\", search_dir, fname)\n                            temp_path = app_settings.archive_dir / \"temp\" / fname\n                            temp_path.parent.mkdir(parents=True, exist_ok=True)\n                            remote_full_path = posixpath.join(search_dir, fname)\n                            if ftp_retry_enabled:\n                                downloaded = await with_ftp_retry(\n                                    download_file_async,\n                                    printer.ip_address,\n                                    printer.access_code,\n                                    remote_full_path,\n                                    temp_path,\n                                    timeout=ftp_timeout,\n                                    socket_timeout=ftp_timeout,\n                                    printer_model=printer.model,\n                                    max_retries=ftp_retry_count,\n                                    retry_delay=ftp_retry_delay,\n                                    operation_name=f\"Download 3MF from {remote_full_path}\",\n                                )\n                            else:\n                                downloaded = await download_file_async(\n                                    printer.ip_address,\n                                    printer.access_code,\n                                    remote_full_path,\n                                    temp_path,\n                                    timeout=ftp_timeout,\n                                    socket_timeout=ftp_timeout,\n                                    printer_model=printer.model,\n                                )\n                            if downloaded:\n                                downloaded_filename = fname\n                                logger.info(\"Found and downloaded from %s: %s\", search_dir, fname)\n                                cache_3mf_download(printer_id, fname, temp_path)\n                                break\n                except Exception as e:\n                    logger.debug(\"Failed to list %s: %s\", search_dir, e)\n\n        if not downloaded_filename or not temp_path:\n            logger.warning(\"Could not find 3MF file for print: %s\", filename or subtask_name)\n            # Create a fallback archive without 3MF data so the print is still tracked\n            # This commonly happens with P1S/A1 printers where FTP has file size limitations\n            try:\n                from backend.app.models.archive import PrintArchive\n\n                # Derive print name from subtask_name or filename\n                print_name = subtask_name or filename\n                if print_name:\n                    # Clean up the name (remove extensions, path parts)\n                    print_name = print_name.split(\"/\")[-1]\n                    print_name = print_name.replace(\".gcode.3mf\", \"\").replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n                else:\n                    print_name = \"Unknown Print\"\n\n                # Recover estimated print time from MQTT (best-effort for notifications)\n                fallback_print_time = None\n                mqtt_remaining = data.get(\"remaining_time\")\n                if mqtt_remaining and isinstance(mqtt_remaining, (int, float)) and mqtt_remaining > 0:\n                    fallback_print_time = int(mqtt_remaining)\n                if fallback_print_time is None:\n                    mc_remaining = (data.get(\"raw_data\") or {}).get(\"mc_remaining_time\")\n                    if mc_remaining and isinstance(mc_remaining, (int, float)) and mc_remaining > 0:\n                        fallback_print_time = int(mc_remaining * 60)\n\n                # Create minimal archive entry\n                fallback_archive = PrintArchive(\n                    printer_id=printer_id,\n                    filename=filename or f\"{print_name}.3mf\",\n                    file_path=\"\",  # Empty - no 3MF file available\n                    file_size=0,\n                    print_name=print_name,\n                    print_time_seconds=fallback_print_time,\n                    status=\"printing\",\n                    started_at=datetime.now(timezone.utc),\n                    subtask_id=subtask_id,\n                    extra_data={\"no_3mf_available\": True, \"original_subtask\": subtask_name, \"_print_data\": data},\n                )\n\n                db.add(fallback_archive)\n                await db.commit()\n                await db.refresh(fallback_archive)\n\n                logger.info(\"Created fallback archive %s for %s (no 3MF available)\", fallback_archive.id, print_name)\n\n                # Start timelapse session if external camera is enabled\n                if printer.external_camera_enabled and printer.external_camera_url:\n                    from backend.app.services.layer_timelapse import start_session\n\n                    start_session(\n                        printer_id,\n                        fallback_archive.id,\n                        printer.external_camera_url,\n                        printer.external_camera_type or \"mjpeg\",\n                    )\n                    logger.info(\"Started layer timelapse for printer %s, archive %s\", printer_id, fallback_archive.id)\n\n                # Track as active print\n                _active_prints[(printer_id, fallback_archive.filename)] = fallback_archive.id\n                if filename:\n                    _active_prints[(printer_id, filename)] = fallback_archive.id\n                if subtask_name:\n                    _active_prints[(printer_id, f\"{subtask_name}.3mf\")] = fallback_archive.id\n                    _active_prints[(printer_id, subtask_name)] = fallback_archive.id\n\n                # Record starting energy if smart plug available (#941: persisted column)\n                await _record_energy_start(fallback_archive, printer_id, db, context=\"fallback\")\n\n                # Send WebSocket notification\n                await ws_manager.send_archive_created(\n                    {\n                        \"id\": fallback_archive.id,\n                        \"printer_id\": fallback_archive.printer_id,\n                        \"filename\": fallback_archive.filename,\n                        \"print_name\": fallback_archive.print_name,\n                        \"status\": fallback_archive.status,\n                    }\n                )\n\n                # MQTT relay - publish archive created\n                try:\n                    await mqtt_relay.on_archive_created(\n                        archive_id=fallback_archive.id,\n                        print_name=fallback_archive.print_name,\n                        printer_name=printer.name,\n                        status=fallback_archive.status,\n                    )\n                except Exception:\n                    pass  # Don't fail if MQTT fails\n\n                # Store Spoolman tracking data (may not work for fallback since no 3MF)\n                try:\n                    await _store_spoolman_print_data(\n                        printer_id,\n                        fallback_archive.id,\n                        fallback_archive.file_path,\n                        db,\n                        printer_manager,\n                        ams_mapping=_get_start_ams_mapping(data, fallback_archive.id),\n                    )\n                except Exception as e:\n                    logger.debug(\"[SPOOLMAN] Could not store tracking for fallback archive: %s\", e)\n\n                # Send notification without archive data (file not found)\n                if not notification_sent:\n                    await _send_print_start_notification(printer_id, data, logger=logger)\n                return\n            except Exception as e:\n                logger.error(\"Failed to create fallback archive: %s\", e)\n                # Send notification without archive data (file not found)\n                if not notification_sent:\n                    await _send_print_start_notification(printer_id, data, logger=logger)\n                return\n\n        try:\n            # Archive the file with status \"printing\"\n            service = ArchiveService(db)\n            archive = await service.archive_print(\n                printer_id=printer_id,\n                source_file=temp_path,\n                print_data={**data, \"status\": \"printing\"},\n                subtask_id=subtask_id,\n            )\n\n            if archive:\n                # Track this active print (use both original filename and downloaded filename)\n                _active_prints[(printer_id, downloaded_filename)] = archive.id\n                if filename and filename != downloaded_filename:\n                    _active_prints[(printer_id, filename)] = archive.id\n                if subtask_name:\n                    _active_prints[(printer_id, f\"{subtask_name}.3mf\")] = archive.id\n\n                logger.info(\"Created archive %s for %s\", archive.id, downloaded_filename)\n\n                # Start timelapse session if external camera is enabled\n                if printer.external_camera_enabled and printer.external_camera_url:\n                    from backend.app.services.layer_timelapse import start_session\n\n                    start_session(\n                        printer_id,\n                        archive.id,\n                        printer.external_camera_url,\n                        printer.external_camera_type or \"mjpeg\",\n                    )\n                    logger.info(\"Started layer timelapse for printer %s, archive %s\", printer_id, archive.id)\n\n                # Record starting energy from smart plug if available (#941: persisted column)\n                await _record_energy_start(archive, printer_id, db, context=\"auto-archive\")\n\n                await ws_manager.send_archive_created(\n                    {\n                        \"id\": archive.id,\n                        \"printer_id\": archive.printer_id,\n                        \"filename\": archive.filename,\n                        \"print_name\": archive.print_name,\n                        \"status\": archive.status,\n                    }\n                )\n\n                # MQTT relay - publish archive created\n                try:\n                    await mqtt_relay.on_archive_created(\n                        archive_id=archive.id,\n                        print_name=archive.print_name,\n                        printer_name=printer.name,\n                        status=archive.status,\n                    )\n                except Exception:\n                    pass  # Don't fail if MQTT fails\n\n                # Send notification with archive data (new archive created)\n                if not notification_sent:\n                    archive_data = {\n                        \"print_time_seconds\": archive.print_time_seconds,\n                        \"created_by_id\": archive.created_by_id,\n                    }\n                    await _send_print_start_notification(printer_id, data, archive_data, logger)\n\n                # Extract printable objects for skip object functionality\n                try:\n                    from backend.app.services.archive import extract_printable_objects_from_3mf\n\n                    with open(temp_path, \"rb\") as f:\n                        threemf_data = f.read()\n                    # Extract with positions for UI overlay\n                    printable_objects, bbox_all = extract_printable_objects_from_3mf(\n                        threemf_data, include_positions=True\n                    )\n                    if printable_objects:\n                        # Store objects in printer state\n                        client = printer_manager.get_client(printer_id)\n                        if client:\n                            client.state.printable_objects = printable_objects\n                            client.state.printable_objects_bbox_all = bbox_all\n                            client.state.skipped_objects = []  # Reset skipped objects for new print\n                            logger.info(\n                                \"Loaded %s printable objects for printer %s\", len(printable_objects), printer_id\n                            )\n                except Exception as e:\n                    logger.debug(\"Failed to extract printable objects: %s\", e)\n\n                # Store Spoolman tracking data for per-filament usage reporting\n                try:\n                    await _store_spoolman_print_data(\n                        printer_id,\n                        archive.id,\n                        archive.file_path,\n                        db,\n                        printer_manager,\n                        ams_mapping=_get_start_ams_mapping(data, archive.id),\n                    )\n                except Exception as e:\n                    logger.warning(\"[SPOOLMAN] Failed to store tracking data: %s\", e)\n\n                # Capture timelapse file baseline for snapshot-diff on completion\n                try:\n                    baseline_files, _ = await _list_timelapse_videos(printer)\n                    _timelapse_baselines[printer_id] = {f.get(\"name\", \"\") for f in baseline_files}\n                    logger.info(\n                        \"[TIMELAPSE] Baseline at print start: %s video files for printer %s\",\n                        len(_timelapse_baselines[printer_id]),\n                        printer_id,\n                    )\n                except Exception as e:\n                    logger.warning(\"[TIMELAPSE] Failed to capture baseline at print start: %s\", e)\n        finally:\n            # Keep temp_path around until print completes so the cover endpoint\n            # can reuse it (#972). Cache eviction in on_print_complete deletes\n            # the file. If the cache entry was evicted early (file vanished),\n            # clean up any stragglers here to avoid leaking disk on retries.\n            cached_now = get_cached_3mf(printer_id, downloaded_filename) if downloaded_filename else None\n            if temp_path and temp_path.exists() and cached_now != temp_path:\n                temp_path.unlink()\n\n\n_TIMELAPSE_VIDEO_EXTENSIONS = (\".mp4\", \".avi\")\n\n\nasync def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:\n    \"\"\"List video files from printer's timelapse directory.\n\n    Finds MP4 (X1/A1 series) and AVI (P1 series) timelapse files.\n    Returns (video_files, found_path) where video_files is a list of file dicts\n    and found_path is the directory where they were found, or ([], None).\n    \"\"\"\n    from backend.app.services.bambu_ftp import list_files_async\n\n    logger = logging.getLogger(__name__)\n\n    for timelapse_path in [\"/timelapse\", \"/timelapse/video\", \"/record\", \"/recording\"]:\n        try:\n            found_files = await list_files_async(\n                printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model\n            )\n            if found_files:\n                video_files = [\n                    f\n                    for f in found_files\n                    if not f.get(\"is_directory\") and f.get(\"name\", \"\").lower().endswith(_TIMELAPSE_VIDEO_EXTENSIONS)\n                ]\n                if video_files:\n                    return video_files, timelapse_path\n        except Exception as e:\n            logger.debug(\"[TIMELAPSE] Path %s failed: %s\", timelapse_path, e)\n            continue\n\n    return [], None\n\n\nasync def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):\n    \"\"\"\n    Scan for timelapse with retries using a snapshot-diff approach.\n\n    Instead of picking the \"most recent by mtime\" (unreliable when the printer\n    clock is wrong in LAN-only mode), we snapshot existing MP4 filenames BEFORE\n    waiting, then look for any NEW filename that appears after each delay.\n\n    If baseline_names is provided (captured at print start), it is used directly.\n    Otherwise falls back to taking a baseline at completion time (best-effort\n    for prints started before app restart).\n\n    Falls back to name-matching (print name contained in MP4 filename) if no\n    new file appears after all retries.\n    \"\"\"\n    from pathlib import Path\n\n    logger = logging.getLogger(__name__)\n\n    # --- Phase 1: Take baseline snapshot of existing timelapse files ---\n    try:\n        async with async_session() as db:\n            from backend.app.models.printer import Printer\n\n            service = ArchiveService(db)\n            archive = await service.get_archive(archive_id)\n\n            if not archive:\n                logger.warning(\"[TIMELAPSE] Archive %s not found, aborting\", archive_id)\n                return\n            if archive.timelapse_path:\n                logger.info(\"[TIMELAPSE] Archive %s already has timelapse attached\", archive_id)\n                return\n            if not archive.printer_id:\n                logger.warning(\"[TIMELAPSE] Archive %s has no printer, aborting\", archive_id)\n                return\n\n            if baseline_names is not None:\n                # Use pre-captured baseline from print start (no race condition)\n                logger.info(\n                    \"[TIMELAPSE] Using print-start baseline: %s existing video files for archive %s\",\n                    len(baseline_names),\n                    archive_id,\n                )\n            else:\n                # Fallback: take baseline now (e.g. app restarted mid-print)\n                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))\n                printer = result.scalar_one_or_none()\n                if not printer:\n                    logger.warning(\"[TIMELAPSE] Printer not found for archive %s, aborting\", archive_id)\n                    return\n\n                baseline_files, _ = await _list_timelapse_videos(printer)\n                baseline_names = {f.get(\"name\", \"\") for f in baseline_files}\n                logger.info(\n                    \"[TIMELAPSE] Baseline snapshot (fallback): %s existing video files for archive %s\",\n                    len(baseline_names),\n                    archive_id,\n                )\n\n            # Derive base_name for name-matching fallback\n            base_name = Path(archive.filename).stem if archive.filename else \"\"\n            if base_name.endswith(\".gcode\"):\n                base_name = base_name[:-6]\n\n    except Exception as e:\n        logger.warning(\"[TIMELAPSE] Failed to take baseline snapshot for archive %s: %s\", archive_id, e)\n        return\n\n    # --- Phase 2: Retry loop — look for NEW files that weren't in baseline ---\n    retry_delays = [5, 10, 20, 30]\n\n    for attempt, delay in enumerate(retry_delays, 1):\n        logger.info(\n            \"[TIMELAPSE] Attempt %s/%s: waiting %ss before scanning for archive %s\",\n            attempt,\n            len(retry_delays),\n            delay,\n            archive_id,\n        )\n        await asyncio.sleep(delay)\n\n        try:\n            async with async_session() as db:\n                from backend.app.models.printer import Printer\n                from backend.app.services.bambu_ftp import download_file_bytes_async\n\n                service = ArchiveService(db)\n                archive = await service.get_archive(archive_id)\n\n                if not archive:\n                    logger.warning(\"[TIMELAPSE] Archive %s not found, stopping retries\", archive_id)\n                    return\n                if archive.timelapse_path:\n                    logger.info(\"[TIMELAPSE] Archive %s already has timelapse attached, stopping retries\", archive_id)\n                    return\n\n                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))\n                printer = result.scalar_one_or_none()\n                if not printer:\n                    logger.warning(\"[TIMELAPSE] Printer not found for archive %s, stopping retries\", archive_id)\n                    return\n\n                video_files, found_path = await _list_timelapse_videos(printer)\n\n                if not video_files:\n                    logger.info(\"[TIMELAPSE] Attempt %s: No video files found, will retry\", attempt)\n                    continue\n\n                logger.info(\"[TIMELAPSE] Attempt %s: Found %s video files in %s\", attempt, len(video_files), found_path)\n                for f in video_files[:5]:\n                    logger.info(\"[TIMELAPSE]   - %s\", f.get(\"name\"))\n\n                # Find files that are NEW (not in baseline snapshot)\n                new_files = [f for f in video_files if f.get(\"name\", \"\") not in baseline_names]\n\n                if new_files:\n                    # Pick the first new file (there should typically be exactly one)\n                    target = new_files[0]\n                    file_name = target.get(\"name\")\n                    remote_path = target.get(\"path\") or f\"/timelapse/{file_name}\"\n                    logger.info(\n                        \"[TIMELAPSE] Attempt %s: New file detected: %s (downloading for archive %s)\",\n                        attempt,\n                        file_name,\n                        archive_id,\n                    )\n\n                    timelapse_data = await download_file_bytes_async(\n                        printer.ip_address, printer.access_code, remote_path, printer_model=printer.model\n                    )\n                    if timelapse_data:\n                        success = await service.attach_timelapse(archive_id, timelapse_data, file_name)\n                        if success:\n                            logger.info(\"[TIMELAPSE] Successfully attached timelapse to archive %s\", archive_id)\n                            await ws_manager.send_archive_updated({\"id\": archive_id, \"timelapse_attached\": True})\n                            return\n                        else:\n                            logger.warning(\"[TIMELAPSE] Failed to attach timelapse to archive %s\", archive_id)\n                    else:\n                        logger.warning(\"[TIMELAPSE] Attempt %s: Failed to download new file, will retry\", attempt)\n                else:\n                    logger.info(\"[TIMELAPSE] Attempt %s: No new files since baseline, will retry\", attempt)\n\n        except Exception as e:\n            logger.warning(\"[TIMELAPSE] Attempt %s failed with error: %s\", attempt, e)\n\n    # --- Phase 3: Fallback — try name matching against all files ---\n    if base_name:\n        logger.info(\"[TIMELAPSE] Retries exhausted, trying name-match fallback for '%s'\", base_name)\n        try:\n            async with async_session() as db:\n                from backend.app.models.printer import Printer\n                from backend.app.services.bambu_ftp import download_file_bytes_async\n\n                service = ArchiveService(db)\n                archive = await service.get_archive(archive_id)\n                if not archive or archive.timelapse_path:\n                    return\n\n                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))\n                printer = result.scalar_one_or_none()\n                if not printer:\n                    return\n\n                video_files, found_path = await _list_timelapse_videos(printer)\n                for f in video_files:\n                    fname = f.get(\"name\", \"\")\n                    if base_name.lower() in fname.lower():\n                        remote_path = f.get(\"path\") or f\"/timelapse/{fname}\"\n                        logger.info(\"[TIMELAPSE] Name-match fallback: '%s' matches '%s'\", base_name, fname)\n\n                        timelapse_data = await download_file_bytes_async(\n                            printer.ip_address, printer.access_code, remote_path, printer_model=printer.model\n                        )\n                        if timelapse_data:\n                            success = await service.attach_timelapse(archive_id, timelapse_data, fname)\n                            if success:\n                                logger.info(\n                                    \"[TIMELAPSE] Name-match fallback attached timelapse to archive %s\", archive_id\n                                )\n                                await ws_manager.send_archive_updated({\"id\": archive_id, \"timelapse_attached\": True})\n                                return\n                        break  # Only try the first name match\n\n        except Exception as e:\n            logger.warning(\"[TIMELAPSE] Name-match fallback failed: %s\", e)\n\n    logger.warning(\"[TIMELAPSE] All attempts exhausted for archive %s, giving up\", archive_id)\n\n\nasync def on_print_complete(printer_id: int, data: dict):\n    \"\"\"Handle print completion - update the archive status.\"\"\"\n    import time\n\n    logger = logging.getLogger(__name__)\n    start_time = time.time()\n\n    def log_timing(section: str):\n        elapsed = time.time() - start_time\n        logger.info(\"[TIMING] %s: %.3fs elapsed\", section, elapsed)\n\n    logger.info(\"[CALLBACK] on_print_complete started for printer %s\", printer_id)\n\n    # Drop the 3MF download cache for this printer (#972). The print is over,\n    # nothing else legitimately needs the bytes; keeping them would only risk\n    # handing a stale file to the next print if it reuses the same name.\n    clear_3mf_cache(printer_id)\n\n    try:\n        ws_data = {\n            \"status\": data.get(\"status\"),\n            \"filename\": data.get(\"filename\"),\n            \"subtask_name\": data.get(\"subtask_name\"),\n            \"timelapse_was_active\": data.get(\"timelapse_was_active\"),\n        }\n        await ws_manager.send_print_complete(printer_id, ws_data)\n        log_timing(\"WebSocket send_print_complete\")\n    except Exception as e:\n        logger.warning(\"[CALLBACK] WebSocket send_print_complete failed: %s\", e)\n\n    # Capture user info before clearing (needed for print log entry)\n    _print_user_info = printer_manager.get_current_print_user(printer_id)\n\n    # Clear current print user tracking (Issue #206)\n    printer_manager.clear_current_print_user(printer_id)\n\n    # If the user explicitly stopped this print from the queue UI the printer will\n    # report \"failed\" or \"aborted\" via MQTT.  Override that to \"cancelled\" so the\n    # correct \"print stopped\" notification/email is sent instead of a failure alert.\n    _raw_status = data.get(\"status\", \"completed\")\n    if printer_id in _user_stopped_printers and _raw_status in (\"failed\", \"aborted\"):\n        logger.info(\n            \"[CALLBACK] Overriding status '%s' -> 'cancelled' for printer %s (print was stopped from queue by user)\",\n            _raw_status,\n            printer_id,\n        )\n        data = {**data, \"status\": \"cancelled\"}\n    _user_stopped_printers.discard(printer_id)\n\n    # Raise the plate-clear gate for queued dispatch (#961). Only for completed/failed —\n    # user-cancelled prints don't require a plate-clear ack (nothing printed on the bed).\n    # Persisted to DB so the gate survives Auto Off power cycles and Bambuddy restarts.\n    _final_status = data.get(\"status\", \"completed\")\n    if _final_status in (\"completed\", \"failed\"):\n        printer_manager.set_awaiting_plate_clear(printer_id, True)\n\n    # MQTT relay - publish print complete\n    try:\n        printer_info = printer_manager.get_printer(printer_id)\n        if printer_info:\n            await mqtt_relay.on_print_complete(\n                printer_id,\n                printer_info.name,\n                printer_info.serial_number,\n                data.get(\"filename\", \"\"),\n                data.get(\"subtask_name\", \"\"),\n                data.get(\"status\", \"completed\"),\n            )\n    except Exception:\n        pass  # Don't fail print complete callback if MQTT fails\n\n    filename = data.get(\"filename\", \"\")\n    subtask_name = data.get(\"subtask_name\", \"\")\n\n    if not filename and not subtask_name:\n        logger.warning(\"Print complete without filename or subtask_name\")\n        return\n\n    logger.info(\"Print complete - filename: %s, subtask: %s, status: %s\", filename, subtask_name, data.get(\"status\"))\n\n    # Build list of possible keys to try (matching how they were registered in on_print_start)\n    possible_keys = []\n\n    # Try subtask_name variations first (most reliable for matching)\n    if subtask_name:\n        possible_keys.append((printer_id, f\"{subtask_name}.3mf\"))\n        possible_keys.append((printer_id, f\"{subtask_name}.gcode.3mf\"))\n        possible_keys.append((printer_id, subtask_name))\n\n    # Try filename variations\n    if filename:\n        # Extract just the filename if it's a path\n        fname = filename.split(\"/\")[-1] if \"/\" in filename else filename\n\n        if fname.endswith(\".3mf\"):\n            possible_keys.append((printer_id, fname))\n        elif fname.endswith(\".gcode\"):\n            base_name = fname.rsplit(\".\", 1)[0]\n            possible_keys.append((printer_id, f\"{base_name}.gcode.3mf\"))\n            possible_keys.append((printer_id, f\"{base_name}.3mf\"))\n            possible_keys.append((printer_id, fname))\n        else:\n            possible_keys.append((printer_id, f\"{fname}.gcode.3mf\"))\n            possible_keys.append((printer_id, f\"{fname}.3mf\"))\n            possible_keys.append((printer_id, fname))\n\n        # Also try full path versions\n        if filename.endswith(\".3mf\"):\n            possible_keys.append((printer_id, filename))\n        elif filename.endswith(\".gcode\"):\n            base_name = filename.rsplit(\".\", 1)[0]\n            possible_keys.append((printer_id, f\"{base_name}.3mf\"))\n            possible_keys.append((printer_id, filename))\n        else:\n            possible_keys.append((printer_id, f\"{filename}.3mf\"))\n            possible_keys.append((printer_id, filename))\n\n    # Find the archive for this print\n    logger.info(\"Looking for archive in _active_prints, keys to try: %s...\", possible_keys[:5])\n    logger.info(\"Current _active_prints: %s\", list(_active_prints.keys()))\n    archive_id = None\n    for key in possible_keys:\n        archive_id = _active_prints.pop(key, None)\n        if archive_id:\n            logger.info(\"Found archive %s with key %s\", archive_id, key)\n            # Also clean up any other keys pointing to this archive\n            keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id]\n            for k in keys_to_remove:\n                _active_prints.pop(k, None)\n            break\n\n    if not archive_id:\n        # Try to find by filename or subtask_name if not tracked (for prints started before app)\n        async with async_session() as db:\n            from backend.app.models.archive import PrintArchive\n\n            # Try matching by subtask_name (stored as print_name) first\n            if subtask_name:\n                result = await db.execute(\n                    select(PrintArchive)\n                    .where(PrintArchive.printer_id == printer_id)\n                    .where(PrintArchive.status == \"printing\")\n                    .where(\n                        or_(\n                            PrintArchive.print_name.ilike(f\"%{subtask_name}%\"),\n                            PrintArchive.filename.ilike(f\"%{subtask_name}%\"),\n                        )\n                    )\n                    .order_by(PrintArchive.created_at.desc())\n                    .limit(1)\n                )\n                archive = result.scalar_one_or_none()\n                if archive:\n                    archive_id = archive.id\n                    logger.info(\"Found archive %s by subtask_name match: %s\", archive_id, subtask_name)\n\n            # Also try by filename\n            if not archive_id and filename:\n                result = await db.execute(\n                    select(PrintArchive)\n                    .where(PrintArchive.printer_id == printer_id)\n                    .where(PrintArchive.filename == filename)\n                    .where(PrintArchive.status == \"printing\")\n                    .order_by(PrintArchive.created_at.desc())\n                    .limit(1)\n                )\n                archive = result.scalar_one_or_none()\n                if archive:\n                    archive_id = archive.id\n\n    # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374)\n    # The print scheduler uploads files to the SD card root (/). Some printers (e.g. P1S)\n    # auto-start files found in root on power cycle, causing ghost prints.\n    # Must run before the archive_id early-return so it executes even when archiving is disabled.\n    try:\n        if subtask_name:\n            async with async_session() as db:\n                from backend.app.models.printer import Printer\n\n                result = await db.execute(select(Printer).where(Printer.id == printer_id))\n                printer = result.scalar_one_or_none()\n\n            if printer:\n                from backend.app.services.bambu_ftp import delete_file_async\n\n                # Try both .3mf and .gcode extensions — the printer may have either\n                for ext in (\".3mf\", \".gcode\"):\n                    remote_path = f\"/{subtask_name}{ext}\"\n                    # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends\n                    for attempt in range(1, 4):\n                        try:\n                            delete_result = await delete_file_async(\n                                printer.ip_address,\n                                printer.access_code,\n                                remote_path,\n                                printer_model=printer.model,\n                            )\n                            if delete_result:\n                                logger.info(\"Deleted %s from printer %s SD card\", remote_path, printer.name)\n                                break\n                        except Exception as e:\n                            delete_result = False\n                            logger.warning(\n                                \"SD card cleanup attempt %d/3 raised for %s: %s\",\n                                attempt,\n                                remote_path,\n                                e,\n                            )\n                        if not delete_result and attempt < 3:\n                            await asyncio.sleep(2)\n                        elif not delete_result:\n                            logger.warning(\n                                \"SD card cleanup failed after 3 attempts for %s (file may linger on SD card)\",\n                                remote_path,\n                            )\n    except Exception as e:\n        logger.warning(\"SD card file cleanup failed for printer %s: %s\", printer_id, e)\n\n    log_timing(\"SD card cleanup\")\n\n    # Update queue item status early — must run before the archive_id early-return\n    # so queue items don't get stuck in \"printing\" when archive lookup fails.\n    # Uses run_with_retry to handle SQLite \"database is locked\" errors (#897).\n    queue_item_id = None\n    queue_status = None\n    queue_auto_off = False\n    try:\n        from backend.app.core.database import run_with_retry\n        from backend.app.models.print_queue import PrintQueueItem\n\n        async def _update_queue_status(db):\n            nonlocal queue_item_id, queue_status, queue_auto_off\n            result = await db.execute(\n                select(PrintQueueItem)\n                .where(PrintQueueItem.printer_id == printer_id)\n                .where(PrintQueueItem.status == \"printing\")\n            )\n            printing_items = list(result.scalars().all())\n            if len(printing_items) > 1:\n                logger.warning(\n                    \"BUG: Multiple queue items in 'printing' status for printer %s: %s\",\n                    printer_id,\n                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],\n                )\n            item = printing_items[0] if printing_items else None\n            if item:\n                queue_status = data.get(\"status\", \"completed\")\n                # MQTT sends \"aborted\" for cancelled prints; normalise to\n                # \"cancelled\" so it matches the queue schema Literal.\n                if queue_status == \"aborted\":\n                    queue_status = \"cancelled\"\n                item.status = queue_status\n                item.completed_at = datetime.now(timezone.utc)\n\n                # Bump usage counters on the source library file so admins can\n                # sort by \"last printed\" and (eventually) auto-purge stale\n                # files — #1008.\n                await _bump_library_file_usage_if_completed(db, item, queue_status)\n\n                await db.commit()\n                queue_item_id = item.id\n                queue_auto_off = item.auto_off_after\n                logger.info(\"Updated queue item %s status to %s\", item.id, queue_status)\n\n        await run_with_retry(_update_queue_status, label=\"queue status update\")\n\n        # Post-commit side effects (notifications, MQTT relay, auto-off) use\n        # their own sessions and have their own error handling — no retry needed.\n        if queue_item_id is not None:\n            # MQTT relay - publish queue job completed\n            try:\n                printer_info = printer_manager.get_printer(printer_id)\n                await mqtt_relay.on_queue_job_completed(\n                    job_id=queue_item_id,\n                    filename=filename or subtask_name,\n                    printer_id=printer_id,\n                    printer_name=printer_info.name if printer_info else \"Unknown\",\n                    status=queue_status,\n                )\n            except Exception:\n                pass  # Don't fail if MQTT fails\n\n            # Check if queue is now empty and send notification\n            try:\n                from sqlalchemy import func as sa_func\n\n                async with async_session() as db:\n                    count_result = await db.execute(\n                        select(sa_func.count(PrintQueueItem.id)).where(PrintQueueItem.status == \"pending\")\n                    )\n                    pending_count = count_result.scalar() or 0\n\n                    if pending_count == 0:\n                        today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)\n                        completed_result = await db.execute(\n                            select(sa_func.count(PrintQueueItem.id)).where(\n                                PrintQueueItem.status.in_([\"completed\", \"failed\", \"skipped\"]),\n                                PrintQueueItem.completed_at >= today_start,\n                            )\n                        )\n                        completed_count = completed_result.scalar() or 1\n\n                        await notification_service.on_queue_completed(\n                            completed_count=completed_count,\n                            db=db,\n                        )\n            except Exception:\n                pass  # Don't fail if notification fails\n\n            # Handle auto_off_after - power off printer if requested (after cooldown)\n            if queue_auto_off:\n                async with async_session() as db:\n                    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n                    plugs = list(result.scalars().all())\n                enabled_plugs = [p for p in plugs if p.enabled]\n                if enabled_plugs:\n                    logger.info(\"Auto-off requested for printer %s, waiting for cooldown...\", printer_id)\n\n                    async def cooldown_and_poweroff(pid: int, plug_ids: list[int]):\n                        # Wait for nozzle to cool down\n                        await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600)\n                        # Re-fetch plugs in new session and turn off each one\n                        async with async_session() as new_db:\n                            for plug_id in plug_ids:\n                                try:\n                                    result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n                                    p = result.scalar_one_or_none()\n                                    if p and p.enabled:\n                                        service = await smart_plug_manager.get_service_for_plug(p, new_db)\n                                        success = await service.turn_off(p)\n                                        if success:\n                                            logger.info(\"Powered off printer %s via smart plug '%s'\", pid, p.name)\n                                        else:\n                                            logger.warning(\"Failed to power off plug '%s' for printer %s\", p.name, pid)\n                                except Exception as e:\n                                    logger.warning(\"Failed to power off plug %s for printer %s: %s\", plug_id, pid, e)\n\n                    asyncio.create_task(cooldown_and_poweroff(printer_id, [p.id for p in enabled_plugs]))\n    except Exception as e:\n        logging.getLogger(__name__).warning(f\"Queue item update failed: {e}\")\n\n    log_timing(\"Queue item update\")\n\n    # Register bed cooldown waiter (event-driven via on_bed_temp_update callback).\n    # Must run before archive_id early-return so it fires for all prints (including\n    # prints started from BambuStudio/touchscreen that have no archive).\n    if data.get(\"status\") == \"completed\":\n        try:\n            from backend.app.api.routes.settings import get_setting\n\n            async with async_session() as db:\n                threshold_str = await get_setting(db, \"bed_cooled_threshold\")\n            threshold = float(threshold_str) if threshold_str else 35.0\n\n            # Check if any provider has on_bed_cooled enabled (skip registration if none)\n            async with async_session() as db:\n                providers = await notification_service._get_providers_for_event(db, \"on_bed_cooled\", printer_id)\n            if providers:\n                _bed_cool_waiters[printer_id] = {\n                    \"threshold\": threshold,\n                    \"filename\": filename or subtask_name or \"\",\n                    \"registered_at\": time.time(),\n                }\n                logger.info(\n                    \"[BED-COOL] Registered waiter for printer %s (threshold: %.0f°C)\",\n                    printer_id,\n                    threshold,\n                )\n            else:\n                logger.debug(\"[BED-COOL] No providers enabled for bed_cooled on printer %s\", printer_id)\n        except Exception as e:\n            logger.warning(\"[BED-COOL] Failed to register waiter: %s\", e)\n\n    # --- Track filament consumption (must run before archive_id early-return so usage\n    # is recorded even when auto-archive is disabled) ---\n    usage_results: list[dict] = []\n    # Prefer ams_mapping captured from MQTT request topic (works for all print sources)\n    stored_ams_mapping = data.get(\"ams_mapping\")\n    # Fallback to _print_ams_mappings for queue/reprint (set before print starts)\n    if not stored_ams_mapping and archive_id:\n        stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)\n\n    # Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)\n    try:\n        async with async_session() as db:\n            from backend.app.api.routes.settings import get_setting\n\n            _spoolman_on = await get_setting(db, \"spoolman_enabled\")\n        if not _spoolman_on or _spoolman_on.lower() != \"true\":\n            from backend.app.services.usage_tracker import on_print_complete as usage_on_print_complete\n\n            async with async_session() as db:\n                usage_results = await usage_on_print_complete(\n                    printer_id,\n                    data,\n                    printer_manager,\n                    db,\n                    archive_id=archive_id,\n                    ams_mapping=stored_ams_mapping,\n                )\n                if usage_results:\n                    await ws_manager.broadcast(\n                        {\n                            \"type\": \"spool_usage_logged\",\n                            \"printer_id\": printer_id,\n                            \"usage\": usage_results,\n                        }\n                    )\n                    log_timing(\"Usage tracker\")\n\n    except Exception as e:\n        logger.warning(\"Usage tracker on_print_complete failed: %s\", e)\n\n    # Spoolman: report filament usage (requires archive_id for tracking data lookup)\n    if archive_id:\n        if data.get(\"status\") == \"completed\":\n            try:\n                await _report_spoolman_usage(printer_id, archive_id)\n                log_timing(\"Spoolman usage report\")\n            except Exception as e:\n                logger.warning(\"Spoolman usage reporting failed: %s\", e)\n        else:\n            # Report partial usage if tracking data exists (only stored when weight sync is disabled)\n            try:\n                async with async_session() as db:\n                    await _cleanup_spoolman_tracking(\n                        printer_id,\n                        archive_id,\n                        db,\n                        last_layer_num=data.get(\"last_layer_num\"),\n                        last_progress=data.get(\"last_progress\"),\n                    )\n            except Exception as e:\n                logger.debug(\"[SPOOLMAN] Cleanup failed: %s\", e)\n\n    log_timing(\"Filament usage tracking\")\n\n    if not archive_id:\n        logger.warning(\"Could not find archive for print complete: filename=%s, subtask=%s\", filename, subtask_name)\n\n        # Still send print-complete/failed/stopped notifications even without an archive.\n        # Try to enrich with queue/library-file data so user-specific emails work too.\n        async def _notify_no_archive():\n            try:\n                async with async_session() as db:\n                    from backend.app.models.library import LibraryFile\n                    from backend.app.models.print_queue import PrintQueueItem\n                    from backend.app.models.printer import Printer\n\n                    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n                    printer_obj = result.scalar_one_or_none()\n                    p_name = printer_obj.name if printer_obj else f\"Printer {printer_id}\"\n\n                    # Try to find the most-recent queue item for this printer so we can\n                    # recover created_by_id and estimated print time.\n                    # NOTE: By the time this task runs the queue item status has already\n                    # been updated to a terminal state (completed/failed/cancelled), so\n                    # we look for recently-completed items (within the last 5 minutes).\n                    no_archive_data: dict | None = None\n                    try:\n                        cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)\n                        q_result = await db.execute(\n                            select(PrintQueueItem)\n                            .where(PrintQueueItem.printer_id == printer_id)\n                            .where(PrintQueueItem.status.in_([\"completed\", \"failed\", \"cancelled\"]))\n                            .where(PrintQueueItem.completed_at >= cutoff)\n                            .order_by(PrintQueueItem.completed_at.desc())\n                            .limit(1)\n                        )\n                        queue_item = q_result.scalar_one_or_none()\n                        if queue_item:\n                            no_archive_data = {\"created_by_id\": queue_item.created_by_id}\n                            # Pull estimated time from library file when available\n                            if queue_item.library_file_id:\n                                lib_result = await db.execute(\n                                    select(LibraryFile).where(LibraryFile.id == queue_item.library_file_id)\n                                )\n                                lib_file = lib_result.scalar_one_or_none()\n                                if lib_file and lib_file.print_time_seconds:\n                                    no_archive_data[\"print_time_seconds\"] = lib_file.print_time_seconds\n                    except Exception as lookup_err:\n                        logger.debug(\n                            \"[NOTIFY-BG] Could not look up queue item for no-archive notification: %s\", lookup_err\n                        )\n\n                    # Enrich with usage tracker results (captured in enclosing scope)\n                    if usage_results:\n                        if no_archive_data is None:\n                            no_archive_data = {}\n                        total_from_usage = sum(r.get(\"weight_used\", 0) for r in usage_results)\n                        if total_from_usage > 0:\n                            no_archive_data[\"actual_filament_grams\"] = round(total_from_usage, 1)\n                        no_archive_data[\"usage_results\"] = usage_results\n\n                    # Try MQTT remaining_time for print duration when no queue/library data\n                    if no_archive_data and not no_archive_data.get(\"print_time_seconds\"):\n                        mqtt_remaining = data.get(\"remaining_time\")\n                        if mqtt_remaining and isinstance(mqtt_remaining, (int, float)) and mqtt_remaining > 0:\n                            no_archive_data[\"print_time_seconds\"] = int(mqtt_remaining)\n\n                    ps = data.get(\"status\", \"completed\")\n                    logger.info(\n                        \"[NOTIFY-BG] Sending notification without archive: printer=%s, status=%s\", printer_id, ps\n                    )\n                    await notification_service.on_print_complete(\n                        printer_id, p_name, ps, data, db, archive_data=no_archive_data\n                    )\n\n                    # Send user-specific email if we have a created_by_id\n                    if no_archive_data and no_archive_data.get(\"created_by_id\"):\n                        raw_filename = data.get(\"subtask_name\") or data.get(\"filename\", \"Unknown\")\n                        await _dispatch_user_print_email(\n                            ps,\n                            no_archive_data[\"created_by_id\"],\n                            p_name,\n                            raw_filename,\n                            db,\n                        )\n                    logger.info(\"[NOTIFY-BG] Completed (no-archive path)\")\n            except Exception as e:\n                logger.warning(\"[NOTIFY-BG] Failed to send notification without archive: %s\", e, exc_info=True)\n\n        task = asyncio.create_task(_notify_no_archive())\n        task.add_done_callback(lambda _t: None)\n        return\n\n    log_timing(\"Archive lookup\")\n\n    # Update archive status\n    logger.info(\"[ARCHIVE] Updating archive %s status...\", archive_id)\n    try:\n        async with async_session() as db:\n            service = ArchiveService(db)\n            status = data.get(\"status\", \"completed\")\n\n            # Auto-detect failure reason\n            failure_reason = None\n            if status == \"aborted\":\n                failure_reason = \"User cancelled\"\n                logger.info(\"[ARCHIVE] Print was aborted by user, setting failure_reason='User cancelled'\")\n            elif status == \"failed\":\n                # Try to determine failure reason from HMS errors\n                hms_errors = data.get(\"hms_errors\", [])\n                if hms_errors:\n                    logger.info(\"[ARCHIVE] HMS errors at failure: %s\", hms_errors)\n                    # Map known HMS error modules to failure reasons\n                    # Module 0x07 = Filament, 0x0C = MC (Motion Controller), etc.\n                    for err in hms_errors:\n                        module = err.get(\"module\", 0)\n                        if module == 0x07:  # Filament module\n                            failure_reason = \"Filament runout\"\n                            break\n                        elif module == 0x0C:  # Motion controller\n                            failure_reason = \"Layer shift\"\n                            break\n                        elif module == 0x05:  # Nozzle/extruder\n                            failure_reason = \"Clogged nozzle\"\n                            break\n                    if failure_reason:\n                        logger.info(\"[ARCHIVE] Detected failure_reason from HMS: %s\", failure_reason)\n                else:\n                    logger.info(\"[ARCHIVE] No HMS errors available to determine failure reason\")\n\n            await service.update_archive_status(\n                archive_id,\n                status=status,\n                completed_at=datetime.now(timezone.utc) if status in (\"completed\", \"failed\", \"aborted\") else None,\n                failure_reason=failure_reason,\n            )\n            logger.info(\n                \"[ARCHIVE] Archive %s status updated to %s, failure_reason=%s\", archive_id, status, failure_reason\n            )\n\n            await ws_manager.send_archive_updated(\n                {\n                    \"id\": archive_id,\n                    \"status\": status,\n                }\n            )\n            logger.info(\"[ARCHIVE] WebSocket notification sent for archive %s\", archive_id)\n\n            # MQTT relay - publish archive updated\n            try:\n                await mqtt_relay.on_archive_updated(\n                    archive_id=archive_id,\n                    print_name=filename or subtask_name,\n                    status=status,\n                )\n            except Exception:\n                pass  # Don't fail if MQTT fails\n    except Exception as e:\n        logger.error(\"[ARCHIVE] Failed to update archive %s status: %s\", archive_id, e, exc_info=True)\n        # Continue with other operations even if archive update fails\n\n    log_timing(\"Archive status update\")\n\n    # Write independent print log entry (separate table, never touches archives)\n    try:\n        async with async_session() as db:\n            from backend.app.models.archive import PrintArchive\n            from backend.app.services.print_log import write_log_entry\n\n            archive = await db.get(PrintArchive, archive_id)\n            if archive:\n                p_info = printer_manager.get_printer(printer_id)\n                await write_log_entry(\n                    db,\n                    status=data.get(\"status\", \"completed\"),\n                    print_name=archive.print_name,\n                    printer_name=p_info.name if p_info else None,\n                    printer_id=printer_id,\n                    started_at=archive.started_at,\n                    completed_at=archive.completed_at,\n                    filament_type=archive.filament_type,\n                    filament_color=archive.filament_color,\n                    filament_used_grams=archive.filament_used_grams,\n                    thumbnail_path=archive.thumbnail_path,\n                    created_by_username=_print_user_info.get(\"username\") if _print_user_info else None,\n                )\n                await db.commit()\n                logger.info(\"[PRINT_LOG] Log entry written for archive %s\", archive_id)\n    except Exception as e:\n        logger.warning(\"[PRINT_LOG] Failed to write log entry for archive %s: %s\", archive_id, e)\n\n    log_timing(\"Print log entry\")\n\n    # Run slow operations as background tasks to avoid blocking the event loop\n    # These operations can take 5-10+ seconds and would freeze the UI if awaited\n\n    async def _background_energy_calculation():\n        \"\"\"Calculate and save energy usage in background.\n\n        Reads the starting kWh from the archive row (#941: persisted so a mid-print\n        backend restart no longer loses per-print energy data).\n        \"\"\"\n        try:\n            logger.info(\"[ENERGY-BG] Starting energy calculation for archive %s\", archive_id)\n            async with async_session() as db:\n                from backend.app.models.archive import PrintArchive\n\n                archive = await db.get(PrintArchive, archive_id)\n                if archive is None:\n                    logger.warning(\"[ENERGY-BG] Archive %s no longer exists\", archive_id)\n                    return\n                starting_kwh = archive.energy_start_kwh\n                if starting_kwh is None:\n                    logger.info(\"[ENERGY-BG] No start kWh recorded for archive %s\", archive_id)\n                    return\n\n                plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n                plug = plug_result.scalar_one_or_none()\n                if plug is None:\n                    logger.info(\"[ENERGY-BG] No smart plug for printer %s\", printer_id)\n                    return\n\n                energy = await _get_plug_energy(plug, db)\n                logger.info(\"[ENERGY-BG] Energy response: %s\", energy)\n                if not energy or energy.get(\"total\") is None:\n                    logger.warning(\"[ENERGY-BG] No 'total' in energy response\")\n                    return\n\n                energy_used = round(energy[\"total\"] - starting_kwh, 4)\n                logger.info(\"[ENERGY-BG] Per-print energy: %s kWh\", energy_used)\n                if energy_used < 0:\n                    logger.warning(\n                        \"[ENERGY-BG] Negative energy delta for archive %s (start=%s, end=%s) — counter reset?\",\n                        archive_id,\n                        starting_kwh,\n                        energy[\"total\"],\n                    )\n                    return\n\n                from backend.app.api.routes.settings import get_setting\n\n                energy_cost_per_kwh = await get_setting(db, \"energy_cost_per_kwh\")\n                cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15\n                archive.energy_kwh = energy_used\n                archive.energy_cost = round(energy_used * cost_per_kwh, 3)\n                await db.commit()\n                logger.info(\"[ENERGY-BG] Saved: %s kWh, cost=%s\", energy_used, archive.energy_cost)\n        except Exception as e:\n            logger.warning(\"[ENERGY-BG] Failed: %s\", e)\n\n    async def _background_finish_photo() -> str | None:\n        \"\"\"Capture finish photo in background. Returns photo filename if captured.\"\"\"\n        try:\n            logger.info(\"[PHOTO-BG] Starting finish photo capture for archive %s\", archive_id)\n\n            from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame\n\n            async with async_session() as db:\n                from backend.app.api.routes.settings import get_setting\n\n                capture_enabled = await get_setting(db, \"capture_finish_photo\")\n\n                if capture_enabled is None or capture_enabled.lower() == \"true\":\n                    from backend.app.models.printer import Printer\n\n                    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n                    printer = result.scalar_one_or_none()\n\n                    if printer and archive_id:\n                        from backend.app.models.archive import PrintArchive\n\n                        result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n                        archive = result.scalar_one_or_none()\n\n                        if archive:\n                            import uuid\n                            from datetime import datetime\n                            from pathlib import Path\n\n                            if archive.file_path:\n                                archive_dir = app_settings.base_dir / Path(archive.file_path).parent\n                            else:\n                                logger.warning(\"[PHOTO-BG] Archive %s has no file_path, using fallback dir\", archive_id)\n                                archive_dir = app_settings.archive_dir / str(archive.id)\n                            photo_filename = None\n\n                            # Check for external camera first\n                            if printer.external_camera_enabled and printer.external_camera_url:\n                                logger.info(\"[PHOTO-BG] Using external camera\")\n                                from backend.app.services.external_camera import capture_frame\n\n                                frame_data = await capture_frame(\n                                    printer.external_camera_url, printer.external_camera_type or \"mjpeg\"\n                                )\n                                if frame_data:\n                                    photos_dir = archive_dir / \"photos\"\n                                    photos_dir.mkdir(parents=True, exist_ok=True)\n                                    timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n                                    photo_filename = f\"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg\"\n                                    photo_path = photos_dir / photo_filename\n                                    await asyncio.to_thread(photo_path.write_bytes, frame_data)\n                                    logger.info(\"[PHOTO-BG] Saved external camera frame: %s\", photo_filename)\n                            else:\n                                # Check if camera stream is active - use buffered frame to avoid freeze\n                                # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)\n                                active_for_printer = [k for k in _active_streams if k.startswith(f\"{printer_id}-\")]\n                                active_chamber_for_printer = [\n                                    k for k in _active_chamber_streams if k.startswith(f\"{printer_id}-\")\n                                ]\n                                buffered_frame = get_buffered_frame(printer_id)\n\n                                if (active_for_printer or active_chamber_for_printer) and buffered_frame:\n                                    # Use frame from active stream\n                                    logger.info(\"[PHOTO-BG] Using buffered frame from active stream\")\n                                    photos_dir = archive_dir / \"photos\"\n                                    photos_dir.mkdir(parents=True, exist_ok=True)\n                                    timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n                                    photo_filename = f\"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg\"\n                                    photo_path = photos_dir / photo_filename\n                                    await asyncio.to_thread(photo_path.write_bytes, buffered_frame)\n                                    logger.info(\"[PHOTO-BG] Saved buffered frame: %s\", photo_filename)\n                                else:\n                                    # No active stream - capture new frame\n                                    from backend.app.services.camera import capture_finish_photo\n\n                                    photo_filename = await capture_finish_photo(\n                                        printer_id=printer_id,\n                                        ip_address=printer.ip_address,\n                                        access_code=printer.access_code,\n                                        model=printer.model,\n                                        archive_dir=archive_dir,\n                                    )\n\n                            if photo_filename:\n                                photos = archive.photos or []\n                                photos.append(photo_filename)\n                                archive.photos = photos\n                                await db.commit()\n                                logger.info(\"[PHOTO-BG] Saved: %s\", photo_filename)\n                                return photo_filename\n            return None\n        except Exception as e:\n            logger.warning(\"[PHOTO-BG] Failed: %s\", e)\n            return None\n\n    asyncio.create_task(_background_energy_calculation())\n    # Photo capture task - result will be used by notifications\n    photo_task = asyncio.create_task(_background_finish_photo())\n    log_timing(\"Background tasks scheduled (energy, photo)\")\n\n    # Also run smart plug, notifications, and maintenance as background tasks\n    print_status = data.get(\"status\", \"completed\")\n\n    async def _background_smart_plug():\n        \"\"\"Handle smart plug automation in background.\"\"\"\n        try:\n            logger.info(\"[AUTO-OFF-BG] Starting smart plug automation for printer %s\", printer_id)\n            async with async_session() as db:\n                await smart_plug_manager.on_print_complete(printer_id, print_status, db)\n                logger.info(\"[AUTO-OFF-BG] Completed\")\n        except Exception as e:\n            logger.warning(\"[AUTO-OFF-BG] Failed: %s\", e)\n\n    async def _background_notifications(finish_photo_filename: str | None = None):\n        \"\"\"Send print complete notifications in background.\"\"\"\n        try:\n            logger.info(\n                \"[NOTIFY-BG] Starting notifications for printer %s, photo=%s\", printer_id, finish_photo_filename\n            )\n            async with async_session() as db:\n                from backend.app.models.archive import PrintArchive\n                from backend.app.models.printer import Printer\n\n                result = await db.execute(select(Printer).where(Printer.id == printer_id))\n                printer = result.scalar_one_or_none()\n                printer_name = printer.name if printer else f\"Printer {printer_id}\"\n\n                archive_data = None\n                if archive_id:\n                    archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n                    archive = archive_result.scalar_one_or_none()\n                    if archive:\n                        archive_data = {\n                            \"print_time_seconds\": archive.print_time_seconds,\n                            \"actual_filament_grams\": archive.filament_used_grams,\n                            \"failure_reason\": archive.failure_reason,\n                            \"created_by_id\": archive.created_by_id,\n                        }\n\n                        # Scale filament usage for partial prints\n                        if print_status != \"completed\" and archive.filament_used_grams:\n                            progress = data.get(\"progress\") or 0\n                            scale = max(0.0, min(progress / 100.0, 1.0))\n                            archive_data[\"actual_filament_grams\"] = round(archive.filament_used_grams * scale, 1)\n                            archive_data[\"progress\"] = progress\n\n                        # Pass per-slot data from archive.extra_data\n                        if archive.extra_data and archive.extra_data.get(\"filament_slots\"):\n                            slots = archive.extra_data[\"filament_slots\"]\n                            if print_status != \"completed\":\n                                scale = max(0.0, min((data.get(\"progress\") or 0) / 100.0, 1.0))\n                                slots = [{**s, \"used_g\": round(s[\"used_g\"] * scale, 1)} for s in slots]\n                            archive_data[\"filament_slots\"] = slots\n\n                        # Enrich filament_grams from usage_results when archive has no 3MF data\n                        if not archive_data.get(\"actual_filament_grams\") and usage_results:\n                            total_from_usage = sum(r.get(\"weight_used\", 0) for r in usage_results)\n                            if total_from_usage > 0:\n                                archive_data[\"actual_filament_grams\"] = round(total_from_usage, 1)\n\n                        # Pass usage tracker results for AMS slot info in notifications\n                        if usage_results:\n                            archive_data[\"usage_results\"] = usage_results\n                        # Add finish photo URL and image bytes if available\n                        if finish_photo_filename:\n                            from backend.app.api.routes.settings import get_setting\n\n                            external_url = await get_setting(db, \"external_url\")\n                            if external_url:\n                                external_url = external_url.rstrip(\"/\")\n                                archive_data[\"finish_photo_url\"] = (\n                                    f\"{external_url}/api/v1/archives/{archive_id}/photos/{finish_photo_filename}\"\n                                )\n                            else:\n                                # Fallback to relative URL (won't work for external services)\n                                archive_data[\"finish_photo_url\"] = (\n                                    f\"/api/v1/archives/{archive_id}/photos/{finish_photo_filename}\"\n                                )\n\n                            # Read finish photo bytes for image attachment (e.g. Pushover)\n                            try:\n                                from pathlib import Path\n\n                                photo_path = (\n                                    app_settings.base_dir\n                                    / Path(archive.file_path).parent\n                                    / \"photos\"\n                                    / finish_photo_filename\n                                )\n                                if photo_path.exists():\n                                    photo_bytes = await asyncio.to_thread(photo_path.read_bytes)\n                                    if len(photo_bytes) <= 2_500_000:\n                                        archive_data[\"image_data\"] = photo_bytes\n                                        logger.info(\"[NOTIFY-BG] Loaded finish photo bytes: %s bytes\", len(photo_bytes))\n                                    else:\n                                        logger.warning(\n                                            f\"[NOTIFY-BG] Finish photo too large for attachment: \"\n                                            f\"{len(photo_bytes)} bytes\"\n                                        )\n                            except Exception as e:\n                                logger.warning(\"[NOTIFY-BG] Failed to read finish photo bytes: %s\", e)\n\n                await notification_service.on_print_complete(\n                    printer_id, printer_name, print_status, data, db, archive_data=archive_data\n                )\n\n                # Send user-specific email notification\n                if archive_data:\n                    created_by_id = archive_data.get(\"created_by_id\")\n                    raw_filename = data.get(\"subtask_name\") or data.get(\"filename\", \"Unknown\")\n                    await _dispatch_user_print_email(\n                        print_status,\n                        created_by_id,\n                        printer_name,\n                        raw_filename,\n                        db,\n                    )\n\n                logger.info(\"[NOTIFY-BG] Completed\")\n        except Exception as e:\n            logger.error(\"[NOTIFY-BG] Failed: %s\", e, exc_info=True)\n\n    async def _background_maintenance_check():\n        \"\"\"Check for maintenance due in background.\"\"\"\n        if print_status != \"completed\":\n            return\n        try:\n            logger.info(\"[MAINT-BG] Starting maintenance check for printer %s\", printer_id)\n            async with async_session() as db:\n                from backend.app.models.printer import Printer\n\n                result = await db.execute(select(Printer).where(Printer.id == printer_id))\n                printer = result.scalar_one_or_none()\n                printer_name = printer.name if printer else f\"Printer {printer_id}\"\n\n                await ensure_default_types(db)\n                overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)\n\n                items_needing_attention = [\n                    {\"name\": item.maintenance_type_name, \"is_due\": item.is_due, \"is_warning\": item.is_warning}\n                    for item in overview.maintenance_items\n                    if item.enabled and (item.is_due or item.is_warning)\n                ]\n\n                if items_needing_attention:\n                    await notification_service.on_maintenance_due(printer_id, printer_name, items_needing_attention, db)\n                    logger.info(\"[MAINT-BG] Sent notification: %s items need attention\", len(items_needing_attention))\n\n                    # MQTT relay - publish maintenance alerts\n                    for item in items_needing_attention:\n                        try:\n                            await mqtt_relay.on_maintenance_alert(\n                                printer_id=printer_id,\n                                printer_name=printer_name,\n                                maintenance_type=item[\"name\"],\n                                current_value=0,  # Not easily available here\n                                threshold=0,  # Not easily available here\n                            )\n                        except Exception:\n                            pass  # Don't fail if MQTT fails\n                else:\n                    logger.info(\"[MAINT-BG] Completed (no items need attention)\")\n        except Exception as e:\n            logger.warning(\"[MAINT-BG] Failed: %s\", e)\n\n    asyncio.create_task(_background_smart_plug())\n    asyncio.create_task(_background_maintenance_check())\n\n    # Notification task waits for photo capture to complete first (with timeout)\n    async def _photo_then_notify():\n        \"\"\"Wait for photo capture, then send notification with photo URL.\"\"\"\n        finish_photo = None\n        try:\n            finish_photo = await asyncio.wait_for(photo_task, timeout=45)\n            logger.info(\"[PHOTO-NOTIFY] Photo task returned: %s\", finish_photo)\n        except TimeoutError:\n            logger.warning(\"[PHOTO-NOTIFY] Photo capture timed out after 45s, sending notification without photo\")\n        except Exception as e:\n            logger.warning(\"[PHOTO-NOTIFY] Photo task failed: %s\", e)\n        try:\n            await _background_notifications(finish_photo)\n        except Exception as e:\n            logger.error(\"[PHOTO-NOTIFY] Notification sending failed: %s\", e, exc_info=True)\n\n    asyncio.create_task(_photo_then_notify())\n\n    # Stitch external camera layer timelapse if session was active\n    print_status = data.get(\"status\", \"completed\")\n\n    async def _background_layer_timelapse():\n        \"\"\"Stitch layer timelapse and attach to archive.\"\"\"\n        from backend.app.services.layer_timelapse import cancel_session, on_print_complete as tl_complete\n\n        try:\n            if print_status == \"completed\":\n                logger.info(\"[LAYER-TL] Stitching layer timelapse for printer %s\", printer_id)\n                timelapse_path = await tl_complete(printer_id)\n                if timelapse_path and archive_id:\n                    logger.info(\"[LAYER-TL] Attaching timelapse %s to archive %s\", timelapse_path, archive_id)\n                    async with async_session() as db:\n                        service = ArchiveService(db)\n                        timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes)\n                        await service.attach_timelapse(archive_id, timelapse_data, \"layer_timelapse.mp4\")\n                        # Clean up the temp file\n                        await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)\n                        logger.info(\"[LAYER-TL] Layer timelapse attached successfully\")\n                elif timelapse_path:\n                    # Timelapse created but no archive - just clean up\n                    await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)\n            else:\n                # Print failed or cancelled - cancel timelapse session\n                cancel_session(printer_id)\n                logger.info(\n                    \"[LAYER-TL] Cancelled layer timelapse for printer %s (status: %s)\", printer_id, print_status\n                )\n        except Exception as e:\n            logger.warning(\"[LAYER-TL] Failed: %s\", e)\n            # Try to cancel session on error\n            try:\n                cancel_session(printer_id)\n            except Exception:\n                pass  # Best-effort timelapse session cancellation on error\n\n    asyncio.create_task(_background_layer_timelapse())\n\n    log_timing(\"All background tasks scheduled\")\n\n    # Auto-scan for timelapse if recording was active during the print\n    if archive_id and data.get(\"timelapse_was_active\") and data.get(\"status\") == \"completed\":\n        logger.info(\"[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s\", archive_id)\n        # Schedule timelapse scan as background task with retries\n        # The printer needs time to encode the video after print completion\n        baseline = _timelapse_baselines.pop(printer_id, None)\n        asyncio.create_task(_scan_for_timelapse_with_retries(archive_id, baseline))\n        log_timing(\"Timelapse scan scheduled\")\n\n    logger.info(\"[CALLBACK] on_print_complete finished for printer %s, archive %s\", printer_id, archive_id)\n\n\n# AMS sensor history recording\n_ams_history_task: asyncio.Task | None = None\nAMS_HISTORY_INTERVAL = 300  # Record every 5 minutes\nAMS_HISTORY_RETENTION_DAYS = 30  # Keep data for 30 days\n_ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup\n# Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)\n_ams_alarm_cooldown: dict[str, datetime] = {}\nAMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour\n\n\nasync def record_ams_history():\n    \"\"\"Background task to record AMS humidity and temperature data.\"\"\"\n    logger = logging.getLogger(__name__)\n\n    # Wait a short time for MQTT connections to establish on startup\n    await asyncio.sleep(10)\n\n    while True:\n        try:\n            from backend.app.models.ams_history import AMSSensorHistory\n            from backend.app.models.printer import Printer\n            from backend.app.models.settings import Settings\n\n            async with async_session() as db:\n                # Get all active printers\n                result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))\n                printers = result.scalars().all()\n\n                # Get alarm thresholds from settings\n                humidity_threshold = 60.0  # Default: fair threshold\n                temp_threshold = 35.0  # Default: fair threshold\n                result = await db.execute(select(Settings).where(Settings.key == \"ams_humidity_fair\"))\n                setting = result.scalar_one_or_none()\n                if setting:\n                    try:\n                        humidity_threshold = float(setting.value)\n                    except (ValueError, TypeError):\n                        pass  # Keep default threshold if stored value is invalid\n                result = await db.execute(select(Settings).where(Settings.key == \"ams_temp_fair\"))\n                setting = result.scalar_one_or_none()\n                if setting:\n                    try:\n                        temp_threshold = float(setting.value)\n                    except (ValueError, TypeError):\n                        pass  # Keep default threshold if stored value is invalid\n\n                recorded_count = 0\n                for printer in printers:\n                    # Get current state from printer manager\n                    state = printer_manager.get_status(printer.id)\n                    if not state or not state.connected or not state.raw_data:\n                        continue  # Skip disconnected printers - don't use stale data\n\n                    raw_data = state.raw_data\n                    if \"ams\" not in raw_data or not isinstance(raw_data[\"ams\"], list):\n                        continue\n\n                    # Record data for each AMS unit\n                    for ams_data in raw_data[\"ams\"]:\n                        ams_id = int(ams_data.get(\"id\", 0))\n\n                        # Get humidity (prefer humidity_raw)\n                        humidity_raw = ams_data.get(\"humidity_raw\")\n                        humidity_idx = ams_data.get(\"humidity\")\n                        humidity = None\n                        if humidity_raw is not None:\n                            try:\n                                humidity = float(humidity_raw)\n                            except (ValueError, TypeError):\n                                pass  # Skip unparseable humidity; will try fallback\n                        if humidity is None and humidity_idx is not None:\n                            try:\n                                humidity = float(humidity_idx)\n                            except (ValueError, TypeError):\n                                pass  # Skip unparseable humidity index value\n\n                        # Get temperature\n                        temperature = None\n                        temp_str = ams_data.get(\"temp\")\n                        if temp_str is not None:\n                            try:\n                                temperature = float(temp_str)\n                            except (ValueError, TypeError):\n                                pass  # Skip unparseable temperature value\n\n                        # Skip if no data\n                        if humidity is None and temperature is None:\n                            continue\n\n                        # Record the data point\n                        history = AMSSensorHistory(\n                            printer_id=printer.id,\n                            ams_id=ams_id,\n                            humidity=humidity,\n                            humidity_raw=float(humidity_raw) if humidity_raw else None,\n                            temperature=temperature,\n                        )\n                        db.add(history)\n                        recorded_count += 1\n\n                        # Generate AMS label and determine if it's AMS-HT (A, B, C, D or HT-A for AMS-Lite/Hub)\n                        is_ams_ht = ams_id >= 128\n                        if is_ams_ht:\n                            ams_label = f\"HT-{chr(65 + (ams_id - 128))}\"\n                        else:\n                            ams_label = f\"AMS-{chr(65 + ams_id)}\"\n\n                        # Check humidity alarm (only if above threshold)\n                        if humidity is not None and humidity > humidity_threshold:\n                            cooldown_key = f\"{printer.id}:{ams_id}:humidity\"\n                            last_alarm = _ams_alarm_cooldown.get(cooldown_key)\n                            now = datetime.now(timezone.utc)\n                            if (\n                                last_alarm is None\n                                or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60\n                            ):\n                                _ams_alarm_cooldown[cooldown_key] = now\n                                logger.info(\n                                    f\"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {humidity_threshold}%\"\n                                )\n                                try:\n                                    # Call different notification method based on AMS type\n                                    if is_ams_ht:\n                                        await notification_service.on_ams_ht_humidity_high(\n                                            printer.id, printer.name, ams_label, humidity, humidity_threshold, db\n                                        )\n                                    else:\n                                        await notification_service.on_ams_humidity_high(\n                                            printer.id, printer.name, ams_label, humidity, humidity_threshold, db\n                                        )\n                                except Exception as e:\n                                    logger.warning(\"Failed to send humidity alarm: %s\", e)\n\n                        # Check temperature alarm (only if above threshold)\n                        if temperature is not None and temperature > temp_threshold:\n                            cooldown_key = f\"{printer.id}:{ams_id}:temperature\"\n                            last_alarm = _ams_alarm_cooldown.get(cooldown_key)\n                            now = datetime.now(timezone.utc)\n                            if (\n                                last_alarm is None\n                                or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60\n                            ):\n                                _ams_alarm_cooldown[cooldown_key] = now\n                                logger.info(\n                                    f\"Sending temperature alarm for {printer.name} {ams_label}: {temperature}°C > {temp_threshold}°C\"\n                                )\n                                try:\n                                    # Call different notification method based on AMS type\n                                    if is_ams_ht:\n                                        await notification_service.on_ams_ht_temperature_high(\n                                            printer.id, printer.name, ams_label, temperature, temp_threshold, db\n                                        )\n                                    else:\n                                        await notification_service.on_ams_temperature_high(\n                                            printer.id, printer.name, ams_label, temperature, temp_threshold, db\n                                        )\n                                except Exception as e:\n                                    logger.warning(\"Failed to send temperature alarm: %s\", e)\n\n                await db.commit()\n                if recorded_count > 0:\n                    logger.info(\"Recorded %s AMS sensor history entries\", recorded_count)\n\n                # Periodic cleanup of old data (every ~288 recordings = ~24 hours at 5min interval)\n                global _ams_cleanup_counter\n                _ams_cleanup_counter += 1\n                if _ams_cleanup_counter >= 288:\n                    _ams_cleanup_counter = 0\n                    # Get retention days from settings\n                    from backend.app.models.settings import Settings\n\n                    result = await db.execute(select(Settings).where(Settings.key == \"ams_history_retention_days\"))\n                    setting = result.scalar_one_or_none()\n                    retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS\n\n                    cutoff = datetime.utcnow() - timedelta(days=retention_days)\n                    result = await db.execute(delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff))\n                    await db.commit()\n                    if result.rowcount > 0:\n                        logger.info(\n                            f\"Cleaned up {result.rowcount} old AMS sensor history entries (older than {retention_days} days)\"\n                        )\n\n            # Wait until next recording interval\n            await asyncio.sleep(AMS_HISTORY_INTERVAL)\n\n        except asyncio.CancelledError:\n            break\n        except Exception as e:\n            logger.warning(\"AMS history recording failed: %s\", e)\n            await asyncio.sleep(60)  # Wait a bit before retrying\n\n\ndef start_ams_history_recording():\n    \"\"\"Start the AMS history recording background task.\"\"\"\n    global _ams_history_task\n    if _ams_history_task is None:\n        _ams_history_task = asyncio.create_task(record_ams_history())\n        logging.getLogger(__name__).info(\"AMS history recording started\")\n\n\ndef stop_ams_history_recording():\n    \"\"\"Stop the AMS history recording background task.\"\"\"\n    global _ams_history_task\n    if _ams_history_task:\n        _ams_history_task.cancel()\n        _ams_history_task = None\n        logging.getLogger(__name__).info(\"AMS history recording stopped\")\n\n\n# Printer runtime tracking\n_runtime_tracking_task: asyncio.Task | None = None\nRUNTIME_TRACKING_INTERVAL = 30  # Update every 30 seconds\n\n\nasync def track_printer_runtime():\n    \"\"\"Background task to track printer active runtime (RUNNING/PAUSE states).\"\"\"\n    logger = logging.getLogger(__name__)\n\n    # Wait for MQTT connections to establish on startup\n    await asyncio.sleep(15)\n\n    while True:\n        try:\n            from backend.app.models.printer import Printer\n\n            # Fetch printer IDs in a short-lived read-only session\n            async with async_session() as db:\n                result = await db.execute(\n                    select(Printer.id, Printer.name, Printer.runtime_seconds, Printer.last_runtime_update).where(\n                        Printer.is_active.is_(True)\n                    )\n                )\n                printer_rows = result.all()\n\n            now = datetime.now(timezone.utc)\n            updated_count = 0\n\n            # Update each printer in its own short session to minimise write-lock\n            # hold time and avoid blocking critical commits like queue status\n            # updates (#897).\n            for pid, pname, runtime_secs, last_update in printer_rows:\n                state = printer_manager.get_status(pid)\n                if not state:\n                    logger.debug(\"[%s] Runtime tracking: no state available\", pname)\n                    continue\n                if not state.connected:\n                    logger.debug(\"[%s] Runtime tracking: not connected\", pname)\n                    continue\n\n                needs_commit = False\n                new_runtime = runtime_secs\n                new_last_update = last_update\n\n                if state.state in (\"RUNNING\", \"PAUSE\"):\n                    if last_update:\n                        lu = last_update if last_update.tzinfo else last_update.replace(tzinfo=timezone.utc)\n                        elapsed = (now - lu).total_seconds()\n                        if elapsed > 0:\n                            new_runtime = runtime_secs + int(elapsed)\n                            updated_count += 1\n                            needs_commit = True\n                            logger.debug(\n                                f\"[{pname}] Runtime tracking: added {int(elapsed)}s, \"\n                                f\"total={new_runtime}s ({new_runtime / 3600:.2f}h)\"\n                            )\n                    else:\n                        needs_commit = True\n                        logger.debug(\"[%s] Runtime tracking: first active detection\", pname)\n                    new_last_update = now\n                else:\n                    if last_update is not None:\n                        logger.debug(f\"[{pname}] Runtime tracking: state={state.state}, clearing last_runtime_update\")\n                        new_last_update = None\n                        needs_commit = True\n\n                if needs_commit:\n                    try:\n                        async with async_session() as db:\n                            result = await db.execute(select(Printer).where(Printer.id == pid))\n                            printer = result.scalar_one_or_none()\n                            if printer:\n                                printer.runtime_seconds = new_runtime\n                                printer.last_runtime_update = new_last_update\n                                await db.commit()\n                    except Exception as e:\n                        logger.warning(\"[%s] Runtime tracking commit failed: %s\", pname, e)\n\n            if updated_count > 0:\n                logger.debug(\"Updated runtime for %s printer(s)\", updated_count)\n\n        except asyncio.CancelledError:\n            logger.info(\"Runtime tracking cancelled\")\n            break\n        except Exception as e:\n            logger.warning(\"Runtime tracking failed: %s\", e)\n\n        await asyncio.sleep(RUNTIME_TRACKING_INTERVAL)\n\n\ndef start_runtime_tracking():\n    \"\"\"Start the printer runtime tracking background task.\"\"\"\n    global _runtime_tracking_task\n    if _runtime_tracking_task is None:\n        _runtime_tracking_task = asyncio.create_task(track_printer_runtime())\n        logging.getLogger(__name__).info(\"Printer runtime tracking started\")\n\n\ndef stop_runtime_tracking():\n    \"\"\"Stop the printer runtime tracking background task.\"\"\"\n    global _runtime_tracking_task\n    if _runtime_tracking_task:\n        _runtime_tracking_task.cancel()\n        _runtime_tracking_task = None\n        logging.getLogger(__name__).info(\"Printer runtime tracking stopped\")\n\n\n# SpoolBuddy device watchdog\n_spoolbuddy_watchdog_task: asyncio.Task | None = None\nSPOOLBUDDY_WATCHDOG_INTERVAL = 15\n\n\nasync def _spoolbuddy_watchdog_loop():\n    \"\"\"Periodic check for SpoolBuddy devices that have gone offline.\"\"\"\n    from backend.app.api.routes.spoolbuddy import spoolbuddy_watchdog\n\n    while True:\n        try:\n            await spoolbuddy_watchdog()\n        except asyncio.CancelledError:\n            break\n        except Exception as e:\n            logging.getLogger(__name__).warning(\"SpoolBuddy watchdog failed: %s\", e)\n        await asyncio.sleep(SPOOLBUDDY_WATCHDOG_INTERVAL)\n\n\ndef start_spoolbuddy_watchdog():\n    global _spoolbuddy_watchdog_task\n    if _spoolbuddy_watchdog_task is None:\n        _spoolbuddy_watchdog_task = asyncio.create_task(_spoolbuddy_watchdog_loop())\n        logging.getLogger(__name__).info(\"SpoolBuddy watchdog started\")\n\n\ndef stop_spoolbuddy_watchdog():\n    global _spoolbuddy_watchdog_task\n    if _spoolbuddy_watchdog_task:\n        _spoolbuddy_watchdog_task.cancel()\n        _spoolbuddy_watchdog_task = None\n        logging.getLogger(__name__).info(\"SpoolBuddy watchdog stopped\")\n\n\n# Camera stream orphan cleanup\n_camera_cleanup_task: asyncio.Task | None = None\nCAMERA_CLEANUP_INTERVAL = 60\n\n\nasync def _camera_cleanup_loop():\n    \"\"\"Periodically clean up orphaned ffmpeg processes.\"\"\"\n    from backend.app.api.routes.camera import cleanup_orphaned_streams\n\n    while True:\n        try:\n            await cleanup_orphaned_streams()\n        except asyncio.CancelledError:\n            break\n        except Exception as e:\n            logging.getLogger(__name__).warning(\"Camera stream cleanup failed: %s\", e)\n        await asyncio.sleep(CAMERA_CLEANUP_INTERVAL)\n\n\ndef start_camera_cleanup():\n    global _camera_cleanup_task\n    if _camera_cleanup_task is None:\n        _camera_cleanup_task = asyncio.create_task(_camera_cleanup_loop())\n        logging.getLogger(__name__).info(\"Camera stream cleanup started\")\n\n\ndef stop_camera_cleanup():\n    global _camera_cleanup_task\n    if _camera_cleanup_task:\n        _camera_cleanup_task.cancel()\n        _camera_cleanup_task = None\n        logging.getLogger(__name__).info(\"Camera stream cleanup stopped\")\n\n\n# ---------------------------------------------------------------------------\n# Expected-print TTL eviction\n# ---------------------------------------------------------------------------\n\n\ndef _evict_stale_expected_prints() -> None:\n    \"\"\"Remove entries from _expected_prints / _expected_print_creators that are\n    older than _EXPECTED_PRINT_TTL_SECONDS.\n\n    This prevents unbounded growth when a print is registered (via\n    register_expected_print) but on_print_start never fires — e.g. because the\n    printer disconnects, the app restarts, or the print is started directly from\n    the printer panel without going through the queue.\n    \"\"\"\n    # Use monotonic time so the TTL is unaffected by system clock adjustments\n    # (e.g. NTP sync, DST changes).\n    cutoff = time.monotonic() - _EXPECTED_PRINT_TTL_SECONDS\n    stale_keys = [k for k, t in _expected_print_registered_at.items() if t < cutoff]\n    if not stale_keys:\n        return\n\n    evicted_archive_ids: set[int] = set()\n    for key in stale_keys:\n        archive_id = _expected_prints.pop(key, None)\n        if archive_id is not None:\n            evicted_archive_ids.add(archive_id)\n        _expected_print_creators.pop(key, None)\n        _expected_print_registered_at.pop(key, None)\n\n    # Also clean up _print_ams_mappings for archive_ids that have no remaining\n    # live keys in _expected_prints (i.e. all variants were just evicted).\n    live_archive_ids = set(_expected_prints.values())\n    for archive_id in evicted_archive_ids:\n        if archive_id not in live_archive_ids:\n            _print_ams_mappings.pop(archive_id, None)\n\n    logging.getLogger(__name__).info(\n        \"Evicted %d stale expected-print entries (TTL=%ds)\", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS\n    )\n\n\nasync def _expected_prints_cleanup_loop() -> None:\n    \"\"\"Background task: periodically evict stale expected-print entries.\"\"\"\n    while True:\n        try:\n            _evict_stale_expected_prints()\n        except asyncio.CancelledError:\n            raise\n        except Exception as e:\n            logging.getLogger(__name__).warning(\"Expected prints cleanup failed: %s\", e)\n        await asyncio.sleep(_EXPECTED_PRINT_CLEANUP_INTERVAL)\n\n\ndef start_expected_prints_cleanup() -> None:\n    global _expected_prints_cleanup_task\n    if _expected_prints_cleanup_task is None:\n        _expected_prints_cleanup_task = asyncio.create_task(_expected_prints_cleanup_loop())\n        logging.getLogger(__name__).info(\"Expected prints cleanup started\")\n\n\ndef stop_expected_prints_cleanup() -> None:\n    global _expected_prints_cleanup_task\n    if _expected_prints_cleanup_task:\n        _expected_prints_cleanup_task.cancel()\n        _expected_prints_cleanup_task = None\n        logging.getLogger(__name__).info(\"Expected prints cleanup stopped\")\n\n\n# ---------------------------------------------------------------------------\n# L-2: Periodic auth-token cleanup (stale TOTP + expired revoked JTIs)\n# ---------------------------------------------------------------------------\n\n_auth_cleanup_task: asyncio.Task | None = None\n_AUTH_CLEANUP_INTERVAL = 3600  # seconds (hourly)\n\n\nasync def _run_auth_cleanup() -> None:\n    \"\"\"Single cleanup pass: remove stale TOTP records, expired revoked JTIs, and old rate-limit events.\"\"\"\n    from backend.app.core.database import async_session\n    from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent\n    from backend.app.models.user_totp import UserTOTP\n\n    now = datetime.now(timezone.utc)\n\n    # Remove unconfirmed (is_enabled=False) TOTP records older than 1 hour.\n    try:\n        async with async_session() as db:\n            stale_cutoff = now - timedelta(hours=1)\n            result = await db.execute(\n                select(UserTOTP).where(\n                    UserTOTP.is_enabled.is_(False),\n                    UserTOTP.created_at < stale_cutoff,\n                )\n            )\n            stale_records = result.scalars().all()\n            if stale_records:\n                for rec in stale_records:\n                    await db.delete(rec)\n                await db.commit()\n                logging.info(\"Auth cleanup: removed %d stale unconfirmed TOTP record(s)\", len(stale_records))\n    except Exception as e:\n        logging.warning(\"Auth cleanup: failed to purge stale TOTP records: %s\", e)\n\n    # Remove expired revoked-JTI entries (they are no longer needed once the\n    # original token's exp has passed — the token would be rejected by JWT\n    # signature verification regardless).\n    try:\n        async with async_session() as db:\n            await db.execute(\n                delete(AuthEphemeralToken).where(\n                    AuthEphemeralToken.token_type == \"revoked_jti\",\n                    AuthEphemeralToken.expires_at < now,\n                )\n            )\n            await db.commit()\n    except Exception as e:\n        logging.warning(\"Auth cleanup: failed to purge expired revoked JTIs: %s\", e)\n\n    # L-R6-B: Purge AuthRateLimitEvent rows older than the lockout window (15 min).\n    # Events outside this window can never affect rate-limit decisions — they only\n    # consume DB space.  Use the same window constant as the rate limiter so the\n    # two are always in sync.\n    try:\n        from backend.app.api.routes.mfa import LOCKOUT_WINDOW\n\n        async with async_session() as db:\n            await db.execute(\n                delete(AuthRateLimitEvent).where(\n                    AuthRateLimitEvent.occurred_at < now - LOCKOUT_WINDOW,\n                )\n            )\n            await db.commit()\n    except Exception as e:\n        logging.warning(\"Auth cleanup: failed to purge stale rate-limit events: %s\", e)\n\n\nasync def _auth_cleanup_loop() -> None:\n    \"\"\"Periodic background task: run auth cleanup every hour.\"\"\"\n    while True:\n        try:\n            await _run_auth_cleanup()\n        except asyncio.CancelledError:\n            break\n        except Exception as e:\n            logging.warning(\"Auth cleanup loop error: %s\", e)\n        await asyncio.sleep(_AUTH_CLEANUP_INTERVAL)\n\n\ndef start_auth_cleanup() -> None:\n    global _auth_cleanup_task\n    if _auth_cleanup_task is None:\n        _auth_cleanup_task = asyncio.create_task(_auth_cleanup_loop())\n        logging.getLogger(__name__).info(\"Auth periodic cleanup started\")\n\n\ndef stop_auth_cleanup() -> None:\n    global _auth_cleanup_task\n    if _auth_cleanup_task:\n        _auth_cleanup_task.cancel()\n        _auth_cleanup_task = None\n        logging.getLogger(__name__).info(\"Auth periodic cleanup stopped\")\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    # Startup\n    await init_db()\n\n    # Register an app-scoped httpx client for Bambu Cloud services so\n    # per-request BambuCloudService instances reuse the same connection pool\n    # (important for routes like /cloud/filament-info that chain many\n    # get_setting_detail calls). The shared client stores no region/token\n    # state, so the per-request ownership pattern that fixed the region-bleed\n    # bug is preserved.\n    import httpx as _httpx\n\n    from backend.app.services.bambu_cloud import set_shared_http_client\n\n    _shared_cloud_http_client = _httpx.AsyncClient(timeout=30.0)\n    set_shared_http_client(_shared_cloud_http_client)\n\n    # Fix queue items stuck with invalid \"aborted\" status (should be \"cancelled\").\n    # This can happen when a print was cancelled mid-print on versions before this fix.\n    try:\n        async with async_session() as db:\n            from backend.app.models.print_queue import PrintQueueItem\n\n            result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.status == \"aborted\"))\n            aborted_items = result.scalars().all()\n            if aborted_items:\n                for item in aborted_items:\n                    item.status = \"cancelled\"\n                await db.commit()\n                logging.info(\"Fixed %d queue item(s) with invalid 'aborted' status → 'cancelled'\", len(aborted_items))\n    except Exception as e:\n        logging.warning(\"Failed to fix aborted queue items: %s\", e)\n\n    # Restore debug logging state from previous session\n    await init_debug_logging()\n\n    # Set up printer manager callbacks\n    loop = asyncio.get_event_loop()\n    printer_manager.set_event_loop(loop)\n    printer_manager.set_status_change_callback(on_printer_status_change)\n    printer_manager.set_print_start_callback(on_print_start)\n    printer_manager.set_print_complete_callback(on_print_complete)\n    printer_manager.set_ams_change_callback(on_ams_change)\n\n    # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts\n    await printer_manager.load_awaiting_plate_clear_from_db()\n\n    # Layer change callback for external camera timelapse\n    async def on_layer_change(printer_id: int, layer_num: int):\n        \"\"\"Capture timelapse frame on layer change + first layer notification.\"\"\"\n        from backend.app.services.layer_timelapse import on_layer_change as tl_layer_change\n\n        await tl_layer_change(printer_id, layer_num)\n\n        # First layer complete notification (layer_num >= 2 means layer 1 is done)\n        if 2 <= layer_num <= 5 and not _first_layer_notified.get(printer_id, False):\n            _first_layer_notified[printer_id] = True\n            try:\n                async with async_session() as db:\n                    from backend.app.models.printer import Printer\n\n                    result = await db.execute(select(Printer).where(Printer.id == printer_id))\n                    printer = result.scalar_one_or_none()\n                    if not printer:\n                        return\n                    printer_name = printer.name\n                    client = printer_manager.get_client(printer_id)\n                    state = client.state if client else None\n                    filename = (state.subtask_name or state.gcode_file or \"Unknown\") if state else \"Unknown\"\n                    total_layers = state.total_layers if state else 0\n\n                    image_data = await _capture_snapshot_for_notification(\n                        printer_id, printer, logging.getLogger(__name__)\n                    )\n                    await notification_service.on_first_layer_complete(\n                        printer_id, printer_name, filename, total_layers, db, image_data=image_data\n                    )\n            except Exception as e:\n                logging.getLogger(__name__).warning(\"First layer notification failed: %s\", e)\n\n    printer_manager.set_layer_change_callback(on_layer_change)\n\n    # Event-driven bed cooldown: fires whenever bed_temper arrives via MQTT\n    async def on_bed_temp_update(printer_id: int, bed_temp: float):\n        waiter = _bed_cool_waiters.get(printer_id)\n        if not waiter:\n            return\n        threshold = waiter[\"threshold\"]\n        if bed_temp > threshold:\n            return\n        # Bed is at or below threshold — fire notification and remove waiter\n        waiter_info = _bed_cool_waiters.pop(printer_id, None)\n        if not waiter_info:\n            return  # Another callback already handled it\n        bed_cool_logger = logging.getLogger(__name__)\n        bed_cool_logger.info(\n            \"[BED-COOL] Bed cooled to %.1f°C on printer %s (threshold: %.0f°C)\",\n            bed_temp,\n            printer_id,\n            threshold,\n        )\n        try:\n            printer_info = printer_manager.get_printer(printer_id)\n            p_name = printer_info.name if printer_info else \"Unknown\"\n            async with async_session() as db:\n                await notification_service.on_bed_cooled(\n                    printer_id=printer_id,\n                    printer_name=p_name,\n                    bed_temp=bed_temp,\n                    threshold=threshold,\n                    filename=waiter_info[\"filename\"],\n                    db=db,\n                )\n        except Exception as e:\n            bed_cool_logger.warning(\"[BED-COOL] Failed to send notification: %s\", e)\n\n    printer_manager.set_bed_temp_update_callback(on_bed_temp_update)\n\n    # Initialize MQTT relay from settings\n    async with async_session() as db:\n        from backend.app.api.routes.settings import get_setting\n\n        mqtt_settings = {\n            \"mqtt_enabled\": (await get_setting(db, \"mqtt_enabled\") or \"false\") == \"true\",\n            \"mqtt_broker\": await get_setting(db, \"mqtt_broker\") or \"\",\n            \"mqtt_port\": int(await get_setting(db, \"mqtt_port\") or \"1883\"),\n            \"mqtt_username\": await get_setting(db, \"mqtt_username\") or \"\",\n            \"mqtt_password\": await get_setting(db, \"mqtt_password\") or \"\",\n            \"mqtt_topic_prefix\": await get_setting(db, \"mqtt_topic_prefix\") or \"bambuddy\",\n            \"mqtt_use_tls\": (await get_setting(db, \"mqtt_use_tls\") or \"false\") == \"true\",\n        }\n        await mqtt_relay.configure(mqtt_settings)\n\n        # Restore MQTT smart plug subscriptions\n        if mqtt_settings.get(\"mqtt_enabled\"):\n            from backend.app.models.smart_plug import SmartPlug\n            from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt\n\n            result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == \"mqtt\"))\n            mqtt_plugs = result.scalars().all()\n            restored = 0\n            for plug in mqtt_plugs:\n                if subscribe_plug_to_mqtt(mqtt_relay.smart_plug_service, plug):\n                    restored += 1\n            if restored:\n                logging.info(\"Restored %s MQTT smart plug subscriptions\", restored)\n\n    # Connect to all active printers\n    async with async_session() as db:\n        await init_printer_connections(db)\n\n    # Auto-connect to Spoolman if enabled\n    async with async_session() as db:\n        from backend.app.api.routes.settings import get_setting\n\n        spoolman_enabled = await get_setting(db, \"spoolman_enabled\")\n        spoolman_url = await get_setting(db, \"spoolman_url\")\n\n        if spoolman_enabled and spoolman_enabled.lower() == \"true\" and spoolman_url:\n            try:\n                client = await init_spoolman_client(spoolman_url)\n                if await client.health_check():\n                    logging.info(\"Auto-connected to Spoolman at %s\", spoolman_url)\n                    # Ensure the 'tag' extra field exists for RFID/UUID storage\n                    await client.ensure_tag_extra_field()\n                else:\n                    logging.warning(\"Spoolman at %s is not reachable\", spoolman_url)\n            except Exception as e:\n                logging.warning(\"Failed to auto-connect to Spoolman: %s\", e)\n\n    # Start the print scheduler\n    asyncio.create_task(print_scheduler.run())\n\n    # Start background dispatch worker for send/start operations\n    await background_dispatch.start()\n\n    # Start the smart plug scheduler for time-based on/off\n    smart_plug_manager.start_scheduler()\n\n    # Resume any pending auto-offs that were interrupted by restart\n    await smart_plug_manager.resume_pending_auto_offs()\n\n    # Start the notification digest scheduler\n    notification_service.start_digest_scheduler()\n\n    # Start the GitHub backup scheduler\n    await github_backup_service.start_scheduler()\n\n    # Start the local backup scheduler\n    await local_backup_service.start_scheduler()\n    await obico_detection_service.start()\n\n    # Start AMS history recording\n    start_ams_history_recording()\n\n    # Start printer runtime tracking\n    start_runtime_tracking()\n\n    # Start SpoolBuddy device watchdog\n    start_spoolbuddy_watchdog()\n\n    # Start camera stream orphan cleanup\n    start_camera_cleanup()\n\n    # Start expected-print TTL eviction (prevents memory leak when prints are\n    # registered but on_print_start never fires)\n    start_expected_prints_cleanup()\n\n    # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)\n    start_auth_cleanup()\n\n    # Initialize virtual printer manager and sync from DB\n    from backend.app.services.virtual_printer import virtual_printer_manager\n\n    virtual_printer_manager.set_session_factory(async_session)\n    try:\n        await virtual_printer_manager.sync_from_db()\n        logging.info(\"Virtual printer manager synced from database\")\n    except Exception as e:\n        logging.warning(\"Failed to sync virtual printers: %s\", e)\n\n    yield\n\n    # Shutdown\n    print_scheduler.stop()\n    await background_dispatch.stop()\n    smart_plug_manager.stop_scheduler()\n    notification_service.stop_digest_scheduler()\n    github_backup_service.stop_scheduler()\n    local_backup_service.stop_scheduler()\n    obico_detection_service.stop()\n    stop_ams_history_recording()\n    stop_runtime_tracking()\n    stop_spoolbuddy_watchdog()\n    stop_camera_cleanup()\n    stop_expected_prints_cleanup()\n    stop_auth_cleanup()\n    printer_manager.disconnect_all()\n    await close_spoolman_client()\n\n    # Stop all virtual printer services\n    await virtual_printer_manager.stop_all()\n\n    await mqtt_smart_plug_service.disconnect(timeout=2)\n\n    await mqtt_relay.disconnect(timeout=2)\n\n    # Drop the shared Bambu Cloud HTTP client we registered at startup.\n    set_shared_http_client(None)\n    await _shared_cloud_http_client.aclose()\n\n    # Checkpoint WAL (SQLite only) and close all database connections\n    from backend.app.core.db_dialect import is_sqlite\n\n    if is_sqlite():\n        try:\n            async with engine.begin() as conn:\n                await conn.execute(text(\"PRAGMA wal_checkpoint(TRUNCATE)\"))\n            logging.info(\"WAL checkpoint completed\")\n        except Exception as e:\n            logging.warning(\"WAL checkpoint failed: %s\", e)\n    await engine.dispose()\n\n\napp = FastAPI(\n    title=app_settings.app_name,\n    description=\"Archive and manage Bambu Lab 3MF files\",\n    version=APP_VERSION,\n    lifespan=lifespan,\n)\n\n\n# =============================================================================\n# Authentication Middleware - Secures ALL API routes by default\n# =============================================================================\n# Public routes that don't require authentication even when auth is enabled\nPUBLIC_API_ROUTES = {\n    # Auth routes needed before/during login\n    \"/api/v1/auth/status\",\n    \"/api/v1/auth/login\",\n    \"/api/v1/auth/setup\",  # Needed for initial setup and recovery\n    # Advanced auth status needed for login page\n    \"/api/v1/auth/advanced-auth/status\",\n    \"/api/v1/auth/forgot-password\",  # Password reset for advanced auth\n    \"/api/v1/auth/forgot-password/confirm\",  # Complete password reset with token (H-6)\n    # 2FA routes that are called BEFORE a JWT is issued (pre-auth flow)\n    \"/api/v1/auth/2fa/verify\",  # Exchange pre_auth_token + 2FA code for JWT\n    \"/api/v1/auth/2fa/email/send\",  # Send OTP email (pre_auth_token based)\n    # OIDC routes that must be reachable without a JWT\n    \"/api/v1/auth/oidc/providers\",  # Public list of enabled providers\n    \"/api/v1/auth/oidc/callback\",  # Redirect target from OIDC provider\n    \"/api/v1/auth/oidc/exchange\",  # Exchange short-lived OIDC token for JWT\n    # Version check for updates (no sensitive data)\n    \"/api/v1/updates/version\",\n    # Metrics endpoint handles its own prometheus_token authentication\n    \"/api/v1/metrics\",\n}\n\n# Route prefixes that are public (for routes with dynamic segments)\nPUBLIC_API_PREFIXES = [\n    # WebSocket connections handle their own auth\n    \"/api/v1/ws\",\n    # OIDC authorize redirects — include provider_id in path\n    \"/api/v1/auth/oidc/authorize/\",\n]\n\n# Route patterns that are public (read-only display data)\n# These are checked with \"in path\" - needed because browsers load images/videos\n# via <img src> and <video src> which don't include Authorization headers\nPUBLIC_API_PATTERNS = [\n    # Thumbnails\n    \"/thumbnail\",  # /archives/{id}/thumbnail, /library/files/{id}/thumbnail\n    \"/plate-thumbnail/\",  # /archives/{id}/plate-thumbnail/{plate_id}\n    # Images and media\n    \"/photos/\",  # /archives/{id}/photos/{filename}\n    \"/project-image/\",  # /archives/{id}/project-image/{path}\n    \"/qrcode\",  # /archives/{id}/qrcode\n    \"/timelapse\",  # /archives/{id}/timelapse (video)\n    \"/cover\",  # /printers/{id}/cover\n    \"/icon\",  # /external-links/{id}/icon\n    # Camera (streams loaded via <img> tag)\n    \"/camera/stream\",  # /printers/{id}/camera/stream\n    \"/camera/snapshot\",  # /printers/{id}/camera/snapshot\n    # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,\n    # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived\n    # download token in the URL path instead.\n    \"/dl/\",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}\n    # Obico ML API fetches JPEG frames by one-shot nonce (issue #172 follow-up).\n    # The nonce itself is the credential: 32-byte random, single-use, ~30s TTL.\n    \"/obico/cached-frame/\",  # /obico/cached-frame/{nonce}\n]\n\n\n@app.middleware(\"http\")\nasync def security_headers_middleware(request, call_next):\n    \"\"\"Add standard HTTP security headers to every response.\"\"\"\n    response = await call_next(request)\n    response.headers[\"X-Content-Type-Options\"] = \"nosniff\"\n    response.headers[\"X-Frame-Options\"] = \"SAMEORIGIN\"\n    response.headers[\"Referrer-Policy\"] = \"strict-origin-when-cross-origin\"\n    # Content-Security-Policy for the React SPA.\n    # Notes:\n    #   - 'unsafe-inline' for style-src: React and UI libs inject inline styles at runtime.\n    #   - connect-src ws:/wss:: MQTT/printer WebSocket connections.\n    #   - img-src data: / blob:: base64 thumbnails and Blob-URL timelapse previews.\n    #   - media-src blob:: timelapse video player uses Blob URLs.\n    #   - font-src data:: some icon fonts are embedded as data URIs.\n    response.headers[\"Content-Security-Policy\"] = (\n        \"default-src 'self'; \"\n        \"script-src 'self'; \"\n        \"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \"\n        \"img-src 'self' data: blob:; \"\n        \"media-src 'self' blob:; \"\n        \"connect-src 'self' ws: wss:; \"\n        \"font-src 'self' data: https://fonts.gstatic.com; \"\n        \"object-src 'none'; \"\n        \"base-uri 'self'; \"\n        \"frame-src 'self' http: https:; \"\n        \"frame-ancestors 'none';\"\n    )\n    if request.url.scheme == \"https\":\n        response.headers[\"Strict-Transport-Security\"] = \"max-age=31536000; includeSubDomains\"\n    return response\n\n\n@app.middleware(\"http\")\nasync def auth_middleware(request, call_next):\n    \"\"\"Enforce authentication on all API routes when auth is enabled.\n\n    This middleware provides defense-in-depth by checking auth at the API gateway level,\n    regardless of whether individual routes have auth dependencies.\n    \"\"\"\n    from starlette.responses import JSONResponse\n\n    path = request.url.path\n\n    # Only apply to API routes\n    if not path.startswith(\"/api/\"):\n        return await call_next(request)\n\n    # Allow public routes\n    if path in PUBLIC_API_ROUTES:\n        return await call_next(request)\n\n    # Allow public prefixes\n    for prefix in PUBLIC_API_PREFIXES:\n        if path.startswith(prefix):\n            return await call_next(request)\n\n    # Allow public patterns (read-only display data like thumbnails)\n    for pattern in PUBLIC_API_PATTERNS:\n        if pattern in path:\n            return await call_next(request)\n\n    # Check if auth is enabled\n    try:\n        async with async_session() as db:\n            from backend.app.core.auth import is_auth_enabled\n\n            auth_enabled = await is_auth_enabled(db)\n\n        if not auth_enabled:\n            # Auth disabled, allow all requests\n            return await call_next(request)\n    except Exception:\n        # If we can't check auth status, allow request (fail open for DB issues)\n        return await call_next(request)\n\n    # Auth is enabled - require valid token\n    auth_header = request.headers.get(\"Authorization\")\n    x_api_key = request.headers.get(\"X-API-Key\")\n\n    # Check for API key auth first\n    if x_api_key or (auth_header and auth_header.startswith(\"Bearer bb_\")):\n        # API key authentication - let the request through to be validated by route handler\n        # API keys are validated per-route since they have different permission levels\n        return await call_next(request)\n\n    # Check for JWT auth\n    if not auth_header or not auth_header.startswith(\"Bearer \"):\n        return JSONResponse(\n            status_code=401,\n            content={\"detail\": \"Authentication required\"},\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n    # Validate JWT token\n    import jwt\n\n    try:\n        from backend.app.core.auth import (\n            ALGORITHM,\n            SECRET_KEY,\n            _is_token_fresh,\n            get_user_by_username,\n            is_jti_revoked,\n        )\n\n        token = auth_header.replace(\"Bearer \", \"\")\n        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])\n        username = payload.get(\"sub\")\n        if not username:\n            raise ValueError(\"No username in token\")\n        jti = payload.get(\"jti\")\n        if not jti:\n            raise ValueError(\"No jti in token\")\n        iat = payload.get(\"iat\")\n\n        # Reject revoked tokens (defense-in-depth gateway check)\n        if await is_jti_revoked(jti):\n            return JSONResponse(\n                status_code=401,\n                content={\"detail\": \"Token has been revoked\"},\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n        # Verify user exists, is active, and token is still fresh (L-R8-A)\n        async with async_session() as db:\n            user = await get_user_by_username(db, username)\n            if not user or not user.is_active:\n                return JSONResponse(\n                    status_code=401,\n                    content={\"detail\": \"User not found or inactive\"},\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n            if not _is_token_fresh(iat, user):\n                return JSONResponse(\n                    status_code=401,\n                    content={\"detail\": \"Token no longer valid\"},\n                    headers={\"WWW-Authenticate\": \"Bearer\"},\n                )\n    except jwt.ExpiredSignatureError:\n        return JSONResponse(\n            status_code=401,\n            content={\"detail\": \"Token has expired\"},\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n    except (jwt.InvalidTokenError, ValueError, Exception):\n        return JSONResponse(\n            status_code=401,\n            content={\"detail\": \"Invalid token\"},\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n    return await call_next(request)\n\n\n# API routes\napp.include_router(auth.router, prefix=app_settings.api_prefix)\napp.include_router(mfa.router, prefix=app_settings.api_prefix)\napp.include_router(bug_report.router, prefix=app_settings.api_prefix)\napp.include_router(users.router, prefix=app_settings.api_prefix)\napp.include_router(groups.router, prefix=app_settings.api_prefix)\napp.include_router(printers.router, prefix=app_settings.api_prefix)\napp.include_router(archives.router, prefix=app_settings.api_prefix)\napp.include_router(filaments.router, prefix=app_settings.api_prefix)\napp.include_router(inventory.router, prefix=app_settings.api_prefix)\napp.include_router(settings_routes.router, prefix=app_settings.api_prefix)\napp.include_router(cloud.router, prefix=app_settings.api_prefix)\napp.include_router(local_presets.router, prefix=app_settings.api_prefix)\napp.include_router(smart_plugs.router, prefix=app_settings.api_prefix)\napp.include_router(print_log.router, prefix=app_settings.api_prefix)\napp.include_router(print_queue.router, prefix=app_settings.api_prefix)\napp.include_router(background_dispatch_routes.router, prefix=app_settings.api_prefix)\napp.include_router(kprofiles.router, prefix=app_settings.api_prefix)\napp.include_router(notifications.router, prefix=app_settings.api_prefix)\napp.include_router(notification_templates.router, prefix=app_settings.api_prefix)\napp.include_router(user_notifications.router, prefix=app_settings.api_prefix)\napp.include_router(spoolman.router, prefix=app_settings.api_prefix)\napp.include_router(updates.router, prefix=app_settings.api_prefix)\napp.include_router(maintenance.router, prefix=app_settings.api_prefix)\napp.include_router(camera.router, prefix=app_settings.api_prefix)\napp.include_router(external_links.router, prefix=app_settings.api_prefix)\napp.include_router(projects.router, prefix=app_settings.api_prefix)\napp.include_router(library.router, prefix=app_settings.api_prefix)\napp.include_router(api_keys.router, prefix=app_settings.api_prefix)\napp.include_router(webhook.router, prefix=app_settings.api_prefix)\napp.include_router(ams_history.router, prefix=app_settings.api_prefix)\napp.include_router(system.router, prefix=app_settings.api_prefix)\napp.include_router(support.router, prefix=app_settings.api_prefix)\napp.include_router(websocket.router, prefix=app_settings.api_prefix)\napp.include_router(discovery.router, prefix=app_settings.api_prefix)\napp.include_router(pending_uploads.router, prefix=app_settings.api_prefix)\napp.include_router(firmware.router, prefix=app_settings.api_prefix)\napp.include_router(github_backup.router, prefix=app_settings.api_prefix)\napp.include_router(local_backup.router, prefix=app_settings.api_prefix)\napp.include_router(obico.router, prefix=app_settings.api_prefix)\napp.include_router(metrics.router, prefix=app_settings.api_prefix)\napp.include_router(virtual_printers.router, prefix=app_settings.api_prefix)\napp.include_router(spoolbuddy.router, prefix=app_settings.api_prefix)\n\n\n# Serve static files (React build)\nif app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):\n    app.mount(\n        \"/assets\",\n        StaticFiles(directory=app_settings.static_dir / \"assets\"),\n        name=\"assets\",\n    )\n    if (app_settings.static_dir / \"img\").exists():\n        app.mount(\n            \"/img\",\n            StaticFiles(directory=app_settings.static_dir / \"img\"),\n            name=\"img\",\n        )\n    if (app_settings.static_dir / \"icons\").exists():\n        app.mount(\n            \"/icons\",\n            StaticFiles(directory=app_settings.static_dir / \"icons\"),\n            name=\"icons\",\n        )\n\n\n@app.get(\"/\")\nasync def serve_frontend():\n    \"\"\"Serve the React frontend.\"\"\"\n    index_file = app_settings.static_dir / \"index.html\"\n    if index_file.exists():\n        return FileResponse(index_file)\n    return {\n        \"message\": \"Bambuddy API\",\n        \"docs\": \"/docs\",\n        \"frontend\": \"Build and place React app in /static directory\",\n    }\n\n\n@app.get(\"/health\")\nasync def health_check():\n    \"\"\"Health check endpoint.\"\"\"\n    return {\"status\": \"healthy\"}\n\n\n@app.get(\"/manifest.json\")\nasync def serve_manifest():\n    \"\"\"Serve PWA manifest.\"\"\"\n    manifest_file = app_settings.static_dir / \"manifest.json\"\n    if manifest_file.exists():\n        return FileResponse(manifest_file, media_type=\"application/manifest+json\")\n    return {\"error\": \"Manifest not found\"}\n\n\n@app.get(\"/sw.js\")\nasync def serve_service_worker():\n    \"\"\"Serve service worker.\"\"\"\n    sw_file = app_settings.static_dir / \"sw.js\"\n    if sw_file.exists():\n        return FileResponse(\n            sw_file,\n            media_type=\"application/javascript\",\n            headers={\"Cache-Control\": \"no-cache, no-store, must-revalidate\"},\n        )\n    return {\"error\": \"Service worker not found\"}\n\n\n@app.get(\"/sw-register.js\")\nasync def serve_sw_register():\n    \"\"\"Serve the service-worker registration bootstrap script.\n\n    Served as a real JS file so the strict `script-src 'self'` CSP covers it\n    without needing 'unsafe-inline' or per-build hashes on the inline tag.\n    \"\"\"\n    reg_file = app_settings.static_dir / \"sw-register.js\"\n    if reg_file.exists():\n        return FileResponse(reg_file, media_type=\"application/javascript\")\n    return {\"error\": \"sw-register.js not found\"}\n\n\n# Catch-all route for React Router (must be last)\n@app.get(\"/{full_path:path}\")\nasync def serve_spa(full_path: str):\n    \"\"\"Serve React app for client-side routing.\"\"\"\n    # Don't intercept API routes - raise proper 404 so FastAPI can handle redirects\n    if full_path.startswith(\"api/\"):\n        from fastapi import HTTPException\n\n        raise HTTPException(status_code=404, detail=\"Not found\")\n\n    index_file = app_settings.static_dir / \"index.html\"\n    if index_file.exists():\n        return FileResponse(index_file)\n\n    return {\"error\": \"Frontend not built\"}\n"
  },
  {
    "path": "backend/app/models/__init__.py",
    "content": "from backend.app.models.ams_history import AMSSensorHistory\nfrom backend.app.models.ams_label import AmsLabel\nfrom backend.app.models.api_key import APIKey\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent\nfrom backend.app.models.color_catalog import ColorCatalogEntry\nfrom backend.app.models.filament import Filament\nfrom backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog\nfrom backend.app.models.group import Group, user_groups\nfrom backend.app.models.kprofile_note import KProfileNote\nfrom backend.app.models.library import LibraryFile, LibraryFolder\nfrom backend.app.models.local_preset import LocalPreset\nfrom backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance\nfrom backend.app.models.notification import NotificationLog\nfrom backend.app.models.notification_template import NotificationTemplate\nfrom backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink\nfrom backend.app.models.orca_base_cache import OrcaBaseProfile\nfrom backend.app.models.pending_upload import PendingUpload\nfrom backend.app.models.print_batch import PrintBatch\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.project import Project\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.smart_plug import SmartPlug\nfrom backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot\nfrom backend.app.models.spool import Spool\nfrom backend.app.models.spool_assignment import SpoolAssignment\nfrom backend.app.models.spool_catalog import SpoolCatalogEntry\nfrom backend.app.models.spool_k_profile import SpoolKProfile\nfrom backend.app.models.spool_usage_history import SpoolUsageHistory\nfrom backend.app.models.spoolbuddy_device import SpoolBuddyDevice\nfrom backend.app.models.user import User\nfrom backend.app.models.user_email_pref import UserEmailPreference\nfrom backend.app.models.user_otp_code import UserOTPCode\nfrom backend.app.models.user_totp import UserTOTP\n\n__all__ = [\n    \"Printer\",\n    \"PrintArchive\",\n    \"Filament\",\n    \"Settings\",\n    \"SmartPlug\",\n    \"SmartPlugEnergySnapshot\",\n    \"MaintenanceType\",\n    \"PrinterMaintenance\",\n    \"MaintenanceHistory\",\n    \"KProfileNote\",\n    \"NotificationTemplate\",\n    \"NotificationLog\",\n    \"Project\",\n    \"APIKey\",\n    \"AMSSensorHistory\",\n    \"AmsLabel\",\n    \"PendingUpload\",\n    \"PrintBatch\",\n    \"LibraryFolder\",\n    \"LibraryFile\",\n    \"User\",\n    \"Group\",\n    \"user_groups\",\n    \"GitHubBackupConfig\",\n    \"GitHubBackupLog\",\n    \"LocalPreset\",\n    \"OIDCProvider\",\n    \"UserOIDCLink\",\n    \"OrcaBaseProfile\",\n    \"Spool\",\n    \"SpoolKProfile\",\n    \"SpoolAssignment\",\n    \"SpoolCatalogEntry\",\n    \"SpoolUsageHistory\",\n    \"ColorCatalogEntry\",\n    \"SpoolBuddyDevice\",\n    \"UserEmailPreference\",\n    \"UserOTPCode\",\n    \"UserTOTP\",\n    \"AuthEphemeralToken\",\n    \"AuthRateLimitEvent\",\n]\n"
  },
  {
    "path": "backend/app/models/active_print_spoolman.py",
    "content": "\"\"\"Track Spoolman data for active prints.\"\"\"\n\nfrom sqlalchemy import JSON, ForeignKey, UniqueConstraint\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass ActivePrintSpoolman(Base):\n    \"\"\"Stores Spoolman tracking data for active prints.\n\n    This data is captured at print start and used at print completion\n    to report per-filament usage to the correct Spoolman spools.\n    Rows are deleted after print completes.\n\n    Key: (printer_id, archive_id) - allows same archive on different printers\n    \"\"\"\n\n    __tablename__ = \"active_print_spoolman\"\n    __table_args__ = (UniqueConstraint(\"printer_id\", \"archive_id\", name=\"uq_printer_archive\"),)\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    printer_id: Mapped[int] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"CASCADE\"))\n    archive_id: Mapped[int] = mapped_column(ForeignKey(\"print_archives.id\", ondelete=\"CASCADE\"))\n\n    # Per-filament usage from 3MF: [{\"slot_id\": 1, \"used_g\": 50.5, \"type\": \"PLA\"}, ...]\n    filament_usage: Mapped[list] = mapped_column(JSON)\n\n    # AMS tray state at print start: {0: {\"tray_uuid\": \"...\", \"tag_uid\": \"...\"}, ...}\n    ams_trays: Mapped[dict] = mapped_column(JSON)\n\n    # Custom slot-to-tray mapping from queue (optional): [5, -1, 2, -1]\n    slot_to_tray: Mapped[list | None] = mapped_column(JSON, nullable=True)\n\n    # Per-layer cumulative usage from G-code parsing (for accurate partial usage)\n    # Format: {\"0\": {0: 125.5}, \"1\": {0: 250.0, 1: 50.0}, ...}\n    # Keys are layer numbers (as strings for JSON), values are filament_id -> mm\n    layer_usage: Mapped[dict | None] = mapped_column(JSON, nullable=True)\n\n    # Filament properties (density, diameter per filament slot)\n    # Format: {1: {\"density\": 1.24, \"diameter\": 1.75, \"type\": \"PLA\"}, ...}\n    filament_properties: Mapped[dict | None] = mapped_column(JSON, nullable=True)\n"
  },
  {
    "path": "backend/app/models/ams_history.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass AMSSensorHistory(Base):\n    \"\"\"Historical sensor data from AMS units (humidity and temperature).\"\"\"\n\n    __tablename__ = \"ams_sensor_history\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    printer_id: Mapped[int] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"CASCADE\"))\n    ams_id: Mapped[int] = mapped_column(Integer)  # AMS unit index (0, 1, 2, 3)\n    humidity: Mapped[float | None] = mapped_column(Float)  # Humidity percentage\n    humidity_raw: Mapped[float | None] = mapped_column(Float)  # Raw humidity value\n    temperature: Mapped[float | None] = mapped_column(Float)  # Temperature in Celsius\n    recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)\n\n    # Indexes for efficient querying\n    __table_args__ = (Index(\"ix_ams_history_printer_ams_time\", \"printer_id\", \"ams_id\", \"recorded_at\"),)\n\n    # Relationship\n    printer: Mapped[\"Printer\"] = relationship(back_populates=\"ams_history\")\n\n\nfrom backend.app.models.printer import Printer  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/ams_label.py",
    "content": "\"\"\"Model for storing user-defined friendly names for AMS units.\n\nUsers can assign a custom label to each AMS (e.g. \"Workshop AMS\", \"Silk Colours\")\nthat is displayed in place of or alongside the auto-generated label (AMS-A, HT-A, …).\n\nLabels are keyed by AMS serial number so they persist when the AMS is moved to a\ndifferent printer.  A fallback (printer_id + ams_id) is retained for units whose\nserial number is not yet known.\n\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import DateTime, Integer, String, UniqueConstraint, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass AmsLabel(Base):\n    \"\"\"Maps an AMS unit serial number to a user-defined friendly name.\"\"\"\n\n    __tablename__ = \"ams_labels\"\n    __table_args__ = (UniqueConstraint(\"ams_serial_number\", name=\"uq_ams_label_serial\"),)\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    ams_serial_number: Mapped[str] = mapped_column(String(50))  # AMS unit serial number (sn from MQTT)\n    ams_id: Mapped[int | None] = mapped_column(Integer, nullable=True)  # AMS unit ID hint (0, 1, 2, 3, 128…)\n    label: Mapped[str] = mapped_column(String(100))  # User-defined friendly name\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n"
  },
  {
    "path": "backend/app/models/api_key.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import JSON, Boolean, DateTime, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass APIKey(Base):\n    \"\"\"API key for external webhook access.\"\"\"\n\n    __tablename__ = \"api_keys\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))  # User-friendly name\n    key_hash: Mapped[str] = mapped_column(String(255))  # bcrypt hash of the key\n    key_prefix: Mapped[str] = mapped_column(String(20))  # First 8 chars + \"...\" for display\n\n    # Permissions\n    can_queue: Mapped[bool] = mapped_column(Boolean, default=True)  # Add to queue\n    can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel\n    can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status\n\n    # Optional scope limits\n    printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers\n\n    enabled: Mapped[bool] = mapped_column(Boolean, default=True)\n    last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # Optional expiry\n"
  },
  {
    "path": "backend/app/models/archive.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass PrintArchive(Base):\n    __tablename__ = \"print_archives\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    printer_id: Mapped[int | None] = mapped_column(ForeignKey(\"printers.id\"), nullable=True)\n    project_id: Mapped[int | None] = mapped_column(ForeignKey(\"projects.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # File info\n    filename: Mapped[str] = mapped_column(String(255))\n    file_path: Mapped[str] = mapped_column(String(500))\n    file_size: Mapped[int] = mapped_column(Integer)\n    content_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 hash for duplicate detection\n    thumbnail_path: Mapped[str | None] = mapped_column(String(500))\n    timelapse_path: Mapped[str | None] = mapped_column(String(500))\n    source_3mf_path: Mapped[str | None] = mapped_column(String(500))  # Original project 3MF from slicer\n    f3d_path: Mapped[str | None] = mapped_column(String(500))  # Fusion 360 design file\n\n    # Print details from 3MF / printer\n    print_name: Mapped[str | None] = mapped_column(String(255))\n    print_time_seconds: Mapped[int | None] = mapped_column(Integer)\n    filament_used_grams: Mapped[float | None] = mapped_column(Float)\n    filament_type: Mapped[str | None] = mapped_column(String(50))\n    filament_color: Mapped[str | None] = mapped_column(String(200))\n    layer_height: Mapped[float | None] = mapped_column(Float)\n    total_layers: Mapped[int | None] = mapped_column(Integer)\n    nozzle_diameter: Mapped[float | None] = mapped_column(Float)\n    bed_temperature: Mapped[int | None] = mapped_column(Integer)\n    nozzle_temperature: Mapped[int | None] = mapped_column(Integer)\n\n    # Printer model this file was sliced for (extracted from 3MF metadata)\n    sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True)\n\n    # Print result\n    status: Mapped[str] = mapped_column(String(20), default=\"completed\")\n    started_at: Mapped[datetime | None] = mapped_column(DateTime)\n    completed_at: Mapped[datetime | None] = mapped_column(DateTime)\n\n    # Printer-assigned subtask identifier from MQTT. Used to resume the same\n    # archive row across a backend restart during a long-running print (#972):\n    # if the same subtask_id reappears after restart, we know it's the same\n    # print and keep the original row instead of cancel-then-create.\n    subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)\n\n    # Extended metadata (JSON blob for flexibility)\n    extra_data: Mapped[dict | None] = mapped_column(JSON)\n\n    # MakerWorld info (auto-extracted from 3MF)\n    makerworld_url: Mapped[str | None] = mapped_column(String(500))\n    designer: Mapped[str | None] = mapped_column(String(255))\n\n    # User-defined external link (Printables, Thingiverse, etc.)\n    external_url: Mapped[str | None] = mapped_column(String(500))\n\n    # User additions\n    is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)\n    tags: Mapped[str | None] = mapped_column(Text)\n    notes: Mapped[str | None] = mapped_column(Text)\n    cost: Mapped[float | None] = mapped_column(Float)\n    photos: Mapped[list | None] = mapped_column(JSON)  # List of photo filenames\n    failure_reason: Mapped[str | None] = mapped_column(String(100))  # For failed prints\n    quantity: Mapped[int] = mapped_column(Integer, default=1)  # Number of items printed\n\n    # Energy tracking\n    energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh\n    energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed\n    # Plug lifetime counter captured at print start; delta at print end becomes energy_kwh.\n    # Persisted so per-print tracking survives backend restarts mid-print (#941).\n    energy_start_kwh: Mapped[float | None] = mapped_column(Float)\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    # User tracking (who uploaded/created this archive)\n    created_by_id: Mapped[int | None] = mapped_column(ForeignKey(\"users.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Relationships\n    printer: Mapped[\"Printer | None\"] = relationship(back_populates=\"archives\")\n    project: Mapped[\"Project | None\"] = relationship(back_populates=\"archives\")\n    created_by: Mapped[\"User | None\"] = relationship()\n\n\nfrom backend.app.models.printer import Printer  # noqa: E402, F811\nfrom backend.app.models.project import Project  # noqa: E402, F811\nfrom backend.app.models.user import User  # noqa: E402, F811\n"
  },
  {
    "path": "backend/app/models/auth_ephemeral.py",
    "content": "\"\"\"Ephemeral authentication tokens and rate-limit events.\n\nThese tables replace the module-level in-memory dicts in mfa.py, making\nthe 2FA / OIDC flow compatible with multi-worker deployments and persistent\nacross server restarts.\n\nTables\n------\nAuthEphemeralToken\n    Short-lived, single-use tokens for:\n    - pre_auth   : issued after password check, consumed when 2FA is verified\n    - oidc_state : CSRF nonce for the OIDC authorization-code flow\n    - oidc_exchange : short bridge token from the OIDC callback to the SPA\n\nAuthRateLimitEvent\n    Timestamped events used for sliding-window rate limiting:\n    - 2fa_attempt  : each failed 2FA verification attempt\n    - email_send   : each OTP email sent (prevents email flooding)\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime, timezone\nfrom enum import Enum\n\nfrom sqlalchemy import DateTime, Integer, String\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass TokenType(str, Enum):\n    \"\"\"T3: Enumerated token types for AuthEphemeralToken.token_type.\n\n    Using str-based Enum keeps the stored values human-readable and\n    backward-compatible with existing rows.\n    \"\"\"\n\n    PRE_AUTH = \"pre_auth\"\n    OIDC_STATE = \"oidc_state\"\n    OIDC_EXCHANGE = \"oidc_exchange\"\n    PASSWORD_RESET = \"password_reset\"\n    EMAIL_OTP_SETUP = \"email_otp_setup\"\n    SLICER_DOWNLOAD = \"slicer_download\"\n\n\nclass EventType(str, Enum):\n    \"\"\"T3: Enumerated event types for AuthRateLimitEvent.event_type.\n\n    Using str-based Enum keeps the stored values human-readable and\n    backward-compatible with existing rows.\n    \"\"\"\n\n    TWO_FA_ATTEMPT = \"2fa_attempt\"\n    EMAIL_SEND = \"email_send\"\n    LOGIN_ATTEMPT = \"login_attempt\"\n    LOGIN_IP = \"login_ip\"\n    PASSWORD_RESET_SEND = \"password_reset_send\"\n    PASSWORD_RESET_IP = \"password_reset_ip\"\n\n\nclass AuthEphemeralToken(Base):\n    \"\"\"Single-use, time-limited token for pre-auth / OIDC flows.\"\"\"\n\n    __tablename__ = \"auth_ephemeral_tokens\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)\n    token_type: Mapped[str] = mapped_column(String(20), nullable=False)  # 'pre_auth' | 'oidc_state' | 'oidc_exchange'\n\n    # pre_auth + oidc_exchange: which user this session belongs to\n    username: Mapped[str | None] = mapped_column(String(150), nullable=True)\n\n    # oidc_state: which provider initiated the flow\n    provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)\n\n    # oidc_state: replay-protection nonce embedded in the ID token\n    nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)\n\n    # oidc_state: PKCE code verifier (S256 method)\n    code_verifier: Mapped[str | None] = mapped_column(String(128), nullable=True)\n\n    # pre_auth: HttpOnly cookie value bound to this token to prevent token theft\n    # (XSS can read JS memory but cannot read HttpOnly cookies).\n    challenge_id: Mapped[str | None] = mapped_column(String(128), nullable=True)\n\n    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)\n    created_at: Mapped[datetime] = mapped_column(\n        DateTime(timezone=True),\n        nullable=False,\n        default=lambda: datetime.now(timezone.utc),\n    )\n\n    # ------------------------------------------------------------------\n    # T1: Classmethod factories — enforce required fields per token type\n    # and prevent accidentally leaving optional fields at their defaults.\n    # ------------------------------------------------------------------\n\n    @classmethod\n    def new_pre_auth(\n        cls,\n        token: str,\n        username: str,\n        expires_at: datetime,\n        challenge_id: str | None = None,\n    ) -> AuthEphemeralToken:\n        \"\"\"Create a pre-auth token (issued after password check, before 2FA).\"\"\"\n        return cls(\n            token=token,\n            token_type=TokenType.PRE_AUTH,\n            username=username,\n            expires_at=expires_at,\n            challenge_id=challenge_id,\n        )\n\n    @classmethod\n    def new_oidc_state(\n        cls,\n        token: str,\n        provider_id: int,\n        nonce: str,\n        code_verifier: str,\n        expires_at: datetime,\n    ) -> AuthEphemeralToken:\n        \"\"\"Create an OIDC state token (CSRF protection + PKCE for authorize redirect).\"\"\"\n        return cls(\n            token=token,\n            token_type=TokenType.OIDC_STATE,\n            provider_id=provider_id,\n            nonce=nonce,\n            code_verifier=code_verifier,\n            expires_at=expires_at,\n        )\n\n    @classmethod\n    def new_oidc_exchange(\n        cls,\n        token: str,\n        username: str,\n        expires_at: datetime,\n    ) -> AuthEphemeralToken:\n        \"\"\"Create an OIDC exchange token (bridge from callback to SPA).\"\"\"\n        return cls(\n            token=token,\n            token_type=TokenType.OIDC_EXCHANGE,\n            username=username,\n            expires_at=expires_at,\n        )\n\n    @classmethod\n    def new_password_reset(\n        cls,\n        token: str,\n        username: str,\n        expires_at: datetime,\n    ) -> AuthEphemeralToken:\n        \"\"\"Create a password-reset token (single-use link emailed to the user).\"\"\"\n        return cls(\n            token=token,\n            token_type=TokenType.PASSWORD_RESET,\n            username=username,\n            expires_at=expires_at,\n        )\n\n    @classmethod\n    def new_email_otp_setup(\n        cls,\n        token: str,\n        username: str,\n        code_hash: str,\n        expires_at: datetime,\n    ) -> AuthEphemeralToken:\n        \"\"\"Create an email-OTP setup token.\n\n        The ``code_hash`` is stored in the ``nonce`` column (field reuse\n        documented inline in the enable_email_otp endpoint).\n        \"\"\"\n        return cls(\n            token=token,\n            token_type=TokenType.EMAIL_OTP_SETUP,\n            username=username,\n            nonce=code_hash,\n            expires_at=expires_at,\n        )\n\n\nclass AuthRateLimitEvent(Base):\n    \"\"\"Timestamped events used for sliding-window rate limiting.\"\"\"\n\n    __tablename__ = \"auth_rate_limit_events\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    username: Mapped[str] = mapped_column(String(150), nullable=False, index=True)\n    event_type: Mapped[str] = mapped_column(String(20), nullable=False)  # '2fa_attempt' | 'email_send'\n    occurred_at: Mapped[datetime] = mapped_column(\n        DateTime(timezone=True),\n        nullable=False,\n        default=lambda: datetime.now(timezone.utc),\n    )\n"
  },
  {
    "path": "backend/app/models/bug_report.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass BugReport(Base):\n    __tablename__ = \"bug_reports\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    description: Mapped[str] = mapped_column(Text)\n    reporter_email: Mapped[str | None] = mapped_column(String(255), nullable=True)\n    github_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True)\n    github_issue_url: Mapped[str | None] = mapped_column(String(500), nullable=True)\n    status: Mapped[str] = mapped_column(String(20), default=\"submitted\")\n    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)\n    email_sent: Mapped[bool] = mapped_column(Boolean, default=False)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n"
  },
  {
    "path": "backend/app/models/color_catalog.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass ColorCatalogEntry(Base):\n    \"\"\"Color catalog entry for automatic color lookup when adding spools.\"\"\"\n\n    __tablename__ = \"color_catalog\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    manufacturer: Mapped[str] = mapped_column(String(200))\n    color_name: Mapped[str] = mapped_column(String(200))\n    hex_color: Mapped[str] = mapped_column(String(7))  # #RRGGBB\n    material: Mapped[str | None] = mapped_column(String(100))\n    is_default: Mapped[bool] = mapped_column(Boolean, default=False)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n"
  },
  {
    "path": "backend/app/models/external_link.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass ExternalLink(Base):\n    \"\"\"External links for sidebar navigation.\"\"\"\n\n    __tablename__ = \"external_links\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(50))\n    url: Mapped[str] = mapped_column(String(500))\n    icon: Mapped[str] = mapped_column(String(50), default=\"link\")\n    custom_icon: Mapped[str | None] = mapped_column(String(255), nullable=True)  # Filename of uploaded icon\n    open_in_new_tab: Mapped[bool] = mapped_column(Boolean, default=False)\n    sort_order: Mapped[int] = mapped_column(Integer, default=0)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n"
  },
  {
    "path": "backend/app/models/filament.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, Float, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass Filament(Base):\n    \"\"\"Filament type with cost information.\"\"\"\n\n    __tablename__ = \"filaments\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n    type: Mapped[str] = mapped_column(String(50))  # PLA, PETG, ABS, etc.\n    brand: Mapped[str | None] = mapped_column(String(100))\n    color: Mapped[str | None] = mapped_column(String(50))\n    color_hex: Mapped[str | None] = mapped_column(String(7))  # #RRGGBB\n\n    # Cost information\n    cost_per_kg: Mapped[float] = mapped_column(Float, default=25.0)\n    spool_weight_g: Mapped[float] = mapped_column(Float, default=1000.0)\n    currency: Mapped[str] = mapped_column(String(3), default=\"USD\")\n\n    # Properties\n    density: Mapped[float | None] = mapped_column(Float)  # g/cm³\n    print_temp_min: Mapped[int | None] = mapped_column()\n    print_temp_max: Mapped[int | None] = mapped_column()\n    bed_temp_min: Mapped[int | None] = mapped_column()\n    bed_temp_max: Mapped[int | None] = mapped_column()\n\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n"
  },
  {
    "path": "backend/app/models/github_backup.py",
    "content": "\"\"\"GitHub backup configuration and log models.\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass GitHubBackupConfig(Base):\n    \"\"\"Configuration for GitHub profile backup.\"\"\"\n\n    __tablename__ = \"github_backup_config\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    repository_url: Mapped[str] = mapped_column(String(500))  # Full GitHub URL\n    access_token: Mapped[str] = mapped_column(Text)  # Personal Access Token\n    branch: Mapped[str] = mapped_column(String(100), default=\"main\")\n\n    # Schedule configuration\n    schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)\n    schedule_type: Mapped[str] = mapped_column(String(20), default=\"daily\")  # hourly/daily/weekly\n    schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)  # For future cron support\n\n    # What to backup\n    backup_kprofiles: Mapped[bool] = mapped_column(Boolean, default=True)\n    backup_cloud_profiles: Mapped[bool] = mapped_column(Boolean, default=True)\n    backup_settings: Mapped[bool] = mapped_column(Boolean, default=False)\n    backup_spools: Mapped[bool] = mapped_column(Boolean, default=False)\n    backup_archives: Mapped[bool] = mapped_column(Boolean, default=False)\n\n    # Status tracking\n    enabled: Mapped[bool] = mapped_column(Boolean, default=True)\n    last_backup_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n    last_backup_status: Mapped[str | None] = mapped_column(String(20), nullable=True)  # success/failed/skipped\n    last_backup_message: Mapped[str | None] = mapped_column(Text, nullable=True)\n    last_backup_commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)\n    next_scheduled_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationships\n    logs: Mapped[list[\"GitHubBackupLog\"]] = relationship(back_populates=\"config\", cascade=\"all, delete-orphan\")\n\n\nclass GitHubBackupLog(Base):\n    \"\"\"Log entry for GitHub backup runs.\"\"\"\n\n    __tablename__ = \"github_backup_logs\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    config_id: Mapped[int] = mapped_column(ForeignKey(\"github_backup_config.id\", ondelete=\"CASCADE\"))\n\n    started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n    status: Mapped[str] = mapped_column(String(20))  # running/success/failed/skipped\n    trigger: Mapped[str] = mapped_column(String(20))  # manual/scheduled\n\n    commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)\n    files_changed: Mapped[int] = mapped_column(Integer, default=0)\n    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # Relationships\n    config: Mapped[\"GitHubBackupConfig\"] = relationship(back_populates=\"logs\")\n"
  },
  {
    "path": "backend/app/models/group.py",
    "content": "\"\"\"Group model for permission-based access control.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING\n\nfrom sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\nfrom sqlalchemy.types import JSON\n\nfrom backend.app.core.database import Base\n\nif TYPE_CHECKING:\n    from backend.app.models.user import User\n\n\n# Many-to-many association table between users and groups\nuser_groups = Table(\n    \"user_groups\",\n    Base.metadata,\n    Column(\"user_id\", Integer, ForeignKey(\"users.id\", ondelete=\"CASCADE\"), primary_key=True),\n    Column(\"group_id\", Integer, ForeignKey(\"groups.id\", ondelete=\"CASCADE\"), primary_key=True),\n)\n\n\nclass Group(Base):\n    \"\"\"Group model for organizing users and assigning permissions.\n\n    Groups contain a list of permissions that are granted to all members.\n    Users can belong to multiple groups, and their permissions are additive.\n    System groups (Administrators, Operators, Viewers) cannot be deleted.\n    \"\"\"\n\n    __tablename__ = \"groups\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(100), unique=True, index=True)\n    description: Mapped[str | None] = mapped_column(String(500), nullable=True)\n    permissions: Mapped[list[str]] = mapped_column(JSON, default=list)\n    is_system: Mapped[bool] = mapped_column(Boolean, default=False)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationship to users through association table\n    users: Mapped[list[User]] = relationship(\n        \"User\",\n        secondary=user_groups,\n        back_populates=\"groups\",\n        lazy=\"selectin\",\n    )\n\n    def __repr__(self) -> str:\n        return f\"<Group {self.name}>\"\n"
  },
  {
    "path": "backend/app/models/kprofile_note.py",
    "content": "\"\"\"Model for K-profile notes stored locally (not on printer).\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import DateTime, ForeignKey, Index, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass KProfileNote(Base):\n    \"\"\"Notes for K-profiles stored locally since printers don't support notes.\"\"\"\n\n    __tablename__ = \"kprofile_notes\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    printer_id: Mapped[int] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"CASCADE\"))\n    # setting_id is the unique identifier for a K-profile on the printer\n    setting_id: Mapped[str] = mapped_column(String(100))\n    note: Mapped[str] = mapped_column(Text, default=\"\")\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationship to printer\n    printer: Mapped[\"Printer\"] = relationship(back_populates=\"kprofile_notes\")\n\n    # Composite index for efficient lookups\n    __table_args__ = (Index(\"ix_kprofile_notes_printer_setting\", \"printer_id\", \"setting_id\", unique=True),)\n\n\nfrom backend.app.models.printer import Printer  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/library.py",
    "content": "\"\"\"Library models for file manager functionality.\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass LibraryFolder(Base):\n    \"\"\"Folder for organizing library files.\"\"\"\n\n    __tablename__ = \"library_folders\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(255))\n    parent_id: Mapped[int | None] = mapped_column(ForeignKey(\"library_folders.id\", ondelete=\"CASCADE\"), nullable=True)\n\n    # External folder flags (for folders that point to external paths)\n    is_external: Mapped[bool] = mapped_column(Boolean, default=False)\n    external_readonly: Mapped[bool] = mapped_column(Boolean, default=False)\n    external_show_hidden: Mapped[bool] = mapped_column(Boolean, default=False)\n    external_path: Mapped[str | None] = mapped_column(String(500), nullable=True)\n\n    # Link to project or archive\n    project_id: Mapped[int | None] = mapped_column(ForeignKey(\"projects.id\", ondelete=\"SET NULL\"), nullable=True)\n    archive_id: Mapped[int | None] = mapped_column(ForeignKey(\"print_archives.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationships\n    parent: Mapped[\"LibraryFolder | None\"] = relationship(\n        \"LibraryFolder\",\n        back_populates=\"children\",\n        remote_side=\"LibraryFolder.id\",\n        foreign_keys=\"LibraryFolder.parent_id\",\n    )\n    children: Mapped[list[\"LibraryFolder\"]] = relationship(\n        \"LibraryFolder\",\n        back_populates=\"parent\",\n        foreign_keys=\"LibraryFolder.parent_id\",\n        cascade=\"all, delete-orphan\",\n    )\n    files: Mapped[list[\"LibraryFile\"]] = relationship(\n        back_populates=\"folder\",\n        cascade=\"all, delete-orphan\",\n    )\n    project: Mapped[\"Project | None\"] = relationship()\n    archive: Mapped[\"PrintArchive | None\"] = relationship()\n\n\nclass LibraryFile(Base):\n    \"\"\"File stored in the library.\"\"\"\n\n    __tablename__ = \"library_files\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    folder_id: Mapped[int | None] = mapped_column(ForeignKey(\"library_folders.id\", ondelete=\"CASCADE\"), nullable=True)\n    project_id: Mapped[int | None] = mapped_column(ForeignKey(\"projects.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # External file flag\n    is_external: Mapped[bool] = mapped_column(Boolean, default=False)\n\n    # File info\n    filename: Mapped[str] = mapped_column(String(255))  # Original filename\n    file_path: Mapped[str] = mapped_column(String(500))  # Storage path\n    file_type: Mapped[str] = mapped_column(String(10))  # \"3mf\" or \"gcode\"\n    file_size: Mapped[int] = mapped_column(Integer)\n    file_hash: Mapped[str | None] = mapped_column(String(64))  # SHA256 for duplicate detection\n    thumbnail_path: Mapped[str | None] = mapped_column(String(500))\n\n    # Extracted metadata (from 3MF parser)\n    file_metadata: Mapped[dict | None] = mapped_column(JSON)\n\n    # Usage tracking\n    print_count: Mapped[int] = mapped_column(Integer, default=0)\n    last_printed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n\n    # User notes\n    notes: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # User tracking (Issue #206)\n    created_by_id: Mapped[int | None] = mapped_column(ForeignKey(\"users.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationships\n    folder: Mapped[\"LibraryFolder | None\"] = relationship(back_populates=\"files\")\n    project: Mapped[\"Project | None\"] = relationship()\n    created_by: Mapped[\"User | None\"] = relationship()\n\n\nfrom backend.app.models.archive import PrintArchive  # noqa: E402, F811\nfrom backend.app.models.project import Project  # noqa: E402, F811\nfrom backend.app.models.user import User  # noqa: E402, F811\n"
  },
  {
    "path": "backend/app/models/local_preset.py",
    "content": "\"\"\"Model for locally stored slicer presets (imported from OrcaSlicer, etc.).\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import DateTime, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass LocalPreset(Base):\n    \"\"\"A locally stored slicer preset, typically imported from OrcaSlicer.\"\"\"\n\n    __tablename__ = \"local_presets\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(300))\n    preset_type: Mapped[str] = mapped_column(String(20))  # filament, printer, process\n    source: Mapped[str] = mapped_column(String(50), default=\"orcaslicer\")  # orcaslicer, manual\n\n    # Core fields extracted for filtering / AMS config\n    filament_type: Mapped[str | None] = mapped_column(String(50))\n    filament_vendor: Mapped[str | None] = mapped_column(String(200))\n    nozzle_temp_min: Mapped[int | None] = mapped_column(Integer)\n    nozzle_temp_max: Mapped[int | None] = mapped_column(Integer)\n    pressure_advance: Mapped[str | None] = mapped_column(String(50))\n    default_filament_colour: Mapped[str | None] = mapped_column(String(50))\n    filament_cost: Mapped[str | None] = mapped_column(String(50))\n    filament_density: Mapped[str | None] = mapped_column(String(50))\n    compatible_printers: Mapped[str | None] = mapped_column(Text)  # JSON array\n\n    # Full resolved JSON blob\n    setting: Mapped[str] = mapped_column(Text)\n\n    # Inheritance info\n    inherits: Mapped[str | None] = mapped_column(String(300))\n    version: Mapped[str | None] = mapped_column(String(50))\n\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n"
  },
  {
    "path": "backend/app/models/maintenance.py",
    "content": "\"\"\"Maintenance tracking models.\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Float, ForeignKey, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass MaintenanceType(Base):\n    \"\"\"Defines a type of maintenance task with default interval.\"\"\"\n\n    __tablename__ = \"maintenance_types\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n    description: Mapped[str | None] = mapped_column(Text)\n    default_interval_hours: Mapped[float] = mapped_column(Float, default=100.0)\n    # Interval type: \"hours\" (print hours) or \"days\" (calendar days)\n    interval_type: Mapped[str] = mapped_column(String(20), default=\"hours\")\n    icon: Mapped[str | None] = mapped_column(String(50))  # Icon name for UI\n    wiki_url: Mapped[str | None] = mapped_column(String(500))  # Documentation link\n    is_system: Mapped[bool] = mapped_column(Boolean, default=False)  # Pre-defined vs custom\n    is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)  # Hidden/removed type\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    # Relationships\n    printer_maintenance: Mapped[list[\"PrinterMaintenance\"]] = relationship(\n        back_populates=\"maintenance_type\", cascade=\"all, delete-orphan\"\n    )\n\n\nclass PrinterMaintenance(Base):\n    \"\"\"Tracks maintenance status for a specific printer.\"\"\"\n\n    __tablename__ = \"printer_maintenance\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    printer_id: Mapped[int] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"CASCADE\"))\n    maintenance_type_id: Mapped[int] = mapped_column(ForeignKey(\"maintenance_types.id\", ondelete=\"CASCADE\"))\n\n    # Custom interval for this printer (overrides default if set)\n    custom_interval_hours: Mapped[float | None] = mapped_column(Float, nullable=True)\n    # Custom interval type for this printer (overrides default if set)\n    custom_interval_type: Mapped[str | None] = mapped_column(String(20), nullable=True)\n\n    # Tracking\n    enabled: Mapped[bool] = mapped_column(Boolean, default=True)\n    last_performed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n    last_performed_hours: Mapped[float] = mapped_column(Float, default=0.0)  # Hours at last reset\n\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationships\n    printer: Mapped[\"Printer\"] = relationship(back_populates=\"maintenance_items\")\n    maintenance_type: Mapped[\"MaintenanceType\"] = relationship(back_populates=\"printer_maintenance\")\n    history: Mapped[list[\"MaintenanceHistory\"]] = relationship(\n        back_populates=\"printer_maintenance\", cascade=\"all, delete-orphan\"\n    )\n\n\nclass MaintenanceHistory(Base):\n    \"\"\"Log of maintenance actions performed.\"\"\"\n\n    __tablename__ = \"maintenance_history\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    printer_maintenance_id: Mapped[int] = mapped_column(ForeignKey(\"printer_maintenance.id\", ondelete=\"CASCADE\"))\n    performed_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    hours_at_maintenance: Mapped[float] = mapped_column(Float, default=0.0)\n    notes: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # Relationships\n    printer_maintenance: Mapped[\"PrinterMaintenance\"] = relationship(back_populates=\"history\")\n\n\n# Import at end to avoid circular imports\nfrom backend.app.models.printer import Printer  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/notification.py",
    "content": "\"\"\"Notification provider and log models for push notifications.\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text\nfrom sqlalchemy.orm import relationship\n\nfrom backend.app.core.database import Base\n\n\nclass NotificationDigestQueue(Base):\n    \"\"\"Model for queuing notifications to be sent in daily digest.\"\"\"\n\n    __tablename__ = \"notification_digest_queue\"\n\n    id = Column(Integer, primary_key=True, index=True)\n    provider_id = Column(Integer, ForeignKey(\"notification_providers.id\", ondelete=\"CASCADE\"), nullable=False)\n    event_type = Column(String(50), nullable=False)  # print_start, print_complete, etc.\n    title = Column(String(255), nullable=False)\n    message = Column(Text, nullable=False)\n    printer_id = Column(Integer, ForeignKey(\"printers.id\", ondelete=\"SET NULL\"), nullable=True)\n    printer_name = Column(String(100), nullable=True)\n    created_at = Column(DateTime, default=datetime.utcnow, index=True)\n\n    # Relationships\n    provider = relationship(\"NotificationProvider\", back_populates=\"digest_queue\")\n\n\nclass NotificationLog(Base):\n    \"\"\"Model for logging sent notifications.\"\"\"\n\n    __tablename__ = \"notification_logs\"\n\n    id = Column(Integer, primary_key=True, index=True)\n    provider_id = Column(Integer, ForeignKey(\"notification_providers.id\", ondelete=\"CASCADE\"), nullable=False)\n    event_type = Column(String(50), nullable=False)  # print_start, print_complete, etc.\n    title = Column(String(255), nullable=False)\n    message = Column(Text, nullable=False)\n    success = Column(Boolean, default=True)\n    error_message = Column(Text, nullable=True)\n    printer_id = Column(Integer, ForeignKey(\"printers.id\", ondelete=\"SET NULL\"), nullable=True)\n    printer_name = Column(String(100), nullable=True)  # Store name in case printer is deleted\n    created_at = Column(DateTime, default=datetime.utcnow, index=True)\n\n    # Relationships\n    provider = relationship(\"NotificationProvider\", back_populates=\"logs\")\n\n\nclass NotificationProvider(Base):\n    \"\"\"Model for notification providers (WhatsApp, ntfy, Pushover, etc.).\"\"\"\n\n    __tablename__ = \"notification_providers\"\n\n    id = Column(Integer, primary_key=True, index=True)\n    name = Column(String(100), nullable=False)  # User-defined name\n    provider_type = Column(String(50), nullable=False)  # callmebot, ntfy, pushover, telegram, email\n    enabled = Column(Boolean, default=True)\n\n    # Provider-specific configuration stored as JSON string\n    config = Column(Text, nullable=False)\n\n    # Event triggers - print lifecycle\n    on_print_start = Column(Boolean, default=False)\n    on_print_complete = Column(Boolean, default=True)\n    on_print_failed = Column(Boolean, default=True)\n    on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print\n    on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones\n    on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)\n\n    # Event triggers - printer status\n    on_printer_offline = Column(Boolean, default=False)\n    on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.\n    on_filament_low = Column(Boolean, default=False)\n    on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder\n\n    # Event triggers - AMS environmental alarms (regular AMS with 4 slots)\n    on_ams_humidity_high = Column(Boolean, default=False)  # AMS humidity above threshold\n    on_ams_temperature_high = Column(Boolean, default=False)  # AMS temperature above threshold\n\n    # Event triggers - AMS-HT environmental alarms (single slot heated AMS)\n    on_ams_ht_humidity_high = Column(Boolean, default=False)  # AMS-HT humidity above threshold\n    on_ams_ht_temperature_high = Column(Boolean, default=False)  # AMS-HT temperature above threshold\n\n    # Event triggers - Build plate detection\n    on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print\n\n    # Event triggers - Bed cooled after print\n    on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print\n    on_first_layer_complete = Column(Boolean, default=False)  # First layer finished printing\n\n    # Event triggers - Print queue\n    on_queue_job_added = Column(Boolean, default=False)  # Job added to queue\n    on_queue_job_assigned = Column(Boolean, default=False)  # Model-based job assigned to printer\n    on_queue_job_started = Column(Boolean, default=False)  # Queue job started printing\n    on_queue_job_waiting = Column(Boolean, default=True)  # Job waiting for filament or printer\n    on_queue_job_skipped = Column(Boolean, default=True)  # Job skipped (previous print failed)\n    on_queue_job_failed = Column(Boolean, default=True)  # Job failed to start\n    on_queue_completed = Column(Boolean, default=False)  # All pending jobs finished\n\n    # Quiet hours (do not disturb)\n    quiet_hours_enabled = Column(Boolean, default=False)\n    quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., \"22:00\"\n    quiet_hours_end = Column(String(5), nullable=True)  # HH:MM format, e.g., \"07:00\"\n\n    # Daily digest (batch notifications into a single daily summary)\n    daily_digest_enabled = Column(Boolean, default=False)\n    daily_digest_time = Column(String(5), nullable=True)  # HH:MM format, e.g., \"08:00\"\n\n    # Optional: Link to specific printer (NULL = all printers)\n    printer_id = Column(Integer, ForeignKey(\"printers.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Status tracking\n    last_success = Column(DateTime, nullable=True)\n    last_error = Column(Text, nullable=True)\n    last_error_at = Column(DateTime, nullable=True)\n\n    # Timestamps\n    created_at = Column(DateTime, default=datetime.utcnow)\n    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)\n\n    # Relationships\n    printer = relationship(\"Printer\", back_populates=\"notification_providers\")\n    logs = relationship(\"NotificationLog\", back_populates=\"provider\", cascade=\"all, delete-orphan\")\n    digest_queue = relationship(\"NotificationDigestQueue\", back_populates=\"provider\", cascade=\"all, delete-orphan\")\n"
  },
  {
    "path": "backend/app/models/notification_template.py",
    "content": "\"\"\"Notification template model for customizable notification messages.\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass NotificationTemplate(Base):\n    \"\"\"Model for notification message templates.\"\"\"\n\n    __tablename__ = \"notification_templates\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)\n    event_type: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)\n    name: Mapped[str] = mapped_column(String(100), nullable=False)\n    title_template: Mapped[str] = mapped_column(Text, nullable=False)\n    body_template: Mapped[str] = mapped_column(Text, nullable=False)\n    is_default: Mapped[bool] = mapped_column(Boolean, default=True)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n\n# Default templates for seeding\nDEFAULT_TEMPLATES = [\n    {\n        \"event_type\": \"print_start\",\n        \"name\": \"Print Started\",\n        \"title_template\": \"Print Started\",\n        \"body_template\": \"{printer}: {filename}\\nEstimated: {estimated_time}\",\n    },\n    {\n        \"event_type\": \"print_complete\",\n        \"name\": \"Print Completed\",\n        \"title_template\": \"Print Completed\",\n        \"body_template\": \"{printer}: {filename}\\nTime: {duration}\\nFilament: {filament_grams}g\",\n    },\n    {\n        \"event_type\": \"print_failed\",\n        \"name\": \"Print Failed\",\n        \"title_template\": \"Print Failed\",\n        \"body_template\": \"{printer}: {filename}\\nTime: {duration}\\nReason: {reason}\",\n    },\n    {\n        \"event_type\": \"print_stopped\",\n        \"name\": \"Print Stopped\",\n        \"title_template\": \"Print Stopped\",\n        \"body_template\": \"{printer}: {filename}\\nTime: {duration}\",\n    },\n    {\n        \"event_type\": \"print_progress\",\n        \"name\": \"Print Progress\",\n        \"title_template\": \"Print {progress}% Complete\",\n        \"body_template\": \"{printer}: {filename}\\nRemaining: {remaining_time}\",\n    },\n    {\n        \"event_type\": \"print_missing_spool_assignment\",\n        \"name\": \"Missing Spool Assignment\",\n        \"title_template\": \"Missing Spool Assignment\",\n        \"body_template\": \"{printer}: print started with missing spool assignments\\nSlots: {missing_slots}\\nExpected profile:\\n{missing_slot_details}\",\n    },\n    {\n        \"event_type\": \"printer_offline\",\n        \"name\": \"Printer Offline\",\n        \"title_template\": \"Printer Offline\",\n        \"body_template\": \"{printer} has disconnected\",\n    },\n    {\n        \"event_type\": \"printer_error\",\n        \"name\": \"Printer Error\",\n        \"title_template\": \"Printer Error: {error_type}\",\n        \"body_template\": \"{printer}\\n{error_detail}\",\n    },\n    {\n        \"event_type\": \"plate_not_empty\",\n        \"name\": \"Plate Not Empty\",\n        \"title_template\": \"Plate Not Empty - Print Paused\",\n        \"body_template\": \"{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.\",\n    },\n    {\n        \"event_type\": \"filament_low\",\n        \"name\": \"Filament Low\",\n        \"title_template\": \"Filament Low\",\n        \"body_template\": \"{printer}: Slot {slot} at {remaining_percent}%\",\n    },\n    {\n        \"event_type\": \"maintenance_due\",\n        \"name\": \"Maintenance Due\",\n        \"title_template\": \"Maintenance Due\",\n        \"body_template\": \"{printer}:\\n{items}\",\n    },\n    {\n        \"event_type\": \"ams_humidity_high\",\n        \"name\": \"AMS Humidity High\",\n        \"title_template\": \"AMS Humidity Alert\",\n        \"body_template\": \"{printer} {ams_label}: Humidity {humidity}% exceeds {threshold}% threshold\",\n    },\n    {\n        \"event_type\": \"ams_temperature_high\",\n        \"name\": \"AMS Temperature High\",\n        \"title_template\": \"AMS Temperature Alert\",\n        \"body_template\": \"{printer} {ams_label}: Temperature {temperature}°C exceeds {threshold}°C threshold\",\n    },\n    {\n        \"event_type\": \"bed_cooled\",\n        \"name\": \"Bed Cooled\",\n        \"title_template\": \"Bed Cooled\",\n        \"body_template\": \"{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)\",\n    },\n    {\n        \"event_type\": \"first_layer_complete\",\n        \"name\": \"First Layer Complete\",\n        \"title_template\": \"First Layer Complete\",\n        \"body_template\": \"{printer}: {filename}\\nLayer 1/{total_layers} done\",\n    },\n    {\n        \"event_type\": \"test\",\n        \"name\": \"Test Notification\",\n        \"title_template\": \"Bambuddy Test\",\n        \"body_template\": \"This is a test notification. If you see this, notifications are working!\",\n    },\n    # Queue notifications\n    {\n        \"event_type\": \"queue_job_added\",\n        \"name\": \"Queue Job Added\",\n        \"title_template\": \"Job Queued\",\n        \"body_template\": \"{job_name} added to queue for {target}\",\n    },\n    {\n        \"event_type\": \"queue_job_assigned\",\n        \"name\": \"Queue Job Assigned\",\n        \"title_template\": \"Job Assigned\",\n        \"body_template\": \"{job_name} assigned to {printer} (from Any {target_model} queue)\",\n    },\n    {\n        \"event_type\": \"queue_job_started\",\n        \"name\": \"Queue Job Started\",\n        \"title_template\": \"Queue Job Started\",\n        \"body_template\": \"{printer}: {job_name}\\nEstimated: {estimated_time}\",\n    },\n    {\n        \"event_type\": \"queue_job_waiting\",\n        \"name\": \"Queue Job Waiting\",\n        \"title_template\": \"Queue Job Waiting\",\n        \"body_template\": \"{job_name} waiting for {target_model}\\n{waiting_reason}\",\n    },\n    {\n        \"event_type\": \"queue_job_skipped\",\n        \"name\": \"Queue Job Skipped\",\n        \"title_template\": \"Job Skipped\",\n        \"body_template\": \"{printer}: {job_name}\\nReason: {reason}\",\n    },\n    {\n        \"event_type\": \"queue_job_failed\",\n        \"name\": \"Queue Job Failed\",\n        \"title_template\": \"Job Failed to Start\",\n        \"body_template\": \"{printer}: {job_name}\\nReason: {reason}\",\n    },\n    {\n        \"event_type\": \"queue_completed\",\n        \"name\": \"Queue Completed\",\n        \"title_template\": \"Queue Complete\",\n        \"body_template\": \"All {completed_count} queued jobs have finished\",\n    },\n    {\n        \"event_type\": \"user_created\",\n        \"name\": \"Welcome Email\",\n        \"title_template\": \"Welcome to {app_name}\",\n        \"body_template\": \"Welcome {username}!\\n\\nYour account has been created.\\nUsername: {username}\\nPassword: {password}\\n\\nLogin at: {login_url}\",\n    },\n    {\n        \"event_type\": \"password_reset\",\n        \"name\": \"Password Reset\",\n        \"title_template\": \"{app_name} - Password Reset\",\n        \"body_template\": \"Hello {username},\\n\\nYour password has been reset.\\nNew Password: {password}\\n\\nLogin at: {login_url}\",\n    },\n    # User email notification templates (sent to the print job owner)\n    {\n        \"event_type\": \"user_print_start\",\n        \"name\": \"User Print Started\",\n        \"title_template\": \"Your Print Has Started\",\n        \"body_template\": \"Hello {username},\\n\\nYour print job has started on {printer}.\\n\\nFile: {filename}\\n\\nYou will be notified when it completes.\",\n    },\n    {\n        \"event_type\": \"user_print_complete\",\n        \"name\": \"User Print Completed\",\n        \"title_template\": \"Your Print Is Complete\",\n        \"body_template\": \"Hello {username},\\n\\nYour print job has completed on {printer}.\\n\\nFile: {filename}\",\n    },\n    {\n        \"event_type\": \"user_print_failed\",\n        \"name\": \"User Print Failed\",\n        \"title_template\": \"Your Print Has Failed\",\n        \"body_template\": \"Hello {username},\\n\\nYour print job has failed on {printer}.\\n\\nFile: {filename}\",\n    },\n    {\n        \"event_type\": \"user_print_stopped\",\n        \"name\": \"User Print Stopped\",\n        \"title_template\": \"Your Print Has Been Stopped\",\n        \"body_template\": \"Hello {username},\\n\\nYour print job was stopped on {printer}.\\n\\nFile: {filename}\",\n    },\n]\n"
  },
  {
    "path": "backend/app/models/oidc_provider.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\nfrom backend.app.core.encryption import mfa_decrypt, mfa_encrypt\n\n\nclass OIDCProvider(Base):\n    \"\"\"OpenID Connect provider configuration.\n\n    Supports any standards-compliant OIDC provider such as PocketID,\n    Authentik, Keycloak, Authelia, Google, etc.\n\n    The issuer_url must point to the root issuer (e.g. ``https://id.example.com``).\n    The OIDC discovery document is fetched from\n    ``{issuer_url}/.well-known/openid-configuration`` at runtime.\n    \"\"\"\n\n    __tablename__ = \"oidc_providers\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    # Human-readable name shown on the login button (e.g. \"PocketID\", \"Google\")\n    name: Mapped[str] = mapped_column(String(100), unique=True)\n    # Full OIDC issuer URL (e.g. \"https://id.example.com\")\n    issuer_url: Mapped[str] = mapped_column(String(500))\n    client_id: Mapped[str] = mapped_column(String(255))\n    # Encrypted at rest when MFA_ENCRYPTION_KEY is set.\n    # Use .client_secret / .client_secret setter rather than _client_secret_enc directly.\n    _client_secret_enc: Mapped[str] = mapped_column(\"client_secret\", String(512))\n\n    @property\n    def client_secret(self) -> str:\n        return mfa_decrypt(self._client_secret_enc)\n\n    @client_secret.setter\n    def client_secret(self, value: str) -> None:\n        self._client_secret_enc = mfa_encrypt(value)\n\n    # Space-separated scopes; must include \"openid\"\n    scopes: Mapped[str] = mapped_column(String(500), default=\"openid email profile\")\n    is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)\n    # When True, a new local user is created automatically on first OIDC login\n    auto_create_users: Mapped[bool] = mapped_column(Boolean, default=False)\n    # When True, an existing local user whose email matches the OIDC claim is\n    # automatically linked on first SSO login.  Default is False (conservative):\n    # operators must explicitly opt-in to prevent an attacker-controlled IdP from\n    # silently hijacking local accounts via email matching (M-2 fix).\n    auto_link_existing_accounts: Mapped[bool] = mapped_column(Boolean, default=False)\n    # Optional icon URL (SVG/PNG) shown on the login button\n    icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationship to linked user accounts\n    user_links: Mapped[list[UserOIDCLink]] = relationship(\n        \"UserOIDCLink\",\n        back_populates=\"provider\",\n        cascade=\"all, delete-orphan\",\n    )\n\n    def __repr__(self) -> str:\n        return f\"<OIDCProvider {self.name!r}>\"\n\n\nclass UserOIDCLink(Base):\n    \"\"\"Links a local Bambuddy user account to an identity at an OIDC provider.\"\"\"\n\n    __tablename__ = \"user_oidc_links\"\n    __table_args__ = (\n        # T2: Prevent duplicate OIDC identities and duplicate provider links.\n        # (provider_id, provider_user_id) — one OIDC sub per provider maps to at most one local user.\n        UniqueConstraint(\"provider_id\", \"provider_user_id\", name=\"uq_oidc_link_provider_sub\"),\n        # (user_id, provider_id) — one local user can link to each provider at most once.\n        UniqueConstraint(\"user_id\", \"provider_id\", name=\"uq_oidc_link_user_provider\"),\n    )\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    user_id: Mapped[int] = mapped_column(Integer, ForeignKey(\"users.id\", ondelete=\"CASCADE\"), index=True)\n    provider_id: Mapped[int] = mapped_column(Integer, ForeignKey(\"oidc_providers.id\", ondelete=\"CASCADE\"), index=True)\n    # The \"sub\" claim from the OIDC ID token — stable identifier for the user\n    provider_user_id: Mapped[str] = mapped_column(String(500))\n    # Email returned by the provider (informational; may differ from local email)\n    provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    provider: Mapped[OIDCProvider] = relationship(\"OIDCProvider\", back_populates=\"user_links\")\n\n    def __repr__(self) -> str:\n        return f\"<UserOIDCLink user_id={self.user_id} provider_id={self.provider_id}>\"\n"
  },
  {
    "path": "backend/app/models/orca_base_cache.py",
    "content": "\"\"\"Cache model for OrcaSlicer base profiles fetched from GitHub.\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import DateTime, Index, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass OrcaBaseProfile(Base):\n    \"\"\"Cached OrcaSlicer base profile from GitHub for inheritance resolution.\"\"\"\n\n    __tablename__ = \"orca_base_profiles\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(300))\n    profile_type: Mapped[str] = mapped_column(String(20))  # filament, machine, process\n    setting: Mapped[str] = mapped_column(Text)  # Full JSON\n    fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    __table_args__ = (Index(\"ix_orca_base_profiles_name\", \"name\", unique=True),)\n"
  },
  {
    "path": "backend/app/models/pending_upload.py",
    "content": "\"\"\"Pending upload model for virtual printer queue mode.\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass PendingUpload(Base):\n    \"\"\"Pending upload from virtual printer awaiting user review.\"\"\"\n\n    __tablename__ = \"pending_uploads\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n\n    # File info\n    filename: Mapped[str] = mapped_column(String(255))\n    file_path: Mapped[str] = mapped_column(String(500))  # Temp storage path\n    file_size: Mapped[int] = mapped_column(Integer)\n\n    # Source info\n    source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)\n\n    # Status: pending, archived, discarded\n    status: Mapped[str] = mapped_column(String(20), default=\"pending\")\n\n    # User additions (before archiving)\n    tags: Mapped[str | None] = mapped_column(Text, nullable=True)\n    notes: Mapped[str | None] = mapped_column(Text, nullable=True)\n    project_id: Mapped[int | None] = mapped_column(ForeignKey(\"projects.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # After archiving - link to created archive\n    archived_id: Mapped[int | None] = mapped_column(ForeignKey(\"print_archives.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Timestamps\n    uploaded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n\n    # Relationships\n    project: Mapped[\"Project | None\"] = relationship()\n    archive: Mapped[\"PrintArchive | None\"] = relationship()\n\n\nfrom backend.app.models.archive import PrintArchive  # noqa: E402\nfrom backend.app.models.project import Project  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/print_batch.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, ForeignKey, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass PrintBatch(Base):\n    \"\"\"Batch grouping for multiple queue items created from the same file.\"\"\"\n\n    __tablename__ = \"print_batches\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(255))\n\n    # Source file (one of these)\n    archive_id: Mapped[int | None] = mapped_column(ForeignKey(\"print_archives.id\", ondelete=\"SET NULL\"), nullable=True)\n    library_file_id: Mapped[int | None] = mapped_column(\n        ForeignKey(\"library_files.id\", ondelete=\"SET NULL\"), nullable=True\n    )\n\n    # Total requested quantity (for display — actual items may differ if cancelled)\n    quantity: Mapped[int] = mapped_column(Integer, default=1)\n\n    # Status: active, completed, cancelled\n    status: Mapped[str] = mapped_column(String(20), default=\"active\")\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    # User tracking\n    created_by_id: Mapped[int | None] = mapped_column(ForeignKey(\"users.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Relationships\n    archive: Mapped[\"PrintArchive | None\"] = relationship()\n    library_file: Mapped[\"LibraryFile | None\"] = relationship()\n    created_by: Mapped[\"User | None\"] = relationship()\n    queue_items: Mapped[list[\"PrintQueueItem\"]] = relationship(back_populates=\"batch\")\n\n\nfrom backend.app.models.archive import PrintArchive  # noqa: E402\nfrom backend.app.models.library import LibraryFile  # noqa: E402\nfrom backend.app.models.print_queue import PrintQueueItem  # noqa: E402\nfrom backend.app.models.user import User  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/print_log.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, Float, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass PrintLogEntry(Base):\n    \"\"\"Independent print log entry. Written when print events occur.\n\n    This is a separate table from archives/queue — clearing the log\n    never touches archives or queue items.\n    \"\"\"\n\n    __tablename__ = \"print_log_entries\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    print_name: Mapped[str | None] = mapped_column(String(255))\n    printer_name: Mapped[str | None] = mapped_column(String(255))\n    printer_id: Mapped[int | None] = mapped_column(Integer)\n    status: Mapped[str] = mapped_column(String(20))  # completed, failed, stopped, cancelled, skipped\n    started_at: Mapped[datetime | None] = mapped_column(DateTime)\n    completed_at: Mapped[datetime | None] = mapped_column(DateTime)\n    duration_seconds: Mapped[int | None] = mapped_column(Integer)\n    filament_type: Mapped[str | None] = mapped_column(String(50))\n    filament_color: Mapped[str | None] = mapped_column(String(50))\n    filament_used_grams: Mapped[float | None] = mapped_column(Float)\n    thumbnail_path: Mapped[str | None] = mapped_column(String(500))\n    created_by_username: Mapped[str | None] = mapped_column(String(100))\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n"
  },
  {
    "path": "backend/app/models/print_queue.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass PrintQueueItem(Base):\n    \"\"\"Print queue item for scheduled/queued prints.\"\"\"\n\n    __tablename__ = \"print_queue\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n\n    # Links\n    printer_id: Mapped[int | None] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"CASCADE\"), nullable=True)\n    # Target printer model for model-based assignment (mutually exclusive with printer_id)\n    # When set, scheduler assigns to any idle printer of matching model\n    target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)\n    # Target location filter for model-based assignment (only used with target_model)\n    # When set, only printers in this location are considered\n    target_location: Mapped[str | None] = mapped_column(String(100), nullable=True)\n    # Required filament types for model-based assignment (JSON array, e.g., '[\"PLA\", \"PETG\"]')\n    # Used by scheduler to validate printer has compatible filaments loaded\n    required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)\n    # Waiting reason - explains why a model-based job hasn't started yet\n    # Set by scheduler when no matching printer is available\n    waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)\n    # Either archive_id OR library_file_id must be set (archive created at print start from library file)\n    archive_id: Mapped[int | None] = mapped_column(ForeignKey(\"print_archives.id\", ondelete=\"CASCADE\"), nullable=True)\n    library_file_id: Mapped[int | None] = mapped_column(\n        ForeignKey(\"library_files.id\", ondelete=\"CASCADE\"), nullable=True\n    )\n    project_id: Mapped[int | None] = mapped_column(ForeignKey(\"projects.id\", ondelete=\"SET NULL\"), nullable=True)\n    batch_id: Mapped[int | None] = mapped_column(ForeignKey(\"print_batches.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Scheduling\n    position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order\n    scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # None = ASAP\n    manual_start: Mapped[bool] = mapped_column(Boolean, default=False)  # Requires manual trigger to start\n\n    # Conditions\n    require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)\n\n    # Power management\n    auto_off_after: Mapped[bool] = mapped_column(Boolean, default=False)  # Power off printer after print\n\n    # AMS mapping: JSON array of global tray IDs for each filament slot\n    # Format: \"[5, -1, 2, -1]\" where position = slot_id-1, value = global tray ID (-1 = unused)\n    ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # Filament overrides for model-based assignment: JSON array of override objects\n    # Format: '[{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FFFFFF\"}]'\n    # Only slots with overrides are included (sparse). null = use original 3MF values.\n    filament_overrides: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)\n    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)\n\n    # Shortest-job-first scheduling\n    print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Cached from archive/library\n    been_jumped: Mapped[bool] = mapped_column(Boolean, default=False)  # Starvation guard for SJF\n\n    # Auto-print G-code injection (#422)\n    gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)\n\n    # Print options\n    bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)\n    flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)\n    vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)\n    layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)\n    timelapse: Mapped[bool] = mapped_column(Boolean, default=False)\n    use_ams: Mapped[bool] = mapped_column(Boolean, default=True)\n\n    # Status: pending, printing, completed, failed, skipped, cancelled\n    status: Mapped[str] = mapped_column(String(20), default=\"pending\")\n\n    # Tracking\n    started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    # User tracking (who added this to the queue)\n    created_by_id: Mapped[int | None] = mapped_column(ForeignKey(\"users.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Relationships\n    printer: Mapped[\"Printer\"] = relationship()\n    archive: Mapped[\"PrintArchive | None\"] = relationship()\n    library_file: Mapped[\"LibraryFile | None\"] = relationship()\n    project: Mapped[\"Project | None\"] = relationship(back_populates=\"queue_items\")\n    batch: Mapped[\"PrintBatch | None\"] = relationship(back_populates=\"queue_items\")\n    created_by: Mapped[\"User | None\"] = relationship()\n\n\nfrom backend.app.models.archive import PrintArchive  # noqa: E402\nfrom backend.app.models.library import LibraryFile  # noqa: E402\nfrom backend.app.models.print_batch import PrintBatch  # noqa: E402\nfrom backend.app.models.printer import Printer  # noqa: E402\nfrom backend.app.models.project import Project  # noqa: E402\nfrom backend.app.models.user import User  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/printer.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Float, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass Printer(Base):\n    __tablename__ = \"printers\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n    serial_number: Mapped[str] = mapped_column(String(50), unique=True)\n    ip_address: Mapped[str] = mapped_column(String(253))\n    access_code: Mapped[str] = mapped_column(String(20))\n    model: Mapped[str | None] = mapped_column(String(50))\n    location: Mapped[str | None] = mapped_column(String(100))  # Group/location name\n    nozzle_count: Mapped[int] = mapped_column(default=1)  # 1 or 2, auto-detected from MQTT\n    is_active: Mapped[bool] = mapped_column(Boolean, default=True)\n    auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)\n    print_hours_offset: Mapped[float] = mapped_column(Float, default=0.0)  # Baseline hours to add\n    runtime_seconds: Mapped[int] = mapped_column(default=0)  # Accumulated active runtime (RUNNING/PAUSE states)\n    last_runtime_update: Mapped[datetime | None] = mapped_column(\n        DateTime, nullable=True\n    )  # Last time runtime was updated\n    # External camera configuration\n    external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)\n    external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True)  # mjpeg, rtsp, snapshot\n    external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)\n    camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees\n    # Plate detection - check if build plate is empty before starting print\n    plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)\n    # ROI for plate detection (percentages: 0.0-1.0)\n    plate_detection_roi_x: Mapped[float | None] = mapped_column(Float, nullable=True)  # X start %\n    plate_detection_roi_y: Mapped[float | None] = mapped_column(Float, nullable=True)  # Y start %\n    plate_detection_roi_w: Mapped[float | None] = mapped_column(Float, nullable=True)  # Width %\n    plate_detection_roi_h: Mapped[float | None] = mapped_column(Float, nullable=True)  # Height %\n    # Queue: True after a print finishes/fails, until user acknowledges the plate is cleared.\n    # Persisted so the gate survives crashes and power cycles (issue #961).\n    awaiting_plate_clear: Mapped[bool] = mapped_column(Boolean, default=False)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationships\n    archives: Mapped[list[\"PrintArchive\"]] = relationship(back_populates=\"printer\", cascade=\"all, delete-orphan\")\n    smart_plugs: Mapped[list[\"SmartPlug\"]] = relationship(back_populates=\"printer\")\n    notification_providers: Mapped[list[\"NotificationProvider\"]] = relationship(back_populates=\"printer\")\n    maintenance_items: Mapped[list[\"PrinterMaintenance\"]] = relationship(\n        back_populates=\"printer\", cascade=\"all, delete-orphan\"\n    )\n    kprofile_notes: Mapped[list[\"KProfileNote\"]] = relationship(back_populates=\"printer\", cascade=\"all, delete-orphan\")\n    ams_history: Mapped[list[\"AMSSensorHistory\"]] = relationship(back_populates=\"printer\", cascade=\"all, delete-orphan\")\n\n\nfrom backend.app.models.ams_history import AMSSensorHistory  # noqa: E402\nfrom backend.app.models.archive import PrintArchive  # noqa: E402\nfrom backend.app.models.kprofile_note import KProfileNote  # noqa: E402\nfrom backend.app.models.maintenance import PrinterMaintenance  # noqa: E402\nfrom backend.app.models.notification import NotificationProvider  # noqa: E402\nfrom backend.app.models.smart_plug import SmartPlug  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/project.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass Project(Base):\n    \"\"\"Project to group related prints (e.g., 'Voron Build' with multiple parts).\"\"\"\n\n    __tablename__ = \"projects\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(255))\n    description: Mapped[str | None] = mapped_column(Text, nullable=True)\n    color: Mapped[str | None] = mapped_column(String(20), nullable=True)  # Hex color for UI\n    status: Mapped[str] = mapped_column(String(20), default=\"active\")  # active, completed, archived\n    target_count: Mapped[int | None] = mapped_column(\n        Integer, nullable=True\n    )  # Optional target number of prints (plates)\n    target_parts_count: Mapped[int | None] = mapped_column(\n        Integer, nullable=True\n    )  # Optional target number of parts/objects\n\n    # Phase 2: Rich text notes (HTML from WYSIWYG editor)\n    notes: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # Phase 3: File attachments stored as JSON array\n    # Format: [{\"filename\": \"x.stl\", \"original_name\": \"part.stl\", \"size\": 1234, \"uploaded_at\": \"...\"}]\n    attachments: Mapped[list | None] = mapped_column(JSON, nullable=True)\n\n    # Phase 4: Tags (comma-separated)\n    tags: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # Phase 5: Due dates and priority\n    due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n    priority: Mapped[str] = mapped_column(String(20), default=\"normal\")  # low, normal, high, urgent\n\n    # Phase 6: Budget tracking\n    budget: Mapped[float | None] = mapped_column(Float, nullable=True)\n\n    # Phase 8: Templates\n    is_template: Mapped[bool] = mapped_column(Boolean, default=False)\n    template_source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)\n\n    # Phase 10: Sub-projects (hierarchical)\n    parent_id: Mapped[int | None] = mapped_column(ForeignKey(\"projects.id\"), nullable=True)\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationships\n    archives: Mapped[list[\"PrintArchive\"]] = relationship(back_populates=\"project\")\n    queue_items: Mapped[list[\"PrintQueueItem\"]] = relationship(back_populates=\"project\")\n    children: Mapped[list[\"Project\"]] = relationship(\n        \"Project\",\n        back_populates=\"parent\",\n        foreign_keys=\"Project.parent_id\",\n    )\n    parent: Mapped[\"Project | None\"] = relationship(\n        \"Project\",\n        back_populates=\"children\",\n        remote_side=\"Project.id\",\n        foreign_keys=\"Project.parent_id\",\n    )\n    bom_items: Mapped[list[\"ProjectBOMItem\"]] = relationship(back_populates=\"project\", cascade=\"all, delete-orphan\")\n\n\nfrom backend.app.models.archive import PrintArchive  # noqa: E402\nfrom backend.app.models.print_queue import PrintQueueItem  # noqa: E402\nfrom backend.app.models.project_bom import ProjectBOMItem  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/project_bom.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass ProjectBOMItem(Base):\n    \"\"\"Bill of Materials item for a project.\n\n    Tracks sourced/purchased parts (hardware, electronics, screws, etc.)\n    that need to be acquired for a project.\n    \"\"\"\n\n    __tablename__ = \"project_bom_items\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    project_id: Mapped[int] = mapped_column(ForeignKey(\"projects.id\", ondelete=\"CASCADE\"))\n    name: Mapped[str] = mapped_column(String(255))\n    quantity_needed: Mapped[int] = mapped_column(Integer, default=1)\n    quantity_acquired: Mapped[int] = mapped_column(Integer, default=0)\n\n    # Sourcing information\n    unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)\n    sourcing_url: Mapped[str | None] = mapped_column(String(512), nullable=True)\n\n    # Optional link to archive (for reference)\n    archive_id: Mapped[int | None] = mapped_column(ForeignKey(\"print_archives.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Reference to attachment filename\n    stl_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)\n\n    # Remarks about this part\n    remarks: Mapped[str | None] = mapped_column(Text, nullable=True)\n\n    # Sort order\n    sort_order: Mapped[int] = mapped_column(Integer, default=0)\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationships\n    project: Mapped[\"Project\"] = relationship(back_populates=\"bom_items\")\n    archive: Mapped[\"PrintArchive | None\"] = relationship()\n\n\nfrom backend.app.models.archive import PrintArchive  # noqa: E402\nfrom backend.app.models.project import Project  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/settings.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass Settings(Base):\n    \"\"\"App settings stored as key-value pairs.\"\"\"\n\n    __tablename__ = \"settings\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    key: Mapped[str] = mapped_column(String(100), unique=True, index=True)\n    value: Mapped[str] = mapped_column(Text)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n"
  },
  {
    "path": "backend/app/models/slot_preset.py",
    "content": "\"\"\"Model for storing AMS slot to filament preset mappings.\n\nThis stores the user's preferred filament preset for each AMS slot,\nsimilar to how Bambu Studio remembers preset selections.\n\"\"\"\n\nfrom datetime import datetime\n\nfrom sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass SlotPresetMapping(Base):\n    \"\"\"Maps an AMS slot to a cloud filament preset.\"\"\"\n\n    __tablename__ = \"slot_preset_mappings\"\n    __table_args__ = (UniqueConstraint(\"printer_id\", \"ams_id\", \"tray_id\", name=\"uq_slot_preset\"),)\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    printer_id: Mapped[int] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"CASCADE\"))\n    ams_id: Mapped[int] = mapped_column(Integer)  # AMS unit ID (0, 1, 2, 3)\n    tray_id: Mapped[int] = mapped_column(Integer)  # Tray ID within AMS (0-3)\n    preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id\n    preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display\n    preset_source: Mapped[str] = mapped_column(String(20), default=\"cloud\")  # cloud or local\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationship\n    printer: Mapped[\"Printer\"] = relationship()\n\n\nfrom backend.app.models.printer import Printer  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/smart_plug.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass SmartPlug(Base):\n    \"\"\"Smart plug for printer power control (Tasmota, Home Assistant, MQTT, or REST).\"\"\"\n\n    __tablename__ = \"smart_plugs\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(100))\n    ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)\n\n    # Plug type: \"tasmota\" (default), \"homeassistant\", \"mqtt\", or \"rest\"\n    plug_type: Mapped[str] = mapped_column(String(20), default=\"tasmota\")\n    # Home Assistant entity ID (e.g., \"switch.printer_plug\")\n    ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)\n    # Home Assistant energy sensor entities (optional, for separate energy sensors)\n    ha_power_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_power\n    ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_today\n    ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_total\n\n    # MQTT plug fields (required when plug_type=\"mqtt\")\n    # Legacy field - kept for backward compatibility, now use mqtt_power_topic\n    mqtt_topic: Mapped[str | None] = mapped_column(\n        String(200), nullable=True\n    )  # e.g., \"zigbee2mqtt/shelly-working-room\" (deprecated, use mqtt_power_topic)\n\n    # Power monitoring\n    mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for power data\n    mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., \"power_l1\" or \"data.power\"\n    mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for power\n\n    # Energy monitoring\n    mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for energy data\n    mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., \"energy_l1\"\n    mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for energy\n\n    # State monitoring\n    mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for state data\n    mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., \"state_l1\" for ON/OFF\n    mqtt_state_on_value: Mapped[str | None] = mapped_column(\n        String(50), nullable=True\n    )  # What value means \"ON\" (e.g., \"ON\", \"true\", \"1\")\n\n    # Legacy multiplier - kept for backward compatibility\n    mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Deprecated, use mqtt_power_multiplier\n\n    # REST/Webhook plug fields (required when plug_type=\"rest\")\n    # Control URLs\n    rest_on_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Full URL to turn ON\n    rest_on_body: Mapped[str | None] = mapped_column(Text, nullable=True)  # Request body for ON\n    rest_off_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Full URL to turn OFF\n    rest_off_body: Mapped[str | None] = mapped_column(Text, nullable=True)  # Request body for OFF\n    rest_method: Mapped[str | None] = mapped_column(String(10), nullable=True)  # HTTP method: POST, PUT, GET\n    rest_headers: Mapped[str | None] = mapped_column(Text, nullable=True)  # JSON string of custom headers\n    # Status polling (optional)\n    rest_status_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # GET endpoint for state\n    rest_status_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path to state value\n    rest_status_on_value: Mapped[str | None] = mapped_column(String(50), nullable=True)  # What value means ON\n    # Energy monitoring (optional — can use separate URLs or extract from status response)\n    rest_power_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for power data\n    rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for power (watts)\n    rest_power_multiplier: Mapped[float] = mapped_column(Float, server_default=\"1.0\")  # Unit conversion for power\n    rest_energy_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for energy data\n    rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for energy (kWh)\n    rest_energy_multiplier: Mapped[float] = mapped_column(\n        Float, server_default=\"1.0\"\n    )  # Unit conversion (e.g., 0.001 for Wh→kWh)\n\n    # Link to printer (multiple plugs/scripts can be linked to one printer)\n    printer_id: Mapped[int | None] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"SET NULL\"), nullable=True)\n\n    # Automation settings\n    enabled: Mapped[bool] = mapped_column(Boolean, default=True)\n    auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start\n    auto_off: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn off at print complete/fail\n    auto_off_persistent: Mapped[bool] = mapped_column(Boolean, default=False)  # Keep auto-off enabled between prints\n\n    # Turn-off delay mode: \"time\" or \"temperature\"\n    off_delay_mode: Mapped[str] = mapped_column(String(20), default=\"time\")\n    off_delay_minutes: Mapped[int] = mapped_column(Integer, default=5)  # For time mode\n    off_temp_threshold: Mapped[int] = mapped_column(Integer, default=70)  # For temp mode (°C)\n\n    # Optional auth (some Tasmota configs require it)\n    username: Mapped[str | None] = mapped_column(String(50), nullable=True)\n    password: Mapped[str | None] = mapped_column(String(100), nullable=True)\n\n    # Power alerts\n    power_alert_enabled: Mapped[bool] = mapped_column(Boolean, default=False)\n    power_alert_high: Mapped[float | None] = mapped_column(Float, nullable=True)  # Alert when power > this (watts)\n    power_alert_low: Mapped[float | None] = mapped_column(Float, nullable=True)  # Alert when power < this (watts)\n    power_alert_last_triggered: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # Cooldown tracking\n\n    # Schedule (time-based on/off)\n    schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)\n    schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # \"HH:MM\" format\n    schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # \"HH:MM\" format\n\n    # Switchbar visibility\n    show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)\n\n    # Printer card visibility (for scripts)\n    show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True)\n\n    # Status tracking\n    last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # \"ON\"/\"OFF\"\n    last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)\n    auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False)  # True when auto-off was triggered\n    auto_off_pending: Mapped[bool] = mapped_column(Boolean, default=False)  # True when waiting for cooldown\n    auto_off_pending_since: Mapped[datetime | None] = mapped_column(\n        DateTime, nullable=True\n    )  # When auto-off was scheduled\n\n    # Timestamps\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationship\n    printer: Mapped[\"Printer\"] = relationship(back_populates=\"smart_plugs\")\n\n\nfrom backend.app.models.printer import Printer  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/smart_plug_energy_snapshot.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, Float, ForeignKey, Index, Integer\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass SmartPlugEnergySnapshot(Base):\n    \"\"\"Hourly snapshot of a smart plug's lifetime energy counter.\n\n    Powers date-range queries in \"total consumption\" energy mode. For a given\n    range we sum `(last_snapshot_in_range - last_snapshot_before_range)` per plug.\n    \"\"\"\n\n    __tablename__ = \"smart_plug_energy_snapshots\"\n    __table_args__ = (Index(\"ix_plug_energy_snapshots_plug_time\", \"plug_id\", \"recorded_at\"),)\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True)\n    plug_id: Mapped[int] = mapped_column(ForeignKey(\"smart_plugs.id\", ondelete=\"CASCADE\"), nullable=False)\n    recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)\n    lifetime_kwh: Mapped[float] = mapped_column(Float, nullable=False)\n"
  },
  {
    "path": "backend/app/models/spool.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Float, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass Spool(Base):\n    \"\"\"Spool inventory item for tracking filament spools and their properties.\"\"\"\n\n    __tablename__ = \"spool\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    material: Mapped[str] = mapped_column(String(50))  # PLA, PETG, ABS, etc.\n    subtype: Mapped[str | None] = mapped_column(String(50))  # Basic, Matte, Silk, etc.\n    color_name: Mapped[str | None] = mapped_column(String(100))  # \"Jade White\"\n    rgba: Mapped[str | None] = mapped_column(String(8))  # RRGGBBAA hex\n    brand: Mapped[str | None] = mapped_column(String(100))  # \"Polymaker\"\n    label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)\n    core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)\n    core_weight_catalog_id: Mapped[int | None] = mapped_column(\n        Integer\n    )  # Reference to spool_catalog entry for core weight\n    weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams\n    weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync\n    last_scale_weight: Mapped[int | None] = mapped_column(Integer)  # Last gross weight from scale (g)\n    last_weighed_at: Mapped[datetime | None] = mapped_column(DateTime)  # When last weighed\n    slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. \"GFL99\")\n    slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer\n    nozzle_temp_min: Mapped[int | None] = mapped_column()  # Override min temp\n    nozzle_temp_max: Mapped[int | None] = mapped_column()  # Override max temp\n    note: Mapped[str | None] = mapped_column(String(500))\n    added_full: Mapped[bool | None] = mapped_column()  # Whether spool was added as full (unused)\n\n    # Cost tracking\n    cost_per_kg: Mapped[float | None] = mapped_column(Float)  # Cost per kilogram\n\n    last_used: Mapped[datetime | None] = mapped_column(DateTime)  # Last time this spool was used in a print\n    encode_time: Mapped[datetime | None] = mapped_column(DateTime)  # When spool was encoded/written to tag\n    tag_uid: Mapped[str | None] = mapped_column(String(16))  # RFID tag UID (16 hex chars)\n    tray_uuid: Mapped[str | None] = mapped_column(String(32))  # Bambu Lab spool UUID (32 hex chars)\n    data_origin: Mapped[str | None] = mapped_column(String(20))  # How data was populated: manual, rfid_auto, nfc_link\n    tag_type: Mapped[str | None] = mapped_column(String(20))  # Tag vendor: bambulab, generic, etc.\n    archived_at: Mapped[datetime | None] = mapped_column(DateTime)  # NULL = active\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    k_profiles: Mapped[list[\"SpoolKProfile\"]] = relationship(back_populates=\"spool\", cascade=\"all, delete-orphan\")\n    assignments: Mapped[list[\"SpoolAssignment\"]] = relationship(back_populates=\"spool\", cascade=\"all, delete-orphan\")\n\n\nfrom backend.app.models.spool_assignment import SpoolAssignment  # noqa: E402\nfrom backend.app.models.spool_k_profile import SpoolKProfile  # noqa: E402\n"
  },
  {
    "path": "backend/app/models/spool_assignment.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass SpoolAssignment(Base):\n    \"\"\"Assignment of a spool to a specific AMS slot on a printer.\"\"\"\n\n    __tablename__ = \"spool_assignment\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    spool_id: Mapped[int] = mapped_column(ForeignKey(\"spool.id\", ondelete=\"CASCADE\"))\n    printer_id: Mapped[int] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"CASCADE\"))\n    ams_id: Mapped[int] = mapped_column(Integer)  # 0-3, 128+ (HT), 254/255 (ext)\n    tray_id: Mapped[int] = mapped_column(Integer)  # 0-3\n    fingerprint_color: Mapped[str | None] = mapped_column(String(8))  # tray_color snapshot\n    fingerprint_type: Mapped[str | None] = mapped_column(String(50))  # tray_type snapshot\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    spool: Mapped[\"Spool\"] = relationship(back_populates=\"assignments\")\n    printer: Mapped[\"Printer\"] = relationship()\n\n    __table_args__ = (UniqueConstraint(\"printer_id\", \"ams_id\", \"tray_id\"),)\n\n    @property\n    def printer_name(self) -> str | None:\n        \"\"\"Get printer name from loaded relationship.\"\"\"\n        return self.printer.name if self.printer else None\n\n\nfrom backend.app.models.printer import Printer  # noqa: E402, F401\nfrom backend.app.models.spool import Spool  # noqa: E402, F401\n"
  },
  {
    "path": "backend/app/models/spool_catalog.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass SpoolCatalogEntry(Base):\n    \"\"\"Spool weight catalog entry for weight lookup when adding spools.\"\"\"\n\n    __tablename__ = \"spool_catalog\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(200))\n    weight: Mapped[int] = mapped_column(Integer)\n    is_default: Mapped[bool] = mapped_column(Boolean, default=False)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n"
  },
  {
    "path": "backend/app/models/spool_k_profile.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\n\nclass SpoolKProfile(Base):\n    \"\"\"K-value calibration profile for a spool on a specific printer/nozzle combo.\"\"\"\n\n    __tablename__ = \"spool_k_profile\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    spool_id: Mapped[int] = mapped_column(ForeignKey(\"spool.id\", ondelete=\"CASCADE\"))\n    printer_id: Mapped[int] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"CASCADE\"))\n    extruder: Mapped[int] = mapped_column(Integer, default=0)  # 0 or 1 (H2D)\n    nozzle_diameter: Mapped[str] = mapped_column(String(10), default=\"0.4\")  # \"0.4\", \"0.6\"\n    nozzle_type: Mapped[str | None] = mapped_column(String(50))\n    k_value: Mapped[float] = mapped_column(Float)  # e.g. 0.020\n    name: Mapped[str | None] = mapped_column(String(100))  # Profile display name\n    cali_idx: Mapped[int | None] = mapped_column(Integer)  # Calibration index on printer\n    setting_id: Mapped[str | None] = mapped_column(String(50))  # Full setting ID\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    spool: Mapped[\"Spool\"] = relationship(back_populates=\"k_profiles\")\n    printer: Mapped[\"Printer\"] = relationship()\n\n\nfrom backend.app.models.printer import Printer  # noqa: E402, F401\nfrom backend.app.models.spool import Spool  # noqa: E402, F401\n"
  },
  {
    "path": "backend/app/models/spool_usage_history.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass SpoolUsageHistory(Base):\n    \"\"\"Record of filament consumption for a spool during a print.\"\"\"\n\n    __tablename__ = \"spool_usage_history\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    spool_id: Mapped[int] = mapped_column(ForeignKey(\"spool.id\", ondelete=\"CASCADE\"))\n    printer_id: Mapped[int | None] = mapped_column(ForeignKey(\"printers.id\", ondelete=\"SET NULL\"))\n    print_name: Mapped[str | None] = mapped_column(String(500))\n    archive_id: Mapped[int | None] = mapped_column(ForeignKey(\"print_archives.id\"), nullable=True)\n    weight_used: Mapped[float] = mapped_column(Float, default=0)\n    percent_used: Mapped[int] = mapped_column(Integer, default=0)\n    status: Mapped[str] = mapped_column(String(20), default=\"completed\")  # completed/failed/aborted\n    cost: Mapped[float | None] = mapped_column(Float)  # Calculated cost for this usage event\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n"
  },
  {
    "path": "backend/app/models/spoolbuddy_device.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass SpoolBuddyDevice(Base):\n    \"\"\"SpoolBuddy device registration for RPi-based filament management stations.\"\"\"\n\n    __tablename__ = \"spoolbuddy_devices\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    device_id: Mapped[str] = mapped_column(String(50), unique=True, index=True)\n    hostname: Mapped[str] = mapped_column(String(100))\n    ip_address: Mapped[str] = mapped_column(String(45))\n    firmware_version: Mapped[str | None] = mapped_column(String(20))\n    has_nfc: Mapped[bool] = mapped_column(Boolean, default=True)\n    has_scale: Mapped[bool] = mapped_column(Boolean, default=True)\n    tare_offset: Mapped[int] = mapped_column(Integer, default=0)\n    calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)\n    nfc_reader_type: Mapped[str | None] = mapped_column(String(20))\n    nfc_connection: Mapped[str | None] = mapped_column(String(20))\n    backend_url: Mapped[str | None] = mapped_column(String(255), nullable=True)\n    display_brightness: Mapped[int] = mapped_column(Integer, default=100)\n    display_blank_timeout: Mapped[int] = mapped_column(Integer, default=0)\n    has_backlight: Mapped[bool] = mapped_column(Boolean, default=False)\n    last_calibrated_at: Mapped[datetime | None] = mapped_column(DateTime)\n    last_seen: Mapped[datetime | None] = mapped_column(DateTime)\n    pending_command: Mapped[str | None] = mapped_column(String(50))\n    pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)\n    update_status: Mapped[str | None] = mapped_column(String(20), nullable=True)\n    update_message: Mapped[str | None] = mapped_column(String(255), nullable=True)\n    pending_system_payload: Mapped[str | None] = mapped_column(Text, nullable=True)\n    nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)\n    scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)\n    uptime_s: Mapped[int] = mapped_column(Integer, default=0)\n    system_stats: Mapped[str | None] = mapped_column(Text, nullable=True)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n"
  },
  {
    "path": "backend/app/models/user.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING\n\nfrom sqlalchemy import DateTime, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\nif TYPE_CHECKING:\n    from backend.app.models.group import Group\n    from backend.app.models.user_email_pref import UserEmailPreference\n\n\nclass User(Base):\n    \"\"\"User model for authentication and authorization.\n\n    Users can belong to multiple groups, and their permissions are additive\n    across all groups. The legacy 'role' field is kept for backward compatibility\n    but is_admin property now also considers group membership.\n    \"\"\"\n\n    __tablename__ = \"users\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    username: Mapped[str] = mapped_column(String(100), unique=True, index=True)\n    email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)\n    password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)\n    role: Mapped[str] = mapped_column(\n        String(20), default=\"user\"\n    )  # \"admin\" or \"user\" (legacy, kept for backward compat)\n    auth_source: Mapped[str] = mapped_column(String(20), default=\"local\")  # \"local\", \"ldap\", or \"oidc\"\n    is_active: Mapped[bool] = mapped_column(default=True)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Set whenever the local password is changed/reset — used to invalidate JWTs\n    # issued before the change (M-R7-B).  NULL means no password change recorded yet.\n    password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)\n\n    # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)\n    cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)\n    cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)\n    # \"global\" or \"china\"; NULL treated as \"global\" for legacy rows.\n    cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)\n\n    # Relationship to groups through association table\n    groups: Mapped[list[Group]] = relationship(\n        \"Group\",\n        secondary=\"user_groups\",\n        back_populates=\"users\",\n        lazy=\"selectin\",\n    )\n\n    # Relationship to email notification preferences\n    email_preferences: Mapped[UserEmailPreference | None] = relationship(\n        \"UserEmailPreference\",\n        back_populates=\"user\",\n        uselist=False,\n        cascade=\"all, delete-orphan\",\n        lazy=\"select\",\n    )\n\n    @property\n    def is_admin(self) -> bool:\n        \"\"\"Check if user is an admin.\n\n        Returns True if:\n        - User has legacy role='admin', OR\n        - User belongs to the Administrators group\n        \"\"\"\n        if self.role == \"admin\":\n            return True\n        return any(g.name == \"Administrators\" for g in self.groups)\n\n    def get_permissions(self) -> set[str]:\n        \"\"\"Get all permissions from all groups the user belongs to.\n\n        Returns a set of permission strings. Permissions are additive across groups.\n        \"\"\"\n        permissions: set[str] = set()\n        for group in self.groups:\n            if group.permissions:\n                permissions.update(group.permissions)\n        return permissions\n\n    def has_permission(self, permission: str) -> bool:\n        \"\"\"Check if user has a specific permission.\n\n        Admins have all permissions. For other users, checks if the permission\n        exists in any of their groups.\n        \"\"\"\n        if self.is_admin:\n            return True\n        return permission in self.get_permissions()\n\n    def has_all_permissions(self, *permissions: str) -> bool:\n        \"\"\"Check if user has ALL specified permissions.\n\n        Admins have all permissions. For other users, checks if all permissions\n        exist in their combined group permissions.\n        \"\"\"\n        if self.is_admin:\n            return True\n        user_permissions = self.get_permissions()\n        return all(p in user_permissions for p in permissions)\n\n    def has_any_permission(self, *permissions: str) -> bool:\n        \"\"\"Check if user has ANY of the specified permissions.\n\n        Admins have all permissions. For other users, checks if at least one\n        permission exists in their combined group permissions.\n        \"\"\"\n        if self.is_admin:\n            return True\n        user_permissions = self.get_permissions()\n        return any(p in user_permissions for p in permissions)\n\n    def __repr__(self) -> str:\n        return f\"<User {self.username}>\"\n"
  },
  {
    "path": "backend/app/models/user_email_pref.py",
    "content": "\"\"\"User email notification preference model.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING\n\nfrom sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\n\nfrom backend.app.core.database import Base\n\nif TYPE_CHECKING:\n    from backend.app.models.user import User\n\n\nclass UserEmailPreference(Base):\n    \"\"\"Stores per-user email notification preferences for their own print jobs.\"\"\"\n\n    __tablename__ = \"user_email_preferences\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)\n    user_id: Mapped[int] = mapped_column(\n        Integer, ForeignKey(\"users.id\", ondelete=\"CASCADE\"), nullable=False, unique=True, index=True\n    )\n\n    # Print lifecycle notifications (only for jobs submitted by this user)\n    notify_print_start: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)\n    notify_print_complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)\n    notify_print_failed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)\n    notify_print_stopped: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)\n\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    # Relationship\n    user: Mapped[User] = relationship(back_populates=\"email_preferences\")\n"
  },
  {
    "path": "backend/app/models/user_otp_code.py",
    "content": "from __future__ import annotations\n\nfrom datetime import datetime, timezone\n\nfrom sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass UserOTPCode(Base):\n    \"\"\"Temporary email OTP (One-Time Password) code for 2FA verification.\n\n    Each record represents a single sent OTP code.  Codes expire after\n    OTP_TTL_MINUTES and are invalidated after MAX_ATTEMPTS failed attempts\n    or after successful verification.\n    \"\"\"\n\n    __tablename__ = \"user_otp_codes\"\n\n    OTP_TTL_MINUTES = 10\n    MAX_ATTEMPTS = 5\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    user_id: Mapped[int] = mapped_column(Integer, ForeignKey(\"users.id\", ondelete=\"CASCADE\"), index=True)\n    # pbkdf2_sha256 hash of the 6-digit code\n    code_hash: Mapped[str] = mapped_column(String(255))\n    # Number of failed verification attempts for this code\n    attempts: Mapped[int] = mapped_column(Integer, default=0)\n    # True once the code has been successfully used or explicitly invalidated\n    used: Mapped[bool] = mapped_column(Boolean, default=False)\n    expires_at: Mapped[datetime] = mapped_column(DateTime)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n\n    def consume(self) -> None:\n        \"\"\"T4: Mark this OTP as used, enforcing preconditions.\n\n        Raises ``ValueError`` if the code is already used or expired so callers\n        cannot silently re-use an invalidated code.  The caller is responsible\n        for flushing/committing the change to the DB.\n        \"\"\"\n        now = datetime.now(timezone.utc)\n        exp = self.expires_at\n        if exp.tzinfo is None:\n            from datetime import timezone as _tz\n\n            exp = exp.replace(tzinfo=_tz.utc)\n        if self.used:\n            raise ValueError(\"OTP code has already been used\")\n        if exp < now:\n            raise ValueError(\"OTP code has expired\")\n        self.used = True\n\n    def __repr__(self) -> str:\n        return f\"<UserOTPCode user_id={self.user_id} used={self.used}>\"\n"
  },
  {
    "path": "backend/app/models/user_totp.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom datetime import datetime\n\nfrom fastapi import HTTPException, status\nfrom sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\nfrom backend.app.core.encryption import mfa_decrypt, mfa_encrypt\n\n\nclass UserTOTP(Base):\n    \"\"\"TOTP (Time-based One-Time Password) secret for a user.\n\n    Stores the TOTP secret used by authenticator apps (Google Authenticator,\n    Proton Authenticator, Aegis, etc.). One record per user; is_enabled=False\n    while the setup is pending confirmation.\n    \"\"\"\n\n    __tablename__ = \"user_totp\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    user_id: Mapped[int] = mapped_column(Integer, ForeignKey(\"users.id\", ondelete=\"CASCADE\"), unique=True, index=True)\n    # TOTP secret — encrypted at rest when MFA_ENCRYPTION_KEY is set.\n    # Use .secret / .set_secret() rather than accessing _secret_enc directly.\n    _secret_enc: Mapped[str] = mapped_column(\"secret\", String(512))\n    is_enabled: Mapped[bool] = mapped_column(Boolean, default=False)\n    # Hashed backup codes stored as JSON array of strings\n    # Each entry is a hashed one-time-use recovery code\n    backup_codes_json: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)\n    # TOTP replay protection: stores the 30-second time-step counter of the last\n    # accepted code so the same code cannot be used twice within one window.\n    last_totp_counter: Mapped[int | None] = mapped_column(BigInteger, nullable=True, default=None)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n\n    @property\n    def secret(self) -> str:\n        \"\"\"Return the decrypted TOTP secret.\"\"\"\n        return mfa_decrypt(self._secret_enc)\n\n    @secret.setter\n    def secret(self, value: str) -> None:\n        \"\"\"Store the TOTP secret, encrypting it when MFA_ENCRYPTION_KEY is set.\"\"\"\n        self._secret_enc = mfa_encrypt(value)\n\n    @property\n    def backup_code_hashes(self) -> list[str]:\n        \"\"\"T5: Get stored backup-code hashes as a list.\n\n        The name makes clear that these are *hashes*, not plaintext codes,\n        so callers know they must verify with a password-hashing library\n        rather than compare directly.\n        \"\"\"\n        if not self.backup_codes_json:\n            return []\n        return json.loads(self.backup_codes_json)\n\n    @backup_code_hashes.setter\n    def backup_code_hashes(self, hashes: list[str]) -> None:\n        \"\"\"Persist backup-code hashes as a JSON array.\"\"\"\n        self.backup_codes_json = json.dumps(hashes)\n\n    def accept_counter(self, new_counter: int) -> None:\n        \"\"\"T4: Record an accepted TOTP time-step counter, rejecting backward movement.\n\n        Raises ``HTTPException(400)`` if ``new_counter`` is not strictly greater\n        than ``last_totp_counter``, preventing counter roll-back attacks (e.g. an\n        attacker who replays a previously accepted code after the counter wraps or\n        the clock is skewed backward).\n\n        The caller is responsible for flushing/committing the change to the DB.\n        \"\"\"\n        if self.last_totp_counter is not None and new_counter <= self.last_totp_counter:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST,\n                detail=\"TOTP code already used\",\n            )\n        self.last_totp_counter = new_counter\n\n    def __repr__(self) -> str:\n        return f\"<UserTOTP user_id={self.user_id} enabled={self.is_enabled}>\"\n"
  },
  {
    "path": "backend/app/models/virtual_printer.py",
    "content": "from datetime import datetime\n\nfrom sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column\n\nfrom backend.app.core.database import Base\n\n\nclass VirtualPrinter(Base):\n    \"\"\"Virtual printer configuration for multi-instance support.\"\"\"\n\n    __tablename__ = \"virtual_printers\"\n\n    id: Mapped[int] = mapped_column(primary_key=True)\n    name: Mapped[str] = mapped_column(String(100), default=\"Bambuddy\")\n    enabled: Mapped[bool] = mapped_column(Boolean, default=False)\n    mode: Mapped[str] = mapped_column(String(20), default=\"immediate\")  # immediate|review|print_queue|proxy\n    auto_dispatch: Mapped[bool] = mapped_column(\n        Boolean, server_default=\"true\"\n    )  # print_queue mode: auto-start or manual\n    model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)\n    access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)\n    target_printer_id: Mapped[int | None] = mapped_column(\n        Integer, ForeignKey(\"printers.id\", ondelete=\"SET NULL\"), nullable=True\n    )  # proxy mode\n    bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # dedicated IP (proxy mode)\n    remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # SSDP advertise IP\n    serial_suffix: Mapped[str] = mapped_column(String(9), default=\"391800001\")  # unique per printer\n    position: Mapped[int] = mapped_column(Integer, default=0)\n    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())\n"
  },
  {
    "path": "backend/app/schemas/__init__.py",
    "content": "from backend.app.schemas.archive import (\n    ArchiveBase,\n    ArchiveResponse,\n    ArchiveUpdate,\n    ProjectPageImage,\n    ProjectPageResponse,\n)\nfrom backend.app.schemas.printer import (\n    PrinterBase,\n    PrinterCreate,\n    PrinterResponse,\n    PrinterStatus,\n    PrinterUpdate,\n)\nfrom backend.app.schemas.smart_plug import (\n    SmartPlugBase,\n    SmartPlugControl,\n    SmartPlugCreate,\n    SmartPlugResponse,\n    SmartPlugStatus,\n    SmartPlugTestConnection,\n    SmartPlugUpdate,\n)\n\n__all__ = [\n    \"PrinterBase\",\n    \"PrinterCreate\",\n    \"PrinterUpdate\",\n    \"PrinterResponse\",\n    \"PrinterStatus\",\n    \"ArchiveBase\",\n    \"ArchiveUpdate\",\n    \"ArchiveResponse\",\n    \"ProjectPageResponse\",\n    \"ProjectPageImage\",\n    \"SmartPlugBase\",\n    \"SmartPlugCreate\",\n    \"SmartPlugUpdate\",\n    \"SmartPlugResponse\",\n    \"SmartPlugControl\",\n    \"SmartPlugStatus\",\n    \"SmartPlugTestConnection\",\n]\n"
  },
  {
    "path": "backend/app/schemas/api_key.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass APIKeyCreate(BaseModel):\n    \"\"\"Schema for creating a new API key.\"\"\"\n\n    name: str\n    can_queue: bool = True\n    can_control_printer: bool = False\n    can_read_status: bool = True\n    printer_ids: list[int] | None = None  # null = all printers\n    expires_at: datetime | None = None\n\n\nclass APIKeyUpdate(BaseModel):\n    \"\"\"Schema for updating an API key.\"\"\"\n\n    name: str | None = None\n    can_queue: bool | None = None\n    can_control_printer: bool | None = None\n    can_read_status: bool | None = None\n    printer_ids: list[int] | None = None\n    enabled: bool | None = None\n    expires_at: datetime | None = None\n\n\nclass APIKeyResponse(BaseModel):\n    \"\"\"Schema for API key response (without full key).\"\"\"\n\n    id: int\n    name: str\n    key_prefix: str  # First 8 chars for identification\n    can_queue: bool\n    can_control_printer: bool\n    can_read_status: bool\n    printer_ids: list[int] | None\n    enabled: bool\n    last_used: datetime | None\n    created_at: datetime\n    expires_at: datetime | None\n\n    class Config:\n        from_attributes = True\n\n\nclass APIKeyCreateResponse(APIKeyResponse):\n    \"\"\"Response when creating a key - includes full key (shown only once).\"\"\"\n\n    key: str  # Full API key, only shown on creation\n"
  },
  {
    "path": "backend/app/schemas/archive.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel, model_validator\n\n\nclass ArchiveBase(BaseModel):\n    print_name: str | None = None\n    is_favorite: bool | None = None\n    tags: str | None = None\n    notes: str | None = None\n    cost: float | None = None\n    failure_reason: str | None = None\n    quantity: int | None = None  # Number of items printed\n    # User-defined link (Printables, Thingiverse, etc.)\n    external_url: str | None = None\n\n\nclass ArchiveUpdate(ArchiveBase):\n    printer_id: int | None = None\n    project_id: int | None = None\n    # Allow changing status (e.g., clearing failed flag)\n    status: str | None = None\n\n\nclass ArchiveDuplicate(BaseModel):\n    \"\"\"Reference to a duplicate archive.\"\"\"\n\n    id: int\n    print_name: str | None\n    created_at: datetime\n    match_type: str  # \"exact\" (hash match) or \"similar\" (name match)\n\n\nclass ArchiveResponse(BaseModel):\n    id: int\n    printer_id: int | None\n    project_id: int | None = None\n    project_name: str | None = None  # Included for convenience\n    filename: str\n    file_path: str\n    file_size: int\n    content_hash: str | None\n    thumbnail_path: str | None\n    timelapse_path: str | None\n    source_3mf_path: str | None = None  # Original project 3MF from slicer\n    f3d_path: str | None = None  # Fusion 360 design file\n\n    # Duplicate detection\n    duplicates: list[ArchiveDuplicate] | None = None\n    duplicate_count: int = 0  # Quick count for list views\n    duplicate_sequence: int = 0  # 0 = original, 1+ = nth duplicate\n    original_archive_id: int | None = None  # ID of the first/original archive\n\n    # Object count (computed from extra_data.printable_objects)\n    object_count: int | None = None\n\n    print_name: str | None\n    print_time_seconds: int | None  # Estimated time from slicer\n    actual_time_seconds: int | None = None  # Computed from started_at/completed_at\n    # Percentage: 100 = perfect, >100 = faster than estimated\n    time_accuracy: float | None = None\n    filament_used_grams: float | None\n    filament_type: str | None\n    filament_color: str | None\n    layer_height: float | None\n    total_layers: int | None = None\n    nozzle_diameter: float | None\n    bed_temperature: int | None\n    nozzle_temperature: int | None\n\n    sliced_for_model: str | None = None  # Printer model this file was sliced for\n\n    status: str\n    started_at: datetime | None\n    completed_at: datetime | None\n\n    extra_data: dict | None\n\n    makerworld_url: str | None\n    designer: str | None\n    # User-defined link (Printables, Thingiverse, etc.)\n    external_url: str | None = None\n\n    is_favorite: bool\n    tags: str | None\n    notes: str | None\n    cost: float | None\n    photos: list | None\n    failure_reason: str | None\n    quantity: int = 1  # Number of items printed\n\n    # Energy tracking\n    energy_kwh: float | None = None\n    energy_cost: float | None = None\n\n    created_at: datetime\n\n    # User tracking (Issue #206)\n    created_by_id: int | None = None\n    created_by_username: str | None = None\n\n    @model_validator(mode=\"after\")\n    def compute_object_count(self) -> \"ArchiveResponse\":\n        \"\"\"Compute object_count from extra_data.printable_objects if not set.\"\"\"\n        if self.object_count is None and self.extra_data:\n            printable_objects = self.extra_data.get(\"printable_objects\")\n            if printable_objects and isinstance(printable_objects, dict):\n                self.object_count = len(printable_objects)\n        return self\n\n    class Config:\n        from_attributes = True\n\n\nclass ArchiveSlim(BaseModel):\n    \"\"\"Lightweight archive response for stats/dashboard widgets.\"\"\"\n\n    printer_id: int | None\n    print_name: str | None\n    print_time_seconds: int | None\n    actual_time_seconds: int | None = None\n    filament_used_grams: float | None\n    filament_type: str | None\n    filament_color: str | None\n    status: str\n    started_at: datetime | None\n    completed_at: datetime | None\n    cost: float | None\n    quantity: int = 1\n    created_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass ArchiveStats(BaseModel):\n    total_prints: int\n    successful_prints: int\n    failed_prints: int\n    total_print_time_hours: float\n    total_filament_grams: float\n    total_cost: float\n    prints_by_filament_type: dict\n    prints_by_printer: dict\n    # Time accuracy stats\n    # Average across all prints with data\n    average_time_accuracy: float | None = None\n    time_accuracy_by_printer: dict | None = None  # Per-printer accuracy\n    # Energy stats\n    total_energy_kwh: float = 0.0\n    total_energy_cost: float = 0.0\n    # Set when the date-range query in \"total consumption\" mode is running on\n    # incomplete snapshot history — e.g. right after a fresh upgrade before the\n    # hourly snapshot loop has built up a baseline. Frontend shows a tooltip.\n    energy_data_warming_up: bool = False\n\n\nclass ProjectPageImage(BaseModel):\n    \"\"\"Image embedded in 3MF project page.\"\"\"\n\n    name: str\n    path: str  # Path within 3MF\n    url: str  # API URL to fetch image\n\n\nclass ProjectPageResponse(BaseModel):\n    \"\"\"Project page data extracted from 3MF file.\"\"\"\n\n    # Model info\n    title: str | None = None\n    description: str | None = None  # HTML content\n    designer: str | None = None\n    designer_user_id: str | None = None\n    license: str | None = None\n    copyright: str | None = None\n    creation_date: str | None = None\n    modification_date: str | None = None\n    origin: str | None = None  # \"original\" or \"remix\"\n\n    # Profile info\n    profile_title: str | None = None\n    profile_description: str | None = None\n    profile_cover: str | None = None\n    profile_user_id: str | None = None\n    profile_user_name: str | None = None\n\n    # MakerWorld info\n    design_model_id: str | None = None\n    design_profile_id: str | None = None\n    design_region: str | None = None\n\n    # Images\n    model_pictures: list[ProjectPageImage] = []\n    profile_pictures: list[ProjectPageImage] = []\n    thumbnails: list[ProjectPageImage] = []\n\n\nclass ProjectPageUpdate(BaseModel):\n    \"\"\"Update project page data in 3MF file.\"\"\"\n\n    title: str | None = None\n    description: str | None = None\n    designer: str | None = None\n    license: str | None = None\n    copyright: str | None = None\n    profile_title: str | None = None\n    profile_description: str | None = None\n\n\nclass ReprintRequest(BaseModel):\n    \"\"\"Request body for reprinting an archive.\"\"\"\n\n    # Plate selection for multi-plate 3MF files\n    # If not specified, auto-detects from file (legacy behavior for single-plate files)\n    plate_id: int | None = None\n    plate_name: str | None = None\n\n    # AMS slot mapping: list of tray IDs for each filament slot in the 3MF\n    # Global tray ID = (ams_id * 4) + slot_id, external = 254\n    ams_mapping: list[int] | None = None\n\n    # Print options\n    bed_levelling: bool = True\n    flow_cali: bool = False\n    vibration_cali: bool = True\n    layer_inspect: bool = False\n    timelapse: bool = False\n    use_ams: bool = True  # Not exposed in UI, but needed for API\n"
  },
  {
    "path": "backend/app/schemas/auth.py",
    "content": "import re\nfrom typing import Literal\n\nfrom pydantic import BaseModel, Field, field_validator\n\n\ndef _validate_password_complexity(v: str) -> str:\n    \"\"\"Enforce minimum password complexity (M-C).\n\n    Requires at least one uppercase letter, one lowercase letter, one digit,\n    and one special character in addition to the min_length=8 Field constraint.\n    \"\"\"\n    if not re.search(r\"[A-Z]\", v):\n        raise ValueError(\"Password must contain at least one uppercase letter\")\n    if not re.search(r\"[a-z]\", v):\n        raise ValueError(\"Password must contain at least one lowercase letter\")\n    if not re.search(r\"\\d\", v):\n        raise ValueError(\"Password must contain at least one digit\")\n    if not re.search(r\"[^A-Za-z0-9]\", v):\n        raise ValueError(\"Password must contain at least one special character\")\n    return v\n\n\nclass GroupBrief(BaseModel):\n    \"\"\"Brief group info for embedding in user responses.\"\"\"\n\n    id: int\n    name: str\n\n    class Config:\n        from_attributes = True\n\n\nclass LoginRequest(BaseModel):\n    username: str = Field(..., max_length=150)\n    password: str = Field(..., max_length=256)\n\n\nclass LoginResponse(BaseModel):\n    access_token: str | None = None\n    token_type: str = \"bearer\"\n    user: \"UserResponse | None\" = None\n    # Set when 2FA is required; the frontend must call /auth/2fa/verify\n    requires_2fa: bool = False\n    pre_auth_token: str | None = None\n    two_fa_methods: list[str] = []\n\n\nclass UserCreate(BaseModel):\n    username: str = Field(..., max_length=150)\n    password: str | None = Field(default=None, max_length=256)  # M-NEW-4: cap before pbkdf2\n    email: str | None = Field(default=None, max_length=254)  # L-NEW-5: RFC 5321 max\n    role: str = \"user\"\n    group_ids: list[int] | None = None\n\n    @field_validator(\"password\")\n    @classmethod\n    def validate_password(cls, v: str | None) -> str | None:\n        if v is not None:\n            _validate_password_complexity(v)\n        return v\n\n\nclass UserUpdate(BaseModel):\n    username: str | None = Field(default=None, max_length=150)\n    password: str | None = Field(default=None, max_length=256)  # M-NEW-4: cap before pbkdf2\n    email: str | None = Field(default=None, max_length=254)  # L-NEW-5: RFC 5321 max\n    role: str | None = None\n    is_active: bool | None = None\n    group_ids: list[int] | None = None\n\n    @field_validator(\"password\")\n    @classmethod\n    def validate_password(cls, v: str | None) -> str | None:\n        if v is not None:\n            _validate_password_complexity(v)\n        return v\n\n\nclass UserResponse(BaseModel):\n    id: int\n    username: str\n    email: str | None = None\n    role: str  # Deprecated, kept for backward compatibility\n    is_active: bool\n    is_admin: bool  # Computed from role and group membership\n    auth_source: str = \"local\"  # \"local\" or \"ldap\"\n    groups: list[GroupBrief] = []\n    permissions: list[str] = []  # All permissions from groups\n    created_at: str\n\n    class Config:\n        from_attributes = True\n\n\nclass ChangePasswordRequest(BaseModel):\n    current_password: str = Field(..., max_length=256)  # M-NEW-3: cap before pbkdf2\n    new_password: str = Field(..., min_length=8, max_length=256)\n\n    @field_validator(\"new_password\")\n    @classmethod\n    def validate_new_password(cls, v: str) -> str:\n        return _validate_password_complexity(v)\n\n\nclass SetupRequest(BaseModel):\n    auth_enabled: bool\n    admin_username: str | None = Field(default=None, max_length=150)\n    admin_password: str | None = Field(default=None, max_length=256)\n\n    @field_validator(\"admin_password\")\n    @classmethod\n    def validate_admin_password(cls, v: str | None) -> str | None:\n        if v is not None:\n            _validate_password_complexity(v)\n        return v\n\n\nclass SetupResponse(BaseModel):\n    auth_enabled: bool\n    admin_created: bool | None = None\n\n\nclass ForgotPasswordRequest(BaseModel):\n    email: str = Field(..., max_length=254)  # L-NEW-1: RFC 5321 max; caps memory/CPU before lookup\n\n\nclass ForgotPasswordConfirmRequest(BaseModel):\n    token: str = Field(..., max_length=128)\n    new_password: str = Field(..., min_length=8, max_length=256)\n\n    @field_validator(\"new_password\")\n    @classmethod\n    def validate_new_password(cls, v: str) -> str:\n        return _validate_password_complexity(v)\n\n\nclass ForgotPasswordResponse(BaseModel):\n    message: str\n\n\nclass ResetPasswordRequest(BaseModel):\n    user_id: int\n\n\nclass ResetPasswordResponse(BaseModel):\n    message: str\n\n\nclass SMTPSettings(BaseModel):\n    smtp_host: str\n    smtp_port: int\n    smtp_username: str | None = None  # Optional when auth is disabled\n    smtp_password: str | None = None  # Optional for read operations or when auth is disabled\n    smtp_security: str = \"starttls\"  # 'starttls', 'ssl', 'none'\n    smtp_auth_enabled: bool = True\n    smtp_from_email: str\n    smtp_from_name: str = \"BamBuddy\"\n    # Deprecated field for backward compatibility\n    smtp_use_tls: bool | None = None\n\n\nclass TestSMTPRequest(BaseModel):\n    test_recipient: str\n\n\nclass TestSMTPResponse(BaseModel):\n    success: bool\n    message: str\n\n\n# ---------------------------------------------------------------------------\n# 2FA / MFA schemas\n# ---------------------------------------------------------------------------\n\n\nclass TwoFAStatusResponse(BaseModel):\n    totp_enabled: bool\n    email_otp_enabled: bool\n    backup_codes_remaining: int\n\n\nclass TOTPSetupResponse(BaseModel):\n    \"\"\"Returned when a user initiates TOTP setup.  The frontend should display\n    the QR code image (base64 PNG) and ask the user to scan it, then call\n    /auth/2fa/totp/enable with a valid code to confirm.\"\"\"\n\n    secret: str  # base32 secret (shown as fallback text)\n    qr_code_b64: str  # base64-encoded PNG of the QR code\n    issuer: str\n\n\nclass TOTPSetupRequest(BaseModel):\n    \"\"\"Optional body for POST /auth/2fa/totp/setup.\n\n    Only required when re-initialising setup while an active TOTP record exists.\n    Provide the current TOTP code (from the existing authenticator app) to\n    confirm intent — mirrors the verification requirement in disable_totp.\n    \"\"\"\n\n    code: str | None = Field(default=None, max_length=8)  # L-NEW-2: bound before pyotp\n\n\nclass TOTPEnableRequest(BaseModel):\n    code: str  # 6-digit TOTP code from the authenticator app\n\n    @field_validator(\"code\")\n    @classmethod\n    def validate_code(cls, v: str) -> str:\n        v = v.strip()\n        if not v.isdigit() or len(v) != 6:\n            raise ValueError(\"TOTP code must be exactly 6 digits\")\n        return v\n\n\nclass TOTPEnableResponse(BaseModel):\n    message: str\n    backup_codes: list[str]  # plain-text codes shown once; user must save them\n\n\nclass TOTPDisableRequest(BaseModel):\n    \"\"\"Requires a valid TOTP code OR a backup code to disable TOTP.\"\"\"\n\n    code: str = Field(..., max_length=128)\n\n\nclass BackupCodesResponse(BaseModel):\n    backup_codes: list[str]\n    message: str\n\n\nclass EmailOTPEnableRequest(BaseModel):\n    \"\"\"No body required — email is taken from the authenticated user's profile.\"\"\"\n\n    pass\n\n\nclass TwoFAVerifyRequest(BaseModel):\n    pre_auth_token: str = Field(..., max_length=128)\n    # TOTP/email codes are 6 digits; backup codes are 8 uppercase alphanumeric chars.\n    # max_length=8 prevents excessively long inputs from reaching pbkdf2/pyotp.\n    code: str = Field(..., min_length=6, max_length=8)\n    method: Literal[\"totp\", \"email\", \"backup\"] = \"totp\"\n\n    @field_validator(\"code\")\n    @classmethod\n    def validate_code_format(cls, v: str) -> str:\n        v = v.strip()\n        if not re.match(r\"^[A-Za-z0-9]{6,8}$\", v):\n            raise ValueError(\"Code must be 6–8 alphanumeric characters\")\n        return v.upper()  # normalise backup codes to uppercase\n\n\nclass TwoFAVerifyResponse(BaseModel):\n    access_token: str\n    token_type: str = \"bearer\"\n    user: \"UserResponse\"\n\n\nclass EmailOTPSendRequest(BaseModel):\n    pre_auth_token: str = Field(..., max_length=128)\n\n\nclass EmailOTPEnableConfirmRequest(BaseModel):\n    \"\"\"Body for the second step of email OTP enable: verify the proof-of-possession code.\"\"\"\n\n    setup_token: str = Field(..., max_length=128)\n    # L-NEW-3: email OTP setup codes are always exactly 6 digits; reject anything else.\n    code: str = Field(..., min_length=6, max_length=6)\n\n    @field_validator(\"code\")\n    @classmethod\n    def validate_code_digits(cls, v: str) -> str:\n        v = v.strip()\n        if not v.isdigit() or len(v) != 6:\n            raise ValueError(\"Email OTP setup code must be exactly 6 digits\")\n        return v\n\n\nclass EmailOTPDisableRequest(BaseModel):\n    \"\"\"Requires the account password to disable email OTP.\"\"\"\n\n    password: str = Field(..., max_length=256)\n\n\nclass AdminDisable2FARequest(BaseModel):\n    \"\"\"Admin must supply their own password as re-auth before disabling 2FA for another user.\n\n    OIDC/LDAP-only admins (no local password_hash) are exempt from this check.\n    \"\"\"\n\n    admin_password: str | None = Field(default=None, max_length=256)\n\n\n# ---------------------------------------------------------------------------\n# OIDC schemas\n# ---------------------------------------------------------------------------\n\n\ndef _validate_icon_url(v: str | None) -> str | None:\n    \"\"\"Reject non-HTTPS icon URLs to prevent SSRF / mixed-content issues.\"\"\"\n    if v is None:\n        return v\n    if not v.startswith(\"https://\"):\n        raise ValueError(\"icon_url must start with https://\")\n    return v\n\n\ndef _validate_issuer_url(v: str | None) -> str | None:\n    \"\"\"Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.\n\n    HTTP is no longer accepted — OIDC providers must be reachable over TLS.\n    Private-network and loopback addresses are rejected to prevent SSRF attacks\n    where an admin-supplied URL could reach internal services.\n    \"\"\"\n    import ipaddress\n    from urllib.parse import urlparse\n\n    if v is None:\n        return v\n    if not v.startswith(\"https://\"):\n        raise ValueError(\"issuer_url must start with https://\")\n    host = urlparse(v).hostname or \"\"\n    try:\n        addr = ipaddress.ip_address(host)\n        if addr.is_private or addr.is_loopback or addr.is_link_local:\n            raise ValueError(\"issuer_url must not point to a private, loopback, or link-local address\")\n    except ValueError as exc:\n        if \"issuer_url\" in str(exc):\n            raise\n        # hostname is a domain name, not a bare IP — that's fine\n    return v\n\n\ndef _validate_scopes(v: str | None) -> str | None:\n    \"\"\"Nit5: Require that the 'openid' scope is present.\n\n    The OpenID Connect spec mandates the 'openid' scope; without it the\n    response is plain OAuth2, not OIDC, and claims like sub/email are not\n    guaranteed.\n    \"\"\"\n    if v is None:\n        return v\n    scope_list = v.split()\n    if \"openid\" not in scope_list:\n        raise ValueError(\"scopes must include 'openid'\")\n    return v\n\n\nclass OIDCProviderCreate(BaseModel):\n    name: str = Field(..., max_length=100)  # L-NEW-4\n    issuer_url: str\n    client_id: str = Field(..., max_length=256)  # L-NEW-4\n    client_secret: str = Field(..., max_length=512)  # L-NEW-4: Fernet input bounded\n    scopes: str = Field(default=\"openid email profile\", max_length=256)  # L-NEW-4\n    is_enabled: bool = True\n    auto_create_users: bool = False\n    auto_link_existing_accounts: bool = False  # M-2: conservative default, opt-in only\n    icon_url: str | None = None\n\n    @field_validator(\"issuer_url\")\n    @classmethod\n    def validate_issuer_url(cls, v: str) -> str:\n        result = _validate_issuer_url(v)\n        assert result is not None\n        return result\n\n    @field_validator(\"scopes\")\n    @classmethod\n    def validate_scopes(cls, v: str) -> str:\n        result = _validate_scopes(v)\n        assert result is not None\n        return result\n\n    @field_validator(\"icon_url\")\n    @classmethod\n    def validate_icon_url(cls, v: str | None) -> str | None:\n        return _validate_icon_url(v)\n\n\nclass OIDCProviderUpdate(BaseModel):\n    name: str | None = Field(default=None, max_length=100)\n    issuer_url: str | None = None\n\n    @field_validator(\"issuer_url\")\n    @classmethod\n    def validate_issuer_url(cls, v: str | None) -> str | None:\n        return _validate_issuer_url(v)\n\n    client_id: str | None = Field(default=None, max_length=256)\n    client_secret: str | None = Field(default=None, max_length=512)\n    scopes: str | None = Field(default=None, max_length=256)\n    is_enabled: bool | None = None\n    auto_create_users: bool | None = None\n    auto_link_existing_accounts: bool | None = None\n    icon_url: str | None = None\n\n    @field_validator(\"scopes\")\n    @classmethod\n    def validate_scopes(cls, v: str | None) -> str | None:\n        return _validate_scopes(v)\n\n    @field_validator(\"icon_url\")\n    @classmethod\n    def validate_icon_url(cls, v: str | None) -> str | None:\n        return _validate_icon_url(v)\n\n\nclass OIDCProviderResponse(BaseModel):\n    id: int\n    name: str\n    issuer_url: str\n    client_id: str\n    scopes: str\n    is_enabled: bool\n    auto_create_users: bool\n    auto_link_existing_accounts: bool = False\n    icon_url: str | None = None\n\n    class Config:\n        from_attributes = True\n\n\nclass OIDCAuthorizeResponse(BaseModel):\n    auth_url: str\n\n\nclass OIDCExchangeRequest(BaseModel):\n    oidc_token: str = Field(..., max_length=128)\n\n\nclass OIDCLinkResponse(BaseModel):\n    id: int\n    provider_id: int\n    provider_name: str\n    provider_email: str | None = None\n    created_at: str\n"
  },
  {
    "path": "backend/app/schemas/cloud.py",
    "content": "from typing import Literal\n\nfrom pydantic import BaseModel, Field\n\nRegion = Literal[\"global\", \"china\"]\n\n\nclass CloudLoginRequest(BaseModel):\n    \"\"\"Request to initiate cloud login.\"\"\"\n\n    email: str = Field(..., description=\"Bambu Lab account email\")\n    password: str = Field(..., description=\"Account password\")\n    region: Region = Field(default=\"global\", description=\"Region: 'global' or 'china'\")\n\n\nclass CloudVerifyRequest(BaseModel):\n    \"\"\"Request to verify login with 2FA code (email or TOTP).\"\"\"\n\n    email: str = Field(..., description=\"Bambu Lab account email\")\n    code: str = Field(..., description=\"6-digit verification code\")\n    tfa_key: str | None = Field(None, description=\"TFA key for TOTP verification (from login response)\")\n    region: Region = Field(default=\"global\", description=\"Region: 'global' or 'china'\")\n\n\nclass CloudLoginResponse(BaseModel):\n    \"\"\"Response from login attempt.\"\"\"\n\n    success: bool\n    needs_verification: bool = False\n    message: str\n    verification_type: str | None = None  # \"email\" or \"totp\"\n    tfa_key: str | None = None  # Key needed for TOTP verification\n\n\nclass CloudAuthStatus(BaseModel):\n    \"\"\"Current authentication status.\"\"\"\n\n    is_authenticated: bool\n    email: str | None = None\n    region: Region | None = None\n\n\nclass CloudTokenRequest(BaseModel):\n    \"\"\"Request to set access token directly.\"\"\"\n\n    access_token: str = Field(..., description=\"Bambu Lab access token\")\n    region: Region = Field(default=\"global\", description=\"Region: 'global' or 'china'\")\n\n\nclass SlicerSetting(BaseModel):\n    \"\"\"A slicer setting/preset.\"\"\"\n\n    setting_id: str\n    name: str\n    type: str  # filament, printer, process\n    version: str | None = None\n    user_id: str | None = None\n    updated_time: str | None = None\n    is_custom: bool = False\n\n\nclass SlicerSettingsResponse(BaseModel):\n    \"\"\"Response containing slicer settings.\"\"\"\n\n    filament: list[SlicerSetting] = []\n    printer: list[SlicerSetting] = []\n    process: list[SlicerSetting] = []\n\n\nclass CloudDevice(BaseModel):\n    \"\"\"A bound printer device.\"\"\"\n\n    dev_id: str\n    name: str\n    dev_model_name: str | None = None\n    dev_product_name: str | None = None\n    online: bool = False\n\n\nclass SlicerSettingCreate(BaseModel):\n    \"\"\"Request to create a new slicer preset.\"\"\"\n\n    type: str = Field(..., description=\"Preset type: 'filament', 'print', or 'printer'\")\n    name: str = Field(..., description=\"Display name for the preset\")\n    base_id: str = Field(..., description=\"Base preset ID to inherit from\")\n    version: str = Field(default=\"2.0.0.0\", description=\"Version string for the preset\")\n    setting: dict = Field(default_factory=dict, description=\"Setting key-value pairs (delta from base)\")\n\n\nclass SlicerSettingUpdate(BaseModel):\n    \"\"\"Request to update an existing slicer preset.\"\"\"\n\n    name: str | None = Field(None, description=\"New display name\")\n    setting: dict | None = Field(None, description=\"Setting key-value pairs to update\")\n\n\nclass SlicerSettingDetail(BaseModel):\n    \"\"\"Detailed slicer setting/preset response.\"\"\"\n\n    message: str | None = None\n    code: str | None = None\n    error: str | None = None\n    public: bool = False\n    version: str | None = None\n    type: str\n    name: str\n    update_time: str | None = None\n    nickname: str | None = None\n    base_id: str | None = None\n    setting: dict = Field(default_factory=dict)\n    filament_id: str | None = None\n    setting_id: str | None = None  # For response after create\n\n\nclass SlicerSettingDeleteResponse(BaseModel):\n    \"\"\"Response from deleting a preset.\"\"\"\n\n    success: bool\n    message: str\n\n\nclass FirmwareUpdateInfo(BaseModel):\n    \"\"\"Firmware update information for a device.\"\"\"\n\n    device_id: str = Field(..., description=\"Device ID\")\n    device_name: str = Field(..., description=\"Device name\")\n    current_version: str | None = Field(None, description=\"Currently installed firmware version\")\n    latest_version: str | None = Field(None, description=\"Latest available firmware version\")\n    update_available: bool = Field(False, description=\"Whether an update is available\")\n    release_notes: str | None = Field(None, description=\"Release notes for the latest version\")\n\n\nclass FirmwareUpdatesResponse(BaseModel):\n    \"\"\"Response containing firmware updates for all devices.\"\"\"\n\n    updates: list[FirmwareUpdateInfo] = Field(default_factory=list)\n    updates_available: int = Field(0, description=\"Total number of devices with updates available\")\n"
  },
  {
    "path": "backend/app/schemas/external_link.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel, Field, field_validator\n\n\nclass ExternalLinkBase(BaseModel):\n    \"\"\"Base schema for external links.\"\"\"\n\n    name: str = Field(..., min_length=1, max_length=50, description=\"Display name for the link\")\n    url: str = Field(..., min_length=1, max_length=500, description=\"External URL\")\n    icon: str = Field(default=\"link\", max_length=50, description=\"Lucide icon name\")\n    open_in_new_tab: bool = False\n\n    @field_validator(\"url\")\n    @classmethod\n    def validate_url(cls, v: str) -> str:\n        \"\"\"Validate URL format.\"\"\"\n        if not v.startswith((\"http://\", \"https://\")):\n            raise ValueError(\"URL must start with http:// or https://\")\n        return v\n\n\nclass ExternalLinkCreate(ExternalLinkBase):\n    \"\"\"Schema for creating an external link.\"\"\"\n\n    pass\n\n\nclass ExternalLinkUpdate(BaseModel):\n    \"\"\"Schema for updating an external link (all fields optional).\"\"\"\n\n    name: str | None = Field(default=None, min_length=1, max_length=50)\n    url: str | None = Field(default=None, min_length=1, max_length=500)\n    icon: str | None = Field(default=None, max_length=50)\n    open_in_new_tab: bool | None = None\n\n    @field_validator(\"url\")\n    @classmethod\n    def validate_url(cls, v: str | None) -> str | None:\n        \"\"\"Validate URL format.\"\"\"\n        if v is not None and not v.startswith((\"http://\", \"https://\")):\n            raise ValueError(\"URL must start with http:// or https://\")\n        return v\n\n\nclass ExternalLinkResponse(ExternalLinkBase):\n    \"\"\"Response schema for external links.\"\"\"\n\n    id: int\n    open_in_new_tab: bool\n    custom_icon: str | None = None\n    sort_order: int\n    created_at: datetime\n    updated_at: datetime\n\n    model_config = {\"from_attributes\": True}\n\n\nclass ExternalLinkReorder(BaseModel):\n    \"\"\"Schema for reordering external links.\"\"\"\n\n    ids: list[int] = Field(..., description=\"List of link IDs in desired order\")\n"
  },
  {
    "path": "backend/app/schemas/filament.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel, Field\n\n\nclass FilamentBase(BaseModel):\n    name: str = Field(..., min_length=1, max_length=100)\n    type: str = Field(..., min_length=1, max_length=50)\n    brand: str | None = None\n    color: str | None = None\n    color_hex: str | None = Field(None, pattern=r\"^#[0-9A-Fa-f]{6}$\")\n    cost_per_kg: float = 25.0\n    spool_weight_g: float = 1000.0\n    currency: str = \"USD\"\n    density: float | None = None\n    print_temp_min: int | None = None\n    print_temp_max: int | None = None\n    bed_temp_min: int | None = None\n    bed_temp_max: int | None = None\n\n\nclass FilamentCreate(FilamentBase):\n    pass\n\n\nclass FilamentUpdate(BaseModel):\n    name: str | None = None\n    type: str | None = None\n    brand: str | None = None\n    color: str | None = None\n    color_hex: str | None = None\n    cost_per_kg: float | None = None\n    spool_weight_g: float | None = None\n    currency: str | None = None\n    density: float | None = None\n    print_temp_min: int | None = None\n    print_temp_max: int | None = None\n    bed_temp_min: int | None = None\n    bed_temp_max: int | None = None\n\n\nclass FilamentResponse(FilamentBase):\n    id: int\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass FilamentCostCalculation(BaseModel):\n    filament_id: int\n    filament_name: str\n    weight_grams: float\n    cost: float\n    currency: str\n"
  },
  {
    "path": "backend/app/schemas/github_backup.py",
    "content": "\"\"\"Pydantic schemas for GitHub backup configuration.\"\"\"\n\nimport re\nfrom datetime import datetime\n\nfrom pydantic import BaseModel, Field, field_validator\n\nfrom backend.app.core.compat import StrEnum\n\n\nclass ScheduleType(StrEnum):\n    \"\"\"Backup schedule types.\"\"\"\n\n    HOURLY = \"hourly\"\n    DAILY = \"daily\"\n    WEEKLY = \"weekly\"\n\n\nclass GitHubBackupConfigCreate(BaseModel):\n    \"\"\"Schema for creating/updating GitHub backup config.\"\"\"\n\n    repository_url: str = Field(..., min_length=1, max_length=500, description=\"GitHub repository URL\")\n    access_token: str = Field(..., min_length=1, description=\"Personal Access Token\")\n    branch: str = Field(default=\"main\", max_length=100, description=\"Branch to push to\")\n\n    schedule_enabled: bool = Field(default=False, description=\"Enable scheduled backups\")\n    schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description=\"Schedule frequency\")\n\n    backup_kprofiles: bool = Field(default=True, description=\"Backup K-profiles\")\n    backup_cloud_profiles: bool = Field(default=True, description=\"Backup Bambu Cloud profiles\")\n    backup_settings: bool = Field(default=False, description=\"Backup app settings\")\n    backup_spools: bool = Field(default=False, description=\"Backup spool inventory\")\n    backup_archives: bool = Field(default=False, description=\"Backup print archive history\")\n\n    enabled: bool = Field(default=True, description=\"Enable backup feature\")\n\n    @field_validator(\"repository_url\")\n    @classmethod\n    def validate_repo_url(cls, v: str) -> str:\n        \"\"\"Validate GitHub repository URL format.\"\"\"\n        # Accept various GitHub URL formats\n        patterns = [\n            r\"^https://github\\.com/[\\w.-]+/[\\w.-]+(?:\\.git)?$\",\n            r\"^git@github\\.com:[\\w.-]+/[\\w.-]+(?:\\.git)?$\",\n        ]\n        v = v.strip().rstrip(\"/\")\n        if not any(re.match(p, v) for p in patterns):\n            raise ValueError(\"Invalid GitHub repository URL. Expected format: https://github.com/owner/repo\")\n        return v\n\n\nclass GitHubBackupConfigUpdate(BaseModel):\n    \"\"\"Schema for updating GitHub backup config (all fields optional).\"\"\"\n\n    repository_url: str | None = Field(default=None, max_length=500)\n    access_token: str | None = Field(default=None)\n    branch: str | None = Field(default=None, max_length=100)\n\n    schedule_enabled: bool | None = None\n    schedule_type: ScheduleType | None = None\n\n    backup_kprofiles: bool | None = None\n    backup_cloud_profiles: bool | None = None\n    backup_settings: bool | None = None\n    backup_spools: bool | None = None\n    backup_archives: bool | None = None\n\n    enabled: bool | None = None\n\n    @field_validator(\"repository_url\")\n    @classmethod\n    def validate_repo_url(cls, v: str | None) -> str | None:\n        if v is None:\n            return v\n        patterns = [\n            r\"^https://github\\.com/[\\w.-]+/[\\w.-]+(?:\\.git)?$\",\n            r\"^git@github\\.com:[\\w.-]+/[\\w.-]+(?:\\.git)?$\",\n        ]\n        v = v.strip().rstrip(\"/\")\n        if not any(re.match(p, v) for p in patterns):\n            raise ValueError(\"Invalid GitHub repository URL\")\n        return v\n\n\nclass GitHubBackupConfigResponse(BaseModel):\n    \"\"\"Schema for GitHub backup config API response.\"\"\"\n\n    id: int\n    repository_url: str\n    has_token: bool = Field(description=\"Whether an access token is configured\")\n    branch: str\n\n    schedule_enabled: bool\n    schedule_type: str\n\n    backup_kprofiles: bool\n    backup_cloud_profiles: bool\n    backup_settings: bool\n    backup_spools: bool\n    backup_archives: bool\n\n    enabled: bool\n    last_backup_at: datetime | None\n    last_backup_status: str | None\n    last_backup_message: str | None\n    last_backup_commit_sha: str | None\n    next_scheduled_run: datetime | None\n\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass GitHubBackupLogResponse(BaseModel):\n    \"\"\"Schema for backup log API response.\"\"\"\n\n    id: int\n    config_id: int\n    started_at: datetime\n    completed_at: datetime | None\n    status: str\n    trigger: str\n    commit_sha: str | None\n    files_changed: int\n    error_message: str | None\n\n    class Config:\n        from_attributes = True\n\n\nclass GitHubBackupStatus(BaseModel):\n    \"\"\"Schema for current backup status.\"\"\"\n\n    configured: bool = Field(description=\"Whether backup is configured\")\n    enabled: bool = Field(description=\"Whether backup is enabled\")\n    is_running: bool = Field(description=\"Whether a backup is currently running\")\n    progress: str | None = Field(default=None, description=\"Current backup progress message\")\n    last_backup_at: datetime | None\n    last_backup_status: str | None\n    next_scheduled_run: datetime | None\n\n\nclass GitHubTestConnectionResponse(BaseModel):\n    \"\"\"Schema for test connection response.\"\"\"\n\n    success: bool\n    message: str\n    repo_name: str | None = None\n    permissions: dict | None = None\n\n\nclass GitHubBackupTriggerResponse(BaseModel):\n    \"\"\"Schema for manual backup trigger response.\"\"\"\n\n    success: bool\n    message: str\n    log_id: int | None = None\n    commit_sha: str | None = None\n    files_changed: int = 0\n"
  },
  {
    "path": "backend/app/schemas/group.py",
    "content": "\"\"\"Pydantic schemas for Group CRUD operations.\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass GroupBrief(BaseModel):\n    \"\"\"Brief group info for embedding in other responses.\"\"\"\n\n    id: int\n    name: str\n\n    class Config:\n        from_attributes = True\n\n\nclass GroupCreate(BaseModel):\n    \"\"\"Schema for creating a new group.\"\"\"\n\n    name: str\n    description: str | None = None\n    permissions: list[str] = []\n\n\nclass GroupUpdate(BaseModel):\n    \"\"\"Schema for updating a group.\"\"\"\n\n    name: str | None = None\n    description: str | None = None\n    permissions: list[str] | None = None\n\n\nclass GroupResponse(BaseModel):\n    \"\"\"Schema for group response.\"\"\"\n\n    id: int\n    name: str\n    description: str | None\n    permissions: list[str]\n    is_system: bool\n    user_count: int = 0\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass GroupDetailResponse(GroupResponse):\n    \"\"\"Schema for detailed group response including users.\"\"\"\n\n    users: list[\"UserBrief\"] = []\n\n\nclass UserBrief(BaseModel):\n    \"\"\"Brief user info for embedding in group response.\"\"\"\n\n    id: int\n    username: str\n    is_active: bool\n\n    class Config:\n        from_attributes = True\n\n\nclass PermissionInfo(BaseModel):\n    \"\"\"Schema for permission information.\"\"\"\n\n    value: str\n    label: str\n\n\nclass PermissionCategory(BaseModel):\n    \"\"\"Schema for a category of permissions.\"\"\"\n\n    name: str\n    permissions: list[PermissionInfo]\n\n\nclass PermissionsListResponse(BaseModel):\n    \"\"\"Schema for listing all permissions by category.\"\"\"\n\n    categories: list[PermissionCategory]\n    all_permissions: list[str]\n\n\n# Update forward references\nGroupDetailResponse.model_rebuild()\n"
  },
  {
    "path": "backend/app/schemas/kprofile.py",
    "content": "\"\"\"Pydantic schemas for K-profile (pressure advance) management.\"\"\"\n\nfrom pydantic import BaseModel\n\n\nclass KProfile(BaseModel):\n    \"\"\"A pressure advance (K) calibration profile stored on the printer.\"\"\"\n\n    slot_id: int  # Storage slot on printer (limited capacity ~20 slots)\n    extruder_id: int = 0  # 0 or 1 for dual nozzle printers\n    nozzle_id: str  # e.g., \"HS00-0.4\" (hardened steel 0.4mm)\n    nozzle_diameter: str  # e.g., \"0.4\"\n    filament_id: str  # Bambu filament identifier\n    name: str  # User-defined name for the profile\n    k_value: str  # Pressure advance coefficient as string, e.g., \"0.020000\"\n    n_coef: str = \"0.000000\"  # N coefficient (usually 0)\n    ams_id: int = 0  # AMS unit ID\n    tray_id: int = -1  # AMS tray ID (-1 if not linked)\n    setting_id: str | None = None  # Unique setting identifier\n\n\nclass KProfileCreate(BaseModel):\n    \"\"\"Schema for creating/updating a K-profile.\"\"\"\n\n    slot_id: int = 0  # Storage slot, 0 for new profiles\n    extruder_id: int = 0\n    nozzle_id: str\n    nozzle_diameter: str\n    filament_id: str\n    name: str\n    k_value: str\n    n_coef: str = \"0.000000\"\n    ams_id: int = 0\n    tray_id: int = -1\n    setting_id: str | None = None\n\n\nclass KProfilesResponse(BaseModel):\n    \"\"\"Response containing K-profiles from a printer.\"\"\"\n\n    profiles: list[KProfile]\n    nozzle_diameter: str  # Current nozzle filter\n\n\nclass KProfileDelete(BaseModel):\n    \"\"\"Schema for deleting a K-profile.\"\"\"\n\n    slot_id: int  # cali_idx - calibration index to delete\n    extruder_id: int = 0\n    nozzle_id: str  # e.g., \"HH00-0.4\"\n    nozzle_diameter: str  # e.g., \"0.4\"\n    filament_id: str  # Bambu filament identifier\n    setting_id: str | None = None  # Setting ID (for X1C series)\n\n\nclass KProfileNote(BaseModel):\n    \"\"\"Schema for K-profile notes (stored locally, not on printer).\"\"\"\n\n    setting_id: str  # Unique identifier for the K-profile\n    note: str  # The note content\n\n\nclass KProfileNoteResponse(BaseModel):\n    \"\"\"Response containing notes for K-profiles.\"\"\"\n\n    notes: dict[str, str]  # mapping of setting_id -> note\n"
  },
  {
    "path": "backend/app/schemas/library.py",
    "content": "\"\"\"Pydantic schemas for library (File Manager) functionality.\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel, Field\n\n# ============ Folder Schemas ============\n\n\nclass FolderCreate(BaseModel):\n    \"\"\"Schema for creating a new folder.\"\"\"\n\n    name: str = Field(..., min_length=1, max_length=255)\n    parent_id: int | None = None\n    project_id: int | None = None\n    archive_id: int | None = None\n\n\nclass ExternalFolderCreate(BaseModel):\n    \"\"\"Schema for linking an external folder.\"\"\"\n\n    name: str = Field(..., min_length=1, max_length=255)\n    external_path: str = Field(..., min_length=1, max_length=500)\n    readonly: bool = True\n    show_hidden: bool = False\n    parent_id: int | None = None\n\n\nclass FolderUpdate(BaseModel):\n    \"\"\"Schema for updating a folder.\"\"\"\n\n    name: str | None = Field(None, min_length=1, max_length=255)\n    parent_id: int | None = None\n    project_id: int | None = None  # 0 to unlink\n    archive_id: int | None = None  # 0 to unlink\n\n\nclass FolderResponse(BaseModel):\n    \"\"\"Schema for folder response.\"\"\"\n\n    id: int\n    name: str\n    parent_id: int | None\n    project_id: int | None = None\n    archive_id: int | None = None\n    project_name: str | None = None\n    archive_name: str | None = None\n    is_external: bool = False\n    external_path: str | None = None\n    external_readonly: bool = False\n    external_show_hidden: bool = False\n    file_count: int = 0  # Computed field\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass FolderTreeItem(BaseModel):\n    \"\"\"Schema for folder tree item (includes children).\"\"\"\n\n    id: int\n    name: str\n    parent_id: int | None\n    project_id: int | None = None\n    archive_id: int | None = None\n    project_name: str | None = None\n    archive_name: str | None = None\n    is_external: bool = False\n    external_path: str | None = None\n    external_readonly: bool = False\n    file_count: int = 0\n    children: list[\"FolderTreeItem\"] = []\n\n    class Config:\n        from_attributes = True\n\n\n# ============ File Schemas ============\n\n\nclass FileCreate(BaseModel):\n    \"\"\"Schema for creating a file entry (internal use after upload).\"\"\"\n\n    filename: str\n    file_path: str\n    file_type: str\n    file_size: int\n    file_hash: str | None = None\n    thumbnail_path: str | None = None\n    metadata: dict | None = None\n    folder_id: int | None = None\n    project_id: int | None = None\n\n\nclass FileUpdate(BaseModel):\n    \"\"\"Schema for updating a file.\"\"\"\n\n    filename: str | None = Field(None, min_length=1, max_length=255)\n    folder_id: int | None = None\n    project_id: int | None = None\n    notes: str | None = None\n\n\nclass FileDuplicate(BaseModel):\n    \"\"\"Reference to a duplicate file.\"\"\"\n\n    id: int\n    filename: str\n    folder_id: int | None\n    folder_name: str | None\n    created_at: datetime\n\n\nclass FileResponse(BaseModel):\n    \"\"\"Schema for file response.\"\"\"\n\n    id: int\n    folder_id: int | None\n    folder_name: str | None = None\n    project_id: int | None\n    project_name: str | None = None\n    is_external: bool = False\n\n    filename: str\n    file_path: str\n    file_type: str\n    file_size: int\n    file_hash: str | None\n    thumbnail_path: str | None\n\n    metadata: dict | None\n\n    print_count: int\n    last_printed_at: datetime | None\n\n    notes: str | None\n\n    # Duplicate detection\n    duplicates: list[FileDuplicate] | None = None\n    duplicate_count: int = 0\n\n    # User tracking (Issue #206)\n    created_by_id: int | None = None\n    created_by_username: str | None = None\n\n    created_at: datetime\n    updated_at: datetime\n\n    # Metadata fields\n    print_name: str | None = None\n    print_time_seconds: int | None = None\n    filament_used_grams: float | None = None\n    sliced_for_model: str | None = None\n\n    class Config:\n        from_attributes = True\n\n\nclass FileListResponse(BaseModel):\n    \"\"\"Schema for file list item (lighter than full response).\"\"\"\n\n    id: int\n    folder_id: int | None\n    is_external: bool = False\n    filename: str\n    file_type: str\n    file_size: int\n    thumbnail_path: str | None\n    print_count: int\n    duplicate_count: int = 0\n    # User tracking (Issue #206)\n    created_by_id: int | None = None\n    created_by_username: str | None = None\n    created_at: datetime\n\n    # Key metadata fields for display\n    print_name: str | None = None\n    print_time_seconds: int | None = None\n    filament_used_grams: float | None = None\n    sliced_for_model: str | None = None\n\n    class Config:\n        from_attributes = True\n\n\nclass FileMoveRequest(BaseModel):\n    \"\"\"Schema for moving files to a folder.\"\"\"\n\n    file_ids: list[int]\n    folder_id: int | None = None  # None = move to root\n\n\nclass FilePrintRequest(BaseModel):\n    \"\"\"Schema for printing a file from the library.\n\n    Note: printer_id is passed as a query parameter, not in the body.\n    \"\"\"\n\n    # Print options (same as archive reprint)\n    plate_id: int | None = None\n    plate_name: str | None = None\n    ams_mapping: list[int] | None = None\n    bed_levelling: bool = True\n    flow_cali: bool = False\n    vibration_cali: bool = True\n    layer_inspect: bool = False\n    timelapse: bool = False\n    use_ams: bool = True\n    # Project to associate the resulting archive with\n    project_id: int | None = None\n    # When true, delete the LibraryFile row + disk file after the archive has\n    # been created and the print has been dispatched. Used by the Printers-page\n    # Direct-Print flow (click / drag-drop a file onto a printer card) so the\n    # transient upload doesn't linger in File Manager. Cleanup is skipped on\n    # external library files.\n    cleanup_library_after_dispatch: bool = False\n\n\nclass FileUploadResponse(BaseModel):\n    \"\"\"Schema for file upload response.\"\"\"\n\n    id: int\n    filename: str\n    file_type: str\n    file_size: int\n    thumbnail_path: str | None\n    duplicate_of: int | None = None  # ID of existing file with same hash\n    metadata: dict | None = None\n\n\n# ============ Bulk Operations ============\n\n\nclass BulkDeleteRequest(BaseModel):\n    \"\"\"Schema for bulk delete operations.\"\"\"\n\n    file_ids: list[int] = []\n    folder_ids: list[int] = []\n\n\nclass BulkDeleteResponse(BaseModel):\n    \"\"\"Schema for bulk delete response.\"\"\"\n\n    deleted_files: int\n    deleted_folders: int\n\n\n# ============ Queue Operations ============\n\n\nclass AddToQueueRequest(BaseModel):\n    \"\"\"Schema for adding library files to the print queue.\"\"\"\n\n    file_ids: list[int] = Field(..., min_length=1)\n\n\nclass AddToQueueResult(BaseModel):\n    \"\"\"Result for a single file added to queue.\"\"\"\n\n    file_id: int\n    filename: str\n    queue_item_id: int\n\n\nclass AddToQueueError(BaseModel):\n    \"\"\"Error for a file that couldn't be added to queue.\"\"\"\n\n    file_id: int\n    filename: str\n    error: str\n\n\nclass AddToQueueResponse(BaseModel):\n    \"\"\"Schema for add-to-queue response.\"\"\"\n\n    added: list[AddToQueueResult]\n    errors: list[AddToQueueError]\n\n\n# ============ ZIP Extraction ============\n\n\nclass ZipExtractResult(BaseModel):\n    \"\"\"Result for a single file extracted from ZIP.\"\"\"\n\n    filename: str\n    file_id: int\n    folder_id: int | None = None\n\n\nclass ZipExtractError(BaseModel):\n    \"\"\"Error for a file that couldn't be extracted.\"\"\"\n\n    filename: str\n    error: str\n\n\nclass ZipExtractResponse(BaseModel):\n    \"\"\"Schema for ZIP extraction response.\"\"\"\n\n    extracted: int\n    folders_created: int\n    files: list[ZipExtractResult]\n    errors: list[ZipExtractError]\n\n\n# ============ STL Thumbnail Generation ============\n\n\nclass BatchThumbnailRequest(BaseModel):\n    \"\"\"Schema for batch STL thumbnail generation request.\"\"\"\n\n    file_ids: list[int] | None = None\n    folder_id: int | None = None\n    all_missing: bool = False\n\n\nclass BatchThumbnailResult(BaseModel):\n    \"\"\"Result for a single file thumbnail generation.\"\"\"\n\n    file_id: int\n    filename: str\n    success: bool\n    error: str | None = None\n\n\nclass BatchThumbnailResponse(BaseModel):\n    \"\"\"Schema for batch thumbnail generation response.\"\"\"\n\n    processed: int\n    succeeded: int\n    failed: int\n    results: list[BatchThumbnailResult]\n"
  },
  {
    "path": "backend/app/schemas/local_preset.py",
    "content": "\"\"\"Pydantic schemas for local preset API.\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass LocalPresetResponse(BaseModel):\n    \"\"\"Local preset summary (without full setting blob).\"\"\"\n\n    id: int\n    name: str\n    preset_type: str\n    source: str\n    filament_type: str | None = None\n    filament_vendor: str | None = None\n    nozzle_temp_min: int | None = None\n    nozzle_temp_max: int | None = None\n    pressure_advance: str | None = None\n    default_filament_colour: str | None = None\n    filament_cost: str | None = None\n    filament_density: str | None = None\n    compatible_printers: str | None = None\n    inherits: str | None = None\n    version: str | None = None\n    created_at: datetime\n    updated_at: datetime\n\n    model_config = {\"from_attributes\": True}\n\n\nclass LocalPresetDetail(LocalPresetResponse):\n    \"\"\"Full preset detail including the resolved setting JSON.\"\"\"\n\n    setting: dict\n\n\nclass LocalPresetCreate(BaseModel):\n    \"\"\"Schema for manually creating a local preset.\"\"\"\n\n    name: str\n    preset_type: str  # filament, printer, process\n    setting: dict\n\n\nclass LocalPresetUpdate(BaseModel):\n    \"\"\"Schema for updating a local preset.\"\"\"\n\n    name: str | None = None\n    setting: dict | None = None\n\n\nclass LocalPresetsResponse(BaseModel):\n    \"\"\"Grouped local presets by type.\"\"\"\n\n    filament: list[LocalPresetResponse] = []\n    printer: list[LocalPresetResponse] = []\n    process: list[LocalPresetResponse] = []\n\n\nclass ImportResponse(BaseModel):\n    \"\"\"Result of an import operation.\"\"\"\n\n    success: bool\n    imported: int\n    skipped: int\n    errors: list[str] = []\n"
  },
  {
    "path": "backend/app/schemas/maintenance.py",
    "content": "\"\"\"Maintenance tracking schemas.\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel, Field\n\n\n# Maintenance Type schemas\nclass MaintenanceTypeBase(BaseModel):\n    name: str = Field(..., min_length=1, max_length=100)\n    description: str | None = None\n    default_interval_hours: float = Field(default=100.0, ge=1.0)\n    # \"hours\" = print hours, \"days\" = calendar days\n    interval_type: str = Field(default=\"hours\", pattern=\"^(hours|days)$\")\n    icon: str | None = None\n    wiki_url: str | None = None  # Documentation link for custom types\n\n\nclass MaintenanceTypeCreate(MaintenanceTypeBase):\n    pass\n\n\nclass MaintenanceTypeUpdate(BaseModel):\n    name: str | None = None\n    description: str | None = None\n    default_interval_hours: float | None = Field(default=None, ge=1.0)\n    interval_type: str | None = Field(default=None, pattern=\"^(hours|days)$\")\n    icon: str | None = None\n    wiki_url: str | None = None\n\n\nclass MaintenanceTypeResponse(MaintenanceTypeBase):\n    id: int\n    is_system: bool\n    created_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\n# Printer Maintenance schemas\nclass PrinterMaintenanceBase(BaseModel):\n    printer_id: int\n    maintenance_type_id: int\n    custom_interval_hours: float | None = None\n    enabled: bool = True\n\n\nclass PrinterMaintenanceCreate(PrinterMaintenanceBase):\n    pass\n\n\nclass PrinterMaintenanceUpdate(BaseModel):\n    custom_interval_hours: float | None = None\n    custom_interval_type: str | None = Field(default=None, pattern=\"^(hours|days)$\")\n    enabled: bool | None = None\n\n\nclass PrinterMaintenanceResponse(BaseModel):\n    id: int\n    printer_id: int\n    maintenance_type_id: int\n    maintenance_type: MaintenanceTypeResponse\n    custom_interval_hours: float | None\n    enabled: bool\n    last_performed_at: datetime | None\n    last_performed_hours: float\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\n# Maintenance History schemas\nclass MaintenanceHistoryBase(BaseModel):\n    notes: str | None = None\n\n\nclass MaintenanceHistoryCreate(MaintenanceHistoryBase):\n    pass\n\n\nclass MaintenanceHistoryResponse(MaintenanceHistoryBase):\n    id: int\n    printer_maintenance_id: int\n    performed_at: datetime\n    hours_at_maintenance: float\n\n    class Config:\n        from_attributes = True\n\n\n# Combined status response for frontend\nclass MaintenanceStatus(BaseModel):\n    \"\"\"Maintenance status for a printer with calculated values.\"\"\"\n\n    id: int\n    printer_id: int\n    printer_name: str\n    printer_model: str | None  # For model-specific documentation links\n    maintenance_type_id: int\n    maintenance_type_name: str\n    maintenance_type_icon: str | None\n    maintenance_type_wiki_url: str | None  # Custom wiki URL for the type\n    enabled: bool\n    # Interval configuration\n    interval_hours: float  # custom or default (hours for print-based, days for time-based)\n    interval_type: str  # \"hours\" or \"days\"\n    # For print-hour based maintenance\n    current_hours: float  # total print hours for printer\n    hours_since_maintenance: float  # current - last_performed\n    hours_until_due: float  # interval - hours_since (for hours type)\n    # For time-based maintenance\n    days_since_maintenance: float | None  # days since last performed\n    days_until_due: float | None  # for days type\n    # Status flags\n    is_due: bool  # hours_until_due <= 0 OR days_until_due <= 0\n    is_warning: bool  # within 10% of interval\n    last_performed_at: datetime | None\n\n\nclass PrinterMaintenanceOverview(BaseModel):\n    \"\"\"Overview of all maintenance items for a printer.\"\"\"\n\n    printer_id: int\n    printer_name: str\n    printer_model: str | None  # For model-specific documentation links\n    total_print_hours: float\n    maintenance_items: list[MaintenanceStatus]\n    due_count: int\n    warning_count: int\n\n\nclass PerformMaintenanceRequest(BaseModel):\n    \"\"\"Request to mark maintenance as performed.\"\"\"\n\n    notes: str | None = None\n"
  },
  {
    "path": "backend/app/schemas/notification.py",
    "content": "\"\"\"Pydantic schemas for notification providers.\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any\n\nfrom pydantic import BaseModel, Field, field_validator\n\nfrom backend.app.core.compat import StrEnum\n\n\nclass ProviderType(StrEnum):\n    \"\"\"Supported notification provider types.\"\"\"\n\n    CALLMEBOT = \"callmebot\"\n    NTFY = \"ntfy\"\n    PUSHOVER = \"pushover\"\n    TELEGRAM = \"telegram\"\n    EMAIL = \"email\"\n    DISCORD = \"discord\"\n    WEBHOOK = \"webhook\"\n    HOMEASSISTANT = \"homeassistant\"\n\n\nclass NotificationProviderBase(BaseModel):\n    \"\"\"Base schema for notification providers.\"\"\"\n\n    name: str = Field(..., min_length=1, max_length=100, description=\"User-defined name\")\n    provider_type: ProviderType = Field(..., description=\"Type of notification provider\")\n    enabled: bool = Field(default=True, description=\"Whether notifications are enabled\")\n    config: dict[str, Any] = Field(..., description=\"Provider-specific configuration\")\n\n    # Event triggers - print lifecycle\n    on_print_start: bool = Field(default=False, description=\"Notify on print start\")\n    on_print_complete: bool = Field(default=True, description=\"Notify on print complete\")\n    on_print_failed: bool = Field(default=True, description=\"Notify on print failed\")\n    on_print_stopped: bool = Field(default=True, description=\"Notify when print is stopped/cancelled\")\n    on_print_progress: bool = Field(default=False, description=\"Notify at 25%, 50%, 75% progress\")\n    on_print_missing_spool_assignment: bool = Field(\n        default=False,\n        description=\"Notify when a print starts with required trays missing spool assignments\",\n    )\n\n    # Event triggers - printer status\n    on_printer_offline: bool = Field(default=False, description=\"Notify when printer goes offline\")\n    on_printer_error: bool = Field(default=False, description=\"Notify on printer errors (AMS, etc.)\")\n    on_filament_low: bool = Field(default=False, description=\"Notify when filament is running low\")\n    on_maintenance_due: bool = Field(default=False, description=\"Notify when maintenance is due\")\n\n    # Event triggers - AMS environmental alarms (regular AMS)\n    on_ams_humidity_high: bool = Field(default=False, description=\"Notify when AMS humidity exceeds threshold\")\n    on_ams_temperature_high: bool = Field(default=False, description=\"Notify when AMS temperature exceeds threshold\")\n\n    # Event triggers - AMS-HT environmental alarms\n    on_ams_ht_humidity_high: bool = Field(default=False, description=\"Notify when AMS-HT humidity exceeds threshold\")\n    on_ams_ht_temperature_high: bool = Field(\n        default=False, description=\"Notify when AMS-HT temperature exceeds threshold\"\n    )\n\n    # Event triggers - Build plate detection\n    on_plate_not_empty: bool = Field(default=True, description=\"Notify when objects detected on plate before print\")\n\n    # Event triggers - Bed cooled\n    on_bed_cooled: bool = Field(default=False, description=\"Notify when bed cools after print\")\n\n    # Event triggers - First layer complete\n    on_first_layer_complete: bool = Field(default=False, description=\"Notify when first layer completes\")\n\n    # Event triggers - Print queue\n    on_queue_job_added: bool = Field(default=False, description=\"Notify when job is added to queue\")\n    on_queue_job_assigned: bool = Field(default=False, description=\"Notify when model-based job is assigned to printer\")\n    on_queue_job_started: bool = Field(default=False, description=\"Notify when queue job starts printing\")\n    on_queue_job_waiting: bool = Field(default=True, description=\"Notify when job is waiting for filament or printer\")\n    on_queue_job_skipped: bool = Field(default=True, description=\"Notify when job is skipped\")\n    on_queue_job_failed: bool = Field(default=True, description=\"Notify when job fails to start\")\n    on_queue_completed: bool = Field(default=False, description=\"Notify when all queue jobs finish\")\n\n    # Quiet hours\n    quiet_hours_enabled: bool = Field(default=False, description=\"Enable quiet hours\")\n    quiet_hours_start: str | None = Field(default=None, description=\"Start time in HH:MM format\")\n    quiet_hours_end: str | None = Field(default=None, description=\"End time in HH:MM format\")\n\n    # Daily digest\n    daily_digest_enabled: bool = Field(default=False, description=\"Batch notifications into daily digest\")\n    daily_digest_time: str | None = Field(default=None, description=\"Time to send digest in HH:MM format\")\n\n    # Printer filter\n    printer_id: int | None = Field(default=None, description=\"Specific printer ID or null for all\")\n\n    @field_validator(\"quiet_hours_start\", \"quiet_hours_end\", \"daily_digest_time\")\n    @classmethod\n    def validate_time_format(cls, v: str | None) -> str | None:\n        if v is None:\n            return v\n        try:\n            parts = v.split(\":\")\n            if len(parts) != 2:\n                raise ValueError(\"Invalid time format\")\n            hour, minute = int(parts[0]), int(parts[1])\n            if not (0 <= hour <= 23 and 0 <= minute <= 59):\n                raise ValueError(\"Invalid time range\")\n            return f\"{hour:02d}:{minute:02d}\"\n        except (ValueError, TypeError):\n            raise ValueError(\"Time must be in HH:MM format (e.g., 22:00)\")\n\n\nclass NotificationProviderCreate(NotificationProviderBase):\n    \"\"\"Schema for creating a notification provider.\"\"\"\n\n    pass\n\n\nclass NotificationProviderUpdate(BaseModel):\n    \"\"\"Schema for updating a notification provider (all fields optional).\"\"\"\n\n    name: str | None = Field(default=None, min_length=1, max_length=100)\n    provider_type: ProviderType | None = None\n    enabled: bool | None = None\n    config: dict[str, Any] | None = None\n\n    # Event triggers - print lifecycle\n    on_print_start: bool | None = None\n    on_print_complete: bool | None = None\n    on_print_failed: bool | None = None\n    on_print_stopped: bool | None = None\n    on_print_progress: bool | None = None\n    on_print_missing_spool_assignment: bool | None = None\n\n    # Event triggers - printer status\n    on_printer_offline: bool | None = None\n    on_printer_error: bool | None = None\n    on_filament_low: bool | None = None\n    on_maintenance_due: bool | None = None\n\n    # Event triggers - AMS environmental alarms (regular AMS)\n    on_ams_humidity_high: bool | None = None\n    on_ams_temperature_high: bool | None = None\n\n    # Event triggers - AMS-HT environmental alarms\n    on_ams_ht_humidity_high: bool | None = None\n    on_ams_ht_temperature_high: bool | None = None\n\n    # Event triggers - Build plate detection\n    on_plate_not_empty: bool | None = None\n\n    # Event triggers - Bed cooled\n    on_bed_cooled: bool | None = None\n\n    # Event triggers - First layer complete\n    on_first_layer_complete: bool | None = None\n\n    # Event triggers - Print queue\n    on_queue_job_added: bool | None = None\n    on_queue_job_assigned: bool | None = None\n    on_queue_job_started: bool | None = None\n    on_queue_job_waiting: bool | None = None\n    on_queue_job_skipped: bool | None = None\n    on_queue_job_failed: bool | None = None\n    on_queue_completed: bool | None = None\n\n    # Quiet hours\n    quiet_hours_enabled: bool | None = None\n    quiet_hours_start: str | None = None\n    quiet_hours_end: str | None = None\n\n    # Daily digest\n    daily_digest_enabled: bool | None = None\n    daily_digest_time: str | None = None\n\n    # Printer filter\n    printer_id: int | None = None\n\n\nclass NotificationProviderResponse(NotificationProviderBase):\n    \"\"\"Schema for notification provider API responses.\"\"\"\n\n    id: int\n    last_success: datetime | None = None\n    last_error: str | None = None\n    last_error_at: datetime | None = None\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass NotificationTestRequest(BaseModel):\n    \"\"\"Schema for testing notification configuration.\"\"\"\n\n    provider_type: ProviderType\n    config: dict[str, Any]\n\n\nclass NotificationTestResponse(BaseModel):\n    \"\"\"Schema for test notification response.\"\"\"\n\n    success: bool\n    message: str\n\n\n# Provider-specific config schemas for documentation/validation reference\nclass CallMeBotConfig(BaseModel):\n    \"\"\"CallMeBot/WhatsApp configuration.\"\"\"\n\n    phone: str = Field(..., description=\"Phone number with country code (e.g., +1234567890)\")\n    apikey: str = Field(..., description=\"API key from CallMeBot\")\n\n\nclass NtfyConfig(BaseModel):\n    \"\"\"ntfy configuration.\"\"\"\n\n    server: str = Field(default=\"https://ntfy.sh\", description=\"ntfy server URL\")\n    topic: str = Field(..., description=\"Topic name to publish to\")\n    auth_token: str | None = Field(default=None, description=\"Optional authentication token\")\n\n\nclass PushoverConfig(BaseModel):\n    \"\"\"Pushover configuration.\"\"\"\n\n    user_key: str = Field(..., description=\"Your Pushover user key\")\n    app_token: str = Field(..., description=\"Your Pushover application token\")\n    priority: int = Field(default=0, ge=-2, le=2, description=\"Message priority (-2 to 2)\")\n\n\nclass TelegramConfig(BaseModel):\n    \"\"\"Telegram bot configuration.\"\"\"\n\n    bot_token: str = Field(..., description=\"Bot token from @BotFather\")\n    chat_id: str = Field(..., description=\"Chat ID to send messages to\")\n\n\nclass EmailConfig(BaseModel):\n    \"\"\"Email/SMTP configuration.\"\"\"\n\n    smtp_server: str = Field(..., description=\"SMTP server hostname\")\n    smtp_port: int = Field(default=587, description=\"SMTP port (587 for TLS, 465 for SSL)\")\n    username: str = Field(..., description=\"SMTP username/email\")\n    password: str = Field(..., description=\"SMTP password or app password\")\n    from_email: str = Field(..., description=\"From email address\")\n    to_email: str = Field(..., description=\"Recipient email address\")\n    use_tls: bool = Field(default=True, description=\"Use TLS encryption\")\n\n\n# Notification Log schemas\nclass NotificationLogResponse(BaseModel):\n    \"\"\"Schema for notification log API responses.\"\"\"\n\n    id: int\n    provider_id: int\n    provider_name: str | None = None\n    provider_type: str | None = None\n    event_type: str\n    title: str\n    message: str\n    success: bool\n    error_message: str | None = None\n    printer_id: int | None = None\n    printer_name: str | None = None\n    created_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass NotificationLogStats(BaseModel):\n    \"\"\"Statistics for notification logs.\"\"\"\n\n    total: int\n    success_count: int\n    failure_count: int\n    by_event_type: dict[str, int]\n    by_provider: dict[str, int]\n"
  },
  {
    "path": "backend/app/schemas/notification_template.py",
    "content": "\"\"\"Pydantic schemas for notification templates.\"\"\"\n\nfrom datetime import datetime\n\nfrom pydantic import BaseModel, Field\n\nfrom backend.app.core.compat import StrEnum\n\n\nclass EventType(StrEnum):\n    \"\"\"Supported notification event types.\"\"\"\n\n    PRINT_START = \"print_start\"\n    PRINT_COMPLETE = \"print_complete\"\n    PRINT_FAILED = \"print_failed\"\n    PRINT_STOPPED = \"print_stopped\"\n    PRINT_PROGRESS = \"print_progress\"\n    PRINT_MISSING_SPOOL_ASSIGNMENT = \"print_missing_spool_assignment\"\n    PRINTER_OFFLINE = \"printer_offline\"\n    PRINTER_ERROR = \"printer_error\"\n    FILAMENT_LOW = \"filament_low\"\n    MAINTENANCE_DUE = \"maintenance_due\"\n    AMS_HUMIDITY_HIGH = \"ams_humidity_high\"\n    AMS_TEMPERATURE_HIGH = \"ams_temperature_high\"\n    BED_COOLED = \"bed_cooled\"\n    TEST = \"test\"\n\n\n# Available variables for each event type\nEVENT_VARIABLES: dict[str, list[str]] = {\n    \"print_start\": [\"printer\", \"filename\", \"estimated_time\", \"eta\", \"timestamp\", \"app_name\"],\n    \"print_complete\": [\n        \"printer\",\n        \"filename\",\n        \"duration\",\n        \"filament_grams\",\n        \"filament_details\",\n        \"finish_photo_url\",\n        \"timestamp\",\n        \"app_name\",\n    ],\n    \"print_failed\": [\n        \"printer\",\n        \"filename\",\n        \"duration\",\n        \"filament_grams\",\n        \"filament_details\",\n        \"progress\",\n        \"reason\",\n        \"finish_photo_url\",\n        \"timestamp\",\n        \"app_name\",\n    ],\n    \"print_stopped\": [\n        \"printer\",\n        \"filename\",\n        \"duration\",\n        \"filament_grams\",\n        \"filament_details\",\n        \"progress\",\n        \"finish_photo_url\",\n        \"timestamp\",\n        \"app_name\",\n    ],\n    \"print_progress\": [\"printer\", \"filename\", \"progress\", \"remaining_time\", \"eta\", \"timestamp\", \"app_name\"],\n    \"print_missing_spool_assignment\": [\n        \"printer\",\n        \"missing_slots\",\n        \"missing_slot_details\",\n        \"timestamp\",\n        \"app_name\",\n    ],\n    \"printer_offline\": [\"printer\", \"timestamp\", \"app_name\"],\n    \"printer_error\": [\"printer\", \"error_type\", \"error_detail\", \"timestamp\", \"app_name\"],\n    \"filament_low\": [\"printer\", \"slot\", \"remaining_percent\", \"color\", \"timestamp\", \"app_name\"],\n    \"maintenance_due\": [\"printer\", \"items\", \"timestamp\", \"app_name\"],\n    \"ams_humidity_high\": [\"printer\", \"ams_label\", \"humidity\", \"threshold\", \"timestamp\", \"app_name\"],\n    \"ams_temperature_high\": [\"printer\", \"ams_label\", \"temperature\", \"threshold\", \"timestamp\", \"app_name\"],\n    \"bed_cooled\": [\"printer\", \"bed_temp\", \"threshold\", \"filename\", \"timestamp\", \"app_name\"],\n    \"test\": [\"app_name\", \"timestamp\"],\n    # Queue notifications\n    \"queue_job_added\": [\"job_name\", \"target\", \"timestamp\", \"app_name\"],\n    \"queue_job_assigned\": [\"job_name\", \"printer\", \"target_model\", \"timestamp\", \"app_name\"],\n    \"queue_job_started\": [\"printer\", \"job_name\", \"estimated_time\", \"eta\", \"timestamp\", \"app_name\"],\n    \"queue_job_waiting\": [\"job_name\", \"target_model\", \"waiting_reason\", \"timestamp\", \"app_name\"],\n    \"queue_job_skipped\": [\"printer\", \"job_name\", \"reason\", \"timestamp\", \"app_name\"],\n    \"queue_job_failed\": [\"printer\", \"job_name\", \"reason\", \"timestamp\", \"app_name\"],\n    \"queue_completed\": [\"completed_count\", \"timestamp\", \"app_name\"],\n    # User management notifications\n    \"user_created\": [\"username\", \"password\", \"login_url\", \"app_name\", \"timestamp\"],\n    \"password_reset\": [\"username\", \"password\", \"login_url\", \"app_name\", \"timestamp\"],\n    # User email print notifications\n    \"user_print_start\": [\"username\", \"printer\", \"filename\", \"timestamp\", \"app_name\"],\n    \"user_print_complete\": [\"username\", \"printer\", \"filename\", \"timestamp\", \"app_name\"],\n    \"user_print_failed\": [\"username\", \"printer\", \"filename\", \"timestamp\", \"app_name\"],\n    \"user_print_stopped\": [\"username\", \"printer\", \"filename\", \"timestamp\", \"app_name\"],\n}\n\n# Sample data for previewing templates\nSAMPLE_DATA: dict[str, dict[str, str]] = {\n    \"print_start\": {\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"estimated_time\": \"1h 23m\",\n        \"eta\": \"15:53\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"print_complete\": {\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"duration\": \"1h 18m\",\n        \"filament_grams\": \"15.2\",\n        \"filament_details\": \"AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g\",\n        \"finish_photo_url\": \"/api/v1/archives/123/photos/finish_20240115_154800_abc12345.jpg\",\n        \"timestamp\": \"2024-01-15 15:48\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"print_failed\": {\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"duration\": \"0h 45m\",\n        \"filament_grams\": \"7.6\",\n        \"filament_details\": \"AMS-A T1 PLA: 7.6g\",\n        \"progress\": \"50\",\n        \"reason\": \"Filament runout\",\n        \"finish_photo_url\": \"/api/v1/archives/123/photos/finish_20240115_151500_def67890.jpg\",\n        \"timestamp\": \"2024-01-15 15:15\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"print_stopped\": {\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"duration\": \"0h 30m\",\n        \"filament_grams\": \"4.6\",\n        \"filament_details\": \"AMS-A T2 PLA: 4.6g\",\n        \"progress\": \"30\",\n        \"finish_photo_url\": \"/api/v1/archives/123/photos/finish_20240115_150000_ghi11223.jpg\",\n        \"timestamp\": \"2024-01-15 15:00\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"print_progress\": {\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"progress\": \"50\",\n        \"remaining_time\": \"0h 41m\",\n        \"eta\": \"15:41\",\n        \"timestamp\": \"2024-01-15 15:00\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"print_missing_spool_assignment\": {\n        \"printer\": \"Bambu X1C\",\n        \"missing_slots\": \"A1, A3\",\n        \"missing_slot_details\": \"- A1: PLA Basic\\n- A3: PETG HF\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"printer_offline\": {\n        \"printer\": \"Bambu X1C\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"printer_error\": {\n        \"printer\": \"Bambu X1C\",\n        \"error_type\": \"AMS Error\",\n        \"error_detail\": \"Filament slot 1 jammed\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"filament_low\": {\n        \"printer\": \"Bambu X1C\",\n        \"slot\": \"1\",\n        \"remaining_percent\": \"15\",\n        \"color\": \"Black PLA\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"maintenance_due\": {\n        \"printer\": \"Bambu X1C\",\n        \"items\": \"• Nozzle cleaning (OVERDUE)\\n• Carbon rod lubrication (Soon)\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"ams_humidity_high\": {\n        \"printer\": \"Bambu X1C\",\n        \"ams_label\": \"AMS-A\",\n        \"humidity\": \"75\",\n        \"threshold\": \"60\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"ams_temperature_high\": {\n        \"printer\": \"Bambu X1C\",\n        \"ams_label\": \"AMS-A\",\n        \"temperature\": \"42\",\n        \"threshold\": \"35\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"bed_cooled\": {\n        \"printer\": \"Bambu X1C\",\n        \"bed_temp\": \"34\",\n        \"threshold\": \"35\",\n        \"filename\": \"Benchy\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"test\": {\n        \"app_name\": \"Bambuddy\",\n        \"timestamp\": \"2024-01-15 14:30\",\n    },\n    # Queue notifications\n    \"queue_job_added\": {\n        \"job_name\": \"Benchy.3mf\",\n        \"target\": \"Bambu X1C\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"queue_job_assigned\": {\n        \"job_name\": \"Benchy.3mf\",\n        \"printer\": \"Bambu X1C #1\",\n        \"target_model\": \"X1C\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"queue_job_started\": {\n        \"printer\": \"Bambu X1C\",\n        \"job_name\": \"Benchy.3mf\",\n        \"estimated_time\": \"1h 23m\",\n        \"eta\": \"15:53\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"queue_job_waiting\": {\n        \"job_name\": \"Benchy.3mf\",\n        \"target_model\": \"X1C\",\n        \"waiting_reason\": \"Printer1 (needs PLA)\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"queue_job_skipped\": {\n        \"printer\": \"Bambu X1C\",\n        \"job_name\": \"Benchy.3mf\",\n        \"reason\": \"Previous print failed\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"queue_job_failed\": {\n        \"printer\": \"Bambu X1C\",\n        \"job_name\": \"Benchy.3mf\",\n        \"reason\": \"Upload failed: connection timeout\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"queue_completed\": {\n        \"completed_count\": \"5\",\n        \"timestamp\": \"2024-01-15 18:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    # User management notifications\n    \"user_created\": {\n        \"username\": \"john_doe\",\n        \"password\": \"<generated-password>\",\n        \"login_url\": \"https://bambuddy.example.com/login\",\n        \"app_name\": \"Bambuddy\",\n        \"timestamp\": \"2024-01-15 14:30\",\n    },\n    \"password_reset\": {\n        \"username\": \"john_doe\",\n        \"password\": \"<new-password>\",\n        \"login_url\": \"https://bambuddy.example.com/login\",\n        \"app_name\": \"Bambuddy\",\n        \"timestamp\": \"2024-01-15 14:30\",\n    },\n    # User email print notifications\n    \"user_print_start\": {\n        \"username\": \"john_doe\",\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"timestamp\": \"2024-01-15 14:30\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"user_print_complete\": {\n        \"username\": \"john_doe\",\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"timestamp\": \"2024-01-15 15:48\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"user_print_failed\": {\n        \"username\": \"john_doe\",\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"timestamp\": \"2024-01-15 15:15\",\n        \"app_name\": \"Bambuddy\",\n    },\n    \"user_print_stopped\": {\n        \"username\": \"john_doe\",\n        \"printer\": \"Bambu X1C\",\n        \"filename\": \"Benchy.3mf\",\n        \"timestamp\": \"2024-01-15 15:15\",\n        \"app_name\": \"Bambuddy\",\n    },\n}\n\n\nclass NotificationTemplateBase(BaseModel):\n    \"\"\"Base schema for notification templates.\"\"\"\n\n    title_template: str = Field(..., min_length=1, max_length=200)\n    body_template: str = Field(..., min_length=1, max_length=2000)\n\n\nclass NotificationTemplateUpdate(BaseModel):\n    \"\"\"Schema for updating a notification template.\"\"\"\n\n    title_template: str | None = Field(default=None, min_length=1, max_length=200)\n    body_template: str | None = Field(default=None, min_length=1, max_length=2000)\n\n\nclass NotificationTemplateResponse(NotificationTemplateBase):\n    \"\"\"Schema for notification template API responses.\"\"\"\n\n    id: int\n    event_type: str\n    name: str\n    is_default: bool\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass TemplateVariableInfo(BaseModel):\n    \"\"\"Information about a template variable.\"\"\"\n\n    name: str\n    description: str\n\n\nclass EventVariablesResponse(BaseModel):\n    \"\"\"Response for available variables per event type.\"\"\"\n\n    event_type: str\n    event_name: str\n    variables: list[str]\n\n\nclass TemplatePreviewRequest(BaseModel):\n    \"\"\"Request to preview a template with sample data.\"\"\"\n\n    event_type: str\n    title_template: str\n    body_template: str\n\n\nclass TemplatePreviewResponse(BaseModel):\n    \"\"\"Response with rendered template preview.\"\"\"\n\n    title: str\n    body: str\n"
  },
  {
    "path": "backend/app/schemas/print_log.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass PrintLogEntrySchema(BaseModel):\n    id: int\n    print_name: str | None = None\n    printer_name: str | None = None\n    printer_id: int | None = None\n    status: str\n    started_at: datetime | None = None\n    completed_at: datetime | None = None\n    duration_seconds: int | None = None\n    filament_type: str | None = None\n    filament_color: str | None = None\n    filament_used_grams: float | None = None\n    thumbnail_path: str | None = None\n    created_by_username: str | None = None\n    created_at: datetime\n\n\nclass PrintLogResponse(BaseModel):\n    items: list[PrintLogEntrySchema]\n    total: int\n"
  },
  {
    "path": "backend/app/schemas/print_queue.py",
    "content": "from datetime import datetime\nfrom typing import Annotated, Literal\n\nfrom pydantic import BaseModel, PlainSerializer\n\n\n# Custom serializer to ensure UTC datetimes have Z suffix\ndef serialize_utc_datetime(dt: datetime | None) -> str | None:\n    if dt is None:\n        return None\n    # Add Z suffix to indicate UTC\n    return dt.isoformat() + \"Z\"\n\n\nUTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]\n\n\nclass PrintQueueItemCreate(BaseModel):\n    printer_id: int | None = None  # None = unassigned, user assigns later\n    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)\n    target_location: str | None = None  # Target location filter (only used with target_model)\n    required_filament_types: list[str] | None = None  # Required filament types for model-based assignment\n    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment\n    # Either archive_id OR library_file_id must be provided\n    archive_id: int | None = None\n    library_file_id: int | None = None\n    scheduled_time: datetime | None = None  # None = ASAP (next when idle)\n    require_previous_success: bool = False\n    auto_off_after: bool = False  # Power off printer after print completes\n    manual_start: bool = False  # Requires manual trigger to start (staged)\n    # AMS mapping: list of global tray IDs for each filament slot\n    # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)\n    ams_mapping: list[int] | None = None\n    # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)\n    plate_id: int | None = None\n    # Print options\n    bed_levelling: bool = True\n    flow_cali: bool = False\n    vibration_cali: bool = True\n    layer_inspect: bool = False\n    timelapse: bool = False\n    use_ams: bool = True\n    # Auto-print G-code injection\n    gcode_injection: bool = False\n    # Batch: create multiple copies (creates a batch if > 1)\n    quantity: int = 1\n    # Project to associate the resulting archive with\n    project_id: int | None = None\n\n\nclass PrintQueueItemUpdate(BaseModel):\n    printer_id: int | None = None\n    target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)\n    target_location: str | None = None  # Target location filter (only used with target_model)\n    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment\n    position: int | None = None\n    scheduled_time: datetime | None = None\n    require_previous_success: bool | None = None\n    auto_off_after: bool | None = None\n    manual_start: bool | None = None\n    ams_mapping: list[int] | None = None\n    plate_id: int | None = None\n    # Print options\n    bed_levelling: bool | None = None\n    flow_cali: bool | None = None\n    vibration_cali: bool | None = None\n    layer_inspect: bool | None = None\n    timelapse: bool | None = None\n    use_ams: bool | None = None\n    # Auto-print G-code injection\n    gcode_injection: bool | None = None\n\n\nclass PrintQueueItemResponse(BaseModel):\n    id: int\n    printer_id: int | None  # None = unassigned\n    target_model: str | None = None  # Target printer model for model-based assignment\n    target_location: str | None = None  # Target location filter for model-based assignment\n    required_filament_types: list[str] | None = None  # Required filament types for model-based assignment\n    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment\n    waiting_reason: str | None = None  # Why a model-based job hasn't started yet\n    archive_id: int | None  # None if library_file_id is set (archive created at print start)\n    library_file_id: int | None  # For queue items from library files\n    position: int\n    scheduled_time: UTCDatetime\n    require_previous_success: bool\n    auto_off_after: bool\n    manual_start: bool\n    ams_mapping: list[int] | None = None\n    plate_id: int | None = None  # Plate ID for multi-plate 3MF files\n    # Print options\n    bed_levelling: bool = True\n    flow_cali: bool = False\n    vibration_cali: bool = True\n    layer_inspect: bool = False\n    timelapse: bool = False\n    use_ams: bool = True\n    status: Literal[\"pending\", \"printing\", \"completed\", \"failed\", \"skipped\", \"cancelled\"]\n    started_at: UTCDatetime\n    completed_at: UTCDatetime\n    error_message: str | None\n    created_at: UTCDatetime\n\n    # Nested info for UI (populated in route)\n    archive_name: str | None = None\n    archive_thumbnail: str | None = None\n    library_file_name: str | None = None  # Name of library file (if library_file_id is set)\n    library_file_thumbnail: str | None = None  # Thumbnail of library file\n    printer_name: str | None = None\n    print_time_seconds: int | None = None  # Estimated print time from archive or library file\n    filament_used_grams: float | None = None  # Estimated print weight from archive or library file\n    filament_type: str | None = None  # e.g. \"PLA\", \"PETG\" (from archive/library file)\n    filament_color: str | None = None  # e.g. \"#FFFFFF\" (from archive/library file)\n    layer_height: float | None = None  # e.g. 0.2 (from archive/library file)\n    nozzle_diameter: float | None = None  # e.g. 0.4 (from archive/library file)\n    sliced_for_model: str | None = None  # e.g. \"P1S\" (from archive/library file)\n\n    # User tracking (Issue #206)\n    created_by_id: int | None = None\n    created_by_username: str | None = None\n\n    # Batch grouping\n    batch_id: int | None = None\n    batch_name: str | None = None\n\n    # Shortest-job-first scheduling\n    been_jumped: bool = False\n\n    # Auto-print G-code injection\n    gcode_injection: bool = False\n\n    class Config:\n        from_attributes = True\n\n\nclass PrintQueueReorderItem(BaseModel):\n    id: int\n    position: int\n\n\nclass PrintQueueReorder(BaseModel):\n    items: list[PrintQueueReorderItem]\n\n\nclass PrintQueueBulkUpdate(BaseModel):\n    \"\"\"Bulk update multiple queue items with the same values.\"\"\"\n\n    item_ids: list[int]\n    # Fields to update (all optional - only set fields are applied)\n    printer_id: int | None = None\n    scheduled_time: datetime | None = None\n    require_previous_success: bool | None = None\n    auto_off_after: bool | None = None\n    manual_start: bool | None = None\n    # Print options\n    bed_levelling: bool | None = None\n    flow_cali: bool | None = None\n    vibration_cali: bool | None = None\n    layer_inspect: bool | None = None\n    timelapse: bool | None = None\n    use_ams: bool | None = None\n    # Auto-print G-code injection\n    gcode_injection: bool | None = None\n\n\nclass PrintQueueBulkUpdateResponse(BaseModel):\n    \"\"\"Response for bulk update operation.\"\"\"\n\n    updated_count: int\n    skipped_count: int  # Items that were not pending\n    message: str\n\n\nclass PrintBatchResponse(BaseModel):\n    \"\"\"Response for a print batch with progress stats.\"\"\"\n\n    id: int\n    name: str\n    archive_id: int | None = None\n    library_file_id: int | None = None\n    quantity: int\n    status: str\n    created_at: UTCDatetime\n    created_by_id: int | None = None\n    created_by_username: str | None = None\n    # Derived counts\n    pending_count: int = 0\n    printing_count: int = 0\n    completed_count: int = 0\n    failed_count: int = 0\n    cancelled_count: int = 0\n\n    class Config:\n        from_attributes = True\n"
  },
  {
    "path": "backend/app/schemas/printer.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel, Field\n\n\nclass PrinterBase(BaseModel):\n    name: str = Field(..., min_length=1, max_length=100)\n    serial_number: str = Field(..., min_length=1, max_length=50)\n    ip_address: str = Field(\n        ...,\n        max_length=253,\n        pattern=r\"^(\\d{1,3}(\\.\\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*)$\",\n    )\n    access_code: str = Field(..., min_length=1, max_length=20)\n    model: str | None = None\n    location: str | None = None  # Group/location name\n    auto_archive: bool = True\n    external_camera_url: str | None = None\n    external_camera_type: str | None = None  # \"mjpeg\", \"rtsp\", \"snapshot\", \"usb\"\n    external_camera_enabled: bool = False\n    camera_rotation: int = 0  # 0, 90, 180, 270 degrees\n\n\nclass PrinterCreate(PrinterBase):\n    pass\n\n\nclass PlateDetectionROI(BaseModel):\n    \"\"\"Region of interest for plate detection (percentages 0.0-1.0).\"\"\"\n\n    x: float = Field(..., ge=0.0, le=1.0)  # X start %\n    y: float = Field(..., ge=0.0, le=1.0)  # Y start %\n    w: float = Field(..., ge=0.0, le=1.0)  # Width %\n    h: float = Field(..., ge=0.0, le=1.0)  # Height %\n\n\nclass PrinterUpdate(BaseModel):\n    name: str | None = None\n    ip_address: str | None = Field(\n        default=None,\n        max_length=253,\n        pattern=r\"^(\\d{1,3}(\\.\\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*)$\",\n    )\n    access_code: str | None = None\n    model: str | None = None\n    location: str | None = None\n    is_active: bool | None = None\n    auto_archive: bool | None = None\n    print_hours_offset: float | None = None\n    external_camera_url: str | None = None\n    external_camera_type: str | None = None\n    external_camera_enabled: bool | None = None\n    camera_rotation: int | None = None  # 0, 90, 180, 270 degrees\n    plate_detection_enabled: bool | None = None\n    plate_detection_roi: PlateDetectionROI | None = None\n\n\nclass PrinterResponse(PrinterBase):\n    id: int\n    is_active: bool\n    nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT\n    print_hours_offset: float = 0.0\n    external_camera_url: str | None = None\n    external_camera_type: str | None = None\n    external_camera_enabled: bool = False\n    camera_rotation: int = 0  # 0, 90, 180, 270 degrees\n    plate_detection_enabled: bool = False\n    plate_detection_roi: PlateDetectionROI | None = None\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n    @classmethod\n    def from_orm_with_roi(cls, printer) -> \"PrinterResponse\":\n        \"\"\"Create response from ORM model, converting ROI fields to nested object.\"\"\"\n        data = {\n            \"id\": printer.id,\n            \"name\": printer.name,\n            \"serial_number\": printer.serial_number,\n            \"ip_address\": printer.ip_address,\n            \"access_code\": printer.access_code,\n            \"model\": printer.model,\n            \"location\": printer.location,\n            \"auto_archive\": printer.auto_archive,\n            \"external_camera_url\": printer.external_camera_url,\n            \"external_camera_type\": printer.external_camera_type,\n            \"external_camera_enabled\": printer.external_camera_enabled,\n            \"camera_rotation\": printer.camera_rotation,\n            \"is_active\": printer.is_active,\n            \"nozzle_count\": printer.nozzle_count,\n            \"print_hours_offset\": printer.print_hours_offset,\n            \"plate_detection_enabled\": printer.plate_detection_enabled,\n            \"created_at\": printer.created_at,\n            \"updated_at\": printer.updated_at,\n        }\n        # Build ROI object if any ROI field is set\n        if any(\n            [\n                printer.plate_detection_roi_x is not None,\n                printer.plate_detection_roi_y is not None,\n                printer.plate_detection_roi_w is not None,\n                printer.plate_detection_roi_h is not None,\n            ]\n        ):\n            data[\"plate_detection_roi\"] = PlateDetectionROI(\n                x=printer.plate_detection_roi_x or 0.15,\n                y=printer.plate_detection_roi_y or 0.35,\n                w=printer.plate_detection_roi_w or 0.70,\n                h=printer.plate_detection_roi_h or 0.55,\n            )\n        return cls(**data)\n\n\nclass HMSErrorResponse(BaseModel):\n    code: str\n    attr: int = 0  # Attribute value for constructing wiki URL\n    module: int\n    severity: int  # 1=fatal, 2=serious, 3=common, 4=info\n\n\nclass AMSTray(BaseModel):\n    id: int\n    tray_color: str | None = None\n    tray_type: str | None = None\n    tray_sub_brands: str | None = None  # Full name like \"PLA Basic\", \"PETG HF\"\n    tray_id_name: str | None = None  # Bambu filament ID like \"A00-Y2\" (can decode to color)\n    tray_info_idx: str | None = None  # Filament preset ID like \"GFA00\"\n    remain: int = 0\n    k: float | None = None  # Pressure advance value (from tray or K-profile lookup)\n    cali_idx: int | None = None  # Calibration index for K-profile lookup\n    tag_uid: str | None = None  # RFID tag UID (any tag)\n    tray_uuid: str | None = None  # Bambu Lab spool UUID (32-char hex)\n    nozzle_temp_min: int | None = None  # Min nozzle temperature\n    nozzle_temp_max: int | None = None  # Max nozzle temperature\n    drying_temp: int | None = None  # RFID-recommended drying temp\n    drying_time: int | None = None  # RFID-recommended drying time (hours)\n    state: int | None = None  # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded\n\n\nclass AMSUnit(BaseModel):\n    id: int\n    humidity: int | None = None\n    temp: float | None = None\n    is_ams_ht: bool = False  # True for AMS-HT (single spool), False for regular AMS (4 spools)\n    tray: list[AMSTray] = []\n    serial_number: str = \"\"  # AMS unit serial number (sn from MQTT)\n    sw_ver: str = \"\"  # AMS firmware version (from get_version info.module)\n    dry_time: int = 0  # Minutes remaining (0 = not drying, >0 = drying active)\n    dry_status: int = 0  # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error\n    dry_sub_status: int = 0  # 0=Off, 1=Heating, 2=Dehumidify\n    dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)\n    module_type: str = \"\"  # \"ams\", \"n3f\", \"n3s\"\n\n\nclass NozzleInfoResponse(BaseModel):\n    nozzle_type: str = \"\"  # \"stainless_steel\" or \"hardened_steel\"\n    nozzle_diameter: str = \"\"  # e.g., \"0.4\"\n\n\nclass NozzleRackSlot(BaseModel):\n    \"\"\"H2C nozzle rack slot (6-position tool-changer dock).\"\"\"\n\n    id: int = 0\n    nozzle_type: str = \"\"\n    nozzle_diameter: str = \"\"\n    wear: int | None = None\n    stat: int | None = None  # Nozzle status (e.g. mounted/docked)\n    max_temp: int = 0  # Max temperature rating °C (0 = not set)\n    serial_number: str = \"\"  # Nozzle serial number\n    filament_color: str = \"\"  # RGBA hex (\"00000000\" = no filament)\n    filament_id: str = \"\"  # Bambu filament ID\n    filament_type: str = \"\"  # Material type (e.g. \"PLA\", \"PETG\")\n\n\nclass AmsLabelBody(BaseModel):\n    label: str = Field(..., min_length=1, max_length=100)\n    ams_serial: str = Field(default=\"\", max_length=50)\n\n\nclass PrintOptionsResponse(BaseModel):\n    \"\"\"AI detection and print options from xcam data.\"\"\"\n\n    # Core AI detectors\n    spaghetti_detector: bool = False\n    print_halt: bool = False\n    halt_print_sensitivity: str = \"medium\"  # Spaghetti sensitivity\n    first_layer_inspector: bool = False\n    printing_monitor: bool = False\n    buildplate_marker_detector: bool = False\n    allow_skip_parts: bool = False\n    # Additional AI detectors (decoded from cfg bitmask)\n    nozzle_clumping_detector: bool = True\n    nozzle_clumping_sensitivity: str = \"medium\"\n    pileup_detector: bool = True\n    pileup_sensitivity: str = \"medium\"\n    airprint_detector: bool = True\n    airprint_sensitivity: str = \"medium\"\n    auto_recovery_step_loss: bool = True\n    filament_tangle_detect: bool = False\n\n\nclass PrinterStatus(BaseModel):\n    id: int\n    name: str\n    connected: bool\n    state: str | None = None\n    current_print: str | None = None\n    subtask_name: str | None = None\n    gcode_file: str | None = None\n    progress: float | None = None\n    remaining_time: int | None = None\n    layer_num: int | None = None\n    total_layers: int | None = None\n    temperatures: dict | None = None\n    cover_url: str | None = None\n    hms_errors: list[HMSErrorResponse] = []\n    ams: list[AMSUnit] = []\n    ams_exists: bool = False\n    vt_tray: list[AMSTray] = []  # Virtual tray / external spool(s)\n    sdcard: bool = False  # SD card inserted\n    store_to_sdcard: bool = False  # Store sent files on SD card\n    timelapse: bool = False  # Timelapse recording active\n    ipcam: bool = False  # Live view enabled\n    wifi_signal: int | None = None  # WiFi signal strength in dBm\n    wired_network: bool = False  # Ethernet connection detected\n    door_open: bool = False  # Enclosure door open (X1/P1S/P2S/H2*)\n    nozzles: list[NozzleInfoResponse] = []  # Nozzle hardware info (index 0=left/primary, 1=right)\n    nozzle_rack: list[NozzleRackSlot] = []  # H2C 6-nozzle tool-changer rack\n    print_options: PrintOptionsResponse | None = None  # AI detection and print options\n    # Calibration stage tracking\n    stg_cur: int = -1  # Current stage number (-1 = not calibrating)\n    stg_cur_name: str | None = None  # Human-readable current stage name\n    stg: list[int] = []  # List of stage numbers in calibration sequence\n    # Air conditioning mode (0=cooling, 1=heating)\n    airduct_mode: int = 0\n    # Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)\n    speed_level: int = 2\n    # Chamber light on/off\n    chamber_light: bool = False\n    # Active extruder for dual nozzle (0=right, 1=left)\n    active_extruder: int = 0\n    # AMS mapping for dual nozzle: which AMS is connected to which nozzle\n    ams_mapping: list[int] = []\n    # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left\n    ams_extruder_map: dict[str, int] = {}\n    # Currently loaded tray (global ID): 254 = external spool, 255 = no filament\n    tray_now: int = 255\n    # AMS status for filament change tracking\n    # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration\n    ams_status_main: int = 0\n    # Sub status: specific step within filament change (when main=1)\n    # Known values: 4=retraction, 6=load verification, 7=purge\n    ams_status_sub: int = 0\n    # mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio\n    mc_print_sub_stage: int = 0\n    # Timestamp of last AMS data update (for RFID refresh detection)\n    last_ams_update: float = 0.0\n    # Number of printable objects in current print (for skip objects feature)\n    printable_objects_count: int = 0\n    # Fan speeds (0-100 percentage, None if not available for this model)\n    cooling_fan_speed: int | None = None  # Part cooling fan\n    big_fan1_speed: int | None = None  # Auxiliary fan\n    big_fan2_speed: int | None = None  # Chamber/exhaust fan\n    heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan\n    # Firmware version (from info.module[name=\"ota\"].sw_ver)\n    firmware_version: str | None = None\n    # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown\n    developer_mode: bool | None = None\n    # Queue: printer is awaiting the user to acknowledge the build plate is cleared\n    # after a finished/failed print. Persisted across restarts (#961).\n    awaiting_plate_clear: bool = False\n    # AMS drying support\n    supports_drying: bool = False\n    # Linked archive for the active print (resolved via subtask_id). Frontend uses\n    # this to fetch plate metadata and show the plate name when the source 3MF is\n    # multi-plate (#881 follow-up).\n    current_archive_id: int | None = None\n    # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).\n    # Set for every active print regardless of plate count; the frontend decides\n    # whether to render it based on current_archive_id's is_multi_plate flag.\n    current_plate_id: int | None = None\n"
  },
  {
    "path": "backend/app/schemas/project.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass ProjectCreate(BaseModel):\n    \"\"\"Schema for creating a new project.\"\"\"\n\n    name: str\n    description: str | None = None\n    color: str | None = None\n    target_count: int | None = None\n    target_parts_count: int | None = None\n    notes: str | None = None\n    tags: str | None = None\n    due_date: datetime | None = None\n    priority: str = \"normal\"\n    budget: float | None = None\n    parent_id: int | None = None  # For sub-projects\n\n\nclass ProjectUpdate(BaseModel):\n    \"\"\"Schema for updating a project.\"\"\"\n\n    name: str | None = None\n    description: str | None = None\n    color: str | None = None\n    status: str | None = None  # active, completed, archived\n    target_count: int | None = None\n    target_parts_count: int | None = None\n    notes: str | None = None\n    tags: str | None = None\n    due_date: datetime | None = None\n    priority: str | None = None\n    budget: float | None = None\n    parent_id: int | None = None\n\n\nclass ProjectStats(BaseModel):\n    \"\"\"Statistics for a project.\"\"\"\n\n    total_archives: int = 0  # Number of archive records\n    total_items: int = 0  # Sum of quantities (total items printed)\n    completed_prints: int = 0  # Sum of quantities for completed prints\n    failed_prints: int = 0  # Sum of quantities for failed prints\n    queued_prints: int = 0\n    in_progress_prints: int = 0\n    total_print_time_hours: float = 0.0\n    total_filament_grams: float = 0.0\n    progress_percent: float | None = None  # Based on target_count (plates)\n    parts_progress_percent: float | None = None  # Based on target_parts_count\n    # Cost tracking (Phase 6)\n    estimated_cost: float = 0.0  # Based on filament cost\n    total_energy_kwh: float = 0.0\n    total_energy_cost: float = 0.0\n    remaining_prints: int | None = None  # target_count - total_archives\n    remaining_parts: int | None = None  # target_parts_count - completed_prints\n    # BOM stats (Phase 7)\n    bom_total_items: int = 0\n    bom_completed_items: int = 0\n    bom_cost: float = 0.0  # Total cost of BOM items (sum of unit_price * quantity_needed)\n\n\nclass ProjectChildPreview(BaseModel):\n    \"\"\"Minimal project data for child preview.\"\"\"\n\n    id: int\n    name: str\n    color: str | None\n    status: str\n    progress_percent: float | None = None\n\n\nclass ProjectResponse(BaseModel):\n    \"\"\"Schema for project response.\"\"\"\n\n    id: int\n    name: str\n    description: str | None\n    color: str | None\n    status: str\n    target_count: int | None\n    target_parts_count: int | None = None\n    notes: str | None = None\n    attachments: list | None = None\n    tags: str | None = None\n    due_date: datetime | None = None\n    priority: str = \"normal\"\n    budget: float | None = None\n    is_template: bool = False\n    template_source_id: int | None = None\n    parent_id: int | None = None\n    parent_name: str | None = None  # For display\n    children: list[ProjectChildPreview] = []\n    created_at: datetime\n    updated_at: datetime\n    stats: ProjectStats | None = None\n\n    class Config:\n        from_attributes = True\n\n\nclass ArchivePreview(BaseModel):\n    \"\"\"Minimal archive data for project preview.\"\"\"\n\n    id: int\n    print_name: str | None\n    thumbnail_path: str | None\n    status: str\n    filament_type: str | None = None\n    filament_color: str | None = None\n\n\nclass ProjectListResponse(BaseModel):\n    \"\"\"Schema for project list item (lighter weight).\"\"\"\n\n    id: int\n    name: str\n    description: str | None\n    color: str | None\n    status: str\n    target_count: int | None\n    target_parts_count: int | None = None\n    budget: float | None = None\n    created_at: datetime\n    # Quick stats\n    archive_count: int = 0  # Number of print jobs\n    total_items: int = 0  # Sum of quantities (total items printed, including failed)\n    completed_count: int = 0  # Sum of quantities for completed prints only\n    failed_count: int = 0  # Sum of quantities for failed prints\n    queue_count: int = 0\n    progress_percent: float | None = None\n    # Preview of archives (up to 5)\n    archives: list[ArchivePreview] = []\n\n    class Config:\n        from_attributes = True\n\n\nclass BatchAddArchives(BaseModel):\n    \"\"\"Schema for batch adding archives to a project.\"\"\"\n\n    archive_ids: list[int]\n\n\nclass BatchAddQueueItems(BaseModel):\n    \"\"\"Schema for batch adding queue items to a project.\"\"\"\n\n    queue_item_ids: list[int]\n\n\n# Phase 7: BOM Schemas - Tracks sourced/purchased parts\nclass BOMItemCreate(BaseModel):\n    \"\"\"Schema for creating a BOM item.\"\"\"\n\n    name: str\n    quantity_needed: int = 1\n    unit_price: float | None = None\n    sourcing_url: str | None = None\n    archive_id: int | None = None\n    stl_filename: str | None = None\n    remarks: str | None = None\n\n\nclass BOMItemUpdate(BaseModel):\n    \"\"\"Schema for updating a BOM item.\"\"\"\n\n    name: str | None = None\n    quantity_needed: int | None = None\n    quantity_acquired: int | None = None\n    unit_price: float | None = None\n    sourcing_url: str | None = None\n    archive_id: int | None = None\n    stl_filename: str | None = None\n    remarks: str | None = None\n\n\nclass BOMItemResponse(BaseModel):\n    \"\"\"Schema for BOM item response.\"\"\"\n\n    id: int\n    project_id: int\n    name: str\n    quantity_needed: int\n    quantity_acquired: int\n    unit_price: float | None\n    sourcing_url: str | None\n    archive_id: int | None\n    archive_name: str | None = None\n    stl_filename: str | None\n    remarks: str | None\n    sort_order: int\n    is_complete: bool = False\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\n# Phase 9: Timeline Schemas\nclass TimelineEvent(BaseModel):\n    \"\"\"Schema for a timeline event.\"\"\"\n\n    event_type: str  # archive_added, queue_started, queue_completed, status_changed, note_updated\n    timestamp: datetime\n    title: str\n    description: str | None = None\n    metadata: dict | None = None  # Additional event-specific data\n\n\n# Phase 10: Import/Export Schemas\nclass BOMItemExport(BaseModel):\n    \"\"\"Schema for exporting a BOM item.\"\"\"\n\n    name: str\n    quantity_needed: int\n    quantity_acquired: int\n    unit_price: float | None\n    sourcing_url: str | None\n    stl_filename: str | None\n    remarks: str | None\n\n\nclass LinkedFolderExport(BaseModel):\n    \"\"\"Schema for exporting a linked library folder.\"\"\"\n\n    name: str\n\n\nclass ProjectExport(BaseModel):\n    \"\"\"Schema for exporting a project.\"\"\"\n\n    name: str\n    description: str | None\n    color: str | None\n    status: str\n    target_count: int | None\n    target_parts_count: int | None\n    notes: str | None\n    tags: str | None\n    due_date: datetime | None\n    priority: str\n    budget: float | None\n    bom_items: list[BOMItemExport] = []\n    linked_folders: list[LinkedFolderExport] = []\n\n\nclass ProjectImport(BaseModel):\n    \"\"\"Schema for importing a project.\"\"\"\n\n    name: str\n    description: str | None = None\n    color: str | None = None\n    status: str = \"active\"\n    target_count: int | None = None\n    target_parts_count: int | None = None\n    notes: str | None = None\n    tags: str | None = None\n    due_date: datetime | None = None\n    priority: str = \"normal\"\n    budget: float | None = None\n    bom_items: list[BOMItemExport] = []\n    linked_folders: list[LinkedFolderExport] = []\n"
  },
  {
    "path": "backend/app/schemas/settings.py",
    "content": "import json\n\nfrom pydantic import BaseModel, Field, field_validator\n\n\nclass AppSettings(BaseModel):\n    \"\"\"Application settings schema.\"\"\"\n\n    auto_archive: bool = Field(default=True, description=\"Automatically archive prints when completed\")\n    save_thumbnails: bool = Field(default=True, description=\"Extract and save preview images from 3MF files\")\n    capture_finish_photo: bool = Field(\n        default=True, description=\"Capture photo from printer camera when print completes\"\n    )\n    default_filament_cost: float = Field(default=25.0, description=\"Default filament cost per kg\")\n    currency: str = Field(default=\"USD\", description=\"Currency for cost tracking\")\n    energy_cost_per_kwh: float = Field(default=0.15, description=\"Electricity cost per kWh for energy tracking\")\n    energy_tracking_mode: str = Field(\n        default=\"total\",\n        description=\"Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption\",\n    )\n\n    # Spoolman integration\n    spoolman_enabled: bool = Field(default=False, description=\"Enable Spoolman integration for filament tracking\")\n    spoolman_url: str = Field(default=\"\", description=\"Spoolman server URL (e.g., http://localhost:7912)\")\n    spoolman_sync_mode: str = Field(\n        default=\"auto\", description=\"Sync mode: 'auto' syncs immediately, 'manual' requires button press\"\n    )\n    spoolman_disable_weight_sync: bool = Field(\n        default=False,\n        description=\"Disable remaining_weight sync. When enabled, only location is updated for existing spools.\",\n    )\n    spoolman_report_partial_usage: bool = Field(\n        default=True,\n        description=\"Report Partial Usage for Failed Prints. When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.\",\n    )\n    disable_filament_warnings: bool = Field(\n        default=False,\n        description=\"Disable insufficient filament warnings when printing or queueing prints\",\n    )\n    prefer_lowest_filament: bool = Field(\n        default=False,\n        description=\"When multiple AMS spools match, prefer the one with lowest remaining filament\",\n    )\n\n    # Updates\n    check_updates: bool = Field(default=True, description=\"Automatically check for updates on startup\")\n    check_printer_firmware: bool = Field(default=True, description=\"Check for printer firmware updates from Bambu Lab\")\n    include_beta_updates: bool = Field(default=False, description=\"Include beta/prerelease versions in update checks\")\n\n    # Language\n    language: str = Field(default=\"en\", description=\"UI language (en, de, fr, ja, it, pt-BR)\")\n    notification_language: str = Field(default=\"en\", description=\"Language for push notifications (en, de)\")\n\n    # Bed cooled notification threshold\n    bed_cooled_threshold: float = Field(\n        default=35.0, description=\"Bed temperature threshold for cooled notification (°C)\"\n    )\n\n    # AMS threshold settings for humidity and temperature coloring\n    ams_humidity_good: int = Field(default=40, description=\"Humidity threshold for good (green): <= this value\")\n    ams_humidity_fair: int = Field(\n        default=60, description=\"Humidity threshold for fair (orange): <= this value, > is red\"\n    )\n    ams_temp_good: float = Field(default=28.0, description=\"Temperature threshold for good (blue): <= this value\")\n    ams_temp_fair: float = Field(\n        default=35.0, description=\"Temperature threshold for fair (orange): <= this value, > is red\"\n    )\n    ams_history_retention_days: int = Field(default=30, description=\"Number of days to keep AMS sensor history data\")\n\n    # Queue auto-drying settings\n    queue_drying_enabled: bool = Field(\n        default=False, description=\"Automatically dry AMS filament between queued prints\"\n    )\n    queue_drying_block: bool = Field(\n        default=False,\n        description=\"Block queue until drying completes (when disabled, prints take priority over drying)\",\n    )\n    ambient_drying_enabled: bool = Field(\n        default=False,\n        description=\"Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue\",\n    )\n    drying_presets: str = Field(\n        default=\"\",\n        description=\"JSON blob of drying presets per filament type (empty = use built-in defaults)\",\n    )\n\n    # Auto-print G-code injection (#422)\n    gcode_snippets: str = Field(\n        default=\"\",\n        description=\"JSON: per-model G-code injection snippets {model: {start_gcode, end_gcode}}\",\n    )\n\n    # Scheduled local backup (#884)\n    local_backup_enabled: bool = Field(default=False, description=\"Enable scheduled local backups\")\n    local_backup_schedule: str = Field(default=\"daily\", description=\"Backup frequency: hourly, daily, weekly\")\n    local_backup_time: str = Field(default=\"03:00\", description=\"Time of day for daily/weekly backups (HH:MM, 24h)\")\n    local_backup_retention: int = Field(default=5, description=\"Number of backup files to keep (1-100)\")\n    local_backup_path: str = Field(default=\"\", description=\"Backup output directory (empty = DATA_DIR/backups)\")\n\n    # Print modal settings\n    per_printer_mapping_expanded: bool = Field(\n        default=False, description=\"Expand custom filament mapping by default in print modal\"\n    )\n\n    # Date/time display format\n    date_format: str = Field(default=\"system\", description=\"Date format: system, us, eu, iso\")\n    time_format: str = Field(default=\"system\", description=\"Time format: system, 12h, 24h\")\n\n    # Default printer for operations\n    default_printer_id: int | None = Field(default=None, description=\"Default printer ID for uploads, reprints, etc.\")\n\n    # Virtual Printer\n    virtual_printer_enabled: bool = Field(default=False, description=\"Enable virtual printer for slicer uploads\")\n    virtual_printer_access_code: str = Field(default=\"\", description=\"Access code for virtual printer authentication\")\n    virtual_printer_mode: str = Field(\n        default=\"immediate\",\n        description=\"Mode: 'immediate' (archive now), 'review' (pending review), or 'print_queue' (add to print queue)\",\n    )\n\n    # Dark mode theme settings\n    dark_style: str = Field(default=\"classic\", description=\"Dark mode style: classic, glow, vibrant\")\n    dark_background: str = Field(\n        default=\"neutral\", description=\"Dark mode background: neutral, warm, cool, oled, slate, forest\"\n    )\n    dark_accent: str = Field(default=\"green\", description=\"Dark mode accent: green, teal, blue, orange, purple, red\")\n\n    # Light mode theme settings\n    light_style: str = Field(default=\"classic\", description=\"Light mode style: classic, glow, vibrant\")\n    light_background: str = Field(default=\"neutral\", description=\"Light mode background: neutral, warm, cool\")\n    light_accent: str = Field(default=\"green\", description=\"Light mode accent: green, teal, blue, orange, purple, red\")\n\n    # FTP retry settings for unreliable WiFi connections\n    ftp_retry_enabled: bool = Field(default=True, description=\"Enable automatic retry for FTP operations\")\n    ftp_retry_count: int = Field(default=3, description=\"Number of retry attempts for FTP operations (1-10)\")\n    ftp_retry_delay: int = Field(default=2, description=\"Seconds to wait between FTP retry attempts (1-30)\")\n    ftp_timeout: int = Field(default=30, description=\"FTP connection timeout in seconds (10-300)\")\n\n    # MQTT Relay settings for publishing events to external broker\n    mqtt_enabled: bool = Field(default=False, description=\"Enable MQTT event publishing to external broker\")\n    mqtt_broker: str = Field(default=\"\", description=\"MQTT broker hostname or IP address\")\n    mqtt_port: int = Field(default=1883, description=\"MQTT broker port (default 1883, TLS typically 8883)\")\n    mqtt_username: str = Field(default=\"\", description=\"MQTT username for authentication (optional)\")\n    mqtt_password: str = Field(default=\"\", description=\"MQTT password for authentication (optional)\")\n    mqtt_topic_prefix: str = Field(default=\"bambuddy\", description=\"Topic prefix for all published messages\")\n    mqtt_use_tls: bool = Field(default=False, description=\"Use TLS/SSL encryption for MQTT connection\")\n\n    # External URL for notifications\n    external_url: str = Field(\n        default=\"\", description=\"External URL where Bambuddy is accessible (for notification images)\"\n    )\n\n    # Home Assistant integration for smart plug control\n    ha_enabled: bool = Field(default=False, description=\"Enable Home Assistant integration for smart plug control\")\n    ha_url: str = Field(default=\"\", description=\"Home Assistant URL (e.g., http://192.168.1.100:8123)\")\n    ha_token: str = Field(default=\"\", description=\"Home Assistant Long-Lived Access Token\")\n    ha_url_from_env: bool = Field(default=False, description=\"Whether HA URL is set via HA_URL environment variable\")\n    ha_token_from_env: bool = Field(\n        default=False, description=\"Whether HA token is set via HA_TOKEN environment variable\"\n    )\n    ha_env_managed: bool = Field(\n        default=False, description=\"Whether HA integration is fully managed by environment variables\"\n    )\n\n    # File Manager / Library settings\n    library_archive_mode: str = Field(\n        default=\"ask\",\n        description=\"When printing from File Manager, create archive entry: 'always', 'never', or 'ask'\",\n    )\n    library_disk_warning_gb: float = Field(\n        default=5.0,\n        description=\"Show warning when free disk space falls below this threshold (GB)\",\n    )\n\n    # Camera view settings\n    camera_view_mode: str = Field(\n        default=\"window\",\n        description=\"Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen\",\n    )\n\n    # Preferred slicer application\n    preferred_slicer: str = Field(\n        default=\"bambu_studio\",\n        description=\"Preferred slicer: 'bambu_studio' or 'orcaslicer'\",\n    )\n\n    # Prometheus metrics endpoint\n    prometheus_enabled: bool = Field(default=False, description=\"Enable Prometheus metrics endpoint at /metrics\")\n    prometheus_token: str = Field(\n        default=\"\", description=\"Bearer token for Prometheus metrics authentication (optional)\"\n    )\n\n    # Inventory low stock threshold\n    low_stock_threshold: float = Field(\n        default=20.0,\n        ge=0.1,\n        le=99.9,\n        description=\"Low stock threshold percentage (%) for inventory filtering and display\",\n    )\n\n    # User email notifications (requires Advanced Authentication)\n    user_notifications_enabled: bool = Field(\n        default=True,\n        description=\"Enable user email notifications for print job events (requires Advanced Authentication)\",\n    )\n\n    # Default print options\n    default_bed_levelling: bool = Field(default=True, description=\"Default bed levelling option for new prints\")\n    default_flow_cali: bool = Field(default=False, description=\"Default flow calibration option for new prints\")\n    default_vibration_cali: bool = Field(\n        default=True, description=\"Default vibration calibration option for new prints\"\n    )\n    default_layer_inspect: bool = Field(\n        default=False, description=\"Default first layer inspection option for new prints\"\n    )\n    default_timelapse: bool = Field(default=False, description=\"Default timelapse option for new prints\")\n\n    # Staggered batch start for multi-printer jobs\n    stagger_group_size: int = Field(\n        default=2, ge=1, le=50, description=\"Number of printers to start simultaneously in staggered mode\"\n    )\n    stagger_interval_minutes: int = Field(\n        default=5, ge=1, le=60, description=\"Minutes between staggered printer groups\"\n    )\n\n    # Plate-clear confirmation for queue scheduling\n    require_plate_clear: bool = Field(\n        default=False,\n        description=\"Require per-printer plate-clear confirmation before starting queued prints on finished printers\",\n    )\n    queue_shortest_first: bool = Field(\n        default=False,\n        description=\"Shortest Job First — scheduler prioritizes shorter print jobs over longer ones\",\n    )\n\n    # LDAP authentication (#794)\n    ldap_enabled: bool = Field(default=False, description=\"Enable LDAP authentication\")\n    ldap_server_url: str = Field(default=\"\", description=\"LDAP server URL (e.g., ldap://ldap.example.com:389)\")\n    ldap_bind_dn: str = Field(default=\"\", description=\"Bind DN for LDAP searches (e.g., cn=admin,dc=example,dc=com)\")\n    ldap_bind_password: str = Field(default=\"\", description=\"Bind password for LDAP searches\")\n    ldap_search_base: str = Field(default=\"\", description=\"Search base DN (e.g., ou=users,dc=example,dc=com)\")\n    ldap_user_filter: str = Field(\n        default=\"(sAMAccountName={username})\",\n        description=\"LDAP user search filter. {username} is replaced with the login username\",\n    )\n    ldap_security: str = Field(default=\"starttls\", description=\"LDAP security: 'starttls' or 'ldaps'\")\n    ldap_group_mapping: str = Field(\n        default=\"\",\n        description=\"JSON: LDAP group to BamBuddy group mapping {ldap_group_dn: bambuddy_group_name}\",\n    )\n    ldap_auto_provision: bool = Field(\n        default=False,\n        description=\"Auto-create BamBuddy user on first successful LDAP login\",\n    )\n    ldap_default_group: str = Field(\n        default=\"\",\n        description=\"Fallback BamBuddy group name assigned when an LDAP user authenticates but has no mapped groups. Empty = no fallback.\",\n    )\n\n    # Obico AI failure detection (#172)\n    obico_enabled: bool = Field(default=False, description=\"Enable Obico AI print failure detection\")\n    obico_ml_url: str = Field(\n        default=\"\",\n        description=\"Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)\",\n    )\n    obico_sensitivity: str = Field(\n        default=\"medium\",\n        description=\"Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)\",\n    )\n    obico_action: str = Field(\n        default=\"notify\",\n        description=\"Action on detected failure: 'notify', 'pause', or 'pause_and_off'\",\n    )\n    obico_poll_interval: int = Field(\n        default=10,\n        ge=5,\n        le=120,\n        description=\"Seconds between detection checks while a print is running\",\n    )\n    obico_enabled_printers: str = Field(\n        default=\"\",\n        description=\"JSON array of printer IDs to monitor (empty = all connected printers)\",\n    )\n\n    # Default sidebar order (admin-set for all users)\n    default_sidebar_order: str = Field(\n        default=\"\",\n        description=\"JSON object with 'order' key containing array of sidebar item IDs (empty = no default)\",\n    )\n\n\nclass AppSettingsUpdate(BaseModel):\n    \"\"\"Schema for updating settings (all fields optional).\"\"\"\n\n    auto_archive: bool | None = None\n    save_thumbnails: bool | None = None\n    capture_finish_photo: bool | None = None\n    default_filament_cost: float | None = None\n    currency: str | None = None\n    energy_cost_per_kwh: float | None = None\n    energy_tracking_mode: str | None = None\n    spoolman_enabled: bool | None = None\n    spoolman_url: str | None = None\n    spoolman_sync_mode: str | None = None\n    spoolman_disable_weight_sync: bool | None = None\n    spoolman_report_partial_usage: bool | None = None\n    disable_filament_warnings: bool | None = None\n    prefer_lowest_filament: bool | None = None\n    check_updates: bool | None = None\n    check_printer_firmware: bool | None = None\n    include_beta_updates: bool | None = None\n    language: str | None = None\n    notification_language: str | None = None\n    bed_cooled_threshold: float | None = None\n    ams_humidity_good: int | None = None\n    ams_humidity_fair: int | None = None\n    ams_temp_good: float | None = None\n    ams_temp_fair: float | None = None\n    ams_history_retention_days: int | None = None\n    queue_drying_enabled: bool | None = None\n    queue_drying_block: bool | None = None\n    ambient_drying_enabled: bool | None = None\n    drying_presets: str | None = None\n    per_printer_mapping_expanded: bool | None = None\n    date_format: str | None = None\n    time_format: str | None = None\n    default_printer_id: int | None = None\n    virtual_printer_enabled: bool | None = None\n    virtual_printer_access_code: str | None = None\n    virtual_printer_mode: str | None = None\n    dark_style: str | None = None\n    dark_background: str | None = None\n    dark_accent: str | None = None\n    light_style: str | None = None\n    light_background: str | None = None\n    light_accent: str | None = None\n    ftp_retry_enabled: bool | None = None\n    ftp_retry_count: int | None = None\n    ftp_retry_delay: int | None = None\n    ftp_timeout: int | None = None\n    mqtt_enabled: bool | None = None\n    mqtt_broker: str | None = None\n    mqtt_port: int | None = None\n    mqtt_username: str | None = None\n    mqtt_password: str | None = None\n    mqtt_topic_prefix: str | None = None\n    mqtt_use_tls: bool | None = None\n    external_url: str | None = None\n    ha_enabled: bool | None = None\n    ha_url: str | None = None\n    ha_token: str | None = None\n    library_archive_mode: str | None = None\n    library_disk_warning_gb: float | None = None\n    camera_view_mode: str | None = None\n    preferred_slicer: str | None = None\n    prometheus_enabled: bool | None = None\n    prometheus_token: str | None = None\n    low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)\n    user_notifications_enabled: bool | None = None\n    default_bed_levelling: bool | None = None\n    default_flow_cali: bool | None = None\n    default_vibration_cali: bool | None = None\n    default_layer_inspect: bool | None = None\n    default_timelapse: bool | None = None\n    stagger_group_size: int | None = Field(default=None, ge=1, le=50)\n    stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)\n    require_plate_clear: bool | None = None\n    queue_shortest_first: bool | None = None\n    gcode_snippets: str | None = None\n    local_backup_enabled: bool | None = None\n    local_backup_schedule: str | None = None\n    local_backup_time: str | None = None\n    local_backup_retention: int | None = None\n    local_backup_path: str | None = None\n    ldap_enabled: bool | None = None\n    ldap_server_url: str | None = None\n    ldap_bind_dn: str | None = None\n    ldap_bind_password: str | None = None\n    ldap_search_base: str | None = None\n    ldap_user_filter: str | None = None\n    ldap_security: str | None = None\n    ldap_group_mapping: str | None = None\n    ldap_auto_provision: bool | None = None\n    ldap_default_group: str | None = None\n    obico_enabled: bool | None = None\n    obico_ml_url: str | None = None\n    obico_sensitivity: str | None = None\n    obico_action: str | None = None\n    obico_poll_interval: int | None = Field(default=None, ge=5, le=120)\n    obico_enabled_printers: str | None = None\n    default_sidebar_order: str | None = None\n\n    @field_validator(\"gcode_snippets\")\n    @classmethod\n    def validate_gcode_snippets(cls, v: str | None) -> str | None:\n        if v is None or v == \"\":\n            return v\n        try:\n            parsed = json.loads(v)\n        except json.JSONDecodeError:\n            raise ValueError(\"gcode_snippets must be valid JSON or empty\")\n        if not isinstance(parsed, dict):\n            raise ValueError(\"gcode_snippets must be a JSON object keyed by printer model\")\n        return v\n\n    @field_validator(\"ldap_group_mapping\")\n    @classmethod\n    def validate_ldap_group_mapping(cls, v: str | None) -> str | None:\n        if v is None or v == \"\":\n            return v\n        try:\n            parsed = json.loads(v)\n        except json.JSONDecodeError:\n            raise ValueError(\"ldap_group_mapping must be valid JSON or empty\")\n        if not isinstance(parsed, dict):\n            raise ValueError(\"ldap_group_mapping must be a JSON object mapping LDAP group DNs to BamBuddy group names\")\n        return v\n\n    @field_validator(\"obico_enabled_printers\")\n    @classmethod\n    def validate_obico_enabled_printers(cls, v: str | None) -> str | None:\n        if v is None or v == \"\":\n            return v\n        try:\n            parsed = json.loads(v)\n        except json.JSONDecodeError:\n            raise ValueError(\"obico_enabled_printers must be valid JSON or empty\")\n        if not isinstance(parsed, list) or not all(isinstance(item, int) for item in parsed):\n            raise ValueError(\"obico_enabled_printers must be a JSON array of printer IDs (integers)\")\n        return v\n\n    @field_validator(\"obico_sensitivity\")\n    @classmethod\n    def validate_obico_sensitivity(cls, v: str | None) -> str | None:\n        if v is None:\n            return v\n        if v not in (\"low\", \"medium\", \"high\"):\n            raise ValueError(\"obico_sensitivity must be 'low', 'medium', or 'high'\")\n        return v\n\n    @field_validator(\"obico_action\")\n    @classmethod\n    def validate_obico_action(cls, v: str | None) -> str | None:\n        if v is None:\n            return v\n        if v not in (\"notify\", \"pause\", \"pause_and_off\"):\n            raise ValueError(\"obico_action must be 'notify', 'pause', or 'pause_and_off'\")\n        return v\n\n    @field_validator(\"default_sidebar_order\")\n    @classmethod\n    def validate_default_sidebar_order(cls, v: str | None) -> str | None:\n        if v is None or v == \"\":\n            return v\n        try:\n            parsed = json.loads(v)\n        except json.JSONDecodeError:\n            raise ValueError(\"default_sidebar_order must be valid JSON or empty\")\n        if isinstance(parsed, dict):\n            order = parsed.get(\"order\")\n        elif isinstance(parsed, list):\n            order = parsed\n        else:\n            raise ValueError(\"default_sidebar_order must be a JSON object with 'order' key or a JSON array\")\n        if not isinstance(order, list) or not all(isinstance(item, str) for item in order):\n            raise ValueError(\"sidebar order must be an array of strings\")\n        return v\n"
  },
  {
    "path": "backend/app/schemas/smart_plug.py",
    "content": "from datetime import datetime\nfrom typing import Literal\n\nfrom pydantic import BaseModel, Field, model_validator\n\n\nclass SmartPlugBase(BaseModel):\n    name: str = Field(..., min_length=1, max_length=100)\n    plug_type: Literal[\"tasmota\", \"homeassistant\", \"mqtt\", \"rest\"] = \"tasmota\"\n\n    # Tasmota fields (required when plug_type=\"tasmota\")\n    ip_address: str | None = Field(default=None, pattern=r\"^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$\")\n    username: str | None = None\n    password: str | None = None\n\n    # Home Assistant fields (required when plug_type=\"homeassistant\")\n    ha_entity_id: str | None = Field(default=None, pattern=r\"^(switch|light|input_boolean|script)\\.[a-z0-9_]+$\")\n    # Home Assistant energy sensor entities (optional, for separate energy sensors)\n    ha_power_entity: str | None = Field(default=None, pattern=r\"^sensor\\.[a-z0-9_]+$\")\n    ha_energy_today_entity: str | None = Field(default=None, pattern=r\"^sensor\\.[a-z0-9_]+$\")\n    ha_energy_total_entity: str | None = Field(default=None, pattern=r\"^sensor\\.[a-z0-9_]+$\")\n\n    # MQTT fields (required when plug_type=\"mqtt\")\n    # Legacy field - kept for backward compatibility\n    mqtt_topic: str | None = Field(default=None, max_length=200)  # Deprecated, use mqtt_power_topic\n\n    # Power monitoring\n    mqtt_power_topic: str | None = Field(default=None, max_length=200)  # Topic for power data\n    mqtt_power_path: str | None = Field(default=None, max_length=100)  # e.g., \"power_l1\" or \"data.power\"\n    mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for power\n\n    # Energy monitoring\n    mqtt_energy_topic: str | None = Field(default=None, max_length=200)  # Topic for energy data\n    mqtt_energy_path: str | None = Field(default=None, max_length=100)  # e.g., \"energy_l1\"\n    mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for energy\n\n    # State monitoring\n    mqtt_state_topic: str | None = Field(default=None, max_length=200)  # Topic for state data\n    mqtt_state_path: str | None = Field(default=None, max_length=100)  # e.g., \"state_l1\" for ON/OFF\n    mqtt_state_on_value: str | None = Field(\n        default=None, max_length=50\n    )  # What value means \"ON\" (e.g., \"ON\", \"true\", \"1\")\n\n    # Legacy multiplier - kept for backward compatibility\n    mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Deprecated, use mqtt_power_multiplier\n\n    # REST/Webhook fields (required when plug_type=\"rest\")\n    rest_on_url: str | None = Field(default=None, max_length=500)\n    rest_on_body: str | None = None\n    rest_off_url: str | None = Field(default=None, max_length=500)\n    rest_off_body: str | None = None\n    rest_method: Literal[\"GET\", \"POST\", \"PUT\", \"PATCH\"] | None = None\n    rest_headers: str | None = None  # JSON string of custom headers\n    rest_status_url: str | None = Field(default=None, max_length=500)\n    rest_status_path: str | None = Field(default=None, max_length=200)\n    rest_status_on_value: str | None = Field(default=None, max_length=50)\n    rest_power_url: str | None = Field(default=None, max_length=500)\n    rest_power_path: str | None = Field(default=None, max_length=200)\n    rest_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)\n    rest_energy_url: str | None = Field(default=None, max_length=500)\n    rest_energy_path: str | None = Field(default=None, max_length=200)\n    rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)\n\n    printer_id: int | None = None\n    enabled: bool = True\n    auto_on: bool = True\n    auto_off: bool = True\n    auto_off_persistent: bool = False\n    off_delay_mode: Literal[\"time\", \"temperature\"] = \"time\"\n    off_delay_minutes: int = Field(default=5, ge=0, le=60)\n    off_temp_threshold: int = Field(default=70, ge=30, le=150)\n    # Power alerts\n    power_alert_enabled: bool = False\n    power_alert_high: float | None = Field(default=None, ge=0, le=5000)  # Alert when power > this (watts)\n    power_alert_low: float | None = Field(default=None, ge=0, le=5000)  # Alert when power < this (watts)\n    # Schedule\n    schedule_enabled: bool = False\n    schedule_on_time: str | None = Field(default=None, pattern=r\"^([01]\\d|2[0-3]):[0-5]\\d$\")  # HH:MM format\n    schedule_off_time: str | None = Field(default=None, pattern=r\"^([01]\\d|2[0-3]):[0-5]\\d$\")  # HH:MM format\n    # Visibility options\n    show_in_switchbar: bool = False\n    show_on_printer_card: bool = True  # For scripts: show on printer card\n\n    @model_validator(mode=\"after\")\n    def validate_plug_type_fields(self) -> \"SmartPlugBase\":\n        if self.plug_type == \"tasmota\" and not self.ip_address:\n            raise ValueError(\"ip_address is required for Tasmota plugs\")\n        if self.plug_type == \"homeassistant\" and not self.ha_entity_id:\n            raise ValueError(\"ha_entity_id is required for Home Assistant plugs\")\n        if self.plug_type == \"mqtt\":\n            # Determine the effective power topic (new field takes priority, fall back to legacy)\n            power_topic = self.mqtt_power_topic or self.mqtt_topic\n            # Path is optional - if not set, raw MQTT payload value will be used\n            has_power = bool(power_topic)\n            has_energy = bool(self.mqtt_energy_topic)\n            has_state = bool(self.mqtt_state_topic)\n\n            # At least one data source must be configured (path is optional)\n            if not has_power and not has_energy and not has_state:\n                raise ValueError(\"At least one MQTT topic must be configured for power, energy, or state monitoring\")\n        if self.plug_type == \"rest\":\n            if not self.rest_on_url and not self.rest_off_url:\n                raise ValueError(\"At least one of ON URL or OFF URL is required for REST plugs\")\n        return self\n\n\nclass SmartPlugCreate(SmartPlugBase):\n    pass\n\n\nclass SmartPlugUpdate(BaseModel):\n    name: str | None = None\n    plug_type: Literal[\"tasmota\", \"homeassistant\", \"mqtt\", \"rest\"] | None = None\n    ip_address: str | None = None\n    ha_entity_id: str | None = None\n    # Home Assistant energy sensor entities (optional)\n    ha_power_entity: str | None = None\n    ha_energy_today_entity: str | None = None\n    ha_energy_total_entity: str | None = None\n    # MQTT fields (legacy)\n    mqtt_topic: str | None = None\n    mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)\n    # MQTT power fields\n    mqtt_power_topic: str | None = None\n    mqtt_power_path: str | None = None\n    mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)\n    # MQTT energy fields\n    mqtt_energy_topic: str | None = None\n    mqtt_energy_path: str | None = None\n    mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)\n    # MQTT state fields\n    mqtt_state_topic: str | None = None\n    mqtt_state_path: str | None = None\n    mqtt_state_on_value: str | None = None\n    # REST fields\n    rest_on_url: str | None = None\n    rest_on_body: str | None = None\n    rest_off_url: str | None = None\n    rest_off_body: str | None = None\n    rest_method: Literal[\"GET\", \"POST\", \"PUT\", \"PATCH\"] | None = None\n    rest_headers: str | None = None\n    rest_status_url: str | None = None\n    rest_status_path: str | None = None\n    rest_status_on_value: str | None = None\n    rest_power_url: str | None = None\n    rest_power_path: str | None = None\n    rest_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)\n    rest_energy_url: str | None = None\n    rest_energy_path: str | None = None\n    rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)\n    printer_id: int | None = None\n    enabled: bool | None = None\n    auto_on: bool | None = None\n    auto_off: bool | None = None\n    auto_off_persistent: bool | None = None\n    off_delay_mode: Literal[\"time\", \"temperature\"] | None = None\n    off_delay_minutes: int | None = Field(default=None, ge=0, le=60)\n    off_temp_threshold: int | None = Field(default=None, ge=30, le=150)\n    username: str | None = None\n    password: str | None = None\n    # Power alerts\n    power_alert_enabled: bool | None = None\n    power_alert_high: float | None = Field(default=None, ge=0, le=5000)\n    power_alert_low: float | None = Field(default=None, ge=0, le=5000)\n    # Schedule\n    schedule_enabled: bool | None = None\n    schedule_on_time: str | None = Field(default=None, pattern=r\"^([01]\\d|2[0-3]):[0-5]\\d$\")\n    schedule_off_time: str | None = Field(default=None, pattern=r\"^([01]\\d|2[0-3]):[0-5]\\d$\")\n    # Visibility options\n    show_in_switchbar: bool | None = None\n    show_on_printer_card: bool | None = None\n\n\nclass SmartPlugResponse(SmartPlugBase):\n    id: int\n    last_state: str | None = None\n    last_checked: datetime | None = None\n    auto_off_executed: bool = False  # True when auto-off was triggered after print\n    power_alert_last_triggered: datetime | None = None\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass SmartPlugControl(BaseModel):\n    action: Literal[\"on\", \"off\", \"toggle\"]\n\n\nclass SmartPlugEnergy(BaseModel):\n    \"\"\"Energy monitoring data from a smart plug.\"\"\"\n\n    power: float | None = None  # Current watts\n    voltage: float | None = None  # Volts\n    current: float | None = None  # Amps\n    today: float | None = None  # kWh used today\n    yesterday: float | None = None  # kWh used yesterday\n    total: float | None = None  # Total kWh\n    factor: float | None = None  # Power factor (0-1)\n    apparent_power: float | None = None  # VA\n    reactive_power: float | None = None  # VAr\n\n\nclass SmartPlugStatus(BaseModel):\n    state: str | None = None  # \"ON\", \"OFF\", or None if unreachable\n    reachable: bool = True\n    device_name: str | None = None\n    energy: SmartPlugEnergy | None = None  # Energy data if available\n\n\nclass SmartPlugTestConnection(BaseModel):\n    ip_address: str = Field(..., pattern=r\"^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$\")\n    username: str | None = None\n    password: str | None = None\n\n\n# Home Assistant schemas\nclass HATestConnectionRequest(BaseModel):\n    \"\"\"Request to test Home Assistant connection.\"\"\"\n\n    url: str = Field(..., min_length=1)\n    token: str = Field(..., min_length=1)\n\n\nclass HATestConnectionResponse(BaseModel):\n    \"\"\"Response from HA connection test.\"\"\"\n\n    success: bool\n    message: str | None = None\n    error: str | None = None\n\n\nclass HAEntity(BaseModel):\n    \"\"\"A Home Assistant entity that can be used as a smart plug.\"\"\"\n\n    entity_id: str\n    friendly_name: str\n    state: str | None = None\n    domain: str  # \"switch\", \"light\", \"input_boolean\", \"script\"\n\n\nclass HASensorEntity(BaseModel):\n    \"\"\"A Home Assistant sensor entity for energy monitoring.\"\"\"\n\n    entity_id: str\n    friendly_name: str\n    state: str | None = None\n    unit_of_measurement: str | None = None  # \"W\", \"kW\", \"kWh\", \"Wh\"\n\n\nclass RESTTestConnectionRequest(BaseModel):\n    \"\"\"Request to test a REST smart plug connection.\"\"\"\n\n    url: str = Field(..., min_length=1)\n    method: str = Field(default=\"GET\")\n    headers: str | None = None  # JSON string of custom headers\n\n\nclass RESTTestConnectionResponse(BaseModel):\n    \"\"\"Response from REST connection test.\"\"\"\n\n    success: bool\n    error: str | None = None\n"
  },
  {
    "path": "backend/app/schemas/spool.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel, Field\n\n\nclass SpoolBase(BaseModel):\n    material: str = Field(..., min_length=1, max_length=50)\n    subtype: str | None = None\n    color_name: str | None = None\n    rgba: str | None = Field(None, pattern=r\"^[0-9A-Fa-f]{8}$\")\n    brand: str | None = None\n    label_weight: int = 1000\n    core_weight: int = 250\n    core_weight_catalog_id: int | None = None\n    weight_used: float = 0\n    slicer_filament: str | None = None\n    slicer_filament_name: str | None = None\n    nozzle_temp_min: int | None = None\n    nozzle_temp_max: int | None = None\n    note: str | None = None\n    tag_uid: str | None = None\n    tray_uuid: str | None = None\n    data_origin: str | None = None\n    tag_type: str | None = None\n    cost_per_kg: float | None = Field(default=None, ge=0)\n    weight_locked: bool = False\n    last_scale_weight: int | None = None\n    last_weighed_at: datetime | None = None\n\n\nclass SpoolCreate(SpoolBase):\n    pass\n\n\nclass SpoolBulkCreate(BaseModel):\n    spool: SpoolCreate\n    quantity: int = Field(default=1, ge=1, le=100)\n\n\nclass SpoolUpdate(BaseModel):\n    material: str | None = None\n    subtype: str | None = None\n    color_name: str | None = None\n    rgba: str | None = Field(None, pattern=r\"^[0-9A-Fa-f]{8}$\")\n    brand: str | None = None\n    label_weight: int | None = None\n    core_weight: int | None = None\n    core_weight_catalog_id: int | None = None\n    weight_used: float | None = None\n    slicer_filament: str | None = None\n    slicer_filament_name: str | None = None\n    nozzle_temp_min: int | None = None\n    nozzle_temp_max: int | None = None\n    note: str | None = None\n    tag_uid: str | None = None\n    tray_uuid: str | None = None\n    data_origin: str | None = None\n    tag_type: str | None = None\n    cost_per_kg: float | None = Field(default=None, ge=0)\n    weight_locked: bool | None = None\n\n\nclass SpoolKProfileBase(BaseModel):\n    printer_id: int\n    extruder: int = 0\n    nozzle_diameter: str = \"0.4\"\n    nozzle_type: str | None = None\n    k_value: float\n    name: str | None = None\n    cali_idx: int | None = None\n    setting_id: str | None = None\n\n\nclass SpoolKProfileResponse(SpoolKProfileBase):\n    id: int\n    spool_id: int\n    created_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass SpoolResponse(SpoolBase):\n    id: int\n    # rgba is intentionally unconstrained on the response side: the write paths\n    # (SpoolCreate, SpoolUpdate) enforce the 8-char hex pattern, but legacy rows\n    # or data sourced from AMS firmware / backups may carry malformed values.\n    # A single bad row must not 500 the entire inventory list endpoint (#1055).\n    rgba: str | None = None\n    added_full: bool | None = None\n    last_used: datetime | None = None\n    encode_time: datetime | None = None\n    tag_uid: str | None = None\n    tray_uuid: str | None = None\n    data_origin: str | None = None\n    tag_type: str | None = None\n    archived_at: datetime | None = None\n    created_at: datetime\n    updated_at: datetime\n    k_profiles: list[SpoolKProfileResponse] = []\n\n    class Config:\n        from_attributes = True\n\n\nclass SpoolAssignmentCreate(BaseModel):\n    spool_id: int\n    printer_id: int\n    ams_id: int\n    tray_id: int\n\n\nclass SpoolAssignmentResponse(BaseModel):\n    id: int\n    spool_id: int\n    printer_id: int\n    printer_name: str | None = None\n    ams_id: int\n    tray_id: int\n    fingerprint_color: str | None = None\n    fingerprint_type: str | None = None\n    created_at: datetime\n    spool: SpoolResponse | None = None\n    configured: bool = False\n    ams_label: str | None = None  # User-defined friendly name for the AMS unit\n\n    class Config:\n        from_attributes = True\n"
  },
  {
    "path": "backend/app/schemas/spool_usage.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel\n\n\nclass SpoolUsageHistoryResponse(BaseModel):\n    id: int\n    spool_id: int\n    printer_id: int | None = None\n    print_name: str | None = None\n    weight_used: float\n    percent_used: int\n    status: str\n    cost: float | None = None\n    created_at: datetime\n\n    class Config:\n        from_attributes = True\n"
  },
  {
    "path": "backend/app/schemas/spoolbuddy.py",
    "content": "from datetime import datetime\n\nfrom pydantic import BaseModel, Field\n\n# --- Device schemas ---\n\n\nclass DeviceRegisterRequest(BaseModel):\n    device_id: str = Field(..., min_length=1, max_length=50)\n    hostname: str = Field(..., min_length=1, max_length=100)\n    ip_address: str = Field(..., min_length=1, max_length=45)\n    firmware_version: str | None = None\n    has_nfc: bool = True\n    has_scale: bool = True\n    tare_offset: int = 0\n    calibration_factor: float = 1.0\n    nfc_reader_type: str | None = None\n    nfc_connection: str | None = None\n    backend_url: str | None = None\n    has_backlight: bool = False\n\n\nclass DeviceResponse(BaseModel):\n    id: int\n    device_id: str\n    hostname: str\n    ip_address: str\n    firmware_version: str | None = None\n    has_nfc: bool\n    has_scale: bool\n    tare_offset: int\n    calibration_factor: float\n    nfc_reader_type: str | None = None\n    nfc_connection: str | None = None\n    backend_url: str | None = None\n    display_brightness: int = 100\n    display_blank_timeout: int = 0\n    has_backlight: bool = False\n    last_calibrated_at: datetime | None = None\n    last_seen: datetime | None = None\n    pending_command: str | None = None\n    nfc_ok: bool\n    scale_ok: bool\n    uptime_s: int\n    update_status: str | None = None\n    update_message: str | None = None\n    system_stats: dict | None = None\n    online: bool = False\n    ssh_public_key: str | None = None\n    created_at: datetime\n    updated_at: datetime\n\n    class Config:\n        from_attributes = True\n\n\nclass HeartbeatRequest(BaseModel):\n    nfc_ok: bool = False\n    scale_ok: bool = False\n    uptime_s: int = 0\n    firmware_version: str | None = None\n    ip_address: str | None = None\n    nfc_reader_type: str | None = None\n    nfc_connection: str | None = None\n    backend_url: str | None = None\n    system_stats: dict | None = None\n\n\nclass HeartbeatResponse(BaseModel):\n    pending_command: str | None = None\n    pending_write_payload: dict | None = None\n    pending_system_payload: dict | None = None\n    tare_offset: int\n    calibration_factor: float\n    display_brightness: int = 100\n    display_blank_timeout: int = 0\n\n\n# --- NFC schemas ---\n\n\nclass TagScannedRequest(BaseModel):\n    device_id: str\n    tag_uid: str\n    tray_uuid: str | None = None\n    sak: int | None = None\n    tag_type: str | None = None\n    raw_blocks: dict | None = None\n\n\nclass TagRemovedRequest(BaseModel):\n    device_id: str\n    tag_uid: str\n\n\n# --- Scale schemas ---\n\n\nclass ScaleReadingRequest(BaseModel):\n    device_id: str\n    weight_grams: float\n    stable: bool = False\n    raw_adc: int | None = None\n\n\nclass UpdateSpoolWeightRequest(BaseModel):\n    spool_id: int\n    weight_grams: float\n\n\n# --- Calibration schemas ---\n\n\nclass SetTareRequest(BaseModel):\n    tare_offset: int\n\n\nclass SetCalibrationFactorRequest(BaseModel):\n    known_weight_grams: float = Field(..., gt=0)\n    raw_adc: int\n    tare_raw_adc: int | None = None\n\n\nclass CalibrationResponse(BaseModel):\n    tare_offset: int\n    calibration_factor: float\n\n\n# --- Display schemas ---\n\n\nclass WriteTagRequest(BaseModel):\n    device_id: str\n    spool_id: int\n\n\nclass WriteTagResultRequest(BaseModel):\n    device_id: str\n    spool_id: int\n    tag_uid: str\n    success: bool\n    message: str | None = None\n\n\nclass DisplaySettingsRequest(BaseModel):\n    brightness: int = Field(ge=0, le=100)\n    blank_timeout: int = Field(ge=0)\n\n\nclass SystemConfigRequest(BaseModel):\n    backend_url: str = Field(..., min_length=1, max_length=255)\n    api_key: str | None = Field(default=None, max_length=255)\n\n\nclass SystemCommandRequest(BaseModel):\n    command: str = Field(..., description=\"System command: reboot, shutdown, restart_daemon, restart_browser\")\n\n\nclass SystemCommandResultRequest(BaseModel):\n    command: str\n    success: bool\n    message: str | None = None\n\n\n# --- Diagnostics schemas ---\n\n\nclass DiagnosticResultRequest(BaseModel):\n    diagnostic: str  # 'nfc', 'scale', or 'read_tag'\n    success: bool\n    output: str\n    exit_code: int\n"
  },
  {
    "path": "backend/app/schemas/timelapse.py",
    "content": "\"\"\"Schemas for timelapse video processing.\"\"\"\n\nfrom pydantic import BaseModel, Field\n\n\nclass TimelapseInfoResponse(BaseModel):\n    \"\"\"Video metadata response.\"\"\"\n\n    duration: float = Field(description=\"Video duration in seconds\")\n    width: int = Field(description=\"Video width in pixels\")\n    height: int = Field(description=\"Video height in pixels\")\n    fps: float = Field(description=\"Frames per second\")\n    codec: str = Field(description=\"Video codec name\")\n    file_size: int = Field(description=\"File size in bytes\")\n    has_audio: bool = Field(description=\"Whether video has audio track\")\n\n\nclass ThumbnailResponse(BaseModel):\n    \"\"\"Timeline thumbnail response.\"\"\"\n\n    thumbnails: list[str] = Field(description=\"Base64 encoded JPEG thumbnails\")\n    timestamps: list[float] = Field(description=\"Timestamp for each thumbnail in seconds\")\n\n\nclass ProcessResponse(BaseModel):\n    \"\"\"Processing result response.\"\"\"\n\n    status: str = Field(description=\"Processing status: completed, error\")\n    output_path: str | None = Field(default=None, description=\"Relative path to output file\")\n    message: str = Field(description=\"Status message\")\n"
  },
  {
    "path": "backend/app/schemas/user_notifications.py",
    "content": "\"\"\"Schemas for user email notification preferences.\"\"\"\n\nfrom pydantic import BaseModel\n\n\nclass UserEmailPreferenceResponse(BaseModel):\n    \"\"\"Response schema for user email notification preferences.\"\"\"\n\n    notify_print_start: bool\n    notify_print_complete: bool\n    notify_print_failed: bool\n    notify_print_stopped: bool\n\n    class Config:\n        from_attributes = True\n\n\nclass UserEmailPreferenceUpdate(BaseModel):\n    \"\"\"Update schema for user email notification preferences.\"\"\"\n\n    notify_print_start: bool\n    notify_print_complete: bool\n    notify_print_failed: bool\n    notify_print_stopped: bool\n"
  },
  {
    "path": "backend/app/services/__init__.py",
    "content": ""
  },
  {
    "path": "backend/app/services/archive.py",
    "content": "import hashlib\nimport json\nimport logging\nimport os\nimport re\nimport shutil\nimport zipfile\nfrom datetime import date, datetime, time, timezone\nfrom pathlib import Path\n\nfrom defusedxml import ElementTree as ET\nfrom sqlalchemy import and_, or_, select, text\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.config import settings\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.filament import Filament\nfrom backend.app.models.printer import Printer\n\nlogger = logging.getLogger(__name__)\n\n\ndef _copy_and_fsync(src: Path, dst: Path, chunk_size: int = 1024 * 1024) -> None:\n    \"\"\"Copy src to dst with an explicit chunked read/write and fsync the dst.\n\n    Replacement for shutil.copy2 in the archive pipeline. shutil.copy2 uses\n    Linux sendfile(), which on some kernels/filesystems has returned a short\n    count on the first call and truncated the destination for larger 3MF\n    uploads (#1032, observed on Raspberry Pi OS bookworm / armv7l). An\n    explicit loop with fsync avoids that path and guarantees the dest bytes\n    are on disk before the caller inspects them as a ZIP.\n    \"\"\"\n    with src.open(\"rb\") as rf, dst.open(\"wb\") as wf:\n        while True:\n            buf = rf.read(chunk_size)\n            if not buf:\n                break\n            wf.write(buf)\n        wf.flush()\n        os.fsync(wf.fileno())\n    shutil.copystat(src, dst)\n\n\nclass ThreeMFParser:\n    \"\"\"Parser for Bambu Lab 3MF files.\"\"\"\n\n    def __init__(self, file_path: Path, plate_number: int | None = None):\n        self.file_path = file_path\n        self.plate_number = plate_number  # Which plate was printed (1, 2, 3, etc.)\n        self.metadata: dict = {}\n\n    def parse(self) -> dict:\n        \"\"\"Extract metadata from 3MF file.\"\"\"\n        try:\n            with zipfile.ZipFile(self.file_path, \"r\") as zf:\n                self._parse_slice_info(zf)  # Now sets self.plate_number from slice_info\n                self._parse_project_settings(zf)\n                self._parse_gcode_header(zf)\n                self._parse_3dmodel(zf)\n                self._extract_thumbnail(zf)  # Uses correct plate_number for thumbnail\n\n                # Enhance print_name with plate info if this is a multi-plate export\n                plate_index = self.metadata.get(\"_plate_index\")\n                if plate_index and plate_index > 1:\n                    # Append plate number to distinguish from other plates\n                    existing_name = self.metadata.get(\"print_name\", \"\")\n                    if existing_name and f\"Plate {plate_index}\" not in existing_name:\n                        self.metadata[\"print_name\"] = f\"{existing_name} - Plate {plate_index}\"\n\n                # ALWAYS prefer slice_info values - they contain ONLY filaments actually used in print\n                # project_settings contains ALL configured filaments (AMS slots), not just used ones\n                if self.metadata.get(\"_slice_filament_type\"):\n                    self.metadata[\"filament_type\"] = self.metadata[\"_slice_filament_type\"]\n                if self.metadata.get(\"_slice_filament_color\"):\n                    self.metadata[\"filament_color\"] = self.metadata[\"_slice_filament_color\"]\n\n                # Clean up internal keys\n                self.metadata.pop(\"_slice_filament_type\", None)\n                self.metadata.pop(\"_slice_filament_color\", None)\n                self.metadata.pop(\"_plate_index\", None)\n        except Exception as e:\n            # Return whatever metadata was extracted before the error, but\n            # surface the failure so corrupted / truncated 3MF archives are\n            # visible in support bundles (#1032).\n            logger.warning(\n                \"ThreeMFParser: failed to parse %s: %s(%s) — returning partial metadata\",\n                self.file_path,\n                type(e).__name__,\n                e,\n            )\n        return self.metadata\n\n    def _parse_slice_info(self, zf: zipfile.ZipFile):\n        \"\"\"Parse slice_info.config for print settings and printable objects.\"\"\"\n        try:\n            if \"Metadata/slice_info.config\" in zf.namelist():\n                content = zf.read(\"Metadata/slice_info.config\").decode()\n                root = ET.fromstring(content)\n\n                # Extract printer_model_id from plate metadata\n                # Format: <plate><metadata key=\"printer_model_id\" value=\"C11\" /></plate>\n                for meta in root.findall(\".//metadata\"):\n                    key = meta.get(\"key\")\n                    value = meta.get(\"value\")\n                    if key == \"printer_model_id\" and value:\n                        from backend.app.utils.printer_models import normalize_printer_model_id\n\n                        normalized = normalize_printer_model_id(value)\n                        if normalized:\n                            self.metadata[\"sliced_for_model\"] = normalized\n                        break\n\n                # Find the plate element (single-plate exports only have one plate)\n                plate = root.find(\".//plate\")\n\n                if plate is not None:\n                    # Extract metadata from plate element\n                    for meta in plate.findall(\"metadata\"):\n                        key = meta.get(\"key\")\n                        value = meta.get(\"value\")\n                        if key == \"index\" and value:\n                            # Extract plate index - this tells us which plate was exported\n                            try:\n                                extracted_index = int(value)\n                                # Set plate_number if not already set from filename\n                                if not self.plate_number:\n                                    self.plate_number = extracted_index\n                                # Store in metadata for print_name generation\n                                self.metadata[\"_plate_index\"] = extracted_index\n                            except ValueError:\n                                pass  # Skip non-numeric plate index\n                        elif key == \"prediction\" and value:\n                            self.metadata[\"print_time_seconds\"] = int(value)\n                        elif key == \"weight\" and value:\n                            self.metadata[\"filament_used_grams\"] = float(value)\n\n                    # Extract printable objects for skip object functionality\n                    # Objects are stored as <object identify_id=\"123\" name=\"Part1\" skipped=\"false\" />\n                    printable_objects = {}\n                    for obj in plate.findall(\"object\"):\n                        identify_id = obj.get(\"identify_id\")\n                        name = obj.get(\"name\")\n                        skipped = obj.get(\"skipped\", \"false\")\n\n                        # Only include objects that are not pre-skipped\n                        if identify_id and name and skipped.lower() != \"true\":\n                            try:\n                                printable_objects[int(identify_id)] = name\n                            except ValueError:\n                                pass  # Skip objects with non-numeric identify_id\n\n                    if printable_objects:\n                        self.metadata[\"printable_objects\"] = printable_objects\n\n                # Get filament info from filaments ACTUALLY USED in the print\n                # slice_info has <filament id=\"1\" type=\"PLA\" color=\"#FFFFFF\" used_g=\"100\" />\n                # Only include filaments where used_g > 0\n                filaments = root.findall(\".//filament\")\n                if filaments:\n                    # Collect unique filament types and colors for filaments that are actually used\n                    types = []\n                    colors = []\n                    for f in filaments:\n                        # Check if this filament is actually used in the print\n                        used_g = f.get(\"used_g\", \"0\")\n                        try:\n                            used_amount = float(used_g)\n                        except (ValueError, TypeError):\n                            used_amount = 0\n\n                        # Only include if used_g > 0 (filament is actually consumed)\n                        if used_amount > 0:\n                            ftype = f.get(\"type\")\n                            fcolor = f.get(\"color\")\n                            if ftype and ftype not in types:\n                                types.append(ftype)\n                            if fcolor and fcolor not in colors:\n                                colors.append(fcolor)\n\n                    if types:\n                        self.metadata[\"_slice_filament_type\"] = \", \".join(types)\n                    if colors:\n                        self.metadata[\"_slice_filament_color\"] = \",\".join(colors)\n\n                    # Collect per-slot filament usage for tracking & notifications\n                    filament_slots = []\n                    for f in filaments:\n                        slot_id = f.get(\"id\")\n                        used_g_str = f.get(\"used_g\", \"0\")\n                        try:\n                            used_g = float(used_g_str)\n                        except (ValueError, TypeError):\n                            used_g = 0\n                        if used_g > 0 and slot_id:\n                            filament_slots.append(\n                                {\n                                    \"slot_id\": int(slot_id),\n                                    \"used_g\": round(used_g, 2),\n                                    \"type\": f.get(\"type\", \"\"),\n                                    \"color\": f.get(\"color\", \"\"),\n                                }\n                            )\n                    if filament_slots:\n                        self.metadata[\"filament_slots\"] = filament_slots\n        except Exception:\n            pass  # Skip unparseable slice_info metadata\n\n    def _parse_project_settings(self, zf: zipfile.ZipFile):\n        \"\"\"Parse project settings for print configuration.\"\"\"\n        try:\n            if \"Metadata/project_settings.config\" in zf.namelist():\n                content = zf.read(\"Metadata/project_settings.config\").decode()\n                try:\n                    data = json.loads(content)\n                    self._extract_filament_info(data)\n                    self._extract_print_settings(data)\n                except json.JSONDecodeError:\n                    pass  # Skip malformed project_settings JSON\n        except Exception:\n            pass  # Skip unreadable project settings file\n\n    def _parse_gcode_header(self, zf: zipfile.ZipFile):\n        \"\"\"Parse G-code file header for total layer count and printer model.\"\"\"\n        try:\n            # Look for plate_1.gcode or similar\n            gcode_files = [f for f in zf.namelist() if f.endswith(\".gcode\")]\n            if not gcode_files:\n                return\n\n            # Read first 4KB of G-code (header contains metadata)\n            gcode_path = gcode_files[0]\n            with zf.open(gcode_path) as f:\n                header = f.read(4096).decode(\"utf-8\", errors=\"ignore\")\n\n            # Look for \"; total layer number: XX\" pattern\n            match = re.search(r\";\\s*total\\s+layer\\s+number[:\\s]+(\\d+)\", header, re.IGNORECASE)\n            if match:\n                self.metadata[\"total_layers\"] = int(match.group(1))\n\n            # Look for printer_model in gcode header (fallback if not found in slice_info)\n            # Format: \"; printer_model = Bambu Lab X1 Carbon\" or \"; printer_model = X1C\"\n            if \"sliced_for_model\" not in self.metadata:\n                match = re.search(r\";\\s*printer_model\\s*=\\s*(.+)\", header, re.IGNORECASE)\n                if match:\n                    from backend.app.utils.printer_models import normalize_printer_model\n\n                    raw_model = match.group(1).strip()\n                    self.metadata[\"sliced_for_model\"] = normalize_printer_model(raw_model)\n        except Exception:\n            pass  # G-code header parsing is best-effort; metadata may come from other sources\n\n    def _extract_filament_info(self, data: dict):\n        \"\"\"Extract filament info, preferring non-support filaments.\"\"\"\n        try:\n            filament_types = data.get(\"filament_type\", [])\n            filament_colors = data.get(\"filament_colour\", [])\n            filament_is_support = data.get(\"filament_is_support\", [])\n\n            if not filament_types:\n                return\n\n            # Collect all non-support filaments\n            non_support_types = []\n            non_support_colors = []\n\n            for i, ftype in enumerate(filament_types):\n                is_support = filament_is_support[i] if i < len(filament_is_support) else \"0\"\n                if is_support == \"0\":\n                    if ftype and ftype not in non_support_types:\n                        non_support_types.append(ftype)\n                    if i < len(filament_colors) and filament_colors[i]:\n                        color = filament_colors[i]\n                        if color not in non_support_colors:\n                            non_support_colors.append(color)\n\n            # Fallback to first filament if all are support\n            if not non_support_types and filament_types:\n                non_support_types = [filament_types[0]]\n            if not non_support_colors and filament_colors:\n                non_support_colors = [filament_colors[0]]\n\n            # Store filament type(s)\n            if non_support_types:\n                self.metadata[\"filament_type\"] = \", \".join(non_support_types)\n\n            # Store all colors as comma-separated (for multi-color display)\n            if non_support_colors:\n                self.metadata[\"filament_color\"] = \",\".join(non_support_colors)\n\n        except Exception:\n            pass  # Filament info is optional; fall back to slice_info values\n\n    def _extract_print_settings(self, data: dict):\n        \"\"\"Extract print settings from JSON config.\"\"\"\n        try:\n            # Layer height - usually an array, get first value\n            if \"layer_height\" in data:\n                val = data[\"layer_height\"]\n                if isinstance(val, list) and val:\n                    self.metadata[\"layer_height\"] = float(val[0])\n                elif isinstance(val, (int, float, str)):\n                    self.metadata[\"layer_height\"] = float(val)\n\n            # Nozzle diameter\n            if \"nozzle_diameter\" in data:\n                val = data[\"nozzle_diameter\"]\n                if isinstance(val, list) and val:\n                    self.metadata[\"nozzle_diameter\"] = float(val[0])\n                elif isinstance(val, (int, float, str)):\n                    self.metadata[\"nozzle_diameter\"] = float(val)\n\n            # Bed temperature - first layer or regular\n            for key in [\"bed_temperature_initial_layer\", \"bed_temperature\"]:\n                if key in data:\n                    val = data[key]\n                    if isinstance(val, list) and val:\n                        self.metadata[\"bed_temperature\"] = int(float(val[0]))\n                    elif isinstance(val, (int, float, str)):\n                        self.metadata[\"bed_temperature\"] = int(float(val))\n                    break\n\n            # Nozzle temperature\n            for key in [\"nozzle_temperature_initial_layer\", \"nozzle_temperature\"]:\n                if key in data:\n                    val = data[key]\n                    if isinstance(val, list) and val:\n                        self.metadata[\"nozzle_temperature\"] = int(float(val[0]))\n                    elif isinstance(val, (int, float, str)):\n                        self.metadata[\"nozzle_temperature\"] = int(float(val))\n                    break\n\n            # Printer model (extract and normalize)\n            if \"printer_model\" in data:\n                from backend.app.utils.printer_models import normalize_printer_model\n\n                self.metadata[\"sliced_for_model\"] = normalize_printer_model(data[\"printer_model\"])\n        except Exception:\n            pass  # Print settings are optional; missing values are left unset\n\n    def _extract_settings_from_content(self, content: str):\n        \"\"\"Extract print settings from config content.\"\"\"\n        settings_map = {\n            \"layer_height\": (\"layer_height\", float),\n            \"nozzle_diameter\": (\"nozzle_diameter\", float),\n            \"bed_temperature\": (\"bed_temperature\", int),\n            \"nozzle_temperature\": (\"nozzle_temperature\", int),\n        }\n\n        for key, (search_key, converter) in settings_map.items():\n            if key not in self.metadata:\n                try:\n                    # Try JSON format\n                    if f'\"{search_key}\"' in content:\n                        start = content.find(f'\"{search_key}\"')\n                        value_start = content.find(\":\", start) + 1\n                        value_end = content.find(\",\", value_start)\n                        if value_end == -1:\n                            value_end = content.find(\"}\", value_start)\n                        value = content[value_start:value_end].strip().strip('\"')\n                        self.metadata[key] = converter(value)\n                except (ValueError, TypeError):\n                    pass  # Skip settings with unconvertible values\n\n    def _parse_3dmodel(self, zf: zipfile.ZipFile):\n        \"\"\"Parse 3D/3dmodel.model for MakerWorld metadata.\"\"\"\n        try:\n            model_path = \"3D/3dmodel.model\"\n            if model_path not in zf.namelist():\n                return\n\n            content = zf.read(model_path).decode(\"utf-8\", errors=\"ignore\")\n\n            # Parse XML metadata elements\n            # MakerWorld adds metadata like: <metadata name=\"Designer\">username</metadata>\n            metadata_pattern = r'<metadata\\s+name=\"([^\"]+)\"[^>]*>([^<]*)</metadata>'\n            matches = re.findall(metadata_pattern, content)\n\n            makerworld_fields = {}\n            for name, value in matches:\n                makerworld_fields[name] = value.strip()\n\n            # Check for direct MakerWorld URL in content\n            url_pattern = r'https?://makerworld\\.com/[^\\s<>\"\\']+/models/(\\d+)'\n            url_match = re.search(url_pattern, content)\n            if url_match:\n                self.metadata[\"makerworld_url\"] = url_match.group(0)\n                self.metadata[\"makerworld_model_id\"] = url_match.group(1)\n\n            # Extract model ID from DSM reference in image URLs\n            # Format: https://makerworld.bblmw.com/makerworld/model/DSM00000001275614/...\n            # The numeric part (1275614) is the MakerWorld model ID\n            if \"makerworld_url\" not in self.metadata:\n                dsm_pattern = r\"DSM0+(\\d+)\"\n                dsm_match = re.search(dsm_pattern, content)\n                if dsm_match:\n                    model_id = dsm_match.group(1)\n                    self.metadata[\"makerworld_url\"] = f\"https://makerworld.com/en/models/{model_id}\"\n                    self.metadata[\"makerworld_model_id\"] = model_id\n\n            # Store designer info\n            if \"Designer\" in makerworld_fields:\n                self.metadata[\"designer\"] = makerworld_fields[\"Designer\"]\n            if \"Title\" in makerworld_fields:\n                self.metadata[\"print_name\"] = makerworld_fields[\"Title\"]\n\n        except Exception:\n            pass  # MakerWorld/3dmodel metadata is optional\n\n    def _extract_thumbnail(self, zf: zipfile.ZipFile):\n        \"\"\"Extract thumbnail image from 3MF.\n\n        If a plate_number was specified, try to use that plate's thumbnail first.\n        \"\"\"\n        thumbnail_paths = []\n\n        # If a specific plate was printed, try that thumbnail first\n        if self.plate_number:\n            thumbnail_paths.append(f\"Metadata/plate_{self.plate_number}.png\")\n\n        # Fallback to default paths\n        thumbnail_paths.extend(\n            [\n                \"Metadata/plate_1.png\",\n                \"Metadata/thumbnail.png\",\n                \"Metadata/model_thumbnail.png\",\n            ]\n        )\n\n        for thumb_path in thumbnail_paths:\n            if thumb_path in zf.namelist():\n                self.metadata[\"_thumbnail_data\"] = zf.read(thumb_path)\n                self.metadata[\"_thumbnail_ext\"] = \".png\"\n                break\n\n\ndef extract_printable_objects_from_3mf(\n    data: bytes, plate_number: int | None = None, include_positions: bool = False\n) -> dict[int, str] | dict[int, dict] | tuple[dict[int, dict], list | None]:\n    \"\"\"Extract printable objects from 3MF file bytes.\n\n    This is a lightweight function used during print start to get the list\n    of objects that can be skipped.\n\n    Args:\n        data: Raw bytes of the 3MF file\n        plate_number: Which plate was printed (1-based), or None for first plate\n        include_positions: If True, return tuple of (objects dict, bbox_all)\n\n    Returns:\n        If include_positions=False: Dictionary mapping identify_id (int) to object name (str)\n        If include_positions=True: Tuple of (dict mapping identify_id to {name, x, y}, bbox_all list or None)\n    \"\"\"\n    from io import BytesIO\n\n    printable_objects: dict = {}\n    bbox_all: list | None = None\n\n    try:\n        with zipfile.ZipFile(BytesIO(data), \"r\") as zf:\n            if \"Metadata/slice_info.config\" not in zf.namelist():\n                return printable_objects\n\n            content = zf.read(\"Metadata/slice_info.config\").decode()\n            root = ET.fromstring(content)\n\n            # Find the correct plate\n            if plate_number:\n                plate = root.find(f\".//plate[@plate_idx='{plate_number}']\")\n                if plate is None:\n                    plate = root.find(\".//plate\")\n            else:\n                plate = root.find(\".//plate\")\n\n            if plate is None:\n                return printable_objects\n\n            # Get actual plate index from metadata (sliced files only have one plate)\n            plate_idx = plate_number or 1\n            for meta in plate.findall(\"metadata\"):\n                if meta.get(\"key\") == \"index\":\n                    try:\n                        plate_idx = int(meta.get(\"value\", \"1\"))\n                    except ValueError:\n                        pass  # Use default plate_idx if value is non-numeric\n                    break\n\n            # Load position data from plate_N.json if we need positions\n            # Build a lookup by name - use list to handle duplicate names\n            bbox_by_name: dict[str, list[list]] = {}\n            if include_positions:\n                plate_json_path = f\"Metadata/plate_{plate_idx}.json\"\n                if plate_json_path in zf.namelist():\n                    try:\n                        plate_json = json.loads(zf.read(plate_json_path).decode())\n                        # Get bbox_all - the bounding box of all objects (used for image bounds)\n                        bbox_all = plate_json.get(\"bbox_all\")\n                        for bbox_obj in plate_json.get(\"bbox_objects\", []):\n                            obj_name = bbox_obj.get(\"name\")\n                            bbox = bbox_obj.get(\"bbox\", [])\n                            if obj_name and len(bbox) >= 4:\n                                if obj_name not in bbox_by_name:\n                                    bbox_by_name[obj_name] = []\n                                bbox_by_name[obj_name].append(bbox)\n                    except (json.JSONDecodeError, KeyError):\n                        pass  # Position data is optional; objects will lack x/y coordinates\n\n            # Extract objects from slice_info.config\n            for obj in plate.findall(\"object\"):\n                identify_id = obj.get(\"identify_id\")\n                name = obj.get(\"name\")\n                skipped = obj.get(\"skipped\", \"false\")\n\n                if identify_id and name and skipped.lower() != \"true\":\n                    try:\n                        obj_id = int(identify_id)\n                        if include_positions:\n                            x, y = None, None\n                            # Match by name - pop first bbox to handle duplicates\n                            bboxes = bbox_by_name.get(name)\n                            if bboxes:\n                                bbox = bboxes.pop(0)\n                                # Calculate center from bbox [x_min, y_min, x_max, y_max]\n                                x = (bbox[0] + bbox[2]) / 2\n                                y = (bbox[1] + bbox[3]) / 2\n                            printable_objects[obj_id] = {\"name\": name, \"x\": x, \"y\": y}\n                        else:\n                            printable_objects[obj_id] = name\n                    except ValueError:\n                        pass  # Skip objects with non-numeric identify_id\n\n    except Exception:\n        pass  # Return empty dict if 3MF is corrupt or unreadable\n\n    if include_positions:\n        return printable_objects, bbox_all\n    return printable_objects\n\n\nclass ProjectPageParser:\n    \"\"\"Parser for extracting project page data from Bambu Lab 3MF files.\"\"\"\n\n    def __init__(self, file_path: Path):\n        self.file_path = file_path\n\n    def parse(self, archive_id: int) -> dict:\n        \"\"\"Extract project page metadata and images from 3MF file.\"\"\"\n        import html\n\n        result = {\n            \"title\": None,\n            \"description\": None,\n            \"designer\": None,\n            \"designer_user_id\": None,\n            \"license\": None,\n            \"copyright\": None,\n            \"creation_date\": None,\n            \"modification_date\": None,\n            \"origin\": None,\n            \"profile_title\": None,\n            \"profile_description\": None,\n            \"profile_cover\": None,\n            \"profile_user_id\": None,\n            \"profile_user_name\": None,\n            \"design_model_id\": None,\n            \"design_profile_id\": None,\n            \"design_region\": None,\n            \"model_pictures\": [],\n            \"profile_pictures\": [],\n            \"thumbnails\": [],\n        }\n\n        try:\n            with zipfile.ZipFile(self.file_path, \"r\") as zf:\n                # Parse 3D/3dmodel.model for metadata\n                model_path = \"3D/3dmodel.model\"\n                if model_path in zf.namelist():\n                    content = zf.read(model_path).decode(\"utf-8\", errors=\"ignore\")\n\n                    # Extract metadata elements using regex\n                    # Format: <metadata name=\"Key\">Value</metadata> or <metadata name=\"Key\" />\n                    metadata_pattern = r'<metadata\\s+name=\"([^\"]+)\"[^>]*>([^<]*)</metadata>'\n                    matches = re.findall(metadata_pattern, content)\n\n                    field_mapping = {\n                        \"Title\": \"title\",\n                        \"Description\": \"description\",\n                        \"Designer\": \"designer\",\n                        \"DesignerUserId\": \"designer_user_id\",\n                        \"License\": \"license\",\n                        \"Copyright\": \"copyright\",\n                        \"CreationDate\": \"creation_date\",\n                        \"ModificationDate\": \"modification_date\",\n                        \"Origin\": \"origin\",\n                        \"ProfileTitle\": \"profile_title\",\n                        \"ProfileDescription\": \"profile_description\",\n                        \"ProfileCover\": \"profile_cover\",\n                        \"ProfileUserId\": \"profile_user_id\",\n                        \"ProfileUserName\": \"profile_user_name\",\n                        \"DesignModelId\": \"design_model_id\",\n                        \"DesignProfileId\": \"design_profile_id\",\n                        \"DesignRegion\": \"design_region\",\n                    }\n\n                    for name, value in matches:\n                        if name in field_mapping:\n                            # Decode HTML entities multiple times (content is often triple-encoded)\n                            decoded = value.strip()\n                            prev = None\n                            while prev != decoded:\n                                prev = decoded\n                                decoded = html.unescape(decoded)\n                            # Normalize non-breaking spaces to regular spaces\n                            decoded = decoded.replace(\"\\xa0\", \" \")\n                            result[field_mapping[name]] = decoded if decoded else None\n\n                # List images in Auxiliaries folder\n                from urllib.parse import quote\n\n                for name in zf.namelist():\n                    if name.startswith(\"Auxiliaries/Model Pictures/\"):\n                        filename = name.split(\"/\")[-1]\n                        if filename:\n                            result[\"model_pictures\"].append(\n                                {\n                                    \"name\": filename,\n                                    \"path\": name,\n                                    \"url\": f\"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}\",\n                                }\n                            )\n                    elif name.startswith(\"Auxiliaries/Profile Pictures/\"):\n                        filename = name.split(\"/\")[-1]\n                        if filename:\n                            result[\"profile_pictures\"].append(\n                                {\n                                    \"name\": filename,\n                                    \"path\": name,\n                                    \"url\": f\"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}\",\n                                }\n                            )\n                    elif name.startswith(\"Auxiliaries/.thumbnails/\"):\n                        filename = name.split(\"/\")[-1]\n                        if filename:\n                            result[\"thumbnails\"].append(\n                                {\n                                    \"name\": filename,\n                                    \"path\": name,\n                                    \"url\": f\"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}\",\n                                }\n                            )\n\n        except Exception as e:\n            result[\"_error\"] = str(e)\n\n        return result\n\n    def get_image(self, image_path: str) -> tuple[bytes, str] | None:\n        \"\"\"Extract an image from the 3MF file.\n\n        Returns tuple of (image_data, content_type) or None if not found.\n        \"\"\"\n        try:\n            with zipfile.ZipFile(self.file_path, \"r\") as zf:\n                if image_path in zf.namelist():\n                    data = zf.read(image_path)\n                    # Determine content type from extension\n                    ext = image_path.lower().split(\".\")[-1]\n                    content_types = {\n                        \"png\": \"image/png\",\n                        \"jpg\": \"image/jpeg\",\n                        \"jpeg\": \"image/jpeg\",\n                        \"webp\": \"image/webp\",\n                        \"gif\": \"image/gif\",\n                    }\n                    content_type = content_types.get(ext, \"application/octet-stream\")\n                    return (data, content_type)\n        except Exception:\n            pass  # Return None if image cannot be extracted from 3MF\n        return None\n\n    def update_metadata(self, updates: dict) -> bool:\n        \"\"\"Update project page metadata in the 3MF file.\n\n        Args:\n            updates: Dict with fields to update (title, description, designer, etc.)\n\n        Returns:\n            True if successful, False otherwise.\n        \"\"\"\n        import html\n        import tempfile\n\n        try:\n            # Read the 3MF file\n            with zipfile.ZipFile(self.file_path, \"r\") as zf_read:\n                # Find and read the 3dmodel.model file\n                model_path = \"3D/3dmodel.model\"\n                if model_path not in zf_read.namelist():\n                    return False\n\n                content = zf_read.read(model_path).decode(\"utf-8\")\n\n                # Update metadata fields\n                field_mapping = {\n                    \"title\": \"Title\",\n                    \"description\": \"Description\",\n                    \"designer\": \"Designer\",\n                    \"license\": \"License\",\n                    \"copyright\": \"Copyright\",\n                    \"profile_title\": \"ProfileTitle\",\n                    \"profile_description\": \"ProfileDescription\",\n                }\n\n                for field, xml_name in field_mapping.items():\n                    if field in updates and updates[field] is not None:\n                        new_value = html.escape(updates[field])\n                        # Replace existing metadata or we'd need to add it\n                        pattern = rf'(<metadata\\s+name=\"{xml_name}\"[^>]*>)[^<]*(</metadata>)'\n                        replacement = rf\"\\g<1>{new_value}\\g<2>\"\n                        content = re.sub(pattern, replacement, content)\n\n                # Write to a temporary file first\n                with tempfile.NamedTemporaryFile(delete=False, suffix=\".3mf\") as tmp:\n                    tmp_path = Path(tmp.name)\n\n                # Create new zip with updated content\n                with zipfile.ZipFile(tmp_path, \"w\", zipfile.ZIP_DEFLATED) as zf_write:\n                    for item in zf_read.namelist():\n                        if item == model_path:\n                            zf_write.writestr(item, content.encode(\"utf-8\"))\n                        else:\n                            zf_write.writestr(item, zf_read.read(item))\n\n            # Replace original file with updated one\n            shutil.move(tmp_path, self.file_path)\n            return True\n\n        except Exception:\n            # Clean up temp file if it exists\n            if \"tmp_path\" in locals() and tmp_path.exists():\n                tmp_path.unlink()\n            return False\n\n\nclass ArchiveService:\n    \"\"\"Service for archiving print jobs.\"\"\"\n\n    def __init__(self, db: AsyncSession):\n        self.db = db\n\n    @staticmethod\n    def compute_file_hash(file_path: Path) -> str:\n        \"\"\"Compute SHA256 hash of a file for duplicate detection.\"\"\"\n        sha256 = hashlib.sha256()\n        with open(file_path, \"rb\") as f:\n            # Read in chunks to handle large files\n            for chunk in iter(lambda: f.read(8192), b\"\"):\n                sha256.update(chunk)\n        return sha256.hexdigest()\n\n    async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[tuple[str, str]]]:\n        \"\"\"Get all content hashes and (print name, hash) pairs that appear more than once.\n\n        For hashes: returns all hashes with > 1 archive (true duplicates).\n        For name/hash pairs: returns only pairs that have > 1 archive\n                     (i.e., same file archived multiple times, not different files with same name).\n\n        Returns a tuple of (duplicate_hashes, duplicate_name_hash_pairs).\n        \"\"\"\n        from sqlalchemy import func\n\n        result = await self.db.execute(\n            select(PrintArchive.content_hash)\n            .where(PrintArchive.content_hash.isnot(None))\n            .group_by(PrintArchive.content_hash)\n            .having(func.count(PrintArchive.id) > 1)\n        )\n        duplicate_hashes = {row[0] for row in result.all()}\n\n        # Find print names that have multiple archives with the SAME hash\n        # This avoids marking different files with the same name as duplicates\n        result = await self.db.execute(\n            select(func.lower(PrintArchive.print_name), PrintArchive.content_hash)\n            .where(PrintArchive.print_name.isnot(None), PrintArchive.content_hash.isnot(None))\n            .group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)\n            .having(func.count(PrintArchive.id) > 1)\n        )\n        duplicate_name_hash_pairs = {(row[0], row[1]) for row in result.all()}\n\n        return duplicate_hashes, duplicate_name_hash_pairs\n\n    async def find_duplicates(\n        self,\n        archive_id: int,\n        content_hash: str | None = None,\n        print_name: str | None = None,\n        makerworld_model_id: str | None = None,\n    ) -> list[dict]:\n        \"\"\"Find duplicate archives based on hash or name matching.\n\n        Returns list of dicts with id, print_name, created_at, match_type.\n        \"\"\"\n        duplicates = []\n\n        # First, find exact matches by content hash\n        if content_hash:\n            result = await self.db.execute(\n                select(PrintArchive)\n                .where(\n                    and_(\n                        PrintArchive.content_hash == content_hash,\n                        PrintArchive.id != archive_id,\n                    )\n                )\n                .order_by(PrintArchive.created_at.desc())\n                .limit(10)\n            )\n            for archive in result.scalars().all():\n                duplicates.append(\n                    {\n                        \"id\": archive.id,\n                        \"print_name\": archive.print_name,\n                        \"created_at\": archive.created_at,\n                        \"match_type\": \"exact\",\n                    }\n                )\n\n        # Then, find similar matches by print name or MakerWorld ID\n        # Prefer strict name+hash matching when hash exists; fallback to name-only for legacy/manual\n        # archives that may not have a content_hash.\n        if print_name or makerworld_model_id:\n            conditions = [PrintArchive.id != archive_id]\n\n            name_conditions = []\n            if print_name:\n                if content_hash:\n                    # Match if print names are similar AND have the same hash (same file)\n                    name_conditions.append(\n                        and_(PrintArchive.print_name.ilike(print_name), PrintArchive.content_hash == content_hash)\n                    )\n                else:\n                    # Fallback for archives without hash data: match by print name only.\n                    name_conditions.append(PrintArchive.print_name.ilike(print_name))\n            if makerworld_model_id:\n                # Match by MakerWorld model ID stored in extra_data\n                from backend.app.core.db_dialect import is_sqlite\n\n                if is_sqlite():\n                    from sqlalchemy import func\n\n                    name_conditions.append(\n                        func.json_extract(PrintArchive.extra_data, \"$.makerworld_model_id\") == str(makerworld_model_id)\n                    )\n                else:\n                    name_conditions.append(\n                        text(\"(extra_data::jsonb->>'makerworld_model_id') = :mw_id\").bindparams(\n                            mw_id=str(makerworld_model_id)\n                        )\n                    )\n\n            if name_conditions:\n                conditions.append(or_(*name_conditions))\n\n                result = await self.db.execute(\n                    select(PrintArchive).where(and_(*conditions)).order_by(PrintArchive.created_at.desc()).limit(10)\n                )\n                for archive in result.scalars().all():\n                    # Don't add if already in duplicates (exact match)\n                    if not any(d[\"id\"] == archive.id for d in duplicates):\n                        duplicates.append(\n                            {\n                                \"id\": archive.id,\n                                \"print_name\": archive.print_name,\n                                \"created_at\": archive.created_at,\n                                \"match_type\": \"similar\",\n                            }\n                        )\n\n        return duplicates\n\n    async def archive_print(\n        self,\n        printer_id: int | None,\n        source_file: Path,\n        print_data: dict | None = None,\n        created_by_id: int | None = None,\n        original_filename: str | None = None,\n        project_id: int | None = None,\n        subtask_id: str | None = None,\n    ) -> PrintArchive | None:\n        \"\"\"Archive a 3MF file with metadata.\n\n        Args:\n            printer_id: ID of the printer (optional)\n            source_file: Path to the 3MF file\n            print_data: Print data from MQTT (optional)\n            created_by_id: User ID who created this archive (optional, for user tracking)\n            original_filename: Original human-readable filename (optional, for library files\n                stored with UUID names)\n            project_id: Project to associate this archive with (optional, set when triggered\n                from the project view)\n            subtask_id: MQTT-provided task identifier (optional). Used to match an\n                existing archive across a backend restart mid-print so the\n                original row can be resumed instead of cancelled (#972).\n        \"\"\"\n        # Verify printer exists if specified\n        if printer_id is not None:\n            result = await self.db.execute(select(Printer).where(Printer.id == printer_id))\n            printer = result.scalar_one_or_none()\n            if not printer:\n                return None\n\n        # Create archive directory structure\n        timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n        display_stem = Path(original_filename).stem if original_filename else source_file.stem\n        archive_name = f\"{timestamp}_{display_stem}\"\n        # Use \"unassigned\" folder for archives without a printer\n        printer_folder = str(printer_id) if printer_id is not None else \"unassigned\"\n        archive_dir = settings.archive_dir / printer_folder / archive_name\n        archive_dir.mkdir(parents=True, exist_ok=True)\n\n        # Copy 3MF file with an explicit fsync'd loop (avoids a sendfile\n        # short-read quirk that silently truncated 3MF archives on some\n        # platforms — see _copy_and_fsync and #1032).\n        dest_file = archive_dir / source_file.name\n        _copy_and_fsync(source_file, dest_file)\n\n        # If we just archived a 3MF, verify the dest is a valid ZIP before\n        # going any further. Staying quiet here is how #1032 escaped review —\n        # the archive row was written but every later zipfile.ZipFile() call\n        # on the dest failed with \"File is not a zip file\".\n        if (\n            source_file.suffix.lower() == \".3mf\"\n            and zipfile.is_zipfile(source_file)\n            and not zipfile.is_zipfile(dest_file)\n        ):\n            try:\n                src_size = source_file.stat().st_size\n                dst_size = dest_file.stat().st_size\n            except OSError:\n                src_size = dst_size = -1\n            logger.error(\n                \"Archive copy corrupted 3MF: src=%s (%s bytes, valid ZIP) -> dst=%s (%s bytes, NOT a ZIP). Refusing to create archive row.\",\n                source_file,\n                src_size,\n                dest_file,\n                dst_size,\n            )\n            # Narrow cleanup: remove only the truncated file and the archive\n            # directory if it's now empty. archive_dir was created with\n            # exist_ok=True so it could in theory pre-date this call (e.g.\n            # same-second same-filename collision); rmtree would be too broad.\n            try:\n                dest_file.unlink()\n            except OSError:\n                pass\n            try:\n                archive_dir.rmdir()\n            except OSError:\n                pass  # directory not empty — leave untouched\n            return None\n\n        # Compute content hash for duplicate detection\n        content_hash = self.compute_file_hash(dest_file)\n\n        # Extract plate number from filename (e.g., \"plate_5\" from \"/data/Metadata/plate_5.gcode\")\n        plate_number = None\n        if print_data:\n            filename = print_data.get(\"filename\", \"\")\n            match = re.search(r\"plate_(\\d+)\", filename)\n            if match:\n                plate_number = int(match.group(1))\n\n        # Parse 3MF metadata\n        parser = ThreeMFParser(dest_file, plate_number=plate_number)\n        metadata = parser.parse()\n\n        # Save thumbnail if present\n        thumbnail_path = None\n        if \"_thumbnail_data\" in metadata:\n            thumb_file = archive_dir / f\"thumbnail{metadata['_thumbnail_ext']}\"\n            thumb_file.write_bytes(metadata[\"_thumbnail_data\"])\n            thumbnail_path = str(thumb_file.relative_to(settings.base_dir))\n            del metadata[\"_thumbnail_data\"]\n            del metadata[\"_thumbnail_ext\"]\n\n        # Merge with print data from MQTT\n        if print_data:\n            metadata[\"_print_data\"] = print_data\n\n        # Determine status and timestamps\n        status = print_data.get(\"status\", \"completed\") if print_data else \"archived\"\n        started_at = datetime.now(timezone.utc) if status == \"printing\" else None\n        completed_at = datetime.now(timezone.utc) if status in (\"completed\", \"failed\", \"archived\") else None\n\n        # Calculate cost based on filament usage and type\n        cost = None\n        filament_grams = metadata.get(\"filament_used_grams\")\n        filament_type = metadata.get(\"filament_type\")\n        if filament_grams and filament_type:\n            # For multi-material prints, use the first filament type for cost calculation\n            primary_type = filament_type.split(\",\")[0].strip()\n            # Look up filament cost_per_kg from database\n            filament_result = await self.db.execute(select(Filament).where(Filament.type == primary_type).limit(1))\n            filament = filament_result.scalar_one_or_none()\n            if filament:\n                cost = round((filament_grams / 1000) * filament.cost_per_kg, 2)\n            else:\n                # Use default filament cost from settings\n                from backend.app.api.routes.settings import get_setting\n\n                default_cost_setting = await get_setting(self.db, \"default_filament_cost\")\n                default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0\n                cost = round((filament_grams / 1000) * default_cost_per_kg, 2)\n\n        # Calculate quantity from printable objects count\n        # printable_objects is a dict of {identify_id: name} for non-skipped objects\n        quantity = 1  # Default to 1\n        printable_objects = metadata.get(\"printable_objects\")\n        if printable_objects and isinstance(printable_objects, dict):\n            quantity = len(printable_objects)\n            logger.debug(\"Auto-detected %s parts from 3MF printable objects\", quantity)\n\n        # Create archive record\n        archive = PrintArchive(\n            printer_id=printer_id,\n            filename=original_filename or source_file.name,\n            file_path=str(dest_file.relative_to(settings.base_dir)),\n            file_size=dest_file.stat().st_size,\n            content_hash=content_hash,\n            thumbnail_path=thumbnail_path,\n            print_name=metadata.get(\"print_name\") or display_stem,\n            print_time_seconds=metadata.get(\"print_time_seconds\"),\n            filament_used_grams=metadata.get(\"filament_used_grams\"),\n            filament_type=metadata.get(\"filament_type\"),\n            filament_color=metadata.get(\"filament_color\"),\n            layer_height=metadata.get(\"layer_height\"),\n            total_layers=metadata.get(\"total_layers\"),\n            nozzle_diameter=metadata.get(\"nozzle_diameter\"),\n            bed_temperature=metadata.get(\"bed_temperature\"),\n            nozzle_temperature=metadata.get(\"nozzle_temperature\"),\n            sliced_for_model=metadata.get(\"sliced_for_model\"),\n            makerworld_url=metadata.get(\"makerworld_url\"),\n            designer=metadata.get(\"designer\"),\n            status=status,\n            started_at=started_at,\n            completed_at=completed_at,\n            cost=cost,\n            quantity=quantity,\n            extra_data=metadata,\n            created_by_id=created_by_id,\n            project_id=project_id,\n            subtask_id=subtask_id,\n        )\n\n        self.db.add(archive)\n        await self.db.commit()\n        await self.db.refresh(archive)\n\n        return archive\n\n    async def get_archive(self, archive_id: int) -> PrintArchive | None:\n        \"\"\"Get an archive by ID with relationships loaded.\"\"\"\n        from sqlalchemy.orm import selectinload\n\n        result = await self.db.execute(\n            select(PrintArchive)\n            .options(selectinload(PrintArchive.created_by), selectinload(PrintArchive.project))\n            .where(PrintArchive.id == archive_id)\n        )\n        return result.scalar_one_or_none()\n\n    async def update_archive_status(\n        self,\n        archive_id: int,\n        status: str,\n        completed_at: datetime | None = None,\n        failure_reason: str | None = None,\n    ) -> bool:\n        \"\"\"Update the status of an archive.\"\"\"\n        archive = await self.get_archive(archive_id)\n        if not archive:\n            return False\n\n        archive.status = status\n        if completed_at:\n            archive.completed_at = completed_at\n        if failure_reason:\n            archive.failure_reason = failure_reason\n\n        await self.db.commit()\n        return True\n\n    async def list_archives(\n        self,\n        printer_id: int | None = None,\n        project_id: int | None = None,\n        date_from: date | None = None,\n        date_to: date | None = None,\n        limit: int = 50,\n        offset: int = 0,\n    ) -> list[PrintArchive]:\n        \"\"\"List archives with optional filtering.\"\"\"\n        from sqlalchemy.orm import selectinload\n\n        query = (\n            select(PrintArchive)\n            .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))\n            .order_by(PrintArchive.created_at.desc())\n        )\n\n        if printer_id:\n            query = query.where(PrintArchive.printer_id == printer_id)\n\n        if project_id:\n            query = query.where(PrintArchive.project_id == project_id)\n\n        if date_from:\n            dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)\n            query = query.where(PrintArchive.created_at >= dt_from)\n\n        if date_to:\n            dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)\n            query = query.where(PrintArchive.created_at <= dt_to)\n\n        query = query.limit(limit).offset(offset)\n        result = await self.db.execute(query)\n        return list(result.scalars().all())\n\n    async def delete_archive(self, archive_id: int) -> bool:\n        \"\"\"Delete an archive and its files.\"\"\"\n        archive = await self.get_archive(archive_id)\n        if not archive:\n            return False\n\n        # Resolve the directory to delete BEFORE committing the DB change\n        dir_to_delete: Path | None = None\n\n        if archive.file_path and archive.file_path.strip():\n            file_path = settings.base_dir / archive.file_path\n            if file_path.exists():\n                archive_dir = file_path.parent\n\n                # Safety check 1: archive_dir must be inside archive_dir\n                try:\n                    archive_dir.resolve().relative_to(settings.archive_dir.resolve())\n                except ValueError:\n                    logger.error(\n                        f\"SECURITY: Refusing to delete archive {archive_id} - \"\n                        f\"path {archive_dir} is outside archive directory {settings.archive_dir}\"\n                    )\n                    await self.db.delete(archive)\n                    await self.db.commit()\n                    return True\n\n                # Safety check 2: archive_dir must be at least 1 level deep inside archive_dir\n                try:\n                    relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())\n                    if len(relative_path.parts) < 1:\n                        logger.error(\n                            f\"SECURITY: Refusing to delete archive {archive_id} - \"\n                            f\"path {archive_dir} is not deep enough inside archive directory\"\n                        )\n                        await self.db.delete(archive)\n                        await self.db.commit()\n                        return True\n                except ValueError:\n                    pass  # Already handled above\n\n                dir_to_delete = archive_dir\n        else:\n            logger.error(\n                f\"SECURITY: Refusing to delete files for archive {archive_id} - \"\n                f\"file_path is empty or invalid: '{archive.file_path}'\"\n            )\n\n        # Delete database record FIRST — if the commit fails (e.g. database locked\n        # during concurrent bulk deletes), the files stay on disk and nothing is lost.\n        await self.db.delete(archive)\n        await self.db.commit()\n\n        # Only delete files AFTER the DB commit succeeds to avoid orphaned records\n        if dir_to_delete:\n            shutil.rmtree(dir_to_delete, ignore_errors=True)\n\n        return True\n\n    async def attach_timelapse(\n        self,\n        archive_id: int,\n        timelapse_data: bytes,\n        filename: str = \"timelapse.mp4\",\n    ) -> bool:\n        \"\"\"Attach a timelapse video to an archive.\n\n        Non-MP4 videos (e.g. AVI from P1S) are saved as-is and a background\n        task converts them to MP4 for browser compatibility.\n        \"\"\"\n        import asyncio\n\n        archive = await self.get_archive(archive_id)\n        if not archive:\n            return False\n\n        # Get archive directory\n        file_path = settings.base_dir / archive.file_path\n        archive_dir = file_path.parent\n\n        # Save timelapse - use thread pool to avoid blocking event loop\n        # (timelapse files can be 100MB+, sync write blocks for seconds)\n        timelapse_file = archive_dir / filename\n        await asyncio.to_thread(timelapse_file.write_bytes, timelapse_data)\n\n        # Update archive record\n        archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))\n        await self.db.commit()\n\n        # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion\n        if not filename.lower().endswith(\".mp4\"):\n            asyncio.create_task(\n                _convert_timelapse_to_mp4(archive_id, timelapse_file),\n                name=f\"timelapse-convert-{archive_id}\",\n            )\n\n        return True\n\n\nasync def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:\n    \"\"\"Background task: convert non-MP4 timelapse (e.g. AVI from P1S) to MP4.\n\n    Runs with low CPU priority (-threads 1, nice) so it doesn't starve\n    other processes on resource-constrained devices like Raspberry Pi.\n    \"\"\"\n    import asyncio\n\n    from backend.app.core.database import async_session\n    from backend.app.services.camera import get_ffmpeg_path\n\n    logger = logging.getLogger(__name__)\n\n    ffmpeg = get_ffmpeg_path()\n    if not ffmpeg:\n        logger.info(\n            \"FFmpeg not available, skipping timelapse conversion for archive %s (file saved as %s)\",\n            archive_id,\n            source_path.suffix,\n        )\n        return\n\n    mp4_path = source_path.with_suffix(\".mp4\")\n\n    try:\n        cmd = [\n            ffmpeg,\n            \"-y\",\n            \"-i\",\n            str(source_path),\n            \"-c:v\",\n            \"libx264\",\n            \"-preset\",\n            \"fast\",\n            \"-crf\",\n            \"23\",\n            \"-threads\",\n            \"1\",\n            \"-movflags\",\n            \"+faststart\",\n            str(mp4_path),\n        ]\n\n        # Try with nice for lower CPU priority (standard on Linux/macOS)\n        try:\n            process = await asyncio.create_subprocess_exec(\n                \"nice\",\n                \"-n\",\n                \"19\",\n                *cmd,\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n            )\n        except FileNotFoundError:\n            # nice not available (e.g. Windows), run without\n            process = await asyncio.create_subprocess_exec(\n                *cmd,\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n            )\n\n        _, stderr = await process.communicate()\n\n        if process.returncode != 0:\n            logger.warning(\n                \"Timelapse conversion failed for archive %s: %s\",\n                archive_id,\n                stderr.decode()[-500:],\n            )\n            if mp4_path.exists():\n                mp4_path.unlink()\n            return\n\n        # Update DB path to the new MP4 file\n        async with async_session() as db:\n            from backend.app.models.archive import PrintArchive\n\n            result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n            archive = result.scalar_one_or_none()\n            if archive:\n                archive.timelapse_path = str(mp4_path.relative_to(settings.base_dir))\n                await db.commit()\n\n        # Remove original non-MP4 file\n        if source_path.exists():\n            source_path.unlink()\n\n        logger.info(\n            \"Converted timelapse to MP4 for archive %s (%s → %s)\",\n            archive_id,\n            source_path.name,\n            mp4_path.name,\n        )\n\n    except Exception as e:\n        logger.warning(\"Timelapse conversion error for archive %s: %s\", archive_id, e)\n        if mp4_path.exists():\n            mp4_path.unlink()\n"
  },
  {
    "path": "backend/app/services/archive_comparison.py",
    "content": "from sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.models.archive import PrintArchive\n\n\nclass ArchiveComparisonService:\n    \"\"\"Service for comparing print archives.\"\"\"\n\n    # Fields to compare\n    COMPARABLE_FIELDS = [\n        (\"layer_height\", \"Layer Height\", \"mm\"),\n        (\"nozzle_diameter\", \"Nozzle Diameter\", \"mm\"),\n        (\"bed_temperature\", \"Bed Temperature\", \"°C\"),\n        (\"nozzle_temperature\", \"Nozzle Temperature\", \"°C\"),\n        (\"filament_type\", \"Filament Type\", None),\n        (\"filament_used_grams\", \"Filament Used\", \"g\"),\n        (\"print_time_seconds\", \"Print Time\", \"s\"),\n        (\"total_layers\", \"Total Layers\", None),\n        (\"status\", \"Status\", None),\n    ]\n\n    def __init__(self, db: AsyncSession):\n        self.db = db\n\n    async def compare_archives(self, archive_ids: list[int]) -> dict:\n        \"\"\"Compare multiple archives side by side.\n\n        Args:\n            archive_ids: List of 2-5 archive IDs to compare\n\n        Returns:\n            Dictionary with comparison results\n        \"\"\"\n        if len(archive_ids) < 2:\n            raise ValueError(\"At least 2 archives required for comparison\")\n        if len(archive_ids) > 5:\n            raise ValueError(\"Maximum 5 archives can be compared at once\")\n\n        # Fetch archives\n        result = await self.db.execute(\n            select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(archive_ids))\n        )\n        archives = {a.id: a for a in result.scalars().all()}\n\n        if len(archives) != len(archive_ids):\n            missing = set(archive_ids) - set(archives.keys())\n            raise ValueError(f\"Archives not found: {missing}\")\n\n        # Preserve order from input\n        ordered_archives = [archives[id] for id in archive_ids]\n\n        # Build basic info for each archive\n        archive_info = [\n            {\n                \"id\": a.id,\n                \"print_name\": a.print_name or a.filename,\n                \"status\": a.status,\n                \"created_at\": a.created_at.isoformat() if a.created_at else None,\n                \"printer_id\": a.printer_id,\n                \"project_name\": a.project.name if a.project else None,\n            }\n            for a in ordered_archives\n        ]\n\n        # Build field comparison\n        comparison = []\n        differences = []\n\n        for field_name, display_name, unit in self.COMPARABLE_FIELDS:\n            values = [getattr(a, field_name) for a in ordered_archives]\n\n            # Format values for display\n            formatted_values = []\n            for v in values:\n                if v is None:\n                    formatted_values.append(None)\n                elif field_name == \"print_time_seconds\":\n                    # Format as human-readable time\n                    hours = int(v) // 3600\n                    minutes = (int(v) % 3600) // 60\n                    formatted_values.append(f\"{hours}h {minutes}m\" if hours else f\"{minutes}m\")\n                elif isinstance(v, float):\n                    formatted_values.append(round(v, 2))\n                else:\n                    formatted_values.append(v)\n\n            # Check if values differ\n            non_none_values = [v for v in values if v is not None]\n            has_difference = len({str(v) for v in non_none_values}) > 1 if non_none_values else False\n\n            field_data = {\n                \"field\": field_name,\n                \"label\": display_name,\n                \"unit\": unit,\n                \"values\": formatted_values,\n                \"raw_values\": values,\n                \"has_difference\": has_difference,\n            }\n\n            comparison.append(field_data)\n\n            if has_difference:\n                differences.append(field_data)\n\n        # Analyze success/failure correlation\n        success_correlation = self._analyze_success_correlation(ordered_archives)\n\n        return {\n            \"archives\": archive_info,\n            \"comparison\": comparison,\n            \"differences\": differences,\n            \"success_correlation\": success_correlation,\n        }\n\n    def _analyze_success_correlation(self, archives: list[PrintArchive]) -> dict:\n        \"\"\"Analyze what settings correlate with success/failure.\"\"\"\n        successful = [a for a in archives if a.status == \"completed\"]\n        failed = [a for a in archives if a.status == \"failed\"]\n\n        if not successful or not failed:\n            return {\n                \"has_both_outcomes\": False,\n                \"message\": \"Need both successful and failed prints to analyze correlation\",\n            }\n\n        # Find settings that differ between successful and failed\n        insights = []\n\n        for field_name, display_name, _unit in self.COMPARABLE_FIELDS:\n            if field_name == \"status\":\n                continue\n\n            success_values = [getattr(a, field_name) for a in successful if getattr(a, field_name) is not None]\n            failed_values = [getattr(a, field_name) for a in failed if getattr(a, field_name) is not None]\n\n            if not success_values or not failed_values:\n                continue\n\n            # For numeric fields, compare averages\n            if isinstance(success_values[0], (int, float)):\n                success_avg = sum(success_values) / len(success_values)\n                failed_avg = sum(failed_values) / len(failed_values)\n\n                if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01):\n                    direction = \"higher\" if success_avg > failed_avg else \"lower\"\n                    insights.append(\n                        {\n                            \"field\": field_name,\n                            \"label\": display_name,\n                            \"success_avg\": round(success_avg, 2),\n                            \"failed_avg\": round(failed_avg, 2),\n                            \"insight\": f\"Successful prints had {direction} {display_name}\",\n                        }\n                    )\n            else:\n                # For categorical fields, check if success uses different values\n                success_set = {str(v) for v in success_values}\n                failed_set = {str(v) for v in failed_values}\n\n                if success_set != failed_set:\n                    insights.append(\n                        {\n                            \"field\": field_name,\n                            \"label\": display_name,\n                            \"success_values\": list(success_set),\n                            \"failed_values\": list(failed_set),\n                            \"insight\": f\"Different {display_name} used in successful vs failed prints\",\n                        }\n                    )\n\n        return {\n            \"has_both_outcomes\": True,\n            \"successful_count\": len(successful),\n            \"failed_count\": len(failed),\n            \"insights\": insights,\n        }\n\n    async def find_similar_archives(\n        self,\n        archive_id: int,\n        limit: int = 10,\n    ) -> list[dict]:\n        \"\"\"Find archives with similar settings for comparison.\n\n        Args:\n            archive_id: The archive to find similar ones for\n            limit: Maximum number of results\n\n        Returns:\n            List of similar archives with match reasons\n        \"\"\"\n        # Get the reference archive\n        result = await self.db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n        reference = result.scalar_one_or_none()\n\n        if not reference:\n            raise ValueError(\"Archive not found\")\n\n        # Find similar archives\n        similar = []\n\n        # By same print name\n        if reference.print_name:\n            result = await self.db.execute(\n                select(PrintArchive)\n                .where(\n                    PrintArchive.id != archive_id,\n                    PrintArchive.print_name == reference.print_name,\n                )\n                .order_by(PrintArchive.created_at.desc())\n                .limit(limit)\n            )\n            for a in result.scalars().all():\n                similar.append(\n                    {\n                        \"archive\": {\n                            \"id\": a.id,\n                            \"print_name\": a.print_name or a.filename,\n                            \"status\": a.status,\n                            \"created_at\": a.created_at.isoformat() if a.created_at else None,\n                        },\n                        \"match_reason\": \"Same print name\",\n                        \"match_score\": 100,\n                    }\n                )\n\n        # By content hash\n        if reference.content_hash and len(similar) < limit:\n            result = await self.db.execute(\n                select(PrintArchive)\n                .where(\n                    PrintArchive.id != archive_id,\n                    PrintArchive.content_hash == reference.content_hash,\n                )\n                .order_by(PrintArchive.created_at.desc())\n                .limit(limit - len(similar))\n            )\n            for a in result.scalars().all():\n                if not any(s[\"archive\"][\"id\"] == a.id for s in similar):\n                    similar.append(\n                        {\n                            \"archive\": {\n                                \"id\": a.id,\n                                \"print_name\": a.print_name or a.filename,\n                                \"status\": a.status,\n                                \"created_at\": a.created_at.isoformat() if a.created_at else None,\n                            },\n                            \"match_reason\": \"Same file content\",\n                            \"match_score\": 95,\n                        }\n                    )\n\n        # By same filament type\n        if reference.filament_type and len(similar) < limit:\n            result = await self.db.execute(\n                select(PrintArchive)\n                .where(\n                    PrintArchive.id != archive_id,\n                    PrintArchive.filament_type == reference.filament_type,\n                )\n                .order_by(PrintArchive.created_at.desc())\n                .limit(limit - len(similar))\n            )\n            for a in result.scalars().all():\n                if not any(s[\"archive\"][\"id\"] == a.id for s in similar):\n                    similar.append(\n                        {\n                            \"archive\": {\n                                \"id\": a.id,\n                                \"print_name\": a.print_name or a.filename,\n                                \"status\": a.status,\n                                \"created_at\": a.created_at.isoformat() if a.created_at else None,\n                            },\n                            \"match_reason\": f\"Same filament type ({reference.filament_type})\",\n                            \"match_score\": 50,\n                        }\n                    )\n\n        # Sort by match score\n        similar.sort(key=lambda x: x[\"match_score\"], reverse=True)\n\n        return similar[:limit]\n"
  },
  {
    "path": "backend/app/services/background_dispatch.py",
    "content": "\"\"\"Background dispatch for print/reprint jobs.\n\nThis service is separate from the app's print queue feature. It exists only to\ndecouple \"send/start print\" operations (FTP upload + start command) from API\nrequest latency so the UI can continue immediately after dispatch.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nimport time\nimport zipfile\nfrom collections import deque\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Any, Literal\n\nfrom sqlalchemy import select\n\nfrom backend.app.core.config import settings\nfrom backend.app.core.database import async_session\nfrom backend.app.core.websocket import ws_manager\nfrom backend.app.models.library import LibraryFile\nfrom backend.app.models.printer import Printer\nfrom backend.app.services.archive import ArchiveService\nfrom backend.app.services.bambu_ftp import (\n    delete_file_async,\n    get_ftp_retry_settings,\n    upload_file_async,\n    with_ftp_retry,\n)\nfrom backend.app.services.printer_manager import printer_manager\n\nlogger = logging.getLogger(__name__)\n\n\nclass DispatchJobCancelled(Exception):\n    \"\"\"Raised when a dispatch job is cancelled by the user.\"\"\"\n\n\nclass DispatchEnqueueRejected(Exception):\n    \"\"\"Raised when a dispatch job should not be accepted.\"\"\"\n\n\n@dataclass(slots=True)\nclass PrintDispatchJob:\n    id: int\n    kind: Literal[\"reprint_archive\", \"print_library_file\"]\n    source_id: int\n    source_name: str\n    printer_id: int\n    printer_name: str\n    options: dict[str, Any] = field(default_factory=dict)\n    requested_by_user_id: int | None = None\n    requested_by_username: str | None = None\n    project_id: int | None = None\n    cleanup_library_after_dispatch: bool = False\n\n\n@dataclass(slots=True)\nclass ActiveDispatchState:\n    job: PrintDispatchJob\n    message: str\n    upload_bytes: int | None = None\n    upload_total_bytes: int | None = None\n\n\nclass BackgroundDispatchService:\n    def __init__(self):\n        self._queued_jobs: deque[PrintDispatchJob] = deque()\n        self._dispatcher_task: asyncio.Task | None = None\n        self._running_tasks: dict[int, asyncio.Task] = {}\n        self._lock = asyncio.Lock()\n        self._job_event = asyncio.Event()\n        self._next_job_id = 1\n        self._active_jobs: dict[int, ActiveDispatchState] = {}\n        self._cancel_requested_job_ids: set[int] = set()\n\n        # Progress for the current \"batch\" (since queue became non-empty)\n        self._batch_total = 0\n        self._batch_completed = 0\n        self._batch_failed = 0\n\n    @staticmethod\n    def _printer_is_busy_printing(printer_id: int) -> bool:\n        state = printer_manager.get_status(printer_id)\n        if not state:\n            return False\n        return state.state in (\"RUNNING\", \"PAUSE\", \"PAUSED\") and bool(state.gcode_file)\n\n    async def start(self):\n        async with self._lock:\n            if self._dispatcher_task and not self._dispatcher_task.done():\n                return\n            self._dispatcher_task = asyncio.create_task(self._dispatcher_loop(), name=\"background-dispatch-dispatcher\")\n            logger.info(\"Background dispatch dispatcher started\")\n\n    async def stop(self):\n        dispatcher: asyncio.Task | None = None\n        running_tasks: list[asyncio.Task] = []\n        async with self._lock:\n            dispatcher = self._dispatcher_task\n            self._dispatcher_task = None\n            running_tasks = list(self._running_tasks.values())\n            self._running_tasks.clear()\n            self._active_jobs.clear()\n            self._queued_jobs.clear()\n            self._cancel_requested_job_ids.clear()\n            self._job_event.set()\n\n        if dispatcher:\n            dispatcher.cancel()\n        for task in running_tasks:\n            task.cancel()\n\n        if dispatcher:\n            try:\n                await dispatcher\n            except asyncio.CancelledError:\n                pass\n\n        if running_tasks:\n            await asyncio.gather(*running_tasks, return_exceptions=True)\n\n        logger.info(\"Background dispatch dispatcher stopped\")\n\n    async def dispatch_reprint_archive(\n        self,\n        *,\n        archive_id: int,\n        archive_name: str,\n        printer_id: int,\n        printer_name: str,\n        options: dict[str, Any],\n        requested_by_user_id: int | None,\n        requested_by_username: str | None,\n    ) -> dict[str, Any]:\n        return await self._dispatch(\n            kind=\"reprint_archive\",\n            source_id=archive_id,\n            source_name=archive_name,\n            printer_id=printer_id,\n            printer_name=printer_name,\n            options=options,\n            requested_by_user_id=requested_by_user_id,\n            requested_by_username=requested_by_username,\n        )\n\n    async def get_state(self) -> dict[str, Any]:\n        \"\"\"Get current dispatch queue state snapshot for newly connected clients.\"\"\"\n        async with self._lock:\n            return self._build_state_payload_unlocked()\n\n    async def dispatch_print_library_file(\n        self,\n        *,\n        file_id: int,\n        filename: str,\n        printer_id: int,\n        printer_name: str,\n        options: dict[str, Any],\n        requested_by_user_id: int | None,\n        requested_by_username: str | None,\n        project_id: int | None = None,\n        cleanup_library_after_dispatch: bool = False,\n    ) -> dict[str, Any]:\n        return await self._dispatch(\n            kind=\"print_library_file\",\n            source_id=file_id,\n            source_name=filename,\n            printer_id=printer_id,\n            printer_name=printer_name,\n            options=options,\n            requested_by_user_id=requested_by_user_id,\n            requested_by_username=requested_by_username,\n            project_id=project_id,\n            cleanup_library_after_dispatch=cleanup_library_after_dispatch,\n        )\n\n    async def cancel_job(self, job_id: int) -> dict[str, Any]:\n        \"\"\"Cancel a queued dispatch job.\n\n        Queued jobs are removed immediately. Active jobs are cancelled\n        cooperatively and will stop at the next cancellation checkpoint.\n        \"\"\"\n        async with self._lock:\n            # Check active jobs first\n            active_state = self._active_jobs.get(job_id)\n            if active_state is not None:\n                logger.info(\"Cancel requested for active dispatch job %s\", job_id)\n                self._cancel_requested_job_ids.add(job_id)\n                active_job = active_state.job\n                payload = self._build_state_payload_unlocked(\n                    recent_event={\n                        \"status\": \"cancelling\",\n                        \"job_id\": active_job.id,\n                        \"source_name\": active_job.source_name,\n                        \"printer_id\": active_job.printer_id,\n                        \"printer_name\": active_job.printer_name,\n                        \"message\": \"Cancelling current dispatch...\",\n                    }\n                )\n                result = {\n                    \"cancelled\": True,\n                    \"pending\": True,\n                    \"job_id\": active_job.id,\n                    \"source_name\": active_job.source_name,\n                    \"printer_id\": active_job.printer_id,\n                    \"printer_name\": active_job.printer_name,\n                }\n                await ws_manager.broadcast({\"type\": \"background_dispatch\", \"data\": payload})\n                return result\n\n            # Check queued jobs\n            cancelled_job: PrintDispatchJob | None = None\n            for job in self._queued_jobs:\n                if job.id == job_id:\n                    cancelled_job = job\n                    break\n\n            if not cancelled_job:\n                logger.info(\"Cancel requested for unknown dispatch job %s\", job_id)\n                return {\"cancelled\": False, \"reason\": \"not_found\"}\n\n            self._queued_jobs.remove(cancelled_job)\n            logger.info(\"Cancelled queued dispatch job %s\", cancelled_job.id)\n            self._batch_total = max(0, self._batch_total - 1)\n\n            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:\n                self._batch_completed = 0\n                self._batch_failed = 0\n\n            payload = self._build_state_payload_unlocked(\n                recent_event={\n                    \"status\": \"cancelled\",\n                    \"job_id\": cancelled_job.id,\n                    \"source_name\": cancelled_job.source_name,\n                    \"printer_id\": cancelled_job.printer_id,\n                    \"printer_name\": cancelled_job.printer_name,\n                    \"message\": \"Cancelled from queue\",\n                }\n            )\n\n        await ws_manager.broadcast({\"type\": \"background_dispatch\", \"data\": payload})\n        return {\n            \"cancelled\": True,\n            \"pending\": False,\n            \"job_id\": cancelled_job.id,\n            \"source_name\": cancelled_job.source_name,\n            \"printer_id\": cancelled_job.printer_id,\n            \"printer_name\": cancelled_job.printer_name,\n        }\n\n    async def _dispatch(\n        self,\n        *,\n        kind: Literal[\"reprint_archive\", \"print_library_file\"],\n        source_id: int,\n        source_name: str,\n        printer_id: int,\n        printer_name: str,\n        options: dict[str, Any],\n        requested_by_user_id: int | None,\n        requested_by_username: str | None,\n        project_id: int | None = None,\n        cleanup_library_after_dispatch: bool = False,\n    ) -> dict[str, Any]:\n        async with self._lock:\n            has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)\n            has_active_for_printer = any(active.job.printer_id == printer_id for active in self._active_jobs.values())\n\n            if has_pending_for_printer or has_active_for_printer:\n                raise DispatchEnqueueRejected(f\"Printer {printer_name} already has a background dispatch in progress\")\n\n            if self._printer_is_busy_printing(printer_id):\n                raise DispatchEnqueueRejected(f\"Printer {printer_name} is currently busy printing\")\n\n            dispatch_position = len(self._queued_jobs) + len(self._active_jobs) + 1\n            job = PrintDispatchJob(\n                id=self._next_job_id,\n                kind=kind,\n                source_id=source_id,\n                source_name=source_name,\n                printer_id=printer_id,\n                printer_name=printer_name,\n                options=options,\n                requested_by_user_id=requested_by_user_id,\n                requested_by_username=requested_by_username,\n                project_id=project_id,\n                cleanup_library_after_dispatch=cleanup_library_after_dispatch,\n            )\n            self._next_job_id += 1\n            self._batch_total += 1\n            self._queued_jobs.append(job)\n            self._job_event.set()\n\n            payload = self._build_state_payload_unlocked(\n                recent_event={\n                    \"status\": \"dispatched\",\n                    \"job_id\": job.id,\n                    \"source_name\": source_name,\n                    \"printer_id\": printer_id,\n                    \"printer_name\": printer_name,\n                    \"message\": f\"Dispatched to {printer_name}\",\n                }\n            )\n\n        await ws_manager.broadcast({\"type\": \"background_dispatch\", \"data\": payload})\n\n        return {\n            \"dispatch_job_id\": job.id,\n            \"dispatch_position\": dispatch_position,\n            \"status\": \"dispatched\",\n            \"printer_id\": printer_id,\n            \"source_id\": source_id,\n            \"source_name\": source_name,\n        }\n\n    async def _dispatcher_loop(self):\n        while True:\n            await self._job_event.wait()\n            self._job_event.clear()\n\n            while True:\n                payload: dict[str, Any] | None = None\n                job_to_start: PrintDispatchJob | None = None\n                async with self._lock:\n                    busy_printer_ids = {state.job.printer_id for state in self._active_jobs.values()}\n                    start_index = next(\n                        (\n                            idx\n                            for idx, queued_job in enumerate(self._queued_jobs)\n                            if queued_job.printer_id not in busy_printer_ids\n                        ),\n                        None,\n                    )\n\n                    if start_index is None:\n                        break\n\n                    job_to_start = self._queued_jobs[start_index]\n                    del self._queued_jobs[start_index]\n                    self._active_jobs[job_to_start.id] = ActiveDispatchState(\n                        job=job_to_start,\n                        message=\"Preparing background dispatch...\",\n                    )\n\n                    task = asyncio.create_task(\n                        self._run_active_job(job_to_start), name=f\"background-dispatch-job-{job_to_start.id}\"\n                    )\n                    self._running_tasks[job_to_start.id] = task\n\n                    payload = self._build_state_payload_unlocked(\n                        recent_event={\n                            \"status\": \"processing\",\n                            \"job_id\": job_to_start.id,\n                            \"source_name\": job_to_start.source_name,\n                            \"printer_id\": job_to_start.printer_id,\n                            \"printer_name\": job_to_start.printer_name,\n                            \"message\": \"Preparing background dispatch...\",\n                        }\n                    )\n\n                if payload:\n                    await ws_manager.broadcast({\"type\": \"background_dispatch\", \"data\": payload})\n\n    async def _run_active_job(self, job: PrintDispatchJob):\n        try:\n            await self._process_job(job)\n            await self._mark_job_finished(job, failed=False, message=\"Background dispatch complete\")\n        except DispatchJobCancelled:\n            await self._mark_job_cancelled(job)\n        except asyncio.CancelledError:\n            raise\n        except Exception as e:\n            logger.error(\"Background dispatch job %s failed: %s\", job.id, e, exc_info=True)\n            await self._mark_job_finished(job, failed=True, message=str(e))\n        finally:\n            self._job_event.set()\n\n    async def _set_active_message(self, job: PrintDispatchJob, message: str):\n        async with self._lock:\n            active = self._active_jobs.get(job.id)\n            if not active:\n                return\n            active.message = message\n            payload = self._build_state_payload_unlocked(\n                recent_event={\n                    \"status\": \"processing\",\n                    \"job_id\": active.job.id,\n                    \"source_name\": active.job.source_name,\n                    \"printer_id\": active.job.printer_id,\n                    \"printer_name\": active.job.printer_name,\n                    \"message\": message,\n                }\n            )\n        await ws_manager.broadcast({\"type\": \"background_dispatch\", \"data\": payload})\n\n    async def _set_active_upload_progress(self, job: PrintDispatchJob, uploaded: int, total: int):\n        async with self._lock:\n            active = self._active_jobs.get(job.id)\n            if not active:\n                return\n\n            active.upload_bytes = max(0, int(uploaded))\n            active.upload_total_bytes = max(0, int(total))\n            payload = self._build_state_payload_unlocked(\n                recent_event={\n                    \"status\": \"processing\",\n                    \"job_id\": active.job.id,\n                    \"source_name\": active.job.source_name,\n                    \"printer_id\": active.job.printer_id,\n                    \"printer_name\": active.job.printer_name,\n                    \"message\": active.message,\n                }\n            )\n        await ws_manager.broadcast({\"type\": \"background_dispatch\", \"data\": payload})\n\n    async def _mark_job_finished(self, job: PrintDispatchJob, *, failed: bool, message: str):\n        async with self._lock:\n            if failed:\n                self._batch_failed += 1\n            else:\n                self._batch_completed += 1\n\n            self._active_jobs.pop(job.id, None)\n            self._running_tasks.pop(job.id, None)\n            self._cancel_requested_job_ids.discard(job.id)\n\n            payload = self._build_state_payload_unlocked(\n                recent_event={\n                    \"status\": \"failed\" if failed else \"completed\",\n                    \"job_id\": job.id,\n                    \"source_name\": job.source_name,\n                    \"printer_id\": job.printer_id,\n                    \"printer_name\": job.printer_name,\n                    \"message\": message,\n                }\n            )\n            should_reset_batch = len(self._queued_jobs) == 0 and len(self._active_jobs) == 0\n\n        await ws_manager.broadcast({\"type\": \"background_dispatch\", \"data\": payload})\n\n        if should_reset_batch:\n            async with self._lock:\n                if len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:\n                    self._batch_total = 0\n                    self._batch_completed = 0\n                    self._batch_failed = 0\n\n    async def _mark_job_cancelled(self, job: PrintDispatchJob):\n        async with self._lock:\n            self._active_jobs.pop(job.id, None)\n            self._running_tasks.pop(job.id, None)\n            self._cancel_requested_job_ids.discard(job.id)\n            self._batch_total = max(0, self._batch_total - 1)\n\n            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:\n                self._batch_completed = 0\n                self._batch_failed = 0\n\n            payload = self._build_state_payload_unlocked(\n                recent_event={\n                    \"status\": \"cancelled\",\n                    \"job_id\": job.id,\n                    \"source_name\": job.source_name,\n                    \"printer_id\": job.printer_id,\n                    \"printer_name\": job.printer_name,\n                    \"message\": \"Cancelled during dispatch\",\n                }\n            )\n\n        await ws_manager.broadcast({\"type\": \"background_dispatch\", \"data\": payload})\n\n    def _is_cancel_requested(self, job_id: int) -> bool:\n        return job_id in self._cancel_requested_job_ids\n\n    def _raise_if_cancel_requested(self, job: PrintDispatchJob):\n        if self._is_cancel_requested(job.id):\n            raise DispatchJobCancelled(f\"Dispatch job {job.id} cancelled\")\n\n    def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:\n        processing = len(self._active_jobs)\n        dispatched = len(self._queued_jobs)\n\n        dispatched_jobs = [\n            {\n                \"job_id\": job.id,\n                \"kind\": job.kind,\n                \"source_id\": job.source_id,\n                \"source_name\": job.source_name,\n                \"printer_id\": job.printer_id,\n                \"printer_name\": job.printer_name,\n            }\n            for job in list(self._queued_jobs)\n        ]\n\n        active_jobs: list[dict[str, Any]] = []\n        for active in self._active_jobs.values():\n            upload_progress_pct = None\n            if active.upload_total_bytes and active.upload_total_bytes > 0 and active.upload_bytes is not None:\n                upload_progress_pct = round(\n                    max(0.0, min(100.0, (active.upload_bytes / active.upload_total_bytes) * 100.0)), 1\n                )\n\n            active_jobs.append(\n                {\n                    \"job_id\": active.job.id,\n                    \"kind\": active.job.kind,\n                    \"source_id\": active.job.source_id,\n                    \"source_name\": active.job.source_name,\n                    \"printer_id\": active.job.printer_id,\n                    \"printer_name\": active.job.printer_name,\n                    \"message\": active.message,\n                    \"upload_bytes\": active.upload_bytes,\n                    \"upload_total_bytes\": active.upload_total_bytes,\n                    \"upload_progress_pct\": upload_progress_pct,\n                }\n            )\n\n        active_jobs.sort(key=lambda item: int(item[\"job_id\"]))\n        active_job = active_jobs[0] if active_jobs else None\n\n        return {\n            \"total\": self._batch_total,\n            \"dispatched\": dispatched,\n            \"processing\": processing,\n            \"completed\": self._batch_completed,\n            \"failed\": self._batch_failed,\n            \"dispatched_jobs\": dispatched_jobs,\n            \"active_jobs\": active_jobs,\n            \"active_job\": active_job,\n            \"recent_event\": recent_event,\n        }\n\n    async def _process_job(self, job: PrintDispatchJob):\n        if job.kind == \"reprint_archive\":\n            await self._run_reprint_archive(job)\n            return\n        if job.kind == \"print_library_file\":\n            await self._run_print_library_file(job)\n            return\n        raise RuntimeError(f\"Unknown dispatch job kind: {job.kind}\")\n\n    async def _run_reprint_archive(self, job: PrintDispatchJob):\n        from backend.app.main import register_expected_print\n\n        async with async_session() as db:\n            service = ArchiveService(db)\n            archive = await service.get_archive(job.source_id)\n            if not archive:\n                raise RuntimeError(\"Archive not found\")\n\n            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))\n            if not printer:\n                raise RuntimeError(\"Printer not found\")\n\n            printer_name = printer.name\n            printer_ip = printer.ip_address\n            printer_access_code = printer.access_code\n            printer_model = printer.model\n            archive_filename = archive.filename\n\n            if not printer_manager.is_connected(job.printer_id):\n                raise RuntimeError(\"Printer is not connected\")\n\n            file_path = settings.base_dir / archive.file_path\n            if not file_path.exists():\n                raise RuntimeError(\"Archive file not found\")\n\n            base_name = archive.filename\n            if base_name.endswith(\".gcode.3mf\"):\n                base_name = base_name[:-10]\n            elif base_name.endswith(\".3mf\"):\n                base_name = base_name[:-4]\n            remote_filename = f\"{base_name}.3mf\"\n            # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it\n            remote_filename = remote_filename.replace(\" \", \"_\")\n            remote_path = f\"/{remote_filename}\"\n\n            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()\n            self._raise_if_cancel_requested(job)\n\n            await self._set_active_message(job, f\"Preparing upload to {printer_name}...\")\n            await delete_file_async(\n                printer_ip,\n                printer_access_code,\n                remote_path,\n                socket_timeout=ftp_timeout,\n                printer_model=printer_model,\n            )\n\n            self._raise_if_cancel_requested(job)\n\n            try:\n                await self._set_active_message(job, f\"Uploading {archive_filename} to {printer_name}...\")\n                loop = asyncio.get_running_loop()\n                progress_state = {\"last_emit\": 0.0, \"last_bytes\": 0}\n\n                def upload_progress_callback(uploaded: int, total: int):\n                    if self._is_cancel_requested(job.id):\n                        raise DispatchJobCancelled(f\"Dispatch job {job.id} cancelled during upload\")\n\n                    now = time.monotonic()\n                    should_emit = (\n                        uploaded >= total\n                        or now - progress_state[\"last_emit\"] >= 0.2\n                        or uploaded - progress_state[\"last_bytes\"] >= 256 * 1024\n                    )\n\n                    if should_emit:\n                        progress_state[\"last_emit\"] = now\n                        progress_state[\"last_bytes\"] = uploaded\n                        loop.call_soon_threadsafe(\n                            lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))\n                        )\n\n                if ftp_retry_enabled:\n                    uploaded = await with_ftp_retry(\n                        upload_file_async,\n                        printer_ip,\n                        printer_access_code,\n                        file_path,\n                        remote_path,\n                        progress_callback=upload_progress_callback,\n                        socket_timeout=ftp_timeout,\n                        printer_model=printer_model,\n                        max_retries=ftp_retry_count,\n                        retry_delay=ftp_retry_delay,\n                        operation_name=f\"Upload for reprint to {printer_name}\",\n                        non_retry_exceptions=(DispatchJobCancelled,),\n                    )\n                else:\n                    uploaded = await upload_file_async(\n                        printer_ip,\n                        printer_access_code,\n                        file_path,\n                        remote_path,\n                        progress_callback=upload_progress_callback,\n                        socket_timeout=ftp_timeout,\n                        printer_model=printer_model,\n                    )\n\n                if uploaded:\n                    await self._set_active_upload_progress(job, 1, 1)\n\n                if not uploaded:\n                    raise RuntimeError(\n                        \"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT).\"\n                    )\n\n                register_expected_print(\n                    job.printer_id,\n                    remote_filename,\n                    job.source_id,\n                    ams_mapping=job.options.get(\"ams_mapping\"),\n                )\n\n                plate_id = self._resolve_plate_id(file_path, job.options.get(\"plate_id\"))\n\n                self._raise_if_cancel_requested(job)\n\n                await self._set_active_message(job, f\"Starting print on {printer_name}...\")\n                started = printer_manager.start_print(\n                    job.printer_id,\n                    remote_filename,\n                    plate_id,\n                    ams_mapping=job.options.get(\"ams_mapping\"),\n                    timelapse=job.options.get(\"timelapse\", False),\n                    bed_levelling=job.options.get(\"bed_levelling\", True),\n                    flow_cali=job.options.get(\"flow_cali\", False),\n                    vibration_cali=job.options.get(\"vibration_cali\", False),\n                    layer_inspect=job.options.get(\"layer_inspect\", False),\n                    use_ams=job.options.get(\"use_ams\", True),\n                )\n\n                if not started:\n                    await self._cleanup_sd_card_file(\n                        printer_ip,\n                        printer_access_code,\n                        remote_path,\n                        printer_model,\n                    )\n                    raise RuntimeError(\"Failed to start print\")\n\n                pre_state = getattr(printer_manager.get_status(job.printer_id), \"state\", None)\n                if pre_state:\n                    asyncio.create_task(self._verify_print_response(job.printer_id, printer_name, pre_state))\n\n                if job.requested_by_user_id and job.requested_by_username:\n                    printer_manager.set_current_print_user(\n                        job.printer_id,\n                        job.requested_by_user_id,\n                        job.requested_by_username,\n                    )\n            except DispatchJobCancelled:\n                await self._set_active_message(job, f\"Cancelled upload on {printer_name}.\")\n                raise\n\n    async def _run_print_library_file(self, job: PrintDispatchJob):\n        from backend.app.main import register_expected_print\n\n        async with async_session() as db:\n            lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == job.source_id))\n            if not lib_file:\n                raise RuntimeError(\"File not found\")\n\n            if not self._is_sliced_file(lib_file.filename):\n                raise RuntimeError(\"Not a sliced file. Only .gcode or .gcode.3mf files can be printed.\")\n\n            file_path = Path(settings.base_dir) / lib_file.file_path\n            if not file_path.exists():\n                raise RuntimeError(\"File not found on disk\")\n\n            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))\n            if not printer:\n                raise RuntimeError(\"Printer not found\")\n\n            printer_name = printer.name\n            printer_ip = printer.ip_address\n            printer_access_code = printer.access_code\n            printer_model = printer.model\n            library_filename = lib_file.filename\n\n            if not printer_manager.is_connected(job.printer_id):\n                raise RuntimeError(\"Printer is not connected\")\n\n            await self._set_active_message(job, f\"Creating archive for {lib_file.filename}...\")\n            archive_service = ArchiveService(db)\n            archive = await archive_service.archive_print(\n                printer_id=job.printer_id,\n                source_file=file_path,\n                original_filename=lib_file.filename,\n                project_id=job.project_id,\n                created_by_id=job.requested_by_user_id,\n            )\n            if not archive:\n                raise RuntimeError(\"Failed to create archive\")\n\n            await db.flush()\n\n            base_name = lib_file.filename\n            if base_name.endswith(\".gcode.3mf\"):\n                base_name = base_name[:-10]\n            elif base_name.endswith(\".3mf\"):\n                base_name = base_name[:-4]\n            remote_filename = f\"{base_name}.3mf\"\n            # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it\n            remote_filename = remote_filename.replace(\" \", \"_\")\n            remote_path = f\"/{remote_filename}\"\n\n            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()\n            self._raise_if_cancel_requested(job)\n\n            await self._set_active_message(job, f\"Preparing upload to {printer_name}...\")\n            await delete_file_async(\n                printer_ip,\n                printer_access_code,\n                remote_path,\n                socket_timeout=ftp_timeout,\n                printer_model=printer_model,\n            )\n\n            self._raise_if_cancel_requested(job)\n\n            try:\n                await self._set_active_message(job, f\"Uploading {library_filename} to {printer_name}...\")\n                loop = asyncio.get_running_loop()\n                progress_state = {\"last_emit\": 0.0, \"last_bytes\": 0}\n\n                def upload_progress_callback(uploaded: int, total: int):\n                    if self._is_cancel_requested(job.id):\n                        raise DispatchJobCancelled(f\"Dispatch job {job.id} cancelled during upload\")\n\n                    now = time.monotonic()\n                    should_emit = (\n                        uploaded >= total\n                        or now - progress_state[\"last_emit\"] >= 0.2\n                        or uploaded - progress_state[\"last_bytes\"] >= 256 * 1024\n                    )\n\n                    if should_emit:\n                        progress_state[\"last_emit\"] = now\n                        progress_state[\"last_bytes\"] = uploaded\n                        loop.call_soon_threadsafe(\n                            lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))\n                        )\n\n                if ftp_retry_enabled:\n                    uploaded = await with_ftp_retry(\n                        upload_file_async,\n                        printer_ip,\n                        printer_access_code,\n                        file_path,\n                        remote_path,\n                        progress_callback=upload_progress_callback,\n                        socket_timeout=ftp_timeout,\n                        printer_model=printer_model,\n                        max_retries=ftp_retry_count,\n                        retry_delay=ftp_retry_delay,\n                        operation_name=f\"Upload for print to {printer_name}\",\n                        non_retry_exceptions=(DispatchJobCancelled,),\n                    )\n                else:\n                    uploaded = await upload_file_async(\n                        printer_ip,\n                        printer_access_code,\n                        file_path,\n                        remote_path,\n                        progress_callback=upload_progress_callback,\n                        socket_timeout=ftp_timeout,\n                        printer_model=printer_model,\n                    )\n\n                if uploaded:\n                    await self._set_active_upload_progress(job, 1, 1)\n\n                if not uploaded:\n                    await db.rollback()\n                    raise RuntimeError(\n                        \"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT).\"\n                    )\n\n                register_expected_print(\n                    job.printer_id,\n                    remote_filename,\n                    archive.id,\n                    ams_mapping=job.options.get(\"ams_mapping\"),\n                )\n\n                plate_id = self._resolve_plate_id(file_path, job.options.get(\"plate_id\"))\n\n                self._raise_if_cancel_requested(job)\n\n                await self._set_active_message(job, f\"Starting print on {printer_name}...\")\n                started = printer_manager.start_print(\n                    job.printer_id,\n                    remote_filename,\n                    plate_id,\n                    ams_mapping=job.options.get(\"ams_mapping\"),\n                    timelapse=job.options.get(\"timelapse\", False),\n                    bed_levelling=job.options.get(\"bed_levelling\", True),\n                    flow_cali=job.options.get(\"flow_cali\", False),\n                    vibration_cali=job.options.get(\"vibration_cali\", False),\n                    layer_inspect=job.options.get(\"layer_inspect\", False),\n                    use_ams=job.options.get(\"use_ams\", True),\n                )\n\n                if not started:\n                    await self._cleanup_sd_card_file(\n                        printer_ip,\n                        printer_access_code,\n                        remote_path,\n                        printer_model,\n                    )\n                    await db.rollback()\n                    raise RuntimeError(\"Failed to start print\")\n\n                pre_state = getattr(printer_manager.get_status(job.printer_id), \"state\", None)\n                if pre_state:\n                    asyncio.create_task(self._verify_print_response(job.printer_id, printer_name, pre_state))\n\n                if job.requested_by_user_id and job.requested_by_username:\n                    printer_manager.set_current_print_user(\n                        job.printer_id,\n                        job.requested_by_user_id,\n                        job.requested_by_username,\n                    )\n\n                # Direct-Print flow only: archive_print copies, so deleting the\n                # transient library row + files here leaves archive intact. Disk\n                # deletes run only after commit so a rollback leaves no orphan.\n                cleanup_disk_paths: list[Path] = []\n                if job.cleanup_library_after_dispatch and not lib_file.is_external:\n                    cleanup_disk_paths.append(file_path)\n                    if lib_file.thumbnail_path:\n                        thumb_path = Path(lib_file.thumbnail_path)\n                        if not thumb_path.is_absolute():\n                            thumb_path = Path(settings.base_dir) / lib_file.thumbnail_path\n                        cleanup_disk_paths.append(thumb_path)\n                    await db.delete(lib_file)\n\n                await db.commit()\n\n                for cleanup_path in cleanup_disk_paths:\n                    try:\n                        if cleanup_path.exists():\n                            cleanup_path.unlink()\n                    except OSError as cleanup_err:\n                        logger.warning(\"Failed to delete transient library file %s: %s\", cleanup_path, cleanup_err)\n            except DispatchJobCancelled:\n                await db.rollback()\n                await self._set_active_message(job, f\"Cancelled upload on {printer_name}.\")\n                raise\n\n    @staticmethod\n    async def _verify_print_response(\n        printer_id: int,\n        printer_name: str,\n        pre_state: str,\n        timeout: float = 15.0,\n        poll_interval: float = 3.0,\n    ):\n        \"\"\"Check if the printer responded to a print command.\n\n        Runs as a fire-and-forget background task after start_print() succeeds.\n        If the printer's gcode_state hasn't changed within the timeout, logs a\n        warning for diagnostics (visible in support packages).\n        \"\"\"\n        deadline = time.monotonic() + timeout\n        while time.monotonic() < deadline:\n            await asyncio.sleep(poll_interval)\n            state = printer_manager.get_status(printer_id)\n            if not state:\n                return  # Printer disconnected\n            if state.state != pre_state:\n                return  # Printer responded\n        logger.warning(\n            \"Printer %s (%d) did not respond to print command within %.0fs (state still %s) — printer may need restart\",\n            printer_name,\n            printer_id,\n            timeout,\n            pre_state,\n        )\n        # Strong signal the MQTT session is half-broken (#887, #936): telemetry\n        # still arrives but our publishes don't reach the printer. Force a fresh\n        # session so the next dispatch can land without a power cycle.\n        client = printer_manager.get_client(printer_id)\n        if client:\n            client.force_reconnect_stale_session(\n                f\"print command unacknowledged after {timeout:.0f}s (state still {pre_state})\"\n            )\n\n    @staticmethod\n    async def _cleanup_sd_card_file(\n        printer_ip: str,\n        access_code: str,\n        remote_path: str,\n        printer_model: str | None,\n    ):\n        \"\"\"Best-effort delete of uploaded file from printer SD card.\"\"\"\n        try:\n            await delete_file_async(printer_ip, access_code, remote_path, printer_model=printer_model)\n        except Exception:\n            pass  # Best-effort — don't fail the error handler\n\n    @staticmethod\n    def _resolve_plate_id(file_path: Path, requested_plate_id: int | None) -> int:\n        if requested_plate_id is not None:\n            return requested_plate_id\n\n        plate_id = 1\n        try:\n            with zipfile.ZipFile(file_path, \"r\") as zf:\n                for name in zf.namelist():\n                    if name.startswith(\"Metadata/plate_\") and name.endswith(\".gcode\"):\n                        plate_str = name[15:-6]\n                        plate_id = int(plate_str)\n                        break\n        except (ValueError, zipfile.BadZipFile, OSError):\n            pass\n        return plate_id\n\n    @staticmethod\n    def _is_sliced_file(filename: str) -> bool:\n        lower = filename.lower()\n        return lower.endswith(\".gcode\") or lower.endswith(\".gcode.3mf\")\n\n\nbackground_dispatch = BackgroundDispatchService()\n"
  },
  {
    "path": "backend/app/services/bambu_cloud.py",
    "content": "\"\"\"\nBambu Lab Cloud API Service\n\nHandles authentication and profile management with Bambu Lab's cloud services.\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timedelta, timezone\n\nimport httpx\n\nlogger = logging.getLogger(__name__)\n\nBAMBU_API_BASE = \"https://api.bambulab.com\"\nBAMBU_API_BASE_CN = \"https://api.bambulab.cn\"\n\n\nclass BambuCloudError(Exception):\n    \"\"\"Base exception for Bambu Cloud errors.\"\"\"\n\n    pass\n\n\nclass BambuCloudAuthError(BambuCloudError):\n    \"\"\"Authentication related errors.\"\"\"\n\n    pass\n\n\n_shared_http_client: httpx.AsyncClient | None = None\n\n\ndef set_shared_http_client(client: httpx.AsyncClient | None) -> None:\n    \"\"\"Register an app-scoped ``httpx.AsyncClient`` so per-request\n    ``BambuCloudService`` instances can reuse its connection pool.\n\n    Pass ``None`` during shutdown to unregister. The service only holds a\n    reference (never closes a client it does not own), so region + token\n    state still stays per-request — this only shares the transport pool.\n    \"\"\"\n    global _shared_http_client\n    _shared_http_client = client\n\n\nclass BambuCloudService:\n    \"\"\"Service for interacting with Bambu Lab Cloud API.\"\"\"\n\n    def __init__(self, region: str = \"global\", client: httpx.AsyncClient | None = None):\n        self.base_url = BAMBU_API_BASE if region == \"global\" else BAMBU_API_BASE_CN\n        self.access_token: str | None = None\n        self.refresh_token: str | None = None\n        self.token_expiry: datetime | None = None\n        # Prefer an explicitly-injected client (tests), else fall back to the\n        # app-scoped shared client (production), and finally create our own so\n        # scripts / tests that skip the lifespan still get a working service.\n        if client is not None:\n            self._client = client\n            self._owns_client = False\n        elif _shared_http_client is not None:\n            self._client = _shared_http_client\n            self._owns_client = False\n        else:\n            self._client = httpx.AsyncClient(timeout=30.0)\n            self._owns_client = True\n\n    @property\n    def is_authenticated(self) -> bool:\n        \"\"\"Check if we have a valid token.\"\"\"\n        if not self.access_token:\n            return False\n        return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)\n\n    def _get_headers(self) -> dict:\n        \"\"\"Get headers for authenticated requests.\"\"\"\n        headers = {\n            \"Content-Type\": \"application/json\",\n            \"User-Agent\": \"Bambuddy/1.0\",\n        }\n        if self.access_token:\n            headers[\"Authorization\"] = f\"Bearer {self.access_token}\"\n        return headers\n\n    async def login_request(self, email: str, password: str) -> dict:\n        \"\"\"\n        Initiate login - this will trigger either email verification or TOTP prompt.\n\n        Returns dict with login status, verification type, and tfaKey if needed.\n        \"\"\"\n        try:\n            response = await self._client.post(\n                f\"{self.base_url}/v1/user-service/user/login\",\n                headers={\"Content-Type\": \"application/json\"},\n                json={\n                    \"account\": email,\n                    \"password\": password,\n                },\n            )\n\n            data = response.json()\n            logger.debug(\n                f\"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}\"\n            )\n\n            if response.status_code == 200:\n                login_type = data.get(\"loginType\")\n                tfa_key = data.get(\"tfaKey\")\n\n                # TOTP authentication required\n                if login_type == \"tfa\" or (tfa_key and login_type != \"verifyCode\"):\n                    return {\n                        \"success\": False,\n                        \"needs_verification\": True,\n                        \"verification_type\": \"totp\",\n                        \"tfa_key\": tfa_key,\n                        \"message\": \"Enter the code from your authenticator app\",\n                    }\n\n                # Email verification required\n                if login_type == \"verifyCode\":\n                    return {\n                        \"success\": False,\n                        \"needs_verification\": True,\n                        \"verification_type\": \"email\",\n                        \"tfa_key\": None,\n                        \"message\": \"Verification code sent to email\",\n                    }\n\n                # Direct login success (rare, usually needs 2FA)\n                if \"accessToken\" in data:\n                    self._set_tokens(data)\n                    return {\"success\": True, \"needs_verification\": False, \"message\": \"Login successful\"}\n\n            # Handle specific error codes\n            error_msg = data.get(\"message\") or data.get(\"error\") or \"Login failed\"\n            return {\"success\": False, \"needs_verification\": False, \"message\": error_msg}\n\n        except Exception as e:\n            logger.error(\"Login request failed: %s\", e)\n            raise BambuCloudAuthError(f\"Login request failed: {e}\")\n\n    async def verify_code(self, email: str, code: str) -> dict:\n        \"\"\"\n        Complete login with email verification code.\n        \"\"\"\n        try:\n            response = await self._client.post(\n                f\"{self.base_url}/v1/user-service/user/login\",\n                headers={\"Content-Type\": \"application/json\"},\n                json={\n                    \"account\": email,\n                    \"code\": code,\n                },\n            )\n\n            data = response.json()\n            logger.debug(\"Email verify response: status=%s, hasToken=%s\", response.status_code, \"accessToken\" in data)\n\n            if response.status_code == 200 and \"accessToken\" in data:\n                self._set_tokens(data)\n                return {\"success\": True, \"message\": \"Login successful\"}\n\n            return {\"success\": False, \"message\": data.get(\"message\", \"Verification failed\")}\n\n        except Exception as e:\n            logger.error(\"Email verification failed: %s\", e)\n            raise BambuCloudAuthError(f\"Verification failed: {e}\")\n\n    async def verify_totp(self, tfa_key: str, code: str) -> dict:\n        \"\"\"\n        Complete login with TOTP code from authenticator app.\n\n        Args:\n            tfa_key: The tfaKey returned from initial login request\n            code: 6-digit TOTP code from authenticator app\n        \"\"\"\n        try:\n            # TFA endpoint is on bambulab.com, NOT api.bambulab.com\n            # Requires browser-like headers to bypass Cloudflare\n            tfa_url = \"https://bambulab.com/api/sign-in/tfa\"\n            if \"bambulab.cn\" in self.base_url:\n                tfa_url = \"https://bambulab.cn/api/sign-in/tfa\"\n\n            browser_headers = {\n                \"Content-Type\": \"application/json\",\n                \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n                \"Accept\": \"application/json, text/plain, */*\",\n                \"Accept-Language\": \"en-US,en;q=0.9\",\n                \"Origin\": \"https://bambulab.com\",\n                \"Referer\": \"https://bambulab.com/\",\n            }\n\n            response = await self._client.post(\n                tfa_url,\n                headers=browser_headers,\n                json={\n                    \"tfaKey\": tfa_key,\n                    \"tfaCode\": code,\n                },\n            )\n\n            logger.debug(\n                f\"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}\"\n            )\n\n            # Handle empty response\n            if not response.text or not response.text.strip():\n                logger.warning(\"TOTP verification returned empty response (status %s)\", response.status_code)\n                return {\"success\": False, \"message\": \"Bambu Cloud returned empty response. Please try again.\"}\n\n            try:\n                data = response.json()\n            except Exception as json_err:\n                logger.error(\"Failed to parse TOTP response: %s, body: %s\", json_err, response.text[:500])\n                return {\"success\": False, \"message\": \"Invalid response from Bambu Cloud\"}\n\n            # Token might be in accessToken, token field, or cookies\n            access_token = data.get(\"accessToken\") or data.get(\"token\")\n\n            # Also check cookies for token\n            if not access_token:\n                for cookie in response.cookies:\n                    if \"token\" in cookie.lower():\n                        access_token = response.cookies.get(cookie)\n                        break\n\n            if response.status_code == 200 and access_token:\n                self.access_token = access_token\n                self.refresh_token = data.get(\"refreshToken\")\n                from datetime import datetime, timedelta, timezone\n\n                self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)\n                return {\"success\": True, \"message\": \"Login successful\"}\n\n            # Provide helpful error message\n            error_msg = data.get(\"message\", \"\")\n            if \"expired\" in error_msg.lower():\n                return {\"success\": False, \"message\": \"TOTP session expired. Please try logging in again.\"}\n            if not error_msg:\n                error_msg = f\"TOTP verification failed (status {response.status_code})\"\n\n            return {\"success\": False, \"message\": error_msg}\n\n        except Exception as e:\n            logger.error(\"TOTP verification failed: %s\", e)\n            # Return error instead of raising - don't trigger 401/500\n            return {\"success\": False, \"message\": f\"TOTP verification error: {e}\"}\n\n    def _set_tokens(self, data: dict):\n        \"\"\"Set tokens from login response.\"\"\"\n        self.access_token = data.get(\"accessToken\")\n        self.refresh_token = data.get(\"refreshToken\")\n        # Token typically valid for ~3 months, but we'll refresh more often\n        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)\n\n    def set_token(self, access_token: str):\n        \"\"\"Set access token directly (for stored tokens).\"\"\"\n        self.access_token = access_token\n        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)\n\n    def logout(self):\n        \"\"\"Clear authentication state.\"\"\"\n        self.access_token = None\n        self.refresh_token = None\n        self.token_expiry = None\n\n    async def get_user_profile(self) -> dict:\n        \"\"\"Get user profile information.\"\"\"\n        if not self.is_authenticated:\n            raise BambuCloudAuthError(\"Not authenticated\")\n\n        try:\n            response = await self._client.get(\n                f\"{self.base_url}/v1/design-user-service/my/preference\", headers=self._get_headers()\n            )\n\n            if response.status_code == 200:\n                return response.json()\n\n            raise BambuCloudError(f\"Failed to get profile: {response.status_code}\")\n\n        except httpx.RequestError as e:\n            raise BambuCloudError(f\"Request failed: {e}\")\n\n    async def get_slicer_settings(self, version: str = \"02.04.00.70\") -> dict:\n        \"\"\"\n        Get all slicer settings (filament, printer, process presets).\n\n        Args:\n            version: Slicer version string\n        \"\"\"\n        if not self.is_authenticated:\n            raise BambuCloudAuthError(\"Not authenticated\")\n\n        try:\n            response = await self._client.get(\n                f\"{self.base_url}/v1/iot-service/api/slicer/setting\",\n                headers=self._get_headers(),\n                params={\"version\": version},\n            )\n\n            data = response.json()\n\n            if response.status_code == 200:\n                return data\n\n            raise BambuCloudError(f\"Failed to get settings: {response.status_code}\")\n\n        except httpx.RequestError as e:\n            raise BambuCloudError(f\"Request failed: {e}\")\n\n    async def get_setting_detail(self, setting_id: str) -> dict:\n        \"\"\"Get detailed information for a specific setting/preset.\"\"\"\n        if not self.is_authenticated:\n            raise BambuCloudAuthError(\"Not authenticated\")\n\n        try:\n            response = await self._client.get(\n                f\"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}\", headers=self._get_headers()\n            )\n\n            if response.status_code == 200:\n                return response.json()\n\n            raise BambuCloudError(f\"Failed to get setting detail: {response.status_code}\")\n\n        except httpx.RequestError as e:\n            raise BambuCloudError(f\"Request failed: {e}\")\n\n    async def create_setting(\n        self, preset_type: str, name: str, base_id: str, setting: dict, version: str = \"2.0.0.0\"\n    ) -> dict:\n        \"\"\"\n        Create a new slicer preset/setting.\n\n        Args:\n            preset_type: Type of preset - \"filament\", \"print\", or \"printer\"\n            name: Display name for the preset\n            base_id: Base preset ID to inherit from (e.g., \"GFSA00\")\n            setting: Dict of setting key-value pairs (only modified values from base)\n            version: Version string for the preset (default: \"2.0.0.0\")\n\n        Returns:\n            Created preset data including the new setting_id\n        \"\"\"\n        if not self.is_authenticated:\n            raise BambuCloudAuthError(\"Not authenticated\")\n\n        try:\n            # Add timestamp if not present\n            import time\n\n            if \"updated_time\" not in setting:\n                setting[\"updated_time\"] = str(int(time.time()))\n\n            payload = {\n                \"type\": preset_type,\n                \"name\": name,\n                \"version\": version,\n                \"base_id\": base_id,\n                \"setting\": setting,\n            }\n\n            response = await self._client.post(\n                f\"{self.base_url}/v1/iot-service/api/slicer/setting\", headers=self._get_headers(), json=payload\n            )\n\n            data = response.json()\n\n            if response.status_code in (200, 201):\n                return data\n\n            error_msg = data.get(\"message\") or data.get(\"error\") or f\"HTTP {response.status_code}\"\n            raise BambuCloudError(f\"Failed to create setting: {error_msg}\")\n\n        except httpx.RequestError as e:\n            raise BambuCloudError(f\"Request failed: {e}\")\n\n    async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:\n        \"\"\"\n        Update an existing slicer preset/setting.\n\n        Note: Bambu Cloud API doesn't support true updates. Instead, we:\n        1. Fetch the current setting metadata (type, base_id, version)\n        2. Use the provided settings as the new complete settings (NOT merged)\n        3. Delete the old setting first (to avoid name conflicts)\n        4. Create a new setting via POST\n\n        Args:\n            setting_id: ID of the preset to update\n            name: New display name (optional)\n            setting: Dict of setting key-value pairs - this REPLACES the old settings entirely\n\n        Returns:\n            Updated preset data with new setting_id\n        \"\"\"\n        if not self.is_authenticated:\n            raise BambuCloudAuthError(\"Not authenticated\")\n\n        try:\n            # Fetch current setting to get metadata (type, base_id, version)\n            current = await self.get_setting_detail(setting_id)\n            preset_type = current.get(\"type\", \"filament\")\n\n            # Use provided settings directly (complete replacement, not merge)\n            # This allows the frontend to edit the full settings JSON\n            if setting is not None:\n                updated_setting = setting.copy()\n            else:\n                updated_setting = current.get(\"setting\", {}).copy()\n\n            # Extract name from settings_id field in the JSON, or use provided name, or fall back to current\n            # The settings_id field contains the name in quotes, e.g., '\"My Preset Name\"'\n            settings_id_key = {\n                \"filament\": \"filament_settings_id\",\n                \"print\": \"print_settings_id\",\n                \"printer\": \"printer_settings_id\",\n            }.get(preset_type, \"filament_settings_id\")\n\n            settings_id_value = updated_setting.get(settings_id_key, \"\")\n            if settings_id_value:\n                # Remove surrounding quotes if present (e.g., '\"foo\"' -> 'foo')\n                updated_name = settings_id_value.strip('\"')\n            elif name is not None:\n                updated_name = name\n            else:\n                updated_name = current.get(\"name\", \"Untitled\")\n\n            # Update the timestamp\n            import time\n\n            updated_setting[\"updated_time\"] = str(int(time.time()))\n\n            # Ensure settings_id field matches the name\n            updated_setting[settings_id_key] = f'\"{updated_name}\"'\n\n            # Delete the old setting FIRST to avoid name conflicts\n            await self.delete_setting(setting_id)\n\n            # Create new setting via POST\n            payload = {\n                \"type\": preset_type,\n                \"name\": updated_name,\n                \"version\": current.get(\"version\", \"2.0.0.0\"),\n                \"base_id\": current.get(\"base_id\", \"\"),\n                \"setting\": updated_setting,\n            }\n\n            response = await self._client.post(\n                f\"{self.base_url}/v1/iot-service/api/slicer/setting\", headers=self._get_headers(), json=payload\n            )\n\n            data = response.json()\n\n            if response.status_code == 200:\n                return data\n\n            error_msg = data.get(\"message\") or data.get(\"error\") or f\"HTTP {response.status_code}\"\n            raise BambuCloudError(f\"Failed to update setting: {error_msg}\")\n\n        except httpx.RequestError as e:\n            raise BambuCloudError(f\"Request failed: {e}\")\n\n    async def delete_setting(self, setting_id: str) -> dict:\n        \"\"\"\n        Delete a slicer preset/setting.\n\n        Args:\n            setting_id: ID of the preset to delete\n\n        Returns:\n            Deletion confirmation\n        \"\"\"\n        if not self.is_authenticated:\n            raise BambuCloudAuthError(\"Not authenticated\")\n\n        try:\n            response = await self._client.delete(\n                f\"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}\", headers=self._get_headers()\n            )\n\n            if response.status_code in (200, 204):\n                return {\"success\": True, \"message\": \"Setting deleted\"}\n\n            data = response.json() if response.content else {}\n            error_msg = data.get(\"message\") or data.get(\"error\") or f\"HTTP {response.status_code}\"\n            raise BambuCloudError(f\"Failed to delete setting: {error_msg}\")\n\n        except httpx.RequestError as e:\n            raise BambuCloudError(f\"Request failed: {e}\")\n\n    async def get_devices(self) -> dict:\n        \"\"\"Get list of bound devices.\"\"\"\n        if not self.is_authenticated:\n            raise BambuCloudAuthError(\"Not authenticated\")\n\n        try:\n            response = await self._client.get(\n                f\"{self.base_url}/v1/iot-service/api/user/bind\", headers=self._get_headers()\n            )\n\n            if response.status_code == 200:\n                return response.json()\n\n            raise BambuCloudError(f\"Failed to get devices: {response.status_code}\")\n\n        except httpx.RequestError as e:\n            raise BambuCloudError(f\"Request failed: {e}\")\n\n    async def get_firmware_version(self, device_id: str) -> dict:\n        \"\"\"\n        Get firmware version info for a device.\n\n        Returns dict with:\n        - current_version: Installed firmware version\n        - latest_version: Latest available firmware version\n        - update_available: Boolean indicating if update is available\n        - release_notes: Release notes for latest version\n        \"\"\"\n        if not self.is_authenticated:\n            raise BambuCloudAuthError(\"Not authenticated\")\n\n        try:\n            response = await self._client.get(\n                f\"{self.base_url}/v1/iot-service/api/user/device/version\",\n                headers=self._get_headers(),\n                params={\"device_id\": device_id},\n            )\n\n            if response.status_code == 200:\n                data = response.json()\n                # API wraps response in 'data' field\n                return data.get(\"data\", data)\n\n            raise BambuCloudError(f\"Failed to get firmware version: {response.status_code}\")\n\n        except httpx.RequestError as e:\n            raise BambuCloudError(f\"Request failed: {e}\")\n\n    async def close(self):\n        \"\"\"Close the HTTP client we own. No-op when sharing an app-scoped client.\"\"\"\n        if self._owns_client:\n            await self._client.aclose()\n\n\n# Previously this module exposed a process-wide ``_cloud_service`` singleton\n# via ``get_cloud_service()`` / ``reset_cloud_service()``. That pattern leaked\n# region and token state across users (a China-region login would pin the\n# singleton to api.bambulab.cn until the next explicit reset), so the singleton\n# has been removed. Callers should construct a per-request\n# ``BambuCloudService(region=...)`` from the stored region and ``await\n# cloud.close()`` it when done. See ``routes.cloud.build_authenticated_cloud``\n# for the standard pattern.\n"
  },
  {
    "path": "backend/app/services/bambu_ftp.py",
    "content": "import asyncio\nimport ftplib  # nosec B402\nimport logging\nimport os\nimport socket\nimport ssl\nimport threading\nimport time\nfrom collections.abc import Awaitable, Callable\nfrom ftplib import FTP, FTP_TLS  # nosec B402\nfrom io import BytesIO\nfrom pathlib import Path\nfrom typing import TypeVar\n\nlogger = logging.getLogger(__name__)\n\nT = TypeVar(\"T\")\n\n\nclass FileNotOnPrinterError(Exception):\n    \"\"\"Raised when a remote FTP path returns 550 (file not found).\n\n    550 means the file does not exist at that path — retrying the same path\n    will never succeed. Callers use this sentinel with with_ftp_retry's\n    non_retry_exceptions to immediately move on to the next candidate path\n    instead of burning the full retry budget (up to 11 × 30s per path) on\n    a lookup that cannot recover.\n    \"\"\"\n\n\nclass ImplicitFTP_TLS(FTP_TLS):\n    \"\"\"FTP_TLS subclass for implicit FTPS (port 990) with model-specific SSL handling.\n\n    X1C/P1S printers (vsFTPd) require SSL with session reuse on the data channel.\n    A1/A1 Mini printers have issues with SSL on the data channel entirely and\n    timeout waiting for transfer completion. Set skip_session_reuse=True for A1\n    printers to skip SSL on the data channel (control channel remains encrypted).\n    \"\"\"\n\n    def __init__(self, *args, skip_session_reuse: bool = False, **kwargs):\n        super().__init__(*args, **kwargs)\n        self._sock = None\n        self.skip_session_reuse = skip_session_reuse\n        self.ssl_context = ssl.create_default_context()\n        self.ssl_context.check_hostname = False\n        self.ssl_context.verify_mode = ssl.CERT_NONE\n\n    def connect(self, host=\"\", port=990, timeout=-999, source_address=None):\n        \"\"\"Connect to host, wrapping socket in TLS immediately (implicit FTPS).\"\"\"\n        if host:\n            self.host = host\n        if port > 0:\n            self.port = port\n        if timeout != -999:\n            self.timeout = timeout\n        if source_address:\n            self.source_address = source_address\n\n        # Create and wrap socket immediately (implicit TLS)\n        self.sock = socket.create_connection((self.host, self.port), self.timeout, source_address=self.source_address)\n        self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host)\n        self.af = self.sock.family\n        self.file = self.sock.makefile(\"r\", encoding=self.encoding)\n        self.welcome = self.getresp()\n        return self.welcome\n\n    def ntransfercmd(self, cmd, rest=None):\n        \"\"\"Override to wrap data connection in SSL for X1C/P1S only.\n\n        X1C/P1S printers (vsFTPd) require SSL session reuse on the data channel.\n        A1/A1 Mini printers have issues with SSL on the data channel entirely -\n        they timeout waiting for the transfer completion response. For A1, we\n        skip SSL wrapping on the data channel (control channel remains encrypted).\n        \"\"\"\n        conn, size = FTP.ntransfercmd(self, cmd, rest)\n        if self._prot_p and not self.skip_session_reuse:\n            # X1C/P1S: Wrap data channel with SSL session reuse (required by vsFTPd)\n            conn = self.ssl_context.wrap_socket(\n                conn,\n                server_hostname=self.host,\n                session=self.sock.session,\n            )\n        # A1/A1 Mini (skip_session_reuse=True): Don't wrap data channel in SSL\n        # The control channel remains encrypted via implicit FTPS\n        return conn, size\n\n\nclass BambuFTPClient:\n    \"\"\"FTP client for retrieving files from Bambu Lab printers.\"\"\"\n\n    FTP_PORT = 990\n    # Default timeout in seconds (increased for A1 printers)\n    DEFAULT_TIMEOUT = 30\n    # Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)\n    # These models have varying FTP SSL behavior depending on firmware version\n    A1_MODELS = (\"A1\", \"A1 Mini\")\n    # Chunk size for manual upload transfer (64KB)\n    # Smaller chunks provide smoother progress reporting — at typical printer FTP\n    # speeds (~50-100KB/s) this gives a progress update roughly every second.\n    CHUNK_SIZE = 64 * 1024\n\n    # Cache for working FTP modes per printer IP\n    # Maps IP -> \"prot_p\" or \"prot_c\"\n    _mode_cache: dict[str, str] = {}\n\n    def __init__(\n        self,\n        ip_address: str,\n        access_code: str,\n        timeout: float | None = None,\n        printer_model: str | None = None,\n        force_prot_c: bool = False,\n    ):\n        self.ip_address = ip_address\n        self.access_code = access_code\n        self.timeout = timeout if timeout is not None else self.DEFAULT_TIMEOUT\n        self.printer_model = printer_model\n        self.force_prot_c = force_prot_c\n        self._ftp: ImplicitFTP_TLS | None = None\n\n    def _is_a1_model(self) -> bool:\n        \"\"\"Check if this is an A1 series printer.\"\"\"\n        if not self.printer_model:\n            return False\n        return self.printer_model in self.A1_MODELS\n\n    def _get_cached_mode(self) -> str | None:\n        \"\"\"Get cached FTP mode for this printer.\"\"\"\n        return self._mode_cache.get(self.ip_address)\n\n    @classmethod\n    def cache_mode(cls, ip_address: str, mode: str):\n        \"\"\"Cache the working FTP mode for a printer.\"\"\"\n        cls._mode_cache[ip_address] = mode\n        logger.info(\"FTP mode cached for %s: %s\", ip_address, mode)\n\n    def _should_use_prot_c(self) -> bool:\n        \"\"\"Determine if we should use prot_c (clear) mode.\"\"\"\n        # If explicitly forced, use prot_c\n        if self.force_prot_c:\n            return True\n        # Check cache first\n        cached = self._get_cached_mode()\n        if cached:\n            return cached == \"prot_c\"\n        # Default: try prot_p first (will fall back if needed)\n        return False\n\n    def connect(self) -> bool:\n        \"\"\"Connect to the printer FTP server (implicit FTPS on port 990).\"\"\"\n        try:\n            use_prot_c = self._should_use_prot_c()\n            logger.debug(\n                f\"FTP connecting to {self.ip_address}:{self.FTP_PORT} \"\n                f\"(timeout={self.timeout}s, model={self.printer_model}, prot_c={use_prot_c})\"\n            )\n            self._ftp = ImplicitFTP_TLS(skip_session_reuse=use_prot_c)\n            self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)\n            logger.debug(\"FTP connected, logging in as bblp\")\n            self._ftp.login(\"bblp\", self.access_code)\n            if use_prot_c:\n                # Use clear (unencrypted) data channel\n                logger.debug(\"FTP logged in, setting prot_c (clear) and passive mode\")\n                self._ftp.prot_c()\n            else:\n                # Use protected (encrypted) data channel with session reuse\n                logger.debug(\"FTP logged in, setting prot_p (protected) and passive mode\")\n                self._ftp.prot_p()\n            self._ftp.set_pasv(True)\n            # Log welcome message for debugging\n            if hasattr(self._ftp, \"welcome\") and self._ftp.welcome:\n                logger.debug(\"FTP server welcome: %s\", self._ftp.welcome)\n            logger.info(\n                f\"FTP connected successfully to {self.ip_address} (model={self.printer_model}, prot_c={use_prot_c})\"\n            )\n            return True\n        except ftplib.error_perm as e:\n            logger.warning(\"FTP connection permission error to %s: %s\", self.ip_address, e)\n            self._ftp = None\n            return False\n        except TimeoutError as e:\n            logger.warning(\"FTP connection timed out to %s: %s\", self.ip_address, e)\n            self._ftp = None\n            return False\n        except ssl.SSLError as e:\n            logger.warning(\"FTP SSL error connecting to %s: %s\", self.ip_address, e)\n            self._ftp = None\n            return False\n        except (OSError, ftplib.Error) as e:\n            logger.warning(\"FTP connection failed to %s: %s (type: %s)\", self.ip_address, e, type(e).__name__)\n            self._ftp = None\n            return False\n\n    def disconnect(self):\n        \"\"\"Disconnect from the FTP server.\"\"\"\n        if self._ftp:\n            try:\n                self._ftp.quit()\n            except (OSError, ftplib.Error, EOFError):\n                pass  # Best-effort FTP cleanup; connection may already be closed\n            self._ftp = None\n\n    def list_files(self, path: str = \"/\") -> list[dict]:\n        \"\"\"List files in a directory.\"\"\"\n        if not self._ftp:\n            return []\n\n        files = []\n        try:\n            self._ftp.cwd(path)\n            items = []\n            self._ftp.retrlines(\"LIST\", items.append)\n\n            for item in items:\n                parts = item.split()\n                if len(parts) >= 9:\n                    name = \" \".join(parts[8:])\n                    is_dir = item.startswith(\"d\")\n                    size = int(parts[4]) if not is_dir else 0\n\n                    # Parse modification time from FTP listing\n                    # Format: \"Nov 30 10:15\" or \"Nov 30  2024\"\n                    mtime = None\n                    try:\n                        from datetime import datetime\n\n                        month = parts[5]\n                        day = parts[6]\n                        time_or_year = parts[7]\n\n                        # Determine if it's time (HH:MM) or year\n                        if \":\" in time_or_year:\n                            # Recent file: \"Nov 30 10:15\" - assume current year\n                            year = datetime.now().year\n                            time_str = f\"{month} {day} {year} {time_or_year}\"\n                            mtime = datetime.strptime(time_str, \"%b %d %Y %H:%M\")\n                            # If parsed date is in the future, use last year\n                            if mtime > datetime.now():\n                                mtime = mtime.replace(year=year - 1)\n                        else:\n                            # Older file: \"Nov 30 2024\" - no time, just date\n                            time_str = f\"{month} {day} {time_or_year}\"\n                            mtime = datetime.strptime(time_str, \"%b %d %Y\")\n                    except (ValueError, IndexError):\n                        pass  # Non-critical: mtime parsing is best-effort; file entry works without it\n\n                    file_entry = {\n                        \"name\": name,\n                        \"is_directory\": is_dir,\n                        \"size\": size,\n                        \"path\": f\"{path.rstrip('/')}/{name}\",\n                    }\n                    if mtime:\n                        file_entry[\"mtime\"] = mtime\n                    files.append(file_entry)\n            logger.debug(\"Listed %s files in %s\", len(files), path)\n        except (OSError, ftplib.Error) as e:\n            logger.info(\"FTP list_files failed for %s: %s\", path, e)\n\n        return files\n\n    def download_file(self, remote_path: str) -> bytes | None:\n        \"\"\"Download a file from the printer.\"\"\"\n        if not self._ftp:\n            return None\n\n        try:\n            buffer = BytesIO()\n            self._ftp.retrbinary(f\"RETR {remote_path}\", buffer.write)\n            return buffer.getvalue()\n        except (OSError, ftplib.Error):\n            return None\n\n    def download_to_file(self, remote_path: str, local_path: Path) -> bool:\n        \"\"\"Download a file from the printer to local filesystem.\"\"\"\n        if not self._ftp:\n            logger.warning(\"download_to_file called but FTP not connected\")\n            return False\n\n        try:\n            local_path.parent.mkdir(parents=True, exist_ok=True)\n            with open(local_path, \"wb\") as f:\n                self._ftp.retrbinary(f\"RETR {remote_path}\", f.write)\n                f.flush()\n                os.fsync(f.fileno())\n            file_size = local_path.stat().st_size if local_path.exists() else 0\n            if file_size == 0:\n                logger.warning(\"FTP download returned 0 bytes for %s\", remote_path)\n                if local_path.exists():\n                    local_path.unlink()\n                return False\n            logger.info(\"Successfully downloaded %s to %s (%s bytes)\", remote_path, local_path, file_size)\n            return True\n        except (OSError, ftplib.Error) as e:\n            # Clean up partial file if it exists\n            if local_path.exists():\n                try:\n                    local_path.unlink()\n                except OSError:\n                    pass  # Best-effort partial file cleanup; not critical if removal fails\n            # 550 means the file is not at this path. Surface as a sentinel so\n            # with_ftp_retry can abandon this path immediately and the caller\n            # can advance to the next candidate instead of retrying 11× at\n            # 30s intervals (the pattern that cost #972's reporter ~48min).\n            if isinstance(e, ftplib.error_perm) and str(e).startswith(\"550\"):\n                logger.info(\"FTP download failed for %s: %s (not on printer)\", remote_path, e)\n                raise FileNotOnPrinterError(f\"{remote_path}: {e}\") from e\n            # Log at INFO level so we can see failures in normal logs\n            logger.info(\"FTP download failed for %s: %s\", remote_path, e)\n            return False\n\n    def diagnose_storage(self) -> dict:\n        \"\"\"Run storage diagnostics and return results. For debugging upload issues.\"\"\"\n        results = {\n            \"connected\": self._ftp is not None,\n            \"can_list_root\": False,\n            \"root_files\": [],\n            \"can_list_cache\": False,\n            \"storage_info\": None,\n            \"pwd\": None,\n            \"errors\": [],\n        }\n\n        if not self._ftp:\n            results[\"errors\"].append(\"FTP not connected\")\n            return results\n\n        # Try to get current directory\n        try:\n            results[\"pwd\"] = self._ftp.pwd()\n            logger.debug(\"FTP current directory: %s\", results[\"pwd\"])\n        except (OSError, ftplib.Error) as e:\n            results[\"errors\"].append(f\"PWD failed: {e}\")\n            logger.debug(\"FTP PWD failed: %s\", e)\n\n        # Try to list root directory\n        try:\n            self._ftp.cwd(\"/\")\n            items = []\n            self._ftp.retrlines(\"LIST\", items.append)\n            results[\"can_list_root\"] = True\n            results[\"root_files\"] = items[:10]  # First 10 entries\n            logger.debug(\"FTP root listing (%s items): %s\", len(items), items[:5])\n        except (OSError, ftplib.Error) as e:\n            results[\"errors\"].append(f\"LIST / failed: {e}\")\n            logger.debug(\"FTP LIST / failed: %s\", e)\n\n        # Try to list /cache (should exist on all printers)\n        try:\n            self._ftp.cwd(\"/cache\")\n            items = []\n            self._ftp.retrlines(\"LIST\", items.append)\n            results[\"can_list_cache\"] = True\n            logger.debug(\"FTP /cache listing: %s items\", len(items))\n        except (OSError, ftplib.Error) as e:\n            results[\"errors\"].append(f\"LIST /cache failed: {e}\")\n            logger.debug(\"FTP LIST /cache failed: %s\", e)\n\n        # Try to get storage info\n        try:\n            results[\"storage_info\"] = self.get_storage_info()\n            logger.debug(\"FTP storage info: %s\", results[\"storage_info\"])\n        except (OSError, ftplib.Error) as e:\n            results[\"errors\"].append(f\"Storage info failed: {e}\")\n\n        return results\n\n    def upload_file(\n        self,\n        local_path: Path,\n        remote_path: str,\n        progress_callback: Callable[[int, int], None] | None = None,\n    ) -> bool:\n        \"\"\"Upload a file to the printer with optional progress callback.\"\"\"\n        if not self._ftp:\n            logger.warning(\"upload_file: FTP not connected\")\n            return False\n\n        try:\n            file_size = local_path.stat().st_size if local_path.exists() else 0\n            logger.info(\"FTP uploading %s (%s bytes) to %s\", local_path, file_size, remote_path)\n\n            uploaded = 0\n            callback_exception: Exception | None = None\n\n            # Use manual transfer instead of storbinary() for A1 compatibility\n            # A1 printers have issues with storbinary's voidresp() hanging after transfer\n            with open(local_path, \"rb\") as f:\n                logger.debug(\"FTP STOR command starting for %s\", remote_path)\n                t0 = time.monotonic()\n                conn = self._ftp.transfercmd(f\"STOR {remote_path}\")\n                logger.info(\n                    \"FTP data channel ready in %.1fs (PASV + TLS handshake)\",\n                    time.monotonic() - t0,\n                )\n\n                # Set explicit socket options for reliable transfer\n                conn.setblocking(True)\n                conn.settimeout(self.timeout)\n\n                try:\n                    while True:\n                        chunk = f.read(self.CHUNK_SIZE)\n                        if not chunk:\n                            logger.debug(\"FTP upload: final chunk reached\")\n                            break\n\n                        conn.sendall(chunk)\n                        uploaded += len(chunk)\n                        logger.debug(\"FTP upload progress: %s/%s bytes\", uploaded, file_size)\n\n                        if progress_callback:\n                            try:\n                                progress_callback(uploaded, file_size)\n                            except Exception as e:\n                                callback_exception = e\n                                logger.info(\n                                    \"FTP upload callback requested stop for %s at %s/%s bytes: %s\",\n                                    remote_path,\n                                    uploaded,\n                                    file_size,\n                                    e,\n                                )\n                                break\n\n                except OSError as e:\n                    logger.error(\"FTP connection lost during upload: %s\", e)\n                    raise\n                finally:\n                    try:\n                        conn.close()\n                    except OSError:\n                        pass\n\n            # Wait for the server's 226 \"Transfer complete\" response to confirm\n            # the file has been flushed to the SD card. Without this, the printer\n            # may try to read an incomplete file when the print command is sent,\n            # causing 0500-C010 \"MicroSD Card read/write exception\" errors.\n            # See: https://bugs.python.org/issue25458 (ftplib response desync)\n            try:\n                old_timeout = self._ftp.sock.gettimeout()\n                # Use a generous timeout — H2D printers can take 30+ seconds\n                # to send the 226 after the data channel closes.\n                self._ftp.sock.settimeout(max(self.timeout, 60))\n                try:\n                    resp = self._ftp.voidresp()\n                    logger.info(\"FTP STOR confirmed for %s: %s\", remote_path, resp.strip())\n                finally:\n                    self._ftp.sock.settimeout(old_timeout)\n            except Exception as e:\n                # Timeout or error reading 226 — log but proceed, the data\n                # was fully sent so the file is likely on the SD card.\n                logger.warning(\n                    \"FTP STOR confirmation not received for %s (proceeding): %s (%s)\",\n                    remote_path,\n                    e,\n                    type(e).__name__,\n                )\n\n            if callback_exception is not None:\n                cleanup_ok = False\n                try:\n                    cleanup_ok = self.delete_file(remote_path)\n                except Exception as cleanup_error:\n                    logger.warning(\"FTP cancel cleanup failed for %s: %s\", remote_path, cleanup_error)\n\n                if cleanup_ok:\n                    logger.info(\"FTP cancel cleanup succeeded for %s\", remote_path)\n                    raise callback_exception\n\n                raise RuntimeError(\n                    f\"Upload cancelled but failed to remove partial file {remote_path} from printer\"\n                ) from callback_exception\n\n            elapsed = time.monotonic() - t0\n            speed_kbs = (file_size / 1024) / elapsed if elapsed > 0 else 0\n            logger.info(\n                \"FTP upload complete: %s (%s bytes in %.1fs, %.0f KB/s)\",\n                remote_path,\n                file_size,\n                elapsed,\n                speed_kbs,\n            )\n            return True\n        except ftplib.error_perm as e:\n            # Permanent FTP error (4xx/5xx response)\n            error_code = str(e)[:3] if str(e) else \"unknown\"\n            logger.error(\"FTP upload failed for %s: %s (error code: %s)\", remote_path, e, error_code)\n            if error_code == \"553\":\n                logger.error(\n                    \"FTP 553 error - Could not create file. Possible causes: \"\n                    \"1) No SD card inserted, 2) SD card full, 3) SD card not formatted correctly (needs FAT32/exFAT), \"\n                    \"4) Printer busy/not ready, 5) File path issue\"\n                )\n            elif error_code == \"550\":\n                logger.error(\"FTP 550 error - File/directory not found or permission denied\")\n            elif error_code == \"552\":\n                logger.error(\"FTP 552 error - Storage quota exceeded (SD card full?)\")\n            return False\n        except (OSError, ftplib.Error) as e:\n            logger.error(\"FTP upload failed for %s: %s (type: %s)\", remote_path, e, type(e).__name__)\n            return False\n\n    def upload_bytes(self, data: bytes, remote_path: str) -> bool:\n        \"\"\"Upload bytes to the printer.\"\"\"\n        if not self._ftp:\n            return False\n\n        try:\n            # Use manual transfer instead of storbinary() for A1 compatibility\n            conn = self._ftp.transfercmd(f\"STOR {remote_path}\")\n            conn.setblocking(True)\n            conn.settimeout(self.timeout)\n\n            try:\n                # Send data in chunks\n                offset = 0\n                while offset < len(data):\n                    chunk = data[offset : offset + self.CHUNK_SIZE]\n                    conn.sendall(chunk)\n                    offset += len(chunk)\n            except OSError as e:\n                logger.error(\"FTP connection lost during upload_bytes: %s\", e)\n                raise\n            finally:\n                try:\n                    conn.close()\n                except OSError:\n                    pass\n            # Wait for 226 confirmation (see upload_file for rationale)\n            try:\n                old_timeout = self._ftp.sock.gettimeout()\n                self._ftp.sock.settimeout(max(self.timeout, 60))\n                try:\n                    self._ftp.voidresp()\n                finally:\n                    self._ftp.sock.settimeout(old_timeout)\n            except Exception:\n                pass  # Best-effort — data was sent, proceed\n            return True\n        except (OSError, ftplib.Error):\n            return False\n\n    def delete_file(self, remote_path: str) -> bool:\n        \"\"\"Delete a file from the printer.\"\"\"\n        if not self._ftp:\n            return False\n\n        try:\n            self._ftp.delete(remote_path)\n            return True\n        except (OSError, ftplib.Error) as e:\n            logger.warning(\"Failed to delete %s: %s\", remote_path, e)\n            return False\n\n    def get_file_size(self, remote_path: str) -> int | None:\n        \"\"\"Get the size of a file.\"\"\"\n        if not self._ftp:\n            return None\n\n        try:\n            return self._ftp.size(remote_path)\n        except (OSError, ftplib.Error):\n            return None\n\n    def get_storage_info(self) -> dict | None:\n        \"\"\"Get storage information from the printer.\"\"\"\n        if not self._ftp:\n            return None\n\n        result = {}\n\n        # Try AVBL command (available space) - some FTP servers support this\n        try:\n            response = self._ftp.sendcmd(\"AVBL\")\n            logger.debug(\"AVBL response: %s\", response)\n            # Response format: \"213 <bytes available>\"\n            if response.startswith(\"213\"):\n                parts = response.split()\n                if len(parts) >= 2:\n                    result[\"free_bytes\"] = int(parts[1])\n        except (OSError, ftplib.Error) as e:\n            logger.debug(\"AVBL command not supported: %s\", e)\n            # Try STAT command as fallback\n            try:\n                response = self._ftp.sendcmd(\"STAT\")\n                logger.debug(\"STAT response: %s\", response)\n            except (OSError, ftplib.Error):\n                pass  # Both AVBL and STAT unsupported; storage info will rely on directory scan\n\n        # Calculate used space by listing root directories\n        try:\n            total_used = 0\n            dirs_to_scan = [\"/cache\", \"/timelapse\", \"/model\", \"/data\", \"/data/Metadata\", \"/\"]\n\n            for dir_path in dirs_to_scan:\n                try:\n                    self._ftp.cwd(dir_path)\n                    items = []\n                    self._ftp.retrlines(\"LIST\", items.append)\n\n                    for item in items:\n                        parts = item.split()\n                        if len(parts) >= 5 and not item.startswith(\"d\"):\n                            try:\n                                total_used += int(parts[4])\n                            except ValueError:\n                                pass  # Skip entries with non-numeric size fields\n                except (OSError, ftplib.Error):\n                    pass  # Directory may not exist on this printer model; skip it\n\n            result[\"used_bytes\"] = total_used\n        except (OSError, ftplib.Error):\n            pass  # Storage scan failed; return whatever info was collected above\n\n        return result if result else None\n\n\n# Shared 3MF download cache (#972).\n#\n# Both the cover thumbnail endpoint (api/routes/printers.py) and the archive\n# metadata flow (main.py) fetch the same 3MF file over FTP during a print.\n# On slow / contended links (A1 Wi-Fi, large files) the duplicate transfers\n# compete for the printer's single FTP socket and trigger 425 \"can't open\n# data channel\" errors, feeding back into cause-2's retry storm.\n#\n# This cache stores the local path of a successfully-downloaded 3MF keyed\n# by (printer_id, normalized_name). Whichever flow downloads first populates\n# the cache; the other flow reuses the file read-only. Evicted on print\n# completion so a later print with the same name re-downloads fresh bytes.\n_threemf_path_cache: dict[tuple[int, str], Path] = {}\n\n\ndef normalize_3mf_name(name: str) -> str:\n    \"\"\"Collapse various 3MF filename variants to a cache key.\n\n    Bambu tooling produces names as bare subtask (\"Part\"), with .3mf, with\n    .gcode.3mf, or (Studio-normalized) with spaces → underscores. All of\n    these refer to the same print job on the same printer, so they must\n    hash to the same cache key.\n    \"\"\"\n    # Lowercase first so .3MF / .GCODE.3MF variants strip cleanly — a\n    # real-world case since Windows-side tooling sometimes uppercases\n    # extensions.\n    cleaned = name.strip().lower().replace(\".gcode.3mf\", \"\").replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n    return cleaned.replace(\" \", \"_\")\n\n\ndef cache_3mf_download(printer_id: int, name: str, local_path: Path) -> None:\n    \"\"\"Record a successfully-downloaded 3MF so a sibling flow can reuse it.\"\"\"\n    _threemf_path_cache[(printer_id, normalize_3mf_name(name))] = local_path\n\n\ndef get_cached_3mf(printer_id: int, name: str) -> Path | None:\n    \"\"\"Return a cached 3MF path for this printer/name if the file still exists.\"\"\"\n    key = (printer_id, normalize_3mf_name(name))\n    cached = _threemf_path_cache.get(key)\n    if cached and cached.exists() and cached.stat().st_size > 0:\n        return cached\n    # Evict dead entry — the file was cleaned up (temp dir clean, manual\n    # deletion, restart) so the cache value is no longer usable.\n    if cached:\n        _threemf_path_cache.pop(key, None)\n    return None\n\n\ndef clear_3mf_cache(printer_id: int | None = None, delete_files: bool = True) -> None:\n    \"\"\"Drop cache entries for one printer (or all with None).\n\n    When ``delete_files`` is True (default) the on-disk 3MF is removed as well\n    — called from on_print_complete so temp files don't accumulate across\n    prints. Tests that want to inspect the cache contents disable this.\n    \"\"\"\n\n    def _maybe_unlink(path: Path) -> None:\n        if delete_files and path.exists():\n            try:\n                path.unlink()\n            except OSError as exc:\n                logger.debug(\"3MF cache cleanup skipped %s: %s\", path, exc)\n\n    if printer_id is None:\n        for path in list(_threemf_path_cache.values()):\n            _maybe_unlink(path)\n        _threemf_path_cache.clear()\n        return\n    for key in [k for k in _threemf_path_cache if k[0] == printer_id]:\n        _maybe_unlink(_threemf_path_cache[key])\n        _threemf_path_cache.pop(key, None)\n\n\nasync def download_file_async(\n    ip_address: str,\n    access_code: str,\n    remote_path: str,\n    local_path: Path,\n    timeout: float = 60.0,\n    socket_timeout: float | None = None,\n    printer_model: str | None = None,\n) -> bool:\n    \"\"\"Async wrapper for downloading a file with timeout.\n\n    For A1/A1 Mini printers, automatically tries prot_p first, then falls back\n    to prot_c if the download fails. The working mode is cached for future operations.\n\n    Args:\n        ip_address: Printer IP address\n        access_code: Printer access code\n        remote_path: Remote file path on printer\n        local_path: Local path to save file\n        timeout: Overall operation timeout (asyncio)\n        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)\n        printer_model: Printer model for A1-specific workarounds\n    \"\"\"\n    loop = asyncio.get_event_loop()\n    is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False\n\n    # Per-attempt completion state: asyncio.wait_for cannot cancel\n    # run_in_executor threads, so on timeout the executor may still complete\n    # the download after we stop waiting. The thread flips `success` to True\n    # ONLY after the file is fully written — a post-timeout check lets us\n    # salvage the download without mistaking an in-progress partial write\n    # for a completed one. Each attempt gets its own dict and event so a\n    # zombie from an earlier attempt can't flip the flag for a later one.\n    # The event is set in `_download`'s finally block so the post-timeout\n    # path can wait for genuine thread completion instead of a fixed sleep.\n\n    def _download(force_prot_c: bool, completion: dict, done: threading.Event) -> bool:\n        mode_str = \"prot_c\" if force_prot_c else \"prot_p\"\n        try:\n            client = BambuFTPClient(\n                ip_address,\n                access_code,\n                timeout=socket_timeout,\n                printer_model=printer_model,\n                force_prot_c=force_prot_c,\n            )\n            if client.connect():\n                try:\n                    result = client.download_to_file(remote_path, local_path)\n                    if result:\n                        BambuFTPClient.cache_mode(ip_address, mode_str)\n                        completion[\"success\"] = True\n                    return result\n                finally:\n                    client.disconnect()\n            return False\n        finally:\n            done.set()\n\n    async def _run(force_prot_c: bool) -> bool:\n        completion = {\"success\": False}\n        done = threading.Event()\n        try:\n            return await asyncio.wait_for(\n                loop.run_in_executor(None, _download, force_prot_c, completion, done), timeout=timeout\n            )\n        except TimeoutError:\n            # Slow WiFi links commonly overshoot ftp_timeout by 10–30 s without\n            # actually being stuck, so starting attempt 2 now would just contend\n            # with the still-progressing RETR on attempt 1 and produce the\n            # zombie-write race reported in #1014 (file landed on disk minutes\n            # after the retry loop had already given up). Wait for the worker\n            # thread to genuinely finish — capped at 30 s so a truly stuck\n            # connection can't stall a whole attempt indefinitely, with a 0.5 s\n            # floor so artificially small test timeouts still give zombies a\n            # realistic window to finish.\n            grace = max(min(timeout, 30.0), 0.5)\n            await loop.run_in_executor(None, done.wait, grace)\n            if completion[\"success\"] and local_path.exists() and local_path.stat().st_size > 0:\n                logger.info(\n                    \"FTP download wait_for timed out after %ss for %s, but thread completed within %ss grace (%s bytes) — salvaging\",\n                    timeout,\n                    remote_path,\n                    grace,\n                    local_path.stat().st_size,\n                )\n                return True\n            logger.warning(\n                \"FTP download timed out after %ss (plus %ss grace) for %s\",\n                timeout,\n                grace,\n                remote_path,\n            )\n            return False\n\n    # Check if we have a cached mode for this printer\n    cached_mode = BambuFTPClient._mode_cache.get(ip_address)\n\n    if cached_mode:\n        force_prot_c = cached_mode == \"prot_c\"\n        return await _run(force_prot_c)\n\n    # No cached mode - try prot_p first\n    if await _run(False):\n        return True\n\n    # Download failed - for A1 models, try prot_c fallback\n    if is_a1:\n        logger.info(\"FTP download failed with prot_p for A1 model, trying prot_c fallback...\")\n        return await _run(True)\n\n    return False\n\n\nasync def download_file_try_paths_async(\n    ip_address: str,\n    access_code: str,\n    remote_paths: list[str],\n    local_path: Path,\n    socket_timeout: float | None = None,\n    printer_model: str | None = None,\n) -> bool:\n    \"\"\"Try downloading a file from multiple paths using a single connection.\n\n    Args:\n        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)\n        printer_model: Printer model for A1-specific workarounds\n    \"\"\"\n    loop = asyncio.get_event_loop()\n\n    def _download():\n        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)\n        if not client.connect():\n            return False\n\n        try:\n            # FileNotOnPrinterError signals \"try the next path\", not \"give up\" —\n            # this function's whole purpose is to walk a list of candidates\n            # over one connection. Only a real transport error should bubble.\n            for remote_path in remote_paths:\n                try:\n                    if client.download_to_file(remote_path, local_path):\n                        return True\n                except FileNotOnPrinterError:\n                    continue\n            return False\n        finally:\n            client.disconnect()\n\n    return await loop.run_in_executor(None, _download)\n\n\nasync def upload_file_async(\n    ip_address: str,\n    access_code: str,\n    local_path: Path,\n    remote_path: str,\n    timeout: float = 600.0,\n    progress_callback: Callable[[int, int], None] | None = None,\n    socket_timeout: float | None = None,\n    printer_model: str | None = None,\n) -> bool:\n    \"\"\"Async wrapper for uploading a file with timeout and progress callback.\n\n    For A1/A1 Mini printers, automatically tries prot_p first, then falls back\n    to prot_c if the upload fails. The working mode is cached for future uploads.\n\n    Args:\n        ip_address: Printer IP address\n        access_code: Printer access code\n        local_path: Local file path to upload\n        remote_path: Remote path on printer\n        timeout: Overall operation timeout (asyncio)\n        progress_callback: Optional callback for progress updates\n        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)\n        printer_model: Printer model for A1-specific workarounds\n    \"\"\"\n    loop = asyncio.get_event_loop()\n    is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False\n\n    def _upload(force_prot_c: bool = False) -> bool:\n        mode_str = \"prot_c\" if force_prot_c else \"prot_p\"\n        logger.info(\n            f\"FTP connecting to {ip_address} for upload (model={printer_model}, \"\n            f\"mode={mode_str}, socket_timeout={socket_timeout}s)...\"\n        )\n        client = BambuFTPClient(\n            ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c\n        )\n        if client.connect():\n            logger.info(\"FTP connected to %s\", ip_address)\n            try:\n                result = client.upload_file(local_path, remote_path, progress_callback)\n                if result:\n                    # Cache the working mode\n                    BambuFTPClient.cache_mode(ip_address, mode_str)\n                return result\n            finally:\n                client.disconnect()\n        logger.warning(\"FTP connection failed to %s\", ip_address)\n        return False\n\n    try:\n        # Check if we have a cached mode for this printer\n        cached_mode = BambuFTPClient._mode_cache.get(ip_address)\n\n        if cached_mode:\n            # Use cached mode\n            force_prot_c = cached_mode == \"prot_c\"\n            return await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(force_prot_c)), timeout=timeout)\n\n        # No cached mode - try prot_p first\n        result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(False)), timeout=timeout)\n\n        if result:\n            return True\n\n        # Upload failed - for A1 models, try prot_c fallback\n        if is_a1:\n            logger.info(\"FTP upload failed with prot_p for A1 model, trying prot_c fallback...\")\n            result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(True)), timeout=timeout)\n            return result\n\n        return False\n\n    except TimeoutError:\n        logger.warning(\"FTP upload timed out after %ss for %s\", timeout, remote_path)\n        return False\n\n\nasync def list_files_async(\n    ip_address: str,\n    access_code: str,\n    path: str = \"/\",\n    timeout: float = 30.0,\n    socket_timeout: float | None = None,\n    printer_model: str | None = None,\n) -> list[dict]:\n    \"\"\"Async wrapper for listing files with timeout.\n\n    Args:\n        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)\n        printer_model: Printer model for A1-specific workarounds\n    \"\"\"\n    loop = asyncio.get_event_loop()\n\n    def _list():\n        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)\n        if client.connect():\n            try:\n                return client.list_files(path)\n            finally:\n                client.disconnect()\n        return []\n\n    try:\n        return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)\n    except TimeoutError:\n        logger.warning(\"FTP list_files timed out after %ss for %s\", timeout, path)\n        return []\n\n\nasync def delete_file_async(\n    ip_address: str,\n    access_code: str,\n    remote_path: str,\n    socket_timeout: float | None = None,\n    printer_model: str | None = None,\n) -> bool:\n    \"\"\"Async wrapper for deleting a file.\n\n    Args:\n        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)\n        printer_model: Printer model for A1-specific workarounds\n    \"\"\"\n    loop = asyncio.get_event_loop()\n\n    def _delete():\n        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)\n        if client.connect():\n            try:\n                return client.delete_file(remote_path)\n            finally:\n                client.disconnect()\n        return False\n\n    return await loop.run_in_executor(None, _delete)\n\n\nasync def download_file_bytes_async(\n    ip_address: str,\n    access_code: str,\n    remote_path: str,\n    socket_timeout: float | None = None,\n    printer_model: str | None = None,\n) -> bytes | None:\n    \"\"\"Async wrapper for downloading file as bytes.\n\n    Args:\n        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)\n        printer_model: Printer model for A1-specific workarounds\n    \"\"\"\n    loop = asyncio.get_event_loop()\n\n    def _download():\n        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)\n        if client.connect():\n            try:\n                return client.download_file(remote_path)\n            finally:\n                client.disconnect()\n        return None\n\n    return await loop.run_in_executor(None, _download)\n\n\nasync def get_storage_info_async(\n    ip_address: str,\n    access_code: str,\n    socket_timeout: float | None = None,\n    printer_model: str | None = None,\n) -> dict | None:\n    \"\"\"Async wrapper for getting storage info.\n\n    Args:\n        socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)\n        printer_model: Printer model for A1-specific workarounds\n    \"\"\"\n    loop = asyncio.get_event_loop()\n\n    def _get_storage():\n        client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)\n        if client.connect():\n            try:\n                return client.get_storage_info()\n            finally:\n                client.disconnect()\n        return None\n\n    return await loop.run_in_executor(None, _get_storage)\n\n\nasync def get_ftp_retry_settings() -> tuple[bool, int, float, float]:\n    \"\"\"Get FTP retry settings from database.\n\n    Returns:\n        Tuple of (retry_enabled, retry_count, retry_delay, timeout)\n    \"\"\"\n    from backend.app.api.routes.settings import get_setting\n    from backend.app.core.database import async_session\n\n    async with async_session() as db:\n        enabled = (await get_setting(db, \"ftp_retry_enabled\") or \"true\") == \"true\"\n        count = int(await get_setting(db, \"ftp_retry_count\") or \"3\")\n        delay = float(await get_setting(db, \"ftp_retry_delay\") or \"2\")\n        timeout = float(await get_setting(db, \"ftp_timeout\") or \"30\")\n    return enabled, count, delay, timeout\n\n\nasync def with_ftp_retry(\n    operation: Callable[..., Awaitable[T]],\n    *args,\n    max_retries: int = 3,\n    retry_delay: float = 2.0,\n    operation_name: str = \"FTP operation\",\n    non_retry_exceptions: tuple[type[BaseException], ...] = (),\n    **kwargs,\n) -> T | None:\n    \"\"\"Execute FTP operation with retry logic.\n\n    Args:\n        operation: Async function to execute\n        *args: Positional arguments for the operation\n        max_retries: Number of retry attempts (default: 3)\n        retry_delay: Seconds to wait between retries (default: 2.0)\n        operation_name: Name for logging purposes\n        non_retry_exceptions: Exception types that should immediately abort retries\n        **kwargs: Keyword arguments for the operation\n\n    Returns:\n        Result of the operation, or None if all attempts fail\n    \"\"\"\n    last_error = None\n\n    for attempt in range(max_retries + 1):\n        try:\n            result = await operation(*args, **kwargs)\n            # Check for \"falsy\" success indicators\n            if result not in (False, None, []):\n                if attempt > 0:\n                    logger.info(\"%s succeeded on attempt %s/%s\", operation_name, attempt + 1, max_retries + 1)\n                return result\n            # Operation returned failure indicator\n            if attempt > 0:\n                logger.info(\"%s attempt %s/%s returned failure\", operation_name, attempt + 1, max_retries + 1)\n        except Exception as e:\n            if non_retry_exceptions and isinstance(e, non_retry_exceptions):\n                raise\n            last_error = e\n            logger.warning(\"%s attempt %s/%s failed: %s\", operation_name, attempt + 1, max_retries + 1, e)\n\n        # Don't wait after the last attempt\n        if attempt < max_retries:\n            logger.info(\"%s will retry in %ss...\", operation_name, retry_delay)\n            await asyncio.sleep(retry_delay)\n\n    logger.error(\"%s failed after %s attempts\", operation_name, max_retries + 1)\n    if last_error:\n        logger.debug(\"Last error: %s\", last_error)\n    return None\n"
  },
  {
    "path": "backend/app/services/bambu_mqtt.py",
    "content": "\"\"\"Bambu Lab MQTT communication service.\n\nIMPORTANT: Always use qos=1 for all MQTT publish calls!\nThe printer ignores qos=0 messages when busy broadcasting status updates.\nUsing qos=1 ensures the printer acknowledges and processes our commands immediately.\nThis was discovered when K-profile requests with qos=0 took 20-30 seconds,\nbut with qos=1 they respond instantly.\n\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport os\nimport ssl\nimport threading\nimport time\nfrom collections import deque\nfrom collections.abc import Callable\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\nimport paho.mqtt.client as mqtt\n\nlogger = logging.getLogger(__name__)\n\n# AMS module name prefixes used in get_version responses.\n# The numeric suffix after '/' is the AMS unit ID as reported in push_status.\n#   \"ams/<id>\"  – original AMS (X1C, X1E, P1S, …)\n#   \"n3f/<id>\"  – AMS 2 Pro (H2D Pro and similar)\n#   \"n3s/<id>\"  – AMS HT (H2D Pro and similar; IDs typically start at 128)\n_AMS_MODULE_PREFIXES = (\"ams/\", \"n3f/\", \"n3s/\")\n\n\n@dataclass\nclass MQTTLogEntry:\n    \"\"\"Log entry for MQTT message debugging.\"\"\"\n\n    timestamp: str\n    topic: str\n    direction: str  # \"in\" or \"out\"\n    payload: dict\n\n\n@dataclass\nclass HMSError:\n    \"\"\"Health Management System error from printer.\"\"\"\n\n    code: str\n    attr: int  # Attribute value for constructing wiki URL\n    module: int\n    severity: int  # 1=fatal, 2=serious, 3=common, 4=info\n    message: str = \"\"\n\n\n@dataclass\nclass KProfile:\n    \"\"\"Pressure advance (K) calibration profile from printer.\"\"\"\n\n    slot_id: int\n    extruder_id: int\n    nozzle_id: str\n    nozzle_diameter: str\n    filament_id: str\n    name: str\n    k_value: str\n    n_coef: str = \"0.000000\"\n    ams_id: int = 0\n    tray_id: int = -1\n    setting_id: str | None = None\n\n\n@dataclass\nclass NozzleInfo:\n    \"\"\"Nozzle hardware configuration.\"\"\"\n\n    nozzle_type: str = \"\"  # \"stainless_steel\" or \"hardened_steel\"\n    nozzle_diameter: str = \"\"  # e.g., \"0.4\"\n\n\n@dataclass\nclass PrintOptions:\n    \"\"\"AI detection and print options from xcam data.\"\"\"\n\n    # Core AI detectors\n    spaghetti_detector: bool = False\n    print_halt: bool = False\n    halt_print_sensitivity: str = \"medium\"  # Spaghetti sensitivity\n    first_layer_inspector: bool = False\n    printing_monitor: bool = False  # AI print quality monitoring\n    buildplate_marker_detector: bool = False\n    allow_skip_parts: bool = False\n    # Additional AI detectors - decoded from cfg bitmask\n    nozzle_clumping_detector: bool = True\n    nozzle_clumping_sensitivity: str = \"medium\"\n    pileup_detector: bool = True\n    pileup_sensitivity: str = \"medium\"\n    airprint_detector: bool = True\n    airprint_sensitivity: str = \"medium\"\n    auto_recovery_step_loss: bool = True  # Uses print.print_option command\n    filament_tangle_detect: bool = False\n\n\n@dataclass\nclass PrinterState:\n    connected: bool = False\n    state: str = \"unknown\"\n    current_print: str | None = None\n    subtask_name: str | None = None\n    progress: float = 0.0\n    remaining_time: int = 0\n    layer_num: int = 0\n    total_layers: int = 0\n    temperatures: dict = field(default_factory=dict)\n    raw_data: dict = field(default_factory=dict)\n    gcode_file: str | None = None\n    subtask_id: str | None = None\n    hms_errors: list = field(default_factory=list)  # List of HMSError\n    kprofiles: list = field(default_factory=list)  # List of KProfile\n    sdcard: bool = False  # SD card inserted\n    store_to_sdcard: bool = False  # Store sent files on SD card (home_flag bit 11)\n    timelapse: bool = False  # Timelapse recording active\n    ipcam: bool = False  # Live view / camera streaming enabled\n    wifi_signal: int | None = None  # WiFi signal strength in dBm\n    wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)\n    door_open: bool = False  # Enclosure door open (home_flag bit 23, X1/P1S/P2S/H2*)\n    # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)\n    nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])\n    # AI detection and print options\n    print_options: PrintOptions = field(default_factory=PrintOptions)\n    # Calibration stage tracking (from stg_cur and stg fields)\n    stg_cur: int = -1  # Current stage index (-1 = not calibrating)\n    stg: list = field(default_factory=list)  # List of stages to execute\n    # Air conditioning mode (0=cooling, 1=heating)\n    airduct_mode: int = 0\n    # Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)\n    speed_level: int = 2\n    # Chamber light on/off\n    chamber_light: bool = False\n    # Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow\n    active_extruder: int = 0\n    # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers\n    tray_now: int = 255\n    # Last valid tray_now (0-253) — survives unload (255) for usage tracking after print completes\n    last_loaded_tray: int = -1\n    # Pending load target - used to track what tray we're loading for H2D disambiguation\n    pending_tray_target: int | None = None\n    # AMS status for filament change tracking (from print.ams.ams_status field)\n    # ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status\n    # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration, etc.\n    ams_status: int = 0\n    ams_status_main: int = 0  # (ams_status >> 8) & 0xFF\n    ams_status_sub: int = 0  # ams_status & 0xFF\n    # mc_print_sub_stage - filament change step indicator from print.mc_print_sub_stage\n    # Used by OrcaSlicer/BambuStudio to track progress during filament load/unload\n    mc_print_sub_stage: int = 0\n    # AMS mapping for dual nozzle: which slot is active (from ams.ams_exist_bits/tray_exist_bits)\n    ams_mapping: list = field(default_factory=list)\n    # Per-AMS extruder map: {ams_id: extruder_id} where 0=right/main, 1=left/deputy\n    ams_extruder_map: dict = field(default_factory=dict)\n    # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}\n    # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF\n    h2d_extruder_snow: dict = field(default_factory=dict)\n    # H2C nozzle rack: full device.nozzle.info array for tool-changer printers (>2 nozzles)\n    nozzle_rack: list = field(default_factory=list)\n    # Timestamp of last AMS data update (for RFID refresh detection)\n    last_ams_update: float = 0.0\n    # Printable objects for skip object functionality: {identify_id: object_name}\n    printable_objects: dict = field(default_factory=dict)\n    # Objects that have been skipped during the current print\n    skipped_objects: list = field(default_factory=list)\n    # Fan speeds (0-100 percentage, None if not available for this model)\n    cooling_fan_speed: int | None = None  # Part cooling fan\n    big_fan1_speed: int | None = None  # Auxiliary fan\n    big_fan2_speed: int | None = None  # Chamber/exhaust fan\n    heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan\n    # Tray change history during current print: [(global_tray_id, layer_num), ...]\n    # Used by usage tracker to split filament weight on mid-print tray switch\n    tray_change_log: list = field(default_factory=list)\n    # Firmware version info (from info.module[name=\"ota\"].sw_ver)\n    firmware_version: str | None = None\n    # Developer LAN mode: parsed from MQTT \"fun\" field bit 0x20000000\n    # True = dev mode ON (no encryption), False = dev mode OFF (encryption required), None = unknown\n    developer_mode: bool | None = None\n\n\n# Stage name mapping from BambuStudio DeviceManager.cpp\nSTAGE_NAMES = {\n    0: \"Printing\",\n    1: \"Auto bed leveling\",\n    2: \"Heatbed preheating\",\n    3: \"Vibration compensation\",\n    4: \"Changing filament\",\n    5: \"M400 pause\",\n    6: \"Paused (filament ran out)\",\n    7: \"Heating nozzle\",\n    8: \"Calibrating dynamic flow\",\n    9: \"Scanning bed surface\",\n    10: \"Inspecting first layer\",\n    11: \"Identifying build plate type\",\n    12: \"Calibrating Micro Lidar\",\n    13: \"Homing toolhead\",\n    14: \"Cleaning nozzle tip\",\n    15: \"Checking extruder temperature\",\n    16: \"Paused by the user\",\n    17: \"Pause (front cover fall off)\",\n    18: \"Calibrating the micro lidar\",\n    19: \"Calibrating flow ratio\",\n    20: \"Pause (nozzle temperature malfunction)\",\n    21: \"Pause (heatbed temperature malfunction)\",\n    22: \"Filament unloading\",\n    23: \"Pause (step loss)\",\n    24: \"Filament loading\",\n    25: \"Motor noise cancellation\",\n    26: \"Pause (AMS offline)\",\n    27: \"Pause (low speed of the heatbreak fan)\",\n    28: \"Pause (chamber temperature control problem)\",\n    29: \"Cooling chamber\",\n    30: \"Pause (Gcode inserted by user)\",\n    31: \"Motor noise showoff\",\n    32: \"Pause (nozzle clumping)\",\n    33: \"Pause (cutter error)\",\n    34: \"Pause (first layer error)\",\n    35: \"Pause (nozzle clog)\",\n    36: \"Measuring motion precision\",\n    37: \"Enhancing motion precision\",\n    38: \"Measure motion accuracy\",\n    39: \"Nozzle offset calibration\",\n    40: \"High temperature auto bed leveling\",\n    41: \"Auto Check: Quick Release Lever\",\n    42: \"Auto Check: Door and Upper Cover\",\n    43: \"Laser Calibration\",\n    44: \"Auto Check: Platform\",\n    45: \"Confirming BirdsEye Camera location\",\n    46: \"Calibrating BirdsEye Camera\",\n    47: \"Auto bed leveling - phase 1\",\n    48: \"Auto bed leveling - phase 2\",\n    49: \"Heating chamber\",\n    50: \"Cooling heatbed\",\n    51: \"Printing calibration lines\",\n    52: \"Auto Check: Material\",\n    53: \"Live View Camera Calibration\",\n    54: \"Waiting for heatbed temperature\",\n    55: \"Auto Check: Material Position\",\n    56: \"Cutting Module Offset Calibration\",\n    57: \"Measuring Surface\",\n    58: \"Thermal Preconditioning\",\n    59: \"Homing Blade Holder\",\n    60: \"Calibrating Camera Offset\",\n    61: \"Calibrating Blade Holder Position\",\n    62: \"Hotend Pick and Place Test\",\n    63: \"Waiting for Chamber temperature\",\n    64: \"Preparing Hotend\",\n    65: \"Calibrating nozzle clumping detection\",\n    66: \"Purifying the chamber air\",\n    74: \"Preparing\",  # Seen on H2D during print preparation\n    77: \"Preparing AMS\",\n}\n\n\ndef get_stage_name(stage: int) -> str:\n    \"\"\"Get human-readable stage name from stage number.\"\"\"\n    return STAGE_NAMES.get(stage, f\"Unknown stage ({stage})\")\n\n\nclass BambuMQTTClient:\n    \"\"\"MQTT client for Bambu Lab printer communication.\"\"\"\n\n    MQTT_PORT = 8883\n\n    # Class-level cache: serial_number -> False when request topic is known unsupported.\n    # Persists across client instances so reconnects don't re-trigger failed subscriptions.\n    _request_topic_cache: dict[str, bool] = {}\n    # Counter for generating unique MQTT client IDs across instances.\n    _client_instance_counter: int = 0\n\n    def __init__(\n        self,\n        ip_address: str,\n        serial_number: str,\n        access_code: str,\n        model: str | None = None,\n        on_state_change: Callable[[PrinterState], None] | None = None,\n        on_print_start: Callable[[dict], None] | None = None,\n        on_print_complete: Callable[[dict], None] | None = None,\n        on_ams_change: Callable[[list], None] | None = None,\n        on_layer_change: Callable[[int], None] | None = None,\n        on_bed_temp_update: Callable[[float], None] | None = None,\n    ):\n        self.ip_address = ip_address\n        self.serial_number = serial_number\n        self.access_code = access_code\n        self.model = model\n        self.on_state_change = on_state_change\n        self.on_print_start = on_print_start\n        self.on_print_complete = on_print_complete\n        self.on_ams_change = on_ams_change\n        self.on_layer_change = on_layer_change\n        self.on_bed_temp_update = on_bed_temp_update\n\n        self.state = PrinterState()\n        self._client: mqtt.Client | None = None\n        self._loop: asyncio.AbstractEventLoop | None = None\n        self._previous_gcode_state: str | None = None\n        self._previous_gcode_file: str | None = None\n        self._was_running: bool = False  # Track if we've seen RUNNING state for current print\n        self._completion_triggered: bool = False  # Prevent duplicate completion triggers\n        self._timelapse_during_print: bool = False  # Track if timelapse was active during this print\n        self._last_valid_progress: float = 0.0  # Last non-zero progress (firmware resets on cancel)\n        self._last_valid_layer_num: int = 0  # Last non-zero layer (firmware resets on cancel)\n        self._is_dual_nozzle: bool = False  # Set when device.extruder.info has >= 2 entries\n        self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)\n        self._logging_enabled: bool = False\n        self._last_message_time: float = 0.0  # Track when we last received a message\n        self._disconnection_event: threading.Event | None = None\n        self._previous_ams_hash: str | None = None  # Track AMS changes\n\n        # Cache AMS firmware/SN from get_version in case it arrives before AMS status\n        # Key: ams_id (int). Value: {'sw_ver': str, 'sn': str}\n        self._ams_version_cache: dict[int, dict[str, str]] = {}\n\n        # Track which (ams_id, field) warnings have already been emitted this connection\n        # so that missing-serial / missing-firmware warnings fire only once per connection.\n        self._ams_version_warned: set[tuple[int | str, str]] = set()\n\n        # K-profile command tracking\n        self._sequence_id: int = 0\n        self._pending_kprofile_response: asyncio.Event | None = None\n        self._kprofile_response_data: list | None = None\n\n        # Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command\n        # Key: module_name, Value: timestamp when command was sent\n        self._xcam_hold_start: dict[str, float] = {}\n        self._xcam_hold_time: float = 3.0  # Ignore incoming data for 3 seconds after command\n\n        # Track last requested tray ID for H2D dual-nozzle printers\n        # H2D only reports slot number (0-3) in tray_now, not global tray ID\n        # We use our tracked value to resolve the correct global ID\n        self._last_load_tray_id: int | None = None\n\n        # Captured ams_mapping from print commands on the request topic\n        # Intercepts slicer/Bambuddy print commands to get the slot-to-tray mapping\n        self._captured_ams_mapping: list[int] | None = None\n\n        # Request topic subscription tracking\n        # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request\n        # topic by killing the TCP connection. We detect this and gracefully degrade.\n        # Check class-level cache first so new client instances don't retry known-bad subscriptions.\n        self._request_topic_supported: bool = BambuMQTTClient._request_topic_cache.get(self.serial_number, True)\n        self._request_topic_sub_mid: int | None = None\n        self._request_topic_sub_time: float = 0.0\n        self._request_topic_confirmed: bool = False\n\n        # Developer mode probe: when the \"fun\" field is absent (A1/P1 printers),\n        # we probe by sending an ams_filament_setting and checking the response.\n        # \"mqtt message verify failed\" → dev mode OFF, success → dev mode ON.\n        self._dev_mode_probed: bool = False\n        self._dev_mode_needs_probe: bool = False  # True after seeing a pushall without \"fun\"\n        self._dev_mode_probe_seq: str | None = None\n        self._dev_mode_probe_time: float = 0.0  # monotonic timestamp when probe was sent\n        self._dev_mode_probe_failures: int = 0  # consecutive unanswered probes\n        self._connect_time: float = 0.0  # monotonic timestamp of last _on_connect\n\n        # Set when check_staleness() force-closes the socket to trigger reconnect.\n        # Prevents _on_disconnect from redundantly broadcasting state (already done).\n        self._stale_reconnecting: bool = False\n        # Timestamp of last stale reconnect — prevents rapid-fire socket closes\n        # when the frontend polls status faster than paho can reconnect.\n        self._last_stale_reconnect: float = 0.0\n\n        # Zombie session detection via ams_filament_setting response tracking (#887).\n        # The dev-mode probe only runs on first connect; this catches zombie sessions\n        # that develop later (telemetry flows but publishes silently fail).\n        self._last_ams_cmd_time: float = 0.0  # monotonic time of last published command\n        self._ams_cmd_unanswered: int = 0  # consecutive commands with no response\n\n    @property\n    def topic_subscribe(self) -> str:\n        return f\"device/{self.serial_number}/report\"\n\n    @property\n    def topic_publish(self) -> str:\n        return f\"device/{self.serial_number}/request\"\n\n    # Maximum time (seconds) without a message before considering connection stale\n    STALE_TIMEOUT = 60.0\n\n    def is_stale(self) -> bool:\n        \"\"\"Check if the connection is stale (no messages for too long).\"\"\"\n        if self._last_message_time == 0:\n            return False  # Never received a message yet\n        time_since_last = time.time() - self._last_message_time\n        return time_since_last > self.STALE_TIMEOUT\n\n    # Minimum seconds between stale reconnect attempts.  Frontend polls\n    # status every few seconds — without a cooldown, each poll would\n    # force-close the socket before paho has time to reconnect.\n    STALE_RECONNECT_COOLDOWN = 30.0\n\n    def check_staleness(self) -> bool:\n        \"\"\"Check staleness and update connected state if stale. Returns True if connected.\"\"\"\n        if self.state.connected and self.is_stale():\n            # Don't force-close again if we already did recently — give paho\n            # time to reconnect and the printer time to send its first message.\n            now = time.time()\n            if now - self._last_stale_reconnect < self.STALE_RECONNECT_COOLDOWN:\n                return self.state.connected\n\n            logger.warning(\n                f\"[{self.serial_number}] Connection stale - no message for {now - self._last_message_time:.1f}s, forcing reconnect\"\n            )\n            self._last_stale_reconnect = now\n            self.state.connected = False\n            if self.on_state_change:\n                self.on_state_change(self.state)\n            # Force-close the underlying socket so paho's loop thread detects\n            # the broken connection and triggers auto-reconnect.  We don't call\n            # client.disconnect() because that's a clean disconnect and paho\n            # would NOT auto-reconnect afterwards.\n            # Set flag so _on_disconnect knows this was intentional and skips\n            # redundant state broadcast (we already set connected=False above).\n            self._stale_reconnecting = True\n            if self._client:\n                try:\n                    sock = self._client.socket()\n                    if sock:\n                        sock.close()\n                except Exception:\n                    pass  # Best-effort; paho loop will reconnect on next iteration\n        return self.state.connected\n\n    def force_reconnect_stale_session(self, reason: str) -> None:\n        # Heals the #887 half-broken session: telemetry keeps arriving but our\n        # publishes no longer reach the printer. Closing the socket makes paho\n        # drop and re-establish with a fresh session.\n        logger.warning(\"[%s] Forcing MQTT reconnect: %s\", self.serial_number, reason)\n        self._stale_reconnecting = True\n        self.state.connected = False\n        if self.on_state_change:\n            self.on_state_change(self.state)\n        if self._client:\n            try:\n                sock = self._client.socket()\n                if sock:\n                    sock.close()\n            except Exception:\n                pass\n\n    def _on_connect(self, client, userdata, flags, rc, properties=None):\n        if rc == 0:\n            self.state.connected = True\n            self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect\n            # Reset per-connection warning state so warnings fire once per (re)connection\n            self._ams_version_warned = set()\n            # Preserve cached developer_mode across auto-reconnects to avoid\n            # re-probing on every reconnect.  The probe (ams_filament_setting to\n            # ext slot) can destabilize some firmware MQTT brokers, causing a\n            # reconnect → probe → disconnect feedback loop (#887).  Only probe\n            # once when developer_mode is truly unknown (first connect).\n            # Reset probe tracking so stale timeout state doesn't carry over.\n            self._dev_mode_probed = False\n            self._dev_mode_needs_probe = False\n            self._dev_mode_probe_seq = None\n            self._dev_mode_probe_time = 0.0\n            self._dev_mode_probe_failures = 0\n            self._connect_time = time.monotonic()\n            self._last_ams_cmd_time = 0.0\n            self._ams_cmd_unanswered = 0\n            client.subscribe(self.topic_subscribe)\n            # Subscribe to request topic for ams_mapping capture (if supported by broker)\n            if self._request_topic_supported:\n                result, mid = client.subscribe(self.topic_publish)\n                if result == mqtt.MQTT_ERR_SUCCESS:\n                    self._request_topic_sub_mid = mid\n                    self._request_topic_sub_time = time.time()\n                    self._request_topic_confirmed = False\n                else:\n                    logger.warning(\n                        \"[%s] Failed to send request topic subscription\",\n                        self.serial_number,\n                    )\n                    self._request_topic_supported = False\n                    BambuMQTTClient._request_topic_cache[self.serial_number] = False\n            # Request full status update (includes nozzle info in push_status response)\n            self._request_push_all()\n            # Request firmware version info\n            self._request_version()\n            # Note: get_accessories returns stale nozzle data on H2D, so we don't use it.\n            # The correct nozzle data comes from push_status.\n            # Prime K-profile request (Bambu printers often ignore first request)\n            self._prime_kprofile_request()\n            # Immediately broadcast connection state change\n            if self.on_state_change:\n                self.on_state_change(self.state)\n        else:\n            self.state.connected = False\n\n    def _on_subscribe(self, client, userdata, mid, reason_code_list, properties=None):\n        \"\"\"Handle SUBACK responses to detect request topic subscription rejection.\"\"\"\n        if mid == self._request_topic_sub_mid:\n            for rc in reason_code_list:\n                if rc.is_failure:\n                    logger.warning(\n                        \"[%s] Request topic subscription rejected (code=%d: %s). \"\n                        \"ams_mapping capture from slicer-initiated prints unavailable.\",\n                        self.serial_number,\n                        rc.value,\n                        rc.getName(),\n                    )\n                    self._request_topic_supported = False\n                    BambuMQTTClient._request_topic_cache[self.serial_number] = False\n                else:\n                    logger.info(\n                        \"[%s] Request topic subscription accepted. \"\n                        \"ams_mapping capture enabled for slicer-initiated prints.\",\n                        self.serial_number,\n                    )\n                    self._request_topic_confirmed = True\n                    BambuMQTTClient._request_topic_cache[self.serial_number] = True\n            self._request_topic_sub_mid = None\n            self._request_topic_sub_time = 0.0\n\n    def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None):\n        # Always unblock disconnect() callers, regardless of whether we suppress\n        # the state broadcast below.  disconnect() sets _disconnection_event and\n        # waits on it — every callback path must fire it.\n        if self._disconnection_event:\n            self._disconnection_event.set()\n\n        # If we intentionally closed the socket for stale reconnect, don't broadcast\n        # another state change — check_staleness() already set connected=False and\n        # notified the UI.  Just log and let paho auto-reconnect.\n        if self._stale_reconnecting:\n            logger.info(\n                \"[%s] Disconnect callback after stale reconnect (expected), rc=%s\",\n                self.serial_number,\n                rc,\n            )\n            return\n\n        # Ignore spurious disconnect callbacks if we've received a message recently\n        # Paho-mqtt sometimes fires disconnect callbacks while the connection is still active.\n        # BUT: never suppress error disconnects (keepalive timeout, connection lost, etc.)\n        # — only suppress when rc indicates a clean/normal disconnect.\n        is_error_disconnect = rc is not None and hasattr(rc, \"is_failure\") and rc.is_failure\n        time_since_last_message = time.time() - self._last_message_time\n        if not is_error_disconnect and time_since_last_message < 10.0 and self._last_message_time > 0:\n            logger.debug(\n                f\"[{self.serial_number}] Ignoring spurious disconnect (last message {time_since_last_message:.1f}s ago)\"\n            )\n            return\n\n        logger.warning(\"[%s] MQTT disconnected: rc=%s, flags=%s\", self.serial_number, rc, disconnect_flags)\n\n        # Detect if request topic subscription caused the disconnect.\n        # If we just subscribed and got disconnected before any SUBACK confirmation,\n        # the broker likely killed the connection due to the unauthorized subscription.\n        if (\n            self._request_topic_sub_time > 0\n            and not self._request_topic_confirmed\n            and time.time() - self._request_topic_sub_time < 10.0\n        ):\n            logger.warning(\n                \"[%s] Disconnected shortly after request topic subscription. Disabling request topic for this printer.\",\n                self.serial_number,\n            )\n            self._request_topic_supported = False\n            BambuMQTTClient._request_topic_cache[self.serial_number] = False\n        self._request_topic_sub_mid = None\n        self._request_topic_sub_time = 0.0\n\n        self.state.connected = False\n        if self.on_state_change:\n            self.on_state_change(self.state)\n\n    def _on_message(self, client, userdata, msg):\n        try:\n            try:\n                raw = msg.payload.decode()\n            except UnicodeDecodeError:\n                # Some firmware versions (e.g. A1 Mini 01.07.02.00) send payloads\n                # with non-UTF-8 bytes. Replace invalid bytes to keep JSON parseable.\n                raw = msg.payload.decode(errors=\"replace\")\n                logger.warning(\n                    \"[%s] MQTT payload contained non-UTF-8 bytes (topic=%s, len=%d)\",\n                    self.serial_number,\n                    msg.topic,\n                    len(msg.payload),\n                )\n            payload = json.loads(raw)\n            # Track last message time - receiving a message proves we're connected\n            self._last_message_time = time.time()\n            self.state.connected = True\n\n            # Intercept request-topic messages (print commands from slicer/Bambuddy)\n            if msg.topic == self.topic_publish:\n                self._handle_request_message(payload)\n                return\n\n            # Log message if logging is enabled\n            if self._logging_enabled:\n                self._message_log.append(\n                    MQTTLogEntry(\n                        timestamp=datetime.now(timezone.utc).isoformat(),\n                        topic=msg.topic,\n                        direction=\"in\",\n                        payload=payload,\n                    )\n                )\n            self._process_message(payload)\n        except json.JSONDecodeError:\n            pass  # Ignore non-JSON MQTT messages (e.g. binary or malformed payloads)\n\n    def _handle_request_message(self, data: dict) -> None:\n        \"\"\"Intercept print commands on the request topic to capture ams_mapping.\"\"\"\n        print_data = data.get(\"print\", {})\n        if not isinstance(print_data, dict):\n            return\n        command = print_data.get(\"command\", \"\")\n        if command == \"project_file\" and \"ams_mapping\" in print_data:\n            self._captured_ams_mapping = print_data[\"ams_mapping\"]\n            logger.info(\n                \"[%s] Captured ams_mapping from print command: %s\",\n                self.serial_number,\n                self._captured_ams_mapping,\n            )\n\n    def _process_message(self, payload: dict):\n        \"\"\"Process incoming MQTT message from printer.\"\"\"\n        # Handle top-level AMS data (comes outside of \"print\" key)\n        # Wrap in try/except to prevent breaking the MQTT connection\n        if \"ams\" in payload:\n            try:\n                self._handle_ams_data(payload[\"ams\"])\n            except Exception as e:\n                logger.error(\"[%s] Error handling AMS data: %s\", self.serial_number, e)\n\n        # Handle xcam data (camera settings and AI detection) at top level\n        if \"xcam\" in payload:\n            xcam_data = payload[\"xcam\"]\n            logger.debug(\"[%s] Received xcam data at top level: %s\", self.serial_number, xcam_data)\n            self._parse_xcam_data(xcam_data)\n            # Fire state change callback for top-level xcam (not nested in \"print\")\n            if \"print\" not in payload and self.on_state_change:\n                self.on_state_change(self.state)\n\n        # Handle system responses (accessories info, etc.)\n        if \"system\" in payload:\n            system_data = payload[\"system\"]\n            logger.debug(\"[%s] Received system data: %s\", self.serial_number, system_data)\n            self._handle_system_response(system_data)\n\n        # Handle info responses (firmware version info from get_version command)\n        if \"info\" in payload:\n            info_data = payload[\"info\"]\n            if isinstance(info_data, dict) and info_data.get(\"command\") == \"get_version\":\n                self._handle_version_info(info_data)\n\n        # Parse WiFi signal at top level (some printers send it here)\n        if \"wifi_signal\" in payload:\n            wifi_signal = payload[\"wifi_signal\"]\n            if isinstance(wifi_signal, (int, float)):\n                self.state.wifi_signal = int(wifi_signal)\n            elif isinstance(wifi_signal, str):\n                try:\n                    self.state.wifi_signal = int(wifi_signal.replace(\"dBm\", \"\").strip())\n                except ValueError:\n                    pass  # Ignore unparseable wifi_signal strings; field is non-critical\n\n            # Detect ethernet: wifi_signal == -90 is a sentinel for \"WiFi disabled/ethernet\"\n            from backend.app.utils.printer_models import has_ethernet\n\n            if has_ethernet(self.model):\n                self.state.wired_network = self.state.wifi_signal == -90\n\n        # Parse developer LAN mode from top-level \"fun\" field\n        # Some firmware versions send \"fun\" at the top level, others inside \"print\"\n        if \"fun\" in payload:\n            try:\n                fun_val = payload[\"fun\"]\n                fun_int = fun_val if isinstance(fun_val, int) else int(fun_val, 16)\n                self.state.developer_mode = (fun_int & 0x20000000) == 0\n            except (ValueError, TypeError):\n                pass\n\n        if \"print\" in payload:\n            print_data = payload[\"print\"]\n\n            # Check if xcam is nested inside print data\n            if \"xcam\" in print_data:\n                logger.debug(\"[%s] Found xcam inside print data: %s\", self.serial_number, print_data[\"xcam\"])\n                self._parse_xcam_data(print_data[\"xcam\"])\n\n            # Log when we see gcode_state changes\n            if \"gcode_state\" in print_data:\n                logger.debug(\n                    f\"[{self.serial_number}] Received gcode_state: {print_data.get('gcode_state')}, \"\n                    f\"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}\"\n                )\n\n            # Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it)\n            # device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro)\n            if not self._is_dual_nozzle and \"device\" in print_data:\n                dev = print_data.get(\"device\")\n                if isinstance(dev, dict):\n                    ext_info = dev.get(\"extruder\", {}).get(\"info\", [])\n                    if isinstance(ext_info, list) and len(ext_info) >= 2:\n                        self._is_dual_nozzle = True\n                        logger.info(\"[%s] Detected dual-nozzle printer from device.extruder.info\", self.serial_number)\n\n            # Handle AMS data that comes inside print key\n            if \"ams\" in print_data:\n                try:\n                    self._handle_ams_data(print_data[\"ams\"])\n                except Exception as e:\n                    logger.error(\"[%s] Error handling AMS data from print: %s\", self.serial_number, e)\n\n            # Handle vir_slot (H2-series external spool data) — list of external trays\n            # Process vir_slot FIRST so it takes priority over vt_tray\n            if \"vir_slot\" in print_data:\n                vir_slot = print_data[\"vir_slot\"]\n                if isinstance(vir_slot, list) and vir_slot:\n                    # Fix: single-nozzle printers (X1C, P1S, A1) report their single\n                    # external slot with id=255 in vir_slot, but tray_now=254 when active.\n                    # Remap id=255→254 for single-slot printers so active detection works.\n                    # Dual-nozzle (H2D) has 2 slots: id=254 (Ext-L) and id=255 (Ext-R).\n                    if len(vir_slot) == 1 and str(vir_slot[0].get(\"id\", \"\")) == \"255\":\n                        vir_slot[0][\"id\"] = \"254\"\n                    self.state.raw_data[\"vt_tray\"] = vir_slot\n\n            # Handle vt_tray (virtual tray / external spool) data\n            # Only use vt_tray if vir_slot is NOT in this message AND we don't already\n            # have vir_slot data (H2-series sends vt_tray as a single active spool dict\n            # which would overwrite the correct multi-slot vir_slot data)\n            if \"vt_tray\" in print_data and \"vir_slot\" not in print_data:\n                vt_tray = print_data[\"vt_tray\"]\n                existing = self.state.raw_data.get(\"vt_tray\")\n                # Don't let a single-spool vt_tray dict overwrite multi-slot vir_slot data\n                if isinstance(vt_tray, dict) and isinstance(existing, list) and len(existing) > 1:\n                    pass  # Keep the vir_slot data\n                else:\n                    if isinstance(vt_tray, dict):\n                        vt_tray = [vt_tray]\n                    self.state.raw_data[\"vt_tray\"] = vt_tray\n\n            # Parse ams_status directly from print data (NOT from print.ams)\n            # ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status\n            # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration\n            # Sub status (when main=1): 2=heating, 3=AMS feeding, 4=retract, 6=push, 7=purge\n            if \"ams_status\" in print_data:\n                raw_ams_status = print_data[\"ams_status\"]\n                if isinstance(raw_ams_status, str):\n                    try:\n                        self.state.ams_status = int(raw_ams_status)\n                    except ValueError:\n                        self.state.ams_status = 0\n                else:\n                    self.state.ams_status = raw_ams_status if raw_ams_status is not None else 0\n\n                # Compute main and sub status\n                self.state.ams_status_sub = self.state.ams_status & 0xFF\n                self.state.ams_status_main = (self.state.ams_status >> 8) & 0xFF\n\n                # Log when ams_status changes (for filament change tracking debug)\n                logger.debug(\n                    f\"[{self.serial_number}] ams_status: {self.state.ams_status} \"\n                    f\"(main={self.state.ams_status_main}, sub={self.state.ams_status_sub})\"\n                )\n\n            # Check for command responses\n            if \"command\" in print_data:\n                cmd = print_data.get(\"command\")\n                logger.debug(\"[%s] Received command response: %s\", self.serial_number, cmd)\n                if cmd in (\"extrusion_cali_sel\", \"extrusion_cali_set\", \"extrusion_cali_del\", \"ams_filament_setting\"):\n                    logger.debug(\"[%s] %s response: %s\", self.serial_number, cmd, print_data)\n                # Check for developer mode probe response\n                if (\n                    cmd == \"ams_filament_setting\"\n                    and self._dev_mode_probe_seq is not None\n                    and print_data.get(\"sequence_id\") == self._dev_mode_probe_seq\n                ):\n                    self._handle_dev_mode_probe_response(print_data)\n                # Track user-initiated ams_filament_setting responses (#887 zombie detection)\n                elif cmd == \"ams_filament_setting\" and self._last_ams_cmd_time > 0:\n                    self._last_ams_cmd_time = 0.0\n                    self._ams_cmd_unanswered = 0\n            if \"command\" in print_data and print_data.get(\"command\") == \"extrusion_cali_get\":\n                self._handle_kprofile_response(print_data)\n\n            self._update_state(print_data)\n\n    def _handle_system_response(self, data: dict):\n        \"\"\"Handle system responses including accessories info.\n\n        Note: get_accessories returns stale/incorrect nozzle_type data on H2D.\n        The correct nozzle data comes from push_status, so we don't update\n        nozzle type/diameter from get_accessories. We just log the response\n        for debugging purposes.\n        \"\"\"\n        command = data.get(\"command\")\n\n        if command == \"get_accessories\":\n            # Log response for debugging - but DON'T use it to update nozzle data\n            # because it returns stale values (e.g., 'stainless_steel' when the\n            # actual nozzle is 'HH01' hardened steel high-flow)\n            logger.debug(\"[%s] Accessories response (not used for nozzle data): %s\", self.serial_number, data)\n\n    def _handle_version_info(self, data: dict):\n        \"\"\"Handle version info response from get_version command.\n\n        Parses firmware version from the 'ota' module in the module list.\n        Also extracts AMS unit firmware versions from AMS modules and stores\n        them on the corresponding AMS unit in raw_data so the status route can\n        expose them to the frontend.\n\n        AMS module naming conventions (numeric suffix is the AMS unit ID):\n        - ``ams/<id>``  – original AMS\n        - ``n3f/<id>``  – AMS 2 Pro (H2D Pro and similar)\n        - ``n3s/<id>``  – AMS HT (H2D Pro and similar)\n\n        Message format:\n        {\n            \"command\": \"get_version\",\n            \"module\": [\n                {\"name\": \"ota\", \"sw_ver\": \"01.08.05.00\"},\n                {\"name\": \"rv1126\", \"sw_ver\": \"00.00.14.74\"},\n                {\"name\": \"ams/0\", \"sw_ver\": \"00.00.06.96\", \"sn\": \"ABC123\"},\n                {\"name\": \"n3f/0\", \"sw_ver\": \"03.00.21.29\", \"sn\": \"19C06A552504488\"},\n                {\"name\": \"n3s/128\", \"sw_ver\": \"03.00.21.29\", \"sn\": \"19F06A561801096\"},\n                ...\n            ]\n        }\n        \"\"\"\n        modules = data.get(\"module\", [])\n        if not isinstance(modules, list):\n            return\n\n        state_changed = False\n        for module in modules:\n            if not isinstance(module, dict):\n                continue\n            if module.get(\"name\") == \"ota\":\n                version = module.get(\"sw_ver\")\n                if version:\n                    old_version = self.state.firmware_version\n                    self.state.firmware_version = version\n                    if old_version != version:\n                        logger.info(\"[%s] Firmware version: %s\", self.serial_number, version)\n                    state_changed = True\n                break\n\n        # Extract AMS unit firmware versions from AMS modules.\n        # See module-level _AMS_MODULE_PREFIXES for supported naming conventions.\n        # Always cache regardless of whether AMS data has arrived yet — get_version\n        # often arrives before the first push_status, so caching must be unconditional.\n        ams_raw = self.state.raw_data.get(\"ams\")\n        for module in modules:\n            if not isinstance(module, dict):\n                continue\n            name = module.get(\"name\", \"\")\n            if not any(name.startswith(prefix) for prefix in _AMS_MODULE_PREFIXES):\n                continue\n            try:\n                ams_id = int(name.split(\"/\", 1)[1])\n            except (ValueError, IndexError):\n                continue\n            sw_ver = module.get(\"sw_ver\", \"\")\n            sn = module.get(\"sn\", \"\")\n\n            # Extract module type from prefix (e.g. \"ams/0\" → \"ams\", \"n3f/0\" → \"n3f\")\n            module_type = name.split(\"/\", 1)[0]\n\n            # Always cache so _apply_ams_version_cache can apply it when AMS data arrives\n            if sw_ver or sn or module_type:\n                self._ams_version_cache[ams_id] = {\"sw_ver\": sw_ver, \"sn\": sn, \"module_type\": module_type}\n                state_changed = True\n\n            # Also directly update any AMS unit already present in raw_data\n            if ams_raw and isinstance(ams_raw, list):\n                for ams_unit in ams_raw:\n                    if not isinstance(ams_unit, dict):\n                        continue\n                    try:\n                        unit_id = int(ams_unit.get(\"id\")) if ams_unit.get(\"id\") is not None else None\n                    except (ValueError, TypeError):\n                        unit_id = None\n                    if unit_id == ams_id:\n                        if sw_ver:\n                            ams_unit[\"sw_ver\"] = sw_ver\n                            logger.debug(\"[%s] AMS %s firmware: %s\", self.serial_number, ams_id, sw_ver)\n                        # Only set sn from version info if not already present in AMS data\n                        if sn and not ams_unit.get(\"sn\"):\n                            ams_unit[\"sn\"] = sn\n                        if module_type:\n                            ams_unit[\"module_type\"] = module_type\n                        break\n\n        # Trigger state change callback AFTER both loops so AMS sn/sw_ver are\n        # included in the broadcast (not just the printer firmware version).\n        if state_changed and self.on_state_change:\n            self.on_state_change(self.state)\n\n        # Warn if any AMS unit is still missing serial number or firmware version\n        # after processing the version info response. Warn only once per connection\n        # to avoid repeated noise on older firmware that doesn't report these fields.\n        if ams_raw and isinstance(ams_raw, list):\n            for ams_unit in ams_raw:\n                if not isinstance(ams_unit, dict):\n                    continue\n                ams_id = ams_unit.get(\"id\", \"?\")\n                if not ams_unit.get(\"sn\") and not ams_unit.get(\"serial_number\"):\n                    key = (ams_id, \"sn\")\n                    if key not in self._ams_version_warned:\n                        self._ams_version_warned.add(key)\n                        logger.warning(\n                            \"[%s] AMS unit %s: serial number not available in version info\",\n                            self.serial_number,\n                            ams_id,\n                        )\n                if not ams_unit.get(\"sw_ver\"):\n                    key = (ams_id, \"sw_ver\")\n                    if key not in self._ams_version_warned:\n                        self._ams_version_warned.add(key)\n                        logger.warning(\n                            \"[%s] AMS unit %s: firmware version not available in version info\",\n                            self.serial_number,\n                            ams_id,\n                        )\n\n    def _apply_ams_version_cache(self, ams_list: list) -> None:\n        \"\"\"Apply cached AMS firmware/SN (from get_version) onto an AMS list in-place.\n\n        get_version may arrive before pushall/AMS status, and AMS unit IDs may be\n        strings in MQTT payloads. This helper normalizes IDs and fills missing\n        sw_ver/sn fields without overwriting values already present.\n        \"\"\"\n        if not ams_list or not isinstance(ams_list, list):\n            return\n        cache = self._ams_version_cache\n        if not cache:\n            return\n        for unit in ams_list:\n            if not isinstance(unit, dict):\n                continue\n            raw_id = unit.get(\"id\")\n            try:\n                unit_id = int(raw_id) if raw_id is not None else None\n            except (ValueError, TypeError):\n                unit_id = None\n            if unit_id is None:\n                continue\n            cached = cache.get(unit_id)\n            if not cached:\n                continue\n            sw_ver = cached.get(\"sw_ver\") or \"\"\n            sn = cached.get(\"sn\") or \"\"\n            if sw_ver and not unit.get(\"sw_ver\"):\n                unit[\"sw_ver\"] = sw_ver\n            # Only set sn if not already present in AMS data\n            if sn and not unit.get(\"sn\") and not unit.get(\"serial_number\"):\n                unit[\"sn\"] = sn\n            module_type = cached.get(\"module_type\") or \"\"\n            if module_type and not unit.get(\"module_type\"):\n                unit[\"module_type\"] = module_type\n\n    def _parse_xcam_data(self, xcam_data):\n        \"\"\"Parse xcam data for camera settings and AI detection options.\"\"\"\n        if not isinstance(xcam_data, dict):\n            return\n\n        current_time = time.time()\n\n        # Helper to check if we should accept incoming value for a module\n        # OrcaSlicer pattern: simple hold timer, ignore ALL data for 3 seconds after command\n        def should_accept_value(module_name: str, incoming_value: bool) -> bool:\n            \"\"\"Check if we should accept an incoming xcam value.\n\n            OrcaSlicer pattern: After sending a command, ignore incoming data\n            for 3 seconds. After that, accept whatever the printer sends.\n            \"\"\"\n            if module_name not in self._xcam_hold_start:\n                return True  # No hold timer, accept incoming\n\n            hold_start = self._xcam_hold_start[module_name]\n            elapsed = current_time - hold_start\n\n            if elapsed > self._xcam_hold_time:\n                # Hold timer expired - accept incoming and clear hold\n                del self._xcam_hold_start[module_name]\n                logger.debug(\"[%s] Hold expired for %s, accepting %s\", self.serial_number, module_name, incoming_value)\n                return True\n\n            # Within hold period - ignore incoming data\n            logger.debug(\n                f\"[{self.serial_number}] Ignoring {module_name}={incoming_value} \"\n                f\"(hold active, {elapsed:.1f}s < {self._xcam_hold_time}s)\"\n            )\n            return False\n\n        # Log all xcam fields for debugging\n        logger.debug(\"[%s] Parsing xcam data - all fields: %s\", self.serial_number, list(xcam_data.keys()))\n\n        # The cfg bitmask contains the ACTUAL detector states - the individual boolean\n        # fields (spaghetti_detector, etc.) are often stale/cached.\n        # CFG bitmask structure (each detector uses 3 bits: [sens_low, sens_high, enabled]):\n        # - Bits 5-7: spaghetti_detector (sens in 5-6, enabled in 7)\n        # - Bits 8-10: pileup_detector (sens in 8-9, enabled in 10)\n        # - Bits 11-13: clump_detector/nozzle_clumping (sens in 11-12, enabled in 13)\n        # - Bits 14-16: airprint_detector (sens in 14-15, enabled in 16)\n        # Sensitivity values: 0=low, 1=medium, 2=high\n        if \"cfg\" in xcam_data:\n            cfg = xcam_data[\"cfg\"]\n            logger.debug(\"[%s] xcam cfg bitmask: %s (binary: %s)\", self.serial_number, cfg, bin(cfg))\n\n            def decode_detector(start_bit):\n                \"\"\"Decode a detector from cfg: returns (enabled, sensitivity_str)\"\"\"\n                sens_bits = (cfg >> start_bit) & 0x3\n                enabled = bool((cfg >> (start_bit + 2)) & 1)\n                sensitivity = {0: \"low\", 1: \"medium\", 2: \"high\"}.get(sens_bits, \"medium\")\n                return enabled, sensitivity\n\n            # Spaghetti detector (bits 5-7)\n            cfg_spaghetti, cfg_sensitivity = decode_detector(5)\n            if should_accept_value(\"spaghetti_detector\", cfg_spaghetti):\n                old_value = self.state.print_options.spaghetti_detector\n                if cfg_spaghetti != old_value:\n                    logger.debug(\n                        f\"[{self.serial_number}] spaghetti_detector changed (from cfg): {old_value} -> {cfg_spaghetti}\"\n                    )\n                self.state.print_options.spaghetti_detector = cfg_spaghetti\n\n            # Check hold timer for sensitivity before accepting\n            if \"halt_print_sensitivity\" not in self._xcam_hold_start:\n                if cfg_sensitivity != self.state.print_options.halt_print_sensitivity:\n                    logger.debug(\n                        f\"[{self.serial_number}] Sensitivity changed (from cfg): \"\n                        f\"{self.state.print_options.halt_print_sensitivity} -> {cfg_sensitivity}\"\n                    )\n                    self.state.print_options.halt_print_sensitivity = cfg_sensitivity\n            else:\n                hold_start = self._xcam_hold_start[\"halt_print_sensitivity\"]\n                elapsed = current_time - hold_start\n                if elapsed <= self._xcam_hold_time:\n                    logger.debug(\n                        f\"[{self.serial_number}] Ignoring cfg sensitivity={cfg_sensitivity} \"\n                        f\"(hold active, {elapsed:.1f}s < {self._xcam_hold_time}s)\"\n                    )\n                else:\n                    # Hold expired - accept from cfg\n                    if cfg_sensitivity != self.state.print_options.halt_print_sensitivity:\n                        logger.debug(\n                            f\"[{self.serial_number}] Sensitivity synced (from cfg after hold): \"\n                            f\"{self.state.print_options.halt_print_sensitivity} -> {cfg_sensitivity}\"\n                        )\n                        self.state.print_options.halt_print_sensitivity = cfg_sensitivity\n                    del self._xcam_hold_start[\"halt_print_sensitivity\"]\n\n            # Pileup detector (bits 8-10)\n            cfg_pileup, cfg_pileup_sens = decode_detector(8)\n            if should_accept_value(\"pileup_detector\", cfg_pileup):\n                if cfg_pileup != self.state.print_options.pileup_detector:\n                    logger.debug(\n                        f\"[{self.serial_number}] pileup_detector changed (from cfg): {self.state.print_options.pileup_detector} -> {cfg_pileup}\"\n                    )\n                    self.state.print_options.pileup_detector = cfg_pileup\n            # Pileup sensitivity with hold timer\n            if \"pileup_sensitivity\" not in self._xcam_hold_start:\n                if cfg_pileup_sens != self.state.print_options.pileup_sensitivity:\n                    logger.debug(\n                        f\"[{self.serial_number}] pileup_sensitivity changed (from cfg): {self.state.print_options.pileup_sensitivity} -> {cfg_pileup_sens}\"\n                    )\n                    self.state.print_options.pileup_sensitivity = cfg_pileup_sens\n            else:\n                hold_start = self._xcam_hold_start[\"pileup_sensitivity\"]\n                elapsed = current_time - hold_start\n                if elapsed > self._xcam_hold_time:\n                    if cfg_pileup_sens != self.state.print_options.pileup_sensitivity:\n                        logger.debug(\n                            f\"[{self.serial_number}] pileup_sensitivity synced (from cfg after hold): {self.state.print_options.pileup_sensitivity} -> {cfg_pileup_sens}\"\n                        )\n                        self.state.print_options.pileup_sensitivity = cfg_pileup_sens\n                    del self._xcam_hold_start[\"pileup_sensitivity\"]\n\n            # Clump/nozzle clumping detector (bits 11-13)\n            cfg_clump, cfg_clump_sens = decode_detector(11)\n            if should_accept_value(\"clump_detector\", cfg_clump):\n                if cfg_clump != self.state.print_options.nozzle_clumping_detector:\n                    logger.debug(\n                        f\"[{self.serial_number}] nozzle_clumping_detector changed (from cfg): {self.state.print_options.nozzle_clumping_detector} -> {cfg_clump}\"\n                    )\n                    self.state.print_options.nozzle_clumping_detector = cfg_clump\n            # Clump sensitivity with hold timer\n            if \"nozzle_clumping_sensitivity\" not in self._xcam_hold_start:\n                if cfg_clump_sens != self.state.print_options.nozzle_clumping_sensitivity:\n                    logger.debug(\n                        f\"[{self.serial_number}] nozzle_clumping_sensitivity changed (from cfg): {self.state.print_options.nozzle_clumping_sensitivity} -> {cfg_clump_sens}\"\n                    )\n                    self.state.print_options.nozzle_clumping_sensitivity = cfg_clump_sens\n            else:\n                hold_start = self._xcam_hold_start[\"nozzle_clumping_sensitivity\"]\n                elapsed = current_time - hold_start\n                if elapsed > self._xcam_hold_time:\n                    if cfg_clump_sens != self.state.print_options.nozzle_clumping_sensitivity:\n                        logger.debug(\n                            f\"[{self.serial_number}] nozzle_clumping_sensitivity synced (from cfg after hold): {self.state.print_options.nozzle_clumping_sensitivity} -> {cfg_clump_sens}\"\n                        )\n                        self.state.print_options.nozzle_clumping_sensitivity = cfg_clump_sens\n                    del self._xcam_hold_start[\"nozzle_clumping_sensitivity\"]\n\n            # Airprint detector (bits 14-16)\n            cfg_airprint, cfg_airprint_sens = decode_detector(14)\n            if should_accept_value(\"airprint_detector\", cfg_airprint):\n                if cfg_airprint != self.state.print_options.airprint_detector:\n                    logger.debug(\n                        f\"[{self.serial_number}] airprint_detector changed (from cfg): {self.state.print_options.airprint_detector} -> {cfg_airprint}\"\n                    )\n                    self.state.print_options.airprint_detector = cfg_airprint\n            # Airprint sensitivity with hold timer\n            if \"airprint_sensitivity\" not in self._xcam_hold_start:\n                if cfg_airprint_sens != self.state.print_options.airprint_sensitivity:\n                    logger.debug(\n                        f\"[{self.serial_number}] airprint_sensitivity changed (from cfg): {self.state.print_options.airprint_sensitivity} -> {cfg_airprint_sens}\"\n                    )\n                    self.state.print_options.airprint_sensitivity = cfg_airprint_sens\n            else:\n                hold_start = self._xcam_hold_start[\"airprint_sensitivity\"]\n                elapsed = current_time - hold_start\n                if elapsed > self._xcam_hold_time:\n                    if cfg_airprint_sens != self.state.print_options.airprint_sensitivity:\n                        logger.debug(\n                            f\"[{self.serial_number}] airprint_sensitivity synced (from cfg after hold): {self.state.print_options.airprint_sensitivity} -> {cfg_airprint_sens}\"\n                        )\n                        self.state.print_options.airprint_sensitivity = cfg_airprint_sens\n                    del self._xcam_hold_start[\"airprint_sensitivity\"]\n\n        # Camera settings\n        if \"ipcam_record\" in xcam_data:\n            self.state.ipcam = xcam_data.get(\"ipcam_record\") == \"enable\"\n        if \"timelapse\" in xcam_data:\n            self.state.timelapse = xcam_data.get(\"timelapse\") == \"enable\"\n            # Track if timelapse was ever active during this print\n            if self.state.timelapse and self._was_running:\n                self._timelapse_during_print = True\n\n        # Skip spaghetti_detector boolean field - we read from cfg bitmask above\n        if \"print_halt\" in xcam_data:\n            self.state.print_options.print_halt = bool(xcam_data.get(\"print_halt\"))\n        # Skip halt_print_sensitivity field - it's always stale (\"medium\")\n        # We read the actual sensitivity from cfg bits 5-6 above\n        if \"first_layer_inspector\" in xcam_data:\n            new_value = bool(xcam_data.get(\"first_layer_inspector\"))\n            if should_accept_value(\"first_layer_inspector\", new_value):\n                self.state.print_options.first_layer_inspector = new_value\n        if \"printing_monitor\" in xcam_data:\n            new_value = bool(xcam_data.get(\"printing_monitor\"))\n            if should_accept_value(\"printing_monitor\", new_value):\n                self.state.print_options.printing_monitor = new_value\n        if \"buildplate_marker_detector\" in xcam_data:\n            new_value = bool(xcam_data.get(\"buildplate_marker_detector\"))\n            if should_accept_value(\"buildplate_marker_detector\", new_value):\n                self.state.print_options.buildplate_marker_detector = new_value\n        if \"allow_skip_parts\" in xcam_data:\n            new_value = bool(xcam_data.get(\"allow_skip_parts\"))\n            if should_accept_value(\"allow_skip_parts\", new_value):\n                self.state.print_options.allow_skip_parts = new_value\n\n        # Additional AI detectors - these are decoded from cfg bitmask above, not from\n        # individual boolean fields (which are not sent by the printer)\n        # pileup_detector, nozzle_clumping_detector, airprint_detector - from cfg\n        # auto_recovery_step_loss and filament_tangle_detect - tracked locally only\n        if \"auto_recovery_step_loss\" in xcam_data:\n            self.state.print_options.auto_recovery_step_loss = bool(xcam_data.get(\"auto_recovery_step_loss\"))\n        if \"filament_tangle_detect\" in xcam_data:\n            self.state.print_options.filament_tangle_detect = bool(xcam_data.get(\"filament_tangle_detect\"))\n\n    @staticmethod\n    def _resolve_local_slot_from_mapping(local_slot: int, mapping_raw: list | None) -> int | None:\n        \"\"\"Resolve a local AMS slot ID to a global tray ID using the MQTT mapping field.\n\n        The MQTT mapping field is an array of snow-encoded values:\n        each entry = ams_hw_id * 256 + slot_id (65535 = unmapped).\n\n        Finds entries where the local slot matches, then computes the global tray ID.\n        Returns the global ID if exactly one AMS matches, or None if ambiguous/unavailable.\n        \"\"\"\n        if not isinstance(mapping_raw, list) or not mapping_raw:\n            return None\n\n        candidates: set[int] = set()\n        for value in mapping_raw:\n            if not isinstance(value, int) or value >= 65535:\n                continue\n            ams_hw_id = value >> 8\n            slot = value & 0xFF\n            if 0 <= ams_hw_id <= 3 and (slot & 0x03) == local_slot:\n                candidates.add(ams_hw_id * 4 + local_slot)\n            elif 128 <= ams_hw_id <= 135 and local_slot == 0:\n                candidates.add(ams_hw_id)\n\n        if len(candidates) == 1:\n            return candidates.pop()\n        return None\n\n    def _handle_ams_data(self, ams_data):\n        \"\"\"Handle AMS data changes for Spoolman integration.\n\n        This is called when we receive top-level AMS data in MQTT messages.\n        It detects changes and triggers the callback for Spoolman sync.\n        \"\"\"\n        import hashlib\n\n        # Handle nested ams structure: {\"ams\": {\"ams\": [...]}} or {\"ams\": [...]}\n        # Also handle P1S partial updates: {\"tray_now\": ..., \"tray_tar\": ...} without \"ams\" key\n        ams_list = None\n        if isinstance(ams_data, dict):\n            if \"ams\" in ams_data:\n                ams_list = ams_data[\"ams\"]\n            # Log all AMS dict fields to debug tray_now for H2D dual-nozzle\n            non_list_fields = {k: v for k, v in ams_data.items() if k != \"ams\"}\n            if non_list_fields:\n                logger.debug(\"[%s] AMS dict fields: %s\", self.serial_number, non_list_fields)\n\n            # IMPORTANT: Parse ams_status FIRST before tray_now, so we have fresh status\n            # when checking if we're in filament change mode for tray_now disambiguation\n            if \"ams_status\" in ams_data:\n                raw_ams_status = ams_data[\"ams_status\"]\n                if isinstance(raw_ams_status, str):\n                    try:\n                        self.state.ams_status = int(raw_ams_status)\n                    except ValueError:\n                        self.state.ams_status = 0\n                else:\n                    self.state.ams_status = raw_ams_status if raw_ams_status is not None else 0\n                # Compute main and sub status\n                self.state.ams_status_sub = self.state.ams_status & 0xFF\n                self.state.ams_status_main = (self.state.ams_status >> 8) & 0xFF\n                logger.debug(\n                    f\"[{self.serial_number}] ams_status: {self.state.ams_status} \"\n                    f\"(main={self.state.ams_status_main}, sub={self.state.ams_status_sub})\"\n                )\n\n            # Parse tray_now from AMS dict - this is the currently loaded tray global ID\n            # Note: tray_tar is also available but on H2D it's just slot number (0-3), not global ID\n            if \"tray_now\" in ams_data:\n                raw_tray_now = ams_data[\"tray_now\"]\n                # Convert string to int if needed\n                if isinstance(raw_tray_now, str):\n                    try:\n                        parsed_tray_now = int(raw_tray_now)\n                    except ValueError:\n                        parsed_tray_now = 255\n                else:\n                    parsed_tray_now = raw_tray_now if raw_tray_now is not None else 255\n\n                # H2D dual-nozzle printers report only slot number (0-3), not global tray ID\n                # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to\n                # Single-nozzle printers with multiple AMS (e.g. P2S) also report local slot IDs (#420)\n                # — disambiguated below using MQTT mapping field\n                ams_map = self.state.ams_extruder_map\n                if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:\n                    # First, check if we have a pending target that matches this slot\n                    pending_target = self.state.pending_tray_target\n                    if pending_target is not None:\n                        pending_slot = pending_target % 4\n                        if pending_slot == parsed_tray_now:\n                            # Slot matches our pending target - use the full global ID\n                            logger.debug(\n                                f\"[{self.serial_number}] H2D tray_now disambiguation: \"\n                                f\"slot {parsed_tray_now} matches pending_tray_target {pending_target} -> using global ID {pending_target}\"\n                            )\n                            self.state.tray_now = pending_target\n                            # Clear pending target now that load is confirmed\n                            self.state.pending_tray_target = None\n                        else:\n                            # Slot doesn't match our pending target - something changed, use slot as-is\n                            logger.warning(\n                                f\"[{self.serial_number}] H2D tray_now: slot {parsed_tray_now} doesn't match \"\n                                f\"pending_tray_target {pending_target} (slot {pending_slot}) - using slot as global ID\"\n                            )\n                            self.state.tray_now = parsed_tray_now\n                            # Clear pending target since it's stale\n                            self.state.pending_tray_target = None\n                    else:\n                        # No pending target - use h2d_extruder_snow for accurate disambiguation\n                        # H2D sends snow field in device.extruder.info with AMS ID in high byte\n                        active_ext = self.state.active_extruder  # 0=right, 1=left\n\n                        # Best source: use snow value from device.extruder.info if available\n                        snow_tray = self.state.h2d_extruder_snow.get(active_ext)\n                        if snow_tray is not None and snow_tray != 255:\n                            # snow_tray is already normalized to global ID\n                            # Verify the slot matches what we see in tray_now\n                            # Regular AMS: slot = global_id % 4; AMS HT (128-135): single slot = 0\n                            snow_slot = snow_tray % 4 if snow_tray < 128 else (0 if snow_tray <= 135 else -1)\n                            if snow_slot == parsed_tray_now:\n                                if self.state.tray_now != snow_tray:\n                                    logger.debug(\n                                        f\"[{self.serial_number}] H2D tray_now from snow: \"\n                                        f\"extruder[{active_ext}] snow={snow_tray} (slot {snow_slot})\"\n                                    )\n                                self.state.tray_now = snow_tray\n                            else:\n                                # Slot mismatch - snow field may not have updated yet, trust snow\n                                logger.debug(\n                                    f\"[{self.serial_number}] H2D tray_now: ams.tray_now slot {parsed_tray_now} \"\n                                    f\"!= snow slot {snow_slot}, using snow value {snow_tray}\"\n                                )\n                                self.state.tray_now = snow_tray\n                        else:\n                            # Fallback: snow not available, use ams_extruder_map (less reliable)\n                            # Find ALL AMS units on the active extruder\n                            ams_on_extruder = []\n                            for ams_id_str, ext_id in ams_map.items():\n                                if ext_id == active_ext:\n                                    try:\n                                        ams_on_extruder.append(int(ams_id_str))\n                                    except ValueError:\n                                        pass  # Skip AMS IDs that aren't valid integers\n\n                            if len(ams_on_extruder) == 1:\n                                # Single AMS on this extruder - unambiguous\n                                active_ams_id = ams_on_extruder[0]\n                                if 128 <= active_ams_id <= 135:\n                                    # AMS-HT: single slot per unit, global ID = unit ID\n                                    global_tray_id = active_ams_id\n                                else:\n                                    global_tray_id = active_ams_id * 4 + parsed_tray_now\n                                logger.debug(\n                                    f\"[{self.serial_number}] H2D tray_now fallback: \"\n                                    f\"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {global_tray_id}\"\n                                )\n                                self.state.tray_now = global_tray_id\n                            elif len(ams_on_extruder) > 1:\n                                # Multiple AMS on this extruder - keep current if valid, else try to narrow down\n                                current_tray = self.state.tray_now\n                                # Determine which AMS unit and slot the current tray belongs to\n                                if 0 <= current_tray <= 15:\n                                    current_ams = current_tray // 4\n                                    current_slot = current_tray % 4\n                                elif 128 <= current_tray <= 135:\n                                    current_ams = current_tray  # AMS-HT: ID = tray ID\n                                    current_slot = 0\n                                else:\n                                    current_ams = -1\n                                    current_slot = -1\n                                if current_ams in ams_on_extruder and current_slot == parsed_tray_now:\n                                    # Current is valid and matches slot - keep it\n                                    logger.debug(\n                                        f\"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, \"\n                                        f\"keeping current {current_tray} (matches slot {parsed_tray_now})\"\n                                    )\n                                else:\n                                    # Filter candidates: AMS-HT (128-135) only valid for slot 0\n                                    if parsed_tray_now > 0:\n                                        candidates = [a for a in ams_on_extruder if a <= 3]\n                                    else:\n                                        candidates = ams_on_extruder\n                                    if len(candidates) == 1:\n                                        cand = candidates[0]\n                                        resolved = cand if 128 <= cand <= 135 else cand * 4 + parsed_tray_now\n                                        logger.debug(\n                                            f\"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, \"\n                                            f\"narrowed to AMS {cand} -> global ID {resolved}\"\n                                        )\n                                        self.state.tray_now = resolved\n                                    else:\n                                        # Genuinely ambiguous - use slot as-is (will be wrong for non-first AMS)\n                                        logger.warning(\n                                            f\"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder} on extruder {active_ext}, \"\n                                            f\"no snow field, using slot {parsed_tray_now} (may be incorrect)\"\n                                        )\n                                        self.state.tray_now = parsed_tray_now\n                            else:\n                                # No AMS on this extruder - use slot as-is\n                                logger.warning(\n                                    f\"[{self.serial_number}] H2D tray_now: no AMS on extruder {active_ext}, \"\n                                    f\"using slot {parsed_tray_now}\"\n                                )\n                                self.state.tray_now = parsed_tray_now\n                elif not self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:\n                    # Single-nozzle printer with tray_now in 0-3 range.\n                    # P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs\n                    # in tray_now, not global tray IDs (#420). Use the MQTT mapping field\n                    # (snow-encoded) to resolve the correct AMS unit.\n                    ams_exist_raw = ams_data.get(\"ams_exist_bits\", \"0\")\n                    try:\n                        ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)\n                    except (ValueError, TypeError):\n                        ams_exist = 0\n                    num_ams = bin(ams_exist).count(\"1\")\n\n                    if num_ams > 1:\n                        # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.\n                        # Cross-reference with MQTT mapping field to find the correct AMS unit.\n                        mapping_raw = self.state.raw_data.get(\"mapping\")\n                        resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)\n                        if resolved is not None:\n                            if resolved != parsed_tray_now:\n                                logger.debug(\n                                    f\"[{self.serial_number}] Multi-AMS tray_now: \"\n                                    f\"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)\"\n                                )\n                            self.state.tray_now = resolved\n                        else:\n                            # No mapping available (not printing, or ambiguous) — use as-is.\n                            # This matches the old behavior and is correct for AMS 0.\n                            self.state.tray_now = parsed_tray_now\n                    else:\n                        # Single AMS — local slot 0-3 equals global ID\n                        self.state.tray_now = parsed_tray_now\n                else:\n                    # tray_now > 3 means it's already a global ID, or 255 means unloaded\n                    # Note: Do NOT clear pending_tray_target on tray_now=255 here.\n                    # During filament change, the printer sends 255 first (unload), then the slot.\n                    # We only clear pending_tray_target explicitly in ams_unload_filament().\n                    # Trust the printer's reported value.\n                    self.state.tray_now = parsed_tray_now\n\n                # Track last valid tray for usage tracking (survives retract → 255 at print end)\n                # Valid physical trays: 0-15 (regular AMS), 128-135 (AMS-HT), 254 (external spool)\n                tn = self.state.tray_now\n                if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:\n                    # Log tray change for mid-print usage splitting\n                    if tn != self.state.last_loaded_tray and self.state.state in (\"RUNNING\", \"PAUSE\"):\n                        self.state.tray_change_log.append((tn, self.state.layer_num))\n                        logger.info(\n                            \"[%s] Tray change during print: tray=%d at layer=%d\",\n                            self.serial_number,\n                            tn,\n                            self.state.layer_num,\n                        )\n                    self.state.last_loaded_tray = self.state.tray_now\n\n                logger.debug(\"[%s] tray_now updated: %s\", self.serial_number, self.state.tray_now)\n\n            # NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct\n            # state when checking filament change mode for H2D disambiguation\n\n            # P1S/P1P send partial updates without \"ams\" key - this is valid, not an error\n            # We've already processed the status fields above, so just return if no ams list\n            if ams_list is None:\n                logger.debug(\"[%s] AMS partial update (no tray data)\", self.serial_number)\n                return\n        elif isinstance(ams_data, list):\n            ams_list = ams_data\n        else:\n            logger.warning(\"[%s] Unexpected AMS data format: %s\", self.serial_number, type(ams_data))\n            return\n\n        # Merge AMS data instead of replacing, to handle partial updates\n        # During prints, the printer may only send updates for active AMS units\n        # We need deep merging at the tray level to preserve fields like tray_sub_brands\n        existing_ams = self.state.raw_data.get(\"ams\", [])\n        existing_by_id = {ams.get(\"id\"): ams for ams in existing_ams if ams.get(\"id\") is not None}\n\n        # Update existing units with new data, add new units\n        for ams_unit in ams_list:\n            ams_id = ams_unit.get(\"id\")\n            if ams_id is not None:\n                existing_unit = existing_by_id.get(ams_id)\n                if existing_unit and \"tray\" in ams_unit:\n                    # Deep merge trays to preserve fields from previous updates\n                    existing_trays = {t.get(\"id\"): t for t in existing_unit.get(\"tray\", []) if t.get(\"id\") is not None}\n                    merged_trays = []\n                    for new_tray in ams_unit.get(\"tray\", []):\n                        tray_id = new_tray.get(\"id\")\n                        if tray_id is not None and tray_id in existing_trays:\n                            # Merge: start with existing, update with new non-empty values\n                            merged_tray = existing_trays[tray_id].copy()\n                            # Detect slot-clearing updates (spool removal):\n                            # When tray_type is explicitly empty, clear everything\n                            # including RFID data (tag_uid/tray_uuid).\n                            slot_clearing = new_tray.get(\"tray_type\") == \"\"\n                            # Some printers (e.g. H2D) only send {id, state} in\n                            # incremental updates when a tray is not fully loaded.\n                            # state=11 means loaded; other values (9=empty,\n                            # 10=spool present but filament not in feeder) indicate\n                            # the slot should be cleared.  Without this, old\n                            # tray_type/tray_color persist indefinitely (#784).\n                            tray_state = new_tray.get(\"state\")\n                            if (\n                                tray_state is not None\n                                and tray_state != 11\n                                and \"tray_type\" not in new_tray\n                                and merged_tray.get(\"tray_type\")\n                            ):\n                                logger.info(\n                                    \"[%s] AMS %s tray %s: state=%s (not loaded) — clearing stale tray data\",\n                                    self.serial_number,\n                                    ams_id,\n                                    tray_id,\n                                    tray_state,\n                                )\n                                slot_clearing = True\n                                # The incremental update only has {id, state} — inject\n                                # empty values for all content fields so the merge loop\n                                # below clears the stale data from merged_tray.\n                                new_tray.update(\n                                    {\n                                        \"tray_type\": \"\",\n                                        \"tray_sub_brands\": \"\",\n                                        \"tray_color\": \"\",\n                                        \"tray_id_name\": \"\",\n                                        \"tray_info_idx\": \"\",\n                                        \"tag_uid\": \"0000000000000000\",\n                                        \"tray_uuid\": \"00000000000000000000000000000000\",\n                                        \"remain\": 0,\n                                        \"k\": None,\n                                        \"cali_idx\": None,\n                                    }\n                                )\n                            for key, value in new_tray.items():\n                                # Fields that should always be updated (even with empty/zero values):\n                                # - remain, k, id, cali_idx: status indicators where 0 is valid\n                                # - tray_type, tray_sub_brands, tray_info_idx, tray_color,\n                                #   tray_id_name: slot content indicators that must be cleared\n                                #   when a spool is removed (fixes #147 - old AMS empty slot)\n                                # NOTE: tag_uid and tray_uuid are NOT in always_update_fields.\n                                # They are only cleared during spool removal (slot_clearing=True).\n                                # Periodic AMS updates often include empty RFID fields which\n                                # would overwrite valid data from the initial pushall.\n                                always_update_fields = (\n                                    \"remain\",\n                                    \"k\",\n                                    \"id\",\n                                    \"cali_idx\",\n                                    \"tray_type\",\n                                    \"tray_sub_brands\",\n                                    \"tray_info_idx\",\n                                    \"tray_color\",\n                                    \"tray_id_name\",\n                                )\n                                if (\n                                    key in always_update_fields\n                                    or slot_clearing\n                                    or value\n                                    not in (\n                                        None,\n                                        \"\",\n                                        \"0000000000000000\",\n                                        \"00000000000000000000000000000000\",\n                                    )\n                                ):\n                                    merged_tray[key] = value\n                            merged_trays.append(merged_tray)\n                        else:\n                            merged_trays.append(new_tray)\n                    # Update ams_unit with merged trays\n                    ams_unit = {**ams_unit, \"tray\": merged_trays}\n                elif existing_unit:\n                    # Partial update without tray data: merge new fields into existing\n                    # unit to preserve tray, sn, sw_ver, and other accumulated data.\n                    ams_unit = {**existing_unit, **ams_unit}\n                existing_by_id[ams_id] = ams_unit\n\n        # Convert back to list, sorted by ID for consistent ordering\n        merged_ams = sorted(existing_by_id.values(), key=lambda x: x.get(\"id\", 0))\n\n        # Check tray_exist_bits to clear empty slots (Issue #147)\n        # New AMS models don't send empty tray data - they just update tray_exist_bits\n        # Each bit in tray_exist_bits represents a slot: bit=0 means empty, bit=1 means has spool\n        # Skip when power_on_flag=False: printer shutdown sends all-zero bits which would\n        # wipe all slot data and cause auto-unlink to remove spool assignments (#765)\n        tray_exist_bits_str = ams_data.get(\"tray_exist_bits\") if isinstance(ams_data, dict) else None\n        power_on = ams_data.get(\"power_on_flag\", True) if isinstance(ams_data, dict) else True\n        if tray_exist_bits_str and power_on:\n            try:\n                tray_exist_bits = int(tray_exist_bits_str, 16)\n                for ams_unit in merged_ams:\n                    ams_id_raw = ams_unit.get(\"id\")\n                    if ams_id_raw is None:\n                        continue\n                    # Convert to int (may be string from JSON)\n                    ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw\n                    if ams_id >= 128:  # Skip HT AMS (id >= 128)\n                        continue\n                    # Bits for this AMS unit: bits (ams_id*4) to (ams_id*4 + 3)\n                    for tray in ams_unit.get(\"tray\", []):\n                        tray_id_raw = tray.get(\"id\")\n                        if tray_id_raw is None:\n                            continue\n                        # Convert to int (may be string from JSON)\n                        tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw\n                        global_bit = ams_id * 4 + tray_id\n                        slot_exists = (tray_exist_bits >> global_bit) & 1\n                        if not slot_exists and tray.get(\"tray_type\"):\n                            # Slot is marked empty but has data - clear it\n                            logger.debug(\n                                f\"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} \"\n                                f\"(tray_exist_bits bit {global_bit} = 0)\"\n                            )\n                            tray[\"tray_type\"] = \"\"\n                            tray[\"tray_sub_brands\"] = \"\"\n                            tray[\"tray_color\"] = \"\"\n                            tray[\"tray_id_name\"] = \"\"\n                            tray[\"tag_uid\"] = \"0000000000000000\"\n                            tray[\"tray_uuid\"] = \"00000000000000000000000000000000\"\n                            tray[\"tray_info_idx\"] = \"\"\n                            tray[\"remain\"] = 0\n            except (ValueError, TypeError) as e:\n                logger.debug(\"[%s] Could not parse tray_exist_bits: %s\", self.serial_number, e)\n\n        self.state.raw_data[\"ams\"] = merged_ams\n\n        # Apply cached AMS firmware/SN from get_version (handles ordering and id type mismatches)\n        self._apply_ams_version_cache(merged_ams)\n        # Update timestamp for RFID refresh detection (frontend can detect \"new data arrived\")\n        self.state.last_ams_update = time.time()\n        logger.debug(\"[%s] Merged AMS data: %s new units, %s total\", self.serial_number, len(ams_list), len(merged_ams))\n\n        # Extract ams_extruder_map from each AMS unit's info field\n        # BambuStudio DevFilaSystem.cpp parses info as hex string:\n        #   type_id    = get_flag_bits(info, 0, 4)   // bits 0-3: AMS type\n        #   extruder_id = get_flag_bits(info, 8, 4)  // bits 8-11: extruder assignment\n        # where get_flag_bits uses std::stoull(str, nullptr, 16) — hex parsing.\n        # extruder_id: 0=right/main, 1=left/deputy, 0xE=uninitialized (skip)\n        #\n        # Use merged_ams (not ams_list) to avoid partial MQTT updates overwriting\n        # the full map. Merge into existing map to preserve entries from prior updates.\n\n        ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}\n        for ams_unit in merged_ams:\n            ams_id = ams_unit.get(\"id\")\n            info = ams_unit.get(\"info\")\n            if ams_id is not None and info is not None:\n                try:\n                    # info is a hex-encoded string in MQTT JSON (e.g. \"10001003\")\n                    info_val = int(str(info), 16)\n                    # Extract 4 bits starting at bit 8 for extruder assignment\n                    extruder_id = (info_val >> 8) & 0xF\n                    if extruder_id == 0xE:\n                        # 0xE = uninitialized AMS, skip\n                        continue\n                    ams_extruder_map[str(ams_id)] = extruder_id\n                    logger.debug(f\"[{self.serial_number}] AMS {ams_id} info=0x{info} -> extruder {extruder_id}\")\n                except (ValueError, TypeError):\n                    pass  # Skip AMS units with unparseable info bitmask values\n        if ams_extruder_map:\n            self.state.raw_data[\"ams_extruder_map\"] = ams_extruder_map\n            self.state.ams_extruder_map = ams_extruder_map\n            logger.debug(\"[%s] ams_extruder_map: %s\", self.serial_number, ams_extruder_map)\n\n        # Extract drying status from info hex string and dry_sf_reason per AMS unit\n        # BambuStudio DevFilaSystem.cpp parses info bits:\n        #   dry_status     = get_flag_bits(info, 4, 4)   // bits 4-7\n        #   dry_sub_status = get_flag_bits(info, 22, 4)  // bits 22-25\n        for ams_unit in merged_ams:\n            info = ams_unit.get(\"info\")\n            if info is not None:\n                try:\n                    info_val = int(str(info), 16)\n                    ams_unit[\"dry_status\"] = (info_val >> 4) & 0xF\n                    ams_unit[\"dry_sub_status\"] = (info_val >> 22) & 0xF\n                except (ValueError, TypeError):\n                    pass  # Skip unparseable info values\n            # dry_sf_reason is a per-unit array of cannot-dry reason codes\n            if \"dry_sf_reason\" in ams_unit:\n                sf_reason = ams_unit[\"dry_sf_reason\"]\n                if isinstance(sf_reason, list):\n                    ams_unit[\"dry_sf_reason\"] = [\n                        int(r) for r in sf_reason if isinstance(r, int) or (isinstance(r, str) and r.isdigit())\n                    ]\n                else:\n                    ams_unit[\"dry_sf_reason\"] = []\n\n        # Persist updated drying fields back to raw_data\n        self.state.raw_data[\"ams\"] = merged_ams\n\n        # Create a hash of relevant AMS data to detect changes\n        ams_hash_data = []\n        for ams_unit in ams_list:\n            for tray in ams_unit.get(\"tray\", []):\n                # Include fields that matter for filament tracking\n                ams_hash_data.append(\n                    f\"{ams_unit.get('id')}:{tray.get('id')}:\"\n                    f\"{tray.get('tray_type')}:{tray.get('tag_uid')}:{tray.get('remain')}\"\n                )\n        ams_hash = hashlib.md5(\":\".join(ams_hash_data).encode(), usedforsecurity=False).hexdigest()\n\n        # Only trigger callback if AMS data actually changed\n        if ams_hash != self._previous_ams_hash:\n            self._previous_ams_hash = ams_hash\n            if self.on_ams_change:\n                logger.debug(\"[%s] AMS data changed, triggering sync callback\", self.serial_number)\n                # Pass merged AMS data (not raw ams_list) — partial MQTT updates\n                # may lack fields like 'remain' that the merged state preserves\n                self.on_ams_change(merged_ams)\n\n    def _update_state(self, data: dict):\n        \"\"\"Update printer state from message data.\"\"\"\n        _previous_state = self.state.state\n\n        # Update state fields\n        if \"gcode_state\" in data:\n            self.state.state = data[\"gcode_state\"]\n        if \"gcode_file\" in data:\n            self.state.gcode_file = data[\"gcode_file\"]\n            self.state.current_print = data[\"gcode_file\"]\n        if \"subtask_name\" in data:\n            self.state.subtask_name = data[\"subtask_name\"]\n            # Prefer subtask_name as current_print if available\n            if data[\"subtask_name\"]:\n                self.state.current_print = data[\"subtask_name\"]\n        if \"subtask_id\" in data:\n            self.state.subtask_id = data[\"subtask_id\"]\n        if \"mc_percent\" in data:\n            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)\n            if self.state.progress > 0:\n                self._last_valid_progress = self.state.progress\n            self.state.progress = float(data[\"mc_percent\"])\n        if \"mc_remaining_time\" in data:\n            self.state.remaining_time = int(data[\"mc_remaining_time\"])\n        if \"mc_print_sub_stage\" in data:\n            new_sub_stage = int(data[\"mc_print_sub_stage\"])\n            if new_sub_stage != self.state.mc_print_sub_stage:\n                logger.debug(\n                    f\"[{self.serial_number}] mc_print_sub_stage changed: \"\n                    f\"{self.state.mc_print_sub_stage} -> {new_sub_stage}\"\n                )\n            self.state.mc_print_sub_stage = new_sub_stage\n        if \"layer_num\" in data:\n            new_layer = int(data[\"layer_num\"])\n            old_layer = self.state.layer_num\n            # Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)\n            if old_layer > 0:\n                self._last_valid_layer_num = old_layer\n            self.state.layer_num = new_layer\n            # Trigger layer change callback if layer increased\n            if new_layer > old_layer and self.on_layer_change:\n                self.on_layer_change(new_layer)\n        if \"total_layer_num\" in data:\n            self.state.total_layers = int(data[\"total_layer_num\"])\n\n        # Fan speeds (MQTT sends as string \"0\"-\"15\" representing speed levels, or percentage)\n        # Convert to 0-100 percentage for display\n        def parse_fan_speed(value: str | int | None) -> int | None:\n            if value is None:\n                return None\n            try:\n                speed = int(value)\n                # MQTT reports 0-15 speed levels, convert to percentage (0-100)\n                # 15 = 100%, so multiply by 100/15 ≈ 6.67\n                if speed <= 15:\n                    return round(speed * 100 / 15)\n                # If already a percentage (0-255 scale from some printers), convert\n                elif speed <= 255:\n                    return round(speed * 100 / 255)\n                return speed\n            except (ValueError, TypeError):\n                return None\n\n        # Log fan fields once for debugging\n        if not hasattr(self, \"_fan_fields_logged\"):\n            fan_fields = {k: v for k, v in data.items() if \"fan\" in k.lower()}\n            if fan_fields:\n                logger.debug(\"[%s] Fan fields in MQTT data: %s\", self.serial_number, fan_fields)\n                self._fan_fields_logged = True\n\n        if \"cooling_fan_speed\" in data:\n            self.state.cooling_fan_speed = parse_fan_speed(data[\"cooling_fan_speed\"])\n        if \"big_fan1_speed\" in data:\n            self.state.big_fan1_speed = parse_fan_speed(data[\"big_fan1_speed\"])\n        if \"big_fan2_speed\" in data:\n            self.state.big_fan2_speed = parse_fan_speed(data[\"big_fan2_speed\"])\n        if \"heatbreak_fan_speed\" in data:\n            self.state.heatbreak_fan_speed = parse_fan_speed(data[\"heatbreak_fan_speed\"])\n\n        # Calibration stage tracking\n        if \"stg_cur\" in data:\n            new_stg = data[\"stg_cur\"]\n            # Always log ANY stg_cur change for debugging filament operations\n            if new_stg != self.state.stg_cur:\n                logger.debug(\n                    f\"[{self.serial_number}] stg_cur changed: {self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})\"\n                )\n            self.state.stg_cur = new_stg\n        if \"stg\" in data:\n            self.state.stg = data[\"stg\"] if isinstance(data[\"stg\"], list) else []\n\n        # Temperature data\n        temps = {}\n        # Log all fields for debugging dual-nozzle temperature discovery (only once)\n        if \"bed_temper\" in data and not hasattr(self, \"_temp_fields_logged\"):\n            temp_fields = {k: v for k, v in data.items() if \"temp\" in k.lower() or \"chamber\" in k.lower()}\n            logger.debug(\"[%s] Temperature-related fields: %s\", self.serial_number, temp_fields)\n            # Log ALL keys in print data for H2D temperature discovery\n            all_keys = sorted(data.keys())\n            logger.debug(\"[%s] ALL print data keys (%s): %s\", self.serial_number, len(all_keys), all_keys)\n            self._temp_fields_logged = True\n\n        # Log vir_slot data (once) - this may contain per-extruder slot mapping for H2D\n        if \"vir_slot\" in data and not hasattr(self, \"_vir_slot_logged\"):\n            logger.debug(\"[%s] vir_slot data: %s\", self.serial_number, data[\"vir_slot\"])\n            self._vir_slot_logged = True\n\n        # Log nozzle hardware info fields (once)\n        nozzle_fields = {\n            k: v\n            for k, v in data.items()\n            if \"nozzle\" in k.lower() or \"hw\" in k.lower() or \"extruder\" in k.lower() or \"upgrade\" in k.lower()\n        }\n        if nozzle_fields and not hasattr(self, \"_nozzle_fields_logged\"):\n            logger.debug(\"[%s] Nozzle/hardware fields in MQTT data: %s\", self.serial_number, nozzle_fields)\n            self._nozzle_fields_logged = True\n        # Parse active extruder from device.extruder.state bit 8\n        # bit 8 = 0 → RIGHT extruder (active_extruder=0)\n        # bit 8 = 1 → LEFT extruder (active_extruder=1)\n        if \"device\" in data and isinstance(data.get(\"device\"), dict):\n            device = data[\"device\"]\n            if \"extruder\" in device and \"state\" in device[\"extruder\"]:\n                state_val = device[\"extruder\"][\"state\"]\n                # Extract bit 8 for extruder position\n                new_extruder = (state_val >> 8) & 0x1\n                if new_extruder != self.state.active_extruder:\n                    logger.debug(\n                        f\"[{self.serial_number}] ACTIVE EXTRUDER CHANGED (state bit 8): {self.state.active_extruder} -> {new_extruder} (0=right, 1=left) [state={state_val}]\"\n                    )\n                    self.state.active_extruder = new_extruder\n\n        # Log device.extruder structure for active extruder\n        if \"device\" in data and isinstance(data.get(\"device\"), dict):\n            device = data[\"device\"]\n            if \"extruder\" in device:\n                ext_data = device[\"extruder\"]\n                # Log 'state' field - OrcaSlicer uses bits 12-14 for switch state\n                if \"state\" in ext_data:\n                    state_val = ext_data[\"state\"]\n                    # Extract bits 12-14 (3 bits) for switch state\n                    switch_state = (state_val >> 12) & 0x7\n                    logger.debug(\n                        f\"[{self.serial_number}] device.extruder.state={state_val} (switch_state bits 12-14: {switch_state})\"\n                    )\n                # Log 'cur' field if present (might indicate current/active extruder)\n                if \"cur\" in ext_data:\n                    logger.debug(\"[%s] device.extruder.cur: %s\", self.serial_number, ext_data[\"cur\"])\n        if \"bed_temper\" in data:\n            temps[\"bed\"] = float(data[\"bed_temper\"])\n        if \"bed_target_temper\" in data:\n            temps[\"bed_target\"] = float(data[\"bed_target_temper\"])\n        # Check if this is H2D (has device.extruder.info with 2 extruders)\n        has_h2d_extruder_info = (\n            \"device\" in data\n            and isinstance(data.get(\"device\"), dict)\n            and \"extruder\" in data[\"device\"]\n            and isinstance(data[\"device\"][\"extruder\"].get(\"info\"), list)\n            and len(data[\"device\"][\"extruder\"][\"info\"]) >= 2\n        )\n\n        # Standard nozzle fields: these are for the RIGHT/default nozzle on H2D\n        # For H2D, we use these for nozzle_2 (RIGHT), for others use as nozzle (primary)\n        # NOTE: On H2D, nozzle_temper seems to mirror left nozzle - we override with extruder_info[0] later\n        if \"nozzle_temper\" in data:\n            if has_h2d_extruder_info:\n                temps[\"nozzle_2\"] = float(data[\"nozzle_temper\"])  # Will be overridden by extruder_info[0]\n            else:\n                temps[\"nozzle\"] = float(data[\"nozzle_temper\"])\n        if \"nozzle_target_temper\" in data:\n            if has_h2d_extruder_info:\n                temps[\"nozzle_2_target\"] = float(data[\"nozzle_target_temper\"])  # RIGHT target on H2D\n            else:\n                temps[\"nozzle_target\"] = float(data[\"nozzle_target_temper\"])\n        # Second nozzle for dual-extruder printers - skip for H2D (uses device.extruder.info instead)\n        if not has_h2d_extruder_info:\n            # Try multiple possible field names used by different firmware versions\n            if \"nozzle_temper_2\" in data:\n                val = float(data[\"nozzle_temper_2\"])\n                if -50 < val < 500:  # Valid temp range\n                    temps[\"nozzle_2\"] = val\n                else:\n                    logger.debug(\"[%s] nozzle_temper_2=%s out of range\", self.serial_number, val)\n            elif \"right_nozzle_temper\" in data:\n                val = float(data[\"right_nozzle_temper\"])\n                if -50 < val < 500:  # Valid temp range\n                    temps[\"nozzle_2\"] = val\n                else:\n                    logger.debug(\"[%s] right_nozzle_temper=%s out of range\", self.serial_number, val)\n            if \"nozzle_target_temper_2\" in data:\n                val = float(data[\"nozzle_target_temper_2\"])\n                if 0 <= val < 500:  # Valid temp range\n                    temps[\"nozzle_2_target\"] = val\n                else:\n                    logger.debug(\"[%s] nozzle_target_temper_2=%s out of range\", self.serial_number, val)\n            elif \"right_nozzle_target_temper\" in data:\n                val = float(data[\"right_nozzle_target_temper\"])\n                if 0 <= val < 500:  # Valid temp range\n                    temps[\"nozzle_2_target\"] = val\n                else:\n                    logger.debug(\"[%s] right_nozzle_target_temper=%s out of range\", self.serial_number, val)\n            # Also check for left nozzle as primary (some H2 models)\n            if \"left_nozzle_temper\" in data and \"nozzle\" not in temps:\n                temps[\"nozzle\"] = float(data[\"left_nozzle_temper\"])\n            if \"left_nozzle_target_temper\" in data and \"nozzle_target\" not in temps:\n                temps[\"nozzle_target\"] = float(data[\"left_nozzle_target_temper\"])\n        if \"chamber_temper\" in data:\n            chamber_val = float(data[\"chamber_temper\"])\n            logger.debug(\"[%s] chamber_temper raw value: %s\", self.serial_number, chamber_val)\n            # Check if we recently set the target locally (within 5 seconds)\n            local_set_time = self.state.temperatures.get(\"_chamber_target_set_time\", 0)\n            respect_local = (time.time() - local_set_time) < 5.0\n            # H2D protocol: chamber_temper encoding indicates heater state\n            # - When > 500: encoded as (target * 65536 + current) - heater is ON\n            # - When < 500: direct Celsius current temp only - heater is OFF\n            if -50 < chamber_val < 100:\n                # Direct value = heater is OFF\n                temps[\"chamber\"] = chamber_val\n                if not respect_local:\n                    temps[\"chamber_target\"] = 0.0  # Heater off means target = 0\n                    logger.debug(\"[%s] chamber_temper direct value: %s°C (heater OFF)\", self.serial_number, chamber_val)\n            else:\n                logger.debug(\"[%s] chamber_temper %s out of direct range\", self.serial_number, chamber_val)\n                # Try to decode if it looks like an encoded value\n                if chamber_val > 500:\n                    mqtt_target = int(chamber_val) // 65536\n                    current = int(chamber_val) % 65536\n                    logger.debug(\n                        f\"[{self.serial_number}] chamber_temper decoded: mqtt_target={mqtt_target}, current={current}, respect_local={respect_local}\"\n                    )\n                    if -50 < current < 100:\n                        temps[\"chamber\"] = float(current)\n                    # Store decoded target for later use, but DON'T set chamber_heating here!\n                    # Heating state will be calculated later after parsing ctc.info.target (explicit target)\n                    # which is the authoritative source the slicer uses.\n                    if not respect_local:\n                        if 0 <= mqtt_target <= 60:\n                            # Store as \"decoded\" target - may be overridden by explicit target fields\n                            temps[\"_chamber_decoded_target\"] = float(mqtt_target)\n        # Chamber target temperature (set by print file or display)\n        if \"mc_target_cham\" in data:\n            mc_target = float(data[\"mc_target_cham\"])\n            logger.debug(\"[%s] mc_target_cham raw value: %s\", self.serial_number, mc_target)\n            # Filter out encoded/invalid values - valid chamber target is 0-60°C\n            if 0 <= mc_target <= 60:\n                temps[\"chamber_target\"] = mc_target\n        # H2D series: Chamber temp is in info.temp (may be encoded or direct °C)\n        # NOTE: Don't set chamber_heating here - let ctc.info.target or fallback logic handle it\n        # The encoded target in info.temp may be stale (slicer uses ctc.info.target as source of truth)\n        try:\n            if \"info\" in data and isinstance(data[\"info\"], dict):\n                info_temp = data[\"info\"].get(\"temp\")\n                if info_temp is not None and \"chamber\" not in temps:\n                    # Check for encoded value (target * 65536 + current)\n                    if info_temp > 500:\n                        # Decode: extract current temperature and target\n                        target = info_temp // 65536\n                        current = info_temp % 65536\n                        temps[\"chamber\"] = float(current)\n                        # Store decoded target as fallback (may be overridden by ctc.info.target)\n                        if \"_chamber_decoded_target\" not in temps:\n                            temps[\"_chamber_decoded_target\"] = float(target)\n                        logger.debug(\n                            f\"[{self.serial_number}] info.temp encoded: {info_temp} -> current={current}, decoded_target={target}\"\n                        )\n                    elif -50 < info_temp < 100:\n                        # Valid direct temperature - heater is OFF\n                        temps[\"chamber\"] = float(info_temp)\n                        temps[\"chamber_target\"] = 0.0  # Direct value means heater off\n                        logger.debug(\"[%s] info.temp direct: %s°C (heater OFF)\", self.serial_number, info_temp)\n            # H2D series: Dual extruder temps are in device.extruder.info array\n            # Temperature values are encoded as fixed-point (value / 65536 = °C)\n            if \"device\" in data and isinstance(data[\"device\"], dict):\n                device = data[\"device\"]\n                # Parse dual extruder temperatures\n                extruder_data = device.get(\"extruder\", {})\n                extruder_info = extruder_data.get(\"info\", [])\n                if isinstance(extruder_info, list) and len(extruder_info) >= 1:\n                    # H2D nozzle mapping: id=0 is RIGHT nozzle (default), id=1 is LEFT nozzle\n                    # Only parse dual nozzle temps if this is actually a dual nozzle printer (H2D)\n                    # has_h2d_extruder_info requires len(extruder_info) >= 2\n                    if has_h2d_extruder_info:\n                        # Right nozzle (extruder 0) - use extruder_info for actual temp, not nozzle_temper\n                        # nozzle_temper field seems to mirror left nozzle on H2D, so use extruder_info[0]\n                        if \"temp\" in extruder_info[0]:\n                            temp_val = extruder_info[0][\"temp\"]\n                            if temp_val > 500:\n                                # Encoded format: temp = target * 65536 + current\n                                target = temp_val // 65536\n                                current = temp_val % 65536\n                                if -50 < current < 500:\n                                    temps[\"nozzle_2\"] = float(current)\n                                if 0 < target < 500:\n                                    temps[\"nozzle_2_target\"] = float(target)\n                                temps[\"nozzle_2_heating\"] = target > 0 and current < target\n                            elif -50 < temp_val < 500:\n                                # Direct Celsius value = heater is OFF\n                                temps[\"nozzle_2\"] = float(temp_val)\n                                temps[\"nozzle_2_target\"] = 0.0\n                                temps[\"nozzle_2_heating\"] = False\n                    # Left nozzle (extruder 1) - only for dual nozzle printers\n                    # H2D protocol: temp field encoding depends on value\n                    # - When > 500: encoded as (target * 65536 + current) - heater is ON\n                    # - When < 500: direct Celsius current temp only - heater is OFF\n                    if len(extruder_info) >= 2 and \"temp\" in extruder_info[1]:\n                        ext1 = extruder_info[1]\n                        temp_val = ext1[\"temp\"]\n\n                        # Check if we recently set the target locally (within 5 seconds)\n                        # If so, don't let MQTT data overwrite it\n                        local_set_time = self.state.temperatures.get(\"_nozzle_target_set_time\", 0)\n                        respect_local_target = (time.time() - local_set_time) < 5.0\n\n                        if temp_val > 500:\n                            # Encoded format: temp = target * 65536 + current\n                            target = temp_val // 65536\n                            current = temp_val % 65536\n                            if 0 < target < 500 and not respect_local_target:\n                                temps[\"nozzle_target\"] = float(target)\n                            if -50 < current < 500:\n                                temps[\"nozzle\"] = float(current)\n                            # Heating = encoded AND we're using the MQTT target (not local override)\n                            # If local target is being respected, use local target to determine heating\n                            if respect_local_target:\n                                local_target = self.state.temperatures.get(\"nozzle_target\", 0)\n                                temps[\"nozzle_heating\"] = local_target > 0 and current < local_target\n                            else:\n                                temps[\"nozzle_heating\"] = target > 0 and current < target\n                        elif -50 < temp_val < 500:\n                            # Direct Celsius = heater is OFF (or at target with heater off)\n                            temps[\"nozzle\"] = float(temp_val)\n                            if not respect_local_target:\n                                temps[\"nozzle_target\"] = 0.0\n                            temps[\"nozzle_heating\"] = False  # Direct = not heating\n                    # Parse H2D snow field (slot now) for accurate tray_now disambiguation\n                    # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF\n                    if has_h2d_extruder_info:\n                        for ext_info in extruder_info:\n                            ext_id = ext_info.get(\"id\")\n                            snow = ext_info.get(\"snow\")\n                            if ext_id is not None and snow is not None and ext_id <= 1:\n                                # Normalize H2D snow value to global tray ID\n                                ams_id = snow >> 8\n                                slot = snow & 0xFF\n                                if 0 <= ams_id <= 3:\n                                    # Regular AMS slot\n                                    global_tray = ams_id * 4 + (slot & 0x03)\n                                    old_val = self.state.h2d_extruder_snow.get(ext_id)\n                                    if old_val != global_tray:\n                                        logger.debug(\n                                            f\"[{self.serial_number}] H2D extruder[{ext_id}] snow: \"\n                                            f\"raw={snow} (AMS {ams_id} slot {slot}) -> global tray {global_tray}\"\n                                        )\n                                    self.state.h2d_extruder_snow[ext_id] = global_tray\n                                elif ams_id == 254 or ams_id == 255:\n                                    # External spool or unloaded\n                                    normalized = 254 if slot != 255 else 255\n                                    old_val = self.state.h2d_extruder_snow.get(ext_id)\n                                    if old_val != normalized:\n                                        logger.debug(\n                                            f\"[{self.serial_number}] H2D extruder[{ext_id}] snow: \"\n                                            f\"raw={snow} -> {'external' if normalized == 254 else 'unloaded'}\"\n                                        )\n                                    self.state.h2d_extruder_snow[ext_id] = normalized\n                                elif 128 <= ams_id <= 135:\n                                    # External spool with hub mapping\n                                    old_val = self.state.h2d_extruder_snow.get(ext_id)\n                                    if old_val != ams_id:\n                                        logger.debug(\n                                            f\"[{self.serial_number}] H2D extruder[{ext_id}] snow: \"\n                                            f\"raw={snow} -> external hub {ams_id}\"\n                                        )\n                                    self.state.h2d_extruder_snow[ext_id] = ams_id\n                # Parse bed heating state from device.bed.info.temp encoding\n                # temp > 500 means encoded (target*65536+current), heating = target > 0 AND current < target\n                bed_data = device.get(\"bed\", {})\n                bed_info = bed_data.get(\"info\", {})\n                if \"temp\" in bed_info:\n                    temp_val = bed_info[\"temp\"]\n                    if temp_val > 500:\n                        target = temp_val // 65536\n                        current = temp_val % 65536\n                        temps[\"bed_heating\"] = target > 0 and current < target\n                    else:\n                        temps[\"bed_heating\"] = False\n                # Parse chamber temp from device.ctc.info.temp if not already set\n                ctc_data = device.get(\"ctc\", {})\n                ctc_info = ctc_data.get(\"info\", {})\n                # Parse airduct mode (0=cooling, 1=heating)\n                airduct_data = device.get(\"airduct\", {})\n                if \"modeCur\" in airduct_data:\n                    new_mode = airduct_data[\"modeCur\"]\n                    if new_mode != self.state.airduct_mode:\n                        logger.debug(\n                            f\"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}\"\n                        )\n                    self.state.airduct_mode = new_mode\n                # Parse chamber temp - may be encoded as (target*65536+current) when > 500\n                # Check if we recently set the target locally (within 5 seconds)\n                local_set_time = self.state.temperatures.get(\"_chamber_target_set_time\", 0)\n                respect_local_target = (time.time() - local_set_time) < 5.0\n\n                # Log ctc_info contents for debugging\n                if ctc_info:\n                    logger.debug(\"[%s] ctc_info keys: %s\", self.serial_number, list(ctc_info.keys()))\n\n                # FIRST: Parse explicit ctc.info.target if available - this is the authoritative target\n                # (what the slicer shows). This OVERRIDES any previously decoded target.\n                explicit_target = None\n                if \"target\" in ctc_info:\n                    target_val = ctc_info[\"target\"]\n                    logger.debug(\n                        f\"[{self.serial_number}] ctc_info.target explicit value: {target_val}, respect_local={respect_local_target}\"\n                    )\n                    # Filter out invalid values (valid chamber target is 0-60°C)\n                    if 0 <= target_val <= 60 and not respect_local_target:\n                        explicit_target = float(target_val)\n                        temps[\"chamber_target\"] = explicit_target  # Override any previous value\n                        logger.debug(\n                            f\"[{self.serial_number}] Setting chamber_target from ctc_info.target: {explicit_target}\"\n                        )\n\n                # Parse chamber temp from ctc.info.temp - may be encoded\n                if \"temp\" in ctc_info and \"chamber\" not in temps:\n                    temp_val = ctc_info[\"temp\"]\n                    logger.debug(\"[%s] ctc_info.temp raw value: %s\", self.serial_number, temp_val)\n                    if temp_val > 500:\n                        # Encoded value: decode target and current\n                        decoded_target = temp_val // 65536\n                        current = temp_val % 65536\n                        temps[\"chamber\"] = float(current)\n                        logger.debug(\n                            f\"[{self.serial_number}] ctc_info.temp decoded: target={decoded_target}, current={current}, explicit_target={explicit_target}\"\n                        )\n\n                        # Determine which target to use for heating state:\n                        # Priority: local target > explicit target > decoded target\n                        if respect_local_target:\n                            local_target = self.state.temperatures.get(\"chamber_target\", 0)\n                            temps[\"chamber_heating\"] = local_target > 0 and current < local_target\n                        elif explicit_target is not None:\n                            # Use explicit ctc.info.target - this is what slicer sees\n                            temps[\"chamber_heating\"] = explicit_target > 0 and current < explicit_target\n                        else:\n                            # Fallback to decoded target only if no explicit target available\n                            if not respect_local_target and \"chamber_target\" not in temps:\n                                temps[\"chamber_target\"] = float(decoded_target)\n                            temps[\"chamber_heating\"] = decoded_target > 0 and current < decoded_target\n                    else:\n                        # Direct value (not encoded) - heater is OFF\n                        temps[\"chamber\"] = float(temp_val)\n                        temps[\"chamber_heating\"] = False\n        except Exception as e:\n            logger.warning(\"[%s] Error parsing H2D temperatures: %s\", self.serial_number, e)\n        if temps:\n            # Handle chamber_target: prefer explicit over decoded\n            if \"_chamber_decoded_target\" in temps and \"chamber_target\" not in temps:\n                # No explicit target available, use decoded target from chamber_temper\n                temps[\"chamber_target\"] = temps[\"_chamber_decoded_target\"]\n            # Remove internal temp key before merging\n            temps.pop(\"_chamber_decoded_target\", None)\n\n            # Merge new temps into existing, preserving valid values when new ones are filtered out\n            for key, value in temps.items():\n                self.state.temperatures[key] = value\n\n            # Notify bed temperature updates (used by event-driven bed cooldown monitor)\n            if \"bed\" in temps and self.on_bed_temp_update:\n                self.on_bed_temp_update(temps[\"bed\"])\n\n            # Calculate chamber_heating after all targets are known\n            # Priority: local target (if recent) > explicit target (chamber_target) > 0\n            if \"chamber\" in temps and \"chamber_heating\" not in temps:\n                current = self.state.temperatures.get(\"chamber\", 0)\n                local_set_time = self.state.temperatures.get(\"_chamber_target_set_time\", 0)\n                respect_local = (time.time() - local_set_time) < 5.0\n\n                if respect_local:\n                    # Use locally-set target\n                    target = self.state.temperatures.get(\"chamber_target\", 0)\n                else:\n                    # Use explicit/decoded target from MQTT\n                    target = self.state.temperatures.get(\"chamber_target\", 0)\n\n                self.state.temperatures[\"chamber_heating\"] = target > 0 and current < target\n                logger.debug(\n                    f\"[{self.serial_number}] Chamber heating calculated: target={target}, current={current}, heating={self.state.temperatures['chamber_heating']}, respect_local={respect_local}\"\n                )\n\n            # Debug: log chamber value if it was updated\n            if \"chamber\" in temps:\n                logger.debug(\n                    f\"[{self.serial_number}] Chamber temp updated to: {self.state.temperatures.get('chamber')}, target: {self.state.temperatures.get('chamber_target')}, heating: {self.state.temperatures.get('chamber_heating')}\"\n                )\n\n            # Calculate nozzle_heating for single nozzle printers (not set by H2D parsing)\n            # For H2D, nozzle_heating is set in temps dict; for single nozzle, calculate here\n            if \"nozzle\" in temps and \"nozzle_heating\" not in temps:\n                current = self.state.temperatures.get(\"nozzle\", 0)\n                target = self.state.temperatures.get(\"nozzle_target\", 0)\n                self.state.temperatures[\"nozzle_heating\"] = target > 0 and current < target\n\n        # Parse HMS (Health Management System) errors\n        if \"hms\" in data:\n            hms_list = data[\"hms\"]\n            logger.debug(\"[%s] HMS data received: %s\", self.serial_number, hms_list)\n            self.state.hms_errors = []\n            if isinstance(hms_list, list):\n                for hms in hms_list:\n                    if isinstance(hms, dict):\n                        # HMS format: {\"attr\": attribute_code, \"code\": error_code}\n                        # attr contains module/severity info, code contains error number\n                        # Both are needed to construct the wiki URL\n                        attr = hms.get(\"attr\", 0)\n                        code = hms.get(\"code\", 0)\n                        if isinstance(attr, str):\n                            attr = int(attr.replace(\"0x\", \"\"), 16) if attr else 0\n                        if isinstance(code, str):\n                            code = int(code.replace(\"0x\", \"\"), 16) if code else 0\n                        # Severity is in attr byte 1 (bits 8-15)\n                        severity = (attr >> 8) & 0xF\n                        # Module is in attr byte 3 (bits 24-31)\n                        module = (attr >> 24) & 0xFF\n                        # Skip non-error status codes — all real HMS errors\n                        # have code >= 0x4000. Lower values are status/phase\n                        # indicators that some firmware sends during normal printing.\n                        if code < 0x4000:\n                            continue\n                        self.state.hms_errors.append(\n                            HMSError(\n                                code=f\"0x{code:x}\" if code else \"0x0\",\n                                attr=attr,\n                                module=module,\n                                severity=severity if severity > 0 else 2,\n                            )\n                        )\n\n        # Parse print_error - this is a different error format than HMS\n        # print_error is a 32-bit integer where:\n        #   - High 16 bits contain module info (e.g., 0x0500)\n        #   - Low 16 bits contain error code (e.g., 0x8061)\n        # Format on printer screen: [0500-8061] -> short code: 0500_8061\n        if \"print_error\" in data:\n            print_error = data[\"print_error\"]\n            if print_error and print_error != 0:\n                # Extract components: MMMMEEEE -> MMMM_EEEE\n                module = (print_error >> 16) & 0xFFFF  # High 16 bits (e.g., 0x0500)\n                error = print_error & 0xFFFF  # Low 16 bits (e.g., 0x8061)\n\n                # Values below 0x4000 are status/phase indicators, not real errors.\n                # All known HMS errors use 0x4xxx (fatal), 0x8xxx (warning), 0xCxxx (prompt).\n                # Some firmware sends low values like 0x0002 during normal printing.\n                if error < 0x4000:\n                    pass  # Skip — not a real error\n                else:\n                    # Store in a format that matches the community error database\n                    # attr stores the full 32-bit value for reconstruction\n                    # code stores the short format string for lookup\n                    short_code = f\"{module:04X}_{error:04X}\"\n\n                    logger.debug(\n                        f\"[{self.serial_number}] print_error: {print_error} (0x{print_error:08x}) -> short_code={short_code}\"\n                    )\n\n                    # Only add if not already in HMS errors (avoid duplicates)\n                    existing_short_codes = set()\n                    for e in self.state.hms_errors:\n                        # Extract short code from existing errors\n                        e_module = (e.attr >> 16) & 0xFFFF\n                        e_error = int(e.code.replace(\"0x\", \"\"), 16) if e.code else 0\n                        existing_short_codes.add(f\"{e_module:04X}_{e_error:04X}\")\n\n                    if short_code not in existing_short_codes:\n                        self.state.hms_errors.append(\n                            HMSError(\n                                code=f\"0x{error:x}\",\n                                attr=print_error,  # Store full value for display\n                                module=module >> 8,  # High byte of module (e.g., 0x05)\n                                severity=3,  # Warning level for print_error\n                            )\n                        )\n\n        # Parse home_flag first so SD-card detection below can prefer it.\n        # Bit 8 = HAS_SDCARD_NORMAL, bit 9 = HAS_SDCARD_ABNORMAL, bit 11 = store-to-SD,\n        # bit 23 = door-open (X1 family only).\n        home_flag = None\n        if \"home_flag\" in data:\n            home_flag = data[\"home_flag\"]\n            if home_flag < 0:\n                home_flag = home_flag & 0xFFFFFFFF\n\n        # SD card presence: the only remaining consumer is the firmware-update\n        # precondition check (firmware_update.py). Use the top-level `sdcard`\n        # field when present with a permissive truthy check covering the\n        # bool/int/\"HAS_SDCARD_NORMAL\" variants real firmware emits. We do NOT\n        # derive this from home_flag — heartbeat pushes clear bits 8-9 even\n        # when a card is inserted, which caused the badge to flap before the\n        # badge was removed entirely.\n        if \"sdcard\" in data:\n            raw_sdcard = data[\"sdcard\"]\n            if isinstance(raw_sdcard, str):\n                self.state.sdcard = \"HAS_SDCARD\" in raw_sdcard.upper() or raw_sdcard.lower() in (\"true\", \"normal\", \"1\")\n            else:\n                self.state.sdcard = bool(raw_sdcard)\n\n        if home_flag is not None:\n            store_to_sdcard = bool((home_flag >> 11) & 1)\n            if store_to_sdcard != self.state.store_to_sdcard:\n                logger.debug(\n                    f\"[{self.serial_number}] store_to_sdcard changed: {self.state.store_to_sdcard} -> {store_to_sdcard}\"\n                )\n            self.state.store_to_sdcard = store_to_sdcard\n\n        # Door open detection — source depends on printer family:\n        #   X1 series (X1, X1C, X1E): home_flag bit 23\n        #   All others (P1/P2/H2/A1/N-series): top-level `stat` field (hex string), bit 23\n        # Both share the same bitmask (0x00800000) but live in different fields.\n        model_upper = (self.model or \"\").upper().strip()\n        is_x1_family = model_upper in (\"X1\", \"X1C\", \"X1E\")\n        if is_x1_family and home_flag is not None:\n            door_open = (home_flag & 0x00800000) != 0\n            if door_open != self.state.door_open:\n                logger.debug(\n                    \"[%s] door_open changed: %s -> %s (home_flag=0x%08X)\",\n                    self.serial_number,\n                    self.state.door_open,\n                    door_open,\n                    home_flag,\n                )\n            self.state.door_open = door_open\n        elif not is_x1_family and \"stat\" in data:\n            try:\n                stat_value = int(data[\"stat\"], 16) if isinstance(data[\"stat\"], str) else int(data[\"stat\"])\n                door_open = (stat_value & 0x00800000) != 0\n                if door_open != self.state.door_open:\n                    logger.debug(\n                        \"[%s] door_open changed: %s -> %s (stat=0x%08X)\",\n                        self.serial_number,\n                        self.state.door_open,\n                        door_open,\n                        stat_value,\n                    )\n                self.state.door_open = door_open\n            except (ValueError, TypeError):\n                logger.debug(\"[%s] could not parse stat field: %r\", self.serial_number, data[\"stat\"])\n\n        # Parse timelapse status (recording active during print)\n        if \"timelapse\" in data:\n            logger.debug(\"[%s] timelapse field: %s\", self.serial_number, data[\"timelapse\"])\n            self.state.timelapse = data[\"timelapse\"] is True\n            # Track if timelapse was ever active during this print\n            if self.state.timelapse and self._was_running:\n                self._timelapse_during_print = True\n\n        # Parse ipcam/live view status\n        if \"ipcam\" in data:\n            ipcam_data = data[\"ipcam\"]\n            logger.debug(\"[%s] ipcam field: %s\", self.serial_number, ipcam_data)\n            if isinstance(ipcam_data, dict):\n                # Check ipcam_record field for live view status\n                self.state.ipcam = ipcam_data.get(\"ipcam_record\") == \"enable\"\n                # Check timelapse field (H2D sends it here, not in xcam)\n                if \"timelapse\" in ipcam_data:\n                    timelapse_enabled = ipcam_data.get(\"timelapse\") == \"enable\"\n                    if timelapse_enabled != self.state.timelapse:\n                        logger.debug(\n                            f\"[{self.serial_number}] timelapse changed (from ipcam): {self.state.timelapse} -> {timelapse_enabled}\"\n                        )\n                    self.state.timelapse = timelapse_enabled\n                    # Track if timelapse was ever active during this print\n                    if self.state.timelapse and self._was_running:\n                        self._timelapse_during_print = True\n                        logger.debug(\"[%s] Timelapse detected during print (from ipcam)\", self.serial_number)\n            else:\n                self.state.ipcam = ipcam_data is True\n\n        # Parse WiFi signal strength (dBm)\n        if \"wifi_signal\" in data:\n            wifi_signal = data[\"wifi_signal\"]\n            logger.debug(\"[%s] wifi_signal received: %s\", self.serial_number, wifi_signal)\n            if isinstance(wifi_signal, (int, float)):\n                self.state.wifi_signal = int(wifi_signal)\n            elif isinstance(wifi_signal, str):\n                # Handle string format like \"-52dBm\"\n                try:\n                    self.state.wifi_signal = int(wifi_signal.replace(\"dBm\", \"\").strip())\n                except ValueError:\n                    pass  # Ignore unparseable wifi_signal strings; field is non-critical\n\n            # Detect ethernet connection: printers on ethernet with WiFi disabled\n            # report a hardcoded wifi_signal of -90 dBm. Real WiFi signals vary\n            # (typically -30 to -80 dBm). Only check models with an ethernet port.\n            from backend.app.utils.printer_models import has_ethernet\n\n            if has_ethernet(self.model):\n                self.state.wired_network = self.state.wifi_signal == -90\n\n        # Parse print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)\n        if \"spd_lvl\" in data:\n            new_speed = data[\"spd_lvl\"]\n            if new_speed != self.state.speed_level:\n                logger.debug(\n                    \"[%s] speed_level changed: %s -> %s\", self.serial_number, self.state.speed_level, new_speed\n                )\n            self.state.speed_level = new_speed\n\n        # Parse skipped objects from printer status (s_obj field)\n        # This allows us to restore skipped objects state after reconnection\n        if \"s_obj\" in data:\n            s_obj = data[\"s_obj\"]\n            if isinstance(s_obj, list):\n                # Update skipped objects from printer's list\n                new_skipped = [int(oid) for oid in s_obj if isinstance(oid, (int, str))]\n                if new_skipped != self.state.skipped_objects:\n                    logger.debug(\"[%s] skipped_objects updated from printer: %s\", self.serial_number, new_skipped)\n                    self.state.skipped_objects = new_skipped\n\n        # Parse chamber light status from lights_report\n        if \"lights_report\" in data:\n            lights = data[\"lights_report\"]\n            logger.debug(\"[%s] lights_report: %s\", self.serial_number, lights)\n            if isinstance(lights, list):\n                for light in lights:\n                    if isinstance(light, dict) and light.get(\"node\") == \"chamber_light\":\n                        new_light_state = light.get(\"mode\") == \"on\"\n                        if new_light_state != self.state.chamber_light:\n                            logger.debug(\n                                f\"[{self.serial_number}] chamber_light changed: {self.state.chamber_light} -> {new_light_state}\"\n                            )\n                        self.state.chamber_light = new_light_state\n                        break\n\n        # Parse nozzle hardware info (single nozzle printers)\n        if \"nozzle_type\" in data:\n            self.state.nozzles[0].nozzle_type = str(data[\"nozzle_type\"])\n        if \"nozzle_diameter\" in data:\n            self.state.nozzles[0].nozzle_diameter = str(data[\"nozzle_diameter\"])\n\n        # Parse nozzle hardware info (dual nozzle printers - H2D series)\n        # Left nozzle\n        if \"left_nozzle_type\" in data:\n            self.state.nozzles[0].nozzle_type = str(data[\"left_nozzle_type\"])\n        if \"left_nozzle_diameter\" in data:\n            self.state.nozzles[0].nozzle_diameter = str(data[\"left_nozzle_diameter\"])\n        # Right nozzle\n        if \"right_nozzle_type\" in data:\n            self.state.nozzles[1].nozzle_type = str(data[\"right_nozzle_type\"])\n        if \"right_nozzle_diameter\" in data:\n            self.state.nozzles[1].nozzle_diameter = str(data[\"right_nozzle_diameter\"])\n\n        # Alternative format for dual nozzle (nozzle_type_2, etc.)\n        if \"nozzle_type_2\" in data:\n            self.state.nozzles[1].nozzle_type = str(data[\"nozzle_type_2\"])\n        if \"nozzle_diameter_2\" in data:\n            self.state.nozzles[1].nozzle_diameter = str(data[\"nozzle_diameter_2\"])\n\n        # H2D/H2C series: Nozzle hardware info is in device.nozzle.info array\n        if \"device\" in data and isinstance(data[\"device\"], dict):\n            device = data[\"device\"]\n            nozzle_data = device.get(\"nozzle\", {})\n            nozzle_info = nozzle_data.get(\"info\", [])\n            if isinstance(nozzle_info, list):\n                # H2 series: nozzle_info contains extended nozzle data (wear, serial,\n                # max_temp, etc.) for all nozzles: L/R hotend (IDs 0,1) and rack slots\n                # (IDs 16-21 on H2C). Store ALL entries so the frontend can use them\n                # for hover cards on both the L/R indicator and the nozzle rack card.\n                if nozzle_info:\n                    self.state.nozzle_rack = sorted(\n                        [\n                            {\n                                \"id\": n.get(\"id\", i),\n                                \"type\": str(n.get(\"type\", \"\")),\n                                \"diameter\": str(n.get(\"diameter\", \"\")),\n                                \"wear\": n.get(\"wear\"),\n                                \"stat\": n.get(\"stat\"),\n                                # H2C uses \"tm\", H2D uses \"max_temp\"\n                                \"max_temp\": n.get(\"max_temp\") or n.get(\"tm\", 0),\n                                # H2C uses \"sn\", H2D uses \"serial_number\"\n                                \"serial_number\": str(n.get(\"serial_number\") or n.get(\"sn\", \"\")),\n                                # H2C uses \"color_m\", H2D uses \"filament_colour\"\n                                \"filament_color\": str(n.get(\"filament_colour\") or n.get(\"color_m\", \"\")),\n                                # H2C uses \"fila_id\", H2D uses \"filament_id\"\n                                \"filament_id\": str(n.get(\"filament_id\") or n.get(\"fila_id\", \"\")),\n                                \"filament_type\": str(n.get(\"tray_type\", \"\") or n.get(\"filament_type\", \"\")),\n                            }\n                            for i, n in enumerate(nozzle_info)\n                        ],\n                        key=lambda x: x[\"id\"],\n                    )\n                    if not hasattr(self, \"_nozzle_rack_logged\") and nozzle_info:\n                        self._nozzle_rack_logged = True\n                        logger.debug(\n                            \"[%s] Nozzle info: %d entries, IDs: %s\",\n                            self.serial_number,\n                            len(nozzle_info),\n                            [n.get(\"id\") for n in nozzle_info],\n                        )\n                for nozzle in nozzle_info:\n                    idx = nozzle.get(\"id\", 0)\n                    if idx < len(self.state.nozzles):\n                        if \"type\" in nozzle and nozzle[\"type\"]:\n                            self.state.nozzles[idx].nozzle_type = str(nozzle[\"type\"])\n                        if \"diameter\" in nozzle:\n                            self.state.nozzles[idx].nozzle_diameter = str(nozzle[\"diameter\"])\n\n        # Preserve AMS, vt_tray, ams_extruder_map, and mapping data when updating raw_data\n        # (these fields aren't sent in every MQTT push, only when changed)\n        ams_data = self.state.raw_data.get(\"ams\")\n        vt_tray_data = self.state.raw_data.get(\"vt_tray\")\n        ams_extruder_map_data = self.state.raw_data.get(\"ams_extruder_map\")\n        mapping_data = self.state.raw_data.get(\"mapping\")\n\n        # Normalize vt_tray in data before assigning to raw_data: MQTT sends it\n        # as a dict but consumers expect a list.  Without this, the dev mode probe\n        # below can release the GIL (via publish), letting the event-loop thread\n        # read raw_data[\"vt_tray\"] as a dict and crash iterating over string keys.\n        if \"vt_tray\" in data and isinstance(data[\"vt_tray\"], dict):\n            data[\"vt_tray\"] = [data[\"vt_tray\"]]\n\n        self.state.raw_data = data\n\n        # Restore preserved fields BEFORE any work that may release the GIL\n        # (e.g. _probe_developer_mode publishes an MQTT message).\n        if ams_data is not None:\n            self.state.raw_data[\"ams\"] = ams_data\n        if vt_tray_data is not None:\n            self.state.raw_data[\"vt_tray\"] = vt_tray_data\n        if ams_extruder_map_data is not None:\n            self.state.raw_data[\"ams_extruder_map\"] = ams_extruder_map_data\n        if mapping_data is not None and \"mapping\" not in data:\n            self.state.raw_data[\"mapping\"] = mapping_data\n\n        # Parse developer LAN mode from \"fun\" field\n        if \"fun\" in data:\n            try:\n                fun_val = data[\"fun\"]\n                fun_int = fun_val if isinstance(fun_val, int) else int(fun_val, 16)\n                self.state.developer_mode = (fun_int & 0x20000000) == 0\n            except (ValueError, TypeError):\n                pass\n        elif self.state.developer_mode is None and not self._dev_mode_probed:\n            # No \"fun\" field — A1/P1 series never send it, so we need to probe.\n            # Two gates: (1) wait for a full pushall (30+ keys) so we don't probe\n            # before a pushall that might contain \"fun\" arrives, and (2) delay 5s\n            # after connect to let the MQTT session stabilize — probing too early\n            # can destabilize some firmware MQTT brokers (#887).\n            if not self._dev_mode_needs_probe and len(data) > 30:\n                # First full status without \"fun\" — mark that probe is needed\n                self._dev_mode_needs_probe = True\n            if self._dev_mode_needs_probe and time.monotonic() - self._connect_time >= 5.0:\n                self._probe_developer_mode()\n            elif self._dev_mode_needs_probe:\n                logger.debug(\n                    \"[%s] Deferring developer mode probe (%.1fs since connect, need 5s)\",\n                    self.serial_number,\n                    time.monotonic() - self._connect_time,\n                )\n        elif self._dev_mode_probed and self._dev_mode_probe_seq is not None:\n            # Probe was sent but no response yet — check for timeout.\n            # A half-broken MQTT session (e.g. after keep-alive timeout reconnect)\n            # may deliver status pushes but silently drop commands (#887).\n            elapsed = time.monotonic() - self._dev_mode_probe_time\n            if elapsed > 10.0:\n                self._dev_mode_probe_failures += 1\n                logger.warning(\n                    \"[%s] Developer mode probe timed out after %.0fs (attempt %d)\",\n                    self.serial_number,\n                    elapsed,\n                    self._dev_mode_probe_failures,\n                )\n                self._dev_mode_probe_seq = None\n                if self._dev_mode_probe_failures >= 2:\n                    self.force_reconnect_stale_session(\"developer mode probe unanswered 2×\")\n                else:\n                    # Allow retry on next full status message\n                    self._dev_mode_probed = False\n\n        # Zombie session detection: if an ams_filament_setting command has been\n        # pending for >10s with no response, the publish path is likely dead (#887).\n        if self._last_ams_cmd_time > 0:\n            elapsed = time.monotonic() - self._last_ams_cmd_time\n            if elapsed > 10.0:\n                self._ams_cmd_unanswered += 1\n                logger.warning(\n                    \"[%s] ams_filament_setting unanswered for %.0fs (count=%d)\",\n                    self.serial_number,\n                    elapsed,\n                    self._ams_cmd_unanswered,\n                )\n                self._last_ams_cmd_time = 0.0  # don't re-trigger on next push_status\n                if self._ams_cmd_unanswered >= 2:\n                    self.force_reconnect_stale_session(\"ams_filament_setting unanswered 2\\u00d7\")\n                    self._ams_cmd_unanswered = 0\n\n        # Log mapping data when received (for usage tracking debugging)\n        if \"mapping\" in data:\n            logger.debug(\"[%s] MQTT mapping field: %s\", self.serial_number, data[\"mapping\"])\n\n        # Log state transitions for debugging\n        if \"gcode_state\" in data:\n            logger.debug(\n                f\"[{self.serial_number}] gcode_state: {self._previous_gcode_state} -> {self.state.state}, \"\n                f\"file: {self.state.gcode_file}, subtask: {self.state.subtask_name}\"\n            )\n\n        # Detect print start (state changes TO RUNNING with a file)\n        current_file = self.state.gcode_file or self.state.current_print\n        is_new_print = (\n            self.state.state == \"RUNNING\"\n            and self._previous_gcode_state != \"RUNNING\"\n            and current_file\n            and not self._was_running  # Prevent duplicates when resuming from PAUSE\n        )\n        # Also detect if file changed while running (new print started)\n        is_file_change = (\n            self.state.state == \"RUNNING\"\n            and current_file\n            and current_file != self._previous_gcode_file\n            and self._previous_gcode_file is not None\n        )\n\n        # Track RUNNING state for more robust completion detection\n        if self.state.state == \"RUNNING\" and current_file:\n            if not self._was_running:\n                logger.debug(\"[%s] Now tracking RUNNING state for %s\", self.serial_number, current_file)\n                # Check if timelapse was enabled in the same message (xcam parsed before this)\n                if self.state.timelapse:\n                    self._timelapse_during_print = True\n                    logger.debug(\"[%s] Timelapse detected when entering RUNNING state\", self.serial_number)\n            self._was_running = True\n            self._completion_triggered = False\n\n        if is_new_print or is_file_change:\n            # Clear any old HMS errors when a new print starts\n            self.state.hms_errors = []\n            # Reset layer tracking for new print (needed for layer-based timelapse)\n            self.state.layer_num = 0\n            # Reset completion tracking for new print\n            self._was_running = True\n            self._completion_triggered = False\n            # Reset last valid progress/layer for usage tracking\n            self._last_valid_progress = 0.0\n            self._last_valid_layer_num = 0\n            # Clear and seed tray change log for mid-print usage splitting\n            self.state.tray_change_log.clear()\n            tn = self.state.tray_now\n            if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:\n                self.state.tray_change_log.append((tn, 0))\n            # Initialize timelapse tracking based on current state\n            # NOTE: xcam data is parsed BEFORE this code runs in _process_message,\n            # so self.state.timelapse may already be set from this message.\n            # We preserve that value instead of blindly resetting to False.\n            if self.state.timelapse:\n                self._timelapse_during_print = True\n                logger.debug(\"[%s] Timelapse detected at print start\", self.serial_number)\n            else:\n                self._timelapse_during_print = False\n\n        if (is_new_print or is_file_change) and self.on_print_start:\n            logger.info(\n                f\"[{self.serial_number}] PRINT START detected - file: {current_file}, \"\n                f\"subtask: {self.state.subtask_name}, is_new: {is_new_print}, is_file_change: {is_file_change}\"\n            )\n            self.on_print_start(\n                {\n                    \"filename\": current_file,\n                    \"subtask_name\": self.state.subtask_name,\n                    \"remaining_time\": self.state.remaining_time * 60\n                    if self.state.remaining_time > 0\n                    else None,  # Convert minutes to seconds\n                    \"raw_data\": data,\n                    \"ams_mapping\": self._captured_ams_mapping,\n                }\n            )\n\n        # Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)\n        # Use _was_running flag in addition to _previous_gcode_state for more robust detection\n        # This handles cases where server restarts during a print\n        should_trigger_completion = (\n            self.state.state in (\"FINISH\", \"FAILED\")\n            and not self._completion_triggered\n            and self.on_print_complete\n            and (\n                self._previous_gcode_state == \"RUNNING\"  # Normal transition\n                or (self._was_running and self._previous_gcode_state != self.state.state)  # After server restart\n            )\n        )\n        # For IDLE, only trigger if we just came from RUNNING (explicit abort/cancel)\n        if (\n            self.state.state == \"IDLE\"\n            and self._previous_gcode_state == \"RUNNING\"\n            and not self._completion_triggered\n            and self.on_print_complete\n        ):\n            should_trigger_completion = True\n\n        # Log when we FIRST see a terminal state but DON'T trigger completion (diagnostics)\n        # Only log on the transition (prev != current) to avoid flooding logs every MQTT update\n        if (\n            not should_trigger_completion\n            and self.state.state in (\"FINISH\", \"FAILED\")\n            and self._previous_gcode_state != self.state.state\n        ):\n            logger.info(\n                f\"[{self.serial_number}] State is {self.state.state} but completion NOT triggered: \"\n                f\"prev={self._previous_gcode_state}, was_running={self._was_running}, \"\n                f\"already_triggered={self._completion_triggered}, has_callback={bool(self.on_print_complete)}\"\n            )\n            # Mark as triggered so state is clean for the next print cycle\n            self._completion_triggered = True\n\n        if should_trigger_completion:\n            if self.state.state == \"FINISH\":\n                status = \"completed\"\n            elif self.state.state == \"FAILED\":\n                status = \"failed\"\n            else:\n                status = \"aborted\"\n            logger.info(\n                f\"[{self.serial_number}] PRINT COMPLETE detected - state: {self.state.state}, \"\n                f\"status: {status}, file: {self._previous_gcode_file or current_file}, \"\n                f\"subtask: {self.state.subtask_name}, was_running: {self._was_running}, \"\n                f\"timelapse_during_print: {self._timelapse_during_print}\"\n            )\n            timelapse_was_active = self._timelapse_during_print\n            self._completion_triggered = True\n            self._was_running = False\n            self._timelapse_during_print = False  # Reset for next print\n            # Include HMS errors for failure reason detection\n            hms_errors_data = (\n                [\n                    {\"code\": e.code, \"attr\": e.attr, \"module\": e.module, \"severity\": e.severity}\n                    for e in self.state.hms_errors\n                ]\n                if self.state.hms_errors\n                else []\n            )\n            self.on_print_complete(\n                {\n                    \"status\": status,\n                    \"filename\": self._previous_gcode_file or current_file,\n                    \"subtask_name\": self.state.subtask_name,\n                    \"raw_data\": data,\n                    \"timelapse_was_active\": timelapse_was_active,\n                    \"hms_errors\": hms_errors_data,\n                    \"ams_mapping\": self._captured_ams_mapping,\n                    # Last valid progress/layer before firmware reset (for partial usage tracking)\n                    \"last_progress\": self._last_valid_progress,\n                    \"last_layer_num\": self._last_valid_layer_num,\n                }\n            )\n            self._captured_ams_mapping = None\n\n        self._previous_gcode_state = self.state.state\n        if current_file:\n            self._previous_gcode_file = current_file\n\n        if self.on_state_change:\n            self.on_state_change(self.state)\n\n    def _request_push_all(self):\n        \"\"\"Request full status update from printer.\"\"\"\n        if self._client:\n            message = {\"pushing\": {\"command\": \"pushall\"}}\n            self._client.publish(self.topic_publish, json.dumps(message), qos=1)\n\n    def _probe_developer_mode(self):\n        \"\"\"Probe developer mode by sending an ams_filament_setting for the external slot.\n\n        Some printers (A1/P1 series) never send the \"fun\" field in MQTT status.\n        For these, we detect developer mode by sending a harmless command and\n        checking whether the printer accepts or rejects it:\n        - result=\"success\" → developer mode ON (commands accepted)\n        - result=\"failed\", reason=\"mqtt message verify failed\" → developer mode OFF\n\n        The probe re-sends the current external slot configuration so it's a no-op\n        when the command succeeds. If there's no external slot data yet, we send a\n        reset (empty filament) which is also safe.\n        \"\"\"\n        if not self._client or not self.state.connected:\n            return\n        self._dev_mode_probed = True\n        self._dev_mode_probe_time = time.monotonic()\n        self._sequence_id += 1\n        seq = str(self._sequence_id)\n        self._dev_mode_probe_seq = seq\n\n        # Build probe command: re-send current external slot config (no-op on success)\n        vt_tray = self.state.raw_data.get(\"vt_tray\", []) if self.state.raw_data else []\n        current = vt_tray[0] if vt_tray else {}\n\n        command = {\n            \"print\": {\n                \"command\": \"ams_filament_setting\",\n                \"ams_id\": 255,\n                \"tray_id\": 0,\n                \"slot_id\": 0,\n                \"tray_info_idx\": current.get(\"tray_info_idx\", \"\"),\n                \"tray_type\": current.get(\"tray_type\", \"\"),\n                \"tray_sub_brands\": current.get(\"tray_sub_brands\", \"\"),\n                \"tray_color\": current.get(\"tray_color\", \"00000000\"),\n                \"nozzle_temp_min\": current.get(\"nozzle_temp_min\", 0),\n                \"nozzle_temp_max\": current.get(\"nozzle_temp_max\", 0),\n                \"sequence_id\": seq,\n            }\n        }\n        setting_id = current.get(\"setting_id\")\n        if setting_id:\n            command[\"print\"][\"setting_id\"] = setting_id\n\n        logger.info(\"[%s] Probing developer mode via ams_filament_setting (seq=%s)\", self.serial_number, seq)\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n\n    def _handle_dev_mode_probe_response(self, data: dict):\n        \"\"\"Handle response to the developer mode probe command.\n\n        Sets developer_mode based on whether the printer accepted or rejected the command.\n        \"\"\"\n        self._dev_mode_probe_seq = None  # One-shot: don't match future responses\n        self._dev_mode_probe_failures = 0  # Reset on any response\n        result = data.get(\"result\", \"\")\n        reason = data.get(\"reason\", \"\")\n\n        if result == \"failed\" and \"verify failed\" in reason:\n            self.state.developer_mode = False\n            logger.info(\"[%s] Developer mode probe: DISABLED (reason=%r)\", self.serial_number, reason)\n        else:\n            # Success or any other response — commands are accepted\n            self.state.developer_mode = True\n            logger.info(\"[%s] Developer mode probe: ENABLED (result=%r)\", self.serial_number, result)\n\n        if self.on_state_change:\n            self.on_state_change(self.state)\n\n    def _request_version(self):\n        \"\"\"Request firmware version info from printer.\"\"\"\n        if self._client:\n            self._sequence_id += 1\n            message = {\n                \"info\": {\n                    \"sequence_id\": str(self._sequence_id),\n                    \"command\": \"get_version\",\n                }\n            }\n            logger.debug(\"[%s] Requesting firmware version info\", self.serial_number)\n            self._client.publish(self.topic_publish, json.dumps(message), qos=1)\n\n    def request_status_update(self) -> bool:\n        \"\"\"Request a full status update from the printer (public API).\n\n        Sends both pushall and get_accessories commands to refresh all data\n        including nozzle hardware info.\n\n        Returns:\n            True if the request was sent, False if not connected.\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] request_status_update: not connected\", self.serial_number)\n            return False\n        logger.debug(\"[%s] Requesting status update (pushall)\", self.serial_number)\n        self._request_push_all()\n        # Note: get_accessories returns stale nozzle data on H2D.\n        # The correct nozzle data comes from push_status response.\n        return True\n\n    def _request_accessories(self):\n        \"\"\"Request accessories info (nozzle type, etc.) from printer.\"\"\"\n        if self._client:\n            self._sequence_id += 1\n            message = {\n                \"system\": {\n                    \"sequence_id\": str(self._sequence_id),\n                    \"command\": \"get_accessories\",\n                    \"accessory_type\": \"none\",\n                }\n            }\n            logger.debug(\"[%s] Requesting accessories info\", self.serial_number)\n            self._client.publish(self.topic_publish, json.dumps(message), qos=1)\n\n    def _prime_kprofile_request(self):\n        \"\"\"Send a priming K-profile request on connect.\n\n        Bambu printers often ignore the first K-profile request after connection,\n        so we send a dummy request on connect to 'prime' the system.\n        \"\"\"\n        if self._client:\n            self._sequence_id += 1\n            command = {\n                \"print\": {\n                    \"command\": \"extrusion_cali_get\",\n                    \"filament_id\": \"\",\n                    \"nozzle_diameter\": \"0.4\",\n                    \"sequence_id\": str(self._sequence_id),\n                }\n            }\n            logger.debug(\"[%s] Sending K-profile priming request\", self.serial_number)\n            self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n\n    def connect(self, loop: asyncio.AbstractEventLoop | None = None):\n        \"\"\"Connect to the printer MQTT broker.\n\n        Args:\n            loop: The asyncio event loop to use for thread-safe callbacks.\n                  If not provided, will try to get the running loop.\n        \"\"\"\n        self._loop = loop\n        BambuMQTTClient._client_instance_counter += 1\n        client_id = f\"bambuddy_{self.serial_number}_{os.getpid()}_{BambuMQTTClient._client_instance_counter}\"\n        self._client = mqtt.Client(\n            callback_api_version=mqtt.CallbackAPIVersion.VERSION2,\n            client_id=client_id,\n            protocol=mqtt.MQTTv311,\n        )\n\n        self._client.username_pw_set(\"bblp\", self.access_code)\n        self._client.on_connect = self._on_connect\n        self._client.on_disconnect = self._on_disconnect\n        self._client.on_subscribe = self._on_subscribe\n        self._client.on_message = self._on_message\n\n        # TLS setup - Bambu uses self-signed certs\n        ssl_context = ssl.create_default_context()\n        ssl_context.check_hostname = False\n        ssl_context.verify_mode = ssl.CERT_NONE\n        self._client.tls_set_context(ssl_context)\n\n        # Backoff reconnects to avoid tight reconnect loops on unstable brokers.\n        self._client.reconnect_delay_set(min_delay=1, max_delay=30)\n\n        # Keepalive: paho sends PINGREQs at this interval, broker considers\n        # client dead at 1.5x.  30s is a good balance — fast enough to detect\n        # real network loss (45s), not so aggressive that transient hiccups\n        # trigger false disconnects.  Stale detection (60s no messages) handles\n        # the P1S/P1P firmware bug where the broker stops publishing but the\n        # TCP connection stays alive.\n        self._client.connect_async(self.ip_address, self.MQTT_PORT, keepalive=30)\n        self._client.loop_start()\n\n    def start_print(\n        self,\n        filename: str,\n        plate_id: int = 1,\n        ams_mapping: list[int] | None = None,\n        bed_levelling: bool = True,\n        flow_cali: bool = False,\n        vibration_cali: bool = True,\n        layer_inspect: bool = False,\n        timelapse: bool = False,\n        use_ams: bool = True,\n    ):\n        \"\"\"Start a print job on the printer.\n\n        The file should already be uploaded to the printer's root directory via FTP.\n\n        Args:\n            filename: Name of the uploaded file\n            plate_id: Plate number to print (default 1)\n            ams_mapping: List of tray IDs for each filament slot in the 3MF.\n                         Global tray ID = (ams_id * 4) + slot_id, external = 254\n            timelapse: Record timelapse video\n            bed_levelling: Auto bed levelling before print\n            flow_cali: Flow/pressure advance calibration\n            vibration_cali: Vibration compensation calibration\n            layer_inspect: First layer AI inspection\n            use_ams: Use AMS for automatic filament changes\n        \"\"\"\n        if self._client and self.state.connected:\n            # Bambu print command format - matches Bambu Studio's format\n            # H2D series requires integer values (0/1) for calibration/leveling fields\n            # but use_ams MUST remain boolean — H2D Pro firmware interprets integer\n            # values as nozzle index (1 = deputy nozzle), causing wrong extruder routing\n            # Other printers (X1C, P1S, A1, etc.) require actual booleans for all fields\n            is_h2d = self.model and self.model.upper().strip() in (\"H2D\", \"H2D PRO\", \"H2DPRO\", \"H2C\", \"H2S\", \"X2D\")\n\n            # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)\n            ams_mapping2 = []\n            # BambuStudio converts virtual tray IDs (254/255) to -1 in the flat\n            # ams_mapping and relies on ams_mapping2 for external spool details.\n            # Passing raw 254/255 in the flat array causes H2D firmware to fail\n            # with 0700_8012 \"Failed to get AMS mapping table\".\n            flat_ams_mapping = []\n            if ams_mapping is not None:\n                for tray_id in ams_mapping:\n                    # Ensure tray_id is an integer (may be string from JSON)\n                    tray_id = int(tray_id) if tray_id is not None else -1\n                    if tray_id == -1:\n                        # Unmapped filament slot\n                        flat_ams_mapping.append(-1)\n                        ams_mapping2.append({\"ams_id\": 255, \"slot_id\": 255})\n                    elif tray_id >= 254:\n                        # External/virtual spool. BambuStudio convention:\n                        #   255 = VIRTUAL_TRAY_MAIN_ID (main/right nozzle)\n                        #   254 = VIRTUAL_TRAY_DEPUTY_ID (deputy/left nozzle)\n                        # Flat mapping must use -1 (firmware doesn't accept raw 254/255).\n                        # Single-nozzle printers (X1C, P1S, A1, etc.) report tray_now=254\n                        # for external spool, but BambuStudio always sends ams_id=255\n                        # (VIRTUAL_TRAY_MAIN_ID) in ams_mapping2. Sending 254 causes the\n                        # firmware to target AMS tray 0 instead of external spool, leading\n                        # to 07FF_8012 \"Failed to get AMS mapping table\" or stuck prints.\n                        # Only H2D dual-nozzle printers use 254 (deputy/left nozzle).\n                        flat_ams_mapping.append(-1)\n                        ext_ams_id = tray_id if is_h2d else 255\n                        ams_mapping2.append({\"ams_id\": ext_ams_id, \"slot_id\": 0})\n                    elif tray_id >= 128:\n                        # AMS-HT: global tray ID IS the ams_id (single tray per unit)\n                        flat_ams_mapping.append(tray_id)\n                        ams_mapping2.append({\"ams_id\": tray_id, \"slot_id\": 0})\n                    else:\n                        # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id\n                        ams_id = tray_id // 4\n                        slot_id = tray_id % 4\n                        flat_ams_mapping.append(tray_id)\n                        ams_mapping2.append({\"ams_id\": ams_id, \"slot_id\": slot_id})\n\n            # If all mapped slots are external spool (no real AMS trays), force use_ams=False.\n            # P1S/P1P with no AMS rejects use_ams=True with \"Failed to get AMS mapping table\".\n            # Skip for H2D series — use_ams controls nozzle routing on those printers.\n            if ams_mapping and use_ams and not is_h2d:\n                if all(t is None or int(t) < 0 or int(t) >= 254 for t in ams_mapping):\n                    use_ams = False\n                    logger.info(\n                        \"[%s] All filament slots use external spool — setting use_ams=False\",\n                        self.serial_number,\n                    )\n\n            # Unique per-submission identity fields. Hardcoded \"0\" values caused\n            # third-party MQTT observers (OctoEverywhere, etc.) to see reprints as\n            # continuations of the same job: the printer reuses gcode_start_time\n            # from the prior print with task_id=0, so observers latch onto a stale\n            # timestamp and report compounding durations on repeat replays (#1011).\n            # BambuStudio mints fresh IDs per submission; matching that behavior\n            # makes the printer emit a clean state-transition for each job.\n            # md5 is left empty — firmware historically accepts \"\" as \"skip\n            # validation\" (unlike Studio, we don't have the file's real md5 here\n            # without re-reading the upload, and sending a synthetic wrong digest\n            # risks activation of md5 verification on some firmwares).\n            # Cap at signed int32 max: P1S firmware (01.10.00.00) clamps oversized\n            # task identity fields to 2**31-1, so raw epoch-ms (13 digits, ~1.7e12)\n            # overflows and every submission ends up with the same task_id from\n            # the printer's perspective — the printer then treats a fresh dispatch\n            # as a continuation of the last FAILED job and never leaves IDLE (#1042).\n            # Modulo keeps uniqueness within a ~24-day wrap window; `or 1` guards\n            # the (astronomically unlikely) zero case since task_id=0 is rejected.\n            submission_id = str(int(time.time() * 1000) % 2_147_483_647 or 1)\n\n            command = {\n                \"print\": {\n                    \"sequence_id\": \"20000\",\n                    \"command\": \"project_file\",\n                    \"param\": f\"Metadata/plate_{plate_id}.gcode\",\n                    \"url\": f\"ftp://{filename}\",\n                    \"file\": filename,\n                    \"md5\": \"\",\n                    \"bed_type\": \"auto\",\n                    \"timelapse\": (1 if timelapse else 0) if is_h2d else timelapse,\n                    \"bed_leveling\": (1 if bed_levelling else 0) if is_h2d else bed_levelling,\n                    \"auto_bed_leveling\": 1 if bed_levelling else 0,\n                    \"flow_cali\": (1 if flow_cali else 0) if is_h2d else flow_cali,\n                    \"vibration_cali\": (1 if vibration_cali else 0) if is_h2d else vibration_cali,\n                    \"layer_inspect\": (1 if layer_inspect else 0) if is_h2d else layer_inspect,\n                    \"use_ams\": use_ams,\n                    \"cfg\": \"0\",\n                    \"extrude_cali_flag\": 0,\n                    \"extrude_cali_manual_mode\": 0,\n                    \"nozzle_offset_cali\": 2,\n                    \"subtask_name\": filename.replace(\".3mf\", \"\").replace(\".gcode\", \"\"),\n                    \"profile_id\": \"0\",\n                    \"project_id\": submission_id,\n                    \"subtask_id\": submission_id,\n                    \"task_id\": submission_id,\n                }\n            }\n\n            if is_h2d:\n                logger.debug(\n                    \"[%s] H2D series detected: using integer format for calibration fields (use_ams stays boolean)\",\n                    self.serial_number,\n                )\n\n            # P2S-specific parameter adjustments\n            # P2S printer doesn't support vibration calibration like X1/P1 series\n            if self.model and self.model.upper().strip() in (\"P2S\", \"N7\"):\n                command[\"print\"][\"vibration_cali\"] = False\n                logger.debug(\"[%s] P2S detected: disabling vibration_cali\", self.serial_number)\n\n            # Add AMS mapping if provided\n            if ams_mapping is not None:\n                command[\"print\"][\"ams_mapping\"] = flat_ams_mapping\n                command[\"print\"][\"ams_mapping2\"] = ams_mapping2\n\n            logger.info(\"[%s] Sending print command: %s\", self.serial_number, json.dumps(command))\n            self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n            return True\n        else:\n            # Log why we couldn't send the command\n            if not self._client:\n                logger.error(\"[%s] Cannot start print: MQTT client not initialized\", self.serial_number)\n            elif not self.state.connected:\n                logger.error(\n                    f\"[{self.serial_number}] Cannot start print: Printer not connected (client exists but disconnected). \"\n                    f\"Connection state: {self.state.connected}, Last message: {self._last_message_time}\"\n                )\n            return False\n\n    def stop_print(self) -> bool:\n        \"\"\"Stop the current print job.\"\"\"\n        if self._client and self.state.connected:\n            command = {\"print\": {\"command\": \"stop\", \"sequence_id\": \"0\"}}\n            self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n            logger.info(\"[%s] Sent stop print command\", self.serial_number)\n            return True\n        return False\n\n    def set_xcam_option(\n        self, module_name: str, enabled: bool, print_halt: bool = True, sensitivity: str = \"medium\"\n    ) -> bool:\n        \"\"\"Set an xcam (AI detection) option on the printer.\n\n        Args:\n            module_name: The xcam module to control (e.g., \"spaghetti_detector\",\n                        \"first_layer_inspector\", \"printing_monitor\", \"buildplate_marker_detector\")\n            enabled: Whether to enable or disable the feature\n            print_halt: Whether to halt print on detection (only applies to some detectors)\n            sensitivity: Sensitivity level (\"low\", \"medium\", \"high\", or \"never_halt\")\n\n        Returns:\n            True if command was sent, False if not connected\n        \"\"\"\n        if not self._client or not self.state.connected:\n            return False\n\n        # auto_recovery_step_loss uses a different command format (print.print_option)\n        if module_name == \"auto_recovery_step_loss\":\n            return self._set_print_option(\"auto_recovery\", enabled)\n\n        self._sequence_id += 1\n\n        # Build the xcam control command (exact OrcaSlicer format)\n        # Key findings from OrcaSlicer source:\n        # - Uses \"xcam\" wrapper (not \"print\")\n        # - print_halt is ALWAYS true (legacy protocol requirement)\n        # - Both \"control\" and \"enable\" are set to the same value\n        # - halt_print_sensitivity controls actual halt behavior\n        command = {\n            \"xcam\": {\n                \"command\": \"xcam_control_set\",\n                \"sequence_id\": str(self._sequence_id),\n                \"module_name\": module_name,\n                \"control\": enabled,\n                \"enable\": enabled,  # old protocol compatibility\n                \"print_halt\": True,  # ALWAYS true per OrcaSlicer\n            }\n        }\n\n        # Only add sensitivity if not \"never_halt\"\n        # OrcaSlicer uses halt_print_sensitivity for ALL detectors\n        # The module_name field determines which detector's sensitivity is being set\n        if sensitivity and sensitivity != \"never_halt\":\n            command[\"xcam\"][\"halt_print_sensitivity\"] = sensitivity\n\n        command_json = json.dumps(command)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        logger.debug(\n            \"[%s] Set xcam option: %s=%s, sensitivity=%s\", self.serial_number, module_name, enabled, sensitivity\n        )\n        logger.debug(\"[%s] MQTT command sent: %s\", self.serial_number, command_json)\n\n        # OrcaSlicer pattern: Set hold timer to ignore incoming data for 3 seconds\n        # This prevents stale MQTT data from immediately overwriting our change\n        self._xcam_hold_start[module_name] = time.time()\n\n        # Update local state immediately for responsive UI\n        # NOTE: Spaghetti and Pileup sensitivities are linked in firmware\n        # When spaghetti_detector sensitivity is changed, pileup also changes\n        if module_name == \"spaghetti_detector\":\n            self.state.print_options.spaghetti_detector = enabled\n            self.state.print_options.print_halt = print_halt\n            if sensitivity and sensitivity != \"never_halt\":\n                # spaghetti_detector controls BOTH spaghetti and pileup sensitivities\n                self.state.print_options.halt_print_sensitivity = sensitivity\n                self.state.print_options.pileup_sensitivity = sensitivity\n                self._xcam_hold_start[\"halt_print_sensitivity\"] = time.time()\n                self._xcam_hold_start[\"pileup_sensitivity\"] = time.time()\n        elif module_name == \"first_layer_inspector\":\n            self.state.print_options.first_layer_inspector = enabled\n        elif module_name == \"printing_monitor\":\n            self.state.print_options.printing_monitor = enabled\n        elif module_name == \"buildplate_marker_detector\":\n            self.state.print_options.buildplate_marker_detector = enabled\n        elif module_name == \"allow_skip_parts\":\n            self.state.print_options.allow_skip_parts = enabled\n        elif module_name == \"pileup_detector\":\n            self.state.print_options.pileup_detector = enabled\n            # Pileup sensitivity is linked to spaghetti - both are set via spaghetti_detector\n        elif module_name == \"clump_detector\":\n            self.state.print_options.nozzle_clumping_detector = enabled\n            if sensitivity and sensitivity != \"never_halt\":\n                self.state.print_options.nozzle_clumping_sensitivity = sensitivity\n                self._xcam_hold_start[\"nozzle_clumping_sensitivity\"] = time.time()\n        elif module_name == \"airprint_detector\":\n            self.state.print_options.airprint_detector = enabled\n            if sensitivity and sensitivity != \"never_halt\":\n                self.state.print_options.airprint_sensitivity = sensitivity\n                self._xcam_hold_start[\"airprint_sensitivity\"] = time.time()\n        elif module_name == \"auto_recovery_step_loss\":\n            self.state.print_options.auto_recovery_step_loss = enabled\n\n        return True\n\n    def _set_print_option(self, option_name: str, enabled: bool) -> bool:\n        \"\"\"Set a print option using the print.print_option command.\n\n        This is different from xcam_control_set and is used for options like:\n        - auto_recovery\n        - air_print_detect\n        - filament_tangle_detect\n        - nozzle_blob_detect\n        - sound_enable\n\n        Args:\n            option_name: The option to control (e.g., \"auto_recovery\")\n            enabled: Whether to enable or disable the option\n\n        Returns:\n            True if command was sent, False if not connected\n        \"\"\"\n        if not self._client or not self.state.connected:\n            return False\n\n        self._sequence_id += 1\n\n        command = {\n            \"print\": {\n                \"command\": \"print_option\",\n                \"sequence_id\": str(self._sequence_id),\n                option_name: enabled,\n            }\n        }\n\n        command_json = json.dumps(command)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        logger.debug(\"[%s] Set print option: %s=%s\", self.serial_number, option_name, enabled)\n\n        # Set hold timer\n        hold_key = f\"print_option_{option_name}\"\n        self._xcam_hold_start[hold_key] = time.time()\n\n        # Update local state immediately\n        if option_name == \"auto_recovery\":\n            self.state.print_options.auto_recovery_step_loss = enabled\n\n        return True\n\n    def start_calibration(\n        self,\n        bed_leveling: bool = False,\n        vibration: bool = False,\n        motor_noise: bool = False,\n        nozzle_offset: bool = False,\n        high_temp_heatbed: bool = False,\n    ) -> bool:\n        \"\"\"Start printer calibration with selected options.\n\n        Args:\n            bed_leveling: Run bed leveling calibration\n            vibration: Run vibration compensation calibration\n            motor_noise: Run motor noise cancellation calibration\n            nozzle_offset: Run nozzle offset calibration (dual nozzle printers)\n            high_temp_heatbed: Run high-temperature heatbed calibration\n\n        Returns:\n            True if command was sent, False if not connected\n        \"\"\"\n        if not self._client or not self.state.connected:\n            return False\n\n        # Build calibration bitmask based on OrcaSlicer DeviceManager.cpp\n        # Bit 0: xcam_cali (not exposed in UI)\n        # Bit 1: bed_leveling\n        # Bit 2: vibration\n        # Bit 3: motor_noise\n        # Bit 4: nozzle_cali\n        # Bit 5: bed_cali (high-temp heatbed)\n        # Bit 6: clumppos_cali (not exposed in UI)\n        option = 0\n        if bed_leveling:\n            option |= 1 << 1\n        if vibration:\n            option |= 1 << 2\n        if motor_noise:\n            option |= 1 << 3\n        if nozzle_offset:\n            option |= 1 << 4\n        if high_temp_heatbed:\n            option |= 1 << 5\n\n        if option == 0:\n            logger.warning(\"[%s] No calibration options selected\", self.serial_number)\n            return False\n\n        self._sequence_id += 1\n\n        command = {\n            \"print\": {\n                \"command\": \"calibration\",\n                \"sequence_id\": str(self._sequence_id),\n                \"option\": option,\n            }\n        }\n\n        command_json = json.dumps(command)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        logger.info(\n            f\"[{self.serial_number}] Starting calibration: \"\n            f\"bed_leveling={bed_leveling}, vibration={vibration}, \"\n            f\"motor_noise={motor_noise}, nozzle_offset={nozzle_offset}, \"\n            f\"high_temp_heatbed={high_temp_heatbed} (option={option})\"\n        )\n\n        return True\n\n    def disconnect(self, timeout: float = 0):\n        \"\"\"Disconnect from the printer.\"\"\"\n        if self._client:\n            self._disconnection_event = threading.Event()\n            self._client.disconnect()\n            self._disconnection_event.wait(timeout=timeout)\n            self._client.loop_stop()\n            self._client = None\n            self.state.connected = False\n\n    def send_command(self, command: dict):\n        \"\"\"Send a command to the printer.\"\"\"\n        if self._client and self.state.connected:\n            # Log outgoing message if logging is enabled\n            if self._logging_enabled:\n                self._message_log.append(\n                    MQTTLogEntry(\n                        timestamp=datetime.now(timezone.utc).isoformat(),\n                        topic=self.topic_publish,\n                        direction=\"out\",\n                        payload=command,\n                    )\n                )\n            self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n\n    def enable_logging(self, enabled: bool = True):\n        \"\"\"Enable or disable MQTT message logging.\"\"\"\n        self._logging_enabled = enabled\n        # Don't clear logs when stopping - user can manually clear with clear_logs()\n\n    def get_logs(self) -> list[MQTTLogEntry]:\n        \"\"\"Get all logged MQTT messages.\"\"\"\n        return list(self._message_log)\n\n    def clear_logs(self):\n        \"\"\"Clear the message log.\"\"\"\n        self._message_log.clear()\n\n    @property\n    def logging_enabled(self) -> bool:\n        \"\"\"Check if logging is enabled.\"\"\"\n        return self._logging_enabled\n\n    def send_drying_command(\n        self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = \"\", rotate_tray: bool = False\n    ):\n        \"\"\"Send AMS drying start/stop command.\n\n        Args:\n            ams_id: AMS unit ID (0-3 for AMS 2 Pro, 128-135 for AMS-HT)\n            temp: Target drying temperature (45-65 for AMS 2 Pro, 45-85 for AMS-HT)\n            duration: Drying duration in hours\n            mode: 1=start, 0=stop\n            filament: Filament type string (e.g. \"PLA\", \"PETG\")\n            rotate_tray: Whether to rotate the spool during drying for even heat\n        \"\"\"\n        if not self._client:\n            return False\n        self._sequence_id += 1\n        command = {\n            \"print\": {\n                \"sequence_id\": str(self._sequence_id),\n                \"command\": \"ams_filament_drying\",\n                \"ams_id\": ams_id,\n                \"temp\": temp,\n                \"cooling_temp\": 20 if mode == 1 else 0,\n                \"duration\": duration,\n                \"humidity\": 0,\n                \"mode\": mode,\n                \"rotate_tray\": rotate_tray,\n                \"filament\": filament,\n                \"close_power_conflict\": False,\n            }\n        }\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\n            \"[%s] Sent drying command: ams_id=%d, temp=%d, duration=%d, mode=%d\",\n            self.serial_number,\n            ams_id,\n            temp,\n            duration,\n            mode,\n        )\n        return True\n\n    def _handle_kprofile_response(self, data: dict):\n        \"\"\"Handle K-profile response from printer.\"\"\"\n        response_nozzle = data.get(\"nozzle_diameter\")\n        response_seq_id = data.get(\"sequence_id\", \"?\")\n        filaments = data.get(\"filaments\", [])\n        expected_nozzle = getattr(self, \"_expected_kprofile_nozzle\", None)\n        has_pending_request = self._pending_kprofile_response is not None\n\n        # Log all incoming responses when we have a pending request (for debugging)\n        if has_pending_request:\n            logger.info(\n                f\"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, \"\n                f\"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}\"\n            )\n\n        # If we have a pending request, only accept responses with matching nozzle_diameter\n        # The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response\n        if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle:\n            # Ignore this broadcast, keep waiting for matching response\n            logger.debug(\n                f\"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}\"\n            )\n            return\n\n        # If no pending request, this is just a broadcast - update state silently and return early\n        if not has_pending_request:\n            # Still parse profiles to keep state updated, but don't log\n            profiles = []\n            for f in filaments:\n                if isinstance(f, dict):\n                    try:\n                        cali_idx = f.get(\"cali_idx\", 0)\n                        profiles.append(\n                            KProfile(\n                                slot_id=cali_idx,\n                                extruder_id=int(f.get(\"extruder_id\", 0)),\n                                nozzle_id=str(f.get(\"nozzle_id\", \"\")),\n                                nozzle_diameter=str(f.get(\"nozzle_diameter\", \"0.4\")),\n                                filament_id=str(f.get(\"filament_id\", \"\")),\n                                name=str(f.get(\"name\", \"\")),\n                                k_value=str(f.get(\"k_value\", \"0.000000\")),\n                                n_coef=str(f.get(\"n_coef\", \"0.000000\")),\n                                ams_id=int(f.get(\"ams_id\", 0)),\n                                tray_id=int(f.get(\"tray_id\", -1)),\n                                setting_id=f.get(\"setting_id\"),\n                            )\n                        )\n                    except (ValueError, TypeError):\n                        pass  # Skip malformed K-profile entries; remaining profiles still usable\n            self.state.kprofiles = profiles\n            return\n\n        profiles = []\n\n        for i, f in enumerate(filaments):\n            if isinstance(f, dict):\n                try:\n                    # cali_idx is the actual slot/calibration index from the printer\n                    cali_idx = f.get(\"cali_idx\", i)\n                    profiles.append(\n                        KProfile(\n                            slot_id=cali_idx,\n                            extruder_id=int(f.get(\"extruder_id\", 0)),\n                            nozzle_id=str(f.get(\"nozzle_id\", \"\")),\n                            nozzle_diameter=str(f.get(\"nozzle_diameter\", \"0.4\")),\n                            filament_id=str(f.get(\"filament_id\", \"\")),\n                            name=str(f.get(\"name\", \"\")),\n                            k_value=str(f.get(\"k_value\", \"0.000000\")),\n                            n_coef=str(f.get(\"n_coef\", \"0.000000\")),\n                            ams_id=int(f.get(\"ams_id\", 0)),\n                            tray_id=int(f.get(\"tray_id\", -1)),\n                            setting_id=f.get(\"setting_id\"),\n                        )\n                    )\n                except (ValueError, TypeError) as e:\n                    logger.warning(\"Failed to parse K-profile: %s\", e)\n\n        self.state.kprofiles = profiles\n        self._kprofile_response_data = profiles\n\n        # Signal that we received the response (only if we were waiting for one)\n        # Use thread-safe method since MQTT callbacks run in a different thread\n        # Capture in local var to avoid TOCTOU race: asyncio thread can clear\n        # self._pending_kprofile_response between the check and the .set() call\n        event = self._pending_kprofile_response\n        if event:\n            logger.info(\"[%s] Got %s K-profiles for nozzle=%s\", self.serial_number, len(profiles), response_nozzle)\n            if self._loop and self._loop.is_running():\n                self._loop.call_soon_threadsafe(event.set)\n            else:\n                # Fallback for when loop is not available\n                event.set()\n\n    async def get_kprofiles(\n        self, nozzle_diameter: str = \"0.4\", timeout: float = 5.0, max_retries: int = 3\n    ) -> list[KProfile]:\n        \"\"\"Request K-profiles from the printer with retry logic.\n\n        Bambu printers sometimes ignore the first K-profile request, so we\n        implement retry logic to ensure reliable retrieval.\n\n        Args:\n            nozzle_diameter: Filter by nozzle diameter (e.g., \"0.4\")\n            timeout: Timeout in seconds to wait for each response attempt\n            max_retries: Maximum number of retry attempts\n\n        Returns:\n            List of KProfile objects\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot get K-profiles: not connected\", self.serial_number)\n            return []\n\n        # Capture current event loop for thread-safe callback\n        try:\n            self._loop = asyncio.get_running_loop()\n        except RuntimeError:\n            logger.warning(\"[%s] No running event loop\", self.serial_number)\n            return []\n\n        for attempt in range(max_retries):\n            # Set up response event for this attempt\n            self._sequence_id += 1\n            self._pending_kprofile_response = asyncio.Event()\n            self._kprofile_response_data = None\n            self._expected_kprofile_nozzle = nozzle_diameter  # Track which nozzle response we expect\n\n            # Send the command with nozzle_diameter filter\n            command = {\n                \"print\": {\n                    \"command\": \"extrusion_cali_get\",\n                    \"filament_id\": \"\",\n                    \"nozzle_diameter\": nozzle_diameter,\n                    \"sequence_id\": str(self._sequence_id),\n                }\n            }\n\n            logger.info(\n                f\"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})\"\n            )\n            logger.debug(\"[%s] K-profile request JSON: %s\", self.serial_number, json.dumps(command))\n            self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n\n            # Wait for response (response handler already filters by nozzle_diameter)\n            try:\n                await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)\n                profiles = self._kprofile_response_data or []\n                logger.info(\n                    f\"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}\"\n                )\n                return profiles\n            except TimeoutError:\n                logger.warning(\n                    f\"[{self.serial_number}] Timeout on K-profiles request attempt {attempt + 1}/{max_retries}\"\n                )\n                if attempt < max_retries - 1:\n                    # Brief delay before retry\n                    await asyncio.sleep(0.5)\n            finally:\n                self._pending_kprofile_response = None\n                self._expected_kprofile_nozzle = None\n\n        logger.error(\"[%s] Failed to get K-profiles after %s attempts\", self.serial_number, max_retries)\n        return []\n\n    def set_kprofile(\n        self,\n        filament_id: str,\n        name: str,\n        k_value: str,\n        nozzle_diameter: str = \"0.4\",\n        nozzle_id: str = \"HS00-0.4\",\n        extruder_id: int = 0,\n        setting_id: str | None = None,\n        slot_id: int = 0,\n        cali_idx: int | None = None,\n    ) -> bool:\n        \"\"\"Set/update a K-profile on the printer.\n\n        Args:\n            filament_id: Bambu filament identifier\n            name: Profile name\n            k_value: Pressure advance value (e.g., \"0.020000\")\n            nozzle_diameter: Nozzle diameter (e.g., \"0.4\")\n            nozzle_id: Nozzle identifier (e.g., \"HS00-0.4\")\n            extruder_id: Extruder ID (0 or 1 for dual nozzle)\n            setting_id: Existing setting ID for updates, None for new\n            slot_id: Calibration index (cali_idx) for the profile\n            cali_idx: For edits, the existing slot being edited (enables in-place edit)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set K-profile: not connected\", self.serial_number)\n            return False\n\n        self._sequence_id += 1\n\n        # Build the filament entry - printer uses cali_idx for profile identification\n        # For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot\n        # For edits, use the provided cali_idx or slot_id\n        if cali_idx is not None:\n            effective_cali_idx = cali_idx\n        else:\n            effective_cali_idx = -1 if slot_id == 0 else slot_id\n\n        # Generate a setting_id for new profiles (required by printer)\n        # Format: \"PF\" + 17 random digits\n        import random\n\n        if not setting_id and slot_id == 0:\n            setting_id = f\"PF{random.randint(10000000000000000, 99999999999999999)}\"\n\n        filament_entry = {\n            \"ams_id\": 0,\n            \"cali_idx\": effective_cali_idx,\n            \"extruder_id\": extruder_id,\n            \"filament_id\": filament_id,\n            \"k_value\": k_value,\n            \"n_coef\": \"0.000000\",\n            \"name\": name,\n            \"nozzle_diameter\": nozzle_diameter,\n            \"nozzle_id\": nozzle_id,\n            \"setting_id\": setting_id if setting_id else \"\",\n            \"tray_id\": -1,\n        }\n\n        command = {\n            \"print\": {\n                \"command\": \"extrusion_cali_set\",\n                \"filaments\": [filament_entry],\n                \"nozzle_diameter\": nozzle_diameter,\n                \"sequence_id\": str(self._sequence_id),\n            }\n        }\n\n        command_json = json.dumps(command)\n        logger.info(\n            f\"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})\"\n        )\n        logger.debug(\"[%s] K-profile SET command: %s\", self.serial_number, command_json)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        return True\n\n    def set_kprofiles_batch(\n        self,\n        profiles: list[dict],\n        nozzle_diameter: str = \"0.4\",\n    ) -> bool:\n        \"\"\"Set multiple K-profiles in a single command (for dual-nozzle).\n\n        Args:\n            profiles: List of profile dicts, each with:\n                - filament_id, name, k_value, nozzle_id, extruder_id, setting_id (optional), slot_id\n            nozzle_diameter: Common nozzle diameter for all profiles\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set K-profiles batch: not connected\", self.serial_number)\n            return False\n\n        import random\n\n        self._sequence_id += 1\n\n        filament_entries = []\n        for p in profiles:\n            slot_id = p.get(\"slot_id\", 0)\n            cali_idx = p.get(\"cali_idx\")\n\n            if cali_idx is not None:\n                effective_cali_idx = cali_idx\n            else:\n                effective_cali_idx = -1 if slot_id == 0 else slot_id\n\n            setting_id = p.get(\"setting_id\")\n            if not setting_id and slot_id == 0:\n                setting_id = f\"PF{random.randint(10000000000000000, 99999999999999999)}\"\n\n            filament_entries.append(\n                {\n                    \"ams_id\": 0,\n                    \"cali_idx\": effective_cali_idx,\n                    \"extruder_id\": p.get(\"extruder_id\", 0),\n                    \"filament_id\": p.get(\"filament_id\", \"\"),\n                    \"k_value\": p.get(\"k_value\", \"0.020000\"),\n                    \"n_coef\": \"0.000000\",\n                    \"name\": p.get(\"name\", \"\"),\n                    \"nozzle_diameter\": nozzle_diameter,\n                    \"nozzle_id\": p.get(\"nozzle_id\", f\"HS00-{nozzle_diameter}\"),\n                    \"setting_id\": setting_id if setting_id else \"\",\n                    \"tray_id\": -1,\n                }\n            )\n\n        command = {\n            \"print\": {\n                \"command\": \"extrusion_cali_set\",\n                \"filaments\": filament_entries,\n                \"nozzle_diameter\": nozzle_diameter,\n                \"sequence_id\": str(self._sequence_id),\n            }\n        }\n\n        command_json = json.dumps(command)\n        logger.info(\"[%s] Setting %s K-profiles in batch\", self.serial_number, len(filament_entries))\n        logger.debug(\"[%s] K-profile SET batch command: %s\", self.serial_number, command_json)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        return True\n\n    def delete_kprofile(\n        self,\n        cali_idx: int,\n        filament_id: str,\n        nozzle_id: str,\n        nozzle_diameter: str = \"0.4\",\n        extruder_id: int = 0,\n        setting_id: str | None = None,\n    ) -> bool:\n        \"\"\"Delete a K-profile from the printer.\n\n        Args:\n            cali_idx: The calibration index (slot_id) of the profile to delete\n            filament_id: Bambu filament identifier\n            nozzle_id: Nozzle identifier (e.g., \"HH00-0.4\")\n            nozzle_diameter: Nozzle diameter (e.g., \"0.4\")\n            extruder_id: Extruder ID (0 or 1 for dual nozzle)\n            setting_id: Unique setting identifier (for X1C series)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot delete K-profile: not connected\", self.serial_number)\n            return False\n\n        self._sequence_id += 1\n\n        # Detect printer type by serial number prefix\n        # Dual-nozzle families:\n        #   H2D series: serial starts with \"094\"\n        #   X2D series: serial starts with \"20P9\"\n        is_dual_nozzle = self.serial_number.startswith((\"094\", \"20P9\"))\n\n        if is_dual_nozzle:\n            # H2D format: uses extruder_id, nozzle_id, nozzle_diameter\n            command = {\n                \"print\": {\n                    \"command\": \"extrusion_cali_del\",\n                    \"sequence_id\": str(self._sequence_id),\n                    \"extruder_id\": extruder_id,\n                    \"nozzle_id\": nozzle_id,\n                    \"filament_id\": filament_id,\n                    \"cali_idx\": cali_idx,\n                    \"nozzle_diameter\": nozzle_diameter,\n                }\n            }\n        else:\n            # X1C/P1/A1 format: include all fields like the set command\n            # The delete command structure should match what set uses\n            command = {\n                \"print\": {\n                    \"command\": \"extrusion_cali_del\",\n                    \"sequence_id\": str(self._sequence_id),\n                    \"filament_id\": filament_id,\n                    \"cali_idx\": cali_idx,\n                    \"setting_id\": setting_id if setting_id else \"\",\n                    \"nozzle_diameter\": nozzle_diameter,\n                    \"nozzle_id\": nozzle_id,\n                    \"extruder_id\": extruder_id,\n                }\n            }\n\n        command_json = json.dumps(command)\n        logger.info(\n            f\"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}\"\n        )\n        logger.debug(\"[%s] K-profile DELETE command: %s\", self.serial_number, command_json)\n        # Use QoS 1 for reliable delivery (at least once)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        return True\n\n    # =========================================================================\n    # Printer Control Commands\n    # =========================================================================\n\n    def pause_print(self) -> bool:\n        \"\"\"Pause the current print job.\"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot pause print: not connected\", self.serial_number)\n            return False\n\n        command = {\"print\": {\"command\": \"pause\", \"sequence_id\": \"0\"}}\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\"[%s] Sent pause print command\", self.serial_number)\n        return True\n\n    def resume_print(self) -> bool:\n        \"\"\"Resume a paused print job.\"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot resume print: not connected\", self.serial_number)\n            return False\n\n        command = {\"print\": {\"command\": \"resume\", \"sequence_id\": \"0\"}}\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\"[%s] Sent resume print command\", self.serial_number)\n        return True\n\n    def clear_hms_errors(self) -> bool:\n        \"\"\"Clear HMS/print errors on the printer and locally.\"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot clear HMS errors: not connected\", self.serial_number)\n            return False\n\n        command = {\"print\": {\"command\": \"clean_print_error\", \"sequence_id\": \"0\"}}\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        self.state.hms_errors = []\n        logger.info(\"[%s] Sent clear HMS errors command\", self.serial_number)\n        return True\n\n    def skip_objects(self, object_ids: list[int]) -> bool:\n        \"\"\"Skip specific objects during a print.\n\n        This command tells the printer to skip printing the specified objects.\n        The object IDs come from the slice_info.config file in the 3MF.\n\n        Args:\n            object_ids: List of identify_id values from slice_info.config\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot skip objects: not connected\", self.serial_number)\n            return False\n\n        if self.state.state != \"RUNNING\" and self.state.state != \"PAUSE\":\n            logger.warning(\n                f\"[{self.serial_number}] Cannot skip objects: printer not printing (state={self.state.state})\"\n            )\n            return False\n\n        if not object_ids:\n            logger.warning(\"[%s] Cannot skip objects: no object IDs provided\", self.serial_number)\n            return False\n\n        # Validate all IDs are integers\n        try:\n            obj_list = [int(oid) for oid in object_ids]\n        except (ValueError, TypeError) as e:\n            logger.warning(\"[%s] Invalid object IDs: %s\", self.serial_number, e)\n            return False\n\n        self._sequence_id += 1\n        command = {\"print\": {\"sequence_id\": str(self._sequence_id), \"command\": \"skip_objects\", \"obj_list\": obj_list}}\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\"[%s] Sent skip_objects command: %s\", self.serial_number, obj_list)\n\n        # Track skipped objects in state\n        for oid in obj_list:\n            if oid not in self.state.skipped_objects:\n                self.state.skipped_objects.append(oid)\n\n        return True\n\n    def send_gcode(self, gcode: str) -> bool:\n        \"\"\"Send G-code command(s) to the printer.\n\n        Multiple commands can be separated by newlines.\n\n        Args:\n            gcode: G-code command(s) to send\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot send G-code: not connected\", self.serial_number)\n            return False\n\n        self._sequence_id += 1\n        command = {\"print\": {\"command\": \"gcode_line\", \"param\": gcode, \"sequence_id\": str(self._sequence_id)}}\n        # Use QoS 1 for reliable delivery (at least once)\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.debug(\"[%s] Sent G-code: %s...\", self.serial_number, gcode[:50])\n        return True\n\n    def set_bed_temperature(self, target: int) -> bool:\n        \"\"\"Set the bed target temperature.\n\n        Args:\n            target: Target temperature in Celsius (0 to turn off)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        return self.send_gcode(f\"M140 S{target}\")\n\n    def set_nozzle_temperature(self, target: int, nozzle: int = 0) -> bool:\n        \"\"\"Set the nozzle target temperature.\n\n        Args:\n            target: Target temperature in Celsius (0 to turn off)\n            nozzle: Nozzle index (0 for right/default, 1 for left on H2D)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        # Use M104 for non-blocking\n        # Always use T parameter for H2D compatibility\n        result = self.send_gcode(f\"M104 T{nozzle} S{target}\")\n        # H2D quirk: left nozzle (nozzle=1) target isn't reported in MQTT\n        # Track it locally so we can display it correctly\n        if result and nozzle == 1:\n            self.state.temperatures[\"nozzle_target\"] = float(target)\n            self.state.temperatures[\"_nozzle_target_set_time\"] = time.time()\n            logger.info(\"[%s] Tracking LEFT nozzle target locally: %s°C\", self.serial_number, target)\n        return result\n\n    def set_chamber_temperature(self, target: int) -> bool:\n        \"\"\"Set the chamber target temperature.\n\n        Args:\n            target: Target temperature in Celsius (0 to turn off heating)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        # M141 sets chamber temperature\n        result = self.send_gcode(f\"M141 S{target}\")\n        # Track chamber target locally (MQTT reports encoded values that need filtering)\n        if result:\n            self.state.temperatures[\"chamber_target\"] = float(target)\n            self.state.temperatures[\"_chamber_target_set_time\"] = time.time()\n            # Update heating state immediately based on new target\n            current_temp = self.state.temperatures.get(\"chamber\", 0)\n            self.state.temperatures[\"chamber_heating\"] = target > 0 and current_temp < target\n            logger.info(\n                f\"[{self.serial_number}] Tracking chamber target locally: {target}°C (heating={self.state.temperatures['chamber_heating']})\"\n            )\n        return result\n\n    def set_print_speed(self, mode: int) -> bool:\n        \"\"\"Set the print speed mode.\n\n        Args:\n            mode: Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set print speed: not connected\", self.serial_number)\n            return False\n\n        if mode not in (1, 2, 3, 4):\n            logger.warning(\"[%s] Invalid speed mode: %s\", self.serial_number, mode)\n            return False\n\n        command = {\"print\": {\"command\": \"print_speed\", \"param\": str(mode), \"sequence_id\": \"0\"}}\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\"[%s] Set print speed mode to %s\", self.serial_number, mode)\n        return True\n\n    def set_fan_speed(self, fan: int, speed: int) -> bool:\n        \"\"\"Set fan speed.\n\n        Args:\n            fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber)\n            speed: Speed 0-255 (0=off, 255=full)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if fan not in (1, 2, 3):\n            logger.warning(\"[%s] Invalid fan index: %s\", self.serial_number, fan)\n            return False\n\n        speed = max(0, min(255, speed))  # Clamp to 0-255\n        return self.send_gcode(f\"M106 P{fan} S{speed}\")\n\n    def set_part_fan(self, speed: int) -> bool:\n        \"\"\"Set part cooling fan speed (0-255).\"\"\"\n        return self.set_fan_speed(1, speed)\n\n    def set_aux_fan(self, speed: int) -> bool:\n        \"\"\"Set auxiliary fan speed (0-255).\"\"\"\n        return self.set_fan_speed(2, speed)\n\n    def set_chamber_fan(self, speed: int) -> bool:\n        \"\"\"Set chamber fan speed (0-255).\"\"\"\n        return self.set_fan_speed(3, speed)\n\n    def set_airduct_mode(self, mode: str) -> bool:\n        \"\"\"Set air conditioning mode (cooling or heating).\n\n        Args:\n            mode: \"cooling\" (modeId=0) or \"heating\" (modeId=1)\n                - Cooling: Suitable for PLA/PETG/TPU, filters and cools chamber air\n                - Heating: Suitable for ABS/ASA/PC/PA, circulates and heats chamber air,\n                           closes top exhaust flap\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set airduct mode: not connected\", self.serial_number)\n            return False\n\n        self._sequence_id += 1\n        mode_id = 0 if mode == \"cooling\" else 1\n        command = {\n            \"print\": {\"command\": \"set_airduct\", \"modeId\": mode_id, \"sequence_id\": str(self._sequence_id), \"submode\": -1}\n        }\n        # Use QoS 1 for reliable delivery\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\n            \"[%s] Set airduct mode to %s (modeId=%s, seq=%s)\", self.serial_number, mode, mode_id, self._sequence_id\n        )\n        return True\n\n    def set_chamber_light(self, on: bool) -> bool:\n        \"\"\"Turn chamber light on or off.\n\n        Args:\n            on: True to turn on, False to turn off\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set chamber light: not connected\", self.serial_number)\n            return False\n\n        mode = \"on\" if on else \"off\"\n        # Control both chamber lights (some printers like H2D have two)\n        for led_node in [\"chamber_light\", \"chamber_light2\"]:\n            self._sequence_id += 1\n            command = {\n                \"system\": {\n                    \"command\": \"ledctrl\",\n                    \"led_node\": led_node,\n                    \"led_mode\": mode,\n                    \"led_on_time\": 500,\n                    \"led_off_time\": 500,\n                    \"loop_times\": 0,\n                    \"interval_time\": 0,\n                    \"sequence_id\": str(self._sequence_id),\n                }\n            }\n            self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\"[%s] Set chamber lights %s (seq=%s)\", self.serial_number, \"on\" if on else \"off\", self._sequence_id)\n        return True\n\n    def select_extruder(self, extruder: int) -> bool:\n        \"\"\"Select the active extruder for dual-nozzle printers (H2D).\n\n        Args:\n            extruder: Extruder index (0=right, 1=left for H2D)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if extruder not in (0, 1):\n            logger.warning(\"[%s] Invalid extruder: %s\", self.serial_number, extruder)\n            return False\n\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot switch extruder: not connected\", self.serial_number)\n            return False\n\n        # H2D extruder switching via select_extruder command\n        # Command format captured from OrcaSlicer:\n        # {\"print\": {\"command\": \"select_extruder\", \"extruder_index\": 0, \"sequence_id\": \"...\"}}\n        # extruder_index: 0 = RIGHT, 1 = LEFT\n        self._sequence_id += 1\n        command = {\n            \"print\": {\"command\": \"select_extruder\", \"extruder_index\": extruder, \"sequence_id\": str(self._sequence_id)}\n        }\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\n            \"[%s] Sent select_extruder command: extruder_index=%s (0=right, 1=left)\", self.serial_number, extruder\n        )\n        return True\n\n    def home_axes(self, axes: str = \"XYZ\") -> bool:\n        \"\"\"Run the printer's full auto-home sequence.\n\n        The ``axes`` argument is ignored: a bare ``G28`` is always sent so\n        Bambu firmware runs its safe multi-step routine (park toolhead →\n        home XY → home Z). Partial-axis variants like ``G28 Z`` skip the\n        toolhead-park step and can crash the bed into the toolhead on H2C\n        / H2D / H2S / X1 where Z-home moves the bed UP — see #1052.\n        \"\"\"\n        return self.send_gcode(\"G28\")\n\n    def move_axis(self, axis: str, distance: float, speed: int = 3000) -> bool:\n        \"\"\"Move an axis by a relative distance.\n\n        Args:\n            axis: Axis to move (\"X\", \"Y\", or \"Z\")\n            distance: Distance to move in mm (positive or negative)\n            speed: Movement speed in mm/min\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        axis = axis.upper()\n        if axis not in (\"X\", \"Y\", \"Z\"):\n            logger.warning(\"[%s] Invalid axis: %s\", self.serial_number, axis)\n            return False\n\n        # G91 = relative mode, G0 = rapid move, G90 = back to absolute\n        gcode = f\"G91\\nG0 {axis}{distance:.2f} F{speed}\\nG90\"\n        return self.send_gcode(gcode)\n\n    def disable_motors(self) -> bool:\n        \"\"\"Disable all stepper motors.\n\n        Warning: This will cause the printer to lose its position.\n        A homing operation will be required before printing.\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        return self.send_gcode(\"M18\")\n\n    def enable_motors(self) -> bool:\n        \"\"\"Enable all stepper motors.\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        return self.send_gcode(\"M17\")\n\n    def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:\n        \"\"\"Load filament from a specific AMS tray.\n\n        Args:\n            tray_id: Global tray ID (0-15 for AMS slots, or 254 for external spool)\n            extruder_id: Unused - kept for API compatibility\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot load filament: not connected\", self.serial_number)\n            return False\n\n        # Calculate ams_id and slot_id for logging\n        if tray_id == 254:\n            ams_id = 255  # External spool\n            slot_id = 254\n        else:\n            ams_id = tray_id // 4  # AMS unit (0, 1, 2, 3...)\n            slot_id = tray_id % 4  # Slot within AMS (0, 1, 2, 3)\n\n        # Command format from BambuStudio traffic capture:\n        # - No extruder_id field\n        # - curr_temp and tar_temp are -1 (not 0)\n        self._sequence_id += 1\n        command = {\n            \"print\": {\n                \"command\": \"ams_change_filament\",\n                \"sequence_id\": str(self._sequence_id),\n                \"ams_id\": ams_id,\n                \"slot_id\": slot_id,\n                \"target\": tray_id,\n                \"curr_temp\": -1,\n                \"tar_temp\": -1,\n            }\n        }\n\n        command_json = json.dumps(command)\n        logger.info(\"[%s] Publishing ams_change_filament command: %s\", self.serial_number, command_json)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        logger.info(\"[%s] Loading filament from tray %s (AMS %s slot %s)\", self.serial_number, tray_id, ams_id, slot_id)\n\n        # Track this load request for H2D dual-nozzle disambiguation\n        # H2D reports only slot number (0-3) in tray_now, so we use our tracked value\n        self._last_load_tray_id = tray_id\n        self.state.pending_tray_target = tray_id\n        logger.info(\"[%s] Set pending_tray_target=%s for H2D disambiguation\", self.serial_number, tray_id)\n\n        return True\n\n    def ams_unload_filament(self) -> bool:\n        \"\"\"Unload the currently loaded filament.\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot unload filament: not connected\", self.serial_number)\n            return False\n\n        # Get the currently loaded tray info\n        tray_now = self.state.tray_now\n        logger.info(\"[%s] Unload requested, tray_now=%s\", self.serial_number, tray_now)\n\n        # Determine source ams_id for the unload command\n        if tray_now == 255 or tray_now == 254:\n            ams_id = 255  # No filament or external spool\n        else:\n            ams_id = tray_now // 4  # Source AMS\n\n        # Command format from BambuStudio traffic capture:\n        # - No extruder_id field\n        # - For UNLOAD: curr_temp and tar_temp are the actual nozzle temp (e.g., 210)\n        # - slot_id=255 and target=255 for unload\n        # Get current nozzle temperature for the unload command\n        nozzle_temp = int(self.state.temperatures.get(\"nozzle\", 210))\n        if nozzle_temp < 180:\n            nozzle_temp = 210  # Default to PLA temp if nozzle is cold\n\n        self._sequence_id += 1\n        command = {\n            \"print\": {\n                \"command\": \"ams_change_filament\",\n                \"sequence_id\": str(self._sequence_id),\n                \"ams_id\": ams_id,\n                \"slot_id\": 255,  # 255 = unload marker\n                \"target\": 255,  # 255 = unload destination\n                \"curr_temp\": nozzle_temp,\n                \"tar_temp\": nozzle_temp,\n            }\n        }\n\n        command_json = json.dumps(command)\n        logger.info(\"[%s] Publishing ams_change_filament (unload) command: %s\", self.serial_number, command_json)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        logger.info(\"[%s] Unloading filament (tray_now was %s)\", self.serial_number, tray_now)\n\n        # Clear tracked load request since we're unloading\n        self._last_load_tray_id = None\n        self.state.pending_tray_target = None\n        logger.info(\"[%s] Cleared pending_tray_target (unload)\", self.serial_number)\n\n        return True\n\n    def ams_control(self, action: str) -> bool:\n        \"\"\"Control AMS operations.\n\n        Args:\n            action: \"resume\", \"reset\", or \"pause\"\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot control AMS: not connected\", self.serial_number)\n            return False\n\n        if action not in (\"resume\", \"reset\", \"pause\"):\n            logger.warning(\"[%s] Invalid AMS action: %s\", self.serial_number, action)\n            return False\n\n        command = {\"print\": {\"command\": \"ams_control\", \"param\": action, \"sequence_id\": \"0\"}}\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\"[%s] AMS control: %s\", self.serial_number, action)\n        return True\n\n    def ams_refresh_tray(self, ams_id: int, tray_id: int) -> tuple[bool, str]:\n        \"\"\"Trigger RFID re-read for a specific AMS tray.\n\n        Args:\n            ams_id: AMS unit ID (0-3, or 128 for H2D external tray)\n            tray_id: Tray ID within the AMS (0-3)\n\n        Returns:\n            Tuple of (success, message)\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot refresh AMS tray: not connected\", self.serial_number)\n            return False, \"Printer not connected\"\n\n        # Check if filament is currently loaded (tray_now != 255)\n        # RFID refresh requires the AMS to move filament, which can't happen if one is loaded\n        tray_now = self.state.tray_now\n        if tray_now != 255:\n            # Decode which tray is loaded for the message\n            if tray_now == 254:\n                loaded_tray = \"external spool\"\n            elif tray_now >= 0 and tray_now < 128:\n                loaded_ams = tray_now // 4\n                loaded_slot = tray_now % 4\n                loaded_tray = f\"AMS {loaded_ams + 1} slot {loaded_slot + 1}\"\n            else:\n                loaded_tray = f\"tray {tray_now}\"\n            logger.warning(\"[%s] Cannot refresh AMS tray: filament loaded from %s\", self.serial_number, loaded_tray)\n            return False, f\"Please unload filament first. Currently loaded: {loaded_tray}\"\n\n        # Use ams_get_rfid command to trigger RFID re-read\n        # This command is used by Bambu Studio to re-read the RFID tag\n        command = {\"print\": {\"command\": \"ams_get_rfid\", \"ams_id\": ams_id, \"slot_id\": tray_id, \"sequence_id\": \"0\"}}\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\"[%s] Triggering RFID re-read: AMS %s, slot %s\", self.serial_number, ams_id, tray_id)\n\n        return True, f\"Refreshing AMS {ams_id} tray {tray_id}\"\n\n    def ams_set_filament_setting(\n        self,\n        ams_id: int,\n        tray_id: int,\n        tray_info_idx: str,\n        tray_type: str,\n        tray_sub_brands: str,\n        tray_color: str,\n        nozzle_temp_min: int,\n        nozzle_temp_max: int,\n        setting_id: str = \"\",\n    ) -> bool:\n        \"\"\"Set AMS tray filament settings (type, color, temperature).\n\n        Note: K value is set separately via extrusion_cali_sel command.\n\n        Args:\n            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)\n            tray_id: Tray ID within the AMS (0-3)\n            tray_info_idx: Filament ID short format (e.g., \"GFL05\")\n            tray_type: Filament type (e.g., \"PLA\", \"PETG\")\n            tray_sub_brands: Sub-brand name (e.g., \"PLA Basic\", \"PETG HF\")\n            tray_color: Color in RRGGBBAA hex format (e.g., \"FFFF00FF\")\n            nozzle_temp_min: Minimum nozzle temperature\n            nozzle_temp_max: Maximum nozzle temperature\n            setting_id: Full setting ID with version (e.g., \"GFSL05_07\") - optional\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set AMS filament setting: not connected\", self.serial_number)\n            return False\n\n        # Calculate mqtt IDs based on AMS type\n        if ams_id == 255:\n            vt_tray = self.state.raw_data.get(\"vt_tray\", []) if self.state.raw_data else []\n            if len(vt_tray) > 1:\n                # Dual external slots (H2D): each ext slot is its own virtual AMS unit\n                # (254=ext-L / slot 0, 255=ext-R / slot 1)\n                mqtt_ams_id = 254 + tray_id\n            else:\n                # Single external slot (X1C, P1S, A1): always ams_id=255\n                mqtt_ams_id = 255\n            mqtt_tray_id = 0\n            slot_id = 0\n        elif ams_id <= 3:\n            mqtt_ams_id = ams_id\n            mqtt_tray_id = tray_id\n            slot_id = tray_id\n        else:\n            # AMS-HT: single tray per unit\n            mqtt_ams_id = ams_id\n            mqtt_tray_id = tray_id\n            slot_id = 0\n\n        command = {\n            \"print\": {\n                \"command\": \"ams_filament_setting\",\n                \"ams_id\": mqtt_ams_id,\n                \"tray_id\": mqtt_tray_id,\n                \"slot_id\": slot_id,\n                \"tray_info_idx\": tray_info_idx,\n                \"tray_type\": tray_type,\n                \"tray_sub_brands\": tray_sub_brands,\n                \"tray_color\": tray_color,\n                \"nozzle_temp_min\": nozzle_temp_min,\n                \"nozzle_temp_max\": nozzle_temp_max,\n                \"sequence_id\": \"0\",\n            }\n        }\n\n        # Include setting_id if provided (helps slicer show correct profile)\n        if setting_id:\n            command[\"print\"][\"setting_id\"] = setting_id\n\n        command_json = json.dumps(command)\n        logger.info(\n            f\"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, tray_info_idx={tray_info_idx}, setting_id={setting_id}\"\n        )\n        logger.debug(\"[%s] ams_filament_setting command: %s\", self.serial_number, command_json)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        self._last_ams_cmd_time = time.monotonic()\n        return True\n\n    def reset_ams_slot(self, ams_id: int, tray_id: int) -> bool:\n        \"\"\"Reset an AMS slot to empty/unconfigured state.\n\n        Args:\n            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)\n            tray_id: Tray ID within the AMS (0-3)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot reset AMS slot: not connected\", self.serial_number)\n            return False\n\n        # Calculate mqtt IDs based on AMS type\n        if ams_id == 255:\n            vt_tray = self.state.raw_data.get(\"vt_tray\", []) if self.state.raw_data else []\n            if len(vt_tray) > 1:\n                # Dual external slots (H2D): each ext slot is its own virtual AMS unit\n                mqtt_ams_id = 254 + tray_id\n            else:\n                # Single external slot (X1C, P1S, A1): always ams_id=255\n                mqtt_ams_id = 255\n            mqtt_tray_id = 0\n            slot_id = 0\n        elif ams_id <= 3:\n            mqtt_ams_id = ams_id\n            mqtt_tray_id = tray_id\n            slot_id = tray_id\n        else:\n            # AMS-HT: single tray per unit\n            mqtt_ams_id = ams_id\n            mqtt_tray_id = tray_id\n            slot_id = 0\n\n        command = {\n            \"print\": {\n                \"command\": \"ams_filament_setting\",\n                \"ams_id\": mqtt_ams_id,\n                \"tray_id\": mqtt_tray_id,\n                \"slot_id\": slot_id,\n                \"tray_info_idx\": \"\",\n                \"tray_type\": \"\",\n                \"tray_sub_brands\": \"\",\n                \"tray_color\": \"00000000\",\n                \"nozzle_temp_min\": 0,\n                \"nozzle_temp_max\": 0,\n                \"sequence_id\": \"0\",\n            }\n        }\n\n        command_json = json.dumps(command)\n        logger.info(\"[%s] Resetting AMS slot: AMS %s, tray %s\", self.serial_number, ams_id, tray_id)\n        logger.debug(\"[%s] reset_ams_slot command: %s\", self.serial_number, command_json)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        self._last_ams_cmd_time = time.monotonic()\n        return True\n\n    def extrusion_cali_sel(\n        self,\n        ams_id: int,\n        tray_id: int,\n        cali_idx: int,\n        filament_id: str,\n        nozzle_diameter: str = \"0.4\",\n    ) -> bool:\n        \"\"\"Set calibration profile (K value) for an AMS slot.\n\n        This command selects a K profile from the printer's calibration list.\n        Use cali_idx=-1 to use the default K value (0.020).\n\n        Note: Do NOT send setting_id in this command — BambuStudio never includes\n        it, and adding it causes the firmware to mislink the profile on X1C/P1S.\n\n        Args:\n            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)\n            tray_id: Tray ID within the AMS (0-3)\n            cali_idx: Calibration profile index (-1 for default)\n            filament_id: Filament preset ID (same as tray_info_idx)\n            nozzle_diameter: Nozzle diameter string (e.g., \"0.4\")\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set calibration: not connected\", self.serial_number)\n            return False\n\n        # Calculate mqtt IDs based on AMS type.\n        # IMPORTANT: extrusion_cali_sel uses GLOBAL tray_id (unlike ams_filament_setting\n        # which uses LOCAL).  BambuStudio confirms: tray_id = ams_id * 4 + slot.\n        if ams_id == 255:\n            # External spool: extrusion_cali_sel uses GLOBAL tray_id (unlike\n            # ams_filament_setting which uses LOCAL tray_id=0).\n            vt_tray = self.state.raw_data.get(\"vt_tray\", []) if self.state.raw_data else []\n            if len(vt_tray) > 1:\n                # Dual external slots (H2D): each ext slot is its own virtual AMS unit\n                # Confirmed from BambuStudio logs: ext-R sends ams_id=255, tray_id=255\n                mqtt_ams_id = 254 + tray_id\n                mqtt_tray_id = 254 + tray_id\n            else:\n                # Single external slot (X1C, P1S, A1): global tray_id=254\n                mqtt_ams_id = 254\n                mqtt_tray_id = 254\n            slot_id = 0\n        elif ams_id <= 3:\n            mqtt_ams_id = ams_id\n            mqtt_tray_id = ams_id * 4 + tray_id\n            slot_id = tray_id\n        elif ams_id >= 128 and ams_id <= 135:\n            mqtt_ams_id = ams_id\n            mqtt_tray_id = tray_id\n            slot_id = 0\n        else:\n            mqtt_ams_id = ams_id\n            mqtt_tray_id = tray_id\n            slot_id = 0\n\n        command = {\n            \"print\": {\n                \"command\": \"extrusion_cali_sel\",\n                \"cali_idx\": cali_idx,\n                \"filament_id\": filament_id,\n                \"nozzle_diameter\": nozzle_diameter,\n                \"ams_id\": mqtt_ams_id,\n                \"tray_id\": mqtt_tray_id,\n                \"slot_id\": slot_id,\n                \"sequence_id\": \"0\",\n            }\n        }\n\n        command_json = json.dumps(command)\n        logger.info(\n            f\"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}\"\n        )\n        logger.debug(\"[%s] extrusion_cali_sel command: %s\", self.serial_number, command_json)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        return True\n\n    def extrusion_cali_set(\n        self,\n        tray_id: int,\n        k_value: float,\n        nozzle_diameter: str = \"0.4\",\n        nozzle_temp: int = 220,\n        filament_id: str = \"\",\n        setting_id: str = \"\",\n        name: str = \"\",\n        cali_idx: int = -1,\n    ) -> bool:\n        \"\"\"Directly set K value (pressure advance) for a tray.\n\n        Uses the filaments array format required by current firmware.\n\n        Args:\n            tray_id: Global tray ID (ams_id * 4 + slot)\n            k_value: Pressure advance K value (e.g., 0.020)\n            nozzle_diameter: Nozzle diameter string (e.g., \"0.4\")\n            nozzle_temp: Nozzle temperature for calibration reference\n            filament_id: Filament preset ID (e.g., \"GFA02\")\n            setting_id: Setting ID (e.g., \"GFSA02_07\")\n            name: Profile display name\n            cali_idx: Calibration index (-1 for new)\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set K value: not connected\", self.serial_number)\n            return False\n\n        nozzle_id = f\"HS00-{nozzle_diameter}\"\n\n        filament_entry = {\n            \"ams_id\": 0,\n            \"cali_idx\": cali_idx,\n            \"extruder_id\": 0,\n            \"filament_id\": filament_id,\n            \"k_value\": f\"{k_value:.6f}\",\n            \"n_coef\": \"1.400000\",\n            \"name\": name,\n            \"nozzle_diameter\": nozzle_diameter,\n            \"nozzle_id\": nozzle_id,\n            \"setting_id\": setting_id,\n            \"tray_id\": tray_id,\n        }\n\n        command = {\n            \"print\": {\n                \"command\": \"extrusion_cali_set\",\n                \"filaments\": [filament_entry],\n                \"nozzle_diameter\": nozzle_diameter,\n                \"sequence_id\": str(self._sequence_id),\n            }\n        }\n\n        command_json = json.dumps(command)\n        logger.info(\"[%s] Publishing extrusion_cali_set: tray %s, k_value=%s\", self.serial_number, tray_id, k_value)\n        logger.debug(\"[%s] extrusion_cali_set command: %s\", self.serial_number, command_json)\n        self._client.publish(self.topic_publish, command_json, qos=1)\n        return True\n\n    def set_timelapse(self, enable: bool) -> bool:\n        \"\"\"Enable or disable timelapse recording.\n\n        Args:\n            enable: True to enable, False to disable\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set timelapse: not connected\", self.serial_number)\n            return False\n\n        command = {\"pushing\": {\"command\": \"pushall\", \"sequence_id\": \"0\"}}\n        # First send the timelapse setting\n        timelapse_cmd = {\n            \"print\": {\"command\": \"gcode_line\", \"param\": f\"M981 S{1 if enable else 0} P20000\", \"sequence_id\": \"0\"}\n        }\n        self._client.publish(self.topic_publish, json.dumps(timelapse_cmd), qos=1)\n        # Request status update\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        logger.info(\"[%s] Set timelapse %s\", self.serial_number, \"enabled\" if enable else \"disabled\")\n        return True\n\n    def set_liveview(self, enable: bool) -> bool:\n        \"\"\"Enable or disable live view / camera streaming.\n\n        Args:\n            enable: True to enable, False to disable\n\n        Returns:\n            True if command was sent, False otherwise\n        \"\"\"\n        if not self._client or not self.state.connected:\n            logger.warning(\"[%s] Cannot set liveview: not connected\", self.serial_number)\n            return False\n\n        command = {\n            \"xcam\": {\"command\": \"ipcam_record_set\", \"control\": \"enable\" if enable else \"disable\", \"sequence_id\": \"0\"}\n        }\n        self._client.publish(self.topic_publish, json.dumps(command), qos=1)\n        # Request status update\n        pushall = {\"pushing\": {\"command\": \"pushall\", \"sequence_id\": \"0\"}}\n        self._client.publish(self.topic_publish, json.dumps(pushall), qos=1)\n        logger.info(\"[%s] Set liveview %s\", self.serial_number, \"enabled\" if enable else \"disabled\")\n        return True\n"
  },
  {
    "path": "backend/app/services/bug_report.py",
    "content": "\"\"\"Bug report service — posts to the bambuddy.cool relay which holds the GitHub PAT.\"\"\"\n\nimport logging\nimport time\n\nimport httpx\n\nfrom backend.app.core.config import BUG_REPORT_RELAY_URL\nfrom backend.app.core.database import async_session\nfrom backend.app.models.bug_report import BugReport\n\nlogger = logging.getLogger(__name__)\n\n# Rate limiting: max 5 reports per hour\n_rate_limit_window = 3600\n_rate_limit_max = 5\n_rate_limit_timestamps: list[float] = []\n\n\ndef _check_rate_limit() -> bool:\n    \"\"\"Check if rate limit allows a new report. Returns True if allowed.\"\"\"\n    now = time.time()\n    _rate_limit_timestamps[:] = [t for t in _rate_limit_timestamps if now - t < _rate_limit_window]\n    if len(_rate_limit_timestamps) >= _rate_limit_max:\n        return False\n    _rate_limit_timestamps.append(now)\n    return True\n\n\nasync def submit_report(\n    description: str,\n    reporter_email: str | None,\n    screenshot_base64: str | None,\n    support_info: dict | None,\n) -> dict:\n    \"\"\"Submit a bug report via the bambuddy.cool relay.\"\"\"\n    if not _check_rate_limit():\n        return {\n            \"success\": False,\n            \"message\": \"Rate limit exceeded. Please try again later.\",\n            \"issue_url\": None,\n            \"issue_number\": None,\n        }\n\n    if not BUG_REPORT_RELAY_URL:\n        return {\n            \"success\": False,\n            \"message\": \"Bug reporting is not configured. BUG_REPORT_RELAY_URL is not set.\",\n            \"issue_url\": None,\n            \"issue_number\": None,\n        }\n\n    # Build relay payload — email is sent to relay for maintainer notification + issue body\n    payload: dict = {\"description\": description}\n    if reporter_email:\n        payload[\"reporter_email\"] = reporter_email\n    if screenshot_base64:\n        payload[\"screenshot_base64\"] = screenshot_base64\n    if support_info:\n        payload[\"support_info\"] = support_info\n\n    try:\n        async with httpx.AsyncClient(timeout=60.0) as client:\n            resp = await client.post(BUG_REPORT_RELAY_URL, json=payload)\n            if resp.status_code != 200:\n                error_msg = f\"Relay returned HTTP {resp.status_code}\"\n                logger.error(\"%s at %s\", error_msg, BUG_REPORT_RELAY_URL)\n                async with async_session() as db:\n                    report = BugReport(\n                        description=description,\n                        reporter_email=reporter_email,\n                        status=\"failed\",\n                        error_message=error_msg,\n                    )\n                    db.add(report)\n                    await db.commit()\n                return {\n                    \"success\": False,\n                    \"message\": \"Bug report relay is not available. Please try again later.\",\n                    \"issue_url\": None,\n                    \"issue_number\": None,\n                }\n            relay_data = resp.json()\n    except Exception:\n        logger.exception(\"Failed to reach bug report relay at %s\", BUG_REPORT_RELAY_URL)\n        async with async_session() as db:\n            report = BugReport(\n                description=description,\n                reporter_email=reporter_email,\n                status=\"failed\",\n                error_message=\"Failed to reach bug report relay\",\n            )\n            db.add(report)\n            await db.commit()\n\n        return {\n            \"success\": False,\n            \"message\": \"Failed to submit bug report. Please try again later.\",\n            \"issue_url\": None,\n            \"issue_number\": None,\n        }\n\n    if not relay_data.get(\"success\"):\n        async with async_session() as db:\n            report = BugReport(\n                description=description,\n                reporter_email=reporter_email,\n                status=\"failed\",\n                error_message=relay_data.get(\"message\", \"Relay returned failure\"),\n            )\n            db.add(report)\n            await db.commit()\n\n        return {\n            \"success\": False,\n            \"message\": relay_data.get(\"message\", \"Failed to create bug report.\"),\n            \"issue_url\": None,\n            \"issue_number\": None,\n        }\n\n    issue_number = relay_data[\"issue_number\"]\n    issue_url = relay_data[\"issue_url\"]\n\n    # Save to DB\n    async with async_session() as db:\n        report = BugReport(\n            description=description,\n            reporter_email=reporter_email,\n            github_issue_number=issue_number,\n            github_issue_url=issue_url,\n            status=\"submitted\",\n            email_sent=True,\n        )\n        db.add(report)\n        await db.commit()\n\n    return {\n        \"success\": True,\n        \"message\": \"Bug report submitted successfully!\",\n        \"issue_url\": issue_url,\n        \"issue_number\": issue_number,\n    }\n"
  },
  {
    "path": "backend/app/services/camera.py",
    "content": "\"\"\"Camera capture service for Bambu Lab printers.\n\nSupports two camera protocols:\n- RTSP: Used by X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S (port 322)\n- Chamber Image: Used by A1, A1MINI, P1P, P1S (port 6000, custom binary protocol)\n\"\"\"\n\nimport asyncio\nimport logging\nimport os\nimport shutil\nimport ssl\nimport struct\nimport uuid\nfrom datetime import datetime\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n# JPEG markers\nJPEG_START = b\"\\xff\\xd8\"\nJPEG_END = b\"\\xff\\xd9\"\n\n# Cache the ffmpeg path after first lookup\n_ffmpeg_path: str | None = None\n\n# Track PIDs of ffmpeg processes spawned for one-shot frame capture (snapshot).\n# The cleanup task in routes/camera.py checks this set to avoid killing active captures.\n_active_capture_pids: set[int] = set()\n\n\ndef get_ffmpeg_path() -> str | None:\n    \"\"\"Find the ffmpeg executable path.\n\n    Uses shutil.which first, then checks common installation locations\n    for systems where PATH may be limited (e.g., systemd services).\n    \"\"\"\n    global _ffmpeg_path\n\n    if _ffmpeg_path is not None:\n        return _ffmpeg_path\n\n    # Try PATH first\n    ffmpeg_path = shutil.which(\"ffmpeg\")\n\n    # If not found via PATH, check common installation locations\n    if ffmpeg_path is None:\n        common_paths = [\n            \"/usr/bin/ffmpeg\",\n            \"/usr/local/bin/ffmpeg\",\n            \"/opt/homebrew/bin/ffmpeg\",  # macOS Homebrew\n            \"/snap/bin/ffmpeg\",  # Ubuntu Snap\n            \"C:\\\\ffmpeg\\\\bin\\\\ffmpeg.exe\",  # Windows common\n        ]\n        for path in common_paths:\n            if Path(path).exists():\n                ffmpeg_path = path\n                break\n\n    _ffmpeg_path = ffmpeg_path\n    if ffmpeg_path:\n        logger.info(\"Found ffmpeg at: %s\", ffmpeg_path)\n    else:\n        logger.warning(\"ffmpeg not found in PATH or common locations\")\n\n    return ffmpeg_path\n\n\ndef supports_rtsp(model: str | None) -> bool:\n    \"\"\"Check if printer model supports RTSP camera streaming.\n\n    RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S\n    Chamber image only: A1, A1MINI, P1P, P1S\n\n    Note: Model can be either display name (e.g., \"P2S\") or internal code (e.g., \"N7\").\n    Internal codes from MQTT/SSDP:\n      - BL-P001: X1/X1C\n      - C13: X1E\n      - N6: X2D\n      - O1D: H2D\n      - O1C, O1C2: H2C\n      - O1S: H2S\n      - O1E, O2D: H2D Pro\n      - N7: P2S\n    \"\"\"\n    if model:\n        model_upper = model.upper()\n        # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S\n        if model_upper.startswith((\"X1\", \"X2\", \"H2\", \"P2\")):\n            return True\n        # Internal codes for RTSP models\n        if model_upper in (\"BL-P001\", \"C13\", \"N6\", \"O1D\", \"O1C\", \"O1C2\", \"O1S\", \"O1E\", \"O2D\", \"N7\"):\n            return True\n    # A1/P1 and unknown models use chamber image protocol\n    return False\n\n\ndef get_camera_port(model: str | None) -> int:\n    \"\"\"Get the camera port based on printer model.\n\n    X1/X2/H2/P2 series use RTSP on port 322.\n    A1/P1 series use chamber image protocol on port 6000.\n    \"\"\"\n    if supports_rtsp(model):\n        return 322\n    return 6000\n\n\ndef rewrite_rtsp_request_url(data: bytes, proxy_url: bytes, real_url: bytes) -> bytes:\n    \"\"\"Rewrite RTSP request-line URLs, leaving other lines (e.g. Authorization) intact.\n\n    RTSP request lines have the form ``METHOD <url> RTSP/1.0\\\\r\\\\n``.\n    Only those lines are modified so that Digest auth headers (which embed\n    the original URL and a cryptographic hash) are not broken.\n    \"\"\"\n    rtsp_marker = b\" RTSP/1.0\"\n    if rtsp_marker not in data:\n        return data\n    lines = data.split(b\"\\r\\n\")\n    for i, line in enumerate(lines):\n        if line.endswith(rtsp_marker):\n            lines[i] = line.replace(proxy_url, real_url)\n            break\n    return b\"\\r\\n\".join(lines)\n\n\nasync def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, \"asyncio.Server\"]:\n    \"\"\"Create a local TCP→TLS proxy for RTSP streams.\n\n    Bambu printers use RTSPS (RTSP over TLS) with self-signed certificates.\n    The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject\n    certain TLS behaviors (renegotiation, legacy ciphers) that some printer\n    firmwares (notably P2S) rely on.  This causes streams to drop after a\n    few seconds.\n\n    This proxy terminates TLS using Python's ssl module (OpenSSL), which is\n    more permissive, and exposes a plain TCP port that ffmpeg connects to\n    with ``rtsp://`` instead of ``rtsps://``.\n\n    RTSP embeds URLs in protocol messages (DESCRIBE, SETUP, PLAY).  The proxy\n    rewrites ``127.0.0.1:<proxy_port>`` → ``<target_host>:<target_port>`` in\n    client→server data so the printer recognises the stream path.\n\n    Returns ``(local_port, server)``.  Caller must close the server when done.\n    \"\"\"\n    ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)\n    ssl_ctx.check_hostname = False\n    ssl_ctx.verify_mode = ssl.CERT_NONE\n\n    # Filled in after the server socket is created (handler only runs after).\n    _local_port: list[int] = [0]\n\n    async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):\n        tls_writer = None\n        try:\n            tls_reader, tls_writer = await asyncio.wait_for(\n                asyncio.open_connection(target_host, target_port, ssl=ssl_ctx),\n                timeout=10.0,\n            )\n\n            # URL patterns for RTSP request-line rewriting.\n            proxy_url = f\"rtsp://127.0.0.1:{_local_port[0]}\".encode()\n            real_url = f\"rtsps://{target_host}:{target_port}\".encode()\n\n            async def _fwd_to_server(src: asyncio.StreamReader, dst: asyncio.StreamWriter):\n                \"\"\"Forward client→server, rewriting RTSP request-line URLs only.\"\"\"\n                try:\n                    while True:\n                        data = await src.read(65536)\n                        if not data:\n                            break\n                        data = rewrite_rtsp_request_url(data, proxy_url, real_url)\n                        dst.write(data)\n                        await dst.drain()\n                except (ConnectionError, OSError, asyncio.CancelledError):\n                    pass\n                finally:\n                    if not dst.is_closing():\n                        try:\n                            dst.close()\n                        except OSError:\n                            pass\n\n            async def _fwd_to_client(src: asyncio.StreamReader, dst: asyncio.StreamWriter):\n                \"\"\"Forward server→client unchanged.\"\"\"\n                try:\n                    while True:\n                        data = await src.read(65536)\n                        if not data:\n                            break\n                        dst.write(data)\n                        await dst.drain()\n                except (ConnectionError, OSError, asyncio.CancelledError):\n                    pass\n                finally:\n                    if not dst.is_closing():\n                        try:\n                            dst.close()\n                        except OSError:\n                            pass\n\n            await asyncio.gather(\n                _fwd_to_server(client_reader, tls_writer),\n                _fwd_to_client(tls_reader, client_writer),\n            )\n        except (ConnectionError, OSError, TimeoutError) as e:\n            logger.debug(\"TLS proxy connection to %s:%s failed: %s\", target_host, target_port, e)\n        finally:\n            for w in (client_writer, tls_writer):\n                if w and not w.is_closing():\n                    try:\n                        w.close()\n                    except OSError:\n                        pass\n\n    server = await asyncio.start_server(_handle, \"127.0.0.1\", 0)\n    _local_port[0] = server.sockets[0].getsockname()[1]\n    logger.debug(\"TLS proxy for %s:%s listening on 127.0.0.1:%s\", target_host, target_port, _local_port[0])\n    return _local_port[0], server\n\n\ndef is_chamber_image_model(model: str | None) -> bool:\n    \"\"\"Check if printer uses chamber image protocol instead of RTSP.\n\n    A1, A1MINI, P1P, P1S use the chamber image protocol on port 6000.\n    \"\"\"\n    return not supports_rtsp(model)\n\n\ndef build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:\n    \"\"\"Build the RTSPS URL for the printer camera (RTSP models only).\"\"\"\n    port = get_camera_port(model)\n    return f\"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1\"\n\n\ndef _create_chamber_auth_payload(access_code: str) -> bytes:\n    \"\"\"Create the 80-byte authentication payload for chamber image protocol.\n\n    Format:\n    - Bytes 0-3: 0x40 0x00 0x00 0x00 (magic)\n    - Bytes 4-7: 0x00 0x30 0x00 0x00 (command)\n    - Bytes 8-15: zeros (padding)\n    - Bytes 16-47: username \"bblp\" (32 bytes, null-padded)\n    - Bytes 48-79: access code (32 bytes, null-padded)\n    \"\"\"\n    username = b\"bblp\"\n    access_code_bytes = access_code.encode(\"utf-8\")\n\n    # Build the 80-byte payload\n    payload = struct.pack(\n        \"<II8s32s32s\",\n        0x40,  # Magic header\n        0x3000,  # Command\n        b\"\\x00\" * 8,  # Padding\n        username.ljust(32, b\"\\x00\"),  # Username padded to 32 bytes\n        access_code_bytes.ljust(32, b\"\\x00\"),  # Access code padded to 32 bytes\n    )\n    return payload\n\n\ndef _create_ssl_context() -> ssl.SSLContext:\n    \"\"\"Create an SSL context for chamber image connection.\n\n    Bambu printers use self-signed certificates, so we disable verification.\n    \"\"\"\n    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)\n    ctx.check_hostname = False\n    ctx.verify_mode = ssl.CERT_NONE\n    return ctx\n\n\nasync def read_chamber_image_frame(\n    ip_address: str,\n    access_code: str,\n    timeout: float = 10.0,\n) -> bytes | None:\n    \"\"\"Read a single JPEG frame from the chamber image protocol.\n\n    This is used by A1/P1 printers which don't support RTSP.\n\n    Args:\n        ip_address: Printer IP address\n        access_code: Printer access code\n        timeout: Connection timeout in seconds\n\n    Returns:\n        JPEG image data or None if failed\n    \"\"\"\n    port = 6000\n    ssl_context = _create_ssl_context()\n\n    try:\n        # Connect with SSL\n        reader, writer = await asyncio.wait_for(\n            asyncio.open_connection(ip_address, port, ssl=ssl_context),\n            timeout=timeout,\n        )\n\n        try:\n            # Send authentication payload\n            auth_payload = _create_chamber_auth_payload(access_code)\n            writer.write(auth_payload)\n            await writer.drain()\n\n            # Read the 16-byte header\n            header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)\n            if len(header) < 16:\n                logger.error(\"Chamber image: incomplete header received\")\n                return None\n\n            # Parse payload size from header (little-endian uint32 at offset 0)\n            payload_size = struct.unpack(\"<I\", header[0:4])[0]\n\n            if payload_size == 0 or payload_size > 10_000_000:  # Sanity check: max 10MB\n                logger.error(\"Chamber image: invalid payload size %s\", payload_size)\n                return None\n\n            # Read the JPEG data\n            jpeg_data = await asyncio.wait_for(\n                reader.readexactly(payload_size),\n                timeout=timeout,\n            )\n\n            # Validate JPEG markers\n            if not jpeg_data.startswith(JPEG_START):\n                logger.error(\"Chamber image: data is not a valid JPEG (missing start marker)\")\n                return None\n\n            if not jpeg_data.endswith(JPEG_END):\n                logger.warning(\"Chamber image: JPEG missing end marker, may be truncated\")\n\n            logger.debug(\"Chamber image: received %s bytes\", len(jpeg_data))\n            return jpeg_data\n\n        finally:\n            writer.close()\n            try:\n                await writer.wait_closed()\n            except OSError:\n                pass  # Socket already closed; cleanup is best-effort\n\n    except TimeoutError:\n        logger.error(\"Chamber image: connection timeout to %s:%s\", ip_address, port)\n        return None\n    except ConnectionRefusedError:\n        logger.error(\"Chamber image: connection refused by %s:%s\", ip_address, port)\n        return None\n    except Exception as e:\n        logger.exception(\"Chamber image: error connecting to %s:%s: %s\", ip_address, port, e)\n        return None\n\n\nasync def generate_chamber_image_stream(\n    ip_address: str,\n    access_code: str,\n    fps: int = 5,\n) -> asyncio.StreamReader | None:\n    \"\"\"Create a persistent connection for streaming chamber images.\n\n    Returns a connected reader or None if connection failed.\n    \"\"\"\n    port = 6000\n    ssl_context = _create_ssl_context()\n\n    try:\n        reader, writer = await asyncio.wait_for(\n            asyncio.open_connection(ip_address, port, ssl=ssl_context),\n            timeout=10.0,\n        )\n\n        # Send authentication payload\n        auth_payload = _create_chamber_auth_payload(access_code)\n        writer.write(auth_payload)\n        await writer.drain()\n\n        logger.info(\"Chamber image: connected to %s:%s\", ip_address, port)\n        return reader, writer\n\n    except Exception as e:\n        logger.error(\"Chamber image: failed to connect to %s:%s: %s\", ip_address, port, e)\n        return None\n\n\nasync def read_next_chamber_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes | None:\n    \"\"\"Read the next JPEG frame from an established chamber image connection.\"\"\"\n    try:\n        # Read the 16-byte header\n        header = await asyncio.wait_for(reader.readexactly(16), timeout=timeout)\n\n        # Parse payload size from header (little-endian uint32 at offset 0)\n        payload_size = struct.unpack(\"<I\", header[0:4])[0]\n\n        if payload_size == 0 or payload_size > 10_000_000:\n            logger.error(\"Chamber image: invalid payload size %s\", payload_size)\n            return None\n\n        # Read the JPEG data\n        jpeg_data = await asyncio.wait_for(\n            reader.readexactly(payload_size),\n            timeout=timeout,\n        )\n\n        return jpeg_data\n\n    except asyncio.IncompleteReadError:\n        logger.warning(\"Chamber image: connection closed by printer\")\n        return None\n    except TimeoutError:\n        logger.warning(\"Chamber image: read timeout\")\n        return None\n    except Exception as e:\n        logger.error(\"Chamber image: error reading frame: %s\", e)\n        return None\n\n\nasync def capture_camera_frame(\n    ip_address: str,\n    access_code: str,\n    model: str | None,\n    output_path: Path,\n    timeout: int = 30,\n) -> bool:\n    \"\"\"Capture a single frame from the printer's camera stream and save to disk.\n\n    Uses capture_camera_frame_bytes() internally for protocol selection,\n    then writes the result to the specified output path.\n\n    Args:\n        ip_address: Printer IP address\n        access_code: Printer access code\n        model: Printer model (X1, H2D, P1, A1, etc.)\n        output_path: Path where to save the captured image\n        timeout: Timeout in seconds for the capture operation\n\n    Returns:\n        True if capture was successful, False otherwise\n    \"\"\"\n    output_path.parent.mkdir(parents=True, exist_ok=True)\n\n    jpeg_data = await capture_camera_frame_bytes(ip_address, access_code, model, timeout)\n    if jpeg_data:\n        try:\n            with open(output_path, \"wb\") as f:\n                f.write(jpeg_data)\n            logger.info(\"Saved camera frame to: %s\", output_path)\n            return True\n        except OSError as e:\n            logger.error(\"Failed to write camera frame: %s\", e)\n            return False\n    return False\n\n\nasync def capture_camera_frame_bytes(\n    ip_address: str,\n    access_code: str,\n    model: str | None,\n    timeout: int = 15,\n) -> bytes | None:\n    \"\"\"Capture a single frame and return as JPEG bytes (no disk write).\n\n    Uses the same protocol selection as capture_camera_frame but returns\n    bytes directly instead of writing to disk.\n\n    Args:\n        ip_address: Printer IP address\n        access_code: Printer access code\n        model: Printer model (X1, H2D, P1, A1, etc.)\n        timeout: Timeout in seconds for the capture operation\n\n    Returns:\n        JPEG bytes if capture was successful, None otherwise\n    \"\"\"\n    # Chamber image models: A1/P1 - returns bytes directly\n    if is_chamber_image_model(model):\n        logger.info(\"Capturing camera frame bytes from %s using chamber image protocol (model: %s)\", ip_address, model)\n        return await read_chamber_image_frame(ip_address, access_code, timeout=float(timeout))\n\n    # RTSP models: X1/H2/P2 - use ffmpeg piping to stdout\n    # TLS proxy avoids GnuTLS compatibility issues with some printer firmwares\n    port = get_camera_port(model)\n    proxy_port, proxy_server = await create_tls_proxy(ip_address, port)\n    camera_url = f\"rtsp://bblp:{access_code}@127.0.0.1:{proxy_port}/streaming/live/1\"\n\n    ffmpeg = get_ffmpeg_path()\n    if not ffmpeg:\n        proxy_server.close()\n        await proxy_server.wait_closed()\n        logger.error(\"ffmpeg not found for camera frame capture\")\n        return None\n\n    cmd = [\n        ffmpeg,\n        \"-y\",\n        \"-rtsp_transport\",\n        \"tcp\",\n        \"-rtsp_flags\",\n        \"prefer_tcp\",\n        \"-i\",\n        camera_url,\n        \"-frames:v\",\n        \"1\",\n        \"-f\",\n        \"image2pipe\",\n        \"-vcodec\",\n        \"mjpeg\",\n        \"-q:v\",\n        \"2\",\n        \"-\",\n    ]\n\n    logger.info(\"Capturing camera frame bytes from %s using RTSP (model: %s)\", ip_address, model)\n\n    process = None\n    try:\n        process = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n\n        _active_capture_pids.add(process.pid)\n        try:\n            stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)\n        except TimeoutError:\n            process.kill()\n            await process.wait()\n            logger.error(\"Camera frame bytes capture timed out after %ss\", timeout)\n            return None\n\n        if process.returncode == 0 and stdout and len(stdout) >= 100:\n            logger.info(\"Successfully captured camera frame bytes: %s bytes\", len(stdout))\n            return stdout\n        else:\n            stderr_text = stderr.decode() if stderr else \"Unknown error\"\n            logger.error(\"ffmpeg frame bytes capture failed (code %s): %s\", process.returncode, stderr_text[:200])\n            return None\n\n    except FileNotFoundError:\n        logger.error(\"ffmpeg not found for camera frame capture\")\n        return None\n    except Exception as e:\n        logger.exception(\"Camera frame bytes capture failed: %s\", e)\n        return None\n    finally:\n        if process is not None:\n            _active_capture_pids.discard(process.pid)\n        proxy_server.close()\n        await proxy_server.wait_closed()\n\n\nasync def capture_finish_photo(\n    printer_id: int,\n    ip_address: str,\n    access_code: str,\n    model: str | None,\n    archive_dir: Path,\n) -> str | None:\n    \"\"\"Capture a finish photo and save it to the archive's photos folder.\n\n    Args:\n        printer_id: ID of the printer\n        ip_address: Printer IP address\n        access_code: Printer access code\n        model: Printer model\n        archive_dir: Directory of the archive (where the 3MF is stored)\n\n    Returns:\n        Filename of the captured photo, or None if capture failed\n    \"\"\"\n    # Create photos subdirectory\n    photos_dir = archive_dir / \"photos\"\n    photos_dir.mkdir(parents=True, exist_ok=True)\n\n    # Generate filename with timestamp\n    timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n    filename = f\"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg\"\n    output_path = photos_dir / filename\n\n    success = await capture_camera_frame(\n        ip_address=ip_address,\n        access_code=access_code,\n        model=model,\n        output_path=output_path,\n        timeout=30,\n    )\n\n    if success:\n        logger.info(\"Finish photo saved: %s\", filename)\n        return filename\n    else:\n        logger.warning(\"Failed to capture finish photo for printer %s\", printer_id)\n        return None\n\n\nasync def test_camera_connection(\n    ip_address: str,\n    access_code: str,\n    model: str | None,\n) -> dict:\n    \"\"\"Test if the camera stream is accessible.\n\n    Returns dict with success status and any error message.\n    \"\"\"\n    import tempfile\n\n    fd, tmp_name = tempfile.mkstemp(suffix=\".jpg\")\n    os.close(fd)\n    test_path = Path(tmp_name)\n    test_path.chmod(0o600)\n\n    try:\n        success = await capture_camera_frame(\n            ip_address=ip_address,\n            access_code=access_code,\n            model=model,\n            output_path=test_path,\n            timeout=15,\n        )\n\n        if success:\n            return {\"success\": True, \"message\": \"Camera connection successful\"}\n        else:\n            return {\n                \"success\": False,\n                \"error\": (\n                    \"Failed to capture frame from camera. \"\n                    \"Ensure the printer is powered on, camera is enabled, and Developer Mode is active. \"\n                    \"If running in Docker, try 'network_mode: host' in docker-compose.yml.\"\n                ),\n            }\n    finally:\n        # Clean up test file\n        if test_path.exists():\n            test_path.unlink()\n"
  },
  {
    "path": "backend/app/services/discovery.py",
    "content": "\"\"\"\nBambu Lab printer discovery service using SSDP and subnet scanning.\n\nBambu Lab printers advertise themselves via SSDP (Simple Service Discovery Protocol)\non the local network. This service listens for these advertisements and provides\na list of discovered printers.\n\nFor Docker environments where SSDP multicast doesn't work, subnet scanning is\navailable as an alternative discovery method.\n\"\"\"\n\nimport asyncio\nimport ipaddress\nimport logging\nimport os\nimport re\nimport socket\nimport struct\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n\ndef is_running_in_docker() -> bool:\n    \"\"\"Detect if we're running inside a Docker container.\"\"\"\n    # Check for .dockerenv file\n    if Path(\"/.dockerenv\").exists():\n        return True\n\n    # Check cgroup for docker/containerd\n    try:\n        with open(\"/proc/1/cgroup\") as f:\n            content = f.read()\n            if \"docker\" in content or \"containerd\" in content or \"kubepods\" in content:\n                return True\n    except (FileNotFoundError, PermissionError):\n        pass  # /proc/1/cgroup may not exist or be readable; fall through to env check\n\n    # Check for container environment variable\n    return bool(os.environ.get(\"CONTAINER\") or os.environ.get(\"DOCKER_CONTAINER\"))\n\n\n# SSDP multicast address - Bambu uses port 2021, not standard 1900\nSSDP_ADDR = \"239.255.255.250\"\nSSDP_PORT = 2021  # Bambu Lab uses non-standard port\n\n# Bambu Lab SSDP search target\nBAMBU_SEARCH_TARGET = \"urn:bambulab-com:device:3dprinter:1\"\n\n# Virtual printer serial suffix to exclude from discovery (Bambuddy's own virtual printer)\n# All virtual printer serials end with this suffix, regardless of model\nVIRTUAL_PRINTER_SERIAL_SUFFIX = \"391800001\"\n\n# SSDP M-SEARCH message\nSSDP_MSEARCH = (\n    \"M-SEARCH * HTTP/1.1\\r\\n\"\n    f\"HOST: {SSDP_ADDR}:{SSDP_PORT}\\r\\n\"\n    'MAN: \"ssdp:discover\"\\r\\n'\n    \"MX: 3\\r\\n\"\n    f\"ST: {BAMBU_SEARCH_TARGET}\\r\\n\"\n    \"\\r\\n\"\n)\n\n\n@dataclass\nclass DiscoveredPrinter:\n    \"\"\"Represents a discovered Bambu Lab printer.\"\"\"\n\n    serial: str\n    name: str\n    ip_address: str\n    model: str | None = None\n    discovered_at: str | None = None\n\n    def to_dict(self) -> dict:\n        return {\n            \"serial\": self.serial,\n            \"name\": self.name,\n            \"ip_address\": self.ip_address,\n            \"model\": self.model,\n            \"discovered_at\": self.discovered_at,\n        }\n\n\nclass PrinterDiscoveryService:\n    \"\"\"Service for discovering Bambu Lab printers on the network.\"\"\"\n\n    def __init__(self):\n        self._discovered: dict[str, DiscoveredPrinter] = {}\n        self._running = False\n        self._task: asyncio.Task | None = None\n\n    @property\n    def is_running(self) -> bool:\n        return self._running\n\n    @property\n    def discovered_printers(self) -> list[DiscoveredPrinter]:\n        return list(self._discovered.values())\n\n    def clear(self):\n        \"\"\"Clear discovered printers.\"\"\"\n        self._discovered.clear()\n\n    async def start(self, duration: float = 10.0):\n        \"\"\"Start discovery for a specified duration.\"\"\"\n        if self._running:\n            return\n\n        self._running = True\n        self._discovered.clear()\n        self._task = asyncio.create_task(self._discover(duration))\n\n    async def stop(self):\n        \"\"\"Stop discovery.\"\"\"\n        self._running = False\n        if self._task and not self._task.done():\n            self._task.cancel()\n            try:\n                await self._task\n            except asyncio.CancelledError:\n                pass  # Expected when cancelling the discovery task\n        self._task = None\n\n    async def _discover(self, duration: float):\n        \"\"\"Run discovery for the specified duration.\n\n        Bambu printers broadcast NOTIFY messages periodically on port 2021.\n        We need to bind to that port and listen for broadcasts.\n        \"\"\"\n        sock = None\n        try:\n            # Create UDP socket for SSDP\n            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)\n            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n\n            # Try to set SO_REUSEPORT if available (Linux/macOS)\n            try:\n                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)\n            except (AttributeError, OSError):\n                pass  # SO_REUSEPORT not available on all platforms; non-critical\n\n            # Set non-blocking mode\n            sock.setblocking(False)\n\n            # Bind to the SSDP port to receive NOTIFY broadcasts from printers\n            sock.bind((\"\", SSDP_PORT))\n\n            # Join multicast group to receive multicast messages\n            mreq = struct.pack(\"4sl\", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)\n            sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)\n\n            # Enable broadcast\n            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)\n\n            logger.info(\"Starting SSDP discovery on port %s for Bambu Lab printers...\", SSDP_PORT)\n\n            # Send initial M-SEARCH request to trigger responses\n            try:\n                sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))\n            except OSError as e:\n                logger.debug(\"M-SEARCH send error: %s\", e)\n\n            start_time = asyncio.get_event_loop().time()\n            last_send = start_time\n\n            while self._running and (asyncio.get_event_loop().time() - start_time) < duration:\n                # Try to receive data\n                try:\n                    data, addr = sock.recvfrom(4096)\n                    message = data.decode(\"utf-8\", errors=\"ignore\")\n                    logger.debug(\"Received from %s: %s...\", addr[0], message[:100])\n                    self._handle_response(message, addr[0])\n                except BlockingIOError:\n                    # No data available, that's fine\n                    pass\n                except OSError as e:\n                    logger.debug(\"SSDP receive error: %s\", e)\n\n                # Re-send M-SEARCH every 3 seconds\n                now = asyncio.get_event_loop().time()\n                if now - last_send >= 3.0:\n                    try:\n                        sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))\n                        last_send = now\n                    except OSError as e:\n                        logger.debug(\"SSDP send error: %s\", e)\n\n                await asyncio.sleep(0.1)\n\n            logger.info(\"Discovery complete. Found %s printers.\", len(self._discovered))\n\n        except OSError as e:\n            if e.errno == 98:  # Address already in use\n                logger.warning(\"Port %s is in use, trying alternative discovery...\", SSDP_PORT)\n                await self._discover_alternative(duration)\n            else:\n                logger.error(\"Discovery error: %s\", e)\n        except Exception as e:\n            logger.error(\"Discovery error: %s\", e)\n        finally:\n            self._running = False\n            if sock:\n                try:\n                    sock.close()\n                except OSError:\n                    pass  # Best-effort socket cleanup\n\n    async def _discover_alternative(self, duration: float):\n        \"\"\"Alternative discovery using a random port (less reliable).\"\"\"\n        sock = None\n        try:\n            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)\n            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n            sock.setblocking(False)\n            sock.bind((\"\", 0))\n\n            # Join multicast group\n            mreq = struct.pack(\"4sl\", socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY)\n            sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)\n            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)\n\n            logger.info(\"Using alternative discovery method...\")\n\n            start_time = asyncio.get_event_loop().time()\n            last_send = start_time\n\n            while self._running and (asyncio.get_event_loop().time() - start_time) < duration:\n                try:\n                    data, addr = sock.recvfrom(4096)\n                    self._handle_response(data.decode(\"utf-8\", errors=\"ignore\"), addr[0])\n                except BlockingIOError:\n                    pass  # No data available yet on non-blocking socket\n                except OSError as e:\n                    logger.debug(\"SSDP receive error: %s\", e)\n\n                now = asyncio.get_event_loop().time()\n                if now - last_send >= 2.0:\n                    try:\n                        sock.sendto(SSDP_MSEARCH.encode(), (SSDP_ADDR, SSDP_PORT))\n                        last_send = now\n                    except OSError:\n                        pass  # Best-effort M-SEARCH resend; will retry next interval\n\n                await asyncio.sleep(0.1)\n\n            logger.info(\"Alternative discovery complete. Found %s printers.\", len(self._discovered))\n        except Exception as e:\n            logger.error(\"Alternative discovery error: %s\", e)\n        finally:\n            if sock:\n                try:\n                    sock.close()\n                except OSError:\n                    pass  # Best-effort socket cleanup\n\n    def _handle_response(self, response: str, ip_address: str):\n        \"\"\"Parse SSDP response and extract printer info.\"\"\"\n        # Check if it's a Bambu Lab printer response\n        if BAMBU_SEARCH_TARGET not in response and \"bambulab\" not in response.lower():\n            logger.debug(\"Ignoring non-Bambu response from %s\", ip_address)\n            return\n\n        # Extract USN (Unique Service Name) which contains the serial\n        # Bambu format is just \"USN: SERIALNUMBER\" (no uuid: prefix)\n        usn_match = re.search(r\"USN:\\s*(?:uuid:)?([^\\s\\r\\n]+)\", response, re.IGNORECASE)\n        if not usn_match:\n            logger.debug(\"No USN found in response from %s\", ip_address)\n            return\n\n        serial = usn_match.group(1).strip()\n\n        # Skip Bambuddy's own virtual printer (any model variant)\n        if serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):\n            logger.debug(\"Ignoring Bambuddy virtual printer at %s\", ip_address)\n            return\n\n        # Extract device name from LOCATION or DevName header\n        name = serial  # Default to serial if no name found\n        name_match = re.search(r\"DevName\\.bambu\\.com:\\s*(.+?)(?:\\r\\n|\\n|$)\", response, re.IGNORECASE)\n        if name_match:\n            name = name_match.group(1).strip()\n\n        # Try to extract model from DevModel header\n        model = None\n        model_match = re.search(r\"DevModel\\.bambu\\.com:\\s*(.+?)(?:\\r\\n|\\n|$)\", response, re.IGNORECASE)\n        if model_match:\n            model = model_match.group(1).strip()\n\n        # Also try NT header for model\n        if not model:\n            nt_match = re.search(r\"NT:\\s*urn:bambulab-com:device:([^:]+)\", response, re.IGNORECASE)\n            if nt_match:\n                model = nt_match.group(1).strip()\n\n        # Skip if already discovered\n        if serial in self._discovered:\n            return\n\n        printer = DiscoveredPrinter(\n            serial=serial,\n            name=name,\n            ip_address=ip_address,\n            model=model,\n            discovered_at=datetime.now(timezone.utc).isoformat(),\n        )\n\n        self._discovered[serial] = printer\n        logger.info(\"Discovered printer: %s (%s) at %s\", name, serial, ip_address)\n\n\nclass SubnetScanner:\n    \"\"\"Scanner for discovering Bambu printers by probing IP addresses.\"\"\"\n\n    # Bambu printer ports\n    MQTT_PORT = 8883\n    FTP_PORT = 990\n\n    def __init__(self):\n        self._discovered: dict[str, DiscoveredPrinter] = {}\n        self._running = False\n        self._scanned = 0\n        self._total = 0\n\n    @property\n    def is_running(self) -> bool:\n        return self._running\n\n    @property\n    def discovered_printers(self) -> list[DiscoveredPrinter]:\n        return list(self._discovered.values())\n\n    @property\n    def progress(self) -> tuple[int, int]:\n        \"\"\"Return (scanned, total) counts.\"\"\"\n        return self._scanned, self._total\n\n    async def scan_subnet(self, subnet: str, timeout: float = 1.0) -> list[DiscoveredPrinter]:\n        \"\"\"Scan a subnet for Bambu printers.\n\n        Args:\n            subnet: CIDR notation subnet (e.g., \"192.168.1.0/24\")\n            timeout: Connection timeout per host in seconds\n\n        Returns:\n            List of discovered printers\n        \"\"\"\n        if self._running:\n            return []\n\n        self._running = True\n        self._discovered.clear()\n        self._scanned = 0\n\n        try:\n            network = ipaddress.ip_network(subnet, strict=False)\n            hosts = list(network.hosts())\n            self._total = len(hosts)\n\n            if self._total > 1024:\n                logger.warning(\"Subnet %s has %s hosts, limiting to /22 (1024 hosts)\", subnet, self._total)\n                self._total = 1024\n                hosts = hosts[:1024]\n\n            logger.info(\"Starting subnet scan of %s (%s hosts)\", subnet, self._total)\n\n            # Scan in batches to avoid overwhelming the network\n            batch_size = 50\n            for i in range(0, len(hosts), batch_size):\n                if not self._running:\n                    break\n\n                batch = hosts[i : i + batch_size]\n                tasks = [self._probe_host(str(ip), timeout) for ip in batch]\n                await asyncio.gather(*tasks, return_exceptions=True)\n                self._scanned = min(i + batch_size, len(hosts))\n\n            logger.info(\"Subnet scan complete. Found %s printers.\", len(self._discovered))\n            return self.discovered_printers\n\n        except ValueError as e:\n            logger.error(\"Invalid subnet format: %s\", e)\n            return []\n        finally:\n            self._running = False\n\n    async def _probe_host(self, ip: str, timeout: float):\n        \"\"\"Probe a single host for Bambu printer ports.\"\"\"\n        # Check FTP port (990) - more reliable indicator\n        ftp_open = await self._check_port(ip, self.FTP_PORT, timeout)\n        if not ftp_open:\n            return\n\n        # Also check MQTT port (8883) for confirmation\n        mqtt_open = await self._check_port(ip, self.MQTT_PORT, timeout)\n        if not mqtt_open:\n            return\n\n        # Both ports open - likely a Bambu printer\n        logger.info(\"Found potential Bambu printer at %s\", ip)\n\n        # Try to get printer info via SSDP unicast\n        serial, name, model = await self._get_printer_info_ssdp(ip, timeout)\n\n        # Skip Bambuddy's own virtual printer (any model variant)\n        if serial and serial.endswith(VIRTUAL_PRINTER_SERIAL_SUFFIX):\n            logger.debug(\"Ignoring Bambuddy virtual printer at %s\", ip)\n            return\n\n        printer = DiscoveredPrinter(\n            serial=serial or f\"unknown-{ip.replace('.', '-')}\",\n            name=name or f\"Printer at {ip}\",\n            ip_address=ip,\n            model=model,\n            discovered_at=datetime.now(timezone.utc).isoformat(),\n        )\n        self._discovered[ip] = printer\n\n    async def _get_printer_info_ssdp(self, ip: str, timeout: float) -> tuple[str | None, str | None, str | None]:\n        \"\"\"Try to get printer info via SSDP unicast query.\"\"\"\n        loop = asyncio.get_event_loop()\n\n        def _query():\n            try:\n                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)\n                sock.settimeout(timeout)\n                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n\n                # Send M-SEARCH directly to the printer\n                msearch = (\n                    \"M-SEARCH * HTTP/1.1\\r\\n\"\n                    f\"HOST: {ip}:{SSDP_PORT}\\r\\n\"\n                    'MAN: \"ssdp:discover\"\\r\\n'\n                    \"MX: 1\\r\\n\"\n                    f\"ST: {BAMBU_SEARCH_TARGET}\\r\\n\"\n                    \"\\r\\n\"\n                )\n                sock.sendto(msearch.encode(), (ip, SSDP_PORT))\n\n                # Wait for response\n                data, _ = sock.recvfrom(4096)\n                response = data.decode(\"utf-8\", errors=\"ignore\")\n                sock.close()\n\n                # Parse response\n                serial = None\n                name = None\n                model = None\n\n                usn_match = re.search(r\"USN:\\s*(?:uuid:)?([^\\s\\r\\n]+)\", response, re.IGNORECASE)\n                if usn_match:\n                    serial = usn_match.group(1).strip()\n\n                name_match = re.search(r\"DevName\\.bambu\\.com:\\s*(.+?)(?:\\r\\n|\\n|$)\", response, re.IGNORECASE)\n                if name_match:\n                    name = name_match.group(1).strip()\n\n                model_match = re.search(r\"DevModel\\.bambu\\.com:\\s*(.+?)(?:\\r\\n|\\n|$)\", response, re.IGNORECASE)\n                if model_match:\n                    model = model_match.group(1).strip()\n\n                logger.debug(\"SSDP info from %s: serial=%s, name=%s, model=%s\", ip, serial, name, model)\n                return serial, name, model\n\n            except OSError as e:\n                logger.debug(\"SSDP query to %s failed: %s\", ip, e)\n                return None, None, None\n\n        return await loop.run_in_executor(None, _query)\n\n    async def _check_port(self, ip: str, port: int, timeout: float) -> bool:\n        \"\"\"Check if a port is open on the given IP.\"\"\"\n        try:\n            _, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)\n            writer.close()\n            await writer.wait_closed()\n            logger.debug(\"Port %s open on %s\", port, ip)\n            return True\n        except TimeoutError:\n            return False\n        except ConnectionRefusedError:\n            return False\n        except OSError as e:\n            # Log first few errors to help debug network issues\n            if self._scanned < 5:\n                logger.debug(\"OSError checking %s:%s: %s\", ip, port, e)\n            return False\n\n    def stop(self):\n        \"\"\"Stop the current scan.\"\"\"\n        self._running = False\n\n\nclass TasmotaScanner:\n    \"\"\"Scanner for discovering Tasmota devices by probing IP addresses.\"\"\"\n\n    HTTP_PORT = 80\n\n    def __init__(self):\n        self._discovered: dict[str, dict] = {}\n        self._running = False\n        self._scanned = 0\n        self._total = 0\n\n    @property\n    def is_running(self) -> bool:\n        return self._running\n\n    @property\n    def discovered_devices(self) -> list[dict]:\n        return list(self._discovered.values())\n\n    @property\n    def progress(self) -> tuple[int, int]:\n        \"\"\"Return (scanned, total) counts.\"\"\"\n        return self._scanned, self._total\n\n    async def scan_range(self, from_ip: str, to_ip: str, timeout: float = 1.0) -> list[dict]:\n        \"\"\"Scan an IP range for Tasmota devices.\n\n        Args:\n            from_ip: Starting IP address (e.g., \"192.168.1.1\")\n            to_ip: Ending IP address (e.g., \"192.168.1.254\")\n            timeout: Connection timeout per host in seconds\n\n        Returns:\n            List of discovered Tasmota devices\n        \"\"\"\n        if self._running:\n            return []\n\n        self._running = True\n        self._discovered.clear()\n        self._scanned = 0\n\n        try:\n            start = ipaddress.ip_address(from_ip)\n            end = ipaddress.ip_address(to_ip)\n\n            # Generate list of IPs in range\n            hosts = []\n            current = start\n            while current <= end:\n                hosts.append(str(current))\n                current = ipaddress.ip_address(int(current) + 1)\n\n            self._total = len(hosts)\n\n            if self._total > 1024:\n                logger.warning(\"IP range has %s hosts, limiting to 1024\", self._total)\n                self._total = 1024\n                hosts = hosts[:1024]\n\n            logger.info(\"Starting Tasmota scan from %s to %s (%s hosts)\", from_ip, to_ip, self._total)\n\n            # Scan in batches to avoid overwhelming the network\n            batch_size = 50\n            for i in range(0, len(hosts), batch_size):\n                if not self._running:\n                    logger.info(\"Tasmota scan stopped by user\")\n                    break\n\n                batch = hosts[i : i + batch_size]\n                tasks = [self._probe_host(ip) for ip in batch]\n                try:\n                    await asyncio.gather(*tasks, return_exceptions=True)\n                except Exception as e:\n                    logger.warning(\"Batch %s error: %s\", i // batch_size, e)\n                self._scanned = min(i + batch_size, len(hosts))\n\n            logger.info(\"Tasmota scan complete. Found %s devices.\", len(self._discovered))\n            return self.discovered_devices\n\n        except ValueError as e:\n            logger.error(\"Invalid IP address format: %s\", e)\n            return []\n        finally:\n            self._running = False\n\n    async def _probe_host(self, ip: str):\n        \"\"\"Probe a single host for Tasmota HTTP API.\"\"\"\n        try:\n            # Hard timeout of 5 seconds max per host\n            await asyncio.wait_for(self._do_probe(ip), timeout=5.0)\n        except TimeoutError:\n            pass  # Host did not respond in time; skip\n        except Exception:\n            pass  # Probe failed for this host; skip silently\n\n    async def _do_probe(self, ip: str):\n        \"\"\"Actually probe the host.\"\"\"\n        import httpx\n\n        try:\n            # Reasonable timeouts for network scanning\n            client_timeout = httpx.Timeout(3.0, connect=1.0)\n            async with httpx.AsyncClient(timeout=client_timeout, follow_redirects=False) as client:\n                # First try simple Power command - most reliable indicator of Tasmota\n                power_url = f\"http://{ip}/cm?cmnd=Power\"\n                try:\n                    power_response = await client.get(power_url)\n                    if power_response.status_code == 401:\n                        # Device requires auth - still a Tasmota device!\n                        logger.info(\"Discovered Tasmota at %s (requires auth - 401)\", ip)\n                        device = {\n                            \"ip_address\": ip,\n                            \"name\": f\"Tasmota ({ip})\",\n                            \"module\": None,\n                            \"state\": \"UNKNOWN\",\n                            \"discovered_at\": datetime.now(timezone.utc).isoformat(),\n                        }\n                        self._discovered[ip] = device\n                        return\n\n                    if power_response.status_code != 200:\n                        return\n\n                    power_data = power_response.json()\n\n                    # Check for Tasmota auth warning (returns 200 with WARNING)\n                    if \"WARNING\" in power_data:\n                        logger.info(\"Discovered Tasmota at %s (requires auth)\", ip)\n                        device = {\n                            \"ip_address\": ip,\n                            \"name\": f\"Tasmota ({ip})\",\n                            \"module\": None,\n                            \"state\": \"UNKNOWN\",\n                            \"discovered_at\": datetime.now(timezone.utc).isoformat(),\n                        }\n                        self._discovered[ip] = device\n                        return\n\n                    # Check if response looks like Tasmota (has POWER or POWER1 key)\n                    power_state = power_data.get(\"POWER\") or power_data.get(\"POWER1\")\n                    if power_state is None:\n                        return\n\n                except Exception as e:\n                    logger.debug(\"Error probing %s: %s\", ip, e)\n                    return\n\n                # It's a Tasmota device! Now get more info\n                device_name = f\"Tasmota ({ip})\"\n                module = None\n\n                # Try to get device name from Status 0\n                try:\n                    status_url = f\"http://{ip}/cm?cmnd=Status%200\"\n                    status_response = await client.get(status_url)\n                    if status_response.status_code == 200:\n                        status_data = status_response.json()\n                        if \"Status\" in status_data:\n                            status = status_data[\"Status\"]\n                            device_name = status.get(\"DeviceName\") or device_name\n                            if not device_name or device_name == f\"Tasmota ({ip})\":\n                                # Try FriendlyName\n                                friendly = status.get(\"FriendlyName\")\n                                if friendly and isinstance(friendly, list) and friendly[0]:\n                                    device_name = friendly[0]\n                            module = status.get(\"Module\")\n                except Exception:\n                    pass  # Status query is optional; proceed with defaults\n\n                device = {\n                    \"ip_address\": ip,\n                    \"name\": device_name,\n                    \"module\": module,\n                    \"state\": power_state,\n                    \"discovered_at\": datetime.now(timezone.utc).isoformat(),\n                }\n\n                self._discovered[ip] = device\n                logger.info(\"Discovered Tasmota device: %s at %s\", device_name, ip)\n\n        except httpx.TimeoutException:\n            pass  # Host unreachable or too slow; not a Tasmota device\n        except httpx.ConnectError:\n            pass  # Connection refused; no HTTP server on this host\n        except Exception:\n            pass  # Unexpected error probing host; skip silently\n\n    def stop(self):\n        \"\"\"Stop the current scan.\"\"\"\n        self._running = False\n\n\n# Global instances\ndiscovery_service = PrinterDiscoveryService()\nsubnet_scanner = SubnetScanner()\ntasmota_scanner = TasmotaScanner()\n"
  },
  {
    "path": "backend/app/services/email_service.py",
    "content": "\"\"\"Email service for sending authentication-related emails.\"\"\"\n\nfrom __future__ import annotations\n\nimport html\nimport logging\nimport re\nimport secrets\nimport smtplib\nimport string\nfrom datetime import datetime, timezone\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\nfrom typing import Any\n\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.notification_template import NotificationTemplate\nfrom backend.app.models.settings import Settings\nfrom backend.app.schemas.auth import SMTPSettings\n\nlogger = logging.getLogger(__name__)\n\n\ndef generate_secure_password(length: int = 16) -> str:\n    \"\"\"Generate a secure random password.\n\n    Args:\n        length: Length of the password (default: 16)\n\n    Returns:\n        A secure random password containing uppercase, lowercase, digits, and special characters\n    \"\"\"\n    # Define character sets\n    lowercase = string.ascii_lowercase\n    uppercase = string.ascii_uppercase\n    digits = string.digits\n    special = \"!@#$%^&*()_+-=[]{}|;:,.<>?\"\n\n    # Ensure at least one character from each set\n    password_chars = [\n        secrets.choice(lowercase),\n        secrets.choice(uppercase),\n        secrets.choice(digits),\n        secrets.choice(special),\n    ]\n\n    # Fill the rest with random characters from all sets\n    all_chars = lowercase + uppercase + digits + special\n    password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))\n\n    # Shuffle with CSPRNG — random.shuffle() is seeded from time and not cryptographically safe\n    secrets.SystemRandom().shuffle(password_chars)\n\n    return \"\".join(password_chars)\n\n\nasync def get_notification_template(db: AsyncSession, event_type: str) -> NotificationTemplate | None:\n    \"\"\"Get a notification template by event type from database.\n\n    Args:\n        db: Database session\n        event_type: Type of event (e.g., 'user_created', 'password_reset')\n\n    Returns:\n        NotificationTemplate object or None if not found\n    \"\"\"\n    result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))\n    return result.scalar_one_or_none()\n\n\ndef render_template(template_str: str, variables: dict[str, Any]) -> str:\n    \"\"\"Render a template string with variables.\n\n    Args:\n        template_str: Template string with {variable} placeholders\n        variables: Dictionary of variables to substitute\n\n    Returns:\n        Rendered template string\n    \"\"\"\n    result = template_str\n    for key, value in variables.items():\n        result = result.replace(\"{\" + key + \"}\", str(value) if value is not None else \"\")\n    # Remove any remaining unreplaced placeholders (case-insensitive, alphanumeric + underscore)\n    result = re.sub(r\"\\{[a-zA-Z0-9_]+\\}\", \"\", result)\n    return result\n\n\nasync def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:\n    \"\"\"Get SMTP settings from database.\n\n    Args:\n        db: Database session\n\n    Returns:\n        SMTPSettings object or None if not configured\n    \"\"\"\n    # Fetch all SMTP-related settings\n    result = await db.execute(\n        select(Settings).where(\n            Settings.key.in_(\n                [\n                    \"smtp_host\",\n                    \"smtp_port\",\n                    \"smtp_username\",\n                    \"smtp_password\",\n                    \"smtp_use_tls\",\n                    \"smtp_security\",\n                    \"smtp_auth_enabled\",\n                    \"smtp_from_email\",\n                    \"smtp_from_name\",\n                ]\n            )\n        )\n    )\n    settings_dict = {s.key: s.value for s in result.scalars().all()}\n\n    # Check if minimum required settings are present\n    required_keys = [\"smtp_host\", \"smtp_port\", \"smtp_from_email\"]\n    if not all(key in settings_dict for key in required_keys):\n        return None\n\n    # Handle migration: convert old smtp_use_tls to smtp_security if needed\n    smtp_security = settings_dict.get(\"smtp_security\")\n    if not smtp_security:\n        # Migrate from old smtp_use_tls format\n        smtp_use_tls = settings_dict.get(\"smtp_use_tls\", \"true\").lower() == \"true\"\n        smtp_security = \"starttls\" if smtp_use_tls else \"ssl\"\n\n    smtp_auth_enabled = settings_dict.get(\"smtp_auth_enabled\", \"true\").lower() == \"true\"\n\n    return SMTPSettings(\n        smtp_host=settings_dict[\"smtp_host\"],\n        smtp_port=int(settings_dict[\"smtp_port\"]),\n        smtp_username=settings_dict.get(\"smtp_username\"),\n        smtp_password=settings_dict.get(\"smtp_password\"),\n        smtp_security=smtp_security,\n        smtp_auth_enabled=smtp_auth_enabled,\n        smtp_from_email=settings_dict[\"smtp_from_email\"],\n        smtp_from_name=settings_dict.get(\"smtp_from_name\", \"BamBuddy\"),\n    )\n\n\nasync def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> None:\n    \"\"\"Save SMTP settings to database.\n\n    Args:\n        db: Database session\n        smtp_settings: SMTP settings to save\n    \"\"\"\n    from backend.app.core.db_dialect import upsert_setting\n\n    settings_data = {\n        \"smtp_host\": smtp_settings.smtp_host,\n        \"smtp_port\": str(smtp_settings.smtp_port),\n        \"smtp_security\": smtp_settings.smtp_security,\n        \"smtp_auth_enabled\": \"true\" if smtp_settings.smtp_auth_enabled else \"false\",\n        \"smtp_from_email\": smtp_settings.smtp_from_email,\n        \"smtp_from_name\": smtp_settings.smtp_from_name,\n    }\n\n    # Only save username if auth is enabled or if provided\n    if smtp_settings.smtp_username:\n        settings_data[\"smtp_username\"] = smtp_settings.smtp_username\n\n    # Only save password if provided\n    if smtp_settings.smtp_password:\n        settings_data[\"smtp_password\"] = smtp_settings.smtp_password\n\n    for key, value in settings_data.items():\n        await upsert_setting(db, Settings, key, value)\n\n\ndef send_email(\n    smtp_settings: SMTPSettings,\n    to_email: str,\n    subject: str,\n    body_text: str,\n    body_html: str | None = None,\n) -> None:\n    \"\"\"Send an email using SMTP.\n\n    Args:\n        smtp_settings: SMTP configuration\n        to_email: Recipient email address\n        subject: Email subject\n        body_text: Plain text body\n        body_html: Optional HTML body\n\n    Raises:\n        Exception: If email sending fails\n    \"\"\"\n    msg = MIMEMultipart(\"alternative\")\n    msg[\"From\"] = f\"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>\"\n    msg[\"To\"] = to_email\n    msg[\"Subject\"] = subject\n\n    # Attach plain text part\n    msg.attach(MIMEText(body_text, \"plain\"))\n\n    # Attach HTML part if provided\n    if body_html:\n        msg.attach(MIMEText(body_html, \"html\"))\n\n    # Send email\n    try:\n        security = smtp_settings.smtp_security\n        auth_enabled = smtp_settings.smtp_auth_enabled\n\n        # Validate username is provided when authentication is enabled\n        if auth_enabled and smtp_settings.smtp_password:\n            if not smtp_settings.smtp_username:\n                raise ValueError(\"SMTP username is required when authentication is enabled\")\n\n        if security == \"ssl\":\n            # Direct SSL connection (typically port 465)\n            with smtplib.SMTP_SSL(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:\n                if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:\n                    server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)\n                server.send_message(msg)\n        elif security == \"starttls\":\n            # STARTTLS upgrade (typically port 587)\n            with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:\n                server.starttls()\n                if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:\n                    server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)\n                server.send_message(msg)\n        else:\n            # No encryption (typically port 25) - use with caution\n            with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:\n                if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:\n                    server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)\n                server.send_message(msg)\n        logger.info(f\"Email sent successfully to {to_email}\")\n    except Exception as e:\n        logger.error(f\"Failed to send email to {to_email}: {e}\")\n        raise\n\n\ndef create_welcome_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:\n    \"\"\"Create welcome email content for new user.\n\n    Args:\n        username: Username of the new user\n        password: Auto-generated password\n        login_url: URL to login page\n\n    Returns:\n        Tuple of (subject, text_body, html_body)\n    \"\"\"\n    subject = \"Welcome to BamBuddy - Your Account Details\"\n\n    text_body = f\"\"\"Welcome to BamBuddy!\n\nYour account has been created. Here are your login details:\n\nUsername: {username}\nPassword: {password}\n\nYou can login at: {login_url}\n\nFor security reasons, please change your password after your first login.\n\nBest regards,\nBamBuddy Team\n\"\"\"\n\n    html_body = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;\">\n    <div style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;\">\n        <h1 style=\"color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);\">Welcome to BamBuddy!</h1>\n    </div>\n    <div style=\"background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;\">\n        <p style=\"font-size: 16px;\">Your account has been created. Here are your login details:</p>\n\n        <div style=\"background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;\">\n            <p style=\"margin: 0 0 10px 0;\"><strong>Username:</strong> <code style=\"background: #f0f0f0; padding: 2px 6px; border-radius: 3px;\">{username}</code></p>\n            <p style=\"margin: 0;\"><strong>Password:</strong> <code style=\"background: #f0f0f0; padding: 2px 6px; border-radius: 3px;\">{password}</code></p>\n        </div>\n\n        <div style=\"text-align: center; margin: 30px 0;\">\n            <a href=\"{login_url}\" style=\"display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;\">Login Now</a>\n        </div>\n\n        <p style=\"font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;\">\n            <strong>Security Note:</strong> For security reasons, please change your password after your first login.\n        </p>\n\n        <p style=\"font-size: 14px; color: #999; margin-top: 30px;\">\n            Best regards,<br>\n            BamBuddy Team\n        </p>\n    </div>\n</body>\n</html>\n\"\"\"\n\n    return subject, text_body, html_body\n\n\ndef create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:\n    \"\"\"Create password reset email content.\n\n    Args:\n        username: Username of the user\n        password: New auto-generated password\n        login_url: URL to login page\n\n    Returns:\n        Tuple of (subject, text_body, html_body)\n    \"\"\"\n    subject = \"BamBuddy - Your Password Has Been Reset\"\n\n    text_body = f\"\"\"Your BamBuddy password has been reset.\n\nYour login details:\n\nUsername: {username}\nNew Password: {password}\n\nYou can login at: {login_url}\n\nFor security reasons, please change your password after logging in.\n\nIf you did not request this password reset, please contact your administrator immediately.\n\nBest regards,\nBamBuddy Team\n\"\"\"\n\n    html_body = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;\">\n    <div style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;\">\n        <h1 style=\"color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);\">Password Reset</h1>\n    </div>\n    <div style=\"background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;\">\n        <p style=\"font-size: 16px;\">Your BamBuddy password has been reset.</p>\n\n        <div style=\"background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;\">\n            <p style=\"margin: 0 0 10px 0;\"><strong>Username:</strong> <code style=\"background: #f0f0f0; padding: 2px 6px; border-radius: 3px;\">{username}</code></p>\n            <p style=\"margin: 0;\"><strong>New Password:</strong> <code style=\"background: #f0f0f0; padding: 2px 6px; border-radius: 3px;\">{password}</code></p>\n        </div>\n\n        <div style=\"text-align: center; margin: 30px 0;\">\n            <a href=\"{login_url}\" style=\"display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;\">Login Now</a>\n        </div>\n\n        <div style=\"background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;\">\n            <p style=\"margin: 0; font-size: 14px; color: #856404;\">\n                <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.\n            </p>\n        </div>\n\n        <p style=\"font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;\">\n            <strong>Security Note:</strong> For security reasons, please change your password after logging in.\n        </p>\n\n        <p style=\"font-size: 14px; color: #999; margin-top: 30px;\">\n            Best regards,<br>\n            BamBuddy Team\n        </p>\n    </div>\n</body>\n</html>\n\"\"\"\n\n    return subject, text_body, html_body\n\n\ndef create_password_reset_link_email(username: str, reset_url: str) -> tuple[str, str, str]:\n    \"\"\"Create a password-reset email that contains a secure link (not a plaintext password).\"\"\"\n    subject = \"BamBuddy - Password Reset Request\"\n\n    text_body = f\"\"\"A password reset was requested for your BamBuddy account.\n\nUsername: {username}\n\nClick the link below to set a new password (valid for 1 hour):\n{reset_url}\n\nIf you did not request this reset, you can safely ignore this email.\n\nBest regards,\nBamBuddy Team\n\"\"\"\n\n    html_body = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;\">\n    <div style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;\">\n        <h1 style=\"color: #ffffff; margin: 0; font-size: 24px;\">Password Reset Request</h1>\n    </div>\n    <div style=\"background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;\">\n        <p style=\"font-size: 16px;\">A password reset was requested for your BamBuddy account (<strong>{username}</strong>).</p>\n        <p>Click the button below to set a new password. This link is valid for <strong>1 hour</strong>.</p>\n        <div style=\"text-align: center; margin: 30px 0;\">\n            <a href=\"{reset_url}\" style=\"display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;\">Reset Password</a>\n        </div>\n        <div style=\"background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;\">\n            <p style=\"margin: 0; font-size: 14px; color: #856404;\">\n                <strong>Did not request this?</strong> You can safely ignore this email. Your password has not been changed.\n            </p>\n        </div>\n        <p style=\"font-size: 14px; color: #999; margin-top: 30px;\">\n            Best regards,<br>BamBuddy Team\n        </p>\n    </div>\n</body>\n</html>\n\"\"\"\n    return subject, text_body, html_body\n\n\nasync def create_password_reset_link_email_from_template(\n    db: AsyncSession, username: str, reset_url: str\n) -> tuple[str, str, str]:\n    \"\"\"Create password-reset link email, using DB template if configured.\"\"\"\n    template = await get_notification_template(db, \"password_reset_link\")\n    if template:\n        variables = {\"username\": username, \"reset_url\": reset_url}\n        subject = render_template(template.subject or \"BamBuddy - Password Reset Request\", variables)\n        text_body = render_template(template.body or \"\", variables)\n        html_body = render_template(template.html_body or \"\", variables) if template.html_body else None\n        if not html_body:\n            _, text_body, html_body = create_password_reset_link_email(username, reset_url)\n            return subject, text_body, html_body\n        return subject, text_body, html_body\n    return create_password_reset_link_email(username, reset_url)\n\n\nasync def create_welcome_email_from_template(\n    db: AsyncSession, username: str, password: str, login_url: str, app_name: str = \"BamBuddy\"\n) -> tuple[str, str, str]:\n    \"\"\"Create welcome email content using notification template from database.\n\n    Args:\n        db: Database session\n        username: Username of the new user\n        password: Auto-generated password\n        login_url: URL to login page\n        app_name: Application name (default: BamBuddy)\n\n    Returns:\n        Tuple of (subject, text_body, html_body)\n    \"\"\"\n    # Try to get template from database\n    template = await get_notification_template(db, \"user_created\")\n\n    if template:\n        # Render template with variables\n        variables = {\n            \"app_name\": app_name,\n            \"username\": username,\n            \"password\": password,\n            \"login_url\": login_url,\n        }\n\n        subject = render_template(template.title_template, variables)\n        text_body = render_template(template.body_template, variables)\n\n        # Create HTML version with embedded login button\n        # Escape text_body to prevent XSS vulnerabilities and convert newlines to <br> tags\n        escaped_text_body = html.escape(text_body).replace(\"\\n\", \"<br>\\n\")\n        html_body = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;\">\n    <div style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;\">\n        <h1 style=\"color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);\">{html.escape(subject)}</h1>\n    </div>\n    <div style=\"background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;\">\n        <div style=\"font-size: 16px;\">{escaped_text_body}</div>\n\n        <div style=\"text-align: center; margin: 30px 0;\">\n            <a href=\"{login_url}\" style=\"display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;\">Login Now</a>\n        </div>\n    </div>\n</body>\n</html>\n\"\"\"\n\n        logger.info(\"Using custom welcome email template from database\")\n        return subject, text_body, html_body\n    else:\n        # Fallback to hardcoded template\n        logger.warning(\"No welcome email template found in database, using default\")\n        return create_welcome_email(username, password, login_url)\n\n\nasync def create_password_reset_email_from_template(\n    db: AsyncSession, username: str, password: str, login_url: str, app_name: str = \"BamBuddy\"\n) -> tuple[str, str, str]:\n    \"\"\"Create password reset email content using notification template from database.\n\n    Args:\n        db: Database session\n        username: Username of the user\n        password: New auto-generated password\n        login_url: URL to login page\n        app_name: Application name (default: BamBuddy)\n\n    Returns:\n        Tuple of (subject, text_body, html_body)\n    \"\"\"\n    # Try to get template from database\n    template = await get_notification_template(db, \"password_reset\")\n\n    if template:\n        # Render template with variables\n        variables = {\n            \"app_name\": app_name,\n            \"username\": username,\n            \"password\": password,\n            \"login_url\": login_url,\n        }\n\n        subject = render_template(template.title_template, variables)\n        text_body = render_template(template.body_template, variables)\n\n        # Create HTML version with embedded login button\n        # Escape text_body to prevent XSS vulnerabilities and convert newlines to <br> tags\n        escaped_text_body = html.escape(text_body).replace(\"\\n\", \"<br>\\n\")\n        html_body = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;\">\n    <div style=\"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;\">\n        <h1 style=\"color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);\">{html.escape(subject)}</h1>\n    </div>\n    <div style=\"background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;\">\n        <div style=\"font-size: 16px;\">{escaped_text_body}</div>\n\n        <div style=\"text-align: center; margin: 30px 0;\">\n            <a href=\"{login_url}\" style=\"display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;\">Login Now</a>\n        </div>\n\n        <div style=\"background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;\">\n            <p style=\"margin: 0; font-size: 14px; color: #856404;\">\n                <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.\n            </p>\n        </div>\n    </div>\n</body>\n</html>\n\"\"\"\n\n        logger.info(\"Using custom password reset email template from database\")\n        return subject, text_body, html_body\n    else:\n        # Fallback to hardcoded template\n        logger.warning(\"No password reset email template found in database, using default\")\n        return create_password_reset_email(username, password, login_url)\n\n\nasync def send_user_print_notification(\n    db: AsyncSession,\n    event_type: str,\n    user_email: str,\n    username: str,\n    variables: dict,\n) -> None:\n    \"\"\"Send a print notification email to a user using Advanced Auth SMTP settings.\n\n    Args:\n        db: Database session\n        event_type: One of 'user_print_start', 'user_print_complete', 'user_print_failed', 'user_print_stopped'\n        user_email: Recipient email address\n        username: Username of the recipient\n        variables: Template variables (printer, filename, etc.)\n    \"\"\"\n    # Check that advanced auth is enabled (SMTP settings must be configured)\n    smtp_settings = await get_smtp_settings(db)\n    if not smtp_settings:\n        logger.warning(\"Cannot send user print notification: SMTP settings not configured\")\n        return\n\n    # Get the template\n    template = await get_notification_template(db, event_type)\n    if template is None:\n        logger.warning(\"No template found for event type: %s\", event_type)\n        return\n\n    # Add common variables (username, timestamp, app_name) merged with caller-supplied variables\n    all_variables = {\n        \"username\": username,\n        \"timestamp\": datetime.now(timezone.utc).strftime(\"%Y-%m-%d %H:%M UTC\"),\n        \"app_name\": \"Bambuddy\",\n        **variables,\n    }\n\n    subject = render_template(template.title_template, all_variables)\n    text_body = render_template(template.body_template, all_variables)\n\n    # Build HTML body — content comes entirely from the database template\n    escaped_text_body = html.escape(text_body).replace(\"\\n\", \"<br>\\n\")\n    html_body = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;\">\n    <div style=\"background: linear-gradient(135deg, #1db954 0%, #158a3e 100%); background-color: #1db954; padding: 20px; border-radius: 8px 8px 0 0;\">\n        <h1 style=\"color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);\">{html.escape(subject)}</h1>\n    </div>\n    <div style=\"background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;\">\n        <div style=\"font-size: 16px;\">{escaped_text_body}</div>\n    </div>\n</body>\n</html>\n\"\"\"\n\n    try:\n        send_email(smtp_settings, user_email, subject, text_body, html_body)\n        logger.info(\"Sent %s notification email to %s\", event_type, user_email)\n    except Exception as e:\n        logger.error(\"Failed to send %s notification to %s: %s\", event_type, user_email, e)\n"
  },
  {
    "path": "backend/app/services/export.py",
    "content": "import csv\nimport io\nfrom datetime import datetime\nfrom typing import Any\n\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.models.archive import PrintArchive\n\n\nclass ExportService:\n    \"\"\"Service for exporting archive data to CSV/Excel formats.\"\"\"\n\n    # Default fields to export\n    DEFAULT_FIELDS = [\n        \"id\",\n        \"print_name\",\n        \"filename\",\n        \"status\",\n        \"quantity\",\n        \"printer_id\",\n        \"project_name\",\n        \"filament_type\",\n        \"filament_used_grams\",\n        \"print_time_seconds\",\n        \"layer_height\",\n        \"nozzle_diameter\",\n        \"bed_temperature\",\n        \"nozzle_temperature\",\n        \"total_layers\",\n        \"cost\",\n        \"designer\",\n        \"tags\",\n        \"notes\",\n        \"failure_reason\",\n        \"started_at\",\n        \"completed_at\",\n        \"created_at\",\n    ]\n\n    # Field labels for headers\n    FIELD_LABELS = {\n        \"id\": \"ID\",\n        \"print_name\": \"Print Name\",\n        \"filename\": \"Filename\",\n        \"status\": \"Status\",\n        \"quantity\": \"Items Printed\",\n        \"printer_id\": \"Printer ID\",\n        \"project_name\": \"Project\",\n        \"filament_type\": \"Filament Type\",\n        \"filament_used_grams\": \"Filament (g)\",\n        \"print_time_seconds\": \"Print Time (s)\",\n        \"layer_height\": \"Layer Height (mm)\",\n        \"nozzle_diameter\": \"Nozzle (mm)\",\n        \"bed_temperature\": \"Bed Temp (°C)\",\n        \"nozzle_temperature\": \"Nozzle Temp (°C)\",\n        \"total_layers\": \"Total Layers\",\n        \"cost\": \"Cost\",\n        \"designer\": \"Designer\",\n        \"tags\": \"Tags\",\n        \"notes\": \"Notes\",\n        \"failure_reason\": \"Failure Reason\",\n        \"started_at\": \"Started At\",\n        \"completed_at\": \"Completed At\",\n        \"created_at\": \"Created At\",\n    }\n\n    def __init__(self, db: AsyncSession):\n        self.db = db\n\n    async def export_archives(\n        self,\n        format: str = \"csv\",\n        fields: list[str] | None = None,\n        printer_id: int | None = None,\n        project_id: int | None = None,\n        status: str | None = None,\n        date_from: datetime | None = None,\n        date_to: datetime | None = None,\n        search: str | None = None,\n    ) -> tuple[bytes, str, str]:\n        \"\"\"Export archives to CSV or Excel format.\n\n        Args:\n            format: Export format ('csv' or 'xlsx')\n            fields: List of fields to include (None = all default fields)\n            printer_id: Filter by printer\n            project_id: Filter by project\n            status: Filter by status\n            date_from: Filter by start date\n            date_to: Filter by end date\n            search: Search filter\n\n        Returns:\n            Tuple of (file_bytes, filename, content_type)\n        \"\"\"\n        # Build query\n        query = (\n            select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())\n        )\n\n        # Apply filters\n        if printer_id:\n            query = query.where(PrintArchive.printer_id == printer_id)\n        if project_id:\n            query = query.where(PrintArchive.project_id == project_id)\n        if status:\n            query = query.where(PrintArchive.status == status)\n        if date_from:\n            query = query.where(PrintArchive.created_at >= date_from)\n        if date_to:\n            query = query.where(PrintArchive.created_at <= date_to)\n        if search:\n            like_pattern = f\"%{search}%\"\n            query = query.where(\n                (PrintArchive.print_name.ilike(like_pattern))\n                | (PrintArchive.filename.ilike(like_pattern))\n                | (PrintArchive.tags.ilike(like_pattern))\n                | (PrintArchive.notes.ilike(like_pattern))\n                | (PrintArchive.designer.ilike(like_pattern))\n            )\n\n        # Execute query\n        result = await self.db.execute(query)\n        archives = list(result.scalars().all())\n\n        # Determine fields to export\n        export_fields = fields if fields else self.DEFAULT_FIELDS\n\n        # Convert to rows\n        rows = []\n        for archive in archives:\n            row = self._archive_to_row(archive, export_fields)\n            rows.append(row)\n\n        # Generate headers\n        headers = [self.FIELD_LABELS.get(f, f) for f in export_fields]\n\n        # Generate file\n        timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n\n        if format == \"xlsx\":\n            file_bytes = self._generate_xlsx(headers, rows, export_fields)\n            filename = f\"archives_export_{timestamp}.xlsx\"\n            content_type = \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n        else:\n            file_bytes = self._generate_csv(headers, rows)\n            filename = f\"archives_export_{timestamp}.csv\"\n            content_type = \"text/csv\"\n\n        return file_bytes, filename, content_type\n\n    async def export_stats(\n        self,\n        format: str = \"csv\",\n        days: int = 30,\n        printer_id: int | None = None,\n        project_id: int | None = None,\n        created_by_id: int | None = None,\n    ) -> tuple[bytes, str, str]:\n        \"\"\"Export statistics summary to CSV or Excel format.\n\n        Args:\n            format: Export format ('csv' or 'xlsx')\n            days: Number of days to include in stats\n            printer_id: Filter by printer\n            project_id: Filter by project\n            created_by_id: Filter by user who created the print (-1 for no user)\n\n        Returns:\n            Tuple of (file_bytes, filename, content_type)\n        \"\"\"\n        from backend.app.services.failure_analysis import FailureAnalysisService\n\n        # Get failure analysis data (includes stats)\n        analysis_service = FailureAnalysisService(self.db)\n        analysis = await analysis_service.analyze_failures(\n            days=days,\n            printer_id=printer_id,\n            project_id=project_id,\n            created_by_id=created_by_id,\n        )\n\n        # Build stats rows\n        rows = [\n            [\"Metric\", \"Value\"],\n            [\"Period (days)\", analysis[\"period_days\"]],\n            [\"Total Prints\", analysis[\"total_prints\"]],\n            [\"Failed Prints\", analysis[\"failed_prints\"]],\n            [\"Failure Rate (%)\", analysis[\"failure_rate\"]],\n            [\"\"],\n            [\"Failures by Reason\", \"\"],\n        ]\n\n        for reason, count in analysis[\"failures_by_reason\"].items():\n            rows.append([reason, count])\n\n        rows.append([\"\"])\n        rows.append([\"Failures by Filament\", \"\"])\n\n        for filament, count in analysis[\"failures_by_filament\"].items():\n            rows.append([filament, count])\n\n        rows.append([\"\"])\n        rows.append([\"Failures by Printer\", \"\"])\n\n        for printer, count in analysis[\"failures_by_printer\"].items():\n            rows.append([printer, count])\n\n        rows.append([\"\"])\n        rows.append([\"Weekly Trend\", \"\"])\n        rows.append([\"Week\", \"Total\", \"Failed\", \"Rate (%)\"])\n\n        for week in analysis[\"trend\"]:\n            rows.append(\n                [\n                    week[\"week_start\"],\n                    week[\"total_prints\"],\n                    week[\"failed_prints\"],\n                    week[\"failure_rate\"],\n                ]\n            )\n\n        timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n\n        if format == \"xlsx\":\n            file_bytes = self._generate_xlsx_simple(rows)\n            filename = f\"stats_export_{timestamp}.xlsx\"\n            content_type = \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n        else:\n            file_bytes = self._generate_csv_simple(rows)\n            filename = f\"stats_export_{timestamp}.csv\"\n            content_type = \"text/csv\"\n\n        return file_bytes, filename, content_type\n\n    def _archive_to_row(self, archive: PrintArchive, fields: list[str]) -> list[Any]:\n        \"\"\"Convert an archive to a row of values.\"\"\"\n        row = []\n        for field in fields:\n            if field == \"project_name\":\n                value = archive.project.name if archive.project else None\n            elif field in (\"started_at\", \"completed_at\", \"created_at\"):\n                value = getattr(archive, field)\n                if value:\n                    value = value.isoformat()\n            else:\n                value = getattr(archive, field, None)\n            row.append(value)\n        return row\n\n    def _generate_csv(self, headers: list[str], rows: list[list]) -> bytes:\n        \"\"\"Generate CSV file content.\"\"\"\n        output = io.StringIO()\n        writer = csv.writer(output)\n        writer.writerow(headers)\n        writer.writerows(rows)\n        return output.getvalue().encode(\"utf-8\")\n\n    def _generate_csv_simple(self, rows: list[list]) -> bytes:\n        \"\"\"Generate CSV file content from simple rows (no separate headers).\"\"\"\n        output = io.StringIO()\n        writer = csv.writer(output)\n        writer.writerows(rows)\n        return output.getvalue().encode(\"utf-8\")\n\n    def _generate_xlsx(self, headers: list[str], rows: list[list], fields: list[str]) -> bytes:\n        \"\"\"Generate Excel file content.\"\"\"\n        try:\n            from openpyxl import Workbook\n            from openpyxl.styles import Alignment, Font, PatternFill\n            from openpyxl.utils import get_column_letter\n        except ImportError:\n            raise ImportError(\"openpyxl is required for Excel export. Install with: pip install openpyxl\")\n\n        wb = Workbook()\n        ws = wb.active\n        ws.title = \"Archives\"\n\n        # Header style\n        header_font = Font(bold=True, color=\"FFFFFF\")\n        header_fill = PatternFill(start_color=\"4472C4\", end_color=\"4472C4\", fill_type=\"solid\")\n        header_alignment = Alignment(horizontal=\"center\")\n\n        # Write headers\n        for col, header in enumerate(headers, 1):\n            cell = ws.cell(row=1, column=col, value=header)\n            cell.font = header_font\n            cell.fill = header_fill\n            cell.alignment = header_alignment\n\n        # Write data\n        for row_idx, row in enumerate(rows, 2):\n            for col_idx, value in enumerate(row, 1):\n                ws.cell(row=row_idx, column=col_idx, value=value)\n\n        # Auto-adjust column widths\n        for col_idx, _field in enumerate(fields, 1):\n            column_letter = get_column_letter(col_idx)\n            max_length = len(headers[col_idx - 1])\n            for row in rows:\n                cell_value = row[col_idx - 1]\n                if cell_value is not None:\n                    max_length = max(max_length, len(str(cell_value)))\n            ws.column_dimensions[column_letter].width = min(max_length + 2, 50)\n\n        # Freeze header row\n        ws.freeze_panes = \"A2\"\n\n        output = io.BytesIO()\n        wb.save(output)\n        return output.getvalue()\n\n    def _generate_xlsx_simple(self, rows: list[list]) -> bytes:\n        \"\"\"Generate Excel file content from simple rows.\"\"\"\n        try:\n            from openpyxl import Workbook\n            from openpyxl.styles import Font\n        except ImportError:\n            raise ImportError(\"openpyxl is required for Excel export. Install with: pip install openpyxl\")\n\n        wb = Workbook()\n        ws = wb.active\n        ws.title = \"Statistics\"\n\n        bold_font = Font(bold=True)\n\n        for row_idx, row in enumerate(rows, 1):\n            for col_idx, value in enumerate(row, 1):\n                cell = ws.cell(row=row_idx, column=col_idx, value=value)\n                # Bold section headers\n                if col_idx == 1 and value and isinstance(value, str) and value.endswith(\":\"):\n                    cell.font = bold_font\n\n        output = io.BytesIO()\n        wb.save(output)\n        return output.getvalue()\n"
  },
  {
    "path": "backend/app/services/external_camera.py",
    "content": "\"\"\"External camera service.\n\nSupports MJPEG streams, RTSP streams (via ffmpeg), HTTP snapshot URLs, and USB cameras.\n\nSecurity Note: This service intentionally makes requests to user-configured camera URLs.\nThis is necessary functionality for external camera integration. URLs are validated\nto ensure they are well-formed before use.\n\"\"\"\n\nimport asyncio\nimport logging\nimport re\nimport shutil\nfrom collections.abc import AsyncGenerator\nfrom pathlib import Path\nfrom urllib.parse import urlparse\n\nimport aiohttp\n\nlogger = logging.getLogger(__name__)\n\n\ndef _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = (\"http\", \"https\", \"rtsp\")) -> str | None:\n    \"\"\"Validate and sanitize camera URL, returning a safe reconstructed URL.\n\n    This validates that the URL is well-formed, uses an allowed scheme,\n    does not target cloud metadata services, and returns a reconstructed\n    URL from validated components.\n\n    Note: This intentionally allows user-provided URLs as that is the\n    purpose of external camera configuration. Local network IPs are\n    allowed since cameras are typically on the same LAN.\n\n    Args:\n        url: URL to validate and sanitize\n        allowed_schemes: Tuple of allowed URL schemes\n\n    Returns:\n        Sanitized URL string if valid, None otherwise\n    \"\"\"\n    try:\n        parsed = urlparse(url)\n        if not parsed.scheme or not parsed.netloc:\n            return None\n\n        # Validate scheme against allowlist\n        scheme = parsed.scheme.lower()\n        if scheme not in allowed_schemes:\n            return None\n\n        # Block cloud metadata service endpoints (SSRF mitigation)\n        # These are dangerous destinations that should never be accessed\n        hostname = parsed.hostname or \"\"\n        hostname_lower = hostname.lower()\n        blocked_hosts = (\n            \"169.254.169.254\",  # AWS/GCP/Azure metadata\n            \"metadata.google.internal\",  # GCP metadata\n            \"metadata.google\",\n            \"localhost\",  # Block localhost to prevent internal service access\n            \"127.0.0.1\",\n            \"::1\",\n            \"0.0.0.0\",  # nosec B104\n        )\n        if hostname_lower in blocked_hosts:\n            logger.warning(\"Blocked camera URL targeting restricted host: %s\", hostname)\n            return None\n\n        # Block link-local addresses (169.254.x.x)\n        if hostname.startswith(\"169.254.\"):\n            logger.warning(\"Blocked camera URL targeting link-local address: %s\", hostname)\n            return None\n\n        # Reconstruct URL from validated components to break taint chain\n        # This creates a new string from validated parts\n        port_str = f\":{parsed.port}\" if parsed.port else \"\"\n        path = parsed.path or \"\"\n        query = f\"?{parsed.query}\" if parsed.query else \"\"\n        fragment = f\"#{parsed.fragment}\" if parsed.fragment else \"\"\n\n        # Build sanitized URL from validated components\n        sanitized = f\"{scheme}://{hostname}{port_str}{path}{query}{fragment}\"\n        return sanitized\n    except ValueError:\n        return None\n\n\ndef _validate_camera_url(url: str, allowed_schemes: tuple[str, ...] = (\"http\", \"https\", \"rtsp\")) -> bool:\n    \"\"\"Validate camera URL format (legacy wrapper).\n\n    Args:\n        url: URL to validate\n        allowed_schemes: Tuple of allowed URL schemes\n\n    Returns:\n        True if URL is valid, False otherwise\n    \"\"\"\n    return _sanitize_camera_url(url, allowed_schemes) is not None\n\n\ndef list_usb_cameras() -> list[dict]:\n    \"\"\"List available USB cameras (V4L2 devices on Linux).\n\n    Returns:\n        List of dicts with {device: str, name: str, capabilities: list}\n    \"\"\"\n    cameras = []\n    video_devices = sorted(Path(\"/dev\").glob(\"video*\"))\n\n    for device in video_devices:\n        device_path = str(device)\n        info = {\"device\": device_path, \"name\": device.name, \"capabilities\": []}\n\n        # Try to get device info via v4l2-ctl\n        v4l2_ctl = shutil.which(\"v4l2-ctl\")\n        if v4l2_ctl:\n            import subprocess\n\n            try:\n                result = subprocess.run(\n                    [v4l2_ctl, \"-d\", device_path, \"--info\"],\n                    capture_output=True,\n                    text=True,\n                    timeout=5,\n                )\n                if result.returncode == 0:\n                    # Parse device name from output\n                    for line in result.stdout.splitlines():\n                        if \"Card type\" in line:\n                            info[\"name\"] = line.split(\":\", 1)[1].strip()\n                        elif \"Driver name\" in line:\n                            info[\"driver\"] = line.split(\":\", 1)[1].strip()\n\n                    # Check if device supports video capture\n                    result = subprocess.run(\n                        [v4l2_ctl, \"-d\", device_path, \"--list-formats\"],\n                        capture_output=True,\n                        text=True,\n                        timeout=5,\n                    )\n                    if result.returncode == 0 and result.stdout.strip():\n                        info[\"capabilities\"].append(\"capture\")\n                        # Parse available formats\n                        formats = re.findall(r\"'(\\w+)'\", result.stdout)\n                        info[\"formats\"] = list(set(formats))\n\n            except (subprocess.TimeoutExpired, Exception) as e:\n                logger.debug(\"v4l2-ctl failed for %s: %s\", device_path, e)\n\n        # Only include devices that look like video capture devices\n        # Skip metadata devices (typically odd numbered like video1, video3)\n        try:\n            device_num = int(device.name.replace(\"video\", \"\"))\n            # Even numbered devices are usually capture, odd are metadata\n            # But also check if we got capabilities\n            if info.get(\"capabilities\") or device_num % 2 == 0:\n                cameras.append(info)\n        except ValueError:\n            cameras.append(info)\n\n    return cameras\n\n\ndef get_ffmpeg_path() -> str | None:\n    \"\"\"Get the path to ffmpeg executable.\"\"\"\n    # Try shutil.which first\n    path = shutil.which(\"ffmpeg\")\n    if path:\n        return path\n    # Check common locations (systemd services may have limited PATH)\n    for common_path in [\"/usr/bin/ffmpeg\", \"/usr/local/bin/ffmpeg\", \"/opt/homebrew/bin/ffmpeg\"]:\n        if Path(common_path).exists():\n            return common_path\n    return None\n\n\nasync def capture_frame(url: str, camera_type: str, timeout: int = 15) -> bytes | None:\n    \"\"\"Capture single frame from external camera.\n\n    Args:\n        url: Camera URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path)\n        camera_type: \"mjpeg\", \"rtsp\", \"snapshot\", or \"usb\"\n        timeout: Connection timeout in seconds\n\n    Returns:\n        JPEG bytes or None on failure\n    \"\"\"\n    logger.debug(\"capture_frame called: type=%s, url=%s...\", camera_type, url[:50] if url else \"None\")\n    if camera_type == \"mjpeg\":\n        return await _capture_mjpeg_frame(url, timeout)\n    elif camera_type == \"rtsp\":\n        return await _capture_rtsp_frame(url, timeout)\n    elif camera_type == \"snapshot\":\n        return await _capture_snapshot(url, timeout)\n    elif camera_type == \"usb\":\n        return await _capture_usb_frame(url, timeout)\n    else:\n        logger.warning(\"Unknown camera type: %s\", camera_type)\n        return None\n\n\nasync def _capture_usb_frame(device: str, timeout: int) -> bytes | None:\n    \"\"\"Capture frame from USB camera using ffmpeg.\"\"\"\n    ffmpeg = get_ffmpeg_path()\n    if not ffmpeg:\n        logger.error(\"ffmpeg not found - required for USB camera capture\")\n        return None\n\n    # Validate device path - must be /dev/videoN format where N is 0-99\n    # This prevents path traversal by using a strict allowlist approach\n    import re as regex_module\n\n    device_match = regex_module.match(r\"^/dev/video(\\d{1,2})$\", device)\n    if not device_match:\n        logger.error(\"Invalid USB device path format: %s\", device)\n        return None\n\n    # Convert to integer to break taint chain - integers cannot contain path traversal\n    # lgtm[py/path-injection] - device_num is validated integer 0-99\n    device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits\n    if device_num > 99:\n        logger.error(\"USB device number out of range: %s\", device_num)\n        return None\n\n    # Construct safe path from validated integer (completely untainted)\n    safe_device_path = Path(f\"/dev/video{device_num}\")  # lgtm[py/path-injection]\n\n    if not safe_device_path.exists():\n        logger.error(\"USB device does not exist: %s\", safe_device_path)\n        return None\n\n    # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path\n    device = str(safe_device_path)  # lgtm[py/path-injection]\n\n    # Use ffmpeg to grab a single frame from USB camera\n    cmd = [\n        ffmpeg,\n        \"-f\",\n        \"v4l2\",\n        \"-i\",\n        device,\n        \"-frames:v\",\n        \"1\",\n        \"-f\",\n        \"image2pipe\",\n        \"-vcodec\",\n        \"mjpeg\",\n        \"-q:v\",\n        \"2\",\n        \"-\",\n    ]\n\n    try:\n        logger.debug(\"Running USB capture: %s\", \" \".join(cmd))\n        process = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n\n        stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)\n\n        if process.returncode != 0:\n            logger.error(\"ffmpeg USB capture failed: %s\", stderr.decode()[:200])\n            return None\n\n        if not stdout or len(stdout) < 100:\n            logger.error(\"ffmpeg returned empty or too small frame from USB camera\")\n            return None\n\n        return stdout\n\n    except TimeoutError:\n        logger.warning(\"USB frame capture timed out after %ss\", timeout)\n        if process:\n            process.kill()\n        return None\n    except OSError as e:\n        logger.error(\"USB frame capture failed: %s\", e)\n        return None\n\n\nasync def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:\n    \"\"\"Extract single frame from MJPEG stream.\n\n    Note: This function intentionally makes requests to user-configured URLs.\n    External camera support requires connecting to user-specified camera endpoints.\n    URL is sanitized and dangerous destinations are blocked.\n    \"\"\"\n    # Sanitize URL - returns reconstructed URL from validated components\n    safe_url = _sanitize_camera_url(url, (\"http\", \"https\"))\n    if not safe_url:\n        logger.error(\"Invalid MJPEG URL format: %s...\", url[:50])\n        return None\n\n    try:\n        async with (\n            aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,\n            session.get(safe_url) as response,\n        ):\n            if response.status != 200:\n                logger.error(\"MJPEG stream returned status %s\", response.status)\n                return None\n\n            # Read chunks until we find a complete JPEG frame\n            buffer = b\"\"\n            jpeg_start = b\"\\xff\\xd8\"\n            jpeg_end = b\"\\xff\\xd9\"\n\n            async for chunk in response.content.iter_chunked(8192):\n                buffer += chunk\n\n                # Look for complete JPEG frame\n                start_idx = buffer.find(jpeg_start)\n                if start_idx == -1:\n                    continue\n\n                end_idx = buffer.find(jpeg_end, start_idx + 2)\n                if end_idx != -1:\n                    # Found complete frame\n                    frame = buffer[start_idx : end_idx + 2]\n                    return frame\n\n                # Keep searching, but limit buffer size\n                if len(buffer) > 5 * 1024 * 1024:  # 5MB limit\n                    logger.warning(\"MJPEG buffer exceeded 5MB without finding frame\")\n                    return None\n\n    except TimeoutError:\n        logger.warning(\"MJPEG frame capture timed out after %ss\", timeout)\n        return None\n    except (aiohttp.ClientError, OSError) as e:\n        logger.error(\"MJPEG frame capture failed: %s\", e)\n        return None\n\n    return None\n\n\nasync def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:\n    \"\"\"Capture frame from RTSP using ffmpeg.\n\n    For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.\n    \"\"\"\n    ffmpeg = get_ffmpeg_path()\n    if not ffmpeg:\n        logger.error(\"ffmpeg not found - required for RTSP capture\")\n        return None\n\n    # If rtsps://, use TLS proxy\n    proxy_server = None\n    effective_url = url\n    if url.lower().startswith(\"rtsps://\"):\n        try:\n            from urllib.parse import urlparse\n\n            from backend.app.services.camera import create_tls_proxy\n\n            parsed = urlparse(url)\n            target_port = parsed.port or 322\n            proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)\n            userinfo = \"\"\n            if parsed.username:\n                userinfo = parsed.username\n                if parsed.password:\n                    userinfo += f\":{parsed.password}\"\n                userinfo += \"@\"\n            effective_url = f\"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}\"\n            if parsed.query:\n                effective_url += f\"?{parsed.query}\"\n        except Exception as e:\n            logger.warning(\"Failed to create TLS proxy for RTSP capture, falling back: %s\", e)\n            effective_url = url\n\n    cmd = [\n        ffmpeg,\n        \"-rtsp_transport\",\n        \"tcp\",\n        \"-i\",\n        effective_url,\n        \"-frames:v\",\n        \"1\",\n        \"-f\",\n        \"image2pipe\",\n        \"-vcodec\",\n        \"mjpeg\",\n        \"-q:v\",\n        \"2\",\n        \"-\",\n    ]\n\n    try:\n        logger.debug(\"Running ffmpeg RTSP capture...\")\n        process = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n\n        stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)\n        logger.debug(\n            \"ffmpeg returned: code=%s, stdout=%s bytes, stderr=%s bytes\",\n            process.returncode,\n            len(stdout),\n            len(stderr),\n        )\n\n        if process.returncode != 0:\n            logger.error(\"ffmpeg RTSP capture failed: %s\", stderr.decode()[:200])\n            return None\n\n        if not stdout or len(stdout) < 100:\n            logger.error(\"ffmpeg returned empty or too small frame\")\n            return None\n\n        return stdout\n\n    except TimeoutError:\n        logger.warning(\"RTSP frame capture timed out after %ss\", timeout)\n        if process:\n            process.kill()\n        return None\n    except OSError as e:\n        logger.error(\"RTSP frame capture failed: %s\", e)\n        return None\n    finally:\n        if proxy_server:\n            proxy_server.close()\n            await proxy_server.wait_closed()\n\n\nasync def _capture_snapshot(url: str, timeout: int) -> bytes | None:\n    \"\"\"Fetch snapshot from HTTP URL.\n\n    Note: This function intentionally makes requests to user-configured URLs.\n    External camera support requires connecting to user-specified camera endpoints.\n    URL is sanitized and dangerous destinations are blocked.\n    \"\"\"\n    # Sanitize URL - returns reconstructed URL from validated components\n    safe_url = _sanitize_camera_url(url, (\"http\", \"https\"))\n    if not safe_url:\n        logger.error(\"Invalid snapshot URL format: %s...\", url[:50])\n        return None\n\n    try:\n        async with (\n            aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,\n            session.get(safe_url) as response,\n        ):\n            if response.status != 200:\n                logger.error(\"Snapshot URL returned status %s\", response.status)\n                return None\n\n            data = await response.read()\n\n            # Validate it looks like JPEG\n            if not data.startswith(b\"\\xff\\xd8\"):\n                logger.warning(\"Snapshot does not appear to be JPEG\")\n                # Still return it - might be valid with different header\n\n            return data\n\n    except TimeoutError:\n        logger.warning(\"Snapshot capture timed out after %ss\", timeout)\n        return None\n    except (aiohttp.ClientError, OSError) as e:\n        logger.error(\"Snapshot capture failed: %s\", e)\n        return None\n\n\nasync def test_connection(url: str, camera_type: str) -> dict:\n    \"\"\"Test camera connection.\n\n    Returns:\n        Dict with {success: bool, error?: str, resolution?: str}\n    \"\"\"\n    logger.info(\"Testing camera connection: type=%s, url=%s...\", camera_type, url[:50])\n    try:\n        frame = await capture_frame(url, camera_type, timeout=10)\n        logger.info(\"Capture result: %s bytes\", len(frame) if frame else 0)\n\n        if frame:\n            # Try to get resolution from JPEG header\n            resolution = None\n            try:\n                # Simple JPEG dimension extraction\n                # SOF0 marker is FF C0, followed by length, precision, height, width\n                sof_markers = [b\"\\xff\\xc0\", b\"\\xff\\xc1\", b\"\\xff\\xc2\"]\n                for marker in sof_markers:\n                    idx = frame.find(marker)\n                    if idx != -1 and idx + 9 <= len(frame):\n                        height = (frame[idx + 5] << 8) | frame[idx + 6]\n                        width = (frame[idx + 7] << 8) | frame[idx + 8]\n                        resolution = f\"{width}x{height}\"\n                        break\n            except (IndexError, ValueError):\n                pass  # Resolution detection is optional; fall back to default\n\n            return {\"success\": True, \"resolution\": resolution}\n        else:\n            return {\"success\": False, \"error\": \"Failed to capture frame from camera\"}\n\n    except Exception as e:\n        # Sanitize error message - don't expose internal details\n        error_type = type(e).__name__\n        logger.error(\"Camera connection test failed: %s\", e)\n        return {\"success\": False, \"error\": f\"Connection failed: {error_type}\"}\n\n\nasync def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:\n    \"\"\"Generator yielding MJPEG frames for streaming.\n\n    Args:\n        url: Camera URL or USB device path\n        camera_type: \"mjpeg\", \"rtsp\", \"snapshot\", or \"usb\"\n        fps: Target frames per second\n\n    Yields:\n        MJPEG frame data with HTTP multipart boundaries\n    \"\"\"\n    frame_interval = 1.0 / max(fps, 1)\n    last_frame_time = 0.0\n\n    if camera_type == \"mjpeg\":\n        # Proxy MJPEG stream directly, with reconnect on timeout\n        max_retries = 3\n        for attempt in range(max_retries + 1):\n            frame_yielded = False\n            async for frame in _stream_mjpeg(url):\n                frame_yielded = True\n                current_time = asyncio.get_event_loop().time()\n                if current_time - last_frame_time >= frame_interval:\n                    last_frame_time = current_time\n                    yield _format_mjpeg_frame(frame)\n            if not frame_yielded or attempt == max_retries:\n                break\n            logger.warning(\n                \"External MJPEG stream ended, reconnecting (attempt %d/%d)...\",\n                attempt + 1,\n                max_retries,\n            )\n            await asyncio.sleep(2)\n\n    elif camera_type == \"rtsp\":\n        # Use ffmpeg to convert RTSP to MJPEG, with reconnect on timeout\n        max_retries = 3\n        for attempt in range(max_retries + 1):\n            frame_yielded = False\n            async for frame in _stream_rtsp(url, fps):\n                frame_yielded = True\n                yield _format_mjpeg_frame(frame)\n            if not frame_yielded or attempt == max_retries:\n                break\n            logger.warning(\n                \"External RTSP stream ended, reconnecting (attempt %d/%d)...\",\n                attempt + 1,\n                max_retries,\n            )\n            await asyncio.sleep(2)\n\n    elif camera_type == \"usb\":\n        # Use ffmpeg to stream from USB camera\n        async for frame in _stream_usb(url, fps):\n            yield _format_mjpeg_frame(frame)\n\n    elif camera_type == \"snapshot\":\n        # Poll snapshot URL at interval\n        while True:\n            try:\n                frame = await _capture_snapshot(url, timeout=10)\n                if frame:\n                    yield _format_mjpeg_frame(frame)\n                await asyncio.sleep(frame_interval)\n            except asyncio.CancelledError:\n                break\n            except (aiohttp.ClientError, OSError) as e:\n                logger.warning(\"Snapshot poll failed: %s\", e)\n                await asyncio.sleep(frame_interval)\n\n\ndef _format_mjpeg_frame(frame: bytes) -> bytes:\n    \"\"\"Format frame for MJPEG HTTP response.\"\"\"\n    return (\n        b\"--frame\\r\\n\"\n        b\"Content-Type: image/jpeg\\r\\n\"\n        b\"Content-Length: \" + str(len(frame)).encode() + b\"\\r\\n\"\n        b\"\\r\\n\" + frame + b\"\\r\\n\"\n    )\n\n\nasync def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:\n    \"\"\"Stream frames from MJPEG URL.\n\n    Note: This function intentionally makes requests to user-configured URLs.\n    External camera support requires connecting to user-specified camera endpoints.\n    URL is sanitized and dangerous destinations are blocked.\n    \"\"\"\n    # Sanitize URL - returns reconstructed URL from validated components\n    safe_url = _sanitize_camera_url(url, (\"http\", \"https\"))\n    if not safe_url:\n        logger.error(\"Invalid MJPEG stream URL: %s...\", url[:50])\n        return\n\n    try:\n        timeout = aiohttp.ClientTimeout(total=None, sock_read=30)\n        async with aiohttp.ClientSession(timeout=timeout) as session, session.get(safe_url) as response:\n            if response.status != 200:\n                logger.error(\"MJPEG stream returned status %s\", response.status)\n                return\n\n            buffer = b\"\"\n            jpeg_start = b\"\\xff\\xd8\"\n            jpeg_end = b\"\\xff\\xd9\"\n\n            async for chunk in response.content.iter_chunked(8192):\n                buffer += chunk\n\n                # Extract complete frames from buffer\n                while True:\n                    start_idx = buffer.find(jpeg_start)\n                    if start_idx == -1:\n                        buffer = buffer[-2:] if len(buffer) > 2 else buffer\n                        break\n\n                    if start_idx > 0:\n                        buffer = buffer[start_idx:]\n\n                    end_idx = buffer.find(jpeg_end, 2)\n                    if end_idx == -1:\n                        break\n\n                    frame = buffer[: end_idx + 2]\n                    buffer = buffer[end_idx + 2 :]\n                    yield frame\n\n    except asyncio.CancelledError:\n        logger.info(\"MJPEG stream cancelled\")\n    except (aiohttp.ClientError, OSError) as e:\n        logger.error(\"MJPEG stream error: %s\", e)\n\n\nasync def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:\n    \"\"\"Stream frames from RTSP URL via ffmpeg.\n\n    For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead\n    of relying on ffmpeg's GnuTLS backend, which has compatibility issues\n    with some printer firmwares.\n    \"\"\"\n    ffmpeg = get_ffmpeg_path()\n    if not ffmpeg:\n        logger.error(\"ffmpeg not found - required for RTSP streaming\")\n        return\n\n    # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://\n    proxy_server = None\n    effective_url = url\n    if url.lower().startswith(\"rtsps://\"):\n        try:\n            from urllib.parse import urlparse\n\n            from backend.app.services.camera import create_tls_proxy\n\n            parsed = urlparse(url)\n            target_port = parsed.port or 322\n            proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)\n            # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path\n            userinfo = \"\"\n            if parsed.username:\n                userinfo = parsed.username\n                if parsed.password:\n                    userinfo += f\":{parsed.password}\"\n                userinfo += \"@\"\n            effective_url = f\"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}\"\n            if parsed.query:\n                effective_url += f\"?{parsed.query}\"\n        except Exception as e:\n            logger.warning(\"Failed to create TLS proxy for RTSP, falling back to direct: %s\", e)\n            effective_url = url\n\n    cmd = [\n        ffmpeg,\n        \"-rtsp_transport\",\n        \"tcp\",\n        \"-rtsp_flags\",\n        \"prefer_tcp\",\n        \"-timeout\",\n        \"30000000\",\n        \"-buffer_size\",\n        \"1024000\",\n        \"-max_delay\",\n        \"500000\",\n        \"-probesize\",\n        \"32\",\n        \"-analyzeduration\",\n        \"0\",\n        \"-fflags\",\n        \"nobuffer\",\n        \"-flags\",\n        \"low_delay\",\n        \"-i\",\n        effective_url,\n        \"-f\",\n        \"mjpeg\",\n        \"-q:v\",\n        \"5\",\n        \"-r\",\n        str(fps),\n        \"-an\",\n        \"-\",\n    ]\n\n    process = None\n    try:\n        process = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n\n        # Brief check for immediate startup failures\n        await asyncio.sleep(0.1)\n        if process.returncode is not None:\n            stderr = await process.stderr.read()\n            logger.error(\"ffmpeg RTSP stream failed immediately: %s\", stderr.decode()[:300])\n            return\n\n        buffer = b\"\"\n        jpeg_start = b\"\\xff\\xd8\"\n        jpeg_end = b\"\\xff\\xd9\"\n\n        while True:\n            try:\n                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)\n\n                if not chunk:\n                    break\n\n                buffer += chunk\n\n                # Extract complete frames\n                while True:\n                    start_idx = buffer.find(jpeg_start)\n                    if start_idx == -1:\n                        buffer = buffer[-2:] if len(buffer) > 2 else buffer\n                        break\n\n                    if start_idx > 0:\n                        buffer = buffer[start_idx:]\n\n                    end_idx = buffer.find(jpeg_end, 2)\n                    if end_idx == -1:\n                        break\n\n                    frame = buffer[: end_idx + 2]\n                    buffer = buffer[end_idx + 2 :]\n                    yield frame\n\n            except TimeoutError:\n                logger.warning(\"RTSP stream read timeout\")\n                break\n\n    except asyncio.CancelledError:\n        logger.info(\"RTSP stream cancelled\")\n    except OSError as e:\n        logger.error(\"RTSP stream error: %s\", e)\n    finally:\n        if process and process.returncode is None:\n            process.terminate()\n            try:\n                await asyncio.wait_for(process.wait(), timeout=2.0)\n            except TimeoutError:\n                process.kill()\n                await process.wait()\n        if proxy_server:\n            proxy_server.close()\n            await proxy_server.wait_closed()\n\n\nasync def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:\n    \"\"\"Stream frames from USB camera via ffmpeg.\"\"\"\n    ffmpeg = get_ffmpeg_path()\n    if not ffmpeg:\n        logger.error(\"ffmpeg not found - required for USB camera streaming\")\n        return\n\n    # Validate device path\n    if not device.startswith(\"/dev/video\"):\n        logger.error(\"Invalid USB device path: %s\", device)\n        return\n\n    if not Path(device).exists():\n        logger.error(\"USB device does not exist: %s\", device)\n        return\n\n    # ffmpeg command to stream from USB camera (v4l2)\n    cmd = [\n        ffmpeg,\n        \"-f\",\n        \"v4l2\",\n        \"-framerate\",\n        str(fps),\n        \"-i\",\n        device,\n        \"-f\",\n        \"mjpeg\",\n        \"-q:v\",\n        \"5\",\n        \"-r\",\n        str(fps),\n        \"-\",\n    ]\n\n    process = None\n    try:\n        logger.info(\"Starting USB camera stream from %s at %s fps\", device, fps)\n        process = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n\n        # Give ffmpeg a moment to start and check for immediate failures\n        await asyncio.sleep(0.5)\n        if process.returncode is not None:\n            stderr = await process.stderr.read()\n            logger.error(\"ffmpeg USB stream failed immediately: %s\", stderr.decode()[:300])\n            return\n\n        buffer = b\"\"\n        jpeg_start = b\"\\xff\\xd8\"\n        jpeg_end = b\"\\xff\\xd9\"\n\n        while True:\n            try:\n                chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)\n\n                if not chunk:\n                    break\n\n                buffer += chunk\n\n                # Extract complete frames\n                while True:\n                    start_idx = buffer.find(jpeg_start)\n                    if start_idx == -1:\n                        buffer = buffer[-2:] if len(buffer) > 2 else buffer\n                        break\n\n                    if start_idx > 0:\n                        buffer = buffer[start_idx:]\n\n                    end_idx = buffer.find(jpeg_end, 2)\n                    if end_idx == -1:\n                        break\n\n                    frame = buffer[: end_idx + 2]\n                    buffer = buffer[end_idx + 2 :]\n                    yield frame\n\n            except TimeoutError:\n                logger.warning(\"USB stream read timeout\")\n                break\n\n    except asyncio.CancelledError:\n        logger.info(\"USB stream cancelled\")\n    except OSError as e:\n        logger.error(\"USB stream error: %s\", e)\n    finally:\n        if process and process.returncode is None:\n            process.terminate()\n            try:\n                await asyncio.wait_for(process.wait(), timeout=2.0)\n            except TimeoutError:\n                process.kill()\n                await process.wait()\n"
  },
  {
    "path": "backend/app/services/failure_analysis.py",
    "content": "from collections import defaultdict\nfrom datetime import date, datetime, time, timedelta, timezone\n\nfrom sqlalchemy import and_, func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.printer import Printer\n\n\nclass FailureAnalysisService:\n    \"\"\"Service for analyzing print failure patterns.\"\"\"\n\n    def __init__(self, db: AsyncSession):\n        self.db = db\n\n    async def analyze_failures(\n        self,\n        days: int | None = None,\n        date_from: date | None = None,\n        date_to: date | None = None,\n        printer_id: int | None = None,\n        project_id: int | None = None,\n        created_by_id: int | None = None,\n    ) -> dict:\n        \"\"\"Analyze failure patterns across archives.\n\n        Args:\n            days: Number of days to analyze (fallback when no date range)\n            date_from: Start date filter (inclusive)\n            date_to: End date filter (inclusive)\n            printer_id: Optional filter by printer\n            project_id: Optional filter by project\n\n        Returns:\n            Dictionary with failure analysis results\n        \"\"\"\n        # Build base query — separate date vs non-date filters for trend reuse\n        base_filter = []\n        non_date_filter = []\n        if date_from or date_to:\n            if date_from:\n                dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)\n                base_filter.append(PrintArchive.created_at >= dt_from)\n            if date_to:\n                dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)\n                base_filter.append(PrintArchive.created_at <= dt_to)\n            # Compute effective span for trend\n            range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)\n            range_end = dt_to if date_to else datetime.now(timezone.utc)\n            effective_days = max((range_end - range_start).days, 1)\n        else:\n            effective_days = days if days is not None else 30\n            cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)\n            base_filter.append(PrintArchive.created_at >= cutoff_date)\n        if printer_id:\n            non_date_filter.append(PrintArchive.printer_id == printer_id)\n        if project_id:\n            non_date_filter.append(PrintArchive.project_id == project_id)\n        if created_by_id is not None:\n            if created_by_id == -1:\n                non_date_filter.append(PrintArchive.created_by_id.is_(None))\n            else:\n                non_date_filter.append(PrintArchive.created_by_id == created_by_id)\n        base_filter.extend(non_date_filter)\n\n        # Total counts\n        total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))\n        total_prints = total_result.scalar() or 0\n\n        failed_result = await self.db.execute(\n            select(func.count(PrintArchive.id)).where(\n                and_(*base_filter, PrintArchive.status.in_([\"failed\", \"aborted\"]))\n            )\n        )\n        failed_prints = failed_result.scalar() or 0\n\n        failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0\n\n        # Failures by reason\n        reason_result = await self.db.execute(\n            select(\n                PrintArchive.failure_reason,\n                func.count(PrintArchive.id).label(\"count\"),\n            )\n            .where(and_(*base_filter, PrintArchive.status.in_([\"failed\", \"aborted\"])))\n            .group_by(PrintArchive.failure_reason)\n            .order_by(func.count(PrintArchive.id).desc())\n        )\n        failures_by_reason = {(row[0] or \"Unknown\"): row[1] for row in reason_result.fetchall()}\n\n        # Failures by filament type\n        filament_result = await self.db.execute(\n            select(\n                PrintArchive.filament_type,\n                func.count(PrintArchive.id).label(\"count\"),\n            )\n            .where(and_(*base_filter, PrintArchive.status.in_([\"failed\", \"aborted\"])))\n            .group_by(PrintArchive.filament_type)\n            .order_by(func.count(PrintArchive.id).desc())\n        )\n        failures_by_filament = {(row[0] or \"Unknown\"): row[1] for row in filament_result.fetchall()}\n\n        # Failures by printer\n        printer_result = await self.db.execute(\n            select(\n                PrintArchive.printer_id,\n                func.count(PrintArchive.id).label(\"count\"),\n            )\n            .where(\n                and_(*base_filter, PrintArchive.status.in_([\"failed\", \"aborted\"]), PrintArchive.printer_id.isnot(None))\n            )\n            .group_by(PrintArchive.printer_id)\n            .order_by(func.count(PrintArchive.id).desc())\n        )\n        failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}\n\n        # Get printer names\n        if failures_by_printer_id:\n            printers_result = await self.db.execute(\n                select(Printer.id, Printer.name).where(Printer.id.in_(failures_by_printer_id.keys()))\n            )\n            printer_names = {row[0]: row[1] for row in printers_result.fetchall()}\n            failures_by_printer = {\n                printer_names.get(pid, f\"Printer {pid}\"): count for pid, count in failures_by_printer_id.items()\n            }\n        else:\n            failures_by_printer = {}\n\n        # Failures by hour of day\n        failed_archives_result = await self.db.execute(\n            select(PrintArchive.started_at).where(\n                and_(\n                    *base_filter,\n                    PrintArchive.status.in_([\"failed\", \"aborted\"]),\n                    PrintArchive.started_at.isnot(None),\n                )\n            )\n        )\n        failures_by_hour = defaultdict(int)\n        for (started_at,) in failed_archives_result.fetchall():\n            if started_at:\n                hour = started_at.hour\n                failures_by_hour[hour] += 1\n        # Convert to dict with all 24 hours\n        failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}\n\n        # Recent failures\n        recent_result = await self.db.execute(\n            select(PrintArchive)\n            .where(and_(*base_filter, PrintArchive.status.in_([\"failed\", \"aborted\"])))\n            .order_by(PrintArchive.created_at.desc())\n            .limit(10)\n        )\n        recent_failures = [\n            {\n                \"id\": a.id,\n                \"print_name\": a.print_name or a.filename,\n                \"failure_reason\": a.failure_reason,\n                \"filament_type\": a.filament_type,\n                \"printer_id\": a.printer_id,\n                \"created_at\": a.created_at.isoformat() if a.created_at else None,\n            }\n            for a in recent_result.scalars().all()\n        ]\n\n        # Failure rate trend (by week)\n        trend_data = []\n        num_weeks = max(effective_days // 7, 1)\n        for i in range(num_weeks):\n            week_end = datetime.now(timezone.utc) - timedelta(weeks=i)\n            week_start = week_end - timedelta(weeks=1)\n\n            week_filter = [\n                PrintArchive.created_at >= week_start,\n                PrintArchive.created_at < week_end,\n                *non_date_filter,\n            ]\n\n            week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))\n            week_failed = await self.db.execute(\n                select(func.count(PrintArchive.id)).where(\n                    and_(*week_filter, PrintArchive.status.in_([\"failed\", \"aborted\"]))\n                )\n            )\n\n            total = week_total.scalar() or 0\n            failed = week_failed.scalar() or 0\n            rate = (failed / total * 100) if total > 0 else 0\n\n            trend_data.append(\n                {\n                    \"week_start\": week_start.date().isoformat(),\n                    \"total_prints\": total,\n                    \"failed_prints\": failed,\n                    \"failure_rate\": round(rate, 1),\n                }\n            )\n\n        trend_data.reverse()  # Oldest first\n\n        return {\n            \"period_days\": effective_days,\n            \"total_prints\": total_prints,\n            \"failed_prints\": failed_prints,\n            \"failure_rate\": round(failure_rate, 1),\n            \"failures_by_reason\": failures_by_reason,\n            \"failures_by_filament\": failures_by_filament,\n            \"failures_by_printer\": failures_by_printer,\n            \"failures_by_hour\": failures_by_hour_complete,\n            \"recent_failures\": recent_failures,\n            \"trend\": trend_data,\n        }\n"
  },
  {
    "path": "backend/app/services/firmware_check.py",
    "content": "\"\"\"\nFirmware Check Service\n\nChecks for firmware updates by fetching from Bambu Lab's official wiki and firmware\ndownload page. The wiki is used as the primary version source (always up-to-date),\nwhile the download page provides firmware file URLs for offline updates.\n\"\"\"\n\nimport logging\nimport re\nimport time\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport httpx\n\nfrom backend.app.core.config import _data_dir\n\nlogger = logging.getLogger(__name__)\n\n# Bambu Lab firmware download page (for download URLs)\nBAMBU_FIRMWARE_BASE = \"https://bambulab.com\"\nFIRMWARE_PAGE = \"/en/support/firmware-download/all\"\n\n# Bambu Lab wiki (primary source for latest version detection)\nBAMBU_WIKI_BASE = \"https://wiki.bambulab.com\"\n\n# Cache TTL in seconds (1 hour)\nCACHE_TTL = 3600\n\n# Map Bambuddy model names to Bambu Lab API keys\nMODEL_TO_API_KEY = {\n    \"X1\": \"x1\",\n    \"X1C\": \"x1\",\n    \"X1-Carbon\": \"x1\",\n    \"X1 Carbon\": \"x1\",\n    \"P1P\": \"p1\",\n    \"P1S\": \"p1\",\n    \"A1\": \"a1\",\n    \"A1 Mini\": \"a1-mini\",\n    \"A1-Mini\": \"a1-mini\",\n    \"A1mini\": \"a1-mini\",\n    \"H2D\": \"h2d\",\n    \"H2C\": \"h2c\",\n    \"H2S\": \"h2s\",\n    \"P2S\": \"p2s\",\n    \"X1E\": \"x1e\",\n    \"X2D\": \"x2d\",\n    \"H2D Pro\": \"h2d-pro\",\n    \"H2D-Pro\": \"h2d-pro\",\n    \"H2DPRO\": \"h2d-pro\",\n    # SSDP model codes (DevModel header) — in case raw codes are stored\n    \"O1D\": \"h2d\",\n    \"O1E\": \"h2d-pro\",\n    \"O2D\": \"h2d-pro\",\n    \"O1C\": \"h2c\",\n    \"O1C2\": \"h2c\",\n    \"O1S\": \"h2s\",\n    \"BL-P001\": \"x1\",\n    \"BL-P002\": \"x1\",\n    \"BL-P003\": \"x1e\",\n    \"C11\": \"p1\",\n    \"C12\": \"p1\",\n    \"C13\": \"p2s\",\n    \"N2S\": \"a1\",\n    \"N1\": \"a1-mini\",\n    \"N6\": \"x2d\",\n    \"N7\": \"p2s\",\n}\n\n# Reverse mapping: API key to model codes\nAPI_KEY_TO_DEV_MODEL = {\n    \"x1\": \"BL-P001\",\n    \"p1\": \"C11\",\n    \"a1\": \"N2S\",\n    \"a1-mini\": \"N1\",\n    \"h2d\": \"O1D\",\n    \"h2c\": \"O1C\",\n    \"h2s\": \"O1S\",\n    \"p2s\": \"N7\",\n    \"x1e\": \"C13\",\n    \"x2d\": \"N6\",\n    \"h2d-pro\": \"O1E\",\n}\n\n# Wiki firmware release history pages (primary version source)\nAPI_KEY_TO_WIKI_PATH = {\n    \"x1\": \"/en/x1/manual/X1-X1C-firmware-release-history\",\n    \"x1e\": \"/en/x1/manual/X1E-firmware-release-history\",\n    \"p1\": \"/en/p1/manual/p1p-firmware-release-history\",\n    \"a1\": \"/en/a1/manual/a1-firmware-release-history\",\n    \"a1-mini\": \"/en/a1-mini/manual/a1-mini-firmware-release-history\",\n    \"h2d\": \"/en/h2d/manual/h2d-firmware-release-history\",\n    \"h2c\": \"/en/h2c/manual/h2c-firmware-release-history\",\n    \"h2s\": \"/en/h2s/manual/h2s-firmware-release-history\",\n    \"p2s\": \"/en/p2s/manual/p2s-firmware-release-history\",\n    \"x2d\": \"/en/x2d/manual/x2d-firmware-release-history\",\n    \"h2d-pro\": \"/en/h2d-pro/manual/firmware-release-history\",\n}\n\n\n@dataclass\nclass FirmwareVersion:\n    \"\"\"Firmware version information.\"\"\"\n\n    version: str\n    download_url: str\n    release_notes: str | None = None\n    release_time: str | None = None\n\n\nclass FirmwareCheckService:\n    \"\"\"Service for checking firmware updates from Bambu Lab.\"\"\"\n\n    def __init__(self):\n        self._build_id: str | None = None\n        self._build_id_time: float = 0\n        self._version_cache: dict[str, FirmwareVersion] = {}\n        self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}\n        self._cache_time: float = 0\n        self._client = httpx.AsyncClient(\n            timeout=30.0,\n            headers={\n                \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n            },\n        )\n\n    async def _get_build_id(self) -> str | None:\n        \"\"\"Fetch the Next.js build ID from Bambu Lab's firmware page.\"\"\"\n        # Use cached build ID if still valid (cache for 1 hour)\n        if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:\n            return self._build_id\n\n        try:\n            response = await self._client.get(f\"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}\")\n            if response.status_code == 200:\n                # Extract buildId from the page\n                match = re.search(r'\"buildId\":\"([^\"]+)\"', response.text)\n                if match:\n                    self._build_id = match.group(1)\n                    self._build_id_time = time.time()\n                    logger.info(\"Got Bambu Lab build ID: %s\", self._build_id)\n                    return self._build_id\n            logger.warning(\"Failed to get Bambu Lab page: %s\", response.status_code)\n        except Exception as e:\n            logger.error(\"Error fetching Bambu Lab build ID: %s\", e)\n\n        return self._build_id  # Return cached value if available\n\n    async def _fetch_version_from_wiki(self, api_key: str) -> str | None:\n        \"\"\"Fetch the latest firmware version from Bambu Lab's wiki release history page.\"\"\"\n        versions = await self._fetch_all_versions_from_wiki(api_key)\n        if versions:\n            logger.debug(\"Wiki firmware for %s: %s\", api_key, versions[0][0])\n            return versions[0][0]\n        return None\n\n    async def _fetch_all_versions_from_wiki(self, api_key: str) -> list[tuple[str, str | None]]:\n        \"\"\"\n        Fetch all firmware versions from the wiki release history page.\n\n        Only extracts versions that appear in section-heading anchors\n        (e.g. `id=\"h-01030000-20260303\"` or `id=\"h-0102000020260409\"`) —\n        this excludes version-like numbers mentioned incidentally in\n        release-note text. The dash separator between version and date is\n        optional: H2D/X1/H2C/H2S still use it, but P2S and X2D publish\n        anchors without the dash.\n\n        Returns list of (version, release_date_YYYYMMDD | None) tuples, newest first.\n        \"\"\"\n        wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)\n        if not wiki_path:\n            return []\n\n        try:\n            url = f\"{BAMBU_WIKI_BASE}{wiki_path}\"\n            response = await self._client.get(url, follow_redirects=True)\n            if response.status_code != 200:\n                return []\n\n            # Primary: heading anchor ids like id=\"h-01030000-20260303\" (dash)\n            # or id=\"h-0102000020260409\" (no dash, P2S/X2D-style).\n            anchor_matches = re.findall(r'id=\"h-(\\d{2})(\\d{2})(\\d{2})(\\d{2})-?(\\d{8})\"', response.text)\n            seen: set[str] = set()\n            versions: list[tuple[str, str | None]] = []\n            for a, b, c, d, date in anchor_matches:\n                v = f\"{a}.{b}.{c}.{d}\"\n                if v in seen:\n                    continue\n                seen.add(v)\n                versions.append((v, date))\n\n            if versions:\n                return versions\n\n            # Fallback: heading text with \"XX.XX.XX.XX (YYYYMMDD)\" —\n            # accept both ASCII \"()\" and full-width \"（）\" (U+FF08/U+FF09)\n            # which some pages (A1, A1-mini, P2S) use.\n            text_matches = re.findall(\n                r\"(\\d{2}\\.\\d{2}\\.\\d{2}\\.\\d{2})\\s*[(\\uff08](\\d{8})[)\\uff09]\",\n                response.text,\n            )\n            for v, date in text_matches:\n                if v in seen:\n                    continue\n                seen.add(v)\n                versions.append((v, date))\n            return versions\n        except Exception as e:\n            logger.debug(\"Error fetching wiki firmware list for %s: %s\", api_key, e)\n        return []\n\n    async def _fetch_all_versions_from_download_page(self, api_key: str) -> list[FirmwareVersion]:\n        \"\"\"Fetch all firmware versions from Bambu Lab's download page (newest first).\"\"\"\n        build_id = await self._get_build_id()\n        if not build_id:\n            return []\n\n        try:\n            url = f\"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json\"\n            response = await self._client.get(url)\n\n            if response.status_code == 200:\n                data = response.json()\n                page_props = data.get(\"pageProps\", {})\n                printer_map = page_props.get(\"printerMap\", {})\n                printer_data = printer_map.get(api_key, {})\n                versions = printer_data.get(\"versions\", [])\n                return [\n                    FirmwareVersion(\n                        version=v.get(\"version\", \"\"),\n                        download_url=v.get(\"url\", \"\"),\n                        release_notes=v.get(\"release_notes_en\"),\n                        release_time=v.get(\"release_time\"),\n                    )\n                    for v in versions\n                    if v.get(\"version\")\n                ]\n\n        except Exception as e:\n            logger.debug(\"Error fetching download page firmware for %s: %s\", api_key, e)\n\n        return []\n\n    async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:\n        \"\"\"Fetch the latest firmware info from Bambu Lab's download page (has download URLs).\"\"\"\n        versions = await self._fetch_all_versions_from_download_page(api_key)\n        return versions[0] if versions else None\n\n    async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:\n        \"\"\"Fetch firmware version info, using wiki as primary source and download page as fallback.\"\"\"\n        # Try wiki first (always has the latest version)\n        wiki_version = await self._fetch_version_from_wiki(api_key)\n\n        # Try download page (has download URLs, may lag behind wiki)\n        download_info = await self._fetch_from_download_page(api_key)\n\n        if wiki_version:\n            # Wiki has the latest version — use it, attach download URL if available\n            download_url = \"\"\n            release_notes = None\n            if download_info and download_info.version == wiki_version:\n                download_url = download_info.download_url\n                release_notes = download_info.release_notes\n            return FirmwareVersion(\n                version=wiki_version,\n                download_url=download_url,\n                release_notes=release_notes,\n            )\n\n        if download_info:\n            return download_info\n\n        logger.warning(\"Could not fetch firmware info for %s from wiki or download page\", api_key)\n        return None\n\n    async def get_latest_version(self, model: str) -> FirmwareVersion | None:\n        \"\"\"\n        Get the latest firmware version for a printer model.\n\n        Args:\n            model: Bambuddy printer model name (e.g., \"X1C\", \"P1S\", \"H2D\")\n\n        Returns:\n            FirmwareVersion if found, None otherwise\n        \"\"\"\n        # Normalize model name\n        model_upper = model.upper().replace(\" \", \"\").replace(\"-\", \"\")\n\n        # Find the API key for this model\n        api_key = None\n        for model_name, key in MODEL_TO_API_KEY.items():\n            if model_name.upper().replace(\" \", \"\").replace(\"-\", \"\") == model_upper:\n                api_key = key\n                break\n\n        if not api_key:\n            # Try direct lookup with original model\n            api_key = MODEL_TO_API_KEY.get(model)\n\n        if not api_key:\n            logger.debug(\"Unknown printer model: %s\", model)\n            return None\n\n        # Check cache\n        cache_key = api_key\n        if cache_key in self._version_cache and (time.time() - self._cache_time) < CACHE_TTL:\n            return self._version_cache[cache_key]\n\n        # Fetch from API\n        version = await self._fetch_firmware_versions(api_key)\n        if version:\n            self._version_cache[cache_key] = version\n            self._cache_time = time.time()\n\n        return version\n\n    def _resolve_api_key(self, model: str) -> str | None:\n        \"\"\"Resolve a model name to its Bambu API key.\"\"\"\n        model_upper = model.upper().replace(\" \", \"\").replace(\"-\", \"\")\n        for name, key in MODEL_TO_API_KEY.items():\n            if name.upper().replace(\" \", \"\").replace(\"-\", \"\") == model_upper:\n                return key\n        return MODEL_TO_API_KEY.get(model)\n\n    @staticmethod\n    def _version_tuple(v: str) -> tuple[int, ...]:\n        parts = [int(x) for x in v.split(\".\")]\n        while len(parts) < 4:\n            parts.append(0)\n        return tuple(parts)\n\n    async def get_available_versions(self, model: str) -> list[FirmwareVersion]:\n        \"\"\"\n        Get all announced firmware versions for a model, newest first.\n\n        Merges the wiki release history (list of version strings) with the\n        download page JSON (which provides download URLs + release notes).\n        Versions present only on the wiki have an empty download_url and\n        should be treated as \"unavailable\" for file-based installation.\n        \"\"\"\n        api_key = self._resolve_api_key(model)\n        if not api_key:\n            return []\n\n        if api_key in self._versions_list_cache and (time.time() - self._cache_time) < CACHE_TTL:\n            return self._versions_list_cache[api_key]\n\n        wiki_versions = await self._fetch_all_versions_from_wiki(api_key)\n        download_versions = await self._fetch_all_versions_from_download_page(api_key)\n        by_version: dict[str, FirmwareVersion] = {d.version: d for d in download_versions if d.version}\n\n        merged: list[FirmwareVersion] = []\n        seen: set[str] = set()\n        for v, wiki_date in wiki_versions:\n            if v in seen:\n                continue\n            seen.add(v)\n            if v in by_version:\n                merged.append(by_version[v])\n            else:\n                merged.append(FirmwareVersion(version=v, download_url=\"\", release_time=wiki_date))\n        for d in download_versions:\n            if d.version and d.version not in seen:\n                seen.add(d.version)\n                merged.append(d)\n\n        try:\n            merged.sort(key=lambda fv: self._version_tuple(fv.version), reverse=True)\n        except (ValueError, AttributeError):\n            pass\n\n        self._versions_list_cache[api_key] = merged\n        self._cache_time = time.time()\n        return merged\n\n    async def get_version_info(self, model: str, version: str) -> FirmwareVersion | None:\n        \"\"\"Find a specific version's info (including download URL) for a model.\"\"\"\n        for v in await self.get_available_versions(model):\n            if v.version == version:\n                return v\n        return None\n\n    async def check_for_update(self, model: str, current_version: str) -> dict:\n        \"\"\"\n        Check if a firmware update is available for a printer.\n\n        Args:\n            model: Printer model name\n            current_version: Currently installed firmware version\n\n        Returns:\n            Dict with update info:\n            - update_available: bool\n            - current_version: str\n            - latest_version: str or None\n            - download_url: str or None\n            - release_notes: str or None\n        \"\"\"\n        result = {\n            \"update_available\": False,\n            \"current_version\": current_version,\n            \"latest_version\": None,\n            \"download_url\": None,\n            \"release_notes\": None,\n            \"available_versions\": [],\n        }\n\n        available = await self.get_available_versions(model)\n        result[\"available_versions\"] = [\n            {\n                \"version\": v.version,\n                \"download_url\": v.download_url or None,\n                \"file_available\": bool(v.download_url),\n                \"release_notes\": v.release_notes,\n                \"release_time\": v.release_time,\n            }\n            for v in available\n        ]\n\n        if not current_version:\n            return result\n\n        latest = available[0] if available else await self.get_latest_version(model)\n        if not latest:\n            return result\n\n        result[\"latest_version\"] = latest.version\n        result[\"download_url\"] = latest.download_url or None\n        result[\"release_notes\"] = latest.release_notes\n\n        # Compare versions (format: XX.XX.XX.XX)\n        try:\n            current_parts = [int(x) for x in current_version.split(\".\")]\n            latest_parts = [int(x) for x in latest.version.split(\".\")]\n\n            # Pad to same length\n            while len(current_parts) < 4:\n                current_parts.append(0)\n            while len(latest_parts) < 4:\n                latest_parts.append(0)\n\n            result[\"update_available\"] = latest_parts > current_parts\n        except (ValueError, AttributeError):\n            logger.warning(\"Could not compare versions: %s vs %s\", current_version, latest.version)\n\n        return result\n\n    async def get_all_latest_versions(self) -> dict[str, FirmwareVersion]:\n        \"\"\"\n        Fetch latest firmware versions for all known printer models.\n\n        Returns:\n            Dict mapping API key to FirmwareVersion\n        \"\"\"\n        results = {}\n\n        for api_key in API_KEY_TO_DEV_MODEL:\n            version = await self._fetch_firmware_versions(api_key)\n            if version:\n                results[api_key] = version\n\n        return results\n\n    def _get_firmware_cache_dir(self) -> Path:\n        \"\"\"Get the firmware cache directory, creating it if needed.\"\"\"\n        cache_dir = _data_dir / \"firmware\"\n        cache_dir.mkdir(parents=True, exist_ok=True)\n        return cache_dir\n\n    async def get_firmware_file_info(self, model: str, version: str | None = None) -> dict | None:\n        \"\"\"\n        Get information about a firmware file for a model.\n\n        If `version` is provided, returns info for that specific version (must be\n        available on the download page). Otherwise returns info for the latest version.\n        \"\"\"\n        if version:\n            target = await self.get_version_info(model, version)\n        else:\n            target = await self.get_latest_version(model)\n        if not target or not target.download_url:\n            return None\n\n        url_parts = target.download_url.split(\"/\")\n        filename = url_parts[-1] if url_parts else f\"firmware_{model}.bin\"\n\n        return {\n            \"download_url\": target.download_url,\n            \"version\": target.version,\n            \"filename\": filename,\n            \"release_notes\": target.release_notes,\n        }\n\n    async def download_firmware(\n        self,\n        model: str,\n        progress_callback: Callable[[int, int, str], None] | None = None,\n        version: str | None = None,\n    ) -> Path | None:\n        \"\"\"\n        Download firmware file for a printer model.\n\n        Args:\n            model: Printer model name (e.g., \"X1C\", \"P1S\", \"H2D\")\n            progress_callback: Optional callback(bytes_downloaded, total_bytes, status_message)\n\n        Returns:\n            Path to downloaded firmware file, or None on failure\n        \"\"\"\n        if version:\n            latest = await self.get_version_info(model, version)\n        else:\n            latest = await self.get_latest_version(model)\n        if not latest or not latest.download_url:\n            logger.warning(\"No firmware download URL available for model %s version %s\", model, version)\n            return None\n\n        # Extract original filename from URL (must preserve for SD card update)\n        url_parts = latest.download_url.split(\"/\")\n        original_filename = url_parts[-1] if url_parts else f\"firmware_{model}.bin\"\n\n        # Check if already cached (using original filename so SD card gets the right name)\n        cached_path = self._get_firmware_cache_dir() / original_filename\n        if cached_path.exists():\n            logger.info(\"Using cached firmware: %s\", cached_path)\n            return cached_path\n\n        # Download to temp file first\n        temp_path = self._get_firmware_cache_dir() / f\".downloading_{original_filename}\"\n\n        try:\n            logger.info(\"Downloading firmware from %s\", latest.download_url)\n            if progress_callback:\n                progress_callback(0, 0, \"Starting download...\")\n\n            async with self._client.stream(\"GET\", latest.download_url) as response:\n                if response.status_code != 200:\n                    logger.error(\"Firmware download failed with status %s\", response.status_code)\n                    return None\n\n                total_size = int(response.headers.get(\"content-length\", 0))\n                downloaded = 0\n\n                with open(temp_path, \"wb\") as f:\n                    async for chunk in response.aiter_bytes(chunk_size=65536):\n                        f.write(chunk)\n                        downloaded += len(chunk)\n                        if progress_callback:\n                            progress_callback(downloaded, total_size, \"Downloading firmware...\")\n\n            # Move temp to final path, preserving original filename\n            temp_path.rename(cached_path)\n\n            logger.info(\"Firmware downloaded successfully: %s\", cached_path)\n            if progress_callback:\n                progress_callback(downloaded, total_size, \"Download complete\")\n\n            return cached_path\n\n        except Exception as e:\n            logger.error(\"Firmware download failed: %s\", e)\n            if temp_path.exists():\n                try:\n                    temp_path.unlink()\n                except OSError:\n                    pass  # Best-effort cleanup of failed download temp file\n            return None\n\n    async def close(self):\n        \"\"\"Close the HTTP client.\"\"\"\n        await self._client.aclose()\n\n\n# Singleton instance\n_firmware_service: FirmwareCheckService | None = None\n\n\ndef get_firmware_service() -> FirmwareCheckService:\n    \"\"\"Get the singleton firmware check service instance.\"\"\"\n    global _firmware_service\n    if _firmware_service is None:\n        _firmware_service = FirmwareCheckService()\n    return _firmware_service\n"
  },
  {
    "path": "backend/app/services/firmware_update.py",
    "content": "\"\"\"\nFirmware Update Service\n\nOrchestrates firmware updates for Bambu Lab printers:\n1. Check prerequisites (SD card, space, update available)\n2. Download firmware from Bambu Lab\n3. Upload to printer's SD card via FTP\n4. Notify user to trigger update from printer screen\n\"\"\"\n\nimport asyncio\nimport logging\nfrom dataclasses import dataclass\n\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.compat import StrEnum\nfrom backend.app.core.websocket import ws_manager\nfrom backend.app.models.printer import Printer\nfrom backend.app.services.bambu_ftp import (\n    get_ftp_retry_settings,\n    get_storage_info_async,\n    upload_file_async,\n    with_ftp_retry,\n)\nfrom backend.app.services.firmware_check import get_firmware_service\nfrom backend.app.services.printer_manager import printer_manager\n\nlogger = logging.getLogger(__name__)\n\n\nclass FirmwareUploadStatus(StrEnum):\n    \"\"\"Status of a firmware upload operation.\"\"\"\n\n    IDLE = \"idle\"\n    PREPARING = \"preparing\"\n    DOWNLOADING = \"downloading\"\n    UPLOADING = \"uploading\"\n    COMPLETE = \"complete\"\n    ERROR = \"error\"\n\n\n@dataclass\nclass FirmwareUploadState:\n    \"\"\"State of a firmware upload operation for a printer.\"\"\"\n\n    status: FirmwareUploadStatus = FirmwareUploadStatus.IDLE\n    progress: int = 0  # 0-100\n    message: str = \"\"\n    error: str | None = None\n    firmware_filename: str | None = None\n    firmware_version: str | None = None\n\n\n# Track upload state per printer\n_upload_states: dict[int, FirmwareUploadState] = {}\n\n\ndef get_upload_state(printer_id: int) -> FirmwareUploadState:\n    \"\"\"Get the current upload state for a printer.\"\"\"\n    if printer_id not in _upload_states:\n        _upload_states[printer_id] = FirmwareUploadState()\n    return _upload_states[printer_id]\n\n\ndef reset_upload_state(printer_id: int):\n    \"\"\"Reset the upload state for a printer.\"\"\"\n    _upload_states[printer_id] = FirmwareUploadState()\n\n\nclass FirmwareUpdateService:\n    \"\"\"Service for managing firmware updates.\"\"\"\n\n    # Minimum free space required (100MB buffer)\n    MIN_FREE_SPACE_BYTES = 100 * 1024 * 1024\n\n    async def prepare_update(\n        self,\n        printer_id: int,\n        db: AsyncSession,\n        target_version: str | None = None,\n    ) -> dict:\n        \"\"\"\n        Check prerequisites for firmware update.\n\n        Returns:\n            Dict with:\n            - can_proceed: bool\n            - sd_card_present: bool\n            - sd_card_free_space: int (bytes, -1 if unknown)\n            - firmware_size: int (bytes, estimated)\n            - space_sufficient: bool\n            - update_available: bool\n            - current_version: str | None\n            - latest_version: str | None\n            - firmware_filename: str | None\n            - errors: list[str]\n        \"\"\"\n        result = {\n            \"can_proceed\": False,\n            \"sd_card_present\": False,\n            \"sd_card_free_space\": -1,\n            \"firmware_size\": 0,\n            \"space_sufficient\": False,\n            \"update_available\": False,\n            \"current_version\": None,\n            \"latest_version\": None,\n            \"target_version\": target_version,\n            \"firmware_filename\": None,\n            \"errors\": [],\n        }\n\n        # Get printer from database\n        stmt = select(Printer).where(Printer.id == printer_id)\n        db_result = await db.execute(stmt)\n        printer = db_result.scalar_one_or_none()\n\n        if not printer:\n            result[\"errors\"].append(\"Printer not found\")\n            return result\n\n        # Check printer is connected\n        mqtt_client = printer_manager.get_client(printer_id)\n        if not mqtt_client or not mqtt_client.state:\n            result[\"errors\"].append(\"Printer not connected\")\n            return result\n\n        state = mqtt_client.state\n\n        # Get current firmware version\n        result[\"current_version\"] = state.firmware_version\n\n        # Check SD card\n        result[\"sd_card_present\"] = state.sdcard\n        if not state.sdcard:\n            result[\"errors\"].append(\"No SD card inserted in printer\")\n\n        # Get storage info via FTP\n        if state.sdcard:\n            try:\n                storage_info = await get_storage_info_async(\n                    printer.ip_address,\n                    printer.access_code,\n                    printer_model=printer.model,\n                )\n                if storage_info and \"free_bytes\" in storage_info:\n                    result[\"sd_card_free_space\"] = storage_info[\"free_bytes\"]\n            except Exception as e:\n                logger.warning(\"Could not get storage info: %s\", e)\n\n        # Check for firmware update\n        firmware_service = get_firmware_service()\n        model = printer.model or \"Unknown\"\n\n        if state.firmware_version:\n            update_info = await firmware_service.check_for_update(model, state.firmware_version)\n            result[\"update_available\"] = update_info[\"update_available\"]\n            result[\"latest_version\"] = update_info[\"latest_version\"]\n        else:\n            # If we don't know current version, just get latest\n            latest = await firmware_service.get_latest_version(model)\n            if latest:\n                result[\"latest_version\"] = latest.version\n                result[\"update_available\"] = True  # Assume update needed\n\n        # Get firmware file info (for target_version if specified, else latest)\n        file_info = await firmware_service.get_firmware_file_info(model, version=target_version)\n        if file_info:\n            result[\"firmware_filename\"] = file_info[\"filename\"]\n            # Estimate size (typical firmware is 50-150MB)\n            # We'll get actual size during download\n            result[\"firmware_size\"] = 100 * 1024 * 1024  # 100MB estimate\n        elif target_version:\n            # Requested specific version has no download URL\n            result[\"errors\"].append(f\"Firmware file for {target_version} is not available from Bambu Lab\")\n\n        # If a target version is requested, allow proceeding even if it equals or\n        # is older than the current version (explicit downgrade/reinstall).\n        if target_version:\n            result[\"update_available\"] = bool(file_info)\n        elif not result[\"update_available\"]:\n            result[\"errors\"].append(\"Firmware is already up to date\")\n\n        # Check space\n        if result[\"sd_card_free_space\"] > 0:\n            # Need firmware size + buffer\n            required = result[\"firmware_size\"] + self.MIN_FREE_SPACE_BYTES\n            result[\"space_sufficient\"] = result[\"sd_card_free_space\"] >= required\n            if not result[\"space_sufficient\"]:\n                result[\"errors\"].append(\n                    f\"Insufficient SD card space. Need {required // (1024 * 1024)}MB, \"\n                    f\"have {result['sd_card_free_space'] // (1024 * 1024)}MB\"\n                )\n        elif result[\"sd_card_present\"]:\n            # Couldn't determine space, assume sufficient\n            result[\"space_sufficient\"] = True\n\n        # Final check\n        result[\"can_proceed\"] = (\n            result[\"sd_card_present\"]\n            and result[\"space_sufficient\"]\n            and result[\"update_available\"]\n            and len(result[\"errors\"]) == 0\n        )\n\n        return result\n\n    async def start_upload(\n        self,\n        printer_id: int,\n        db: AsyncSession,\n        target_version: str | None = None,\n    ) -> bool:\n        \"\"\"\n        Start the firmware upload process.\n\n        This runs asynchronously and broadcasts progress via WebSocket.\n        Returns True if upload started successfully.\n        \"\"\"\n        state = get_upload_state(printer_id)\n\n        # Check if already in progress\n        if state.status in (FirmwareUploadStatus.DOWNLOADING, FirmwareUploadStatus.UPLOADING):\n            logger.warning(\"Firmware upload already in progress for printer %s\", printer_id)\n            return False\n\n        # Get printer\n        stmt = select(Printer).where(Printer.id == printer_id)\n        db_result = await db.execute(stmt)\n        printer = db_result.scalar_one_or_none()\n\n        if not printer:\n            state.status = FirmwareUploadStatus.ERROR\n            state.error = \"Printer not found\"\n            return False\n\n        # Get printer model\n        model = printer.model or \"Unknown\"\n\n        # Reset state\n        reset_upload_state(printer_id)\n        state = get_upload_state(printer_id)\n        state.status = FirmwareUploadStatus.PREPARING\n        state.message = \"Preparing firmware update...\"\n        await self._broadcast_progress(printer_id, state)\n\n        # Run the upload in background\n        asyncio.create_task(\n            self._do_upload(\n                printer_id=printer_id,\n                ip_address=printer.ip_address,\n                access_code=printer.access_code,\n                model=model,\n                target_version=target_version,\n            )\n        )\n\n        return True\n\n    async def _do_upload(\n        self,\n        printer_id: int,\n        ip_address: str,\n        access_code: str,\n        model: str,\n        target_version: str | None = None,\n    ):\n        \"\"\"Perform the actual firmware download and upload.\"\"\"\n        state = get_upload_state(printer_id)\n        firmware_service = get_firmware_service()\n\n        try:\n            # Download firmware (quick, usually cached)\n            state.status = FirmwareUploadStatus.DOWNLOADING\n            state.progress = 0\n            state.message = \"Preparing firmware...\"\n            await self._broadcast_progress(printer_id, state)\n\n            firmware_path = await firmware_service.download_firmware(model, version=target_version)\n\n            if not firmware_path:\n                raise Exception(\"Failed to download firmware\")\n\n            state.firmware_filename = firmware_path.name\n\n            # Get firmware version for state\n            if target_version:\n                state.firmware_version = target_version\n            else:\n                latest = await firmware_service.get_latest_version(model)\n                if latest:\n                    state.firmware_version = latest.version\n\n            # Upload to printer (0-100% progress shown here)\n            state.status = FirmwareUploadStatus.UPLOADING\n            state.progress = 0\n            state.message = f\"Uploading {firmware_path.name} to printer...\"\n            await self._broadcast_progress(printer_id, state)\n\n            # Upload to root of SD card (where printer expects firmware)\n            remote_path = f\"/{firmware_path.name}\"\n\n            logger.info(\"Uploading firmware to printer %s: %s\", printer_id, remote_path)\n\n            # Track real progress via FTP callback\n            loop = asyncio.get_event_loop()\n            last_progress = 0\n\n            def on_upload_progress(uploaded: int, total: int):\n                nonlocal last_progress\n                if total > 0:\n                    progress = int((uploaded / total) * 100)\n                    # Only broadcast every 1% to avoid flooding\n                    if progress > last_progress:\n                        last_progress = progress\n                        state.progress = min(99, progress)  # Cap at 99 until complete\n                        asyncio.run_coroutine_threadsafe(self._broadcast_progress(printer_id, state), loop)\n\n            # Get FTP retry settings\n            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()\n\n            if ftp_retry_enabled:\n                success = await with_ftp_retry(\n                    upload_file_async,\n                    ip_address,\n                    access_code,\n                    firmware_path,\n                    remote_path,\n                    progress_callback=on_upload_progress,\n                    socket_timeout=ftp_timeout,\n                    printer_model=model,\n                    max_retries=ftp_retry_count,\n                    retry_delay=ftp_retry_delay,\n                    operation_name=f\"Upload firmware to printer {printer_id}\",\n                )\n            else:\n                success = await upload_file_async(\n                    ip_address,\n                    access_code,\n                    firmware_path,\n                    remote_path,\n                    progress_callback=on_upload_progress,\n                    socket_timeout=ftp_timeout,\n                    printer_model=model,\n                )\n\n            if not success:\n                raise Exception(\"Failed to upload firmware to printer\")\n\n            # Complete\n            state.status = FirmwareUploadStatus.COMPLETE\n            state.progress = 100\n            state.message = (\n                f\"Firmware {state.firmware_version or ''} uploaded successfully! \"\n                \"Please go to printer screen and trigger the update from Settings > Firmware.\"\n            )\n            await self._broadcast_progress(printer_id, state)\n\n            logger.info(\"Firmware upload complete for printer %s\", printer_id)\n\n        except Exception as e:\n            logger.error(\"Firmware upload failed for printer %s: %s\", printer_id, e)\n            state.status = FirmwareUploadStatus.ERROR\n            state.error = str(e)\n            state.message = f\"Firmware upload failed: {e}\"\n            await self._broadcast_progress(printer_id, state)\n\n    async def _broadcast_progress(self, printer_id: int, state: FirmwareUploadState):\n        \"\"\"Broadcast firmware upload progress via WebSocket.\"\"\"\n        await ws_manager.broadcast(\n            {\n                \"type\": \"firmware_upload_progress\",\n                \"printer_id\": printer_id,\n                \"status\": state.status.value,\n                \"progress\": state.progress,\n                \"message\": state.message,\n                \"error\": state.error,\n                \"firmware_filename\": state.firmware_filename,\n                \"firmware_version\": state.firmware_version,\n            }\n        )\n\n\n# Singleton instance\n_firmware_update_service: FirmwareUpdateService | None = None\n\n\ndef get_firmware_update_service() -> FirmwareUpdateService:\n    \"\"\"Get the singleton firmware update service instance.\"\"\"\n    global _firmware_update_service\n    if _firmware_update_service is None:\n        _firmware_update_service = FirmwareUpdateService()\n    return _firmware_update_service\n"
  },
  {
    "path": "backend/app/services/github_backup.py",
    "content": "\"\"\"GitHub backup service for printer profiles.\n\nHandles scheduled and on-demand backups of K-profiles and cloud profiles to GitHub.\n\"\"\"\n\nimport asyncio\nimport base64\nimport hashlib\nimport json\nimport logging\nimport re\nfrom datetime import datetime, timedelta, timezone\n\nimport httpx\nfrom sqlalchemy import desc, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.database import async_session\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.spool import Spool\nfrom backend.app.models.spool_usage_history import SpoolUsageHistory\nfrom backend.app.services.printer_manager import printer_manager\n\nlogger = logging.getLogger(__name__)\n\n# Schedule intervals in seconds\nSCHEDULE_INTERVALS = {\n    \"hourly\": 3600,\n    \"daily\": 86400,\n    \"weekly\": 604800,\n}\n\n\nclass GitHubBackupService:\n    \"\"\"Service for backing up profiles to GitHub.\"\"\"\n\n    def __init__(self):\n        self._scheduler_task: asyncio.Task | None = None\n        self._check_interval = 60  # Check every minute for scheduled runs\n        self._running_backup: bool = False\n        self._backup_progress: str | None = None\n        self._http_client: httpx.AsyncClient | None = None\n\n    async def _get_client(self) -> httpx.AsyncClient:\n        \"\"\"Get or create HTTP client.\"\"\"\n        if self._http_client is None or self._http_client.is_closed:\n            self._http_client = httpx.AsyncClient(timeout=60.0)\n        return self._http_client\n\n    async def start_scheduler(self):\n        \"\"\"Start the background scheduler loop.\"\"\"\n        if self._scheduler_task is not None:\n            return\n        logger.info(\"Starting GitHub backup scheduler\")\n        self._scheduler_task = asyncio.create_task(self._scheduler_loop())\n\n    def stop_scheduler(self):\n        \"\"\"Stop the scheduler.\"\"\"\n        if self._scheduler_task:\n            self._scheduler_task.cancel()\n            self._scheduler_task = None\n            logger.info(\"Stopped GitHub backup scheduler\")\n\n    async def _scheduler_loop(self):\n        \"\"\"Main scheduler loop - checks for due backups.\"\"\"\n        while True:\n            try:\n                await asyncio.sleep(self._check_interval)\n                await self._check_scheduled_backups()\n            except asyncio.CancelledError:\n                break\n            except Exception as e:\n                logger.error(\"Error in GitHub backup scheduler: %s\", e)\n                await asyncio.sleep(60)\n\n    async def _check_scheduled_backups(self):\n        \"\"\"Check if any scheduled backups are due.\"\"\"\n        async with async_session() as db:\n            result = await db.execute(\n                select(GitHubBackupConfig).where(\n                    GitHubBackupConfig.enabled == True,  # noqa: E712\n                    GitHubBackupConfig.schedule_enabled == True,  # noqa: E712\n                )\n            )\n            configs = result.scalars().all()\n\n            now = datetime.now(timezone.utc)\n            for config in configs:\n                # Handle both naive (from DB) and aware datetimes\n                next_run = config.next_scheduled_run\n                if next_run and next_run.tzinfo is None:\n                    next_run = next_run.replace(tzinfo=timezone.utc)\n                if next_run and next_run <= now:\n                    logger.info(\"Running scheduled backup for config %s\", config.id)\n                    await self.run_backup(config.id, trigger=\"scheduled\")\n\n    def _calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:\n        \"\"\"Calculate the next scheduled run time.\"\"\"\n        now = from_time or datetime.now(timezone.utc)\n        interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS[\"daily\"])\n        return now + timedelta(seconds=interval)\n\n    async def test_connection(self, repo_url: str, token: str) -> dict:\n        \"\"\"Test GitHub connection and permissions.\n\n        Args:\n            repo_url: GitHub repository URL\n            token: Personal Access Token\n\n        Returns:\n            dict with success, message, repo_name, permissions\n        \"\"\"\n        try:\n            owner, repo = self._parse_repo_url(repo_url)\n            client = await self._get_client()\n\n            # Test API access\n            response = await client.get(\n                f\"https://api.github.com/repos/{owner}/{repo}\",\n                headers={\n                    \"Authorization\": f\"token {token}\",\n                    \"Accept\": \"application/vnd.github.v3+json\",\n                    \"User-Agent\": \"Bambuddy-Backup\",\n                },\n            )\n\n            if response.status_code == 401:\n                return {\"success\": False, \"message\": \"Invalid access token\", \"repo_name\": None, \"permissions\": None}\n\n            if response.status_code == 404:\n                return {\n                    \"success\": False,\n                    \"message\": \"Repository not found. Check URL and token permissions.\",\n                    \"repo_name\": None,\n                    \"permissions\": None,\n                }\n\n            if response.status_code != 200:\n                return {\n                    \"success\": False,\n                    \"message\": f\"GitHub API error: {response.status_code}\",\n                    \"repo_name\": None,\n                    \"permissions\": None,\n                }\n\n            data = response.json()\n            permissions = data.get(\"permissions\", {})\n\n            # Check for push permission\n            if not permissions.get(\"push\", False):\n                return {\n                    \"success\": False,\n                    \"message\": \"Token does not have push permission to this repository\",\n                    \"repo_name\": data.get(\"full_name\"),\n                    \"permissions\": permissions,\n                }\n\n            return {\n                \"success\": True,\n                \"message\": \"Connection successful\",\n                \"repo_name\": data.get(\"full_name\"),\n                \"permissions\": permissions,\n            }\n\n        except Exception as e:\n            logger.error(\"GitHub connection test failed: %s\", e)\n            # Sanitize error - don't expose internal details\n            error_type = type(e).__name__\n            return {\n                \"success\": False,\n                \"message\": f\"Connection failed: {error_type}\",\n                \"repo_name\": None,\n                \"permissions\": None,\n            }\n\n    def _parse_repo_url(self, url: str) -> tuple[str, str]:\n        \"\"\"Parse owner and repo from GitHub URL.\"\"\"\n        # Limit URL length to prevent ReDoS attacks\n        if not url or len(url) > 500:\n            raise ValueError(\"Invalid GitHub URL: URL too long or empty\")\n\n        # Handle HTTPS URLs - use atomic groups via limited character classes\n        # GitHub usernames: 1-39 chars, alphanumeric and hyphens\n        # Repo names: 1-100 chars, alphanumeric, hyphens, underscores, dots\n        match = re.match(r\"https://github\\.com/([\\w-]{1,39})/([\\w.\\-]{1,100})(?:\\.git)?/?$\", url)\n        if match:\n            return match.group(1), match.group(2)\n\n        # Handle SSH URLs\n        match = re.match(r\"git@github\\.com:([\\w-]{1,39})/([\\w.\\-]{1,100})(?:\\.git)?$\", url)\n        if match:\n            return match.group(1), match.group(2)\n\n        raise ValueError(f\"Invalid GitHub URL: {url}\")\n\n    async def run_backup(self, config_id: int, trigger: str = \"manual\") -> dict:\n        \"\"\"Run a backup operation.\n\n        Args:\n            config_id: ID of the backup configuration\n            trigger: \"manual\" or \"scheduled\"\n\n        Returns:\n            dict with success, message, log_id, commit_sha, files_changed\n        \"\"\"\n        if self._running_backup:\n            return {\"success\": False, \"message\": \"A backup is already running\", \"log_id\": None}\n\n        self._running_backup = True\n        log_id = None\n\n        try:\n            async with async_session() as db:\n                # Get config\n                result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))\n                config = result.scalar_one_or_none()\n\n                if not config:\n                    return {\"success\": False, \"message\": \"Configuration not found\", \"log_id\": None}\n\n                if not config.enabled:\n                    return {\"success\": False, \"message\": \"Backup is disabled\", \"log_id\": None}\n\n                # Create log entry\n                log = GitHubBackupLog(config_id=config_id, status=\"running\", trigger=trigger)\n                db.add(log)\n                await db.commit()\n                await db.refresh(log)\n                log_id = log.id\n\n                try:\n                    # Collect backup data\n                    self._backup_progress = \"Collecting profiles...\"\n                    backup_data = await self._collect_backup_data(db, config)\n\n                    if not backup_data:\n                        # No data to backup\n                        log.status = \"skipped\"\n                        log.completed_at = datetime.now(timezone.utc)\n                        log.error_message = \"No data to backup\"\n                        config.last_backup_at = datetime.now(timezone.utc)\n                        config.last_backup_status = \"skipped\"\n                        config.last_backup_message = \"No data to backup\"\n                        if config.schedule_enabled:\n                            config.next_scheduled_run = self._calculate_next_run(config.schedule_type)\n                        await db.commit()\n                        return {\n                            \"success\": True,\n                            \"message\": \"No data to backup\",\n                            \"log_id\": log_id,\n                            \"commit_sha\": None,\n                            \"files_changed\": 0,\n                        }\n\n                    # Push to GitHub\n                    self._backup_progress = \"Pushing to GitHub...\"\n                    push_result = await self._push_to_github(config, backup_data)\n\n                    # Update log and config\n                    log.status = push_result[\"status\"]\n                    log.completed_at = datetime.now(timezone.utc)\n                    log.commit_sha = push_result.get(\"commit_sha\")\n                    log.files_changed = push_result.get(\"files_changed\", 0)\n                    log.error_message = push_result.get(\"error\")\n\n                    config.last_backup_at = datetime.now(timezone.utc)\n                    config.last_backup_status = push_result[\"status\"]\n                    config.last_backup_message = push_result.get(\"message\", \"\")\n                    config.last_backup_commit_sha = push_result.get(\"commit_sha\")\n\n                    if config.schedule_enabled:\n                        config.next_scheduled_run = self._calculate_next_run(config.schedule_type)\n\n                    await db.commit()\n\n                    return {\n                        \"success\": push_result[\"status\"] in (\"success\", \"skipped\"),\n                        \"message\": push_result.get(\"message\", \"Backup completed\"),\n                        \"log_id\": log_id,\n                        \"commit_sha\": push_result.get(\"commit_sha\"),\n                        \"files_changed\": push_result.get(\"files_changed\", 0),\n                    }\n\n                except Exception as e:\n                    logger.error(\"Backup failed: %s\", e)\n                    log.status = \"failed\"\n                    log.completed_at = datetime.now(timezone.utc)\n                    log.error_message = str(e)\n\n                    config.last_backup_at = datetime.now(timezone.utc)\n                    config.last_backup_status = \"failed\"\n                    config.last_backup_message = str(e)\n\n                    if config.schedule_enabled:\n                        config.next_scheduled_run = self._calculate_next_run(config.schedule_type)\n\n                    await db.commit()\n                    return {\n                        \"success\": False,\n                        \"message\": str(e),\n                        \"log_id\": log_id,\n                        \"commit_sha\": None,\n                        \"files_changed\": 0,\n                    }\n\n        finally:\n            self._running_backup = False\n            self._backup_progress = None\n\n    async def _collect_backup_data(self, db: AsyncSession, config: GitHubBackupConfig) -> dict:\n        \"\"\"Collect data to backup based on config settings.\n\n        Returns dict with structure:\n        {\n            \"backup_metadata.json\": {...},\n            \"kprofiles/{serial}/{nozzle}.json\": {...},\n            \"cloud_profiles/filament.json\": [...],\n            \"cloud_profiles/printer.json\": [...],\n            \"cloud_profiles/process.json\": [...],\n            \"settings/app_settings.json\": {...},\n        }\n        \"\"\"\n        files: dict[str, dict | list] = {}\n\n        # Metadata file (no timestamps - git tracks file history)\n        metadata = {\n            \"version\": \"1.0\",\n            \"backup_type\": \"bambuddy_profiles\",\n            \"contents\": {\n                \"kprofiles\": config.backup_kprofiles,\n                \"cloud_profiles\": config.backup_cloud_profiles,\n                \"settings\": config.backup_settings,\n                \"spools\": config.backup_spools,\n                \"archives\": config.backup_archives,\n            },\n        }\n        files[\"backup_metadata.json\"] = metadata\n\n        # Collect K-profiles from all connected printers\n        if config.backup_kprofiles:\n            self._backup_progress = \"Collecting K-profiles from printers...\"\n            await self._collect_kprofiles(db, files)\n\n        # Collect cloud profiles\n        if config.backup_cloud_profiles:\n            self._backup_progress = \"Collecting cloud profiles from Bambu Cloud...\"\n            await self._collect_cloud_profiles(db, files)\n\n        # Collect app settings\n        if config.backup_settings:\n            self._backup_progress = \"Collecting app settings...\"\n            await self._collect_settings(db, files)\n\n        # Collect spool inventory\n        if config.backup_spools:\n            self._backup_progress = \"Collecting spool inventory...\"\n            await self._collect_spools(db, files)\n\n        # Collect print archives\n        if config.backup_archives:\n            self._backup_progress = \"Collecting print archives...\"\n            await self._collect_archives(db, files)\n\n        return files\n\n    async def _collect_kprofiles(self, db: AsyncSession, files: dict):\n        \"\"\"Collect K-profiles from all connected printers.\"\"\"\n        result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712\n        printers = result.scalars().all()\n\n        nozzle_diameters = [\"0.2\", \"0.4\", \"0.6\", \"0.8\"]\n\n        for printer in printers:\n            client = printer_manager.get_client(printer.id)\n            if not client or not client.state.connected:\n                continue\n\n            serial = printer.serial_number\n            printer_profiles = {}\n\n            for nozzle in nozzle_diameters:\n                try:\n                    profiles = await client.get_kprofiles(nozzle_diameter=nozzle)\n                    if profiles:\n                        profile_data = {\n                            \"version\": \"1.0\",\n                            \"printer_name\": printer.name,\n                            \"printer_serial\": serial,\n                            \"nozzle_diameter\": nozzle,\n                            \"profiles\": [\n                                {\n                                    \"slot_id\": p.slot_id,\n                                    \"name\": p.name,\n                                    \"k_value\": p.k_value,\n                                    \"filament_id\": p.filament_id,\n                                    \"nozzle_id\": p.nozzle_id,\n                                    \"extruder_id\": p.extruder_id,\n                                    \"setting_id\": p.setting_id,\n                                    \"n_coef\": p.n_coef,\n                                }\n                                for p in profiles\n                            ],\n                        }\n                        files[f\"kprofiles/{serial}/{nozzle}.json\"] = profile_data\n                        printer_profiles[nozzle] = len(profiles)\n                except Exception as e:\n                    logger.warning(\"Failed to get K-profiles for printer %s nozzle %s: %s\", serial, nozzle, e)\n\n            if printer_profiles:\n                logger.info(\"Collected K-profiles for %s: %s\", serial, printer_profiles)\n\n    async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):\n        \"\"\"Collect Bambu Cloud profiles if authenticated.\"\"\"\n        # Backup runs without a user context, so fall back to the auth-disabled\n        # Settings storage. ``build_authenticated_cloud`` honours the stored\n        # region so China-region tokens are validated against api.bambulab.cn.\n        from backend.app.api.routes.cloud import build_authenticated_cloud\n\n        cloud = await build_authenticated_cloud(db, user=None)\n        if cloud is None or not cloud.is_authenticated:\n            if cloud is not None:\n                await cloud.close()\n            logger.info(\"Cloud not authenticated, skipping cloud profiles\")\n            return\n\n        try:\n            settings = await cloud.get_slicer_settings()\n            if not settings:\n                return\n\n            # Separate by type\n            filament_settings = []\n            printer_settings = []\n            process_settings = []\n\n            for setting in settings.get(\"setting\", []) if isinstance(settings.get(\"setting\"), list) else []:\n                setting_type = setting.get(\"type\", \"\")\n                if setting_type == \"filament\":\n                    filament_settings.append(setting)\n                elif setting_type == \"printer\":\n                    printer_settings.append(setting)\n                elif setting_type == \"process\":\n                    process_settings.append(setting)\n\n            if filament_settings:\n                files[\"cloud_profiles/filament.json\"] = {\n                    \"version\": \"1.0\",\n                    \"profiles\": filament_settings,\n                }\n\n            if printer_settings:\n                files[\"cloud_profiles/printer.json\"] = {\n                    \"version\": \"1.0\",\n                    \"profiles\": printer_settings,\n                }\n\n            if process_settings:\n                files[\"cloud_profiles/process.json\"] = {\n                    \"version\": \"1.0\",\n                    \"profiles\": process_settings,\n                }\n\n            logger.info(\n                f\"Collected cloud profiles: {len(filament_settings)} filament, \"\n                f\"{len(printer_settings)} printer, {len(process_settings)} process\"\n            )\n\n        except Exception as e:\n            logger.warning(\"Failed to collect cloud profiles: %s\", e)\n        finally:\n            await cloud.close()\n\n    async def _collect_settings(self, db: AsyncSession, files: dict):\n        \"\"\"Collect app settings.\"\"\"\n        result = await db.execute(select(Settings))\n        settings = result.scalars().all()\n\n        # Filter out sensitive settings\n        sensitive_keys = {\"bambu_cloud_token\", \"auth_secret_key\"}\n        settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}\n\n        files[\"settings/app_settings.json\"] = {\n            \"version\": \"1.0\",\n            \"settings\": settings_data,\n        }\n\n    async def _collect_spools(self, db: AsyncSession, files: dict):\n        \"\"\"Collect spool inventory data.\"\"\"\n        result = await db.execute(select(Spool))\n        spools = result.scalars().all()\n\n        if not spools:\n            return\n\n        spool_list = []\n        for s in spools:\n            spool_data = {\n                \"id\": s.id,\n                \"material\": s.material,\n                \"subtype\": s.subtype,\n                \"color_name\": s.color_name,\n                \"rgba\": s.rgba,\n                \"brand\": s.brand,\n                \"label_weight\": s.label_weight,\n                \"core_weight\": s.core_weight,\n                \"weight_used\": s.weight_used,\n                \"weight_locked\": s.weight_locked,\n                \"slicer_filament\": s.slicer_filament,\n                \"slicer_filament_name\": s.slicer_filament_name,\n                \"nozzle_temp_min\": s.nozzle_temp_min,\n                \"nozzle_temp_max\": s.nozzle_temp_max,\n                \"note\": s.note,\n                \"cost_per_kg\": s.cost_per_kg,\n                \"tag_uid\": s.tag_uid,\n                \"tray_uuid\": s.tray_uuid,\n                \"data_origin\": s.data_origin,\n                \"tag_type\": s.tag_type,\n                \"archived_at\": str(s.archived_at) if s.archived_at else None,\n                \"created_at\": str(s.created_at) if s.created_at else None,\n            }\n            spool_list.append(spool_data)\n\n        files[\"spools/inventory.json\"] = {\n            \"version\": \"1.0\",\n            \"spools\": spool_list,\n        }\n\n        # Collect usage history\n        usage_result = await db.execute(select(SpoolUsageHistory))\n        usages = usage_result.scalars().all()\n\n        if usages:\n            usage_list = []\n            for u in usages:\n                usage_list.append(\n                    {\n                        \"id\": u.id,\n                        \"spool_id\": u.spool_id,\n                        \"printer_id\": u.printer_id,\n                        \"print_name\": u.print_name,\n                        \"archive_id\": u.archive_id,\n                        \"weight_used\": u.weight_used,\n                        \"percent_used\": u.percent_used,\n                        \"status\": u.status,\n                        \"cost\": u.cost,\n                        \"created_at\": str(u.created_at) if u.created_at else None,\n                    }\n                )\n            files[\"spools/usage_history.json\"] = {\n                \"version\": \"1.0\",\n                \"usage_history\": usage_list,\n            }\n\n        logger.info(\"Collected %d spools and %d usage records\", len(spool_list), len(usages))\n\n    async def _collect_archives(self, db: AsyncSession, files: dict):\n        \"\"\"Collect print archive metadata (no binary files).\"\"\"\n        result = await db.execute(select(PrintArchive))\n        archives = result.scalars().all()\n\n        if not archives:\n            return\n\n        archive_list = []\n        for a in archives:\n            archive_data = {\n                \"id\": a.id,\n                \"printer_id\": a.printer_id,\n                \"project_id\": a.project_id,\n                \"filename\": a.filename,\n                \"file_size\": a.file_size,\n                \"content_hash\": a.content_hash,\n                \"print_name\": a.print_name,\n                \"print_time_seconds\": a.print_time_seconds,\n                \"filament_used_grams\": a.filament_used_grams,\n                \"filament_type\": a.filament_type,\n                \"filament_color\": a.filament_color,\n                \"layer_height\": a.layer_height,\n                \"total_layers\": a.total_layers,\n                \"nozzle_diameter\": a.nozzle_diameter,\n                \"bed_temperature\": a.bed_temperature,\n                \"nozzle_temperature\": a.nozzle_temperature,\n                \"sliced_for_model\": a.sliced_for_model,\n                \"status\": a.status,\n                \"started_at\": str(a.started_at) if a.started_at else None,\n                \"completed_at\": str(a.completed_at) if a.completed_at else None,\n                \"makerworld_url\": a.makerworld_url,\n                \"designer\": a.designer,\n                \"external_url\": a.external_url,\n                \"is_favorite\": a.is_favorite,\n                \"tags\": a.tags,\n                \"notes\": a.notes,\n                \"cost\": a.cost,\n                \"failure_reason\": a.failure_reason,\n                \"quantity\": a.quantity,\n                \"energy_kwh\": a.energy_kwh,\n                \"energy_cost\": a.energy_cost,\n                \"created_at\": str(a.created_at) if a.created_at else None,\n            }\n            archive_list.append(archive_data)\n\n        files[\"archives/print_history.json\"] = {\n            \"version\": \"1.0\",\n            \"archives\": archive_list,\n        }\n\n        logger.info(\"Collected %d print archives\", len(archive_list))\n\n    async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:\n        \"\"\"Push files to GitHub using the GitHub API.\n\n        Uses the Git Data API to create blobs, tree, and commit.\n\n        Returns:\n            dict with status, message, commit_sha, files_changed\n        \"\"\"\n        try:\n            owner, repo = self._parse_repo_url(config.repository_url)\n            branch = config.branch\n            client = await self._get_client()\n            headers = {\n                \"Authorization\": f\"token {config.access_token}\",\n                \"Accept\": \"application/vnd.github.v3+json\",\n                \"User-Agent\": \"Bambuddy-Backup\",\n            }\n\n            # Get current branch reference\n            ref_response = await client.get(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}\", headers=headers\n            )\n\n            if ref_response.status_code == 404:\n                # Branch doesn't exist, need to create it from default branch\n                return await self._create_branch_and_push(client, headers, owner, repo, branch, files)\n\n            if ref_response.status_code != 200:\n                return {\n                    \"status\": \"failed\",\n                    \"message\": f\"Failed to get branch ref: {ref_response.status_code}\",\n                    \"error\": ref_response.text,\n                }\n\n            ref_data = ref_response.json()\n            current_commit_sha = ref_data[\"object\"][\"sha\"]\n\n            # Get the current tree\n            commit_response = await client.get(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/commits/{current_commit_sha}\", headers=headers\n            )\n            if commit_response.status_code != 200:\n                return {\"status\": \"failed\", \"message\": \"Failed to get current commit\"}\n\n            current_tree_sha = commit_response.json()[\"tree\"][\"sha\"]\n\n            # Get existing files to check for changes\n            tree_response = await client.get(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1\", headers=headers\n            )\n            existing_files = {}\n            if tree_response.status_code == 200:\n                for item in tree_response.json().get(\"tree\", []):\n                    if item[\"type\"] == \"blob\":\n                        existing_files[item[\"path\"]] = item[\"sha\"]\n\n            # Create blobs for changed files\n            tree_items = []\n            files_changed = 0\n\n            for path, content in files.items():\n                content_str = json.dumps(content, indent=2, default=str)\n                content_bytes = content_str.encode(\"utf-8\")\n                content_sha = hashlib.sha1(\n                    f\"blob {len(content_bytes)}\\0\".encode() + content_bytes, usedforsecurity=False\n                ).hexdigest()\n\n                # Skip if file hasn't changed\n                if path in existing_files and existing_files[path] == content_sha:\n                    continue\n\n                # Create blob\n                blob_response = await client.post(\n                    f\"https://api.github.com/repos/{owner}/{repo}/git/blobs\",\n                    headers=headers,\n                    json={\"content\": base64.b64encode(content_bytes).decode(), \"encoding\": \"base64\"},\n                )\n\n                if blob_response.status_code != 201:\n                    logger.error(\"Failed to create blob for %s: %s\", path, blob_response.text)\n                    continue\n\n                blob_sha = blob_response.json()[\"sha\"]\n                tree_items.append({\"path\": path, \"mode\": \"100644\", \"type\": \"blob\", \"sha\": blob_sha})\n                files_changed += 1\n\n            if not tree_items:\n                return {\"status\": \"skipped\", \"message\": \"No changes to commit\", \"commit_sha\": None, \"files_changed\": 0}\n\n            # Create new tree\n            tree_response = await client.post(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/trees\",\n                headers=headers,\n                json={\"base_tree\": current_tree_sha, \"tree\": tree_items},\n            )\n\n            if tree_response.status_code != 201:\n                return {\"status\": \"failed\", \"message\": f\"Failed to create tree: {tree_response.text}\"}\n\n            new_tree_sha = tree_response.json()[\"sha\"]\n\n            # Create commit\n            commit_message = f\"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}\"\n            commit_response = await client.post(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/commits\",\n                headers=headers,\n                json={\"message\": commit_message, \"tree\": new_tree_sha, \"parents\": [current_commit_sha]},\n            )\n\n            if commit_response.status_code != 201:\n                return {\"status\": \"failed\", \"message\": f\"Failed to create commit: {commit_response.text}\"}\n\n            new_commit_sha = commit_response.json()[\"sha\"]\n\n            # Update branch reference\n            ref_update = await client.patch(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}\",\n                headers=headers,\n                json={\"sha\": new_commit_sha},\n            )\n\n            if ref_update.status_code != 200:\n                return {\"status\": \"failed\", \"message\": f\"Failed to update branch: {ref_update.text}\"}\n\n            return {\n                \"status\": \"success\",\n                \"message\": f\"Backup successful - {files_changed} files updated\",\n                \"commit_sha\": new_commit_sha,\n                \"files_changed\": files_changed,\n            }\n\n        except Exception as e:\n            logger.error(\"Push to GitHub failed: %s\", e)\n            return {\"status\": \"failed\", \"message\": str(e), \"error\": str(e)}\n\n    async def _create_branch_and_push(\n        self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict\n    ) -> dict:\n        \"\"\"Create a new branch and push files when branch doesn't exist.\"\"\"\n        try:\n            # Get default branch\n            repo_response = await client.get(f\"https://api.github.com/repos/{owner}/{repo}\", headers=headers)\n            if repo_response.status_code != 200:\n                return {\"status\": \"failed\", \"message\": \"Failed to get repo info\"}\n\n            default_branch = repo_response.json().get(\"default_branch\", \"main\")\n\n            # Get default branch ref\n            ref_response = await client.get(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{default_branch}\", headers=headers\n            )\n            if ref_response.status_code != 200:\n                # Empty repo - create initial commit\n                return await self._create_initial_commit(client, headers, owner, repo, branch, files)\n\n            base_sha = ref_response.json()[\"object\"][\"sha\"]\n\n            # Create new branch\n            create_ref = await client.post(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/refs\",\n                headers=headers,\n                json={\"ref\": f\"refs/heads/{branch}\", \"sha\": base_sha},\n            )\n\n            if create_ref.status_code != 201:\n                return {\"status\": \"failed\", \"message\": f\"Failed to create branch: {create_ref.text}\"}\n\n            # Now push to the new branch (recursive call will find the branch)\n            return await self._push_to_github(\n                type(\n                    \"Config\",\n                    (),\n                    {\n                        \"repository_url\": f\"https://github.com/{owner}/{repo}\",\n                        \"access_token\": headers[\"Authorization\"].replace(\"token \", \"\"),\n                        \"branch\": branch,\n                    },\n                )(),\n                files,\n            )\n\n        except Exception as e:\n            return {\"status\": \"failed\", \"message\": str(e)}\n\n    async def _create_initial_commit(\n        self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict\n    ) -> dict:\n        \"\"\"Create initial commit in an empty repository.\"\"\"\n        try:\n            # Create blobs\n            tree_items = []\n            for path, content in files.items():\n                content_str = json.dumps(content, indent=2, default=str)\n                blob_response = await client.post(\n                    f\"https://api.github.com/repos/{owner}/{repo}/git/blobs\",\n                    headers=headers,\n                    json={\"content\": base64.b64encode(content_str.encode()).decode(), \"encoding\": \"base64\"},\n                )\n                if blob_response.status_code == 201:\n                    tree_items.append(\n                        {\"path\": path, \"mode\": \"100644\", \"type\": \"blob\", \"sha\": blob_response.json()[\"sha\"]}\n                    )\n\n            # Create tree\n            tree_response = await client.post(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/trees\",\n                headers=headers,\n                json={\"tree\": tree_items},\n            )\n            if tree_response.status_code != 201:\n                return {\"status\": \"failed\", \"message\": \"Failed to create tree\"}\n\n            tree_sha = tree_response.json()[\"sha\"]\n\n            # Create commit (no parents for initial)\n            commit_response = await client.post(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/commits\",\n                headers=headers,\n                json={\n                    \"message\": f\"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}\",\n                    \"tree\": tree_sha,\n                },\n            )\n            if commit_response.status_code != 201:\n                return {\"status\": \"failed\", \"message\": \"Failed to create commit\"}\n\n            commit_sha = commit_response.json()[\"sha\"]\n\n            # Create branch ref\n            ref_response = await client.post(\n                f\"https://api.github.com/repos/{owner}/{repo}/git/refs\",\n                headers=headers,\n                json={\"ref\": f\"refs/heads/{branch}\", \"sha\": commit_sha},\n            )\n            if ref_response.status_code != 201:\n                return {\"status\": \"failed\", \"message\": \"Failed to create branch ref\"}\n\n            return {\n                \"status\": \"success\",\n                \"message\": f\"Initial backup created - {len(files)} files\",\n                \"commit_sha\": commit_sha,\n                \"files_changed\": len(files),\n            }\n\n        except Exception as e:\n            return {\"status\": \"failed\", \"message\": str(e)}\n\n    @property\n    def is_running(self) -> bool:\n        \"\"\"Check if a backup is currently running.\"\"\"\n        return self._running_backup\n\n    @property\n    def progress(self) -> str | None:\n        \"\"\"Get current backup progress message.\"\"\"\n        return self._backup_progress\n\n    async def get_logs(self, config_id: int, limit: int = 50, offset: int = 0) -> list[GitHubBackupLog]:\n        \"\"\"Get backup logs for a configuration.\"\"\"\n        async with async_session() as db:\n            result = await db.execute(\n                select(GitHubBackupLog)\n                .where(GitHubBackupLog.config_id == config_id)\n                .order_by(desc(GitHubBackupLog.started_at))\n                .offset(offset)\n                .limit(limit)\n            )\n            return list(result.scalars().all())\n\n\n# Singleton instance\ngithub_backup_service = GitHubBackupService()\n"
  },
  {
    "path": "backend/app/services/hms_errors.py",
    "content": "\"\"\"HMS Error Code Descriptions.\n\nAuto-generated from frontend/src/components/HMSErrorModal.tsx\nSource: https://github.com/greghesp/ha-bambulab\n\"\"\"\n\n# HMS error code to human-readable description mapping\n# Format: \"XXXX_YYYY\" where XXXX is module code, YYYY is error code\nHMS_ERROR_DESCRIPTIONS: dict[str, str] = {\n    \"0300_4000\": \"Z axis homing failed; the task has been stopped.\",\n    \"0300_4001\": \"The printer timed out waiting for the nozzle to cool down before homing.\",\n    \"0300_4002\": \"Auto Bed Leveling failed; the task has been stopped.\",\n    \"0300_4005\": \"The hotend cooling fan speed is abnormal.\",\n    \"0300_4006\": \"The nozzle is clogged.\",\n    \"0300_4008\": \"The AMS failed to change filament.\",\n    \"0300_4009\": \"Homing XY axis failed.\",\n    \"0300_400A\": \"Mechanical resonance frequency identification failed.\",\n    \"0300_400B\": \"Internal communication exception\",\n    \"0300_400C\": \"The task was canceled.\",\n    \"0300_400D\": \"Resume failed after power loss.\",\n    \"0300_400E\": \"The motor self-check failed.\",\n    \"0300_400F\": \"The power supply voltage does not match the printer.\",\n    \"0300_4010\": \"Nozzle offset calibration failed.\",\n    \"0300_4011\": \"Flow Dynamics Calibration failed; please reinitiate printing or calibration.\",\n    \"0300_4013\": \"Printing cannot be initiated while AMS is drying.\",\n    \"0300_4014\": \"Homing Z axis failed: temperature control abnormality.\",\n    \"0300_4015\": \"Nozzle clumping detection calibration failed. Please go to 'Assistant' for troubleshooting.\",\n    \"0300_4016\": \"Nozzle cleaning failed. Please click the Assistant for troubleshooting.\",\n    \"0300_401F\": \"The hotend is not installed, and the toolhead cannot perform homing. Please install the hotend and then continue.\",\n    \"0300_4020\": \"The nozzle presence detection failed. Please check the Assistant for details.\",\n    \"0300_4021\": \"Nozzle offset calibration sensor signal abnormality detected. Please check the sensor and retry.\",\n    \"0300_4042\": \"The Laser Safety Window is not properly installed. The task has been stopped.\",\n    \"0300_4044\": \"The Flame Sensor is abnormal. The sensor may be short-circuited. Please troubleshoot the issue before starting a print job.\",\n    \"0300_404B\": \"Task aborted because the front door or top cover is open.\",\n    \"0300_404D\": \"The current temperature of the hotend, heatbed, or chamber is too high. Please wait for it to cool down to room temperature before restarting the task.\",\n    \"0300_4050\": \"Liveview Camera calibration timeout; please restart the printer.\",\n    \"0300_4052\": \"Blade Z-axis homing failed\",\n    \"0300_4057\": \"Z-axis step loss detected. The task has stopped. Please check if there are any obstructions beneath the heatbed.\",\n    \"0300_4066\": \"Calibration of motion precision failed.\",\n    \"0300_4067\": \"Calibration result is over the threshold.\",\n    \"0300_4068\": \"Step loss occurred during the motion accuracy enhancement process. Please try again.\",\n    \"0300_8000\": \"Printing was paused for unknown reason. You can select 'Resume' to resume the print job.\",\n    \"0300_8001\": \"Printing was paused by the user. You can select 'Resume' to continue printing.\",\n    \"0300_8002\": \"First layer defects were detected by the Micro Lidar. Please check the quality of the printed model before continuing your print.\",\n    \"0300_8003\": \"Spaghetti defects were detected by the AI Print Monitoring. Please check the quality of the printed model before continuing your print.\",\n    \"0300_8004\": \"Filament ran out. Please load new filament.\",\n    \"0300_8005\": \"Toolhead front cover fell off. Please remount the front cover and check to make sure your print is going okay.\",\n    \"0300_8006\": \"The build plate marker was not detected. Please confirm the build plate is correctly positioned on the heatbed with all four corners aligned, and the marker is visible.\",\n    \"0300_8007\": \"There was an unfinished print job when the printer lost power. If the model is still adhered to the build plate, you can try resuming the print job.\",\n    \"0300_8008\": \"Nozzle temperature malfunction\",\n    \"0300_8009\": \"Heatbed temperature malfunction\",\n    \"0300_800A\": \"A Filament pile-up was detected by AI Print Monitoring. Please clean filament from the waste chute.\",\n    \"0300_800B\": \"The cutter is stuck. Please make sure the cutter handle is out and check the filament sensor cable connection.\",\n    \"0300_800C\": \"Skipped step detected: auto-recover complete; please resume print and check if there are any layer shift problems.\",\n    \"0300_800D\": \"Detected that the extruder is not extruding normally. If the defects are acceptable, select 'Resume' to resume the print job.\",\n    \"0300_800E\": \"The print file is not available. Please check to see if the storage media has been removed.\",\n    \"0300_800F\": \"The door seems to be open, so printing was paused.\",\n    \"0300_8010\": \"The hotend cooling fan speed is abnormal.\",\n    \"0300_8011\": \"Detected build plate is not the same as the Gcode file. Please adjust slicer settings or use the correct plate.\",\n    \"0300_8013\": \"Printing paused due to the pause command added to the printing file.\",\n    \"0300_8014\": \"The nozzle is covered with filament, or the build plate is installed incorrectly. Please cancel this print and clean the nozzle or adjust the build plate according to the actual status. You can als...\",\n    \"0300_8015\": \"The filament on external spool has run out; please load new filament. If the filament is loaded, please select 'Resume'.\",\n    \"0300_8016\": \"The nozzle is clogged with filament. Please cancel this print and clean the nozzle or select 'Resume' to resume the print job.\",\n    \"0300_8017\": \"Foreign objects detected on heatbed. Please check and clean the heatbed. Then, select 'Resume' to resume the print job.\",\n    \"0300_8018\": \"Chamber temperature malfunction.\",\n    \"0300_8019\": \"No build plate is placed.\",\n    \"0300_801A\": \"Filament extrusion error; please check the assistant for troubleshooting. After resolving the issue, decide whether to cancel or resume the print job based on the actual print status.\",\n    \"0300_801B\": \"Nozzle temperature problem detected. Refer to Assistant to re-connect the hotend connector. POWER OFF the printer before this operation to avoid short circuits.\",\n    \"0300_801C\": \"The extrusion resistance is abnormal. The extruder may be clogged; please refer to the assistant. After trouble shooting, you can select 'Resume' to resume the print job.\",\n    \"0300_801D\": \"The extruder servo motor position sensor is malfunctioning. Please power off the printer first and check if the connection cable is loose.\",\n    \"0300_801E\": \"The extrusion motor is overloaded, please check the Assistant for details.\",\n    \"0300_8021\": \"The nozzle may not be installed or not properly installed. Please ensure the nozzle is correctly installed before proceeding.\",\n    \"0300_8022\": \"The heatbed may be obstructed while moving downward. Please clear any objects beneath the heatbed and check for any resistance or jamming during its movement.\",\n    \"0300_8028\": \"Nozzle offset calibration sensor error. If using a single hotend or the calibration function is disabled, you may ignore this and continue printing; otherwise, it is recommended to check the sensor...\",\n    \"0300_8041\": \"Platform detection timeout: please restart the printer.\",\n    \"0300_8042\": \"Task paused because the door is open.\",\n    \"0300_8043\": \"The laser module is abnormal.\",\n    \"0300_8044\": \"Fire was detected inside the chamber.\",\n    \"0300_8045\": \"Material detection timeout: please restart the printer.\",\n    \"0300_8046\": \"Foreign object detect timeout: please restart the printer.\",\n    \"0300_8047\": \"Quick-release lever detection time out: please restart the printer.\",\n    \"0300_8048\": \"Laser Module unlock has timed out, and the task cannot proceed. Please restart the printer and try again.\",\n    \"0300_8049\": \"The current plate is invalid.\",\n    \"0300_804A\": \"Emergency stop button improperly installed. Please reinstall according to the Wiki before proceeding.\",\n    \"0300_804B\": \"Task paused. The Laser Safety Window is open.\",\n    \"0300_804E\": \"This is a printing task. Please detach the Laser/Cutting Module from the Toolhead.\",\n    \"0300_804F\": \"The loading/unloading process is currently ongoing. Please stop the process or remove the laser/cutting module.\",\n    \"0300_8050\": \"This device does not support the 40W Laser Module. Please remove it or replace it with a 10W Laser Module.\",\n    \"0300_8051\": \"The cutting module has dropped or the cutting module cable is disconnected; please check the module.\",\n    \"0300_8053\": \"Laser module detected. Please install the right nozzle correctly to ensure proper Laser Module Mounting Calibration.\",\n    \"0300_8054\": \"Please place the paper required for Print Then Cut.\",\n    \"0300_8055\": \"The module mounted on the toolhead does not match the task. Please install the correct module.\",\n    \"0300_8057\": \"The rotary attachment is disconnected. Please ensure it is properly installed and the cable is securely plugged in.\",\n    \"0300_8058\": \"The rotary attachment is detected. Please remove it before continuing.\",\n    \"0300_8061\": \"The mode of Airflow System failed to activate; check the air door condition.\",\n    \"0300_8062\": \"The chamber temperature is too high. It may be due to high environmental temperature.\",\n    \"0300_8063\": \"The chamber temperature is too high. Please open the top cover and front door to cool down.\",\n    \"0300_8064\": \"The chamber temperature is too high. Please open the top cover and front door to cool down. (Open door detection for this print job will be set to 'Notification' level)\",\n    \"0300_8065\": \"The temperature of the MC module is too high. Please check the Wiki for possible explanations.\",\n    \"0300_8071\": \"The Toolhead Enhanced Cooling Fan module is malfunctioning.\",\n    \"0300_807D\": \"Fire Extinguisher not detected, the automatic extinguishing function will be unavailable.\",\n    \"0300_807E\": \"Fire Extinguisher not detected, the automatic extinguishing function will be unavailable.\",\n    \"0300_807F\": \"Fire Extinguisher is malfunctioning.\",\n    \"0300_8080\": \"Fire extinguisher motor reset failed.\",\n    \"0300_8081\": \"Fire extinguisher cylinder not installed. Please confirm on the extinguisher page.\",\n    \"0300_8082\": \"The Fire Extinguisher Gas Cylinder is empty.\",\n    \"0300_C012\": \"Please heat the nozzle to above 170°C.\",\n    \"0300_C056\": \"A minor fire was detected inside the chamber, and the Auto Fire Extinguishing process has been aborted.\",\n    \"0300_C070\": \"The fire extinguisher has been detected and is ready for use after the laser module is connected.\",\n    \"0500_4001\": \"Failed to connect to Bambu Cloud. Please check your network connection.\",\n    \"0500_4002\": \"Unsupported print file path or name. Please resend the print job.\",\n    \"0500_4003\": \"Printing stopped because the printer was unable to parse the file. Please resend your print job.\",\n    \"0500_4004\": \"Device is busy and cannot start new task. Please wait for current task to complete before sending new task.\",\n    \"0500_4005\": \"Print jobs are not allowed to be sent while updating firmware.\",\n    \"0500_4006\": \"There is not enough free storage space for the print job. Restoring to factory settings can free up available space.\",\n    \"0500_4007\": \"The device requires a repair upgrade, and printing is currently unavailable.\",\n    \"0500_4008\": \"Starting printing failed; please power cycle the printer and resend the print job.\",\n    \"0500_4009\": \"Print jobs are not allowed to be sent while updating logs.\",\n    \"0500_400A\": \"The file name is not supported. Please rename and restart the print job.\",\n    \"0500_400B\": \"There was a problem downloading a file. Please check your network connection and resend the print job.\",\n    \"0500_400C\": \"Please insert a MicroSD card and restart the print job.\",\n    \"0500_400D\": \"Please run a self-test and restart the print job.\",\n    \"0500_400E\": \"Printing was cancelled.\",\n    \"0500_400F\": \"AMS is initializing and cannot be upgraded at the moment. Please try again later.\",\n    \"0500_4010\": \"AMS is drying and cannot be upgraded at the moment. Please try again later.\",\n    \"0500_4011\": \"The printer is loading or unloading filament and cannot be upgraded at the moment. Please try again later.\",\n    \"0500_4012\": \"The device is printing and cannot be upgraded at the moment. Please try again later.\",\n    \"0500_4013\": \"AMS is in operation and cannot be upgraded at the moment. Please try again when it is idle.\",\n    \"0500_4014\": \"Slicing for the print job failed. Please check your settings and restart the print job.\",\n    \"0500_4015\": \"There is not enough free storage space for the print job. Please format or clear files from the MicroSD card to free up space.\",\n    \"0500_4016\": \"The MicroSD Card is write-protected. Please replace the MicroSD Card.\",\n    \"0500_4017\": \"Binding failed. Please retry or restart the printer and retry.\",\n    \"0500_4018\": \"Binding configuration information parsing failed; please try again.\",\n    \"0500_4019\": \"The printer has already been bound. Please unbind it and try again.\",\n    \"0500_401A\": \"Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...\",\n    \"0500_401B\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_401C\": \"Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_401D\": \"Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\n    \"0500_401E\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_401F\": \"Authorization timed out. Please make sure that your phone or PC has access to the internet, and ensure that the Bambu Studio/Bambu Handy APP is running in the foreground during the binding operation.\",\n    \"0500_4020\": \"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_4021\": \"Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\n    \"0500_4022\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_4023\": \"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_4024\": \"Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...\",\n    \"0500_4025\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_4026\": \"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_4027\": \"Cloud access failed; this may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\n    \"0500_4028\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_4029\": \"Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0500_402A\": \"Failed to connect to the router, which may be caused by wireless interference or being too far away from the router. Please try again or move the printer closer to the router and try again.\",\n    \"0500_402B\": \"Router connection failed due to incorrect password. Please check the password and try again.\",\n    \"0500_402C\": \"Failed to obtain IP address, which may be caused by wireless interference resulting in data transmission failure or the DHCP address pool of the router being full. Please move the printer closer to...\",\n    \"0500_402D\": \"System exception\",\n    \"0500_402E\": \"System does not support the file system currently used by the USB flash drive. Please replace or format the USB flash drive to FAT32.\",\n    \"0500_402F\": \"The MicroSD card sector data is damaged. Please use the SD card repair tool to repair or format it. If it still cannot be identified, please replace the MicroSD card.\",\n    \"0500_4030\": \"The device is currently upgrading. Please try again when it is idle.\",\n    \"0500_4031\": \"The accessory firmware does not match the printer. Please update it on the 'Firmware' page.\",\n    \"0500_4033\": \"The AMS firmware does not match the printer. Please update it on the 'Firmware' page.\",\n    \"0500_4034\": \"The Laser Module firmware does not match the printer. Please update it on the 'Firmware' page.\",\n    \"0500_4035\": \"The BirdsEye Camera is malfunctioning. Please try restarting the device. If the issue persists after multiple restarts, check the camera connection status or contact customer support.\",\n    \"0500_4037\": \"Your sliced file is not compatible with current printer model. This file can't be printed on this printer.\",\n    \"0500_4038\": \"The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can't be printed.\",\n    \"0500_4039\": \"The current task does not allow the installation of the laser/cutting module, and the task has been halted.\",\n    \"0500_403A\": \"The current temperature is too low. In order to protect you and your printer, printing tasks, moving an axis and other operations are disabled. Please move the printer to an environment above 10 de...\",\n    \"0500_403B\": \"Laser/cutting tasks cannot be initiated on the machine at the moment. Please use the computer software to start the task.\",\n    \"0500_403C\": \"The current nozzle setting does not match the slicing file. Continuing to print may affect print quality. It is recommended to re-slice before starting the print.\",\n    \"0500_403D\": \"The toolhead module is not set up. Please set it up before initiating the task.\",\n    \"0500_403E\": \"The current tool head does not support initialization.\",\n    \"0500_403F\": \"Failed to download print job; please check your network connection.\",\n    \"0500_4040\": \"The printer has reached its power limit. Please connect a dedicated power adapter to this AMS to enable drying.\",\n    \"0500_4041\": \"The AMS drying cannot be started during printing.\",\n    \"0500_4042\": \"Due to power limitations, starting AMS drying will pause current operations such as nozzle heating and fan running. Do you want to proceed with drying?\",\n    \"0500_4043\": \"Due to power limitations, only one AMS is allowed to use the device's power for drying.\",\n    \"0500_4044\": \"BirdsEye Camera malfunction: please contact customer support.\",\n    \"0500_4045\": \"Hotend check in progress. This operation is temporarily unavailable. Please wait.\",\n    \"0500_4050\": \"Error detected on the print board.\",\n    \"0500_4052\": \"Error detected on the hot end.\",\n    \"0500_4054\": \"Error detected on the mat.\",\n    \"0500_405D\": \"Laser module Serial Number error: unable to calibrate or make project.\",\n    \"0500_4065\": \"The task requires a Laser Platform, but the current one is a Cutting Platform. Please replace it, measure the material thickness in the software, and then restart the task.\",\n    \"0500_4070\": \"The laser or cutter module is connected, so the device cannot initiate a 3D printing task.\",\n    \"0500_4075\": \"No Laser Platform was detected, which may affect thickness measurement accuracy. Please place the laser platform correctly and ensure the rear markers are not blocked, then restart the thickness me...\",\n    \"0500_4076\": \"Please place the Laser Platform correctly and ensure the rear markers are not blocked, then restart the thickness measurement in the software before initiating the task.\",\n    \"0500_4097\": \"The device cannot detect the Laser Module. Please reconnect the module cable or restart the printer.\",\n    \"0500_4098\": \"The device cannot detect AMS A. Please reconnect the AMS cable or restart the printer.\",\n    \"0500_4099\": \"The firmware of Cutting Module does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0500_409A\": \"The firmware of the Air Pump does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0500_409B\": \"The firmware of the Laser Module does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0500_409D\": \"The firmware of AMS A does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.\",\n    \"0500_409E\": \"The device cannot detect the Cutting Module. Please reconnect the module cable or restart the printer.\",\n    \"0500_409F\": \"The device cannot detect the Air Pump.  Please reconnect the module cable or restart the printer.\",\n    \"0500_40A0\": \"The Rotary Attachment module is not detected. Please reconnect the cable or restart the printer.\",\n    \"0500_40A1\": \"The Auto Fire Extinguishing System is not detected.  Please reconnect the module cable or restart the printer.\",\n    \"0500_40A3\": \"AMS(or AMS lite) A communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0500_40A4\": \"The current firmware only supports 1 AMS Lite. Please remove all AMS units before reconnecting the supported AMS Lite device.\",\n    \"0500_40A5\": \"The current firmware only supports AMS/AMS 2 Pro/AMS HT, with a maximum of 4 units. Please remove all AMS units before reconnecting the supported one.\",\n    \"0500_8013\": \"The print file is not available. Please check to see if the storage media has been removed.\",\n    \"0500_8036\": \"Your sliced file is not consistent with the current printer model. Continue?\",\n    \"0500_803C\": \"The current nozzle setting does not match the slicing file. Continuing to print may affect print quality. It is recommended to re-slice before starting the print.\",\n    \"0500_8040\": \"Toolhead front cover is detached. Moving the toolhead may damage the printer. Do you want to continue?\",\n    \"0500_8041\": \"The filament in hotend is too cold. Extrusion may damage the extruder. Still feeding in/out the filament?\",\n    \"0500_8048\": \"The module on the toolhead is not calibrated. Please cancel the task to perform calibration or switch to a calibrated module.\",\n    \"0500_8051\": \"Detected build plate is not the same as the Gcode file. Please adjust slicer settings or use the correct plate.\",\n    \"0500_8053\": \"Nozzle mismatch was detected during printing. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.\",\n    \"0500_8055\": \"Laser module is installed, but a Cutting Platform is detected. Please place a Laser Platform and perform laser calibration.\",\n    \"0500_8056\": \"Cutting module is installed, but the laser platform is detected. Please place the cutting platform for calibration.\",\n    \"0500_8058\": \"Please place the light grip cutting mat correctly and ensure the marker is exposed.\",\n    \"0500_8059\": \"Cutting platform base is not correctly aligned. Please ensure that the four corners of the platform are aligned with the heatbed.\",\n    \"0500_805A\": \"Please place the cutting mat on cutting protection base.\",\n    \"0500_805B\": \"The cutting mat type is unknown; please replace it with the correct cutting mat.\",\n    \"0500_805C\": \"The grip cutting mat type does not match; please place a LightGrip cutting mat.\",\n    \"0500_805E\": \"Cutting module Serial Number error: unable to calibrate or make project.\",\n    \"0500_8060\": \"The current module on toolhead does not meet requirements. Please replace the module as per the on-screen instructions.\",\n    \"0500_8061\": \"No print plate detected. Please make sure it is placed correctly.\",\n    \"0500_8062\": \"The print plate marker was not detected. Please confirm the print plate is correctly positioned on the heatbed with all four corners aligned, and the marker is visible. If strong light is shining o...\",\n    \"0500_8063\": \"The platform is not detected during calibration; please make sure the Laser Platform is properly placed.\",\n    \"0500_8064\": \"Please place the Laser Platform correctly and ensure the rear markers are not blocked for laser calibration.\",\n    \"0500_8066\": \"The task requires a Cutting Platform, but the current one is a Laser Platform. Please replace it with a Cutting Platform (Cutting Protection Base + LightGrip cutting mat).\",\n    \"0500_8067\": \"Please place a LightGrip cutting mat on the cutting protection base.\",\n    \"0500_8068\": \"Please place the strong grip cutting mat correctly and ensure the marker is exposed.\",\n    \"0500_8069\": \"Unable to recognize the left and right hotends. They might be third party hotends, or the hotend marks may be dirty. Please manually set the hotend types.\",\n    \"0500_806A\": \"Unable to recognize the left and right hotends. They might be third party hotends, or the hotend marks may be dirty. Please set hotend types on printer screen before next print.\",\n    \"0500_806B\": \"Quick-release Lever is not locked. Please press down the external toolhead module to ensure it is properly seated, then push down the level to lock it in place.\",\n    \"0500_806C\": \"Please place the cutting platform correctly and ensure the marker is exposed.\",\n    \"0500_806D\": \"Material not detected. Please confirm placement and continue.\",\n    \"0500_806E\": \"Foreign objects detected on heatbed; please check and clean up the heatbed.\",\n    \"0500_806F\": \"The grip cutting mat type does not match; please place a StrongGrip cutting mat.\",\n    \"0500_8071\": \"No cutting platform was detected. Please confirm that it has been correctly placed.\",\n    \"0500_8072\": \"Live View camera is blocked\",\n    \"0500_8073\": \"Heatbed limit block is obstructed or contaminated. Please clean and ensure the limit block is visible, otherwise platform position offset detection may be inaccurate.\",\n    \"0500_8074\": \"The Laser Platform is offset. Please ensure that the four corners of the platform are aligned with the heatbed, and the marker is not obstructed.\",\n    \"0500_8077\": \"The visual marker was not detected. Please ensure the paper is properly placed.\",\n    \"0500_8078\": \"Current material does not match the sliced file settings. Please load the correct material and ensure the QR code on the material is not damaged or dirty.\",\n    \"0500_8079\": \"Please place the Laser Test Material (350g paperboard) and position support strips underneath to prevent material warping.\",\n    \"0500_807A\": \"The foreign object detection function is not working. You can continue the task or check the assistant for troubleshooting.\",\n    \"0500_807B\": \"Please place the cutting platform (cutting protection base + LightGrip cutting mat).\",\n    \"0500_807C\": \"Please place the cutting platform (cutting protection base + StrongGrip cutting mat).\",\n    \"0500_807D\": \"This task requires a Cutting Platform, but the current one is a Laser Platform. Please replace it with a Cutting Platform (Cutting Protection Base + StrongGrip Cutting Mat).\",\n    \"0500_807E\": \"Please place a StrongGrip cutting mat on the cutting protection base.\",\n    \"0500_8080\": \"The left and right hotends are not installed.\",\n    \"0500_8081\": \"The left and right hotends are not installed.\",\n    \"0500_8082\": \"Please remove the protective film on the Opaque Glossy Acrylic before processing\",\n    \"0500_8083\": \"Material is not allowed in Mounting Calibration. Please remove the material from the platform.\",\n    \"0500_8084\": \"The Live View Camera is dirty; please clean it and continue.\",\n    \"0500_8085\": \"Toolhead camera is obstructed\",\n    \"0500_8086\": \"Toolhead Camera is dirty, which affects the AI function; please clean the lens surface.\",\n    \"0500_8087\": \"BirdsEye camera is obstructed\",\n    \"0500_8088\": \"The Birdseye Camera is dirty\",\n    \"0500_8089\": \"Task paused due to Presence Check failed. Please check the printer to continue.\",\n    \"0500_808A\": \"The BirdsEye Camera is installed offset. Please refer to the assistant to reinstall it.\",\n    \"0500_808B\": \"The BirdsEye Camera setup failed. Please remove all objects and the mat on the heatbed to ensure the heatbed markers are visible. Meanwhile, please ensure the BirdsEye Camera is installed correctly...\",\n    \"0500_808C\": \"Detected build plate offset. Please align the build plate with the heatbed, and then continue.\",\n    \"0500_808D\": \"The Cutting Module offset calibration failed, which may result in inaccurate cuts. Please ensure the cutting material is properly positioned and check whether the cutting blade tip is worn.\",\n    \"0500_808E\": \"BirdsEye Camera initialization failed. The toolhead camera did not detect the Heatbed features. Please clean the Heatbed, remove all objects and pads, and ensure the bed markings are visible. Check...\",\n    \"0500_808F\": \"Nozzle camera lens is dirty, affecting AI monitoring. Clean the lens with a non-woven cloth and a small amount of alcohol. Beware of hotend heat; wait for it to cool before handling.\",\n    \"0500_8090\": \"Please attach the 80g White Printing Paper to the center area of the platform.\",\n    \"0500_8091\": \"The Cutting Module offset calibration failed, which may result in inaccurate cuts. Please ensure the 80g white printer paper(letter paper thickness) is properly positioned and check whether the cut...\",\n    \"0500_8092\": \"Toolhead Camera initialization failed. This print can still continue, but some AI functions will be disabled. If you encounter this issue again after restarting, please contact customer support.\",\n    \"0500_8093\": \"The nozzle silicone sleeve is not installed; there is a risk of temperature control failure. Please install it correctly and try again.\",\n    \"0500_80A0\": \"The visual encoder board was not detected. Please check if the board is properly placed and aligned at all four corners, and ensure the positioning markings are clear and free from wear.\",\n    \"0500_C010\": \"MicroSD Card read/write exception: please reinsert or replace the MicroSD Card.\",\n    \"0500_C032\": \"Laser/Cutting module connected to the toolhead. The drying process has been automatically stopped.\",\n    \"0500_C036\": \"This is a printing task. Please detach the Laser/Cutting Module from the Toolhead.\",\n    \"0500_C07F\": \"Device is busy and cannot perform this operation. To proceed, please pause or stop the current task.\",\n    \"0501_4017\": \"Binding failed. Please retry or restart the printer and retry.\",\n    \"0501_4018\": \"Binding configuration information parsing failed; please try again.\",\n    \"0501_4019\": \"The printer has already been bound. Please unbind it and try again.\",\n    \"0501_401A\": \"Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...\",\n    \"0501_401B\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_401C\": \"Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_401D\": \"Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\n    \"0501_401E\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_401F\": \"Authorization timed out. Please make sure that your phone or PC has access to the internet, and ensure that the Bambu Studio/Bambu Handy APP is running in the foreground during the binding operation.\",\n    \"0501_4020\": \"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_4021\": \"Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\n    \"0501_4022\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_4023\": \"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_4024\": \"Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...\",\n    \"0501_4025\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_4026\": \"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_4027\": \"Cloud access failed; this may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\n    \"0501_4028\": \"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_4029\": \"Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.\",\n    \"0501_4031\": \"Device discovery binding is in progress, and the QR code cannot be displayed on the screen. You can wait for the binding to finish or abort the device discovery binding process in the APP/Studio an...\",\n    \"0501_4032\": \"QR code binding is in progress, so device discovery binding cannot be performed. You can scan the QR code on the screen for binding or exit the QR code display page on screen and try device discove...\",\n    \"0501_4033\": \"Your APP region does not match with your printer; please download the APP in the corresponding region and register your account again.\",\n    \"0501_4034\": \"The slicing progress has not been updated for a long time, and the printing task has exited. Please confirm the parameters and reinitiate printing.\",\n    \"0501_4035\": \"The device is in the process of binding and cannot respond to new binding requests.\",\n    \"0501_4038\": \"The regional settings do not match the printer; please check the printer's regional settings.\",\n    \"0501_4039\": \"Device login has expired; please try to bind again.\",\n    \"0501_4098\": \"The device cannot detect AMS B. Please reconnect the AMS cable or restart the printer.\",\n    \"0501_409D\": \"The firmware of AMS B does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0501_40A3\": \"AMS(or AMS lite) B communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0502_4001\": \"Current filament will be used in this print job. Settings cannot be changed.\",\n    \"0502_4002\": \"Please go to “Settings > Calibration” to run the Motion Accuracy Enhancement Calibration before turning on Motion Accuracy Enhancement mode.\",\n    \"0502_4003\": \"The printer is currently printing and the motion accuracy enhancement feature cannot be turned on or off.\",\n    \"0502_4004\": \"Some features are not supported by the current device. Please check the Studio feature settings or update the firmware to the latest version.\",\n    \"0502_4005\": \"The AMS has not been calibrated yet, so printing cannot be initiated.\",\n    \"0502_4006\": \"Unknown module detected; please try updating the firmware to the latest version.\",\n    \"0502_400D\": \"Failed to start a new task: filament loading/unloading not completed.\",\n    \"0502_400E\": \"Failed to start a new task: The nozzle cold pull was not completed.\",\n    \"0502_4013\": \"This device is not compatible with the 40W laser module. Please replace it with a 10W laser module or remove it.\",\n    \"0502_4098\": \"The device cannot detect AMS C. Please reconnect the AMS cable or restart the printer.\",\n    \"0502_409D\": \"The firmware of AMS C does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.\",\n    \"0502_40A3\": \"AMS(or AMS lite) C communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0502_C00F\": \"The device is busy and cannot perform nozzle identification.\",\n    \"0502_C010\": \"Due to printer power limitations, printing, calibration, controls and other actions cannot be performed during AMS drying. Please stop the drying process before proceeding with any other operation.\",\n    \"0502_C011\": \"Currently in 2D production mode. Please continue the operation on the printer\",\n    \"0502_C012\": \"The task cannot be paused.\",\n    \"0502_C014\": \"The AMS Remaining Filament Estimation is enabled by default and cannot be disabled.\",\n    \"0502_C024\": \"The flow dynamic calibration records have exceeded the storage limit. Please delete some historical records in the slicer software before adding new calibration data.\",\n    \"0503_4098\": \"The device cannot detect AMS D. Please reconnect the AMS cable or restart the printer.\",\n    \"0503_409D\": \"The firmware of AMS D does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0503_40A3\": \"AMS(or AMS lite) D communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0580_4096\": \"The device cannot detect AMS-HT A. Please reconnect the AMS-HT cable or restart the printer.\",\n    \"0580_409C\": \"The firmware of AMS-HT A does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0580_40A2\": \"AMS-HT A communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0581_4096\": \"The device cannot detect AMS-HT B. Please reconnect the AMS-HT cable or restart the printer.\",\n    \"0581_409C\": \"The firmware of AMS-HT B does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0581_40A2\": \"AMS-HT B communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0582_4096\": \"The device cannot detect AMS-HT C. Please reconnect the AMS-HT cable or restart the printer.\",\n    \"0582_409C\": \"The firmware of AMS-HT C does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0582_40A2\": \"AMS-HT C communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0583_4096\": \"The device cannot detect AMS-HT D. Please reconnect the AMS-HT cable or restart the printer.\",\n    \"0583_409C\": \"The firmware of AMS-HT D does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0583_40A2\": \"AMS-HT D communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0584_4096\": \"The device cannot detect AMS-HT F. Please reconnect the AMS-HT cable or restart the printer.\",\n    \"0584_409C\": \"The firmware of AMS-HT E does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0584_40A2\": \"AMS-HT E communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0585_4096\": \"The device cannot detect AMS-HT E. Please reconnect the AMS-HT cable or restart the printer.\",\n    \"0585_409C\": \"The firmware of AMS-HT F does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0585_40A2\": \"AMS-HT F communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0586_4096\": \"The device cannot detect AMS-HT G. Please reconnect the AMS-HT cable or restart the printer.\",\n    \"0586_409C\": \"The firmware of AMS-HT G does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\n    \"0586_40A2\": \"AMS-HT G communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"0587_4096\": \"The device cannot detect AMS-HT H. Please reconnect the AMS-HT cable or restart the printer.\",\n    \"0587_409C\": \"The firmware of AMS-HT H does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.\",\n    \"0587_40A2\": \"AMS-HT H communication is abnormal. Please reconnect the module cable or restart the printer.\",\n    \"05FE_8053\": \"The left nozzle is not matched with slicing file. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.\",\n    \"05FE_8069\": \"Unable to recognize the left hotend. It might be a third party hotend, or the hotend mark may be dirty. Please manually set the hotend type.\",\n    \"05FE_806A\": \"Unable to recognize the left hotend. It might be a third party hotend, or the hotend mark may be dirty. Please set hotend type on printer screen before next print.\",\n    \"05FE_8080\": \"The left hotend is not installed.\",\n    \"05FE_8081\": \"The left hotend is not installed.\",\n    \"05FF_8053\": \"The right nozzle is not matched with slicing file. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.\",\n    \"05FF_8069\": \"Unable to recognize the right hotend. It might be a third party hotend, or the hotend mark may be dirty. Please manually set the hotend type.\",\n    \"05FF_806A\": \"Unable to recognize the right hotend. It might be a third party hotend, or the hotend mark may be dirty. Please set hotend type on printer screen before next print.\",\n    \"05FF_8080\": \"The right hotend is not installed.\",\n    \"05FF_8081\": \"The right hotend is not installed.\",\n    \"0700_4001\": \"The AMS has been disabled for a print, but it still has filament loaded. Please unload the AMS filament and switch to the spool holder filament for printing.\",\n    \"0700_4025\": \"Failed to read the filament information.\",\n    \"0700_8001\": \"Failed to cut the filament. Please check the cutter.\",\n    \"0700_8002\": \"The cutter is stuck. Please make sure the cutter handle is out.\",\n    \"0700_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"0700_8004\": \"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"0700_8005\": \"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"0700_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"0700_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"0700_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS A to the extruder is properly connected.\",\n    \"0700_8010\": \"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"0700_8011\": \"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\n    \"0700_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"0700_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"0700_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"0700_8017\": \"AMS A is drying. Please stop drying process before loading/unloading material.\",\n    \"0700_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"0700_8023\": \"AMS A cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"0700_C069\": \"An error occurred during AMS A drying. Please go to Assistant for more details.\",\n    \"0700_C06A\": \"AMS A is reading RFID. Unable to start drying. Please try again later.\",\n    \"0700_C06B\": \"AMS A is changing filament. Unable to start drying. Please try again later.\",\n    \"0700_C06C\": \"AMS A is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"0700_C06D\": \"AMS A is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"0700_C06E\": \"AMS A motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"0701_4001\": \"Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.\",\n    \"0701_4025\": \"Failed to read the filament information.\",\n    \"0701_8001\": \"Failed to cut the filament. Please check the cutter.\",\n    \"0701_8002\": \"The cutter is stuck. Please make sure the cutter handle is out.\",\n    \"0701_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"0701_8004\": \"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"0701_8005\": \"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"0701_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"0701_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"0701_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS B to the extruder is properly connected.\",\n    \"0701_8010\": \"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"0701_8011\": \"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\n    \"0701_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"0701_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"0701_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"0701_8017\": \"AMS B is drying. Please stop drying process before loading/unloading material.\",\n    \"0701_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"0701_8023\": \"AMS B cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"0701_C069\": \"An error occurred during AMS B drying. Please go to Assistant for more details.\",\n    \"0701_C06A\": \"AMS B is reading RFID. Unable to start drying. Please try again later.\",\n    \"0701_C06B\": \"AMS B is changing filament. Unable to start drying. Please try again later.\",\n    \"0701_C06C\": \"AMS B is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"0701_C06D\": \"AMS B is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"0701_C06E\": \"AMS B motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"0702_4001\": \"Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.\",\n    \"0702_4025\": \"Failed to read the filament information.\",\n    \"0702_8001\": \"Failed to cut the filament. Please check the cutter.\",\n    \"0702_8002\": \"The cutter is stuck. Please make sure the cutter handle is out.\",\n    \"0702_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"0702_8004\": \"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"0702_8005\": \"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"0702_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"0702_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"0702_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS C to the extruder is properly connected.\",\n    \"0702_8010\": \"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"0702_8011\": \"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\n    \"0702_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"0702_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"0702_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"0702_8017\": \"AMS C is drying. Please stop drying process before loading/unloading material.\",\n    \"0702_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"0702_8023\": \"AMS C cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"0702_C069\": \"An error occurred during AMS C drying. Please go to Assistant for more details.\",\n    \"0702_C06A\": \"AMS C is reading RFID. Unable to start drying. Please try again later.\",\n    \"0702_C06B\": \"AMS C is changing filament. Unable to start drying. Please try again later.\",\n    \"0702_C06C\": \"AMS C is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"0702_C06D\": \"AMS C is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"0702_C06E\": \"AMS C motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"0703_4001\": \"Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.\",\n    \"0703_4025\": \"Failed to read the filament information.\",\n    \"0703_8001\": \"Failed to cut the filament. Please check the cutter.\",\n    \"0703_8002\": \"The cutter is stuck. Please make sure the cutter handle is out.\",\n    \"0703_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"0703_8004\": \"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"0703_8005\": \"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"0703_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"0703_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"0703_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS D to the extruder is properly connected.\",\n    \"0703_8010\": \"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"0703_8011\": \"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\n    \"0703_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"0703_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"0703_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"0703_8017\": \"AMS D is drying. Please stop drying process before loading/unloading material.\",\n    \"0703_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"0703_8023\": \"AMS D cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"0703_C069\": \"An error occurred during AMS D drying. Please go to Assistant for more details.\",\n    \"0703_C06A\": \"AMS D is reading RFID. Unable to start drying. Please try again later.\",\n    \"0703_C06B\": \"AMS D is changing filament. Unable to start drying. Please try again later.\",\n    \"0703_C06C\": \"AMS D is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"0703_C06D\": \"AMS D is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"0703_C06E\": \"AMS D motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"0704_4025\": \"Failed to read the filament information.\",\n    \"0704_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"0704_8004\": \"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"0704_8005\": \"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"0704_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"0704_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"0704_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS E to the extruder is properly connected.\",\n    \"0704_8010\": \"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"0704_8011\": \"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\n    \"0704_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"0704_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"0704_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"0704_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"0704_8023\": \"AMS E cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"0705_4025\": \"Failed to read the filament information.\",\n    \"0705_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"0705_8004\": \"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"0705_8005\": \"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"0705_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"0705_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"0705_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS F to the extruder is properly connected.\",\n    \"0705_8010\": \"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"0705_8011\": \"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\n    \"0705_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"0705_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"0705_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"0705_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"0705_8023\": \"AMS F cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"0706_4025\": \"Failed to read the filament information.\",\n    \"0706_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"0706_8004\": \"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"0706_8005\": \"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"0706_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"0706_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"0706_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS G to the extruder is properly connected.\",\n    \"0706_8010\": \"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"0706_8011\": \"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\n    \"0706_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"0706_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"0706_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"0706_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"0706_8023\": \"AMS G cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"0707_4025\": \"Failed to read the filament information.\",\n    \"0707_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"0707_8004\": \"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"0707_8005\": \"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"0707_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"0707_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"0707_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS H to the extruder is properly connected.\",\n    \"0707_8010\": \"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"0707_8011\": \"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\n    \"0707_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"0707_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"0707_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"0707_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"0707_8023\": \"AMS H cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"07FE_8001\": \"Failed to cut the filament of the left extruder. Please check the cutter.\",\n    \"07FE_8002\": \"The cutter of the left extruder is stuck. Please pull out the cutter handle.\",\n    \"07FE_8003\": \"Please pull out the filament on the spool holder  of the left extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are ab...\",\n    \"07FE_8004\": \"Failed to pull back the filament from the left extruder. Please check whether the filament is stuck inside the extruder.\",\n    \"07FE_8005\": \"Failed to feed the filament outside the AMS. Please clip the end of the filament flat and check to see if the spool is stuck.\",\n    \"07FE_8006\": \"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\n    \"07FE_8007\": \"Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.\",\n    \"07FE_8010\": \"Check if the left external filament spool or filament is stuck.\",\n    \"07FE_8011\": \"The external filament connected to the left extruder has run out; please load a new filament.\",\n    \"07FE_8012\": \"Failed to get mapping table; please select 'Resume' to retry.\",\n    \"07FE_8013\": \"Timeout purging old filament of the left extruder: Please check if the filament is stuck or the extruder is clogged.\",\n    \"07FE_8020\": \"Extruder change failed; please refer to the assistant.\",\n    \"07FE_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"07FE_8024\": \"Extruder position calibration failed; please refer to the assistant.\",\n    \"07FE_8025\": \"Cold pull timed out. Please promptly operate or check whether the filament is broken inside the extruder, and click the Assistant for details.\",\n    \"07FE_8030\": \"The filament specified in the slicer has been used up. Printing is paused. Please go to the machine to replace the material and resume printing.\",\n    \"07FE_C003\": \"Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...\",\n    \"07FE_C006\": \"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\n    \"07FE_C008\": \"Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...\",\n    \"07FE_C009\": \"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\n    \"07FE_C00A\": \"Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.\",\n    \"07FE_C010\": \"Insert the filament (over 30cm long) until it stops. You might see slight smoke during flushing. After insertion, close the front door and top cover.\",\n    \"07FE_C011\": \"Please manually and slowly pull out the filament from the extruder. Then click “Continue”.\",\n    \"07FE_C012\": \"Press the black PTFE tube coupler and unplug the PTFE tube. After completing the operation, click 'Continue.'\",\n    \"07FF_4001\": \"Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.\",\n    \"07FF_8001\": \"Failed to cut the filament of the right extruder. Please check the cutter.\",\n    \"07FF_8002\": \"The cutter is stuck. Please make sure the cutter handle is out.\",\n    \"07FF_8003\": \"Please pull out the filament on the spool holder  of the right extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are a...\",\n    \"07FF_8004\": \"Failed to pull back the filament from the right extruder. Please check whether the filament is stuck inside the extruder.\",\n    \"07FF_8005\": \"Failed to feed the filament outside the AMS. Please clip the end of the filament flat and check to see if the spool is stuck.\",\n    \"07FF_8006\": \"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\n    \"07FF_8007\": \"Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.\",\n    \"07FF_8010\": \"Check if the external filament spool or filament is stuck.\",\n    \"07FF_8011\": \"External filament has run out; please load a new filament.\",\n    \"07FF_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"07FF_8013\": \"Timeout purging old filament of the right extruder: Please check if the filament is stuck or the extruder is clogged.\",\n    \"07FF_8020\": \"Extruder change failed; please refer to the assistant.\",\n    \"07FF_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"07FF_8024\": \"Extruder position calibration failed; please refer to the assistant.\",\n    \"07FF_8025\": \"Cold pull timed out. Please promptly operate or check whether the filament is broken inside the extruder, and click the Assistant for details.\",\n    \"07FF_8030\": \"The filament specified in the slicer has been used up. Printing is paused. Please go to the machine to replace the material and resume printing.\",\n    \"07FF_C003\": \"Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...\",\n    \"07FF_C006\": \"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\n    \"07FF_C008\": \"Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...\",\n    \"07FF_C009\": \"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\n    \"07FF_C00A\": \"Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.\",\n    \"07FF_C010\": \"Insert the filament (over 30cm long) until it stops. You might see slight smoke during flushing. After insertion, close the front door and top cover.\",\n    \"07FF_C011\": \"Hold the driven wheel bracket, slowly pull the filament from the extruder, then press 'Continue'.\",\n    \"07FF_C012\": \"Press the black PTFE tube coupler and unplug the PTFE tube. After completing the operation, click 'Continue.'\",\n    \"0C00_4020\": \"The setup of BirdsEye Camera failed. Please clear all objects and remove the mat. Make sure the marker is not obstructed. Meanwhile, clean both the BirdsEye Camera and Toolhead Camera, and remove a...\",\n    \"0C00_4021\": \"The setup of BirdsEye Camera failed; please reboot the printer.\",\n    \"0C00_4022\": \"The setup of BirdsEye Camera failed.  Please check if the laser module is working properly.\",\n    \"0C00_4024\": \"The Birdseye Camera is installed offset. Please refer to the assistant to reinstall it.\",\n    \"0C00_4025\": \"The Birdseye Camera is dirty. Please clean it and restart the process.\",\n    \"0C00_4026\": \"The Live View Camera initialization failed; please reboot the printer.\",\n    \"0C00_4027\": \"The Live View Camera calibration failed. Please refer to the assistant for details and recalibrate the camera after processing.\",\n    \"0C00_4029\": \"Material not detected. Please confirm placement and continue.\",\n    \"0C00_402A\": \"The visual marker was not detected. Please re-paste the paper in the correct position.\",\n    \"0C00_402C\": \"Device data link error. Please reboot the printer\",\n    \"0C00_402D\": \"The toolhead camera is not working properly; please reboot the device.\",\n    \"0C00_403D\": \"The vision encoder plate was not detected. Please confirm it is correctly positioned on the heatbed.\",\n    \"0C00_403E\": \"The high-precision nozzle offset calibration has failed, possibly due to a damaged pattern or the similarity of the colors of the two selected filaments. Please clear the printed pattern and replac...\",\n    \"0C00_4041\": \"Toolhead camera calibration failed. Please ensure the Calibration Marker on the heatbed or Height Calibration Marker on the homing area is clean and undamaged, then re-run the calibration process.\",\n    \"0C00_8001\": \"First layer defects were detected. If the defects are acceptable, select 'Resume' to resume the print job.\",\n    \"0C00_8005\": \"Purged filament has piled up in the waste chute, which may cause a tool head collision.\",\n    \"0C00_8009\": \"Build plate localization marker was not found.\",\n    \"0C00_800B\": \"The heatbed marker was not detected. Please clear all objects and remove the mat. Make sure the marker is not obstructed.\",\n    \"0C00_8015\": \"Objects detected on the platform; please clean them up in a timely manner.\",\n    \"0C00_8016\": \"The foreign object detection function is not working. You can continue the task or check assistant for solutions.\",\n    \"0C00_8017\": \"Foreign objects detected on the platform; please clean them up on time.\",\n    \"0C00_8018\": \"The foreign object detection function is not working. You can continue the task or view the assistant for troubleshooting.\",\n    \"0C00_8033\": \"Quick-release Lever is not locked. Please push it down to secure.\",\n    \"0C00_8034\": \"Liveview Camera initialization failed. This print can still continue, but some AI functions will be disabled. If you encounter this issue again after restarting, please contact customer support.\",\n    \"0C00_803F\": \"AI detected nozzle clumping. Please check the nozzle condition. Refer to assistant for solutions.\",\n    \"0C00_8040\": \"AI detected air-printing defect. Please check the hotend extrusion status. Refer to assistant for solutions.\",\n    \"0C00_8042\": \"The AI print monitor has detected a spaghetti defect. Please check the print and take the necessary action.\",\n    \"0C00_8043\": \"AI detected nozzle clumping. Please check the nozzle condition. Refer to assistant for solutions.\",\n    \"0C00_C003\": \"Possible defects were detected in the first layer.\",\n    \"0C00_C004\": \"Possible spaghetti failure was detected.\",\n    \"0C00_C006\": \"Purged filament may have piled up in the waste chute.\",\n    \"1000_C001\": \"High bed temperature may lead to filament clogging in the nozzle. You may open the chamber door.\",\n    \"1000_C002\": \"Printing CF material with stainless steel may cause nozzle damage.\",\n    \"1000_C003\": \"Enabling Timelapse in traditional mode may cause defects; please activate this feature as needed.\",\n    \"1001_4001\": \"Timelapse is not supported as Spiral Vase mode is enabled in slicing presets.\",\n    \"1001_4002\": \"Timelapse is not supported as the Print sequence is set to 'By object'.\",\n    \"1001_8003\": \"The time-lapse mode is set to Traditional in the slicing file. This may cause surface defects. Would you like to enable it?\",\n    \"1001_8004\": \"Prime Tower is not enabled and time-lapse mode is set to Smooth in slicing file. This may cause surface defects. Would you like to enable it?\",\n    \"1200_4001\": \"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\n    \"1200_8001\": \"Cutting the filament failed. Please check to see if the cutter is stuck. Refer to the Assistant for solutions.\",\n    \"1200_8002\": \"The cutter is stuck. Please pull out the cutter handle.\",\n    \"1200_8003\": \"Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.\",\n    \"1200_8004\": \"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\n    \"1200_8005\": \"The filament is not inserted. Please insert the filament.\",\n    \"1200_8006\": \"Unable to feed filament into the extruder. This could be due to tangled filament or a stuck spool. If not, please check if the AMS PTFE tube is connected.\",\n    \"1200_8007\": \"Failed to extrude the filament. This might be caused by clogged extruder or stuck filament. Refer to the Assistant for solutions.\",\n    \"1200_8010\": \"Filament or spool may be stuck.\",\n    \"1200_8011\": \"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\n    \"1200_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1200_8013\": \"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\n    \"1200_8014\": \"The filament location in the toolhead was not found. Refer to the Assistant for solutions.\",\n    \"1200_8015\": \"Failed to pull out the filament from the toolhead. Please check if the filament is stuck, or if it is broken inside the extruder or PTFE tube.\",\n    \"1200_8016\": \"The extruder is not extruding normally. Refer to the Assistant for troubleshooting. There may be defects in this layer, but you may resume if the defects are acceptable.\",\n    \"1201_4001\": \"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\n    \"1201_8001\": \"Failed to cut the filament. Please check the cutter.\",\n    \"1201_8002\": \"The cutter is stuck. Please pull out the cutter handle.\",\n    \"1201_8003\": \"Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.\",\n    \"1201_8004\": \"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\n    \"1201_8005\": \"Failed to feed the filament. Please load the filament and then select 'Retry'.\",\n    \"1201_8006\": \"Failed to feed the filament into the toolhead. Please check whether the filament is stuck.\",\n    \"1201_8007\": \"Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.\",\n    \"1201_8010\": \"Please check if the spool or filament is stuck.\",\n    \"1201_8011\": \"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\n    \"1201_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1201_8013\": \"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\n    \"1201_8014\": \"Failed to check the filament location in the tool head; please refer to the HMS.\",\n    \"1201_8015\": \"Failed to pull back the filament from the toolhead. Please check if the filament is stuck or the filament is broken inside the extruder.\",\n    \"1201_8016\": \"The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.\",\n    \"1202_4001\": \"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\n    \"1202_8001\": \"Failed to cut the filament. Please check the cutter.\",\n    \"1202_8002\": \"The cutter is stuck. Please pull out the cutter handle.\",\n    \"1202_8003\": \"Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.\",\n    \"1202_8004\": \"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\n    \"1202_8005\": \"The filament is not inserted. Please insert the filament.\",\n    \"1202_8006\": \"Failed to feed the filament into the toolhead. Please check whether the filament is stuck.\",\n    \"1202_8007\": \"Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.\",\n    \"1202_8010\": \"Please check if the spool or filament is stuck.\",\n    \"1202_8011\": \"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\n    \"1202_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1202_8013\": \"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\n    \"1202_8014\": \"Failed to check the filament location in the tool head; please refer to the HMS.\",\n    \"1202_8015\": \"Failed to pull back the filament from the toolhead. Please check if the filament is stuck or is broken inside the extruder.\",\n    \"1202_8016\": \"The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.\",\n    \"1203_4001\": \"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\n    \"1203_8001\": \"Failed to cut the filament. Please check the cutter.\",\n    \"1203_8002\": \"The cutter is stuck. Please pull out the cutter handle.\",\n    \"1203_8003\": \"Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.\",\n    \"1203_8004\": \"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\n    \"1203_8005\": \"The filament is not inserted. Please insert the filament.\",\n    \"1203_8006\": \"Failed to feed the filament into the toolhead. Please check whether the filament is stuck.\",\n    \"1203_8007\": \"Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.\",\n    \"1203_8010\": \"Please check if the spool or filament is stuck.\",\n    \"1203_8011\": \"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\n    \"1203_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1203_8013\": \"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\n    \"1203_8014\": \"Failed to check the filament location in the tool head; please refer to the HMS.\",\n    \"1203_8015\": \"Failed to pull back the filament from the toolhead. Please check if the filament is stuck or is broken inside the extruder.\",\n    \"1203_8016\": \"The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.\",\n    \"12FF_4001\": \"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\n    \"12FF_8001\": \"Failed to cut the filament. Please check the cutter.\",\n    \"12FF_8002\": \"The cutter is stuck. Please pull out the cutter handle.\",\n    \"12FF_8003\": \"Please pull out the filament on the spool holder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube if you are about to us...\",\n    \"12FF_8004\": \"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\n    \"12FF_8005\": \"The filament is not inserted. Please insert the filament.\",\n    \"12FF_8006\": \"Please feed filament into the PTFE tube until it can not be pushed any farther.\",\n    \"12FF_8007\": \"Check nozzle. Select 'Done' if filament was extruded, otherwise push filament forward slightly and select 'Retry.'\",\n    \"12FF_8010\": \"Please check if the filament or the spool is stuck.\",\n    \"12FF_8011\": \"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\n    \"12FF_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"12FF_8013\": \"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\n    \"12FF_C003\": \"Please pull out the filament on the spool holder. If this message persists, please check to see if there is filament broken in the extruder or PTFE Tube. (Connect a PTFE tube if you are about to us...\",\n    \"12FF_C006\": \"Please feed filament into the PTFE tube until it can not be pushed any farther.\",\n    \"1800_4025\": \"Failed to read the filament information.\",\n    \"1800_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"1800_8004\": \"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"1800_8005\": \"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"1800_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"1800_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"1800_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT A to the extruder is properly connected.\",\n    \"1800_8010\": \"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"1800_8011\": \"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\n    \"1800_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1800_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"1800_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"1800_8017\": \"AMS-HT A is drying. Please stop drying process before loading/unloading material.\",\n    \"1800_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"1800_8023\": \"AMS-HT A cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"1800_C069\": \"An error occurred during AMS-HT A drying. Please go to Assistant for more details.\",\n    \"1800_C06A\": \"AMS-HT A is reading RFID. Unable to start drying. Please try again later.\",\n    \"1800_C06B\": \"AMS-HT A is changing filament. Unable to start drying. Please try again later.\",\n    \"1800_C06C\": \"AMS-HT A is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"1800_C06D\": \"AMS-HT A is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"1800_C06E\": \"AMS-HT A motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"1801_4025\": \"Failed to read the filament information.\",\n    \"1801_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"1801_8004\": \"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"1801_8005\": \"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"1801_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"1801_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"1801_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT B to the extruder is properly connected.\",\n    \"1801_8010\": \"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"1801_8011\": \"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\n    \"1801_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1801_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"1801_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"1801_8017\": \"AMS-HT B is drying. Please stop drying process before loading/unloading material.\",\n    \"1801_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"1801_8023\": \"AMS-HT B cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"1801_C069\": \"An error occurred during AMS-HT B drying. Please go to Assistant for more details.\",\n    \"1801_C06A\": \"AMS-HT B is reading RFID. Unable to start drying. Please try again later.\",\n    \"1801_C06B\": \"AMS-HT B is changing filament. Unable to start drying. Please try again later.\",\n    \"1801_C06C\": \"AMS-HT B is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"1801_C06D\": \"AMS-HT B is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"1801_C06E\": \"AMS-HT B motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"1802_4025\": \"Failed to read the filament information.\",\n    \"1802_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"1802_8004\": \"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"1802_8005\": \"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"1802_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"1802_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"1802_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT C to the extruder is properly connected.\",\n    \"1802_8010\": \"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"1802_8011\": \"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\n    \"1802_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1802_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"1802_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"1802_8017\": \"AMS-HT C is drying. Please stop drying process before loading/unloading material.\",\n    \"1802_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"1802_8023\": \"AMS-HT C cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"1802_C069\": \"An error occurred during AMS-HT C drying. Please go to Assistant for more details.\",\n    \"1802_C06A\": \"AMS-HT C is reading RFID. Unable to start drying. Please try again later.\",\n    \"1802_C06B\": \"AMS-HT C is changing filament. Unable to start drying. Please try again later.\",\n    \"1802_C06C\": \"AMS-HT C is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"1802_C06D\": \"AMS-HT C is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"1802_C06E\": \"AMS-HT C motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"1803_4025\": \"Failed to read the filament information.\",\n    \"1803_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"1803_8004\": \"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"1803_8005\": \"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"1803_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"1803_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"1803_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT D to the extruder is properly connected.\",\n    \"1803_8010\": \"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"1803_8011\": \"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\n    \"1803_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1803_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"1803_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"1803_8017\": \"AMS-HT D is drying. Please stop drying process before loading/unloading material.\",\n    \"1803_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"1803_8023\": \"AMS-HT D cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"1803_C069\": \"An error occurred during AMS-HT D drying. Please go to Assistant for more details.\",\n    \"1803_C06A\": \"AMS-HT D is reading RFID. Unable to start drying. Please try again later.\",\n    \"1803_C06B\": \"AMS-HT D is changing filament. Unable to start drying. Please try again later.\",\n    \"1803_C06C\": \"AMS-HT D is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"1803_C06D\": \"AMS-HT D is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"1803_C06E\": \"AMS-HT D motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"1804_4025\": \"Failed to read the filament information.\",\n    \"1804_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"1804_8004\": \"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"1804_8005\": \"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"1804_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"1804_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"1804_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT E to the extruder is properly connected.\",\n    \"1804_8010\": \"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"1804_8011\": \"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\n    \"1804_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1804_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"1804_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"1804_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"1804_8023\": \"AMS-HT E cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"1804_C069\": \"An error occurred during AMS-HT E drying. Please go to Assistant for more details.\",\n    \"1804_C06A\": \"AMS-HT E is reading RFID. Unable to start drying. Please try again later.\",\n    \"1804_C06B\": \"AMS-HT E is changing filament. Unable to start drying. Please try again later.\",\n    \"1804_C06C\": \"AMS-HT E is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"1804_C06D\": \"AMS-HT E is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"1804_C06E\": \"AMS-HT E motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"1805_4025\": \"Failed to read the filament information.\",\n    \"1805_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"1805_8004\": \"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"1805_8005\": \"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"1805_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"1805_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"1805_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT F to the extruder is properly connected.\",\n    \"1805_8010\": \"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"1805_8011\": \"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\n    \"1805_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1805_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"1805_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"1805_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"1805_8023\": \"AMS-HT F cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"1805_C069\": \"An error occurred during AMS-HT F drying. Please go to Assistant for more details.\",\n    \"1805_C06A\": \"AMS-HT F is reading RFID. Unable to start drying. Please try again later.\",\n    \"1805_C06B\": \"AMS-HT F is changing filament. Unable to start drying. Please try again later.\",\n    \"1805_C06C\": \"AMS-HT F is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"1805_C06D\": \"AMS-HT F is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"1805_C06E\": \"AMS-HT F motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"1806_4025\": \"Failed to read the filament information.\",\n    \"1806_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"1806_8004\": \"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"1806_8005\": \"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"1806_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"1806_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"1806_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT G to the extruder is properly connected.\",\n    \"1806_8010\": \"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"1806_8011\": \"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\n    \"1806_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1806_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"1806_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"1806_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"1806_8023\": \"AMS-HT G cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"1806_C069\": \"An error occurred during AMS-HT G drying. Please go to Assistant for more details.\",\n    \"1806_C06A\": \"AMS-HT G is reading RFID. Unable to start drying. Please try again later.\",\n    \"1806_C06B\": \"AMS-HT G is changing filament. Unable to start drying. Please try again later.\",\n    \"1806_C06C\": \"AMS-HT G is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"1806_C06D\": \"AMS-HT G is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"1806_C06E\": \"AMS-HT G motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"1807_4025\": \"Failed to read the filament information.\",\n    \"1807_8003\": \"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\n    \"1807_8004\": \"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\n    \"1807_8005\": \"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\n    \"1807_8006\": \"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\n    \"1807_8007\": \"Extruding filament failed. The extruder might be clogged.\",\n    \"1807_800A\": \"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT H to the extruder is properly connected.\",\n    \"1807_8010\": \"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\n    \"1807_8011\": \"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\n    \"1807_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"1807_8013\": \"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\n    \"1807_8016\": \"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\n    \"1807_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"1807_8023\": \"AMS-HT H cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\n    \"1807_C069\": \"An error occurred during AMS-HT H drying. Please go to Assistant for more details.\",\n    \"1807_C06A\": \"AMS-HT H is reading RFID. Unable to start drying. Please try again later.\",\n    \"1807_C06B\": \"AMS-HT H is changing filament. Unable to start drying. Please try again later.\",\n    \"1807_C06C\": \"AMS-HT H is in Feed Assist Mode. Unable to start drying. Please try again later.\",\n    \"1807_C06D\": \"AMS-HT H is assisting in filament insertion. Unable to start drying. Please try again later.\",\n    \"1807_C06E\": \"AMS-HT H motor is performing self-test. Unable to start drying. Please try again later.\",\n    \"18FE_8001\": \"Failed to cut the filament of the left extruder. Please check the cutter.\",\n    \"18FE_8002\": \"The cutter of the left extruder is stuck. Please pull out the cutter handle.\",\n    \"18FE_8003\": \"Please pull out the filament on the spool holder  of the left extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are ab...\",\n    \"18FE_8004\": \"Failed to pull back the filament from the left extruder. Please check whether the filament is stuck inside the extruder.\",\n    \"18FE_8005\": \"Failed to feed the filament outside the AMS-HT. Please clip the end of the filament flat and check to see if the spool is stuck.\",\n    \"18FE_8006\": \"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\n    \"18FE_8007\": \"Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.\",\n    \"18FE_8011\": \"The external filament connected to the left extruder has run out; please load a new filament.\",\n    \"18FE_8012\": \"Failed to get mapping table; please select 'Resume' to retry.\",\n    \"18FE_8013\": \"Timeout purging old filament of the left extruder: Please check if the filament is stuck or the extruder is clogged.\",\n    \"18FE_8020\": \"Extruder change failed; please refer to the assistant.\",\n    \"18FE_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"18FE_8024\": \"Extruder position calibration failed; please refer to the assistant.\",\n    \"18FE_C003\": \"Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...\",\n    \"18FE_C006\": \"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\n    \"18FE_C008\": \"Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...\",\n    \"18FE_C009\": \"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\n    \"18FE_C00A\": \"Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.\",\n    \"18FF_8001\": \"Failed to cut the filament of the right extruder. Please check the cutter.\",\n    \"18FF_8002\": \"The cutter of the right extruder is stuck. Please pull out the cutter handle.\",\n    \"18FF_8003\": \"Please pull out the filament on the spool holder  of the right extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are a...\",\n    \"18FF_8004\": \"Failed to pull back the filament from the right extruder. Please check whether the filament is stuck inside the extruder.\",\n    \"18FF_8005\": \"Failed to feed the filament outside the AMS-HT. Please clip the end of the filament flat and check to see if the spool is stuck.\",\n    \"18FF_8006\": \"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\n    \"18FF_8007\": \"Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.\",\n    \"18FF_8011\": \"The external filament connected to the right extruder has run out; please load a new filament.\",\n    \"18FF_8012\": \"Failed to get AMS mapping table; please select 'Resume' to retry.\",\n    \"18FF_8013\": \"Timeout purging old filament of the right extruder: Please check if the filament is stuck or the extruder is clogged.\",\n    \"18FF_8020\": \"Extruder change failed; please refer to the assistant.\",\n    \"18FF_8021\": \"AMS setup failed; please refer to the assistant.\",\n    \"18FF_8024\": \"Extruder position calibration failed; please refer to the assistant.\",\n    \"18FF_C003\": \"Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...\",\n    \"18FF_C006\": \"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\n    \"18FF_C008\": \"Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...\",\n    \"18FF_C009\": \"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\n    \"18FF_C00A\": \"Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.\",\n}\n\n\ndef get_error_description(error_code: str) -> str | None:\n    \"\"\"Get human-readable description for an HMS error code.\n\n    Args:\n        error_code: Error code in format \"XXXX_YYYY\" (e.g., \"0300_400C\")\n\n    Returns:\n        Human-readable description or None if not found\n    \"\"\"\n    return HMS_ERROR_DESCRIPTIONS.get(error_code.upper())\n"
  },
  {
    "path": "backend/app/services/homeassistant.py",
    "content": "\"\"\"Service for communicating with Home Assistant via REST API.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\nfrom urllib.parse import urlparse\n\nimport httpx\n\nif TYPE_CHECKING:\n    from backend.app.models.smart_plug import SmartPlug\n\nlogger = logging.getLogger(__name__)\n\n\nclass HomeAssistantService:\n    \"\"\"Service for controlling Home Assistant entities via REST API.\"\"\"\n\n    def __init__(self, timeout: float = 10.0):\n        self.timeout = timeout\n        self.base_url: str = \"\"\n        self.token: str = \"\"\n\n    def configure(self, url: str, token: str):\n        \"\"\"Configure HA connection settings.\"\"\"\n        self.base_url = url.rstrip(\"/\") if url else \"\"\n        self.token = token or \"\"\n\n    def _headers(self) -> dict:\n        return {\n            \"Authorization\": f\"Bearer {self.token}\",\n            \"Content-Type\": \"application/json\",\n        }\n\n    async def get_status(self, plug: \"SmartPlug\") -> dict:\n        \"\"\"Get current state of HA entity.\n\n        Returns dict with:\n            - state: \"ON\" or \"OFF\" or None if unreachable\n            - reachable: bool\n            - device_name: str or None\n        \"\"\"\n        if not self.base_url or not self.token:\n            return {\"state\": None, \"reachable\": False, \"device_name\": None}\n\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                response = await client.get(\n                    f\"{self.base_url}/api/states/{plug.ha_entity_id}\",\n                    headers=self._headers(),\n                )\n                response.raise_for_status()\n                data = response.json()\n\n                state_value = data.get(\"state\", \"\").lower()\n                # Normalize to ON/OFF\n                if state_value == \"on\":\n                    state = \"ON\"\n                elif state_value == \"off\":\n                    state = \"OFF\"\n                else:\n                    state = None\n\n                return {\n                    \"state\": state,\n                    \"reachable\": True,\n                    \"device_name\": data.get(\"attributes\", {}).get(\"friendly_name\"),\n                }\n        except Exception as e:\n            logger.warning(\"Failed to get HA entity state for %s: %s\", plug.ha_entity_id, e)\n            return {\"state\": None, \"reachable\": False, \"device_name\": None}\n\n    async def turn_on(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Turn on HA entity. Returns True if successful.\"\"\"\n        success = await self._call_service(plug, \"turn_on\")\n        if success:\n            logger.info(\"Turned ON HA entity '%s' (%s)\", plug.name, plug.ha_entity_id)\n        return success\n\n    async def turn_off(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Turn off HA entity. Returns True if successful.\"\"\"\n        success = await self._call_service(plug, \"turn_off\")\n        if success:\n            logger.info(\"Turned OFF HA entity '%s' (%s)\", plug.name, plug.ha_entity_id)\n        return success\n\n    async def toggle(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Toggle HA entity. Returns True if successful.\"\"\"\n        success = await self._call_service(plug, \"toggle\")\n        if success:\n            logger.info(\"Toggled HA entity '%s' (%s)\", plug.name, plug.ha_entity_id)\n        return success\n\n    async def _call_service(self, plug: \"SmartPlug\", action: str) -> bool:\n        \"\"\"Call HA service on entity.\"\"\"\n        if not self.base_url or not self.token or not plug.ha_entity_id:\n            return False\n\n        domain = plug.ha_entity_id.split(\".\")[0]  # \"switch\", \"light\", etc.\n\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                response = await client.post(\n                    f\"{self.base_url}/api/services/{domain}/{action}\",\n                    headers=self._headers(),\n                    json={\"entity_id\": plug.ha_entity_id},\n                )\n                response.raise_for_status()\n                return True\n        except Exception as e:\n            logger.warning(\"Failed to %s HA entity %s: %s\", action, plug.ha_entity_id, e)\n            return False\n\n    async def get_energy(self, plug: \"SmartPlug\") -> dict | None:\n        \"\"\"Get energy data from HA sensor entities or switch attributes.\n\n        First tries dedicated sensor entities if configured, then falls back\n        to checking the switch entity's attributes.\n        Returns dict with energy data or None if not available.\n        \"\"\"\n        if not self.base_url or not self.token:\n            return None\n\n        power = None\n        today = None\n        total = None\n\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                # Fetch power from dedicated sensor entity if configured\n                if plug.ha_power_entity:\n                    power = await self._get_sensor_value(client, plug.ha_power_entity)\n\n                # Fetch today's energy from dedicated sensor entity if configured\n                if plug.ha_energy_today_entity:\n                    today = await self._get_sensor_value(client, plug.ha_energy_today_entity)\n\n                # Fetch total energy from dedicated sensor entity if configured\n                if plug.ha_energy_total_entity:\n                    total = await self._get_sensor_value(client, plug.ha_energy_total_entity)\n\n                # Fallback: try switch entity attributes (original behavior)\n                if power is None:\n                    response = await client.get(\n                        f\"{self.base_url}/api/states/{plug.ha_entity_id}\",\n                        headers=self._headers(),\n                    )\n                    response.raise_for_status()\n                    attrs = response.json().get(\"attributes\", {})\n                    power = attrs.get(\"current_power_w\") or attrs.get(\"power\")\n                    if today is None:\n                        today = attrs.get(\"today_energy_kwh\")\n                    if total is None:\n                        total = attrs.get(\"total_energy_kwh\")\n\n                if power is None:\n                    return None\n\n                return {\n                    \"power\": power,\n                    \"voltage\": None,\n                    \"current\": None,\n                    \"today\": today,\n                    \"total\": total,\n                    \"yesterday\": None,\n                    \"factor\": None,\n                    \"apparent_power\": None,\n                    \"reactive_power\": None,\n                }\n        except Exception as e:\n            logger.debug(\"Failed to get HA energy data: %s\", e)\n            return None\n\n    async def _get_sensor_value(self, client: httpx.AsyncClient, entity_id: str) -> float | None:\n        \"\"\"Fetch numeric value from a HA sensor entity.\"\"\"\n        try:\n            response = await client.get(\n                f\"{self.base_url}/api/states/{entity_id}\",\n                headers=self._headers(),\n            )\n            response.raise_for_status()\n            state = response.json().get(\"state\")\n            if state and state not in (\"unknown\", \"unavailable\"):\n                return float(state)\n        except Exception:\n            pass  # Sensor read is best-effort; caller handles None\n        return None\n\n    @staticmethod\n    def _validate_url(url: str) -> str | None:\n        \"\"\"Validate HA URL scheme and block dangerous destinations.\"\"\"\n        try:\n            parsed = urlparse(url)\n        except ValueError:\n            return None\n        if parsed.scheme not in (\"http\", \"https\") or not parsed.hostname:\n            return None\n        blocked = (\"169.254.169.254\", \"metadata.google.internal\", \"0.0.0.0\")  # nosec B104\n        if parsed.hostname.lower() in blocked or (parsed.hostname or \"\").startswith(\"169.254.\"):\n            return None\n        return f\"{parsed.scheme}://{parsed.hostname}\" + (f\":{parsed.port}\" if parsed.port else \"\") + (parsed.path or \"\")\n\n    async def test_connection(self, url: str, token: str) -> dict:\n        \"\"\"Test connection to Home Assistant.\n\n        Returns dict with:\n            - success: bool\n            - message: str or None (HA message on success)\n            - error: str or None (error message on failure)\n        \"\"\"\n        safe_url = self._validate_url(url)\n        if not safe_url:\n            return {\"success\": False, \"message\": None, \"error\": \"Invalid Home Assistant URL\"}\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                response = await client.get(\n                    f\"{safe_url.rstrip('/')}/api/\",\n                    headers={\"Authorization\": f\"Bearer {token}\"},\n                )\n                response.raise_for_status()\n                data = response.json()\n                return {\n                    \"success\": True,\n                    \"message\": data.get(\"message\", \"Connected\"),\n                    \"error\": None,\n                }\n        except httpx.HTTPStatusError as e:\n            if e.response.status_code == 401:\n                return {\"success\": False, \"message\": None, \"error\": \"Invalid access token\"}\n            return {\"success\": False, \"message\": None, \"error\": f\"HTTP {e.response.status_code}\"}\n        except httpx.TimeoutException:\n            return {\"success\": False, \"message\": None, \"error\": \"Connection timeout\"}\n        except httpx.ConnectError:\n            return {\"success\": False, \"message\": None, \"error\": \"Could not connect to Home Assistant\"}\n        except Exception as e:\n            return {\"success\": False, \"message\": None, \"error\": str(e)}\n\n    async def list_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:\n        \"\"\"List available entities from HA.\n\n        By default, returns switch/light/input_boolean domains.\n        When search is provided, searches ALL entities by entity_id or friendly_name.\n\n        Returns list of entity dicts with:\n            - entity_id: str\n            - friendly_name: str\n            - state: str\n            - domain: str\n        \"\"\"\n        # Default domains for smart plug control\n        default_domains = {\"switch\", \"light\", \"input_boolean\", \"script\"}\n\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                response = await client.get(\n                    f\"{url.rstrip('/')}/api/states\",\n                    headers={\"Authorization\": f\"Bearer {token}\"},\n                )\n                response.raise_for_status()\n\n                entities = []\n                search_lower = search.lower().strip() if search else None\n\n                for entity in response.json():\n                    entity_id = entity.get(\"entity_id\", \"\")\n                    domain = entity_id.split(\".\")[0] if \".\" in entity_id else \"\"\n                    friendly_name = entity.get(\"attributes\", {}).get(\"friendly_name\", entity_id)\n\n                    # If searching, match against entity_id or friendly_name\n                    if search_lower:\n                        if search_lower not in entity_id.lower() and search_lower not in friendly_name.lower():\n                            continue\n                    else:\n                        # No search: filter to default domains only\n                        if domain not in default_domains:\n                            continue\n\n                    entities.append(\n                        {\n                            \"entity_id\": entity_id,\n                            \"friendly_name\": friendly_name,\n                            \"state\": entity.get(\"state\"),\n                            \"domain\": domain,\n                        }\n                    )\n\n                return sorted(entities, key=lambda x: x[\"friendly_name\"].lower())\n        except Exception as e:\n            logger.warning(\"Failed to list HA entities: %s\", e)\n            return []\n\n    async def list_sensor_entities(self, url: str, token: str) -> list[dict]:\n        \"\"\"List available sensor entities for energy monitoring.\n\n        Returns list of sensor entities with power/energy units.\n        \"\"\"\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                response = await client.get(\n                    f\"{url.rstrip('/')}/api/states\",\n                    headers={\"Authorization\": f\"Bearer {token}\"},\n                )\n                response.raise_for_status()\n\n                # Valid units for energy monitoring sensors (lowercase for case-insensitive matching)\n                power_units = {\"w\", \"kw\", \"mw\"}\n                energy_units = {\"kwh\", \"wh\", \"mwh\"}\n                valid_units = power_units | energy_units\n\n                entities = []\n                for entity in response.json():\n                    entity_id = entity.get(\"entity_id\", \"\")\n                    domain = entity_id.split(\".\")[0] if \".\" in entity_id else \"\"\n\n                    # Filter to sensor domain only\n                    if domain != \"sensor\":\n                        continue\n\n                    attrs = entity.get(\"attributes\", {})\n                    unit = attrs.get(\"unit_of_measurement\", \"\")\n\n                    # Only include sensors with power/energy units (case-insensitive)\n                    if unit.lower() in valid_units:\n                        entities.append(\n                            {\n                                \"entity_id\": entity_id,\n                                \"friendly_name\": attrs.get(\"friendly_name\", entity_id),\n                                \"state\": entity.get(\"state\"),\n                                \"unit_of_measurement\": unit,\n                            }\n                        )\n\n                return sorted(entities, key=lambda x: x[\"friendly_name\"].lower())\n        except Exception as e:\n            logger.warning(\"Failed to list HA sensor entities: %s\", e)\n            return []\n\n\n# Singleton instance\nhomeassistant_service = HomeAssistantService()\n"
  },
  {
    "path": "backend/app/services/layer_timelapse.py",
    "content": "\"\"\"Layer-based timelapse for external cameras.\n\nCaptures a frame on each layer change and stitches them into a video on print completion.\n\"\"\"\n\nimport asyncio\nimport logging\nimport shutil\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom backend.app.core.config import settings\nfrom backend.app.services.external_camera import capture_frame\n\nlogger = logging.getLogger(__name__)\n\n# Active timelapse sessions: {printer_id: TimelapseSession}\n_active_sessions: dict[int, \"TimelapseSession\"] = {}\n\n\ndef get_ffmpeg_path() -> str | None:\n    \"\"\"Get the path to ffmpeg executable.\"\"\"\n    # Try shutil.which first\n    path = shutil.which(\"ffmpeg\")\n    if path:\n        return path\n    # Check common locations (systemd services may have limited PATH)\n    for common_path in [\"/usr/bin/ffmpeg\", \"/usr/local/bin/ffmpeg\", \"/opt/homebrew/bin/ffmpeg\"]:\n        if Path(common_path).exists():\n            return common_path\n    return None\n\n\n@dataclass\nclass TimelapseSession:\n    \"\"\"Active timelapse recording session.\"\"\"\n\n    printer_id: int\n    archive_id: int | None\n    camera_url: str\n    camera_type: str\n    last_layer: int = -1\n    frame_count: int = 0\n    session_id: str = field(default_factory=lambda: datetime.now().strftime(\"%Y%m%d_%H%M%S\"))\n    frames_dir: Path = field(init=False)\n\n    def __post_init__(self):\n        self.frames_dir = settings.base_dir / \"timelapse_frames\" / str(self.printer_id) / self.session_id\n        self.frames_dir.mkdir(parents=True, exist_ok=True)\n        logger.info(\"Created timelapse session %s for printer %s\", self.session_id, self.printer_id)\n\n    async def capture_layer(self, layer_num: int) -> bool:\n        \"\"\"Capture frame if layer changed.\n\n        Args:\n            layer_num: Current layer number from printer\n\n        Returns:\n            True if frame was captured, False otherwise\n        \"\"\"\n        # Only capture if layer increased\n        if layer_num <= self.last_layer:\n            return False\n\n        self.last_layer = layer_num\n\n        try:\n            frame_data = await capture_frame(self.camera_url, self.camera_type)\n            if frame_data:\n                frame_path = self.frames_dir / f\"layer_{layer_num:05d}.jpg\"\n                await asyncio.to_thread(frame_path.write_bytes, frame_data)\n                self.frame_count += 1\n                logger.debug(\n                    \"Captured layer %s for printer %s (frame %s)\", layer_num, self.printer_id, self.frame_count\n                )\n                return True\n            else:\n                logger.warning(\"Failed to capture frame for layer %s\", layer_num)\n                return False\n        except Exception as e:\n            logger.error(\"Error capturing timelapse frame: %s\", e)\n            return False\n\n    async def stitch(self, output_path: Path, fps: int = 30) -> bool:\n        \"\"\"Create MP4 from captured frames using ffmpeg.\n\n        Args:\n            output_path: Path for output video file\n            fps: Frames per second for output video\n\n        Returns:\n            True if stitching succeeded, False otherwise\n        \"\"\"\n        if self.frame_count == 0:\n            logger.warning(\"No frames to stitch\")\n            return False\n\n        ffmpeg = get_ffmpeg_path()\n        if not ffmpeg:\n            logger.error(\"ffmpeg not found - required for timelapse stitching\")\n            return False\n\n        # Find all frame files and create a sequential list\n        # This handles gaps in layer numbers (e.g., if some captures failed)\n        frame_files = sorted(self.frames_dir.glob(\"layer_*.jpg\"))\n        if not frame_files:\n            logger.warning(\"No frame files found in timelapse directory\")\n            return False\n\n        # Create a concat file listing all frames\n        concat_file = self.frames_dir / \"frames.txt\"\n        try:\n            with open(concat_file, \"w\") as f:\n                for frame in frame_files:\n                    # Each frame shown for 1/fps duration\n                    f.write(f\"file '{frame.name}'\\n\")\n                    f.write(f\"duration {1.0 / fps}\\n\")\n                # Add last frame again (required by concat demuxer)\n                if frame_files:\n                    f.write(f\"file '{frame_files[-1].name}'\\n\")\n        except Exception as e:\n            logger.error(\"Failed to create concat file: %s\", e)\n            return False\n\n        # Use ffmpeg concat demuxer for variable-gap frame sequences\n        cmd = [\n            ffmpeg,\n            \"-y\",  # Overwrite output\n            \"-f\",\n            \"concat\",\n            \"-safe\",\n            \"0\",\n            \"-i\",\n            str(concat_file),\n            \"-c:v\",\n            \"libx264\",\n            \"-pix_fmt\",\n            \"yuv420p\",\n            \"-preset\",\n            \"medium\",\n            \"-crf\",\n            \"23\",\n            str(output_path),\n        ]\n\n        try:\n            process = await asyncio.create_subprocess_exec(\n                *cmd,\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n                cwd=str(self.frames_dir),  # Run in frames dir so relative paths work\n            )\n\n            stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)\n\n            if process.returncode != 0:\n                logger.error(\"ffmpeg timelapse stitch failed: %s\", stderr.decode()[:500])\n                return False\n\n            logger.info(\"Created timelapse video: %s (%s frames)\", output_path, self.frame_count)\n            return True\n\n        except TimeoutError:\n            logger.error(\"Timelapse stitching timed out\")\n            if process:\n                process.kill()\n            return False\n        except Exception as e:\n            logger.error(\"Timelapse stitch failed: %s\", e)\n            return False\n\n    def cleanup(self):\n        \"\"\"Remove temporary frames directory.\"\"\"\n        try:\n            if self.frames_dir.exists():\n                shutil.rmtree(self.frames_dir, ignore_errors=True)\n                logger.info(\"Cleaned up timelapse frames for session %s\", self.session_id)\n        except Exception as e:\n            logger.warning(\"Failed to cleanup timelapse frames: %s\", e)\n\n\ndef start_session(printer_id: int, archive_id: int | None, url: str, cam_type: str) -> TimelapseSession:\n    \"\"\"Start new timelapse session for a printer.\n\n    Args:\n        printer_id: The printer ID\n        archive_id: Associated print archive ID (optional)\n        url: External camera URL\n        cam_type: Camera type (\"mjpeg\", \"rtsp\", \"snapshot\")\n\n    Returns:\n        The new TimelapseSession\n    \"\"\"\n    # Cancel any existing session\n    cancel_session(printer_id)\n\n    session = TimelapseSession(\n        printer_id=printer_id,\n        archive_id=archive_id,\n        camera_url=url,\n        camera_type=cam_type,\n    )\n    _active_sessions[printer_id] = session\n    logger.info(\"Started timelapse session for printer %s\", printer_id)\n    return session\n\n\ndef get_session(printer_id: int) -> TimelapseSession | None:\n    \"\"\"Get active timelapse session for a printer.\"\"\"\n    return _active_sessions.get(printer_id)\n\n\nasync def on_layer_change(printer_id: int, layer_num: int):\n    \"\"\"Called on layer change - captures frame if session active.\n\n    Args:\n        printer_id: The printer ID\n        layer_num: Current layer number\n    \"\"\"\n    session = get_session(printer_id)\n    if session:\n        await session.capture_layer(layer_num)\n\n\nasync def on_print_complete(printer_id: int) -> Path | None:\n    \"\"\"Stitch timelapse and return path. Cleans up session.\n\n    Args:\n        printer_id: The printer ID\n\n    Returns:\n        Path to stitched video, or None if no session or stitching failed\n    \"\"\"\n    session = _active_sessions.pop(printer_id, None)\n    if not session:\n        return None\n\n    if session.frame_count == 0:\n        logger.info(\"No timelapse frames captured for printer %s\", printer_id)\n        session.cleanup()\n        return None\n\n    # Create output path in parent of frames dir\n    output_path = session.frames_dir.parent / f\"timelapse_{session.session_id}.mp4\"\n\n    try:\n        success = await session.stitch(output_path)\n        if success:\n            # Cleanup frames after successful stitch\n            session.cleanup()\n            return output_path\n        else:\n            session.cleanup()\n            return None\n    except Exception as e:\n        logger.error(\"Timelapse completion failed: %s\", e)\n        session.cleanup()\n        return None\n\n\ndef cancel_session(printer_id: int):\n    \"\"\"Cancel and cleanup timelapse session (on print fail/cancel).\n\n    Args:\n        printer_id: The printer ID\n    \"\"\"\n    session = _active_sessions.pop(printer_id, None)\n    if session:\n        session.cleanup()\n        logger.info(\"Cancelled timelapse session for printer %s\", printer_id)\n\n\ndef get_active_sessions() -> dict[int, TimelapseSession]:\n    \"\"\"Get all active timelapse sessions.\"\"\"\n    return _active_sessions.copy()\n"
  },
  {
    "path": "backend/app/services/ldap_service.py",
    "content": "\"\"\"LDAP authentication service for BamBuddy (#794).\n\nSupports:\n- LDAP bind authentication (simple bind with user's credentials)\n- StartTLS, LDAPS, and plaintext connections\n- User search with configurable filter\n- Group membership resolution for role mapping\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\n\nfrom ldap3 import ALL, SUBTREE, Connection, Server, Tls\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass LDAPUserInfo:\n    \"\"\"User information retrieved from LDAP after successful authentication.\"\"\"\n\n    username: str\n    email: str | None\n    display_name: str | None\n    groups: list[str]  # List of group DNs the user belongs to\n\n\n@dataclass\nclass LDAPConfig:\n    \"\"\"LDAP configuration parsed from settings.\"\"\"\n\n    server_url: str\n    bind_dn: str\n    bind_password: str\n    search_base: str\n    user_filter: str  # e.g. \"(sAMAccountName={username})\"\n    security: str  # \"none\", \"starttls\", \"ldaps\"\n    group_mapping: dict[str, str]  # LDAP group DN -> BamBuddy group name\n    auto_provision: bool\n    ca_cert_path: str  # Path to CA certificate file (empty = skip verification)\n    default_group: str  # Fallback BamBuddy group assigned when user has no mapped groups (empty = no fallback)\n\n\ndef parse_ldap_config(settings: dict[str, str]) -> LDAPConfig | None:\n    \"\"\"Parse LDAP config from settings key-value pairs. Returns None if LDAP not enabled.\"\"\"\n    if settings.get(\"ldap_enabled\", \"false\").lower() != \"true\":\n        return None\n\n    server_url = settings.get(\"ldap_server_url\", \"\").strip()\n    if not server_url:\n        return None\n\n    group_mapping_raw = settings.get(\"ldap_group_mapping\", \"\")\n    try:\n        group_mapping = json.loads(group_mapping_raw) if group_mapping_raw else {}\n    except json.JSONDecodeError:\n        group_mapping = {}\n\n    return LDAPConfig(\n        server_url=server_url,\n        bind_dn=settings.get(\"ldap_bind_dn\", \"\").strip(),\n        bind_password=settings.get(\"ldap_bind_password\", \"\"),\n        search_base=settings.get(\"ldap_search_base\", \"\").strip(),\n        user_filter=settings.get(\"ldap_user_filter\", \"(sAMAccountName={username})\").strip(),\n        security=settings.get(\"ldap_security\", \"starttls\").strip(),\n        group_mapping=group_mapping if isinstance(group_mapping, dict) else {},\n        auto_provision=settings.get(\"ldap_auto_provision\", \"false\").lower() == \"true\",\n        ca_cert_path=settings.get(\"ldap_ca_cert_path\", \"\").strip(),\n        default_group=settings.get(\"ldap_default_group\", \"\").strip(),\n    )\n\n\ndef _create_server(config: LDAPConfig) -> Server:\n    \"\"\"Create an ldap3 Server instance from config.\n\n    Always uses TLS — either LDAPS (TLS from start) or StartTLS (upgrade after connect).\n    Plaintext LDAP is not supported.\n    \"\"\"\n    import ssl\n\n    use_ssl = config.security == \"ldaps\" or config.server_url.startswith(\"ldaps://\")\n\n    if config.ca_cert_path:\n        tls = Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=config.ca_cert_path)\n    else:\n        tls = Tls(validate=ssl.CERT_NONE)\n\n    return Server(config.server_url, use_ssl=use_ssl, tls=tls, get_info=ALL, connect_timeout=10)\n\n\ndef authenticate_ldap_user(config: LDAPConfig, username: str, password: str) -> LDAPUserInfo | None:\n    \"\"\"Authenticate a user via LDAP bind.\n\n    1. Bind with service account to search for the user DN\n    2. Attempt bind with the user's DN and provided password\n    3. On success, retrieve user attributes and group memberships\n\n    Returns LDAPUserInfo on success, None on failure.\n    \"\"\"\n    if not password:\n        return None\n\n    server = _create_server(config)\n\n    # Step 1: Service account bind + user search\n    try:\n        service_conn = Connection(\n            server,\n            user=config.bind_dn,\n            password=config.bind_password,\n            auto_bind=False,\n            raise_exceptions=True,\n            read_only=True,\n        )\n        service_conn.open()\n        if config.security == \"starttls\" and not config.server_url.startswith(\"ldaps://\"):\n            service_conn.start_tls()\n        service_conn.bind()\n    except Exception as e:\n        logger.warning(\"LDAP service account bind failed: %s\", e)\n        return None\n\n    try:\n        # Search for the user\n        search_filter = config.user_filter.replace(\"{username}\", _ldap_escape(username))\n        service_conn.search(\n            search_base=config.search_base,\n            search_filter=search_filter,\n            search_scope=SUBTREE,\n            attributes=[\"*\"],\n        )\n\n        if not service_conn.entries:\n            logger.info(\"LDAP user not found: %s\", username)\n            return None\n\n        user_entry = service_conn.entries[0]\n        user_dn = str(user_entry.entry_dn)\n\n        # Step 2: Bind as the user to verify password\n        try:\n            user_conn = Connection(\n                server,\n                user=user_dn,\n                password=password,\n                auto_bind=False,\n                raise_exceptions=True,\n                read_only=True,\n            )\n            user_conn.open()\n            if config.security == \"starttls\" and not config.server_url.startswith(\"ldaps://\"):\n                user_conn.start_tls()\n            user_conn.bind()\n            user_conn.unbind()\n        except Exception as e:\n            logger.info(\"LDAP bind failed for user %s: %s\", username, e)\n            return None\n\n        # Step 3: Extract user info\n        email = str(user_entry.mail) if hasattr(user_entry, \"mail\") and user_entry.mail else None\n        display_name = (\n            str(user_entry.displayName) if hasattr(user_entry, \"displayName\") and user_entry.displayName else None\n        )\n\n        # Collect groups from memberOf attribute (Active Directory / groupOfNames)\n        groups = (\n            [str(g) for g in user_entry.memberOf] if hasattr(user_entry, \"memberOf\") and user_entry.memberOf else []\n        )\n\n        # Also search for POSIX groups (memberUid-based) using the service account\n        canonical_username = username\n        if hasattr(user_entry, \"sAMAccountName\") and user_entry.sAMAccountName:\n            canonical_username = str(user_entry.sAMAccountName)\n        elif hasattr(user_entry, \"uid\") and user_entry.uid:\n            canonical_username = str(user_entry.uid)\n\n        posix_filter = f\"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))\"\n        service_conn.search(\n            search_base=config.search_base,\n            search_filter=posix_filter,\n            search_scope=SUBTREE,\n            attributes=[\"cn\"],\n        )\n        for entry in service_conn.entries:\n            groups.append(str(entry.entry_dn))\n\n        # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.\n        # Standard Unix semantics treat this as full group membership, so we need\n        # to resolve it to a group DN alongside the memberUid results.\n        if hasattr(user_entry, \"gidNumber\") and user_entry.gidNumber:\n            primary_gid = str(user_entry.gidNumber)\n            primary_filter = f\"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))\"\n            service_conn.search(\n                search_base=config.search_base,\n                search_filter=primary_filter,\n                search_scope=SUBTREE,\n                attributes=[\"cn\"],\n            )\n            for entry in service_conn.entries:\n                groups.append(str(entry.entry_dn))\n\n        # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).\n        # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.\n        seen_lower: set[str] = set()\n        deduped_groups: list[str] = []\n        for g in groups:\n            key = g.lower()\n            if key not in seen_lower:\n                seen_lower.add(key)\n                deduped_groups.append(g)\n        groups = deduped_groups\n\n        logger.info(\n            \"LDAP authentication successful for user: %s (DN: %s, groups: %d)\", canonical_username, user_dn, len(groups)\n        )\n\n        return LDAPUserInfo(\n            username=canonical_username,\n            email=email,\n            display_name=display_name,\n            groups=groups,\n        )\n    finally:\n        service_conn.unbind()\n\n\ndef resolve_group_mapping(ldap_groups: list[str], group_mapping: dict[str, str]) -> list[str]:\n    \"\"\"Map LDAP group DNs to BamBuddy group names.\n\n    Returns list of BamBuddy group names that the user should be added to.\n    Comparison is case-insensitive on the LDAP group DN.\n    \"\"\"\n    if not group_mapping:\n        return []\n\n    # Build case-insensitive lookup\n    mapping_lower = {k.lower(): v for k, v in group_mapping.items()}\n    result = []\n    for ldap_group in ldap_groups:\n        bambuddy_group = mapping_lower.get(ldap_group.lower())\n        if bambuddy_group:\n            result.append(bambuddy_group)\n    return result\n\n\ndef test_ldap_connection(config: LDAPConfig) -> tuple[bool, str]:\n    \"\"\"Test LDAP connection and service account bind.\n\n    Returns (success, message).\n    \"\"\"\n    try:\n        server = _create_server(config)\n        conn = Connection(\n            server,\n            user=config.bind_dn,\n            password=config.bind_password,\n            auto_bind=False,\n            raise_exceptions=True,\n            read_only=True,\n        )\n        conn.open()\n        if config.security == \"starttls\" and not config.server_url.startswith(\"ldaps://\"):\n            conn.start_tls()\n        conn.bind()\n\n        # Try a search to verify search base\n        conn.search(\n            search_base=config.search_base,\n            search_filter=\"(objectClass=*)\",\n            search_scope=SUBTREE,\n            size_limit=1,\n        )\n        conn.unbind()\n        return True, \"LDAP connection successful\"\n    except Exception as e:\n        return False, f\"LDAP connection failed: {e}\"\n\n\ndef _ldap_escape(value: str) -> str:\n    \"\"\"Escape special characters in LDAP search filter values (RFC 4515).\"\"\"\n    replacements = {\n        \"\\\\\": \"\\\\5c\",\n        \"*\": \"\\\\2a\",\n        \"(\": \"\\\\28\",\n        \")\": \"\\\\29\",\n        \"\\x00\": \"\\\\00\",\n    }\n    for char, escaped in replacements.items():\n        value = value.replace(char, escaped)\n    return value\n"
  },
  {
    "path": "backend/app/services/local_backup.py",
    "content": "\"\"\"Scheduled local backup service.\n\nCreates ZIP snapshots of the full Bambuddy data (database + data directories)\non a configurable schedule with retention management.\n\"\"\"\n\nimport asyncio\nimport logging\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom sqlalchemy import select\n\nfrom backend.app.core.config import settings as app_settings\nfrom backend.app.core.database import async_session\nfrom backend.app.models.settings import Settings\n\nlogger = logging.getLogger(__name__)\n\nSCHEDULE_INTERVALS = {\n    \"hourly\": 3600,\n    \"daily\": 86400,\n    \"weekly\": 604800,\n}\n\n\ndef _default_backup_dir() -> Path:\n    return app_settings.base_dir / \"backups\"\n\n\nclass LocalBackupService:\n    \"\"\"Manages scheduled local backup snapshots with retention.\"\"\"\n\n    def __init__(self):\n        self._scheduler_task: asyncio.Task | None = None\n        self._check_interval = 60\n        self._running: bool = False\n        self._last_backup_at: str | None = None\n        self._last_status: str | None = None\n        self._last_message: str | None = None\n        self._next_run: datetime | None = None\n\n    async def start_scheduler(self):\n        \"\"\"Start the background scheduler loop.\"\"\"\n        if self._scheduler_task is not None:\n            return\n        logger.info(\"Starting local backup scheduler\")\n        # Seed next_run from settings so the first check has a target\n        await self._seed_next_run()\n        self._scheduler_task = asyncio.create_task(self._scheduler_loop())\n\n    def stop_scheduler(self):\n        \"\"\"Stop the scheduler.\"\"\"\n        if self._scheduler_task:\n            self._scheduler_task.cancel()\n            self._scheduler_task = None\n            logger.info(\"Stopped local backup scheduler\")\n\n    async def _scheduler_loop(self):\n        \"\"\"Main scheduler loop — checks for due backups every minute.\"\"\"\n        while True:\n            try:\n                await asyncio.sleep(self._check_interval)\n                await self._check_scheduled_backup()\n            except asyncio.CancelledError:\n                break\n            except Exception as e:\n                logger.error(\"Error in local backup scheduler: %s\", e)\n                await asyncio.sleep(60)\n\n    async def _seed_next_run(self):\n        \"\"\"Load settings and calculate initial next_run.\"\"\"\n        try:\n            settings = await self._load_settings()\n            if settings.get(\"enabled\"):\n                self._next_run = self._calculate_next_run(\n                    settings.get(\"schedule\", \"daily\"),\n                    settings.get(\"time\", \"03:00\"),\n                )\n        except Exception as e:\n            logger.debug(\"Could not seed local backup next_run: %s\", e)\n\n    async def _load_settings(self) -> dict:\n        \"\"\"Read local backup settings from the DB.\"\"\"\n        async with async_session() as db:\n            keys = [\n                \"local_backup_enabled\",\n                \"local_backup_schedule\",\n                \"local_backup_time\",\n                \"local_backup_retention\",\n                \"local_backup_path\",\n            ]\n            result = await db.execute(select(Settings).where(Settings.key.in_(keys)))\n            rows = {r.key: r.value for r in result.scalars().all()}\n        return {\n            \"enabled\": rows.get(\"local_backup_enabled\", \"false\").lower() == \"true\",\n            \"schedule\": rows.get(\"local_backup_schedule\", \"daily\"),\n            \"time\": rows.get(\"local_backup_time\", \"03:00\"),\n            \"retention\": int(rows.get(\"local_backup_retention\", \"5\")),\n            \"path\": rows.get(\"local_backup_path\", \"\"),\n        }\n\n    async def _check_scheduled_backup(self):\n        \"\"\"Check if a scheduled backup is due and run it.\"\"\"\n        settings = await self._load_settings()\n        if not settings[\"enabled\"]:\n            self._next_run = None\n            return\n\n        now = datetime.now(timezone.utc)\n\n        # If no next_run set, schedule one\n        if self._next_run is None:\n            self._next_run = self._calculate_next_run(settings[\"schedule\"], settings[\"time\"])\n            return\n\n        if self._next_run <= now:\n            logger.info(\"Running scheduled local backup\")\n            await self.run_backup(settings)\n            self._next_run = self._calculate_next_run(settings[\"schedule\"], settings[\"time\"])\n\n    def _calculate_next_run(self, schedule_type: str, time_str: str = \"03:00\") -> datetime:\n        \"\"\"Calculate the next scheduled run time.\n\n        For hourly: next full hour.\n        For daily/weekly: next occurrence of the configured time (HH:MM).\n        \"\"\"\n        now = datetime.now(timezone.utc)\n\n        if schedule_type == \"hourly\":\n            # Next full hour\n            next_run = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)\n            return next_run\n\n        # Parse HH:MM time\n        try:\n            parts = time_str.strip().split(\":\")\n            hour = int(parts[0])\n            minute = int(parts[1]) if len(parts) > 1 else 0\n        except (ValueError, IndexError):\n            hour, minute = 3, 0\n\n        # Next occurrence of this time today or tomorrow\n        next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)\n        if next_run <= now:\n            next_run += timedelta(days=1)\n\n        if schedule_type == \"weekly\":\n            next_run += timedelta(weeks=1)\n\n        return next_run\n\n    def _resolve_backup_dir(self, path_setting: str) -> Path:\n        \"\"\"Resolve the backup output directory from settings.\"\"\"\n        if path_setting.strip():\n            return Path(path_setting.strip())\n        return _default_backup_dir()\n\n    async def run_backup(self, settings: dict | None = None) -> dict:\n        \"\"\"Run a backup now. Returns {success, message, filename}.\"\"\"\n        if self._running:\n            return {\"success\": False, \"message\": \"Backup already in progress\"}\n\n        self._running = True\n        try:\n            if settings is None:\n                settings = await self._load_settings()\n\n            backup_dir = self._resolve_backup_dir(settings[\"path\"])\n            backup_dir.mkdir(parents=True, exist_ok=True)\n\n            from backend.app.api.routes.settings import create_backup_zip\n\n            zip_path, filename = await create_backup_zip(output_path=backup_dir)\n\n            # Prune old backups\n            retention = max(1, settings[\"retention\"])\n            self._prune_backups(backup_dir, retention)\n\n            self._last_backup_at = datetime.now(timezone.utc).isoformat()\n            self._last_status = \"success\"\n            self._last_message = filename\n            logger.info(\"Local backup created: %s\", zip_path)\n            return {\"success\": True, \"message\": \"Backup created\", \"filename\": filename}\n\n        except Exception as e:\n            self._last_backup_at = datetime.now(timezone.utc).isoformat()\n            self._last_status = \"failed\"\n            self._last_message = str(e)\n            logger.error(\"Local backup failed: %s\", e, exc_info=True)\n            return {\"success\": False, \"message\": f\"Backup failed: {e}\"}\n        finally:\n            self._running = False\n\n    def _prune_backups(self, backup_dir: Path, retention: int):\n        \"\"\"Delete oldest backups exceeding the retention count.\"\"\"\n        backups = sorted(\n            backup_dir.glob(\"bambuddy-backup-*.zip\"),\n            key=lambda p: p.stat().st_mtime,\n            reverse=True,\n        )\n        for old_backup in backups[retention:]:\n            try:\n                old_backup.unlink()\n                logger.info(\"Pruned old backup: %s\", old_backup.name)\n            except OSError as e:\n                logger.warning(\"Could not delete old backup %s: %s\", old_backup.name, e)\n\n    def get_status(self) -> dict:\n        \"\"\"Return current scheduler status.\"\"\"\n        return {\n            \"is_running\": self._running,\n            \"last_backup_at\": self._last_backup_at,\n            \"last_status\": self._last_status,\n            \"last_message\": self._last_message,\n            \"next_run\": self._next_run.isoformat() if self._next_run else None,\n        }\n\n    def resolve_backup_file(self, path_setting: str, filename: str) -> Path | None:\n        \"\"\"Resolve a backup filename to a full path, with safety checks.\"\"\"\n        if \"/\" in filename or \"\\\\\" in filename or \"..\" in filename:\n            return None\n        if not filename.startswith(\"bambuddy-backup-\") or not filename.endswith(\".zip\"):\n            return None\n        backup_dir = self._resolve_backup_dir(path_setting)\n        target = backup_dir / filename\n        if not target.exists():\n            return None\n        return target\n\n    def list_backups(self, path_setting: str) -> list[dict]:\n        \"\"\"List backup ZIP files in the backup directory.\"\"\"\n        backup_dir = self._resolve_backup_dir(path_setting)\n        if not backup_dir.exists():\n            return []\n\n        backups = []\n        for f in sorted(backup_dir.glob(\"bambuddy-backup-*.zip\"), key=lambda p: p.stat().st_mtime, reverse=True):\n            stat = f.stat()\n            backups.append(\n                {\n                    \"filename\": f.name,\n                    \"size\": stat.st_size,\n                    \"created_at\": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),\n                }\n            )\n        return backups\n\n    def delete_backup(self, path_setting: str, filename: str) -> dict:\n        \"\"\"Delete a specific backup file. Returns {success, message}.\"\"\"\n        # Path traversal protection\n        if \"/\" in filename or \"\\\\\" in filename or \"..\" in filename:\n            return {\"success\": False, \"message\": \"Invalid filename\"}\n\n        backup_dir = self._resolve_backup_dir(path_setting)\n        target = backup_dir / filename\n\n        if not target.exists():\n            return {\"success\": False, \"message\": \"Backup not found\"}\n        if not target.name.startswith(\"bambuddy-backup-\") or not target.name.endswith(\".zip\"):\n            return {\"success\": False, \"message\": \"Invalid backup file\"}\n\n        try:\n            target.unlink()\n            return {\"success\": True, \"message\": \"Backup deleted\"}\n        except OSError as e:\n            return {\"success\": False, \"message\": f\"Could not delete: {e}\"}\n\n\nlocal_backup_service = LocalBackupService()\n"
  },
  {
    "path": "backend/app/services/mqtt_relay.py",
    "content": "\"\"\"MQTT Relay Service for publishing BamBuddy events to external MQTT brokers.\n\nThis service enables integration with external automation systems like\nNode-RED, Home Assistant, and other MQTT-based platforms.\n\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport ssl\nimport threading\nimport time\nfrom datetime import datetime, timezone\nfrom typing import Any\n\nimport paho.mqtt.client as mqtt\n\nlogger = logging.getLogger(__name__)\n\n\nclass MQTTRelayService:\n    \"\"\"Publishes BamBuddy events to an external MQTT broker.\"\"\"\n\n    # Minimum interval between status updates per printer (seconds)\n    STATUS_THROTTLE_SECONDS = 1.0\n\n    def __init__(self):\n        self.client: mqtt.Client | None = None\n        self.enabled = False\n        self.connected = False\n        self.topic_prefix = \"bambuddy\"\n        self._lock = threading.Lock()\n        self._loop: asyncio.AbstractEventLoop | None = None\n        self._broker = \"\"\n        self._port = 1883\n        self._last_printer_status: dict[int, float] = {}  # printer_id -> last publish timestamp\n        self._smart_plug_service = None  # Lazy import to avoid circular dependency\n        self._settings: dict = {}  # Store settings for smart plug service\n        self._disconnection_event: threading.Event | None = None\n\n    async def configure(self, settings: dict) -> bool:\n        \"\"\"Configure MQTT connection from settings.\n\n        Returns True if connection was successful or MQTT is disabled.\n        \"\"\"\n        self.enabled = settings.get(\"mqtt_enabled\", False)\n        self._settings = settings  # Store for smart plug service\n\n        if not self.enabled:\n            await self.disconnect()\n            # Also configure smart plug service (will disable it)\n            await self._configure_smart_plug_service(settings)\n            logger.info(\"MQTT relay disabled\")\n            return True\n\n        broker = settings.get(\"mqtt_broker\", \"\")\n        port = settings.get(\"mqtt_port\", 1883)\n        username = settings.get(\"mqtt_username\", \"\")\n        password = settings.get(\"mqtt_password\", \"\")\n        self.topic_prefix = settings.get(\"mqtt_topic_prefix\", \"bambuddy\")\n        use_tls = settings.get(\"mqtt_use_tls\", False)\n\n        if not broker:\n            logger.warning(\"MQTT enabled but no broker configured\")\n            return False\n\n        # Store for status endpoint\n        self._broker = broker\n        self._port = port\n\n        # Disconnect existing connection if settings changed\n        if self.client:\n            await self.disconnect()\n\n        # Create and connect client\n        result = await self._connect(broker, port, username, password, use_tls)\n\n        # Configure smart plug service with same settings\n        await self._configure_smart_plug_service(settings)\n\n        return result\n\n    async def _configure_smart_plug_service(self, settings: dict):\n        \"\"\"Configure the MQTT smart plug service with the same broker settings.\"\"\"\n        try:\n            if self._smart_plug_service is None:\n                from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service\n\n                self._smart_plug_service = mqtt_smart_plug_service\n\n            await self._smart_plug_service.configure(settings)\n        except Exception as e:\n            logger.error(\"Failed to configure MQTT smart plug service: %s\", e)\n\n    @property\n    def smart_plug_service(self):\n        \"\"\"Get the MQTT smart plug service instance.\"\"\"\n        if self._smart_plug_service is None:\n            from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service\n\n            self._smart_plug_service = mqtt_smart_plug_service\n        return self._smart_plug_service\n\n    async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:\n        \"\"\"Establish MQTT connection.\"\"\"\n        try:\n            # Create client with callback API version 2 (use MQTTv311 for broader compatibility)\n            self.client = mqtt.Client(\n                callback_api_version=mqtt.CallbackAPIVersion.VERSION2,\n                client_id=f\"bambuddy-{id(self)}\",\n                protocol=mqtt.MQTTv311,\n            )\n\n            # Set up callbacks\n            self.client.on_connect = self._on_connect\n            self.client.on_disconnect = self._on_disconnect\n\n            # Configure authentication\n            if username:\n                self.client.username_pw_set(username, password)\n\n            # Configure TLS (allow self-signed certs for testing)\n            if use_tls:\n                self.client.tls_set(cert_reqs=ssl.CERT_NONE)\n                self.client.tls_insecure_set(True)  # Allow self-signed certs\n\n            # Run connect_async in thread pool with timeout to avoid blocking\n            # on unreachable brokers (connect_async does synchronous socket creation)\n            try:\n                await asyncio.wait_for(asyncio.to_thread(self.client.connect_async, broker, port, 60), timeout=3.0)\n            except TimeoutError:\n                logger.warning(\"MQTT relay connection to %s:%s timed out\", broker, port)\n                return False\n\n            self.client.loop_start()\n\n            # Wait briefly for connection callback\n            await asyncio.sleep(1.0)\n\n            if self.connected:\n                logger.info(\"MQTT relay connected to %s:%s\", broker, port)\n                # Publish online status\n                self._publish_status(\"online\")\n                return True\n            else:\n                logger.warning(\"MQTT relay connection pending to %s:%s\", broker, port)\n                return True  # Connection is async, may succeed later\n\n        except Exception as e:\n            logger.error(\"MQTT relay connection failed: %s\", e)\n            self.connected = False\n            return False\n\n    def _on_connect(\n        self,\n        client: mqtt.Client,\n        userdata: Any,\n        flags: dict,\n        reason_code: int | mqtt.ReasonCode,\n        properties: mqtt.Properties | None = None,\n    ):\n        \"\"\"Callback when connected to broker.\"\"\"\n        # Handle both MQTTv311 (int) and MQTTv5 (ReasonCode) return codes\n        rc = reason_code if isinstance(reason_code, int) else reason_code.value\n        if rc == 0:\n            self.connected = True\n            logger.info(\"MQTT relay connected successfully\")\n            # Publish online status\n            self._publish_status(\"online\")\n        else:\n            self.connected = False\n            logger.error(\"MQTT relay connection failed: %s\", reason_code)\n\n    def _on_disconnect(\n        self,\n        client: mqtt.Client,\n        userdata: Any,\n        flags_or_rc: dict | int | mqtt.ReasonCode,\n        reason_code: int | mqtt.ReasonCode | None = None,\n        properties: mqtt.Properties | None = None,\n    ):\n        \"\"\"Callback when disconnected from broker.\"\"\"\n        self.connected = False\n        # Handle both MQTTv311 (rc as 3rd param) and MQTTv5 (flags, rc, props)\n        rc = reason_code if reason_code is not None else flags_or_rc\n        rc_val = rc if isinstance(rc, int) else getattr(rc, \"value\", 0)\n        if rc_val != 0:\n            logger.warning(\"MQTT relay disconnected: %s\", rc)\n        else:\n            logger.info(\"MQTT relay disconnected cleanly\")\n        if self._disconnection_event:\n            self._disconnection_event.set()\n\n    async def disconnect(self, timeout: float = 0):\n        \"\"\"Disconnect from MQTT broker.\"\"\"\n        if self.client:\n            try:\n                # Publish offline status before disconnecting\n                self._publish_status(\"offline\")\n                self._disconnection_event = threading.Event()\n                self.client.disconnect()\n                await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)\n                self.client.loop_stop()\n            except Exception as e:\n                logger.debug(\"MQTT disconnect error (ignored): %s\", e)\n            finally:\n                self.client = None\n                self.connected = False\n\n    def _publish_status(self, status: str):\n        \"\"\"Publish BamBuddy status (online/offline).\"\"\"\n        self._publish(\n            f\"{self.topic_prefix}/status\",\n            {\"status\": status, \"timestamp\": datetime.now(timezone.utc).isoformat()},\n            retain=True,\n        )\n\n    def _publish(self, topic: str, payload: dict, retain: bool = False):\n        \"\"\"Publish message to MQTT broker.\"\"\"\n        if not self.client or not self.connected:\n            return\n\n        try:\n            with self._lock:\n                self.client.publish(topic, json.dumps(payload, default=str), qos=1, retain=retain)\n        except Exception as e:\n            logger.debug(\"MQTT publish error: %s\", e)\n\n    def get_status(self) -> dict:\n        \"\"\"Get current MQTT relay status for API.\"\"\"\n        return {\n            \"enabled\": self.enabled,\n            \"connected\": self.connected,\n            \"broker\": self._broker if self.enabled else \"\",\n            \"port\": self._port if self.enabled else 0,\n            \"topic_prefix\": self.topic_prefix,\n        }\n\n    # =========================================================================\n    # Printer Events\n    # =========================================================================\n\n    async def on_printer_status(self, printer_id: int, state: Any, printer_name: str, printer_serial: str):\n        \"\"\"Publish printer status change (throttled to 1 update/sec per printer).\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        # Throttle status updates to avoid flooding MQTT broker\n        now = time.time()\n        last_publish = self._last_printer_status.get(printer_id, 0)\n        if now - last_publish < self.STATUS_THROTTLE_SECONDS:\n            return  # Skip this update, too soon since last publish\n        self._last_printer_status[printer_id] = now\n\n        # Build status payload from PrinterState\n        payload = {\n            \"printer_id\": printer_id,\n            \"printer_name\": printer_name,\n            \"printer_serial\": printer_serial,\n            \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            \"connected\": state.connected,\n            \"state\": state.state,\n            \"progress\": state.progress,\n            \"remaining_time\": state.remaining_time,\n            \"layer_num\": state.layer_num,\n            \"total_layers\": state.total_layers,\n            \"current_print\": state.current_print,\n            \"subtask_name\": state.subtask_name,\n            \"gcode_file\": state.gcode_file,\n            \"temperatures\": state.temperatures,\n            \"wifi_signal\": state.wifi_signal,\n            \"chamber_light\": state.chamber_light,\n            \"speed_level\": state.speed_level,\n            \"cooling_fan_speed\": state.cooling_fan_speed,\n            \"big_fan1_speed\": state.big_fan1_speed,\n            \"big_fan2_speed\": state.big_fan2_speed,\n            \"heatbreak_fan_speed\": state.heatbreak_fan_speed,\n        }\n\n        self._publish(\n            f\"{self.topic_prefix}/printers/{printer_serial}/status\",\n            payload,\n            retain=True,\n        )\n\n    async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):\n        \"\"\"Publish printer came online event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/printers/{printer_serial}/online\",\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"printer_serial\": printer_serial,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_printer_offline(self, printer_id: int, printer_name: str, printer_serial: str):\n        \"\"\"Publish printer went offline event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/printers/{printer_serial}/offline\",\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"printer_serial\": printer_serial,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_print_start(\n        self,\n        printer_id: int,\n        printer_name: str,\n        printer_serial: str,\n        filename: str,\n        subtask_name: str,\n    ):\n        \"\"\"Publish print started event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/printers/{printer_serial}/print/started\",\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"printer_serial\": printer_serial,\n                \"filename\": filename,\n                \"subtask_name\": subtask_name,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_print_complete(\n        self,\n        printer_id: int,\n        printer_name: str,\n        printer_serial: str,\n        filename: str,\n        subtask_name: str,\n        status: str,\n    ):\n        \"\"\"Publish print completed event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        # Determine topic based on status\n        if status == \"completed\":\n            topic = f\"{self.topic_prefix}/printers/{printer_serial}/print/completed\"\n        else:\n            topic = f\"{self.topic_prefix}/printers/{printer_serial}/print/failed\"\n\n        self._publish(\n            topic,\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"printer_serial\": printer_serial,\n                \"filename\": filename,\n                \"subtask_name\": subtask_name,\n                \"status\": status,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_ams_change(\n        self,\n        printer_id: int,\n        printer_name: str,\n        printer_serial: str,\n        ams_data: list,\n    ):\n        \"\"\"Publish AMS filament change event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/printers/{printer_serial}/ams/changed\",\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"printer_serial\": printer_serial,\n                \"ams_units\": ams_data,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_printer_error(\n        self,\n        printer_id: int,\n        printer_name: str,\n        printer_serial: str,\n        errors: list,\n    ):\n        \"\"\"Publish printer HMS error event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/printers/{printer_serial}/error\",\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"printer_serial\": printer_serial,\n                \"errors\": errors,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    # =========================================================================\n    # Print Queue Events\n    # =========================================================================\n\n    async def on_queue_job_added(\n        self,\n        job_id: int,\n        filename: str,\n        printer_id: int | None,\n        printer_name: str | None,\n    ):\n        \"\"\"Publish job added to queue event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/queue/job_added\",\n            {\n                \"job_id\": job_id,\n                \"filename\": filename,\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_queue_job_started(\n        self,\n        job_id: int,\n        filename: str,\n        printer_id: int,\n        printer_name: str,\n        printer_serial: str,\n    ):\n        \"\"\"Publish queued job started printing event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/queue/job_started\",\n            {\n                \"job_id\": job_id,\n                \"filename\": filename,\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"printer_serial\": printer_serial,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_queue_job_completed(\n        self,\n        job_id: int,\n        filename: str,\n        printer_id: int,\n        printer_name: str,\n        status: str,\n    ):\n        \"\"\"Publish queued job finished event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        topic = (\n            f\"{self.topic_prefix}/queue/job_completed\"\n            if status == \"completed\"\n            else f\"{self.topic_prefix}/queue/job_failed\"\n        )\n\n        self._publish(\n            topic,\n            {\n                \"job_id\": job_id,\n                \"filename\": filename,\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"status\": status,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    # =========================================================================\n    # Maintenance Events\n    # =========================================================================\n\n    async def on_maintenance_alert(\n        self,\n        printer_id: int,\n        printer_name: str,\n        maintenance_type: str,\n        current_value: float,\n        threshold: float,\n    ):\n        \"\"\"Publish maintenance alert triggered event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/maintenance/alert\",\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"maintenance_type\": maintenance_type,\n                \"current_value\": current_value,\n                \"threshold\": threshold,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_maintenance_acknowledged(\n        self,\n        printer_id: int,\n        printer_name: str,\n        maintenance_type: str,\n    ):\n        \"\"\"Publish maintenance alert acknowledged event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/maintenance/acknowledged\",\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"maintenance_type\": maintenance_type,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_maintenance_reset(\n        self,\n        printer_id: int,\n        printer_name: str,\n        maintenance_type: str,\n    ):\n        \"\"\"Publish maintenance counter reset event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/maintenance/reset\",\n            {\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"maintenance_type\": maintenance_type,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    # =========================================================================\n    # Archive Events\n    # =========================================================================\n\n    async def on_archive_created(\n        self,\n        archive_id: int,\n        print_name: str,\n        printer_name: str,\n        status: str,\n    ):\n        \"\"\"Publish print archived event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/archive/created\",\n            {\n                \"archive_id\": archive_id,\n                \"print_name\": print_name,\n                \"printer_name\": printer_name,\n                \"status\": status,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_archive_updated(\n        self,\n        archive_id: int,\n        print_name: str,\n        status: str,\n    ):\n        \"\"\"Publish archive record updated event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/archive/updated\",\n            {\n                \"archive_id\": archive_id,\n                \"print_name\": print_name,\n                \"status\": status,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    # =========================================================================\n    # Filament/Spoolman Events\n    # =========================================================================\n\n    async def on_filament_low(\n        self,\n        spool_id: int,\n        spool_name: str,\n        remaining_weight: float,\n        remaining_percent: float,\n    ):\n        \"\"\"Publish filament inventory low event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/filament/low\",\n            {\n                \"spool_id\": spool_id,\n                \"spool_name\": spool_name,\n                \"remaining_weight\": remaining_weight,\n                \"remaining_percent\": remaining_percent,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    # =========================================================================\n    # Smart Plug Events\n    # =========================================================================\n\n    async def on_smart_plug_state(\n        self,\n        plug_id: int,\n        plug_name: str,\n        state: str,\n        printer_id: int | None = None,\n        printer_name: str | None = None,\n    ):\n        \"\"\"Publish smart plug state change event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        topic = f\"{self.topic_prefix}/smart_plugs/on\" if state == \"on\" else f\"{self.topic_prefix}/smart_plugs/off\"\n\n        self._publish(\n            topic,\n            {\n                \"plug_id\": plug_id,\n                \"plug_name\": plug_name,\n                \"state\": state,\n                \"printer_id\": printer_id,\n                \"printer_name\": printer_name,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n    async def on_smart_plug_energy(\n        self,\n        plug_id: int,\n        plug_name: str,\n        power: float,\n        energy_today: float,\n        energy_total: float,\n    ):\n        \"\"\"Publish smart plug energy update event.\"\"\"\n        if not self.enabled or not self.connected:\n            return\n\n        self._publish(\n            f\"{self.topic_prefix}/smart_plugs/energy\",\n            {\n                \"plug_id\": plug_id,\n                \"plug_name\": plug_name,\n                \"power_watts\": power,\n                \"energy_today_kwh\": energy_today,\n                \"energy_total_kwh\": energy_total,\n                \"timestamp\": datetime.now(timezone.utc).isoformat(),\n            },\n        )\n\n\n# Global instance\nmqtt_relay = MQTTRelayService()\n"
  },
  {
    "path": "backend/app/services/mqtt_smart_plug.py",
    "content": "\"\"\"MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data.\n\nThis service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.\n\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport threading\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timedelta, timezone\nfrom typing import Any\n\nimport paho.mqtt.client as mqtt\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass SmartPlugMQTTData:\n    \"\"\"Latest data received from an MQTT smart plug.\"\"\"\n\n    plug_id: int\n    power: float | None = None  # Current power in watts\n    energy: float | None = None  # Energy in kWh (today)\n    state: str | None = None  # \"ON\" or \"OFF\"\n    last_seen: datetime = field(default_factory=datetime.utcnow)\n\n\n@dataclass\nclass MQTTDataSourceConfig:\n    \"\"\"Configuration for a single MQTT data source (power, energy, or state).\"\"\"\n\n    topic: str\n    path: str\n    multiplier: float = 1.0  # For power/energy\n    on_value: str | None = None  # For state (what value means \"ON\")\n\n\nclass MQTTSmartPlugService:\n    \"\"\"Subscribes to MQTT topics for smart plug energy monitoring.\"\"\"\n\n    # Consider plug unreachable if no message received in this time\n    REACHABLE_TIMEOUT_MINUTES = 5\n\n    def __init__(self):\n        self.client: mqtt.Client | None = None\n        self.connected = False\n        self._lock = threading.Lock()\n        # topic -> list of (plug_id, data_type) where data_type is \"power\", \"energy\", or \"state\"\n        self.subscriptions: dict[str, list[tuple[int, str]]] = {}\n        # plug_id -> {data_type: MQTTDataSourceConfig}\n        self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}\n        # plug_id -> latest data\n        self.plug_data: dict[int, SmartPlugMQTTData] = {}\n        self._disconnection_event: threading.Event | None = None\n        self._configured = False\n        self._broker = \"\"\n        self._port = 1883\n        self._username = \"\"\n        self._password = \"\"\n        self._use_tls = False\n\n    def is_configured(self) -> bool:\n        \"\"\"Check if the MQTT service is configured and connected.\"\"\"\n        return self._configured and self.connected\n\n    def has_broker_settings(self) -> bool:\n        \"\"\"Check if broker settings are available (even if not connected yet).\"\"\"\n        return bool(self._broker)\n\n    async def configure(self, settings: dict) -> bool:\n        \"\"\"Configure MQTT connection from settings.\n\n        Uses the same broker settings as the MQTT relay service.\n        Returns True if connection was successful or MQTT is disabled.\n        \"\"\"\n        enabled = settings.get(\"mqtt_enabled\", False)\n\n        if not enabled:\n            await self.disconnect()\n            self._configured = False\n            logger.debug(\"MQTT smart plug service disabled (MQTT relay not enabled)\")\n            return True\n\n        broker = settings.get(\"mqtt_broker\", \"\")\n        port = settings.get(\"mqtt_port\", 1883)\n        username = settings.get(\"mqtt_username\", \"\")\n        password = settings.get(\"mqtt_password\", \"\")\n        use_tls = settings.get(\"mqtt_use_tls\", False)\n\n        if not broker:\n            logger.warning(\"MQTT smart plug service: no broker configured\")\n            self._configured = False\n            return False\n\n        # Check if settings changed\n        settings_changed = (\n            self._broker != broker\n            or self._port != port\n            or self._username != username\n            or self._password != password\n            or self._use_tls != use_tls\n        )\n\n        self._broker = broker\n        self._port = port\n        self._username = username\n        self._password = password\n        self._use_tls = use_tls\n        self._configured = True\n\n        # Disconnect and reconnect if settings changed\n        if settings_changed and self.client:\n            await self.disconnect()\n\n        # Connect if not already connected\n        if not self.client or not self.connected:\n            return await self._connect()\n\n        return True\n\n    async def _connect(self) -> bool:\n        \"\"\"Establish MQTT connection.\"\"\"\n        import asyncio\n        import ssl\n\n        try:\n            # Create client with callback API version 2\n            self.client = mqtt.Client(\n                callback_api_version=mqtt.CallbackAPIVersion.VERSION2,\n                client_id=f\"bambuddy-smartplug-{id(self)}\",\n                protocol=mqtt.MQTTv311,\n            )\n\n            # Set up callbacks\n            self.client.on_connect = self._on_connect\n            self.client.on_disconnect = self._on_disconnect\n            self.client.on_message = self._on_message\n\n            # Configure authentication\n            if self._username:\n                self.client.username_pw_set(self._username, self._password)\n\n            # Configure TLS\n            if self._use_tls:\n                self.client.tls_set(cert_reqs=ssl.CERT_NONE)\n                self.client.tls_insecure_set(True)\n\n            # Connect with timeout\n            try:\n                await asyncio.wait_for(\n                    asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60),\n                    timeout=3.0,\n                )\n            except TimeoutError:\n                logger.warning(\"MQTT smart plug connection to %s:%s timed out\", self._broker, self._port)\n                return False\n\n            self.client.loop_start()\n\n            # Wait briefly for connection\n            await asyncio.sleep(1.0)\n\n            if self.connected:\n                logger.info(\"MQTT smart plug service connected to %s:%s\", self._broker, self._port)\n                # Resubscribe to all topics\n                self._resubscribe_all()\n                return True\n            else:\n                logger.warning(\"MQTT smart plug connection pending to %s:%s\", self._broker, self._port)\n                return True  # Connection is async\n\n        except Exception as e:\n            logger.error(\"MQTT smart plug connection failed: %s\", e)\n            self.connected = False\n            return False\n\n    def _on_connect(\n        self,\n        client: mqtt.Client,\n        userdata: Any,\n        flags: dict,\n        reason_code: int | mqtt.ReasonCode,\n        properties: mqtt.Properties | None = None,\n    ):\n        \"\"\"Callback when connected to broker.\"\"\"\n        rc = reason_code if isinstance(reason_code, int) else reason_code.value\n        if rc == 0:\n            self.connected = True\n            logger.info(\"MQTT smart plug service connected successfully\")\n            # Resubscribe to all topics\n            self._resubscribe_all()\n        else:\n            self.connected = False\n            logger.error(\"MQTT smart plug connection failed: %s\", reason_code)\n\n    def _on_disconnect(\n        self,\n        client: mqtt.Client,\n        userdata: Any,\n        flags_or_rc: dict | int | mqtt.ReasonCode,\n        reason_code: int | mqtt.ReasonCode | None = None,\n        properties: mqtt.Properties | None = None,\n    ):\n        \"\"\"Callback when disconnected from broker.\"\"\"\n        self.connected = False\n        rc = reason_code if reason_code is not None else flags_or_rc\n        rc_val = rc if isinstance(rc, int) else getattr(rc, \"value\", 0)\n        if rc_val != 0:\n            logger.warning(\"MQTT smart plug service disconnected: %s\", rc)\n        else:\n            logger.info(\"MQTT smart plug service disconnected cleanly\")\n        if self._disconnection_event:\n            self._disconnection_event.set()\n\n    def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):\n        \"\"\"Handle incoming MQTT message, extract data using JSON path.\"\"\"\n        topic = msg.topic\n\n        with self._lock:\n            subscriptions = self.subscriptions.get(topic, [])\n            if not subscriptions:\n                return\n\n            # Parse JSON payload (or treat as raw value)\n            try:\n                payload = json.loads(msg.payload.decode(\"utf-8\"))\n                is_json = True\n            except (json.JSONDecodeError, UnicodeDecodeError):\n                # Not JSON - treat the whole payload as a raw value\n                payload = msg.payload.decode(\"utf-8\").strip()\n                is_json = False\n\n            # Process for each subscribed (plug_id, data_type)\n            for plug_id, data_type in subscriptions:\n                configs = self.plug_configs.get(plug_id, {})\n                config = configs.get(data_type)\n                if not config:\n                    continue\n\n                # Extract value using path (or use raw payload if no path)\n                if is_json and config.path:\n                    raw_value = self._extract_json_path(payload, config.path)\n                elif is_json and not config.path:\n                    # JSON but no path - if it's a simple value use it, otherwise skip\n                    if isinstance(payload, (int, float, str, bool)):\n                        raw_value = payload\n                    else:\n                        # Can't use a dict/list as a value\n                        logger.debug(\"MQTT plug %s: JSON payload is object/array but no path configured\", plug_id)\n                        continue\n                else:\n                    # Raw value (non-JSON)\n                    raw_value = payload\n\n                if raw_value is None:\n                    continue\n\n                # Initialize plug data if needed\n                if plug_id not in self.plug_data:\n                    self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)\n\n                data = self.plug_data[plug_id]\n                data.last_seen = datetime.now(timezone.utc)\n\n                # Process based on data type\n                if data_type == \"power\":\n                    try:\n                        data.power = float(raw_value) * config.multiplier\n                        logger.debug(\"MQTT smart plug %s: power=%s\", plug_id, data.power)\n                    except (ValueError, TypeError):\n                        pass  # Ignore unparseable power reading from MQTT\n\n                elif data_type == \"energy\":\n                    try:\n                        data.energy = float(raw_value) * config.multiplier\n                        logger.debug(\"MQTT smart plug %s: energy=%s\", plug_id, data.energy)\n                    except (ValueError, TypeError):\n                        pass  # Ignore unparseable energy reading from MQTT\n\n                elif data_type == \"state\":\n                    state_str = str(raw_value)\n                    # Check against configured ON value if set\n                    if config.on_value:\n                        # Case-insensitive comparison\n                        if state_str.lower() == config.on_value.lower():\n                            data.state = \"ON\"\n                        else:\n                            data.state = \"OFF\"\n                    else:\n                        # Default behavior: normalize common values\n                        upper_state = state_str.upper()\n                        if upper_state in (\"ON\", \"1\", \"TRUE\"):\n                            data.state = \"ON\"\n                        elif upper_state in (\"OFF\", \"0\", \"FALSE\"):\n                            data.state = \"OFF\"\n                        else:\n                            data.state = state_str\n                    logger.debug(\"MQTT smart plug %s: state=%s\", plug_id, data.state)\n\n    def _extract_json_path(self, data: dict, path: str) -> Any:\n        \"\"\"Extract value using dot notation (e.g., 'power_l1' or 'data.power').\n\n        Supports simple dot notation for nested objects.\n        \"\"\"\n        if not path:\n            return None\n\n        parts = path.split(\".\")\n        current = data\n\n        for part in parts:\n            if isinstance(current, dict) and part in current:\n                current = current[part]\n            else:\n                return None\n\n        return current\n\n    def _resubscribe_all(self):\n        \"\"\"Resubscribe to all registered topics after reconnection.\"\"\"\n        if not self.client or not self.connected:\n            return\n\n        with self._lock:\n            for topic in self.subscriptions:\n                if self.subscriptions[topic]:  # Only if there are subscribers\n                    try:\n                        self.client.subscribe(topic, qos=1)\n                        logger.debug(\"MQTT smart plug: resubscribed to %s\", topic)\n                    except Exception as e:\n                        logger.error(\"MQTT smart plug: failed to resubscribe to %s: %s\", topic, e)\n\n    def subscribe(\n        self,\n        plug_id: int,\n        # Power source\n        power_topic: str | None = None,\n        power_path: str | None = None,\n        power_multiplier: float = 1.0,\n        # Energy source\n        energy_topic: str | None = None,\n        energy_path: str | None = None,\n        energy_multiplier: float = 1.0,\n        # State source\n        state_topic: str | None = None,\n        state_path: str | None = None,\n        state_on_value: str | None = None,\n        # Legacy: single topic/path/multiplier (for backward compatibility)\n        topic: str | None = None,\n        multiplier: float = 1.0,\n    ):\n        \"\"\"Subscribe to MQTT topics for a plug.\n\n        Each data type (power, energy, state) can have its own topic.\n        For backward compatibility, if power_topic is not set but topic is,\n        topic will be used for all data types that have paths configured.\n        \"\"\"\n        with self._lock:\n            # Initialize config for this plug\n            self.plug_configs[plug_id] = {}\n\n            # Determine topics (new fields take priority, fall back to legacy)\n            effective_power_topic = power_topic or topic\n            effective_energy_topic = energy_topic or topic\n            effective_state_topic = state_topic or topic\n\n            # Use new multipliers or fall back to legacy\n            effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier\n            effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier\n\n            # Configure power subscription (path is optional - empty means use raw payload)\n            if effective_power_topic:\n                config = MQTTDataSourceConfig(\n                    topic=effective_power_topic,\n                    path=power_path or \"\",\n                    multiplier=effective_power_mult,\n                )\n                self.plug_configs[plug_id][\"power\"] = config\n                self._add_subscription(plug_id, effective_power_topic, \"power\")\n\n            # Configure energy subscription (path is optional - empty means use raw payload)\n            if effective_energy_topic:\n                config = MQTTDataSourceConfig(\n                    topic=effective_energy_topic,\n                    path=energy_path or \"\",\n                    multiplier=effective_energy_mult,\n                )\n                self.plug_configs[plug_id][\"energy\"] = config\n                self._add_subscription(plug_id, effective_energy_topic, \"energy\")\n\n            # Configure state subscription (path is optional - empty means use raw payload)\n            if effective_state_topic:\n                config = MQTTDataSourceConfig(\n                    topic=effective_state_topic,\n                    path=state_path or \"\",\n                    on_value=state_on_value,\n                )\n                self.plug_configs[plug_id][\"state\"] = config\n                self._add_subscription(plug_id, effective_state_topic, \"state\")\n\n            # Initialize data entry\n            if plug_id not in self.plug_data:\n                self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)\n\n            logger.info(\n                f\"MQTT smart plug {plug_id}: configured with \"\n                f\"power={effective_power_topic if power_path else None}, \"\n                f\"energy={effective_energy_topic if energy_path else None}, \"\n                f\"state={effective_state_topic if state_path else None}\"\n            )\n\n    def _add_subscription(self, plug_id: int, topic: str, data_type: str):\n        \"\"\"Add a subscription for a plug/data_type to a topic.\"\"\"\n        if topic not in self.subscriptions:\n            self.subscriptions[topic] = []\n            # Actually subscribe if connected\n            if self.client and self.connected:\n                try:\n                    self.client.subscribe(topic, qos=1)\n                    logger.info(\"MQTT smart plug: subscribed to %s\", topic)\n                except Exception as e:\n                    logger.error(\"MQTT smart plug: failed to subscribe to %s: %s\", topic, e)\n\n        entry = (plug_id, data_type)\n        if entry not in self.subscriptions[topic]:\n            self.subscriptions[topic].append(entry)\n\n    def unsubscribe(self, plug_id: int):\n        \"\"\"Unsubscribe when plug is deleted/updated.\"\"\"\n        with self._lock:\n            # Get all configs for this plug\n            configs = self.plug_configs.pop(plug_id, {})\n            if not configs:\n                # Still clean up any stray subscriptions\n                pass\n\n            # Collect all topics this plug was subscribed to\n            topics_to_check = set()\n            for _data_type, config in configs.items():\n                topics_to_check.add(config.topic)\n\n            # Also scan subscriptions to remove any entries for this plug\n            for topic in list(self.subscriptions.keys()):\n                # Remove all entries for this plug_id\n                self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id]\n                topics_to_check.add(topic)\n\n            # Unsubscribe from topics with no more subscribers\n            for topic in topics_to_check:\n                if topic in self.subscriptions and not self.subscriptions[topic]:\n                    del self.subscriptions[topic]\n                    if self.client and self.connected:\n                        try:\n                            self.client.unsubscribe(topic)\n                            logger.info(\"MQTT smart plug: unsubscribed from %s\", topic)\n                        except Exception as e:\n                            logger.error(\"MQTT smart plug: failed to unsubscribe from %s: %s\", topic, e)\n\n            # Remove data\n            self.plug_data.pop(plug_id, None)\n\n    def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None:\n        \"\"\"Get latest data for a plug (called by status endpoint).\"\"\"\n        with self._lock:\n            return self.plug_data.get(plug_id)\n\n    def is_reachable(self, plug_id: int) -> bool:\n        \"\"\"Check if a plug has received data recently.\"\"\"\n        data = self.get_plug_data(plug_id)\n        if not data:\n            return False\n\n        timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)\n        return datetime.now(timezone.utc) - data.last_seen < timeout\n\n    async def disconnect(self, timeout: float = 0):\n        \"\"\"Disconnect from MQTT broker.\"\"\"\n        if self.client:\n            try:\n                self._disconnection_event = threading.Event()\n                self.client.disconnect()\n                await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)\n                self.client.loop_stop()\n            except Exception as e:\n                logger.debug(\"MQTT smart plug disconnect error (ignored): %s\", e)\n            finally:\n                self.client = None\n                self.connected = False\n\n\ndef subscribe_plug_to_mqtt(service: \"MQTTSmartPlugService\", plug: Any) -> list[str]:\n    \"\"\"Resolve per-type topic fields on a SmartPlug and register it with the service.\n\n    The SmartPlug model carries both a legacy single `mqtt_topic` and newer\n    per-type `mqtt_{power,energy,state}_topic` fields. Three code paths used\n    to open-code this resolution (startup restore, create, update) and they\n    drifted — the startup path skipped plugs that only had per-type topics\n    set, leaving them unsubscribed after every restart (#1010). Funnelling\n    all three through this helper keeps them in sync.\n\n    Returns the list of topics subscribed (empty if nothing was configured).\n    \"\"\"\n    power_topic = plug.mqtt_power_topic or plug.mqtt_topic\n    energy_topic = plug.mqtt_energy_topic or plug.mqtt_topic\n    state_topic = plug.mqtt_state_topic or plug.mqtt_topic\n\n    if not (power_topic or energy_topic or state_topic):\n        return []\n\n    legacy_mult = plug.mqtt_multiplier or 1.0\n    service.subscribe(\n        plug_id=plug.id,\n        power_topic=power_topic,\n        power_path=plug.mqtt_power_path,\n        power_multiplier=plug.mqtt_power_multiplier or legacy_mult,\n        energy_topic=energy_topic,\n        energy_path=plug.mqtt_energy_path,\n        energy_multiplier=plug.mqtt_energy_multiplier or legacy_mult,\n        state_topic=state_topic,\n        state_path=plug.mqtt_state_path,\n        state_on_value=plug.mqtt_state_on_value,\n    )\n    return [t for t in {power_topic, energy_topic, state_topic} if t]\n\n\n# Global instance\nmqtt_smart_plug_service = MQTTSmartPlugService()\n"
  },
  {
    "path": "backend/app/services/network_utils.py",
    "content": "\"\"\"Network utility functions for interface detection.\"\"\"\n\nimport ipaddress\nimport json\nimport logging\nimport shutil\nimport socket\nimport struct\nimport subprocess\n\nlogger = logging.getLogger(__name__)\n\n# Interfaces to exclude from selection\nEXCLUDED_INTERFACE_PREFIXES = (\"lo\", \"docker\", \"br-\", \"veth\", \"virbr\")\n\n# Resolve full path to `ip` command (may not be in PATH for service users)\n_IP_CMD: str | None = shutil.which(\"ip\") or shutil.which(\"ip\", path=\"/usr/sbin:/sbin:/usr/bin:/bin\")\n\n\ndef _is_excluded(name: str) -> bool:\n    \"\"\"Check if an interface name should be excluded.\"\"\"\n    return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES)\n\n\ndef get_network_interfaces() -> list[dict]:\n    \"\"\"Get all network interfaces with their IPs and subnets.\n\n    Returns:\n        List of dicts with name, ip, netmask, subnet, broadcast\n    \"\"\"\n    interfaces = []\n\n    try:\n        import fcntl\n\n        for iface in socket.if_nameindex():\n            name = iface[1]\n\n            # Skip excluded interfaces\n            if _is_excluded(name):\n                continue\n\n            try:\n                s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n\n                # Get IP address\n                ip_bytes = fcntl.ioctl(\n                    s.fileno(),\n                    0x8915,  # SIOCGIFADDR\n                    struct.pack(\"256s\", name[:15].encode()),\n                )[20:24]\n                ip = socket.inet_ntoa(ip_bytes)\n\n                # Get netmask\n                netmask_bytes = fcntl.ioctl(\n                    s.fileno(),\n                    0x891B,  # SIOCGIFNETMASK\n                    struct.pack(\"256s\", name[:15].encode()),\n                )[20:24]\n                netmask = socket.inet_ntoa(netmask_bytes)\n\n                # Calculate subnet\n                network = ipaddress.IPv4Network(f\"{ip}/{netmask}\", strict=False)\n\n                interfaces.append(\n                    {\n                        \"name\": name,\n                        \"ip\": ip,\n                        \"netmask\": netmask,\n                        \"subnet\": str(network),\n                    }\n                )\n\n                s.close()\n            except OSError:\n                # Interface doesn't have an IP or other error\n                pass\n            except Exception as e:\n                logger.debug(\"Error getting info for interface %s: %s\", name, e)\n\n    except ImportError:\n        # fcntl not available (Windows)\n        logger.warning(\"fcntl not available, interface detection limited\")\n    except Exception as e:\n        logger.error(\"Error enumerating interfaces: %s\", e)\n\n    return interfaces\n\n\ndef get_all_interface_ips() -> list[dict]:\n    \"\"\"Get all IPs (primary + aliases) for all non-excluded interfaces.\n\n    Uses `ip -j addr show` to see secondary/alias IPs that ioctl misses.\n    Falls back to ioctl-based get_network_interfaces() if `ip` is unavailable.\n\n    Returns:\n        List of dicts with name, ip, netmask, subnet, is_alias, label\n    \"\"\"\n    if not _IP_CMD:\n        logger.debug(\"ip command not found, using ioctl fallback\")\n        return _fallback_get_all_ips()\n\n    try:\n        result = subprocess.run(\n            [_IP_CMD, \"-j\", \"addr\", \"show\"],\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        if result.returncode != 0:\n            logger.warning(\"ip addr show failed: %s\", result.stderr)\n            return _fallback_get_all_ips()\n\n        interfaces_data = json.loads(result.stdout)\n    except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError) as e:\n        logger.warning(\"Failed to run ip -j addr show: %s\", e)\n        return _fallback_get_all_ips()\n\n    entries = []\n    for iface in interfaces_data:\n        ifname = iface.get(\"ifname\", \"\")\n        if _is_excluded(ifname):\n            continue\n\n        ipv4_count = 0\n        for addr_info in iface.get(\"addr_info\", []):\n            if addr_info.get(\"family\") != \"inet\":\n                continue\n\n            ip = addr_info.get(\"local\", \"\")\n            prefix = addr_info.get(\"prefixlen\", 24)\n            label = addr_info.get(\"label\", ifname)\n\n            try:\n                network = ipaddress.IPv4Network(f\"{ip}/{prefix}\", strict=False)\n                netmask = str(network.netmask)\n            except ValueError:\n                continue\n\n            # An alias has \":\" in label (e.g. eth0:vp1) or is not the first IPv4\n            is_alias = \":\" in label or ipv4_count > 0\n\n            entries.append(\n                {\n                    \"name\": ifname,\n                    \"ip\": ip,\n                    \"netmask\": netmask,\n                    \"subnet\": str(network),\n                    \"is_alias\": is_alias,\n                    \"label\": label,\n                }\n            )\n            ipv4_count += 1\n\n    # Sort: primary IPs first per interface, then by interface name\n    entries.sort(key=lambda e: (e[\"name\"], e[\"is_alias\"], e[\"ip\"]))\n    return entries\n\n\ndef _fallback_get_all_ips() -> list[dict]:\n    \"\"\"Fallback: wrap get_network_interfaces() result with alias fields.\"\"\"\n    return [\n        {\n            **iface,\n            \"is_alias\": False,\n            \"label\": iface[\"name\"],\n        }\n        for iface in get_network_interfaces()\n    ]\n\n\ndef find_interface_for_ip(target_ip: str) -> dict | None:\n    \"\"\"Find which interface is on the same subnet as the target IP.\n\n    Args:\n        target_ip: IP address to find the matching interface for\n\n    Returns:\n        Interface dict or None if not found\n    \"\"\"\n    try:\n        target = ipaddress.IPv4Address(target_ip)\n    except ValueError:\n        logger.error(\"Invalid target IP: %s\", target_ip)\n        return None\n\n    interfaces = get_all_interface_ips()\n\n    for iface in interfaces:\n        if iface.get(\"is_alias\"):\n            continue\n        try:\n            network = ipaddress.IPv4Network(iface[\"subnet\"], strict=False)\n            if target in network:\n                logger.debug(\"Found interface %s (%s) for target %s\", iface[\"name\"], iface[\"ip\"], target_ip)\n                return iface\n        except ValueError:\n            continue\n\n    logger.warning(\"No interface found for target IP %s\", target_ip)\n    return None\n\n\ndef get_other_interfaces(exclude_ip: str) -> list[dict]:\n    \"\"\"Get all interfaces except the one with the given IP.\n\n    Args:\n        exclude_ip: IP address of interface to exclude\n\n    Returns:\n        List of interface dicts\n    \"\"\"\n    interfaces = get_network_interfaces()\n    return [iface for iface in interfaces if iface[\"ip\"] != exclude_ip]\n"
  },
  {
    "path": "backend/app/services/notification_service.py",
    "content": "\"\"\"Notification service for sending push notifications via various providers.\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport re\nimport smtplib\nfrom datetime import datetime, timedelta, timezone\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\nfrom typing import Any\nfrom urllib.parse import quote\n\nimport httpx\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider\nfrom backend.app.models.notification_template import NotificationTemplate\n\nlogger = logging.getLogger(__name__)\n\n\nclass NotificationService:\n    \"\"\"Service for sending notifications through various providers.\"\"\"\n\n    def __init__(self):\n        self._http_client: httpx.AsyncClient | None = None\n        self._template_cache: dict[str, NotificationTemplate] = {}\n        self._digest_scheduler_task: asyncio.Task | None = None\n        self._last_digest_check: str = \"\"  # \"HH:MM\" to avoid duplicate checks\n\n    async def _get_client(self) -> httpx.AsyncClient:\n        \"\"\"Get or create HTTP client.\"\"\"\n        if self._http_client is None or self._http_client.is_closed:\n            self._http_client = httpx.AsyncClient(timeout=30.0)\n        return self._http_client\n\n    async def close(self):\n        \"\"\"Close HTTP client.\"\"\"\n        if self._http_client and not self._http_client.is_closed:\n            await self._http_client.aclose()\n\n    def _is_in_quiet_hours(self, provider: NotificationProvider) -> bool:\n        \"\"\"Check if current time is within provider's quiet hours.\"\"\"\n        if not provider.quiet_hours_enabled:\n            return False\n\n        if not provider.quiet_hours_start or not provider.quiet_hours_end:\n            return False\n\n        try:\n            now = datetime.now()\n            current_time = now.hour * 60 + now.minute\n\n            start_parts = provider.quiet_hours_start.split(\":\")\n            end_parts = provider.quiet_hours_end.split(\":\")\n\n            start_minutes = int(start_parts[0]) * 60 + int(start_parts[1])\n            end_minutes = int(end_parts[0]) * 60 + int(end_parts[1])\n\n            # Handle overnight quiet hours (e.g., 22:00 to 07:00)\n            if start_minutes > end_minutes:\n                # Quiet hours span midnight\n                return current_time >= start_minutes or current_time < end_minutes\n            else:\n                # Same day quiet hours\n                return start_minutes <= current_time < end_minutes\n        except (ValueError, TypeError, AttributeError):\n            logger.warning(\"Invalid quiet hours format for provider %s\", provider.name)\n            return False\n\n    async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:\n        \"\"\"Get a notification template by event type.\"\"\"\n        # Check cache first\n        if event_type in self._template_cache:\n            return self._template_cache[event_type]\n\n        result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))\n        template = result.scalar_one_or_none()\n\n        if template:\n            self._template_cache[event_type] = template\n\n        return template\n\n    def _render_template(self, template_str: str, variables: dict[str, Any]) -> str:\n        \"\"\"Render a template string with variables. Missing variables become empty.\"\"\"\n        result = template_str\n        for key, value in variables.items():\n            result = result.replace(\"{\" + key + \"}\", str(value) if value is not None else \"\")\n        # Remove any remaining unreplaced placeholders\n        result = re.sub(r\"\\{[a-z_]+\\}\", \"\", result)\n        return result\n\n    async def _format_eta(self, seconds: int | None, db: AsyncSession) -> str:\n        \"\"\"Format ETA as wall-clock time, respecting user's time_format setting.\"\"\"\n        if not seconds or seconds <= 0:\n            return \"Unknown\"\n\n        from backend.app.api.routes.settings import get_setting\n\n        eta_time = datetime.now() + timedelta(seconds=seconds)\n        time_format = await get_setting(db, \"time_format\")\n\n        if time_format == \"12h\":\n            return eta_time.strftime(\"%I:%M %p\").lstrip(\"0\")\n        # Default to 24h for \"24h\", \"system\", or unset\n        return eta_time.strftime(\"%H:%M\")\n\n    def _format_duration(self, seconds: int | None) -> str:\n        \"\"\"Format duration in seconds to human-readable string.\"\"\"\n        if seconds is None:\n            return \"Unknown\"\n        hours = seconds // 3600\n        minutes = (seconds % 3600) // 60\n        if hours > 0:\n            return f\"{hours}h {minutes}m\"\n        return f\"{minutes}m\"\n\n    def _clean_filename(self, filename: str) -> str:\n        \"\"\"Extract filename and remove file extensions.\"\"\"\n        import os\n\n        # Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode)\n        filename = os.path.basename(filename)\n        # Remove common extensions\n        if filename.endswith(\".gcode.3mf\"):\n            return filename[:-10]\n        elif filename.endswith(\".gcode\"):\n            return filename[:-6]\n        elif filename.endswith(\".3mf\"):\n            return filename[:-4]\n        return filename\n\n    async def _build_message_from_template(\n        self, db: AsyncSession, event_type: str, variables: dict[str, Any]\n    ) -> tuple[str, str]:\n        \"\"\"Build notification title and body from template.\"\"\"\n        # Add common variables\n        variables[\"timestamp\"] = datetime.now().strftime(\"%Y-%m-%d %H:%M\")\n        variables[\"app_name\"] = \"Bambuddy\"\n\n        template = await self._get_template(db, event_type)\n        if not template:\n            # Fallback to simple message\n            logger.warning(\"Template not found for event type: %s\", event_type)\n            return event_type.replace(\"_\", \" \").title(), str(variables)\n\n        title = self._render_template(template.title_template, variables)\n        body = self._render_template(template.body_template, variables)\n\n        return title, body\n\n    async def send_test_notification(\n        self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None\n    ) -> tuple[bool, str]:\n        \"\"\"Send a test notification to verify configuration.\"\"\"\n        if db:\n            title, message = await self._build_message_from_template(db, \"test\", {})\n        else:\n            title = \"Bambuddy Test\"\n            message = \"This is a test notification. If you see this, notifications are working!\"\n\n        try:\n            if provider_type == \"callmebot\":\n                return await self._send_callmebot(config, f\"{title}\\n{message}\")\n            elif provider_type == \"ntfy\":\n                return await self._send_ntfy(config, title, message)\n            elif provider_type == \"pushover\":\n                return await self._send_pushover(config, title, message)\n            elif provider_type == \"telegram\":\n                return await self._send_telegram(config, f\"*{title}*\\n{message}\")\n            elif provider_type == \"email\":\n                return await self._send_email(config, title, message)\n            elif provider_type == \"discord\":\n                return await self._send_discord(config, title, message)\n            elif provider_type == \"webhook\":\n                return await self._send_webhook(config, title, message)\n            elif provider_type == \"homeassistant\":\n                return await self._send_homeassistant(config, title, message, db=db)\n            else:\n                return False, f\"Unknown provider type: {provider_type}\"\n        except Exception as e:\n            logger.exception(\"Error sending test notification via %s\", provider_type)\n            return False, str(e)\n\n    async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:\n        \"\"\"Send notification via CallMeBot (WhatsApp).\"\"\"\n        phone = config.get(\"phone\", \"\").strip()\n        apikey = config.get(\"apikey\", \"\").strip()\n\n        if not phone or not apikey:\n            return False, \"Phone number and API key are required\"\n\n        # URL encode the message\n        encoded_message = quote(message)\n        url = f\"https://api.callmebot.com/whatsapp.php?phone={phone}&text={encoded_message}&apikey={apikey}\"\n\n        client = await self._get_client()\n        response = await client.get(url)\n\n        if response.status_code == 200:\n            return True, \"Message sent successfully\"\n        else:\n            return False, f\"HTTP {response.status_code}: {response.text[:200]}\"\n\n    async def _send_ntfy(\n        self, config: dict, title: str, message: str, image_data: bytes | None = None\n    ) -> tuple[bool, str]:\n        \"\"\"Send notification via ntfy.\"\"\"\n        server = config.get(\"server\", \"https://ntfy.sh\").rstrip(\"/\")\n        topic = config.get(\"topic\", \"\").strip()\n        auth_token = config.get(\"auth_token\", \"\").strip()\n\n        if not topic:\n            return False, \"Topic is required\"\n\n        url = f\"{server}/{topic}\"\n        # ntfy reads Title/Message from HTTP headers. httpx enforces ASCII\n        # for str header values, but printer names and filenames can contain\n        # non-ASCII characters (e.g. accented letters, CJK). Passing bytes\n        # bypasses the ASCII check — ntfy handles UTF-8 headers correctly.\n        headers: dict[str, str | bytes] = {\"Title\": title.encode(\"utf-8\")}\n\n        if auth_token:\n            headers[\"Authorization\"] = f\"Bearer {auth_token}\"\n\n        client = await self._get_client()\n\n        if image_data:\n            # ntfy supports image attachments via multipart form-data.\n            # HTTP headers cannot contain newlines, but ntfy interprets\n            # literal \\n (backslash-n) as newlines in the Message header.\n            headers[\"Filename\"] = \"photo.jpg\"\n            headers[\"Message\"] = message.replace(\"\\n\", \"\\\\n\").encode(\"utf-8\")\n            response = await client.put(url, content=image_data, headers=headers)\n\n            if response.status_code == 400 and \"attachments not allowed\" in response.text:\n                # Server has attachments disabled — retry without the image\n                headers.pop(\"Filename\", None)\n                headers.pop(\"Message\", None)\n                response = await client.post(url, content=message.encode(\"utf-8\"), headers=headers)\n        else:\n            response = await client.post(url, content=message.encode(\"utf-8\"), headers=headers)\n\n        if response.status_code in (200, 204):\n            return True, \"Message sent successfully\"\n        else:\n            return False, f\"HTTP {response.status_code}: {response.text[:200]}\"\n\n    async def _send_pushover(\n        self, config: dict, title: str, message: str, image_data: bytes | None = None\n    ) -> tuple[bool, str]:\n        \"\"\"Send notification via Pushover.\n\n        Args:\n            config: Provider configuration with user_key, app_token, priority\n            title: Notification title\n            message: Notification body\n            image_data: Optional JPEG image bytes to attach (max 2.5MB)\n        \"\"\"\n        user_key = config.get(\"user_key\", \"\").strip()\n        app_token = config.get(\"app_token\", \"\").strip()\n        priority = config.get(\"priority\", 0)\n\n        if not user_key or not app_token:\n            return False, \"User key and app token are required\"\n\n        url = \"https://api.pushover.net/1/messages.json\"\n        data = {\n            \"token\": app_token,\n            \"user\": user_key,\n            \"title\": title,\n            \"message\": message,\n            \"priority\": priority,\n        }\n\n        client = await self._get_client()\n\n        if image_data:\n            # Pushover supports image attachments via multipart form-data\n            files = {\"attachment\": (\"photo.jpg\", image_data, \"image/jpeg\")}\n            response = await client.post(url, data=data, files=files)\n        else:\n            response = await client.post(url, data=data)\n\n        if response.status_code == 200:\n            return True, \"Message sent successfully\"\n        else:\n            try:\n                error_data = response.json()\n                errors = error_data.get(\"errors\", [])\n                return False, f\"Pushover error: {', '.join(errors)}\"\n            except Exception:\n                return False, f\"HTTP {response.status_code}: {response.text[:200]}\"\n\n    async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:\n        \"\"\"Send notification via Telegram bot.\"\"\"\n        bot_token = config.get(\"bot_token\", \"\").strip()\n        chat_id = config.get(\"chat_id\", \"\").strip()\n\n        if not bot_token or not chat_id:\n            return False, \"Bot token and chat ID are required\"\n\n        # Escape underscores in the message body so Telegram Markdown\n        # parsing doesn't break on job names like \"A1_plate_8\" or error\n        # codes like \"0300_0001\".  The title is already wrapped in *bold*\n        # markers, so only escape after the first newline.\n        if \"\\n\" in message:\n            title_part, body_part = message.split(\"\\n\", 1)\n            body_part = body_part.replace(\"_\", \"\\\\_\")\n            message = f\"{title_part}\\n{body_part}\"\n\n        client = await self._get_client()\n\n        if image_data:\n            # Use sendPhoto to attach the thumbnail with the caption\n            url = f\"https://api.telegram.org/bot{bot_token}/sendPhoto\"\n            response = await client.post(\n                url,\n                data={\"chat_id\": chat_id, \"caption\": message, \"parse_mode\": \"Markdown\"},\n                files={\"photo\": (\"photo.jpg\", image_data, \"image/jpeg\")},\n            )\n        else:\n            url = f\"https://api.telegram.org/bot{bot_token}/sendMessage\"\n            data = {\n                \"chat_id\": chat_id,\n                \"text\": message,\n                \"parse_mode\": \"Markdown\",\n            }\n            response = await client.post(url, json=data)\n\n        if response.status_code == 200:\n            result = response.json()\n            if result.get(\"ok\"):\n                return True, \"Message sent successfully\"\n            else:\n                return False, f\"Telegram error: {result.get('description', 'Unknown error')}\"\n        else:\n            return False, f\"HTTP {response.status_code}: {response.text[:200]}\"\n\n    async def _send_email(self, config: dict, subject: str, body: str) -> tuple[bool, str]:\n        \"\"\"Send notification via email (SMTP).\"\"\"\n        smtp_server = config.get(\"smtp_server\", \"\").strip()\n        smtp_port = int(config.get(\"smtp_port\", 587))\n        username = config.get(\"username\", \"\").strip()\n        password = config.get(\"password\", \"\").strip()\n        from_email = config.get(\"from_email\", \"\").strip()\n        to_email = config.get(\"to_email\", \"\").strip()\n        # Security: \"starttls\" (port 587), \"ssl\" (port 465), \"none\" (port 25)\n        security = config.get(\"security\", \"starttls\")\n        # Authentication: \"true\" or \"false\"\n        auth_enabled = config.get(\"auth_enabled\", \"true\").lower() == \"true\"\n\n        if not all([smtp_server, from_email, to_email]):\n            return False, \"SMTP server, from email, and to email are required\"\n\n        if auth_enabled and not all([username, password]):\n            return False, \"Username and password are required when authentication is enabled\"\n\n        try:\n            msg = MIMEMultipart()\n            msg[\"From\"] = from_email\n            msg[\"To\"] = to_email\n            msg[\"Subject\"] = f\"[Bambuddy] {subject}\"\n            msg.attach(MIMEText(body, \"plain\"))\n\n            if security == \"ssl\":\n                # Direct SSL connection (typically port 465)\n                server = smtplib.SMTP_SSL(smtp_server, smtp_port)\n            elif security == \"starttls\":\n                # STARTTLS upgrade (typically port 587)\n                server = smtplib.SMTP(smtp_server, smtp_port)\n                server.starttls()\n            else:\n                # No encryption (typically port 25) - use with caution\n                server = smtplib.SMTP(smtp_server, smtp_port)\n\n            if auth_enabled:\n                server.login(username, password)\n\n            server.sendmail(from_email, to_email, msg.as_string())\n            server.quit()\n\n            return True, \"Email sent successfully\"\n        except smtplib.SMTPAuthenticationError:\n            return False, \"SMTP authentication failed - check username/password\"\n        except smtplib.SMTPException as e:\n            return False, f\"SMTP error: {str(e)}\"\n        except Exception as e:\n            return False, f\"Email error: {str(e)}\"\n\n    async def _send_discord(\n        self, config: dict, title: str, message: str, image_data: bytes | None = None\n    ) -> tuple[bool, str]:\n        \"\"\"Send notification via Discord webhook.\"\"\"\n        webhook_url = config.get(\"webhook_url\", \"\").strip()\n\n        if not webhook_url:\n            return False, \"Webhook URL is required\"\n\n        if not webhook_url.startswith(\"https://discord.com/api/webhooks/\"):\n            return False, \"Invalid Discord webhook URL\"\n\n        # Discord embed format for nicer messages\n        embed = {\n            \"title\": title,\n            \"description\": message,\n            \"color\": 0x00AE42,  # Bambu green\n        }\n\n        client = await self._get_client()\n\n        if image_data:\n            # Attach image via multipart form-data and reference in embed\n            embed[\"image\"] = {\"url\": \"attachment://photo.jpg\"}\n            payload = {\"embeds\": [embed]}\n            response = await client.post(\n                webhook_url,\n                data={\"payload_json\": json.dumps(payload)},\n                files={\"files[0]\": (\"photo.jpg\", image_data, \"image/jpeg\")},\n            )\n        else:\n            response = await client.post(webhook_url, json={\"embeds\": [embed]})\n\n        if response.status_code in (200, 204):\n            return True, \"Message sent successfully\"\n        else:\n            return False, f\"HTTP {response.status_code}: {response.text[:200]}\"\n\n    async def _send_webhook(\n        self,\n        config: dict,\n        title: str,\n        message: str,\n        image_data: bytes | None = None,\n        event_type: str | None = None,\n        variables: dict | None = None,\n    ) -> tuple[bool, str]:\n        \"\"\"Send notification via generic webhook (POST JSON).\n\n        Supports two payload formats:\n        - generic: Custom field names with timestamp/source metadata + structured event data\n        - slack: Slack/Mattermost compatible format (just {\"text\": \"...\"})\n        \"\"\"\n        webhook_url = config.get(\"webhook_url\", \"\").strip()\n        auth_header = config.get(\"auth_header\", \"\").strip()\n        payload_format = config.get(\"payload_format\", \"generic\").strip()\n\n        if not webhook_url:\n            return False, \"Webhook URL is required\"\n\n        # Build payload based on format\n        if payload_format == \"slack\":\n            # Slack/Mattermost format - just text field\n            data = {\"text\": f\"*{title}*\\n{message}\"}\n        else:\n            # Generic format with custom field names\n            custom_field_title = config.get(\"field_title\", \"title\").strip() or \"title\"\n            custom_field_message = config.get(\"field_message\", \"message\").strip() or \"message\"\n            data = {\n                custom_field_title: title,\n                custom_field_message: message,\n                \"timestamp\": datetime.now().isoformat(),\n                \"source\": \"Bambuddy\",\n            }\n\n        # For generic format, include structured event data for automation tools\n        if payload_format != \"slack\":\n            if event_type:\n                data[\"event\"] = event_type\n            if variables:\n                for key, value in variables.items():\n                    if key not in data:  # Don't overwrite title/message/timestamp/source\n                        data[key] = value\n\n        # Attach base64-encoded image when available (generic format only)\n        if image_data and payload_format != \"slack\":\n            import base64\n\n            data[\"image\"] = base64.b64encode(image_data).decode(\"ascii\")\n\n        headers = {\"Content-Type\": \"application/json\"}\n        if auth_header:\n            # Support \"Bearer token\" or just \"token\" format\n            if \" \" in auth_header:\n                headers[\"Authorization\"] = auth_header\n            else:\n                headers[\"Authorization\"] = f\"Bearer {auth_header}\"\n\n        client = await self._get_client()\n        try:\n            response = await client.post(webhook_url, json=data, headers=headers)\n\n            if response.status_code in (200, 201, 202, 204):\n                return True, \"Webhook delivered successfully\"\n            else:\n                return False, f\"HTTP {response.status_code}: {response.text[:200]}\"\n        except Exception as e:\n            return False, f\"Webhook error: {str(e)}\"\n\n    async def _send_homeassistant(\n        self, config: dict, title: str, message: str, db: AsyncSession | None = None\n    ) -> tuple[bool, str]:\n        \"\"\"Send notification via Home Assistant.\n\n        Uses the globally configured HA URL/token from settings.\n        Defaults to persistent_notification/create, but supports\n        custom services via config[\"service\"] (e.g. notify.mobile_app_myphone).\n        \"\"\"\n        # Get HA connection settings from global config\n        ha_url = \"\"\n        ha_token = \"\"\n\n        if db:\n            from backend.app.api.routes.settings import get_homeassistant_settings\n\n            try:\n                ha_settings = await get_homeassistant_settings(db)\n                ha_url = ha_settings.get(\"ha_url\", \"\")\n                ha_token = ha_settings.get(\"ha_token\", \"\")\n            except Exception as e:\n                logger.warning(\"Failed to read HA settings from database: %s\", e)\n        else:\n            # Fallback: read directly from environment if no DB session\n            import os\n\n            ha_url = os.environ.get(\"HA_URL\", \"\")\n            ha_token = os.environ.get(\"HA_TOKEN\", \"\")\n\n        if not ha_url or not ha_token:\n            return False, (\n                \"Home Assistant is not configured. Please set HA URL and token in Settings → Network → Home Assistant.\"\n            )\n\n        # Determine which HA service to call - Default: persistent_notification.create\n        service = (config.get(\"service\") or \"\").strip()\n        if service:\n            # Allow in different forms:\n            # - notify.mobile_app_<device>\n            # - notify/mobile_app_<device>\n            # - api/services/notify/mobile_app_<device>\n            service_str = service.lstrip(\"/\")\n            if service_str.startswith(\"api/services/\"):\n                endpoint = service_str\n            elif \"/\" in service_str:\n                endpoint = f\"api/services/{service_str}\"\n            elif \".\" in service_str:\n                domain, svc = service_str.split(\".\", 1)\n                endpoint = f\"api/services/{domain}/{svc}\"\n            else:\n                return False, (\n                    \"Invalid Home Assistant service name. Use e.g. 'notify.mobile_app_yourdevice' or 'notify/your_service'.\"\n                )\n\n            if not re.match(r\"^api/services/[a-zA-Z0-9_]+/[a-zA-Z0-9_]+$\", endpoint):\n                return False, (\n                    \"Invalid Home Assistant service name. Domain and service must only contain letters, numbers, and underscores.\"\n                )\n        else:\n            endpoint = \"api/services/persistent_notification/create\"\n\n        url = f\"{ha_url.rstrip('/')}/{endpoint}\"\n        headers = {\n            \"Authorization\": f\"Bearer {ha_token}\",\n            \"Content-Type\": \"application/json\",\n        }\n        payload = {\n            \"title\": title,\n            \"message\": message,\n        }\n\n        client = await self._get_client()\n        response = await client.post(url, json=payload, headers=headers)\n\n        if response.status_code in (200, 201):\n            return True, \"Notification sent via Home Assistant\"\n        elif response.status_code == 401:\n            return False, \"Home Assistant authentication failed - check your token\"\n        else:\n            return False, f\"HTTP {response.status_code}: {response.text[:200]}\"\n\n    async def _send_to_provider(\n        self,\n        provider: NotificationProvider,\n        title: str,\n        message: str,\n        db: AsyncSession | None = None,\n        image_data: bytes | None = None,\n        event_type: str | None = None,\n        variables: dict | None = None,\n    ) -> tuple[bool, str]:\n        \"\"\"Send notification to a specific provider.\"\"\"\n        # Check quiet hours\n        if self._is_in_quiet_hours(provider):\n            logger.info(\"Skipping notification to %s - quiet hours active\", provider.name)\n            return True, \"Skipped - quiet hours\"\n\n        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config\n\n        try:\n            if provider.provider_type == \"callmebot\":\n                return await self._send_callmebot(config, f\"{title}\\n{message}\")\n            elif provider.provider_type == \"ntfy\":\n                return await self._send_ntfy(config, title, message, image_data=image_data)\n            elif provider.provider_type == \"pushover\":\n                return await self._send_pushover(config, title, message, image_data=image_data)\n            elif provider.provider_type == \"telegram\":\n                return await self._send_telegram(config, f\"*{title}*\\n{message}\", image_data=image_data)\n            elif provider.provider_type == \"email\":\n                return await self._send_email(config, title, message)\n            elif provider.provider_type == \"discord\":\n                return await self._send_discord(config, title, message, image_data=image_data)\n            elif provider.provider_type == \"webhook\":\n                return await self._send_webhook(\n                    config, title, message, image_data=image_data, event_type=event_type, variables=variables\n                )\n            elif provider.provider_type == \"homeassistant\":\n                return await self._send_homeassistant(config, title, message, db=db)\n            else:\n                return False, f\"Unknown provider type: {provider.provider_type}\"\n        except Exception as e:\n            logger.exception(\"Error sending notification via %s\", provider.provider_type)\n            return False, str(e)\n\n    async def _update_provider_status(\n        self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None\n    ):\n        \"\"\"Update provider status after sending notification.\"\"\"\n        result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))\n        provider = result.scalar_one_or_none()\n        if provider:\n            if success:\n                provider.last_success = datetime.now(timezone.utc)\n            else:\n                provider.last_error = error\n                provider.last_error_at = datetime.now(timezone.utc)\n            await db.commit()\n\n    async def _get_providers_for_event(\n        self,\n        db: AsyncSession,\n        event_field: str,\n        printer_id: int | None = None,\n    ) -> list[NotificationProvider]:\n        \"\"\"Get all enabled providers that want a specific event type.\"\"\"\n        # Build the query dynamically based on event field\n        query = select(NotificationProvider).where(\n            NotificationProvider.enabled.is_(True),\n            getattr(NotificationProvider, event_field).is_(True),\n        )\n\n        if printer_id is not None:\n            query = query.where(\n                (NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id)\n            )\n\n        result = await db.execute(query)\n        return list(result.scalars().all())\n\n    async def _log_notification(\n        self,\n        db: AsyncSession,\n        provider_id: int,\n        event_type: str,\n        title: str,\n        message: str,\n        success: bool,\n        error_message: str | None = None,\n        printer_id: int | None = None,\n        printer_name: str | None = None,\n    ):\n        \"\"\"Create a log entry for a sent notification.\"\"\"\n        try:\n            log = NotificationLog(\n                provider_id=provider_id,\n                event_type=event_type,\n                title=title,\n                message=message,\n                success=success,\n                error_message=error_message,\n                printer_id=printer_id,\n                printer_name=printer_name,\n            )\n            db.add(log)\n            await db.commit()\n        except Exception as e:\n            logger.warning(\"Failed to log notification: %s\", e)\n            # Don't fail the notification just because logging failed\n\n    async def _send_to_providers(\n        self,\n        providers: list[NotificationProvider],\n        title: str,\n        message: str,\n        db: AsyncSession,\n        event_type: str = \"unknown\",\n        printer_id: int | None = None,\n        printer_name: str | None = None,\n        force_immediate: bool = False,\n        image_data: bytes | None = None,\n        variables: dict | None = None,\n    ):\n        \"\"\"Send notification to multiple providers and log the results.\n\n        All notifications are always sent immediately. If digest mode is enabled,\n        the notification is ALSO queued for the daily digest summary.\n        \"\"\"\n        for provider in providers:\n            try:\n                # Always send notification immediately\n                success, error = await self._send_to_provider(\n                    provider, title, message, db, image_data=image_data, event_type=event_type, variables=variables\n                )\n\n                # Also queue for digest if enabled (digest is a summary, not a queue)\n                if provider.daily_digest_enabled and provider.daily_digest_time:\n                    await self._queue_for_digest(\n                        provider=provider,\n                        event_type=event_type,\n                        title=title,\n                        message=message,\n                        db=db,\n                        printer_id=printer_id,\n                        printer_name=printer_name,\n                    )\n                await self._update_provider_status(db, provider.id, success, error if not success else None)\n                await self._log_notification(\n                    db=db,\n                    provider_id=provider.id,\n                    event_type=event_type,\n                    title=title,\n                    message=message,\n                    success=success,\n                    error_message=error if not success else None,\n                    printer_id=printer_id,\n                    printer_name=printer_name,\n                )\n                if success:\n                    logger.info(\"Sent notification via %s\", provider.name)\n                else:\n                    logger.warning(\"Failed to send notification via %s: %s\", provider.name, error)\n            except Exception as e:\n                logger.exception(\"Error sending notification via %s\", provider.name)\n                await self._update_provider_status(db, provider.id, False, str(e))\n                await self._log_notification(\n                    db=db,\n                    provider_id=provider.id,\n                    event_type=event_type,\n                    title=title,\n                    message=message,\n                    success=False,\n                    error_message=str(e),\n                    printer_id=printer_id,\n                    printer_name=printer_name,\n                )\n\n    async def on_print_start(\n        self,\n        printer_id: int,\n        printer_name: str,\n        data: dict,\n        db: AsyncSession,\n        archive_data: dict | None = None,\n    ):\n        \"\"\"Handle print start event - send notifications to relevant providers.\n\n        Args:\n            printer_id: The printer ID\n            printer_name: The printer name\n            data: MQTT event data with filename, subtask_name, remaining_time, raw_data\n            db: Database session\n            archive_data: Optional archive data with print_time_seconds from 3MF parsing\n        \"\"\"\n        logger.info(\"on_print_start called for printer %s (%s)\", printer_id, printer_name)\n        providers = await self._get_providers_for_event(db, \"on_print_start\", printer_id)\n        if not providers:\n            logger.info(\"No notification providers configured for print_start event on printer %s\", printer_id)\n            return\n\n        # Use subtask_name (project name) if available, otherwise use filename\n        subtask_name = data.get(\"subtask_name\")\n        if subtask_name:\n            # Replace underscores with spaces for readability\n            filename = subtask_name.replace(\"_\", \" \")\n        else:\n            filename = self._clean_filename(data.get(\"filename\", \"Unknown\"))\n\n        # Priority for estimated_time:\n        # 1. Archive's print_time_seconds from 3MF parsing (most reliable)\n        # 2. MQTT remaining_time (may be 0 at print start)\n        # 3. raw_data mc_remaining_time\n        estimated_time = None\n\n        # Try archive data first (from 3MF parsing - most reliable)\n        if archive_data and archive_data.get(\"print_time_seconds\"):\n            estimated_time = archive_data[\"print_time_seconds\"]\n            logger.debug(\"Using print_time_seconds from archive: %s\", estimated_time)\n\n        # Fall back to MQTT remaining_time\n        if estimated_time is None:\n            estimated_time = data.get(\"remaining_time\")\n            if estimated_time:\n                logger.debug(\"Using remaining_time from MQTT: %s\", estimated_time)\n\n        # Last resort: raw_data mc_remaining_time (in minutes, convert to seconds)\n        if estimated_time is None:\n            raw_time = data.get(\"raw_data\", {}).get(\"mc_remaining_time\")\n            if raw_time:\n                estimated_time = raw_time * 60\n                logger.debug(\"Using mc_remaining_time from raw_data: %s\", estimated_time)\n\n        time_str = self._format_duration(estimated_time)\n        eta_str = await self._format_eta(estimated_time, db)\n\n        variables = {\n            \"printer\": printer_name,\n            \"filename\": filename,\n            \"estimated_time\": time_str,\n            \"eta\": eta_str,\n        }\n\n        # Extract image data for providers that support attachments (e.g. Pushover)\n        image_data = None\n        if archive_data:\n            image_data = archive_data.get(\"image_data\")\n\n        logger.info(\"Found %s providers for print_start: %s\", len(providers), [p.name for p in providers])\n        title, message = await self._build_message_from_template(db, \"print_start\", variables)\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"print_start\",\n            printer_id,\n            printer_name,\n            image_data=image_data,\n            variables=variables,\n        )\n\n    async def on_print_complete(\n        self,\n        printer_id: int,\n        printer_name: str,\n        status: str,\n        data: dict,\n        db: AsyncSession,\n        archive_data: dict | None = None,\n    ):\n        \"\"\"Handle print complete event - send notifications to relevant providers.\"\"\"\n        logger.info(\"on_print_complete called for printer %s (%s), status=%s\", printer_id, printer_name, status)\n\n        # Determine event type based on status\n        if status == \"completed\":\n            event_field = \"on_print_complete\"\n            event_type = \"print_complete\"\n        elif status in (\"failed\",):\n            event_field = \"on_print_failed\"\n            event_type = \"print_failed\"\n        elif status in (\"aborted\", \"stopped\", \"cancelled\"):\n            event_field = \"on_print_stopped\"\n            event_type = \"print_stopped\"\n        else:\n            logger.warning(\"Unknown print status '%s', defaulting to on_print_complete\", status)\n            event_field = \"on_print_complete\"\n            event_type = \"print_complete\"\n\n        providers = await self._get_providers_for_event(db, event_field, printer_id)\n        if not providers:\n            logger.info(\"No notification providers configured for %s event on printer %s\", event_field, printer_id)\n            return\n\n        # Use subtask_name (project name) if available, otherwise use filename\n        subtask_name = data.get(\"subtask_name\")\n        if subtask_name:\n            filename = subtask_name.replace(\"_\", \" \")\n        else:\n            filename = self._clean_filename(data.get(\"filename\", \"Unknown\"))\n\n        variables = {\n            \"printer\": printer_name,\n            \"filename\": filename,\n            \"duration\": \"Unknown\",\n            \"filament_grams\": \"Unknown\",\n            \"reason\": \"Unknown\",\n        }\n\n        if archive_data:\n            if archive_data.get(\"print_time_seconds\"):\n                variables[\"duration\"] = self._format_duration(archive_data[\"print_time_seconds\"])\n            if archive_data.get(\"actual_filament_grams\"):\n                variables[\"filament_grams\"] = f\"{archive_data['actual_filament_grams']:.1f}\"\n            if status == \"failed\" and archive_data.get(\"failure_reason\"):\n                variables[\"reason\"] = archive_data[\"failure_reason\"]\n            if archive_data.get(\"finish_photo_url\"):\n                variables[\"finish_photo_url\"] = archive_data[\"finish_photo_url\"]\n\n            # Build per-slot breakdown string with AMS info when available\n            if archive_data.get(\"usage_results\"):\n                parts = []\n                for u in archive_data[\"usage_results\"]:\n                    ams_id = u.get(\"ams_id\", 0)\n                    tray_id = u.get(\"tray_id\", 0)\n                    material = u.get(\"material\", \"Unknown\") or \"Unknown\"\n                    used = u.get(\"weight_used\", 0)\n                    if ams_id >= 128:\n                        slot_label = \"Ext\"\n                    else:\n                        slot_label = f\"AMS-{chr(65 + ams_id)} T{tray_id + 1}\"\n                    parts.append(f\"{slot_label} {material}: {used:.1f}g\")\n                variables[\"filament_details\"] = \" | \".join(parts)\n            elif archive_data.get(\"filament_slots\"):\n                parts = []\n                for slot in archive_data[\"filament_slots\"]:\n                    ftype = slot.get(\"type\", \"Unknown\") or \"Unknown\"\n                    used = slot.get(\"used_g\", 0)\n                    parts.append(f\"{ftype}: {used:.1f}g\")\n                variables[\"filament_details\"] = \" | \".join(parts)\n\n            # Add progress for partial prints\n            if archive_data.get(\"progress\") is not None:\n                variables[\"progress\"] = str(archive_data[\"progress\"])\n\n        # Extract image data for providers that support attachments (e.g. Pushover)\n        image_data = None\n        if archive_data:\n            image_data = archive_data.get(\"image_data\")\n\n        logger.info(\"Found %s providers for %s: %s\", len(providers), event_field, [p.name for p in providers])\n        title, message = await self._build_message_from_template(db, event_type, variables)\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            event_type,\n            printer_id,\n            printer_name,\n            image_data=image_data,\n            variables=variables,\n        )\n\n    async def on_print_progress(\n        self,\n        printer_id: int,\n        printer_name: str,\n        filename: str,\n        progress: int,\n        db: AsyncSession,\n        remaining_time: int | None = None,\n        image_data: bytes | None = None,\n    ):\n        \"\"\"Handle print progress milestone (25%, 50%, 75%).\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_print_progress\", printer_id)\n        if not providers:\n            return\n\n        eta_str = await self._format_eta(remaining_time, db)\n\n        variables = {\n            \"printer\": printer_name,\n            \"filename\": self._clean_filename(filename),\n            \"progress\": str(progress),\n            \"remaining_time\": self._format_duration(remaining_time) if remaining_time else \"Unknown\",\n            \"eta\": eta_str,\n        }\n\n        title, message = await self._build_message_from_template(db, \"print_progress\", variables)\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"print_progress\",\n            printer_id,\n            printer_name,\n            image_data=image_data,\n            variables=variables,\n        )\n\n    async def on_print_missing_spool_assignment(\n        self,\n        printer_id: int,\n        printer_name: str,\n        missing_slots: list[dict[str, str]],\n        db: AsyncSession,\n    ):\n        \"\"\"Handle print-start event when required trays are missing spool assignments.\"\"\"\n        if not missing_slots:\n            return\n\n        providers = await self._get_providers_for_event(db, \"on_print_missing_spool_assignment\", printer_id)\n        if not providers:\n            return\n\n        missing_slot_names = \", \".join(slot.get(\"slot\", \"Unknown\") for slot in missing_slots)\n        detail_lines = []\n        for slot in missing_slots:\n            slot_name = slot.get(\"slot\", \"Unknown\")\n            profile = slot.get(\"profile\", \"Unknown\")\n            detail_lines.append(f\"- {slot_name}: {profile}\")\n        missing_profile_details = \"\\n\".join(detail_lines)\n\n        variables = {\n            \"printer\": printer_name,\n            \"missing_slots\": missing_slot_names,\n            \"missing_slot_details\": missing_profile_details,\n        }\n\n        title, message = await self._build_message_from_template(db, \"print_missing_spool_assignment\", variables)\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"print_missing_spool_assignment\",\n            printer_id,\n            printer_name,\n            force_immediate=True,\n            variables=variables,\n        )\n\n    async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):\n        \"\"\"Handle printer offline event.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_printer_offline\", printer_id)\n        if not providers:\n            return\n\n        variables = {\"printer\": printer_name}\n\n        title, message = await self._build_message_from_template(db, \"printer_offline\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"printer_offline\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_printer_error(\n        self,\n        printer_id: int,\n        printer_name: str,\n        error_type: str,\n        db: AsyncSession,\n        error_detail: str | None = None,\n        image_data: bytes | None = None,\n    ):\n        \"\"\"Handle printer error event (AMS issues, etc.).\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_printer_error\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"error_type\": error_type,\n            \"error_detail\": error_detail or \"No details available\",\n        }\n\n        title, message = await self._build_message_from_template(db, \"printer_error\", variables)\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"printer_error\",\n            printer_id,\n            printer_name,\n            image_data=image_data,\n            variables=variables,\n        )\n\n    async def on_plate_not_empty(\n        self,\n        printer_id: int,\n        printer_name: str,\n        db: AsyncSession,\n        difference_percent: float | None = None,\n    ):\n        \"\"\"Handle plate not empty event - objects detected on build plate before print.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_plate_not_empty\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"difference_percent\": f\"{difference_percent:.1f}\" if difference_percent else \"N/A\",\n        }\n\n        title, message = await self._build_message_from_template(db, \"plate_not_empty\", variables)\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"plate_not_empty\",\n            printer_id,\n            printer_name,\n            force_immediate=True,\n            variables=variables,\n        )\n\n    async def on_filament_low(\n        self,\n        printer_id: int,\n        printer_name: str,\n        slot: int,\n        remaining_percent: int,\n        db: AsyncSession,\n        color: str | None = None,\n    ):\n        \"\"\"Handle low filament event.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_filament_low\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"slot\": str(slot),\n            \"remaining_percent\": str(remaining_percent),\n            \"color\": color or \"\",\n        }\n\n        title, message = await self._build_message_from_template(db, \"filament_low\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"filament_low\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_maintenance_due(\n        self,\n        printer_id: int,\n        printer_name: str,\n        maintenance_items: list[dict],\n        db: AsyncSession,\n    ):\n        \"\"\"Handle maintenance due event - sends notification when maintenance is due or warning.\"\"\"\n        if not maintenance_items:\n            return\n\n        providers = await self._get_providers_for_event(db, \"on_maintenance_due\", printer_id)\n        if not providers:\n            logger.info(\"No notification providers configured for maintenance_due event on printer %s\", printer_id)\n            return\n\n        # Format maintenance items list\n        items_list = []\n        for item in maintenance_items:\n            status = \"OVERDUE\" if item.get(\"is_due\") else \"Soon\"\n            items_list.append(f\"- {item['name']} ({status})\")\n        items_str = \"\\n\".join(items_list)\n\n        variables = {\n            \"printer\": printer_name,\n            \"items\": items_str,\n        }\n\n        logger.info(\"Found %s providers for maintenance_due: %s\", len(providers), [p.name for p in providers])\n        title, message = await self._build_message_from_template(db, \"maintenance_due\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"maintenance_due\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_ams_humidity_high(\n        self,\n        printer_id: int,\n        printer_name: str,\n        ams_label: str,\n        humidity: float,\n        threshold: float,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle AMS high humidity alarm event. Always sends immediately (bypasses digest).\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_ams_humidity_high\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"ams_label\": ams_label,\n            \"humidity\": f\"{humidity:.0f}\",\n            \"threshold\": f\"{threshold:.0f}\",\n        }\n\n        title, message = await self._build_message_from_template(db, \"ams_humidity_high\", variables)\n        # Alarms always send immediately, bypassing digest mode\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"ams_humidity_high\",\n            printer_id,\n            printer_name,\n            force_immediate=True,\n            variables=variables,\n        )\n\n    async def on_ams_temperature_high(\n        self,\n        printer_id: int,\n        printer_name: str,\n        ams_label: str,\n        temperature: float,\n        threshold: float,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle AMS high temperature alarm event. Always sends immediately (bypasses digest).\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_ams_temperature_high\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"ams_label\": ams_label,\n            \"temperature\": f\"{temperature:.1f}\",\n            \"threshold\": f\"{threshold:.1f}\",\n        }\n\n        title, message = await self._build_message_from_template(db, \"ams_temperature_high\", variables)\n        # Alarms always send immediately, bypassing digest mode\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"ams_temperature_high\",\n            printer_id,\n            printer_name,\n            force_immediate=True,\n            variables=variables,\n        )\n\n    async def on_ams_ht_humidity_high(\n        self,\n        printer_id: int,\n        printer_name: str,\n        ams_label: str,\n        humidity: float,\n        threshold: float,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle AMS-HT high humidity alarm event. Always sends immediately (bypasses digest).\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_ams_ht_humidity_high\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"ams_label\": ams_label,\n            \"humidity\": f\"{humidity:.0f}\",\n            \"threshold\": f\"{threshold:.0f}\",\n        }\n\n        # Use the same template as regular AMS (can create separate templates later if needed)\n        title, message = await self._build_message_from_template(db, \"ams_humidity_high\", variables)\n        # Alarms always send immediately, bypassing digest mode\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"ams_ht_humidity_high\",\n            printer_id,\n            printer_name,\n            force_immediate=True,\n            variables=variables,\n        )\n\n    async def on_ams_ht_temperature_high(\n        self,\n        printer_id: int,\n        printer_name: str,\n        ams_label: str,\n        temperature: float,\n        threshold: float,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle AMS-HT high temperature alarm event. Always sends immediately (bypasses digest).\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_ams_ht_temperature_high\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"ams_label\": ams_label,\n            \"temperature\": f\"{temperature:.1f}\",\n            \"threshold\": f\"{threshold:.1f}\",\n        }\n\n        # Use the same template as regular AMS (can create separate templates later if needed)\n        title, message = await self._build_message_from_template(db, \"ams_temperature_high\", variables)\n        # Alarms always send immediately, bypassing digest mode\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"ams_ht_temperature_high\",\n            printer_id,\n            printer_name,\n            force_immediate=True,\n            variables=variables,\n        )\n\n    async def on_bed_cooled(\n        self,\n        printer_id: int,\n        printer_name: str,\n        bed_temp: float,\n        threshold: float,\n        filename: str,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle bed cooled event - bed temperature dropped below threshold after print.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_bed_cooled\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"bed_temp\": f\"{bed_temp:.0f}\",\n            \"threshold\": f\"{threshold:.0f}\",\n            \"filename\": self._clean_filename(filename) if filename else \"Unknown\",\n        }\n\n        title, message = await self._build_message_from_template(db, \"bed_cooled\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"bed_cooled\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_first_layer_complete(\n        self,\n        printer_id: int,\n        printer_name: str,\n        filename: str,\n        total_layers: int,\n        db: AsyncSession,\n        image_data: bytes | None = None,\n    ):\n        \"\"\"Handle first layer complete event.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_first_layer_complete\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"printer\": printer_name,\n            \"filename\": self._clean_filename(filename),\n            \"total_layers\": str(total_layers),\n        }\n\n        title, message = await self._build_message_from_template(db, \"first_layer_complete\", variables)\n        await self._send_to_providers(\n            providers,\n            title,\n            message,\n            db,\n            \"first_layer_complete\",\n            printer_id,\n            printer_name,\n            image_data=image_data,\n            variables=variables,\n        )\n\n    def clear_template_cache(self):\n        \"\"\"Clear the template cache. Call this when templates are updated.\"\"\"\n        self._template_cache.clear()\n\n    async def send_user_print_email(\n        self,\n        event_type: str,\n        created_by_id: int | None,\n        printer_name: str,\n        filename: str,\n        db: AsyncSession,\n    ) -> None:\n        \"\"\"Send a print event email notification to the user who submitted the job.\n\n        Args:\n            event_type: 'user_print_start', 'user_print_complete', 'user_print_failed', or 'user_print_stopped'\n            created_by_id: User ID who submitted the print job (from archive)\n            printer_name: Name of the printer\n            filename: Raw filename or subtask name\n            db: Database session\n        \"\"\"\n        if created_by_id is None:\n            logger.debug(\"[EMAIL] Skipping user print email (%s): no created_by_id\", event_type)\n            return\n\n        try:\n            # Check if advanced auth is enabled - required for user email notifications\n            from backend.app.models.settings import Settings\n\n            result = await db.execute(select(Settings).where(Settings.key == \"advanced_auth_enabled\"))\n            setting = result.scalar_one_or_none()\n            if not setting or setting.value.lower() != \"true\":\n                logger.debug(\"[EMAIL] Skipping user print email (%s): advanced_auth not enabled\", event_type)\n                return\n\n            # Check if user notifications are enabled (admin-controlled toggle)\n            notif_enabled_result = await db.execute(\n                select(Settings).where(Settings.key == \"user_notifications_enabled\")\n            )\n            notif_enabled_setting = notif_enabled_result.scalar_one_or_none()\n            if notif_enabled_setting and notif_enabled_setting.value.lower() == \"false\":\n                logger.debug(\"[EMAIL] Skipping user print email (%s): user_notifications_enabled is false\", event_type)\n                return\n\n            # Check SMTP settings are configured - required for sending emails\n            from backend.app.services.email_service import get_smtp_settings, send_user_print_notification\n\n            smtp_settings = await get_smtp_settings(db)\n            if not smtp_settings:\n                logger.debug(\"[EMAIL] Skipping user print email (%s): SMTP settings not configured\", event_type)\n                return\n\n            # Load user preferences\n            from backend.app.models.user import User\n            from backend.app.models.user_email_pref import UserEmailPreference\n\n            user_result = await db.execute(select(User).where(User.id == created_by_id))\n            user = user_result.scalar_one_or_none()\n            if user is None or not user.email:\n                logger.debug(\n                    \"[EMAIL] Skipping user print email (%s): user %s not found or has no email address\",\n                    event_type,\n                    created_by_id,\n                )\n                return\n\n            # Load user's notification preferences\n            pref_result = await db.execute(\n                select(UserEmailPreference).where(UserEmailPreference.user_id == created_by_id)\n            )\n            pref = pref_result.scalar_one_or_none()\n\n            # Determine if this event type should be sent\n            should_send = False\n            if event_type == \"user_print_start\":\n                should_send = pref is None or pref.notify_print_start\n            elif event_type == \"user_print_complete\":\n                should_send = pref is None or pref.notify_print_complete\n            elif event_type == \"user_print_failed\":\n                should_send = pref is None or pref.notify_print_failed\n            elif event_type == \"user_print_stopped\":\n                should_send = pref is None or pref.notify_print_stopped\n\n            if not should_send:\n                logger.debug(\n                    \"[EMAIL] Skipping user print email (%s): user %s has notifications disabled for this event\",\n                    event_type,\n                    created_by_id,\n                )\n                return\n\n            logger.info(\n                \"[EMAIL] Sending user print email: event=%s, user=%s (%s), printer=%s, file=%s\",\n                event_type,\n                user.username,\n                user.email,\n                printer_name,\n                filename,\n            )\n\n            # Build variables\n            variables = {\n                \"printer\": printer_name,\n                \"filename\": self._clean_filename(filename),\n            }\n\n            # Send the email\n            await send_user_print_notification(\n                db=db,\n                event_type=event_type,\n                user_email=user.email,\n                username=user.username,\n                variables=variables,\n            )\n            logger.info(\"[EMAIL] User print email sent: event=%s → %s\", event_type, user.email)\n        except Exception as e:\n            logger.warning(\"Failed to send user print email notification: %s\", e, exc_info=True)\n\n    # ==================== Queue Notifications ====================\n\n    async def on_queue_job_added(\n        self,\n        job_name: str,\n        target: str,\n        db: AsyncSession,\n        printer_id: int | None = None,\n        printer_name: str | None = None,\n    ):\n        \"\"\"Handle queue job added event.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_queue_job_added\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"job_name\": job_name,\n            \"target\": target,  # e.g., \"Printer1\" or \"Any X1C\"\n            \"printer\": printer_name or target,\n        }\n\n        title, message = await self._build_message_from_template(db, \"queue_job_added\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"queue_job_added\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_queue_job_assigned(\n        self,\n        job_name: str,\n        printer_id: int,\n        printer_name: str,\n        target_model: str,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle model-based job assigned to printer event.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_queue_job_assigned\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"job_name\": job_name,\n            \"printer\": printer_name,\n            \"target_model\": target_model,\n        }\n\n        title, message = await self._build_message_from_template(db, \"queue_job_assigned\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"queue_job_assigned\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_queue_job_started(\n        self,\n        job_name: str,\n        printer_id: int,\n        printer_name: str,\n        db: AsyncSession,\n        estimated_time: int | None = None,\n    ):\n        \"\"\"Handle queue job started printing event.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_queue_job_started\", printer_id)\n        if not providers:\n            return\n\n        eta_str = await self._format_eta(estimated_time, db)\n\n        variables = {\n            \"job_name\": job_name,\n            \"printer\": printer_name,\n            \"estimated_time\": self._format_duration(estimated_time),\n            \"eta\": eta_str,\n        }\n\n        title, message = await self._build_message_from_template(db, \"queue_job_started\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"queue_job_started\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_queue_job_waiting(\n        self,\n        job_name: str,\n        target_model: str,\n        waiting_reason: str,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle job waiting for filament event.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_queue_job_waiting\", None)\n        if not providers:\n            return\n\n        variables = {\n            \"job_name\": job_name,\n            \"target_model\": target_model,\n            \"waiting_reason\": waiting_reason,\n        }\n\n        title, message = await self._build_message_from_template(db, \"queue_job_waiting\", variables)\n        await self._send_to_providers(providers, title, message, db, \"queue_job_waiting\", variables=variables)\n\n    async def on_queue_job_skipped(\n        self,\n        job_name: str,\n        printer_id: int,\n        printer_name: str,\n        reason: str,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle job skipped event (e.g., previous print failed).\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_queue_job_skipped\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"job_name\": job_name,\n            \"printer\": printer_name,\n            \"reason\": reason,\n        }\n\n        title, message = await self._build_message_from_template(db, \"queue_job_skipped\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"queue_job_skipped\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_queue_job_failed(\n        self,\n        job_name: str,\n        printer_id: int | None,\n        printer_name: str | None,\n        reason: str,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle job failed to start event (upload error, etc.).\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_queue_job_failed\", printer_id)\n        if not providers:\n            return\n\n        variables = {\n            \"job_name\": job_name,\n            \"printer\": printer_name or \"Unknown\",\n            \"reason\": reason,\n        }\n\n        title, message = await self._build_message_from_template(db, \"queue_job_failed\", variables)\n        await self._send_to_providers(\n            providers, title, message, db, \"queue_job_failed\", printer_id, printer_name, variables=variables\n        )\n\n    async def on_queue_completed(\n        self,\n        completed_count: int,\n        db: AsyncSession,\n    ):\n        \"\"\"Handle all queue jobs completed event.\"\"\"\n        providers = await self._get_providers_for_event(db, \"on_queue_completed\", None)\n        if not providers:\n            return\n\n        variables = {\n            \"completed_count\": str(completed_count),\n        }\n\n        title, message = await self._build_message_from_template(db, \"queue_completed\", variables)\n        await self._send_to_providers(providers, title, message, db, \"queue_completed\", variables=variables)\n\n    async def _queue_for_digest(\n        self,\n        provider: NotificationProvider,\n        event_type: str,\n        title: str,\n        message: str,\n        db: AsyncSession,\n        printer_id: int | None = None,\n        printer_name: str | None = None,\n    ):\n        \"\"\"Queue a notification for later delivery in the daily digest.\"\"\"\n        try:\n            queue_entry = NotificationDigestQueue(\n                provider_id=provider.id,\n                event_type=event_type,\n                title=title,\n                message=message,\n                printer_id=printer_id,\n                printer_name=printer_name,\n            )\n            db.add(queue_entry)\n            await db.commit()\n            logger.info(\"Queued notification for digest: %s for provider %s\", event_type, provider.name)\n        except Exception as e:\n            logger.warning(\"Failed to queue notification for digest: %s\", e)\n\n    async def send_digest(self, provider_id: int):\n        \"\"\"Send all queued notifications as a single digest for a provider.\"\"\"\n        from backend.app.core.database import async_session\n\n        async with async_session() as db:\n            # Get the provider\n            result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))\n            provider = result.scalar_one_or_none()\n\n            if not provider or not provider.enabled:\n                return\n\n            # Get all queued notifications for this provider\n            result = await db.execute(\n                select(NotificationDigestQueue)\n                .where(NotificationDigestQueue.provider_id == provider_id)\n                .order_by(NotificationDigestQueue.created_at)\n            )\n            queue_entries = list(result.scalars().all())\n\n            if not queue_entries:\n                logger.debug(\"No queued notifications for provider %s\", provider.name)\n                return\n\n            # Build digest message\n            title = f\"Daily Digest - {len(queue_entries)} Events\"\n\n            # Group by event type\n            events_by_type: dict[str, list] = {}\n            for entry in queue_entries:\n                if entry.event_type not in events_by_type:\n                    events_by_type[entry.event_type] = []\n                events_by_type[entry.event_type].append(entry)\n\n            # Format the digest body\n            body_parts = []\n            for event_type, entries in events_by_type.items():\n                event_label = event_type.replace(\"_\", \" \").title()\n                body_parts.append(f\"== {event_label} ({len(entries)}) ==\")\n                for entry in entries:\n                    time_str = entry.created_at.strftime(\"%H:%M\")\n                    printer_info = f\"[{entry.printer_name}] \" if entry.printer_name else \"\"\n                    body_parts.append(f\"  {time_str} {printer_info}{entry.title}\")\n                body_parts.append(\"\")\n\n            body = \"\\n\".join(body_parts)\n\n            # Send the digest\n            success, error = await self._send_to_provider(provider, title, body, db)\n\n            # Log the digest\n            await self._log_notification(\n                db=db,\n                provider_id=provider.id,\n                event_type=\"daily_digest\",\n                title=title,\n                message=body,\n                success=success,\n                error_message=error if not success else None,\n            )\n\n            # Clear the queue\n            for entry in queue_entries:\n                await db.delete(entry)\n            await db.commit()\n\n            if success:\n                logger.info(\"Sent daily digest with %s events to %s\", len(queue_entries), provider.name)\n            else:\n                logger.warning(\"Failed to send daily digest to %s: %s\", provider.name, error)\n\n    async def check_and_send_digests(self):\n        \"\"\"Check all providers and send digests if it's their scheduled time.\"\"\"\n        from backend.app.core.database import async_session\n\n        current_time = datetime.now().strftime(\"%H:%M\")\n\n        # Avoid duplicate checks within the same minute\n        if current_time == self._last_digest_check:\n            return\n        self._last_digest_check = current_time\n\n        async with async_session() as db:\n            # Find all providers with digest enabled at this time\n            result = await db.execute(\n                select(NotificationProvider).where(\n                    NotificationProvider.enabled.is_(True),\n                    NotificationProvider.daily_digest_enabled.is_(True),\n                    NotificationProvider.daily_digest_time == current_time,\n                )\n            )\n            providers = result.scalars().all()\n\n            for provider in providers:\n                try:\n                    await self.send_digest(provider.id)\n                except Exception as e:\n                    logger.error(\"Error sending digest for provider %s: %s\", provider.id, e)\n\n    def start_digest_scheduler(self):\n        \"\"\"Start the background scheduler for daily digest notifications.\"\"\"\n        if self._digest_scheduler_task is None:\n            self._digest_scheduler_task = asyncio.create_task(self._digest_scheduler_loop())\n            logger.info(\"Notification digest scheduler started\")\n\n    def stop_digest_scheduler(self):\n        \"\"\"Stop the background scheduler for daily digests.\"\"\"\n        if self._digest_scheduler_task:\n            self._digest_scheduler_task.cancel()\n            self._digest_scheduler_task = None\n            logger.info(\"Notification digest scheduler stopped\")\n\n    async def _digest_scheduler_loop(self):\n        \"\"\"Background loop that checks for scheduled digests every minute.\"\"\"\n        while True:\n            try:\n                await self.check_and_send_digests()\n            except Exception as e:\n                logger.error(\"Error in digest scheduler: %s\", e)\n\n            # Wait until the next minute\n            await asyncio.sleep(60)\n\n\n# Global instance\nnotification_service = NotificationService()\n"
  },
  {
    "path": "backend/app/services/obico_actions.py",
    "content": "\"\"\"Action dispatch for Obico failure detection.\n\nSeparated from the detection loop so actions can be unit-tested and swapped.\n\"\"\"\n\nimport logging\n\nfrom sqlalchemy import select\n\nfrom backend.app.core.database import async_session\nfrom backend.app.models.printer import Printer\n\nlogger = logging.getLogger(__name__)\n\n\nasync def execute_action(printer_id: int, action: str, task_name: str, score: float) -> None:\n    \"\"\"Run the configured action for a detected print failure.\n\n    action: 'notify' | 'pause' | 'pause_and_off'\n    \"\"\"\n    printer_name = await _get_printer_name(printer_id)\n\n    if action in (\"pause\", \"pause_and_off\"):\n        _pause_print(printer_id)\n\n    if action == \"pause_and_off\":\n        await _turn_off_linked_plugs(printer_id)\n\n    await _notify(printer_id, printer_name, task_name, score, action)\n\n\nasync def _get_printer_name(printer_id: int) -> str:\n    async with async_session() as db:\n        result = await db.execute(select(Printer).where(Printer.id == printer_id))\n        printer = result.scalar_one_or_none()\n    return printer.name if printer else f\"Printer {printer_id}\"\n\n\ndef _pause_print(printer_id: int) -> None:\n    from backend.app.services.printer_manager import printer_manager\n\n    client = printer_manager.get_client(printer_id)\n    if not client:\n        logger.warning(\"Obico pause: no MQTT client for printer %s\", printer_id)\n        return\n    if not client.pause_print():\n        logger.warning(\"Obico pause: pause_print() returned False for printer %s\", printer_id)\n\n\nasync def _turn_off_linked_plugs(printer_id: int) -> None:\n    from backend.app.services.smart_plug_manager import smart_plug_manager\n\n    async with async_session() as db:\n        plugs = await smart_plug_manager._get_plugs_for_printer(printer_id, db)\n        for plug in plugs:\n            if not plug.enabled:\n                continue\n            try:\n                service = await smart_plug_manager.get_service_for_plug(plug, db)\n                await service.turn_off(plug)\n                logger.info(\"Obico action: turned off plug %s for printer %s\", plug.name, printer_id)\n            except Exception as e:\n                logger.error(\"Obico action: failed to turn off plug %s: %s\", plug.name, e)\n\n\nasync def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None:\n    from backend.app.services.notification_service import notification_service\n\n    detail = (\n        f\"Possible print failure detected on '{task_name or 'current job'}' \"\n        f\"(confidence {score:.2f}). Action taken: {action}.\"\n    )\n    async with async_session() as db:\n        try:\n            await notification_service.on_printer_error(\n                printer_id=printer_id,\n                printer_name=printer_name,\n                error_type=\"ai_failure_detection\",\n                db=db,\n                error_detail=detail,\n            )\n        except Exception as e:\n            logger.error(\"Obico notify failed for printer %s: %s\", printer_id, e)\n"
  },
  {
    "path": "backend/app/services/obico_detection.py",
    "content": "\"\"\"Obico AI print-failure detection service.\n\nPolls a self-hosted Obico ML API with snapshots from each monitored printer\nwhile a print is running, smooths scores over time, and dispatches a configured\naction (notify / pause / pause_and_off) when a sustained failure is detected.\n\nSee `obico_smoothing.py` for the per-print EWM + rolling-mean math.\n\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport secrets\nimport time\nfrom collections import deque\nfrom datetime import datetime, timezone\n\nimport httpx\nfrom sqlalchemy import select\n\nfrom backend.app.core.database import async_session\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.settings import Settings\nfrom backend.app.services.obico_smoothing import (\n    PrintState,\n    classify,\n    score_from_detections,\n    thresholds,\n)\n\nlogger = logging.getLogger(__name__)\n\nHISTORY_MAX = 50\nHEALTH_TIMEOUT = 5.0\nDETECTION_TIMEOUT = 30.0\nSNAPSHOT_CAPTURE_TIMEOUT = 20  # seconds — we control this, not Obico\nFRAME_CACHE_TTL = 30.0  # seconds — Obico usually fetches within 1s of receiving the URL\n\n# Module-level one-shot frame cache. Obico's ML API is GET-only (/p/?img=URL) and\n# fetches the URL itself with a hardcoded 5s read timeout. We capture locally first,\n# stash the JPEG under a random nonce, and hand Obico a URL that serves the cached\n# bytes instantly — so the 5s ceiling never races RTSP keyframe wait.\n_frame_cache: dict[str, tuple[bytes, float]] = {}\n_frame_cache_lock = asyncio.Lock()\n\n\ndef _prune_frame_cache() -> None:\n    \"\"\"Drop entries older than FRAME_CACHE_TTL. Called under the cache lock.\"\"\"\n    now = time.monotonic()\n    expired = [k for k, (_b, ts) in _frame_cache.items() if now - ts > FRAME_CACHE_TTL]\n    for k in expired:\n        _frame_cache.pop(k, None)\n\n\nasync def stash_frame(data: bytes) -> str:\n    \"\"\"Store JPEG bytes and return a URL-safe nonce that serves them once.\"\"\"\n    nonce = secrets.token_urlsafe(32)\n    async with _frame_cache_lock:\n        _prune_frame_cache()\n        _frame_cache[nonce] = (data, time.monotonic())\n    return nonce\n\n\nasync def pop_frame(nonce: str) -> bytes | None:\n    \"\"\"Return and remove a cached frame by nonce; None if missing or expired.\"\"\"\n    async with _frame_cache_lock:\n        _prune_frame_cache()\n        entry = _frame_cache.pop(nonce, None)\n    if entry is None:\n        return None\n    data, ts = entry\n    if time.monotonic() - ts > FRAME_CACHE_TTL:\n        return None\n    return data\n\n\nclass ObicoDetectionService:\n    \"\"\"Singleton service that polls the ML API and acts on sustained failures.\"\"\"\n\n    def __init__(self):\n        self._task: asyncio.Task | None = None\n        # printer_id -> PrintState (reset when a new print starts)\n        self._states: dict[int, PrintState] = {}\n        # printer_id -> task_name active when state was created (used to detect new prints)\n        self._state_keys: dict[int, str] = {}\n        # printer_id -> last classification (\"safe\"/\"warning\"/\"failure\")\n        self._last_class: dict[int, str] = {}\n        # printer_id -> whether an action has already been fired for the current print\n        self._action_fired: dict[int, bool] = {}\n        # Global detection event log (most-recent-first)\n        self._history: deque = deque(maxlen=HISTORY_MAX)\n        self._last_error: str | None = None\n\n    # ---- lifecycle ----\n\n    async def start(self):\n        if self._task is not None:\n            return\n        logger.info(\"Starting Obico detection service\")\n        self._task = asyncio.create_task(self._loop())\n\n    def stop(self):\n        if self._task:\n            self._task.cancel()\n            self._task = None\n            logger.info(\"Stopped Obico detection service\")\n\n    # ---- settings ----\n\n    async def _load_settings(self) -> dict:\n        keys = [\n            \"obico_enabled\",\n            \"obico_ml_url\",\n            \"obico_sensitivity\",\n            \"obico_action\",\n            \"obico_poll_interval\",\n            \"obico_enabled_printers\",\n            \"external_url\",\n        ]\n        async with async_session() as db:\n            result = await db.execute(select(Settings).where(Settings.key.in_(keys)))\n            rows = {r.key: r.value for r in result.scalars().all()}\n\n        enabled_printers_raw = rows.get(\"obico_enabled_printers\", \"\")\n        if enabled_printers_raw:\n            try:\n                enabled_printers = set(json.loads(enabled_printers_raw))\n            except json.JSONDecodeError:\n                enabled_printers = set()\n        else:\n            enabled_printers = None  # None = all printers\n\n        return {\n            \"enabled\": rows.get(\"obico_enabled\", \"false\").lower() == \"true\",\n            \"ml_url\": (rows.get(\"obico_ml_url\") or \"\").rstrip(\"/\"),\n            \"sensitivity\": rows.get(\"obico_sensitivity\", \"medium\"),\n            \"action\": rows.get(\"obico_action\", \"notify\"),\n            \"poll_interval\": int(rows.get(\"obico_poll_interval\", \"10\")),\n            \"enabled_printers\": enabled_printers,\n            \"external_url\": (rows.get(\"external_url\") or \"\").rstrip(\"/\"),\n        }\n\n    # ---- main loop ----\n\n    async def _loop(self):\n        \"\"\"Poll active printers while enabled. Adjusts interval from settings each cycle.\"\"\"\n        while True:\n            try:\n                settings = await self._load_settings()\n                interval = max(5, settings.get(\"poll_interval\", 10))\n                if not settings[\"enabled\"] or not settings[\"ml_url\"]:\n                    await asyncio.sleep(interval)\n                    continue\n\n                await self._poll_once(settings)\n                await asyncio.sleep(interval)\n            except asyncio.CancelledError:\n                break\n            except Exception as e:\n                logger.error(\"Obico detection loop error: %s\", e)\n                self._last_error = str(e) or type(e).__name__\n                await asyncio.sleep(30)\n\n    async def _poll_once(self, settings: dict):\n        # Late import to avoid cycles at module load time\n        from backend.app.services.printer_manager import printer_manager\n\n        statuses = printer_manager.get_all_statuses()\n        for printer_id, status in list(statuses.items()):\n            if settings[\"enabled_printers\"] is not None and printer_id not in settings[\"enabled_printers\"]:\n                continue\n            if not printer_manager.is_connected(printer_id):\n                continue\n            if not status or getattr(status, \"state\", None) != \"RUNNING\":\n                # Reset state when not printing so the next print starts fresh\n                self._states.pop(printer_id, None)\n                self._state_keys.pop(printer_id, None)\n                self._action_fired.pop(printer_id, None)\n                continue\n\n            await self._check_printer(printer_id, status, settings)\n\n    async def _capture_frame(self, printer_id: int) -> bytes | None:\n        \"\"\"Capture one JPEG frame from the printer camera. Returns None on failure.\"\"\"\n        # Late import to avoid cycles at module load time\n        from backend.app.services.camera import capture_camera_frame_bytes\n        from backend.app.services.external_camera import capture_frame as capture_external_frame\n\n        async with async_session() as db:\n            printer = await db.get(Printer, printer_id)\n        if printer is None:\n            self._last_error = f\"Printer {printer_id} not found\"\n            return None\n\n        if printer.external_camera_enabled and printer.external_camera_url:\n            return await capture_external_frame(\n                printer.external_camera_url,\n                printer.external_camera_type,\n                timeout=SNAPSHOT_CAPTURE_TIMEOUT,\n            )\n        return await capture_camera_frame_bytes(\n            ip_address=printer.ip_address,\n            access_code=printer.access_code,\n            model=printer.model,\n            timeout=SNAPSHOT_CAPTURE_TIMEOUT,\n        )\n\n    async def _check_printer(self, printer_id: int, status, settings: dict):\n        task_name = getattr(status, \"task_name\", None) or getattr(status, \"subtask_name\", \"\") or \"\"\n        key = f\"{task_name}\"\n        if self._state_keys.get(printer_id) != key:\n            self._states[printer_id] = PrintState()\n            self._state_keys[printer_id] = key\n            self._action_fired[printer_id] = False\n\n        # Capture locally first, then hand Obico a nonce URL that returns the\n        # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a\n        # hardcoded 5s read timeout which would otherwise race our /camera/snapshot\n        # keyframe wait.\n        frame = await self._capture_frame(printer_id)\n        if not frame:\n            self._last_error = f\"Failed to capture snapshot for printer {printer_id}\"\n            logger.warning(self._last_error)\n            return\n\n        external_url = settings.get(\"external_url\") or \"\"\n        if not external_url:\n            self._last_error = (\n                \"external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. \"\n                \"Set Settings → General → External URL.\"\n            )\n            logger.warning(self._last_error)\n            return\n\n        nonce = await stash_frame(frame)\n        snapshot_url = f\"{external_url}/api/v1/obico/cached-frame/{nonce}\"\n        ml_url = f\"{settings['ml_url']}/p/\"\n\n        try:\n            async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:\n                resp = await client.get(ml_url, params={\"img\": snapshot_url})\n                resp.raise_for_status()\n                payload = resp.json()\n        except Exception as e:\n            detail = str(e) or type(e).__name__\n            self._last_error = f\"ML API call failed for printer {printer_id}: {detail}\"\n            logger.warning(self._last_error)\n            return\n\n        detections = payload.get(\"detections\", []) if isinstance(payload, dict) else []\n        current_p = score_from_detections(detections)\n        state = self._states[printer_id]\n        score = state.update(current_p)\n        verdict = classify(score, settings[\"sensitivity\"])\n        self._last_class[printer_id] = verdict\n        # A successful capture + ML call clears any transient error from previous\n        # polls (typical case: cold-start RTSP timeout on first frame after startup,\n        # followed by healthy polls that otherwise leave the banner stuck in the UI).\n        self._last_error = None\n\n        # Log every non-safe sample — safe samples would flood history\n        if verdict != \"safe\" or detections:\n            self._history.appendleft(\n                {\n                    \"printer_id\": printer_id,\n                    \"task_name\": task_name,\n                    \"timestamp\": datetime.now(timezone.utc).isoformat(),\n                    \"current_p\": round(current_p, 4),\n                    \"score\": round(score, 4),\n                    \"class\": verdict,\n                    \"detections\": len(detections),\n                }\n            )\n\n        if verdict == \"failure\" and not self._action_fired.get(printer_id):\n            self._action_fired[printer_id] = True\n            await self._dispatch_action(printer_id, settings[\"action\"], task_name, score)\n\n    async def _dispatch_action(self, printer_id: int, action: str, task_name: str, score: float):\n        from backend.app.services.obico_actions import execute_action\n\n        logger.warning(\n            \"Obico: failure detected on printer %s (task=%r score=%.3f) — action=%s\",\n            printer_id,\n            task_name,\n            score,\n            action,\n        )\n        try:\n            await execute_action(printer_id, action, task_name, score)\n        except Exception as e:\n            self._last_error = f\"Action dispatch failed: {e or type(e).__name__}\"\n            logger.error(self._last_error)\n\n    # ---- queries ----\n\n    def get_status(self) -> dict:\n        low, high = thresholds(\"medium\")\n        return {\n            \"is_running\": self._task is not None and not self._task.done(),\n            \"last_error\": self._last_error,\n            \"per_printer\": {\n                pid: {\n                    \"class\": self._last_class.get(pid, \"safe\"),\n                    \"frame_count\": state.frame_count,\n                    \"score\": round(state.ewm_mean, 4),\n                }\n                for pid, state in self._states.items()\n            },\n            \"thresholds\": {\"low\": low, \"high\": high},\n            \"history\": list(self._history),\n        }\n\n    async def test_connection(self, url: str) -> dict:\n        \"\"\"Ping the ML API health endpoint. Returns {ok, status_code, body, error}.\"\"\"\n        target = f\"{url.rstrip('/')}/hc/\"\n        try:\n            async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:\n                resp = await client.get(target)\n            body = resp.text.strip()\n            return {\n                \"ok\": resp.status_code == 200 and body.lower() == \"ok\",\n                \"status_code\": resp.status_code,\n                \"body\": body,\n                \"error\": None,\n            }\n        except Exception as e:\n            return {\"ok\": False, \"status_code\": None, \"body\": None, \"error\": str(e) or type(e).__name__}\n\n\nobico_detection_service = ObicoDetectionService()\n"
  },
  {
    "path": "backend/app/services/obico_smoothing.py",
    "content": "\"\"\"Temporal smoothing for Obico ML detection scores.\n\nPorts Obico's failure-detection math:\n- per-frame `current_p` = sum of detection confidences\n- `ewm_mean` = exponentially weighted mean (alpha = 2 / (span + 1), span = 12)\n- `rolling_mean_short` = ~310 frames of recent activity (≈52 min at 10s/frame)\n- `rolling_mean_long`  = ~7200 frames of long-term baseline noise\n- First `WARMUP_FRAMES` frames always report \"safe\" while the state settles\n- Final score = max(ewm_mean, rolling_mean_short - rolling_mean_long)\n- Thresholds: LOW < score < HIGH is \"warning\", >= HIGH is \"failure\"\n\"\"\"\n\nimport math\nfrom collections import deque\nfrom dataclasses import dataclass, field\n\nEWM_SPAN = 12\nEWM_ALPHA = 2.0 / (EWM_SPAN + 1)\nROLLING_SHORT = 310\nROLLING_LONG = 7200\nWARMUP_FRAMES = 30\n\n# Base thresholds; sensitivity multipliers adjust them\nBASE_LOW = 0.38\nBASE_HIGH = 0.78\n\nSENSITIVITY_MULT = {\n    \"low\": 1.25,  # harder to trigger — higher thresholds\n    \"medium\": 1.0,\n    \"high\": 0.75,  # easier to trigger — lower thresholds\n}\n\n\ndef thresholds(sensitivity: str) -> tuple[float, float]:\n    mult = SENSITIVITY_MULT.get(sensitivity, 1.0)\n    return BASE_LOW * mult, BASE_HIGH * mult\n\n\n@dataclass\nclass PrintState:\n    \"\"\"Per-print smoothing state. Reset when a new print starts.\"\"\"\n\n    frame_count: int = 0\n    ewm_mean: float = 0.0\n    short_sum: float = 0.0\n    long_sum: float = 0.0\n    short_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_SHORT))\n    long_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_LONG))\n\n    def update(self, current_p: float) -> float:\n        \"\"\"Feed a new per-frame score and return the smoothed score.\n\n        Returns 0.0 during warmup so early noise doesn't trigger actions.\n        \"\"\"\n        self.frame_count += 1\n\n        if self.frame_count == 1:\n            self.ewm_mean = current_p\n        else:\n            self.ewm_mean = EWM_ALPHA * current_p + (1 - EWM_ALPHA) * self.ewm_mean\n\n        if len(self.short_buf) == self.short_buf.maxlen:\n            self.short_sum -= self.short_buf[0]\n        self.short_buf.append(current_p)\n        self.short_sum += current_p\n\n        if len(self.long_buf) == self.long_buf.maxlen:\n            self.long_sum -= self.long_buf[0]\n        self.long_buf.append(current_p)\n        self.long_sum += current_p\n\n        if self.frame_count <= WARMUP_FRAMES:\n            return 0.0\n\n        short_mean = self.short_sum / len(self.short_buf)\n        long_mean = self.long_sum / len(self.long_buf)\n        return max(self.ewm_mean, short_mean - long_mean)\n\n\ndef classify(score: float, sensitivity: str) -> str:\n    \"\"\"Return 'safe', 'warning', or 'failure' for a smoothed score.\"\"\"\n    low, high = thresholds(sensitivity)\n    if score >= high:\n        return \"failure\"\n    if score >= low:\n        return \"warning\"\n    return \"safe\"\n\n\ndef score_from_detections(detections: list) -> float:\n    \"\"\"Sum confidences from the ML API `detections` array.\n\n    Each detection is `[label, confidence, [x, y, w, h]]`. We only care about\n    the confidence column — label is always \"failure\" for the single-class model.\n    \"\"\"\n    total = 0.0\n    for det in detections or []:\n        try:\n            value = float(det[1])\n        except (IndexError, TypeError, ValueError):\n            continue\n        if math.isnan(value) or math.isinf(value):\n            continue\n        total += value\n    return total\n"
  },
  {
    "path": "backend/app/services/opentag3d.py",
    "content": "\"\"\"OpenTag3D NDEF encoder for NTAG tags.\n\nEncodes spool data as an OpenTag3D NDEF message ready to write to NTAG\nstarting at page 4 (after the manufacturer pages).\n\nNDEF structure:\n  [CC: E1 10 12 00]              - Capability Container (4 bytes, page 4)\n  [TLV: 03 len]                  - NDEF Message TLV (2 bytes)\n  [NDEF record header]           - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)\n  [Type: \"application/opentag3d\"] - 21 bytes\n  [Payload: OpenTag3D fields]    - 102 bytes\n  [Terminator: FE]               - 1 byte\n\"\"\"\n\nimport struct\n\nfrom backend.app.models.spool import Spool\n\nOPENTAG3D_MIME_TYPE = b\"application/opentag3d\"\nPAYLOAD_SIZE = 102\nTAG_VERSION = 1000  # v1.000\n\n\ndef _build_payload(spool: Spool) -> bytes:\n    \"\"\"Build 102-byte OpenTag3D core payload from spool fields.\"\"\"\n    buf = bytearray(PAYLOAD_SIZE)\n\n    # 0x00: Tag Version (2 bytes, big-endian)\n    struct.pack_into(\">H\", buf, 0x00, TAG_VERSION)\n\n    # 0x02: Base Material (5 bytes, UTF-8, space-padded)\n    material = (spool.material or \"\")[:5].ljust(5)\n    buf[0x02:0x07] = material.encode(\"utf-8\")[:5]\n\n    # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)\n    modifiers = (spool.subtype or \"\")[:5].ljust(5)\n    buf[0x07:0x0C] = modifiers.encode(\"utf-8\")[:5]\n\n    # 0x0C: Reserved (15 bytes, zero-fill) — already zero\n\n    # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)\n    brand = (spool.brand or \"\")[:16].ljust(16)\n    buf[0x1B:0x2B] = brand.encode(\"utf-8\")[:16]\n\n    # 0x2B: Color Name (32 bytes, UTF-8, space-padded)\n    color_name = (spool.color_name or \"\")[:32].ljust(32)\n    buf[0x2B:0x4B] = color_name.encode(\"utf-8\")[:32]\n\n    # 0x4B: Color 1 RGBA (4 bytes)\n    rgba_hex = spool.rgba or \"00000000\"\n    try:\n        rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, \"0\"))\n    except ValueError:\n        rgba_bytes = b\"\\x00\\x00\\x00\\x00\"\n    buf[0x4B:0x4F] = rgba_bytes[:4]\n\n    # 0x4F: Colors 2-4 (12 bytes, zero-fill) — already zero\n\n    # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm\n    struct.pack_into(\">H\", buf, 0x5C, 1750)\n\n    # 0x5E: Target Weight (2 bytes, big-endian)\n    struct.pack_into(\">H\", buf, 0x5E, spool.label_weight or 0)\n\n    # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5\n    buf[0x60] = (spool.nozzle_temp_min or 0) // 5\n\n    # 0x61: Bed Temp (1 byte) — not tracked\n    # 0x62: Density (2 bytes) — not tracked\n    # 0x64: Transmission Distance (2 bytes) — not tracked\n    # All zero — already zero\n\n    return bytes(buf)\n\n\ndef encode_opentag3d(spool: Spool) -> bytes:\n    \"\"\"Encode spool data as OpenTag3D NDEF message (CC + TLV + record + terminator).\n\n    Returns raw bytes ready to write to NTAG starting at page 4.\n    \"\"\"\n    payload = _build_payload(spool)\n    mime_type = OPENTAG3D_MIME_TYPE\n\n    # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2\n    # Type length = 21\n    # Payload length = 102 (fits in SR single byte)\n    record_header = bytes([0xD2, len(mime_type), len(payload)])\n    ndef_record = record_header + mime_type + payload\n\n    # TLV: type=0x03 (NDEF Message), length\n    ndef_len = len(ndef_record)\n    if ndef_len < 0xFF:\n        tlv = bytes([0x03, ndef_len])\n    else:\n        tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])\n\n    # Capability Container (page 4)\n    cc = bytes([0xE1, 0x10, 0x12, 0x00])\n\n    # Terminator TLV\n    terminator = bytes([0xFE])\n\n    return cc + tlv + ndef_record + terminator\n"
  },
  {
    "path": "backend/app/services/orca_profiles.py",
    "content": "\"\"\"Service for importing and resolving OrcaSlicer profiles.\n\nHandles:\n- Parsing .json, .orca_filament, .zip exports\n- Fetching base Bambu profiles from OrcaSlicer GitHub for inheritance resolution\n- Caching base profiles in the database with TTL\n- Extracting core fields for quick access\n\"\"\"\n\nimport io\nimport json\nimport logging\nimport zipfile\nfrom datetime import datetime, timedelta, timezone\n\nimport httpx\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.local_preset import LocalPreset\nfrom backend.app.models.orca_base_cache import OrcaBaseProfile\n\nlogger = logging.getLogger(__name__)\n\nORCA_BASE_URL = \"https://raw.githubusercontent.com/SoftFever/OrcaSlicer/main/resources/profiles/BBL\"\nCACHE_TTL_DAYS = 7\nMAX_INHERITANCE_DEPTH = 10\n\n\nasync def get_cached_base_profile(name: str, db: AsyncSession) -> dict | None:\n    \"\"\"Get a base profile from cache if still fresh.\"\"\"\n    result = await db.execute(select(OrcaBaseProfile).where(OrcaBaseProfile.name == name))\n    profile = result.scalar_one_or_none()\n    if not profile:\n        return None\n\n    # Check TTL\n    cutoff = datetime.now(timezone.utc) - timedelta(days=CACHE_TTL_DAYS)\n    fetched = profile.fetched_at\n    if fetched.tzinfo is None:\n        fetched = fetched.replace(tzinfo=timezone.utc)\n    if fetched < cutoff:\n        return None\n\n    try:\n        return json.loads(profile.setting)\n    except Exception:\n        return None\n\n\nasync def fetch_and_cache_base_profile(name: str, profile_type: str, db: AsyncSession) -> dict | None:\n    \"\"\"Fetch a base profile from OrcaSlicer GitHub and cache it.\"\"\"\n    # Check cache first\n    cached = await get_cached_base_profile(name, db)\n    if cached is not None:\n        return cached\n\n    # Map profile_type to GitHub subdirectory\n    type_dirs = {\n        \"filament\": \"filament\",\n        \"machine\": \"machine\",\n        \"printer\": \"machine\",\n        \"process\": \"process\",\n    }\n    subdir = type_dirs.get(profile_type, \"filament\")\n\n    # Try fetching from GitHub\n    urls_to_try = [\n        f\"{ORCA_BASE_URL}/{subdir}/{name}.json\",\n    ]\n    # Also try filament dir as fallback for any type\n    if subdir != \"filament\":\n        urls_to_try.append(f\"{ORCA_BASE_URL}/filament/{name}.json\")\n\n    data = None\n    async with httpx.AsyncClient(timeout=15.0) as client:\n        for url in urls_to_try:\n            try:\n                resp = await client.get(url)\n                if resp.status_code == 200:\n                    data = resp.json()\n                    break\n            except Exception as e:\n                logger.debug(\"Failed to fetch %s: %s\", url, e)\n\n    if data is None:\n        logger.warning(\"Could not fetch base profile '%s' from GitHub\", name)\n        return None\n\n    # Cache in DB\n    setting_json = json.dumps(data)\n    result = await db.execute(select(OrcaBaseProfile).where(OrcaBaseProfile.name == name))\n    existing = result.scalar_one_or_none()\n    if existing:\n        existing.setting = setting_json\n        existing.profile_type = profile_type\n        existing.fetched_at = datetime.now(timezone.utc)\n    else:\n        cache_entry = OrcaBaseProfile(\n            name=name,\n            profile_type=profile_type,\n            setting=setting_json,\n            fetched_at=datetime.now(timezone.utc),\n        )\n        db.add(cache_entry)\n\n    return data\n\n\nasync def resolve_preset(preset_data: dict, profile_type: str, db: AsyncSession, depth: int = 0) -> dict:\n    \"\"\"Recursively resolve inheritance chain, merging parent into child.\n\n    OrcaSlicer uses shallow merge: child keys fully replace parent keys.\n    \"\"\"\n    if depth >= MAX_INHERITANCE_DEPTH:\n        logger.warning(\"Inheritance depth limit reached for preset\")\n        return preset_data\n\n    inherits = preset_data.get(\"inherits\")\n    if not inherits:\n        return preset_data\n\n    # Fetch the base profile\n    base = await fetch_and_cache_base_profile(inherits, profile_type, db)\n    if base is None:\n        logger.warning(\"Cannot resolve inherits='%s' — base profile not found\", inherits)\n        return preset_data\n\n    # Recursively resolve the base first\n    resolved_base = await resolve_preset(base, profile_type, db, depth + 1)\n\n    # Shallow merge: start with base, override with child\n    merged = {**resolved_base, **preset_data}\n    return merged\n\n\ndef extract_core_fields(data: dict) -> dict:\n    \"\"\"Extract commonly needed fields from a resolved preset for quick access.\"\"\"\n    fields: dict = {}\n\n    # filament_type — often a single-element array like [\"PLA\"]\n    ft = data.get(\"filament_type\")\n    if isinstance(ft, list) and ft:\n        fields[\"filament_type\"] = str(ft[0])\n    elif isinstance(ft, str):\n        fields[\"filament_type\"] = ft\n\n    # filament_vendor\n    fv = data.get(\"filament_vendor\")\n    if isinstance(fv, list) and fv:\n        fields[\"filament_vendor\"] = str(fv[0])\n    elif isinstance(fv, str):\n        fields[\"filament_vendor\"] = fv\n\n    # nozzle_temp_min / max — from nozzle_temperature array or range fields\n    nozzle_temp = data.get(\"nozzle_temperature\")\n    if isinstance(nozzle_temp, list) and nozzle_temp:\n        try:\n            temps = [int(t) for t in nozzle_temp if str(t).isdigit()]\n            if temps:\n                fields[\"nozzle_temp_min\"] = min(temps)\n                fields[\"nozzle_temp_max\"] = max(temps)\n        except (ValueError, TypeError):\n            pass\n\n    # Override with explicit range fields if present\n    range_low = data.get(\"nozzle_temperature_range_low\")\n    range_high = data.get(\"nozzle_temperature_range_high\")\n    if isinstance(range_low, list) and range_low:\n        try:\n            fields[\"nozzle_temp_min\"] = int(range_low[0])\n        except (ValueError, TypeError):\n            pass\n    if isinstance(range_high, list) and range_high:\n        try:\n            fields[\"nozzle_temp_max\"] = int(range_high[0])\n        except (ValueError, TypeError):\n            pass\n\n    # pressure_advance — store as JSON string if it's an array\n    pa = data.get(\"pressure_advance\")\n    if pa is not None:\n        fields[\"pressure_advance\"] = json.dumps(pa) if isinstance(pa, list) else str(pa)\n\n    # default_filament_colour\n    colour = data.get(\"default_filament_colour\")\n    if colour is not None:\n        fields[\"default_filament_colour\"] = json.dumps(colour) if isinstance(colour, list) else str(colour)\n\n    # filament_cost\n    cost = data.get(\"filament_cost\")\n    if isinstance(cost, list) and cost:\n        fields[\"filament_cost\"] = str(cost[0])\n    elif cost is not None:\n        fields[\"filament_cost\"] = str(cost)\n\n    # filament_density\n    density = data.get(\"filament_density\")\n    if isinstance(density, list) and density:\n        fields[\"filament_density\"] = str(density[0])\n    elif density is not None:\n        fields[\"filament_density\"] = str(density)\n\n    # compatible_printers\n    compat = data.get(\"compatible_printers\")\n    if isinstance(compat, list):\n        fields[\"compatible_printers\"] = json.dumps(compat)\n\n    return fields\n\n\nMATERIAL_TYPES = [\n    \"PLA\",\n    \"ABS\",\n    \"ASA\",\n    \"PETG\",\n    \"TPU\",\n    \"PA\",\n    \"PC\",\n    \"PVA\",\n    \"HIPS\",\n    \"PET\",\n    \"PP\",\n    \"PEI\",\n    \"PEEK\",\n    \"PCTG\",\n    \"PPA\",\n    \"POM\",\n]\n\n\ndef _parse_material_from_name(name: str) -> str | None:\n    \"\"\"Extract filament material type from preset name, e.g. 'Overture PLA Matte' -> 'PLA'.\n\n    Handles 'X Support for Y' patterns where the filament type is Y, not X.\n    e.g. 'PLA Support for PETG PETG Basic @Bambu Lab H2D' -> 'PETG'.\n    \"\"\"\n    import re\n\n    upper = name.upper()\n\n    # Handle \"X Support for Y\" pattern: the filament type is Y, not X.\n    support_match = re.search(r\"\\bSUPPORT\\s+FOR\\s+\", upper)\n    if support_match:\n        after_support = upper[support_match.end() :]\n        for mat in MATERIAL_TYPES:\n            if re.search(rf\"\\b{mat}\\b\", after_support):\n                return mat\n\n    for mat in MATERIAL_TYPES:\n        if re.search(rf\"\\b{mat}\\b\", upper):\n            return mat\n    return None\n\n\ndef _parse_vendor_from_name(name: str) -> str | None:\n    \"\"\"Extract vendor from preset name, e.g. 'Overture PLA Matte @BBL X1C' -> 'Overture'.\"\"\"\n    import re\n\n    # Strip @printer suffix\n    clean = re.sub(r\"@.+$\", \"\", name).strip()\n    upper = clean.upper()\n    for mat in MATERIAL_TYPES:\n        idx = upper.find(mat)\n        if idx > 0:\n            vendor = clean[:idx].strip()\n            if vendor and len(vendor) > 1:\n                return vendor\n    return None\n\n\ndef _type_from_path(zip_entry: str) -> str | None:\n    \"\"\"Infer profile type from the ZIP directory path.\"\"\"\n    parts = zip_entry.lower().replace(\"\\\\\", \"/\").split(\"/\")\n    for part in parts:\n        if part in (\"filament\",):\n            return \"filament\"\n        if part in (\"machine\", \"printer\"):\n            return \"printer\"\n        if part in (\"process\", \"print\"):\n            return \"process\"\n    return None\n\n\ndef _guess_profile_type(data: dict, path_hint: str | None = None) -> str:\n    \"\"\"Determine the profile type from JSON data and optional ZIP path hint.\"\"\"\n    import re\n\n    # 1. Explicit \"type\" field set by OrcaSlicer\n    explicit = data.get(\"type\", \"\").lower()\n    if explicit in (\"filament\",):\n        return \"filament\"\n    if explicit in (\"machine\", \"printer\"):\n        return \"printer\"\n    if explicit in (\"process\", \"print\"):\n        return \"process\"\n\n    # 2. ZIP directory path hint (e.g. \"filament/MyPreset.json\")\n    if path_hint:\n        from_path = _type_from_path(path_hint)\n        if from_path:\n            return from_path\n\n    # 3. Strong ID-based heuristics — *_settings_id is definitive\n    if \"print_settings_id\" in data:\n        return \"process\"\n    if \"filament_settings_id\" in data:\n        return \"filament\"\n    if \"printer_settings_id\" in data:\n        return \"printer\"\n\n    # 4. Content-based heuristics — check process BEFORE filament because\n    #    resolved process presets can inherit filament_type from their base\n    process_keys = {\n        \"layer_height\",\n        \"first_layer_height\",\n        \"wall_loops\",\n        \"prime_tower_width\",\n        \"prime_tower_max_speed\",\n        \"prime_tower_rib_wall\",\n        \"outer_wall_speed\",\n        \"inner_wall_speed\",\n        \"interlocking_depth\",\n        \"bottom_shell_layers\",\n        \"top_shell_layers\",\n        \"sparse_infill_density\",\n    }\n    if process_keys & data.keys():\n        return \"process\"\n    if \"machine_max_speed_x\" in data or \"printer_model\" in data or \"bed_shape\" in data:\n        return \"printer\"\n    if \"filament_type\" in data or \"filament_vendor\" in data:\n        return \"filament\"\n\n    # 5. Name-based heuristics as last resort\n    name = data.get(\"name\", \"\")\n    if re.search(r\"\\d+\\.\\d+mm\\s\", name):\n        return \"process\"\n    if name.lower().endswith(\"process\"):\n        return \"process\"\n\n    return \"filament\"\n\n\nasync def import_orca_file(filename: str, content: bytes, db: AsyncSession) -> dict:\n    \"\"\"Import presets from a file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip).\n\n    Returns dict with keys: success, imported, skipped, errors.\n    \"\"\"\n    imported = 0\n    skipped = 0\n    errors: list[str] = []\n\n    # Determine file type\n    lower_name = filename.lower()\n\n    if lower_name.endswith(\".json\"):\n        # Single JSON preset\n        try:\n            data = json.loads(content)\n            result = await _import_single_preset(data, db, path_hint=filename)\n            if result == \"imported\":\n                imported += 1\n            elif result == \"skipped\":\n                skipped += 1\n            else:\n                errors.append(result)\n        except json.JSONDecodeError as e:\n            errors.append(f\"Invalid JSON: {e}\")\n    elif lower_name.endswith((\".orca_filament\", \".zip\", \".bbscfg\", \".bbsflmt\")):\n        # ZIP archive — extract and parse each JSON\n        try:\n            with zipfile.ZipFile(io.BytesIO(content)) as zf:\n                for entry in zf.namelist():\n                    if entry.endswith(\".json\") and \"bundle_structure\" not in entry:\n                        try:\n                            raw = zf.read(entry)\n                            data = json.loads(raw)\n                            result = await _import_single_preset(data, db, path_hint=entry)\n                            if result == \"imported\":\n                                imported += 1\n                            elif result == \"skipped\":\n                                skipped += 1\n                            else:\n                                errors.append(f\"{entry}: {result}\")\n                        except json.JSONDecodeError:\n                            errors.append(f\"{entry}: Invalid JSON\")\n                        except Exception as e:\n                            errors.append(f\"{entry}: {e}\")\n        except zipfile.BadZipFile:\n            errors.append(\"Invalid ZIP/orca_filament archive\")\n    else:\n        errors.append(f\"Unsupported file type: {filename}\")\n\n    return {\n        \"success\": imported > 0 or (imported == 0 and skipped > 0 and not errors),\n        \"imported\": imported,\n        \"skipped\": skipped,\n        \"errors\": errors,\n    }\n\n\nasync def _import_single_preset(data: dict, db: AsyncSession, path_hint: str | None = None) -> str:\n    \"\"\"Import a single preset dict. Returns 'imported', 'skipped', or error string.\"\"\"\n    name = data.get(\"name\")\n    if not name:\n        return \"Preset has no name\"\n\n    # Check for duplicate by name\n    result = await db.execute(select(LocalPreset).where(LocalPreset.name == name))\n    if result.scalar_one_or_none():\n        return \"skipped\"\n\n    profile_type = _guess_profile_type(data, path_hint)\n    inherits_value = data.get(\"inherits\")\n\n    # Resolve inheritance\n    try:\n        resolved = await resolve_preset(data, profile_type, db)\n    except Exception as e:\n        logger.warning(\"Failed to resolve inheritance for '%s': %s\", name, e)\n        resolved = data\n\n    # Extract core fields\n    core = extract_core_fields(resolved)\n\n    # Fallback: parse material/vendor from preset name if not found in data\n    filament_type = core.get(\"filament_type\") or _parse_material_from_name(name)\n    filament_vendor = core.get(\"filament_vendor\") or _parse_vendor_from_name(name)\n\n    preset = LocalPreset(\n        name=name,\n        preset_type=profile_type,\n        source=\"orcaslicer\",\n        filament_type=filament_type,\n        filament_vendor=filament_vendor,\n        nozzle_temp_min=core.get(\"nozzle_temp_min\"),\n        nozzle_temp_max=core.get(\"nozzle_temp_max\"),\n        pressure_advance=core.get(\"pressure_advance\"),\n        default_filament_colour=core.get(\"default_filament_colour\"),\n        filament_cost=core.get(\"filament_cost\"),\n        filament_density=core.get(\"filament_density\"),\n        compatible_printers=core.get(\"compatible_printers\"),\n        setting=json.dumps(resolved),\n        inherits=inherits_value,\n        version=data.get(\"version\"),\n    )\n    db.add(preset)\n    return \"imported\"\n\n\nasync def refresh_base_cache(db: AsyncSession) -> dict:\n    \"\"\"Force refresh all cached base profiles.\"\"\"\n    result = await db.execute(select(OrcaBaseProfile))\n    profiles = result.scalars().all()\n\n    refreshed = 0\n    failed = 0\n\n    for profile in profiles:\n        # Clear fetched_at to force re-fetch\n        try:\n            profile.fetched_at = datetime.min\n            data = await fetch_and_cache_base_profile(profile.name, profile.profile_type, db)\n            if data:\n                refreshed += 1\n            else:\n                failed += 1\n        except Exception:\n            failed += 1\n\n    return {\"refreshed\": refreshed, \"failed\": failed, \"total\": len(profiles)}\n\n\nasync def get_cache_status(db: AsyncSession) -> dict:\n    \"\"\"Get the status of the base profile cache.\"\"\"\n    result = await db.execute(select(OrcaBaseProfile))\n    profiles = result.scalars().all()\n\n    cutoff = datetime.now(timezone.utc) - timedelta(days=CACHE_TTL_DAYS)\n    fresh = 0\n    stale = 0\n\n    for p in profiles:\n        fetched = p.fetched_at\n        if fetched.tzinfo is None:\n            fetched = fetched.replace(tzinfo=timezone.utc)\n        if fetched >= cutoff:\n            fresh += 1\n        else:\n            stale += 1\n\n    return {\n        \"total\": len(profiles),\n        \"fresh\": fresh,\n        \"stale\": stale,\n        \"ttl_days\": CACHE_TTL_DAYS,\n    }\n\n\nasync def reclassify_presets(db: AsyncSession) -> dict:\n    \"\"\"Re-evaluate preset_type for all local presets using the improved heuristic.\"\"\"\n    result = await db.execute(select(LocalPreset))\n    presets = result.scalars().all()\n\n    reclassified = 0\n    for preset in presets:\n        try:\n            data = json.loads(preset.setting)\n        except Exception:\n            continue\n\n        new_type = _guess_profile_type(data)\n        if new_type != preset.preset_type:\n            logger.info(\n                \"Reclassifying '%s' from '%s' to '%s'\",\n                preset.name,\n                preset.preset_type,\n                new_type,\n            )\n            preset.preset_type = new_type\n            reclassified += 1\n\n    return {\"total\": len(presets), \"reclassified\": reclassified}\n"
  },
  {
    "path": "backend/app/services/plate_detection.py",
    "content": "\"\"\"Build plate empty detection using OpenCV.\n\nAnalyzes camera frames to detect if there are objects on the build plate.\nUses calibration-based difference detection - compares current frame to\na reference image of the empty plate.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport os\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n# Optional OpenCV import - feature disabled if not available\ntry:\n    import cv2\n    import numpy as np\n\n    OPENCV_AVAILABLE = True\nexcept ImportError:\n    OPENCV_AVAILABLE = False\n    logger.info(\"OpenCV not available - plate detection feature disabled\")\n\n\ndef _get_calibration_dir() -> Path:\n    \"\"\"Get the calibration directory from settings (ensures persistence in Docker).\"\"\"\n    from backend.app.core.config import settings\n\n    return settings.plate_calibration_dir\n\n\nclass PlateDetectionResult:\n    \"\"\"Result of plate detection analysis.\"\"\"\n\n    def __init__(\n        self,\n        is_empty: bool,\n        confidence: float,\n        difference_percent: float,\n        message: str,\n        debug_image: bytes | None = None,\n        needs_calibration: bool = False,\n    ):\n        self.is_empty = is_empty\n        self.confidence = confidence  # 0.0 to 1.0\n        self.difference_percent = difference_percent  # How different from reference\n        self.message = message\n        self.debug_image = debug_image  # Optional annotated image for debugging\n        self.needs_calibration = needs_calibration  # True if no reference image exists\n\n    def to_dict(self) -> dict:\n        return {\n            \"is_empty\": bool(self.is_empty),\n            \"confidence\": float(round(self.confidence, 2)),\n            \"difference_percent\": float(round(self.difference_percent, 2)),\n            \"message\": self.message,\n            \"has_debug_image\": self.debug_image is not None,\n            \"needs_calibration\": bool(self.needs_calibration),\n        }\n\n\nclass PlateDetector:\n    \"\"\"Detects if the build plate is empty using calibration-based difference detection.\"\"\"\n\n    # Default region of interest (ROI) as percentage of image dimensions\n    # These define where the build plate typically appears in the camera view\n    # Format: (x_start%, y_start%, width%, height%)\n    DEFAULT_ROI = (0.15, 0.35, 0.70, 0.55)  # Center-lower portion of frame\n\n    # Detection thresholds for difference detection\n    # Using mean pixel difference (0-100% scale)\n    # Small objects may only cause 1-2% mean difference\n    DEFAULT_DIFFERENCE_THRESHOLD = 1.0\n    DEFAULT_BLUR_SIZE = 21  # Gaussian blur kernel size (must be odd) - unused with edge detection\n\n    def __init__(\n        self,\n        roi: tuple[float, float, float, float] | None = None,\n        difference_threshold: float = DEFAULT_DIFFERENCE_THRESHOLD,\n        blur_size: int = DEFAULT_BLUR_SIZE,\n    ):\n        \"\"\"Initialize the plate detector.\n\n        Args:\n            roi: Region of interest as (x%, y%, w%, h%) - percentages of image size\n            difference_threshold: Percentage of pixels that must differ to trigger \"not empty\"\n            blur_size: Gaussian blur kernel size for noise reduction\n        \"\"\"\n        if not OPENCV_AVAILABLE:\n            raise RuntimeError(\"OpenCV is not installed. Install with: pip install opencv-python-headless\")\n\n        self.roi = roi or self.DEFAULT_ROI\n        self.difference_threshold = difference_threshold\n        self.blur_size = blur_size if blur_size % 2 == 1 else blur_size + 1  # Must be odd\n\n    # Maximum number of reference images to store per printer\n    MAX_REFERENCES = 5\n\n    def _get_metadata_path(self, printer_id: int) -> Path:\n        \"\"\"Get the path to the metadata JSON file for a printer.\"\"\"\n        _get_calibration_dir().mkdir(parents=True, exist_ok=True)\n        return _get_calibration_dir() / f\"printer_{printer_id}_metadata.json\"\n\n    def _load_metadata(self, printer_id: int) -> dict:\n        \"\"\"Load metadata for a printer's references.\"\"\"\n        import json\n\n        meta_path = self._get_metadata_path(printer_id)\n        if meta_path.exists():\n            try:\n                with open(meta_path) as f:\n                    return json.load(f)\n            except (json.JSONDecodeError, OSError, KeyError, ValueError):\n                pass\n        return {\"references\": {}}\n\n    def _save_metadata(self, printer_id: int, metadata: dict) -> None:\n        \"\"\"Save metadata for a printer's references.\"\"\"\n        import json\n\n        meta_path = self._get_metadata_path(printer_id)\n        with open(meta_path, \"w\") as f:\n            json.dump(metadata, f, indent=2)\n\n    def _get_reference_paths(self, printer_id: int) -> list[Path]:\n        \"\"\"Get all existing reference image paths for a printer.\"\"\"\n        _get_calibration_dir().mkdir(parents=True, exist_ok=True)\n        paths = []\n        for i in range(self.MAX_REFERENCES):\n            path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{i}.jpg\"\n            if path.exists():\n                paths.append(path)\n        return paths\n\n    def _get_next_reference_slot(self, printer_id: int) -> Path:\n        \"\"\"Get the path for the next reference image slot (cycles through slots).\"\"\"\n        _get_calibration_dir().mkdir(parents=True, exist_ok=True)\n        # Find first empty slot, or use oldest (slot 0) and shift others\n        for i in range(self.MAX_REFERENCES):\n            path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{i}.jpg\"\n            if not path.exists():\n                return path\n        # All slots full - return slot 0 (will be overwritten, but we rotate first)\n        return _get_calibration_dir() / f\"printer_{printer_id}_ref_0.jpg\"\n\n    def _rotate_references(self, printer_id: int) -> None:\n        \"\"\"Rotate references: delete oldest (0), shift others down.\"\"\"\n        # Delete slot 0\n        slot0 = _get_calibration_dir() / f\"printer_{printer_id}_ref_0.jpg\"\n        if slot0.exists():\n            logger.info(\"Rotating references: removing oldest %s\", slot0)\n            slot0.unlink()\n        # Shift others down\n        for i in range(1, self.MAX_REFERENCES):\n            old_path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{i}.jpg\"\n            new_path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{i - 1}.jpg\"\n            if old_path.exists():\n                old_path.rename(new_path)\n\n        # Also rotate metadata\n        metadata = self._load_metadata(printer_id)\n        refs = metadata.get(\"references\", {})\n        new_refs = {}\n        for i in range(1, self.MAX_REFERENCES):\n            if str(i) in refs:\n                new_refs[str(i - 1)] = refs[str(i)]\n        metadata[\"references\"] = new_refs\n        self._save_metadata(printer_id, metadata)\n\n    def get_references(self, printer_id: int) -> list[dict]:\n        \"\"\"Get all references with metadata for a printer.\n\n        Returns list of dicts with: index, label, timestamp, has_image\n        \"\"\"\n\n        metadata = self._load_metadata(printer_id)\n        refs = metadata.get(\"references\", {})\n        result = []\n\n        for i in range(self.MAX_REFERENCES):\n            path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{i}.jpg\"\n            if path.exists():\n                ref_meta = refs.get(str(i), {})\n                result.append(\n                    {\n                        \"index\": i,\n                        \"label\": ref_meta.get(\"label\", \"\"),\n                        \"timestamp\": ref_meta.get(\"timestamp\", \"\"),\n                        \"has_image\": True,\n                    }\n                )\n\n        return result\n\n    def update_reference_label(self, printer_id: int, index: int, label: str) -> bool:\n        \"\"\"Update the label for a reference.\"\"\"\n        if index < 0 or index >= self.MAX_REFERENCES:\n            return False\n\n        path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{index}.jpg\"\n        if not path.exists():\n            return False\n\n        metadata = self._load_metadata(printer_id)\n        if \"references\" not in metadata:\n            metadata[\"references\"] = {}\n        if str(index) not in metadata[\"references\"]:\n            metadata[\"references\"][str(index)] = {}\n\n        metadata[\"references\"][str(index)][\"label\"] = label\n        self._save_metadata(printer_id, metadata)\n        return True\n\n    def delete_reference(self, printer_id: int, index: int) -> bool:\n        \"\"\"Delete a specific reference by index.\"\"\"\n        if index < 0 or index >= self.MAX_REFERENCES:\n            return False\n\n        path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{index}.jpg\"\n        if not path.exists():\n            return False\n\n        # Delete image\n        logger.info(\"Deleting reference %s for printer %s: %s\", index, printer_id, path)\n        path.unlink()\n\n        # Remove from metadata\n        metadata = self._load_metadata(printer_id)\n        refs = metadata.get(\"references\", {})\n        if str(index) in refs:\n            del refs[str(index)]\n        metadata[\"references\"] = refs\n        self._save_metadata(printer_id, metadata)\n\n        # Shift remaining references down to fill the gap\n        for i in range(index + 1, self.MAX_REFERENCES):\n            old_img = _get_calibration_dir() / f\"printer_{printer_id}_ref_{i}.jpg\"\n            new_img = _get_calibration_dir() / f\"printer_{printer_id}_ref_{i - 1}.jpg\"\n            if old_img.exists():\n                old_img.rename(new_img)\n                # Also shift metadata\n                if str(i) in refs:\n                    refs[str(i - 1)] = refs[str(i)]\n                    del refs[str(i)]\n\n        metadata[\"references\"] = refs\n        self._save_metadata(printer_id, metadata)\n        return True\n\n    def get_reference_thumbnail(self, printer_id: int, index: int, max_size: int = 150) -> bytes | None:\n        \"\"\"Get a thumbnail of a reference image.\n\n        Returns JPEG bytes or None if not found.\n        \"\"\"\n        path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{index}.jpg\"\n        if not path.exists():\n            return None\n\n        try:\n            img = cv2.imread(str(path))\n            if img is None:\n                return None\n\n            # Calculate thumbnail size maintaining aspect ratio\n            h, w = img.shape[:2]\n            if w > h:\n                new_w = max_size\n                new_h = int(h * max_size / w)\n            else:\n                new_h = max_size\n                new_w = int(w * max_size / h)\n\n            thumb = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)\n            _, buffer = cv2.imencode(\".jpg\", thumb, [cv2.IMWRITE_JPEG_QUALITY, 80])\n            return buffer.tobytes()\n        except Exception as e:\n            logger.error(\"Error creating thumbnail: %s\", e)\n            return None\n\n    def _extract_roi(self, frame: np.ndarray) -> tuple[np.ndarray, int, int, int, int]:\n        \"\"\"Extract the region of interest from a frame.\n\n        Returns:\n            Tuple of (roi_frame, x_start, y_start, roi_width, roi_height)\n        \"\"\"\n        height, width = frame.shape[:2]\n        x_start = int(width * self.roi[0])\n        y_start = int(height * self.roi[1])\n        roi_width = int(width * self.roi[2])\n        roi_height = int(height * self.roi[3])\n        roi_frame = frame[y_start : y_start + roi_height, x_start : x_start + roi_width]\n        return roi_frame, x_start, y_start, roi_width, roi_height\n\n    def _preprocess_for_comparison(self, frame: np.ndarray) -> np.ndarray:\n        \"\"\"Preprocess a frame for comparison.\n\n        Uses heavy blur to create \"blob\" representation - smooths out texture\n        and noise while preserving large objects. Then normalizes brightness\n        to reduce lighting sensitivity.\n        \"\"\"\n        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)\n        # Very heavy blur to smooth texture, keep only large shapes\n        blurred = cv2.GaussianBlur(gray, (51, 51), 0)\n        # Normalize to 0-255 range to reduce brightness sensitivity\n        normalized = cv2.normalize(blurred, None, 0, 255, cv2.NORM_MINMAX)\n        return normalized\n\n    def calibrate(self, image_data: bytes, printer_id: int, label: str | None = None) -> tuple[bool, str, int]:\n        \"\"\"Calibrate by saving a reference image of the empty plate.\n\n        Stores up to MAX_REFERENCES (5) images per printer. When all slots are full,\n        the oldest reference is removed and others are shifted.\n\n        Args:\n            image_data: JPEG image data as bytes\n            printer_id: Printer database ID\n            label: Optional label for this reference (e.g., \"High Temp Plate\")\n\n        Returns:\n            Tuple of (success, message, index) where index is the slot used\n        \"\"\"\n        from datetime import datetime\n\n        try:\n            # Decode image\n            nparr = np.frombuffer(image_data, np.uint8)\n            frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)\n\n            if frame is None:\n                return False, \"Failed to decode image\", -1\n\n            # Get existing references count\n            existing_refs = self._get_reference_paths(printer_id)\n            num_existing = len(existing_refs)\n\n            # If all slots are full, rotate (remove oldest)\n            if num_existing >= self.MAX_REFERENCES:\n                self._rotate_references(printer_id)\n                num_existing = self.MAX_REFERENCES - 1\n\n            # Save to next available slot\n            slot_index = num_existing\n            reference_path = _get_calibration_dir() / f\"printer_{printer_id}_ref_{slot_index}.jpg\"\n            write_success = cv2.imwrite(str(reference_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 95])\n\n            if not write_success:\n                logger.error(\"cv2.imwrite failed for %s\", reference_path)\n                return False, \"Failed to save reference image\", -1\n\n            # Verify the file actually exists and has content\n            if not reference_path.exists():\n                logger.error(\"Reference image not found after save: %s\", reference_path)\n                return False, \"Reference image not found after save\", -1\n\n            file_size = reference_path.stat().st_size\n            if file_size < 1000:  # JPEG should be at least 1KB\n                logger.error(\"Reference image too small (%s bytes): %s\", file_size, reference_path)\n                reference_path.unlink()  # Clean up invalid file\n                return False, f\"Reference image corrupted (only {file_size} bytes)\", -1\n\n            logger.info(\"Saved reference image: %s (%s bytes)\", reference_path, file_size)\n\n            # Save metadata\n            metadata = self._load_metadata(printer_id)\n            if \"references\" not in metadata:\n                metadata[\"references\"] = {}\n            metadata[\"references\"][str(slot_index)] = {\n                \"label\": label or \"\",\n                \"timestamp\": datetime.now().isoformat(),\n            }\n            self._save_metadata(printer_id, metadata)\n\n            logger.info(\n                f\"Saved plate calibration reference {slot_index + 1}/{self.MAX_REFERENCES} for printer {printer_id}\"\n            )\n            return True, f\"Calibration saved ({slot_index + 1}/{self.MAX_REFERENCES} references)\", slot_index\n\n        except Exception as e:\n            logger.exception(\"Error during plate calibration\")\n            # Don't expose exception details to user - log has full info\n            error_type = type(e).__name__\n            return False, f\"Calibration error: {error_type}\", -1\n\n    def get_calibration_count(self, printer_id: int) -> int:\n        \"\"\"Get the number of calibration references for a printer.\"\"\"\n        return len(self._get_reference_paths(printer_id))\n\n    def has_calibration(self, printer_id: int, plate_type: str | None = None) -> bool:\n        \"\"\"Check if a printer has any calibration reference images.\"\"\"\n        return len(self._get_reference_paths(printer_id)) > 0\n\n    def delete_calibration(self, printer_id: int, plate_type: str | None = None) -> bool:\n        \"\"\"Delete all calibration reference images for a printer.\"\"\"\n        paths = self._get_reference_paths(printer_id)\n        if not paths:\n            return False\n        for path in paths:\n            path.unlink()\n        logger.info(\"Deleted %s plate calibration reference(s) for printer %s\", len(paths), printer_id)\n        return True\n\n    def analyze_frame(\n        self, image_data: bytes, printer_id: int, plate_type: str | None = None, include_debug_image: bool = False\n    ) -> PlateDetectionResult:\n        \"\"\"Analyze a camera frame to detect if the plate is empty.\n\n        Compares the current frame to all calibration reference images and uses\n        the best match (lowest difference) for the final result.\n\n        Args:\n            image_data: JPEG image data as bytes\n            printer_id: Printer database ID (for reference lookup)\n            plate_type: Unused - kept for API compatibility\n            include_debug_image: If True, include annotated image in result\n\n        Returns:\n            PlateDetectionResult with analysis results\n        \"\"\"\n        try:\n            # Check for calibration\n            reference_paths = self._get_reference_paths(printer_id)\n            if not reference_paths:\n                return PlateDetectionResult(\n                    is_empty=True,  # Default to empty when not calibrated\n                    confidence=0.0,\n                    difference_percent=0.0,\n                    message=\"No calibration - please calibrate with empty plate first\",\n                    needs_calibration=True,\n                )\n\n            # Decode current image\n            nparr = np.frombuffer(image_data, np.uint8)\n            current_frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)\n\n            if current_frame is None:\n                return PlateDetectionResult(\n                    is_empty=True,\n                    confidence=0.0,\n                    difference_percent=0.0,\n                    message=\"Failed to decode current image\",\n                )\n\n            # Extract ROI from current frame\n            current_roi, x_start, y_start, roi_width, roi_height = self._extract_roi(current_frame)\n            current_processed = self._preprocess_for_comparison(current_roi)\n\n            # Compare against all references, find best match (lowest difference)\n            best_difference_percent = float(\"inf\")\n            best_ref_idx = -1\n            best_diff = None\n\n            for idx, ref_path in enumerate(reference_paths):\n                # Load reference image\n                reference_frame = cv2.imread(str(ref_path), cv2.IMREAD_COLOR)\n                if reference_frame is None:\n                    continue\n\n                # Ensure same dimensions\n                if current_frame.shape != reference_frame.shape:\n                    reference_frame = cv2.resize(reference_frame, (current_frame.shape[1], current_frame.shape[0]))\n\n                # Extract ROI and preprocess\n                reference_roi, _, _, _, _ = self._extract_roi(reference_frame)\n                reference_processed = self._preprocess_for_comparison(reference_roi)\n\n                # Calculate absolute difference\n                diff = cv2.absdiff(current_processed, reference_processed)\n\n                # Calculate mean difference as percentage\n                mean_diff = np.mean(diff)\n                difference_percent = (mean_diff / 255.0) * 100\n\n                if difference_percent < best_difference_percent:\n                    best_difference_percent = difference_percent\n                    best_ref_idx = idx\n                    best_diff = diff\n\n            if best_ref_idx == -1:\n                return PlateDetectionResult(\n                    is_empty=True,\n                    confidence=0.0,\n                    difference_percent=0.0,\n                    message=\"Failed to load any reference images - please recalibrate\",\n                    needs_calibration=True,\n                )\n\n            difference_percent = best_difference_percent\n\n            # Determine if plate is empty (use best match)\n            is_empty = difference_percent < self.difference_threshold\n\n            # Calculate confidence\n            if is_empty:\n                # Higher confidence when very little difference\n                confidence = 1.0 - min(1.0, difference_percent / self.difference_threshold)\n            else:\n                # Higher confidence when clearly different\n                confidence = min(1.0, difference_percent / (self.difference_threshold * 2))\n\n            # Generate message\n            num_refs = len(reference_paths)\n            if is_empty:\n                message = (\n                    f\"Plate appears empty (difference: {difference_percent:.1f}%, ref {best_ref_idx + 1}/{num_refs})\"\n                )\n            else:\n                message = f\"Objects detected on plate (difference: {difference_percent:.1f}%, best ref {best_ref_idx + 1}/{num_refs})\"\n\n            # Generate debug image if requested\n            debug_image = None\n            if include_debug_image and best_diff is not None:\n                debug_frame = current_frame.copy()\n\n                # Draw ROI rectangle\n                cv2.rectangle(\n                    debug_frame,\n                    (x_start, y_start),\n                    (x_start + roi_width, y_start + roi_height),\n                    (0, 255, 0),\n                    2,\n                )\n\n                # Create colored difference overlay\n                # Red = areas that are different from reference\n                # Amplify diff for visibility (multiply by 3, cap at 255)\n                diff_amplified = np.minimum(best_diff * 3, 255).astype(np.uint8)\n                diff_colored = cv2.cvtColor(diff_amplified, cv2.COLOR_GRAY2BGR)\n                diff_colored[:, :, 0] = 0  # Remove blue\n                diff_colored[:, :, 1] = 0  # Remove green\n                # Red channel has the diff\n\n                # Overlay difference on ROI\n                roi_overlay = debug_frame[y_start : y_start + roi_height, x_start : x_start + roi_width]\n                cv2.addWeighted(diff_colored, 0.5, roi_overlay, 0.5, 0, roi_overlay)\n\n                # Add status text\n                status_text = \"EMPTY\" if is_empty else \"OBJECTS DETECTED\"\n                color = (0, 255, 0) if is_empty else (0, 0, 255)\n                cv2.putText(debug_frame, status_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, color, 2)\n                cv2.putText(\n                    debug_frame,\n                    f\"Diff: {difference_percent:.1f}% (ref {best_ref_idx + 1}/{num_refs})\",\n                    (10, 60),\n                    cv2.FONT_HERSHEY_SIMPLEX,\n                    0.7,\n                    color,\n                    2,\n                )\n                cv2.putText(\n                    debug_frame,\n                    f\"Confidence: {confidence:.0%}\",\n                    (10, 90),\n                    cv2.FONT_HERSHEY_SIMPLEX,\n                    0.7,\n                    color,\n                    2,\n                )\n\n                # Encode debug image as JPEG\n                _, buffer = cv2.imencode(\".jpg\", debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])\n                debug_image = buffer.tobytes()\n\n            return PlateDetectionResult(\n                is_empty=is_empty,\n                confidence=confidence,\n                difference_percent=difference_percent,\n                message=message,\n                debug_image=debug_image,\n            )\n\n        except Exception as e:\n            logger.exception(\"Error analyzing frame for plate detection\")\n            return PlateDetectionResult(\n                is_empty=True,  # Default to empty on error (don't block prints)\n                confidence=0.0,\n                difference_percent=0.0,\n                message=f\"Analysis error: {e!s}\",\n            )\n\n\nasync def capture_camera_image(\n    printer_id: int,\n    ip_address: str,\n    access_code: str,\n    model: str,\n    external_camera_url: str | None = None,\n    external_camera_type: str | None = None,\n    use_external: bool = False,\n) -> tuple[bytes | None, str]:\n    \"\"\"Capture an image from the printer camera.\n\n    If there's an active camera stream, uses the buffered frame instead of\n    creating a new connection (which would fail while stream is active).\n\n    Returns:\n        Tuple of (image_data, camera_source) or (None, error_message)\n    \"\"\"\n    image_data: bytes | None = None\n    camera_source = \"unknown\"\n\n    # Try external camera first if requested and available\n    if use_external and external_camera_url and external_camera_type:\n        try:\n            from backend.app.services.external_camera import capture_frame\n\n            image_data = await capture_frame(external_camera_url, external_camera_type)\n            if image_data:\n                camera_source = \"external\"\n                logger.debug(\"Captured frame from external camera for printer %s\", printer_id)\n        except Exception as e:\n            logger.warning(\"Failed to capture from external camera: %s\", e)\n\n    # Fall back to built-in camera\n    if image_data is None:\n        # First, check if there's an active stream with a buffered frame\n        # This avoids blocking when camera viewer is open\n        try:\n            from backend.app.api.routes.camera import get_buffered_frame\n\n            buffered = get_buffered_frame(printer_id)\n            if buffered:\n                image_data = buffered\n                camera_source = \"built-in (buffered)\"\n                logger.debug(\"Using buffered frame from active stream for printer %s\", printer_id)\n        except Exception as e:\n            logger.debug(\"Could not get buffered frame: %s\", e)\n\n        # If no buffered frame, try to capture a new one\n        if image_data is None:\n            import tempfile\n\n            from backend.app.services.camera import capture_camera_frame\n\n            fd, tmp_name = tempfile.mkstemp(suffix=\".jpg\")\n            os.close(fd)\n            tmp_path = Path(tmp_name)\n            tmp_path.chmod(0o600)\n\n            try:\n                success = await capture_camera_frame(ip_address, access_code, model, tmp_path, timeout=10)\n                if success:\n                    with open(tmp_path, \"rb\") as f:\n                        image_data = f.read()\n                    camera_source = \"built-in\"\n                    logger.debug(\"Captured frame from built-in camera for printer %s\", printer_id)\n            finally:\n                try:\n                    tmp_path.unlink()\n                except OSError:\n                    pass  # Best-effort cleanup of temporary camera capture file\n\n    return image_data, camera_source\n\n\nasync def check_plate_empty(\n    printer_id: int,\n    ip_address: str,\n    access_code: str,\n    model: str,\n    plate_type: str | None = None,\n    include_debug_image: bool = False,\n    external_camera_url: str | None = None,\n    external_camera_type: str | None = None,\n    use_external: bool = False,\n    roi: tuple[float, float, float, float] | None = None,\n) -> PlateDetectionResult:\n    \"\"\"Check if the build plate is empty for a printer.\n\n    Args:\n        printer_id: Printer database ID\n        ip_address: Printer IP address\n        access_code: Printer access code\n        model: Printer model string\n        plate_type: Type of build plate for calibration lookup\n        include_debug_image: If True, include annotated image in result\n        external_camera_url: URL of external camera (if configured)\n        external_camera_type: Type of external camera (mjpeg, rtsp, snapshot)\n        use_external: If True, prefer external camera over built-in\n        roi: Region of interest as (x%, y%, w%, h%) - percentages of image size\n\n    Returns:\n        PlateDetectionResult with analysis results\n    \"\"\"\n    if not OPENCV_AVAILABLE:\n        return PlateDetectionResult(\n            is_empty=True,\n            confidence=0.0,\n            difference_percent=0.0,\n            message=\"OpenCV not available - plate detection disabled\",\n        )\n\n    image_data, camera_source = await capture_camera_image(\n        printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external\n    )\n\n    if image_data is None:\n        return PlateDetectionResult(\n            is_empty=True,  # Default to empty on error\n            confidence=0.0,\n            difference_percent=0.0,\n            message=\"Failed to capture camera frame from any source\",\n        )\n\n    # Analyze the captured frame\n    detector = PlateDetector(roi=roi)\n    result = detector.analyze_frame(image_data, printer_id, plate_type, include_debug_image)\n\n    # Add camera source to message\n    result.message = f\"[{camera_source}] {result.message}\"\n\n    return result\n\n\nasync def calibrate_plate(\n    printer_id: int,\n    ip_address: str,\n    access_code: str,\n    model: str,\n    label: str | None = None,\n    external_camera_url: str | None = None,\n    external_camera_type: str | None = None,\n    use_external: bool = False,\n) -> tuple[bool, str, int]:\n    \"\"\"Calibrate plate detection by capturing a reference image of the empty plate.\n\n    Args:\n        printer_id: Printer database ID\n        ip_address: Printer IP address\n        access_code: Printer access code\n        model: Printer model string\n        label: Optional label for this reference (e.g., \"High Temp Plate\")\n        external_camera_url: URL of external camera (if configured)\n        external_camera_type: Type of external camera (mjpeg, rtsp, snapshot)\n        use_external: If True, prefer external camera over built-in\n\n    Returns:\n        Tuple of (success, message, index)\n    \"\"\"\n    if not OPENCV_AVAILABLE:\n        return False, \"OpenCV not available - plate detection disabled\", -1\n\n    image_data, camera_source = await capture_camera_image(\n        printer_id, ip_address, access_code, model, external_camera_url, external_camera_type, use_external\n    )\n\n    if image_data is None:\n        return False, \"Failed to capture camera frame for calibration\", -1\n\n    detector = PlateDetector()\n    success, message, index = detector.calibrate(image_data, printer_id, label)\n\n    if success:\n        message = f\"[{camera_source}] {message}\"\n\n    return success, message, index\n\n\ndef get_calibration_status(printer_id: int, plate_type: str | None = None) -> dict:\n    \"\"\"Get calibration status for a printer.\n\n    Returns:\n        Dict with calibration info including reference count\n    \"\"\"\n    if not OPENCV_AVAILABLE:\n        return {\n            \"available\": False,\n            \"calibrated\": False,\n            \"reference_count\": 0,\n            \"max_references\": 5,\n            \"message\": \"OpenCV not available\",\n        }\n\n    detector = PlateDetector()\n    calibrated = detector.has_calibration(printer_id)\n    ref_count = detector.get_calibration_count(printer_id)\n\n    if calibrated:\n        message = f\"Calibrated with {ref_count}/{detector.MAX_REFERENCES} reference(s)\"\n    else:\n        message = \"Not calibrated - please calibrate with empty plate\"\n\n    return {\n        \"available\": True,\n        \"calibrated\": calibrated,\n        \"reference_count\": ref_count,\n        \"max_references\": detector.MAX_REFERENCES,\n        \"message\": message,\n    }\n\n\ndef delete_calibration(printer_id: int, plate_type: str | None = None) -> bool:\n    \"\"\"Delete calibration for a printer and plate type.\"\"\"\n    if not OPENCV_AVAILABLE:\n        return False\n\n    detector = PlateDetector()\n    return detector.delete_calibration(printer_id, plate_type)\n\n\ndef is_plate_detection_available() -> bool:\n    \"\"\"Check if plate detection feature is available (OpenCV installed).\"\"\"\n    return OPENCV_AVAILABLE\n"
  },
  {
    "path": "backend/app/services/print_log.py",
    "content": "\"\"\"Service for writing independent print log entries.\n\nLog entries are written to a separate table and never touch archives or queue items.\n\"\"\"\n\nimport logging\nfrom datetime import datetime\n\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.print_log import PrintLogEntry\n\nlogger = logging.getLogger(__name__)\n\n\nasync def write_log_entry(\n    db: AsyncSession,\n    *,\n    status: str,\n    print_name: str | None = None,\n    printer_name: str | None = None,\n    printer_id: int | None = None,\n    started_at: datetime | None = None,\n    completed_at: datetime | None = None,\n    filament_type: str | None = None,\n    filament_color: str | None = None,\n    filament_used_grams: float | None = None,\n    thumbnail_path: str | None = None,\n    created_by_username: str | None = None,\n) -> PrintLogEntry:\n    \"\"\"Write a print log entry.\"\"\"\n    duration = None\n    if started_at and completed_at:\n        duration = int((completed_at - started_at).total_seconds())\n\n    entry = PrintLogEntry(\n        print_name=print_name,\n        printer_name=printer_name,\n        printer_id=printer_id,\n        status=status,\n        started_at=started_at,\n        completed_at=completed_at,\n        duration_seconds=duration,\n        filament_type=filament_type,\n        filament_color=filament_color,\n        filament_used_grams=filament_used_grams,\n        thumbnail_path=thumbnail_path,\n        created_by_username=created_by_username,\n    )\n    db.add(entry)\n    await db.flush()\n    return entry\n"
  },
  {
    "path": "backend/app/services/print_scheduler.py",
    "content": "\"\"\"Print scheduler service - processes the print queue.\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport time\nimport zipfile\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nimport defusedxml.ElementTree as ET\nfrom sqlalchemy import func, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.core.config import settings\nfrom backend.app.core.database import async_session\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.library import LibraryFile\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.settings import Settings\nfrom backend.app.models.smart_plug import SmartPlug\nfrom backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry\nfrom backend.app.services.notification_service import notification_service\nfrom backend.app.services.printer_manager import printer_manager, supports_drying\nfrom backend.app.services.smart_plug_manager import smart_plug_manager\nfrom backend.app.utils.printer_models import normalize_printer_model\nfrom backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf\n\nlogger = logging.getLogger(__name__)\n\n# Filament type equivalence groups — types within the same group are\n# interchangeable on the printer side (Bambu Lab firmware treats them as compatible).\n_FILAMENT_TYPE_GROUPS: list[list[str]] = [\n    [\"PA-CF\", \"PA12-CF\", \"PAHT-CF\"],\n]\n_FILAMENT_EQUIV_MAP: dict[str, str] = {}\nfor _group in _FILAMENT_TYPE_GROUPS:\n    _canonical = _group[0].upper()\n    for _t in _group:\n        _FILAMENT_EQUIV_MAP[_t.upper()] = _canonical\n\n\ndef _canonical_filament_type(ftype: str) -> str:\n    \"\"\"Return canonical type for equivalence matching.\"\"\"\n    upper = ftype.upper()\n    return _FILAMENT_EQUIV_MAP.get(upper, upper)\n\n\nclass PrintScheduler:\n    \"\"\"Background scheduler that processes the print queue.\"\"\"\n\n    # Built-in drying presets per filament type (from BambuStudio filament profiles)\n    # Format: { n3f_temp, n3s_temp, n3f_hours, n3s_hours }\n    DEFAULT_DRYING_PRESETS: dict[str, dict[str, int]] = {\n        \"PLA\": {\"n3f\": 45, \"n3s\": 45, \"n3f_hours\": 12, \"n3s_hours\": 12},\n        \"PETG\": {\"n3f\": 65, \"n3s\": 65, \"n3f_hours\": 12, \"n3s_hours\": 12},\n        \"TPU\": {\"n3f\": 65, \"n3s\": 75, \"n3f_hours\": 12, \"n3s_hours\": 18},\n        \"ABS\": {\"n3f\": 65, \"n3s\": 80, \"n3f_hours\": 12, \"n3s_hours\": 8},\n        \"ASA\": {\"n3f\": 65, \"n3s\": 80, \"n3f_hours\": 12, \"n3s_hours\": 8},\n        \"PA\": {\"n3f\": 65, \"n3s\": 85, \"n3f_hours\": 12, \"n3s_hours\": 12},\n        \"PC\": {\"n3f\": 65, \"n3s\": 80, \"n3f_hours\": 12, \"n3s_hours\": 8},\n        \"PVA\": {\"n3f\": 65, \"n3s\": 85, \"n3f_hours\": 12, \"n3s_hours\": 18},\n    }\n\n    def __init__(self):\n        self._running = False\n        self._check_interval = 30  # seconds\n        self._power_on_wait_time = 180  # seconds to wait for printer after power on (3 min)\n        self._power_on_check_interval = 10  # seconds between connection checks\n        self._min_drying_seconds = 1800  # 30 minutes minimum before humidity re-check can stop drying\n        # Track which printers are currently auto-drying (printer_id -> start timestamp)\n        self._drying_in_progress: dict[int, float] = {}\n\n    async def run(self):\n        \"\"\"Main loop - check queue every interval.\"\"\"\n        self._running = True\n        logger.info(\"Print scheduler started\")\n\n        while self._running:\n            try:\n                await self.check_queue()\n            except Exception as e:\n                logger.error(\"Scheduler error: %s\", e)\n\n            await asyncio.sleep(self._check_interval)\n\n    def stop(self):\n        \"\"\"Stop the scheduler.\"\"\"\n        self._running = False\n        logger.info(\"Print scheduler stopped\")\n\n    async def check_queue(self):\n        \"\"\"Check for prints ready to start.\"\"\"\n        async with async_session() as db:\n            # Check if shortest-job-first scheduling is enabled\n            sjf_enabled = await self._get_bool_setting(db, \"queue_shortest_first\")\n\n            # Get all pending items, ordered by printer and position (or SJF order)\n            if sjf_enabled:\n                # SJF: group by printer (and target_model for model-based jobs),\n                # then items already jumped get top priority (starvation guard),\n                # then sort by print_time ascending. Items with no print time go last.\n                result = await db.execute(\n                    select(PrintQueueItem)\n                    .where(PrintQueueItem.status == \"pending\")\n                    .order_by(\n                        PrintQueueItem.printer_id,\n                        PrintQueueItem.target_model,\n                        PrintQueueItem.been_jumped.desc(),\n                        PrintQueueItem.print_time_seconds.asc().nullslast(),\n                        PrintQueueItem.position,\n                    )\n                )\n            else:\n                result = await db.execute(\n                    select(PrintQueueItem)\n                    .where(PrintQueueItem.status == \"pending\")\n                    .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)\n                )\n            items = list(result.scalars().all())\n\n            # Read plate-clear setting once per queue check\n            require_plate_clear = await self._get_bool_setting(db, \"require_plate_clear\", default=True)\n\n            if not items:\n                # No pending items — still check auto-drying on idle printers\n                await self._check_auto_drying(db, [], set(), require_plate_clear=require_plate_clear)\n                return\n\n            logger.info(\n                \"Queue check: found %d pending items: %s\",\n                len(items),\n                [(i.id, i.printer_id, i.archive_id, i.library_file_id) for i in items],\n            )\n\n            # Seed busy_printers with printers that already have an item in 'printing'\n            # status. _is_printer_idle() alone is not sufficient as a dispatch gate —\n            # on H2D / P1 series the MQTT state transition from IDLE to RUNNING can\n            # lag several seconds behind the print command, so the next check_queue\n            # tick still sees IDLE and would double-dispatch onto the same printer.\n            # Without this guard, two pending items targeting the same printer\n            # (e.g. a batch with quantity>1) both end up in 'printing' status —\n            # surfaced via the \"BUG: Multiple queue items\" warning in on_print_complete.\n            busy_result = await db.execute(\n                select(PrintQueueItem.printer_id)\n                .where(PrintQueueItem.status == \"printing\")\n                .where(PrintQueueItem.printer_id.is_not(None))\n            )\n            busy_printers: set[int] = {pid for (pid,) in busy_result.all() if pid is not None}\n\n            # Log skip reasons once per queue check (not per item)\n            skip_reasons: dict[str, int] = {}\n\n            for item in items:\n                # Check scheduled time first (scheduled_time is stored in UTC from ISO string)\n                if item.scheduled_time:\n                    sched = item.scheduled_time\n                    if sched.tzinfo is None:\n                        sched = sched.replace(tzinfo=timezone.utc)\n                    if sched > datetime.now(timezone.utc):\n                        skip_reasons[\"scheduled_future\"] = skip_reasons.get(\"scheduled_future\", 0) + 1\n                        continue\n\n                # Skip items that require manual start\n                if item.manual_start:\n                    skip_reasons[\"manual_start\"] = skip_reasons.get(\"manual_start\", 0) + 1\n                    continue\n\n                if item.printer_id:\n                    # Specific printer assignment (existing behavior)\n                    if item.printer_id in busy_printers:\n                        continue\n\n                    # Check if printer is idle\n                    printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)\n                    printer_connected = printer_manager.is_connected(item.printer_id)\n\n                    # If printer not connected, try to power on via smart plug\n                    if not printer_connected:\n                        plugs = await self._get_smart_plugs(db, item.printer_id)\n                        auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]\n                        if auto_on_plugs:\n                            logger.info(\"Printer %s offline, attempting to power on via smart plug(s)\", item.printer_id)\n                            # Power on using the first auto_on plug (the printer power plug)\n                            powered_on = await self._power_on_and_wait(auto_on_plugs[0], item.printer_id, db)\n                            if powered_on:\n                                # Also turn on any remaining auto_on plugs (e.g., filter)\n                                for extra_plug in auto_on_plugs[1:]:\n                                    try:\n                                        service = await smart_plug_manager.get_service_for_plug(extra_plug, db)\n                                        await service.turn_on(extra_plug)\n                                        logger.info(\n                                            \"Also powered on plug '%s' for printer %s\", extra_plug.name, item.printer_id\n                                        )\n                                    except Exception as e:\n                                        logger.warning(\"Failed to power on extra plug '%s': %s\", extra_plug.name, e)\n                                printer_connected = True\n                                printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)\n                            else:\n                                logger.warning(\"Could not power on printer %s via smart plug\", item.printer_id)\n                                busy_printers.add(item.printer_id)\n                                continue\n                        else:\n                            # No plug or auto_on disabled\n                            busy_printers.add(item.printer_id)\n                            continue\n\n                    # Check if printer is idle (busy with another print)\n                    if not printer_idle:\n                        # If printer is drying (not truly busy), handle based on queue_drying_block\n                        if self._drying_in_progress.get(item.printer_id):\n                            block_for_drying = await self._get_bool_setting(db, \"queue_drying_block\")\n                            if block_for_drying:\n                                # Drying blocks queue — skip this printer\n                                busy_printers.add(item.printer_id)\n                                continue\n                            else:\n                                # Print takes priority — stop drying\n                                await self._stop_drying(item.printer_id)\n                                # Re-check idle after stopping drying\n                                printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)\n                                if not printer_idle:\n                                    busy_printers.add(item.printer_id)\n                                    continue\n                        else:\n                            busy_printers.add(item.printer_id)\n                            continue\n\n                    # Check condition (previous print success)\n                    if item.require_previous_success:\n                        if not await self._check_previous_success(db, item):\n                            item.status = \"skipped\"\n                            item.error_message = \"Previous print failed or was aborted\"\n                            item.completed_at = datetime.now(timezone.utc)\n                            await db.commit()\n                            logger.info(\"Skipped queue item %s - previous print failed\", item.id)\n\n                            # Send notification\n                            job_name = await self._get_job_name(db, item)\n                            printer = await self._get_printer(db, item.printer_id)\n                            await notification_service.on_queue_job_skipped(\n                                job_name=job_name,\n                                printer_id=item.printer_id,\n                                printer_name=printer.name if printer else \"Unknown\",\n                                reason=\"Previous print failed or was aborted\",\n                                db=db,\n                            )\n                            continue\n\n                    # Compute AMS mapping if not already set\n                    if not item.ams_mapping:\n                        computed_mapping = await self._compute_ams_mapping_for_printer(db, item.printer_id, item)\n                        if computed_mapping:\n                            item.ams_mapping = json.dumps(computed_mapping)\n                            logger.info(\n                                f\"Queue item {item.id}: Computed AMS mapping for printer {item.printer_id}: {computed_mapping}\"\n                            )\n                            await db.commit()\n\n                    # Start the print\n                    await self._start_print(db, item)\n                    busy_printers.add(item.printer_id)\n\n                    # SJF starvation guard: mark items that were jumped\n                    if sjf_enabled and item.print_time_seconds is not None:\n                        for other in items:\n                            if (\n                                other.id != item.id\n                                and other.status == \"pending\"\n                                and other.printer_id == item.printer_id\n                                and not other.been_jumped\n                                and other.position < item.position\n                                and (\n                                    other.print_time_seconds is None\n                                    or other.print_time_seconds > item.print_time_seconds\n                                )\n                            ):\n                                other.been_jumped = True\n                        await db.commit()\n\n                elif item.target_model:\n                    # Model-based assignment - find any idle printer of matching model\n                    # Parse required filament types if present\n                    required_types = None\n                    if item.required_filament_types:\n                        try:\n                            required_types = json.loads(item.required_filament_types)\n                        except json.JSONDecodeError:\n                            pass  # Ignore malformed filament types; treat as no constraint\n\n                    # Parse filament overrides if present\n                    filament_overrides = None\n                    if item.filament_overrides:\n                        try:\n                            filament_overrides = json.loads(item.filament_overrides)\n                        except json.JSONDecodeError:\n                            pass\n\n                    # If overrides exist, use override types for validation instead\n                    effective_types = required_types\n                    if filament_overrides:\n                        override_types = sorted({o[\"type\"] for o in filament_overrides if \"type\" in o})\n                        if override_types:\n                            # Merge: keep original types for non-overridden slots, add override types\n                            effective_types = sorted(set(required_types or []) | set(override_types))\n\n                    printer_id, waiting_reason = await self._find_idle_printer_for_model(\n                        db,\n                        item.target_model,\n                        busy_printers,\n                        effective_types,\n                        item.target_location,\n                        filament_overrides=filament_overrides,\n                        require_plate_clear=require_plate_clear,\n                    )\n\n                    # Update waiting_reason if changed and send notification when first waiting\n                    if item.waiting_reason != waiting_reason:\n                        was_waiting = item.waiting_reason is not None\n                        item.waiting_reason = waiting_reason\n                        await db.commit()\n\n                        # Send waiting notification only when transitioning to waiting state\n                        # and the reason requires user action (not just \"all printers busy\")\n                        if waiting_reason and not was_waiting and not self._is_busy_only(waiting_reason):\n                            job_name = await self._get_job_name(db, item)\n                            await notification_service.on_queue_job_waiting(\n                                job_name=job_name,\n                                target_model=item.target_model,\n                                waiting_reason=waiting_reason,\n                                db=db,\n                            )\n\n                    if printer_id:\n                        # Check condition (previous print success) before assigning\n                        if item.require_previous_success:\n                            if not await self._check_previous_success(db, item):\n                                item.status = \"skipped\"\n                                item.error_message = \"Previous print failed or was aborted\"\n                                item.completed_at = datetime.now(timezone.utc)\n                                await db.commit()\n                                logger.info(\"Skipped queue item %s - previous print failed\", item.id)\n\n                                # Send notification\n                                job_name = await self._get_job_name(db, item)\n                                printer = await self._get_printer(db, printer_id)\n                                await notification_service.on_queue_job_skipped(\n                                    job_name=job_name,\n                                    printer_id=printer_id,\n                                    printer_name=printer.name if printer else \"Unknown\",\n                                    reason=\"Previous print failed or was aborted\",\n                                    db=db,\n                                )\n                                continue\n\n                        # Assign printer and start - clear waiting reason\n                        item.printer_id = printer_id\n                        item.waiting_reason = None\n                        logger.info(\"Model-based assignment: queue item %s assigned to printer %s\", item.id, printer_id)\n\n                        # Send assignment notification\n                        job_name = await self._get_job_name(db, item)\n                        printer = await self._get_printer(db, printer_id)\n                        await notification_service.on_queue_job_assigned(\n                            job_name=job_name,\n                            printer_id=printer_id,\n                            printer_name=printer.name if printer else \"Unknown\",\n                            target_model=item.target_model,\n                            db=db,\n                        )\n\n                        # Compute AMS mapping for the assigned printer if not already set\n                        # This is critical for model-based jobs where mapping wasn't computed upfront\n                        if not item.ams_mapping:\n                            computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)\n                            if computed_mapping:\n                                item.ams_mapping = json.dumps(computed_mapping)\n                                logger.info(\n                                    f\"Queue item {item.id}: Computed AMS mapping for printer {printer_id}: {computed_mapping}\"\n                                )\n                                await db.commit()\n\n                        await self._start_print(db, item)\n                        busy_printers.add(printer_id)\n\n                        # SJF starvation guard: mark model-based items that were jumped\n                        if sjf_enabled and item.print_time_seconds is not None:\n                            for other in items:\n                                if (\n                                    other.id != item.id\n                                    and other.status == \"pending\"\n                                    and other.printer_id is None\n                                    and other.target_model\n                                    and other.target_model.upper() == item.target_model.upper()\n                                    and not other.been_jumped\n                                    and other.position < item.position\n                                    and (\n                                        other.print_time_seconds is None\n                                        or other.print_time_seconds > item.print_time_seconds\n                                    )\n                                ):\n                                    other.been_jumped = True\n                            await db.commit()\n\n            # Log summary of skip reasons (helps diagnose why queue items aren't starting)\n            if skip_reasons:\n                logger.info(\"Queue skip summary: %s\", skip_reasons)\n            if busy_printers:\n                # Log why each printer was busy (first time it was checked)\n                for pid in busy_printers:\n                    state = printer_manager.get_status(pid)\n                    connected = printer_manager.is_connected(pid)\n                    awaiting = printer_manager.is_awaiting_plate_clear(pid)\n                    state_name = state.state if state else \"NO_STATUS\"\n                    logger.info(\n                        \"Queue: printer %d not available — connected=%s, state=%s, awaiting_plate_clear=%s\",\n                        pid,\n                        connected,\n                        state_name,\n                        awaiting,\n                    )\n\n            # Auto-drying: start drying on idle printers that have no pending queue items\n            await self._check_auto_drying(db, items, busy_printers, require_plate_clear=require_plate_clear)\n\n    async def _find_idle_printer_for_model(\n        self,\n        db: AsyncSession,\n        model: str,\n        exclude_ids: set[int],\n        required_filament_types: list[str] | None = None,\n        target_location: str | None = None,\n        filament_overrides: list[dict] | None = None,\n        require_plate_clear: bool = True,\n    ) -> tuple[int | None, str | None]:\n        \"\"\"Find an idle, connected printer matching the model with compatible filaments.\n\n        Args:\n            db: Database session\n            model: Printer model to match (e.g., \"X1C\", \"P1S\")\n            exclude_ids: Printer IDs to exclude (already busy)\n            required_filament_types: Optional list of filament types needed (e.g., [\"PLA\", \"PETG\"])\n                                     If provided, only printers with all required types loaded will match.\n            target_location: Optional location filter. If provided, only printers in this location are considered.\n            filament_overrides: Optional list of override dicts. Each entry may include\n                                 ``force_color_match: true`` to require an exact type+color match\n                                 on the printer for that slot. Without the flag the existing\n                                 colour-preference logic applies.\n\n        Returns:\n            Tuple of (printer_id, waiting_reason):\n            - (printer_id, None) if a matching printer was found\n            - (None, reason) if no printer is available, with explanation\n        \"\"\"\n        # Normalize model name and use case-insensitive matching\n        normalized_model = normalize_printer_model(model) or model\n        query = (\n            select(Printer)\n            .where(func.lower(Printer.model) == normalized_model.lower())\n            .where(Printer.is_active == True)  # noqa: E712\n        )\n\n        # Add location filter if specified\n        if target_location:\n            query = query.where(Printer.location == target_location)\n\n        result = await db.execute(query)\n        printers = list(result.scalars().all())\n\n        location_suffix = f\" in {target_location}\" if target_location else \"\"\n        if not printers:\n            return None, f\"No active {normalized_model} printers{location_suffix} configured\"\n\n        # Separate force-matched overrides from preference-only overrides\n        force_overrides = [o for o in (filament_overrides or []) if o.get(\"force_color_match\")]\n        pref_overrides = [o for o in (filament_overrides or []) if not o.get(\"force_color_match\")]\n\n        # Track reasons for skipping printers\n        printers_busy = []\n        printers_offline = []\n        printers_missing_filament: list[tuple[str, list[str]]] = []\n        candidates: list[tuple[int, int]] = []  # (printer_id, color_match_count)\n\n        for printer in printers:\n            if printer.id in exclude_ids:\n                # Printer is already claimed by another job in this scheduling run.\n                # For force-color jobs, still check if the color would match — if not,\n                # report it as a color mismatch rather than plain \"Busy\" so the user\n                # knows the job needs a filament change, not just to wait for availability.\n                if force_overrides and not pref_overrides:\n                    missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)\n                    if missing_colors:\n                        printers_missing_filament.append((printer.name, missing_colors))\n                        continue\n                printers_busy.append(printer.name)\n                continue\n\n            is_connected = printer_manager.is_connected(printer.id)\n            is_idle = self._is_printer_idle(printer.id, require_plate_clear) if is_connected else False\n\n            if not is_connected:\n                printers_offline.append(printer.name)\n                continue\n\n            if not is_idle:\n                # Printer is currently printing.  For force-color jobs, check whether the\n                # loaded color would satisfy the requirement — if not, surface it as a\n                # color-mismatch reason rather than plain \"Busy\" so the user understands\n                # that the job is waiting for a filament change, not just printer availability.\n                if force_overrides and not pref_overrides:\n                    missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)\n                    if missing_colors:\n                        printers_missing_filament.append((printer.name, missing_colors))\n                        logger.debug(\n                            \"Printer %s (%s) is busy but also has wrong force-color: %s\",\n                            printer.id,\n                            printer.name,\n                            missing_colors,\n                        )\n                        continue\n                printers_busy.append(printer.name)\n                continue\n\n            # Validate filament compatibility if required types are specified\n            if required_filament_types:\n                missing = self._get_missing_filament_types(printer.id, required_filament_types)\n                if missing:\n                    # When force_overrides are present, enrich missing entries with color info\n                    # so the \"Waiting on\" message includes \"TYPE (color)\" instead of just \"TYPE\"\n                    if force_overrides:\n                        force_color_map = {\n                            (o.get(\"type\") or \"\").upper(): o.get(\"color_name\") or o.get(\"color\", \"?\")\n                            for o in force_overrides\n                        }\n                        missing_enriched = [\n                            f\"{t} ({force_color_map[t_upper]})\" if (t_upper := t.upper()) in force_color_map else t\n                            for t in missing\n                        ]\n                        printers_missing_filament.append((printer.name, missing_enriched))\n                    else:\n                        printers_missing_filament.append((printer.name, missing))\n                    logger.debug(\"Skipping printer %s (%s) - missing filaments: %s\", printer.id, printer.name, missing)\n                    continue\n\n            # Force color match: ALL flagged slots must have an exact type+color match\n            if force_overrides:\n                missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)\n                if missing_colors:\n                    printers_missing_filament.append((printer.name, missing_colors))\n                    logger.debug(\n                        \"Skipping printer %s (%s) - missing force-matched colors: %s\",\n                        printer.id,\n                        printer.name,\n                        missing_colors,\n                    )\n                    continue\n\n            # If preference-only overrides exist, rank by color matches (existing behaviour)\n            if pref_overrides:\n                color_matches = self._count_override_color_matches(printer.id, pref_overrides)\n                if color_matches > 0:\n                    candidates.append((printer.id, color_matches))\n                else:\n                    override_colors = [f\"{o.get('type', '?')} ({o.get('color', '?')})\" for o in pref_overrides]\n                    printers_missing_filament.append((printer.name, override_colors))\n                    logger.debug(\"Skipping printer %s (%s) - no matching override colors\", printer.id, printer.name)\n                    continue\n            elif force_overrides:\n                # Passed all force checks — immediately eligible (no preference ordering needed)\n                return printer.id, None\n            else:\n                # No overrides at all - take first available (existing behavior)\n                return printer.id, None\n\n        # If we have candidates from preference override matching, pick the one with most color matches\n        if candidates:\n            candidates.sort(key=lambda c: c[1], reverse=True)\n            return candidates[0][0], None\n\n        # Build waiting reason from what we found\n        reasons = []\n        if printers_missing_filament:\n            # Filament/color mismatch is most actionable - show first\n            if force_overrides and not pref_overrides:\n                # All mismatches are force-color failures — use descriptive message only;\n                # but only if there are no busy printers that DO have the matching color.\n                # If a printer has the right color but is busy, surface \"Busy\" instead so\n                # the user knows the job will start automatically once that printer is free.\n                if not printers_busy:\n                    all_missing = sorted({c for _, cols in printers_missing_filament for c in cols})\n                    return None, f\"No matching material/color. Waiting on {', '.join(all_missing)}\"\n                # else: fall through — printers_busy will be appended below\n            else:\n                names_and_missing = [\n                    f\"{name} (needs {', '.join(missing)})\" for name, missing in printers_missing_filament\n                ]\n                reasons.append(f\"Waiting for filament: {'; '.join(names_and_missing)}\")\n        if printers_busy:\n            reasons.append(f\"Busy: {', '.join(printers_busy)}\")\n        if printers_offline:\n            reasons.append(f\"Offline: {', '.join(printers_offline)}\")\n\n        return None, \" | \".join(reasons) if reasons else f\"No available {model} printers{location_suffix}\"\n\n    @staticmethod\n    def _is_busy_only(waiting_reason: str) -> bool:\n        \"\"\"Check if the waiting reason only contains 'Busy' entries.\n\n        When all matching printers are simply busy printing, the queued job\n        will start automatically once a printer finishes — no user action\n        is required, so we skip the notification.\n        \"\"\"\n        parts = [p.strip() for p in waiting_reason.split(\" | \")]\n        return all(p.startswith(\"Busy:\") for p in parts)\n\n    def _get_missing_force_color_slots(self, printer_id: int, force_overrides: list[dict]) -> list[str]:\n        \"\"\"Return descriptive strings for force_color_match slots not satisfied by the printer.\n\n        Each entry in ``force_overrides`` must have ``type`` and ``color`` fields and is expected\n        to carry ``force_color_match: True``.  The printer must have **every** such slot loaded\n        with an exact type+color match.\n\n        Returns:\n            List of ``\"TYPE (color)\"`` strings for unmatched slots (empty list means all match).\n        \"\"\"\n        status = printer_manager.get_status(printer_id)\n        if not status:\n            return [f\"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})\" for o in force_overrides]\n\n        # Build set of loaded type+colour pairs from AMS and external spool\n        loaded: set[tuple[str, str]] = set()\n        for ams_unit in status.raw_data.get(\"ams\", []):\n            for tray in ams_unit.get(\"tray\", []):\n                tray_type = tray.get(\"tray_type\")\n                tray_color = tray.get(\"tray_color\", \"\")\n                if tray_type:\n                    color_norm = tray_color.replace(\"#\", \"\").lower()[:6]\n                    loaded.add((_canonical_filament_type(tray_type), color_norm))\n        for vt in status.raw_data.get(\"vt_tray\") or []:\n            vt_type = vt.get(\"tray_type\")\n            if vt_type:\n                color_norm = (vt.get(\"tray_color\", \"\") or \"\").replace(\"#\", \"\").lower()[:6]\n                loaded.add((_canonical_filament_type(vt_type), color_norm))\n\n        missing = []\n        for o in force_overrides:\n            o_type = _canonical_filament_type(o.get(\"type\") or \"\")\n            o_color = (o.get(\"color\") or \"\").replace(\"#\", \"\").lower()[:6]\n            if (o_type, o_color) not in loaded:\n                color_label = o.get(\"color_name\") or o.get(\"color\", \"?\")\n                missing.append(f\"{o_type} ({color_label})\")\n        return missing\n\n    def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:\n        \"\"\"Get the list of required filament types that are not loaded on the printer.\n\n        Args:\n            printer_id: The printer ID\n            required_types: List of filament types needed (e.g., [\"PLA\", \"PETG\"])\n\n        Returns:\n            List of missing filament types (empty if all are loaded)\n        \"\"\"\n        status = printer_manager.get_status(printer_id)\n        if not status:\n            return required_types  # Can't determine, assume all missing\n\n        # Collect all filament types loaded on this printer (AMS units + external spool)\n        # Use canonical types so equivalence groups (e.g. PA-CF/PA12-CF/PAHT-CF) match.\n        loaded_types: set[str] = set()\n\n        # Check AMS units (stored in raw_data[\"ams\"])\n        ams_data = status.raw_data.get(\"ams\", [])\n        if ams_data:\n            for ams_unit in ams_data:\n                for tray in ams_unit.get(\"tray\", []):\n                    tray_type = tray.get(\"tray_type\")\n                    if tray_type:\n                        loaded_types.add(_canonical_filament_type(tray_type))\n\n        # Check external spool(s) (virtual tray, stored in raw_data[\"vt_tray\"] as list)\n        for vt in status.raw_data.get(\"vt_tray\") or []:\n            vt_type = vt.get(\"tray_type\")\n            if vt_type:\n                loaded_types.add(_canonical_filament_type(vt_type))\n\n        # Find which required types are missing (using canonical type for equivalence)\n        missing = []\n        for req_type in required_types:\n            if _canonical_filament_type(req_type) not in loaded_types:\n                missing.append(req_type)\n\n        return missing\n\n    def _count_override_color_matches(self, printer_id: int, overrides: list[dict]) -> int:\n        \"\"\"Count how many filament overrides have an exact color match on the printer.\n\n        Used to prefer printers that already have the desired override colors loaded.\n        \"\"\"\n        status = printer_manager.get_status(printer_id)\n        if not status:\n            return 0\n\n        # Collect loaded filaments' type+color pairs\n        loaded: set[tuple[str, str]] = set()\n        for ams_unit in status.raw_data.get(\"ams\", []):\n            for tray in ams_unit.get(\"tray\", []):\n                tray_type = tray.get(\"tray_type\")\n                tray_color = tray.get(\"tray_color\", \"\")\n                if tray_type:\n                    color_norm = tray_color.replace(\"#\", \"\").lower()[:6]\n                    loaded.add((tray_type.upper(), color_norm))\n        for vt in status.raw_data.get(\"vt_tray\") or []:\n            vt_type = vt.get(\"tray_type\")\n            if vt_type:\n                color_norm = (vt.get(\"tray_color\", \"\") or \"\").replace(\"#\", \"\").lower()[:6]\n                loaded.add((vt_type.upper(), color_norm))\n\n        matches = 0\n        for o in overrides:\n            o_type = (o.get(\"type\") or \"\").upper()\n            o_color = (o.get(\"color\") or \"\").replace(\"#\", \"\").lower()[:6]\n            if (o_type, o_color) in loaded:\n                matches += 1\n        return matches\n\n    async def _compute_ams_mapping_for_printer(\n        self, db: AsyncSession, printer_id: int, item: PrintQueueItem\n    ) -> list[int] | None:\n        \"\"\"Compute AMS mapping for a printer based on filament requirements.\n\n        Called when a queue item has no ams_mapping set — either for model-based\n        items after printer assignment, or printer-specific items (e.g. from VP).\n\n        Args:\n            db: Database session\n            printer_id: The assigned printer ID\n            item: The queue item (contains archive_id or library_file_id)\n\n        Returns:\n            AMS mapping array or None if no mapping needed/possible\n        \"\"\"\n        # Get printer status\n        status = printer_manager.get_status(printer_id)\n        if not status:\n            logger.warning(\"Cannot compute AMS mapping: printer %s status unavailable\", printer_id)\n            return None\n\n        # Get filament requirements from source file\n        filament_reqs = await self._get_filament_requirements(db, item)\n        if not filament_reqs:\n            logger.debug(\"No filament requirements found for queue item %s\", item.id)\n            return None\n\n        # Apply filament overrides if present\n        if item.filament_overrides:\n            try:\n                overrides = json.loads(item.filament_overrides)\n                override_map = {o[\"slot_id\"]: o for o in overrides}\n                for req in filament_reqs:\n                    if req[\"slot_id\"] in override_map:\n                        override = override_map[req[\"slot_id\"]]\n                        req[\"type\"] = override[\"type\"]\n                        req[\"color\"] = override[\"color\"]\n                        # Clear tray_info_idx so matching uses type+color instead of\n                        # the original 3MF's tray_info_idx (which would match the old filament)\n                        req[\"tray_info_idx\"] = \"\"\n                        logger.debug(\n                            \"Queue item %s: Override slot %d -> %s %s\",\n                            item.id,\n                            req[\"slot_id\"],\n                            override[\"type\"],\n                            override[\"color\"],\n                        )\n            except (json.JSONDecodeError, KeyError, TypeError) as e:\n                logger.warning(\"Failed to apply filament overrides for queue item %s: %s\", item.id, e)\n\n        # Build loaded filaments from printer status\n        loaded_filaments = self._build_loaded_filaments(status)\n        if not loaded_filaments:\n            logger.debug(\"No filaments loaded on printer %s\", printer_id)\n            return None\n\n        # Check if user prefers lowest remaining filament when multiple spools match\n        prefer_lowest = await self._get_bool_setting(db, \"prefer_lowest_filament\")\n\n        # Compute mapping: match required filaments to available slots\n        return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest)\n\n    async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:\n        \"\"\"Extract filament requirements from the source 3MF file.\n\n        Args:\n            db: Database session\n            item: Queue item with archive_id or library_file_id\n\n        Returns:\n            List of filament requirement dicts with slot_id, type, color, used_grams\n        \"\"\"\n        file_path: Path | None = None\n\n        if item.archive_id:\n            result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))\n            archive = result.scalar_one_or_none()\n            if archive:\n                file_path = settings.base_dir / archive.file_path\n        elif item.library_file_id:\n            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))\n            library_file = result.scalar_one_or_none()\n            if library_file:\n                lib_path = Path(library_file.file_path)\n                file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path\n\n        if not file_path or not file_path.exists():\n            return None\n\n        filaments = []\n        try:\n            with zipfile.ZipFile(file_path, \"r\") as zf:\n                if \"Metadata/slice_info.config\" not in zf.namelist():\n                    return None\n\n                content = zf.read(\"Metadata/slice_info.config\").decode()\n                root = ET.fromstring(content)\n\n                # Check if plate_id is specified - use that plate's filaments\n                plate_id = item.plate_id\n                if plate_id:\n                    for plate_elem in root.findall(\"./plate\"):\n                        plate_index = None\n                        for meta in plate_elem.findall(\"metadata\"):\n                            if meta.get(\"key\") == \"index\":\n                                plate_index = int(meta.get(\"value\", \"0\"))\n                                break\n                        if plate_index == plate_id:\n                            for filament_elem in plate_elem.findall(\"./filament\"):\n                                filament_id = filament_elem.get(\"id\")\n                                filament_type = filament_elem.get(\"type\", \"\")\n                                filament_color = filament_elem.get(\"color\", \"\")\n                                # tray_info_idx identifies the specific spool selected when slicing\n                                tray_info_idx = filament_elem.get(\"tray_info_idx\", \"\")\n                                used_g = filament_elem.get(\"used_g\", \"0\")\n                                try:\n                                    used_grams = float(used_g)\n                                    if used_grams > 0 and filament_id:\n                                        filaments.append(\n                                            {\n                                                \"slot_id\": int(filament_id),\n                                                \"type\": filament_type,\n                                                \"color\": filament_color,\n                                                \"tray_info_idx\": tray_info_idx,\n                                                \"used_grams\": round(used_grams, 1),\n                                            }\n                                        )\n                                except (ValueError, TypeError):\n                                    pass  # Skip filament entry with unparseable usage data\n                            break\n                else:\n                    # No plate_id - extract all filaments with used_g > 0\n                    for filament_elem in root.findall(\"./filament\"):\n                        filament_id = filament_elem.get(\"id\")\n                        filament_type = filament_elem.get(\"type\", \"\")\n                        filament_color = filament_elem.get(\"color\", \"\")\n                        # tray_info_idx identifies the specific spool selected when slicing\n                        tray_info_idx = filament_elem.get(\"tray_info_idx\", \"\")\n                        used_g = filament_elem.get(\"used_g\", \"0\")\n                        try:\n                            used_grams = float(used_g)\n                            if used_grams > 0 and filament_id:\n                                filaments.append(\n                                    {\n                                        \"slot_id\": int(filament_id),\n                                        \"type\": filament_type,\n                                        \"color\": filament_color,\n                                        \"tray_info_idx\": tray_info_idx,\n                                        \"used_grams\": round(used_grams, 1),\n                                    }\n                                )\n                        except (ValueError, TypeError):\n                            pass  # Skip filament entry with unparseable usage data\n\n                filaments.sort(key=lambda x: x[\"slot_id\"])\n\n                # Enrich with nozzle mapping for dual-nozzle printers\n                nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)\n                if nozzle_mapping:\n                    for filament in filaments:\n                        filament[\"nozzle_id\"] = nozzle_mapping.get(filament[\"slot_id\"])\n        except Exception as e:\n            logger.warning(\"Failed to parse filament requirements: %s\", e)\n            return None\n\n        return filaments if filaments else None\n\n    def _build_loaded_filaments(self, status) -> list[dict]:\n        \"\"\"Build list of loaded filaments from printer status.\n\n        Args:\n            status: PrinterState from printer_manager\n\n        Returns:\n            List of loaded filament dicts with type, color, ams_id, tray_id, global_tray_id\n        \"\"\"\n        filaments = []\n\n        # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)\n        ams_extruder_map = status.raw_data.get(\"ams_extruder_map\", {})\n\n        # Parse AMS units from raw_data\n        ams_data = status.raw_data.get(\"ams\", [])\n        for ams_unit in ams_data:\n            ams_id = int(ams_unit.get(\"id\", 0))\n            trays = ams_unit.get(\"tray\", [])\n            is_ht = len(trays) == 1  # AMS-HT has single tray\n\n            for tray in trays:\n                tray_type = tray.get(\"tray_type\")\n                if tray_type:\n                    tray_id = int(tray.get(\"id\", 0))\n                    tray_color = tray.get(\"tray_color\", \"\")\n                    # tray_info_idx identifies the specific spool (e.g., \"GFA00\", \"P4d64437\")\n                    tray_info_idx = tray.get(\"tray_info_idx\", \"\")\n                    # Normalize color: remove alpha, add hash\n                    color = self._normalize_color(tray_color)\n                    # Calculate global tray ID\n                    # AMS-HT units have IDs starting at 128 with a single tray\n                    global_tray_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id\n\n                    filaments.append(\n                        {\n                            \"type\": tray_type,\n                            \"color\": color,\n                            \"tray_info_idx\": tray_info_idx,\n                            \"ams_id\": ams_id,\n                            \"tray_id\": tray_id,\n                            \"is_ht\": is_ht,\n                            \"is_external\": False,\n                            \"global_tray_id\": global_tray_id,\n                            \"extruder_id\": ams_extruder_map.get(str(ams_id)),\n                            \"remain\": tray.get(\"remain\", -1),\n                        }\n                    )\n\n        # Check external spool(s) (vt_tray is a list)\n        for idx, vt in enumerate(status.raw_data.get(\"vt_tray\") or []):\n            if vt.get(\"tray_type\"):\n                color = self._normalize_color(vt.get(\"tray_color\", \"\"))\n                tray_id = int(vt.get(\"id\", 254))\n                filaments.append(\n                    {\n                        \"type\": vt[\"tray_type\"],\n                        \"color\": color,\n                        \"tray_info_idx\": vt.get(\"tray_info_idx\", \"\"),\n                        \"ams_id\": -1,\n                        \"tray_id\": idx,\n                        \"is_ht\": False,\n                        \"is_external\": True,\n                        \"global_tray_id\": tray_id,\n                        \"extruder_id\": (255 - tray_id) if ams_extruder_map else None,\n                        \"remain\": vt.get(\"remain\", -1),\n                    }\n                )\n\n        return filaments\n\n    def _normalize_color(self, color: str | None) -> str:\n        \"\"\"Normalize color to #RRGGBB format.\"\"\"\n        if not color:\n            return \"#808080\"\n        hex_color = color.replace(\"#\", \"\")[:6]\n        return f\"#{hex_color}\"\n\n    def _normalize_color_for_compare(self, color: str | None) -> str:\n        \"\"\"Normalize color for comparison (lowercase, no hash).\"\"\"\n        if not color:\n            return \"\"\n        return color.replace(\"#\", \"\").lower()[:6]\n\n    def _colors_are_similar(self, color1: str | None, color2: str | None, threshold: int = 40) -> bool:\n        \"\"\"Check if two colors are visually similar within a threshold.\"\"\"\n        hex1 = self._normalize_color_for_compare(color1)\n        hex2 = self._normalize_color_for_compare(color2)\n        if not hex1 or not hex2 or len(hex1) < 6 or len(hex2) < 6:\n            return False\n\n        try:\n            r1 = int(hex1[0:2], 16)\n            g1 = int(hex1[2:4], 16)\n            b1 = int(hex1[4:6], 16)\n            r2 = int(hex2[0:2], 16)\n            g2 = int(hex2[2:4], 16)\n            b2 = int(hex2[4:6], 16)\n            return abs(r1 - r2) <= threshold and abs(g1 - g2) <= threshold and abs(b1 - b2) <= threshold\n        except ValueError:\n            return False\n\n    def _match_filaments_to_slots(\n        self, required: list[dict], loaded: list[dict], prefer_lowest: bool = False\n    ) -> list[int] | None:\n        \"\"\"Match required filaments to loaded filaments and build AMS mapping.\n\n        Priority: unique tray_info_idx match > exact color match > similar color match > type-only match\n\n        The tray_info_idx is a filament type identifier stored in the 3MF file when the user\n        slices (e.g., \"GFA00\" for generic PLA, \"P4d64437\" for custom presets). If the same\n        tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays\n        have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color\n        matching among those trays.\n\n        Args:\n            required: List of required filaments with slot_id, type, color, tray_info_idx\n            loaded: List of loaded filaments with type, color, tray_info_idx, global_tray_id\n\n        Returns:\n            AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)\n        \"\"\"\n        if not required:\n            return None\n\n        # Track used trays to avoid duplicate assignment\n        used_tray_ids: set[int] = set()\n        comparisons = []\n\n        for req in required:\n            req_type = (req.get(\"type\") or \"\").upper()\n            req_color = req.get(\"color\", \"\")\n            req_tray_info_idx = req.get(\"tray_info_idx\", \"\")\n\n            # Find best match: unique tray_info_idx > exact color > similar color > type-only\n            idx_match = None\n            exact_match = None\n            similar_match = None\n            type_only_match = None\n\n            # Get available trays (not already used)\n            available = [f for f in loaded if f[\"global_tray_id\"] not in used_tray_ids]\n\n            # Nozzle-aware filtering: restrict to trays on the correct nozzle.\n            # Hard filter — cross-nozzle assignment causes print failures\n            # (\"position of left hotend is abnormal\"), so never fall back.\n            req_nozzle_id = req.get(\"nozzle_id\")\n            if req_nozzle_id is not None:\n                available = [f for f in available if f.get(\"extruder_id\") == req_nozzle_id]\n\n            # Sort by remaining filament (ascending) so lowest-remain spool wins .find()\n            if prefer_lowest:\n                available.sort(key=lambda f: f.get(\"remain\", -1) if f.get(\"remain\", -1) >= 0 else 101)\n\n            # Check if tray_info_idx is unique among available trays\n            if req_tray_info_idx:\n                idx_matches = [f for f in available if f.get(\"tray_info_idx\") == req_tray_info_idx]\n                if len(idx_matches) == 1:\n                    # Unique tray_info_idx - use it as definitive match\n                    idx_match = idx_matches[0]\n                    logger.debug(\n                        f\"Matched filament slot {req.get('slot_id')} by unique tray_info_idx={req_tray_info_idx} \"\n                        f\"-> tray {idx_match['global_tray_id']}\"\n                    )\n                elif len(idx_matches) > 1:\n                    # Multiple trays with same tray_info_idx - use color matching among them\n                    logger.debug(\n                        f\"Non-unique tray_info_idx={req_tray_info_idx} found in {len(idx_matches)} trays, \"\n                        f\"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}\"\n                    )\n                    if prefer_lowest:\n                        idx_matches.sort(key=lambda f: f.get(\"remain\", -1) if f.get(\"remain\", -1) >= 0 else 101)\n                    # Use color matching within this subset\n                    for f in idx_matches:\n                        f_color = f.get(\"color\", \"\")\n                        if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):\n                            if not exact_match:\n                                exact_match = f\n                        elif self._colors_are_similar(f_color, req_color):\n                            if not similar_match:\n                                similar_match = f\n                        elif not type_only_match:\n                            type_only_match = f\n\n            # If no idx_match yet, do standard type/color matching on all available trays\n            if not idx_match and not exact_match and not similar_match and not type_only_match:\n                for f in available:\n                    f_type = (f.get(\"type\") or \"\").upper()\n                    if _canonical_filament_type(f_type) != _canonical_filament_type(req_type):\n                        continue\n\n                    # Type matches - check color\n                    f_color = f.get(\"color\", \"\")\n                    if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):\n                        if not exact_match:\n                            exact_match = f\n                    elif self._colors_are_similar(f_color, req_color):\n                        if not similar_match:\n                            similar_match = f\n                    elif not type_only_match:\n                        type_only_match = f\n\n            match = idx_match or exact_match or similar_match or type_only_match\n            if match:\n                used_tray_ids.add(match[\"global_tray_id\"])\n                comparisons.append({\"slot_id\": req.get(\"slot_id\", 0), \"global_tray_id\": match[\"global_tray_id\"]})\n            else:\n                comparisons.append({\"slot_id\": req.get(\"slot_id\", 0), \"global_tray_id\": -1})\n\n        # Build mapping array\n        if not comparisons:\n            return None\n\n        max_slot_id = max(c[\"slot_id\"] for c in comparisons)\n        if max_slot_id <= 0:\n            return None\n\n        mapping = [-1] * max_slot_id\n        for c in comparisons:\n            slot_id = c[\"slot_id\"]\n            if slot_id and slot_id > 0:\n                mapping[slot_id - 1] = c[\"global_tray_id\"]\n\n        return mapping\n\n    def _is_printer_idle(self, printer_id: int, require_plate_clear: bool = True) -> bool:\n        \"\"\"Check if a printer is connected and idle.\"\"\"\n        if not printer_manager.is_connected(printer_id):\n            logger.debug(\"Printer %d: not connected\", printer_id)\n            return False\n\n        state = printer_manager.get_status(printer_id)\n        if not state:\n            logger.debug(\"Printer %d: no status available\", printer_id)\n            return False\n\n        # Plate-clear gate: if the printer finished/failed a previous print and the user\n        # hasn't acknowledged the plate was cleared, the queue must not dispatch the next\n        # job — even if the printer currently reports IDLE. After Auto Off cycles the\n        # printer, it boots back into IDLE with no memory of the previous finish; without\n        # the persisted awaiting flag we'd bypass the confirmation prompt (#961).\n        if require_plate_clear and printer_manager.is_awaiting_plate_clear(printer_id):\n            logger.debug(\n                \"Printer %d: not idle — awaiting plate-clear acknowledgment (state=%s)\",\n                printer_id,\n                state.state,\n            )\n            return False\n\n        idle = state.state in (\"IDLE\", \"FINISH\", \"FAILED\")\n        if not idle:\n            logger.debug(\"Printer %d: not idle — state=%s\", printer_id, state.state)\n        return idle\n\n    async def _get_setting(self, db: AsyncSession, key: str) -> str | None:\n        \"\"\"Read a setting value from the database.\"\"\"\n        result = await db.execute(select(Settings).where(Settings.key == key))\n        setting = result.scalar_one_or_none()\n        return setting.value if setting else None\n\n    async def _get_bool_setting(self, db: AsyncSession, key: str, default: bool = False) -> bool:\n        \"\"\"Read a boolean setting from the database.\"\"\"\n        result = await db.execute(select(Settings).where(Settings.key == key))\n        setting = result.scalar_one_or_none()\n        if setting:\n            return setting.value.lower() == \"true\"\n        return default\n\n    async def _get_drying_presets(self, db: AsyncSession) -> dict[str, dict[str, int]]:\n        \"\"\"Get drying presets (user-configured or built-in defaults).\"\"\"\n        result = await db.execute(select(Settings).where(Settings.key == \"drying_presets\"))\n        setting = result.scalar_one_or_none()\n        if setting and setting.value:\n            try:\n                presets = json.loads(setting.value)\n                if isinstance(presets, dict) and presets:\n                    return presets\n            except json.JSONDecodeError:\n                pass\n        return self.DEFAULT_DRYING_PRESETS\n\n    def _get_conservative_drying_params(\n        self, trays: list[dict], module_type: str, presets: dict[str, dict[str, int]]\n    ) -> tuple[int, int, str] | None:\n        \"\"\"Get the most conservative drying params for mixed filament types in an AMS unit.\n\n        Returns (temp, duration_hours, filament_type) or None if no drying-eligible filaments.\n        \"\"\"\n        temp_key = module_type if module_type in (\"n3f\", \"n3s\") else \"n3f\"\n        hours_key = f\"{temp_key}_hours\"\n\n        min_temp = None\n        max_hours = None\n        filament_type = \"\"\n\n        for tray in trays:\n            tray_type = tray.get(\"tray_type\", \"\")\n            if not tray_type:\n                continue\n            # Normalize filament type for preset lookup (e.g., \"PLA Basic\" -> \"PLA\")\n            base_type = tray_type.split()[0].upper()\n            preset = presets.get(base_type)\n            if not preset:\n                continue\n\n            temp = preset.get(temp_key, 55)\n            hours = preset.get(hours_key, 12)\n\n            # Conservative: lowest temp, longest duration\n            if min_temp is None or temp < min_temp:\n                min_temp = temp\n            if max_hours is None or hours > max_hours:\n                max_hours = hours\n            if not filament_type:\n                filament_type = base_type\n\n        if min_temp is None:\n            return None\n        return (min_temp, max_hours or 12, filament_type)\n\n    async def _check_auto_drying(\n        self,\n        db: AsyncSession,\n        queue_items: list[PrintQueueItem],\n        busy_printers: set[int],\n        *,\n        require_plate_clear: bool = True,\n    ):\n        \"\"\"Start drying on idle printers based on humidity.\n\n        Two modes (can both be enabled):\n        - queue_drying_enabled: Dry between scheduled queue prints\n        - ambient_drying_enabled: Dry any idle printer when humidity is high, regardless of queue\n        \"\"\"\n        queue_drying_enabled = await self._get_bool_setting(db, \"queue_drying_enabled\")\n        ambient_drying_enabled = await self._get_bool_setting(db, \"ambient_drying_enabled\")\n        if not queue_drying_enabled and not ambient_drying_enabled:\n            # Stop active drying on all printers if both features disabled\n            if self._drying_in_progress:\n                for pid in list(self._drying_in_progress):\n                    logger.info(\"Auto-drying: printer %d — stopping, auto-drying disabled\", pid)\n                    await self._stop_drying(pid)\n            return\n\n        # Update drying state from printer status (handles backend restart)\n        self._sync_drying_state()\n\n        # Find printers with scheduled items (for queue drying mode)\n        printers_with_scheduled: set[int] = set()\n        printers_with_items: set[int] = set()\n        for item in queue_items:\n            if item.printer_id:\n                printers_with_items.add(item.printer_id)\n                if item.scheduled_time and not item.manual_start:\n                    printers_with_scheduled.add(item.printer_id)\n\n        # If only queue mode is on and no printers have scheduled items, stop drying\n        if not ambient_drying_enabled and not printers_with_scheduled:\n            for pid in list(self._drying_in_progress):\n                logger.info(\"Auto-drying: printer %d — stopping, no scheduled prints in queue\", pid)\n                await self._stop_drying(pid)\n            return\n\n        # Get humidity threshold\n        result = await db.execute(select(Settings).where(Settings.key == \"ams_humidity_fair\"))\n        setting = result.scalar_one_or_none()\n        humidity_threshold = int(setting.value) if setting else 60\n\n        # Get drying presets\n        presets = await self._get_drying_presets(db)\n\n        # Determine if drying should be skipped for printers with pending items\n        block_for_drying = await self._get_bool_setting(db, \"queue_drying_block\")\n\n        # Get all active printers\n        all_printers = await db.execute(select(Printer).where(Printer.is_active.is_(True)))\n        for printer in all_printers.scalars():\n            pid = printer.id\n            if pid in busy_printers:\n                logger.debug(\"Auto-drying: printer %d skipped — busy\", pid)\n                continue\n            # In queue-only mode, only dry printers that have scheduled prints\n            if not ambient_drying_enabled and pid not in printers_with_scheduled:\n                if self._drying_in_progress.get(pid):\n                    logger.info(\"Auto-drying: printer %d — stopping, no scheduled prints for this printer\", pid)\n                    await self._stop_drying(pid)\n                logger.debug(\"Auto-drying: printer %d skipped — no scheduled prints\", pid)\n                continue\n            # When block mode is on, don't START new drying on printers with pending items.\n            # But allow already-drying printers through so humidity auto-stop logic still runs.\n            if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):\n                logger.debug(\"Auto-drying: printer %d skipped — has pending items (block mode)\", pid)\n                continue\n            if not printer_manager.is_connected(pid):\n                logger.debug(\"Auto-drying: printer %d skipped — not connected\", pid)\n                continue\n            if not self._is_printer_idle(pid, require_plate_clear):\n                logger.debug(\"Auto-drying: printer %d skipped — not idle\", pid)\n                continue\n\n            # Check if this printer supports drying\n            state = printer_manager.get_status(pid)\n            if not state:\n                logger.debug(\"Auto-drying: printer %d skipped — no state\", pid)\n                continue\n            model = printer_manager.get_model(pid)\n            firmware = state.firmware_version\n            if not supports_drying(model, firmware):\n                logger.debug(\"Auto-drying: printer %d skipped — model %s does not support drying\", pid, model)\n                continue\n\n            # Check each AMS unit from raw_data\n            ams_list = state.raw_data.get(\"ams\", [])\n            logger.debug(\"Auto-drying: printer %d — checking %d AMS units\", pid, len(ams_list))\n            for ams_data in ams_list:\n                module_type = str(ams_data.get(\"module_type\") or \"\")\n                ams_id = int(ams_data.get(\"id\", 0))\n                # Only n3f/n3s support drying\n                if module_type not in (\"n3f\", \"n3s\"):\n                    logger.debug(\"Auto-drying: printer %d AMS %d skipped — module_type=%s\", pid, ams_id, module_type)\n                    continue\n\n                dry_time = int(ams_data.get(\"dry_time\") or 0)\n\n                # Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5)\n                humidity = None\n                h_raw = ams_data.get(\"humidity_raw\")\n                if h_raw is not None:\n                    try:\n                        humidity = int(h_raw)\n                    except (ValueError, TypeError):\n                        pass\n                if humidity is None:\n                    h_idx = ams_data.get(\"humidity\")\n                    if h_idx is not None:\n                        try:\n                            humidity = int(h_idx)\n                        except (ValueError, TypeError):\n                            pass\n                # Already drying — check if humidity dropped below threshold (with minimum drying time)\n                if dry_time > 0:\n                    if pid not in self._drying_in_progress:\n                        # Drying we didn't start (manual or from before restart) — track but don't stop\n                        self._drying_in_progress[pid] = time.monotonic()\n                    started_at = self._drying_in_progress[pid]\n                    elapsed = time.monotonic() - started_at\n                    if humidity is not None and humidity <= humidity_threshold and elapsed >= self._min_drying_seconds:\n                        logger.info(\n                            \"Auto-drying: printer %d AMS %d — humidity %d%% <= threshold %d%% after %dm, stopping drying\",\n                            pid,\n                            ams_id,\n                            humidity,\n                            humidity_threshold,\n                            int(elapsed / 60),\n                        )\n                        printer_manager.send_drying_command(pid, ams_id, temp=0, duration=0, mode=0)\n                    else:\n                        logger.debug(\n                            \"Auto-drying: printer %d AMS %d — drying (%dm left, humidity %s%%, elapsed %dm/%dm min)\",\n                            pid,\n                            ams_id,\n                            dry_time,\n                            humidity,\n                            int(elapsed / 60),\n                            self._min_drying_seconds // 60,\n                        )\n                    continue\n\n                # Humidity below threshold — no need to start drying\n                if humidity is None or humidity <= humidity_threshold:\n                    logger.debug(\n                        \"Auto-drying: printer %d AMS %d skipped — humidity %s <= threshold %d\",\n                        pid,\n                        ams_id,\n                        humidity,\n                        humidity_threshold,\n                    )\n                    continue\n\n                # Check cannot-dry reasons (power constraints etc.)\n                sf_reasons = ams_data.get(\"dry_sf_reason\", [])\n                if sf_reasons:\n                    logger.debug(\n                        \"Auto-drying: printer %d AMS %d skipped — cannot dry reasons: %s\",\n                        pid,\n                        ams_id,\n                        sf_reasons,\n                    )\n                    continue\n\n                # Get conservative drying params for mixed filaments\n                trays = ams_data.get(\"tray\", [])\n                params = self._get_conservative_drying_params(trays, module_type, presets)\n                if not params:\n                    logger.debug(\n                        \"Auto-drying: printer %d AMS %d skipped — no drying-eligible filaments in trays\", pid, ams_id\n                    )\n                    continue\n\n                temp, duration_hours, filament_type = params\n\n                # Start drying\n                logger.info(\n                    \"Auto-drying: printer %d AMS %d — humidity %d%% > threshold %d%%, \"\n                    \"starting %s drying at %d°C for %dh\",\n                    pid,\n                    ams_id,\n                    humidity,\n                    humidity_threshold,\n                    filament_type,\n                    temp,\n                    duration_hours,\n                )\n                success = printer_manager.send_drying_command(\n                    pid, ams_id, temp, duration_hours, mode=1, filament=filament_type\n                )\n                if success:\n                    self._drying_in_progress[pid] = time.monotonic()\n\n    def _sync_drying_state(self):\n        \"\"\"Sync in-memory drying state with actual printer status.\n\n        Handles backend restart — if a printer is drying but we don't know about it,\n        update our state. If we think it's drying but it's not, clear it.\n        \"\"\"\n        to_remove = []\n        for pid in self._drying_in_progress:\n            state = printer_manager.get_status(pid)\n            if not state:\n                to_remove.append(pid)\n                continue\n            # Check if any AMS unit is still drying\n            ams_list = state.raw_data.get(\"ams\", [])\n            any_drying = any(int(a.get(\"dry_time\") or 0) > 0 for a in ams_list)\n            if not any_drying:\n                to_remove.append(pid)\n        for pid in to_remove:\n            self._drying_in_progress.pop(pid, None)\n\n    async def _stop_drying(self, printer_id: int):\n        \"\"\"Stop all active drying on a printer (print takes priority).\"\"\"\n        state = printer_manager.get_status(printer_id)\n        if not state:\n            self._drying_in_progress.pop(printer_id, None)\n            return\n\n        ams_list = state.raw_data.get(\"ams\", [])\n        for ams_data in ams_list:\n            dry_time = int(ams_data.get(\"dry_time\") or 0)\n            if dry_time > 0:\n                ams_id = int(ams_data.get(\"id\", 0))\n                logger.info(\n                    \"Auto-drying: stopping drying on printer %d AMS %d — print takes priority\",\n                    printer_id,\n                    ams_id,\n                )\n                printer_manager.send_drying_command(printer_id, ams_id, 0, 0, mode=0)\n        self._drying_in_progress.pop(printer_id, None)\n\n    async def _get_smart_plugs(self, db: AsyncSession, printer_id: int) -> list[SmartPlug]:\n        \"\"\"Get all smart plugs associated with a printer.\"\"\"\n        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n        return list(result.scalars().all())\n\n    async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:\n        \"\"\"Turn on smart plug and wait for printer to connect.\n\n        Returns True if printer connected successfully within timeout.\n        \"\"\"\n        # Get the appropriate service for the plug type (Tasmota or Home Assistant)\n        service = await smart_plug_manager.get_service_for_plug(plug, db)\n\n        # Check current plug state\n        status = await service.get_status(plug)\n        if not status.get(\"reachable\"):\n            logger.warning(\"Smart plug '%s' is not reachable\", plug.name)\n            return False\n\n        # Turn on if not already on\n        if status.get(\"state\") != \"ON\":\n            success = await service.turn_on(plug)\n            if not success:\n                logger.warning(\"Failed to turn on smart plug '%s'\", plug.name)\n                return False\n            logger.info(\"Powered on smart plug '%s' for printer %s\", plug.name, printer_id)\n\n        # Get printer from database for connection\n        result = await db.execute(select(Printer).where(Printer.id == printer_id))\n        printer = result.scalar_one_or_none()\n        if not printer:\n            logger.error(\"Printer %s not found in database\", printer_id)\n            return False\n\n        # Wait for printer to boot (give it some time before trying to connect)\n        logger.info(\"Waiting 30s for printer %s to boot...\", printer_id)\n        await asyncio.sleep(30)\n\n        # Try to connect to the printer periodically\n        elapsed = 30  # Already waited 30s\n        while elapsed < self._power_on_wait_time:\n            # Try to connect\n            logger.info(\"Attempting to connect to printer %s...\", printer_id)\n            try:\n                connected = await printer_manager.connect_printer(printer)\n                if connected:\n                    logger.info(\"Printer %s connected after %ss\", printer_id, elapsed)\n                    # Give it a moment to stabilize and get status\n                    await asyncio.sleep(5)\n                    return True\n            except Exception as e:\n                logger.debug(\"Connection attempt failed: %s\", e)\n\n            await asyncio.sleep(self._power_on_check_interval)\n            elapsed += self._power_on_check_interval\n            logger.debug(\"Waiting for printer %s to connect... (%ss)\", printer_id, elapsed)\n\n        logger.warning(\"Printer %s did not connect within %ss after power on\", printer_id, self._power_on_wait_time)\n        return False\n\n    async def _check_previous_success(self, db: AsyncSession, item: PrintQueueItem) -> bool:\n        \"\"\"Check if the previous print on this printer succeeded.\"\"\"\n        # Find the most recent completed queue item for this printer\n        result = await db.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.printer_id == item.printer_id)\n            .where(PrintQueueItem.id != item.id)\n            .where(PrintQueueItem.status.in_([\"completed\", \"failed\", \"skipped\", \"aborted\"]))\n            .order_by(PrintQueueItem.completed_at.desc())\n            .limit(1)\n        )\n        prev_item = result.scalar_one_or_none()\n\n        # If no previous item, assume success (first in queue)\n        if not prev_item:\n            return True\n\n        return prev_item.status == \"completed\"\n\n    async def _power_off_if_needed(self, db: AsyncSession, item: PrintQueueItem):\n        \"\"\"Power off printer if auto_off_after is enabled (waits for cooldown).\"\"\"\n        if not item.auto_off_after:\n            return\n\n        plugs = await self._get_smart_plugs(db, item.printer_id)\n        plug_ids = [p.id for p in plugs if p.enabled]\n        if plug_ids:\n            logger.info(\"Auto-off: Waiting for printer %s to cool down before power off...\", item.printer_id)\n            # Wait for cooldown (up to 10 minutes)\n            await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)\n            # Re-fetch plugs in a fresh session after the long cooldown wait\n            async with async_session() as new_db:\n                for plug_id in plug_ids:\n                    try:\n                        result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n                        plug = result.scalar_one_or_none()\n                        if plug and plug.enabled:\n                            logger.info(\"Auto-off: Powering off plug '%s' for printer %s\", plug.name, item.printer_id)\n                            service = await smart_plug_manager.get_service_for_plug(plug, new_db)\n                            await service.turn_off(plug)\n                    except Exception as e:\n                        logger.warning(\n                            \"Auto-off: Failed to power off plug %s for printer %s: %s\", plug_id, item.printer_id, e\n                        )\n\n    async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:\n        \"\"\"Get a human-readable name for a queue item.\"\"\"\n        if item.archive_id:\n            result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))\n            archive = result.scalar_one_or_none()\n            if archive:\n                return archive.filename.replace(\".gcode.3mf\", \"\").replace(\".3mf\", \"\")\n        if item.library_file_id:\n            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))\n            library_file = result.scalar_one_or_none()\n            if library_file:\n                return library_file.filename.replace(\".gcode.3mf\", \"\").replace(\".3mf\", \"\")\n        return f\"Job #{item.id}\"\n\n    async def _get_printer(self, db: AsyncSession, printer_id: int) -> Printer | None:\n        \"\"\"Get printer by ID.\"\"\"\n        result = await db.execute(select(Printer).where(Printer.id == printer_id))\n        return result.scalar_one_or_none()\n\n    async def _start_print(self, db: AsyncSession, item: PrintQueueItem):\n        \"\"\"Upload file and start print for a queue item.\n\n        Supports two sources:\n        - archive_id: Print from an existing archive\n        - library_file_id: Print from a library file (file manager)\n        \"\"\"\n        logger.info(\"Starting queue item %s\", item.id)\n\n        # Get printer first (needed for both paths)\n        result = await db.execute(select(Printer).where(Printer.id == item.printer_id))\n        printer = result.scalar_one_or_none()\n        if not printer:\n            item.status = \"failed\"\n            item.error_message = \"Printer not found\"\n            item.completed_at = datetime.now(timezone.utc)\n            await db.commit()\n            logger.error(\"Queue item %s: Printer %s not found\", item.id, item.printer_id)\n            await self._power_off_if_needed(db, item)\n            return\n\n        # Check printer is connected\n        if not printer_manager.is_connected(item.printer_id):\n            item.status = \"failed\"\n            item.error_message = \"Printer not connected\"\n            item.completed_at = datetime.now(timezone.utc)\n            await db.commit()\n            logger.error(\"Queue item %s: Printer %s not connected\", item.id, item.printer_id)\n            await self._power_off_if_needed(db, item)\n            return\n\n        # Determine source: archive or library file\n        archive = None\n        library_file = None\n        file_path = None\n        filename = None\n\n        if item.archive_id:\n            # Print from archive\n            result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))\n            archive = result.scalar_one_or_none()\n            if not archive:\n                item.status = \"failed\"\n                item.error_message = \"Archive not found\"\n                item.completed_at = datetime.now(timezone.utc)\n                await db.commit()\n                logger.error(\"Queue item %s: Archive %s not found\", item.id, item.archive_id)\n                await self._power_off_if_needed(db, item)\n                return\n\n            file_path = settings.base_dir / archive.file_path\n            filename = archive.filename\n\n        elif item.library_file_id:\n            # Print from library file (file manager)\n            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))\n            library_file = result.scalar_one_or_none()\n            if not library_file:\n                item.status = \"failed\"\n                item.error_message = \"Library file not found\"\n                item.completed_at = datetime.now(timezone.utc)\n                await db.commit()\n                logger.error(\"Queue item %s: Library file %s not found\", item.id, item.library_file_id)\n                await self._power_off_if_needed(db, item)\n                return\n            # Library files store absolute paths\n            lib_path = Path(library_file.file_path)\n            file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path\n            filename = library_file.filename\n\n            # Create archive from library file so usage tracking has access to the 3MF\n            try:\n                from backend.app.services.archive import ArchiveService\n\n                archive_service = ArchiveService(db)\n                archive = await archive_service.archive_print(\n                    printer_id=item.printer_id,\n                    source_file=file_path,\n                    original_filename=filename,\n                    created_by_id=item.created_by_id,\n                    project_id=item.project_id,\n                )\n                if archive:\n                    item.archive_id = archive.id\n                    await db.flush()\n                    logger.info(\n                        \"Queue item %s: Created archive %s from library file %s\",\n                        item.id,\n                        archive.id,\n                        item.library_file_id,\n                    )\n            except Exception as e:\n                logger.warning(\"Queue item %s: Failed to create archive from library file: %s\", item.id, e)\n\n        else:\n            # Neither archive nor library file specified\n            item.status = \"failed\"\n            item.error_message = \"No source file specified\"\n            item.completed_at = datetime.now(timezone.utc)\n            await db.commit()\n            logger.error(\"Queue item %s: No archive_id or library_file_id specified\", item.id)\n            await self._power_off_if_needed(db, item)\n            return\n\n        # Check file exists on disk\n        if not file_path.exists():\n            item.status = \"failed\"\n            item.error_message = \"Source file not found on disk\"\n            item.completed_at = datetime.now(timezone.utc)\n            await db.commit()\n            logger.error(\"Queue item %s: File not found: %s\", item.id, file_path)\n            await self._power_off_if_needed(db, item)\n            return\n\n        # G-code injection for auto-print systems (#422)\n        injected_path = None\n        if item.gcode_injection:\n            try:\n                snippets_raw = await self._get_setting(db, \"gcode_snippets\")\n                if snippets_raw:\n                    snippets = json.loads(snippets_raw)\n                    model_snippets = snippets.get(printer.model, {})\n                    start_gc = (model_snippets.get(\"start_gcode\") or \"\").strip()\n                    end_gc = (model_snippets.get(\"end_gcode\") or \"\").strip()\n                    if start_gc or end_gc:\n                        from backend.app.utils.threemf_tools import inject_gcode_into_3mf\n\n                        injected_path = inject_gcode_into_3mf(\n                            file_path, item.plate_id or 1, start_gc or None, end_gc or None\n                        )\n                        if injected_path:\n                            file_path = injected_path\n                            logger.info(\"Queue item %s: G-code injected for model %s\", item.id, printer.model)\n                        else:\n                            logger.warning(\n                                \"Queue item %s: G-code injection returned no result, using original\", item.id\n                            )\n            except Exception as e:\n                logger.warning(\"Queue item %s: G-code injection failed, using original: %s\", item.id, e)\n\n        # Upload file to printer via FTP\n        # Use a clean filename to avoid issues with double extensions like .gcode.3mf\n        base_name = filename\n        if base_name.endswith(\".gcode.3mf\"):\n            base_name = base_name[:-10]  # Remove .gcode.3mf\n        elif base_name.endswith(\".3mf\"):\n            base_name = base_name[:-4]  # Remove .3mf\n        remote_filename = f\"{base_name}.3mf\"\n        # Sanitize: firmware parses ftp://{filename} as a URL, spaces break it\n        remote_filename = remote_filename.replace(\" \", \"_\")\n        # Upload to root directory (not /cache/) - the start_print command references\n        # files by name only (ftp://{filename}), so they must be in the root\n        remote_path = f\"/{remote_filename}\"\n\n        # Get FTP retry settings\n        ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()\n\n        logger.info(\n            f\"Queue item {item.id}: FTP upload starting - printer={printer.name} ({printer.model}), \"\n            f\"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, \"\n            f\"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}\"\n        )\n\n        # Delete existing file if present (avoids 553 error on overwrite)\n        try:\n            logger.debug(\"Queue item %s: Deleting existing file %s if present...\", item.id, remote_path)\n            delete_result = await delete_file_async(\n                printer.ip_address,\n                printer.access_code,\n                remote_path,\n                socket_timeout=ftp_timeout,\n                printer_model=printer.model,\n            )\n            logger.debug(\"Queue item %s: Delete result: %s\", item.id, delete_result)\n        except Exception as e:\n            logger.debug(\"Queue item %s: Delete failed (may not exist): %s\", item.id, e)\n\n        try:\n            if ftp_retry_enabled:\n                uploaded = await with_ftp_retry(\n                    upload_file_async,\n                    printer.ip_address,\n                    printer.access_code,\n                    file_path,\n                    remote_path,\n                    socket_timeout=ftp_timeout,\n                    printer_model=printer.model,\n                    max_retries=ftp_retry_count,\n                    retry_delay=ftp_retry_delay,\n                    operation_name=f\"Upload print to {printer.name}\",\n                )\n            else:\n                uploaded = await upload_file_async(\n                    printer.ip_address,\n                    printer.access_code,\n                    file_path,\n                    remote_path,\n                    socket_timeout=ftp_timeout,\n                    printer_model=printer.model,\n                )\n        except Exception as e:\n            uploaded = False\n            logger.error(\"Queue item %s: FTP error: %s (type: %s)\", item.id, e, type(e).__name__)\n\n        # Clean up injected temp file after upload attempt\n        if injected_path and injected_path.exists():\n            injected_path.unlink(missing_ok=True)\n\n        if not uploaded:\n            error_msg = (\n                \"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). \"\n                \"See server logs for detailed diagnostics.\"\n            )\n            item.status = \"failed\"\n            item.error_message = error_msg\n            item.completed_at = datetime.now(timezone.utc)\n            await db.commit()\n            logger.error(\n                f\"Queue item {item.id}: FTP upload failed - printer={printer.name}, model={printer.model}, \"\n                f\"ip={printer.ip_address}. Check logs above for storage diagnostics and specific error codes.\"\n            )\n\n            # Send failure notification\n            await notification_service.on_queue_job_failed(\n                job_name=filename.replace(\".gcode.3mf\", \"\").replace(\".3mf\", \"\"),\n                printer_id=printer.id,\n                printer_name=printer.name,\n                reason=\"Failed to upload file to printer\",\n                db=db,\n            )\n            await self._power_off_if_needed(db, item)\n            return\n\n        # Parse AMS mapping if stored\n        ams_mapping = None\n        if item.ams_mapping:\n            try:\n                ams_mapping = json.loads(item.ams_mapping)\n            except json.JSONDecodeError:\n                logger.warning(\"Queue item %s: Invalid AMS mapping JSON, ignoring\", item.id)\n\n        # Register as expected print so we don't create a duplicate archive\n        # Only applicable for archive-based prints\n        if archive:\n            from backend.app.main import register_expected_print\n\n            register_expected_print(\n                item.printer_id,\n                remote_filename,\n                archive.id,\n                ams_mapping=ams_mapping,\n                created_by_id=item.created_by_id,\n            )\n\n        # IMPORTANT: Set status to \"printing\" BEFORE sending the print command.\n        # This prevents phantom reprints if the backend crashes/restarts after the\n        # print command is sent but before the status update is committed.\n        # If we crash after this commit but before start_print(), the item will be\n        # in \"printing\" status without actually printing - but that's safer than\n        # accidentally reprinting the same file hours later.\n        item.status = \"printing\"\n        item.started_at = datetime.now(timezone.utc)\n        await db.commit()\n\n        # Clear the awaiting-plate-clear flag now that we're starting a new print\n        printer_manager.set_awaiting_plate_clear(item.printer_id, False)\n        logger.info(\"Queue item %s: Status set to 'printing', sending print command...\", item.id)\n\n        # Capture state before dispatch so the watchdog can detect whether the\n        # printer actually transitioned (#967). Also capture subtask_id so the\n        # watchdog can recognise \"command landed but state hasn't flipped yet\"\n        # on slow H2D transitions (#1078).\n        pre_status = printer_manager.get_status(item.printer_id)\n        pre_state = getattr(pre_status, \"state\", None) if pre_status else None\n        pre_subtask_id = getattr(pre_status, \"subtask_id\", None) if pre_status else None\n\n        # Start the print with AMS mapping, plate_id and print options\n        started = printer_manager.start_print(\n            item.printer_id,\n            remote_filename,\n            plate_id=item.plate_id or 1,\n            ams_mapping=ams_mapping,\n            bed_levelling=item.bed_levelling,\n            flow_cali=item.flow_cali,\n            vibration_cali=item.vibration_cali,\n            layer_inspect=item.layer_inspect,\n            timelapse=item.timelapse,\n            use_ams=item.use_ams,\n        )\n\n        if started:\n            logger.info(\"Queue item %s: Print started successfully - %s\", item.id, filename)\n\n            # Watchdog: if the printer never transitions out of pre_state AND\n            # never advances subtask_id, the MQTT publish was accepted locally but\n            # didn't reach the printer (half-broken session — same shape as\n            # #887/#936). Revert the queue item so the next dispatch can pick it\n            # up instead of leaving it stuck in \"printing\" (#967). subtask_id\n            # check avoids false reverts on slow H2D FINISH→PREPARE transitions\n            # that would otherwise cause the item to re-dispatch as a reprint\n            # of the just-finished job (#1078).\n            if pre_state:\n                asyncio.create_task(\n                    self._watchdog_print_start(\n                        item.id,\n                        item.printer_id,\n                        pre_state,\n                        pre_subtask_id,\n                    )\n                )\n\n            # Get estimated time for notification\n            estimated_time = None\n            if archive and archive.print_time_seconds:\n                estimated_time = archive.print_time_seconds\n            elif library_file and library_file.print_time_seconds:\n                estimated_time = library_file.print_time_seconds\n\n            # Send job started notification\n            await notification_service.on_queue_job_started(\n                job_name=filename.replace(\".gcode.3mf\", \"\").replace(\".3mf\", \"\"),\n                printer_id=printer.id,\n                printer_name=printer.name,\n                db=db,\n                estimated_time=estimated_time,\n            )\n\n            # MQTT relay - publish queue job started\n            try:\n                from backend.app.services.mqtt_relay import mqtt_relay\n\n                await mqtt_relay.on_queue_job_started(\n                    job_id=item.id,\n                    filename=filename,\n                    printer_id=printer.id,\n                    printer_name=printer.name,\n                    printer_serial=printer.serial_number,\n                )\n            except Exception:\n                pass  # Don't fail if MQTT fails\n        else:\n            # Clean up uploaded file from SD card to prevent phantom prints\n            try:\n                await delete_file_async(\n                    printer.ip_address,\n                    printer.access_code,\n                    remote_path,\n                    printer_model=printer.model,\n                )\n            except Exception:\n                pass  # Best-effort — don't fail the error handler\n\n            # Print command failed - revert status\n            item.status = \"failed\"\n            item.error_message = \"Failed to send print command to printer\"\n            item.completed_at = datetime.now(timezone.utc)\n            await db.commit()\n            logger.error(\n                f\"Queue item {item.id}: Failed to start print on {printer.name} ({printer.model}) - \"\n                f\"printer_manager.start_print() returned False. \"\n                f\"This may indicate: printer not connected, MQTT error, unsupported model configuration, or firmware issue. \"\n                f\"Check printer status and backend logs for details.\"\n            )\n\n            # Send failure notification\n            await notification_service.on_queue_job_failed(\n                job_name=filename.replace(\".gcode.3mf\", \"\").replace(\".3mf\", \"\"),\n                printer_id=printer.id,\n                printer_name=printer.name,\n                reason=\"Failed to send print command to printer - check printer connection and status\",\n                db=db,\n            )\n\n            await self._power_off_if_needed(db, item)\n\n    @staticmethod\n    async def _watchdog_print_start(\n        queue_item_id: int,\n        printer_id: int,\n        pre_state: str,\n        pre_subtask_id: str | None = None,\n        timeout: float = 90.0,\n        poll_interval: float = 3.0,\n    ) -> None:\n        \"\"\"Revert a queue item if the printer never acknowledges the start command.\n\n        Bambuddy optimistically marks the queue item as \"printing\" right after the\n        MQTT project_file publish succeeds locally. If the printer drops/ignores the\n        command (half-broken MQTT session — #887/#936), the state never transitions\n        and the item would otherwise stay stuck in \"printing\" forever (#967).\n\n        Exit paths (printer picked up the job — no revert):\n          - gcode_state changed from pre_state, OR\n          - subtask_id advanced past pre_subtask_id — the printer echoes our\n            per-dispatch identity back on push_status, so a subtask_id change is\n            a definitive \"command landed\" signal even while state is still FINISH.\n            H2D can sit at FINISH for ~50 s after accepting project_file before\n            transitioning to PREPARE, which used to trip the state-only watchdog\n            and caused the scheduler to revert + re-dispatch the item; the next\n            successful dispatch then looked like a reprint of the just-finished\n            job (#1078).\n\n        Timeout raised from 45 s → 90 s as belt-and-braces for slow transitions\n        that also don't emit an early subtask_id tick.\n        \"\"\"\n        deadline = time.monotonic() + timeout\n        while time.monotonic() < deadline:\n            await asyncio.sleep(poll_interval)\n            status = printer_manager.get_status(printer_id)\n            if not status:\n                return  # Printer disconnected — don't mess with the DB\n            if status.state != pre_state:\n                return  # Printer picked up the job (state transition)\n            if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:\n                return  # Printer picked up the job (subtask_id advanced)\n\n        # No transition. Revert the item so the scheduler can retry.\n        async with async_session() as db:\n            item = await db.get(PrintQueueItem, queue_item_id)\n            if not item or item.status != \"printing\":\n                return  # Already moved on (completed/cancelled/etc.)\n            item.status = \"pending\"\n            item.started_at = None\n            await db.commit()\n            logger.warning(\n                \"Queue item %s: printer %d did not respond to print command within \"\n                \"%.0fs (state still %s, subtask_id still %s) — reverted to 'pending' \"\n                \"for retry (#967)\",\n                queue_item_id,\n                printer_id,\n                timeout,\n                pre_state,\n                pre_subtask_id,\n            )\n\n        # Same half-broken-session recovery as background_dispatch: force the\n        # MQTT client to reconnect so the next dispatch lands without a power cycle.\n        client = printer_manager.get_client(printer_id)\n        if client and hasattr(client, \"force_reconnect_stale_session\"):\n            client.force_reconnect_stale_session(\n                f\"queue print command unacknowledged after {timeout:.0f}s (state still {pre_state})\"\n            )\n\n\n# Global scheduler instance\nscheduler = PrintScheduler()\n"
  },
  {
    "path": "backend/app/services/printer_manager.py",
    "content": "import asyncio\nimport logging\nimport re\nimport traceback\nfrom collections.abc import Callable\n\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.printer import Printer\nfrom backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState, get_stage_name\n\nlogger = logging.getLogger(__name__)\n\n# Models that have a real chamber temperature sensor\n# Based on Home Assistant Bambu Lab integration\n# P1P/P1S and A1/A1Mini do NOT have chamber temp sensors\n# Includes both display names and internal codes from MQTT/SSDP\nCHAMBER_TEMP_SUPPORTED_MODELS = frozenset(\n    [\n        # Display names\n        \"X1\",\n        \"X1C\",\n        \"X1E\",  # X1 series\n        \"X2D\",  # X2 series\n        \"P2S\",  # P2 series\n        \"H2C\",\n        \"H2D\",\n        \"H2DPRO\",\n        \"H2S\",  # H2 series\n        # Internal codes (from MQTT/SSDP)\n        \"BL-P001\",  # X1/X1C\n        \"C13\",  # X1E\n        \"N6\",  # X2D\n        \"O1D\",  # H2D\n        \"O1C\",  # H2C\n        \"O1C2\",  # H2C (dual nozzle variant)\n        \"O1S\",  # H2S\n        \"O1E\",  # H2D Pro\n        \"O2D\",  # H2D Pro (alternate code)\n        \"N7\",  # P2S\n    ]\n)\n\n# Models that may incorrectly report stg_cur=0 when idle (firmware bug)\n# Based on Home Assistant Bambu Lab integration observations\n# See: https://github.com/greghesp/ha-bambulab/blob/main/custom_components/bambu_lab/pybambu/models.py\nA1_MODELS = frozenset(\n    [\n        # Display names\n        \"A1\",\n        \"A1 MINI\",\n        \"A1-MINI\",\n        \"A1MINI\",\n        # Internal codes (from MQTT/SSDP)\n        \"N1\",  # A1 Mini\n        \"N2S\",  # A1\n    ]\n)\n\n# Models affected by the stg_cur=0 idle bug (firmware reports stg_cur=0 when idle,\n# which maps to \"Printing\" in STAGE_NAMES and overrides the correct IDLE state)\nSTG_CUR_IDLE_BUG_MODELS = A1_MODELS | frozenset(\n    [\n        # Display names\n        \"P1P\",\n        \"P1S\",\n        # Internal codes (from MQTT/SSDP)\n        \"C11\",  # P1P\n        \"C12\",  # P1S\n    ]\n)\n\n\ndef supports_chamber_temp(model: str | None) -> bool:\n    \"\"\"Check if a printer model has a real chamber temperature sensor.\n\n    P1P, P1S, A1, and A1Mini do NOT have chamber temp sensors.\n    The 'chamber_temper' value they report is meaningless.\n    \"\"\"\n    if not model:\n        return False\n    # Normalize model name (uppercase, strip whitespace)\n    model_upper = model.strip().upper()\n    return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS\n\n\ndef has_stg_cur_idle_bug(model: str | None) -> bool:\n    \"\"\"Check if a printer model may incorrectly report stg_cur=0 when idle.\n\n    Some firmware versions report stg_cur=0 (which maps to \"Printing\")\n    even when the printer is idle. Originally observed on A1/A1 Mini via the\n    Home Assistant Bambu Lab integration, also confirmed on P1S.\n    \"\"\"\n    if not model:\n        return False\n    model_upper = model.strip().upper()\n    return model_upper in STG_CUR_IDLE_BUG_MODELS\n\n\n# Minimum firmware versions for AMS drying support (confirmed via capture testing)\n# Keys are exact model names (upper-cased). Do NOT use substring matching — it would\n# incorrectly gate X1E (matched by \"X1\") and H2D Pro (matched by \"H2D\").\n_DRYING_MIN_FIRMWARE: dict[str, str] = {\n    \"H2D\": \"01.02.30.00\",\n    \"H2S\": \"01.02.00.00\",\n    \"X1\": \"01.09.00.00\",\n    \"X1C\": \"01.09.00.00\",\n    \"P1P\": \"01.08.00.00\",\n    \"P1S\": \"01.08.00.00\",\n    \"P2S\": \"01.02.00.00\",\n    \"N7\": \"01.02.00.00\",  # P2S internal model code\n}\n# Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)\n_DRYING_UNSUPPORTED_MODELS = frozenset({\"A1\", \"A1MINI\", \"A1-MINI\", \"A1 MINI\", \"H2C\", \"O1C\", \"O1C2\", \"O1S\", \"N1\", \"N2S\"})\n\n\ndef supports_drying(model: str | None, firmware: str | None) -> bool:\n    \"\"\"Check if a printer model supports AMS drying commands.\n\n    Known models with confirmed min firmware get version-gated.\n    Known unsupported models are blocked.\n    All other models (H2D Pro, X1E, future models) are allowed —\n    the command fails gracefully with result: \"fail\" if unsupported.\n    \"\"\"\n    if not model:\n        return False\n    model_upper = model.strip().upper()\n    if model_upper in _DRYING_UNSUPPORTED_MODELS:\n        return False\n    if model_upper in _DRYING_MIN_FIRMWARE:\n        return bool(firmware and firmware >= _DRYING_MIN_FIRMWARE[model_upper])\n    # For all other models: allow\n    return True\n\n\nclass PrinterInfo:\n    \"\"\"Basic printer info for callbacks.\"\"\"\n\n    def __init__(self, name: str, serial_number: str):\n        self.name = name\n        self.serial_number = serial_number\n\n\nclass PrinterManager:\n    \"\"\"Manager for multiple printer connections.\"\"\"\n\n    def __init__(self):\n        self._clients: dict[int, BambuMQTTClient] = {}\n        self._models: dict[int, str | None] = {}  # Cache printer models for feature detection\n        self._printer_info: dict[int, PrinterInfo] = {}  # Cache printer name/serial for callbacks\n        self._on_print_start: Callable[[int, dict], None] | None = None\n        self._on_print_complete: Callable[[int, dict], None] | None = None\n        self._on_status_change: Callable[[int, PrinterState], None] | None = None\n        self._on_ams_change: Callable[[int, list], None] | None = None\n        self._on_layer_change: Callable[[int, int], None] | None = None\n        self._on_bed_temp_update: Callable[[int, float], None] | None = None\n        self._loop: asyncio.AbstractEventLoop | None = None\n        # Track who started the current print (Issue #206)\n        self._current_print_user: dict[int, dict] = {}  # {printer_id: {\"user_id\": int, \"username\": str}}\n        # Track printers awaiting plate-clear acknowledgment after a finished/failed print.\n        # Persisted to DB (printers.awaiting_plate_clear) so the gate survives restarts/power\n        # cycles — see issue #961. Loaded into this set at startup via load_awaiting_plate_clear_from_db().\n        self._awaiting_plate_clear: set[int] = set()\n\n    def get_printer(self, printer_id: int) -> PrinterInfo | None:\n        \"\"\"Get printer info by ID.\"\"\"\n        return self._printer_info.get(printer_id)\n\n    def set_current_print_user(self, printer_id: int, user_id: int, username: str):\n        \"\"\"Track who started the current print (Issue #206).\"\"\"\n        self._current_print_user[printer_id] = {\"user_id\": user_id, \"username\": username}\n\n    def get_current_print_user(self, printer_id: int) -> dict | None:\n        \"\"\"Get the user who started the current print (Issue #206).\"\"\"\n        return self._current_print_user.get(printer_id)\n\n    def clear_current_print_user(self, printer_id: int):\n        \"\"\"Clear the current print user when print completes (Issue #206).\"\"\"\n        self._current_print_user.pop(printer_id, None)\n\n    def is_awaiting_plate_clear(self, printer_id: int) -> bool:\n        \"\"\"Return True when the printer finished/failed a print and is waiting for the\n        user to acknowledge the plate is cleared before the queue may dispatch the next job.\n        \"\"\"\n        return printer_id in self._awaiting_plate_clear\n\n    def set_awaiting_plate_clear(self, printer_id: int, awaiting: bool):\n        \"\"\"Set/clear the awaiting-plate-clear gate and persist it to DB.\n\n        Persisted so the gate survives Bambuddy/printer restarts (#961): after Auto Off\n        cycles the printer, the printer boots into IDLE with no memory of the previous\n        finish, and without persistence the queue would bypass the confirmation prompt.\n        \"\"\"\n        if awaiting:\n            self._awaiting_plate_clear.add(printer_id)\n        else:\n            self._awaiting_plate_clear.discard(printer_id)\n        # Only create the coroutine when there is a loop to run it on — otherwise Python\n        # emits \"coroutine was never awaited\" warnings (e.g. in sync unit tests).\n        if self._loop and self._loop.is_running():\n            self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))\n\n    async def _persist_awaiting_plate_clear(self, printer_id: int, awaiting: bool):\n        from backend.app.core.database import async_session\n\n        try:\n            async with async_session() as db:\n                printer = await db.get(Printer, printer_id)\n                if printer is not None:\n                    printer.awaiting_plate_clear = awaiting\n                    await db.commit()\n        except Exception as e:\n            logger.warning(\"Failed to persist awaiting_plate_clear for printer %d: %s\", printer_id, e)\n\n    async def load_awaiting_plate_clear_from_db(self):\n        \"\"\"Rehydrate the awaiting-plate-clear set from the printers table on startup.\"\"\"\n        from backend.app.core.database import async_session\n\n        try:\n            async with async_session() as db:\n                result = await db.execute(select(Printer.id).where(Printer.awaiting_plate_clear.is_(True)))\n                ids = {row[0] for row in result.all()}\n                self._awaiting_plate_clear = ids\n                if ids:\n                    logger.info(\"Loaded %d printer(s) awaiting plate-clear acknowledgment: %s\", len(ids), sorted(ids))\n        except Exception as e:\n            logger.warning(\"Failed to load awaiting_plate_clear from DB: %s\", e)\n\n    def set_event_loop(self, loop: asyncio.AbstractEventLoop):\n        \"\"\"Set the event loop for async callbacks.\"\"\"\n        self._loop = loop\n\n    def set_print_start_callback(self, callback: Callable[[int, dict], None]):\n        \"\"\"Set callback for print start events.\"\"\"\n        self._on_print_start = callback\n\n    def set_print_complete_callback(self, callback: Callable[[int, dict], None]):\n        \"\"\"Set callback for print completion events.\"\"\"\n        self._on_print_complete = callback\n\n    def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]):\n        \"\"\"Set callback for status change events.\"\"\"\n        self._on_status_change = callback\n\n    def set_ams_change_callback(self, callback: Callable[[int, list], None]):\n        \"\"\"Set callback for AMS data change events.\"\"\"\n        self._on_ams_change = callback\n\n    def set_layer_change_callback(self, callback: Callable[[int, int], None]):\n        \"\"\"Set callback for layer change events. Receives (printer_id, layer_num).\"\"\"\n        self._on_layer_change = callback\n\n    def set_bed_temp_update_callback(self, callback: Callable[[int, float], None]):\n        \"\"\"Set callback for bed temperature updates. Receives (printer_id, bed_temp).\"\"\"\n        self._on_bed_temp_update = callback\n\n    def _schedule_async(self, coro):\n        \"\"\"Schedule an async coroutine from a sync context.\n\n        Captures exceptions from the coroutine and logs them to prevent\n        silent failures in callbacks.\n        \"\"\"\n        if self._loop and self._loop.is_running():\n            future = asyncio.run_coroutine_threadsafe(coro, self._loop)\n\n            def handle_exception(f):\n                try:\n                    # This will re-raise any exception from the coroutine\n                    f.result()\n                except Exception as e:\n                    import logging\n\n                    logging.getLogger(__name__).error(f\"Exception in scheduled callback: {e}\", exc_info=True)\n\n            future.add_done_callback(handle_exception)\n\n    async def connect_printer(self, printer: Printer) -> bool:\n        \"\"\"Connect to a printer.\"\"\"\n        if printer.id in self._clients:\n            self.disconnect_printer(printer.id)\n\n        printer_id = printer.id\n\n        def on_state_change(state: PrinterState):\n            if self._on_status_change:\n                self._schedule_async(self._on_status_change(printer_id, state))\n\n        def on_print_start(data: dict):\n            if self._on_print_start:\n                self._schedule_async(self._on_print_start(printer_id, data))\n\n        def on_print_complete(data: dict):\n            if self._on_print_complete:\n                self._schedule_async(self._on_print_complete(printer_id, data))\n\n        def on_ams_change(ams_data: list):\n            if self._on_ams_change:\n                self._schedule_async(self._on_ams_change(printer_id, ams_data))\n\n        def on_layer_change(layer_num: int):\n            if self._on_layer_change:\n                self._schedule_async(self._on_layer_change(printer_id, layer_num))\n\n        def on_bed_temp_update(bed_temp: float):\n            if self._on_bed_temp_update:\n                self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))\n\n        client = BambuMQTTClient(\n            ip_address=printer.ip_address,\n            serial_number=printer.serial_number,\n            access_code=printer.access_code,\n            model=printer.model,\n            on_state_change=on_state_change,\n            on_print_start=on_print_start,\n            on_print_complete=on_print_complete,\n            on_ams_change=on_ams_change,\n            on_layer_change=on_layer_change,\n            on_bed_temp_update=on_bed_temp_update,\n        )\n\n        client.connect()\n        self._clients[printer_id] = client\n        self._models[printer_id] = printer.model  # Cache model for feature detection\n        self._printer_info[printer_id] = PrinterInfo(printer.name, printer.serial_number)\n\n        # Wait a moment for connection\n        await asyncio.sleep(1)\n        return client.state.connected\n\n    def disconnect_printer(self, printer_id: int, timeout: float = 0):\n        \"\"\"Disconnect from a printer.\"\"\"\n        if printer_id in self._clients:\n            self._clients[printer_id].disconnect(timeout=timeout)\n            del self._clients[printer_id]\n        self._models.pop(printer_id, None)  # Clean up model cache\n        self._printer_info.pop(printer_id, None)  # Clean up printer info cache\n\n    def disconnect_all(self, timeout: float = 0):\n        \"\"\"Disconnect from all printers.\"\"\"\n        for printer_id in list(self._clients.keys()):\n            self.disconnect_printer(printer_id, timeout=timeout)\n\n    def get_status(self, printer_id: int) -> PrinterState | None:\n        \"\"\"Get the current status of a printer (checks for stale connections).\"\"\"\n        if printer_id in self._clients:\n            client = self._clients[printer_id]\n            # Check staleness and update connected state if needed\n            client.check_staleness()\n            return client.state\n        return None\n\n    def get_model(self, printer_id: int) -> str | None:\n        \"\"\"Get the cached model for a printer.\"\"\"\n        return self._models.get(printer_id)\n\n    def get_all_statuses(self) -> dict[int, PrinterState]:\n        \"\"\"Get status of all connected printers (checks for stale connections).\"\"\"\n        result = {}\n        for printer_id, client in self._clients.items():\n            # Check staleness and update connected state if needed\n            client.check_staleness()\n            result[printer_id] = client.state\n        return result\n\n    def is_connected(self, printer_id: int) -> bool:\n        \"\"\"Check if a printer is connected (checks for stale connections).\"\"\"\n        if printer_id in self._clients:\n            client = self._clients[printer_id]\n            # Check staleness and update connected state if needed\n            return client.check_staleness()\n        return False\n\n    def get_client(self, printer_id: int) -> BambuMQTTClient | None:\n        \"\"\"Get the MQTT client for a printer.\"\"\"\n        return self._clients.get(printer_id)\n\n    def mark_printer_offline(self, printer_id: int):\n        \"\"\"Mark a printer as offline and trigger status callback.\n\n        This is used when we know the printer power was cut (e.g., smart plug turned off)\n        to immediately update the UI without waiting for MQTT timeout.\n        \"\"\"\n        import logging\n\n        logger = logging.getLogger(__name__)\n\n        if printer_id in self._clients:\n            client = self._clients[printer_id]\n            if client.state.connected:\n                logger.info(\"Marking printer %s as offline (smart plug power off)\", printer_id)\n                client.state.connected = False\n                client.state.state = \"unknown\"\n                # Trigger the status change callback to broadcast via WebSocket\n                if self._on_status_change:\n                    self._schedule_async(self._on_status_change(printer_id, client.state))\n\n    def start_print(\n        self,\n        printer_id: int,\n        filename: str,\n        plate_id: int = 1,\n        ams_mapping: list[int] | None = None,\n        bed_levelling: bool = True,\n        flow_cali: bool = False,\n        vibration_cali: bool = True,\n        layer_inspect: bool = False,\n        timelapse: bool = False,\n        use_ams: bool = True,\n    ) -> bool:\n        \"\"\"Start a print on a connected printer.\"\"\"\n        caller = traceback.extract_stack(limit=3)[0]\n        logger.info(\n            \"PRINT COMMAND: printer=%s, file=%s, caller=%s:%s:%s\",\n            printer_id,\n            filename,\n            caller.filename.split(\"/\")[-1],\n            caller.lineno,\n            caller.name,\n        )\n        if printer_id in self._clients:\n            return self._clients[printer_id].start_print(\n                filename,\n                plate_id,\n                ams_mapping=ams_mapping,\n                timelapse=timelapse,\n                bed_levelling=bed_levelling,\n                flow_cali=flow_cali,\n                vibration_cali=vibration_cali,\n                layer_inspect=layer_inspect,\n                use_ams=use_ams,\n            )\n        return False\n\n    def stop_print(self, printer_id: int) -> bool:\n        \"\"\"Stop the current print on a connected printer.\"\"\"\n        if printer_id in self._clients:\n            return self._clients[printer_id].stop_print()\n        return False\n\n    async def wait_for_cooldown(\n        self,\n        printer_id: int,\n        target_temp: float = 50.0,\n        timeout: int = 600,\n        check_interval: int = 10,\n    ) -> bool:\n        \"\"\"Wait for the nozzle to cool down to a safe temperature.\n\n        Args:\n            printer_id: The printer to monitor\n            target_temp: Target temperature to wait for (default 50°C)\n            timeout: Maximum seconds to wait (default 600s = 10 min)\n            check_interval: Seconds between temperature checks (default 10s)\n\n        Returns:\n            True if cooled down, False if timeout or not connected\n        \"\"\"\n        import logging\n\n        logger = logging.getLogger(__name__)\n\n        elapsed = 0\n        while elapsed < timeout:\n            state = self.get_status(printer_id)\n            if not state or not state.connected:\n                logger.warning(\"Printer %s disconnected during cooldown wait\", printer_id)\n                return False\n\n            # Check nozzle temperature (and nozzle_2 for dual extruders)\n            nozzle_temp = state.temperatures.get(\"nozzle\", 0)\n            nozzle_2_temp = state.temperatures.get(\"nozzle_2\", 0)\n            max_temp = max(nozzle_temp, nozzle_2_temp)\n\n            if max_temp <= target_temp:\n                logger.info(\"Printer %s cooled down to %s°C\", printer_id, max_temp)\n                return True\n\n            logger.debug(\"Printer %s nozzle at %s°C, waiting for %s°C...\", printer_id, max_temp, target_temp)\n            await asyncio.sleep(check_interval)\n            elapsed += check_interval\n\n        logger.warning(\"Printer %s cooldown timeout after %ss\", printer_id, timeout)\n        return False\n\n    def enable_logging(self, printer_id: int, enabled: bool = True) -> bool:\n        \"\"\"Enable or disable MQTT logging for a printer.\"\"\"\n        if printer_id in self._clients:\n            self._clients[printer_id].enable_logging(enabled)\n            return True\n        return False\n\n    def get_logs(self, printer_id: int) -> list[MQTTLogEntry]:\n        \"\"\"Get MQTT logs for a printer.\"\"\"\n        if printer_id in self._clients:\n            return self._clients[printer_id].get_logs()\n        return []\n\n    def clear_logs(self, printer_id: int) -> bool:\n        \"\"\"Clear MQTT logs for a printer.\"\"\"\n        if printer_id in self._clients:\n            self._clients[printer_id].clear_logs()\n            return True\n        return False\n\n    def is_logging_enabled(self, printer_id: int) -> bool:\n        \"\"\"Check if logging is enabled for a printer.\"\"\"\n        if printer_id in self._clients:\n            return self._clients[printer_id].logging_enabled\n        return False\n\n    def send_drying_command(\n        self,\n        printer_id: int,\n        ams_id: int,\n        temp: int,\n        duration: int,\n        mode: int = 1,\n        filament: str = \"\",\n        rotate_tray: bool = False,\n    ) -> bool:\n        \"\"\"Send AMS drying command to printer.\"\"\"\n        if printer_id not in self._clients:\n            return False\n        return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament, rotate_tray)\n\n    def request_status_update(self, printer_id: int) -> bool:\n        \"\"\"Request a full status update from the printer.\n\n        This sends a 'pushall' command to get the latest data including nozzle info.\n        \"\"\"\n        if printer_id in self._clients:\n            return self._clients[printer_id].request_status_update()\n        return False\n\n    async def test_connection(\n        self,\n        ip_address: str,\n        serial_number: str,\n        access_code: str,\n    ) -> dict:\n        \"\"\"Test connection to a printer without persisting.\"\"\"\n        client = BambuMQTTClient(\n            ip_address=ip_address,\n            serial_number=serial_number,\n            access_code=access_code,\n        )\n\n        try:\n            client.connect()\n            await asyncio.sleep(2)\n\n            result = {\n                \"success\": client.state.connected,\n                \"state\": client.state.state if client.state.connected else None,\n                \"model\": client.state.raw_data.get(\"device_model\"),\n            }\n        finally:\n            client.disconnect()\n\n        return result\n\n\ndef get_derived_status_name(state: PrinterState, model: str | None = None) -> str | None:\n    \"\"\"\n    Compute a human-readable status name based on printer state.\n\n    Uses stg_cur when available, otherwise derives status from temperature data\n    when the printer is heating before a print starts.\n\n    Args:\n        state: The printer state to analyze\n        model: Optional printer model for model-specific workarounds\n    \"\"\"\n    # Firmware bug: some models (A1, P1P, P1S) report stg_cur=0 when not printing.\n    # stg_cur=0 maps to \"Printing\" in STAGE_NAMES, which incorrectly overrides the\n    # real state (IDLE, FINISH, FAILED, etc.). Only trust stg_cur when the printer\n    # is actually in an active print state (RUNNING or PAUSE).\n    if state.state not in (\"RUNNING\", \"PAUSE\") and state.stg_cur == 0 and has_stg_cur_idle_bug(model):\n        return None\n\n    # If we have a valid calibration stage, use it\n    # X1 models use -1 for idle, A1/P1 models use 255 for idle\n    # Valid stage numbers are 0-254\n    if 0 <= state.stg_cur < 255:\n        return get_stage_name(state.stg_cur)\n\n    # If not in RUNNING state, no derived status needed\n    if state.state != \"RUNNING\":\n        return None\n\n    # Check if we're in an early phase where temperatures are heating\n    temps = state.temperatures or {}\n    progress = state.progress or 0\n\n    # Only derive heating status when progress is very low (< 2%)\n    # This indicates we're in the preparation phase, not actually printing\n    if progress >= 2:\n        return None\n\n    # Check bed temperature - if target is set and current is significantly below\n    bed_temp = temps.get(\"bed\", 0)\n    bed_target = temps.get(\"bed_target\", 0)\n\n    # Check nozzle temperature\n    nozzle_temp = temps.get(\"nozzle\", 0)\n    nozzle_target = temps.get(\"nozzle_target\", 0)\n\n    # Temperature thresholds: consider \"heating\" if more than 10°C below target\n    TEMP_THRESHOLD = 10\n\n    # Determine what's heating (prioritize bed since it takes longer)\n    if bed_target > 30 and (bed_target - bed_temp) > TEMP_THRESHOLD:\n        return \"Heating heatbed\"\n    elif nozzle_target > 30 and (nozzle_target - nozzle_temp) > TEMP_THRESHOLD:\n        return \"Heating nozzle\"\n\n    # If targets are set but we're close to them, we might be in final prep\n    if bed_target > 30 or nozzle_target > 30:\n        if progress == 0 and state.layer_num == 0:\n            return \"Preparing\"\n\n    return None\n\n\n_PLATE_ID_RE = re.compile(r\"plate_(\\d+)\\.gcode\")\n\n\ndef parse_plate_id(gcode_file: str | None) -> int | None:\n    \"\"\"Extract the 1-indexed plate number from a Bambu gcode_file path.\n\n    Returns None when the path is missing or has no `plate_N.gcode` segment.\n    Shared by the REST status route and the WebSocket push path so both agree\n    on the value sent to the frontend (#881 follow-up).\n    \"\"\"\n    if not gcode_file:\n        return None\n    match = _PLATE_ID_RE.search(gcode_file)\n    return int(match.group(1)) if match else None\n\n\ndef printer_state_to_dict(state: PrinterState, printer_id: int | None = None, model: str | None = None) -> dict:\n    \"\"\"Convert PrinterState to a JSON-serializable dict.\n\n    Args:\n        state: The printer state to convert\n        printer_id: Optional printer ID for generating cover URLs\n        model: Optional printer model for filtering unsupported features\n    \"\"\"\n    # Parse AMS data from raw_data\n    ams_units = []\n    vt_tray = []\n    raw_data = state.raw_data or {}\n\n    # Build K-profile lookup map: cali_idx -> k_value\n    kprofile_map: dict[int, float] = {}\n    for kp in state.kprofiles or []:\n        if kp.slot_id is not None and kp.k_value:\n            try:\n                kprofile_map[kp.slot_id] = float(kp.k_value)\n            except (ValueError, TypeError):\n                pass  # Skip K-profile entries with unparseable values\n\n    if \"ams\" in raw_data and isinstance(raw_data[\"ams\"], list):\n        for ams_data in raw_data[\"ams\"]:\n            trays = []\n            for tray in ams_data.get(\"tray\", []):\n                tag_uid = tray.get(\"tag_uid\")\n                if tag_uid in (\"\", \"0000000000000000\"):\n                    tag_uid = None\n                tray_uuid = tray.get(\"tray_uuid\")\n                if tray_uuid in (\"\", \"00000000000000000000000000000000\"):\n                    tray_uuid = None\n\n                # Get K value: first try tray's k field, then lookup from K-profiles\n                k_value = tray.get(\"k\")\n                cali_idx = tray.get(\"cali_idx\")\n                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:\n                    k_value = kprofile_map[cali_idx]\n\n                trays.append(\n                    {\n                        \"id\": int(tray.get(\"id\", 0)),\n                        \"tray_color\": tray.get(\"tray_color\"),\n                        \"tray_type\": tray.get(\"tray_type\"),\n                        \"tray_sub_brands\": tray.get(\"tray_sub_brands\"),\n                        \"tray_id_name\": tray.get(\"tray_id_name\"),\n                        \"tray_info_idx\": tray.get(\"tray_info_idx\"),\n                        \"remain\": tray.get(\"remain\", 0),\n                        \"k\": k_value,\n                        \"cali_idx\": cali_idx,\n                        \"tag_uid\": tag_uid,\n                        \"tray_uuid\": tray_uuid,\n                        \"nozzle_temp_min\": tray.get(\"nozzle_temp_min\"),\n                        \"nozzle_temp_max\": tray.get(\"nozzle_temp_max\"),\n                        \"drying_temp\": tray.get(\"drying_temp\"),\n                        \"drying_time\": tray.get(\"drying_time\"),\n                        \"state\": tray.get(\"state\"),\n                    }\n                )\n            # Prefer humidity_raw (actual percentage) over humidity (index 1-5)\n            humidity_raw = ams_data.get(\"humidity_raw\")\n            humidity_idx = ams_data.get(\"humidity\")\n            humidity_value = None\n\n            if humidity_raw is not None:\n                try:\n                    humidity_value = int(humidity_raw)\n                except (ValueError, TypeError):\n                    pass  # Skip unparseable humidity; will try index fallback\n            # Fall back to index if no raw value (index is 1-5, not percentage)\n            if humidity_value is None and humidity_idx is not None:\n                try:\n                    humidity_value = int(humidity_idx)\n                except (ValueError, TypeError):\n                    pass  # Skip unparseable humidity index; humidity remains None\n\n            # AMS-HT has 1 tray, regular AMS has 4 trays\n            is_ams_ht = len(trays) == 1\n\n            ams_units.append(\n                {\n                    \"id\": int(ams_data.get(\"id\", 0)),\n                    \"humidity\": humidity_value,\n                    \"temp\": ams_data.get(\"temp\"),\n                    \"is_ams_ht\": is_ams_ht,\n                    \"tray\": trays,\n                    # Serial number: Bambu MQTT uses \"sn\" key on AMS unit objects\n                    \"serial_number\": str(ams_data.get(\"sn\") or ams_data.get(\"serial_number\") or \"\"),\n                    # Firmware version: populated by _handle_version_info from get_version\n                    \"sw_ver\": str(ams_data.get(\"sw_ver\") or \"\"),\n                    # Drying: dry_time > 0 means drying is active (minutes remaining)\n                    \"dry_time\": int(ams_data.get(\"dry_time\") or 0),\n                    # Drying status from info hex bits (0=Off, 1=Checking, 2=Drying, 3=Cooling, etc.)\n                    \"dry_status\": int(ams_data.get(\"dry_status\") or 0),\n                    \"dry_sub_status\": int(ams_data.get(\"dry_sub_status\") or 0),\n                    # Cannot-dry reasons from firmware (e.g. 1=InsufficientPower, 8=NeedPluginPower)\n                    \"dry_sf_reason\": list(ams_data.get(\"dry_sf_reason\") or []),\n                    # Module type: \"ams\", \"n3f\", \"n3s\" (from get_version)\n                    \"module_type\": str(ams_data.get(\"module_type\") or \"\"),\n                }\n            )\n\n    # Parse virtual tray (external spool) — now a list\n    if \"vt_tray\" in raw_data:\n        vt_tray_raw = raw_data[\"vt_tray\"]\n        # Defensive: MQTT sends vt_tray as a dict; normalize to list\n        if isinstance(vt_tray_raw, dict):\n            vt_tray_raw = [vt_tray_raw]\n        elif not isinstance(vt_tray_raw, list):\n            vt_tray_raw = []\n        for vt_data in vt_tray_raw:\n            vt_tag_uid = vt_data.get(\"tag_uid\")\n            if vt_tag_uid in (\"\", \"0000000000000000\"):\n                vt_tag_uid = None\n            vt_tray_uuid = vt_data.get(\"tray_uuid\")\n            if vt_tray_uuid in (\"\", \"00000000000000000000000000000000\"):\n                vt_tray_uuid = None\n\n            # Get K value for vt_tray\n            vt_k_value = vt_data.get(\"k\")\n            vt_cali_idx = vt_data.get(\"cali_idx\")\n            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:\n                vt_k_value = kprofile_map[vt_cali_idx]\n\n            tray_id = int(vt_data.get(\"id\", 254))\n            vt_tray.append(\n                {\n                    \"id\": tray_id,\n                    \"tray_color\": vt_data.get(\"tray_color\"),\n                    \"tray_type\": vt_data.get(\"tray_type\"),\n                    \"tray_sub_brands\": vt_data.get(\"tray_sub_brands\"),\n                    \"tray_id_name\": vt_data.get(\"tray_id_name\"),\n                    \"tray_info_idx\": vt_data.get(\"tray_info_idx\"),\n                    \"remain\": vt_data.get(\"remain\", 0),\n                    \"k\": vt_k_value,\n                    \"cali_idx\": vt_cali_idx,\n                    \"tag_uid\": vt_tag_uid,\n                    \"tray_uuid\": vt_tray_uuid,\n                    \"nozzle_temp_min\": vt_data.get(\"nozzle_temp_min\"),\n                    \"nozzle_temp_max\": vt_data.get(\"nozzle_temp_max\"),\n                }\n            )\n\n    # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)\n    ams_extruder_map = raw_data.get(\"ams_extruder_map\", {})\n\n    # Filter out chamber temp for models that don't have a real sensor\n    # P1P, P1S, A1, A1Mini report meaningless chamber_temper values\n    temperatures = state.temperatures\n    if not supports_chamber_temp(model):\n        temperatures = {\n            k: v for k, v in temperatures.items() if k not in (\"chamber\", \"chamber_target\", \"chamber_heating\")\n        }\n\n    result = {\n        \"connected\": state.connected,\n        \"state\": state.state,\n        \"current_print\": state.current_print,\n        \"subtask_name\": state.subtask_name,\n        \"gcode_file\": state.gcode_file,\n        \"progress\": state.progress,\n        \"remaining_time\": state.remaining_time,\n        \"layer_num\": state.layer_num,\n        \"total_layers\": state.total_layers,\n        \"temperatures\": temperatures,\n        \"hms_errors\": [\n            {\"code\": e.code, \"attr\": e.attr, \"module\": e.module, \"severity\": e.severity}\n            for e in (state.hms_errors or [])\n        ],\n        # AMS data for filament colors\n        \"ams\": ams_units if ams_units else None,\n        \"vt_tray\": vt_tray,\n        # AMS status for filament change tracking\n        \"ams_status_main\": state.ams_status_main,\n        \"ams_status_sub\": state.ams_status_sub,\n        \"tray_now\": state.tray_now,\n        # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left\n        \"ams_extruder_map\": ams_extruder_map,\n        # WiFi signal strength\n        \"wifi_signal\": state.wifi_signal,\n        \"wired_network\": state.wired_network,\n        \"door_open\": state.door_open,\n        # Calibration stage tracking\n        \"stg_cur\": state.stg_cur,\n        \"stg_cur_name\": get_derived_status_name(state, model),\n        # Printable objects count for skip objects feature\n        \"printable_objects_count\": len(state.printable_objects),\n        # Fan speeds (0-100 percentage, None if not available)\n        \"cooling_fan_speed\": state.cooling_fan_speed,\n        \"big_fan1_speed\": state.big_fan1_speed,\n        \"big_fan2_speed\": state.big_fan2_speed,\n        \"heatbreak_fan_speed\": state.heatbreak_fan_speed,\n        # Chamber light state\n        \"chamber_light\": state.chamber_light,\n        # Active extruder for dual-nozzle printers (0=right, 1=left)\n        \"active_extruder\": state.active_extruder,\n        # Print speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)\n        \"speed_level\": state.speed_level,\n        # H2C nozzle rack (tool-changer dock positions)\n        # Map raw MQTT field names (type/diameter) to schema names (nozzle_type/nozzle_diameter)\n        \"nozzle_rack\": [\n            {\n                \"id\": n.get(\"id\", 0),\n                \"nozzle_type\": n.get(\"type\", \"\"),\n                \"nozzle_diameter\": n.get(\"diameter\", \"\"),\n                \"wear\": n.get(\"wear\"),\n                \"stat\": n.get(\"stat\"),\n                \"max_temp\": n.get(\"max_temp\", 0),\n                \"serial_number\": n.get(\"serial_number\", \"\"),\n                \"filament_color\": n.get(\"filament_color\", \"\"),\n                \"filament_id\": n.get(\"filament_id\", \"\"),\n            }\n            for n in (state.nozzle_rack or [])\n        ],\n        # AMS drying support\n        \"supports_drying\": supports_drying(model, state.firmware_version),\n        # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).\n        # Pushed via WebSocket so the printer card picks up plate transitions within\n        # a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).\n        # current_archive_id is intentionally REST-only — it's stable for the life\n        # of a print and needs a DB lookup the WebSocket path shouldn't pay for.\n        \"current_plate_id\": parse_plate_id(state.gcode_file),\n        # Plate-clear gate (#939). Lives on the PrinterManager rather than PrinterState,\n        # so surface it here — without this, WebSocket merges drop the flag and the\n        # \"Clear Plate\" button only appears when the 30 s REST fallback poll runs.\n        \"awaiting_plate_clear\": printer_manager.is_awaiting_plate_clear(printer_id) if printer_id else False,\n    }\n    # Add cover URL if there's an active print and printer_id is provided\n    # Include PAUSE state so skip objects modal can show cover\n    if printer_id and state.state in (\"RUNNING\", \"PAUSE\") and state.gcode_file:\n        result[\"cover_url\"] = f\"/api/v1/printers/{printer_id}/cover\"\n    else:\n        result[\"cover_url\"] = None\n    return result\n\n\n# Global printer manager instance\nprinter_manager = PrinterManager()\n\n\nasync def init_printer_connections(db: AsyncSession):\n    \"\"\"Initialize connections to all active printers.\"\"\"\n    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))\n    printers = result.scalars().all()\n\n    for printer in printers:\n        await printer_manager.connect_printer(printer)\n"
  },
  {
    "path": "backend/app/services/rest_smart_plug.py",
    "content": "\"\"\"Service for controlling smart plugs via generic REST/HTTP API.\"\"\"\n\nimport ipaddress\nimport json\nimport logging\nfrom typing import TYPE_CHECKING, Any\nfrom urllib.parse import urlparse\n\nimport httpx\n\nif TYPE_CHECKING:\n    from backend.app.models.smart_plug import SmartPlug\n\nlogger = logging.getLogger(__name__)\n\n\nclass RESTSmartPlugService:\n    \"\"\"Service for controlling smart plugs via generic REST/HTTP API.\n\n    Supports any home automation platform with an HTTP API (openHAB, ioBroker, FHEM, Node-RED, etc.).\n    \"\"\"\n\n    def __init__(self, timeout: float = 10.0):\n        self.timeout = timeout\n\n    @staticmethod\n    def _validate_url(url: str) -> bool:\n        \"\"\"Block cloud metadata and link-local IPs.\"\"\"\n        try:\n            parsed = urlparse(url)\n            hostname = parsed.hostname\n            if not hostname:\n                return False\n            addr = ipaddress.ip_address(hostname)\n            return not addr.is_loopback and not addr.is_link_local\n        except ValueError:\n            # Hostname is not an IP (e.g., \"openhab.local\") — allow it\n            return True\n\n    def _parse_headers(self, headers_json: str | None) -> dict[str, str]:\n        \"\"\"Parse JSON string to dict of headers.\"\"\"\n        if not headers_json:\n            return {}\n        try:\n            headers = json.loads(headers_json)\n            if isinstance(headers, dict):\n                return {str(k): str(v) for k, v in headers.items()}\n        except (json.JSONDecodeError, TypeError):\n            logger.warning(\"Failed to parse REST headers JSON: %s\", headers_json)\n        return {}\n\n    @staticmethod\n    def _extract_json_path(data: Any, path: str) -> Any:\n        \"\"\"Extract value using dot notation (e.g., 'state' or 'data.power.status').\"\"\"\n        if not path:\n            return None\n\n        parts = path.split(\".\")\n        current = data\n\n        for part in parts:\n            if isinstance(current, dict) and part in current:\n                current = current[part]\n            else:\n                return None\n\n        return current\n\n    async def _send_request(\n        self,\n        url: str,\n        method: str = \"POST\",\n        headers: dict[str, str] | None = None,\n        body: str | None = None,\n    ) -> httpx.Response | None:\n        \"\"\"Send an HTTP request and return the response.\"\"\"\n        if not self._validate_url(url):\n            logger.warning(\"Blocked REST request to invalid URL: %s\", url)\n            return None\n\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                kwargs: dict[str, Any] = {\"headers\": headers or {}}\n                if body is not None:\n                    # Try to detect if body is JSON\n                    try:\n                        json.loads(body)\n                        kwargs[\"content\"] = body\n                        if \"Content-Type\" not in (headers or {}):\n                            kwargs[\"headers\"][\"Content-Type\"] = \"application/json\"\n                    except (json.JSONDecodeError, TypeError):\n                        kwargs[\"content\"] = body\n\n                response = await client.request(method.upper(), url, **kwargs)\n                response.raise_for_status()\n                return response\n        except httpx.TimeoutException:\n            logger.warning(\"REST smart plug at %s timed out\", url)\n            return None\n        except httpx.HTTPStatusError as e:\n            logger.warning(\"REST smart plug at %s returned error: %s\", url, e)\n            return None\n        except httpx.RequestError as e:\n            logger.warning(\"Failed to connect to REST smart plug at %s: %s\", url, e)\n            return None\n        except Exception as e:\n            logger.error(\"Unexpected error communicating with REST smart plug at %s: %s\", url, e)\n            return None\n\n    async def turn_on(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Turn on the plug. Returns True if successful.\"\"\"\n        if not plug.rest_on_url:\n            logger.warning(\"No ON URL configured for REST plug '%s'\", plug.name)\n            return False\n\n        headers = self._parse_headers(plug.rest_headers)\n        method = plug.rest_method or \"POST\"\n        response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body)\n\n        if response is not None:\n            logger.info(\"Turned ON REST smart plug '%s' via %s %s\", plug.name, method, plug.rest_on_url)\n            return True\n\n        logger.warning(\"Failed to turn ON REST smart plug '%s'\", plug.name)\n        return False\n\n    async def turn_off(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Turn off the plug. Returns True if successful.\"\"\"\n        if not plug.rest_off_url:\n            logger.warning(\"No OFF URL configured for REST plug '%s'\", plug.name)\n            return False\n\n        headers = self._parse_headers(plug.rest_headers)\n        method = plug.rest_method or \"POST\"\n        response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body)\n\n        if response is not None:\n            logger.info(\"Turned OFF REST smart plug '%s' via %s %s\", plug.name, method, plug.rest_off_url)\n            return True\n\n        logger.warning(\"Failed to turn OFF REST smart plug '%s'\", plug.name)\n        return False\n\n    async def toggle(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Toggle the plug state by checking status first.\"\"\"\n        status = await self.get_status(plug)\n        if status[\"state\"] == \"ON\":\n            return await self.turn_off(plug)\n        else:\n            return await self.turn_on(plug)\n\n    async def get_status(self, plug: \"SmartPlug\") -> dict:\n        \"\"\"Get current power state.\n\n        Returns dict with:\n            - state: \"ON\" or \"OFF\" or None if unreachable\n            - reachable: bool\n            - device_name: None (REST plugs don't report device names)\n        \"\"\"\n        if not plug.rest_status_url:\n            return {\"state\": None, \"reachable\": True, \"device_name\": None}\n\n        headers = self._parse_headers(plug.rest_headers)\n        response = await self._send_request(plug.rest_status_url, \"GET\", headers)\n\n        if response is None:\n            return {\"state\": None, \"reachable\": False, \"device_name\": None}\n\n        # Try to extract state from response\n        state = None\n        try:\n            data = response.json()\n            if plug.rest_status_path:\n                raw_value = self._extract_json_path(data, plug.rest_status_path)\n                if raw_value is not None:\n                    on_value = (plug.rest_status_on_value or \"ON\").upper()\n                    state = \"ON\" if str(raw_value).upper() == on_value else \"OFF\"\n            else:\n                # No path configured — try common patterns\n                raw_value = str(data).upper() if not isinstance(data, dict) else None\n                if raw_value in (\"ON\", \"TRUE\", \"1\"):\n                    state = \"ON\"\n                elif raw_value in (\"OFF\", \"FALSE\", \"0\"):\n                    state = \"OFF\"\n        except Exception:\n            # Response is not JSON — try raw text\n            text = response.text.strip().upper()\n            on_value = (plug.rest_status_on_value or \"ON\").upper()\n            state = \"ON\" if text == on_value else \"OFF\"\n\n        return {\"state\": state, \"reachable\": True, \"device_name\": None}\n\n    async def get_energy(self, plug: \"SmartPlug\") -> dict | None:\n        \"\"\"Get energy monitoring data.\n\n        Each value (power, energy) can come from its own URL or fall back to the shared status URL.\n        Multipliers are applied to convert units (e.g., Wh → kWh with multiplier 0.001).\n\n        Returns dict with energy data or None if not available.\n        \"\"\"\n        if not plug.rest_power_path and not plug.rest_energy_path:\n            return None\n\n        headers = self._parse_headers(plug.rest_headers)\n        energy: dict[str, float | None] = {}\n\n        power_url = plug.rest_power_url or plug.rest_status_url if plug.rest_power_path else None\n        energy_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_path else None\n\n        # Fetch data — deduplicate when both resolve to the same URL\n        fetched: dict[str, Any] = {}\n\n        for url in {power_url, energy_url} - {None}:\n            fetched[url] = await self._fetch_json(url, headers)\n\n        # Extract power value\n        if plug.rest_power_path and power_url and fetched.get(power_url) is not None:\n            raw = self._extract_json_path(fetched[power_url], plug.rest_power_path)\n            if raw is not None:\n                try:\n                    energy[\"power\"] = float(raw) * (plug.rest_power_multiplier or 1.0)\n                except (ValueError, TypeError):\n                    pass\n\n        # Extract energy value\n        if plug.rest_energy_path and energy_url and fetched.get(energy_url) is not None:\n            raw = self._extract_json_path(fetched[energy_url], plug.rest_energy_path)\n            if raw is not None:\n                try:\n                    energy[\"today\"] = float(raw) * (plug.rest_energy_multiplier or 1.0)\n                except (ValueError, TypeError):\n                    pass\n\n        return energy if energy else None\n\n    async def _fetch_json(self, url: str, headers: dict[str, str]) -> Any:\n        \"\"\"Fetch a URL and parse JSON response. Returns parsed data or None.\"\"\"\n        response = await self._send_request(url, \"GET\", headers)\n        if response is None:\n            return None\n        try:\n            return response.json()\n        except Exception:\n            return None\n\n    async def test_connection(self, url: str, method: str = \"GET\", headers: str | None = None) -> dict:\n        \"\"\"Test connection to a REST endpoint.\n\n        Returns dict with:\n            - success: bool\n            - error: error message if failed\n        \"\"\"\n        if not self._validate_url(url):\n            return {\"success\": False, \"error\": \"Invalid URL (loopback/link-local addresses are blocked)\"}\n\n        parsed_headers = self._parse_headers(headers)\n\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                response = await client.request(method.upper(), url, headers=parsed_headers)\n                response.raise_for_status()\n                return {\"success\": True, \"error\": None}\n        except httpx.TimeoutException:\n            return {\"success\": False, \"error\": \"Connection timed out\"}\n        except httpx.HTTPStatusError as e:\n            return {\"success\": False, \"error\": f\"HTTP {e.response.status_code}: {e.response.reason_phrase}\"}\n        except httpx.RequestError as e:\n            return {\"success\": False, \"error\": f\"Connection failed: {e}\"}\n        except Exception as e:\n            return {\"success\": False, \"error\": str(e)}\n\n\n# Singleton instance\nrest_smart_plug_service = RESTSmartPlugService()\n"
  },
  {
    "path": "backend/app/services/smart_plug_manager.py",
    "content": "\"\"\"Manager for smart plug automation and delayed turn-off.\"\"\"\n\nimport asyncio\nimport logging\nfrom datetime import datetime, timezone\nfrom typing import TYPE_CHECKING\n\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.services.homeassistant import homeassistant_service\nfrom backend.app.services.printer_manager import printer_manager\nfrom backend.app.services.rest_smart_plug import rest_smart_plug_service\nfrom backend.app.services.tasmota import tasmota_service\n\nif TYPE_CHECKING:\n    from backend.app.models.smart_plug import SmartPlug\n\nlogger = logging.getLogger(__name__)\n\n\nclass SmartPlugManager:\n    \"\"\"Manages smart plug automation and delayed turn-off.\"\"\"\n\n    def __init__(self):\n        self._pending_off: dict[int, asyncio.Task] = {}  # plug_id -> task\n        self._loop: asyncio.AbstractEventLoop | None = None\n        self._scheduler_task: asyncio.Task | None = None\n        self._snapshot_task: asyncio.Task | None = None\n        self._last_schedule_check: dict[int, str] = {}  # plug_id -> \"HH:MM\" last executed\n\n    async def get_service_for_plug(self, plug: \"SmartPlug\", db: AsyncSession | None = None):\n        \"\"\"Get the appropriate service for the plug type.\n\n        For HA plugs, configures the service with current settings from DB.\n        \"\"\"\n        if plug.plug_type == \"homeassistant\":\n            # Configure HA service with current settings\n            await self._configure_ha_service(db)\n            return homeassistant_service\n        if plug.plug_type == \"rest\":\n            return rest_smart_plug_service\n        return tasmota_service\n\n    async def _configure_ha_service(self, db: AsyncSession | None = None):\n        \"\"\"Configure the HA service with URL and token from settings.\"\"\"\n        from backend.app.api.routes.settings import get_homeassistant_settings\n\n        try:\n            if db:\n                # Use provided session\n                ha_settings = await get_homeassistant_settings(db)\n            else:\n                # Create new session\n                from backend.app.core.database import async_session\n\n                async with async_session() as session:\n                    ha_settings = await get_homeassistant_settings(session)\n\n            homeassistant_service.configure(ha_settings[\"ha_url\"], ha_settings[\"ha_token\"])\n        except Exception as e:\n            logger.warning(\"Failed to configure HA service: %s\", e)\n\n    def set_event_loop(self, loop: asyncio.AbstractEventLoop):\n        \"\"\"Set the event loop for async operations.\"\"\"\n        self._loop = loop\n\n    def start_scheduler(self):\n        \"\"\"Start the background scheduler for time-based plug control.\"\"\"\n        if self._scheduler_task is None:\n            self._scheduler_task = asyncio.create_task(self._schedule_loop())\n            logger.info(\"Smart plug scheduler started\")\n        if self._snapshot_task is None:\n            self._snapshot_task = asyncio.create_task(self._snapshot_loop())\n            logger.info(\"Smart plug energy snapshot loop started\")\n\n    def stop_scheduler(self):\n        \"\"\"Stop the background scheduler.\"\"\"\n        if self._scheduler_task:\n            self._scheduler_task.cancel()\n            self._scheduler_task = None\n            logger.info(\"Smart plug scheduler stopped\")\n        if self._snapshot_task:\n            self._snapshot_task.cancel()\n            self._snapshot_task = None\n            logger.info(\"Smart plug energy snapshot loop stopped\")\n\n    async def _schedule_loop(self):\n        \"\"\"Background loop that checks scheduled on/off times every minute.\"\"\"\n        while True:\n            try:\n                await self._check_schedules()\n            except Exception as e:\n                logger.error(\"Error in schedule check: %s\", e)\n\n            # Wait until the next minute\n            await asyncio.sleep(60)\n\n    async def _snapshot_loop(self):\n        \"\"\"Background loop that captures each plug's lifetime energy counter hourly.\n\n        Powers date-range queries in \"total consumption\" energy mode (#941). Takes\n        a snapshot shortly after startup so the first bucket isn't empty, then\n        every hour.\n        \"\"\"\n        # Short warm-up delay so other services finish booting; still gives us\n        # an initial snapshot well before the first hour mark.\n        await asyncio.sleep(30)\n        while True:\n            try:\n                await self._capture_energy_snapshots()\n            except Exception as e:\n                logger.error(\"Error in energy snapshot capture: %s\", e)\n            await asyncio.sleep(3600)  # 1 hour\n\n    async def _capture_energy_snapshots(self):\n        \"\"\"Capture one energy snapshot row per plug with a usable lifetime counter.\"\"\"\n        from datetime import timezone\n\n        from backend.app.core.database import async_session\n        from backend.app.models.smart_plug import SmartPlug\n        from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot\n\n        async with async_session() as db:\n            plugs_result = await db.execute(select(SmartPlug).where(SmartPlug.enabled.is_(True)))\n            plugs = list(plugs_result.scalars().all())\n            if not plugs:\n                return\n\n            now = datetime.now(timezone.utc)\n            captured = 0\n            for plug in plugs:\n                # MQTT plugs only publish a \"today\" counter that resets at midnight —\n                # they can never feed cumulative snapshots, so skip them outright to\n                # avoid a noisy tasmota-service fallback attempt on an IP-less plug.\n                if plug.plug_type == \"mqtt\":\n                    continue\n                try:\n                    service = await self.get_service_for_plug(plug, db)\n                    energy = await service.get_energy(plug)\n                except Exception as e:\n                    logger.debug(\"Snapshot: failed to read energy from plug %s: %s\", plug.id, e)\n                    continue\n                if not energy:\n                    continue\n                lifetime = energy.get(\"total\")\n                if lifetime is None:\n                    # MQTT / REST plugs that only expose \"today\" can't be used for\n                    # cumulative snapshots — skip them.\n                    continue\n                db.add(\n                    SmartPlugEnergySnapshot(\n                        plug_id=plug.id,\n                        recorded_at=now,\n                        lifetime_kwh=float(lifetime),\n                    )\n                )\n                captured += 1\n\n            if captured:\n                await db.commit()\n                logger.info(\"Captured %d energy snapshot(s)\", captured)\n\n    async def _check_schedules(self):\n        \"\"\"Check all plugs for scheduled on/off times.\"\"\"\n        from backend.app.core.database import async_session\n        from backend.app.models.smart_plug import SmartPlug\n\n        current_time = datetime.now().strftime(\"%H:%M\")\n\n        async with async_session() as db:\n            result = await db.execute(\n                select(SmartPlug).where(\n                    SmartPlug.enabled.is_(True),\n                    SmartPlug.schedule_enabled.is_(True),\n                )\n            )\n            plugs = result.scalars().all()\n\n            for plug in plugs:\n                service = await self.get_service_for_plug(plug, db)\n\n                # Check if we should turn on\n                if plug.schedule_on_time == current_time:\n                    last_check = self._last_schedule_check.get(plug.id)\n                    if last_check != f\"on:{current_time}\":\n                        logger.info(\"Schedule: Turning on plug '%s' at %s\", plug.name, current_time)\n                        success = await service.turn_on(plug)\n                        if success:\n                            plug.last_state = \"ON\"\n                            plug.last_checked = datetime.now(timezone.utc)\n                            self._last_schedule_check[plug.id] = f\"on:{current_time}\"\n\n                # Check if we should turn off\n                if plug.schedule_off_time == current_time:\n                    last_check = self._last_schedule_check.get(plug.id)\n                    if last_check != f\"off:{current_time}\":\n                        logger.info(\"Schedule: Turning off plug '%s' at %s\", plug.name, current_time)\n                        success = await service.turn_off(plug)\n                        if success:\n                            plug.last_state = \"OFF\"\n                            plug.last_checked = datetime.now(timezone.utc)\n                            self._last_schedule_check[plug.id] = f\"off:{current_time}\"\n                            # Mark printer offline if linked\n                            if plug.printer_id:\n                                printer_manager.mark_printer_offline(plug.printer_id)\n\n            await db.commit()\n\n    async def _get_plugs_for_printer(self, printer_id: int, db: AsyncSession) -> list[\"SmartPlug\"]:\n        \"\"\"Get all smart plugs linked to a printer for automation control.\"\"\"\n        from backend.app.models.smart_plug import SmartPlug\n\n        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))\n        return list(result.scalars().all())\n\n    async def on_print_start(self, printer_id: int, db: AsyncSession):\n        \"\"\"Called when a print starts - turn on all plugs linked to this printer.\"\"\"\n        plugs = await self._get_plugs_for_printer(printer_id, db)\n\n        if not plugs:\n            return\n\n        for plug in plugs:\n            if not plug.enabled:\n                logger.debug(\"Smart plug '%s' is disabled, skipping auto-on\", plug.name)\n                continue\n\n            if not plug.auto_on:\n                logger.debug(\"Smart plug '%s' auto_on is disabled\", plug.name)\n                continue\n\n            # Cancel any pending off task\n            self._cancel_pending_off(plug.id)\n\n            # Turn on the plug\n            logger.info(\"Print started on printer %s, turning on plug '%s'\", printer_id, plug.name)\n            try:\n                service = await self.get_service_for_plug(plug, db)\n                success = await service.turn_on(plug)\n\n                if success:\n                    plug.last_state = \"ON\"\n                    plug.last_checked = datetime.now(timezone.utc)\n                    plug.auto_off_executed = False  # Reset flag when turning on\n            except Exception as e:\n                logger.warning(\"Failed to turn on plug '%s' for printer %s: %s\", plug.name, printer_id, e)\n\n        await db.commit()\n\n    async def on_print_complete(self, printer_id: int, status: str, db: AsyncSession):\n        \"\"\"Called when a print completes - schedule turn off for all plugs linked to this printer.\n\n        Only triggers auto-off on successful completion (status='completed').\n        Failed prints keep the printer powered on for user investigation.\n        \"\"\"\n        # Only auto-off on successful completion, not on failures\n        if status != \"completed\":\n            logger.info(\n                \"Print on printer %s ended with status '%s', skipping auto-off to allow investigation\",\n                printer_id,\n                status,\n            )\n            return\n\n        plugs = await self._get_plugs_for_printer(printer_id, db)\n\n        if not plugs:\n            return\n\n        for plug in plugs:\n            if not plug.enabled:\n                logger.debug(\"Smart plug '%s' is disabled, skipping auto-off\", plug.name)\n                continue\n\n            if not plug.auto_off:\n                logger.debug(\"Smart plug '%s' auto_off is disabled\", plug.name)\n                continue\n\n            # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)\n            if plug.plug_type == \"homeassistant\" and plug.ha_entity_id and plug.ha_entity_id.startswith(\"script.\"):\n                logger.debug(\"Smart plug '%s' is a HA script entity, skipping auto-off\", plug.name)\n                continue\n\n            logger.info(\n                \"Print completed successfully on printer %s, scheduling turn-off for plug '%s'\",\n                printer_id,\n                plug.name,\n            )\n\n            if plug.off_delay_mode == \"time\":\n                self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)\n            elif plug.off_delay_mode == \"temperature\":\n                self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)\n\n    def _schedule_delayed_off(self, plug: \"SmartPlug\", printer_id: int, delay_seconds: int):\n        \"\"\"Schedule turn-off after delay.\"\"\"\n        # Cancel any existing task for this plug\n        self._cancel_pending_off(plug.id)\n\n        logger.info(\"Scheduling turn-off for plug '%s' in %s seconds\", plug.name, delay_seconds)\n\n        # Mark as pending in database (survives restarts)\n        asyncio.create_task(self._mark_auto_off_pending(plug.id, True))\n\n        task = asyncio.create_task(\n            self._delayed_off(\n                plug.id,\n                plug.plug_type,\n                plug.ip_address,\n                plug.ha_entity_id,\n                plug.username,\n                plug.password,\n                printer_id,\n                delay_seconds,\n                rest_off_url=plug.rest_off_url if plug.plug_type == \"rest\" else None,\n                rest_off_body=plug.rest_off_body if plug.plug_type == \"rest\" else None,\n                rest_method=plug.rest_method if plug.plug_type == \"rest\" else None,\n                rest_headers=plug.rest_headers if plug.plug_type == \"rest\" else None,\n            )\n        )\n        self._pending_off[plug.id] = task\n\n    async def _delayed_off(\n        self,\n        plug_id: int,\n        plug_type: str,\n        ip_address: str | None,\n        ha_entity_id: str | None,\n        username: str | None,\n        password: str | None,\n        printer_id: int,\n        delay_seconds: int,\n        *,\n        rest_off_url: str | None = None,\n        rest_off_body: str | None = None,\n        rest_method: str | None = None,\n        rest_headers: str | None = None,\n    ):\n        \"\"\"Wait and turn off.\"\"\"\n        try:\n            await asyncio.sleep(delay_seconds)\n\n            # Create a minimal plug-like object for the service\n            class PlugInfo:\n                def __init__(self):\n                    self.plug_type = plug_type\n                    self.ip_address = ip_address\n                    self.ha_entity_id = ha_entity_id\n                    self.username = username\n                    self.password = password\n                    self.name = f\"plug_{plug_id}\"\n                    # REST fields\n                    self.rest_off_url = rest_off_url\n                    self.rest_off_body = rest_off_body\n                    self.rest_method = rest_method\n                    self.rest_headers = rest_headers\n\n            plug_info = PlugInfo()\n            service = await self.get_service_for_plug(plug_info)\n            success = await service.turn_off(plug_info)\n            logger.info(\"Turned off plug %s after time delay\", plug_id)\n\n            # Mark auto_off_executed in database and update printer status\n            if success:\n                await self._mark_auto_off_executed(plug_id)\n                # Mark the printer as offline immediately\n                printer_manager.mark_printer_offline(printer_id)\n\n        except asyncio.CancelledError:\n            logger.debug(\"Delayed turn-off cancelled for plug %s\", plug_id)\n        finally:\n            self._pending_off.pop(plug_id, None)\n\n    def _schedule_temp_based_off(self, plug: \"SmartPlug\", printer_id: int, temp_threshold: int):\n        \"\"\"Monitor temperature and turn off when below threshold.\"\"\"\n        # Cancel any existing task for this plug\n        self._cancel_pending_off(plug.id)\n\n        logger.info(\"Scheduling temperature-based turn-off for plug '%s' (threshold: %s°C)\", plug.name, temp_threshold)\n\n        # Mark as pending in database (survives restarts)\n        asyncio.create_task(self._mark_auto_off_pending(plug.id, True))\n\n        task = asyncio.create_task(\n            self._temp_based_off(\n                plug.id,\n                plug.plug_type,\n                plug.ip_address,\n                plug.ha_entity_id,\n                plug.username,\n                plug.password,\n                printer_id,\n                temp_threshold,\n                rest_off_url=plug.rest_off_url if plug.plug_type == \"rest\" else None,\n                rest_off_body=plug.rest_off_body if plug.plug_type == \"rest\" else None,\n                rest_method=plug.rest_method if plug.plug_type == \"rest\" else None,\n                rest_headers=plug.rest_headers if plug.plug_type == \"rest\" else None,\n            )\n        )\n        self._pending_off[plug.id] = task\n\n    async def _temp_based_off(\n        self,\n        plug_id: int,\n        plug_type: str,\n        ip_address: str | None,\n        ha_entity_id: str | None,\n        username: str | None,\n        password: str | None,\n        printer_id: int,\n        temp_threshold: int,\n        *,\n        rest_off_url: str | None = None,\n        rest_off_body: str | None = None,\n        rest_method: str | None = None,\n        rest_headers: str | None = None,\n    ):\n        \"\"\"Poll temperature until below threshold, then turn off.\n\n        For dual-extruder printers (H2 series), checks both nozzles.\n        \"\"\"\n        try:\n            check_interval = 10  # seconds\n            max_wait = 3600  # 1 hour max\n            elapsed = 0\n\n            while elapsed < max_wait:\n                status = printer_manager.get_status(printer_id)\n\n                if status:\n                    temps = status.temperatures or {}\n                    nozzle_temp = temps.get(\"nozzle\", 999)\n                    # Check second nozzle for dual-extruder printers (H2 series)\n                    nozzle_2_temp = temps.get(\"nozzle_2\")\n\n                    # Get the maximum temperature across all nozzles\n                    max_nozzle_temp = nozzle_temp\n                    if nozzle_2_temp is not None:\n                        max_nozzle_temp = max(nozzle_temp, nozzle_2_temp)\n                        logger.info(\n                            f\"Temp check plug {plug_id}: nozzle1={nozzle_temp}°C, \"\n                            f\"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, \"\n                            f\"threshold={temp_threshold}°C\"\n                        )\n                    else:\n                        logger.info(\n                            \"Temp check plug %s: nozzle=%s°C, threshold=%s°C\", plug_id, nozzle_temp, temp_threshold\n                        )\n\n                    if max_nozzle_temp < temp_threshold:\n                        # All nozzles are below threshold, turn off\n                        class PlugInfo:\n                            def __init__(self):\n                                self.plug_type = plug_type\n                                self.ip_address = ip_address\n                                self.ha_entity_id = ha_entity_id\n                                self.username = username\n                                self.password = password\n                                self.name = f\"plug_{plug_id}\"\n                                # REST fields\n                                self.rest_off_url = rest_off_url\n                                self.rest_off_body = rest_off_body\n                                self.rest_method = rest_method\n                                self.rest_headers = rest_headers\n\n                        plug_info = PlugInfo()\n                        service = await self.get_service_for_plug(plug_info)\n                        success = await service.turn_off(plug_info)\n                        logger.info(\n                            f\"Turned off plug {plug_id} after nozzle temp dropped to \"\n                            f\"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)\"\n                        )\n\n                        # Mark auto_off_executed in database and update printer status\n                        if success:\n                            await self._mark_auto_off_executed(plug_id)\n                            # Mark the printer as offline immediately\n                            printer_manager.mark_printer_offline(printer_id)\n\n                        break\n\n                await asyncio.sleep(check_interval)\n                elapsed += check_interval\n\n            if elapsed >= max_wait:\n                logger.warning(\"Temperature-based turn-off timed out for plug %s after %ss\", plug_id, max_wait)\n\n        except asyncio.CancelledError:\n            logger.debug(\"Temperature-based turn-off cancelled for plug %s\", plug_id)\n        finally:\n            self._pending_off.pop(plug_id, None)\n\n    async def _mark_auto_off_pending(self, plug_id: int, pending: bool):\n        \"\"\"Mark a plug as having a pending auto-off (survives restarts).\"\"\"\n        try:\n            from backend.app.core.database import async_session\n            from backend.app.models.smart_plug import SmartPlug\n\n            async with async_session() as db:\n                result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n                plug = result.scalar_one_or_none()\n                if plug:\n                    plug.auto_off_pending = pending\n                    plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None\n                    await db.commit()\n                    logger.debug(\"Marked plug %s auto_off_pending=%s\", plug_id, pending)\n        except Exception as e:\n            logger.warning(\"Failed to update plug %s pending state: %s\", plug_id, e)\n\n    async def _mark_auto_off_executed(self, plug_id: int):\n        \"\"\"Disable auto-off after it was executed (one-shot behavior unless persistent).\"\"\"\n        try:\n            from backend.app.core.database import async_session\n            from backend.app.models.smart_plug import SmartPlug\n\n            async with async_session() as db:\n                result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))\n                plug = result.scalar_one_or_none()\n                if plug:\n                    if not plug.auto_off_persistent:\n                        plug.auto_off = False  # Disable auto-off (one-shot behavior)\n                    plug.auto_off_executed = False  # Reset the flag\n                    plug.auto_off_pending = False  # Clear pending state\n                    plug.auto_off_pending_since = None\n                    plug.last_state = \"OFF\"\n                    plug.last_checked = datetime.now(timezone.utc)\n                    await db.commit()\n                    if plug.auto_off_persistent:\n                        logger.info(\"Auto-off executed for plug %s (persistent, stays enabled)\", plug_id)\n                    else:\n                        logger.info(\"Auto-off executed and disabled for plug %s\", plug_id)\n        except Exception as e:\n            logger.warning(\"Failed to update plug %s after auto-off: %s\", plug_id, e)\n\n    def _cancel_pending_off(self, plug_id: int):\n        \"\"\"Cancel any pending off task for this plug.\"\"\"\n        if plug_id in self._pending_off:\n            logger.debug(\"Cancelling pending turn-off for plug %s\", plug_id)\n            self._pending_off[plug_id].cancel()\n            del self._pending_off[plug_id]\n            # Clear pending state in database\n            asyncio.create_task(self._mark_auto_off_pending(plug_id, False))\n\n    def cancel_all_pending(self):\n        \"\"\"Cancel all pending turn-off tasks.\"\"\"\n        for plug_id in list(self._pending_off.keys()):\n            self._cancel_pending_off(plug_id)\n\n    async def resume_pending_auto_offs(self):\n        \"\"\"Resume any pending auto-offs that were interrupted by a restart.\n\n        Called on startup to check for plugs that had auto-off pending but\n        never completed (e.g., due to service restart).\n        \"\"\"\n        try:\n            from backend.app.core.database import async_session\n            from backend.app.models.smart_plug import SmartPlug\n\n            async with async_session() as db:\n                # Find all plugs with pending auto-off\n                result = await db.execute(\n                    select(SmartPlug).where(\n                        SmartPlug.auto_off_pending.is_(True),\n                        SmartPlug.printer_id.isnot(None),\n                    )\n                )\n                pending_plugs = result.scalars().all()\n\n                for plug in pending_plugs:\n                    # Check how long it's been pending (timeout after 2 hours)\n                    if plug.auto_off_pending_since:\n                        pending_since = plug.auto_off_pending_since\n                        if pending_since.tzinfo is None:\n                            pending_since = pending_since.replace(tzinfo=timezone.utc)\n                        elapsed = (datetime.now(timezone.utc) - pending_since).total_seconds()\n                        if elapsed > 7200:  # 2 hours\n                            logger.warning(\n                                f\"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, \"\n                                f\"clearing stale pending state\"\n                            )\n                            plug.auto_off_pending = False\n                            plug.auto_off_pending_since = None\n                            await db.commit()\n                            continue\n\n                    logger.info(\"Resuming pending auto-off for plug '%s' (printer %s)\", plug.name, plug.printer_id)\n\n                    # Resume the appropriate off mode\n                    if plug.off_delay_mode == \"temperature\":\n                        self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)\n                    else:\n                        # For time mode, just turn off immediately since delay already passed\n                        logger.info(\"Time-based auto-off was pending, turning off plug '%s' now\", plug.name)\n\n                        service = await self.get_service_for_plug(plug, db)\n                        success = await service.turn_off(plug)\n                        if success:\n                            await self._mark_auto_off_executed(plug.id)\n                            printer_manager.mark_printer_offline(plug.printer_id)\n\n                if pending_plugs:\n                    logger.info(\"Resumed %s pending auto-off(s)\", len(pending_plugs))\n\n        except Exception as e:\n            logger.warning(\"Failed to resume pending auto-offs: %s\", e)\n\n\n# Global singleton\nsmart_plug_manager = SmartPlugManager()\n"
  },
  {
    "path": "backend/app/services/spool_assignment_notifications.py",
    "content": "import logging\n\nfrom backend.app.core.database import async_session\nfrom backend.app.core.websocket import ws_manager\nfrom backend.app.models.printer import Printer\nfrom backend.app.models.spool_assignment import SpoolAssignment\nfrom backend.app.services.bambu_mqtt import PrinterState\nfrom backend.app.services.notification_service import notification_service\nfrom backend.app.services.printer_manager import printer_manager\n\n\ndef _global_tray_from_assignment(ams_id: int, tray_id: int) -> int:\n    \"\"\"Convert an assignment tuple to Bambuddy global tray ID.\"\"\"\n    if ams_id in (254, 255):\n        return 254 + tray_id\n    if ams_id >= 128:\n        return ams_id\n    return ams_id * 4 + tray_id\n\n\ndef _slot_label_from_global_tray(global_tray_id: int) -> str:\n    \"\"\"Return a human-readable slot label from a global tray ID.\"\"\"\n    if global_tray_id == 254:\n        return \"Ext-L\"\n    if global_tray_id == 255:\n        return \"Ext-R\"\n    if global_tray_id >= 128:\n        return f\"HT-{chr(65 + (global_tray_id - 128))}\"\n    ams_id = global_tray_id // 4\n    tray_id = global_tray_id % 4\n    return f\"{chr(65 + ams_id)}{tray_id + 1}\"\n\n\ndef _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tray_id: int) -> tuple[str, str]:\n    \"\"\"Resolve expected tray material/profile and color for a global tray ID from current printer state.\"\"\"\n    if not state or not state.raw_data:\n        return (\"Unknown\", \"Unknown\")\n\n    ams_raw = state.raw_data.get(\"ams\", {})\n    ams_units = ams_raw.get(\"ams\", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []\n\n    vt_trays = state.raw_data.get(\"vt_tray\", [])\n    if not isinstance(vt_trays, list):\n        vt_trays = []\n\n    for tray in vt_trays:\n        if not isinstance(tray, dict):\n            continue\n        if int(tray.get(\"id\", -1)) == global_tray_id:\n            profile = tray.get(\"tray_sub_brands\") or tray.get(\"tray_type\") or \"Unknown\"\n            color = tray.get(\"tray_color\") or \"Unknown\"\n            return (profile, color)\n\n    for ams in ams_units:\n        if not isinstance(ams, dict):\n            continue\n        ams_id = int(ams.get(\"id\", -1))\n        trays = ams.get(\"tray\", [])\n        if not isinstance(trays, list):\n            continue\n        for tray in trays:\n            if not isinstance(tray, dict):\n                continue\n            tray_id = int(tray.get(\"id\", -1))\n            candidate = ams_id if ams_id >= 128 else (ams_id * 4 + tray_id)\n            if candidate == global_tray_id:\n                profile = tray.get(\"tray_sub_brands\") or tray.get(\"tray_type\") or \"Unknown\"\n                color = tray.get(\"tray_color\") or \"Unknown\"\n                return (profile, color)\n\n    return (\"Unknown\", \"Unknown\")\n\n\ndef _decode_mqtt_mapping_to_global_trays(mapping_raw: object) -> list[int]:\n    \"\"\"Decode printer MQTT mapping values into Bambuddy global tray IDs.\"\"\"\n    if not isinstance(mapping_raw, list) or not mapping_raw:\n        return []\n\n    decoded: list[int] = []\n    for value in mapping_raw:\n        try:\n            if isinstance(value, int):\n                encoded = value\n            elif isinstance(value, str):\n                encoded = int(value, 10)\n            else:\n                continue\n        except ValueError:\n            continue\n\n        if encoded >= 65535:\n            continue\n\n        ams_hw_id = (encoded >> 8) & 0xFF\n        slot = encoded & 0xFF\n\n        if 0 <= ams_hw_id <= 3:\n            decoded.append(ams_hw_id * 4 + (slot & 0x03))\n        elif 128 <= ams_hw_id <= 135:\n            decoded.append(ams_hw_id)\n        elif ams_hw_id in (254, 255):\n            decoded.append(255 if slot == 255 else 254)\n\n    return decoded\n\n\nasync def notify_missing_spool_assignments_on_print_start(\n    printer_id: int,\n    data: dict,\n    logger: logging.Logger,\n) -> None:\n    \"\"\"Send notification when print-start mapping references unassigned trays.\"\"\"\n    explicit_mapping = data.get(\"ams_mapping\")\n    explicit_values = (\n        [value for value in explicit_mapping if isinstance(value, int)] if isinstance(explicit_mapping, list) else []\n    )\n    raw_mapping = data.get(\"raw_data\", {}).get(\"mapping\") if isinstance(data.get(\"raw_data\"), dict) else None\n    decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping)\n    mapping_values = explicit_values if explicit_values else decoded_values\n\n    used_global_trays = {value for value in mapping_values if value >= 0}\n    if not used_global_trays:\n        return\n\n    try:\n        async with async_session() as db:\n            printer = await db.get(Printer, printer_id)\n            printer_name = printer.name if printer else f\"Printer {printer_id}\"\n\n            assignments_result = await db.execute(\n                SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id)\n            )\n            assignments = assignments_result.fetchall()\n            assigned_global_trays = {\n                _global_tray_from_assignment(assignment.ams_id, assignment.tray_id) for assignment in assignments\n            }\n\n            missing_global = sorted(used_global_trays - assigned_global_trays)\n            if not missing_global:\n                return\n\n            state = printer_manager.get_status(printer_id)\n            missing_slots = []\n            for global_id in missing_global:\n                profile, color = _tray_profile_and_color_for_global_id(state, global_id)\n                missing_slots.append(\n                    {\n                        \"slot\": _slot_label_from_global_tray(global_id),\n                        \"profile\": profile,\n                        \"color\": color,\n                    }\n                )\n\n            await ws_manager.send_missing_spool_assignment(\n                printer_id=printer_id,\n                printer_name=printer_name,\n                missing_slots=missing_slots,\n            )\n\n            await notification_service.on_print_missing_spool_assignment(\n                printer_id=printer_id,\n                printer_name=printer_name,\n                missing_slots=missing_slots,\n                db=db,\n            )\n    except Exception as e:\n        logger.warning(\"Missing spool-assignment notification failed: %s\", e)\n"
  },
  {
    "path": "backend/app/services/spool_tag_matcher.py",
    "content": "\"\"\"RFID tag matching and auto-assignment for spool inventory.\"\"\"\n\nimport logging\n\nfrom sqlalchemy import func, or_, select\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.orm import selectinload\n\nfrom backend.app.models.spool import Spool\nfrom backend.app.models.spool_assignment import SpoolAssignment\nfrom backend.app.utils.tag_normalization import (\n    normalize_tag_uid as _normalize_tag_uid,\n    normalize_tray_uuid as _normalize_tray_uuid,\n)\n\nlogger = logging.getLogger(__name__)\n\n# Zero-value constants for tag validation\nZERO_TAG_UID = \"0000000000000000\"\nZERO_TRAY_UUID = \"00000000000000000000000000000000\"\n\n\ndef is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:\n    \"\"\"Check if a tag/UUID pair contains a non-zero, non-empty value.\"\"\"\n    uid = _normalize_tag_uid(tag_uid)\n    uuid = _normalize_tray_uuid(tray_uuid)\n    uid_valid = bool(uid) and uid != ZERO_TAG_UID and uid != \"0\" * len(uid)\n    uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != \"0\" * len(uuid)\n    return uid_valid or uuid_valid\n\n\ndef is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:\n    \"\"\"Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset).\"\"\"\n    uuid = _normalize_tray_uuid(tray_uuid)\n    uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != \"0\" * len(uuid)\n    has_preset = bool(tray_info_idx)\n    return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)\n\n\nasync def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:\n    \"\"\"Create a new Spool inventory entry from AMS tray MQTT data.\n\n    Extracts material, subtype, color, temps, and tag info from the tray dict.\n    Looks up core_weight from the spool catalog if a Bambu Lab entry matches.\n    \"\"\"\n    from backend.app.models.color_catalog import ColorCatalogEntry\n    from backend.app.models.spool_catalog import SpoolCatalogEntry\n\n    tray_type = tray_data.get(\"tray_type\", \"\")  # \"PLA\"\n    tray_sub_brands = tray_data.get(\"tray_sub_brands\", \"\")  # \"PLA Basic\"\n    tray_color = tray_data.get(\"tray_color\", \"FFFFFFFF\")  # RRGGBBAA\n    tray_id_name = tray_data.get(\"tray_id_name\", \"\")  # Color name e.g. \"Jade White\"\n    tag_uid = _normalize_tag_uid(tray_data.get(\"tag_uid\", \"\"))\n    tray_uuid = _normalize_tray_uuid(tray_data.get(\"tray_uuid\", \"\"))\n    tray_info_idx = tray_data.get(\"tray_info_idx\", \"\")\n    nozzle_min = tray_data.get(\"nozzle_temp_min\", 0)\n    nozzle_max = tray_data.get(\"nozzle_temp_max\", 0)\n    label_weight = int(tray_data.get(\"tray_weight\", 1000))\n\n    # Parse material and subtype from tray_sub_brands (\"PLA Basic\" → material=\"PLA\", subtype=\"Basic\")\n    material = tray_type or \"PLA\"\n    subtype = None\n    if tray_sub_brands and \" \" in tray_sub_brands:\n        parts = tray_sub_brands.split(\" \", 1)\n        if parts[0].upper() == material.upper():\n            subtype = parts[1]\n        else:\n            # tray_sub_brands is the full material name (e.g. \"PETG-HF\")\n            material = tray_sub_brands\n    elif tray_sub_brands and tray_sub_brands.upper() != material.upper():\n        material = tray_sub_brands\n\n    # Upgrade subtype for gradient/multi-color variants based on tray_id_name color code.\n    # Firmware sends tray_sub_brands=\"PLA Basic\" for gradients and \"PLA Silk\" for dual/tri-color,\n    # but the M*/T* suffix in tray_id_name distinguishes them:\n    #   A00-M* = PLA Basic Gradient, A05-M* = PLA Silk Dual Color, A05-T* = PLA Silk Tri Color\n    if tray_id_name and \"-\" in tray_id_name:\n        color_code = tray_id_name.split(\"-\", 1)[1]\n        if color_code and color_code[0] == \"M\":\n            # M* = gradient for PLA Basic (A00), dual-color for PLA Silk (A05)\n            prefix = tray_id_name.split(\"-\", 1)[0]\n            if prefix == \"A05\":\n                subtype = \"Dual Color\"\n            else:\n                subtype = \"Gradient\"\n        elif color_code and color_code[0] == \"T\":\n            subtype = \"Tri Color\"\n\n    # Resolve color name from the color catalog by hex. The catalog is the single\n    # source of truth — tray_id_name codes (e.g. \"A17-R1\") are NOT globally unique\n    # across material families (A17-R1 is PLA Translucent Cherry Pink; A01-R1 is\n    # PLA Matte Scarlet Red), so a suffix-based fallback would pick the wrong name.\n    # See #857.\n    rgba = tray_color if tray_color else None\n    color_name = None\n\n    if rgba and len(rgba) >= 6:\n        hex_prefix = f\"#{rgba[:6].upper()}\"\n        cat_result = await db.execute(\n            select(ColorCatalogEntry)\n            .where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix)\n            .where(func.upper(ColorCatalogEntry.manufacturer) == \"BAMBU LAB\")\n            .limit(1)\n        )\n        entry = cat_result.scalar_one_or_none()\n        if entry:\n            color_name = entry.color_name\n\n    # If tray_id_name is a human-readable name (no \"-\" code), fall back to it.\n    if not color_name and tray_id_name and \"-\" not in tray_id_name:\n        color_name = tray_id_name\n\n    logger.info(\n        \"Color resolve: tray_id_name=%r rgba=%r → resolved=%r\",\n        tray_id_name,\n        rgba,\n        color_name,\n    )\n\n    # Look up core weight from spool catalog\n    core_weight = 250  # Default for Bambu Lab plastic spools\n    cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike(\"Bambu Lab%\")).limit(10))\n    for entry in cat_result.scalars().all():\n        # Pick the best match (prefer exact, fallback to first Bambu Lab entry)\n        core_weight = entry.weight\n        break\n\n    # Resolve slicer filament name from builtin table\n    slicer_filament_name = None\n    if tray_info_idx:\n        try:\n            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES\n\n            slicer_filament_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)\n        except Exception:\n            pass\n        # Fallback: use tray_sub_brands as the display name\n        if not slicer_filament_name and tray_sub_brands:\n            slicer_filament_name = tray_sub_brands\n\n    # Calculate initial weight_used from AMS remain percentage\n    remain_raw = tray_data.get(\"remain\")\n    try:\n        remain_pct = int(remain_raw) if remain_raw is not None else 100\n    except (TypeError, ValueError):\n        remain_pct = 100\n    # Clamp to valid range: negative means unknown, >100 is invalid\n    if remain_pct < 0 or remain_pct > 100:\n        remain_pct = 100  # Unknown → assume full\n    weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1)\n\n    spool = Spool(\n        material=material,\n        subtype=subtype,\n        color_name=color_name,\n        rgba=rgba,\n        brand=\"Bambu Lab\",\n        label_weight=label_weight,\n        core_weight=core_weight,\n        weight_used=weight_used,\n        slicer_filament=tray_info_idx or None,\n        slicer_filament_name=slicer_filament_name,\n        nozzle_temp_min=int(nozzle_min) if nozzle_min else None,\n        nozzle_temp_max=int(nozzle_max) if nozzle_max else None,\n        tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None,\n        tray_uuid=tray_uuid if tray_uuid and tray_uuid != ZERO_TRAY_UUID else None,\n        data_origin=\"rfid_auto\",\n        tag_type=\"bambulab\",\n    )\n    # Initialize relationships BEFORE db.add() to prevent lazy loads.\n    # Setting them after flush() would trigger a lazy load because SQLAlchemy\n    # loads the current collection before replacing it on a persistent object.\n    # They must also be set before add() because cascade processing during\n    # add/flush accesses these collections, and back_populates resolution\n    # when creating SpoolAssignment runs synchronously outside the greenlet.\n    spool.k_profiles = []\n    spool.assignments = []\n    db.add(spool)\n    await db.flush()\n\n    logger.info(\n        \"Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)\",\n        spool.id,\n        material,\n        subtype or \"\",\n        color_name or \"\",\n        tag_uid,\n        tray_uuid,\n    )\n    return spool\n\n\nasync def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spool | None:\n    \"\"\"Find an existing untagged inventory spool matching brand/material/color.\n\n    When a Bambu Lab spool is detected in the AMS but no tag match exists,\n    check if the user has a manually-added spool with the same properties\n    that hasn't been linked to a tag yet. Returns the oldest match (FIFO).\n    \"\"\"\n    tray_type = tray_data.get(\"tray_type\", \"\")\n    tray_sub_brands = tray_data.get(\"tray_sub_brands\", \"\")\n    tray_color = tray_data.get(\"tray_color\", \"\")  # RRGGBBAA\n\n    if not tray_type or not tray_color:\n        return None\n\n    # Parse material the same way create_spool_from_tray does\n    material = tray_type\n    subtype = None\n    if tray_sub_brands and \" \" in tray_sub_brands:\n        parts = tray_sub_brands.split(\" \", 1)\n        if parts[0].upper() == material.upper():\n            subtype = parts[1]\n        else:\n            material = tray_sub_brands\n    elif tray_sub_brands and tray_sub_brands.upper() != material.upper():\n        material = tray_sub_brands\n\n    # Upgrade subtype for gradient/multi-color variants (same logic as create_spool_from_tray)\n    tray_id_name = tray_data.get(\"tray_id_name\", \"\")\n    if tray_id_name and \"-\" in tray_id_name:\n        color_code = tray_id_name.split(\"-\", 1)[1]\n        if color_code and color_code[0] == \"M\":\n            prefix = tray_id_name.split(\"-\", 1)[0]\n            if prefix == \"A05\":\n                subtype = \"Dual Color\"\n            else:\n                subtype = \"Gradient\"\n        elif color_code and color_code[0] == \"T\":\n            subtype = \"Tri Color\"\n\n    # Build query: active spools with no tag, matching brand + material + color\n    query = (\n        select(Spool)\n        .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))\n        .where(\n            Spool.archived_at.is_(None),\n            Spool.tag_uid.is_(None),\n            Spool.tray_uuid.is_(None),\n            func.upper(Spool.material) == material.upper(),\n            func.upper(Spool.rgba) == tray_color.upper(),\n        )\n    )\n\n    # Match subtype if parsed (e.g. \"Basic\", \"Matte\")\n    if subtype:\n        query = query.where(func.upper(Spool.subtype) == subtype.upper())\n    else:\n        query = query.where(Spool.subtype.is_(None))\n\n    # FIFO: oldest spool first (user likely added in purchase order)\n    query = query.order_by(Spool.created_at.asc()).limit(1)\n\n    result = await db.execute(query)\n    spool = result.scalar_one_or_none()\n\n    if spool:\n        logger.info(\n            \"Found matching untagged spool %d: %s %s %s (rgba=%s)\",\n            spool.id,\n            spool.brand or \"\",\n            spool.material,\n            spool.color_name or \"\",\n            spool.rgba or \"\",\n        )\n\n    return spool\n\n\nasync def link_tag_to_inventory_spool(db: AsyncSession, spool: Spool, tray_data: dict) -> None:\n    \"\"\"Link RFID tag data from AMS tray to an existing inventory spool.\"\"\"\n    tag_uid = tray_data.get(\"tag_uid\", \"\")\n    tray_uuid = tray_data.get(\"tray_uuid\", \"\")\n    tray_info_idx = tray_data.get(\"tray_info_idx\", \"\")\n\n    if tag_uid and tag_uid != ZERO_TAG_UID:\n        spool.tag_uid = tag_uid\n    if tray_uuid and tray_uuid != ZERO_TRAY_UUID:\n        spool.tray_uuid = tray_uuid\n    spool.data_origin = \"rfid_linked\"\n    spool.tag_type = \"bambulab\"\n\n    # Update slicer preset if not already set\n    if tray_info_idx and not spool.slicer_filament:\n        spool.slicer_filament = tray_info_idx\n        try:\n            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES\n\n            name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)\n            if name and not spool.slicer_filament_name:\n                spool.slicer_filament_name = name\n        except Exception:\n            pass\n\n    await db.flush()\n    logger.info(\n        \"Linked RFID tag to existing spool %d (tag=%s uuid=%s origin=rfid_linked)\",\n        spool.id,\n        spool.tag_uid or \"\",\n        spool.tray_uuid or \"\",\n    )\n\n\nasync def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:\n    \"\"\"Look up an active spool by RFID tag UID or Bambu Lab tray UUID.\n\n    Prefers tray_uuid match over tag_uid (more reliable).\n    \"\"\"\n    tray_uuid_norm = _normalize_tray_uuid(tray_uuid)\n    tag_uid_norm = _normalize_tag_uid(tag_uid)\n\n    # Try tray_uuid first (Bambu Lab spools — more reliable)\n    if tray_uuid_norm and tray_uuid_norm != ZERO_TRAY_UUID and tray_uuid_norm != \"0\" * len(tray_uuid_norm):\n        result = await db.execute(\n            select(Spool)\n            .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))\n            .where(func.upper(Spool.tray_uuid) == tray_uuid_norm, Spool.archived_at.is_(None))\n            .limit(1)\n        )\n        spool = result.scalar_one_or_none()\n        if spool:\n            return spool\n\n    # Fall back to tag_uid\n    if tag_uid_norm and tag_uid_norm != ZERO_TAG_UID and tag_uid_norm != \"0\" * len(tag_uid_norm):\n        result = await db.execute(\n            select(Spool)\n            .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))\n            .where(func.upper(Spool.tag_uid) == tag_uid_norm, Spool.archived_at.is_(None))\n            .limit(1)\n        )\n        spool = result.scalar_one_or_none()\n        if spool:\n            return spool\n\n        # Compatibility fallback: some readers report 4-byte UID (8 hex) while\n        # stored values may contain longer forms. Prefer suffix match only.\n        if len(tag_uid_norm) >= 8:\n            suffix8 = tag_uid_norm[-8:]\n            short_uid_body = tag_uid_norm[1:] if len(tag_uid_norm) == 8 else \"\"\n\n            # Build LIKE patterns for candidates search\n            like_patterns = [\n                func.upper(Spool.tag_uid).like(f\"%{tag_uid_norm}\"),\n                func.upper(Spool.tag_uid).like(f\"%{suffix8}\"),\n            ]\n            if short_uid_body:\n                like_patterns.append(func.upper(Spool.tag_uid).like(f\"%{short_uid_body}%\"))\n\n            candidates = await db.execute(\n                select(Spool)\n                .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))\n                .where(\n                    Spool.tag_uid.is_not(None),\n                    Spool.archived_at.is_(None),\n                    or_(*like_patterns),\n                )\n                .limit(100)\n            )\n            for candidate in candidates.scalars().all():\n                candidate_uid = _normalize_tag_uid(candidate.tag_uid)\n                if not candidate_uid:\n                    continue\n                if candidate_uid == tag_uid_norm:\n                    return candidate\n                if len(candidate_uid) > len(tag_uid_norm) and candidate_uid.endswith(tag_uid_norm):\n                    return candidate\n                if len(tag_uid_norm) > len(candidate_uid) and tag_uid_norm.endswith(candidate_uid):\n                    return candidate\n                # Backward-compatible matching: allow first-character mismatch\n                # when remaining characters match. This handles cases where the same\n                # physical tag reports different first bytes across different readers\n                # (e.g., one reader reports \"A45012F\", another reports \"B45012F\").\n                if len(tag_uid_norm) == len(candidate_uid) and len(tag_uid_norm) > 1:\n                    # Same length: check if all chars except the first match\n                    if candidate_uid[1:] == tag_uid_norm[1:]:\n                        logger.warning(\n                            \"Matched spool %d via first-char variance: stored=%s → scanned=%s\",\n                            candidate.id,\n                            candidate_uid,\n                            tag_uid_norm,\n                        )\n                        return candidate\n                # Short UID (8 chars) matching: allow first-character mismatch\n                # within the first 8 bytes when remaining 7 chars match.\n                if len(tag_uid_norm) == 8 and len(candidate_uid) >= 8:\n                    if candidate_uid[:8][1:] == tag_uid_norm[1:]:\n                        logger.warning(\n                            \"Matched spool %d via short UID variance: stored=%s → scanned=%s\",\n                            candidate.id,\n                            candidate_uid,\n                            tag_uid_norm,\n                        )\n                        return candidate\n\n    return None\n\n\nasync def auto_assign_spool(\n    printer_id: int,\n    ams_id: int,\n    tray_id: int,\n    spool: Spool,\n    printer_manager,\n    db: AsyncSession,\n    tray_info_idx: str = \"\",\n) -> SpoolAssignment:\n    \"\"\"Create a SpoolAssignment and auto-configure the AMS slot via MQTT.\n\n    For BL spools (RFID-detected), only K-profile commands are sent.\n    ams_set_filament_setting is NOT sent because the firmware already has\n    filament configuration from the RFID tag, and sending it would destroy\n    the RFID-detected state (eye → pen icon in BambuStudio).\n    \"\"\"\n    # Get current tray state for fingerprint\n    fingerprint_color = None\n    fingerprint_type = None\n    tray = None\n    state = printer_manager.get_status(printer_id)\n    if state and state.raw_data:\n        from backend.app.api.routes.inventory import _find_tray_in_ams_data\n\n        ams = state.raw_data.get(\"ams\", [])\n        if isinstance(ams, dict):\n            ams = ams.get(\"ams\", [])\n        tray = _find_tray_in_ams_data(\n            ams,\n            ams_id,\n            tray_id,\n        )\n        if tray:\n            fingerprint_color = tray.get(\"tray_color\", \"\")\n            fingerprint_type = tray.get(\"tray_type\", \"\")\n\n    # Upsert: remove old assignment for this slot\n    existing = await db.execute(\n        select(SpoolAssignment).where(\n            SpoolAssignment.printer_id == printer_id,\n            SpoolAssignment.ams_id == ams_id,\n            SpoolAssignment.tray_id == tray_id,\n        )\n    )\n    old = existing.scalar_one_or_none()\n    if old:\n        await db.delete(old)\n        await db.flush()\n\n    assignment = SpoolAssignment(\n        spool_id=spool.id,\n        printer_id=printer_id,\n        ams_id=ams_id,\n        tray_id=tray_id,\n        fingerprint_color=fingerprint_color,\n        fingerprint_type=fingerprint_type,\n    )\n    db.add(assignment)\n    await db.flush()\n\n    # Apply K-profile via MQTT (if available)\n    # NOTE: Do NOT send ams_set_filament_setting here. This function is only\n    # called for BL spools (RFID-detected). The firmware already has the filament\n    # configuration from the RFID tag. Sending ams_set_filament_setting would\n    # destroy the RFID-detected state (eye → pen icon in BambuStudio/OrcaSlicer).\n    try:\n        client = printer_manager.get_client(printer_id)\n        if client:\n            # Apply K-profile if available\n            nozzle_diameter = \"0.4\"\n            if state and state.nozzles:\n                nd = state.nozzles[0].nozzle_diameter\n                if nd:\n                    nozzle_diameter = nd\n\n            matching_kp = None\n            for kp in spool.k_profiles:\n                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:\n                    matching_kp = kp\n                    break\n\n            if matching_kp and matching_kp.cali_idx is not None:\n                # The filament_id in extrusion_cali_sel must match the filament preset\n                # under which the K-profile was calibrated. Use spool.slicer_filament\n                # (the preset assigned in inventory), falling back to tray's RFID value.\n                cali_filament_id = spool.slicer_filament or tray_info_idx or \"\"\n                client.extrusion_cali_sel(\n                    ams_id=ams_id,\n                    tray_id=tray_id,\n                    cali_idx=matching_kp.cali_idx,\n                    filament_id=cali_filament_id,\n                    nozzle_diameter=nozzle_diameter,\n                )\n\n                # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already\n                # selected the correct profile by cali_idx. Sending extrusion_cali_set\n                # with the same cali_idx would MODIFY the existing profile's metadata\n                # (extruder_id, nozzle_id, name), corrupting it.\n\n                logger.info(\n                    \"Applied K-profile cali_idx=%d for spool %d on printer %d AMS%d-T%d\",\n                    matching_kp.cali_idx,\n                    spool.id,\n                    printer_id,\n                    ams_id,\n                    tray_id,\n                )\n\n            logger.info(\n                \"Auto-assigned spool %d to printer %d AMS%d-T%d (RFID match)\",\n                spool.id,\n                printer_id,\n                ams_id,\n                tray_id,\n            )\n    except Exception as e:\n        logger.warning(\"K-profile apply failed for spool %d (RFID match): %s\", spool.id, e)\n\n    return assignment\n"
  },
  {
    "path": "backend/app/services/spoolbuddy_ssh.py",
    "content": "\"\"\"SSH-based update service for SpoolBuddy devices.\n\nInstead of the daemon updating itself (fragile: permission issues, self-modifying\ncode, hardcoded branch), Bambuddy SSHes into the SpoolBuddy Pi and drives the\nupdate remotely: git fetch/checkout, pip install, systemctl restart.\n\nUses `asyncssh` (pure-Python async SSH client) rather than shelling out to the\nOpenSSH `ssh` binary. The subprocess approach fails in Docker: both `ssh` and\n`ssh-keygen` call `getpwuid(getuid())` during startup and abort with\n\"No user exists for uid <N>\" when the container runs under a UID that is not\nlisted in /etc/passwd (e.g. PUID=1000 on python:3.13-slim, which only has\nentries for root). asyncssh does all of its work in-process.\n\"\"\"\n\nimport asyncio\nimport logging\nimport os\nfrom pathlib import Path\n\nimport asyncssh\nfrom cryptography.hazmat.primitives import serialization\nfrom cryptography.hazmat.primitives.asymmetric import ed25519\n\nfrom backend.app.core.config import settings\n\nlogger = logging.getLogger(__name__)\n\nSSH_USER = \"spoolbuddy\"\nDEFAULT_INSTALL_PATH = \"/opt/bambuddy\"\n\n# Project root — where the `.git` directory lives for native installs and for\n# Docker containers that bind-mount the repo. This is intentionally distinct\n# from `settings.base_dir`, which points at the persistent *data* directory\n# (e.g. `DATA_DIR=/app/data` in Docker) and therefore never contains `.git`.\n# `backend/app/services/spoolbuddy_ssh.py` → parents[3] = project root.\n_APP_DIR = Path(__file__).resolve().parents[3]\n\n# Note for Docker: asyncssh.connect() internally calls getpass.getuser() to\n# resolve the *local* username for ~/.ssh/config host matching. Under an\n# arbitrary PUID with no /etc/passwd entry this would raise OSError. The\n# Dockerfile sets LOGNAME/USER/HOME so getpass.getuser() succeeds via env-var\n# lookup before ever touching the passwd database.\n\n\ndef _get_ssh_key_dir() -> Path:\n    \"\"\"Return (and create if needed) the directory for SpoolBuddy SSH keys.\"\"\"\n    key_dir = settings.base_dir / \"spoolbuddy\" / \"ssh\"\n    if not key_dir.exists():\n        key_dir.mkdir(mode=0o700, parents=True)\n    return key_dir\n\n\nasync def get_or_create_keypair() -> tuple[Path, Path]:\n    \"\"\"Return (private_key_path, public_key_path), generating if missing.\n\n    Uses the in-process `cryptography` library instead of shelling out to\n    `ssh-keygen`. The subprocess approach fails inside Docker containers when\n    the image runs under an arbitrary UID (e.g. PUID=1001) that is not listed\n    in /etc/passwd — `ssh-keygen` calls `getpwuid()` for the current user's\n    home directory and aborts with \"no user exists for uid <N>\".\n    \"\"\"\n    key_dir = _get_ssh_key_dir()\n    private_key = key_dir / \"id_ed25519\"\n    public_key = key_dir / \"id_ed25519.pub\"\n\n    if private_key.exists() and public_key.exists():\n        return private_key, public_key\n\n    logger.info(\"Generating SSH keypair for SpoolBuddy updates\")\n    priv_obj = ed25519.Ed25519PrivateKey.generate()\n    pub_obj = priv_obj.public_key()\n\n    private_bytes = priv_obj.private_bytes(\n        encoding=serialization.Encoding.PEM,\n        format=serialization.PrivateFormat.OpenSSH,\n        encryption_algorithm=serialization.NoEncryption(),\n    )\n    public_bytes = pub_obj.public_bytes(\n        encoding=serialization.Encoding.OpenSSH,\n        format=serialization.PublicFormat.OpenSSH,\n    )\n    # OpenSSH public format has no comment field by default; append one to match\n    # the previous ssh-keygen output so the authorized_keys line is identifiable.\n    public_line = public_bytes + b\" bambuddy-spoolbuddy\\n\"\n\n    private_key.write_bytes(private_bytes)\n    private_key.chmod(0o600)\n    public_key.write_bytes(public_line)\n\n    logger.info(\"SSH keypair generated at %s\", key_dir)\n    return private_key, public_key\n\n\nasync def get_public_key() -> str:\n    \"\"\"Return the SSH public key content for pairing.\"\"\"\n    _, public_key = await get_or_create_keypair()\n    return public_key.read_text().strip()\n\n\ndef detect_current_branch() -> str:\n    \"\"\"Detect the git branch Bambuddy is running on.\n\n    Reads `.git/HEAD` directly from the application root (``_APP_DIR``) rather\n    than shelling out to `git`. The application root is deliberately distinct\n    from ``settings.base_dir``: in Docker, ``base_dir`` points at the data\n    volume (``/app/data``) which never contains ``.git``, while the repo is\n    bind-mounted (or COPYd) to ``/app``. This works for native installs,\n    bare Docker containers (no ``.git`` — fall through to the env var), and\n    Docker containers that bind-mount the repo (``.git`` is present, no\n    ``git`` binary required, and no ``getpwuid()`` call that could fail under\n    an arbitrary PUID).\n\n    Fallback order: ``.git/HEAD`` → ``GIT_BRANCH`` env var → ``\"main\"``.\n    \"\"\"\n    git_path = _APP_DIR / \".git\"\n    try:\n        if git_path.exists():\n            # Git worktrees use a file containing `gitdir: <path>` instead of\n            # a directory — follow the pointer.\n            if git_path.is_file():\n                content = git_path.read_text(encoding=\"utf-8\").strip()\n                if content.startswith(\"gitdir:\"):\n                    git_path = (_APP_DIR / content.removeprefix(\"gitdir:\").strip()).resolve()\n\n            head_file = git_path / \"HEAD\"\n            if head_file.is_file():\n                head = head_file.read_text(encoding=\"utf-8\").strip()\n                # Normal case: `ref: refs/heads/<branch>`.\n                # Detached HEAD stores a raw commit hash — fall through to env var.\n                if head.startswith(\"ref: refs/heads/\"):\n                    return head.removeprefix(\"ref: refs/heads/\").strip()\n    except OSError as exc:\n        logger.debug(\"Could not read .git/HEAD, falling back: %s\", exc)\n\n    return os.environ.get(\"GIT_BRANCH\", \"main\")\n\n\nasync def _run_ssh_command(\n    ip: str,\n    command: str,\n    private_key: Path,\n    timeout: int = 60,\n) -> tuple[int, str, str]:\n    \"\"\"Execute a command on a SpoolBuddy device via SSH.\n\n    Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring\n    for the Docker/PUID rationale.\n\n    Returns (returncode, stdout, stderr). On connection failure the return\n    code is 255 (matching `ssh`'s own convention) and stderr carries the\n    asyncssh error message. On timeout the return code is -1.\n    \"\"\"\n    try:\n        async with asyncio.timeout(timeout):\n            async with asyncssh.connect(\n                host=ip,\n                username=SSH_USER,\n                client_keys=[str(private_key)],\n                known_hosts=None,  # equivalent to StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null\n                config=[],  # do not load ~/.ssh/config — HOME may not resolve under arbitrary Docker PUIDs\n                connect_timeout=10,\n            ) as conn:\n                result = await conn.run(command, check=False)\n    except TimeoutError:\n        return -1, \"\", \"SSH command timed out\"\n    except (asyncssh.Error, OSError) as exc:\n        return 255, \"\", str(exc)\n\n    stdout = result.stdout if isinstance(result.stdout, str) else (result.stdout or b\"\").decode(errors=\"replace\")\n    stderr = result.stderr if isinstance(result.stderr, str) else (result.stderr or b\"\").decode(errors=\"replace\")\n    # asyncssh's exit_status is None when the remote closed without setting one\n    returncode = result.exit_status if result.exit_status is not None else 0\n    return returncode, stdout, stderr\n\n\nasync def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:\n    \"\"\"SSH into a SpoolBuddy device and update it to match Bambuddy's branch.\n\n    Updates device.update_status/update_message in the DB and broadcasts\n    progress via WebSocket at each step.\n    \"\"\"\n    from sqlalchemy import select\n\n    from backend.app.api.routes.spoolbuddy import ws_manager\n    from backend.app.core.database import async_session\n    from backend.app.models.spoolbuddy_device import SpoolBuddyDevice\n\n    install_path = install_path or DEFAULT_INSTALL_PATH\n    branch = detect_current_branch()\n\n    async def _update_progress(status: str, message: str) -> None:\n        \"\"\"Update device status in DB and broadcast via WebSocket.\"\"\"\n        async with async_session() as db:\n            result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))\n            device = result.scalar_one_or_none()\n            if device:\n                device.update_status = status\n                device.update_message = message[:255] if message else None\n                if status in (\"complete\", \"error\"):\n                    device.pending_command = None\n                await db.commit()\n\n        await ws_manager.broadcast(\n            {\n                \"type\": \"spoolbuddy_update\",\n                \"device_id\": device_id,\n                \"update_status\": status,\n                \"update_message\": message[:255] if message else None,\n            }\n        )\n\n    try:\n        private_key, _ = await get_or_create_keypair()\n\n        # Step 1: Test SSH connectivity\n        await _update_progress(\"updating\", \"Connecting via SSH...\")\n        rc, _, stderr = await _run_ssh_command(ip_address, \"echo ok\", private_key)\n        if rc != 0:\n            await _update_progress(\"error\", f\"SSH connection failed: {stderr[:200]}\")\n            return\n\n        # Step 2: Git fetch\n        await _update_progress(\"updating\", f\"Fetching latest code (branch: {branch})...\")\n        rc, _, stderr = await _run_ssh_command(\n            ip_address,\n            f\"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}\",\n            private_key,\n            timeout=120,\n        )\n        if rc != 0:\n            await _update_progress(\"error\", f\"git fetch failed: {stderr[:200]}\")\n            return\n\n        # Step 3: Git checkout + reset\n        await _update_progress(\"updating\", \"Applying update...\")\n        rc, _, stderr = await _run_ssh_command(\n            ip_address,\n            f\"cd {install_path} && git -c safe.directory={install_path} checkout {branch} \"\n            f\"&& git -c safe.directory={install_path} reset --hard origin/{branch}\",\n            private_key,\n        )\n        if rc != 0:\n            await _update_progress(\"error\", f\"git checkout/reset failed: {stderr[:200]}\")\n            return\n\n        # Step 4: Install dependencies\n        await _update_progress(\"updating\", \"Installing dependencies...\")\n        venv_pip = f\"{install_path}/spoolbuddy/venv/bin/pip\"\n        rc, _, stderr = await _run_ssh_command(\n            ip_address,\n            f\"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1\",\n            private_key,\n            timeout=120,\n        )\n        if rc != 0:\n            logger.warning(\"SpoolBuddy %s: pip install returned non-zero (continuing): %s\", device_id, stderr[:200])\n\n        # Step 5: Restart daemon\n        await _update_progress(\"updating\", \"Restarting daemon...\")\n        rc, _, stderr = await _run_ssh_command(\n            ip_address,\n            \"sudo /usr/bin/systemctl restart spoolbuddy.service\",\n            private_key,\n        )\n        if rc != 0:\n            await _update_progress(\"error\", f\"Service restart failed: {stderr[:200]}\")\n            return\n\n        # Step 6: Clear browser cache and restart kiosk\n        # Remove Chromium's Service Worker + cache storage to prevent stale frontend\n        await _run_ssh_command(\n            ip_address,\n            \"sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true\",\n            private_key,\n        )\n        rc, _, stderr = await _run_ssh_command(\n            ip_address,\n            \"sudo /usr/bin/systemctl restart getty@tty1.service\",\n            private_key,\n        )\n        if rc != 0:\n            logger.warning(\"SpoolBuddy %s: kiosk restart failed (non-fatal): %s\", device_id, stderr[:200])\n\n        logger.info(\"SpoolBuddy %s: SSH update complete (branch=%s)\", device_id, branch)\n\n    except Exception as e:\n        logger.error(\"SpoolBuddy %s: SSH update failed: %s\", device_id, e)\n        await _update_progress(\"error\", f\"Update failed: {str(e)[:200]}\")\n"
  },
  {
    "path": "backend/app/services/spoolman.py",
    "content": "\"\"\"Spoolman integration service for syncing AMS filament data.\"\"\"\n\nimport asyncio\nimport logging\nfrom dataclasses import dataclass\nfrom datetime import datetime, timezone\n\nimport httpx\n\nlogger = logging.getLogger(__name__)\n\nBAMBU_RFID_TAG_LENGTH = 32\n\n\n@dataclass\nclass SpoolmanSpool:\n    \"\"\"Represents a spool in Spoolman.\"\"\"\n\n    id: int\n    filament_id: int | None\n    remaining_weight: float | None\n    used_weight: float\n    first_used: str | None\n    last_used: str | None\n    location: str | None\n    lot_nr: str | None\n    comment: str | None\n    extra: dict | None  # Contains tag_uid in extra.tag\n\n\n@dataclass\nclass SpoolmanFilament:\n    \"\"\"Represents a filament type in Spoolman.\"\"\"\n\n    id: int\n    name: str\n    vendor_id: int | None\n    material: str | None\n    color_hex: str | None\n    weight: float | None  # Net weight in grams\n\n\n@dataclass\nclass AMSTray:\n    \"\"\"Represents an AMS tray with filament data from Bambu printer.\"\"\"\n\n    ams_id: int  # 0-3 for regular AMS, 128-135 for AMS-HT, 254+ for external spool\n    tray_id: int  # 0-3\n    tray_type: str  # PLA, PETG, ABS, etc.\n    tray_sub_brands: str  # Full name like \"PLA Basic\", \"PETG HF\"\n    tray_color: str  # Hex color like \"FEC600FF\"\n    remain: int  # Remaining percentage (0-100)\n    tag_uid: str  # RFID tag UID\n    tray_uuid: str  # Spool UUID\n    tray_info_idx: str  # Bambu filament preset ID like \"GFA00\"\n    tray_weight: int  # Spool weight in grams (usually 1000)\n\n\nclass SpoolmanClient:\n    \"\"\"Client for interacting with Spoolman API.\"\"\"\n\n    def __init__(self, base_url: str):\n        \"\"\"Initialize the Spoolman client.\n\n        Args:\n            base_url: The base URL of the Spoolman server (e.g., http://localhost:7912)\n        \"\"\"\n        self.base_url = base_url.rstrip(\"/\")\n        self.api_url = f\"{self.base_url}/api/v1\"\n        self._client: httpx.AsyncClient | None = None\n        self._connected = False\n\n    async def _get_client(self) -> httpx.AsyncClient:\n        \"\"\"Get or create the HTTP client with connection pooling limits.\n\n        Configures the client to prevent idle connection issues:\n        - max_keepalive_connections=5: Limit number of persistent connections\n        - keepalive_expiry=30: Close idle connections after 30 seconds\n        - max_connections=10: Limit total connections to prevent resource exhaustion\n        \"\"\"\n        if self._client is None:\n            self._client = httpx.AsyncClient(\n                timeout=10.0,\n                limits=httpx.Limits(\n                    max_keepalive_connections=5,\n                    max_connections=10,\n                    keepalive_expiry=30.0,\n                ),\n            )\n        return self._client\n\n    async def close(self):\n        \"\"\"Close the HTTP client.\"\"\"\n        if self._client:\n            await self._client.aclose()\n            self._client = None\n\n    async def health_check(self) -> bool:\n        \"\"\"Check if Spoolman server is reachable.\n\n        Returns:\n            True if server is healthy, False otherwise.\n        \"\"\"\n        try:\n            client = await self._get_client()\n            response = await client.get(f\"{self.api_url}/health\")\n            self._connected = response.status_code == 200\n            return self._connected\n        except Exception as e:\n            logger.warning(\"Spoolman health check failed: %s\", e)\n            self._connected = False\n            return False\n\n    @property\n    def is_connected(self) -> bool:\n        \"\"\"Check if client is connected to Spoolman.\"\"\"\n        return self._connected\n\n    async def get_spools(self) -> list[dict]:\n        \"\"\"Get all spools from Spoolman with retry logic.\n\n        Attempts to fetch spools up to 3 times with 500ms delay between attempts.\n        This handles transient network errors like closed connections.\n\n        Returns:\n            List of spool dictionaries.\n\n        Raises:\n            Exception: If all 3 retry attempts fail.\n        \"\"\"\n        max_attempts = 3\n        retry_delay = 0.5  # 500ms\n\n        for attempt in range(1, max_attempts + 1):\n            try:\n                client = await self._get_client()\n                response = await client.get(f\"{self.api_url}/spool\")\n                response.raise_for_status()\n                spools = response.json()\n                if attempt > 1:\n                    logger.info(\"Successfully fetched %d spools on attempt %d\", len(spools), attempt)\n                return spools\n            except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:\n                # Connection-related errors - close and recreate client for next attempt\n                if attempt < max_attempts:\n                    logger.warning(\n                        \"Connection error getting spools (attempt %d/%d): %s. Recreating client and retrying in %dms...\",\n                        attempt,\n                        max_attempts,\n                        e,\n                        int(retry_delay * 1000),\n                    )\n                    # Close the stale client and recreate it\n                    await self.close()\n                    await asyncio.sleep(retry_delay)\n                else:\n                    logger.error(\"Failed to get spools from Spoolman after %d attempts: %s\", max_attempts, e)\n                    raise\n            except Exception as e:\n                # Other errors (HTTP errors, JSON decode errors, etc.)\n                if attempt < max_attempts:\n                    logger.warning(\n                        \"Failed to get spools from Spoolman (attempt %d/%d): %s. Retrying in %dms...\",\n                        attempt,\n                        max_attempts,\n                        e,\n                        int(retry_delay * 1000),\n                    )\n                    await asyncio.sleep(retry_delay)\n                else:\n                    logger.error(\"Failed to get spools from Spoolman after %d attempts: %s\", max_attempts, e)\n                    raise\n\n    async def get_filaments(self) -> list[dict]:\n        \"\"\"Get all internal filaments from Spoolman.\n\n        Returns:\n            List of filament dictionaries.\n        \"\"\"\n        try:\n            client = await self._get_client()\n            response = await client.get(f\"{self.api_url}/filament\")\n            response.raise_for_status()\n            return response.json()\n        except Exception as e:\n            logger.error(\"Failed to get filaments from Spoolman: %s\", e)\n            return []\n\n    async def get_external_filaments(self) -> list[dict]:\n        \"\"\"Get external/library filaments from Spoolman.\n\n        Returns:\n            List of external filament dictionaries.\n        \"\"\"\n        try:\n            client = await self._get_client()\n            response = await client.get(f\"{self.api_url}/external/filament\")\n            response.raise_for_status()\n            return response.json()\n        except Exception as e:\n            logger.error(\"Failed to get external filaments from Spoolman: %s\", e)\n            return []\n\n    async def get_vendors(self) -> list[dict]:\n        \"\"\"Get all vendors from Spoolman.\n\n        Returns:\n            List of vendor dictionaries.\n        \"\"\"\n        try:\n            client = await self._get_client()\n            response = await client.get(f\"{self.api_url}/vendor\")\n            response.raise_for_status()\n            return response.json()\n        except Exception as e:\n            logger.error(\"Failed to get vendors from Spoolman: %s\", e)\n            return []\n\n    async def create_vendor(self, name: str) -> dict | None:\n        \"\"\"Create a new vendor in Spoolman.\n\n        Args:\n            name: Vendor name (e.g., \"Bambu Lab\")\n\n        Returns:\n            Created vendor dictionary or None on failure.\n        \"\"\"\n        try:\n            client = await self._get_client()\n            response = await client.post(f\"{self.api_url}/vendor\", json={\"name\": name})\n            response.raise_for_status()\n            return response.json()\n        except Exception as e:\n            logger.error(\"Failed to create vendor in Spoolman: %s\", e)\n            return None\n\n    def _get_material_density(self, material: str | None) -> float:\n        \"\"\"Get typical density for a filament material type.\n\n        Args:\n            material: Material type (PLA, PETG, ABS, etc.)\n\n        Returns:\n            Density in g/cm³\n        \"\"\"\n        # Typical densities for common filament materials\n        densities = {\n            \"PLA\": 1.24,\n            \"PLA-CF\": 1.29,\n            \"PLA-S\": 1.24,\n            \"PETG\": 1.27,\n            \"ABS\": 1.04,\n            \"ASA\": 1.07,\n            \"TPU\": 1.21,\n            \"PA\": 1.14,  # Nylon\n            \"PA-CF\": 1.20,\n            \"PC\": 1.20,\n            \"PVA\": 1.23,\n            \"HIPS\": 1.04,\n            \"PP\": 0.90,\n            \"PET\": 1.38,\n        }\n        if material:\n            # Try exact match first, then uppercase\n            mat_upper = material.upper()\n            for key, density in densities.items():\n                if key.upper() == mat_upper or mat_upper.startswith(key.upper()):\n                    return density\n        return 1.24  # Default to PLA density\n\n    async def create_filament(\n        self,\n        name: str,\n        vendor_id: int | None = None,\n        material: str | None = None,\n        color_hex: str | None = None,\n        weight: float | None = None,\n        diameter: float = 1.75,\n        density: float | None = None,\n    ) -> dict | None:\n        \"\"\"Create a new filament in Spoolman.\n\n        Args:\n            name: Filament name\n            vendor_id: Vendor ID\n            material: Material type (PLA, PETG, etc.)\n            color_hex: Color in hex format (without #)\n            weight: Net weight in grams\n            diameter: Filament diameter in mm (default 1.75)\n            density: Filament density in g/cm³ (auto-calculated if not provided)\n\n        Returns:\n            Created filament dictionary or None on failure.\n        \"\"\"\n        # Validate required fields\n        if not name or not name.strip():\n            logger.error(\"Cannot create filament: name is required\")\n            return None\n\n        try:\n            # Calculate density from material if not provided\n            if density is None:\n                density = self._get_material_density(material)\n\n            data = {\n                \"name\": name.strip(),\n                \"diameter\": diameter,\n                \"density\": density,\n            }\n            if vendor_id:\n                data[\"vendor_id\"] = vendor_id\n            if material:\n                data[\"material\"] = material\n            if color_hex:\n                # Strip alpha channel if present (RRGGBBAA -> RRGGBB)\n                color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex\n                data[\"color_hex\"] = color_hex\n            if weight:\n                data[\"weight\"] = weight\n\n            logger.debug(\"Creating filament in Spoolman: %s\", data)\n            client = await self._get_client()\n            response = await client.post(f\"{self.api_url}/filament\", json=data)\n            response.raise_for_status()\n            return response.json()\n        except httpx.HTTPStatusError as e:\n            logger.error(\"Failed to create filament in Spoolman: %s, response: %s\", e, e.response.text)\n            return None\n        except Exception as e:\n            logger.error(\"Failed to create filament in Spoolman: %s\", e)\n            return None\n\n    async def create_spool(\n        self,\n        filament_id: int,\n        remaining_weight: float | None = None,\n        location: str | None = None,\n        lot_nr: str | None = None,\n        comment: str | None = None,\n        extra: dict | None = None,\n    ) -> dict | None:\n        \"\"\"Create a new spool in Spoolman.\n\n        Args:\n            filament_id: ID of the filament type\n            remaining_weight: Remaining weight in grams\n            location: Physical location description\n            lot_nr: Lot/batch number\n            comment: Optional comment\n            extra: Extra fields (e.g., {\"tag\": \"RFID_TAG_UID\"})\n\n        Returns:\n            Created spool dictionary or None on failure.\n        \"\"\"\n        try:\n            data = {\"filament_id\": filament_id}\n            if remaining_weight is not None:\n                data[\"remaining_weight\"] = remaining_weight\n            if location:\n                data[\"location\"] = location\n            if lot_nr:\n                data[\"lot_nr\"] = lot_nr\n            if comment:\n                data[\"comment\"] = comment\n            if extra:\n                data[\"extra\"] = extra\n\n            logger.debug(\"Creating spool in Spoolman: %s\", data)\n            client = await self._get_client()\n            response = await client.post(f\"{self.api_url}/spool\", json=data)\n            response.raise_for_status()\n            result = response.json()\n            logger.info(\"Created spool %s in Spoolman\", result.get(\"id\"))\n            return result\n        except httpx.HTTPStatusError as e:\n            logger.error(\"Failed to create spool in Spoolman: %s, response: %s\", e, e.response.text)\n            return None\n        except Exception as e:\n            logger.error(\"Failed to create spool in Spoolman: %s\", e)\n            return None\n\n    async def update_spool(\n        self,\n        spool_id: int,\n        remaining_weight: float | None = None,\n        location: str | None = None,\n        clear_location: bool = False,\n        extra: dict | None = None,\n    ) -> dict | None:\n        \"\"\"Update an existing spool in Spoolman.\n\n        Args:\n            spool_id: ID of the spool to update\n            remaining_weight: New remaining weight in grams\n            location: New location (ignored if clear_location is True)\n            clear_location: If True, clears the location field\n            extra: Extra fields to update\n\n        Returns:\n            Updated spool dictionary or None on failure.\n        \"\"\"\n        try:\n            data = {}\n            if remaining_weight is not None:\n                data[\"remaining_weight\"] = remaining_weight\n            if clear_location:\n                data[\"location\"] = None\n            elif location:\n                data[\"location\"] = location\n            if extra:\n                data[\"extra\"] = extra\n\n            # Always update last_used\n            data[\"last_used\"] = datetime.now(timezone.utc).isoformat()\n\n            client = await self._get_client()\n            response = await client.patch(f\"{self.api_url}/spool/{spool_id}\", json=data)\n            response.raise_for_status()\n            return response.json()\n        except Exception as e:\n            logger.error(\"Failed to update spool in Spoolman: %s\", e)\n            return None\n\n    async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:\n        \"\"\"Record filament usage for a spool.\n\n        Args:\n            spool_id: ID of the spool\n            used_weight: Amount of filament used in grams\n\n        Returns:\n            Updated spool dictionary or None on failure.\n        \"\"\"\n        try:\n            client = await self._get_client()\n            response = await client.put(\n                f\"{self.api_url}/spool/{spool_id}/use\",\n                json={\"use_weight\": used_weight},\n            )\n            response.raise_for_status()\n            return response.json()\n        except Exception as e:\n            logger.error(\"Failed to record spool usage in Spoolman: %s\", e)\n            return None\n\n    async def find_spool_by_tag(self, tag_uid: str, cached_spools: list[dict] | None = None) -> dict | None:\n        \"\"\"Find a spool by its RFID tag UID.\n\n        Args:\n            tag_uid: The RFID tag UID to search for\n            cached_spools: Optional pre-fetched list of spools to search (avoids API call)\n\n        Returns:\n            Spool dictionary or None if not found.\n        \"\"\"\n        # Use cached spools if provided, otherwise fetch from API\n        spools = cached_spools if cached_spools is not None else await self.get_spools()\n        # Normalize tag_uid for comparison (uppercase, strip quotes)\n        search_tag = tag_uid.strip('\"').upper()\n\n        for spool in spools:\n            extra = spool.get(\"extra\", {})\n            if extra:\n                stored_tag = extra.get(\"tag\", \"\")\n                # Normalize stored tag (strip quotes, uppercase)\n                if stored_tag:\n                    normalized_tag = stored_tag.strip('\"').upper()\n                    if normalized_tag == search_tag:\n                        logger.debug(\"Found spool %s matching tag %s\", spool[\"id\"], tag_uid)\n                        return spool\n        return None\n\n    def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:\n        \"\"\"Find a spool by exact location match.\n\n        Used as fallback when RFID tag data is unavailable (e.g., newer firmware\n        that doesn't expose tray_uuid/tag_uid via MQTT).\n\n        Args:\n            location: Exact location string (e.g., \"H2D-1 - AMS A1\")\n            cached_spools: Pre-fetched list of spools to search\n\n        Returns:\n            Spool dictionary or None if not found.\n        \"\"\"\n        if not cached_spools:\n            return None\n        for spool in cached_spools:\n            if spool.get(\"location\") == location:\n                return spool\n        return None\n\n    async def find_spools_by_location_prefix(\n        self, location_prefix: str, cached_spools: list[dict] | None = None\n    ) -> list[dict]:\n        \"\"\"Find all spools with locations starting with a given prefix.\n\n        Args:\n            location_prefix: The location prefix to search for (e.g., \"PrinterName - \")\n            cached_spools: Optional pre-fetched list of spools to search (avoids API call)\n\n        Returns:\n            List of spool dictionaries with matching locations.\n        \"\"\"\n        # Use cached spools if provided, otherwise fetch from API\n        spools = cached_spools if cached_spools is not None else await self.get_spools()\n        matching = []\n        for spool in spools:\n            location = spool.get(\"location\", \"\")\n            if location and location.startswith(location_prefix):\n                matching.append(spool)\n        return matching\n\n    async def clear_location_for_removed_spools(\n        self,\n        printer_name: str,\n        current_tray_uuids: set[str],\n        cached_spools: list[dict] | None = None,\n        synced_spool_ids: set[int] | None = None,\n    ) -> int:\n        \"\"\"Clear location for spools that are no longer in the AMS.\n\n        When a spool is removed from the AMS, its location should be cleared\n        in Spoolman. This method finds all spools with locations for this printer\n        and clears the location for any that are not in the current_tray_uuids set\n        and were not synced in this cycle (synced_spool_ids).\n\n        Args:\n            printer_name: The printer name used as location prefix\n            current_tray_uuids: Set of tray_uuids currently in the AMS\n            cached_spools: Optional pre-fetched list of spools to search (avoids API call)\n            synced_spool_ids: Set of spool IDs that were synced in this cycle\n                (protects location-matched spools when RFID data is unavailable)\n\n        Returns:\n            Number of spools whose location was cleared.\n        \"\"\"\n        location_prefix = f\"{printer_name} - \"\n        spools_at_printer = await self.find_spools_by_location_prefix(location_prefix, cached_spools=cached_spools)\n        cleared_count = 0\n\n        for spool in spools_at_printer:\n            spool_id = spool.get(\"id\")\n\n            # Skip spools that were just synced (matched by location or tag)\n            if synced_spool_ids and spool_id in synced_spool_ids:\n                continue\n\n            # Get the tray_uuid (stored as \"tag\" in extra field)\n            extra = spool.get(\"extra\", {}) or {}\n            stored_tag = extra.get(\"tag\", \"\")\n            if stored_tag:\n                # Normalize: strip quotes and uppercase\n                spool_uuid = stored_tag.strip('\"').upper()\n            else:\n                spool_uuid = \"\"\n\n            # Only clear location for Bambu Lab spools (those with a stored 32-character RFID tag).\n            if len(spool_uuid) != BAMBU_RFID_TAG_LENGTH:\n                continue\n\n            # If this spool's UUID is not in the current AMS, clear its location\n            if spool_uuid not in current_tray_uuids:\n                logger.info(\n                    f\"Clearing location for spool {spool_id} \"\n                    f\"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)\"\n                )\n                result = await self.update_spool(spool_id=spool_id, clear_location=True)\n                if result:\n                    cleared_count += 1\n\n        return cleared_count\n\n    async def ensure_bambu_vendor(self) -> int | None:\n        \"\"\"Ensure Bambu Lab vendor exists and return its ID.\n\n        Returns:\n            Vendor ID or None on failure.\n        \"\"\"\n        vendors = await self.get_vendors()\n        for vendor in vendors:\n            if vendor.get(\"name\", \"\").lower() == \"bambu lab\":\n                return vendor[\"id\"]\n\n        # Create Bambu Lab vendor if not exists\n        vendor = await self.create_vendor(\"Bambu Lab\")\n        return vendor[\"id\"] if vendor else None\n\n    async def ensure_tag_extra_field(self) -> bool:\n        \"\"\"Ensure the 'tag' extra field exists for spools.\n\n        Spoolman requires extra fields to be registered before use.\n        This creates the 'tag' field used to store RFID/UUID identifiers.\n\n        Returns:\n            True if field exists or was created, False on failure.\n        \"\"\"\n        try:\n            client = await self._get_client()\n\n            # Check if field already exists\n            response = await client.get(f\"{self.api_url}/field/spool/tag\")\n            if response.status_code == 200:\n                logger.debug(\"Spoolman 'tag' extra field already exists\")\n                return True\n\n            # Field doesn't exist - create it\n            field_data = {\n                \"name\": \"tag\",\n                \"field_type\": \"text\",\n                \"default_value\": None,\n            }\n            response = await client.post(f\"{self.api_url}/field/spool/tag\", json=field_data)\n            if response.status_code in (200, 201):\n                logger.info(\"Created 'tag' extra field in Spoolman\")\n                return True\n\n            logger.warning(\"Failed to create 'tag' extra field: %s - %s\", response.status_code, response.text)\n            return False\n\n        except Exception as e:\n            logger.warning(\"Failed to ensure 'tag' extra field exists: %s\", e)\n            return False\n\n    def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:\n        \"\"\"Parse AMS tray data into AMSTray object.\n\n        Args:\n            ams_id: The AMS unit ID (0-3 for regular, 128-135 for AMS-HT, 254+ for external)\n            tray_data: Raw tray data from MQTT\n\n        Returns:\n            AMSTray object or None if tray is empty or invalid.\n        \"\"\"\n        # Skip empty trays - check for valid tray_type\n        tray_type = tray_data.get(\"tray_type\", \"\")\n        if not tray_type or tray_type.strip() == \"\":\n            return None\n\n        # Need valid color to create filament\n        tray_color = tray_data.get(\"tray_color\", \"\")\n        if not tray_color or tray_color.strip() == \"\":\n            logger.debug(\"Skipping tray with empty color\")\n            return None\n\n        # Handle transparent/natural filament (RRGGBBAA with alpha=00)\n        # Replace with cream color that represents how natural PLA actually looks\n        if tray_color == \"00000000\":\n            tray_color = \"F5E6D3FF\"  # Light cream/natural color\n\n        # Get sub_brands, falling back to tray_type\n        tray_sub_brands = tray_data.get(\"tray_sub_brands\", \"\")\n        if not tray_sub_brands or tray_sub_brands.strip() == \"\":\n            tray_sub_brands = tray_type\n\n        # Get tag_uid and tray_uuid, filtering out empty/invalid values\n        tag_uid = tray_data.get(\"tag_uid\", \"\")\n        if tag_uid in (\"\", \"0000000000000000\"):\n            tag_uid = \"\"\n        tray_uuid = tray_data.get(\"tray_uuid\", \"\")\n        if tray_uuid in (\"\", \"00000000000000000000000000000000\"):\n            tray_uuid = \"\"\n\n        # Get tray_info_idx (Bambu filament preset ID like \"GFA00\")\n        tray_info_idx = tray_data.get(\"tray_info_idx\", \"\") or \"\"\n\n        # Get remaining percentage (-1 means unknown/not read by AMS)\n        remain = int(tray_data.get(\"remain\", -1))\n\n        return AMSTray(\n            ams_id=ams_id,\n            tray_id=int(tray_data.get(\"id\", 0)),\n            tray_type=tray_type.strip(),\n            tray_sub_brands=tray_sub_brands.strip(),\n            tray_color=tray_color,\n            remain=remain,\n            tag_uid=tag_uid,\n            tray_uuid=tray_uuid,\n            tray_info_idx=tray_info_idx.strip(),\n            tray_weight=int(tray_data.get(\"tray_weight\", 1000)),\n        )\n\n    def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:\n        \"\"\"Convert AMS ID and tray ID to human-readable location.\n\n        Args:\n            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for AMS-HT, 254+ for external spool)\n            tray_id: Tray ID within the AMS (0-3)\n\n        Returns:\n            Location string like \"AMS A1\", \"AMS-HT A1\", \"External Spool\", etc.\n        \"\"\"\n        if ams_id >= 254:\n            return \"External Spool\"\n\n        if 128 <= ams_id <= 135:\n            # AMS-HT units use IDs 128-135\n            ht_letter = chr(ord(\"A\") + (ams_id - 128))\n            return f\"AMS-HT {ht_letter}{tray_id + 1}\"\n\n        ams_letter = chr(ord(\"A\") + ams_id)\n        return f\"AMS {ams_letter}{tray_id + 1}\"\n\n    def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = \"\", tray_info_idx: str = \"\") -> bool:\n        \"\"\"Check if a tray has a valid Bambu Lab spool.\n\n        Bambu Lab spools are identified by hardware RFID identifiers only:\n        1. tray_uuid: 32-character hex string (preferred, consistent across printers)\n        2. tag_uid: 16-character hex string (RFID tag, varies between readers)\n\n        Note: tray_info_idx (e.g. \"GFA00\") is NOT a reliable indicator — third-party\n        spools using Bambu generic presets also have GF-prefixed tray_info_idx values.\n        The tray_info_idx parameter is kept for API compatibility but ignored.\n\n        Args:\n            tray_uuid: The tray UUID to check (32 hex chars)\n            tag_uid: The RFID tag UID to check as fallback (16 hex chars)\n            tray_info_idx: Ignored (kept for API compatibility)\n\n        Returns:\n            True if the spool has valid Bambu Lab RFID identifiers, False otherwise.\n        \"\"\"\n        # Check tray_uuid (preferred - consistent across printer models)\n        if tray_uuid:\n            uuid = tray_uuid.strip()\n            if len(uuid) == 32 and uuid != \"00000000000000000000000000000000\":\n                try:\n                    int(uuid, 16)\n                    return True\n                except ValueError:\n                    pass\n\n        # Fallback: check tag_uid (RFID tag - varies between printer readers)\n        # Bambu Lab RFID tags are 16 hex characters (8 bytes)\n        if tag_uid:\n            tag = tag_uid.strip()\n            if len(tag) == 16 and tag != \"0000000000000000\":\n                try:\n                    int(tag, 16)\n                    logger.debug(\"Identified Bambu Lab spool via tag_uid fallback: %s\", tag)\n                    return True\n                except ValueError:\n                    pass\n\n        return False\n\n    def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:\n        \"\"\"Calculate remaining weight from percentage.\n\n        Args:\n            remain_percent: Remaining percentage (0-100)\n            spool_weight: Total spool weight in grams\n\n        Returns:\n            Remaining weight in grams.\n        \"\"\"\n        return (remain_percent / 100.0) * spool_weight\n\n    async def sync_ams_tray(\n        self,\n        tray: AMSTray,\n        printer_name: str,\n        disable_weight_sync: bool = False,\n        cached_spools: list[dict] | None = None,\n        inventory_remaining: float | None = None,\n    ) -> dict | None:\n        \"\"\"Sync a single AMS tray to Spoolman.\n\n        Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).\n        Non-Bambu Lab spools (SpoolEase/third-party) are skipped.\n\n        Uses tray_uuid for matching, as it's consistent across all printer models\n        (unlike tag_uid which varies between X1C/H2D readers).\n\n        Args:\n            tray: The AMSTray to sync\n            printer_name: Name of the printer for location\n            disable_weight_sync: If True, skip updating remaining_weight for existing spools.\n                This allows Spoolman's granular usage tracking to maintain accurate weights.\n            cached_spools: Optional pre-fetched list of spools to search (avoids API calls).\n                When provided, this cache is passed to find_spool_by_tag to avoid redundant\n                API calls during batch sync operations.\n            inventory_remaining: Optional fallback remaining weight (grams) from the built-in\n                inventory when AMS MQTT data has invalid remain/tray_weight values.\n\n        Returns:\n            Synced spool dictionary or None if skipped or failed.\n        \"\"\"\n        logger.debug(\n            f\"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: \"\n            f\"type={tray.tray_type}, idx={tray.tray_info_idx or 'none'}, \"\n            f\"uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}, \"\n            f\"tag={tray.tag_uid[:8] if tray.tag_uid else 'none'}...\"\n        )\n\n        # Only sync trays with valid Bambu Lab identifiers\n        if not self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):\n            if tray.tray_uuid or tray.tag_uid or tray.tray_info_idx:\n                logger.info(\n                    f\"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} \"\n                    f\"(tray_info_idx={tray.tray_info_idx}, tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})\"\n                )\n            else:\n                logger.debug(\"Skipping tray without RFID tag: AMS %s tray %s\", tray.ams_id, tray.tray_id)\n            return None\n\n        # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)\n        # Zero-filled values mean the AMS hasn't read the RFID tag — treat as no tag\n        zero_uuid = \"00000000000000000000000000000000\"\n        zero_tag = \"0000000000000000\"\n        spool_tag = None\n        if tray.tray_uuid and tray.tray_uuid != zero_uuid:\n            spool_tag = tray.tray_uuid\n        elif tray.tag_uid and tray.tag_uid != zero_tag:\n            spool_tag = tray.tag_uid\n\n        # Calculate remaining weight\n        # Primary: AMS MQTT data (remain percentage + tray_weight)\n        # Fallback: Built-in inventory tracked weight (when firmware sends invalid remain/tray_weight)\n        if tray.remain >= 0 and tray.tray_weight > 0:\n            remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)\n        elif inventory_remaining is not None:\n            remaining = inventory_remaining\n            logger.debug(\n                \"Using inventory weight fallback for %s AMS %s tray %s: %.1fg\",\n                printer_name,\n                tray.ams_id,\n                tray.tray_id,\n                remaining,\n            )\n        else:\n            remaining = None\n        location = f\"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}\"\n\n        if spool_tag:\n            # Primary path: match by RFID tag\n            existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)\n            if existing:\n                logger.info(\"Updating existing spool %s for tag %s...\", existing[\"id\"], spool_tag[:16])\n                return await self.update_spool(\n                    spool_id=existing[\"id\"],\n                    remaining_weight=None if disable_weight_sync else remaining,\n                    location=location,\n                )\n\n            # Spool not found by tag - auto-create it\n            logger.info(\"Creating new spool in Spoolman for %s (tag: %s...)\", tray.tray_sub_brands, spool_tag[:16])\n            filament = await self._find_or_create_filament(tray)\n            if not filament:\n                logger.error(\"Failed to find or create filament for %s\", tray.tray_sub_brands)\n                return None\n\n            import json\n\n            return await self.create_spool(\n                filament_id=filament[\"id\"],\n                remaining_weight=remaining,\n                location=location,\n                comment=\"Created by Bambuddy\",\n                extra={\"tag\": json.dumps(spool_tag)},\n            )\n\n        # Fallback path: no RFID tag available (newer firmware may not expose UUIDs)\n        # Only update existing spools matched by location — never create new ones without a tag\n        # to avoid duplicates when old spools exist from previous RFID-based syncs\n        existing = self._find_spool_by_location(location, cached_spools)\n        if existing:\n            logger.info(\n                \"Updating spool %s by location match '%s' (no RFID tag available)\",\n                existing[\"id\"],\n                location,\n            )\n            return await self.update_spool(\n                spool_id=existing[\"id\"],\n                remaining_weight=None if disable_weight_sync else remaining,\n                location=location,\n            )\n\n        logger.info(\n            \"No existing spool found at '%s' — skipping (no RFID tag to create with)\",\n            location,\n        )\n        return None\n\n    async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:\n        \"\"\"Find existing filament or create new one.\n\n        Only matches Bambu Lab vendor filaments since this is called for\n        Bambu Lab spools. Third-party filaments (like 3DJAKE) are ignored\n        to prevent incorrect matching by color alone.\n\n        Args:\n            tray: The AMSTray containing filament info\n\n        Returns:\n            Filament dictionary or None on failure.\n        \"\"\"\n        # Get Bambu Lab vendor ID for filtering\n        bambu_vendor_id = await self.ensure_bambu_vendor()\n        color_hex = tray.tray_color[:6]  # Strip alpha channel\n\n        # Search internal filaments - only match Bambu Lab vendor\n        filaments = await self.get_filaments()\n        for filament in filaments:\n            # Only match filaments from Bambu Lab vendor\n            fil_vendor_id = filament.get(\"vendor_id\") or filament.get(\"vendor\", {}).get(\"id\")\n            if fil_vendor_id != bambu_vendor_id:\n                continue\n\n            # Match by material and color (handle None values)\n            fil_material = filament.get(\"material\") or \"\"\n            fil_color = filament.get(\"color_hex\") or \"\"\n            if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():\n                return filament\n\n        # Search external filaments (Bambu library)\n        external = await self.get_external_filaments()\n        for filament in external:\n            fil_material = filament.get(\"material\") or \"\"\n            fil_color = filament.get(\"color_hex\") or \"\"\n            if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():\n                # Found in external library - need to create internal copy\n                return await self._create_filament_from_external(filament, tray)\n\n        # Not found - create new Bambu Lab filament\n        return await self.create_filament(\n            name=tray.tray_sub_brands or tray.tray_type,\n            vendor_id=bambu_vendor_id,\n            material=tray.tray_type,\n            color_hex=color_hex,\n            weight=tray.tray_weight,\n        )\n\n    async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:\n        \"\"\"Create internal filament from external library entry.\n\n        Args:\n            external: External filament dictionary\n            tray: The AMSTray for additional info\n\n        Returns:\n            Created filament dictionary or None on failure.\n        \"\"\"\n        vendor_id = await self.ensure_bambu_vendor()\n        return await self.create_filament(\n            name=external.get(\"name\", tray.tray_sub_brands),\n            vendor_id=vendor_id,\n            material=external.get(\"material\", tray.tray_type),\n            color_hex=external.get(\"color_hex\", tray.tray_color[:6]),\n            weight=external.get(\"weight\", tray.tray_weight),\n        )\n\n\n# Global client instance (initialized when settings are loaded)\n_spoolman_client: SpoolmanClient | None = None\n\n\nasync def get_spoolman_client() -> SpoolmanClient | None:\n    \"\"\"Get the global Spoolman client instance.\n\n    Returns:\n        SpoolmanClient instance or None if not configured.\n    \"\"\"\n    return _spoolman_client\n\n\nasync def init_spoolman_client(url: str) -> SpoolmanClient:\n    \"\"\"Initialize the global Spoolman client.\n\n    Args:\n        url: Spoolman server URL\n\n    Returns:\n        Initialized SpoolmanClient instance.\n    \"\"\"\n    global _spoolman_client\n    if _spoolman_client:\n        await _spoolman_client.close()\n\n    _spoolman_client = SpoolmanClient(url)\n    return _spoolman_client\n\n\nasync def close_spoolman_client():\n    \"\"\"Close the global Spoolman client.\"\"\"\n    global _spoolman_client\n    if _spoolman_client:\n        await _spoolman_client.close()\n        _spoolman_client = None\n"
  },
  {
    "path": "backend/app/services/spoolman_tracking.py",
    "content": "\"\"\"Spoolman per-filament usage tracking for active prints.\n\nCaptures AMS tray state and G-code data at print start, then reports\nper-filament usage to the correct Spoolman spools at print completion.\nSupports accurate partial usage reporting for failed/cancelled prints.\n\"\"\"\n\nimport json\nimport logging\n\nfrom sqlalchemy import delete, select\n\nfrom backend.app.core.config import settings as app_settings\nfrom backend.app.core.database import async_session\nfrom backend.app.services.spoolman import get_spoolman_client, init_spoolman_client\n\nlogger = logging.getLogger(__name__)\n\n# Zero UUID used by Bambu printers for empty/unset tray_uuid\n_ZERO_UUID = \"00000000000000000000000000000000\"\n_ZERO_TAG_UID = \"0000000000000000\"\n\n\ndef _is_non_zero_identifier(value: str) -> bool:\n    \"\"\"Return True when identifier is non-empty and not all zeros.\"\"\"\n    if not value:\n        return False\n    return set(value) != {\"0\"}\n\n\ndef _to_fixed_hex(value: int, width: int) -> str:\n    \"\"\"Mirror frontend toFixedHex(): uppercase, zero-padded, fixed width.\"\"\"\n    safe = max(0, int(value))\n    return format(safe, \"X\").zfill(width)[-width:]\n\n\ndef _hash_serial_to_hex32(serial: str) -> str:\n    \"\"\"Mirror frontend hashSerialToHex32() exactly (32-bit FNV-1a).\"\"\"\n    input_str = (serial or \"\").strip().upper()\n    hash_value = 0x811C9DC5\n    for char in input_str:\n        hash_value ^= ord(char)\n        hash_value = (hash_value * 0x01000193) & 0xFFFFFFFF\n    return format(hash_value, \"X\").zfill(8)\n\n\ndef _global_tray_id_to_ams_slot(global_tray_id: int) -> tuple[int, int]:\n    \"\"\"Convert global tray id to (ams_id, tray_id) tuple for fallback tag generation.\"\"\"\n    # External spool slots use IDs 254/255 and map to ams_id=255 tray_id=0/1.\n    if global_tray_id >= 254:\n        return 255, max(0, global_tray_id - 254)\n    # AMS-HT units are addressed by ams_id directly and have a single tray.\n    if global_tray_id >= 128:\n        return global_tray_id, 0\n    # Standard AMS units: four trays each.\n    return global_tray_id // 4, global_tray_id % 4\n\n\ndef _get_fallback_spool_tag(printer_serial: str, global_tray_id: int) -> str:\n    \"\"\"Mirror frontend getFallbackSpoolTag(serial, amsId, trayId) exactly.\"\"\"\n    if not printer_serial:\n        return \"\"\n    ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)\n    return f\"{_hash_serial_to_hex32(printer_serial)}{_to_fixed_hex(ams_id, 4)}{_to_fixed_hex(tray_id, 4)}\"\n\n\ndef _resolve_spool_tag(tray_info: dict, printer_serial: str = \"\", global_tray_id: int | None = None) -> str:\n    \"\"\"Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).\n\n    Returns empty string if no usable identifier is found.\n    \"\"\"\n    tray_uuid = str(tray_info.get(\"tray_uuid\", \"\") or \"\")\n    tag_uid = str(tray_info.get(\"tag_uid\", \"\") or \"\")\n    if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid):\n        return tray_uuid\n    if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):\n        return tag_uid\n    if global_tray_id is not None:\n        return _get_fallback_spool_tag(printer_serial, global_tray_id)\n    return \"\"\n\n\nasync def _get_printer_serial(printer_id: int) -> str:\n    \"\"\"Get printer serial for deterministic fallback tag generation.\"\"\"\n    from backend.app.models.printer import Printer\n    from backend.app.services.printer_manager import printer_manager\n\n    printer_info = printer_manager.get_printer(printer_id)\n    if printer_info and printer_info.serial_number:\n        return printer_info.serial_number\n\n    async with async_session() as db:\n        result = await db.execute(select(Printer.serial_number).where(Printer.id == printer_id))\n        serial_number = result.scalar_one_or_none()\n        return serial_number or \"\"\n\n\ndef _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays: dict | None = None) -> int:\n    \"\"\"Map a 1-based slot_id to a global_tray_id using optional custom mapping.\n\n    Custom mapping: slot_to_tray[slot_id - 1] is used when >= 0.\n    Position-based default: uses sorted ams_trays keys so external spools (ID 254/255)\n    naturally follow standard AMS trays, matching the slicer's slot numbering.\n    A value of -1 in the custom mapping means unmapped (uses position-based default).\n    Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools).\n    \"\"\"\n    if slot_to_tray and slot_id <= len(slot_to_tray):\n        mapped_tray = slot_to_tray[slot_id - 1]\n        if mapped_tray >= 0:\n            return mapped_tray\n    # Position-based default: sort available tray IDs so external spools (254/255)\n    # come after standard AMS trays, matching the slicer's slot assignment order.\n    if ams_trays:\n        sorted_tray_ids = sorted(ams_trays.keys())\n        if slot_id <= len(sorted_tray_ids):\n            return sorted_tray_ids[slot_id - 1]\n    return slot_id - 1\n\n\ndef build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:\n    \"\"\"Build lookup of global_tray_id -> tray info from printer state.\n\n    Returns: {0: {\"tray_uuid\": \"...\", \"tag_uid\": \"...\", \"tray_type\": \"...\"}, ...}\n    \"\"\"\n    lookup = {}\n    ams_data = raw_data.get(\"ams\", [])\n    for ams_unit in ams_data:\n        ams_id = int(ams_unit.get(\"id\", 0))\n        for tray in ams_unit.get(\"tray\", []):\n            tray_id = int(tray.get(\"id\", 0))\n            # AMS-HT units have IDs starting at 128 with a single tray\n            global_tray_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id\n            lookup[global_tray_id] = {\n                \"tray_uuid\": tray.get(\"tray_uuid\", \"\"),\n                \"tag_uid\": tray.get(\"tag_uid\", \"\"),\n                \"tray_type\": tray.get(\"tray_type\", \"\"),\n            }\n\n    # External spool(s) (vt_tray is a list, global_tray_id from each entry's \"id\")\n    for vt in raw_data.get(\"vt_tray\") or []:\n        if vt.get(\"tray_type\"):\n            tray_id = int(vt.get(\"id\", 254))\n            lookup[tray_id] = {\n                \"tray_uuid\": vt.get(\"tray_uuid\", \"\"),\n                \"tag_uid\": vt.get(\"tag_uid\", \"\"),\n                \"tray_type\": vt.get(\"tray_type\", \"\"),\n            }\n\n    return lookup\n\n\nasync def store_print_data(\n    printer_id: int,\n    archive_id: int,\n    file_path: str,\n    db,\n    printer_manager,\n    ams_mapping: list[int] | None = None,\n):\n    \"\"\"Store Spoolman tracking data at print start (persisted to database).\n\n    Only stores data when Spoolman is enabled and AMS weight sync is disabled\n    (i.e., we're using per-usage tracking instead of AMS percentage estimates).\n    \"\"\"\n    from backend.app.api.routes.settings import get_setting\n    from backend.app.models.active_print_spoolman import ActivePrintSpoolman\n    from backend.app.models.print_queue import PrintQueueItem\n    from backend.app.utils.threemf_tools import (\n        extract_filament_properties_from_3mf,\n        extract_filament_usage_from_3mf,\n        extract_layer_filament_usage_from_3mf,\n    )\n\n    # Check if Spoolman is enabled\n    spoolman_enabled = await get_setting(db, \"spoolman_enabled\")\n    if not spoolman_enabled or spoolman_enabled.lower() != \"true\":\n        return\n\n    # Only store tracking data if \"Disable AMS Weight Sync\" is enabled\n    disable_weight_sync_str = await get_setting(db, \"spoolman_disable_weight_sync\")\n    disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == \"true\"\n    if not disable_weight_sync:\n        logger.debug(\"[SPOOLMAN] Weight sync enabled, skipping per-usage tracking data storage\")\n        return\n\n    # Get 3MF file path\n    full_path = app_settings.base_dir / file_path\n    if not full_path.exists():\n        logger.debug(\"[SPOOLMAN] 3MF file not found: %s\", full_path)\n        return\n\n    # Extract per-filament usage from 3MF (total usage per slot)\n    filament_usage = extract_filament_usage_from_3mf(full_path)\n    if not filament_usage:\n        logger.debug(\"[SPOOLMAN] No filament usage data in 3MF for archive %s\", archive_id)\n        return\n\n    # Get current AMS tray state\n    state = printer_manager.get_status(printer_id)\n    ams_trays = {}\n    if state and state.raw_data:\n        ams_trays = build_ams_tray_lookup(state.raw_data)\n\n    # Prefer the explicit mapping captured from the print command, then fall back\n    # to any queue mapping stored for scheduled/reprint jobs.\n    slot_to_tray = ams_mapping if ams_mapping is not None else None\n    if not slot_to_tray:\n        queue_result = await db.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.archive_id == archive_id)\n            .where(PrintQueueItem.status == \"printing\")\n        )\n        queue_item = queue_result.scalar_one_or_none()\n        if queue_item and queue_item.ams_mapping:\n            try:\n                slot_to_tray = json.loads(queue_item.ams_mapping)\n            except json.JSONDecodeError:\n                pass  # Ignore malformed AMS mapping; fall back to default slot assignment\n\n    # Parse G-code for per-layer filament usage (for accurate partial usage tracking)\n    layer_usage = extract_layer_filament_usage_from_3mf(full_path)\n    layer_usage_json = None\n    if layer_usage:\n        # Convert int keys to string for JSON serialization\n        layer_usage_json = {str(k): v for k, v in layer_usage.items()}\n        logger.debug(\"[SPOOLMAN] Parsed %s layers from G-code\", len(layer_usage))\n\n    # Extract filament properties (density, diameter) for mm -> grams conversion\n    filament_properties = extract_filament_properties_from_3mf(full_path)\n\n    # Delete any existing row for this printer/archive (shouldn't exist, but just in case)\n    await db.execute(\n        delete(ActivePrintSpoolman)\n        .where(ActivePrintSpoolman.printer_id == printer_id)\n        .where(ActivePrintSpoolman.archive_id == archive_id)\n    )\n\n    # Insert new tracking data\n    tracking = ActivePrintSpoolman(\n        printer_id=printer_id,\n        archive_id=archive_id,\n        filament_usage=filament_usage,\n        ams_trays=ams_trays,\n        slot_to_tray=slot_to_tray,\n        layer_usage=layer_usage_json,\n        filament_properties=filament_properties,\n    )\n    db.add(tracking)\n    await db.commit()\n\n    logger.info(\"[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s\", printer_id, archive_id)\n    logger.debug(\"[SPOOLMAN] Filament usage: %s\", filament_usage)\n    logger.debug(\"[SPOOLMAN] AMS trays: %s\", list(ams_trays.keys()))\n    if slot_to_tray:\n        logger.debug(\"[SPOOLMAN] Custom slot mapping: %s\", slot_to_tray)\n    if layer_usage_json:\n        logger.debug(\"[SPOOLMAN] Layer usage data available for partial tracking\")\n\n\nasync def cleanup_tracking(\n    printer_id: int,\n    archive_id: int,\n    db,\n    last_layer_num: int | None = None,\n    last_progress: int | None = None,\n):\n    \"\"\"Report partial usage and clean up Spoolman tracking data for failed/aborted prints.\"\"\"\n    from backend.app.models.active_print_spoolman import ActivePrintSpoolman\n\n    # Get tracking data first (needed for partial usage reporting)\n    result = await db.execute(\n        select(ActivePrintSpoolman)\n        .where(ActivePrintSpoolman.printer_id == printer_id)\n        .where(ActivePrintSpoolman.archive_id == archive_id)\n    )\n    tracking = result.scalar_one_or_none()\n\n    if not tracking:\n        logger.debug(\"[SPOOLMAN] No tracking data to clean up for printer=%s, archive=%s\", printer_id, archive_id)\n        return\n\n    # Try to report partial usage before cleanup\n    try:\n        await _report_partial_usage(\n            printer_id,\n            tracking,\n            last_layer_num=last_layer_num,\n            last_progress=last_progress,\n        )\n    except Exception as e:\n        logger.warning(\"[SPOOLMAN] Partial usage report failed: %s\", e)\n\n    # Delete tracking data\n    await db.execute(\n        delete(ActivePrintSpoolman)\n        .where(ActivePrintSpoolman.printer_id == printer_id)\n        .where(ActivePrintSpoolman.archive_id == archive_id)\n    )\n    await db.commit()\n    logger.debug(\"[SPOOLMAN] Cleaned up tracking data for printer=%s, archive=%s\", printer_id, archive_id)\n\n\nasync def _get_spoolman_client_with_fallback():\n    \"\"\"Get Spoolman client, initializing from settings if needed.\n\n    Returns (client, is_healthy) tuple. Client may be None.\n    \"\"\"\n    client = await get_spoolman_client()\n    if not client:\n        async with async_session() as db:\n            from backend.app.api.routes.settings import get_setting\n\n            spoolman_url = await get_setting(db, \"spoolman_url\")\n            if spoolman_url:\n                client = await init_spoolman_client(spoolman_url)\n\n    if not client or not await client.health_check():\n        return None\n\n    return client\n\n\nasync def _report_spool_usage_for_slots(\n    client,\n    filament_usage_items: list[tuple[int, float]],\n    ams_trays: dict[int, dict],\n    slot_to_tray: list | None,\n    method_label: str,\n    printer_serial: str = \"\",\n) -> int:\n    \"\"\"Report usage to Spoolman for a list of (slot_id, grams) pairs.\n\n    Returns number of spools successfully updated.\n    \"\"\"\n    spools_updated = 0\n    for slot_id, grams_used in filament_usage_items:\n        if grams_used <= 0:\n            continue\n\n        global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)\n        tray_info = ams_trays.get(global_tray_id)\n        if not tray_info:\n            logger.debug(\"[SPOOLMAN] Slot %s: no tray at global_tray_id %s\", slot_id, global_tray_id)\n            continue\n\n        is_external = global_tray_id >= 254\n        tray_type = tray_info.get(\"tray_type\", \"\")\n        logger.debug(\n            \"[SPOOLMAN] Slot %s resolved to global_tray_id %s (tray_type=%s, external=%s)\",\n            slot_id,\n            global_tray_id,\n            tray_type or \"unknown\",\n            is_external,\n        )\n\n        spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)\n        if not spool_tag:\n            logger.debug(\"[SPOOLMAN] Slot %s: no identifier for tray %s\", slot_id, global_tray_id)\n            continue\n\n        spool = await client.find_spool_by_tag(spool_tag)\n        if not spool:\n            logger.debug(\"[SPOOLMAN] Slot %s: no spool for tag %s...\", slot_id, spool_tag[:16])\n            continue\n\n        result = await client.use_spool(spool[\"id\"], grams_used)\n        if result:\n            logger.info(\"[SPOOLMAN] %s: slot %s: %sg -> spool %s\", method_label, slot_id, grams_used, spool[\"id\"])\n            spools_updated += 1\n\n    return spools_updated\n\n\nasync def _report_partial_usage(\n    printer_id: int,\n    tracking,\n    last_layer_num: int | None = None,\n    last_progress: int | None = None,\n):\n    \"\"\"Report partial filament usage based on actual G-code layer data.\n\n    Uses per-layer cumulative extrusion from G-code parsing for accurate\n    multi-material tracking. Falls back to linear interpolation if G-code\n    data is unavailable.\n    \"\"\"\n    from backend.app.services.printer_manager import printer_manager\n    from backend.app.utils.threemf_tools import get_cumulative_usage_at_layer, mm_to_grams\n\n    async with async_session() as db:\n        from backend.app.api.routes.settings import get_setting\n\n        # Check if partial usage reporting is enabled (default: true)\n        report_partial = await get_setting(db, \"spoolman_report_partial_usage\")\n        if report_partial and report_partial.lower() == \"false\":\n            logger.debug(\"[SPOOLMAN] Partial usage reporting disabled by setting\")\n            return\n\n        # Check if Spoolman is enabled\n        spoolman_enabled = await get_setting(db, \"spoolman_enabled\")\n        if not spoolman_enabled or spoolman_enabled.lower() != \"true\":\n            return\n\n    # Get current printer state for layer progress.\n    # On failed/aborted prints the firmware may already reset to IDLE with layer=0,\n    # so we fall back to completion-time hints captured from MQTT.\n    state = printer_manager.get_status(printer_id)\n    current_layer = state.layer_num if state else None\n    total_layers = state.total_layers if state else None\n\n    if (not current_layer or current_layer <= 0) and last_layer_num and last_layer_num > 0:\n        current_layer = last_layer_num\n        logger.debug(\"[SPOOLMAN] Using captured last_layer_num=%s for partial usage\", current_layer)\n\n    progress_ratio_from_event = None\n    if last_progress is not None:\n        try:\n            progress_ratio_from_event = min(max(float(last_progress), 0.0), 100.0) / 100.0\n        except (TypeError, ValueError):\n            progress_ratio_from_event = None\n\n    if (not current_layer or current_layer <= 0) and progress_ratio_from_event and total_layers and total_layers > 0:\n        current_layer = max(1, int(round(total_layers * progress_ratio_from_event)))\n        logger.debug(\n            \"[SPOOLMAN] Estimated layer from last_progress=%s%% and total_layers=%s -> %s\",\n            last_progress,\n            total_layers,\n            current_layer,\n        )\n\n    if not current_layer or current_layer <= 0:\n        logger.debug(\n            \"[SPOOLMAN] No progress to report (layer 0/unknown, last_layer_num=%s, last_progress=%s)\",\n            last_layer_num,\n            last_progress,\n        )\n        return\n\n    logger.info(\"[SPOOLMAN] Reporting partial usage at layer %s/%s\", current_layer, total_layers or \"?\")\n\n    # Get tracking data\n    layer_usage = tracking.layer_usage\n    filament_properties = tracking.filament_properties or {}\n    filament_usage = tracking.filament_usage or []\n    ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}\n    slot_to_tray = tracking.slot_to_tray\n    printer_serial = await _get_printer_serial(printer_id)\n\n    client = await _get_spoolman_client_with_fallback()\n    if not client:\n        logger.warning(\"[SPOOLMAN] Not reachable for partial usage reporting\")\n        return\n\n    # Try to use accurate G-code parsed data\n    if layer_usage:\n        layer_usage_int = {\n            int(layer): {int(fid): mm for fid, mm in filaments.items()} for layer, filaments in layer_usage.items()\n        }\n        usage_mm = get_cumulative_usage_at_layer(layer_usage_int, current_layer)\n\n        if usage_mm:\n            logger.info(\"[SPOOLMAN] Using G-code parsed data for layer %s\", current_layer)\n\n            # Build (slot_id, grams) list using Spoolman densities with 3MF fallback\n            usage_items = []\n            for filament_id, mm_used in usage_mm.items():\n                slot_id = filament_id + 1  # filament_id is 0-based, slot_id is 1-based\n\n                # Get density from Spoolman (most accurate), fall back to 3MF, then PLA default\n                global_tray_id = _resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)\n                tray_info = ams_trays.get(global_tray_id)\n                density = None\n                diameter = 1.75\n\n                if tray_info:\n                    spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)\n                    if spool_tag:\n                        spool = await client.find_spool_by_tag(spool_tag)\n                        if spool:\n                            filament_data = spool.get(\"filament\", {})\n                            density = filament_data.get(\"density\")\n                            diameter = filament_data.get(\"diameter\", 1.75)\n\n                if not density:\n                    props = filament_properties.get(str(slot_id), filament_properties.get(slot_id, {}))\n                    density = props.get(\"density\", 1.24)\n                    logger.debug(\"[SPOOLMAN] Using fallback density %s for slot %s\", density, slot_id)\n\n                grams_used = round(mm_to_grams(mm_used, diameter, density), 2)\n                usage_items.append((slot_id, grams_used))\n\n            spools_updated = await _report_spool_usage_for_slots(\n                client, usage_items, ams_trays, slot_to_tray, \"Partial (G-code)\", printer_serial\n            )\n            if spools_updated > 0:\n                logger.info(\"[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data\", spools_updated)\n            return\n\n    # Fallback: linear interpolation (if no G-code data available)\n    progress_ratio = None\n    if total_layers and total_layers > 0:\n        progress_ratio = min(current_layer / total_layers, 1.0)\n    elif progress_ratio_from_event is not None:\n        progress_ratio = progress_ratio_from_event\n\n    if progress_ratio is None:\n        logger.debug(\n            \"[SPOOLMAN] Cannot use linear fallback: total_layers=%s, last_progress=%s\",\n            total_layers,\n            last_progress,\n        )\n        return\n\n    logger.info(\"[SPOOLMAN] Falling back to linear interpolation (%s)\", progress_ratio)\n\n    usage_items = []\n    for usage in filament_usage:\n        slot_id = usage.get(\"slot_id\", 0)\n        total_used_g = usage.get(\"used_g\", 0)\n        if total_used_g > 0:\n            partial_used_g = round(total_used_g * progress_ratio, 2)\n            usage_items.append((slot_id, partial_used_g))\n\n    spools_updated = await _report_spool_usage_for_slots(\n        client, usage_items, ams_trays, slot_to_tray, \"Partial (linear)\", printer_serial\n    )\n    if spools_updated > 0:\n        logger.info(\"[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation\", spools_updated)\n\n\nasync def report_usage(printer_id: int, archive_id: int):\n    \"\"\"Report filament usage to Spoolman after print completion.\n\n    Uses per-filament usage data captured at print start to report\n    usage to the correct spools.\n    \"\"\"\n    async with async_session() as db:\n        from backend.app.api.routes.settings import get_setting\n        from backend.app.models.active_print_spoolman import ActivePrintSpoolman\n\n        # Get tracking data stored at print start\n        result = await db.execute(\n            select(ActivePrintSpoolman)\n            .where(ActivePrintSpoolman.printer_id == printer_id)\n            .where(ActivePrintSpoolman.archive_id == archive_id)\n        )\n        tracking = result.scalar_one_or_none()\n\n        if not tracking:\n            logger.info(\"[SPOOLMAN] No tracking data for print (printer=%s, archive=%s)\", printer_id, archive_id)\n            return\n\n        filament_usage = tracking.filament_usage or []\n        ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}\n        slot_to_tray = tracking.slot_to_tray\n        printer_serial = await _get_printer_serial(printer_id)\n\n        # Delete tracking row (we're done with it)\n        await db.delete(tracking)\n        await db.commit()\n\n        if not filament_usage:\n            logger.debug(\"[SPOOLMAN] No filament usage data for archive %s\", archive_id)\n            return\n\n        # Check if Spoolman is enabled\n        spoolman_enabled = await get_setting(db, \"spoolman_enabled\")\n        if not spoolman_enabled or spoolman_enabled.lower() != \"true\":\n            return\n\n        client = await _get_spoolman_client_with_fallback()\n        if not client:\n            logger.warning(\"[SPOOLMAN] Not reachable for usage reporting\")\n            return\n\n        logger.info(\"[SPOOLMAN] Reporting per-filament usage for archive %s\", archive_id)\n\n        usage_items = [(u.get(\"slot_id\", 0), u.get(\"used_g\", 0)) for u in filament_usage]\n        spools_updated = await _report_spool_usage_for_slots(\n            client, usage_items, ams_trays, slot_to_tray, f\"Archive {archive_id}\", printer_serial\n        )\n\n        if spools_updated == 0:\n            logger.info(\"[SPOOLMAN] Archive %s: no spools updated\", archive_id)\n        else:\n            logger.info(\"[SPOOLMAN] Archive %s: updated %s spool(s)\", archive_id, spools_updated)\n"
  },
  {
    "path": "backend/app/services/stl_thumbnail.py",
    "content": "\"\"\"STL Thumbnail Generation Service.\n\nGenerates thumbnail images from STL files using trimesh and matplotlib.\n\"\"\"\n\nimport logging\nimport uuid\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n# Bambu green color for rendering\nBAMBU_GREEN = \"#00AE42\"\nBACKGROUND_COLOR = \"#1a1a1a\"\n\n# Maximum vertices before simplification\nMAX_VERTICES = 100000\n\n\ndef generate_stl_thumbnail(\n    stl_path: Path,\n    thumbnails_dir: Path,\n    size: int = 256,\n) -> str | None:\n    \"\"\"Generate a thumbnail image from an STL file.\n\n    Args:\n        stl_path: Path to the STL file\n        thumbnails_dir: Directory to save the thumbnail\n        size: Thumbnail size in pixels (default 256x256)\n\n    Returns:\n        Path to the generated thumbnail, or None on failure\n    \"\"\"\n    try:\n        import matplotlib\n        import trimesh\n\n        # Use Agg backend for headless rendering\n        matplotlib.use(\"Agg\")\n        import matplotlib.pyplot as plt\n        from mpl_toolkits.mplot3d import Axes3D  # noqa: F401\n        from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n\n        # Load the STL file\n        mesh = trimesh.load(str(stl_path), force=\"mesh\")\n\n        if mesh is None or not hasattr(mesh, \"vertices\") or len(mesh.vertices) == 0:\n            logger.warning(\"Failed to load STL or empty mesh: %s\", stl_path)\n            return None\n\n        # Simplify large meshes for performance\n        if len(mesh.vertices) > MAX_VERTICES:\n            logger.info(\"Simplifying mesh from %s vertices\", len(mesh.vertices))\n            try:\n                # Calculate reduction ratio (0-1 range)\n                # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%\n                keep_ratio = MAX_VERTICES / len(mesh.vertices)\n                target_reduction = 1.0 - keep_ratio\n                # Clamp to valid range (0.01 to 0.99)\n                target_reduction = max(0.01, min(0.99, target_reduction))\n                mesh = mesh.simplify_quadric_decimation(target_reduction)\n                logger.info(\"Simplified mesh to %s vertices\", len(mesh.vertices))\n            except Exception as e:\n                logger.warning(\"Mesh simplification failed, using original: %s\", e)\n\n        # Get mesh bounds and center it\n        vertices = mesh.vertices\n        bounds_min = vertices.min(axis=0)\n        bounds_max = vertices.max(axis=0)\n        center = (bounds_min + bounds_max) / 2\n        vertices_centered = vertices - center\n\n        # Scale to fit in view\n        max_extent = (bounds_max - bounds_min).max()\n        if max_extent > 0:\n            scale = 1.0 / max_extent\n            vertices_scaled = vertices_centered * scale\n        else:\n            vertices_scaled = vertices_centered\n\n        # Create figure with dark background\n        fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)\n        fig.patch.set_facecolor(BACKGROUND_COLOR)\n\n        ax = fig.add_subplot(111, projection=\"3d\")\n        ax.set_facecolor(BACKGROUND_COLOR)\n\n        # Create polygon collection from mesh faces\n        faces = mesh.faces\n        poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces]\n\n        collection = Poly3DCollection(\n            poly3d,\n            facecolors=BAMBU_GREEN,\n            edgecolors=BAMBU_GREEN,\n            linewidths=0.1,\n            alpha=0.9,\n        )\n        ax.add_collection3d(collection)\n\n        # Set axis limits\n        ax.set_xlim(-0.6, 0.6)\n        ax.set_ylim(-0.6, 0.6)\n        ax.set_zlim(-0.6, 0.6)\n\n        # Set view angle (isometric-ish)\n        ax.view_init(elev=25, azim=45)\n\n        # Remove axes and grid\n        ax.set_axis_off()\n        ax.grid(False)\n\n        # Remove margins\n        plt.subplots_adjust(left=0, right=1, top=1, bottom=0)\n\n        # Save thumbnail\n        thumb_filename = f\"{uuid.uuid4().hex}.png\"\n        thumb_path = thumbnails_dir / thumb_filename\n\n        fig.savefig(\n            thumb_path,\n            format=\"png\",\n            facecolor=BACKGROUND_COLOR,\n            edgecolor=\"none\",\n            bbox_inches=\"tight\",\n            pad_inches=0.05,\n            dpi=100,\n        )\n        plt.close(fig)\n\n        logger.info(\"Generated STL thumbnail: %s\", thumb_path)\n        return str(thumb_path)\n\n    except ImportError as e:\n        logger.warning(\"STL thumbnail generation unavailable (missing dependencies): %s\", e)\n        return None\n    except Exception as e:\n        logger.warning(\"Failed to generate STL thumbnail for %s: %s\", stl_path, e)\n        return None\n"
  },
  {
    "path": "backend/app/services/tasmota.py",
    "content": "\"\"\"Service for communicating with Tasmota devices via HTTP API.\"\"\"\n\nimport ipaddress\nimport logging\nfrom typing import TYPE_CHECKING\n\nimport httpx\n\nif TYPE_CHECKING:\n    from backend.app.models.smart_plug import SmartPlug\n\nlogger = logging.getLogger(__name__)\n\n\nclass TasmotaService:\n    \"\"\"Service for communicating with Tasmota devices via HTTP API.\"\"\"\n\n    def __init__(self, timeout: float = 5.0):\n        self.timeout = timeout\n\n    def _build_url(self, ip: str, command: str) -> str:\n        \"\"\"Build Tasmota command URL.\"\"\"\n        # URL encode the command\n        cmd = command.replace(\" \", \"%20\")\n        return f\"http://{ip}/cm?cmnd={cmd}\"\n\n    @staticmethod\n    def _validate_ip(ip: str) -> bool:\n        \"\"\"Block cloud metadata and link-local IPs.\"\"\"\n        try:\n            addr = ipaddress.ip_address(ip)\n        except ValueError:\n            return False  # Not a valid IP\n        return not addr.is_loopback and not addr.is_link_local\n\n    async def _send_command(\n        self,\n        ip: str,\n        command: str,\n        username: str | None = None,\n        password: str | None = None,\n    ) -> dict | None:\n        \"\"\"Send a command to a Tasmota device and return the response.\"\"\"\n        if not self._validate_ip(ip):\n            logger.warning(\"Blocked Tasmota request to invalid IP: %s\", ip)\n            return None\n        url = self._build_url(ip, command)\n        auth = (username, password) if username and password else None\n\n        try:\n            async with httpx.AsyncClient(timeout=self.timeout) as client:\n                response = await client.get(url, auth=auth)\n                response.raise_for_status()\n                return response.json()\n        except httpx.TimeoutException:\n            logger.warning(\"Tasmota device at %s timed out\", ip)\n            return None\n        except httpx.HTTPStatusError as e:\n            logger.warning(\"Tasmota device at %s returned error: %s\", ip, e)\n            return None\n        except httpx.RequestError as e:\n            logger.warning(\"Failed to connect to Tasmota device at %s: %s\", ip, e)\n            return None\n        except Exception as e:\n            logger.error(\"Unexpected error communicating with Tasmota at %s: %s\", ip, e)\n            return None\n\n    async def get_status(self, plug: \"SmartPlug\") -> dict:\n        \"\"\"Get current power state and device info.\n\n        Returns dict with:\n            - state: \"ON\" or \"OFF\" or None if unreachable\n            - reachable: bool\n            - device_name: str or None\n        \"\"\"\n        result = await self._send_command(plug.ip_address, \"Power\", plug.username, plug.password)\n\n        if result is None:\n            return {\"state\": None, \"reachable\": False, \"device_name\": None}\n\n        # Response format: {\"POWER\":\"ON\"} or {\"POWER\":\"OFF\"}\n        # Some devices use {\"POWER1\":\"ON\"} for multi-relay\n        state = None\n        for key in [\"POWER\", \"POWER1\"]:\n            if key in result:\n                state = result[key]\n                break\n\n        return {\"state\": state, \"reachable\": True, \"device_name\": None}\n\n    async def turn_on(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Turn on the plug. Returns True if successful.\"\"\"\n        result = await self._send_command(plug.ip_address, \"Power On\", plug.username, plug.password)\n\n        if result is None:\n            return False\n\n        # Check if the command was successful\n        state = result.get(\"POWER\") or result.get(\"POWER1\")\n        success = state == \"ON\"\n\n        if success:\n            logger.info(\"Turned ON smart plug '%s' at %s\", plug.name, plug.ip_address)\n        else:\n            logger.warning(\"Failed to turn ON smart plug '%s' at %s\", plug.name, plug.ip_address)\n\n        return success\n\n    async def turn_off(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Turn off the plug. Returns True if successful.\"\"\"\n        result = await self._send_command(plug.ip_address, \"Power Off\", plug.username, plug.password)\n\n        if result is None:\n            return False\n\n        # Check if the command was successful\n        state = result.get(\"POWER\") or result.get(\"POWER1\")\n        success = state == \"OFF\"\n\n        if success:\n            logger.info(\"Turned OFF smart plug '%s' at %s\", plug.name, plug.ip_address)\n        else:\n            logger.warning(\"Failed to turn OFF smart plug '%s' at %s\", plug.name, plug.ip_address)\n\n        return success\n\n    async def toggle(self, plug: \"SmartPlug\") -> bool:\n        \"\"\"Toggle the plug state. Returns True if successful.\"\"\"\n        result = await self._send_command(plug.ip_address, \"Power Toggle\", plug.username, plug.password)\n\n        if result is None:\n            return False\n\n        state = result.get(\"POWER\") or result.get(\"POWER1\")\n        success = state in [\"ON\", \"OFF\"]\n\n        if success:\n            logger.info(\"Toggled smart plug '%s' at %s to %s\", plug.name, plug.ip_address, state)\n\n        return success\n\n    async def get_energy(self, plug: \"SmartPlug\") -> dict | None:\n        \"\"\"Get energy monitoring data from the plug.\n\n        Returns dict with energy data or None if not available:\n            - power: Current power in watts\n            - voltage: Voltage in V\n            - current: Current in A\n            - today: Energy used today in kWh\n            - total: Total energy in kWh\n            - factor: Power factor (0-1)\n        \"\"\"\n        result = await self._send_command(plug.ip_address, \"Status 8\", plug.username, plug.password)\n\n        if result is None:\n            return None\n\n        # Response format: {\"StatusSNS\":{\"ENERGY\":{...}}}\n        status_sns = result.get(\"StatusSNS\", {})\n        energy = status_sns.get(\"ENERGY\")\n\n        if not energy:\n            # Device doesn't have energy monitoring\n            return None\n\n        return {\n            \"power\": energy.get(\"Power\"),  # Current watts\n            \"voltage\": energy.get(\"Voltage\"),  # Volts\n            \"current\": energy.get(\"Current\"),  # Amps\n            \"today\": energy.get(\"Today\"),  # kWh today\n            \"yesterday\": energy.get(\"Yesterday\"),  # kWh yesterday\n            \"total\": energy.get(\"Total\"),  # Total kWh\n            \"factor\": energy.get(\"Factor\"),  # Power factor\n            \"apparent_power\": energy.get(\"ApparentPower\"),  # VA\n            \"reactive_power\": energy.get(\"ReactivePower\"),  # VAr\n        }\n\n    async def test_connection(\n        self,\n        ip: str,\n        username: str | None = None,\n        password: str | None = None,\n    ) -> dict:\n        \"\"\"Test connection to a Tasmota device.\n\n        Returns dict with:\n            - success: bool\n            - state: current power state or None\n            - device_name: device name or None\n            - error: error message if failed\n        \"\"\"\n        # Try to get power status\n        result = await self._send_command(ip, \"Power\", username, password)\n\n        if result is None:\n            return {\n                \"success\": False,\n                \"state\": None,\n                \"device_name\": None,\n                \"error\": \"Could not connect to device\",\n            }\n\n        state = result.get(\"POWER\") or result.get(\"POWER1\")\n\n        # Try to get device name\n        status_result = await self._send_command(ip, \"Status 0\", username, password)\n        device_name = None\n        if status_result and \"Status\" in status_result:\n            device_name = status_result[\"Status\"].get(\"DeviceName\")\n\n        return {\n            \"success\": True,\n            \"state\": state,\n            \"device_name\": device_name,\n            \"error\": None,\n        }\n\n\n# Singleton instance\ntasmota_service = TasmotaService()\n"
  },
  {
    "path": "backend/app/services/timelapse_processor.py",
    "content": "\"\"\"Timelapse video processing service using FFmpeg.\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport tempfile\nfrom pathlib import Path\n\nfrom backend.app.services.camera import get_ffmpeg_path\n\nlogger = logging.getLogger(__name__)\n\n\nclass TimelapseProcessor:\n    \"\"\"Service for processing timelapse videos with FFmpeg.\"\"\"\n\n    def __init__(self, input_path: Path):\n        self.input_path = input_path\n        self.ffmpeg = get_ffmpeg_path()\n        if not self.ffmpeg:\n            raise RuntimeError(\"FFmpeg not found\")\n        # Derive ffprobe path from ffmpeg path\n        self.ffprobe = self.ffmpeg.replace(\"ffmpeg\", \"ffprobe\")\n\n    async def get_info(self) -> dict:\n        \"\"\"Get video metadata using ffprobe.\"\"\"\n        cmd = [\n            self.ffprobe,\n            \"-v\",\n            \"quiet\",\n            \"-print_format\",\n            \"json\",\n            \"-show_format\",\n            \"-show_streams\",\n            str(self.input_path),\n        ]\n\n        process = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await process.communicate()\n\n        if process.returncode != 0:\n            logger.error(\"ffprobe failed: %s\", stderr.decode())\n            raise RuntimeError(f\"ffprobe failed: {stderr.decode()}\")\n\n        data = json.loads(stdout.decode())\n        video_stream = next(\n            (s for s in data.get(\"streams\", []) if s.get(\"codec_type\") == \"video\"),\n            {},\n        )\n        audio_stream = next(\n            (s for s in data.get(\"streams\", []) if s.get(\"codec_type\") == \"audio\"),\n            None,\n        )\n\n        # Parse frame rate (can be \"30/1\" or \"29.97\")\n        fps = 30.0\n        r_frame_rate = video_stream.get(\"r_frame_rate\", \"30/1\")\n        try:\n            if \"/\" in r_frame_rate:\n                num, den = r_frame_rate.split(\"/\")\n                fps = float(num) / float(den)\n            else:\n                fps = float(r_frame_rate)\n        except (ValueError, ZeroDivisionError):\n            pass  # Keep default fps if frame rate string is unparseable\n\n        return {\n            \"duration\": float(data.get(\"format\", {}).get(\"duration\", 0)),\n            \"width\": video_stream.get(\"width\", 0),\n            \"height\": video_stream.get(\"height\", 0),\n            \"fps\": fps,\n            \"codec\": video_stream.get(\"codec_name\", \"unknown\"),\n            \"file_size\": int(data.get(\"format\", {}).get(\"size\", 0)),\n            \"has_audio\": audio_stream is not None,\n        }\n\n    async def generate_thumbnails(\n        self,\n        count: int = 10,\n        width: int = 160,\n    ) -> list[tuple[float, bytes]]:\n        \"\"\"Generate evenly-spaced thumbnail frames.\"\"\"\n        info = await self.get_info()\n        duration = info[\"duration\"]\n\n        if duration <= 0:\n            return []\n\n        interval = duration / max(count, 1)\n        thumbnails = []\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            for i in range(count):\n                timestamp = i * interval\n                output_path = Path(tmpdir) / f\"thumb_{i:03d}.jpg\"\n\n                cmd = [\n                    self.ffmpeg,\n                    \"-y\",\n                    \"-ss\",\n                    str(timestamp),\n                    \"-i\",\n                    str(self.input_path),\n                    \"-vframes\",\n                    \"1\",\n                    \"-vf\",\n                    f\"scale={width}:-1\",\n                    \"-q:v\",\n                    \"5\",\n                    str(output_path),\n                ]\n\n                process = await asyncio.create_subprocess_exec(\n                    *cmd,\n                    stdout=asyncio.subprocess.PIPE,\n                    stderr=asyncio.subprocess.PIPE,\n                )\n                await process.communicate()\n\n                if output_path.exists():\n                    thumbnails.append((timestamp, output_path.read_bytes()))\n\n        return thumbnails\n\n    async def process(\n        self,\n        output_path: Path,\n        trim_start: float = 0,\n        trim_end: float | None = None,\n        speed: float = 1.0,\n        audio_path: Path | None = None,\n        audio_volume: float = 1.0,\n    ) -> bool:\n        \"\"\"Process video with trim, speed, and optional audio overlay.\n\n        Args:\n            output_path: Where to save the processed video\n            trim_start: Start time in seconds\n            trim_end: End time in seconds (None = full duration)\n            speed: Speed multiplier (0.25 to 4.0)\n            audio_path: Optional music file to overlay\n            audio_volume: Volume for audio overlay (0.0 to 1.0)\n\n        Returns:\n            True if processing succeeded, False otherwise\n        \"\"\"\n        # Build FFmpeg command\n        cmd = [self.ffmpeg, \"-y\"]\n\n        # Input seeking (fast seek before input)\n        if trim_start > 0:\n            cmd.extend([\"-ss\", str(trim_start)])\n\n        cmd.extend([\"-i\", str(self.input_path)])\n\n        # Add audio input if provided\n        if audio_path:\n            cmd.extend([\"-i\", str(audio_path)])\n\n        # Duration limit\n        if trim_end is not None and trim_end > trim_start:\n            duration = trim_end - trim_start\n            cmd.extend([\"-t\", str(duration)])\n\n        # Build filters - use filter_complex when we have audio overlay\n        video_filter = \"\"\n        if speed != 1.0:\n            # setpts changes video speed: PTS/speed = faster, PTS*speed = slower\n            setpts_value = 1.0 / speed\n            video_filter = f\"setpts={setpts_value}*PTS\"\n\n        if audio_path:\n            # Use filter_complex for audio overlay (can't mix with -vf/-af)\n            filter_parts = []\n\n            # Video filter\n            if video_filter:\n                filter_parts.append(f\"[0:v]{video_filter}[v]\")\n                video_out = \"[v]\"\n            else:\n                video_out = \"0:v\"\n\n            # Audio filter with volume\n            filter_parts.append(f\"[1:a]volume={audio_volume}[a]\")\n\n            cmd.extend([\"-filter_complex\", \";\".join(filter_parts)])\n            cmd.extend([\"-map\", video_out, \"-map\", \"[a]\"])\n            cmd.extend([\"-shortest\"])\n        elif speed != 1.0:\n            # No audio overlay - use simple -vf and -af\n            if video_filter:\n                cmd.extend([\"-vf\", video_filter])\n            # Adjust original audio speed with atempo\n            atempo_chain = self._build_atempo_chain(speed)\n            if atempo_chain:\n                cmd.extend([\"-af\", atempo_chain])\n\n        # Output settings\n        cmd.extend(\n            [\n                \"-c:v\",\n                \"libx264\",\n                \"-preset\",\n                \"fast\",\n                \"-crf\",\n                \"23\",\n                \"-c:a\",\n                \"aac\",\n                \"-b:a\",\n                \"128k\",\n                \"-movflags\",\n                \"+faststart\",  # Enable streaming\n                str(output_path),\n            ]\n        )\n\n        logger.info(\"Processing timelapse: %s\", \" \".join(cmd))\n\n        # Run FFmpeg\n        process = await asyncio.create_subprocess_exec(\n            *cmd,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n\n        _, stderr = await process.communicate()\n\n        if process.returncode != 0:\n            logger.error(\"FFmpeg processing failed: %s\", stderr.decode())\n            return False\n\n        return output_path.exists()\n\n    def _build_atempo_chain(self, speed: float) -> str:\n        \"\"\"Build atempo filter chain.\n\n        atempo filter only supports values between 0.5 and 2.0,\n        so we chain multiple filters for extreme speeds.\n        \"\"\"\n        if speed == 1.0:\n            return \"\"\n\n        filters = []\n        remaining_speed = speed\n\n        # Handle speeds > 2.0 by chaining atempo=2.0\n        while remaining_speed > 2.0:\n            filters.append(\"atempo=2.0\")\n            remaining_speed /= 2.0\n\n        # Handle speeds < 0.5 by chaining atempo=0.5\n        while remaining_speed < 0.5:\n            filters.append(\"atempo=0.5\")\n            remaining_speed *= 2.0\n\n        # Add final atempo for remaining adjustment\n        # After the while loops above, remaining_speed is guaranteed to be in [0.5, 2.0]\n        if remaining_speed != 1.0:\n            filters.append(f\"atempo={remaining_speed:.4f}\")\n\n        return \",\".join(filters)\n"
  },
  {
    "path": "backend/app/services/usage_tracker.py",
    "content": "\"\"\"Automatic filament consumption tracking.\n\nCaptures AMS tray remain% at print start, then computes consumption\ndeltas at print complete to update spool weight_used and last_used.\n\nPrimary tracking uses 3MF slicer estimates (precise per-filament data).\nAMS remain% delta is the fallback for trays not covered by 3MF data.\n\"\"\"\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\n\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.spool import Spool\nfrom backend.app.models.spool_assignment import SpoolAssignment\nfrom backend.app.models.spool_usage_history import SpoolUsageHistory\n\nlogger = logging.getLogger(__name__)\n\n\ndef _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:\n    \"\"\"Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.\n\n    The printer's MQTT mapping field is an array indexed by slicer filament slot\n    (0-based). Each value uses snow encoding: ams_hw_id * 256 + local_slot.\n    65535 means unmapped.\n\n    Returns a list of bambuddy global tray IDs (or -1 for unmapped), or None if\n    no valid mappings found.\n    \"\"\"\n    if not isinstance(mapping_raw, list) or not mapping_raw:\n        return None\n\n    result = []\n    for value in mapping_raw:\n        if not isinstance(value, int) or value >= 65535:\n            result.append(-1)\n            continue\n\n        ams_hw_id = value >> 8\n        slot = value & 0xFF\n\n        if 0 <= ams_hw_id <= 3:\n            # Regular AMS: sequential global ID\n            result.append(ams_hw_id * 4 + (slot & 0x03))\n        elif 128 <= ams_hw_id <= 135:\n            # AMS-HT: global ID is the hardware ID (one slot per unit)\n            result.append(ams_hw_id)\n        elif ams_hw_id in (254, 255):\n            # External spool\n            result.append(254 if slot != 255 else 255)\n        else:\n            result.append(-1)\n\n    # Only return if at least one valid mapping exists\n    if all(v < 0 for v in result):\n        return None\n\n    return result\n\n\ndef _match_slots_by_color(\n    filament_usage: list[dict],\n    ams_raw: dict | list | None,\n) -> list[int] | None:\n    \"\"\"Match 3MF filament slots to AMS trays by color.\n\n    Fallback mapping for printers that don't provide the MQTT mapping field\n    or request topic subscription (e.g. A1, A1 Mini, P1S, P2S).\n\n    Compares the 3MF slicer filament color (per slot) against each AMS tray's\n    color to find a unique match. Only returns a mapping if every used slot\n    matches exactly one tray (no ambiguity).\n\n    Args:\n        filament_usage: List of 3MF slot dicts with 'slot_id', 'color', 'type'\n        ams_raw: raw_data[\"ams\"] dict or list from printer state\n\n    Returns:\n        List of global tray IDs indexed by slicer slot (0-based), or None.\n    \"\"\"\n    if not filament_usage or not ams_raw:\n        return None\n\n    ams_data = ams_raw.get(\"ams\", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []\n    if not ams_data:\n        return None\n\n    # Build map of normalized color → list of global tray IDs\n    color_to_trays: dict[str, list[int]] = {}\n    for ams_unit in ams_data:\n        ams_id = int(ams_unit.get(\"id\", 0))\n        for tray in ams_unit.get(\"tray\", []):\n            tray_id = int(tray.get(\"id\", 0))\n            tray_color = tray.get(\"tray_color\", \"\")\n            tray_type = tray.get(\"tray_type\", \"\")\n            if not tray_color or not tray_type:\n                continue\n            # Normalize AMS color: strip alpha (last 2 chars), lowercase\n            norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()\n            if ams_id >= 128:\n                global_id = ams_id  # AMS-HT\n            else:\n                global_id = ams_id * 4 + tray_id\n            color_to_trays.setdefault(norm, []).append(global_id)\n\n    if not color_to_trays:\n        return None\n\n    # Find max slot_id to size the result array\n    max_slot = max(u.get(\"slot_id\", 0) for u in filament_usage)\n    if max_slot <= 0:\n        return None\n\n    result = [-1] * max_slot\n    used_trays: set[int] = set()\n\n    for usage in filament_usage:\n        slot_id = usage.get(\"slot_id\", 0)\n        if slot_id <= 0:\n            continue\n        slot_color = usage.get(\"color\", \"\").lstrip(\"#\").lower()\n        if len(slot_color) < 6:\n            return None  # Can't match without a valid color\n\n        slot_color = slot_color[:6]  # Strip alpha if present\n        candidates = color_to_trays.get(slot_color, [])\n        # Filter out trays already claimed by another slot\n        available = [t for t in candidates if t not in used_trays]\n\n        if len(available) != 1:\n            # Ambiguous (multiple trays with same color) or no match\n            return None\n\n        result[slot_id - 1] = available[0]\n        used_trays.add(available[0])\n\n    # Only return if at least one valid mapping exists\n    if all(v < 0 for v in result):\n        return None\n\n    logger.info(\"[UsageTracker] Color-matched slot_to_tray: %s\", result)\n    return result\n\n\n@dataclass\nclass PrintSession:\n    printer_id: int\n    print_name: str\n    started_at: datetime\n    tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)\n    # tray_now at print start (correct value, unlike at completion where it's 255)\n    tray_now_at_start: int = -1\n    # Snapshot of spool assignments at print start: {(ams_id, tray_id): spool_id}\n    # Prevents usage loss when on_ams_change unlinks a spool mid-print\n    spool_assignments: dict[tuple[int, int], int] = field(default_factory=dict)\n    # AMS mapping from print command (captured at start, needed when auto-archive is off)\n    ams_mapping: list[int] | None = None\n\n\n# Module-level storage, keyed by printer_id\n_active_sessions: dict[int, PrintSession] = {}\n\n\ndef _to_epoch_seconds(value: datetime | None) -> float | None:\n    \"\"\"Convert datetime to epoch seconds, assuming UTC for naive values.\"\"\"\n    if value is None:\n        return None\n    dt = value\n    if dt.tzinfo is None:\n        dt = dt.replace(tzinfo=timezone.utc)\n    return dt.timestamp()\n\n\nasync def _resolve_spool_id_for_tray(\n    printer_id: int,\n    ams_id: int,\n    tray_id: int,\n    db: AsyncSession,\n    spool_assignments_snapshot: dict[tuple[int, int], int] | None = None,\n    print_started_at: datetime | None = None,\n) -> int | None:\n    \"\"\"Resolve spool ID for a tray with safe support for mid-print reassignment.\n\n    Resolution order:\n    1. If snapshot exists and live assignment changed *during this print*, use live spool.\n    2. Otherwise use snapshot spool when available.\n    3. Fall back to live assignment.\n    \"\"\"\n    key = (ams_id, tray_id)\n    snapshot_spool_id = spool_assignments_snapshot.get(key) if spool_assignments_snapshot else None\n\n    # Backward-compatible fast path: if we have a snapshot but no print-start\n    # timestamp, preserve legacy behavior and avoid extra DB lookups.\n    if snapshot_spool_id is not None and print_started_at is None:\n        return snapshot_spool_id\n\n    result = await db.execute(\n        select(SpoolAssignment).where(\n            SpoolAssignment.printer_id == printer_id,\n            SpoolAssignment.ams_id == ams_id,\n            SpoolAssignment.tray_id == tray_id,\n        )\n    )\n    live_assignment = result.scalar_one_or_none()\n\n    if snapshot_spool_id is not None:\n        if live_assignment and live_assignment.spool_id != snapshot_spool_id:\n            live_created_ts = _to_epoch_seconds(getattr(live_assignment, \"created_at\", None))\n            started_ts = _to_epoch_seconds(print_started_at)\n            if live_created_ts is not None and started_ts is not None and live_created_ts >= started_ts:\n                logger.info(\n                    \"[UsageTracker] Assignment changed during print for printer %d AMS%d-T%d: snapshot spool %d -> live spool %d\",\n                    printer_id,\n                    ams_id,\n                    tray_id,\n                    snapshot_spool_id,\n                    live_assignment.spool_id,\n                )\n                return live_assignment.spool_id\n        return snapshot_spool_id\n\n    if live_assignment:\n        return live_assignment.spool_id\n\n    return None\n\n\nasync def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:\n    \"\"\"Capture AMS tray remain% and spool assignments at print start.\"\"\"\n    state = printer_manager.get_status(printer_id)\n    if not state or not state.raw_data:\n        logger.debug(\"[UsageTracker] No state for printer %d, skipping\", printer_id)\n        return\n\n    ams_raw = state.raw_data.get(\"ams\", [])\n    ams_data = ams_raw.get(\"ams\", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []\n\n    tray_remain_start: dict[tuple[int, int], int] = {}\n    skipped_invalid: list[str] = []\n\n    for ams_unit in ams_data:\n        ams_id = int(ams_unit.get(\"id\", 0))\n        for tray in ams_unit.get(\"tray\", []):\n            tray_id = int(tray.get(\"id\", 0))\n            remain = tray.get(\"remain\", -1)\n            if isinstance(remain, int) and 0 <= remain <= 100:\n                tray_remain_start[(ams_id, tray_id)] = remain\n            else:\n                skipped_invalid.append(f\"AMS{ams_id}-T{tray_id}(remain={remain})\")\n\n    # Also capture VT (external) tray remain% — these are separate from AMS units\n    vt_tray_raw = state.raw_data.get(\"vt_tray\") or []\n    if isinstance(vt_tray_raw, dict):\n        vt_tray_raw = [vt_tray_raw]\n    for vt in vt_tray_raw:\n        if not isinstance(vt, dict):\n            continue\n        vt_id = int(vt.get(\"id\", 254))\n        # VT tray id 254 → (ams_id=255, tray_id=0), id 255 → (ams_id=255, tray_id=1)\n        vt_tray_id = vt_id - 254\n        remain = vt.get(\"remain\", -1)\n        if isinstance(remain, int) and 0 <= remain <= 100:\n            tray_remain_start[(255, vt_tray_id)] = remain\n        else:\n            skipped_invalid.append(f\"VT{vt_id}(remain={remain})\")\n\n    if skipped_invalid:\n        logger.info(\n            \"[UsageTracker] Skipped trays with invalid remain%% for printer %d: %s\",\n            printer_id,\n            \", \".join(skipped_invalid),\n        )\n\n    if not ams_data and not vt_tray_raw:\n        logger.debug(\"[UsageTracker] No AMS or VT tray data for printer %d, skipping\", printer_id)\n        return\n\n    print_name = data.get(\"subtask_name\", \"\") or data.get(\"filename\", \"unknown\")\n\n    # Capture tray_now at print start (reliable, unlike at completion where it's 255)\n    tray_now_at_start = state.tray_now if state else -1\n\n    # --- Diagnostic logging: dump mapping-related MQTT fields at print start ---\n    # This helps us understand what each printer model reports for slot-to-tray mapping.\n    mapping_field = state.raw_data.get(\"mapping\")\n    logger.info(\n        \"[UsageTracker] PRINT START printer %d: mapping=%s, tray_now=%d, last_loaded_tray=%s\",\n        printer_id,\n        mapping_field,\n        tray_now_at_start,\n        getattr(state, \"last_loaded_tray\", \"N/A\"),\n    )\n    # Log all raw_data keys containing \"map\" or \"ams\" for discovery\n    map_keys = {k: state.raw_data[k] for k in state.raw_data if \"map\" in k.lower()}\n    if map_keys:\n        logger.info(\"[UsageTracker] PRINT START printer %d: mapping-related keys: %s\", printer_id, map_keys)\n    # Log per-tray summary: tray_now, tray_tar, tray_type, tray_color for each slot\n    for ams_unit in ams_data:\n        ams_id = int(ams_unit.get(\"id\", 0))\n        tray_summary = []\n        for tray in ams_unit.get(\"tray\", []):\n            tray_summary.append(\n                f\"T{tray.get('id', '?')}(type={tray.get('tray_type', '')}, \"\n                f\"color={tray.get('tray_color', '')}, \"\n                f\"now={ams_raw.get('tray_now', '?') if isinstance(ams_raw, dict) else '?'}, \"\n                f\"tar={ams_raw.get('tray_tar', '?') if isinstance(ams_raw, dict) else '?'})\"\n            )\n        logger.info(\"[UsageTracker] PRINT START printer %d AMS %d: %s\", printer_id, ams_id, \", \".join(tray_summary))\n\n    # Snapshot spool assignments so usage isn't lost if on_ams_change unlinks mid-print\n    spool_assignments: dict[tuple[int, int], int] = {}\n    if db:\n        assign_result = await db.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == printer_id))\n        for assignment in assign_result.scalars().all():\n            spool_assignments[(assignment.ams_id, assignment.tray_id)] = assignment.spool_id\n        if spool_assignments:\n            logger.info(\n                \"[UsageTracker] Snapshotted %d spool assignments for printer %d: %s\",\n                len(spool_assignments),\n                printer_id,\n                {f\"{k[0]}-{k[1]}\": v for k, v in spool_assignments.items()},\n            )\n\n    # Always create session (even without valid remain data) so print_name\n    # is available at completion for 3MF-based tracking\n    session = PrintSession(\n        printer_id=printer_id,\n        print_name=print_name,\n        started_at=datetime.now(timezone.utc),\n        tray_remain_start=tray_remain_start,\n        tray_now_at_start=tray_now_at_start,\n        spool_assignments=spool_assignments,\n        ams_mapping=data.get(\"ams_mapping\"),\n    )\n    _active_sessions[printer_id] = session\n\n    if tray_remain_start:\n        logger.info(\n            \"[UsageTracker] Captured start remain%% for printer %d (%d trays): %s\",\n            printer_id,\n            len(tray_remain_start),\n            {f\"{k[0]}-{k[1]}\": v for k, v in tray_remain_start.items()},\n        )\n    else:\n        logger.debug(\"[UsageTracker] No valid remain%% for printer %d, 3MF fallback available\", printer_id)\n\n\nasync def on_print_complete(\n    printer_id: int,\n    data: dict,\n    printer_manager,\n    db: AsyncSession,\n    archive_id: int | None = None,\n    ams_mapping: list[int] | None = None,\n) -> list[dict]:\n    \"\"\"Compute consumption deltas and update spool weight_used/last_used.\n\n    Uses two tracking strategies in priority order:\n    1. 3MF per-filament estimates (primary) — precise slicer data for all spools\n    2. AMS remain% delta (fallback) — only for trays not already handled by 3MF\n\n    Returns a list of dicts describing what was logged (for WebSocket broadcast).\n    \"\"\"\n    from sqlalchemy import select\n\n    from backend.app.api.routes.settings import get_setting\n    from backend.app.models.spool_usage_history import SpoolUsageHistory\n\n    session = _active_sessions.pop(printer_id, None)\n    status = data.get(\"status\", \"completed\")\n    results = []\n    handled_trays: set[tuple[int, int]] = set()\n\n    # Fetch default filament cost from settings for fallback\n    default_cost_str = await get_setting(db, \"default_filament_cost\")\n    default_filament_cost = float(default_cost_str) if default_cost_str else 0.0\n\n    # Fall back to ams_mapping captured at print start (needed when auto-archive is off\n    # and the caller can't retrieve the mapping from _print_ams_mappings without archive_id)\n    if not ams_mapping and session and session.ams_mapping:\n        ams_mapping = session.ams_mapping\n\n    logger.info(\n        \"[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s\",\n        printer_id,\n        archive_id,\n        \"yes\" if session else \"no\",\n        ams_mapping,\n    )\n\n    # --- Diagnostic logging: dump mapping-related MQTT fields at print completion ---\n    state = printer_manager.get_status(printer_id)\n    if state and state.raw_data:\n        logger.info(\n            \"[UsageTracker] PRINT COMPLETE printer %d: mapping=%s, tray_now=%s, last_loaded_tray=%s\",\n            printer_id,\n            state.raw_data.get(\"mapping\"),\n            state.tray_now,\n            getattr(state, \"last_loaded_tray\", \"N/A\"),\n        )\n\n    # --- Path 1 (PRIMARY): 3MF per-filament estimates ---\n    print_name = (\n        (session.print_name if session else None) or data.get(\"subtask_name\", \"\") or data.get(\"filename\", \"unknown\")\n    )\n\n    # When auto-archive is disabled (archive_id=None), try to find a 3MF by filename\n    # from the library or previous archives so we can still track filament usage.\n    threemf_path = None\n    if not archive_id:\n        from backend.app.core.config import settings as app_settings\n\n        search_filename = data.get(\"filename\") or data.get(\"subtask_name\") or (session.print_name if session else \"\")\n        if search_filename:\n            threemf_path = await _find_3mf_by_filename(printer_id, search_filename, db, app_settings.base_dir)\n\n    if archive_id or threemf_path:\n        threemf_results = await _track_from_3mf(\n            printer_id,\n            archive_id,\n            status,\n            print_name,\n            handled_trays,\n            printer_manager,\n            db,\n            ams_mapping=ams_mapping,\n            tray_now_at_start=session.tray_now_at_start if session else -1,\n            last_progress=data.get(\"last_progress\", 0.0),\n            last_layer_num=data.get(\"last_layer_num\", 0),\n            default_filament_cost=default_filament_cost,\n            spool_assignments=session.spool_assignments if session else None,\n            print_started_at=session.started_at if session else None,\n            threemf_path=threemf_path,\n        )\n        results.extend(threemf_results)\n\n    # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---\n    if session and session.tray_remain_start:\n        state = printer_manager.get_status(printer_id)\n        if state and state.raw_data:\n            ams_raw = state.raw_data.get(\"ams\", [])\n            ams_data = (\n                ams_raw.get(\"ams\", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []\n            )\n\n            # Collect all trays to check: AMS trays + VT (external) trays\n            # Each entry: (ams_id_for_assignment, tray_id_for_assignment, current_remain, label)\n            trays_to_check: list[tuple[int, int, int, str]] = []\n\n            for ams_unit in ams_data:\n                ams_id = int(ams_unit.get(\"id\", 0))\n                for tray in ams_unit.get(\"tray\", []):\n                    tray_id = int(tray.get(\"id\", 0))\n                    remain = tray.get(\"remain\", -1)\n                    trays_to_check.append((ams_id, tray_id, remain, f\"AMS{ams_id}-T{tray_id}\"))\n\n            # VT (external) trays — same remain% delta logic\n            vt_tray_raw = state.raw_data.get(\"vt_tray\") or []\n            if isinstance(vt_tray_raw, dict):\n                vt_tray_raw = [vt_tray_raw]\n            for vt in vt_tray_raw:\n                if not isinstance(vt, dict):\n                    continue\n                vt_id = int(vt.get(\"id\", 254))\n                vt_tray_id = vt_id - 254  # 254→0, 255→1\n                remain = vt.get(\"remain\", -1)\n                trays_to_check.append((255, vt_tray_id, remain, f\"VT{vt_id}\"))\n\n            for assign_ams_id, assign_tray_id, current_remain, tray_label in trays_to_check:\n                key = (assign_ams_id, assign_tray_id)\n\n                if key in handled_trays:\n                    continue  # Already tracked via 3MF\n\n                if key not in session.tray_remain_start:\n                    continue\n\n                if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:\n                    logger.info(\n                        \"[UsageTracker] %s: invalid remain%% at completion (%s), skipping fallback for printer %d\",\n                        tray_label,\n                        current_remain,\n                        printer_id,\n                    )\n                    continue\n\n                start_remain = session.tray_remain_start[key]\n                delta_pct = start_remain - current_remain\n\n                if delta_pct <= 0:\n                    continue  # No consumption or tray was refilled\n\n                spool_id = await _resolve_spool_id_for_tray(\n                    printer_id=printer_id,\n                    ams_id=assign_ams_id,\n                    tray_id=assign_tray_id,\n                    db=db,\n                    spool_assignments_snapshot=session.spool_assignments,\n                    print_started_at=session.started_at,\n                )\n                if spool_id is None:\n                    logger.info(\n                        \"[UsageTracker] %s: no spool assigned, skipping fallback for printer %d\",\n                        tray_label,\n                        printer_id,\n                    )\n                    continue\n\n                # Load spool\n                spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))\n                spool = spool_result.scalar_one_or_none()\n                if not spool:\n                    continue\n\n                # Compute weight consumed\n                weight_grams = (delta_pct / 100.0) * spool.label_weight\n\n                # Update spool\n                spool.weight_used = (spool.weight_used or 0) + weight_grams\n                spool.last_used = datetime.now(timezone.utc)\n\n                # Calculate cost for this usage\n                cost = None\n                cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost\n                if cost_per_kg > 0:\n                    cost = round((weight_grams / 1000.0) * cost_per_kg, 2)\n\n                # Insert usage history record\n                history = SpoolUsageHistory(\n                    spool_id=spool.id,\n                    printer_id=printer_id,\n                    print_name=session.print_name,\n                    weight_used=round(weight_grams, 1),\n                    percent_used=delta_pct,\n                    status=status,\n                    cost=cost,\n                    archive_id=archive_id,\n                )\n                db.add(history)\n\n                handled_trays.add(key)\n                results.append(\n                    {\n                        \"spool_id\": spool.id,\n                        \"weight_used\": round(weight_grams, 1),\n                        \"percent_used\": delta_pct,\n                        \"ams_id\": assign_ams_id,\n                        \"tray_id\": assign_tray_id,\n                        \"material\": spool.material,\n                        \"cost\": cost,\n                    }\n                )\n\n                logger.info(\n                    \"[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d %s (AMS fallback, %s)\",\n                    spool.id,\n                    weight_grams,\n                    delta_pct,\n                    printer_id,\n                    tray_label,\n                    status,\n                )\n\n    if results:\n        await db.commit()\n\n    # --- Update PrintArchive.cost from THIS print session only ---\n\n    if archive_id and results:\n        from sqlalchemy import select\n\n        from backend.app.models.archive import PrintArchive\n\n        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n        archive = archive_result.scalar_one_or_none()\n        if archive:\n            total_cost = sum(r.get(\"cost\", 0) or 0 for r in results)\n            if total_cost > 0:\n                archive.cost = round(total_cost, 2)\n                await db.commit()\n\n    return results\n\n\nasync def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):\n    \"\"\"Try to find a 3MF file from library or a previous archive when the current archive has none.\n\n    This handles fallback archives (FTP download failed) where the 3MF may already exist\n    locally from a library upload or a previous successful print of the same file.\n    \"\"\"\n    from pathlib import Path\n\n    from backend.app.models.archive import PrintArchive\n    from backend.app.models.library import LibraryFile\n\n    # Derive search name from archive filename (e.g. \"benchy.3mf\" or \"benchy.gcode.3mf\")\n    search_name = archive.filename or archive.print_name\n    if not search_name:\n        return None\n    # Normalize: strip path parts, get base name\n    search_name = search_name.split(\"/\")[-1]\n    search_base = search_name.replace(\".gcode.3mf\", \"\").replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n    if not search_base:\n        return None\n\n    # 1. Try library files matching the name (match base name at file boundary)\n    try:\n        lib_result = await db.execute(\n            select(LibraryFile)\n            .where(LibraryFile.file_path.ilike(f\"%/{search_base}.%\") | LibraryFile.file_path.ilike(f\"{search_base}.%\"))\n            .where(LibraryFile.file_path.ilike(\"%.3mf\"))\n            .order_by(LibraryFile.created_at.desc())\n            .limit(3)\n        )\n        for lib_file in lib_result.scalars().all():\n            lib_path = Path(lib_file.file_path)\n            candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path\n            if candidate.exists() and candidate.suffix == \".3mf\":\n                logger.info(\"[UsageTracker] 3MF fallback: found library file %s for archive %s\", candidate, archive.id)\n                return candidate\n    except Exception as e:\n        logger.debug(\"[UsageTracker] 3MF fallback: library lookup failed: %s\", e)\n\n    # 2. Try previous archives with the same filename that have a valid file_path\n    try:\n        prev_result = await db.execute(\n            select(PrintArchive)\n            .where(PrintArchive.id != archive.id)\n            .where(PrintArchive.printer_id == archive.printer_id)\n            .where(PrintArchive.file_path != \"\")\n            .where(PrintArchive.file_path.isnot(None))\n            .where(\n                PrintArchive.filename.ilike(f\"%{search_base}.%\") | PrintArchive.filename.ilike(f\"{search_base}.%\"),\n            )\n            .order_by(PrintArchive.created_at.desc())\n            .limit(3)\n        )\n        for prev_archive in prev_result.scalars().all():\n            candidate = base_dir / prev_archive.file_path\n            if candidate.exists() and candidate.suffix == \".3mf\":\n                logger.info(\n                    \"[UsageTracker] 3MF fallback: found previous archive %s file for archive %s\",\n                    prev_archive.id,\n                    archive.id,\n                )\n                return candidate\n    except Exception as e:\n        logger.debug(\"[UsageTracker] 3MF fallback: previous archive lookup failed: %s\", e)\n\n    return None\n\n\nasync def _find_3mf_by_filename(\n    printer_id: int,\n    filename: str,\n    db: AsyncSession,\n    base_dir,\n):\n    \"\"\"Find a 3MF file by filename from library or previous archives.\n\n    Used when auto-archive is disabled and there's no archive_id, but we still\n    need the 3MF slicer data for filament usage tracking.\n    \"\"\"\n    from pathlib import Path\n\n    from backend.app.models.archive import PrintArchive\n    from backend.app.models.library import LibraryFile\n\n    search_name = filename.split(\"/\")[-1] if \"/\" in filename else filename\n    search_base = search_name.replace(\".gcode.3mf\", \"\").replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n    if not search_base:\n        return None\n\n    # 1. Try library files matching the name\n    try:\n        lib_result = await db.execute(\n            select(LibraryFile)\n            .where(LibraryFile.file_path.ilike(f\"%/{search_base}.%\") | LibraryFile.file_path.ilike(f\"{search_base}.%\"))\n            .where(LibraryFile.file_path.ilike(\"%.3mf\"))\n            .order_by(LibraryFile.created_at.desc())\n            .limit(3)\n        )\n        for lib_file in lib_result.scalars().all():\n            lib_path = Path(lib_file.file_path)\n            candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path\n            if candidate.exists() and candidate.suffix == \".3mf\":\n                logger.info(\"[UsageTracker] 3MF (no-archive): found library file %s for '%s'\", candidate, filename)\n                return candidate\n    except Exception as e:\n        logger.debug(\"[UsageTracker] 3MF (no-archive): library lookup failed: %s\", e)\n\n    # 2. Try previous archives with a valid 3MF file_path\n    try:\n        prev_result = await db.execute(\n            select(PrintArchive)\n            .where(PrintArchive.printer_id == printer_id)\n            .where(PrintArchive.file_path != \"\")\n            .where(PrintArchive.file_path.isnot(None))\n            .where(\n                PrintArchive.filename.ilike(f\"%{search_base}.%\") | PrintArchive.filename.ilike(f\"{search_base}.%\"),\n            )\n            .order_by(PrintArchive.created_at.desc())\n            .limit(3)\n        )\n        for prev_archive in prev_result.scalars().all():\n            candidate = base_dir / prev_archive.file_path\n            if candidate.exists() and candidate.suffix == \".3mf\":\n                logger.info(\n                    \"[UsageTracker] 3MF (no-archive): found previous archive %s file for '%s'\",\n                    prev_archive.id,\n                    filename,\n                )\n                return candidate\n    except Exception as e:\n        logger.debug(\"[UsageTracker] 3MF (no-archive): previous archive lookup failed: %s\", e)\n\n    return None\n\n\nasync def _track_from_3mf(\n    printer_id: int,\n    archive_id: int | None,\n    status: str,\n    print_name: str,\n    handled_trays: set[tuple[int, int]],\n    printer_manager,\n    db: AsyncSession,\n    ams_mapping: list[int] | None = None,\n    tray_now_at_start: int = -1,\n    last_progress: float = 0.0,\n    last_layer_num: int = 0,\n    default_filament_cost: float = 0.0,\n    spool_assignments: dict[tuple[int, int], int] | None = None,\n    print_started_at: datetime | None = None,\n    threemf_path=None,\n) -> list[dict]:\n    \"\"\"Track usage from 3MF per-filament slicer data (primary path).\n\n    Uses slicer-estimated filament weight for all spools (BL and non-BL).\n    For partial prints (failed/aborted), tries per-layer gcode data first,\n    then falls back to linear scaling by progress.\n\n    When archive_id is None (auto-archive disabled), a pre-resolved threemf_path\n    can be provided to still track filament usage from slicer data.\n\n    Slot-to-tray mapping priority:\n    1. Stored ams_mapping from print command (reprints/direct prints)\n    2. MQTT mapping field from printer state (universal, all print sources)\n    3. Queue item ams_mapping (for queue-initiated prints)\n    4. tray_now from printer state (for single-filament non-queue prints)\n    5. Position-based default using sorted available tray IDs (handles external spools)\n    6. Default mapping: slot_id - 1 = global_tray_id (last resort)\n    \"\"\"\n    from pathlib import Path\n\n    from backend.app.core.config import settings as app_settings\n    from backend.app.models.archive import PrintArchive\n    from backend.app.models.print_queue import PrintQueueItem\n    from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf\n\n    file_path: Path | None = threemf_path\n\n    if file_path is None and archive_id:\n        result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n        archive = result.scalar_one_or_none()\n        if not archive:\n            logger.info(\"[UsageTracker] 3MF: archive %s not found, skipping\", archive_id)\n            return []\n\n        # Try archive's own file_path first\n        if archive.file_path:\n            candidate = app_settings.base_dir / archive.file_path\n            if candidate.exists():\n                file_path = candidate\n\n        # Fallback: find 3MF from library or a previous archive with the same filename\n        if file_path is None:\n            file_path = await _resolve_3mf_fallback(archive, db, app_settings.base_dir)\n\n    if file_path is None:\n        logger.info(\"[UsageTracker] 3MF: no file available for archive %s, skipping\", archive_id)\n        return []\n\n    filament_usage = extract_filament_usage_from_3mf(file_path)\n    if not filament_usage:\n        logger.info(\"[UsageTracker] 3MF: no filament usage data in %s\", file_path)\n        return []\n\n    logger.info(\"[UsageTracker] 3MF: archive %s, filament_usage=%s\", archive_id, filament_usage)\n\n    # --- Resolve slot-to-tray mapping ---\n    mapping_source = None\n\n    # 1. Use stored ams_mapping from the print command (reprints/direct prints)\n    slot_to_tray = ams_mapping\n    if slot_to_tray:\n        mapping_source = \"print_cmd\"\n\n    # 2. Try MQTT mapping field from printer state (universal, all print sources)\n    if not slot_to_tray:\n        state = printer_manager.get_status(printer_id)\n        raw_data = getattr(state, \"raw_data\", None) if state else None\n        if raw_data:\n            mqtt_mapping = raw_data.get(\"mapping\")\n            decoded = _decode_mqtt_mapping(mqtt_mapping)\n            if decoded:\n                slot_to_tray = decoded\n                mapping_source = \"mqtt\"\n\n    # 3. Try queue item ams_mapping (queue-initiated prints store the exact mapping)\n    if not slot_to_tray and archive_id:\n        queue_result = await db.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.archive_id == archive_id)\n            .where(PrintQueueItem.status.in_([\"printing\", \"completed\", \"failed\"]))\n        )\n        queue_item = queue_result.scalar_one_or_none()\n        if queue_item and queue_item.ams_mapping:\n            try:\n                slot_to_tray = json.loads(queue_item.ams_mapping)\n                mapping_source = \"queue\"\n            except (json.JSONDecodeError, TypeError):\n                pass\n\n    # 4. Color-match 3MF filament slots to AMS trays (for printers without mapping field)\n    if not slot_to_tray:\n        state = printer_manager.get_status(printer_id)\n        raw_data = getattr(state, \"raw_data\", None) if state else None\n        if raw_data:\n            matched = _match_slots_by_color(filament_usage, raw_data.get(\"ams\"))\n            if matched:\n                slot_to_tray = matched\n                mapping_source = \"color_match\"\n\n    logger.info(\n        \"[UsageTracker] 3MF: slot_to_tray=%s (source: %s)\",\n        slot_to_tray,\n        mapping_source or \"none\",\n    )\n\n    # 5. For single-filament non-queue prints, use tray_now from printer state\n    #    Priority: tray_change_log (multi-tray split) > tray_now_at_start > current tray_now\n    #              > last_loaded_tray > vt_tray check\n    nonzero_slots = [u for u in filament_usage if u.get(\"used_g\", 0) > 0]\n    tray_now_override: int | None = None\n    tray_changes: list[tuple[int, int]] = []  # [(global_tray_id, layer_num), ...]\n    if not slot_to_tray and len(nonzero_slots) == 1:\n        state = printer_manager.get_status(printer_id)\n        tray_changes = getattr(state, \"tray_change_log\", []) if state else []\n\n        if len(tray_changes) > 1:\n            # Multi-tray usage detected — will split in per-slot loop using per-layer gcode\n            logger.info(\"[UsageTracker] 3MF: tray change log: %s (will split weight)\", tray_changes)\n        elif 0 <= tray_now_at_start <= 254:\n            # Try tray_now_at_start first (captured at print start)\n            tray_now_override = tray_now_at_start\n            logger.info(\"[UsageTracker] 3MF: using tray_now_at_start=%d (single-filament fallback)\", tray_now_at_start)\n        elif state and 0 <= state.tray_now <= 254:\n            # Current state is valid (printer didn't retract yet)\n            tray_now_override = state.tray_now\n            logger.info(\"[UsageTracker] 3MF: using current tray_now=%d\", state.tray_now)\n        elif state and 0 <= state.last_loaded_tray <= 253:\n            # Last valid tray before retract (H2D retracts before completion callback)\n            tray_now_override = state.last_loaded_tray\n            logger.info(\"[UsageTracker] 3MF: using last_loaded_tray=%d (post-retract fallback)\", state.last_loaded_tray)\n        elif state and state.tray_now == 255:\n            # 255 = \"no filament\" on legacy printers, but valid 2nd external spool on H2-series\n            vt_tray = state.raw_data.get(\"vt_tray\") or []\n            if any(int(vt.get(\"id\", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):\n                tray_now_override = state.tray_now\n                logger.info(\"[UsageTracker] 3MF: using tray_now=255 (H2-series external spool)\")\n        if tray_now_override is None and len(tray_changes) <= 1:\n            logger.info(\n                \"[UsageTracker] 3MF: no valid tray_now (at_start=%d, current=%s, last_loaded=%s)\",\n                tray_now_at_start,\n                state.tray_now if state else \"N/A\",\n                state.last_loaded_tray if state else \"N/A\",\n            )\n\n    # Scale factor for partial prints (failed/aborted)\n    if status == \"completed\":\n        scale = 1.0\n    else:\n        state = printer_manager.get_status(printer_id)\n        progress = state.progress if state else 0\n        # Firmware resets progress to 0 on cancel — use last valid progress captured during print\n        if progress <= 0 and last_progress > 0:\n            progress = last_progress\n            logger.info(\"[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)\", last_progress)\n        scale = max(0.0, min(progress / 100.0, 1.0))\n\n    # Per-layer gcode accuracy for partial prints\n    layer_grams: dict[int, float] | None = None\n    if status != \"completed\":\n        state = printer_manager.get_status(printer_id)\n        current_layer = state.layer_num if state else 0\n        # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print\n        if current_layer <= 0 and last_layer_num > 0:\n            current_layer = last_layer_num\n            logger.info(\"[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)\", last_layer_num)\n        if current_layer > 0:\n            try:\n                from backend.app.utils.threemf_tools import (\n                    extract_filament_properties_from_3mf,\n                    extract_layer_filament_usage_from_3mf,\n                    get_cumulative_usage_at_layer,\n                    mm_to_grams,\n                )\n\n                layer_usage = extract_layer_filament_usage_from_3mf(file_path)\n                if layer_usage:\n                    cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)\n                    filament_props = extract_filament_properties_from_3mf(file_path)\n                    layer_grams = {}\n                    for filament_id, mm_used in cumulative_mm.items():\n                        slot_id = filament_id + 1  # 0-based to 1-based\n                        props = filament_props.get(slot_id, {})\n                        density = props.get(\"density\", 1.24)\n                        diameter = props.get(\"diameter\", 1.75)\n                        layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)\n            except Exception:\n                pass  # Fall back to linear scaling\n\n    results = []\n\n    for usage in filament_usage:\n        slot_id = usage.get(\"slot_id\", 0)\n        used_g = usage.get(\"used_g\", 0)\n        if used_g <= 0:\n            continue\n\n        # --- Mid-print tray switch: split weight across trays ---\n        if len(tray_changes) > 1:\n            # Compute total weight for this slot (same logic as normal path)\n            if layer_grams and slot_id in layer_grams:\n                total_weight = layer_grams[slot_id]\n            else:\n                total_weight = used_g * scale\n\n            if total_weight <= 0:\n                continue\n\n            # Extract per-layer gcode for segment splitting\n            split_layer_usage = None\n            split_props: dict = {}\n            try:\n                from backend.app.utils.threemf_tools import (\n                    extract_filament_properties_from_3mf,\n                    extract_layer_filament_usage_from_3mf,\n                    get_cumulative_usage_at_layer,\n                    mm_to_grams,\n                )\n\n                split_layer_usage = extract_layer_filament_usage_from_3mf(file_path)\n                filament_props = extract_filament_properties_from_3mf(file_path)\n                split_props = filament_props.get(slot_id, {})\n            except Exception:\n                pass  # Fall back to linear splitting\n\n            density = split_props.get(\"density\", 1.24)\n            diameter = split_props.get(\"diameter\", 1.75)\n            filament_id = slot_id - 1  # 0-based for gcode\n\n            sum_previous = 0.0\n            for seg_idx, (tray_global, seg_start_layer) in enumerate(tray_changes):\n                is_last = seg_idx + 1 >= len(tray_changes)\n\n                if is_last:\n                    # Last segment: remainder to avoid rounding drift\n                    segment_grams = total_weight - sum_previous\n                elif split_layer_usage:\n                    seg_end_layer = tray_changes[seg_idx + 1][1]\n                    mm_at_start = get_cumulative_usage_at_layer(split_layer_usage, seg_start_layer).get(filament_id, 0)\n                    mm_at_end = get_cumulative_usage_at_layer(split_layer_usage, seg_end_layer).get(filament_id, 0)\n                    segment_grams = mm_to_grams(mm_at_end - mm_at_start, diameter, density)\n                else:\n                    # No per-layer data: linear fallback by layer ratio\n                    seg_end_layer = tray_changes[seg_idx + 1][1]\n                    total_layers = state.total_layers if state else 0\n                    if total_layers > 0:\n                        segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers\n                    else:\n                        # Can't compute ratio — assign all to last segment\n                        segment_grams = 0.0\n\n                sum_previous += segment_grams\n                if segment_grams <= 0:\n                    continue\n\n                # Convert global tray ID to (ams_id, tray_id)\n                if tray_global >= 254:\n                    seg_ams_id = 255\n                    seg_tray_id = tray_global - 254\n                elif tray_global >= 128:\n                    seg_ams_id = tray_global\n                    seg_tray_id = 0\n                else:\n                    seg_ams_id = tray_global // 4\n                    seg_tray_id = tray_global % 4\n\n                seg_key = (seg_ams_id, seg_tray_id)\n                if seg_key in handled_trays:\n                    continue\n\n                logger.info(\n                    \"[UsageTracker] 3MF split: segment %d tray=%d (AMS%d-T%d) layers %d-%s -> %.1fg\",\n                    seg_idx,\n                    tray_global,\n                    seg_ams_id,\n                    seg_tray_id,\n                    seg_start_layer,\n                    tray_changes[seg_idx + 1][1] if not is_last else \"end\",\n                    segment_grams,\n                )\n\n                seg_spool_id = await _resolve_spool_id_for_tray(\n                    printer_id=printer_id,\n                    ams_id=seg_ams_id,\n                    tray_id=seg_tray_id,\n                    db=db,\n                    spool_assignments_snapshot=spool_assignments,\n                    print_started_at=print_started_at,\n                )\n                if seg_spool_id is None:\n                    logger.info(\n                        \"[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment\",\n                        printer_id,\n                        seg_ams_id,\n                        seg_tray_id,\n                    )\n                    continue\n\n                spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))\n                spool = spool_result.scalar_one_or_none()\n                if not spool:\n                    continue\n\n                spool.weight_used = (spool.weight_used or 0) + segment_grams\n                spool.last_used = datetime.now(timezone.utc)\n\n                percent = round(segment_grams / (spool.label_weight or 1000) * 100)\n\n                cost = None\n                cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost\n                if cost_per_kg > 0:\n                    cost = round((segment_grams / 1000.0) * cost_per_kg, 2)\n\n                history = SpoolUsageHistory(\n                    spool_id=spool.id,\n                    printer_id=printer_id,\n                    print_name=print_name,\n                    weight_used=round(segment_grams, 1),\n                    percent_used=percent,\n                    status=status,\n                    cost=cost,\n                    archive_id=archive_id,\n                )\n                db.add(history)\n\n                handled_trays.add(seg_key)\n                results.append(\n                    {\n                        \"spool_id\": spool.id,\n                        \"weight_used\": round(segment_grams, 1),\n                        \"percent_used\": percent,\n                        \"ams_id\": seg_ams_id,\n                        \"tray_id\": seg_tray_id,\n                        \"material\": spool.material,\n                        \"cost\": cost,\n                    }\n                )\n\n                logger.info(\n                    \"[UsageTracker] Spool %d consumed %.1fg (3MF split seg%d) on printer %d AMS%d-T%d (%s)\",\n                    spool.id,\n                    segment_grams,\n                    seg_idx,\n                    printer_id,\n                    seg_ams_id,\n                    seg_tray_id,\n                    status,\n                )\n\n            continue  # Skip normal single-tray processing for this slot\n\n        # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping\n        if tray_now_override is not None:\n            # Single-filament non-queue print: use actual tray from printer state\n            global_tray_id = tray_now_override\n        else:\n            # Explicit mapping (print command, MQTT, queue, color match)\n            global_tray_id = None\n            if slot_to_tray and slot_id <= len(slot_to_tray):\n                mapped = slot_to_tray[slot_id - 1]\n                if isinstance(mapped, int) and mapped >= 0:\n                    global_tray_id = mapped\n            # Position-based default: sort available tray IDs so external spools (254/255)\n            # naturally follow standard AMS trays, matching slicer slot numbering\n            if global_tray_id is None:\n                _state = printer_manager.get_status(printer_id)\n                _raw = getattr(_state, \"raw_data\", None) if _state else None\n                if _raw:\n                    from backend.app.services.spoolman_tracking import build_ams_tray_lookup\n\n                    available_trays = sorted(build_ams_tray_lookup(_raw).keys())\n                    if slot_id <= len(available_trays):\n                        global_tray_id = available_trays[slot_id - 1]\n            # Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools)\n            if global_tray_id is None:\n                global_tray_id = slot_id - 1\n\n        if global_tray_id >= 254:\n            # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)\n            ams_id = 255\n            tray_id = global_tray_id - 254\n        elif global_tray_id >= 128:\n            ams_id = global_tray_id\n            tray_id = 0\n        else:\n            ams_id = global_tray_id // 4\n            tray_id = global_tray_id % 4\n\n        logger.info(\n            \"[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)\",\n            slot_id,\n            global_tray_id,\n            ams_id,\n            tray_id,\n            used_g,\n            tray_now_override,\n        )\n\n        key = (ams_id, tray_id)\n        if key in handled_trays:\n            continue\n\n        spool_id = await _resolve_spool_id_for_tray(\n            printer_id=printer_id,\n            ams_id=ams_id,\n            tray_id=tray_id,\n            db=db,\n            spool_assignments_snapshot=spool_assignments,\n            print_started_at=print_started_at,\n        )\n        if spool_id is None:\n            logger.info(\"[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d\", printer_id, ams_id, tray_id)\n            continue\n\n        # Load spool\n        spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))\n        spool = spool_result.scalar_one_or_none()\n        if not spool:\n            continue\n\n        # Use per-layer grams if available, otherwise linear scale\n        if layer_grams and slot_id in layer_grams:\n            weight_grams = layer_grams[slot_id]\n        else:\n            weight_grams = used_g * scale\n\n        if weight_grams <= 0:\n            continue\n\n        # Update spool\n        spool.weight_used = (spool.weight_used or 0) + weight_grams\n        spool.last_used = datetime.now(timezone.utc)\n\n        percent = round(weight_grams / (spool.label_weight or 1000) * 100)\n\n        # Calculate cost for this usage\n        cost = None\n        cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost\n        if cost_per_kg > 0:\n            cost = round((weight_grams / 1000.0) * cost_per_kg, 2)\n\n        # Insert usage history record\n        history = SpoolUsageHistory(\n            spool_id=spool.id,\n            printer_id=printer_id,\n            print_name=print_name,\n            weight_used=round(weight_grams, 1),\n            percent_used=percent,\n            status=status,\n            cost=cost,\n            archive_id=archive_id,\n        )\n        db.add(history)\n\n        handled_trays.add(key)\n        results.append(\n            {\n                \"spool_id\": spool.id,\n                \"weight_used\": round(weight_grams, 1),\n                \"percent_used\": percent,\n                \"ams_id\": ams_id,\n                \"tray_id\": tray_id,\n                \"material\": spool.material,\n                \"cost\": cost,\n            }\n        )\n\n        # Determine mapping source for debug logging\n        if tray_now_override is not None:\n            map_src = \", tray_now\"\n        elif mapping_source:\n            map_src = f\", {mapping_source}_map\"\n        else:\n            map_src = \"\"\n        logger.info(\n            \"[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)\",\n            spool.id,\n            weight_grams,\n            \" per-layer\" if (layer_grams and slot_id in layer_grams) else (f\" scaled {scale:.0%}\" if scale < 1 else \"\"),\n            map_src,\n            printer_id,\n            ams_id,\n            tray_id,\n            status,\n        )\n\n    return results\n"
  },
  {
    "path": "backend/app/services/virtual_printer/__init__.py",
    "content": "\"\"\"Virtual printer services for slicer integration.\"\"\"\n\nfrom backend.app.services.virtual_printer.manager import (\n    DEFAULT_VIRTUAL_PRINTER_MODEL,\n    VIRTUAL_PRINTER_MODELS,\n    virtual_printer_manager,\n)\n\n__all__ = [\n    \"virtual_printer_manager\",\n    \"VIRTUAL_PRINTER_MODELS\",\n    \"DEFAULT_VIRTUAL_PRINTER_MODEL\",\n]\n"
  },
  {
    "path": "backend/app/services/virtual_printer/bind_server.py",
    "content": "\"\"\"Bind/detect server for virtual printer discovery (ports 3000 + 3002).\n\nBambu slicers (BambuStudio, OrcaSlicer) connect to a printer on port 3000\nor 3002 to perform the \"bind with access code\" handshake before using\nMQTT/FTP.\n\nPort 3000: plain TCP (legacy / some printer models).\nPort 3002: TLS (newer firmware, e.g. A1 Mini 01.07.x).\n\nProtocol (same on both ports, only transport differs):\n  - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7\n  - Slicer sends: {\"login\":{\"command\":\"detect\",\"sequence_id\":\"20000\"}}\n  - Printer replies: {\"login\":{\"bind\":\"free\",\"command\":\"detect\",\"connect\":\"lan\",\n      \"dev_cap\":1,\"id\":\"<serial>\",\"model\":\"<model>\",\"name\":\"<name>\",\n      \"sequence_id\":<int>,\"version\":\"<firmware>\"}}\n  - Connection closes after one exchange.\n\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport ssl\nimport struct\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\nBIND_PORT_PLAIN = 3000\nBIND_PORT_TLS = 3002\nBIND_PORTS = [BIND_PORT_PLAIN, BIND_PORT_TLS]\nFRAME_HEADER = b\"\\xa5\\xa5\"\nFRAME_TRAILER = b\"\\xa7\\xa7\"\nHEADER_SIZE = 4  # 2 bytes magic + 2 bytes length\nTRAILER_SIZE = 2\n\n\nclass BindServer:\n    \"\"\"Responds to slicer bind/detect requests on ports 3000 and 3002.\n\n    In server mode, Bambuddy IS the printer — it responds with its own\n    identity so the slicer can discover and bind to it.\n\n    Port 3000 is plain TCP, port 3002 is TLS.  BambuStudio chooses which\n    port to use based on the printer model discovered via SSDP.\n    \"\"\"\n\n    def __init__(\n        self,\n        serial: str,\n        model: str,\n        name: str,\n        version: str = \"01.00.00.00\",\n        bind_address: str = \"0.0.0.0\",  # nosec B104\n        cert_path: Path | None = None,\n        key_path: Path | None = None,\n    ):\n        self.serial = serial\n        self.model = model\n        self.name = name\n        self.version = version\n        self.bind_address = bind_address\n        self.cert_path = cert_path\n        self.key_path = key_path\n\n        self._servers: list[asyncio.Server] = []\n        self._running = False\n\n    def _create_tls_context(self) -> ssl.SSLContext | None:\n        \"\"\"Create SSL context for the TLS bind port (3002).\"\"\"\n        if not self.cert_path or not self.key_path:\n            return None\n        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n        ctx.load_cert_chain(str(self.cert_path), str(self.key_path))\n        ctx.minimum_version = ssl.TLSVersion.TLSv1_2\n        ctx.verify_mode = ssl.CERT_NONE\n        return ctx\n\n    async def start(self) -> None:\n        \"\"\"Start the bind server on ports 3000 (plain) and 3002 (TLS).\"\"\"\n        if self._running:\n            return\n\n        self._running = True\n\n        tls_ctx = self._create_tls_context()\n        if not tls_ctx:\n            logger.warning(\"Bind server: no TLS cert provided, port %s will be plain TCP\", BIND_PORT_TLS)\n\n        logger.info(\n            \"Starting bind server on ports %s (serial=%s, model=%s, tls=%s)\",\n            BIND_PORTS,\n            self.serial,\n            self.model,\n            tls_ctx is not None,\n        )\n\n        try:\n            for port in BIND_PORTS:\n                use_tls = port == BIND_PORT_TLS and tls_ctx is not None\n                try:\n                    server = await asyncio.start_server(\n                        self._handle_client,\n                        self.bind_address,\n                        port,\n                        ssl=tls_ctx if use_tls else None,\n                    )\n                    self._servers.append(server)\n                    logger.info(\n                        \"Bind server listening on %s:%s (%s)\",\n                        self.bind_address,\n                        port,\n                        \"TLS\" if use_tls else \"plain\",\n                    )\n                except OSError as e:\n                    if e.errno == 98:\n                        logger.warning(\"Bind server port %s already in use, skipping\", port)\n                    elif e.errno == 13:\n                        logger.warning(\"Bind server: cannot bind to port %s (permission denied), skipping\", port)\n                    else:\n                        logger.warning(\"Bind server: failed to bind port %s: %s\", port, e)\n\n            if not self._servers:\n                logger.error(\"Bind server: could not bind to any port\")\n                return\n\n            # Serve all successfully bound ports\n            await asyncio.gather(*(s.serve_forever() for s in self._servers))\n\n        except asyncio.CancelledError:\n            logger.debug(\"Bind server task cancelled\")\n        except Exception as e:\n            logger.error(\"Bind server error: %s\", e)\n        finally:\n            await self.stop()\n\n    async def stop(self) -> None:\n        \"\"\"Stop the bind server.\"\"\"\n        logger.info(\"Stopping bind server\")\n        self._running = False\n\n        for server in self._servers:\n            try:\n                server.close()\n                await server.wait_closed()\n            except OSError as e:\n                logger.debug(\"Error closing bind server: %s\", e)\n        self._servers = []\n\n    async def _handle_client(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n    ) -> None:\n        \"\"\"Handle a single bind/detect request from a slicer.\"\"\"\n        peername = writer.get_extra_info(\"peername\")\n        client_id = f\"{peername[0]}:{peername[1]}\" if peername else \"unknown\"\n        logger.info(\"Bind server: client connected from %s\", client_id)\n\n        try:\n            # Read the framed message (timeout after 10s)\n            data = await asyncio.wait_for(reader.read(4096), timeout=10.0)\n            if not data:\n                return\n\n            # Parse the request\n            request = self._parse_frame(data)\n            if request is None:\n                logger.warning(\"Bind server: invalid frame from %s\", client_id)\n                return\n\n            logger.info(\"Bind server: received from %s: %s\", client_id, request)\n\n            # Check if this is a detect command\n            login = request.get(\"login\", {})\n            if not isinstance(login, dict) or login.get(\"command\") != \"detect\":\n                logger.warning(\"Bind server: unexpected command from %s: %s\", client_id, request)\n                return\n\n            # Build response\n            response = {\n                \"login\": {\n                    \"bind\": \"free\",\n                    \"command\": \"detect\",\n                    \"connect\": \"lan\",\n                    \"dev_cap\": 1,\n                    \"id\": self.serial,\n                    \"model\": self.model,\n                    \"name\": self.name,\n                    \"sequence_id\": 3021,\n                    \"version\": self.version,\n                }\n            }\n\n            frame = self._build_frame(response)\n            writer.write(frame)\n            await writer.drain()\n\n            logger.info(\"Bind server: sent detect response to %s (serial=%s)\", client_id, self.serial)\n\n        except TimeoutError:\n            logger.debug(\"Bind server: timeout waiting for data from %s\", client_id)\n        except Exception as e:\n            logger.error(\"Bind server: error handling %s: %s\", client_id, e)\n        finally:\n            try:\n                writer.close()\n                await writer.wait_closed()\n            except OSError:\n                pass\n            logger.debug(\"Bind server: client %s disconnected\", client_id)\n\n    def _parse_frame(self, data: bytes) -> dict | None:\n        \"\"\"Parse a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7.\"\"\"\n        if len(data) < HEADER_SIZE + TRAILER_SIZE:\n            return None\n\n        if data[:2] != FRAME_HEADER:\n            return None\n\n        if data[-2:] != FRAME_TRAILER:\n            return None\n\n        # Length field is total message size (header + json + trailer)\n        total_len = struct.unpack_from(\"<H\", data, 2)[0]\n        if total_len != len(data):\n            logger.debug(\"Bind frame length mismatch: header says %d, got %d\", total_len, len(data))\n\n        # JSON payload is between header and trailer\n        json_bytes = data[HEADER_SIZE:-TRAILER_SIZE]\n        try:\n            return json.loads(json_bytes)\n        except (json.JSONDecodeError, UnicodeDecodeError) as e:\n            logger.warning(\"Bind server: failed to parse JSON: %s\", e)\n            return None\n\n    def _build_frame(self, payload: dict) -> bytes:\n        \"\"\"Build a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7.\"\"\"\n        json_bytes = json.dumps(payload, separators=(\",\", \":\")).encode(\"utf-8\")\n        total_len = HEADER_SIZE + len(json_bytes) + TRAILER_SIZE\n        header = FRAME_HEADER + struct.pack(\"<H\", total_len)\n        return header + json_bytes + FRAME_TRAILER\n"
  },
  {
    "path": "backend/app/services/virtual_printer/certificate.py",
    "content": "\"\"\"TLS certificate generation for virtual printer services.\n\nGenerates certificates that mimic real Bambu printer certificate format:\n- CA certificate mimics \"BBL CA\" from \"BBL Technologies Co., Ltd\"\n- Printer certificate has CN = serial number, signed by the CA\n\nThe CA certificate is persistent and only regenerated if missing or expired.\nThis allows users to add the CA to their slicer's trust store once.\n\"\"\"\n\nimport logging\nimport socket\nfrom datetime import datetime, timedelta, timezone\nfrom ipaddress import IPv4Address\nfrom pathlib import Path\n\nfrom cryptography import x509\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.primitives.asymmetric import rsa\nfrom cryptography.x509.oid import ExtendedKeyUsageOID, NameOID\n\nlogger = logging.getLogger(__name__)\n\n# Default serial number for virtual printer (matches SSDP/MQTT config)\nDEFAULT_SERIAL = \"00M09A391800001\"\n\n# Minimum days remaining before CA is considered expired and needs regeneration\nCA_EXPIRY_THRESHOLD_DAYS = 30\n\n\ndef _get_local_ip() -> str:\n    \"\"\"Get the local IP address.\"\"\"\n    try:\n        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n        s.connect((\"8.8.8.8\", 80))\n        ip = s.getsockname()[0]\n        s.close()\n        return ip\n    except OSError:\n        return \"127.0.0.1\"\n\n\nclass CertificateService:\n    \"\"\"Generate and manage TLS certificates for virtual printer.\n\n    Creates a certificate chain mimicking real Bambu printers:\n    - Root CA with CN=\"BBL CA\", O=\"BBL Technologies Co., Ltd\", C=\"CN\"\n    - Printer cert with CN=serial_number, signed by the CA\n    \"\"\"\n\n    def __init__(self, cert_dir: Path, serial: str = DEFAULT_SERIAL, shared_ca_dir: Path | None = None):\n        \"\"\"Initialize the certificate service.\n\n        Args:\n            cert_dir: Directory to store per-instance certificates\n            serial: Serial number to use as CN in printer certificate\n            shared_ca_dir: If set, CA cert/key are read from this directory\n                instead of cert_dir (for multi-instance shared CA)\n        \"\"\"\n        self.cert_dir = cert_dir\n        self.serial = serial\n        ca_dir = shared_ca_dir or cert_dir\n        self.ca_cert_path = ca_dir / \"bbl_ca.crt\"\n        self.ca_key_path = ca_dir / \"bbl_ca.key\"\n        self.cert_path = cert_dir / \"virtual_printer.crt\"\n        self.key_path = cert_dir / \"virtual_printer.key\"\n\n    def ensure_certificates(self) -> tuple[Path, Path]:\n        \"\"\"Ensure certificates exist, generate if needed.\n\n        Returns:\n            Tuple of (cert_path, key_path)\n        \"\"\"\n        if self.cert_path.exists() and self.key_path.exists():\n            logger.debug(\"Using existing virtual printer certificates\")\n            return self.cert_path, self.key_path\n        return self.generate_certificates()\n\n    def _load_existing_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate] | None:\n        \"\"\"Try to load existing CA certificate and key.\n\n        Returns:\n            Tuple of (ca_private_key, ca_certificate) if valid CA exists, None otherwise\n        \"\"\"\n        if not self.ca_cert_path.exists() or not self.ca_key_path.exists():\n            logger.debug(\"CA certificate or key not found\")\n            return None\n\n        try:\n            # Load CA certificate\n            ca_cert_pem = self.ca_cert_path.read_bytes()\n            ca_cert = x509.load_pem_x509_certificate(ca_cert_pem)\n\n            # Check if CA is expired or about to expire\n            now = datetime.now(timezone.utc)\n            days_remaining = (ca_cert.not_valid_after_utc - now).days\n            if days_remaining < CA_EXPIRY_THRESHOLD_DAYS:\n                logger.warning(\"CA certificate expires in %s days, will regenerate\", days_remaining)\n                return None\n\n            # Load CA private key\n            ca_key_pem = self.ca_key_path.read_bytes()\n            ca_key = serialization.load_pem_private_key(ca_key_pem, password=None)\n\n            logger.info(\"Using existing CA certificate (expires in %s days)\", days_remaining)\n            return ca_key, ca_cert\n\n        except (OSError, ValueError) as e:\n            logger.warning(\"Failed to load existing CA: %s\", e)\n            return None\n\n    def _get_or_create_ca(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:\n        \"\"\"Get existing CA or create a new one.\n\n        Returns:\n            Tuple of (ca_private_key, ca_certificate)\n        \"\"\"\n        # Try to load existing CA first\n        existing = self._load_existing_ca()\n        if existing:\n            return existing\n\n        # Generate new CA\n        ca_key, ca_cert = self._generate_ca_certificate()\n\n        # Save CA certificate and key\n        self.cert_dir.mkdir(parents=True, exist_ok=True)\n        self.ca_key_path.write_bytes(\n            ca_key.private_bytes(\n                encoding=serialization.Encoding.PEM,\n                format=serialization.PrivateFormat.TraditionalOpenSSL,\n                encryption_algorithm=serialization.NoEncryption(),\n            )\n        )\n        self.ca_key_path.chmod(0o600)\n        self.ca_cert_path.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))\n\n        logger.info(\"Saved new CA certificate\")\n        return ca_key, ca_cert\n\n    def _generate_ca_certificate(self) -> tuple[rsa.RSAPrivateKey, x509.Certificate]:\n        \"\"\"Generate a new CA certificate for the virtual printer.\n\n        We use a generic name instead of mimicking BBL CA, since the slicer\n        may specifically reject certificates claiming to be from BBL but\n        with a different public key.\n\n        Returns:\n            Tuple of (ca_private_key, ca_certificate)\n        \"\"\"\n        logger.info(\"Generating new Virtual Printer CA certificate...\")\n\n        # Generate CA private key\n        ca_key = rsa.generate_private_key(\n            public_exponent=65537,\n            key_size=2048,\n        )\n\n        # Use a generic CA name - NOT BBL to avoid being rejected as fake\n        ca_name = x509.Name(\n            [\n                x509.NameAttribute(NameOID.COMMON_NAME, \"Virtual Printer CA\"),\n            ]\n        )\n\n        now = datetime.now(timezone.utc)\n\n        ca_cert = (\n            x509.CertificateBuilder()\n            .subject_name(ca_name)\n            .issuer_name(ca_name)\n            .public_key(ca_key.public_key())\n            .serial_number(x509.random_serial_number())\n            .not_valid_before(now)\n            .not_valid_after(now + timedelta(days=7300))  # 20 years\n            .add_extension(\n                x509.BasicConstraints(ca=True, path_length=0),\n                critical=True,\n            )\n            .add_extension(\n                x509.KeyUsage(\n                    digital_signature=True,\n                    content_commitment=False,\n                    key_encipherment=False,\n                    data_encipherment=False,\n                    key_agreement=False,\n                    key_cert_sign=True,\n                    crl_sign=True,\n                    encipher_only=False,\n                    decipher_only=False,\n                ),\n                critical=True,\n            )\n            .sign(ca_key, hashes.SHA256())\n        )\n\n        return ca_key, ca_cert\n\n    def _build_san_entries(self, local_ip: str, additional_ips: list[str] | None) -> list[x509.GeneralName]:\n        \"\"\"Build Subject Alternative Name entries for the printer certificate.\"\"\"\n        entries: list[x509.GeneralName] = [\n            x509.DNSName(\"localhost\"),\n            x509.DNSName(\"bambuddy\"),\n            x509.DNSName(self.serial),\n            x509.IPAddress(IPv4Address(local_ip)),\n            x509.IPAddress(IPv4Address(\"127.0.0.1\")),\n        ]\n        seen_ips = {local_ip, \"127.0.0.1\"}\n        if additional_ips:\n            for ip in additional_ips:\n                if ip and ip not in seen_ips:\n                    try:\n                        entries.append(x509.IPAddress(IPv4Address(ip)))\n                        seen_ips.add(ip)\n                        logger.info(\"Added additional SAN IP: %s\", ip)\n                    except ValueError:\n                        logger.warning(\"Skipping invalid additional SAN IP: %s\", ip)\n        return entries\n\n    def generate_certificates(self, additional_ips: list[str] | None = None) -> tuple[Path, Path]:\n        \"\"\"Generate printer certificate (reusing existing CA if available).\n\n        Creates a certificate chain mimicking real Bambu printers:\n        - CA certificate (reused if exists and valid, otherwise generated)\n        - Printer certificate (CN=serial, signed by CA)\n\n        Args:\n            additional_ips: Extra IP addresses to include in certificate SAN.\n                Used in proxy mode to include the remote interface IP so the\n                slicer's TLS handshake succeeds when connecting to the proxy.\n\n        Returns:\n            Tuple of (cert_path, key_path)\n        \"\"\"\n        logger.info(\"Generating certificates for virtual printer (serial: %s)...\", self.serial)\n\n        # Ensure directory exists\n        self.cert_dir.mkdir(parents=True, exist_ok=True)\n\n        # Get or create CA (reuses existing if valid)\n        ca_key, ca_cert = self._get_or_create_ca()\n\n        # Generate printer private key\n        printer_key = rsa.generate_private_key(\n            public_exponent=65537,\n            key_size=2048,\n        )\n\n        # Printer certificate subject - CN is the serial number (like real Bambu printers)\n        printer_subject = x509.Name(\n            [\n                x509.NameAttribute(NameOID.COMMON_NAME, self.serial),\n            ]\n        )\n\n        # Issuer is the CA\n        issuer = ca_cert.subject\n\n        now = datetime.now(timezone.utc)\n        local_ip = _get_local_ip()\n        logger.info(\"Generating printer certificate with CN=%s, local IP: %s\", self.serial, local_ip)\n\n        # Build printer certificate signed by CA\n        printer_cert = (\n            x509.CertificateBuilder()\n            .subject_name(printer_subject)\n            .issuer_name(issuer)\n            .public_key(printer_key.public_key())\n            .serial_number(x509.random_serial_number())\n            .not_valid_before(now)\n            .not_valid_after(now + timedelta(days=3650))  # 10 years\n            .add_extension(\n                x509.BasicConstraints(ca=False, path_length=None),\n                critical=True,\n            )\n            .add_extension(\n                x509.SubjectAlternativeName(self._build_san_entries(local_ip, additional_ips)),\n                critical=False,\n            )\n            .add_extension(\n                x509.ExtendedKeyUsage(\n                    [\n                        ExtendedKeyUsageOID.SERVER_AUTH,\n                        ExtendedKeyUsageOID.CLIENT_AUTH,\n                    ]\n                ),\n                critical=False,\n            )\n            .add_extension(\n                x509.KeyUsage(\n                    digital_signature=True,\n                    content_commitment=False,\n                    key_encipherment=True,\n                    data_encipherment=False,\n                    key_agreement=False,\n                    key_cert_sign=False,\n                    crl_sign=False,\n                    encipher_only=False,\n                    decipher_only=False,\n                ),\n                critical=True,\n            )\n            .sign(ca_key, hashes.SHA256())  # Signed by CA, not self-signed\n        )\n\n        # Write printer private key\n        self.key_path.write_bytes(\n            printer_key.private_bytes(\n                encoding=serialization.Encoding.PEM,\n                format=serialization.PrivateFormat.TraditionalOpenSSL,\n                encryption_algorithm=serialization.NoEncryption(),\n            )\n        )\n        self.key_path.chmod(0o600)\n\n        # Write printer certificate (include CA cert in chain for full chain)\n        cert_chain = printer_cert.public_bytes(serialization.Encoding.PEM) + ca_cert.public_bytes(\n            serialization.Encoding.PEM\n        )\n        self.cert_path.write_bytes(cert_chain)\n\n        logger.info(\"Generated certificate chain at %s\", self.cert_dir)\n        logger.info(\"  CA: CN=Virtual Printer CA\")\n        logger.info(\"  Printer: CN=%s\", self.serial)\n        return self.cert_path, self.key_path\n\n    def delete_printer_certificate(self) -> None:\n        \"\"\"Delete only the printer certificate (preserves CA).\"\"\"\n        for path in [self.cert_path, self.key_path]:\n            if path.exists():\n                path.unlink()\n        logger.info(\"Deleted printer certificate (CA preserved)\")\n\n    def delete_certificates(self, include_ca: bool = False) -> None:\n        \"\"\"Delete existing certificates.\n\n        Args:\n            include_ca: If True, also delete CA certificate and key.\n                       If False (default), only delete printer certificate.\n        \"\"\"\n        # Always delete printer certificate\n        for path in [self.cert_path, self.key_path]:\n            if path.exists():\n                path.unlink()\n\n        # Only delete CA if explicitly requested\n        if include_ca:\n            for path in [self.ca_cert_path, self.ca_key_path]:\n                if path.exists():\n                    path.unlink()\n            logger.info(\"Deleted all certificates including CA\")\n        else:\n            logger.info(\"Deleted printer certificate (CA preserved)\")\n"
  },
  {
    "path": "backend/app/services/virtual_printer/ftp_server.py",
    "content": "\"\"\"Implicit FTPS server for receiving 3MF uploads from slicers.\n\nImplements an implicit FTPS server (TLS from byte 0) that accepts file uploads\nfrom Bambu Studio and OrcaSlicer, matching the real Bambu printer behavior.\n\nUnlike explicit FTPS (AUTH TLS), implicit FTPS wraps the connection in TLS\nimmediately upon connection, before any FTP commands are exchanged.\n\"\"\"\n\nimport asyncio\nimport logging\nimport os\nimport random\nimport ssl\nfrom collections.abc import Callable\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n# Default FTP port for Bambu printers (implicit FTPS).\n# Must be 990 (same as real printers) to avoid iptables REDIRECT,\n# which rewrites the destination IP to the incoming interface's primary\n# address — breaking multi-VP setups with different bind IPs.\n# Requires CAP_NET_BIND_SERVICE or root.\nFTP_PORT = 990\n\n\nclass FTPSession:\n    \"\"\"Handles a single FTP client session.\"\"\"\n\n    def __init__(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n        upload_dir: Path,\n        access_code: str,\n        ssl_context: ssl.SSLContext,\n        on_file_received: Callable[[Path, str], None] | None,\n        passive_port_range: tuple[int, int] = (50000, 50100),\n        pasv_address: str = \"\",\n        bind_address: str = \"0.0.0.0\",  # nosec B104\n        vp_name: str = \"\",\n    ):\n        self.reader = reader\n        self.writer = writer\n        self.upload_dir = upload_dir\n        self.access_code = access_code\n        self.ssl_context = ssl_context\n        self.on_file_received = on_file_received\n        self.passive_port_range = passive_port_range\n        self.pasv_address = pasv_address\n        self.bind_address = bind_address\n        self.vp_name = vp_name\n        self._log_prefix = f\"[{vp_name}] \" if vp_name else \"\"\n\n        self.authenticated = False\n        self.username: str | None = None\n        self.current_dir = upload_dir\n        self.transfer_type = \"A\"  # ASCII by default\n        self.data_server: asyncio.Server | None = None\n        self.data_port: int | None = None\n\n        # For data transfer coordination\n        self._data_reader: asyncio.StreamReader | None = None\n        self._data_writer: asyncio.StreamWriter | None = None\n        self._data_connected = asyncio.Event()\n        self._transfer_done = asyncio.Event()\n\n        peername = writer.get_extra_info(\"peername\")\n        self.remote_ip = peername[0] if peername else \"unknown\"\n\n    async def send(self, code: int, message: str) -> None:\n        \"\"\"Send an FTP response.\"\"\"\n        response = f\"{code} {message}\\r\\n\"\n        logger.debug(\"%sFTP -> %s: %s\", self._log_prefix, self.remote_ip, response.strip())\n        self.writer.write(response.encode(\"utf-8\"))\n        await self.writer.drain()\n\n    async def handle(self) -> None:\n        \"\"\"Handle the FTP session.\"\"\"\n        try:\n            # Send welcome banner\n            await self.send(220, \"Bambuddy Virtual Printer FTP ready\")\n\n            while True:\n                try:\n                    line = await asyncio.wait_for(\n                        self.reader.readline(),\n                        timeout=300,  # 5 minute timeout\n                    )\n                except TimeoutError:\n                    logger.debug(\"%sFTP session timeout from %s\", self._log_prefix, self.remote_ip)\n                    break\n\n                if not line:\n                    break\n\n                try:\n                    command_line = line.decode(\"utf-8\").strip()\n                except UnicodeDecodeError:\n                    command_line = line.decode(\"latin-1\").strip()\n\n                if not command_line:\n                    continue\n\n                # Never log passwords\n                if command_line.upper().startswith(\"PASS\"):\n                    logger.debug(\"%sFTP <- %s: PASS ********\", self._log_prefix, self.remote_ip)\n                else:\n                    logger.debug(\"%sFTP <- %s: %s\", self._log_prefix, self.remote_ip, command_line)\n\n                # Parse command and argument\n                parts = command_line.split(\" \", 1)\n                cmd = parts[0].upper()\n                arg = parts[1] if len(parts) > 1 else \"\"\n\n                # Dispatch command\n                handler = getattr(self, f\"cmd_{cmd}\", None)\n                if handler:\n                    await handler(arg)\n                else:\n                    logger.debug(\"%sFTP command not implemented: %s\", self._log_prefix, cmd)\n                    await self.send(502, f\"Command {cmd} not implemented\")\n\n        except asyncio.CancelledError:\n            logger.info(\"%sFTP session cancelled from %s\", self._log_prefix, self.remote_ip)\n        except Exception as e:\n            logger.error(\"%sFTP session error from %s: %s\", self._log_prefix, self.remote_ip, e)\n        finally:\n            logger.info(\"%sFTP session ended from %s\", self._log_prefix, self.remote_ip)\n            await self._cleanup()\n\n    async def _cleanup(self) -> None:\n        \"\"\"Clean up session resources.\"\"\"\n        # Release any waiting data connection callback\n        self._transfer_done.set()\n\n        if self.data_server:\n            self.data_server.close()\n            try:\n                await self.data_server.wait_closed()\n            except OSError:\n                pass  # Best-effort data server cleanup; may already be closed\n            self.data_server = None\n\n        try:\n            self.writer.close()\n            await self.writer.wait_closed()\n        except OSError:\n            pass  # Best-effort control connection cleanup; client may have disconnected\n\n    # FTP Commands\n\n    async def cmd_USER(self, arg: str) -> None:\n        \"\"\"Handle USER command.\"\"\"\n        self.username = arg\n        if arg.lower() == \"bblp\":\n            await self.send(331, \"Password required\")\n        else:\n            await self.send(530, \"Invalid user\")\n\n    async def cmd_PASS(self, arg: str) -> None:\n        \"\"\"Handle PASS command.\"\"\"\n        if self.username and self.username.lower() == \"bblp\":\n            if arg == self.access_code:\n                self.authenticated = True\n                await self.send(230, \"Login successful\")\n                logger.info(\"%sFTP login from %s\", self._log_prefix, self.remote_ip)\n            else:\n                await self.send(530, \"Login incorrect\")\n                logger.warning(\"%sFTP failed login from %s (access code mismatch)\", self._log_prefix, self.remote_ip)\n        else:\n            await self.send(503, \"Login with USER first\")\n\n    async def cmd_SYST(self, arg: str) -> None:\n        \"\"\"Handle SYST command.\"\"\"\n        await self.send(215, \"UNIX Type: L8\")\n\n    async def cmd_FEAT(self, arg: str) -> None:\n        \"\"\"Handle FEAT command.\"\"\"\n        features = [\n            \"211-Features:\",\n            \" PASV\",\n            \" EPSV\",\n            \" UTF8\",\n            \" SIZE\",\n            \"211 End\",\n        ]\n        for line in features[:-1]:\n            self.writer.write(f\"{line}\\r\\n\".encode())\n        await self.writer.drain()\n        self.writer.write(f\"{features[-1]}\\r\\n\".encode())\n        await self.writer.drain()\n\n    async def cmd_PWD(self, arg: str) -> None:\n        \"\"\"Handle PWD command.\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n        await self.send(257, '\"/\" is current directory')\n\n    async def cmd_CWD(self, arg: str) -> None:\n        \"\"\"Handle CWD command.\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n        # Accept any directory change (we use a flat structure)\n        await self.send(250, \"Directory changed\")\n\n    async def cmd_TYPE(self, arg: str) -> None:\n        \"\"\"Handle TYPE command.\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n        if arg.upper() in (\"A\", \"I\"):\n            self.transfer_type = arg.upper()\n            type_name = \"ASCII\" if arg.upper() == \"A\" else \"Binary\"\n            await self.send(200, f\"Type set to {type_name}\")\n        else:\n            await self.send(504, \"Type not supported\")\n\n    async def _bind_passive_port(self) -> bool:\n        \"\"\"Try to bind a passive data port with retries.\n\n        Returns True if a port was successfully bound, False otherwise.\n        Sets self.data_server and self.data_port on success.\n        \"\"\"\n        port_min, port_max = self.passive_port_range\n        for attempt in range(10):\n            port = random.randint(port_min, port_max)\n            try:\n                self.data_server = await asyncio.start_server(\n                    self._handle_data_connection,\n                    self.bind_address,\n                    port,\n                    ssl=self.ssl_context,\n                )\n                self.data_port = port\n                return True\n            except OSError:\n                logger.debug(\"FTP passive port %s in use, retrying (%s/10)\", port, attempt + 1)\n        return False\n\n    async def cmd_EPSV(self, arg: str) -> None:\n        \"\"\"Handle EPSV command - Extended Passive Mode (IPv6 compatible).\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n\n        # Close any existing data connection/server\n        await self._close_data_connection()\n\n        # Reset connection state for the new transfer\n        self._data_connected.clear()\n        self._data_reader = None\n        self._data_writer = None\n        self._transfer_done = asyncio.Event()\n\n        if await self._bind_passive_port():\n            # EPSV response format: 229 Entering Extended Passive Mode (|||port|)\n            await self.send(229, f\"Entering Extended Passive Mode (|||{self.data_port}|)\")\n            logger.info(\"FTP EPSV listening on port %s\", self.data_port)\n        else:\n            logger.error(\"Failed to bind any passive port for EPSV\")\n            await self.send(425, \"Cannot open data connection\")\n\n    async def cmd_PASV(self, arg: str) -> None:\n        \"\"\"Handle PASV command - set up passive data connection.\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n\n        # Close any existing data connection/server\n        await self._close_data_connection()\n\n        # Reset connection state for the new transfer\n        self._data_connected.clear()\n        self._data_reader = None\n        self._data_writer = None\n        self._transfer_done = asyncio.Event()\n\n        if await self._bind_passive_port():\n            # Determine the IP to advertise in PASV response\n            if self.pasv_address:\n                # Explicit override (e.g., for Docker bridge mode behind NAT)\n                ip = self.pasv_address\n            else:\n                # Use the local IP of the control connection\n                sockname = self.writer.get_extra_info(\"sockname\")\n                ip = sockname[0] if sockname else \"127.0.0.1\"\n                # 0.0.0.0 is not routable — fall back to control connection IP\n                if ip == \"0.0.0.0\":  # nosec B104\n                    ip = \"127.0.0.1\"\n\n            # Format IP and port for PASV response\n            ip_parts = ip.split(\".\")\n            port_hi = self.data_port // 256\n            port_lo = self.data_port % 256\n\n            await self.send(\n                227,\n                f\"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})\",\n            )\n            logger.info(\"FTP PASV listening on %s:%s\", ip, self.data_port)\n        else:\n            logger.error(\"Failed to bind any passive port for PASV\")\n            await self.send(425, \"Cannot open data connection\")\n\n    async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:\n        \"\"\"Handle incoming data connection (used by PASV/EPSV).\n\n        This callback stays alive until the transfer completes to ensure the\n        asyncio task holds strong references to the reader/writer throughout\n        the data transfer.  If the callback returned immediately, the task\n        would complete and the StreamReaderProtocol could release its strong\n        reader reference, potentially destabilising the connection.\n        \"\"\"\n        # Reject duplicate connections — only one data connection per transfer\n        if self._data_reader is not None:\n            logger.warning(\"FTP rejecting duplicate data connection from %s\", self.remote_ip)\n            try:\n                writer.close()\n                await writer.wait_closed()\n            except OSError:\n                pass\n            return\n\n        # Log TLS details for debugging\n        ssl_obj = writer.get_extra_info(\"ssl_object\")\n        if ssl_obj:\n            logger.info(\n                f\"FTP data TLS from {self.remote_ip}: cipher={ssl_obj.cipher()}, \"\n                f\"version={ssl_obj.version()}, session_reused={ssl_obj.session_reused}\"\n            )\n        else:\n            logger.warning(\"FTP data connection from %s has no SSL!\", self.remote_ip)\n\n        logger.info(\"FTP data connection established from %s\", self.remote_ip)\n        self._data_reader = reader\n        self._data_writer = writer\n\n        # Stop accepting further connections on the passive port\n        if self.data_server:\n            self.data_server.close()\n\n        self._data_connected.set()\n\n        # Keep this callback alive until the transfer command (STOR/RETR)\n        # finishes. This ensures the asyncio server-handler task holds strong\n        # references to reader/writer for the entire transfer lifetime.\n        await self._transfer_done.wait()\n\n    async def _close_data_connection(self) -> None:\n        \"\"\"Close the data connection and server.\"\"\"\n        had_connection = self._data_writer is not None or self.data_server is not None\n\n        # Signal the _handle_data_connection callback to return, allowing\n        # its asyncio task to complete cleanly.\n        self._transfer_done.set()\n\n        if self._data_writer:\n            try:\n                self._data_writer.close()\n                await self._data_writer.wait_closed()\n            except OSError:\n                pass  # Best-effort data writer cleanup; peer may have closed already\n            self._data_writer = None\n            self._data_reader = None\n\n        if self.data_server:\n            try:\n                self.data_server.close()\n                await self.data_server.wait_closed()\n            except OSError:\n                pass  # Best-effort data server shutdown; port may already be released\n            self.data_server = None\n\n        # Only delay if we actually closed something\n        if had_connection:\n            await asyncio.sleep(0.1)\n\n    async def cmd_STOR(self, arg: str) -> None:\n        \"\"\"Handle STOR command - receive file upload.\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n\n        if not self.data_server and not self._data_connected.is_set():\n            await self.send(425, \"Use PASV first\")\n            return\n\n        filename = Path(arg).name  # Sanitize filename\n        file_path = self.upload_dir / filename\n\n        logger.info(\"FTP receiving file: %s from %s\", filename, self.remote_ip)\n\n        await self.send(150, f\"Opening data connection for {filename}\")\n\n        # Wait for data connection to be established (client connects after 150)\n        try:\n            await asyncio.wait_for(self._data_connected.wait(), timeout=30)\n        except TimeoutError:\n            logger.error(\"FTP data connection timeout - client didn't connect\")\n            await self.send(425, \"Data connection timeout\")\n            await self._close_data_connection()\n            return\n\n        if not self._data_reader:\n            await self.send(425, \"Data connection failed\")\n            await self._close_data_connection()\n            return\n\n        # Receive data\n        data_content: list[bytes] = []\n        total_received = 0\n        try:\n            while True:\n                chunk = await asyncio.wait_for(self._data_reader.read(65536), timeout=60)\n                if not chunk:\n                    break\n                data_content.append(chunk)\n                total_received += len(chunk)\n                logger.debug(\"FTP received chunk: %s bytes (total: %s)\", len(chunk), total_received)\n        except TimeoutError:\n            logger.error(\"FTP data transfer timeout after %s bytes for %s\", total_received, filename)\n            await self.send(426, \"Transfer timeout\")\n            await self._close_data_connection()\n            return\n        except Exception as e:\n            logger.error(\n                \"FTP data transfer error after %s bytes for %s: %s(%s)\",\n                total_received,\n                filename,\n                type(e).__name__,\n                e,\n            )\n            await self.send(426, f\"Transfer failed: {e}\")\n            await self._close_data_connection()\n            return\n\n        # Close data connection\n        await self._close_data_connection()\n\n        # Write file\n        try:\n            total_size = sum(len(c) for c in data_content)\n            file_path.write_bytes(b\"\".join(data_content))\n            logger.info(\"FTP saved file: %s (%s bytes)\", file_path, total_size)\n            await self.send(226, \"Transfer complete\")\n\n            # Notify callback\n            if self.on_file_received:\n                try:\n                    result = self.on_file_received(file_path, self.remote_ip)\n                    if asyncio.iscoroutine(result):\n                        await result\n                except Exception as e:\n                    logger.error(\"File received callback error: %s\", e)\n\n        except Exception as e:\n            logger.error(\"Failed to save file %s: %s\", file_path, e)\n            await self.send(550, \"Failed to save file\")\n\n    async def cmd_SIZE(self, arg: str) -> None:\n        \"\"\"Handle SIZE command.\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n        # We don't store files for SIZE queries\n        await self.send(550, \"File not found\")\n\n    async def cmd_QUIT(self, arg: str) -> None:\n        \"\"\"Handle QUIT command.\"\"\"\n        await self.send(221, \"Goodbye\")\n        raise asyncio.CancelledError()\n\n    async def cmd_NOOP(self, arg: str) -> None:\n        \"\"\"Handle NOOP command.\"\"\"\n        await self.send(200, \"OK\")\n\n    async def cmd_OPTS(self, arg: str) -> None:\n        \"\"\"Handle OPTS command.\"\"\"\n        if arg.upper().startswith(\"UTF8\"):\n            await self.send(200, \"UTF8 mode enabled\")\n        else:\n            await self.send(501, \"Option not supported\")\n\n    async def cmd_PBSZ(self, arg: str) -> None:\n        \"\"\"Handle PBSZ (Protection Buffer Size) command.\n\n        Required for FTP security extensions. With TLS, buffer size is 0.\n        \"\"\"\n        await self.send(200, \"PBSZ=0\")\n\n    async def cmd_PROT(self, arg: str) -> None:\n        \"\"\"Handle PROT (Data Channel Protection Level) command.\n\n        P = Private (encrypted), which we always use with implicit FTPS.\n        \"\"\"\n        if arg.upper() == \"P\":\n            await self.send(200, \"Protection level set to Private\")\n        elif arg.upper() == \"C\":\n            # Clear (unprotected) - we don't support this\n            await self.send(536, \"Protection level C not supported\")\n        else:\n            await self.send(504, f\"Protection level {arg} not supported\")\n\n    async def cmd_MKD(self, arg: str) -> None:\n        \"\"\"Handle MKD (Make Directory) command.\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n        # We don't really create directories, just pretend it works\n        await self.send(257, f'\"{arg}\" directory created')\n\n    async def cmd_LIST(self, arg: str) -> None:\n        \"\"\"Handle LIST command - list directory contents.\"\"\"\n        if not self.authenticated:\n            await self.send(530, \"Not logged in\")\n            return\n        # We don't support listing, return empty\n        await self.send(150, \"Opening data connection\")\n        await self.send(226, \"Transfer complete\")\n\n\nclass VirtualPrinterFTPServer:\n    \"\"\"Implicit FTPS server that accepts uploads from slicers.\"\"\"\n\n    PASSIVE_PORT_MIN = 50000\n    PASSIVE_PORT_MAX = 50100\n\n    def __init__(\n        self,\n        upload_dir: Path,\n        access_code: str,\n        cert_path: Path,\n        key_path: Path,\n        port: int = FTP_PORT,\n        on_file_received: Callable[[Path, str], None] | None = None,\n        bind_address: str = \"0.0.0.0\",  # nosec B104\n        vp_name: str = \"\",\n    ):\n        \"\"\"Initialize the FTPS server.\n\n        Args:\n            upload_dir: Directory to store uploaded files\n            access_code: Password for authentication (bblp user)\n            cert_path: Path to TLS certificate file\n            key_path: Path to TLS private key file\n            port: Port to listen on (default 990)\n            on_file_received: Callback when file upload completes (path, source_ip)\n            bind_address: IP address to bind to (default 0.0.0.0)\n            vp_name: Virtual printer name for log identification\n        \"\"\"\n        self.upload_dir = upload_dir\n        self.access_code = access_code\n        self.cert_path = cert_path\n        self.key_path = key_path\n        self.port = port\n        self.on_file_received = on_file_received\n        self.bind_address = bind_address\n        self.vp_name = vp_name\n        self._server: asyncio.Server | None = None\n        self._running = False\n        self._ssl_context: ssl.SSLContext | None = None\n        self._active_sessions: list[asyncio.Task] = []\n        # Override PASV response IP for Docker bridge mode / NAT environments\n        self._pasv_address = os.environ.get(\"VIRTUAL_PRINTER_PASV_ADDRESS\", \"\")\n\n    async def start(self) -> None:\n        \"\"\"Start the implicit FTPS server.\"\"\"\n        if self._running:\n            return\n\n        logger.info(\"[%s] Starting virtual printer implicit FTPS on %s:%s\", self.vp_name, self.bind_address, self.port)\n\n        # Ensure upload directory exists\n        self.upload_dir.mkdir(parents=True, exist_ok=True)\n        cache_dir = self.upload_dir / \"cache\"\n        cache_dir.mkdir(exist_ok=True)\n\n        # Create SSL context for implicit FTPS (TLS from byte 0)\n        self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n        self._ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))\n        self._ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2\n        self._ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2\n\n        # Use standard TLS settings for compatibility\n        self._ssl_context.set_ciphers(\"HIGH:!aNULL:!MD5:!RC4\")\n\n        logger.info(\"FTP SSL context created with standard settings\")\n\n        try:\n            # Create server with SSL - TLS handshake happens before any FTP data\n            self._server = await asyncio.start_server(\n                self._handle_client,\n                self.bind_address,\n                self.port,\n                ssl=self._ssl_context,  # This makes it implicit FTPS!\n            )\n            self._running = True\n\n            logger.info(\"Implicit FTPS server started on port %s\", self.port)\n            logger.info(\n                \"FTP passive data port range: %s-%s\",\n                self.PASSIVE_PORT_MIN,\n                self.PASSIVE_PORT_MAX,\n            )\n            if self._pasv_address:\n                logger.info(\"FTP PASV address override: %s\", self._pasv_address)\n\n            async with self._server:\n                await self._server.serve_forever()\n\n        except OSError as e:\n            if e.errno == 98:  # Address already in use\n                logger.error(\"FTP port %s is already in use\", self.port)\n            else:\n                logger.error(\"FTP server error: %s\", e)\n        except asyncio.CancelledError:\n            logger.debug(\"FTP server task cancelled\")\n        except Exception as e:\n            logger.error(\"FTP server error: %s\", e)\n        finally:\n            await self.stop()\n\n    async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:\n        \"\"\"Handle a new FTP client connection.\"\"\"\n        peername = writer.get_extra_info(\"peername\")\n        log_prefix = f\"[{self.vp_name}] \" if self.vp_name else \"\"\n        logger.info(\"%sFTP connection from %s\", log_prefix, peername)\n\n        session = FTPSession(\n            reader=reader,\n            writer=writer,\n            upload_dir=self.upload_dir,\n            access_code=self.access_code,\n            ssl_context=self._ssl_context,\n            on_file_received=self.on_file_received,\n            passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),\n            pasv_address=self._pasv_address,\n            bind_address=self.bind_address,\n            vp_name=self.vp_name,\n        )\n\n        # Track the session task so we can cancel it on stop\n        task = asyncio.current_task()\n        if task:\n            self._active_sessions.append(task)\n        try:\n            await session.handle()\n        finally:\n            if task and task in self._active_sessions:\n                self._active_sessions.remove(task)\n\n    async def stop(self) -> None:\n        \"\"\"Stop the FTPS server.\"\"\"\n        logger.info(\"Stopping FTP server\")\n        self._running = False\n\n        # Cancel all active sessions first\n        for task in self._active_sessions[:]:  # Copy list to avoid modification during iteration\n            task.cancel()\n\n        # Wait briefly for sessions to clean up\n        if self._active_sessions:\n            await asyncio.sleep(0.1)\n\n        self._active_sessions.clear()\n\n        if self._server:\n            try:\n                self._server.close()\n                await self._server.wait_closed()\n            except OSError as e:\n                logger.debug(\"Error closing FTP server: %s\", e)\n            self._server = None\n"
  },
  {
    "path": "backend/app/services/virtual_printer/manager.py",
    "content": "\"\"\"Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.\n\nEach virtual printer runs its own independent services (FTP, MQTT, SSDP, Bind)\nbound to its dedicated IP address, regardless of mode.\n\"\"\"\n\nimport asyncio\nimport logging\nfrom collections.abc import Callable\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nfrom backend.app.core.config import settings as app_settings\nfrom backend.app.services.virtual_printer.bind_server import BindServer\nfrom backend.app.services.virtual_printer.certificate import CertificateService\nfrom backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer\nfrom backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer\nfrom backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer\nfrom backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager\n\nlogger = logging.getLogger(__name__)\n\n\n# Mapping of SSDP model codes to display names\n# These are the codes that slicers expect during discovery\n# Sources:\n#   - https://gist.github.com/Alex-Schaefer/72a9e2491a42da2ef99fb87601955cc3\n#   - https://github.com/psychoticbeef/BambuLabOrcaSlicerDiscovery\nVIRTUAL_PRINTER_MODELS = {\n    # X1 Series\n    \"BL-P001\": \"X1C\",  # X1 Carbon\n    \"BL-P002\": \"X1\",  # X1\n    \"C13\": \"X1E\",  # X1E\n    # X2 Series\n    \"N6\": \"X2D\",  # X2D\n    # P Series\n    \"C11\": \"P1P\",  # P1P\n    \"C12\": \"P1S\",  # P1S\n    \"N7\": \"P2S\",  # P2S\n    # A1 Series\n    \"N2S\": \"A1\",  # A1\n    \"N1\": \"A1 Mini\",  # A1 Mini\n    # H2 Series\n    \"O1D\": \"H2D\",  # H2D\n    \"O1C\": \"H2C\",  # H2C\n    \"O1C2\": \"H2C\",  # H2C (dual nozzle variant)\n    \"O1S\": \"H2S\",  # H2S\n}\n\n# Serial number prefixes for each model (based on Bambu Lab serial number format)\n# Format: MMM??RYMDDUUUUU (15 chars total)\n#   MMM = Model prefix (3 chars)\n#   ?? = Unknown/revision code (2 chars)\n#   R = Revision letter (1 char)\n#   Y = Year digit (1 char)\n#   M = Month (1 char, hex: 1-9, A=Oct, B=Nov, C=Dec)\n#   DD = Day (2 chars)\n#   UUUUU = Unit number (5 chars)\nMODEL_SERIAL_PREFIXES = {\n    # X1 Series\n    \"BL-P001\": \"00M00A\",  # X1C\n    \"BL-P002\": \"00M00A\",  # X1\n    \"C13\": \"03W00A\",  # X1E\n    # X2 Series\n    \"N6\": \"20P90A\",  # X2D (first 4 chars \"20P9\" match real serials)\n    # P Series\n    \"C11\": \"01S00A\",  # P1P\n    \"C12\": \"01P00A\",  # P1S\n    \"N7\": \"22E00A\",  # P2S\n    # A1 Series\n    \"N2S\": \"03900A\",  # A1\n    \"N1\": \"03000A\",  # A1 Mini\n    # H2 Series\n    \"O1D\": \"09400A\",  # H2D\n    \"O1C\": \"09400A\",  # H2C\n    \"O1C2\": \"09400A\",  # H2C (dual nozzle variant)\n    \"O1S\": \"09400A\",  # H2S\n}\n\n# Reverse mapping: display name → SSDP model code (for auto-inheriting from printer model)\nDISPLAY_NAME_TO_MODEL_CODE = {v: k for k, v in VIRTUAL_PRINTER_MODELS.items()}\n\n# Default model\nDEFAULT_VIRTUAL_PRINTER_MODEL = \"BL-P001\"  # X1C\n\n\ndef _get_serial_for_model(model: str, serial_suffix: str) -> str:\n    \"\"\"Get serial number for the given model and suffix.\"\"\"\n    prefix = MODEL_SERIAL_PREFIXES.get(model, \"00M09A\")\n    return f\"{prefix}{serial_suffix}\"\n\n\nclass VirtualPrinterInstance:\n    \"\"\"Per-printer state and file handling logic.\n\n    Each instance represents one virtual printer with its own config,\n    upload directory, certificates, and file handling mode.\n    \"\"\"\n\n    def __init__(\n        self,\n        *,\n        vp_id: int,\n        name: str,\n        mode: str,\n        model: str,\n        access_code: str,\n        serial_suffix: str,\n        target_printer_ip: str = \"\",\n        target_printer_serial: str = \"\",\n        target_printer_id: int | None = None,\n        auto_dispatch: bool = True,\n        bind_ip: str = \"\",\n        remote_interface_ip: str = \"\",\n        base_dir: Path,\n        session_factory: Callable | None = None,\n    ):\n        self.id = vp_id\n        self.name = name\n        self.mode = mode\n        self.model = model\n        self.access_code = access_code\n        self.serial_suffix = serial_suffix\n        self.target_printer_ip = target_printer_ip\n        self.target_printer_serial = target_printer_serial\n        self.target_printer_id = target_printer_id\n        self.auto_dispatch = auto_dispatch\n        self.bind_ip = bind_ip\n        self.remote_interface_ip = remote_interface_ip\n        self._session_factory = session_factory\n\n        # Directories\n        self.upload_dir = base_dir / \"uploads\" / str(vp_id)\n        self.cert_dir = base_dir / \"certs\" / str(vp_id)\n        shared_ca_dir = base_dir / \"certs\"\n\n        # Ensure directories exist\n        self.upload_dir.mkdir(parents=True, exist_ok=True)\n        (self.upload_dir / \"cache\").mkdir(exist_ok=True)\n        self.cert_dir.mkdir(parents=True, exist_ok=True)\n\n        # Certificate service (shared CA, per-instance printer cert)\n        self._cert_service = CertificateService(\n            cert_dir=self.cert_dir,\n            serial=self.serial,\n            shared_ca_dir=shared_ca_dir,\n        )\n\n        # Pending files for MQTT correlation\n        self._pending_files: dict[str, Path] = {}\n\n        # Per-instance services\n        self._proxy: SlicerProxyManager | None = None\n        self._ftp: VirtualPrinterFTPServer | None = None\n        self._mqtt: SimpleMQTTServer | None = None\n        self._bind: BindServer | None = None\n        self._ssdp: VirtualPrinterSSDPServer | None = None\n        self._ssdp_proxy: SSDPProxy | None = None\n        self._tasks: list[asyncio.Task] = []\n\n    @property\n    def serial(self) -> str:\n        \"\"\"Full serial number for this virtual printer.\"\"\"\n        return _get_serial_for_model(self.model or DEFAULT_VIRTUAL_PRINTER_MODEL, self.serial_suffix)\n\n    @property\n    def cert_path(self) -> Path:\n        return self._cert_service.cert_path\n\n    @property\n    def key_path(self) -> Path:\n        return self._cert_service.key_path\n\n    @property\n    def is_proxy(self) -> bool:\n        return self.mode == \"proxy\"\n\n    @property\n    def is_running(self) -> bool:\n        return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)\n\n    def generate_certificates(self) -> tuple[Path, Path]:\n        \"\"\"Generate certificates for this instance.\"\"\"\n        self._cert_service.serial = self.serial if not self.is_proxy else (self.target_printer_serial or self.serial)\n        additional_ips = [self.remote_interface_ip] if self.remote_interface_ip else None\n        if self.bind_ip:\n            additional_ips = additional_ips or []\n            additional_ips.append(self.bind_ip)\n        self._cert_service.delete_printer_certificate()\n        return self._cert_service.generate_certificates(additional_ips=additional_ips)\n\n    # -- File handling callbacks --\n\n    async def on_file_received(self, file_path: Path, source_ip: str) -> None:\n        \"\"\"Handle file upload completion from FTP.\"\"\"\n        logger.info(\"[VP %s] Received file: %s from %s\", self.name, file_path.name, source_ip)\n\n        self._pending_files[file_path.name] = file_path\n\n        if self.mode == \"immediate\":\n            await self._archive_file(file_path, source_ip)\n        elif self.mode == \"print_queue\":\n            await self._add_to_print_queue(file_path, source_ip)\n        else:\n            await self._queue_file(file_path, source_ip)\n\n        # Reset MQTT status back to IDLE\n        if self._mqtt and file_path.suffix.lower() == \".3mf\":\n            self._mqtt.set_gcode_state(\"IDLE\")\n\n    async def on_print_command(self, filename: str, data: dict) -> None:\n        \"\"\"Handle print command from MQTT.\"\"\"\n        logger.info(\"[VP %s] Print command for: %s\", self.name, filename)\n\n    async def _archive_file(self, file_path: Path, source_ip: str) -> None:\n        \"\"\"Archive file immediately.\"\"\"\n        if not self._session_factory:\n            logger.error(\"Cannot archive: no database session factory configured\")\n            return\n\n        if file_path.suffix.lower() != \".3mf\":\n            logger.debug(\"Skipping non-3MF file: %s\", file_path.name)\n            self._pending_files.pop(file_path.name, None)\n            try:\n                file_path.unlink()\n            except OSError:\n                pass\n            return\n\n        try:\n            from backend.app.services.archive import ArchiveService\n\n            async with self._session_factory() as db:\n                service = ArchiveService(db)\n                archive = await service.archive_print(\n                    printer_id=None,\n                    source_file=file_path,\n                    print_data={\n                        \"status\": \"archived\",\n                        \"source\": \"virtual_printer\",\n                        \"source_ip\": source_ip,\n                    },\n                )\n                if archive:\n                    logger.info(\"[VP %s] Archived: %s - %s\", self.name, archive.id, archive.print_name)\n                    try:\n                        file_path.unlink()\n                    except OSError:\n                        pass\n                    self._pending_files.pop(file_path.name, None)\n                else:\n                    logger.error(\"Failed to archive file: %s\", file_path.name)\n        except Exception as e:\n            logger.error(\"Error archiving file: %s\", e)\n\n    async def _queue_file(self, file_path: Path, source_ip: str) -> None:\n        \"\"\"Queue file for user review.\"\"\"\n        if not self._session_factory:\n            logger.error(\"Cannot queue: no database session factory configured\")\n            return\n\n        if file_path.suffix.lower() != \".3mf\":\n            self._pending_files.pop(file_path.name, None)\n            try:\n                file_path.unlink()\n            except OSError:\n                pass\n            return\n\n        try:\n            from backend.app.models.pending_upload import PendingUpload\n\n            async with self._session_factory() as db:\n                pending = PendingUpload(\n                    filename=file_path.name,\n                    file_path=str(file_path),\n                    file_size=file_path.stat().st_size,\n                    source_ip=source_ip,\n                    status=\"pending\",\n                    uploaded_at=datetime.now(timezone.utc),\n                )\n                db.add(pending)\n                await db.commit()\n                logger.info(\"[VP %s] Queued: %s - %s\", self.name, pending.id, file_path.name)\n                self._pending_files.pop(file_path.name, None)\n        except Exception as e:\n            logger.error(\"Error queueing file: %s\", e)\n\n    async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:\n        \"\"\"Archive file and add to print queue, assigned to target printer or model.\"\"\"\n        if not self._session_factory:\n            logger.error(\"Cannot add to print queue: no database session factory configured\")\n            return\n\n        if file_path.suffix.lower() != \".3mf\":\n            self._pending_files.pop(file_path.name, None)\n            try:\n                file_path.unlink()\n            except OSError:\n                pass\n            return\n\n        try:\n            from backend.app.models.print_queue import PrintQueueItem\n            from backend.app.services.archive import ArchiveService\n\n            async with self._session_factory() as db:\n                service = ArchiveService(db)\n                archive = await service.archive_print(\n                    printer_id=None,\n                    source_file=file_path,\n                    print_data={\n                        \"status\": \"archived\",\n                        \"source\": \"virtual_printer\",\n                        \"source_ip\": source_ip,\n                    },\n                )\n                if archive:\n                    logger.info(\"[VP %s] Archived: %s - %s\", self.name, archive.id, archive.print_name)\n                    # Assign to specific printer if configured, otherwise use model for \"Any X\" scheduling\n                    target_model = None\n                    if not self.target_printer_id and self.model:\n                        target_model = VIRTUAL_PRINTER_MODELS.get(self.model)\n                    plate_id = self._extract_plate_id(file_path)\n                    queue_item = PrintQueueItem(\n                        printer_id=self.target_printer_id,\n                        target_model=target_model,\n                        archive_id=archive.id,\n                        plate_id=plate_id,\n                        position=1,\n                        status=\"pending\",\n                        manual_start=not self.auto_dispatch,\n                    )\n                    db.add(queue_item)\n                    await db.commit()\n                    logger.info(\"[VP %s] Added to queue: %s\", self.name, queue_item.id)\n                    try:\n                        file_path.unlink()\n                    except OSError:\n                        pass\n                    self._pending_files.pop(file_path.name, None)\n                else:\n                    logger.error(\"Failed to archive file: %s\", file_path.name)\n        except Exception as e:\n            logger.error(\"Error adding to print queue: %s\", e)\n\n    @staticmethod\n    def _extract_plate_id(file_path: Path) -> int | None:\n        \"\"\"Extract plate index from 3MF slice_info.config.\"\"\"\n        try:\n            import xml.etree.ElementTree as ET\n            import zipfile\n\n            with zipfile.ZipFile(file_path, \"r\") as zf:\n                if \"Metadata/slice_info.config\" in zf.namelist():\n                    content = zf.read(\"Metadata/slice_info.config\").decode()\n                    root = ET.fromstring(content)  # noqa: S314  # nosec B314\n                    plate = root.find(\".//plate\")\n                    if plate is not None:\n                        for meta in plate.findall(\"metadata\"):\n                            if meta.get(\"key\") == \"index\" and meta.get(\"value\"):\n                                return int(meta.get(\"value\"))\n        except Exception:\n            return None\n        return None\n\n    # -- Service lifecycle --\n\n    async def start_server(self) -> None:\n        \"\"\"Start server-mode services (FTP, MQTT, SSDP, Bind) on this VP's bind_ip.\"\"\"\n        logger.info(\"[VP %s] Starting server-mode services on %s\", self.name, self.bind_ip)\n\n        cert_path, key_path = self.generate_certificates()\n        bind_addr = self.bind_ip or \"0.0.0.0\"  # nosec B104\n\n        async def run_with_logging(coro, svc_name):\n            try:\n                await coro\n            except Exception as e:\n                logger.error(\"[VP %s] %s failed: %s\", self.name, svc_name, e)\n\n        self._tasks = []\n\n        # FTP server\n        self._ftp = VirtualPrinterFTPServer(\n            upload_dir=self.upload_dir,\n            access_code=self.access_code,\n            cert_path=cert_path,\n            key_path=key_path,\n            on_file_received=self.on_file_received,\n            bind_address=bind_addr,\n            vp_name=self.name,\n        )\n        self._tasks.append(\n            asyncio.create_task(\n                run_with_logging(self._ftp.start(), \"FTP\"),\n                name=f\"vp_{self.id}_ftp\",\n            )\n        )\n\n        # MQTT server\n        self._mqtt = SimpleMQTTServer(\n            serial=self.serial,\n            access_code=self.access_code,\n            cert_path=cert_path,\n            key_path=key_path,\n            on_print_command=self.on_print_command,\n            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n            bind_address=bind_addr,\n            vp_name=self.name,\n        )\n        self._tasks.append(\n            asyncio.create_task(\n                run_with_logging(self._mqtt.start(), \"MQTT\"),\n                name=f\"vp_{self.id}_mqtt\",\n            )\n        )\n\n        # Bind server\n        self._bind = BindServer(\n            serial=self.serial,\n            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n            name=self.name,\n            bind_address=bind_addr,\n            cert_path=cert_path,\n            key_path=key_path,\n        )\n        self._tasks.append(\n            asyncio.create_task(\n                run_with_logging(self._bind.start(), \"Bind\"),\n                name=f\"vp_{self.id}_bind\",\n            )\n        )\n\n        # SSDP server\n        self._ssdp = VirtualPrinterSSDPServer(\n            name=self.name,\n            serial=self.serial,\n            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n            advertise_ip=self.remote_interface_ip or self.bind_ip or \"\",\n            bind_ip=bind_addr,\n        )\n        self._tasks.append(\n            asyncio.create_task(\n                run_with_logging(self._ssdp.start(), \"SSDP\"),\n                name=f\"vp_{self.id}_ssdp\",\n            )\n        )\n\n        logger.info(\"[VP %s] Server-mode services started on %s\", self.name, bind_addr)\n\n    async def stop_server(self) -> None:\n        \"\"\"Stop server-mode services.\"\"\"\n        if self._ftp:\n            await self._ftp.stop()\n            self._ftp = None\n        if self._mqtt:\n            await self._mqtt.stop()\n            self._mqtt = None\n        if self._bind:\n            await self._bind.stop()\n            self._bind = None\n        if self._ssdp:\n            await self._ssdp.stop()\n            self._ssdp = None\n        await self._cancel_tasks()\n\n    async def start_proxy(self) -> None:\n        \"\"\"Start proxy mode services for this instance.\"\"\"\n        logger.info(\"[VP %s] Starting proxy mode to %s\", self.name, self.target_printer_ip)\n\n        cert_path, key_path = self.generate_certificates()\n\n        self._proxy = SlicerProxyManager(\n            target_host=self.target_printer_ip,\n            cert_path=cert_path,\n            key_path=key_path,\n            on_activity=lambda n, m: logger.info(\"[VP %s] Proxy %s: %s\", self.name, n, m),\n            bind_address=self.bind_ip or \"0.0.0.0\",  # nosec B104\n            bind_identity={\n                \"serial\": self.target_printer_serial or self.serial,\n                \"model\": self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n                \"name\": self.name,\n                \"version\": \"01.00.00.00\",\n            },\n        )\n\n        async def run_with_logging(coro, svc_name):\n            try:\n                await coro\n            except Exception as e:\n                logger.error(\"[VP %s] %s failed: %s\", self.name, svc_name, e)\n\n        self._tasks = []\n\n        # SSDP for proxy\n        proxy_serial = self.target_printer_serial or self.serial\n        if self.remote_interface_ip:\n            from backend.app.services.network_utils import find_interface_for_ip\n\n            local_iface = find_interface_for_ip(self.target_printer_ip)\n            if local_iface:\n                self._ssdp_proxy = SSDPProxy(\n                    local_interface_ip=local_iface[\"ip\"],\n                    remote_interface_ip=self.remote_interface_ip,\n                    target_printer_ip=self.target_printer_ip,\n                    name=self.name,\n                )\n                self._tasks.append(\n                    asyncio.create_task(\n                        run_with_logging(self._ssdp_proxy.start(), \"SSDP Proxy\"),\n                        name=f\"vp_{self.id}_ssdp_proxy\",\n                    )\n                )\n            else:\n                self._start_fallback_ssdp(proxy_serial, run_with_logging)\n        else:\n            self._start_fallback_ssdp(proxy_serial, run_with_logging)\n\n        self._tasks.append(\n            asyncio.create_task(\n                run_with_logging(self._proxy.start(), \"Proxy\"),\n                name=f\"vp_{self.id}_proxy\",\n            )\n        )\n\n    def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:\n        \"\"\"Start single-interface SSDP server as fallback for proxy mode.\"\"\"\n        self._ssdp = VirtualPrinterSSDPServer(\n            name=f\"{self.name} (Proxy)\",\n            serial=proxy_serial,\n            model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n            advertise_ip=self.bind_ip or \"\",\n            bind_ip=self.bind_ip or \"\",\n        )\n        self._tasks.append(\n            asyncio.create_task(\n                run_with_logging(self._ssdp.start(), \"SSDP\"),\n                name=f\"vp_{self.id}_ssdp\",\n            )\n        )\n\n    async def stop_proxy(self) -> None:\n        \"\"\"Stop proxy mode services for this instance.\"\"\"\n        if self._proxy:\n            await self._proxy.stop()\n            self._proxy = None\n        if self._ssdp:\n            await self._ssdp.stop()\n            self._ssdp = None\n        if self._ssdp_proxy:\n            await self._ssdp_proxy.stop()\n            self._ssdp_proxy = None\n        await self._cancel_tasks()\n\n    async def _cancel_tasks(self) -> None:\n        \"\"\"Cancel all running tasks and wait for cleanup.\"\"\"\n        for task in self._tasks:\n            task.cancel()\n        if self._tasks:\n            try:\n                await asyncio.wait_for(asyncio.gather(*self._tasks, return_exceptions=True), timeout=1.0)\n            except TimeoutError:\n                pass\n        self._tasks = []\n\n    def get_status(self) -> dict:\n        \"\"\"Get status for this instance.\"\"\"\n        status: dict = {\n            \"running\": self.is_running,\n            \"pending_files\": len(self._pending_files),\n        }\n        if self.is_proxy and self._proxy:\n            status[\"proxy\"] = self._proxy.get_status()\n        return status\n\n\nclass VirtualPrinterManager:\n    \"\"\"Multi-instance virtual printer registry and orchestrator.\n\n    Every VP runs its own independent services on a dedicated bind IP.\n    \"\"\"\n\n    def __init__(self):\n        self._session_factory: Callable | None = None\n        self._instances: dict[int, VirtualPrinterInstance] = {}\n\n        # Directories\n        self._base_dir = app_settings.base_dir / \"virtual_printer\"\n\n        # Ensure base directories exist\n        self._ensure_base_directories()\n\n    def _ensure_base_directories(self) -> None:\n        \"\"\"Create base directories at startup.\"\"\"\n        for dir_path in [self._base_dir, self._base_dir / \"uploads\", self._base_dir / \"certs\"]:\n            try:\n                dir_path.mkdir(parents=True, exist_ok=True)\n            except PermissionError:\n                logger.error(\n                    f\"Cannot create directory {dir_path}: Permission denied. \"\n                    f\"For Docker: ensure the data volume is writable by the container user. \"\n                    f\"For bare metal: run 'sudo chown -R $(whoami) {self._base_dir}'\"\n                )\n\n    def set_session_factory(self, session_factory: Callable) -> None:\n        \"\"\"Set the database session factory.\"\"\"\n        self._session_factory = session_factory\n\n    @property\n    def is_enabled(self) -> bool:\n        \"\"\"Check if any virtual printer is running.\"\"\"\n        return len(self._instances) > 0\n\n    async def sync_from_db(self) -> None:\n        \"\"\"Load all VPs from DB, reconcile running state.\"\"\"\n        if not self._session_factory:\n            logger.warning(\"Cannot sync virtual printers: no session factory\")\n            return\n\n        from sqlalchemy import select\n\n        from backend.app.models.printer import Printer\n        from backend.app.models.virtual_printer import VirtualPrinter\n\n        async with self._session_factory() as db:\n            result = await db.execute(\n                select(VirtualPrinter).where(VirtualPrinter.enabled == True).order_by(VirtualPrinter.position)  # noqa: E712\n            )\n            enabled_vps = result.scalars().all()\n\n        # Stop instances that are no longer enabled or changed mode\n        enabled_ids = {vp.id for vp in enabled_vps}\n        for vp_id in list(self._instances.keys()):\n            if vp_id not in enabled_ids:\n                await self.remove_instance(vp_id)\n\n        # Look up printer IPs for proxy VPs\n        proxy_vps = [vp for vp in enabled_vps if vp.mode == \"proxy\"]\n        proxy_ips: dict[int, tuple[str, str]] = {}\n        if proxy_vps:\n            async with self._session_factory() as db:\n                for pvp in proxy_vps:\n                    if pvp.target_printer_id:\n                        result = await db.execute(select(Printer).where(Printer.id == pvp.target_printer_id))\n                        printer = result.scalar_one_or_none()\n                        if printer:\n                            proxy_ips[pvp.id] = (printer.ip_address, printer.serial_number)\n\n        # Detect config changes on running instances and restart if needed\n        for vp in enabled_vps:\n            instance = self._instances.get(vp.id)\n            if not instance:\n                continue\n\n            changed = (\n                instance.mode != vp.mode\n                or instance.model != (vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL)\n                or instance.access_code != (vp.access_code or \"\")\n                or instance.bind_ip != (vp.bind_ip or \"\")\n                or instance.remote_interface_ip != (vp.remote_interface_ip or \"\")\n                or instance.target_printer_id != vp.target_printer_id\n                or instance.auto_dispatch != vp.auto_dispatch\n            )\n\n            if changed:\n                logger.info(\n                    \"VP %s config changed (mode: %s→%s), restarting\",\n                    instance.name,\n                    instance.mode,\n                    vp.mode,\n                )\n                await self.remove_instance(vp.id)\n\n        # Start instances for all enabled VPs (skip already running)\n        for vp in enabled_vps:\n            if vp.id in self._instances:\n                continue\n\n            if vp.mode == \"proxy\":\n                ip_info = proxy_ips.get(vp.id)\n                if not ip_info:\n                    logger.warning(\"Proxy VP %s: target printer not found, skipping\", vp.name)\n                    continue\n                target_ip, target_serial = ip_info\n                instance = VirtualPrinterInstance(\n                    vp_id=vp.id,\n                    name=vp.name,\n                    mode=vp.mode,\n                    model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n                    access_code=vp.access_code or \"\",\n                    serial_suffix=vp.serial_suffix,\n                    target_printer_ip=target_ip,\n                    target_printer_serial=target_serial,\n                    auto_dispatch=vp.auto_dispatch,\n                    bind_ip=vp.bind_ip or \"\",\n                    remote_interface_ip=vp.remote_interface_ip or \"\",\n                    base_dir=self._base_dir,\n                    session_factory=self._session_factory,\n                )\n                self._instances[vp.id] = instance\n                await instance.start_proxy()\n                logger.info(\"Started proxy VP: %s → %s (bind=%s)\", instance.name, target_ip, instance.bind_ip)\n            else:\n                instance = VirtualPrinterInstance(\n                    vp_id=vp.id,\n                    name=vp.name,\n                    mode=vp.mode,\n                    model=vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n                    access_code=vp.access_code or \"\",\n                    serial_suffix=vp.serial_suffix,\n                    target_printer_id=vp.target_printer_id,\n                    auto_dispatch=vp.auto_dispatch,\n                    bind_ip=vp.bind_ip or \"\",\n                    remote_interface_ip=vp.remote_interface_ip or \"\",\n                    base_dir=self._base_dir,\n                    session_factory=self._session_factory,\n                )\n                self._instances[vp.id] = instance\n                await instance.start_server()\n                logger.info(\"Started server-mode VP: %s on %s\", instance.name, vp.bind_ip)\n\n    async def remove_instance(self, vp_id: int) -> None:\n        \"\"\"Stop and remove a single VP instance.\"\"\"\n        instance = self._instances.pop(vp_id, None)\n        if instance:\n            if instance.is_proxy:\n                await instance.stop_proxy()\n            else:\n                await instance.stop_server()\n            logger.info(\"Removed VP instance: %s\", instance.name)\n\n    async def stop_all(self) -> None:\n        \"\"\"Shutdown all virtual printer services.\"\"\"\n        logger.info(\"Stopping all virtual printer services...\")\n\n        for vp_id in list(self._instances.keys()):\n            await self.remove_instance(vp_id)\n\n        logger.info(\"All virtual printer services stopped\")\n\n    def get_instance(self, vp_id: int) -> VirtualPrinterInstance | None:\n        \"\"\"Get a running instance by ID.\"\"\"\n        return self._instances.get(vp_id)\n\n    def get_all_status(self) -> list[dict]:\n        \"\"\"Get status for all running instances.\"\"\"\n        return [\n            {\n                \"id\": inst.id,\n                \"name\": inst.name,\n                \"mode\": inst.mode,\n                **inst.get_status(),\n            }\n            for inst in self._instances.values()\n        ]\n\n    # -- Legacy single-printer compat --\n\n    def get_status(self) -> dict:\n        \"\"\"Get status for first virtual printer (backward compat).\"\"\"\n        if self._instances:\n            first = next(iter(self._instances.values()))\n            return {\n                \"enabled\": True,\n                \"running\": first.is_running,\n                \"mode\": first.mode,\n                \"name\": first.name,\n                \"serial\": first.serial,\n                \"model\": first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n                \"model_name\": VIRTUAL_PRINTER_MODELS.get(\n                    first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n                    first.model or DEFAULT_VIRTUAL_PRINTER_MODEL,\n                ),\n                \"pending_files\": first.get_status().get(\"pending_files\", 0),\n                **({\"target_printer_ip\": first.target_printer_ip} if first.is_proxy else {}),\n                **({\"proxy\": first.get_status().get(\"proxy\", {})} if first.is_proxy else {}),\n            }\n        return {\n            \"enabled\": False,\n            \"running\": False,\n            \"mode\": \"immediate\",\n            \"name\": \"Bambuddy\",\n            \"serial\": \"\",\n            \"model\": DEFAULT_VIRTUAL_PRINTER_MODEL,\n            \"model_name\": VIRTUAL_PRINTER_MODELS[DEFAULT_VIRTUAL_PRINTER_MODEL],\n            \"pending_files\": 0,\n        }\n\n    async def configure(\n        self,\n        enabled: bool,\n        access_code: str = \"\",\n        mode: str = \"immediate\",\n        model: str = \"\",\n        target_printer_ip: str = \"\",\n        target_printer_serial: str = \"\",\n        remote_interface_ip: str = \"\",\n    ) -> None:\n        \"\"\"Legacy single-printer configure. Delegates to sync_from_db().\"\"\"\n        # This method is kept for backward compat with the settings endpoint.\n        # The actual work is done by sync_from_db() which reads from the DB.\n        await self.sync_from_db()\n\n\n# Global instance\nvirtual_printer_manager = VirtualPrinterManager()\n"
  },
  {
    "path": "backend/app/services/virtual_printer/mqtt_server.py",
    "content": "\"\"\"MQTT broker for virtual printer.\n\nImplements an MQTT broker that accepts connections from slicers,\nauthenticates with the configured access code, and logs print commands.\n\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport ssl\nfrom collections.abc import Callable\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n# Default MQTT port for Bambu printers (MQTT over TLS)\nMQTT_PORT = 8883\n\n# Model code → product_name for version response (must match what slicer expects)\nMODEL_PRODUCT_NAMES = {\n    \"BL-P001\": \"X1 Carbon\",\n    \"BL-P002\": \"X1\",\n    \"C13\": \"X1E\",\n    \"N6\": \"X2D\",\n    \"C11\": \"P1P\",\n    \"C12\": \"P1S\",\n    \"N7\": \"P2S\",\n    \"N2S\": \"A1\",\n    \"N1\": \"A1 mini\",\n    \"O1D\": \"H2D\",\n    \"O1C\": \"H2C\",\n    \"O1C2\": \"H2C\",\n    \"O1S\": \"H2S\",\n}\n\n\nclass VirtualPrinterMQTTServer:\n    \"\"\"MQTT broker that accepts connections from slicers.\n\n    This is a minimal MQTT broker implementation that:\n    - Accepts TLS connections on port 8883\n    - Authenticates with username 'bblp' and the configured access code\n    - Receives print commands on device/{serial}/request\n    - Can publish status on device/{serial}/report\n    \"\"\"\n\n    def __init__(\n        self,\n        serial: str,\n        access_code: str,\n        cert_path: Path,\n        key_path: Path,\n        port: int = MQTT_PORT,\n        on_print_command: Callable[[str, dict], None] | None = None,\n    ):\n        \"\"\"Initialize the MQTT server.\n\n        Args:\n            serial: Virtual printer serial number\n            access_code: Password for authentication\n            cert_path: Path to TLS certificate\n            key_path: Path to TLS private key\n            port: Port to listen on (default 8883)\n            on_print_command: Callback when print command received (filename, data)\n        \"\"\"\n        self.serial = serial\n        self.access_code = access_code\n        self.cert_path = cert_path\n        self.key_path = key_path\n        self.port = port\n        self.on_print_command = on_print_command\n        self._running = False\n        self._broker = None\n        self._broker_task = None\n\n    async def start(self) -> None:\n        \"\"\"Start the MQTT broker.\"\"\"\n        if self._running:\n            return\n\n        # Try to import amqtt\n        try:\n            from amqtt.broker import Broker\n        except ImportError:\n            logger.error(\"amqtt not installed. Run: pip install amqtt\")\n            return\n\n        logger.info(\"Starting virtual printer MQTT broker on port %s\", self.port)\n\n        # Build broker configuration\n        config = {\n            \"listeners\": {\n                \"default\": {\n                    \"type\": \"tcp\",\n                    \"bind\": f\"0.0.0.0:{self.port}\",\n                    \"ssl\": \"on\",\n                    \"certfile\": str(self.cert_path),\n                    \"keyfile\": str(self.key_path),\n                },\n            },\n            \"auth\": {\n                \"allow-anonymous\": False,\n                \"plugins\": [\"auth_custom\"],\n            },\n            \"topic-check\": {\n                \"enabled\": False,  # Allow any topic\n            },\n        }\n\n        try:\n            self._running = True\n\n            # Create and start broker\n            self._broker = Broker(config)\n\n            # Register custom auth plugin\n            self._broker.plugins_manager.plugins_handlers[\"auth_custom\"] = self._authenticate\n\n            # Start the broker\n            await self._broker.start()\n            logger.info(\"MQTT broker started on port %s\", self.port)\n\n            # Keep running\n            while self._running:\n                await asyncio.sleep(1)\n\n        except OSError as e:\n            if e.errno == 98:  # Address already in use\n                logger.error(\"MQTT port %s is already in use\", self.port)\n            else:\n                logger.error(\"MQTT broker error: %s\", e)\n        except asyncio.CancelledError:\n            logger.debug(\"MQTT broker task cancelled\")\n        except Exception as e:\n            logger.error(\"MQTT broker error: %s\", e)\n        finally:\n            await self.stop()\n\n    async def _authenticate(self, session) -> bool:\n        \"\"\"Authenticate MQTT connection.\n\n        Args:\n            session: MQTT session with username/password\n\n        Returns:\n            True if authentication successful\n        \"\"\"\n        username = getattr(session, \"username\", None)\n        password = getattr(session, \"password\", None)\n\n        # Bambu slicers use 'bblp' as username and access code as password\n        if username == \"bblp\" and password == self.access_code:\n            logger.debug(\"MQTT client authenticated from %s\", session.remote_address)\n            return True\n\n        logger.warning(\"MQTT auth failed for user '%s' from %s\", username, session.remote_address)\n        return False\n\n    async def stop(self) -> None:\n        \"\"\"Stop the MQTT broker.\"\"\"\n        logger.info(\"Stopping MQTT broker\")\n        self._running = False\n\n        if self._broker:\n            try:\n                await self._broker.shutdown()\n            except OSError as e:\n                logger.debug(\"Error shutting down MQTT broker: %s\", e)\n            self._broker = None\n\n\nclass SimpleMQTTServer:\n    \"\"\"Simplified MQTT server using raw sockets.\n\n    This is a fallback implementation that handles basic MQTT protocol\n    without requiring the amqtt library. It's less feature-complete but\n    more lightweight.\n    \"\"\"\n\n    def __init__(\n        self,\n        serial: str,\n        access_code: str,\n        cert_path: Path,\n        key_path: Path,\n        port: int = MQTT_PORT,\n        on_print_command: Callable[[str, dict], None] | None = None,\n        model: str = \"\",\n        bind_address: str = \"0.0.0.0\",  # nosec B104\n        vp_name: str = \"\",\n    ):\n        self.serial = serial\n        self.access_code = access_code\n        self.model = model\n        self.cert_path = cert_path\n        self.key_path = key_path\n        self.port = port\n        self.on_print_command = on_print_command\n        self.bind_address = bind_address\n        self.vp_name = vp_name\n        self._log_prefix = f\"[{vp_name}] \" if vp_name else \"\"\n        self._running = False\n        self._server = None\n        self._clients: dict[str, asyncio.StreamWriter] = {}\n        # Per-client \"effective serial\" — the serial the slicer actually uses in\n        # device/{serial}/report|request topics. Populated from the first\n        # SUBSCRIBE/PUBLISH we see on a connection. This lets the VP respond on\n        # the topic the slicer is listening on even when it disagrees with\n        # self.serial (e.g. a stale Orca config that was bound to an older VP\n        # serial, or a printer entry that was re-pointed at the VP IP without\n        # updating the serial).\n        self._client_serials: dict[str, str] = {}\n        self._status_push_task: asyncio.Task | None = None\n        self._sequence_id = 0\n\n        # Dynamic state for status reports\n        self._gcode_state = \"IDLE\"\n        self._current_file = \"\"\n        self._prepare_percent = \"0\"\n\n    async def start(self) -> None:\n        \"\"\"Start the MQTT server.\"\"\"\n        if self._running:\n            return\n\n        logger.info(\"Starting simple MQTT server on port %s\", self.port)\n\n        # Create SSL context with Bambu-compatible settings\n        ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n        ssl_context.load_cert_chain(str(self.cert_path), str(self.key_path))\n        # Match Bambu printer behavior - accept any client\n        ssl_context.verify_mode = ssl.CERT_NONE\n        # Allow TLS 1.2 for broader compatibility (some slicers may not support 1.3)\n        ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2\n        # Disable hostname checking\n        ssl_context.check_hostname = False\n\n        # Log certificate info\n        import subprocess\n\n        try:\n            result = subprocess.run(\n                [\"openssl\", \"x509\", \"-in\", str(self.cert_path), \"-noout\", \"-subject\", \"-issuer\"],\n                capture_output=True,\n                text=True,\n                timeout=5,\n            )\n            logger.info(\"MQTT SSL cert info: %s\", result.stdout.strip())\n        except (OSError, subprocess.SubprocessError):\n            pass  # Certificate info is for debug logging only; not critical\n\n        logger.info(\"MQTT SSL context: TLS 1.2+, cert=%s\", self.cert_path)\n\n        try:\n            self._running = True\n\n            # Wrapper to log ALL connection attempts including SSL errors\n            async def connection_handler(reader, writer):\n                try:\n                    addr = writer.get_extra_info(\"peername\")\n                    ssl_obj = writer.get_extra_info(\"ssl_object\")\n                    if ssl_obj:\n                        logger.info(\n                            f\"{self._log_prefix}MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}\"\n                        )\n                    else:\n                        logger.info(\"%sMQTT connection from %s (no TLS?)\", self._log_prefix, addr)\n                    await self._handle_client(reader, writer)\n                except ssl.SSLError as e:\n                    logger.error(\"MQTT SSL error: %s\", e)\n                except Exception as e:\n                    logger.error(\"MQTT connection handler error: %s\", e)\n\n            self._server = await asyncio.start_server(\n                connection_handler,\n                self.bind_address,\n                self.port,\n                ssl=ssl_context,\n            )\n\n            logger.info(\"Simple MQTT server listening on port %s\", self.port)\n\n            # Start periodic status push task\n            self._status_push_task = asyncio.create_task(self._periodic_status_push())\n\n            async with self._server:\n                await self._server.serve_forever()\n\n        except OSError as e:\n            if e.errno == 98:  # Address already in use\n                logger.error(\"MQTT port %s is already in use\", self.port)\n            else:\n                logger.error(\"MQTT server error: %s\", e)\n        except asyncio.CancelledError:\n            logger.debug(\"MQTT server task cancelled\")\n        except Exception as e:\n            logger.error(\"MQTT server error: %s\", e)\n        finally:\n            await self.stop()\n\n    async def stop(self) -> None:\n        \"\"\"Stop the MQTT server.\"\"\"\n        logger.info(\"Stopping simple MQTT server\")\n        self._running = False\n\n        # Stop periodic status push\n        if self._status_push_task:\n            self._status_push_task.cancel()\n            try:\n                await self._status_push_task\n            except asyncio.CancelledError:\n                pass  # Expected when stopping the periodic status push task\n            self._status_push_task = None\n\n        # Close all client connections (iterate over copy to avoid modification during iteration)\n        for _client_id, writer in list(self._clients.items()):\n            try:\n                writer.close()\n                await writer.wait_closed()\n            except OSError:\n                pass  # Best-effort client connection cleanup; client may have disconnected\n        self._clients.clear()\n        self._client_serials.clear()\n\n        if self._server:\n            try:\n                self._server.close()\n                await self._server.wait_closed()\n            except OSError:\n                pass  # Best-effort server shutdown; port may already be released\n            self._server = None\n\n    @staticmethod\n    def _extract_serial_from_topic(topic: str) -> str | None:\n        \"\"\"Pull the serial out of a `device/{serial}/report|request` topic.\n\n        Returns None if the topic doesn't match that shape — callers fall back\n        to self.serial in that case.\n        \"\"\"\n        if not topic.startswith(\"device/\"):\n            return None\n        rest = topic[len(\"device/\") :]\n        # Expect \"{serial}/report\" or \"{serial}/request\" (possibly with suffixes).\n        slash = rest.find(\"/\")\n        if slash <= 0:\n            return None\n        return rest[:slash]\n\n    async def _periodic_status_push(self) -> None:\n        \"\"\"Send periodic status updates to all connected clients.\"\"\"\n        logger.info(\"Starting periodic status push task\")\n        while self._running:\n            try:\n                await asyncio.sleep(1)  # Push every 1 second like real printers\n\n                # Send status to all connected clients\n                disconnected = []\n                for client_id, writer in list(self._clients.items()):\n                    try:\n                        if writer.is_closing():\n                            disconnected.append(client_id)\n                            continue\n                        serial = self._client_serials.get(client_id, self.serial)\n                        await self._send_status_report(writer, serial=serial)\n                    except OSError as e:\n                        logger.debug(\"Failed to push status to %s: %s\", client_id, e)\n                        disconnected.append(client_id)\n\n                # Remove disconnected clients\n                for client_id in disconnected:\n                    self._clients.pop(client_id, None)\n                    self._client_serials.pop(client_id, None)\n\n            except asyncio.CancelledError:\n                break\n            except Exception as e:\n                logger.error(\"Periodic status push error: %s\", e)\n\n        logger.info(\"Periodic status push task stopped\")\n\n    async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:\n        \"\"\"Handle an MQTT client connection.\"\"\"\n        addr = writer.get_extra_info(\"peername\")\n        client_id = f\"{addr[0]}:{addr[1]}\" if addr else \"unknown\"\n        logger.info(\"%sMQTT client connected: %s\", self._log_prefix, client_id)\n\n        authenticated = False\n\n        try:\n            while self._running:\n                # Read MQTT fixed header\n                try:\n                    header = await asyncio.wait_for(reader.read(1), timeout=60)\n                except TimeoutError:\n                    break\n\n                if not header:\n                    break\n\n                packet_type = (header[0] & 0xF0) >> 4\n\n                # Read remaining length\n                remaining_length = await self._read_remaining_length(reader)\n                if remaining_length is None:\n                    break\n\n                # Read payload\n                payload = await reader.read(remaining_length) if remaining_length > 0 else b\"\"\n\n                # Handle packet types\n                if packet_type == 1:  # CONNECT\n                    authenticated = await self._handle_connect(payload, writer)\n                    if not authenticated:\n                        break\n                    # Register client for periodic status pushes; start with\n                    # self.serial as the fallback until we learn the slicer's\n                    # preferred serial from the first SUBSCRIBE/PUBLISH.\n                    self._clients[client_id] = writer\n                    self._client_serials[client_id] = self.serial\n                elif packet_type == 3:  # PUBLISH\n                    if authenticated:\n                        await self._handle_publish(header[0], payload, writer, client_id)\n                elif packet_type == 8:  # SUBSCRIBE\n                    if authenticated:\n                        await self._handle_subscribe(payload, writer, client_id)\n                elif packet_type == 12:  # PINGREQ\n                    # Send PINGRESP\n                    writer.write(bytes([0xD0, 0x00]))\n                    await writer.drain()\n                elif packet_type == 14:  # DISCONNECT\n                    break\n\n        except asyncio.CancelledError:\n            pass  # Expected when server is shutting down and cancels client tasks\n        except Exception as e:\n            logger.debug(\"MQTT client error: %s\", e)\n        finally:\n            logger.debug(\"MQTT client disconnected: %s\", client_id)\n            self._clients.pop(client_id, None)\n            self._client_serials.pop(client_id, None)\n            try:\n                writer.close()\n                await writer.wait_closed()\n            except OSError:\n                pass  # Best-effort socket cleanup on client disconnect\n\n    async def _read_remaining_length(self, reader: asyncio.StreamReader) -> int | None:\n        \"\"\"Read MQTT remaining length (variable byte integer).\"\"\"\n        multiplier = 1\n        value = 0\n\n        for _ in range(4):\n            try:\n                byte = await reader.read(1)\n                if not byte:\n                    return None\n                encoded = byte[0]\n                value += (encoded & 127) * multiplier\n                if (encoded & 128) == 0:\n                    return value\n                multiplier *= 128\n            except OSError:\n                return None\n\n        return None\n\n    async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> bool:\n        \"\"\"Handle MQTT CONNECT packet.\n\n        Returns True if authentication successful.\n        \"\"\"\n        try:\n            # Parse CONNECT packet\n            # Skip protocol name length and name\n            idx = 0\n            proto_len = (payload[idx] << 8) | payload[idx + 1]\n            idx += 2 + proto_len\n\n            # Skip protocol level and connect flags\n            # connect_flags = payload[idx + 1]\n            idx += 2\n\n            # Skip keepalive\n            idx += 2\n\n            # Read client ID\n            client_id_len = (payload[idx] << 8) | payload[idx + 1]\n            idx += 2\n            # client_id = payload[idx : idx + client_id_len].decode(\"utf-8\")\n            idx += client_id_len\n\n            # Read username\n            username_len = (payload[idx] << 8) | payload[idx + 1]\n            idx += 2\n            username = payload[idx : idx + username_len].decode(\"utf-8\")\n            idx += username_len\n\n            # Read password\n            password_len = (payload[idx] << 8) | payload[idx + 1]\n            idx += 2\n            password = payload[idx : idx + password_len].decode(\"utf-8\")\n\n            # Authenticate\n            if username == \"bblp\" and password == self.access_code:\n                # Send CONNACK with success\n                writer.write(bytes([0x20, 0x02, 0x00, 0x00]))\n                await writer.drain()\n                logger.info(\"%sMQTT client authenticated successfully\", self._log_prefix)\n\n                # Send immediate status report after auth - slicer expects this\n                await self._send_status_report(writer)\n                return True\n            else:\n                # Send CONNACK with auth failure\n                writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized\n                await writer.drain()\n                logger.warning(\"%sMQTT auth failed for user '%s' (access code mismatch)\", self._log_prefix, username)\n                return False\n\n        except (IndexError, ValueError) as e:\n            logger.debug(\"MQTT CONNECT parse error: %s\", e)\n            # Send CONNACK with error\n            writer.write(bytes([0x20, 0x02, 0x00, 0x02]))  # Protocol error\n            await writer.drain()\n            return False\n\n    async def _handle_subscribe(self, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:\n        \"\"\"Handle MQTT SUBSCRIBE packet.\"\"\"\n        try:\n            # Parse packet ID\n            packet_id = (payload[0] << 8) | payload[1]\n\n            # Parse topic filters (just acknowledge them)\n            idx = 2\n            granted_qos = []\n            learned_serial: str | None = None\n            while idx < len(payload):\n                topic_len = (payload[idx] << 8) | payload[idx + 1]\n                idx += 2\n                topic = payload[idx : idx + topic_len].decode(\"utf-8\")\n                idx += topic_len\n                requested_qos = payload[idx]\n                idx += 1\n\n                logger.info(\"%sMQTT subscribe: %s QoS=%s\", self._log_prefix, topic, requested_qos)\n                granted_qos.append(min(requested_qos, 1))  # Grant up to QoS 1\n\n                # Remember the serial the slicer is listening on so status/version\n                # responses go to a topic it actually subscribed to.\n                if learned_serial is None:\n                    extracted = self._extract_serial_from_topic(topic)\n                    if extracted:\n                        learned_serial = extracted\n\n            if learned_serial and learned_serial != self._client_serials.get(client_id):\n                if learned_serial != self.serial:\n                    logger.info(\n                        \"%sMQTT client subscribed with serial %s (VP serial is %s) — adapting responses\",\n                        self._log_prefix,\n                        learned_serial,\n                        self.serial,\n                    )\n                self._client_serials[client_id] = learned_serial\n\n            # Send SUBACK\n            suback = bytes([0x90, 2 + len(granted_qos), packet_id >> 8, packet_id & 0xFF])\n            suback += bytes(granted_qos)\n            writer.write(suback)\n            await writer.drain()\n\n            # Send initial status report after subscribe on the client's subscribed topic\n            await self._send_status_report(writer, serial=self._client_serials.get(client_id, self.serial))\n\n        except (IndexError, ValueError, OSError) as e:\n            logger.debug(\"MQTT SUBSCRIBE error: %s\", e)\n\n    async def _send_status_report(self, writer: asyncio.StreamWriter, serial: str | None = None) -> None:\n        \"\"\"Send a status report to the slicer after connection.\"\"\"\n        try:\n            # Build status message matching Bambu printer format\n            self._sequence_id += 1\n            status = {\n                \"print\": {\n                    \"sequence_id\": str(self._sequence_id),\n                    \"command\": \"push_status\",\n                    \"msg\": 0,\n                    \"gcode_state\": self._gcode_state,\n                    \"gcode_file\": self._current_file,\n                    \"gcode_file_prepare_percent\": self._prepare_percent,\n                    \"subtask_name\": self._current_file.replace(\".3mf\", \"\") if self._current_file else \"\",\n                    \"mc_print_stage\": \"\",\n                    \"mc_percent\": 0,\n                    \"mc_remaining_time\": 0,\n                    \"wifi_signal\": \"-44dBm\",\n                    \"print_error\": 0,\n                    \"print_type\": \"\",\n                    \"bed_temper\": 25.0,\n                    \"bed_target_temper\": 0.0,\n                    \"nozzle_temper\": 25.0,\n                    \"nozzle_target_temper\": 0.0,\n                    \"chamber_temper\": 25.0,\n                    \"cooling_fan_speed\": \"0\",\n                    \"big_fan1_speed\": \"0\",\n                    \"big_fan2_speed\": \"0\",\n                    \"heatbreak_fan_speed\": \"0\",\n                    \"spd_lvl\": 1,\n                    \"spd_mag\": 100,\n                    \"stg\": [],\n                    \"stg_cur\": 0,\n                    \"layer_num\": 0,\n                    \"total_layer_num\": 0,\n                    \"home_flag\": 256,  # Bit 8 = SD card present (HAS_SDCARD_NORMAL)\n                    \"hw_switch_state\": 0,\n                    \"online\": {\"ahb\": False, \"rfid\": False, \"version\": 7},\n                    \"ams_status\": 0,\n                    \"sdcard\": True,\n                    \"storage\": {\"free\": 1000000000, \"total\": 32000000000},\n                    \"upgrade_state\": {\n                        \"sequence_id\": 0,\n                        \"progress\": \"\",\n                        \"status\": \"\",\n                        \"consistency_request\": False,\n                        \"dis_state\": 0,\n                        \"err_code\": 0,\n                        \"force_upgrade\": False,\n                        \"message\": \"\",\n                        \"module\": \"\",\n                        \"new_version_state\": 2,\n                        \"new_ver_list\": [],\n                        \"ota_new_version_number\": \"\",\n                        \"ahb_new_version_number\": \"\",\n                    },\n                    \"ipcam\": {\n                        \"ipcam_dev\": \"1\",\n                        \"ipcam_record\": \"enable\",\n                        \"timelapse\": \"disable\",\n                        \"resolution\": \"1080p\",\n                        \"mode_bits\": 0,\n                    },\n                    \"xcam\": {\n                        \"allow_skip_parts\": False,\n                        \"buildplate_marker_detector\": True,\n                        \"first_layer_inspector\": True,\n                        \"halt_print_sensitivity\": \"medium\",\n                        \"print_halt\": True,\n                        \"printing_monitor\": True,\n                        \"spaghetti_detector\": True,\n                    },\n                    \"lights_report\": [{\"node\": \"chamber_light\", \"mode\": \"on\"}],\n                    \"nozzle_diameter\": \"0.4\",\n                    \"nozzle_type\": \"hardened_steel\",\n                }\n            }\n\n            await self._publish_to_report(writer, status, serial or self.serial)\n\n        except OSError as e:\n            logger.error(\"Failed to send status report: %s\", e)\n\n    async def _send_version_response(\n        self, writer: asyncio.StreamWriter, sequence_id: str, serial: str | None = None\n    ) -> None:\n        \"\"\"Send version info response to the slicer.\"\"\"\n        try:\n            product_name = MODEL_PRODUCT_NAMES.get(self.model, self.model or \"X1 Carbon\")\n            # The serial is embedded inside the module[].sn fields *and* used as\n            # the report topic. Use the client's effective serial so the slicer\n            # sees internal/topic consistency even when it differs from self.serial.\n            serial = serial or self.serial\n\n            # Build version response matching OrcaSlicer expectations\n            # Required fields per module: name, product_name, sw_ver, sw_new_ver, sn, hw_ver, flag\n            version_info = {\n                \"info\": {\n                    \"command\": \"get_version\",\n                    \"sequence_id\": sequence_id,\n                    \"module\": [\n                        {\n                            \"name\": \"ota\",\n                            \"product_name\": product_name,\n                            \"sw_ver\": \"01.07.00.00\",\n                            \"sw_new_ver\": \"\",\n                            \"hw_ver\": \"OTA\",\n                            \"sn\": serial,\n                            \"flag\": 0,\n                        },\n                        {\n                            \"name\": \"esp32\",\n                            \"product_name\": product_name,\n                            \"sw_ver\": \"01.07.22.25\",\n                            \"sw_new_ver\": \"\",\n                            \"hw_ver\": \"AP05\",\n                            \"sn\": serial,\n                            \"flag\": 0,\n                        },\n                        {\n                            \"name\": \"rv1126\",\n                            \"product_name\": product_name,\n                            \"sw_ver\": \"00.00.27.38\",\n                            \"sw_new_ver\": \"\",\n                            \"hw_ver\": \"AP05\",\n                            \"sn\": serial,\n                            \"flag\": 0,\n                        },\n                        {\n                            \"name\": \"th\",\n                            \"product_name\": product_name,\n                            \"sw_ver\": \"00.00.04.00\",\n                            \"sw_new_ver\": \"\",\n                            \"hw_ver\": \"TH07\",\n                            \"sn\": serial,\n                            \"flag\": 0,\n                        },\n                        {\n                            \"name\": \"mc\",\n                            \"product_name\": product_name,\n                            \"sw_ver\": \"00.00.10.00\",\n                            \"sw_new_ver\": \"\",\n                            \"hw_ver\": \"MC07\",\n                            \"sn\": serial,\n                            \"flag\": 0,\n                        },\n                    ],\n                }\n            }\n\n            await self._publish_to_report(writer, version_info, serial)\n            logger.info(\"Sent version response (product_name=%s)\", product_name)\n\n        except OSError as e:\n            logger.error(\"Failed to send version response: %s\", e)\n\n    def set_gcode_state(self, state: str, filename: str = \"\", prepare_percent: str = \"0\") -> None:\n        \"\"\"Update the gcode state reported to connected slicers.\n\n        Called by the manager to reflect FTP upload progress/completion.\n        \"\"\"\n        self._gcode_state = state\n        self._current_file = filename\n        self._prepare_percent = prepare_percent\n\n    async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict, serial: str = \"\") -> None:\n        \"\"\"Publish a message on the device report topic.\"\"\"\n        topic = f\"device/{serial or self.serial}/report\"\n        message = json.dumps(payload)\n\n        topic_bytes = topic.encode(\"utf-8\")\n        message_bytes = message.encode(\"utf-8\")\n\n        remaining = 2 + len(topic_bytes) + len(message_bytes)\n        packet = bytes([0x30])  # PUBLISH, QoS 0\n\n        while remaining > 0:\n            byte = remaining % 128\n            remaining //= 128\n            if remaining > 0:\n                byte |= 0x80\n            packet += bytes([byte])\n\n        packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])\n        packet += topic_bytes\n        packet += message_bytes\n\n        writer.write(packet)\n        # Timeout the drain to prevent blocking the event loop if the\n        # MQTT client stops reading (e.g. slicer busy with FTP upload).\n        try:\n            await asyncio.wait_for(writer.drain(), timeout=5)\n        except TimeoutError:\n            logger.debug(\"MQTT drain timeout for %s — client may be busy\", topic)\n\n    async def _send_print_response(\n        self, writer: asyncio.StreamWriter, sequence_id: str, filename: str, serial: str | None = None\n    ) -> None:\n        \"\"\"Send project_file acknowledgment matching real Bambu printer behavior.\"\"\"\n        # Update state so periodic status pushes reflect preparation\n        self._gcode_state = \"PREPARE\"\n        self._current_file = filename\n        self._prepare_percent = \"0\"\n\n        try:\n            # Send command acknowledgment — slicer expects to see\n            # command: \"project_file\" echoed back before starting FTP upload\n            subtask_name = filename.replace(\".3mf\", \"\") if filename else \"\"\n            response = {\n                \"print\": {\n                    \"command\": \"project_file\",\n                    \"sequence_id\": sequence_id,\n                    \"param\": \"Metadata/plate_1.gcode\",\n                    \"subtask_name\": subtask_name,\n                    \"gcode_state\": \"PREPARE\",\n                    \"gcode_file\": filename,\n                    \"gcode_file_prepare_percent\": \"0\",\n                    \"result\": \"SUCCESS\",\n                    \"msg\": 0,\n                }\n            }\n            await self._publish_to_report(writer, response, serial or self.serial)\n            logger.info(\"Sent project_file acknowledgment for %s\", filename)\n        except OSError as e:\n            logger.error(\"Failed to send print response: %s\", e)\n\n    async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter, client_id: str) -> None:\n        \"\"\"Handle MQTT PUBLISH packet.\"\"\"\n        try:\n            # Parse topic\n            idx = 0\n            topic_len = (payload[idx] << 8) | payload[idx + 1]\n            idx += 2\n            topic = payload[idx : idx + topic_len].decode(\"utf-8\")\n            idx += topic_len\n\n            # Check for packet ID (QoS > 0)\n            qos = (header & 0x06) >> 1\n            if qos > 0:\n                # packet_id = (payload[idx] << 8) | payload[idx + 1]\n                idx += 2\n\n            # Parse message\n            message = payload[idx:].decode(\"utf-8\")\n\n            logger.info(\"MQTT publish to %s: %s...\", topic, message[:100])\n\n            # Only handle publishes on *some* device/.../request topic. The\n            # serial is taken from the topic rather than compared against\n            # self.serial: the client is already authenticated via the access\n            # code, and Orca/BambuStudio may have a cached serial that differs\n            # from the VP's computed self.serial (#927). Use the topic's serial\n            # for all responses so they land on the topic the slicer subscribed\n            # to.\n            if not topic.startswith(\"device/\") or \"/request\" not in topic:\n                return\n\n            client_serial = self._extract_serial_from_topic(topic) or self.serial\n            if client_serial and client_serial != self._client_serials.get(client_id):\n                if client_serial != self.serial:\n                    logger.info(\n                        \"%sMQTT client publishing with serial %s (VP serial is %s) — adapting responses\",\n                        self._log_prefix,\n                        client_serial,\n                        self.serial,\n                    )\n                self._client_serials[client_id] = client_serial\n\n            try:\n                # Some slicer builds (observed with OrcaSlicer on Linux, #927)\n                # include the C-string null terminator in the MQTT payload\n                # length, so the decoded message ends with \\x00. Real brokers\n                # pass the bytes through; strict json.loads raises \"Extra data\"\n                # and every pushall/get_version/project_file silently dropped.\n                data = json.loads(message.rstrip(\"\\x00 \\r\\n\\t\"))\n            except json.JSONDecodeError as e:\n                logger.debug(\n                    \"MQTT publish JSON decode failed: %s (payload=%r)\",\n                    e,\n                    message[:200],\n                )\n                return\n\n            # Handle pushing command (status request)\n            if \"pushing\" in data:\n                pushing_data = data[\"pushing\"]\n                command = pushing_data.get(\"command\", \"\")\n                logger.info(\"MQTT pushing command: %s\", command)\n\n                if command == \"pushall\":\n                    # Slicer is requesting full status - send response\n                    logger.info(\"Sending status report in response to pushall\")\n                    await self._send_status_report(writer, serial=client_serial)\n                elif command == \"start\":\n                    # Slicer wants periodic status updates - send one now\n                    logger.info(\"Starting status push stream\")\n                    await self._send_status_report(writer, serial=client_serial)\n\n            # Handle info commands (get_version, etc.)\n            if \"info\" in data:\n                info_data = data[\"info\"]\n                command = info_data.get(\"command\", \"\")\n                sequence_id = info_data.get(\"sequence_id\", \"0\")\n                logger.info(\"MQTT info command: %s\", command)\n\n                if command == \"get_version\":\n                    await self._send_version_response(writer, sequence_id, serial=client_serial)\n\n            # Handle print commands\n            if \"print\" in data:\n                print_data = data[\"print\"]\n                command = print_data.get(\"command\", \"\")\n                filename = print_data.get(\"subtask_name\", \"\")\n                sequence_id = print_data.get(\"sequence_id\", \"0\")\n\n                logger.info(\"MQTT print command: %s for %s\", command, filename)\n\n                if command == \"project_file\":\n                    # Respond with PREPARE status so slicer proceeds with FTP upload\n                    file_3mf = print_data.get(\"file\", filename)\n                    await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)\n\n                    if self.on_print_command:\n                        await self._notify_print_command(filename, print_data)\n\n        except (IndexError, ValueError, OSError) as e:\n            logger.debug(\"MQTT PUBLISH error: %s\", e)\n\n    async def _notify_print_command(self, filename: str, data: dict) -> None:\n        \"\"\"Notify callback of print command.\"\"\"\n        if self.on_print_command:\n            try:\n                result = self.on_print_command(filename, data)\n                if asyncio.iscoroutine(result):\n                    await result\n            except Exception as e:\n                logger.error(\"Print command callback error: %s\", e)\n"
  },
  {
    "path": "backend/app/services/virtual_printer/ssdp_server.py",
    "content": "\"\"\"SSDP discovery responder for virtual printer.\n\nResponds to M-SEARCH requests from slicers and sends periodic NOTIFY\nannouncements so the virtual printer appears as a discoverable Bambu printer.\n\nAlso provides SSDP proxy functionality for proxy mode, where Bambuddy sits\nbetween two networks and re-broadcasts printer SSDP from LAN A to LAN B.\n\"\"\"\n\nimport asyncio\nimport logging\nimport re\nimport socket\nimport struct\n\nlogger = logging.getLogger(__name__)\n\n# SSDP addresses - Bambu uses port 2021\n# Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250\nSSDP_MULTICAST_ADDR = \"239.255.255.250\"\nSSDP_BROADCAST_ADDR = \"255.255.255.255\"\nSSDP_PORT = 2021\n\n# Bambu service target\nBAMBU_SEARCH_TARGET = \"urn:bambulab-com:device:3dprinter:1\"\n\n\nclass VirtualPrinterSSDPServer:\n    \"\"\"SSDP server that responds to discovery requests as a virtual Bambu printer.\"\"\"\n\n    def __init__(\n        self,\n        name: str = \"Bambuddy\",\n        serial: str = \"00M09A391800001\",  # X1C serial format for compatibility\n        model: str = \"BL-P001\",  # X1C model code for best compatibility\n        advertise_ip: str = \"\",\n        bind_ip: str = \"\",\n        extra_interfaces: list[str] | None = None,\n    ):\n        \"\"\"Initialize the SSDP server.\n\n        Args:\n            name: Display name shown in slicer discovery\n            serial: Unique serial number\n            model: Model code\n            advertise_ip: Override IP to advertise instead of auto-detecting\n            bind_ip: IP address to bind the SSDP socket to\n            extra_interfaces: Additional interface IPs to broadcast on (e.g. VPN).\n                NOTIFY and M-SEARCH responses are sent on these interfaces too,\n                but Location always points to the bind IP so the slicer connects\n                to the correct address for MQTT/FTP.\n        \"\"\"\n        self.name = name\n        self.serial = serial\n        self.model = model\n        self._bind_ip = bind_ip\n        self._running = False\n        self._socket: socket.socket | None = None\n        self._extra_sockets: list[socket.socket] = []\n        self._extra_interfaces = extra_interfaces or []\n        self._local_ip: str | None = advertise_ip or bind_ip or None\n\n    def _get_local_ip(self) -> str:\n        \"\"\"Get the local IP address to advertise.\"\"\"\n        if self._local_ip:\n            return self._local_ip\n\n        # Try to determine local IP by connecting to a public address\n        try:\n            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n            s.connect((\"8.8.8.8\", 80))\n            ip = s.getsockname()[0]\n            s.close()\n            self._local_ip = ip\n            return ip\n        except OSError:\n            return \"127.0.0.1\"\n\n    def _build_notify_message(self) -> bytes:\n        \"\"\"Build SSDP NOTIFY message for periodic announcements.\"\"\"\n        ip = self._get_local_ip()\n        message = (\n            \"NOTIFY * HTTP/1.1\\r\\n\"\n            f\"Host: {SSDP_MULTICAST_ADDR}:1990\\r\\n\"\n            \"Server: UPnP/1.0\\r\\n\"\n            f\"Location: {ip}\\r\\n\"\n            f\"NT: {BAMBU_SEARCH_TARGET}\\r\\n\"\n            \"NTS: ssdp:alive\\r\\n\"\n            f\"USN: {self.serial}\\r\\n\"\n            \"Cache-Control: max-age=1800\\r\\n\"\n            f\"DevModel.bambu.com: {self.model}\\r\\n\"\n            f\"DevName.bambu.com: {self.name}\\r\\n\"\n            \"DevSignal.bambu.com: -44\\r\\n\"\n            \"DevConnect.bambu.com: lan\\r\\n\"\n            \"DevBind.bambu.com: free\\r\\n\"\n            \"Devseclink.bambu.com: secure\\r\\n\"\n            \"DevInf.bambu.com: eth0\\r\\n\"\n            \"DevVersion.bambu.com: 01.07.00.00\\r\\n\"\n            \"DevCap.bambu.com: 1\\r\\n\"\n            \"\\r\\n\"\n        )\n        return message.encode()\n\n    def _build_response_message(self) -> bytes:\n        \"\"\"Build SSDP response message for M-SEARCH requests.\"\"\"\n        ip = self._get_local_ip()\n        message = (\n            \"HTTP/1.1 200 OK\\r\\n\"\n            \"Server: UPnP/1.0\\r\\n\"\n            f\"Location: {ip}\\r\\n\"\n            f\"ST: {BAMBU_SEARCH_TARGET}\\r\\n\"\n            f\"USN: {self.serial}\\r\\n\"\n            \"Cache-Control: max-age=1800\\r\\n\"\n            f\"DevModel.bambu.com: {self.model}\\r\\n\"\n            f\"DevName.bambu.com: {self.name}\\r\\n\"\n            \"DevSignal.bambu.com: -44\\r\\n\"\n            \"DevConnect.bambu.com: lan\\r\\n\"\n            \"DevBind.bambu.com: free\\r\\n\"\n            \"Devseclink.bambu.com: secure\\r\\n\"\n            \"DevInf.bambu.com: eth0\\r\\n\"\n            \"DevVersion.bambu.com: 01.07.00.00\\r\\n\"\n            \"DevCap.bambu.com: 1\\r\\n\"\n            \"\\r\\n\"\n        )\n        return message.encode()\n\n    async def start(self) -> None:\n        \"\"\"Start the SSDP server.\"\"\"\n        if self._running:\n            return\n\n        logger.info(\"Starting virtual printer SSDP server: %s (%s)\", self.name, self.serial)\n        self._running = True\n\n        try:\n            # Create UDP socket\n            self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)\n            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n\n            # Try to set SO_REUSEPORT if available\n            try:\n                self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)\n            except (AttributeError, OSError):\n                pass  # SO_REUSEPORT not available on all platforms; non-critical\n\n            # Set non-blocking mode\n            self._socket.setblocking(False)\n\n            # Bind to SSDP port on specific interface (or all interfaces)\n            self._socket.bind((self._bind_ip or \"\", SSDP_PORT))\n\n            # Join multicast group (on specific interface if bind_ip is set)\n            if self._bind_ip:\n                mreq = struct.pack(\n                    \"4s4s\",\n                    socket.inet_aton(SSDP_MULTICAST_ADDR),\n                    socket.inet_aton(self._bind_ip),\n                )\n            else:\n                mreq = struct.pack(\"4sl\", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)\n            self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)\n\n            # Enable broadcast\n            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)\n\n            # Set multicast TTL\n            self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)\n\n            local_ip = self._get_local_ip()\n            logger.info(\"SSDP server listening on port %s, advertising IP: %s\", SSDP_PORT, local_ip)\n            logger.info(\"Virtual printer: %s (%s) model=%s\", self.name, self.serial, self.model)\n\n            # Create extra sockets for additional interfaces (VPN, etc.)\n            # If no explicit extra interfaces given and we're bound to a\n            # specific IP, add a wildcard socket to catch M-SEARCH from\n            # other subnets (VPN tunnels, secondary NICs, etc.)\n            extra_ips = list(self._extra_interfaces)\n            if not extra_ips and self._bind_ip:\n                extra_ips.append(\"0.0.0.0\")  # nosec B104\n\n            for iface_ip in extra_ips:\n                try:\n                    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)\n                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n                    try:\n                        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)\n                    except (AttributeError, OSError):\n                        pass\n                    sock.setblocking(False)\n                    sock.bind((iface_ip, SSDP_PORT))\n                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)\n                    self._extra_sockets.append(sock)\n                    logger.info(\"SSDP server also listening on %s:%s\", iface_ip, SSDP_PORT)\n                except OSError as e:\n                    logger.warning(\"SSDP server: failed to bind extra interface %s: %s\", iface_ip, e)\n\n            # Send initial NOTIFY\n            await self._send_notify()\n            logger.info(\"Sent initial SSDP NOTIFY announcement\")\n\n            # Run receive and announce loops\n            last_notify = asyncio.get_event_loop().time()\n            notify_interval = 30.0  # Send NOTIFY every 30 seconds\n\n            while self._running:\n                # Try to receive M-SEARCH requests on primary socket\n                try:\n                    data, addr = self._socket.recvfrom(4096)\n                    message = data.decode(\"utf-8\", errors=\"ignore\")\n                    await self._handle_message(message, addr)\n                except BlockingIOError:\n                    pass  # No data available on non-blocking socket; will retry\n                except OSError as e:\n                    if self._running:\n                        logger.debug(\"SSDP receive error: %s\", e)\n\n                # Try to receive M-SEARCH requests on extra sockets\n                for sock in self._extra_sockets:\n                    try:\n                        data, addr = sock.recvfrom(4096)\n                        message = data.decode(\"utf-8\", errors=\"ignore\")\n                        await self._handle_message(message, addr, sock)\n                    except BlockingIOError:\n                        pass\n                    except OSError:\n                        pass\n\n                # Send periodic NOTIFY\n                now = asyncio.get_event_loop().time()\n                if now - last_notify >= notify_interval:\n                    await self._send_notify()\n                    last_notify = now\n\n                await asyncio.sleep(0.1)\n\n        except OSError as e:\n            if e.errno == 98:  # Address already in use\n                logger.warning(\"SSDP port %s in use - real printers may be running\", SSDP_PORT)\n            else:\n                logger.error(\"SSDP server error: %s\", e)\n        except asyncio.CancelledError:\n            logger.debug(\"SSDP server cancelled\")\n        except Exception as e:\n            logger.error(\"SSDP server error: %s\", e)\n        finally:\n            await self._cleanup()\n\n    async def stop(self) -> None:\n        \"\"\"Stop the SSDP server.\"\"\"\n        logger.info(\"Stopping SSDP server\")\n        self._running = False\n        await self._cleanup()\n\n    async def _cleanup(self) -> None:\n        \"\"\"Clean up resources.\"\"\"\n        if self._socket:\n            try:\n                # Send byebye message\n                await self._send_byebye()\n            except OSError:\n                pass  # Best-effort byebye broadcast; socket may already be closed\n\n            try:\n                self._socket.close()\n            except OSError:\n                pass  # Best-effort socket close; may already be released\n            self._socket = None\n\n        for sock in self._extra_sockets:\n            try:\n                sock.close()\n            except OSError:\n                pass\n        self._extra_sockets = []\n\n    async def _send_notify(self) -> None:\n        \"\"\"Send SSDP NOTIFY message via broadcast on all sockets.\"\"\"\n        msg = self._build_notify_message()\n\n        if self._socket:\n            try:\n                self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))\n                logger.debug(\n                    \"Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)\",\n                    self.name,\n                    self._get_local_ip(),\n                    self.serial,\n                    self._bind_ip,\n                )\n            except OSError as e:\n                logger.debug(\"Failed to send NOTIFY for %s: %s\", self.name, e)\n\n        for sock in self._extra_sockets:\n            try:\n                sock.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))\n            except OSError:\n                pass  # Best-effort broadcast on extra interfaces\n\n    async def _send_byebye(self) -> None:\n        \"\"\"Send SSDP byebye message when shutting down.\"\"\"\n        if not self._socket:\n            return\n\n        message = (\n            \"NOTIFY * HTTP/1.1\\r\\n\"\n            f\"Host: {SSDP_MULTICAST_ADDR}:1990\\r\\n\"\n            f\"NT: {BAMBU_SEARCH_TARGET}\\r\\n\"\n            \"NTS: ssdp:byebye\\r\\n\"\n            f\"USN: {self.serial}\\r\\n\"\n            \"\\r\\n\"\n        )\n\n        try:\n            self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))\n            logger.debug(\"Sent SSDP byebye\")\n        except OSError:\n            pass  # Best-effort byebye send; network may be unavailable during shutdown\n\n    async def _handle_message(\n        self, message: str, addr: tuple[str, int], reply_socket: socket.socket | None = None\n    ) -> None:\n        \"\"\"Handle incoming SSDP message.\n\n        Args:\n            message: The SSDP message content\n            addr: Tuple of (ip_address, port) of sender\n            reply_socket: Socket to send the response on (defaults to primary)\n        \"\"\"\n        # Check if this is an M-SEARCH request for Bambu printers\n        if \"M-SEARCH\" not in message:\n            return\n\n        # Check search target\n        if BAMBU_SEARCH_TARGET not in message and \"ssdp:all\" not in message.lower():\n            return\n\n        logger.debug(\"Received M-SEARCH from %s\", addr[0])\n\n        # Send response on the socket that received the request\n        sock = reply_socket or self._socket\n        if sock:\n            try:\n                response = self._build_response_message()\n                sock.sendto(response, addr)\n                logger.info(\n                    \"Sent SSDP response to %s for '%s' (Location=%s, USN=%s)\",\n                    addr[0],\n                    self.name,\n                    self._get_local_ip(),\n                    self.serial,\n                )\n            except OSError as e:\n                logger.debug(\"Failed to send SSDP response for %s: %s\", self.name, e)\n\n\nclass SSDPProxy:\n    \"\"\"SSDP proxy that re-broadcasts printer discovery from one network to another.\n\n    Listens for SSDP broadcasts from a real printer on the local interface (LAN A),\n    then re-broadcasts them on the remote interface (LAN B) with the Location\n    header changed to point to Bambuddy's IP on LAN B.\n\n    This allows Bambu Studio on LAN B to discover the printer via Bambuddy.\n    \"\"\"\n\n    def __init__(\n        self,\n        local_interface_ip: str,\n        remote_interface_ip: str,\n        target_printer_ip: str,\n        name: str | None = None,\n    ):\n        \"\"\"Initialize the SSDP proxy.\n\n        Args:\n            local_interface_ip: IP of interface on printer's network (LAN A)\n            remote_interface_ip: IP of interface on slicer's network (LAN B)\n            target_printer_ip: IP of the real printer to proxy SSDP for\n            name: Optional VP name to advertise (replaces printer's real name)\n        \"\"\"\n        self.local_interface_ip = local_interface_ip\n        self.remote_interface_ip = remote_interface_ip\n        self.target_printer_ip = target_printer_ip\n        self.proxy_name = name\n        self._running = False\n        self._local_socket: socket.socket | None = None\n        self._remote_socket: socket.socket | None = None\n        self._last_printer_ssdp: bytes | None = None\n        self._printer_info: dict[str, str] = {}\n\n    def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:\n        \"\"\"Parse SSDP message into header dict.\"\"\"\n        headers = {}\n        try:\n            text = data.decode(\"utf-8\", errors=\"ignore\")\n            for line in text.split(\"\\r\\n\"):\n                if \":\" in line:\n                    key, value = line.split(\":\", 1)\n                    headers[key.strip().lower()] = value.strip()\n        except Exception:\n            pass  # Return partial headers if parsing fails; malformed packets are common\n        return headers\n\n    def _rewrite_ssdp(self, data: bytes) -> bytes:\n        \"\"\"Rewrite SSDP message for proxy re-broadcast.\n\n        - Location: changed to Bambuddy's remote interface IP\n        - DevBind: forced to 'free' so the slicer treats the proxy as a\n          LAN-only printer (avoids cloud auth requirement for sending prints)\n        \"\"\"\n        try:\n            text = data.decode(\"utf-8\", errors=\"ignore\")\n            original = text\n            # Replace Location header with our remote interface IP\n            text = re.sub(\n                r\"(Location:\\s*)[\\d.]+\",\n                f\"\\\\g<1>{self.remote_interface_ip}\",\n                text,\n                flags=re.IGNORECASE,\n            )\n            # Force DevBind to 'free' - ensures slicer uses LAN mode for\n            # both monitoring AND sending prints through the proxy\n            text = re.sub(\n                r\"(DevBind\\.bambu\\.com:\\s*)\\S+\",\n                r\"\\g<1>free\",\n                text,\n                flags=re.IGNORECASE,\n            )\n            # Replace printer name with configured VP name, or append \" - Proxy\"\n            if self.proxy_name:\n                text = re.sub(\n                    r\"(DevName\\.bambu\\.com:\\s*)[^\\r\\n]+\",\n                    rf\"\\g<1>{self.proxy_name}\",\n                    text,\n                    flags=re.IGNORECASE,\n                )\n            else:\n                text = re.sub(\n                    r\"(DevName\\.bambu\\.com:\\s*)([^\\r\\n]+)\",\n                    r\"\\g<1>\\g<2> - Proxy\",\n                    text,\n                    flags=re.IGNORECASE,\n                )\n            if text != original:\n                logger.debug(\"Rewrote SSDP for proxy:\\n%s\", text)\n            else:\n                logger.warning(\"SSDP rewrite had no effect. Packet:\\n%s\", original)\n            return text.encode(\"utf-8\")\n        except Exception as e:\n            logger.error(\"Failed to rewrite SSDP: %s\", e)\n            return data\n\n    async def start(self) -> None:\n        \"\"\"Start the SSDP proxy.\"\"\"\n        if self._running:\n            return\n\n        logger.info(\n            f\"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), \"\n            f\"broadcasting on {self.remote_interface_ip} (LAN B), \"\n            f\"proxying printer {self.target_printer_ip}\"\n        )\n        self._running = True\n\n        try:\n            # Create socket for listening on LAN A (printer network)\n            # Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)\n            # We filter by source IP in the handler\n            self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)\n            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n            try:\n                self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)\n            except (AttributeError, OSError):\n                pass  # SO_REUSEPORT not available on all platforms; non-critical\n            self._local_socket.setblocking(False)\n            # Bind to all interfaces to receive broadcasts\n            self._local_socket.bind((\"\", SSDP_PORT))\n\n            # Join multicast group on local interface (for multicast SSDP if used)\n            mreq = struct.pack(\n                \"4s4s\",\n                socket.inet_aton(SSDP_MULTICAST_ADDR),\n                socket.inet_aton(self.local_interface_ip),\n            )\n            self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)\n            self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)\n\n            # Create socket for broadcasting on LAN B (slicer network)\n            self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)\n            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n            try:\n                self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)\n            except (AttributeError, OSError):\n                pass  # SO_REUSEPORT not available on all platforms; non-critical\n            self._remote_socket.setblocking(False)\n            # Bind to remote interface\n            self._remote_socket.bind((self.remote_interface_ip, 0))\n            self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)\n\n            logger.info(\n                \"SSDP proxy listening on 0.0.0.0:%s (filtering for printer %s)\", SSDP_PORT, self.target_printer_ip\n            )\n            logger.info(\"SSDP proxy will broadcast on %s\", self.remote_interface_ip)\n\n            # Main loop\n            last_broadcast = 0.0\n            broadcast_interval = 30.0  # Re-broadcast every 30 seconds\n\n            while self._running:\n                # Listen for SSDP from printer on LAN A\n                try:\n                    data, addr = self._local_socket.recvfrom(4096)\n                    await self._handle_local_packet(data, addr)\n                except BlockingIOError:\n                    pass  # No data available on non-blocking socket; will retry\n                except OSError as e:\n                    if self._running:\n                        logger.debug(\"SSDP proxy receive error: %s\", e)\n\n                # Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)\n                # For now, we periodically re-broadcast cached printer SSDP\n                now = asyncio.get_event_loop().time()\n                if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:\n                    await self._broadcast_to_remote()\n                    last_broadcast = now\n\n                await asyncio.sleep(0.1)\n\n        except OSError as e:\n            logger.error(\"SSDP proxy error: %s\", e)\n        except asyncio.CancelledError:\n            logger.debug(\"SSDP proxy cancelled\")\n        except Exception as e:\n            logger.error(\"SSDP proxy error: %s\", e)\n        finally:\n            await self._cleanup()\n\n    async def stop(self) -> None:\n        \"\"\"Stop the SSDP proxy.\"\"\"\n        logger.info(\"Stopping SSDP proxy\")\n        self._running = False\n        await self._cleanup()\n\n    async def _cleanup(self) -> None:\n        \"\"\"Clean up resources.\"\"\"\n        for sock in [self._local_socket, self._remote_socket]:\n            if sock:\n                try:\n                    sock.close()\n                except OSError:\n                    pass  # Best-effort socket close; may already be released\n        self._local_socket = None\n        self._remote_socket = None\n\n    async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:\n        \"\"\"Handle SSDP packet received on local interface (LAN A).\n\n        Processes two types of traffic:\n        - NOTIFY from the real printer → cache and re-broadcast on LAN B\n        - M-SEARCH from slicers on LAN B → respond with cached printer info\n        \"\"\"\n        sender_ip = addr[0]\n\n        # Ignore packets from our own interfaces (prevent loops)\n        if sender_ip in (self.local_interface_ip, self.remote_interface_ip):\n            return\n\n        # Handle M-SEARCH from slicers (any IP that's not the target printer)\n        if sender_ip != self.target_printer_ip:\n            if b\"M-SEARCH\" in data:\n                await self._respond_to_msearch(data, addr)\n            return\n\n        # Below: NOTIFY handling from the real printer\n\n        # Check if it's a NOTIFY message\n        if b\"NOTIFY\" not in data and b\"HTTP/1.1 200\" not in data:\n            return\n\n        # Check if it's a Bambu printer SSDP\n        if b\"bambulab-com:device:3dprinter\" not in data:\n            return\n\n        # Parse and store printer info\n        headers = self._parse_ssdp_message(data)\n        if headers:\n            self._printer_info = headers\n            logger.debug(\"Received SSDP from printer %s: %s\", sender_ip, headers.get(\"devname.bambu.com\", \"unknown\"))\n\n        # Store and immediately broadcast\n        self._last_printer_ssdp = data\n        await self._broadcast_to_remote()\n\n    async def _respond_to_msearch(self, data: bytes, addr: tuple[str, int]) -> None:\n        \"\"\"Respond to M-SEARCH from a slicer with cached, rewritten printer info.\n\n        When Bambu Studio sends an M-SEARCH (e.g., before sending a print),\n        we respond with the cached printer info, rewritten to point to the\n        proxy's LAN B IP. Without this, the slicer thinks the printer is\n        offline and shows a 'connect to printer' modal.\n        \"\"\"\n        # Check if it's a relevant M-SEARCH\n        if b\"bambulab-com:device:3dprinter\" not in data and b\"ssdp:all\" not in data.lower():\n            return\n\n        if not self._last_printer_ssdp:\n            logger.debug(\"M-SEARCH from %s but no cached printer SSDP yet\", addr[0])\n            return\n\n        logger.debug(\"Received M-SEARCH from slicer %s\", addr[0])\n\n        # Rewrite the cached printer SSDP (Location → proxy IP, DevBind → free)\n        rewritten = self._rewrite_ssdp(self._last_printer_ssdp)\n        text = rewritten.decode(\"utf-8\", errors=\"ignore\")\n\n        # Convert NOTIFY format to M-SEARCH response format:\n        #   \"NOTIFY * HTTP/1.1\" → \"HTTP/1.1 200 OK\"\n        #   NT: → ST: (Notification Type → Search Target)\n        #   Remove NTS: header (only in NOTIFY)\n        text = re.sub(r\"^NOTIFY \\* HTTP/1\\.1\", \"HTTP/1.1 200 OK\", text)\n        text = re.sub(r\"^NT:\", \"ST:\", text, flags=re.MULTILINE)\n        text = re.sub(r\"^NTS:.*\\r\\n\", \"\", text, flags=re.MULTILINE)\n\n        # Send unicast response directly to the slicer via remote socket\n        if self._remote_socket:\n            try:\n                self._remote_socket.sendto(text.encode(\"utf-8\"), addr)\n                logger.info(\"Sent SSDP M-SEARCH response to %s\", addr[0])\n            except OSError as e:\n                logger.debug(\"Failed to send M-SEARCH response to %s: %s\", addr[0], e)\n\n    async def _broadcast_to_remote(self) -> None:\n        \"\"\"Broadcast cached printer SSDP on remote interface (LAN B).\"\"\"\n        if not self._remote_socket or not self._last_printer_ssdp:\n            return\n\n        try:\n            # Rewrite Location to point to Bambuddy's remote interface\n            rewritten = self._rewrite_ssdp(self._last_printer_ssdp)\n\n            # Calculate broadcast address for remote network\n            # Use 255.255.255.255 for simplicity (works across subnets)\n            self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))\n\n            printer_name = self._printer_info.get(\"devname.bambu.com\", \"unknown\")\n            logger.debug(\"Broadcast SSDP for '%s' on LAN B (%s)\", printer_name, self.remote_interface_ip)\n        except OSError as e:\n            logger.debug(\"Failed to broadcast SSDP on remote: %s\", e)\n"
  },
  {
    "path": "backend/app/services/virtual_printer/tcp_proxy.py",
    "content": "\"\"\"Proxy for slicer-to-printer communication.\n\nThis module provides both transparent TCP proxying and TLS-terminating\nproxying for forwarding data between a slicer and a real Bambu printer,\nenabling remote printing over any network connection.\n\nMost protocols (FTP, FileTransfer, Camera) use transparent TCP proxying —\nraw bytes are forwarded without decryption, preserving end-to-end TLS\nbetween slicer and printer. Only MQTT is TLS-terminated so Bambuddy can\nrewrite the printer's real IP with the proxy's bind IP in MQTT payloads.\n\"\"\"\n\n# ruff: noqa: N801\n\nimport asyncio\nimport logging\nimport random\nimport re\nimport ssl\nimport subprocess\nfrom collections.abc import Callable\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\n\nclass _SessionReuseSSLContext:\n    \"\"\"Proxy around SSLContext that injects a TLS session into wrap_bio().\n\n    vsFTPd (used by some Bambu printers like X1C) requires TLS session reuse\n    on FTP data channels — the data connection must reuse the TLS session from\n    the control channel. Without this, the printer rejects the data connection\n    with \"522 SSL connection failed: session reuse required\".\n\n    asyncio's open_connection() calls SSLContext.wrap_bio() internally but\n    doesn't expose a session parameter. This wrapper intercepts wrap_bio()\n    to inject the saved control-channel session, enabling session reuse.\n    \"\"\"\n\n    def __init__(self, ctx: ssl.SSLContext, session: ssl.SSLSession) -> None:\n        object.__setattr__(self, \"_ctx\", ctx)\n        object.__setattr__(self, \"_session\", session)\n\n    def __getattr__(self, name: str) -> object:\n        return getattr(self._ctx, name)\n\n    def wrap_bio(\n        self,\n        incoming: ssl.MemoryBIO,\n        outgoing: ssl.MemoryBIO,\n        server_side: bool = False,\n        server_hostname: str | None = None,\n        **kwargs: object,\n    ) -> ssl.SSLObject:\n        return self._ctx.wrap_bio(\n            incoming,\n            outgoing,\n            server_side=server_side,\n            server_hostname=server_hostname,\n            session=self._session,\n            **kwargs,\n        )\n\n\ndef detect_port_redirect(port: int) -> int | None:\n    \"\"\"Detect if iptables redirects a port to another port.\n\n    When iptables NAT REDIRECT rules exist (e.g. port redirects), connections\n    to the original port never reach our socket because iptables intercepts\n    them in PREROUTING. We must listen on the redirect target instead.\n\n    Returns the redirect target port, or None if no redirect is active.\n    \"\"\"\n    # Method 1: Read persistent rules file (doesn't require root)\n    for rules_path in (\"/etc/iptables/rules.v4\", \"/etc/iptables.rules\"):\n        try:\n            with open(rules_path) as f:\n                content = f.read()\n            match = re.search(rf\"--dport {port}\\b.*?--to-ports\\s+(\\d+)\", content)\n            if match:\n                target = int(match.group(1))\n                if target != port:\n                    return target\n        except (FileNotFoundError, PermissionError, OSError):\n            continue\n\n    # Method 2: Query live iptables rules (may require root)\n    try:\n        result = subprocess.run(  # noqa: S603, S607\n            [\"iptables-save\", \"-t\", \"nat\"],\n            capture_output=True,\n            text=True,\n            timeout=5,\n        )\n        if result.returncode == 0:\n            match = re.search(rf\"--dport {port}\\b.*?--to-ports\\s+(\\d+)\", result.stdout)\n            if match:\n                target = int(match.group(1))\n                if target != port:\n                    return target\n    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):\n        pass\n\n    return None\n\n\nclass TLSProxy:\n    \"\"\"TLS terminating proxy that forwards data between client and target.\n\n    This proxy terminates TLS on both ends, allowing the slicer to connect\n    to Bambuddy's certificate while Bambuddy connects to the real printer.\n    \"\"\"\n\n    def __init__(\n        self,\n        name: str,\n        listen_port: int,\n        target_host: str,\n        target_port: int,\n        server_cert_path: Path,\n        server_key_path: Path,\n        on_connect: Callable[[str], None] | None = None,\n        on_disconnect: Callable[[str], None] | None = None,\n        bind_address: str = \"0.0.0.0\",  # nosec B104\n        rewrite_ip: tuple[str, str] | None = None,\n    ):\n        \"\"\"Initialize the TLS proxy.\n\n        Args:\n            name: Friendly name for logging (e.g., \"FTP\", \"MQTT\")\n            listen_port: Port to listen on for incoming connections\n            target_host: Target printer IP/hostname\n            target_port: Target printer port\n            server_cert_path: Path to server certificate (for accepting slicer connections)\n            server_key_path: Path to server private key\n            on_connect: Optional callback when client connects (receives client_id)\n            on_disconnect: Optional callback when client disconnects (receives client_id)\n            bind_address: IP address to bind to (default: all interfaces)\n            rewrite_ip: Optional (old_ip, new_ip) tuple — replaces occurrences of\n                the printer's real IP with the proxy's bind IP in printer→client data.\n                This prevents the slicer from discovering the printer's real IP\n                in MQTT payloads (ip_addr, rtsp_url, etc.) and bypassing the proxy.\n        \"\"\"\n        self.name = name\n        self.listen_port = listen_port\n        self.target_host = target_host\n        self.target_port = target_port\n        self.server_cert_path = server_cert_path\n        self.server_key_path = server_key_path\n        self.on_connect = on_connect\n        self.on_disconnect = on_disconnect\n        self.bind_address = bind_address\n\n        # IP rewriting for printer→client direction\n        if rewrite_ip:\n            self._rewrite_old = rewrite_ip[0].encode(\"utf-8\")\n            self._rewrite_new = rewrite_ip[1].encode(\"utf-8\")\n            # Also rewrite the integer IP in net.info[].ip fields.\n            # Bambu printers encode their IP as a little-endian uint32 integer\n            # in the JSON payload. BambuStudio reads this to set dev_ip.\n            self._rewrite_old_int = self._ip_to_le_int_bytes(rewrite_ip[0])\n            self._rewrite_new_int = self._ip_to_le_int_bytes(rewrite_ip[1])\n        else:\n            self._rewrite_old = None\n            self._rewrite_new = None\n            self._rewrite_old_int = None\n            self._rewrite_new_int = None\n\n        self._server: asyncio.Server | None = None\n        self._running = False\n        self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}\n        self._server_ssl_context: ssl.SSLContext | None = None\n        self._client_ssl_context: ssl.SSLContext | None = None\n\n    @staticmethod\n    def _ip_to_le_int_bytes(ip: str) -> bytes:\n        \"\"\"Convert an IP address to its little-endian integer JSON representation.\n\n        E.g. \"192.168.255.16\" → b\"285190336\" (the integer as a decimal string,\n        as it appears in Bambu MQTT JSON payloads in the net.info[].ip field).\n        \"\"\"\n        import struct as _struct\n\n        parts = ip.split(\".\")\n        packed = bytes(int(p) for p in parts)\n        le_int = _struct.unpack(\"<I\", packed)[0]\n        return str(le_int).encode(\"utf-8\")\n\n    def _create_server_ssl_context(self) -> ssl.SSLContext:\n        \"\"\"Create SSL context for accepting client (slicer) connections.\"\"\"\n        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n        ctx.load_cert_chain(self.server_cert_path, self.server_key_path)\n        # Allow older TLS versions for compatibility with slicers\n        ctx.minimum_version = ssl.TLSVersion.TLSv1_2\n        # Don't require client certificates\n        ctx.verify_mode = ssl.CERT_NONE\n        return ctx\n\n    def _create_client_ssl_context(self) -> ssl.SSLContext:\n        \"\"\"Create SSL context for connecting to printer.\"\"\"\n        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)\n        # Don't verify printer's certificate (self-signed)\n        ctx.check_hostname = False\n        ctx.verify_mode = ssl.CERT_NONE\n        ctx.minimum_version = ssl.TLSVersion.TLSv1_2\n        # Bambu printers use plain RSA key exchange (no ECDHE/DHE),\n        # which modern OpenSSL 3.x defaults exclude. Add them back.\n        ctx.set_ciphers(\"DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256\")\n        return ctx\n\n    async def start(self) -> None:\n        \"\"\"Start the TLS proxy server.\"\"\"\n        if self._running:\n            return\n\n        logger.info(\n            f\"Starting {self.name} TLS proxy: {self.bind_address}:{self.listen_port} → {self.target_host}:{self.target_port}\"\n        )\n\n        try:\n            self._running = True\n\n            # Create SSL contexts\n            self._server_ssl_context = self._create_server_ssl_context()\n            self._client_ssl_context = self._create_client_ssl_context()\n\n            # Start server with TLS\n            self._server = await asyncio.start_server(\n                self._handle_client,\n                self.bind_address,\n                self.listen_port,\n                ssl=self._server_ssl_context,\n            )\n\n            logger.info(\"%s TLS proxy listening on port %s\", self.name, self.listen_port)\n\n            async with self._server:\n                await self._server.serve_forever()\n\n        except OSError as e:\n            if e.errno == 98:  # Address already in use\n                logger.error(\"%s proxy port %s is already in use\", self.name, self.listen_port)\n            elif e.errno == 13:  # Permission denied\n                logger.error(\n                    \"%s proxy: cannot bind to port %s (permission denied). \"\n                    \"Port %s requires root or CAP_NET_BIND_SERVICE. \"\n                    \"Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. \"\n                    \"Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' \"\n                    \"or redirect with iptables.\",\n                    self.name,\n                    self.listen_port,\n                    self.listen_port,\n                )\n            else:\n                logger.error(\"%s proxy error: %s\", self.name, e)\n        except asyncio.CancelledError:\n            logger.debug(\"%s proxy task cancelled\", self.name)\n        except Exception as e:\n            logger.error(\"%s proxy error: %s\", self.name, e)\n        finally:\n            await self.stop()\n\n    async def stop(self) -> None:\n        \"\"\"Stop the TLS proxy server.\"\"\"\n        logger.info(\"Stopping %s proxy\", self.name)\n        self._running = False\n\n        # Cancel all active connection tasks\n        for client_id, (task1, task2) in list(self._active_connections.items()):\n            task1.cancel()\n            task2.cancel()\n            if self.on_disconnect:\n                try:\n                    self.on_disconnect(client_id)\n                except Exception:\n                    pass  # Ignore disconnect callback errors during shutdown\n\n        self._active_connections.clear()\n\n        if self._server:\n            try:\n                self._server.close()\n                await self._server.wait_closed()\n            except OSError as e:\n                logger.debug(\"Error closing %s proxy server: %s\", self.name, e)\n            self._server = None\n\n    async def _handle_client(\n        self,\n        client_reader: asyncio.StreamReader,\n        client_writer: asyncio.StreamWriter,\n    ) -> None:\n        \"\"\"Handle a new client connection by proxying to target.\"\"\"\n        peername = client_writer.get_extra_info(\"peername\")\n        client_id = f\"{peername[0]}:{peername[1]}\" if peername else \"unknown\"\n\n        logger.info(\"%s proxy: client connected from %s\", self.name, client_id)\n\n        if self.on_connect:\n            try:\n                self.on_connect(client_id)\n            except Exception:\n                pass  # Ignore connect callback errors; connection proceeds regardless\n\n        # Connect to target printer with TLS\n        try:\n            printer_reader, printer_writer = await asyncio.wait_for(\n                asyncio.open_connection(\n                    self.target_host,\n                    self.target_port,\n                    ssl=self._client_ssl_context,\n                ),\n                timeout=10.0,\n            )\n            logger.info(\"%s proxy: connected to printer %s:%s\", self.name, self.target_host, self.target_port)\n        except TimeoutError:\n            logger.error(\"%s proxy: timeout connecting to %s:%s\", self.name, self.target_host, self.target_port)\n            client_writer.close()\n            await client_writer.wait_closed()\n            return\n        except ssl.SSLError as e:\n            logger.error(\n                \"%s proxy: SSL error connecting to %s:%s: %s\", self.name, self.target_host, self.target_port, e\n            )\n            client_writer.close()\n            await client_writer.wait_closed()\n            return\n        except OSError as e:\n            logger.error(\"%s proxy: failed to connect to %s:%s: %s\", self.name, self.target_host, self.target_port, e)\n            client_writer.close()\n            await client_writer.wait_closed()\n            return\n\n        # Create bidirectional forwarding tasks\n        client_to_printer = asyncio.create_task(\n            self._forward(client_reader, printer_writer, f\"{client_id}→printer\"),\n            name=f\"{self.name}_c2p_{client_id}\",\n        )\n        printer_to_client = asyncio.create_task(\n            self._forward(printer_reader, client_writer, f\"printer→{client_id}\", rewrite_ip=True),\n            name=f\"{self.name}_p2c_{client_id}\",\n        )\n\n        self._active_connections[client_id] = (client_to_printer, printer_to_client)\n\n        try:\n            # Wait for either direction to complete (connection closed)\n            done, pending = await asyncio.wait(\n                [client_to_printer, printer_to_client],\n                return_when=asyncio.FIRST_COMPLETED,\n            )\n\n            # Cancel the other direction\n            for task in pending:\n                task.cancel()\n                try:\n                    await task\n                except asyncio.CancelledError:\n                    pass  # Expected when cancelling the other forwarding direction\n\n        except Exception as e:\n            logger.debug(\"%s proxy connection error: %s\", self.name, e)\n        finally:\n            # Clean up\n            self._active_connections.pop(client_id, None)\n\n            for writer in [client_writer, printer_writer]:\n                try:\n                    writer.close()\n                    await writer.wait_closed()\n                except OSError:\n                    pass  # Best-effort connection cleanup; peer may have disconnected\n\n            logger.info(\"%s proxy: client %s disconnected\", self.name, client_id)\n\n            if self.on_disconnect:\n                try:\n                    self.on_disconnect(client_id)\n                except Exception:\n                    pass  # Ignore disconnect callback errors; cleanup continues\n\n    @staticmethod\n    def _rewrite_mqtt_ip(\n        data: bytes,\n        old_ip: bytes,\n        new_ip: bytes,\n        buffer: bytearray,\n        extra_replacements: list[tuple[bytes, bytes]] | None = None,\n    ) -> tuple[bytes, bytearray]:\n        \"\"\"Rewrite IP addresses inside MQTT packets, preserving packet framing.\n\n        MQTT packets have a variable-length header encoding the remaining\n        packet length.  A naive bytes.replace() would corrupt this framing\n        when old_ip and new_ip differ in length.\n\n        This method parses individual MQTT packets out of the data stream,\n        performs the replacement only on PUBLISH payloads, and re-encodes\n        the remaining-length field to match the new size.\n\n        Incomplete packets are buffered and returned for the next call.\n\n        Args:\n            extra_replacements: Additional (old, new) byte pairs to replace\n                (e.g. the integer IP representation in net.info[].ip).\n\n        Returns (output_data, remaining_buffer).\n        \"\"\"\n        buffer.extend(data)\n\n        # Check if any replacement target exists in the buffer\n        has_target = old_ip in buffer\n        if not has_target and extra_replacements:\n            has_target = any(old in buffer for old, _new in extra_replacements)\n\n        if not has_target:\n            # Fast path: no IP in buffer, but we still need to check for\n            # incomplete packets at the end that might contain a partial IP.\n            # For safety, try to parse and emit only complete packets.\n            result = bytearray()\n            pos = 0\n            length = len(buffer)\n\n            while pos < length:\n                packet_start = pos\n                if pos + 1 >= length:\n                    break\n                pos += 1  # header byte\n\n                # Parse remaining length\n                remaining_length = 0\n                multiplier = 1\n                length_bytes = 0\n                while pos < length:\n                    encoded_byte = buffer[pos]\n                    pos += 1\n                    remaining_length += (encoded_byte & 0x7F) * multiplier\n                    multiplier *= 128\n                    length_bytes += 1\n                    if (encoded_byte & 0x80) == 0:\n                        break\n                    if length_bytes >= 4:\n                        break\n\n                if pos + remaining_length > length:\n                    # Incomplete — keep in buffer\n                    new_buffer = bytearray(buffer[packet_start:])\n                    return bytes(result), new_buffer\n\n                pos += remaining_length\n                result.extend(buffer[packet_start:pos])\n\n            # All complete\n            buffer.clear()\n            return bytes(result) if result else bytes(data), buffer\n\n        # Buffer contains old_ip — parse packets and rewrite\n        result = bytearray()\n        pos = 0\n        length = len(buffer)\n\n        while pos < length:\n            packet_start = pos\n\n            if pos >= length:\n                break\n            header_byte = buffer[pos]\n            pos += 1\n\n            # Remaining length: variable-length encoding (1-4 bytes)\n            remaining_length = 0\n            multiplier = 1\n            length_bytes = 0\n            while pos < length:\n                encoded_byte = buffer[pos]\n                pos += 1\n                remaining_length += (encoded_byte & 0x7F) * multiplier\n                multiplier *= 128\n                length_bytes += 1\n                if (encoded_byte & 0x80) == 0:\n                    break\n                if length_bytes >= 4:\n                    break\n\n            # Check if we have enough data for the full packet\n            if pos + remaining_length > length:\n                # Incomplete packet — keep in buffer for next call\n                new_buffer = bytearray(buffer[packet_start:])\n                return bytes(result), new_buffer\n\n            packet_type = (header_byte >> 4) & 0x0F\n            packet_body = buffer[pos : pos + remaining_length]\n            pos += remaining_length\n\n            # Only rewrite PUBLISH packets (type 3)\n            needs_rewrite = packet_type == 3 and (\n                old_ip in packet_body\n                or (extra_replacements and any(old in packet_body for old, _new in extra_replacements))\n            )\n            if needs_rewrite:\n                new_body = bytes(packet_body).replace(old_ip, new_ip)\n                if extra_replacements:\n                    for old_val, new_val in extra_replacements:\n                        new_body = new_body.replace(old_val, new_val)\n\n                # Re-encode: header byte + new remaining length + new body\n                result.append(header_byte)\n\n                # Encode remaining length (MQTT variable-length encoding)\n                new_remaining = len(new_body)\n                while True:\n                    encoded_byte = new_remaining % 128\n                    new_remaining //= 128\n                    if new_remaining > 0:\n                        encoded_byte |= 0x80\n                    result.append(encoded_byte)\n                    if new_remaining == 0:\n                        break\n\n                result.extend(new_body)\n            else:\n                # Pass through unchanged\n                result.extend(buffer[packet_start:pos])\n\n        buffer.clear()\n        return bytes(result), buffer\n\n    async def _forward(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n        direction: str,\n        rewrite_ip: bool = False,\n    ) -> None:\n        \"\"\"Forward data from reader to writer.\n\n        Args:\n            reader: Source stream (already TLS-decrypted)\n            writer: Destination stream (will be TLS-encrypted by the stream)\n            direction: Description for logging (e.g., \"client→printer\")\n            rewrite_ip: If True and rewrite_ip was configured, replace the\n                printer's real IP with the proxy's bind IP in the data.\n        \"\"\"\n        do_rewrite = rewrite_ip and self._rewrite_old is not None\n        rewrite_buffer = bytearray() if do_rewrite else None\n        rewrite_logged = False\n        total_bytes = 0\n        try:\n            while self._running:\n                # Read chunk - use reasonable buffer size\n                data = await reader.read(65536)\n                if not data:\n                    # Connection closed\n                    break\n\n                # Rewrite printer IP → proxy IP in MQTT PUBLISH payloads\n                # to prevent the slicer from bypassing the proxy.\n                if do_rewrite:\n                    extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None\n                    data, rewrite_buffer = self._rewrite_mqtt_ip(\n                        data,\n                        self._rewrite_old,\n                        self._rewrite_new,\n                        rewrite_buffer,\n                        extra_replacements=extra,\n                    )\n                    if not rewrite_logged and data:\n                        if self._rewrite_old in data:\n                            logger.warning(\n                                \"%s proxy IP rewrite FAILED — %s still present after rewrite!\",\n                                self.name,\n                                self._rewrite_old.decode(),\n                            )\n                        else:\n                            logger.info(\n                                \"%s proxy IP rewrite active: %s → %s\",\n                                self.name,\n                                self._rewrite_old.decode(),\n                                self._rewrite_new.decode(),\n                            )\n                        rewrite_logged = True\n                    if not data:\n                        continue  # All data buffered, waiting for more\n\n                # Forward to destination\n                writer.write(data)\n                await writer.drain()\n\n                total_bytes += len(data)\n\n        except asyncio.CancelledError:\n            pass  # Expected when the other forwarding direction closes first\n        except ConnectionResetError:\n            logger.debug(\"%s proxy %s: connection reset\", self.name, direction)\n        except BrokenPipeError:\n            logger.debug(\"%s proxy %s: broken pipe\", self.name, direction)\n        except OSError as e:\n            logger.debug(\"%s proxy %s error: %s\", self.name, direction, e)\n\n        logger.debug(\"%s proxy %s: total %s bytes\", self.name, direction, total_bytes)\n\n\nclass TCPProxy:\n    \"\"\"Raw TCP proxy that forwards data without TLS termination.\n\n    Used for protocols where the printer doesn't use TLS (e.g., port 3000\n    binding/authentication protocol).\n    \"\"\"\n\n    def __init__(\n        self,\n        name: str,\n        listen_port: int,\n        target_host: str,\n        target_port: int,\n        on_connect: Callable[[str], None] | None = None,\n        on_disconnect: Callable[[str], None] | None = None,\n        bind_address: str = \"0.0.0.0\",  # nosec B104\n    ):\n        self.name = name\n        self.listen_port = listen_port\n        self.target_host = target_host\n        self.target_port = target_port\n        self.on_connect = on_connect\n        self.on_disconnect = on_disconnect\n        self.bind_address = bind_address\n\n        self._server: asyncio.Server | None = None\n        self._running = False\n        self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}\n\n    async def start(self) -> None:\n        \"\"\"Start the TCP proxy server.\"\"\"\n        if self._running:\n            return\n\n        logger.info(\n            \"Starting %s TCP proxy: %s:%s → %s:%s\",\n            self.name,\n            self.bind_address,\n            self.listen_port,\n            self.target_host,\n            self.target_port,\n        )\n\n        try:\n            self._running = True\n\n            self._server = await asyncio.start_server(\n                self._handle_client,\n                self.bind_address,\n                self.listen_port,\n            )\n\n            logger.info(\"%s TCP proxy listening on port %s\", self.name, self.listen_port)\n\n            async with self._server:\n                await self._server.serve_forever()\n\n        except OSError as e:\n            if e.errno == 98:  # Address already in use\n                logger.error(\"%s proxy port %s is already in use\", self.name, self.listen_port)\n            else:\n                logger.error(\"%s proxy error: %s\", self.name, e)\n        except asyncio.CancelledError:\n            logger.debug(\"%s proxy task cancelled\", self.name)\n        except Exception as e:\n            logger.error(\"%s proxy error: %s\", self.name, e)\n        finally:\n            await self.stop()\n\n    async def stop(self) -> None:\n        \"\"\"Stop the TCP proxy server.\"\"\"\n        logger.info(\"Stopping %s proxy\", self.name)\n        self._running = False\n\n        for client_id, (task1, task2) in list(self._active_connections.items()):\n            task1.cancel()\n            task2.cancel()\n            if self.on_disconnect:\n                try:\n                    self.on_disconnect(client_id)\n                except Exception:\n                    pass\n\n        self._active_connections.clear()\n\n        if self._server:\n            try:\n                self._server.close()\n                await self._server.wait_closed()\n            except OSError as e:\n                logger.debug(\"Error closing %s proxy server: %s\", self.name, e)\n            self._server = None\n\n    async def _handle_client(\n        self,\n        client_reader: asyncio.StreamReader,\n        client_writer: asyncio.StreamWriter,\n    ) -> None:\n        \"\"\"Handle a new client connection by proxying to target.\"\"\"\n        peername = client_writer.get_extra_info(\"peername\")\n        client_id = f\"{peername[0]}:{peername[1]}\" if peername else \"unknown\"\n\n        logger.info(\"%s proxy: client connected from %s\", self.name, client_id)\n\n        if self.on_connect:\n            try:\n                self.on_connect(client_id)\n            except Exception:\n                pass\n\n        try:\n            printer_reader, printer_writer = await asyncio.wait_for(\n                asyncio.open_connection(self.target_host, self.target_port),\n                timeout=10.0,\n            )\n            logger.info(\"%s proxy: connected to printer %s:%s\", self.name, self.target_host, self.target_port)\n        except TimeoutError:\n            logger.error(\"%s proxy: timeout connecting to %s:%s\", self.name, self.target_host, self.target_port)\n            client_writer.close()\n            await client_writer.wait_closed()\n            return\n        except OSError as e:\n            logger.error(\"%s proxy: failed to connect to %s:%s: %s\", self.name, self.target_host, self.target_port, e)\n            client_writer.close()\n            await client_writer.wait_closed()\n            return\n\n        client_to_printer = asyncio.create_task(\n            self._forward(client_reader, printer_writer, f\"{client_id}→printer\"),\n            name=f\"{self.name}_c2p_{client_id}\",\n        )\n        printer_to_client = asyncio.create_task(\n            self._forward(printer_reader, client_writer, f\"printer→{client_id}\"),\n            name=f\"{self.name}_p2c_{client_id}\",\n        )\n\n        self._active_connections[client_id] = (client_to_printer, printer_to_client)\n\n        try:\n            done, pending = await asyncio.wait(\n                [client_to_printer, printer_to_client],\n                return_when=asyncio.FIRST_COMPLETED,\n            )\n            for task in pending:\n                task.cancel()\n                try:\n                    await task\n                except asyncio.CancelledError:\n                    pass\n\n        except Exception as e:\n            logger.debug(\"%s proxy connection error: %s\", self.name, e)\n        finally:\n            self._active_connections.pop(client_id, None)\n\n            for writer in [client_writer, printer_writer]:\n                try:\n                    writer.close()\n                    await writer.wait_closed()\n                except OSError:\n                    pass\n\n            logger.info(\"%s proxy: client %s disconnected\", self.name, client_id)\n\n            if self.on_disconnect:\n                try:\n                    self.on_disconnect(client_id)\n                except Exception:\n                    pass\n\n    async def _forward(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n        direction: str,\n    ) -> None:\n        \"\"\"Forward data from reader to writer.\"\"\"\n        total_bytes = 0\n        try:\n            while self._running:\n                data = await reader.read(65536)\n                if not data:\n                    break\n                writer.write(data)\n                await writer.drain()\n                total_bytes += len(data)\n                logger.debug(\"%s proxy %s: %s bytes\", self.name, direction, len(data))\n        except asyncio.CancelledError:\n            pass\n        except ConnectionResetError:\n            logger.debug(\"%s proxy %s: connection reset\", self.name, direction)\n        except BrokenPipeError:\n            logger.debug(\"%s proxy %s: broken pipe\", self.name, direction)\n        except OSError as e:\n            logger.debug(\"%s proxy %s error: %s\", self.name, direction, e)\n\n        logger.debug(\"%s proxy %s: total %s bytes\", self.name, direction, total_bytes)\n\n\nclass FTPTLSProxy(TLSProxy):\n    \"\"\"FTP-aware TLS proxy that handles passive data connections.\n\n    Extends TLSProxy to intercept PASV/EPSV responses on the FTP control\n    channel, dynamically create TLS data proxies on local ports, and rewrite\n    the responses so the slicer connects to the proxy instead of the printer.\n\n    Without this, FTP passive data connections bypass the proxy and go directly\n    to the printer, which fails when the slicer can't reach the printer's IP.\n    \"\"\"\n\n    PASV_PORT_MIN = 50000\n    PASV_PORT_MAX = 50100\n\n    async def stop(self) -> None:\n        \"\"\"Stop proxy and clean up data connection servers.\"\"\"\n        # Close all data servers first\n        for server in list(self._data_servers):\n            try:\n                server.close()\n                await server.wait_closed()\n            except OSError:\n                pass  # Best-effort cleanup of data proxy servers\n        self._data_servers.clear()\n        await super().stop()\n\n    async def start(self) -> None:\n        \"\"\"Start the FTP TLS proxy.\"\"\"\n        self._data_servers: list[asyncio.Server] = []\n        await super().start()\n\n    async def _handle_client(\n        self,\n        client_reader: asyncio.StreamReader,\n        client_writer: asyncio.StreamWriter,\n    ) -> None:\n        \"\"\"Handle FTP client with PASV/EPSV-aware response forwarding.\"\"\"\n        peername = client_writer.get_extra_info(\"peername\")\n        client_id = f\"{peername[0]}:{peername[1]}\" if peername else \"unknown\"\n\n        logger.info(\"%s proxy: client connected from %s\", self.name, client_id)\n\n        if self.on_connect:\n            try:\n                self.on_connect(client_id)\n            except Exception:\n                pass  # Ignore connect callback errors; connection proceeds regardless\n\n        # Determine our local IP from the control connection socket\n        sockname = client_writer.get_extra_info(\"sockname\")\n        local_ip = sockname[0] if sockname else \"0.0.0.0\"  # nosec B104\n        if local_ip in (\"0.0.0.0\", \"::\"):  # nosec B104\n            local_ip = \"127.0.0.1\"\n\n        # Connect to target printer with TLS\n        try:\n            printer_reader, printer_writer = await asyncio.wait_for(\n                asyncio.open_connection(\n                    self.target_host,\n                    self.target_port,\n                    ssl=self._client_ssl_context,\n                ),\n                timeout=10.0,\n            )\n            logger.info(\"%s proxy: connected to printer %s:%s\", self.name, self.target_host, self.target_port)\n        except TimeoutError:\n            logger.error(\"%s proxy: timeout connecting to %s:%s\", self.name, self.target_host, self.target_port)\n            client_writer.close()\n            await client_writer.wait_closed()\n            return\n        except ssl.SSLError as e:\n            logger.error(\n                \"%s proxy: SSL error connecting to %s:%s: %s\", self.name, self.target_host, self.target_port, e\n            )\n            client_writer.close()\n            await client_writer.wait_closed()\n            return\n        except OSError as e:\n            logger.error(\"%s proxy: failed to connect to %s:%s: %s\", self.name, self.target_host, self.target_port, e)\n            client_writer.close()\n            await client_writer.wait_closed()\n            return\n\n        # Capture the TLS session from the control channel for data channel\n        # reuse. vsFTPd (X1C) requires require_ssl_reuse — the data connection\n        # must present the same TLS session as the control channel.\n        ctrl_ssl_object = printer_writer.get_extra_info(\"ssl_object\")\n        ctrl_tls_session = ctrl_ssl_object.session if ctrl_ssl_object else None\n        if ctrl_tls_session:\n            logger.debug(\"%s proxy: captured TLS session for data channel reuse\", self.name)\n\n        # Track data channel protection level per session.\n        # PROT C = cleartext data, PROT P = TLS data.\n        # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.\n        # If the slicer sends PROT P, we switch to TLS for data connections.\n        session_state: dict[str, str | ssl.SSLSession] = {\"prot\": \"C\"}\n        if ctrl_tls_session:\n            session_state[\"tls_session\"] = ctrl_tls_session\n\n        # Client→Printer: intercept EPSV and replace with PASV\n        # EPSV responses only contain a port (no IP), so the slicer reuses\n        # the control connection IP. If that IP is the real printer (via\n        # iptables REDIRECT), the data connection bypasses the proxy.\n        # PASV responses include an explicit IP that we can rewrite.\n        client_to_printer = asyncio.create_task(\n            self._forward_ftp_commands(client_reader, printer_writer, f\"{client_id}→printer\", session_state),\n            name=f\"{self.name}_c2p_{client_id}\",\n        )\n        # Printer→Client: intercept PASV/EPSV responses\n        printer_to_client = asyncio.create_task(\n            self._forward_ftp_control(printer_reader, client_writer, f\"printer→{client_id}\", local_ip, session_state),\n            name=f\"{self.name}_p2c_{client_id}\",\n        )\n\n        self._active_connections[client_id] = (client_to_printer, printer_to_client)\n\n        try:\n            done, pending = await asyncio.wait(\n                [client_to_printer, printer_to_client],\n                return_when=asyncio.FIRST_COMPLETED,\n            )\n            for task in pending:\n                task.cancel()\n                try:\n                    await task\n                except asyncio.CancelledError:\n                    pass  # Expected when cancelling the other forwarding direction\n\n        except Exception as e:\n            logger.debug(\"%s proxy connection error: %s\", self.name, e)\n        finally:\n            self._active_connections.pop(client_id, None)\n\n            for writer in [client_writer, printer_writer]:\n                try:\n                    writer.close()\n                    await writer.wait_closed()\n                except OSError:\n                    pass  # Best-effort connection cleanup; peer may have disconnected\n\n            logger.info(\"%s proxy: client %s disconnected\", self.name, client_id)\n\n            if self.on_disconnect:\n                try:\n                    self.on_disconnect(client_id)\n                except Exception:\n                    pass  # Ignore disconnect callback errors; cleanup continues\n\n    async def _forward_ftp_commands(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n        direction: str,\n        session_state: dict[str, str | ssl.SSLSession],\n    ) -> None:\n        \"\"\"Forward FTP client commands, replacing EPSV with PASV.\n\n        EPSV responses only contain a port number — the client reuses the\n        control connection IP for data.  When the control IP is the real\n        printer (due to iptables REDIRECT), EPSV data connections bypass\n        the proxy.  PASV responses include an explicit IP that the proxy\n        can rewrite to its own address.\n\n        Also tracks PROT P/C commands to know whether data connections\n        should use TLS or cleartext.\n        \"\"\"\n        buffer = b\"\"\n        total_bytes = 0\n        try:\n            while self._running:\n                data = await reader.read(65536)\n                if not data:\n                    break\n\n                total_bytes += len(data)\n                buffer += data\n                output = b\"\"\n\n                while b\"\\r\\n\" in buffer:\n                    idx = buffer.index(b\"\\r\\n\")\n                    line = buffer[:idx]\n                    buffer = buffer[idx + 2 :]\n\n                    cmd_upper = line.strip().upper()\n\n                    # Track PROT level for data channel encryption\n                    if cmd_upper == b\"PROT P\":\n                        session_state[\"prot\"] = \"P\"\n                        logger.info(\"FTP data protection: PROT P (TLS)\")\n                    elif cmd_upper == b\"PROT C\":\n                        session_state[\"prot\"] = \"C\"\n                        logger.info(\"FTP data protection: PROT C (cleartext)\")\n\n                    output += line + b\"\\r\\n\"\n\n                if output:\n                    writer.write(output)\n                    await writer.drain()\n\n                logger.debug(\"%s proxy %s: %s bytes\", self.name, direction, len(data))\n\n        except asyncio.CancelledError:\n            pass  # Expected when the other forwarding direction closes first\n        except ConnectionResetError:\n            logger.debug(\"%s proxy %s: connection reset\", self.name, direction)\n        except BrokenPipeError:\n            logger.debug(\"%s proxy %s: broken pipe\", self.name, direction)\n        except OSError as e:\n            logger.debug(\"%s proxy %s error: %s\", self.name, direction, e)\n\n        if buffer:\n            try:\n                writer.write(buffer)\n                await writer.drain()\n            except OSError:\n                pass  # Best-effort flush of remaining FTP command data\n\n        logger.debug(\"%s proxy %s: total %s bytes\", self.name, direction, total_bytes)\n\n    async def _forward_ftp_control(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n        direction: str,\n        local_ip: str,\n        session_state: dict[str, str | ssl.SSLSession],\n    ) -> None:\n        \"\"\"Forward FTP control channel responses, rewriting PASV/EPSV.\n\n        FTP control channel is line-based (\\\\r\\\\n terminated). We buffer data\n        and process complete lines, intercepting 227 (PASV) and 229 (EPSV)\n        responses to create local data proxies.\n        \"\"\"\n        buffer = b\"\"\n        total_bytes = 0\n\n        try:\n            while self._running:\n                data = await reader.read(65536)\n                if not data:\n                    break\n\n                total_bytes += len(data)\n                buffer += data\n                output = b\"\"\n\n                # Process all complete lines\n                while b\"\\r\\n\" in buffer:\n                    idx = buffer.index(b\"\\r\\n\")\n                    line = buffer[:idx]\n                    buffer = buffer[idx + 2 :]\n\n                    rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)\n                    output += rewritten + b\"\\r\\n\"\n\n                if output:\n                    writer.write(output)\n                    await writer.drain()\n\n                logger.debug(\"%s proxy %s: %s bytes\", self.name, direction, len(data))\n\n        except asyncio.CancelledError:\n            pass  # Expected when the other forwarding direction closes first\n        except ConnectionResetError:\n            logger.debug(\"%s proxy %s: connection reset\", self.name, direction)\n        except BrokenPipeError:\n            logger.debug(\"%s proxy %s: broken pipe\", self.name, direction)\n        except OSError as e:\n            logger.debug(\"%s proxy %s error: %s\", self.name, direction, e)\n\n        # Flush any remaining buffered data\n        if buffer:\n            try:\n                writer.write(buffer)\n                await writer.drain()\n            except OSError:\n                pass  # Best-effort flush of remaining FTP control data\n\n        logger.debug(\"%s proxy %s: total %s bytes\", self.name, direction, total_bytes)\n\n    async def _maybe_rewrite_pasv(\n        self, line: bytes, local_ip: str, session_state: dict[str, str | ssl.SSLSession]\n    ) -> bytes:\n        \"\"\"Rewrite PASV/EPSV response to point to a local data proxy.\"\"\"\n        try:\n            text = line.decode(\"utf-8\")\n        except UnicodeDecodeError:\n            return line\n\n        # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)\n        if text.startswith(\"227 \"):\n            match = re.search(r\"\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)\", text)\n            if match:\n                h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())\n                printer_ip = f\"{h1}.{h2}.{h3}.{h4}\"\n                printer_port = p1 * 256 + p2\n\n                local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)\n                if local_port:\n                    ip_parts = local_ip.split(\".\")\n                    lp1 = local_port // 256\n                    lp2 = local_port % 256\n                    rewritten = (\n                        f\"227 Entering Passive Mode \"\n                        f\"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})\"\n                    )\n                    logger.info(\"FTP PASV rewrite: %s:%s → %s:%s\", printer_ip, printer_port, local_ip, local_port)\n                    return rewritten.encode(\"utf-8\")\n                else:\n                    logger.error(\"FTP PASV: failed to create data proxy for %s:%s\", printer_ip, printer_port)\n            else:\n                logger.warning(\"FTP PASV: 227 response didn't match expected format: %s\", text[:100])\n\n        # 229 Entering Extended Passive Mode (|||port|)\n        elif text.startswith(\"229 \"):\n            match = re.search(r\"\\(\\|\\|\\|(\\d+)\\|\\)\", text)\n            if match:\n                printer_port = int(match.group(1))\n\n                local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)\n                if local_port:\n                    rewritten = f\"229 Entering Extended Passive Mode (|||{local_port}|)\"\n                    logger.info(\"FTP EPSV rewrite: port %s → %s\", printer_port, local_port)\n                    return rewritten.encode(\"utf-8\")\n                else:\n                    logger.error(\"FTP EPSV: failed to create data proxy for port %s\", printer_port)\n            else:\n                logger.warning(\"FTP EPSV: 229 response didn't match expected format: %s\", text[:100])\n\n        return line\n\n    async def _create_data_proxy(\n        self, printer_ip: str, printer_port: int, session_state: dict[str, str | ssl.SSLSession]\n    ) -> int | None:\n        \"\"\"Create a one-shot proxy for an FTP data connection.\n\n        Prefers the printer's original passive port so the port number stays\n        the same in the rewritten PASV/EPSV response.  This is critical when\n        the slicer's FTP bounce-attack protection overrides the IP in the PASV\n        response: the slicer connects to <control_IP>:<port>, and if iptables\n        REDIRECT maps that port to the local machine, the data proxy must be\n        listening on the *same* port number.\n\n        Falls back to a random port if the original is unavailable.\n\n        Uses TLS or cleartext based on the session's PROT level:\n        - PROT P: TLS on both slicer and printer data connections\n        - PROT C: cleartext on both sides (common for A1/H2D printers)\n\n        Returns the local port number, or None if binding failed.\n        \"\"\"\n        use_tls = session_state.get(\"prot\") == \"P\"\n        logger.info(\n            \"FTP data proxy: creating data proxy for %s:%s (printer-side %s)\",\n            printer_ip,\n            printer_port,\n            \"TLS\" if use_tls else \"cleartext\",\n        )\n\n        # Get control channel TLS session for data channel reuse\n        tls_session = session_state.get(\"tls_session\") if use_tls else None\n\n        # Try the printer's original port first — this ensures the port\n        # matches even when bounce protection or iptables REDIRECT is in play.\n        try:\n            await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls, tls_session)\n            logger.info(\"FTP data proxy: using printer's port %s\", printer_port)\n            return printer_port\n        except OSError as e:\n            logger.debug(\n                \"FTP data proxy: printer port %s unavailable (%s), trying random\",\n                printer_port,\n                e,\n            )\n\n        for _attempt in range(10):\n            port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)\n            try:\n                await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls, tls_session)\n                logger.info(\"FTP data proxy: using random port %s\", port)\n                return port\n            except OSError:\n                continue\n\n        logger.error(\"Failed to bind FTP data proxy port after 10 attempts\")\n        return None\n\n    async def _start_data_proxy_server(\n        self,\n        port: int,\n        printer_ip: str,\n        printer_port: int,\n        use_tls: bool,\n        tls_session: ssl.SSLSession | None = None,\n    ) -> None:\n        \"\"\"Start a one-shot server for one FTP data connection.\n\n        When the slicer connects, immediately connects to the printer's data\n        port and buffers any slicer data until the printer connection is ready.\n        This handles zero-byte uploads (verify_job) where the slicer closes\n        the data channel before a naive proxy would finish its TLS handshake.\n\n        The slicer-side listener is ALWAYS cleartext.  Even when the slicer\n        sends PROT P on the control channel, Bambu Studio does not perform\n        a TLS handshake on the data connection — it relies on the implicit\n        FTPS control channel for authentication and sends data unencrypted.\n\n        The printer-side outbound connection follows the PROT level:\n        - PROT P (use_tls=True): TLS to the printer's data port\n        - PROT C (use_tls=False): cleartext to the printer's data port\n\n        This mirrors the control channel's TLS-termination architecture.\n\n        Raises OSError if the port is already in use.\n        \"\"\"\n        connected = asyncio.Event()\n        server_holder: list[asyncio.Server] = []\n\n        # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on\n        # the data channel even after sending PROT P.\n        # Printer side: TLS if PROT P, cleartext if PROT C.\n        # For TLS data connections, wrap the SSL context to reuse the\n        # control channel's TLS session if available. vsFTPd (X1C) requires\n        # require_ssl_reuse — without this, data connections are rejected\n        # with \"522 SSL connection failed: session reuse required\".\n        if use_tls and tls_session:\n            client_ssl = _SessionReuseSSLContext(self._client_ssl_context, tls_session)\n            logger.debug(\"FTP data proxy: using TLS session reuse for port %s\", port)\n        else:\n            client_ssl = self._client_ssl_context if use_tls else None\n\n        # Slicer side is ALWAYS cleartext — Bambu Studio does not do TLS on\n        # the data channel even after PROT P (confirmed for both H2D and X1C).\n        printer_mode = \"TLS\" if use_tls else \"cleartext\"\n\n        async def handle_data(\n            client_reader: asyncio.StreamReader,\n            client_writer: asyncio.StreamWriter,\n        ) -> None:\n            \"\"\"Handle one FTP data connection, then close the server.\"\"\"\n            peername = client_writer.get_extra_info(\"peername\")\n            data_client = f\"{peername[0]}:{peername[1]}\" if peername else \"unknown\"\n            logger.info(\n                \"FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s\",\n                port,\n                printer_mode,\n                data_client,\n                printer_ip,\n                printer_port,\n            )\n            connected.set()\n            # One-shot: close server after accepting first connection\n            if server_holder:\n                server_holder[0].close()\n\n            printer_writer = None\n            try:\n                # Buffer any slicer data while connecting to printer.\n                # This handles the race where the slicer sends data (or closes\n                # for zero-byte files) before the TLS handshake completes.\n                slicer_buffer = bytearray()\n                slicer_eof = False\n\n                async def buffer_slicer():\n                    nonlocal slicer_eof\n                    while True:\n                        chunk = await client_reader.read(65536)\n                        if not chunk:\n                            slicer_eof = True\n                            return\n                        slicer_buffer.extend(chunk)\n\n                buffer_task = asyncio.create_task(buffer_slicer())\n\n                # Connect to printer's data port\n                printer_reader, printer_writer = await asyncio.wait_for(\n                    asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),\n                    timeout=10.0,\n                )\n                logger.info(\n                    \"FTP data proxy port %s (printer=%s): connected to printer %s:%s\",\n                    port,\n                    printer_mode,\n                    printer_ip,\n                    printer_port,\n                )\n\n                # Stop buffering\n                buffer_task.cancel()\n                try:\n                    await buffer_task\n                except asyncio.CancelledError:\n                    pass\n\n                # Flush buffered slicer data to printer\n                logger.info(\n                    \"FTP data proxy port %s: buffer=%s bytes, slicer_eof=%s\",\n                    port,\n                    len(slicer_buffer),\n                    slicer_eof,\n                )\n                if slicer_buffer:\n                    printer_writer.write(bytes(slicer_buffer))\n                    await printer_writer.drain()\n\n                # Forward remaining slicer data to printer, then close the\n                # printer side to signal upload complete.\n                #\n                # Bambu Studio does NOT close the FTP data channel after sending\n                # STOR data — it keeps the connection open and waits for the\n                # printer to close its side + send 226 on the control channel.\n                # A naive bidirectional proxy deadlocks here because the proxy\n                # waits for the slicer EOF that never comes.\n                #\n                # Fix: read slicer data with an idle timeout. Once data has been\n                # received and the slicer goes quiet, close the printer side so\n                # the printer can send 226. For RETR (download), the printer\n                # sends data and closes — the slicer reads until EOF — so this\n                # unidirectional approach works for both directions.\n                total_c2p = len(slicer_buffer)\n                if not slicer_eof:\n                    # Read remaining slicer data with idle detection.\n                    # Must be short — Bambu Studio expects 226 almost instantly\n                    # after sending data. Too long and the slicer times out.\n                    idle_timeout = 0.3\n                    while True:\n                        try:\n                            chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)\n                        except TimeoutError:\n                            if total_c2p > 0:\n                                # Slicer sent data then went idle — upload done\n                                logger.debug(\n                                    \"FTP data proxy port %s: slicer idle after %s bytes, closing printer side\",\n                                    port,\n                                    total_c2p,\n                                )\n                                break\n                            continue  # No data yet, keep waiting\n                        if not chunk:\n                            break  # Slicer closed\n                        printer_writer.write(chunk)\n                        await printer_writer.drain()\n                        total_c2p += len(chunk)\n\n                logger.debug(\"FTP proxy data_c2p: total %s bytes\", total_c2p)\n\n                # Close printer side to signal upload complete.\n                # For TLS, close() sends close_notify which the printer treats\n                # as end-of-data. The printer then sends 226 on the control\n                # channel. For RETR, this is a no-op since the printer closes\n                # first and we'd have exited the loop above via EOF.\n                try:\n                    printer_writer.close()\n                    await printer_writer.wait_closed()\n                except OSError:\n                    pass\n\n                # Wait for 226 response to propagate through the FTP control\n                # channel before closing the slicer's data channel.\n                #\n                # Without this delay, the data channel FIN arrives at the\n                # slicer before the 226 response on the control channel.\n                # BambuStudio reacts to the data channel FIN within <1ms\n                # by sending QUIT + closing the control channel — before\n                # 226 arrives (~2-3ms network RTT). This causes verify_job\n                # to be treated as failed and shows the login modal.\n                #\n                # In a direct connection, the printer sends 226 AND closes\n                # the data channel simultaneously, so the slicer gets both\n                # at once. The delay here emulates that timing.\n                if total_c2p > 0:\n                    await asyncio.sleep(0.5)\n\n            except Exception as e:\n                logger.error(\"FTP data proxy port %s: error: %s\", port, e)\n            finally:\n                for w in [client_writer, printer_writer]:\n                    if w:\n                        try:\n                            w.close()\n                            await w.wait_closed()\n                        except OSError:\n                            pass  # Best-effort data connection cleanup\n                logger.info(\"FTP data proxy port %s: connection closed\", port)\n\n        server = await asyncio.start_server(\n            handle_data,\n            \"0.0.0.0\",  # nosec B104\n            port,\n            # No TLS on slicer side — Bambu Studio doesn't do TLS on data\n            # channel even after PROT P (confirmed by connection hang test).\n        )\n        server_holder.append(server)\n        self._data_servers.append(server)\n\n        # Auto-close after 60s if no connection arrives\n        async def auto_close() -> None:\n            try:\n                await asyncio.wait_for(connected.wait(), timeout=60.0)\n            except TimeoutError:\n                logger.debug(\"FTP data proxy on port %s timed out, closing\", port)\n                try:\n                    server.close()\n                    await server.wait_closed()\n                except OSError:\n                    pass  # Best-effort timeout cleanup\n            finally:\n                if server in self._data_servers:\n                    self._data_servers.remove(server)\n\n        asyncio.create_task(auto_close(), name=f\"ftp_data_timeout_{port}\")\n\n        logger.debug(\"FTP data proxy: port %s → %s:%s\", port, printer_ip, printer_port)\n\n\nclass SlicerProxyManager:\n    \"\"\"Manages FTP and MQTT TLS proxies for a single printer target.\"\"\"\n\n    # Bambu printer ports\n    PRINTER_FTP_PORT = 990\n    PRINTER_MQTT_PORT = 8883\n    PRINTER_FILE_TRANSFER_PORT = 6000\n    PRINTER_RTSP_PORT = 322  # X1/H2/P2 series camera (A1/P1 use port 6000)\n    # Undocumented proprietary ports used by some models (A1, P1S, etc.)\n    # BambuStudio requires port 2024 for printing; OrcaSlicer also needs 2025.\n    PRINTER_AUX_PORTS = [2024, 2025, 2026]\n    PRINTER_BIND_PORTS = [3000, 3002]\n\n    # Local listen ports - must match what Bambu Studio expects\n    # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability\n    LOCAL_FTP_PORT = 990\n    LOCAL_MQTT_PORT = 8883\n\n    def __init__(\n        self,\n        target_host: str,\n        cert_path: Path,\n        key_path: Path,\n        on_activity: Callable[[str, str], None] | None = None,\n        bind_address: str = \"0.0.0.0\",  # nosec B104\n        bind_identity: dict[str, str] | None = None,\n    ):\n        \"\"\"Initialize the slicer proxy manager.\n\n        Args:\n            target_host: Target printer IP address\n            cert_path: Path to server certificate\n            key_path: Path to server private key\n            on_activity: Optional callback for activity logging (name, message)\n            bind_address: IP address to bind proxy listeners to\n            bind_identity: Optional dict with keys (serial, model, name, version)\n                for the bind/detect response. When provided, the proxy responds\n                to detect requests itself instead of forwarding to the printer.\n                This ensures the slicer sees the VP identity, not the real printer.\n        \"\"\"\n        self.target_host = target_host\n        self.cert_path = cert_path\n        self.key_path = key_path\n        self.on_activity = on_activity\n        self.bind_address = bind_address\n        self.bind_identity = bind_identity\n\n        self._ftp_proxy: TCPProxy | None = None\n        self._mqtt_proxy: TLSProxy | None = None\n        self._file_transfer_proxy: TCPProxy | None = None\n        self._rtsp_proxy: TCPProxy | None = None\n        self._aux_proxies: list[TCPProxy] = []\n        self._bind_proxies: list[TCPProxy] = []\n        self._bind_server = None\n        self._probe_servers: list[asyncio.Server] = []\n        self._tasks: list[asyncio.Task] = []\n\n    # FTP passive data port range — Bambu printers typically use ports in\n    # this range for EPSV/PASV data connections. We pre-listen on all of\n    # them so EPSV works transparently without decrypting FTP control.\n    FTP_DATA_PORT_MIN = 50000\n    FTP_DATA_PORT_MAX = 50100\n\n    async def start(self) -> None:\n        \"\"\"Start proxy services.\n\n        Uses transparent TCP proxying for most protocols (FTP, FileTransfer,\n        Camera) — raw bytes are forwarded without TLS termination, so the\n        slicer gets the printer's real TLS certificate end-to-end.\n\n        Only MQTT is TLS-terminated because we must decrypt the payload to\n        rewrite the printer's real IP with the proxy's bind IP.\n        \"\"\"\n        logger.info(\"Starting slicer proxy to %s (transparent mode)\", self.target_host)\n\n        # Detect iptables port redirect for FTP\n        ftp_listen_port = self.LOCAL_FTP_PORT\n        redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)\n        if redirect_target:\n            logger.info(\n                \"Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.\",\n                self.LOCAL_FTP_PORT,\n                redirect_target,\n                redirect_target,\n            )\n            ftp_listen_port = redirect_target\n\n        # FTP control — raw TCP pass-through (end-to-end TLS with printer)\n        self._ftp_proxy = TCPProxy(\n            name=\"FTP\",\n            listen_port=ftp_listen_port,\n            target_host=self.target_host,\n            target_port=self.PRINTER_FTP_PORT,\n            on_connect=lambda cid: self._log_activity(\"FTP\", f\"connected: {cid}\"),\n            on_disconnect=lambda cid: self._log_activity(\"FTP\", f\"disconnected: {cid}\"),\n            bind_address=self.bind_address,\n        )\n\n        # FTP data ports — pre-listen on the entire passive port range.\n        # Since FTP control is encrypted end-to-end, we can't read EPSV\n        # responses to know which port the printer chose. Instead, we\n        # listen on every port in the range and forward to the same port\n        # on the printer. The slicer connects to bind_ip:PORT (from EPSV)\n        # and we transparently relay to printer_ip:PORT.\n        self._ftp_data_proxies: list[TCPProxy] = []\n        for port in range(self.FTP_DATA_PORT_MIN, self.FTP_DATA_PORT_MAX + 1):\n            dp = TCPProxy(\n                name=f\"FTP-Data-{port}\",\n                listen_port=port,\n                target_host=self.target_host,\n                target_port=port,\n                bind_address=self.bind_address,\n            )\n            self._ftp_data_proxies.append(dp)\n\n        # MQTT — TLS-terminating proxy (must decrypt to rewrite IP addresses)\n        self._mqtt_proxy = TLSProxy(\n            name=\"MQTT\",\n            listen_port=self.LOCAL_MQTT_PORT,\n            target_host=self.target_host,\n            target_port=self.PRINTER_MQTT_PORT,\n            server_cert_path=self.cert_path,\n            server_key_path=self.key_path,\n            on_connect=lambda cid: self._log_activity(\"MQTT\", f\"connected: {cid}\"),\n            on_disconnect=lambda cid: self._log_activity(\"MQTT\", f\"disconnected: {cid}\"),\n            bind_address=self.bind_address,\n            rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != \"0.0.0.0\" else None,  # nosec B104\n        )\n\n        # File transfer — raw TCP pass-through (port 6000)\n        self._file_transfer_proxy = TCPProxy(\n            name=\"FileTransfer\",\n            listen_port=self.PRINTER_FILE_TRANSFER_PORT,\n            target_host=self.target_host,\n            target_port=self.PRINTER_FILE_TRANSFER_PORT,\n            on_connect=lambda cid: self._log_activity(\"FileTransfer\", f\"connected: {cid}\"),\n            on_disconnect=lambda cid: self._log_activity(\"FileTransfer\", f\"disconnected: {cid}\"),\n            bind_address=self.bind_address,\n        )\n\n        # RTSP camera — raw TCP pass-through (port 322)\n        self._rtsp_proxy = TCPProxy(\n            name=\"RTSP\",\n            listen_port=self.PRINTER_RTSP_PORT,\n            target_host=self.target_host,\n            target_port=self.PRINTER_RTSP_PORT,\n            on_connect=lambda cid: self._log_activity(\"RTSP\", f\"connected: {cid}\"),\n            on_disconnect=lambda cid: self._log_activity(\"RTSP\", f\"disconnected: {cid}\"),\n            bind_address=self.bind_address,\n        )\n\n        # Auxiliary ports (2024-2026) — raw TCP pass-through for undocumented\n        # proprietary services. Required by BambuStudio/OrcaSlicer for some\n        # models (A1, P1S). Silently ignored if the printer doesn't listen.\n        for aux_port in self.PRINTER_AUX_PORTS:\n            self._aux_proxies.append(\n                TCPProxy(\n                    name=f\"Aux-{aux_port}\",\n                    listen_port=aux_port,\n                    target_host=self.target_host,\n                    target_port=aux_port,\n                    on_connect=lambda cid, p=aux_port: self._log_activity(f\"Aux-{p}\", f\"connected: {cid}\"),\n                    on_disconnect=lambda cid, p=aux_port: self._log_activity(f\"Aux-{p}\", f\"disconnected: {cid}\"),\n                    bind_address=self.bind_address,\n                )\n            )\n\n        # Bind/auth — respond with VP identity instead of proxying to printer.\n        # The detect response contains the printer name, serial, model, and\n        # bind status. Proxying it would leak the real printer's identity and\n        # cause the slicer to treat it as a different device.\n        if self.bind_identity:\n            from backend.app.services.virtual_printer.bind_server import BindServer\n\n            self._bind_server = BindServer(\n                serial=self.bind_identity[\"serial\"],\n                model=self.bind_identity[\"model\"],\n                name=self.bind_identity[\"name\"],\n                version=self.bind_identity.get(\"version\", \"01.00.00.00\"),\n                bind_address=self.bind_address,\n                cert_path=self.cert_path,\n                key_path=self.key_path,\n            )\n        else:\n            # Fallback: proxy bind requests to the real printer\n            for bind_port in self.PRINTER_BIND_PORTS:\n                if bind_port == 3002:\n                    proxy = TLSProxy(\n                        name=\"Bind-TLS\",\n                        listen_port=bind_port,\n                        target_host=self.target_host,\n                        target_port=bind_port,\n                        server_cert_path=self.cert_path,\n                        server_key_path=self.key_path,\n                        on_connect=lambda cid: self._log_activity(\"Bind\", f\"connected: {cid}\"),\n                        on_disconnect=lambda cid: self._log_activity(\"Bind\", f\"disconnected: {cid}\"),\n                        bind_address=self.bind_address,\n                    )\n                else:\n                    proxy = TCPProxy(\n                        name=\"Bind\",\n                        listen_port=bind_port,\n                        target_host=self.target_host,\n                        target_port=bind_port,\n                        on_connect=lambda cid: self._log_activity(\"Bind\", f\"connected: {cid}\"),\n                        on_disconnect=lambda cid: self._log_activity(\"Bind\", f\"disconnected: {cid}\"),\n                        bind_address=self.bind_address,\n                    )\n                self._bind_proxies.append(proxy)\n\n        # Start as background tasks\n        async def run_with_logging(proxy: TLSProxy | TCPProxy) -> None:\n            try:\n                await proxy.start()\n            except Exception as e:\n                logger.error(\"Slicer proxy %s failed: %s\", proxy.name, e)\n\n        self._tasks = [\n            asyncio.create_task(\n                run_with_logging(self._ftp_proxy),\n                name=\"slicer_proxy_ftp\",\n            ),\n            asyncio.create_task(\n                run_with_logging(self._mqtt_proxy),\n                name=\"slicer_proxy_mqtt\",\n            ),\n            asyncio.create_task(\n                run_with_logging(self._file_transfer_proxy),\n                name=\"slicer_proxy_file_transfer\",\n            ),\n            asyncio.create_task(\n                run_with_logging(self._rtsp_proxy),\n                name=\"slicer_proxy_rtsp\",\n            ),\n        ]\n        for ap in self._aux_proxies:\n            self._tasks.append(\n                asyncio.create_task(\n                    run_with_logging(ap),\n                    name=f\"slicer_proxy_aux_{ap.listen_port}\",\n                )\n            )\n        if self._bind_server:\n            self._tasks.append(\n                asyncio.create_task(\n                    run_with_logging(self._bind_server),\n                    name=\"slicer_proxy_bind_server\",\n                )\n            )\n        for bp in self._bind_proxies:\n            self._tasks.append(\n                asyncio.create_task(\n                    run_with_logging(bp),\n                    name=f\"slicer_proxy_bind_{bp.listen_port}\",\n                )\n            )\n        # FTP data port proxies (50000-50100)\n        for dp in self._ftp_data_proxies:\n            self._tasks.append(\n                asyncio.create_task(\n                    run_with_logging(dp),\n                    name=f\"slicer_proxy_ftp_data_{dp.listen_port}\",\n                )\n            )\n\n        # Diagnostic probe: listen on common un-proxied ports to detect\n        # if the slicer tries to reach a service we don't handle.\n        if self.bind_address and self.bind_address != \"0.0.0.0\":  # nosec B104\n            for probe_port in (21, 80, 443):\n                try:\n                    srv = await asyncio.start_server(\n                        lambda r, w, p=probe_port: self._probe_handler(r, w, p),\n                        self.bind_address,\n                        probe_port,\n                    )\n                    self._probe_servers.append(srv)\n                except OSError:\n                    pass  # Port in use or no permission — skip\n            if self._probe_servers:\n                probed = [s.sockets[0].getsockname()[1] for s in self._probe_servers if s.sockets]\n                logger.info(\"Proxy diagnostic: probing un-proxied ports %s on %s\", probed, self.bind_address)\n\n        logger.info(\n            \"Slicer proxy started for %s (transparent TCP + MQTT TLS, %d FTP data ports)\",\n            self.target_host,\n            len(self._ftp_data_proxies),\n        )\n\n        # Wait for tasks to complete (they run until cancelled)\n        # This keeps the start() coroutine alive so the parent task doesn't complete\n        try:\n            await asyncio.gather(*self._tasks)\n        except asyncio.CancelledError:\n            logger.debug(\"Slicer proxy start cancelled\")\n\n    async def stop(self) -> None:\n        \"\"\"Stop all proxies.\"\"\"\n        logger.info(\"Stopping slicer proxy\")\n\n        # Stop proxies\n        if self._ftp_proxy:\n            await self._ftp_proxy.stop()\n            self._ftp_proxy = None\n\n        if self._mqtt_proxy:\n            await self._mqtt_proxy.stop()\n            self._mqtt_proxy = None\n\n        if self._file_transfer_proxy:\n            await self._file_transfer_proxy.stop()\n            self._file_transfer_proxy = None\n\n        if self._rtsp_proxy:\n            await self._rtsp_proxy.stop()\n            self._rtsp_proxy = None\n\n        for ap in self._aux_proxies:\n            await ap.stop()\n        self._aux_proxies = []\n\n        if self._bind_server:\n            await self._bind_server.stop()\n            self._bind_server = None\n\n        for bp in self._bind_proxies:\n            await bp.stop()\n        self._bind_proxies = []\n\n        for dp in self._ftp_data_proxies:\n            await dp.stop()\n        self._ftp_data_proxies = []\n\n        for srv in self._probe_servers:\n            srv.close()\n        self._probe_servers = []\n\n        # Cancel tasks\n        for task in self._tasks:\n            task.cancel()\n\n        if self._tasks:\n            try:\n                await asyncio.wait_for(\n                    asyncio.gather(*self._tasks, return_exceptions=True),\n                    timeout=2.0,\n                )\n            except TimeoutError:\n                logger.debug(\"Some proxy tasks didn't stop in time\")\n\n        self._tasks = []\n        logger.info(\"Slicer proxy stopped\")\n\n    async def _probe_handler(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int) -> None:\n        \"\"\"Log unexpected connections on un-proxied ports for diagnostics.\"\"\"\n        peername = writer.get_extra_info(\"peername\")\n        client = f\"{peername[0]}:{peername[1]}\" if peername else \"unknown\"\n        logger.warning(\n            \"PROBE: slicer connected to un-proxied port %d from %s — this port may need proxying\",\n            port,\n            client,\n        )\n        writer.close()\n        try:\n            await writer.wait_closed()\n        except OSError:\n            pass\n\n    def _log_activity(self, name: str, message: str) -> None:\n        \"\"\"Log activity via callback if configured.\"\"\"\n        if self.on_activity:\n            try:\n                self.on_activity(name, message)\n            except Exception:\n                pass  # Ignore activity callback errors; logging is non-critical\n\n    @property\n    def is_running(self) -> bool:\n        \"\"\"Check if proxies are running.\"\"\"\n        return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)\n\n    def get_status(self) -> dict:\n        \"\"\"Get proxy status.\"\"\"\n        return {\n            \"running\": self.is_running,\n            \"target_host\": self.target_host,\n            \"ftp_port\": self.LOCAL_FTP_PORT,\n            \"mqtt_port\": self.LOCAL_MQTT_PORT,\n            \"bind_ports\": self.PRINTER_BIND_PORTS,\n            \"ftp_connections\": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),\n            \"mqtt_connections\": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),\n            \"bind_connections\": sum(len(bp._active_connections) for bp in self._bind_proxies),\n        }\n"
  },
  {
    "path": "backend/app/utils/color_utils.py",
    "content": "\"\"\"Color comparison utilities for RFID/firmware color matching.\"\"\"\n\n\ndef colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool:\n    \"\"\"Compare two RRGGBB(AA) hex colors with tolerance for RFID/firmware variations.\n\n    Uses Euclidean RGB distance. Alpha channel (bytes 7-8) is ignored.\n    Default threshold of 50 accommodates typical RFID read variations\n    (e.g. 7CC4D5 vs 56B7E6 = distance ~43.6) while rejecting clearly\n    different colors (e.g. red vs blue = distance ~360).\n    \"\"\"\n    a = hex_a.strip().upper()\n    b = hex_b.strip().upper()\n    if a == b:\n        return True\n    if len(a) < 6 or len(b) < 6:\n        return False\n    try:\n        ra, ga, ba = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16)\n        rb, gb, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16)\n    except ValueError:\n        return False\n    dist = ((ra - rb) ** 2 + (ga - gb) ** 2 + (ba - bb) ** 2) ** 0.5\n    return dist <= threshold\n"
  },
  {
    "path": "backend/app/utils/filament_ids.py",
    "content": "\"\"\"Utility functions for converting between filament_id and setting_id formats.\n\nBambu printers use two ID formats for filament presets:\n  - **filament_id** (aka tray_info_idx): e.g. \"GFL05\", \"GFG02\", \"GFA00\"\n    Reported by printer firmware (RFID tags, AMS status).\n  - **setting_id**: e.g. \"GFSL05\", \"GFSG02\", \"GFSA00\"\n    Used by BambuStudio / Bambu Cloud API to resolve presets.\n\nThe only difference for official Bambu filaments is an \"S\" inserted after \"GF\".\nUser presets (starting with \"P\") use the same ID in both contexts.\n\"\"\"\n\n\ndef filament_id_to_setting_id(filament_id: str) -> str:\n    \"\"\"Convert filament_id → setting_id (e.g. \"GFL05\" → \"GFSL05\").\n\n    - Already a setting_id (\"GFS…\") → returned unchanged.\n    - User presets (\"P…\") → returned unchanged.\n    - Empty / unknown → returned unchanged.\n    \"\"\"\n    if not filament_id:\n        return filament_id\n\n    # User presets start with \"P\" - leave unchanged\n    if filament_id.startswith(\"P\"):\n        return filament_id\n\n    # Official Bambu presets: GFx## -> GFSx##\n    if filament_id.startswith(\"GF\") and len(filament_id) >= 4:\n        # Already a setting_id (has S after GF)\n        if filament_id[2] == \"S\":\n            return filament_id\n        return f\"GFS{filament_id[2:]}\"\n\n    return filament_id\n\n\ndef setting_id_to_filament_id(setting_id: str) -> str:\n    \"\"\"Convert setting_id → filament_id (e.g. \"GFSL05\" → \"GFL05\").\n\n    - Already a filament_id (\"GF\" without \"S\") → returned unchanged.\n    - User presets (\"P…\") → returned unchanged.\n    - Empty / unknown → returned unchanged.\n    \"\"\"\n    if not setting_id:\n        return setting_id\n\n    # User presets start with \"P\" - leave unchanged\n    if setting_id.startswith(\"P\"):\n        return setting_id\n\n    # Setting_id format: GFSx## -> GFx##  (remove the \"S\")\n    if setting_id.startswith(\"GFS\") and len(setting_id) >= 5:\n        return f\"GF{setting_id[3:]}\"\n\n    return setting_id\n\n\ndef normalize_slicer_filament(slicer_filament: str | None) -> tuple[str, str]:\n    \"\"\"Normalize a slicer_filament value into (tray_info_idx, setting_id).\n\n    The slicer_filament field on a spool can be stored in either format:\n      - filament_id: \"GFL05\"  (from RFID tag scan)\n      - setting_id:  \"GFSL05\" or \"GFSL05_07\"  (from cloud preset picker)\n\n    Returns (tray_info_idx, setting_id) with version suffixes stripped.\n    \"\"\"\n    raw = slicer_filament or \"\"\n    if not raw:\n        return (\"\", \"\")\n\n    # Strip version suffix (e.g. \"GFSL05_07\" → \"GFSL05\")\n    base = raw.split(\"_\")[0] if \"_\" in raw else raw\n\n    tray_info_idx = setting_id_to_filament_id(base)\n    sid = filament_id_to_setting_id(base)\n\n    return (tray_info_idx, sid)\n"
  },
  {
    "path": "backend/app/utils/printer_models.py",
    "content": "\"\"\"Printer model normalization utilities.\n\nConverts 3MF printer model names (e.g., \"Bambu Lab X1 Carbon\") to\nnormalized short names (e.g., \"X1C\") that match database storage.\n\"\"\"\n\n# Map from 3MF printer_model strings to normalized short names\nPRINTER_MODEL_MAP = {\n    \"Bambu Lab X1 Carbon\": \"X1C\",\n    \"Bambu Lab X1\": \"X1\",\n    \"Bambu Lab X1E\": \"X1E\",\n    \"Bambu Lab P1S\": \"P1S\",\n    \"Bambu Lab P1P\": \"P1P\",\n    \"Bambu Lab P2S\": \"P2S\",\n    \"Bambu Lab A1\": \"A1\",\n    \"Bambu Lab A1 Mini\": \"A1 Mini\",\n    \"Bambu Lab A1 mini\": \"A1 Mini\",\n    \"Bambu Lab H2D\": \"H2D\",\n    \"Bambu Lab H2D Pro\": \"H2D Pro\",\n    \"Bambu Lab H2C\": \"H2C\",\n    \"Bambu Lab H2S\": \"H2S\",\n    \"Bambu Lab X2D\": \"X2D\",\n}\n\n# Map from printer_model_id (internal codes in slice_info.config) to short names\n# These are the codes Bambu Studio uses internally\nPRINTER_MODEL_ID_MAP = {\n    # X1 series\n    \"C11\": \"X1C\",\n    \"C12\": \"X1\",\n    \"C13\": \"X1E\",\n    # P1 series\n    \"P1P\": \"P1P\",\n    \"P1S\": \"P1S\",\n    # P2 series\n    \"P2S\": \"P2S\",\n    # X2 series\n    \"N6\": \"X2D\",\n    # A1 series\n    \"A11\": \"A1\",\n    \"A12\": \"A1 Mini\",\n    \"N1\": \"A1\",\n    \"N2S\": \"A1 Mini\",\n    \"A04\": \"A1 Mini\",\n    # H2 series (Office/H series)\n    \"O1D\": \"H2D\",\n    \"O1E\": \"H2D Pro\",  # Some devices report O1E\n    \"O2D\": \"H2D Pro\",  # Some devices report O2D\n    \"O1C\": \"H2C\",\n    \"O1C2\": \"H2C\",\n    \"O1S\": \"H2S\",\n}\n\n\n# Rod/rail type classification for maintenance tasks.\n# Carbon rods: X1, P1 series (CoreXY with carbon fiber rods)\n# Steel rods: P2S, X2D series (hardened steel linear shafts)\n# Linear rails: A1, H2 series (linear rail motion system)\n# Values must be uppercase with spaces stripped for normalized comparison.\nCARBON_ROD_MODELS = frozenset(\n    [\n        # Display names (uppercase, no spaces)\n        \"X1\",\n        \"X1C\",\n        \"X1E\",\n        \"P1P\",\n        \"P1S\",\n        # Internal codes\n        \"C11\",  # X1C\n        \"C12\",  # X1\n        \"C13\",  # X1E\n    ]\n)\n\nSTEEL_ROD_MODELS = frozenset(\n    [\n        # Display names (uppercase, no spaces)\n        \"P2S\",\n        \"X2D\",\n        # Internal codes\n        \"N7\",  # P2S\n        \"N6\",  # X2D\n    ]\n)\n\nLINEAR_RAIL_MODELS = frozenset(\n    [\n        # Display names (uppercase, no spaces)\n        \"A1\",\n        \"A1MINI\",\n        \"H2D\",\n        \"H2DPRO\",\n        \"H2C\",\n        \"H2S\",\n        # Internal codes\n        \"N1\",  # A1\n        \"N2S\",  # A1 Mini\n        \"A04\",  # A1 Mini (alternate)\n        \"A11\",  # A1\n        \"A12\",  # A1 Mini\n        \"O1D\",  # H2D\n        \"O1E\",  # H2D Pro\n        \"O2D\",  # H2D Pro (alternate)\n        \"O1C\",  # H2C\n        \"O1C2\",  # H2C (dual nozzle variant)\n        \"O1S\",  # H2S\n    ]\n)\n\n\n# Models with an ethernet port.\n# X1, P1P, A1, A1 Mini do NOT have ethernet.\nETHERNET_MODELS = frozenset(\n    [\n        # Display names (uppercase, no spaces)\n        \"X1C\",\n        \"X1E\",\n        \"X2D\",\n        \"P1S\",\n        \"P2S\",\n        \"H2D\",\n        \"H2DPRO\",\n        \"H2C\",\n        \"H2S\",\n        # Internal codes\n        \"C11\",  # X1C\n        \"C13\",  # X1E\n        \"N6\",  # X2D\n        \"P1S\",  # P1S\n        \"O1D\",  # H2D\n        \"O1E\",  # H2D Pro\n        \"O2D\",  # H2D Pro (alternate)\n        \"O1C\",  # H2C\n        \"O1C2\",  # H2C (dual nozzle variant)\n        \"O1S\",  # H2S\n    ]\n)\n\n\ndef has_ethernet(model: str | None) -> bool:\n    \"\"\"Return True if the printer model has an ethernet port.\"\"\"\n    if not model:\n        return False\n    normalized = model.strip().upper().replace(\" \", \"\").replace(\"-\", \"\")\n    return normalized in ETHERNET_MODELS\n\n\ndef get_rod_type(model: str | None) -> str | None:\n    \"\"\"Return the rod/rail type for a printer model.\n\n    Returns:\n        \"carbon\" for X1/P1 series (carbon fiber rods),\n        \"steel_rod\" for P2S/X2D series (hardened steel rods),\n        \"linear_rail\" for A1/H2 series (linear rails),\n        None for unknown models.\n    \"\"\"\n    if not model:\n        return None\n    normalized = model.strip().upper().replace(\" \", \"\").replace(\"-\", \"\")\n    if normalized in CARBON_ROD_MODELS:\n        return \"carbon\"\n    if normalized in STEEL_ROD_MODELS:\n        return \"steel_rod\"\n    if normalized in LINEAR_RAIL_MODELS:\n        return \"linear_rail\"\n    return None\n\n\ndef normalize_printer_model_id(model_id: str | None) -> str | None:\n    \"\"\"Convert printer_model_id (internal code) to normalized short name.\n\n    Args:\n        model_id: The printer_model_id from slice_info.config (e.g., \"C11\", \"O1D\")\n\n    Returns:\n        Normalized short name (e.g., \"X1C\", \"H2D\") or the original ID if unknown.\n    \"\"\"\n    if not model_id:\n        return None\n\n    # Check known mappings\n    if model_id in PRINTER_MODEL_ID_MAP:\n        return PRINTER_MODEL_ID_MAP[model_id]\n\n    # Return original if unknown (might already be a short name)\n    return model_id\n\n\ndef normalize_printer_model(raw_model: str | None) -> str | None:\n    \"\"\"Convert 3MF printer_model to normalized short name.\n\n    Args:\n        raw_model: The printer_model string from 3MF metadata\n            (e.g., \"Bambu Lab X1 Carbon\")\n\n    Returns:\n        Normalized short name (e.g., \"X1C\") or None if input is empty.\n        Unknown models have \"Bambu Lab \" prefix stripped.\n    \"\"\"\n    if not raw_model:\n        return None\n\n    # Check known mappings first\n    if raw_model in PRINTER_MODEL_MAP:\n        return PRINTER_MODEL_MAP[raw_model]\n\n    # Strip \"Bambu Lab \" prefix for unknown models\n    stripped = raw_model.replace(\"Bambu Lab \", \"\").strip()\n    return stripped or None\n"
  },
  {
    "path": "backend/app/utils/tag_normalization.py",
    "content": "\"\"\"Shared helpers for normalizing RFID tag and tray identifiers.\"\"\"\n\n\ndef normalize_hex(value: str | None) -> str:\n    if not value:\n        return \"\"\n    hex_chars = \"\".join(ch for ch in str(value).strip() if ch in \"0123456789abcdefABCDEF\")\n    return hex_chars.upper()\n\n\ndef normalize_tag_uid(value: str | None) -> str:\n    uid = normalize_hex(value)\n    # DB column is VARCHAR(16), so keep the least-significant bytes if longer.\n    if len(uid) > 16:\n        uid = uid[-16:]\n    return uid\n\n\ndef normalize_tray_uuid(value: str | None) -> str:\n    uuid = normalize_hex(value)\n    # DB column is VARCHAR(32). Keep canonical 32-char UUID when possible.\n    if len(uuid) >= 32:\n        uuid = uuid[:32]\n    return uuid\n"
  },
  {
    "path": "backend/app/utils/threemf_tools.py",
    "content": "\"\"\"3MF file parsing utilities for filament tracking.\n\nThis module provides functions to parse Bambu Lab 3MF files and extract\nper-layer filament usage data from the embedded G-code. This enables\naccurate partial usage reporting for multi-material prints.\n\"\"\"\n\nimport json\nimport math\nimport re\nimport zipfile\nfrom pathlib import Path\n\nimport defusedxml.ElementTree as ET\n\n# Default filament properties\nDEFAULT_FILAMENT_DIAMETER = 1.75  # mm\nDEFAULT_FILAMENT_DENSITY = 1.24  # g/cm³ (PLA)\n\n\ndef parse_gcode_layer_filament_usage(gcode_content: str) -> dict[int, dict[int, float]]:\n    \"\"\"Parse G-code to extract per-layer, per-filament cumulative extrusion in mm.\n\n    This function tracks filament extrusion across layers and tool changes,\n    building a cumulative usage map that can be used to calculate partial\n    usage at any layer.\n\n    Args:\n        gcode_content: The raw G-code content as a string\n\n    Returns:\n        A nested dictionary mapping layer numbers to filament usage:\n        {layer: {filament_id: cumulative_mm}, ...}\n\n    Example:\n        {0: {0: 125.5}, 1: {0: 250.0, 1: 50.0}, 2: {0: 375.0, 1: 150.0}}\n\n        This shows:\n        - Layer 0: filament 0 used 125.5mm cumulative\n        - Layer 1: filament 0 used 250mm cumulative, filament 1 used 50mm\n        - Layer 2: filament 0 used 375mm cumulative, filament 1 used 150mm\n\n    G-code commands parsed:\n        - M73 L<layer>: Layer change marker\n        - M620 S<filament>: Filament/tool change (S255 = unload)\n        - G0/G1/G2/G3 E<amount>: Extrusion moves\n    \"\"\"\n    layer_filaments: dict[int, dict[int, float]] = {}\n    current_layer = 0\n    active_filament: int | None = None\n    cumulative_extrusion: dict[int, float] = {}  # filament_id -> total mm\n\n    for line in gcode_content.splitlines():\n        line = line.strip()\n        if not line:\n            continue\n\n        # Handle comments - skip but check for layer markers\n        if line.startswith(\";\"):\n            # Some slicers use comment-based layer markers\n            # e.g., \"; CHANGE_LAYER\" or \";LAYER_CHANGE\"\n            continue\n\n        # Split line into command and inline comment\n        if \";\" in line:\n            line = line.split(\";\")[0].strip()\n\n        # Extract command and parameters\n        parts = line.split()\n        if not parts:\n            continue\n        cmd = parts[0].upper()\n\n        # Layer change: M73 L<layer>\n        # Bambu printers use M73 with L parameter for layer indication\n        if cmd == \"M73\":\n            for part in parts[1:]:\n                part_upper = part.upper()\n                if part_upper.startswith(\"L\"):\n                    try:\n                        new_layer = int(part[1:])\n                        # Save current state before layer change\n                        if cumulative_extrusion:\n                            layer_filaments[current_layer] = cumulative_extrusion.copy()\n                        current_layer = new_layer\n                    except ValueError:\n                        pass  # Skip G-code lines with unparseable layer numbers\n\n        # Filament change: M620 S<filament>\n        # Bambu uses M620 for AMS filament switching\n        # S255 means full unload (no active filament)\n        elif cmd == \"M620\":\n            for part in parts[1:]:\n                part_upper = part.upper()\n                if part_upper.startswith(\"S\"):\n                    filament_str = part[1:]\n                    if filament_str == \"255\":\n                        # Full unload - no active filament\n                        active_filament = None\n                    else:\n                        try:\n                            # Extract digits (e.g., \"0A\" -> 0, \"1\" -> 1)\n                            match = re.match(r\"(\\d+)\", filament_str)\n                            if match:\n                                active_filament = int(match.group(1))\n                        except (ValueError, AttributeError):\n                            pass  # Skip unparseable filament switch commands\n\n        # Extrusion moves: G0/G1/G2/G3 with E parameter\n        # Only G1 typically has extrusion, but check all for safety\n        elif cmd in (\"G0\", \"G1\", \"G2\", \"G3\"):\n            if active_filament is None:\n                continue\n            for part in parts[1:]:\n                part_upper = part.upper()\n                if part_upper.startswith(\"E\"):\n                    try:\n                        extrusion = float(part[1:])\n                        # Only count positive extrusion (not retractions)\n                        if extrusion > 0:\n                            current = cumulative_extrusion.get(active_filament, 0)\n                            cumulative_extrusion[active_filament] = current + extrusion\n                    except ValueError:\n                        pass  # Skip G-code lines with unparseable extrusion values\n\n    # Save final layer state\n    if cumulative_extrusion:\n        layer_filaments[current_layer] = cumulative_extrusion.copy()\n\n    return layer_filaments\n\n\ndef mm_to_grams(\n    length_mm: float,\n    diameter_mm: float = DEFAULT_FILAMENT_DIAMETER,\n    density_g_cm3: float = DEFAULT_FILAMENT_DENSITY,\n) -> float:\n    \"\"\"Convert filament length in mm to weight in grams.\n\n    Uses the formula: mass = volume × density\n    where volume = π × r² × length\n\n    Args:\n        length_mm: Length of filament in millimeters\n        diameter_mm: Filament diameter in millimeters (default: 1.75)\n        density_g_cm3: Material density in g/cm³ (default: 1.24 for PLA)\n\n    Returns:\n        Weight in grams\n    \"\"\"\n    radius_cm = (diameter_mm / 2) / 10  # Convert mm to cm\n    length_cm = length_mm / 10  # Convert mm to cm\n    volume_cm3 = math.pi * radius_cm * radius_cm * length_cm\n    return volume_cm3 * density_g_cm3\n\n\ndef extract_layer_filament_usage_from_3mf(file_path: Path) -> dict[int, dict[int, float]] | None:\n    \"\"\"Extract per-layer filament usage from a 3MF file's embedded G-code.\n\n    Args:\n        file_path: Path to the 3MF file\n\n    Returns:\n        Dictionary mapping layers to filament usage, or None if parsing fails.\n        Format: {layer: {filament_id: cumulative_mm}, ...}\n    \"\"\"\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            # Find G-code file(s) - usually plate_1.gcode or Metadata/plate_1.gcode\n            gcode_files = [f for f in zf.namelist() if f.endswith(\".gcode\")]\n            if not gcode_files:\n                return None\n\n            # Use the first G-code file (typically only one per 3MF export)\n            gcode_path = gcode_files[0]\n            gcode_content = zf.read(gcode_path).decode(\"utf-8\", errors=\"ignore\")\n\n            return parse_gcode_layer_filament_usage(gcode_content)\n    except Exception:\n        return None\n\n\ndef get_cumulative_usage_at_layer(\n    layer_usage: dict[int, dict[int, float]],\n    target_layer: int,\n) -> dict[int, float]:\n    \"\"\"Get cumulative filament usage (in mm) up to and including target_layer.\n\n    Args:\n        layer_usage: The output from parse_gcode_layer_filament_usage()\n        target_layer: The layer number to get usage for\n\n    Returns:\n        Dictionary of {filament_id: cumulative_mm} for each filament used\n        up to target_layer. Returns empty dict if no data available.\n    \"\"\"\n    if not layer_usage:\n        return {}\n\n    # Find the highest recorded layer <= target_layer\n    # (we store snapshots at layer changes, so we need the closest one)\n    relevant_layers = [layer for layer in layer_usage if layer <= target_layer]\n    if not relevant_layers:\n        return {}\n\n    max_layer = max(relevant_layers)\n    return layer_usage.get(max_layer, {})\n\n\ndef extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:\n    \"\"\"Extract filament properties (density, diameter, type) from 3MF metadata.\n\n    Args:\n        file_path: Path to the 3MF file\n\n    Returns:\n        Dictionary mapping filament IDs to their properties:\n        {filament_id: {\"diameter\": 1.75, \"density\": 1.24, \"type\": \"PLA\"}, ...}\n\n        Note: filament_id is 1-based (matches slot_id in slice_info.config)\n    \"\"\"\n    properties: dict[int, dict] = {}\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            # Try slice_info.config first for filament types\n            if \"Metadata/slice_info.config\" in zf.namelist():\n                content = zf.read(\"Metadata/slice_info.config\").decode()\n                root = ET.fromstring(content)\n                for f in root.findall(\".//filament\"):\n                    try:\n                        # id is 1-based in slice_info.config\n                        fid = int(f.get(\"id\", 0))\n                        properties[fid] = {\n                            \"type\": f.get(\"type\", \"PLA\"),\n                            \"diameter\": DEFAULT_FILAMENT_DIAMETER,\n                            \"density\": DEFAULT_FILAMENT_DENSITY,\n                        }\n                    except ValueError:\n                        pass  # Skip filament entries with unparseable IDs\n\n            # Try project_settings.config for density values\n            if \"Metadata/project_settings.config\" in zf.namelist():\n                content = zf.read(\"Metadata/project_settings.config\").decode()\n                try:\n                    data = json.loads(content)\n                    densities = data.get(\"filament_density\", [])\n                    for i, density in enumerate(densities):\n                        # project_settings uses 0-based indexing, convert to 1-based\n                        fid = i + 1\n                        if fid not in properties:\n                            properties[fid] = {\n                                \"type\": \"\",\n                                \"diameter\": DEFAULT_FILAMENT_DIAMETER,\n                            }\n                        try:\n                            properties[fid][\"density\"] = float(density)\n                        except (ValueError, TypeError):\n                            properties[fid][\"density\"] = DEFAULT_FILAMENT_DENSITY\n                except json.JSONDecodeError:\n                    pass  # Skip malformed project_settings.config JSON\n    except Exception:\n        pass  # Return whatever properties were collected before the error\n\n    return properties\n\n\ndef extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:\n    \"\"\"Extract per-slot nozzle/extruder mapping from a 3MF file.\n\n    On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a\n    specific nozzle. The slicer may override user preferences when using \"Auto For\n    Flush\" mode, so the actual assignment comes from slice_info.config group_id\n    attributes, not from the user's filament_nozzle_map preference.\n\n    Priority:\n        1. group_id on <filament> elements in slice_info.config (actual assignment)\n        2. filament_nozzle_map in project_settings.config (user preference fallback)\n\n    Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).\n\n    Args:\n        zf: An open ZipFile of the 3MF archive\n\n    Returns:\n        Dictionary mapping {slot_id: extruder_id} for dual-nozzle files,\n        or None if single-nozzle, missing data, or parse error.\n    \"\"\"\n    try:\n        if \"Metadata/project_settings.config\" not in zf.namelist():\n            return None\n\n        content = zf.read(\"Metadata/project_settings.config\").decode()\n        data = json.loads(content)\n\n        physical_extruder_map = data.get(\"physical_extruder_map\")\n        if not physical_extruder_map or len(physical_extruder_map) <= 1:\n            return None  # Single-nozzle printer\n\n        # Check if only one extruder is active.\n        # If so, we can skip the mapping and just assign all slots to that extruder.\n        # extruder_nozzle_stats format: [\"Standard#0|High Flow#0\", \"Standard#1\"]\n        # Each entry = one extruder. Format: <NozzleVolumeType>#<count>[|...]\n        # #N is the count of physical nozzles of that type (0 = none installed).\n        # Types: Standard, High Flow, Hybrid, TPU High Flow\n\n        active_extruders = []\n        for stats_str in data.get(\"extruder_nozzle_stats\") or []:\n            nozzle_counts = [n.partition(\"#\")[2] for n in stats_str.split(\"|\")]\n            active_extruders.append(1 if any(c not in (\"0\", \"\") for c in nozzle_counts) else 0)\n\n        if sum(active_extruders) == 1:\n            nozzle_mapping: dict[int, int] = {}\n            active_idx = active_extruders.index(1)\n            target_extruder = int(physical_extruder_map[active_idx])\n            if \"Metadata/slice_info.config\" in zf.namelist():\n                si_content = zf.read(\"Metadata/slice_info.config\").decode()\n                si_root = ET.fromstring(si_content)\n                for filament_elem in si_root.findall(\".//filament\"):\n                    try:\n                        nozzle_mapping[int(filament_elem.get(\"id\"))] = target_extruder\n                    except (ValueError, TypeError):\n                        pass\n            return nozzle_mapping or None\n\n        # Priority 1: Use group_id from slice_info filament elements.\n        # This reflects the actual slicer assignment (respects \"Auto For Flush\").\n        nozzle_mapping: dict[int, int] = {}\n        if \"Metadata/slice_info.config\" in zf.namelist():\n            si_content = zf.read(\"Metadata/slice_info.config\").decode()\n            si_root = ET.fromstring(si_content)\n            for filament_elem in si_root.findall(\".//filament\"):\n                group_id_str = filament_elem.get(\"group_id\")\n                filament_id_str = filament_elem.get(\"id\")\n                if group_id_str is not None and filament_id_str:\n                    try:\n                        group_id = int(group_id_str)\n                        slot_id = int(filament_id_str)\n                        if group_id < len(physical_extruder_map):\n                            nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])\n                    except (ValueError, TypeError, IndexError):\n                        pass\n\n        if nozzle_mapping:\n            return nozzle_mapping\n\n        # Priority 2: Fall back to filament_nozzle_map (user preference).\n        # This is correct when the user manually assigned nozzles, but may be\n        # wrong when the slicer overrides via \"Auto For Flush\".\n        filament_nozzle_map = data.get(\"filament_nozzle_map\")\n        if not filament_nozzle_map:\n            return None\n\n        for i, slicer_ext_str in enumerate(filament_nozzle_map):\n            slot_id = i + 1\n            try:\n                slicer_ext = int(slicer_ext_str)\n                if slicer_ext < len(physical_extruder_map):\n                    nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])\n            except (ValueError, TypeError, IndexError):\n                pass\n\n        return nozzle_mapping if nozzle_mapping else None\n    except Exception:\n        return None\n\n\ndef extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:\n    \"\"\"Extract per-filament total usage from 3MF slice_info.config.\n\n    This extracts the slicer-estimated total usage per filament slot,\n    not the per-layer breakdown.\n\n    Args:\n        file_path: Path to the 3MF file\n        plate_id: Optional plate index to filter for (for multi-plate files)\n\n    Returns:\n        List of filament usage dictionaries:\n        [{\"slot_id\": 1, \"used_g\": 50.5, \"type\": \"PLA\", \"color\": \"#FF0000\"}, ...]\n    \"\"\"\n    filament_usage = []\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            if \"Metadata/slice_info.config\" not in zf.namelist():\n                return []\n\n            content = zf.read(\"Metadata/slice_info.config\").decode()\n            root = ET.fromstring(content)\n\n            if plate_id is not None:\n                # Find the plate element with matching index\n                for plate_elem in root.findall(\".//plate\"):\n                    plate_index = None\n                    for meta in plate_elem.findall(\"metadata\"):\n                        if meta.get(\"key\") == \"index\":\n                            try:\n                                plate_index = int(meta.get(\"value\", \"0\"))\n                            except ValueError:\n                                pass\n                            break\n\n                    if plate_index == plate_id:\n                        for f in plate_elem.findall(\"filament\"):\n                            filament_id = f.get(\"id\")\n                            used_g = f.get(\"used_g\", \"0\")\n                            try:\n                                used_amount = float(used_g)\n                                if filament_id:\n                                    filament_usage.append(\n                                        {\n                                            \"slot_id\": int(filament_id),\n                                            \"used_g\": used_amount,\n                                            \"type\": f.get(\"type\", \"\"),\n                                            \"color\": f.get(\"color\", \"\"),\n                                        }\n                                    )\n                            except (ValueError, TypeError):\n                                pass\n                        break\n            else:\n                # No plate_id specified - extract all filaments\n                for f in root.findall(\".//filament\"):\n                    filament_id = f.get(\"id\")\n                    used_g = f.get(\"used_g\", \"0\")\n                    try:\n                        used_amount = float(used_g)\n                        if filament_id:\n                            filament_usage.append(\n                                {\n                                    \"slot_id\": int(filament_id),\n                                    \"used_g\": used_amount,\n                                    \"type\": f.get(\"type\", \"\"),\n                                    \"color\": f.get(\"color\", \"\"),\n                                }\n                            )\n                    except (ValueError, TypeError):\n                        pass  # Skip filament entries with unparseable usage values\n\n    except Exception:\n        pass  # Return whatever usage data was collected before the error\n\n    return filament_usage\n\n\ndef inject_gcode_into_3mf(\n    source_path: Path,\n    plate_id: int,\n    start_gcode: str | None,\n    end_gcode: str | None,\n):\n    \"\"\"Create a temp copy of a 3MF with G-code injected at start/end.\n\n    Args:\n        source_path: Path to the original 3MF file.\n        plate_id: Plate number (1-indexed) to inject into.\n        start_gcode: G-code to prepend, or None.\n        end_gcode: G-code to append, or None.\n\n    Returns:\n        Path to temp file with injected G-code, or None if injection failed.\n        Caller is responsible for cleaning up the temp file.\n    \"\"\"\n    import tempfile\n\n    if not start_gcode and not end_gcode:\n        return None\n\n    try:\n        # Find the target gcode file inside the 3MF\n        with zipfile.ZipFile(source_path, \"r\") as zf:\n            all_gcode = [f for f in zf.namelist() if f.endswith(\".gcode\")]\n            if not all_gcode:\n                return None\n\n            # Try plate-specific gcode file first\n            target_gcode = None\n            plate_pattern = f\"plate_{plate_id}.gcode\"\n            for f in all_gcode:\n                if f.endswith(plate_pattern):\n                    target_gcode = f\n                    break\n\n            # Fall back to first gcode file\n            if target_gcode is None:\n                target_gcode = all_gcode[0]\n\n            # Read and modify gcode content\n            gcode_content = zf.read(target_gcode).decode(\"utf-8\", errors=\"ignore\")\n\n            if start_gcode:\n                gcode_content = start_gcode + \"\\n\" + gcode_content\n            if end_gcode:\n                gcode_content = gcode_content.rstrip(\"\\n\") + \"\\n\" + end_gcode + \"\\n\"\n\n            # Write modified 3MF to temp file\n            with tempfile.NamedTemporaryFile(delete=False, suffix=\".3mf\") as tmp:\n                tmp_path = Path(tmp.name)\n\n            with zipfile.ZipFile(tmp_path, \"w\", zipfile.ZIP_DEFLATED) as zf_write:\n                for item in zf.namelist():\n                    info = zf.getinfo(item)\n                    if item == target_gcode:\n                        zf_write.writestr(info, gcode_content.encode(\"utf-8\"))\n                    else:\n                        zf_write.writestr(info, zf.read(item))\n\n        return tmp_path\n\n    except Exception:\n        # Clean up temp file on error\n        if \"tmp_path\" in locals() and tmp_path.exists():\n            tmp_path.unlink(missing_ok=True)\n        return None\n"
  },
  {
    "path": "backend/tests/__init__.py",
    "content": "\"\"\"BamBuddy backend tests.\"\"\"\n"
  },
  {
    "path": "backend/tests/conftest.py",
    "content": "\"\"\"Shared test fixtures for BamBuddy backend tests.\"\"\"\n\nimport asyncio\nimport atexit\nimport json\nimport logging\nimport os\nimport shutil\nimport tempfile\nfrom collections.abc import AsyncGenerator\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\n# IMPORTANT: Set environment variables BEFORE any app imports\n# This must happen before settings/config are loaded\nos.environ[\"LOG_TO_FILE\"] = \"false\"\nos.environ[\"DEBUG\"] = \"false\"\n\nfrom httpx import ASGITransport, AsyncClient  # noqa: E402\nfrom sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine  # noqa: E402\n\n# Ensure settings use our env vars - import and override before database import\nfrom backend.app.core.config import settings  # noqa: E402\n\nsettings.log_to_file = False\n\n# Use a temp directory for plate calibration to avoid deleting real calibration files\n_test_plate_cal_dir = Path(tempfile.mkdtemp(prefix=\"bambuddy_test_plate_cal_\"))\nsettings.plate_calibration_dir = _test_plate_cal_dir\n\n\n# Clean up temp directory when tests finish\ndef _cleanup_test_plate_cal_dir():\n    if _test_plate_cal_dir.exists():\n        shutil.rmtree(_test_plate_cal_dir, ignore_errors=True)\n\n\natexit.register(_cleanup_test_plate_cal_dir)\n\nfrom backend.app.core.database import Base  # noqa: E402\n\n# Use in-memory SQLite for tests\nTEST_DATABASE_URL = \"sqlite+aiosqlite:///:memory:\"\n\n\n@pytest.fixture(scope=\"session\")\ndef event_loop():\n    \"\"\"Create an instance of the default event loop for each test session.\"\"\"\n    loop = asyncio.get_event_loop_policy().new_event_loop()\n    yield loop\n    # Dispose the module-level engine so aiosqlite worker threads finish\n    # before the event loop closes, preventing \"Event loop is closed\" errors.\n    from backend.app.core.database import engine\n\n    loop.run_until_complete(engine.dispose())\n    loop.run_until_complete(asyncio.sleep(0.05))\n    loop.close()\n\n\n@pytest.fixture\nasync def test_engine():\n    \"\"\"Create a test database engine.\"\"\"\n    engine = create_async_engine(TEST_DATABASE_URL, echo=False)\n\n    # Import all models to register them\n    from backend.app.models import (\n        ams_history,\n        ams_label,\n        api_key,\n        archive,\n        auth_ephemeral,\n        color_catalog,\n        external_link,\n        filament,\n        group,\n        kprofile_note,\n        maintenance,\n        notification,\n        notification_template,\n        oidc_provider,\n        print_queue,\n        printer,\n        project,\n        project_bom,\n        settings,\n        slot_preset,\n        smart_plug,\n        smart_plug_energy_snapshot,  # noqa: F401\n        spool,\n        spool_assignment,\n        spool_catalog,\n        spool_k_profile,\n        spool_usage_history,\n        spoolbuddy_device,\n        user,\n        user_email_pref,\n        user_otp_code,\n        user_totp,\n        virtual_printer,\n    )\n\n    async with engine.begin() as conn:\n        await conn.run_sync(Base.metadata.create_all)\n\n    yield engine\n\n    async with engine.begin() as conn:\n        await conn.run_sync(Base.metadata.drop_all)\n    await engine.dispose()\n    # Allow aiosqlite's background thread to finish processing the close\n    # response before the per-function event loop shuts down, preventing\n    # \"RuntimeError: Event loop is closed\" in call_soon_threadsafe.\n    await asyncio.sleep(0.1)\n\n\n@pytest.fixture\nasync def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:\n    \"\"\"Create a test database session.\"\"\"\n    async_session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)\n    async with async_session_maker() as session:\n        yield session\n\n\n@pytest.fixture\nasync def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, None]:\n    \"\"\"Create an async test client.\"\"\"\n    from backend.app.core.database import async_session, get_db\n    from backend.app.main import app\n\n    # Create a new session maker for the test engine\n    test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)\n\n    async def override_get_db():\n        async with test_async_session() as session:\n            yield session\n\n    app.dependency_overrides[get_db] = override_get_db\n\n    # Mock init_printer_connections to prevent MQTT connection attempts during tests\n    async def mock_init_printer_connections(db):\n        pass  # No-op - don't connect to real printers\n\n    # Also patch the module-level async_session used by services, auth, and middleware\n    with (\n        patch(\"backend.app.core.database.async_session\", test_async_session),\n        patch(\"backend.app.core.auth.async_session\", test_async_session),\n        patch(\"backend.app.main.async_session\", test_async_session),\n        patch(\"backend.app.main.init_printer_connections\", mock_init_printer_connections),\n    ):\n        # Seed default groups for tests that need them\n        from backend.app.core.database import seed_default_groups\n\n        await seed_default_groups()\n\n        async with AsyncClient(transport=ASGITransport(app=app), base_url=\"http://test\") as client:\n            yield client\n\n        # The app lifespan called init_db() which used the module-level engine\n        # (not the test engine), creating aiosqlite connections. Dispose those\n        # connections so their background threads finish before the event loop closes.\n        from backend.app.core.database import engine as real_engine\n\n        await real_engine.dispose()\n\n    app.dependency_overrides.clear()\n\n\n# ============================================================================\n# Mock External Services\n# ============================================================================\n\n\n@pytest.fixture\ndef mock_tasmota_service():\n    \"\"\"Mock the Tasmota service for smart plug tests.\"\"\"\n    # Patch both the module where it's defined and where it's imported\n    with (\n        patch(\"backend.app.services.tasmota.tasmota_service\") as mock,\n        patch(\"backend.app.api.routes.smart_plugs.tasmota_service\") as mock2,\n    ):\n        mock.turn_on = AsyncMock(return_value=True)\n        mock.turn_off = AsyncMock(return_value=True)\n        mock.toggle = AsyncMock(return_value=True)\n        mock.get_status = AsyncMock(return_value={\"state\": \"ON\", \"reachable\": True, \"device_name\": \"Test Plug\"})\n        mock.get_energy = AsyncMock(\n            return_value={\n                \"power\": 150.5,\n                \"voltage\": 120.0,\n                \"current\": 1.25,\n                \"today\": 2.5,\n                \"total\": 100.0,\n                \"factor\": 0.95,\n            }\n        )\n        mock.test_connection = AsyncMock(return_value={\"success\": True, \"state\": \"ON\", \"device_name\": \"Test Plug\"})\n        # Copy mocks to second patch target\n        mock2.turn_on = mock.turn_on\n        mock2.turn_off = mock.turn_off\n        mock2.toggle = mock.toggle\n        mock2.get_status = mock.get_status\n        mock2.get_energy = mock.get_energy\n        mock2.test_connection = mock.test_connection\n        yield mock\n\n\n@pytest.fixture\ndef mock_homeassistant_service():\n    \"\"\"Mock the Home Assistant service for smart plug tests.\"\"\"\n    # Patch both the module where it's defined and where it's imported\n    with (\n        patch(\"backend.app.services.homeassistant.homeassistant_service\") as mock,\n        patch(\"backend.app.api.routes.smart_plugs.homeassistant_service\") as mock2,\n    ):\n        mock.turn_on = AsyncMock(return_value=True)\n        mock.turn_off = AsyncMock(return_value=True)\n        mock.toggle = AsyncMock(return_value=True)\n        mock.get_status = AsyncMock(return_value={\"state\": \"ON\", \"reachable\": True, \"device_name\": \"Test HA Entity\"})\n        mock.get_energy = AsyncMock(return_value=None)  # Most HA entities don't have power monitoring\n        mock.test_connection = AsyncMock(return_value={\"success\": True, \"message\": \"API running\", \"error\": None})\n        mock.list_entities = AsyncMock(\n            return_value=[\n                {\n                    \"entity_id\": \"switch.printer_plug\",\n                    \"friendly_name\": \"Printer Plug\",\n                    \"state\": \"on\",\n                    \"domain\": \"switch\",\n                },\n                {\"entity_id\": \"switch.test\", \"friendly_name\": \"Test Switch\", \"state\": \"off\", \"domain\": \"switch\"},\n            ]\n        )\n        mock.configure = MagicMock()\n        # Copy mocks to second patch target\n        mock2.turn_on = mock.turn_on\n        mock2.turn_off = mock.turn_off\n        mock2.toggle = mock.toggle\n        mock2.get_status = mock.get_status\n        mock2.get_energy = mock.get_energy\n        mock2.test_connection = mock.test_connection\n        mock2.list_entities = mock.list_entities\n        mock2.configure = mock.configure\n        yield mock\n\n\n@pytest.fixture\ndef mock_mqtt_client():\n    \"\"\"Mock the MQTT client for printer communication tests.\"\"\"\n    with patch(\"backend.app.services.bambu_mqtt.BambuMQTTClient\") as mock:\n        instance = MagicMock()\n        instance.state = MagicMock(connected=True, state=\"IDLE\", progress=0, temperatures={\"nozzle\": 25, \"bed\": 25})\n        instance.connect = MagicMock()\n        instance.disconnect = MagicMock()\n        mock.return_value = instance\n        yield mock\n\n\n@pytest.fixture\ndef mock_mqtt_smart_plug_service():\n    \"\"\"Mock the MQTT smart plug service for MQTT plug tests.\"\"\"\n    with patch(\"backend.app.api.routes.smart_plugs.mqtt_relay\") as mock:\n        # Create a mock smart_plug_service\n        mock_service = MagicMock()\n        mock_service.is_configured = MagicMock(return_value=True)\n        mock_service.has_broker_settings = MagicMock(return_value=True)\n        mock_service.configure = AsyncMock(return_value=True)\n        mock_service.subscribe = MagicMock()\n        mock_service.unsubscribe = MagicMock()\n        mock_service.get_plug_data = MagicMock(return_value=None)\n        mock_service.is_reachable = MagicMock(return_value=False)\n\n        mock.smart_plug_service = mock_service\n        yield mock\n\n\n@pytest.fixture\ndef mock_ftp_client():\n    \"\"\"Mock the FTP client for file transfer tests.\"\"\"\n    with (\n        patch(\"backend.app.services.bambu_ftp.download_file_async\") as download_mock,\n        patch(\"backend.app.services.bambu_ftp.list_files_async\") as list_mock,\n    ):\n        download_mock.return_value = True\n        list_mock.return_value = []\n        yield {\"download\": download_mock, \"list\": list_mock}\n\n\n@pytest.fixture\ndef mock_httpx_client():\n    \"\"\"Mock httpx for webhook/notification HTTP calls.\"\"\"\n    with patch(\"httpx.AsyncClient\") as mock_class:\n        mock_instance = AsyncMock()\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.text = \"OK\"\n        mock_response.json.return_value = {}\n\n        mock_instance.get = AsyncMock(return_value=mock_response)\n        mock_instance.post = AsyncMock(return_value=mock_response)\n        mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)\n        mock_instance.__aexit__ = AsyncMock()\n\n        mock_class.return_value = mock_instance\n        yield mock_instance\n\n\n@pytest.fixture\ndef mock_printer_manager():\n    \"\"\"Mock the printer manager for status checks.\"\"\"\n    with patch(\"backend.app.services.printer_manager.printer_manager\") as mock:\n        mock.get_status = MagicMock(\n            return_value=MagicMock(\n                connected=True,\n                state=\"IDLE\",\n                progress=0,\n                temperatures={\"nozzle\": 25, \"bed\": 25, \"chamber\": 25},\n                raw_data={},\n            )\n        )\n        mock.mark_printer_offline = MagicMock()\n        yield mock\n\n\n# ============================================================================\n# Factory Fixtures for Test Data\n# ============================================================================\n\n\n@pytest.fixture\ndef smart_plug_factory(db_session):\n    \"\"\"Factory to create test smart plugs.\"\"\"\n\n    async def _create_plug(**kwargs):\n        from backend.app.models.smart_plug import SmartPlug\n\n        # Determine defaults based on plug_type\n        plug_type = kwargs.get(\"plug_type\", \"tasmota\")\n\n        defaults = {\n            \"name\": \"Test Plug\",\n            \"plug_type\": plug_type,\n            \"enabled\": True,\n            \"auto_on\": True,\n            \"auto_off\": True,\n            \"off_delay_mode\": \"time\",\n            \"off_delay_minutes\": 5,\n            \"off_temp_threshold\": 70,\n            \"schedule_enabled\": False,\n            \"power_alert_enabled\": False,\n        }\n\n        # Set required fields based on plug_type\n        if plug_type == \"homeassistant\":\n            defaults[\"ha_entity_id\"] = \"switch.test\"\n            defaults[\"ip_address\"] = None\n        elif plug_type == \"mqtt\":\n            # Legacy fields (for backward compatibility tests)\n            defaults[\"mqtt_topic\"] = kwargs.get(\"mqtt_topic\", \"test/topic\")\n            defaults[\"mqtt_multiplier\"] = kwargs.get(\"mqtt_multiplier\", 1.0)\n            # New separate topic/path/multiplier fields\n            defaults[\"mqtt_power_topic\"] = kwargs.get(\"mqtt_power_topic\")\n            defaults[\"mqtt_power_path\"] = kwargs.get(\"mqtt_power_path\", \"power\")\n            defaults[\"mqtt_power_multiplier\"] = kwargs.get(\"mqtt_power_multiplier\", 1.0)\n            defaults[\"mqtt_energy_topic\"] = kwargs.get(\"mqtt_energy_topic\")\n            defaults[\"mqtt_energy_path\"] = kwargs.get(\"mqtt_energy_path\")\n            defaults[\"mqtt_energy_multiplier\"] = kwargs.get(\"mqtt_energy_multiplier\", 1.0)\n            defaults[\"mqtt_state_topic\"] = kwargs.get(\"mqtt_state_topic\")\n            defaults[\"mqtt_state_path\"] = kwargs.get(\"mqtt_state_path\")\n            defaults[\"mqtt_state_on_value\"] = kwargs.get(\"mqtt_state_on_value\")\n            defaults[\"ip_address\"] = None\n            defaults[\"ha_entity_id\"] = None\n        elif plug_type == \"rest\":\n            defaults[\"rest_on_url\"] = kwargs.get(\"rest_on_url\", \"http://192.168.1.100/api/plug/on\")\n            defaults[\"rest_off_url\"] = kwargs.get(\"rest_off_url\", \"http://192.168.1.100/api/plug/off\")\n            defaults[\"rest_method\"] = kwargs.get(\"rest_method\", \"POST\")\n            defaults[\"ip_address\"] = None\n            defaults[\"ha_entity_id\"] = None\n        else:\n            defaults[\"ip_address\"] = \"192.168.1.100\"\n            defaults[\"ha_entity_id\"] = None\n\n        defaults.update(kwargs)\n\n        plug = SmartPlug(**defaults)\n        db_session.add(plug)\n        await db_session.commit()\n        await db_session.refresh(plug)\n        return plug\n\n    return _create_plug\n\n\n@pytest.fixture\ndef printer_factory(db_session):\n    \"\"\"Factory to create test printers.\"\"\"\n    _counter = [0]  # Use list to allow mutation in nested function\n\n    async def _create_printer(**kwargs):\n        from backend.app.models.printer import Printer\n\n        _counter[0] += 1\n        counter = _counter[0]\n\n        defaults = {\n            \"name\": \"Test Printer\",\n            \"serial_number\": f\"00M09A{counter:09d}\",  # Unique serial per printer\n            \"ip_address\": f\"192.168.1.{100 + counter}\",  # Unique IP per printer\n            \"access_code\": \"12345678\",\n            \"is_active\": True,\n            \"auto_archive\": True,\n            \"model\": \"X1C\",\n        }\n        defaults.update(kwargs)\n\n        printer = Printer(**defaults)\n        db_session.add(printer)\n        await db_session.commit()\n        await db_session.refresh(printer)\n        return printer\n\n    return _create_printer\n\n\n@pytest.fixture\ndef notification_provider_factory(db_session):\n    \"\"\"Factory to create test notification providers.\"\"\"\n\n    async def _create_provider(**kwargs):\n        from backend.app.models.notification import NotificationProvider\n\n        config = kwargs.pop(\"config\", {\"server\": \"https://ntfy.sh\", \"topic\": \"test-topic\"})\n        if isinstance(config, dict):\n            config = json.dumps(config)\n\n        defaults = {\n            \"name\": \"Test Provider\",\n            \"provider_type\": \"ntfy\",\n            \"enabled\": True,\n            \"config\": config,\n            \"on_print_start\": True,\n            \"on_print_complete\": True,\n            \"on_print_failed\": True,\n            \"on_print_stopped\": True,\n            \"on_print_progress\": False,\n            \"on_print_missing_spool_assignment\": False,\n            \"on_printer_offline\": False,\n            \"on_printer_error\": False,\n            \"on_filament_low\": False,\n            \"on_maintenance_due\": False,\n            \"on_ams_humidity_high\": False,\n            \"on_ams_temperature_high\": False,\n            \"on_bed_cooled\": False,\n            \"quiet_hours_enabled\": False,\n            \"daily_digest_enabled\": False,\n        }\n        defaults.update(kwargs)\n\n        provider = NotificationProvider(**defaults)\n        db_session.add(provider)\n        await db_session.commit()\n        await db_session.refresh(provider)\n        return provider\n\n    return _create_provider\n\n\n@pytest.fixture\ndef archive_factory(db_session):\n    \"\"\"Factory to create test archives.\"\"\"\n\n    async def _create_archive(printer_id: int, **kwargs):\n        from backend.app.models.archive import PrintArchive\n\n        defaults = {\n            \"printer_id\": printer_id,\n            \"filename\": \"test_print.gcode.3mf\",\n            \"print_name\": \"Test Print\",\n            \"file_path\": \"archives/test/test_print.gcode.3mf\",\n            \"file_size\": 1024000,\n            \"status\": \"completed\",\n            \"filament_type\": \"PLA\",\n            \"filament_used_grams\": 50.0,\n            \"print_time_seconds\": 3600,\n        }\n        defaults.update(kwargs)\n\n        archive = PrintArchive(**defaults)\n        db_session.add(archive)\n        await db_session.commit()\n        await db_session.refresh(archive)\n        return archive\n\n    return _create_archive\n\n\n# ============================================================================\n# Sample Data Fixtures\n# ============================================================================\n\n\n@pytest.fixture\ndef sample_mqtt_print_start():\n    \"\"\"Sample MQTT message for print start.\"\"\"\n    return {\n        \"print\": {\n            \"command\": \"project_file\",\n            \"param\": \"/sdcard/test.gcode.3mf\",\n            \"subtask_name\": \"test_print\",\n            \"gcode_state\": \"RUNNING\",\n            \"mc_percent\": 0,\n        }\n    }\n\n\n@pytest.fixture\ndef sample_mqtt_print_complete():\n    \"\"\"Sample MQTT message for print complete.\"\"\"\n    return {\n        \"print\": {\n            \"gcode_state\": \"FINISH\",\n            \"mc_percent\": 100,\n            \"subtask_name\": \"test_print\",\n        }\n    }\n\n\n@pytest.fixture\ndef sample_printer_status():\n    \"\"\"Sample printer status data.\"\"\"\n    return {\n        \"connected\": True,\n        \"state\": \"IDLE\",\n        \"progress\": 0,\n        \"layer_num\": 0,\n        \"total_layers\": 0,\n        \"temperatures\": {\n            \"nozzle\": 25.0,\n            \"bed\": 25.0,\n            \"chamber\": 25.0,\n        },\n        \"remaining_time\": 0,\n        \"filename\": None,\n    }\n\n\n# ============================================================================\n# Log Capture Fixtures for Error Detection\n# ============================================================================\n\n\nclass LogCapture(logging.Handler):\n    \"\"\"Handler that captures log records for testing.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.records: list[logging.LogRecord] = []\n\n    def emit(self, record: logging.LogRecord):\n        self.records.append(record)\n\n    def clear(self):\n        self.records.clear()\n\n    def get_errors(self) -> list[logging.LogRecord]:\n        \"\"\"Get all ERROR and CRITICAL level records.\"\"\"\n        return [r for r in self.records if r.levelno >= logging.ERROR]\n\n    def get_warnings(self) -> list[logging.LogRecord]:\n        \"\"\"Get all WARNING level records.\"\"\"\n        return [r for r in self.records if r.levelno == logging.WARNING]\n\n    def has_errors(self) -> bool:\n        \"\"\"Check if any errors were logged.\"\"\"\n        return len(self.get_errors()) > 0\n\n    def format_errors(self) -> str:\n        \"\"\"Format all errors as a string for assertion messages.\"\"\"\n        errors = self.get_errors()\n        if not errors:\n            return \"No errors\"\n        formatter = logging.Formatter(\"%(name)s - %(levelname)s - %(message)s\")\n        return \"\\n\".join(formatter.format(r) for r in errors)\n\n\n@pytest.fixture\ndef capture_logs():\n    \"\"\"Fixture that captures log output during a test.\n\n    Usage:\n        def test_something(capture_logs):\n            # Do something that might log errors\n            some_function()\n\n            # Check no errors were logged\n            assert not capture_logs.has_errors(), capture_logs.format_errors()\n    \"\"\"\n    handler = LogCapture()\n    handler.setLevel(logging.DEBUG)\n\n    # Attach to root logger to capture all logs\n    root_logger = logging.getLogger()\n    root_logger.addHandler(handler)\n\n    yield handler\n\n    root_logger.removeHandler(handler)\n\n\n@pytest.fixture\ndef assert_no_log_errors(capture_logs):\n    \"\"\"Fixture that automatically asserts no errors were logged.\n\n    Usage:\n        def test_something(assert_no_log_errors):\n            # If any ERROR logs occur during this test, it will fail\n            some_function()\n    \"\"\"\n    yield capture_logs\n\n    errors = capture_logs.get_errors()\n    if errors:\n        pytest.fail(f\"Unexpected log errors:\\n{capture_logs.format_errors()}\")\n"
  },
  {
    "path": "backend/tests/integration/__init__.py",
    "content": "\"\"\"Integration tests for BamBuddy API endpoints.\"\"\"\n"
  },
  {
    "path": "backend/tests/integration/test_advanced_auth_api.py",
    "content": "\"\"\"Integration tests for Advanced Authentication API endpoints.\n\nTests the full request/response cycle for SMTP configuration, advanced auth toggle,\nemail-based login, forgot password, admin password reset, and user creation\nwith advanced authentication enabled.\n\"\"\"\n\nfrom unittest.mock import patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n# Shared SMTP settings data used across test classes\nSMTP_DATA = {\n    \"smtp_host\": \"smtp.test.com\",\n    \"smtp_port\": 587,\n    \"smtp_username\": \"test@test.com\",\n    \"smtp_password\": \"testpass\",\n    \"smtp_security\": \"starttls\",\n    \"smtp_auth_enabled\": True,\n    \"smtp_from_email\": \"noreply@test.com\",\n}\n\n\nasync def _setup_admin(async_client: AsyncClient, username: str = \"admin\", password: str = \"AdminPass1!\"):\n    \"\"\"Enable auth and create admin user, return admin token.\"\"\"\n    await async_client.post(\n        \"/api/v1/auth/setup\",\n        json={\n            \"auth_enabled\": True,\n            \"admin_username\": username,\n            \"admin_password\": password,\n        },\n    )\n    login = await async_client.post(\n        \"/api/v1/auth/login\",\n        json={\"username\": username, \"password\": password},\n    )\n    return login.json()[\"access_token\"]\n\n\nasync def _setup_smtp_and_advanced_auth(async_client: AsyncClient, token: str):\n    \"\"\"Configure SMTP and enable advanced auth. Must mock send_email externally.\"\"\"\n    headers = {\"Authorization\": f\"Bearer {token}\"}\n    await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n    await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n\nasync def _create_regular_user(\n    async_client: AsyncClient, token: str, username: str = \"regular\", password: str = \"Regularpass1!\"\n):\n    \"\"\"Create a regular (non-admin) user and return their token.\"\"\"\n    headers = {\"Authorization\": f\"Bearer {token}\"}\n    await async_client.post(\n        \"/api/v1/users/\",\n        headers=headers,\n        json={\"username\": username, \"password\": password, \"role\": \"user\"},\n    )\n    login = await async_client.post(\n        \"/api/v1/auth/login\",\n        json={\"username\": username, \"password\": password},\n    )\n    return login.json()[\"access_token\"]\n\n\nclass TestSMTPConfigAPI:\n    \"\"\"Integration tests for SMTP configuration endpoints.\"\"\"\n\n    @pytest.fixture\n    async def admin_token(self, async_client: AsyncClient):\n        return await _setup_admin(async_client, \"smtpadmin\", \"AdminPass1!\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_save_smtp_settings(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"POST /auth/smtp with valid settings returns 200.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/smtp\",\n            headers={\"Authorization\": f\"Bearer {admin_token}\"},\n            json=SMTP_DATA,\n        )\n        assert response.status_code == 200\n        assert \"saved\" in response.json()[\"message\"].lower() or \"success\" in response.json()[\"message\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_smtp_settings_masks_password(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"GET /auth/smtp returns settings with password masked (None).\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        # Save settings first\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n\n        response = await async_client.get(\"/api/v1/auth/smtp\", headers=headers)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"smtp_host\"] == \"smtp.test.com\"\n        assert result[\"smtp_password\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_smtp_settings_requires_admin(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Non-admin user gets 403 on SMTP endpoints.\"\"\"\n        user_token = await _create_regular_user(async_client, admin_token, \"smtpregular\", \"Pass12345!\")\n        headers = {\"Authorization\": f\"Bearer {user_token}\"}\n\n        response = await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        assert response.status_code == 403\n\n        response = await async_client.get(\"/api/v1/auth/smtp\", headers=headers)\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_save_smtp_settings_no_auth(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"No token on SMTP save returns 401.\"\"\"\n        response = await async_client.post(\"/api/v1/auth/smtp\", json=SMTP_DATA)\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_test_smtp_connection(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"POST /auth/smtp/test with mocked send_email returns success.\"\"\"\n        await async_client.post(\n            \"/api/v1/auth/smtp\",\n            headers={\"Authorization\": f\"Bearer {admin_token}\"},\n            json=SMTP_DATA,\n        )\n\n        with patch(\"backend.app.api.routes.auth.send_email\"):\n            response = await async_client.post(\n                \"/api/v1/auth/smtp/test\",\n                headers={\"Authorization\": f\"Bearer {admin_token}\"},\n                json={\n                    \"test_recipient\": \"recipient@test.com\",\n                },\n            )\n        assert response.status_code == 200\n        assert response.json()[\"success\"] is True\n\n\nclass TestAdvancedAuthToggleAPI:\n    \"\"\"Integration tests for enabling/disabling advanced authentication.\"\"\"\n\n    @pytest.fixture\n    async def admin_token(self, async_client: AsyncClient):\n        return await _setup_admin(async_client, \"toggleadmin\", \"AdminPass1!\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_advanced_auth(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Enable advanced auth after SMTP is configured returns 200.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        # Configure SMTP first\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n\n        response = await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n        assert response.status_code == 200\n        assert response.json()[\"advanced_auth_enabled\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_advanced_auth_without_smtp(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Enable advanced auth without SMTP configured returns 400.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        response = await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n        assert response.status_code == 400\n        assert \"SMTP\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_advanced_auth(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Disable advanced auth returns 200.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        # Enable first\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        response = await async_client.post(\"/api/v1/auth/advanced-auth/disable\", headers=headers)\n        assert response.status_code == 200\n        assert response.json()[\"advanced_auth_enabled\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_advanced_auth_status_public(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"GET /auth/advanced-auth/status is accessible without token.\"\"\"\n        response = await async_client.get(\"/api/v1/auth/advanced-auth/status\")\n        assert response.status_code == 200\n        result = response.json()\n        assert \"advanced_auth_enabled\" in result\n        assert \"smtp_configured\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_requires_admin(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Non-admin user gets 403 on enable/disable.\"\"\"\n        user_token = await _create_regular_user(async_client, admin_token, \"toggleregular\", \"Pass12345!\")\n        headers = {\"Authorization\": f\"Bearer {user_token}\"}\n\n        response = await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n        assert response.status_code == 403\n\n        response = await async_client.post(\"/api/v1/auth/advanced-auth/disable\", headers=headers)\n        assert response.status_code == 403\n\n\nclass TestEmailLoginAPI:\n    \"\"\"Integration tests for email-based login.\"\"\"\n\n    @pytest.fixture\n    async def admin_token(self, async_client: AsyncClient):\n        return await _setup_admin(async_client, \"emailadmin\", \"AdminPass1!\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_with_email(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Login with email address when advanced auth is enabled returns token.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            # Configure SMTP + advanced auth\n            await _setup_smtp_and_advanced_auth(async_client, admin_token)\n\n            # Create user with email (password auto-generated, so we set one explicitly via update)\n            create_resp = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"emailuser\", \"email\": \"emailuser@test.com\", \"role\": \"user\"},\n            )\n            assert create_resp.status_code == 201\n            user_id = create_resp.json()[\"id\"]\n\n            # Set a known password via admin update\n            await async_client.patch(\n                f\"/api/v1/users/{user_id}\",\n                headers=headers,\n                json={\"password\": \"Knownpassword1!\"},\n            )\n\n        # Login with email\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"emailuser@test.com\", \"password\": \"Knownpassword1!\"},\n        )\n        assert response.status_code == 200\n        assert \"access_token\" in response.json()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_with_email_case_insensitive(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Login with uppercase email matches case-insensitively.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            await _setup_smtp_and_advanced_auth(async_client, admin_token)\n\n            create_resp = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"caseuser\", \"email\": \"caseuser@test.com\", \"role\": \"user\"},\n            )\n            user_id = create_resp.json()[\"id\"]\n            await async_client.patch(\n                f\"/api/v1/users/{user_id}\",\n                headers=headers,\n                json={\"password\": \"Casepassword1!\"},\n            )\n\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"CASEUSER@TEST.COM\", \"password\": \"Casepassword1!\"},\n        )\n        assert response.status_code == 200\n        assert \"access_token\" in response.json()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_with_email_advanced_auth_disabled(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Email login fails when advanced auth is disabled.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n\n        # Create user with email but no advanced auth\n        await async_client.post(\n            \"/api/v1/users/\",\n            headers=headers,\n            json={\"username\": \"noemail\", \"password\": \"NoEmailPass1!\", \"email\": \"noemail@test.com\", \"role\": \"user\"},\n        )\n\n        # Try to login with email — should fail since advanced auth is off\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"noemail@test.com\", \"password\": \"NoEmailPass1!\"},\n        )\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_with_username_still_works(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Username-based login still works when advanced auth is enabled.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            await _setup_smtp_and_advanced_auth(async_client, admin_token)\n\n            create_resp = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"usernameuser\", \"email\": \"usernameuser@test.com\", \"role\": \"user\"},\n            )\n            user_id = create_resp.json()[\"id\"]\n            await async_client.patch(\n                f\"/api/v1/users/{user_id}\",\n                headers=headers,\n                json={\"password\": \"Usernamepass1!\"},\n            )\n\n        # Login with username (not email)\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"usernameuser\", \"password\": \"Usernamepass1!\"},\n        )\n        assert response.status_code == 200\n        assert \"access_token\" in response.json()\n\n\nclass TestForgotPasswordAPI:\n    \"\"\"Integration tests for forgot-password flow.\"\"\"\n\n    @pytest.fixture\n    async def admin_token(self, async_client: AsyncClient):\n        return await _setup_admin(async_client, \"forgotadmin\", \"AdminPass1!\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_forgot_password_sends_email(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"POST /auth/forgot-password with valid email sends reset email.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            await _setup_smtp_and_advanced_auth(async_client, admin_token)\n\n            # Create a user with email\n            create_resp = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"forgotuser\", \"email\": \"forgot@test.com\", \"role\": \"user\"},\n            )\n            assert create_resp.status_code == 201\n\n        with patch(\"backend.app.api.routes.auth.send_email\") as mock_send:\n            response = await async_client.post(\n                \"/api/v1/auth/forgot-password\",\n                json={\"email\": \"forgot@test.com\"},\n            )\n\n        assert response.status_code == 200\n        mock_send.assert_called_once()\n        # Verify the email was sent to the right address\n        assert mock_send.call_args[0][1] == \"forgot@test.com\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_forgot_password_unknown_email(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Unknown email still returns 200 (anti-enumeration) but send_email not called.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        with patch(\"backend.app.api.routes.auth.send_email\") as mock_send:\n            response = await async_client.post(\n                \"/api/v1/auth/forgot-password\",\n                json={\"email\": \"unknown@test.com\"},\n            )\n\n        assert response.status_code == 200\n        mock_send.assert_not_called()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_forgot_password_requires_advanced_auth(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Forgot password returns 400 when advanced auth is disabled.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/forgot-password\",\n            json={\"email\": \"test@test.com\"},\n        )\n        assert response.status_code == 400\n        assert \"not enabled\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_forgot_password_changes_password(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"After forgot-password + confirm, old password stops working and new one works.\n\n        H-6: The flow is now token-based: /forgot-password issues a reset link and\n        /forgot-password/confirm consumes the token and sets the new password.\n        \"\"\"\n        from unittest.mock import AsyncMock\n\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            await _setup_smtp_and_advanced_auth(async_client, admin_token)\n\n            create_resp = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"resetme\", \"email\": \"resetme@test.com\", \"role\": \"user\"},\n            )\n            user_id = create_resp.json()[\"id\"]\n            await async_client.patch(\n                f\"/api/v1/users/{user_id}\",\n                headers=headers,\n                json={\"password\": \"Originalpass1!\"},\n            )\n\n        # Verify login works with original password\n        login_resp = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"resetme\", \"password\": \"Originalpass1!\"},\n        )\n        assert login_resp.status_code == 200\n\n        # Trigger forgot-password and capture the reset URL (contains the token)\n        captured: dict[str, str] = {}\n\n        async def _capture_link_email(db, username, reset_url):\n            captured[\"reset_url\"] = reset_url\n            return (\"subject\", \"body\", \"<body/>\")\n\n        with (\n            patch(\n                \"backend.app.api.routes.auth.create_password_reset_link_email_from_template\",\n                side_effect=_capture_link_email,\n            ),\n            patch(\"backend.app.api.routes.auth.send_email\"),\n        ):\n            resp = await async_client.post(\n                \"/api/v1/auth/forgot-password\",\n                json={\"email\": \"resetme@test.com\"},\n            )\n        assert resp.status_code == 200\n        assert \"reset_url\" in captured, \"Reset URL not captured — email function was not called\"\n\n        # Extract the token from the captured URL and confirm the reset\n        reset_token = captured[\"reset_url\"].split(\"reset_token=\")[1]\n        confirm_resp = await async_client.post(\n            \"/api/v1/auth/forgot-password/confirm\",\n            json={\"token\": reset_token, \"new_password\": \"Newpass456!\"},\n        )\n        assert confirm_resp.status_code == 200\n\n        # Old password should no longer work\n        login_resp = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"resetme\", \"password\": \"Originalpass1!\"},\n        )\n        assert login_resp.status_code == 401\n\n        # New password must work\n        login_resp = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"resetme\", \"password\": \"Newpass456!\"},\n        )\n        assert login_resp.status_code == 200\n\n\nclass TestAdminResetPasswordAPI:\n    \"\"\"Integration tests for admin password reset endpoint.\"\"\"\n\n    @pytest.fixture\n    async def admin_token(self, async_client: AsyncClient):\n        return await _setup_admin(async_client, \"resetadmin\", \"AdminPass1!\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reset_password_sends_email(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"POST /auth/reset-password sends email to user.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            await _setup_smtp_and_advanced_auth(async_client, admin_token)\n\n            create_resp = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"resetuser\", \"email\": \"resetuser@test.com\", \"role\": \"user\"},\n            )\n            user_id = create_resp.json()[\"id\"]\n\n        with patch(\"backend.app.api.routes.auth.send_email\") as mock_send:\n            response = await async_client.post(\n                \"/api/v1/auth/reset-password\",\n                headers=headers,\n                json={\"user_id\": user_id},\n            )\n\n        assert response.status_code == 200\n        mock_send.assert_called_once()\n        assert mock_send.call_args[0][1] == \"resetuser@test.com\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reset_password_requires_admin(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Non-admin user gets 403 on reset-password.\"\"\"\n        # Create regular user before enabling advanced auth (no email required)\n        user_token = await _create_regular_user(async_client, admin_token, \"resetregular\", \"Pass12345!\")\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            await _setup_smtp_and_advanced_auth(async_client, admin_token)\n\n        response = await async_client.post(\n            \"/api/v1/auth/reset-password\",\n            headers={\"Authorization\": f\"Bearer {user_token}\"},\n            json={\"user_id\": 1},\n        )\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reset_password_requires_advanced_auth(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Reset password returns 400 when advanced auth is disabled.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n\n        response = await async_client.post(\n            \"/api/v1/auth/reset-password\",\n            headers=headers,\n            json={\"user_id\": 999},\n        )\n        assert response.status_code == 400\n        assert \"not enabled\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reset_password_user_not_found(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Reset password with invalid user_id returns 404.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        response = await async_client.post(\n            \"/api/v1/auth/reset-password\",\n            headers=headers,\n            json={\"user_id\": 99999},\n        )\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reset_password_user_no_email(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Reset password for user without email returns 400.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        # Save SMTP and enable advanced auth\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        # Disable advanced auth temporarily to create a user without email\n        await async_client.post(\"/api/v1/auth/advanced-auth/disable\", headers=headers)\n        create_resp = await async_client.post(\n            \"/api/v1/users/\",\n            headers=headers,\n            json={\"username\": \"noemailuser\", \"password\": \"Noemail12345!\", \"role\": \"user\"},\n        )\n        user_id = create_resp.json()[\"id\"]\n\n        # Re-enable advanced auth\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        response = await async_client.post(\n            \"/api/v1/auth/reset-password\",\n            headers=headers,\n            json={\"user_id\": user_id},\n        )\n        assert response.status_code == 400\n        assert \"email\" in response.json()[\"detail\"].lower()\n\n\nclass TestUserCreationAdvancedAuth:\n    \"\"\"Integration tests for user creation with advanced auth enabled.\"\"\"\n\n    @pytest.fixture\n    async def admin_token(self, async_client: AsyncClient):\n        return await _setup_admin(async_client, \"createadmin\", \"AdminPass1!\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_user_advanced_auth_requires_email(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Creating user without email when advanced auth is on returns 400.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        response = await async_client.post(\n            \"/api/v1/users/\",\n            headers=headers,\n            json={\"username\": \"noemailcreate\", \"role\": \"user\"},\n        )\n        assert response.status_code == 400\n        assert \"email\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_user_advanced_auth_auto_password(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Creating user with email auto-generates password and sends welcome email.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        with patch(\"backend.app.api.routes.users.send_email\") as mock_send:\n            response = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"autopassuser\", \"email\": \"autopass@test.com\", \"role\": \"user\"},\n            )\n\n        assert response.status_code == 201\n        result = response.json()\n        assert result[\"username\"] == \"autopassuser\"\n        assert result[\"email\"] == \"autopass@test.com\"\n        # Welcome email should have been sent\n        mock_send.assert_called_once()\n        assert mock_send.call_args[0][1] == \"autopass@test.com\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_user_duplicate_email(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Creating two users with the same email returns 400.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            resp1 = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"dupemail1\", \"email\": \"dupe@test.com\", \"role\": \"user\"},\n            )\n            assert resp1.status_code == 201\n\n            resp2 = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"dupemail2\", \"email\": \"dupe@test.com\", \"role\": \"user\"},\n            )\n\n        assert resp2.status_code == 400\n        assert \"email\" in resp2.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_user_response_includes_email(self, async_client: AsyncClient, admin_token: str):\n        \"\"\"Created user response includes email field.\"\"\"\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        with patch(\"backend.app.api.routes.users.send_email\"):\n            response = await async_client.post(\n                \"/api/v1/users/\",\n                headers=headers,\n                json={\"username\": \"emailresp\", \"email\": \"emailresp@test.com\", \"role\": \"user\"},\n            )\n\n        assert response.status_code == 201\n        result = response.json()\n        assert \"email\" in result\n        assert result[\"email\"] == \"emailresp@test.com\"\n\n\n# ===========================================================================\n# M-1: OIDC/LDAP users must not be able to use the password reset flow\n# ===========================================================================\n\n\nclass TestAuthSourcePasswordResetBlocking:\n    \"\"\"Forgot-password must silently skip OIDC and LDAP users (M-1).\"\"\"\n\n    @pytest.fixture\n    async def admin_token(self, async_client: AsyncClient):\n        return await _setup_admin(async_client, \"authsrcadmin\", \"AdminPass1!\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_forgot_password_silently_skips_oidc_user(\n        self, async_client: AsyncClient, admin_token: str, db_session\n    ):\n        \"\"\"forgot-password for an OIDC user returns 200 but does NOT send email.\"\"\"\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.user import User\n\n        headers = {\"Authorization\": f\"Bearer {admin_token}\"}\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=SMTP_DATA)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n\n        # Directly insert an OIDC-sourced user into the DB\n        oidc_user = User(\n            username=\"oidcpwreset\",\n            email=\"oidcpwreset@test.com\",\n            auth_source=\"oidc\",\n            password_hash=get_password_hash(\"irrelevant\"),\n            role=\"user\",\n            is_active=True,\n        )\n        db_session.add(oidc_user)\n        await db_session.commit()\n\n        with patch(\"backend.app.api.routes.auth.send_email\") as mock_send:\n            response = await async_client.post(\n                \"/api/v1/auth/forgot-password\",\n                json={\"email\": \"oidcpwreset@test.com\"},\n            )\n\n        # Anti-enumeration: still returns 200\n        assert response.status_code == 200\n        # But no email is sent for OIDC users\n        mock_send.assert_not_called()\n"
  },
  {
    "path": "backend/tests/integration/test_ams_history_api.py",
    "content": "\"\"\"Integration tests for AMS History API endpoints.\"\"\"\n\nfrom datetime import datetime, timedelta\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestAMSHistoryAPI:\n    \"\"\"Integration tests for /api/v1/ams-history endpoints.\"\"\"\n\n    @pytest.fixture\n    async def ams_history_factory(self, db_session, printer_factory):\n        \"\"\"Factory to create test AMS history records.\"\"\"\n\n        async def _create_history(printer_id=None, ams_id=0, **kwargs):\n            from backend.app.models.ams_history import AMSSensorHistory\n\n            if printer_id is None:\n                printer = await printer_factory()\n                printer_id = printer.id\n\n            defaults = {\n                \"printer_id\": printer_id,\n                \"ams_id\": ams_id,\n                \"humidity\": 45.0,\n                \"humidity_raw\": 4500,\n                \"temperature\": 25.0,\n                \"recorded_at\": datetime.now(),\n            }\n            defaults.update(kwargs)\n\n            history = AMSSensorHistory(**defaults)\n            db_session.add(history)\n            await db_session.commit()\n            await db_session.refresh(history)\n            return history\n\n        return _create_history\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_ams_history_empty(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify empty history returns empty data array.\"\"\"\n        printer = await printer_factory()\n        response = await async_client.get(f\"/api/v1/ams-history/{printer.id}/0\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"printer_id\"] == printer.id\n        assert data[\"ams_id\"] == 0\n        assert data[\"data\"] == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_ams_history_with_data(self, async_client: AsyncClient, ams_history_factory, db_session):\n        \"\"\"Verify history returns recorded data.\"\"\"\n        # Create history records\n        history = await ams_history_factory()\n        printer_id = history.printer_id\n\n        response = await async_client.get(f\"/api/v1/ams-history/{printer_id}/0\")\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data[\"data\"]) >= 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_ams_history_with_stats(\n        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify history includes statistics.\"\"\"\n        printer = await printer_factory()\n        # Create multiple records with different values\n        await ams_history_factory(printer_id=printer.id, humidity=40.0, temperature=24.0)\n        await ams_history_factory(printer_id=printer.id, humidity=50.0, temperature=26.0)\n        await ams_history_factory(printer_id=printer.id, humidity=45.0, temperature=25.0)\n\n        response = await async_client.get(f\"/api/v1/ams-history/{printer.id}/0\")\n        assert response.status_code == 200\n        data = response.json()\n\n        # Check statistics\n        assert data[\"min_humidity\"] == 40.0\n        assert data[\"max_humidity\"] == 50.0\n        assert data[\"min_temperature\"] == 24.0\n        assert data[\"max_temperature\"] == 26.0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_ams_history_with_hours_filter(\n        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify hours parameter filters data.\"\"\"\n        printer = await printer_factory()\n        # Create a recent record\n        await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now())\n        # Create an old record (outside default 24h)\n        await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now() - timedelta(hours=48))\n\n        # Request only last 24 hours (default)\n        response = await async_client.get(f\"/api/v1/ams-history/{printer.id}/0\")\n        assert response.status_code == 200\n        data = response.json()\n        # Should only get the recent record\n        assert len(data[\"data\"]) == 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_ams_history_custom_hours(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify custom hours parameter works.\"\"\"\n        printer = await printer_factory()\n        response = await async_client.get(f\"/api/v1/ams-history/{printer.id}/0\", params={\"hours\": 48})\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"printer_id\"] == printer.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_ams_history_different_ams_units(\n        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify filtering by AMS unit ID.\"\"\"\n        printer = await printer_factory()\n        await ams_history_factory(printer_id=printer.id, ams_id=0, humidity=40.0)\n        await ams_history_factory(printer_id=printer.id, ams_id=1, humidity=50.0)\n\n        # Get AMS unit 0\n        response = await async_client.get(f\"/api/v1/ams-history/{printer.id}/0\")\n        assert response.status_code == 200\n        data0 = response.json()\n        assert len(data0[\"data\"]) == 1\n        assert data0[\"data\"][0][\"humidity\"] == 40.0\n\n        # Get AMS unit 1\n        response = await async_client.get(f\"/api/v1/ams-history/{printer.id}/1\")\n        assert response.status_code == 200\n        data1 = response.json()\n        assert len(data1[\"data\"]) == 1\n        assert data1[\"data\"][0][\"humidity\"] == 50.0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_old_history(\n        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify old history can be deleted.\"\"\"\n        printer = await printer_factory()\n        # Create an old record\n        await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now() - timedelta(days=60))\n\n        # Delete records older than 30 days\n        response = await async_client.delete(f\"/api/v1/ams-history/{printer.id}\", params={\"days\": 30})\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"deleted\"] >= 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_old_history_no_records(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify delete with no old records returns 0.\"\"\"\n        printer = await printer_factory()\n        response = await async_client.delete(f\"/api/v1/ams-history/{printer.id}\", params={\"days\": 30})\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"deleted\"] == 0\n"
  },
  {
    "path": "backend/tests/integration/test_ams_labels_api.py",
    "content": "\"\"\"Integration tests for AMS Labels API endpoints.\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\nfrom backend.app.models.ams_label import AmsLabel\n\n\nclass TestAmsLabelsAPI:\n    \"\"\"Integration tests for /api/v1/printers/{printer_id}/ams-labels endpoints.\"\"\"\n\n    def _mock_printer_state(self, ams_units=None):\n        \"\"\"Create a mock printer state with AMS data.\"\"\"\n        state = MagicMock()\n        state.connected = True\n        state.raw_data = {\n            \"ams\": ams_units\n            or [\n                {\"id\": \"0\", \"sn\": \"AMS_SERIAL_0\"},\n                {\"id\": \"1\", \"sn\": \"AMS_SERIAL_1\"},\n            ],\n        }\n        return state\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_labels_empty(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Returns empty dict when no labels are saved.\"\"\"\n        printer = await printer_factory()\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_status.return_value = self._mock_printer_state()\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/ams-labels\")\n        assert response.status_code == 200\n        assert response.json() == {}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_save_label_with_serial(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Save a label keyed by AMS serial number.\"\"\"\n        printer = await printer_factory()\n        response = await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/0\",\n            json={\"label\": \"Workshop AMS\", \"ams_serial\": \"AMS_SERIAL_0\"},\n        )\n        assert response.status_code == 200\n        assert response.json() == {\"ams_id\": 0, \"label\": \"Workshop AMS\"}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_save_label_without_serial_uses_synthetic_key(\n        self, async_client: AsyncClient, printer_factory, db_session\n    ):\n        \"\"\"When no serial is provided, a synthetic key p{printer_id}a{ams_id} is used.\"\"\"\n        printer = await printer_factory()\n        response = await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/2\",\n            json={\"label\": \"Old Firmware AMS\"},\n        )\n        assert response.status_code == 200\n\n        # Verify the synthetic key was stored\n        from sqlalchemy import select\n\n        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f\"p{printer.id}a2\"))\n        label = result.scalar_one_or_none()\n        assert label is not None\n        assert label.label == \"Old Firmware AMS\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_save_label_whitespace_serial_uses_synthetic_key(\n        self, async_client: AsyncClient, printer_factory, db_session\n    ):\n        \"\"\"Whitespace-only serial falls back to synthetic key.\"\"\"\n        printer = await printer_factory()\n        response = await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/0\",\n            json={\"label\": \"Whitespace Test\", \"ams_serial\": \"   \"},\n        )\n        assert response.status_code == 200\n\n        from sqlalchemy import select\n\n        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f\"p{printer.id}a0\"))\n        label = result.scalar_one_or_none()\n        assert label is not None\n        assert label.label == \"Whitespace Test\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_save_label_updates_existing(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Saving a label with the same serial updates the existing record.\"\"\"\n        printer = await printer_factory()\n        await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/0\",\n            json={\"label\": \"Original Name\", \"ams_serial\": \"SN123\"},\n        )\n        response = await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/0\",\n            json={\"label\": \"Updated Name\", \"ams_serial\": \"SN123\"},\n        )\n        assert response.status_code == 200\n        assert response.json()[\"label\"] == \"Updated Name\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_save_label_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Returns 404 when printer does not exist.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/printers/99999/ams-labels/0\",\n            json={\"label\": \"Ghost Printer\"},\n        )\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_save_label_validation_empty_label(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Rejects empty label.\"\"\"\n        printer = await printer_factory()\n        response = await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/0\",\n            json={\"label\": \"\"},\n        )\n        assert response.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_labels_resolves_serial_to_ams_id(self, async_client: AsyncClient, printer_factory):\n        \"\"\"GET returns labels keyed by ams_id, resolved from live printer state.\"\"\"\n        printer = await printer_factory()\n\n        # Save a label with a known serial\n        await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/0\",\n            json={\"label\": \"Silk Colours\", \"ams_serial\": \"AMS_SERIAL_0\"},\n        )\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_status.return_value = self._mock_printer_state()\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/ams-labels\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data.get(\"0\") == \"Silk Colours\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_labels_no_printer_state(self, async_client: AsyncClient, printer_factory):\n        \"\"\"GET returns empty when printer has no live state.\"\"\"\n        printer = await printer_factory()\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_status.return_value = None\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/ams-labels\")\n        assert response.status_code == 200\n        assert response.json() == {}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_label(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Delete removes the label from the database.\"\"\"\n        printer = await printer_factory()\n        await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/0\",\n            json={\"label\": \"To Delete\", \"ams_serial\": \"DEL_SN\"},\n        )\n\n        response = await async_client.delete(f\"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=DEL_SN\")\n        assert response.status_code == 200\n        assert response.json() == {\"success\": True}\n\n        # Verify it's gone\n        from sqlalchemy import select\n\n        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == \"DEL_SN\"))\n        assert result.scalar_one_or_none() is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_nonexistent_label_succeeds(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Delete returns success even if no label exists (idempotent).\"\"\"\n        printer = await printer_factory()\n        response = await async_client.delete(f\"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=NONEXISTENT\")\n        assert response.status_code == 200\n        assert response.json() == {\"success\": True}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_label_whitespace_serial_uses_synthetic_key(\n        self, async_client: AsyncClient, printer_factory, db_session\n    ):\n        \"\"\"Delete with whitespace serial falls back to synthetic key.\"\"\"\n        printer = await printer_factory()\n        # Save with synthetic key\n        await async_client.put(\n            f\"/api/v1/printers/{printer.id}/ams-labels/0\",\n            json={\"label\": \"Synthetic Label\"},\n        )\n\n        response = await async_client.delete(f\"/api/v1/printers/{printer.id}/ams-labels/0?ams_serial=%20%20\")\n        assert response.status_code == 200\n\n        from sqlalchemy import select\n\n        result = await db_session.execute(select(AmsLabel).where(AmsLabel.ams_serial_number == f\"p{printer.id}a0\"))\n        assert result.scalar_one_or_none() is None\n"
  },
  {
    "path": "backend/tests/integration/test_archives_api.py",
    "content": "\"\"\"Integration tests for Archives API endpoints.\n\nTests the full request/response cycle for /api/v1/archives/ endpoints.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestArchivesAPI:\n    \"\"\"Integration tests for /api/v1/archives/ endpoints.\"\"\"\n\n    # ========================================================================\n    # List endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_archives_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list is returned when no archives exist.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n        assert len(data) == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_archives_with_data(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify list returns existing archives.\"\"\"\n        printer = await printer_factory()\n        await archive_factory(printer.id, print_name=\"Test Archive\")\n\n        response = await async_client.get(\"/api/v1/archives/\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n        assert len(data) >= 1\n        assert any(a[\"print_name\"] == \"Test Archive\" for a in data)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_archives_pagination(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify pagination works correctly.\"\"\"\n        printer = await printer_factory()\n        # Create 5 archives\n        for i in range(5):\n            await archive_factory(printer.id, print_name=f\"Archive {i}\")\n\n        # Get first page with limit 2\n        response = await async_client.get(\"/api/v1/archives/?limit=2&offset=0\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n        assert len(data) == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_archives_filter_by_printer(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify filtering by printer_id works.\"\"\"\n        printer1 = await printer_factory(name=\"Printer 1\", serial_number=\"00M09A000000001\")\n        printer2 = await printer_factory(name=\"Printer 2\", serial_number=\"00M09A000000002\")\n        await archive_factory(printer1.id, print_name=\"Printer 1 Archive\")\n        await archive_factory(printer2.id, print_name=\"Printer 2 Archive\")\n\n        response = await async_client.get(f\"/api/v1/archives/?printer_id={printer1.id}\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert all(a[\"printer_id\"] == printer1.id for a in data)\n\n    # ========================================================================\n    # Get single endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify single archive can be retrieved.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id, print_name=\"Get Test Archive\")\n\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == archive.id\n        assert result[\"print_name\"] == \"Get Test Archive\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_archive_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent archive.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/9999\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Update endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_archive_name(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify archive name can be updated.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id, print_name=\"Original Name\")\n\n        response = await async_client.patch(f\"/api/v1/archives/{archive.id}\", json={\"print_name\": \"Updated Name\"})\n\n        assert response.status_code == 200\n        assert response.json()[\"print_name\"] == \"Updated Name\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_archive_notes(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify archive notes can be updated.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.patch(f\"/api/v1/archives/{archive.id}\", json={\"notes\": \"Great print!\"})\n\n        assert response.status_code == 200\n        assert response.json()[\"notes\"] == \"Great print!\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_archive_favorite(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify archive favorite status can be updated.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.patch(f\"/api/v1/archives/{archive.id}\", json={\"is_favorite\": True})\n\n        assert response.status_code == 200\n        assert response.json()[\"is_favorite\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_archive_external_url(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify archive external_url can be updated.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.patch(\n            f\"/api/v1/archives/{archive.id}\", json={\"external_url\": \"https://printables.com/model/12345\"}\n        )\n\n        assert response.status_code == 200\n        assert response.json()[\"external_url\"] == \"https://printables.com/model/12345\"\n\n        # Verify it can be cleared\n        response = await async_client.patch(f\"/api/v1/archives/{archive.id}\", json={\"external_url\": None})\n\n        assert response.status_code == 200\n        assert response.json()[\"external_url\"] is None\n\n    # ========================================================================\n    # Delete endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify archive can be deleted.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n        archive_id = archive.id\n\n        response = await async_client.delete(f\"/api/v1/archives/{archive_id}\")\n\n        assert response.status_code == 200\n\n        # Verify deleted\n        response = await async_client.get(f\"/api/v1/archives/{archive_id}\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_nonexistent_archive(self, async_client: AsyncClient):\n        \"\"\"Verify deleting non-existent archive returns 404.\"\"\"\n        response = await async_client.delete(\"/api/v1/archives/9999\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Statistics endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify archive statistics can be retrieved.\"\"\"\n        printer = await printer_factory()\n        await archive_factory(\n            printer.id,\n            status=\"completed\",\n            print_time_seconds=3600,\n            filament_used_grams=50.0,\n        )\n        await archive_factory(\n            printer.id,\n            status=\"completed\",\n            print_time_seconds=7200,\n            filament_used_grams=100.0,\n        )\n\n        response = await async_client.get(\"/api/v1/archives/stats\")\n\n        assert response.status_code == 200\n        result = response.json()\n        # Check for actual stats fields\n        assert \"total_prints\" in result\n        assert \"successful_prints\" in result\n\n\nclass TestArchivesSlimAPI:\n    \"\"\"Integration tests for /api/v1/archives/slim endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_slim_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list when no archives exist.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/slim\")\n\n        assert response.status_code == 200\n        assert response.json() == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_slim_returns_only_expected_fields(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify response contains only slim fields, not full archive data.\"\"\"\n        printer = await printer_factory()\n        await archive_factory(\n            printer.id,\n            print_name=\"Slim Test\",\n            status=\"completed\",\n            filament_type=\"PLA\",\n            filament_color=\"#FF0000\",\n            filament_used_grams=50.0,\n            print_time_seconds=3600,\n            cost=1.50,\n            quantity=2,\n        )\n\n        response = await async_client.get(\"/api/v1/archives/slim\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 1\n        item = data[0]\n\n        # Expected fields present\n        assert item[\"printer_id\"] == printer.id\n        assert item[\"print_name\"] == \"Slim Test\"\n        assert item[\"status\"] == \"completed\"\n        assert item[\"filament_type\"] == \"PLA\"\n        assert item[\"filament_color\"] == \"#FF0000\"\n        assert item[\"filament_used_grams\"] == 50.0\n        assert item[\"print_time_seconds\"] == 3600\n        assert item[\"cost\"] == 1.50\n        assert item[\"quantity\"] == 2\n        assert \"created_at\" in item\n\n        # Full archive fields must NOT be present\n        assert \"id\" not in item\n        assert \"filename\" not in item\n        assert \"file_path\" not in item\n        assert \"file_size\" not in item\n        assert \"extra_data\" not in item\n        assert \"notes\" not in item\n        assert \"tags\" not in item\n        assert \"photos\" not in item\n        assert \"thumbnail_path\" not in item\n        assert \"content_hash\" not in item\n        assert \"duplicates\" not in item\n        assert \"duplicate_count\" not in item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_slim_computes_actual_time(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify actual_time_seconds is computed from started_at/completed_at.\"\"\"\n        from datetime import datetime, timezone\n\n        printer = await printer_factory()\n        started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)\n        completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)  # 2 hours = 7200s\n        await archive_factory(\n            printer.id,\n            status=\"completed\",\n            started_at=started,\n            completed_at=completed,\n        )\n\n        response = await async_client.get(\"/api/v1/archives/slim\")\n\n        assert response.status_code == 200\n        item = response.json()[0]\n        assert item[\"actual_time_seconds\"] == 7200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_slim_actual_time_null_for_failed(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify actual_time_seconds is null for non-completed prints.\"\"\"\n        from datetime import datetime, timezone\n\n        printer = await printer_factory()\n        await archive_factory(\n            printer.id,\n            status=\"failed\",\n            started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),\n            completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),\n        )\n\n        response = await async_client.get(\"/api/v1/archives/slim\")\n\n        assert response.status_code == 200\n        item = response.json()[0]\n        assert item[\"actual_time_seconds\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify date_from and date_to filters work.\"\"\"\n        from datetime import datetime, timezone\n\n        printer = await printer_factory()\n        await archive_factory(\n            printer.id,\n            print_name=\"Old Print\",\n            created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),\n        )\n        await archive_factory(\n            printer.id,\n            print_name=\"New Print\",\n            created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),\n        )\n\n        # Filter to only June 2024\n        response = await async_client.get(\"/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 1\n        assert data[0][\"print_name\"] == \"New Print\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify limit and offset work.\"\"\"\n        printer = await printer_factory()\n        for i in range(5):\n            await archive_factory(printer.id, print_name=f\"Print {i}\")\n\n        response = await async_client.get(\"/api/v1/archives/slim?limit=2&offset=0\")\n\n        assert response.status_code == 200\n        assert len(response.json()) == 2\n\n\nclass TestArchiveDataIntegrity:\n    \"\"\"Tests for archive data integrity.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_linked_to_printer(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify archive is properly linked to printer.\"\"\"\n        printer = await printer_factory(name=\"My Printer\")\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"printer_id\"] == printer.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_stores_print_data(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify archive stores all print data correctly.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Test Print\",\n            filename=\"test.3mf\",\n            status=\"completed\",\n            filament_type=\"PLA\",\n            filament_used_grams=75.5,\n            print_time_seconds=5400,\n        )\n\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"print_name\"] == \"Test Print\"\n        assert result[\"filename\"] == \"test.3mf\"\n        assert result[\"status\"] == \"completed\"\n        assert result[\"filament_type\"] == \"PLA\"\n        assert result[\"filament_used_grams\"] == 75.5\n        assert result[\"print_time_seconds\"] == 5400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_update_persists(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"CRITICAL: Verify archive updates persist.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id, notes=\"Original notes\")\n\n        # Update\n        await async_client.patch(f\"/api/v1/archives/{archive.id}\", json={\"notes\": \"Updated notes\", \"is_favorite\": True})\n\n        # Verify persistence\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}\")\n        result = response.json()\n        assert result[\"notes\"] == \"Updated notes\"\n        assert result[\"is_favorite\"] is True\n\n\nclass TestArchiveF3DEndpoints:\n    \"\"\"Tests for F3D (Fusion 360 design file) attachment endpoints.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_response_includes_f3d_path(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify f3d_path is included in archive response.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id, f3d_path=\"archives/test/design.f3d\")\n\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"f3d_path\" in result\n        assert result[\"f3d_path\"] == \"archives/test/design.f3d\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_response_f3d_path_null_when_not_set(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify f3d_path is null when no F3D file attached.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"f3d_path\" in result\n        assert result[\"f3d_path\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_upload_f3d_to_nonexistent_archive(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when uploading F3D to non-existent archive.\"\"\"\n        # Create a minimal file-like upload\n        files = {\"file\": (\"design.f3d\", b\"fake f3d content\", \"application/octet-stream\")}\n        response = await async_client.post(\"/api/v1/archives/9999/f3d\", files=files)\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_download_f3d_not_found_when_no_file(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify 404 when downloading F3D from archive without F3D file.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}/f3d\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_download_f3d_nonexistent_archive(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when downloading F3D from non-existent archive.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/9999/f3d\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_f3d_nonexistent_archive(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when deleting F3D from non-existent archive.\"\"\"\n        response = await async_client.delete(\"/api/v1/archives/9999/f3d\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_f3d_when_no_file(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify 404 when deleting F3D from archive without F3D file.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.delete(f\"/api/v1/archives/{archive.id}/f3d\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_archives_includes_f3d_path(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify f3d_path is included in archive list responses.\"\"\"\n        printer = await printer_factory()\n        await archive_factory(printer.id, print_name=\"With F3D\", f3d_path=\"archives/test/design.f3d\")\n        await archive_factory(printer.id, print_name=\"Without F3D\")\n\n        response = await async_client.get(\"/api/v1/archives/\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) >= 2\n\n        with_f3d = next((a for a in data if a[\"print_name\"] == \"With F3D\"), None)\n        without_f3d = next((a for a in data if a[\"print_name\"] == \"Without F3D\"), None)\n\n        assert with_f3d is not None\n        assert with_f3d[\"f3d_path\"] == \"archives/test/design.f3d\"\n        assert without_f3d is not None\n        assert without_f3d[\"f3d_path\"] is None\n\n    # ========================================================================\n    # Multi-Plate 3MF endpoints (Issue #93)\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_archive_plates_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when fetching plates for non-existent archive.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/999999/plates\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when fetching plate thumbnail for non-existent archive.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/999999/plate-thumbnail/1\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_filament_requirements_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify filament-requirements returns 404 for non-existent archive.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/999999/filament-requirements\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify filament-requirements with plate_id returns 404 for non-existent archive.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/999999/filament-requirements?plate_id=1\")\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Tag Management endpoints (Issue #183)\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_tags_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list when no tags exist.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/tags\")\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n        assert len(data) == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify tags are returned with counts.\"\"\"\n        printer = await printer_factory()\n        await archive_factory(printer.id, print_name=\"Archive 1\", tags=\"functional, test\")\n        await archive_factory(printer.id, print_name=\"Archive 2\", tags=\"functional, calibration\")\n        await archive_factory(printer.id, print_name=\"Archive 3\", tags=\"test\")\n\n        response = await async_client.get(\"/api/v1/archives/tags\")\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n\n        # Convert to dict for easier lookup\n        tags_dict = {t[\"name\"]: t[\"count\"] for t in data}\n        assert tags_dict.get(\"functional\") == 2\n        assert tags_dict.get(\"test\") == 2\n        assert tags_dict.get(\"calibration\") == 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_tags_sorted_by_count(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify tags are sorted by count descending, then by name.\"\"\"\n        printer = await printer_factory()\n        await archive_factory(printer.id, tags=\"alpha\")\n        await archive_factory(printer.id, tags=\"beta, alpha\")\n        await archive_factory(printer.id, tags=\"gamma, beta, alpha\")\n\n        response = await async_client.get(\"/api/v1/archives/tags\")\n        assert response.status_code == 200\n        data = response.json()\n\n        # alpha=3, beta=2, gamma=1\n        assert data[0][\"name\"] == \"alpha\"\n        assert data[0][\"count\"] == 3\n        assert data[1][\"name\"] == \"beta\"\n        assert data[1][\"count\"] == 2\n        assert data[2][\"name\"] == \"gamma\"\n        assert data[2][\"count\"] == 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify renaming a tag updates all archives.\"\"\"\n        printer = await printer_factory()\n        a1 = await archive_factory(printer.id, print_name=\"Archive 1\", tags=\"old-tag, other\")\n        a2 = await archive_factory(printer.id, print_name=\"Archive 2\", tags=\"old-tag\")\n        await archive_factory(printer.id, print_name=\"Archive 3\", tags=\"different\")\n\n        response = await async_client.put(\"/api/v1/archives/tags/old-tag\", json={\"new_name\": \"new-tag\"})\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"affected\"] == 2\n\n        # Verify the archives were updated\n        response = await async_client.get(f\"/api/v1/archives/{a1.id}\")\n        assert \"new-tag\" in response.json()[\"tags\"]\n        assert \"old-tag\" not in response.json()[\"tags\"]\n\n        response = await async_client.get(f\"/api/v1/archives/{a2.id}\")\n        assert response.json()[\"tags\"] == \"new-tag\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_rename_tag_no_change(self, async_client: AsyncClient):\n        \"\"\"Verify renaming to same name returns 0 affected.\"\"\"\n        response = await async_client.put(\"/api/v1/archives/tags/some-tag\", json={\"new_name\": \"some-tag\"})\n        assert response.status_code == 200\n        assert response.json()[\"affected\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):\n        \"\"\"Verify renaming to empty name returns error.\"\"\"\n        response = await async_client.put(\"/api/v1/archives/tags/some-tag\", json={\"new_name\": \"\"})\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):\n        \"\"\"Verify deleting a tag removes it from all archives.\"\"\"\n        printer = await printer_factory()\n        a1 = await archive_factory(printer.id, print_name=\"Archive 1\", tags=\"delete-me, keep\")\n        a2 = await archive_factory(printer.id, print_name=\"Archive 2\", tags=\"delete-me\")\n        await archive_factory(printer.id, print_name=\"Archive 3\", tags=\"different\")\n\n        response = await async_client.delete(\"/api/v1/archives/tags/delete-me\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"affected\"] == 2\n\n        # Verify the archives were updated\n        response = await async_client.get(f\"/api/v1/archives/{a1.id}\")\n        assert response.json()[\"tags\"] == \"keep\"\n\n        response = await async_client.get(f\"/api/v1/archives/{a2.id}\")\n        # Should be None or empty when last tag is removed\n        assert response.json()[\"tags\"] is None or response.json()[\"tags\"] == \"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_tag_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify deleting non-existent tag returns 0 affected.\"\"\"\n        response = await async_client.delete(\"/api/v1/archives/tags/nonexistent-tag\")\n        assert response.status_code == 200\n        assert response.json()[\"affected\"] == 0\n"
  },
  {
    "path": "backend/tests/integration/test_auth_api.py",
    "content": "\"\"\"Integration tests for Authentication API endpoints.\n\nTests the full request/response cycle for /api/v1/auth/ and /api/v1/users/ endpoints.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestAuthStatusAPI:\n    \"\"\"Integration tests for /api/v1/auth/status endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_auth_status_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify auth status returns disabled when not configured.\"\"\"\n        response = await async_client.get(\"/api/v1/auth/status\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"auth_enabled\" in result\n        assert result[\"auth_enabled\"] is False\n        assert result[\"requires_setup\"] is True\n\n\nclass TestAuthSetupAPI:\n    \"\"\"Integration tests for /api/v1/auth/setup endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_setup_auth_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify auth can be set up with auth disabled (no password required).\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\"auth_enabled\": False},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"auth_enabled\"] is False\n        assert result[\"admin_created\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_setup_auth_enabled_requires_credentials(self, async_client: AsyncClient):\n        \"\"\"Verify enabling auth requires admin username and password.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\"auth_enabled\": True},\n        )\n\n        assert response.status_code == 400\n        assert \"Admin username and password are required\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_setup_auth_enabled_with_credentials(self, async_client: AsyncClient):\n        \"\"\"Verify auth can be enabled with admin credentials.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"testadmin\",\n                \"admin_password\": \"TestPass1!\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"auth_enabled\"] is True\n        assert result[\"admin_created\"] is True\n\n\nclass TestAuthLoginAPI:\n    \"\"\"Integration tests for /api/v1/auth/login endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_auth_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify login fails when auth is not enabled.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"admin\", \"password\": \"password\"},\n        )\n\n        assert response.status_code == 400\n        assert \"Authentication is not enabled\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_success(self, async_client: AsyncClient):\n        \"\"\"Verify login succeeds with valid credentials after setup.\"\"\"\n        # First enable auth\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"logintest\",\n                \"admin_password\": \"LoginPass1!\",\n            },\n        )\n\n        # Now login\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"logintest\", \"password\": \"LoginPass1!\"},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"access_token\" in result\n        assert result[\"token_type\"] == \"bearer\"\n        assert result[\"user\"][\"username\"] == \"logintest\"\n        assert result[\"user\"][\"role\"] == \"admin\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_invalid_credentials(self, async_client: AsyncClient):\n        \"\"\"Verify login fails with invalid credentials.\"\"\"\n        # First enable auth\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"invalidtest\",\n                \"admin_password\": \"CorrectPass1!\",\n            },\n        )\n\n        # Try login with wrong password\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"invalidtest\", \"password\": \"wrongpassword\"},\n        )\n\n        assert response.status_code == 401\n        assert \"Incorrect username or password\" in response.json()[\"detail\"]\n\n\nclass TestAuthMeAPI:\n    \"\"\"Integration tests for /api/v1/auth/me endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_me_without_token(self, async_client: AsyncClient):\n        \"\"\"Verify /me fails without authentication token.\"\"\"\n        response = await async_client.get(\"/api/v1/auth/me\")\n\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_me_with_valid_token(self, async_client: AsyncClient):\n        \"\"\"Verify /me returns user info with valid token.\"\"\"\n        # Setup and login\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"metest\",\n                \"admin_password\": \"MePass1!\",\n            },\n        )\n\n        login_response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"metest\", \"password\": \"MePass1!\"},\n        )\n        token = login_response.json()[\"access_token\"]\n\n        # Get current user\n        response = await async_client.get(\n            \"/api/v1/auth/me\",\n            headers={\"Authorization\": f\"Bearer {token}\"},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"username\"] == \"metest\"\n        assert result[\"role\"] == \"admin\"\n        assert result[\"is_active\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_me_with_api_key_bearer(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify /me returns synthetic admin user when using API key via Bearer token.\"\"\"\n        from backend.app.core.auth import generate_api_key\n        from backend.app.models.api_key import APIKey\n\n        # Create an API key directly in the database\n        full_key, key_hash, key_prefix = generate_api_key()\n        api_key = APIKey(name=\"test-kiosk\", key_hash=key_hash, key_prefix=key_prefix, enabled=True)\n        db_session.add(api_key)\n        await db_session.commit()\n\n        # Call /me with the API key as Bearer token\n        response = await async_client.get(\n            \"/api/v1/auth/me\",\n            headers={\"Authorization\": f\"Bearer {full_key}\"},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == 0\n        assert result[\"username\"].startswith(\"api-key:\")\n        assert result[\"role\"] == \"admin\"\n        assert result[\"is_admin\"] is True\n        assert result[\"is_active\"] is True\n        assert len(result[\"permissions\"]) > 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_me_with_api_key_header(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify /me returns synthetic admin user when using X-API-Key header.\"\"\"\n        from backend.app.core.auth import generate_api_key\n        from backend.app.models.api_key import APIKey\n\n        full_key, key_hash, key_prefix = generate_api_key()\n        api_key = APIKey(name=\"test-kiosk-header\", key_hash=key_hash, key_prefix=key_prefix, enabled=True)\n        db_session.add(api_key)\n        await db_session.commit()\n\n        response = await async_client.get(\n            \"/api/v1/auth/me\",\n            headers={\"X-API-Key\": full_key},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == 0\n        assert result[\"username\"].startswith(\"api-key:\")\n        assert result[\"is_admin\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_me_with_invalid_api_key(self, async_client: AsyncClient):\n        \"\"\"Verify /me rejects invalid API key.\"\"\"\n        response = await async_client.get(\n            \"/api/v1/auth/me\",\n            headers={\"Authorization\": \"Bearer bb_invalid_key_value\"},\n        )\n\n        assert response.status_code == 401\n\n\nclass TestUsersAPI:\n    \"\"\"Integration tests for /api/v1/users/ endpoints.\"\"\"\n\n    @pytest.fixture\n    async def auth_token(self, async_client: AsyncClient):\n        \"\"\"Setup auth and return admin token.\"\"\"\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"usersadmin\",\n                \"admin_password\": \"AdminPass1!\",\n            },\n        )\n\n        login_response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"usersadmin\", \"password\": \"AdminPass1!\"},\n        )\n        return login_response.json()[\"access_token\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_users_requires_auth(self, async_client: AsyncClient):\n        \"\"\"Verify listing users requires authentication when auth is enabled.\"\"\"\n        # First enable auth\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"authreqadmin\",\n                \"admin_password\": \"AdminPass1!\",\n            },\n        )\n\n        # Now try to list users without a token\n        response = await async_client.get(\"/api/v1/users/\")\n\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_users_as_admin(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify admin can list users.\"\"\"\n        response = await async_client.get(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert isinstance(result, list)\n        assert len(result) >= 1  # At least the admin user\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_user(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify admin can create a new user.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"username\": \"newuser\",\n                \"password\": \"Newuserpass1!\",\n                \"role\": \"user\",\n            },\n        )\n\n        assert response.status_code == 201\n        result = response.json()\n        assert result[\"username\"] == \"newuser\"\n        assert result[\"role\"] == \"user\"\n        assert result[\"is_active\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_user_duplicate_username(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify creating user with duplicate username fails.\"\"\"\n        # Create first user\n        await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"username\": \"duplicateuser\",\n                \"password\": \"Password123!\",\n                \"role\": \"user\",\n            },\n        )\n\n        # Try to create duplicate\n        response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"username\": \"duplicateuser\",\n                \"password\": \"Password456!\",\n                \"role\": \"user\",\n            },\n        )\n\n        assert response.status_code == 400\n        assert \"Username already exists\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_user(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify admin can update a user.\"\"\"\n        # Create user\n        create_response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"username\": \"updateuser\",\n                \"password\": \"Password123!\",\n                \"role\": \"user\",\n            },\n        )\n        user_id = create_response.json()[\"id\"]\n\n        # Update user\n        response = await async_client.patch(\n            f\"/api/v1/users/{user_id}\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\"role\": \"admin\"},\n        )\n\n        assert response.status_code == 200\n        assert response.json()[\"role\"] == \"admin\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_user(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify admin can delete a user.\"\"\"\n        # Create user\n        create_response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"username\": \"deleteuser\",\n                \"password\": \"Password123!\",\n                \"role\": \"user\",\n            },\n        )\n        user_id = create_response.json()[\"id\"]\n\n        # Delete user\n        response = await async_client.delete(\n            f\"/api/v1/users/{user_id}\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n\n        assert response.status_code == 204\n\n\nclass TestAuthDisableAPI:\n    \"\"\"Integration tests for /api/v1/auth/disable endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_auth(self, async_client: AsyncClient):\n        \"\"\"Verify admin can disable authentication.\"\"\"\n        # Setup auth\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"disableadmin\",\n                \"admin_password\": \"AdminPass1!\",\n            },\n        )\n\n        # Login to get token\n        login_response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"disableadmin\", \"password\": \"AdminPass1!\"},\n        )\n        token = login_response.json()[\"access_token\"]\n\n        # Disable auth\n        response = await async_client.post(\n            \"/api/v1/auth/disable\",\n            headers={\"Authorization\": f\"Bearer {token}\"},\n        )\n\n        assert response.status_code == 200\n        assert response.json()[\"auth_enabled\"] is False\n\n        # Verify auth is now disabled\n        status_response = await async_client.get(\"/api/v1/auth/status\")\n        assert status_response.json()[\"auth_enabled\"] is False\n\n\nclass TestGroupsAPI:\n    \"\"\"Integration tests for /api/v1/groups/ endpoints.\"\"\"\n\n    @pytest.fixture\n    async def auth_token(self, async_client: AsyncClient):\n        \"\"\"Setup auth and return admin token.\"\"\"\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"groupsadmin\",\n                \"admin_password\": \"AdminPass1!\",\n            },\n        )\n\n        login_response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"groupsadmin\", \"password\": \"AdminPass1!\"},\n        )\n        return login_response.json()[\"access_token\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_groups(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify listing groups returns default groups.\"\"\"\n        response = await async_client.get(\n            \"/api/v1/groups/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n\n        assert response.status_code == 200\n        groups = response.json()\n        assert isinstance(groups, list)\n        # Should have default groups: Administrators, Operators, Viewers\n        group_names = [g[\"name\"] for g in groups]\n        assert \"Administrators\" in group_names\n        assert \"Operators\" in group_names\n        assert \"Viewers\" in group_names\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_permissions(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify getting available permissions.\"\"\"\n        response = await async_client.get(\n            \"/api/v1/groups/permissions\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n\n        assert response.status_code == 200\n        permissions = response.json()\n        assert isinstance(permissions, dict)\n        # Should have permission categories\n        assert \"Printers\" in permissions or len(permissions) > 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_group(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify creating a new group.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/groups/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"name\": \"Custom Group\",\n                \"description\": \"A custom test group\",\n                \"permissions\": [\"printers:read\", \"archives:read\"],\n            },\n        )\n\n        assert response.status_code == 201\n        group = response.json()\n        assert group[\"name\"] == \"Custom Group\"\n        assert group[\"description\"] == \"A custom test group\"\n        assert \"printers:read\" in group[\"permissions\"]\n        assert group[\"is_system\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_group(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify updating a group.\"\"\"\n        # Create a group first\n        create_response = await async_client.post(\n            \"/api/v1/groups/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"name\": \"Update Test Group\",\n                \"permissions\": [\"printers:read\"],\n            },\n        )\n        group_id = create_response.json()[\"id\"]\n\n        # Update the group\n        response = await async_client.patch(\n            f\"/api/v1/groups/{group_id}\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"description\": \"Updated description\",\n                \"permissions\": [\"printers:read\", \"printers:control\"],\n            },\n        )\n\n        assert response.status_code == 200\n        group = response.json()\n        assert group[\"description\"] == \"Updated description\"\n        assert \"printers:control\" in group[\"permissions\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cannot_delete_system_group(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify system groups cannot be deleted.\"\"\"\n        # Get the Administrators group\n        list_response = await async_client.get(\n            \"/api/v1/groups/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n        admin_group = next(g for g in list_response.json() if g[\"name\"] == \"Administrators\")\n\n        # Try to delete it\n        response = await async_client.delete(\n            f\"/api/v1/groups/{admin_group['id']}\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n\n        assert response.status_code == 400\n        assert \"system group\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_custom_group(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify custom groups can be deleted.\"\"\"\n        # Create a group\n        create_response = await async_client.post(\n            \"/api/v1/groups/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\"name\": \"Delete Test Group\"},\n        )\n        group_id = create_response.json()[\"id\"]\n\n        # Delete it\n        response = await async_client.delete(\n            f\"/api/v1/groups/{group_id}\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n\n        assert response.status_code == 204\n\n\nclass TestUserGroupsAPI:\n    \"\"\"Integration tests for user-group assignments.\"\"\"\n\n    @pytest.fixture\n    async def auth_token(self, async_client: AsyncClient):\n        \"\"\"Setup auth and return admin token.\"\"\"\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"usergroupadmin\",\n                \"admin_password\": \"AdminPass1!\",\n            },\n        )\n\n        login_response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"usergroupadmin\", \"password\": \"AdminPass1!\"},\n        )\n        return login_response.json()[\"access_token\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_user_with_groups(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify creating a user with group assignments.\"\"\"\n        # Get Operators group ID\n        groups_response = await async_client.get(\n            \"/api/v1/groups/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n        operators_group = next(g for g in groups_response.json() if g[\"name\"] == \"Operators\")\n\n        # Create user with group\n        response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\n                \"username\": \"groupuser\",\n                \"password\": \"Password123!\",\n                \"group_ids\": [operators_group[\"id\"]],\n            },\n        )\n\n        assert response.status_code == 201\n        user = response.json()\n        assert any(g[\"name\"] == \"Operators\" for g in user[\"groups\"])\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_user_to_group(self, async_client: AsyncClient, auth_token: str):\n        \"\"\"Verify adding a user to a group.\"\"\"\n        # Create a user\n        user_response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n            json={\"username\": \"addtogroup\", \"password\": \"Password123!\"},\n        )\n        user_id = user_response.json()[\"id\"]\n\n        # Get Viewers group\n        groups_response = await async_client.get(\n            \"/api/v1/groups/\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n        viewers_group = next(g for g in groups_response.json() if g[\"name\"] == \"Viewers\")\n\n        # Add user to group\n        response = await async_client.post(\n            f\"/api/v1/groups/{viewers_group['id']}/users/{user_id}\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n\n        assert response.status_code == 204\n\n        # Verify user is in group\n        user_check = await async_client.get(\n            f\"/api/v1/users/{user_id}\",\n            headers={\"Authorization\": f\"Bearer {auth_token}\"},\n        )\n        assert any(g[\"name\"] == \"Viewers\" for g in user_check.json()[\"groups\"])\n\n\nclass TestChangePasswordAPI:\n    \"\"\"Integration tests for /api/v1/users/me/change-password endpoint.\"\"\"\n\n    @pytest.fixture\n    async def user_token(self, async_client: AsyncClient):\n        \"\"\"Setup auth and return regular user token.\"\"\"\n        # Enable auth with admin\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"pwchangeadmin\",\n                \"admin_password\": \"AdminPass1!\",\n            },\n        )\n\n        admin_login = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"pwchangeadmin\", \"password\": \"AdminPass1!\"},\n        )\n        admin_token = admin_login.json()[\"access_token\"]\n\n        # Create a regular user\n        await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {admin_token}\"},\n            json={\"username\": \"pwchangeuser\", \"password\": \"Oldpassword123!\"},\n        )\n\n        # Login as regular user\n        user_login = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"pwchangeuser\", \"password\": \"Oldpassword123!\"},\n        )\n        return user_login.json()[\"access_token\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_change_password_success(self, async_client: AsyncClient, user_token: str):\n        \"\"\"Verify user can change their own password.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/users/me/change-password\",\n            headers={\"Authorization\": f\"Bearer {user_token}\"},\n            json={\n                \"current_password\": \"Oldpassword123!\",\n                \"new_password\": \"Newpassword456!\",\n            },\n        )\n\n        assert response.status_code == 200\n        assert \"success\" in response.json()[\"message\"].lower()\n\n        # Verify can login with new password\n        login_response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"pwchangeuser\", \"password\": \"Newpassword456!\"},\n        )\n        assert login_response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_change_password_wrong_current(self, async_client: AsyncClient, user_token: str):\n        \"\"\"Verify changing password fails with wrong current password.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/users/me/change-password\",\n            headers={\"Authorization\": f\"Bearer {user_token}\"},\n            json={\n                \"current_password\": \"wrongpassword\",\n                \"new_password\": \"Newpassword456!\",\n            },\n        )\n\n        assert response.status_code == 400\n        assert \"incorrect\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_change_password_requires_auth(self, async_client: AsyncClient):\n        \"\"\"Verify changing password requires authentication.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/users/me/change-password\",\n            json={\n                \"current_password\": \"oldpassword\",\n                \"new_password\": \"Strongpass456!\",\n            },\n        )\n\n        assert response.status_code == 401\n\n\nclass TestAuthMiddlewarePublicRoutes:\n    \"\"\"Tests for auth middleware public route configuration.\n\n    These routes must be accessible without authentication, even when auth is enabled,\n    because browser elements like <img src> and <video src> don't send Authorization headers.\n    \"\"\"\n\n    @pytest.fixture\n    async def enabled_auth(self, async_client: AsyncClient):\n        \"\"\"Enable auth for testing middleware behavior.\"\"\"\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"middlewareadmin\",\n                \"admin_password\": \"AdminPass1!\",\n            },\n        )\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):\n        \"\"\"Verify /api/v1/auth/status is accessible without auth.\"\"\"\n        response = await async_client.get(\"/api/v1/auth/status\")\n        assert response.status_code == 200\n        assert \"auth_enabled\" in response.json()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth):\n        \"\"\"Verify /api/v1/auth/login is accessible without auth.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"middlewareadmin\", \"password\": \"AdminPass1!\"},\n        )\n        # Should not return 401 (unauthorized) - it should either succeed or return\n        # a different error (like 400 for wrong credentials)\n        assert response.status_code != 401 or \"token\" in response.json()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_auth_setup_is_public(self, async_client: AsyncClient):\n        \"\"\"Verify /api/v1/auth/setup is accessible without auth (needed for setup/recovery).\"\"\"\n        # Don't enable auth first - test that setup endpoint itself is accessible\n        response = await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\"auth_enabled\": False},\n        )\n        # Should not be 401\n        assert response.status_code != 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_updates_version_is_public(self, async_client: AsyncClient, enabled_auth):\n        \"\"\"Verify /api/v1/updates/version is accessible without auth.\"\"\"\n        response = await async_client.get(\"/api/v1/updates/version\")\n        # Should not be 401\n        assert response.status_code != 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_protected_route_requires_auth(self, async_client: AsyncClient, enabled_auth):\n        \"\"\"Verify non-public routes return 401 without token.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/\")\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_protected_route_works_with_token(self, async_client: AsyncClient, enabled_auth):\n        \"\"\"Verify non-public routes work with valid token.\"\"\"\n        # Login to get token\n        login_response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"middlewareadmin\", \"password\": \"AdminPass1!\"},\n        )\n        token = login_response.json()[\"access_token\"]\n\n        # Access protected route\n        response = await async_client.get(\n            \"/api/v1/printers/\",\n            headers={\"Authorization\": f\"Bearer {token}\"},\n        )\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_advanced_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):\n        \"\"\"Verify /api/v1/auth/advanced-auth/status is accessible without auth.\"\"\"\n        response = await async_client.get(\"/api/v1/auth/advanced-auth/status\")\n        # Should not be 401 (must be accessible for login page)\n        assert response.status_code != 401\n        # Should return valid response (200 with auth status)\n        if response.status_code == 200:\n            result = response.json()\n            assert \"advanced_auth_enabled\" in result\n            assert \"smtp_configured\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_forgot_password_is_public(self, async_client: AsyncClient, enabled_auth):\n        \"\"\"Verify /api/v1/auth/forgot-password is accessible without auth.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/forgot-password\",\n            json={\"email\": \"test@example.com\"},\n        )\n        # Should not be 401 (must be accessible for password reset from login page)\n        assert response.status_code != 401\n        # Will likely be 400 (advanced auth not enabled) but that's okay -\n        # the important thing is it's not blocked by auth middleware\n        assert response.status_code in [200, 400]\n\n\n# ===========================================================================\n# H-1: Input length validation\n# ===========================================================================\n\n\nclass TestInputLengthValidation:\n    \"\"\"LoginRequest and SetupRequest must reject oversized inputs (H-1).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_password_too_long_rejected(self, async_client: AsyncClient):\n        \"\"\"Password exceeding 256 characters must be rejected with 422.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"admin\", \"password\": \"x\" * 257},\n        )\n        assert response.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_username_too_long_rejected(self, async_client: AsyncClient):\n        \"\"\"Username exceeding 150 characters must be rejected with 422.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"u\" * 151, \"password\": \"password\"},\n        )\n        assert response.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_setup_password_too_long_rejected(self, async_client: AsyncClient):\n        \"\"\"SetupRequest admin_password exceeding 256 characters must be rejected with 422.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"admin\",\n                \"admin_password\": \"x\" * 257,\n            },\n        )\n        assert response.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_password_at_limit_accepted(self, async_client: AsyncClient):\n        \"\"\"Password of exactly 256 characters must pass schema validation (may fail auth).\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"admin\", \"password\": \"x\" * 256},\n        )\n        # Schema accepts it; auth may reject with 401 (auth disabled) or 400\n        assert response.status_code != 422\n"
  },
  {
    "path": "backend/tests/integration/test_available_filaments.py",
    "content": "\"\"\"Integration tests for GET /api/v1/printers/available-filaments endpoint.\n\nTests that the endpoint returns deduplicated filaments with tray_sub_brands,\ncorrectly distinguishing subtypes like \"PLA Basic\" vs \"PLA Matte\".\n\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\ndef _make_mock_status(ams_data: list, vt_tray: list | None = None, ams_extruder_map: dict | None = None) -> MagicMock:\n    \"\"\"Create a mock printer status with raw_data containing AMS info.\"\"\"\n    status = MagicMock()\n    raw = {\"ams\": ams_data}\n    if vt_tray is not None:\n        raw[\"vt_tray\"] = vt_tray\n    if ams_extruder_map is not None:\n        raw[\"ams_extruder_map\"] = ams_extruder_map\n    else:\n        raw[\"ams_extruder_map\"] = {}\n    status.raw_data = raw\n    return status\n\n\nclass TestAvailableFilaments:\n    \"\"\"Tests for /api/v1/printers/available-filaments endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_returns_tray_sub_brands(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify tray_sub_brands is included in the response.\"\"\"\n        await printer_factory(name=\"Test Printer\", model=\"X1C\")\n\n        status = _make_mock_status(\n            ams_data=[\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_type\": \"PLA\",\n                            \"tray_color\": \"000000FF\",\n                            \"tray_info_idx\": \"GFL99\",\n                            \"tray_sub_brands\": \"PLA Basic\",\n                        },\n                    ],\n                },\n            ]\n        )\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.get(\"/api/v1/printers/available-filaments?model=X1C\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 1\n        assert data[0][\"tray_sub_brands\"] == \"PLA Basic\"\n        assert data[0][\"type\"] == \"PLA\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_dedup_distinguishes_subtypes(self, async_client: AsyncClient, printer_factory):\n        \"\"\"PLA Basic Black and PLA Matte Black should be separate entries.\"\"\"\n        await printer_factory(name=\"Printer 1\", model=\"X1C\")\n\n        status = _make_mock_status(\n            ams_data=[\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_type\": \"PLA\",\n                            \"tray_color\": \"000000FF\",\n                            \"tray_info_idx\": \"GFL99\",\n                            \"tray_sub_brands\": \"PLA Basic\",\n                        },\n                        {\n                            \"id\": 1,\n                            \"tray_type\": \"PLA\",\n                            \"tray_color\": \"000000FF\",\n                            \"tray_info_idx\": \"GFL05\",\n                            \"tray_sub_brands\": \"PLA Matte\",\n                        },\n                    ],\n                },\n            ]\n        )\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.get(\"/api/v1/printers/available-filaments?model=X1C\")\n\n        assert response.status_code == 200\n        data = response.json()\n        # Same type + color but different tray_sub_brands → 2 entries\n        assert len(data) == 2\n        sub_brands = {d[\"tray_sub_brands\"] for d in data}\n        assert sub_brands == {\"PLA Basic\", \"PLA Matte\"}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_dedup_same_subtype_same_color(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Same subtype + same color across two printers should be deduped to one entry.\"\"\"\n        await printer_factory(name=\"Printer 1\", model=\"X1C\")\n        await printer_factory(name=\"Printer 2\", model=\"X1C\")\n\n        status1 = _make_mock_status(\n            ams_data=[\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_type\": \"PLA\",\n                            \"tray_color\": \"FF0000FF\",\n                            \"tray_info_idx\": \"GFL99\",\n                            \"tray_sub_brands\": \"PLA Basic\",\n                        }\n                    ],\n                },\n            ]\n        )\n        status2 = _make_mock_status(\n            ams_data=[\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_type\": \"PLA\",\n                            \"tray_color\": \"FF0000FF\",\n                            \"tray_info_idx\": \"GFL99\",\n                            \"tray_sub_brands\": \"PLA Basic\",\n                        }\n                    ],\n                },\n            ]\n        )\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_status.side_effect = [status1, status2]\n\n            response = await async_client.get(\"/api/v1/printers/available-filaments?model=X1C\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_empty_sub_brands_handled(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Filaments with empty/missing tray_sub_brands should still be returned.\"\"\"\n        await printer_factory(name=\"Test Printer\", model=\"X1C\")\n\n        status = _make_mock_status(\n            ams_data=[\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000FF\", \"tray_info_idx\": \"GFL99\"},\n                    ],\n                },\n            ]\n        )\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.get(\"/api/v1/printers/available-filaments?model=X1C\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 1\n        assert data[0][\"tray_sub_brands\"] == \"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_external_spool_includes_sub_brands(self, async_client: AsyncClient, printer_factory):\n        \"\"\"External spools (vt_tray) should also include tray_sub_brands.\"\"\"\n        await printer_factory(name=\"Test Printer\", model=\"X1C\")\n\n        status = _make_mock_status(\n            ams_data=[],\n            vt_tray=[\n                {\n                    \"id\": 254,\n                    \"tray_type\": \"PETG\",\n                    \"tray_color\": \"00FF00FF\",\n                    \"tray_info_idx\": \"GFG00\",\n                    \"tray_sub_brands\": \"PETG HF\",\n                },\n            ],\n        )\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.get(\"/api/v1/printers/available-filaments?model=X1C\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 1\n        assert data[0][\"tray_sub_brands\"] == \"PETG HF\"\n        assert data[0][\"type\"] == \"PETG\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_no_printers_returns_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list when no printers match the model.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/available-filaments?model=X1C\")\n\n        assert response.status_code == 200\n        assert response.json() == []\n"
  },
  {
    "path": "backend/tests/integration/test_background_dispatch_api.py",
    "content": "\"\"\"Integration tests for background dispatch API behavior.\"\"\"\n\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\nfrom backend.app.services.background_dispatch import DispatchEnqueueRejected\n\n\nclass TestBackgroundDispatchArchivesAPI:\n    \"\"\"Tests for archive reprint dispatch endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reprint_returns_dispatched_payload(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path\n    ):\n        \"\"\"Reprint endpoint returns background dispatch metadata.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            filename=\"widget.gcode.3mf\",\n            file_path=\"archives/test/widget.gcode.3mf\",\n        )\n\n        archive_file = tmp_path / archive.file_path\n        archive_file.parent.mkdir(parents=True, exist_ok=True)\n        archive_file.write_bytes(b\"3mf-data\")\n\n        with (\n            patch(\"backend.app.api.routes.archives.settings.base_dir\", tmp_path),\n            patch(\"backend.app.services.printer_manager.printer_manager.is_connected\", return_value=True),\n            patch(\n                \"backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive\",\n                new=AsyncMock(return_value={\"dispatch_job_id\": 15, \"dispatch_position\": 1}),\n            ) as mock_dispatch,\n        ):\n            response = await async_client.post(\n                f\"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}\",\n                json={\"plate_id\": 2},\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] == \"dispatched\"\n        assert data[\"dispatch_job_id\"] == 15\n        assert data[\"dispatch_position\"] == 1\n        assert data[\"filename\"] == \"widget.gcode.3mf\"\n\n        mock_dispatch.assert_awaited_once()\n        kwargs = mock_dispatch.await_args.kwargs\n        assert kwargs[\"archive_name\"].endswith(\"• Plate 2\")\n        assert kwargs[\"options\"][\"plate_id\"] == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reprint_returns_409_when_enqueue_rejected(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path\n    ):\n        \"\"\"Reprint endpoint maps enqueue rejection to HTTP 409.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            filename=\"widget2.gcode.3mf\",\n            file_path=\"archives/test/widget2.gcode.3mf\",\n        )\n\n        archive_file = tmp_path / archive.file_path\n        archive_file.parent.mkdir(parents=True, exist_ok=True)\n        archive_file.write_bytes(b\"3mf-data\")\n\n        with (\n            patch(\"backend.app.api.routes.archives.settings.base_dir\", tmp_path),\n            patch(\"backend.app.services.printer_manager.printer_manager.is_connected\", return_value=True),\n            patch(\n                \"backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive\",\n                new=AsyncMock(side_effect=DispatchEnqueueRejected(\"already has a background dispatch\")),\n            ),\n        ):\n            response = await async_client.post(\n                f\"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}\",\n                json={\"plate_id\": 1},\n            )\n\n        assert response.status_code == 409\n        assert \"already has a background dispatch\" in response.json()[\"detail\"]\n\n\nclass TestBackgroundDispatchLibraryAPI:\n    \"\"\"Tests for library print dispatch endpoint.\"\"\"\n\n    @pytest.fixture\n    async def library_file_factory(self, db_session):\n        \"\"\"Factory to create library files.\"\"\"\n\n        async def _create_file(**kwargs):\n            from backend.app.models.library import LibraryFile\n\n            defaults = {\n                \"filename\": \"library_part.gcode.3mf\",\n                \"file_path\": \"library/files/library_part.gcode.3mf\",\n                \"file_type\": \"gcode\",\n                \"file_size\": 1024,\n            }\n            defaults.update(kwargs)\n            lib_file = LibraryFile(**defaults)\n            db_session.add(lib_file)\n            await db_session.commit()\n            await db_session.refresh(lib_file)\n            return lib_file\n\n        return _create_file\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_library_print_returns_dispatched_payload(\n        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path\n    ):\n        \"\"\"Library print endpoint returns dispatch job metadata.\"\"\"\n        printer = await printer_factory()\n        lib_file = await library_file_factory()\n\n        disk_path = tmp_path / lib_file.file_path\n        disk_path.parent.mkdir(parents=True, exist_ok=True)\n        disk_path.write_bytes(b\"library data\")\n\n        with (\n            patch(\"backend.app.api.routes.library.app_settings.base_dir\", tmp_path),\n            patch(\"backend.app.services.printer_manager.printer_manager.is_connected\", return_value=True),\n            patch(\n                \"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file\",\n                new=AsyncMock(return_value={\"dispatch_job_id\": 21, \"dispatch_position\": 2}),\n            ) as mock_dispatch,\n        ):\n            response = await async_client.post(\n                f\"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}\",\n                json={\"plate_id\": 4},\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] == \"dispatched\"\n        assert data[\"dispatch_job_id\"] == 21\n        assert data[\"dispatch_position\"] == 2\n        assert data[\"archive_id\"] is None\n\n        mock_dispatch.assert_awaited_once()\n        kwargs = mock_dispatch.await_args.kwargs\n        assert kwargs[\"filename\"].endswith(\"• Plate 4\")\n        assert kwargs[\"options\"][\"plate_id\"] == 4\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_library_print_returns_409_when_enqueue_rejected(\n        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path\n    ):\n        \"\"\"Library print endpoint maps enqueue rejection to HTTP 409.\"\"\"\n        printer = await printer_factory()\n        lib_file = await library_file_factory(filename=\"another_part.gcode\")\n\n        disk_path = tmp_path / lib_file.file_path\n        disk_path.parent.mkdir(parents=True, exist_ok=True)\n        disk_path.write_bytes(b\"library data\")\n\n        with (\n            patch(\"backend.app.api.routes.library.app_settings.base_dir\", tmp_path),\n            patch(\"backend.app.services.printer_manager.printer_manager.is_connected\", return_value=True),\n            patch(\n                \"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file\",\n                new=AsyncMock(side_effect=DispatchEnqueueRejected(\"queue conflict\")),\n            ),\n        ):\n            response = await async_client.post(\n                f\"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}\",\n                json={\"plate_id\": 1},\n            )\n\n        assert response.status_code == 409\n        assert \"queue conflict\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_library_print_cleanup_flag_defaults_false(\n        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path\n    ):\n        \"\"\"Absent cleanup_library_after_dispatch in the request body ⇒ False reaches the dispatcher.\n        Guards the File Manager / Project Detail paths from accidental deletion.\"\"\"\n        printer = await printer_factory()\n        lib_file = await library_file_factory(filename=\"filemgr_part.gcode.3mf\")\n\n        disk_path = tmp_path / lib_file.file_path\n        disk_path.parent.mkdir(parents=True, exist_ok=True)\n        disk_path.write_bytes(b\"library data\")\n\n        with (\n            patch(\"backend.app.api.routes.library.app_settings.base_dir\", tmp_path),\n            patch(\"backend.app.services.printer_manager.printer_manager.is_connected\", return_value=True),\n            patch(\n                \"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file\",\n                new=AsyncMock(return_value={\"dispatch_job_id\": 30, \"dispatch_position\": 1}),\n            ) as mock_dispatch,\n        ):\n            response = await async_client.post(\n                f\"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}\",\n                json={},\n            )\n\n        assert response.status_code == 200\n        mock_dispatch.assert_awaited_once()\n        assert mock_dispatch.await_args.kwargs[\"cleanup_library_after_dispatch\"] is False\n        # cleanup flag must never leak into the print-option dict forwarded to MQTT\n        assert \"cleanup_library_after_dispatch\" not in mock_dispatch.await_args.kwargs[\"options\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_library_print_forwards_cleanup_flag_true(\n        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path\n    ):\n        \"\"\"Direct-Print flow sends cleanup_library_after_dispatch=True, which must reach the dispatcher.\"\"\"\n        printer = await printer_factory()\n        lib_file = await library_file_factory(filename=\"transient_part.gcode.3mf\")\n\n        disk_path = tmp_path / lib_file.file_path\n        disk_path.parent.mkdir(parents=True, exist_ok=True)\n        disk_path.write_bytes(b\"library data\")\n\n        with (\n            patch(\"backend.app.api.routes.library.app_settings.base_dir\", tmp_path),\n            patch(\"backend.app.services.printer_manager.printer_manager.is_connected\", return_value=True),\n            patch(\n                \"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file\",\n                new=AsyncMock(return_value={\"dispatch_job_id\": 31, \"dispatch_position\": 1}),\n            ) as mock_dispatch,\n        ):\n            response = await async_client.post(\n                f\"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}\",\n                json={\"cleanup_library_after_dispatch\": True},\n            )\n\n        assert response.status_code == 200\n        mock_dispatch.assert_awaited_once()\n        assert mock_dispatch.await_args.kwargs[\"cleanup_library_after_dispatch\"] is True\n\n\nclass TestBackgroundDispatchCancelAPI:\n    \"\"\"Tests for /background-dispatch cancel endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cancel_job_returns_cancelled(self, async_client: AsyncClient):\n        \"\"\"Cancel endpoint returns cancelled for queued job.\"\"\"\n        with patch(\n            \"backend.app.services.background_dispatch.background_dispatch.cancel_job\",\n            new=AsyncMock(\n                return_value={\n                    \"cancelled\": True,\n                    \"pending\": False,\n                    \"job_id\": 9,\n                    \"source_name\": \"cube.gcode.3mf\",\n                    \"printer_id\": 1,\n                    \"printer_name\": \"Printer A\",\n                }\n            ),\n        ):\n            response = await async_client.delete(\"/api/v1/background-dispatch/9\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] == \"cancelled\"\n        assert data[\"job_id\"] == 9\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cancel_job_returns_cancelling_for_active_job(self, async_client: AsyncClient):\n        \"\"\"Cancel endpoint returns cancelling while active upload is being interrupted.\"\"\"\n        with patch(\n            \"backend.app.services.background_dispatch.background_dispatch.cancel_job\",\n            new=AsyncMock(\n                return_value={\n                    \"cancelled\": True,\n                    \"pending\": True,\n                    \"job_id\": 10,\n                    \"source_name\": \"cube.gcode.3mf\",\n                    \"printer_id\": 1,\n                    \"printer_name\": \"Printer A\",\n                }\n            ),\n        ):\n            response = await async_client.delete(\"/api/v1/background-dispatch/10\")\n\n        assert response.status_code == 200\n        assert response.json()[\"status\"] == \"cancelling\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cancel_job_returns_404_when_not_found(self, async_client: AsyncClient):\n        \"\"\"Cancel endpoint returns 404 for unknown job id.\"\"\"\n        with patch(\n            \"backend.app.services.background_dispatch.background_dispatch.cancel_job\",\n            new=AsyncMock(return_value={\"cancelled\": False, \"reason\": \"not_found\"}),\n        ):\n            response = await async_client.delete(\"/api/v1/background-dispatch/999\")\n\n        assert response.status_code == 404\n        assert response.json()[\"detail\"] == \"Dispatch job not found\"\n"
  },
  {
    "path": "backend/tests/integration/test_camera_api.py",
    "content": "\"\"\"Integration tests for Camera API endpoints.\n\nTests the full request/response cycle for /api/v1/printers/{id}/camera/ endpoints.\n\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestCameraAPI:\n    \"\"\"Integration tests for /api/v1/printers/{id}/camera/ endpoints.\"\"\"\n\n    # ========================================================================\n    # Camera Stop Endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_camera_stream_get(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify camera stop endpoint works with GET method.\"\"\"\n        printer = await printer_factory()\n\n        response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/stop\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"stopped\" in result\n        assert isinstance(result[\"stopped\"], int)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_camera_stream_post(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify camera stop endpoint works with POST method (sendBeacon compatibility).\"\"\"\n        printer = await printer_factory()\n\n        response = await async_client.post(f\"/api/v1/printers/{printer.id}/camera/stop\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"stopped\" in result\n        assert isinstance(result[\"stopped\"], int)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_camera_stream_no_active_streams(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify stop returns 0 when no active streams exist.\"\"\"\n        printer = await printer_factory()\n\n        response = await async_client.post(f\"/api/v1/printers/{printer.id}/camera/stop\")\n\n        assert response.status_code == 200\n        assert response.json()[\"stopped\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_camera_stream_with_active_stream(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify stop terminates active streams for the printer.\"\"\"\n        printer = await printer_factory()\n\n        # Mock an active stream — wait() must be AsyncMock since it's awaited\n        mock_process = MagicMock()\n        mock_process.returncode = None\n        mock_process.pid = 99999\n        mock_process.terminate = MagicMock()\n        mock_process.wait = AsyncMock()\n\n        with patch(\"backend.app.api.routes.camera._active_streams\", {f\"{printer.id}-abc123\": mock_process}):\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/camera/stop\")\n\n        assert response.status_code == 200\n        assert response.json()[\"stopped\"] == 1\n        mock_process.terminate.assert_called_once()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_camera_stream_only_stops_matching_printer(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify stop only terminates streams for the specified printer.\"\"\"\n        printer1 = await printer_factory(name=\"Printer 1\")\n        printer2 = await printer_factory(name=\"Printer 2\")\n\n        # Mock active streams for both printers — wait() must be AsyncMock since it's awaited\n        mock_process1 = MagicMock()\n        mock_process1.returncode = None\n        mock_process1.pid = 99998\n        mock_process1.terminate = MagicMock()\n        mock_process1.wait = AsyncMock()\n\n        mock_process2 = MagicMock()\n        mock_process2.returncode = None\n        mock_process2.pid = 99997\n        mock_process2.terminate = MagicMock()\n        mock_process2.wait = AsyncMock()\n\n        active_streams = {\n            f\"{printer1.id}-abc123\": mock_process1,\n            f\"{printer2.id}-def456\": mock_process2,\n        }\n\n        with patch(\"backend.app.api.routes.camera._active_streams\", active_streams):\n            response = await async_client.post(f\"/api/v1/printers/{printer1.id}/camera/stop\")\n\n        assert response.status_code == 200\n        assert response.json()[\"stopped\"] == 1\n        mock_process1.terminate.assert_called_once()\n        mock_process2.terminate.assert_not_called()\n\n    # ========================================================================\n    # Camera Test Endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_test_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when testing camera for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/camera/test\")\n\n        assert response.status_code == 404\n        assert \"not found\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_test_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify camera test returns success when camera is accessible.\"\"\"\n        printer = await printer_factory()\n\n        with patch(\"backend.app.api.routes.camera.test_camera_connection\", new_callable=AsyncMock) as mock_test:\n            mock_test.return_value = {\"success\": True, \"message\": \"Camera connected\"}\n\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/test\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_test_failure(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify camera test returns failure when camera is not accessible.\"\"\"\n        printer = await printer_factory()\n\n        with patch(\"backend.app.api.routes.camera.test_camera_connection\", new_callable=AsyncMock) as mock_test:\n            mock_test.return_value = {\"success\": False, \"message\": \"Connection timeout\"}\n\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/test\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is False\n\n    # ========================================================================\n    # Camera Snapshot Endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_snapshot_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when capturing snapshot for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/camera/snapshot\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_snapshot_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify snapshot returns JPEG image when successful.\"\"\"\n        printer = await printer_factory()\n\n        # Create a fake JPEG (starts with FFD8)\n        fake_jpeg = b\"\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x00\\x00\\x01\\x00\\x01\\x00\\x00\"\n\n        with patch(\"backend.app.api.routes.camera.capture_camera_frame\", new_callable=AsyncMock) as mock_capture:\n            mock_capture.return_value = True\n\n            # Mock the file read\n            with patch(\"builtins.open\", create=True) as mock_open:\n                mock_open.return_value.__enter__.return_value.read.return_value = fake_jpeg\n\n                with patch(\"pathlib.Path.exists\", return_value=True), patch(\"pathlib.Path.unlink\"):\n                    _response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/snapshot\")\n\n        # Note: The actual test might fail due to file operations, but this tests the endpoint structure\n        # In production tests, we'd mock more comprehensively\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_snapshot_failure(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify 503 when camera capture fails.\"\"\"\n        printer = await printer_factory()\n\n        with patch(\"backend.app.api.routes.camera.capture_camera_frame\", new_callable=AsyncMock) as mock_capture:\n            mock_capture.return_value = False\n\n            with patch(\"pathlib.Path.exists\", return_value=False), patch(\"pathlib.Path.unlink\"):\n                response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/snapshot\")\n\n        assert response.status_code == 503\n        assert \"Failed to capture\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify snapshot uses external camera when configured.\"\"\"\n        printer = await printer_factory(\n            external_camera_enabled=True,\n            external_camera_url=\"http://192.168.1.50/mjpeg\",\n            external_camera_type=\"mjpeg\",\n        )\n\n        fake_jpeg = b\"\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x00\\x00\\x01\\x00\\x01\\x00\\x00\"\n\n        with patch(\n            \"backend.app.services.external_camera.capture_frame\",\n            new_callable=AsyncMock,\n            return_value=fake_jpeg,\n        ):\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/snapshot\")\n\n        assert response.status_code == 200\n        assert response.headers[\"content-type\"] == \"image/jpeg\"\n        assert response.content == fake_jpeg\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_snapshot_external_camera_failure(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify 503 when external camera capture fails.\"\"\"\n        printer = await printer_factory(\n            external_camera_enabled=True,\n            external_camera_url=\"http://192.168.1.50/mjpeg\",\n            external_camera_type=\"mjpeg\",\n        )\n\n        with patch(\n            \"backend.app.services.external_camera.capture_frame\",\n            new_callable=AsyncMock,\n            return_value=None,\n        ):\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/snapshot\")\n\n        assert response.status_code == 503\n        assert \"external camera\" in response.json()[\"detail\"].lower()\n\n    # ========================================================================\n    # Camera Stream Endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_stream_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when streaming camera for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/camera/stream\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_stream_fps_validation(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify FPS parameter is validated and clamped.\"\"\"\n        printer = await printer_factory()\n\n        # FPS should be clamped between 1 and 30\n        # Testing that the endpoint accepts various FPS values without error\n        # (actual streaming would require mocking ffmpeg)\n\n        with patch(\"backend.app.api.routes.camera.get_ffmpeg_path\", return_value=None):\n            # With no ffmpeg, stream should return error message but not crash\n            response = await async_client.get(\n                f\"/api/v1/printers/{printer.id}/camera/stream\",\n                params={\"fps\": 100},  # Should be clamped to 30\n            )\n            # Response will be a streaming response with error\n            assert response.status_code == 200\n\n    # ========================================================================\n    # Plate Detection Endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_plate_detection_status_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when checking plate detection status for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/camera/plate-detection/status\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_plate_detection_status_opencv_not_available(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify plate detection status returns unavailable when OpenCV not installed.\"\"\"\n        printer = await printer_factory()\n\n        with patch(\"backend.app.services.plate_detection.OPENCV_AVAILABLE\", False):\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/plate-detection/status\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"available\"] is False\n        assert result[\"calibrated\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_plate_detection_status_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify plate detection status returns correctly when OpenCV available.\"\"\"\n        printer = await printer_factory()\n\n        # OpenCV is available in test environment, just check the response structure\n        response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/plate-detection/status\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"available\" in result\n        assert \"calibrated\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_check_plate_empty_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when checking plate for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/camera/check-plate\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_check_plate_empty_success_structure(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify check plate returns proper structure when OpenCV available.\"\"\"\n        printer = await printer_factory()\n\n        # Mock PlateDetectionResult to avoid camera timeout\n        mock_result = MagicMock()\n        mock_result.is_empty = True\n        mock_result.confidence = 0.95\n        mock_result.difference_percent = 0.5\n        mock_result.message = \"Plate appears empty\"\n        mock_result.needs_calibration = False\n        mock_result.debug_image = None\n        mock_result.to_dict.return_value = {\n            \"is_empty\": True,\n            \"confidence\": 0.95,\n            \"difference_percent\": 0.5,\n            \"message\": \"Plate appears empty\",\n            \"has_debug_image\": False,\n            \"needs_calibration\": False,\n        }\n\n        # Mock PlateDetector for reference count\n        mock_detector = MagicMock()\n        mock_detector.get_calibration_count.return_value = 0\n        mock_detector.MAX_REFERENCES = 5\n\n        with (\n            patch(\"backend.app.services.plate_detection.is_plate_detection_available\", return_value=True),\n            patch(\"backend.app.services.plate_detection.check_plate_empty\", new_callable=AsyncMock) as mock_check,\n            patch(\"backend.app.services.plate_detection.PlateDetector\", return_value=mock_detector),\n        ):\n            mock_check.return_value = mock_result\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/check-plate\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"is_empty\" in result\n        assert \"confidence\" in result\n        assert \"message\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_calibrate_plate_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when calibrating plate for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/camera/plate-detection/calibrate\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_calibrate_plate_success_structure(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify calibrate endpoint responds with proper structure.\"\"\"\n        printer = await printer_factory()\n\n        # Mock calibrate_plate at the source module to avoid camera timeout\n        with (\n            patch(\"backend.app.services.plate_detection.is_plate_detection_available\", return_value=True),\n            patch(\"backend.app.services.plate_detection.calibrate_plate\", new_callable=AsyncMock) as mock_calibrate,\n        ):\n            mock_calibrate.return_value = (True, \"Calibration saved (1/5 references)\", 0)\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is True\n        assert \"index\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when deleting calibration for non-existent printer.\"\"\"\n        response = await async_client.delete(\"/api/v1/printers/99999/camera/plate-detection/calibrate\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_calibration_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify delete calibration returns proper structure.\"\"\"\n        printer = await printer_factory()\n\n        with patch(\"backend.app.services.plate_detection.is_plate_detection_available\", return_value=True):\n            response = await async_client.delete(f\"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"success\" in result\n        assert \"message\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_references_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when getting references for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/camera/plate-detection/references\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_references_opencv_not_available(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify get references returns unavailable when OpenCV not installed.\"\"\"\n        printer = await printer_factory()\n\n        with patch(\"backend.app.services.plate_detection.OPENCV_AVAILABLE\", False):\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/plate-detection/references\")\n\n        assert response.status_code == 503\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_references_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify get references returns proper structure.\"\"\"\n        printer = await printer_factory()\n\n        # Mock OpenCV availability and PlateDetector\n        mock_detector = MagicMock()\n        mock_detector.get_references.return_value = []\n        mock_detector.MAX_REFERENCES = 5\n\n        with (\n            patch(\"backend.app.services.plate_detection.is_plate_detection_available\", return_value=True),\n            patch(\"backend.app.services.plate_detection.PlateDetector\", return_value=mock_detector),\n        ):\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/camera/plate-detection/references\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"references\" in result\n        assert \"max_references\" in result\n        assert isinstance(result[\"references\"], list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_reference_label_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when updating reference label for non-existent printer.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/printers/99999/camera/plate-detection/references/0\", params={\"label\": \"New Label\"}\n        )\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_reference_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when deleting reference for non-existent printer.\"\"\"\n        response = await async_client.delete(\"/api/v1/printers/99999/camera/plate-detection/references/0\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_reference_thumbnail_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 when getting reference thumbnail for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/camera/plate-detection/references/0/thumbnail\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # USB Camera Endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_usb_cameras_returns_list(self, async_client: AsyncClient):\n        \"\"\"Verify USB cameras endpoint returns a list of cameras.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/usb-cameras\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"cameras\" in result\n        assert isinstance(result[\"cameras\"], list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_usb_cameras_structure(self, async_client: AsyncClient):\n        \"\"\"Verify USB cameras endpoint returns proper structure for each camera.\"\"\"\n        with patch(\"backend.app.services.external_camera.list_usb_cameras\") as mock_list:\n            mock_list.return_value = [\n                {\"device\": \"/dev/video0\", \"name\": \"Logitech Webcam C920\", \"index\": 0},\n                {\"device\": \"/dev/video2\", \"name\": \"USB Camera\", \"index\": 2},\n            ]\n\n            response = await async_client.get(\"/api/v1/printers/usb-cameras\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result[\"cameras\"]) == 2\n        assert result[\"cameras\"][0][\"device\"] == \"/dev/video0\"\n        assert result[\"cameras\"][0][\"name\"] == \"Logitech Webcam C920\"\n        assert result[\"cameras\"][1][\"device\"] == \"/dev/video2\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_usb_cameras_empty_on_non_linux(self, async_client: AsyncClient):\n        \"\"\"Verify USB cameras endpoint returns empty list on non-Linux systems.\"\"\"\n        with patch(\"backend.app.services.external_camera.list_usb_cameras\") as mock_list:\n            # Simulate non-Linux system (no /dev/video* devices)\n            mock_list.return_value = []\n\n            response = await async_client.get(\"/api/v1/printers/usb-cameras\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"cameras\"] == []\n"
  },
  {
    "path": "backend/tests/integration/test_client_ip.py",
    "content": "\"\"\"Unit tests for _get_client_ip (M-R9-A / M-R10-A).\n\nCovers:\n- Direct connection without TRUSTED_PROXY_IPS → returns client.host\n- Trusted proxy with XFF → walks right-to-left, returns first non-proxy IP\n- Spoofed XFF from an untrusted client → client.host is returned\n- Multiple trusted proxies in chain → returns leftmost non-proxy entry\n- All XFF entries are trusted proxies → falls back to leftmost\n- Empty XFF header with trusted proxy → returns direct_ip\n- No client (client=None) → returns unique per-request token\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import MagicMock, patch\n\n\ndef _make_request(client_host: str | None, xff: str = \"\") -> MagicMock:\n    \"\"\"Create a minimal mock Request with given client.host and X-Forwarded-For.\"\"\"\n    req = MagicMock()\n    if client_host is None:\n        req.client = None\n    else:\n        req.client = MagicMock()\n        req.client.host = client_host\n    req.headers = MagicMock()\n    req.headers.get = lambda key, default=\"\": xff if key == \"X-Forwarded-For\" else default\n    return req\n\n\ndef _call(request, trusted: frozenset[str]) -> str:\n    from backend.app.api.routes.auth import _get_client_ip\n\n    with patch(\"backend.app.api.routes.auth._TRUSTED_PROXY_IPS\", trusted):\n        return _get_client_ip(request)\n\n\n# ---------------------------------------------------------------------------\n# No proxy configured (TRUSTED_PROXY_IPS empty)\n# ---------------------------------------------------------------------------\n\n\ndef test_no_proxy_returns_client_host():\n    req = _make_request(\"1.2.3.4\")\n    assert _call(req, frozenset()) == \"1.2.3.4\"\n\n\ndef test_no_proxy_xff_ignored():\n    \"\"\"XFF must be ignored when TRUSTED_PROXY_IPS is not set.\"\"\"\n    req = _make_request(\"1.2.3.4\", xff=\"9.9.9.9\")\n    assert _call(req, frozenset()) == \"1.2.3.4\"\n\n\n# ---------------------------------------------------------------------------\n# Trusted proxy present; direct peer is the proxy\n# ---------------------------------------------------------------------------\n\n\ndef test_trusted_proxy_returns_rightmost_non_proxy():\n    \"\"\"Single proxy: XFF = client_ip; direct_ip = proxy_ip → return client.\"\"\"\n    proxy = \"10.0.0.1\"\n    client = \"203.0.113.5\"\n    req = _make_request(proxy, xff=client)\n    assert _call(req, frozenset({proxy})) == client\n\n\ndef test_trusted_proxy_chain_skips_proxy_ips():\n    \"\"\"Multi-hop: client → proxy1 → proxy2 (direct) → app.\n    XFF = 'client, proxy1'; direct = proxy2.  Should return client.\"\"\"\n    proxy1 = \"10.0.0.1\"\n    proxy2 = \"10.0.0.2\"\n    client = \"198.51.100.7\"\n    req = _make_request(proxy2, xff=f\"{client}, {proxy1}\")\n    assert _call(req, frozenset({proxy1, proxy2})) == client\n\n\ndef test_all_xff_entries_are_proxies_falls_back_to_leftmost():\n    \"\"\"When every XFF entry is a trusted proxy, return the leftmost (original) entry.\"\"\"\n    proxy1 = \"10.0.0.1\"\n    proxy2 = \"10.0.0.2\"\n    req = _make_request(proxy2, xff=f\"{proxy1}, {proxy2}\")\n    assert _call(req, frozenset({proxy1, proxy2})) == proxy1\n\n\ndef test_empty_xff_with_trusted_proxy_returns_direct_ip():\n    \"\"\"Trusted proxy but no XFF header → fall through to direct_ip.\"\"\"\n    proxy = \"10.0.0.1\"\n    req = _make_request(proxy, xff=\"\")\n    assert _call(req, frozenset({proxy})) == proxy\n\n\n# ---------------------------------------------------------------------------\n# Spoofed XFF from an untrusted client\n# ---------------------------------------------------------------------------\n\n\ndef test_spoofed_xff_from_untrusted_client_ignored():\n    \"\"\"Client not in TRUSTED_PROXY_IPS → XFF is ignored; client.host returned.\"\"\"\n    untrusted_client = \"203.0.113.99\"\n    req = _make_request(untrusted_client, xff=\"1.1.1.1\")\n    assert _call(req, frozenset({\"10.0.0.1\"})) == untrusted_client\n\n\n# ---------------------------------------------------------------------------\n# No client (transport layer provides no address)\n# ---------------------------------------------------------------------------\n\n\ndef test_no_client_returns_unique_token():\n    \"\"\"When request.client is None, each call returns a unique rate-limit sentinel.\"\"\"\n    req1 = _make_request(None)\n    req2 = _make_request(None)\n    ip1 = _call(req1, frozenset())\n    ip2 = _call(req2, frozenset())\n    assert ip1.startswith(\"__no_ip_\")\n    assert ip2.startswith(\"__no_ip_\")\n    assert ip1 != ip2, \"Each missing-client request must get a distinct sentinel\"\n\n\n# ---------------------------------------------------------------------------\n# Whitespace in XFF values\n# ---------------------------------------------------------------------------\n\n\ndef test_xff_with_extra_whitespace_trimmed():\n    \"\"\"IPs in XFF with leading/trailing spaces are handled correctly.\"\"\"\n    proxy = \"10.0.0.1\"\n    client = \"192.0.2.33\"\n    req = _make_request(proxy, xff=f\"  {client}  ,  {proxy}  \")\n    assert _call(req, frozenset({proxy})) == client\n"
  },
  {
    "path": "backend/tests/integration/test_cloud_auth.py",
    "content": "\"\"\"Integration tests for per-user cloud credentials and cloud endpoint permissions.\n\nRegression tests for:\n- Per-user cloud token storage (when auth enabled)\n- Global fallback (when auth disabled)\n- Cloud endpoints use CLOUD_AUTH permission (not SETTINGS_READ)\n\"\"\"\n\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestPerUserCloudCredentials:\n    \"\"\"Tests that cloud credentials are stored per-user when auth is enabled.\"\"\"\n\n    @pytest.fixture\n    async def user_with_cloud_auth(self, db_session):\n        \"\"\"Create a user with CLOUD_AUTH permission via a group.\"\"\"\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.group import Group\n        from backend.app.models.user import User\n\n        group = Group(\n            name=\"CloudUsers\",\n            permissions=[\"cloud:auth\", \"filaments:read\", \"printers:read\", \"firmware:read\"],\n        )\n        db_session.add(group)\n        await db_session.flush()\n\n        user = User(\n            username=\"clouduser\",\n            password_hash=get_password_hash(\"testpass123\"),\n            role=\"user\",\n        )\n        db_session.add(user)\n        await db_session.flush()\n        user.groups.append(group)\n        await db_session.commit()\n        await db_session.refresh(user)\n        return user\n\n    @pytest.fixture\n    async def second_user_with_cloud_auth(self, db_session):\n        \"\"\"Create a second user with CLOUD_AUTH permission.\"\"\"\n        from sqlalchemy import select\n\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.group import Group\n        from backend.app.models.user import User\n\n        result = await db_session.execute(select(Group).where(Group.name == \"CloudUsers\"))\n        group = result.scalar_one_or_none()\n        if not group:\n            group = Group(\n                name=\"CloudUsers2\",\n                permissions=[\"cloud:auth\", \"filaments:read\", \"printers:read\", \"firmware:read\"],\n            )\n            db_session.add(group)\n            await db_session.flush()\n\n        user = User(\n            username=\"clouduser2\",\n            password_hash=get_password_hash(\"testpass456\"),\n            role=\"user\",\n        )\n        db_session.add(user)\n        await db_session.flush()\n        user.groups.append(group)\n        await db_session.commit()\n        await db_session.refresh(user)\n        return user\n\n    @pytest.fixture\n    async def cloud_auth_token(self, user_with_cloud_auth, async_client: AsyncClient):\n        \"\"\"Get auth token for user with cloud permissions.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"clouduser\", \"password\": \"testpass123\"},\n        )\n        if response.status_code == 200:\n            return response.json().get(\"access_token\")\n        return None\n\n    @pytest.fixture\n    async def second_auth_token(self, second_user_with_cloud_auth, async_client: AsyncClient):\n        \"\"\"Get auth token for second user.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"clouduser2\", \"password\": \"testpass456\"},\n        )\n        if response.status_code == 200:\n            return response.json().get(\"access_token\")\n        return None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cloud_status_returns_not_authenticated_by_default(self, async_client: AsyncClient):\n        \"\"\"Cloud status should show not authenticated when no token is stored.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            response = await async_client.get(\"/api/v1/cloud/status\")\n            assert response.status_code == 200\n            data = response.json()\n            assert data[\"is_authenticated\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cloud_status_accessible_when_auth_disabled(self, async_client: AsyncClient):\n        \"\"\"Cloud endpoints should work when auth is disabled (global fallback).\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            response = await async_client.get(\"/api/v1/cloud/status\")\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cloud_status_requires_auth_when_enabled(self, async_client: AsyncClient):\n        \"\"\"Cloud endpoints should require auth when auth is enabled.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=True):\n            response = await async_client.get(\"/api/v1/cloud/status\")\n            assert response.status_code == 401\n\n\nclass TestCloudEndpointPermissions:\n    \"\"\"Tests that cloud endpoints use CLOUD_AUTH permission, not SETTINGS_READ.\n\n    Uses JWT tokens created directly (not via login endpoint) to avoid\n    test infrastructure complexity with user creation across sessions.\n    \"\"\"\n\n    @pytest.fixture\n    async def settings_only_setup(self, async_client: AsyncClient):\n        \"\"\"Create user with settings:read but NOT cloud:auth, return JWT.\"\"\"\n        from backend.app.core.auth import create_access_token, get_password_hash\n        from backend.app.core.database import async_session\n        from backend.app.models.group import Group\n        from backend.app.models.user import User\n\n        async with async_session() as db:\n            group = Group(name=\"SettingsReaders\", permissions=[\"settings:read\"])\n            db.add(group)\n            user = User(\n                username=\"settingsuser\",\n                password_hash=get_password_hash(\"testpass123\"),\n                role=\"user\",\n            )\n            db.add(user)\n            await db.commit()\n            await db.refresh(group)\n            await db.refresh(user)\n\n            from sqlalchemy import text\n\n            await db.execute(\n                text(\"INSERT INTO user_groups (user_id, group_id) VALUES (:uid, :gid)\"),\n                {\"uid\": user.id, \"gid\": group.id},\n            )\n            await db.commit()\n\n        return create_access_token(data={\"sub\": \"settingsuser\"})\n\n    @pytest.fixture\n    async def cloud_only_setup(self, async_client: AsyncClient):\n        \"\"\"Create user with cloud:auth but NOT settings:read, return JWT.\"\"\"\n        from backend.app.core.auth import create_access_token, get_password_hash\n        from backend.app.core.database import async_session\n        from backend.app.models.group import Group\n        from backend.app.models.user import User\n\n        async with async_session() as db:\n            group = Group(name=\"CloudOnly\", permissions=[\"cloud:auth\"])\n            db.add(group)\n            user = User(\n                username=\"cloudonly\",\n                password_hash=get_password_hash(\"testpass123\"),\n                role=\"user\",\n            )\n            db.add(user)\n            await db.commit()\n            await db.refresh(group)\n            await db.refresh(user)\n\n            from sqlalchemy import text\n\n            await db.execute(\n                text(\"INSERT INTO user_groups (user_id, group_id) VALUES (:uid, :gid)\"),\n                {\"uid\": user.id, \"gid\": group.id},\n            )\n            await db.commit()\n\n        return create_access_token(data={\"sub\": \"cloudonly\"})\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cloud_settings_requires_cloud_auth_not_settings_read(\n        self, async_client: AsyncClient, settings_only_setup, cloud_only_setup\n    ):\n        \"\"\"GET /cloud/settings should require CLOUD_AUTH, not SETTINGS_READ.\n\n        Regression test: previously used SETTINGS_READ which blocked users who\n        had cloud:auth permission but not settings:read.\n        \"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=True):\n            # User with only settings:read should be denied\n            response = await async_client.get(\n                \"/api/v1/cloud/settings\",\n                headers={\"Authorization\": f\"Bearer {settings_only_setup}\"},\n            )\n            assert response.status_code == 403\n\n            # User with cloud:auth should be allowed (will get 401 since no cloud token,\n            # but NOT 403 — permission check passes)\n            response = await async_client.get(\n                \"/api/v1/cloud/settings\",\n                headers={\"Authorization\": f\"Bearer {cloud_only_setup}\"},\n            )\n            assert response.status_code == 401  # No cloud token, but permission OK\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cloud_status_requires_cloud_auth(\n        self, async_client: AsyncClient, settings_only_setup, cloud_only_setup\n    ):\n        \"\"\"GET /cloud/status should require CLOUD_AUTH.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=True):\n            # settings:read only → 403\n            response = await async_client.get(\n                \"/api/v1/cloud/status\",\n                headers={\"Authorization\": f\"Bearer {settings_only_setup}\"},\n            )\n            assert response.status_code == 403\n\n            # cloud:auth → 200\n            response = await async_client.get(\n                \"/api/v1/cloud/status\",\n                headers={\"Authorization\": f\"Bearer {cloud_only_setup}\"},\n            )\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cloud_fields_requires_cloud_auth(\n        self, async_client: AsyncClient, settings_only_setup, cloud_only_setup\n    ):\n        \"\"\"GET /cloud/fields should require CLOUD_AUTH, not SETTINGS_READ.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=True):\n            # settings:read only → 403\n            response = await async_client.get(\n                \"/api/v1/cloud/fields\",\n                headers={\"Authorization\": f\"Bearer {settings_only_setup}\"},\n            )\n            assert response.status_code == 403\n\n            # cloud:auth → 200\n            response = await async_client.get(\n                \"/api/v1/cloud/fields\",\n                headers={\"Authorization\": f\"Bearer {cloud_only_setup}\"},\n            )\n            assert response.status_code == 200\n\n\nclass TestCloudTokenStorage:\n    \"\"\"Unit-level tests for the token storage functions.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_get_stored_token_returns_none_when_no_user_no_global(self, db_session):\n        \"\"\"get_stored_token with user=None and no global token returns (None, None).\"\"\"\n        from backend.app.api.routes.cloud import get_stored_token\n\n        token, email, region = await get_stored_token(db_session, user=None)\n        assert token is None\n        assert email is None\n        assert region == \"global\"  # default for missing rows\n\n    @pytest.mark.asyncio\n    async def test_store_and_get_global_token(self, db_session):\n        \"\"\"store_token with user=None stores in global Settings table.\"\"\"\n        from backend.app.api.routes.cloud import get_stored_token, store_token\n\n        await store_token(db_session, \"test-token-123\", \"test@example.com\", \"global\", user=None)\n        token, email, region = await get_stored_token(db_session, user=None)\n        assert token == \"test-token-123\"\n        assert email == \"test@example.com\"\n        assert region == \"global\"\n\n    @pytest.mark.asyncio\n    async def test_store_and_get_per_user_token(self, db_session):\n        \"\"\"store_token with user stores on the user record.\"\"\"\n        from backend.app.api.routes.cloud import get_stored_token, store_token\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.user import User\n\n        user = User(username=\"tokentest\", password_hash=get_password_hash(\"pass\"), role=\"user\")\n        db_session.add(user)\n        await db_session.commit()\n        await db_session.refresh(user)\n\n        await store_token(db_session, \"user-token-abc\", \"user@example.com\", \"global\", user=user)\n\n        # Re-fetch user to verify persistence\n        from sqlalchemy import select\n\n        result = await db_session.execute(select(User).where(User.id == user.id))\n        refreshed = result.scalar_one()\n        assert refreshed.cloud_token == \"user-token-abc\"\n        assert refreshed.cloud_email == \"user@example.com\"\n        assert refreshed.cloud_region == \"global\"\n\n    @pytest.mark.asyncio\n    async def test_per_user_token_does_not_affect_global(self, db_session):\n        \"\"\"Storing per-user token should not affect global Settings.\"\"\"\n        from backend.app.api.routes.cloud import get_stored_token, store_token\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.user import User\n\n        user = User(username=\"isolationtest\", password_hash=get_password_hash(\"pass\"), role=\"user\")\n        db_session.add(user)\n        await db_session.commit()\n        await db_session.refresh(user)\n\n        # Store per-user token\n        await store_token(db_session, \"per-user-token\", \"per-user@test.com\", \"global\", user=user)\n\n        # Global should still be empty\n        global_token, global_email, _ = await get_stored_token(db_session, user=None)\n        assert global_token is None\n        assert global_email is None\n\n    @pytest.mark.asyncio\n    async def test_clear_per_user_token(self, db_session):\n        \"\"\"clear_token with user clears only that user's credentials.\"\"\"\n        from backend.app.api.routes.cloud import clear_token, store_token\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.user import User\n\n        user = User(username=\"cleartest\", password_hash=get_password_hash(\"pass\"), role=\"user\")\n        db_session.add(user)\n        await db_session.commit()\n        await db_session.refresh(user)\n\n        await store_token(db_session, \"to-clear\", \"clear@test.com\", \"china\", user=user)\n        await clear_token(db_session, user=user)\n\n        from sqlalchemy import select\n\n        result = await db_session.execute(select(User).where(User.id == user.id))\n        refreshed = result.scalar_one()\n        assert refreshed.cloud_token is None\n        assert refreshed.cloud_email is None\n        assert refreshed.cloud_region is None\n\n    @pytest.mark.asyncio\n    async def test_clear_global_token(self, db_session):\n        \"\"\"clear_token with user=None clears from global Settings.\"\"\"\n        from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token\n\n        await store_token(db_session, \"global-token\", \"global@test.com\", \"global\", user=None)\n        await clear_token(db_session, user=None)\n\n        token, email, region = await get_stored_token(db_session, user=None)\n        assert token is None\n        assert email is None\n        assert region == \"global\"  # normalised default\n\n    @pytest.mark.asyncio\n    async def test_two_users_independent_tokens(self, db_session):\n        \"\"\"Two users should have completely independent cloud tokens and regions.\"\"\"\n        from backend.app.api.routes.cloud import get_stored_token, store_token\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.user import User\n\n        user_a = User(username=\"user_a\", password_hash=get_password_hash(\"pass\"), role=\"user\")\n        user_b = User(username=\"user_b\", password_hash=get_password_hash(\"pass\"), role=\"user\")\n        db_session.add_all([user_a, user_b])\n        await db_session.commit()\n        await db_session.refresh(user_a)\n        await db_session.refresh(user_b)\n\n        # Different regions on purpose — a China user and a Global user must not\n        # bleed their region into each other's lookups.\n        await store_token(db_session, \"token-a\", \"a@test.com\", \"china\", user=user_a)\n        await store_token(db_session, \"token-b\", \"b@test.com\", \"global\", user=user_b)\n\n        # Verify each user reads their own token (re-fetch from DB)\n        from sqlalchemy import select\n\n        result_a = await db_session.execute(select(User).where(User.id == user_a.id))\n        result_b = await db_session.execute(select(User).where(User.id == user_b.id))\n        fresh_a = result_a.scalar_one()\n        fresh_b = result_b.scalar_one()\n\n        token_a, email_a, region_a = await get_stored_token(db_session, user=fresh_a)\n        token_b, email_b, region_b = await get_stored_token(db_session, user=fresh_b)\n\n        assert token_a == \"token-a\"\n        assert email_a == \"a@test.com\"\n        assert region_a == \"china\"\n        assert token_b == \"token-b\"\n        assert email_b == \"b@test.com\"\n        assert region_b == \"global\"\n\n\nclass TestCloudRegionPersistence:\n    \"\"\"Region must survive a DB round-trip so restarts don't silently flip users to api.bambulab.com.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_region_survives_roundtrip_per_user(self, db_session):\n        \"\"\"Stored China region is returned on subsequent get_stored_token calls.\"\"\"\n        from backend.app.api.routes.cloud import get_stored_token, store_token\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.user import User\n\n        user = User(username=\"region-user\", password_hash=get_password_hash(\"pass\"), role=\"user\")\n        db_session.add(user)\n        await db_session.commit()\n        await db_session.refresh(user)\n\n        await store_token(db_session, \"cn-token\", \"token-auth\", \"china\", user=user)\n\n        # Simulate \"next request\": re-fetch the user fresh from the DB.\n        from sqlalchemy import select\n\n        result = await db_session.execute(select(User).where(User.id == user.id))\n        refreshed = result.scalar_one()\n\n        _token, _email, region = await get_stored_token(db_session, user=refreshed)\n        assert region == \"china\"\n\n    @pytest.mark.asyncio\n    async def test_region_survives_roundtrip_global_fallback(self, db_session):\n        \"\"\"Stored China region in auth-disabled Settings fallback survives too.\"\"\"\n        from backend.app.api.routes.cloud import get_stored_token, store_token\n\n        await store_token(db_session, \"cn-token\", \"token-auth\", \"china\", user=None)\n        _token, _email, region = await get_stored_token(db_session, user=None)\n        assert region == \"china\"\n\n    @pytest.mark.asyncio\n    async def test_invalid_region_is_normalised_to_global(self, db_session):\n        \"\"\"Unknown region values fall back to 'global' rather than mis-route.\"\"\"\n        from backend.app.api.routes.cloud import get_stored_token, store_token\n\n        await store_token(db_session, \"t\", \"x@test.com\", \"mars\", user=None)\n        _token, _email, region = await get_stored_token(db_session, user=None)\n        assert region == \"global\"\n\n    @pytest.mark.asyncio\n    async def test_build_authenticated_cloud_uses_stored_region(self, db_session):\n        \"\"\"build_authenticated_cloud wires the stored region into the per-request service.\"\"\"\n        from backend.app.api.routes.cloud import build_authenticated_cloud, store_token\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.user import User\n\n        user = User(username=\"cn-build\", password_hash=get_password_hash(\"pass\"), role=\"user\")\n        db_session.add(user)\n        await db_session.commit()\n        await db_session.refresh(user)\n\n        await store_token(db_session, \"cn-token\", \"token-auth\", \"china\", user=user)\n\n        from sqlalchemy import select\n\n        result = await db_session.execute(select(User).where(User.id == user.id))\n        refreshed = result.scalar_one()\n\n        cloud = await build_authenticated_cloud(db_session, refreshed)\n        assert cloud is not None\n        try:\n            assert cloud.base_url == \"https://api.bambulab.cn\"\n            assert cloud.access_token == \"cn-token\"\n        finally:\n            await cloud.close()\n\n\nclass TestCloudRouteRegionPlumbing:\n    \"\"\"Route-level proof that region=china on the wire actually steers outbound\n    HTTP calls to api.bambulab.cn / bambulab.cn. This is the core bug the PR\n    fixes — unit tests prove the service does the right thing given the region,\n    storage tests prove the region persists, but only these tests prove the\n    route handlers plumb the region through end-to-end.\n\n    Auth is disabled (Settings-fallback path) to keep the fixture footprint\n    minimal; the region plumbing code path is identical for the per-user path.\n    \"\"\"\n\n    @staticmethod\n    def _capturing_client(response_json: dict, status: int = 200):\n        \"\"\"Build an httpx.AsyncClient backed by MockTransport that records every\n        outbound request URL. Returns ``(client, captured_urls)``.\n\n        Using MockTransport (rather than ``patch.object(httpx.AsyncClient, ...)``)\n        is critical: class-level method patches also intercept the ASGI test\n        client's own requests, so the route handler never runs and the\n        assertions end up inspecting the test-client URL instead of the\n        backend's outbound URL. MockTransport only affects the client we\n        inject into the backend via ``set_shared_http_client``.\n        \"\"\"\n        import httpx\n\n        captured: list[str] = []\n\n        def handler(request: httpx.Request) -> httpx.Response:\n            captured.append(str(request.url))\n            return httpx.Response(status, json=response_json)\n\n        client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n        return client, captured\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_set_token_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient):\n        \"\"\"POST /cloud/token with region=china routes get_user_profile to api.bambulab.cn.\"\"\"\n        from backend.app.services.bambu_cloud import set_shared_http_client\n\n        mock_client, captured_urls = self._capturing_client({\"uid\": \"123\", \"email\": \"x\"})\n        set_shared_http_client(mock_client)\n        try:\n            with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n                response = await async_client.post(\n                    \"/api/v1/cloud/token\",\n                    json={\"access_token\": \"cn-token\", \"region\": \"china\"},\n                )\n\n                assert response.status_code == 200\n                assert any(\"api.bambulab.cn\" in url for url in captured_urls), captured_urls\n                assert not any(\"api.bambulab.com\" in url for url in captured_urls), captured_urls\n        finally:\n            set_shared_http_client(None)\n            await mock_client.aclose()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient):\n        \"\"\"POST /cloud/login with region=china routes login_request to api.bambulab.cn.\"\"\"\n        from backend.app.services.bambu_cloud import set_shared_http_client\n\n        mock_client, captured_urls = self._capturing_client({\"loginType\": \"verifyCode\"})\n        set_shared_http_client(mock_client)\n        try:\n            with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n                response = await async_client.post(\n                    \"/api/v1/cloud/login\",\n                    json={\"email\": \"user@example.com\", \"password\": \"x\", \"region\": \"china\"},\n                )\n\n                assert response.status_code == 200\n                assert any(\"api.bambulab.cn\" in url for url in captured_urls), captured_urls\n                assert not any(\"api.bambulab.com\" in url for url in captured_urls), captured_urls\n        finally:\n            set_shared_http_client(None)\n            await mock_client.aclose()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_verify_route_with_china_region_hits_cn_tfa_endpoint(self, async_client: AsyncClient):\n        \"\"\"POST /cloud/verify with region=china + tfa_key routes TOTP to bambulab.cn.\"\"\"\n        from backend.app.services.bambu_cloud import set_shared_http_client\n\n        mock_client, captured_urls = self._capturing_client({\"token\": \"t\"})\n        set_shared_http_client(mock_client)\n        try:\n            with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n                response = await async_client.post(\n                    \"/api/v1/cloud/verify\",\n                    json={\n                        \"email\": \"user@example.com\",\n                        \"code\": \"123456\",\n                        \"tfa_key\": \"tfa-xyz\",\n                        \"region\": \"china\",\n                    },\n                )\n\n                assert response.status_code == 200\n                # TOTP endpoint lives on bambulab.cn (without the api. prefix),\n                # NOT bambulab.com — that's exactly the bug we just fixed.\n                assert any(\"bambulab.cn/api/sign-in/tfa\" in url for url in captured_urls), captured_urls\n                assert not any(\"bambulab.com\" in url for url in captured_urls), captured_urls\n        finally:\n            set_shared_http_client(None)\n            await mock_client.aclose()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cloud_status_exposes_stored_region(self, async_client: AsyncClient):\n        \"\"\"GET /cloud/status returns the stored region so the UI can render\n        'Connected (China)' after a reload.\"\"\"\n        from backend.app.api.routes.cloud import store_token\n        from backend.app.core.database import async_session\n\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            async with async_session() as db:\n                await store_token(db, \"cn-token\", \"token-auth\", \"china\", user=None)\n\n            response = await async_client.get(\"/api/v1/cloud/status\")\n            assert response.status_code == 200\n            data = response.json()\n            assert data[\"is_authenticated\"] is True\n            assert data[\"region\"] == \"china\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cloud_status_region_is_null_when_unauthenticated(self, async_client: AsyncClient):\n        \"\"\"No stored token ⇒ no region in the status payload.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            response = await async_client.get(\"/api/v1/cloud/status\")\n            assert response.status_code == 200\n            data = response.json()\n            assert data[\"is_authenticated\"] is False\n            assert data[\"region\"] is None\n"
  },
  {
    "path": "backend/tests/integration/test_color_map_api.py",
    "content": "\"\"\"Integration tests for GET /api/v1/inventory/colors/map — the lean color-name\nlookup endpoint the frontend uses to resolve hex → name synchronously (see #857).\n\nRegression guards for the behaviors the fix relies on:\n - Not gated on INVENTORY_READ (anyone authenticated can call it, otherwise the\n   login page and read-only views would fail to render color names).\n - Keys are normalized to lowercase 6-char hex without the '#' prefix.\n - When multiple catalog rows share a hex, Bambu Lab wins over generic brands so\n   the display name matches what users see in the slicer.\n - Default-seeded rows outrank user-added non-default rows on the same hex.\n - A17-R1 / F5B6CD resolves to \"Cherry Pink\" when catalog is seeded, the exact\n   scenario that triggered #857 on @lightmaster's install.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\nfrom backend.app.models.color_catalog import ColorCatalogEntry\n\n\nasync def _seed(db_session, entries):\n    for kwargs in entries:\n        db_session.add(ColorCatalogEntry(**kwargs))\n    await db_session.commit()\n\n\n@pytest.mark.asyncio\n@pytest.mark.integration\nasync def test_color_map_empty_catalog(async_client: AsyncClient):\n    \"\"\"Returns an empty mapping when the catalog has no rows.\"\"\"\n    response = await async_client.get(\"/api/v1/inventory/colors/map\")\n    assert response.status_code == 200\n    body = response.json()\n    assert body == {\"colors\": {}}\n\n\n@pytest.mark.asyncio\n@pytest.mark.integration\nasync def test_color_map_returns_lowercase_hex_without_hash(async_client: AsyncClient, db_session):\n    \"\"\"Catalog rows can store hex with or without '#' and in any case; the map\n    endpoint always emits lowercase 6-char hex without the '#' prefix so the\n    frontend can do direct dict lookups.\"\"\"\n    await _seed(\n        db_session,\n        [\n            {\n                \"manufacturer\": \"Bambu Lab\",\n                \"color_name\": \"Cherry Pink\",\n                \"hex_color\": \"#F5B6CD\",\n                \"material\": \"PLA Translucent\",\n                \"is_default\": True,\n            },\n            {\n                \"manufacturer\": \"Bambu Lab\",\n                \"color_name\": \"Scarlet Red\",\n                \"hex_color\": \"#DE4343\",\n                \"material\": \"PLA Matte\",\n                \"is_default\": True,\n            },\n        ],\n    )\n    response = await async_client.get(\"/api/v1/inventory/colors/map\")\n    assert response.status_code == 200\n    colors = response.json()[\"colors\"]\n    assert \"f5b6cd\" in colors\n    assert \"de4343\" in colors\n    assert colors[\"f5b6cd\"] == \"Cherry Pink\"\n    assert colors[\"de4343\"] == \"Scarlet Red\"\n    # No uppercase, no '#' keys\n    assert \"F5B6CD\" not in colors\n    assert \"#f5b6cd\" not in colors\n\n\n@pytest.mark.asyncio\n@pytest.mark.integration\nasync def test_color_map_bambu_wins_over_generic_on_same_hex(async_client: AsyncClient, db_session):\n    \"\"\"When a generic brand happens to share a hex with Bambu Lab, Bambu wins —\n    the canonical Bambu name is what the user expects to see on the AMS popup.\"\"\"\n    await _seed(\n        db_session,\n        [\n            {\n                \"manufacturer\": \"Generic\",\n                \"color_name\": \"Pinkish\",\n                \"hex_color\": \"#F5B6CD\",\n                \"material\": \"PLA\",\n                \"is_default\": False,\n            },\n            {\n                \"manufacturer\": \"Bambu Lab\",\n                \"color_name\": \"Cherry Pink\",\n                \"hex_color\": \"#F5B6CD\",\n                \"material\": \"PLA Translucent\",\n                \"is_default\": True,\n            },\n        ],\n    )\n    response = await async_client.get(\"/api/v1/inventory/colors/map\")\n    assert response.status_code == 200\n    assert response.json()[\"colors\"][\"f5b6cd\"] == \"Cherry Pink\"\n\n\n@pytest.mark.asyncio\n@pytest.mark.integration\nasync def test_color_map_default_wins_over_user_added(async_client: AsyncClient, db_session):\n    \"\"\"Within the same manufacturer, default-seeded rows outrank user-added rows\n    — the defaults are trusted and a user's custom alias shouldn't shadow the\n    canonical catalog entry.\"\"\"\n    await _seed(\n        db_session,\n        [\n            {\n                \"manufacturer\": \"Bambu Lab\",\n                \"color_name\": \"My Custom Name\",\n                \"hex_color\": \"#F5B6CD\",\n                \"material\": \"PLA\",\n                \"is_default\": False,\n            },\n            {\n                \"manufacturer\": \"Bambu Lab\",\n                \"color_name\": \"Cherry Pink\",\n                \"hex_color\": \"#F5B6CD\",\n                \"material\": \"PLA Translucent\",\n                \"is_default\": True,\n            },\n        ],\n    )\n    response = await async_client.get(\"/api/v1/inventory/colors/map\")\n    assert response.status_code == 200\n    assert response.json()[\"colors\"][\"f5b6cd\"] == \"Cherry Pink\"\n\n\n@pytest.mark.asyncio\n@pytest.mark.integration\nasync def test_color_map_skips_invalid_entries(async_client: AsyncClient, db_session):\n    \"\"\"Rows with missing hex or name must be silently dropped rather than crashing\n    the endpoint. Malformed data shouldn't take down every color name in the UI.\"\"\"\n    await _seed(\n        db_session,\n        [\n            # Too short to normalize to 6-char hex\n            {\n                \"manufacturer\": \"Bambu Lab\",\n                \"color_name\": \"Weird\",\n                \"hex_color\": \"#FFF\",\n                \"material\": None,\n                \"is_default\": False,\n            },\n            # Valid row that must still appear\n            {\n                \"manufacturer\": \"Bambu Lab\",\n                \"color_name\": \"Cherry Pink\",\n                \"hex_color\": \"#F5B6CD\",\n                \"material\": \"PLA Translucent\",\n                \"is_default\": True,\n            },\n        ],\n    )\n    response = await async_client.get(\"/api/v1/inventory/colors/map\")\n    assert response.status_code == 200\n    colors = response.json()[\"colors\"]\n    assert \"f5b6cd\" in colors\n    assert colors[\"f5b6cd\"] == \"Cherry Pink\"\n    # 3-char hex was dropped\n    assert \"fff\" not in colors\n"
  },
  {
    "path": "backend/tests/integration/test_cost_statistics.py",
    "content": "import pytest\nfrom httpx import AsyncClient\nfrom sqlalchemy import select\n\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.spool import Spool\nfrom backend.app.models.spool_assignment import SpoolAssignment\nfrom backend.app.models.spool_usage_history import SpoolUsageHistory\n\n\n@pytest.fixture(autouse=True)\ndef cleanup_test_archive_files():\n    yield\n    import glob\n    import os\n\n    # Remove any test archive files created in archives/test/\n    for f in glob.glob(\"archives/test/test_print*.3mf\"):\n        try:\n            os.remove(f)\n        except Exception:\n            pass\n\n\n\"\"\"Integration tests for cost tracking in archives and statistics.\n\nTests the full flow of cost tracking from usage to statistics:\n- Archive cost field populated correctly\n- Statistics endpoint aggregates costs\n- Completed vs failed prints cost handling\n\"\"\"\n\n\nclass TestArchiveCostTracking:\n    \"\"\"Tests for cost field in PrintArchive.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_has_cost_field(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        # Verify PrintArchive includes cost field in response.\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Test Archive\",\n            status=\"completed\",\n            cost=5.50,  # Set a cost\n        )\n\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"cost\" in result\n        assert result[\"cost\"] == 5.50\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_cost_null_when_not_set(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        # Verify cost is null when not set.\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Test Archive\",\n            status=\"completed\",\n            # cost not set\n        )\n\n        response = await async_client.get(f\"/api/v1/archives/{archive.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"cost\"] is None or result[\"cost\"] == 0\n        await db_session.rollback()\n\n\nclass TestStatisticsCostAggregation:\n    \"\"\"Tests for cost aggregation in statistics endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_statistics_includes_total_cost(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        # Verify statistics endpoint includes total_cost field.\n        printer = await printer_factory()\n\n        # Create archives with costs\n        await archive_factory(\n            printer.id,\n            status=\"completed\",\n            cost=2.50,\n            filament_used_grams=100.0,\n        )\n        await archive_factory(\n            printer.id,\n            status=\"completed\",\n            cost=3.75,\n            filament_used_grams=150.0,\n        )\n\n        response = await async_client.get(\"/api/v1/archives/stats\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"total_cost\" in result\n        assert result[\"total_cost\"] == 6.25\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_statistics_aggregates_costs_correctly(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        # Verify statistics correctly sums costs from all archives.\n        printer = await printer_factory()\n\n        # Create multiple archives with different costs\n        costs = [1.25, 2.50, 0.75, 5.00, 0.50]\n        for cost in costs:\n            await archive_factory(\n                printer.id,\n                status=\"completed\",\n                cost=cost,\n                filament_used_grams=50.0,\n            )\n\n        response = await async_client.get(\"/api/v1/archives/stats\")\n\n        assert response.status_code == 200\n        result = response.json()\n        expected_total = sum(costs)\n        assert result[\"total_cost\"] == expected_total\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_statistics_handles_null_costs(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        # Verify statistics handles archives with null costs gracefully.\n        printer = await printer_factory()\n\n        # Mix of archives with and without costs\n        await archive_factory(printer.id, status=\"completed\", cost=2.50)\n        await archive_factory(printer.id, status=\"completed\", cost=None)\n        await archive_factory(printer.id, status=\"completed\", cost=1.75)\n        await archive_factory(printer.id, status=\"completed\")  # No cost field\n\n        response = await async_client.get(\"/api/v1/archives/stats\")\n\n        assert response.status_code == 200\n        result = response.json()\n        # Should sum only non-null costs\n        assert result[\"total_cost\"] == 4.25\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_statistics_includes_failed_print_costs(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        # Verify failed prints with costs are included in statistics.\n        printer = await printer_factory()\n\n        await archive_factory(printer.id, status=\"completed\", cost=5.00)\n        await archive_factory(printer.id, status=\"failed\", cost=2.50)  # Failed but has cost\n        await archive_factory(printer.id, status=\"cancelled\", cost=1.00)\n\n        response = await async_client.get(\"/api/v1/archives/stats\")\n\n        assert response.status_code == 200\n        result = response.json()\n        # All prints should contribute to total cost\n        assert result[\"total_cost\"] == 8.50\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_statistics_zero_cost_when_no_archives(self, async_client: AsyncClient):\n        \"\"\"Verify total_cost is 0 when no archives exist.\"\"\"\n        response = await async_client.get(\"/api/v1/archives/stats\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"total_cost\"] == 0.0\n\n\nclass TestSpoolCostPersistence:\n    \"\"\"Tests for spool cost_per_kg field.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_spool_cost_fields_persist(self, async_client: AsyncClient, db_session):\n        # Verify cost_per_kg is saved and retrieved.\n        # Create a spool with cost\n        spool_data = {\n            \"material\": \"PLA\",\n            \"brand\": \"TestBrand\",\n            \"label_weight\": 1000,\n            \"core_weight\": 250,\n            \"cost_per_kg\": 25.50,\n        }\n\n        create_response = await async_client.post(\"/api/v1/inventory/spools\", json=spool_data)\n        assert create_response.status_code == 200\n        spool_id = create_response.json()[\"id\"]\n\n        # Retrieve and verify\n        get_response = await async_client.get(f\"/api/v1/inventory/spools/{spool_id}\")\n        assert get_response.status_code == 200\n        result = get_response.json()\n\n        assert result[\"cost_per_kg\"] == 25.50\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_spool_update_cost_fields(self, async_client: AsyncClient, db_session):\n        # Verify cost fields can be updated.\n        # Create spool without cost\n        spool_data = {\n            \"material\": \"PETG\",\n            \"brand\": \"TestBrand\",\n            \"label_weight\": 1000,\n            \"core_weight\": 250,\n        }\n\n        create_response = await async_client.post(\"/api/v1/inventory/spools\", json=spool_data)\n        assert create_response.status_code == 200\n        spool_id = create_response.json()[\"id\"]\n\n        # Update with cost\n        update_data = {\n            \"cost_per_kg\": 30.00,\n        }\n\n        update_response = await async_client.patch(f\"/api/v1/inventory/spools/{spool_id}\", json=update_data)\n        assert update_response.status_code == 200\n\n        result = update_response.json()\n        assert result[\"cost_per_kg\"] == 30.00\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_spool_cost_null_by_default(self, async_client: AsyncClient, db_session):\n        # Verify cost_per_kg defaults to null when not provided.\n        spool_data = {\n            \"material\": \"ABS\",\n            \"label_weight\": 1000,\n            \"core_weight\": 250,\n        }\n\n        create_response = await async_client.post(\"/api/v1/inventory/spools\", json=spool_data)\n        assert create_response.status_code == 200\n\n        result = create_response.json()\n        assert result[\"cost_per_kg\"] is None\n        await db_session.rollback()\n\n\nclass TestCostCalculationScenarios:\n    \"\"\"End-to-end tests for various cost calculation scenarios.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cost_with_multiple_colors(self, async_client: AsyncClient, printer_factory, db_session):\n        # Verify cost tracking works for multi-color prints.\n\n        # Create two spools with different costs\n        spool1_data = {\n            \"material\": \"ABS\",\n            \"brand\": \"TestBrand\",\n            \"label_weight\": 1000,\n            \"core_weight\": 250,\n            \"cost_per_kg\": 20.00,\n        }\n        spool2_data = {\n            \"material\": \"PLA\",\n            \"label_weight\": 1000,\n            \"core_weight\": 250,\n            \"cost_per_kg\": 25.00,\n        }\n\n        spool1_response = await async_client.post(\"/api/v1/inventory/spools\", json=spool1_data)\n        spool2_response = await async_client.post(\"/api/v1/inventory/spools\", json=spool2_data)\n\n        assert spool1_response.status_code == 200\n        assert spool2_response.status_code == 200\n\n        # Verify spools created with correct costs\n        assert spool1_response.json()[\"cost_per_kg\"] == 20.00\n        assert spool2_response.json()[\"cost_per_kg\"] == 25.00\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cost_precision(self, async_client: AsyncClient, db_session):\n        # Verify cost calculations maintain proper precision.\n        # Create spool with specific cost\n        spool_data = {\n            \"material\": \"PLA\",\n            \"brand\": \"TestBrand\",\n            \"label_weight\": 1000,\n            \"core_weight\": 250,\n            \"cost_per_kg\": 19.99,  # Specific price\n        }\n\n        response = await async_client.post(\"/api/v1/inventory/spools\", json=spool_data)\n        assert response.status_code == 200\n\n        result = response.json()\n        # Verify precision is maintained\n        assert result[\"cost_per_kg\"] == 19.99\n        await db_session.rollback()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_cost_with_archive_id_and_print_name(\n        self, async_client, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Test archive cost recalculation using both archive_id and print_name fallback.\"\"\"\n        from backend.app.models.spool import Spool\n        from backend.app.models.spool_usage_history import SpoolUsageHistory\n\n        printer = await printer_factory()\n\n        # Create spools and commit\n        spool_new = Spool(\n            material=\"PLA\",\n            brand=\"BrandA\",\n            label_weight=1000,\n            core_weight=250,\n            cost_per_kg=20.0,\n        )\n        spool_old = Spool(\n            material=\"ABS\",\n            brand=\"BrandB\",\n            label_weight=1000,\n            core_weight=250,\n            cost_per_kg=15.0,\n        )\n        db_session.add_all([spool_new, spool_old])\n        await db_session.commit()\n        await db_session.refresh(spool_new)\n        await db_session.refresh(spool_old)\n\n        # Create archive with new SpoolUsageHistory (archive_id set)\n        archive_new = await archive_factory(\n            printer.id,\n            print_name=\"UniquePrint\",\n            status=\"completed\",\n            cost=None,\n        )\n\n        history_new = SpoolUsageHistory(\n            spool_id=spool_new.id,\n            printer_id=printer.id,\n            print_name=\"UniquePrint\",\n            weight_used=20.0,\n            percent_used=20,\n            status=\"completed\",\n            cost=0.50,\n            archive_id=archive_new.id,\n        )\n        db_session.add(history_new)\n\n        # Create archive with old SpoolUsageHistory (archive_id NULL — legacy record)\n        archive_old = await archive_factory(\n            printer.id,\n            print_name=\"LegacyPrint\",\n            status=\"completed\",\n            cost=None,\n        )\n        archive_old.filament_used_grams = 30.0\n        await db_session.commit()\n\n        history_old = SpoolUsageHistory(\n            spool_id=spool_old.id,\n            printer_id=printer.id,\n            print_name=\"LegacyPrint\",\n            weight_used=30.0,\n            percent_used=30,\n            status=\"completed\",\n            cost=0.45,\n            archive_id=None,\n        )\n        db_session.add(history_old)\n\n        await db_session.commit()\n\n        # Recalculate costs for all archives\n        recalc_response = await async_client.post(\"/api/v1/archives/recalculate-costs\")\n        assert recalc_response.status_code == 200\n        assert recalc_response.json()[\"updated\"] >= 1\n\n        # Verify archive_new cost from archive_id-linked SpoolUsageHistory\n        response_new = await async_client.get(f\"/api/v1/archives/{archive_new.id}\")\n        assert response_new.status_code == 200\n        assert response_new.json()[\"cost\"] == 0.50\n\n        # Verify archive_old cost from legacy print_name fallback\n        response_old = await async_client.get(f\"/api/v1/archives/{archive_old.id}\")\n        assert response_old.status_code == 200\n        assert response_old.json()[\"cost\"] == 0.45\n\n        await db_session.rollback()\n"
  },
  {
    "path": "backend/tests/integration/test_discovery_api.py",
    "content": "\"\"\"Integration tests for Discovery API endpoints.\n\nTests the full request/response cycle for /api/v1/discovery/ endpoints.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestDiscoveryAPI:\n    \"\"\"Integration tests for /api/v1/discovery/ endpoints.\"\"\"\n\n    # ========================================================================\n    # Info endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_discovery_info(self, async_client: AsyncClient):\n        \"\"\"Verify discovery info endpoint returns expected fields.\"\"\"\n        response = await async_client.get(\"/api/v1/discovery/info\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"is_docker\" in data\n        assert \"ssdp_running\" in data\n        assert \"scan_running\" in data\n        assert \"subnets\" in data\n        assert isinstance(data[\"is_docker\"], bool)\n        assert isinstance(data[\"ssdp_running\"], bool)\n        assert isinstance(data[\"scan_running\"], bool)\n        assert isinstance(data[\"subnets\"], list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_discovery_info_subnets_are_cidr(self, async_client: AsyncClient):\n        \"\"\"Verify subnets are valid CIDR notation strings.\"\"\"\n        response = await async_client.get(\"/api/v1/discovery/info\")\n\n        assert response.status_code == 200\n        data = response.json()\n        for subnet in data[\"subnets\"]:\n            assert isinstance(subnet, str)\n            # Should contain a slash for CIDR notation\n            assert \"/\" in subnet, f\"Subnet {subnet} is not in CIDR notation\"\n\n    # ========================================================================\n    # SSDP Discovery endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_discovery_status(self, async_client: AsyncClient):\n        \"\"\"Verify SSDP discovery status endpoint works.\"\"\"\n        response = await async_client.get(\"/api/v1/discovery/status\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n        assert isinstance(data[\"running\"], bool)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_start_discovery(self, async_client: AsyncClient):\n        \"\"\"Verify SSDP discovery can be started.\"\"\"\n        response = await async_client.post(\"/api/v1/discovery/start?duration=1.0\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_discovery(self, async_client: AsyncClient):\n        \"\"\"Verify SSDP discovery can be stopped.\"\"\"\n        response = await async_client.post(\"/api/v1/discovery/stop\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n        assert data[\"running\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_discovered_printers_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list when no printers discovered.\"\"\"\n        response = await async_client.get(\"/api/v1/discovery/printers\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n\n    # ========================================================================\n    # Subnet scanning endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_start_subnet_scan(self, async_client: AsyncClient):\n        \"\"\"Verify subnet scan can be started.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/discovery/scan\",\n            json={\"subnet\": \"192.168.1.0/30\", \"timeout\": 0.1},  # Small subnet for testing\n        )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n        assert \"scanned\" in data\n        assert \"total\" in data\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_scan_status(self, async_client: AsyncClient):\n        \"\"\"Verify subnet scan status endpoint works.\"\"\"\n        response = await async_client.get(\"/api/v1/discovery/scan/status\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n        assert \"scanned\" in data\n        assert \"total\" in data\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_subnet_scan(self, async_client: AsyncClient):\n        \"\"\"Verify subnet scan can be stopped.\"\"\"\n        response = await async_client.post(\"/api/v1/discovery/scan/stop\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_subnet_scan_invalid_subnet(self, async_client: AsyncClient):\n        \"\"\"Verify invalid subnet format is rejected.\"\"\"\n        response = await async_client.post(\"/api/v1/discovery/scan\", json={\"subnet\": \"invalid-subnet\", \"timeout\": 1.0})\n\n        # Should return 422 validation error or 200 with empty results\n        assert response.status_code in [200, 422]\n\n\nclass TestDiscoveryService:\n    \"\"\"Unit tests for discovery service functionality.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_docker_detection_fields(self, async_client: AsyncClient):\n        \"\"\"Verify Docker detection returns consistent response.\"\"\"\n        # Call multiple times to ensure consistency\n        response1 = await async_client.get(\"/api/v1/discovery/info\")\n        response2 = await async_client.get(\"/api/v1/discovery/info\")\n\n        assert response1.status_code == 200\n        assert response2.status_code == 200\n        assert response1.json()[\"is_docker\"] == response2.json()[\"is_docker\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_subnets_consistent_across_calls(self, async_client: AsyncClient):\n        \"\"\"Verify subnet detection returns consistent results.\"\"\"\n        response1 = await async_client.get(\"/api/v1/discovery/info\")\n        response2 = await async_client.get(\"/api/v1/discovery/info\")\n\n        assert response1.status_code == 200\n        assert response2.status_code == 200\n        assert response1.json()[\"subnets\"] == response2.json()[\"subnets\"]\n"
  },
  {
    "path": "backend/tests/integration/test_endpoint_auth.py",
    "content": "\"\"\"Integration tests for API endpoint authentication.\n\nTests that verify endpoints properly enforce authentication when auth is enabled,\nand allow access when auth is disabled (CVE-2026-25505 fix verification).\n\"\"\"\n\nfrom unittest.mock import patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestEndpointAuthenticationEnforcement:\n    \"\"\"Tests that endpoints enforce authentication when auth is enabled.\"\"\"\n\n    @pytest.fixture\n    async def user_factory(self, db_session):\n        \"\"\"Factory to create test users.\"\"\"\n\n        async def _create_user(**kwargs):\n            from passlib.hash import bcrypt\n\n            from backend.app.models.user import User\n\n            defaults = {\n                \"username\": \"testuser\",\n                \"password_hash\": bcrypt.hash(\"testpass123\"),\n                \"is_admin\": False,\n            }\n            defaults.update(kwargs)\n\n            user = User(**defaults)\n            db_session.add(user)\n            await db_session.commit()\n            await db_session.refresh(user)\n            return user\n\n        return _create_user\n\n    @pytest.fixture\n    async def admin_user(self, user_factory, db_session):\n        \"\"\"Create an admin user for testing.\"\"\"\n        from sqlalchemy import select\n\n        from backend.app.models.group import Group\n\n        # Get or create admin group\n        result = await db_session.execute(select(Group).where(Group.name == \"Administrators\"))\n        admin_group = result.scalar_one_or_none()\n\n        user = await user_factory(username=\"admin\", is_admin=True)\n        if admin_group:\n            user.groups.append(admin_group)\n            await db_session.commit()\n        return user\n\n    @pytest.fixture\n    async def auth_token(self, admin_user, async_client: AsyncClient):\n        \"\"\"Get a valid auth token for the admin user.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"admin\", \"password\": \"testpass123\"},\n        )\n        if response.status_code == 200:\n            return response.json().get(\"access_token\")\n        return None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_filaments_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify filaments list is accessible when auth is disabled.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            response = await async_client.get(\"/api/v1/filament-catalog/\")\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_external_links_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify external links list is accessible when auth is disabled.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            response = await async_client.get(\"/api/v1/external-links/\")\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_notifications_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify notifications list is accessible when auth is disabled.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            response = await async_client.get(\"/api/v1/notifications/\")\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_maintenance_types_accessible_without_auth_when_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify maintenance types is accessible when auth is disabled.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            response = await async_client.get(\"/api/v1/maintenance/types\")\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_system_info_accessible_without_auth_when_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify system info is accessible when auth is disabled.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            response = await async_client.get(\"/api/v1/system/info\")\n            assert response.status_code == 200\n\n\nclass TestImageEndpointsPublicAccess:\n    \"\"\"Tests that image endpoints remain accessible without auth.\n\n    These endpoints serve images via <img> tags which cannot send Authorization headers.\n    \"\"\"\n\n    @pytest.fixture\n    async def link_with_icon(self, db_session):\n        \"\"\"Create an external link with a custom icon for testing.\"\"\"\n        from backend.app.models.external_link import ExternalLink\n\n        link = ExternalLink(\n            name=\"Test Link\",\n            url=\"https://example.com\",\n            icon=\"Link\",\n            sort_order=0,\n            custom_icon=None,  # No custom icon set\n        )\n        db_session.add(link)\n        await db_session.commit()\n        await db_session.refresh(link)\n        return link\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_external_link_icon_returns_404_when_no_icon(self, async_client: AsyncClient, link_with_icon):\n        \"\"\"Verify icon endpoint returns 404 (not 401) when no icon is set.\n\n        This confirms the endpoint doesn't require auth - a 401 would indicate\n        auth is being enforced, but 404 means the endpoint is accessible but\n        no icon exists.\n        \"\"\"\n        response = await async_client.get(f\"/api/v1/external-links/{link_with_icon.id}/icon\")\n        # Should be 404 (no icon set), not 401 (unauthorized)\n        assert response.status_code == 404\n        assert \"No custom icon set\" in response.json().get(\"detail\", \"\")\n\n\nclass TestAuthenticationPatterns:\n    \"\"\"Tests for authentication helper functions and patterns.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_require_permission_if_auth_enabled_allows_access_when_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify require_permission_if_auth_enabled allows access when auth disabled.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            # Test a protected endpoint\n            response = await async_client.get(\"/api/v1/filament-catalog/\")\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_multiple_endpoints_accessible_when_auth_disabled(self, async_client: AsyncClient):\n        \"\"\"Verify multiple protected endpoints are accessible when auth is disabled.\"\"\"\n        with patch(\"backend.app.core.auth.is_auth_enabled\", return_value=False):\n            endpoints = [\n                \"/api/v1/filament-catalog/\",\n                \"/api/v1/external-links/\",\n                \"/api/v1/notifications/\",\n                \"/api/v1/maintenance/types\",\n            ]\n\n            for endpoint in endpoints:\n                response = await async_client.get(endpoint)\n                assert response.status_code == 200, f\"Endpoint {endpoint} should be accessible\"\n"
  },
  {
    "path": "backend/tests/integration/test_external_folders_api.py",
    "content": "\"\"\"Integration tests for External Folder API endpoints.\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestExternalFolderCreation:\n    \"\"\"Tests for POST /library/folders/external.\"\"\"\n\n    @pytest.fixture\n    def external_dir(self, tmp_path):\n        \"\"\"Create a temporary directory to act as an external folder.\"\"\"\n        ext_dir = tmp_path / \"nas_share\"\n        ext_dir.mkdir()\n        # Add some test files\n        (ext_dir / \"benchy.3mf\").write_bytes(b\"fake3mf\")\n        (ext_dir / \"bracket.stl\").write_bytes(b\"fakestl\")\n        (ext_dir / \"print.gcode\").write_text(\"G28\\nG1 X10 Y10\")\n        (ext_dir / \"readme.txt\").write_text(\"not a print file\")\n        (ext_dir / \".hidden.3mf\").write_bytes(b\"hidden\")\n        return ext_dir\n\n    @pytest.fixture\n    def nested_external_dir(self, external_dir):\n        \"\"\"Create a nested subdirectory in the external folder.\"\"\"\n        sub = external_dir / \"subfolder\"\n        sub.mkdir()\n        (sub / \"nested_part.stl\").write_bytes(b\"nestedstl\")\n        return external_dir\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_external_folder(self, async_client: AsyncClient, db_session, external_dir):\n        \"\"\"Verify external folder can be created with valid path.\"\"\"\n        data = {\n            \"name\": \"NAS Prints\",\n            \"external_path\": str(external_dir),\n            \"readonly\": True,\n            \"show_hidden\": False,\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"NAS Prints\"\n        assert result[\"is_external\"] is True\n        assert result[\"external_readonly\"] is True\n        assert result[\"external_show_hidden\"] is False\n        assert result[\"external_path\"] == str(external_dir.resolve())\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify 400 for non-existent path.\"\"\"\n        data = {\n            \"name\": \"Bad Path\",\n            \"external_path\": \"/nonexistent/path/that/does/not/exist\",\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        assert response.status_code == 400\n        assert \"does not exist\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify system directories are blocked.\"\"\"\n        data = {\n            \"name\": \"System\",\n            \"external_path\": \"/proc\",\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        assert response.status_code == 400\n        assert \"system directory\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_external_folder_file_not_dir(self, async_client: AsyncClient, db_session, tmp_path):\n        \"\"\"Verify 400 when path is a file, not directory.\"\"\"\n        file_path = tmp_path / \"not_a_dir.txt\"\n        file_path.write_text(\"hello\")\n        data = {\n            \"name\": \"Not A Dir\",\n            \"external_path\": str(file_path),\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        assert response.status_code == 400\n        assert \"not a directory\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_external_folder_duplicate_path(self, async_client: AsyncClient, db_session, external_dir):\n        \"\"\"Verify 409 when same path already linked.\"\"\"\n        data = {\n            \"name\": \"First\",\n            \"external_path\": str(external_dir),\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        assert response.status_code == 200\n\n        data[\"name\"] = \"Duplicate\"\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        assert response.status_code == 409\n        assert \"already exists\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_external_folder_appears_in_tree(self, async_client: AsyncClient, db_session, external_dir):\n        \"\"\"Verify external folder shows up in folder tree with external fields.\"\"\"\n        data = {\n            \"name\": \"My NAS\",\n            \"external_path\": str(external_dir),\n            \"readonly\": True,\n        }\n        await async_client.post(\"/api/v1/library/folders/external\", json=data)\n\n        response = await async_client.get(\"/api/v1/library/folders\")\n        assert response.status_code == 200\n        folders = response.json()\n        ext_folder = next((f for f in folders if f[\"name\"] == \"My NAS\"), None)\n        assert ext_folder is not None\n        assert ext_folder[\"is_external\"] is True\n        assert ext_folder[\"external_readonly\"] is True\n\n\ndef find_folder_in_tree(folders: list, name: str) -> dict | None:\n    \"\"\"Recursively search a folder tree for a folder by name.\"\"\"\n    for f in folders:\n        if f[\"name\"] == name:\n            return f\n        result = find_folder_in_tree(f.get(\"children\", []), name)\n        if result:\n            return result\n    return None\n\n\ndef collect_folder_names(folders: list) -> list[str]:\n    \"\"\"Recursively collect all folder names from a tree.\"\"\"\n    names = []\n    for f in folders:\n        names.append(f[\"name\"])\n        names.extend(collect_folder_names(f.get(\"children\", [])))\n    return names\n\n\nclass TestExternalFolderScan:\n    \"\"\"Tests for POST /library/folders/{id}/scan.\"\"\"\n\n    @pytest.fixture\n    def external_dir(self, tmp_path):\n        \"\"\"Create a temporary directory with test files.\"\"\"\n        ext_dir = tmp_path / \"prints\"\n        ext_dir.mkdir()\n        (ext_dir / \"benchy.3mf\").write_bytes(b\"fake3mf\")\n        (ext_dir / \"bracket.stl\").write_bytes(b\"fakestl\")\n        (ext_dir / \"print.gcode\").write_text(\"G28\\nG1 X10 Y10\")\n        (ext_dir / \"readme.txt\").write_text(\"not a print file\")\n        (ext_dir / \".hidden.3mf\").write_bytes(b\"hidden\")\n        sub = ext_dir / \"subfolder\"\n        sub.mkdir()\n        (sub / \"nested.stl\").write_bytes(b\"nested\")\n        return ext_dir\n\n    @pytest.fixture\n    async def external_folder(self, async_client, db_session, external_dir):\n        \"\"\"Create an external folder via API.\"\"\"\n        data = {\n            \"name\": \"Scan Test\",\n            \"external_path\": str(external_dir),\n            \"readonly\": True,\n            \"show_hidden\": False,\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        return response.json()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_discovers_files(self, async_client: AsyncClient, db_session, external_folder):\n        \"\"\"Verify scan discovers supported files and creates subfolders.\"\"\"\n        response = await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n        assert response.status_code == 200\n        result = response.json()\n        # Should find: benchy.3mf, bracket.stl, print.gcode (root) + subfolder/nested.stl\n        # Should skip: readme.txt (unsupported), .hidden.3mf (hidden)\n        assert result[\"added\"] == 4\n        assert result[\"removed\"] == 0\n\n        # Root folder should have 3 files (nested.stl is in subfolder)\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={external_folder['id']}\")\n        root_files = response.json()\n        assert len(root_files) == 3\n        root_filenames = {f[\"filename\"] for f in root_files}\n        assert root_filenames == {\"benchy.3mf\", \"bracket.stl\", \"print.gcode\"}\n\n        # Subfolder should exist in the tree and contain nested.stl\n        response = await async_client.get(\"/api/v1/library/folders\")\n        folders = response.json()\n        subfolder = find_folder_in_tree(folders, \"subfolder\")\n        assert subfolder is not None\n        assert subfolder[\"is_external\"] is True\n        assert subfolder[\"parent_id\"] == external_folder[\"id\"]\n\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={subfolder['id']}\")\n        sub_files = response.json()\n        assert len(sub_files) == 1\n        assert sub_files[0][\"filename\"] == \"nested.stl\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_skips_hidden_files(self, async_client: AsyncClient, db_session, external_folder):\n        \"\"\"Verify hidden files are skipped by default.\"\"\"\n        await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n\n        # List files in root folder\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={external_folder['id']}\")\n        assert response.status_code == 200\n        files = response.json()\n        filenames = [f[\"filename\"] for f in files]\n        assert \".hidden.3mf\" not in filenames\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_shows_hidden_when_enabled(self, async_client: AsyncClient, db_session, external_dir):\n        \"\"\"Verify hidden files found when show_hidden=True.\"\"\"\n        data = {\n            \"name\": \"Show Hidden Test\",\n            \"external_path\": str(external_dir),\n            \"show_hidden\": True,\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        folder = response.json()\n\n        response = await async_client.post(f\"/api/v1/library/folders/{folder['id']}/scan\")\n        result = response.json()\n        # Now should also find .hidden.3mf → 5 total\n        assert result[\"added\"] == 5\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_idempotent(self, async_client: AsyncClient, db_session, external_folder):\n        \"\"\"Verify scanning twice doesn't duplicate files.\"\"\"\n        response1 = await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n        assert response1.json()[\"added\"] == 4\n\n        response2 = await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n        assert response2.json()[\"added\"] == 0\n        assert response2.json()[\"removed\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_removes_deleted_files(\n        self, async_client: AsyncClient, db_session, external_folder, external_dir\n    ):\n        \"\"\"Verify scan removes entries for files no longer on disk.\"\"\"\n        await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n\n        # Delete a file from disk\n        (external_dir / \"bracket.stl\").unlink()\n\n        response = await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n        result = response.json()\n        assert result[\"removed\"] == 1\n        assert result[\"added\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify scan fails on regular (non-external) folder.\"\"\"\n        # Create a regular folder\n        data = {\"name\": \"Regular Folder\"}\n        response = await async_client.post(\"/api/v1/library/folders\", json=data)\n        folder = response.json()\n\n        response = await async_client.post(f\"/api/v1/library/folders/{folder['id']}/scan\")\n        assert response.status_code == 400\n        assert \"not an external\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):\n        \"\"\"Verify scanned files have is_external=True in root and subfolders.\"\"\"\n        await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n\n        # Check root folder files\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={external_folder['id']}\")\n        files = response.json()\n        assert len(files) > 0\n        for f in files:\n            assert f[\"is_external\"] is True\n\n        # Check subfolder files\n        response = await async_client.get(\"/api/v1/library/folders\")\n        folders = response.json()\n        subfolder = find_folder_in_tree(folders, \"subfolder\")\n        assert subfolder is not None\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={subfolder['id']}\")\n        sub_files = response.json()\n        for f in sub_files:\n            assert f[\"is_external\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_creates_nested_subfolders(self, async_client: AsyncClient, db_session, external_dir):\n        \"\"\"Verify deeply nested directories create correct folder hierarchy.\"\"\"\n        # Create nested structure: deep/nested/dir/model.stl\n        deep = external_dir / \"deep\" / \"nested\" / \"dir\"\n        deep.mkdir(parents=True)\n        (deep / \"model.stl\").write_bytes(b\"deepstl\")\n\n        data = {\n            \"name\": \"Nested Test\",\n            \"external_path\": str(external_dir),\n            \"readonly\": True,\n            \"show_hidden\": False,\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        root = response.json()\n\n        response = await async_client.post(f\"/api/v1/library/folders/{root['id']}/scan\")\n        assert response.status_code == 200\n\n        # Verify folder chain: root -> deep -> nested -> dir\n        response = await async_client.get(\"/api/v1/library/folders\")\n        all_folders = response.json()\n\n        deep = find_folder_in_tree(all_folders, \"deep\")\n        assert deep is not None\n        assert deep[\"parent_id\"] == root[\"id\"]\n        assert deep[\"is_external\"] is True\n\n        nested = find_folder_in_tree(all_folders, \"nested\")\n        assert nested is not None\n        assert nested[\"parent_id\"] == deep[\"id\"]\n\n        dir_folder = find_folder_in_tree(all_folders, \"dir\")\n        assert dir_folder is not None\n        assert dir_folder[\"parent_id\"] == nested[\"id\"]\n\n        # model.stl should be in the \"dir\" folder\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={dir_folder['id']}\")\n        files = response.json()\n        assert len(files) == 1\n        assert files[0][\"filename\"] == \"model.stl\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_skips_hidden_directories(self, async_client: AsyncClient, db_session, external_dir):\n        \"\"\"Verify hidden directories are skipped when show_hidden=False.\"\"\"\n        hidden_dir = external_dir / \".hidden_dir\"\n        hidden_dir.mkdir()\n        (hidden_dir / \"secret.stl\").write_bytes(b\"secret\")\n\n        data = {\n            \"name\": \"Hidden Dir Test\",\n            \"external_path\": str(external_dir),\n            \"readonly\": True,\n            \"show_hidden\": False,\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        root = response.json()\n\n        response = await async_client.post(f\"/api/v1/library/folders/{root['id']}/scan\")\n        result = response.json()\n        # Should find 4 files (root 3 + subfolder/nested.stl) but NOT .hidden_dir/secret.stl\n        assert result[\"added\"] == 4\n\n        # No \".hidden_dir\" folder should be created\n        response = await async_client.get(\"/api/v1/library/folders\")\n        folder_names = collect_folder_names(response.json())\n        assert \".hidden_dir\" not in folder_names\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_removes_deleted_subfolder(\n        self, async_client: AsyncClient, db_session, external_folder, external_dir\n    ):\n        \"\"\"Verify scan removes empty subfolder entries when directory deleted from disk.\"\"\"\n        await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n\n        # Verify subfolder exists\n        response = await async_client.get(\"/api/v1/library/folders\")\n        subfolder = find_folder_in_tree(response.json(), \"subfolder\")\n        assert subfolder is not None\n\n        # Delete the subfolder from disk\n        import shutil\n\n        shutil.rmtree(external_dir / \"subfolder\")\n\n        # Re-scan\n        response = await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n        result = response.json()\n        assert result[\"removed\"] == 1  # nested.stl removed\n\n        # Subfolder should be cleaned up (empty + directory gone)\n        response = await async_client.get(\"/api/v1/library/folders\")\n        subfolder = find_folder_in_tree(response.json(), \"subfolder\")\n        assert subfolder is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scan_subfolder_inherits_readonly(\n        self, async_client: AsyncClient, db_session, external_folder, external_dir\n    ):\n        \"\"\"Verify created subfolders inherit external_readonly from parent.\"\"\"\n        await async_client.post(f\"/api/v1/library/folders/{external_folder['id']}/scan\")\n\n        response = await async_client.get(\"/api/v1/library/folders\")\n        subfolder = find_folder_in_tree(response.json(), \"subfolder\")\n        assert subfolder is not None\n        assert subfolder[\"external_readonly\"] is True\n\n\nclass TestExternalFolderProtections:\n    \"\"\"Tests for read-only protections on external folders.\"\"\"\n\n    @pytest.fixture\n    def external_dir(self, tmp_path):\n        ext_dir = tmp_path / \"readonly_share\"\n        ext_dir.mkdir()\n        (ext_dir / \"test.stl\").write_bytes(b\"fakestl\")\n        return ext_dir\n\n    @pytest.fixture\n    async def readonly_folder(self, async_client, db_session, external_dir):\n        \"\"\"Create a read-only external folder with files scanned.\"\"\"\n        data = {\n            \"name\": \"Read Only\",\n            \"external_path\": str(external_dir),\n            \"readonly\": True,\n        }\n        response = await async_client.post(\"/api/v1/library/folders/external\", json=data)\n        folder = response.json()\n        await async_client.post(f\"/api/v1/library/folders/{folder['id']}/scan\")\n        return folder\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):\n        \"\"\"Verify uploads to read-only external folders are blocked.\"\"\"\n        import io\n\n        file_content = io.BytesIO(b\"test content\")\n        response = await async_client.post(\n            f\"/api/v1/library/files?folder_id={readonly_folder['id']}\",\n            files={\"file\": (\"test.gcode\", file_content, \"application/octet-stream\")},\n        )\n        assert response.status_code == 403\n        assert \"read-only\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):\n        \"\"\"Verify moving files to read-only external folder is blocked.\"\"\"\n        from backend.app.models.library import LibraryFile\n\n        # Create a regular file\n        lib_file = LibraryFile(\n            filename=\"regular.3mf\",\n            file_path=\"/test/regular.3mf\",\n            file_size=1024,\n            file_type=\"3mf\",\n        )\n        db_session.add(lib_file)\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n\n        data = {\"file_ids\": [lib_file.id], \"folder_id\": readonly_folder[\"id\"]}\n        response = await async_client.post(\"/api/v1/library/files/move\", json=data)\n        assert response.status_code == 403\n        assert \"read-only\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):\n        \"\"\"Verify external files can't be moved to other folders.\"\"\"\n        # Get the external file ID\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={readonly_folder['id']}\")\n        files = response.json()\n        assert len(files) > 0\n        ext_file_id = files[0][\"id\"]\n\n        # Try to move to root\n        data = {\"file_ids\": [ext_file_id], \"folder_id\": None}\n        response = await async_client.post(\"/api/v1/library/files/move\", json=data)\n        assert response.status_code == 200\n        # File should be skipped, not moved\n        result = response.json()\n        assert result[\"moved\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_external_file_removes_db_only(\n        self, async_client: AsyncClient, db_session, readonly_folder, external_dir\n    ):\n        \"\"\"Verify deleting an external file only removes DB entry, not the file on disk.\"\"\"\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={readonly_folder['id']}\")\n        files = response.json()\n        ext_file_id = files[0][\"id\"]\n        ext_filename = files[0][\"filename\"]\n\n        # Delete via API\n        response = await async_client.delete(f\"/api/v1/library/files/{ext_file_id}\")\n        assert response.status_code == 200\n\n        # File should still exist on disk\n        assert (external_dir / ext_filename).exists()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_external_folder_preserves_files(\n        self, async_client: AsyncClient, db_session, readonly_folder, external_dir\n    ):\n        \"\"\"Verify deleting an external folder doesn't delete files from disk.\"\"\"\n        response = await async_client.delete(f\"/api/v1/library/folders/{readonly_folder['id']}\")\n        assert response.status_code == 200\n\n        # Files should still exist on disk\n        assert (external_dir / \"test.stl\").exists()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):\n        \"\"\"Verify ZIP extraction to read-only external folder is blocked.\"\"\"\n        import io\n        import zipfile\n\n        # Create a minimal zip\n        buf = io.BytesIO()\n        with zipfile.ZipFile(buf, \"w\") as zf:\n            zf.writestr(\"test.stl\", b\"fakestl\")\n        buf.seek(0)\n\n        response = await async_client.post(\n            f\"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}\",\n            files={\"file\": (\"test.zip\", buf, \"application/zip\")},\n        )\n        assert response.status_code == 403\n        assert \"read-only\" in response.json()[\"detail\"].lower()\n"
  },
  {
    "path": "backend/tests/integration/test_external_links_api.py",
    "content": "\"\"\"Integration tests for External Links API endpoints.\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestExternalLinksAPI:\n    \"\"\"Integration tests for /api/v1/external-links endpoints.\"\"\"\n\n    @pytest.fixture\n    async def link_factory(self, db_session):\n        \"\"\"Factory to create test external links.\"\"\"\n        _counter = [0]\n\n        async def _create_link(**kwargs):\n            from backend.app.models.external_link import ExternalLink\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Test Link {counter}\",\n                \"url\": f\"https://example.com/{counter}\",\n                \"icon\": \"Link\",\n                \"sort_order\": counter,\n            }\n            defaults.update(kwargs)\n\n            link = ExternalLink(**defaults)\n            db_session.add(link)\n            await db_session.commit()\n            await db_session.refresh(link)\n            return link\n\n        return _create_link\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_external_links_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list when no links exist.\"\"\"\n        response = await async_client.get(\"/api/v1/external-links/\")\n        assert response.status_code == 200\n        assert isinstance(response.json(), list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_external_links_with_data(self, async_client: AsyncClient, link_factory, db_session):\n        \"\"\"Verify list returns existing links.\"\"\"\n        await link_factory(name=\"My Link\")\n        response = await async_client.get(\"/api/v1/external-links/\")\n        assert response.status_code == 200\n        data = response.json()\n        assert any(link[\"name\"] == \"My Link\" for link in data)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_external_link(self, async_client: AsyncClient):\n        \"\"\"Verify external link can be created.\"\"\"\n        data = {\n            \"name\": \"New Link\",\n            \"url\": \"https://new-link.example.com\",\n            \"icon\": \"ExternalLink\",\n        }\n        response = await async_client.post(\"/api/v1/external-links/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"New Link\"\n        assert result[\"url\"] == \"https://new-link.example.com\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_external_link(self, async_client: AsyncClient, link_factory, db_session):\n        \"\"\"Verify single link can be retrieved.\"\"\"\n        link = await link_factory(name=\"Get Test Link\")\n        response = await async_client.get(f\"/api/v1/external-links/{link.id}\")\n        assert response.status_code == 200\n        assert response.json()[\"name\"] == \"Get Test Link\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_external_link_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent link.\"\"\"\n        response = await async_client.get(\"/api/v1/external-links/9999\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_external_link(self, async_client: AsyncClient, link_factory, db_session):\n        \"\"\"Verify link can be updated.\"\"\"\n        link = await link_factory(name=\"Original\")\n        response = await async_client.patch(\n            f\"/api/v1/external-links/{link.id}\", json={\"name\": \"Updated\", \"url\": \"https://updated.example.com\"}\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"Updated\"\n        assert result[\"url\"] == \"https://updated.example.com\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_external_link(self, async_client: AsyncClient, link_factory, db_session):\n        \"\"\"Verify link can be deleted.\"\"\"\n        link = await link_factory()\n        response = await async_client.delete(f\"/api/v1/external-links/{link.id}\")\n        assert response.status_code == 200\n        # Verify deleted\n        response = await async_client.get(f\"/api/v1/external-links/{link.id}\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reorder_external_links(self, async_client: AsyncClient, link_factory, db_session):\n        \"\"\"Verify links can be reordered.\"\"\"\n        link1 = await link_factory(name=\"Link 1\")\n        link2 = await link_factory(name=\"Link 2\")\n        link3 = await link_factory(name=\"Link 3\")\n\n        # Reorder: 3, 1, 2\n        response = await async_client.put(\n            \"/api/v1/external-links/reorder\", json={\"ids\": [link3.id, link1.id, link2.id]}\n        )\n        assert response.status_code == 200\n        data = response.json()\n        # First link should be link3\n        assert data[0][\"id\"] == link3.id\n        assert data[0][\"sort_order\"] == 0\n\n\nclass TestExternalLinksIconAPI:\n    \"\"\"Tests for external link icon upload/delete.\"\"\"\n\n    @pytest.fixture\n    async def link_factory(self, db_session):\n        \"\"\"Factory to create test external links.\"\"\"\n\n        async def _create_link(**kwargs):\n            from backend.app.models.external_link import ExternalLink\n\n            defaults = {\n                \"name\": \"Icon Test Link\",\n                \"url\": \"https://example.com\",\n                \"icon\": \"Link\",\n                \"sort_order\": 0,\n            }\n            defaults.update(kwargs)\n\n            link = ExternalLink(**defaults)\n            db_session.add(link)\n            await db_session.commit()\n            await db_session.refresh(link)\n            return link\n\n        return _create_link\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_icon_not_set(self, async_client: AsyncClient, link_factory, db_session):\n        \"\"\"Verify 404 when no custom icon is set.\"\"\"\n        link = await link_factory()\n        response = await async_client.get(f\"/api/v1/external-links/{link.id}/icon\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_icon_when_none(self, async_client: AsyncClient, link_factory, db_session):\n        \"\"\"Verify deleting non-existent icon succeeds silently.\"\"\"\n        link = await link_factory()\n        response = await async_client.delete(f\"/api/v1/external-links/{link.id}/icon\")\n        assert response.status_code == 200\n"
  },
  {
    "path": "backend/tests/integration/test_filaments_api.py",
    "content": "\"\"\"Integration tests for Filaments API endpoints.\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestFilamentsAPI:\n    \"\"\"Integration tests for /api/v1/filament-catalog/ (material types) endpoints.\"\"\"\n\n    @pytest.fixture\n    async def filament_factory(self, db_session):\n        \"\"\"Factory to create test filaments.\"\"\"\n\n        async def _create_filament(**kwargs):\n            from backend.app.models.filament import Filament\n\n            defaults = {\n                \"name\": \"Test PLA\",\n                \"type\": \"PLA\",\n                \"color\": \"Red\",\n                \"color_hex\": \"#FF0000\",\n                \"brand\": \"Generic\",\n                \"cost_per_kg\": 25.0,\n            }\n            defaults.update(kwargs)\n\n            filament = Filament(**defaults)\n            db_session.add(filament)\n            await db_session.commit()\n            await db_session.refresh(filament)\n            return filament\n\n        return _create_filament\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_filaments_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list when no filaments exist.\"\"\"\n        response = await async_client.get(\"/api/v1/filament-catalog/\")\n        assert response.status_code == 200\n        assert isinstance(response.json(), list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_filaments_with_data(self, async_client: AsyncClient, filament_factory, db_session):\n        \"\"\"Verify list returns existing filaments.\"\"\"\n        await filament_factory(name=\"Test Filament\")\n        response = await async_client.get(\"/api/v1/filament-catalog/\")\n        assert response.status_code == 200\n        data = response.json()\n        assert any(f[\"name\"] == \"Test Filament\" for f in data)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_filament(self, async_client: AsyncClient):\n        \"\"\"Verify filament can be created.\"\"\"\n        data = {\n            \"name\": \"New PETG\",\n            \"type\": \"PETG\",\n            \"color\": \"Blue\",\n            \"color_hex\": \"#0000FF\",\n            \"brand\": \"Bambu\",\n            \"cost_per_kg\": 30.0,\n        }\n        response = await async_client.post(\"/api/v1/filament-catalog/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"New PETG\"\n        assert result[\"type\"] == \"PETG\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_filament(self, async_client: AsyncClient, filament_factory, db_session):\n        \"\"\"Verify single filament can be retrieved.\"\"\"\n        filament = await filament_factory(name=\"Get Test\")\n        response = await async_client.get(f\"/api/v1/filament-catalog/{filament.id}\")\n        assert response.status_code == 200\n        assert response.json()[\"name\"] == \"Get Test\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_filament_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent filament.\"\"\"\n        response = await async_client.get(\"/api/v1/filament-catalog/9999\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_filament(self, async_client: AsyncClient, filament_factory, db_session):\n        \"\"\"Verify filament can be updated.\"\"\"\n        filament = await filament_factory(name=\"Original\")\n        response = await async_client.patch(\n            f\"/api/v1/filament-catalog/{filament.id}\", json={\"name\": \"Updated\", \"cost_per_kg\": 35.0}\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"Updated\"\n        assert result[\"cost_per_kg\"] == 35.0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_filament(self, async_client: AsyncClient, filament_factory, db_session):\n        \"\"\"Verify filament can be deleted.\"\"\"\n        filament = await filament_factory()\n        response = await async_client.delete(f\"/api/v1/filament-catalog/{filament.id}\")\n        assert response.status_code == 200\n        # Verify deleted\n        response = await async_client.get(f\"/api/v1/filament-catalog/{filament.id}\")\n        assert response.status_code == 404\n"
  },
  {
    "path": "backend/tests/integration/test_github_backup_api.py",
    "content": "\"\"\"Integration tests for GitHub Backup API endpoints.\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestGitHubBackupConfigAPI:\n    \"\"\"Integration tests for /api/v1/github-backup endpoints.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_config_no_config(self, async_client: AsyncClient):\n        \"\"\"Verify getting config when none exists returns null.\"\"\"\n        response = await async_client.get(\"/api/v1/github-backup/config\")\n        assert response.status_code == 200\n        assert response.json() is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_config(self, async_client: AsyncClient):\n        \"\"\"Verify GitHub backup config can be created.\"\"\"\n        data = {\n            \"repository_url\": \"https://github.com/test/repo\",\n            \"access_token\": \"ghp_testtoken123\",\n            \"branch\": \"main\",\n            \"schedule_enabled\": False,\n            \"schedule_type\": \"daily\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": True,\n            \"backup_settings\": False,\n            \"backup_spools\": False,\n            \"backup_archives\": False,\n            \"enabled\": True,\n        }\n        response = await async_client.post(\"/api/v1/github-backup/config\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"repository_url\"] == \"https://github.com/test/repo\"\n        assert result[\"branch\"] == \"main\"\n        assert result[\"has_token\"] is True\n        assert result[\"enabled\"] is True\n        assert result[\"backup_spools\"] is False\n        assert result[\"backup_archives\"] is False\n        # Token should not be exposed in response\n        assert \"access_token\" not in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_config_after_create(self, async_client: AsyncClient):\n        \"\"\"Verify getting config after creation returns the config.\"\"\"\n        # Create config first\n        data = {\n            \"repository_url\": \"https://github.com/test/getrepo\",\n            \"access_token\": \"ghp_testtoken456\",\n            \"branch\": \"develop\",\n            \"schedule_enabled\": True,\n            \"schedule_type\": \"weekly\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": False,\n            \"backup_settings\": True,\n            \"enabled\": True,\n        }\n        await async_client.post(\"/api/v1/github-backup/config\", json=data)\n\n        # Get config\n        response = await async_client.get(\"/api/v1/github-backup/config\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result is not None\n        assert result[\"repository_url\"] == \"https://github.com/test/getrepo\"\n        assert result[\"branch\"] == \"develop\"\n        assert result[\"schedule_type\"] == \"weekly\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_config_with_spools_and_archives(self, async_client: AsyncClient):\n        \"\"\"Verify config with spool and archive backup enabled.\"\"\"\n        data = {\n            \"repository_url\": \"https://github.com/test/spoolarchive\",\n            \"access_token\": \"ghp_spooltoken\",\n            \"branch\": \"main\",\n            \"schedule_enabled\": False,\n            \"schedule_type\": \"daily\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": False,\n            \"backup_settings\": False,\n            \"backup_spools\": True,\n            \"backup_archives\": True,\n            \"enabled\": True,\n        }\n        response = await async_client.post(\"/api/v1/github-backup/config\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"backup_spools\"] is True\n        assert result[\"backup_archives\"] is True\n        assert result[\"backup_cloud_profiles\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_config_partial(self, async_client: AsyncClient):\n        \"\"\"Verify partial update of GitHub backup config.\"\"\"\n        # Create config first\n        create_data = {\n            \"repository_url\": \"https://github.com/test/update\",\n            \"access_token\": \"ghp_token\",\n            \"branch\": \"main\",\n            \"schedule_enabled\": False,\n            \"schedule_type\": \"daily\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": True,\n            \"backup_settings\": False,\n            \"backup_spools\": False,\n            \"backup_archives\": False,\n            \"enabled\": True,\n        }\n        await async_client.post(\"/api/v1/github-backup/config\", json=create_data)\n\n        # Partial update\n        update_data = {\n            \"branch\": \"develop\",\n            \"schedule_enabled\": True,\n        }\n        response = await async_client.patch(\"/api/v1/github-backup/config\", json=update_data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"branch\"] == \"develop\"\n        assert result[\"schedule_enabled\"] is True\n        # Original values should be preserved\n        assert result[\"repository_url\"] == \"https://github.com/test/update\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_config_enable_spools_and_archives(self, async_client: AsyncClient):\n        \"\"\"Verify partial update can enable spool and archive backup.\"\"\"\n        # Create config first\n        create_data = {\n            \"repository_url\": \"https://github.com/test/updatetoggle\",\n            \"access_token\": \"ghp_toggletoken\",\n            \"branch\": \"main\",\n            \"schedule_enabled\": False,\n            \"schedule_type\": \"daily\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": True,\n            \"backup_settings\": False,\n            \"backup_spools\": False,\n            \"backup_archives\": False,\n            \"enabled\": True,\n        }\n        await async_client.post(\"/api/v1/github-backup/config\", json=create_data)\n\n        # Enable spools and archives via partial update\n        update_data = {\n            \"backup_spools\": True,\n            \"backup_archives\": True,\n        }\n        response = await async_client.patch(\"/api/v1/github-backup/config\", json=update_data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"backup_spools\"] is True\n        assert result[\"backup_archives\"] is True\n        # Other values preserved\n        assert result[\"backup_kprofiles\"] is True\n        assert result[\"backup_settings\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_config(self, async_client: AsyncClient):\n        \"\"\"Verify GitHub backup config can be deleted.\"\"\"\n        # Create config first\n        create_data = {\n            \"repository_url\": \"https://github.com/test/delete\",\n            \"access_token\": \"ghp_deletetoken\",\n            \"branch\": \"main\",\n            \"schedule_enabled\": False,\n            \"schedule_type\": \"daily\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": True,\n            \"backup_settings\": False,\n            \"enabled\": True,\n        }\n        await async_client.post(\"/api/v1/github-backup/config\", json=create_data)\n\n        # Delete\n        response = await async_client.delete(\"/api/v1/github-backup/config\")\n        assert response.status_code == 200\n\n        # Verify it's deleted\n        get_response = await async_client.get(\"/api/v1/github-backup/config\")\n        assert get_response.status_code == 200\n        assert get_response.json() is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_config_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify deleting non-existent config returns 404.\"\"\"\n        # Make sure no config exists\n        await async_client.delete(\"/api/v1/github-backup/config\")\n\n        # Try to delete again\n        response = await async_client.delete(\"/api/v1/github-backup/config\")\n        assert response.status_code == 404\n\n\nclass TestGitHubBackupStatusAPI:\n    \"\"\"Integration tests for /api/v1/github-backup/status endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_status_no_config(self, async_client: AsyncClient):\n        \"\"\"Verify status when no config exists.\"\"\"\n        # Ensure no config\n        await async_client.delete(\"/api/v1/github-backup/config\")\n\n        response = await async_client.get(\"/api/v1/github-backup/status\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"configured\"] is False\n        assert result[\"enabled\"] is False\n        assert result[\"is_running\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_status_with_config(self, async_client: AsyncClient):\n        \"\"\"Verify status when config exists.\"\"\"\n        # Create config\n        create_data = {\n            \"repository_url\": \"https://github.com/test/status\",\n            \"access_token\": \"ghp_statustoken\",\n            \"branch\": \"main\",\n            \"schedule_enabled\": True,\n            \"schedule_type\": \"hourly\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": True,\n            \"backup_settings\": False,\n            \"enabled\": True,\n        }\n        await async_client.post(\"/api/v1/github-backup/config\", json=create_data)\n\n        response = await async_client.get(\"/api/v1/github-backup/status\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"configured\"] is True\n        assert result[\"enabled\"] is True\n        assert result[\"is_running\"] is False\n        assert result[\"next_scheduled_run\"] is not None\n\n\nclass TestGitHubBackupLogsAPI:\n    \"\"\"Integration tests for /api/v1/github-backup/logs endpoints.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_logs_no_config(self, async_client: AsyncClient):\n        \"\"\"Verify getting logs when no config exists returns empty list.\"\"\"\n        # Ensure no config\n        await async_client.delete(\"/api/v1/github-backup/config\")\n\n        response = await async_client.get(\"/api/v1/github-backup/logs\")\n        assert response.status_code == 200\n        assert response.json() == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_logs_with_config(self, async_client: AsyncClient):\n        \"\"\"Verify getting logs with config.\"\"\"\n        # Create config\n        create_data = {\n            \"repository_url\": \"https://github.com/test/logs\",\n            \"access_token\": \"ghp_logstoken\",\n            \"branch\": \"main\",\n            \"schedule_enabled\": False,\n            \"schedule_type\": \"daily\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": True,\n            \"backup_settings\": False,\n            \"enabled\": True,\n        }\n        await async_client.post(\"/api/v1/github-backup/config\", json=create_data)\n\n        response = await async_client.get(\"/api/v1/github-backup/logs\")\n        assert response.status_code == 200\n        # No backups run yet, so empty list\n        assert response.json() == []\n\n\nclass TestGitHubBackupTriggerAPI:\n    \"\"\"Integration tests for /api/v1/github-backup/run endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_trigger_no_config(self, async_client: AsyncClient):\n        \"\"\"Verify triggering backup without config returns 404.\"\"\"\n        # Ensure no config\n        await async_client.delete(\"/api/v1/github-backup/config\")\n\n        response = await async_client.post(\"/api/v1/github-backup/run\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_trigger_disabled_config(self, async_client: AsyncClient):\n        \"\"\"Verify triggering backup with disabled config returns 400.\"\"\"\n        # Create disabled config\n        create_data = {\n            \"repository_url\": \"https://github.com/test/trigger\",\n            \"access_token\": \"ghp_triggertoken\",\n            \"branch\": \"main\",\n            \"schedule_enabled\": False,\n            \"schedule_type\": \"daily\",\n            \"backup_kprofiles\": True,\n            \"backup_cloud_profiles\": True,\n            \"backup_settings\": False,\n            \"enabled\": False,  # Disabled\n        }\n        await async_client.post(\"/api/v1/github-backup/config\", json=create_data)\n\n        response = await async_client.post(\"/api/v1/github-backup/run\")\n        assert response.status_code == 400\n        assert \"disabled\" in response.json()[\"detail\"].lower()\n"
  },
  {
    "path": "backend/tests/integration/test_inventory_assign.py",
    "content": "\"\"\"Integration tests for inventory spool assignment — tray_info_idx resolution.\n\nTests that the spool's own slicer_filament (including PFUS* cloud-synced\ncustom presets) takes priority, with slot reuse and generic fallback as\nlower-priority fallbacks.\n\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.spool import Spool\n\n\n@pytest.fixture\nasync def spool_factory(db_session: AsyncSession):\n    \"\"\"Factory to create test spools.\"\"\"\n    _counter = [0]\n\n    async def _create_spool(**kwargs):\n        _counter[0] += 1\n        defaults = {\n            \"material\": \"PLA\",\n            \"subtype\": \"Basic\",\n            \"brand\": \"Devil Design\",\n            \"color_name\": \"Red\",\n            \"rgba\": \"FF0000FF\",\n            \"label_weight\": 1000,\n            \"weight_used\": 0,\n            \"slicer_filament\": \"PFUS9ac902733670a9\",\n        }\n        defaults.update(kwargs)\n        spool = Spool(**defaults)\n        db_session.add(spool)\n        await db_session.commit()\n        await db_session.refresh(spool)\n        return spool\n\n    return _create_spool\n\n\ndef _make_mock_status(ams_data=None, vt_tray=None, nozzles=None, ams_extruder_map=None):\n    \"\"\"Build a mock printer status with optional AMS/nozzle data.\"\"\"\n    status = MagicMock()\n    raw = {}\n    if ams_data is not None:\n        raw[\"ams\"] = {\"ams\": ams_data}\n    if vt_tray is not None:\n        raw[\"vt_tray\"] = vt_tray\n    status.raw_data = raw\n    status.nozzles = nozzles or [MagicMock(nozzle_diameter=\"0.4\")]\n    status.ams_extruder_map = ams_extruder_map\n    return status\n\n\nclass TestAssignSpoolTrayInfoIdx:\n    \"\"\"Tests for tray_info_idx resolution during spool assignment.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_pfus_slicer_filament_used_directly(self, async_client: AsyncClient, printer_factory, spool_factory):\n        \"\"\"PFUS* cloud-synced custom preset IDs are sent to the printer.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n        spool = await spool_factory(slicer_filament=\"PFUS9ac902733670a9\", material=\"PLA\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        status = _make_mock_status(ams_data=[{\"id\": 2, \"tray\": [{\"id\": 3, \"tray_info_idx\": \"\", \"tray_type\": \"\"}]}])\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 2, \"tray_id\": 3},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUS9ac902733670a9\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_spool_preset_takes_priority_over_slot(\n        self, async_client: AsyncClient, printer_factory, spool_factory\n    ):\n        \"\"\"Spool's own slicer_filament takes priority over slot's existing preset.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n        spool = await spool_factory(slicer_filament=\"PFUS9ac902733670a9\", material=\"PLA\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        # Slot already configured by slicer with cloud-synced preset\n        status = _make_mock_status(\n            ams_data=[{\"id\": 2, \"tray\": [{\"id\": 3, \"tray_info_idx\": \"P4d64437\", \"tray_type\": \"PLA\"}]}]\n        )\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 2, \"tray_id\": 3},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            # Spool's own preset wins over slot's existing one\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUS9ac902733670a9\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_spool_preset_used_even_if_different_material_on_slot(\n        self, async_client: AsyncClient, printer_factory, spool_factory\n    ):\n        \"\"\"Spool's own slicer_filament is used regardless of what's on the slot.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n        spool = await spool_factory(slicer_filament=\"PFUS9ac902733670a9\", material=\"PETG\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        # Slot currently has PLA but spool is PETG\n        status = _make_mock_status(\n            ams_data=[{\"id\": 2, \"tray\": [{\"id\": 3, \"tray_info_idx\": \"P4d64437\", \"tray_type\": \"PLA\"}]}]\n        )\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 2, \"tray_id\": 3},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUS9ac902733670a9\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):\n        \"\"\"Standard GF* IDs from spool.slicer_filament are used directly.\"\"\"\n        printer = await printer_factory(name=\"X1C\")\n        spool = await spool_factory(slicer_filament=\"GFL05\", material=\"PLA\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        status = _make_mock_status(ams_data=[])\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 0},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"GFL05\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):\n        \"\"\"Spool with no slicer_filament gets a generic ID from material type.\"\"\"\n        printer = await printer_factory(name=\"X1C\")\n        spool = await spool_factory(slicer_filament=None, material=\"ABS\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        status = _make_mock_status(ams_data=[])\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 0},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"GFB99\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_spool_pfus_used_over_slot_pfus(self, async_client: AsyncClient, printer_factory, spool_factory):\n        \"\"\"Spool's own PFUS preset is used even when slot has a different PFUS.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n        spool = await spool_factory(slicer_filament=\"PFUS1111111111\", material=\"PLA\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        # Slot has a PFUS* ID from some previous config\n        status = _make_mock_status(\n            ams_data=[{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_info_idx\": \"PFUS2222222222\", \"tray_type\": \"PLA\"}]}]\n        )\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 0},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            # Spool's own preset wins\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUS1111111111\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_generic_on_slot_not_reused_over_spool_preset(\n        self, async_client: AsyncClient, printer_factory, spool_factory\n    ):\n        \"\"\"Generic ID on slot (e.g. GFB99) must not override spool's own preset.\"\"\"\n        printer = await printer_factory(name=\"P2S\")\n        spool = await spool_factory(slicer_filament=\"PFUScda4c46fc9031\", material=\"ABS\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        # Slot stuck on generic ABS from a previous assignment\n        status = _make_mock_status(\n            ams_data=[{\"id\": 0, \"tray\": [{\"id\": 1, \"tray_info_idx\": \"GFB99\", \"tray_type\": \"ABS\"}]}]\n        )\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 1},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            # Spool's preset wins — generic on slot must not be sticky\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUScda4c46fc9031\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_no_preset_with_generic_on_slot_still_uses_generic(\n        self, async_client: AsyncClient, printer_factory, spool_factory\n    ):\n        \"\"\"Spool without preset + generic on slot → generic fallback (not slot reuse).\"\"\"\n        printer = await printer_factory(name=\"P2S\")\n        spool = await spool_factory(slicer_filament=None, material=\"ABS\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        # Slot has generic ABS\n        status = _make_mock_status(\n            ams_data=[{\"id\": 0, \"tray\": [{\"id\": 1, \"tray_info_idx\": \"GFB99\", \"tray_type\": \"ABS\"}]}]\n        )\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 1},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            # Still gets generic, but via fallback — not via sticky reuse\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"GFB99\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_no_preset_reuses_specific_slot_preset(\n        self, async_client: AsyncClient, printer_factory, spool_factory\n    ):\n        \"\"\"Spool without preset + specific preset on slot → reuse slot's preset.\"\"\"\n        printer = await printer_factory(name=\"X1C\")\n        spool = await spool_factory(slicer_filament=None, material=\"PLA\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n\n        # Slot has a specific Bambu PLA preset (not generic)\n        status = _make_mock_status(\n            ams_data=[{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_info_idx\": \"GFA05\", \"tray_type\": \"PLA\"}]}]\n        )\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 0},\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            # Slot's specific preset is reused when spool has no own preset\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"GFA05\"\n\n\nclass TestAssignSpoolPresetMapping:\n    \"\"\"Tests that assign_spool saves the slot preset mapping for correct UI display.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_preset_mapping_saved_with_slicer_filament_name(\n        self, async_client: AsyncClient, printer_factory, spool_factory\n    ):\n        \"\"\"Slot preset mapping uses slicer_filament_name (not material+subtype).\"\"\"\n\n        printer = await printer_factory(name=\"X1C\")\n        spool = await spool_factory(\n            slicer_filament=\"GFA05\",\n            slicer_filament_name=\"Bambu PLA Silk\",\n            material=\"PLA\",\n            subtype=\"Silk\",\n            brand=\"Bambu\",\n        )\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        status = _make_mock_status(ams_data=[])\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 1},\n            )\n\n        assert response.status_code == 200\n\n        # Verify via the slot presets API\n        presets_resp = await async_client.get(f\"/api/v1/printers/{printer.id}/slot-presets\")\n        assert presets_resp.status_code == 200\n        presets = presets_resp.json()\n        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → \"1\"\n        assert \"1\" in presets\n        # Must use slicer_filament_name, NOT \"PLA Silk\" from material+subtype\n        assert presets[\"1\"][\"preset_name\"] == \"Bambu PLA Silk\"\n        assert presets[\"1\"][\"preset_id\"] == \"GFSA05\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_preset_mapping_overwrites_old_mapping(\n        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession\n    ):\n        \"\"\"Assigning a new spool overwrites the old slot preset mapping.\"\"\"\n        from backend.app.models.slot_preset import SlotPresetMapping\n\n        printer = await printer_factory(name=\"X1C\")\n\n        # Pre-existing mapping (e.g. from previous manual configuration)\n        old_mapping = SlotPresetMapping(\n            printer_id=printer.id,\n            ams_id=0,\n            tray_id=2,\n            preset_id=\"GFSA01\",\n            preset_name=\"Bambu PLA Matte\",\n            preset_source=\"cloud\",\n        )\n        db_session.add(old_mapping)\n        await db_session.commit()\n\n        # Assign a \"Generic PLA Silk\" spool to same slot\n        spool = await spool_factory(\n            slicer_filament=\"GFL96\",\n            slicer_filament_name=\"Generic PLA Silk\",\n            material=\"PLA\",\n            subtype=\"Silk\",\n        )\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        status = _make_mock_status(ams_data=[])\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 2},\n            )\n\n        assert response.status_code == 200\n\n        # Verify via the slot presets API to avoid stale session cache\n        presets_resp = await async_client.get(f\"/api/v1/printers/{printer.id}/slot-presets\")\n        assert presets_resp.status_code == 200\n        presets = presets_resp.json()\n        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → \"2\"\n        assert \"2\" in presets\n        # Old \"Bambu PLA Matte\" must be overwritten\n        assert presets[\"2\"][\"preset_name\"] == \"Generic PLA Silk\"\n        assert presets[\"2\"][\"preset_id\"] == \"GFSL96\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_preset_mapping_fallback_to_tray_sub_brands(\n        self, async_client: AsyncClient, printer_factory, spool_factory\n    ):\n        \"\"\"When slicer_filament_name is null, falls back to tray_sub_brands.\"\"\"\n        from backend.app.models.slot_preset import SlotPresetMapping\n\n        printer = await printer_factory(name=\"A1M\")\n        spool = await spool_factory(\n            slicer_filament=\"GFL05\",\n            slicer_filament_name=None,\n            material=\"PLA\",\n            subtype=\"Matte\",\n            brand=\"Overture\",\n        )\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        status = _make_mock_status(ams_data=[])\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = status\n\n            response = await async_client.post(\n                \"/api/v1/inventory/assignments\",\n                json={\"spool_id\": spool.id, \"printer_id\": printer.id, \"ams_id\": 0, \"tray_id\": 0},\n            )\n\n        assert response.status_code == 200\n\n        # Verify via the slot presets API\n        presets_resp = await async_client.get(f\"/api/v1/printers/{printer.id}/slot-presets\")\n        assert presets_resp.status_code == 200\n        presets = presets_resp.json()\n        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → \"0\"\n        assert \"0\" in presets\n        # Falls back to tray_sub_brands (\"Overture PLA Matte\")\n        assert presets[\"0\"][\"preset_name\"] == \"Overture PLA Matte\"\n"
  },
  {
    "path": "backend/tests/integration/test_library_api.py",
    "content": "\"\"\"Integration tests for Library API endpoints.\"\"\"\n\nimport io\nimport tempfile\nimport zipfile\nfrom pathlib import Path\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestLibraryFoldersAPI:\n    \"\"\"Integration tests for library folders endpoints.\"\"\"\n\n    @pytest.fixture\n    async def folder_factory(self, db_session):\n        \"\"\"Factory to create test folders.\"\"\"\n        _counter = [0]\n\n        async def _create_folder(**kwargs):\n            from backend.app.models.library import LibraryFolder\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Test Folder {counter}\",\n            }\n            defaults.update(kwargs)\n\n            folder = LibraryFolder(**defaults)\n            db_session.add(folder)\n            await db_session.commit()\n            await db_session.refresh(folder)\n            return folder\n\n        return _create_folder\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_folders_empty(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify empty folder list returns empty array.\"\"\"\n        response = await async_client.get(\"/api/v1/library/folders\")\n        assert response.status_code == 200\n        assert response.json() == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_folder(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify folder can be created.\"\"\"\n        data = {\"name\": \"New Folder\"}\n        response = await async_client.post(\"/api/v1/library/folders\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"New Folder\"\n        assert result[\"id\"] is not None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_nested_folder(self, async_client: AsyncClient, folder_factory, db_session):\n        \"\"\"Verify nested folder can be created.\"\"\"\n        parent = await folder_factory(name=\"Parent\")\n        data = {\"name\": \"Child\", \"parent_id\": parent.id}\n        response = await async_client.post(\"/api/v1/library/folders\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"Child\"\n        assert result[\"parent_id\"] == parent.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_folder(self, async_client: AsyncClient, folder_factory, db_session):\n        \"\"\"Verify single folder can be retrieved.\"\"\"\n        folder = await folder_factory(name=\"Test Folder\")\n        response = await async_client.get(f\"/api/v1/library/folders/{folder.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == folder.id\n        assert result[\"name\"] == \"Test Folder\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_folder_not_found(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify 404 for non-existent folder.\"\"\"\n        response = await async_client.get(\"/api/v1/library/folders/9999\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_folder(self, async_client: AsyncClient, folder_factory, db_session):\n        \"\"\"Verify folder can be updated.\"\"\"\n        folder = await folder_factory(name=\"Old Name\")\n        data = {\"name\": \"New Name\"}\n        response = await async_client.put(f\"/api/v1/library/folders/{folder.id}\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"New Name\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_folder(self, async_client: AsyncClient, folder_factory, db_session):\n        \"\"\"Verify folder can be deleted.\"\"\"\n        folder = await folder_factory()\n        response = await async_client.delete(f\"/api/v1/library/folders/{folder.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result.get(\"message\") or result.get(\"success\", True)\n\n\nclass TestLibraryFilesAPI:\n    \"\"\"Integration tests for library files endpoints.\"\"\"\n\n    @pytest.fixture\n    async def folder_factory(self, db_session):\n        \"\"\"Factory to create test folders.\"\"\"\n        _counter = [0]\n\n        async def _create_folder(**kwargs):\n            from backend.app.models.library import LibraryFolder\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\"name\": f\"Test Folder {counter}\"}\n            defaults.update(kwargs)\n\n            folder = LibraryFolder(**defaults)\n            db_session.add(folder)\n            await db_session.commit()\n            await db_session.refresh(folder)\n            return folder\n\n        return _create_folder\n\n    @pytest.fixture\n    async def file_factory(self, db_session):\n        \"\"\"Factory to create test files.\"\"\"\n        _counter = [0]\n\n        async def _create_file(**kwargs):\n            from backend.app.models.library import LibraryFile\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"test_file_{counter}.3mf\",\n                \"file_path\": f\"/test/path/test_file_{counter}.3mf\",\n                \"file_size\": 1024,\n                \"file_type\": \"3mf\",\n            }\n            defaults.update(kwargs)\n\n            lib_file = LibraryFile(**defaults)\n            db_session.add(lib_file)\n            await db_session.commit()\n            await db_session.refresh(lib_file)\n            return lib_file\n\n        return _create_file\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_files_empty(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify empty file list returns empty array.\"\"\"\n        response = await async_client.get(\"/api/v1/library/files\")\n        assert response.status_code == 200\n        assert response.json() == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_files_in_folder(self, async_client: AsyncClient, folder_factory, file_factory, db_session):\n        \"\"\"Verify files can be filtered by folder.\"\"\"\n        folder = await folder_factory()\n        file1 = await file_factory(folder_id=folder.id)\n        await file_factory()  # File in root (no folder)\n\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={folder.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result) == 1\n        assert result[0][\"id\"] == file1.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_files_by_project_id(self, async_client: AsyncClient, folder_factory, file_factory, db_session):\n        \"\"\"#932: project_id filter returns files across all folders linked to the project.\n\n        Replaces the prior N+1 pattern where the frontend fired one request per\n        linked folder. A single JOIN query must return every file in folders whose\n        project_id matches, while excluding files from unlinked folders.\n        \"\"\"\n        from backend.app.models.project import Project\n\n        project = Project(name=\"Test Project for Files\", color=\"#00ff00\")\n        db_session.add(project)\n        await db_session.commit()\n        await db_session.refresh(project)\n\n        folder_a = await folder_factory(name=\"Folder A\", project_id=project.id)\n        folder_b = await folder_factory(name=\"Folder B\", project_id=project.id)\n        other_folder = await folder_factory(name=\"Unlinked\")\n\n        linked_a = await file_factory(folder_id=folder_a.id, filename=\"a.3mf\")\n        linked_b = await file_factory(folder_id=folder_b.id, filename=\"b.3mf\")\n        await file_factory(folder_id=other_folder.id, filename=\"unlinked.3mf\")\n        await file_factory(filename=\"root.3mf\")  # no folder → not part of any project\n\n        response = await async_client.get(f\"/api/v1/library/files?project_id={project.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        ids = {f[\"id\"] for f in result}\n        assert ids == {linked_a.id, linked_b.id}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_files_folder_id_takes_precedence_over_project_id(\n        self, async_client: AsyncClient, folder_factory, file_factory, db_session\n    ):\n        \"\"\"When both folder_id and project_id are passed, folder_id wins.\n\n        Documented precedence in list_files(): folder_id > project_id > include_root.\n        This guards the behavior so a future refactor can't silently flip it.\n        \"\"\"\n        from backend.app.models.project import Project\n\n        project = Project(name=\"Precedence Project\")\n        db_session.add(project)\n        await db_session.commit()\n        await db_session.refresh(project)\n\n        folder_linked = await folder_factory(name=\"Linked\", project_id=project.id)\n        folder_other = await folder_factory(name=\"Other\")\n\n        await file_factory(folder_id=folder_linked.id, filename=\"linked.3mf\")\n        other_file = await file_factory(folder_id=folder_other.id, filename=\"other.3mf\")\n\n        # folder_id points at a folder that is NOT in the project — must return\n        # that folder's contents and ignore project_id entirely.\n        response = await async_client.get(f\"/api/v1/library/files?folder_id={folder_other.id}&project_id={project.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result) == 1\n        assert result[0][\"id\"] == other_file.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify single file can be retrieved.\"\"\"\n        lib_file = await file_factory(filename=\"test.3mf\")\n        response = await async_client.get(f\"/api/v1/library/files/{lib_file.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == lib_file.id\n        assert result[\"filename\"] == \"test.3mf\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_file_not_found(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify 404 for non-existent file.\"\"\"\n        response = await async_client.get(\"/api/v1/library/files/9999\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_file(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify file can be deleted.\"\"\"\n        lib_file = await file_factory()\n        response = await async_client.delete(f\"/api/v1/library/files/{lib_file.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result.get(\"message\") or result.get(\"success\", True)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify file can be renamed.\"\"\"\n        lib_file = await file_factory(filename=\"old_name.3mf\")\n        data = {\"filename\": \"new_name.3mf\"}\n        response = await async_client.put(f\"/api/v1/library/files/{lib_file.id}\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"filename\"] == \"new_name.3mf\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify file rename fails with path separators.\"\"\"\n        lib_file = await file_factory(filename=\"test.3mf\")\n        data = {\"filename\": \"path/to/file.3mf\"}\n        response = await async_client.put(f\"/api/v1/library/files/{lib_file.id}\", json=data)\n        assert response.status_code == 400\n        assert \"path separator\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify file rename fails with backslash.\"\"\"\n        lib_file = await file_factory(filename=\"test.3mf\")\n        data = {\"filename\": \"path\\\\to\\\\file.3mf\"}\n        response = await async_client.put(f\"/api/v1/library/files/{lib_file.id}\", json=data)\n        assert response.status_code == 400\n        assert \"path separator\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):\n        \"\"\"Verify library stats endpoint returns counts.\"\"\"\n        await folder_factory()\n        await folder_factory()\n        await file_factory()\n\n        response = await async_client.get(\"/api/v1/library/stats\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"total_folders\"] == 2\n        assert result[\"total_files\"] == 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify file list response includes user tracking fields (Issue #206).\"\"\"\n        lib_file = await file_factory(filename=\"test.3mf\")\n        response = await async_client.get(\"/api/v1/library/files?include_root=false\")\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result) >= 1\n        # Find our test file\n        test_file = next((f for f in result if f[\"id\"] == lib_file.id), None)\n        assert test_file is not None\n        # User tracking fields should be present (even if null)\n        assert \"created_by_id\" in test_file\n        assert \"created_by_username\" in test_file\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify file detail response includes user tracking fields (Issue #206).\"\"\"\n        lib_file = await file_factory(filename=\"test_detail.3mf\")\n        response = await async_client.get(f\"/api/v1/library/files/{lib_file.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        # User tracking fields should be present (even if null)\n        assert \"created_by_id\" in result\n        assert \"created_by_username\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify file created with user shows username in response (Issue #206).\"\"\"\n        from backend.app.models.library import LibraryFile\n        from backend.app.models.user import User\n\n        # Create a test user\n        user = User(username=\"testuploader\", password_hash=\"fakehash\", role=\"user\")\n        db_session.add(user)\n        await db_session.flush()\n\n        # Create a file with created_by_id set\n        lib_file = LibraryFile(\n            filename=\"user_uploaded.3mf\",\n            file_path=\"/test/user_uploaded.3mf\",\n            file_size=2048,\n            file_type=\"3mf\",\n            created_by_id=user.id,\n        )\n        db_session.add(lib_file)\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n\n        # Verify file detail shows username\n        response = await async_client.get(f\"/api/v1/library/files/{lib_file.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"created_by_id\"] == user.id\n        assert result[\"created_by_username\"] == \"testuploader\"\n\n        # Verify file list also shows username\n        response = await async_client.get(\"/api/v1/library/files?include_root=false\")\n        assert response.status_code == 200\n        files = response.json()\n        test_file = next((f for f in files if f[\"id\"] == lib_file.id), None)\n        assert test_file is not None\n        assert test_file[\"created_by_id\"] == user.id\n        assert test_file[\"created_by_username\"] == \"testuploader\"\n\n\nclass TestLibraryAddToQueueAPI:\n    \"\"\"Integration tests for /api/v1/library/files/add-to-queue endpoint.\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n        _counter = [0]\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Test Printer {counter}\",\n                \"ip_address\": f\"192.168.1.{100 + counter}\",\n                \"serial_number\": f\"TESTSERIAL{counter:04d}\",\n                \"access_code\": \"12345678\",\n                \"model\": \"X1C\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def library_file_factory(self, db_session):\n        \"\"\"Factory to create test library files.\"\"\"\n        _counter = [0]\n\n        async def _create_library_file(**kwargs):\n            from backend.app.models.library import LibraryFile\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"test_file_{counter}.gcode.3mf\",\n                \"file_path\": f\"/test/path/test_file_{counter}.gcode.3mf\",\n                \"file_size\": 1024,\n                \"file_type\": \"3mf\",\n            }\n            defaults.update(kwargs)\n\n            lib_file = LibraryFile(**defaults)\n            db_session.add(lib_file)\n            await db_session.commit()\n            await db_session.refresh(lib_file)\n            return lib_file\n\n        return _create_library_file\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify error for non-existent file.\"\"\"\n        await printer_factory()\n\n        data = {\"file_ids\": [9999]}\n        response = await async_client.post(\"/api/v1/library/files/add-to-queue\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result[\"added\"]) == 0\n        assert len(result[\"errors\"]) == 1\n        assert result[\"errors\"][0][\"file_id\"] == 9999\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_non_sliced_file_to_queue_fails(\n        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session\n    ):\n        \"\"\"Verify non-sliced file cannot be added to queue.\"\"\"\n        await printer_factory()\n        lib_file = await library_file_factory(\n            filename=\"model.stl\",\n            file_path=\"/test/path/model.stl\",\n            file_type=\"stl\",\n        )\n\n        data = {\"file_ids\": [lib_file.id]}\n        response = await async_client.post(\"/api/v1/library/files/add-to-queue\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result[\"added\"]) == 0\n        assert len(result[\"errors\"]) == 1\n        assert \"sliced\" in result[\"errors\"][0][\"error\"].lower()\n\n\nclass TestLibraryZipExtractAPI:\n    \"\"\"Integration tests for ZIP extraction endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify non-ZIP files are rejected.\"\"\"\n        # Create a fake file that's not a ZIP\n        files = {\"file\": (\"test.txt\", b\"This is not a zip file\", \"text/plain\")}\n        response = await async_client.post(\"/api/v1/library/files/extract-zip\", files=files)\n        assert response.status_code == 400\n        assert \"ZIP\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify basic ZIP extraction works.\"\"\"\n        import io\n\n        # Create a simple ZIP file in memory\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"test1.txt\", \"Content of file 1\")\n            zf.writestr(\"test2.txt\", \"Content of file 2\")\n        zip_buffer.seek(0)\n\n        files = {\"file\": (\"test.zip\", zip_buffer.read(), \"application/zip\")}\n        response = await async_client.post(\"/api/v1/library/files/extract-zip\", files=files)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"extracted\"] == 2\n        assert len(result[\"files\"]) == 2\n        assert len(result[\"errors\"]) == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify ZIP extraction preserves folder structure.\"\"\"\n        import io\n\n        # Create a ZIP file with folder structure\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"folder1/file1.txt\", \"Content 1\")\n            zf.writestr(\"folder1/subfolder/file2.txt\", \"Content 2\")\n            zf.writestr(\"folder2/file3.txt\", \"Content 3\")\n        zip_buffer.seek(0)\n\n        files = {\"file\": (\"test.zip\", zip_buffer.read(), \"application/zip\")}\n        params = {\"preserve_structure\": \"true\"}\n        response = await async_client.post(\"/api/v1/library/files/extract-zip\", files=files, params=params)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"extracted\"] == 3\n        assert result[\"folders_created\"] >= 3  # folder1, folder1/subfolder, folder2\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify ZIP extraction can extract flat (no folders).\"\"\"\n        import io\n\n        # Create a ZIP file with folder structure\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"folder/file1.txt\", \"Content 1\")\n            zf.writestr(\"folder/file2.txt\", \"Content 2\")\n        zip_buffer.seek(0)\n\n        files = {\"file\": (\"test.zip\", zip_buffer.read(), \"application/zip\")}\n        params = {\"preserve_structure\": \"false\"}\n        response = await async_client.post(\"/api/v1/library/files/extract-zip\", files=files, params=params)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"extracted\"] == 2\n        assert result[\"folders_created\"] == 0  # No folders created when flat\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify ZIP extraction skips __MACOSX and hidden files.\"\"\"\n        import io\n\n        # Create a ZIP file with macOS junk files\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"real_file.txt\", \"Real content\")\n            zf.writestr(\"__MACOSX/._real_file.txt\", \"macOS metadata\")\n            zf.writestr(\".hidden_file\", \"Hidden content\")\n        zip_buffer.seek(0)\n\n        files = {\"file\": (\"test.zip\", zip_buffer.read(), \"application/zip\")}\n        response = await async_client.post(\"/api/v1/library/files/extract-zip\", files=files)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"extracted\"] == 1  # Only real_file.txt\n        assert result[\"files\"][0][\"filename\"] == \"real_file.txt\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_extract_zip_create_folder_from_zip(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify ZIP extraction creates a folder from the ZIP filename.\"\"\"\n        import io\n\n        # Create a ZIP file with some files\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"file1.txt\", \"Content 1\")\n            zf.writestr(\"file2.txt\", \"Content 2\")\n        zip_buffer.seek(0)\n\n        files = {\"file\": (\"MyProject.zip\", zip_buffer.read(), \"application/zip\")}\n        params = {\"create_folder_from_zip\": \"true\", \"preserve_structure\": \"false\"}\n        response = await async_client.post(\"/api/v1/library/files/extract-zip\", files=files, params=params)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"extracted\"] == 2\n        assert result[\"folders_created\"] == 1  # MyProject folder created\n\n        # Verify the files are in a folder\n        assert result[\"files\"][0][\"folder_id\"] is not None\n        assert result[\"files\"][1][\"folder_id\"] is not None\n        # Both files should be in the same folder\n        assert result[\"files\"][0][\"folder_id\"] == result[\"files\"][1][\"folder_id\"]\n\n        # Verify the folder was created with the right name\n        folder_response = await async_client.get(f\"/api/v1/library/folders/{result['files'][0]['folder_id']}\")\n        assert folder_response.status_code == 200\n        folder = folder_response.json()\n        assert folder[\"name\"] == \"MyProject\"\n\n\nclass TestLibraryStlThumbnailAPI:\n    \"\"\"Integration tests for STL thumbnail generation endpoints.\"\"\"\n\n    @pytest.fixture\n    async def file_factory(self, db_session):\n        \"\"\"Factory to create test files.\"\"\"\n        _counter = [0]\n\n        async def _create_file(**kwargs):\n            from backend.app.models.library import LibraryFile\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"test_model_{counter}.stl\",\n                \"file_path\": f\"/test/path/test_model_{counter}.stl\",\n                \"file_size\": 1024,\n                \"file_type\": \"stl\",\n            }\n            defaults.update(kwargs)\n\n            lib_file = LibraryFile(**defaults)\n            db_session.add(lib_file)\n            await db_session.commit()\n            await db_session.refresh(lib_file)\n            return lib_file\n\n        return _create_file\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify batch thumbnail generation with no files.\"\"\"\n        data = {\"all_missing\": True}\n        response = await async_client.post(\"/api/v1/library/generate-stl-thumbnails\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"processed\"] == 0\n        assert result[\"succeeded\"] == 0\n        assert result[\"failed\"] == 0\n        assert result[\"results\"] == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify batch thumbnail generation with no criteria returns empty.\"\"\"\n        data = {}\n        response = await async_client.post(\"/api/v1/library/generate-stl-thumbnails\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"processed\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_batch_generate_thumbnails_file_not_on_disk(\n        self, async_client: AsyncClient, file_factory, db_session\n    ):\n        \"\"\"Verify batch thumbnail generation handles missing files gracefully.\"\"\"\n        # Create a file in DB but not on disk\n        stl_file = await file_factory(\n            filename=\"missing.stl\",\n            file_path=\"/nonexistent/path/missing.stl\",\n            thumbnail_path=None,\n        )\n\n        data = {\"file_ids\": [stl_file.id]}\n        response = await async_client.post(\"/api/v1/library/generate-stl-thumbnails\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"processed\"] == 1\n        assert result[\"succeeded\"] == 0\n        assert result[\"failed\"] == 1\n        assert result[\"results\"][0][\"success\"] is False\n        assert \"not found\" in result[\"results\"][0][\"error\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify batch thumbnail generation with a real STL file.\"\"\"\n        from backend.app.models.library import LibraryFile\n\n        # Create a simple ASCII STL cube\n        stl_content = \"\"\"solid cube\nfacet normal 0 0 -1\n  outer loop\n    vertex 0 0 0\n    vertex 1 0 0\n    vertex 1 1 0\n  endloop\nendfacet\nfacet normal 0 0 1\n  outer loop\n    vertex 0 0 1\n    vertex 1 1 1\n    vertex 1 0 1\n  endloop\nendfacet\nendsolid cube\"\"\"\n\n        with tempfile.NamedTemporaryFile(suffix=\".stl\", delete=False, mode=\"w\") as f:\n            f.write(stl_content)\n            stl_path = f.name\n\n        try:\n            # Create file in DB pointing to real STL\n            lib_file = LibraryFile(\n                filename=\"test_cube.stl\",\n                file_path=stl_path,\n                file_size=len(stl_content),\n                file_type=\"stl\",\n                thumbnail_path=None,\n            )\n            db_session.add(lib_file)\n            await db_session.commit()\n            await db_session.refresh(lib_file)\n\n            data = {\"file_ids\": [lib_file.id]}\n            response = await async_client.post(\"/api/v1/library/generate-stl-thumbnails\", json=data)\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"processed\"] == 1\n            # Result depends on whether trimesh/matplotlib are installed\n            # Either succeeds or fails gracefully\n            assert result[\"succeeded\"] + result[\"failed\"] == 1\n        finally:\n            import os\n\n            if os.path.exists(stl_path):\n                os.unlink(stl_path)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify file upload accepts generate_stl_thumbnails parameter.\"\"\"\n        # Create a simple STL file\n        stl_content = b\"solid test\\nendsolid test\"\n\n        files = {\"file\": (\"test.stl\", stl_content, \"application/octet-stream\")}\n        params = {\"generate_stl_thumbnails\": \"false\"}\n        response = await async_client.post(\"/api/v1/library/files\", files=files, params=params)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"filename\"] == \"test.stl\"\n        assert result[\"file_type\"] == \"stl\"\n        # No thumbnail should be generated when disabled\n        assert result[\"thumbnail_path\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify ZIP extraction accepts generate_stl_thumbnails parameter.\"\"\"\n        # Create a ZIP file containing an STL\n        stl_content = b\"solid test\\nendsolid test\"\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"model.stl\", stl_content)\n        zip_buffer.seek(0)\n\n        files = {\"file\": (\"test.zip\", zip_buffer.read(), \"application/zip\")}\n        params = {\"generate_stl_thumbnails\": \"false\"}\n        response = await async_client.post(\"/api/v1/library/files/extract-zip\", files=files, params=params)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"extracted\"] == 1\n        assert result[\"files\"][0][\"filename\"] == \"model.stl\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify batch thumbnail generation can filter by folder.\"\"\"\n        from backend.app.models.library import LibraryFolder\n\n        # Create a folder\n        folder = LibraryFolder(name=\"STL Folder\")\n        db_session.add(folder)\n        await db_session.commit()\n        await db_session.refresh(folder)\n\n        # Create STL file in folder (no thumbnail)\n        stl_in_folder = await file_factory(\n            filename=\"in_folder.stl\",\n            folder_id=folder.id,\n            thumbnail_path=None,\n        )\n\n        # Create STL file at root (no thumbnail)\n        _stl_at_root = await file_factory(\n            filename=\"at_root.stl\",\n            folder_id=None,\n            thumbnail_path=None,\n        )\n\n        # Request thumbnails only for files in folder\n        data = {\"folder_id\": folder.id, \"all_missing\": True}\n        response = await async_client.post(\"/api/v1/library/generate-stl-thumbnails\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        # Should only process the file in the folder\n        assert result[\"processed\"] == 1\n        assert result[\"results\"][0][\"file_id\"] == stl_in_folder.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):\n        \"\"\"Verify batch thumbnail generation finds all STL files missing thumbnails.\"\"\"\n        # Create files with and without thumbnails\n        _stl_with_thumb = await file_factory(\n            filename=\"with_thumb.stl\",\n            thumbnail_path=\"/some/path/thumb.png\",\n        )\n        stl_without_thumb1 = await file_factory(\n            filename=\"without_thumb1.stl\",\n            thumbnail_path=None,\n        )\n        stl_without_thumb2 = await file_factory(\n            filename=\"without_thumb2.stl\",\n            thumbnail_path=None,\n        )\n\n        data = {\"all_missing\": True}\n        response = await async_client.post(\"/api/v1/library/generate-stl-thumbnails\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        # Should only process files without thumbnails\n        assert result[\"processed\"] == 2\n        file_ids = {r[\"file_id\"] for r in result[\"results\"]}\n        assert stl_without_thumb1.id in file_ids\n        assert stl_without_thumb2.id in file_ids\n\n\nclass TestLibraryPathHelpers:\n    \"\"\"Tests for path handling utilities used for backup portability.\"\"\"\n\n    def test_to_relative_path_converts_absolute(self):\n        \"\"\"Verify absolute paths are converted to relative paths.\"\"\"\n        from backend.app.api.routes.library import to_relative_path\n        from backend.app.core.config import settings\n\n        base_dir = str(settings.base_dir)\n        abs_path = f\"{base_dir}/archive/library/files/test.3mf\"\n        rel_path = to_relative_path(abs_path)\n\n        assert not rel_path.startswith(\"/\")\n        assert rel_path == \"archive/library/files/test.3mf\"\n\n    def test_to_relative_path_handles_path_object(self):\n        \"\"\"Verify Path objects are handled correctly.\"\"\"\n        from pathlib import Path\n\n        from backend.app.api.routes.library import to_relative_path\n        from backend.app.core.config import settings\n\n        abs_path = Path(settings.base_dir) / \"archive\" / \"test.3mf\"\n        rel_path = to_relative_path(abs_path)\n\n        assert not rel_path.startswith(\"/\")\n        assert rel_path == \"archive/test.3mf\"\n\n    def test_to_relative_path_returns_empty_for_empty_input(self):\n        \"\"\"Verify empty input returns empty string.\"\"\"\n        from backend.app.api.routes.library import to_relative_path\n\n        assert to_relative_path(\"\") == \"\"\n        assert to_relative_path(None) == \"\"\n\n    def test_to_absolute_path_converts_relative(self):\n        \"\"\"Verify relative paths are converted to absolute paths.\"\"\"\n        from backend.app.api.routes.library import to_absolute_path\n        from backend.app.core.config import settings\n\n        rel_path = \"archive/library/files/test.3mf\"\n        abs_path = to_absolute_path(rel_path)\n\n        assert abs_path is not None\n        assert abs_path.is_absolute()\n        assert str(abs_path) == f\"{settings.base_dir}/archive/library/files/test.3mf\"\n\n    def test_to_absolute_path_handles_already_absolute(self):\n        \"\"\"Verify already absolute paths are returned as-is (for backwards compatibility).\"\"\"\n        from backend.app.api.routes.library import to_absolute_path\n\n        abs_path_str = \"/data/archive/test.3mf\"\n        result = to_absolute_path(abs_path_str)\n\n        assert result is not None\n        assert str(result) == abs_path_str\n\n    def test_to_absolute_path_returns_none_for_empty(self):\n        \"\"\"Verify None/empty input returns None.\"\"\"\n        from backend.app.api.routes.library import to_absolute_path\n\n        assert to_absolute_path(None) is None\n        assert to_absolute_path(\"\") is None\n\n\nclass TestLibraryPermissions:\n    \"\"\"Tests for library permission enforcement.\"\"\"\n\n    @pytest.fixture\n    async def auth_setup(self, db_session):\n        \"\"\"Set up auth with users of different permission levels.\"\"\"\n        from backend.app.core.auth import create_access_token, get_password_hash\n        from backend.app.models.group import Group\n        from backend.app.models.settings import Settings\n        from backend.app.models.user import User\n\n        # Enable auth\n        settings = Settings(key=\"auth_enabled\", value=\"true\")\n        db_session.add(settings)\n        await db_session.commit()\n\n        # Groups are auto-seeded during db init, but we need to commit them\n        await db_session.commit()\n\n        # Get groups\n        from sqlalchemy import select\n\n        admin_group = (await db_session.execute(select(Group).where(Group.name == \"Administrators\"))).scalar_one()\n        operator_group = (await db_session.execute(select(Group).where(Group.name == \"Operators\"))).scalar_one()\n        viewer_group = (await db_session.execute(select(Group).where(Group.name == \"Viewers\"))).scalar_one()\n\n        password_hash = get_password_hash(\"password\")\n\n        # Create users\n        admin_user = User(username=\"admin_lib\", password_hash=password_hash, role=\"admin\", is_active=True)\n        admin_user.groups.append(admin_group)\n\n        operator_user = User(username=\"operator_lib\", password_hash=password_hash, is_active=True)\n        operator_user.groups.append(operator_group)\n\n        viewer_user = User(username=\"viewer_lib\", password_hash=password_hash, is_active=True)\n        viewer_user.groups.append(viewer_group)\n\n        db_session.add_all([admin_user, operator_user, viewer_user])\n        await db_session.commit()\n\n        # Create tokens\n        admin_token = create_access_token(data={\"sub\": admin_user.username})\n        operator_token = create_access_token(data={\"sub\": operator_user.username})\n        viewer_token = create_access_token(data={\"sub\": viewer_user.username})\n\n        return {\n            \"admin_user\": admin_user,\n            \"operator_user\": operator_user,\n            \"viewer_user\": viewer_user,\n            \"admin_token\": admin_token,\n            \"operator_token\": operator_token,\n            \"viewer_token\": viewer_token,\n        }\n\n    @pytest.fixture\n    async def test_file(self, db_session, auth_setup):\n        \"\"\"Create a test file owned by the operator user.\"\"\"\n        from backend.app.models.library import LibraryFile\n\n        operator_user = auth_setup[\"operator_user\"]\n        lib_file = LibraryFile(\n            filename=\"test.txt\",\n            file_path=\"data/archive/library/files/test.txt\",\n            file_type=\"txt\",\n            file_size=100,\n            created_by_id=operator_user.id,\n        )\n        db_session.add(lib_file)\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n        return lib_file\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):\n        \"\"\"Verify list_files requires library:read permission.\"\"\"\n        viewer_token = auth_setup[\"viewer_token\"]\n\n        # Viewers have library:read, should succeed\n        response = await async_client.get(\"/api/v1/library/files\", headers={\"Authorization\": f\"Bearer {viewer_token}\"})\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):\n        \"\"\"Verify list_files denied without auth when auth is enabled.\"\"\"\n        from backend.app.models.settings import Settings\n\n        # Enable auth\n        settings = Settings(key=\"auth_enabled\", value=\"true\")\n        db_session.add(settings)\n        await db_session.commit()\n\n        # Request without token should fail\n        response = await async_client.get(\"/api/v1/library/files\")\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):\n        \"\"\"Verify operator can delete their own files.\"\"\"\n        from pathlib import Path\n\n        # Create actual file on disk so delete doesn't fail\n        from backend.app.core.config import settings as app_settings\n\n        file_path = Path(app_settings.base_dir) / test_file.file_path\n        file_path.parent.mkdir(parents=True, exist_ok=True)\n        file_path.write_text(\"test content\")\n\n        operator_token = auth_setup[\"operator_token\"]\n\n        response = await async_client.delete(\n            f\"/api/v1/library/files/{test_file.id}\", headers={\"Authorization\": f\"Bearer {operator_token}\"}\n        )\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):\n        \"\"\"Verify operator cannot delete files owned by others.\"\"\"\n        # Create another operator user with a file\n        from sqlalchemy import select\n\n        from backend.app.core.auth import create_access_token\n        from backend.app.models.group import Group\n        from backend.app.models.library import LibraryFile\n        from backend.app.models.user import User\n\n        operator_group = (await db_session.execute(select(Group).where(Group.name == \"Operators\"))).scalar_one()\n\n        from backend.app.core.auth import get_password_hash as get_pw_hash\n\n        other_user = User(username=\"other_op\", password_hash=get_pw_hash(\"password\"), is_active=True)\n        other_user.groups.append(operator_group)\n        db_session.add(other_user)\n        await db_session.commit()\n        await db_session.refresh(other_user)\n\n        # Create file owned by other user\n        other_file = LibraryFile(\n            filename=\"other.txt\",\n            file_path=\"data/archive/library/files/other.txt\",\n            file_type=\"txt\",\n            file_size=100,\n            created_by_id=other_user.id,\n        )\n        db_session.add(other_file)\n        await db_session.commit()\n        await db_session.refresh(other_file)\n\n        # Original operator should not be able to delete it\n        operator_token = auth_setup[\"operator_token\"]\n        response = await async_client.delete(\n            f\"/api/v1/library/files/{other_file.id}\", headers={\"Authorization\": f\"Bearer {operator_token}\"}\n        )\n        assert response.status_code == 403\n        assert \"your own files\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):\n        \"\"\"Verify admin can delete any file.\"\"\"\n        from pathlib import Path\n\n        from backend.app.core.config import settings as app_settings\n        from backend.app.models.library import LibraryFile\n\n        # Create file owned by operator\n        operator_user = auth_setup[\"operator_user\"]\n        lib_file = LibraryFile(\n            filename=\"admin_can_delete.txt\",\n            file_path=\"data/archive/library/files/admin_can_delete.txt\",\n            file_type=\"txt\",\n            file_size=100,\n            created_by_id=operator_user.id,\n        )\n        db_session.add(lib_file)\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n\n        # Create actual file on disk\n        file_path = Path(app_settings.base_dir) / lib_file.file_path\n        file_path.parent.mkdir(parents=True, exist_ok=True)\n        file_path.write_text(\"test content\")\n\n        # Admin should be able to delete it\n        admin_token = auth_setup[\"admin_token\"]\n        response = await async_client.delete(\n            f\"/api/v1/library/files/{lib_file.id}\", headers={\"Authorization\": f\"Bearer {admin_token}\"}\n        )\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):\n        \"\"\"Verify viewer cannot delete any files.\"\"\"\n        viewer_token = auth_setup[\"viewer_token\"]\n\n        response = await async_client.delete(\n            f\"/api/v1/library/files/{test_file.id}\", headers={\"Authorization\": f\"Bearer {viewer_token}\"}\n        )\n        # Viewers don't have delete_own or delete_all permissions\n        assert response.status_code == 403\n"
  },
  {
    "path": "backend/tests/integration/test_maintenance_api.py",
    "content": "\"\"\"Integration tests for Maintenance API endpoints.\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestMaintenanceTypesAPI:\n    \"\"\"Integration tests for /api/v1/maintenance/types endpoints.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_maintenance_types(self, async_client: AsyncClient):\n        \"\"\"Verify maintenance types list returns data with defaults.\"\"\"\n        response = await async_client.get(\"/api/v1/maintenance/types\")\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n        # Should have default system types\n        assert len(data) >= 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_includes_system_types(self, async_client: AsyncClient):\n        \"\"\"Verify default system types are created.\"\"\"\n        response = await async_client.get(\"/api/v1/maintenance/types\")\n        assert response.status_code == 200\n        data = response.json()\n        names = [t[\"name\"] for t in data]\n        # Check for some default types\n        assert \"Lubricate Linear Rails\" in names or len(data) > 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_custom_maintenance_type(self, async_client: AsyncClient):\n        \"\"\"Verify custom maintenance type can be created.\"\"\"\n        data = {\n            \"name\": \"Custom Test Task\",\n            \"description\": \"Test description\",\n            \"default_interval_hours\": 200.0,\n            \"interval_type\": \"hours\",\n            \"icon\": \"Wrench\",\n        }\n        response = await async_client.post(\"/api/v1/maintenance/types\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"Custom Test Task\"\n        assert result[\"is_system\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_maintenance_type(self, async_client: AsyncClient):\n        \"\"\"Verify maintenance type can be updated.\"\"\"\n        # First create a custom type\n        create_data = {\n            \"name\": \"Update Test\",\n            \"description\": \"Original\",\n            \"default_interval_hours\": 100.0,\n        }\n        create_response = await async_client.post(\"/api/v1/maintenance/types\", json=create_data)\n        assert create_response.status_code == 200\n        type_id = create_response.json()[\"id\"]\n\n        # Update it\n        update_data = {\"description\": \"Updated description\"}\n        response = await async_client.patch(f\"/api/v1/maintenance/types/{type_id}\", json=update_data)\n        assert response.status_code == 200\n        assert response.json()[\"description\"] == \"Updated description\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_custom_maintenance_type(self, async_client: AsyncClient):\n        \"\"\"Verify custom maintenance type can be deleted.\"\"\"\n        # Create a custom type\n        create_data = {\n            \"name\": \"Delete Test\",\n            \"description\": \"To be deleted\",\n            \"default_interval_hours\": 50.0,\n        }\n        create_response = await async_client.post(\"/api/v1/maintenance/types\", json=create_data)\n        type_id = create_response.json()[\"id\"]\n\n        # Delete it\n        response = await async_client.delete(f\"/api/v1/maintenance/types/{type_id}\")\n        assert response.status_code == 200\n\n\nclass TestPrinterMaintenanceAPI:\n    \"\"\"Integration tests for /api/v1/maintenance/printers endpoints.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_printer_maintenance_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/maintenance/printers/9999\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_printer_maintenance(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify maintenance overview for a printer.\"\"\"\n        printer = await printer_factory(name=\"Maintenance Test Printer\")\n        response = await async_client.get(f\"/api/v1/maintenance/printers/{printer.id}\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"printer_id\"] == printer.id\n        assert data[\"printer_name\"] == \"Maintenance Test Printer\"\n        assert \"maintenance_items\" in data\n        assert \"total_print_hours\" in data\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_all_maintenance_overview(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify overview endpoint returns all printers.\"\"\"\n        await printer_factory(name=\"Overview Printer 1\")\n        await printer_factory(name=\"Overview Printer 2\")\n        response = await async_client.get(\"/api/v1/maintenance/overview\")\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_maintenance_summary(self, async_client: AsyncClient):\n        \"\"\"Verify summary endpoint returns counts.\"\"\"\n        response = await async_client.get(\"/api/v1/maintenance/summary\")\n        assert response.status_code == 200\n        data = response.json()\n        assert \"total_due\" in data\n        assert \"total_warning\" in data\n        assert \"printers_with_issues\" in data\n\n\nclass TestMaintenanceItemsAPI:\n    \"\"\"Integration tests for /api/v1/maintenance/items endpoints.\"\"\"\n\n    @pytest.fixture\n    async def maintenance_item(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Create a maintenance item for testing.\"\"\"\n        printer = await printer_factory(name=\"Item Test Printer\")\n        # Get the printer's maintenance overview to create items\n        response = await async_client.get(f\"/api/v1/maintenance/printers/{printer.id}\")\n        assert response.status_code == 200\n        data = response.json()\n        # Return the first maintenance item\n        if data[\"maintenance_items\"]:\n            return data[\"maintenance_items\"][0]\n        return None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_maintenance_item(self, async_client: AsyncClient, maintenance_item):\n        \"\"\"Verify maintenance item can be updated.\"\"\"\n        if not maintenance_item:\n            pytest.skip(\"No maintenance items available\")\n\n        item_id = maintenance_item[\"id\"]\n        response = await async_client.patch(\n            f\"/api/v1/maintenance/items/{item_id}\", json={\"custom_interval_hours\": 150.0}\n        )\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_maintenance_item(self, async_client: AsyncClient, maintenance_item):\n        \"\"\"Verify maintenance item can be disabled.\"\"\"\n        if not maintenance_item:\n            pytest.skip(\"No maintenance items available\")\n\n        item_id = maintenance_item[\"id\"]\n        response = await async_client.patch(f\"/api/v1/maintenance/items/{item_id}\", json={\"enabled\": False})\n        assert response.status_code == 200\n        assert response.json()[\"enabled\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_perform_maintenance(self, async_client: AsyncClient, maintenance_item):\n        \"\"\"Verify maintenance can be marked as performed.\"\"\"\n        if not maintenance_item:\n            pytest.skip(\"No maintenance items available\")\n\n        item_id = maintenance_item[\"id\"]\n        response = await async_client.post(\n            f\"/api/v1/maintenance/items/{item_id}/perform\", json={\"notes\": \"Test maintenance performed\"}\n        )\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"last_performed_at\"] is not None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_maintenance_history(self, async_client: AsyncClient, maintenance_item):\n        \"\"\"Verify maintenance history can be retrieved.\"\"\"\n        if not maintenance_item:\n            pytest.skip(\"No maintenance items available\")\n\n        item_id = maintenance_item[\"id\"]\n        # First perform maintenance to create history\n        await async_client.post(f\"/api/v1/maintenance/items/{item_id}/perform\", json={\"notes\": \"History test\"})\n\n        response = await async_client.get(f\"/api/v1/maintenance/items/{item_id}/history\")\n        assert response.status_code == 200\n        history = response.json()\n        assert isinstance(history, list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_maintenance_item_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent maintenance item.\"\"\"\n        response = await async_client.patch(\"/api/v1/maintenance/items/9999\", json={\"enabled\": False})\n        assert response.status_code == 404\n\n\nclass TestPrinterHoursAPI:\n    \"\"\"Integration tests for /api/v1/maintenance/printers/{id}/hours endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_set_printer_hours(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify printer hours can be set.\"\"\"\n        printer = await printer_factory(name=\"Hours Test Printer\")\n        response = await async_client.patch(\n            f\"/api/v1/maintenance/printers/{printer.id}/hours\", params={\"total_hours\": 500.0}\n        )\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"total_hours\"] == 500.0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_set_printer_hours_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.patch(\"/api/v1/maintenance/printers/9999/hours\", params={\"total_hours\": 100.0})\n        assert response.status_code == 404\n"
  },
  {
    "path": "backend/tests/integration/test_metrics_api.py",
    "content": "\"\"\"Integration tests for Prometheus Metrics API endpoint.\n\nTests the /api/v1/metrics endpoint for Prometheus scraping.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestMetricsAPI:\n    \"\"\"Integration tests for /api/v1/metrics endpoint.\"\"\"\n\n    # ========================================================================\n    # Metrics endpoint access control\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_metrics_disabled_returns_404(self, async_client: AsyncClient):\n        \"\"\"Verify metrics endpoint returns 404 when disabled.\"\"\"\n        # Ensure prometheus is disabled\n        await async_client.put(\"/api/v1/settings/\", json={\"prometheus_enabled\": False})\n\n        response = await async_client.get(\"/api/v1/metrics\")\n\n        assert response.status_code == 404\n        assert \"not enabled\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_metrics_enabled_without_token(self, async_client: AsyncClient):\n        \"\"\"Verify metrics endpoint works when enabled without token.\"\"\"\n        # Enable prometheus without token\n        await async_client.put(\"/api/v1/settings/\", json={\"prometheus_enabled\": True, \"prometheus_token\": \"\"})\n\n        response = await async_client.get(\"/api/v1/metrics\")\n\n        assert response.status_code == 200\n        assert response.headers[\"content-type\"].startswith(\"text/plain\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_metrics_with_token_requires_auth(self, async_client: AsyncClient):\n        \"\"\"Verify metrics endpoint requires auth when token is set.\"\"\"\n        # Enable prometheus with token\n        await async_client.put(\"/api/v1/settings/\", json={\"prometheus_enabled\": True, \"prometheus_token\": \"secret123\"})\n\n        # Request without auth\n        response = await async_client.get(\"/api/v1/metrics\")\n        assert response.status_code == 401\n\n        # Request with wrong token\n        response = await async_client.get(\"/api/v1/metrics\", headers={\"Authorization\": \"Bearer wrongtoken\"})\n        assert response.status_code == 401\n\n        # Request with correct token\n        response = await async_client.get(\"/api/v1/metrics\", headers={\"Authorization\": \"Bearer secret123\"})\n        assert response.status_code == 200\n\n    # ========================================================================\n    # Metrics content validation\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_metrics_format(self, async_client: AsyncClient):\n        \"\"\"Verify metrics are in Prometheus text format.\"\"\"\n        # Enable prometheus\n        await async_client.put(\"/api/v1/settings/\", json={\"prometheus_enabled\": True, \"prometheus_token\": \"\"})\n\n        response = await async_client.get(\"/api/v1/metrics\")\n\n        assert response.status_code == 200\n        content = response.text\n\n        # Check for Prometheus format markers\n        assert \"# HELP\" in content\n        assert \"# TYPE\" in content\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_metrics_contains_expected_metrics(self, async_client: AsyncClient):\n        \"\"\"Verify expected metrics are present.\"\"\"\n        # Enable prometheus\n        await async_client.put(\"/api/v1/settings/\", json={\"prometheus_enabled\": True, \"prometheus_token\": \"\"})\n\n        response = await async_client.get(\"/api/v1/metrics\")\n\n        assert response.status_code == 200\n        content = response.text\n\n        # Check for key metrics\n        assert \"bambuddy_printers_connected\" in content\n        assert \"bambuddy_printers_total\" in content\n        assert \"bambuddy_prints_total\" in content\n        assert \"bambuddy_filament_used_grams\" in content\n        assert \"bambuddy_print_time_seconds\" in content\n        assert \"bambuddy_queue_pending\" in content\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_metrics_printer_metrics_when_no_printers(self, async_client: AsyncClient):\n        \"\"\"Verify printer metrics work when no printers configured.\"\"\"\n        # Enable prometheus\n        await async_client.put(\"/api/v1/settings/\", json={\"prometheus_enabled\": True, \"prometheus_token\": \"\"})\n\n        response = await async_client.get(\"/api/v1/metrics\")\n\n        assert response.status_code == 200\n        content = response.text\n\n        # Should still have system metrics\n        assert \"bambuddy_printers_total\" in content\n        assert \"bambuddy_printers_connected\" in content\n\n    # ========================================================================\n    # Settings persistence\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_prometheus_settings_persist(self, async_client: AsyncClient):\n        \"\"\"Verify prometheus settings are saved correctly.\"\"\"\n        # Update settings\n        await async_client.put(\"/api/v1/settings/\", json={\"prometheus_enabled\": True, \"prometheus_token\": \"mytoken\"})\n\n        # Read back settings\n        response = await async_client.get(\"/api/v1/settings/\")\n        settings = response.json()\n\n        assert settings[\"prometheus_enabled\"] is True\n        assert settings[\"prometheus_token\"] == \"mytoken\"\n\n        # Disable and verify\n        await async_client.put(\"/api/v1/settings/\", json={\"prometheus_enabled\": False})\n        response = await async_client.get(\"/api/v1/settings/\")\n        settings = response.json()\n\n        assert settings[\"prometheus_enabled\"] is False\n"
  },
  {
    "path": "backend/tests/integration/test_mfa_api.py",
    "content": "\"\"\"Integration tests for 2FA and OIDC API endpoints.\n\nTests the full request/response cycle for:\n- GET  /api/v1/auth/2fa/status\n- POST /api/v1/auth/2fa/totp/setup\n- POST /api/v1/auth/2fa/totp/enable\n- POST /api/v1/auth/2fa/totp/disable\n- POST /api/v1/auth/2fa/email/enable\n- POST /api/v1/auth/2fa/email/disable\n- POST /api/v1/auth/2fa/verify   (TOTP, email, backup paths)\n- DELETE /api/v1/auth/2fa/admin/{user_id}\n- GET  /api/v1/auth/oidc/providers\n- POST /api/v1/auth/oidc/providers\n- PATCH /api/v1/auth/oidc/providers/{id}\n- DELETE /api/v1/auth/oidc/providers/{id}\n\"\"\"\n\nfrom __future__ import annotations\n\nimport secrets\nfrom datetime import datetime, timedelta, timezone\n\nimport pyotp\nimport pytest\nfrom httpx import AsyncClient\nfrom passlib.context import CryptContext\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.auth_ephemeral import AuthEphemeralToken\nfrom backend.app.models.user import User\n\n_pwd_context = CryptContext(schemes=[\"pbkdf2_sha256\"], deprecated=\"auto\")\n\n# ---------------------------------------------------------------------------\n# Fixtures / helpers\n# ---------------------------------------------------------------------------\n\nAUTH_SETUP_URL = \"/api/v1/auth/setup\"\nLOGIN_URL = \"/api/v1/auth/login\"\n\n\ndef _norm_pw(password: str) -> str:\n    \"\"\"Ensure password meets complexity requirements (I4: SetupRequest now validates).\"\"\"\n    if not any(c.isupper() for c in password):\n        password = password[0].upper() + password[1:]\n    if not any(c not in \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\" for c in password):\n        password = password + \"!\"\n    return password\n\n\nasync def _setup_and_login(client: AsyncClient, username: str, password: str) -> str:\n    \"\"\"Enable auth, create an admin user, login, and return the bearer token.\"\"\"\n    password = _norm_pw(password)\n    await client.post(\n        AUTH_SETUP_URL,\n        json={\n            \"auth_enabled\": True,\n            \"admin_username\": username,\n            \"admin_password\": password,\n        },\n    )\n    resp = await client.post(LOGIN_URL, json={\"username\": username, \"password\": password})\n    assert resp.status_code == 200\n    return resp.json()[\"access_token\"]\n\n\nasync def _login_get_pre_auth_token(client: AsyncClient, username: str, password: str) -> str:\n    \"\"\"Login a user who has 2FA enabled; return the pre_auth_token from the response.\"\"\"\n    password = _norm_pw(password)\n    resp = await client.post(LOGIN_URL, json={\"username\": username, \"password\": password})\n    assert resp.status_code == 200\n    data = resp.json()\n    assert data[\"requires_2fa\"] is True, f\"Expected requires_2fa=True, got {data}\"\n    assert data[\"pre_auth_token\"] is not None\n    return data[\"pre_auth_token\"]\n\n\ndef _auth_header(token: str) -> dict[str, str]:\n    return {\"Authorization\": f\"Bearer {token}\"}\n\n\n# ===========================================================================\n# 2FA Status\n# ===========================================================================\n\n\nclass TestTwoFAStatus:\n    \"\"\"Tests for GET /api/v1/auth/2fa/status.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_status_requires_auth(self, async_client: AsyncClient):\n        response = await async_client.get(\"/api/v1/auth/2fa/status\")\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_status_default_disabled(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"statususer\", \"statuspass123\")\n        response = await async_client.get(\"/api/v1/auth/2fa/status\", headers=_auth_header(token))\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"totp_enabled\"] is False\n        assert data[\"email_otp_enabled\"] is False\n        assert data[\"backup_codes_remaining\"] == 0\n\n\n# ===========================================================================\n# TOTP Setup\n# ===========================================================================\n\n\nclass TestTOTPSetup:\n    \"\"\"Tests for POST /api/v1/auth/2fa/totp/setup.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_setup_requires_auth(self, async_client: AsyncClient):\n        response = await async_client.post(\"/api/v1/auth/2fa/totp/setup\")\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_setup_returns_secret_and_qr(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"totpsetup\", \"totpsetup123\")\n        response = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        assert response.status_code == 200\n        data = response.json()\n        assert \"secret\" in data\n        assert len(data[\"secret\"]) > 0\n        assert \"qr_code_b64\" in data\n        assert data[\"issuer\"] == \"Bambuddy\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_setup_secret_is_valid_base32(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"totpbase32\", \"totpbase32pw\")\n        response = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        assert response.status_code == 200\n        secret = response.json()[\"secret\"]\n        # pyotp will raise on invalid base32\n        totp = pyotp.TOTP(secret)\n        assert len(totp.now()) == 6\n\n\n# ===========================================================================\n# TOTP Enable\n# ===========================================================================\n\n\nclass TestTOTPEnable:\n    \"\"\"Tests for POST /api/v1/auth/2fa/totp/enable.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_without_setup_returns_400(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"nosetupenable\", \"nosetupenable1\")\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": \"123456\"},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_with_invalid_code_returns_400(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"badcodeuser\", \"badcodeuser1\")\n        await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": \"000000\"},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_with_valid_code_returns_backup_codes(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"enableok\", \"enableok123\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 200\n        data = response.json()\n        assert \"backup_codes\" in data\n        assert len(data[\"backup_codes\"]) == 10\n        for code in data[\"backup_codes\"]:\n            assert len(code) == 8\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_status_reflects_enabled_totp(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"statustotp\", \"statustotp1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n\n        status_resp = await async_client.get(\"/api/v1/auth/2fa/status\", headers=_auth_header(token))\n        data = status_resp.json()\n        assert data[\"totp_enabled\"] is True\n        assert data[\"backup_codes_remaining\"] == 10\n\n\n# ===========================================================================\n# TOTP Disable\n# ===========================================================================\n\n\nclass TestTOTPDisable:\n    \"\"\"Tests for POST /api/v1/auth/2fa/totp/disable.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_when_not_enabled_returns_400(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"disablenoenab\", \"disablenoenab1\")\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/totp/disable\",\n            json={\"code\": \"123456\"},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_with_valid_code(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"disableok\", \"disableok123\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n\n        # Disable with a fresh valid code\n        disable_code = pyotp.TOTP(secret).now()\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/totp/disable\",\n            json={\"code\": disable_code},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 200\n        assert \"disabled\" in response.json()[\"message\"].lower()\n\n        # Status should now show disabled\n        status_resp = await async_client.get(\"/api/v1/auth/2fa/status\", headers=_auth_header(token))\n        assert status_resp.json()[\"totp_enabled\"] is False\n\n\n# ===========================================================================\n# Email OTP Enable/Disable\n# ===========================================================================\n\n\nclass TestEmailOTP:\n    \"\"\"Tests for POST /api/v1/auth/2fa/email/enable, /enable/confirm and /disable.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_email_otp_without_email_returns_400(self, async_client: AsyncClient):\n        \"\"\"Users without an email address cannot enable email OTP.\"\"\"\n        token = await _setup_and_login(async_client, \"noemailuser\", \"noemailuser1\")\n        response = await async_client.post(\"/api/v1/auth/2fa/email/enable\", headers=_auth_header(token))\n        assert response.status_code == 400\n        assert \"email\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_confirm_enable_email_otp_happy_path(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"Confirm step activates email OTP when setup_token + code are valid (C5).\"\"\"\n        token = await _setup_and_login(async_client, \"confirmenable\", \"confirmenable1\")\n\n        # Give user an email address directly (SMTP not available in tests)\n        from sqlalchemy import select as sa_select\n\n        result = await db_session.execute(sa_select(User).where(User.username == \"confirmenable\"))\n        user = result.scalar_one()\n        user.email = \"confirmenable@example.com\"\n        await db_session.commit()\n\n        # Inject a known setup token directly into the DB (bypasses SMTP)\n        code = \"123456\"\n        code_hash = _pwd_context.hash(code)\n        setup_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=setup_token,\n                token_type=\"email_otp_setup\",\n                username=\"confirmenable\",\n                nonce=code_hash,\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": code},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 200\n\n        status_resp = await async_client.get(\"/api/v1/auth/2fa/status\", headers=_auth_header(token))\n        assert status_resp.json()[\"email_otp_enabled\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_confirm_enable_email_otp_wrong_code(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"Wrong code on confirm step returns 400 and does not enable email OTP.\"\"\"\n        token = await _setup_and_login(async_client, \"confirmwrong\", \"confirmwrong1\")\n\n        code_hash = _pwd_context.hash(\"654321\")\n        setup_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=setup_token,\n                token_type=\"email_otp_setup\",\n                username=\"confirmwrong\",\n                nonce=code_hash,\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": \"000000\"},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_confirm_enable_email_otp_setup_token_is_single_use(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        \"\"\"Setup token is consumed on first use; replay returns 400.\"\"\"\n        token = await _setup_and_login(async_client, \"confirmonce\", \"confirmonce1\")\n\n        code = \"111111\"\n        code_hash = _pwd_context.hash(code)\n        setup_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=setup_token,\n                token_type=\"email_otp_setup\",\n                username=\"confirmonce\",\n                nonce=code_hash,\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n\n        first = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": code},\n            headers=_auth_header(token),\n        )\n        assert first.status_code == 200\n\n        second = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": code},\n            headers=_auth_header(token),\n        )\n        assert second.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_email_otp_requires_password(self, async_client: AsyncClient):\n        \"\"\"Disabling email OTP requires the account password (C6: re-auth).\"\"\"\n        token = await _setup_and_login(async_client, \"disemailotp\", \"disemailotp1\")\n        # Wrong password → 401\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/email/disable\",\n            json={\"password\": \"wrongpassword\"},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_email_otp_when_enabled(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"Disabling email OTP when enabled turns it off and status reflects that.\"\"\"\n        token = await _setup_and_login(async_client, \"disemailpw\", \"disemailpw1\")\n\n        # Enable email OTP via direct DB injection (no SMTP)\n        code = \"222222\"\n        setup_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=setup_token,\n                token_type=\"email_otp_setup\",\n                username=\"disemailpw\",\n                nonce=_pwd_context.hash(code),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n        await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": code},\n            headers=_auth_header(token),\n        )\n\n        # Now disable\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/email/disable\",\n            json={\"password\": _norm_pw(\"disemailpw1\")},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 200\n\n        status_resp = await async_client.get(\"/api/v1/auth/2fa/status\", headers=_auth_header(token))\n        assert status_resp.json()[\"email_otp_enabled\"] is False\n\n\n# ===========================================================================\n# 2FA Verify — TOTP path\n# ===========================================================================\n\n\nclass TestTwoFAVerifyTOTP:\n    \"\"\"Tests for POST /api/v1/auth/2fa/verify using the TOTP method.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_verify_with_invalid_pre_auth_token(self, async_client: AsyncClient):\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": \"bogus\", \"method\": \"totp\", \"code\": \"123456\"},\n        )\n        assert response.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_verify_totp_issues_jwt(self, async_client: AsyncClient):\n        \"\"\"Full flow: setup → enable TOTP → login → pre_auth_token → verify → JWT.\"\"\"\n        token = await _setup_and_login(async_client, \"verifytotpok\", \"verifytotpok1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n\n        # Login now returns requires_2fa=True + pre_auth_token\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"verifytotpok\", \"verifytotpok1\")\n\n        verify_resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\n                \"pre_auth_token\": pre_auth_token,\n                \"method\": \"totp\",\n                \"code\": pyotp.TOTP(secret).now(),\n            },\n        )\n        assert verify_resp.status_code == 200\n        data = verify_resp.json()\n        assert \"access_token\" in data\n        assert data[\"token_type\"] == \"bearer\"\n        assert data[\"user\"][\"username\"] == \"verifytotpok\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_verify_totp_invalid_code(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"verifybadcode\", \"verifybadcode1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"verifybadcode\", \"verifybadcode1\")\n        verify_resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"totp\", \"code\": \"000000\"},\n        )\n        assert verify_resp.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_verify_invalid_method(self, async_client: AsyncClient):\n        \"\"\"An invalid 2FA method should return 400 even with a valid pre_auth_token.\"\"\"\n        token = await _setup_and_login(async_client, \"invalidmethod\", \"invalidmethod1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"invalidmethod\", \"invalidmethod1\")\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"sms\", \"code\": \"123456\"},\n        )\n        assert response.status_code == 422  # Pydantic Literal validation\n\n\n# ===========================================================================\n# 2FA Verify — Backup code path\n# ===========================================================================\n\n\nclass TestTwoFAVerifyBackup:\n    \"\"\"Tests for POST /api/v1/auth/2fa/verify using the backup method.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_verify_with_backup_code(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"backupcodeok\", \"backupcodeok1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        enable_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n        backup_code = enable_resp.json()[\"backup_codes\"][0]\n\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"backupcodeok\", \"backupcodeok1\")\n        verify_resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"backup\", \"code\": backup_code},\n        )\n        assert verify_resp.status_code == 200\n        assert \"access_token\" in verify_resp.json()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_backup_code_is_single_use(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"backupsingle\", \"backupsingle1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        enable_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n        backup_code = enable_resp.json()[\"backup_codes\"][0]\n\n        # First use — should succeed\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"backupsingle\", \"backupsingle1\")\n        first_resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"backup\", \"code\": backup_code},\n        )\n        assert first_resp.status_code == 200\n\n        # Second use of the same code — must fail (need new pre_auth_token + same backup code)\n        pre_auth_token2 = await _login_get_pre_auth_token(async_client, \"backupsingle\", \"backupsingle1\")\n        second_resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token2, \"method\": \"backup\", \"code\": backup_code},\n        )\n        assert second_resp.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_backup_code_count_decrements(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"backupcount\", \"backupcount1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        enable_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n        backup_code = enable_resp.json()[\"backup_codes\"][0]\n\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"backupcount\", \"backupcount1\")\n        await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"backup\", \"code\": backup_code},\n        )\n\n        # Status is readable with the original full token (still valid)\n        status_resp = await async_client.get(\"/api/v1/auth/2fa/status\", headers=_auth_header(token))\n        assert status_resp.json()[\"backup_codes_remaining\"] == 9\n\n\n# ===========================================================================\n# Rate Limiting\n# ===========================================================================\n\n\nclass TestRateLimiting:\n    \"\"\"Ensure 429 is returned after 5 failed 2FA attempts.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_rate_limit_lockout(self, async_client: AsyncClient):\n        \"\"\"After 5 failed TOTP attempts the 6th must return 429.\"\"\"\n        token = await _setup_and_login(async_client, \"ratelimituser\", \"ratelimituser1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n\n        # 5 failed attempts via the login → pre_auth_token → verify flow\n        for _ in range(5):\n            pre_auth_token = await _login_get_pre_auth_token(async_client, \"ratelimituser\", \"ratelimituser1\")\n            await async_client.post(\n                \"/api/v1/auth/2fa/verify\",\n                json={\"pre_auth_token\": pre_auth_token, \"method\": \"totp\", \"code\": \"000000\"},\n            )\n\n        # 6th attempt should hit the rate limit\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"ratelimituser\", \"ratelimituser1\")\n        response = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"totp\", \"code\": \"000000\"},\n        )\n        assert response.status_code == 429\n\n\n# ===========================================================================\n# Admin 2FA Disable\n# ===========================================================================\n\n\nclass TestAdminDisable2FA:\n    \"\"\"Tests for DELETE /api/v1/auth/2fa/admin/{user_id}.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_admin_disable_requires_admin(self, async_client: AsyncClient):\n        \"\"\"Only admins can use the admin disable endpoint.\"\"\"\n        # The only user in a fresh setup IS admin, so just check the 404 path\n        token = await _setup_and_login(async_client, \"admincheck\", \"admincheck123\")\n        # Try to disable for a non-existent user_id — should get 200 (no-op) or 404\n        response = await async_client.request(\n            \"DELETE\",\n            \"/api/v1/auth/2fa/admin/99999\",\n            json={\"admin_password\": _norm_pw(\"admincheck123\")},\n            headers=_auth_header(token),\n        )\n        # Admin users succeed regardless (returns 200 even if user doesn't exist)\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_admin_disable_clears_totp(self, async_client: AsyncClient):\n        from sqlalchemy import select\n\n        from backend.app.models.user import User\n\n        token = await _setup_and_login(async_client, \"admintotp\", \"admintotp123\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n\n        # Find the user's id by querying status (which works with the token)\n        me_resp = await async_client.get(\"/api/v1/auth/me\", headers=_auth_header(token))\n        user_id = me_resp.json()[\"id\"]\n\n        response = await async_client.request(\n            \"DELETE\",\n            f\"/api/v1/auth/2fa/admin/{user_id}\",\n            json={\"admin_password\": _norm_pw(\"admintotp123\")},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 200\n\n        # I2: admin_disable_2fa bumps password_changed_at, invalidating the old token.\n        # Re-login to get a fresh token before checking status.\n        new_login = await async_client.post(\n            LOGIN_URL, json={\"username\": \"admintotp\", \"password\": _norm_pw(\"admintotp123\")}\n        )\n        assert new_login.status_code == 200, f\"re-login failed: {new_login.json()}\"\n        assert new_login.json().get(\"requires_2fa\") is False, f\"still requires 2FA: {new_login.json()}\"\n        new_token = new_login.json()[\"access_token\"]\n        assert new_token is not None, f\"no access_token in: {new_login.json()}\"\n\n        # Status should now show TOTP disabled\n        status_resp = await async_client.get(\"/api/v1/auth/2fa/status\", headers=_auth_header(new_token))\n        assert status_resp.status_code == 200, f\"status check failed: {status_resp.json()}\"\n        assert status_resp.json()[\"totp_enabled\"] is False\n\n\n# ===========================================================================\n# OIDC Provider CRUD\n# ===========================================================================\n\n\nclass TestOIDCProviders:\n    \"\"\"Tests for OIDC provider management endpoints.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_public_providers_empty(self, async_client: AsyncClient):\n        response = await async_client.get(\"/api/v1/auth/oidc/providers\")\n        assert response.status_code == 200\n        assert isinstance(response.json(), list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_provider_requires_admin(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"oidcadmincreate\", \"oidcadmincreate1\")\n        response = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"PocketID\",\n                \"issuer_url\": \"https://auth.example.com\",\n                \"client_id\": \"bambuddy\",\n                \"client_secret\": \"supersecret\",\n                \"scopes\": \"openid email profile\",\n                \"is_enabled\": True,\n                \"auto_create_users\": False,\n            },\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 201\n        data = response.json()\n        assert data[\"name\"] == \"PocketID\"\n        assert data[\"issuer_url\"] == \"https://auth.example.com\"\n        assert \"client_secret\" not in data  # Secret must not be returned\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_created_provider_appears_in_all_list(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"oidclistall\", \"oidclistall123\")\n        await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"TestProvider\",\n                \"issuer_url\": \"https://test.example.com\",\n                \"client_id\": \"testclient\",\n                \"client_secret\": \"testsecret\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": False,\n            },\n            headers=_auth_header(token),\n        )\n        response = await async_client.get(\"/api/v1/auth/oidc/providers/all\", headers=_auth_header(token))\n        assert response.status_code == 200\n        names = [p[\"name\"] for p in response.json()]\n        assert \"TestProvider\" in names\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disabled_provider_not_in_public_list(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"oidcdisabled\", \"oidcdisabled1\")\n        await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"DisabledProvider\",\n                \"issuer_url\": \"https://disabled.example.com\",\n                \"client_id\": \"dc\",\n                \"client_secret\": \"ds\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": False,\n                \"auto_create_users\": False,\n            },\n            headers=_auth_header(token),\n        )\n        response = await async_client.get(\"/api/v1/auth/oidc/providers\")\n        names = [p[\"name\"] for p in response.json()]\n        assert \"DisabledProvider\" not in names\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_provider(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"oidcupdate\", \"oidcupdate123\")\n        create_resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"OldName\",\n                \"issuer_url\": \"https://update.example.com\",\n                \"client_id\": \"uc\",\n                \"client_secret\": \"us\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": False,\n            },\n            headers=_auth_header(token),\n        )\n        provider_id = create_resp.json()[\"id\"]\n\n        put_resp = await async_client.put(\n            f\"/api/v1/auth/oidc/providers/{provider_id}\",\n            json={\"name\": \"NewName\"},\n            headers=_auth_header(token),\n        )\n        assert put_resp.status_code == 200\n        assert put_resp.json()[\"name\"] == \"NewName\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_provider(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"oidcdelete\", \"oidcdelete123\")\n        create_resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"ToDelete\",\n                \"issuer_url\": \"https://delete.example.com\",\n                \"client_id\": \"dc\",\n                \"client_secret\": \"ds\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": False,\n            },\n            headers=_auth_header(token),\n        )\n        provider_id = create_resp.json()[\"id\"]\n\n        del_resp = await async_client.delete(\n            f\"/api/v1/auth/oidc/providers/{provider_id}\",\n            headers=_auth_header(token),\n        )\n        assert del_resp.status_code == 200\n\n        # No longer in list\n        all_resp = await async_client.get(\"/api/v1/auth/oidc/providers/all\", headers=_auth_header(token))\n        ids = [p[\"id\"] for p in all_resp.json()]\n        assert provider_id not in ids\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_nonexistent_provider_returns_404(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"oidc404\", \"oidc404pass1\")\n        response = await async_client.put(\n            \"/api/v1/auth/oidc/providers/99999\",\n            json={\"name\": \"ghost\"},\n            headers=_auth_header(token),\n        )\n        assert response.status_code == 404\n\n\n# ===========================================================================\n# Security: pre-auth token single-use\n# ===========================================================================\n\n\nclass TestPreAuthTokenSingleUse:\n    \"\"\"pre_auth_token must be consumed on successful 2FA and cannot be reused.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_pre_auth_token_is_single_use(self, async_client: AsyncClient):\n        \"\"\"A pre_auth_token that was successfully used cannot be reused.\"\"\"\n        token = await _setup_and_login(async_client, \"singleusepat\", \"singleusepat1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"singleusepat\", \"singleusepat1\")\n\n        # First use — succeeds\n        first = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"totp\", \"code\": pyotp.TOTP(secret).now()},\n        )\n        assert first.status_code == 200\n\n        # Second use of the same token — must fail (token already consumed on success)\n        second = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"totp\", \"code\": pyotp.TOTP(secret).now()},\n        )\n        assert second.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_pre_auth_token_survives_wrong_code(self, async_client: AsyncClient):\n        \"\"\"A wrong 2FA code must NOT burn the pre_auth_token (user can retry).\"\"\"\n        token = await _setup_and_login(async_client, \"survivepatuser\", \"survivepatuser1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        valid_code = pyotp.TOTP(secret).now()\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"survivepatuser\", \"survivepatuser1\")\n\n        # Wrong code — should fail but not burn the token\n        bad = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"totp\", \"code\": \"000000\"},\n        )\n        assert bad.status_code == 401\n\n        # Same token, correct code — should succeed (token still valid)\n        good = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"totp\", \"code\": pyotp.TOTP(secret).now()},\n        )\n        assert good.status_code == 200\n\n\n# ===========================================================================\n# Security: cross-user token isolation\n# ===========================================================================\n\n\nclass TestCrossUserTokenIsolation:\n    \"\"\"A pre_auth_token issued for user A cannot authenticate as user B.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_token_cannot_be_used_for_different_user(self, async_client: AsyncClient):\n        \"\"\"pre_auth_token is bound to the issuing user; using it to verify a different\n        user's TOTP code must fail.\"\"\"\n        # Set up two users with TOTP\n        token_a = await _setup_and_login(async_client, \"crossusera\", \"crossusera1\")\n        setup_a = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token_a))\n        secret_a = setup_a.json()[\"secret\"]\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": pyotp.TOTP(secret_a).now()},\n            headers=_auth_header(token_a),\n        )\n\n        # Get pre_auth_token for user A\n        pre_auth_a = await _login_get_pre_auth_token(async_client, \"crossusera\", \"crossusera1\")\n\n        # Try to use user A's token but supply a clearly invalid code — must fail\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_a, \"method\": \"totp\", \"code\": \"000000\"},\n        )\n        assert resp.status_code == 401\n\n\n# ===========================================================================\n# Security: admin disable non-admin rejection\n# ===========================================================================\n\n\nclass TestAdminDisableNonAdminRejection:\n    \"\"\"Non-admin users must be rejected from the admin disable endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_non_admin_cannot_disable_2fa(self, async_client: AsyncClient):\n        \"\"\"A regular (non-admin) user must receive 403 from DELETE /auth/2fa/admin/{id}.\"\"\"\n        # Set up admin, then create a regular user\n        admin_token = await _setup_and_login(async_client, \"adminusr2fa\", \"adminusr2fa1\")\n\n        # Create a regular user via user management\n        create_resp = await async_client.post(\n            \"/api/v1/users\",\n            json={\"username\": \"regularusr2fa\", \"password\": \"Regularusr2fa1!\"},\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n\n        # Login as regular user\n        login_resp = await async_client.post(\n            LOGIN_URL,\n            json={\"username\": \"regularusr2fa\", \"password\": \"Regularusr2fa1!\"},\n        )\n        regular_token = login_resp.json()[\"access_token\"]\n\n        # Try to call admin endpoint with the regular user's token\n        resp = await async_client.delete(\n            f\"/api/v1/auth/2fa/admin/{create_resp.json()['id']}\",\n            headers=_auth_header(regular_token),\n        )\n        assert resp.status_code == 403\n\n\n# ===========================================================================\n# Regenerate backup codes\n# ===========================================================================\n\n\nclass TestRegenerateBackupCodes:\n    \"\"\"Tests for POST /api/v1/auth/2fa/totp/regenerate-backup-codes.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_regenerate_requires_totp_enabled(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"regennototp\", \"regennototp1\")\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/regenerate-backup-codes\",\n            json={\"code\": \"123456\"},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_regenerate_invalidates_old_codes(self, async_client: AsyncClient):\n        \"\"\"After regenerating, old backup codes must no longer work.\"\"\"\n        token = await _setup_and_login(async_client, \"regeninval\", \"regeninval1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        enable_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": pyotp.TOTP(secret).now()},\n            headers=_auth_header(token),\n        )\n        old_backup = enable_resp.json()[\"backup_codes\"][0]\n\n        # Regenerate backup codes\n        regen_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/regenerate-backup-codes\",\n            json={\"code\": pyotp.TOTP(secret).now()},\n            headers=_auth_header(token),\n        )\n        assert regen_resp.status_code == 200\n        new_codes = regen_resp.json()[\"backup_codes\"]\n        assert len(new_codes) == 10\n        assert old_backup not in new_codes\n\n        # Old backup code must now fail\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"regeninval\", \"regeninval1\")\n        fail_resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"method\": \"backup\", \"code\": old_backup},\n        )\n        assert fail_resp.status_code == 401\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_regenerate_with_invalid_code_fails(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"regeninvcode\", \"regeninvcode1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": pyotp.TOTP(secret).now()},\n            headers=_auth_header(token),\n        )\n\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/regenerate-backup-codes\",\n            json={\"code\": \"000000\"},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 400\n\n\n# ===========================================================================\n# Security: method field validation\n# ===========================================================================\n\n\nclass TestVerifyMethodValidation:\n    \"\"\"The method field must be one of totp/email/backup (Pydantic Literal).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_invalid_method_rejected_by_schema(self, async_client: AsyncClient):\n        \"\"\"Pydantic should reject unknown method values with 422.\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": \"anytoken\", \"code\": \"123456\", \"method\": \"sms\"},\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_oversized_pre_auth_token_rejected(self, async_client: AsyncClient):\n        \"\"\"pre_auth_token exceeding max_length=128 should be rejected with 422.\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": \"x\" * 200, \"code\": \"123456\", \"method\": \"totp\"},\n        )\n        assert resp.status_code == 422\n\n\n# ===========================================================================\n# Login response shape for 2FA users\n# ===========================================================================\n\n\nclass TestLoginResponseShape:\n    \"\"\"Login for a 2FA-enabled user must return requires_2fa+pre_auth_token\n    and must NOT include access_token (which would bypass the 2FA gate).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_login_2fa_user_omits_access_token(self, async_client: AsyncClient):\n        \"\"\"A user with TOTP enabled must not receive an access_token on /auth/login.\"\"\"\n        token = await _setup_and_login(async_client, \"loginshape\", \"loginshape1\")\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": pyotp.TOTP(secret).now()},\n            headers=_auth_header(token),\n        )\n\n        login_resp = await async_client.post(LOGIN_URL, json={\"username\": \"loginshape\", \"password\": \"Loginshape1!\"})\n        assert login_resp.status_code == 200\n        data = login_resp.json()\n        assert data.get(\"requires_2fa\") is True\n        assert data.get(\"pre_auth_token\") is not None\n        # access_token must NOT be present — it would bypass the 2FA gate\n        assert \"access_token\" not in data or data[\"access_token\"] is None\n\n\n# ===========================================================================\n# TOTP replay protection\n# ===========================================================================\n\n\nasync def _setup_totp_user(client: AsyncClient, username: str, password: str) -> tuple[str, str]:\n    \"\"\"Create user, set up and enable TOTP; return (bearer_token, totp_secret).\"\"\"\n    token = await _setup_and_login(client, username, password)\n    setup_resp = await client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n    secret = setup_resp.json()[\"secret\"]\n    await client.post(\n        \"/api/v1/auth/2fa/totp/enable\",\n        json={\"code\": pyotp.TOTP(secret).now()},\n        headers=_auth_header(token),\n    )\n    return token, secret\n\n\nclass TestTOTPReplay:\n    \"\"\"The same TOTP code must not be accepted twice within one 30-second window.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_totp_replay_rejected_on_verify(self, async_client: AsyncClient):\n        \"\"\"Replaying the same code on /2fa/verify must return 400.\"\"\"\n        _token, secret = await _setup_totp_user(async_client, \"replayverify\", \"replayverify1\")\n        code = pyotp.TOTP(secret).now()\n\n        pre_auth = await _login_get_pre_auth_token(async_client, \"replayverify\", \"replayverify1\")\n        first = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth, \"method\": \"totp\", \"code\": code},\n        )\n        assert first.status_code == 200\n\n        # Second login to get a fresh pre_auth_token (first was consumed)\n        pre_auth2 = await _login_get_pre_auth_token(async_client, \"replayverify\", \"replayverify1\")\n        second = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth2, \"method\": \"totp\", \"code\": code},\n        )\n        assert second.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_totp_replay_rejected_on_disable(self, async_client: AsyncClient):\n        \"\"\"A code already used in verify_2fa must be rejected on /2fa/totp/disable.\"\"\"\n        _setup_token, secret = await _setup_totp_user(async_client, \"replaydisable\", \"replaydisable1\")\n        code = pyotp.TOTP(secret).now()\n\n        # Use the code in verify_2fa — this sets last_totp_counter in DB\n        pre_auth = await _login_get_pre_auth_token(async_client, \"replaydisable\", \"replaydisable1\")\n        verify_resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth, \"method\": \"totp\", \"code\": code},\n        )\n        assert verify_resp.status_code == 200\n        authed_token = verify_resp.json()[\"access_token\"]\n\n        # Replay the same code on disable — must be rejected (same 30-second window)\n        disable_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/disable\",\n            json={\"code\": code},\n            headers=_auth_header(authed_token),\n        )\n        assert disable_resp.status_code == 400\n\n\n# ===========================================================================\n# Rate limiting on disable_totp and regenerate_backup_codes (I10)\n# ===========================================================================\n\n\nclass TestRateLimitingDisableRegenerate:\n    \"\"\"disable_totp and regenerate_backup_codes must enforce rate limiting (I10).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_totp_rate_limited_after_failures(self, async_client: AsyncClient):\n        \"\"\"Repeated wrong codes on /2fa/totp/disable trigger 429.\"\"\"\n        token, _secret = await _setup_totp_user(async_client, \"rldisable\", \"rldisable1\")\n        for _ in range(5):\n            await async_client.post(\n                \"/api/v1/auth/2fa/totp/disable\",\n                json={\"code\": \"000000\"},\n                headers=_auth_header(token),\n            )\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/disable\",\n            json={\"code\": \"000000\"},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 429\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_regenerate_backup_codes_rate_limited_after_failures(self, async_client: AsyncClient):\n        \"\"\"Repeated wrong codes on /2fa/totp/regenerate-backup-codes trigger 429.\"\"\"\n        token, _secret = await _setup_totp_user(async_client, \"rlregen\", \"rlregen1\")\n        for _ in range(5):\n            await async_client.post(\n                \"/api/v1/auth/2fa/totp/regenerate-backup-codes\",\n                json={\"code\": \"000000\"},\n                headers=_auth_header(token),\n            )\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/regenerate-backup-codes\",\n            json={\"code\": \"000000\"},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 429\n\n\n# ===========================================================================\n# Email OTP send → verify end-to-end (coverage gap C3)\n# ===========================================================================\n\n\nclass TestEmailOTPSendVerify:\n    \"\"\"Full email OTP login: send code → verify code → JWT.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_email_otp_send_and_verify(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"login → POST /2fa/email/send (patched SMTP) → POST /2fa/verify → JWT.\"\"\"\n        import re\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        from sqlalchemy import select as sa_select\n\n        token = await _setup_and_login(async_client, \"emailsendok\", \"emailsendok1\")\n\n        # Give the user an email address\n        result = await db_session.execute(sa_select(User).where(User.username == \"emailsendok\"))\n        user = result.scalar_one()\n        user.email = \"emailsendok@example.com\"\n        await db_session.commit()\n\n        # Enable email OTP via DB injection\n        setup_code = \"444444\"\n        setup_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=setup_token,\n                token_type=\"email_otp_setup\",\n                username=\"emailsendok\",\n                nonce=_pwd_context.hash(setup_code),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n        await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": setup_code},\n            headers=_auth_header(token),\n        )\n\n        # Login now requires 2FA — get pre_auth_token (cookie set automatically)\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"emailsendok\", \"emailsendok1\")\n\n        # Mock SMTP and capture the sent OTP code\n        captured: dict[str, str] = {}\n        smtp_settings_mock = MagicMock()\n\n        def _capture_email(smtp_settings, to_email, subject, body_text, body_html):\n            m = re.search(r\"login code is: (\\d{6})\", body_text)\n            if m:\n                captured[\"otp\"] = m.group(1)\n\n        with (\n            patch(\"backend.app.api.routes.mfa.get_smtp_settings\", new=AsyncMock(return_value=smtp_settings_mock)),\n            patch(\"backend.app.api.routes.mfa.send_email\", side_effect=_capture_email),\n        ):\n            send_resp = await async_client.post(\n                \"/api/v1/auth/2fa/email/send\",\n                json={\"pre_auth_token\": pre_auth_token},\n            )\n\n        assert send_resp.status_code == 200, send_resp.text\n        fresh_token = send_resp.json()[\"pre_auth_token\"]\n        assert \"otp\" in captured, \"send_email was not called or code not found in body\"\n\n        # Verify with the captured OTP code — cookie still in the async_client jar\n        verify_resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": fresh_token, \"method\": \"email\", \"code\": captured[\"otp\"]},\n        )\n        assert verify_resp.status_code == 200\n        data = verify_resp.json()\n        assert \"access_token\" in data\n        assert data[\"user\"][\"username\"] == \"emailsendok\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_email_otp_wrong_code_rejected(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"A wrong email OTP code must return 401 without burning the pre_auth_token.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        from sqlalchemy import select as sa_select\n\n        token = await _setup_and_login(async_client, \"emailwrongcode\", \"emailwrongcode1\")\n\n        result = await db_session.execute(sa_select(User).where(User.username == \"emailwrongcode\"))\n        user = result.scalar_one()\n        user.email = \"emailwrongcode@example.com\"\n        await db_session.commit()\n\n        setup_code = \"555555\"\n        setup_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=setup_token,\n                token_type=\"email_otp_setup\",\n                username=\"emailwrongcode\",\n                nonce=_pwd_context.hash(setup_code),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n        await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": setup_code},\n            headers=_auth_header(token),\n        )\n\n        pre_auth_token = await _login_get_pre_auth_token(async_client, \"emailwrongcode\", \"emailwrongcode1\")\n\n        captured: dict[str, str] = {}\n        smtp_mock = MagicMock()\n\n        def _capture(smtp_settings, to_email, subject, body_text, body_html):\n            import re\n\n            m = re.search(r\"login code is: (\\d{6})\", body_text)\n            if m:\n                captured[\"otp\"] = m.group(1)\n\n        with (\n            patch(\"backend.app.api.routes.mfa.get_smtp_settings\", new=AsyncMock(return_value=smtp_mock)),\n            patch(\"backend.app.api.routes.mfa.send_email\", side_effect=_capture),\n        ):\n            send_resp = await async_client.post(\n                \"/api/v1/auth/2fa/email/send\",\n                json={\"pre_auth_token\": pre_auth_token},\n            )\n        assert send_resp.status_code == 200\n        fresh_token = send_resp.json()[\"pre_auth_token\"]\n\n        # Wrong code → 401\n        bad = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": fresh_token, \"method\": \"email\", \"code\": \"000000\"},\n        )\n        assert bad.status_code == 401\n\n        # Correct code still works (token not burned by wrong attempt)\n        good = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": fresh_token, \"method\": \"email\", \"code\": captured[\"otp\"]},\n        )\n        assert good.status_code == 200\n\n\n# ===========================================================================\n# OIDC end-to-end (coverage gap C4)\n# ===========================================================================\n\n\ndef _make_test_rsa_key():\n    \"\"\"Generate a throwaway RSA key pair and a matching JWK set for tests.\"\"\"\n    import base64\n\n    from cryptography.hazmat.primitives import serialization\n    from cryptography.hazmat.primitives.asymmetric import rsa\n\n    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)\n    private_pem = private_key.private_bytes(\n        serialization.Encoding.PEM,\n        serialization.PrivateFormat.TraditionalOpenSSL,\n        serialization.NoEncryption(),\n    )\n    pub_numbers = private_key.public_key().public_numbers()\n\n    def _b64url(n: int, length: int) -> str:\n        return base64.urlsafe_b64encode(n.to_bytes(length, \"big\")).rstrip(b\"=\").decode()\n\n    jwks = {\n        \"keys\": [\n            {\n                \"kty\": \"RSA\",\n                \"use\": \"sig\",\n                \"alg\": \"RS256\",\n                \"kid\": \"test-kid-1\",\n                \"n\": _b64url(pub_numbers.n, 256),\n                \"e\": _b64url(pub_numbers.e, 3),\n            }\n        ]\n    }\n    return private_pem, jwks\n\n\nclass TestOIDCEndToEnd:\n    \"\"\"Full OIDC auth-code flow: state → callback (mocked IdP) → exchange → JWT.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_oidc_callback_creates_user_and_issues_jwt(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"callback validates the mocked ID token, creates a user, and redirects\n        with an oidc_exchange token; exchanging that token returns a full JWT.\"\"\"\n        import time\n        from unittest.mock import patch\n\n        import jwt as pyjwt\n\n        private_pem, jwks_data = _make_test_rsa_key()\n        issuer = \"https://idp.test.example.com\"\n        client_id = \"oidc-test-client\"\n        nonce = secrets.token_urlsafe(16)\n\n        now = int(time.time())\n        id_token = pyjwt.encode(\n            {\n                \"sub\": \"oidc-sub-e2e\",\n                \"iss\": issuer,\n                \"aud\": client_id,\n                \"nonce\": nonce,\n                \"email\": \"oidce2e@example.com\",\n                \"email_verified\": True,\n                \"iat\": now,\n                \"exp\": now + 300,\n            },\n            private_pem,\n            algorithm=\"RS256\",\n            headers={\"kid\": \"test-kid-1\"},\n        )\n\n        # Create OIDC provider\n        admin_token = await _setup_and_login(async_client, \"oidce2eadm\", \"oidce2eadm1\")\n        create_resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"E2E-IdP\",\n                \"issuer_url\": issuer,\n                \"client_id\": client_id,\n                \"client_secret\": \"test-secret\",\n                \"scopes\": \"openid email profile\",\n                \"is_enabled\": True,\n                \"auto_create_users\": True,\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n        provider_id = create_resp.json()[\"id\"]\n\n        # Simulate the authorize step: insert an oidc_state token directly\n        state = secrets.token_urlsafe(32)\n        code_verifier = secrets.token_urlsafe(48)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider_id,\n                nonce=nonce,\n                code_verifier=code_verifier,\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        # Mock httpx calls made inside oidc_callback\n        discovery_doc = {\n            \"issuer\": issuer,\n            \"authorization_endpoint\": f\"{issuer}/auth\",\n            \"token_endpoint\": f\"{issuer}/token\",\n            \"jwks_uri\": f\"{issuer}/.well-known/jwks.json\",\n        }\n        token_response = {\n            \"access_token\": \"mock-access\",\n            \"token_type\": \"Bearer\",\n            \"id_token\": id_token,\n        }\n\n        class _MockResp:\n            def __init__(self, data):\n                self._data = data\n                self.status_code = 200\n                self.is_success = True\n                self.text = str(data)\n\n            def json(self):\n                return self._data\n\n            def raise_for_status(self):\n                pass\n\n        class _MockHttpxClient:\n            def __init__(self, *args, **kwargs):\n                pass\n\n            async def __aenter__(self):\n                return self\n\n            async def __aexit__(self, *args):\n                pass\n\n            async def get(self, url, **kwargs):\n                if \"jwks\" in url:\n                    return _MockResp(jwks_data)\n                return _MockResp(discovery_doc)\n\n            async def post(self, url, **kwargs):\n                return _MockResp(token_response)\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\", _MockHttpxClient):\n            callback_resp = await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=test-auth-code&state={state}\",\n                follow_redirects=False,\n            )\n\n        assert callback_resp.status_code == 302, callback_resp.text\n        location = callback_resp.headers.get(\"location\", \"\")\n        assert \"oidc_token=\" in location, f\"Expected oidc_token in redirect, got: {location}\"\n\n        # Extract and exchange the oidc_exchange token\n        oidc_exchange_token = location.split(\"oidc_token=\")[1].split(\"&\")[0]\n        exchange_resp = await async_client.post(\n            \"/api/v1/auth/oidc/exchange\",\n            json={\"oidc_token\": oidc_exchange_token},\n        )\n        assert exchange_resp.status_code == 200\n        data = exchange_resp.json()\n        assert \"access_token\" in data\n        assert data[\"user\"][\"username\"] is not None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_oidc_callback_invalid_state_redirects_error(self, async_client: AsyncClient):\n        \"\"\"An unknown state token must redirect to /?oidc_error=invalid_state.\"\"\"\n        resp = await async_client.get(\n            \"/api/v1/auth/oidc/callback?code=x&state=totally-bogus-state\",\n            follow_redirects=False,\n        )\n        assert resp.status_code == 302\n        assert \"invalid_state\" in resp.headers.get(\"location\", \"\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_oidc_state_is_single_use(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"Replaying the same state token must fail on the second callback.\"\"\"\n        import time\n        from unittest.mock import patch\n\n        import jwt as pyjwt\n\n        private_pem, jwks_data = _make_test_rsa_key()\n        issuer = \"https://idp2.test.example.com\"\n        client_id = \"oidc-client-2\"\n        nonce = secrets.token_urlsafe(16)\n        now = int(time.time())\n        id_token = pyjwt.encode(\n            {\n                \"sub\": \"sub-single-use\",\n                \"iss\": issuer,\n                \"aud\": client_id,\n                \"nonce\": nonce,\n                \"email\": \"su@example.com\",\n                \"email_verified\": True,\n                \"iat\": now,\n                \"exp\": now + 300,\n            },\n            private_pem,\n            algorithm=\"RS256\",\n            headers={\"kid\": \"test-kid-1\"},\n        )\n\n        admin_token = await _setup_and_login(async_client, \"oidcsuadm\", \"oidcsuadm1\")\n        cr = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"SU-IdP\",\n                \"issuer_url\": issuer,\n                \"client_id\": client_id,\n                \"client_secret\": \"s\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": True,\n            },\n            headers=_auth_header(admin_token),\n        )\n        provider_id = cr.json()[\"id\"]\n\n        state = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider_id,\n                nonce=nonce,\n                code_verifier=secrets.token_urlsafe(48),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        discovery_doc = {\n            \"issuer\": issuer,\n            \"authorization_endpoint\": f\"{issuer}/auth\",\n            \"token_endpoint\": f\"{issuer}/token\",\n            \"jwks_uri\": f\"{issuer}/.well-known/jwks.json\",\n        }\n        token_response = {\"access_token\": \"a\", \"token_type\": \"Bearer\", \"id_token\": id_token}\n\n        class _MockResp:\n            def __init__(self, data):\n                self._data = data\n                self.status_code = 200\n                self.is_success = True\n                self.text = str(data)\n\n            def json(self):\n                return self._data\n\n            def raise_for_status(self):\n                pass\n\n        class _MockHttpxClient:\n            def __init__(self, *a, **kw):\n                pass\n\n            async def __aenter__(self):\n                return self\n\n            async def __aexit__(self, *a):\n                pass\n\n            async def get(self, url, **kw):\n                return _MockResp(jwks_data if \"jwks\" in url else discovery_doc)\n\n            async def post(self, url, **kw):\n                return _MockResp(token_response)\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\", _MockHttpxClient):\n            first = await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=c&state={state}\",\n                follow_redirects=False,\n            )\n            assert first.status_code == 302\n            assert \"oidc_token=\" in first.headers.get(\"location\", \"\")\n\n            # Replay: second callback with the same state must fail\n            second = await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=c&state={state}\",\n                follow_redirects=False,\n            )\n            assert second.status_code == 302\n            assert \"invalid_state\" in second.headers.get(\"location\", \"\")\n\n\n# ===========================================================================\n# H-2: Wrong code must NOT consume the email OTP setup token (peek-then-consume)\n# ===========================================================================\n\n\nclass TestEmailOTPSetupTokenPreservedOnWrongCode:\n    \"\"\"After H-2 fix: a wrong code leaves the setup token intact so the user can retry.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_wrong_code_does_not_consume_setup_token(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"Wrong code returns 400 but the setup token survives; correct code then works.\"\"\"\n        token = await _setup_and_login(async_client, \"h2retryuser\", \"h2retrypass1\")\n\n        code = \"999999\"\n        code_hash = _pwd_context.hash(code)\n        setup_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=setup_token,\n                token_type=\"email_otp_setup\",\n                username=\"h2retryuser\",\n                nonce=code_hash,\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n\n        # First attempt: wrong code → 400\n        wrong = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": \"000000\"},\n            headers=_auth_header(token),\n        )\n        assert wrong.status_code == 400\n\n        # Second attempt: correct code → must succeed (token was NOT consumed)\n        correct = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": code},\n            headers=_auth_header(token),\n        )\n        assert correct.status_code == 200\n\n\n# ===========================================================================\n# M-2: New OIDC provider must default to auto_link_existing_accounts=False\n# ===========================================================================\n\n\nclass TestOIDCProviderAutoLinkDefault:\n    \"\"\"auto_link_existing_accounts must default to False (M-2 fix).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_new_provider_auto_link_defaults_to_false(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"m2autolinkadmin\", \"m2autolinkadmin1\")\n        resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"AutoLinkTest\",\n                \"issuer_url\": \"https://autolink.example.com\",\n                \"client_id\": \"alc\",\n                \"client_secret\": \"als\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": False,\n                # auto_link_existing_accounts intentionally omitted\n            },\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 201\n        assert resp.json()[\"auto_link_existing_accounts\"] is False\n\n\n# ===========================================================================\n# L-5: 2FA verify code format validation\n# ===========================================================================\n\n\nclass TestTwoFAVerifyCodeFormat:\n    \"\"\"TwoFAVerifyRequest.code must be 6–8 alphanumeric characters (L-5).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_code_too_long_rejected(self, async_client: AsyncClient):\n        \"\"\"code > 8 characters must be rejected with 422.\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": \"anytoken\", \"code\": \"1\" * 9, \"method\": \"totp\"},\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_code_non_alphanumeric_rejected(self, async_client: AsyncClient):\n        \"\"\"code containing non-alphanumeric chars must be rejected with 422.\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": \"anytoken\", \"code\": \"12-456\", \"method\": \"totp\"},\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_code_too_short_rejected(self, async_client: AsyncClient):\n        \"\"\"code < 6 characters must be rejected with 422.\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": \"anytoken\", \"code\": \"12345\", \"method\": \"totp\"},\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_code_exactly_6_passes_schema(self, async_client: AsyncClient):\n        \"\"\"6-character alphanumeric code passes schema (may fail 2FA logic with 400).\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": \"x\" * 32, \"code\": \"123456\", \"method\": \"totp\"},\n        )\n        assert resp.status_code != 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_code_exactly_8_passes_schema(self, async_client: AsyncClient):\n        \"\"\"8-character alphanumeric backup code passes schema.\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": \"x\" * 32, \"code\": \"ABCD1234\", \"method\": \"backup\"},\n        )\n        assert resp.status_code != 422\n\n\n# ===========================================================================\n# M-NEW-1: verify_slicer_download_token must NOT consume token on wrong resource\n# ===========================================================================\n\n\nclass TestSlicerTokenResourceBinding:\n    \"\"\"Token for resource A must survive a wrong-resource check and still work for A.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_wrong_resource_does_not_consume_token(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"A slicer token bound to archive:5 must NOT be consumed when checked against archive:6.\"\"\"\n        from datetime import datetime, timedelta, timezone\n\n        from backend.app.core.auth import verify_slicer_download_token\n        from backend.app.models.auth_ephemeral import AuthEphemeralToken\n\n        now = datetime.now(timezone.utc)\n        token_val = secrets.token_urlsafe(24)\n        db_session.add(\n            AuthEphemeralToken(\n                token=token_val,\n                token_type=\"slicer_download\",\n                nonce=\"archive:5\",\n                expires_at=now + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        # Wrong resource → must return False and NOT consume the token\n        wrong = await verify_slicer_download_token(token_val, \"archive\", 6)\n        assert wrong is False\n\n        # Correct resource → must return True (token survived the wrong-resource check)\n        correct = await verify_slicer_download_token(token_val, \"archive\", 5)\n        assert correct is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_correct_resource_consumes_token(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"A slicer token is single-use: second correct-resource check must return False.\"\"\"\n        from datetime import datetime, timedelta, timezone\n\n        from backend.app.core.auth import verify_slicer_download_token\n        from backend.app.models.auth_ephemeral import AuthEphemeralToken\n\n        now = datetime.now(timezone.utc)\n        token_val = secrets.token_urlsafe(24)\n        db_session.add(\n            AuthEphemeralToken(\n                token=token_val,\n                token_type=\"slicer_download\",\n                nonce=\"library:99\",\n                expires_at=now + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        first = await verify_slicer_download_token(token_val, \"library\", 99)\n        assert first is True\n\n        second = await verify_slicer_download_token(token_val, \"library\", 99)\n        assert second is False\n\n\n# ===========================================================================\n# M-NEW-3 / L-NEW-1: Schema length validation for change-password & forgot-password\n# ===========================================================================\n\n\nclass TestSchemaLengthValidationR2:\n    \"\"\"Input length limits added in review round 2.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_change_password_current_too_long_rejected(self, async_client: AsyncClient):\n        \"\"\"current_password > 256 chars must be rejected with 422 (prevents pbkdf2 DoS).\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/users/me/change-password\",\n            json={\"current_password\": \"x\" * 257, \"new_password\": \"ValidPass1!\"},\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_forgot_password_email_too_long_rejected(self, async_client: AsyncClient):\n        \"\"\"email > 254 chars must be rejected with 422.\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/forgot-password\",\n            json={\"email\": \"a\" * 243 + \"@example.com\"},\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_forgot_password_email_at_limit_passes_schema(self, async_client: AsyncClient):\n        \"\"\"Short email passes schema (may return 400/200 from business logic).\"\"\"\n        resp = await async_client.post(\n            \"/api/v1/auth/forgot-password\",\n            json={\"email\": \"user@example.com\"},\n        )\n        assert resp.status_code != 422\n\n\n# ===========================================================================\n# L-NEW-2: TOTPSetupRequest.code max_length\n# ===========================================================================\n\n\nclass TestTOTPSetupCodeMaxLength:\n    \"\"\"TOTPSetupRequest.code must be bounded (L-NEW-2).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_setup_code_too_long_rejected(self, async_client: AsyncClient):\n        \"\"\"code > 8 chars must be rejected with 422.\"\"\"\n        import pyotp as _pyotp\n\n        token = await _setup_and_login(async_client, \"totp_setup_maxlen\", \"totp_setup_maxlen1\")\n        # Enable TOTP so the setup-code guard path is active\n        setup_resp = await async_client.post(\"/api/v1/auth/2fa/totp/setup\", headers=_auth_header(token))\n        secret = setup_resp.json()[\"secret\"]\n        await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": _pyotp.TOTP(secret).now()},\n            headers=_auth_header(token),\n        )\n\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/setup\",\n            json={\"code\": \"1\" * 9},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 422\n\n\n# ===========================================================================\n# L-NEW-3: EmailOTPEnableConfirmRequest.code must be exactly 6 digits\n# ===========================================================================\n\n\nclass TestEmailOTPConfirmCodeFormat:\n    \"\"\"EmailOTPEnableConfirmRequest.code must be 6 digits (L-NEW-3).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_non_digit_code_rejected(self, async_client: AsyncClient):\n        \"\"\"Alpha characters in the email OTP confirm code must be rejected with 422.\"\"\"\n        token = await _setup_and_login(async_client, \"emailotpfmt\", \"emailotpfmt1\")\n\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": \"x\" * 32, \"code\": \"ABCDEF\"},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_seven_digit_code_rejected(self, async_client: AsyncClient):\n        \"\"\"7-digit code must be rejected with 422 (min_length=max_length=6).\"\"\"\n        token = await _setup_and_login(async_client, \"emailotplen7\", \"emailotplen7x\")\n\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": \"x\" * 32, \"code\": \"1234567\"},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_valid_six_digit_code_passes_schema(self, async_client: AsyncClient):\n        \"\"\"6-digit numeric code passes schema (may return 400 on bad token — that's fine).\"\"\"\n        token = await _setup_and_login(async_client, \"emailotpfmt6\", \"emailotpfmt6x\")\n\n        resp = await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": \"x\" * 32, \"code\": \"123456\"},\n            headers=_auth_header(token),\n        )\n        assert resp.status_code != 422\n\n\n# ===========================================================================\n# L-NEW-4: OIDCProviderCreate field max_length constraints\n# ===========================================================================\n\n\nclass TestOIDCProviderFieldLengths:\n    \"\"\"OIDCProviderCreate fields must reject inputs exceeding max_length (L-NEW-4).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_name_too_long_rejected(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"oidcfldadmin\", \"oidcfldadmin1\")\n        resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"n\" * 101,\n                \"issuer_url\": \"https://test.example.com\",\n                \"client_id\": \"cid\",\n                \"client_secret\": \"csec\",\n                \"scopes\": \"openid\",\n            },\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_client_secret_too_long_rejected(self, async_client: AsyncClient):\n        token = await _setup_and_login(async_client, \"oidcseclen\", \"oidcseclen123\")\n        resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"ValidName\",\n                \"issuer_url\": \"https://test.example.com\",\n                \"client_id\": \"cid\",\n                \"client_secret\": \"s\" * 513,\n                \"scopes\": \"openid\",\n            },\n            headers=_auth_header(token),\n        )\n        assert resp.status_code == 422\n\n\n# ---------------------------------------------------------------------------\n# M-NEW-4 / M-NEW-5 / L-NEW-5: UserCreate & UserUpdate field length limits\n# ---------------------------------------------------------------------------\n\n\nclass TestUserCreateUpdateFieldLengths:\n    \"\"\"UserCreate and UserUpdate must enforce max_length on username, password, email.\"\"\"\n\n    @pytest.fixture\n    async def admin_token(self, async_client: AsyncClient) -> str:\n        return await _setup_and_login(async_client, \"ucfldadmin\", \"ucfldadmin1!\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_username_too_long_rejected(self, async_client: AsyncClient, admin_token: str):\n        resp = await async_client.post(\n            \"/api/v1/users/\",\n            json={\n                \"username\": \"u\" * 151,\n                \"password\": \"ValidPass1!\",\n                \"role\": \"user\",\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_password_too_long_rejected(self, async_client: AsyncClient, admin_token: str):\n        resp = await async_client.post(\n            \"/api/v1/users/\",\n            json={\n                \"username\": \"newuserX\",\n                \"password\": \"A1!\" + \"x\" * 254,\n                \"role\": \"user\",\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_email_too_long_rejected(self, async_client: AsyncClient, admin_token: str):\n        resp = await async_client.post(\n            \"/api/v1/users/\",\n            json={\n                \"username\": \"newuserY\",\n                \"password\": \"ValidPass1!\",\n                \"email\": \"a\" * 246 + \"@x.com\",  # total 253 chars -> fine; 248+@x.com=255 -> too long\n                \"role\": \"user\",\n            },\n            headers=_auth_header(admin_token),\n        )\n        # 248 'a' + '@x.com' (6) = 254 chars — just at limit, should pass\n        # Use 249 + '@x.com' = 255 chars to trigger the 422\n        assert resp.status_code in (201, 422)  # boundary sanity check\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_email_exceeds_limit_rejected(self, async_client: AsyncClient, admin_token: str):\n        resp = await async_client.post(\n            \"/api/v1/users/\",\n            json={\n                \"username\": \"newuserZ\",\n                \"password\": \"ValidPass1!\",\n                \"email\": \"a\" * 249 + \"@x.com\",  # 255 chars — exceeds RFC 5321 max of 254\n                \"role\": \"user\",\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_username_too_long_rejected(self, async_client: AsyncClient, admin_token: str):\n        # Create a user first\n        create_resp = await async_client.post(\n            \"/api/v1/users/\",\n            json={\"username\": \"updusr1\", \"password\": \"ValidPass1!\", \"role\": \"user\"},\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n        user_id = create_resp.json()[\"id\"]\n\n        resp = await async_client.patch(\n            f\"/api/v1/users/{user_id}\",\n            json={\"username\": \"u\" * 151},\n            headers=_auth_header(admin_token),\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_password_too_long_rejected(self, async_client: AsyncClient, admin_token: str):\n        create_resp = await async_client.post(\n            \"/api/v1/users/\",\n            json={\"username\": \"updusr2\", \"password\": \"ValidPass1!\", \"role\": \"user\"},\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n        user_id = create_resp.json()[\"id\"]\n\n        resp = await async_client.patch(\n            f\"/api/v1/users/{user_id}\",\n            json={\"password\": \"A1!\" + \"x\" * 254},\n            headers=_auth_header(admin_token),\n        )\n        assert resp.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_email_too_long_rejected(self, async_client: AsyncClient, admin_token: str):\n        create_resp = await async_client.post(\n            \"/api/v1/users/\",\n            json={\"username\": \"updusr3\", \"password\": \"ValidPass1!\", \"role\": \"user\"},\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n        user_id = create_resp.json()[\"id\"]\n\n        resp = await async_client.patch(\n            f\"/api/v1/users/{user_id}\",\n            json={\"email\": \"a\" * 249 + \"@x.com\"},  # 255 chars\n            headers=_auth_header(admin_token),\n        )\n        assert resp.status_code == 422\n\n\n# ---------------------------------------------------------------------------\n# L-NEW-6: per-IP rate limiting on /forgot-password\n# ---------------------------------------------------------------------------\n\n_SMTP_DATA_FOR_IPLIMIT = {\n    \"smtp_host\": \"smtp.test.com\",\n    \"smtp_port\": 587,\n    \"smtp_username\": \"test@test.com\",\n    \"smtp_password\": \"testpass\",\n    \"smtp_security\": \"starttls\",\n    \"smtp_auth_enabled\": True,\n    \"smtp_from_email\": \"noreply@test.com\",\n}\n\n\nclass TestForgotPasswordPerIpRateLimit:\n    \"\"\"POST /forgot-password must enforce a per-IP cap (L-NEW-6).\n\n    The test sends 11 requests from the simulated test-client IP using 11\n    different email addresses (so the per-email bucket is never exhausted).\n    The 11th request must be rejected with 429.\n    \"\"\"\n\n    @pytest.fixture\n    async def advanced_auth_token(self, async_client: AsyncClient) -> str:\n        \"\"\"Set up auth, SMTP, and enable advanced auth; return admin token.\"\"\"\n        token = await _setup_and_login(async_client, \"iprladmin\", \"iprladmin1!\")\n        headers = _auth_header(token)\n        await async_client.post(\"/api/v1/auth/smtp\", headers=headers, json=_SMTP_DATA_FOR_IPLIMIT)\n        await async_client.post(\"/api/v1/auth/advanced-auth/enable\", headers=headers)\n        return token\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_per_ip_limit_triggers_429(self, async_client: AsyncClient, advanced_auth_token: str):\n        # Send 11 requests from the same test-client IP using unique email\n        # addresses so the per-email bucket (limit=3) is never exhausted.\n        responses = []\n        for i in range(11):\n            resp = await async_client.post(\n                \"/api/v1/auth/forgot-password\",\n                json={\"email\": f\"unique{i}@example.com\"},\n            )\n            responses.append(resp.status_code)\n\n        # First 10 must not be rate-limited by the IP bucket\n        for code in responses[:10]:\n            assert code != 429, f\"Unexpected 429 before limit reached: {responses}\"\n\n        # The 11th must be rate-limited\n        assert responses[10] == 429, f\"Expected 429 on 11th request, got {responses[10]}\"\n\n\n# ---------------------------------------------------------------------------\n# M-NEW-6: OIDC auto-link must be rejected if target user already has an\n#          OIDC link to a different provider\n# ---------------------------------------------------------------------------\n\n\nclass TestOIDCAutoLinkExistingLinkRejection:\n    \"\"\"OIDC callback must reject auto-linking when the email-matched user\n    already has an OIDC link to a different provider (M-NEW-6).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_auto_link_rejected_when_user_already_linked(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        \"\"\"Auto-link via email-match is rejected when the target user is\n        already linked to another OIDC provider.\"\"\"\n        import base64\n        import hashlib\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink\n        from backend.app.models.user import User\n\n        # ── 1. Target user with a known email ────────────────────────────\n        target = User(\n            username=\"oidcALTarget\",\n            email=\"alinktest@example.com\",\n            auth_source=\"oidc\",\n            password_hash=get_password_hash(secrets.token_urlsafe(16)),\n            role=\"user\",\n            is_active=True,\n        )\n        db_session.add(target)\n        await db_session.flush()\n\n        # ── 2. Provider B — legitimate, already linked to target ──────────\n        prov_b = OIDCProvider(\n            name=\"ProvB_m6test\",\n            issuer_url=\"https://providerb-m6.example.com\",\n            client_id=\"client_b\",\n            _client_secret_enc=\"secret_b\",\n            scopes=\"openid email profile\",\n            is_enabled=True,\n            auto_link_existing_accounts=False,\n            auto_create_users=False,\n        )\n        db_session.add(prov_b)\n        await db_session.flush()\n\n        db_session.add(\n            UserOIDCLink(\n                user_id=target.id,\n                provider_id=prov_b.id,\n                provider_user_id=\"legitimate_sub\",\n                provider_email=\"alinktest@example.com\",\n            )\n        )\n\n        # ── 3. Provider A — attacker-controlled, auto_link=True ───────────\n        prov_a = OIDCProvider(\n            name=\"ProvA_m6test\",\n            issuer_url=\"https://providera-m6.example.com\",\n            client_id=\"client_a\",\n            _client_secret_enc=\"secret_a\",\n            scopes=\"openid email profile\",\n            is_enabled=True,\n            auto_link_existing_accounts=True,\n            auto_create_users=False,\n        )\n        db_session.add(prov_a)\n        await db_session.flush()\n\n        # ── 4. OIDC state for Provider A ──────────────────────────────────\n        state = secrets.token_urlsafe(32)\n        nonce = secrets.token_urlsafe(32)\n        code_verifier = secrets.token_urlsafe(48)\n\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=prov_a.id,\n                nonce=nonce,\n                code_verifier=code_verifier,\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n\n        # ── 5. Mock HTTP + JWT so the callback can reach the auto-link check ─\n        fake_discovery = {\n            \"issuer\": \"https://providera-m6.example.com\",\n            \"token_endpoint\": \"https://providera-m6.example.com/token\",\n            \"jwks_uri\": \"https://providera-m6.example.com/jwks\",\n        }\n        fake_token = {\"access_token\": \"acc_tok\", \"id_token\": \"fake.id.token\"}\n        fake_claims = {\n            \"sub\": \"attacker_sub_unique\",\n            \"email\": \"alinktest@example.com\",\n            \"email_verified\": True,\n            \"nonce\": nonce,\n            \"iss\": \"https://providera-m6.example.com\",\n            \"aud\": \"client_a\",\n            \"exp\": 9_999_999_999,\n        }\n\n        disc_resp = AsyncMock()\n        disc_resp.raise_for_status = MagicMock()\n        disc_resp.json = MagicMock(return_value=fake_discovery)\n\n        token_resp = AsyncMock()\n        token_resp.ok = True\n        token_resp.json = MagicMock(return_value=fake_token)\n\n        jwks_resp = AsyncMock()\n        jwks_resp.raise_for_status = MagicMock()\n        jwks_resp.json = MagicMock(return_value={})\n\n        mock_http = AsyncMock()\n        mock_http.get = AsyncMock(side_effect=[disc_resp, jwks_resp])\n        mock_http.post = AsyncMock(return_value=token_resp)\n\n        mock_signing_key = MagicMock()\n        mock_signing_key.key = \"fake_key\"\n\n        with (\n            patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\") as mock_httpx_cls,\n            patch(\"backend.app.api.routes.mfa.jwt.decode\", return_value=fake_claims),\n            patch(\"backend.app.api.routes.mfa.PyJWKClient\") as mock_jwks_cls,\n        ):\n            mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)\n            mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=False)\n            mock_jwks_cls.return_value.get_signing_key_from_jwt.return_value = mock_signing_key\n\n            resp = await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=fake_code&state={state}\",\n                follow_redirects=False,\n            )\n\n        # M-NEW-6: must redirect with no_linked_account — NOT create a second link\n        assert resp.status_code == 302\n        location = resp.headers.get(\"location\", \"\")\n        assert \"no_linked_account\" in location, f\"Expected no_linked_account in redirect, got: {location}\"\n\n        # Verify no second OIDC link was created for Provider A\n        from sqlalchemy import select as sa_select\n\n        from backend.app.models.oidc_provider import UserOIDCLink as _UOL\n\n        async with db_session as s:\n            links_result = await s.execute(\n                sa_select(_UOL).where(_UOL.user_id == target.id, _UOL.provider_id == prov_a.id)\n            )\n            assert links_result.scalar_one_or_none() is None, \"No link to Provider A must exist\"\n\n\n# ===========================================================================\n# Test Gap 1: OIDC state token is single-use — replay must be rejected\n# ===========================================================================\n\n\nclass TestOIDCStateReplay:\n    \"\"\"OIDC state token must be consumed on first use; a second callback with\n    the same state must redirect to ``?oidc_error=invalid_state``.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_state_replay_rejected(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"Replaying a consumed OIDC state token must return invalid_state.\"\"\"\n        from backend.app.models.oidc_provider import OIDCProvider\n\n        # ── 1. Seed a minimal provider ────────────────────────────────────\n        provider = OIDCProvider(\n            name=\"StateReplayIdP\",\n            issuer_url=\"https://statereplay-idp.example.com\",\n            client_id=\"client_replay\",\n            _client_secret_enc=\"secret_replay\",\n            scopes=\"openid\",\n            is_enabled=True,\n            auto_link_existing_accounts=False,\n            auto_create_users=False,\n        )\n        db_session.add(provider)\n        await db_session.flush()\n\n        # ── 2. Seed an OIDC state token ───────────────────────────────────\n        state = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider.id,\n                nonce=secrets.token_urlsafe(32),\n                code_verifier=secrets.token_urlsafe(48),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n\n        # ── 3. First callback — discovery will fail (no real IdP), but the\n        #       state token is atomically consumed (DELETE…RETURNING + commit)\n        #       before the HTTP call is attempted.\n        first = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code=any_code&state={state}\",\n            follow_redirects=False,\n        )\n        assert first.status_code == 302\n        # The first call may fail for any reason except invalid_state\n        assert \"invalid_state\" not in first.headers.get(\"location\", \"\"), (\n            f\"First call should NOT get invalid_state: {first.headers.get('location')}\"\n        )\n\n        # ── 4. Second callback with the same state → must be invalid_state ─\n        second = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code=any_code&state={state}\",\n            follow_redirects=False,\n        )\n        assert second.status_code == 302\n        assert \"invalid_state\" in second.headers.get(\"location\", \"\"), (\n            f\"Replayed state must redirect to invalid_state, got: {second.headers.get('location')}\"\n        )\n\n\n# ===========================================================================\n# Test Gap 2: OIDC iss claim mismatch must redirect to token_validation_failed\n# ===========================================================================\n\n\nclass TestOIDCIssMismatch:\n    \"\"\"JWT whose iss claim does not match the discovery issuer must be rejected.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_iss_mismatch_redirects_token_validation_failed(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        import time\n        from unittest.mock import patch\n\n        import jwt as pyjwt\n\n        private_pem, jwks_data = _make_test_rsa_key()\n        correct_issuer = \"https://correct-iss.example.com\"\n        wrong_issuer = \"https://wrong-iss.example.com\"\n        client_id = \"iss-mismatch-client\"\n        nonce = secrets.token_urlsafe(16)\n        now = int(time.time())\n\n        # Sign the token with the WRONG issuer (iss != discovery_issuer)\n        id_token = pyjwt.encode(\n            {\n                \"sub\": \"sub-iss-test\",\n                \"iss\": wrong_issuer,\n                \"aud\": client_id,\n                \"nonce\": nonce,\n                \"email\": \"iss@example.com\",\n                \"email_verified\": True,\n                \"iat\": now,\n                \"exp\": now + 300,\n            },\n            private_pem,\n            algorithm=\"RS256\",\n            headers={\"kid\": \"test-kid-1\"},\n        )\n\n        admin_token = await _setup_and_login(async_client, \"issadmin1\", \"issadmin1!\")\n        cr = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"IssTest-IdP\",\n                \"issuer_url\": correct_issuer,\n                \"client_id\": client_id,\n                \"client_secret\": \"s\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": True,\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert cr.status_code in (200, 201), cr.text\n        provider_id = cr.json()[\"id\"]\n\n        state = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider_id,\n                nonce=nonce,\n                code_verifier=secrets.token_urlsafe(48),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        # Discovery returns the CORRECT issuer; JWT carries the WRONG one.\n        discovery_doc = {\n            \"issuer\": correct_issuer,\n            \"token_endpoint\": f\"{correct_issuer}/token\",\n            \"jwks_uri\": f\"{correct_issuer}/.well-known/jwks.json\",\n        }\n        token_response = {\"access_token\": \"a\", \"id_token\": id_token}\n\n        class _MockResp:\n            def __init__(self, data):\n                self._data = data\n                self.status_code = 200\n                self.is_success = True\n                self.text = \"\"\n\n            def json(self):\n                return self._data\n\n            def raise_for_status(self):\n                pass\n\n        class _MockHttpxClient:\n            def __init__(self, *a, **kw):\n                pass\n\n            async def __aenter__(self):\n                return self\n\n            async def __aexit__(self, *a):\n                pass\n\n            async def get(self, url, **kw):\n                return _MockResp(jwks_data if \"jwks\" in url else discovery_doc)\n\n            async def post(self, url, **kw):\n                return _MockResp(token_response)\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\", _MockHttpxClient):\n            resp = await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=c&state={state}\",\n                follow_redirects=False,\n            )\n\n        assert resp.status_code == 302\n        location = resp.headers.get(\"location\", \"\")\n        assert \"token_validation_failed\" in location, f\"Expected token_validation_failed, got: {location}\"\n\n\n# ===========================================================================\n# Test Gap 3: /forgot-password/confirm token is single-use\n# ===========================================================================\n\n\nclass TestForgotPasswordTokenSingleUse:\n    \"\"\"POST /forgot-password/confirm must reject a token after its first use.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_token_reuse_rejected(self, async_client: AsyncClient, db_session: AsyncSession):\n        from backend.app.core.auth import get_password_hash\n        from backend.app.models.user import User as _User\n\n        user = _User(\n            username=\"fpcuser1\",\n            email=\"fpc@example.com\",\n            password_hash=get_password_hash(\"OldPass1!\"),\n            role=\"user\",\n            is_active=True,\n        )\n        db_session.add(user)\n        await db_session.flush()\n\n        reset_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=reset_token,\n                token_type=\"password_reset\",\n                username=\"fpcuser1\",\n                expires_at=datetime.now(timezone.utc) + timedelta(hours=1),\n            )\n        )\n        await db_session.commit()\n\n        # First use → success\n        resp1 = await async_client.post(\n            \"/api/v1/auth/forgot-password/confirm\",\n            json={\"token\": reset_token, \"new_password\": \"NewPass1!\"},\n        )\n        assert resp1.status_code == 200, resp1.text\n\n        # Second use → token already consumed, must fail\n        resp2 = await async_client.post(\n            \"/api/v1/auth/forgot-password/confirm\",\n            json={\"token\": reset_token, \"new_password\": \"AnotherNew1!\"},\n        )\n        assert resp2.status_code == 400\n\n\n# ===========================================================================\n# C1 regression: setup_totp must reject a replayed TOTP code\n# ===========================================================================\n\n\nclass TestSetupTOTPReplayRejected:\n    \"\"\"setup_totp must reject a TOTP code that was already accepted in its window.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_replayed_setup_code_rejected(self, async_client: AsyncClient, db_session: AsyncSession):\n        from sqlalchemy import select as sa_select\n\n        from backend.app.models.user_totp import UserTOTP\n\n        token = await _setup_and_login(async_client, \"setupreplay1\", \"setupreplay1!\")\n\n        # Step 1: Initial TOTP setup (no active TOTP yet → no code required)\n        setup_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/setup\",\n            headers=_auth_header(token),\n        )\n        assert setup_resp.status_code == 200\n        secret = setup_resp.json()[\"secret\"]\n\n        # Step 2: Enable TOTP with a valid code\n        totp_obj = pyotp.TOTP(secret)\n        enable_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/enable\",\n            json={\"code\": totp_obj.now()},\n            headers=_auth_header(token),\n        )\n        assert enable_resp.status_code == 200  # TOTP is now active (is_enabled=True)\n\n        # Step 3: Determine current valid code and its counter\n        me_resp = await async_client.get(\"/api/v1/auth/me\", headers=_auth_header(token))\n        user_id = me_resp.json()[\"id\"]\n\n        totp_result = await db_session.execute(sa_select(UserTOTP).where(UserTOTP.user_id == user_id))\n        totp_record = totp_result.scalar_one()\n        secret_now = totp_record.secret  # decrypted via property\n\n        totp_now = pyotp.TOTP(secret_now)\n        valid_code = totp_now.now()\n        accepted_counter = totp_now.timecode(datetime.now(timezone.utc))\n\n        # Step 4: Pre-set last_totp_counter so this code looks already used\n        totp_record.last_totp_counter = accepted_counter\n        await db_session.commit()\n\n        # Step 5: Attempt setup_totp with the \"already used\" code → must be rejected\n        replay_resp = await async_client.post(\n            \"/api/v1/auth/2fa/totp/setup\",\n            json={\"code\": valid_code},\n            headers=_auth_header(token),\n        )\n        assert replay_resp.status_code == 400\n        assert \"already used\" in replay_resp.json()[\"detail\"]\n\n\n# ===========================================================================\n# Nit8: OIDC aud mismatch and nonce mismatch tests\n# ===========================================================================\n\n\nclass TestOIDCAudAndNonceMismatch:\n    \"\"\"Nit8: aud != client_id and nonce != stored value must each fail the callback.\"\"\"\n\n    def _make_oidc_provider_setup(self):\n        \"\"\"Return a helper for building OIDC test fixtures inline.\"\"\"\n        private_pem, jwks_data = _make_test_rsa_key()\n        return private_pem, jwks_data\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_aud_mismatch_redirects_token_validation_failed(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        \"\"\"ID token with aud != client_id must be rejected (PyJWT InvalidAudienceError).\"\"\"\n        import time\n        from unittest.mock import patch\n\n        import jwt as pyjwt\n\n        private_pem, jwks_data = _make_test_rsa_key()\n        issuer = \"https://aud-mismatch.example.com\"\n        client_id = \"aud-test-client\"\n        wrong_aud = \"some-other-client\"\n        nonce = secrets.token_urlsafe(16)\n        now = int(time.time())\n\n        id_token = pyjwt.encode(\n            {\n                \"sub\": \"sub-aud-test\",\n                \"iss\": issuer,\n                \"aud\": wrong_aud,  # <-- wrong audience\n                \"nonce\": nonce,\n                \"email\": \"aud@example.com\",\n                \"email_verified\": True,\n                \"iat\": now,\n                \"exp\": now + 300,\n            },\n            private_pem,\n            algorithm=\"RS256\",\n            headers={\"kid\": \"test-kid-1\"},\n        )\n\n        admin_token = await _setup_and_login(async_client, \"audmismatch_admin\", \"AudMismatch_admin1\")\n        cr = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"AudMismatch-IdP\",\n                \"issuer_url\": issuer,\n                \"client_id\": client_id,\n                \"client_secret\": \"s\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": True,\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert cr.status_code in (200, 201), cr.text\n        provider_id = cr.json()[\"id\"]\n\n        state = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider_id,\n                nonce=nonce,\n                code_verifier=secrets.token_urlsafe(48),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        discovery_doc = {\n            \"issuer\": issuer,\n            \"token_endpoint\": f\"{issuer}/token\",\n            \"jwks_uri\": f\"{issuer}/.well-known/jwks.json\",\n        }\n\n        class _MockResp:\n            def __init__(self, data):\n                self._data = data\n                self.status_code = 200\n                self.is_success = True\n                self.text = \"\"\n\n            def json(self):\n                return self._data\n\n            def raise_for_status(self):\n                pass\n\n        class _MockHttpxClient:\n            def __init__(self, *a, **kw):\n                pass\n\n            async def __aenter__(self):\n                return self\n\n            async def __aexit__(self, *a):\n                pass\n\n            async def get(self, url, **kw):\n                return _MockResp(jwks_data if \"jwks\" in url else discovery_doc)\n\n            async def post(self, url, **kw):\n                return _MockResp({\"access_token\": \"a\", \"id_token\": id_token})\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\", _MockHttpxClient):\n            resp = await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=c&state={state}\",\n                follow_redirects=False,\n            )\n\n        assert resp.status_code == 302\n        location = resp.headers.get(\"location\", \"\")\n        assert \"token_validation_failed\" in location, (\n            f\"Expected token_validation_failed redirect for aud mismatch, got: {location}\"\n        )\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_nonce_mismatch_redirects_token_validation_failed(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        \"\"\"ID token with nonce != stored state nonce must be rejected.\"\"\"\n        import time\n        from unittest.mock import patch\n\n        import jwt as pyjwt\n\n        private_pem, jwks_data = _make_test_rsa_key()\n        issuer = \"https://nonce-mismatch.example.com\"\n        client_id = \"nonce-test-client\"\n        stored_nonce = secrets.token_urlsafe(16)\n        wrong_nonce = secrets.token_urlsafe(16)  # different from stored_nonce\n        now = int(time.time())\n\n        id_token = pyjwt.encode(\n            {\n                \"sub\": \"sub-nonce-test\",\n                \"iss\": issuer,\n                \"aud\": client_id,\n                \"nonce\": wrong_nonce,  # <-- does not match stored_nonce\n                \"email\": \"nonce@example.com\",\n                \"email_verified\": True,\n                \"iat\": now,\n                \"exp\": now + 300,\n            },\n            private_pem,\n            algorithm=\"RS256\",\n            headers={\"kid\": \"test-kid-1\"},\n        )\n\n        admin_token = await _setup_and_login(async_client, \"noncemismatch_admin\", \"NonceMismatch_admin1\")\n        cr = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"NonceMismatch-IdP\",\n                \"issuer_url\": issuer,\n                \"client_id\": client_id,\n                \"client_secret\": \"s\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": True,\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert cr.status_code in (200, 201), cr.text\n        provider_id = cr.json()[\"id\"]\n\n        state = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider_id,\n                nonce=stored_nonce,  # state has correct nonce; JWT carries wrong_nonce\n                code_verifier=secrets.token_urlsafe(48),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        discovery_doc = {\n            \"issuer\": issuer,\n            \"token_endpoint\": f\"{issuer}/token\",\n            \"jwks_uri\": f\"{issuer}/.well-known/jwks.json\",\n        }\n\n        class _MockResp:\n            def __init__(self, data):\n                self._data = data\n                self.status_code = 200\n                self.is_success = True\n                self.text = \"\"\n\n            def json(self):\n                return self._data\n\n            def raise_for_status(self):\n                pass\n\n        class _MockHttpxClient:\n            def __init__(self, *a, **kw):\n                pass\n\n            async def __aenter__(self):\n                return self\n\n            async def __aexit__(self, *a):\n                pass\n\n            async def get(self, url, **kw):\n                return _MockResp(jwks_data if \"jwks\" in url else discovery_doc)\n\n            async def post(self, url, **kw):\n                return _MockResp({\"access_token\": \"a\", \"id_token\": id_token})\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\", _MockHttpxClient):\n            resp = await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=c&state={state}\",\n                follow_redirects=False,\n            )\n\n        assert resp.status_code == 302\n        location = resp.headers.get(\"location\", \"\")\n        # The callback redirects to ?oidc_error=nonce_mismatch when nonces differ.\n        assert \"nonce_mismatch\" in location, f\"Expected nonce_mismatch redirect for nonce mismatch, got: {location}\"\n\n\n# ===========================================================================\n# Expired OIDC token rejection — state and exchange tokens\n# ===========================================================================\n\n\nclass TestOIDCExpiredTokenRejection:\n    \"\"\"Expired OIDC state and exchange tokens must be rejected atomically.\n\n    The DELETE … WHERE expires_at > now must ensure that an already-expired\n    token is never consumed (committed) before the expiry is checked, so the\n    token row stays in the DB and is not silently discarded.\n    \"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_expired_state_token_rejected_as_invalid_state(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        \"\"\"An expired OIDC state token must redirect to invalid_state without\n        being consumed — it must still exist in the DB after the rejected call.\"\"\"\n        from backend.app.models.oidc_provider import OIDCProvider\n\n        provider = OIDCProvider(\n            name=\"ExpiredStateIdP\",\n            issuer_url=\"https://expired-state.example.com\",\n            client_id=\"client_expired_state\",\n            _client_secret_enc=\"secret_exp_state\",\n            scopes=\"openid\",\n            is_enabled=True,\n            auto_link_existing_accounts=False,\n            auto_create_users=False,\n        )\n        db_session.add(provider)\n        await db_session.flush()\n\n        state = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider.id,\n                nonce=secrets.token_urlsafe(16),\n                code_verifier=secrets.token_urlsafe(48),\n                # already expired\n                expires_at=datetime.now(timezone.utc) - timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        resp = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code=any_code&state={state}\",\n            follow_redirects=False,\n        )\n\n        assert resp.status_code == 302\n        location = resp.headers.get(\"location\", \"\")\n        assert \"invalid_state\" in location, f\"Expected invalid_state redirect for expired state, got: {location}\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_expired_exchange_token_rejected(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"An expired OIDC exchange token must return 401 without being consumed.\"\"\"\n        from sqlalchemy import select as sa_select\n\n        expired_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=expired_token,\n                token_type=\"oidc_exchange\",\n                username=\"some_user\",\n                # already expired\n                expires_at=datetime.now(timezone.utc) - timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        resp = await async_client.post(\n            \"/api/v1/auth/oidc/exchange\",\n            json={\"oidc_token\": expired_token},\n        )\n\n        assert resp.status_code == 401\n        assert \"expired\" in resp.json().get(\"detail\", \"\").lower() or \"invalid\" in resp.json().get(\"detail\", \"\").lower()\n\n        # Token must NOT have been consumed — it should still be in the DB\n        # (the atomic DELETE WHERE expires_at > now left it untouched)\n        result = await db_session.execute(\n            sa_select(AuthEphemeralToken).where(AuthEphemeralToken.token == expired_token)\n        )\n        remaining = result.scalar_one_or_none()\n        assert remaining is not None, \"Expired exchange token must not be consumed by a rejected request\"\n\n\n# ===========================================================================\n# Trailing slash in issuer_url — discovery URL must not contain double slash\n# ===========================================================================\n\n\nclass TestOIDCIssuerUrlTrailingSlash:\n    \"\"\"Providers like Authentik use issuer URLs with a trailing slash.\n\n    BamBuddy must strip the slash before appending /.well-known/openid-configuration\n    to avoid a double-slash that results in a 404.\n    \"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_trailing_slash_issuer_url_fetches_correct_discovery_url(self, async_client: AsyncClient):\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        issuer_with_slash = \"https://authentik.example.com/application/o/bambuddy/\"\n\n        admin_token = await _setup_and_login(async_client, \"oidcslashadm\", \"oidcslashadm1\")\n        create_resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"Authentik-Slash\",\n                \"issuer_url\": issuer_with_slash,\n                \"client_id\": \"bambuddy\",\n                \"client_secret\": \"secret\",\n                \"scopes\": \"openid email profile\",\n                \"is_enabled\": True,\n                \"auto_create_users\": False,\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n        provider_id = create_resp.json()[\"id\"]\n\n        fake_discovery = {\n            \"issuer\": issuer_with_slash,\n            \"authorization_endpoint\": \"https://authentik.example.com/application/o/bambuddy/authorize\",\n        }\n        disc_resp = AsyncMock()\n        disc_resp.raise_for_status = MagicMock()\n        disc_resp.json = MagicMock(return_value=fake_discovery)\n\n        mock_http = AsyncMock()\n        mock_http.get = AsyncMock(return_value=disc_resp)\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\") as mock_cls:\n            mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)\n            mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            resp = await async_client.get(f\"/api/v1/auth/oidc/authorize/{provider_id}\")\n\n        assert resp.status_code == 200\n        called_url = mock_http.get.call_args_list[0][0][0]\n        assert \"//\" not in called_url.replace(\"https://\", \"\"), (\n            f\"Discovery URL must not contain double slash: {called_url}\"\n        )\n        assert called_url.endswith(\"/.well-known/openid-configuration\"), (\n            f\"Expected discovery URL to end with /.well-known/openid-configuration, got: {called_url}\"\n        )\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_iss_claim_trailing_slash_accepted(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"Provider configured without trailing slash, Authentik JWT iss has trailing slash.\n\n        Both sides must be normalised before comparison so the login succeeds.\n        \"\"\"\n        import time\n        from unittest.mock import patch\n\n        import jwt as pyjwt\n\n        private_pem, jwks_data = _make_test_rsa_key()\n        issuer_no_slash = \"https://authentik.example.com/application/o/bambuddy\"\n        issuer_with_slash = issuer_no_slash + \"/\"\n        client_id = \"bambuddy-client\"\n        nonce = secrets.token_urlsafe(16)\n\n        now = int(time.time())\n        id_token = pyjwt.encode(\n            {\n                \"sub\": \"authentik-sub-123\",\n                \"iss\": issuer_with_slash,\n                \"aud\": client_id,\n                \"nonce\": nonce,\n                \"email\": \"authentik-user@example.com\",\n                \"email_verified\": True,\n                \"iat\": now,\n                \"exp\": now + 300,\n            },\n            private_pem,\n            algorithm=\"RS256\",\n            headers={\"kid\": \"test-kid-1\"},\n        )\n\n        admin_token = await _setup_and_login(async_client, \"authentikadm\", \"authentikadm1\")\n        create_resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"Authentik-ISS\",\n                \"issuer_url\": issuer_no_slash,\n                \"client_id\": client_id,\n                \"client_secret\": \"secret\",\n                \"scopes\": \"openid email profile\",\n                \"is_enabled\": True,\n                \"auto_create_users\": True,\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n        provider_id = create_resp.json()[\"id\"]\n\n        state = secrets.token_urlsafe(32)\n        code_verifier = secrets.token_urlsafe(48)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider_id,\n                nonce=nonce,\n                code_verifier=code_verifier,\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        discovery_doc = {\n            \"issuer\": issuer_with_slash,\n            \"authorization_endpoint\": f\"{issuer_no_slash}/authorize\",\n            \"token_endpoint\": f\"{issuer_no_slash}/token\",\n            \"jwks_uri\": f\"{issuer_no_slash}/.well-known/jwks.json\",\n        }\n        token_response = {\"access_token\": \"mock\", \"token_type\": \"Bearer\", \"id_token\": id_token}\n\n        class _MockResp:\n            def __init__(self, data):\n                self._data = data\n                self.is_success = True\n                self.status_code = 200\n                self.text = str(data)\n\n            def json(self):\n                return self._data\n\n            def raise_for_status(self):\n                pass\n\n        class _MockHttpxClient:\n            def __init__(self, *a, **kw):\n                pass\n\n            async def __aenter__(self):\n                return self\n\n            async def __aexit__(self, *a):\n                pass\n\n            async def get(self, url, **kw):\n                return _MockResp(jwks_data if \"jwks\" in url else discovery_doc)\n\n            async def post(self, url, **kw):\n                return _MockResp(token_response)\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\", _MockHttpxClient):\n            resp = await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=auth-code&state={state}\",\n                follow_redirects=False,\n            )\n\n        location = resp.headers.get(\"location\", \"\")\n        assert resp.status_code == 302, f\"Expected redirect, got {resp.status_code}\"\n        assert \"token_validation_failed\" not in location, (\n            \"Trailing slash mismatch in iss claim must not cause token_validation_failed\"\n        )\n        assert \"oidc_token=\" in location, f\"Expected oidc_token in redirect, got: {location}\"\n\n\nclass TestOIDCCallbackCodeLength:\n    \"\"\"OIDC callback code/state query params must accept up to 2048 characters (OAuth spec).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_code_512_chars_accepted(self, async_client: AsyncClient):\n        \"\"\"A 512-character code (old limit) must not be rejected with 422.\"\"\"\n        code = \"a\" * 512\n        resp = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code={code}&state=bogus-state\",\n            follow_redirects=False,\n        )\n        assert resp.status_code != 422, \"512-char code must not be rejected by Pydantic\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_code_2048_chars_accepted(self, async_client: AsyncClient):\n        \"\"\"A 2048-character code must not be rejected with 422.\"\"\"\n        code = \"a\" * 2048\n        resp = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code={code}&state=bogus-state\",\n            follow_redirects=False,\n        )\n        assert resp.status_code != 422, \"2048-char code must not be rejected by Pydantic\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_code_2049_chars_rejected(self, async_client: AsyncClient):\n        \"\"\"A 2049-character code must be rejected with 422.\"\"\"\n        code = \"a\" * 2049\n        resp = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code={code}&state=bogus-state\",\n            follow_redirects=False,\n        )\n        assert resp.status_code == 422, \"2049-char code must be rejected by Pydantic\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_state_512_chars_accepted(self, async_client: AsyncClient):\n        \"\"\"A 512-character state (old limit) must not be rejected with 422.\"\"\"\n        state = \"a\" * 512\n        resp = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code=bogus-code&state={state}\",\n            follow_redirects=False,\n        )\n        assert resp.status_code != 422, \"512-char state must not be rejected by Pydantic\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_state_2048_chars_accepted(self, async_client: AsyncClient):\n        \"\"\"A 2048-character state must not be rejected with 422.\"\"\"\n        state = \"a\" * 2048\n        resp = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code=bogus-code&state={state}\",\n            follow_redirects=False,\n        )\n        assert resp.status_code != 422, \"2048-char state must not be rejected by Pydantic\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_state_2049_chars_rejected(self, async_client: AsyncClient):\n        \"\"\"A 2049-character state must be rejected with 422.\"\"\"\n        state = \"a\" * 2049\n        resp = await async_client.get(\n            f\"/api/v1/auth/oidc/callback?code=bogus-code&state={state}\",\n            follow_redirects=False,\n        )\n        assert resp.status_code == 422, \"2049-char state must be rejected by Pydantic\"\n"
  },
  {
    "path": "backend/tests/integration/test_notifications_api.py",
    "content": "\"\"\"Integration tests for Notifications API endpoints.\n\nTests the full request/response cycle for /api/v1/notifications/ endpoints.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestNotificationsAPI:\n    \"\"\"Integration tests for /api/v1/notifications/ endpoints.\"\"\"\n\n    # ========================================================================\n    # List endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_notification_providers_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list is returned when no providers exist.\"\"\"\n        response = await async_client.get(\"/api/v1/notifications/\")\n\n        assert response.status_code == 200\n        assert response.json() == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_notification_providers_with_data(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"Verify list returns existing providers.\"\"\"\n        _provider = await notification_provider_factory(name=\"Test Provider\")\n\n        response = await async_client.get(\"/api/v1/notifications/\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) >= 1\n        assert any(p[\"name\"] == \"Test Provider\" for p in data)\n\n    # ========================================================================\n    # Create endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_callmebot_provider(self, async_client: AsyncClient):\n        \"\"\"Verify callmebot notification provider can be created.\"\"\"\n        data = {\n            \"name\": \"Test CallMeBot\",\n            \"provider_type\": \"callmebot\",\n            \"enabled\": True,\n            \"config\": {\"phone_number\": \"+1234567890\", \"api_key\": \"test-api-key\"},\n            \"on_print_start\": True,\n            \"on_print_complete\": True,\n            \"on_print_failed\": True,\n            \"on_print_stopped\": False,\n        }\n\n        response = await async_client.post(\"/api/v1/notifications/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"Test CallMeBot\"\n        assert result[\"provider_type\"] == \"callmebot\"\n        assert result[\"on_print_start\"] is True\n        assert result[\"on_print_stopped\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_ntfy_provider(self, async_client: AsyncClient):\n        \"\"\"Verify ntfy notification provider can be created.\"\"\"\n        data = {\n            \"name\": \"Test Ntfy\",\n            \"provider_type\": \"ntfy\",\n            \"enabled\": True,\n            \"config\": {\n                \"server\": \"https://ntfy.sh\",\n                \"topic\": \"test-topic\",\n            },\n            \"on_print_complete\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/notifications/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"provider_type\"] == \"ntfy\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_provider_with_printer(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify provider can be linked to specific printer.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        data = {\n            \"name\": \"Printer Ntfy\",\n            \"provider_type\": \"ntfy\",\n            \"config\": {\"server\": \"https://ntfy.sh\", \"topic\": \"test-topic\"},\n            \"printer_id\": printer.id,\n        }\n\n        response = await async_client.post(\"/api/v1/notifications/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"printer_id\"] == printer.id\n\n    # ========================================================================\n    # Get single endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_notification_provider(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"Verify single provider can be retrieved.\"\"\"\n        provider = await notification_provider_factory(name=\"Get Test Provider\")\n\n        response = await async_client.get(f\"/api/v1/notifications/{provider.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == provider.id\n        assert result[\"name\"] == \"Get Test Provider\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_provider_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent provider.\"\"\"\n        response = await async_client.get(\"/api/v1/notifications/9999\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Update endpoints (CRITICAL - toggle persistence)\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_event_toggles(self, async_client: AsyncClient, notification_provider_factory, db_session):\n        \"\"\"CRITICAL: Verify notification event toggles persist correctly.\"\"\"\n        provider = await notification_provider_factory(\n            on_print_start=True,\n            on_print_complete=True,\n            on_print_stopped=False,\n        )\n\n        # Toggle on_print_stopped to True\n        response = await async_client.patch(f\"/api/v1/notifications/{provider.id}\", json={\"on_print_stopped\": True})\n\n        assert response.status_code == 200\n        assert response.json()[\"on_print_stopped\"] is True\n\n        # Verify change persisted\n        response = await async_client.get(f\"/api/v1/notifications/{provider.id}\")\n        assert response.json()[\"on_print_stopped\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_ams_alarm_toggles(self, async_client: AsyncClient, notification_provider_factory, db_session):\n        \"\"\"CRITICAL: Verify AMS alarm toggles persist correctly.\"\"\"\n        provider = await notification_provider_factory(\n            on_ams_humidity_high=False,\n            on_ams_temperature_high=False,\n        )\n\n        # Enable AMS alarms\n        response = await async_client.patch(\n            f\"/api/v1/notifications/{provider.id}\",\n            json={\n                \"on_ams_humidity_high\": True,\n                \"on_ams_temperature_high\": True,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"on_ams_humidity_high\"] is True\n        assert result[\"on_ams_temperature_high\"] is True\n\n        # Verify persistence\n        response = await async_client.get(f\"/api/v1/notifications/{provider.id}\")\n        result = response.json()\n        assert result[\"on_ams_humidity_high\"] is True\n        assert result[\"on_ams_temperature_high\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_disable_provider(self, async_client: AsyncClient, notification_provider_factory, db_session):\n        \"\"\"Verify provider can be enabled/disabled.\"\"\"\n        provider = await notification_provider_factory(enabled=True)\n\n        # Disable\n        response = await async_client.patch(f\"/api/v1/notifications/{provider.id}\", json={\"enabled\": False})\n\n        assert response.status_code == 200\n        assert response.json()[\"enabled\"] is False\n\n        # Enable\n        response = await async_client.patch(f\"/api/v1/notifications/{provider.id}\", json={\"enabled\": True})\n\n        assert response.status_code == 200\n        assert response.json()[\"enabled\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_quiet_hours(self, async_client: AsyncClient, notification_provider_factory, db_session):\n        \"\"\"Verify quiet hours can be configured.\"\"\"\n        provider = await notification_provider_factory(quiet_hours_enabled=False)\n\n        response = await async_client.patch(\n            f\"/api/v1/notifications/{provider.id}\",\n            json={\n                \"quiet_hours_enabled\": True,\n                \"quiet_hours_start\": \"22:00\",\n                \"quiet_hours_end\": \"07:00\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"quiet_hours_enabled\"] is True\n        assert result[\"quiet_hours_start\"] == \"22:00\"\n        assert result[\"quiet_hours_end\"] == \"07:00\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_daily_digest(self, async_client: AsyncClient, notification_provider_factory, db_session):\n        \"\"\"Verify daily digest can be configured.\"\"\"\n        provider = await notification_provider_factory(daily_digest_enabled=False)\n\n        response = await async_client.patch(\n            f\"/api/v1/notifications/{provider.id}\",\n            json={\n                \"daily_digest_enabled\": True,\n                \"daily_digest_time\": \"09:00\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"daily_digest_enabled\"] is True\n        assert result[\"daily_digest_time\"] == \"09:00\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_multiple_event_toggles(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"Verify multiple event toggles can be updated at once.\"\"\"\n        provider = await notification_provider_factory(\n            on_print_start=True,\n            on_print_complete=True,\n            on_print_failed=True,\n            on_print_stopped=False,\n            on_printer_offline=False,\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/notifications/{provider.id}\",\n            json={\n                \"on_print_start\": False,\n                \"on_print_stopped\": True,\n                \"on_printer_offline\": True,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"on_print_start\"] is False\n        assert result[\"on_print_stopped\"] is True\n        assert result[\"on_printer_offline\"] is True\n        # Unchanged fields should remain\n        assert result[\"on_print_complete\"] is True\n        assert result[\"on_print_failed\"] is True\n\n    # ========================================================================\n    # Test notification endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_test_notification(\n        self, async_client: AsyncClient, notification_provider_factory, mock_httpx_client, db_session\n    ):\n        \"\"\"Verify test notification can be sent.\"\"\"\n        provider = await notification_provider_factory()\n\n        response = await async_client.post(f\"/api/v1/notifications/{provider.id}/test\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_test_notification_disabled_provider(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"Verify test notification works even for disabled provider.\"\"\"\n        provider = await notification_provider_factory(enabled=False)\n\n        response = await async_client.post(f\"/api/v1/notifications/{provider.id}/test\")\n\n        # Test should still work for disabled providers\n        assert response.status_code == 200\n\n    # ========================================================================\n    # Delete endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_notification_provider(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"Verify notification provider can be deleted.\"\"\"\n        provider = await notification_provider_factory()\n        provider_id = provider.id\n\n        response = await async_client.delete(f\"/api/v1/notifications/{provider_id}\")\n\n        assert response.status_code == 200\n\n        # Verify deleted\n        response = await async_client.get(f\"/api/v1/notifications/{provider_id}\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_nonexistent_provider(self, async_client: AsyncClient):\n        \"\"\"Verify deleting non-existent provider returns 404.\"\"\"\n        response = await async_client.delete(\"/api/v1/notifications/9999\")\n\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_provider_with_first_layer_complete(self, async_client: AsyncClient):\n        \"\"\"Verify first layer complete toggle persists on create.\"\"\"\n        data = {\n            \"name\": \"First Layer Test\",\n            \"provider_type\": \"ntfy\",\n            \"config\": {\"server\": \"https://ntfy.sh\", \"topic\": \"test\"},\n            \"on_first_layer_complete\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/notifications/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"on_first_layer_complete\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_first_layer_complete_toggle(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"CRITICAL: Verify first layer complete toggle persists correctly.\"\"\"\n        provider = await notification_provider_factory(on_first_layer_complete=False)\n\n        response = await async_client.patch(\n            f\"/api/v1/notifications/{provider.id}\",\n            json={\"on_first_layer_complete\": True},\n        )\n\n        assert response.status_code == 200\n        assert response.json()[\"on_first_layer_complete\"] is True\n\n        # Verify persistence\n        response = await async_client.get(f\"/api/v1/notifications/{provider.id}\")\n        assert response.json()[\"on_first_layer_complete\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_first_layer_complete_independent_from_other_toggles(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"Verify first layer complete is independent from bed cooled and print complete.\"\"\"\n        provider = await notification_provider_factory(\n            on_print_complete=True,\n            on_bed_cooled=False,\n            on_first_layer_complete=True,\n        )\n\n        response = await async_client.get(f\"/api/v1/notifications/{provider.id}\")\n        result = response.json()\n        assert result[\"on_print_complete\"] is True\n        assert result[\"on_bed_cooled\"] is False\n        assert result[\"on_first_layer_complete\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_provider_with_missing_spool_assignment_toggle(self, async_client: AsyncClient):\n        \"\"\"Verify missing spool assignment toggle persists on create.\"\"\"\n        data = {\n            \"name\": \"Missing Spool Assignment Test\",\n            \"provider_type\": \"ntfy\",\n            \"config\": {\"server\": \"https://ntfy.sh\", \"topic\": \"test\"},\n            \"on_print_missing_spool_assignment\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/notifications/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"on_print_missing_spool_assignment\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_missing_spool_assignment_toggle(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"CRITICAL: Verify missing spool assignment toggle persists correctly.\"\"\"\n        provider = await notification_provider_factory(on_print_missing_spool_assignment=False)\n\n        response = await async_client.patch(\n            f\"/api/v1/notifications/{provider.id}\",\n            json={\"on_print_missing_spool_assignment\": True},\n        )\n\n        assert response.status_code == 200\n        assert response.json()[\"on_print_missing_spool_assignment\"] is True\n\n        response = await async_client.get(f\"/api/v1/notifications/{provider.id}\")\n        assert response.json()[\"on_print_missing_spool_assignment\"] is True\n\n\nclass TestNotificationTemplatesAPI:\n    \"\"\"Integration tests for /api/v1/notification-templates/ endpoints.\"\"\"\n\n    @pytest.fixture\n    async def seeded_templates(self, db_session):\n        \"\"\"Seed notification templates for tests.\"\"\"\n        from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate\n\n        templates = []\n        for template_data in DEFAULT_TEMPLATES:\n            template = NotificationTemplate(**template_data)\n            db_session.add(template)\n            templates.append(template)\n        await db_session.commit()\n        for template in templates:\n            await db_session.refresh(template)\n        return templates\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_templates(self, async_client: AsyncClient, seeded_templates):\n        \"\"\"Verify default templates are seeded and can be listed.\"\"\"\n        response = await async_client.get(\"/api/v1/notification-templates/\")\n\n        assert response.status_code == 200\n        templates = response.json()\n        # Should have default templates seeded\n        assert len(templates) >= 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_template_by_id(self, async_client: AsyncClient, seeded_templates):\n        \"\"\"Verify template can be retrieved by ID.\"\"\"\n        # Get first template ID from seeded data\n        template_id = seeded_templates[0].id\n\n        response = await async_client.get(f\"/api/v1/notification-templates/{template_id}\")\n\n        assert response.status_code == 200\n        template = response.json()\n        assert template[\"id\"] == template_id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_template(self, async_client: AsyncClient, seeded_templates):\n        \"\"\"Verify template can be updated.\"\"\"\n        # Get first template\n        template_id = seeded_templates[0].id\n\n        # Update it (route uses PUT, not PATCH)\n        response = await async_client.put(\n            f\"/api/v1/notification-templates/{template_id}\",\n            json={\n                \"title_template\": \"Custom Title: {printer}\",\n                \"body_template\": \"Custom body for {filename}\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"title_template\"] == \"Custom Title: {printer}\"\n        assert result[\"body_template\"] == \"Custom body for {filename}\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_reset_template_to_default(self, async_client: AsyncClient, seeded_templates):\n        \"\"\"Verify template can be reset to default.\"\"\"\n        template_id = seeded_templates[0].id\n\n        response = await async_client.post(f\"/api/v1/notification-templates/{template_id}/reset\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"is_default\"] is True\n\n\nclass TestHomeAssistantNotificationProvider:\n    \"\"\"Integration tests for Home Assistant notification provider.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_homeassistant_provider(self, async_client: AsyncClient):\n        \"\"\"Verify homeassistant notification provider can be created with empty config.\"\"\"\n        data = {\n            \"name\": \"HA Notifications\",\n            \"provider_type\": \"homeassistant\",\n            \"enabled\": True,\n            \"config\": {},\n            \"on_print_complete\": True,\n            \"on_print_failed\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/notifications/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"HA Notifications\"\n        assert result[\"provider_type\"] == \"homeassistant\"\n        assert result[\"on_print_complete\"] is True\n        assert result[\"on_print_failed\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_homeassistant_provider(\n        self, async_client: AsyncClient, notification_provider_factory, db_session\n    ):\n        \"\"\"Verify homeassistant provider can be updated.\"\"\"\n        provider = await notification_provider_factory(\n            name=\"HA Test\",\n            provider_type=\"homeassistant\",\n            config=\"{}\",\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/notifications/{provider.id}\",\n            json={\"on_print_start\": True, \"on_printer_offline\": True},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"on_print_start\"] is True\n        assert result[\"on_printer_offline\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_test_homeassistant_config_without_ha_settings(self, async_client: AsyncClient):\n        \"\"\"Verify test-config returns error when HA is not configured.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/notifications/test-config\",\n            json={\"provider_type\": \"homeassistant\", \"config\": {}},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is False\n        assert \"not configured\" in result[\"message\"].lower() or \"Home Assistant\" in result[\"message\"]\n"
  },
  {
    "path": "backend/tests/integration/test_obico_api.py",
    "content": "\"\"\"Integration tests for Obico API endpoints (#172 follow-up).\n\nVerifies the /obico/cached-frame/{nonce} endpoint used by Obico's ML API to fetch\npre-captured JPEG frames. This endpoint lets the detection loop sidestep Obico's\nhardcoded 5s read timeout by pre-populating a cache before issuing the ML call.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\nfrom backend.app.services.obico_detection import _frame_cache, stash_frame\n\nFAKE_JPEG = b\"\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x00\\x00\\x01\\x00\\x01\\x00\\x00\\xff\\xd9\"\n\n\n@pytest.fixture(autouse=True)\ndef clear_cache():\n    _frame_cache.clear()\n    yield\n    _frame_cache.clear()\n\n\nclass TestObicoCachedFrame:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_valid_nonce_returns_jpeg(self, async_client: AsyncClient):\n        \"\"\"A stashed nonce returns the stored JPEG bytes with image/jpeg.\"\"\"\n        nonce = await stash_frame(FAKE_JPEG)\n        response = await async_client.get(f\"/api/v1/obico/cached-frame/{nonce}\")\n        assert response.status_code == 200\n        assert response.headers[\"content-type\"] == \"image/jpeg\"\n        assert response.content == FAKE_JPEG\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_unknown_nonce_is_404(self, async_client: AsyncClient):\n        \"\"\"An unguessable URL must not leak that the endpoint exists — return 404.\"\"\"\n        response = await async_client.get(\"/api/v1/obico/cached-frame/definitely-not-a-real-nonce\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_nonce_is_single_use(self, async_client: AsyncClient):\n        \"\"\"A second fetch with the same nonce returns 404 — prevents replay.\"\"\"\n        nonce = await stash_frame(FAKE_JPEG)\n        first = await async_client.get(f\"/api/v1/obico/cached-frame/{nonce}\")\n        assert first.status_code == 200\n        second = await async_client.get(f\"/api/v1/obico/cached-frame/{nonce}\")\n        assert second.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_endpoint_is_public(self, async_client: AsyncClient):\n        \"\"\"Obico's ML API can't send auth headers, so the nonce IS the credential.\n        The path must be in PUBLIC_API_PATTERNS (no auth wall).\"\"\"\n        nonce = await stash_frame(FAKE_JPEG)\n        # Intentionally omit any auth headers even if the fixture would normally inject them\n        response = await async_client.get(\n            f\"/api/v1/obico/cached-frame/{nonce}\",\n            headers={},  # no Authorization header\n        )\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_response_is_not_cached(self, async_client: AsyncClient):\n        \"\"\"Browsers/proxies must not hold onto the image after Obico consumes it.\"\"\"\n        nonce = await stash_frame(FAKE_JPEG)\n        response = await async_client.get(f\"/api/v1/obico/cached-frame/{nonce}\")\n        assert response.status_code == 200\n        assert \"no-store\" in response.headers.get(\"cache-control\", \"\")\n"
  },
  {
    "path": "backend/tests/integration/test_ownership_permissions.py",
    "content": "\"\"\"Integration tests for ownership-based permission system.\n\nTests the ownership permission model where users can have:\n- *_all permissions: can modify any item\n- *_own permissions: can only modify items they created\n- Ownerless items (created_by_id = null) require *_all permission\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestOwnershipPermissionsSetup:\n    \"\"\"Helper fixture class for ownership permission tests.\"\"\"\n\n    @pytest.fixture\n    async def auth_setup(self, async_client: AsyncClient):\n        \"\"\"Setup auth with admin, create test users with different permission levels.\"\"\"\n        # Enable auth with admin user\n        await async_client.post(\n            \"/api/v1/auth/setup\",\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"ownershipadmin\",\n                \"admin_password\": \"AdminPass1!\",\n            },\n        )\n\n        # Login as admin\n        admin_login = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"ownershipadmin\", \"password\": \"AdminPass1!\"},\n        )\n        admin_token = admin_login.json()[\"access_token\"]\n        admin_user = admin_login.json()[\"user\"]\n\n        # Get group IDs\n        groups_response = await async_client.get(\n            \"/api/v1/groups/\",\n            headers={\"Authorization\": f\"Bearer {admin_token}\"},\n        )\n        groups = groups_response.json()\n        operators_group = next(g for g in groups if g[\"name\"] == \"Operators\")\n        viewers_group = next(g for g in groups if g[\"name\"] == \"Viewers\")\n\n        # Create operator user (has *_own permissions)\n        operator_response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {admin_token}\"},\n            json={\n                \"username\": \"operator1\",\n                \"password\": \"Operatorpass1!\",\n                \"group_ids\": [operators_group[\"id\"]],\n            },\n        )\n        operator_user = operator_response.json()\n\n        # Login as operator\n        operator_login = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"operator1\", \"password\": \"Operatorpass1!\"},\n        )\n        operator_token = operator_login.json()[\"access_token\"]\n\n        # Create second operator (for cross-user tests)\n        operator2_response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {admin_token}\"},\n            json={\n                \"username\": \"operator2\",\n                \"password\": \"Operatorpass1!\",\n                \"group_ids\": [operators_group[\"id\"]],\n            },\n        )\n        operator2_user = operator2_response.json()\n\n        operator2_login = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"operator2\", \"password\": \"Operatorpass1!\"},\n        )\n        operator2_token = operator2_login.json()[\"access_token\"]\n\n        # Create viewer user (has no update/delete permissions)\n        await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {admin_token}\"},\n            json={\n                \"username\": \"viewer1\",\n                \"password\": \"Viewerpass1!\",\n                \"group_ids\": [viewers_group[\"id\"]],\n            },\n        )\n\n        viewer_login = await async_client.post(\n            \"/api/v1/auth/login\",\n            json={\"username\": \"viewer1\", \"password\": \"Viewerpass1!\"},\n        )\n        viewer_token = viewer_login.json()[\"access_token\"]\n\n        return {\n            \"admin_token\": admin_token,\n            \"admin_user\": admin_user,\n            \"operator_token\": operator_token,\n            \"operator_user\": operator_user,\n            \"operator2_token\": operator2_token,\n            \"operator2_user\": operator2_user,\n            \"viewer_token\": viewer_token,\n        }\n\n\nclass TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):\n    \"\"\"Tests for archive ownership-based permissions.\"\"\"\n\n    # ========================================================================\n    # DELETE permissions\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_admin_can_delete_any_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Admin with *_all permissions can delete any archive.\"\"\"\n        printer = await printer_factory()\n        # Create archive owned by operator\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Operator Archive\",\n            created_by_id=auth_setup[\"operator_user\"][\"id\"],\n        )\n\n        # Admin deletes it\n        response = await async_client.delete(\n            f\"/api/v1/archives/{archive.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n        )\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_can_delete_own_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Operator with *_own permissions can delete their own archive.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"My Archive\",\n            created_by_id=auth_setup[\"operator_user\"][\"id\"],\n        )\n\n        response = await async_client.delete(\n            f\"/api/v1/archives/{archive.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_delete_others_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Operator with *_own permissions cannot delete another user's archive.\"\"\"\n        printer = await printer_factory()\n        # Archive created by operator2\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Other's Archive\",\n            created_by_id=auth_setup[\"operator2_user\"][\"id\"],\n        )\n\n        # operator1 tries to delete it\n        response = await async_client.delete(\n            f\"/api/v1/archives/{archive.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 403\n        assert \"your own\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_delete_ownerless_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Operator with *_own permissions cannot delete ownerless archive.\"\"\"\n        printer = await printer_factory()\n        # Archive with no owner (legacy data)\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Ownerless Archive\",\n            created_by_id=None,\n        )\n\n        response = await async_client.delete(\n            f\"/api/v1/archives/{archive.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_viewer_cannot_delete_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Viewer with no delete permissions cannot delete any archive.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id, print_name=\"Any Archive\")\n\n        response = await async_client.delete(\n            f\"/api/v1/archives/{archive.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['viewer_token']}\"},\n        )\n\n        assert response.status_code == 403\n\n    # ========================================================================\n    # UPDATE permissions\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_admin_can_update_any_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Admin can update any archive.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Original Name\",\n            created_by_id=auth_setup[\"operator_user\"][\"id\"],\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/archives/{archive.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n            json={\"print_name\": \"Admin Updated\"},\n        )\n\n        assert response.status_code == 200\n        assert response.json()[\"print_name\"] == \"Admin Updated\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_can_update_own_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Operator can update their own archive.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Original Name\",\n            created_by_id=auth_setup[\"operator_user\"][\"id\"],\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/archives/{archive.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n            json={\"print_name\": \"Operator Updated\"},\n        )\n\n        assert response.status_code == 200\n        assert response.json()[\"print_name\"] == \"Operator Updated\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_update_others_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Operator cannot update another user's archive.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            print_name=\"Other's Archive\",\n            created_by_id=auth_setup[\"operator2_user\"][\"id\"],\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/archives/{archive.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n            json={\"print_name\": \"Attempted Update\"},\n        )\n\n        assert response.status_code == 403\n\n    # ========================================================================\n    # REPRINT permissions\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_reprint_others_archive(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Operator cannot reprint another user's archive.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(\n            printer.id,\n            created_by_id=auth_setup[\"operator2_user\"][\"id\"],\n        )\n\n        response = await async_client.post(\n            f\"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 403\n\n\nclass TestQueueOwnershipPermissions(TestOwnershipPermissionsSetup):\n    \"\"\"Tests for print queue ownership-based permissions.\"\"\"\n\n    @pytest.fixture\n    async def queue_item_factory(self, db_session, printer_factory, archive_factory):\n        \"\"\"Factory to create test queue items.\"\"\"\n\n        async def _create_item(**kwargs):\n            from backend.app.models.print_queue import PrintQueueItem\n\n            printer = await printer_factory()\n            # Create an archive to link to the queue item\n            archive = await archive_factory(printer.id)\n\n            defaults = {\n                \"printer_id\": printer.id,\n                \"archive_id\": archive.id,\n                \"status\": \"pending\",\n                \"position\": 0,\n            }\n            defaults.update(kwargs)\n\n            item = PrintQueueItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_admin_can_delete_any_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):\n        \"\"\"Admin can delete any queue item.\"\"\"\n        item = await queue_item_factory(created_by_id=auth_setup[\"operator_user\"][\"id\"])\n\n        response = await async_client.delete(\n            f\"/api/v1/queue/{item.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n        )\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_can_delete_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):\n        \"\"\"Operator can delete their own queue item.\"\"\"\n        item = await queue_item_factory(created_by_id=auth_setup[\"operator_user\"][\"id\"])\n\n        response = await async_client.delete(\n            f\"/api/v1/queue/{item.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_delete_others_queue_item(\n        self, async_client: AsyncClient, auth_setup, queue_item_factory\n    ):\n        \"\"\"Operator cannot delete another user's queue item.\"\"\"\n        item = await queue_item_factory(created_by_id=auth_setup[\"operator2_user\"][\"id\"])\n\n        response = await async_client.delete(\n            f\"/api/v1/queue/{item.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_can_update_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):\n        \"\"\"Operator can update their own queue item.\"\"\"\n        item = await queue_item_factory(created_by_id=auth_setup[\"operator_user\"][\"id\"])\n\n        response = await async_client.patch(\n            f\"/api/v1/queue/{item.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n            json={\"position\": 10},\n        )\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_update_others_queue_item(\n        self, async_client: AsyncClient, auth_setup, queue_item_factory\n    ):\n        \"\"\"Operator cannot update another user's queue item.\"\"\"\n        item = await queue_item_factory(created_by_id=auth_setup[\"operator2_user\"][\"id\"])\n\n        response = await async_client.patch(\n            f\"/api/v1/queue/{item.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n            json={\"position\": 10},\n        )\n\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_cancel_others_queue_item(\n        self, async_client: AsyncClient, auth_setup, queue_item_factory\n    ):\n        \"\"\"Operator cannot cancel another user's queue item.\"\"\"\n        item = await queue_item_factory(created_by_id=auth_setup[\"operator2_user\"][\"id\"])\n\n        response = await async_client.post(\n            f\"/api/v1/queue/{item.id}/cancel\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_update_skips_non_owned_items(self, async_client: AsyncClient, auth_setup, queue_item_factory):\n        \"\"\"Bulk update only updates items the user owns.\"\"\"\n        # Create items owned by different users\n        own_item = await queue_item_factory(\n            created_by_id=auth_setup[\"operator_user\"][\"id\"],\n        )\n        other_item = await queue_item_factory(\n            created_by_id=auth_setup[\"operator2_user\"][\"id\"],\n        )\n\n        response = await async_client.patch(\n            \"/api/v1/queue/bulk\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n            json={\n                \"item_ids\": [own_item.id, other_item.id],\n                \"manual_start\": True,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        # Should only update the owned item\n        assert result[\"updated_count\"] == 1\n        assert result[\"skipped_count\"] == 1\n\n\nclass TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):\n    \"\"\"Tests for library file ownership-based permissions.\"\"\"\n\n    @pytest.fixture\n    async def library_file_factory(self, db_session):\n        \"\"\"Factory to create test library files.\"\"\"\n        _counter = [0]\n\n        async def _create_file(**kwargs):\n            from backend.app.models.library import LibraryFile\n\n            _counter[0] += 1\n            defaults = {\n                \"filename\": f\"test_{_counter[0]}.3mf\",\n                \"file_path\": f\"library/test_{_counter[0]}.3mf\",\n                \"file_type\": \"3mf\",\n                \"file_size\": 1024,\n            }\n            defaults.update(kwargs)\n\n            file = LibraryFile(**defaults)\n            db_session.add(file)\n            await db_session.commit()\n            await db_session.refresh(file)\n            return file\n\n        return _create_file\n\n    @pytest.fixture\n    async def library_folder_factory(self, db_session):\n        \"\"\"Factory to create test library folders.\"\"\"\n        _counter = [0]\n\n        async def _create_folder(**kwargs):\n            from backend.app.models.library import LibraryFolder\n\n            _counter[0] += 1\n            defaults = {\n                \"name\": f\"TestFolder_{_counter[0]}\",\n            }\n            defaults.update(kwargs)\n\n            folder = LibraryFolder(**defaults)\n            db_session.add(folder)\n            await db_session.commit()\n            await db_session.refresh(folder)\n            return folder\n\n        return _create_folder\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_admin_can_delete_any_library_file(self, async_client: AsyncClient, auth_setup, library_file_factory):\n        \"\"\"Admin can delete any library file.\"\"\"\n        file = await library_file_factory(created_by_id=auth_setup[\"operator_user\"][\"id\"])\n\n        response = await async_client.delete(\n            f\"/api/v1/library/files/{file.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n        )\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_can_delete_own_library_file(\n        self, async_client: AsyncClient, auth_setup, library_file_factory\n    ):\n        \"\"\"Operator can delete their own library file.\"\"\"\n        file = await library_file_factory(created_by_id=auth_setup[\"operator_user\"][\"id\"])\n\n        response = await async_client.delete(\n            f\"/api/v1/library/files/{file.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_delete_others_library_file(\n        self, async_client: AsyncClient, auth_setup, library_file_factory\n    ):\n        \"\"\"Operator cannot delete another user's library file.\"\"\"\n        file = await library_file_factory(created_by_id=auth_setup[\"operator2_user\"][\"id\"])\n\n        response = await async_client.delete(\n            f\"/api/v1/library/files/{file.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_can_update_own_library_file(\n        self, async_client: AsyncClient, auth_setup, library_file_factory\n    ):\n        \"\"\"Operator can update their own library file.\"\"\"\n        file = await library_file_factory(created_by_id=auth_setup[\"operator_user\"][\"id\"])\n\n        response = await async_client.put(\n            f\"/api/v1/library/files/{file.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n            json={\"filename\": \"renamed.3mf\"},\n        )\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_operator_cannot_update_others_library_file(\n        self, async_client: AsyncClient, auth_setup, library_file_factory\n    ):\n        \"\"\"Operator cannot update another user's library file.\"\"\"\n        file = await library_file_factory(created_by_id=auth_setup[\"operator2_user\"][\"id\"])\n\n        response = await async_client.put(\n            f\"/api/v1/library/files/{file.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n            json={\"filename\": \"renamed.3mf\"},\n        )\n\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_folders_require_all_permission(self, async_client: AsyncClient, auth_setup, library_folder_factory):\n        \"\"\"Folders require *_all permission (no ownership tracking on folders).\"\"\"\n        folder = await library_folder_factory(name=\"TestFolder\")\n\n        # Operator cannot delete folder (needs *_all)\n        response = await async_client.delete(\n            f\"/api/v1/library/folders/{folder.id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n        )\n\n        assert response.status_code == 403\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):\n        \"\"\"Bulk delete only deletes files the user owns.\"\"\"\n        own_file = await library_file_factory(\n            filename=\"own.3mf\",\n            created_by_id=auth_setup[\"operator_user\"][\"id\"],\n        )\n        other_file = await library_file_factory(\n            filename=\"other.3mf\",\n            created_by_id=auth_setup[\"operator2_user\"][\"id\"],\n        )\n\n        response = await async_client.post(\n            \"/api/v1/library/bulk-delete\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['operator_token']}\"},\n            json={\"file_ids\": [own_file.id, other_file.id], \"folder_ids\": []},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        # Should only delete the owned file; other_file is skipped (but skipped count not in response)\n        assert result[\"deleted_files\"] == 1\n\n\nclass TestAuthDisabledPermissions:\n    \"\"\"Tests that verify all operations are allowed when auth is disabled.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_archive_without_auth(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"When auth is disabled, anyone can delete archives.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.delete(f\"/api/v1/archives/{archive.id}\")\n\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_archive_without_auth(\n        self, async_client: AsyncClient, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"When auth is disabled, anyone can update archives.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory(printer.id)\n\n        response = await async_client.patch(\n            f\"/api/v1/archives/{archive.id}\",\n            json={\"print_name\": \"Updated Name\"},\n        )\n\n        assert response.status_code == 200\n\n\nclass TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):\n    \"\"\"Tests for user items count endpoint and deletion with items.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_user_items_count(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify items count endpoint returns correct counts.\"\"\"\n        printer = await printer_factory()\n        user_id = auth_setup[\"operator_user\"][\"id\"]\n\n        # Create some items for the operator\n        await archive_factory(printer.id, created_by_id=user_id)\n        await archive_factory(printer.id, created_by_id=user_id)\n\n        response = await async_client.get(\n            f\"/api/v1/users/{user_id}/items-count\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n        )\n\n        assert response.status_code == 200\n        counts = response.json()\n        assert counts[\"archives\"] >= 2\n        assert \"queue_items\" in counts\n        assert \"library_files\" in counts\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_user_keeps_items(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify deleting user without delete_items keeps items (ownerless).\"\"\"\n        printer = await printer_factory()\n        user_id = auth_setup[\"operator2_user\"][\"id\"]\n\n        # Create archive for operator2\n        archive = await archive_factory(printer.id, created_by_id=user_id)\n        archive_id = archive.id\n\n        # Delete user without deleting items\n        response = await async_client.delete(\n            f\"/api/v1/users/{user_id}?delete_items=false\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n        )\n\n        assert response.status_code == 204\n\n        # Verify archive still exists but is now ownerless\n        archive_response = await async_client.get(\n            f\"/api/v1/archives/{archive_id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n        )\n        assert archive_response.status_code == 200\n        assert archive_response.json()[\"created_by_id\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_user_with_items(\n        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify deleting user with delete_items=true removes their items.\"\"\"\n        printer = await printer_factory()\n\n        # Create a new user with items\n        create_response = await async_client.post(\n            \"/api/v1/users/\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n            json={\n                \"username\": \"deletewithitems\",\n                \"password\": \"Password123!\",\n            },\n        )\n        user_id = create_response.json()[\"id\"]\n\n        # Create archive for this user\n        archive = await archive_factory(printer.id, created_by_id=user_id)\n        archive_id = archive.id\n\n        # Delete user WITH deleting items\n        response = await async_client.delete(\n            f\"/api/v1/users/{user_id}?delete_items=true\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n        )\n\n        assert response.status_code == 204\n\n        # Verify archive was deleted\n        archive_response = await async_client.get(\n            f\"/api/v1/archives/{archive_id}\",\n            headers={\"Authorization\": f\"Bearer {auth_setup['admin_token']}\"},\n        )\n        assert archive_response.status_code == 404\n"
  },
  {
    "path": "backend/tests/integration/test_print_lifecycle.py",
    "content": "\"\"\"\nIntegration tests for the full print lifecycle.\n\nThese tests verify that:\n1. Print start creates a new archive\n2. Print complete updates archive status\n3. Callbacks are properly executed\n4. Energy tracking works\n5. Notifications are sent\n\nNote: These tests use mocking to avoid database conflicts.\nFull end-to-end tests require the actual database setup.\n\"\"\"\n\nimport asyncio\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\n\nclass TestPrintStartLogic:\n    \"\"\"Test print start callback logic without database integration.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_print_start_calls_notification_service(self, capture_logs):\n        \"\"\"Verify on_print_start triggers notification service.\"\"\"\n        with (\n            patch(\"backend.app.main.async_session\") as mock_session_maker,\n            patch(\"backend.app.main.notification_service\") as mock_notif,\n            patch(\"backend.app.main.smart_plug_manager\") as mock_plug,\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n        ):\n            mock_notif.on_print_start = AsyncMock()\n            mock_plug.on_print_start = AsyncMock()\n            mock_ws.send_print_start = AsyncMock()\n\n            # Mock the database session\n            mock_session = AsyncMock()\n            mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n            mock_session.__aexit__ = AsyncMock()\n            mock_session.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))\n            mock_session_maker.return_value = mock_session\n\n            from backend.app.main import on_print_start\n\n            await on_print_start(\n                1,\n                {\n                    \"filename\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                },\n            )\n\n            # Verify WebSocket notification was sent\n            mock_ws.send_print_start.assert_called_once()\n\n        # Verify no import shadowing errors\n        errors = [r for r in capture_logs.get_errors() if \"cannot access local variable\" in str(r.message)]\n        assert not errors, f\"Import shadowing error: {capture_logs.format_errors()}\"\n\n\nclass TestPrintCompleteLogic:\n    \"\"\"Test print complete callback logic.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_print_complete_no_import_errors(self, capture_logs):\n        \"\"\"Verify on_print_complete doesn't have import shadowing issues.\"\"\"\n        # Snapshot tasks before the call so we can cancel orphans afterwards.\n        # on_print_complete fires background tasks (maintenance check, notifications,\n        # smart-plug) via asyncio.create_task.  If those tasks outlive the mock\n        # context they use the *real* async_session and can send real notifications.\n        tasks_before = set(asyncio.all_tasks())\n\n        with (\n            patch(\"backend.app.main.async_session\") as mock_session_maker,\n            patch(\"backend.app.main.notification_service\") as mock_notif,\n            patch(\"backend.app.main.smart_plug_manager\") as mock_plug,\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.mqtt_relay\") as mock_relay,\n            patch(\"backend.app.main.printer_manager\") as mock_pm,\n        ):\n            mock_notif.on_print_complete = AsyncMock()\n            mock_plug.on_print_complete = AsyncMock()\n            mock_ws.send_print_complete = AsyncMock()\n            mock_ws.broadcast = AsyncMock()\n            mock_relay.on_print_complete = AsyncMock()\n            mock_pm.get_printer.return_value = None\n\n            # Mock the database session\n            mock_session = AsyncMock()\n            mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n            mock_session.__aexit__ = AsyncMock()\n            mock_session.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))\n            mock_session_maker.return_value = mock_session\n\n            from backend.app.main import on_print_complete\n\n            await on_print_complete(\n                1,\n                {\n                    \"status\": \"completed\",\n                    \"filename\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"timelapse_was_active\": False,\n                },\n            )\n\n            # Cancel background tasks spawned by on_print_complete before\n            # leaving the mock context — prevents them from running with\n            # the real async_session and sending real notifications.\n            for task in asyncio.all_tasks() - tasks_before:\n                task.cancel()\n                try:\n                    await task\n                except (asyncio.CancelledError, Exception):\n                    pass\n\n        # Verify no import shadowing errors - this would have caught the ArchiveService bug\n        errors = [r for r in capture_logs.get_errors() if \"cannot access local variable\" in str(r.message)]\n        assert not errors, f\"Import shadowing error: {capture_logs.format_errors()}\"\n\n\nclass TestTimelapseTracking:\n    \"\"\"Test timelapse detection during prints.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_timelapse_detected_in_same_message_as_print_start(self):\n        \"\"\"Verify timelapse is detected when xcam and state come together.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        client.on_print_start = lambda data: None\n\n        # Initial state\n        client._was_running = False\n        client._timelapse_during_print = False\n\n        # Message with both state and timelapse\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"xcam\": {\"timelapse\": \"enable\"},\n                }\n            }\n        )\n\n        assert client._was_running is True\n        assert client._timelapse_during_print is True, (\n            \"Timelapse should be detected even when xcam is parsed before state\"\n        )\n\n    @pytest.mark.asyncio\n    async def test_timelapse_flag_included_in_completion_callback(self):\n        \"\"\"Verify completion callback receives timelapse_was_active flag.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        completion_data = {}\n\n        def on_complete(data):\n            completion_data.update(data)\n\n        client.on_print_start = lambda data: None\n        client.on_print_complete = on_complete\n\n        # Start with timelapse\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"xcam\": {\"timelapse\": \"enable\"},\n                }\n            }\n        )\n\n        # Complete print\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FINISH\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert \"timelapse_was_active\" in completion_data\n        assert completion_data[\"timelapse_was_active\"] is True\n\n    @pytest.mark.asyncio\n    async def test_hms_errors_included_in_failed_completion_callback(self):\n        \"\"\"Verify completion callback receives hms_errors for failed prints.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        completion_data = {}\n\n        def on_complete(data):\n            completion_data.update(data)\n\n        client.on_print_start = lambda data: None\n        client.on_print_complete = on_complete\n\n        # Start print\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        # Add HMS error during print\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"hms\": [{\"attr\": 0x07000002, \"code\": 0x8001}],  # Filament module error (code must be >= 0x4000)\n                }\n            }\n        )\n\n        # Fail print\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FAILED\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert \"hms_errors\" in completion_data\n        assert len(completion_data[\"hms_errors\"]) == 1\n        assert completion_data[\"hms_errors\"][0][\"module\"] == 0x07\n        assert completion_data[\"status\"] == \"failed\"\n\n    @pytest.mark.asyncio\n    async def test_aborted_status_when_cancelled(self):\n        \"\"\"Verify completion callback receives 'aborted' status when print is cancelled.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        completion_data = {}\n\n        def on_complete(data):\n            completion_data.update(data)\n\n        client.on_print_start = lambda data: None\n        client.on_print_complete = on_complete\n\n        # Start print\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        # User cancels (goes to IDLE)\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"IDLE\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert completion_data[\"status\"] == \"aborted\"\n        assert \"hms_errors\" in completion_data\n\n    @pytest.mark.asyncio\n    async def test_timelapse_detected_from_ipcam_data(self):\n        \"\"\"Verify timelapse is detected from ipcam data (H2D sends it there, not xcam).\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        completion_data = {}\n\n        def on_complete(data):\n            completion_data.update(data)\n\n        client.on_print_start = lambda data: None\n        client.on_print_complete = on_complete\n\n        # Start print with timelapse in ipcam data (H2D format)\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"ipcam\": {\n                        \"ipcam_record\": \"enable\",\n                        \"timelapse\": \"enable\",\n                        \"resolution\": \"1080p\",\n                    },\n                }\n            }\n        )\n\n        assert client._timelapse_during_print is True, \"Timelapse should be detected from ipcam data\"\n\n        # Complete print\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FINISH\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert completion_data[\"timelapse_was_active\"] is True, (\n            \"timelapse_was_active should be True when timelapse was in ipcam\"\n        )\n\n\nclass TestCallbackErrorHandling:\n    \"\"\"Test that callback errors are properly logged.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_callback_errors_are_logged(self, capture_logs):\n        \"\"\"Verify that exceptions in callbacks are logged, not swallowed.\"\"\"\n        from backend.app.services.printer_manager import PrinterManager\n\n        manager = PrinterManager()\n\n        # Set up event loop\n        loop = asyncio.get_event_loop()\n        manager.set_event_loop(loop)\n\n        # Create a callback that raises an error\n        error_raised = False\n\n        async def failing_callback(printer_id, data):\n            nonlocal error_raised\n            error_raised = True\n            raise ValueError(\"Test error in callback\")\n\n        manager.set_print_complete_callback(failing_callback)\n\n        # The _schedule_async should log the error\n        # This is tested indirectly - if exception handling is broken,\n        # the error would be swallowed silently\n\n\nclass TestNoImportShadowing:\n    \"\"\"Verify no import shadowing issues exist in callbacks.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_no_import_errors(self, capture_logs):\n        \"\"\"Verify on_print_complete doesn't have import shadowing issues.\"\"\"\n        # Import the module to check for syntax/import errors\n        from backend.app import main\n\n        # The ArchiveService should be accessible\n        from backend.app.services.archive import ArchiveService\n\n        # Verify we can instantiate it (would fail with shadowing bug)\n        assert ArchiveService is not None\n\n        # Check logs for any import-related errors\n        errors = capture_logs.get_errors()\n        import_errors = [\n            e for e in errors if \"import\" in str(e.message).lower() or \"local variable\" in str(e.message).lower()\n        ]\n        assert not import_errors, f\"Import errors found: {import_errors}\"\n"
  },
  {
    "path": "backend/tests/integration/test_print_queue_api.py",
    "content": "\"\"\"Integration tests for Print Queue API endpoints.\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestPrintQueueAPI:\n    \"\"\"Integration tests for /api/v1/queue endpoints.\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n        _counter = [0]\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Test Printer {counter}\",\n                \"ip_address\": f\"192.168.1.{100 + counter}\",\n                \"serial_number\": f\"TESTSERIAL{counter:04d}\",\n                \"access_code\": \"12345678\",\n                \"model\": \"X1C\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n        _counter = [0]\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"test_print_{counter}.3mf\",\n                \"print_name\": f\"Test Print {counter}\",\n                \"file_path\": f\"/tmp/test_print_{counter}.3mf\",\n                \"file_size\": 1024,\n                \"content_hash\": f\"testhash{counter:08d}\",\n                \"status\": \"completed\",\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.fixture\n    async def queue_item_factory(self, db_session, printer_factory, archive_factory):\n        \"\"\"Factory to create test queue items.\"\"\"\n        _counter = [0]\n\n        async def _create_queue_item(**kwargs):\n            from backend.app.models.print_queue import PrintQueueItem\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            # Create printer and archive if not provided\n            if \"printer_id\" not in kwargs:\n                printer = await printer_factory()\n                kwargs[\"printer_id\"] = printer.id\n\n            if \"archive_id\" not in kwargs:\n                archive = await archive_factory()\n                kwargs[\"archive_id\"] = archive.id\n\n            defaults = {\n                \"status\": \"pending\",\n                \"position\": counter,\n            }\n            defaults.update(kwargs)\n\n            item = PrintQueueItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_queue_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_queue_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list when no queue items exist.\"\"\"\n        response = await async_client.get(\"/api/v1/queue/\")\n        assert response.status_code == 200\n        assert isinstance(response.json(), list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):\n        \"\"\"Verify item can be added to queue.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"printer_id\"] == printer.id\n        assert result[\"archive_id\"] == archive.id\n        assert result[\"status\"] == \"pending\"\n        assert result[\"manual_start\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_with_manual_start(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify item can be added to queue with manual_start=True.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"manual_start\": True,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"printer_id\"] == printer.id\n        assert result[\"archive_id\"] == archive.id\n        assert result[\"status\"] == \"pending\"\n        assert result[\"manual_start\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_with_project_id(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"#932: queue items created from the project view carry project_id forward.\"\"\"\n        from backend.app.models.project import Project\n\n        printer = await printer_factory()\n        archive = await archive_factory()\n        project = Project(name=\"Queue Project\")\n        db_session.add(project)\n        await db_session.commit()\n        await db_session.refresh(project)\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"project_id\": project.id,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        # The response schema may or may not echo project_id; the stored row is\n        # what matters, so verify via DB.\n        from sqlalchemy import select\n\n        from backend.app.models.print_queue import PrintQueueItem\n\n        row = (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == result[\"id\"]))).scalar_one()\n        assert row.project_id == project.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_invalid_project_id_returns_404(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"#932: bogus project_id must be rejected before the FK constraint fires.\n\n        Regression guard for the pre-check added to add_to_queue. Without the\n        validation, a nonexistent project_id would reach db.commit() and raise\n        an IntegrityError → 500. The pre-check must convert that to a 404 so\n        the UI gets a clean error it can surface.\n        \"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"project_id\": 999999,  # nonexistent\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 404\n        assert \"project\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_with_ams_mapping(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify item can be added to queue with ams_mapping.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"ams_mapping\": [5, -1, 2, -1],  # Slot 1 -> tray 5, slot 3 -> tray 2\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"printer_id\"] == printer.id\n        assert result[\"archive_id\"] == archive.id\n        assert result[\"ams_mapping\"] == [5, -1, 2, -1]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_with_plate_id(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify item can be added to queue with plate_id for multi-plate 3MF.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"plate_id\": 3,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"plate_id\"] == 3\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_with_print_options(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify item can be added to queue with print options.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"bed_levelling\": False,\n            \"flow_cali\": True,\n            \"vibration_cali\": False,\n            \"layer_inspect\": True,\n            \"timelapse\": True,\n            \"use_ams\": False,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"bed_levelling\"] is False\n        assert result[\"flow_cali\"] is True\n        assert result[\"vibration_cali\"] is False\n        assert result[\"layer_inspect\"] is True\n        assert result[\"timelapse\"] is True\n        assert result[\"use_ams\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_queue_item_plate_id(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify queue item plate_id can be updated.\"\"\"\n        item = await queue_item_factory()\n        response = await async_client.patch(f\"/api/v1/queue/{item.id}\", json={\"plate_id\": 5})\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"plate_id\"] == 5\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_queue_item_print_options(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify queue item print options can be updated.\"\"\"\n        item = await queue_item_factory()\n        response = await async_client.patch(\n            f\"/api/v1/queue/{item.id}\",\n            json={\n                \"bed_levelling\": False,\n                \"timelapse\": True,\n            },\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"bed_levelling\"] is False\n        assert result[\"timelapse\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify single queue item can be retrieved.\"\"\"\n        item = await queue_item_factory()\n        response = await async_client.get(f\"/api/v1/queue/{item.id}\")\n        assert response.status_code == 200\n        assert response.json()[\"id\"] == item.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_queue_item_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent queue item.\"\"\"\n        response = await async_client.get(\"/api/v1/queue/9999\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify queue item can be updated.\"\"\"\n        item = await queue_item_factory()\n        response = await async_client.patch(f\"/api/v1/queue/{item.id}\", json={\"auto_off_after\": True})\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"auto_off_after\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_queue_item_manual_start(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify queue item manual_start can be updated.\"\"\"\n        item = await queue_item_factory(manual_start=False)\n        response = await async_client.patch(f\"/api/v1/queue/{item.id}\", json={\"manual_start\": True})\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"manual_start\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify queue item can be deleted.\"\"\"\n        item = await queue_item_factory()\n        response = await async_client.delete(f\"/api/v1/queue/{item.id}\")\n        assert response.status_code == 200\n        assert response.json()[\"message\"] == \"Queue item deleted\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_queue_item_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for deleting non-existent queue item.\"\"\"\n        response = await async_client.delete(\"/api/v1/queue/9999\")\n        assert response.status_code == 404\n\n\nclass TestQueueStartEndpoint:\n    \"\"\"Tests for the /queue/{item_id}/start endpoint.\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n        _counter = [0]\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Test Printer {counter}\",\n                \"ip_address\": f\"192.168.1.{100 + counter}\",\n                \"serial_number\": f\"TESTSERIAL{counter:04d}\",\n                \"access_code\": \"12345678\",\n                \"model\": \"X1C\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n        _counter = [0]\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"test_print_{counter}.3mf\",\n                \"print_name\": f\"Test Print {counter}\",\n                \"file_path\": f\"/tmp/test_print_{counter}.3mf\",\n                \"file_size\": 1024,\n                \"content_hash\": f\"testhash{counter:08d}\",\n                \"status\": \"completed\",\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.fixture\n    async def queue_item_factory(self, db_session, printer_factory, archive_factory):\n        \"\"\"Factory to create test queue items.\"\"\"\n        _counter = [0]\n\n        async def _create_queue_item(**kwargs):\n            from backend.app.models.print_queue import PrintQueueItem\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            if \"printer_id\" not in kwargs:\n                printer = await printer_factory()\n                kwargs[\"printer_id\"] = printer.id\n\n            if \"archive_id\" not in kwargs:\n                archive = await archive_factory()\n                kwargs[\"archive_id\"] = archive.id\n\n            defaults = {\n                \"status\": \"pending\",\n                \"position\": counter,\n            }\n            defaults.update(kwargs)\n\n            item = PrintQueueItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_queue_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_start_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify starting a staged (manual_start=True) queue item clears the flag.\"\"\"\n        item = await queue_item_factory(manual_start=True)\n        assert item.manual_start is True\n\n        response = await async_client.post(f\"/api/v1/queue/{item.id}/start\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"manual_start\"] is False\n        assert result[\"status\"] == \"pending\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_start_non_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify starting a non-staged queue item still works (idempotent).\"\"\"\n        item = await queue_item_factory(manual_start=False)\n        assert item.manual_start is False\n\n        response = await async_client.post(f\"/api/v1/queue/{item.id}/start\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"manual_start\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_start_queue_item_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent queue item.\"\"\"\n        response = await async_client.post(\"/api/v1/queue/9999/start\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_start_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify 400 error when trying to start a non-pending queue item.\"\"\"\n        item = await queue_item_factory(status=\"printing\", manual_start=True)\n\n        response = await async_client.post(f\"/api/v1/queue/{item.id}/start\")\n        assert response.status_code == 400\n        assert \"pending\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_start_completed_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify 400 error when trying to start a completed queue item.\"\"\"\n        item = await queue_item_factory(status=\"completed\", manual_start=True)\n\n        response = await async_client.post(f\"/api/v1/queue/{item.id}/start\")\n        assert response.status_code == 400\n\n\nclass TestQueueCancelEndpoint:\n    \"\"\"Tests for the /queue/{item_id}/cancel endpoint.\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            defaults = {\n                \"name\": \"Cancel Test Printer\",\n                \"ip_address\": \"192.168.1.200\",\n                \"serial_number\": \"TESTCANCEL001\",\n                \"access_code\": \"12345678\",\n                \"model\": \"X1C\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            defaults = {\n                \"filename\": \"cancel_test.3mf\",\n                \"print_name\": \"Cancel Test Print\",\n                \"file_path\": \"/tmp/cancel_test.3mf\",\n                \"file_size\": 1024,\n                \"content_hash\": \"cancelhash001\",\n                \"status\": \"completed\",\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.fixture\n    async def queue_item_factory(self, db_session, printer_factory, archive_factory):\n        \"\"\"Factory to create test queue items.\"\"\"\n\n        async def _create_queue_item(**kwargs):\n            from backend.app.models.print_queue import PrintQueueItem\n\n            if \"printer_id\" not in kwargs:\n                printer = await printer_factory()\n                kwargs[\"printer_id\"] = printer.id\n\n            if \"archive_id\" not in kwargs:\n                archive = await archive_factory()\n                kwargs[\"archive_id\"] = archive.id\n\n            defaults = {\n                \"status\": \"pending\",\n                \"position\": 1,\n            }\n            defaults.update(kwargs)\n\n            item = PrintQueueItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_queue_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cancel_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify cancelling a pending queue item.\"\"\"\n        item = await queue_item_factory(status=\"pending\")\n\n        response = await async_client.post(f\"/api/v1/queue/{item.id}/cancel\")\n        assert response.status_code == 200\n        assert response.json()[\"message\"] == \"Queue item cancelled\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cancel_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify 400 error when trying to cancel a non-pending queue item.\"\"\"\n        item = await queue_item_factory(status=\"printing\")\n\n        response = await async_client.post(f\"/api/v1/queue/{item.id}/cancel\")\n        assert response.status_code == 400\n\n\nclass TestQueueLibraryFileSupport:\n    \"\"\"Tests for queue items with library_file_id (instead of archive_id).\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n        _counter = [0]\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Library Test Printer {counter}\",\n                \"ip_address\": f\"192.168.1.{150 + counter}\",\n                \"serial_number\": f\"TESTLIB{counter:04d}\",\n                \"access_code\": \"12345678\",\n                \"model\": \"X1C\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def library_file_factory(self, db_session):\n        \"\"\"Factory to create test library files.\"\"\"\n        _counter = [0]\n\n        async def _create_library_file(**kwargs):\n            from backend.app.models.library import LibraryFile\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"library_test_{counter}.3mf\",\n                \"file_path\": f\"/test/library/library_test_{counter}.3mf\",\n                \"file_size\": 2048,\n                \"file_type\": \"3mf\",\n                \"file_metadata\": {\"print_name\": f\"Library Print {counter}\", \"print_time_seconds\": 3600},\n            }\n            defaults.update(kwargs)\n\n            lib_file = LibraryFile(**defaults)\n            db_session.add(lib_file)\n            await db_session.commit()\n            await db_session.refresh(lib_file)\n            return lib_file\n\n        return _create_library_file\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_with_library_file(\n        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session\n    ):\n        \"\"\"Verify item can be added to queue using library_file_id instead of archive_id.\"\"\"\n        printer = await printer_factory()\n        lib_file = await library_file_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"library_file_id\": lib_file.id,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"printer_id\"] == printer.id\n        assert result[\"library_file_id\"] == lib_file.id\n        assert result[\"archive_id\"] is None\n        assert result[\"status\"] == \"pending\"\n        assert result[\"library_file_name\"] == \"Library Print 1\"\n        assert result[\"print_time_seconds\"] == 3600\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_library_file_with_options(\n        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session\n    ):\n        \"\"\"Verify library file queue item can have all options set.\"\"\"\n        printer = await printer_factory()\n        lib_file = await library_file_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"library_file_id\": lib_file.id,\n            \"ams_mapping\": [1, 2, -1, -1],\n            \"plate_id\": 2,\n            \"bed_levelling\": False,\n            \"timelapse\": True,\n            \"manual_start\": True,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"library_file_id\"] == lib_file.id\n        assert result[\"ams_mapping\"] == [1, 2, -1, -1]\n        assert result[\"plate_id\"] == 2\n        assert result[\"bed_levelling\"] is False\n        assert result[\"timelapse\"] is True\n        assert result[\"manual_start\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_requires_archive_or_library_file(\n        self, async_client: AsyncClient, printer_factory, db_session\n    ):\n        \"\"\"Verify 400 error when neither archive_id nor library_file_id provided.\"\"\"\n        printer = await printer_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 400\n        assert (\n            \"archive_id\" in response.json()[\"detail\"].lower() or \"library_file_id\" in response.json()[\"detail\"].lower()\n        )\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_queue_item_with_library_file(\n        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session\n    ):\n        \"\"\"Verify queue item with library_file_id can be updated.\"\"\"\n        from backend.app.models.print_queue import PrintQueueItem\n\n        printer = await printer_factory()\n        lib_file = await library_file_factory()\n\n        # Create queue item directly\n        item = PrintQueueItem(\n            printer_id=printer.id,\n            library_file_id=lib_file.id,\n            status=\"pending\",\n            position=1,\n        )\n        db_session.add(item)\n        await db_session.commit()\n        await db_session.refresh(item)\n\n        # Update the item\n        response = await async_client.patch(\n            f\"/api/v1/queue/{item.id}\",\n            json={\"auto_off_after\": True, \"plate_id\": 3},\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"auto_off_after\"] is True\n        assert result[\"plate_id\"] == 3\n        assert result[\"library_file_id\"] == lib_file.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_queue_includes_library_file_info(\n        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session\n    ):\n        \"\"\"Verify queue list includes library file metadata.\"\"\"\n        from backend.app.models.print_queue import PrintQueueItem\n\n        printer = await printer_factory()\n        lib_file = await library_file_factory(\n            file_metadata={\"print_name\": \"Custom Print Name\", \"print_time_seconds\": 7200}\n        )\n\n        item = PrintQueueItem(\n            printer_id=printer.id,\n            library_file_id=lib_file.id,\n            status=\"pending\",\n            position=1,\n        )\n        db_session.add(item)\n        await db_session.commit()\n\n        response = await async_client.get(\"/api/v1/queue/\")\n        assert response.status_code == 200\n        items = response.json()\n        assert len(items) >= 1\n\n        # Find our item\n        our_item = next((i for i in items if i[\"library_file_id\"] == lib_file.id), None)\n        assert our_item is not None\n        assert our_item[\"library_file_name\"] == \"Custom Print Name\"\n        assert our_item[\"print_time_seconds\"] == 7200\n\n\nclass TestBulkUpdateEndpoint:\n    \"\"\"Tests for the /queue/bulk endpoint.\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n        _counter = [0]\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Bulk Test Printer {counter}\",\n                \"ip_address\": f\"192.168.1.{150 + counter}\",\n                \"serial_number\": f\"TESTBULK{counter:04d}\",\n                \"access_code\": \"12345678\",\n                \"model\": \"X1C\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n        _counter = [0]\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"bulk_test_{counter}.3mf\",\n                \"print_name\": f\"Bulk Test Print {counter}\",\n                \"file_path\": f\"/tmp/bulk_test_{counter}.3mf\",\n                \"file_size\": 1024,\n                \"content_hash\": f\"bulkhash{counter:04d}\",\n                \"status\": \"completed\",\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.fixture\n    async def queue_item_factory(self, db_session, printer_factory, archive_factory):\n        \"\"\"Factory to create test queue items.\"\"\"\n\n        async def _create_item(**kwargs):\n            from backend.app.models.print_queue import PrintQueueItem\n\n            if \"printer_id\" not in kwargs:\n                printer = await printer_factory()\n                kwargs[\"printer_id\"] = printer.id\n\n            if \"archive_id\" not in kwargs:\n                archive = await archive_factory()\n                kwargs[\"archive_id\"] = archive.id\n\n            defaults = {\n                \"status\": \"pending\",\n                \"position\": 1,\n                \"bed_levelling\": True,\n                \"flow_cali\": False,\n                \"vibration_cali\": True,\n            }\n            defaults.update(kwargs)\n\n            item = PrintQueueItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify bulk update can change a single field on multiple items.\"\"\"\n        item1 = await queue_item_factory(bed_levelling=True)\n        item2 = await queue_item_factory(bed_levelling=True)\n\n        response = await async_client.patch(\n            \"/api/v1/queue/bulk\",\n            json={\"item_ids\": [item1.id, item2.id], \"bed_levelling\": False},\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"updated_count\"] == 2\n        assert result[\"skipped_count\"] == 0\n\n        # Verify items were updated\n        await db_session.refresh(item1)\n        await db_session.refresh(item2)\n        assert item1.bed_levelling is False\n        assert item2.bed_levelling is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify bulk update can change multiple fields at once.\"\"\"\n        item1 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)\n        item2 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)\n\n        response = await async_client.patch(\n            \"/api/v1/queue/bulk\",\n            json={\n                \"item_ids\": [item1.id, item2.id],\n                \"bed_levelling\": False,\n                \"flow_cali\": True,\n                \"manual_start\": True,\n            },\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"updated_count\"] == 2\n\n        await db_session.refresh(item1)\n        assert item1.bed_levelling is False\n        assert item1.flow_cali is True\n        assert item1.manual_start is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify bulk update skips non-pending items.\"\"\"\n        pending_item = await queue_item_factory(status=\"pending\", bed_levelling=True)\n        printing_item = await queue_item_factory(status=\"printing\", bed_levelling=True)\n        completed_item = await queue_item_factory(status=\"completed\", bed_levelling=True)\n\n        response = await async_client.patch(\n            \"/api/v1/queue/bulk\",\n            json={\n                \"item_ids\": [pending_item.id, printing_item.id, completed_item.id],\n                \"bed_levelling\": False,\n            },\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"updated_count\"] == 1\n        assert result[\"skipped_count\"] == 2\n\n        # Only pending item should be updated\n        await db_session.refresh(pending_item)\n        await db_session.refresh(printing_item)\n        await db_session.refresh(completed_item)\n        assert pending_item.bed_levelling is False\n        assert printing_item.bed_levelling is True\n        assert completed_item.bed_levelling is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_update_change_printer(\n        self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session\n    ):\n        \"\"\"Verify bulk update can reassign items to a different printer.\"\"\"\n        new_printer = await printer_factory(name=\"New Target Printer\")\n        item1 = await queue_item_factory()\n        item2 = await queue_item_factory()\n\n        original_printer_id = item1.printer_id\n\n        response = await async_client.patch(\n            \"/api/v1/queue/bulk\",\n            json={\"item_ids\": [item1.id, item2.id], \"printer_id\": new_printer.id},\n        )\n        assert response.status_code == 200\n\n        await db_session.refresh(item1)\n        await db_session.refresh(item2)\n        assert item1.printer_id == new_printer.id\n        assert item2.printer_id == new_printer.id\n        assert item1.printer_id != original_printer_id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_update_empty_item_ids(self, async_client: AsyncClient):\n        \"\"\"Verify 400 error when item_ids is empty.\"\"\"\n        response = await async_client.patch(\n            \"/api/v1/queue/bulk\",\n            json={\"item_ids\": [], \"bed_levelling\": False},\n        )\n        assert response.status_code == 400\n        assert \"no item\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_update_no_fields(self, async_client: AsyncClient, queue_item_factory):\n        \"\"\"Verify 400 error when no fields to update.\"\"\"\n        item = await queue_item_factory()\n\n        response = await async_client.patch(\n            \"/api/v1/queue/bulk\",\n            json={\"item_ids\": [item.id]},\n        )\n        assert response.status_code == 400\n        assert \"no fields\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bulk_update_invalid_printer(self, async_client: AsyncClient, queue_item_factory):\n        \"\"\"Verify 400 error when printer_id doesn't exist.\"\"\"\n        item = await queue_item_factory()\n\n        response = await async_client.patch(\n            \"/api/v1/queue/bulk\",\n            json={\"item_ids\": [item.id], \"printer_id\": 99999},\n        )\n        assert response.status_code == 400\n        assert \"printer not found\" in response.json()[\"detail\"].lower()\n\n\nclass TestTargetLocationFeature:\n    \"\"\"Tests for queue items with target_location (Issue #220).\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n        _counter = [0]\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Location Test Printer {counter}\",\n                \"ip_address\": f\"192.168.1.{50 + counter}\",\n                \"serial_number\": f\"TESTLOC{counter:04d}\",\n                \"access_code\": \"12345678\",\n                \"model\": \"X1C\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n        _counter = [0]\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"location_test_{counter}.3mf\",\n                \"print_name\": f\"Location Test Print {counter}\",\n                \"file_path\": f\"/tmp/location_test_{counter}.3mf\",\n                \"file_size\": 1024,\n                \"content_hash\": f\"lochash{counter:08d}\",\n                \"status\": \"completed\",\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.fixture\n    async def queue_item_factory(self, db_session, printer_factory, archive_factory):\n        \"\"\"Factory to create test queue items.\"\"\"\n        _counter = [0]\n\n        async def _create_queue_item(**kwargs):\n            from backend.app.models.print_queue import PrintQueueItem\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            if \"printer_id\" not in kwargs and \"target_model\" not in kwargs:\n                printer = await printer_factory()\n                kwargs[\"printer_id\"] = printer.id\n\n            if \"archive_id\" not in kwargs:\n                archive = await archive_factory()\n                kwargs[\"archive_id\"] = archive.id\n\n            defaults = {\n                \"status\": \"pending\",\n                \"position\": counter,\n            }\n            defaults.update(kwargs)\n\n            item = PrintQueueItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_queue_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_with_target_location(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify item can be added with target_model and target_location.\"\"\"\n        # Create a printer with model X1C so the API can validate\n        await printer_factory(model=\"X1C\", location=\"Office\")\n        archive = await archive_factory()\n\n        data = {\n            \"target_model\": \"X1C\",\n            \"target_location\": \"Workbench\",\n            \"archive_id\": archive.id,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"target_model\"] == \"X1C\"\n        assert result[\"target_location\"] == \"Workbench\"\n        assert result[\"printer_id\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_location_without_model_ignored(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify target_location without target_model is allowed (location is just ignored).\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"target_location\": \"Workbench\",  # This gets ignored since printer_id is set\n            \"archive_id\": archive.id,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        # The API accepts this but the location is only used with target_model\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"printer_id\"] == printer.id\n        # Location may or may not be stored since it's meaningless without target_model\n        # The important thing is the request succeeds\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_item_target_location_in_response(\n        self, async_client: AsyncClient, queue_item_factory, db_session\n    ):\n        \"\"\"Verify target_location is returned in queue item response.\"\"\"\n        item = await queue_item_factory(\n            printer_id=None,\n            target_model=\"X1C\",\n            target_location=\"Workshop\",\n        )\n\n        response = await async_client.get(f\"/api/v1/queue/{item.id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"target_model\"] == \"X1C\"\n        assert result[\"target_location\"] == \"Workshop\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_list_includes_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify target_location is included in queue list.\"\"\"\n        await queue_item_factory(\n            printer_id=None,\n            target_model=\"P1S\",\n            target_location=\"Garage\",\n        )\n\n        response = await async_client.get(\"/api/v1/queue/\")\n        assert response.status_code == 200\n        items = response.json()\n        assert len(items) >= 1\n\n        # Find our item\n        our_item = next((i for i in items if i[\"target_location\"] == \"Garage\"), None)\n        assert our_item is not None\n        assert our_item[\"target_model\"] == \"P1S\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_queue_item_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify target_location can be updated on existing queue item.\"\"\"\n        item = await queue_item_factory(\n            printer_id=None,\n            target_model=\"X1C\",\n            target_location=\"Office\",\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/queue/{item.id}\",\n            json={\"target_location\": \"Basement\"},\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"target_location\"] == \"Basement\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_clear_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):\n        \"\"\"Verify target_location can be cleared (set to None).\"\"\"\n        item = await queue_item_factory(\n            printer_id=None,\n            target_model=\"X1C\",\n            target_location=\"Office\",\n        )\n\n        # Note: Setting to empty string should clear it\n        response = await async_client.patch(\n            f\"/api/v1/queue/{item.id}\",\n            json={\"target_location\": None},\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"target_location\"] is None\n\n\nclass TestAbortedStatusNormalisation:\n    \"\"\"Tests for issue #558: 'aborted' queue status causes 500 error.\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n        _counter = [0]\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Abort Test Printer {counter}\",\n                \"ip_address\": f\"192.168.1.{60 + counter}\",\n                \"serial_number\": f\"TESTABORT{counter:04d}\",\n                \"access_code\": \"12345678\",\n                \"model\": \"P1S\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n        _counter = [0]\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"abort_test_{counter}.3mf\",\n                \"print_name\": f\"Abort Test Print {counter}\",\n                \"file_path\": f\"/tmp/abort_test_{counter}.3mf\",\n                \"file_size\": 1024,\n                \"content_hash\": f\"aborthash{counter:06d}\",\n                \"status\": \"completed\",\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.fixture\n    async def queue_item_factory(self, db_session, printer_factory, archive_factory):\n        \"\"\"Factory to create test queue items.\"\"\"\n        _counter = [0]\n\n        async def _create_queue_item(**kwargs):\n            from backend.app.models.print_queue import PrintQueueItem\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            if \"printer_id\" not in kwargs:\n                printer = await printer_factory()\n                kwargs[\"printer_id\"] = printer.id\n            if \"archive_id\" not in kwargs:\n                archive = await archive_factory()\n                kwargs[\"archive_id\"] = archive.id\n\n            defaults = {\n                \"status\": \"pending\",\n                \"position\": counter,\n            }\n            defaults.update(kwargs)\n\n            item = PrintQueueItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_queue_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_on_print_complete_normalises_aborted_to_cancelled(self, queue_item_factory, db_session):\n        \"\"\"Verify the completion handler maps 'aborted' → 'cancelled' for queue items.\"\"\"\n        import asyncio\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        item = await queue_item_factory(status=\"printing\")\n\n        # Build a mock session whose execute returns our item\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = [item]\n\n        mock_session = AsyncMock()\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock(return_value=False)\n        mock_session.execute = AsyncMock(return_value=mock_result)\n        mock_session.commit = AsyncMock()\n\n        tasks_before = set(asyncio.all_tasks())\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.core.database.async_session\", return_value=mock_session),\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.mqtt_relay\") as mock_relay,\n            patch(\"backend.app.main.notification_service\") as mock_notif,\n            patch(\"backend.app.main.smart_plug_manager\") as mock_plug,\n            patch(\"backend.app.main.printer_manager\") as mock_pm,\n        ):\n            mock_ws.send_print_complete = AsyncMock()\n            mock_ws.broadcast = AsyncMock()\n            mock_relay.on_print_complete = AsyncMock()\n            mock_relay.on_queue_job_completed = AsyncMock()\n            mock_notif.on_print_complete = AsyncMock()\n            mock_plug.on_print_complete = AsyncMock()\n            mock_pm.get_printer.return_value = None\n\n            from backend.app.main import on_print_complete\n\n            await on_print_complete(\n                item.printer_id,\n                {\n                    \"status\": \"aborted\",\n                    \"filename\": \"test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"timelapse_was_active\": False,\n                },\n            )\n\n            # Cancel background tasks before leaving mock context\n            for task in asyncio.all_tasks() - tasks_before:\n                task.cancel()\n                try:\n                    await task\n                except (asyncio.CancelledError, Exception):\n                    pass\n\n        # The item status should be normalised to 'cancelled', not 'aborted'\n        assert item.status == \"cancelled\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_startup_fixup_converts_aborted_to_cancelled(self, queue_item_factory, db_session):\n        \"\"\"Verify the startup fixup converts existing 'aborted' rows to 'cancelled'.\"\"\"\n        from sqlalchemy import select\n\n        from backend.app.models.print_queue import PrintQueueItem\n\n        # Create items with various statuses including 'aborted'\n        item_aborted = await queue_item_factory(status=\"pending\")\n        item_pending = await queue_item_factory(status=\"pending\")\n\n        # Manually set the invalid status\n        item_aborted.status = \"aborted\"\n        db_session.add(item_aborted)\n        await db_session.commit()\n\n        # Run the fixup query (same logic as lifespan)\n        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == \"aborted\"))\n        aborted_items = result.scalars().all()\n        for i in aborted_items:\n            i.status = \"cancelled\"\n        await db_session.commit()\n\n        # Verify: no more 'aborted' items\n        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == \"aborted\"))\n        assert len(result.scalars().all()) == 0\n\n        # The previously aborted item should now be 'cancelled'\n        await db_session.refresh(item_aborted)\n        assert item_aborted.status == \"cancelled\"\n\n        # The pending item should be unchanged\n        await db_session.refresh(item_pending)\n        assert item_pending.status == \"pending\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_completed_status_passes_through_unchanged(self, queue_item_factory, db_session):\n        \"\"\"Verify normal statuses like 'completed' are not affected by normalisation.\"\"\"\n        import asyncio\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        item = await queue_item_factory(status=\"printing\")\n\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = [item]\n\n        mock_session = AsyncMock()\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock(return_value=False)\n        mock_session.execute = AsyncMock(return_value=mock_result)\n        mock_session.commit = AsyncMock()\n\n        tasks_before = set(asyncio.all_tasks())\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.core.database.async_session\", return_value=mock_session),\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.mqtt_relay\") as mock_relay,\n            patch(\"backend.app.main.notification_service\") as mock_notif,\n            patch(\"backend.app.main.smart_plug_manager\") as mock_plug,\n            patch(\"backend.app.main.printer_manager\") as mock_pm,\n        ):\n            mock_ws.send_print_complete = AsyncMock()\n            mock_ws.broadcast = AsyncMock()\n            mock_relay.on_print_complete = AsyncMock()\n            mock_relay.on_queue_job_completed = AsyncMock()\n            mock_notif.on_print_complete = AsyncMock()\n            mock_plug.on_print_complete = AsyncMock()\n            mock_pm.get_printer.return_value = None\n\n            from backend.app.main import on_print_complete\n\n            await on_print_complete(\n                item.printer_id,\n                {\n                    \"status\": \"completed\",\n                    \"filename\": \"test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"timelapse_was_active\": False,\n                },\n            )\n\n            # Cancel background tasks before leaving mock context\n            for task in asyncio.all_tasks() - tasks_before:\n                task.cancel()\n                try:\n                    await task\n                except (asyncio.CancelledError, Exception):\n                    pass\n\n        assert item.status == \"completed\"\n\n    # ========================================================================\n    # Library file usage tracking on print completion (#1008)\n    #\n    # These exercise the _bump_library_file_usage_if_completed helper directly\n    # rather than invoking the whole on_print_complete handler — that path\n    # spawns background asyncio tasks (notifications, MQTT relay, smart-plug)\n    # that are expensive to mock and have nothing to do with the bump logic.\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bump_library_file_usage_on_completed(self, printer_factory, db_session):\n        \"\"\"Successful completion increments print_count and stamps last_printed_at.\"\"\"\n        from datetime import datetime, timezone\n\n        from backend.app.main import _bump_library_file_usage_if_completed\n        from backend.app.models.library import LibraryFile\n        from backend.app.models.print_queue import PrintQueueItem\n\n        printer = await printer_factory()\n        lib_file = LibraryFile(\n            filename=\"benchy.gcode.3mf\",\n            file_path=\"/data/library/benchy.gcode.3mf\",\n            file_type=\"gcode.3mf\",\n            file_size=1024,\n            print_count=0,\n            last_printed_at=None,\n        )\n        db_session.add(lib_file)\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n\n        item = PrintQueueItem(\n            printer_id=printer.id,\n            library_file_id=lib_file.id,\n            status=\"printing\",\n            position=1,\n        )\n\n        before = datetime.now(timezone.utc).replace(tzinfo=None)\n        await _bump_library_file_usage_if_completed(db_session, item, \"completed\")\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n\n        assert lib_file.print_count == 1\n        assert lib_file.last_printed_at is not None\n        assert lib_file.last_printed_at >= before\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bump_library_file_usage_repeated_prints_increment_count(self, printer_factory, db_session):\n        \"\"\"Each successful completion bumps print_count cumulatively.\"\"\"\n        from backend.app.main import _bump_library_file_usage_if_completed\n        from backend.app.models.library import LibraryFile\n        from backend.app.models.print_queue import PrintQueueItem\n\n        printer = await printer_factory()\n        lib_file = LibraryFile(\n            filename=\"repeat.gcode.3mf\",\n            file_path=\"/data/library/repeat.gcode.3mf\",\n            file_type=\"gcode.3mf\",\n            file_size=1024,\n            print_count=0,\n        )\n        db_session.add(lib_file)\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n\n        item = PrintQueueItem(\n            printer_id=printer.id,\n            library_file_id=lib_file.id,\n            status=\"printing\",\n            position=1,\n        )\n\n        for _ in range(3):\n            await _bump_library_file_usage_if_completed(db_session, item, \"completed\")\n\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n        assert lib_file.print_count == 3\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    @pytest.mark.parametrize(\"terminal_status\", [\"failed\", \"cancelled\"])\n    async def test_bump_library_file_usage_skips_non_completed(self, printer_factory, db_session, terminal_status):\n        \"\"\"Failed and cancelled prints must NOT count as usage.\"\"\"\n        from backend.app.main import _bump_library_file_usage_if_completed\n        from backend.app.models.library import LibraryFile\n        from backend.app.models.print_queue import PrintQueueItem\n\n        printer = await printer_factory()\n        lib_file = LibraryFile(\n            filename=\"broken.gcode.3mf\",\n            file_path=\"/data/library/broken.gcode.3mf\",\n            file_type=\"gcode.3mf\",\n            file_size=1024,\n            print_count=0,\n            last_printed_at=None,\n        )\n        db_session.add(lib_file)\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n\n        item = PrintQueueItem(\n            printer_id=printer.id,\n            library_file_id=lib_file.id,\n            status=\"printing\",\n            position=1,\n        )\n\n        await _bump_library_file_usage_if_completed(db_session, item, terminal_status)\n        await db_session.commit()\n        await db_session.refresh(lib_file)\n\n        assert lib_file.print_count == 0\n        assert lib_file.last_printed_at is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_bump_library_file_usage_skips_when_no_library_file_id(\n        self, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Queue items without library_file_id (e.g. archive reprints) are a no-op.\"\"\"\n        from backend.app.main import _bump_library_file_usage_if_completed\n        from backend.app.models.print_queue import PrintQueueItem\n\n        printer = await printer_factory()\n        archive = await archive_factory()\n        item = PrintQueueItem(\n            printer_id=printer.id,\n            library_file_id=None,\n            archive_id=archive.id,\n            status=\"printing\",\n            position=1,\n        )\n\n        # Must not raise.\n        await _bump_library_file_usage_if_completed(db_session, item, \"completed\")\n\n    # ========================================================================\n    # Batch quantity tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_quantity_default(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify quantity=1 (default) creates a single item with no batch.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"batch_id\"] is None\n        assert result[\"batch_name\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_quantity_one_explicit(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify quantity=1 explicitly creates a single item with no batch.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"quantity\": 1,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"batch_id\"] is None\n        assert result[\"batch_name\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_quantity_creates_batch(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify quantity > 1 creates a batch and multiple queue items.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"quantity\": 3,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        # First item is returned, linked to a batch\n        assert result[\"batch_id\"] is not None\n        assert result[\"batch_name\"] is not None\n        assert \"×3\" in result[\"batch_name\"]\n\n        # Verify all 3 items were created\n        list_response = await async_client.get(\"/api/v1/queue/\")\n        items = list_response.json()\n        batch_items = [i for i in items if i[\"batch_id\"] == result[\"batch_id\"]]\n        assert len(batch_items) == 3\n        # All items should have the same settings\n        for item in batch_items:\n            assert item[\"printer_id\"] == printer.id\n            assert item[\"archive_id\"] == archive.id\n            assert item[\"status\"] == \"pending\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_quantity_sequential_positions(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify batch items get sequential positions.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"quantity\": 3,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        batch_id = response.json()[\"batch_id\"]\n\n        list_response = await async_client.get(\"/api/v1/queue/\")\n        items = list_response.json()\n        batch_items = sorted(\n            [i for i in items if i[\"batch_id\"] == batch_id],\n            key=lambda i: i[\"position\"],\n        )\n        positions = [i[\"position\"] for i in batch_items]\n        assert positions == [positions[0], positions[0] + 1, positions[0] + 2]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_add_to_queue_quantity_with_print_options(\n        self, async_client: AsyncClient, printer_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify print options are applied to all batch items.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"quantity\": 2,\n            \"bed_levelling\": False,\n            \"timelapse\": True,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        assert response.status_code == 200\n        batch_id = response.json()[\"batch_id\"]\n\n        list_response = await async_client.get(\"/api/v1/queue/\")\n        batch_items = [i for i in list_response.json() if i[\"batch_id\"] == batch_id]\n        assert len(batch_items) == 2\n        for item in batch_items:\n            assert item[\"bed_levelling\"] is False\n            assert item[\"timelapse\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):\n        \"\"\"Verify batch can be retrieved with progress stats.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        # Create a batch of 3\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"quantity\": 3,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        batch_id = response.json()[\"batch_id\"]\n\n        # Get batch\n        response = await async_client.get(f\"/api/v1/queue/batches/{batch_id}\")\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == batch_id\n        assert result[\"quantity\"] == 3\n        assert result[\"status\"] == \"active\"\n        assert result[\"pending_count\"] == 3\n        assert result[\"printing_count\"] == 0\n        assert result[\"completed_count\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_batches(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):\n        \"\"\"Verify batches can be listed.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        # Create two batches\n        for qty in [2, 3]:\n            await async_client.post(\n                \"/api/v1/queue/\",\n                json={\"printer_id\": printer.id, \"archive_id\": archive.id, \"quantity\": qty},\n            )\n\n        response = await async_client.get(\"/api/v1/queue/batches\")\n        assert response.status_code == 200\n        batches = response.json()\n        assert len(batches) >= 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cancel_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):\n        \"\"\"Verify cancelling a batch cancels all pending items.\"\"\"\n        printer = await printer_factory()\n        archive = await archive_factory()\n\n        data = {\n            \"printer_id\": printer.id,\n            \"archive_id\": archive.id,\n            \"quantity\": 3,\n        }\n        response = await async_client.post(\"/api/v1/queue/\", json=data)\n        batch_id = response.json()[\"batch_id\"]\n\n        # Cancel the batch\n        response = await async_client.delete(f\"/api/v1/queue/batches/{batch_id}\")\n        assert response.status_code == 200\n\n        # Verify all items are cancelled\n        list_response = await async_client.get(\"/api/v1/queue/\")\n        batch_items = [i for i in list_response.json() if i[\"batch_id\"] == batch_id]\n        for item in batch_items:\n            assert item[\"status\"] == \"cancelled\"\n\n        # Verify batch status\n        batch_response = await async_client.get(f\"/api/v1/queue/batches/{batch_id}\")\n        assert batch_response.json()[\"status\"] == \"cancelled\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_batch_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent batch.\"\"\"\n        response = await async_client.get(\"/api/v1/queue/batches/9999\")\n        assert response.status_code == 404\n"
  },
  {
    "path": "backend/tests/integration/test_printers_api.py",
    "content": "\"\"\"Integration tests for Printers API endpoints.\n\nTests the full request/response cycle for /api/v1/printers/ endpoints.\n\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestPrintersAPI:\n    \"\"\"Integration tests for /api/v1/printers/ endpoints.\"\"\"\n\n    # ========================================================================\n    # List endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_printers_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list is returned when no printers exist.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/\")\n\n        assert response.status_code == 200\n        assert response.json() == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_printers_with_data(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify list returns existing printers.\"\"\"\n        await printer_factory(name=\"Test Printer\")\n\n        response = await async_client.get(\"/api/v1/printers/\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) >= 1\n        assert any(p[\"name\"] == \"Test Printer\" for p in data)\n\n    # ========================================================================\n    # Create endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_printer(self, async_client: AsyncClient):\n        \"\"\"Verify printer can be created.\"\"\"\n        data = {\n            \"name\": \"New Printer\",\n            \"serial_number\": \"00M09A111111111\",\n            \"ip_address\": \"192.168.1.100\",\n            \"access_code\": \"12345678\",\n            \"is_active\": True,\n            \"model\": \"X1C\",\n        }\n\n        response = await async_client.post(\"/api/v1/printers/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"New Printer\"\n        assert result[\"serial_number\"] == \"00M09A111111111\"\n        assert result[\"model\"] == \"X1C\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_printer_with_hostname(self, async_client: AsyncClient):\n        \"\"\"Verify printer can be created with a hostname instead of IP address.\"\"\"\n        data = {\n            \"name\": \"DNS Printer\",\n            \"serial_number\": \"00M09A555555555\",\n            \"ip_address\": \"printer.local\",\n            \"access_code\": \"12345678\",\n            \"model\": \"P1S\",\n        }\n\n        response = await async_client.post(\"/api/v1/printers/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"DNS Printer\"\n        assert result[\"ip_address\"] == \"printer.local\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_printer_with_fqdn(self, async_client: AsyncClient):\n        \"\"\"Verify printer can be created with a fully qualified domain name.\"\"\"\n        data = {\n            \"name\": \"FQDN Printer\",\n            \"serial_number\": \"00M09A666666666\",\n            \"ip_address\": \"my-printer.home.lan\",\n            \"access_code\": \"12345678\",\n            \"model\": \"X1C\",\n        }\n\n        response = await async_client.post(\"/api/v1/printers/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"ip_address\"] == \"my-printer.home.lan\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_printer_invalid_hostname(self, async_client: AsyncClient):\n        \"\"\"Verify invalid hostnames are rejected.\"\"\"\n        data = {\n            \"name\": \"Bad Printer\",\n            \"serial_number\": \"00M09A777777777\",\n            \"ip_address\": \"-invalid\",\n            \"access_code\": \"12345678\",\n        }\n\n        response = await async_client.post(\"/api/v1/printers/\", json=data)\n\n        assert response.status_code == 422\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_printer_duplicate_serial(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify duplicate serial number is rejected.\"\"\"\n        await printer_factory(serial_number=\"00M09A222222222\")\n\n        data = {\n            \"name\": \"Duplicate Printer\",\n            \"serial_number\": \"00M09A222222222\",\n            \"ip_address\": \"192.168.1.101\",\n            \"access_code\": \"12345678\",\n        }\n\n        response = await async_client.post(\"/api/v1/printers/\", json=data)\n\n        # Should fail due to duplicate serial\n        assert response.status_code in [400, 409, 422, 500]\n\n    # ========================================================================\n    # Get single endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_printer(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify single printer can be retrieved.\"\"\"\n        printer = await printer_factory(name=\"Get Test Printer\")\n\n        response = await async_client.get(f\"/api/v1/printers/{printer.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == printer.id\n        assert result[\"name\"] == \"Get Test Printer\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_printer_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/9999\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Update endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_printer_name(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify printer name can be updated.\"\"\"\n        printer = await printer_factory(name=\"Original Name\")\n\n        response = await async_client.patch(f\"/api/v1/printers/{printer.id}\", json={\"name\": \"Updated Name\"})\n\n        assert response.status_code == 200\n        assert response.json()[\"name\"] == \"Updated Name\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_printer_active_status(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify printer active status can be updated.\"\"\"\n        printer = await printer_factory(is_active=True)\n\n        response = await async_client.patch(f\"/api/v1/printers/{printer.id}\", json={\"is_active\": False})\n\n        assert response.status_code == 200\n        assert response.json()[\"is_active\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify auto_archive setting can be updated.\"\"\"\n        printer = await printer_factory(auto_archive=True)\n\n        response = await async_client.patch(f\"/api/v1/printers/{printer.id}\", json={\"auto_archive\": False})\n\n        assert response.status_code == 200\n        assert response.json()[\"auto_archive\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_nonexistent_printer(self, async_client: AsyncClient):\n        \"\"\"Verify updating non-existent printer returns 404.\"\"\"\n        response = await async_client.patch(\"/api/v1/printers/9999\", json={\"name\": \"New Name\"})\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Delete endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_printer(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify printer can be deleted.\"\"\"\n        printer = await printer_factory()\n        printer_id = printer.id\n\n        response = await async_client.delete(f\"/api/v1/printers/{printer_id}\")\n\n        assert response.status_code == 200\n\n        # Verify deleted\n        response = await async_client.get(f\"/api/v1/printers/{printer_id}\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_nonexistent_printer(self, async_client: AsyncClient):\n        \"\"\"Verify deleting non-existent printer returns 404.\"\"\"\n        response = await async_client.delete(\"/api/v1/printers/9999\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Status endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_printer_status(\n        self, async_client: AsyncClient, printer_factory, mock_printer_manager, db_session\n    ):\n        \"\"\"Verify printer status can be retrieved.\"\"\"\n        printer = await printer_factory()\n\n        response = await async_client.get(f\"/api/v1/printers/{printer.id}/status\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"connected\" in result\n        assert \"state\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_printer_status_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for status of non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/9999/status\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Test connection endpoint\n    # ========================================================================\n\n\nclass TestPrinterDataIntegrity:\n    \"\"\"Tests for printer data integrity.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_printer_stores_all_fields(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify printer stores all fields correctly.\"\"\"\n        printer = await printer_factory(\n            name=\"Full Test Printer\",\n            serial_number=\"00M09A444444444\",\n            ip_address=\"192.168.1.150\",\n            model=\"P1S\",\n            is_active=True,\n            auto_archive=False,\n        )\n\n        response = await async_client.get(f\"/api/v1/printers/{printer.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"Full Test Printer\"\n        assert result[\"serial_number\"] == \"00M09A444444444\"\n        assert result[\"ip_address\"] == \"192.168.1.150\"\n        assert result[\"model\"] == \"P1S\"\n        assert result[\"is_active\"] is True\n        assert result[\"auto_archive\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_printer_update_persists(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"CRITICAL: Verify printer updates persist.\"\"\"\n        printer = await printer_factory(name=\"Original\", is_active=True)\n\n        # Update\n        await async_client.patch(f\"/api/v1/printers/{printer.id}\", json={\"name\": \"Updated\", \"is_active\": False})\n\n        # Verify persistence\n        response = await async_client.get(f\"/api/v1/printers/{printer.id}\")\n        result = response.json()\n        assert result[\"name\"] == \"Updated\"\n        assert result[\"is_active\"] is False\n\n    # ========================================================================\n    # Refresh status endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_refresh_status_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/refresh-status\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_refresh_status_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify 400 when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.request_status_update.return_value = False\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/refresh-status\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_refresh_status_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful refresh request.\"\"\"\n        printer = await printer_factory(name=\"Connected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.request_status_update.return_value = True\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/refresh-status\")\n\n            assert response.status_code == 200\n            assert response.json()[\"status\"] == \"refresh_requested\"\n            mock_pm.request_status_update.assert_called_once_with(printer.id)\n\n    # ========================================================================\n    # Current print user endpoint (Issue #206)\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_current_print_user_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/current-print-user\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify empty object returned when no user is tracked.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_current_print_user.return_value = None\n\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/current-print-user\")\n\n            assert response.status_code == 200\n            assert response.json() == {}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify user info is returned when tracked.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_current_print_user.return_value = {\"user_id\": 42, \"username\": \"testuser\"}\n\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/current-print-user\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"user_id\"] == 42\n            assert result[\"username\"] == \"testuser\"\n\n\nclass TestPrintControlAPI:\n    \"\"\"Integration tests for print control endpoints (stop, pause, resume).\"\"\"\n\n    # ========================================================================\n    # Stop print endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_print_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/print/stop\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_print_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/stop\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stop_print_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful stop print request.\"\"\"\n        printer = await printer_factory(name=\"Printing Printer\")\n\n        mock_client = MagicMock()\n        mock_client.stop_print.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/stop\")\n\n            assert response.status_code == 200\n            assert response.json()[\"success\"] is True\n            mock_client.stop_print.assert_called_once()\n\n    # ========================================================================\n    # Pause print endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_pause_print_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/print/pause\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_pause_print_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/pause\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_pause_print_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful pause print request.\"\"\"\n        printer = await printer_factory(name=\"Printing Printer\")\n\n        mock_client = MagicMock()\n        mock_client.pause_print.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/pause\")\n\n            assert response.status_code == 200\n            assert response.json()[\"success\"] is True\n            mock_client.pause_print.assert_called_once()\n\n    # ========================================================================\n    # Resume print endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_resume_print_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/print/resume\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_resume_print_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/resume\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_resume_print_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful resume print request.\"\"\"\n        printer = await printer_factory(name=\"Paused Printer\")\n\n        mock_client = MagicMock()\n        mock_client.resume_print.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/resume\")\n\n            assert response.status_code == 200\n            assert response.json()[\"success\"] is True\n            mock_client.resume_print.assert_called_once()\n\n\nclass TestAMSRefreshAPI:\n    \"\"\"Integration tests for AMS slot refresh endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ams_refresh_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/ams/0/slot/0/refresh\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ams_refresh_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ams_refresh_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful AMS refresh request.\"\"\"\n        printer = await printer_factory(name=\"Printer with AMS\")\n\n        mock_client = MagicMock()\n        mock_client.ams_refresh_tray.return_value = (True, \"Refreshing AMS 0 tray 1\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/ams/0/slot/1/refresh\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"success\"] is True\n            mock_client.ams_refresh_tray.assert_called_once_with(0, 1)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ams_refresh_filament_loaded(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when filament is loaded (can't refresh while loaded).\"\"\"\n        printer = await printer_factory(name=\"Printer with AMS\")\n\n        mock_client = MagicMock()\n        mock_client.ams_refresh_tray.return_value = (False, \"Please unload filament first\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/ams/0/slot/0/refresh\")\n\n            assert response.status_code == 400\n            assert \"unload\" in response.json()[\"detail\"].lower()\n\n\nclass TestConfigureAMSSlotAPI:\n    \"\"\"Integration tests for AMS slot configure endpoint — tray_info_idx resolution.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(\n                f\"/api/v1/printers/{printer.id}/slots/0/0/configure\",\n                params={\n                    \"tray_info_idx\": \"GFL99\",\n                    \"tray_type\": \"PLA\",\n                    \"tray_sub_brands\": \"PLA Basic\",\n                    \"tray_color\": \"FF0000FF\",\n                    \"nozzle_temp_min\": 190,\n                    \"nozzle_temp_max\": 230,\n                },\n            )\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Standard Bambu GF* filament IDs are sent as-is.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        mock_client.request_status_update.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = None  # No existing state\n\n            response = await async_client.post(\n                f\"/api/v1/printers/{printer.id}/slots/2/3/configure\",\n                params={\n                    \"tray_info_idx\": \"GFL05\",\n                    \"tray_type\": \"PLA\",\n                    \"tray_sub_brands\": \"PLA Basic\",\n                    \"tray_color\": \"FFFFFFFF\",\n                    \"nozzle_temp_min\": 190,\n                    \"nozzle_temp_max\": 230,\n                },\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"GFL05\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):\n        \"\"\"PFUS* cloud-synced custom preset IDs are sent to the printer.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        mock_client.request_status_update.return_value = True\n\n        mock_status = MagicMock()\n        mock_status.raw_data = {\"ams\": {\"ams\": []}}  # No existing tray data\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = mock_status\n\n            response = await async_client.post(\n                f\"/api/v1/printers/{printer.id}/slots/2/3/configure\",\n                params={\n                    \"tray_info_idx\": \"PFUS9ac902733670a9\",\n                    \"tray_type\": \"PLA\",\n                    \"tray_sub_brands\": \"Devil Design PLA\",\n                    \"tray_color\": \"FF0000FF\",\n                    \"nozzle_temp_min\": 190,\n                    \"nozzle_temp_max\": 230,\n                },\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUS9ac902733670a9\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_configure_pfus_takes_priority_over_slot(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Provided PFUS* preset takes priority over slot's existing preset.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        mock_client.request_status_update.return_value = True\n\n        # Simulate slot already configured by slicer with cloud-synced preset\n        mock_status = MagicMock()\n        mock_status.raw_data = {\n            \"ams\": {\n                \"ams\": [\n                    {\n                        \"id\": 2,\n                        \"tray\": [\n                            {\n                                \"id\": 3,\n                                \"tray_info_idx\": \"P4d64437\",\n                                \"tray_type\": \"PLA\",\n                                \"tray_color\": \"FF0000FF\",\n                            }\n                        ],\n                    }\n                ]\n            }\n        }\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = mock_status\n\n            response = await async_client.post(\n                f\"/api/v1/printers/{printer.id}/slots/2/3/configure\",\n                params={\n                    \"tray_info_idx\": \"PFUS9ac902733670a9\",\n                    \"tray_type\": \"PLA\",\n                    \"tray_sub_brands\": \"Devil Design PLA\",\n                    \"tray_color\": \"FF0000FF\",\n                    \"nozzle_temp_min\": 190,\n                    \"nozzle_temp_max\": 230,\n                },\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            # Provided preset wins over slot's existing one\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUS9ac902733670a9\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_configure_pfus_used_regardless_of_slot_material(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Provided PFUS* preset is used even when slot has a different material.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        mock_client.request_status_update.return_value = True\n\n        # Slot currently has PETG but user is configuring PLA\n        mock_status = MagicMock()\n        mock_status.raw_data = {\n            \"ams\": {\n                \"ams\": [\n                    {\n                        \"id\": 2,\n                        \"tray\": [{\"id\": 3, \"tray_info_idx\": \"GFG99\", \"tray_type\": \"PETG\", \"tray_color\": \"FFFFFFFF\"}],\n                    }\n                ]\n            }\n        }\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = mock_status\n\n            response = await async_client.post(\n                f\"/api/v1/printers/{printer.id}/slots/2/3/configure\",\n                params={\n                    \"tray_info_idx\": \"PFUS9ac902733670a9\",\n                    \"tray_type\": \"PLA\",\n                    \"tray_sub_brands\": \"Devil Design PLA\",\n                    \"tray_color\": \"FF0000FF\",\n                    \"nozzle_temp_min\": 190,\n                    \"nozzle_temp_max\": 230,\n                },\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            # Provided preset wins — slot's material is irrelevant\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUS9ac902733670a9\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Empty tray_info_idx (local preset) is replaced with generic.\"\"\"\n        printer = await printer_factory(name=\"H2D\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        mock_client.request_status_update.return_value = True\n\n        mock_status = MagicMock()\n        mock_status.raw_data = {\"ams\": {\"ams\": []}}\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = mock_status\n\n            response = await async_client.post(\n                f\"/api/v1/printers/{printer.id}/slots/2/3/configure\",\n                params={\n                    \"tray_info_idx\": \"\",\n                    \"tray_type\": \"PETG\",\n                    \"tray_sub_brands\": \"PETG Basic\",\n                    \"tray_color\": \"FFFFFFFF\",\n                    \"nozzle_temp_min\": 220,\n                    \"nozzle_temp_max\": 260,\n                },\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"GFG99\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_configure_pfus_preserves_setting_id_pair(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Both tray_info_idx=PFUS* and setting_id=PFUS* are forwarded untouched.\n\n        Pins the end-to-end contract the frontend #1053 fix relies on: when the\n        user configures a slot with a custom cloud preset whose cloud detail\n        has filament_id=null, the frontend sends the setting_id in BOTH fields\n        and the backend must not collapse either to a generic GF* ID.\n        \"\"\"\n        printer = await printer_factory(name=\"H2D\")\n\n        mock_client = MagicMock()\n        mock_client.ams_set_filament_setting.return_value = True\n        mock_client.extrusion_cali_sel.return_value = True\n        mock_client.request_status_update.return_value = True\n\n        mock_status = MagicMock()\n        mock_status.raw_data = {\"ams\": {\"ams\": []}}\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            mock_pm.get_status.return_value = mock_status\n\n            response = await async_client.post(\n                f\"/api/v1/printers/{printer.id}/slots/128/0/configure\",\n                params={\n                    \"tray_info_idx\": \"PFUSa8fb76f9733e3c\",\n                    \"tray_type\": \"ABS\",\n                    \"tray_sub_brands\": \"Sting3D ABS\",\n                    \"tray_color\": \"000000FF\",\n                    \"nozzle_temp_min\": 240,\n                    \"nozzle_temp_max\": 280,\n                    \"setting_id\": \"PFUSa8fb76f9733e3c\",\n                },\n            )\n\n            assert response.status_code == 200\n            call_kwargs = mock_client.ams_set_filament_setting.call_args\n            assert call_kwargs.kwargs[\"tray_info_idx\"] == \"PFUSa8fb76f9733e3c\"\n            assert call_kwargs.kwargs[\"setting_id\"] == \"PFUSa8fb76f9733e3c\"\n            # Explicitly assert no generic-collapse happened for this HT slot.\n            assert call_kwargs.kwargs[\"tray_info_idx\"] != \"GFB99\"\n\n\nclass TestSkipObjectsAPI:\n    \"\"\"Integration tests for skip objects endpoints.\"\"\"\n\n    # ========================================================================\n    # Get printable objects endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_objects_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.get(\"/api/v1/printers/99999/print/objects\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_objects_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/print/objects\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_objects_empty(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify empty objects list when no print is active.\"\"\"\n        printer = await printer_factory(name=\"Idle Printer\")\n\n        mock_client = MagicMock()\n        mock_client.state.printable_objects = {}\n        mock_client.state.skipped_objects = []\n        mock_client.state.state = \"IDLE\"\n        mock_client.state.subtask_name = None  # Prevent FTP download attempt\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/print/objects\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"objects\"] == []\n            assert result[\"total\"] == 0\n            assert result[\"skipped_count\"] == 0\n            assert result[\"is_printing\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_objects_with_data(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify objects list when print is active.\"\"\"\n        printer = await printer_factory(name=\"Printing Printer\")\n\n        mock_client = MagicMock()\n        mock_client.state.printable_objects = {100: \"Part A\", 200: \"Part B\", 300: \"Part C\"}\n        mock_client.state.skipped_objects = [200]\n        mock_client.state.state = \"RUNNING\"\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/print/objects\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"total\"] == 3\n            assert result[\"skipped_count\"] == 1\n            assert result[\"is_printing\"] is True\n\n            # Check objects have correct structure\n            objects_by_id = {obj[\"id\"]: obj for obj in result[\"objects\"]}\n            assert objects_by_id[100][\"name\"] == \"Part A\"\n            assert objects_by_id[100][\"skipped\"] is False\n            assert objects_by_id[200][\"name\"] == \"Part B\"\n            assert objects_by_id[200][\"skipped\"] is True\n            assert objects_by_id[300][\"name\"] == \"Part C\"\n            assert objects_by_id[300][\"skipped\"] is False\n\n    # ========================================================================\n    # Skip objects endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_objects_with_positions(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify objects list includes position data when available.\"\"\"\n        printer = await printer_factory(name=\"Printing Printer\")\n\n        # New format with position data\n        mock_client = MagicMock()\n        mock_client.state.printable_objects = {\n            100: {\"name\": \"Part A\", \"x\": 50.0, \"y\": 100.0},\n            200: {\"name\": \"Part B\", \"x\": 150.0, \"y\": 100.0},\n        }\n        mock_client.state.skipped_objects = []\n        mock_client.state.state = \"RUNNING\"\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.get(f\"/api/v1/printers/{printer.id}/print/objects\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"total\"] == 2\n\n            # Check objects have position data\n            objects_by_id = {obj[\"id\"]: obj for obj in result[\"objects\"]}\n            assert objects_by_id[100][\"name\"] == \"Part A\"\n            assert objects_by_id[100][\"x\"] == 50.0\n            assert objects_by_id[100][\"y\"] == 100.0\n            assert objects_by_id[200][\"name\"] == \"Part B\"\n            assert objects_by_id[200][\"x\"] == 150.0\n            assert objects_by_id[200][\"y\"] == 100.0\n\n    # ========================================================================\n    # Skip objects endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_skip_objects_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/print/skip-objects\", json=[100])\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_skip_objects_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/skip-objects\", json=[100])\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_skip_objects_empty_list(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when no object IDs provided.\"\"\"\n        printer = await printer_factory(name=\"Printing Printer\")\n\n        mock_client = MagicMock()\n        mock_client.state.printable_objects = {100: \"Part A\"}\n        mock_client.state.skipped_objects = []\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/skip-objects\", json=[])\n\n            assert response.status_code == 400\n            assert \"no object\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_skip_objects_invalid_id(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when object ID doesn't exist.\"\"\"\n        printer = await printer_factory(name=\"Printing Printer\")\n\n        mock_client = MagicMock()\n        mock_client.state.printable_objects = {100: \"Part A\"}\n        mock_client.state.skipped_objects = []\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/skip-objects\", json=[999])\n\n            assert response.status_code == 400\n            assert \"invalid\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_skip_objects_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful skip objects request.\"\"\"\n        printer = await printer_factory(name=\"Printing Printer\")\n\n        mock_client = MagicMock()\n        mock_client.state.printable_objects = {100: \"Part A\", 200: \"Part B\"}\n        mock_client.state.skipped_objects = []\n        mock_client.skip_objects.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/skip-objects\", json=[100])\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"success\"] is True\n            assert 100 in result[\"skipped_objects\"]\n            mock_client.skip_objects.assert_called_once_with([100])\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_skip_objects_multiple(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify skipping multiple objects at once.\"\"\"\n        printer = await printer_factory(name=\"Printing Printer\")\n\n        mock_client = MagicMock()\n        mock_client.state.printable_objects = {100: \"Part A\", 200: \"Part B\", 300: \"Part C\"}\n        mock_client.state.skipped_objects = []\n        mock_client.skip_objects.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print/skip-objects\", json=[100, 200])\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"success\"] is True\n            assert 100 in result[\"skipped_objects\"]\n            assert 200 in result[\"skipped_objects\"]\n            mock_client.skip_objects.assert_called_once_with([100, 200])\n\n\nclass TestChamberLightAPI:\n    \"\"\"Integration tests for chamber light control endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_chamber_light_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/chamber-light?on=true\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/chamber-light?on=true\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful chamber light on request.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        mock_client = MagicMock()\n        mock_client.set_chamber_light.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/chamber-light?on=true\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"success\"] is True\n            assert \"on\" in result[\"message\"].lower()\n            mock_client.set_chamber_light.assert_called_once_with(True)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful chamber light off request.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        mock_client = MagicMock()\n        mock_client.set_chamber_light.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/chamber-light?on=false\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"success\"] is True\n            assert \"off\" in result[\"message\"].lower()\n            mock_client.set_chamber_light.assert_called_once_with(False)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error handling when chamber light control fails.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        mock_client = MagicMock()\n        mock_client.set_chamber_light.return_value = False\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/chamber-light?on=true\")\n\n            assert response.status_code == 500\n            assert \"failed\" in response.json()[\"detail\"].lower()\n\n\nclass TestAirductModeAPI:\n    \"\"\"Integration tests for the airduct mode endpoint (P2S/H2*).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_invalid_mode_rejected(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"P\", model=\"P2S\")\n        response = await async_client.post(f\"/api/v1/printers/{printer.id}/airduct-mode?mode=foo\")\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_not_connected(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"P\", model=\"P2S\")\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling\")\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cooling_success(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"P\", model=\"P2S\")\n        mock_client = MagicMock()\n        mock_client.set_airduct_mode.return_value = True\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/airduct-mode?mode=cooling\")\n        assert response.status_code == 200\n        assert response.json()[\"success\"] is True\n        mock_client.set_airduct_mode.assert_called_once_with(\"cooling\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_heating_failure_returns_500(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"P\", model=\"P2S\")\n        mock_client = MagicMock()\n        mock_client.set_airduct_mode.return_value = False\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/airduct-mode?mode=heating\")\n        assert response.status_code == 500\n\n\nclass TestClearHMSErrorsAPI:\n    \"\"\"Integration tests for clear HMS errors endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_clear_hms_errors_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/hms/clear\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_clear_hms_errors_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/hms/clear\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_clear_hms_errors_success(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify successful clear HMS errors request.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        mock_client = MagicMock()\n        mock_client.clear_hms_errors.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/hms/clear\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"success\"] is True\n            assert \"cleared\" in result[\"message\"].lower()\n            mock_client.clear_hms_errors.assert_called_once()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_clear_hms_errors_failure(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error handling when clear HMS errors fails.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        mock_client = MagicMock()\n        mock_client.clear_hms_errors.return_value = False\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/hms/clear\")\n\n            assert response.status_code == 500\n            assert \"failed\" in response.json()[\"detail\"].lower()\n"
  },
  {
    "path": "backend/tests/integration/test_projects_api.py",
    "content": "\"\"\"Integration tests for Projects API endpoints.\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestProjectsAPI:\n    \"\"\"Integration tests for /api/v1/projects endpoints.\"\"\"\n\n    @pytest.fixture\n    async def project_factory(self, db_session):\n        \"\"\"Factory to create test projects.\"\"\"\n        _counter = [0]\n\n        async def _create_project(**kwargs):\n            from backend.app.models.project import Project\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Test Project {counter}\",\n                \"description\": \"Test project description\",\n                \"color\": \"#FF0000\",\n            }\n            defaults.update(kwargs)\n\n            project = Project(**defaults)\n            db_session.add(project)\n            await db_session.commit()\n            await db_session.refresh(project)\n            return project\n\n        return _create_project\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_projects_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list when no projects exist.\"\"\"\n        response = await async_client.get(\"/api/v1/projects/\")\n        assert response.status_code == 200\n        assert isinstance(response.json(), list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_projects_with_data(self, async_client: AsyncClient, project_factory, db_session):\n        \"\"\"Verify list returns existing projects.\"\"\"\n        await project_factory(name=\"My Project\")\n        response = await async_client.get(\"/api/v1/projects/\")\n        assert response.status_code == 200\n        data = response.json()\n        assert any(p[\"name\"] == \"My Project\" for p in data)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_project(self, async_client: AsyncClient):\n        \"\"\"Verify project can be created.\"\"\"\n        data = {\n            \"name\": \"New Project\",\n            \"description\": \"A new project\",\n            \"color\": \"#00FF00\",\n        }\n        response = await async_client.post(\"/api/v1/projects/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"New Project\"\n        assert result[\"color\"] == \"#00FF00\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_project(self, async_client: AsyncClient, project_factory, db_session):\n        \"\"\"Verify single project can be retrieved.\"\"\"\n        project = await project_factory(name=\"Get Test Project\")\n        response = await async_client.get(f\"/api/v1/projects/{project.id}\")\n        assert response.status_code == 200\n        assert response.json()[\"name\"] == \"Get Test Project\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_project_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent project.\"\"\"\n        response = await async_client.get(\"/api/v1/projects/9999\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_project(self, async_client: AsyncClient, project_factory, db_session):\n        \"\"\"Verify project can be updated.\"\"\"\n        project = await project_factory(name=\"Original\")\n        response = await async_client.patch(\n            f\"/api/v1/projects/{project.id}\", json={\"name\": \"Updated\", \"description\": \"Updated description\"}\n        )\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"Updated\"\n        assert result[\"description\"] == \"Updated description\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_project(self, async_client: AsyncClient, project_factory, db_session):\n        \"\"\"Verify project can be deleted.\"\"\"\n        project = await project_factory()\n        response = await async_client.delete(f\"/api/v1/projects/{project.id}\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"message\"] == \"Project deleted\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_project_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for deleting non-existent project.\"\"\"\n        response = await async_client.delete(\"/api/v1/projects/9999\")\n        assert response.status_code == 404\n\n\nclass TestProjectPartsTracking:\n    \"\"\"Tests for project parts tracking feature.\"\"\"\n\n    @pytest.fixture\n    async def project_factory(self, db_session):\n        \"\"\"Factory to create test projects.\"\"\"\n\n        async def _create_project(**kwargs):\n            from backend.app.models.project import Project\n\n            defaults = {\n                \"name\": \"Parts Test Project\",\n                \"description\": \"Test project\",\n                \"color\": \"#FF0000\",\n            }\n            defaults.update(kwargs)\n\n            project = Project(**defaults)\n            db_session.add(project)\n            await db_session.commit()\n            await db_session.refresh(project)\n            return project\n\n        return _create_project\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            defaults = {\n                \"filename\": \"test.3mf\",\n                \"file_path\": \"test/test.3mf\",\n                \"file_size\": 1000,\n                \"print_name\": \"Test Print\",\n                \"status\": \"completed\",\n                \"quantity\": 1,\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_project_with_target_parts_count(self, async_client: AsyncClient):\n        \"\"\"Verify project can be created with target_parts_count.\"\"\"\n        data = {\n            \"name\": \"Parts Project\",\n            \"target_count\": 10,  # 10 plates\n            \"target_parts_count\": 50,  # 50 parts total\n        }\n        response = await async_client.post(\"/api/v1/projects/\", json=data)\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"target_count\"] == 10\n        assert result[\"target_parts_count\"] == 50\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_project_target_parts_count(self, async_client: AsyncClient, project_factory, db_session):\n        \"\"\"Verify target_parts_count can be updated.\"\"\"\n        project = await project_factory()\n        response = await async_client.patch(\n            f\"/api/v1/projects/{project.id}\",\n            json={\"target_parts_count\": 100},\n        )\n        assert response.status_code == 200\n        assert response.json()[\"target_parts_count\"] == 100\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_project_parts_progress_calculation(\n        self, async_client: AsyncClient, project_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify parts progress is calculated from archive quantities.\"\"\"\n        # Create project with target of 20 parts\n        project = await project_factory(target_parts_count=20)\n\n        # Create archives with different quantities\n        await archive_factory(project_id=project.id, quantity=3, status=\"completed\")  # 3 parts\n        await archive_factory(project_id=project.id, quantity=5, status=\"completed\")  # 5 parts\n        await archive_factory(project_id=project.id, quantity=2, status=\"completed\")  # 2 parts\n        # Total: 10 parts completed out of 20 = 50%\n\n        response = await async_client.get(f\"/api/v1/projects/{project.id}\")\n        assert response.status_code == 200\n        data = response.json()\n\n        # Check stats\n        assert data[\"stats\"][\"completed_prints\"] == 10  # Sum of quantities\n        assert data[\"stats\"][\"parts_progress_percent\"] == 50.0  # 10/20 = 50%\n        assert data[\"stats\"][\"remaining_parts\"] == 10  # 20 - 10 = 10\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_project_list_shows_parts_count(\n        self, async_client: AsyncClient, project_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify project list returns correct completed_count (parts sum).\"\"\"\n        project = await project_factory(name=\"List Parts Project\", target_parts_count=100)\n\n        # Create archives with quantities\n        await archive_factory(project_id=project.id, quantity=4, status=\"completed\")\n        await archive_factory(project_id=project.id, quantity=6, status=\"completed\")\n        # Total: 10 parts, 2 plates\n\n        response = await async_client.get(\"/api/v1/projects/\")\n        assert response.status_code == 200\n        data = response.json()\n\n        # Find our project\n        our_project = next((p for p in data if p[\"name\"] == \"List Parts Project\"), None)\n        assert our_project is not None\n        assert our_project[\"archive_count\"] == 2  # 2 plates\n        assert our_project[\"completed_count\"] == 10  # 10 parts (sum of quantities)\n        assert our_project[\"target_parts_count\"] == 100\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_plates_vs_parts_progress(\n        self, async_client: AsyncClient, project_factory, archive_factory, db_session\n    ):\n        \"\"\"Verify plates and parts progress are calculated separately.\"\"\"\n        # Project needs 5 plates producing 25 parts total (5 parts per plate)\n        project = await project_factory(target_count=5, target_parts_count=25)\n\n        # Complete 2 plates, each with 5 parts\n        await archive_factory(project_id=project.id, quantity=5, status=\"completed\")\n        await archive_factory(project_id=project.id, quantity=5, status=\"completed\")\n        # Plates: 2/5 = 40%, Parts: 10/25 = 40%\n\n        response = await async_client.get(f\"/api/v1/projects/{project.id}\")\n        assert response.status_code == 200\n        data = response.json()\n\n        assert data[\"stats\"][\"total_archives\"] == 2  # 2 plates\n        assert data[\"stats\"][\"completed_prints\"] == 10  # 10 parts\n        assert data[\"stats\"][\"progress_percent\"] == 40.0  # plates: 2/5\n        assert data[\"stats\"][\"parts_progress_percent\"] == 40.0  # parts: 10/25\n\n\nclass TestProjectArchivedStatusNotCounted:\n    \"\"\"Tests for bug #630: archived files added to a project should not count as printed.\"\"\"\n\n    @pytest.fixture\n    async def project_factory(self, db_session):\n        \"\"\"Factory to create test projects.\"\"\"\n\n        async def _create_project(**kwargs):\n            from backend.app.models.project import Project\n\n            defaults = {\n                \"name\": \"Archived Status Test\",\n                \"description\": \"Test project\",\n                \"color\": \"#FF0000\",\n            }\n            defaults.update(kwargs)\n\n            project = Project(**defaults)\n            db_session.add(project)\n            await db_session.commit()\n            await db_session.refresh(project)\n            return project\n\n        return _create_project\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            defaults = {\n                \"filename\": \"test.3mf\",\n                \"file_path\": \"test/test.3mf\",\n                \"file_size\": 1000,\n                \"print_name\": \"Test Print\",\n                \"status\": \"completed\",\n                \"quantity\": 1,\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archived_files_not_counted_as_completed(\n        self, async_client: AsyncClient, project_factory, archive_factory, db_session\n    ):\n        \"\"\"Archived files added to a project should not count in completed_prints stats.\"\"\"\n        project = await project_factory(target_parts_count=20)\n\n        # 2 actually printed (completed), 3 just archived (not printed yet)\n        await archive_factory(project_id=project.id, quantity=2, status=\"completed\")\n        await archive_factory(project_id=project.id, quantity=3, status=\"archived\")\n        await archive_factory(project_id=project.id, quantity=5, status=\"archived\")\n\n        response = await async_client.get(f\"/api/v1/projects/{project.id}\")\n        assert response.status_code == 200\n        data = response.json()\n\n        # Only the completed archive should count\n        assert data[\"stats\"][\"completed_prints\"] == 2\n        assert data[\"stats\"][\"parts_progress_percent\"] == 10.0  # 2/20 = 10%\n        assert data[\"stats\"][\"remaining_parts\"] == 18\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archived_files_not_counted_in_project_list(\n        self, async_client: AsyncClient, project_factory, archive_factory, db_session\n    ):\n        \"\"\"Project list endpoint should not count archived files as completed.\"\"\"\n        project = await project_factory(name=\"List Archived Test\", target_parts_count=50)\n\n        await archive_factory(project_id=project.id, quantity=4, status=\"completed\")\n        await archive_factory(project_id=project.id, quantity=6, status=\"archived\")\n\n        response = await async_client.get(\"/api/v1/projects/\")\n        assert response.status_code == 200\n        data = response.json()\n\n        our_project = next((p for p in data if p[\"name\"] == \"List Archived Test\"), None)\n        assert our_project is not None\n        assert our_project[\"completed_count\"] == 4  # Only completed, not archived\n        assert our_project[\"archive_count\"] == 2  # Both archives exist as plates\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_only_completed_status_counts(\n        self, async_client: AsyncClient, project_factory, archive_factory, db_session\n    ):\n        \"\"\"Only 'completed' status should count in stats, not archived/failed/etc.\"\"\"\n        project = await project_factory(target_parts_count=100)\n\n        await archive_factory(project_id=project.id, quantity=10, status=\"completed\")\n        await archive_factory(project_id=project.id, quantity=5, status=\"archived\")\n        await archive_factory(project_id=project.id, quantity=3, status=\"failed\")\n        await archive_factory(project_id=project.id, quantity=2, status=\"aborted\")\n\n        response = await async_client.get(f\"/api/v1/projects/{project.id}\")\n        assert response.status_code == 200\n        data = response.json()\n\n        assert data[\"stats\"][\"completed_prints\"] == 10  # Only \"completed\"\n        assert data[\"stats\"][\"failed_prints\"] == 2  # failed + aborted (count of archives, not sum)\n        assert data[\"stats\"][\"total_archives\"] == 4  # All archives\n        assert data[\"stats\"][\"total_items\"] == 20  # Sum of all quantities\n\n\nclass TestProjectArchivesAPI:\n    \"\"\"Tests for project-archive relationships.\"\"\"\n\n    @pytest.fixture\n    async def project_factory(self, db_session):\n        \"\"\"Factory to create test projects.\"\"\"\n\n        async def _create_project(**kwargs):\n            from backend.app.models.project import Project\n\n            defaults = {\n                \"name\": \"Archive Test Project\",\n                \"description\": \"Test project\",\n                \"color\": \"#0000FF\",\n            }\n            defaults.update(kwargs)\n\n            project = Project(**defaults)\n            db_session.add(project)\n            await db_session.commit()\n            await db_session.refresh(project)\n            return project\n\n        return _create_project\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_project_with_archives(self, async_client: AsyncClient, project_factory, db_session):\n        \"\"\"Verify project can be retrieved with archive count.\"\"\"\n        project = await project_factory()\n        response = await async_client.get(f\"/api/v1/projects/{project.id}\")\n        assert response.status_code == 200\n        # Project should have an archive count (may be 0)\n        data = response.json()\n        assert \"name\" in data\n\n\nclass TestProjectExportImport:\n    \"\"\"Tests for project export/import functionality.\"\"\"\n\n    @pytest.fixture\n    async def project_factory(self, db_session):\n        \"\"\"Factory to create test projects.\"\"\"\n        _counter = [0]\n\n        async def _create_project(**kwargs):\n            from backend.app.models.project import Project\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Export Test Project {counter}\",\n                \"description\": \"Test project for export\",\n                \"color\": \"#00FF00\",\n            }\n            defaults.update(kwargs)\n\n            project = Project(**defaults)\n            db_session.add(project)\n            await db_session.commit()\n            await db_session.refresh(project)\n            return project\n\n        return _create_project\n\n    @pytest.fixture\n    async def bom_item_factory(self, db_session):\n        \"\"\"Factory to create test BOM items.\"\"\"\n\n        async def _create_bom_item(project_id: int, **kwargs):\n            from backend.app.models.project_bom import ProjectBOMItem\n\n            defaults = {\n                \"project_id\": project_id,\n                \"name\": \"Test Part\",\n                \"quantity_needed\": 1,\n                \"quantity_acquired\": 0,\n                \"sort_order\": 0,\n            }\n            defaults.update(kwargs)\n\n            item = ProjectBOMItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_bom_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):\n        \"\"\"Verify project export includes BOM items.\"\"\"\n        project = await project_factory(\n            name=\"Export Me\",\n            description=\"A test project\",\n            target_count=10,\n            target_parts_count=50,\n            budget=100.0,\n        )\n\n        # Add BOM items\n        await bom_item_factory(project.id, name=\"M3x8 Screws\", quantity_needed=20, unit_price=0.10)\n        await bom_item_factory(project.id, name=\"Heat Inserts\", quantity_needed=10, unit_price=0.25)\n\n        # Test JSON format export\n        response = await async_client.get(f\"/api/v1/projects/{project.id}/export?format=json\")\n        assert response.status_code == 200\n\n        data = response.json()\n        assert data[\"name\"] == \"Export Me\"\n        assert data[\"description\"] == \"A test project\"\n        assert data[\"target_count\"] == 10\n        assert data[\"target_parts_count\"] == 50\n        assert data[\"budget\"] == 100.0\n        assert len(data[\"bom_items\"]) == 2\n\n        # Check BOM items\n        bom_names = [item[\"name\"] for item in data[\"bom_items\"]]\n        assert \"M3x8 Screws\" in bom_names\n        assert \"Heat Inserts\" in bom_names\n\n        # Test ZIP format export (default)\n        zip_response = await async_client.get(f\"/api/v1/projects/{project.id}/export\")\n        assert zip_response.status_code == 200\n        assert zip_response.headers[\"content-type\"] == \"application/zip\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_import_project(self, async_client: AsyncClient):\n        \"\"\"Verify project can be imported with BOM items.\"\"\"\n        import_data = {\n            \"name\": \"Imported Project\",\n            \"description\": \"Imported from JSON\",\n            \"color\": \"#FF00FF\",\n            \"target_count\": 5,\n            \"target_parts_count\": 25,\n            \"budget\": 50.0,\n            \"bom_items\": [\n                {\n                    \"name\": \"PTFE Tubes\",\n                    \"quantity_needed\": 4,\n                    \"quantity_acquired\": 0,\n                    \"unit_price\": 2.50,\n                    \"sourcing_url\": \"https://example.com\",\n                    \"stl_filename\": None,\n                    \"remarks\": \"Need 4mm ID\",\n                },\n            ],\n        }\n\n        response = await async_client.post(\"/api/v1/projects/import\", json=import_data)\n        assert response.status_code == 200\n\n        data = response.json()\n        assert data[\"name\"] == \"Imported Project\"\n        assert data[\"description\"] == \"Imported from JSON\"\n        assert data[\"target_count\"] == 5\n        assert data[\"target_parts_count\"] == 25\n        assert data[\"budget\"] == 50.0\n        assert data[\"id\"] > 0  # Has a valid ID\n        # BOM stats should show 1 item imported\n        assert data[\"stats\"][\"bom_total_items\"] == 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):\n        \"\"\"Verify project export includes linked folders.\"\"\"\n        from backend.app.models.library import LibraryFolder\n\n        project = await project_factory(name=\"Project With Folder\")\n\n        # Create a linked folder\n        folder = LibraryFolder(name=\"Project Files\", project_id=project.id)\n        db_session.add(folder)\n        await db_session.commit()\n\n        response = await async_client.get(f\"/api/v1/projects/{project.id}/export?format=json\")\n        assert response.status_code == 200\n\n        data = response.json()\n        assert data[\"name\"] == \"Project With Folder\"\n        assert len(data[\"linked_folders\"]) == 1\n        assert data[\"linked_folders\"][0][\"name\"] == \"Project Files\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_import_project_with_linked_folder(self, async_client: AsyncClient):\n        \"\"\"Verify project import accepts linked folders data.\"\"\"\n        import_data = {\n            \"name\": \"Imported With Folders\",\n            \"linked_folders\": [\n                {\"name\": \"STL Files\"},\n                {\"name\": \"Documentation\"},\n            ],\n        }\n\n        # Import should succeed with linked_folders\n        response = await async_client.post(\"/api/v1/projects/import\", json=import_data)\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"name\"] == \"Imported With Folders\"\n        assert data[\"id\"] > 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_import_project_from_json_file(self, async_client: AsyncClient):\n        \"\"\"Verify project can be imported from JSON file upload.\"\"\"\n        import io\n        import json\n\n        project_data = {\n            \"name\": \"File Uploaded Project\",\n            \"description\": \"Imported from JSON file\",\n            \"color\": \"#123456\",\n        }\n\n        # Create a file-like object\n        file_content = json.dumps(project_data).encode()\n        files = {\"file\": (\"project.json\", io.BytesIO(file_content), \"application/json\")}\n\n        response = await async_client.post(\"/api/v1/projects/import/file\", files=files)\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"name\"] == \"File Uploaded Project\"\n        assert data[\"description\"] == \"Imported from JSON file\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_import_project_from_zip_file(self, async_client: AsyncClient):\n        \"\"\"Verify project can be imported from ZIP file with files.\"\"\"\n        import io\n        import json\n        import zipfile\n\n        project_data = {\n            \"name\": \"ZIP Imported Project\",\n            \"description\": \"Imported from ZIP\",\n            \"linked_folders\": [{\"name\": \"TestFolder\", \"files\": [{\"filename\": \"test.txt\"}]}],\n        }\n\n        # Create a ZIP file in memory\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"project.json\", json.dumps(project_data))\n            zf.writestr(\"files/TestFolder/test.txt\", \"Hello World\")\n\n        zip_buffer.seek(0)\n        files = {\"file\": (\"project.zip\", zip_buffer, \"application/zip\")}\n\n        response = await async_client.post(\"/api/v1/projects/import/file\", files=files)\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"name\"] == \"ZIP Imported Project\"\n        assert data[\"description\"] == \"Imported from ZIP\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):\n        \"\"\"Verify ZIP export contains actual files from linked folders.\"\"\"\n        import io\n        import json\n        import zipfile\n        from pathlib import Path\n\n        from backend.app.api.routes.library import get_library_dir\n        from backend.app.models.library import LibraryFile, LibraryFolder\n\n        project = await project_factory(name=\"Project With Files\")\n\n        # Create a linked folder with is_external fields\n        folder = LibraryFolder(\n            name=\"TestExportFolder\",\n            project_id=project.id,\n            is_external=False,\n            external_readonly=False,\n            external_show_hidden=False,\n        )\n        db_session.add(folder)\n        await db_session.flush()\n\n        # Create a test file on disk\n        library_dir = get_library_dir()\n        folder_path = library_dir / \"TestExportFolder\"\n        folder_path.mkdir(parents=True, exist_ok=True)\n        test_file_path = folder_path / \"test_export.txt\"\n        test_file_path.write_text(\"Export test content\")\n\n        # Create library file record\n        lib_file = LibraryFile(\n            folder_id=folder.id,\n            filename=\"test_export.txt\",\n            file_path=\"TestExportFolder/test_export.txt\",\n            file_type=\"other\",\n            file_size=19,\n            is_external=False,\n        )\n        db_session.add(lib_file)\n        await db_session.commit()\n\n        # Export as ZIP\n        response = await async_client.get(f\"/api/v1/projects/{project.id}/export\")\n        assert response.status_code == 200\n        assert response.headers[\"content-type\"] == \"application/zip\"\n\n        # Verify ZIP contents\n        zip_buffer = io.BytesIO(response.content)\n        with zipfile.ZipFile(zip_buffer, \"r\") as zf:\n            assert \"project.json\" in zf.namelist()\n            assert \"files/TestExportFolder/test_export.txt\" in zf.namelist()\n\n            # Verify file content\n            file_content = zf.read(\"files/TestExportFolder/test_export.txt\").decode()\n            assert file_content == \"Export test content\"\n\n            # Verify project.json\n            project_data = json.loads(zf.read(\"project.json\"))\n            assert project_data[\"name\"] == \"Project With Files\"\n\n        # Cleanup\n        test_file_path.unlink(missing_ok=True)\n        folder_path.rmdir()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_import_invalid_file_type(self, async_client: AsyncClient):\n        \"\"\"Verify import rejects invalid file types.\"\"\"\n        import io\n\n        files = {\"file\": (\"project.txt\", io.BytesIO(b\"invalid\"), \"text/plain\")}\n        response = await async_client.post(\"/api/v1/projects/import/file\", files=files)\n        assert response.status_code == 400\n        assert \"must be .zip or .json\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_import_zip_missing_project_json(self, async_client: AsyncClient):\n        \"\"\"Verify import rejects ZIP without project.json.\"\"\"\n        import io\n        import zipfile\n\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\") as zf:\n            zf.writestr(\"other.txt\", \"no project.json here\")\n\n        zip_buffer.seek(0)\n        files = {\"file\": (\"project.zip\", zip_buffer, \"application/zip\")}\n        response = await async_client.post(\"/api/v1/projects/import/file\", files=files)\n        assert response.status_code == 400\n        assert \"project.json\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_import_invalid_json(self, async_client: AsyncClient):\n        \"\"\"Verify import rejects invalid JSON content.\"\"\"\n        import io\n\n        files = {\"file\": (\"project.json\", io.BytesIO(b\"not valid json\"), \"application/json\")}\n        response = await async_client.post(\"/api/v1/projects/import/file\", files=files)\n        assert response.status_code == 400\n        assert \"Invalid JSON\" in response.json()[\"detail\"]\n"
  },
  {
    "path": "backend/tests/integration/test_security.py",
    "content": "\"\"\"Security tests for the 8 coverage gaps identified in the maintainer review.\n\nGap 1: encryption.py has zero tests\nGap 2: JWT revocation (revoke_jti, is_jti_revoked, _is_token_fresh) untested\nGap 3: OIDC exchange token replay untested\nGap 4: OIDC email_verified claim handling untested\nGap 5: Email OTP max-attempts invalidation untested\nGap 6: OIDC callback error redirects (SSRF protection) undertested\nGap 7: Login rate limiting untested\nGap 8: challenge_id cookie binding untested\n\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\nimport secrets\nimport time\nfrom datetime import datetime, timedelta, timezone\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport jwt as pyjwt\nimport pytest\nfrom cryptography.hazmat.primitives import serialization\nfrom cryptography.hazmat.primitives.asymmetric import rsa\nfrom httpx import AsyncClient\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.auth_ephemeral import AuthEphemeralToken\nfrom backend.app.models.user import User\n\nAUTH_SETUP_URL = \"/api/v1/auth/setup\"\nLOGIN_URL = \"/api/v1/auth/login\"\nLOGOUT_URL = \"/api/v1/auth/logout\"\nME_URL = \"/api/v1/auth/me\"\n\n\ndef _auth_header(token: str) -> dict[str, str]:\n    return {\"Authorization\": f\"Bearer {token}\"}\n\n\ndef _norm_pw(password: str) -> str:\n    \"\"\"Ensure password meets complexity requirements (I4: SetupRequest now validates).\"\"\"\n    if not any(c.isupper() for c in password):\n        password = password[0].upper() + password[1:]\n    if not any(c not in \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\" for c in password):\n        password = password + \"!\"\n    return password\n\n\nasync def _setup_and_login(client: AsyncClient, username: str, password: str) -> str:\n    password = _norm_pw(password)\n    await client.post(\n        AUTH_SETUP_URL,\n        json={\"auth_enabled\": True, \"admin_username\": username, \"admin_password\": password},\n    )\n    resp = await client.post(LOGIN_URL, json={\"username\": username, \"password\": password})\n    assert resp.status_code == 200\n    return resp.json()[\"access_token\"]\n\n\ndef _make_test_rsa_key():\n    def _b64url(n: int, length: int) -> str:\n        return base64.urlsafe_b64encode(n.to_bytes(length, \"big\")).rstrip(b\"=\").decode()\n\n    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)\n    private_pem = private_key.private_bytes(\n        serialization.Encoding.PEM,\n        serialization.PrivateFormat.TraditionalOpenSSL,\n        serialization.NoEncryption(),\n    )\n    pub_numbers = private_key.public_key().public_numbers()\n    jwks = {\n        \"keys\": [\n            {\n                \"kty\": \"RSA\",\n                \"use\": \"sig\",\n                \"alg\": \"RS256\",\n                \"kid\": \"test-kid-1\",\n                \"n\": _b64url(pub_numbers.n, 256),\n                \"e\": _b64url(pub_numbers.e, 3),\n            }\n        ]\n    }\n    return private_pem, jwks\n\n\n# ===========================================================================\n# Gap 1: encryption.py unit tests\n# ===========================================================================\n\n\nclass TestEncryption:\n    \"\"\"encrypt/decrypt round-trips, plaintext passthrough, RuntimeError on missing key.\"\"\"\n\n    def test_encrypt_decrypt_roundtrip_with_key(self):\n        from cryptography.fernet import Fernet\n\n        test_key = Fernet.generate_key().decode()\n\n        import backend.app.core.encryption as enc_mod\n\n        original = enc_mod._fernet_instance\n        original_warn = enc_mod._warn_shown\n        try:\n            enc_mod._fernet_instance = None\n            enc_mod._warn_shown = False\n            with patch.dict(\"os.environ\", {\"MFA_ENCRYPTION_KEY\": test_key}):\n                ciphertext = enc_mod.mfa_encrypt(\"my-totp-secret\")\n                assert ciphertext.startswith(\"fernet:\")\n                assert enc_mod.mfa_decrypt(ciphertext) == \"my-totp-secret\"\n        finally:\n            enc_mod._fernet_instance = original\n            enc_mod._warn_shown = original_warn\n\n    def test_plaintext_passthrough_without_key(self):\n        import backend.app.core.encryption as enc_mod\n\n        original = enc_mod._fernet_instance\n        original_warn = enc_mod._warn_shown\n        try:\n            enc_mod._fernet_instance = None\n            enc_mod._warn_shown = False\n            with patch.dict(\"os.environ\", {}, clear=True):\n                env = {k: v for k, v in __import__(\"os\").environ.items() if k != \"MFA_ENCRYPTION_KEY\"}\n                with patch.dict(\"os.environ\", env, clear=True):\n                    result = enc_mod.mfa_encrypt(\"plaintext-secret\")\n                    assert result == \"plaintext-secret\"\n                    assert enc_mod.mfa_decrypt(\"plaintext-secret\") == \"plaintext-secret\"\n        finally:\n            enc_mod._fernet_instance = original\n            enc_mod._warn_shown = original_warn\n\n    def test_decrypt_raises_runtime_error_without_key_for_encrypted_value(self):\n        import backend.app.core.encryption as enc_mod\n\n        original = enc_mod._fernet_instance\n        original_warn = enc_mod._warn_shown\n        try:\n            enc_mod._fernet_instance = None\n            enc_mod._warn_shown = False\n            # A value with the fernet: prefix but no key configured\n            env = {k: v for k, v in __import__(\"os\").environ.items() if k != \"MFA_ENCRYPTION_KEY\"}\n            with (\n                patch.dict(\"os.environ\", env, clear=True),\n                pytest.raises(RuntimeError, match=\"MFA_ENCRYPTION_KEY must be set\"),\n            ):\n                enc_mod.mfa_decrypt(\"fernet:gAAAAA-fake-ciphertext\")\n        finally:\n            enc_mod._fernet_instance = original\n            enc_mod._warn_shown = original_warn\n\n\n# ===========================================================================\n# Gap 2: JWT revocation — revoke_jti, is_jti_revoked, _is_token_fresh, /me\n# ===========================================================================\n\n\nclass TestJWTRevocation:\n    \"\"\"JWT revocation and token freshness checks.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_revoke_jti_and_is_jti_revoked(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"revoke_jti stores the JTI; is_jti_revoked returns True afterwards.\"\"\"\n        from backend.app.core.auth import is_jti_revoked, revoke_jti\n\n        test_jti = secrets.token_urlsafe(16)\n        expires = datetime.now(timezone.utc) + timedelta(hours=1)\n\n        assert not await is_jti_revoked(test_jti)\n        await revoke_jti(test_jti, expires, username=\"testuser\")\n        assert await is_jti_revoked(test_jti)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_revoke_jti_idempotent(self, async_client: AsyncClient):\n        \"\"\"Double-revocation of the same JTI should not raise.\"\"\"\n        from backend.app.core.auth import is_jti_revoked, revoke_jti\n\n        jti = secrets.token_urlsafe(16)\n        expires = datetime.now(timezone.utc) + timedelta(hours=1)\n        await revoke_jti(jti, expires)\n        await revoke_jti(jti, expires)  # must not raise\n        assert await is_jti_revoked(jti)\n\n    def test_is_token_fresh_rejects_none_iat(self):\n        \"\"\"_is_token_fresh returns False when iat is None (I1 hard cutoff).\"\"\"\n        from backend.app.core.auth import _is_token_fresh\n\n        user = MagicMock()\n        user.password_changed_at = None\n        assert _is_token_fresh(None, user) is False\n\n    def test_is_token_fresh_rejects_token_before_password_change(self):\n        \"\"\"_is_token_fresh returns False when iat predates password_changed_at.\"\"\"\n        from backend.app.core.auth import _is_token_fresh\n\n        now = datetime.now(timezone.utc)\n        user = MagicMock()\n        user.password_changed_at = now\n        old_iat = (now - timedelta(hours=1)).timestamp()\n        assert _is_token_fresh(old_iat, user) is False\n\n    def test_is_token_fresh_accepts_token_after_password_change(self):\n        \"\"\"_is_token_fresh returns True when iat is after password_changed_at.\"\"\"\n        from backend.app.core.auth import _is_token_fresh\n\n        now = datetime.now(timezone.utc)\n        user = MagicMock()\n        user.password_changed_at = now - timedelta(hours=1)\n        recent_iat = now.timestamp()\n        assert _is_token_fresh(recent_iat, user) is True\n\n    def test_is_token_fresh_returns_true_when_no_password_change(self):\n        \"\"\"_is_token_fresh returns True when password_changed_at is None (I2 migration not yet run).\"\"\"\n        from backend.app.core.auth import _is_token_fresh\n\n        user = MagicMock()\n        user.password_changed_at = None\n        assert _is_token_fresh(time.time(), user) is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_me_endpoint_rejects_token_after_logout(self, async_client: AsyncClient):\n        \"\"\"After logout, the bearer token must be rejected by /me (B1 + revocation).\"\"\"\n        token = await _setup_and_login(async_client, \"sec_logout_me\", \"sec_logout_me1\")\n\n        # Token works before logout\n        me_resp = await async_client.get(ME_URL, headers=_auth_header(token))\n        assert me_resp.status_code == 200\n\n        # Logout\n        logout_resp = await async_client.post(LOGOUT_URL, headers=_auth_header(token))\n        assert logout_resp.status_code == 200\n\n        # Token must now be rejected\n        me_after = await async_client.get(ME_URL, headers=_auth_header(token))\n        assert me_after.status_code == 401\n\n\n# ===========================================================================\n# Gap 3: OIDC exchange token replay\n# ===========================================================================\n\n\nclass TestOIDCExchangeReplay:\n    \"\"\"A single-use OIDC exchange token cannot be redeemed twice.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_exchange_token_is_single_use(self, async_client: AsyncClient, db_session: AsyncSession):\n        \"\"\"The second call to /oidc/exchange with the same token returns 401.\"\"\"\n        exchange_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AuthEphemeralToken(\n                token=exchange_token,\n                token_type=\"oidc_exchange\",\n                username=\"oidc_replay_user\",\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        # Seed the user so the exchange can resolve it\n        from backend.app.core.auth import get_password_hash\n        from backend.app.core.database import async_session, seed_default_groups\n\n        async with async_session() as db:\n            result = await db.execute(__import__(\"sqlalchemy\").select(User).where(User.username == \"oidc_replay_user\"))\n            if result.scalar_one_or_none() is None:\n                db.add(\n                    User(\n                        username=\"oidc_replay_user\",\n                        password_hash=get_password_hash(\"pw\"),\n                        is_active=True,\n                    )\n                )\n                await db.commit()\n\n        first = await async_client.post(\"/api/v1/auth/oidc/exchange\", json={\"oidc_token\": exchange_token})\n        assert first.status_code == 200\n\n        second = await async_client.post(\"/api/v1/auth/oidc/exchange\", json={\"oidc_token\": exchange_token})\n        assert second.status_code == 401\n\n\n# ===========================================================================\n# Gap 4: OIDC email_verified claim handling\n# ===========================================================================\n\n\nclass TestOIDCEmailVerified:\n    \"\"\"email_verified: False/absent must not link OIDC identity to an existing email.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_unverified_email_does_not_link_to_existing_user(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        \"\"\"If email_verified is False, the OIDC callback must not auto-link by email.\"\"\"\n        private_pem, jwks_data = _make_test_rsa_key()\n        issuer = \"https://idp.evtest.example.com\"\n        client_id = \"ev-client\"\n        nonce = secrets.token_urlsafe(16)\n        now = int(time.time())\n\n        id_token = pyjwt.encode(\n            {\n                \"sub\": \"ev-sub-new\",\n                \"iss\": issuer,\n                \"aud\": client_id,\n                \"nonce\": nonce,\n                \"email\": \"existing@example.com\",\n                \"email_verified\": False,  # <-- must be ignored\n                \"iat\": now,\n                \"exp\": now + 300,\n            },\n            private_pem,\n            algorithm=\"RS256\",\n            headers={\"kid\": \"test-kid-1\"},\n        )\n\n        admin_token = await _setup_and_login(async_client, \"ev_admin\", \"ev_admin1\")\n\n        # Create existing user with the same email (use strong password for validator)\n        create_user_resp = await async_client.post(\n            \"/api/v1/users\",\n            json={\"username\": \"existing_email_user\", \"password\": \"Str0ng!Pass\", \"email\": \"existing@example.com\"},\n            headers=_auth_header(admin_token),\n        )\n        assert create_user_resp.status_code in (200, 201), create_user_resp.json()\n\n        # Create OIDC provider\n        create_resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"EV-IdP\",\n                \"issuer_url\": issuer,\n                \"client_id\": client_id,\n                \"client_secret\": \"secret\",\n                \"scopes\": \"openid email\",\n                \"is_enabled\": True,\n                \"auto_create_users\": True,\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n        provider_id = create_resp.json()[\"id\"]\n\n        state = secrets.token_urlsafe(32)\n        code_verifier = secrets.token_urlsafe(48)\n        db_session.add(\n            AuthEphemeralToken(\n                token=state,\n                token_type=\"oidc_state\",\n                provider_id=provider_id,\n                nonce=nonce,\n                code_verifier=code_verifier,\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),\n            )\n        )\n        await db_session.commit()\n\n        discovery_doc = {\n            \"issuer\": issuer,\n            \"authorization_endpoint\": f\"{issuer}/auth\",\n            \"token_endpoint\": f\"{issuer}/token\",\n            \"jwks_uri\": f\"{issuer}/.well-known/jwks.json\",\n        }\n\n        class _MockResp:\n            def __init__(self, data):\n                self._data = data\n                self.status_code = 200\n                self.is_success = True\n                self.text = str(data)\n\n            def json(self):\n                return self._data\n\n            def raise_for_status(self):\n                pass\n\n        class _MockHttpxClientEV:\n            def __init__(self, *args, **kwargs):\n                pass\n\n            async def __aenter__(self):\n                return self\n\n            async def __aexit__(self, *_):\n                pass\n\n            async def get(self, url, **kwargs):\n                if \"jwks\" in url:\n                    return _MockResp(jwks_data)\n                return _MockResp(discovery_doc)\n\n            async def post(self, url, **kwargs):\n                return _MockResp({\"access_token\": \"mock\", \"token_type\": \"Bearer\", \"id_token\": id_token})\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\", _MockHttpxClientEV):\n            await async_client.get(\n                f\"/api/v1/auth/oidc/callback?code=test-code&state={state}\",\n                follow_redirects=False,\n            )\n\n        # Callback must NOT link to the existing_email_user — a new user is created\n        # instead (because the email claim was ignored due to email_verified=False).\n        # Either a new user is provisioned (redirect with oidc_token) or the callback\n        # fails.  In either case, the existing user must not have an OIDC link.\n        from sqlalchemy import select as sa_select\n\n        from backend.app.models.oidc_provider import UserOIDCLink\n\n        link_result = await db_session.execute(\n            sa_select(UserOIDCLink)\n            .join(User, UserOIDCLink.user_id == User.id)\n            .where(User.email == \"existing@example.com\")\n        )\n        link = link_result.scalar_one_or_none()\n        assert link is None, \"Existing user must not be auto-linked when email_verified is False\"\n\n\n# ===========================================================================\n# Gap 5: Email OTP max-attempts invalidation\n# ===========================================================================\n\n\nclass TestEmailOTPMaxAttempts:\n    \"\"\"After MAX_ATTEMPTS wrong codes, the OTP is permanently invalidated.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_email_otp_invalidated_after_max_attempts(self, async_client: AsyncClient, db_session: AsyncSession):\n        from passlib.context import CryptContext\n        from sqlalchemy import select as sa_select\n\n        from backend.app.models.user_otp_code import UserOTPCode\n\n        _pwd_ctx = CryptContext(schemes=[\"pbkdf2_sha256\"], deprecated=\"auto\")\n\n        admin_token = await _setup_and_login(async_client, \"otp_max_admin\", \"otp_max_admin1\")\n\n        # Enable email OTP for admin user\n        result = await db_session.execute(sa_select(User).where(User.username == \"otp_max_admin\"))\n        user = result.scalar_one()\n        user.email = \"otpmax@example.com\"\n        await db_session.commit()\n\n        setup_code = \"123456\"\n        from backend.app.models.auth_ephemeral import AuthEphemeralToken as AET\n\n        setup_token = secrets.token_urlsafe(32)\n        db_session.add(\n            AET(\n                token=setup_token,\n                token_type=\"email_otp_setup\",\n                username=\"otp_max_admin\",\n                nonce=_pwd_ctx.hash(setup_code),\n                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n            )\n        )\n        await db_session.commit()\n        await async_client.post(\n            \"/api/v1/auth/2fa/email/enable/confirm\",\n            json={\"setup_token\": setup_token, \"code\": setup_code},\n            headers=_auth_header(admin_token),\n        )\n\n        # Login to get pre_auth_token\n        login_resp = await async_client.post(\n            LOGIN_URL, json={\"username\": \"otp_max_admin\", \"password\": \"Otp_max_admin1\"}\n        )\n        pre_auth_token = login_resp.json()[\"pre_auth_token\"]\n\n        # Insert an OTP record directly (bypassing SMTP)\n        real_code = \"654321\"\n        otp = UserOTPCode(\n            user_id=user.id,\n            code_hash=_pwd_ctx.hash(real_code),\n            attempts=0,\n            used=False,\n            expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),\n        )\n        db_session.add(otp)\n        await db_session.commit()\n\n        # Submit MAX_ATTEMPTS wrong codes\n        from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS\n\n        for _ in range(MAX_2FA_ATTEMPTS):\n            r = await async_client.post(\n                \"/api/v1/auth/2fa/verify\",\n                json={\"pre_auth_token\": pre_auth_token, \"code\": \"000000\", \"method\": \"email\"},\n            )\n            # Each attempt must fail with 401\n            assert r.status_code == 401\n\n        # After max attempts, the correct code is also rejected (either OTP\n        # invalidated → 401, or rate limit hit → 429). Either means locked out.\n        final = await async_client.post(\n            \"/api/v1/auth/2fa/verify\",\n            json={\"pre_auth_token\": pre_auth_token, \"code\": real_code, \"method\": \"email\"},\n        )\n        assert final.status_code in (401, 429), f\"Expected lockout, got {final.status_code}: {final.json()}\"\n\n\n# ===========================================================================\n# Gap 6: OIDC callback SSRF protection — invalid authorization_endpoint scheme\n# ===========================================================================\n\n\nclass TestOIDCSSRFProtection:\n    \"\"\"authorization_endpoint with non-http(s) scheme must be rejected.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_invalid_authorization_endpoint_scheme_rejected(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        issuer = \"https://idp.ssrf.example.com\"\n        client_id = \"ssrf-client\"\n\n        admin_token = await _setup_and_login(async_client, \"ssrf_admin\", \"ssrf_admin1\")\n        create_resp = await async_client.post(\n            \"/api/v1/auth/oidc/providers\",\n            json={\n                \"name\": \"SSRF-IdP\",\n                \"issuer_url\": issuer,\n                \"client_id\": client_id,\n                \"client_secret\": \"secret\",\n                \"scopes\": \"openid\",\n                \"is_enabled\": True,\n                \"auto_create_users\": False,\n            },\n            headers=_auth_header(admin_token),\n        )\n        assert create_resp.status_code == 201\n        provider_id = create_resp.json()[\"id\"]\n\n        # Discovery doc returns a javascript: authorization_endpoint\n        malicious_discovery = {\n            \"issuer\": issuer,\n            \"authorization_endpoint\": \"javascript:alert(1)\",  # <-- malicious\n            \"token_endpoint\": f\"{issuer}/token\",\n            \"jwks_uri\": f\"{issuer}/.well-known/jwks.json\",\n        }\n\n        class _MockResp:\n            def __init__(self, data):\n                self._data = data\n                self.status_code = 200\n                self.is_success = True\n                self.text = str(data)\n\n            def json(self):\n                return self._data\n\n            def raise_for_status(self):\n                pass\n\n        class _MockHttpxClientSSRF:\n            def __init__(self, *args, **kwargs):\n                pass\n\n            async def __aenter__(self):\n                return self\n\n            async def __aexit__(self, *_):\n                pass\n\n            async def get(self, url, **kwargs):\n                return _MockResp(malicious_discovery)\n\n            async def post(self, url, **kwargs):\n                return _MockResp({})\n\n        with patch(\"backend.app.api.routes.mfa.httpx.AsyncClient\", _MockHttpxClientSSRF):\n            # oidc_authorize uses a path parameter, not query param\n            authorize_resp = await async_client.get(\n                f\"/api/v1/auth/oidc/authorize/{provider_id}\",\n                follow_redirects=False,\n            )\n\n        # Must be rejected with 502 — B2 guard rejects invalid authorization_endpoint scheme\n        assert authorize_resp.status_code == 502, authorize_resp.json()\n        detail = authorize_resp.json().get(\"detail\", \"\").lower()\n        assert \"authorization_endpoint\" in detail or \"invalid\" in detail\n\n\n# ===========================================================================\n# Gap 7: Login rate limiting\n# ===========================================================================\n\n\nclass TestLoginRateLimiting:\n    \"\"\"10+ failed logins for the same username must return 429.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_excessive_failed_logins_return_429(self, async_client: AsyncClient):\n        from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS\n\n        # Setup auth but do NOT log in\n        await async_client.post(\n            AUTH_SETUP_URL,\n            json={\"auth_enabled\": True, \"admin_username\": \"ratelimit_user\", \"admin_password\": \"Ratelimit_pw1\"},\n        )\n\n        status_codes = []\n        for _ in range(MAX_LOGIN_ATTEMPTS + 2):\n            resp = await async_client.post(\n                LOGIN_URL,\n                json={\"username\": \"ratelimit_user\", \"password\": \"wrong_password\"},\n            )\n            status_codes.append(resp.status_code)\n\n        # The last attempts must be 429 (Too Many Requests)\n        assert status_codes[-1] == 429, f\"Expected 429 after {MAX_LOGIN_ATTEMPTS} failures, got: {status_codes}\"\n\n\n# ===========================================================================\n# Gap 8: challenge_id cookie binding\n# ===========================================================================\n\n\nclass TestChallengeIdCookieBinding:\n    \"\"\"A pre-auth token stolen from session A cannot be used from session B.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_pre_auth_token_rejected_without_matching_cookie(\n        self, async_client: AsyncClient, db_session: AsyncSession\n    ):\n        import pyotp\n        from passlib.context import CryptContext\n\n        _pwd_ctx = CryptContext(schemes=[\"pbkdf2_sha256\"], deprecated=\"auto\")\n\n        # Set up user with TOTP\n        await _setup_and_login(async_client, \"cookie_bind_user\", \"cookie_bind_pw1\")\n\n        secret = pyotp.random_base32()\n        totp_obj = pyotp.TOTP(secret)\n        from sqlalchemy import select as sa_select\n\n        from backend.app.models.user_totp import UserTOTP\n\n        result = await db_session.execute(sa_select(User).where(User.username == \"cookie_bind_user\"))\n        user = result.scalar_one()\n        db_session.add(UserTOTP(user_id=user.id, secret=secret, is_enabled=True))\n        await db_session.commit()\n\n        # Login from \"session A\" — gets a pre_auth_token and a 2fa_challenge cookie\n        login_resp = await async_client.post(\n            LOGIN_URL, json={\"username\": \"cookie_bind_user\", \"password\": \"Cookie_bind_pw1\"}\n        )\n        assert login_resp.status_code == 200\n        assert login_resp.json()[\"requires_2fa\"] is True\n        pre_auth_token = login_resp.json()[\"pre_auth_token\"]\n        # The async_client jar now holds the 2fa_challenge cookie for session A\n\n        # Simulate session B by creating a new client WITHOUT the cookie\n        from httpx import ASGITransport, AsyncClient as FreshClient\n\n        from backend.app.main import app\n\n        async with FreshClient(transport=ASGITransport(app=app), base_url=\"http://test\") as session_b:\n            # Attempt to use session A's pre_auth_token from session B (no cookie)\n            verify_resp = await session_b.post(\n                \"/api/v1/auth/2fa/verify\",\n                json={\n                    \"pre_auth_token\": pre_auth_token,\n                    \"code\": totp_obj.now(),\n                    \"method\": \"totp\",\n                },\n            )\n            # Must be rejected — pre_auth_token is bound to session A's cookie\n            assert verify_resp.status_code == 401, (\n                f\"Expected 401 for token replay from cookieless session, got {verify_resp.status_code}: \"\n                f\"{verify_resp.json()}\"\n            )\n\n\n# ===========================================================================\n# C2: Security-header middleware\n# ===========================================================================\n\n\nclass TestSecurityHeaders:\n    \"\"\"Every HTTP response must include standard security headers (C2).\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_security_headers_present(self, async_client: AsyncClient):\n        \"\"\"GET /api/v1/auth/me (unauthenticated → 401) still carries security headers.\"\"\"\n        resp = await async_client.get(ME_URL)\n        assert resp.status_code == 401  # sanity — no auth token\n\n        assert resp.headers.get(\"x-content-type-options\") == \"nosniff\"\n        assert resp.headers.get(\"x-frame-options\") == \"SAMEORIGIN\"\n        assert resp.headers.get(\"referrer-policy\") == \"strict-origin-when-cross-origin\"\n\n        csp = resp.headers.get(\"content-security-policy\", \"\")\n        assert \"default-src 'self'\" in csp\n        assert \"script-src 'self'\" in csp\n        assert \"frame-ancestors 'none'\" in csp\n        assert \"object-src 'none'\" in csp\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_hsts_absent_for_http(self, async_client: AsyncClient):\n        \"\"\"HSTS must NOT be set over plain HTTP (test transport uses http).\"\"\"\n        resp = await async_client.get(ME_URL)\n        assert \"strict-transport-security\" not in resp.headers\n\n\n# ===========================================================================\n# I3: Rate-limit bucket interaction — IP spray vs. username spray\n# ===========================================================================\n\n\nclass TestRateLimitBuckets:\n    \"\"\"IP-spray and username-spray must each trip the correct independent bucket.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ip_spray_trips_ip_bucket(self, async_client: AsyncClient):\n        \"\"\"20 failed logins from one IP across 20 different usernames trips the IP bucket.\n\n        Each per-username bucket only has 1 failure (well below MAX_LOGIN_ATTEMPTS=10),\n        so the username bucket is never the reason for the 429.\n        \"\"\"\n        from unittest.mock import patch as _patch\n\n        unique_ip = \"10.99.1.1\"\n\n        # Ensure auth is enabled\n        await async_client.post(\n            AUTH_SETUP_URL,\n            json={\"auth_enabled\": True, \"admin_username\": \"spray_ip_admin\", \"admin_password\": \"SprayIp_admin1\"},\n        )\n\n        status_codes: list[int] = []\n        with _patch(\"backend.app.api.routes.auth._get_client_ip\", return_value=unique_ip):\n            for i in range(22):\n                resp = await async_client.post(\n                    LOGIN_URL,\n                    json={\"username\": f\"spray_ip_victim_{i}\", \"password\": \"wrong\"},\n                )\n                status_codes.append(resp.status_code)\n\n        # The first 20 attempts fail with 401; the 21st+ must be 429 (IP bucket full)\n        assert status_codes[-1] == 429, f\"Expected 429 after 20 IP-spray failures, got: {status_codes}\"\n        # No single username saw more than one attempt → username buckets not tripped\n        non_429 = [c for c in status_codes[:-2] if c == 429]\n        assert not non_429, f\"Username bucket triggered early: {status_codes}\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_username_spray_trips_username_bucket(self, async_client: AsyncClient):\n        \"\"\"One username targeted from 10+ different IPs trips the username bucket.\n\n        Each per-IP bucket only sees 1 failure, so no IP bucket is tripped.\n        The username bucket (max 10) is what fires the 429.\n        \"\"\"\n        from unittest.mock import patch as _patch\n\n        from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS\n\n        # Ensure auth is enabled\n        await async_client.post(\n            AUTH_SETUP_URL,\n            json={\n                \"auth_enabled\": True,\n                \"admin_username\": \"spray_uname_admin\",\n                \"admin_password\": \"SprayUname_admin1\",\n            },\n        )\n\n        target_username = \"spray_uname_victim\"\n        status_codes: list[int] = []\n        for i in range(MAX_LOGIN_ATTEMPTS + 2):\n            rotating_ip = f\"10.99.2.{i + 1}\"\n            with _patch(\"backend.app.api.routes.auth._get_client_ip\", return_value=rotating_ip):\n                resp = await async_client.post(\n                    LOGIN_URL,\n                    json={\"username\": target_username, \"password\": \"wrong\"},\n                )\n                status_codes.append(resp.status_code)\n\n        # After MAX_LOGIN_ATTEMPTS failures for same username the bucket fires\n        assert status_codes[-1] == 429, (\n            f\"Expected 429 after {MAX_LOGIN_ATTEMPTS} username-spray failures, got: {status_codes}\"\n        )\n"
  },
  {
    "path": "backend/tests/integration/test_settings_api.py",
    "content": "\"\"\"Integration tests for Settings API endpoints.\n\nTests the full request/response cycle for /api/v1/settings/ endpoints.\n\"\"\"\n\nimport os\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestSettingsAPI:\n    \"\"\"Integration tests for /api/v1/settings/ endpoints.\"\"\"\n\n    # ========================================================================\n    # Get settings\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_settings(self, async_client: AsyncClient):\n        \"\"\"Verify settings can be retrieved.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/\")\n\n        assert response.status_code == 200\n        result = response.json()\n        # Check for actual settings fields\n        assert \"auto_archive\" in result\n        assert \"currency\" in result\n        assert \"date_format\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_settings_has_defaults(self, async_client: AsyncClient):\n        \"\"\"Verify default settings values are returned.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/\")\n\n        assert response.status_code == 200\n        result = response.json()\n        # Verify some default values\n        assert isinstance(result[\"auto_archive\"], bool)\n        assert isinstance(result[\"currency\"], str)\n\n    # ========================================================================\n    # Update settings\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_auto_archive(self, async_client: AsyncClient):\n        \"\"\"Verify auto_archive can be updated.\"\"\"\n        # First get current value\n        response = await async_client.get(\"/api/v1/settings/\")\n        original = response.json()[\"auto_archive\"]\n\n        # Update to opposite value\n        new_value = not original\n        response = await async_client.put(\"/api/v1/settings/\", json={\"auto_archive\": new_value})\n\n        assert response.status_code == 200\n        assert response.json()[\"auto_archive\"] == new_value\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_currency(self, async_client: AsyncClient):\n        \"\"\"Verify currency can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"currency\": \"EUR\"})\n\n        assert response.status_code == 200\n        assert response.json()[\"currency\"] == \"EUR\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_date_format(self, async_client: AsyncClient):\n        \"\"\"Verify date format can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"date_format\": \"eu\"})\n\n        assert response.status_code == 200\n        assert response.json()[\"date_format\"] == \"eu\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_time_format(self, async_client: AsyncClient):\n        \"\"\"Verify time format can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"time_format\": \"24h\"})\n\n        assert response.status_code == 200\n        assert response.json()[\"time_format\"] == \"24h\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_filament_cost(self, async_client: AsyncClient):\n        \"\"\"Verify default filament cost can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"default_filament_cost\": 30.0})\n\n        assert response.status_code == 200\n        assert response.json()[\"default_filament_cost\"] == 30.0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_energy_cost(self, async_client: AsyncClient):\n        \"\"\"Verify energy cost can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"energy_cost_per_kwh\": 0.20})\n\n        assert response.status_code == 200\n        assert response.json()[\"energy_cost_per_kwh\"] == 0.20\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_multiple_settings(self, async_client: AsyncClient):\n        \"\"\"Verify multiple settings can be updated at once.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"currency\": \"GBP\",\n                \"date_format\": \"iso\",\n                \"time_format\": \"12h\",\n                \"save_thumbnails\": False,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"currency\"] == \"GBP\"\n        assert result[\"date_format\"] == \"iso\"\n        assert result[\"time_format\"] == \"12h\"\n        assert result[\"save_thumbnails\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_spoolman_settings(self, async_client: AsyncClient):\n        \"\"\"Verify Spoolman settings can be updated.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"spoolman_enabled\": True,\n                \"spoolman_url\": \"http://localhost:7912\",\n                \"spoolman_sync_mode\": \"manual\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"spoolman_enabled\"] is True\n        assert result[\"spoolman_url\"] == \"http://localhost:7912\"\n        assert result[\"spoolman_sync_mode\"] == \"manual\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_ams_thresholds(self, async_client: AsyncClient):\n        \"\"\"Verify AMS threshold settings can be updated.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"ams_humidity_good\": 35,\n                \"ams_humidity_fair\": 55,\n                \"ams_temp_good\": 25.0,\n                \"ams_temp_fair\": 32.0,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"ams_humidity_good\"] == 35\n        assert result[\"ams_humidity_fair\"] == 55\n        assert result[\"ams_temp_good\"] == 25.0\n        assert result[\"ams_temp_fair\"] == 32.0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_low_stock_threshold(self, async_client: AsyncClient):\n        \"\"\"Verify low stock threshold setting can be updated.\"\"\"\n        # Get default value\n        response = await async_client.get(\"/api/v1/settings/\")\n        assert response.status_code == 200\n        assert response.json()[\"low_stock_threshold\"] == 20.0\n\n        # Update to custom value\n        response = await async_client.put(\"/api/v1/settings/\", json={\"low_stock_threshold\": 15.5})\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"low_stock_threshold\"] == 15.5\n\n        # Verify persistence\n        response = await async_client.get(\"/api/v1/settings/\")\n        assert response.status_code == 200\n        assert response.json()[\"low_stock_threshold\"] == 15.5\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_notification_language(self, async_client: AsyncClient):\n        \"\"\"Verify notification language can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"notification_language\": \"de\"})\n\n        assert response.status_code == 200\n        assert response.json()[\"notification_language\"] == \"de\"\n\n    # ========================================================================\n    # Settings persistence tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_theme_settings(self, async_client: AsyncClient):\n        \"\"\"Verify theme settings can be updated.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"dark_style\": \"glow\",\n                \"dark_background\": \"forest\",\n                \"dark_accent\": \"teal\",\n                \"light_style\": \"vibrant\",\n                \"light_background\": \"warm\",\n                \"light_accent\": \"blue\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"dark_style\"] == \"glow\"\n        assert result[\"dark_background\"] == \"forest\"\n        assert result[\"dark_accent\"] == \"teal\"\n        assert result[\"light_style\"] == \"vibrant\"\n        assert result[\"light_background\"] == \"warm\"\n        assert result[\"light_accent\"] == \"blue\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_settings_persist_after_update(self, async_client: AsyncClient):\n        \"\"\"CRITICAL: Verify settings changes persist across requests.\"\"\"\n        # Update settings\n        await async_client.put(\"/api/v1/settings/\", json={\"currency\": \"JPY\", \"check_updates\": False})\n\n        # Verify persistence in new request\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n        assert result[\"currency\"] == \"JPY\"\n        assert result[\"check_updates\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_check_printer_firmware(self, async_client: AsyncClient):\n        \"\"\"Verify check_printer_firmware can be updated.\"\"\"\n        # Default should be True\n        response = await async_client.get(\"/api/v1/settings/\")\n        assert response.json()[\"check_printer_firmware\"] is True\n\n        # Update to False\n        response = await async_client.put(\"/api/v1/settings/\", json={\"check_printer_firmware\": False})\n        assert response.status_code == 200\n        assert response.json()[\"check_printer_firmware\"] is False\n\n        # Verify persistence\n        response = await async_client.get(\"/api/v1/settings/\")\n        assert response.json()[\"check_printer_firmware\"] is False\n\n        # Update back to True\n        response = await async_client.put(\"/api/v1/settings/\", json={\"check_printer_firmware\": True})\n        assert response.status_code == 200\n        assert response.json()[\"check_printer_firmware\"] is True\n\n    # ========================================================================\n    # MQTT settings tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_mqtt_settings(self, async_client: AsyncClient):\n        \"\"\"Verify MQTT settings can be updated.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"mqtt_enabled\": True,\n                \"mqtt_broker\": \"mqtt.example.com\",\n                \"mqtt_port\": 8883,\n                \"mqtt_username\": \"testuser\",\n                \"mqtt_password\": \"testpass\",\n                \"mqtt_topic_prefix\": \"myprefix\",\n                \"mqtt_use_tls\": True,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mqtt_enabled\"] is True\n        assert result[\"mqtt_broker\"] == \"mqtt.example.com\"\n        assert result[\"mqtt_port\"] == 8883\n        assert result[\"mqtt_username\"] == \"testuser\"\n        assert result[\"mqtt_password\"] == \"testpass\"\n        assert result[\"mqtt_topic_prefix\"] == \"myprefix\"\n        assert result[\"mqtt_use_tls\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_mqtt_status_endpoint(self, async_client: AsyncClient):\n        \"\"\"Verify MQTT status endpoint returns expected fields.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/mqtt/status\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"enabled\" in result\n        assert \"connected\" in result\n        assert \"broker\" in result\n        assert \"port\" in result\n        assert \"topic_prefix\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_mqtt_defaults(self, async_client: AsyncClient):\n        \"\"\"Verify MQTT has correct default values.\"\"\"\n        # Reset MQTT settings to defaults\n        await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"mqtt_enabled\": False,\n                \"mqtt_broker\": \"\",\n                \"mqtt_port\": 1883,\n                \"mqtt_username\": \"\",\n                \"mqtt_password\": \"\",\n                \"mqtt_topic_prefix\": \"bambuddy\",\n                \"mqtt_use_tls\": False,\n            },\n        )\n\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n\n        assert result[\"mqtt_enabled\"] is False\n        assert result[\"mqtt_port\"] == 1883\n        assert result[\"mqtt_topic_prefix\"] == \"bambuddy\"\n        assert result[\"mqtt_use_tls\"] is False\n\n    # ========================================================================\n    # Camera settings tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_camera_view_mode(self, async_client: AsyncClient):\n        \"\"\"Verify camera view mode can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"camera_view_mode\": \"embedded\"})\n\n        assert response.status_code == 200\n        assert response.json()[\"camera_view_mode\"] == \"embedded\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_view_mode_persists(self, async_client: AsyncClient):\n        \"\"\"CRITICAL: Verify camera view mode persists after update.\"\"\"\n        # Update to embedded\n        await async_client.put(\"/api/v1/settings/\", json={\"camera_view_mode\": \"embedded\"})\n\n        # Verify persistence in new request\n        response = await async_client.get(\"/api/v1/settings/\")\n        assert response.json()[\"camera_view_mode\"] == \"embedded\"\n\n        # Update back to window\n        await async_client.put(\"/api/v1/settings/\", json={\"camera_view_mode\": \"window\"})\n\n        # Verify persistence\n        response = await async_client.get(\"/api/v1/settings/\")\n        assert response.json()[\"camera_view_mode\"] == \"window\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_camera_view_mode_default(self, async_client: AsyncClient):\n        \"\"\"Verify camera view mode has correct default value.\"\"\"\n        # Reset by requesting settings (default should be 'window')\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n\n        assert \"camera_view_mode\" in result\n        # Default is 'window' as defined in schema\n        assert result[\"camera_view_mode\"] in [\"window\", \"embedded\"]\n\n    # ========================================================================\n    # Per-printer mapping settings tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_per_printer_mapping_expanded(self, async_client: AsyncClient):\n        \"\"\"Verify per_printer_mapping_expanded can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"per_printer_mapping_expanded\": True})\n\n        assert response.status_code == 200\n        assert response.json()[\"per_printer_mapping_expanded\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_per_printer_mapping_expanded_persists(self, async_client: AsyncClient):\n        \"\"\"CRITICAL: Verify per_printer_mapping_expanded persists after update.\"\"\"\n        # Update to True\n        await async_client.put(\"/api/v1/settings/\", json={\"per_printer_mapping_expanded\": True})\n\n        # Verify persistence in new request\n        response = await async_client.get(\"/api/v1/settings/\")\n        assert response.json()[\"per_printer_mapping_expanded\"] is True\n\n        # Update back to False\n        await async_client.put(\"/api/v1/settings/\", json={\"per_printer_mapping_expanded\": False})\n\n        # Verify persistence\n        response = await async_client.get(\"/api/v1/settings/\")\n        assert response.json()[\"per_printer_mapping_expanded\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_per_printer_mapping_expanded_default(self, async_client: AsyncClient):\n        \"\"\"Verify per_printer_mapping_expanded has correct default value.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n\n        assert \"per_printer_mapping_expanded\" in result\n        # Default is False as defined in schema\n        assert isinstance(result[\"per_printer_mapping_expanded\"], bool)\n\n    # ========================================================================\n    # Stagger settings tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stagger_settings_defaults(self, async_client: AsyncClient):\n        \"\"\"Verify stagger settings have correct defaults.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n\n        assert result[\"stagger_group_size\"] == 2\n        assert result[\"stagger_interval_minutes\"] == 5\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_stagger_settings(self, async_client: AsyncClient):\n        \"\"\"Verify stagger settings can be updated.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\"stagger_group_size\": 3, \"stagger_interval_minutes\": 10},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"stagger_group_size\"] == 3\n        assert result[\"stagger_interval_minutes\"] == 10\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stagger_settings_persist(self, async_client: AsyncClient):\n        \"\"\"Verify stagger settings persist after update.\"\"\"\n        await async_client.put(\n            \"/api/v1/settings/\",\n            json={\"stagger_group_size\": 4, \"stagger_interval_minutes\": 15},\n        )\n\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n        assert result[\"stagger_group_size\"] == 4\n        assert result[\"stagger_interval_minutes\"] == 15\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_stagger_settings_validation(self, async_client: AsyncClient):\n        \"\"\"Verify stagger settings reject out-of-range values.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/\", json={\"stagger_group_size\": 0})\n        assert response.status_code == 422\n\n        response = await async_client.put(\"/api/v1/settings/\", json={\"stagger_group_size\": 51})\n        assert response.status_code == 422\n\n        response = await async_client.put(\"/api/v1/settings/\", json={\"stagger_interval_minutes\": 0})\n        assert response.status_code == 422\n\n        response = await async_client.put(\"/api/v1/settings/\", json={\"stagger_interval_minutes\": 61})\n        assert response.status_code == 422\n\n    # ========================================================================\n    # Default print options tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_default_print_options_defaults(self, async_client: AsyncClient):\n        \"\"\"Verify default print options have correct defaults.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n\n        assert result[\"default_bed_levelling\"] is True\n        assert result[\"default_flow_cali\"] is False\n        assert result[\"default_vibration_cali\"] is True\n        assert result[\"default_layer_inspect\"] is False\n        assert result[\"default_timelapse\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_default_print_options(self, async_client: AsyncClient):\n        \"\"\"Verify default print options can be updated.\"\"\"\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"default_bed_levelling\": False,\n                \"default_flow_cali\": True,\n                \"default_vibration_cali\": False,\n                \"default_layer_inspect\": True,\n                \"default_timelapse\": True,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"default_bed_levelling\"] is False\n        assert result[\"default_flow_cali\"] is True\n        assert result[\"default_vibration_cali\"] is False\n        assert result[\"default_layer_inspect\"] is True\n        assert result[\"default_timelapse\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_default_print_options_persist(self, async_client: AsyncClient):\n        \"\"\"CRITICAL: Verify default print options persist after update.\"\"\"\n        await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"default_bed_levelling\": False,\n                \"default_timelapse\": True,\n            },\n        )\n\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n        assert result[\"default_bed_levelling\"] is False\n        assert result[\"default_timelapse\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_default_print_options_partial_update(self, async_client: AsyncClient):\n        \"\"\"Verify partial updates don't affect other default print options.\"\"\"\n        # Set all to non-default\n        await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"default_bed_levelling\": False,\n                \"default_flow_cali\": True,\n            },\n        )\n\n        # Update only one\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\"default_bed_levelling\": True},\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"default_bed_levelling\"] is True\n        assert result[\"default_flow_cali\"] is True  # Should remain from previous update\n\n    # ========================================================================\n    # Home Assistant environment variable tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_settings_default_no_env_vars(self, async_client: AsyncClient):\n        \"\"\"Verify HA settings work without environment variables (default behavior).\"\"\"\n        # Ensure no env vars are set\n        os.environ.pop(\"HA_URL\", None)\n        os.environ.pop(\"HA_TOKEN\", None)\n\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n\n        assert response.status_code == 200\n        assert \"ha_enabled\" in result\n        assert \"ha_url\" in result\n        assert \"ha_token\" in result\n        assert \"ha_url_from_env\" in result\n        assert \"ha_token_from_env\" in result\n        assert \"ha_env_managed\" in result\n\n        # Default values without env vars\n        assert result[\"ha_url_from_env\"] is False\n        assert result[\"ha_token_from_env\"] is False\n        assert result[\"ha_env_managed\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_settings_with_both_env_vars(self, async_client: AsyncClient):\n        \"\"\"Verify HA settings are overridden when both env vars are set.\"\"\"\n        # Set environment variables\n        os.environ[\"HA_URL\"] = \"http://supervisor/core\"\n        os.environ[\"HA_TOKEN\"] = \"test-token-12345\"\n\n        try:\n            response = await async_client.get(\"/api/v1/settings/\")\n            result = response.json()\n\n            assert response.status_code == 200\n\n            # Verify env var values are used\n            assert result[\"ha_url\"] == \"http://supervisor/core\"\n            assert result[\"ha_token\"] == \"test-token-12345\"\n\n            # Verify metadata fields\n            assert result[\"ha_url_from_env\"] is True\n            assert result[\"ha_token_from_env\"] is True\n            assert result[\"ha_env_managed\"] is True\n\n            # Verify auto-enable behavior\n            assert result[\"ha_enabled\"] is True\n\n        finally:\n            # Clean up\n            os.environ.pop(\"HA_URL\", None)\n            os.environ.pop(\"HA_TOKEN\", None)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_settings_with_only_url_env_var(self, async_client: AsyncClient):\n        \"\"\"Verify partial configuration when only HA_URL is set.\"\"\"\n        # Set only URL env var\n        os.environ[\"HA_URL\"] = \"http://supervisor/core\"\n        os.environ.pop(\"HA_TOKEN\", None)\n\n        try:\n            response = await async_client.get(\"/api/v1/settings/\")\n            result = response.json()\n\n            assert response.status_code == 200\n\n            # Verify URL is from env, token is from database\n            assert result[\"ha_url\"] == \"http://supervisor/core\"\n            assert result[\"ha_url_from_env\"] is True\n            assert result[\"ha_token_from_env\"] is False\n            assert result[\"ha_env_managed\"] is False\n\n            # No auto-enable with partial config\n            assert result[\"ha_enabled\"] is False  # Database default\n\n        finally:\n            os.environ.pop(\"HA_URL\", None)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_settings_with_only_token_env_var(self, async_client: AsyncClient):\n        \"\"\"Verify partial configuration when only HA_TOKEN is set.\"\"\"\n        # Set only token env var\n        os.environ.pop(\"HA_URL\", None)\n        os.environ[\"HA_TOKEN\"] = \"test-token-12345\"\n\n        try:\n            response = await async_client.get(\"/api/v1/settings/\")\n            result = response.json()\n\n            assert response.status_code == 200\n\n            # Verify token is from env, URL is from database\n            assert result[\"ha_token\"] == \"test-token-12345\"\n            assert result[\"ha_url_from_env\"] is False\n            assert result[\"ha_token_from_env\"] is True\n            assert result[\"ha_env_managed\"] is False\n\n            # No auto-enable with partial config\n            assert result[\"ha_enabled\"] is False  # Database default\n\n        finally:\n            os.environ.pop(\"HA_TOKEN\", None)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_settings_env_vars_override_database(self, async_client: AsyncClient):\n        \"\"\"Verify environment variables take precedence over database values.\"\"\"\n        # First, set database values\n        await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"ha_enabled\": True,\n                \"ha_url\": \"http://database-url:8123\",\n                \"ha_token\": \"database-token\",\n            },\n        )\n\n        # Verify database values are set\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n        assert result[\"ha_url\"] == \"http://database-url:8123\"\n        assert result[\"ha_token\"] == \"database-token\"\n\n        # Now set environment variables\n        os.environ[\"HA_URL\"] = \"http://env-url/core\"\n        os.environ[\"HA_TOKEN\"] = \"env-token-xyz\"\n\n        try:\n            response = await async_client.get(\"/api/v1/settings/\")\n            result = response.json()\n\n            # Verify env vars override database\n            assert result[\"ha_url\"] == \"http://env-url/core\"\n            assert result[\"ha_token\"] == \"env-token-xyz\"\n            assert result[\"ha_url_from_env\"] is True\n            assert result[\"ha_token_from_env\"] is True\n            assert result[\"ha_env_managed\"] is True\n            assert result[\"ha_enabled\"] is True\n\n        finally:\n            os.environ.pop(\"HA_URL\", None)\n            os.environ.pop(\"HA_TOKEN\", None)\n\n        # Verify database values are still there after removing env vars\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n        assert result[\"ha_url\"] == \"http://database-url:8123\"\n        assert result[\"ha_token\"] == \"database-token\"\n        assert result[\"ha_url_from_env\"] is False\n        assert result[\"ha_token_from_env\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_settings_database_updates_accepted_but_ignored(self, async_client: AsyncClient):\n        \"\"\"Verify database updates are accepted but have no effect when env vars are set.\"\"\"\n        # Set environment variables\n        os.environ[\"HA_URL\"] = \"http://supervisor/core\"\n        os.environ[\"HA_TOKEN\"] = \"env-token\"\n\n        try:\n            # Attempt to update via API\n            response = await async_client.put(\n                \"/api/v1/settings/\",\n                json={\n                    \"ha_url\": \"http://different-url:8123\",\n                    \"ha_token\": \"different-token\",\n                },\n            )\n\n            # Update should succeed\n            assert response.status_code == 200\n\n            # But values should still be from env vars\n            result = response.json()\n            assert result[\"ha_url\"] == \"http://supervisor/core\"\n            assert result[\"ha_token\"] == \"env-token\"\n            assert result[\"ha_url_from_env\"] is True\n            assert result[\"ha_token_from_env\"] is True\n\n        finally:\n            os.environ.pop(\"HA_URL\", None)\n            os.environ.pop(\"HA_TOKEN\", None)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_settings_empty_env_vars_treated_as_not_set(self, async_client: AsyncClient):\n        \"\"\"Verify empty environment variables are treated as not set.\"\"\"\n        # Set empty env vars\n        os.environ[\"HA_URL\"] = \"\"\n        os.environ[\"HA_TOKEN\"] = \"\"\n\n        try:\n            response = await async_client.get(\"/api/v1/settings/\")\n            result = response.json()\n\n            # Empty env vars should be treated as not set\n            assert result[\"ha_url_from_env\"] is False\n            assert result[\"ha_token_from_env\"] is False\n            assert result[\"ha_env_managed\"] is False\n\n        finally:\n            os.environ.pop(\"HA_URL\", None)\n            os.environ.pop(\"HA_TOKEN\", None)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_settings_can_be_updated_normally_without_env_vars(self, async_client: AsyncClient):\n        \"\"\"Verify HA settings can be updated normally when env vars are not set.\"\"\"\n        # Ensure no env vars\n        os.environ.pop(\"HA_URL\", None)\n        os.environ.pop(\"HA_TOKEN\", None)\n\n        # Update HA settings\n        response = await async_client.put(\n            \"/api/v1/settings/\",\n            json={\n                \"ha_enabled\": True,\n                \"ha_url\": \"http://192.168.1.100:8123\",\n                \"ha_token\": \"my-long-lived-token\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"ha_enabled\"] is True\n        assert result[\"ha_url\"] == \"http://192.168.1.100:8123\"\n        assert result[\"ha_token\"] == \"my-long-lived-token\"\n        assert result[\"ha_url_from_env\"] is False\n        assert result[\"ha_token_from_env\"] is False\n        assert result[\"ha_env_managed\"] is False\n\n        # Verify persistence\n        response = await async_client.get(\"/api/v1/settings/\")\n        result = response.json()\n        assert result[\"ha_enabled\"] is True\n        assert result[\"ha_url\"] == \"http://192.168.1.100:8123\"\n        assert result[\"ha_token\"] == \"my-long-lived-token\"\n\n\nclass TestSimplifiedBackupRestore:\n    \"\"\"Integration tests for the simplified backup/restore endpoints (ZIP-based).\n\n    Note: Tests that require actual file operations (backup creation) are skipped\n    because the test suite uses an in-memory database. These tests focus on\n    validation and error handling which don't require file I/O.\n    \"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_restore_requires_zip_file(self, async_client: AsyncClient):\n        \"\"\"Verify restore rejects non-ZIP files.\"\"\"\n        files = {\"file\": (\"backup.txt\", b\"not a zip file\", \"text/plain\")}\n        response = await async_client.post(\"/api/v1/settings/restore\", files=files)\n\n        assert response.status_code == 400\n        assert \"zip\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_restore_requires_database_in_zip(self, async_client: AsyncClient):\n        \"\"\"Verify restore rejects ZIP without database file.\"\"\"\n        import io\n        import zipfile\n\n        # Create a ZIP without bambuddy.db\n        zip_buffer = io.BytesIO()\n        with zipfile.ZipFile(zip_buffer, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"dummy.txt\", \"dummy content\")\n        zip_buffer.seek(0)\n\n        files = {\"file\": (\"backup.zip\", zip_buffer.read(), \"application/zip\")}\n        response = await async_client.post(\"/api/v1/settings/restore\", files=files)\n\n        assert response.status_code == 400\n        assert \"missing bambuddy.db\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_restore_invalid_zip(self, async_client: AsyncClient):\n        \"\"\"Verify restore rejects corrupted ZIP files.\"\"\"\n        files = {\"file\": (\"backup.zip\", b\"not valid zip content\", \"application/zip\")}\n        response = await async_client.post(\"/api/v1/settings/restore\", files=files)\n\n        assert response.status_code == 400\n        assert \"not a valid zip\" in response.json()[\"detail\"].lower()\n"
  },
  {
    "path": "backend/tests/integration/test_sjf_scheduling.py",
    "content": "\"\"\"Integration tests for Shortest Job First (SJF) queue scheduling.\"\"\"\n\nimport pytest\nfrom sqlalchemy import select\n\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.models.settings import Settings\n\n\nclass TestSJFScheduling:\n    \"\"\"Tests for shortest-job-first queue ordering and starvation guard.\"\"\"\n\n    @pytest.fixture\n    async def printer_factory(self, db_session):\n        \"\"\"Factory to create test printers.\"\"\"\n        _counter = [0]\n\n        async def _create_printer(**kwargs):\n            from backend.app.models.printer import Printer\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"name\": f\"Test Printer {counter}\",\n                \"ip_address\": f\"192.168.1.{100 + counter}\",\n                \"serial_number\": f\"TESTSERIAL{counter:04d}\",\n                \"access_code\": \"12345678\",\n                \"model\": \"X1C\",\n            }\n            defaults.update(kwargs)\n\n            printer = Printer(**defaults)\n            db_session.add(printer)\n            await db_session.commit()\n            await db_session.refresh(printer)\n            return printer\n\n        return _create_printer\n\n    @pytest.fixture\n    async def archive_factory(self, db_session):\n        \"\"\"Factory to create test archives.\"\"\"\n        _counter = [0]\n\n        async def _create_archive(**kwargs):\n            from backend.app.models.archive import PrintArchive\n\n            _counter[0] += 1\n            counter = _counter[0]\n\n            defaults = {\n                \"filename\": f\"test_print_{counter}.3mf\",\n                \"print_name\": f\"Test Print {counter}\",\n                \"file_path\": f\"/tmp/test_print_{counter}.3mf\",  # nosec B108\n                \"file_size\": 1024,\n                \"content_hash\": f\"testhash{counter:08d}\",\n                \"status\": \"completed\",\n            }\n            defaults.update(kwargs)\n\n            archive = PrintArchive(**defaults)\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return archive\n\n        return _create_archive\n\n    @pytest.fixture\n    async def queue_item_factory(self, db_session, printer_factory, archive_factory):\n        \"\"\"Factory to create test queue items with print_time_seconds.\"\"\"\n\n        async def _create_queue_item(**kwargs):\n            if \"printer_id\" not in kwargs:\n                printer = await printer_factory()\n                kwargs[\"printer_id\"] = printer.id\n\n            if \"archive_id\" not in kwargs:\n                archive = await archive_factory()\n                kwargs[\"archive_id\"] = archive.id\n\n            defaults = {\n                \"status\": \"pending\",\n                \"position\": 0,\n            }\n            defaults.update(kwargs)\n\n            item = PrintQueueItem(**defaults)\n            db_session.add(item)\n            await db_session.commit()\n            await db_session.refresh(item)\n            return item\n\n        return _create_queue_item\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_item_has_print_time_seconds(self, queue_item_factory):\n        \"\"\"Verify print_time_seconds can be stored on queue items.\"\"\"\n        item = await queue_item_factory(print_time_seconds=3600, position=1)\n        assert item.print_time_seconds == 3600\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_item_has_been_jumped(self, queue_item_factory):\n        \"\"\"Verify been_jumped defaults to False and can be set.\"\"\"\n        item = await queue_item_factory(position=1)\n        assert item.been_jumped is False\n\n        item2 = await queue_item_factory(been_jumped=True, position=2)\n        assert item2.been_jumped is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_sjf_ordering_shorter_jobs_first(self, db_session, queue_item_factory, printer_factory):\n        \"\"\"Verify SJF query orders by print_time_seconds ascending.\"\"\"\n        printer = await printer_factory()\n\n        # Add items in FIFO order: long, medium, short\n        long_job = await queue_item_factory(\n            printer_id=printer.id,\n            position=1,\n            print_time_seconds=28800,  # 8 hours\n        )\n        medium_job = await queue_item_factory(\n            printer_id=printer.id,\n            position=2,\n            print_time_seconds=3600,  # 1 hour\n        )\n        short_job = await queue_item_factory(\n            printer_id=printer.id,\n            position=3,\n            print_time_seconds=1200,  # 20 min\n        )\n\n        # SJF query: been_jumped DESC, print_time_seconds ASC NULLS LAST, position\n        result = await db_session.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.status == \"pending\")\n            .where(PrintQueueItem.printer_id == printer.id)\n            .order_by(\n                PrintQueueItem.been_jumped.desc(),\n                PrintQueueItem.print_time_seconds.asc().nullslast(),\n                PrintQueueItem.position,\n            )\n        )\n        items = list(result.scalars().all())\n\n        assert len(items) == 3\n        assert items[0].id == short_job.id  # 20 min first\n        assert items[1].id == medium_job.id  # 1 hour second\n        assert items[2].id == long_job.id  # 8 hours last\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_sjf_null_print_time_goes_last(self, db_session, queue_item_factory, printer_factory):\n        \"\"\"Verify items without print_time_seconds are sorted last in SJF mode.\"\"\"\n        printer = await printer_factory()\n\n        no_time = await queue_item_factory(printer_id=printer.id, position=1, print_time_seconds=None)\n        short_job = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=600)\n\n        result = await db_session.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.status == \"pending\")\n            .where(PrintQueueItem.printer_id == printer.id)\n            .order_by(\n                PrintQueueItem.been_jumped.desc(),\n                PrintQueueItem.print_time_seconds.asc().nullslast(),\n                PrintQueueItem.position,\n            )\n        )\n        items = list(result.scalars().all())\n\n        assert items[0].id == short_job.id  # Known duration first\n        assert items[1].id == no_time.id  # Unknown duration last\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_starvation_guard_jumped_items_first(self, db_session, queue_item_factory, printer_factory):\n        \"\"\"Verify been_jumped items are sorted before non-jumped items.\"\"\"\n        printer = await printer_factory()\n\n        # Long job that was jumped (should go first now)\n        jumped_long = await queue_item_factory(\n            printer_id=printer.id, position=1, print_time_seconds=28800, been_jumped=True\n        )\n        # Short job (would normally go first, but jumped_long has priority)\n        short_job = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=600)\n\n        result = await db_session.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.status == \"pending\")\n            .where(PrintQueueItem.printer_id == printer.id)\n            .order_by(\n                PrintQueueItem.been_jumped.desc(),\n                PrintQueueItem.print_time_seconds.asc().nullslast(),\n                PrintQueueItem.position,\n            )\n        )\n        items = list(result.scalars().all())\n\n        assert items[0].id == jumped_long.id  # Jumped item gets priority\n        assert items[1].id == short_job.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_fifo_ordering_ignores_print_time(self, db_session, queue_item_factory, printer_factory):\n        \"\"\"Verify default FIFO ordering uses position only, not print_time_seconds.\"\"\"\n        printer = await printer_factory()\n\n        long_first = await queue_item_factory(printer_id=printer.id, position=1, print_time_seconds=28800)\n        short_second = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=600)\n\n        # Default FIFO query (no SJF)\n        result = await db_session.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.status == \"pending\")\n            .where(PrintQueueItem.printer_id == printer.id)\n            .order_by(PrintQueueItem.position)\n        )\n        items = list(result.scalars().all())\n\n        assert items[0].id == long_first.id  # Position 1 first (FIFO)\n        assert items[1].id == short_second.id  # Position 2 second\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_sjf_position_as_tiebreaker(self, db_session, queue_item_factory, printer_factory):\n        \"\"\"Verify position is used as tiebreaker when print times are equal.\"\"\"\n        printer = await printer_factory()\n\n        first = await queue_item_factory(printer_id=printer.id, position=1, print_time_seconds=3600)\n        second = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=3600)\n\n        result = await db_session.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.status == \"pending\")\n            .where(PrintQueueItem.printer_id == printer.id)\n            .order_by(\n                PrintQueueItem.been_jumped.desc(),\n                PrintQueueItem.print_time_seconds.asc().nullslast(),\n                PrintQueueItem.position,\n            )\n        )\n        items = list(result.scalars().all())\n\n        assert items[0].id == first.id  # Same duration, lower position wins\n        assert items[1].id == second.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_starvation_flag_set_on_jumped_items(self, db_session, queue_item_factory, printer_factory):\n        \"\"\"Verify the starvation flag logic marks jumped items correctly.\"\"\"\n        printer = await printer_factory()\n\n        # Simulate: long job at position 1, short job at position 2\n        long_job = await queue_item_factory(printer_id=printer.id, position=1, print_time_seconds=28800)\n        short_job = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=1200)\n\n        # Simulate what the scheduler does when SJF picks short_job first:\n        # Mark items that were jumped (lower position, longer duration)\n        items = [long_job, short_job]\n        winning_item = short_job  # SJF would pick this\n\n        for other in items:\n            if (\n                other.id != winning_item.id\n                and other.status == \"pending\"\n                and other.printer_id == winning_item.printer_id\n                and not other.been_jumped\n                and other.position < winning_item.position\n                and (other.print_time_seconds is None or other.print_time_seconds > winning_item.print_time_seconds)\n            ):\n                other.been_jumped = True\n\n        await db_session.commit()\n        await db_session.refresh(long_job)\n\n        assert long_job.been_jumped is True\n        assert short_job.been_jumped is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_starvation_guard_prevents_double_jump(self, db_session, queue_item_factory, printer_factory):\n        \"\"\"Verify an already-jumped item won't be jumped again.\"\"\"\n        printer = await printer_factory()\n\n        # Long job already jumped once\n        long_job = await queue_item_factory(\n            printer_id=printer.id, position=1, print_time_seconds=28800, been_jumped=True\n        )\n        # Even shorter job arrives\n        tiny_job = await queue_item_factory(printer_id=printer.id, position=3, print_time_seconds=300)\n\n        # SJF order: jumped items first, then by duration\n        result = await db_session.execute(\n            select(PrintQueueItem)\n            .where(PrintQueueItem.status == \"pending\")\n            .where(PrintQueueItem.printer_id == printer.id)\n            .order_by(\n                PrintQueueItem.been_jumped.desc(),\n                PrintQueueItem.print_time_seconds.asc().nullslast(),\n                PrintQueueItem.position,\n            )\n        )\n        items = list(result.scalars().all())\n\n        # long_job goes first because it was already jumped (starvation protection)\n        assert items[0].id == long_job.id\n        assert items[1].id == tiny_job.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_shortest_first_setting(self, db_session):\n        \"\"\"Verify the queue_shortest_first setting can be stored and read.\"\"\"\n        setting = Settings(key=\"queue_shortest_first\", value=\"true\")\n        db_session.add(setting)\n        await db_session.commit()\n\n        result = await db_session.execute(select(Settings).where(Settings.key == \"queue_shortest_first\"))\n        stored = result.scalar_one_or_none()\n        assert stored is not None\n        assert stored.value == \"true\"\n"
  },
  {
    "path": "backend/tests/integration/test_smart_plugs_api.py",
    "content": "\"\"\"Integration tests for Smart Plugs API endpoints.\n\nTests the full request/response cycle for /api/v1/smart-plugs/ endpoints.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestSmartPlugsAPI:\n    \"\"\"Integration tests for /api/v1/smart-plugs/ endpoints.\"\"\"\n\n    # ========================================================================\n    # List endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_smart_plugs_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list is returned when no plugs exist.\"\"\"\n        response = await async_client.get(\"/api/v1/smart-plugs/\")\n\n        assert response.status_code == 200\n        assert response.json() == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_smart_plugs_with_data(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify list returns existing plugs.\"\"\"\n        await smart_plug_factory(name=\"Test Plug 1\")\n\n        response = await async_client.get(\"/api/v1/smart-plugs/\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) >= 1\n        assert any(p[\"name\"] == \"Test Plug 1\" for p in data)\n\n    # ========================================================================\n    # Create endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_smart_plug(self, async_client: AsyncClient):\n        \"\"\"Verify smart plug can be created.\"\"\"\n        data = {\n            \"name\": \"New Plug\",\n            \"ip_address\": \"192.168.1.100\",\n            \"enabled\": True,\n            \"auto_on\": True,\n            \"auto_off\": False,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"New Plug\"\n        assert result[\"ip_address\"] == \"192.168.1.100\"\n        assert result[\"auto_off\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_smart_plug_with_printer(self, async_client: AsyncClient, printer_factory, db_session):\n        \"\"\"Verify smart plug can be linked to a printer.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        data = {\n            \"name\": \"Printer Plug\",\n            \"ip_address\": \"192.168.1.101\",\n            \"printer_id\": printer.id,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"printer_id\"] == printer.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_plug_with_invalid_printer_id(self, async_client: AsyncClient):\n        \"\"\"Verify creating plug with non-existent printer fails.\"\"\"\n        data = {\n            \"name\": \"Test Plug\",\n            \"ip_address\": \"192.168.1.100\",\n            \"printer_id\": 9999,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 400\n        assert \"Printer not found\" in response.json()[\"detail\"]\n\n    # ========================================================================\n    # Get single endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_smart_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify single plug can be retrieved.\"\"\"\n        plug = await smart_plug_factory(name=\"Get Test Plug\")\n\n        response = await async_client.get(f\"/api/v1/smart-plugs/{plug.id}\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"id\"] == plug.id\n        assert result[\"name\"] == \"Get Test Plug\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_smart_plug_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent plug.\"\"\"\n        response = await async_client.get(\"/api/v1/smart-plugs/9999\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Update endpoints (CRITICAL - toggle persistence)\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_auto_off_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"CRITICAL: Verify auto_off toggle persists correctly.\n\n        This tests the regression scenario where toggling auto_off\n        wasn't being saved properly.\n        \"\"\"\n        # Create plug with auto_off=True\n        plug = await smart_plug_factory(auto_off=True)\n\n        # Verify initial state\n        response = await async_client.get(f\"/api/v1/smart-plugs/{plug.id}\")\n        assert response.status_code == 200\n        assert response.json()[\"auto_off\"] is True\n\n        # Toggle auto_off to False\n        response = await async_client.patch(f\"/api/v1/smart-plugs/{plug.id}\", json={\"auto_off\": False})\n\n        assert response.status_code == 200\n        assert response.json()[\"auto_off\"] is False\n\n        # Verify change persisted by fetching again\n        response = await async_client.get(f\"/api/v1/smart-plugs/{plug.id}\")\n        assert response.json()[\"auto_off\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_auto_on_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify auto_on toggle persists correctly.\"\"\"\n        plug = await smart_plug_factory(auto_on=True)\n\n        response = await async_client.patch(f\"/api/v1/smart-plugs/{plug.id}\", json={\"auto_on\": False})\n\n        assert response.status_code == 200\n        assert response.json()[\"auto_on\"] is False\n\n        # Verify persistence\n        response = await async_client.get(f\"/api/v1/smart-plugs/{plug.id}\")\n        assert response.json()[\"auto_on\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_enabled_toggle(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify enabled toggle persists correctly.\"\"\"\n        plug = await smart_plug_factory(enabled=True)\n\n        response = await async_client.patch(f\"/api/v1/smart-plugs/{plug.id}\", json={\"enabled\": False})\n\n        assert response.status_code == 200\n        assert response.json()[\"enabled\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_off_delay_mode(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify off_delay_mode can be changed.\"\"\"\n        plug = await smart_plug_factory(off_delay_mode=\"time\")\n\n        response = await async_client.patch(\n            f\"/api/v1/smart-plugs/{plug.id}\", json={\"off_delay_mode\": \"temperature\", \"off_temp_threshold\": 50}\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"off_delay_mode\"] == \"temperature\"\n        assert result[\"off_temp_threshold\"] == 50\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_schedule_settings(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify schedule settings can be updated.\"\"\"\n        plug = await smart_plug_factory(schedule_enabled=False)\n\n        response = await async_client.patch(\n            f\"/api/v1/smart-plugs/{plug.id}\",\n            json={\n                \"schedule_enabled\": True,\n                \"schedule_on_time\": \"08:00\",\n                \"schedule_off_time\": \"22:00\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"schedule_enabled\"] is True\n        assert result[\"schedule_on_time\"] == \"08:00\"\n        assert result[\"schedule_off_time\"] == \"22:00\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_multiple_fields(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify multiple fields can be updated at once.\"\"\"\n        plug = await smart_plug_factory(\n            name=\"Old Name\",\n            auto_on=True,\n            auto_off=True,\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/smart-plugs/{plug.id}\",\n            json={\n                \"name\": \"New Name\",\n                \"auto_on\": False,\n                \"auto_off\": False,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"New Name\"\n        assert result[\"auto_on\"] is False\n        assert result[\"auto_off\"] is False\n\n    # ========================================================================\n    # Control endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_control_smart_plug_on(\n        self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session\n    ):\n        \"\"\"Verify smart plug can be turned on.\"\"\"\n        plug = await smart_plug_factory()\n\n        response = await async_client.post(f\"/api/v1/smart-plugs/{plug.id}/control\", json={\"action\": \"on\"})\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is True\n        assert result[\"action\"] == \"on\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_control_smart_plug_off(\n        self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session\n    ):\n        \"\"\"Verify smart plug can be turned off.\"\"\"\n        plug = await smart_plug_factory()\n\n        response = await async_client.post(f\"/api/v1/smart-plugs/{plug.id}/control\", json={\"action\": \"off\"})\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is True\n        assert result[\"action\"] == \"off\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_control_smart_plug_toggle(\n        self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session\n    ):\n        \"\"\"Verify smart plug can be toggled.\"\"\"\n        plug = await smart_plug_factory()\n\n        response = await async_client.post(f\"/api/v1/smart-plugs/{plug.id}/control\", json={\"action\": \"toggle\"})\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is True\n        assert result[\"action\"] == \"toggle\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_control_invalid_action(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify invalid action returns error.\"\"\"\n        plug = await smart_plug_factory()\n\n        response = await async_client.post(f\"/api/v1/smart-plugs/{plug.id}/control\", json={\"action\": \"invalid\"})\n\n        # FastAPI returns 422 for pydantic validation errors\n        assert response.status_code == 422\n\n    # ========================================================================\n    # Status endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_smart_plug_status(\n        self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session\n    ):\n        \"\"\"Verify smart plug status can be retrieved.\"\"\"\n        plug = await smart_plug_factory()\n\n        response = await async_client.get(f\"/api/v1/smart-plugs/{plug.id}/status\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"state\"] == \"ON\"\n        assert result[\"reachable\"] is True\n\n    # ========================================================================\n    # Delete endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_smart_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify smart plug can be deleted.\"\"\"\n        plug = await smart_plug_factory()\n        plug_id = plug.id\n\n        response = await async_client.delete(f\"/api/v1/smart-plugs/{plug_id}\")\n\n        assert response.status_code == 200\n\n        # Verify deleted\n        response = await async_client.get(f\"/api/v1/smart-plugs/{plug_id}\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_delete_nonexistent_plug(self, async_client: AsyncClient):\n        \"\"\"Verify deleting non-existent plug returns 404.\"\"\"\n        response = await async_client.delete(\"/api/v1/smart-plugs/9999\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Switchbar visibility\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_show_in_switchbar(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify show_in_switchbar toggle persists correctly.\"\"\"\n        plug = await smart_plug_factory(show_in_switchbar=False)\n\n        response = await async_client.patch(f\"/api/v1/smart-plugs/{plug.id}\", json={\"show_in_switchbar\": True})\n\n        assert response.status_code == 200\n        assert response.json()[\"show_in_switchbar\"] is True\n\n        # Verify persistence\n        response = await async_client.get(f\"/api/v1/smart-plugs/{plug.id}\")\n        assert response.json()[\"show_in_switchbar\"] is True\n\n    # ========================================================================\n    # Tasmota Discovery endpoints\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tasmota_discovery_scan(self, async_client: AsyncClient):\n        \"\"\"Verify Tasmota discovery scan can be started.\"\"\"\n        response = await async_client.post(\"/api/v1/smart-plugs/discover/scan\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n        assert \"scanned\" in data\n        assert \"total\" in data\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tasmota_discovery_status(self, async_client: AsyncClient):\n        \"\"\"Verify Tasmota discovery status endpoint works.\"\"\"\n        response = await async_client.get(\"/api/v1/smart-plugs/discover/status\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n        assert \"scanned\" in data\n        assert \"total\" in data\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tasmota_discovery_devices(self, async_client: AsyncClient):\n        \"\"\"Verify Tasmota discovered devices endpoint works.\"\"\"\n        response = await async_client.get(\"/api/v1/smart-plugs/discover/devices\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tasmota_discovery_stop(self, async_client: AsyncClient):\n        \"\"\"Verify Tasmota discovery can be stopped.\"\"\"\n        response = await async_client.post(\"/api/v1/smart-plugs/discover/stop\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"running\" in data\n\n    # ========================================================================\n    # Home Assistant Integration tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_homeassistant_plug(self, async_client: AsyncClient):\n        \"\"\"Verify Home Assistant plug can be created.\"\"\"\n        data = {\n            \"name\": \"HA Plug\",\n            \"plug_type\": \"homeassistant\",\n            \"ha_entity_id\": \"switch.printer_plug\",\n            \"enabled\": True,\n            \"auto_on\": True,\n            \"auto_off\": False,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"HA Plug\"\n        assert result[\"plug_type\"] == \"homeassistant\"\n        assert result[\"ha_entity_id\"] == \"switch.printer_plug\"\n        assert result[\"ip_address\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_homeassistant_plug_missing_entity_id(self, async_client: AsyncClient):\n        \"\"\"Verify creating HA plug without entity_id fails.\"\"\"\n        data = {\n            \"name\": \"HA Plug\",\n            \"plug_type\": \"homeassistant\",\n            # Missing ha_entity_id\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 422  # Validation error\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_tasmota_plug_missing_ip(self, async_client: AsyncClient):\n        \"\"\"Verify creating Tasmota plug without IP fails.\"\"\"\n        data = {\n            \"name\": \"Tasmota Plug\",\n            \"plug_type\": \"tasmota\",\n            # Missing ip_address\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 422  # Validation error\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_entities_endpoint_not_configured(self, async_client: AsyncClient):\n        \"\"\"Verify HA entities endpoint returns error when not configured.\"\"\"\n        response = await async_client.get(\"/api/v1/smart-plugs/ha/entities\")\n\n        assert response.status_code == 400\n        assert \"not configured\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_plug_type(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify plug_type can be updated.\"\"\"\n        plug = await smart_plug_factory(plug_type=\"tasmota\", ip_address=\"192.168.1.100\")\n\n        response = await async_client.patch(\n            f\"/api/v1/smart-plugs/{plug.id}\",\n            json={\n                \"plug_type\": \"homeassistant\",\n                \"ha_entity_id\": \"switch.test\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"plug_type\"] == \"homeassistant\"\n        assert result[\"ha_entity_id\"] == \"switch.test\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_control_homeassistant_plug(\n        self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session\n    ):\n        \"\"\"Verify HA smart plug can be controlled.\"\"\"\n        plug = await smart_plug_factory(plug_type=\"homeassistant\", ha_entity_id=\"switch.test\")\n\n        response = await async_client.post(f\"/api/v1/smart-plugs/{plug.id}/control\", json={\"action\": \"on\"})\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"success\"] is True\n        assert result[\"action\"] == \"on\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_homeassistant_plug_status(\n        self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session\n    ):\n        \"\"\"Verify HA smart plug status can be retrieved.\"\"\"\n        plug = await smart_plug_factory(plug_type=\"homeassistant\", ha_entity_id=\"switch.test\")\n\n        response = await async_client.get(f\"/api/v1/smart-plugs/{plug.id}/status\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"state\"] == \"ON\"\n        assert result[\"reachable\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_homeassistant_plug_with_energy_sensors(self, async_client: AsyncClient):\n        \"\"\"Verify HA plug can be created with energy sensor entities.\"\"\"\n        data = {\n            \"name\": \"HA Plug with Energy\",\n            \"plug_type\": \"homeassistant\",\n            \"ha_entity_id\": \"switch.printer_plug\",\n            \"ha_power_entity\": \"sensor.printer_power\",\n            \"ha_energy_today_entity\": \"sensor.printer_energy_today\",\n            \"ha_energy_total_entity\": \"sensor.printer_energy_total\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"ha_power_entity\"] == \"sensor.printer_power\"\n        assert result[\"ha_energy_today_entity\"] == \"sensor.printer_energy_today\"\n        assert result[\"ha_energy_total_entity\"] == \"sensor.printer_energy_total\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_ha_energy_sensor_entities(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify HA energy sensor entities can be updated.\"\"\"\n        plug = await smart_plug_factory(plug_type=\"homeassistant\", ha_entity_id=\"switch.test\")\n\n        response = await async_client.patch(\n            f\"/api/v1/smart-plugs/{plug.id}\",\n            json={\n                \"ha_power_entity\": \"sensor.new_power\",\n                \"ha_energy_today_entity\": \"sensor.new_today\",\n                \"ha_energy_total_entity\": \"sensor.new_total\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"ha_power_entity\"] == \"sensor.new_power\"\n        assert result[\"ha_energy_today_entity\"] == \"sensor.new_today\"\n        assert result[\"ha_energy_total_entity\"] == \"sensor.new_total\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_ha_sensors_endpoint_not_configured(self, async_client: AsyncClient):\n        \"\"\"Verify HA sensors endpoint returns error when not configured.\"\"\"\n        response = await async_client.get(\"/api/v1/smart-plugs/ha/sensors\")\n\n        assert response.status_code == 400\n        assert \"not configured\" in response.json()[\"detail\"].lower()\n\n    # ========================================================================\n    # MQTT Integration tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):\n        \"\"\"Verify MQTT plug can be created with topic and JSON paths.\"\"\"\n        data = {\n            \"name\": \"MQTT Energy Monitor\",\n            \"plug_type\": \"mqtt\",\n            \"mqtt_topic\": \"zigbee2mqtt/shelly-working-room\",\n            \"mqtt_power_path\": \"power_l1\",\n            \"mqtt_energy_path\": \"energy_l1\",\n            \"mqtt_state_path\": \"state_l1\",\n            \"mqtt_multiplier\": 1.0,\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"MQTT Energy Monitor\"\n        assert result[\"plug_type\"] == \"mqtt\"\n        assert result[\"mqtt_topic\"] == \"zigbee2mqtt/shelly-working-room\"\n        assert result[\"mqtt_power_path\"] == \"power_l1\"\n        assert result[\"mqtt_energy_path\"] == \"energy_l1\"\n        assert result[\"mqtt_state_path\"] == \"state_l1\"\n        assert result[\"mqtt_multiplier\"] == 1.0\n        assert result[\"ip_address\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):\n        \"\"\"Verify creating MQTT plug without topic fails.\"\"\"\n        data = {\n            \"name\": \"MQTT Plug\",\n            \"plug_type\": \"mqtt\",\n            # Missing mqtt_topic\n            \"mqtt_power_path\": \"power\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 422  # Validation error\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):\n        \"\"\"Verify creating MQTT plug without any topic fails.\"\"\"\n        data = {\n            \"name\": \"MQTT Plug\",\n            \"plug_type\": \"mqtt\",\n            # No topic configured at all\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 422  # Validation error\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):\n        \"\"\"Verify MQTT plug can use multiplier for unit conversion.\"\"\"\n        data = {\n            \"name\": \"MQTT mW to W\",\n            \"plug_type\": \"mqtt\",\n            \"mqtt_topic\": \"sensors/power\",\n            \"mqtt_power_path\": \"power_mw\",\n            \"mqtt_multiplier\": 0.001,  # Convert mW to W\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mqtt_multiplier\"] == 0.001\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify MQTT plugs cannot be controlled (monitor-only).\"\"\"\n        plug = await smart_plug_factory(\n            plug_type=\"mqtt\",\n            mqtt_topic=\"test/topic\",\n            mqtt_power_path=\"power\",\n        )\n\n        response = await async_client.post(f\"/api/v1/smart-plugs/{plug.id}/control\", json={\"action\": \"on\"})\n\n        assert response.status_code == 400\n        assert \"monitor-only\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify MQTT plug topic can be updated.\"\"\"\n        plug = await smart_plug_factory(\n            plug_type=\"mqtt\",\n            mqtt_topic=\"old/topic\",\n            mqtt_power_path=\"power\",\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/smart-plugs/{plug.id}\",\n            json={\n                \"mqtt_topic\": \"new/topic\",\n                \"mqtt_power_path\": \"new_power\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mqtt_topic\"] == \"new/topic\"\n        assert result[\"mqtt_power_path\"] == \"new_power\"\n\n    # ========================================================================\n    # Enhanced MQTT Integration tests (separate topics per data type)\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):\n        \"\"\"Verify MQTT plug can be created with separate topics for power, energy, and state.\"\"\"\n        data = {\n            \"name\": \"MQTT Separate Topics\",\n            \"plug_type\": \"mqtt\",\n            \"mqtt_power_topic\": \"zigbee/power\",\n            \"mqtt_power_path\": \"power_l1\",\n            \"mqtt_power_multiplier\": 0.001,\n            \"mqtt_energy_topic\": \"zigbee/energy\",\n            \"mqtt_energy_path\": \"energy_total\",\n            \"mqtt_energy_multiplier\": 1.0,\n            \"mqtt_state_topic\": \"zigbee/state\",\n            \"mqtt_state_path\": \"state\",\n            \"mqtt_state_on_value\": \"ON\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"MQTT Separate Topics\"\n        assert result[\"plug_type\"] == \"mqtt\"\n        # Power fields\n        assert result[\"mqtt_power_topic\"] == \"zigbee/power\"\n        assert result[\"mqtt_power_path\"] == \"power_l1\"\n        assert result[\"mqtt_power_multiplier\"] == 0.001\n        # Energy fields\n        assert result[\"mqtt_energy_topic\"] == \"zigbee/energy\"\n        assert result[\"mqtt_energy_path\"] == \"energy_total\"\n        assert result[\"mqtt_energy_multiplier\"] == 1.0\n        # State fields\n        assert result[\"mqtt_state_topic\"] == \"zigbee/state\"\n        assert result[\"mqtt_state_path\"] == \"state\"\n        assert result[\"mqtt_state_on_value\"] == \"ON\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):\n        \"\"\"Verify MQTT plug can be created with only energy monitoring.\"\"\"\n        data = {\n            \"name\": \"Energy Only Monitor\",\n            \"plug_type\": \"mqtt\",\n            \"mqtt_energy_topic\": \"sensors/energy\",\n            \"mqtt_energy_path\": \"kwh\",\n            \"mqtt_energy_multiplier\": 0.001,  # Wh to kWh\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mqtt_energy_topic\"] == \"sensors/energy\"\n        assert result[\"mqtt_energy_path\"] == \"kwh\"\n        assert result[\"mqtt_energy_multiplier\"] == 0.001\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):\n        \"\"\"Verify MQTT plug can be created with only state monitoring.\"\"\"\n        data = {\n            \"name\": \"State Only Monitor\",\n            \"plug_type\": \"mqtt\",\n            \"mqtt_state_topic\": \"switches/outlet\",\n            \"mqtt_state_path\": \"state\",\n            \"mqtt_state_on_value\": \"true\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mqtt_state_topic\"] == \"switches/outlet\"\n        assert result[\"mqtt_state_path\"] == \"state\"\n        assert result[\"mqtt_state_on_value\"] == \"true\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):\n        \"\"\"Verify creating MQTT plug with topic only (no path) succeeds for raw values.\"\"\"\n        data = {\n            \"name\": \"Raw MQTT Plug\",\n            \"plug_type\": \"mqtt\",\n            # Topic only, no path - valid for raw numeric MQTT values\n            \"mqtt_power_topic\": \"zigbee/power\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200  # Should succeed\n        result = response.json()\n        assert result[\"mqtt_power_topic\"] == \"zigbee/power\"\n        assert result[\"mqtt_power_path\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_mqtt_plug_separate_multipliers(\n        self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service\n    ):\n        \"\"\"Verify MQTT plug multipliers can be updated separately.\"\"\"\n        plug = await smart_plug_factory(\n            plug_type=\"mqtt\",\n            mqtt_power_topic=\"test/power\",\n            mqtt_power_path=\"power\",\n            mqtt_power_multiplier=1.0,\n            mqtt_energy_topic=\"test/energy\",\n            mqtt_energy_path=\"energy\",\n            mqtt_energy_multiplier=1.0,\n        )\n\n        response = await async_client.patch(\n            f\"/api/v1/smart-plugs/{plug.id}\",\n            json={\n                \"mqtt_power_multiplier\": 0.001,  # Change power multiplier only\n                \"mqtt_energy_multiplier\": 0.001,  # Change energy multiplier only\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mqtt_power_multiplier\"] == 0.001\n        assert result[\"mqtt_energy_multiplier\"] == 0.001\n\n    # ========================================================================\n    # REST Smart Plug Integration tests\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_rest_plug(self, async_client: AsyncClient):\n        \"\"\"Verify REST plug can be created with ON/OFF URLs.\"\"\"\n        data = {\n            \"name\": \"REST Plug\",\n            \"plug_type\": \"rest\",\n            \"rest_on_url\": \"http://openhab:8080/rest/items/MyPlug\",\n            \"rest_on_body\": \"ON\",\n            \"rest_off_url\": \"http://openhab:8080/rest/items/MyPlug\",\n            \"rest_off_body\": \"OFF\",\n            \"rest_method\": \"POST\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"name\"] == \"REST Plug\"\n        assert result[\"plug_type\"] == \"rest\"\n        assert result[\"rest_on_url\"] == \"http://openhab:8080/rest/items/MyPlug\"\n        assert result[\"rest_on_body\"] == \"ON\"\n        assert result[\"rest_off_url\"] == \"http://openhab:8080/rest/items/MyPlug\"\n        assert result[\"rest_off_body\"] == \"OFF\"\n        assert result[\"rest_method\"] == \"POST\"\n        assert result[\"ip_address\"] is None\n        assert result[\"ha_entity_id\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_rest_plug_on_url_only(self, async_client: AsyncClient):\n        \"\"\"Verify REST plug can be created with only ON URL.\"\"\"\n        data = {\n            \"name\": \"REST ON Only\",\n            \"plug_type\": \"rest\",\n            \"rest_on_url\": \"http://iobroker:8087/set/plug?value=true\",\n            \"rest_method\": \"GET\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"rest_on_url\"] == \"http://iobroker:8087/set/plug?value=true\"\n        assert result[\"rest_off_url\"] is None\n        assert result[\"rest_method\"] == \"GET\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_rest_plug_missing_urls_fails(self, async_client: AsyncClient):\n        \"\"\"Verify creating REST plug without any URL fails.\"\"\"\n        data = {\n            \"name\": \"REST Plug\",\n            \"plug_type\": \"rest\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 422  # Validation error\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_rest_plug_with_status_and_energy(self, async_client: AsyncClient):\n        \"\"\"Verify REST plug with status polling and energy monitoring.\"\"\"\n        data = {\n            \"name\": \"REST Full\",\n            \"plug_type\": \"rest\",\n            \"rest_on_url\": \"http://ha:8080/api/plug/on\",\n            \"rest_off_url\": \"http://ha:8080/api/plug/off\",\n            \"rest_method\": \"POST\",\n            \"rest_headers\": '{\"Authorization\": \"Bearer test-token\"}',\n            \"rest_status_url\": \"http://ha:8080/api/plug/status\",\n            \"rest_status_path\": \"state\",\n            \"rest_status_on_value\": \"ON\",\n            \"rest_power_path\": \"power.current\",\n            \"rest_energy_path\": \"energy.today\",\n            \"enabled\": True,\n        }\n\n        response = await async_client.post(\"/api/v1/smart-plugs/\", json=data)\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"rest_headers\"] == '{\"Authorization\": \"Bearer test-token\"}'\n        assert result[\"rest_status_url\"] == \"http://ha:8080/api/plug/status\"\n        assert result[\"rest_status_path\"] == \"state\"\n        assert result[\"rest_status_on_value\"] == \"ON\"\n        assert result[\"rest_power_path\"] == \"power.current\"\n        assert result[\"rest_energy_path\"] == \"energy.today\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_rest_plug(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify REST plug fields can be updated.\"\"\"\n        plug = await smart_plug_factory(plug_type=\"rest\")\n\n        response = await async_client.patch(\n            f\"/api/v1/smart-plugs/{plug.id}\",\n            json={\n                \"rest_on_url\": \"http://new-host:8080/on\",\n                \"rest_method\": \"PUT\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"rest_on_url\"] == \"http://new-host:8080/on\"\n        assert result[\"rest_method\"] == \"PUT\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_rest_plug_is_controllable(self, async_client: AsyncClient, smart_plug_factory, db_session):\n        \"\"\"Verify REST plugs can be controlled (not monitor-only like MQTT).\"\"\"\n        plug = await smart_plug_factory(plug_type=\"rest\")\n\n        # REST plugs should NOT return 400 like MQTT plugs\n        response = await async_client.post(\n            f\"/api/v1/smart-plugs/{plug.id}/control\",\n            json={\"action\": \"on\"},\n        )\n\n        # Should attempt to send the request (may fail with 503 since URL is not real,\n        # but should NOT return 400 \"monitor-only\")\n        assert response.status_code != 400\n"
  },
  {
    "path": "backend/tests/integration/test_spoolbuddy.py",
    "content": "\"\"\"Integration tests for SpoolBuddy API endpoints.\"\"\"\n\nfrom datetime import datetime, timedelta, timezone\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.api.routes import spoolbuddy as spoolbuddy_routes\nfrom backend.app.models.spool import Spool\nfrom backend.app.models.spoolbuddy_device import SpoolBuddyDevice\n\nAPI = \"/api/v1/spoolbuddy\"\n\n\n@pytest.fixture\ndef device_factory(db_session: AsyncSession):\n    \"\"\"Factory to create SpoolBuddyDevice records.\"\"\"\n    _counter = [0]\n\n    async def _create(**kwargs):\n        _counter[0] += 1\n        n = _counter[0]\n        defaults = {\n            \"device_id\": f\"sb-{n:04d}\",\n            \"hostname\": f\"spoolbuddy-{n}\",\n            \"ip_address\": f\"10.0.0.{n}\",\n            \"firmware_version\": \"1.0.0\",\n            \"has_nfc\": True,\n            \"has_scale\": True,\n            \"tare_offset\": 0,\n            \"calibration_factor\": 1.0,\n            \"last_seen\": datetime.now(timezone.utc),\n        }\n        defaults.update(kwargs)\n        device = SpoolBuddyDevice(**defaults)\n        db_session.add(device)\n        await db_session.commit()\n        await db_session.refresh(device)\n        return device\n\n    return _create\n\n\n@pytest.fixture\ndef spool_factory(db_session: AsyncSession):\n    \"\"\"Factory to create Spool records.\"\"\"\n    _counter = [0]\n\n    async def _create(**kwargs):\n        _counter[0] += 1\n        defaults = {\n            \"material\": \"PLA\",\n            \"subtype\": \"Basic\",\n            \"brand\": \"Polymaker\",\n            \"color_name\": \"Red\",\n            \"rgba\": \"FF0000FF\",\n            \"label_weight\": 1000,\n            \"core_weight\": 250,\n            \"weight_used\": 0,\n        }\n        defaults.update(kwargs)\n        spool = Spool(**defaults)\n        db_session.add(spool)\n        await db_session.commit()\n        await db_session.refresh(spool)\n        return spool\n\n    return _create\n\n\n# ============================================================================\n# Device endpoints\n# ============================================================================\n\n\nclass TestDeviceEndpoints:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_register_new_device(self, async_client: AsyncClient):\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/register\",\n                json={\n                    \"device_id\": \"sb-new\",\n                    \"hostname\": \"spoolbuddy-new\",\n                    \"ip_address\": \"10.0.0.99\",\n                    \"firmware_version\": \"1.2.0\",\n                },\n            )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"device_id\"] == \"sb-new\"\n        assert data[\"hostname\"] == \"spoolbuddy-new\"\n        assert data[\"online\"] is True\n        mock_ws.broadcast.assert_called_once()\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_online\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):\n        device = await device_factory(\n            device_id=\"sb-exist\",\n            tare_offset=12345,\n            calibration_factor=0.0042,\n        )\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/register\",\n                json={\n                    \"device_id\": \"sb-exist\",\n                    \"hostname\": \"updated-host\",\n                    \"ip_address\": \"10.0.0.200\",\n                    \"firmware_version\": \"2.0.0\",\n                },\n            )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"id\"] == device.id\n        assert data[\"hostname\"] == \"updated-host\"\n        assert data[\"ip_address\"] == \"10.0.0.200\"\n        assert data[\"firmware_version\"] == \"2.0.0\"\n        # Calibration preserved on re-register\n        assert data[\"tare_offset\"] == 12345\n        assert data[\"calibration_factor\"] == pytest.approx(0.0042)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_devices_empty(self, async_client: AsyncClient):\n        resp = await async_client.get(f\"{API}/devices\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_devices(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-a\", hostname=\"alpha\")\n        await device_factory(device_id=\"sb-b\", hostname=\"beta\")\n\n        resp = await async_client.get(f\"{API}/devices\")\n        assert resp.status_code == 200\n        devices = resp.json()\n        assert len(devices) == 2\n        hostnames = {d[\"hostname\"] for d in devices}\n        assert hostnames == {\"alpha\", \"beta\"}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_unregister_device(self, async_client: AsyncClient, device_factory, db_session):\n        await device_factory(device_id=\"sb-keep\", hostname=\"keep\")\n        await device_factory(device_id=\"sb-drop\", hostname=\"drop\")\n        spoolbuddy_routes._spoolbuddy_online_last_broadcast[\"sb-drop\"] = 123.0\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.delete(f\"{API}/devices/sb-drop\")\n\n        assert resp.status_code == 200\n        assert resp.json() == {\"status\": \"deleted\", \"device_id\": \"sb-drop\"}\n        assert \"sb-drop\" not in spoolbuddy_routes._spoolbuddy_online_last_broadcast\n        mock_ws.broadcast.assert_called_once()\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_unregistered\"\n        assert msg[\"device_id\"] == \"sb-drop\"\n\n        # Other device still present\n        resp = await async_client.get(f\"{API}/devices\")\n        remaining = {d[\"device_id\"] for d in resp.json()}\n        assert remaining == {\"sb-keep\"}\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_unregister_device_not_found(self, async_client: AsyncClient):\n        resp = await async_client.delete(f\"{API}/devices/sb-ghost\")\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):\n        device = await device_factory(device_id=\"sb-hb\")\n        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/sb-hb/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 600},\n            )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"tare_offset\"] == device.tare_offset\n        assert data[\"calibration_factor\"] == pytest.approx(device.calibration_factor)\n        mock_ws.broadcast.assert_called_once()\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_online\"\n        assert msg[\"device_id\"] == \"sb-hb\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-cmd\", pending_command=\"tare\")\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/sb-cmd/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 10},\n            )\n\n        assert resp.status_code == 200\n        assert resp.json()[\"pending_command\"] == \"tare\"\n\n        # Second heartbeat should have no pending command (cleared)\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp2 = await async_client.post(\n                f\"{API}/devices/sb-cmd/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 20},\n            )\n\n        assert resp2.json()[\"pending_command\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/nonexistent/heartbeat\",\n                json={\"nfc_ok\": False, \"scale_ok\": False, \"uptime_s\": 0},\n            )\n\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):\n        # Create device with last_seen far in the past (offline)\n        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()\n        await device_factory(\n            device_id=\"sb-offline\",\n            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),\n        )\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/sb-offline/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 5},\n            )\n\n        assert resp.status_code == 200\n        # Should broadcast online since device was offline\n        mock_ws.broadcast.assert_called_once()\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_online\"\n        assert msg[\"device_id\"] == \"sb-offline\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):\n        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()\n        await device_factory(\n            device_id=\"sb-already-online\",\n            last_seen=datetime.now(timezone.utc),\n        )\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/sb-already-online/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 42},\n            )\n\n        assert resp.status_code == 200\n        mock_ws.broadcast.assert_called_once()\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_online\"\n        assert msg[\"device_id\"] == \"sb-already-online\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):\n        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()\n        await device_factory(\n            device_id=\"sb-throttle\",\n            last_seen=datetime.now(timezone.utc),\n        )\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp1 = await async_client.post(\n                f\"{API}/devices/sb-throttle/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 10},\n            )\n            resp2 = await async_client.post(\n                f\"{API}/devices/sb-throttle/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 11},\n            )\n\n        assert resp1.status_code == 200\n        assert resp2.status_code == 200\n        mock_ws.broadcast.assert_called_once()\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_online\"\n        assert msg[\"device_id\"] == \"sb-throttle\"\n\n\n# ============================================================================\n# NFC endpoints\n# ============================================================================\n\n\nclass TestNfcEndpoints:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):\n        spool = await spool_factory(tag_uid=\"AABB1122\", material=\"PLA\")\n        mock_spool = MagicMock()\n        mock_spool.id = spool.id\n        mock_spool.material = spool.material\n        mock_spool.subtype = spool.subtype\n        mock_spool.color_name = spool.color_name\n        mock_spool.rgba = spool.rgba\n        mock_spool.brand = spool.brand\n        mock_spool.label_weight = spool.label_weight\n        mock_spool.core_weight = spool.core_weight\n        mock_spool.weight_used = spool.weight_used\n\n        with (\n            patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws,\n            patch(\"backend.app.api.routes.spoolbuddy.get_spool_by_tag\", new_callable=AsyncMock) as mock_lookup,\n        ):\n            mock_ws.broadcast = AsyncMock()\n            mock_lookup.return_value = mock_spool\n\n            resp = await async_client.post(\n                f\"{API}/nfc/tag-scanned\",\n                json={\"device_id\": \"sb-1\", \"tag_uid\": \"AABB1122\"},\n            )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"matched\"] is True\n        assert data[\"spool_id\"] == spool.id\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_tag_matched\"\n        assert msg[\"spool\"][\"id\"] == spool.id\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tag_scanned_unmatched(self, async_client: AsyncClient):\n        with (\n            patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws,\n            patch(\"backend.app.api.routes.spoolbuddy.get_spool_by_tag\", new_callable=AsyncMock) as mock_lookup,\n        ):\n            mock_ws.broadcast = AsyncMock()\n            mock_lookup.return_value = None\n\n            resp = await async_client.post(\n                f\"{API}/nfc/tag-scanned\",\n                json={\"device_id\": \"sb-1\", \"tag_uid\": \"DEADBEEF\"},\n            )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"matched\"] is False\n        assert data[\"spool_id\"] is None\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_unknown_tag\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tag_removed(self, async_client: AsyncClient):\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/nfc/tag-removed\",\n                json={\"device_id\": \"sb-1\", \"tag_uid\": \"AABB1122\"},\n            )\n\n        assert resp.status_code == 200\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_tag_removed\"\n        assert msg[\"device_id\"] == \"sb-1\"\n        assert msg[\"tag_uid\"] == \"AABB1122\"\n\n\n# ============================================================================\n# NFC write-tag endpoints\n# ============================================================================\n\n\nclass TestWriteTagEndpoints:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_write_tag_queues_command(self, async_client: AsyncClient, device_factory, spool_factory):\n        device = await device_factory(device_id=\"sb-wt\")\n        spool = await spool_factory(material=\"PLA\", brand=\"Polymaker\", color_name=\"Red\", rgba=\"FF0000FF\")\n\n        resp = await async_client.post(\n            f\"{API}/nfc/write-tag\",\n            json={\"device_id\": device.device_id, \"spool_id\": spool.id},\n        )\n\n        assert resp.status_code == 200\n        assert resp.json()[\"status\"] == \"queued\"\n\n        # Verify heartbeat returns write_tag command with payload\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb = await async_client.post(\n                f\"{API}/devices/{device.device_id}/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 10},\n            )\n\n        hb_data = hb.json()\n        assert hb_data[\"pending_command\"] == \"write_tag\"\n        assert hb_data[\"pending_write_payload\"] is not None\n        assert hb_data[\"pending_write_payload\"][\"spool_id\"] == spool.id\n        assert \"ndef_data_hex\" in hb_data[\"pending_write_payload\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_write_tag_heartbeat_not_cleared(self, async_client: AsyncClient, device_factory, spool_factory):\n        \"\"\"write_tag command persists across heartbeats until write-result clears it.\"\"\"\n        device = await device_factory(device_id=\"sb-wt-persist\")\n        spool = await spool_factory(material=\"PETG\")\n\n        await async_client.post(\n            f\"{API}/nfc/write-tag\",\n            json={\"device_id\": device.device_id, \"spool_id\": spool.id},\n        )\n\n        # First heartbeat — command present\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb1 = await async_client.post(\n                f\"{API}/devices/{device.device_id}/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 10},\n            )\n        assert hb1.json()[\"pending_command\"] == \"write_tag\"\n\n        # Second heartbeat — should still be present (not cleared like tare)\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb2 = await async_client.post(\n                f\"{API}/devices/{device.device_id}/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 20},\n            )\n        assert hb2.json()[\"pending_command\"] == \"write_tag\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_write_tag_missing_spool_404(self, async_client: AsyncClient, device_factory):\n        device = await device_factory(device_id=\"sb-wt-nospool\")\n\n        resp = await async_client.post(\n            f\"{API}/nfc/write-tag\",\n            json={\"device_id\": device.device_id, \"spool_id\": 99999},\n        )\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_write_tag_missing_device_404(self, async_client: AsyncClient, spool_factory):\n        spool = await spool_factory()\n\n        resp = await async_client.post(\n            f\"{API}/nfc/write-tag\",\n            json={\"device_id\": \"nonexistent\", \"spool_id\": spool.id},\n        )\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_write_result_success_links_tag(self, async_client: AsyncClient, device_factory, spool_factory):\n        device = await device_factory(device_id=\"sb-wr\", pending_command=\"write_tag\")\n        spool = await spool_factory(material=\"PLA\", tag_uid=None)\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/nfc/write-result\",\n                json={\n                    \"device_id\": device.device_id,\n                    \"spool_id\": spool.id,\n                    \"tag_uid\": \"04AABB11223344\",\n                    \"success\": True,\n                },\n            )\n\n        assert resp.status_code == 200\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_tag_written\"\n        assert msg[\"spool_id\"] == spool.id\n        assert msg[\"tag_uid\"] == \"04AABB11223344\"\n\n        # Verify spool got tag linked\n        spool_resp = await async_client.get(f\"/api/v1/inventory/spools/{spool.id}\")\n        spool_data = spool_resp.json()\n        assert spool_data[\"tag_uid\"] == \"04AABB11223344\"\n        assert spool_data[\"tag_type\"] == \"ntag\"\n        assert spool_data[\"data_origin\"] == \"opentag3d\"\n        assert spool_data[\"encode_time\"] is not None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_write_result_failure_broadcasts_error(\n        self, async_client: AsyncClient, device_factory, spool_factory\n    ):\n        device = await device_factory(device_id=\"sb-wr-fail\", pending_command=\"write_tag\")\n        spool = await spool_factory(material=\"PLA\", tag_uid=None)\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/nfc/write-result\",\n                json={\n                    \"device_id\": device.device_id,\n                    \"spool_id\": spool.id,\n                    \"tag_uid\": \"04AABB\",\n                    \"success\": False,\n                    \"message\": \"Write or verification failed\",\n                },\n            )\n\n        assert resp.status_code == 200\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_tag_write_failed\"\n        assert msg[\"message\"] == \"Write or verification failed\"\n\n        # Verify spool NOT linked\n        spool_resp = await async_client.get(f\"/api/v1/inventory/spools/{spool.id}\")\n        assert spool_resp.json()[\"tag_uid\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_write_result_clears_pending_command(self, async_client: AsyncClient, device_factory, spool_factory):\n        device = await device_factory(\n            device_id=\"sb-wr-clear\",\n            pending_command=\"write_tag\",\n            pending_write_payload='{\"spool_id\": 1, \"ndef_data_hex\": \"E110120003\"}',\n        )\n        spool = await spool_factory()\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            await async_client.post(\n                f\"{API}/nfc/write-result\",\n                json={\n                    \"device_id\": device.device_id,\n                    \"spool_id\": spool.id,\n                    \"tag_uid\": \"AABB\",\n                    \"success\": True,\n                },\n            )\n\n        # Heartbeat should have no pending command\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb = await async_client.post(\n                f\"{API}/devices/{device.device_id}/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 30},\n            )\n        assert hb.json()[\"pending_command\"] is None\n        assert hb.json()[\"pending_write_payload\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cancel_write(self, async_client: AsyncClient, device_factory, spool_factory):\n        device = await device_factory(device_id=\"sb-cancel\")\n        spool = await spool_factory()\n\n        # Queue a write\n        await async_client.post(\n            f\"{API}/nfc/write-tag\",\n            json={\"device_id\": device.device_id, \"spool_id\": spool.id},\n        )\n\n        # Cancel it\n        resp = await async_client.post(f\"{API}/devices/{device.device_id}/cancel-write\", json={})\n        assert resp.status_code == 200\n\n        # Heartbeat should have no pending command\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb = await async_client.post(\n                f\"{API}/devices/{device.device_id}/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 10},\n            )\n        assert hb.json()[\"pending_command\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_cancel_write_unknown_device_404(self, async_client: AsyncClient):\n        resp = await async_client.post(f\"{API}/devices/ghost/cancel-write\", json={})\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_write_tag_ndef_data_is_valid(self, async_client: AsyncClient, device_factory, spool_factory):\n        \"\"\"Verify the NDEF data in the heartbeat is a valid OpenTag3D message.\"\"\"\n        device = await device_factory(device_id=\"sb-wt-ndef\")\n        spool = await spool_factory(\n            material=\"PLA\",\n            brand=\"Polymaker\",\n            color_name=\"White\",\n            rgba=\"FFFFFFFF\",\n            label_weight=1000,\n        )\n\n        await async_client.post(\n            f\"{API}/nfc/write-tag\",\n            json={\"device_id\": device.device_id, \"spool_id\": spool.id},\n        )\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb = await async_client.post(\n                f\"{API}/devices/{device.device_id}/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 10},\n            )\n\n        payload = hb.json()[\"pending_write_payload\"]\n        ndef_bytes = bytes.fromhex(payload[\"ndef_data_hex\"])\n\n        # CC bytes\n        assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])\n        # TLV type\n        assert ndef_bytes[4] == 0x03\n        # NDEF record: TNF=MIME, type=application/opentag3d\n        assert ndef_bytes[6] == 0xD2\n        assert ndef_bytes[9:30] == b\"application/opentag3d\"\n        # Terminator\n        assert ndef_bytes[-1] == 0xFE\n        # Total size fits NTAG213\n        assert len(ndef_bytes) <= 144\n\n\n# ============================================================================\n# Scale endpoints\n# ============================================================================\n\n\nclass TestScaleEndpoints:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_scale_reading_broadcast(self, async_client: AsyncClient):\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/scale/reading\",\n                json={\n                    \"device_id\": \"sb-1\",\n                    \"weight_grams\": 823.5,\n                    \"stable\": True,\n                    \"raw_adc\": 456789,\n                },\n            )\n\n        assert resp.status_code == 200\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_weight\"\n        assert msg[\"device_id\"] == \"sb-1\"\n        assert msg[\"weight_grams\"] == 823.5\n        assert msg[\"stable\"] is True\n        assert msg[\"raw_adc\"] == 456789\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):\n        # label=1000g, core=250g, scale reads 750g\n        # net_filament = max(0, 750 - 250) = 500\n        # weight_used = max(0, 1000 - 500) = 500\n        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)\n\n        resp = await async_client.post(\n            f\"{API}/scale/update-spool-weight\",\n            json={\"spool_id\": spool.id, \"weight_grams\": 750},\n        )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"weight_used\"] == 500\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):\n        # label=1000g, core=250g, scale reads 1250g (full spool)\n        # net_filament = max(0, 1250 - 250) = 1000\n        # weight_used = max(0, 1000 - 1000) = 0\n        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)\n\n        resp = await async_client.post(\n            f\"{API}/scale/update-spool-weight\",\n            json={\"spool_id\": spool.id, \"weight_grams\": 1250},\n        )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"weight_used\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):\n        \"\"\"Verify last_scale_weight and last_weighed_at are stored after weight sync.\"\"\"\n        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)\n\n        resp = await async_client.post(\n            f\"{API}/scale/update-spool-weight\",\n            json={\"spool_id\": spool.id, \"weight_grams\": 750},\n        )\n        assert resp.status_code == 200\n\n        # Fetch the spool via inventory API to verify stored fields\n        spool_resp = await async_client.get(f\"/api/v1/inventory/spools/{spool.id}\")\n        assert spool_resp.status_code == 200\n        spool_data = spool_resp.json()\n        assert spool_data[\"last_scale_weight\"] == 750\n        assert spool_data[\"last_weighed_at\"] is not None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):\n        resp = await async_client.post(\n            f\"{API}/scale/update-spool-weight\",\n            json={\"spool_id\": 99999, \"weight_grams\": 500},\n        )\n        assert resp.status_code == 404\n\n\n# ============================================================================\n# Calibration endpoints\n# ============================================================================\n\n\nclass TestCalibrationEndpoints:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-tare\")\n\n        resp = await async_client.post(f\"{API}/devices/sb-tare/calibration/tare\", json={})\n        assert resp.status_code == 200\n        assert resp.json()[\"status\"] == \"ok\"\n\n        # Verify pending_command via heartbeat\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb = await async_client.post(\n                f\"{API}/devices/sb-tare/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 1},\n            )\n        assert hb.json()[\"pending_command\"] == \"tare\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_tare_unknown_device_404(self, async_client: AsyncClient):\n        resp = await async_client.post(f\"{API}/devices/ghost/calibration/tare\", json={})\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-st\", calibration_factor=0.005)\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-st/calibration/set-tare\",\n            json={\"tare_offset\": 54321},\n        )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"tare_offset\"] == 54321\n        assert data[\"calibration_factor\"] == pytest.approx(0.005)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):\n        # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005\n        await device_factory(device_id=\"sb-cf\", tare_offset=10000)\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-cf/calibration/set-factor\",\n            json={\"known_weight_grams\": 200, \"raw_adc\": 50000},\n        )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"calibration_factor\"] == pytest.approx(0.005)\n        assert data[\"tare_offset\"] == 10000\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):\n        # raw_adc == tare_offset → delta is 0 → 400 error\n        await device_factory(device_id=\"sb-zero\", tare_offset=5000)\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-zero/calibration/set-factor\",\n            json={\"known_weight_grams\": 100, \"raw_adc\": 5000},\n        )\n\n        assert resp.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_calibration(self, async_client: AsyncClient, device_factory):\n        await device_factory(\n            device_id=\"sb-gcal\",\n            tare_offset=11111,\n            calibration_factor=0.0042,\n        )\n\n        resp = await async_client.get(f\"{API}/devices/sb-gcal/calibration\")\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"tare_offset\"] == 11111\n        assert data[\"calibration_factor\"] == pytest.approx(0.0042)\n\n\n# ============================================================================\n# Display endpoints\n# ============================================================================\n\n\nclass TestDisplayEndpoints:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_display_settings(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-disp\", display_brightness=100, display_blank_timeout=0)\n\n        resp = await async_client.put(\n            f\"{API}/devices/sb-disp/display\",\n            json={\"brightness\": 75, \"blank_timeout\": 300},\n        )\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"brightness\"] == 75\n        assert data[\"blank_timeout\"] == 300\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-disp-hb\")\n\n        await async_client.put(\n            f\"{API}/devices/sb-disp-hb/display\",\n            json={\"brightness\": 50, \"blank_timeout\": 600},\n        )\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb = await async_client.post(\n                f\"{API}/devices/sb-disp-hb/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 10},\n            )\n\n        assert hb.json()[\"display_brightness\"] == 50\n        assert hb.json()[\"display_blank_timeout\"] == 600\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_display_unknown_device_404(self, async_client: AsyncClient):\n        resp = await async_client.put(\n            f\"{API}/devices/ghost/display\",\n            json={\"brightness\": 50, \"blank_timeout\": 60},\n        )\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-disp-val\")\n\n        resp = await async_client.put(\n            f\"{API}/devices/sb-disp-val/display\",\n            json={\"brightness\": 150, \"blank_timeout\": 0},\n        )\n        assert resp.status_code == 422  # Validation error: brightness > 100\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_display_settings(self, async_client: AsyncClient, device_factory):\n        \"\"\"The kiosk idle watchdog (install/spoolbuddy-idle.sh) reads this\n        endpoint on autostart to configure swayidle with the user-selected\n        blank timeout before launching. See issue #937.\"\"\"\n        await device_factory(device_id=\"sb-disp-get\", display_brightness=60, display_blank_timeout=450)\n\n        resp = await async_client.get(f\"{API}/devices/sb-disp-get/display\")\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"brightness\"] == 60\n        assert data[\"blank_timeout\"] == 450\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_display_unknown_device_404(self, async_client: AsyncClient):\n        resp = await async_client.get(f\"{API}/devices/ghost/display\")\n        assert resp.status_code == 404\n\n\n# ============================================================================\n# Update endpoints\n# ============================================================================\n\n\nclass TestUpdateEndpoints:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_trigger_update_starts_ssh_update(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-upd\")\n\n        with (\n            patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws,\n            patch(\"backend.app.services.spoolbuddy_ssh.perform_ssh_update\", new_callable=AsyncMock),\n        ):\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(f\"{API}/devices/sb-upd/update\")\n\n        assert resp.status_code == 200\n        assert resp.json()[\"status\"] == \"ok\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):\n        await device_factory(\n            device_id=\"sb-upd-off\",\n            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),\n        )\n\n        resp = await async_client.post(f\"{API}/devices/sb-upd-off/update\")\n        assert resp.status_code == 409\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):\n        resp = await async_client.post(f\"{API}/devices/ghost/update\")\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-upd-dup\", update_status=\"updating\")\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(f\"{API}/devices/sb-upd-dup/update\")\n\n        assert resp.status_code == 200\n        assert resp.json()[\"status\"] == \"already_updating\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-upd-st\", pending_command=\"update\", update_status=\"pending\")\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/sb-upd-st/update-status\",\n                json={\"status\": \"updating\", \"message\": \"Fetching latest code...\"},\n            )\n\n        assert resp.status_code == 200\n        mock_ws.broadcast.assert_called_once()\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_update\"\n        assert msg[\"update_status\"] == \"updating\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-upd-done\", pending_command=\"update\", update_status=\"updating\")\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            await async_client.post(\n                f\"{API}/devices/sb-upd-done/update-status\",\n                json={\"status\": \"complete\", \"message\": \"Update complete, restarting...\"},\n            )\n\n        # Heartbeat should have no pending command\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            hb = await async_client.post(\n                f\"{API}/devices/sb-upd-done/heartbeat\",\n                json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 10},\n            )\n\n        assert hb.json()[\"pending_command\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-upd-err\", pending_command=\"update\", update_status=\"updating\")\n\n        with patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws:\n            mock_ws.broadcast = AsyncMock()\n            resp = await async_client.post(\n                f\"{API}/devices/sb-upd-err/update-status\",\n                json={\"status\": \"error\", \"message\": \"git fetch failed: network unreachable\"},\n            )\n\n        assert resp.status_code == 200\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"update_status\"] == \"error\"\n        assert \"git fetch failed\" in msg[\"update_message\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):\n        resp = await async_client.post(\n            f\"{API}/devices/ghost/update-status\",\n            json={\"status\": \"updating\", \"message\": \"test\"},\n        )\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-upd-resp\", update_status=\"complete\", update_message=\"Done!\")\n\n        resp = await async_client.get(f\"{API}/devices\")\n        assert resp.status_code == 200\n        device = next(d for d in resp.json() if d[\"device_id\"] == \"sb-upd-resp\")\n        assert device[\"update_status\"] == \"complete\"\n        assert device[\"update_message\"] == \"Done!\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):\n        \"\"\"GET /devices/{id}/update-check compares device version against APP_VERSION.\"\"\"\n        await device_factory(device_id=\"sb-uc\", firmware_version=\"0.1.0\")\n\n        resp = await async_client.get(f\"{API}/devices/sb-uc/update-check\")\n\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"current_version\"] == \"0.1.0\"\n        assert data[\"latest_version\"] is not None\n        assert data[\"update_available\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):\n        from backend.app.core.config import APP_VERSION\n\n        await device_factory(device_id=\"sb-uc2\", firmware_version=APP_VERSION)\n\n        resp = await async_client.get(f\"{API}/devices/sb-uc2/update-check\")\n\n        assert resp.status_code == 200\n        assert resp.json()[\"update_available\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_check_unknown_device_404(self, async_client: AsyncClient):\n        resp = await async_client.get(f\"{API}/devices/ghost/update-check\")\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-upd-ws\")\n\n        with (\n            patch(\"backend.app.api.routes.spoolbuddy.ws_manager\") as mock_ws,\n            patch(\"backend.app.services.spoolbuddy_ssh.perform_ssh_update\", new_callable=AsyncMock),\n        ):\n            mock_ws.broadcast = AsyncMock()\n            await async_client.post(f\"{API}/devices/sb-upd-ws/update\")\n\n        mock_ws.broadcast.assert_called_once()\n        msg = mock_ws.broadcast.call_args[0][0]\n        assert msg[\"type\"] == \"spoolbuddy_update\"\n        assert msg[\"device_id\"] == \"sb-upd-ws\"\n        assert msg[\"update_status\"] == \"pending\"\n\n\n# ============================================================================\n# System command endpoints\n# ============================================================================\n\n\nclass TestSystemCommandEndpoints:\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_reboot(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-reboot\")\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-reboot/system/command\",\n            json={\"command\": \"reboot\"},\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"status\"] == \"queued\"\n        assert data[\"command\"] == \"reboot\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_shutdown(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-shutdown\")\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-shutdown/system/command\",\n            json={\"command\": \"shutdown\"},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"command\"] == \"shutdown\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_restart_daemon(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-rd\")\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-rd/system/command\",\n            json={\"command\": \"restart_daemon\"},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"command\"] == \"restart_daemon\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_queue_restart_browser(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-rb\")\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-rb/system/command\",\n            json={\"command\": \"restart_browser\"},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"command\"] == \"restart_browser\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_invalid_command_rejected(self, async_client: AsyncClient, device_factory):\n        await device_factory(device_id=\"sb-invalid\")\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-invalid/system/command\",\n            json={\"command\": \"format_disk\"},\n        )\n        assert resp.status_code == 400\n        assert \"Invalid command\" in resp.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_command_unknown_device_404(self, async_client: AsyncClient):\n        resp = await async_client.post(\n            f\"{API}/devices/ghost/system/command\",\n            json={\"command\": \"reboot\"},\n        )\n        assert resp.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_command_offline_device_409(self, async_client: AsyncClient, device_factory):\n        await device_factory(\n            device_id=\"sb-offline-cmd\",\n            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),\n        )\n\n        resp = await async_client.post(\n            f\"{API}/devices/sb-offline-cmd/system/command\",\n            json={\"command\": \"reboot\"},\n        )\n        assert resp.status_code == 409\n        assert \"offline\" in resp.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_command_sets_pending_command(self, async_client: AsyncClient, device_factory, db_session):\n        device = await device_factory(device_id=\"sb-pending\")\n\n        await async_client.post(\n            f\"{API}/devices/sb-pending/system/command\",\n            json={\"command\": \"restart_daemon\"},\n        )\n\n        await db_session.refresh(device)\n        assert device.pending_command == \"restart_daemon\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_heartbeat_clears_system_command(self, async_client: AsyncClient, device_factory):\n        \"\"\"System commands (reboot/shutdown/restart_*) are fire-and-forget — heartbeat clears them.\"\"\"\n        await device_factory(device_id=\"sb-hb-clear\")\n\n        # Queue a command\n        await async_client.post(\n            f\"{API}/devices/sb-hb-clear/system/command\",\n            json={\"command\": \"restart_browser\"},\n        )\n\n        # Heartbeat should return the command and clear it\n        resp = await async_client.post(\n            f\"{API}/devices/sb-hb-clear/heartbeat\",\n            json={\"nfc_ok\": True, \"scale_ok\": True, \"uptime_s\": 100},\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"pending_command\"] == \"restart_browser\"\n"
  },
  {
    "path": "backend/tests/integration/test_spoolman_api.py",
    "content": "\"\"\"Integration tests for Spoolman API endpoints.\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestSpoolmanAPI:\n    \"\"\"Integration tests for /api/v1/spoolman/ endpoints.\"\"\"\n\n    @pytest.fixture\n    async def spoolman_settings(self, db_session):\n        \"\"\"Create Spoolman settings in the database (enabled with URL).\"\"\"\n        from backend.app.models.settings import Settings\n\n        # Both settings are required for Spoolman to work\n        enabled_setting = Settings(key=\"spoolman_enabled\", value=\"true\")\n        url_setting = Settings(key=\"spoolman_url\", value=\"http://localhost:7912\")\n        db_session.add(enabled_setting)\n        db_session.add(url_setting)\n        await db_session.commit()\n        return {\"enabled\": enabled_setting, \"url\": url_setting}\n\n    @pytest.fixture\n    async def spoolman_url_only(self, db_session):\n        \"\"\"Create only the URL setting (not enabled).\"\"\"\n        from backend.app.models.settings import Settings\n\n        setting = Settings(key=\"spoolman_url\", value=\"http://localhost:7912\")\n        db_session.add(setting)\n        await db_session.commit()\n        return setting\n\n    @pytest.fixture\n    def mock_spoolman_client(self):\n        \"\"\"Mock the Spoolman client functions.\"\"\"\n        mock_client = MagicMock()\n        mock_client.is_connected = True\n        mock_client.base_url = \"http://localhost:7912\"\n        mock_client.health_check = AsyncMock(return_value=True)\n        mock_client.ensure_tag_extra_field = AsyncMock(return_value=True)\n        mock_client.get_spools = AsyncMock(return_value=[])\n        mock_client.get_filaments = AsyncMock(return_value=[])\n        mock_client.create_spool = AsyncMock(return_value={\"id\": 1})\n        mock_client.update_spool = AsyncMock(return_value={\"id\": 1})\n        mock_client.close = AsyncMock()\n\n        with (\n            patch(\n                \"backend.app.api.routes.spoolman.get_spoolman_client\",\n                AsyncMock(return_value=mock_client),\n            ),\n            patch(\n                \"backend.app.api.routes.spoolman.init_spoolman_client\",\n                AsyncMock(return_value=mock_client),\n            ),\n            patch(\n                \"backend.app.api.routes.spoolman.close_spoolman_client\",\n                AsyncMock(),\n            ),\n        ):\n            yield mock_client\n\n    @pytest.fixture\n    def mock_spoolman_disconnected(self):\n        \"\"\"Mock the Spoolman client as disconnected (returns None).\"\"\"\n        with (\n            patch(\n                \"backend.app.api.routes.spoolman.get_spoolman_client\",\n                AsyncMock(return_value=None),\n            ),\n            patch(\n                \"backend.app.api.routes.spoolman.init_spoolman_client\",\n                AsyncMock(return_value=None),\n            ),\n        ):\n            yield\n\n    # =========================================================================\n    # Status Endpoint Tests\n    # =========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_status_not_configured(self, async_client: AsyncClient):\n        \"\"\"Verify status shows not enabled when no settings exist.\"\"\"\n        response = await async_client.get(\"/api/v1/spoolman/status\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"enabled\"] is False\n        assert data[\"connected\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_status_url_only_not_enabled(self, async_client: AsyncClient, spoolman_url_only):\n        \"\"\"Verify status shows not enabled when only URL is set.\"\"\"\n        response = await async_client.get(\"/api/v1/spoolman/status\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"enabled\"] is False\n        assert data[\"url\"] == \"http://localhost:7912\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_status_enabled_and_connected(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify status shows enabled and connected when properly configured.\"\"\"\n        response = await async_client.get(\"/api/v1/spoolman/status\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"enabled\"] is True\n        assert data[\"connected\"] is True\n        assert data[\"url\"] == \"http://localhost:7912\"\n\n    # =========================================================================\n    # Connect/Disconnect Tests\n    # =========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_connect_not_enabled(self, async_client: AsyncClient):\n        \"\"\"Verify connect fails when not enabled.\"\"\"\n        response = await async_client.post(\"/api/v1/spoolman/connect\")\n        assert response.status_code == 400\n        assert \"not enabled\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_connect_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):\n        \"\"\"Verify successful connection to Spoolman.\"\"\"\n        response = await async_client.post(\"/api/v1/spoolman/connect\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"success\"] is True\n        assert \"connected\" in data[\"message\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disconnect(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):\n        \"\"\"Verify disconnect works.\"\"\"\n        response = await async_client.post(\"/api/v1/spoolman/disconnect\")\n        assert response.status_code == 200\n        assert \"disconnected\" in response.json()[\"message\"].lower()\n\n    # =========================================================================\n    # Spools Endpoint Tests\n    # =========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_spools_not_enabled(self, async_client: AsyncClient):\n        \"\"\"Verify get spools fails when not enabled.\"\"\"\n        response = await async_client.get(\"/api/v1/spoolman/spools\")\n        assert response.status_code == 400\n        assert \"not enabled\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_spools_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):\n        \"\"\"Verify get spools returns data in expected format.\"\"\"\n        mock_spool = {\n            \"id\": 1,\n            \"remaining_weight\": 500,\n            \"used_weight\": 500,\n            \"filament\": {\n                \"id\": 1,\n                \"name\": \"PLA Basic\",\n                \"material\": \"PLA\",\n                \"color_hex\": \"FF0000\",\n            },\n            \"first_used\": \"2024-01-01\",\n            \"last_used\": \"2024-01-15\",\n            \"location\": \"AMS1\",\n            \"lot_nr\": \"LOT123\",\n            \"comment\": \"Test spool\",\n            \"extra\": {\"tag\": '\"ABC123\"'},\n        }\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools\")\n        assert response.status_code == 200\n        data = response.json()\n        assert \"spools\" in data\n        assert isinstance(data[\"spools\"], list)\n        assert len(data[\"spools\"]) == 1\n        assert data[\"spools\"][0][\"id\"] == 1\n\n    # =========================================================================\n    # Unlinked Spools Tests\n    # =========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_unlinked_spools_not_enabled(self, async_client: AsyncClient):\n        \"\"\"Verify get unlinked spools fails when not enabled.\"\"\"\n        response = await async_client.get(\"/api/v1/spoolman/spools/unlinked\")\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_unlinked_spools_success(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify get unlinked spools returns spools without tags.\"\"\"\n        # Mock spool without extra.tag (unlinked)\n        mock_spool = {\n            \"id\": 1,\n            \"remaining_weight\": 800,\n            \"used_weight\": 200,\n            \"extra\": {},  # No tag = unlinked\n            \"filament\": {\n                \"id\": 1,\n                \"name\": \"PLA Basic\",\n                \"material\": \"PLA\",\n                \"color_hex\": \"FF0000\",\n            },\n            \"location\": \"Shelf A\",\n        }\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools/unlinked\")\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n        assert len(data) == 1\n        assert data[0][\"id\"] == 1\n        assert data[0][\"filament_name\"] == \"PLA Basic\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_unlinked_spools_excludes_linked(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify linked spools (with tag) are excluded.\"\"\"\n        # Mock spool with extra.tag (linked)\n        mock_spool_linked = {\n            \"id\": 1,\n            \"remaining_weight\": 800,\n            \"used_weight\": 200,\n            \"extra\": {\"tag\": '\"ABC123\"'},  # Has tag = linked\n            \"filament\": {\"id\": 1, \"name\": \"PLA Red\", \"material\": \"PLA\", \"color_hex\": \"FF0000\"},\n        }\n\n        # Mock spool without tag (unlinked)\n        mock_spool_unlinked = {\n            \"id\": 2,\n            \"remaining_weight\": 900,\n            \"used_weight\": 100,\n            \"extra\": {},  # No tag = unlinked\n            \"filament\": {\"id\": 2, \"name\": \"PLA Blue\", \"material\": \"PLA\", \"color_hex\": \"0000FF\"},\n        }\n\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools/unlinked\")\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 1\n        assert data[0][\"id\"] == 2  # Only unlinked spool\n\n    # =========================================================================\n    # Linked Spools Tests\n    # =========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_linked_spools_not_enabled(self, async_client: AsyncClient):\n        \"\"\"Verify get linked spools fails when not enabled.\"\"\"\n        response = await async_client.get(\"/api/v1/spoolman/spools/linked\")\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_linked_spools_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):\n        \"\"\"Verify get linked spools returns map of tag -> spool_id.\"\"\"\n        # Mock spool with extra.tag (linked)\n        mock_spool = {\n            \"id\": 42,\n            \"remaining_weight\": 800,\n            \"extra\": {\"tag\": '\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\"'},\n            \"filament\": {\"id\": 1, \"name\": \"PLA Red\", \"material\": \"PLA\", \"weight\": 1000},\n        }\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools/linked\")\n        assert response.status_code == 200\n        data = response.json()\n        assert \"linked\" in data\n        assert isinstance(data[\"linked\"], dict)\n        # Tag should be uppercase and stripped of quotes\n        assert \"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\" in data[\"linked\"]\n        linked_info = data[\"linked\"][\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\"]\n        assert linked_info[\"id\"] == 42\n        assert linked_info[\"remaining_weight\"] == 800\n        assert linked_info[\"filament_weight\"] == 1000\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_linked_spools_excludes_unlinked(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify unlinked spools (without tag) are excluded.\"\"\"\n        # Mock spool with tag (linked)\n        mock_spool_linked = {\n            \"id\": 1,\n            \"extra\": {\"tag\": '\"ABC12345678901234567890123456789A\"'},\n            \"filament\": {\"id\": 1, \"name\": \"PLA Red\", \"material\": \"PLA\"},\n        }\n        # Mock spool without tag (unlinked)\n        mock_spool_unlinked = {\n            \"id\": 2,\n            \"extra\": {},\n            \"filament\": {\"id\": 2, \"name\": \"PLA Blue\", \"material\": \"PLA\"},\n        }\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools/linked\")\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data[\"linked\"]) == 1  # Only linked spool\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_linked_spools_empty_tag_excluded(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify spools with empty tag (JSON-encoded empty string) are excluded.\"\"\"\n        # Mock spool with empty JSON-encoded tag\n        mock_spool = {\n            \"id\": 1,\n            \"extra\": {\"tag\": '\"\"'},  # JSON-encoded empty string\n            \"filament\": {\"id\": 1, \"name\": \"PLA Red\", \"material\": \"PLA\"},\n        }\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools/linked\")\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data[\"linked\"]) == 0  # Empty tag should be excluded\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_linked_spools_includes_weight_data(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify linked spools response includes remaining_weight and filament_weight.\"\"\"\n        mock_spool = {\n            \"id\": 10,\n            \"remaining_weight\": 500.5,\n            \"extra\": {\"tag\": '\"AABB11223344556677889900AABBCCDD\"'},\n            \"filament\": {\"id\": 1, \"name\": \"PETG Blue\", \"material\": \"PETG\", \"weight\": 750},\n        }\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools/linked\")\n        assert response.status_code == 200\n        data = response.json()\n        info = data[\"linked\"][\"AABB11223344556677889900AABBCCDD\"]\n        assert info[\"id\"] == 10\n        assert info[\"remaining_weight\"] == 500.5\n        assert info[\"filament_weight\"] == 750\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_linked_spools_missing_weight_fields(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify linked spools handles missing weight data gracefully.\"\"\"\n        mock_spool = {\n            \"id\": 5,\n            \"extra\": {\"tag\": '\"CCDD11223344556677889900AABBCCDD\"'},\n            \"filament\": {\"id\": 1, \"name\": \"PLA Red\", \"material\": \"PLA\"},\n        }\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools/linked\")\n        assert response.status_code == 200\n        data = response.json()\n        info = data[\"linked\"][\"CCDD11223344556677889900AABBCCDD\"]\n        assert info[\"id\"] == 5\n        assert info[\"remaining_weight\"] is None\n        assert info[\"filament_weight\"] is None\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_linked_spools_null_filament(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify linked spools handles null filament object.\"\"\"\n        mock_spool = {\n            \"id\": 7,\n            \"remaining_weight\": 300,\n            \"extra\": {\"tag\": '\"EEFF11223344556677889900AABBCCDD\"'},\n            \"filament\": None,\n        }\n        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])\n\n        response = await async_client.get(\"/api/v1/spoolman/spools/linked\")\n        assert response.status_code == 200\n        data = response.json()\n        info = data[\"linked\"][\"EEFF11223344556677889900AABBCCDD\"]\n        assert info[\"id\"] == 7\n        assert info[\"remaining_weight\"] == 300\n        assert info[\"filament_weight\"] is None\n\n    # =========================================================================\n    # Link Spool Tests\n    # =========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_link_spool_not_enabled(self, async_client: AsyncClient):\n        \"\"\"Verify link spool fails when not enabled.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/spoolman/spools/1/link\",\n            json={\"tray_uuid\": \"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\"},\n        )\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_link_spool_invalid_uuid_length(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify link spool fails with invalid UUID length.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/spoolman/spools/1/link\",\n            json={\"tray_uuid\": \"ABC123\"},  # Too short\n        )\n        assert response.status_code == 400\n        assert \"16 or 32 hex characters\" in response.json()[\"detail\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_link_spool_invalid_uuid_format(\n        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client\n    ):\n        \"\"\"Verify link spool fails with non-hex UUID.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/spoolman/spools/1/link\",\n            json={\"tray_uuid\": \"ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ\"},  # Not hex\n        )\n        assert response.status_code == 400\n        assert \"hex\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_link_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):\n        \"\"\"Verify successfully linking a spool to AMS tray.\"\"\"\n        mock_spoolman_client.update_spool = AsyncMock(\n            return_value={\"id\": 1, \"extra\": {\"tag\": '\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\"'}}\n        )\n\n        response = await async_client.post(\n            \"/api/v1/spoolman/spools/1/link\",\n            json={\"tray_uuid\": \"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\"},\n        )\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"success\"] is True\n        assert \"linked\" in data[\"message\"].lower()\n\n        # Verify update_spool was called\n        mock_spoolman_client.update_spool.assert_called_once()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_unlink_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):\n        \"\"\"Verify successfully unlinking a spool clears extra.tag.\"\"\"\n        mock_spoolman_client.update_spool = AsyncMock(return_value={\"id\": 1, \"extra\": {\"tag\": '\"\"'}})\n\n        response = await async_client.post(\"/api/v1/spoolman/spools/1/unlink\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"success\"] is True\n        assert \"unlinked\" in data[\"message\"].lower()\n\n        mock_spoolman_client.update_spool.assert_called_once_with(\n            spool_id=1,\n            clear_location=True,\n            extra={\"tag\": '\"\"'},\n        )\n\n    # =========================================================================\n    # Sync Tests\n    # =========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_sync_printer_not_enabled(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify sync fails when Spoolman not enabled.\"\"\"\n        printer = await printer_factory()\n        response = await async_client.post(f\"/api/v1/spoolman/sync/{printer.id}\")\n        assert response.status_code == 400\n        assert \"not enabled\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_sync_printer_not_found(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):\n        \"\"\"Verify sync fails for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/spoolman/sync/9999\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_sync_returns_result_structure(\n        self,\n        async_client: AsyncClient,\n        spoolman_settings,\n        mock_spoolman_client,\n        printer_factory,\n    ):\n        \"\"\"Verify sync returns proper result structure.\"\"\"\n        printer = await printer_factory()\n\n        # Mock printer manager to return AMS data\n        with patch(\"backend.app.api.routes.spoolman.printer_manager\") as pm_mock:\n            mock_state = MagicMock()\n            mock_state.raw_data = {\"ams\": [{\"id\": 0, \"tray\": []}]}\n            pm_mock.get_status = MagicMock(return_value=mock_state)\n\n            response = await async_client.post(f\"/api/v1/spoolman/sync/{printer.id}\")\n            assert response.status_code == 200\n            data = response.json()\n            # Verify SyncResult structure\n            assert \"success\" in data\n            assert \"synced_count\" in data\n            assert \"skipped_count\" in data\n            assert \"skipped\" in data\n            assert \"errors\" in data\n            assert isinstance(data[\"skipped\"], list)\n            assert isinstance(data[\"errors\"], list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_sync_printer_not_connected(\n        self,\n        async_client: AsyncClient,\n        spoolman_settings,\n        mock_spoolman_client,\n        printer_factory,\n    ):\n        \"\"\"Verify sync fails when printer is not connected (no status).\"\"\"\n        printer = await printer_factory()\n\n        with patch(\"backend.app.api.routes.spoolman.printer_manager\") as pm_mock:\n            pm_mock.get_status = MagicMock(return_value=None)\n\n            response = await async_client.post(f\"/api/v1/spoolman/sync/{printer.id}\")\n            assert response.status_code == 404\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    # =========================================================================\n    # Filaments Endpoint Tests\n    # =========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_filaments_not_enabled(self, async_client: AsyncClient):\n        \"\"\"Verify get filaments fails when not enabled.\"\"\"\n        response = await async_client.get(\"/api/v1/spoolman/filaments\")\n        assert response.status_code == 400\n        assert \"not enabled\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_filaments_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):\n        \"\"\"Verify get filaments returns data in expected format.\"\"\"\n        mock_filament = {\n            \"id\": 1,\n            \"name\": \"PLA Basic\",\n            \"material\": \"PLA\",\n            \"color_hex\": \"FF0000\",\n            \"vendor_id\": 1,\n            \"weight\": 1000,\n        }\n        mock_spoolman_client.get_filaments = AsyncMock(return_value=[mock_filament])\n\n        response = await async_client.get(\"/api/v1/spoolman/filaments\")\n        assert response.status_code == 200\n        data = response.json()\n        assert \"filaments\" in data\n        assert isinstance(data[\"filaments\"], list)\n        assert len(data[\"filaments\"]) == 1\n        assert data[\"filaments\"][0][\"name\"] == \"PLA Basic\"\n\n    # =========================================================================\n    # Disable Weight Sync Tests\n    # =========================================================================\n\n    @pytest.fixture\n    async def spoolman_settings_weight_sync_disabled(self, db_session):\n        \"\"\"Create Spoolman settings with weight sync disabled.\"\"\"\n        from backend.app.models.settings import Settings\n\n        enabled_setting = Settings(key=\"spoolman_enabled\", value=\"true\")\n        url_setting = Settings(key=\"spoolman_url\", value=\"http://localhost:7912\")\n        disable_weight_setting = Settings(key=\"spoolman_disable_weight_sync\", value=\"true\")\n        partial_usage_setting = Settings(key=\"spoolman_report_partial_usage\", value=\"true\")\n        db_session.add(enabled_setting)\n        db_session.add(url_setting)\n        db_session.add(disable_weight_setting)\n        db_session.add(partial_usage_setting)\n        await db_session.commit()\n        return {\n            \"enabled\": enabled_setting,\n            \"url\": url_setting,\n            \"disable_weight\": disable_weight_setting,\n            \"partial_usage\": partial_usage_setting,\n        }\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_settings_returns_disable_weight_sync(\n        self, async_client: AsyncClient, spoolman_settings_weight_sync_disabled\n    ):\n        \"\"\"Verify settings endpoint returns the disable_weight_sync setting.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/spoolman\")\n        assert response.status_code == 200\n        data = response.json()\n        assert \"spoolman_disable_weight_sync\" in data\n        assert data[\"spoolman_disable_weight_sync\"] == \"true\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_settings_update_disable_weight_sync(self, async_client: AsyncClient, spoolman_settings):\n        \"\"\"Verify settings endpoint can update the disable_weight_sync setting.\"\"\"\n        # First verify it's false by default\n        response = await async_client.get(\"/api/v1/settings/spoolman\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data.get(\"spoolman_disable_weight_sync\", \"false\") == \"false\"\n\n        # Update the setting\n        response = await async_client.put(\n            \"/api/v1/settings/spoolman\",\n            json={\"spoolman_disable_weight_sync\": \"true\"},\n        )\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"spoolman_disable_weight_sync\"] == \"true\"\n\n        # Verify it persisted\n        response = await async_client.get(\"/api/v1/settings/spoolman\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"spoolman_disable_weight_sync\"] == \"true\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_sync_with_weight_sync_disabled_updates_location_only(\n        self,\n        async_client: AsyncClient,\n        spoolman_settings_weight_sync_disabled,\n        mock_spoolman_client,\n        printer_factory,\n    ):\n        \"\"\"Verify sync only updates location when disable_weight_sync is enabled.\"\"\"\n        printer = await printer_factory()\n\n        # Mock existing spool\n        mock_existing_spool = {\n            \"id\": 42,\n            \"remaining_weight\": 800,\n            \"extra\": {\"tag\": '\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\"'},\n            \"filament\": {\"id\": 1, \"name\": \"PLA Red\", \"material\": \"PLA\"},\n        }\n        mock_spoolman_client.find_spool_by_tag = AsyncMock(return_value=mock_existing_spool)\n        mock_spoolman_client.parse_ams_tray = MagicMock()\n\n        # Create mock AMSTray\n        from backend.app.services.spoolman import AMSTray\n\n        mock_tray = AMSTray(\n            ams_id=0,\n            tray_id=0,\n            tray_type=\"PLA\",\n            tray_sub_brands=\"PLA Basic\",\n            tray_color=\"FF0000FF\",\n            remain=50,\n            tag_uid=\"\",\n            tray_uuid=\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\",\n            tray_info_idx=\"GFA00\",\n            tray_weight=1000,\n        )\n        mock_spoolman_client.parse_ams_tray.return_value = mock_tray\n        mock_spoolman_client.is_bambu_lab_spool = MagicMock(return_value=True)\n        mock_spoolman_client.convert_ams_slot_to_location = MagicMock(return_value=\"AMS A1\")\n        mock_spoolman_client.sync_ams_tray = AsyncMock(return_value={\"id\": 42})\n        mock_spoolman_client.clear_location_for_removed_spools = AsyncMock(return_value=0)\n\n        with patch(\"backend.app.api.routes.spoolman.printer_manager\") as pm_mock:\n            mock_state = MagicMock()\n            mock_state.raw_data = {\n                \"ams\": [\n                    {\n                        \"id\": 0,\n                        \"tray\": [\n                            {\n                                \"id\": 0,\n                                \"tray_type\": \"PLA\",\n                                \"tray_sub_brands\": \"PLA Basic\",\n                                \"tray_color\": \"FF0000FF\",\n                                \"remain\": 50,\n                                \"tag_uid\": \"\",\n                                \"tray_uuid\": \"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\",\n                                \"tray_info_idx\": \"GFA00\",\n                                \"tray_weight\": 1000,\n                            }\n                        ],\n                    }\n                ]\n            }\n            pm_mock.get_status = MagicMock(return_value=mock_state)\n\n            response = await async_client.post(f\"/api/v1/spoolman/sync/{printer.id}\")\n            assert response.status_code == 200\n\n            # Verify sync_ams_tray was called with disable_weight_sync=True\n            mock_spoolman_client.sync_ams_tray.assert_called()\n            call_kwargs = mock_spoolman_client.sync_ams_tray.call_args.kwargs\n            assert call_kwargs.get(\"disable_weight_sync\") is True\n\n    # =========================================================================\n    # Report Partial Usage Tests\n    # =========================================================================\n\n    @pytest.fixture\n    async def spoolman_settings_partial_usage_disabled(self, db_session):\n        \"\"\"Create Spoolman settings with partial usage reporting disabled.\"\"\"\n        from backend.app.models.settings import Settings\n\n        enabled_setting = Settings(key=\"spoolman_enabled\", value=\"true\")\n        url_setting = Settings(key=\"spoolman_url\", value=\"http://localhost:7912\")\n        partial_usage_setting = Settings(key=\"spoolman_report_partial_usage\", value=\"false\")\n        db_session.add(enabled_setting)\n        db_session.add(url_setting)\n        db_session.add(partial_usage_setting)\n        await db_session.commit()\n        return {\n            \"enabled\": enabled_setting,\n            \"url\": url_setting,\n            \"partial_usage\": partial_usage_setting,\n        }\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_settings_returns_report_partial_usage(\n        self, async_client: AsyncClient, spoolman_settings_partial_usage_disabled\n    ):\n        \"\"\"Verify settings endpoint returns the report_partial_usage setting.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/spoolman\")\n        assert response.status_code == 200\n        data = response.json()\n        assert \"spoolman_report_partial_usage\" in data\n        assert data[\"spoolman_report_partial_usage\"] == \"false\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_settings_update_report_partial_usage(self, async_client: AsyncClient, spoolman_settings):\n        \"\"\"Verify settings endpoint can update the report_partial_usage setting.\"\"\"\n        # First verify it's true by default\n        response = await async_client.get(\"/api/v1/settings/spoolman\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data.get(\"spoolman_report_partial_usage\", \"true\") == \"true\"\n\n        # Update the setting to false\n        response = await async_client.put(\n            \"/api/v1/settings/spoolman\",\n            json={\"spoolman_report_partial_usage\": \"false\"},\n        )\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"spoolman_report_partial_usage\"] == \"false\"\n\n        # Verify it persisted\n        response = await async_client.get(\"/api/v1/settings/spoolman\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"spoolman_report_partial_usage\"] == \"false\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_settings_report_partial_usage_defaults_to_true(self, async_client: AsyncClient, spoolman_settings):\n        \"\"\"Verify report_partial_usage defaults to true (unlike disable_weight_sync which defaults to false).\"\"\"\n        response = await async_client.get(\"/api/v1/settings/spoolman\")\n        assert response.status_code == 200\n        data = response.json()\n        # Should default to \"true\"\n        assert data[\"spoolman_report_partial_usage\"] == \"true\"\n"
  },
  {
    "path": "backend/tests/integration/test_support_api.py",
    "content": "\"\"\"Integration tests for Support API endpoints.\n\nTests the full request/response cycle for /api/v1/support/ endpoints.\n\"\"\"\n\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestSupportLogsAPI:\n    \"\"\"Integration tests for /api/v1/support/logs endpoints.\"\"\"\n\n    # ========================================================================\n    # GET /api/v1/support/logs\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_logs_empty_file(self, async_client: AsyncClient):\n        \"\"\"Verify get logs returns empty list when log file doesn't exist.\"\"\"\n        with patch(\"backend.app.api.routes.support.settings\") as mock_settings:\n            mock_settings.log_dir = Path(\"/nonexistent/path\")\n\n            response = await async_client.get(\"/api/v1/support/logs\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"entries\"] == []\n        assert result[\"total_in_file\"] == 0\n        assert result[\"filtered_count\"] == 0\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_logs_with_entries(self, async_client: AsyncClient):\n        \"\"\"Verify get logs returns parsed log entries.\"\"\"\n        log_content = \"\"\"2024-01-15 10:30:45,123 INFO [backend.app.main] Server started\n2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Connecting to printer\n2024-01-15 10:30:47,789 WARNING [backend.app.services.mqtt] Connection timeout\n2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file\n\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            log_file = Path(tmpdir) / \"bambuddy.log\"\n            log_file.write_text(log_content)\n\n            with patch(\"backend.app.api.routes.support.settings\") as mock_settings:\n                mock_settings.log_dir = Path(tmpdir)\n\n                response = await async_client.get(\"/api/v1/support/logs\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result[\"entries\"]) == 4\n        assert result[\"total_in_file\"] == 4\n        assert result[\"filtered_count\"] == 4\n\n        # Entries are in newest-first order\n        assert result[\"entries\"][0][\"level\"] == \"ERROR\"\n        assert result[\"entries\"][1][\"level\"] == \"WARNING\"\n        assert result[\"entries\"][2][\"level\"] == \"DEBUG\"\n        assert result[\"entries\"][3][\"level\"] == \"INFO\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_logs_with_level_filter(self, async_client: AsyncClient):\n        \"\"\"Verify get logs filters by log level.\"\"\"\n        log_content = \"\"\"2024-01-15 10:30:45,123 INFO [backend.app.main] Server started\n2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Connecting to printer\n2024-01-15 10:30:47,789 ERROR [backend.app.services.mqtt] Connection timeout\n2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file\n\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            log_file = Path(tmpdir) / \"bambuddy.log\"\n            log_file.write_text(log_content)\n\n            with patch(\"backend.app.api.routes.support.settings\") as mock_settings:\n                mock_settings.log_dir = Path(tmpdir)\n\n                response = await async_client.get(\"/api/v1/support/logs?level=ERROR\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result[\"entries\"]) == 2\n        assert result[\"filtered_count\"] == 2\n        assert all(e[\"level\"] == \"ERROR\" for e in result[\"entries\"])\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_logs_with_search_filter(self, async_client: AsyncClient):\n        \"\"\"Verify get logs filters by search query.\"\"\"\n        log_content = \"\"\"2024-01-15 10:30:45,123 INFO [backend.app.main] Server started\n2024-01-15 10:30:46,456 INFO [backend.app.services.printer] Connecting to printer X1C\n2024-01-15 10:30:47,789 ERROR [backend.app.services.mqtt] Connection to printer failed\n2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file\n\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            log_file = Path(tmpdir) / \"bambuddy.log\"\n            log_file.write_text(log_content)\n\n            with patch(\"backend.app.api.routes.support.settings\") as mock_settings:\n                mock_settings.log_dir = Path(tmpdir)\n\n                response = await async_client.get(\"/api/v1/support/logs?search=printer\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result[\"entries\"]) == 2\n        assert result[\"filtered_count\"] == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_logs_with_limit(self, async_client: AsyncClient):\n        \"\"\"Verify get logs respects limit parameter.\"\"\"\n        log_content = \"\"\"2024-01-15 10:30:45,123 INFO [backend.app.main] Line 1\n2024-01-15 10:30:46,456 INFO [backend.app.main] Line 2\n2024-01-15 10:30:47,789 INFO [backend.app.main] Line 3\n2024-01-15 10:30:48,012 INFO [backend.app.main] Line 4\n2024-01-15 10:30:49,345 INFO [backend.app.main] Line 5\n\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            log_file = Path(tmpdir) / \"bambuddy.log\"\n            log_file.write_text(log_content)\n\n            with patch(\"backend.app.api.routes.support.settings\") as mock_settings:\n                mock_settings.log_dir = Path(tmpdir)\n\n                response = await async_client.get(\"/api/v1/support/logs?limit=2\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result[\"entries\"]) == 2\n        assert result[\"filtered_count\"] == 2\n        # Should get the newest entries (Line 5 and Line 4)\n        assert \"Line 5\" in result[\"entries\"][0][\"message\"]\n        assert \"Line 4\" in result[\"entries\"][1][\"message\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_logs_multiline_entry(self, async_client: AsyncClient):\n        \"\"\"Verify get logs handles multi-line log entries.\"\"\"\n        log_content = \"\"\"2024-01-15 10:30:45,123 INFO [backend.app.main] Server started\n2024-01-15 10:30:46,456 ERROR [backend.app.services.mqtt] Exception occurred\nTraceback (most recent call last):\n  File \"test.py\", line 10, in test\n    raise ValueError(\"test error\")\nValueError: test error\n2024-01-15 10:30:47,789 INFO [backend.app.main] Recovery complete\n\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            log_file = Path(tmpdir) / \"bambuddy.log\"\n            log_file.write_text(log_content)\n\n            with patch(\"backend.app.api.routes.support.settings\") as mock_settings:\n                mock_settings.log_dir = Path(tmpdir)\n\n                response = await async_client.get(\"/api/v1/support/logs\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert len(result[\"entries\"]) == 3\n\n        # Find the error entry\n        error_entry = next(e for e in result[\"entries\"] if e[\"level\"] == \"ERROR\")\n        assert \"Exception occurred\" in error_entry[\"message\"]\n        assert \"Traceback\" in error_entry[\"message\"]\n        assert \"ValueError\" in error_entry[\"message\"]\n\n    # ========================================================================\n    # DELETE /api/v1/support/logs\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_clear_logs_success(self, async_client: AsyncClient):\n        \"\"\"Verify clear logs truncates the log file.\"\"\"\n        log_content = \"\"\"2024-01-15 10:30:45,123 INFO [backend.app.main] Server started\n2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Some debug info\n\"\"\"\n        with tempfile.TemporaryDirectory() as tmpdir:\n            log_file = Path(tmpdir) / \"bambuddy.log\"\n            log_file.write_text(log_content)\n\n            with patch(\"backend.app.api.routes.support.settings\") as mock_settings:\n                mock_settings.log_dir = Path(tmpdir)\n\n                response = await async_client.delete(\"/api/v1/support/logs\")\n\n                # Verify file was cleared\n                assert log_file.read_text() == \"\"\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"cleared\" in result[\"message\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_clear_logs_no_file(self, async_client: AsyncClient):\n        \"\"\"Verify clear logs handles missing log file gracefully.\"\"\"\n        with patch(\"backend.app.api.routes.support.settings\") as mock_settings:\n            mock_settings.log_dir = Path(\"/nonexistent/path\")\n\n            response = await async_client.delete(\"/api/v1/support/logs\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"does not exist\" in result[\"message\"].lower()\n\n\nclass TestLogParsingHelpers:\n    \"\"\"Tests for log parsing helper functions.\"\"\"\n\n    def test_parse_log_line_valid(self):\n        \"\"\"Verify _parse_log_line handles valid log lines.\"\"\"\n        from backend.app.api.routes.support import _parse_log_line\n\n        line = \"2024-01-15 10:30:45,123 INFO [backend.app.main] Server started\"\n        entry = _parse_log_line(line)\n\n        assert entry is not None\n        assert entry.timestamp == \"2024-01-15 10:30:45,123\"\n        assert entry.level == \"INFO\"\n        assert entry.logger_name == \"backend.app.main\"\n        assert entry.message == \"Server started\"\n\n    def test_parse_log_line_invalid(self):\n        \"\"\"Verify _parse_log_line returns None for invalid lines.\"\"\"\n        from backend.app.api.routes.support import _parse_log_line\n\n        line = \"This is not a valid log line\"\n        entry = _parse_log_line(line)\n\n        assert entry is None\n\n    def test_parse_log_line_with_brackets_in_message(self):\n        \"\"\"Verify _parse_log_line handles messages with brackets.\"\"\"\n        from backend.app.api.routes.support import _parse_log_line\n\n        line = \"2024-01-15 10:30:45,123 INFO [backend.app.main] Processing [item 1] and [item 2]\"\n        entry = _parse_log_line(line)\n\n        assert entry is not None\n        assert entry.message == \"Processing [item 1] and [item 2]\"\n\n    def test_parse_log_line_all_levels(self):\n        \"\"\"Verify _parse_log_line handles all log levels.\"\"\"\n        from backend.app.api.routes.support import _parse_log_line\n\n        levels = [\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\", \"CRITICAL\"]\n        for level in levels:\n            line = f\"2024-01-15 10:30:45,123 {level} [test.module] Test message\"\n            entry = _parse_log_line(line)\n            assert entry is not None\n            assert entry.level == level\n"
  },
  {
    "path": "backend/tests/integration/test_system_api.py",
    "content": "\"\"\"Integration tests for System API endpoints.\n\nTests the full request/response cycle for /api/v1/system/ endpoints.\n\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestSystemAPI:\n    \"\"\"Integration tests for /api/v1/system/ endpoints.\"\"\"\n\n    # ========================================================================\n    # System Info Endpoint\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_system_info(self, async_client: AsyncClient):\n        \"\"\"Verify system info endpoint returns expected structure.\"\"\"\n        # Mock psutil to avoid system-specific values\n        with patch(\"backend.app.api.routes.system.psutil\") as mock_psutil:\n            mock_psutil.disk_usage.return_value = MagicMock(\n                total=500000000000, used=250000000000, free=250000000000, percent=50.0\n            )\n            mock_psutil.virtual_memory.return_value = MagicMock(\n                total=16000000000, available=8000000000, used=8000000000, percent=50.0\n            )\n            mock_psutil.boot_time.return_value = 1700000000.0\n            mock_psutil.cpu_count.return_value = 4\n            mock_psutil.cpu_percent.return_value = 25.0\n\n            response = await async_client.get(\"/api/v1/system/info\")\n\n        assert response.status_code == 200\n        result = response.json()\n\n        # Verify top-level structure\n        assert \"app\" in result\n        assert \"database\" in result\n        assert \"printers\" in result\n        assert \"storage\" in result\n        assert \"system\" in result\n        assert \"memory\" in result\n        assert \"cpu\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_system_info_app_section(self, async_client: AsyncClient):\n        \"\"\"Verify app section contains version and directory info.\"\"\"\n        with patch(\"backend.app.api.routes.system.psutil\") as mock_psutil:\n            mock_psutil.disk_usage.return_value = MagicMock(\n                total=500000000000, used=250000000000, free=250000000000, percent=50.0\n            )\n            mock_psutil.virtual_memory.return_value = MagicMock(\n                total=16000000000, available=8000000000, used=8000000000, percent=50.0\n            )\n            mock_psutil.boot_time.return_value = 1700000000.0\n            mock_psutil.cpu_count.return_value = 4\n            mock_psutil.cpu_percent.return_value = 25.0\n\n            response = await async_client.get(\"/api/v1/system/info\")\n\n        result = response.json()\n        app_info = result[\"app\"]\n\n        assert \"version\" in app_info\n        assert \"base_dir\" in app_info\n        assert \"archive_dir\" in app_info\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_system_info_database_section(self, async_client: AsyncClient):\n        \"\"\"Verify database section contains counts and statistics.\"\"\"\n        with patch(\"backend.app.api.routes.system.psutil\") as mock_psutil:\n            mock_psutil.disk_usage.return_value = MagicMock(\n                total=500000000000, used=250000000000, free=250000000000, percent=50.0\n            )\n            mock_psutil.virtual_memory.return_value = MagicMock(\n                total=16000000000, available=8000000000, used=8000000000, percent=50.0\n            )\n            mock_psutil.boot_time.return_value = 1700000000.0\n            mock_psutil.cpu_count.return_value = 4\n            mock_psutil.cpu_percent.return_value = 25.0\n\n            response = await async_client.get(\"/api/v1/system/info\")\n\n        result = response.json()\n        db_info = result[\"database\"]\n\n        assert \"archives\" in db_info\n        assert \"archives_completed\" in db_info\n        assert \"archives_failed\" in db_info\n        assert \"printers\" in db_info\n        assert \"filaments\" in db_info\n        assert \"projects\" in db_info\n        assert \"smart_plugs\" in db_info\n        assert \"total_print_time_seconds\" in db_info\n        assert \"total_print_time_formatted\" in db_info\n        assert \"total_filament_grams\" in db_info\n        assert \"total_filament_kg\" in db_info\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_system_info_storage_section(self, async_client: AsyncClient):\n        \"\"\"Verify storage section contains disk usage info.\"\"\"\n        with patch(\"backend.app.api.routes.system.psutil\") as mock_psutil:\n            mock_psutil.disk_usage.return_value = MagicMock(\n                total=500000000000, used=250000000000, free=250000000000, percent=50.0\n            )\n            mock_psutil.virtual_memory.return_value = MagicMock(\n                total=16000000000, available=8000000000, used=8000000000, percent=50.0\n            )\n            mock_psutil.boot_time.return_value = 1700000000.0\n            mock_psutil.cpu_count.return_value = 4\n            mock_psutil.cpu_percent.return_value = 25.0\n\n            response = await async_client.get(\"/api/v1/system/info\")\n\n        result = response.json()\n        storage_info = result[\"storage\"]\n\n        assert \"archive_size_bytes\" in storage_info\n        assert \"archive_size_formatted\" in storage_info\n        assert \"database_size_bytes\" in storage_info\n        assert \"database_size_formatted\" in storage_info\n        assert \"disk_total_bytes\" in storage_info\n        assert \"disk_total_formatted\" in storage_info\n        assert \"disk_used_bytes\" in storage_info\n        assert \"disk_free_bytes\" in storage_info\n        assert \"disk_percent_used\" in storage_info\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_system_info_memory_section(self, async_client: AsyncClient):\n        \"\"\"Verify memory section contains RAM usage info.\"\"\"\n        with patch(\"backend.app.api.routes.system.psutil\") as mock_psutil:\n            mock_psutil.disk_usage.return_value = MagicMock(\n                total=500000000000, used=250000000000, free=250000000000, percent=50.0\n            )\n            mock_psutil.virtual_memory.return_value = MagicMock(\n                total=16000000000, available=8000000000, used=8000000000, percent=50.0\n            )\n            mock_psutil.boot_time.return_value = 1700000000.0\n            mock_psutil.cpu_count.return_value = 4\n            mock_psutil.cpu_percent.return_value = 25.0\n\n            response = await async_client.get(\"/api/v1/system/info\")\n\n        result = response.json()\n        memory_info = result[\"memory\"]\n\n        assert \"total_bytes\" in memory_info\n        assert \"total_formatted\" in memory_info\n        assert \"available_bytes\" in memory_info\n        assert \"used_bytes\" in memory_info\n        assert \"percent_used\" in memory_info\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_system_info_cpu_section(self, async_client: AsyncClient):\n        \"\"\"Verify CPU section contains processor info.\"\"\"\n        with patch(\"backend.app.api.routes.system.psutil\") as mock_psutil:\n            mock_psutil.disk_usage.return_value = MagicMock(\n                total=500000000000, used=250000000000, free=250000000000, percent=50.0\n            )\n            mock_psutil.virtual_memory.return_value = MagicMock(\n                total=16000000000, available=8000000000, used=8000000000, percent=50.0\n            )\n            mock_psutil.boot_time.return_value = 1700000000.0\n            mock_psutil.cpu_count.return_value = 4\n            mock_psutil.cpu_percent.return_value = 25.0\n\n            response = await async_client.get(\"/api/v1/system/info\")\n\n        result = response.json()\n        cpu_info = result[\"cpu\"]\n\n        assert \"count\" in cpu_info\n        assert \"count_logical\" in cpu_info\n        assert \"percent\" in cpu_info\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_system_info_printers_section(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify printers section contains connected printer info.\"\"\"\n        # Create a test printer\n        _printer = await printer_factory(name=\"Test Printer\", model=\"X1C\")\n\n        with (\n            patch(\"backend.app.api.routes.system.psutil\") as mock_psutil,\n            patch(\"backend.app.api.routes.system.printer_manager\") as mock_pm,\n        ):\n            mock_psutil.disk_usage.return_value = MagicMock(\n                total=500000000000, used=250000000000, free=250000000000, percent=50.0\n            )\n            mock_psutil.virtual_memory.return_value = MagicMock(\n                total=16000000000, available=8000000000, used=8000000000, percent=50.0\n            )\n            mock_psutil.boot_time.return_value = 1700000000.0\n            mock_psutil.cpu_count.return_value = 4\n            mock_psutil.cpu_percent.return_value = 25.0\n\n            # Mock no connected printers for simplicity\n            mock_pm._clients = {}\n\n            response = await async_client.get(\"/api/v1/system/info\")\n\n        result = response.json()\n        printers_info = result[\"printers\"]\n\n        assert \"total\" in printers_info\n        assert \"connected\" in printers_info\n        assert \"connected_list\" in printers_info\n        assert printers_info[\"total\"] >= 1  # At least our test printer\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory):\n        \"\"\"Verify database stats include archive counts.\"\"\"\n        printer = await printer_factory()\n        await archive_factory(printer.id, status=\"completed\", print_time_seconds=3600)\n        await archive_factory(printer.id, status=\"failed\", print_time_seconds=1800)\n\n        with (\n            patch(\"backend.app.api.routes.system.psutil\") as mock_psutil,\n            patch(\"backend.app.api.routes.system.printer_manager\") as mock_pm,\n        ):\n            mock_psutil.disk_usage.return_value = MagicMock(\n                total=500000000000, used=250000000000, free=250000000000, percent=50.0\n            )\n            mock_psutil.virtual_memory.return_value = MagicMock(\n                total=16000000000, available=8000000000, used=8000000000, percent=50.0\n            )\n            mock_psutil.boot_time.return_value = 1700000000.0\n            mock_psutil.cpu_count.return_value = 4\n            mock_psutil.cpu_percent.return_value = 25.0\n            mock_pm._clients = {}\n\n            response = await async_client.get(\"/api/v1/system/info\")\n\n        result = response.json()\n        db_info = result[\"database\"]\n\n        assert db_info[\"archives\"] >= 2\n        assert db_info[\"archives_completed\"] >= 1\n        assert db_info[\"archives_failed\"] >= 1\n        assert db_info[\"total_print_time_seconds\"] >= 5400\n\n\nclass TestSystemHelperFunctions:\n    \"\"\"Tests for system info helper functions.\"\"\"\n\n    def test_format_bytes_bytes(self):\n        \"\"\"Verify format_bytes handles bytes correctly.\"\"\"\n        from backend.app.api.routes.system import format_bytes\n\n        assert format_bytes(500) == \"500.0 B\"\n\n    def test_format_bytes_kilobytes(self):\n        \"\"\"Verify format_bytes handles kilobytes correctly.\"\"\"\n        from backend.app.api.routes.system import format_bytes\n\n        result = format_bytes(1536)\n        assert \"KB\" in result\n\n    def test_format_bytes_megabytes(self):\n        \"\"\"Verify format_bytes handles megabytes correctly.\"\"\"\n        from backend.app.api.routes.system import format_bytes\n\n        result = format_bytes(1536 * 1024)\n        assert \"MB\" in result\n\n    def test_format_bytes_gigabytes(self):\n        \"\"\"Verify format_bytes handles gigabytes correctly.\"\"\"\n        from backend.app.api.routes.system import format_bytes\n\n        result = format_bytes(1536 * 1024 * 1024)\n        assert \"GB\" in result\n\n    def test_format_uptime_minutes(self):\n        \"\"\"Verify format_uptime handles minutes correctly.\"\"\"\n        from backend.app.api.routes.system import format_uptime\n\n        result = format_uptime(300)  # 5 minutes\n        assert \"5m\" in result\n\n    def test_format_uptime_hours(self):\n        \"\"\"Verify format_uptime handles hours correctly.\"\"\"\n        from backend.app.api.routes.system import format_uptime\n\n        result = format_uptime(7200)  # 2 hours\n        assert \"2h\" in result\n\n    def test_format_uptime_days(self):\n        \"\"\"Verify format_uptime handles days correctly.\"\"\"\n        from backend.app.api.routes.system import format_uptime\n\n        result = format_uptime(86400 * 2 + 3600 * 5)  # 2 days 5 hours\n        assert \"2d\" in result\n        assert \"5h\" in result\n\n    def test_format_uptime_less_than_minute(self):\n        \"\"\"Verify format_uptime handles < 1 minute correctly.\"\"\"\n        from backend.app.api.routes.system import format_uptime\n\n        result = format_uptime(30)  # 30 seconds\n        assert result == \"< 1m\"\n"
  },
  {
    "path": "backend/tests/integration/test_updates_api.py",
    "content": "\"\"\"Integration tests for Updates API endpoints.\"\"\"\n\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestUpdatesAPI:\n    @pytest.mark.asyncio\n    async def test_get_version(self, async_client: AsyncClient):\n        response = await async_client.get(\"/api/v1/updates/version\")\n        assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    async def test_apply_update_docker_rejection(self, async_client: AsyncClient):\n        with patch(\"backend.app.api.routes.updates._is_docker_environment\", return_value=True):\n            response = await async_client.post(\"/api/v1/updates/apply\")\n        result = response.json()\n        assert result[\"success\"] is False\n        assert result[\"is_docker\"] is True\n\n    @pytest.mark.asyncio\n    async def test_apply_update_non_docker(self, async_client: AsyncClient):\n        \"\"\"Test non-Docker path - mock _perform_update to prevent side effects.\"\"\"\n        with (\n            patch(\"backend.app.api.routes.updates._is_docker_environment\", return_value=False),\n            patch(\"backend.app.api.routes.updates._perform_update\", new_callable=AsyncMock),\n        ):\n            response = await async_client.post(\"/api/v1/updates/apply\")\n        assert response.json()[\"success\"] is True\n\n    def test_is_docker_with_dockerenv(self):\n        from backend.app.api.routes.updates import _is_docker_environment\n\n        with patch(\"os.path.exists\", return_value=True):\n            assert _is_docker_environment() is True\n\n    def test_parse_version(self):\n        from backend.app.api.routes.updates import parse_version\n\n        assert parse_version(\"0.1.5\")[:3] == (0, 1, 5)\n\n    def test_is_newer_version(self):\n        from backend.app.api.routes.updates import is_newer_version\n\n        assert is_newer_version(\"0.1.5\", \"0.1.5b7\") is True\n"
  },
  {
    "path": "backend/tests/integration/test_user_notifications_api.py",
    "content": "\"\"\"Integration tests for User Notifications API endpoints.\n\nTests the full request/response cycle for /api/v1/user-notifications/ endpoints.\n\"\"\"\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestUserNotificationsAPI:\n    \"\"\"Integration tests for /api/v1/user-notifications/ endpoints.\"\"\"\n\n    # ========================================================================\n    # GET /preferences — no auth\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_preferences_returns_defaults_when_no_auth(self, async_client: AsyncClient):\n        \"\"\"Without auth, GET should return all-enabled defaults.\"\"\"\n        response = await async_client.get(\"/api/v1/user-notifications/preferences\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"notify_print_start\"] is True\n        assert data[\"notify_print_complete\"] is True\n        assert data[\"notify_print_failed\"] is True\n        assert data[\"notify_print_stopped\"] is True\n\n    # ========================================================================\n    # PUT /preferences — no auth\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_preferences_fails_without_auth(self, async_client: AsyncClient):\n        \"\"\"Without auth enabled, PUT should return 400 (no user context).\"\"\"\n        data = {\n            \"notify_print_start\": False,\n            \"notify_print_complete\": True,\n            \"notify_print_failed\": True,\n            \"notify_print_stopped\": False,\n        }\n\n        response = await async_client.put(\"/api/v1/user-notifications/preferences\", json=data)\n\n        assert response.status_code == 400\n        assert \"Authentication must be enabled\" in response.json()[\"detail\"]\n\n    # ========================================================================\n    # Schema validation\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_preferences_rejects_missing_fields(self, async_client: AsyncClient):\n        \"\"\"PUT should reject requests missing required boolean fields.\"\"\"\n        data = {\n            \"notify_print_start\": True,\n            # missing other fields\n        }\n\n        response = await async_client.put(\"/api/v1/user-notifications/preferences\", json=data)\n\n        assert response.status_code == 422  # Pydantic validation error\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_preferences_rejects_invalid_type(self, async_client: AsyncClient):\n        \"\"\"PUT should reject values that cannot be coerced to boolean.\"\"\"\n        data = {\n            \"notify_print_start\": [1, 2, 3],\n            \"notify_print_complete\": True,\n            \"notify_print_failed\": True,\n            \"notify_print_stopped\": True,\n        }\n\n        response = await async_client.put(\"/api/v1/user-notifications/preferences\", json=data)\n\n        assert response.status_code == 422\n"
  },
  {
    "path": "backend/tests/integration/test_virtual_printer_api.py",
    "content": "\"\"\"Integration tests for Virtual Printer API endpoints.\n\nTests the full request/response cycle for /api/v1/settings/virtual-printer endpoints.\n\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestVirtualPrinterSettingsAPI:\n    \"\"\"Integration tests for /api/v1/settings/virtual-printer endpoints.\"\"\"\n\n    # ========================================================================\n    # Get settings\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_virtual_printer_settings(self, async_client: AsyncClient):\n        \"\"\"Verify virtual printer settings can be retrieved.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/virtual-printer\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"enabled\" in result\n        assert \"access_code_set\" in result\n        assert \"mode\" in result\n        assert \"status\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_settings_has_status(self, async_client: AsyncClient):\n        \"\"\"Verify settings include status details.\"\"\"\n        response = await async_client.get(\"/api/v1/settings/virtual-printer\")\n\n        assert response.status_code == 200\n        result = response.json()\n        status = result[\"status\"]\n        assert \"enabled\" in status\n        assert \"running\" in status\n        assert \"mode\" in status\n        assert \"name\" in status\n        assert \"serial\" in status\n        assert \"pending_files\" in status\n\n    # ========================================================================\n    # Update settings\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_mode(self, async_client: AsyncClient):\n        \"\"\"Verify mode can be updated.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/virtual-printer?mode=review\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mode\"] == \"review\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_mode_to_print_queue(self, async_client: AsyncClient):\n        \"\"\"Verify mode can be set to print_queue.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/virtual-printer?mode=print_queue\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mode\"] == \"print_queue\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_mode_legacy_queue_maps_to_review(self, async_client: AsyncClient):\n        \"\"\"Verify legacy 'queue' mode is normalized to 'review'.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/virtual-printer?mode=queue\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mode\"] == \"review\"  # Legacy queue maps to review\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_mode_to_immediate(self, async_client: AsyncClient):\n        \"\"\"Verify mode can be set to immediate.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/virtual-printer?mode=immediate\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"mode\"] == \"immediate\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_access_code(self, async_client: AsyncClient):\n        \"\"\"Verify access code can be set.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/virtual-printer?access_code=12345678\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"access_code_set\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_access_code_wrong_length(self, async_client: AsyncClient):\n        \"\"\"Verify access code validation for length.\"\"\"\n        response = await async_client.put(\"/api/v1/settings/virtual-printer?access_code=123\")\n\n        # Should fail validation\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_without_access_code(self, async_client: AsyncClient):\n        \"\"\"Verify enabling fails without access code set.\"\"\"\n        # First ensure no access code is set by checking current state\n        # Then try to enable\n        response = await async_client.put(\"/api/v1/settings/virtual-printer?enabled=true\")\n\n        # If access code wasn't set, this should fail\n        # If it was already set, it will succeed\n        # Both are valid test outcomes\n        assert response.status_code in [200, 400]\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_enable_with_access_code(self, async_client: AsyncClient):\n        \"\"\"Verify enabling succeeds when access code is set.\"\"\"\n        # First set access code\n        await async_client.put(\"/api/v1/settings/virtual-printer?access_code=12345678\")\n\n        # Then enable (this will start the servers which may fail in test env)\n        # We mock the manager to avoid actually starting servers\n        with patch(\"backend.app.services.virtual_printer.virtual_printer_manager\") as mock_manager:\n            mock_manager.configure = AsyncMock()\n            mock_manager.get_status = MagicMock(\n                return_value={\n                    \"enabled\": True,\n                    \"running\": True,\n                    \"mode\": \"immediate\",\n                    \"name\": \"Bambuddy\",\n                    \"serial\": \"00M09A391800001\",\n                    \"pending_files\": 0,\n                }\n            )\n\n            response = await async_client.put(\"/api/v1/settings/virtual-printer?enabled=true\")\n\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_disable_virtual_printer(self, async_client: AsyncClient):\n        \"\"\"Verify virtual printer can be disabled.\"\"\"\n        with patch(\"backend.app.services.virtual_printer.virtual_printer_manager\") as mock_manager:\n            mock_manager.configure = AsyncMock()\n            mock_manager.get_status = MagicMock(\n                return_value={\n                    \"enabled\": False,\n                    \"running\": False,\n                    \"mode\": \"immediate\",\n                    \"name\": \"Bambuddy\",\n                    \"serial\": \"00M09A391800001\",\n                    \"pending_files\": 0,\n                }\n            )\n\n            response = await async_client.put(\"/api/v1/settings/virtual-printer?enabled=false\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"enabled\"] is False\n\n\nclass TestPendingUploadsAPI:\n    \"\"\"Integration tests for /api/v1/pending-uploads/ endpoints.\"\"\"\n\n    @pytest.fixture\n    def mock_pending_uploads(self, db_session):\n        \"\"\"Create mock pending uploads in database.\"\"\"\n\n        async def _create_pending(filename: str = \"test.3mf\"):\n            from datetime import datetime\n\n            from backend.app.models.pending_upload import PendingUpload\n\n            upload = PendingUpload(\n                filename=filename,\n                file_path=f\"/tmp/{filename}\",\n                file_size=1024,\n                source_ip=\"192.168.1.100\",\n                status=\"pending\",\n            )\n            db_session.add(upload)\n            await db_session.commit()\n            await db_session.refresh(upload)\n            return upload\n\n        return _create_pending\n\n    # ========================================================================\n    # List pending uploads\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_list_pending_uploads_empty(self, async_client: AsyncClient):\n        \"\"\"Verify empty list is returned when no pending uploads.\"\"\"\n        response = await async_client.get(\"/api/v1/pending-uploads/\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert isinstance(result, list)\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_get_pending_uploads_count(self, async_client: AsyncClient):\n        \"\"\"Verify count endpoint returns correct count.\"\"\"\n        response = await async_client.get(\"/api/v1/pending-uploads/count\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"count\" in result\n        assert isinstance(result[\"count\"], int)\n\n    # ========================================================================\n    # Archive pending upload\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_nonexistent_upload(self, async_client: AsyncClient):\n        \"\"\"Verify archiving non-existent upload returns 404.\"\"\"\n        response = await async_client.post(\"/api/v1/pending-uploads/99999/archive\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Discard pending upload\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_discard_nonexistent_upload(self, async_client: AsyncClient):\n        \"\"\"Verify discarding non-existent upload returns 404.\"\"\"\n        response = await async_client.delete(\"/api/v1/pending-uploads/99999\")\n\n        assert response.status_code == 404\n\n    # ========================================================================\n    # Bulk operations\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_archive_all_empty(self, async_client: AsyncClient):\n        \"\"\"Verify archive all with no pending uploads.\"\"\"\n        response = await async_client.post(\"/api/v1/pending-uploads/archive-all\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"archived\" in result\n        assert \"failed\" in result\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_discard_all_empty(self, async_client: AsyncClient):\n        \"\"\"Verify discard all with no pending uploads.\"\"\"\n        response = await async_client.delete(\"/api/v1/pending-uploads/discard-all\")\n\n        assert response.status_code == 200\n        result = response.json()\n        assert \"discarded\" in result\n\n\nclass TestVirtualPrinterAutoDispatchAPI:\n    \"\"\"Integration tests for auto_dispatch on /api/v1/virtual-printers endpoints.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_virtual_printer_auto_dispatch_default(self, async_client: AsyncClient):\n        \"\"\"Verify creating a VP without auto_dispatch defaults to true.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/virtual-printers\",\n            json={\n                \"name\": \"TestDefaultDispatch\",\n                \"mode\": \"print_queue\",\n                \"access_code\": \"12345678\",\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"auto_dispatch\"] is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_create_virtual_printer_auto_dispatch_false(self, async_client: AsyncClient):\n        \"\"\"Verify creating a VP with auto_dispatch=false persists correctly.\"\"\"\n        response = await async_client.post(\n            \"/api/v1/virtual-printers\",\n            json={\n                \"name\": \"TestManualDispatch\",\n                \"mode\": \"print_queue\",\n                \"access_code\": \"12345678\",\n                \"auto_dispatch\": False,\n            },\n        )\n\n        assert response.status_code == 200\n        result = response.json()\n        assert result[\"auto_dispatch\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.integration\n    async def test_update_virtual_printer_auto_dispatch(self, async_client: AsyncClient):\n        \"\"\"Verify auto_dispatch can be toggled via PUT and persists.\"\"\"\n        # Create with auto_dispatch=True (default)\n        create_resp = await async_client.post(\n            \"/api/v1/virtual-printers\",\n            json={\n                \"name\": \"TestToggleDispatch\",\n                \"mode\": \"print_queue\",\n                \"access_code\": \"12345678\",\n            },\n        )\n        assert create_resp.status_code == 200\n        vp_id = create_resp.json()[\"id\"]\n\n        # Update to auto_dispatch=False\n        update_resp = await async_client.put(\n            f\"/api/v1/virtual-printers/{vp_id}\",\n            json={\"auto_dispatch\": False},\n        )\n        assert update_resp.status_code == 200\n        assert update_resp.json()[\"auto_dispatch\"] is False\n\n        # Verify it persists by fetching\n        get_resp = await async_client.get(f\"/api/v1/virtual-printers/{vp_id}\")\n        assert get_resp.status_code == 200\n        assert get_resp.json()[\"auto_dispatch\"] is False\n"
  },
  {
    "path": "backend/tests/pytest.ini",
    "content": "[pytest]\ntestpaths = .\nasyncio_mode = auto\nasyncio_default_fixture_loop_scope = function\nfilterwarnings =\n    ignore::DeprecationWarning\n    ignore::sqlalchemy.exc.SAWarning\n    # Filter warnings from async mocks - coroutines created by mocks that are\n    # intentionally not awaited (expected behavior in unit tests)\n    ignore:coroutine.*was never awaited:RuntimeWarning\nmarkers =\n    unit: Unit tests (fast, no external deps)\n    integration: Integration tests (slower, test full API)\n    slow: Slow tests (skip with -m \"not slow\")\n"
  },
  {
    "path": "backend/tests/unit/__init__.py",
    "content": "\"\"\"Unit tests for BamBuddy backend services.\"\"\"\n"
  },
  {
    "path": "backend/tests/unit/services/__init__.py",
    "content": "\"\"\"Unit tests for BamBuddy backend services layer.\"\"\"\n"
  },
  {
    "path": "backend/tests/unit/services/conftest.py",
    "content": "\"\"\"Test fixtures for FTP service tests.\n\nProvides a real implicit FTPS server (via mock_ftp_server) and client factory\nfor integration-style testing of BambuFTPClient against a live server.\n\nThe server fixture is class-scoped to avoid the overhead of starting a new\nTLS server for every test (~67 TLS handshakes → ~9 per class).\n\"\"\"\n\nimport os\nimport shutil\nimport socket\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom backend.app.services.bambu_ftp import BambuFTPClient\nfrom backend.app.services.virtual_printer.certificate import CertificateService\nfrom backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer\n\nBAMBU_DIRS = (\"cache\", \"timelapse\", \"model\", \"data\", \"data/Metadata\")\n\n\n@pytest.fixture(scope=\"session\")\ndef ftp_certs(tmp_path_factory):\n    \"\"\"Generate self-signed TLS certificates once per test session.\"\"\"\n    cert_dir = tmp_path_factory.mktemp(\"ftp_certs\")\n    svc = CertificateService(cert_dir, serial=\"TEST_FTP_SERVER\")\n    cert_path, key_path = svc.generate_certificates()\n    return str(cert_path), str(key_path)\n\n\ndef _find_free_port() -> int:\n    \"\"\"Find a free TCP port on localhost.\"\"\"\n    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n        s.bind((\"127.0.0.1\", 0))\n        return s.getsockname()[1]\n\n\n@pytest.fixture(scope=\"class\")\ndef ftp_root(tmp_path_factory):\n    \"\"\"Create temp directory with standard Bambu printer directory structure.\"\"\"\n    root = tmp_path_factory.mktemp(\"ftp_root\")\n    for d in BAMBU_DIRS:\n        (root / d).mkdir(parents=True, exist_ok=True)\n    return root\n\n\n@pytest.fixture(scope=\"class\")\ndef ftp_server(ftp_certs, ftp_root):\n    \"\"\"Start a mock implicit FTPS server, yield it, stop on cleanup.\"\"\"\n    cert_path, key_path = ftp_certs\n    port = _find_free_port()\n    server = MockBambuFTPServer(\n        host=\"127.0.0.1\",\n        port=port,\n        root_dir=str(ftp_root),\n        cert_path=cert_path,\n        key_path=key_path,\n        access_code=\"12345678\",\n    )\n    server.start()\n    yield server\n    server.stop()\n\n\n@pytest.fixture(autouse=True)\ndef _ftp_test_cleanup(request):\n    \"\"\"Reset server state between tests within a class.\n\n    Clears injected failures and restores the Bambu directory structure\n    so each test starts with a clean filesystem.  Skips cleanup for test\n    classes that don't use the class-scoped ftp_server (e.g.\n    TestDisconnectServerGone).\n    \"\"\"\n    yield\n    # Only clean up if this test class uses the class-scoped fixtures\n    ftp_root = request.node.funcargs.get(\"ftp_root\")\n    if ftp_root is None:\n        return\n    server = request.node.funcargs.get(\"ftp_server\")\n    if server is not None:\n        server.clear_failures()\n    # Restore clean directory structure\n    root = str(ftp_root)\n    for entry in os.listdir(root):\n        path = os.path.join(root, entry)\n        if os.path.isdir(path):\n            shutil.rmtree(path)\n        else:\n            os.remove(path)\n    for d in BAMBU_DIRS:\n        os.makedirs(os.path.join(root, d), exist_ok=True)\n\n\n@pytest.fixture()\ndef ftp_client_factory(ftp_server):\n    \"\"\"Factory that creates BambuFTPClient instances pointed at the mock server.\"\"\"\n\n    def _make_client(\n        printer_model: str = \"X1C\",\n        force_prot_c: bool = False,\n        access_code: str = \"12345678\",\n        timeout: float = 10.0,\n    ) -> BambuFTPClient:\n        client = BambuFTPClient(\n            ip_address=\"127.0.0.1\",\n            access_code=access_code,\n            timeout=timeout,\n            printer_model=printer_model,\n            force_prot_c=force_prot_c,\n        )\n        # Override port to point at mock server\n        client.FTP_PORT = ftp_server.port\n        return client\n\n    return _make_client\n\n\n@pytest.fixture(autouse=True)\ndef clear_ftp_mode_cache():\n    \"\"\"Clear BambuFTPClient mode cache before and after each test.\"\"\"\n    BambuFTPClient._mode_cache.clear()\n    yield\n    BambuFTPClient._mode_cache.clear()\n\n\n@pytest.fixture()\ndef patch_ftp_port(ftp_server):\n    \"\"\"Patch FTP_PORT at class level for async wrapper tests.\n\n    Async wrappers create their own BambuFTPClient instances internally,\n    so we need to patch the class-level default port.\n    \"\"\"\n    with patch.object(BambuFTPClient, \"FTP_PORT\", ftp_server.port):\n        yield ftp_server\n"
  },
  {
    "path": "backend/tests/unit/services/mock_ftp_server.py",
    "content": "\"\"\"Mock implicit FTPS server for testing BambuFTPClient.\n\nBuilt on pyftpdlib with implicit TLS support to match Bambu printer behavior.\nSupports failure injection, custom AVBL command, and filesystem inspection.\n\"\"\"\n\nimport logging\nimport os\nimport threading\nimport time\n\nfrom pyftpdlib.authorizers import DummyAuthorizer\nfrom pyftpdlib.handlers import TLS_FTPHandler\nfrom pyftpdlib.servers import FTPServer\n\n\nclass ImplicitTLS_FTPHandler(TLS_FTPHandler):\n    \"\"\"FTP handler that wraps the socket in TLS before sending the 220 banner.\n\n    This implements implicit FTPS (port 990 style) where the TLS handshake\n    happens immediately on connect, before any FTP protocol exchange.\n    pyftpdlib only natively supports explicit FTPS (AUTH TLS after connect).\n    \"\"\"\n\n    # Per-class failure injection map: command -> (code, message, remaining_count)\n    # -1 remaining_count = permanent failure\n    _failure_map: dict = {}\n\n    # AVBL command response (bytes available)\n    _avbl_bytes: int = 1073741824  # 1 GB default\n\n    # Register AVBL as a recognized FTP command (pyftpdlib requires this)\n    proto_cmds = {\n        **TLS_FTPHandler.proto_cmds,\n        \"AVBL\": {\n            \"perm\": None,\n            \"auth\": True,\n            \"arg\": None,\n            \"help\": \"Syntax: AVBL (get available bytes).\",\n        },\n    }\n\n    def handle(self):\n        \"\"\"Wrap socket in TLS immediately, then send 220 banner.\"\"\"\n        self.secure_connection(self.get_ssl_context())\n        super().handle()\n\n    def ftp_PROT(self, line):\n        \"\"\"Override PROT to auto-set _pbsz for implicit FTPS.\n\n        In implicit FTPS the connection is already TLS-secured, so requiring\n        a separate PBSZ command is unnecessary. Python's ftplib prot_c()\n        doesn't send PBSZ first (unlike prot_p()), causing 503 errors.\n        Real Bambu printers don't enforce this for implicit FTPS either.\n        \"\"\"\n        self._pbsz = True\n        return super().ftp_PROT(line)\n\n    def _check_failure(self, command: str, line: str):\n        \"\"\"Check if a failure is injected for this command.\n\n        Returns True if a failure response was sent, False otherwise.\n        \"\"\"\n        if command in self._failure_map:\n            code, message, remaining = self._failure_map[command]\n            if remaining != 0:\n                if remaining > 0:\n                    self._failure_map[command] = (code, message, remaining - 1)\n                    if remaining - 1 == 0:\n                        del self._failure_map[command]\n                self.respond(f\"{code} {message}\")\n                return True\n        return False\n\n    def ftp_AVBL(self, line):\n        \"\"\"Handle custom AVBL command (available bytes on storage).\"\"\"\n        self.respond(f\"213 {self._avbl_bytes}\")\n\n    def ftp_RETR(self, file):\n        if self._check_failure(\"RETR\", file):\n            return\n        return super().ftp_RETR(file)\n\n    def ftp_STOR(self, file):\n        if self._check_failure(\"STOR\", file):\n            return\n        return super().ftp_STOR(file)\n\n    def ftp_DELE(self, line):\n        if self._check_failure(\"DELE\", line):\n            return\n        return super().ftp_DELE(line)\n\n    def ftp_CWD(self, path):\n        if self._check_failure(\"CWD\", path):\n            return\n        return super().ftp_CWD(path)\n\n    def ftp_LIST(self, path=\"\"):\n        if self._check_failure(\"LIST\", path):\n            return\n        return super().ftp_LIST(path)\n\n    def ftp_SIZE(self, path):\n        if self._check_failure(\"SIZE\", path):\n            return\n        # Override to allow SIZE in ASCII mode (real Bambu printers allow it,\n        # and BambuFTPClient.get_file_size() doesn't set TYPE I first)\n        if not self.fs.isfile(self.fs.realpath(path)):\n            self.respond(f\"550 {self.fs.fs2ftp(path)} is not retrievable.\")\n            return\n        try:\n            size = self.run_as_current_user(self.fs.getsize, path)\n        except OSError as err:\n            self.respond(f\"550 {err}.\")\n        else:\n            self.respond(f\"213 {size}\")\n\n    def ftp_PASS(self, line):\n        if self._check_failure(\"PASS\", line):\n            return\n        return super().ftp_PASS(line)\n\n\nclass MockBambuFTPServer:\n    \"\"\"Manages a mock implicit FTPS server in a background thread.\n\n    Simulates a Bambu printer FTP server with:\n    - Implicit TLS (like real printers on port 990)\n    - Standard Bambu directory structure\n    - AVBL command support\n    - Per-command failure injection for testing error paths\n    \"\"\"\n\n    def __init__(\n        self,\n        host: str,\n        port: int,\n        root_dir: str,\n        cert_path: str,\n        key_path: str,\n        access_code: str = \"12345678\",\n    ):\n        self.host = host\n        self.port = port\n        self.root_dir = root_dir\n        self.cert_path = cert_path\n        self.key_path = key_path\n        self.access_code = access_code\n        self._server: FTPServer | None = None\n        self._thread: threading.Thread | None = None\n        # Create a unique handler class per instance so _failure_map is isolated\n        self._handler_class = type(\n            \"TestFTPHandler\",\n            (ImplicitTLS_FTPHandler,),\n            {\n                \"_failure_map\": {},\n                \"_avbl_bytes\": 1073741824,\n            },\n        )\n\n    def start(self):\n        \"\"\"Start the FTP server in a background daemon thread.\"\"\"\n        authorizer = DummyAuthorizer()\n        authorizer.add_user(\"bblp\", self.access_code, self.root_dir, perm=\"elradfmwMT\")\n\n        handler = self._handler_class\n        handler.authorizer = authorizer\n        handler.certfile = self.cert_path\n        handler.keyfile = self.key_path\n        handler.passive_ports = range(60000, 60101)\n        handler.tls_control_required = False\n        handler.tls_data_required = False\n        # Reset ssl_context so it picks up our cert/key\n        handler.ssl_context = None\n\n        # Suppress pyftpdlib's noisy logging (startup/shutdown banners)\n        # to avoid \"I/O operation on closed file\" errors when xdist\n        # workers tear down while the daemon thread is still logging.\n        logging.getLogger(\"pyftpdlib\").setLevel(logging.CRITICAL)\n\n        self._server = FTPServer((self.host, self.port), handler)\n        self._server.max_cons = 10\n        self._server.max_cons_per_ip = 5\n\n        self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)\n        self._thread.start()\n        # Brief wait for server to be ready\n        time.sleep(0.1)\n\n    def stop(self):\n        \"\"\"Stop the FTP server and wait for thread to exit.\"\"\"\n        if self._server:\n            self._server.close_all()\n        if self._thread:\n            self._thread.join(timeout=5)\n        self._server = None\n        self._thread = None\n\n    def inject_failure(self, command: str, code: int, message: str, count: int = -1):\n        \"\"\"Inject a failure response for a specific FTP command.\n\n        Args:\n            command: FTP command name (RETR, STOR, DELE, CWD, LIST, SIZE, PASS)\n            code: FTP response code (e.g. 550, 553)\n            message: Response message\n            count: Number of times to fail (-1 = permanent)\n        \"\"\"\n        self._handler_class._failure_map[command] = (code, message, count)\n\n    def clear_failures(self):\n        \"\"\"Remove all injected failures.\"\"\"\n        self._handler_class._failure_map.clear()\n\n    def set_avbl_bytes(self, n: int):\n        \"\"\"Set the response value for the AVBL command.\"\"\"\n        self._handler_class._avbl_bytes = n\n\n    def add_file(self, relative_path: str, content: bytes = b\"\"):\n        \"\"\"Add a file to the server's filesystem.\"\"\"\n        full_path = os.path.join(self.root_dir, relative_path.lstrip(\"/\"))\n        os.makedirs(os.path.dirname(full_path), exist_ok=True)\n        with open(full_path, \"wb\") as f:\n            f.write(content)\n\n    def add_directory(self, relative_path: str):\n        \"\"\"Create a directory in the server's filesystem.\"\"\"\n        full_path = os.path.join(self.root_dir, relative_path.lstrip(\"/\"))\n        os.makedirs(full_path, exist_ok=True)\n\n    def file_exists(self, relative_path: str) -> bool:\n        \"\"\"Check if a file exists on the server.\"\"\"\n        full_path = os.path.join(self.root_dir, relative_path.lstrip(\"/\"))\n        return os.path.isfile(full_path)\n\n    def read_file(self, relative_path: str) -> bytes:\n        \"\"\"Read file content from the server's filesystem.\"\"\"\n        full_path = os.path.join(self.root_dir, relative_path.lstrip(\"/\"))\n        with open(full_path, \"rb\") as f:\n            return f.read()\n"
  },
  {
    "path": "backend/tests/unit/services/test_archive_copy.py",
    "content": "\"\"\"\nTests for the 3MF archive copy path.\n\nRegression guards for #1032 where large 3MF files were silently truncated\nduring archiving on Raspberry Pi OS / armv7l, leaving the archive row in\nplace but the on-disk file no longer a valid ZIP.\n\"\"\"\n\nimport io\nimport logging\nimport os\nimport zipfile\nfrom pathlib import Path\n\nimport pytest\n\nfrom backend.app.services.archive import ThreeMFParser, _copy_and_fsync\n\n\ndef _make_3mf(path: Path, payload_size: int = 0) -> None:\n    \"\"\"Write a minimal valid 3MF (ZIP) file with an optional large payload.\"\"\"\n    with zipfile.ZipFile(path, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        zf.writestr(\"Metadata/slice_info.config\", \"<config/>\")\n        if payload_size:\n            # Uncompressible payload forces real bytes on disk.\n            zf.writestr(\"blob.bin\", os.urandom(payload_size))\n\n\nclass TestCopyAndFsync:\n    def test_copies_small_file_byte_for_byte(self, tmp_path: Path) -> None:\n        src = tmp_path / \"src.bin\"\n        dst = tmp_path / \"dst.bin\"\n        src.write_bytes(b\"hello world\")\n\n        _copy_and_fsync(src, dst)\n\n        assert dst.read_bytes() == b\"hello world\"\n\n    def test_copies_large_file_byte_for_byte(self, tmp_path: Path) -> None:\n        \"\"\"Spans multiple 1 MiB chunks to exercise the copy loop.\"\"\"\n        src = tmp_path / \"src.bin\"\n        dst = tmp_path / \"dst.bin\"\n        payload = os.urandom(5 * 1024 * 1024 + 123)  # 5 MiB + change\n        src.write_bytes(payload)\n\n        _copy_and_fsync(src, dst)\n\n        assert dst.stat().st_size == len(payload)\n        assert dst.read_bytes() == payload\n\n    def test_preserves_mtime_via_copystat(self, tmp_path: Path) -> None:\n        src = tmp_path / \"src.bin\"\n        dst = tmp_path / \"dst.bin\"\n        src.write_bytes(b\"x\")\n        os.utime(src, (1_700_000_000, 1_700_000_000))\n\n        _copy_and_fsync(src, dst)\n\n        assert int(dst.stat().st_mtime) == 1_700_000_000\n\n    def test_overwrites_existing_destination(self, tmp_path: Path) -> None:\n        src = tmp_path / \"src.bin\"\n        dst = tmp_path / \"dst.bin\"\n        src.write_bytes(b\"new\")\n        dst.write_bytes(b\"old old old\")\n\n        _copy_and_fsync(src, dst)\n\n        assert dst.read_bytes() == b\"new\"\n\n    def test_produces_valid_zip_on_3mf(self, tmp_path: Path) -> None:\n        \"\"\"The whole point of #1032: copy of a valid 3MF stays a valid ZIP.\"\"\"\n        src = tmp_path / \"src.3mf\"\n        dst = tmp_path / \"dst.3mf\"\n        _make_3mf(src, payload_size=2 * 1024 * 1024)  # 2 MiB, multi-chunk\n        assert zipfile.is_zipfile(src)\n\n        _copy_and_fsync(src, dst)\n\n        assert zipfile.is_zipfile(dst)\n\n\nclass TestThreeMFParserErrorVisibility:\n    def test_parse_logs_warning_on_corrupted_zip(\n        self,\n        tmp_path: Path,\n        caplog: pytest.LogCaptureFixture,\n    ) -> None:\n        \"\"\"Silent `except Exception: pass` was how #1032 escaped detection;\n        parse() must now surface the failure at WARNING.\"\"\"\n        corrupted = tmp_path / \"bad.3mf\"\n        corrupted.write_bytes(b\"not a zip\")\n\n        with caplog.at_level(logging.WARNING, logger=\"backend.app.services.archive\"):\n            result = ThreeMFParser(corrupted).parse()\n\n        assert result == {}\n        assert any(\"failed to parse\" in rec.message and str(corrupted) in rec.message for rec in caplog.records), (\n            \"Expected a WARNING mentioning the failed parse and file path\"\n        )\n\n    def test_parse_returns_partial_metadata_without_raising(\n        self,\n        tmp_path: Path,\n    ) -> None:\n        \"\"\"A valid-but-minimal 3MF must still parse without raising.\"\"\"\n        p = tmp_path / \"ok.3mf\"\n        with zipfile.ZipFile(p, \"w\") as zf:\n            zf.writestr(\"Metadata/slice_info.config\", \"<config/>\")\n\n        result = ThreeMFParser(p).parse()\n\n        # No assertions about which keys are present — just that it didn't blow up.\n        assert isinstance(result, dict)\n\n\nclass TestZipFileSentinel:\n    \"\"\"Sanity check the sentinel the archive pipeline relies on.\"\"\"\n\n    def test_is_zipfile_on_truncated_zip_returns_false(self, tmp_path: Path) -> None:\n        \"\"\"Truncating a valid ZIP mid-stream must flip is_zipfile() to False.\n        This is the exact post-condition archive_print now trusts.\"\"\"\n        src = tmp_path / \"src.3mf\"\n        _make_3mf(src, payload_size=1024 * 1024)\n        full = src.read_bytes()\n        assert zipfile.is_zipfile(io.BytesIO(full))\n\n        truncated = tmp_path / \"truncated.3mf\"\n        # Strip the trailing end-of-central-directory record — exactly what a\n        # short sendfile return would leave behind.\n        truncated.write_bytes(full[: len(full) // 2])\n\n        assert not zipfile.is_zipfile(truncated)\n"
  },
  {
    "path": "backend/tests/unit/services/test_archive_service.py",
    "content": "\"\"\"Unit tests for the archive service.\"\"\"\n\nfrom datetime import datetime\n\n\nclass TestArchiveServiceHelpers:\n    \"\"\"Tests for archive service helper functions.\"\"\"\n\n    def test_parse_print_time_seconds(self):\n        \"\"\"Test parsing print time to seconds.\"\"\"\n        # Import the actual function if available, otherwise test the logic\n        # 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds\n        _time_str = \"2h 30m 15s\"  # Example format\n        # Parse hours\n        hours = 2\n        minutes = 30\n        seconds = 15\n        total = hours * 3600 + minutes * 60 + seconds\n        assert total == 9015\n\n    def test_parse_filament_grams(self):\n        \"\"\"Test parsing filament usage to grams.\"\"\"\n        # Example: \"150.5g\" -> 150.5\n        filament_str = \"150.5g\"\n        grams = float(filament_str.replace(\"g\", \"\"))\n        assert grams == 150.5\n\n    def test_format_duration(self):\n        \"\"\"Test formatting seconds to human readable duration.\"\"\"\n        # 3661 seconds = 1h 1m 1s\n        seconds = 3661\n        hours = seconds // 3600\n        minutes = (seconds % 3600) // 60\n        secs = seconds % 60\n        assert hours == 1\n        assert minutes == 1\n        assert secs == 1\n\n\nclass TestArchiveDataParsing:\n    \"\"\"Tests for parsing archive data from MQTT messages.\"\"\"\n\n    def test_parse_gcode_state(self):\n        \"\"\"Test parsing gcode state.\"\"\"\n        states = {\n            \"RUNNING\": \"printing\",\n            \"FINISH\": \"completed\",\n            \"FAILED\": \"failed\",\n            \"IDLE\": \"idle\",\n            \"PAUSE\": \"paused\",\n        }\n        for gcode_state, expected in states.items():\n            # Simple state mapping\n            mapped = gcode_state.lower()\n            if gcode_state == \"RUNNING\":\n                mapped = \"printing\"\n            elif gcode_state == \"FINISH\":\n                mapped = \"completed\"\n            elif gcode_state == \"FAILED\":\n                mapped = \"failed\"\n            elif gcode_state == \"IDLE\":\n                mapped = \"idle\"\n            elif gcode_state == \"PAUSE\":\n                mapped = \"paused\"\n            assert mapped == expected\n\n    def test_parse_progress(self):\n        \"\"\"Test parsing print progress.\"\"\"\n        # mc_percent is the progress field in MQTT messages\n        data = {\"mc_percent\": 75}\n        progress = data.get(\"mc_percent\", 0)\n        assert progress == 75\n        assert 0 <= progress <= 100\n\n    def test_parse_layer_info(self):\n        \"\"\"Test parsing layer information.\"\"\"\n        data = {\n            \"layer_num\": 50,\n            \"total_layers\": 200,\n        }\n        current_layer = data.get(\"layer_num\", 0)\n        total_layers = data.get(\"total_layers\", 0)\n        assert current_layer == 50\n        assert total_layers == 200\n        if total_layers > 0:\n            layer_percent = (current_layer / total_layers) * 100\n            assert layer_percent == 25.0\n\n\nclass TestArchiveFilePaths:\n    \"\"\"Tests for archive file path handling.\"\"\"\n\n    def test_generate_archive_path(self):\n        \"\"\"Test generating archive file paths.\"\"\"\n        printer_name = \"X1C_01\"\n        _print_name = \"benchy\"  # Example print name\n        timestamp = datetime(2024, 1, 15, 14, 30, 0)\n\n        # Expected pattern: archives/{printer}/{year}/{month}/{filename}\n        year = timestamp.year\n        month = f\"{timestamp.month:02d}\"\n        expected_dir = f\"archives/{printer_name}/{year}/{month}\"\n\n        assert \"archives\" in expected_dir\n        assert printer_name in expected_dir\n        assert str(year) in expected_dir\n\n    def test_sanitize_filename(self):\n        \"\"\"Test filename sanitization.\"\"\"\n        # Characters to remove: / \\ : * ? \" < > |\n        dirty_name = \"test:file<name>.3mf\"\n        # Simple sanitization\n        safe_chars = []\n        for c in dirty_name:\n            if c not in '\\\\/:*?\"<>|':\n                safe_chars.append(c)\n        clean_name = \"\".join(safe_chars)\n        assert \":\" not in clean_name\n        assert \"<\" not in clean_name\n        assert \">\" not in clean_name\n\n    def test_thumbnail_path(self):\n        \"\"\"Test thumbnail path generation.\"\"\"\n        archive_path = \"archives/X1C_01/2024/01/benchy.3mf\"\n        # Thumbnail typically has same path with _thumb.png suffix\n        base_path = archive_path.rsplit(\".\", 1)[0]\n        thumbnail_path = f\"{base_path}_thumb.png\"\n        assert thumbnail_path.endswith(\"_thumb.png\")\n        assert \"benchy\" in thumbnail_path\n\n\nclass TestArchiveStatus:\n    \"\"\"Tests for archive status handling.\"\"\"\n\n    def test_valid_status_values(self):\n        \"\"\"Test valid archive status values.\"\"\"\n        valid_statuses = [\"completed\", \"failed\", \"cancelled\", \"stopped\"]\n        for status in valid_statuses:\n            assert status in valid_statuses\n\n    def test_status_from_gcode_state(self):\n        \"\"\"Test mapping gcode state to archive status.\"\"\"\n        state_mapping = {\n            \"FINISH\": \"completed\",\n            \"FAILED\": \"failed\",\n            \"CANCEL\": \"cancelled\",\n        }\n        for gcode_state, expected_status in state_mapping.items():\n            assert state_mapping[gcode_state] == expected_status\n\n\nclass TestArchiveFilamentData:\n    \"\"\"Tests for filament data parsing.\"\"\"\n\n    def test_parse_ams_filament(self):\n        \"\"\"Test parsing AMS filament information.\"\"\"\n        ams_data = {\n            \"ams\": {\n                \"ams\": [\n                    {\n                        \"tray\": [\n                            {\"tray_type\": \"PLA\", \"tray_color\": \"FF0000\"},\n                            {\"tray_type\": \"PETG\", \"tray_color\": \"00FF00\"},\n                        ]\n                    }\n                ]\n            }\n        }\n        trays = ams_data[\"ams\"][\"ams\"][0][\"tray\"]\n        assert trays[0][\"tray_type\"] == \"PLA\"\n        assert trays[1][\"tray_type\"] == \"PETG\"\n\n    def test_parse_filament_color_hex(self):\n        \"\"\"Test parsing filament color from hex.\"\"\"\n        color_hex = \"FF5500\"\n        # Should be valid hex\n        assert len(color_hex) == 6\n        r = int(color_hex[0:2], 16)\n        g = int(color_hex[2:4], 16)\n        b = int(color_hex[4:6], 16)\n        assert r == 255\n        assert g == 85\n        assert b == 0\n\n    def test_calculate_filament_cost(self):\n        \"\"\"Test calculating filament cost.\"\"\"\n        grams_used = 150.0\n        cost_per_kg = 25.0  # $25 per kg\n        cost = (grams_used / 1000) * cost_per_kg\n        assert cost == 3.75\n\n\nclass TestArchiveThumbnails:\n    \"\"\"Tests for archive thumbnail handling.\"\"\"\n\n    def test_thumbnail_file_types(self):\n        \"\"\"Test supported thumbnail file types.\"\"\"\n        supported_types = [\".png\", \".jpg\", \".jpeg\"]\n        for ext in supported_types:\n            assert ext.startswith(\".\")\n            assert ext.lower() in [\".png\", \".jpg\", \".jpeg\"]\n\n    def test_extract_thumbnail_from_3mf(self):\n        \"\"\"Test thumbnail extraction concept from 3MF.\"\"\"\n        # 3MF files are ZIP archives containing:\n        # - Metadata/thumbnail.png\n        # - 3D/3dmodel.model\n        expected_thumbnail_paths = [\n            \"Metadata/thumbnail.png\",\n            \"Metadata/plate_1.png\",\n        ]\n        for path in expected_thumbnail_paths:\n            assert \"png\" in path.lower()\n\n\nclass TestPrintableObjectsExtraction:\n    \"\"\"Tests for extracting printable objects count from 3MF files.\"\"\"\n\n    def test_extract_printable_objects_from_slice_info(self):\n        \"\"\"Test parsing printable objects from slice_info.config XML.\"\"\"\n        from defusedxml import ElementTree as ET\n\n        # Example slice_info.config content with 4 objects\n        slice_info_xml = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate plate_idx=\"1\">\n                <metadata key=\"prediction\" value=\"3600\" />\n                <metadata key=\"weight\" value=\"50.5\" />\n                <object identify_id=\"1\" name=\"Part_A\" skipped=\"false\" />\n                <object identify_id=\"2\" name=\"Part_B\" skipped=\"false\" />\n                <object identify_id=\"3\" name=\"Part_C\" skipped=\"false\" />\n                <object identify_id=\"4\" name=\"Part_D\" skipped=\"true\" />\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(slice_info_xml)\n        plate = root.find(\".//plate\")\n\n        # Count non-skipped objects (should be 3, not 4)\n        count = 0\n        for obj in plate.findall(\"object\"):\n            skipped = obj.get(\"skipped\", \"false\")\n            if skipped.lower() != \"true\":\n                count += 1\n\n        assert count == 3  # 3 objects (Part_D is skipped)\n\n    def test_extract_printable_objects_empty_plate(self):\n        \"\"\"Test handling plate with no objects.\"\"\"\n        from defusedxml import ElementTree as ET\n\n        slice_info_xml = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate plate_idx=\"1\">\n                <metadata key=\"prediction\" value=\"0\" />\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(slice_info_xml)\n        plate = root.find(\".//plate\")\n\n        count = 0\n        for obj in plate.findall(\"object\"):\n            skipped = obj.get(\"skipped\", \"false\")\n            if skipped.lower() != \"true\":\n                count += 1\n\n        assert count == 0\n\n    def test_extract_printable_objects_all_skipped(self):\n        \"\"\"Test handling plate where all objects are skipped.\"\"\"\n        from defusedxml import ElementTree as ET\n\n        slice_info_xml = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate plate_idx=\"1\">\n                <object identify_id=\"1\" name=\"Part_A\" skipped=\"true\" />\n                <object identify_id=\"2\" name=\"Part_B\" skipped=\"true\" />\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(slice_info_xml)\n        plate = root.find(\".//plate\")\n\n        count = 0\n        for obj in plate.findall(\"object\"):\n            skipped = obj.get(\"skipped\", \"false\")\n            if skipped.lower() != \"true\":\n                count += 1\n\n        assert count == 0  # All objects skipped\n\n\nclass TestThreeMFPlateIndexExtraction:\n    \"\"\"Tests for extracting plate index from multi-plate 3MF exports (Issue #92).\"\"\"\n\n    def test_extract_plate_index_from_slice_info(self):\n        \"\"\"Test parsing plate index from slice_info.config metadata.\"\"\"\n        from defusedxml import ElementTree as ET\n\n        # Single-plate export from plate 5 of a multi-plate project\n        slice_info_xml = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"index\" value=\"5\" />\n                <metadata key=\"prediction\" value=\"3600\" />\n                <metadata key=\"weight\" value=\"50.5\" />\n                <object identify_id=\"1\" name=\"Part_A\" skipped=\"false\" />\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(slice_info_xml)\n        plate = root.find(\".//plate\")\n\n        plate_index = None\n        for meta in plate.findall(\"metadata\"):\n            if meta.get(\"key\") == \"index\":\n                plate_index = int(meta.get(\"value\"))\n                break\n\n        assert plate_index == 5\n\n    def test_extract_plate_index_plate_1(self):\n        \"\"\"Test parsing plate index when it's plate 1.\"\"\"\n        from defusedxml import ElementTree as ET\n\n        slice_info_xml = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"index\" value=\"1\" />\n                <metadata key=\"prediction\" value=\"1800\" />\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(slice_info_xml)\n        plate = root.find(\".//plate\")\n\n        plate_index = None\n        for meta in plate.findall(\"metadata\"):\n            if meta.get(\"key\") == \"index\":\n                plate_index = int(meta.get(\"value\"))\n                break\n\n        assert plate_index == 1\n\n    def test_thumbnail_path_uses_plate_number(self):\n        \"\"\"Test that thumbnail path correctly uses the extracted plate number.\"\"\"\n        plate_number = 5\n        thumbnail_paths = []\n\n        if plate_number:\n            thumbnail_paths.append(f\"Metadata/plate_{plate_number}.png\")\n\n        thumbnail_paths.extend(\n            [\n                \"Metadata/plate_1.png\",\n                \"Metadata/thumbnail.png\",\n            ]\n        )\n\n        # First priority should be plate_5.png\n        assert thumbnail_paths[0] == \"Metadata/plate_5.png\"\n\n    @staticmethod\n    def _enhance_print_name(print_name: str, plate_index: int) -> str:\n        \"\"\"Apply plate name enhancement logic from archive.py.\"\"\"\n        if plate_index and plate_index > 1:\n            if print_name and f\"Plate {plate_index}\" not in print_name:\n                print_name = f\"{print_name} - Plate {plate_index}\"\n        return print_name\n\n    def test_print_name_enhanced_for_plate_greater_than_1(self):\n        \"\"\"Test that print_name is enhanced with plate info for plate > 1.\"\"\"\n        assert self._enhance_print_name(\"Benchy\", 5) == \"Benchy - Plate 5\"\n\n    def test_print_name_not_enhanced_for_plate_1(self):\n        \"\"\"Test that print_name is NOT enhanced for plate 1.\"\"\"\n        assert self._enhance_print_name(\"Benchy\", 1) == \"Benchy\"\n\n    def test_print_name_not_duplicated(self):\n        \"\"\"Test that plate info is not added if already present in print_name.\"\"\"\n        assert self._enhance_print_name(\"Benchy - Plate 5\", 5) == \"Benchy - Plate 5\"\n\n    def test_high_plate_number_extraction(self):\n        \"\"\"Test extracting high plate numbers (e.g., plate 28).\"\"\"\n        from defusedxml import ElementTree as ET\n\n        slice_info_xml = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"index\" value=\"28\" />\n                <metadata key=\"prediction\" value=\"7200\" />\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(slice_info_xml)\n        plate = root.find(\".//plate\")\n\n        plate_index = None\n        for meta in plate.findall(\"metadata\"):\n            if meta.get(\"key\") == \"index\":\n                plate_index = int(meta.get(\"value\"))\n                break\n\n        assert plate_index == 28\n\n        # Verify thumbnail would use correct plate\n        thumbnail_path = f\"Metadata/plate_{plate_index}.png\"\n        assert thumbnail_path == \"Metadata/plate_28.png\"\n\n\nclass TestMultiPlate3MFParsing:\n    \"\"\"Tests for parsing multi-plate 3MF files (Issue #93).\"\"\"\n\n    def test_parse_multiple_plates_from_slice_info(self):\n        \"\"\"Test parsing multiple plates from slice_info.config.\"\"\"\n        from defusedxml import ElementTree as ET\n\n        # Multi-plate 3MF with 3 plates\n        slice_info_xml = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"index\" value=\"1\" />\n                <metadata key=\"prediction\" value=\"3600\" />\n                <metadata key=\"weight\" value=\"50.0\" />\n                <filament id=\"1\" type=\"PLA\" color=\"#FF0000\" used_g=\"25.0\" used_m=\"8.5\" />\n                <object identify_id=\"1\" name=\"Part_A\" skipped=\"false\" />\n            </plate>\n            <plate>\n                <metadata key=\"index\" value=\"2\" />\n                <metadata key=\"prediction\" value=\"7200\" />\n                <metadata key=\"weight\" value=\"100.0\" />\n                <filament id=\"2\" type=\"PETG\" color=\"#00FF00\" used_g=\"50.0\" used_m=\"17.0\" />\n                <object identify_id=\"2\" name=\"Part_B\" skipped=\"false\" />\n            </plate>\n            <plate>\n                <metadata key=\"index\" value=\"3\" />\n                <metadata key=\"prediction\" value=\"1800\" />\n                <metadata key=\"weight\" value=\"25.0\" />\n                <filament id=\"1\" type=\"PLA\" color=\"#FF0000\" used_g=\"12.5\" used_m=\"4.2\" />\n                <filament id=\"3\" type=\"TPU\" color=\"#0000FF\" used_g=\"12.5\" used_m=\"4.2\" />\n                <object identify_id=\"3\" name=\"Part_C\" skipped=\"false\" />\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(slice_info_xml)\n        plates = root.findall(\".//plate\")\n\n        assert len(plates) == 3\n\n        # Parse each plate\n        plate_data = []\n        for plate_elem in plates:\n            plate_info = {\"index\": None, \"filaments\": []}\n\n            for meta in plate_elem.findall(\"metadata\"):\n                if meta.get(\"key\") == \"index\":\n                    plate_info[\"index\"] = int(meta.get(\"value\"))\n\n            for filament_elem in plate_elem.findall(\"filament\"):\n                used_g = float(filament_elem.get(\"used_g\", \"0\"))\n                if used_g > 0:\n                    plate_info[\"filaments\"].append(\n                        {\n                            \"slot_id\": int(filament_elem.get(\"id\")),\n                            \"type\": filament_elem.get(\"type\"),\n                            \"color\": filament_elem.get(\"color\"),\n                            \"used_grams\": used_g,\n                        }\n                    )\n\n            plate_data.append(plate_info)\n\n        # Verify plate 1\n        assert plate_data[0][\"index\"] == 1\n        assert len(plate_data[0][\"filaments\"]) == 1\n        assert plate_data[0][\"filaments\"][0][\"slot_id\"] == 1\n        assert plate_data[0][\"filaments\"][0][\"type\"] == \"PLA\"\n\n        # Verify plate 2\n        assert plate_data[1][\"index\"] == 2\n        assert len(plate_data[1][\"filaments\"]) == 1\n        assert plate_data[1][\"filaments\"][0][\"slot_id\"] == 2\n        assert plate_data[1][\"filaments\"][0][\"type\"] == \"PETG\"\n\n        # Verify plate 3 (has 2 filaments)\n        assert plate_data[2][\"index\"] == 3\n        assert len(plate_data[2][\"filaments\"]) == 2\n        filament_types = {f[\"type\"] for f in plate_data[2][\"filaments\"]}\n        assert filament_types == {\"PLA\", \"TPU\"}\n\n    def test_filter_filaments_by_plate_id(self):\n        \"\"\"Test filtering filaments for a specific plate.\"\"\"\n        from defusedxml import ElementTree as ET\n\n        slice_info_xml = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"index\" value=\"1\" />\n                <filament id=\"1\" type=\"PLA\" color=\"#FF0000\" used_g=\"25.0\" />\n            </plate>\n            <plate>\n                <metadata key=\"index\" value=\"2\" />\n                <filament id=\"2\" type=\"PETG\" color=\"#00FF00\" used_g=\"50.0\" />\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(slice_info_xml)\n\n        # Filter for plate 2 only\n        target_plate_id = 2\n        filaments = []\n\n        for plate_elem in root.findall(\".//plate\"):\n            plate_index = None\n            for meta in plate_elem.findall(\"metadata\"):\n                if meta.get(\"key\") == \"index\":\n                    plate_index = int(meta.get(\"value\", \"0\"))\n                    break\n\n            if plate_index == target_plate_id:\n                for filament_elem in plate_elem.findall(\"filament\"):\n                    used_g = float(filament_elem.get(\"used_g\", \"0\"))\n                    if used_g > 0:\n                        filaments.append(\n                            {\n                                \"slot_id\": int(filament_elem.get(\"id\")),\n                                \"type\": filament_elem.get(\"type\"),\n                            }\n                        )\n                break\n\n        # Should only have plate 2's filament\n        assert len(filaments) == 1\n        assert filaments[0][\"slot_id\"] == 2\n        assert filaments[0][\"type\"] == \"PETG\"\n\n    def test_detect_multi_plate_from_gcode_files(self):\n        \"\"\"Test detecting multiple plates from gcode file presence.\"\"\"\n        # Simulate namelist from a multi-plate 3MF\n        namelist = [\n            \"Metadata/plate_1.gcode\",\n            \"Metadata/plate_2.gcode\",\n            \"Metadata/plate_3.gcode\",\n            \"Metadata/plate_1.png\",\n            \"Metadata/plate_2.png\",\n            \"Metadata/plate_3.png\",\n            \"Metadata/slice_info.config\",\n            \"3D/3dmodel.model\",\n        ]\n\n        # Extract plate indices from gcode files\n        gcode_files = [n for n in namelist if n.startswith(\"Metadata/plate_\") and n.endswith(\".gcode\")]\n        plate_indices = []\n        for gf in gcode_files:\n            plate_str = gf[15:-6]  # Remove \"Metadata/plate_\" and \".gcode\"\n            plate_indices.append(int(plate_str))\n\n        plate_indices.sort()\n\n        assert len(plate_indices) == 3\n        assert plate_indices == [1, 2, 3]\n\n        # Verify it's a multi-plate file\n        is_multi_plate = len(plate_indices) > 1\n        assert is_multi_plate is True\n\n    def test_single_plate_export_not_multi_plate(self):\n        \"\"\"Test that single-plate exports are not detected as multi-plate.\"\"\"\n        # Simulate namelist from a single-plate export (plate 5 only)\n        namelist = [\n            \"Metadata/plate_5.gcode\",\n            \"Metadata/plate_1.png\",\n            \"Metadata/plate_2.png\",\n            \"Metadata/plate_3.png\",\n            \"Metadata/plate_4.png\",\n            \"Metadata/plate_5.png\",  # All thumbnails present\n            \"Metadata/slice_info.config\",\n            \"3D/3dmodel.model\",\n        ]\n\n        # Extract plate indices from gcode files (not thumbnails!)\n        gcode_files = [n for n in namelist if n.startswith(\"Metadata/plate_\") and n.endswith(\".gcode\")]\n        plate_indices = []\n        for gf in gcode_files:\n            plate_str = gf[15:-6]\n            plate_indices.append(int(plate_str))\n\n        # Only one gcode file = single plate export\n        assert len(plate_indices) == 1\n        assert plate_indices[0] == 5\n\n        is_multi_plate = len(plate_indices) > 1\n        assert is_multi_plate is False\n\n\nclass TestReprintCostCalculation:\n    \"\"\"Tests for reprint cost calculation.\"\"\"\n\n    def test_cost_addition_logic(self):\n        \"\"\"Test that reprint costs are added correctly.\"\"\"\n        # Simulate the cost addition logic\n        existing_cost = 5.25  # Original print cost\n        filament_grams = 100.0\n        cost_per_kg = 25.0  # Default cost\n\n        # Calculate additional cost for reprint\n        additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)\n        assert additional_cost == 2.50\n\n        # Add to existing cost\n        new_total = round(existing_cost + additional_cost, 2)\n        assert new_total == 7.75\n\n    def test_cost_addition_with_none_existing(self):\n        \"\"\"Test cost addition when existing cost is None.\"\"\"\n        existing_cost = None\n        filament_grams = 200.0\n        cost_per_kg = 15.0\n\n        additional_cost = round((filament_grams / 1000) * cost_per_kg, 2)\n        assert additional_cost == 3.0\n\n        # When existing is None, just use additional\n        new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2)\n        assert new_total == 3.0\n\n    def test_cost_with_custom_filament_price(self):\n        \"\"\"Test cost calculation with custom filament price.\"\"\"\n        filament_grams = 150.0\n        custom_cost_per_kg = 35.0  # More expensive filament\n\n        cost = round((filament_grams / 1000) * custom_cost_per_kg, 2)\n        assert cost == 5.25\n\n    def test_multiple_reprints_accumulate(self):\n        \"\"\"Test that multiple reprints accumulate costs correctly.\"\"\"\n        filament_grams = 100.0\n        cost_per_kg = 20.0\n        single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2)\n        assert single_print_cost == 2.0\n\n        # After 3 prints (1 original + 2 reprints)\n        total_after_3_prints = round(single_print_cost * 3, 2)\n        assert total_after_3_prints == 6.0\n"
  },
  {
    "path": "backend/tests/unit/services/test_background_dispatch.py",
    "content": "\"\"\"Unit tests for background dispatch service.\"\"\"\n\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\nfrom backend.app.services.background_dispatch import (\n    ActiveDispatchState,\n    BackgroundDispatchService,\n    DispatchEnqueueRejected,\n    PrintDispatchJob,\n)\n\n\n@pytest.mark.asyncio\nasync def test_dispatch_rejects_when_printer_busy_printing():\n    \"\"\"Reject enqueue when target printer is already printing.\"\"\"\n    service = BackgroundDispatchService()\n\n    with (\n        patch(\n            \"backend.app.services.background_dispatch.printer_manager.get_status\",\n            return_value=SimpleNamespace(state=\"RUNNING\", gcode_file=\"active.gcode.3mf\"),\n        ),\n        pytest.raises(DispatchEnqueueRejected, match=\"currently busy printing\"),\n    ):\n        await service.dispatch_reprint_archive(\n            archive_id=1,\n            archive_name=\"Test Archive\",\n            printer_id=10,\n            printer_name=\"Printer A\",\n            options={},\n            requested_by_user_id=None,\n            requested_by_username=None,\n        )\n\n\n@pytest.mark.asyncio\nasync def test_dispatch_enqueues_job_and_broadcasts_state():\n    \"\"\"Enqueue succeeds and emits websocket queue update.\"\"\"\n    service = BackgroundDispatchService()\n\n    with (\n        patch(\"backend.app.services.background_dispatch.printer_manager.get_status\", return_value=None),\n        patch(\n            \"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock\n        ) as mock_broadcast,\n    ):\n        result = await service.dispatch_print_library_file(\n            file_id=22,\n            filename=\"cube.gcode.3mf\",\n            printer_id=7,\n            printer_name=\"Printer B\",\n            options={\"plate_id\": 2},\n            requested_by_user_id=5,\n            requested_by_username=\"tester\",\n        )\n\n    assert result[\"status\"] == \"dispatched\"\n    assert result[\"dispatch_job_id\"] == 1\n    assert result[\"dispatch_position\"] == 1\n    assert len(service._queued_jobs) == 1\n\n    mock_broadcast.assert_awaited_once()\n    payload = mock_broadcast.await_args.args[0]\n    assert payload[\"type\"] == \"background_dispatch\"\n    assert payload[\"data\"][\"recent_event\"][\"status\"] == \"dispatched\"\n\n\n@pytest.mark.asyncio\nasync def test_dispatch_library_file_defaults_cleanup_flag_false():\n    \"\"\"cleanup_library_after_dispatch defaults to False when not passed — protects\n    File Manager / Project Detail / queued-library-file paths from surprise deletion.\"\"\"\n    service = BackgroundDispatchService()\n\n    with (\n        patch(\"backend.app.services.background_dispatch.printer_manager.get_status\", return_value=None),\n        patch(\"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock),\n    ):\n        await service.dispatch_print_library_file(\n            file_id=1,\n            filename=\"cube.gcode.3mf\",\n            printer_id=1,\n            printer_name=\"Printer A\",\n            options={},\n            requested_by_user_id=None,\n            requested_by_username=None,\n        )\n\n    assert len(service._queued_jobs) == 1\n    assert service._queued_jobs[0].cleanup_library_after_dispatch is False\n\n\n@pytest.mark.asyncio\nasync def test_dispatch_library_file_propagates_cleanup_flag_true():\n    \"\"\"cleanup_library_after_dispatch=True arrives on the queued job so the runner\n    can delete the transient LibraryFile after the print is accepted by the printer.\"\"\"\n    service = BackgroundDispatchService()\n\n    with (\n        patch(\"backend.app.services.background_dispatch.printer_manager.get_status\", return_value=None),\n        patch(\"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock),\n    ):\n        await service.dispatch_print_library_file(\n            file_id=1,\n            filename=\"cube.gcode.3mf\",\n            printer_id=1,\n            printer_name=\"Printer A\",\n            options={},\n            requested_by_user_id=42,\n            requested_by_username=\"alice\",\n            cleanup_library_after_dispatch=True,\n        )\n\n    assert len(service._queued_jobs) == 1\n    job = service._queued_jobs[0]\n    assert job.cleanup_library_after_dispatch is True\n    # Sanity: other fields still wired correctly\n    assert job.requested_by_user_id == 42\n    assert job.requested_by_username == \"alice\"\n    assert job.kind == \"print_library_file\"\n\n\n@pytest.mark.asyncio\nasync def test_cancel_queued_job_removes_it_and_broadcasts():\n    \"\"\"Cancelling queued job removes it immediately.\"\"\"\n    service = BackgroundDispatchService()\n\n    with (\n        patch(\"backend.app.services.background_dispatch.printer_manager.get_status\", return_value=None),\n        patch(\n            \"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock\n        ) as mock_broadcast,\n    ):\n        result = await service.dispatch_reprint_archive(\n            archive_id=1,\n            archive_name=\"benchy.gcode.3mf\",\n            printer_id=1,\n            printer_name=\"Printer 1\",\n            options={},\n            requested_by_user_id=None,\n            requested_by_username=None,\n        )\n        mock_broadcast.reset_mock()\n\n        cancel_result = await service.cancel_job(result[\"dispatch_job_id\"])\n\n    assert cancel_result[\"cancelled\"] is True\n    assert cancel_result[\"pending\"] is False\n    assert len(service._queued_jobs) == 0\n    assert service._batch_total == 0\n\n    mock_broadcast.assert_awaited_once()\n    payload = mock_broadcast.await_args.args[0]\n    assert payload[\"data\"][\"recent_event\"][\"status\"] == \"cancelled\"\n\n\n@pytest.mark.asyncio\nasync def test_cancel_active_job_marks_pending_and_sets_cancel_flag():\n    \"\"\"Cancelling active job marks it as pending cancellation.\"\"\"\n    service = BackgroundDispatchService()\n    job = PrintDispatchJob(\n        id=42,\n        kind=\"reprint_archive\",\n        source_id=100,\n        source_name=\"gearbox.gcode.3mf\",\n        printer_id=3,\n        printer_name=\"Printer C\",\n    )\n    service._active_jobs[job.id] = ActiveDispatchState(job=job, message=\"Uploading...\")\n\n    with patch(\n        \"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock\n    ) as mock_broadcast:\n        result = await service.cancel_job(job.id)\n\n    assert result[\"cancelled\"] is True\n    assert result[\"pending\"] is True\n    assert job.id in service._cancel_requested_job_ids\n\n    mock_broadcast.assert_awaited_once()\n    payload = mock_broadcast.await_args.args[0]\n    assert payload[\"data\"][\"recent_event\"][\"status\"] == \"cancelling\"\n\n\ndef test_resolve_plate_id_uses_request_value_when_provided(tmp_path):\n    \"\"\"Explicit plate_id wins over auto-detection.\"\"\"\n    file_path = tmp_path / \"dummy.3mf\"\n    file_path.write_text(\"not-a-zip\")\n\n    plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=9)\n    assert plate_id == 9\n\n\ndef test_resolve_plate_id_auto_detects_from_3mf(tmp_path):\n    \"\"\"Auto-detect plate from Metadata/plate_X.gcode entry.\"\"\"\n    import zipfile\n\n    file_path = tmp_path / \"multi.3mf\"\n    with zipfile.ZipFile(file_path, \"w\") as zf:\n        zf.writestr(\"Metadata/plate_7.gcode\", b\"G1 X0 Y0\")\n\n    plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=None)\n    assert plate_id == 7\n\n\ndef test_is_sliced_file_recognizes_supported_extensions():\n    \"\"\"Only .gcode and .gcode.3mf should be accepted.\"\"\"\n    assert BackgroundDispatchService._is_sliced_file(\"part.gcode\") is True\n    assert BackgroundDispatchService._is_sliced_file(\"part.gcode.3mf\") is True\n    assert BackgroundDispatchService._is_sliced_file(\"part.3mf\") is False\n\n\n@pytest.mark.asyncio\nasync def test_cancel_job_not_found_returns_false():\n    \"\"\"Cancelling a nonexistent job returns not_found.\"\"\"\n    service = BackgroundDispatchService()\n\n    with patch(\"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock):\n        result = await service.cancel_job(999)\n\n    assert result[\"cancelled\"] is False\n    assert result[\"reason\"] == \"not_found\"\n\n\n@pytest.mark.asyncio\nasync def test_cancel_job_single_lock_covers_both_active_and_queued():\n    \"\"\"cancel_job checks both active and queued jobs under a single lock acquisition.\n\n    Regression test for TOCTOU race: previously two separate lock acquisitions allowed\n    the dispatcher loop to move a job from queue to active between them, causing cancel\n    to find it in neither place.\n    \"\"\"\n    service = BackgroundDispatchService()\n\n    # Set up a job in the queue AND an active job for a different printer\n    active_job = PrintDispatchJob(\n        id=1,\n        kind=\"reprint_archive\",\n        source_id=10,\n        source_name=\"active.3mf\",\n        printer_id=1,\n        printer_name=\"Printer 1\",\n    )\n    service._active_jobs[active_job.id] = ActiveDispatchState(job=active_job, message=\"Uploading...\")\n\n    queued_job = PrintDispatchJob(\n        id=2,\n        kind=\"reprint_archive\",\n        source_id=20,\n        source_name=\"queued.3mf\",\n        printer_id=2,\n        printer_name=\"Printer 2\",\n    )\n    service._queued_jobs.append(queued_job)\n    service._batch_total = 2\n\n    with patch(\n        \"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock\n    ) as mock_broadcast:\n        # Cancel the queued job — should find it in single lock acquisition\n        result = await service.cancel_job(2)\n\n    assert result[\"cancelled\"] is True\n    assert result[\"pending\"] is False\n    assert len(service._queued_jobs) == 0\n    # Active job should be untouched\n    assert 1 in service._active_jobs\n\n    mock_broadcast.assert_awaited_once()\n    payload = mock_broadcast.await_args.args[0]\n    assert payload[\"data\"][\"recent_event\"][\"status\"] == \"cancelled\"\n\n\n@pytest.mark.asyncio\nasync def test_mark_job_finished_resets_batch_when_all_done():\n    \"\"\"Batch counters reset after last job completes.\"\"\"\n    service = BackgroundDispatchService()\n    job = PrintDispatchJob(\n        id=1,\n        kind=\"reprint_archive\",\n        source_id=10,\n        source_name=\"test.3mf\",\n        printer_id=1,\n        printer_name=\"Printer 1\",\n    )\n    service._active_jobs[job.id] = ActiveDispatchState(job=job, message=\"Done\")\n    service._batch_total = 1\n\n    with patch(\"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock):\n        await service._mark_job_finished(job, failed=False, message=\"Complete\")\n\n    assert service._batch_total == 0\n    assert service._batch_completed == 0\n    assert service._batch_failed == 0\n\n\n@pytest.mark.asyncio\nasync def test_mark_job_finished_no_reset_when_jobs_remain():\n    \"\"\"Batch counters NOT reset when queued jobs remain.\"\"\"\n    service = BackgroundDispatchService()\n    job = PrintDispatchJob(\n        id=1,\n        kind=\"reprint_archive\",\n        source_id=10,\n        source_name=\"test.3mf\",\n        printer_id=1,\n        printer_name=\"Printer 1\",\n    )\n    remaining_job = PrintDispatchJob(\n        id=2,\n        kind=\"reprint_archive\",\n        source_id=20,\n        source_name=\"next.3mf\",\n        printer_id=2,\n        printer_name=\"Printer 2\",\n    )\n    service._active_jobs[job.id] = ActiveDispatchState(job=job, message=\"Done\")\n    service._queued_jobs.append(remaining_job)\n    service._batch_total = 2\n\n    with patch(\"backend.app.services.background_dispatch.ws_manager.broadcast\", new_callable=AsyncMock):\n        await service._mark_job_finished(job, failed=False, message=\"Complete\")\n\n    # Batch counters should NOT be reset — remaining job still queued\n    assert service._batch_total == 2\n    assert service._batch_completed == 1\n\n\n@pytest.mark.asyncio\nasync def test_mark_job_finished_batch_reset_rechecks_under_lock():\n    \"\"\"Batch reset re-checks condition inside second lock acquisition.\n\n    Regression test for TOCTOU: a new dispatch between the two lock acquisitions\n    could get its counters zeroed if the re-check is missing.\n    \"\"\"\n    service = BackgroundDispatchService()\n    job = PrintDispatchJob(\n        id=1,\n        kind=\"reprint_archive\",\n        source_id=10,\n        source_name=\"test.3mf\",\n        printer_id=1,\n        printer_name=\"Printer 1\",\n    )\n    service._active_jobs[job.id] = ActiveDispatchState(job=job, message=\"Done\")\n    service._batch_total = 1\n\n    original_broadcast = AsyncMock()\n\n    async def inject_new_job_during_broadcast(msg):\n        \"\"\"Simulate a new dispatch arriving between the two lock acquisitions.\"\"\"\n        await original_broadcast(msg)\n        # After broadcast (lock released), inject a new job before reset re-check\n        if not service._queued_jobs:\n            new_job = PrintDispatchJob(\n                id=99,\n                kind=\"reprint_archive\",\n                source_id=99,\n                source_name=\"injected.3mf\",\n                printer_id=5,\n                printer_name=\"Printer 5\",\n            )\n            service._queued_jobs.append(new_job)\n            service._batch_total = 1\n\n    with patch(\n        \"backend.app.services.background_dispatch.ws_manager.broadcast\",\n        side_effect=inject_new_job_during_broadcast,\n    ):\n        await service._mark_job_finished(job, failed=False, message=\"Complete\")\n\n    # Re-check should prevent reset since a new job appeared\n    assert service._batch_total == 1\n    assert len(service._queued_jobs) == 1\n"
  },
  {
    "path": "backend/tests/unit/services/test_bambu_cloud.py",
    "content": "\"\"\"Tests for Bambu Cloud service - TOTP and email verification flows.\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.bambu_cloud import BambuCloudService\n\n\nclass TestBambuCloudLogin:\n    \"\"\"Test login flow detection (email vs TOTP).\"\"\"\n\n    @pytest.fixture\n    def cloud_service(self):\n        \"\"\"Create a BambuCloudService instance.\"\"\"\n        return BambuCloudService()\n\n    @pytest.mark.asyncio\n    async def test_login_detects_email_verification(self, cloud_service):\n        \"\"\"When loginType is verifyCode, should return email verification type.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"loginType\": \"verifyCode\",\n        }\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.login_request(\"test@example.com\", \"password\")\n\n            assert result[\"success\"] is False\n            assert result[\"needs_verification\"] is True\n            assert result[\"verification_type\"] == \"email\"\n            assert result[\"tfa_key\"] is None\n            assert \"email\" in result[\"message\"].lower()\n\n    @pytest.mark.asyncio\n    async def test_login_detects_totp(self, cloud_service):\n        \"\"\"When loginType is tfa, should return TOTP verification type with tfaKey.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"loginType\": \"tfa\",\n            \"tfaKey\": \"test-tfa-key-123\",\n        }\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.login_request(\"test@example.com\", \"password\")\n\n            assert result[\"success\"] is False\n            assert result[\"needs_verification\"] is True\n            assert result[\"verification_type\"] == \"totp\"\n            assert result[\"tfa_key\"] == \"test-tfa-key-123\"\n            assert \"authenticator\" in result[\"message\"].lower()\n\n    @pytest.mark.asyncio\n    async def test_login_direct_success(self, cloud_service):\n        \"\"\"When accessToken is returned directly, should succeed without verification.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"accessToken\": \"test-access-token\",\n            \"refreshToken\": \"test-refresh-token\",\n        }\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.login_request(\"test@example.com\", \"password\")\n\n            assert result[\"success\"] is True\n            assert result[\"needs_verification\"] is False\n            assert cloud_service.access_token == \"test-access-token\"\n\n    @pytest.mark.asyncio\n    async def test_login_failure(self, cloud_service):\n        \"\"\"When login fails, should return error message.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 401\n        mock_response.json.return_value = {\n            \"message\": \"Invalid credentials\",\n        }\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.login_request(\"test@example.com\", \"wrong-password\")\n\n            assert result[\"success\"] is False\n            assert result[\"needs_verification\"] is False\n            assert \"Invalid credentials\" in result[\"message\"]\n\n\nclass TestBambuCloudEmailVerification:\n    \"\"\"Test email verification flow.\"\"\"\n\n    @pytest.fixture\n    def cloud_service(self):\n        \"\"\"Create a BambuCloudService instance.\"\"\"\n        return BambuCloudService()\n\n    @pytest.mark.asyncio\n    async def test_verify_code_success(self, cloud_service):\n        \"\"\"When email code is correct, should return success with token.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"accessToken\": \"test-access-token\",\n            \"refreshToken\": \"test-refresh-token\",\n        }\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.verify_code(\"test@example.com\", \"123456\")\n\n            assert result[\"success\"] is True\n            assert cloud_service.access_token == \"test-access-token\"\n\n    @pytest.mark.asyncio\n    async def test_verify_code_failure(self, cloud_service):\n        \"\"\"When email code is incorrect, should return failure.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 400\n        mock_response.json.return_value = {\n            \"message\": \"Invalid verification code\",\n        }\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.verify_code(\"test@example.com\", \"000000\")\n\n            assert result[\"success\"] is False\n            assert \"Invalid\" in result[\"message\"] or \"Verification failed\" in result[\"message\"]\n\n\nclass TestBambuCloudTOTPVerification:\n    \"\"\"Test TOTP verification flow.\"\"\"\n\n    @pytest.fixture\n    def cloud_service(self):\n        \"\"\"Create a BambuCloudService instance.\"\"\"\n        return BambuCloudService()\n\n    @pytest.mark.asyncio\n    async def test_verify_totp_success(self, cloud_service):\n        \"\"\"When TOTP code is correct, should return success with token.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.text = '{\"token\": \"test-access-token\"}'\n        mock_response.json.return_value = {\n            \"token\": \"test-access-token\",\n        }\n        mock_response.cookies = {}\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.verify_totp(\"test-tfa-key\", \"123456\")\n\n            assert result[\"success\"] is True\n            assert cloud_service.access_token == \"test-access-token\"\n\n    @pytest.mark.asyncio\n    async def test_verify_totp_uses_correct_endpoint(self, cloud_service):\n        \"\"\"TOTP verification should use bambulab.com, not api.bambulab.com.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.text = '{\"token\": \"test-token\"}'\n        mock_response.json.return_value = {\"token\": \"test-token\"}\n        mock_response.cookies = {}\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            await cloud_service.verify_totp(\"test-tfa-key\", \"123456\")\n\n            # Check the URL used\n            call_args = mock_post.call_args\n            url = call_args[0][0]\n            assert \"bambulab.com/api/sign-in/tfa\" in url\n            assert \"api.bambulab.com\" not in url\n\n    @pytest.mark.asyncio\n    async def test_verify_totp_empty_response(self, cloud_service):\n        \"\"\"When TOTP returns empty response, should handle gracefully.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 400\n        mock_response.text = \"\"\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.verify_totp(\"test-tfa-key\", \"123456\")\n\n            assert result[\"success\"] is False\n            assert \"empty response\" in result[\"message\"].lower()\n\n    @pytest.mark.asyncio\n    async def test_verify_totp_cloudflare_blocked(self, cloud_service):\n        \"\"\"When Cloudflare blocks request, should handle gracefully.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 403\n        mock_response.text = \"<!DOCTYPE html><html><head><title>Just a moment...</title>\"\n        # json() raises an error when response is HTML\n        mock_response.json.side_effect = ValueError(\"No JSON\")\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            result = await cloud_service.verify_totp(\"test-tfa-key\", \"123456\")\n\n            assert result[\"success\"] is False\n            assert \"Invalid response\" in result[\"message\"]\n\n    @pytest.mark.asyncio\n    async def test_verify_totp_includes_browser_headers(self, cloud_service):\n        \"\"\"TOTP verification should include browser-like headers to bypass Cloudflare.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.text = '{\"token\": \"test-token\"}'\n        mock_response.json.return_value = {\"token\": \"test-token\"}\n        mock_response.cookies = {}\n\n        with patch.object(cloud_service._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            await cloud_service.verify_totp(\"test-tfa-key\", \"123456\")\n\n            # Check headers include User-Agent\n            call_args = mock_post.call_args\n            headers = call_args[1][\"headers\"]\n            assert \"User-Agent\" in headers\n            assert \"Mozilla\" in headers[\"User-Agent\"]\n\n\nclass TestBambuCloudRegion:\n    \"\"\"Region routing — China-region instances must hit api.bambulab.cn.\"\"\"\n\n    def test_global_region_uses_com_base(self):\n        \"\"\"Default / 'global' region should use api.bambulab.com.\"\"\"\n        cloud = BambuCloudService()  # default region\n        assert cloud.base_url == \"https://api.bambulab.com\"\n\n        cloud_explicit = BambuCloudService(region=\"global\")\n        assert cloud_explicit.base_url == \"https://api.bambulab.com\"\n\n    def test_china_region_uses_cn_base(self):\n        \"\"\"'china' region should use api.bambulab.cn.\"\"\"\n        cloud = BambuCloudService(region=\"china\")\n        assert cloud.base_url == \"https://api.bambulab.cn\"\n\n    @pytest.mark.asyncio\n    async def test_china_region_login_hits_cn_endpoint(self):\n        \"\"\"A login_request from a China-region instance must POST to api.bambulab.cn.\"\"\"\n        cloud = BambuCloudService(region=\"china\")\n\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\"loginType\": \"verifyCode\"}\n\n        with patch.object(cloud._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            await cloud.login_request(\"test@example.com\", \"password\")\n\n            url = mock_post.call_args[0][0]\n            assert \"api.bambulab.cn\" in url\n            assert \"api.bambulab.com\" not in url\n\n    @pytest.mark.asyncio\n    async def test_china_region_totp_hits_cn_tfa_endpoint(self):\n        \"\"\"TOTP verification from a China-region instance uses the CN TFA endpoint.\"\"\"\n        cloud = BambuCloudService(region=\"china\")\n\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.text = '{\"token\": \"t\"}'\n        mock_response.json.return_value = {\"token\": \"t\"}\n        mock_response.cookies = {}\n\n        with patch.object(cloud._client, \"post\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = mock_response\n\n            await cloud.verify_totp(\"tfa-key\", \"123456\")\n\n            url = mock_post.call_args[0][0]\n            assert \"bambulab.cn/api/sign-in/tfa\" in url\n            assert \"bambulab.com\" not in url\n"
  },
  {
    "path": "backend/tests/unit/services/test_bambu_ftp.py",
    "content": "\"\"\"Comprehensive FTP test suite for BambuFTPClient.\n\nTests against a real mock implicit FTPS server, covering:\n- Connection (auth, SSL modes, timeout, caching)\n- File listing\n- Download (bytes, to_file, 0-byte regression)\n- Upload (chunked transfer, progress, error codes)\n- Delete\n- File size\n- Storage info (AVBL, directory scan, diagnose_storage)\n- Model-specific behavior (X1C prot_p, A1 prot_c fallback)\n- Async wrappers\n- Failure injection scenarios (regressions for 0.1.8 bugs)\n\"\"\"\n\nimport time\nfrom pathlib import Path\n\nimport pytest\n\nfrom backend.app.services.bambu_ftp import (\n    BambuFTPClient,\n    FileNotOnPrinterError,\n    cache_3mf_download,\n    clear_3mf_cache,\n    delete_file_async,\n    download_file_async,\n    download_file_try_paths_async,\n    get_cached_3mf,\n    list_files_async,\n    normalize_3mf_name,\n    upload_file_async,\n    with_ftp_retry,\n)\n\n# Brief delay to allow pyftpdlib to flush uploaded files to disk.\n# Needed because upload_file() skips voidresp() for all models,\n# so the server may still be processing the data channel close event.\n_UPLOAD_FLUSH_DELAY = 0.3\n\n\n# ---------------------------------------------------------------------------\n# TestConnection\n# ---------------------------------------------------------------------------\nclass TestConnection:\n    \"\"\"Tests for FTP connect/disconnect behavior.\"\"\"\n\n    def test_connect_success(self, ftp_client_factory):\n        \"\"\"Successful implicit FTPS connection and login.\"\"\"\n        client = ftp_client_factory()\n        assert client.connect() is True\n        client.disconnect()\n\n    def test_connect_wrong_access_code(self, ftp_client_factory):\n        \"\"\"Wrong access code returns False.\"\"\"\n        client = ftp_client_factory(access_code=\"wrongcode\")\n        assert client.connect() is False\n\n    def test_connect_unreachable_host(self, ftp_server):\n        \"\"\"Unreachable host returns False.\"\"\"\n        client = BambuFTPClient(\n            ip_address=\"192.0.2.1\",  # TEST-NET, guaranteed unreachable\n            access_code=\"12345678\",\n            timeout=1.0,\n            printer_model=\"X1C\",\n        )\n        client.FTP_PORT = ftp_server.port\n        assert client.connect() is False\n\n    def test_connect_timeout(self, ftp_server):\n        \"\"\"Very short timeout triggers timeout error.\"\"\"\n        client = BambuFTPClient(\n            ip_address=\"192.0.2.1\",\n            access_code=\"12345678\",\n            timeout=0.001,  # Extremely short\n            printer_model=\"X1C\",\n        )\n        client.FTP_PORT = ftp_server.port\n        assert client.connect() is False\n\n    def test_disconnect_clean(self, ftp_client_factory):\n        \"\"\"Clean disconnect after successful connect.\"\"\"\n        client = ftp_client_factory()\n        client.connect()\n        client.disconnect()\n        assert client._ftp is None\n\n    def test_disconnect_without_connect(self, ftp_client_factory):\n        \"\"\"Disconnect without connect does not raise.\"\"\"\n        client = ftp_client_factory()\n        client.disconnect()  # Should not raise\n        assert client._ftp is None\n\n    def test_x1c_uses_prot_p(self, ftp_client_factory):\n        \"\"\"X1C model connects with prot_p (protected data channel).\"\"\"\n        client = ftp_client_factory(printer_model=\"X1C\")\n        assert client.connect() is True\n        assert client._should_use_prot_c() is False\n        client.disconnect()\n\n    def test_a1_defaults_prot_p(self, ftp_client_factory):\n        \"\"\"A1 model defaults to prot_p when no cache exists.\"\"\"\n        client = ftp_client_factory(printer_model=\"A1\")\n        assert client._should_use_prot_c() is False\n        assert client.connect() is True\n        client.disconnect()\n\n    def test_a1_force_prot_c(self, ftp_client_factory):\n        \"\"\"A1 model with force_prot_c uses clear data channel.\"\"\"\n        client = ftp_client_factory(printer_model=\"A1\", force_prot_c=True)\n        assert client._should_use_prot_c() is True\n        assert client.connect() is True\n        client.disconnect()\n\n    def test_cached_mode_respected(self, ftp_client_factory):\n        \"\"\"Cached mode is used on subsequent connections.\"\"\"\n        BambuFTPClient.cache_mode(\"127.0.0.1\", \"prot_c\")\n        client = ftp_client_factory(printer_model=\"A1\")\n        assert client._should_use_prot_c() is True\n        assert client.connect() is True\n        client.disconnect()\n\n\n# ---------------------------------------------------------------------------\n# TestDisconnectServerGone — isolated class because server.stop() calls\n# close_all() which nukes all asyncore sockets globally.\n# ---------------------------------------------------------------------------\nclass TestDisconnectServerGone:\n    \"\"\"Test disconnect behavior when the server has stopped.\"\"\"\n\n    def test_disconnect_after_server_gone(self, ftp_certs, tmp_path):\n        \"\"\"Disconnect after server has stopped does not raise.\n\n        disconnect() catches OSError, ftplib.Error, and EOFError so that\n        best-effort cleanup never propagates exceptions to the caller.\n        \"\"\"\n        from backend.tests.unit.services.mock_ftp_server import (\n            MockBambuFTPServer,\n        )\n\n        from .conftest import _find_free_port\n\n        cert_path, key_path = ftp_certs\n        port = _find_free_port()\n        server = MockBambuFTPServer(\"127.0.0.1\", port, str(tmp_path), cert_path, key_path)\n        server.start()\n\n        client = BambuFTPClient(\"127.0.0.1\", \"12345678\", timeout=5.0)\n        client.FTP_PORT = port\n        client.connect()\n\n        server.stop()\n        # Should not raise — disconnect() catches all connection errors\n        client.disconnect()\n        assert client._ftp is None\n\n\n# ---------------------------------------------------------------------------\n# TestListFiles\n# ---------------------------------------------------------------------------\nclass TestListFiles:\n    \"\"\"Tests for directory listing.\"\"\"\n\n    def test_list_empty_directory(self, ftp_client_factory):\n        \"\"\"Listing an empty directory returns empty list.\"\"\"\n        client = ftp_client_factory()\n        client.connect()\n        files = client.list_files(\"/cache\")\n        assert files == []\n        client.disconnect()\n\n    def test_list_directory_with_files(self, ftp_client_factory, ftp_server):\n        \"\"\"Files in directory are listed correctly.\"\"\"\n        ftp_server.add_file(\"cache/test.3mf\", b\"x\" * 1024)\n        ftp_server.add_file(\"cache/test2.gcode\", b\"y\" * 512)\n        client = ftp_client_factory()\n        client.connect()\n        files = client.list_files(\"/cache\")\n        names = {f[\"name\"] for f in files}\n        assert \"test.3mf\" in names\n        assert \"test2.gcode\" in names\n        client.disconnect()\n\n    def test_directories_marked(self, ftp_client_factory, ftp_server):\n        \"\"\"Subdirectories are identified with is_directory=True.\"\"\"\n        ftp_server.add_directory(\"model/subdir\")\n        client = ftp_client_factory()\n        client.connect()\n        files = client.list_files(\"/model\")\n        dirs = [f for f in files if f[\"is_directory\"]]\n        assert len(dirs) >= 1\n        assert dirs[0][\"name\"] == \"subdir\"\n        client.disconnect()\n\n    def test_nonexistent_path_returns_empty(self, ftp_client_factory):\n        \"\"\"Listing a nonexistent path returns empty list.\"\"\"\n        client = ftp_client_factory()\n        client.connect()\n        files = client.list_files(\"/nonexistent/path\")\n        assert files == []\n        client.disconnect()\n\n    def test_file_sizes_and_paths(self, ftp_client_factory, ftp_server):\n        \"\"\"File sizes and full paths are parsed correctly.\"\"\"\n        ftp_server.add_file(\"cache/sized.bin\", b\"a\" * 2048)\n        client = ftp_client_factory()\n        client.connect()\n        files = client.list_files(\"/cache\")\n        sized = [f for f in files if f[\"name\"] == \"sized.bin\"]\n        assert len(sized) == 1\n        assert sized[0][\"size\"] == 2048\n        assert sized[0][\"path\"] == \"/cache/sized.bin\"\n        client.disconnect()\n\n\n# ---------------------------------------------------------------------------\n# TestDownload\n# ---------------------------------------------------------------------------\nclass TestDownload:\n    \"\"\"Tests for file download operations.\"\"\"\n\n    def test_download_file_returns_bytes(self, ftp_client_factory, ftp_server):\n        \"\"\"download_file() returns file content as bytes.\"\"\"\n        content = b\"Hello FTP World!\"\n        ftp_server.add_file(\"cache/hello.txt\", content)\n        client = ftp_client_factory()\n        client.connect()\n        result = client.download_file(\"/cache/hello.txt\")\n        assert result == content\n        client.disconnect()\n\n    def test_download_file_missing(self, ftp_client_factory):\n        \"\"\"download_file() returns None for missing file.\"\"\"\n        client = ftp_client_factory()\n        client.connect()\n        result = client.download_file(\"/cache/does_not_exist.txt\")\n        assert result is None\n        client.disconnect()\n\n    def test_download_to_file_writes_to_disk(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"download_to_file() writes content to local filesystem.\"\"\"\n        content = b\"Downloaded content\"\n        ftp_server.add_file(\"cache/dl.bin\", content)\n        local = tmp_path / \"output\" / \"dl.bin\"\n        client = ftp_client_factory()\n        client.connect()\n        result = client.download_to_file(\"/cache/dl.bin\", local)\n        assert result is True\n        assert local.read_bytes() == content\n        client.disconnect()\n\n    def test_download_to_file_creates_parent_dirs(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"download_to_file() creates parent directories automatically.\"\"\"\n        ftp_server.add_file(\"cache/nested.txt\", b\"nested content\")\n        local = tmp_path / \"deep\" / \"nested\" / \"path\" / \"nested.txt\"\n        client = ftp_client_factory()\n        client.connect()\n        result = client.download_to_file(\"/cache/nested.txt\", local)\n        assert result is True\n        assert local.exists()\n        client.disconnect()\n\n    def test_zero_byte_download_returns_false(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"0-byte download returns False and cleans up (regression test).\"\"\"\n        ftp_server.add_file(\"cache/empty.bin\", b\"\")\n        local = tmp_path / \"empty.bin\"\n        client = ftp_client_factory()\n        client.connect()\n        result = client.download_to_file(\"/cache/empty.bin\", local)\n        assert result is False\n        assert not local.exists()\n        client.disconnect()\n\n    def test_download_to_file_missing_raises_not_on_printer(self, ftp_client_factory, tmp_path):\n        \"\"\"Missing file raises FileNotOnPrinterError so callers can short-circuit\n        the retry loop — 550 means the file isn't there and retrying won't help.\"\"\"\n        from backend.app.services.bambu_ftp import FileNotOnPrinterError\n\n        local = tmp_path / \"missing.bin\"\n        client = ftp_client_factory()\n        client.connect()\n        try:\n            with pytest.raises(FileNotOnPrinterError):\n                client.download_to_file(\"/cache/no_such_file.bin\", local)\n        finally:\n            client.disconnect()\n\n    def test_download_large_file(self, ftp_client_factory, ftp_server):\n        \"\"\"Large file download (>1MB) works correctly.\"\"\"\n        large_content = b\"X\" * (1024 * 1024 + 500)  # ~1MB + 500 bytes\n        ftp_server.add_file(\"cache/large.bin\", large_content)\n        client = ftp_client_factory()\n        client.connect()\n        result = client.download_file(\"/cache/large.bin\")\n        assert result == large_content\n        client.disconnect()\n\n    def test_download_not_connected(self):\n        \"\"\"download_file() returns None when not connected.\"\"\"\n        client = BambuFTPClient(\"127.0.0.1\", \"12345678\")\n        assert client.download_file(\"/cache/test.bin\") is None\n\n\n# ---------------------------------------------------------------------------\n# TestUpload\n# ---------------------------------------------------------------------------\nclass TestUpload:\n    \"\"\"Tests for file upload operations.\"\"\"\n\n    def test_upload_success(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"Successful upload via transfercmd (not storbinary).\"\"\"\n        content = b\"Upload test content\"\n        local = tmp_path / \"upload.3mf\"\n        local.write_bytes(content)\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_file(local, \"/cache/upload.3mf\")\n        assert result is True\n        client.disconnect()\n        # Verify via fresh connection (upload_file skips voidresp() for all\n        # models, so the original session can't be reused for download)\n        time.sleep(_UPLOAD_FLUSH_DELAY)\n        client2 = ftp_client_factory()\n        client2.connect()\n        downloaded = client2.download_file(\"/cache/upload.3mf\")\n        assert downloaded == content\n        client2.disconnect()\n\n    def test_upload_progress_callback(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"Progress callback receives updates during upload.\"\"\"\n        content = b\"P\" * 2048\n        local = tmp_path / \"progress.bin\"\n        local.write_bytes(content)\n\n        progress_calls = []\n\n        def on_progress(uploaded, total):\n            progress_calls.append((uploaded, total))\n\n        client = ftp_client_factory()\n        client.connect()\n        client.upload_file(local, \"/cache/progress.bin\", on_progress)\n        assert len(progress_calls) >= 1\n        # Last call should report full file uploaded\n        assert progress_calls[-1][0] == len(content)\n        assert progress_calls[-1][1] == len(content)\n        client.disconnect()\n\n    def test_upload_not_connected(self, tmp_path):\n        \"\"\"Upload when not connected returns False.\"\"\"\n        local = tmp_path / \"test.bin\"\n        local.write_bytes(b\"data\")\n        client = BambuFTPClient(\"127.0.0.1\", \"12345678\")\n        assert client.upload_file(local, \"/cache/test.bin\") is False\n\n    def test_upload_553_no_sd_card(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"553 error (no SD card) returns False.\"\"\"\n        ftp_server.inject_failure(\"STOR\", 553, \"Could not create file.\")\n        local = tmp_path / \"test.bin\"\n        local.write_bytes(b\"data\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_file(local, \"/cache/test.bin\")\n        assert result is False\n        client.disconnect()\n\n    def test_upload_550_permission_denied(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"550 error (permission denied) returns False.\"\"\"\n        ftp_server.inject_failure(\"STOR\", 550, \"Permission denied.\")\n        local = tmp_path / \"test.bin\"\n        local.write_bytes(b\"data\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_file(local, \"/cache/test.bin\")\n        assert result is False\n        client.disconnect()\n\n    def test_upload_552_storage_full(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"552 error (storage full) returns False.\"\"\"\n        ftp_server.inject_failure(\"STOR\", 552, \"Storage quota exceeded.\")\n        local = tmp_path / \"test.bin\"\n        local.write_bytes(b\"data\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_file(local, \"/cache/test.bin\")\n        assert result is False\n        client.disconnect()\n\n    def test_upload_bytes_success(self, ftp_client_factory, ftp_server):\n        \"\"\"upload_bytes() writes data to server.\"\"\"\n        data = b\"Bytes upload content\"\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_bytes(data, \"/cache/bytes.bin\")\n        assert result is True\n        client.disconnect()\n        # Verify via fresh connection\n        time.sleep(_UPLOAD_FLUSH_DELAY)\n        client2 = ftp_client_factory()\n        client2.connect()\n        downloaded = client2.download_file(\"/cache/bytes.bin\")\n        assert downloaded == data\n        client2.disconnect()\n\n    def test_upload_bytes_failure(self, ftp_client_factory, ftp_server):\n        \"\"\"upload_bytes() returns False on STOR failure.\"\"\"\n        ftp_server.inject_failure(\"STOR\", 553, \"No space.\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_bytes(b\"data\", \"/cache/fail.bin\")\n        assert result is False\n        client.disconnect()\n\n    def test_upload_large_chunked(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"Large file upload in chunks completes without error.\n\n        Uses 2.5MB to trigger multiple chunks with 64KB CHUNK_SIZE.\n        Content verification skipped because upload_file() skips\n        voidresp() for all models, so the server may still be flushing\n        when we check. The upload result=True confirms the client sent\n        all chunks without error.\n        \"\"\"\n        content = b\"C\" * (1024 * 1024 * 2 + 512 * 1024)\n        local = tmp_path / \"large.bin\"\n        local.write_bytes(content)\n\n        progress_calls = []\n\n        def on_progress(uploaded, total):\n            progress_calls.append((uploaded, total))\n\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_file(local, \"/cache/large.bin\", on_progress)\n        assert result is True\n        # Verify many chunks were sent (2.5MB / 64KB = 40 chunks)\n        assert len(progress_calls) >= 38\n        assert progress_calls[-1][0] == len(content)\n        client.disconnect()\n\n\n# ---------------------------------------------------------------------------\n# TestDelete\n# ---------------------------------------------------------------------------\nclass TestDelete:\n    \"\"\"Tests for file deletion.\"\"\"\n\n    def test_delete_success(self, ftp_client_factory, ftp_server):\n        \"\"\"Successful file deletion.\"\"\"\n        ftp_server.add_file(\"cache/to_delete.bin\", b\"delete me\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.delete_file(\"/cache/to_delete.bin\")\n        assert result is True\n        assert not ftp_server.file_exists(\"cache/to_delete.bin\")\n        client.disconnect()\n\n    def test_delete_not_found(self, ftp_client_factory):\n        \"\"\"Deleting a nonexistent file returns False.\"\"\"\n        client = ftp_client_factory()\n        client.connect()\n        result = client.delete_file(\"/cache/no_such_file.bin\")\n        assert result is False\n        client.disconnect()\n\n    def test_delete_not_connected(self):\n        \"\"\"Delete when not connected returns False.\"\"\"\n        client = BambuFTPClient(\"127.0.0.1\", \"12345678\")\n        assert client.delete_file(\"/cache/test.bin\") is False\n\n\n# ---------------------------------------------------------------------------\n# TestFileSize\n# ---------------------------------------------------------------------------\nclass TestFileSize:\n    \"\"\"Tests for get_file_size.\"\"\"\n\n    def test_file_size_correct(self, ftp_client_factory, ftp_server):\n        \"\"\"Returns correct file size.\"\"\"\n        ftp_server.add_file(\"cache/sized.bin\", b\"a\" * 4096)\n        client = ftp_client_factory()\n        client.connect()\n        size = client.get_file_size(\"/cache/sized.bin\")\n        assert size == 4096\n        client.disconnect()\n\n    def test_file_size_missing(self, ftp_client_factory):\n        \"\"\"Returns None for missing file.\"\"\"\n        client = ftp_client_factory()\n        client.connect()\n        size = client.get_file_size(\"/cache/no_file.bin\")\n        assert size is None\n        client.disconnect()\n\n    def test_file_size_not_connected(self):\n        \"\"\"Returns None when not connected.\"\"\"\n        client = BambuFTPClient(\"127.0.0.1\", \"12345678\")\n        assert client.get_file_size(\"/cache/test.bin\") is None\n\n\n# ---------------------------------------------------------------------------\n# TestStorageInfo\n# ---------------------------------------------------------------------------\nclass TestStorageInfo:\n    \"\"\"Tests for storage info and diagnostics.\"\"\"\n\n    def test_avbl_parsed(self, ftp_client_factory, ftp_server):\n        \"\"\"AVBL response is parsed for free_bytes.\"\"\"\n        ftp_server.set_avbl_bytes(5000000000)\n        client = ftp_client_factory()\n        client.connect()\n        info = client.get_storage_info()\n        assert info is not None\n        assert info[\"free_bytes\"] == 5000000000\n        client.disconnect()\n\n    def test_used_bytes_from_scan(self, ftp_client_factory, ftp_server):\n        \"\"\"used_bytes calculated from directory scan.\"\"\"\n        ftp_server.add_file(\"cache/file1.bin\", b\"a\" * 1000)\n        ftp_server.add_file(\"cache/file2.bin\", b\"b\" * 2000)\n        client = ftp_client_factory()\n        client.connect()\n        info = client.get_storage_info()\n        assert info is not None\n        assert info[\"used_bytes\"] >= 3000  # At least these two files\n        client.disconnect()\n\n    def test_storage_info_not_connected(self):\n        \"\"\"Returns None when not connected.\"\"\"\n        client = BambuFTPClient(\"127.0.0.1\", \"12345678\")\n        assert client.get_storage_info() is None\n\n    def test_diagnose_storage_success(self, ftp_client_factory, ftp_server):\n        \"\"\"diagnose_storage() returns connected=True with working diagnostics.\"\"\"\n        client = ftp_client_factory()\n        client.connect()\n        diag = client.diagnose_storage()\n        assert diag[\"connected\"] is True\n        assert diag[\"can_list_root\"] is True\n        assert diag[\"can_list_cache\"] is True\n        assert diag[\"pwd\"] is not None\n        assert diag[\"storage_info\"] is not None\n        client.disconnect()\n\n    def test_diagnose_storage_not_connected(self):\n        \"\"\"diagnose_storage() reports not connected.\"\"\"\n        client = BambuFTPClient(\"127.0.0.1\", \"12345678\")\n        diag = client.diagnose_storage()\n        assert diag[\"connected\"] is False\n        assert \"FTP not connected\" in diag[\"errors\"]\n\n\n# ---------------------------------------------------------------------------\n# TestModelSpecificBehavior\n# ---------------------------------------------------------------------------\nclass TestModelSpecificBehavior:\n    \"\"\"Tests for printer model-specific FTP behavior.\"\"\"\n\n    def test_x1c_upload(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"X1C upload with session reuse succeeds.\"\"\"\n        content = b\"X1C upload data\"\n        local = tmp_path / \"x1c.3mf\"\n        local.write_bytes(content)\n        client = ftp_client_factory(printer_model=\"X1C\")\n        client.connect()\n        result = client.upload_file(local, \"/cache/x1c.3mf\")\n        assert result is True\n        client.disconnect()\n        # Verify via fresh connection\n        time.sleep(_UPLOAD_FLUSH_DELAY)\n        client2 = ftp_client_factory(printer_model=\"X1C\")\n        client2.connect()\n        downloaded = client2.download_file(\"/cache/x1c.3mf\")\n        assert downloaded == content\n        client2.disconnect()\n\n    def test_a1_upload_prot_c(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"A1 model upload with prot_c succeeds.\"\"\"\n        content = b\"A1 upload data\"\n        local = tmp_path / \"a1.3mf\"\n        local.write_bytes(content)\n        client = ftp_client_factory(printer_model=\"A1\", force_prot_c=True)\n        client.connect()\n        result = client.upload_file(local, \"/cache/a1.3mf\")\n        assert result is True\n        client.disconnect()\n        # Verify via fresh connection\n        time.sleep(_UPLOAD_FLUSH_DELAY)\n        client2 = ftp_client_factory(printer_model=\"A1\", force_prot_c=True)\n        client2.connect()\n        downloaded = client2.download_file(\"/cache/a1.3mf\")\n        assert downloaded == content\n        client2.disconnect()\n\n    def test_a1_mini_upload(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"A1 Mini model upload succeeds.\"\"\"\n        content = b\"A1 Mini data\"\n        local = tmp_path / \"a1mini.3mf\"\n        local.write_bytes(content)\n        client = ftp_client_factory(printer_model=\"A1 Mini\", force_prot_c=True)\n        client.connect()\n        result = client.upload_file(local, \"/cache/a1mini.3mf\")\n        assert result is True\n        client.disconnect()\n\n    def test_p1s_upload(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"P1S model upload with session reuse succeeds.\"\"\"\n        content = b\"P1S upload data\"\n        local = tmp_path / \"p1s.3mf\"\n        local.write_bytes(content)\n        client = ftp_client_factory(printer_model=\"P1S\")\n        client.connect()\n        result = client.upload_file(local, \"/cache/p1s.3mf\")\n        assert result is True\n        client.disconnect()\n\n    def test_unknown_model_defaults_prot_p(self, ftp_client_factory):\n        \"\"\"Unknown model defaults to prot_p.\"\"\"\n        client = ftp_client_factory(printer_model=\"FuturePrinter3000\")\n        assert client._is_a1_model() is False\n        assert client._should_use_prot_c() is False\n        assert client.connect() is True\n        client.disconnect()\n\n    def test_mode_cache_persists_and_clears(self, ftp_client_factory):\n        \"\"\"Mode cache works within a test and clears between tests.\"\"\"\n        # Cache should be empty at start (autouse fixture clears it)\n        assert BambuFTPClient._mode_cache == {}\n\n        # Connect and cache a mode\n        BambuFTPClient.cache_mode(\"127.0.0.1\", \"prot_p\")\n        assert BambuFTPClient._mode_cache[\"127.0.0.1\"] == \"prot_p\"\n\n        # New client for same IP uses cached mode\n        client = ftp_client_factory(printer_model=\"A1\")\n        assert client._get_cached_mode() == \"prot_p\"\n        assert client._should_use_prot_c() is False\n        client.disconnect()\n\n\n# ---------------------------------------------------------------------------\n# TestAsyncWrappers\n# ---------------------------------------------------------------------------\nclass TestAsyncWrappers:\n    \"\"\"Tests for async wrapper functions using patch_ftp_port fixture.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_upload_file_async_success(self, patch_ftp_port, tmp_path):\n        \"\"\"upload_file_async succeeds for X1C.\"\"\"\n        content = b\"async upload\"\n        local = tmp_path / \"async_up.3mf\"\n        local.write_bytes(content)\n        result = await upload_file_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            local,\n            \"/cache/async_up.3mf\",\n            timeout=30.0,\n            printer_model=\"X1C\",\n        )\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_upload_file_async_a1_fallback(self, patch_ftp_port, tmp_path):\n        \"\"\"upload_file_async tries prot_p then falls back to prot_c for A1.\"\"\"\n        content = b\"a1 async upload\"\n        local = tmp_path / \"a1_async.3mf\"\n        local.write_bytes(content)\n        # For A1 models, if prot_p succeeds we get True.\n        # If prot_p fails, it tries prot_c. Either way should succeed\n        # against our mock server which accepts both.\n        result = await upload_file_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            local,\n            \"/cache/a1_async.3mf\",\n            timeout=30.0,\n            printer_model=\"A1\",\n        )\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_download_file_async_success(self, patch_ftp_port, tmp_path):\n        \"\"\"download_file_async succeeds.\"\"\"\n        server = patch_ftp_port\n        content = b\"async download content\"\n        server.add_file(\"cache/async_dl.bin\", content)\n        local = tmp_path / \"async_dl.bin\"\n        result = await download_file_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            \"/cache/async_dl.bin\",\n            local,\n            timeout=30.0,\n            printer_model=\"X1C\",\n        )\n        assert result is True\n        assert local.read_bytes() == content\n\n    @pytest.mark.asyncio\n    async def test_download_file_async_a1_fallback(self, patch_ftp_port, tmp_path):\n        \"\"\"download_file_async falls back for A1 models.\"\"\"\n        server = patch_ftp_port\n        server.add_file(\"cache/a1_dl.bin\", b\"a1 data\")\n        local = tmp_path / \"a1_dl.bin\"\n        result = await download_file_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            \"/cache/a1_dl.bin\",\n            local,\n            timeout=30.0,\n            printer_model=\"A1\",\n        )\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_download_file_async_timeout_salvages_completed_zombie(self, tmp_path, monkeypatch):\n        \"\"\"Executor thread that completes after wait_for timeout is salvaged.\n\n        asyncio.wait_for cannot cancel run_in_executor threads, so the FTP\n        download may still complete after we give up waiting. If the thread\n        genuinely finished (signalled via completion[\"success\"] and the file\n        is on disk), download_file_async should return True rather than False.\n\n        Regression for #972: A1 user with 14 MB 3MF hit the hardcoded 60s\n        timeout, but the download thread finished ~45s later. The successful\n        file was written to disk but the async wrapper returned False, so the\n        archive was created as a fallback with no 3MF data.\n        \"\"\"\n        from backend.app.services import bambu_ftp\n\n        # Clear mode cache so prot_p path is exercised.\n        bambu_ftp.BambuFTPClient._mode_cache.pop(\"127.0.0.1\", None)\n\n        local = tmp_path / \"zombie.bin\"\n        expected_content = b\"late arrival but complete\"\n\n        class FakeClient:\n            \"\"\"Connects instantly, download_to_file sleeps past wait_for's\n            timeout then writes the file and returns True.\"\"\"\n\n            def __init__(self, *args, **kwargs):\n                pass\n\n            def connect(self):\n                return True\n\n            def download_to_file(self, remote_path, local_path):\n                time.sleep(0.4)  # longer than wait_for timeout=0.1\n                local_path.write_bytes(expected_content)\n                return True\n\n            def disconnect(self):\n                pass\n\n        monkeypatch.setattr(bambu_ftp, \"BambuFTPClient\", FakeClient)\n        monkeypatch.setattr(FakeClient, \"_mode_cache\", {}, raising=False)\n        monkeypatch.setattr(FakeClient, \"A1_MODELS\", {\"A1\"}, raising=False)\n\n        def _noop_cache(ip, mode):\n            pass\n\n        monkeypatch.setattr(FakeClient, \"cache_mode\", staticmethod(_noop_cache), raising=False)\n\n        result = await download_file_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            \"/cache/zombie.bin\",\n            local,\n            timeout=0.1,\n            printer_model=\"X1C\",\n        )\n        assert result is True\n        assert local.read_bytes() == expected_content\n\n    @pytest.mark.asyncio\n    async def test_download_file_async_timeout_no_salvage_when_incomplete(self, tmp_path, monkeypatch):\n        \"\"\"Timeout returns False when thread has not signalled success.\n\n        A partial file on disk (mid-retrbinary) must NOT be mistaken for a\n        completed download — only the thread's explicit success flag permits\n        salvage.\n        \"\"\"\n        from backend.app.services import bambu_ftp\n\n        bambu_ftp.BambuFTPClient._mode_cache.pop(\"127.0.0.1\", None)\n\n        local = tmp_path / \"partial.bin\"\n\n        class FakeClient:\n            def __init__(self, *args, **kwargs):\n                pass\n\n            def connect(self):\n                return True\n\n            def download_to_file(self, remote_path, local_path):\n                # Simulate an in-progress partial write that never completes\n                # within the salvage grace period.\n                local_path.write_bytes(b\"partial...\")\n                time.sleep(2.0)\n                return True  # would complete eventually, but too late\n\n            def disconnect(self):\n                pass\n\n        monkeypatch.setattr(bambu_ftp, \"BambuFTPClient\", FakeClient)\n        monkeypatch.setattr(FakeClient, \"_mode_cache\", {}, raising=False)\n        monkeypatch.setattr(FakeClient, \"A1_MODELS\", set(), raising=False)\n        monkeypatch.setattr(FakeClient, \"cache_mode\", staticmethod(lambda ip, mode: None), raising=False)\n\n        result = await download_file_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            \"/cache/partial.bin\",\n            local,\n            timeout=0.1,\n            printer_model=\"X1C\",\n        )\n        assert result is False\n\n    @pytest.mark.asyncio\n    async def test_download_file_async_timeout_waits_for_slow_zombie(self, tmp_path, monkeypatch):\n        \"\"\"A zombie that completes within the 30s grace window is salvaged.\n\n        Regression for #1014: on slow WiFi, download_to_file can overshoot the\n        user's ftp_timeout by 10–30 s without being stuck. The old fixed 0.5 s\n        post-timeout sleep was too short — it gave up and started attempt 2\n        while attempt 1's zombie thread kept running, and by the time the zombie\n        wrote the file to disk with a success flag, attempt 2 had already\n        reported failure (its own completion dict was still False). The async\n        wrapper now waits up to min(timeout, 30 s) for the worker thread to\n        finish before returning, so a slow-but-progressing download salvages.\n        \"\"\"\n        from backend.app.services import bambu_ftp\n\n        bambu_ftp.BambuFTPClient._mode_cache.pop(\"127.0.0.1\", None)\n\n        local = tmp_path / \"slow_zombie.bin\"\n        expected_content = b\"finished during grace window\"\n\n        class FakeClient:\n            \"\"\"Mimics a slow FTP: wait_for gives up at 1.0 s but RETR takes\n            1.5 s total. Old 0.5 s fixed sleep would have bailed (0.5 < 0.5\n            extra); new grace = max(min(1.0, 30), 0.5) = 1.0 s covers the\n            remaining 0.5 s so salvage succeeds.\"\"\"\n\n            def __init__(self, *args, **kwargs):\n                pass\n\n            def connect(self):\n                return True\n\n            def download_to_file(self, remote_path, local_path):\n                time.sleep(1.5)  # wait_for times out at 1.0 s; zombie finishes 0.5 s later\n                local_path.write_bytes(expected_content)\n                return True\n\n            def disconnect(self):\n                pass\n\n        monkeypatch.setattr(bambu_ftp, \"BambuFTPClient\", FakeClient)\n        monkeypatch.setattr(FakeClient, \"_mode_cache\", {}, raising=False)\n        monkeypatch.setattr(FakeClient, \"A1_MODELS\", set(), raising=False)\n        monkeypatch.setattr(FakeClient, \"cache_mode\", staticmethod(lambda ip, mode: None), raising=False)\n\n        result = await download_file_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            \"/cache/slow_zombie.bin\",\n            local,\n            timeout=1.0,\n            printer_model=\"X1C\",\n        )\n        assert result is True\n        assert local.read_bytes() == expected_content\n\n    @pytest.mark.asyncio\n    async def test_download_file_try_paths_first_succeeds(self, patch_ftp_port, tmp_path):\n        \"\"\"download_file_try_paths_async succeeds on first path.\"\"\"\n        server = patch_ftp_port\n        server.add_file(\"cache/try1.bin\", b\"first path\")\n        local = tmp_path / \"try.bin\"\n        result = await download_file_try_paths_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            [\"/cache/try1.bin\", \"/cache/try2.bin\"],\n            local,\n            printer_model=\"X1C\",\n        )\n        assert result is True\n        assert local.read_bytes() == b\"first path\"\n\n    @pytest.mark.asyncio\n    async def test_download_file_try_paths_fallback(self, patch_ftp_port, tmp_path):\n        \"\"\"download_file_try_paths_async falls back to second path.\"\"\"\n        server = patch_ftp_port\n        server.add_file(\"cache/second.bin\", b\"second path\")\n        local = tmp_path / \"fallback.bin\"\n        result = await download_file_try_paths_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            [\"/cache/missing.bin\", \"/cache/second.bin\"],\n            local,\n            printer_model=\"X1C\",\n        )\n        assert result is True\n        assert local.read_bytes() == b\"second path\"\n\n    @pytest.mark.asyncio\n    async def test_list_files_async_success(self, patch_ftp_port):\n        \"\"\"list_files_async returns file list.\"\"\"\n        server = patch_ftp_port\n        server.add_file(\"cache/listed.bin\", b\"data\")\n        result = await list_files_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            \"/cache\",\n            timeout=30.0,\n            printer_model=\"X1C\",\n        )\n        names = {f[\"name\"] for f in result}\n        assert \"listed.bin\" in names\n\n    @pytest.mark.asyncio\n    async def test_delete_file_async_success(self, patch_ftp_port):\n        \"\"\"delete_file_async deletes a file.\"\"\"\n        server = patch_ftp_port\n        server.add_file(\"cache/to_async_del.bin\", b\"delete me\")\n        result = await delete_file_async(\n            \"127.0.0.1\",\n            \"12345678\",\n            \"/cache/to_async_del.bin\",\n            printer_model=\"X1C\",\n        )\n        assert result is True\n        assert not server.file_exists(\"cache/to_async_del.bin\")\n\n\n# ---------------------------------------------------------------------------\n# TestFailureScenarios\n# ---------------------------------------------------------------------------\nclass TestFailureScenarios:\n    \"\"\"Regression tests for known FTP failure modes.\"\"\"\n\n    def test_550_caught_by_broad_except(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"550 error_perm is caught by (OSError, ftplib.Error) handler.\n\n        Regression: error_perm is a subclass of ftplib.Error, so the\n        broad except clause in upload_file catches it correctly.\n        \"\"\"\n        ftp_server.inject_failure(\"STOR\", 550, \"Permission denied.\")\n        local = tmp_path / \"test.bin\"\n        local.write_bytes(b\"data\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_file(local, \"/cache/test.bin\")\n        assert result is False\n        client.disconnect()\n\n    def test_zero_byte_download_detected(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"0-byte download is detected and file is cleaned up.\n\n        Regression: Prior to fix, 0-byte downloads were reported as success.\n        \"\"\"\n        ftp_server.add_file(\"cache/zero.bin\", b\"\")\n        local = tmp_path / \"zero.bin\"\n        client = ftp_client_factory()\n        client.connect()\n        result = client.download_to_file(\"/cache/zero.bin\", local)\n        assert result is False\n        assert not local.exists()\n        client.disconnect()\n\n    def test_connection_refused_handled(self):\n        \"\"\"Connection refused is handled gracefully.\"\"\"\n        client = BambuFTPClient(\"127.0.0.1\", \"12345678\", timeout=2.0)\n        client.FTP_PORT = 1  # Almost certainly not listening\n        assert client.connect() is False\n\n    def test_auth_failure_530(self, ftp_client_factory, ftp_server):\n        \"\"\"530 authentication failure returns False.\"\"\"\n        ftp_server.inject_failure(\"PASS\", 530, \"Login incorrect.\")\n        client = ftp_client_factory()\n        result = client.connect()\n        assert result is False\n\n    def test_retr_550_handled(self, ftp_client_factory, ftp_server):\n        \"\"\"RETR 550 (file not found) returns None.\"\"\"\n        ftp_server.inject_failure(\"RETR\", 550, \"File not found.\")\n        ftp_server.add_file(\"cache/exists.bin\", b\"data\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.download_file(\"/cache/exists.bin\")\n        assert result is None\n        client.disconnect()\n\n    def test_cwd_550_handled(self, ftp_client_factory, ftp_server):\n        \"\"\"CWD 550 is handled in list_files.\"\"\"\n        ftp_server.inject_failure(\"CWD\", 550, \"Directory not found.\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.list_files(\"/nonexistent\")\n        assert result == []\n        client.disconnect()\n\n    def test_stor_553_handled(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"STOR 553 (no SD card) handled gracefully.\"\"\"\n        ftp_server.inject_failure(\"STOR\", 553, \"Could not create file.\")\n        local = tmp_path / \"test.bin\"\n        local.write_bytes(b\"test\")\n        client = ftp_client_factory()\n        client.connect()\n        result = client.upload_file(local, \"/cache/test.bin\")\n        assert result is False\n        client.disconnect()\n\n    def test_diagnose_storage_cwd_failure_doesnt_propagate(self, ftp_client_factory, ftp_server):\n        \"\"\"diagnose_storage CWD failure doesn't crash the whole operation.\n\n        Regression: diagnose_storage() was called in the upload path and\n        a CWD failure would propagate and crash the upload.\n        \"\"\"\n        ftp_server.inject_failure(\"CWD\", 550, \"No such directory.\", count=2)\n        client = ftp_client_factory()\n        client.connect()\n        diag = client.diagnose_storage()\n        # Should still return results (with errors noted)\n        assert diag[\"connected\"] is True\n        assert len(diag[\"errors\"]) > 0\n        client.disconnect()\n\n    def test_failure_injection_count_decrements(self, ftp_client_factory, ftp_server):\n        \"\"\"Failure injection with count decrements and eventually succeeds.\"\"\"\n        ftp_server.add_file(\"cache/retry.bin\", b\"data after retry\")\n        ftp_server.inject_failure(\"RETR\", 550, \"Temporary error.\", count=1)\n        client = ftp_client_factory()\n        client.connect()\n        # First attempt fails\n        result1 = client.download_file(\"/cache/retry.bin\")\n        assert result1 is None\n        # Second attempt succeeds (failure count exhausted)\n        result2 = client.download_file(\"/cache/retry.bin\")\n        assert result2 == b\"data after retry\"\n        client.disconnect()\n\n    def test_upload_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):\n        \"\"\"Upload returns True without calling voidresp() for any model.\n\n        voidresp() is skipped for all models: A1 printers hang on it,\n        H2D printers delay the 226 response by 30+ seconds, and X1C/P1S\n        gain nothing from waiting. The file is on the SD card once\n        sendall() returns.\n        \"\"\"\n        content = b\"voidresp test data\"\n        local = tmp_path / \"voidresp_test.3mf\"\n        local.write_bytes(content)\n        for model in (\"X1C\", \"A1\", \"H2D\", None):\n            client = ftp_client_factory(printer_model=model)\n            client.connect()\n            result = client.upload_file(local, \"/cache/voidresp_test.3mf\")\n            assert result is True, f\"Upload failed for model={model}\"\n            client.disconnect()\n            # Verify the file is actually on the server\n            time.sleep(_UPLOAD_FLUSH_DELAY)\n            client2 = ftp_client_factory()\n            client2.connect()\n            downloaded = client2.download_file(\"/cache/voidresp_test.3mf\")\n            assert downloaded == content, f\"Content mismatch for model={model}\"\n            client2.disconnect()\n\n\n# ---------------------------------------------------------------------------\n# Short-circuit retries on 550 (#972)\n# ---------------------------------------------------------------------------\nclass TestFileNotOnPrinterShortCircuit:\n    \"\"\"FileNotOnPrinterError must bypass the retry budget.\n\n    Before this fix, a 3MF path that wasn't on the printer (550) cost\n    `ftp_retry_count + 1` attempts × `ftp_retry_delay` seconds per candidate\n    path. With ftp_retry_count=10 and four candidate paths, that's ~22 min\n    of dead retries before the real path is tried. #972 in the wild showed\n    48 min of retrying paths that didn't exist.\n    \"\"\"\n\n    async def test_with_ftp_retry_propagates_file_not_on_printer_without_retrying(self):\n        \"\"\"with_ftp_retry raises FileNotOnPrinterError on first attempt.\n\n        Verifies non_retry_exceptions short-circuits before the retry loop\n        has a chance to sleep and try again.\n        \"\"\"\n        attempts = {\"n\": 0}\n\n        async def always_missing(*_args, **_kwargs):\n            attempts[\"n\"] += 1\n            raise FileNotOnPrinterError(\"/cache/absent.3mf: 550\")\n\n        with pytest.raises(FileNotOnPrinterError):\n            await with_ftp_retry(\n                always_missing,\n                max_retries=10,\n                retry_delay=0.01,\n                operation_name=\"test 550 short-circuit\",\n                non_retry_exceptions=(FileNotOnPrinterError,),\n            )\n\n        assert attempts[\"n\"] == 1, \"550 must not trigger any retry\"\n\n    async def test_with_ftp_retry_still_retries_transient_errors(self):\n        \"\"\"Non-550 exceptions continue to retry up to max_retries + 1.\"\"\"\n        attempts = {\"n\": 0}\n\n        async def flaky(*_args, **_kwargs):\n            attempts[\"n\"] += 1\n            raise TimeoutError(\"transient\")\n\n        result = await with_ftp_retry(\n            flaky,\n            max_retries=2,\n            retry_delay=0.01,\n            operation_name=\"test transient retries\",\n            non_retry_exceptions=(FileNotOnPrinterError,),\n        )\n\n        assert result is None\n        assert attempts[\"n\"] == 3, \"Transient errors should retry to exhaustion\"\n\n    def test_download_to_file_raises_on_missing_path(self, ftp_client_factory, tmp_path):\n        \"\"\"download_to_file surfaces 550 as FileNotOnPrinterError end-to-end\n        against the real mock FTPS server, not just a hand-rolled mock.\"\"\"\n        local = tmp_path / \"never_downloaded.3mf\"\n        client = ftp_client_factory()\n        client.connect()\n        try:\n            with pytest.raises(FileNotOnPrinterError):\n                client.download_to_file(\"/cache/does_not_exist.3mf\", local)\n        finally:\n            client.disconnect()\n        assert not local.exists(), \"Partial file must be cleaned up on 550\"\n\n\n# ---------------------------------------------------------------------------\n# 3MF download cache (#972)\n# ---------------------------------------------------------------------------\nclass TestThreeMFCache:\n    \"\"\"Cover endpoint and archive flow share downloaded 3MF bytes via this\n    cache. Tests isolate themselves with clear_3mf_cache(delete_files=False)\n    so they don't clobber each other.\"\"\"\n\n    def setup_method(self):\n        clear_3mf_cache(delete_files=False)\n\n    def teardown_method(self):\n        clear_3mf_cache(delete_files=False)\n\n    def test_normalize_collapses_filename_variants(self):\n        \"\"\"Bambu names vary (.3mf, .gcode.3mf, with spaces) — they all map\n        to the same cache slot so both flows agree on the key.\"\"\"\n        canonical = normalize_3mf_name(\"Broly_Legendary.gcode.3mf\")\n        assert normalize_3mf_name(\"Broly_Legendary.3mf\") == canonical\n        assert normalize_3mf_name(\"Broly_Legendary\") == canonical\n        # Bambu Studio rewrites spaces to underscores on upload — treat as equal\n        assert normalize_3mf_name(\"Broly Legendary\") == canonical\n        # Case is also collapsed so keys match across capitalizations\n        assert normalize_3mf_name(\"BROLY_LEGENDARY.3MF\") == canonical\n\n    def test_cache_hit_returns_stored_path(self, tmp_path):\n        \"\"\"get_cached_3mf returns the same Path that was put in.\"\"\"\n        f = tmp_path / \"Broly.gcode.3mf\"\n        f.write_bytes(b\"fake 3mf content\")\n        cache_3mf_download(1, \"Broly.gcode.3mf\", f)\n        assert get_cached_3mf(1, \"Broly.gcode.3mf\") == f\n\n    def test_cache_lookup_uses_normalized_name(self, tmp_path):\n        \"\"\"Caching under .gcode.3mf and querying with bare name still hits.\"\"\"\n        f = tmp_path / \"Broly.gcode.3mf\"\n        f.write_bytes(b\"x\")\n        cache_3mf_download(1, \"Broly.gcode.3mf\", f)\n        assert get_cached_3mf(1, \"Broly.3mf\") == f\n        assert get_cached_3mf(1, \"Broly\") == f\n\n    def test_cache_miss_on_different_printer(self, tmp_path):\n        \"\"\"Printer id is part of the key — two printers never collide.\"\"\"\n        f = tmp_path / \"A.3mf\"\n        f.write_bytes(b\"x\")\n        cache_3mf_download(1, \"A.3mf\", f)\n        assert get_cached_3mf(2, \"A.3mf\") is None\n\n    def test_cache_evicts_when_file_deleted(self, tmp_path):\n        \"\"\"Stale entry (file gone) returns None and is dropped from the dict.\"\"\"\n        f = tmp_path / \"A.3mf\"\n        f.write_bytes(b\"x\")\n        cache_3mf_download(1, \"A.3mf\", f)\n        f.unlink()\n        assert get_cached_3mf(1, \"A.3mf\") is None\n        # Re-populating after eviction works — no ghost entries remain.\n        f.write_bytes(b\"y\")\n        cache_3mf_download(1, \"A.3mf\", f)\n        assert get_cached_3mf(1, \"A.3mf\") == f\n\n    def test_clear_by_printer_scoped(self, tmp_path):\n        \"\"\"Clearing one printer leaves the other untouched.\"\"\"\n        f1 = tmp_path / \"one.3mf\"\n        f1.write_bytes(b\"1\")\n        f2 = tmp_path / \"two.3mf\"\n        f2.write_bytes(b\"2\")\n        cache_3mf_download(1, \"one.3mf\", f1)\n        cache_3mf_download(2, \"two.3mf\", f2)\n        clear_3mf_cache(1)\n        assert get_cached_3mf(1, \"one.3mf\") is None\n        assert get_cached_3mf(2, \"two.3mf\") == f2\n        # clear_3mf_cache defaulted to delete_files=True, so the file is gone\n        assert not f1.exists()\n        assert f2.exists()\n\n    def test_clear_without_deleting_files(self, tmp_path):\n        \"\"\"delete_files=False leaves files on disk — used by tests.\"\"\"\n        f = tmp_path / \"keep.3mf\"\n        f.write_bytes(b\"x\")\n        cache_3mf_download(1, \"keep.3mf\", f)\n        clear_3mf_cache(1, delete_files=False)\n        assert get_cached_3mf(1, \"keep.3mf\") is None\n        assert f.exists()\n"
  },
  {
    "path": "backend/tests/unit/services/test_bambu_mqtt.py",
    "content": "\"\"\"\nTests for the BambuMQTTClient service.\n\nThese tests focus on timelapse tracking during prints.\n\"\"\"\n\nimport json\nimport time\n\nimport pytest\n\n\nclass TestTimelapseTracking:\n    \"\"\"Tests for timelapse state tracking during prints.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient instance for testing.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_timelapse_flag_initializes_to_false(self, mqtt_client):\n        \"\"\"Verify _timelapse_during_print starts as False.\"\"\"\n        assert mqtt_client._timelapse_during_print is False\n\n    def test_timelapse_flag_set_when_timelapse_active_during_running(self, mqtt_client):\n        \"\"\"Verify timelapse flag is set when timelapse is active while printing.\"\"\"\n        # Simulate print running\n        mqtt_client._was_running = True\n        mqtt_client.state.timelapse = False\n\n        # Simulate xcam data showing timelapse is enabled\n        xcam_data = {\"timelapse\": \"enable\"}\n        mqtt_client._parse_xcam_data(xcam_data)\n\n        assert mqtt_client.state.timelapse is True\n        assert mqtt_client._timelapse_during_print is True\n\n    def test_timelapse_flag_not_set_when_not_running(self, mqtt_client):\n        \"\"\"Verify timelapse flag is NOT set when printer not running.\"\"\"\n        # Printer is idle (not running)\n        mqtt_client._was_running = False\n        mqtt_client.state.timelapse = False\n\n        # Timelapse is enabled but we're not printing\n        xcam_data = {\"timelapse\": \"enable\"}\n        mqtt_client._parse_xcam_data(xcam_data)\n\n        assert mqtt_client.state.timelapse is True\n        # Flag should NOT be set since we're not printing\n        assert mqtt_client._timelapse_during_print is False\n\n    def test_timelapse_flag_persists_after_timelapse_stops(self, mqtt_client):\n        \"\"\"Verify timelapse flag stays True even after recording stops.\"\"\"\n        # Simulate print running with timelapse\n        mqtt_client._was_running = True\n\n        # Enable timelapse during print\n        xcam_data = {\"timelapse\": \"enable\"}\n        mqtt_client._parse_xcam_data(xcam_data)\n        assert mqtt_client._timelapse_during_print is True\n\n        # Disable timelapse (recording stops at end of print)\n        xcam_data = {\"timelapse\": \"disable\"}\n        mqtt_client._parse_xcam_data(xcam_data)\n\n        # Flag should still be True (persists until reset)\n        assert mqtt_client.state.timelapse is False\n        assert mqtt_client._timelapse_during_print is True\n\n    def test_timelapse_flag_from_print_data(self, mqtt_client):\n        \"\"\"Verify timelapse flag is set from print data (not just xcam).\"\"\"\n        # Simulate print running\n        mqtt_client._was_running = True\n        mqtt_client.state.timelapse = False\n        mqtt_client._timelapse_during_print = False\n\n        # Manually test the timelapse parsing logic from _parse_print_data\n        # This tests the \"timelapse\" field in the main print data\n        data = {\"timelapse\": True}\n        mqtt_client.state.timelapse = data[\"timelapse\"] is True\n        if mqtt_client.state.timelapse and mqtt_client._was_running:\n            mqtt_client._timelapse_during_print = True\n\n        assert mqtt_client._timelapse_during_print is True\n\n\nclass TestPrintCompletionWithTimelapse:\n    \"\"\"Tests for print completion including timelapse flag.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient instance for testing.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_print_complete_includes_timelapse_flag(self, mqtt_client):\n        \"\"\"Verify print complete callback includes timelapse_was_active.\"\"\"\n        # Set up completion callback\n        callback_data = {}\n\n        def on_complete(data):\n            callback_data.update(data)\n\n        mqtt_client.on_print_complete = on_complete\n\n        # Simulate a print that had timelapse active\n        mqtt_client._was_running = True\n        mqtt_client._completion_triggered = False\n        mqtt_client._timelapse_during_print = True\n        mqtt_client._previous_gcode_state = \"RUNNING\"\n        mqtt_client._previous_gcode_file = \"test.gcode\"\n        mqtt_client.state.subtask_name = \"Test Print\"\n\n        # Simulate print finish\n        mqtt_client.state.state = \"FINISH\"\n\n        # Manually trigger the completion logic (simplified)\n        # In real code this happens in _parse_print_data\n        should_trigger = (\n            mqtt_client.state.state in (\"FINISH\", \"FAILED\")\n            and not mqtt_client._completion_triggered\n            and mqtt_client.on_print_complete\n            and mqtt_client._previous_gcode_state == \"RUNNING\"\n        )\n\n        if should_trigger:\n            status = \"completed\" if mqtt_client.state.state == \"FINISH\" else \"failed\"\n            timelapse_was_active = mqtt_client._timelapse_during_print\n            mqtt_client._completion_triggered = True\n            mqtt_client._was_running = False\n            mqtt_client._timelapse_during_print = False\n            mqtt_client.on_print_complete(\n                {\n                    \"status\": status,\n                    \"filename\": mqtt_client._previous_gcode_file,\n                    \"subtask_name\": mqtt_client.state.subtask_name,\n                    \"timelapse_was_active\": timelapse_was_active,\n                }\n            )\n\n        assert \"timelapse_was_active\" in callback_data\n        assert callback_data[\"timelapse_was_active\"] is True\n\n    def test_print_complete_timelapse_flag_false_when_no_timelapse(self, mqtt_client):\n        \"\"\"Verify timelapse_was_active is False when no timelapse during print.\"\"\"\n        callback_data = {}\n\n        def on_complete(data):\n            callback_data.update(data)\n\n        mqtt_client.on_print_complete = on_complete\n\n        # Print without timelapse\n        mqtt_client._was_running = True\n        mqtt_client._completion_triggered = False\n        mqtt_client._timelapse_during_print = False  # No timelapse\n        mqtt_client._previous_gcode_state = \"RUNNING\"\n        mqtt_client._previous_gcode_file = \"test.gcode\"\n        mqtt_client.state.subtask_name = \"Test Print\"\n        mqtt_client.state.state = \"FINISH\"\n\n        # Trigger completion\n        timelapse_was_active = mqtt_client._timelapse_during_print\n        mqtt_client.on_print_complete(\n            {\n                \"status\": \"completed\",\n                \"filename\": mqtt_client._previous_gcode_file,\n                \"subtask_name\": mqtt_client.state.subtask_name,\n                \"timelapse_was_active\": timelapse_was_active,\n            }\n        )\n\n        assert callback_data[\"timelapse_was_active\"] is False\n\n    def test_timelapse_flag_reset_after_completion(self, mqtt_client):\n        \"\"\"Verify _timelapse_during_print is reset after print completion.\"\"\"\n        mqtt_client._timelapse_during_print = True\n        mqtt_client._was_running = True\n        mqtt_client._completion_triggered = False\n\n        # Simulate completion reset\n        mqtt_client._completion_triggered = True\n        mqtt_client._was_running = False\n        mqtt_client._timelapse_during_print = False\n\n        assert mqtt_client._timelapse_during_print is False\n\n\nclass TestRealisticMessageFlow:\n    \"\"\"Tests that simulate realistic MQTT message sequences.\n\n    These tests process messages through _process_message to test the full flow,\n    including the order of xcam parsing vs state detection.\n    \"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient instance for testing.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_timelapse_detected_at_print_start_in_same_message(self, mqtt_client):\n        \"\"\"Test that timelapse is detected when xcam and state come in same message.\n\n        This is the critical race condition test - xcam data is parsed BEFORE\n        state detection, so the timelapse flag must be set AFTER _was_running is True.\n        \"\"\"\n        # Callbacks to track events\n        start_callback_data = {}\n\n        def on_start(data):\n            start_callback_data.update(data)\n\n        mqtt_client.on_print_start = on_start\n\n        # Initial state - idle\n        mqtt_client._was_running = False\n        mqtt_client._timelapse_during_print = False\n        mqtt_client._previous_gcode_state = None\n\n        # Simulate first message when print starts - contains both xcam and gcode_state\n        # This is the realistic scenario from the printer\n        # NOTE: Real MQTT messages wrap print data inside a \"print\" key\n        payload = {\n            \"print\": {\n                \"gcode_state\": \"RUNNING\",\n                \"gcode_file\": \"/data/Metadata/test_print.gcode\",\n                \"subtask_name\": \"Test_Print\",\n                \"xcam\": {\n                    \"timelapse\": \"enable\",  # Timelapse is enabled in this print\n                    \"printing_monitor\": True,\n                },\n                \"mc_percent\": 0,\n                \"mc_remaining_time\": 3600,\n            }\n        }\n\n        # Process the message (this is what happens in real MQTT flow)\n        mqtt_client._process_message(payload)\n\n        # Verify timelapse was detected even though xcam is parsed before state\n        assert mqtt_client._was_running is True, \"_was_running should be True after RUNNING state\"\n        assert mqtt_client.state.timelapse is True, \"state.timelapse should be True\"\n        assert mqtt_client._timelapse_during_print is True, (\n            \"timelapse_during_print should be True when timelapse is in the same message as RUNNING state\"\n        )\n\n    def test_timelapse_not_detected_when_disabled(self, mqtt_client):\n        \"\"\"Test that timelapse is NOT detected when disabled in xcam data.\"\"\"\n        mqtt_client.on_print_start = lambda data: None\n\n        # Initial state - idle\n        mqtt_client._was_running = False\n        mqtt_client._timelapse_during_print = False\n        mqtt_client._previous_gcode_state = None\n\n        # Print starts without timelapse\n        payload = {\n            \"print\": {\n                \"gcode_state\": \"RUNNING\",\n                \"gcode_file\": \"/data/Metadata/test_print.gcode\",\n                \"subtask_name\": \"Test_Print\",\n                \"xcam\": {\n                    \"timelapse\": \"disable\",  # Timelapse is disabled\n                    \"printing_monitor\": True,\n                },\n            }\n        }\n\n        mqtt_client._process_message(payload)\n\n        assert mqtt_client._was_running is True\n        assert mqtt_client.state.timelapse is False\n        assert mqtt_client._timelapse_during_print is False\n\n    def test_timelapse_detected_when_enabled_after_print_start(self, mqtt_client):\n        \"\"\"Test timelapse detected when enabled in a message after print starts.\"\"\"\n        mqtt_client.on_print_start = lambda data: None\n\n        # First message - print starts without timelapse info\n        payload_start = {\n            \"print\": {\n                \"gcode_state\": \"RUNNING\",\n                \"gcode_file\": \"/data/Metadata/test_print.gcode\",\n                \"subtask_name\": \"Test_Print\",\n            }\n        }\n        mqtt_client._process_message(payload_start)\n\n        assert mqtt_client._was_running is True\n        assert mqtt_client._timelapse_during_print is False  # Not detected yet\n\n        # Second message - xcam data arrives with timelapse enabled\n        payload_xcam = {\n            \"print\": {\n                \"gcode_state\": \"RUNNING\",\n                \"gcode_file\": \"/data/Metadata/test_print.gcode\",\n                \"subtask_name\": \"Test_Print\",\n                \"xcam\": {\n                    \"timelapse\": \"enable\",\n                },\n            }\n        }\n        mqtt_client._process_message(payload_xcam)\n\n        # Now timelapse should be detected because _was_running is already True\n        assert mqtt_client._timelapse_during_print is True\n\n    def test_print_complete_includes_timelapse_flag_full_flow(self, mqtt_client):\n        \"\"\"Test full print lifecycle with timelapse - from start to completion.\"\"\"\n        start_data = {}\n        complete_data = {}\n\n        def on_start(data):\n            start_data.update(data)\n\n        def on_complete(data):\n            complete_data.update(data)\n\n        mqtt_client.on_print_start = on_start\n        mqtt_client.on_print_complete = on_complete\n\n        # 1. Print starts with timelapse\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"xcam\": {\"timelapse\": \"enable\"},\n                }\n            }\n        )\n\n        assert mqtt_client._timelapse_during_print is True\n        assert \"subtask_name\" in start_data\n\n        # 2. Print continues (multiple messages)\n        for _ in range(3):\n            mqtt_client._process_message(\n                {\n                    \"print\": {\n                        \"gcode_state\": \"RUNNING\",\n                        \"gcode_file\": \"/data/Metadata/test.gcode\",\n                        \"subtask_name\": \"Test\",\n                        \"mc_percent\": 50,\n                    }\n                }\n            )\n\n        # Timelapse flag should still be True\n        assert mqtt_client._timelapse_during_print is True\n\n        # 3. Print completes\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FINISH\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        # Verify completion callback received timelapse flag\n        assert \"timelapse_was_active\" in complete_data\n        assert complete_data[\"timelapse_was_active\"] is True\n        assert complete_data[\"status\"] == \"completed\"\n\n        # Flags should be reset after completion\n        assert mqtt_client._timelapse_during_print is False\n        assert mqtt_client._was_running is False\n\n    def test_print_failed_includes_timelapse_flag(self, mqtt_client):\n        \"\"\"Test that failed print also includes timelapse flag.\"\"\"\n        complete_data = {}\n\n        def on_complete(data):\n            complete_data.update(data)\n\n        mqtt_client.on_print_start = lambda data: None\n        mqtt_client.on_print_complete = on_complete\n\n        # Start with timelapse\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"xcam\": {\"timelapse\": \"enable\"},\n                }\n            }\n        )\n\n        # Print fails\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FAILED\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert complete_data[\"timelapse_was_active\"] is True\n        assert complete_data[\"status\"] == \"failed\"\n\n\nclass TestAMSDataMerging:\n    \"\"\"Tests for AMS data merging, particularly handling empty slots.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient instance for testing.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_empty_slot_clears_tray_type(self, mqtt_client):\n        \"\"\"Test that empty slot update clears tray_type (Issue #147).\n\n        When a spool is removed from an old AMS, the printer sends empty values.\n        These must overwrite the previous values to show the slot as empty.\n        \"\"\"\n        # Initial state: AMS unit with a loaded spool\n        initial_ams = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_type\": \"PLA\",\n                            \"tray_sub_brands\": \"Bambu PLA Basic\",\n                            \"tray_color\": \"FF0000\",\n                            \"tag_uid\": \"1234567890ABCDEF\",\n                            \"remain\": 80,\n                        }\n                    ],\n                }\n            ]\n        }\n        mqtt_client._handle_ams_data(initial_ams)\n\n        # Verify initial state\n        ams_data = mqtt_client.state.raw_data.get(\"ams\", [])\n        assert len(ams_data) == 1\n        tray = ams_data[0][\"tray\"][0]\n        assert tray[\"tray_type\"] == \"PLA\"\n        assert tray[\"tray_color\"] == \"FF0000\"\n\n        # Now simulate spool removal - printer sends empty values\n        empty_update = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_type\": \"\",  # Empty = slot is empty\n                            \"tray_sub_brands\": \"\",\n                            \"tray_color\": \"\",\n                            \"tag_uid\": \"0000000000000000\",  # Zero UID\n                            \"remain\": 0,\n                        }\n                    ],\n                }\n            ]\n        }\n        mqtt_client._handle_ams_data(empty_update)\n\n        # Verify empty values were applied (not ignored by merge logic)\n        ams_data = mqtt_client.state.raw_data.get(\"ams\", [])\n        tray = ams_data[0][\"tray\"][0]\n        assert tray[\"tray_type\"] == \"\", \"tray_type should be cleared when slot is empty\"\n        assert tray[\"tray_color\"] == \"\", \"tray_color should be cleared when slot is empty\"\n        assert tray[\"tray_sub_brands\"] == \"\", \"tray_sub_brands should be cleared\"\n        assert tray[\"tag_uid\"] == \"0000000000000000\", \"tag_uid should be cleared\"\n\n    def test_partial_update_preserves_other_fields(self, mqtt_client):\n        \"\"\"Test that partial updates still preserve non-slot-status fields.\"\"\"\n        # Initial state with full data\n        initial_ams = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"humidity\": \"3\",\n                    \"temp\": \"25.5\",\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_type\": \"PLA\",\n                            \"tray_color\": \"00FF00\",\n                            \"remain\": 90,\n                            \"k\": 0.02,\n                        }\n                    ],\n                }\n            ]\n        }\n        mqtt_client._handle_ams_data(initial_ams)\n\n        # Partial update - only remain changes\n        partial_update = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"remain\": 85,  # Only this changed\n                        }\n                    ],\n                }\n            ]\n        }\n        mqtt_client._handle_ams_data(partial_update)\n\n        # Verify remain was updated but other fields preserved\n        ams_data = mqtt_client.state.raw_data.get(\"ams\", [])\n        tray = ams_data[0][\"tray\"][0]\n        assert tray[\"remain\"] == 85, \"remain should be updated\"\n        assert tray[\"tray_type\"] == \"PLA\", \"tray_type should be preserved\"\n        assert tray[\"tray_color\"] == \"00FF00\", \"tray_color should be preserved\"\n        assert tray[\"k\"] == 0.02, \"k should be preserved\"\n\n    def test_tray_exist_bits_clears_empty_slots(self, mqtt_client):\n        \"\"\"Test that tray_exist_bits clears slots marked as empty (Issue #147).\n\n        New AMS models (AMS 2 Pro) don't send empty tray data when a spool is removed.\n        Instead, they update tray_exist_bits to indicate which slots have spools.\n        \"\"\"\n        # Initial state: AMS 0 and AMS 1 with loaded spools\n        initial_ams = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000\", \"remain\": 80},\n                        {\"id\": 1, \"tray_type\": \"PETG\", \"tray_color\": \"00FF00\", \"remain\": 60},\n                        {\"id\": 2, \"tray_type\": \"ABS\", \"tray_color\": \"0000FF\", \"remain\": 40},\n                        {\"id\": 3, \"tray_type\": \"TPU\", \"tray_color\": \"FFFF00\", \"remain\": 20},\n                    ],\n                },\n                {\n                    \"id\": 1,\n                    \"tray\": [\n                        {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FFFFFF\", \"remain\": 90},\n                        {\"id\": 1, \"tray_type\": \"PLA\", \"tray_color\": \"000000\", \"remain\": 70},\n                        {\"id\": 2, \"tray_type\": \"PLA\", \"tray_color\": \"FF00FF\", \"remain\": 50},\n                        {\"id\": 3, \"tray_type\": \"PLA\", \"tray_color\": \"00FFFF\", \"remain\": 30},\n                    ],\n                },\n            ],\n            \"tray_exist_bits\": \"ff\",  # All 8 slots have spools (0xFF = 11111111)\n        }\n        mqtt_client._handle_ams_data(initial_ams)\n\n        # Verify initial state\n        ams_data = mqtt_client.state.raw_data.get(\"ams\", [])\n        assert ams_data[1][\"tray\"][3][\"tray_type\"] == \"PLA\"  # AMS 1 slot 3 (B4) has spool\n\n        # Now simulate spool removal from AMS 1 slot 3 (B4)\n        # tray_exist_bits: 0x7f = 01111111 (bit 7 = 0 means AMS 1 slot 3 is empty)\n        update_ams = {\n            \"ams\": [\n                {\"id\": 0, \"tray\": [{\"id\": 0}, {\"id\": 1}, {\"id\": 2}, {\"id\": 3}]},\n                {\"id\": 1, \"tray\": [{\"id\": 0}, {\"id\": 1}, {\"id\": 2}, {\"id\": 3}]},\n            ],\n            \"tray_exist_bits\": \"7f\",  # Bit 7 = 0 -> AMS 1 slot 3 is empty\n        }\n        mqtt_client._handle_ams_data(update_ams)\n\n        # Verify AMS 1 slot 3 was cleared\n        ams_data = mqtt_client.state.raw_data.get(\"ams\", [])\n        b4_tray = ams_data[1][\"tray\"][3]\n        assert b4_tray[\"tray_type\"] == \"\", \"tray_type should be cleared for empty slot\"\n        assert b4_tray[\"remain\"] == 0, \"remain should be 0 for empty slot\"\n\n        # Verify other slots are preserved\n        assert ams_data[0][\"tray\"][0][\"tray_type\"] == \"PLA\", \"A1 should still have PLA\"\n        assert ams_data[1][\"tray\"][0][\"tray_type\"] == \"PLA\", \"B1 should still have PLA\"\n\n    def test_shutdown_message_preserves_ams_data(self, mqtt_client):\n        \"\"\"Printer shutdown (power_on_flag=False) must not wipe AMS slot data (#765).\n\n        When a printer shuts down it sends a final MQTT message with\n        tray_exist_bits='0' and power_on_flag=False. This all-zero value\n        previously caused every slot to be cleared, which then triggered\n        auto-unlink of all spool assignments on reconnect.\n        \"\"\"\n        # Initial state: two AMS units with loaded spools\n        initial_ams = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000FF\", \"remain\": 80},\n                        {\"id\": 1, \"tray_type\": \"PETG\", \"tray_color\": \"00FF00FF\", \"remain\": 60},\n                    ],\n                },\n                {\n                    \"id\": 1,\n                    \"tray\": [\n                        {\"id\": 0, \"tray_type\": \"PETG\", \"tray_color\": \"DBDDD9FF\", \"remain\": 90},\n                        {\"id\": 1, \"tray_type\": \"PETG\", \"tray_color\": \"67DB25FF\", \"remain\": 70},\n                    ],\n                },\n            ],\n            \"tray_exist_bits\": \"33\",  # Slots 0,1 of each AMS (0b00110011)\n            \"power_on_flag\": True,\n        }\n        mqtt_client._handle_ams_data(initial_ams)\n\n        # Verify initial state\n        ams_data = mqtt_client.state.raw_data[\"ams\"]\n        assert ams_data[0][\"tray\"][0][\"tray_type\"] == \"PLA\"\n        assert ams_data[1][\"tray\"][0][\"tray_type\"] == \"PETG\"\n\n        # Simulate printer shutdown — all-zero bits with power_on_flag=False\n        shutdown_ams = {\n            \"ams_exist_bits\": \"0\",\n            \"tray_exist_bits\": \"0\",\n            \"power_on_flag\": False,\n            \"insert_flag\": False,\n            \"tray_now\": \"0\",\n            \"version\": 0,\n        }\n        mqtt_client._handle_ams_data(shutdown_ams)\n\n        # AMS slot data MUST be preserved — shutdown should not clear it\n        ams_data = mqtt_client.state.raw_data[\"ams\"]\n        assert ams_data[0][\"tray\"][0][\"tray_type\"] == \"PLA\", \"Shutdown must not clear AMS 0 slot 0\"\n        assert ams_data[0][\"tray\"][0][\"tray_color\"] == \"FF0000FF\", \"Shutdown must not clear AMS 0 slot 0 color\"\n        assert ams_data[0][\"tray\"][1][\"tray_type\"] == \"PETG\", \"Shutdown must not clear AMS 0 slot 1\"\n        assert ams_data[1][\"tray\"][0][\"tray_type\"] == \"PETG\", \"Shutdown must not clear AMS 1 slot 0\"\n        assert ams_data[1][\"tray\"][1][\"tray_type\"] == \"PETG\", \"Shutdown must not clear AMS 1 slot 1\"\n\n    def test_genuine_removal_still_clears_with_power_on(self, mqtt_client):\n        \"\"\"Genuine spool removal (power_on_flag=True) must still clear slot data.\n\n        Ensures the #765 fix doesn't break normal spool removal detection.\n        \"\"\"\n        # Initial state: AMS with loaded spool\n        initial_ams = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000\", \"remain\": 80},\n                        {\"id\": 1, \"tray_type\": \"PETG\", \"tray_color\": \"00FF00\", \"remain\": 60},\n                    ],\n                },\n            ],\n            \"tray_exist_bits\": \"3\",  # Both slots occupied (0b11)\n            \"power_on_flag\": True,\n        }\n        mqtt_client._handle_ams_data(initial_ams)\n\n        # Spool removed from slot 1 while printer is running\n        removal_ams = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [{\"id\": 0}, {\"id\": 1}],\n                },\n            ],\n            \"tray_exist_bits\": \"1\",  # Only slot 0 occupied (0b01)\n            \"power_on_flag\": True,\n        }\n        mqtt_client._handle_ams_data(removal_ams)\n\n        # Slot 0 preserved, slot 1 cleared\n        ams_data = mqtt_client.state.raw_data[\"ams\"]\n        assert ams_data[0][\"tray\"][0][\"tray_type\"] == \"PLA\", \"Slot 0 should be preserved\"\n        assert ams_data[0][\"tray\"][1][\"tray_type\"] == \"\", \"Slot 1 should be cleared on removal\"\n        assert ams_data[0][\"tray\"][1][\"tray_color\"] == \"\", \"Slot 1 color should be cleared\"\n\n    def test_power_on_flag_defaults_true_when_absent(self, mqtt_client):\n        \"\"\"When power_on_flag is not in the MQTT data, clearing must proceed normally.\n\n        Ensures backwards compatibility with firmware that doesn't send power_on_flag.\n        \"\"\"\n        # Initial state\n        initial_ams = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000\", \"remain\": 80},\n                    ],\n                },\n            ],\n            \"tray_exist_bits\": \"1\",\n        }\n        mqtt_client._handle_ams_data(initial_ams)\n\n        # Update WITHOUT power_on_flag — should still clear when bit=0\n        update_ams = {\n            \"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0}]}],\n            \"tray_exist_bits\": \"0\",\n            # No power_on_flag key at all\n        }\n        mqtt_client._handle_ams_data(update_ams)\n\n        ams_data = mqtt_client.state.raw_data[\"ams\"]\n        assert ams_data[0][\"tray\"][0][\"tray_type\"] == \"\", (\n            \"Without power_on_flag, clearing should proceed (defaults to True)\"\n        )\n\n\nclass TestAMSTrayStateClearning:\n    \"\"\"Tests for AMS tray state-based clearing (#784).\n\n    Some printers (e.g. H2D) only send {id, state} in incremental MQTT\n    updates when a tray is not fully loaded.  state=11 means loaded;\n    other values (9=empty, 10=spool present but filament not in feeder)\n    should clear stale tray data that was set from an earlier pushall.\n    \"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_H2D\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def _seed_loaded_tray(self, mqtt_client):\n        \"\"\"Seed AMS 0 with a fully loaded tray (state=11) and an empty slot.\"\"\"\n        initial = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_type\": \"PETG\",\n                            \"tray_sub_brands\": \"PETG HF\",\n                            \"tray_color\": \"00FF00FF\",\n                            \"tray_id_name\": \"A00-G1\",\n                            \"tray_info_idx\": \"GFG99\",\n                            \"tag_uid\": \"AABBCCDD11223344\",\n                            \"tray_uuid\": \"AABBCCDD11223344AABBCCDD11223344\",\n                            \"remain\": 75,\n                            \"k\": 0.02,\n                            \"cali_idx\": 5,\n                            \"state\": 11,\n                        },\n                        {\n                            \"id\": 1,\n                            \"tray_type\": \"PLA\",\n                            \"tray_color\": \"FF0000FF\",\n                            \"remain\": 50,\n                            \"state\": 11,\n                        },\n                    ],\n                }\n            ],\n            \"power_on_flag\": False,  # H2D always sends False\n        }\n        mqtt_client._handle_ams_data(initial)\n        ams = mqtt_client.state.raw_data[\"ams\"]\n        assert ams[0][\"tray\"][0][\"tray_type\"] == \"PETG\"\n        assert ams[0][\"tray\"][1][\"tray_type\"] == \"PLA\"\n\n    def test_state_10_clears_stale_tray_data(self, mqtt_client):\n        \"\"\"Incremental update with state=10 (spool present, not loaded) clears tray.\"\"\"\n        self._seed_loaded_tray(mqtt_client)\n\n        # H2D sends only {id, state} when filament is retracted\n        update = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"state\": 10},\n                        {\"id\": 1, \"state\": 11},  # slot 1 still loaded\n                    ],\n                }\n            ],\n            \"power_on_flag\": False,\n        }\n        mqtt_client._handle_ams_data(update)\n\n        ams = mqtt_client.state.raw_data[\"ams\"]\n        tray0 = ams[0][\"tray\"][0]\n        tray1 = ams[0][\"tray\"][1]\n\n        # Tray 0 should be cleared\n        assert tray0[\"tray_type\"] == \"\", \"tray_type must be cleared on state=10\"\n        assert tray0[\"tray_color\"] == \"\", \"tray_color must be cleared\"\n        assert tray0[\"tray_sub_brands\"] == \"\", \"tray_sub_brands must be cleared\"\n        assert tray0[\"tray_id_name\"] == \"\", \"tray_id_name must be cleared\"\n        assert tray0[\"tray_info_idx\"] == \"\", \"tray_info_idx must be cleared\"\n        assert tray0[\"tag_uid\"] == \"0000000000000000\", \"tag_uid must be cleared\"\n        assert tray0[\"tray_uuid\"] == \"00000000000000000000000000000000\", \"tray_uuid must be cleared\"\n        assert tray0[\"remain\"] == 0, \"remain must be 0\"\n        assert tray0[\"k\"] is None, \"k must be cleared\"\n        assert tray0[\"cali_idx\"] is None, \"cali_idx must be cleared\"\n        assert tray0[\"state\"] == 10, \"state should be preserved\"\n\n        # Tray 1 should be untouched\n        assert tray1[\"tray_type\"] == \"PLA\", \"Loaded slot must be preserved\"\n        assert tray1[\"remain\"] == 50\n\n    def test_state_9_clears_stale_tray_data(self, mqtt_client):\n        \"\"\"Incremental update with state=9 (empty, no spool) clears tray.\"\"\"\n        self._seed_loaded_tray(mqtt_client)\n\n        update = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"state\": 9},\n                        {\"id\": 1, \"state\": 11},\n                    ],\n                }\n            ],\n            \"power_on_flag\": False,\n        }\n        mqtt_client._handle_ams_data(update)\n\n        tray0 = mqtt_client.state.raw_data[\"ams\"][0][\"tray\"][0]\n        assert tray0[\"tray_type\"] == \"\", \"state=9 must clear tray_type\"\n        assert tray0[\"remain\"] == 0\n\n    def test_state_11_preserves_tray_data(self, mqtt_client):\n        \"\"\"Incremental update with state=11 (loaded) must NOT clear tray.\"\"\"\n        self._seed_loaded_tray(mqtt_client)\n\n        update = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"state\": 11},\n                        {\"id\": 1, \"state\": 11},\n                    ],\n                }\n            ],\n            \"power_on_flag\": False,\n        }\n        mqtt_client._handle_ams_data(update)\n\n        tray0 = mqtt_client.state.raw_data[\"ams\"][0][\"tray\"][0]\n        assert tray0[\"tray_type\"] == \"PETG\", \"state=11 must preserve tray data\"\n        assert tray0[\"tray_color\"] == \"00FF00FF\"\n        assert tray0[\"remain\"] == 75\n\n    def test_no_clearing_when_tray_type_already_empty(self, mqtt_client):\n        \"\"\"Don't re-clear a tray that's already empty (avoids log spam).\"\"\"\n        self._seed_loaded_tray(mqtt_client)\n\n        # First unload clears\n        update = {\n            \"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"state\": 10}, {\"id\": 1, \"state\": 11}]}],\n            \"power_on_flag\": False,\n        }\n        mqtt_client._handle_ams_data(update)\n        assert mqtt_client.state.raw_data[\"ams\"][0][\"tray\"][0][\"tray_type\"] == \"\"\n\n        # Second identical update should not trigger clearing again\n        # (merged_tray.get(\"tray_type\") is already empty/falsy)\n        mqtt_client._handle_ams_data(update)\n        assert mqtt_client.state.raw_data[\"ams\"][0][\"tray\"][0][\"tray_type\"] == \"\"\n\n    def test_reload_after_unload_restores_data(self, mqtt_client):\n        \"\"\"After clearing via state=10, a full update with state=11 restores data.\"\"\"\n        self._seed_loaded_tray(mqtt_client)\n\n        # Unload\n        mqtt_client._handle_ams_data(\n            {\n                \"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"state\": 10}, {\"id\": 1, \"state\": 11}]}],\n                \"power_on_flag\": False,\n            }\n        )\n        assert mqtt_client.state.raw_data[\"ams\"][0][\"tray\"][0][\"tray_type\"] == \"\"\n\n        # Reload — full tray data arrives again\n        mqtt_client._handle_ams_data(\n            {\n                \"ams\": [\n                    {\n                        \"id\": 0,\n                        \"tray\": [\n                            {\n                                \"id\": 0,\n                                \"tray_type\": \"PETG\",\n                                \"tray_sub_brands\": \"PETG HF\",\n                                \"tray_color\": \"00FF00FF\",\n                                \"remain\": 75,\n                                \"state\": 11,\n                            },\n                            {\"id\": 1, \"state\": 11},\n                        ],\n                    }\n                ],\n                \"power_on_flag\": False,\n            }\n        )\n        tray0 = mqtt_client.state.raw_data[\"ams\"][0][\"tray\"][0]\n        assert tray0[\"tray_type\"] == \"PETG\", \"Reload must restore tray data\"\n        assert tray0[\"tray_color\"] == \"00FF00FF\"\n        assert tray0[\"remain\"] == 75\n\n\nclass TestNozzleRackData:\n    \"\"\"Tests for nozzle rack data parsing from H2 series device.nozzle.info.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient instance for testing.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_h2c_nozzle_rack_populated_with_8_entries(self, mqtt_client):\n        \"\"\"H2C provides 8 nozzle entries: IDs 0,1 (L/R hotend) + 16-21 (rack).\"\"\"\n        payload = {\n            \"print\": {\n                \"device\": {\n                    \"nozzle\": {\n                        \"info\": [\n                            {\n                                \"id\": 0,\n                                \"type\": \"HS\",\n                                \"diameter\": \"0.4\",\n                                \"wear\": 5,\n                                \"stat\": 1,\n                                \"max_temp\": 300,\n                                \"serial_number\": \"SN-L\",\n                            },\n                            {\n                                \"id\": 1,\n                                \"type\": \"HS\",\n                                \"diameter\": \"0.4\",\n                                \"wear\": 3,\n                                \"stat\": 0,\n                                \"max_temp\": 300,\n                                \"serial_number\": \"SN-R\",\n                            },\n                            {\n                                \"id\": 16,\n                                \"type\": \"HS\",\n                                \"diameter\": \"0.4\",\n                                \"wear\": 10,\n                                \"stat\": 0,\n                                \"max_temp\": 300,\n                                \"serial_number\": \"SN-16\",\n                            },\n                            {\n                                \"id\": 17,\n                                \"type\": \"HH01\",\n                                \"diameter\": \"0.6\",\n                                \"wear\": 0,\n                                \"stat\": 0,\n                                \"max_temp\": 300,\n                                \"serial_number\": \"SN-17\",\n                            },\n                            {\n                                \"id\": 18,\n                                \"type\": \"HS\",\n                                \"diameter\": \"0.4\",\n                                \"wear\": 2,\n                                \"stat\": 0,\n                                \"max_temp\": 300,\n                                \"serial_number\": \"SN-18\",\n                            },\n                            {\n                                \"id\": 19,\n                                \"type\": \"\",\n                                \"diameter\": \"\",\n                                \"wear\": None,\n                                \"stat\": None,\n                                \"max_temp\": 0,\n                                \"serial_number\": \"\",\n                            },\n                            {\n                                \"id\": 20,\n                                \"type\": \"\",\n                                \"diameter\": \"\",\n                                \"wear\": None,\n                                \"stat\": None,\n                                \"max_temp\": 0,\n                                \"serial_number\": \"\",\n                            },\n                            {\n                                \"id\": 21,\n                                \"type\": \"\",\n                                \"diameter\": \"\",\n                                \"wear\": None,\n                                \"stat\": None,\n                                \"max_temp\": 0,\n                                \"serial_number\": \"\",\n                            },\n                        ]\n                    }\n                }\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        assert len(mqtt_client.state.nozzle_rack) == 8\n        ids = [n[\"id\"] for n in mqtt_client.state.nozzle_rack]\n        assert ids == [0, 1, 16, 17, 18, 19, 20, 21]\n\n    def test_h2d_nozzle_rack_populated_with_2_entries(self, mqtt_client):\n        \"\"\"H2D provides 2 nozzle entries: IDs 0,1 (L/R hotend) — no rack slots.\"\"\"\n        payload = {\n            \"print\": {\n                \"device\": {\n                    \"nozzle\": {\n                        \"info\": [\n                            {\n                                \"id\": 0,\n                                \"type\": \"HS\",\n                                \"diameter\": \"0.4\",\n                                \"wear\": 5,\n                                \"stat\": 1,\n                                \"max_temp\": 300,\n                                \"serial_number\": \"SN-L\",\n                            },\n                            {\n                                \"id\": 1,\n                                \"type\": \"HS\",\n                                \"diameter\": \"0.4\",\n                                \"wear\": 3,\n                                \"stat\": 1,\n                                \"max_temp\": 300,\n                                \"serial_number\": \"SN-R\",\n                            },\n                        ]\n                    }\n                }\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        assert len(mqtt_client.state.nozzle_rack) == 2\n        ids = [n[\"id\"] for n in mqtt_client.state.nozzle_rack]\n        assert ids == [0, 1]\n\n    def test_single_nozzle_h2s_populated(self, mqtt_client):\n        \"\"\"H2S provides 1 nozzle entry: ID 0 only — single nozzle printer.\"\"\"\n        payload = {\n            \"print\": {\n                \"device\": {\n                    \"nozzle\": {\n                        \"info\": [\n                            {\n                                \"id\": 0,\n                                \"type\": \"HS\",\n                                \"diameter\": \"0.4\",\n                                \"wear\": 2,\n                                \"stat\": 1,\n                                \"max_temp\": 300,\n                                \"serial_number\": \"SN-0\",\n                            },\n                        ]\n                    }\n                }\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        assert len(mqtt_client.state.nozzle_rack) == 1\n        assert mqtt_client.state.nozzle_rack[0][\"id\"] == 0\n\n    def test_empty_nozzle_info_does_not_populate_rack(self, mqtt_client):\n        \"\"\"Empty nozzle info list should not populate nozzle_rack.\"\"\"\n        payload = {\"print\": {\"device\": {\"nozzle\": {\"info\": []}}}}\n        mqtt_client._process_message(payload)\n\n        assert mqtt_client.state.nozzle_rack == []\n\n    def test_nozzle_rack_sorted_by_id(self, mqtt_client):\n        \"\"\"Nozzle rack entries should be sorted by ID regardless of input order.\"\"\"\n        payload = {\n            \"print\": {\n                \"device\": {\n                    \"nozzle\": {\n                        \"info\": [\n                            {\"id\": 17, \"type\": \"HS\", \"diameter\": \"0.6\"},\n                            {\"id\": 0, \"type\": \"HS\", \"diameter\": \"0.4\"},\n                            {\"id\": 16, \"type\": \"HS\", \"diameter\": \"0.4\"},\n                            {\"id\": 1, \"type\": \"HS\", \"diameter\": \"0.4\"},\n                        ]\n                    }\n                }\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        ids = [n[\"id\"] for n in mqtt_client.state.nozzle_rack]\n        assert ids == [0, 1, 16, 17]\n\n    def test_nozzle_rack_field_mapping(self, mqtt_client):\n        \"\"\"Verify field mapping from MQTT nozzle_info to nozzle_rack dict keys.\"\"\"\n        payload = {\n            \"print\": {\n                \"device\": {\n                    \"nozzle\": {\n                        \"info\": [\n                            {\n                                \"id\": 16,\n                                \"type\": \"HH01\",\n                                \"diameter\": \"0.6\",\n                                \"wear\": 15,\n                                \"stat\": 0,\n                                \"max_temp\": 320,\n                                \"serial_number\": \"SN-ABC123\",\n                                \"filament_colour\": \"FF8800\",\n                                \"filament_id\": \"F42\",\n                                \"tray_type\": \"ABS\",\n                            }\n                        ]\n                    }\n                }\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        slot = mqtt_client.state.nozzle_rack[0]\n        assert slot[\"id\"] == 16\n        assert slot[\"type\"] == \"HH01\"\n        assert slot[\"diameter\"] == \"0.6\"\n        assert slot[\"wear\"] == 15\n        assert slot[\"stat\"] == 0\n        assert slot[\"max_temp\"] == 320\n        assert slot[\"serial_number\"] == \"SN-ABC123\"\n        assert slot[\"filament_color\"] == \"FF8800\"\n        assert slot[\"filament_id\"] == \"F42\"\n        assert slot[\"filament_type\"] == \"ABS\"\n\n    def test_nozzle_info_updates_nozzle_state(self, mqtt_client):\n        \"\"\"Nozzle info for IDs 0,1 should also update nozzle state (type/diameter).\"\"\"\n        payload = {\n            \"print\": {\n                \"device\": {\n                    \"nozzle\": {\n                        \"info\": [\n                            {\"id\": 0, \"type\": \"HS\", \"diameter\": \"0.4\"},\n                            {\"id\": 1, \"type\": \"HH01\", \"diameter\": \"0.6\"},\n                        ]\n                    }\n                }\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        assert mqtt_client.state.nozzles[0].nozzle_type == \"HS\"\n        assert mqtt_client.state.nozzles[0].nozzle_diameter == \"0.4\"\n        assert mqtt_client.state.nozzles[1].nozzle_type == \"HH01\"\n        assert mqtt_client.state.nozzles[1].nozzle_diameter == \"0.6\"\n\n\nclass TestRequestTopicFailSafe:\n    \"\"\"Tests for graceful degradation when broker rejects request topic subscription.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def clear_request_topic_cache(self):\n        \"\"\"Clear class-level cache before each test to avoid cross-test pollution.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        BambuMQTTClient._request_topic_cache.clear()\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_request_topic_supported_by_default(self, mqtt_client):\n        \"\"\"Request topic subscription is attempted by default.\"\"\"\n        assert mqtt_client._request_topic_supported is True\n        assert mqtt_client._request_topic_confirmed is False\n\n    def test_on_subscribe_confirms_success(self, mqtt_client):\n        \"\"\"Successful SUBACK marks request topic as confirmed.\"\"\"\n        from paho.mqtt.reasoncodes import ReasonCode\n\n        mqtt_client._request_topic_sub_mid = 42\n        rc = ReasonCode(9, identifier=0)  # SUBACK packetType=9, QoS 0 = success\n        mqtt_client._on_subscribe(None, None, 42, [rc], None)\n\n        assert mqtt_client._request_topic_confirmed is True\n        assert mqtt_client._request_topic_supported is True\n        assert mqtt_client._request_topic_sub_mid is None\n        assert mqtt_client._request_topic_sub_time == 0.0\n\n    def test_on_subscribe_detects_rejection(self, mqtt_client):\n        \"\"\"SUBACK with failure code disables request topic.\"\"\"\n        from paho.mqtt.reasoncodes import ReasonCode\n\n        mqtt_client._request_topic_sub_mid = 42\n        rc = ReasonCode(9, identifier=0x80)  # SUBACK packetType=9, 0x80 = failure\n        mqtt_client._on_subscribe(None, None, 42, [rc], None)\n\n        assert mqtt_client._request_topic_supported is False\n        assert mqtt_client._request_topic_confirmed is False\n\n    def test_on_subscribe_ignores_other_mids(self, mqtt_client):\n        \"\"\"SUBACK for other subscriptions (e.g. report topic) is ignored.\"\"\"\n        from paho.mqtt.reasoncodes import ReasonCode\n\n        mqtt_client._request_topic_sub_mid = 42\n        rc = ReasonCode(9, identifier=0x80)\n        mqtt_client._on_subscribe(None, None, 99, [rc], None)\n\n        # Not affected — mid doesn't match\n        assert mqtt_client._request_topic_supported is True\n\n    def test_disconnect_after_subscription_disables_topic(self, mqtt_client):\n        \"\"\"Disconnect within 10s of subscription attempt disables request topic.\"\"\"\n        import time\n\n        mqtt_client._request_topic_sub_time = time.time()\n        mqtt_client._request_topic_confirmed = False\n        mqtt_client._last_message_time = 0.0\n\n        mqtt_client._on_disconnect(None, None)\n\n        assert mqtt_client._request_topic_supported is False\n        assert mqtt_client._request_topic_sub_time == 0.0\n\n    def test_disconnect_after_confirmation_does_not_disable(self, mqtt_client):\n        \"\"\"Disconnect after SUBACK confirmation keeps request topic enabled.\"\"\"\n        import time\n\n        mqtt_client._request_topic_sub_time = time.time()\n        mqtt_client._request_topic_confirmed = True\n        mqtt_client._last_message_time = 0.0\n\n        mqtt_client._on_disconnect(None, None)\n\n        assert mqtt_client._request_topic_supported is True\n\n    def test_late_disconnect_does_not_disable(self, mqtt_client):\n        \"\"\"Disconnect long after subscription (>10s) doesn't blame request topic.\"\"\"\n        import time\n\n        mqtt_client._request_topic_sub_time = time.time() - 30.0\n        mqtt_client._request_topic_confirmed = False\n        mqtt_client._last_message_time = 0.0\n\n        mqtt_client._on_disconnect(None, None)\n\n        assert mqtt_client._request_topic_supported is True\n\n    def test_on_connect_skips_request_topic_when_unsupported(self, mqtt_client):\n        \"\"\"After marking unsupported, reconnect skips request topic subscription.\"\"\"\n        mqtt_client._request_topic_supported = False\n\n        subscribe_calls = []\n        mock_client = type(\n            \"MockClient\",\n            (),\n            {\n                \"subscribe\": lambda self, topic: subscribe_calls.append(topic) or (0, 1),\n            },\n        )()\n\n        mqtt_client._on_connect(mock_client, None, None, 0)\n\n        # Only report topic subscribed, not request topic\n        assert len(subscribe_calls) == 1\n        assert subscribe_calls[0] == mqtt_client.topic_subscribe\n\n    def test_cache_persists_across_instances(self):\n        \"\"\"New client instance inherits request topic unsupported state from cache.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client1 = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_CACHE\",\n            access_code=\"12345678\",\n        )\n        assert client1._request_topic_supported is True\n\n        # Simulate disconnect-after-subscribe disabling the topic\n        client1._request_topic_sub_time = __import__(\"time\").time()\n        client1._request_topic_confirmed = False\n        client1._last_message_time = 0.0\n        client1._on_disconnect(None, None)\n        assert client1._request_topic_supported is False\n\n        # New instance for same serial should inherit the cached state\n        client2 = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_CACHE\",\n            access_code=\"12345678\",\n        )\n        assert client2._request_topic_supported is False\n\n    def test_cache_does_not_affect_different_serial(self):\n        \"\"\"Cache is per-serial — different printer is unaffected.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        BambuMQTTClient._request_topic_cache[\"SERIAL_A\"] = False\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"SERIAL_B\",\n            access_code=\"12345678\",\n        )\n        assert client._request_topic_supported is True\n\n    def test_cache_updated_on_suback_success(self):\n        \"\"\"Successful SUBACK caches positive confirmation.\"\"\"\n        from paho.mqtt.reasoncodes import ReasonCode\n\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_SUBACK\",\n            access_code=\"12345678\",\n        )\n        client._request_topic_sub_mid = 42\n        rc = ReasonCode(9, identifier=0)  # Success\n        client._on_subscribe(None, None, 42, [rc], None)\n\n        assert BambuMQTTClient._request_topic_cache[\"TEST_SUBACK\"] is True\n\n    def test_cache_updated_on_suback_rejection(self):\n        \"\"\"SUBACK rejection caches negative state.\"\"\"\n        from paho.mqtt.reasoncodes import ReasonCode\n\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_REJECT\",\n            access_code=\"12345678\",\n        )\n        client._request_topic_sub_mid = 42\n        rc = ReasonCode(9, identifier=0x80)  # Failure\n        client._on_subscribe(None, None, 42, [rc], None)\n\n        assert BambuMQTTClient._request_topic_cache[\"TEST_REJECT\"] is False\n\n\nclass TestRequestTopicAmsMapping:\n    \"\"\"Tests for capturing ams_mapping from the MQTT request topic.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient instance for testing.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_captured_ams_mapping_initializes_to_none(self, mqtt_client):\n        \"\"\"Verify _captured_ams_mapping starts as None.\"\"\"\n        assert mqtt_client._captured_ams_mapping is None\n\n    def test_handle_request_message_captures_ams_mapping(self, mqtt_client):\n        \"\"\"project_file command with ams_mapping stores the mapping.\"\"\"\n        data = {\n            \"print\": {\n                \"command\": \"project_file\",\n                \"ams_mapping\": [0, 4, -1, -1],\n                \"url\": \"ftp://192.168.1.100/test.3mf\",\n            }\n        }\n        mqtt_client._handle_request_message(data)\n        assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]\n\n    def test_handle_request_message_ignores_non_print_commands(self, mqtt_client):\n        \"\"\"Non-project_file commands don't store ams_mapping.\"\"\"\n        data = {\n            \"print\": {\n                \"command\": \"pause\",\n            }\n        }\n        mqtt_client._handle_request_message(data)\n        assert mqtt_client._captured_ams_mapping is None\n\n    def test_handle_request_message_ignores_missing_ams_mapping(self, mqtt_client):\n        \"\"\"project_file command without ams_mapping doesn't store anything.\"\"\"\n        data = {\n            \"print\": {\n                \"command\": \"project_file\",\n                \"url\": \"ftp://192.168.1.100/test.3mf\",\n            }\n        }\n        mqtt_client._handle_request_message(data)\n        assert mqtt_client._captured_ams_mapping is None\n\n    def test_handle_request_message_ignores_non_dict_print(self, mqtt_client):\n        \"\"\"Non-dict print value is safely ignored.\"\"\"\n        data = {\"print\": \"not_a_dict\"}\n        mqtt_client._handle_request_message(data)\n        assert mqtt_client._captured_ams_mapping is None\n\n    def test_handle_request_message_ignores_missing_print(self, mqtt_client):\n        \"\"\"Message without print key is safely ignored.\"\"\"\n        data = {\"pushing\": {\"command\": \"pushall\"}}\n        mqtt_client._handle_request_message(data)\n        assert mqtt_client._captured_ams_mapping is None\n\n    def test_captured_mapping_overwrites_previous(self, mqtt_client):\n        \"\"\"A new print command overwrites a previously captured mapping.\"\"\"\n        mqtt_client._captured_ams_mapping = [0, -1, -1, -1]\n        data = {\n            \"print\": {\n                \"command\": \"project_file\",\n                \"ams_mapping\": [4, 8, -1, -1],\n            }\n        }\n        mqtt_client._handle_request_message(data)\n        assert mqtt_client._captured_ams_mapping == [4, 8, -1, -1]\n\n    def test_print_start_callback_includes_ams_mapping(self, mqtt_client):\n        \"\"\"on_print_start callback data includes captured ams_mapping.\"\"\"\n        start_data = {}\n\n        def on_start(data):\n            start_data.update(data)\n\n        mqtt_client.on_print_start = on_start\n        mqtt_client._captured_ams_mapping = [0, 4, -1, -1]\n\n        # Trigger print start\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert start_data.get(\"ams_mapping\") == [0, 4, -1, -1]\n\n    def test_print_start_callback_ams_mapping_none_when_not_captured(self, mqtt_client):\n        \"\"\"on_print_start callback has ams_mapping=None when no mapping captured.\"\"\"\n        start_data = {}\n\n        def on_start(data):\n            start_data.update(data)\n\n        mqtt_client.on_print_start = on_start\n\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert \"ams_mapping\" in start_data\n        assert start_data[\"ams_mapping\"] is None\n\n    def test_print_complete_callback_includes_ams_mapping(self, mqtt_client):\n        \"\"\"on_print_complete callback data includes captured ams_mapping.\"\"\"\n        complete_data = {}\n\n        def on_complete(data):\n            complete_data.update(data)\n\n        mqtt_client.on_print_start = lambda d: None\n        mqtt_client.on_print_complete = on_complete\n        mqtt_client._captured_ams_mapping = [0, 9, -1, -1]\n\n        # Start print\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        # Complete print\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FINISH\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert complete_data.get(\"ams_mapping\") == [0, 9, -1, -1]\n\n    def test_captured_mapping_cleared_after_print_complete(self, mqtt_client):\n        \"\"\"_captured_ams_mapping is reset to None after print completion.\"\"\"\n        mqtt_client.on_print_start = lambda d: None\n        mqtt_client.on_print_complete = lambda d: None\n        mqtt_client._captured_ams_mapping = [0, 4, -1, -1]\n\n        # Start print\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        # Complete print\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FINISH\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert mqtt_client._captured_ams_mapping is None\n\n    def test_full_flow_capture_and_deliver(self, mqtt_client):\n        \"\"\"Full flow: slicer sends print command → MQTT captures mapping → completion delivers it.\"\"\"\n        complete_data = {}\n\n        def on_complete(data):\n            complete_data.update(data)\n\n        mqtt_client.on_print_start = lambda d: None\n        mqtt_client.on_print_complete = on_complete\n\n        # 1. Slicer sends print command (captured from request topic)\n        mqtt_client._handle_request_message(\n            {\n                \"print\": {\n                    \"command\": \"project_file\",\n                    \"ams_mapping\": [4, 9, -1, -1],\n                    \"url\": \"ftp://192.168.1.100/model.3mf\",\n                }\n            }\n        )\n        assert mqtt_client._captured_ams_mapping == [4, 9, -1, -1]\n\n        # 2. Printer reports RUNNING\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/model.gcode\",\n                    \"subtask_name\": \"Model\",\n                }\n            }\n        )\n\n        # 3. Printer reports FINISH\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FINISH\",\n                    \"gcode_file\": \"/data/Metadata/model.gcode\",\n                    \"subtask_name\": \"Model\",\n                }\n            }\n        )\n\n        assert complete_data[\"ams_mapping\"] == [4, 9, -1, -1]\n        assert complete_data[\"status\"] == \"completed\"\n        # Mapping cleared after completion\n        assert mqtt_client._captured_ams_mapping is None\n\n\n# ---------------------------------------------------------------------------\n# tray_now disambiguation helpers\n# ---------------------------------------------------------------------------\n\n\ndef _ams_payload(tray_now, ams_units=None, tray_exist_bits=None, ams_exist_bits=None):\n    \"\"\"Build minimal print.ams payload for tray_now disambiguation tests.\"\"\"\n    ams = {\"tray_now\": str(tray_now)}\n    if ams_units is not None:\n        ams[\"ams\"] = ams_units\n    if tray_exist_bits is not None:\n        ams[\"tray_exist_bits\"] = tray_exist_bits\n    if ams_exist_bits is not None:\n        ams[\"ams_exist_bits\"] = ams_exist_bits\n    return {\"print\": {\"ams\": ams}}\n\n\ndef _extruder_info_payload(extruders):\n    \"\"\"Build device.extruder.info payload (dual-nozzle detection + snow).\n\n    Each entry in *extruders* is a dict with at least ``id`` and ``snow``.\n    \"\"\"\n    return {\n        \"print\": {\n            \"device\": {\n                \"extruder\": {\n                    \"info\": extruders,\n                }\n            }\n        }\n    }\n\n\ndef _extruder_state_payload(state_val):\n    \"\"\"Build device.extruder.state payload (active extruder via bit 8).\"\"\"\n    return {\n        \"print\": {\n            \"device\": {\n                \"extruder\": {\n                    \"state\": state_val,\n                }\n            }\n        }\n    }\n\n\n# ---------------------------------------------------------------------------\n# 1. Single-nozzle X1E — direct passthrough\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowSingleNozzleX1E:\n    \"\"\"Single-nozzle, 1 AMS — tray_now is a direct passthrough.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        return BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_X1E\",\n            access_code=\"12345678\",\n        )\n\n    def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):\n        \"\"\"Each tray_now 0-3 maps 1:1 on single-nozzle printers.\"\"\"\n        for slot in range(4):\n            mqtt_client._process_message(_ams_payload(slot))\n            assert mqtt_client.state.tray_now == slot\n\n    def test_tray_now_255_means_unloaded(self, mqtt_client):\n        \"\"\"tray_now=255 means no filament loaded.\"\"\"\n        mqtt_client._process_message(_ams_payload(255))\n        assert mqtt_client.state.tray_now == 255\n\n    def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):\n        \"\"\"device.extruder.info with 1 entry must NOT set _is_dual_nozzle.\"\"\"\n        mqtt_client._process_message(_extruder_info_payload([{\"id\": 0, \"snow\": 0xFF00FF}]))\n        assert mqtt_client._is_dual_nozzle is False\n\n    def test_last_loaded_tray_survives_unload(self, mqtt_client):\n        \"\"\"Load tray 2, unload → last_loaded_tray stays 2.\"\"\"\n        mqtt_client._process_message(_ams_payload(2))\n        assert mqtt_client.state.last_loaded_tray == 2\n\n        mqtt_client._process_message(_ams_payload(255))\n        assert mqtt_client.state.tray_now == 255\n        assert mqtt_client.state.last_loaded_tray == 2\n\n\n# ---------------------------------------------------------------------------\n# 2. Single-nozzle P2S — multiple AMS, global IDs pass through\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowSingleNozzleP2S:\n    \"\"\"Single-nozzle, 2 AMS — tray_now > 3 passes through as global ID.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        return BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_P2S\",\n            access_code=\"12345678\",\n        )\n\n    def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):\n        \"\"\"tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers.\"\"\"\n        for global_id in range(4, 8):\n            mqtt_client._process_message(_ams_payload(global_id))\n            assert mqtt_client.state.tray_now == global_id\n\n    def test_tray_change_across_ams_units(self, mqtt_client):\n        \"\"\"Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6).\"\"\"\n        mqtt_client._process_message(_ams_payload(1))\n        assert mqtt_client.state.tray_now == 1\n\n        mqtt_client._process_message(_ams_payload(6))\n        assert mqtt_client.state.tray_now == 6\n\n\n# ---------------------------------------------------------------------------\n# 2b. Single-nozzle P2S — multi-AMS local slot disambiguation (#420)\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowP2SMultiAmsDisambiguation:\n    \"\"\"P2S firmware sends local slot IDs (0-3) in tray_now even with dual AMS.\n\n    When ams_exist_bits indicates >1 AMS unit and tray_now is 0-3, the backend\n    should use the MQTT mapping field (snow-encoded) to resolve the correct\n    global tray ID.\n    \"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_P2S_DUAL\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_resolves_ams1_slot1_from_mapping(self, mqtt_client):\n        \"\"\"tray_now=1 with mapping=[257] → global ID 5 (AMS1-T1).\n\n        257 snow-decoded: ams_hw_id=1, slot=1 → global 1*4+1=5.\n        \"\"\"\n        # Set mapping field in raw_data (as the MQTT handler would)\n        mqtt_client.state.raw_data[\"mapping\"] = [257]\n        mqtt_client._process_message(\n            _ams_payload(1, ams_exist_bits=\"3\")  # '3' = 0b11 → AMS 0 and 1\n        )\n        assert mqtt_client.state.tray_now == 5\n\n    def test_resolves_ams1_slot0_from_mapping(self, mqtt_client):\n        \"\"\"tray_now=0 with mapping=[256] → global ID 4 (AMS1-T0).\n\n        256 snow-decoded: ams_hw_id=1, slot=0 → global 1*4+0=4.\n        \"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = [256]\n        mqtt_client._process_message(_ams_payload(0, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 4\n\n    def test_resolves_ams1_slot3_from_mapping(self, mqtt_client):\n        \"\"\"tray_now=3 with mapping=[259] → global ID 7 (AMS1-T3).\n\n        259 snow-decoded: ams_hw_id=1, slot=3 → global 1*4+3=7.\n        \"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = [259]\n        mqtt_client._process_message(_ams_payload(3, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 7\n\n    def test_ams0_slot_unchanged_when_mapping_confirms_ams0(self, mqtt_client):\n        \"\"\"tray_now=1 with mapping=[1] → stays 1 (AMS0-T1).\n\n        1 snow-decoded: ams_hw_id=0, slot=1 → global 0*4+1=1.\n        \"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = [1]\n        mqtt_client._process_message(_ams_payload(1, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 1\n\n    def test_multicolor_resolves_ams1_from_multi_entry_mapping(self, mqtt_client):\n        \"\"\"Multi-color print: mapping=[0, 257] → tray_now=1 resolves to AMS1-T1 (5).\n\n        Entry 0: ams_hw_id=0, slot=0 (local 0) — doesn't match tray_now=1.\n        Entry 257: ams_hw_id=1, slot=1 (local 1) — matches tray_now=1 → global 5.\n        \"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = [0, 257]\n        mqtt_client._process_message(_ams_payload(1, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 5\n\n    def test_multicolor_four_slot_mapping(self, mqtt_client):\n        \"\"\"mapping=[65535, 65535, 65535, 257] → tray_now=1 resolves to global 5.\n\n        Only entry 257 has local slot=1, other entries are unmapped (65535).\n        Reproduces exact data from issue #420 support package.\n        \"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = [65535, 65535, 65535, 257]\n        mqtt_client._process_message(_ams_payload(1, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 5\n\n    def test_ambiguous_mapping_falls_back_to_local_slot(self, mqtt_client):\n        \"\"\"Two AMS units with same local slot in mapping → ambiguous, keep local slot.\n\n        mapping=[1, 257]: both have local slot 1 (AMS0-T1 and AMS1-T1).\n        Cannot disambiguate → fall back to tray_now=1.\n        \"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = [1, 257]\n        mqtt_client._process_message(_ams_payload(1, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 1\n\n    def test_no_mapping_falls_back_to_local_slot(self, mqtt_client):\n        \"\"\"No mapping field available → fall back to raw tray_now.\"\"\"\n        # No mapping in raw_data (e.g. manual filament load, not during print)\n        mqtt_client._process_message(_ams_payload(1, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 1\n\n    def test_empty_mapping_falls_back_to_local_slot(self, mqtt_client):\n        \"\"\"Empty mapping list → fall back to raw tray_now.\"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = []\n        mqtt_client._process_message(_ams_payload(1, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 1\n\n    def test_single_ams_passthrough(self, mqtt_client):\n        \"\"\"Single AMS (ams_exist_bits='1') → tray_now 0-3 is direct global ID.\"\"\"\n        mqtt_client._process_message(_ams_payload(2, ams_exist_bits=\"1\"))\n        assert mqtt_client.state.tray_now == 2\n\n    def test_no_ams_exist_bits_passthrough(self, mqtt_client):\n        \"\"\"No ams_exist_bits in payload → fall back to raw tray_now.\"\"\"\n        mqtt_client._process_message(_ams_payload(1))\n        assert mqtt_client.state.tray_now == 1\n\n    def test_tray_now_255_unaffected_by_multi_ams(self, mqtt_client):\n        \"\"\"tray_now=255 (unloaded) passes through regardless of AMS count.\"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = [257]\n        mqtt_client._process_message(_ams_payload(255, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 255\n\n    def test_tray_now_above_3_unaffected(self, mqtt_client):\n        \"\"\"tray_now > 3 is already a global ID and passes through directly.\"\"\"\n        mqtt_client._process_message(_ams_payload(6, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 6\n\n    def test_last_loaded_tray_uses_resolved_global_id(self, mqtt_client):\n        \"\"\"last_loaded_tray should reflect the resolved global ID, not local slot.\"\"\"\n        mqtt_client.state.raw_data[\"mapping\"] = [257]\n        mqtt_client.state.state = \"RUNNING\"\n        mqtt_client._process_message(_ams_payload(1, ams_exist_bits=\"3\"))\n        assert mqtt_client.state.tray_now == 5\n        assert mqtt_client.state.last_loaded_tray == 5\n\n\nclass TestResolveLocalSlotFromMapping:\n    \"\"\"Unit tests for _resolve_local_slot_from_mapping static method.\"\"\"\n\n    def test_single_match_ams0(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1]) == 1\n\n    def test_single_match_ams1(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        # 257 = 1*256 + 1 → AMS1 slot1 → global 5\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [257]) == 5\n\n    def test_single_match_ams2(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        # 514 = 2*256 + 2 → AMS2 slot2 → global 10\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [514]) == 10\n\n    def test_unmapped_entries_skipped(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [65535, 65535, 65535, 257]) == 5\n\n    def test_no_match_returns_none(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        # mapping has slot 0 only, looking for slot 2\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [0]) is None\n\n    def test_ambiguous_returns_none(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        # Both AMS0 slot1 (1) and AMS1 slot1 (257) → ambiguous\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1, 257]) is None\n\n    def test_none_mapping_returns_none(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, None) is None\n\n    def test_empty_mapping_returns_none(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(1, []) is None\n\n    def test_ams_ht_slot0_match(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        # AMS-HT id=128: snow = 128*256 + 0 = 32768\n        assert BambuMQTTClient._resolve_local_slot_from_mapping(0, [32768]) == 128\n\n\n# ---------------------------------------------------------------------------\n# 3. H2D Pro — initial state detection\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowDualNozzleH2DSetup:\n    \"\"\"H2D Pro initial state detection.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        return BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_H2D\",\n            access_code=\"12345678\",\n        )\n\n    def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):\n        \"\"\"2 entries in device.extruder.info → _is_dual_nozzle=True.\"\"\"\n        mqtt_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0xFF00FF},\n                    {\"id\": 1, \"snow\": 0xFF00FF},\n                ]\n            )\n        )\n        assert mqtt_client._is_dual_nozzle is True\n\n    def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):\n        \"\"\"AMS info field is hex: 0x2003 → ext 0 (right), 0x2104 → ext 1 (left).\"\"\"\n        # MQTT sends info as string; BambuStudio parses as hex via stoull(str, 16)\n        ams_units = [\n            {\"id\": 0, \"info\": \"2003\", \"tray\": [{\"id\": i} for i in range(4)]},\n            {\"id\": 128, \"info\": \"2104\", \"tray\": [{\"id\": 0}]},\n        ]\n        payload = {\n            \"print\": {\n                \"ams\": {\n                    \"ams\": ams_units,\n                    \"tray_now\": \"255\",\n                    \"tray_exist_bits\": \"1000f\",\n                },\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        # 0x2003: bits 8-11 = (0x2003 >> 8) & 0xF = 0x20 & 0xF = 0 → extruder 0 (right)\n        # 0x2104: bits 8-11 = (0x2104 >> 8) & 0xF = 0x21 & 0xF = 1 → extruder 1 (left)\n        assert mqtt_client.state.ams_extruder_map == {\"0\": 0, \"128\": 1}\n\n    def test_ams_extruder_map_real_h2d_values(self, mqtt_client):\n        \"\"\"Real H2D MQTT values: AMS2 Pro on right, AMS-HT on left.\"\"\"\n        ams_units = [\n            {\"id\": 0, \"info\": \"10001003\", \"tray\": [{\"id\": i} for i in range(4)]},\n            {\"id\": 128, \"info\": \"10002104\", \"tray\": [{\"id\": 0}]},\n        ]\n        payload = {\n            \"print\": {\n                \"ams\": {\n                    \"ams\": ams_units,\n                    \"tray_now\": \"255\",\n                    \"tray_exist_bits\": \"1000a\",\n                },\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        # 0x10001003: bits 8-11 = (0x10001003 >> 8) & 0xF = 0x10 & 0xF = 0 → right\n        # 0x10002104: bits 8-11 = (0x10002104 >> 8) & 0xF = 0x21 & 0xF = 1 → left\n        assert mqtt_client.state.ams_extruder_map == {\"0\": 0, \"128\": 1}\n\n    def test_ams_extruder_map_skips_uninitialized(self, mqtt_client):\n        \"\"\"extruder_id 0xE means uninitialized AMS — should be skipped.\"\"\"\n        ams_units = [\n            {\"id\": 0, \"info\": \"e03\", \"tray\": [{\"id\": i} for i in range(4)]},\n        ]\n        payload = {\n            \"print\": {\n                \"ams\": {\n                    \"ams\": ams_units,\n                    \"tray_now\": \"255\",\n                    \"tray_exist_bits\": \"f\",\n                },\n            }\n        }\n        mqtt_client._process_message(payload)\n        assert mqtt_client.state.ams_extruder_map == {}\n\n    def test_ams_extruder_map_partial_update_preserves_entries(self, mqtt_client):\n        \"\"\"Partial MQTT update with one AMS should not overwrite other entries.\"\"\"\n        # First: full update with both AMS units\n        full_payload = {\n            \"print\": {\n                \"ams\": {\n                    \"ams\": [\n                        {\"id\": 0, \"info\": \"2003\", \"tray\": [{\"id\": i} for i in range(4)]},\n                        {\"id\": 128, \"info\": \"2104\", \"tray\": [{\"id\": 0}]},\n                    ],\n                    \"tray_now\": \"255\",\n                    \"tray_exist_bits\": \"1000f\",\n                },\n            }\n        }\n        mqtt_client._process_message(full_payload)\n        assert mqtt_client.state.ams_extruder_map == {\"0\": 0, \"128\": 1}\n\n        # Then: partial update with only AMS 0 (no info field this time)\n        partial_payload = {\n            \"print\": {\n                \"ams\": {\n                    \"ams\": [\n                        {\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 50}]},\n                    ],\n                    \"tray_now\": \"0\",\n                    \"tray_exist_bits\": \"1000f\",\n                },\n            }\n        }\n        mqtt_client._process_message(partial_payload)\n        # Both entries should still be present\n        assert mqtt_client.state.ams_extruder_map == {\"0\": 0, \"128\": 1}\n\n    def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):\n        \"\"\"Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.\n\n        If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.\n        \"\"\"\n        payload = {\n            \"print\": {\n                \"device\": {\n                    \"extruder\": {\n                        \"info\": [\n                            {\"id\": 0, \"snow\": 0xFF00FF},\n                            {\"id\": 1, \"snow\": 0xFF00FF},\n                        ],\n                        \"state\": 0x0001,\n                    }\n                },\n                \"ams\": {\n                    \"ams\": [\n                        {\"id\": 0, \"info\": \"2003\", \"tray\": [{\"id\": i} for i in range(4)]},\n                    ],\n                    \"tray_now\": \"2\",\n                    \"tray_exist_bits\": \"f\",\n                },\n            }\n        }\n        mqtt_client._process_message(payload)\n\n        # Dual-nozzle was detected; AMS 0 on right extruder (active by default);\n        # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.\n        # Single AMS on extruder 0 → global_id = 0*4+2 = 2\n        assert mqtt_client._is_dual_nozzle is True\n        assert mqtt_client.state.tray_now == 2\n\n\n# ---------------------------------------------------------------------------\n# Shared H2D fixture for classes 4-8\n# ---------------------------------------------------------------------------\n\n\nclass _H2DFixtureMixin:\n    \"\"\"Mixin providing a pre-configured H2D Pro client.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        return BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_H2D\",\n            access_code=\"12345678\",\n        )\n\n    @pytest.fixture\n    def h2d_client(self, mqtt_client):\n        \"\"\"Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map.\"\"\"\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"device\": {\n                        \"extruder\": {\n                            \"info\": [\n                                {\"id\": 0, \"snow\": 0xFF00FF},\n                                {\"id\": 1, \"snow\": 0xFF00FF},\n                            ],\n                            \"state\": 0x0001,  # right extruder active\n                        }\n                    },\n                    \"ams\": {\n                        \"ams\": [\n                            {\"id\": 0, \"info\": \"2003\", \"tray\": [{\"id\": i} for i in range(4)]},\n                            {\"id\": 128, \"info\": \"2104\", \"tray\": [{\"id\": 0}]},\n                        ],\n                        \"tray_now\": \"255\",\n                        \"tray_exist_bits\": \"1000f\",\n                    },\n                }\n            }\n        )\n        assert mqtt_client._is_dual_nozzle is True\n        assert mqtt_client.state.ams_extruder_map == {\"0\": 0, \"128\": 1}\n        return mqtt_client\n\n\n# ---------------------------------------------------------------------------\n# 4. H2D Snow field disambiguation\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):\n    \"\"\"Snow field disambiguation (primary path).\"\"\"\n\n    def test_snow_disambiguates_ams0_slot(self, h2d_client):\n        \"\"\"snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2.\"\"\"\n        # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,\n        # so we need it in a prior message).\n        snow_val = 0 << 8 | 2  # AMS 0 slot 2 = raw 2\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": snow_val},\n                    {\"id\": 1, \"snow\": 0xFF00FF},\n                ]\n            )\n        )\n        assert h2d_client.state.h2d_extruder_snow.get(0) == 2\n\n        # Now send tray_now=2\n        h2d_client._process_message(_ams_payload(2))\n        assert h2d_client.state.tray_now == 2\n\n    def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):\n        \"\"\"snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128.\"\"\"\n        # Snow: extruder 1 → AMS 128 slot 0\n        snow_val = 128 << 8 | 0  # = 32768\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0xFF00FF},\n                    {\"id\": 1, \"snow\": snow_val},\n                ]\n            )\n        )\n        assert h2d_client.state.h2d_extruder_snow.get(1) == 128\n\n        # Switch to left extruder\n        h2d_client._process_message(_extruder_state_payload(0x0100))\n        assert h2d_client.state.active_extruder == 1\n\n        # tray_now=\"0\" with left extruder active, snow says AMS HT (128)\n        # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match\n        h2d_client._process_message(_ams_payload(0))\n        assert h2d_client.state.tray_now == 128\n\n    def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):\n        \"\"\"Verify state.h2d_extruder_snow dict is populated correctly.\"\"\"\n        snow_ext0 = 1 << 8 | 3  # AMS 1 slot 3 → global 7\n        snow_ext1 = 0 << 8 | 0  # AMS 0 slot 0 → global 0\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": snow_ext0},\n                    {\"id\": 1, \"snow\": snow_ext1},\n                ]\n            )\n        )\n        assert h2d_client.state.h2d_extruder_snow[0] == 7\n        assert h2d_client.state.h2d_extruder_snow[1] == 0\n\n    def test_snow_unloaded_value(self, h2d_client):\n        \"\"\"snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded).\"\"\"\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0xFFFF},\n                    {\"id\": 1, \"snow\": 0xFFFF},\n                ]\n            )\n        )\n        assert h2d_client.state.h2d_extruder_snow[0] == 255\n        assert h2d_client.state.h2d_extruder_snow[1] == 255\n\n    def test_snow_initial_sentinel_not_stored(self, h2d_client):\n        \"\"\"snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow.\"\"\"\n        # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0xFF00FF},\n                    {\"id\": 1, \"snow\": 0xFF00FF},\n                ]\n            )\n        )\n        # Snow dict should remain empty (no matching branch)\n        assert h2d_client.state.h2d_extruder_snow == {}\n\n\n# ---------------------------------------------------------------------------\n# 5. H2D Pending target disambiguation\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):\n    \"\"\"Pending target disambiguation (when Bambuddy initiates load).\"\"\"\n\n    def test_pending_target_matches_slot(self, h2d_client):\n        \"\"\"pending=5, tray_now='1' (5%4=1 matches) → tray_now=5.\"\"\"\n        h2d_client.state.pending_tray_target = 5\n        h2d_client._process_message(_ams_payload(1))\n        assert h2d_client.state.tray_now == 5\n        assert h2d_client.state.pending_tray_target is None  # cleared\n\n    def test_pending_target_slot_mismatch(self, h2d_client):\n        \"\"\"pending=5, tray_now='2' → uses raw slot, clears pending.\"\"\"\n        h2d_client.state.pending_tray_target = 5\n        h2d_client._process_message(_ams_payload(2))\n        # Slot 2 != 5%4=1 → mismatch, uses raw slot 2\n        assert h2d_client.state.tray_now == 2\n        assert h2d_client.state.pending_tray_target is None\n\n    def test_pending_target_takes_priority_over_snow(self, h2d_client):\n        \"\"\"When both pending and snow are set, pending wins.\"\"\"\n        # Set up snow for extruder 0 → AMS 0 slot 1 → global 1\n        snow_val = 0 << 8 | 1\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": snow_val},\n                    {\"id\": 1, \"snow\": 0xFF00FF},\n                ]\n            )\n        )\n        assert h2d_client.state.h2d_extruder_snow.get(0) == 1\n\n        # Set pending target to AMS 1 slot 1 (global 5)\n        h2d_client.state.pending_tray_target = 5\n        # tray_now=\"1\" — matches pending (5%4=1), pending should win over snow\n        h2d_client._process_message(_ams_payload(1))\n        assert h2d_client.state.tray_now == 5\n\n\n# ---------------------------------------------------------------------------\n# 6. H2D ams_extruder_map fallback\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):\n    \"\"\"ams_extruder_map fallback (no pending, no snow).\"\"\"\n\n    def test_single_ams_on_extruder_computes_global_id(self, h2d_client):\n        \"\"\"AMS 0 on right extruder, tray_now='2' → 0*4+2=2.\"\"\"\n        # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips\n        h2d_client._process_message(_ams_payload(2))\n        # AMS 0 is the only AMS on extruder 0 (right, active by default)\n        # Fallback: single AMS → global = 0*4+2 = 2\n        assert h2d_client.state.tray_now == 2\n\n    def test_multiple_ams_keeps_current_if_valid(self, h2d_client):\n        \"\"\"Current tray matches slot → keeps it (multi-AMS on same extruder).\"\"\"\n        # Set up: two AMS units on the same extruder (right, ext 0)\n        h2d_client.state.ams_extruder_map = {\"0\": 0, \"1\": 0}\n        # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder\n        h2d_client.state.tray_now = 5\n        # tray_now=\"1\" → 5%4=1 matches → keep current=5\n        h2d_client._process_message(_ams_payload(1))\n        assert h2d_client.state.tray_now == 5\n\n    def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):\n        \"\"\"No AMS mapped to the active extruder → raw slot as global ID.\"\"\"\n        # All AMS on left extruder, but right is active\n        h2d_client.state.ams_extruder_map = {\"0\": 1, \"128\": 1}\n        h2d_client._process_message(_ams_payload(2))\n        assert h2d_client.state.tray_now == 2\n\n    def test_single_ams_ht_on_extruder_returns_unit_id(self, h2d_client):\n        \"\"\"AMS-HT 128 alone on left extruder, slot 0 → global ID 128 (not 512).\"\"\"\n        # Switch to left extruder (where AMS-HT 128 is mapped)\n        h2d_client._process_message(_extruder_state_payload(0x0100))\n        # Only AMS-HT 128 on left extruder; no snow available\n        h2d_client._process_message(_ams_payload(0))\n        assert h2d_client.state.tray_now == 128\n\n    def test_single_ams_ht_ignores_nonzero_slot(self, h2d_client):\n        \"\"\"AMS-HT has single slot; even if printer reports slot 1, global ID = unit ID.\"\"\"\n        h2d_client.state.ams_extruder_map = {\"129\": 0}\n        h2d_client._process_message(_ams_payload(1))\n        # AMS-HT 129: global ID = 129, not 129*4+1=517\n        assert h2d_client.state.tray_now == 129\n\n    def test_multiple_ams_keeps_current_ams_ht(self, h2d_client):\n        \"\"\"Current tray is AMS-HT 128, slot 0 reported → keeps 128.\"\"\"\n        h2d_client.state.ams_extruder_map = {\"0\": 0, \"128\": 0}\n        h2d_client.state.tray_now = 128\n        h2d_client._process_message(_ams_payload(0))\n        assert h2d_client.state.tray_now == 128\n\n    def test_multiple_ams_slot_nonzero_excludes_ams_ht(self, h2d_client):\n        \"\"\"Slot > 0 eliminates AMS-HT candidates; single regular AMS left → resolves.\"\"\"\n        # AMS 0 + AMS-HT 128 both on right extruder\n        h2d_client.state.ams_extruder_map = {\"0\": 0, \"128\": 0}\n        h2d_client.state.tray_now = 255  # no current match\n        # Slot 2 → can't be AMS-HT → only AMS 0 → global = 0*4+2 = 2\n        h2d_client._process_message(_ams_payload(2))\n        assert h2d_client.state.tray_now == 2\n\n    def test_multiple_ams_slot_nonzero_narrows_to_single_ht_excluded(self, h2d_client):\n        \"\"\"Two regular AMS + one AMS-HT, slot > 0 → AMS-HT excluded but still ambiguous.\"\"\"\n        h2d_client.state.ams_extruder_map = {\"0\": 0, \"1\": 0, \"128\": 0}\n        h2d_client.state.tray_now = 255\n        # Slot 3 → excludes AMS-HT, but AMS 0 and AMS 1 both remain → ambiguous\n        h2d_client._process_message(_ams_payload(3))\n        assert h2d_client.state.tray_now == 3  # raw slot fallback\n\n\n# ---------------------------------------------------------------------------\n# 6b. H2D last_loaded_tray validation\n# ---------------------------------------------------------------------------\n\n\nclass TestLastLoadedTrayValidation(_H2DFixtureMixin):\n    \"\"\"last_loaded_tray only stores physically valid tray IDs.\"\"\"\n\n    def test_regular_ams_tray_stored(self, h2d_client):\n        \"\"\"Valid regular AMS tray (0-15) → stored in last_loaded_tray.\"\"\"\n        h2d_client.state.tray_now = 7\n        # Trigger tray_now processing via AMS message\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 1 << 8 | 3},  # AMS 1 slot 3 → global 7\n                    {\"id\": 1, \"snow\": 0xFF00FF},\n                ]\n            )\n        )\n        h2d_client._process_message(_ams_payload(3))\n        assert h2d_client.state.tray_now == 7\n        assert h2d_client.state.last_loaded_tray == 7\n\n    def test_ams_ht_tray_stored(self, h2d_client):\n        \"\"\"Valid AMS-HT tray (128-135) → stored in last_loaded_tray.\"\"\"\n        h2d_client._process_message(_extruder_state_payload(0x0100))\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0xFF00FF},\n                    {\"id\": 1, \"snow\": 128 << 8 | 0},\n                ]\n            )\n        )\n        h2d_client._process_message(_ams_payload(0))\n        assert h2d_client.state.tray_now == 128\n        assert h2d_client.state.last_loaded_tray == 128\n\n    def test_unloaded_not_stored(self, h2d_client):\n        \"\"\"tray_now=255 (unloaded) → last_loaded_tray unchanged.\"\"\"\n        h2d_client.state.last_loaded_tray = 5\n        h2d_client._process_message(_ams_payload(255))\n        assert h2d_client.state.tray_now == 255\n        assert h2d_client.state.last_loaded_tray == 5\n\n\n# ---------------------------------------------------------------------------\n# 7. H2D Active extruder switching\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):\n    \"\"\"Active extruder switching via device.extruder.state bit 8.\"\"\"\n\n    def test_active_extruder_right_by_default(self, h2d_client):\n        \"\"\"Initial state.active_extruder == 0 (right).\"\"\"\n        assert h2d_client.state.active_extruder == 0\n\n    def test_extruder_state_bit8_switches_to_left(self, h2d_client):\n        \"\"\"state=0x100 → active_extruder=1 (left).\"\"\"\n        h2d_client._process_message(_extruder_state_payload(0x0100))\n        assert h2d_client.state.active_extruder == 1\n\n    def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):\n        \"\"\"Cycle 0 → 1 → 0.\"\"\"\n        h2d_client._process_message(_extruder_state_payload(0x0100))\n        assert h2d_client.state.active_extruder == 1\n\n        h2d_client._process_message(_extruder_state_payload(0x0001))\n        assert h2d_client.state.active_extruder == 0\n\n    def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):\n        \"\"\"Snow on both extruders; switching active changes which snow is used.\"\"\"\n        # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0 << 8 | 1},  # AMS 0 slot 1 → global 1\n                    {\"id\": 1, \"snow\": 128 << 8 | 0},  # AMS HT → global 128\n                ]\n            )\n        )\n\n        # Right active (default) — tray_now=\"1\" → snow ext[0] says global 1\n        h2d_client._process_message(_ams_payload(1))\n        assert h2d_client.state.tray_now == 1\n\n        # Switch to left\n        h2d_client._process_message(_extruder_state_payload(0x0100))\n\n        # Left active — tray_now=\"0\" → snow ext[1] says AMS HT (128), slot 0 matches\n        h2d_client._process_message(_ams_payload(0))\n        assert h2d_client.state.tray_now == 128\n\n\n# ---------------------------------------------------------------------------\n# 8. H2D Full multi-message sequences\n# ---------------------------------------------------------------------------\n\n\nclass TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):\n    \"\"\"Multi-message sequences simulating real H2D Pro prints.\"\"\"\n\n    def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):\n        \"\"\"Setup → load AMS 0 slot 1 → verify tray_now=1.\"\"\"\n        # Snow update: extruder 0 loading AMS 0 slot 1\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0 << 8 | 1},\n                    {\"id\": 1, \"snow\": 0xFF00FF},\n                ]\n            )\n        )\n        # Printer reports tray_now=\"1\"\n        h2d_client._process_message(_ams_payload(1))\n        assert h2d_client.state.tray_now == 1\n        assert h2d_client.state.last_loaded_tray == 1\n\n    def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):\n        \"\"\"Setup → switch left → load AMS HT → verify tray_now=128.\"\"\"\n        # Switch to left extruder\n        h2d_client._process_message(_extruder_state_payload(0x0100))\n\n        # Snow: ext 1 → AMS HT slot 0\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0xFF00FF},\n                    {\"id\": 1, \"snow\": 128 << 8 | 0},\n                ]\n            )\n        )\n\n        # Printer reports tray_now=\"0\" (AMS HT single slot)\n        h2d_client._process_message(_ams_payload(0))\n        assert h2d_client.state.tray_now == 128\n        assert h2d_client.state.last_loaded_tray == 128\n\n    def test_h2d_multi_color_alternating_nozzles(self, h2d_client):\n        \"\"\"Multi-color print alternating between right and left nozzles.\n\n        Sequence:\n        1. Right loads AMS 0 slot 0 (tray=0)\n        2. Switch left, load AMS HT (tray=128)\n        3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)\n        4. Unload (255)\n        \"\"\"\n        # Step 1: Right extruder loads AMS 0 slot 0\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0 << 8 | 0},\n                    {\"id\": 1, \"snow\": 0xFF00FF},\n                ]\n            )\n        )\n        h2d_client._process_message(_ams_payload(0))\n        assert h2d_client.state.tray_now == 0\n\n        # Step 2: Switch to left, load AMS HT\n        h2d_client._process_message(_extruder_state_payload(0x0100))\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0 << 8 | 0},\n                    {\"id\": 1, \"snow\": 128 << 8 | 0},\n                ]\n            )\n        )\n        h2d_client._process_message(_ams_payload(0))\n        assert h2d_client.state.tray_now == 128\n\n        # Step 3: Switch back to right, load AMS 0 slot 2\n        h2d_client._process_message(_extruder_state_payload(0x0001))\n        h2d_client._process_message(\n            _extruder_info_payload(\n                [\n                    {\"id\": 0, \"snow\": 0 << 8 | 2},\n                    {\"id\": 1, \"snow\": 128 << 8 | 0},\n                ]\n            )\n        )\n        h2d_client._process_message(_ams_payload(2))\n        assert h2d_client.state.tray_now == 2\n\n        # Step 4: Unload\n        h2d_client._process_message(_ams_payload(255))\n        assert h2d_client.state.tray_now == 255\n        assert h2d_client.state.last_loaded_tray == 2\n\n\nclass TestTrayChangeLog:\n    \"\"\"Tests for tray_change_log tracking during prints (mid-print tray switch).\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient instance for testing.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TRAYLOG1\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_tray_change_log_defaults_empty(self, mqtt_client):\n        \"\"\"tray_change_log starts as an empty list.\"\"\"\n        assert mqtt_client.state.tray_change_log == []\n\n    def test_tray_change_log_seeded_on_print_start(self, mqtt_client):\n        \"\"\"Print start clears log and seeds with initial tray at layer 0.\"\"\"\n        mqtt_client.state.tray_now = 2\n        mqtt_client.state.last_loaded_tray = 2\n        mqtt_client._previous_gcode_state = \"IDLE\"\n\n        # Transition to RUNNING via _process_message\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"test.3mf\",\n                }\n            }\n        )\n\n        assert mqtt_client.state.tray_change_log == [(2, 0)]\n\n    def test_tray_change_log_cleared_on_new_print(self, mqtt_client):\n        \"\"\"Old log entries are cleared when a new print starts.\"\"\"\n        mqtt_client.state.tray_change_log = [(5, 0), (3, 100)]\n        mqtt_client.state.tray_now = 1\n        mqtt_client.state.last_loaded_tray = 1\n        mqtt_client._previous_gcode_state = \"IDLE\"\n\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"new.3mf\",\n                }\n            }\n        )\n\n        assert mqtt_client.state.tray_change_log == [(1, 0)]\n\n    def test_tray_change_recorded_during_running(self, mqtt_client):\n        \"\"\"Tray change while RUNNING is appended to the log.\"\"\"\n        mqtt_client.state.state = \"RUNNING\"\n        mqtt_client.state.layer_num = 50\n        mqtt_client.state.last_loaded_tray = 0\n        mqtt_client.state.tray_change_log = [(0, 0)]\n\n        # Simulate tray_now update via AMS data\n        mqtt_client.state.tray_now = 1\n        # Trigger the tracking code path\n        tn = mqtt_client.state.tray_now\n        if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in (\"RUNNING\", \"PAUSE\"):\n            mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))\n        mqtt_client.state.last_loaded_tray = tn\n\n        assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50)]\n\n    def test_tray_change_not_recorded_when_idle(self, mqtt_client):\n        \"\"\"Tray changes while IDLE are NOT logged.\"\"\"\n        mqtt_client.state.state = \"IDLE\"\n        mqtt_client.state.layer_num = 0\n        mqtt_client.state.last_loaded_tray = 0\n        mqtt_client.state.tray_change_log = []\n\n        mqtt_client.state.tray_now = 3\n        tn = mqtt_client.state.tray_now\n        if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in (\"RUNNING\", \"PAUSE\"):\n            mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))\n        mqtt_client.state.last_loaded_tray = tn\n\n        assert mqtt_client.state.tray_change_log == []\n\n    def test_tray_change_recorded_during_pause(self, mqtt_client):\n        \"\"\"Tray change while PAUSE is also logged (AMS can swap during pause).\"\"\"\n        mqtt_client.state.state = \"PAUSE\"\n        mqtt_client.state.layer_num = 75\n        mqtt_client.state.last_loaded_tray = 2\n        mqtt_client.state.tray_change_log = [(2, 0)]\n\n        mqtt_client.state.tray_now = 5\n        tn = mqtt_client.state.tray_now\n        if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in (\"RUNNING\", \"PAUSE\"):\n            mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))\n        mqtt_client.state.last_loaded_tray = tn\n\n        assert mqtt_client.state.tray_change_log == [(2, 0), (5, 75)]\n\n    def test_same_tray_not_logged_twice(self, mqtt_client):\n        \"\"\"Same tray value doesn't create duplicate log entries.\"\"\"\n        mqtt_client.state.state = \"RUNNING\"\n        mqtt_client.state.layer_num = 30\n        mqtt_client.state.last_loaded_tray = 2\n        mqtt_client.state.tray_change_log = [(2, 0)]\n\n        # Same tray again\n        mqtt_client.state.tray_now = 2\n        tn = mqtt_client.state.tray_now\n        if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in (\"RUNNING\", \"PAUSE\"):\n            mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))\n        mqtt_client.state.last_loaded_tray = tn\n\n        assert mqtt_client.state.tray_change_log == [(2, 0)]\n\n    def test_multiple_tray_changes(self, mqtt_client):\n        \"\"\"Multiple tray changes create a full history.\"\"\"\n        mqtt_client.state.state = \"RUNNING\"\n        mqtt_client.state.last_loaded_tray = 0\n        mqtt_client.state.tray_change_log = [(0, 0)]\n\n        changes = [(1, 50), (3, 120), (0, 200)]\n        for tray, layer in changes:\n            mqtt_client.state.tray_now = tray\n            mqtt_client.state.layer_num = layer\n            tn = mqtt_client.state.tray_now\n            if tn != mqtt_client.state.last_loaded_tray and mqtt_client.state.state in (\"RUNNING\", \"PAUSE\"):\n                mqtt_client.state.tray_change_log.append((tn, mqtt_client.state.layer_num))\n            mqtt_client.state.last_loaded_tray = tn\n\n        assert mqtt_client.state.tray_change_log == [(0, 0), (1, 50), (3, 120), (0, 200)]\n\n\nclass TestDeveloperModeDetection:\n    \"\"\"Tests for developer LAN mode detection from MQTT 'fun' field.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient instance for testing.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_developer_mode_initially_none(self, mqtt_client):\n        \"\"\"Verify developer_mode starts as None (unknown).\"\"\"\n        assert mqtt_client.state.developer_mode is None\n\n    def test_developer_mode_on_when_bit_clear(self, mqtt_client):\n        \"\"\"Verify developer_mode is True when bit 0x20000000 is clear.\"\"\"\n        # Bit 29 clear in lower 32 bits = developer mode ON\n        payload = {\n            \"print\": {\n                \"gcode_state\": \"IDLE\",\n                \"fun\": \"1C8187FF9CFF\",\n            }\n        }\n        mqtt_client._process_message(payload)\n        assert mqtt_client.state.developer_mode is True\n\n    def test_developer_mode_off_when_bit_set(self, mqtt_client):\n        \"\"\"Verify developer_mode is False when bit 0x20000000 is set.\"\"\"\n        # Bit 29 set in lower 32 bits = developer mode OFF (encryption required)\n        payload = {\n            \"print\": {\n                \"gcode_state\": \"IDLE\",\n                \"fun\": \"1C81A7FF9CFF\",\n            }\n        }\n        mqtt_client._process_message(payload)\n        assert mqtt_client.state.developer_mode is False\n\n    def test_developer_mode_exact_bit_check(self, mqtt_client):\n        \"\"\"Verify only bit 0x20000000 matters, not other bits.\"\"\"\n        # 0x20000000 in hex = bit 29. Set ONLY that bit.\n        payload = {\n            \"print\": {\n                \"gcode_state\": \"IDLE\",\n                \"fun\": \"000020000000\",\n            }\n        }\n        mqtt_client._process_message(payload)\n        assert mqtt_client.state.developer_mode is False\n\n        # All zeros = all bits clear = developer mode ON\n        payload[\"print\"][\"fun\"] = \"000000000000\"\n        mqtt_client._process_message(payload)\n        assert mqtt_client.state.developer_mode is True\n\n    def test_developer_mode_invalid_fun_ignored(self, mqtt_client):\n        \"\"\"Verify invalid fun values don't crash or change state.\"\"\"\n        mqtt_client.state.developer_mode = True\n\n        payload = {\n            \"print\": {\n                \"gcode_state\": \"IDLE\",\n                \"fun\": \"not_a_hex_value\",\n            }\n        }\n        mqtt_client._process_message(payload)\n        # Should remain unchanged\n        assert mqtt_client.state.developer_mode is True\n\n    def test_developer_mode_missing_fun_preserves_state(self, mqtt_client):\n        \"\"\"Verify messages without fun field don't reset developer_mode.\"\"\"\n        mqtt_client.state.developer_mode = False\n\n        payload = {\n            \"print\": {\n                \"gcode_state\": \"RUNNING\",\n                \"mc_percent\": 50,\n            }\n        }\n        mqtt_client._process_message(payload)\n        assert mqtt_client.state.developer_mode is False\n\n    def test_developer_mode_persists_across_messages(self, mqtt_client):\n        \"\"\"Verify developer_mode set by fun persists across messages without fun.\"\"\"\n        # First message sets developer_mode\n        mqtt_client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"IDLE\",\n                    \"fun\": \"3EC1AFFF9CFF\",\n                }\n            }\n        )\n        assert mqtt_client.state.developer_mode is False\n\n        # Subsequent messages without fun don't change it\n        for _ in range(3):\n            mqtt_client._process_message(\n                {\n                    \"print\": {\n                        \"gcode_state\": \"RUNNING\",\n                        \"mc_percent\": 50,\n                    }\n                }\n            )\n        assert mqtt_client.state.developer_mode is False\n\n\nclass TestDeveloperModeProbeTimeout:\n    \"\"\"Tests for developer mode probe timeout, retry, and forced reconnect (#887).\n\n    When a printer's MQTT session is half-broken (sends status but ignores\n    commands), the developer mode probe gets no response.  The timeout logic\n    retries once, then force-closes the socket on the second failure.\n    \"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        import time\n        from unittest.mock import MagicMock\n\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        # Simulate connected state with a mock MQTT client\n        client.state.connected = True\n        mock_paho = MagicMock()\n        mock_paho.socket.return_value = MagicMock()\n        client._client = mock_paho\n        # Set connect time in the past so the 5s probe delay is satisfied\n        client._connect_time = time.monotonic() - 10.0\n        return client\n\n    def _make_pushall_data(self):\n        \"\"\"Create a print data dict with >30 keys (triggers probe) and no 'fun' field.\"\"\"\n        return {f\"key_{i}\": i for i in range(35)}\n\n    def test_first_timeout_allows_retry(self, mqtt_client):\n        \"\"\"After first probe timeout, _dev_mode_probed resets to allow retry.\"\"\"\n        import time\n\n        data = self._make_pushall_data()\n\n        # First pushall triggers the probe\n        mqtt_client._update_state(data)\n        assert mqtt_client._dev_mode_probed is True\n        assert mqtt_client._dev_mode_probe_seq is not None\n        assert mqtt_client.state.developer_mode is None\n\n        # Simulate 11 seconds passing\n        mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0\n\n        # Next status message detects the timeout\n        mqtt_client._update_state(data)\n        assert mqtt_client._dev_mode_probe_failures == 1\n        assert mqtt_client._dev_mode_probe_seq is None\n        # Should allow retry on next full message\n        assert mqtt_client._dev_mode_probed is False\n        # Connection should NOT be force-closed after 1 failure\n        assert mqtt_client.state.connected is True\n\n    def test_second_timeout_forces_reconnect(self, mqtt_client):\n        \"\"\"After two consecutive probe timeouts, force-close the socket.\"\"\"\n        import time\n\n        data = self._make_pushall_data()\n        state_change_called = []\n        mqtt_client.on_state_change = lambda s: state_change_called.append(True)\n\n        # First probe + timeout\n        mqtt_client._update_state(data)\n        mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0\n        mqtt_client._update_state(data)\n        assert mqtt_client._dev_mode_probe_failures == 1\n\n        # Second probe (retry) + timeout\n        mqtt_client._update_state(data)  # triggers new probe\n        assert mqtt_client._dev_mode_probed is True\n        mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0\n        mqtt_client._update_state(data)  # detects second timeout\n\n        assert mqtt_client._dev_mode_probe_failures == 2\n        assert mqtt_client.state.connected is False\n        assert mqtt_client._stale_reconnecting is True\n        # Socket should have been closed\n        mqtt_client._client.socket().close.assert_called()\n        # on_state_change should have been called\n        assert len(state_change_called) > 0\n\n    def test_successful_probe_resets_failure_counter(self, mqtt_client):\n        \"\"\"A probe response after a previous failure resets the counter.\"\"\"\n        import time\n\n        data = self._make_pushall_data()\n\n        # First probe + timeout → failure=1\n        mqtt_client._update_state(data)\n        seq = mqtt_client._dev_mode_probe_seq\n        mqtt_client._dev_mode_probe_time = time.monotonic() - 11.0\n        mqtt_client._update_state(data)\n        assert mqtt_client._dev_mode_probe_failures == 1\n\n        # Retry probe\n        mqtt_client._update_state(data)\n        new_seq = mqtt_client._dev_mode_probe_seq\n        assert new_seq is not None\n        assert new_seq != seq\n\n        # Simulate successful response\n        mqtt_client._handle_dev_mode_probe_response(\n            {\n                \"command\": \"ams_filament_setting\",\n                \"sequence_id\": new_seq,\n                \"result\": \"success\",\n            }\n        )\n        assert mqtt_client._dev_mode_probe_failures == 0\n        assert mqtt_client.state.developer_mode is True\n        assert mqtt_client._dev_mode_probe_seq is None\n\n    def test_no_timeout_when_probe_not_sent(self, mqtt_client):\n        \"\"\"The timeout branch is only entered when a probe is pending.\"\"\"\n        # No probe sent — _dev_mode_probed is False, _dev_mode_probe_seq is None\n        data = {\"gcode_state\": \"IDLE\", \"mc_percent\": 0}  # < 30 keys\n        mqtt_client._update_state(data)\n        assert mqtt_client._dev_mode_probe_failures == 0\n\n    def test_on_connect_resets_probe_state_but_preserves_developer_mode(self, mqtt_client):\n        \"\"\"_on_connect resets probe tracking but preserves cached developer_mode.\"\"\"\n        import time\n\n        mqtt_client._dev_mode_probed = True\n        mqtt_client._dev_mode_probe_seq = \"42\"\n        mqtt_client._dev_mode_probe_time = time.monotonic()\n        mqtt_client._dev_mode_probe_failures = 2\n        mqtt_client.state.developer_mode = True\n\n        # subscribe() must return (result, mid) tuple\n        mqtt_client._client.subscribe.return_value = (0, 1)\n        mqtt_client._on_connect(mqtt_client._client, None, None, 0)\n\n        # developer_mode is preserved across reconnects (#887)\n        assert mqtt_client.state.developer_mode is True\n        assert mqtt_client._dev_mode_probed is False\n        assert mqtt_client._dev_mode_probe_seq is None\n        assert mqtt_client._dev_mode_probe_time == 0.0\n        assert mqtt_client._dev_mode_probe_failures == 0\n        assert mqtt_client._connect_time > 0\n\n    def test_probe_deferred_when_connect_too_recent(self, mqtt_client):\n        \"\"\"Probe is deferred if less than 5s have passed since _on_connect.\"\"\"\n        import time\n\n        data = self._make_pushall_data()\n\n        # Set connect time to 1 second ago — too recent for probe\n        mqtt_client._connect_time = time.monotonic() - 1.0\n\n        mqtt_client._update_state(data)\n        # Pushall seen, so needs_probe is set, but probe NOT fired yet\n        assert mqtt_client._dev_mode_needs_probe is True\n        assert mqtt_client._dev_mode_probed is False\n        assert mqtt_client._dev_mode_probe_seq is None\n\n    def test_probe_fires_after_delay(self, mqtt_client):\n        \"\"\"Probe fires once 5s have passed since _on_connect.\"\"\"\n        import time\n\n        data = self._make_pushall_data()\n\n        # Set connect time to 6 seconds ago — delay satisfied\n        mqtt_client._connect_time = time.monotonic() - 6.0\n\n        mqtt_client._update_state(data)\n        # Probe should have fired\n        assert mqtt_client._dev_mode_needs_probe is True\n        assert mqtt_client._dev_mode_probed is True\n        assert mqtt_client._dev_mode_probe_seq is not None\n\n    def test_probe_fires_on_incremental_after_delay(self, mqtt_client):\n        \"\"\"After seeing a pushall within 5s, probe fires on later incremental message.\"\"\"\n        import time\n\n        pushall_data = self._make_pushall_data()\n        incremental_data = {\"gcode_state\": \"IDLE\", \"mc_percent\": 0}  # < 30 keys\n\n        # Pushall arrives 1s after connect — too early for probe\n        mqtt_client._connect_time = time.monotonic() - 1.0\n        mqtt_client._update_state(pushall_data)\n        assert mqtt_client._dev_mode_needs_probe is True\n        assert mqtt_client._dev_mode_probed is False\n\n        # 5s later, an incremental update arrives — probe fires now\n        mqtt_client._connect_time = time.monotonic() - 6.0\n        mqtt_client._update_state(incremental_data)\n        assert mqtt_client._dev_mode_probed is True\n        assert mqtt_client._dev_mode_probe_seq is not None\n\n    def test_no_reprobe_when_developer_mode_cached(self, mqtt_client):\n        \"\"\"Auto-reconnect preserves developer_mode, skipping reprobe.\"\"\"\n        import time\n\n        data = self._make_pushall_data()\n\n        # Simulate known developer_mode from previous connection\n        mqtt_client.state.developer_mode = True\n        mqtt_client._connect_time = time.monotonic() - 10.0\n\n        mqtt_client._update_state(data)\n        # Should NOT probe — developer_mode is already known\n        assert mqtt_client._dev_mode_needs_probe is False\n        assert mqtt_client._dev_mode_probed is False\n        assert mqtt_client._dev_mode_probe_seq is None\n        assert mqtt_client.state.developer_mode is True\n\n    def test_on_connect_resets_needs_probe(self, mqtt_client):\n        \"\"\"_on_connect resets _dev_mode_needs_probe for a clean start.\"\"\"\n        mqtt_client._dev_mode_needs_probe = True\n\n        mqtt_client._client.subscribe.return_value = (0, 1)\n        mqtt_client._on_connect(mqtt_client._client, None, None, 0)\n\n        assert mqtt_client._dev_mode_needs_probe is False\n\n\nclass TestVtTrayNormalization:\n    \"\"\"Tests for vt_tray dict→list normalization in _update_state.\n\n    MQTT sends vt_tray as a dict for single-slot printers, but all consumers\n    expect a list.  _update_state must normalize it before any callback can\n    read raw_data, because the dev-mode probe may release the GIL and let\n    the event loop read the partially-updated state.\n    \"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_vt_tray_dict_normalized_in_update_state(self, mqtt_client):\n        \"\"\"Verify _update_state wraps a raw vt_tray dict into a list.\"\"\"\n        vt_dict = {\n            \"id\": \"254\",\n            \"tray_color\": \"FF0000\",\n            \"tray_type\": \"PLA\",\n            \"tag_uid\": \"0000000000000000\",\n            \"tray_uuid\": \"00000000000000000000000000000000\",\n        }\n        data = {\"gcode_state\": \"IDLE\", \"vt_tray\": vt_dict}\n        mqtt_client._update_state(data)\n\n        stored = mqtt_client.state.raw_data.get(\"vt_tray\")\n        assert isinstance(stored, list)\n        assert len(stored) == 1\n        assert stored[0][\"tray_color\"] == \"FF0000\"\n\n    def test_vt_tray_list_unchanged_in_update_state(self, mqtt_client):\n        \"\"\"Verify _update_state keeps an already-list vt_tray unchanged.\"\"\"\n        vt_list = [\n            {\"id\": \"254\", \"tray_type\": \"PLA\"},\n            {\"id\": \"255\", \"tray_type\": \"PETG\"},\n        ]\n        data = {\"gcode_state\": \"IDLE\", \"vt_tray\": vt_list}\n        mqtt_client._update_state(data)\n\n        stored = mqtt_client.state.raw_data.get(\"vt_tray\")\n        assert isinstance(stored, list)\n        assert len(stored) == 2\n\n    def test_preserved_vt_tray_restored_before_probe(self, mqtt_client):\n        \"\"\"Verify preserved vt_tray is restored before dev-mode probe runs.\n\n        On the first message, the incremental handler wraps vt_tray into a list\n        and stores it.  _update_state then replaces raw_data with the full data\n        dict, but must restore preserved fields BEFORE the probe publishes\n        (which can release the GIL).\n        \"\"\"\n        # Simulate: incremental handler already stored a wrapped list\n        mqtt_client.state.raw_data = {\n            \"vt_tray\": [{\"id\": \"254\", \"tray_type\": \"PLA\", \"tray_color\": \"00FF00\"}],\n        }\n\n        # Now _update_state runs with new data that has vt_tray as dict\n        new_data = {\n            \"gcode_state\": \"IDLE\",\n            \"vt_tray\": {\"id\": \"254\", \"tray_type\": \"PETG\", \"tray_color\": \"FF0000\"},\n        }\n        mqtt_client._update_state(new_data)\n\n        # The preserved list (PLA/green) should take priority over new data\n        stored = mqtt_client.state.raw_data[\"vt_tray\"]\n        assert isinstance(stored, list)\n        assert stored[0][\"tray_type\"] == \"PLA\"\n        assert stored[0][\"tray_color\"] == \"00FF00\"\n\n    def test_first_message_vt_tray_dict_becomes_list(self, mqtt_client):\n        \"\"\"Verify on the very first message, vt_tray dict is still a list.\n\n        When there's no previously preserved data, the normalized dict should\n        remain as a list in raw_data.\n        \"\"\"\n        # raw_data starts empty — no preserved vt_tray\n        mqtt_client.state.raw_data = {}\n\n        data = {\n            \"gcode_state\": \"IDLE\",\n            \"vt_tray\": {\"id\": \"254\", \"tray_type\": \"ABS\"},\n        }\n        mqtt_client._update_state(data)\n\n        stored = mqtt_client.state.raw_data[\"vt_tray\"]\n        assert isinstance(stored, list)\n        assert stored[0][\"tray_type\"] == \"ABS\"\n\n\nclass TestSendDryingCommand:\n    \"\"\"Tests for send_drying_command MQTT payload construction.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        \"\"\"Create a BambuMQTTClient with a mock MQTT client.\"\"\"\n        from unittest.mock import MagicMock\n\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        client._client = MagicMock()\n        return client\n\n    def test_rotate_tray_false_by_default(self, mqtt_client):\n        \"\"\"Verify rotate_tray defaults to False in the MQTT payload.\"\"\"\n        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament=\"PLA\")\n\n        call_args = mqtt_client._client.publish.call_args\n        payload = json.loads(call_args[0][1])\n        assert payload[\"print\"][\"rotate_tray\"] is False\n\n    def test_rotate_tray_true_when_enabled(self, mqtt_client):\n        \"\"\"Verify rotate_tray is True when explicitly enabled.\"\"\"\n        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament=\"PLA\", rotate_tray=True)\n\n        call_args = mqtt_client._client.publish.call_args\n        payload = json.loads(call_args[0][1])\n        assert payload[\"print\"][\"rotate_tray\"] is True\n\n    def test_rotate_tray_false_on_stop(self, mqtt_client):\n        \"\"\"Verify rotate_tray is False when stopping drying (mode=0).\"\"\"\n        mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)\n\n        call_args = mqtt_client._client.publish.call_args\n        payload = json.loads(call_args[0][1])\n        assert payload[\"print\"][\"rotate_tray\"] is False\n\n    def test_all_required_fields_present(self, mqtt_client):\n        \"\"\"Verify all required MQTT fields are present in the drying command.\"\"\"\n        mqtt_client.send_drying_command(ams_id=128, temp=75, duration=8, mode=1, filament=\"ABS\", rotate_tray=True)\n\n        call_args = mqtt_client._client.publish.call_args\n        payload = json.loads(call_args[0][1])\n        cmd = payload[\"print\"]\n        assert cmd[\"command\"] == \"ams_filament_drying\"\n        assert cmd[\"ams_id\"] == 128\n        assert cmd[\"temp\"] == 75\n        assert cmd[\"duration\"] == 8\n        assert cmd[\"mode\"] == 1\n        assert cmd[\"rotate_tray\"] is True\n        assert cmd[\"filament\"] == \"ABS\"\n        assert cmd[\"cooling_temp\"] == 20\n        assert cmd[\"humidity\"] == 0\n        assert cmd[\"close_power_conflict\"] is False\n        assert \"sequence_id\" in cmd\n\n    def test_publishes_with_qos_1(self, mqtt_client):\n        \"\"\"Verify drying commands are published with QoS 1.\"\"\"\n        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4)\n\n        call_args = mqtt_client._client.publish.call_args\n        # qos may be positional arg [2] or keyword\n        qos = call_args.kwargs.get(\"qos\", call_args[0][2] if len(call_args[0]) > 2 else None)\n        assert qos == 1\n\n\nclass TestStartPrintAmsMapping:\n    \"\"\"Tests for ams_mapping/ams_mapping2 construction in start_print().\n\n    BambuStudio converts virtual tray IDs (254/255) to -1 in the flat\n    ams_mapping and puts the real external spool info only in ams_mapping2.\n    Passing raw 254/255 in the flat array causes H2D firmware to fail\n    with 0700_8012 \"Failed to get AMS mapping table\".\n    \"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from unittest.mock import MagicMock\n\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        client._client = MagicMock()\n        client.state.connected = True\n        return client\n\n    def _get_published_command(self, mqtt_client):\n        \"\"\"Extract the parsed print command from the last publish call.\"\"\"\n        call_args = mqtt_client._client.publish.call_args\n        return json.loads(call_args[0][1])[\"print\"]\n\n    def test_regular_ams_trays_preserved_in_flat_mapping(self, mqtt_client):\n        \"\"\"Regular AMS tray IDs pass through unchanged in flat ams_mapping.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[0, 5, 11])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [0, 5, 11]\n        assert cmd[\"ams_mapping2\"] == [\n            {\"ams_id\": 0, \"slot_id\": 0},\n            {\"ams_id\": 1, \"slot_id\": 1},\n            {\"ams_id\": 2, \"slot_id\": 3},\n        ]\n\n    def test_unmapped_slots(self, mqtt_client):\n        \"\"\"Unmapped slots (-1) produce -1 in flat and 0xFF/0xFF in mapping2.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[-1, -1])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [-1, -1]\n        assert cmd[\"ams_mapping2\"] == [\n            {\"ams_id\": 255, \"slot_id\": 255},\n            {\"ams_id\": 255, \"slot_id\": 255},\n        ]\n\n    def test_external_main_nozzle_becomes_minus_one_in_flat(self, mqtt_client):\n        \"\"\"Virtual tray 255 (main nozzle) must be -1 in flat mapping.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[255])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [-1]\n        assert cmd[\"ams_mapping2\"] == [{\"ams_id\": 255, \"slot_id\": 0}]\n\n    def test_single_nozzle_external_spool_uses_main_id(self, mqtt_client):\n        \"\"\"Single-nozzle external spool (254) maps to ams_id=255 (VIRTUAL_TRAY_MAIN_ID).\n\n        Firmware reports tray_now=254 for external spool, but the print command\n        must use ams_id=255 in ams_mapping2. Sending 254 causes the firmware to\n        target AMS tray 0 instead of external spool (07FF_8012 error).\n        \"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[254])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [-1]\n        assert cmd[\"ams_mapping2\"] == [{\"ams_id\": 255, \"slot_id\": 0}]\n\n    def test_h2d_external_spool_mixed_with_ams(self, mqtt_client):\n        \"\"\"H2D scenario: AMS trays + unmapped + external deputy nozzle.\"\"\"\n        # Reproduces the exact scenario from issue #797:\n        # 5-slot 3MF, only slot 5 assigned to external deputy nozzle (254)\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[-1, -1, -1, -1, 255])\n\n        cmd = self._get_published_command(mqtt_client)\n        # Flat mapping: all -1 (external converted, unmapped stay -1)\n        assert cmd[\"ams_mapping\"] == [-1, -1, -1, -1, -1]\n        # Detailed mapping: unmapped slots use 0xFF, external uses real ams_id\n        assert cmd[\"ams_mapping2\"] == [\n            {\"ams_id\": 255, \"slot_id\": 255},\n            {\"ams_id\": 255, \"slot_id\": 255},\n            {\"ams_id\": 255, \"slot_id\": 255},\n            {\"ams_id\": 255, \"slot_id\": 255},\n            {\"ams_id\": 255, \"slot_id\": 0},\n        ]\n\n    def test_ams_ht_trays_preserved_in_flat_mapping(self, mqtt_client):\n        \"\"\"AMS-HT tray IDs (>=128) pass through in flat mapping.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[128, 131])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [128, 131]\n        assert cmd[\"ams_mapping2\"] == [\n            {\"ams_id\": 128, \"slot_id\": 0},\n            {\"ams_id\": 131, \"slot_id\": 0},\n        ]\n\n    def test_non_h2d_both_external_maps_to_main_id(self, mqtt_client):\n        \"\"\"Non-H2D: both 254 and 255 map to ams_id=255 (single nozzle).\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[254, 255])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [-1, -1]\n        assert cmd[\"ams_mapping2\"] == [\n            {\"ams_id\": 255, \"slot_id\": 0},\n            {\"ams_id\": 255, \"slot_id\": 0},\n        ]\n\n    def test_h2d_external_preserves_deputy_id(self, mqtt_client):\n        \"\"\"H2D dual-nozzle: 254 (deputy) stays 254, 255 (main) stays 255.\"\"\"\n        mqtt_client.model = \"H2D\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[254, 255])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [-1, -1]\n        assert cmd[\"ams_mapping2\"] == [\n            {\"ams_id\": 254, \"slot_id\": 0},\n            {\"ams_id\": 255, \"slot_id\": 0},\n        ]\n\n    def test_h2d_single_external_deputy(self, mqtt_client):\n        \"\"\"H2D: single external spool on deputy nozzle (254) keeps ams_id=254.\"\"\"\n        mqtt_client.model = \"H2D Pro\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[254])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [-1]\n        assert cmd[\"ams_mapping2\"] == [{\"ams_id\": 254, \"slot_id\": 0}]\n\n    def test_external_spool_only_sets_use_ams_false(self, mqtt_client):\n        \"\"\"Single external spool on non-H2D printer sets use_ams=False.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[254], use_ams=True)\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"use_ams\"] is False\n\n    def test_all_unmapped_sets_use_ams_false(self, mqtt_client):\n        \"\"\"All unmapped slots on non-H2D printer sets use_ams=False.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[-1, -1], use_ams=True)\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"use_ams\"] is False\n\n    def test_mixed_ams_and_external_keeps_use_ams_true(self, mqtt_client):\n        \"\"\"AMS tray + external spool keeps use_ams=True.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[0, 254], use_ams=True)\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"use_ams\"] is True\n\n    def test_h2d_both_external_keeps_use_ams_true(self, mqtt_client):\n        \"\"\"H2D with both external spools keeps use_ams=True (nozzle routing).\"\"\"\n        mqtt_client.model = \"H2D\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[254, 255], use_ams=True)\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"use_ams\"] is True\n\n    def test_empty_ams_mapping_keeps_use_ams_true(self, mqtt_client):\n        \"\"\"Empty ams_mapping list does not override use_ams.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[], use_ams=True)\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"use_ams\"] is True\n\n    def test_no_ams_mapping_omits_fields(self, mqtt_client):\n        \"\"\"When ams_mapping is None, neither field is in the command.\"\"\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=None)\n\n        cmd = self._get_published_command(mqtt_client)\n        assert \"ams_mapping\" not in cmd\n        assert \"ams_mapping2\" not in cmd\n\n    def test_x2d_external_preserves_deputy_id(self, mqtt_client):\n        \"\"\"X2D dual-nozzle (#988): 254 (deputy) stays 254, like H2D family.\n\n        X2D launched April 2026 and shares the H2D-style dual-extruder\n        firmware convention — external spool on the deputy (left) nozzle\n        is addressed as ams_id=254, not coerced to 255.\n        \"\"\"\n        mqtt_client.model = \"X2D\"\n        mqtt_client.start_print(\"test.3mf\", ams_mapping=[254, 255])\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"ams_mapping\"] == [-1, -1]\n        assert cmd[\"ams_mapping2\"] == [\n            {\"ams_id\": 254, \"slot_id\": 0},\n            {\"ams_id\": 255, \"slot_id\": 0},\n        ]\n\n    def test_x2d_uses_integer_format_for_calibration_fields(self, mqtt_client):\n        \"\"\"X2D must use H2D-style integer (0/1) format for calibration fields (#988).\n\n        The reporter's support bundle showed X2D running firmware in the same\n        family as H2D. Booleans in these fields are interpreted as nozzle\n        indexes by H2D firmware; X2D is treated identically until proven\n        otherwise.\n        \"\"\"\n        mqtt_client.model = \"X2D\"\n        mqtt_client.start_print(\n            \"test.3mf\",\n            timelapse=True,\n            bed_levelling=False,\n            flow_cali=True,\n            vibration_cali=False,\n            layer_inspect=True,\n        )\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"timelapse\"] == 1\n        assert cmd[\"bed_leveling\"] == 0\n        assert cmd[\"flow_cali\"] == 1\n        assert cmd[\"vibration_cali\"] == 0\n        assert cmd[\"layer_inspect\"] == 1\n\n    def test_p2s_still_uses_boolean_format(self, mqtt_client):\n        \"\"\"Regression guard: P2S is NOT in the is_h2d gate — must still use booleans.\n\n        Adding X2D to the is_h2d set must not accidentally affect P2S, which\n        is single-nozzle and uses boolean format like X1C/A1/P1.\n        \"\"\"\n        mqtt_client.model = \"P2S\"\n        mqtt_client.start_print(\"test.3mf\", timelapse=True, flow_cali=False)\n\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"timelapse\"] is True\n        assert cmd[\"flow_cali\"] is False\n\n\nclass TestStartPrintUniqueIdentityFields:\n    \"\"\"Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).\n\n    Hardcoded \"0\" values caused third-party MQTT observers (e.g. OctoEverywhere)\n    to treat archive reprints as continuations of the same job and report\n    compounding durations on repeat replays. Each start_print call must produce\n    a distinct, non-zero identity triplet so the printer emits a fresh state\n    transition. md5 is deliberately left empty — historically firmware treats\n    \"\" as \"skip validation\" and we don't have the file's real digest here.\n    \"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from unittest.mock import MagicMock\n\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        client._client = MagicMock()\n        client.state.connected = True\n        return client\n\n    def _get_published_command(self, mqtt_client):\n        call_args = mqtt_client._client.publish.call_args\n        return json.loads(call_args[0][1])[\"print\"]\n\n    def test_identity_fields_are_non_zero(self, mqtt_client):\n        mqtt_client.start_print(\"test.3mf\")\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"project_id\"] != \"0\"\n        assert cmd[\"subtask_id\"] != \"0\"\n        assert cmd[\"task_id\"] != \"0\"\n\n    def test_identity_fields_are_all_equal_per_submission(self, mqtt_client):\n        \"\"\"All three IDs come from the same submission timestamp — Studio also\n        uses a single identity per submission across the three fields.\"\"\"\n        mqtt_client.start_print(\"test.3mf\")\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"project_id\"] == cmd[\"subtask_id\"] == cmd[\"task_id\"]\n\n    def test_md5_stays_empty(self, mqtt_client):\n        \"\"\"Deliberate: synthetic md5 risks activating firmware validation.\"\"\"\n        mqtt_client.start_print(\"test.3mf\")\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"md5\"] == \"\"\n\n    def test_identity_fields_change_between_submissions(self, mqtt_client):\n        \"\"\"Two successive start_print calls must produce different IDs.\n\n        Without this, the printer can't tell replays apart and reuses\n        gcode_start_time from the prior job.\n        \"\"\"\n        mqtt_client.start_print(\"test.3mf\")\n        first = self._get_published_command(mqtt_client)\n\n        time.sleep(0.002)\n\n        mqtt_client.start_print(\"test.3mf\")\n        second = self._get_published_command(mqtt_client)\n\n        assert first[\"task_id\"] != second[\"task_id\"]\n        assert first[\"subtask_id\"] != second[\"subtask_id\"]\n        assert first[\"project_id\"] != second[\"project_id\"]\n\n    def test_submission_id_is_numeric_string(self, mqtt_client):\n        \"\"\"ID format: digits-only string. Studio uses cloud task IDs that are\n        also numeric-looking strings; the DB column is VARCHAR(64) and\n        Bambuddy's own subtask_id parser treats '0'/'' as absent — any valid\n        digit string that isn't '0' is fine.\"\"\"\n        mqtt_client.start_print(\"test.3mf\")\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"task_id\"].isdigit()\n        assert int(cmd[\"task_id\"]) > 0\n        assert len(cmd[\"task_id\"]) <= 64\n\n    def test_submission_id_fits_signed_int32(self, mqtt_client):\n        \"\"\"Regression for #1042: P1S firmware clamps oversized task identity\n        fields to signed int32 max (2**31-1 = 2147483647). If we send raw\n        epoch-ms (~1.7e12), the printer sees a saturated constant on every\n        submission and treats fresh dispatches as continuations of the last\n        FAILED job — never leaves IDLE. Keep below 2**31.\n        \"\"\"\n        mqtt_client.start_print(\"test.3mf\")\n        cmd = self._get_published_command(mqtt_client)\n        assert int(cmd[\"task_id\"]) < 2**31\n        assert int(cmd[\"project_id\"]) < 2**31\n        assert int(cmd[\"subtask_id\"]) < 2**31\n\n    def test_unrelated_payload_fields_untouched(self, mqtt_client):\n        \"\"\"Regression guard: fix only touches identity fields; everything else\n        (sequence_id, command verb, calibration defaults, profile_id) must be\n        unchanged to avoid silently breaking printer behavior.\"\"\"\n        mqtt_client.start_print(\"test.3mf\")\n        cmd = self._get_published_command(mqtt_client)\n        assert cmd[\"sequence_id\"] == \"20000\"\n        assert cmd[\"command\"] == \"project_file\"\n        assert cmd[\"param\"] == \"Metadata/plate_1.gcode\"\n        assert cmd[\"url\"] == \"ftp://test.3mf\"\n        assert cmd[\"file\"] == \"test.3mf\"\n        assert cmd[\"profile_id\"] == \"0\"\n        assert cmd[\"cfg\"] == \"0\"\n        assert cmd[\"subtask_name\"] == \"test\"\n\n\nclass TestDeleteKProfileDualNozzleDetection:\n    \"\"\"Regression guard: dual-nozzle detection by serial prefix (#988).\n\n    delete_kprofile branches on serial-prefix-derived dual-nozzle status.\n    H2D serials start with \"094\"; X2D serials start with \"20P9\". Non-dual\n    families (X1C \"00M\", P1S \"01P\", P2S \"22E\", A1 \"039\", etc.) must take\n    the single-nozzle branch.\n    \"\"\"\n\n    def _make_client(self, serial: str):\n        from unittest.mock import MagicMock\n\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=serial,\n            access_code=\"12345678\",\n        )\n        client._client = MagicMock()\n        client.state.connected = True\n        return client\n\n    def _published(self, client):\n        return json.loads(client._client.publish.call_args[0][1])[\"print\"]\n\n    def test_h2d_serial_uses_dual_nozzle_format(self):\n        client = self._make_client(\"09400A000000001\")\n        client.delete_kprofile(cali_idx=1, filament_id=\"GFA00\", nozzle_id=\"HH00-0.4\")\n        cmd = self._published(client)\n        # Dual-nozzle command omits setting_id.\n        assert \"setting_id\" not in cmd\n        assert cmd[\"extruder_id\"] == 0\n\n    def test_x2d_serial_uses_dual_nozzle_format(self):\n        client = self._make_client(\"20P90A000000001\")\n        client.delete_kprofile(cali_idx=1, filament_id=\"GFA00\", nozzle_id=\"HH00-0.4\")\n        cmd = self._published(client)\n        assert \"setting_id\" not in cmd\n        assert cmd[\"extruder_id\"] == 0\n\n    def test_p2s_serial_uses_single_nozzle_format(self):\n        \"\"\"P2S is single-nozzle — must NOT take the dual-nozzle branch.\"\"\"\n        client = self._make_client(\"22E00A000000001\")\n        client.delete_kprofile(\n            cali_idx=1,\n            filament_id=\"GFA00\",\n            nozzle_id=\"HH00-0.4\",\n            setting_id=\"PFB123\",\n        )\n        cmd = self._published(client)\n        # Single-nozzle command includes setting_id.\n        assert cmd[\"setting_id\"] == \"PFB123\"\n\n    def test_x1c_serial_uses_single_nozzle_format(self):\n        client = self._make_client(\"00M00A000000001\")\n        client.delete_kprofile(\n            cali_idx=1,\n            filament_id=\"GFA00\",\n            nozzle_id=\"HH00-0.4\",\n            setting_id=\"PFB123\",\n        )\n        cmd = self._published(client)\n        assert cmd[\"setting_id\"] == \"PFB123\"\n\n\nclass TestStaleReconnect:\n    \"\"\"Tests for stale connection detection and reconnect without UI bouncing.\"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST_STALE\",\n            access_code=\"12345678\",\n        )\n        return client\n\n    def test_check_staleness_sets_flag_and_broadcasts_once(self, mqtt_client):\n        \"\"\"check_staleness() should set connected=False, broadcast, and set _stale_reconnecting.\"\"\"\n        import time\n\n        state_changes = []\n        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)\n        mqtt_client.state.connected = True\n        mqtt_client._last_message_time = time.time() - 120  # well past 60s threshold\n\n        result = mqtt_client.check_staleness()\n\n        assert result is False\n        assert mqtt_client.state.connected is False\n        assert mqtt_client._stale_reconnecting is True\n        assert state_changes == [False]  # Exactly one broadcast\n\n    def test_check_staleness_noop_when_not_connected(self, mqtt_client):\n        \"\"\"check_staleness() should not set flag when already disconnected.\"\"\"\n        import time\n\n        mqtt_client.state.connected = False\n        mqtt_client._last_message_time = time.time() - 120\n\n        mqtt_client.check_staleness()\n\n        assert mqtt_client._stale_reconnecting is False\n\n    def test_check_staleness_noop_when_not_stale(self, mqtt_client):\n        \"\"\"check_staleness() should not set flag when messages are recent.\"\"\"\n        import time\n\n        mqtt_client.state.connected = True\n        mqtt_client._last_message_time = time.time() - 5  # 5s ago, well within 60s\n\n        result = mqtt_client.check_staleness()\n\n        assert result is True\n        assert mqtt_client.state.connected is True\n        assert mqtt_client._stale_reconnecting is False\n\n    def test_on_disconnect_skipped_during_stale_reconnect(self, mqtt_client):\n        \"\"\"_on_disconnect should not broadcast state when _stale_reconnecting is set.\"\"\"\n        state_changes = []\n        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)\n        mqtt_client._stale_reconnecting = True\n        mqtt_client.state.connected = False\n\n        mqtt_client._on_disconnect(None, None)\n\n        # No state change broadcast — check_staleness() already did it\n        assert state_changes == []\n        assert mqtt_client.state.connected is False\n\n    def test_on_disconnect_fires_event_during_stale_reconnect(self, mqtt_client):\n        \"\"\"_on_disconnect must still fire _disconnection_event even during stale reconnect.\n\n        If disconnect() is called while _stale_reconnecting is True (e.g. user removes\n        the printer before paho reconnects), the event must fire so disconnect() doesn't hang.\n        \"\"\"\n        import threading\n\n        mqtt_client._stale_reconnecting = True\n        mqtt_client._disconnection_event = threading.Event()\n\n        mqtt_client._on_disconnect(None, None)\n\n        assert mqtt_client._disconnection_event.is_set()\n\n    def test_on_connect_clears_stale_reconnecting_flag(self, mqtt_client):\n        \"\"\"_on_connect should clear _stale_reconnecting and restore connected=True.\"\"\"\n        mqtt_client._stale_reconnecting = True\n        mqtt_client.state.connected = False\n\n        subscribe_calls = []\n        mock_client = type(\n            \"MockClient\",\n            (),\n            {\n                \"subscribe\": lambda self, topic: subscribe_calls.append(topic) or (0, 1),\n            },\n        )()\n\n        mqtt_client._on_connect(mock_client, None, None, 0)\n\n        assert mqtt_client._stale_reconnecting is False\n        assert mqtt_client.state.connected is True\n\n    def test_full_stale_reconnect_cycle_no_bounce(self, mqtt_client):\n        \"\"\"Full cycle: stale → disconnect callback → reconnect. UI should see exactly one disconnect.\"\"\"\n        import time\n\n        state_changes = []\n        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)\n        mqtt_client.state.connected = True\n        mqtt_client._last_message_time = time.time() - 120\n\n        # Step 1: Stale detection triggers\n        mqtt_client.check_staleness()\n        assert state_changes == [False]\n\n        # Step 2: Paho fires disconnect callback (from socket close)\n        mqtt_client._on_disconnect(None, None)\n        # Should NOT add another state change\n        assert state_changes == [False]\n\n        # Step 3: Paho reconnects\n        subscribe_calls = []\n        mock_client = type(\n            \"MockClient\",\n            (),\n            {\n                \"subscribe\": lambda self, topic: subscribe_calls.append(topic) or (0, 1),\n            },\n        )()\n        mqtt_client._on_connect(mock_client, None, None, 0)\n        assert state_changes == [False, True]  # Now connected again\n        assert mqtt_client._stale_reconnecting is False\n\n    def test_spurious_disconnect_suppressed_when_recent_messages(self, mqtt_client):\n        \"\"\"Non-error disconnect with recent messages should be suppressed.\"\"\"\n        import time\n\n        state_changes = []\n        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)\n        mqtt_client.state.connected = True\n        mqtt_client._last_message_time = time.time() - 3  # 3s ago\n\n        # Non-error disconnect (rc=None)\n        mqtt_client._on_disconnect(None, None)\n\n        assert state_changes == []\n        assert mqtt_client.state.connected is True\n\n    def test_error_disconnect_not_suppressed_despite_recent_messages(self, mqtt_client):\n        \"\"\"Error disconnect should always be processed, even with recent messages.\"\"\"\n        import time\n\n        import paho.mqtt.client as mqtt\n        from paho.mqtt.reasoncodes import ReasonCode\n\n        state_changes = []\n        mqtt_client.on_state_change = lambda s: state_changes.append(s.connected)\n        mqtt_client.state.connected = True\n        mqtt_client._last_message_time = time.time() - 3  # 3s ago\n\n        # Error disconnect (rc.is_failure = True)\n        rc = ReasonCode(mqtt.CONNACK >> 4, identifier=0x80)  # Failure code\n        mqtt_client._on_disconnect(None, None, rc=rc)\n\n        assert state_changes == [False]\n        assert mqtt_client.state.connected is False\n\n\nclass TestDoorOpenParsing:\n    \"\"\"Tests for enclosure door state parsing (X1 home_flag bit 23 vs others stat bit 23).\"\"\"\n\n    def _make_client(self, model: str):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        return BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST\",\n            access_code=\"12345678\",\n            model=model,\n        )\n\n    def test_x1c_door_open_from_home_flag(self):\n        client = self._make_client(\"X1C\")\n        # bit 23 set\n        client._update_state({\"home_flag\": 0xC0E5CD98})\n        assert client.state.door_open is True\n\n    def test_x1c_door_closed_from_home_flag(self):\n        client = self._make_client(\"X1C\")\n        client.state.door_open = True  # start \"open\"\n        client._update_state({\"home_flag\": 0xC065CD98})\n        assert client.state.door_open is False\n\n    def test_x1c_ignores_stat_field(self):\n        # X1C must NOT use stat (bit 23 in stat is unrelated for X1)\n        client = self._make_client(\"X1C\")\n        client._update_state({\"home_flag\": 0xC065CD98, \"stat\": \"47A58000\"})\n        assert client.state.door_open is False  # home_flag wins\n\n    def test_h2d_door_open_from_stat(self):\n        client = self._make_client(\"H2D\")\n        client._update_state({\"stat\": \"640A58000\"})  # bit 23 set\n        assert client.state.door_open is True\n\n    def test_h2d_door_closed_from_stat(self):\n        client = self._make_client(\"H2D\")\n        client.state.door_open = True\n        client._update_state({\"stat\": \"640258000\"})  # bit 23 cleared\n        assert client.state.door_open is False\n\n    def test_h2d_ignores_home_flag(self):\n        # Non-X1 must NOT consume home_flag for door state\n        client = self._make_client(\"H2D\")\n        client._update_state({\"home_flag\": 0xC0E5CD98, \"stat\": \"640258000\"})\n        assert client.state.door_open is False  # stat wins\n\n    def test_invalid_stat_does_not_raise(self):\n        client = self._make_client(\"H2D\")\n        client._update_state({\"stat\": \"not-hex\"})\n        assert client.state.door_open is False\n\n\nclass TestSdCardParsing:\n    \"\"\"SD-card state is only set from the top-level `sdcard` field (bool/int/\n    string variants). home_flag is NOT consulted — heartbeat pushes clear those\n    bits even when a card is inserted, and the prior badge feature was removed\n    entirely because no reliable heartbeat-vs-full-push heuristic existed.\"\"\"\n\n    def _make_client(self, model: str = \"H2D\"):\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        return BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST\",\n            access_code=\"12345678\",\n            model=model,\n        )\n\n    def test_home_flag_alone_does_not_touch_sdcard(self):\n        client = self._make_client()\n        client.state.sdcard = True\n        for home_flag in (0x00000000, 0x00000100, 0x00000200):\n            client._update_state({\"home_flag\": home_flag})\n        assert client.state.sdcard is True\n\n    def test_sdcard_string_fallback_when_no_home_flag(self):\n        client = self._make_client()\n        client._update_state({\"sdcard\": \"HAS_SDCARD_NORMAL\"})\n        assert client.state.sdcard is True\n\n    def test_sdcard_int_fallback_when_no_home_flag(self):\n        # `1 is True` is False — the old strict check flapped here.\n        client = self._make_client()\n        client._update_state({\"sdcard\": 1})\n        assert client.state.sdcard is True\n\n    def test_sdcard_bool_fallback_when_no_home_flag(self):\n        client = self._make_client()\n        client._update_state({\"sdcard\": True})\n        assert client.state.sdcard is True\n        client._update_state({\"sdcard\": False})\n        assert client.state.sdcard is False\n\n\nclass TestZombieSessionDetection:\n    \"\"\"Tests for ams_filament_setting response tracking (#887).\n\n    When a printer's MQTT session degrades so that telemetry flows but\n    published commands never reach the printer, the zombie detector\n    counts consecutive unanswered ams_filament_setting commands and\n    force-reconnects after two.\n    \"\"\"\n\n    @pytest.fixture\n    def mqtt_client(self):\n        import time\n        from unittest.mock import MagicMock\n\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        client.state.connected = True\n        mock_paho = MagicMock()\n        mock_paho.socket.return_value = MagicMock()\n        client._client = mock_paho\n        client._connect_time = time.monotonic() - 10.0\n        # Set developer_mode so the dev-mode probe branch doesn't interfere\n        client.state.developer_mode = True\n        return client\n\n    def test_initial_state_is_clean(self, mqtt_client):\n        \"\"\"Tracking fields start at zero / no pending command.\"\"\"\n        assert mqtt_client._last_ams_cmd_time == 0.0\n        assert mqtt_client._ams_cmd_unanswered == 0\n\n    def test_publish_sets_pending_time(self, mqtt_client):\n        \"\"\"set_ams_filament_setting records the publish timestamp.\"\"\"\n        import time\n\n        before = time.monotonic()\n        mqtt_client.ams_set_filament_setting(\n            ams_id=0,\n            tray_id=0,\n            tray_info_idx=\"GFL99\",\n            tray_type=\"PLA\",\n            tray_sub_brands=\"\",\n            tray_color=\"FF0000FF\",\n            nozzle_temp_min=190,\n            nozzle_temp_max=230,\n        )\n        assert mqtt_client._last_ams_cmd_time >= before\n\n    def test_reset_slot_sets_pending_time(self, mqtt_client):\n        \"\"\"reset_ams_slot also records the publish timestamp.\"\"\"\n        import time\n\n        before = time.monotonic()\n        mqtt_client.reset_ams_slot(ams_id=0, tray_id=0)\n        assert mqtt_client._last_ams_cmd_time >= before\n\n    def test_response_clears_pending(self, mqtt_client):\n        \"\"\"An ams_filament_setting response clears the pending state.\"\"\"\n        import time\n\n        mqtt_client._last_ams_cmd_time = time.monotonic()\n        mqtt_client._ams_cmd_unanswered = 1\n\n        # Simulate receiving a user-command response (sequence_id \"0\")\n        print_data = {\n            \"command\": \"ams_filament_setting\",\n            \"sequence_id\": \"0\",\n            \"result\": \"success\",\n        }\n        # Walk the same path as _on_message: command response check then _update_state\n        cmd = print_data.get(\"command\")\n        if cmd == \"ams_filament_setting\" and mqtt_client._last_ams_cmd_time > 0:\n            mqtt_client._last_ams_cmd_time = 0.0\n            mqtt_client._ams_cmd_unanswered = 0\n\n        assert mqtt_client._last_ams_cmd_time == 0.0\n        assert mqtt_client._ams_cmd_unanswered == 0\n\n    def test_single_timeout_increments_counter(self, mqtt_client):\n        \"\"\"One unanswered command increments the counter but does not reconnect.\"\"\"\n        import time\n\n        mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0\n\n        mqtt_client._update_state({\"gcode_state\": \"IDLE\"})\n\n        assert mqtt_client._ams_cmd_unanswered == 1\n        assert mqtt_client._last_ams_cmd_time == 0.0\n        # Should NOT force-reconnect after just one\n        assert mqtt_client.state.connected is True\n\n    def test_two_timeouts_force_reconnect(self, mqtt_client):\n        \"\"\"Two consecutive unanswered commands trigger force_reconnect.\"\"\"\n        import time\n\n        state_change_called = []\n        mqtt_client.on_state_change = lambda s: state_change_called.append(True)\n\n        # First unanswered command\n        mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0\n        mqtt_client._update_state({\"gcode_state\": \"IDLE\"})\n        assert mqtt_client._ams_cmd_unanswered == 1\n        assert mqtt_client.state.connected is True\n\n        # Second unanswered command\n        mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0\n        mqtt_client._update_state({\"gcode_state\": \"IDLE\"})\n\n        assert mqtt_client._ams_cmd_unanswered == 0  # reset after reconnect\n        assert mqtt_client.state.connected is False\n        assert mqtt_client._stale_reconnecting is True\n        mqtt_client._client.socket().close.assert_called()\n        assert len(state_change_called) > 0\n\n    def test_response_between_timeouts_resets_counter(self, mqtt_client):\n        \"\"\"A successful response after one timeout resets the counter.\"\"\"\n        import time\n\n        # First unanswered command\n        mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0\n        mqtt_client._update_state({\"gcode_state\": \"IDLE\"})\n        assert mqtt_client._ams_cmd_unanswered == 1\n\n        # Now a response arrives — clear pending\n        mqtt_client._last_ams_cmd_time = time.monotonic()\n        mqtt_client._last_ams_cmd_time = 0.0\n        mqtt_client._ams_cmd_unanswered = 0\n\n        # Next unanswered command should be count=1, not count=2\n        mqtt_client._last_ams_cmd_time = time.monotonic() - 11.0\n        mqtt_client._update_state({\"gcode_state\": \"IDLE\"})\n        assert mqtt_client._ams_cmd_unanswered == 1\n        assert mqtt_client.state.connected is True  # no reconnect\n\n    def test_on_connect_resets_tracking(self, mqtt_client):\n        \"\"\"_on_connect resets zombie tracking fields.\"\"\"\n        import time\n\n        mqtt_client._last_ams_cmd_time = time.monotonic()\n        mqtt_client._ams_cmd_unanswered = 5\n\n        # subscribe() must return (result, mid) tuple\n        mqtt_client._client.subscribe.return_value = (0, 1)\n        mqtt_client._on_connect(mqtt_client._client, None, None, 0)\n\n        assert mqtt_client._last_ams_cmd_time == 0.0\n        assert mqtt_client._ams_cmd_unanswered == 0\n\n    def test_no_check_when_no_command_pending(self, mqtt_client):\n        \"\"\"If no command was published, push_status does not trigger detection.\"\"\"\n        assert mqtt_client._last_ams_cmd_time == 0.0\n        mqtt_client._update_state({\"gcode_state\": \"IDLE\"})\n        assert mqtt_client._ams_cmd_unanswered == 0\n\n    def test_no_timeout_within_window(self, mqtt_client):\n        \"\"\"A command published <10s ago should not trigger a timeout.\"\"\"\n        import time\n\n        mqtt_client._last_ams_cmd_time = time.monotonic() - 5.0\n        mqtt_client._update_state({\"gcode_state\": \"IDLE\"})\n        assert mqtt_client._ams_cmd_unanswered == 0\n        assert mqtt_client._last_ams_cmd_time > 0  # still pending\n"
  },
  {
    "path": "backend/tests/unit/services/test_camera_tls_proxy.py",
    "content": "\"\"\"Tests for the camera TLS proxy and RTSP URL rewriting.\"\"\"\n\nimport asyncio\n\nimport pytest\n\nfrom backend.app.services.camera import create_tls_proxy, rewrite_rtsp_request_url\n\n\nclass TestRewriteRtspRequestUrl:\n    \"\"\"Tests for RTSP request-line URL rewriting.\"\"\"\n\n    def test_rewrites_describe_request_line(self):\n        proxy_url = b\"rtsp://127.0.0.1:45221\"\n        real_url = b\"rtsps://192.168.1.100:322\"\n\n        data = b\"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\\r\\nCSeq: 1\\r\\n\\r\\n\"\n        result = rewrite_rtsp_request_url(data, proxy_url, real_url)\n\n        assert b\"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\\r\\n\" in result\n\n    def test_rewrites_setup_request_line(self):\n        proxy_url = b\"rtsp://127.0.0.1:45221\"\n        real_url = b\"rtsps://192.168.1.100:322\"\n\n        data = b\"SETUP rtsp://127.0.0.1:45221/streaming/live/1/trackID=0 RTSP/1.0\\r\\nCSeq: 3\\r\\n\\r\\n\"\n        result = rewrite_rtsp_request_url(data, proxy_url, real_url)\n\n        assert b\"SETUP rtsps://192.168.1.100:322/streaming/live/1/trackID=0 RTSP/1.0\\r\\n\" in result\n\n    def test_rewrites_play_request_line(self):\n        proxy_url = b\"rtsp://127.0.0.1:45221\"\n        real_url = b\"rtsps://192.168.1.100:322\"\n\n        data = b\"PLAY rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\\r\\nCSeq: 5\\r\\n\\r\\n\"\n        result = rewrite_rtsp_request_url(data, proxy_url, real_url)\n\n        assert b\"PLAY rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\\r\\n\" in result\n\n    def test_preserves_authorization_header(self):\n        \"\"\"Digest auth embeds the URI in a hash — rewriting it breaks auth.\"\"\"\n        proxy_url = b\"rtsp://127.0.0.1:45221\"\n        real_url = b\"rtsps://192.168.1.100:322\"\n\n        data = (\n            b\"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\\r\\n\"\n            b\"CSeq: 2\\r\\n\"\n            b'Authorization: Digest username=\"bblp\", '\n            b'uri=\"rtsp://127.0.0.1:45221/streaming/live/1\", '\n            b'response=\"abc123\"\\r\\n'\n            b\"\\r\\n\"\n        )\n        result = rewrite_rtsp_request_url(data, proxy_url, real_url)\n\n        # Request line IS rewritten\n        assert b\"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\\r\\n\" in result\n        # Authorization header is NOT rewritten\n        assert b'uri=\"rtsp://127.0.0.1:45221/streaming/live/1\"' in result\n        assert b'response=\"abc123\"' in result\n\n    def test_no_rewrite_on_non_rtsp_data(self):\n        \"\"\"Binary RTP data and other non-RTSP data should pass through unchanged.\"\"\"\n        proxy_url = b\"rtsp://127.0.0.1:45221\"\n        real_url = b\"rtsps://192.168.1.100:322\"\n\n        # Interleaved RTP data (starts with $)\n        data = b\"$\\x00\\x00\\x10\" + b\"\\x00\" * 16\n        result = rewrite_rtsp_request_url(data, proxy_url, real_url)\n        assert result == data\n\n    def test_no_rewrite_on_empty_data(self):\n        proxy_url = b\"rtsp://127.0.0.1:45221\"\n        real_url = b\"rtsps://192.168.1.100:322\"\n\n        assert rewrite_rtsp_request_url(b\"\", proxy_url, real_url) == b\"\"\n\n    def test_only_first_rtsp_line_rewritten(self):\n        \"\"\"If somehow multiple RTSP/1.0 lines exist, only the first is rewritten.\"\"\"\n        proxy_url = b\"rtsp://127.0.0.1:45221\"\n        real_url = b\"rtsps://192.168.1.100:322\"\n\n        data = (\n            b\"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\\r\\n\"\n            b\"CSeq: 1\\r\\n\"\n            b\"X-Custom: rtsp://127.0.0.1:45221/other RTSP/1.0\\r\\n\"\n            b\"\\r\\n\"\n        )\n        result = rewrite_rtsp_request_url(data, proxy_url, real_url)\n\n        lines = result.split(b\"\\r\\n\")\n        # First line rewritten\n        assert lines[0] == b\"DESCRIBE rtsps://192.168.1.100:322/streaming/live/1 RTSP/1.0\"\n        # Hypothetical other line NOT rewritten\n        assert lines[2] == b\"X-Custom: rtsp://127.0.0.1:45221/other RTSP/1.0\"\n\n    def test_preserves_crlf_structure(self):\n        proxy_url = b\"rtsp://127.0.0.1:45221\"\n        real_url = b\"rtsps://192.168.1.100:322\"\n\n        data = b\"DESCRIBE rtsp://127.0.0.1:45221/streaming/live/1 RTSP/1.0\\r\\nCSeq: 1\\r\\n\\r\\n\"\n        result = rewrite_rtsp_request_url(data, proxy_url, real_url)\n\n        # Must still end with double CRLF (empty line terminates headers)\n        assert result.endswith(b\"\\r\\n\\r\\n\")\n        # Must have CSeq intact\n        assert b\"CSeq: 1\\r\\n\" in result\n\n\nclass TestCreateTlsProxy:\n    \"\"\"Tests for TLS proxy server lifecycle.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_proxy_returns_port_and_server(self):\n        \"\"\"Verify proxy creates a listening server on an ephemeral port.\"\"\"\n        # Use a non-routable target — we just test the server starts, not the TLS connection\n        port, server = await create_tls_proxy(\"192.0.2.1\", 322)\n\n        assert isinstance(port, int)\n        assert port > 0\n        assert server.is_serving()\n\n        server.close()\n        await server.wait_closed()\n\n    @pytest.mark.asyncio\n    async def test_proxy_accepts_connection(self):\n        \"\"\"Verify proxy accepts TCP connections (TLS to target will fail, but accept works).\"\"\"\n        port, server = await create_tls_proxy(\"192.0.2.1\", 322)\n\n        try:\n            # Connect to the proxy — it should accept the connection\n            reader, writer = await asyncio.wait_for(\n                asyncio.open_connection(\"127.0.0.1\", port),\n                timeout=2.0,\n            )\n            # The proxy will try to connect to 192.0.2.1:322 (non-routable), fail,\n            # and close our connection. That's expected.\n            writer.close()\n            await writer.wait_closed()\n        except (ConnectionError, TimeoutError):\n            pass  # Expected — target is unreachable\n\n        server.close()\n        await server.wait_closed()\n\n    @pytest.mark.asyncio\n    async def test_proxy_cleanup(self):\n        \"\"\"Verify proxy stops serving after close.\"\"\"\n        port, server = await create_tls_proxy(\"192.0.2.1\", 322)\n        assert server.is_serving()\n\n        server.close()\n        await server.wait_closed()\n\n        assert not server.is_serving()\n"
  },
  {
    "path": "backend/tests/unit/services/test_email_service.py",
    "content": "\"\"\"Unit tests for email service.\n\nThese tests verify email template rendering, HTML formatting,\npassword generation, and SMTP settings persistence.\n\"\"\"\n\nimport string\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\nfrom backend.app.models.notification_template import NotificationTemplate\nfrom backend.app.services.email_service import (\n    create_password_reset_email_from_template,\n    create_welcome_email_from_template,\n    generate_secure_password,\n    render_template,\n)\n\n\nclass TestEmailTemplateFormatting:\n    \"\"\"Tests for email template formatting.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_welcome_email_newlines_converted_to_br(self):\n        \"\"\"Verify that newlines in welcome email body are converted to <br> tags.\"\"\"\n        # Mock database session\n        db = AsyncMock(spec=AsyncSession)\n\n        # Mock template with newlines\n        template = NotificationTemplate(\n            event_type=\"user_created\",\n            name=\"Welcome Email\",\n            title_template=\"Welcome to {app_name}\",\n            body_template=\"Hello {username}!\\n\\nYour password is: {password}\\n\\nPlease login at: {login_url}\",\n            is_default=True,\n        )\n\n        # Patch get_notification_template to return our template\n        with patch(\"backend.app.services.email_service.get_notification_template\", return_value=template):\n            # Generate email\n            subject, text_body, html_body = await create_welcome_email_from_template(\n                db=db,\n                username=\"testuser\",\n                password=\"testpass123\",\n                login_url=\"http://example.com/login\",\n                app_name=\"TestApp\",\n            )\n\n        # Verify subject\n        assert subject == \"Welcome to TestApp\"\n\n        # Verify text body has newlines\n        assert \"\\n\\n\" in text_body\n        assert \"Hello testuser!\" in text_body\n        assert \"Your password is: testpass123\" in text_body\n\n        # Verify HTML body has <br> tags instead of relying on CSS\n        assert \"<br>\" in html_body\n        # Should not use white-space: pre-wrap\n        assert \"white-space: pre-wrap\" not in html_body\n        # Should have proper structure\n        assert \"<!DOCTYPE html>\" in html_body\n        assert '<div style=\"font-size: 16px;\">' in html_body\n\n        # Verify that escaped content is present (XSS protection)\n        assert \"Hello testuser!<br>\" in html_body\n        assert \"Your password is: testpass123<br>\" in html_body\n\n    @pytest.mark.asyncio\n    async def test_password_reset_email_newlines_converted_to_br(self):\n        \"\"\"Verify that newlines in password reset email body are converted to <br> tags.\"\"\"\n        # Mock database session\n        db = AsyncMock(spec=AsyncSession)\n\n        # Mock template with newlines\n        template = NotificationTemplate(\n            event_type=\"password_reset\",\n            name=\"Password Reset\",\n            title_template=\"{app_name} - Password Reset\",\n            body_template=\"Hello {username},\\n\\nYour password has been reset.\\nNew password: {password}\\n\\nLogin at: {login_url}\",\n            is_default=True,\n        )\n\n        # Patch get_notification_template to return our template\n        with patch(\"backend.app.services.email_service.get_notification_template\", return_value=template):\n            # Generate email\n            subject, text_body, html_body = await create_password_reset_email_from_template(\n                db=db,\n                username=\"testuser\",\n                password=\"newpass456\",\n                login_url=\"http://example.com/login\",\n                app_name=\"TestApp\",\n            )\n\n        # Verify subject\n        assert subject == \"TestApp - Password Reset\"\n\n        # Verify text body has newlines\n        assert \"\\n\\n\" in text_body\n        assert \"Hello testuser,\" in text_body\n\n        # Verify HTML body has <br> tags\n        assert \"<br>\" in html_body\n        # Should not use white-space: pre-wrap\n        assert \"white-space: pre-wrap\" not in html_body\n        # Should have security alert\n        assert \"Security Alert\" in html_body\n\n    @pytest.mark.asyncio\n    async def test_email_header_padding(self):\n        \"\"\"Verify that email header has proper padding to prevent cutoff.\"\"\"\n        # Mock database session\n        db = AsyncMock(spec=AsyncSession)\n\n        # Mock template\n        template = NotificationTemplate(\n            event_type=\"user_created\",\n            name=\"Welcome Email\",\n            title_template=\"Welcome\",\n            body_template=\"Test body\",\n            is_default=True,\n        )\n\n        # Patch get_notification_template to return our template\n        with patch(\"backend.app.services.email_service.get_notification_template\", return_value=template):\n            # Generate email\n            subject, text_body, html_body = await create_welcome_email_from_template(\n                db=db,\n                username=\"testuser\",\n                password=\"testpass123\",\n                login_url=\"http://example.com/login\",\n            )\n\n        # Verify header has 30px padding (not 20px which was cutting off)\n        assert \"padding: 30px; border-radius: 8px 8px 0 0;\" in html_body\n\n    @pytest.mark.asyncio\n    async def test_email_xss_protection(self):\n        \"\"\"Verify that HTML escaping is applied to prevent XSS attacks.\"\"\"\n        # Mock database session\n        db = AsyncMock(spec=AsyncSession)\n\n        # Mock template with potential XSS content\n        template = NotificationTemplate(\n            event_type=\"user_created\",\n            name=\"Welcome Email\",\n            title_template=\"Welcome <script>alert('xss')</script>\",\n            body_template=\"Hello <script>alert('xss')</script>\\nTest\",\n            is_default=True,\n        )\n\n        # Patch get_notification_template to return our template\n        with patch(\"backend.app.services.email_service.get_notification_template\", return_value=template):\n            # Generate email\n            subject, text_body, html_body = await create_welcome_email_from_template(\n                db=db,\n                username=\"testuser\",\n                password=\"testpass123\",\n                login_url=\"http://example.com/login\",\n            )\n\n        # Verify that script tags are escaped\n        assert \"&lt;script&gt;\" in html_body\n        # Verify no unescaped script tags\n        assert \"<script>\" not in html_body\n\n\nclass TestGenerateSecurePassword:\n    \"\"\"Tests for generate_secure_password().\"\"\"\n\n    def test_password_default_length(self):\n        \"\"\"Default password is 16 characters.\"\"\"\n        password = generate_secure_password()\n        assert len(password) == 16\n\n    def test_password_custom_length(self):\n        \"\"\"Custom length is respected.\"\"\"\n        password = generate_secure_password(24)\n        assert len(password) == 24\n\n    def test_password_has_required_char_types(self):\n        \"\"\"Password contains uppercase, lowercase, digit, and special character.\"\"\"\n        # Run multiple times to reduce flakiness from random shuffling\n        for _ in range(5):\n            password = generate_secure_password()\n            assert any(c in string.ascii_uppercase for c in password), \"Missing uppercase\"\n            assert any(c in string.ascii_lowercase for c in password), \"Missing lowercase\"\n            assert any(c in string.digits for c in password), \"Missing digit\"\n            assert any(c in \"!@#$%^&*()_+-=[]{}|;:,.<>?\" for c in password), \"Missing special\"\n\n\nclass TestRenderTemplate:\n    \"\"\"Tests for render_template().\"\"\"\n\n    def test_render_template_basic(self):\n        \"\"\"Placeholders are replaced correctly.\"\"\"\n        result = render_template(\"Hello {name}, welcome to {app}!\", {\"name\": \"Alice\", \"app\": \"BamBuddy\"})\n        assert result == \"Hello Alice, welcome to BamBuddy!\"\n\n    def test_render_template_removes_unreplaced(self):\n        \"\"\"Unreplaced placeholders are removed.\"\"\"\n        result = render_template(\"Hello {name}, your code is {code}\", {\"name\": \"Bob\"})\n        assert result == \"Hello Bob, your code is \"\n\n\nclass TestSMTPSettingsPersistence:\n    \"\"\"Tests for save_smtp_settings() and get_smtp_settings() round-trip.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_save_and_retrieve_smtp_settings(self, db_session):\n        \"\"\"Save SMTP settings, then retrieve them and verify values match.\"\"\"\n        from backend.app.schemas.auth import SMTPSettings\n        from backend.app.services.email_service import get_smtp_settings, save_smtp_settings\n\n        settings = SMTPSettings(\n            smtp_host=\"mail.example.com\",\n            smtp_port=465,\n            smtp_username=\"user@example.com\",\n            smtp_password=\"secret\",\n            smtp_security=\"ssl\",\n            smtp_auth_enabled=True,\n            smtp_from_email=\"noreply@example.com\",\n        )\n        await save_smtp_settings(db_session, settings)\n        await db_session.commit()\n\n        retrieved = await get_smtp_settings(db_session)\n        assert retrieved is not None\n        assert retrieved.smtp_host == \"mail.example.com\"\n        assert retrieved.smtp_port == 465\n        assert retrieved.smtp_username == \"user@example.com\"\n        assert retrieved.smtp_password == \"secret\"\n        assert retrieved.smtp_security == \"ssl\"\n        assert retrieved.smtp_auth_enabled is True\n        assert retrieved.smtp_from_email == \"noreply@example.com\"\n\n    @pytest.mark.asyncio\n    async def test_get_smtp_settings_returns_none_when_unconfigured(self, db_session):\n        \"\"\"Empty DB returns None for SMTP settings.\"\"\"\n        from backend.app.services.email_service import get_smtp_settings\n\n        result = await get_smtp_settings(db_session)\n        assert result is None\n"
  },
  {
    "path": "backend/tests/unit/services/test_external_camera.py",
    "content": "\"\"\"\nTests for the external camera service.\n\nThese tests cover pure functions and frame parsing logic.\n\"\"\"\n\nfrom unittest.mock import patch\n\nimport pytest\n\n\nclass TestFormatMjpegFrame:\n    \"\"\"Tests for MJPEG frame formatting.\"\"\"\n\n    def test_format_mjpeg_frame_basic(self):\n        \"\"\"Verify MJPEG frame is formatted correctly with boundary and headers.\"\"\"\n        from backend.app.services.external_camera import _format_mjpeg_frame\n\n        # Minimal JPEG data (just SOI and EOI markers)\n        jpeg_data = b\"\\xff\\xd8\\xff\\xd9\"\n\n        result = _format_mjpeg_frame(jpeg_data)\n\n        # Check boundary\n        assert result.startswith(b\"--frame\\r\\n\")\n        # Check content type\n        assert b\"Content-Type: image/jpeg\\r\\n\" in result\n        # Check content length\n        assert b\"Content-Length: 4\\r\\n\" in result\n        # Check frame data is included\n        assert jpeg_data in result\n        # Check ends with CRLF\n        assert result.endswith(b\"\\r\\n\")\n\n    def test_format_mjpeg_frame_larger_data(self):\n        \"\"\"Verify content length is correct for larger frames.\"\"\"\n        from backend.app.services.external_camera import _format_mjpeg_frame\n\n        # Simulate a larger JPEG (1000 bytes)\n        jpeg_data = b\"\\xff\\xd8\" + b\"\\x00\" * 996 + b\"\\xff\\xd9\"\n\n        result = _format_mjpeg_frame(jpeg_data)\n\n        assert b\"Content-Length: 1000\\r\\n\" in result\n\n\nclass TestGetFfmpegPath:\n    \"\"\"Tests for ffmpeg path detection.\"\"\"\n\n    def test_get_ffmpeg_path_from_shutil_which(self):\n        \"\"\"Verify ffmpeg found via shutil.which is returned.\"\"\"\n        from backend.app.services.external_camera import get_ffmpeg_path\n\n        with patch(\"shutil.which\", return_value=\"/usr/bin/ffmpeg\"):\n            result = get_ffmpeg_path()\n            assert result == \"/usr/bin/ffmpeg\"\n\n    def test_get_ffmpeg_path_fallback_to_common_paths(self):\n        \"\"\"Verify common paths are checked when shutil.which fails.\"\"\"\n        from backend.app.services.external_camera import get_ffmpeg_path\n\n        with patch(\"shutil.which\", return_value=None), patch(\"pathlib.Path.exists\") as mock_exists:\n            # First common path exists\n            mock_exists.return_value = True\n            result = get_ffmpeg_path()\n            assert result in [\"/usr/bin/ffmpeg\", \"/usr/local/bin/ffmpeg\", \"/opt/homebrew/bin/ffmpeg\"]\n\n    def test_get_ffmpeg_path_returns_none_when_not_found(self):\n        \"\"\"Verify None is returned when ffmpeg not found anywhere.\"\"\"\n        from backend.app.services.external_camera import get_ffmpeg_path\n\n        with patch(\"shutil.which\", return_value=None), patch(\"pathlib.Path.exists\", return_value=False):\n            result = get_ffmpeg_path()\n            assert result is None\n\n\nclass TestJpegFrameExtraction:\n    \"\"\"Tests for JPEG frame extraction from buffer.\"\"\"\n\n    def test_extract_single_frame_from_buffer(self):\n        \"\"\"Test extracting a complete JPEG frame from buffer.\"\"\"\n        # JPEG markers\n        jpeg_start = b\"\\xff\\xd8\"\n        jpeg_end = b\"\\xff\\xd9\"\n\n        # Create a buffer with one complete frame\n        frame_content = b\"\\x00\" * 100\n        buffer = jpeg_start + frame_content + jpeg_end\n\n        # Find frame boundaries\n        start_idx = buffer.find(jpeg_start)\n        end_idx = buffer.find(jpeg_end, start_idx + 2)\n\n        assert start_idx == 0\n        assert end_idx == 102\n\n        # Extract frame\n        frame = buffer[start_idx : end_idx + 2]\n        assert frame == buffer\n        assert len(frame) == 104\n\n    def test_extract_frame_with_leading_garbage(self):\n        \"\"\"Test extracting frame when buffer has leading garbage data.\"\"\"\n        jpeg_start = b\"\\xff\\xd8\"\n        jpeg_end = b\"\\xff\\xd9\"\n\n        # Buffer with garbage before the JPEG\n        garbage = b\"\\x00\\x01\\x02\\x03\"\n        frame_content = b\"\\xff\" * 50\n        buffer = garbage + jpeg_start + frame_content + jpeg_end\n\n        start_idx = buffer.find(jpeg_start)\n        assert start_idx == 4  # After garbage\n\n        end_idx = buffer.find(jpeg_end, start_idx + 2)\n        frame = buffer[start_idx : end_idx + 2]\n\n        assert frame.startswith(jpeg_start)\n        assert frame.endswith(jpeg_end)\n        assert len(frame) == 54  # 2 + 50 + 2\n\n    def test_incomplete_frame_detection(self):\n        \"\"\"Test detection of incomplete frame (no end marker).\"\"\"\n        jpeg_start = b\"\\xff\\xd8\"\n\n        # Incomplete buffer - no end marker\n        buffer = jpeg_start + b\"\\x00\" * 100\n\n        start_idx = buffer.find(jpeg_start)\n        end_idx = buffer.find(b\"\\xff\\xd9\", start_idx + 2)\n\n        assert start_idx == 0\n        assert end_idx == -1  # Not found\n\n    def test_multiple_frames_in_buffer(self):\n        \"\"\"Test extracting first frame when buffer contains multiple frames.\"\"\"\n        jpeg_start = b\"\\xff\\xd8\"\n        jpeg_end = b\"\\xff\\xd9\"\n\n        # Two complete frames\n        frame1 = jpeg_start + b\"\\x01\" * 10 + jpeg_end\n        frame2 = jpeg_start + b\"\\x02\" * 20 + jpeg_end\n        buffer = frame1 + frame2\n\n        # Extract first frame\n        start_idx = buffer.find(jpeg_start)\n        end_idx = buffer.find(jpeg_end, start_idx + 2)\n        first_frame = buffer[start_idx : end_idx + 2]\n\n        assert first_frame == frame1\n        assert len(first_frame) == 14\n\n        # Remaining buffer should contain second frame\n        remaining = buffer[end_idx + 2 :]\n        assert remaining == frame2\n\n\nclass TestCameraTypeValidation:\n    \"\"\"Tests for camera type handling.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_capture_frame_unknown_type_returns_none(self):\n        \"\"\"Verify unknown camera type returns None.\"\"\"\n        from backend.app.services.external_camera import capture_frame\n\n        result = await capture_frame(\"http://example.com\", \"unknown_type\")\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_capture_frame_valid_types(self):\n        \"\"\"Verify valid camera types are accepted (they may fail but shouldn't error on type).\"\"\"\n        from backend.app.services.external_camera import capture_frame\n\n        # These will fail to connect but shouldn't raise type errors\n        for camera_type in [\"mjpeg\", \"rtsp\", \"snapshot\"]:\n            # Use a non-routable IP to fail fast\n            result = await capture_frame(\"http://192.0.2.1/test\", camera_type, timeout=1)\n            # Should return None (failed connection) not raise exception\n            assert result is None\n\n\nclass TestRtspUrlHandling:\n    \"\"\"Tests for RTSP/RTSPS URL handling.\"\"\"\n\n    def test_rtsps_url_detection(self):\n        \"\"\"Verify rtsps:// and rtsp:// URL schemes are distinct.\"\"\"\n        url_rtsps = \"rtsps://user:pass@192.168.1.1:554/stream\"\n        url_rtsp = \"rtsp://user:pass@192.168.1.1:554/stream\"\n\n        assert url_rtsps.startswith(\"rtsps://\")\n        assert not url_rtsp.startswith(\"rtsps://\")\n        assert url_rtsp.startswith(\"rtsp://\")\n\n    def test_ffmpeg_handles_both_rtsp_and_rtsps(self):\n        \"\"\"Verify ffmpeg command structure handles both URL schemes identically.\n\n        ffmpeg automatically handles TLS for rtsps:// URLs, so no special\n        flags are needed - both URL schemes use the same command structure.\n        \"\"\"\n        # Both URL types should use the same basic ffmpeg options\n        base_cmd = [\n            \"ffmpeg\",\n            \"-rtsp_transport\",\n            \"tcp\",\n            \"-i\",\n        ]\n\n        rtsp_url = \"rtsp://user:pass@192.168.1.1:554/stream\"\n        rtsps_url = \"rtsps://user:pass@192.168.1.1:554/stream\"\n\n        # Command structure is identical for both\n        cmd_rtsp = base_cmd + [rtsp_url]\n        cmd_rtsps = base_cmd + [rtsps_url]\n\n        # Only the URL differs\n        assert cmd_rtsp[:-1] == cmd_rtsps[:-1]\n        assert cmd_rtsp[-1] != cmd_rtsps[-1]\n\n\nclass TestUsbCameraHandling:\n    \"\"\"Tests for USB camera support.\"\"\"\n\n    def test_list_usb_cameras_returns_list(self):\n        \"\"\"Verify list_usb_cameras returns a list (may be empty if no cameras).\"\"\"\n        from backend.app.services.external_camera import list_usb_cameras\n\n        result = list_usb_cameras()\n        assert isinstance(result, list)\n\n    def test_list_usb_cameras_dict_structure(self):\n        \"\"\"Verify each camera entry has expected fields.\"\"\"\n        from backend.app.services.external_camera import list_usb_cameras\n\n        result = list_usb_cameras()\n        for camera in result:\n            assert \"device\" in camera\n            assert \"name\" in camera\n            assert camera[\"device\"].startswith(\"/dev/video\")\n\n    @pytest.mark.asyncio\n    async def test_capture_frame_usb_type_accepted(self):\n        \"\"\"Verify 'usb' camera type is accepted.\"\"\"\n        from backend.app.services.external_camera import capture_frame\n\n        # Non-existent device should fail gracefully\n        result = await capture_frame(\"/dev/video999\", \"usb\", timeout=1)\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_capture_frame_usb_invalid_device_path(self):\n        \"\"\"Verify invalid USB device paths are rejected.\"\"\"\n        from backend.app.services.external_camera import capture_frame\n\n        # Invalid device path (not /dev/video*)\n        result = await capture_frame(\"/dev/sda1\", \"usb\", timeout=1)\n        assert result is None\n\n        result = await capture_frame(\"http://example.com\", \"usb\", timeout=1)\n        assert result is None\n"
  },
  {
    "path": "backend/tests/unit/services/test_hms_errors.py",
    "content": "\"\"\"Tests for HMS error code translations.\"\"\"\n\nfrom backend.app.services.hms_errors import HMS_ERROR_DESCRIPTIONS, get_error_description\n\n\nclass TestHMSErrorDescriptions:\n    \"\"\"Tests for the HMS error descriptions dictionary.\"\"\"\n\n    def test_dictionary_is_not_empty(self):\n        \"\"\"Verify the error descriptions dictionary has entries.\"\"\"\n        assert len(HMS_ERROR_DESCRIPTIONS) > 0\n\n    def test_dictionary_has_expected_count(self):\n        \"\"\"Verify we have the expected number of error codes.\"\"\"\n        # Should have 853 error codes from the frontend\n        assert len(HMS_ERROR_DESCRIPTIONS) == 853\n\n    def test_all_keys_are_valid_format(self):\n        \"\"\"Verify all keys follow the XXXX_YYYY format.\"\"\"\n        import re\n\n        pattern = re.compile(r\"^[0-9A-F]{4}_[0-9A-F]{4}$\")\n        for code in HMS_ERROR_DESCRIPTIONS:\n            assert pattern.match(code), f\"Invalid error code format: {code}\"\n\n    def test_all_values_are_non_empty_strings(self):\n        \"\"\"Verify all descriptions are non-empty strings.\"\"\"\n        for code, description in HMS_ERROR_DESCRIPTIONS.items():\n            assert isinstance(description, str), f\"Description for {code} is not a string\"\n            assert len(description) > 0, f\"Description for {code} is empty\"\n\n\nclass TestGetErrorDescription:\n    \"\"\"Tests for the get_error_description function.\"\"\"\n\n    def test_returns_description_for_known_code(self):\n        \"\"\"Verify known error codes return their descriptions.\"\"\"\n        # 0300_400C = \"The task was canceled.\"\n        result = get_error_description(\"0300_400C\")\n        assert result == \"The task was canceled.\"\n\n    def test_returns_description_for_ams_error(self):\n        \"\"\"Verify AMS error codes return their descriptions.\"\"\"\n        # 0700_8010 = AMS assist motor overloaded\n        result = get_error_description(\"0700_8010\")\n        assert \"AMS assist motor\" in result\n\n    def test_returns_none_for_unknown_code(self):\n        \"\"\"Verify unknown error codes return None.\"\"\"\n        result = get_error_description(\"XXXX_YYYY\")\n        assert result is None\n\n    def test_handles_lowercase_input(self):\n        \"\"\"Verify function handles lowercase input.\"\"\"\n        result = get_error_description(\"0300_400c\")\n        assert result == \"The task was canceled.\"\n\n    def test_handles_mixed_case_input(self):\n        \"\"\"Verify function handles mixed case input.\"\"\"\n        result = get_error_description(\"0300_400C\")\n        assert result == \"The task was canceled.\"\n\n    def test_common_error_codes_have_descriptions(self):\n        \"\"\"Verify common error codes have descriptions.\"\"\"\n        common_codes = [\n            \"0300_4000\",  # Z axis homing failed\n            \"0300_4006\",  # Nozzle clogged\n            \"0300_8004\",  # Filament ran out\n            \"0500_4001\",  # Failed to connect to Bambu Cloud\n            \"0700_8010\",  # AMS assist motor overloaded\n        ]\n        for code in common_codes:\n            result = get_error_description(code)\n            assert result is not None, f\"Missing description for common code: {code}\"\n"
  },
  {
    "path": "backend/tests/unit/services/test_layer_timelapse.py",
    "content": "\"\"\"\nTests for the layer timelapse service.\n\nThese tests cover session management and pure logic functions.\n\"\"\"\n\nfrom datetime import datetime\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\n\nclass TestTimelapseSessionManagement:\n    \"\"\"Tests for timelapse session lifecycle.\"\"\"\n\n    def test_start_session_creates_new_session(self):\n        \"\"\"Verify start_session creates and registers a new session.\"\"\"\n        from backend.app.services.layer_timelapse import (\n            _active_sessions,\n            cancel_session,\n            get_session,\n            start_session,\n        )\n\n        # Clear any existing sessions\n        _active_sessions.clear()\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/tmp/test_bambuddy\")\n\n            session = start_session(\n                printer_id=1,\n                archive_id=100,\n                url=\"http://camera.local/mjpeg\",\n                cam_type=\"mjpeg\",\n            )\n\n            assert session is not None\n            assert session.printer_id == 1\n            assert session.archive_id == 100\n            assert session.camera_url == \"http://camera.local/mjpeg\"\n            assert session.camera_type == \"mjpeg\"\n            assert session.last_layer == -1\n            assert session.frame_count == 0\n\n            # Session should be retrievable\n            retrieved = get_session(1)\n            assert retrieved is session\n\n            # Cleanup\n            cancel_session(1)\n\n    def test_start_session_cancels_existing(self):\n        \"\"\"Verify starting a new session cancels any existing session.\"\"\"\n        from backend.app.services.layer_timelapse import (\n            _active_sessions,\n            cancel_session,\n            get_session,\n            start_session,\n        )\n\n        _active_sessions.clear()\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/tmp/test_bambuddy\")\n\n            # Start first session\n            session1 = start_session(1, 100, \"http://cam1/\", \"mjpeg\")\n\n            # Mock cleanup to track if it was called\n            session1.cleanup = MagicMock()\n\n            # Start second session for same printer\n            session2 = start_session(1, 101, \"http://cam2/\", \"rtsp\")\n\n            # First session should be replaced\n            current = get_session(1)\n            assert current is session2\n            assert current.archive_id == 101  # Verify it's the new session\n            assert current.camera_url == \"http://cam2/\"\n\n            # First session's cleanup should have been called\n            session1.cleanup.assert_called_once()\n\n            # Cleanup\n            cancel_session(1)\n\n    def test_get_session_returns_none_for_unknown(self):\n        \"\"\"Verify get_session returns None for unknown printer.\"\"\"\n        from backend.app.services.layer_timelapse import _active_sessions, get_session\n\n        _active_sessions.clear()\n\n        result = get_session(999)\n        assert result is None\n\n    def test_cancel_session_removes_and_cleans_up(self):\n        \"\"\"Verify cancel_session removes session and cleans up.\"\"\"\n        from backend.app.services.layer_timelapse import (\n            _active_sessions,\n            cancel_session,\n            get_session,\n            start_session,\n        )\n\n        _active_sessions.clear()\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/tmp/test_bambuddy\")\n\n            session = start_session(1, 100, \"http://cam/\", \"mjpeg\")\n\n            # Mock cleanup to avoid filesystem operations\n            session.cleanup = MagicMock()\n\n            cancel_session(1)\n\n            # Session should be removed\n            assert get_session(1) is None\n            # Cleanup should have been called\n            session.cleanup.assert_called_once()\n\n    def test_cancel_nonexistent_session_is_safe(self):\n        \"\"\"Verify canceling a non-existent session doesn't error.\"\"\"\n        from backend.app.services.layer_timelapse import _active_sessions, cancel_session\n\n        _active_sessions.clear()\n\n        # Should not raise\n        cancel_session(999)\n\n\nclass TestTimelapseSession:\n    \"\"\"Tests for TimelapseSession class.\"\"\"\n\n    def test_session_id_format(self):\n        \"\"\"Verify session ID follows expected datetime format.\"\"\"\n        from backend.app.services.layer_timelapse import TimelapseSession, _active_sessions\n\n        _active_sessions.clear()\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/tmp/test_bambuddy\")\n\n            session = TimelapseSession(\n                printer_id=1,\n                archive_id=100,\n                camera_url=\"http://test/\",\n                camera_type=\"mjpeg\",\n            )\n\n            # Session ID should be timestamp format YYYYMMDD_HHMMSS\n            assert len(session.session_id) == 15\n            assert session.session_id[8] == \"_\"\n\n            # Should be parseable as datetime\n            try:\n                datetime.strptime(session.session_id, \"%Y%m%d_%H%M%S\")\n            except ValueError:\n                pytest.fail(\"Session ID is not valid datetime format\")\n\n    def test_frames_dir_path_structure(self):\n        \"\"\"Verify frames directory path is structured correctly.\"\"\"\n        from backend.app.services.layer_timelapse import TimelapseSession\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/data/bambuddy\")\n\n            with patch.object(Path, \"mkdir\"):  # Avoid creating real directories\n                session = TimelapseSession(\n                    printer_id=42,\n                    archive_id=100,\n                    camera_url=\"http://test/\",\n                    camera_type=\"mjpeg\",\n                )\n\n                expected_path = Path(\"/data/bambuddy/timelapse_frames/42\") / session.session_id\n                assert session.frames_dir == expected_path\n\n\nclass TestLayerChangeLogic:\n    \"\"\"Tests for layer change capture logic.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_capture_layer_only_on_increase(self):\n        \"\"\"Verify frames are only captured when layer increases.\"\"\"\n        from backend.app.services.layer_timelapse import TimelapseSession\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/tmp/test\")\n\n            with patch.object(Path, \"mkdir\"):\n                session = TimelapseSession(1, 100, \"http://test/\", \"mjpeg\")\n\n                # Mock capture_frame to return data\n                with patch(\n                    \"backend.app.services.layer_timelapse.capture_frame\", new_callable=AsyncMock\n                ) as mock_capture:\n                    mock_capture.return_value = b\"\\xff\\xd8test\\xff\\xd9\"\n\n                    with patch.object(Path, \"write_bytes\"):\n                        # First layer should capture\n                        result = await session.capture_layer(1)\n                        assert result is True\n                        assert session.last_layer == 1\n                        assert session.frame_count == 1\n\n                        # Same layer should NOT capture\n                        result = await session.capture_layer(1)\n                        assert result is False\n                        assert session.frame_count == 1\n\n                        # Lower layer should NOT capture\n                        result = await session.capture_layer(0)\n                        assert result is False\n                        assert session.frame_count == 1\n\n                        # Higher layer should capture\n                        result = await session.capture_layer(5)\n                        assert result is True\n                        assert session.last_layer == 5\n                        assert session.frame_count == 2\n\n    @pytest.mark.asyncio\n    async def test_capture_layer_handles_failed_capture(self):\n        \"\"\"Verify failed capture returns False but updates layer.\"\"\"\n        from backend.app.services.layer_timelapse import TimelapseSession\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/tmp/test\")\n\n            with patch.object(Path, \"mkdir\"):\n                session = TimelapseSession(1, 100, \"http://test/\", \"mjpeg\")\n\n                # Mock capture_frame to return None (failure)\n                with patch(\n                    \"backend.app.services.layer_timelapse.capture_frame\", new_callable=AsyncMock\n                ) as mock_capture:\n                    mock_capture.return_value = None\n\n                    result = await session.capture_layer(1)\n\n                    assert result is False\n                    assert session.last_layer == 1  # Layer is still updated\n                    assert session.frame_count == 0  # But frame count not incremented\n\n\nclass TestOnLayerChange:\n    \"\"\"Tests for the on_layer_change callback.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_on_layer_change_captures_when_session_exists(self):\n        \"\"\"Verify on_layer_change triggers capture when session exists.\"\"\"\n        from backend.app.services.layer_timelapse import (\n            _active_sessions,\n            cancel_session,\n            on_layer_change,\n            start_session,\n        )\n\n        _active_sessions.clear()\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/tmp/test\")\n\n            with patch.object(Path, \"mkdir\"):\n                session = start_session(1, 100, \"http://test/\", \"mjpeg\")\n\n                with patch.object(session, \"capture_layer\", new_callable=AsyncMock) as mock_capture:\n                    mock_capture.return_value = True\n\n                    await on_layer_change(1, 5)\n\n                    mock_capture.assert_called_once_with(5)\n\n                cancel_session(1)\n\n    @pytest.mark.asyncio\n    async def test_on_layer_change_does_nothing_without_session(self):\n        \"\"\"Verify on_layer_change is safe when no session exists.\"\"\"\n        from backend.app.services.layer_timelapse import _active_sessions, on_layer_change\n\n        _active_sessions.clear()\n\n        # Should not raise\n        await on_layer_change(999, 10)\n\n\nclass TestGetActiveSessions:\n    \"\"\"Tests for get_active_sessions.\"\"\"\n\n    def test_get_active_sessions_returns_copy(self):\n        \"\"\"Verify get_active_sessions returns a copy, not the original dict.\"\"\"\n        from backend.app.services.layer_timelapse import (\n            _active_sessions,\n            cancel_session,\n            get_active_sessions,\n            start_session,\n        )\n\n        _active_sessions.clear()\n\n        with patch(\"backend.app.services.layer_timelapse.settings\") as mock_settings:\n            mock_settings.base_dir = Path(\"/tmp/test\")\n\n            with patch.object(Path, \"mkdir\"):\n                start_session(1, 100, \"http://test/\", \"mjpeg\")\n\n                sessions = get_active_sessions()\n\n                # Should be a copy\n                assert sessions is not _active_sessions\n                assert 1 in sessions\n\n                # Modifying copy shouldn't affect original\n                sessions.clear()\n                assert 1 in _active_sessions\n\n                cancel_session(1)\n"
  },
  {
    "path": "backend/tests/unit/services/test_ldap_service.py",
    "content": "\"\"\"Tests for LDAP authentication service (#794).\n\nTests the pure logic functions in ldap_service.py:\n- Config parsing from settings dict\n- LDAP filter escaping (RFC 4515)\n- Group mapping resolution\n- LDAPConfig/LDAPUserInfo dataclass construction\n\nNetwork-dependent functions (authenticate_ldap_user, test_ldap_connection)\nare not tested here — they require a live LDAP server.\n\"\"\"\n\nimport pytest\n\nfrom backend.app.services.ldap_service import (\n    LDAPConfig,\n    LDAPUserInfo,\n    _ldap_escape,\n    authenticate_ldap_user,\n    parse_ldap_config,\n    resolve_group_mapping,\n)\n\n\nclass TestParseConfig:\n    \"\"\"Verify parse_ldap_config builds LDAPConfig from settings dict.\"\"\"\n\n    def test_returns_none_when_disabled(self):\n        settings = {\"ldap_enabled\": \"false\", \"ldap_server_url\": \"ldaps://example.com\"}\n        assert parse_ldap_config(settings) is None\n\n    def test_returns_none_when_missing_enabled(self):\n        settings = {\"ldap_server_url\": \"ldaps://example.com\"}\n        assert parse_ldap_config(settings) is None\n\n    def test_returns_none_when_no_server_url(self):\n        settings = {\"ldap_enabled\": \"true\", \"ldap_server_url\": \"\"}\n        assert parse_ldap_config(settings) is None\n\n    def test_returns_none_when_server_url_whitespace(self):\n        settings = {\"ldap_enabled\": \"true\", \"ldap_server_url\": \"   \"}\n        assert parse_ldap_config(settings) is None\n\n    def test_parses_minimal_config(self):\n        settings = {\n            \"ldap_enabled\": \"true\",\n            \"ldap_server_url\": \"ldaps://ldap.example.com:636\",\n        }\n        config = parse_ldap_config(settings)\n        assert config is not None\n        assert config.server_url == \"ldaps://ldap.example.com:636\"\n        assert config.bind_dn == \"\"\n        assert config.search_base == \"\"\n        assert config.user_filter == \"(sAMAccountName={username})\"\n        assert config.security == \"starttls\"\n        assert config.group_mapping == {}\n        assert config.auto_provision is False\n        assert config.ca_cert_path == \"\"\n        assert config.default_group == \"\"\n\n    def test_parses_full_config(self):\n        settings = {\n            \"ldap_enabled\": \"true\",\n            \"ldap_server_url\": \"ldaps://ldap.example.com:636\",\n            \"ldap_bind_dn\": \"cn=admin,dc=example,dc=com\",\n            \"ldap_bind_password\": \"secret\",\n            \"ldap_search_base\": \"ou=users,dc=example,dc=com\",\n            \"ldap_user_filter\": \"(uid={username})\",\n            \"ldap_security\": \"ldaps\",\n            \"ldap_group_mapping\": '{\"cn=admins,dc=example,dc=com\": \"Administrators\"}',\n            \"ldap_auto_provision\": \"true\",\n            \"ldap_ca_cert_path\": \"/path/to/ca.pem\",\n            \"ldap_default_group\": \"Viewers\",\n        }\n        config = parse_ldap_config(settings)\n        assert config is not None\n        assert config.bind_dn == \"cn=admin,dc=example,dc=com\"\n        assert config.bind_password == \"secret\"\n        assert config.search_base == \"ou=users,dc=example,dc=com\"\n        assert config.user_filter == \"(uid={username})\"\n        assert config.security == \"ldaps\"\n        assert config.group_mapping == {\"cn=admins,dc=example,dc=com\": \"Administrators\"}\n        assert config.auto_provision is True\n        assert config.ca_cert_path == \"/path/to/ca.pem\"\n        assert config.default_group == \"Viewers\"\n\n    def test_handles_invalid_group_mapping_json(self):\n        settings = {\n            \"ldap_enabled\": \"true\",\n            \"ldap_server_url\": \"ldaps://ldap.example.com\",\n            \"ldap_group_mapping\": \"not valid json\",\n        }\n        config = parse_ldap_config(settings)\n        assert config is not None\n        assert config.group_mapping == {}\n\n    def test_handles_non_dict_group_mapping(self):\n        settings = {\n            \"ldap_enabled\": \"true\",\n            \"ldap_server_url\": \"ldaps://ldap.example.com\",\n            \"ldap_group_mapping\": '[\"not\", \"a\", \"dict\"]',\n        }\n        config = parse_ldap_config(settings)\n        assert config is not None\n        assert config.group_mapping == {}\n\n    def test_enabled_case_insensitive(self):\n        settings = {\"ldap_enabled\": \"True\", \"ldap_server_url\": \"ldaps://ldap.example.com\"}\n        assert parse_ldap_config(settings) is not None\n\n        settings = {\"ldap_enabled\": \"TRUE\", \"ldap_server_url\": \"ldaps://ldap.example.com\"}\n        assert parse_ldap_config(settings) is not None\n\n    def test_strips_whitespace(self):\n        settings = {\n            \"ldap_enabled\": \"true\",\n            \"ldap_server_url\": \"  ldaps://ldap.example.com  \",\n            \"ldap_bind_dn\": \"  cn=admin,dc=example,dc=com  \",\n            \"ldap_search_base\": \"  dc=example,dc=com  \",\n            \"ldap_default_group\": \"  Viewers  \",\n        }\n        config = parse_ldap_config(settings)\n        assert config.server_url == \"ldaps://ldap.example.com\"\n        assert config.bind_dn == \"cn=admin,dc=example,dc=com\"\n        assert config.search_base == \"dc=example,dc=com\"\n        assert config.default_group == \"Viewers\"\n\n\nclass TestLDAPEscape:\n    \"\"\"Verify RFC 4515 escaping for LDAP search filter values.\"\"\"\n\n    def test_plain_string(self):\n        assert _ldap_escape(\"testuser\") == \"testuser\"\n\n    def test_escapes_backslash(self):\n        assert _ldap_escape(\"test\\\\user\") == \"test\\\\5cuser\"\n\n    def test_escapes_asterisk(self):\n        assert _ldap_escape(\"test*user\") == \"test\\\\2auser\"\n\n    def test_escapes_open_paren(self):\n        assert _ldap_escape(\"test(user\") == \"test\\\\28user\"\n\n    def test_escapes_close_paren(self):\n        assert _ldap_escape(\"test)user\") == \"test\\\\29user\"\n\n    def test_escapes_null(self):\n        assert _ldap_escape(\"test\\x00user\") == \"test\\\\00user\"\n\n    def test_escapes_multiple_chars(self):\n        assert _ldap_escape(\"a*b(c)d\\\\e\") == \"a\\\\2ab\\\\28c\\\\29d\\\\5ce\"\n\n    def test_empty_string(self):\n        assert _ldap_escape(\"\") == \"\"\n\n\nclass TestResolveGroupMapping:\n    \"\"\"Verify LDAP group DN to BamBuddy group name resolution.\"\"\"\n\n    def test_empty_mapping(self):\n        assert resolve_group_mapping([\"cn=admins,dc=example\"], {}) == []\n\n    def test_empty_groups(self):\n        mapping = {\"cn=admins,dc=example\": \"Administrators\"}\n        assert resolve_group_mapping([], mapping) == []\n\n    def test_single_match(self):\n        mapping = {\"cn=admins,dc=example,dc=com\": \"Administrators\"}\n        groups = [\"cn=admins,dc=example,dc=com\"]\n        assert resolve_group_mapping(groups, mapping) == [\"Administrators\"]\n\n    def test_multiple_matches(self):\n        mapping = {\n            \"cn=admins,dc=example,dc=com\": \"Administrators\",\n            \"cn=ops,dc=example,dc=com\": \"Operators\",\n        }\n        groups = [\"cn=admins,dc=example,dc=com\", \"cn=ops,dc=example,dc=com\"]\n        result = resolve_group_mapping(groups, mapping)\n        assert set(result) == {\"Administrators\", \"Operators\"}\n\n    def test_no_match(self):\n        mapping = {\"cn=admins,dc=example,dc=com\": \"Administrators\"}\n        groups = [\"cn=users,dc=example,dc=com\"]\n        assert resolve_group_mapping(groups, mapping) == []\n\n    def test_case_insensitive_dn(self):\n        mapping = {\"CN=Admins,DC=Example,DC=Com\": \"Administrators\"}\n        groups = [\"cn=admins,dc=example,dc=com\"]\n        assert resolve_group_mapping(groups, mapping) == [\"Administrators\"]\n\n    def test_partial_match_not_matched(self):\n        mapping = {\"cn=admins,dc=example,dc=com\": \"Administrators\"}\n        groups = [\"cn=admins,dc=other,dc=com\"]\n        assert resolve_group_mapping(groups, mapping) == []\n\n    def test_extra_groups_ignored(self):\n        mapping = {\"cn=admins,dc=example,dc=com\": \"Administrators\"}\n        groups = [\"cn=admins,dc=example,dc=com\", \"cn=users,dc=example,dc=com\", \"cn=devs,dc=example,dc=com\"]\n        assert resolve_group_mapping(groups, mapping) == [\"Administrators\"]\n\n\nclass TestDataclasses:\n    \"\"\"Verify dataclass construction.\"\"\"\n\n    def test_ldap_user_info(self):\n        info = LDAPUserInfo(\n            username=\"testuser\",\n            email=\"test@example.com\",\n            display_name=\"Test User\",\n            groups=[\"cn=admins,dc=example,dc=com\"],\n        )\n        assert info.username == \"testuser\"\n        assert info.email == \"test@example.com\"\n        assert info.display_name == \"Test User\"\n        assert info.groups == [\"cn=admins,dc=example,dc=com\"]\n\n    def test_ldap_user_info_none_fields(self):\n        info = LDAPUserInfo(username=\"testuser\", email=None, display_name=None, groups=[])\n        assert info.email is None\n        assert info.display_name is None\n        assert info.groups == []\n\n    def test_ldap_config(self):\n        config = LDAPConfig(\n            server_url=\"ldaps://ldap.example.com:636\",\n            bind_dn=\"cn=admin,dc=example,dc=com\",\n            bind_password=\"secret\",\n            search_base=\"dc=example,dc=com\",\n            user_filter=\"(uid={username})\",\n            security=\"ldaps\",\n            group_mapping={\"cn=admins\": \"Administrators\"},\n            auto_provision=True,\n            ca_cert_path=\"\",\n            default_group=\"Viewers\",\n        )\n        assert config.server_url == \"ldaps://ldap.example.com:636\"\n        assert config.auto_provision is True\n        assert config.default_group == \"Viewers\"\n\n\n# ---------------------------------------------------------------------------\n# Mocked authenticate_ldap_user group-discovery tests\n# ---------------------------------------------------------------------------\n# These tests mock ldap3.Connection to exercise the group-discovery logic in\n# authenticate_ldap_user without a live LDAP server. Added after a bug where\n# POSIX primary-group membership (via gidNumber) was ignored — see CHANGELOG.\n\n\nclass _MockAttr:\n    \"\"\"Minimal stand-in for ldap3 Attribute objects.\n\n    Supports str(), bool(), .value, .values, and iteration — the operations\n    used by ldap_service against user entry attributes.\n    \"\"\"\n\n    def __init__(self, value):\n        self._value = value\n\n    @property\n    def value(self):\n        return self._value\n\n    @property\n    def values(self):\n        return self._value if isinstance(self._value, list) else [self._value]\n\n    def __str__(self):\n        return str(self._value)\n\n    def __bool__(self):\n        return bool(self._value)\n\n    def __iter__(self):\n        if isinstance(self._value, list):\n            return iter(self._value)\n        return iter([self._value])\n\n\nclass _MockEntry:\n    \"\"\"Minimal stand-in for ldap3 Entry. Only attributes passed at construction exist.\"\"\"\n\n    def __init__(self, dn, **attrs):\n        self.entry_dn = dn\n        for key, val in attrs.items():\n            setattr(self, key, _MockAttr(val))\n\n\nclass _MockConnection:\n    \"\"\"Mock ldap3 Connection that returns pre-configured entries based on filter substring match.\n\n    Every Connection() instance shares a class-level fixture dict so the service-account\n    connection and the user-bind connection both see the same fake directory.\n    \"\"\"\n\n    _search_fixture: dict[str, list] = {}\n    _instances: list[\"_MockConnection\"] = []\n\n    def __init__(self, *args, **kwargs):\n        self.entries: list = []\n        self.search_calls: list[str] = []\n        _MockConnection._instances.append(self)\n\n    def open(self):\n        pass\n\n    def start_tls(self):\n        pass\n\n    def bind(self):\n        return True\n\n    def unbind(self):\n        pass\n\n    def search(self, search_base=None, search_filter=None, search_scope=None, attributes=None):\n        self.search_calls.append(search_filter or \"\")\n        for needle, entries in _MockConnection._search_fixture.items():\n            if needle in (search_filter or \"\"):\n                self.entries = entries\n                return True\n        self.entries = []\n        return True\n\n\n@pytest.fixture\ndef mock_ldap(monkeypatch):\n    \"\"\"Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline.\"\"\"\n    _MockConnection._search_fixture = {}\n    _MockConnection._instances = []\n    monkeypatch.setattr(\"backend.app.services.ldap_service.Connection\", _MockConnection)\n    monkeypatch.setattr(\"backend.app.services.ldap_service._create_server\", lambda config: None)\n    return _MockConnection\n\n\ndef _base_config(**overrides):\n    \"\"\"Build a minimal LDAPConfig for mocked tests.\"\"\"\n    defaults = {\n        \"server_url\": \"ldaps://test.example.com:636\",\n        \"bind_dn\": \"cn=admin,dc=test,dc=com\",\n        \"bind_password\": \"x\",\n        \"search_base\": \"dc=test,dc=com\",\n        \"user_filter\": \"(uid={username})\",\n        \"security\": \"ldaps\",\n        \"group_mapping\": {},\n        \"auto_provision\": False,\n        \"ca_cert_path\": \"\",\n        \"default_group\": \"\",\n    }\n    defaults.update(overrides)\n    return LDAPConfig(**defaults)\n\n\nclass TestAuthenticateLdapUserGroups:\n    \"\"\"Group-discovery behaviour in authenticate_ldap_user.\n\n    Covers the POSIX primary gidNumber lookup and case-insensitive dedupe added\n    to fix a bug where users whose role came from their primary group were\n    authenticated without the correct group membership.\n    \"\"\"\n\n    def test_primary_gidnumber_group_found(self, mock_ldap):\n        \"\"\"Regression: POSIX primary group (gidNumber match) must be included in the result.\"\"\"\n        user_entry = _MockEntry(\"cn=mz,dc=test,dc=com\", uid=\"mz\", gidNumber=10002)\n        operators_group = _MockEntry(\"cn=bambuddy-operators,ou=groups,dc=test,dc=com\")\n\n        mock_ldap._search_fixture = {\n            \"(uid=mz)\": [user_entry],\n            \"memberUid=mz\": [],  # no supplementary memberships\n            \"gidNumber=10002\": [operators_group],\n        }\n\n        info = authenticate_ldap_user(_base_config(), \"mz\", \"password\")\n\n        assert info is not None\n        assert info.groups == [\"cn=bambuddy-operators,ou=groups,dc=test,dc=com\"]\n\n    def test_dedupes_group_found_via_both_memberuid_and_primary_gid(self, mock_ldap):\n        \"\"\"A user in the same group via BOTH memberUid and primary gidNumber should appear once.\"\"\"\n        user_entry = _MockEntry(\"cn=mz,dc=test,dc=com\", uid=\"mz\", gidNumber=10002)\n        group_entry = _MockEntry(\"cn=bambuddy-operators,ou=groups,dc=test,dc=com\")\n\n        mock_ldap._search_fixture = {\n            \"(uid=mz)\": [user_entry],\n            \"memberUid=mz\": [group_entry],  # supplementary membership\n            \"gidNumber=10002\": [group_entry],  # primary group — same DN\n        }\n\n        info = authenticate_ldap_user(_base_config(), \"mz\", \"password\")\n\n        assert info.groups == [\"cn=bambuddy-operators,ou=groups,dc=test,dc=com\"]\n\n    def test_case_insensitive_dedupe(self, mock_ldap):\n        \"\"\"DNs differing only in case should collapse to a single entry (LDAP DNs are case-insensitive).\"\"\"\n        user_entry = _MockEntry(\"cn=mz,dc=test,dc=com\", uid=\"mz\", gidNumber=10002)\n        upper_dn = _MockEntry(\"CN=Bambuddy-Operators,OU=Groups,DC=Test,DC=Com\")\n        lower_dn = _MockEntry(\"cn=bambuddy-operators,ou=groups,dc=test,dc=com\")\n\n        mock_ldap._search_fixture = {\n            \"(uid=mz)\": [user_entry],\n            \"memberUid=mz\": [upper_dn],\n            \"gidNumber=10002\": [lower_dn],\n        }\n\n        info = authenticate_ldap_user(_base_config(), \"mz\", \"password\")\n\n        assert len(info.groups) == 1\n        # The first-seen casing (memberUid result) is kept.\n        assert info.groups[0] == \"CN=Bambuddy-Operators,OU=Groups,DC=Test,DC=Com\"\n\n    def test_no_gidnumber_skips_primary_search(self, mock_ldap):\n        \"\"\"User entries without a gidNumber attribute should not crash and should not issue the primary-gid query.\"\"\"\n        user_entry = _MockEntry(\"cn=tester,dc=test,dc=com\", uid=\"tester\")  # no gidNumber\n        viewers_group = _MockEntry(\"cn=bambuddy-viewers,ou=groups,dc=test,dc=com\")\n\n        mock_ldap._search_fixture = {\n            \"(uid=tester)\": [user_entry],\n            \"memberUid=tester\": [viewers_group],\n        }\n\n        info = authenticate_ldap_user(_base_config(), \"tester\", \"password\")\n\n        assert info is not None\n        assert info.groups == [\"cn=bambuddy-viewers,ou=groups,dc=test,dc=com\"]\n        # Ensure the primary-gidNumber search was never issued — verifying the guard works.\n        service_conn = _MockConnection._instances[0]\n        gidnumber_searches = [call for call in service_conn.search_calls if \"gidNumber=\" in call]\n        assert gidnumber_searches == []\n"
  },
  {
    "path": "backend/tests/unit/services/test_mqtt_smart_plug_subscribe.py",
    "content": "\"\"\"\nTests for subscribe_plug_to_mqtt — the shared helper that resolves a\nSmartPlug row's per-type topic fields (with legacy fallback) and calls\nMQTTSmartPlugService.subscribe().\n\nRegression guard for #1010, where the startup-restore code path had\ndrifted from the create/update routes: it only looked at the legacy\n`mqtt_topic` field and silently skipped plugs whose topics were set\nonly in the newer per-type fields, so the MQTT smart-plug subscription\nwas lost on every Bambuddy restart until the user re-saved the plug.\n\"\"\"\n\nfrom types import SimpleNamespace\nfrom unittest.mock import MagicMock\n\nfrom backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt\n\n\ndef _plug(**overrides):\n    \"\"\"Build a SmartPlug-shaped record. All fields default to None/defaults.\"\"\"\n    defaults = {\n        \"id\": 1,\n        \"mqtt_topic\": None,\n        \"mqtt_power_topic\": None,\n        \"mqtt_power_path\": None,\n        \"mqtt_power_multiplier\": None,\n        \"mqtt_energy_topic\": None,\n        \"mqtt_energy_path\": None,\n        \"mqtt_energy_multiplier\": None,\n        \"mqtt_state_topic\": None,\n        \"mqtt_state_path\": None,\n        \"mqtt_state_on_value\": None,\n        \"mqtt_multiplier\": None,\n    }\n    defaults.update(overrides)\n    return SimpleNamespace(**defaults)\n\n\ndef test_per_type_topics_restored_without_legacy_mqtt_topic():\n    \"\"\"#1010: plug configured only with per-type topics must still subscribe.\"\"\"\n    service = MagicMock()\n    plug = _plug(\n        id=42,\n        mqtt_power_topic=\"shellies/plug-living/power\",\n        mqtt_power_path=\"value\",\n        mqtt_state_topic=\"shellies/plug-living/relay/0\",\n        mqtt_state_on_value=\"on\",\n    )\n\n    topics = subscribe_plug_to_mqtt(service, plug)\n\n    service.subscribe.assert_called_once()\n    kwargs = service.subscribe.call_args.kwargs\n    assert kwargs[\"plug_id\"] == 42\n    assert kwargs[\"power_topic\"] == \"shellies/plug-living/power\"\n    assert kwargs[\"power_path\"] == \"value\"\n    assert kwargs[\"state_topic\"] == \"shellies/plug-living/relay/0\"\n    assert kwargs[\"state_on_value\"] == \"on\"\n    # energy wasn't configured, so no per-type topic\n    assert kwargs[\"energy_topic\"] is None\n    assert set(topics) == {\"shellies/plug-living/power\", \"shellies/plug-living/relay/0\"}\n\n\ndef test_legacy_single_topic_falls_back_for_all_data_types():\n    \"\"\"Backward-compat: a plug with only the legacy mqtt_topic must still work.\"\"\"\n    service = MagicMock()\n    plug = _plug(\n        id=7,\n        mqtt_topic=\"zigbee2mqtt/shelly-office\",\n        mqtt_power_path=\"power\",\n        mqtt_energy_path=\"energy\",\n        mqtt_state_path=\"state\",\n        mqtt_state_on_value=\"ON\",\n        mqtt_multiplier=0.001,  # legacy\n    )\n\n    topics = subscribe_plug_to_mqtt(service, plug)\n\n    kwargs = service.subscribe.call_args.kwargs\n    assert kwargs[\"power_topic\"] == \"zigbee2mqtt/shelly-office\"\n    assert kwargs[\"energy_topic\"] == \"zigbee2mqtt/shelly-office\"\n    assert kwargs[\"state_topic\"] == \"zigbee2mqtt/shelly-office\"\n    # Legacy multiplier flows through for both power and energy.\n    assert kwargs[\"power_multiplier\"] == 0.001\n    assert kwargs[\"energy_multiplier\"] == 0.001\n    assert topics == [\"zigbee2mqtt/shelly-office\"]\n\n\ndef test_per_type_multipliers_override_legacy():\n    service = MagicMock()\n    plug = _plug(\n        mqtt_power_topic=\"t/power\",\n        mqtt_power_multiplier=0.5,\n        mqtt_energy_topic=\"t/energy\",\n        mqtt_energy_multiplier=0.25,\n        mqtt_multiplier=9.0,  # should be overridden by per-type values\n    )\n\n    subscribe_plug_to_mqtt(service, plug)\n\n    kwargs = service.subscribe.call_args.kwargs\n    assert kwargs[\"power_multiplier\"] == 0.5\n    assert kwargs[\"energy_multiplier\"] == 0.25\n\n\ndef test_per_type_topics_beat_legacy_topic_when_both_set():\n    \"\"\"If both legacy and per-type topic are set, per-type wins.\"\"\"\n    service = MagicMock()\n    plug = _plug(\n        mqtt_topic=\"old/topic\",\n        mqtt_power_topic=\"new/power\",\n        mqtt_energy_topic=\"new/energy\",\n    )\n\n    subscribe_plug_to_mqtt(service, plug)\n\n    kwargs = service.subscribe.call_args.kwargs\n    assert kwargs[\"power_topic\"] == \"new/power\"\n    assert kwargs[\"energy_topic\"] == \"new/energy\"\n    # state has no per-type topic set, so it falls back to legacy\n    assert kwargs[\"state_topic\"] == \"old/topic\"\n\n\ndef test_no_topics_configured_skips_subscribe():\n    \"\"\"Nothing to subscribe to means the service is not touched.\"\"\"\n    service = MagicMock()\n    plug = _plug(id=99)  # all fields None\n\n    topics = subscribe_plug_to_mqtt(service, plug)\n\n    service.subscribe.assert_not_called()\n    assert topics == []\n\n\ndef test_returns_unique_topic_list_when_same_topic_used_for_multiple_types():\n    service = MagicMock()\n    plug = _plug(\n        mqtt_power_topic=\"shared/topic\",\n        mqtt_energy_topic=\"shared/topic\",\n        mqtt_state_topic=\"shared/topic\",\n    )\n\n    topics = subscribe_plug_to_mqtt(service, plug)\n\n    assert topics == [\"shared/topic\"]\n"
  },
  {
    "path": "backend/tests/unit/services/test_notification_service.py",
    "content": "\"\"\"Unit tests for NotificationService.\n\nTests event-based notifications and toggle behavior.\n\"\"\"\n\nimport json\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.notification_service import NotificationService\n\n\nclass TestNotificationService:\n    \"\"\"Tests for NotificationService class.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        \"\"\"Create a fresh NotificationService instance.\"\"\"\n        return NotificationService()\n\n    @pytest.fixture\n    def mock_provider(self):\n        \"\"\"Create a mock notification provider.\"\"\"\n        provider = MagicMock()\n        provider.id = 1\n        provider.name = \"Test Provider\"\n        provider.provider_type = \"webhook\"\n        provider.enabled = True\n        provider.config = json.dumps({\"webhook_url\": \"http://test.local/webhook\"})\n        provider.on_print_start = True\n        provider.on_print_complete = True\n        provider.on_print_failed = True\n        provider.on_print_stopped = False\n        provider.on_print_progress = False\n        provider.on_printer_offline = False\n        provider.on_printer_error = False\n        provider.on_filament_low = False\n        provider.on_maintenance_due = False\n        provider.on_ams_humidity_high = False\n        provider.on_ams_temperature_high = False\n        provider.quiet_hours_enabled = False\n        provider.quiet_hours_start = None\n        provider.quiet_hours_end = None\n        provider.daily_digest_enabled = False\n        provider.daily_digest_time = None\n        provider.printer_id = None\n        return provider\n\n    @pytest.fixture\n    def mock_db(self):\n        \"\"\"Create a mock database session.\"\"\"\n        db = AsyncMock()\n        db.commit = AsyncMock()\n        return db\n\n    # ========================================================================\n    # Tests for on_print_start\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_sends_notification(self, service, mock_provider, mock_db):\n        \"\"\"Verify notification is sent when print starts.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Print Started\", \"Test Printer: test.3mf\")\n\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                data={\"filename\": \"test.3mf\", \"subtask_name\": \"test\"},\n                db=mock_db,\n            )\n\n            mock_get.assert_called_once()\n            mock_send.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_skipped_when_no_providers(self, service, mock_db):\n        \"\"\"Verify no error when no providers are configured for event.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n        ):\n            mock_get.return_value = []\n\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                data={},\n                db=mock_db,\n            )\n\n            mock_send.assert_not_called()\n\n    # ========================================================================\n    # Tests for on_print_complete (status routing)\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_routes_completed_status(self, service, mock_provider, mock_db):\n        \"\"\"Verify completed status uses on_print_complete field.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Test\", \"Test\")\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"completed\",\n                data={},\n                db=mock_db,\n            )\n\n            # Verify the correct event field was queried\n            call_args = mock_get.call_args\n            assert call_args[0][1] == \"on_print_complete\"\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_routes_failed_status(self, service, mock_provider, mock_db):\n        \"\"\"Verify failed status uses on_print_failed field.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Test\", \"Test\")\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"failed\",\n                data={},\n                db=mock_db,\n            )\n\n            call_args = mock_get.call_args\n            assert call_args[0][1] == \"on_print_failed\"\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_routes_stopped_status(self, service, mock_provider, mock_db):\n        \"\"\"Verify stopped status uses on_print_stopped field.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Test\", \"Test\")\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"stopped\",\n                data={},\n                db=mock_db,\n            )\n\n            call_args = mock_get.call_args\n            assert call_args[0][1] == \"on_print_stopped\"\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_routes_aborted_status(self, service, mock_provider, mock_db):\n        \"\"\"Verify aborted status uses on_print_stopped field.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Test\", \"Test\")\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"aborted\",\n                data={},\n                db=mock_db,\n            )\n\n            call_args = mock_get.call_args\n            assert call_args[0][1] == \"on_print_stopped\"\n\n    # ========================================================================\n    # Tests for provider filtering\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_disabled_provider_not_returned(self, service, mock_provider, mock_db):\n        \"\"\"CRITICAL: Verify disabled providers don't receive notifications.\"\"\"\n        mock_provider.enabled = False\n\n        # The actual filtering happens in _get_providers_for_event\n        # which queries only enabled providers\n        with patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get:\n            # Simulate the query filtering out disabled providers\n            mock_get.return_value = []\n\n            result = await service._get_providers_for_event(mock_db, \"on_print_start\", printer_id=1)\n\n            assert len(result) == 0\n\n    @pytest.mark.asyncio\n    async def test_provider_filtered_by_printer_id(self, service, mock_provider, mock_db):\n        \"\"\"Verify providers can be filtered by specific printer.\"\"\"\n        mock_provider.printer_id = 2  # Linked to printer 2\n\n        with patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get:\n            # When querying for printer 1, provider linked to printer 2 is excluded\n            mock_get.return_value = []\n\n            result = await service._get_providers_for_event(mock_db, \"on_print_start\", printer_id=1)\n\n            assert len(result) == 0\n\n    # ========================================================================\n    # Tests for quiet hours\n    # ========================================================================\n\n    def test_is_in_quiet_hours_during_quiet_period(self, service, mock_provider):\n        \"\"\"Verify notifications are blocked during quiet hours.\"\"\"\n        mock_provider.quiet_hours_enabled = True\n        mock_provider.quiet_hours_start = \"22:00\"\n        mock_provider.quiet_hours_end = \"07:00\"\n\n        with patch(\"backend.app.services.notification_service.datetime\") as mock_datetime:\n            # Test during quiet hours (23:00)\n            mock_now = MagicMock()\n            mock_now.hour = 23\n            mock_now.minute = 0\n            mock_datetime.now.return_value = mock_now\n\n            result = service._is_in_quiet_hours(mock_provider)\n\n            assert result is True\n\n    def test_is_in_quiet_hours_outside_quiet_period(self, service, mock_provider):\n        \"\"\"Verify notifications are allowed outside quiet hours.\"\"\"\n        mock_provider.quiet_hours_enabled = True\n        mock_provider.quiet_hours_start = \"22:00\"\n        mock_provider.quiet_hours_end = \"07:00\"\n\n        with patch(\"backend.app.services.notification_service.datetime\") as mock_datetime:\n            # Test outside quiet hours (12:00)\n            mock_now = MagicMock()\n            mock_now.hour = 12\n            mock_now.minute = 0\n            mock_datetime.now.return_value = mock_now\n\n            result = service._is_in_quiet_hours(mock_provider)\n\n            assert result is False\n\n    def test_is_in_quiet_hours_disabled(self, service, mock_provider):\n        \"\"\"Verify quiet hours check returns False when disabled.\"\"\"\n        mock_provider.quiet_hours_enabled = False\n\n        result = service._is_in_quiet_hours(mock_provider)\n\n        assert result is False\n\n    def test_is_in_quiet_hours_early_morning(self, service, mock_provider):\n        \"\"\"Verify quiet hours work across midnight (early morning).\"\"\"\n        mock_provider.quiet_hours_enabled = True\n        mock_provider.quiet_hours_start = \"22:00\"\n        mock_provider.quiet_hours_end = \"07:00\"\n\n        with patch(\"backend.app.services.notification_service.datetime\") as mock_datetime:\n            # Test early morning (03:00) - should be in quiet hours\n            mock_now = MagicMock()\n            mock_now.hour = 3\n            mock_now.minute = 0\n            mock_datetime.now.return_value = mock_now\n\n            result = service._is_in_quiet_hours(mock_provider)\n\n            assert result is True\n\n    # ========================================================================\n    # Tests for AMS alarms\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_on_ams_humidity_high_sends_notification(self, service, mock_provider, mock_db):\n        \"\"\"Verify AMS humidity alarm sends notification.\"\"\"\n        mock_provider.on_ams_humidity_high = True\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"AMS Humidity Alert\", \"High humidity detected\")\n\n            await service.on_ams_humidity_high(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                ams_label=\"AMS-A\",\n                humidity=75.0,\n                threshold=60.0,\n                db=mock_db,\n            )\n\n            mock_send.assert_called_once()\n            # Verify force_immediate is True for alarms\n            call_kwargs = mock_send.call_args[1]\n            assert call_kwargs.get(\"force_immediate\") is True\n\n    @pytest.mark.asyncio\n    async def test_on_ams_temperature_high_sends_notification(self, service, mock_provider, mock_db):\n        \"\"\"Verify AMS temperature alarm sends notification.\"\"\"\n        mock_provider.on_ams_temperature_high = True\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"AMS Temperature Alert\", \"High temp detected\")\n\n            await service.on_ams_temperature_high(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                ams_label=\"AMS-A\",\n                temperature=40.0,\n                threshold=35.0,\n                db=mock_db,\n            )\n\n            mock_send.assert_called_once()\n            # Verify force_immediate is True for alarms\n            call_kwargs = mock_send.call_args[1]\n            assert call_kwargs.get(\"force_immediate\") is True\n\n    @pytest.mark.asyncio\n    async def test_ams_alarm_skipped_when_toggle_disabled(self, service, mock_provider, mock_db):\n        \"\"\"CRITICAL: Verify AMS alarms respect toggle setting.\"\"\"\n        mock_provider.on_ams_humidity_high = False\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n        ):\n            # Provider with toggle disabled won't be returned\n            mock_get.return_value = []\n\n            await service.on_ams_humidity_high(\n                printer_id=1,\n                printer_name=\"Test\",\n                ams_label=\"AMS-A\",\n                humidity=75.0,\n                threshold=60.0,\n                db=mock_db,\n            )\n\n            mock_send.assert_not_called()\n\n    # ========================================================================\n    # Tests for daily digest\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_daily_digest_queues_notification(self, service, mock_provider, mock_db):\n        \"\"\"Verify notifications are queued when digest mode is enabled.\"\"\"\n        mock_provider.daily_digest_enabled = True\n        mock_provider.daily_digest_time = \"09:00\"\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Test\", \"Test\")\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"completed\",\n                data={},\n                db=mock_db,\n            )\n\n            # When digest is enabled, _send_to_providers should still be called\n            # but internally it will queue instead of send immediately\n            mock_send.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_force_immediate_bypasses_digest(self, service, mock_provider, mock_db):\n        \"\"\"Verify force_immediate=True bypasses digest mode.\"\"\"\n        mock_provider.daily_digest_enabled = True\n        mock_provider.on_ams_humidity_high = True\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Alert\", \"Alert message\")\n\n            await service.on_ams_humidity_high(\n                printer_id=1,\n                printer_name=\"Test\",\n                ams_label=\"AMS-A\",\n                humidity=75.0,\n                threshold=60.0,\n                db=mock_db,\n            )\n\n            # Verify force_immediate is passed\n            call_kwargs = mock_send.call_args[1]\n            assert call_kwargs.get(\"force_immediate\") is True\n\n\nclass TestDigestModeAlwaysSendsImmediately:\n    \"\"\"CRITICAL: Tests that notifications always send immediately regardless of digest setting.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    @pytest.mark.asyncio\n    async def test_notification_sends_immediately_even_with_digest_enabled(self, service):\n        \"\"\"CRITICAL: All notifications must be sent immediately, digest is just a summary.\"\"\"\n        # Create a mock provider with digest enabled\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n        mock_provider.name = \"Test Provider\"\n        mock_provider.provider_type = \"ntfy\"\n        mock_provider.enabled = True\n        mock_provider.daily_digest_enabled = True  # Digest enabled\n        mock_provider.daily_digest_time = \"23:59\"\n        mock_provider.config = '{\"server\": \"https://ntfy.sh\", \"topic\": \"test\"}'\n\n        mock_db = AsyncMock()\n\n        # Mock the _send_to_provider method\n        with (\n            patch.object(service, \"_send_to_provider\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_queue_for_digest\", new_callable=AsyncMock) as mock_queue,\n            patch.object(service, \"_update_provider_status\", new_callable=AsyncMock),\n            patch.object(service, \"_log_notification\", new_callable=AsyncMock),\n        ):\n            mock_send.return_value = (True, None)\n\n            await service._send_to_providers(\n                providers=[mock_provider],\n                title=\"Print Started\",\n                message=\"Your print has started\",\n                db=mock_db,\n                event_type=\"print_start\",\n            )\n\n            # CRITICAL: _send_to_provider MUST be called (immediate send)\n            mock_send.assert_called_once()\n\n            # Digest queue should also be called (for daily summary)\n            mock_queue.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_notification_sends_without_digest_queue_when_disabled(self, service):\n        \"\"\"When digest is disabled, notification sends but no digest queue.\"\"\"\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n        mock_provider.name = \"Test Provider\"\n        mock_provider.provider_type = \"ntfy\"\n        mock_provider.enabled = True\n        mock_provider.daily_digest_enabled = False  # Digest disabled\n        mock_provider.daily_digest_time = None\n        mock_provider.config = '{\"server\": \"https://ntfy.sh\", \"topic\": \"test\"}'\n\n        mock_db = AsyncMock()\n\n        with (\n            patch.object(service, \"_send_to_provider\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_queue_for_digest\", new_callable=AsyncMock) as mock_queue,\n            patch.object(service, \"_update_provider_status\", new_callable=AsyncMock),\n            patch.object(service, \"_log_notification\", new_callable=AsyncMock),\n        ):\n            mock_send.return_value = (True, None)\n\n            await service._send_to_providers(\n                providers=[mock_provider],\n                title=\"Print Started\",\n                message=\"Your print has started\",\n                db=mock_db,\n                event_type=\"print_start\",\n            )\n\n            # Notification must still be sent immediately\n            mock_send.assert_called_once()\n\n            # Digest queue should NOT be called when digest is disabled\n            mock_queue.assert_not_called()\n\n\nclass TestNotificationProviderTypes:\n    \"\"\"Tests for different notification provider types.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    @pytest.mark.asyncio\n    async def test_webhook_provider_sends_request(self, service):\n        \"\"\"Verify webhook provider sends HTTP request.\"\"\"\n        config = {\n            \"webhook_url\": \"http://test.local/webhook\",\n            \"field_title\": \"title\",\n            \"field_message\": \"message\",\n        }\n\n        # Create a mock response\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        # Mock the _get_client method\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        with patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client:\n            mock_get_client.return_value = mock_client\n\n            success, message = await service._send_webhook(config, \"Test Title\", \"Test Message\")\n\n            assert success is True\n            mock_client.post.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_webhook_handles_failure(self, service):\n        \"\"\"Verify webhook gracefully handles HTTP errors.\"\"\"\n        config = {\n            \"webhook_url\": \"http://test.local/webhook\",\n        }\n\n        with patch(\"httpx.AsyncClient\") as mock_client_class:\n            mock_instance = AsyncMock()\n            mock_instance.post.side_effect = Exception(\"Connection failed\")\n            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_instance)\n            mock_client_class.return_value.__aexit__ = AsyncMock()\n\n            success, message = await service._send_webhook(config, \"Test\", \"Test\")\n\n            assert success is False\n            assert \"Connection failed\" in message or \"error\" in message.lower()\n\n    @pytest.mark.asyncio\n    async def test_webhook_slack_format_sends_text_only(self, service):\n        \"\"\"Verify Slack/Mattermost format sends only text field.\"\"\"\n        config = {\n            \"webhook_url\": \"http://mattermost.local/hooks/abc123\",\n            \"payload_format\": \"slack\",\n        }\n\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        with patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client:\n            mock_get_client.return_value = mock_client\n\n            success, message = await service._send_webhook(config, \"Test Title\", \"Test Message\")\n\n            assert success is True\n            mock_client.post.assert_called_once()\n\n            # Verify payload format is Slack-compatible\n            call_args = mock_client.post.call_args\n            payload = call_args.kwargs.get(\"json\") or call_args[1].get(\"json\")\n            assert \"text\" in payload\n            assert \"*Test Title*\" in payload[\"text\"]\n            assert \"Test Message\" in payload[\"text\"]\n            # Should NOT have generic fields\n            assert \"timestamp\" not in payload\n            assert \"source\" not in payload\n\n    @pytest.mark.asyncio\n    async def test_webhook_generic_format_includes_image(self, service):\n        \"\"\"Verify generic webhook includes base64-encoded image when provided.\"\"\"\n        config = {\n            \"webhook_url\": \"http://test.local/webhook\",\n            \"field_title\": \"title\",\n            \"field_message\": \"message\",\n        }\n\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        with patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client:\n            mock_get_client.return_value = mock_client\n\n            image_bytes = b\"\\xff\\xd8\\xff\\xe0fake-jpeg-data\"\n            success, message = await service._send_webhook(config, \"Test Title\", \"Test Message\", image_data=image_bytes)\n\n            assert success is True\n            call_args = mock_client.post.call_args\n            payload = call_args.kwargs.get(\"json\") or call_args[1].get(\"json\")\n            assert \"image\" in payload\n\n            import base64\n\n            assert payload[\"image\"] == base64.b64encode(image_bytes).decode(\"ascii\")\n\n    @pytest.mark.asyncio\n    async def test_webhook_generic_format_no_image_when_none(self, service):\n        \"\"\"Verify generic webhook omits image field when no image_data provided.\"\"\"\n        config = {\n            \"webhook_url\": \"http://test.local/webhook\",\n            \"field_title\": \"title\",\n            \"field_message\": \"message\",\n        }\n\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        with patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client:\n            mock_get_client.return_value = mock_client\n\n            success, message = await service._send_webhook(config, \"Test Title\", \"Test Message\")\n\n            assert success is True\n            call_args = mock_client.post.call_args\n            payload = call_args.kwargs.get(\"json\") or call_args[1].get(\"json\")\n            assert \"image\" not in payload\n\n    @pytest.mark.asyncio\n    async def test_webhook_slack_format_excludes_image(self, service):\n        \"\"\"Verify Slack format does not include image even when provided.\"\"\"\n        config = {\n            \"webhook_url\": \"http://mattermost.local/hooks/abc123\",\n            \"payload_format\": \"slack\",\n        }\n\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        with patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client:\n            mock_get_client.return_value = mock_client\n\n            success, message = await service._send_webhook(\n                config, \"Test Title\", \"Test Message\", image_data=b\"fake-image\"\n            )\n\n            assert success is True\n            call_args = mock_client.post.call_args\n            payload = call_args.kwargs.get(\"json\") or call_args[1].get(\"json\")\n            assert \"image\" not in payload\n\n\nclass TestHomeAssistantProvider:\n    \"\"\"Tests for Home Assistant notification provider.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    @pytest.mark.asyncio\n    async def test_send_homeassistant_success(self, service):\n        \"\"\"Verify HA provider sends persistent notification to correct endpoint.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        mock_db = AsyncMock()\n\n        with (\n            patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client,\n            patch(\n                \"backend.app.api.routes.settings.get_homeassistant_settings\",\n                new_callable=AsyncMock,\n            ) as mock_ha_settings,\n        ):\n            mock_get_client.return_value = mock_client\n            mock_ha_settings.return_value = {\n                \"ha_url\": \"http://ha.local:8123\",\n                \"ha_token\": \"test-token-123\",\n                \"ha_enabled\": True,\n            }\n\n            success, message = await service._send_homeassistant({}, \"Test Title\", \"Test Message\", db=mock_db)\n\n            assert success is True\n            mock_client.post.assert_called_once()\n            call_args = mock_client.post.call_args\n            assert call_args[0][0] == \"http://ha.local:8123/api/services/persistent_notification/create\"\n            payload = call_args.kwargs.get(\"json\") or call_args[1].get(\"json\")\n            assert payload[\"title\"] == \"Test Title\"\n            assert payload[\"message\"] == \"Test Message\"\n\n    @pytest.mark.asyncio\n    async def test_send_homeassistant_no_db_no_env(self, service):\n        \"\"\"Verify HA provider fails gracefully without DB or env vars.\"\"\"\n        with patch.dict(\"os.environ\", {}, clear=True):\n            success, message = await service._send_homeassistant({}, \"Test\", \"Test\", db=None)\n\n        assert success is False\n        assert \"not configured\" in message.lower()\n\n    @pytest.mark.asyncio\n    async def test_send_homeassistant_auth_failure(self, service):\n        \"\"\"Verify HA provider reports auth failure.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 401\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        mock_db = AsyncMock()\n\n        with (\n            patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client,\n            patch(\n                \"backend.app.api.routes.settings.get_homeassistant_settings\",\n                new_callable=AsyncMock,\n            ) as mock_ha_settings,\n        ):\n            mock_get_client.return_value = mock_client\n            mock_ha_settings.return_value = {\n                \"ha_url\": \"http://ha.local:8123\",\n                \"ha_token\": \"bad-token\",\n                \"ha_enabled\": True,\n            }\n\n            success, message = await service._send_homeassistant({}, \"Test\", \"Test\", db=mock_db)\n\n        assert success is False\n        assert \"authentication\" in message.lower()\n\n    @pytest.mark.asyncio\n    async def test_send_homeassistant_env_fallback(self, service):\n        \"\"\"Verify HA provider falls back to env vars when no DB session.\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        with (\n            patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client,\n            patch.dict(\"os.environ\", {\"HA_URL\": \"http://env-ha:8123\", \"HA_TOKEN\": \"env-token\"}),\n        ):\n            mock_get_client.return_value = mock_client\n\n            success, message = await service._send_homeassistant({}, \"Test\", \"Test\", db=None)\n\n        assert success is True\n        call_args = mock_client.post.call_args\n        assert \"env-ha:8123\" in call_args[0][0]\n\n    @pytest.mark.asyncio\n    async def test_send_homeassistant_empty_config_accepted(self, service):\n        \"\"\"Verify HA provider works with empty config dict (no fields needed).\"\"\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(return_value=mock_response)\n\n        mock_db = AsyncMock()\n\n        with (\n            patch.object(service, \"_get_client\", new_callable=AsyncMock) as mock_get_client,\n            patch(\n                \"backend.app.api.routes.settings.get_homeassistant_settings\",\n                new_callable=AsyncMock,\n            ) as mock_ha_settings,\n        ):\n            mock_get_client.return_value = mock_client\n            mock_ha_settings.return_value = {\n                \"ha_url\": \"http://ha.local:8123\",\n                \"ha_token\": \"token\",\n                \"ha_enabled\": True,\n            }\n\n            success, _ = await service._send_homeassistant({}, \"Title\", \"Body\", db=mock_db)\n\n        assert success is True\n\n    @pytest.mark.asyncio\n    async def test_send_to_provider_dispatches_homeassistant(self, service):\n        \"\"\"Verify _send_to_provider dispatches to _send_homeassistant.\"\"\"\n        provider = MagicMock()\n        provider.provider_type = \"homeassistant\"\n        provider.config = \"{}\"\n        provider.quiet_hours_enabled = False\n\n        with patch.object(service, \"_send_homeassistant\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = (True, \"OK\")\n\n            success, _ = await service._send_to_provider(provider, \"Title\", \"Message\", db=AsyncMock())\n\n        assert success is True\n        mock_send.assert_called_once()\n\n\nclass TestNotificationVariableFallbacks:\n    \"\"\"Tests for notification variable fallback values.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    def test_format_duration_with_valid_seconds(self, service):\n        \"\"\"Verify duration formats correctly with valid input.\"\"\"\n        result = service._format_duration(3661)  # 1h 1m 1s\n        assert \"1h\" in result\n\n    def test_format_duration_with_none_returns_unknown(self, service):\n        \"\"\"CRITICAL: Verify None duration returns 'Unknown' fallback.\"\"\"\n        result = service._format_duration(None)\n        assert result == \"Unknown\"\n\n    def test_format_duration_with_zero(self, service):\n        \"\"\"Verify zero duration formats correctly.\"\"\"\n        result = service._format_duration(0)\n        # Should return some valid string, not \"Unknown\"\n        assert result is not None\n        assert isinstance(result, str)\n\n    def test_format_duration_hours_and_minutes(self, service):\n        \"\"\"Verify duration formats hours and minutes.\"\"\"\n        result = service._format_duration(5400)  # 1h 30m\n        assert \"1h\" in result\n        assert \"30m\" in result\n\n    def test_format_duration_minutes_only(self, service):\n        \"\"\"Verify duration formats minutes only when < 1 hour.\"\"\"\n        result = service._format_duration(1800)  # 30m\n        assert \"30m\" in result or \"30\" in result\n\n    @pytest.mark.asyncio\n    async def test_print_complete_fallback_values(self, service):\n        \"\"\"CRITICAL: Verify fallback values when archive_data is missing.\"\"\"\n        mock_db = AsyncMock()\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = []  # No providers, just testing variable setup\n            mock_build.return_value = (\"Test\", \"Test\")\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"completed\",\n                data={\"subtask_name\": \"test_print\"},\n                db=mock_db,\n                archive_data=None,  # No archive data - should use fallbacks\n            )\n\n            # Test passes if no exception is raised with missing archive_data\n\n    @pytest.mark.asyncio\n    async def test_print_complete_with_archive_data(self, service):\n        \"\"\"Verify archive data values are used when provided.\"\"\"\n        mock_db = AsyncMock()\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            mock_get.return_value = []\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"completed\",\n                data={\"subtask_name\": \"test_print\"},\n                db=mock_db,\n                archive_data={\n                    \"print_time_seconds\": 3600,\n                    \"actual_filament_grams\": 50.5,\n                },\n            )\n\n            # When archive data is provided, duration should not be \"Unknown\"\n            if captured_variables.get(\"duration\"):\n                assert captured_variables[\"duration\"] != \"Unknown\"\n\n    @pytest.mark.asyncio\n    async def test_print_complete_with_finish_photo_url(self, service):\n        \"\"\"Verify finish_photo_url is passed through from archive_data.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"completed\",\n                data={\"subtask_name\": \"test_print\"},\n                db=mock_db,\n                archive_data={\n                    \"print_time_seconds\": 3600,\n                    \"actual_filament_grams\": 50.5,\n                    \"finish_photo_url\": \"http://localhost:8000/api/v1/archives/1/photos/finish_test.jpg\",\n                },\n            )\n\n            # finish_photo_url should be passed through to template variables\n            assert (\n                captured_variables.get(\"finish_photo_url\")\n                == \"http://localhost:8000/api/v1/archives/1/photos/finish_test.jpg\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_print_start_estimated_time_fallback(self, service):\n        \"\"\"Verify estimated time shows 'Unknown' when not available.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n            patch(\"backend.app.api.routes.settings.get_setting\", new_callable=AsyncMock, return_value=None),\n        ):\n            # Need at least one provider to trigger message building\n            mock_get.return_value = [mock_provider]\n\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test\",\n                data={\n                    \"subtask_name\": \"test\",\n                    # No estimated_time or mc_remaining_time\n                },\n                db=mock_db,\n            )\n\n            # When no time data, should show \"Unknown\"\n            assert captured_variables.get(\"estimated_time\") == \"Unknown\"\n\n    @pytest.mark.asyncio\n    async def test_print_progress_remaining_time_fallback(self, service):\n        \"\"\"Verify remaining time shows 'Unknown' when not available.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n            patch(\"backend.app.api.routes.settings.get_setting\", new_callable=AsyncMock, return_value=None),\n        ):\n            # Need at least one provider to trigger message building\n            mock_get.return_value = [mock_provider]\n\n            await service.on_print_progress(\n                printer_id=1,\n                printer_name=\"Test\",\n                progress=50,\n                remaining_time=None,  # No remaining time\n                filename=\"test.3mf\",\n                db=mock_db,\n            )\n\n            # When no remaining time, should show \"Unknown\"\n            assert captured_variables.get(\"remaining_time\") == \"Unknown\"\n\n    @pytest.mark.asyncio\n    async def test_filename_fallback_to_unknown(self, service):\n        \"\"\"Verify filename defaults to 'Unknown' when not provided.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            # Need at least one provider to trigger message building\n            mock_get.return_value = [mock_provider]\n\n            await service.on_print_complete(\n                printer_id=1,\n                printer_name=\"Test\",\n                status=\"completed\",\n                data={},  # No subtask_name or filename\n                db=mock_db,\n            )\n\n            # Filename should default to something (either \"Unknown\" or cleaned empty)\n            assert \"filename\" in captured_variables\n\n    @pytest.mark.asyncio\n    async def test_print_start_uses_archive_print_time_seconds(self, service):\n        \"\"\"Verify print_time_seconds from archive_data is used for estimated_time.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n            patch(\"backend.app.api.routes.settings.get_setting\", new_callable=AsyncMock, return_value=None),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            # Pass archive_data with print_time_seconds (7200 seconds = 2 hours)\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test\",\n                data={\"subtask_name\": \"test\"},\n                db=mock_db,\n                archive_data={\"print_time_seconds\": 7200},\n            )\n\n            # Should use archive's print_time_seconds: 7200 seconds = 2h 0m\n            assert captured_variables.get(\"estimated_time\") == \"2h 0m\"\n\n    @pytest.mark.asyncio\n    async def test_print_start_archive_data_overrides_mqtt(self, service):\n        \"\"\"Verify archive_data takes priority over MQTT remaining_time.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n            patch(\"backend.app.api.routes.settings.get_setting\", new_callable=AsyncMock, return_value=None),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            # Both archive_data and MQTT remaining_time provided\n            # Archive says 2 hours, MQTT says 30 minutes (wrong at start)\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test\",\n                data={\n                    \"subtask_name\": \"test\",\n                    \"remaining_time\": 1800,  # 30 minutes from MQTT\n                },\n                db=mock_db,\n                archive_data={\"print_time_seconds\": 7200},  # 2 hours from 3MF\n            )\n\n            # Should use archive's print_time_seconds (more reliable)\n            assert captured_variables.get(\"estimated_time\") == \"2h 0m\"\n\n    @pytest.mark.asyncio\n    async def test_print_start_falls_back_to_mqtt_when_no_archive(self, service):\n        \"\"\"Verify MQTT remaining_time is used when archive_data not provided.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n            patch(\"backend.app.api.routes.settings.get_setting\", new_callable=AsyncMock, return_value=None),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            # Only MQTT remaining_time provided (1800 seconds = 30 minutes)\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test\",\n                data={\n                    \"subtask_name\": \"test\",\n                    \"remaining_time\": 1800,\n                },\n                db=mock_db,\n                # No archive_data\n            )\n\n            # Should use MQTT remaining_time\n            assert captured_variables.get(\"estimated_time\") == \"30m\"\n\n    @pytest.mark.asyncio\n    async def test_print_start_eta_calculated_from_estimated_time(self, service):\n        \"\"\"Verify ETA is calculated as wall-clock time from estimated_time.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n            patch(\"backend.app.api.routes.settings.get_setting\", new_callable=AsyncMock, return_value=None),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test\",\n                data={\"subtask_name\": \"test\"},\n                db=mock_db,\n                archive_data={\"print_time_seconds\": 3600},  # 1 hour\n            )\n\n            # ETA should be a time string in HH:MM format\n            eta = captured_variables.get(\"eta\")\n            assert eta is not None\n            assert eta != \"Unknown\"\n            assert \":\" in eta  # HH:MM format\n\n    @pytest.mark.asyncio\n    async def test_print_start_eta_unknown_when_no_time(self, service):\n        \"\"\"Verify ETA shows 'Unknown' when no time data available.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n            patch(\"backend.app.api.routes.settings.get_setting\", new_callable=AsyncMock, return_value=None),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test\",\n                data={\"subtask_name\": \"test\"},\n                db=mock_db,\n            )\n\n            assert captured_variables.get(\"eta\") == \"Unknown\"\n\n    @pytest.mark.asyncio\n    async def test_print_start_eta_respects_12h_format(self, service):\n        \"\"\"Verify ETA uses 12-hour format when time_format is '12h'.\"\"\"\n        mock_db = AsyncMock()\n        mock_provider = MagicMock()\n        mock_provider.id = 1\n\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n            patch(\"backend.app.api.routes.settings.get_setting\", new_callable=AsyncMock, return_value=\"12h\"),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_print_start(\n                printer_id=1,\n                printer_name=\"Test\",\n                data={\"subtask_name\": \"test\"},\n                db=mock_db,\n                archive_data={\"print_time_seconds\": 3600},\n            )\n\n            eta = captured_variables.get(\"eta\")\n            assert eta is not None\n            # 12h format should contain AM or PM\n            assert \"AM\" in eta or \"PM\" in eta\n\n\nclass TestNotificationTemplates:\n    \"\"\"Tests for notification message template rendering.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    @pytest.mark.asyncio\n    async def test_template_renders_variables(self, service):\n        \"\"\"Verify template variables are replaced correctly.\"\"\"\n        template_title = \"Print {progress}% Complete\"\n        template_body = \"{printer}: {filename}\\nRemaining: {remaining_time}\"\n\n        variables = {\n            \"printer\": \"Test Printer\",\n            \"filename\": \"test.3mf\",\n            \"progress\": \"50\",\n            \"remaining_time\": \"1h 30m\",\n        }\n\n        title = template_title.format(**variables)\n        body = template_body.format(**variables)\n\n        assert title == \"Print 50% Complete\"\n        assert \"Test Printer\" in body\n        assert \"test.3mf\" in body\n        assert \"1h 30m\" in body\n\n    @pytest.mark.asyncio\n    async def test_template_handles_missing_variables(self, service):\n        \"\"\"Verify missing template variables don't cause crashes.\"\"\"\n        template = \"{printer}: {unknown_var}\"\n        variables = {\"printer\": \"Test\"}\n\n        # Should handle gracefully - either leave placeholder or skip\n        try:\n            result = template.format_map({**variables, \"unknown_var\": \"{unknown_var}\"})\n            assert \"Test\" in result\n        except KeyError:\n            pytest.fail(\"Template should handle missing variables gracefully\")\n\n\nclass TestPrinterErrorNotifications:\n    \"\"\"Tests for HMS error (printer error) notifications.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    @pytest.fixture\n    def mock_provider(self):\n        \"\"\"Create a mock notification provider with error notifications enabled.\"\"\"\n        provider = MagicMock()\n        provider.id = 1\n        provider.name = \"Test Provider\"\n        provider.provider_type = \"webhook\"\n        provider.enabled = True\n        provider.config = json.dumps({\"webhook_url\": \"http://test.local/webhook\"})\n        provider.on_printer_error = True  # Enable error notifications\n        provider.quiet_hours_enabled = False\n        provider.daily_digest_enabled = False\n        provider.printer_id = None\n        return provider\n\n    @pytest.fixture\n    def mock_db(self):\n        \"\"\"Create a mock database session.\"\"\"\n        db = AsyncMock()\n        db.commit = AsyncMock()\n        return db\n\n    @pytest.mark.asyncio\n    async def test_on_printer_error_sends_notification(self, service, mock_provider, mock_db):\n        \"\"\"Verify HMS error notification is sent when triggered.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Printer Error\", \"AMS/Filament Error: 0700_8010\")\n\n            await service.on_printer_error(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                error_type=\"AMS/Filament Error\",\n                db=mock_db,\n                error_detail=\"Error code: 0700_8010\",\n            )\n\n            mock_get.assert_called_once()\n            mock_send.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_on_printer_error_skipped_when_disabled(self, service, mock_provider, mock_db):\n        \"\"\"CRITICAL: Verify error notifications respect toggle setting.\"\"\"\n        mock_provider.on_printer_error = False\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n        ):\n            # Provider with toggle disabled won't be returned\n            mock_get.return_value = []\n\n            await service.on_printer_error(\n                printer_id=1,\n                printer_name=\"Test\",\n                error_type=\"AMS Error\",\n                db=mock_db,\n                error_detail=\"Test error\",\n            )\n\n            mock_send.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_printer_error_includes_error_detail(self, service, mock_provider, mock_db):\n        \"\"\"Verify error details are passed to template variables.\"\"\"\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_printer_error(\n                printer_id=1,\n                printer_name=\"X1 Carbon\",\n                error_type=\"AMS/Filament Error\",\n                db=mock_db,\n                error_detail=\"Error code: 0700_8010\",\n            )\n\n            assert captured_variables[\"printer\"] == \"X1 Carbon\"\n            assert captured_variables[\"error_type\"] == \"AMS/Filament Error\"\n            assert captured_variables[\"error_detail\"] == \"Error code: 0700_8010\"\n\n    @pytest.mark.asyncio\n    async def test_on_printer_error_fallback_when_no_detail(self, service, mock_provider, mock_db):\n        \"\"\"Verify fallback message when error_detail is None.\"\"\"\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_printer_error(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                error_type=\"Unknown Error\",\n                db=mock_db,\n                error_detail=None,  # No detail provided\n            )\n\n            assert captured_variables[\"error_detail\"] == \"No details available\"\n\n\nclass TestPlateNotEmptyNotifications:\n    \"\"\"Tests for plate not empty (build plate detection) notifications.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    @pytest.fixture\n    def mock_provider(self):\n        \"\"\"Create a mock notification provider with plate detection enabled.\"\"\"\n        provider = MagicMock()\n        provider.id = 1\n        provider.name = \"Test Provider\"\n        provider.provider_type = \"webhook\"\n        provider.enabled = True\n        provider.config = json.dumps({\"webhook_url\": \"http://test.local/webhook\"})\n        provider.on_plate_not_empty = True\n        provider.quiet_hours_enabled = False\n        provider.daily_digest_enabled = False\n        provider.printer_id = None\n        return provider\n\n    @pytest.fixture\n    def mock_db(self):\n        \"\"\"Create a mock database session.\"\"\"\n        db = AsyncMock()\n        db.commit = AsyncMock()\n        return db\n\n    @pytest.mark.asyncio\n    async def test_on_plate_not_empty_sends_notification(self, service, mock_provider, mock_db):\n        \"\"\"Verify plate not empty notification is sent when triggered.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Plate Not Empty\", \"Objects detected on build plate\")\n\n            await service.on_plate_not_empty(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                db=mock_db,\n                difference_percent=5.2,\n            )\n\n            mock_get.assert_called_once()\n            mock_send.assert_called_once()\n            # Verify force_immediate is True (critical alert)\n            call_kwargs = mock_send.call_args[1]\n            assert call_kwargs.get(\"force_immediate\") is True\n\n    @pytest.mark.asyncio\n    async def test_on_plate_not_empty_skipped_when_disabled(self, service, mock_provider, mock_db):\n        \"\"\"Verify notification is skipped when toggle is disabled.\"\"\"\n        mock_provider.on_plate_not_empty = False\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n        ):\n            mock_get.return_value = []\n\n            await service.on_plate_not_empty(\n                printer_id=1,\n                printer_name=\"Test\",\n                db=mock_db,\n            )\n\n            mock_send.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_plate_not_empty_includes_difference_percent(self, service, mock_provider, mock_db):\n        \"\"\"Verify difference percentage is passed to template variables.\"\"\"\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_plate_not_empty(\n                printer_id=1,\n                printer_name=\"X1 Carbon\",\n                db=mock_db,\n                difference_percent=3.5,\n            )\n\n            assert captured_variables[\"printer\"] == \"X1 Carbon\"\n            assert captured_variables[\"difference_percent\"] == \"3.5\"\n\n\nclass TestBedCooledNotifications:\n    \"\"\"Tests for bed cooled (after print) notifications.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    @pytest.fixture\n    def mock_provider(self):\n        \"\"\"Create a mock notification provider with bed cooled enabled.\"\"\"\n        provider = MagicMock()\n        provider.id = 1\n        provider.name = \"Test Provider\"\n        provider.provider_type = \"webhook\"\n        provider.enabled = True\n        provider.config = json.dumps({\"webhook_url\": \"http://test.local/webhook\"})\n        provider.on_bed_cooled = True\n        provider.quiet_hours_enabled = False\n        provider.daily_digest_enabled = False\n        provider.printer_id = None\n        return provider\n\n    @pytest.fixture\n    def mock_db(self):\n        \"\"\"Create a mock database session.\"\"\"\n        db = AsyncMock()\n        db.commit = AsyncMock()\n        return db\n\n    @pytest.mark.asyncio\n    async def test_on_bed_cooled_sends_notification(self, service, mock_provider, mock_db):\n        \"\"\"Verify bed cooled notification is sent when triggered.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"Bed Cooled\", \"Test Printer: Bed cooled to 30°C\")\n\n            await service.on_bed_cooled(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                bed_temp=30.0,\n                threshold=35.0,\n                filename=\"benchy.3mf\",\n                db=mock_db,\n            )\n\n            mock_get.assert_called_once()\n            mock_send.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_on_bed_cooled_skipped_when_no_providers(self, service, mock_db):\n        \"\"\"Verify notification is skipped when no providers have bed cooled enabled.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n        ):\n            mock_get.return_value = []\n\n            await service.on_bed_cooled(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                bed_temp=30.0,\n                threshold=35.0,\n                filename=\"benchy.3mf\",\n                db=mock_db,\n            )\n\n            mock_send.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_bed_cooled_includes_correct_variables(self, service, mock_provider, mock_db):\n        \"\"\"Verify bed temp, threshold, and filename are passed to template variables.\"\"\"\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_bed_cooled(\n                printer_id=1,\n                printer_name=\"X1 Carbon\",\n                bed_temp=28.7,\n                threshold=35.0,\n                filename=\"benchy.gcode.3mf\",\n                db=mock_db,\n            )\n\n            assert captured_variables[\"printer\"] == \"X1 Carbon\"\n            assert captured_variables[\"bed_temp\"] == \"29\"\n            assert captured_variables[\"threshold\"] == \"35\"\n            assert captured_variables[\"filename\"] == \"benchy\"\n\n    @pytest.mark.asyncio\n    async def test_on_bed_cooled_handles_none_filename(self, service, mock_provider, mock_db):\n        \"\"\"Verify None filename is handled gracefully.\"\"\"\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_bed_cooled(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                bed_temp=30.0,\n                threshold=35.0,\n                filename=None,\n                db=mock_db,\n            )\n\n            assert captured_variables[\"filename\"] == \"Unknown\"\n\n\nclass TestFirstLayerCompleteNotifications:\n    \"\"\"Tests for first layer complete notifications.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        return NotificationService()\n\n    @pytest.fixture\n    def mock_provider(self):\n        \"\"\"Create a mock notification provider with first layer complete enabled.\"\"\"\n        provider = MagicMock()\n        provider.id = 1\n        provider.name = \"Test Provider\"\n        provider.provider_type = \"webhook\"\n        provider.enabled = True\n        provider.config = json.dumps({\"webhook_url\": \"http://test.local/webhook\"})\n        provider.on_first_layer_complete = True\n        provider.quiet_hours_enabled = False\n        provider.daily_digest_enabled = False\n        provider.printer_id = None\n        return provider\n\n    @pytest.fixture\n    def mock_db(self):\n        \"\"\"Create a mock database session.\"\"\"\n        db = AsyncMock()\n        db.commit = AsyncMock()\n        return db\n\n    @pytest.mark.asyncio\n    async def test_on_first_layer_complete_sends_notification(self, service, mock_provider, mock_db):\n        \"\"\"Verify first layer complete notification is sent when triggered.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"First Layer Complete\", \"Test Printer: benchy.3mf\")\n\n            await service.on_first_layer_complete(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                filename=\"benchy.3mf\",\n                total_layers=50,\n                db=mock_db,\n            )\n\n            mock_get.assert_called_once()\n            mock_send.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_on_first_layer_complete_skipped_when_no_providers(self, service, mock_db):\n        \"\"\"Verify notification is skipped when no providers have first layer complete enabled.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n        ):\n            mock_get.return_value = []\n\n            await service.on_first_layer_complete(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                filename=\"benchy.3mf\",\n                total_layers=50,\n                db=mock_db,\n            )\n\n            mock_send.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_first_layer_complete_includes_correct_variables(self, service, mock_provider, mock_db):\n        \"\"\"Verify printer name, filename, and total_layers are passed to template variables.\"\"\"\n        captured_variables = {}\n\n        async def capture_build(db, event_type, variables):\n            captured_variables.update(variables)\n            return (\"Test\", \"Test\")\n\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock),\n            patch.object(service, \"_build_message_from_template\", side_effect=capture_build),\n        ):\n            mock_get.return_value = [mock_provider]\n\n            await service.on_first_layer_complete(\n                printer_id=1,\n                printer_name=\"X1 Carbon\",\n                filename=\"benchy.gcode.3mf\",\n                total_layers=120,\n                db=mock_db,\n            )\n\n            assert captured_variables[\"printer\"] == \"X1 Carbon\"\n            assert captured_variables[\"filename\"] == \"benchy\"\n            assert captured_variables[\"total_layers\"] == \"120\"\n\n    @pytest.mark.asyncio\n    async def test_on_first_layer_complete_passes_image_data(self, service, mock_provider, mock_db):\n        \"\"\"Verify image_data is passed through to _send_to_providers.\"\"\"\n        with (\n            patch.object(service, \"_get_providers_for_event\", new_callable=AsyncMock) as mock_get,\n            patch.object(service, \"_send_to_providers\", new_callable=AsyncMock) as mock_send,\n            patch.object(service, \"_build_message_from_template\", new_callable=AsyncMock) as mock_build,\n        ):\n            mock_get.return_value = [mock_provider]\n            mock_build.return_value = (\"First Layer Complete\", \"Test message\")\n            fake_image = b\"\\x89PNG\\r\\n\\x1a\\nfakeimage\"\n\n            await service.on_first_layer_complete(\n                printer_id=1,\n                printer_name=\"Test Printer\",\n                filename=\"benchy.3mf\",\n                total_layers=50,\n                db=mock_db,\n                image_data=fake_image,\n            )\n\n            mock_send.assert_called_once()\n            call_kwargs = mock_send.call_args\n            assert call_kwargs.kwargs.get(\"image_data\") == fake_image\n"
  },
  {
    "path": "backend/tests/unit/services/test_plate_detection.py",
    "content": "\"\"\"Unit tests for plate detection service.\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\n# Mock cv2 and numpy before importing the module\ncv2_mock = MagicMock()\nnp_mock = MagicMock()\n\n\nclass TestPlateDetectionResult:\n    \"\"\"Tests for PlateDetectionResult class.\"\"\"\n\n    def test_result_to_dict(self):\n        \"\"\"Verify PlateDetectionResult.to_dict() returns correct structure.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n            PlateDetectionResult = pd_module.PlateDetectionResult\n\n            result = PlateDetectionResult(\n                is_empty=True,\n                confidence=0.95,\n                difference_percent=0.5,\n                message=\"Test message\",\n                debug_image=None,\n                needs_calibration=False,\n            )\n\n            d = result.to_dict()\n\n            assert d[\"is_empty\"] is True\n            assert d[\"confidence\"] == 0.95\n            assert d[\"difference_percent\"] == 0.5\n            assert d[\"message\"] == \"Test message\"\n            assert d[\"has_debug_image\"] is False\n            assert d[\"needs_calibration\"] is False\n\n    def test_result_with_debug_image(self):\n        \"\"\"Verify has_debug_image is True when debug_image is provided.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n            PlateDetectionResult = pd_module.PlateDetectionResult\n\n            result = PlateDetectionResult(\n                is_empty=False,\n                confidence=0.8,\n                difference_percent=5.0,\n                message=\"Objects detected\",\n                debug_image=b\"fake_image_data\",\n                needs_calibration=False,\n            )\n\n            d = result.to_dict()\n            assert d[\"has_debug_image\"] is True\n\n    def test_result_needs_calibration(self):\n        \"\"\"Verify needs_calibration flag is preserved.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n            PlateDetectionResult = pd_module.PlateDetectionResult\n\n            result = PlateDetectionResult(\n                is_empty=True,\n                confidence=0.0,\n                difference_percent=0.0,\n                message=\"No calibration\",\n                needs_calibration=True,\n            )\n\n            d = result.to_dict()\n            assert d[\"needs_calibration\"] is True\n\n\nclass TestPlateDetector:\n    \"\"\"Tests for PlateDetector class.\"\"\"\n\n    def test_detector_initialization(self):\n        \"\"\"Verify PlateDetector initializes with default values.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            # Re-import to get fresh module\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n\n            # Mock OPENCV_AVAILABLE\n            pd_module.OPENCV_AVAILABLE = True\n\n            detector = pd_module.PlateDetector()\n            assert detector.roi == (0.15, 0.35, 0.70, 0.55)\n            assert detector.difference_threshold == 1.0\n\n    def test_detector_custom_roi(self):\n        \"\"\"Verify PlateDetector accepts custom ROI.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n\n            pd_module.OPENCV_AVAILABLE = True\n\n            custom_roi = (0.1, 0.2, 0.8, 0.6)\n            detector = pd_module.PlateDetector(roi=custom_roi)\n            assert detector.roi == custom_roi\n\n    def test_detector_raises_without_opencv(self):\n        \"\"\"Verify PlateDetector raises when OpenCV not available.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n\n            pd_module.OPENCV_AVAILABLE = False\n\n            with pytest.raises(RuntimeError, match=\"OpenCV is not installed\"):\n                pd_module.PlateDetector()\n\n\nclass TestCalibrationStatus:\n    \"\"\"Tests for calibration status functions.\"\"\"\n\n    def test_get_calibration_status_no_opencv(self):\n        \"\"\"Verify calibration status when OpenCV not available.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n\n            pd_module.OPENCV_AVAILABLE = False\n\n            status = pd_module.get_calibration_status(1)\n\n            assert status[\"available\"] is False\n            assert status[\"calibrated\"] is False\n            assert status[\"reference_count\"] == 0\n            assert \"OpenCV not available\" in status[\"message\"]\n\n    def test_is_plate_detection_available_true(self):\n        \"\"\"Verify is_plate_detection_available returns True when OpenCV available.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n\n            pd_module.OPENCV_AVAILABLE = True\n            assert pd_module.is_plate_detection_available() is True\n\n    def test_is_plate_detection_available_false(self):\n        \"\"\"Verify is_plate_detection_available returns False when OpenCV not available.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n\n            pd_module.OPENCV_AVAILABLE = False\n            assert pd_module.is_plate_detection_available() is False\n\n\nclass TestDeleteCalibration:\n    \"\"\"Tests for delete_calibration function.\"\"\"\n\n    def test_delete_calibration_no_opencv(self):\n        \"\"\"Verify delete_calibration returns False when OpenCV not available.\"\"\"\n        with patch.dict(\"sys.modules\", {\"cv2\": cv2_mock, \"numpy\": np_mock}):\n            import importlib\n\n            import backend.app.services.plate_detection as pd_module\n\n            importlib.reload(pd_module)\n\n            pd_module.OPENCV_AVAILABLE = False\n\n            result = pd_module.delete_calibration(1)\n            assert result is False\n"
  },
  {
    "path": "backend/tests/unit/services/test_printer_manager.py",
    "content": "\"\"\"Unit tests for PrinterManager service.\n\nTests printer connection management, status tracking, and print control.\n\"\"\"\n\nimport logging\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.printer_manager import (\n    PrinterManager,\n    get_derived_status_name,\n    has_stg_cur_idle_bug,\n    init_printer_connections,\n    parse_plate_id,\n    printer_state_to_dict,\n    supports_chamber_temp,\n    supports_drying,\n)\n\n\nclass TestPrinterManager:\n    \"\"\"Tests for PrinterManager class.\"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        \"\"\"Create a fresh PrinterManager instance.\"\"\"\n        return PrinterManager()\n\n    @pytest.fixture\n    def mock_printer(self):\n        \"\"\"Create a mock Printer object.\"\"\"\n        printer = MagicMock()\n        printer.id = 1\n        printer.ip_address = \"192.168.1.100\"\n        printer.serial_number = \"00M09A123456789\"\n        printer.access_code = \"12345678\"\n        printer.is_active = True\n        return printer\n\n    @pytest.fixture\n    def mock_client(self):\n        \"\"\"Create a mock BambuMQTTClient.\"\"\"\n        client = MagicMock()\n        client.state = MagicMock()\n        client.state.connected = True\n        client.state.state = \"IDLE\"\n        client.state.progress = 0\n        client.state.temperatures = {\"nozzle\": 25, \"bed\": 25}\n        client.state.raw_data = {}\n        client.logging_enabled = False\n        return client\n\n    # ========================================================================\n    # Tests for initialization\n    # ========================================================================\n\n    def test_init_creates_empty_clients_dict(self, manager):\n        \"\"\"Verify manager initializes with empty clients dict.\"\"\"\n        assert manager._clients == {}\n\n    def test_init_callbacks_are_none(self, manager):\n        \"\"\"Verify all callbacks are initially None.\"\"\"\n        assert manager._on_print_start is None\n        assert manager._on_print_complete is None\n        assert manager._on_status_change is None\n        assert manager._on_ams_change is None\n\n    def test_init_loop_is_none(self, manager):\n        \"\"\"Verify event loop is initially None.\"\"\"\n        assert manager._loop is None\n\n    # ========================================================================\n    # Tests for callback setters\n    # ========================================================================\n\n    def test_set_event_loop(self, manager):\n        \"\"\"Verify event loop can be set.\"\"\"\n        mock_loop = MagicMock()\n        manager.set_event_loop(mock_loop)\n        assert manager._loop == mock_loop\n\n    def test_set_print_start_callback(self, manager):\n        \"\"\"Verify print start callback can be set.\"\"\"\n        callback = MagicMock()\n        manager.set_print_start_callback(callback)\n        assert manager._on_print_start == callback\n\n    def test_set_print_complete_callback(self, manager):\n        \"\"\"Verify print complete callback can be set.\"\"\"\n        callback = MagicMock()\n        manager.set_print_complete_callback(callback)\n        assert manager._on_print_complete == callback\n\n    def test_set_status_change_callback(self, manager):\n        \"\"\"Verify status change callback can be set.\"\"\"\n        callback = MagicMock()\n        manager.set_status_change_callback(callback)\n        assert manager._on_status_change == callback\n\n    def test_set_ams_change_callback(self, manager):\n        \"\"\"Verify AMS change callback can be set.\"\"\"\n        callback = MagicMock()\n        manager.set_ams_change_callback(callback)\n        assert manager._on_ams_change == callback\n\n    # ========================================================================\n    # Tests for _schedule_async\n    # ========================================================================\n\n    def test_schedule_async_with_running_loop(self, manager):\n        \"\"\"Verify async coroutine is scheduled when loop is running.\"\"\"\n        mock_loop = MagicMock()\n        mock_loop.is_running.return_value = True\n        manager._loop = mock_loop\n\n        async def dummy_coro():\n            pass\n\n        coro = dummy_coro()\n        manager._schedule_async(coro)\n\n        mock_loop.is_running.assert_called_once()\n        # Clean up the coroutine\n        coro.close()\n\n    def test_schedule_async_without_loop(self, manager):\n        \"\"\"Verify nothing happens when no loop is set.\"\"\"\n\n        async def dummy_coro():\n            pass\n\n        coro = dummy_coro()\n        # Should not raise\n        manager._schedule_async(coro)\n        coro.close()\n\n    def test_schedule_async_with_stopped_loop(self, manager):\n        \"\"\"Verify nothing happens when loop is not running.\"\"\"\n        mock_loop = MagicMock()\n        mock_loop.is_running.return_value = False\n        manager._loop = mock_loop\n\n        async def dummy_coro():\n            pass\n\n        coro = dummy_coro()\n        manager._schedule_async(coro)\n        coro.close()\n\n    # ========================================================================\n    # Tests for connect_printer\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_connect_printer_creates_client(self, manager, mock_printer):\n        \"\"\"Verify connecting creates an MQTT client.\"\"\"\n        with patch(\"backend.app.services.printer_manager.BambuMQTTClient\") as MockClient:\n            mock_instance = MagicMock()\n            mock_instance.state = MagicMock()\n            mock_instance.state.connected = True\n            MockClient.return_value = mock_instance\n\n            result = await manager.connect_printer(mock_printer)\n\n            MockClient.assert_called_once()\n            mock_instance.connect.assert_called_once()\n            assert mock_printer.id in manager._clients\n            assert result is True\n\n    @pytest.mark.asyncio\n    async def test_connect_printer_disconnects_existing(self, manager, mock_printer, mock_client):\n        \"\"\"Verify connecting disconnects existing client first.\"\"\"\n        manager._clients[mock_printer.id] = mock_client\n\n        with patch(\"backend.app.services.printer_manager.BambuMQTTClient\") as MockClient:\n            new_client = MagicMock()\n            new_client.state = MagicMock()\n            new_client.state.connected = True\n            MockClient.return_value = new_client\n\n            await manager.connect_printer(mock_printer)\n\n            mock_client.disconnect.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_connect_printer_returns_false_on_failure(self, manager, mock_printer):\n        \"\"\"Verify returns False when connection fails.\"\"\"\n        with patch(\"backend.app.services.printer_manager.BambuMQTTClient\") as MockClient:\n            mock_instance = MagicMock()\n            mock_instance.state = MagicMock()\n            mock_instance.state.connected = False\n            MockClient.return_value = mock_instance\n\n            result = await manager.connect_printer(mock_printer)\n\n            assert result is False\n\n    # ========================================================================\n    # Tests for disconnect_printer\n    # ========================================================================\n\n    def test_disconnect_printer_removes_client(self, manager, mock_client):\n        \"\"\"Verify disconnecting removes and disconnects client.\"\"\"\n        manager._clients[1] = mock_client\n\n        manager.disconnect_printer(1)\n\n        mock_client.disconnect.assert_called_once()\n        assert 1 not in manager._clients\n\n    def test_disconnect_printer_handles_missing(self, manager):\n        \"\"\"Verify disconnecting non-existent printer doesn't raise.\"\"\"\n        manager.disconnect_printer(999)  # Should not raise\n\n    # ========================================================================\n    # Tests for disconnect_all\n    # ========================================================================\n\n    def test_disconnect_all_disconnects_all_clients(self, manager):\n        \"\"\"Verify all clients are disconnected.\"\"\"\n        client1 = MagicMock()\n        client2 = MagicMock()\n        manager._clients[1] = client1\n        manager._clients[2] = client2\n\n        manager.disconnect_all()\n\n        client1.disconnect.assert_called_once()\n        client2.disconnect.assert_called_once()\n        assert len(manager._clients) == 0\n\n    # ========================================================================\n    # Tests for get_status\n    # ========================================================================\n\n    def test_get_status_returns_state(self, manager, mock_client):\n        \"\"\"Verify get_status returns client state.\"\"\"\n        manager._clients[1] = mock_client\n\n        result = manager.get_status(1)\n\n        mock_client.check_staleness.assert_called_once()\n        assert result == mock_client.state\n\n    def test_get_status_returns_none_for_unknown(self, manager):\n        \"\"\"Verify get_status returns None for unknown printer.\"\"\"\n        result = manager.get_status(999)\n        assert result is None\n\n    # ========================================================================\n    # Tests for get_all_statuses\n    # ========================================================================\n\n    def test_get_all_statuses_returns_all(self, manager):\n        \"\"\"Verify all statuses are returned.\"\"\"\n        client1 = MagicMock()\n        client1.state = MagicMock(connected=True)\n        client2 = MagicMock()\n        client2.state = MagicMock(connected=False)\n        manager._clients[1] = client1\n        manager._clients[2] = client2\n\n        result = manager.get_all_statuses()\n\n        assert len(result) == 2\n        assert 1 in result\n        assert 2 in result\n        client1.check_staleness.assert_called_once()\n        client2.check_staleness.assert_called_once()\n\n    # ========================================================================\n    # Tests for is_connected\n    # ========================================================================\n\n    def test_is_connected_returns_true(self, manager, mock_client):\n        \"\"\"Verify is_connected returns True for connected printer.\"\"\"\n        mock_client.check_staleness.return_value = True\n        manager._clients[1] = mock_client\n\n        result = manager.is_connected(1)\n\n        assert result is True\n\n    def test_is_connected_returns_false_for_unknown(self, manager):\n        \"\"\"Verify is_connected returns False for unknown printer.\"\"\"\n        result = manager.is_connected(999)\n        assert result is False\n\n    # ========================================================================\n    # Tests for get_client\n    # ========================================================================\n\n    def test_get_client_returns_client(self, manager, mock_client):\n        \"\"\"Verify get_client returns the client.\"\"\"\n        manager._clients[1] = mock_client\n\n        result = manager.get_client(1)\n\n        assert result == mock_client\n\n    def test_get_client_returns_none_for_unknown(self, manager):\n        \"\"\"Verify get_client returns None for unknown printer.\"\"\"\n        result = manager.get_client(999)\n        assert result is None\n\n    # ========================================================================\n    # Tests for mark_printer_offline\n    # ========================================================================\n\n    def test_mark_printer_offline_updates_state(self, manager, mock_client):\n        \"\"\"Verify mark_printer_offline updates client state.\"\"\"\n        mock_client.state.connected = True\n        manager._clients[1] = mock_client\n\n        manager.mark_printer_offline(1)\n\n        assert mock_client.state.connected is False\n        assert mock_client.state.state == \"unknown\"\n\n    def test_mark_printer_offline_triggers_callback(self, manager, mock_client):\n        \"\"\"Verify mark_printer_offline triggers status callback.\"\"\"\n        mock_client.state.connected = True\n        manager._clients[1] = mock_client\n\n        # Callback must return a coroutine\n        async def async_callback(printer_id, state):\n            pass\n\n        manager._on_status_change = async_callback\n\n        # Need a running loop for callback\n        mock_loop = MagicMock()\n        mock_loop.is_running.return_value = True\n        manager._loop = mock_loop\n\n        manager.mark_printer_offline(1)\n\n        # Callback should be scheduled via run_coroutine_threadsafe\n        mock_loop.is_running.assert_called()\n        # State should be updated\n        assert mock_client.state.connected is False\n\n    def test_mark_printer_offline_handles_unknown(self, manager):\n        \"\"\"Verify mark_printer_offline handles unknown printer.\"\"\"\n        manager.mark_printer_offline(999)  # Should not raise\n\n    def test_mark_printer_offline_skips_already_offline(self, manager, mock_client):\n        \"\"\"Verify mark_printer_offline skips already offline printer.\"\"\"\n        mock_client.state.connected = False\n        manager._clients[1] = mock_client\n\n        manager.mark_printer_offline(1)\n\n        # State should remain unchanged\n        assert mock_client.state.connected is False\n\n    # ========================================================================\n    # Tests for start_print\n    # ========================================================================\n\n    def test_start_print_calls_client(self, manager, mock_client):\n        \"\"\"Verify start_print calls client method.\"\"\"\n        mock_client.start_print.return_value = True\n        manager._clients[1] = mock_client\n\n        result = manager.start_print(1, \"test.gcode\")\n\n        mock_client.start_print.assert_called_once_with(\n            \"test.gcode\",\n            1,\n            ams_mapping=None,\n            timelapse=False,\n            bed_levelling=True,\n            flow_cali=False,\n            vibration_cali=True,\n            layer_inspect=False,\n            use_ams=True,\n        )\n        assert result is True\n\n    def test_start_print_returns_false_for_unknown(self, manager):\n        \"\"\"Verify start_print returns False for unknown printer.\"\"\"\n        result = manager.start_print(999, \"test.gcode\")\n        assert result is False\n\n    def test_start_print_logs_print_command_with_caller(self, manager, mock_client, caplog):\n        \"\"\"Verify start_print logs PRINT COMMAND with caller info (#374).\"\"\"\n        mock_client.start_print.return_value = True\n        manager._clients[1] = mock_client\n\n        with caplog.at_level(logging.INFO, logger=\"backend.app.services.printer_manager\"):\n            manager.start_print(1, \"benchy.3mf\")\n\n        print_cmd_logs = [r for r in caplog.records if \"PRINT COMMAND\" in r.message]\n        assert len(print_cmd_logs) == 1\n        log_msg = print_cmd_logs[0].message\n        assert \"printer=1\" in log_msg\n        assert \"file=benchy.3mf\" in log_msg\n        assert \"caller=\" in log_msg\n\n    def test_start_print_logs_even_when_printer_unknown(self, manager, caplog):\n        \"\"\"Verify PRINT COMMAND is logged even for unknown printers (#374).\"\"\"\n        with caplog.at_level(logging.INFO, logger=\"backend.app.services.printer_manager\"):\n            result = manager.start_print(999, \"ghost.3mf\")\n\n        assert result is False\n        print_cmd_logs = [r for r in caplog.records if \"PRINT COMMAND\" in r.message]\n        assert len(print_cmd_logs) == 1\n\n    # ========================================================================\n    # Tests for stop_print\n    # ========================================================================\n\n    def test_stop_print_calls_client(self, manager, mock_client):\n        \"\"\"Verify stop_print calls client method.\"\"\"\n        mock_client.stop_print.return_value = True\n        manager._clients[1] = mock_client\n\n        result = manager.stop_print(1)\n\n        mock_client.stop_print.assert_called_once()\n        assert result is True\n\n    def test_stop_print_returns_false_for_unknown(self, manager):\n        \"\"\"Verify stop_print returns False for unknown printer.\"\"\"\n        result = manager.stop_print(999)\n        assert result is False\n\n    # ========================================================================\n    # Tests for wait_for_cooldown\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_wait_for_cooldown_returns_true_when_cool(self, manager, mock_client):\n        \"\"\"Verify wait_for_cooldown returns True when printer is cool.\"\"\"\n        mock_client.state.connected = True\n        mock_client.state.temperatures = {\"nozzle\": 40, \"bed\": 30}\n        mock_client.check_staleness.return_value = True\n        manager._clients[1] = mock_client\n\n        result = await manager.wait_for_cooldown(1, target_temp=50)\n\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_wait_for_cooldown_returns_false_on_disconnect(self, manager, mock_client):\n        \"\"\"Verify wait_for_cooldown returns False when printer disconnects.\"\"\"\n        mock_client.state.connected = False\n        mock_client.check_staleness.return_value = False\n        manager._clients[1] = mock_client\n\n        result = await manager.wait_for_cooldown(1, target_temp=50, timeout=1)\n\n        assert result is False\n\n    @pytest.mark.asyncio\n    async def test_wait_for_cooldown_returns_false_for_unknown(self, manager):\n        \"\"\"Verify wait_for_cooldown returns False for unknown printer.\"\"\"\n        result = await manager.wait_for_cooldown(999, target_temp=50, timeout=1)\n        assert result is False\n\n    @pytest.mark.asyncio\n    async def test_wait_for_cooldown_checks_both_nozzles(self, manager, mock_client):\n        \"\"\"Verify wait_for_cooldown checks both nozzles for dual extruders.\"\"\"\n        mock_client.state.connected = True\n        mock_client.state.temperatures = {\"nozzle\": 40, \"nozzle_2\": 45, \"bed\": 30}\n        mock_client.check_staleness.return_value = True\n        manager._clients[1] = mock_client\n\n        result = await manager.wait_for_cooldown(1, target_temp=50)\n\n        assert result is True\n\n    # ========================================================================\n    # Tests for logging methods\n    # ========================================================================\n\n    def test_enable_logging_calls_client(self, manager, mock_client):\n        \"\"\"Verify enable_logging calls client method.\"\"\"\n        manager._clients[1] = mock_client\n\n        result = manager.enable_logging(1, True)\n\n        mock_client.enable_logging.assert_called_once_with(True)\n        assert result is True\n\n    def test_enable_logging_returns_false_for_unknown(self, manager):\n        \"\"\"Verify enable_logging returns False for unknown printer.\"\"\"\n        result = manager.enable_logging(999, True)\n        assert result is False\n\n    def test_get_logs_returns_logs(self, manager, mock_client):\n        \"\"\"Verify get_logs returns client logs.\"\"\"\n        mock_logs = [MagicMock(), MagicMock()]\n        mock_client.get_logs.return_value = mock_logs\n        manager._clients[1] = mock_client\n\n        result = manager.get_logs(1)\n\n        assert result == mock_logs\n\n    def test_get_logs_returns_empty_for_unknown(self, manager):\n        \"\"\"Verify get_logs returns empty list for unknown printer.\"\"\"\n        result = manager.get_logs(999)\n        assert result == []\n\n    def test_clear_logs_calls_client(self, manager, mock_client):\n        \"\"\"Verify clear_logs calls client method.\"\"\"\n        manager._clients[1] = mock_client\n\n        result = manager.clear_logs(1)\n\n        mock_client.clear_logs.assert_called_once()\n        assert result is True\n\n    def test_clear_logs_returns_false_for_unknown(self, manager):\n        \"\"\"Verify clear_logs returns False for unknown printer.\"\"\"\n        result = manager.clear_logs(999)\n        assert result is False\n\n    def test_is_logging_enabled_returns_status(self, manager, mock_client):\n        \"\"\"Verify is_logging_enabled returns client status.\"\"\"\n        mock_client.logging_enabled = True\n        manager._clients[1] = mock_client\n\n        result = manager.is_logging_enabled(1)\n\n        assert result is True\n\n    def test_is_logging_enabled_returns_false_for_unknown(self, manager):\n        \"\"\"Verify is_logging_enabled returns False for unknown printer.\"\"\"\n        result = manager.is_logging_enabled(999)\n        assert result is False\n\n    # ========================================================================\n    # Tests for request_status_update\n    # ========================================================================\n\n    def test_request_status_update_calls_client(self, manager, mock_client):\n        \"\"\"Verify request_status_update calls client method.\"\"\"\n        mock_client.request_status_update.return_value = True\n        manager._clients[1] = mock_client\n\n        result = manager.request_status_update(1)\n\n        mock_client.request_status_update.assert_called_once()\n        assert result is True\n\n    def test_request_status_update_returns_false_for_unknown(self, manager):\n        \"\"\"Verify request_status_update returns False for unknown printer.\"\"\"\n        result = manager.request_status_update(999)\n        assert result is False\n\n    # ========================================================================\n    # Tests for test_connection\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_test_connection_success(self, manager):\n        \"\"\"Verify test_connection returns success on connection.\"\"\"\n        with patch(\"backend.app.services.printer_manager.BambuMQTTClient\") as MockClient:\n            mock_instance = MagicMock()\n            mock_instance.state = MagicMock()\n            mock_instance.state.connected = True\n            mock_instance.state.state = \"IDLE\"\n            mock_instance.state.raw_data = {\"device_model\": \"X1C\"}\n            MockClient.return_value = mock_instance\n\n            result = await manager.test_connection(\"192.168.1.100\", \"00M09A123456789\", \"12345678\")\n\n            assert result[\"success\"] is True\n            assert result[\"state\"] == \"IDLE\"\n            assert result[\"model\"] == \"X1C\"\n            mock_instance.disconnect.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_test_connection_failure(self, manager):\n        \"\"\"Verify test_connection returns failure on connection error.\"\"\"\n        with patch(\"backend.app.services.printer_manager.BambuMQTTClient\") as MockClient:\n            mock_instance = MagicMock()\n            mock_instance.state = MagicMock()\n            mock_instance.state.connected = False\n            MockClient.return_value = mock_instance\n\n            result = await manager.test_connection(\"192.168.1.100\", \"00M09A123456789\", \"12345678\")\n\n            assert result[\"success\"] is False\n            assert result[\"state\"] is None\n            mock_instance.disconnect.assert_called_once()\n\n    # ========================================================================\n    # Tests for current print user tracking (Issue #206)\n    # ========================================================================\n\n    def test_set_current_print_user(self, manager):\n        \"\"\"Verify current print user can be set.\"\"\"\n        manager.set_current_print_user(1, 42, \"testuser\")\n\n        assert 1 in manager._current_print_user\n        assert manager._current_print_user[1][\"user_id\"] == 42\n        assert manager._current_print_user[1][\"username\"] == \"testuser\"\n\n    def test_get_current_print_user_returns_user(self, manager):\n        \"\"\"Verify get_current_print_user returns the stored user.\"\"\"\n        manager.set_current_print_user(1, 42, \"testuser\")\n\n        result = manager.get_current_print_user(1)\n\n        assert result is not None\n        assert result[\"user_id\"] == 42\n        assert result[\"username\"] == \"testuser\"\n\n    def test_get_current_print_user_returns_none_for_unknown(self, manager):\n        \"\"\"Verify get_current_print_user returns None for unknown printer.\"\"\"\n        result = manager.get_current_print_user(999)\n        assert result is None\n\n    def test_clear_current_print_user(self, manager):\n        \"\"\"Verify current print user can be cleared.\"\"\"\n        manager.set_current_print_user(1, 42, \"testuser\")\n        manager.clear_current_print_user(1)\n\n        result = manager.get_current_print_user(1)\n        assert result is None\n\n    def test_clear_current_print_user_no_error_for_unknown(self, manager):\n        \"\"\"Verify clearing unknown printer doesn't raise error.\"\"\"\n        # Should not raise\n        manager.clear_current_print_user(999)\n\n    def test_set_current_print_user_overwrites_existing(self, manager):\n        \"\"\"Verify setting user overwrites existing value.\"\"\"\n        manager.set_current_print_user(1, 42, \"user1\")\n        manager.set_current_print_user(1, 99, \"user2\")\n\n        result = manager.get_current_print_user(1)\n        assert result[\"user_id\"] == 99\n        assert result[\"username\"] == \"user2\"\n\n    def test_multiple_printers_have_separate_users(self, manager):\n        \"\"\"Verify each printer tracks its own user separately.\"\"\"\n        manager.set_current_print_user(1, 42, \"user1\")\n        manager.set_current_print_user(2, 99, \"user2\")\n\n        result1 = manager.get_current_print_user(1)\n        result2 = manager.get_current_print_user(2)\n\n        assert result1[\"username\"] == \"user1\"\n        assert result2[\"username\"] == \"user2\"\n\n\nclass TestPrinterStateToDict:\n    \"\"\"Tests for printer_state_to_dict helper function.\"\"\"\n\n    @pytest.fixture\n    def mock_state(self):\n        \"\"\"Create a mock PrinterState.\"\"\"\n        state = MagicMock()\n        state.connected = True\n        state.state = \"RUNNING\"\n        state.current_print = \"test.3mf\"\n        state.subtask_name = \"Test Print\"\n        state.gcode_file = \"/sdcard/test.gcode\"\n        state.progress = 50\n        state.remaining_time = 3600\n        state.layer_num = 10\n        state.total_layers = 20\n        state.temperatures = {\"nozzle\": 200, \"bed\": 60}\n        state.hms_errors = []\n        state.ams_status_main = 0\n        state.ams_status_sub = 0\n        state.tray_now = \"1\"\n        state.wifi_signal = -50\n        state.raw_data = {}\n        state.stg_cur = -1  # No calibration stage active\n        state.firmware_version = None\n        return state\n\n    def test_basic_conversion(self, mock_state):\n        \"\"\"Verify basic state fields are converted.\"\"\"\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"connected\"] is True\n        assert result[\"state\"] == \"RUNNING\"\n        assert result[\"progress\"] == 50\n        assert result[\"temperatures\"] == {\"nozzle\": 200, \"bed\": 60}\n\n    def test_ams_data_parsing(self, mock_state):\n        \"\"\"Verify AMS data is parsed correctly.\"\"\"\n        mock_state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"humidity_raw\": 45,\n                    \"temp\": 25,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_color\": \"FF0000\",\n                            \"tray_type\": \"PLA\",\n                            \"tray_sub_brands\": \"Generic\",\n                            \"remain\": 80,\n                            \"k\": 0.5,\n                            \"tag_uid\": \"ABC123\",\n                            \"tray_uuid\": \"uuid-123\",\n                        }\n                    ],\n                }\n            ]\n        }\n\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"ams\"] is not None\n        assert len(result[\"ams\"]) == 1\n        assert result[\"ams\"][0][\"humidity\"] == 45\n        assert len(result[\"ams\"][0][\"tray\"]) == 1\n        assert result[\"ams\"][0][\"tray\"][0][\"tray_color\"] == \"FF0000\"\n\n    def test_empty_tag_uid_becomes_none(self, mock_state):\n        \"\"\"Verify empty tag_uid is converted to None.\"\"\"\n        mock_state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tag_uid\": \"\",\n                            \"tray_uuid\": \"00000000000000000000000000000000\",\n                        }\n                    ],\n                }\n            ]\n        }\n\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"ams\"][0][\"tray\"][0][\"tag_uid\"] is None\n        assert result[\"ams\"][0][\"tray\"][0][\"tray_uuid\"] is None\n\n    def test_zero_tag_uid_becomes_none(self, mock_state):\n        \"\"\"Verify zero tag_uid is converted to None.\"\"\"\n        mock_state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tag_uid\": \"0000000000000000\",\n                        }\n                    ],\n                }\n            ]\n        }\n\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"ams\"][0][\"tray\"][0][\"tag_uid\"] is None\n\n    def test_vt_tray_parsing(self, mock_state):\n        \"\"\"Verify virtual tray is parsed correctly as a list.\"\"\"\n        mock_state.raw_data = {\n            \"vt_tray\": [\n                {\n                    \"tray_color\": \"00FF00\",\n                    \"tray_type\": \"PETG\",\n                    \"tray_sub_brands\": \"Generic\",\n                    \"remain\": 60,\n                    \"tag_uid\": \"VT123\",\n                }\n            ]\n        }\n\n        result = printer_state_to_dict(mock_state)\n\n        assert isinstance(result[\"vt_tray\"], list)\n        assert len(result[\"vt_tray\"]) == 1\n        assert result[\"vt_tray\"][0][\"id\"] == 254\n        assert result[\"vt_tray\"][0][\"tray_color\"] == \"00FF00\"\n        assert result[\"vt_tray\"][0][\"tray_type\"] == \"PETG\"\n\n    def test_vt_tray_dict_normalized_to_list(self, mock_state):\n        \"\"\"Verify vt_tray as a raw dict (from MQTT) is normalized to a list.\"\"\"\n        mock_state.raw_data = {\n            \"vt_tray\": {\n                \"id\": \"254\",\n                \"tray_color\": \"FF0000\",\n                \"tray_type\": \"PLA\",\n                \"tray_sub_brands\": \"Generic\",\n                \"tag_uid\": \"0000000000000000\",\n                \"tray_uuid\": \"00000000000000000000000000000000\",\n                \"remain\": 0,\n            }\n        }\n\n        result = printer_state_to_dict(mock_state)\n\n        assert isinstance(result[\"vt_tray\"], list)\n        assert len(result[\"vt_tray\"]) == 1\n        assert result[\"vt_tray\"][0][\"tray_color\"] == \"FF0000\"\n        assert result[\"vt_tray\"][0][\"tray_type\"] == \"PLA\"\n        assert result[\"vt_tray\"][0][\"tag_uid\"] is None\n        assert result[\"vt_tray\"][0][\"tray_uuid\"] is None\n\n    def test_vt_tray_non_list_non_dict_ignored(self, mock_state):\n        \"\"\"Verify unexpected vt_tray types (e.g. string) produce empty list.\"\"\"\n        mock_state.raw_data = {\"vt_tray\": \"unexpected_string\"}\n\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"vt_tray\"] == []\n\n    def test_hms_errors_conversion(self, mock_state):\n        \"\"\"Verify HMS errors are converted correctly.\"\"\"\n        error = MagicMock()\n        error.code = \"0700_0100\"\n        error.attr = 1\n        error.module = \"AMS\"\n        error.severity = 2\n        mock_state.hms_errors = [error]\n\n        result = printer_state_to_dict(mock_state)\n\n        assert len(result[\"hms_errors\"]) == 1\n        assert result[\"hms_errors\"][0][\"code\"] == \"0700_0100\"\n        assert result[\"hms_errors\"][0][\"module\"] == \"AMS\"\n\n    def test_cover_url_added_for_running_print(self, mock_state):\n        \"\"\"Verify cover_url is added for running prints.\"\"\"\n        result = printer_state_to_dict(mock_state, printer_id=1)\n\n        assert result[\"cover_url\"] == \"/api/v1/printers/1/cover\"\n\n    def test_current_plate_id_extracted_from_gcode_file(self, mock_state):\n        \"\"\"Verify current_plate_id is parsed from a Bambu plate path (#881).\"\"\"\n        mock_state.gcode_file = \"/Metadata/plate_3.gcode\"\n\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"current_plate_id\"] == 3\n\n    def test_current_plate_id_none_when_no_plate_segment(self, mock_state):\n        \"\"\"Verify current_plate_id stays None when gcode_file has no plate marker.\"\"\"\n        mock_state.gcode_file = \"/sdcard/test.gcode\"\n\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"current_plate_id\"] is None\n\n    def test_cover_url_none_when_not_running(self, mock_state):\n        \"\"\"Verify cover_url is None when not printing.\"\"\"\n        mock_state.state = \"IDLE\"\n\n        result = printer_state_to_dict(mock_state, printer_id=1)\n\n        assert result[\"cover_url\"] is None\n\n    def test_ams_ht_detection(self, mock_state):\n        \"\"\"Verify AMS-HT is detected (1 tray vs 4).\"\"\"\n        mock_state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [{\"id\": 0}],  # Only 1 tray = AMS-HT\n                }\n            ]\n        }\n\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"ams\"][0][\"is_ams_ht\"] is True\n\n    def test_regular_ams_detection(self, mock_state):\n        \"\"\"Verify regular AMS is detected (4 trays).\"\"\"\n        mock_state.raw_data = {\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0}, {\"id\": 1}, {\"id\": 2}, {\"id\": 3}]}]}\n\n        result = printer_state_to_dict(mock_state)\n\n        assert result[\"ams\"][0][\"is_ams_ht\"] is False\n\n    def test_chamber_temp_filtered_for_p1s(self, mock_state):\n        \"\"\"Verify chamber temperature is filtered out for P1S (no chamber sensor).\"\"\"\n        mock_state.temperatures = {\n            \"nozzle\": 200,\n            \"bed\": 60,\n            \"chamber\": 5,\n            \"chamber_target\": 0,\n            \"chamber_heating\": False,\n        }\n\n        result = printer_state_to_dict(mock_state, model=\"P1S\")\n\n        assert \"chamber\" not in result[\"temperatures\"]\n        assert \"chamber_target\" not in result[\"temperatures\"]\n        assert \"chamber_heating\" not in result[\"temperatures\"]\n        assert result[\"temperatures\"][\"nozzle\"] == 200\n        assert result[\"temperatures\"][\"bed\"] == 60\n\n    def test_chamber_temp_kept_for_x1c(self, mock_state):\n        \"\"\"Verify chamber temperature is kept for X1C (has chamber sensor).\"\"\"\n        mock_state.temperatures = {\n            \"nozzle\": 200,\n            \"bed\": 60,\n            \"chamber\": 25,\n            \"chamber_target\": 45,\n            \"chamber_heating\": True,\n        }\n\n        result = printer_state_to_dict(mock_state, model=\"X1C\")\n\n        assert result[\"temperatures\"][\"chamber\"] == 25\n        assert result[\"temperatures\"][\"chamber_target\"] == 45\n        assert result[\"temperatures\"][\"chamber_heating\"] is True\n\n    def test_chamber_temp_filtered_for_a1(self, mock_state):\n        \"\"\"Verify chamber temperature is filtered out for A1 (no chamber sensor).\"\"\"\n        mock_state.temperatures = {\"nozzle\": 200, \"bed\": 60, \"chamber\": 5}\n\n        result = printer_state_to_dict(mock_state, model=\"A1\")\n\n        assert \"chamber\" not in result[\"temperatures\"]\n\n    def test_chamber_temp_kept_when_no_model(self, mock_state):\n        \"\"\"Verify chamber temperature is kept when model is not specified (conservative approach).\"\"\"\n        mock_state.temperatures = {\"nozzle\": 200, \"bed\": 60, \"chamber\": 25}\n\n        result = printer_state_to_dict(mock_state)  # No model specified\n\n        # When model is unknown, we can't filter - leave as is\n        # Actually supports_chamber_temp returns False for None, so it will filter\n        # Let's check the actual behavior\n        assert \"chamber\" not in result[\"temperatures\"]\n\n    def test_ams_drying_fields_included(self, mock_state):\n        \"\"\"Verify AMS drying fields (dry_time, module_type) are included in output.\"\"\"\n        mock_state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"dry_time\": 42,\n                    \"module_type\": \"n3f\",\n                    \"tray\": [\n                        {\n                            \"id\": 0,\n                            \"tray_color\": \"FF0000\",\n                            \"tray_type\": \"PLA\",\n                            \"drying_temp\": 55,\n                            \"drying_time\": 240,\n                        }\n                    ],\n                }\n            ]\n        }\n\n        result = printer_state_to_dict(mock_state)\n\n        ams_unit = result[\"ams\"][0]\n        assert ams_unit[\"dry_time\"] == 42\n        assert ams_unit[\"module_type\"] == \"n3f\"\n        # Tray-level drying fields\n        tray = ams_unit[\"tray\"][0]\n        assert tray[\"drying_temp\"] == 55\n        assert tray[\"drying_time\"] == 240\n\n    def test_awaiting_plate_clear_defaults_false(self, mock_state):\n        \"\"\"Without a printer_id, awaiting_plate_clear is False (no lookup possible).\"\"\"\n        result = printer_state_to_dict(mock_state)\n        assert result[\"awaiting_plate_clear\"] is False\n\n    def test_awaiting_plate_clear_surfaced_when_set(self, mock_state):\n        \"\"\"With printer_id, awaiting_plate_clear reflects PrinterManager state.\n\n        Regression: PR #939 left this flag off the WebSocket payload, so the\n        \"Clear Plate\" button only appeared after the 30 s REST fallback poll.\n        \"\"\"\n        from backend.app.services.printer_manager import printer_manager\n\n        printer_manager.set_awaiting_plate_clear(12345, True)\n        try:\n            result = printer_state_to_dict(mock_state, printer_id=12345)\n            assert result[\"awaiting_plate_clear\"] is True\n        finally:\n            printer_manager.set_awaiting_plate_clear(12345, False)\n\n\nclass TestStatusKeyDryingDedup:\n    \"\"\"Regression tests for WebSocket dedup including drying fields.\n\n    The WebSocket broadcast deduplication uses printer_state_to_dict output\n    to detect changes. If drying fields (like dry_time) are missing from\n    the dict, changes to those fields won't trigger broadcasts.\n    \"\"\"\n\n    def test_dry_time_change_changes_status_key(self):\n        \"\"\"Verify dry_time is present in AMS unit data so dedup can detect changes.\"\"\"\n        state = MagicMock()\n        state.connected = True\n        state.state = \"IDLE\"\n        state.current_print = None\n        state.subtask_name = None\n        state.gcode_file = None\n        state.progress = 0\n        state.remaining_time = 0\n        state.layer_num = 0\n        state.total_layers = 0\n        state.temperatures = {\"nozzle\": 25, \"bed\": 25}\n        state.hms_errors = []\n        state.ams_status_main = 0\n        state.ams_status_sub = 0\n        state.tray_now = None\n        state.wifi_signal = -50\n        state.stg_cur = -1\n\n        # First state: drying active with 30 minutes remaining\n        state.raw_data = {\"ams\": [{\"id\": 0, \"dry_time\": 30, \"module_type\": \"n3f\", \"tray\": [{\"id\": 0}]}]}\n        result1 = printer_state_to_dict(state)\n\n        # Second state: drying time decreased\n        state.raw_data = {\"ams\": [{\"id\": 0, \"dry_time\": 29, \"module_type\": \"n3f\", \"tray\": [{\"id\": 0}]}]}\n        result2 = printer_state_to_dict(state)\n\n        # The dicts should differ — dry_time changed\n        assert result1[\"ams\"][0][\"dry_time\"] == 30\n        assert result2[\"ams\"][0][\"dry_time\"] == 29\n        assert result1[\"ams\"] != result2[\"ams\"]\n\n\nclass TestSupportsChamberTemp:\n    \"\"\"Tests for supports_chamber_temp helper function.\"\"\"\n\n    def test_x1_series_supported(self):\n        \"\"\"Verify X1 series printers support chamber temp.\"\"\"\n        assert supports_chamber_temp(\"X1\") is True\n        assert supports_chamber_temp(\"X1C\") is True\n        assert supports_chamber_temp(\"X1E\") is True\n\n    def test_p2_series_supported(self):\n        \"\"\"Verify P2 series printers support chamber temp.\"\"\"\n        assert supports_chamber_temp(\"P2S\") is True\n\n    def test_h2_series_supported(self):\n        \"\"\"Verify H2 series printers support chamber temp.\"\"\"\n        assert supports_chamber_temp(\"H2C\") is True\n        assert supports_chamber_temp(\"H2D\") is True\n        assert supports_chamber_temp(\"H2DPRO\") is True\n        assert supports_chamber_temp(\"H2S\") is True\n\n    def test_p1_series_not_supported(self):\n        \"\"\"Verify P1 series printers do NOT support chamber temp.\"\"\"\n        assert supports_chamber_temp(\"P1P\") is False\n        assert supports_chamber_temp(\"P1S\") is False\n\n    def test_a1_series_not_supported(self):\n        \"\"\"Verify A1 series printers do NOT support chamber temp.\"\"\"\n        assert supports_chamber_temp(\"A1\") is False\n        assert supports_chamber_temp(\"A1MINI\") is False\n\n    def test_none_model_not_supported(self):\n        \"\"\"Verify None model returns False.\"\"\"\n        assert supports_chamber_temp(None) is False\n\n    def test_case_insensitive(self):\n        \"\"\"Verify model matching is case-insensitive.\"\"\"\n        assert supports_chamber_temp(\"x1c\") is True\n        assert supports_chamber_temp(\"X1c\") is True\n        assert supports_chamber_temp(\"p1s\") is False\n\n    def test_internal_model_codes_supported(self):\n        \"\"\"Verify internal model codes from MQTT/SSDP are recognized.\"\"\"\n        # X1/X1C\n        assert supports_chamber_temp(\"BL-P001\") is True\n        # X1E\n        assert supports_chamber_temp(\"C13\") is True\n        # H2D\n        assert supports_chamber_temp(\"O1D\") is True\n        # H2C\n        assert supports_chamber_temp(\"O1C\") is True\n        # H2S\n        assert supports_chamber_temp(\"O1S\") is True\n        # H2D Pro\n        assert supports_chamber_temp(\"O1E\") is True\n        # P2S\n        assert supports_chamber_temp(\"N7\") is True\n\n    def test_internal_model_codes_not_supported(self):\n        \"\"\"Verify A1/P1 internal codes are NOT supported.\"\"\"\n        # P1P\n        assert supports_chamber_temp(\"C11\") is False\n        # P1S\n        assert supports_chamber_temp(\"C12\") is False\n        # A1\n        assert supports_chamber_temp(\"N2S\") is False\n        # A1 Mini\n        assert supports_chamber_temp(\"N1\") is False\n\n\nclass TestSupportsDrying:\n    \"\"\"Tests for supports_drying helper function.\"\"\"\n\n    def test_known_supported_with_firmware(self):\n        \"\"\"Verify known models with sufficient firmware return True.\"\"\"\n        assert supports_drying(\"X1C\", \"01.09.00.00\") is True\n        assert supports_drying(\"P1S\", \"01.08.00.00\") is True\n        assert supports_drying(\"H2D\", \"01.02.30.00\") is True\n        assert supports_drying(\"H2S\", \"01.02.00.00\") is True\n        assert supports_drying(\"P2S\", \"01.02.00.00\") is True\n        assert supports_drying(\"N7\", \"01.02.00.00\") is True\n\n    def test_known_supported_old_firmware(self):\n        \"\"\"Verify known models with old firmware return False.\"\"\"\n        assert supports_drying(\"X1C\", \"01.08.00.00\") is False\n        assert supports_drying(\"P1S\", \"01.07.00.00\") is False\n        assert supports_drying(\"H2S\", \"01.01.00.00\") is False\n        assert supports_drying(\"P2S\", \"01.01.99.99\") is False\n        assert supports_drying(\"N7\", \"01.01.99.99\") is False\n\n    def test_known_supported_no_firmware(self):\n        \"\"\"Verify known models with no firmware return False.\"\"\"\n        assert supports_drying(\"X1C\", None) is False\n        assert supports_drying(\"P2S\", None) is False\n\n    def test_unsupported_models(self):\n        \"\"\"Verify models without AMS drying support return False regardless of firmware.\"\"\"\n        for model in [\"A1\", \"A1MINI\", \"A1-MINI\", \"H2C\", \"N1\", \"N2S\"]:\n            assert supports_drying(model, \"99.99.99.99\") is False, f\"Expected False for {model}\"\n\n    def test_unknown_models_allowed(self):\n        \"\"\"Verify unknown models are allowed (graceful fallback).\n\n        Models not in the unsupported set AND not matching any known firmware-gated\n        model substring get the benefit of the doubt and return True.\n        \"H2D Pro\" contains \"H2D\" so it IS firmware-gated (needs firmware).\n        \"\"\"\n        # Truly unknown models: no substring match in _DRYING_MIN_FIRMWARE\n        assert supports_drying(\"FUTURE_MODEL\", None) is True\n        # X1E contains \"X1\" substring, so it IS firmware-gated\n        assert supports_drying(\"X1E\", \"01.09.00.00\") is True\n        # H2D Pro contains \"H2D\" substring, so it IS firmware-gated\n        assert supports_drying(\"H2D Pro\", \"01.02.30.00\") is True\n\n    def test_none_model(self):\n        \"\"\"Verify None model returns False.\"\"\"\n        assert supports_drying(None, \"01.09.00.00\") is False\n\n    def test_case_insensitive(self):\n        \"\"\"Verify model matching is case-insensitive.\"\"\"\n        assert supports_drying(\"x1c\", \"01.09.00.00\") is True\n        assert supports_drying(\"p2s\", \"01.02.00.00\") is True\n        assert supports_drying(\"a1\", \"99.99.99.99\") is False\n\n\nclass TestGetDerivedStatusName:\n    \"\"\"Tests for get_derived_status_name function.\"\"\"\n\n    def test_stg_cur_255_returns_none(self):\n        \"\"\"Verify stg_cur=255 (A1/P1 idle) returns None, not 'Unknown stage (255)'.\"\"\"\n        state = MagicMock()\n        state.stg_cur = 255\n        state.state = \"IDLE\"\n\n        result = get_derived_status_name(state)\n\n        assert result is None\n\n    def test_stg_cur_negative_one_returns_none_when_idle(self):\n        \"\"\"Verify stg_cur=-1 (X1 idle) returns None.\"\"\"\n        state = MagicMock()\n        state.stg_cur = -1\n        state.state = \"IDLE\"\n\n        result = get_derived_status_name(state)\n\n        assert result is None\n\n    def test_valid_stage_returns_name(self):\n        \"\"\"Verify valid stg_cur values return stage name.\"\"\"\n        state = MagicMock()\n        state.stg_cur = 1  # Auto bed leveling\n\n        result = get_derived_status_name(state)\n\n        assert result == \"Auto bed leveling\"\n\n    def test_stg_cur_zero_returns_printing(self):\n        \"\"\"Verify stg_cur=0 returns 'Printing' when no model specified.\"\"\"\n        state = MagicMock()\n        state.stg_cur = 0\n\n        result = get_derived_status_name(state)\n\n        assert result == \"Printing\"\n\n    def test_a1_idle_with_stg_cur_zero_returns_none(self):\n        \"\"\"Verify A1 with IDLE state and stg_cur=0 returns None (bug workaround).\"\"\"\n        state = MagicMock()\n        state.stg_cur = 0\n        state.state = \"IDLE\"\n\n        # Test various A1 model names\n        for model in [\"A1\", \"A1 Mini\", \"A1-Mini\", \"A1MINI\", \"N1\", \"N2S\"]:\n            result = get_derived_status_name(state, model)\n            assert result is None, f\"Expected None for model {model}\"\n\n    def test_a1_running_with_stg_cur_zero_returns_printing(self):\n        \"\"\"Verify A1 with RUNNING state and stg_cur=0 still returns 'Printing'.\"\"\"\n        state = MagicMock()\n        state.stg_cur = 0\n        state.state = \"RUNNING\"\n\n        result = get_derived_status_name(state, \"A1\")\n\n        assert result == \"Printing\"\n\n    def test_non_a1_idle_with_stg_cur_zero_returns_printing(self):\n        \"\"\"Verify non-A1 models with IDLE and stg_cur=0 still return 'Printing'.\"\"\"\n        state = MagicMock()\n        state.stg_cur = 0\n        state.state = \"IDLE\"\n\n        # X1C should not get the workaround\n        result = get_derived_status_name(state, \"X1C\")\n\n        assert result == \"Printing\"\n\n\nclass TestHasStgCurIdleBug:\n    \"\"\"Tests for has_stg_cur_idle_bug function.\"\"\"\n\n    def test_a1_models_return_true(self):\n        \"\"\"Verify A1 model variants return True.\"\"\"\n        assert has_stg_cur_idle_bug(\"A1\") is True\n        assert has_stg_cur_idle_bug(\"A1 Mini\") is True\n        assert has_stg_cur_idle_bug(\"A1-Mini\") is True\n        assert has_stg_cur_idle_bug(\"A1MINI\") is True\n        assert has_stg_cur_idle_bug(\"a1\") is True  # case insensitive\n        assert has_stg_cur_idle_bug(\"a1 mini\") is True\n\n    def test_p1_models_return_true(self):\n        \"\"\"Verify P1P/P1S model variants return True.\"\"\"\n        assert has_stg_cur_idle_bug(\"P1P\") is True\n        assert has_stg_cur_idle_bug(\"P1S\") is True\n        assert has_stg_cur_idle_bug(\"p1p\") is True  # case insensitive\n\n    def test_internal_codes_return_true(self):\n        \"\"\"Verify internal model codes return True.\"\"\"\n        assert has_stg_cur_idle_bug(\"N1\") is True  # A1 Mini\n        assert has_stg_cur_idle_bug(\"N2S\") is True  # A1\n        assert has_stg_cur_idle_bug(\"C11\") is True  # P1P\n        assert has_stg_cur_idle_bug(\"C12\") is True  # P1S\n\n    def test_non_affected_models_return_false(self):\n        \"\"\"Verify non-affected models return False.\"\"\"\n        assert has_stg_cur_idle_bug(\"X1C\") is False\n        assert has_stg_cur_idle_bug(\"X1\") is False\n        assert has_stg_cur_idle_bug(\"H2D\") is False\n\n    def test_none_model_returns_false(self):\n        \"\"\"Verify None model returns False.\"\"\"\n        assert has_stg_cur_idle_bug(None) is False\n\n    def test_empty_model_returns_false(self):\n        \"\"\"Verify empty model returns False.\"\"\"\n        assert has_stg_cur_idle_bug(\"\") is False\n\n\nclass TestInitPrinterConnections:\n    \"\"\"Tests for init_printer_connections function.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_connects_all_active_printers(self):\n        \"\"\"Verify all active printers are connected.\"\"\"\n        mock_db = AsyncMock()\n        mock_printer1 = MagicMock(id=1, is_active=True)\n        mock_printer2 = MagicMock(id=2, is_active=True)\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = [mock_printer1, mock_printer2]\n        mock_db.execute.return_value = mock_result\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_manager:\n            mock_manager.connect_printer = AsyncMock()\n\n            await init_printer_connections(mock_db)\n\n            assert mock_manager.connect_printer.call_count == 2\n\n    @pytest.mark.asyncio\n    async def test_handles_empty_printer_list(self):\n        \"\"\"Verify empty printer list is handled.\"\"\"\n        mock_db = AsyncMock()\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = []\n        mock_db.execute.return_value = mock_result\n\n        with patch(\"backend.app.services.printer_manager.printer_manager\") as mock_manager:\n            mock_manager.connect_printer = AsyncMock()\n\n            await init_printer_connections(mock_db)\n\n            mock_manager.connect_printer.assert_not_called()\n\n\nclass TestAmsChangeCallback:\n    \"\"\"Tests for AMS change callback functionality.\"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        \"\"\"Create a fresh PrinterManager instance.\"\"\"\n        return PrinterManager()\n\n    def test_ams_change_callback_is_triggered(self, manager):\n        \"\"\"Verify AMS change callback is called when AMS data changes.\"\"\"\n        callback = MagicMock()\n        manager.set_ams_change_callback(callback)\n\n        # Verify callback was set\n        assert manager._on_ams_change == callback\n\n    def test_ams_change_callback_receives_correct_data(self, manager):\n        \"\"\"Verify AMS change callback receives the correct AMS data format.\"\"\"\n        received_data = []\n\n        def capture_callback(printer_id, ams_data):\n            received_data.append((printer_id, ams_data))\n\n        manager.set_ams_change_callback(capture_callback)\n\n        # The callback should accept printer_id and ams_data\n        # This tests the callback signature\n        assert manager._on_ams_change is not None\n        assert callable(manager._on_ams_change)\n\n\nclass TestParsePlateId:\n    \"\"\"Tests for parse_plate_id() — active-print plate extraction from gcode paths.\n\n    Regression coverage for #881 follow-up: the REST /status endpoint and the\n    WebSocket push path both use this helper, so they must agree on the plate\n    number the frontend sees.\n    \"\"\"\n\n    def test_bambu_metadata_path(self):\n        # Canonical path that Bambu Studio / OrcaSlicer stamp into the 3MF.\n        assert parse_plate_id(\"/Metadata/plate_2.gcode\") == 2\n\n    def test_plate_one(self):\n        assert parse_plate_id(\"/Metadata/plate_1.gcode\") == 1\n\n    def test_double_digit_plate(self):\n        assert parse_plate_id(\"/Metadata/plate_12.gcode\") == 12\n\n    def test_none_input(self):\n        assert parse_plate_id(None) is None\n\n    def test_empty_string(self):\n        assert parse_plate_id(\"\") is None\n\n    def test_path_without_plate_segment(self):\n        # Some firmware / slicers report a bare filename without the plate marker.\n        assert parse_plate_id(\"/upload/my-model.gcode\") is None\n\n    def test_similar_but_non_matching_names(self):\n        # \"plate.gcode\" (no number) and \"nameplate_2.gcode\" (substring) must not\n        # be mistaken for real plate markers. The regex anchors on `plate_<num>`.\n        assert parse_plate_id(\"/Metadata/plate.gcode\") is None\n        assert parse_plate_id(\"/plates/3.gcode\") is None\n\n    def test_substring_match_still_extracts(self):\n        # The regex isn't anchored to the start of a segment — any occurrence\n        # wins. This matches real Bambu paths where the segment is preceded by\n        # arbitrary directory noise, and matches the equivalent frontend regex.\n        assert parse_plate_id(\"/uploads/project/plate_5.gcode.md5\") == 5\n"
  },
  {
    "path": "backend/tests/unit/services/test_rest_smart_plug.py",
    "content": "\"\"\"Unit tests for REST smart plug service.\"\"\"\n\nimport json\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport httpx\nimport pytest\n\nfrom backend.app.services.rest_smart_plug import RESTSmartPlugService\n\n\n@pytest.fixture\ndef service():\n    return RESTSmartPlugService(timeout=5.0)\n\n\n@pytest.fixture\ndef mock_plug():\n    plug = MagicMock()\n    plug.name = \"Test REST Plug\"\n    plug.plug_type = \"rest\"\n    plug.rest_on_url = \"http://192.168.1.50:8080/api/plug/on\"\n    plug.rest_on_body = '{\"state\": \"on\"}'\n    plug.rest_off_url = \"http://192.168.1.50:8080/api/plug/off\"\n    plug.rest_off_body = '{\"state\": \"off\"}'\n    plug.rest_method = \"POST\"\n    plug.rest_headers = '{\"Authorization\": \"Bearer test-token\"}'\n    plug.rest_status_url = \"http://192.168.1.50:8080/api/plug/status\"\n    plug.rest_status_path = \"state\"\n    plug.rest_status_on_value = \"ON\"\n    plug.rest_power_url = None\n    plug.rest_power_path = \"power\"\n    plug.rest_power_multiplier = 1.0\n    plug.rest_energy_url = None\n    plug.rest_energy_path = \"energy.today\"\n    plug.rest_energy_multiplier = 1.0\n    return plug\n\n\nclass TestURLValidation:\n    def test_valid_ip_url(self, service):\n        assert service._validate_url(\"http://192.168.1.50:8080/api\") is True\n\n    def test_hostname_url(self, service):\n        assert service._validate_url(\"http://openhab.local:8080/api\") is True\n\n    def test_loopback_blocked(self, service):\n        assert service._validate_url(\"http://127.0.0.1/api\") is False\n\n    def test_link_local_blocked(self, service):\n        assert service._validate_url(\"http://169.254.1.1/api\") is False\n\n    def test_empty_hostname(self, service):\n        assert service._validate_url(\"http:///api\") is False\n\n\nclass TestParseHeaders:\n    def test_valid_json(self, service):\n        headers = service._parse_headers('{\"Authorization\": \"Bearer abc\", \"X-Custom\": \"val\"}')\n        assert headers == {\"Authorization\": \"Bearer abc\", \"X-Custom\": \"val\"}\n\n    def test_none_headers(self, service):\n        assert service._parse_headers(None) == {}\n\n    def test_empty_string(self, service):\n        assert service._parse_headers(\"\") == {}\n\n    def test_invalid_json(self, service):\n        assert service._parse_headers(\"not json\") == {}\n\n\nclass TestExtractJsonPath:\n    def test_simple_path(self, service):\n        data = {\"state\": \"ON\"}\n        assert service._extract_json_path(data, \"state\") == \"ON\"\n\n    def test_nested_path(self, service):\n        data = {\"data\": {\"power\": {\"current\": 42.5}}}\n        assert service._extract_json_path(data, \"data.power.current\") == 42.5\n\n    def test_missing_path(self, service):\n        data = {\"state\": \"ON\"}\n        assert service._extract_json_path(data, \"missing\") is None\n\n    def test_empty_path(self, service):\n        assert service._extract_json_path({\"a\": 1}, \"\") is None\n\n    def test_none_path(self, service):\n        assert service._extract_json_path({\"a\": 1}, None) is None\n\n\nclass TestTurnOn:\n    @pytest.mark.asyncio\n    async def test_turn_on_success(self, service, mock_plug):\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=mock_response):\n            result = await service.turn_on(mock_plug)\n\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_turn_on_failure(self, service, mock_plug):\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=None):\n            result = await service.turn_on(mock_plug)\n\n        assert result is False\n\n    @pytest.mark.asyncio\n    async def test_turn_on_no_url(self, service, mock_plug):\n        mock_plug.rest_on_url = None\n        result = await service.turn_on(mock_plug)\n        assert result is False\n\n\nclass TestTurnOff:\n    @pytest.mark.asyncio\n    async def test_turn_off_success(self, service, mock_plug):\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=mock_response):\n            result = await service.turn_off(mock_plug)\n\n        assert result is True\n\n    @pytest.mark.asyncio\n    async def test_turn_off_no_url(self, service, mock_plug):\n        mock_plug.rest_off_url = None\n        result = await service.turn_off(mock_plug)\n        assert result is False\n\n\nclass TestGetStatus:\n    @pytest.mark.asyncio\n    async def test_status_on(self, service, mock_plug):\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"state\": \"ON\"}\n\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=mock_response):\n            result = await service.get_status(mock_plug)\n\n        assert result[\"state\"] == \"ON\"\n        assert result[\"reachable\"] is True\n\n    @pytest.mark.asyncio\n    async def test_status_off(self, service, mock_plug):\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"state\": \"OFF\"}\n\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=mock_response):\n            result = await service.get_status(mock_plug)\n\n        assert result[\"state\"] == \"OFF\"\n        assert result[\"reachable\"] is True\n\n    @pytest.mark.asyncio\n    async def test_status_unreachable(self, service, mock_plug):\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=None):\n            result = await service.get_status(mock_plug)\n\n        assert result[\"state\"] is None\n        assert result[\"reachable\"] is False\n\n    @pytest.mark.asyncio\n    async def test_status_no_url(self, service, mock_plug):\n        mock_plug.rest_status_url = None\n        result = await service.get_status(mock_plug)\n\n        assert result[\"state\"] is None\n        assert result[\"reachable\"] is True  # No URL = assume reachable\n\n\nclass TestGetEnergy:\n    @pytest.mark.asyncio\n    async def test_energy_with_paths(self, service, mock_plug):\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"power\": 42.5, \"energy\": {\"today\": 1.23}}\n\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=mock_response):\n            result = await service.get_energy(mock_plug)\n\n        assert result[\"power\"] == 42.5\n        assert result[\"today\"] == 1.23\n\n    @pytest.mark.asyncio\n    async def test_energy_no_status_url_no_separate_urls(self, service, mock_plug):\n        \"\"\"No URLs at all (status=None, power_url=None, energy_url=None) → None.\"\"\"\n        mock_plug.rest_status_url = None\n        mock_plug.rest_power_url = None\n        mock_plug.rest_energy_url = None\n        result = await service.get_energy(mock_plug)\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_energy_no_paths(self, service, mock_plug):\n        mock_plug.rest_power_path = None\n        mock_plug.rest_energy_path = None\n        result = await service.get_energy(mock_plug)\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_energy_with_separate_urls(self, service, mock_plug):\n        \"\"\"Power and energy fetched from different URLs.\"\"\"\n        mock_plug.rest_power_url = \"http://192.168.1.50:8087/power\"\n        mock_plug.rest_energy_url = \"http://192.168.1.50:8087/energy\"\n\n        power_response = MagicMock()\n        power_response.json.return_value = {\"power\": 9.5}\n        energy_response = MagicMock()\n        energy_response.json.return_value = {\"energy\": {\"today\": 30947.07}}\n\n        call_count = 0\n\n        async def mock_send(url, method=\"GET\", headers=None, body=None):\n            nonlocal call_count\n            call_count += 1\n            if \"power\" in url:\n                return power_response\n            return energy_response\n\n        with patch.object(service, \"_send_request\", side_effect=mock_send):\n            result = await service.get_energy(mock_plug)\n\n        assert call_count == 2\n        assert result[\"power\"] == 9.5\n        assert result[\"today\"] == 30947.07\n\n    @pytest.mark.asyncio\n    async def test_energy_with_multipliers(self, service, mock_plug):\n        \"\"\"Multipliers convert units (e.g., Wh → kWh).\"\"\"\n        mock_plug.rest_energy_multiplier = 0.001  # Wh → kWh\n\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"power\": 9.5, \"energy\": {\"today\": 30947.07}}\n\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=mock_response):\n            result = await service.get_energy(mock_plug)\n\n        assert result[\"power\"] == 9.5  # No multiplier (default 1.0)\n        assert result[\"today\"] == pytest.approx(30.94707)  # 30947.07 * 0.001\n\n    @pytest.mark.asyncio\n    async def test_energy_separate_url_falls_back_to_status(self, service, mock_plug):\n        \"\"\"When no separate URL is set, falls back to status URL.\"\"\"\n        mock_plug.rest_power_url = None\n        mock_plug.rest_energy_url = None\n\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"power\": 42.5, \"energy\": {\"today\": 1.23}}\n\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=mock_response):\n            result = await service.get_energy(mock_plug)\n\n        assert result[\"power\"] == 42.5\n        assert result[\"today\"] == 1.23\n\n    @pytest.mark.asyncio\n    async def test_energy_no_urls_at_all(self, service, mock_plug):\n        \"\"\"No status URL and no separate URLs → None.\"\"\"\n        mock_plug.rest_status_url = None\n        mock_plug.rest_power_url = None\n        mock_plug.rest_energy_url = None\n\n        result = await service.get_energy(mock_plug)\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_energy_deduplicates_same_url(self, service, mock_plug):\n        \"\"\"When power and energy both fall back to status URL, only one HTTP request is made.\"\"\"\n        mock_plug.rest_power_url = None\n        mock_plug.rest_energy_url = None\n\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"power\": 42.5, \"energy\": {\"today\": 1.23}}\n\n        with patch.object(service, \"_send_request\", new_callable=AsyncMock, return_value=mock_response) as mock_send:\n            result = await service.get_energy(mock_plug)\n\n        assert mock_send.call_count == 1\n        assert result[\"power\"] == 42.5\n        assert result[\"today\"] == 1.23\n\n\nclass TestTestConnection:\n    @pytest.mark.asyncio\n    async def test_connection_success(self, service):\n        with patch(\"httpx.AsyncClient\") as mock_client_cls:\n            mock_client = AsyncMock()\n            mock_response = MagicMock()\n            mock_response.raise_for_status = MagicMock()\n            mock_client.request = AsyncMock(return_value=mock_response)\n            mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client.__aexit__ = AsyncMock(return_value=None)\n            mock_client_cls.return_value = mock_client\n\n            result = await service.test_connection(\"http://192.168.1.50:8080/api\")\n\n        assert result[\"success\"] is True\n\n    @pytest.mark.asyncio\n    async def test_connection_timeout(self, service):\n        with patch(\"httpx.AsyncClient\") as mock_client_cls:\n            mock_client = AsyncMock()\n            mock_client.request = AsyncMock(side_effect=httpx.TimeoutException(\"timeout\"))\n            mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client.__aexit__ = AsyncMock(return_value=None)\n            mock_client_cls.return_value = mock_client\n\n            result = await service.test_connection(\"http://192.168.1.50:8080/api\")\n\n        assert result[\"success\"] is False\n        assert \"timed out\" in result[\"error\"]\n\n    @pytest.mark.asyncio\n    async def test_connection_invalid_url(self, service):\n        result = await service.test_connection(\"http://127.0.0.1/api\")\n        assert result[\"success\"] is False\n        assert \"blocked\" in result[\"error\"].lower()\n"
  },
  {
    "path": "backend/tests/unit/services/test_smart_plug_manager.py",
    "content": "\"\"\"Unit tests for SmartPlugManager service.\n\nThese tests specifically target the auto-off behavior and toggle functionality\nthat were identified as common regression points.\n\"\"\"\n\nfrom datetime import datetime, timezone\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.smart_plug_manager import SmartPlugManager\n\n\nclass TestSmartPlugManager:\n    \"\"\"Tests for SmartPlugManager class.\"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        \"\"\"Create a fresh SmartPlugManager instance.\"\"\"\n        return SmartPlugManager()\n\n    @pytest.fixture\n    def mock_plug(self):\n        \"\"\"Create a mock SmartPlug object.\"\"\"\n        plug = MagicMock()\n        plug.id = 1\n        plug.name = \"Test Plug\"\n        plug.ip_address = \"192.168.1.100\"\n        plug.username = None\n        plug.password = None\n        plug.enabled = True\n        plug.auto_on = True\n        plug.auto_off = True\n        plug.off_delay_mode = \"time\"\n        plug.off_delay_minutes = 5\n        plug.off_temp_threshold = 70\n        plug.printer_id = 1\n        plug.auto_off_executed = False\n        plug.auto_off_pending = False\n        plug.last_state = \"ON\"\n        plug.last_checked = None\n        return plug\n\n    @pytest.fixture\n    def mock_db(self):\n        \"\"\"Create a mock database session.\"\"\"\n        db = AsyncMock()\n        db.commit = AsyncMock()\n        db.refresh = AsyncMock()\n        return db\n\n    # ========================================================================\n    # Tests for on_print_start\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):\n        \"\"\"Verify plug is turned ON when print starts with auto_on enabled.\"\"\"\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n            mock_tasmota.turn_on = AsyncMock(return_value=True)\n\n            await manager.on_print_start(printer_id=1, db=mock_db)\n\n            mock_tasmota.turn_on.assert_called_once_with(mock_plug)\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_skipped_when_auto_on_disabled(self, manager, mock_plug, mock_db):\n        \"\"\"Verify plug is NOT turned on when auto_on is disabled.\"\"\"\n        mock_plug.auto_on = False\n\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n            mock_tasmota.turn_on = AsyncMock()\n\n            await manager.on_print_start(printer_id=1, db=mock_db)\n\n            mock_tasmota.turn_on.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):\n        \"\"\"Verify plug is NOT turned on when plug.enabled is False.\"\"\"\n        mock_plug.enabled = False\n\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n            mock_tasmota.turn_on = AsyncMock()\n\n            await manager.on_print_start(printer_id=1, db=mock_db)\n\n            mock_tasmota.turn_on.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_skipped_when_no_plug_found(self, manager, mock_db):\n        \"\"\"Verify graceful handling when no plug is linked to printer.\"\"\"\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n        ):\n            mock_get_plug.return_value = []\n            mock_tasmota.turn_on = AsyncMock()\n\n            # Should not raise any exception\n            await manager.on_print_start(printer_id=999, db=mock_db)\n\n            mock_tasmota.turn_on.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_cancels_pending_off(self, manager, mock_plug, mock_db):\n        \"\"\"Verify starting a new print cancels any pending auto-off.\"\"\"\n        # Set up a pending task\n        mock_task = MagicMock()\n        manager._pending_off[mock_plug.id] = mock_task\n\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch.object(manager, \"_mark_auto_off_pending\", new_callable=AsyncMock),\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n            mock_tasmota.turn_on = AsyncMock(return_value=True)\n\n            await manager.on_print_start(printer_id=1, db=mock_db)\n\n            mock_task.cancel.assert_called_once()\n            assert mock_plug.id not in manager._pending_off\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_resets_auto_off_executed_flag(self, manager, mock_plug, mock_db):\n        \"\"\"Verify auto_off_executed flag is reset when turning on.\"\"\"\n        mock_plug.auto_off_executed = True\n\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n            mock_tasmota.turn_on = AsyncMock(return_value=True)\n\n            await manager.on_print_start(printer_id=1, db=mock_db)\n\n            assert mock_plug.auto_off_executed is False\n\n    # ========================================================================\n    # Tests for on_print_complete\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_schedules_time_based_off(self, manager, mock_plug, mock_db):\n        \"\"\"Verify time-based auto-off is scheduled when print completes.\"\"\"\n        mock_plug.off_delay_mode = \"time\"\n        mock_plug.off_delay_minutes = 5\n\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch.object(manager, \"_schedule_delayed_off\") as mock_schedule,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n\n            await manager.on_print_complete(printer_id=1, status=\"completed\", db=mock_db)\n\n            mock_schedule.assert_called_once_with(mock_plug, 1, 300)  # 5 min * 60 sec\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_schedules_temp_based_off(self, manager, mock_plug, mock_db):\n        \"\"\"Verify temperature-based auto-off is scheduled when print completes.\"\"\"\n        mock_plug.off_delay_mode = \"temperature\"\n        mock_plug.off_temp_threshold = 70\n\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch.object(manager, \"_schedule_temp_based_off\") as mock_schedule,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n\n            await manager.on_print_complete(printer_id=1, status=\"completed\", db=mock_db)\n\n            mock_schedule.assert_called_once_with(mock_plug, 1, 70)\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_skipped_when_auto_off_disabled(self, manager, mock_plug, mock_db):\n        \"\"\"CRITICAL: Verify auto-off does NOT trigger when auto_off is False.\n\n        This is a key regression test - the toggle must respect the setting.\n        \"\"\"\n        mock_plug.auto_off = False\n\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch.object(manager, \"_schedule_delayed_off\") as mock_schedule,\n            patch.object(manager, \"_schedule_temp_based_off\") as mock_temp,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n\n            await manager.on_print_complete(printer_id=1, status=\"completed\", db=mock_db)\n\n            mock_schedule.assert_not_called()\n            mock_temp.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):\n        \"\"\"Verify auto-off does NOT trigger when plug is disabled.\"\"\"\n        mock_plug.enabled = False\n\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch.object(manager, \"_schedule_delayed_off\") as mock_schedule,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n\n            await manager.on_print_complete(printer_id=1, status=\"completed\", db=mock_db)\n\n            mock_schedule.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_skipped_on_failed_print(self, manager, mock_plug, mock_db):\n        \"\"\"Verify auto-off does NOT trigger on failed prints for investigation.\"\"\"\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch.object(manager, \"_schedule_delayed_off\") as mock_schedule,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n\n            await manager.on_print_complete(printer_id=1, status=\"failed\", db=mock_db)\n\n            mock_schedule.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_on_print_complete_skipped_on_aborted_print(self, manager, mock_plug, mock_db):\n        \"\"\"Verify auto-off does NOT trigger on aborted prints.\"\"\"\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get_plug,\n            patch.object(manager, \"_schedule_delayed_off\") as mock_schedule,\n        ):\n            mock_get_plug.return_value = [mock_plug]\n\n            await manager.on_print_complete(printer_id=1, status=\"aborted\", db=mock_db)\n\n            mock_schedule.assert_not_called()\n\n    # ========================================================================\n    # Tests for _cancel_pending_off\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_cancel_pending_off_removes_task(self, manager, mock_plug):\n        \"\"\"Verify pending off tasks can be cancelled.\"\"\"\n        mock_task = MagicMock()\n        manager._pending_off[mock_plug.id] = mock_task\n\n        with patch.object(manager, \"_mark_auto_off_pending\", new_callable=AsyncMock):\n            manager._cancel_pending_off(mock_plug.id)\n\n        assert mock_plug.id not in manager._pending_off\n        mock_task.cancel.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_cancel_pending_off_handles_missing_task(self, manager):\n        \"\"\"Verify no error when cancelling non-existent task.\"\"\"\n        # Should not raise any exception\n        with patch.object(manager, \"_mark_auto_off_pending\", new_callable=AsyncMock):\n            manager._cancel_pending_off(999)  # Non-existent plug ID\n\n    @pytest.mark.asyncio\n    async def test_cancel_all_pending(self, manager, mock_plug):\n        \"\"\"Verify all pending tasks can be cancelled.\"\"\"\n        mock_task1 = MagicMock()\n        mock_task2 = MagicMock()\n        manager._pending_off[1] = mock_task1\n        manager._pending_off[2] = mock_task2\n\n        with patch(\"asyncio.create_task\"):\n            manager.cancel_all_pending()\n\n        assert len(manager._pending_off) == 0\n        mock_task1.cancel.assert_called_once()\n        mock_task2.cancel.assert_called_once()\n\n    # ========================================================================\n    # Tests for scheduler\n    # ========================================================================\n\n    def test_start_scheduler(self, manager):\n        \"\"\"Verify scheduler can be started.\"\"\"\n        assert manager._scheduler_task is None\n\n        # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning\n        with patch.object(manager, \"_schedule_loop\") as mock_loop, patch(\"asyncio.create_task\") as mock_create:\n            mock_create.return_value = MagicMock()\n            manager.start_scheduler()\n\n            assert manager._scheduler_task is not None\n            mock_loop.assert_called_once()\n\n    def test_stop_scheduler(self, manager):\n        \"\"\"Verify scheduler can be stopped.\"\"\"\n        mock_task = MagicMock()\n        manager._scheduler_task = mock_task\n\n        manager.stop_scheduler()\n\n        mock_task.cancel.assert_called_once()\n        assert manager._scheduler_task is None\n\n    def test_start_scheduler_idempotent(self, manager):\n        \"\"\"Verify starting scheduler twice doesn't create multiple tasks.\"\"\"\n        mock_schedule_task = MagicMock()\n        mock_snapshot_task = MagicMock()\n        manager._scheduler_task = mock_schedule_task\n        manager._snapshot_task = mock_snapshot_task\n\n        # Mock the loop coroutines to avoid unawaited coroutine warnings\n        with (\n            patch.object(manager, \"_schedule_loop\") as mock_loop,\n            patch.object(manager, \"_snapshot_loop\") as mock_snapshot,\n            patch(\"asyncio.create_task\") as mock_create,\n        ):\n            manager.start_scheduler()\n\n            mock_create.assert_not_called()  # Should not create new tasks\n            mock_loop.assert_not_called()\n            mock_snapshot.assert_not_called()\n\n    def test_stop_scheduler_cancels_snapshot_task(self, manager):\n        \"\"\"Verify stopping scheduler also cancels the snapshot loop (#941).\"\"\"\n        mock_schedule_task = MagicMock()\n        mock_snapshot_task = MagicMock()\n        manager._scheduler_task = mock_schedule_task\n        manager._snapshot_task = mock_snapshot_task\n\n        manager.stop_scheduler()\n\n        mock_schedule_task.cancel.assert_called_once()\n        mock_snapshot_task.cancel.assert_called_once()\n        assert manager._scheduler_task is None\n        assert manager._snapshot_task is None\n\n\nclass TestGetPlugsForPrinter:\n    \"\"\"Tests for _get_plugs_for_printer — returns all plugs for a printer (#903).\"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        return SmartPlugManager()\n\n    @pytest.mark.asyncio\n    async def test_returns_empty_list_when_no_plugs(self, manager):\n        \"\"\"Verify empty list is returned when no plugs are linked to printer.\"\"\"\n        mock_db = AsyncMock()\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = []\n        mock_db.execute = AsyncMock(return_value=mock_result)\n\n        result = await manager._get_plugs_for_printer(1, mock_db)\n        assert result == []\n\n    @pytest.mark.asyncio\n    async def test_returns_single_plug_as_list(self, manager):\n        \"\"\"Verify single plug is returned in a list.\"\"\"\n        plug = MagicMock()\n        plug.plug_type = \"tasmota\"\n\n        mock_db = AsyncMock()\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = [plug]\n        mock_db.execute = AsyncMock(return_value=mock_result)\n\n        result = await manager._get_plugs_for_printer(1, mock_db)\n        assert result == [plug]\n\n    @pytest.mark.asyncio\n    async def test_returns_all_plugs(self, manager):\n        \"\"\"Verify all plugs are returned when multiple exist (#903).\"\"\"\n        plug1 = MagicMock()\n        plug1.plug_type = \"homeassistant\"\n        plug1.ha_entity_id = \"switch.printer\"\n\n        plug2 = MagicMock()\n        plug2.plug_type = \"homeassistant\"\n        plug2.ha_entity_id = \"switch.filter\"\n\n        mock_db = AsyncMock()\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = [plug1, plug2]\n        mock_db.execute = AsyncMock(return_value=mock_result)\n\n        result = await manager._get_plugs_for_printer(1, mock_db)\n        assert result == [plug1, plug2]\n\n\nclass TestAutoOffPersistent:\n    \"\"\"Tests for persistent auto-off behavior (Issue #826).\n\n    When auto_off_persistent is True, auto_off should remain enabled after\n    execution instead of being disabled (one-shot default).\n    \"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        return SmartPlugManager()\n\n    @pytest.mark.asyncio\n    async def test_mark_auto_off_executed_one_shot_disables_auto_off(self, manager):\n        \"\"\"Default one-shot: auto_off should be set to False after execution.\"\"\"\n        mock_plug = MagicMock()\n        mock_plug.id = 1\n        mock_plug.auto_off = True\n        mock_plug.auto_off_persistent = False\n        mock_plug.auto_off_executed = False\n        mock_plug.auto_off_pending = True\n        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)\n\n        with patch(\"backend.app.core.database.async_session\") as mock_session_ctx:\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar_one_or_none.return_value = mock_plug\n            mock_db.execute = AsyncMock(return_value=mock_result)\n            mock_db.commit = AsyncMock()\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock()\n\n            await manager._mark_auto_off_executed(1)\n\n            assert mock_plug.auto_off is False, \"One-shot: auto_off should be disabled\"\n            assert mock_plug.auto_off_pending is False\n            assert mock_plug.auto_off_pending_since is None\n            mock_db.commit.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_mark_auto_off_executed_persistent_keeps_auto_off_enabled(self, manager):\n        \"\"\"Persistent mode: auto_off should remain True after execution.\"\"\"\n        mock_plug = MagicMock()\n        mock_plug.id = 2\n        mock_plug.auto_off = True\n        mock_plug.auto_off_persistent = True\n        mock_plug.auto_off_executed = False\n        mock_plug.auto_off_pending = True\n        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)\n\n        with patch(\"backend.app.core.database.async_session\") as mock_session_ctx:\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar_one_or_none.return_value = mock_plug\n            mock_db.execute = AsyncMock(return_value=mock_result)\n            mock_db.commit = AsyncMock()\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock()\n\n            await manager._mark_auto_off_executed(2)\n\n            assert mock_plug.auto_off is True, \"Persistent: auto_off should stay enabled\"\n            assert mock_plug.auto_off_pending is False\n            assert mock_plug.auto_off_pending_since is None\n            mock_db.commit.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_persistent_auto_off_full_cycle(self, manager):\n        \"\"\"Verify persistent auto-off survives a full print cycle.\n\n        Simulates: print start → print complete → auto-off executes → next print start.\n        auto_off should remain True throughout for persistent plugs.\n        \"\"\"\n        mock_plug = MagicMock()\n        mock_plug.id = 3\n        mock_plug.name = \"HA BentoBox Filter\"\n        mock_plug.plug_type = \"homeassistant\"\n        mock_plug.ha_entity_id = \"switch.bentobox_filter\"\n        mock_plug.ip_address = None\n        mock_plug.username = None\n        mock_plug.password = None\n        mock_plug.enabled = True\n        mock_plug.auto_on = True\n        mock_plug.auto_off = True\n        mock_plug.auto_off_persistent = True\n        mock_plug.off_delay_mode = \"time\"\n        mock_plug.off_delay_minutes = 1\n        mock_plug.off_temp_threshold = 70\n        mock_plug.printer_id = 1\n        mock_plug.auto_off_executed = False\n        mock_plug.auto_off_pending = False\n        mock_plug.last_state = \"OFF\"\n        mock_plug.last_checked = None\n\n        mock_db = AsyncMock()\n        mock_db.commit = AsyncMock()\n\n        # Step 1: Print starts — plug turns on\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get,\n            patch.object(manager, \"get_service_for_plug\", new_callable=AsyncMock) as mock_svc,\n        ):\n            mock_get.return_value = [mock_plug]\n            mock_service = AsyncMock()\n            mock_service.turn_on = AsyncMock(return_value=True)\n            mock_svc.return_value = mock_service\n\n            await manager.on_print_start(printer_id=1, db=mock_db)\n\n            assert mock_plug.auto_off_executed is False\n            assert mock_plug.auto_off is True  # Still enabled\n\n        # Step 2: Print completes — auto-off is scheduled\n        with (\n            patch.object(manager, \"_get_plugs_for_printer\", new_callable=AsyncMock) as mock_get,\n            patch.object(manager, \"_schedule_delayed_off\") as mock_schedule,\n        ):\n            mock_get.return_value = [mock_plug]\n\n            await manager.on_print_complete(printer_id=1, status=\"completed\", db=mock_db)\n\n            mock_schedule.assert_called_once()\n            assert mock_plug.auto_off is True  # Still enabled after scheduling\n\n        # Step 3: Auto-off executes via _mark_auto_off_executed\n        with patch(\"backend.app.core.database.async_session\") as mock_session_ctx:\n            mock_db2 = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar_one_or_none.return_value = mock_plug\n            mock_db2.execute = AsyncMock(return_value=mock_result)\n            mock_db2.commit = AsyncMock()\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db2)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock()\n\n            await manager._mark_auto_off_executed(3)\n\n            # KEY ASSERTION: auto_off stays True for persistent mode\n            assert mock_plug.auto_off is True, \"Persistent auto_off must survive execution\"\n            assert mock_plug.auto_off_pending is False\n\n\nclass TestScheduleLoop:\n    \"\"\"Tests for the schedule-based plug control.\"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        return SmartPlugManager()\n\n    @pytest.mark.asyncio\n    async def test_check_schedules_turns_on_at_scheduled_time(self, manager):\n        \"\"\"Verify scheduled on-time turns plug on.\"\"\"\n        mock_plug = MagicMock()\n        mock_plug.id = 1\n        mock_plug.name = \"Test Plug\"\n        mock_plug.enabled = True\n        mock_plug.schedule_enabled = True\n        mock_plug.schedule_on_time = \"08:00\"\n        mock_plug.schedule_off_time = \"22:00\"\n        mock_plug.printer_id = None\n        mock_plug.last_state = \"OFF\"\n\n        with (\n            patch(\"backend.app.services.smart_plug_manager.datetime\") as mock_datetime,\n            patch(\"backend.app.core.database.async_session\") as mock_session_ctx,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n        ):\n            # Set current time to 08:00\n            mock_now = MagicMock()\n            mock_now.strftime.return_value = \"08:00\"\n            mock_datetime.now.return_value = mock_now\n            mock_datetime.utcnow.return_value = datetime.now(timezone.utc)\n\n            # Set up async session mock\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalars.return_value.all.return_value = [mock_plug]\n            mock_db.execute = AsyncMock(return_value=mock_result)\n            mock_db.commit = AsyncMock()\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock()\n\n            mock_tasmota.turn_on = AsyncMock(return_value=True)\n\n            await manager._check_schedules()\n\n            mock_tasmota.turn_on.assert_called_once_with(mock_plug)\n\n    @pytest.mark.asyncio\n    async def test_check_schedules_turns_off_at_scheduled_time(self, manager):\n        \"\"\"Verify scheduled off-time turns plug off.\"\"\"\n        mock_plug = MagicMock()\n        mock_plug.id = 1\n        mock_plug.name = \"Test Plug\"\n        mock_plug.enabled = True\n        mock_plug.schedule_enabled = True\n        mock_plug.schedule_on_time = \"08:00\"\n        mock_plug.schedule_off_time = \"22:00\"\n        mock_plug.printer_id = 1\n        mock_plug.last_state = \"ON\"\n\n        with (\n            patch(\"backend.app.services.smart_plug_manager.datetime\") as mock_datetime,\n            patch(\"backend.app.core.database.async_session\") as mock_session_ctx,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n            patch(\"backend.app.services.smart_plug_manager.printer_manager\") as mock_pm,\n        ):\n            # Set current time to 22:00\n            mock_now = MagicMock()\n            mock_now.strftime.return_value = \"22:00\"\n            mock_datetime.now.return_value = mock_now\n            mock_datetime.utcnow.return_value = datetime.now(timezone.utc)\n\n            # Set up async session mock\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalars.return_value.all.return_value = [mock_plug]\n            mock_db.execute = AsyncMock(return_value=mock_result)\n            mock_db.commit = AsyncMock()\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock()\n\n            mock_tasmota.turn_off = AsyncMock(return_value=True)\n            mock_pm.mark_printer_offline = MagicMock()\n\n            await manager._check_schedules()\n\n            mock_tasmota.turn_off.assert_called_once_with(mock_plug)\n\n    @pytest.mark.asyncio\n    async def test_check_schedules_skipped_when_disabled(self, manager):\n        \"\"\"Verify schedule is skipped when schedule_enabled is False.\"\"\"\n        mock_plug = MagicMock()\n        mock_plug.id = 1\n        mock_plug.enabled = True\n        mock_plug.schedule_enabled = False  # Disabled\n\n        with (\n            patch(\"backend.app.services.smart_plug_manager.datetime\") as mock_datetime,\n            patch(\"backend.app.core.database.async_session\") as mock_session_ctx,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n        ):\n            mock_now = MagicMock()\n            mock_now.strftime.return_value = \"08:00\"\n            mock_datetime.now.return_value = mock_now\n\n            # Set up async session mock - returns no plugs (filtered by schedule_enabled)\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalars.return_value.all.return_value = []\n            mock_db.execute = AsyncMock(return_value=mock_result)\n            mock_db.commit = AsyncMock()\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock()\n\n            mock_tasmota.turn_on = AsyncMock()\n\n            await manager._check_schedules()\n\n            mock_tasmota.turn_on.assert_not_called()\n\n\nclass TestPendingAutoOffPersistence:\n    \"\"\"Tests for auto-off pending state persistence (restart recovery).\"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        return SmartPlugManager()\n\n    @pytest.mark.asyncio\n    async def test_resume_pending_auto_offs_temperature_mode(self, manager):\n        \"\"\"Verify temperature-based pending auto-offs are resumed on startup.\"\"\"\n        mock_plug = MagicMock()\n        mock_plug.id = 1\n        mock_plug.name = \"Test Plug\"\n        mock_plug.ip_address = \"192.168.1.100\"\n        mock_plug.username = None\n        mock_plug.password = None\n        mock_plug.printer_id = 1\n        mock_plug.auto_off_pending = True\n        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)\n        mock_plug.off_delay_mode = \"temperature\"\n        mock_plug.off_temp_threshold = 70\n\n        with (\n            patch(\"backend.app.core.database.async_session\") as mock_session_ctx,\n            patch.object(manager, \"_schedule_temp_based_off\") as mock_schedule,\n        ):\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalars.return_value.all.return_value = [mock_plug]\n            mock_db.execute = AsyncMock(return_value=mock_result)\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock()\n\n            await manager.resume_pending_auto_offs()\n\n            mock_schedule.assert_called_once_with(mock_plug, 1, 70)\n\n    @pytest.mark.asyncio\n    async def test_resume_pending_auto_offs_time_mode_immediate_off(self, manager):\n        \"\"\"Verify time-based pending auto-offs turn off immediately on resume.\"\"\"\n        mock_plug = MagicMock()\n        mock_plug.id = 1\n        mock_plug.name = \"Test Plug\"\n        mock_plug.ip_address = \"192.168.1.100\"\n        mock_plug.username = None\n        mock_plug.password = None\n        mock_plug.printer_id = 1\n        mock_plug.auto_off_pending = True\n        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)\n        mock_plug.off_delay_mode = \"time\"\n\n        with (\n            patch(\"backend.app.core.database.async_session\") as mock_session_ctx,\n            patch(\"backend.app.services.smart_plug_manager.tasmota_service\") as mock_tasmota,\n            patch.object(manager, \"_mark_auto_off_executed\", new_callable=AsyncMock) as mock_mark,\n            patch(\"backend.app.services.smart_plug_manager.printer_manager\"),\n        ):\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalars.return_value.all.return_value = [mock_plug]\n            mock_db.execute = AsyncMock(return_value=mock_result)\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock()\n\n            mock_tasmota.turn_off = AsyncMock(return_value=True)\n\n            await manager.resume_pending_auto_offs()\n\n            mock_tasmota.turn_off.assert_called_once()\n            mock_mark.assert_called_once_with(1)\n"
  },
  {
    "path": "backend/tests/unit/services/test_spool_assignment_notifications.py",
    "content": "\"\"\"Unit tests for spool assignment notification service.\"\"\"\n\nimport logging\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\nfrom backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start\n\n\nclass _FakeAssignmentsResult:\n    def __init__(self, rows):\n        self._rows = rows\n\n    def fetchall(self):\n        return self._rows\n\n\nclass _FakeSession:\n    def __init__(self, printer_name: str, assignments: list[SimpleNamespace]):\n        self._printer = SimpleNamespace(name=printer_name)\n        self._assignments = assignments\n\n    async def __aenter__(self):\n        return self\n\n    async def __aexit__(self, exc_type, exc, tb):\n        return False\n\n    async def get(self, model, key):\n        return self._printer\n\n    async def execute(self, statement):\n        return _FakeAssignmentsResult(self._assignments)\n\n\n@pytest.mark.asyncio\nasync def test_missing_assignment_broadcasts_websocket_event_and_push_notification():\n    \"\"\"When a mapped tray is unassigned, service emits websocket and notification events.\"\"\"\n    logger = logging.getLogger(__name__)\n    data = {\n        \"ams_mapping\": [1],\n        \"raw_data\": {},\n    }\n\n    # Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).\n    assignments = [SimpleNamespace(ams_id=0, tray_id=0)]\n\n    with (\n        patch(\n            \"backend.app.services.spool_assignment_notifications.async_session\",\n            return_value=_FakeSession(\"Printer A\", assignments),\n        ),\n        patch(\"backend.app.services.spool_assignment_notifications.printer_manager.get_status\", return_value=None),\n        patch(\n            \"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment\",\n            new_callable=AsyncMock,\n        ) as mock_ws,\n        patch(\n            \"backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment\",\n            new_callable=AsyncMock,\n        ) as mock_notify,\n    ):\n        await notify_missing_spool_assignments_on_print_start(1, data, logger)\n\n    mock_ws.assert_awaited_once()\n    ws_kwargs = mock_ws.await_args.kwargs\n    assert ws_kwargs[\"printer_id\"] == 1\n    assert ws_kwargs[\"printer_name\"] == \"Printer A\"\n    assert ws_kwargs[\"missing_slots\"] == [{\"slot\": \"A2\", \"profile\": \"Unknown\", \"color\": \"Unknown\"}]\n\n    mock_notify.assert_awaited_once()\n    notify_kwargs = mock_notify.await_args.kwargs\n    assert notify_kwargs[\"printer_id\"] == 1\n    assert notify_kwargs[\"printer_name\"] == \"Printer A\"\n    assert notify_kwargs[\"missing_slots\"] == [{\"slot\": \"A2\", \"profile\": \"Unknown\", \"color\": \"Unknown\"}]\n"
  },
  {
    "path": "backend/tests/unit/services/test_spool_tag_matcher.py",
    "content": "\"\"\"Tests for spool_tag_matcher service — RFID auto-assign and relationship loading.\"\"\"\n\nimport pytest\nfrom sqlalchemy import inspect\n\nfrom backend.app.models.color_catalog import ColorCatalogEntry\nfrom backend.app.models.spool import Spool\nfrom backend.app.models.spool_assignment import SpoolAssignment\nfrom backend.app.services.spool_tag_matcher import (\n    auto_assign_spool,\n    create_spool_from_tray,\n    find_matching_untagged_spool,\n    get_spool_by_tag,\n    is_bambu_tag,\n    is_valid_tag,\n    link_tag_to_inventory_spool,\n)\n\n# -- helpers -----------------------------------------------------------------\n\nSAMPLE_TRAY = {\n    \"tray_type\": \"PLA\",\n    \"tray_sub_brands\": \"PLA Basic\",\n    \"tray_color\": \"FFFFFFFF\",\n    \"tray_id_name\": \"\",\n    \"tag_uid\": \"AABBCCDD11223344\",\n    \"tray_uuid\": \"AABBCCDD11223344AABBCCDD11223344\",\n    \"tray_info_idx\": \"GFL99\",\n    \"nozzle_temp_min\": 190,\n    \"nozzle_temp_max\": 230,\n    \"tray_weight\": \"1000\",\n    \"remain\": 80,\n}\n\n\ndef _relationship_is_loaded(obj, attr_name: str) -> bool:\n    \"\"\"Check if a relationship attribute has been eagerly loaded (not lazy).\"\"\"\n    return attr_name in inspect(obj).dict\n\n\n# -- is_valid_tag / is_bambu_tag --------------------------------------------\n\n\ndef test_is_valid_tag_with_real_uid():\n    assert is_valid_tag(\"AABBCCDD11223344\", \"\") is True\n\n\ndef test_is_valid_tag_with_real_uuid():\n    assert is_valid_tag(\"\", \"AABBCCDD11223344AABBCCDD11223344\") is True\n\n\ndef test_is_valid_tag_all_zeros():\n    assert is_valid_tag(\"0000000000000000\", \"00000000000000000000000000000000\") is False\n\n\ndef test_is_valid_tag_empty():\n    assert is_valid_tag(\"\", \"\") is False\n\n\ndef test_is_bambu_tag_with_uuid():\n    assert is_bambu_tag(\"\", \"AABBCCDD11223344AABBCCDD11223344\", \"\") is True\n\n\ndef test_is_bambu_tag_with_uid_and_preset():\n    assert is_bambu_tag(\"AABBCCDD11223344\", \"\", \"GFL99\") is True\n\n\ndef test_is_bambu_tag_uid_only_no_preset():\n    \"\"\"A tag UID alone (no UUID, no preset) is NOT considered a Bambu tag.\"\"\"\n    assert is_bambu_tag(\"AABBCCDD11223344\", \"\", \"\") is False\n\n\n# -- create_spool_from_tray -------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_create_spool_from_tray_basic(db_session):\n    \"\"\"Created spool has correct material and tag fields.\"\"\"\n    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)\n    await db_session.commit()\n\n    assert spool.id is not None\n    assert spool.material == \"PLA\"\n    assert spool.brand == \"Bambu Lab\"\n    assert spool.tag_uid == \"AABBCCDD11223344\"\n    assert spool.tray_uuid == \"AABBCCDD11223344AABBCCDD11223344\"\n    assert spool.data_origin == \"rfid_auto\"\n\n\n@pytest.mark.asyncio\nasync def test_create_spool_from_tray_weight_from_remain(db_session):\n    \"\"\"weight_used is calculated from the AMS remain percentage.\"\"\"\n    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)\n    # remain=80 → 20% used → 200g of 1000g\n    assert spool.weight_used == 200.0\n\n\n@pytest.mark.asyncio\nasync def test_create_spool_from_tray_relationships_loaded(db_session):\n    \"\"\"Both k_profiles and assignments must be eagerly initialized.\n\n    If these are lazy, db.add(SpoolAssignment(spool_id=spool.id)) triggers\n    a back_populates lazy load outside the async greenlet → greenlet_spawn error.\n    Regression test for #612.\n    \"\"\"\n    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)\n\n    assert _relationship_is_loaded(spool, \"k_profiles\"), \"k_profiles not eagerly initialized\"\n    assert _relationship_is_loaded(spool, \"assignments\"), \"assignments not eagerly initialized\"\n    assert spool.k_profiles == []\n    assert spool.assignments == []\n\n\n# -- get_spool_by_tag -------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_by_uuid(db_session):\n    \"\"\"Look up a spool by tray_uuid.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        tray_uuid=\"AABBCCDD11223344AABBCCDD11223344\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await get_spool_by_tag(db_session, \"\", \"AABBCCDD11223344AABBCCDD11223344\")\n    assert found is not None\n    assert found.id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_by_uid(db_session):\n    \"\"\"Fall back to tag_uid when tray_uuid doesn't match.\"\"\"\n    spool = Spool(\n        material=\"PETG\",\n        tag_uid=\"1122334455667788\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await get_spool_by_tag(db_session, \"1122334455667788\", \"\")\n    assert found is not None\n    assert found.id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_skips_archived(db_session):\n    \"\"\"Archived spools are not returned.\"\"\"\n    from datetime import datetime\n\n    spool = Spool(\n        material=\"PLA\",\n        tray_uuid=\"AABBCCDD11223344AABBCCDD11223344\",\n        label_weight=1000,\n        core_weight=250,\n        archived_at=datetime.now(),\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await get_spool_by_tag(db_session, \"\", \"AABBCCDD11223344AABBCCDD11223344\")\n    assert found is None\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_relationships_loaded(db_session):\n    \"\"\"Both k_profiles and assignments must be eagerly loaded.\n\n    Regression test for #612 — without selectinload(Spool.assignments),\n    accessing spool.assignments after get_spool_by_tag triggers a lazy load\n    in async context → greenlet_spawn error.\n    \"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        tray_uuid=\"AABBCCDD11223344AABBCCDD11223344\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n    # Expire to clear in-session state — forces selectinload to actually load\n    db_session.expire(spool)\n\n    found = await get_spool_by_tag(db_session, \"\", \"AABBCCDD11223344AABBCCDD11223344\")\n    assert found is not None\n    assert _relationship_is_loaded(found, \"k_profiles\"), \"k_profiles not eagerly loaded\"\n    assert _relationship_is_loaded(found, \"assignments\"), \"assignments not eagerly loaded\"\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_returns_none_for_zeros(db_session):\n    \"\"\"Zero-value tags return None.\"\"\"\n    found = await get_spool_by_tag(db_session, \"0000000000000000\", \"00000000000000000000000000000000\")\n    assert found is None\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_first_char_variance_same_length(db_session):\n    \"\"\"Match spool when scanned tag differs only in first character.\n\n    Handles case where same physical tag reports different first bytes\n    across different readers (e.g., \"A45012F\" stored, \"B45012F\" scanned).\n    Both tags have same length and differ only in first char.\n    \"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        tag_uid=\"A4501234CCDDEE88\",  # First tag variant\n        label_weight=1000,\n        core_weight=250,\n    )\n    spool.k_profiles = []\n    spool.assignments = []\n    db_session.add(spool)\n    await db_session.commit()\n\n    # Scan with different first character — should still match\n    found = await get_spool_by_tag(db_session, \"B4501234CCDDEE88\", \"\")\n    assert found is not None\n    assert found.id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_first_char_variance_short_uid(db_session):\n    \"\"\"Match spool when 8-char scanned tag differs only in first character.\n\n    Handles short UID (8 char) from 4-byte readers with first-char variance.\n    The stored tag is longer (16 char), but the first 8 chars of the stored tag\n    should match the scanned 8-char UID with first-char tolerance.\n    \"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        tag_uid=\"A4501234CCDDEE88\",  # 16-char stored tag\n        label_weight=1000,\n        core_weight=250,\n    )\n    spool.k_profiles = []\n    spool.assignments = []\n    db_session.add(spool)\n    await db_session.commit()\n\n    # Scan with 8-char short UID whose first char differs but remaining 7 match\n    # the first 8 chars of the stored tag: stored[:8] = \"A4501234\",\n    # scanned = \"B4501234\" → first-char variance on short UID\n    found = await get_spool_by_tag(db_session, \"B4501234\", \"\")\n    assert found is not None\n    assert found.id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_short_uid_exact_match_preferred(db_session):\n    \"\"\"Prefer exact match over first-char variance match.\"\"\"\n    # Spool with exact 8-char UID match\n    spool_exact = Spool(\n        material=\"PLA\",\n        tag_uid=\"B4501234\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    spool_exact.k_profiles = []\n    spool_exact.assignments = []\n    db_session.add(spool_exact)\n\n    # Spool that would match via first-char variance\n    spool_variance = Spool(\n        material=\"PETG\",\n        tag_uid=\"A4501234\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    spool_variance.k_profiles = []\n    spool_variance.assignments = []\n    db_session.add(spool_variance)\n    await db_session.commit()\n\n    # Exact match should win over variance match\n    found = await get_spool_by_tag(db_session, \"B4501234\", \"\")\n    assert found is not None\n    assert found.id == spool_exact.id\n\n\n@pytest.mark.asyncio\nasync def test_get_spool_by_tag_no_false_positive_different_suffix(db_session):\n    \"\"\"Don't match tags with different suffixes just because first char varies.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        tag_uid=\"AABBCCDD11223344\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    spool.k_profiles = []\n    spool.assignments = []\n    db_session.add(spool)\n    await db_session.commit()\n\n    # Scan with different suffix (only first char is same) — should NOT match\n    found = await get_spool_by_tag(db_session, \"AABBCCDD11223355\", \"\")\n    assert found is None, \"Should not match when suffix differs\"\n\n\n# -- auto_assign_spool (SpoolAssignment creation) ---------------------------\n\n\n@pytest.mark.asyncio\nasync def test_auto_assign_creates_assignment(db_session, printer_factory):\n    \"\"\"auto_assign_spool creates a SpoolAssignment for the given slot.\"\"\"\n    from unittest.mock import MagicMock\n\n    printer = await printer_factory()\n    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)\n    await db_session.commit()\n\n    mock_pm = MagicMock()\n    mock_pm.get_status.return_value = None\n    mock_pm.get_client.return_value = None\n\n    assignment = await auto_assign_spool(\n        printer_id=printer.id,\n        ams_id=0,\n        tray_id=2,\n        spool=spool,\n        printer_manager=mock_pm,\n        db=db_session,\n    )\n    await db_session.commit()\n\n    assert assignment.spool_id == spool.id\n    assert assignment.printer_id == printer.id\n    assert assignment.ams_id == 0\n    assert assignment.tray_id == 2\n\n\n@pytest.mark.asyncio\nasync def test_auto_assign_replaces_existing(db_session, printer_factory):\n    \"\"\"auto_assign_spool removes old assignment for the same slot.\"\"\"\n    from unittest.mock import MagicMock\n\n    from sqlalchemy import select\n\n    printer = await printer_factory()\n\n    # Create two spools\n    spool1 = Spool(material=\"PLA\", label_weight=1000, core_weight=250)\n    spool1.k_profiles = []\n    spool1.assignments = []\n    db_session.add(spool1)\n    await db_session.flush()\n\n    spool2 = Spool(material=\"PETG\", label_weight=1000, core_weight=250)\n    spool2.k_profiles = []\n    spool2.assignments = []\n    db_session.add(spool2)\n    await db_session.flush()\n\n    mock_pm = MagicMock()\n    mock_pm.get_status.return_value = None\n    mock_pm.get_client.return_value = None\n\n    # Assign spool1 to slot\n    await auto_assign_spool(printer.id, 0, 0, spool1, mock_pm, db_session)\n    await db_session.commit()\n\n    # Assign spool2 to same slot — should replace\n    await auto_assign_spool(printer.id, 0, 0, spool2, mock_pm, db_session)\n    await db_session.commit()\n\n    result = await db_session.execute(\n        select(SpoolAssignment).where(\n            SpoolAssignment.printer_id == printer.id,\n            SpoolAssignment.ams_id == 0,\n            SpoolAssignment.tray_id == 0,\n        )\n    )\n    assignments = result.scalars().all()\n    assert len(assignments) == 1\n    assert assignments[0].spool_id == spool2.id\n\n\n@pytest.mark.asyncio\nasync def test_auto_assign_no_greenlet_error_new_spool(db_session, printer_factory):\n    \"\"\"Creating a SpoolAssignment for a newly created spool must not trigger\n    a lazy load on spool.assignments (greenlet_spawn error).\n\n    Regression test for #612: db.add(SpoolAssignment) resolves\n    back_populates synchronously. If spool.assignments is uninitialized,\n    SQLAlchemy attempts a lazy load outside the async greenlet.\n    \"\"\"\n    from unittest.mock import MagicMock\n\n    printer = await printer_factory()\n    spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)\n    # Don't commit yet — keep spool in same session state as production flow\n\n    mock_pm = MagicMock()\n    mock_pm.get_status.return_value = None\n    mock_pm.get_client.return_value = None\n\n    # This must NOT raise MissingGreenlet / greenlet_spawn error\n    assignment = await auto_assign_spool(\n        printer_id=printer.id,\n        ams_id=0,\n        tray_id=0,\n        spool=spool,\n        printer_manager=mock_pm,\n        db=db_session,\n    )\n    await db_session.commit()\n\n    assert assignment is not None\n    assert assignment.spool_id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_auto_assign_no_greenlet_error_existing_spool(db_session, printer_factory):\n    \"\"\"Creating a SpoolAssignment for an existing spool (from get_spool_by_tag)\n    must not trigger a lazy load on spool.assignments.\n\n    Regression test for #612.\n    \"\"\"\n    from unittest.mock import MagicMock\n\n    printer = await printer_factory()\n\n    # Create spool directly (simulating one that was created in a previous session)\n    spool = Spool(\n        material=\"PLA\",\n        tray_uuid=\"AABBCCDD11223344AABBCCDD11223344\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n    # Expire to clear in-session state — simulates fresh query\n    db_session.expire(spool)\n\n    # Look up via get_spool_by_tag (must eagerly load relationships)\n    found = await get_spool_by_tag(db_session, \"\", \"AABBCCDD11223344AABBCCDD11223344\")\n    assert found is not None\n\n    mock_pm = MagicMock()\n    mock_pm.get_status.return_value = None\n    mock_pm.get_client.return_value = None\n\n    # This must NOT raise MissingGreenlet / greenlet_spawn error\n    assignment = await auto_assign_spool(\n        printer_id=printer.id,\n        ams_id=0,\n        tray_id=0,\n        spool=found,\n        printer_manager=mock_pm,\n        db=db_session,\n    )\n    await db_session.commit()\n\n    assert assignment is not None\n    assert assignment.spool_id == found.id\n\n\n# -- find_matching_untagged_spool -------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_exact_match(db_session):\n    \"\"\"Finds an untagged spool with matching material, subtype, and color.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is not None\n    assert found.id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_skips_tagged(db_session):\n    \"\"\"Spools that already have a tag_uid are not matched.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n        tag_uid=\"1122334455667788\",\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is None\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_skips_uuid_tagged(db_session):\n    \"\"\"Spools that already have a tray_uuid are not matched.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n        tray_uuid=\"AABBCCDD11223344AABBCCDD11223344\",\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is None\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_skips_archived(db_session):\n    \"\"\"Archived spools are not matched.\"\"\"\n    from datetime import datetime\n\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n        archived_at=datetime.now(),\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is None\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_wrong_material(db_session):\n    \"\"\"Material mismatch returns None.\"\"\"\n    spool = Spool(\n        material=\"PETG\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is None\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_wrong_color(db_session):\n    \"\"\"Color (rgba) mismatch returns None.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FF0000FF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is None\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_wrong_subtype(db_session):\n    \"\"\"Subtype mismatch returns None (PLA Matte vs PLA Basic).\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Matte\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is None\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_fifo(db_session):\n    \"\"\"When multiple match, returns the oldest (FIFO).\"\"\"\n    import asyncio\n\n    spool_old = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool_old)\n    await db_session.flush()\n\n    # Small delay to ensure different created_at\n    await asyncio.sleep(0.05)\n\n    spool_new = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool_new)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is not None\n    assert found.id == spool_old.id\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_case_insensitive(db_session):\n    \"\"\"Matching is case-insensitive for material and rgba.\"\"\"\n    spool = Spool(\n        material=\"pla\",\n        subtype=\"basic\",\n        rgba=\"ffffffff\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is not None\n    assert found.id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_no_subtype(db_session):\n    \"\"\"Tray without subtype matches spool without subtype.\"\"\"\n    tray = {**SAMPLE_TRAY, \"tray_sub_brands\": \"PLA\", \"tray_type\": \"PLA\"}\n    spool = Spool(\n        material=\"PLA\",\n        subtype=None,\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    found = await find_matching_untagged_spool(db_session, tray)\n    assert found is not None\n    assert found.id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_spool_relationships_loaded(db_session):\n    \"\"\"Matched spool has k_profiles and assignments eagerly loaded.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n    db_session.expire(spool)\n\n    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)\n    assert found is not None\n    assert _relationship_is_loaded(found, \"k_profiles\")\n    assert _relationship_is_loaded(found, \"assignments\")\n\n\n# -- link_tag_to_inventory_spool -------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_link_tag_to_inventory_spool(db_session):\n    \"\"\"Links RFID tag data to an existing spool.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.flush()\n\n    await link_tag_to_inventory_spool(db_session, spool, SAMPLE_TRAY)\n    await db_session.commit()\n\n    assert spool.tag_uid == \"AABBCCDD11223344\"\n    assert spool.tray_uuid == \"AABBCCDD11223344AABBCCDD11223344\"\n    assert spool.data_origin == \"rfid_linked\"\n    assert spool.tag_type == \"bambulab\"\n    assert spool.slicer_filament == \"GFL99\"\n\n\n@pytest.mark.asyncio\nasync def test_link_tag_preserves_existing_slicer_filament(db_session):\n    \"\"\"Does not overwrite an existing slicer_filament preset.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n        slicer_filament=\"CUSTOM01\",\n        slicer_filament_name=\"My Custom PLA\",\n    )\n    db_session.add(spool)\n    await db_session.flush()\n\n    await link_tag_to_inventory_spool(db_session, spool, SAMPLE_TRAY)\n    await db_session.commit()\n\n    assert spool.slicer_filament == \"CUSTOM01\"\n    assert spool.slicer_filament_name == \"My Custom PLA\"\n\n\n# -- gradient / multi-color subtype detection --------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_create_spool_gradient_from_tray_id_name(db_session):\n    \"\"\"PLA Basic with M* color code → subtype='Gradient'.\"\"\"\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_sub_brands\": \"PLA Basic\",\n        \"tray_id_name\": \"A00-M2\",  # Ocean to Meadow\n    }\n    spool = await create_spool_from_tray(db_session, tray)\n    assert spool.material == \"PLA\"\n    assert spool.subtype == \"Gradient\"\n\n\n@pytest.mark.asyncio\nasync def test_create_spool_dual_color_from_tray_id_name(db_session):\n    \"\"\"PLA Silk with A05-M* color code → subtype='Dual Color'.\"\"\"\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_sub_brands\": \"PLA Silk\",\n        \"tray_id_name\": \"A05-M1\",  # South Beach\n    }\n    spool = await create_spool_from_tray(db_session, tray)\n    assert spool.material == \"PLA\"\n    assert spool.subtype == \"Dual Color\"\n\n\n@pytest.mark.asyncio\nasync def test_create_spool_tri_color_from_tray_id_name(db_session):\n    \"\"\"PLA Silk with A05-T* color code → subtype='Tri Color'.\"\"\"\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_sub_brands\": \"PLA Silk\",\n        \"tray_id_name\": \"A05-T3\",  # Neon City\n    }\n    spool = await create_spool_from_tray(db_session, tray)\n    assert spool.material == \"PLA\"\n    assert spool.subtype == \"Tri Color\"\n\n\n@pytest.mark.asyncio\nasync def test_create_spool_silk_plus_subtype(db_session):\n    \"\"\"PLA Silk+ preserves 'Silk+' subtype (no gradient override).\"\"\"\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_sub_brands\": \"PLA Silk+\",\n        \"tray_id_name\": \"A06-D0\",  # Titan Gray — D code, not M/T\n    }\n    spool = await create_spool_from_tray(db_session, tray)\n    assert spool.material == \"PLA\"\n    assert spool.subtype == \"Silk+\"\n\n\n@pytest.mark.asyncio\nasync def test_create_spool_standard_not_affected(db_session):\n    \"\"\"Standard filaments with D/K/etc codes are not affected.\"\"\"\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_sub_brands\": \"PLA Basic\",\n        \"tray_id_name\": \"A00-D3\",  # Dark Gray\n    }\n    spool = await create_spool_from_tray(db_session, tray)\n    assert spool.material == \"PLA\"\n    assert spool.subtype == \"Basic\"\n\n\n# -- color resolution (#857) -------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_color_resolves_from_catalog_not_suffix_fallback(db_session):\n    \"\"\"Regression for #857 — A17-R1 (PLA Translucent Cherry Pink) must NOT resolve\n    to 'Scarlet Red' just because 'R1' also appears in PLA Matte.\n\n    The old resolver fell back to a suffix lookup table when the exact tray_id_name\n    wasn't mapped, which produced wrong names across material families. Cross-family\n    suffix codes are not globally unique, so only the catalog hex lookup is safe.\n    \"\"\"\n    # Seed the catalog with the entry that the Cherry Pink hex should hit.\n    db_session.add(\n        ColorCatalogEntry(\n            manufacturer=\"Bambu Lab\",\n            color_name=\"Cherry Pink\",\n            hex_color=\"#F5B6CD\",\n            material=\"PLA Translucent\",\n            is_default=True,\n        )\n    )\n    await db_session.flush()\n\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_type\": \"PLA\",\n        \"tray_sub_brands\": \"PLA Translucent\",\n        \"tray_color\": \"F5B6CDFF\",\n        \"tray_id_name\": \"A17-R1\",\n    }\n    spool = await create_spool_from_tray(db_session, tray)\n    assert spool.color_name == \"Cherry Pink\"\n\n\n@pytest.mark.asyncio\nasync def test_color_name_is_none_when_catalog_miss_and_code_unreadable(db_session):\n    \"\"\"When the hex isn't in the catalog and tray_id_name is a code ('X##-Y#'),\n    color_name must stay None rather than falling through to a wrong suffix match.\n    A missing name is preferable to a confidently-wrong one.\n    \"\"\"\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_type\": \"PLA\",\n        \"tray_sub_brands\": \"PLA Translucent\",\n        \"tray_color\": \"F5B6CDFF\",  # not seeded\n        \"tray_id_name\": \"A17-R1\",\n    }\n    spool = await create_spool_from_tray(db_session, tray)\n    assert spool.color_name is None\n\n\n@pytest.mark.asyncio\nasync def test_color_name_falls_back_to_readable_tray_id_name(db_session):\n    \"\"\"If tray_id_name is a human-readable label (no code pattern), use it when the\n    catalog has no entry for the hex. Preserves behavior for third-party spools whose\n    firmware puts a readable string in tray_id_name instead of a Bambu code.\n    \"\"\"\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_color\": \"123456FF\",  # not in catalog\n        \"tray_id_name\": \"Custom Purple\",  # no '-', readable\n    }\n    spool = await create_spool_from_tray(db_session, tray)\n    assert spool.color_name == \"Custom Purple\"\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_gradient_spool(db_session):\n    \"\"\"find_matching_untagged_spool matches gradient subtype from tray_id_name.\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Gradient\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_sub_brands\": \"PLA Basic\",\n        \"tray_id_name\": \"A00-M2\",\n    }\n    found = await find_matching_untagged_spool(db_session, tray)\n    assert found is not None\n    assert found.id == spool.id\n\n\n@pytest.mark.asyncio\nasync def test_find_matching_untagged_gradient_no_match_basic(db_session):\n    \"\"\"A 'Basic' spool does NOT match a Gradient tray (different subtype).\"\"\"\n    spool = Spool(\n        material=\"PLA\",\n        subtype=\"Basic\",\n        rgba=\"FFFFFFFF\",\n        brand=\"Bambu Lab\",\n        label_weight=1000,\n        core_weight=250,\n    )\n    db_session.add(spool)\n    await db_session.commit()\n\n    tray = {\n        **SAMPLE_TRAY,\n        \"tray_sub_brands\": \"PLA Basic\",\n        \"tray_id_name\": \"A00-M2\",  # Gradient\n    }\n    found = await find_matching_untagged_spool(db_session, tray)\n    assert found is None\n"
  },
  {
    "path": "backend/tests/unit/services/test_spoolbuddy_ssh.py",
    "content": "\"\"\"Unit tests for SpoolBuddy SSH update service.\"\"\"\n\nimport asyncio\nimport os\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.spoolbuddy_ssh import (\n    _get_ssh_key_dir,\n    _run_ssh_command,\n    detect_current_branch,\n    get_or_create_keypair,\n    get_public_key,\n    perform_ssh_update,\n)\n\n# -- _get_ssh_key_dir ---------------------------------------------------------\n\n\ndef test_get_ssh_key_dir_creates_directory(tmp_path):\n    with patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings:\n        mock_settings.base_dir = tmp_path\n        key_dir = _get_ssh_key_dir()\n        assert key_dir == tmp_path / \"spoolbuddy\" / \"ssh\"\n        assert key_dir.exists()\n\n\ndef test_get_ssh_key_dir_returns_existing(tmp_path):\n    ssh_dir = tmp_path / \"spoolbuddy\" / \"ssh\"\n    ssh_dir.mkdir(parents=True)\n    with patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings:\n        mock_settings.base_dir = tmp_path\n        assert _get_ssh_key_dir() == ssh_dir\n\n\n# -- get_or_create_keypair -----------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_get_or_create_keypair_returns_existing(tmp_path):\n    ssh_dir = tmp_path / \"spoolbuddy\" / \"ssh\"\n    ssh_dir.mkdir(parents=True)\n    priv = ssh_dir / \"id_ed25519\"\n    pub = ssh_dir / \"id_ed25519.pub\"\n    priv.write_text(\"PRIVATE\")\n    pub.write_text(\"PUBLIC\")\n\n    with patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings:\n        mock_settings.base_dir = tmp_path\n        result = await get_or_create_keypair()\n        assert result == (priv, pub)\n\n\n@pytest.mark.asyncio\nasync def test_get_or_create_keypair_generates_new(tmp_path):\n    \"\"\"Key generation runs in-process via `cryptography` — no ssh-keygen subprocess.\n\n    This matters in Docker: when the container runs under an arbitrary PUID\n    that isn't in /etc/passwd, `ssh-keygen` aborts with \"no user exists for uid\n    <N>\". Generating the keypair in-process avoids the getpwuid() lookup.\n    \"\"\"\n    from cryptography.hazmat.primitives import serialization\n    from cryptography.hazmat.primitives.asymmetric import ed25519\n\n    with patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings:\n        mock_settings.base_dir = tmp_path\n\n        priv, pub = await get_or_create_keypair()\n\n        assert priv.exists()\n        assert pub.exists()\n        # Private key permissions — no world/group access\n        assert (priv.stat().st_mode & 0o077) == 0\n\n        # Public key is a valid OpenSSH ed25519 key with our comment\n        pub_text = pub.read_text()\n        assert pub_text.startswith(\"ssh-ed25519 \")\n        assert pub_text.rstrip().endswith(\"bambuddy-spoolbuddy\")\n\n        # Private key is a valid OpenSSH-format ed25519 key we can load back\n        loaded = serialization.load_ssh_private_key(priv.read_bytes(), password=None)\n        assert isinstance(loaded, ed25519.Ed25519PrivateKey)\n\n\n@pytest.mark.asyncio\nasync def test_get_or_create_keypair_does_not_shell_out(tmp_path):\n    \"\"\"Regression guard: must not invoke any subprocess (fixes Docker PUID bug).\"\"\"\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings,\n        patch(\"asyncio.create_subprocess_exec\") as mock_exec,\n    ):\n        mock_settings.base_dir = tmp_path\n        await get_or_create_keypair()\n        mock_exec.assert_not_called()\n\n\n# -- get_public_key ------------------------------------------------------------\n\n\n@pytest.mark.asyncio\nasync def test_get_public_key(tmp_path):\n    ssh_dir = tmp_path / \"spoolbuddy\" / \"ssh\"\n    ssh_dir.mkdir(parents=True)\n    (ssh_dir / \"id_ed25519\").write_text(\"PRIVATE\")\n    (ssh_dir / \"id_ed25519.pub\").write_text(\"ssh-ed25519 AAAA bambuddy-spoolbuddy\\n\")\n\n    with patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings:\n        mock_settings.base_dir = tmp_path\n        key = await get_public_key()\n        assert key == \"ssh-ed25519 AAAA bambuddy-spoolbuddy\"\n\n\n# -- detect_current_branch ----------------------------------------------------\n\n\ndef test_detect_branch_from_git_head(tmp_path):\n    \"\"\"Read branch directly from .git/HEAD in the application root — no subprocess.\"\"\"\n    git_dir = tmp_path / \".git\"\n    git_dir.mkdir()\n    (git_dir / \"HEAD\").write_text(\"ref: refs/heads/dev\\n\")\n\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh._APP_DIR\", tmp_path),\n        patch(\"asyncio.create_subprocess_exec\") as mock_exec,\n        patch(\"subprocess.run\") as mock_run,\n    ):\n        assert detect_current_branch() == \"dev\"\n        # Regression guard: must not shell out (fails with getpwuid under\n        # arbitrary Docker PUIDs if ever reintroduced).\n        mock_exec.assert_not_called()\n        mock_run.assert_not_called()\n\n\ndef test_detect_branch_uses_app_dir_not_data_dir(tmp_path):\n    \"\"\"Branch detection must look in the application root, not the data dir.\n\n    Regression guard for the Docker bug where `.git` was being looked up in\n    `settings.base_dir` (which is `DATA_DIR=/app/data` in Docker), so it was\n    never found and the fallback always returned \"main\" — even when the user\n    was on a feature branch bind-mounted at `/app`.\n    \"\"\"\n    app_dir = tmp_path / \"app\"\n    data_dir = tmp_path / \"app\" / \"data\"\n    app_dir.mkdir()\n    data_dir.mkdir()\n\n    # Real .git lives at the application root (bind-mount style).\n    (app_dir / \".git\").mkdir()\n    (app_dir / \".git\" / \"HEAD\").write_text(\"ref: refs/heads/dev\\n\")\n\n    # Decoy .git in the data dir — if the code ever regresses to reading\n    # from settings.base_dir, this would be returned instead.\n    (data_dir / \".git\").mkdir()\n    (data_dir / \".git\" / \"HEAD\").write_text(\"ref: refs/heads/wrong-branch\\n\")\n\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh._APP_DIR\", app_dir),\n        patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings,\n    ):\n        mock_settings.base_dir = data_dir\n        assert detect_current_branch() == \"dev\"\n\n\ndef test_detect_branch_worktree_gitdir_file(tmp_path):\n    \"\"\"Git worktrees store a `gitdir:` pointer instead of a dir — follow it.\"\"\"\n    real_git_dir = tmp_path / \"real-git\"\n    real_git_dir.mkdir()\n    (real_git_dir / \"HEAD\").write_text(\"ref: refs/heads/feature-x\\n\")\n    (tmp_path / \".git\").write_text(f\"gitdir: {real_git_dir}\\n\")\n\n    with patch(\"backend.app.services.spoolbuddy_ssh._APP_DIR\", tmp_path):\n        assert detect_current_branch() == \"feature-x\"\n\n\ndef test_detect_branch_detached_head_falls_back(tmp_path):\n    \"\"\"Detached HEAD (raw commit hash) should fall through to the env var.\"\"\"\n    git_dir = tmp_path / \".git\"\n    git_dir.mkdir()\n    (git_dir / \"HEAD\").write_text(\"deadbeef1234\\n\")\n\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh._APP_DIR\", tmp_path),\n        patch.dict(os.environ, {\"GIT_BRANCH\": \"release\"}),\n    ):\n        assert detect_current_branch() == \"release\"\n\n\ndef test_detect_branch_env_fallback(tmp_path):\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh._APP_DIR\", tmp_path),\n        patch.dict(os.environ, {\"GIT_BRANCH\": \"staging\"}),\n    ):\n        assert detect_current_branch() == \"staging\"\n\n\ndef test_detect_branch_default_main(tmp_path):\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh._APP_DIR\", tmp_path),\n        patch.dict(os.environ, {}, clear=True),\n    ):\n        # Remove GIT_BRANCH if present\n        os.environ.pop(\"GIT_BRANCH\", None)\n        assert detect_current_branch() == \"main\"\n\n\n# -- _run_ssh_command ----------------------------------------------------------\n#\n# _run_ssh_command uses asyncssh (pure Python) rather than the OpenSSH `ssh`\n# binary. Both `ssh` and `ssh-keygen` call getpwuid(getuid()) during startup\n# and abort with \"No user exists for uid <N>\" when the container runs under\n# an arbitrary PUID that is not listed in /etc/passwd — asyncssh avoids the\n# subprocess entirely.\n\n\n@pytest.mark.asyncio\nasync def test_run_ssh_command_success(tmp_path):\n    key_file = tmp_path / \"key\"\n    key_file.write_text(\"KEY\")\n\n    mock_result = MagicMock()\n    mock_result.stdout = \"hello\\n\"\n    mock_result.stderr = \"\"\n    mock_result.exit_status = 0\n\n    mock_conn = AsyncMock()\n    mock_conn.run = AsyncMock(return_value=mock_result)\n    mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)\n    mock_conn.__aexit__ = AsyncMock(return_value=False)\n\n    with patch(\"backend.app.services.spoolbuddy_ssh.asyncssh.connect\", return_value=mock_conn) as mock_connect:\n        rc, stdout, stderr = await _run_ssh_command(\"10.0.0.1\", \"echo hello\", key_file)\n\n    assert rc == 0\n    assert stdout == \"hello\\n\"\n    assert stderr == \"\"\n    kwargs = mock_connect.call_args.kwargs\n    assert kwargs[\"host\"] == \"10.0.0.1\"\n    assert kwargs[\"username\"] == \"spoolbuddy\"\n    assert kwargs[\"client_keys\"] == [str(key_file)]\n    # Host-key verification is disabled (equivalent to StrictHostKeyChecking=no)\n    assert kwargs[\"known_hosts\"] is None\n    # ~/.ssh/config loading is disabled — HOME may not resolve under arbitrary\n    # Docker PUIDs.\n    assert kwargs[\"config\"] == []\n    mock_conn.run.assert_awaited_once()\n    run_args = mock_conn.run.call_args\n    assert run_args.args[0] == \"echo hello\"\n    # check=False — we handle non-zero exit codes ourselves\n    assert run_args.kwargs.get(\"check\") is False\n\n\n@pytest.mark.asyncio\nasync def test_run_ssh_command_no_subprocess(tmp_path):\n    \"\"\"Regression guard: _run_ssh_command must not spawn any subprocess.\n\n    The whole point of switching to asyncssh is to avoid `ssh`/`ssh-keygen`\n    calling getpwuid() inside Docker containers with arbitrary PUIDs.\n    \"\"\"\n    key_file = tmp_path / \"key\"\n    key_file.write_text(\"KEY\")\n\n    mock_result = MagicMock()\n    mock_result.stdout = \"\"\n    mock_result.stderr = \"\"\n    mock_result.exit_status = 0\n\n    mock_conn = AsyncMock()\n    mock_conn.run = AsyncMock(return_value=mock_result)\n    mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)\n    mock_conn.__aexit__ = AsyncMock(return_value=False)\n\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh.asyncssh.connect\", return_value=mock_conn),\n        patch(\"asyncio.create_subprocess_exec\") as mock_exec,\n    ):\n        await _run_ssh_command(\"10.0.0.1\", \"echo hi\", key_file)\n\n    mock_exec.assert_not_called()\n\n\n@pytest.mark.asyncio\nasync def test_run_ssh_command_connection_failure(tmp_path):\n    \"\"\"Connection errors should surface as rc=255 with the asyncssh message.\"\"\"\n    import asyncssh\n\n    key_file = tmp_path / \"key\"\n    key_file.write_text(\"KEY\")\n\n    with patch(\n        \"backend.app.services.spoolbuddy_ssh.asyncssh.connect\",\n        side_effect=asyncssh.Error(code=0, reason=\"Connection refused\"),\n    ):\n        rc, stdout, stderr = await _run_ssh_command(\"10.0.0.1\", \"echo hello\", key_file)\n\n    assert rc == 255\n    assert stdout == \"\"\n    assert \"Connection refused\" in stderr\n\n\n@pytest.mark.asyncio\nasync def test_run_ssh_command_os_error(tmp_path):\n    \"\"\"OS-level connection errors (DNS, route) also map to rc=255.\"\"\"\n    key_file = tmp_path / \"key\"\n    key_file.write_text(\"KEY\")\n\n    with patch(\n        \"backend.app.services.spoolbuddy_ssh.asyncssh.connect\",\n        side_effect=OSError(\"Network is unreachable\"),\n    ):\n        rc, _, stderr = await _run_ssh_command(\"10.0.0.1\", \"echo hello\", key_file)\n\n    assert rc == 255\n    assert \"Network is unreachable\" in stderr\n\n\n@pytest.mark.asyncio\nasync def test_run_ssh_command_timeout(tmp_path):\n    \"\"\"asyncio.timeout should convert long-running commands into rc=-1.\"\"\"\n    key_file = tmp_path / \"key\"\n    key_file.write_text(\"KEY\")\n\n    # asyncssh.connect() returns a _ConnectionManager synchronously; the hang\n    # must happen inside __aenter__ so the surrounding asyncio.timeout can\n    # cancel it.\n    mock_conn = AsyncMock()\n\n    async def hang_enter():\n        await asyncio.sleep(10)\n\n    mock_conn.__aenter__ = AsyncMock(side_effect=hang_enter)\n    mock_conn.__aexit__ = AsyncMock(return_value=False)\n\n    with patch(\"backend.app.services.spoolbuddy_ssh.asyncssh.connect\", return_value=mock_conn):\n        rc, _, stderr = await _run_ssh_command(\"10.0.0.1\", \"sleep 999\", key_file, timeout=0.05)\n\n    assert rc == -1\n    assert \"timed out\" in stderr\n\n\n# -- perform_ssh_update --------------------------------------------------------\n\n\ndef _make_update_mocks(tmp_path):\n    \"\"\"Create common mocks for perform_ssh_update tests.\"\"\"\n    mock_db_device = MagicMock()\n    mock_db_device.update_status = None\n    mock_db_device.update_message = None\n    mock_db_device.pending_command = None\n\n    mock_result = MagicMock()\n    mock_result.scalar_one_or_none.return_value = mock_db_device\n\n    mock_session = AsyncMock()\n    mock_session.execute = AsyncMock(return_value=mock_result)\n    mock_session.commit = AsyncMock()\n\n    mock_ctx = AsyncMock()\n    mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)\n    mock_ctx.__aexit__ = AsyncMock(return_value=False)\n\n    mock_ws = MagicMock()\n    mock_ws.broadcast = AsyncMock()\n\n    return mock_db_device, mock_ctx, mock_ws\n\n\n@pytest.mark.asyncio\nasync def test_perform_ssh_update_success(tmp_path):\n    \"\"\"Full update flow: all SSH commands succeed.\"\"\"\n    ssh_dir = tmp_path / \"spoolbuddy\" / \"ssh\"\n    ssh_dir.mkdir(parents=True)\n    (ssh_dir / \"id_ed25519\").write_text(\"PRIVATE\")\n    (ssh_dir / \"id_ed25519.pub\").write_text(\"PUBLIC\")\n\n    ssh_calls = []\n\n    async def mock_ssh(ip, cmd, key, timeout=60):\n        ssh_calls.append(cmd)\n        return 0, \"ok\", \"\"\n\n    _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)\n\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings,\n        patch(\"backend.app.services.spoolbuddy_ssh._run_ssh_command\", side_effect=mock_ssh),\n        patch(\"backend.app.services.spoolbuddy_ssh.detect_current_branch\", return_value=\"dev\"),\n        patch(\"backend.app.core.database.async_session\", return_value=mock_ctx),\n        patch(\"backend.app.api.routes.spoolbuddy.ws_manager\", mock_ws),\n    ):\n        mock_settings.base_dir = tmp_path\n        await perform_ssh_update(\"sb-test\", \"10.0.0.1\")\n\n    # Should have run: echo ok, git fetch, git checkout+reset, pip install,\n    # systemctl restart, find (SW cleanup), systemctl restart getty\n    assert len(ssh_calls) == 7\n    assert \"echo ok\" in ssh_calls[0]\n    assert \"fetch\" in ssh_calls[1]\n    assert \"checkout\" in ssh_calls[2]\n    assert \"pip\" in ssh_calls[3]\n    assert \"spoolbuddy.service\" in ssh_calls[4]\n    assert \"Service Worker\" in ssh_calls[5]\n    assert \"getty\" in ssh_calls[6]\n\n    assert mock_ws.broadcast.call_count >= 4\n\n\n@pytest.mark.asyncio\nasync def test_perform_ssh_update_ssh_failure(tmp_path):\n    \"\"\"SSH connectivity check fails — should set error status.\"\"\"\n    ssh_dir = tmp_path / \"spoolbuddy\" / \"ssh\"\n    ssh_dir.mkdir(parents=True)\n    (ssh_dir / \"id_ed25519\").write_text(\"PRIVATE\")\n    (ssh_dir / \"id_ed25519.pub\").write_text(\"PUBLIC\")\n\n    async def mock_ssh(ip, cmd, key, timeout=60):\n        if \"echo ok\" in cmd:\n            return 255, \"\", \"Connection refused\"\n        return 0, \"\", \"\"\n\n    mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)\n\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings,\n        patch(\"backend.app.services.spoolbuddy_ssh._run_ssh_command\", side_effect=mock_ssh),\n        patch(\"backend.app.services.spoolbuddy_ssh.detect_current_branch\", return_value=\"main\"),\n        patch(\"backend.app.core.database.async_session\", return_value=mock_ctx),\n        patch(\"backend.app.api.routes.spoolbuddy.ws_manager\", mock_ws),\n    ):\n        mock_settings.base_dir = tmp_path\n        await perform_ssh_update(\"sb-test\", \"10.0.0.1\")\n\n    # Should broadcast error status\n    error_broadcasts = [c for c in mock_ws.broadcast.call_args_list if c[0][0].get(\"update_status\") == \"error\"]\n    assert len(error_broadcasts) >= 1\n    assert \"SSH connection failed\" in error_broadcasts[0][0][0][\"update_message\"]\n\n\n@pytest.mark.asyncio\nasync def test_perform_ssh_update_git_fetch_failure(tmp_path):\n    \"\"\"Git fetch fails — should set error and stop.\"\"\"\n    ssh_dir = tmp_path / \"spoolbuddy\" / \"ssh\"\n    ssh_dir.mkdir(parents=True)\n    (ssh_dir / \"id_ed25519\").write_text(\"PRIVATE\")\n    (ssh_dir / \"id_ed25519.pub\").write_text(\"PUBLIC\")\n\n    ssh_calls = []\n\n    async def mock_ssh(ip, cmd, key, timeout=60):\n        ssh_calls.append(cmd)\n        if \"fetch\" in cmd:\n            return 1, \"\", \"fatal: could not read from remote\"\n        return 0, \"ok\", \"\"\n\n    _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)\n\n    with (\n        patch(\"backend.app.services.spoolbuddy_ssh.settings\") as mock_settings,\n        patch(\"backend.app.services.spoolbuddy_ssh._run_ssh_command\", side_effect=mock_ssh),\n        patch(\"backend.app.services.spoolbuddy_ssh.detect_current_branch\", return_value=\"main\"),\n        patch(\"backend.app.core.database.async_session\", return_value=mock_ctx),\n        patch(\"backend.app.api.routes.spoolbuddy.ws_manager\", mock_ws),\n    ):\n        mock_settings.base_dir = tmp_path\n        await perform_ssh_update(\"sb-test\", \"10.0.0.1\")\n\n    # Should stop after git fetch — no checkout, pip, restart\n    assert len(ssh_calls) == 2  # echo ok + git fetch\n    assert not any(\"checkout\" in c for c in ssh_calls)\n"
  },
  {
    "path": "backend/tests/unit/services/test_spoolman_service.py",
    "content": "\"\"\"Unit tests for Spoolman service.\n\nThese tests specifically target the sync_ams_tray method's disable_weight_sync\nfunctionality that controls whether remaining_weight is updated.\nAlso includes tests for is_bambu_lab_spool RFID detection.\n\"\"\"\n\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport pytest\n\nfrom backend.app.services.spoolman import AMSTray, SpoolmanClient\n\n\nclass TestIsBambuLabSpool:\n    \"\"\"Tests for is_bambu_lab_spool — detects BL spools via RFID hardware identifiers only.\"\"\"\n\n    @pytest.fixture\n    def client(self):\n        return SpoolmanClient(\"http://localhost:7912\")\n\n    def test_valid_tray_uuid_returns_true(self, client):\n        \"\"\"A non-zero 32-char hex tray_uuid identifies a BL spool.\"\"\"\n        assert client.is_bambu_lab_spool(\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\") is True\n\n    def test_valid_tag_uid_returns_true(self, client):\n        \"\"\"A non-zero 16-char hex tag_uid identifies a BL spool (fallback).\"\"\"\n        assert client.is_bambu_lab_spool(\"\", tag_uid=\"A1B2C3D4E5F6A1B2\") is True\n\n    def test_zero_tray_uuid_returns_false(self, client):\n        \"\"\"All-zero tray_uuid means no RFID tag read.\"\"\"\n        assert client.is_bambu_lab_spool(\"00000000000000000000000000000000\") is False\n\n    def test_zero_tag_uid_returns_false(self, client):\n        \"\"\"All-zero tag_uid means no RFID tag read.\"\"\"\n        assert client.is_bambu_lab_spool(\"\", tag_uid=\"0000000000000000\") is False\n\n    def test_empty_identifiers_returns_false(self, client):\n        \"\"\"No identifiers means no BL spool.\"\"\"\n        assert client.is_bambu_lab_spool(\"\") is False\n        assert client.is_bambu_lab_spool(\"\", tag_uid=\"\") is False\n\n    def test_tray_info_idx_ignored(self, client):\n        \"\"\"tray_info_idx is NOT a reliable BL indicator — third-party spools\n        using Bambu generic presets also have GF-prefixed tray_info_idx values.\"\"\"\n        # Third-party spool with Bambu preset but no RFID identifiers\n        assert client.is_bambu_lab_spool(\"\", tray_info_idx=\"GFA00\") is False\n        assert client.is_bambu_lab_spool(\"\", tray_info_idx=\"GFB00\") is False\n        assert client.is_bambu_lab_spool(\"\", tray_info_idx=\"GFSA02_04\") is False\n\n    def test_tray_info_idx_with_valid_uuid_returns_true(self, client):\n        \"\"\"BL spool with both RFID UUID and preset ID — detected by UUID.\"\"\"\n        assert (\n            client.is_bambu_lab_spool(\n                \"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\",\n                tray_info_idx=\"GFA00\",\n            )\n            is True\n        )\n\n    def test_tray_uuid_preferred_over_tag_uid(self, client):\n        \"\"\"tray_uuid is checked before tag_uid (both valid).\"\"\"\n        assert (\n            client.is_bambu_lab_spool(\n                \"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\",\n                tag_uid=\"A1B2C3D4E5F6A1B2\",\n            )\n            is True\n        )\n\n    def test_short_tray_uuid_returns_false(self, client):\n        \"\"\"UUID must be exactly 32 hex chars.\"\"\"\n        assert client.is_bambu_lab_spool(\"A1B2C3D4\") is False\n\n    def test_non_hex_tray_uuid_returns_false(self, client):\n        \"\"\"UUID must be valid hex.\"\"\"\n        assert client.is_bambu_lab_spool(\"ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ\") is False\n\n\nclass TestSpoolmanClient:\n    \"\"\"Tests for SpoolmanClient class.\"\"\"\n\n    @pytest.fixture\n    def client(self):\n        \"\"\"Create a SpoolmanClient instance.\"\"\"\n        return SpoolmanClient(\"http://localhost:7912\")\n\n    @pytest.fixture\n    def sample_tray(self):\n        \"\"\"Create a sample AMSTray for testing.\"\"\"\n        return AMSTray(\n            ams_id=0,\n            tray_id=0,\n            tray_type=\"PLA\",\n            tray_sub_brands=\"PLA Basic\",\n            tray_color=\"FF0000FF\",\n            remain=50,\n            tag_uid=\"\",\n            tray_uuid=\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\",\n            tray_info_idx=\"GFA00\",\n            tray_weight=1000,\n        )\n\n    @pytest.fixture\n    def existing_spool(self):\n        \"\"\"Create a mock existing spool response.\"\"\"\n        return {\n            \"id\": 42,\n            \"remaining_weight\": 800,\n            \"extra\": {\"tag\": '\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\"'},\n            \"filament\": {\"id\": 1, \"name\": \"PLA Red\", \"material\": \"PLA\"},\n        }\n\n    @pytest.fixture\n    def mock_filament(self):\n        \"\"\"Create a mock filament response.\"\"\"\n        return {\"id\": 1, \"name\": \"PLA Basic\", \"material\": \"PLA\"}\n\n    # ========================================================================\n    # Tests for sync_ams_tray with disable_weight_sync\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_sync_ams_tray_updates_weight_by_default(self, client, sample_tray, existing_spool):\n        \"\"\"Verify sync_ams_tray updates remaining_weight by default.\"\"\"\n        with (\n            patch.object(client, \"find_spool_by_tag\", AsyncMock(return_value=existing_spool)),\n            patch.object(client, \"update_spool\", AsyncMock(return_value={\"id\": 42})) as mock_update,\n        ):\n            await client.sync_ams_tray(sample_tray, \"TestPrinter\")\n\n            mock_update.assert_called_once()\n            call_kwargs = mock_update.call_args.kwargs\n            assert \"remaining_weight\" in call_kwargs\n            assert call_kwargs[\"remaining_weight\"] == 500.0  # 50% of 1000g\n            assert \"location\" in call_kwargs\n\n    @pytest.mark.asyncio\n    async def test_sync_ams_tray_skips_weight_when_disabled(self, client, sample_tray, existing_spool):\n        \"\"\"Verify sync_ams_tray skips remaining_weight when disable_weight_sync=True.\"\"\"\n        with (\n            patch.object(client, \"find_spool_by_tag\", AsyncMock(return_value=existing_spool)),\n            patch.object(client, \"update_spool\", AsyncMock(return_value={\"id\": 42})) as mock_update,\n        ):\n            await client.sync_ams_tray(sample_tray, \"TestPrinter\", disable_weight_sync=True)\n\n            mock_update.assert_called_once()\n            call_kwargs = mock_update.call_args.kwargs\n            # remaining_weight should be None (not updated)\n            assert call_kwargs.get(\"remaining_weight\") is None\n            # location should still be updated\n            assert \"location\" in call_kwargs\n            assert \"TestPrinter\" in call_kwargs[\"location\"]\n\n    @pytest.mark.asyncio\n    async def test_sync_ams_tray_new_spool_always_includes_weight(self, client, sample_tray, mock_filament):\n        \"\"\"Verify new spool creation always includes remaining_weight even when disabled.\"\"\"\n        with (\n            patch.object(client, \"find_spool_by_tag\", AsyncMock(return_value=None)),\n            patch.object(client, \"_find_or_create_filament\", AsyncMock(return_value=mock_filament)),\n            patch.object(client, \"create_spool\", AsyncMock(return_value={\"id\": 99})) as mock_create,\n        ):\n            await client.sync_ams_tray(sample_tray, \"TestPrinter\", disable_weight_sync=True)\n\n            mock_create.assert_called_once()\n            call_kwargs = mock_create.call_args.kwargs\n            # New spools should ALWAYS include remaining_weight\n            assert \"remaining_weight\" in call_kwargs\n            assert call_kwargs[\"remaining_weight\"] == 500.0  # 50% of 1000g\n\n    @pytest.mark.asyncio\n    async def test_sync_ams_tray_location_format(self, client, sample_tray, existing_spool):\n        \"\"\"Verify location format is correct when updating spool.\"\"\"\n        with (\n            patch.object(client, \"find_spool_by_tag\", AsyncMock(return_value=existing_spool)),\n            patch.object(client, \"update_spool\", AsyncMock(return_value={\"id\": 42})) as mock_update,\n        ):\n            await client.sync_ams_tray(sample_tray, \"My Printer\", disable_weight_sync=True)\n\n            call_kwargs = mock_update.call_args.kwargs\n            # Location should follow pattern: \"PrinterName - AMS A1\"\n            assert \"location\" in call_kwargs\n            assert \"My Printer\" in call_kwargs[\"location\"]\n            assert \"AMS\" in call_kwargs[\"location\"]\n\n    @pytest.mark.asyncio\n    async def test_sync_ams_tray_skips_non_bambu_spool(self, client):\n        \"\"\"Verify non-Bambu Lab spools are skipped.\"\"\"\n        # Third-party spool without proper identifiers\n        tray = AMSTray(\n            ams_id=0,\n            tray_id=0,\n            tray_type=\"PLA\",\n            tray_sub_brands=\"Third Party PLA\",\n            tray_color=\"FF0000FF\",\n            remain=50,\n            tag_uid=\"\",\n            tray_uuid=\"\",\n            tray_info_idx=\"\",  # No Bambu Lab preset ID\n            tray_weight=1000,\n        )\n\n        result = await client.sync_ams_tray(tray, \"TestPrinter\")\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_sync_ams_tray_weight_calculation(self, client, existing_spool):\n        \"\"\"Verify remaining weight is calculated correctly for various percentages.\"\"\"\n        test_cases = [\n            (100, 1000, 1000.0),  # Full spool\n            (50, 1000, 500.0),  # Half spool\n            (25, 1000, 250.0),  # Quarter spool\n            (0, 1000, 0.0),  # Empty spool\n            (75, 500, 375.0),  # Different spool weight\n        ]\n\n        for remain, weight, expected in test_cases:\n            tray = AMSTray(\n                ams_id=0,\n                tray_id=0,\n                tray_type=\"PLA\",\n                tray_sub_brands=\"PLA Basic\",\n                tray_color=\"FF0000FF\",\n                remain=remain,\n                tag_uid=\"\",\n                tray_uuid=\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\",\n                tray_info_idx=\"GFA00\",\n                tray_weight=weight,\n            )\n\n            with (\n                patch.object(client, \"find_spool_by_tag\", AsyncMock(return_value=existing_spool)),\n                patch.object(client, \"update_spool\", AsyncMock(return_value={\"id\": 42})) as mock_update,\n            ):\n                await client.sync_ams_tray(tray, \"TestPrinter\", disable_weight_sync=False)\n\n                call_kwargs = mock_update.call_args.kwargs\n                assert call_kwargs[\"remaining_weight\"] == expected, (\n                    f\"Expected {expected}g for {remain}% of {weight}g, got {call_kwargs['remaining_weight']}\"\n                )\n\n    # ========================================================================\n    # Tests for caching functionality\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_find_spool_by_tag_with_cached_spools(self, client):\n        \"\"\"Verify find_spool_by_tag uses cached spools when provided (no API call).\"\"\"\n        cached = [\n            {\"id\": 1, \"extra\": {\"tag\": '\"ABC123\"'}},\n            {\"id\": 2, \"extra\": {\"tag\": '\"XYZ789\"'}},\n        ]\n\n        with patch.object(client, \"get_spools\", AsyncMock()) as mock_get:\n            result = await client.find_spool_by_tag(\"ABC123\", cached_spools=cached)\n            assert result[\"id\"] == 1\n            mock_get.assert_not_called()  # Should NOT call get_spools\n\n    @pytest.mark.asyncio\n    async def test_find_spool_by_tag_without_cached_spools(self, client):\n        \"\"\"Verify find_spool_by_tag fetches spools when cache not provided.\"\"\"\n        mock_spools = [{\"id\": 1, \"extra\": {\"tag\": '\"ABC123\"'}}]\n\n        with patch.object(client, \"get_spools\", AsyncMock(return_value=mock_spools)) as mock_get:\n            result = await client.find_spool_by_tag(\"ABC123\")\n            assert result[\"id\"] == 1\n            mock_get.assert_called_once()  # Should call get_spools\n\n    @pytest.mark.asyncio\n    async def test_find_spools_by_location_prefix_with_cached_spools(self, client):\n        \"\"\"Verify find_spools_by_location_prefix uses cached spools when provided.\"\"\"\n        cached = [\n            {\"id\": 1, \"location\": \"Printer1 - AMS A1\"},\n            {\"id\": 2, \"location\": \"Printer2 - AMS A1\"},\n            {\"id\": 3, \"location\": \"Printer1 - AMS A2\"},\n        ]\n\n        with patch.object(client, \"get_spools\", AsyncMock()) as mock_get:\n            result = await client.find_spools_by_location_prefix(\"Printer1 - \", cached_spools=cached)\n            assert len(result) == 2\n            assert result[0][\"id\"] == 1\n            assert result[1][\"id\"] == 3\n            mock_get.assert_not_called()  # Should NOT call get_spools\n\n    @pytest.mark.asyncio\n    async def test_sync_ams_tray_with_cached_spools(self, client, sample_tray, existing_spool):\n        \"\"\"Verify sync_ams_tray passes cached_spools to find_spool_by_tag.\"\"\"\n        cached = [existing_spool]\n\n        with (\n            patch.object(client, \"get_spools\", AsyncMock()) as mock_get,\n            patch.object(client, \"update_spool\", AsyncMock(return_value={\"id\": 42})),\n        ):\n            await client.sync_ams_tray(sample_tray, \"TestPrinter\", cached_spools=cached)\n            mock_get.assert_not_called()  # Should NOT call get_spools\n\n    @pytest.mark.asyncio\n    async def test_clear_location_for_removed_spools_with_cached_spools(self, client):\n        \"\"\"Verify clear_location_for_removed_spools uses cached spools.\"\"\"\n        cached = [\n            {\"id\": 1, \"location\": \"Printer1 - AMS A1\", \"extra\": {\"tag\": '\"A1B2C3D4E5F60718293A4B5C6D7E8F90\"'}},\n            {\"id\": 2, \"location\": \"Printer1 - AMS A2\", \"extra\": {\"tag\": '\"B1C2D3E4F5061728394A5B6C7D8E9F01\"'}},\n            {\"id\": 3, \"location\": \"Printer1 - AMS A3\", \"extra\": {\"tag\": '\"C1D2E3F40516273849A5B6C7D8E9F012\"'}},\n        ]\n        # Tag 3 was cleared, so only tags 1 and 2 are current\n        current_tags = {\n            \"A1B2C3D4E5F60718293A4B5C6D7E8F90\",\n            \"B1C2D3E4F5061728394A5B6C7D8E9F01\",\n        }\n\n        with (\n            patch.object(client, \"get_spools\", AsyncMock()) as mock_get,\n            patch.object(client, \"update_spool\", AsyncMock(return_value={\"id\": 3})) as mock_update,\n        ):\n            cleared = await client.clear_location_for_removed_spools(\"Printer1\", current_tags, cached_spools=cached)\n            assert cleared == 1\n            mock_get.assert_not_called()  # Should NOT call get_spools\n            mock_update.assert_called_once()\n            # Verify it cleared TAG3 (not in current_tags)\n            call_kwargs = mock_update.call_args.kwargs\n            assert call_kwargs[\"spool_id\"] == 3\n            assert call_kwargs.get(\"clear_location\") is True\n\n    # ========================================================================\n    # Tests for retry logic in get_spools\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_get_spools_succeeds_on_first_attempt(self, client):\n        \"\"\"Verify get_spools succeeds immediately when no errors occur.\"\"\"\n        mock_spools = [{\"id\": 1}, {\"id\": 2}]\n\n        with patch.object(client, \"_get_client\") as mock_get_client:\n            mock_http_client = AsyncMock()\n            mock_response = Mock()\n            mock_response.raise_for_status = Mock()\n            mock_response.json = Mock(return_value=mock_spools)\n            mock_http_client.get = AsyncMock(return_value=mock_response)\n            mock_get_client.return_value = mock_http_client\n\n            result = await client.get_spools()\n\n            assert result == mock_spools\n            mock_get_client.assert_called_once()\n            mock_http_client.get.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_get_spools_retries_on_connection_error(self, client):\n        \"\"\"Verify get_spools retries up to 3 times on connection errors.\"\"\"\n        import httpx\n\n        mock_spools = [{\"id\": 1}]\n\n        with (\n            patch.object(client, \"_get_client\") as mock_get_client,\n            patch.object(client, \"close\", AsyncMock()) as mock_close,\n            patch(\"asyncio.sleep\", AsyncMock()) as mock_sleep,\n        ):\n            mock_http_client = AsyncMock()\n            mock_get_client.return_value = mock_http_client\n\n            # First 2 attempts fail with ReadError, 3rd succeeds\n            mock_response = Mock()\n            mock_response.raise_for_status = Mock()\n            mock_response.json = Mock(return_value=mock_spools)\n\n            mock_http_client.get = AsyncMock(\n                side_effect=[\n                    httpx.ReadError(\"Connection closed\"),\n                    httpx.ReadError(\"Connection closed\"),\n                    mock_response,\n                ]\n            )\n\n            result = await client.get_spools()\n\n            assert result == mock_spools\n            assert mock_get_client.call_count == 3\n            assert mock_http_client.get.call_count == 3\n            # Should close client twice (after each failed attempt)\n            assert mock_close.call_count == 2\n            # Should sleep twice (after first 2 attempts)\n            assert mock_sleep.call_count == 2\n            mock_sleep.assert_called_with(0.5)\n\n    @pytest.mark.asyncio\n    async def test_get_spools_raises_after_3_failed_attempts(self, client):\n        \"\"\"Verify get_spools raises exception after 3 failed attempts.\"\"\"\n        import httpx\n\n        with (\n            patch.object(client, \"_get_client\", AsyncMock()) as mock_get_client,\n            patch.object(client, \"close\", AsyncMock()) as mock_close,\n            patch(\"asyncio.sleep\", AsyncMock()) as mock_sleep,\n        ):\n            mock_http_client = AsyncMock()\n            mock_get_client.return_value = mock_http_client\n\n            # All 3 attempts fail\n            mock_http_client.get.side_effect = httpx.ReadError(\"Connection closed\")\n\n            with pytest.raises(httpx.ReadError):\n                await client.get_spools()\n\n            assert mock_get_client.call_count == 3\n            assert mock_http_client.get.call_count == 3\n            # Should close client twice (after first 2 failed attempts, not after 3rd)\n            assert mock_close.call_count == 2\n            # Should sleep twice (after first 2 attempts, not after 3rd)\n            assert mock_sleep.call_count == 2\n\n    @pytest.mark.asyncio\n    async def test_get_spools_handles_non_connection_errors(self, client):\n        \"\"\"Verify get_spools retries on non-connection errors without recreating client.\"\"\"\n        import httpx\n\n        mock_spools = [{\"id\": 1}]\n\n        with (\n            patch.object(client, \"_get_client\") as mock_get_client,\n            patch.object(client, \"close\", AsyncMock()) as mock_close,\n            patch(\"asyncio.sleep\", AsyncMock()) as mock_sleep,\n        ):\n            mock_http_client = AsyncMock()\n            mock_get_client.return_value = mock_http_client\n\n            # First attempt fails with HTTP error, 2nd succeeds\n            mock_response_error = Mock()\n            mock_response_error.raise_for_status = Mock(\n                side_effect=httpx.HTTPStatusError(\"500 Server Error\", request=Mock(), response=Mock())\n            )\n\n            mock_response_success = Mock()\n            mock_response_success.raise_for_status = Mock()\n            mock_response_success.json = Mock(return_value=mock_spools)\n\n            mock_http_client.get = AsyncMock(side_effect=[mock_response_error, mock_response_success])\n\n            result = await client.get_spools()\n\n            assert result == mock_spools\n            assert mock_get_client.call_count == 2\n            # Should NOT close client for HTTP errors (only connection errors)\n            mock_close.assert_not_called()\n            # Should sleep once (after first failed attempt)\n            assert mock_sleep.call_count == 1\n"
  },
  {
    "path": "backend/tests/unit/services/test_spoolman_tracking.py",
    "content": "\"\"\"Unit tests for Spoolman tracking service helpers.\"\"\"\n\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.spoolman_tracking import (\n    _get_fallback_spool_tag,\n    _global_tray_id_to_ams_slot,\n    _hash_serial_to_hex32,\n    _resolve_global_tray_id,\n    _resolve_spool_tag,\n    build_ams_tray_lookup,\n    store_print_data,\n)\n\n\nclass TestResolveSpoolTag:\n    \"\"\"Tests for _resolve_spool_tag().\"\"\"\n\n    def test_prefers_tray_uuid(self):\n        tray = {\"tray_uuid\": \"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\", \"tag_uid\": \"DEADBEEF\"}\n        assert _resolve_spool_tag(tray) == \"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\"\n\n    def test_falls_back_to_tag_uid(self):\n        tray = {\"tray_uuid\": \"\", \"tag_uid\": \"DEADBEEF\"}\n        assert _resolve_spool_tag(tray) == \"DEADBEEF\"\n\n    def test_skips_zero_uuid(self):\n        tray = {\"tray_uuid\": \"00000000000000000000000000000000\", \"tag_uid\": \"DEADBEEF\"}\n        assert _resolve_spool_tag(tray) == \"DEADBEEF\"\n\n    def test_rejects_zero_tag_uid(self):\n        tray = {\"tray_uuid\": \"\", \"tag_uid\": \"0000000000000000\"}\n        assert _resolve_spool_tag(tray) == \"\"\n\n    def test_uses_fallback_tag_when_ids_missing(self):\n        tray = {\"tray_uuid\": \"\", \"tag_uid\": \"\"}\n        # global_tray_id 0 -> ams_id 0, tray_id 0\n        assert _resolve_spool_tag(tray, \"01P00A000000000\", 0) == \"ABA7845700000000\"\n\n    def test_uses_fallback_tag_when_ids_zero(self):\n        tray = {\"tray_uuid\": \"00000000000000000000000000000000\", \"tag_uid\": \"0000000000000000\"}\n        # global_tray_id 5 -> ams_id 1, tray_id 1\n        assert _resolve_spool_tag(tray, \"01P00A000000000\", 5) == \"ABA7845700010001\"\n\n    def test_empty_both(self):\n        tray = {\"tray_uuid\": \"\", \"tag_uid\": \"\"}\n        assert _resolve_spool_tag(tray) == \"\"\n\n    def test_missing_keys(self):\n        assert _resolve_spool_tag({}) == \"\"\n\n    def test_zero_uuid_no_tag(self):\n        tray = {\"tray_uuid\": \"00000000000000000000000000000000\", \"tag_uid\": \"\"}\n        assert _resolve_spool_tag(tray) == \"\"\n\n\nclass TestResolveGlobalTrayId:\n    \"\"\"Tests for _resolve_global_tray_id().\"\"\"\n\n    def test_default_mapping(self):\n        \"\"\"slot 1 -> tray 0, slot 2 -> tray 1, etc.\"\"\"\n        assert _resolve_global_tray_id(1, None) == 0\n        assert _resolve_global_tray_id(2, None) == 1\n        assert _resolve_global_tray_id(4, None) == 3\n\n    def test_custom_mapping(self):\n        \"\"\"Custom slot_to_tray overrides default.\"\"\"\n        mapping = [5, 2, -1, 0]\n        assert _resolve_global_tray_id(1, mapping) == 5\n        assert _resolve_global_tray_id(2, mapping) == 2\n        assert _resolve_global_tray_id(4, mapping) == 0\n\n    def test_unmapped_slot(self):\n        \"\"\"Slot with -1 in mapping uses default.\"\"\"\n        mapping = [5, -1, 2, 0]\n        assert _resolve_global_tray_id(2, mapping) == 1  # default: slot 2 -> tray 1\n\n    def test_slot_beyond_mapping(self):\n        \"\"\"Slot beyond mapping length uses default.\"\"\"\n        mapping = [5, 2]\n        assert _resolve_global_tray_id(3, mapping) == 2  # default: slot 3 -> tray 2\n\n    def test_empty_mapping(self):\n        mapping = []\n        assert _resolve_global_tray_id(1, mapping) == 0\n\n\nclass TestFallbackTagHelpers:\n    \"\"\"Tests for frontend-mirrored fallback tag helpers.\"\"\"\n\n    def test_hash_serial_matches_frontend_algorithm(self):\n        assert _hash_serial_to_hex32(\"01P00A000000000\") == \"ABA78457\"\n        # Frontend trims and uppercases before hashing\n        assert _hash_serial_to_hex32(\" 01p00a000000000 \") == \"ABA78457\"\n\n    def test_global_tray_to_ams_slot_standard_ams(self):\n        assert _global_tray_id_to_ams_slot(0) == (0, 0)\n        assert _global_tray_id_to_ams_slot(7) == (1, 3)\n\n    def test_global_tray_to_ams_slot_ams_ht(self):\n        assert _global_tray_id_to_ams_slot(128) == (128, 0)\n        assert _global_tray_id_to_ams_slot(135) == (135, 0)\n\n    def test_global_tray_to_ams_slot_external(self):\n        assert _global_tray_id_to_ams_slot(254) == (255, 0)\n        assert _global_tray_id_to_ams_slot(255) == (255, 1)\n\n    def test_get_fallback_spool_tag_standard(self):\n        assert _get_fallback_spool_tag(\"01P00A000000000\", 5) == \"ABA7845700010001\"\n\n    def test_get_fallback_spool_tag_ams_ht(self):\n        assert _get_fallback_spool_tag(\"01P00A000000000\", 128) == \"ABA7845700800000\"\n\n    def test_get_fallback_spool_tag_external(self):\n        assert _get_fallback_spool_tag(\"01P00A000000000\", 255) == \"ABA7845700FF0001\"\n\n\nclass TestBuildAmsTrayLookup:\n    \"\"\"Tests for build_ams_tray_lookup().\"\"\"\n\n    def test_single_ams_unit(self):\n        raw = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"tray\": [\n                        {\"id\": 0, \"tray_uuid\": \"AAA\", \"tag_uid\": \"111\", \"tray_type\": \"PLA\"},\n                        {\"id\": 1, \"tray_uuid\": \"BBB\", \"tag_uid\": \"222\", \"tray_type\": \"ABS\"},\n                    ],\n                }\n            ]\n        }\n        lookup = build_ams_tray_lookup(raw)\n        assert lookup[0] == {\"tray_uuid\": \"AAA\", \"tag_uid\": \"111\", \"tray_type\": \"PLA\"}\n        assert lookup[1] == {\"tray_uuid\": \"BBB\", \"tag_uid\": \"222\", \"tray_type\": \"ABS\"}\n\n    def test_multiple_ams_units(self):\n        raw = {\n            \"ams\": [\n                {\"id\": 0, \"tray\": [{\"id\": 0, \"tray_uuid\": \"A\", \"tag_uid\": \"\", \"tray_type\": \"PLA\"}]},\n                {\"id\": 1, \"tray\": [{\"id\": 0, \"tray_uuid\": \"B\", \"tag_uid\": \"\", \"tray_type\": \"PETG\"}]},\n            ]\n        }\n        lookup = build_ams_tray_lookup(raw)\n        assert 0 in lookup  # AMS 0, tray 0\n        assert 4 in lookup  # AMS 1, tray 0 (1*4+0)\n        assert lookup[4][\"tray_uuid\"] == \"B\"\n\n    def test_external_spool(self):\n        raw = {\n            \"ams\": [],\n            \"vt_tray\": [{\"tray_uuid\": \"EXT\", \"tag_uid\": \"X\", \"tray_type\": \"TPU\"}],\n        }\n        lookup = build_ams_tray_lookup(raw)\n        assert 254 in lookup\n        assert lookup[254][\"tray_type\"] == \"TPU\"\n\n    def test_empty_external_spool_skipped(self):\n        raw = {\"ams\": [], \"vt_tray\": [{\"tray_type\": \"\"}]}\n        lookup = build_ams_tray_lookup(raw)\n        assert 254 not in lookup\n\n    def test_no_ams_data(self):\n        assert build_ams_tray_lookup({}) == {}\n        assert build_ams_tray_lookup({\"ams\": []}) == {}\n\n    def test_missing_fields_default(self):\n        raw = {\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0}]}]}\n        lookup = build_ams_tray_lookup(raw)\n        assert lookup[0] == {\"tray_uuid\": \"\", \"tag_uid\": \"\", \"tray_type\": \"\"}\n\n\nclass TestStorePrintData:\n    \"\"\"Tests for store_print_data().\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_prefers_explicit_ams_mapping_over_queue_mapping(self):\n        db = AsyncMock()\n        delete_result = MagicMock()\n        db.execute = AsyncMock(side_effect=[delete_result])\n        db.add = MagicMock()\n        db.commit = AsyncMock()\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_type\": \"PLA\"}, {\"id\": 1, \"tray_type\": \"PLA\"}]}]}\n        )\n\n        mock_settings = MagicMock()\n        mock_path = MagicMock()\n        mock_path.exists.return_value = True\n        mock_settings.base_dir.__truediv__.return_value = mock_path\n\n        with (\n            patch(\"backend.app.services.spoolman_tracking.app_settings\", mock_settings),\n            patch(\"backend.app.api.routes.settings.get_setting\", AsyncMock(side_effect=[\"true\", \"true\"])),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=[{\"slot_id\": 1, \"used_g\": 3.83, \"type\": \"PLA\", \"color\": \"#FF0000\"}],\n            ),\n            patch(\"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf\", return_value=None),\n            patch(\"backend.app.utils.threemf_tools.extract_filament_properties_from_3mf\", return_value={}),\n        ):\n            await store_print_data(\n                printer_id=1,\n                archive_id=15,\n                file_path=\"archives/test.3mf\",\n                db=db,\n                printer_manager=printer_manager,\n                ams_mapping=[1, -1, -1, -1],\n            )\n\n        db.add.assert_called_once()\n        tracking = db.add.call_args.args[0]\n        assert tracking.slot_to_tray == [1, -1, -1, -1]\n        db.execute.assert_called_once()\n"
  },
  {
    "path": "backend/tests/unit/services/test_stl_thumbnail.py",
    "content": "\"\"\"Unit tests for the STL thumbnail service.\"\"\"\n\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\n\n\ndef _check_trimesh_available():\n    \"\"\"Check if trimesh is available for import.\"\"\"\n    try:\n        import trimesh\n\n        return True\n    except ImportError:\n        return False\n\n\nclass TestStlThumbnailService:\n    \"\"\"Tests for STL thumbnail generation service.\"\"\"\n\n    def test_generate_stl_thumbnail_imports_available(self):\n        \"\"\"Test that required imports are available.\"\"\"\n        try:\n            import matplotlib\n            import trimesh\n\n            assert trimesh is not None\n            assert matplotlib is not None\n        except ImportError as e:\n            pytest.skip(f\"Required dependencies not installed: {e}\")\n\n    def test_generate_stl_thumbnail_returns_none_on_missing_deps(self):\n        \"\"\"Test graceful degradation when dependencies are missing.\"\"\"\n        from backend.app.services.stl_thumbnail import generate_stl_thumbnail\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            stl_path = Path(tmpdir) / \"test.stl\"\n            thumbnails_dir = Path(tmpdir)\n\n            # Create a dummy STL file (will fail to parse)\n            stl_path.write_text(\"invalid stl content\")\n\n            # Should return None on failure, not raise\n            result = generate_stl_thumbnail(stl_path, thumbnails_dir)\n            assert result is None\n\n    @pytest.mark.skipif(\n        not _check_trimesh_available(),\n        reason=\"trimesh not installed\",\n    )\n    def test_generate_stl_thumbnail_with_simple_cube(self):\n        \"\"\"Test thumbnail generation with a simple cube STL.\"\"\"\n        from backend.app.services.stl_thumbnail import generate_stl_thumbnail\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            stl_path = Path(tmpdir) / \"cube.stl\"\n            thumbnails_dir = Path(tmpdir)\n\n            # Create a simple ASCII STL cube\n            stl_content = \"\"\"solid cube\nfacet normal 0 0 -1\n  outer loop\n    vertex 0 0 0\n    vertex 1 0 0\n    vertex 1 1 0\n  endloop\nendfacet\nfacet normal 0 0 -1\n  outer loop\n    vertex 0 0 0\n    vertex 1 1 0\n    vertex 0 1 0\n  endloop\nendfacet\nfacet normal 0 0 1\n  outer loop\n    vertex 0 0 1\n    vertex 1 1 1\n    vertex 1 0 1\n  endloop\nendfacet\nfacet normal 0 0 1\n  outer loop\n    vertex 0 0 1\n    vertex 0 1 1\n    vertex 1 1 1\n  endloop\nendfacet\nfacet normal 0 -1 0\n  outer loop\n    vertex 0 0 0\n    vertex 1 0 1\n    vertex 1 0 0\n  endloop\nendfacet\nfacet normal 0 -1 0\n  outer loop\n    vertex 0 0 0\n    vertex 0 0 1\n    vertex 1 0 1\n  endloop\nendfacet\nfacet normal 1 0 0\n  outer loop\n    vertex 1 0 0\n    vertex 1 0 1\n    vertex 1 1 1\n  endloop\nendfacet\nfacet normal 1 0 0\n  outer loop\n    vertex 1 0 0\n    vertex 1 1 1\n    vertex 1 1 0\n  endloop\nendfacet\nfacet normal 0 1 0\n  outer loop\n    vertex 0 1 0\n    vertex 1 1 0\n    vertex 1 1 1\n  endloop\nendfacet\nfacet normal 0 1 0\n  outer loop\n    vertex 0 1 0\n    vertex 1 1 1\n    vertex 0 1 1\n  endloop\nendfacet\nfacet normal -1 0 0\n  outer loop\n    vertex 0 0 0\n    vertex 0 1 0\n    vertex 0 1 1\n  endloop\nendfacet\nfacet normal -1 0 0\n  outer loop\n    vertex 0 0 0\n    vertex 0 1 1\n    vertex 0 0 1\n  endloop\nendfacet\nendsolid cube\"\"\"\n            stl_path.write_text(stl_content)\n\n            result = generate_stl_thumbnail(stl_path, thumbnails_dir)\n\n            # Should return a path to the generated thumbnail\n            if result:\n                assert Path(result).exists()\n                assert Path(result).suffix == \".png\"\n            # If result is None, dependencies might not be fully functional\n            # which is acceptable\n\n    def test_generate_stl_thumbnail_nonexistent_file(self):\n        \"\"\"Test thumbnail generation with nonexistent file.\"\"\"\n        from backend.app.services.stl_thumbnail import generate_stl_thumbnail\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            stl_path = Path(tmpdir) / \"nonexistent.stl\"\n            thumbnails_dir = Path(tmpdir)\n\n            result = generate_stl_thumbnail(stl_path, thumbnails_dir)\n            assert result is None\n\n    def test_generate_stl_thumbnail_empty_file(self):\n        \"\"\"Test thumbnail generation with empty file.\"\"\"\n        from backend.app.services.stl_thumbnail import generate_stl_thumbnail\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            stl_path = Path(tmpdir) / \"empty.stl\"\n            thumbnails_dir = Path(tmpdir)\n\n            # Create empty file\n            stl_path.write_bytes(b\"\")\n\n            result = generate_stl_thumbnail(stl_path, thumbnails_dir)\n            assert result is None\n\n\nclass TestStlThumbnailConstants:\n    \"\"\"Tests for STL thumbnail service constants.\"\"\"\n\n    def test_bambu_green_color(self):\n        \"\"\"Test that Bambu green color is defined.\"\"\"\n        from backend.app.services.stl_thumbnail import BAMBU_GREEN\n\n        assert BAMBU_GREEN == \"#00AE42\"\n\n    def test_background_color(self):\n        \"\"\"Test that background color is defined.\"\"\"\n        from backend.app.services.stl_thumbnail import BACKGROUND_COLOR\n\n        assert BACKGROUND_COLOR == \"#1a1a1a\"\n\n    def test_max_vertices_threshold(self):\n        \"\"\"Test that max vertices threshold is defined.\"\"\"\n        from backend.app.services.stl_thumbnail import MAX_VERTICES\n\n        assert MAX_VERTICES == 100000\n"
  },
  {
    "path": "backend/tests/unit/services/test_tasmota.py",
    "content": "\"\"\"Unit tests for TasmotaService.\n\nTests smart plug HTTP communication and error handling.\n\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport httpx\nimport pytest\n\nfrom backend.app.services.tasmota import TasmotaService\n\n\nclass TestTasmotaService:\n    \"\"\"Tests for TasmotaService class.\"\"\"\n\n    @pytest.fixture\n    def service(self):\n        \"\"\"Create a TasmotaService instance.\"\"\"\n        return TasmotaService(timeout=5.0)\n\n    @pytest.fixture\n    def mock_plug(self):\n        \"\"\"Create a mock SmartPlug object.\"\"\"\n        plug = MagicMock()\n        plug.ip_address = \"192.168.1.100\"\n        plug.username = None\n        plug.password = None\n        plug.name = \"Test Plug\"\n        return plug\n\n    # ========================================================================\n    # Tests for URL building\n    # ========================================================================\n\n    def test_build_url_without_auth(self, service):\n        \"\"\"Verify URL is built correctly without auth.\"\"\"\n        url = service._build_url(\"192.168.1.100\", \"Power On\")\n        assert url == \"http://192.168.1.100/cm?cmnd=Power%20On\"\n\n    def test_build_url_never_includes_credentials(self, service):\n        \"\"\"Verify URL never contains credentials (they go via httpx auth param).\"\"\"\n        url = service._build_url(\"192.168.1.100\", \"Power On\")\n        assert url == \"http://192.168.1.100/cm?cmnd=Power%20On\"\n        assert \"@\" not in url\n\n    def test_build_url_encodes_special_characters(self, service):\n        \"\"\"Verify special characters in commands are encoded.\"\"\"\n        url = service._build_url(\"192.168.1.100\", \"Backlog Power On; Delay 100\")\n        assert \"Backlog%20Power%20On\" in url\n\n    # ========================================================================\n    # Tests for turn_on\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_turn_on_success(self, service, mock_plug):\n        \"\"\"Verify turn_on returns True on success.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = {\"POWER\": \"ON\"}\n\n            result = await service.turn_on(mock_plug)\n\n            assert result is True\n            mock_send.assert_called_once_with(\"192.168.1.100\", \"Power On\", None, None)\n\n    @pytest.mark.asyncio\n    async def test_turn_on_failure(self, service, mock_plug):\n        \"\"\"Verify turn_on returns False on failure.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = None\n\n            result = await service.turn_on(mock_plug)\n\n            assert result is False\n\n    @pytest.mark.asyncio\n    async def test_turn_on_with_auth(self, service, mock_plug):\n        \"\"\"Verify turn_on passes credentials when provided.\"\"\"\n        mock_plug.username = \"admin\"\n        mock_plug.password = \"secret\"\n\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = {\"POWER\": \"ON\"}\n\n            await service.turn_on(mock_plug)\n\n            mock_send.assert_called_once_with(\"192.168.1.100\", \"Power On\", \"admin\", \"secret\")\n\n    # ========================================================================\n    # Tests for turn_off\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_turn_off_success(self, service, mock_plug):\n        \"\"\"Verify turn_off returns True on success.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = {\"POWER\": \"OFF\"}\n\n            result = await service.turn_off(mock_plug)\n\n            assert result is True\n\n    @pytest.mark.asyncio\n    async def test_turn_off_failure(self, service, mock_plug):\n        \"\"\"Verify turn_off returns False on failure.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = None\n\n            result = await service.turn_off(mock_plug)\n\n            assert result is False\n\n    # ========================================================================\n    # Tests for toggle\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_toggle_success(self, service, mock_plug):\n        \"\"\"Verify toggle returns True on success.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = {\"POWER\": \"ON\"}\n\n            result = await service.toggle(mock_plug)\n\n            assert result is True\n            mock_send.assert_called_once_with(\"192.168.1.100\", \"Power Toggle\", None, None)\n\n    # ========================================================================\n    # Tests for get_status\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_get_status_returns_on(self, service, mock_plug):\n        \"\"\"Verify get_status returns correct state when ON.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            # Tasmota returns {\"POWER\": \"ON\"} for Power command\n            mock_send.return_value = {\"POWER\": \"ON\"}\n\n            result = await service.get_status(mock_plug)\n\n            assert result is not None\n            assert result[\"state\"] == \"ON\"\n            assert result[\"reachable\"] is True\n\n    @pytest.mark.asyncio\n    async def test_get_status_returns_off(self, service, mock_plug):\n        \"\"\"Verify get_status returns correct state when OFF.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            # Tasmota returns {\"POWER\": \"OFF\"} for Power command\n            mock_send.return_value = {\"POWER\": \"OFF\"}\n\n            result = await service.get_status(mock_plug)\n\n            assert result is not None\n            assert result[\"state\"] == \"OFF\"\n\n    @pytest.mark.asyncio\n    async def test_get_status_unreachable(self, service, mock_plug):\n        \"\"\"Verify get_status handles unreachable device.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = None\n\n            result = await service.get_status(mock_plug)\n\n            assert result is not None\n            assert result[\"reachable\"] is False\n\n    # ========================================================================\n    # Tests for get_energy\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_get_energy_returns_data(self, service, mock_plug):\n        \"\"\"Verify get_energy parses energy data correctly.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = {\n                \"StatusSNS\": {\n                    \"ENERGY\": {\n                        \"Power\": 150.5,\n                        \"Voltage\": 120.0,\n                        \"Current\": 1.25,\n                        \"Today\": 2.5,\n                        \"Total\": 100.0,\n                        \"Factor\": 0.95,\n                    }\n                }\n            }\n\n            result = await service.get_energy(mock_plug)\n\n            assert result is not None\n            assert result[\"power\"] == 150.5\n            assert result[\"voltage\"] == 120.0\n            assert result[\"current\"] == 1.25\n            assert result[\"today\"] == 2.5\n            assert result[\"total\"] == 100.0\n            assert result[\"factor\"] == 0.95\n\n    @pytest.mark.asyncio\n    async def test_get_energy_handles_missing_data(self, service, mock_plug):\n        \"\"\"Verify get_energy handles devices without energy monitoring.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = {\"StatusSNS\": {}}\n\n            result = await service.get_energy(mock_plug)\n\n            assert result is None\n\n    @pytest.mark.asyncio\n    async def test_get_energy_handles_unreachable(self, service, mock_plug):\n        \"\"\"Verify get_energy handles unreachable device.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = None\n\n            result = await service.get_energy(mock_plug)\n\n            assert result is None\n\n    @pytest.mark.asyncio\n    async def test_get_energy_handles_partial_data(self, service, mock_plug):\n        \"\"\"Verify get_energy handles partial energy data.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = {\n                \"StatusSNS\": {\n                    \"ENERGY\": {\n                        \"Power\": 150.5,\n                        # Missing other fields\n                    }\n                }\n            }\n\n            result = await service.get_energy(mock_plug)\n\n            assert result is not None\n            assert result[\"power\"] == 150.5\n            # Missing fields should be None or 0\n            assert result.get(\"voltage\") is None or result.get(\"voltage\") == 0\n\n    # ========================================================================\n    # Tests for test_connection\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_test_connection_success(self, service):\n        \"\"\"Verify test_connection returns success on reachable device.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            # First call (Power) returns state, second call (Status 0) returns device info\n            mock_send.side_effect = [\n                {\"POWER\": \"ON\"},  # Power command response\n                {\"Status\": {\"DeviceName\": \"Test Plug\"}},  # Status 0 response\n            ]\n\n            result = await service.test_connection(\"192.168.1.100\")\n\n            assert result[\"success\"] is True\n            assert result[\"state\"] == \"ON\"\n            assert result[\"device_name\"] == \"Test Plug\"\n\n    @pytest.mark.asyncio\n    async def test_test_connection_failure(self, service):\n        \"\"\"Verify test_connection returns failure on unreachable device.\"\"\"\n        with patch.object(service, \"_send_command\", new_callable=AsyncMock) as mock_send:\n            mock_send.return_value = None\n\n            result = await service.test_connection(\"192.168.1.100\")\n\n            assert result[\"success\"] is False\n\n    # ========================================================================\n    # Tests for _send_command\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_send_command_handles_timeout(self, service):\n        \"\"\"Verify timeout is handled gracefully.\"\"\"\n        with patch(\"httpx.AsyncClient\") as mock_client_class:\n            mock_client = AsyncMock()\n            mock_client.get.side_effect = httpx.TimeoutException(\"Timeout\")\n            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client_class.return_value.__aexit__ = AsyncMock()\n\n            result = await service._send_command(\"192.168.1.100\", \"Power\")\n\n            assert result is None\n\n    @pytest.mark.asyncio\n    async def test_send_command_handles_connection_error(self, service):\n        \"\"\"Verify connection error is handled gracefully.\"\"\"\n        with patch(\"httpx.AsyncClient\") as mock_client_class:\n            mock_client = AsyncMock()\n            mock_client.get.side_effect = httpx.ConnectError(\"Connection refused\")\n            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client_class.return_value.__aexit__ = AsyncMock()\n\n            result = await service._send_command(\"192.168.1.100\", \"Power\")\n\n            assert result is None\n\n    @pytest.mark.asyncio\n    async def test_send_command_handles_invalid_json(self, service):\n        \"\"\"Verify invalid JSON response is handled gracefully.\"\"\"\n        with patch(\"httpx.AsyncClient\") as mock_client_class:\n            mock_client = AsyncMock()\n            mock_response = MagicMock()\n            mock_response.json.side_effect = ValueError(\"Invalid JSON\")\n            mock_client.get.return_value = mock_response\n            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client_class.return_value.__aexit__ = AsyncMock()\n\n            result = await service._send_command(\"192.168.1.100\", \"Power\")\n\n            assert result is None\n\n    @pytest.mark.asyncio\n    async def test_send_command_success(self, service):\n        \"\"\"Verify successful command returns parsed JSON.\"\"\"\n        with patch(\"httpx.AsyncClient\") as mock_client_class:\n            mock_client = AsyncMock()\n            mock_response = MagicMock()\n            mock_response.json.return_value = {\"POWER\": \"ON\"}\n            mock_client.get.return_value = mock_response\n            mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client_class.return_value.__aexit__ = AsyncMock()\n\n            result = await service._send_command(\"192.168.1.100\", \"Power\")\n\n            assert result == {\"POWER\": \"ON\"}\n\n\nclass TestTasmotaServiceSingleton:\n    \"\"\"Tests for the global tasmota_service singleton.\"\"\"\n\n    def test_singleton_exists(self):\n        \"\"\"Verify global tasmota_service instance exists.\"\"\"\n        from backend.app.services.tasmota import tasmota_service\n\n        assert tasmota_service is not None\n        assert isinstance(tasmota_service, TasmotaService)\n"
  },
  {
    "path": "backend/tests/unit/services/test_usage_tracker.py",
    "content": "\"\"\"Unit tests for the filament usage tracker.\n\nTests 3MF-primary tracking (Path 1) and AMS remain% delta fallback\n(Path 2) for spools not covered by 3MF data.\n\"\"\"\n\nfrom datetime import datetime, timezone\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.usage_tracker import (\n    PrintSession,\n    _active_sessions,\n    _track_from_3mf,\n    on_print_complete,\n    on_print_start,\n)\n\n\ndef _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):\n    \"\"\"Create a mock Spool object.\"\"\"\n    spool = MagicMock()\n    spool.id = id\n    spool.label_weight = label_weight\n    spool.weight_used = weight_used\n    spool.tag_uid = tag_uid\n    spool.tray_uuid = tray_uuid\n    spool.last_used = None\n    spool.cost_per_kg = None\n    spool.material = \"PLA\"\n    return spool\n\n\ndef _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0, created_at=None):\n    \"\"\"Create a mock SpoolAssignment object.\"\"\"\n    assignment = MagicMock()\n    assignment.spool_id = spool_id\n    assignment.printer_id = printer_id\n    assignment.ams_id = ams_id\n    assignment.tray_id = tray_id\n    assignment.created_at = created_at or datetime.now(timezone.utc)\n    return assignment\n\n\ndef _make_printer_state(ams_data, progress=0, layer_num=0, tray_now=255):\n    \"\"\"Create a mock printer state with AMS data.\"\"\"\n    state = MagicMock()\n    state.raw_data = {\"ams\": ams_data}\n    state.progress = progress\n    state.layer_num = layer_num\n    state.tray_now = tray_now\n    return state\n\n\ndef _make_printer_manager(state=None):\n    \"\"\"Create a mock printer manager.\"\"\"\n    pm = MagicMock()\n    pm.get_status.return_value = state\n    return pm\n\n\nclass TestOnPrintStart:\n    \"\"\"Tests for on_print_start — capturing AMS remain%.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def _clear_sessions(self):\n        _active_sessions.clear()\n        yield\n        _active_sessions.clear()\n\n    @pytest.mark.asyncio\n    async def test_creates_session_with_valid_remain(self):\n        \"\"\"Session created with remain% data for trays reporting 0-100.\"\"\"\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]\n        pm = _make_printer_manager(_make_printer_state(ams_data))\n\n        await on_print_start(1, {\"subtask_name\": \"test_print\"}, pm)\n\n        assert 1 in _active_sessions\n        session = _active_sessions[1]\n        assert session.print_name == \"test_print\"\n        assert session.tray_remain_start == {(0, 0): 80}\n\n    @pytest.mark.asyncio\n    async def test_creates_session_even_without_valid_remain(self):\n        \"\"\"Session still created when remain=-1 (for 3MF fallback path).\"\"\"\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": -1}]}]\n        pm = _make_printer_manager(_make_printer_state(ams_data))\n\n        await on_print_start(1, {\"subtask_name\": \"test_print\"}, pm)\n\n        assert 1 in _active_sessions\n        session = _active_sessions[1]\n        assert session.tray_remain_start == {}  # Empty, no valid remain\n\n    @pytest.mark.asyncio\n    async def test_skips_without_ams_data(self):\n        \"\"\"No session created when no AMS data available.\"\"\"\n        state = MagicMock()\n        state.raw_data = {\"ams\": []}\n        pm = _make_printer_manager(state)\n\n        await on_print_start(1, {\"subtask_name\": \"test\"}, pm)\n\n        assert 1 not in _active_sessions\n\n\nclass TestOnPrintCompleteAMSDelta:\n    \"\"\"Tests for Path 1: AMS remain% delta tracking.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def _clear_sessions(self):\n        _active_sessions.clear()\n        yield\n        _active_sessions.clear()\n\n    @pytest.fixture(autouse=True)\n    def _mock_get_setting(self):\n        with patch(\n            \"backend.app.api.routes.settings.get_setting\",\n            new_callable=AsyncMock,\n            return_value=None,\n        ):\n            yield\n\n    @pytest.mark.asyncio\n    async def test_computes_delta_and_updates_spool(self):\n        \"\"\"Spool weight_used updated by remain% delta * label_weight.\"\"\"\n        # Set up session with start remain = 80%\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n        )\n\n        # Current remain = 70% → 10% consumed → 100g on 1000g spool\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]\n        pm = _make_printer_manager(_make_printer_state(ams_data))\n\n        spool = _make_spool(label_weight=1000, weight_used=50)\n        assignment = _make_assignment()\n\n        db = AsyncMock()\n        # First 2 executes → _find_3mf_by_filename (library + archive search, uses scalars().all()),\n        # then assignment, then spool for the AMS fallback path\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(),  # _find_3mf_by_filename: library search\n                MagicMock(),  # _find_3mf_by_filename: archive search\n                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        results = await on_print_complete(1, {\"status\": \"completed\"}, pm, db)\n\n        assert len(results) == 1\n        assert results[0][\"weight_used\"] == 100.0\n        assert results[0][\"percent_used\"] == 10\n        # weight_used should be old (50) + delta (100)\n        assert spool.weight_used == 150.0\n        db.commit.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_skips_negative_delta(self):\n        \"\"\"No tracking when remain increased (spool refilled).\"\"\"\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 50},\n        )\n\n        # Remain went UP: 50 → 80 (refilled)\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]\n        pm = _make_printer_manager(_make_printer_state(ams_data))\n        db = AsyncMock()\n\n        results = await on_print_complete(1, {\"status\": \"completed\"}, pm, db)\n\n        assert results == []\n        db.commit.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_no_session_falls_through_to_3mf(self):\n        \"\"\"When no session exists, AMS delta path skipped (3MF may still run).\"\"\"\n        pm = _make_printer_manager()\n        db = AsyncMock()\n\n        results = await on_print_complete(1, {\"status\": \"completed\"}, pm, db)\n\n        assert results == []\n\n\nclass TestTrackFrom3MF:\n    \"\"\"Tests for Path 2: 3MF per-filament fallback tracking.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_updates_non_bl_spool_from_3mf(self):\n        \"\"\"Non-BL spool gets weight_used from 3MF used_g for completed print.\"\"\"\n        spool = _make_spool(id=5, label_weight=1000, weight_used=100)\n        assignment = _make_assignment(spool_id=5)\n        archive = MagicMock()\n        archive.file_path = \"archives/test.3mf\"\n\n        db = AsyncMock()\n        # archive, queue_item(None), assignment, spool\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        pm = _make_printer_manager(_make_printer_state([], tray_now=0))\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 25.5, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"test_print\",\n                handled_trays=set(),\n                printer_manager=pm,\n                db=db,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 5\n        assert results[0][\"weight_used\"] == 25.5\n        # weight_used = old (100) + 3MF (25.5)\n        assert spool.weight_used == 125.5\n\n    @pytest.mark.asyncio\n    async def test_scales_by_progress_for_failed_print(self):\n        \"\"\"Failed print scales 3MF estimate by progress percentage.\"\"\"\n        spool = _make_spool(id=1, label_weight=1000, weight_used=0)\n        assignment = _make_assignment()\n        archive = MagicMock()\n        archive.file_path = \"archives/test.3mf\"\n\n        db = AsyncMock()\n        # archive, queue_item(None), assignment, spool\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        # Print failed at 50% progress → 50g consumed from 100g estimate\n        pm = _make_printer_manager(_make_printer_state([], progress=50, tray_now=0))\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 100.0, \"type\": \"PLA\", \"color\": \"\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"failed\",\n                print_name=\"test\",\n                handled_trays=set(),\n                printer_manager=pm,\n                db=db,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"weight_used\"] == 50.0\n        assert spool.weight_used == 50.0\n\n    @pytest.mark.asyncio\n    async def test_tracks_bl_spools_via_3mf(self):\n        \"\"\"BL spools (with tag_uid) ARE now tracked via 3MF (unified tracking).\"\"\"\n        spool = _make_spool(tag_uid=\"ABCD1234\", tray_uuid=\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\")\n        assignment = _make_assignment()\n        archive = MagicMock()\n        archive.file_path = \"archives/test.3mf\"\n\n        db = AsyncMock()\n        # archive, queue_item(None), assignment, spool\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        pm = _make_printer_manager(_make_printer_state([], tray_now=0))\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 50.0, \"type\": \"PLA\", \"color\": \"\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"test\",\n                handled_trays=set(),\n                printer_manager=pm,\n                db=db,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 1\n        assert results[0][\"weight_used\"] == 50.0\n\n    @pytest.mark.asyncio\n    async def test_skips_already_handled_trays(self):\n        \"\"\"Trays handled by AMS remain% delta are not double-tracked via 3MF.\"\"\"\n        archive = MagicMock()\n        archive.file_path = \"archives/test.3mf\"\n\n        db = AsyncMock()\n        # archive, queue_item(None)\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n            ]\n        )\n\n        pm = _make_printer_manager(_make_printer_state([], tray_now=0))\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 50.0, \"type\": \"PLA\", \"color\": \"\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"test\",\n                handled_trays={(0, 0)},  # slot_id=1 → ams_id=0, tray_id=0\n                printer_manager=pm,\n                db=db,\n            )\n\n        assert results == []\n\n    @pytest.mark.asyncio\n    async def test_slot_to_tray_mapping(self):\n        \"\"\"3MF slot_id maps correctly to (ams_id, tray_id) via tray_now.\"\"\"\n        # tray_now=4 → ams_id=1, tray_id=0 (single filament uses tray_now)\n        spool = _make_spool(id=9)\n        assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)\n        archive = MagicMock()\n        archive.file_path = \"archives/test.3mf\"\n\n        db = AsyncMock()\n        # archive, queue_item(None), assignment, spool\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        pm = _make_printer_manager(_make_printer_state([], tray_now=4))\n        filament_usage = [{\"slot_id\": 5, \"used_g\": 30.0, \"type\": \"PETG\", \"color\": \"\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"test\",\n                handled_trays=set(),\n                printer_manager=pm,\n                db=db,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"ams_id\"] == 1\n        assert results[0][\"tray_id\"] == 0\n\n\nclass TestSpoolAssignmentSnapshot:\n    \"\"\"Tests for spool assignment snapshotting at print start (#459).\n\n    When a spool runs empty mid-print, on_ams_change deletes the SpoolAssignment.\n    The snapshot captured at print start ensures usage is still attributed correctly.\n    \"\"\"\n\n    @pytest.fixture(autouse=True)\n    def _clear_sessions(self):\n        _active_sessions.clear()\n        yield\n        _active_sessions.clear()\n\n    @pytest.fixture(autouse=True)\n    def _mock_get_setting(self):\n        with patch(\n            \"backend.app.api.routes.settings.get_setting\",\n            new_callable=AsyncMock,\n            return_value=None,\n        ):\n            yield\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_snapshots_assignments_with_db(self):\n        \"\"\"on_print_start captures spool assignments when db is provided.\"\"\"\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}, {\"id\": 1, \"remain\": 60}]}]\n        pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))\n\n        assignment_0 = _make_assignment(spool_id=10, printer_id=1, ams_id=0, tray_id=0)\n        assignment_1 = _make_assignment(spool_id=20, printer_id=1, ams_id=0, tray_id=1)\n\n        db = AsyncMock()\n        scalars_mock = MagicMock()\n        scalars_mock.all.return_value = [assignment_0, assignment_1]\n        result_mock = MagicMock()\n        result_mock.scalars.return_value = scalars_mock\n        db.execute = AsyncMock(return_value=result_mock)\n\n        await on_print_start(1, {\"subtask_name\": \"Benchy\"}, pm, db=db)\n\n        session = _active_sessions[1]\n        assert session.spool_assignments == {(0, 0): 10, (0, 1): 20}\n\n    @pytest.mark.asyncio\n    async def test_on_print_start_empty_snapshot_without_db(self):\n        \"\"\"on_print_start creates empty snapshot when no db provided.\"\"\"\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]\n        pm = _make_printer_manager(_make_printer_state(ams_data, tray_now=0))\n\n        await on_print_start(1, {\"subtask_name\": \"Benchy\"}, pm)\n\n        session = _active_sessions[1]\n        assert session.spool_assignments == {}\n\n    @pytest.mark.asyncio\n    async def test_3mf_uses_snapshot_instead_of_live_query(self):\n        \"\"\"_track_from_3mf uses snapshot spool_id without querying SpoolAssignment.\"\"\"\n        spool = _make_spool(id=42, label_weight=1000)\n        archive = MagicMock()\n        archive.file_path = \"archives/test.3mf\"\n\n        # db: archive, queue_item(None), spool — NO assignment query needed\n        db = AsyncMock()\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        pm = _make_printer_manager(_make_printer_state([], tray_now=0))\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 15.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"Test\",\n                handled_trays=set(),\n                printer_manager=pm,\n                db=db,\n                spool_assignments={(0, 0): 42},\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 42\n        assert results[0][\"weight_used\"] == 15.0\n\n    @pytest.mark.asyncio\n    async def test_3mf_falls_back_to_live_query_without_snapshot(self):\n        \"\"\"_track_from_3mf queries SpoolAssignment when no snapshot exists.\"\"\"\n        spool = _make_spool(id=5, label_weight=1000)\n        assignment = _make_assignment(spool_id=5)\n        archive = MagicMock()\n        archive.file_path = \"archives/test.3mf\"\n\n        # db: archive, queue_item(None), assignment, spool\n        db = AsyncMock()\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        pm = _make_printer_manager(_make_printer_state([], tray_now=0))\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"Test\",\n                handled_trays=set(),\n                printer_manager=pm,\n                db=db,\n                spool_assignments=None,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 5\n\n    @pytest.mark.asyncio\n    async def test_ams_delta_uses_snapshot_over_live_query(self):\n        \"\"\"AMS remain% fallback uses snapshot spool_id instead of live query.\"\"\"\n        spool = _make_spool(id=77, label_weight=1000)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Benchy\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            spool_assignments={(0, 0): 77},\n        )\n\n        # Current remain = 70% → 10% delta → 100g\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]\n        pm = _make_printer_manager(_make_printer_state(ams_data))\n\n        # First 2 executes → _find_3mf_by_filename (library + archive search),\n        # then live assignment check (returns None), then spool lookup by snapshot spool_id\n        db = AsyncMock()\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(),  # _find_3mf_by_filename: library search\n                MagicMock(),  # _find_3mf_by_filename: archive search\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),  # live assignment\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        results = await on_print_complete(\n            printer_id=1,\n            data={\"status\": \"completed\"},\n            printer_manager=pm,\n            db=db,\n            archive_id=None,\n        )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 77\n        assert results[0][\"weight_used\"] == 100.0\n\n    @pytest.mark.asyncio\n    async def test_ams_delta_falls_back_to_live_query_without_snapshot(self):\n        \"\"\"AMS remain% fallback queries SpoolAssignment when snapshot is empty.\"\"\"\n        spool = _make_spool(id=33, label_weight=1000)\n        assignment = _make_assignment(spool_id=33)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Benchy\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            spool_assignments={},  # Empty snapshot (pre-upgrade session)\n        )\n\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]\n        pm = _make_printer_manager(_make_printer_state(ams_data))\n\n        # First 2 executes → _find_3mf_by_filename (library + archive search),\n        # then assignment and spool for the AMS fallback path\n        db = AsyncMock()\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(),  # _find_3mf_by_filename: library search\n                MagicMock(),  # _find_3mf_by_filename: archive search\n                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n            ]\n        )\n\n        results = await on_print_complete(\n            printer_id=1,\n            data={\"status\": \"completed\"},\n            printer_manager=pm,\n            db=db,\n            archive_id=None,\n        )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 33\n\n    @pytest.mark.asyncio\n    async def test_snapshot_survives_mid_print_unlink(self):\n        \"\"\"Core bug scenario: snapshot provides spool_id after mid-print unlink.\n\n        Simulates the #459 scenario: spool runs empty mid-print, on_ams_change\n        deletes the SpoolAssignment, but the snapshot from print start still\n        has the spool_id so usage is correctly attributed at print completion.\n        \"\"\"\n        spool = _make_spool(id=8, label_weight=1000, weight_used=50)\n        archive = MagicMock()\n        archive.file_path = \"archives/big_print.3mf\"\n\n        # Session was created at print start WITH snapshot\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Big Print\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 90},\n            spool_assignments={(0, 0): 8},  # Snapshot from print start\n        )\n\n        pm = _make_printer_manager(\n            _make_printer_state(\n                [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 75}]}],\n                tray_now=0,\n            )\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 14.2, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        # db: archive, queue_item(None), live assignment(None), spool,\n        # then cost aggregation queries\n        # NOTE: No assignment in db — it was deleted by on_ams_change mid-print!\n        db = AsyncMock()\n        db.execute = AsyncMock(\n            side_effect=[\n                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),\n                # Cost aggregation: sum query (uses .scalar()), archive lookup\n                MagicMock(scalar=MagicMock(return_value=0)),\n                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),\n            ]\n        )\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=pm,\n                db=db,\n                archive_id=100,\n            )\n\n        # Usage should be tracked despite assignment being deleted mid-print\n        assert len(results) >= 1\n        assert results[0][\"spool_id\"] == 8\n        assert results[0][\"weight_used\"] == 14.2\n        # Spool weight should be updated: 50 + 14.2 = 64.2\n        assert spool.weight_used == 64.2\n"
  },
  {
    "path": "backend/tests/unit/services/test_virtual_printer.py",
    "content": "\"\"\"Unit tests for Virtual Printer services.\n\nTests the virtual printer manager, FTP server, and SSDP server components.\n\"\"\"\n\nimport asyncio\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\n\nclass TestVirtualPrinterInstance:\n    \"\"\"Tests for VirtualPrinterInstance class.\"\"\"\n\n    @pytest.fixture\n    def instance(self, tmp_path):\n        \"\"\"Create a VirtualPrinterInstance with test defaults.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        return VirtualPrinterInstance(\n            vp_id=1,\n            name=\"TestPrinter\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            base_dir=tmp_path,\n        )\n\n    # ========================================================================\n    # Tests for instance properties\n    # ========================================================================\n\n    def test_instance_stores_parameters(self, instance):\n        \"\"\"Verify constructor stores parameters correctly.\"\"\"\n        assert instance.id == 1\n        assert instance.name == \"TestPrinter\"\n        assert instance.mode == \"immediate\"\n        assert instance.model == \"C11\"\n        assert instance.access_code == \"12345678\"\n        assert instance.serial_suffix == \"391800001\"\n\n    def test_instance_serial_property(self, instance):\n        \"\"\"Verify serial is generated from model prefix + suffix.\"\"\"\n        # C11 = P1P, prefix = 01S00A\n        assert instance.serial == \"01S00A391800001\"\n\n    def test_instance_serial_x1c(self, tmp_path):\n        \"\"\"Verify X1C serial uses correct prefix.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=2,\n            name=\"X1C\",\n            mode=\"immediate\",\n            model=\"BL-P001\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800002\",\n            base_dir=tmp_path,\n        )\n        assert inst.serial == \"00M00A391800002\"\n\n    def test_instance_is_proxy_false(self, instance):\n        \"\"\"Verify is_proxy is False for non-proxy mode.\"\"\"\n        assert instance.is_proxy is False\n\n    def test_instance_is_proxy_true(self, tmp_path):\n        \"\"\"Verify is_proxy is True for proxy mode.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=3,\n            name=\"Proxy\",\n            mode=\"proxy\",\n            model=\"C11\",\n            access_code=\"\",\n            serial_suffix=\"391800003\",\n            target_printer_ip=\"192.168.1.100\",\n            base_dir=tmp_path,\n        )\n        assert inst.is_proxy is True\n\n    def test_instance_is_running_with_active_tasks(self, instance):\n        \"\"\"Verify is_running is True when tasks are active.\"\"\"\n        mock_task = MagicMock()\n        mock_task.done.return_value = False\n        instance._tasks = [mock_task]\n        assert instance.is_running is True\n\n    def test_instance_is_running_with_no_tasks(self, instance):\n        \"\"\"Verify is_running is False when no tasks.\"\"\"\n        assert instance.is_running is False\n\n    def test_instance_creates_directories(self, instance, tmp_path):\n        \"\"\"Verify instance creates upload and cert directories.\"\"\"\n        assert (tmp_path / \"uploads\" / \"1\").exists()\n        assert (tmp_path / \"uploads\" / \"1\" / \"cache\").exists()\n        assert (tmp_path / \"certs\" / \"1\").exists()\n\n    # ========================================================================\n    # Tests for status\n    # ========================================================================\n\n    def test_get_status_returns_correct_format(self, instance):\n        \"\"\"Verify get_status returns expected fields.\"\"\"\n        instance._pending_files = {\"file1.3mf\": Path(\"/tmp/file1.3mf\")}  # nosec B108\n        mock_task = MagicMock(done=MagicMock(return_value=False))\n        instance._tasks = [mock_task]\n\n        status = instance.get_status()\n        assert status[\"running\"] is True\n        assert status[\"pending_files\"] == 1\n\n    def test_get_status_not_running(self, instance):\n        \"\"\"Verify get_status when no tasks.\"\"\"\n        status = instance.get_status()\n        assert status[\"running\"] is False\n        assert status[\"pending_files\"] == 0\n\n    # ========================================================================\n    # Tests for file handling\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_on_file_received_adds_to_pending(self, instance):\n        \"\"\"Verify received file is added to pending list in review mode.\"\"\"\n        instance.mode = \"review\"\n\n        file_path = Path(\"/tmp/test.3mf\")  # nosec B108\n\n        with patch.object(instance, \"_queue_file\", new_callable=AsyncMock) as mock_queue:\n            await instance.on_file_received(file_path, \"192.168.1.100\")\n\n            assert \"test.3mf\" in instance._pending_files\n            mock_queue.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_on_file_received_archives_immediately(self, instance):\n        \"\"\"Verify file is archived in immediate mode.\"\"\"\n        file_path = Path(\"/tmp/test.3mf\")  # nosec B108\n\n        with patch.object(instance, \"_archive_file\", new_callable=AsyncMock) as mock_archive:\n            await instance.on_file_received(file_path, \"192.168.1.100\")\n\n            mock_archive.assert_called_once_with(file_path, \"192.168.1.100\")\n\n    @pytest.mark.asyncio\n    async def test_archive_file_skips_non_3mf(self, instance):\n        \"\"\"Verify non-3MF files are skipped and cleaned up.\"\"\"\n        instance._session_factory = MagicMock()\n        instance._pending_files[\"verify_job\"] = Path(\"/tmp/verify_job\")  # nosec B108\n\n        with patch(\"pathlib.Path.unlink\"):\n            await instance._archive_file(Path(\"/tmp/verify_job\"), \"192.168.1.100\")  # nosec B108\n\n            assert \"verify_job\" not in instance._pending_files\n\n    # ========================================================================\n    # Tests for auto_dispatch\n    # ========================================================================\n\n    def test_auto_dispatch_defaults_to_true(self, tmp_path):\n        \"\"\"Verify auto_dispatch defaults to True when not specified.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=10,\n            name=\"DefaultDispatch\",\n            mode=\"print_queue\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800010\",\n            base_dir=tmp_path,\n        )\n        assert inst.auto_dispatch is True\n\n    @pytest.mark.asyncio\n    async def test_add_to_print_queue_with_auto_dispatch_on(self, tmp_path):\n        \"\"\"Verify queue items have manual_start=False when auto_dispatch=True.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        mock_db = AsyncMock()\n        added_items = []\n\n        def capture_add(item):\n            added_items.append(item)\n\n        mock_db.add = MagicMock(side_effect=capture_add)\n        mock_db.commit = AsyncMock()\n\n        mock_session_factory = MagicMock()\n        mock_session_ctx = AsyncMock()\n        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)\n        mock_session_factory.return_value = mock_session_ctx\n\n        inst = VirtualPrinterInstance(\n            vp_id=11,\n            name=\"AutoDispatchOn\",\n            mode=\"print_queue\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800011\",\n            auto_dispatch=True,\n            base_dir=tmp_path,\n            session_factory=mock_session_factory,\n        )\n\n        # Create a temp 3mf file\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(b\"fake3mf\")\n\n        mock_archive = MagicMock()\n        mock_archive.id = 1\n        mock_archive.print_name = \"test\"\n\n        with patch(\n            \"backend.app.services.archive.ArchiveService.archive_print\",\n            new_callable=AsyncMock,\n            return_value=mock_archive,\n        ):\n            await inst._add_to_print_queue(file_path, \"192.168.1.100\")\n\n        assert len(added_items) == 1\n        queue_item = added_items[0]\n        assert queue_item.manual_start is False\n\n    @pytest.mark.asyncio\n    async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path):\n        \"\"\"Verify queue items have manual_start=True when auto_dispatch=False.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        mock_db = AsyncMock()\n        added_items = []\n\n        def capture_add(item):\n            added_items.append(item)\n\n        mock_db.add = MagicMock(side_effect=capture_add)\n        mock_db.commit = AsyncMock()\n\n        mock_session_factory = MagicMock()\n        mock_session_ctx = AsyncMock()\n        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)\n        mock_session_factory.return_value = mock_session_ctx\n\n        inst = VirtualPrinterInstance(\n            vp_id=12,\n            name=\"AutoDispatchOff\",\n            mode=\"print_queue\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800012\",\n            auto_dispatch=False,\n            base_dir=tmp_path,\n            session_factory=mock_session_factory,\n        )\n\n        # Create a temp 3mf file\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(b\"fake3mf\")\n\n        mock_archive = MagicMock()\n        mock_archive.id = 1\n        mock_archive.print_name = \"test\"\n\n        with patch(\n            \"backend.app.services.archive.ArchiveService.archive_print\",\n            new_callable=AsyncMock,\n            return_value=mock_archive,\n        ):\n            await inst._add_to_print_queue(file_path, \"192.168.1.100\")\n\n        assert len(added_items) == 1\n        queue_item = added_items[0]\n        assert queue_item.manual_start is True\n\n\nclass TestVirtualPrinterManager:\n    \"\"\"Tests for VirtualPrinterManager orchestrator.\"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        \"\"\"Create a VirtualPrinterManager instance.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterManager\n\n        return VirtualPrinterManager()\n\n    def test_manager_starts_empty(self, manager):\n        \"\"\"Verify manager starts with no instances.\"\"\"\n        assert len(manager._instances) == 0\n        assert manager.is_enabled is False\n\n    def test_manager_get_status_empty(self, manager):\n        \"\"\"Verify get_status returns disabled state when no instances.\"\"\"\n        status = manager.get_status()\n        assert status[\"enabled\"] is False\n        assert status[\"running\"] is False\n        assert status[\"mode\"] == \"immediate\"\n\n    def test_manager_is_enabled_with_instance(self, manager, tmp_path):\n        \"\"\"Verify is_enabled is True when instances exist.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=1,\n            name=\"Test\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            base_dir=tmp_path,\n        )\n        manager._instances[1] = inst\n        assert manager.is_enabled is True\n\n    @pytest.mark.asyncio\n    async def test_manager_remove_instance_server(self, manager, tmp_path):\n        \"\"\"Verify remove_instance stops and removes a server-mode instance.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=1,\n            name=\"Test\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            base_dir=tmp_path,\n        )\n        inst.stop_server = AsyncMock()\n        manager._instances[1] = inst\n\n        await manager.remove_instance(1)\n\n        assert 1 not in manager._instances\n        inst.stop_server.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_manager_remove_instance_proxy(self, manager, tmp_path):\n        \"\"\"Verify remove_instance stops proxy-mode instance.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=2,\n            name=\"Proxy\",\n            mode=\"proxy\",\n            model=\"C11\",\n            access_code=\"\",\n            serial_suffix=\"391800002\",\n            target_printer_ip=\"192.168.1.100\",\n            base_dir=tmp_path,\n        )\n        inst.stop_proxy = AsyncMock()\n        manager._instances[2] = inst\n\n        await manager.remove_instance(2)\n\n        assert 2 not in manager._instances\n        inst.stop_proxy.assert_called_once()\n\n    def test_manager_get_status_with_instance(self, manager, tmp_path):\n        \"\"\"Verify legacy get_status returns first instance data.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=1,\n            name=\"Bambuddy\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            base_dir=tmp_path,\n        )\n        mock_task = MagicMock(done=MagicMock(return_value=False))\n        inst._tasks = [mock_task]\n        inst._pending_files = {\"file1.3mf\": Path(\"/tmp/file1.3mf\")}  # nosec B108\n        manager._instances[1] = inst\n\n        status = manager.get_status()\n        assert status[\"enabled\"] is True\n        assert status[\"running\"] is True\n        assert status[\"mode\"] == \"immediate\"\n        assert status[\"name\"] == \"Bambuddy\"\n        assert status[\"serial\"] == \"01S00A391800001\"\n        assert status[\"model\"] == \"C11\"\n        assert status[\"model_name\"] == \"P1P\"\n        assert status[\"pending_files\"] == 1\n\n    def test_manager_get_all_status(self, manager, tmp_path):\n        \"\"\"Verify get_all_status returns status for all instances.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        for i in range(1, 3):\n            inst = VirtualPrinterInstance(\n                vp_id=i,\n                name=f\"VP{i}\",\n                mode=\"immediate\",\n                model=\"C11\",\n                access_code=\"12345678\",\n                serial_suffix=f\"39180000{i}\",\n                base_dir=tmp_path,\n            )\n            manager._instances[i] = inst\n\n        statuses = manager.get_all_status()\n        assert len(statuses) == 2\n        assert statuses[0][\"name\"] == \"VP1\"\n        assert statuses[1][\"name\"] == \"VP2\"\n\n    @pytest.mark.asyncio\n    async def test_manager_stop_all(self, manager, tmp_path):\n        \"\"\"Verify stop_all removes all instances.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        for i in range(1, 3):\n            inst = VirtualPrinterInstance(\n                vp_id=i,\n                name=f\"VP{i}\",\n                mode=\"immediate\",\n                model=\"C11\",\n                access_code=\"12345678\",\n                serial_suffix=f\"39180000{i}\",\n                base_dir=tmp_path,\n            )\n            inst.stop_server = AsyncMock()\n            manager._instances[i] = inst\n\n        await manager.stop_all()\n        assert len(manager._instances) == 0\n\n    # ========================================================================\n    # Tests for sync_from_db config change detection\n    # ========================================================================\n\n    def _make_db_vp(self, **overrides):\n        \"\"\"Create a mock VirtualPrinter DB object.\"\"\"\n        defaults = {\n            \"id\": 1,\n            \"name\": \"TestVP\",\n            \"enabled\": True,\n            \"mode\": \"immediate\",\n            \"model\": \"C11\",\n            \"access_code\": \"12345678\",\n            \"serial_suffix\": \"391800001\",\n            \"bind_ip\": \"\",\n            \"remote_interface_ip\": \"\",\n            \"target_printer_id\": None,\n            \"auto_dispatch\": True,\n            \"position\": 0,\n        }\n        defaults.update(overrides)\n        vp = MagicMock()\n        for k, v in defaults.items():\n            setattr(vp, k, v)\n        return vp\n\n    def _setup_sync_mocks(self, manager, enabled_vps, tmp_path):\n        \"\"\"Wire up session_factory mock for sync_from_db.\"\"\"\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = enabled_vps\n\n        mock_db = AsyncMock()\n        mock_db.execute = AsyncMock(return_value=mock_result)\n        mock_db.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_db.__aexit__ = AsyncMock(return_value=False)\n\n        manager._session_factory = MagicMock(return_value=mock_db)\n        manager._base_dir = tmp_path\n\n    @pytest.mark.asyncio\n    async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path):\n        \"\"\"Verify sync_from_db restarts VP when mode changes.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=1,\n            name=\"TestVP\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            base_dir=tmp_path,\n        )\n        inst.stop_server = AsyncMock()\n        manager._instances[1] = inst\n\n        # DB says mode changed to \"archive\"\n        db_vp = self._make_db_vp(mode=\"archive\")\n        self._setup_sync_mocks(manager, [db_vp], tmp_path)\n\n        with patch.object(manager, \"remove_instance\", new_callable=AsyncMock) as mock_remove:\n            # Patch VirtualPrinterInstance to prevent actual start\n            with patch(\"backend.app.services.virtual_printer.manager.VirtualPrinterInstance\") as MockInst:\n                mock_new = MagicMock()\n                mock_new.start_server = AsyncMock()\n                MockInst.return_value = mock_new\n\n                await manager.sync_from_db()\n\n            mock_remove.assert_called_once_with(1)\n\n    @pytest.mark.asyncio\n    async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path):\n        \"\"\"Verify sync_from_db restarts VP when access_code changes.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=1,\n            name=\"TestVP\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            base_dir=tmp_path,\n        )\n        inst.stop_server = AsyncMock()\n        manager._instances[1] = inst\n\n        db_vp = self._make_db_vp(access_code=\"newcode99\")\n        self._setup_sync_mocks(manager, [db_vp], tmp_path)\n\n        with patch.object(manager, \"remove_instance\", new_callable=AsyncMock) as mock_remove:\n            with patch(\"backend.app.services.virtual_printer.manager.VirtualPrinterInstance\") as MockInst:\n                mock_new = MagicMock()\n                mock_new.start_server = AsyncMock()\n                MockInst.return_value = mock_new\n\n                await manager.sync_from_db()\n\n            mock_remove.assert_called_once_with(1)\n\n    @pytest.mark.asyncio\n    async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path):\n        \"\"\"Verify sync_from_db does NOT restart when config is identical.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=1,\n            name=\"TestVP\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            base_dir=tmp_path,\n        )\n        manager._instances[1] = inst\n\n        # DB matches running config exactly\n        db_vp = self._make_db_vp()\n        self._setup_sync_mocks(manager, [db_vp], tmp_path)\n\n        with patch.object(manager, \"remove_instance\", new_callable=AsyncMock) as mock_remove:\n            await manager.sync_from_db()\n\n            mock_remove.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path):\n        \"\"\"Verify sync_from_db restarts VP when bind_ip changes.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=1,\n            name=\"TestVP\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            bind_ip=\"192.168.1.10\",\n            base_dir=tmp_path,\n        )\n        inst.stop_server = AsyncMock()\n        manager._instances[1] = inst\n\n        db_vp = self._make_db_vp(bind_ip=\"192.168.1.20\")\n        self._setup_sync_mocks(manager, [db_vp], tmp_path)\n\n        with patch.object(manager, \"remove_instance\", new_callable=AsyncMock) as mock_remove:\n            with patch(\"backend.app.services.virtual_printer.manager.VirtualPrinterInstance\") as MockInst:\n                mock_new = MagicMock()\n                mock_new.start_server = AsyncMock()\n                MockInst.return_value = mock_new\n\n                await manager.sync_from_db()\n\n            mock_remove.assert_called_once_with(1)\n\n    @pytest.mark.asyncio\n    async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path):\n        \"\"\"Verify sync_from_db restarts VP when model changes.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=1,\n            name=\"TestVP\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800001\",\n            base_dir=tmp_path,\n        )\n        inst.stop_server = AsyncMock()\n        manager._instances[1] = inst\n\n        db_vp = self._make_db_vp(model=\"C12\")\n        self._setup_sync_mocks(manager, [db_vp], tmp_path)\n\n        with patch.object(manager, \"remove_instance\", new_callable=AsyncMock) as mock_remove:\n            with patch(\"backend.app.services.virtual_printer.manager.VirtualPrinterInstance\") as MockInst:\n                mock_new = MagicMock()\n                mock_new.start_server = AsyncMock()\n                MockInst.return_value = mock_new\n\n                await manager.sync_from_db()\n\n            mock_remove.assert_called_once_with(1)\n\n\nclass TestFTPSession:\n    \"\"\"Tests for FTP session handling.\"\"\"\n\n    @pytest.fixture\n    def mock_reader(self):\n        \"\"\"Create a mock StreamReader.\"\"\"\n        reader = AsyncMock()\n        return reader\n\n    @pytest.fixture\n    def mock_writer(self):\n        \"\"\"Create a mock StreamWriter.\"\"\"\n        writer = MagicMock()\n        writer.get_extra_info = MagicMock(return_value=(\"192.168.1.100\", 12345))\n        writer.write = MagicMock()\n        writer.drain = AsyncMock()\n        writer.close = MagicMock()\n        writer.wait_closed = AsyncMock()\n        writer.is_closing = MagicMock(return_value=False)\n        return writer\n\n    @pytest.fixture\n    def ssl_context(self):\n        \"\"\"Create a mock SSL context.\"\"\"\n        return MagicMock()\n\n    @pytest.fixture\n    def session(self, mock_reader, mock_writer, ssl_context, tmp_path):\n        \"\"\"Create an FTPSession instance.\"\"\"\n        from backend.app.services.virtual_printer.ftp_server import FTPSession\n\n        return FTPSession(\n            reader=mock_reader,\n            writer=mock_writer,\n            upload_dir=tmp_path,\n            access_code=\"12345678\",\n            ssl_context=ssl_context,\n            on_file_received=None,\n        )\n\n    # ========================================================================\n    # Tests for authentication\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_user_command_accepts_bblp(self, session):\n        \"\"\"Verify USER command accepts bblp user.\"\"\"\n        await session.cmd_USER(\"bblp\")\n\n        assert session.username == \"bblp\"\n\n    @pytest.mark.asyncio\n    async def test_pass_command_authenticates(self, session):\n        \"\"\"Verify PASS command authenticates with correct code.\"\"\"\n        session.username = \"bblp\"\n\n        await session.cmd_PASS(\"12345678\")\n\n        assert session.authenticated is True\n\n    @pytest.mark.asyncio\n    async def test_pass_command_rejects_wrong_code(self, session):\n        \"\"\"Verify PASS command rejects wrong access code.\"\"\"\n        session.username = \"bblp\"\n\n        await session.cmd_PASS(\"wrongcode\")\n\n        assert session.authenticated is False\n\n    # ========================================================================\n    # Tests for FTP commands\n    # ========================================================================\n\n    @pytest.mark.asyncio\n    async def test_syst_command(self, session):\n        \"\"\"Verify SYST returns UNIX type.\"\"\"\n        await session.cmd_SYST(\"\")\n\n        session.writer.write.assert_called()\n        call_args = session.writer.write.call_args[0][0].decode()\n        assert \"215\" in call_args\n        assert \"UNIX\" in call_args\n\n    @pytest.mark.asyncio\n    async def test_pwd_command_requires_auth(self, session):\n        \"\"\"Verify PWD requires authentication.\"\"\"\n        session.authenticated = False\n\n        await session.cmd_PWD(\"\")\n\n        call_args = session.writer.write.call_args[0][0].decode()\n        assert \"530\" in call_args\n\n    @pytest.mark.asyncio\n    async def test_pwd_command_when_authenticated(self, session):\n        \"\"\"Verify PWD returns root directory when authenticated.\"\"\"\n        session.authenticated = True\n\n        await session.cmd_PWD(\"\")\n\n        call_args = session.writer.write.call_args[0][0].decode()\n        assert \"257\" in call_args\n\n    @pytest.mark.asyncio\n    async def test_type_command_sets_binary(self, session):\n        \"\"\"Verify TYPE I sets binary mode.\"\"\"\n        session.authenticated = True\n\n        await session.cmd_TYPE(\"I\")\n\n        assert session.transfer_type == \"I\"\n\n    @pytest.mark.asyncio\n    async def test_pbsz_command(self, session):\n        \"\"\"Verify PBSZ returns success.\"\"\"\n        await session.cmd_PBSZ(\"0\")\n\n        call_args = session.writer.write.call_args[0][0].decode()\n        assert \"200\" in call_args\n\n    @pytest.mark.asyncio\n    async def test_prot_command_accepts_p(self, session):\n        \"\"\"Verify PROT P is accepted.\"\"\"\n        await session.cmd_PROT(\"P\")\n\n        call_args = session.writer.write.call_args[0][0].decode()\n        assert \"200\" in call_args\n\n    @pytest.mark.asyncio\n    async def test_quit_command(self, session):\n        \"\"\"Verify QUIT sends goodbye and raises CancelledError.\"\"\"\n        with pytest.raises(asyncio.CancelledError):\n            await session.cmd_QUIT(\"\")\n\n\nclass TestSSDPServer:\n    \"\"\"Tests for Virtual Printer SSDP server.\"\"\"\n\n    @pytest.fixture\n    def ssdp_server(self):\n        \"\"\"Create a VirtualPrinterSSDPServer instance.\"\"\"\n        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer\n\n        return VirtualPrinterSSDPServer(\n            serial=\"TEST123\",\n            name=\"TestPrinter\",\n            model=\"BL-P001\",\n        )\n\n    # ========================================================================\n    # Tests for SSDP response\n    # ========================================================================\n\n    def test_build_notify_message(self, ssdp_server):\n        \"\"\"Verify NOTIFY packet contains required headers.\"\"\"\n        # Set a known IP for testing\n        ssdp_server._local_ip = \"192.168.1.100\"\n\n        message = ssdp_server._build_notify_message()\n\n        assert b\"NOTIFY\" in message\n        assert b\"DevName.bambu.com: TestPrinter\" in message\n        assert b\"USN: TEST123\" in message\n\n    def test_build_response_message(self, ssdp_server):\n        \"\"\"Verify response packet contains required headers.\"\"\"\n        # Set a known IP for testing\n        ssdp_server._local_ip = \"192.168.1.100\"\n\n        message = ssdp_server._build_response_message()\n\n        assert b\"HTTP/1.1 200 OK\" in message\n        assert b\"DevName.bambu.com: TestPrinter\" in message\n        assert b\"USN: TEST123\" in message\n\n    def test_ssdp_server_uses_correct_model(self, ssdp_server):\n        \"\"\"Verify SSDP server uses the provided model.\"\"\"\n        ssdp_server._local_ip = \"192.168.1.100\"\n\n        message = ssdp_server._build_notify_message()\n\n        assert b\"DevModel.bambu.com: BL-P001\" in message\n\n    # ========================================================================\n    # Tests for advertise_ip parameter\n    # ========================================================================\n\n    def test_advertise_ip_sets_local_ip(self):\n        \"\"\"Verify advertise_ip overrides auto-detection.\"\"\"\n        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer\n\n        server = VirtualPrinterSSDPServer(\n            serial=\"TEST123\",\n            name=\"TestPrinter\",\n            model=\"BL-P001\",\n            advertise_ip=\"10.0.0.50\",\n        )\n\n        assert server._local_ip == \"10.0.0.50\"\n\n    def test_advertise_ip_empty_string_uses_auto_detect(self):\n        \"\"\"Verify empty advertise_ip falls back to auto-detection.\"\"\"\n        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer\n\n        server = VirtualPrinterSSDPServer(\n            serial=\"TEST123\",\n            name=\"TestPrinter\",\n            model=\"BL-P001\",\n            advertise_ip=\"\",\n        )\n\n        assert server._local_ip is None\n\n    def test_advertise_ip_in_notify_message(self):\n        \"\"\"Verify NOTIFY message uses the advertise_ip.\"\"\"\n        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer\n\n        server = VirtualPrinterSSDPServer(\n            serial=\"TEST123\",\n            name=\"TestPrinter\",\n            model=\"BL-P001\",\n            advertise_ip=\"10.0.0.50\",\n        )\n\n        message = server._build_notify_message()\n\n        assert b\"Location: 10.0.0.50\" in message\n\n    def test_advertise_ip_in_response_message(self):\n        \"\"\"Verify M-SEARCH response uses the advertise_ip.\"\"\"\n        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer\n\n        server = VirtualPrinterSSDPServer(\n            serial=\"TEST123\",\n            name=\"TestPrinter\",\n            model=\"BL-P001\",\n            advertise_ip=\"10.0.0.50\",\n        )\n\n        message = server._build_response_message()\n\n        assert b\"Location: 10.0.0.50\" in message\n\n    def test_default_no_advertise_ip(self):\n        \"\"\"Verify default constructor has None local_ip (auto-detect).\"\"\"\n        from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer\n\n        server = VirtualPrinterSSDPServer()\n\n        assert server._local_ip is None\n\n\nclass TestCertificateService:\n    \"\"\"Tests for TLS certificate generation.\"\"\"\n\n    @pytest.fixture\n    def cert_service(self, tmp_path):\n        \"\"\"Create a CertificateService instance.\"\"\"\n        from backend.app.services.virtual_printer.certificate import CertificateService\n\n        return CertificateService(cert_dir=tmp_path, serial=\"TEST123\")\n\n    def test_generate_certificates(self, cert_service, tmp_path):\n        \"\"\"Verify certificates are generated correctly.\"\"\"\n        cert_path, key_path = cert_service.generate_certificates()\n\n        assert cert_path.exists()\n        assert key_path.exists()\n\n        # Verify certificate content\n        cert_content = cert_path.read_text()\n        assert \"BEGIN CERTIFICATE\" in cert_content\n\n        key_content = key_path.read_text()\n        assert \"BEGIN\" in key_content and \"KEY\" in key_content\n\n    def test_certificates_reused_if_exist(self, cert_service):\n        \"\"\"Verify existing certificates are reused.\"\"\"\n        # First generation\n        cert_path1, key_path1 = cert_service.generate_certificates()\n        mtime1 = cert_path1.stat().st_mtime\n\n        # Second call should reuse (via ensure_certificates)\n        cert_path2, key_path2 = cert_service.ensure_certificates()\n        mtime2 = cert_path2.stat().st_mtime\n\n        assert mtime1 == mtime2  # File wasn't regenerated\n\n    def test_delete_certificates(self, cert_service):\n        \"\"\"Verify certificates can be deleted.\"\"\"\n        cert_service.generate_certificates()\n\n        assert cert_service.cert_path.exists()\n        assert cert_service.key_path.exists()\n\n        cert_service.delete_certificates()\n\n        assert not cert_service.cert_path.exists()\n        assert not cert_service.key_path.exists()\n\n    def test_ensure_creates_if_not_exist(self, cert_service):\n        \"\"\"Verify ensure_certificates generates if not existing.\"\"\"\n        assert not cert_service.cert_path.exists()\n\n        cert_path, key_path = cert_service.ensure_certificates()\n\n        assert cert_path.exists()\n        assert key_path.exists()\n\n\nclass TestBindServer:\n    \"\"\"Tests for BindServer (port 3002 bind/detect protocol).\"\"\"\n\n    @pytest.fixture\n    def bind_server(self):\n        \"\"\"Create a BindServer instance.\"\"\"\n        from backend.app.services.virtual_printer.bind_server import BindServer\n\n        return BindServer(\n            serial=\"09400A391800001\",\n            model=\"O1D\",\n            name=\"Bambuddy\",\n        )\n\n    def test_build_frame(self, bind_server):\n        \"\"\"Verify frame building produces correct format.\"\"\"\n        payload = {\"login\": {\"command\": \"detect\"}}\n        frame = bind_server._build_frame(payload)\n\n        # Header: 0xA5A5\n        assert frame[:2] == b\"\\xa5\\xa5\"\n        # Trailer: 0xA7A7\n        assert frame[-2:] == b\"\\xa7\\xa7\"\n        # Length field is total message size (LE uint16)\n        import struct\n\n        total_len = struct.unpack_from(\"<H\", frame, 2)[0]\n        assert total_len == len(frame)\n        # JSON payload is between header and trailer\n        import json\n\n        json_bytes = frame[4:-2]\n        parsed = json.loads(json_bytes)\n        assert parsed == payload\n\n    def test_parse_frame_valid(self, bind_server):\n        \"\"\"Verify valid frame parsing extracts JSON correctly.\"\"\"\n        import json\n        import struct\n\n        payload = {\"login\": {\"command\": \"detect\", \"sequence_id\": \"20000\"}}\n        json_bytes = json.dumps(payload, separators=(\",\", \":\")).encode()\n        total_len = 4 + len(json_bytes) + 2\n        frame = b\"\\xa5\\xa5\" + struct.pack(\"<H\", total_len) + json_bytes + b\"\\xa7\\xa7\"\n\n        result = bind_server._parse_frame(frame)\n\n        assert result is not None\n        assert result[\"login\"][\"command\"] == \"detect\"\n        assert result[\"login\"][\"sequence_id\"] == \"20000\"\n\n    def test_parse_frame_invalid_header(self, bind_server):\n        \"\"\"Verify invalid header returns None.\"\"\"\n        result = bind_server._parse_frame(b\"\\xbb\\xbb\\x06\\x00{}\\xa7\\xa7\")\n        assert result is None\n\n    def test_parse_frame_invalid_trailer(self, bind_server):\n        \"\"\"Verify invalid trailer returns None.\"\"\"\n        result = bind_server._parse_frame(b\"\\xa5\\xa5\\x06\\x00{}\\xbb\\xbb\")\n        assert result is None\n\n    def test_parse_frame_too_short(self, bind_server):\n        \"\"\"Verify short data returns None.\"\"\"\n        result = bind_server._parse_frame(b\"\\xa5\\xa5\\x00\")\n        assert result is None\n\n    def test_parse_frame_invalid_json(self, bind_server):\n        \"\"\"Verify invalid JSON returns None.\"\"\"\n        import struct\n\n        bad_json = b\"not json\"\n        total_len = 4 + len(bad_json) + 2\n        frame = b\"\\xa5\\xa5\" + struct.pack(\"<H\", total_len) + bad_json + b\"\\xa7\\xa7\"\n        result = bind_server._parse_frame(frame)\n        assert result is None\n\n    def test_build_frame_roundtrip(self, bind_server):\n        \"\"\"Verify build_frame output can be parsed back.\"\"\"\n        payload = {\n            \"login\": {\n                \"bind\": \"free\",\n                \"command\": \"detect\",\n                \"connect\": \"lan\",\n                \"dev_cap\": 1,\n                \"id\": \"09400A391800001\",\n                \"model\": \"O1D\",\n                \"name\": \"Bambuddy\",\n                \"sequence_id\": 3021,\n                \"version\": \"01.00.00.00\",\n            }\n        }\n        frame = bind_server._build_frame(payload)\n        parsed = bind_server._parse_frame(frame)\n\n        assert parsed is not None\n        assert parsed[\"login\"][\"id\"] == \"09400A391800001\"\n        assert parsed[\"login\"][\"model\"] == \"O1D\"\n        assert parsed[\"login\"][\"name\"] == \"Bambuddy\"\n        assert parsed[\"login\"][\"bind\"] == \"free\"\n\n    def test_bind_server_stores_config(self, bind_server):\n        \"\"\"Verify bind server stores serial, model, name.\"\"\"\n        assert bind_server.serial == \"09400A391800001\"\n        assert bind_server.model == \"O1D\"\n        assert bind_server.name == \"Bambuddy\"\n        assert bind_server.version == \"01.00.00.00\"\n\n    def test_bind_server_custom_version(self):\n        \"\"\"Verify custom firmware version is stored.\"\"\"\n        from backend.app.services.virtual_printer.bind_server import BindServer\n\n        server = BindServer(\n            serial=\"TEST123\",\n            model=\"C13\",\n            name=\"Test\",\n            version=\"02.03.04.05\",\n        )\n        assert server.version == \"02.03.04.05\"\n\n    def test_bind_ports_constant(self):\n        \"\"\"Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility.\"\"\"\n        from backend.app.services.virtual_printer.bind_server import BIND_PORTS\n\n        assert 3000 in BIND_PORTS\n        assert 3002 in BIND_PORTS\n\n    def test_bind_server_initializes_empty_servers_list(self, bind_server):\n        \"\"\"Verify bind server starts with empty servers list.\"\"\"\n        assert bind_server._servers == []\n        assert bind_server._running is False\n\n\nclass TestSlicerProxyManager:\n    \"\"\"Tests for SlicerProxyManager (proxy mode).\"\"\"\n\n    @pytest.fixture\n    def proxy_manager(self, tmp_path):\n        \"\"\"Create a SlicerProxyManager instance.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager\n\n        # Create dummy cert files\n        cert_path = tmp_path / \"cert.pem\"\n        key_path = tmp_path / \"key.pem\"\n        cert_path.write_text(\"-----BEGIN CERTIFICATE-----\\ntest\\n-----END CERTIFICATE-----\")\n        # Split string to avoid pre-commit hook false positive on test data\n        key_path.write_text(\"-----BEGIN \" + \"PRIVATE KEY-----\\ntest\\n-----END \" + \"PRIVATE KEY-----\")\n\n        return SlicerProxyManager(\n            target_host=\"192.168.1.100\",\n            cert_path=cert_path,\n            key_path=key_path,\n        )\n\n    def test_proxy_manager_initializes_ports(self, proxy_manager):\n        \"\"\"Verify proxy manager has correct port constants.\"\"\"\n        # FTP proxy uses privileged port 990 to match what Bambu Studio expects\n        assert proxy_manager.LOCAL_FTP_PORT == 990\n        assert proxy_manager.LOCAL_MQTT_PORT == 8883\n        assert proxy_manager.PRINTER_FTP_PORT == 990\n        assert proxy_manager.PRINTER_MQTT_PORT == 8883\n        assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000\n        assert proxy_manager.PRINTER_RTSP_PORT == 322\n        # Auxiliary ports: undocumented proprietary ports for A1/P1S etc.\n        assert proxy_manager.PRINTER_AUX_PORTS == [2024, 2025, 2026]\n        # Bind ports: both 3000 and 3002 for slicer compatibility\n        assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]\n        # FTP data port range for transparent EPSV proxying\n        assert proxy_manager.FTP_DATA_PORT_MIN == 50000\n        assert proxy_manager.FTP_DATA_PORT_MAX == 50100\n\n    def test_proxy_manager_stores_target_host(self, proxy_manager):\n        \"\"\"Verify proxy manager stores target host.\"\"\"\n        assert proxy_manager.target_host == \"192.168.1.100\"\n\n    def test_get_status_before_start(self, proxy_manager):\n        \"\"\"Verify get_status returns zeros before start.\"\"\"\n        status = proxy_manager.get_status()\n\n        assert status[\"running\"] is False\n        assert status[\"ftp_connections\"] == 0\n        assert status[\"mqtt_connections\"] == 0\n\n    @pytest.mark.asyncio\n    async def test_proxy_start_creates_transparent_proxies(self, tmp_path):\n        \"\"\"Verify start() uses TCPProxy for FTP/FileTransfer/RTSP and TLSProxy only for MQTT.\n\n        The transparent proxy architecture preserves end-to-end TLS between\n        slicer and printer for all protocols except MQTT, which must be\n        TLS-terminated to rewrite the printer's IP in MQTT payloads.\n        \"\"\"\n        from unittest.mock import AsyncMock, patch\n\n        from backend.app.services.virtual_printer.tcp_proxy import (\n            SlicerProxyManager,\n            TCPProxy,\n            TLSProxy,\n        )\n\n        cert_path = tmp_path / \"cert.pem\"\n        key_path = tmp_path / \"key.pem\"\n        cert_path.write_text(\"-----BEGIN CERTIFICATE-----\\ntest\\n-----END CERTIFICATE-----\")\n        key_path.write_text(\"-----BEGIN \" + \"PRIVATE KEY-----\\ntest\\n-----END \" + \"PRIVATE KEY-----\")\n\n        mgr = SlicerProxyManager(\n            target_host=\"192.168.1.100\",\n            cert_path=cert_path,\n            key_path=key_path,\n            bind_address=\"10.0.0.1\",\n        )\n\n        # Mock asyncio.create_task and asyncio.gather to prevent actual server start\n        with (\n            patch(\"asyncio.create_task\") as mock_create_task,\n            patch(\"asyncio.gather\", new_callable=AsyncMock),\n            patch.object(SlicerProxyManager, \"_log_activity\"),\n        ):\n            mock_create_task.return_value = MagicMock()\n            # start() will create proxies then try to gather tasks — we just\n            # need to verify the proxy types after creation.\n            # Trigger start but let gather return immediately.\n            await mgr.start()\n\n        # FTP, FileTransfer, RTSP should be TCPProxy (transparent)\n        assert isinstance(mgr._ftp_proxy, TCPProxy), \"FTP should be TCPProxy (transparent)\"\n        assert isinstance(mgr._file_transfer_proxy, TCPProxy), \"FileTransfer should be TCPProxy\"\n        assert isinstance(mgr._rtsp_proxy, TCPProxy), \"RTSP should be TCPProxy\"\n\n        # MQTT should be TLSProxy (TLS-terminated for IP rewriting)\n        assert isinstance(mgr._mqtt_proxy, TLSProxy), \"MQTT should be TLSProxy (TLS-terminated)\"\n\n        # Auxiliary ports (2024-2026) should be TCPProxy (transparent)\n        assert len(mgr._aux_proxies) == 3, \"Should have 3 aux port proxies\"\n        for ap in mgr._aux_proxies:\n            assert isinstance(ap, TCPProxy), \"Aux proxies should be TCPProxy\"\n        assert mgr._aux_proxies[0].listen_port == 2024\n        assert mgr._aux_proxies[0].target_port == 2024\n        assert mgr._aux_proxies[2].listen_port == 2026\n\n        # FTP data ports should be pre-created as TCPProxy instances\n        assert len(mgr._ftp_data_proxies) == 101  # 50000-50100 inclusive\n        for dp in mgr._ftp_data_proxies:\n            assert isinstance(dp, TCPProxy), \"FTP data proxies should be TCPProxy\"\n\n        # Verify FTP data proxies target the same port on the printer\n        first_dp = mgr._ftp_data_proxies[0]\n        assert first_dp.listen_port == 50000\n        assert first_dp.target_port == 50000\n        assert first_dp.target_host == \"192.168.1.100\"\n\n        last_dp = mgr._ftp_data_proxies[-1]\n        assert last_dp.listen_port == 50100\n        assert last_dp.target_port == 50100\n\n    def test_proxy_manager_mqtt_has_ip_rewriting(self, tmp_path):\n        \"\"\"Verify MQTT proxy is configured with IP rewriting when bind_address is set.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager\n\n        cert_path = tmp_path / \"cert.pem\"\n        key_path = tmp_path / \"key.pem\"\n        cert_path.write_text(\"-----BEGIN CERTIFICATE-----\\ntest\\n-----END CERTIFICATE-----\")\n        key_path.write_text(\"-----BEGIN \" + \"PRIVATE KEY-----\\ntest\\n-----END \" + \"PRIVATE KEY-----\")\n\n        mgr = SlicerProxyManager(\n            target_host=\"192.168.1.100\",\n            cert_path=cert_path,\n            key_path=key_path,\n            bind_address=\"10.0.0.1\",\n        )\n\n        # Before start, proxies are None — verify constructor stores rewrite config\n        assert mgr.bind_address == \"10.0.0.1\"\n        assert mgr.target_host == \"192.168.1.100\"\n\n\nclass TestSSDPProxy:\n    \"\"\"Tests for SSDPProxy (cross-network SSDP relay).\"\"\"\n\n    @pytest.fixture\n    def ssdp_proxy(self):\n        \"\"\"Create an SSDPProxy instance.\"\"\"\n        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy\n\n        return SSDPProxy(\n            local_interface_ip=\"192.168.1.100\",\n            remote_interface_ip=\"10.0.0.100\",\n            target_printer_ip=\"192.168.1.50\",\n        )\n\n    def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):\n        \"\"\"Verify SSDPProxy stores interface IPs correctly.\"\"\"\n        assert ssdp_proxy.local_interface_ip == \"192.168.1.100\"\n        assert ssdp_proxy.remote_interface_ip == \"10.0.0.100\"\n        assert ssdp_proxy.target_printer_ip == \"192.168.1.50\"\n\n    def test_rewrite_ssdp_location(self, ssdp_proxy):\n        \"\"\"Verify SSDP Location header is rewritten to remote interface IP.\"\"\"\n        original_packet = b\"NOTIFY * HTTP/1.1\\r\\nLocation: 192.168.1.50\\r\\nDevName.bambu.com: TestPrinter\\r\\n\\r\\n\"\n\n        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)\n\n        # Location should be changed to remote interface IP\n        assert b\"Location: 10.0.0.100\" in rewritten\n        assert b\"Location: 192.168.1.50\" not in rewritten\n        # Other headers should be preserved\n        assert b\"DevName.bambu.com: TestPrinter\" in rewritten\n\n    def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):\n        \"\"\"Verify SSDP Location rewrite is case insensitive.\"\"\"\n        original_packet = b\"NOTIFY * HTTP/1.1\\r\\nlocation: 192.168.1.50\\r\\n\\r\\n\"\n\n        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)\n\n        assert b\"10.0.0.100\" in rewritten\n\n    def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):\n        \"\"\"Verify packet without Location header is returned unchanged.\"\"\"\n        original_packet = b\"NOTIFY * HTTP/1.1\\r\\nDevName.bambu.com: Test\\r\\n\\r\\n\"\n\n        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)\n\n        # No Location header, but _rewrite_ssdp logs a warning and returns as-is\n        assert b\"DevName.bambu.com: Test\" in rewritten\n\n    def test_parse_ssdp_message(self, ssdp_proxy):\n        \"\"\"Verify SSDP message parsing extracts headers.\"\"\"\n        packet = (\n            b\"NOTIFY * HTTP/1.1\\r\\n\"\n            b\"Location: 192.168.1.50\\r\\n\"\n            b\"DevName.bambu.com: TestPrinter\\r\\n\"\n            b\"DevModel.bambu.com: BL-P001\\r\\n\"\n            b\"\\r\\n\"\n        )\n\n        headers = ssdp_proxy._parse_ssdp_message(packet)\n\n        assert headers[\"location\"] == \"192.168.1.50\"\n        assert headers[\"devname.bambu.com\"] == \"TestPrinter\"\n        assert headers[\"devmodel.bambu.com\"] == \"BL-P001\"\n\n\nclass TestVirtualPrinterManagerDirectories:\n    \"\"\"Tests for VirtualPrinterManager directory management.\"\"\"\n\n    def test_ensure_base_directories_creates_subdirs(self, tmp_path):\n        \"\"\"Verify _ensure_base_directories creates required base directories.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterManager\n\n        manager = VirtualPrinterManager()\n        manager._base_dir = tmp_path / \"virtual_printer\"\n        manager._ensure_base_directories()\n\n        assert (tmp_path / \"virtual_printer\").exists()\n        assert (tmp_path / \"virtual_printer\" / \"uploads\").exists()\n        assert (tmp_path / \"virtual_printer\" / \"certs\").exists()\n\n    def test_ensure_base_directories_handles_permission_error(self, tmp_path, caplog):\n        \"\"\"Verify _ensure_base_directories logs error on permission failure.\"\"\"\n        import logging\n\n        from backend.app.services.virtual_printer.manager import VirtualPrinterManager\n\n        manager = VirtualPrinterManager()\n        vp_dir = tmp_path / \"virtual_printer\"\n        manager._base_dir = vp_dir\n\n        original_mkdir = type(vp_dir).mkdir\n\n        def mock_mkdir(self, *args, **kwargs):\n            if \"virtual_printer\" in str(self):\n                raise PermissionError(\"Permission denied\")\n            return original_mkdir(self, *args, **kwargs)\n\n        with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), \"mkdir\", mock_mkdir):\n            manager._ensure_base_directories()\n            assert \"Permission denied\" in caplog.text\n\n    def test_instance_creates_per_vp_directories(self, tmp_path):\n        \"\"\"Verify VirtualPrinterInstance creates per-VP upload and cert dirs.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        VirtualPrinterInstance(\n            vp_id=42,\n            name=\"Test\",\n            mode=\"immediate\",\n            model=\"C11\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800042\",\n            base_dir=tmp_path,\n        )\n\n        assert (tmp_path / \"uploads\" / \"42\").exists()\n        assert (tmp_path / \"uploads\" / \"42\" / \"cache\").exists()\n        assert (tmp_path / \"certs\" / \"42\").exists()\n\n\nclass TestVirtualPrinterInstanceProxyMode:\n    \"\"\"Tests for VirtualPrinterInstance proxy mode.\"\"\"\n\n    @pytest.fixture\n    def proxy_instance(self, tmp_path):\n        \"\"\"Create a proxy-mode VirtualPrinterInstance.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        return VirtualPrinterInstance(\n            vp_id=10,\n            name=\"ProxyTest\",\n            mode=\"proxy\",\n            model=\"C11\",\n            access_code=\"\",\n            serial_suffix=\"391800010\",\n            target_printer_ip=\"192.168.1.100\",\n            target_printer_serial=\"01P00A000000001\",\n            base_dir=tmp_path,\n        )\n\n    def test_proxy_instance_properties(self, proxy_instance):\n        \"\"\"Verify proxy instance stores config correctly.\"\"\"\n        assert proxy_instance.is_proxy is True\n        assert proxy_instance.mode == \"proxy\"\n        assert proxy_instance.target_printer_ip == \"192.168.1.100\"\n        assert proxy_instance.target_printer_serial == \"01P00A000000001\"\n\n    def test_proxy_instance_does_not_require_access_code(self, proxy_instance):\n        \"\"\"Verify proxy mode can have empty access code.\"\"\"\n        assert proxy_instance.access_code == \"\"\n\n    def test_get_status_proxy_includes_proxy_fields(self, proxy_instance):\n        \"\"\"Verify get_status includes proxy fields when proxy is active.\"\"\"\n        mock_proxy = MagicMock()\n        mock_proxy.get_status.return_value = {\n            \"running\": True,\n            \"ftp_port\": 990,\n            \"mqtt_port\": 8883,\n            \"ftp_connections\": 1,\n            \"mqtt_connections\": 2,\n            \"target_host\": \"192.168.1.100\",\n        }\n        proxy_instance._proxy = mock_proxy\n\n        status = proxy_instance.get_status()\n        assert \"proxy\" in status\n        assert status[\"proxy\"][\"ftp_port\"] == 990\n        assert status[\"proxy\"][\"mqtt_connections\"] == 2\n\n    def test_proxy_instance_stores_remote_interface(self, tmp_path):\n        \"\"\"Verify proxy instance stores remote_interface_ip.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=11,\n            name=\"Proxy2\",\n            mode=\"proxy\",\n            model=\"C11\",\n            access_code=\"\",\n            serial_suffix=\"391800011\",\n            target_printer_ip=\"192.168.1.100\",\n            remote_interface_ip=\"10.0.0.50\",\n            base_dir=tmp_path,\n        )\n        assert inst.remote_interface_ip == \"10.0.0.50\"\n\n\nclass TestVirtualPrinterInstanceIPOverride:\n    \"\"\"Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance.\"\"\"\n\n    @pytest.fixture\n    def instance_with_remote_ip(self, tmp_path):\n        \"\"\"Create an instance with remote_interface_ip set.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        return VirtualPrinterInstance(\n            vp_id=20,\n            name=\"IPTest\",\n            mode=\"immediate\",\n            model=\"BL-P001\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800020\",\n            bind_ip=\"192.168.1.50\",\n            remote_interface_ip=\"10.0.0.50\",\n            base_dir=tmp_path,\n        )\n\n    def test_instance_stores_bind_ip(self, instance_with_remote_ip):\n        \"\"\"Verify bind_ip is stored.\"\"\"\n        assert instance_with_remote_ip.bind_ip == \"192.168.1.50\"\n\n    def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip):\n        \"\"\"Verify remote_interface_ip is stored.\"\"\"\n        assert instance_with_remote_ip.remote_interface_ip == \"10.0.0.50\"\n\n    def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip):\n        \"\"\"Verify generate_certificates passes remote_interface_ip and bind_ip as SANs.\"\"\"\n        with (\n            patch.object(instance_with_remote_ip._cert_service, \"delete_printer_certificate\"),\n            patch.object(\n                instance_with_remote_ip._cert_service,\n                \"generate_certificates\",\n                return_value=(Path(\"/tmp/cert.pem\"), Path(\"/tmp/key.pem\")),  # nosec B108\n            ) as mock_gen,\n        ):\n            instance_with_remote_ip.generate_certificates()\n            mock_gen.assert_called_once_with(additional_ips=[\"10.0.0.50\", \"192.168.1.50\"])\n\n    def test_generate_certificates_no_remote_ip(self, tmp_path):\n        \"\"\"Verify generate_certificates passes only bind_ip when no remote_interface_ip.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=21,\n            name=\"NoRemote\",\n            mode=\"immediate\",\n            model=\"BL-P001\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800021\",\n            bind_ip=\"192.168.1.50\",\n            base_dir=tmp_path,\n        )\n\n        with (\n            patch.object(inst._cert_service, \"delete_printer_certificate\"),\n            patch.object(\n                inst._cert_service,\n                \"generate_certificates\",\n                return_value=(Path(\"/tmp/cert.pem\"), Path(\"/tmp/key.pem\")),  # nosec B108\n            ) as mock_gen,\n        ):\n            inst.generate_certificates()\n            mock_gen.assert_called_once_with(additional_ips=[\"192.168.1.50\"])\n\n    def test_generate_certificates_no_ips(self, tmp_path):\n        \"\"\"Verify generate_certificates passes None when no IPs configured.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=22,\n            name=\"NoIPs\",\n            mode=\"immediate\",\n            model=\"BL-P001\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800022\",\n            base_dir=tmp_path,\n        )\n\n        with (\n            patch.object(inst._cert_service, \"delete_printer_certificate\"),\n            patch.object(\n                inst._cert_service,\n                \"generate_certificates\",\n                return_value=(Path(\"/tmp/cert.pem\"), Path(\"/tmp/key.pem\")),  # nosec B108\n            ) as mock_gen,\n        ):\n            inst.generate_certificates()\n            mock_gen.assert_called_once_with(additional_ips=None)\n\n\nclass TestBindServer:\n    \"\"\"Tests for the BindServer (port 3002 bind/detect protocol).\"\"\"\n\n    @pytest.fixture\n    def bind_server(self):\n        \"\"\"Create a BindServer instance.\"\"\"\n        from backend.app.services.virtual_printer.bind_server import BindServer\n\n        return BindServer(\n            serial=\"01S00C000000001\",\n            model=\"BL-P001\",\n            name=\"Bambuddy\",\n        )\n\n    def test_build_frame(self, bind_server):\n        \"\"\"Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7.\"\"\"\n        payload = {\"login\": {\"command\": \"detect\"}}\n        frame = bind_server._build_frame(payload)\n\n        assert frame[:2] == b\"\\xa5\\xa5\"\n        assert frame[-2:] == b\"\\xa7\\xa7\"\n\n        # Length field is total message size\n        import struct\n\n        total_len = struct.unpack_from(\"<H\", frame, 2)[0]\n        assert total_len == len(frame)\n\n        # JSON payload is between header and trailer\n        import json\n\n        json_bytes = frame[4:-2]\n        parsed = json.loads(json_bytes)\n        assert parsed == payload\n\n    def test_parse_frame_valid(self, bind_server):\n        \"\"\"Verify valid frame parsing.\"\"\"\n        frame = bind_server._build_frame({\"login\": {\"command\": \"detect\", \"sequence_id\": \"20000\"}})\n        result = bind_server._parse_frame(frame)\n\n        assert result is not None\n        assert result[\"login\"][\"command\"] == \"detect\"\n        assert result[\"login\"][\"sequence_id\"] == \"20000\"\n\n    def test_parse_frame_invalid_header(self, bind_server):\n        \"\"\"Verify invalid header returns None.\"\"\"\n        frame = b\"\\xb5\\xb5\\x10\\x00\" + b'{\"login\":{}}' + b\"\\xa7\\xa7\"\n        assert bind_server._parse_frame(frame) is None\n\n    def test_parse_frame_invalid_trailer(self, bind_server):\n        \"\"\"Verify invalid trailer returns None.\"\"\"\n        frame = b\"\\xa5\\xa5\\x10\\x00\" + b'{\"login\":{}}' + b\"\\xb7\\xb7\"\n        assert bind_server._parse_frame(frame) is None\n\n    def test_parse_frame_too_short(self, bind_server):\n        \"\"\"Verify short data returns None.\"\"\"\n        assert bind_server._parse_frame(b\"\\xa5\\xa5\\x00\") is None\n        assert bind_server._parse_frame(b\"\") is None\n\n    def test_parse_frame_invalid_json(self, bind_server):\n        \"\"\"Verify invalid JSON returns None.\"\"\"\n        import struct\n\n        bad_json = b\"not json\"\n        total_len = 4 + len(bad_json) + 2\n        frame = b\"\\xa5\\xa5\" + struct.pack(\"<H\", total_len) + bad_json + b\"\\xa7\\xa7\"\n        assert bind_server._parse_frame(frame) is None\n\n    def test_build_frame_roundtrip(self, bind_server):\n        \"\"\"Verify build then parse roundtrip.\"\"\"\n        original = {\"login\": {\"bind\": \"free\", \"command\": \"detect\", \"id\": \"01S00C000000001\"}}\n        frame = bind_server._build_frame(original)\n        parsed = bind_server._parse_frame(frame)\n        assert parsed == original\n\n    def test_bind_server_stores_config(self, bind_server):\n        \"\"\"Verify config is stored correctly.\"\"\"\n        assert bind_server.serial == \"01S00C000000001\"\n        assert bind_server.model == \"BL-P001\"\n        assert bind_server.name == \"Bambuddy\"\n        assert bind_server.version == \"01.00.00.00\"\n\n    def test_bind_server_custom_version(self):\n        \"\"\"Verify custom firmware version is stored.\"\"\"\n        from backend.app.services.virtual_printer.bind_server import BindServer\n\n        server = BindServer(\n            serial=\"01S00C000000001\",\n            model=\"BL-P001\",\n            name=\"Bambuddy\",\n            version=\"01.09.00.10\",\n        )\n        assert server.version == \"01.09.00.10\"\n\n    def test_bind_ports_includes_both(self):\n        \"\"\"Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility.\"\"\"\n        from backend.app.services.virtual_printer.bind_server import BIND_PORTS\n\n        assert 3000 in BIND_PORTS\n        assert 3002 in BIND_PORTS\n\n    def test_bind_server_initializes_empty_servers_list(self, bind_server):\n        \"\"\"Verify bind server starts with empty servers list.\"\"\"\n        assert bind_server._servers == []\n        assert bind_server._running is False\n\n    @pytest.mark.asyncio\n    async def test_start_server_creates_bind_server(self, tmp_path):\n        \"\"\"Verify start_server creates BindServer with correct params.\"\"\"\n        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance\n\n        inst = VirtualPrinterInstance(\n            vp_id=99,\n            name=\"Bambuddy\",\n            mode=\"immediate\",\n            model=\"BL-P001\",\n            access_code=\"12345678\",\n            serial_suffix=\"391800099\",\n            bind_ip=\"192.168.1.50\",\n            base_dir=tmp_path,\n        )\n\n        with (\n            patch(\"backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer\"),\n            patch(\"backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer\"),\n            patch(\"backend.app.services.virtual_printer.manager.SimpleMQTTServer\"),\n            patch(\"backend.app.services.virtual_printer.manager.BindServer\") as mock_bind_cls,\n            patch.object(inst._cert_service, \"delete_printer_certificate\"),\n            patch.object(\n                inst._cert_service,\n                \"generate_certificates\",\n                return_value=(Path(\"/tmp/cert.pem\"), Path(\"/tmp/key.pem\")),  # nosec B108\n            ),\n        ):\n            await inst.start_server()\n\n            mock_bind_cls.assert_called_once_with(\n                serial=inst.serial,\n                model=\"BL-P001\",\n                name=\"Bambuddy\",\n                bind_address=\"192.168.1.50\",\n                cert_path=Path(\"/tmp/cert.pem\"),  # nosec B108\n                key_path=Path(\"/tmp/key.pem\"),  # nosec B108\n            )\n\n\nclass TestResolveModelCodes:\n    \"\"\"Tests for model code resolution (display name → SSDP code).\"\"\"\n\n    def test_display_name_to_model_code_maps_all_models(self):\n        \"\"\"Verify reverse mapping covers all VIRTUAL_PRINTER_MODELS entries.\"\"\"\n        from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE, VIRTUAL_PRINTER_MODELS\n\n        for _code, display_name in VIRTUAL_PRINTER_MODELS.items():\n            assert display_name in DISPLAY_NAME_TO_MODEL_CODE\n            # For non-duplicate display names, should map back to a valid code\n            assert DISPLAY_NAME_TO_MODEL_CODE[display_name] in VIRTUAL_PRINTER_MODELS\n\n    def test_resolve_printer_model_with_ssdp_code(self):\n        \"\"\"SSDP codes pass through unchanged.\"\"\"\n        from backend.app.api.routes.virtual_printers import _resolve_printer_model\n\n        assert _resolve_printer_model(\"BL-P001\") == \"BL-P001\"\n        assert _resolve_printer_model(\"O1D\") == \"O1D\"\n        assert _resolve_printer_model(\"N2S\") == \"N2S\"\n\n    def test_resolve_printer_model_with_display_name(self):\n        \"\"\"Display names resolve to SSDP codes.\"\"\"\n        from backend.app.api.routes.virtual_printers import _resolve_printer_model\n\n        assert _resolve_printer_model(\"X1C\") == \"BL-P001\"\n        assert _resolve_printer_model(\"H2D\") == \"O1D\"\n        assert _resolve_printer_model(\"A1\") == \"N2S\"\n        assert _resolve_printer_model(\"P1S\") == \"C12\"\n\n    def test_resolve_printer_model_with_none_or_unknown(self):\n        \"\"\"None and unknown values return None.\"\"\"\n        from backend.app.api.routes.virtual_printers import _resolve_printer_model\n\n        assert _resolve_printer_model(None) is None\n        assert _resolve_printer_model(\"UnknownModel\") is None\n\n\nclass TestMqttIpRewrite:\n    \"\"\"Tests for TLSProxy._rewrite_mqtt_ip() MQTT packet IP rewriting.\"\"\"\n\n    @staticmethod\n    def _build_mqtt_publish(topic: str, payload: bytes) -> bytes:\n        \"\"\"Build a minimal MQTT PUBLISH packet.\"\"\"\n        # PUBLISH fixed header: type 3, no flags\n        topic_bytes = topic.encode(\"utf-8\")\n        # Variable header: topic length (2 bytes) + topic\n        var_header = len(topic_bytes).to_bytes(2, \"big\") + topic_bytes\n        body = var_header + payload\n\n        # Encode remaining length\n        remaining = len(body)\n        header = bytearray([0x30])  # PUBLISH, QoS 0\n        while True:\n            encoded_byte = remaining % 128\n            remaining //= 128\n            if remaining > 0:\n                encoded_byte |= 0x80\n            header.append(encoded_byte)\n            if remaining == 0:\n                break\n\n        return bytes(header) + body\n\n    @staticmethod\n    def _build_mqtt_pingreq() -> bytes:\n        \"\"\"Build an MQTT PINGREQ packet (2 bytes, no payload).\"\"\"\n        return b\"\\xc0\\x00\"\n\n    def test_rewrite_ip_in_publish(self):\n        \"\"\"IP string in PUBLISH payload is rewritten.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        payload = b'{\"rtsp_url\":\"rtsps://192.168.1.100:322/live\"}'\n        packet = self._build_mqtt_publish(\"device/status\", payload)\n\n        result, buf = TLSProxy._rewrite_mqtt_ip(packet, b\"192.168.1.100\", b\"10.0.0.1\", bytearray())\n\n        assert b\"10.0.0.1\" in result\n        assert b\"192.168.1.100\" not in result\n\n    def test_no_rewrite_when_ip_absent(self):\n        \"\"\"Packets without the target IP are passed through unchanged.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        payload = b'{\"status\":\"idle\"}'\n        packet = self._build_mqtt_publish(\"device/status\", payload)\n\n        result, buf = TLSProxy._rewrite_mqtt_ip(packet, b\"192.168.1.100\", b\"10.0.0.1\", bytearray())\n\n        assert result == packet\n\n    def test_non_publish_packets_unchanged(self):\n        \"\"\"Non-PUBLISH packets (e.g. PINGREQ) are never rewritten.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        pingreq = self._build_mqtt_pingreq()\n        result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b\"192.168.1.100\", b\"10.0.0.1\", bytearray())\n\n        assert result == pingreq\n\n    def test_rewrite_preserves_packet_framing(self):\n        \"\"\"Rewritten packet has valid MQTT remaining length.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        # Use IPs of different lengths to test length re-encoding\n        old_ip = b\"192.168.255.133\"  # 15 bytes\n        new_ip = b\"10.0.0.1\"  # 8 bytes\n\n        payload = b'{\"ip\":\"192.168.255.133\"}'\n        packet = self._build_mqtt_publish(\"device/status\", payload)\n\n        result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray())\n\n        # Parse the result to verify framing\n        assert result[0] == 0x30  # PUBLISH header byte\n        # Decode remaining length\n        pos = 1\n        remaining = 0\n        multiplier = 1\n        while True:\n            b = result[pos]\n            pos += 1\n            remaining += (b & 0x7F) * multiplier\n            multiplier *= 128\n            if (b & 0x80) == 0:\n                break\n\n        # Remaining length should match actual data\n        assert pos + remaining == len(result)\n        assert new_ip in result\n\n    def test_incomplete_packet_buffered(self):\n        \"\"\"Incomplete packet at end of chunk is buffered for next call.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        payload = b'{\"ip\":\"192.168.1.100\"}'\n        packet = self._build_mqtt_publish(\"device/status\", payload)\n\n        # Split packet in the middle\n        half = len(packet) // 2\n        chunk1 = packet[:half]\n        chunk2 = packet[half:]\n\n        result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b\"192.168.1.100\", b\"10.0.0.1\", bytearray())\n        # First chunk should be buffered (incomplete packet)\n        assert len(buf) > 0\n\n        result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b\"192.168.1.100\", b\"10.0.0.1\", buf)\n        # Second chunk completes the packet, IP should be rewritten\n        combined = result1 + result2\n        assert b\"10.0.0.1\" in combined\n        assert b\"192.168.1.100\" not in combined\n\n    def test_multiple_packets_in_one_chunk(self):\n        \"\"\"Multiple MQTT packets in a single chunk are all processed.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        payload1 = b'{\"ip\":\"192.168.1.100\"}'\n        payload2 = b'{\"other\":\"data\"}'\n        packet1 = self._build_mqtt_publish(\"topic1\", payload1)\n        packet2 = self._build_mqtt_publish(\"topic2\", payload2)\n\n        combined = packet1 + packet2\n        result, buf = TLSProxy._rewrite_mqtt_ip(combined, b\"192.168.1.100\", b\"10.0.0.1\", bytearray())\n\n        assert b\"10.0.0.1\" in result\n        assert b\"192.168.1.100\" not in result\n        # Second packet should still be present\n        assert b\"other\" in result\n\n    def test_extra_replacements(self):\n        \"\"\"Extra replacement pairs (e.g. integer IP) are also applied.\"\"\"\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        payload = b'{\"net\":{\"info\":[{\"ip\":2248124608}]}}'\n        packet = self._build_mqtt_publish(\"device/status\", payload)\n\n        result, buf = TLSProxy._rewrite_mqtt_ip(\n            packet,\n            b\"NOMATCH\",\n            b\"NOREPLACE\",\n            bytearray(),\n            extra_replacements=[(b\"2248124608\", b\"285190336\")],\n        )\n\n        assert b\"285190336\" in result\n        assert b\"2248124608\" not in result\n\n\nclass TestIpToLeIntBytes:\n    \"\"\"Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion.\"\"\"\n\n    def test_converts_ip_to_le_int(self):\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        assert TLSProxy._ip_to_le_int_bytes(\"192.168.255.133\") == b\"2248124608\"\n        assert TLSProxy._ip_to_le_int_bytes(\"192.168.255.16\") == b\"285190336\"\n        assert TLSProxy._ip_to_le_int_bytes(\"10.0.0.1\") == b\"16777226\"\n\n    def test_roundtrip(self):\n        \"\"\"Verify the integer converts back to the correct IP.\"\"\"\n        import struct\n\n        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy\n\n        for ip in [\"192.168.1.1\", \"10.0.0.1\", \"172.16.0.100\", \"192.168.255.133\"]:\n            le_int = int(TLSProxy._ip_to_le_int_bytes(ip))\n            parts = ip.split(\".\")\n            expected = struct.unpack(\"<I\", bytes(int(p) for p in parts))[0]\n            assert le_int == expected\n\n\nclass TestSSDPProxyName:\n    \"\"\"Tests for SSDPProxy VP name rewriting.\"\"\"\n\n    @pytest.fixture\n    def ssdp_proxy_with_name(self):\n        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy\n\n        return SSDPProxy(\n            local_interface_ip=\"192.168.1.100\",\n            remote_interface_ip=\"10.0.0.100\",\n            target_printer_ip=\"192.168.1.50\",\n            name=\"H2D-1 Proxy\",\n        )\n\n    @pytest.fixture\n    def ssdp_proxy_without_name(self):\n        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy\n\n        return SSDPProxy(\n            local_interface_ip=\"192.168.1.100\",\n            remote_interface_ip=\"10.0.0.100\",\n            target_printer_ip=\"192.168.1.50\",\n        )\n\n    def test_rewrite_uses_configured_name(self, ssdp_proxy_with_name):\n        \"\"\"When name is set, DevName is replaced entirely.\"\"\"\n        packet = b\"NOTIFY * HTTP/1.1\\r\\nLocation: 192.168.1.50\\r\\nDevName.bambu.com: RealPrinter\\r\\nDevBind.bambu.com: cloud\\r\\n\\r\\n\"\n        rewritten = ssdp_proxy_with_name._rewrite_ssdp(packet)\n\n        assert b\"DevName.bambu.com: H2D-1 Proxy\" in rewritten\n        assert b\"RealPrinter\" not in rewritten\n\n    def test_rewrite_appends_proxy_without_name(self, ssdp_proxy_without_name):\n        \"\"\"When no name is set, ' - Proxy' is appended to the real name.\"\"\"\n        packet = b\"NOTIFY * HTTP/1.1\\r\\nLocation: 192.168.1.50\\r\\nDevName.bambu.com: RealPrinter\\r\\nDevBind.bambu.com: cloud\\r\\n\\r\\n\"\n        rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)\n\n        assert b\"DevName.bambu.com: RealPrinter - Proxy\" in rewritten\n"
  },
  {
    "path": "backend/tests/unit/test_archive_file_path_guard.py",
    "content": "\"\"\"Tests for archive file_path guard against empty paths and directories (#475).\n\nWhen a 3mf download fails (e.g. BambuStudio-initiated prints), the fallback\narchive is created with file_path=\"\". Previously, `settings.base_dir / \"\"`\nresolved to the base directory itself, which passed `exists()` but caused\n`[Errno 21] Is a directory` when opened as a ZipFile.\n\nThe fix replaces `.exists()` with `.is_file()` across all archive endpoints,\nand adds an `archive.file_path` truthiness check for photo capture.\n\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\n\n\nclass TestIsFileGuard:\n    \"\"\"Verify that is_file() correctly rejects directories and empty paths.\"\"\"\n\n    def test_empty_path_resolves_to_parent(self, tmp_path: Path):\n        \"\"\"Path('') / '' resolves to the parent directory (which exists but is not a file).\"\"\"\n        base_dir = tmp_path / \"data\"\n        base_dir.mkdir()\n\n        file_path = base_dir / \"\"\n        # exists() returns True (the directory exists) — this was the old broken check\n        assert file_path.exists()\n        # is_file() returns False (it's a directory, not a file)\n        assert not file_path.is_file()\n\n    def test_real_file_passes_is_file(self, tmp_path: Path):\n        \"\"\"A real 3mf file passes is_file().\"\"\"\n        fake_3mf = tmp_path / \"archive\" / \"test.3mf\"\n        fake_3mf.parent.mkdir(parents=True)\n        fake_3mf.write_bytes(b\"PK\\x03\\x04\")  # ZIP magic bytes\n\n        assert fake_3mf.is_file()\n\n    def test_nonexistent_file_fails_is_file(self, tmp_path: Path):\n        \"\"\"A nonexistent path fails is_file().\"\"\"\n        missing = tmp_path / \"archive\" / \"missing.3mf\"\n        assert not missing.is_file()\n\n    def test_directory_fails_is_file(self, tmp_path: Path):\n        \"\"\"A directory path fails is_file().\"\"\"\n        dir_path = tmp_path / \"archive\"\n        dir_path.mkdir()\n        assert not dir_path.is_file()\n\n\nclass TestFallbackArchiveFilePath:\n    \"\"\"Verify that a fallback archive (file_path='') is handled safely.\"\"\"\n\n    def test_base_dir_slash_empty_string_is_base_dir(self, tmp_path: Path):\n        \"\"\"Joining base_dir with empty string produces base_dir (a directory).\"\"\"\n        base_dir = tmp_path / \"data\"\n        base_dir.mkdir()\n\n        # Simulate: file_path = settings.base_dir / archive.file_path\n        # where archive.file_path = \"\"\n        file_path = base_dir / \"\"\n\n        # The resolved path IS the directory itself\n        assert file_path.resolve() == base_dir.resolve()\n        # exists() says True (this caused the old bug)\n        assert file_path.exists()\n        # is_file() says False (this is the fix)\n        assert not file_path.is_file()\n\n    def test_archive_file_path_empty_string_is_falsy(self):\n        \"\"\"Empty string file_path is falsy (used for photo capture guard).\"\"\"\n        file_path = \"\"\n        assert not file_path\n\n    def test_archive_file_path_real_is_truthy(self):\n        \"\"\"Real file_path is truthy.\"\"\"\n        file_path = \"archive/2026/02/test.3mf\"\n        assert file_path\n\n\nclass TestPhotoPathDerivation:\n    \"\"\"Verify that photo directory derivation is safe with empty file_path.\"\"\"\n\n    def test_empty_file_path_parent_is_dot(self):\n        \"\"\"Path('').parent is '.' — would resolve to base_dir instead of archive dir.\"\"\"\n        parent = Path(\"\").parent\n        assert str(parent) == \".\"\n\n    def test_real_file_path_parent_is_archive_dir(self):\n        \"\"\"Real file_path parent gives the correct archive directory.\"\"\"\n        parent = Path(\"archive/2026/02/test.3mf\").parent\n        assert str(parent) == \"archive/2026/02\"\n"
  },
  {
    "path": "backend/tests/unit/test_archive_filtering.py",
    "content": "\"\"\"\nUnit tests for archive filtering and timelapse snapshot-diff logic.\n\nTests:\n1. Calibration print filtering — /usr/ prefix skips archive creation\n2. Timelapse snapshot-diff — _list_timelapse_videos and _scan_for_timelapse_with_retries\n\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\n# Patch paths for lazy imports inside functions\n_FTP_MODULE = \"backend.app.services.bambu_ftp\"\n\n\nclass TestCalibrationPrintFiltering:\n    \"\"\"Test that internal printer files under /usr/ are not archived.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_usr_prefix_skips_archive(self, capture_logs):\n        \"\"\"Calibration gcode (/usr/etc/print/auto_cali_for_user.gcode) should skip archiving.\"\"\"\n        with (\n            patch(\"backend.app.main.async_session\") as mock_session_maker,\n            patch(\"backend.app.main.notification_service\") as mock_notif,\n            patch(\"backend.app.main.smart_plug_manager\") as mock_plug,\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.printer_manager\") as mock_pm,\n            patch(\"backend.app.main.mqtt_relay\") as mock_relay,\n        ):\n            mock_notif.on_print_start = AsyncMock()\n            mock_plug.on_print_start = AsyncMock()\n            mock_ws.send_print_start = AsyncMock()\n            mock_relay.on_print_start = AsyncMock()\n            mock_pm.get_printer = MagicMock(return_value=MagicMock(name=\"Test\", serial_number=\"TEST123\"))\n\n            # Mock printer with auto_archive enabled\n            mock_printer = MagicMock()\n            mock_printer.auto_archive = True\n            mock_printer.id = 1\n\n            mock_session = AsyncMock()\n            mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n            mock_session.__aexit__ = AsyncMock()\n            mock_session.execute = AsyncMock(\n                return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))\n            )\n            mock_session_maker.return_value = mock_session\n\n            # Mock _send_print_start_notification\n            with patch(\"backend.app.main._send_print_start_notification\", new_callable=AsyncMock) as mock_notif_send:\n                from backend.app.main import on_print_start\n\n                await on_print_start(\n                    1,\n                    {\n                        \"filename\": \"/usr/etc/print/auto_cali_for_user.gcode\",\n                        \"subtask_name\": \"auto_cali_for_user\",\n                    },\n                )\n\n                # Notification should still be sent\n                mock_notif_send.assert_called_once()\n\n        # Verify the skip was logged\n        info_messages = [r.message for r in capture_logs.records if r.levelno >= 20]\n        skip_msgs = [m for m in info_messages if \"internal printer file\" in str(m)]\n        assert skip_msgs, \"Should log that internal printer file was skipped\"\n\n    @pytest.mark.asyncio\n    async def test_usr_prefix_various_paths(self, capture_logs):\n        \"\"\"Various /usr/ paths should all be skipped.\"\"\"\n        test_paths = [\n            \"/usr/etc/print/auto_cali_for_user.gcode\",\n            \"/usr/etc/print/some_other_calibration.gcode\",\n            \"/usr/bin/firmware_test.gcode\",\n        ]\n\n        for path in test_paths:\n            with (\n                patch(\"backend.app.main.async_session\") as mock_session_maker,\n                patch(\"backend.app.main.notification_service\") as mock_notif,\n                patch(\"backend.app.main.smart_plug_manager\") as mock_plug,\n                patch(\"backend.app.main.ws_manager\") as mock_ws,\n                patch(\"backend.app.main.printer_manager\") as mock_pm,\n                patch(\"backend.app.main.mqtt_relay\") as mock_relay,\n                patch(\"backend.app.main._send_print_start_notification\", new_callable=AsyncMock),\n            ):\n                mock_notif.on_print_start = AsyncMock()\n                mock_plug.on_print_start = AsyncMock()\n                mock_ws.send_print_start = AsyncMock()\n                mock_relay.on_print_start = AsyncMock()\n                mock_pm.get_printer = MagicMock(return_value=MagicMock(name=\"Test\", serial_number=\"TEST123\"))\n\n                mock_printer = MagicMock()\n                mock_printer.auto_archive = True\n                mock_printer.id = 1\n\n                mock_session = AsyncMock()\n                mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n                mock_session.__aexit__ = AsyncMock()\n                mock_session.execute = AsyncMock(\n                    return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))\n                )\n                mock_session_maker.return_value = mock_session\n\n                from backend.app.main import on_print_start\n\n                await on_print_start(1, {\"filename\": path, \"subtask_name\": \"test\"})\n\n            skip_msgs = [r for r in capture_logs.records if \"internal printer file\" in str(r.message)]\n            assert skip_msgs, f\"Path {path} should be skipped\"\n            capture_logs.clear()\n\n    @pytest.mark.asyncio\n    async def test_normal_gcode_not_skipped(self, capture_logs):\n        \"\"\"User gcode files under /data/ should NOT be skipped.\"\"\"\n        with (\n            patch(\"backend.app.main.async_session\") as mock_session_maker,\n            patch(\"backend.app.main.notification_service\") as mock_notif,\n            patch(\"backend.app.main.smart_plug_manager\") as mock_plug,\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.printer_manager\") as mock_pm,\n            patch(\"backend.app.main.mqtt_relay\") as mock_relay,\n        ):\n            mock_notif.on_print_start = AsyncMock()\n            mock_plug.on_print_start = AsyncMock()\n            mock_ws.send_print_start = AsyncMock()\n            mock_relay.on_print_start = AsyncMock()\n            mock_pm.get_printer = MagicMock(return_value=MagicMock(name=\"Test\", serial_number=\"TEST123\"))\n\n            mock_printer = MagicMock()\n            mock_printer.auto_archive = True\n            mock_printer.id = 1\n\n            mock_session = AsyncMock()\n            mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n            mock_session.__aexit__ = AsyncMock()\n            mock_session.execute = AsyncMock(\n                return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))\n            )\n            mock_session_maker.return_value = mock_session\n\n            from backend.app.main import on_print_start\n\n            await on_print_start(\n                1,\n                {\n                    \"filename\": \"/data/Metadata/benchy.gcode.3mf\",\n                    \"subtask_name\": \"benchy\",\n                },\n            )\n\n        # Should NOT see \"internal printer file\" skip message\n        skip_msgs = [r for r in capture_logs.records if \"internal printer file\" in str(r.message)]\n        assert not skip_msgs, \"User gcode should not be skipped\"\n\n\nclass TestListTimelapseVideos:\n    \"\"\"Test the _list_timelapse_videos helper function.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_finds_video_files_in_timelapse_dir(self):\n        \"\"\"Should return MP4 and AVI files found in /timelapse directory.\"\"\"\n        mock_printer = MagicMock()\n        mock_printer.ip_address = \"192.168.1.100\"\n        mock_printer.access_code = \"12345678\"\n        mock_printer.model = \"X1C\"\n\n        mock_files = [\n            {\"name\": \"video1.mp4\", \"is_directory\": False, \"size\": 1000, \"path\": \"/timelapse/video1.mp4\"},\n            {\"name\": \"video2.mp4\", \"is_directory\": False, \"size\": 2000, \"path\": \"/timelapse/video2.mp4\"},\n            {\"name\": \"thumbs\", \"is_directory\": True, \"size\": 0, \"path\": \"/timelapse/thumbs\"},\n            {\"name\": \"video3.avi\", \"is_directory\": False, \"size\": 500, \"path\": \"/timelapse/video3.avi\"},\n        ]\n\n        with patch(f\"{_FTP_MODULE}.list_files_async\", new_callable=AsyncMock) as mock_list:\n            mock_list.return_value = mock_files\n\n            from backend.app.main import _list_timelapse_videos\n\n            videos, path = await _list_timelapse_videos(mock_printer)\n\n        assert len(videos) == 3\n        assert path == \"/timelapse\"\n        assert all(f[\"name\"].endswith((\".mp4\", \".avi\")) for f in videos)\n\n    @pytest.mark.asyncio\n    async def test_tries_multiple_directories(self):\n        \"\"\"Should try /timelapse, /timelapse/video, /record, /recording.\"\"\"\n        mock_printer = MagicMock()\n        mock_printer.ip_address = \"192.168.1.100\"\n        mock_printer.access_code = \"12345678\"\n        mock_printer.model = \"H2D\"\n\n        async def mock_list_files(ip, code, path, printer_model=None):\n            if path == \"/record\":\n                return [{\"name\": \"clip.mp4\", \"is_directory\": False, \"size\": 500, \"path\": \"/record/clip.mp4\"}]\n            return []\n\n        with patch(f\"{_FTP_MODULE}.list_files_async\", side_effect=mock_list_files):\n            from backend.app.main import _list_timelapse_videos\n\n            mp4s, path = await _list_timelapse_videos(mock_printer)\n\n        assert len(mp4s) == 1\n        assert path == \"/record\"\n        assert mp4s[0][\"name\"] == \"clip.mp4\"\n\n    @pytest.mark.asyncio\n    async def test_returns_empty_when_no_files(self):\n        \"\"\"Should return ([], None) when no MP4 files exist.\"\"\"\n        mock_printer = MagicMock()\n        mock_printer.ip_address = \"192.168.1.100\"\n        mock_printer.access_code = \"12345678\"\n        mock_printer.model = \"X1C\"\n\n        with patch(f\"{_FTP_MODULE}.list_files_async\", new_callable=AsyncMock) as mock_list:\n            mock_list.return_value = []\n\n            from backend.app.main import _list_timelapse_videos\n\n            mp4s, path = await _list_timelapse_videos(mock_printer)\n\n        assert mp4s == []\n        assert path is None\n\n    @pytest.mark.asyncio\n    async def test_skips_directories(self):\n        \"\"\"Should filter out directory entries even if named .mp4.\"\"\"\n        mock_printer = MagicMock()\n        mock_printer.ip_address = \"192.168.1.100\"\n        mock_printer.access_code = \"12345678\"\n        mock_printer.model = \"X1C\"\n\n        mock_files = [\n            {\"name\": \"fake.mp4\", \"is_directory\": True, \"size\": 0, \"path\": \"/timelapse/fake.mp4\"},\n            {\"name\": \"real.mp4\", \"is_directory\": False, \"size\": 1000, \"path\": \"/timelapse/real.mp4\"},\n        ]\n\n        with patch(f\"{_FTP_MODULE}.list_files_async\", new_callable=AsyncMock) as mock_list:\n            mock_list.return_value = mock_files\n\n            from backend.app.main import _list_timelapse_videos\n\n            mp4s, path = await _list_timelapse_videos(mock_printer)\n\n        assert len(mp4s) == 1\n        assert mp4s[0][\"name\"] == \"real.mp4\"\n\n\nclass TestScanForTimelapseWithRetries:\n    \"\"\"Test the snapshot-diff timelapse scan logic.\"\"\"\n\n    def _make_mocks(self, archive_filename=\"benchy.gcode.3mf\", timelapse_path=None):\n        \"\"\"Create standard mock archive and printer.\"\"\"\n        mock_archive = MagicMock()\n        mock_archive.id = 1\n        mock_archive.timelapse_path = timelapse_path\n        mock_archive.printer_id = 1\n        mock_archive.filename = archive_filename\n\n        mock_printer = MagicMock()\n        mock_printer.id = 1\n        mock_printer.ip_address = \"192.168.1.100\"\n        mock_printer.access_code = \"12345678\"\n        mock_printer.model = \"X1C\"\n\n        return mock_archive, mock_printer\n\n    def _make_session_mock(self, mock_printer):\n        \"\"\"Create a mock async session that returns the given printer.\"\"\"\n        mock_session = AsyncMock()\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock()\n        mock_session.execute = AsyncMock(\n            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))\n        )\n        return mock_session\n\n    @pytest.mark.asyncio\n    async def test_detects_new_file_after_baseline(self):\n        \"\"\"Should detect a file that wasn't in the baseline snapshot.\"\"\"\n        mock_archive, mock_printer = self._make_mocks()\n\n        baseline_files = [\n            {\"name\": \"old_video.mp4\", \"is_directory\": False, \"size\": 1000, \"path\": \"/timelapse/old_video.mp4\"},\n        ]\n        new_files = baseline_files + [\n            {\"name\": \"new_video.mp4\", \"is_directory\": False, \"size\": 2000, \"path\": \"/timelapse/new_video.mp4\"},\n        ]\n\n        call_count = 0\n\n        async def mock_list_mp4s(printer):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return baseline_files, \"/timelapse\"\n            return new_files, \"/timelapse\"\n\n        mock_service = MagicMock()\n        mock_service.get_archive = AsyncMock(return_value=mock_archive)\n        mock_service.attach_timelapse = AsyncMock(return_value=True)\n        mock_session = self._make_session_mock(mock_printer)\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.main._list_timelapse_videos\", side_effect=mock_list_mp4s),\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.asyncio.sleep\", new_callable=AsyncMock),\n            patch(\"backend.app.main.ArchiveService\", return_value=mock_service),\n            patch(f\"{_FTP_MODULE}.download_file_bytes_async\", new_callable=AsyncMock) as mock_download,\n        ):\n            mock_ws.send_archive_updated = AsyncMock()\n            mock_download.return_value = b\"fake video data\"\n\n            from backend.app.main import _scan_for_timelapse_with_retries\n\n            await _scan_for_timelapse_with_retries(1)\n\n        # Should have attached the NEW file, not the old one\n        mock_service.attach_timelapse.assert_called_once()\n        attached_filename = mock_service.attach_timelapse.call_args[0][2]\n        assert attached_filename == \"new_video.mp4\", f\"Expected new_video.mp4, got {attached_filename}\"\n\n    @pytest.mark.asyncio\n    async def test_ignores_old_files_with_wrong_mtime(self):\n        \"\"\"Should not pick old files even if they'd sort first by mtime.\"\"\"\n        mock_archive, mock_printer = self._make_mocks()\n\n        # Both old files exist at baseline — neither should be picked\n        baseline_files = [\n            {\"name\": \"old_video1.mp4\", \"is_directory\": False, \"size\": 1000, \"path\": \"/timelapse/old_video1.mp4\"},\n            {\"name\": \"old_video2.mp4\", \"is_directory\": False, \"size\": 2000, \"path\": \"/timelapse/old_video2.mp4\"},\n        ]\n\n        # Always return same files — no new file ever appears\n        async def mock_list_mp4s(printer):\n            return baseline_files, \"/timelapse\"\n\n        mock_service = MagicMock()\n        mock_service.get_archive = AsyncMock(return_value=mock_archive)\n        mock_service.attach_timelapse = AsyncMock(return_value=True)\n        mock_session = self._make_session_mock(mock_printer)\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.main._list_timelapse_videos\", side_effect=mock_list_mp4s),\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.asyncio.sleep\", new_callable=AsyncMock),\n            patch(\"backend.app.main.ArchiveService\", return_value=mock_service),\n            patch(f\"{_FTP_MODULE}.download_file_bytes_async\", new_callable=AsyncMock) as mock_download,\n        ):\n            mock_ws.send_archive_updated = AsyncMock()\n            mock_download.return_value = b\"fake video data\"\n\n            from backend.app.main import _scan_for_timelapse_with_retries\n\n            await _scan_for_timelapse_with_retries(1)\n\n        # \"benchy\" not in \"old_video1.mp4\" or \"old_video2.mp4\" — no match at all\n        mock_service.attach_timelapse.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_name_match_fallback(self):\n        \"\"\"When no new file appears, should fall back to name matching.\"\"\"\n        mock_archive, mock_printer = self._make_mocks()\n\n        baseline_files = [\n            {\"name\": \"old_video.mp4\", \"is_directory\": False, \"size\": 1000, \"path\": \"/timelapse/old_video.mp4\"},\n            {\n                \"name\": \"benchy_20240101.mp4\",\n                \"is_directory\": False,\n                \"size\": 2000,\n                \"path\": \"/timelapse/benchy_20240101.mp4\",\n            },\n        ]\n\n        async def mock_list_mp4s(printer):\n            return baseline_files, \"/timelapse\"\n\n        mock_service = MagicMock()\n        mock_service.get_archive = AsyncMock(return_value=mock_archive)\n        mock_service.attach_timelapse = AsyncMock(return_value=True)\n        mock_session = self._make_session_mock(mock_printer)\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.main._list_timelapse_videos\", side_effect=mock_list_mp4s),\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.asyncio.sleep\", new_callable=AsyncMock),\n            patch(\"backend.app.main.ArchiveService\", return_value=mock_service),\n            patch(f\"{_FTP_MODULE}.download_file_bytes_async\", new_callable=AsyncMock) as mock_download,\n        ):\n            mock_ws.send_archive_updated = AsyncMock()\n            mock_download.return_value = b\"fake video data\"\n\n            from backend.app.main import _scan_for_timelapse_with_retries\n\n            await _scan_for_timelapse_with_retries(1)\n\n        # Name-match fallback: \"benchy\" is in \"benchy_20240101.mp4\"\n        mock_service.attach_timelapse.assert_called_once()\n        attached_filename = mock_service.attach_timelapse.call_args[0][2]\n        assert attached_filename == \"benchy_20240101.mp4\"\n\n    @pytest.mark.asyncio\n    async def test_stops_when_archive_already_has_timelapse(self):\n        \"\"\"Should stop immediately if archive already has a timelapse.\"\"\"\n        mock_archive, _ = self._make_mocks(timelapse_path=\"/some/existing/timelapse.mp4\")\n\n        mock_service = MagicMock()\n        mock_service.get_archive = AsyncMock(return_value=mock_archive)\n\n        mock_session = AsyncMock()\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock()\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.main._list_timelapse_videos\", new_callable=AsyncMock) as mock_list,\n            patch(\"backend.app.main.asyncio.sleep\", new_callable=AsyncMock) as mock_sleep,\n            patch(\"backend.app.main.ArchiveService\", return_value=mock_service),\n        ):\n            from backend.app.main import _scan_for_timelapse_with_retries\n\n            await _scan_for_timelapse_with_retries(1)\n\n        # Should not have tried to list files or sleep\n        mock_list.assert_not_called()\n        mock_sleep.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_stops_when_archive_not_found(self):\n        \"\"\"Should stop immediately if archive doesn't exist.\"\"\"\n        mock_service = MagicMock()\n        mock_service.get_archive = AsyncMock(return_value=None)\n\n        mock_session = AsyncMock()\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock()\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.main._list_timelapse_videos\", new_callable=AsyncMock) as mock_list,\n            patch(\"backend.app.main.asyncio.sleep\", new_callable=AsyncMock) as mock_sleep,\n            patch(\"backend.app.main.ArchiveService\", return_value=mock_service),\n        ):\n            from backend.app.main import _scan_for_timelapse_with_retries\n\n            await _scan_for_timelapse_with_retries(999)\n\n        mock_list.assert_not_called()\n        mock_sleep.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_retries_four_times(self):\n        \"\"\"Should retry with delays [5, 10, 20, 30].\"\"\"\n        mock_archive, mock_printer = self._make_mocks(archive_filename=\"test.gcode.3mf\")\n\n        # Never find any files\n        async def mock_list_mp4s(printer):\n            return [], None\n\n        mock_service = MagicMock()\n        mock_service.get_archive = AsyncMock(return_value=mock_archive)\n        mock_session = self._make_session_mock(mock_printer)\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.main._list_timelapse_videos\", side_effect=mock_list_mp4s),\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.asyncio.sleep\", new_callable=AsyncMock) as mock_sleep,\n            patch(\"backend.app.main.ArchiveService\", return_value=mock_service),\n        ):\n            mock_ws.send_archive_updated = AsyncMock()\n\n            from backend.app.main import _scan_for_timelapse_with_retries\n\n            await _scan_for_timelapse_with_retries(1)\n\n        # Should have slept 4 times with delays [5, 10, 20, 30]\n        assert mock_sleep.call_count == 4\n        sleep_args = [call.args[0] for call in mock_sleep.call_args_list]\n        assert sleep_args == [5, 10, 20, 30]\n\n\nclass TestListTimelapseVideosAvi:\n    \"\"\"Test that _list_timelapse_videos finds AVI files (P1S format).\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_finds_avi_files(self):\n        \"\"\"Should return AVI files alongside MP4 files.\"\"\"\n        mock_printer = MagicMock()\n        mock_printer.ip_address = \"192.168.1.100\"\n        mock_printer.access_code = \"12345678\"\n        mock_printer.model = \"P1S\"\n\n        mock_files = [\n            {\n                \"name\": \"video_2026-02-17_10-00-00.avi\",\n                \"is_directory\": False,\n                \"size\": 50000,\n                \"path\": \"/timelapse/video_2026-02-17_10-00-00.avi\",\n            },\n        ]\n\n        with patch(f\"{_FTP_MODULE}.list_files_async\", new_callable=AsyncMock) as mock_list:\n            mock_list.return_value = mock_files\n\n            from backend.app.main import _list_timelapse_videos\n\n            videos, path = await _list_timelapse_videos(mock_printer)\n\n        assert len(videos) == 1\n        assert videos[0][\"name\"].endswith(\".avi\")\n        assert path == \"/timelapse\"\n\n    @pytest.mark.asyncio\n    async def test_finds_avi_case_insensitive(self):\n        \"\"\"Should match .AVI (uppercase) extensions.\"\"\"\n        mock_printer = MagicMock()\n        mock_printer.ip_address = \"192.168.1.100\"\n        mock_printer.access_code = \"12345678\"\n        mock_printer.model = \"P1S\"\n\n        mock_files = [\n            {\"name\": \"VIDEO.AVI\", \"is_directory\": False, \"size\": 1000, \"path\": \"/timelapse/VIDEO.AVI\"},\n        ]\n\n        with patch(f\"{_FTP_MODULE}.list_files_async\", new_callable=AsyncMock) as mock_list:\n            mock_list.return_value = mock_files\n\n            from backend.app.main import _list_timelapse_videos\n\n            videos, path = await _list_timelapse_videos(mock_printer)\n\n        assert len(videos) == 1\n\n    @pytest.mark.asyncio\n    async def test_scan_detects_new_avi_file(self):\n        \"\"\"Snapshot-diff should detect new AVI files just like MP4.\"\"\"\n        mock_archive = MagicMock()\n        mock_archive.id = 1\n        mock_archive.timelapse_path = None\n        mock_archive.printer_id = 1\n        mock_archive.filename = \"benchy.gcode.3mf\"\n\n        mock_printer = MagicMock()\n        mock_printer.id = 1\n        mock_printer.ip_address = \"192.168.1.100\"\n        mock_printer.access_code = \"12345678\"\n        mock_printer.model = \"P1S\"\n\n        baseline_files = []\n        new_files = [\n            {\n                \"name\": \"video_2026-02-17.avi\",\n                \"is_directory\": False,\n                \"size\": 50000,\n                \"path\": \"/timelapse/video_2026-02-17.avi\",\n            },\n        ]\n\n        call_count = 0\n\n        async def mock_list_videos(printer):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return baseline_files, \"/timelapse\"\n            return new_files, \"/timelapse\"\n\n        mock_service = MagicMock()\n        mock_service.get_archive = AsyncMock(return_value=mock_archive)\n        mock_service.attach_timelapse = AsyncMock(return_value=True)\n\n        mock_session = AsyncMock()\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock()\n        mock_session.execute = AsyncMock(\n            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))\n        )\n\n        with (\n            patch(\"backend.app.main.async_session\", return_value=mock_session),\n            patch(\"backend.app.main._list_timelapse_videos\", side_effect=mock_list_videos),\n            patch(\"backend.app.main.ws_manager\") as mock_ws,\n            patch(\"backend.app.main.asyncio.sleep\", new_callable=AsyncMock),\n            patch(\"backend.app.main.ArchiveService\", return_value=mock_service),\n            patch(f\"{_FTP_MODULE}.download_file_bytes_async\", new_callable=AsyncMock) as mock_download,\n        ):\n            mock_ws.send_archive_updated = AsyncMock()\n            mock_download.return_value = b\"fake avi data\"\n\n            from backend.app.main import _scan_for_timelapse_with_retries\n\n            await _scan_for_timelapse_with_retries(1)\n\n        mock_service.attach_timelapse.assert_called_once()\n        attached_filename = mock_service.attach_timelapse.call_args[0][2]\n        assert attached_filename == \"video_2026-02-17.avi\"\n\n\nclass TestConvertTimelapseToMp4:\n    \"\"\"Test the background AVI-to-MP4 conversion.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_converts_avi_to_mp4(self, tmp_path):\n        \"\"\"Should call FFmpeg to convert and update the DB path.\"\"\"\n        source = tmp_path / \"video.avi\"\n        source.write_bytes(b\"fake avi\")\n        mp4_path = tmp_path / \"video.mp4\"\n\n        mock_process = AsyncMock()\n        mock_process.communicate = AsyncMock(return_value=(b\"\", b\"\"))\n        mock_process.returncode = 0\n\n        mock_archive = MagicMock()\n        mock_archive.id = 42\n        mock_archive.timelapse_path = \"archives/42/video.avi\"\n\n        mock_session = AsyncMock()\n        mock_session.__aenter__ = AsyncMock(return_value=mock_session)\n        mock_session.__aexit__ = AsyncMock()\n        mock_result = MagicMock()\n        mock_result.scalar_one_or_none.return_value = mock_archive\n        mock_session.execute = AsyncMock(return_value=mock_result)\n        mock_session.commit = AsyncMock()\n\n        with (\n            patch(\"backend.app.services.camera.get_ffmpeg_path\", return_value=\"/usr/bin/ffmpeg\"),\n            patch(\"backend.app.core.database.async_session\", return_value=mock_session),\n            patch(\"backend.app.services.archive.settings\") as mock_settings,\n            patch(\"asyncio.create_subprocess_exec\", new_callable=AsyncMock) as mock_exec,\n        ):\n            mock_settings.base_dir = tmp_path\n            mock_exec.return_value = mock_process\n            # Create the expected output file (as FFmpeg would)\n            mp4_path.write_bytes(b\"fake mp4 output\")\n\n            from backend.app.services.archive import _convert_timelapse_to_mp4\n\n            await _convert_timelapse_to_mp4(42, source)\n\n        # FFmpeg should have been called\n        mock_exec.assert_called_once()\n        cmd_args = mock_exec.call_args[0]\n        assert \"/usr/bin/ffmpeg\" in cmd_args\n        assert \"-threads\" in cmd_args\n        assert \"1\" in cmd_args\n\n        # DB should have been updated to .mp4 path\n        mock_session.commit.assert_called_once()\n        assert mock_archive.timelapse_path == \"video.mp4\"\n\n    @pytest.mark.asyncio\n    async def test_skips_when_no_ffmpeg(self, tmp_path):\n        \"\"\"Should log and return without converting when FFmpeg is unavailable.\"\"\"\n        source = tmp_path / \"video.avi\"\n        source.write_bytes(b\"fake avi\")\n\n        with patch(\"backend.app.services.camera.get_ffmpeg_path\", return_value=None):\n            from backend.app.services.archive import _convert_timelapse_to_mp4\n\n            await _convert_timelapse_to_mp4(1, source)\n\n        # Source file should still exist (not deleted)\n        assert source.exists()\n\n    @pytest.mark.asyncio\n    async def test_cleans_up_on_ffmpeg_failure(self, tmp_path):\n        \"\"\"Should remove partial MP4 and keep source on conversion failure.\"\"\"\n        source = tmp_path / \"video.avi\"\n        source.write_bytes(b\"fake avi\")\n        mp4_path = tmp_path / \"video.mp4\"\n\n        mock_process = AsyncMock()\n        mock_process.communicate = AsyncMock(return_value=(b\"\", b\"conversion error\"))\n        mock_process.returncode = 1\n\n        with (\n            patch(\"backend.app.services.camera.get_ffmpeg_path\", return_value=\"/usr/bin/ffmpeg\"),\n            patch(\"asyncio.create_subprocess_exec\", new_callable=AsyncMock) as mock_exec,\n        ):\n            mock_exec.return_value = mock_process\n            # Simulate partial output file\n            mp4_path.write_bytes(b\"partial\")\n\n            from backend.app.services.archive import _convert_timelapse_to_mp4\n\n            await _convert_timelapse_to_mp4(1, source)\n\n        # Partial MP4 should be cleaned up\n        assert not mp4_path.exists()\n        # Source should still exist\n        assert source.exists()\n\n\nclass TestAttachTimelapseBackgroundConversion:\n    \"\"\"Test that attach_timelapse spawns background conversion for non-MP4.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_mp4_does_not_spawn_conversion(self, tmp_path):\n        \"\"\"MP4 files should not trigger background conversion.\"\"\"\n        from backend.app.services.archive import ArchiveService\n\n        mock_archive = MagicMock()\n        mock_archive.file_path = \"archives/1/file.3mf\"\n\n        mock_db = AsyncMock()\n        service = ArchiveService(mock_db)\n        service.get_archive = AsyncMock(return_value=mock_archive)\n\n        archive_dir = tmp_path / \"archives\" / \"1\"\n        archive_dir.mkdir(parents=True)\n\n        with (\n            patch(\"backend.app.services.archive.settings\") as mock_settings,\n            patch(\"asyncio.create_task\") as mock_create_task,\n        ):\n            mock_settings.base_dir = tmp_path\n\n            result = await service.attach_timelapse(1, b\"fake mp4 data\", \"video.mp4\")\n\n        assert result is True\n        mock_create_task.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_avi_spawns_background_conversion(self, tmp_path):\n        \"\"\"AVI files should trigger background conversion task.\"\"\"\n        from backend.app.services.archive import ArchiveService\n\n        mock_archive = MagicMock()\n        mock_archive.file_path = \"archives/1/file.3mf\"\n\n        mock_db = AsyncMock()\n        service = ArchiveService(mock_db)\n        service.get_archive = AsyncMock(return_value=mock_archive)\n\n        archive_dir = tmp_path / \"archives\" / \"1\"\n        archive_dir.mkdir(parents=True)\n\n        with (\n            patch(\"backend.app.services.archive.settings\") as mock_settings,\n            patch(\"asyncio.create_task\") as mock_create_task,\n        ):\n            mock_settings.base_dir = tmp_path\n\n            result = await service.attach_timelapse(1, b\"fake avi data\", \"video.avi\")\n\n        assert result is True\n        mock_create_task.assert_called_once()\n        # Verify task name includes archive ID\n        assert \"timelapse-convert-1\" in mock_create_task.call_args[1][\"name\"]\n        # Close the unawaited coroutine to prevent GC warning\n        mock_create_task.call_args[0][0].close()\n\n\nclass TestDeleteTimelapse:\n    \"\"\"Test DELETE /archives/{id}/timelapse endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_delete_timelapse_removes_file_and_clears_db(self, tmp_path):\n        \"\"\"Deleting a timelapse should remove the file and clear the DB path.\"\"\"\n        from backend.app.api.routes.archives import delete_timelapse\n\n        timelapse_dir = tmp_path / \"archives\" / \"1\"\n        timelapse_dir.mkdir(parents=True)\n        timelapse_file = timelapse_dir / \"timelapse.mp4\"\n        timelapse_file.write_bytes(b\"fake video data\")\n\n        mock_archive = MagicMock()\n        mock_archive.timelapse_path = \"archives/1/timelapse.mp4\"\n\n        mock_db = AsyncMock()\n        mock_db.execute = AsyncMock()\n        mock_result = MagicMock()\n        mock_result.scalar_one_or_none.return_value = mock_archive\n        mock_db.execute.return_value = mock_result\n\n        with patch(\"backend.app.api.routes.archives.settings\") as mock_settings:\n            mock_settings.base_dir = tmp_path\n            result = await delete_timelapse(archive_id=1, db=mock_db)\n\n        assert result == {\"status\": \"deleted\"}\n        assert mock_archive.timelapse_path is None\n        mock_db.commit.assert_awaited_once()\n        assert not timelapse_file.exists()\n\n    @pytest.mark.asyncio\n    async def test_delete_timelapse_404_when_no_timelapse(self):\n        \"\"\"Should return 404 when archive has no timelapse attached.\"\"\"\n        from fastapi import HTTPException\n\n        from backend.app.api.routes.archives import delete_timelapse\n\n        mock_archive = MagicMock()\n        mock_archive.timelapse_path = None\n\n        mock_db = AsyncMock()\n        mock_result = MagicMock()\n        mock_result.scalar_one_or_none.return_value = mock_archive\n        mock_db.execute = AsyncMock(return_value=mock_result)\n\n        with pytest.raises(HTTPException) as exc_info:\n            await delete_timelapse(archive_id=1, db=mock_db)\n\n        assert exc_info.value.status_code == 404\n\n    @pytest.mark.asyncio\n    async def test_delete_timelapse_404_when_archive_not_found(self):\n        \"\"\"Should return 404 when archive doesn't exist.\"\"\"\n        from fastapi import HTTPException\n\n        from backend.app.api.routes.archives import delete_timelapse\n\n        mock_db = AsyncMock()\n        mock_result = MagicMock()\n        mock_result.scalar_one_or_none.return_value = None\n        mock_db.execute = AsyncMock(return_value=mock_result)\n\n        with pytest.raises(HTTPException) as exc_info:\n            await delete_timelapse(archive_id=999, db=mock_db)\n\n        assert exc_info.value.status_code == 404\n"
  },
  {
    "path": "backend/tests/unit/test_bed_jog.py",
    "content": "\"\"\"Unit tests for the bed-jog and home-axes endpoints (#791).\n\nTests:\n  POST /api/v1/printers/{printer_id}/bed-jog?distance=<mm>&force=<bool>\n  POST /api/v1/printers/{printer_id}/home-axes?axes=<z|xy|all>\n\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestBedJogAPI:\n    @pytest.mark.asyncio\n    async def test_bed_jog_not_found(self, async_client: AsyncClient):\n        response = await async_client.post(\"/api/v1/printers/99999/bed-jog?distance=10\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    async def test_bed_jog_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"P1\")\n        response = await async_client.post(f\"/api/v1/printers/{printer.id}/bed-jog?distance=0\")\n        assert response.status_code == 400\n        assert \"distance\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    async def test_bed_jog_too_large_rejected(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"P1\")\n        response = await async_client.post(f\"/api/v1/printers/{printer.id}/bed-jog?distance=500\")\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    async def test_bed_jog_not_connected(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"Disconnected\")\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/bed-jog?distance=10\")\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    async def test_bed_jog_send_failure(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"P1\")\n        mock_client = MagicMock()\n        mock_client.send_gcode.return_value = False\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/bed-jog?distance=10\")\n            assert response.status_code == 500\n\n    @pytest.mark.asyncio\n    async def test_bed_jog_success_without_force(self, async_client: AsyncClient, printer_factory):\n        \"\"\"When force=false the M211 guard lines must not be emitted.\"\"\"\n        printer = await printer_factory(name=\"P1\")\n        mock_client = MagicMock()\n        mock_client.send_gcode.return_value = True\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/bed-jog?distance=10&force=false\")\n            assert response.status_code == 200\n            sent_gcode = mock_client.send_gcode.call_args[0][0]\n            assert \"G91\" in sent_gcode\n            assert \"G1 Z10.00\" in sent_gcode\n            assert \"G90\" in sent_gcode\n            assert \"M211\" not in sent_gcode\n\n    @pytest.mark.asyncio\n    async def test_bed_jog_success_with_force(self, async_client: AsyncClient, printer_factory):\n        \"\"\"force=true must wrap the move in M211 S0 / M211 S1.\"\"\"\n        printer = await printer_factory(name=\"P1\")\n        mock_client = MagicMock()\n        mock_client.send_gcode.return_value = True\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/bed-jog?distance=-5&force=true\")\n            assert response.status_code == 200\n            sent_gcode = mock_client.send_gcode.call_args[0][0]\n            lines = sent_gcode.splitlines()\n            assert lines[0] == \"M211 S0\"\n            assert lines[-1] == \"M211 S1\"\n            assert \"G1 Z-5.00\" in sent_gcode\n\n\nclass TestHomeAxesAPI:\n    @pytest.mark.asyncio\n    async def test_home_axes_not_found(self, async_client: AsyncClient):\n        response = await async_client.post(\"/api/v1/printers/99999/home-axes?axes=z\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    async def test_home_axes_invalid(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"P1\")\n        response = await async_client.post(f\"/api/v1/printers/{printer.id}/home-axes?axes=bogus\")\n        assert response.status_code == 400\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\"axes\", [\"z\", \"xy\", \"all\"])\n    async def test_home_axes_always_runs_full_home(self, async_client: AsyncClient, printer_factory, axes):\n        # Regression for #1052: regardless of the axes argument, the endpoint must send a bare\n        # `G28` so the printer's safe auto-home sequence (toolhead park → XY home → Z home) runs.\n        # Sending `G28 Z` alone on H2C/H2D/H2S/X1 can crash the bed into the toolhead.\n        printer = await printer_factory(name=\"P1\")\n        mock_client = MagicMock()\n        mock_client.send_gcode.return_value = True\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/home-axes?axes={axes}\")\n            assert response.status_code == 200\n            mock_client.send_gcode.assert_called_once_with(\"G28\")\n\n    @pytest.mark.asyncio\n    async def test_home_axes_not_connected(self, async_client: AsyncClient, printer_factory):\n        printer = await printer_factory(name=\"D\")\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/home-axes?axes=z\")\n            assert response.status_code == 400\n"
  },
  {
    "path": "backend/tests/unit/test_bug_report.py",
    "content": "\"\"\"Unit tests for bug report service and route.\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\n\nclass TestBugReportService:\n    \"\"\"Tests for bug_report.submit_report().\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_submit_success(self):\n        \"\"\"Successful relay call saves report and returns issue details.\"\"\"\n        from backend.app.services.bug_report import submit_report\n\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"success\": True,\n            \"message\": \"Created\",\n            \"issue_url\": \"https://github.com/maziggy/bambuddy/issues/99\",\n            \"issue_number\": 99,\n        }\n\n        mock_db = AsyncMock()\n        mock_db.add = MagicMock()\n        mock_db.commit = AsyncMock()\n\n        with (\n            patch(\"backend.app.services.bug_report.httpx.AsyncClient\") as mock_client_cls,\n            patch(\"backend.app.services.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.services.bug_report._rate_limit_timestamps\", []),\n            patch(\"backend.app.services.bug_report.BUG_REPORT_RELAY_URL\", \"https://example.com/api/bug-report\"),\n        ):\n            mock_client = AsyncMock()\n            mock_client.post = AsyncMock(return_value=mock_response)\n            mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client.__aexit__ = AsyncMock(return_value=False)\n            mock_client_cls.return_value = mock_client\n\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await submit_report(\n                description=\"Test bug\",\n                reporter_email=\"user@test.com\",\n                screenshot_base64=None,\n                support_info=None,\n            )\n\n        assert result[\"success\"] is True\n        assert result[\"issue_number\"] == 99\n        assert result[\"issue_url\"] == \"https://github.com/maziggy/bambuddy/issues/99\"\n        mock_db.add.assert_called_once()\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_submit_rate_limited(self):\n        \"\"\"Returns failure when rate limit exceeded.\"\"\"\n        import time\n\n        from backend.app.services.bug_report import submit_report\n\n        timestamps = [time.time()] * 5  # Already at limit\n\n        with patch(\"backend.app.services.bug_report._rate_limit_timestamps\", timestamps):\n            result = await submit_report(\n                description=\"Test\",\n                reporter_email=None,\n                screenshot_base64=None,\n                support_info=None,\n            )\n\n        assert result[\"success\"] is False\n        assert \"Rate limit\" in result[\"message\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_submit_no_relay_url(self):\n        \"\"\"Returns failure when relay URL is not configured.\"\"\"\n        from backend.app.services.bug_report import submit_report\n\n        with (\n            patch(\"backend.app.services.bug_report._rate_limit_timestamps\", []),\n            patch(\"backend.app.services.bug_report.BUG_REPORT_RELAY_URL\", \"\"),\n        ):\n            result = await submit_report(\n                description=\"Test\",\n                reporter_email=None,\n                screenshot_base64=None,\n                support_info=None,\n            )\n\n        assert result[\"success\"] is False\n        assert \"not configured\" in result[\"message\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_submit_relay_http_error(self):\n        \"\"\"Non-200 relay response saves failed report.\"\"\"\n        from backend.app.services.bug_report import submit_report\n\n        mock_response = MagicMock()\n        mock_response.status_code = 500\n\n        mock_db = AsyncMock()\n        mock_db.add = MagicMock()\n        mock_db.commit = AsyncMock()\n\n        with (\n            patch(\"backend.app.services.bug_report.httpx.AsyncClient\") as mock_client_cls,\n            patch(\"backend.app.services.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.services.bug_report._rate_limit_timestamps\", []),\n            patch(\"backend.app.services.bug_report.BUG_REPORT_RELAY_URL\", \"https://example.com/api/bug-report\"),\n        ):\n            mock_client = AsyncMock()\n            mock_client.post = AsyncMock(return_value=mock_response)\n            mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client.__aexit__ = AsyncMock(return_value=False)\n            mock_client_cls.return_value = mock_client\n\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await submit_report(\n                description=\"Test\",\n                reporter_email=None,\n                screenshot_base64=None,\n                support_info=None,\n            )\n\n        assert result[\"success\"] is False\n        assert \"not available\" in result[\"message\"]\n        mock_db.add.assert_called_once()\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_submit_relay_connection_error(self):\n        \"\"\"Connection failure saves failed report.\"\"\"\n        from backend.app.services.bug_report import submit_report\n\n        mock_db = AsyncMock()\n        mock_db.add = MagicMock()\n        mock_db.commit = AsyncMock()\n\n        with (\n            patch(\"backend.app.services.bug_report.httpx.AsyncClient\") as mock_client_cls,\n            patch(\"backend.app.services.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.services.bug_report._rate_limit_timestamps\", []),\n            patch(\"backend.app.services.bug_report.BUG_REPORT_RELAY_URL\", \"https://example.com/api/bug-report\"),\n        ):\n            mock_client = AsyncMock()\n            mock_client.post = AsyncMock(side_effect=ConnectionError(\"Connection refused\"))\n            mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client.__aexit__ = AsyncMock(return_value=False)\n            mock_client_cls.return_value = mock_client\n\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await submit_report(\n                description=\"Test\",\n                reporter_email=None,\n                screenshot_base64=None,\n                support_info=None,\n            )\n\n        assert result[\"success\"] is False\n        assert \"Failed to submit\" in result[\"message\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_submit_relay_failure_response(self):\n        \"\"\"Relay returns success=false in JSON body.\"\"\"\n        from backend.app.services.bug_report import submit_report\n\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"success\": False,\n            \"message\": \"GitHub API error\",\n        }\n\n        mock_db = AsyncMock()\n        mock_db.add = MagicMock()\n        mock_db.commit = AsyncMock()\n\n        with (\n            patch(\"backend.app.services.bug_report.httpx.AsyncClient\") as mock_client_cls,\n            patch(\"backend.app.services.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.services.bug_report._rate_limit_timestamps\", []),\n            patch(\"backend.app.services.bug_report.BUG_REPORT_RELAY_URL\", \"https://example.com/api/bug-report\"),\n        ):\n            mock_client = AsyncMock()\n            mock_client.post = AsyncMock(return_value=mock_response)\n            mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n            mock_client.__aexit__ = AsyncMock(return_value=False)\n            mock_client_cls.return_value = mock_client\n\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await submit_report(\n                description=\"Test\",\n                reporter_email=None,\n                screenshot_base64=None,\n                support_info=None,\n            )\n\n        assert result[\"success\"] is False\n        assert \"GitHub API error\" in result[\"message\"]\n\n\nclass TestStartLogging:\n    \"\"\"Tests for the start-logging endpoint handler.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_enables_debug_when_not_already_enabled(self):\n        \"\"\"Debug logging is enabled and printers are pushed.\"\"\"\n        from backend.app.api.routes.bug_report import start_logging\n\n        apply_calls = []\n        mock_db = AsyncMock()\n\n        with (\n            patch(\"backend.app.api.routes.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.api.routes.bug_report._get_debug_setting\", return_value=(False, None)),\n            patch(\"backend.app.api.routes.bug_report._set_debug_setting\", new_callable=AsyncMock) as mock_set,\n            patch(\n                \"backend.app.api.routes.bug_report._apply_log_level\",\n                side_effect=lambda v: apply_calls.append(v),\n            ),\n            patch(\"backend.app.api.routes.bug_report.printer_manager\") as mock_pm,\n        ):\n            mock_pm._clients = {\"printer1\": MagicMock()}\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await start_logging()\n\n        assert result.started is True\n        assert result.was_debug is False\n        assert apply_calls == [True]\n        mock_set.assert_called_once()\n        mock_pm.request_status_update.assert_called_once_with(\"printer1\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_skips_enable_when_already_debug(self):\n        \"\"\"Debug logging not toggled when already enabled.\"\"\"\n        mock_db = AsyncMock()\n\n        from backend.app.api.routes.bug_report import start_logging\n\n        with (\n            patch(\"backend.app.api.routes.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.api.routes.bug_report._get_debug_setting\", return_value=(True, None)),\n            patch(\"backend.app.api.routes.bug_report._set_debug_setting\", new_callable=AsyncMock) as mock_set,\n            patch(\"backend.app.api.routes.bug_report._apply_log_level\") as mock_apply,\n            patch(\"backend.app.api.routes.bug_report.printer_manager\") as mock_pm,\n        ):\n            mock_pm._clients = {}\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await start_logging()\n\n        assert result.started is True\n        assert result.was_debug is True\n        mock_apply.assert_not_called()\n        mock_set.assert_not_called()\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_pushes_all_connected_printers(self):\n        \"\"\"Sends status update request to all connected printers.\"\"\"\n        mock_db = AsyncMock()\n\n        from backend.app.api.routes.bug_report import start_logging\n\n        with (\n            patch(\"backend.app.api.routes.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.api.routes.bug_report._get_debug_setting\", return_value=(True, None)),\n            patch(\"backend.app.api.routes.bug_report._set_debug_setting\", new_callable=AsyncMock),\n            patch(\"backend.app.api.routes.bug_report._apply_log_level\"),\n            patch(\"backend.app.api.routes.bug_report.printer_manager\") as mock_pm,\n        ):\n            mock_pm._clients = {\"printer1\": MagicMock(), \"printer2\": MagicMock()}\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            await start_logging()\n\n        assert mock_pm.request_status_update.call_count == 2\n\n\nclass TestStopLogging:\n    \"\"\"Tests for the stop-logging endpoint handler.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_collects_logs_and_restores_level(self):\n        \"\"\"Collects logs and restores log level when was_debug=False.\"\"\"\n        from backend.app.api.routes.bug_report import stop_logging\n\n        apply_calls = []\n        mock_db = AsyncMock()\n\n        with (\n            patch(\"backend.app.api.routes.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.api.routes.bug_report._set_debug_setting\", new_callable=AsyncMock) as mock_set,\n            patch(\n                \"backend.app.api.routes.bug_report._apply_log_level\",\n                side_effect=lambda v: apply_calls.append(v),\n            ),\n            patch(\"backend.app.api.routes.bug_report._get_recent_sanitized_logs\", return_value=\"DEBUG log line\"),\n        ):\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await stop_logging(was_debug=False)\n\n        assert result.logs == \"DEBUG log line\"\n        assert apply_calls == [False]\n        mock_set.assert_called_once()\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_skips_restore_when_was_debug(self):\n        \"\"\"Does not restore log level when was_debug=True.\"\"\"\n        from backend.app.api.routes.bug_report import stop_logging\n\n        with (\n            patch(\"backend.app.api.routes.bug_report.async_session\") as mock_session,\n            patch(\"backend.app.api.routes.bug_report._set_debug_setting\", new_callable=AsyncMock) as mock_set,\n            patch(\"backend.app.api.routes.bug_report._apply_log_level\") as mock_apply,\n            patch(\"backend.app.api.routes.bug_report._get_recent_sanitized_logs\", return_value=\"logs\"),\n        ):\n            mock_db = AsyncMock()\n            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await stop_logging(was_debug=True)\n\n        assert result.logs == \"logs\"\n        mock_apply.assert_not_called()\n        mock_set.assert_not_called()\n\n\nclass TestSubmitBugReportRoute:\n    \"\"\"Tests for the submit_bug_report route handler.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_uses_provided_debug_logs(self):\n        \"\"\"When debug_logs is provided, it is used as recent_logs.\"\"\"\n        from backend.app.api.routes.bug_report import BugReportRequest, submit_bug_report\n\n        report = BugReportRequest(\n            description=\"Test bug\",\n            debug_logs=\"pre-collected debug logs\",\n        )\n\n        with (\n            patch(\"backend.app.api.routes.bug_report._collect_support_info\", return_value={\"version\": \"1.0\"}),\n            patch(\"backend.app.api.routes.bug_report.submit_report\", new_callable=AsyncMock) as mock_submit,\n        ):\n            mock_submit.return_value = {\n                \"success\": True,\n                \"message\": \"Created\",\n                \"issue_url\": \"https://github.com/maziggy/bambuddy/issues/1\",\n                \"issue_number\": 1,\n            }\n\n            result = await submit_bug_report(report)\n\n        assert result.success is True\n        call_kwargs = mock_submit.call_args[1]\n        assert call_kwargs[\"support_info\"][\"recent_logs\"] == \"pre-collected debug logs\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_no_logs_when_debug_logs_not_provided(self):\n        \"\"\"When debug_logs is None, recent_logs is not added.\"\"\"\n        from backend.app.api.routes.bug_report import BugReportRequest, submit_bug_report\n\n        report = BugReportRequest(description=\"Test bug\")\n\n        with (\n            patch(\"backend.app.api.routes.bug_report._collect_support_info\", return_value={\"version\": \"1.0\"}),\n            patch(\"backend.app.api.routes.bug_report.submit_report\", new_callable=AsyncMock) as mock_submit,\n        ):\n            mock_submit.return_value = {\n                \"success\": True,\n                \"message\": \"Created\",\n                \"issue_url\": None,\n                \"issue_number\": None,\n            }\n\n            await submit_bug_report(report)\n\n        call_kwargs = mock_submit.call_args[1]\n        assert \"recent_logs\" not in call_kwargs[\"support_info\"]\n\n\nclass TestRateLimit:\n    \"\"\"Tests for rate limiting in bug report service.\"\"\"\n\n    def test_check_rate_limit_allows_first(self):\n        \"\"\"First request within window is allowed.\"\"\"\n        from backend.app.services.bug_report import _check_rate_limit\n\n        with patch(\"backend.app.services.bug_report._rate_limit_timestamps\", []):\n            assert _check_rate_limit() is True\n\n    def test_check_rate_limit_blocks_at_max(self):\n        \"\"\"Requests at max limit are blocked.\"\"\"\n        import time\n\n        from backend.app.services.bug_report import _check_rate_limit\n\n        now = time.time()\n        timestamps = [now] * 5\n\n        with patch(\"backend.app.services.bug_report._rate_limit_timestamps\", timestamps):\n            assert _check_rate_limit() is False\n\n    def test_check_rate_limit_clears_old(self):\n        \"\"\"Old timestamps outside window are cleared.\"\"\"\n        import time\n\n        from backend.app.services.bug_report import _check_rate_limit\n\n        old_time = time.time() - 7200  # 2 hours ago\n        timestamps = [old_time] * 5\n\n        with patch(\"backend.app.services.bug_report._rate_limit_timestamps\", timestamps):\n            assert _check_rate_limit() is True\n"
  },
  {
    "path": "backend/tests/unit/test_bulk_spool_create.py",
    "content": "\"\"\"Unit tests for bulk spool creation.\n\nTests:\n- SpoolBulkCreate schema validation (quantity bounds)\n- Bulk create endpoint creates the requested number of spools\n- Bulk create with quantity=1 (single spool)\n- Bulk create returns spools with k_profiles loaded\n\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock\n\nimport pytest\nfrom pydantic import ValidationError\n\nfrom backend.app.schemas.spool import SpoolBulkCreate, SpoolCreate\n\n# ── Schema Validation ──────────────────────────────────────────────────────\n\n\nclass TestSpoolBulkCreateSchema:\n    \"\"\"Tests for the SpoolBulkCreate Pydantic model.\"\"\"\n\n    def test_default_quantity_is_1(self):\n        data = SpoolBulkCreate(spool=SpoolCreate(material=\"PLA\"))\n        assert data.quantity == 1\n\n    def test_quantity_within_range(self):\n        data = SpoolBulkCreate(spool=SpoolCreate(material=\"PLA\"), quantity=50)\n        assert data.quantity == 50\n\n    def test_quantity_max_100(self):\n        data = SpoolBulkCreate(spool=SpoolCreate(material=\"PLA\"), quantity=100)\n        assert data.quantity == 100\n\n    def test_quantity_zero_rejected(self):\n        with pytest.raises(ValidationError, match=\"greater than or equal to 1\"):\n            SpoolBulkCreate(spool=SpoolCreate(material=\"PLA\"), quantity=0)\n\n    def test_quantity_negative_rejected(self):\n        with pytest.raises(ValidationError, match=\"greater than or equal to 1\"):\n            SpoolBulkCreate(spool=SpoolCreate(material=\"PLA\"), quantity=-1)\n\n    def test_quantity_over_100_rejected(self):\n        with pytest.raises(ValidationError, match=\"less than or equal to 100\"):\n            SpoolBulkCreate(spool=SpoolCreate(material=\"PLA\"), quantity=101)\n\n    def test_spool_fields_preserved(self):\n        data = SpoolBulkCreate(\n            spool=SpoolCreate(\n                material=\"PETG\",\n                brand=\"Polymaker\",\n                subtype=\"Basic\",\n                color_name=\"Red\",\n                rgba=\"FF0000FF\",\n                label_weight=750,\n                note=\"Test batch\",\n            ),\n            quantity=5,\n        )\n        assert data.spool.material == \"PETG\"\n        assert data.spool.brand == \"Polymaker\"\n        assert data.spool.label_weight == 750\n        assert data.spool.note == \"Test batch\"\n        assert data.quantity == 5\n\n    def test_spool_without_slicer_filament_is_stock(self):\n        \"\"\"A spool without slicer_filament is a 'stock' spool (computed, not stored).\"\"\"\n        data = SpoolBulkCreate(\n            spool=SpoolCreate(material=\"PLA\", label_weight=1000),\n            quantity=3,\n        )\n        assert data.spool.slicer_filament is None\n\n    def test_spool_with_slicer_filament_is_configured(self):\n        data = SpoolBulkCreate(\n            spool=SpoolCreate(material=\"PLA\", slicer_filament=\"GFL99\"),\n            quantity=2,\n        )\n        assert data.spool.slicer_filament == \"GFL99\"\n\n    def test_material_required(self):\n        with pytest.raises(ValidationError):\n            SpoolBulkCreate(spool=SpoolCreate(material=\"\"), quantity=1)\n\n\n# ── Endpoint Logic ─────────────────────────────────────────────────────────\n\n\ndef _make_mock_spool(spool_id):\n    \"\"\"Create a mock Spool ORM object.\"\"\"\n    spool = MagicMock()\n    spool.id = spool_id\n    spool.material = \"PLA\"\n    spool.label_weight = 1000\n    spool.k_profiles = []\n    return spool\n\n\nclass TestBulkCreateEndpoint:\n    \"\"\"Tests for the bulk_create_spools endpoint logic.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_creates_requested_number_of_spools(self):\n        \"\"\"Verify N spools are created and added to the session.\"\"\"\n        from backend.app.api.routes.inventory import bulk_create_spools\n\n        data = SpoolBulkCreate(\n            spool=SpoolCreate(material=\"PLA\", brand=\"Test\", label_weight=1000),\n            quantity=3,\n        )\n\n        db = AsyncMock()\n        added_objects = []\n        db.add = lambda obj: added_objects.append(obj)\n\n        # Mock the re-fetch query\n        mock_result = MagicMock()\n        mock_spools = [_make_mock_spool(i + 1) for i in range(3)]\n        mock_result.scalars.return_value.all.return_value = mock_spools\n        db.execute = AsyncMock(return_value=mock_result)\n\n        result = await bulk_create_spools(data=data, db=db, _=None)\n\n        assert len(result) == 3\n        assert len(added_objects) == 3\n        db.commit.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_single_quantity_creates_one_spool(self):\n        \"\"\"Bulk create with quantity=1 should create exactly one spool.\"\"\"\n        from backend.app.api.routes.inventory import bulk_create_spools\n\n        data = SpoolBulkCreate(\n            spool=SpoolCreate(material=\"PETG\"),\n            quantity=1,\n        )\n\n        db = AsyncMock()\n        added_objects = []\n        db.add = lambda obj: added_objects.append(obj)\n\n        mock_result = MagicMock()\n        mock_spools = [_make_mock_spool(1)]\n        mock_result.scalars.return_value.all.return_value = mock_spools\n        db.execute = AsyncMock(return_value=mock_result)\n\n        result = await bulk_create_spools(data=data, db=db, _=None)\n\n        assert len(result) == 1\n        assert len(added_objects) == 1\n\n    @pytest.mark.asyncio\n    async def test_all_spools_have_same_fields(self):\n        \"\"\"All created spools should have identical field values.\"\"\"\n        from backend.app.api.routes.inventory import bulk_create_spools\n\n        data = SpoolBulkCreate(\n            spool=SpoolCreate(\n                material=\"ABS\",\n                brand=\"Bambu Lab\",\n                color_name=\"Black\",\n                rgba=\"000000FF\",\n                label_weight=750,\n            ),\n            quantity=3,\n        )\n\n        db = AsyncMock()\n        added_objects = []\n        db.add = lambda obj: added_objects.append(obj)\n\n        mock_result = MagicMock()\n        mock_spools = [_make_mock_spool(i + 1) for i in range(3)]\n        mock_result.scalars.return_value.all.return_value = mock_spools\n        db.execute = AsyncMock(return_value=mock_result)\n\n        await bulk_create_spools(data=data, db=db, _=None)\n\n        # All added Spool objects should have the same material/brand/color\n        for spool_obj in added_objects:\n            assert spool_obj.material == \"ABS\"\n            assert spool_obj.brand == \"Bambu Lab\"\n            assert spool_obj.color_name == \"Black\"\n            assert spool_obj.label_weight == 750\n"
  },
  {
    "path": "backend/tests/unit/test_camera_stderr_summary.py",
    "content": "\"\"\"Tests for _summarize_ffmpeg_stderr (#925).\n\nThe ffmpeg banner (version / build / configuration / lib*) dumps ~20 lines\nbefore any actual error. Before this fix, every failed camera retry logged\nthe full banner, producing hundreds of lines per failure — see #925 where a\nsingle click produced 555 lines across 30 retries. The helper strips the\nbanner so logs stay focused on the real error.\n\"\"\"\n\nfrom backend.app.api.routes.camera import _summarize_ffmpeg_stderr\n\n_FAKE_BANNER = \"\"\"ffmpeg version 7.1.3-0+deb13u1 Copyright (c) 2000-2025 the FFmpeg developers\n  built with gcc 14 (Debian 14.2.0-19)\n  configuration: --prefix=/usr --extra-version=0+deb13u1 --toolchain=hardened --enable-gpl --enable-gnutls\n  libavutil      59. 39.100 / 59. 39.100\n  libavcodec     61. 19.101 / 61. 19.101\n  libavformat    61.  7.100 / 61.  7.100\n  libavdevice    61.  3.100 / 61.  3.100\n  libavfilter    10.  4.100 / 10.  4.100\n  libswscale      8.  3.100 /  8.  3.100\n  libswresample   5.  3.100 /  5.  3.100\n  libpostproc    58.  3.100 / 58.  3.100\n\"\"\"\n\n\ndef test_empty_input():\n    assert _summarize_ffmpeg_stderr(\"\") == \"\"\n    assert _summarize_ffmpeg_stderr(None) == \"\"\n\n\ndef test_keeps_error_lines_drops_banner():\n    stderr = _FAKE_BANNER + (\n        \"[in#0 @ 0x64a7cd6350c0] Error opening input: Invalid data found when processing input\\n\"\n        \"Error opening input file rtsp://[CREDENTIALS]@192.0.2.1:322/streaming/live/1.\\n\"\n        \"Error opening input files: Invalid data found when processing input\\n\"\n    )\n    result = _summarize_ffmpeg_stderr(stderr)\n\n    # Banner gone\n    assert \"ffmpeg version\" not in result\n    assert \"configuration:\" not in result\n    assert \"libavcodec\" not in result\n\n    # Real errors preserved\n    assert \"Error opening input: Invalid data found when processing input\" in result\n    assert \"Error opening input file rtsp\" in result\n\n\ndef test_caps_at_10_lines():\n    stderr = _FAKE_BANNER + \"\\n\".join(f\"error line {i}\" for i in range(25))\n    result = _summarize_ffmpeg_stderr(stderr)\n\n    lines = result.splitlines()\n    assert len(lines) == 10\n    # Keeps the *last* 10 lines (most recent errors closest to failure)\n    assert lines[-1] == \"error line 24\"\n    assert lines[0] == \"error line 15\"\n\n\ndef test_drops_blank_lines():\n    stderr = \"real error\\n\\n\\n   \\nsecond error\\n\"\n    result = _summarize_ffmpeg_stderr(stderr)\n    assert result == \"real error\\nsecond error\"\n\n\ndef test_banner_only_returns_empty():\n    \"\"\"If ffmpeg prints only the banner (no errors), the summary should be empty.\"\"\"\n    assert _summarize_ffmpeg_stderr(_FAKE_BANNER) == \"\"\n"
  },
  {
    "path": "backend/tests/unit/test_capture_pid_tracking.py",
    "content": "\"\"\"Tests for capture PID tracking and cleanup exclusion (#172).\n\nThe Obico detection service spawns short-lived ffmpeg processes for snapshot\ncapture via capture_camera_frame_bytes(). These must be registered in\n_active_capture_pids so the cleanup task in routes/camera.py does not kill\nthem as orphaned.\n\"\"\"\n\nimport asyncio\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.camera import (\n    _active_capture_pids,\n    capture_camera_frame_bytes,\n)\n\n\n@pytest.fixture(autouse=True)\ndef _clear_capture_pids():\n    \"\"\"Ensure _active_capture_pids is empty before/after each test.\"\"\"\n    _active_capture_pids.clear()\n    yield\n    _active_capture_pids.clear()\n\n\nclass TestCapturePidRegistration:\n    \"\"\"Verify PIDs are added/removed from _active_capture_pids.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_pid_registered_during_capture(self):\n        \"\"\"PID is in _active_capture_pids while ffmpeg is running.\"\"\"\n        observed_pids_during_run: set[int] = set()\n\n        fake_process = MagicMock()\n        fake_process.pid = 99999\n        fake_process.returncode = 0\n\n        async def fake_communicate():\n            # Snapshot what's in the set while \"ffmpeg is running\"\n            observed_pids_during_run.update(_active_capture_pids)\n            return (b\"\\xff\\xd8\" + b\"\\x00\" * 200 + b\"\\xff\\xd9\", b\"\")\n\n        fake_process.communicate = fake_communicate\n\n        fake_proxy_server = AsyncMock()\n        fake_proxy_server.close = MagicMock()\n\n        with (\n            patch(\"backend.app.services.camera.is_chamber_image_model\", return_value=False),\n            patch(\"backend.app.services.camera.get_camera_port\", return_value=322),\n            patch(\"backend.app.services.camera.create_tls_proxy\", return_value=(12345, fake_proxy_server)),\n            patch(\"backend.app.services.camera.get_ffmpeg_path\", return_value=\"/usr/bin/ffmpeg\"),\n            patch(\"asyncio.create_subprocess_exec\", return_value=fake_process),\n        ):\n            result = await capture_camera_frame_bytes(\"192.168.1.1\", \"test\", \"P2S\", timeout=10)\n\n        # PID was registered during capture\n        assert 99999 in observed_pids_during_run\n        # PID is removed after capture completes\n        assert 99999 not in _active_capture_pids\n        # Capture returned data\n        assert result is not None\n\n    @pytest.mark.asyncio\n    async def test_pid_removed_after_failure(self):\n        \"\"\"PID is cleaned up even when ffmpeg returns non-zero.\"\"\"\n        fake_process = MagicMock()\n        fake_process.pid = 88888\n        fake_process.returncode = 1\n\n        async def fake_communicate():\n            return (b\"\", b\"some error\")\n\n        fake_process.communicate = fake_communicate\n\n        fake_proxy_server = AsyncMock()\n        fake_proxy_server.close = MagicMock()\n\n        with (\n            patch(\"backend.app.services.camera.is_chamber_image_model\", return_value=False),\n            patch(\"backend.app.services.camera.get_camera_port\", return_value=322),\n            patch(\"backend.app.services.camera.create_tls_proxy\", return_value=(12345, fake_proxy_server)),\n            patch(\"backend.app.services.camera.get_ffmpeg_path\", return_value=\"/usr/bin/ffmpeg\"),\n            patch(\"asyncio.create_subprocess_exec\", return_value=fake_process),\n        ):\n            result = await capture_camera_frame_bytes(\"192.168.1.1\", \"test\", \"P2S\", timeout=10)\n\n        assert result is None\n        assert 88888 not in _active_capture_pids\n\n    @pytest.mark.asyncio\n    async def test_pid_removed_after_timeout(self):\n        \"\"\"PID is cleaned up when ffmpeg times out.\"\"\"\n        fake_process = MagicMock()\n        fake_process.pid = 77777\n        fake_process.returncode = None\n        fake_process.kill = MagicMock()\n\n        async def fake_communicate():\n            await asyncio.sleep(60)  # Will be cancelled by wait_for\n            return (b\"\", b\"\")\n\n        fake_process.communicate = fake_communicate\n\n        async def fake_wait():\n            fake_process.returncode = -9\n\n        fake_process.wait = fake_wait\n\n        fake_proxy_server = AsyncMock()\n        fake_proxy_server.close = MagicMock()\n\n        with (\n            patch(\"backend.app.services.camera.is_chamber_image_model\", return_value=False),\n            patch(\"backend.app.services.camera.get_camera_port\", return_value=322),\n            patch(\"backend.app.services.camera.create_tls_proxy\", return_value=(12345, fake_proxy_server)),\n            patch(\"backend.app.services.camera.get_ffmpeg_path\", return_value=\"/usr/bin/ffmpeg\"),\n            patch(\"asyncio.create_subprocess_exec\", return_value=fake_process),\n        ):\n            result = await capture_camera_frame_bytes(\"192.168.1.1\", \"test\", \"P2S\", timeout=0.01)\n\n        assert result is None\n        assert 77777 not in _active_capture_pids\n\n    @pytest.mark.asyncio\n    async def test_no_pid_tracked_for_chamber_image_models(self):\n        \"\"\"Chamber image models (A1/P1) don't spawn ffmpeg — no PID tracking.\"\"\"\n        with (\n            patch(\"backend.app.services.camera.is_chamber_image_model\", return_value=True),\n            patch(\"backend.app.services.camera.read_chamber_image_frame\", return_value=b\"\\xff\\xd8test\\xff\\xd9\"),\n        ):\n            result = await capture_camera_frame_bytes(\"192.168.1.1\", \"test\", \"A1\", timeout=10)\n\n        assert result is not None\n        assert len(_active_capture_pids) == 0\n\n    @pytest.mark.asyncio\n    async def test_no_pid_tracked_when_subprocess_fails(self):\n        \"\"\"If create_subprocess_exec raises, process is None — no PID to track.\"\"\"\n        fake_proxy_server = AsyncMock()\n        fake_proxy_server.close = MagicMock()\n\n        with (\n            patch(\"backend.app.services.camera.is_chamber_image_model\", return_value=False),\n            patch(\"backend.app.services.camera.get_camera_port\", return_value=322),\n            patch(\"backend.app.services.camera.create_tls_proxy\", return_value=(12345, fake_proxy_server)),\n            patch(\"backend.app.services.camera.get_ffmpeg_path\", return_value=\"/usr/bin/ffmpeg\"),\n            patch(\"asyncio.create_subprocess_exec\", side_effect=FileNotFoundError(\"ffmpeg\")),\n        ):\n            result = await capture_camera_frame_bytes(\"192.168.1.1\", \"test\", \"P2S\", timeout=10)\n\n        assert result is None\n        assert len(_active_capture_pids) == 0\n\n\nclass TestCleanupExcludesCapturePids:\n    \"\"\"Verify cleanup_orphaned_streams skips PIDs in _active_capture_pids.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_cleanup_skips_capture_pids(self):\n        \"\"\"A PID in _active_capture_pids must not be killed by cleanup.\"\"\"\n        from backend.app.api.routes.camera import cleanup_orphaned_streams\n\n        _active_capture_pids.add(42000)\n\n        with (\n            patch(\"backend.app.api.routes.camera._scan_bambu_ffmpeg_pids\", return_value=[42000]),\n            patch(\"backend.app.api.routes.camera._active_streams\", {}),\n            patch(\"backend.app.api.routes.camera._spawned_ffmpeg_pids\", {}),\n            patch(\"os.kill\") as mock_kill,\n        ):\n            await cleanup_orphaned_streams()\n\n        # os.kill should NOT have been called with SIGKILL for our capture PID\n        for call in mock_kill.call_args_list:\n            pid, sig = call[0]\n            assert pid != 42000, \"cleanup killed an active capture PID\"\n\n    @pytest.mark.asyncio\n    async def test_cleanup_kills_non_capture_pids(self):\n        \"\"\"PIDs NOT in _active_capture_pids should still be killed.\"\"\"\n        import signal\n\n        from backend.app.api.routes.camera import cleanup_orphaned_streams\n\n        # 42000 is a capture PID, 43000 is truly orphaned\n        _active_capture_pids.add(42000)\n\n        with (\n            patch(\"backend.app.api.routes.camera._scan_bambu_ffmpeg_pids\", return_value=[42000, 43000]),\n            patch(\"backend.app.api.routes.camera._active_streams\", {}),\n            patch(\"backend.app.api.routes.camera._spawned_ffmpeg_pids\", {}),\n            patch(\"os.kill\") as mock_kill,\n        ):\n            await cleanup_orphaned_streams()\n\n        # 43000 should have been killed\n        mock_kill.assert_any_call(43000, signal.SIGKILL)\n\n        # 42000 should NOT have been killed with SIGKILL\n        killed_pids = [call[0][0] for call in mock_kill.call_args_list if call[0][1] == signal.SIGKILL]\n        assert 42000 not in killed_pids\n"
  },
  {
    "path": "backend/tests/unit/test_catalog_bulk_delete.py",
    "content": "\"\"\"Unit tests for catalog bulk delete endpoints.\"\"\"\n\nimport pytest\nfrom pydantic import ValidationError\n\nfrom backend.app.api.routes.inventory import BulkDeleteIdsRequest\n\n\nclass TestBulkDeleteIdsRequest:\n    \"\"\"Tests for BulkDeleteIdsRequest schema.\"\"\"\n\n    def test_accepts_list_of_ids(self):\n        req = BulkDeleteIdsRequest(ids=[1, 2, 3])\n        assert req.ids == [1, 2, 3]\n\n    def test_accepts_empty_list(self):\n        req = BulkDeleteIdsRequest(ids=[])\n        assert req.ids == []\n\n    def test_rejects_missing_ids(self):\n        with pytest.raises(ValidationError):\n            BulkDeleteIdsRequest()\n"
  },
  {
    "path": "backend/tests/unit/test_cli.py",
    "content": "\"\"\"Unit tests for the ``backend.app.cli`` kiosk-bootstrap subcommand.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import AsyncGenerator\n\nimport pytest\nimport pytest_asyncio\nfrom sqlalchemy import select\nfrom sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n\nfrom backend.app.cli import DEFAULT_KIOSK_KEY_NAME, KioskBootstrapError, kiosk_bootstrap\nfrom backend.app.core.auth import _validate_api_key\nfrom backend.app.core.database import Base\nfrom backend.app.models.api_key import APIKey\nfrom backend.app.models.settings import Settings\n\n\n@pytest_asyncio.fixture\nasync def session_maker() -> AsyncGenerator[async_sessionmaker, None]:\n    engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\")\n    async with engine.begin() as conn:\n        await conn.run_sync(Base.metadata.create_all)\n    maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n    try:\n        yield maker\n    finally:\n        await engine.dispose()\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_bootstrap_creates_key_when_none_exists(session_maker):\n    key = await kiosk_bootstrap(\n        DEFAULT_KIOSK_KEY_NAME,\n        force=False,\n        session_maker=session_maker,\n        ensure_schema=False,\n    )\n\n    assert key.startswith(\"bb_\")\n    assert len(key) > 20\n\n    async with session_maker() as db:\n        rows = (await db.execute(select(APIKey))).scalars().all()\n        assert len(rows) == 1\n        row = rows[0]\n        assert row.name == DEFAULT_KIOSK_KEY_NAME\n        assert row.enabled is True\n        assert row.can_queue is False\n        assert row.can_control_printer is False\n        assert row.can_read_status is True\n        assert row.printer_ids is None\n        assert row.expires_at is None\n        assert row.key_prefix.startswith(\"bb_\")\n        assert row.key_hash != key  # stored value is a hash, not the plaintext\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_bootstrap_refuses_to_overwrite_without_force(session_maker):\n    first = await kiosk_bootstrap(\n        DEFAULT_KIOSK_KEY_NAME,\n        force=False,\n        session_maker=session_maker,\n        ensure_schema=False,\n    )\n\n    with pytest.raises(KioskBootstrapError) as exc_info:\n        await kiosk_bootstrap(\n            DEFAULT_KIOSK_KEY_NAME,\n            force=False,\n            session_maker=session_maker,\n            ensure_schema=False,\n        )\n\n    assert \"already exists\" in str(exc_info.value)\n    assert \"--force\" in str(exc_info.value)\n\n    # First key survives unchanged and still validates\n    async with session_maker() as db:\n        row = (await db.execute(select(APIKey))).scalar_one()\n        validated = await _validate_api_key(db, first)\n        assert validated is not None\n        assert validated.id == row.id\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_bootstrap_force_rotates_existing_key(session_maker):\n    first = await kiosk_bootstrap(\n        DEFAULT_KIOSK_KEY_NAME,\n        force=False,\n        session_maker=session_maker,\n        ensure_schema=False,\n    )\n    second = await kiosk_bootstrap(\n        DEFAULT_KIOSK_KEY_NAME,\n        force=True,\n        session_maker=session_maker,\n        ensure_schema=False,\n    )\n\n    assert first != second\n\n    async with session_maker() as db:\n        rows = (await db.execute(select(APIKey))).scalars().all()\n        assert len(rows) == 1  # old row was deleted, not duplicated\n\n        # Old key no longer validates, new key does\n        assert await _validate_api_key(db, first) is None\n        validated = await _validate_api_key(db, second)\n        assert validated is not None\n        assert validated.name == DEFAULT_KIOSK_KEY_NAME\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_bootstrap_marks_setup_completed(session_maker):\n    \"\"\"Bootstrap must set setup_completed=true so AuthContext doesn't redirect the kiosk to /setup.\"\"\"\n    await kiosk_bootstrap(\n        DEFAULT_KIOSK_KEY_NAME,\n        force=False,\n        session_maker=session_maker,\n        ensure_schema=False,\n    )\n\n    async with session_maker() as db:\n        setting = (await db.execute(select(Settings).where(Settings.key == \"setup_completed\"))).scalar_one()\n        assert setting.value == \"true\"\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_bootstrap_setup_idempotent_on_rotate(session_maker):\n    \"\"\"Re-running with --force must not duplicate the setup_completed row.\"\"\"\n    await kiosk_bootstrap(\n        DEFAULT_KIOSK_KEY_NAME,\n        force=False,\n        session_maker=session_maker,\n        ensure_schema=False,\n    )\n    await kiosk_bootstrap(\n        DEFAULT_KIOSK_KEY_NAME,\n        force=True,\n        session_maker=session_maker,\n        ensure_schema=False,\n    )\n\n    async with session_maker() as db:\n        rows = (await db.execute(select(Settings).where(Settings.key == \"setup_completed\"))).scalars().all()\n        assert len(rows) == 1\n        assert rows[0].value == \"true\"\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_bootstrap_custom_name(session_maker):\n    key = await kiosk_bootstrap(\n        \"custom-kiosk-name\",\n        force=False,\n        session_maker=session_maker,\n        ensure_schema=False,\n    )\n\n    async with session_maker() as db:\n        row = (await db.execute(select(APIKey))).scalar_one()\n        assert row.name == \"custom-kiosk-name\"\n        validated = await _validate_api_key(db, key)\n        assert validated is not None\n        assert validated.name == \"custom-kiosk-name\"\n"
  },
  {
    "path": "backend/tests/unit/test_code_quality.py",
    "content": "\"\"\"\nCode quality tests for BamBuddy backend.\n\nThese tests check for common anti-patterns and code quality issues\nthat could cause runtime errors but aren't caught by normal tests.\n\"\"\"\n\nimport ast\nfrom pathlib import Path\n\nimport pytest\n\n# Get the backend source directory\nBACKEND_DIR = Path(__file__).parent.parent.parent / \"app\"\n\n\n# Safe imports that are commonly re-imported in functions without issues\n# These are typically imported at the START of a function, not midway through\nSAFE_REIMPORT_NAMES = {\n    \"logging\",\n    \"re\",\n    \"os\",\n    \"sys\",\n    \"json\",\n    \"Path\",\n    \"datetime\",\n    \"timedelta\",\n    \"asyncio\",\n    \"time\",\n    \"typing\",\n    \"Optional\",\n    \"List\",\n    \"Dict\",\n    \"Any\",\n    \"Union\",\n}\n\n\nclass DangerousImportVisitor(ast.NodeVisitor):\n    \"\"\"AST visitor that detects dangerous import patterns.\n\n    Specifically looks for cases where:\n    1. A name is imported at module level\n    2. The same name is imported locally in a function\n    3. The name is USED before the local import in that function\n\n    This pattern causes 'cannot access local variable' errors.\n    \"\"\"\n\n    def __init__(self):\n        self.module_imports: set[str] = set()\n        self.dangerous_imports: list[tuple[str, int, str, int]] = []  # (name, import_line, function, first_use_line)\n        self.current_function: str | None = None\n        self.function_start_line: int = 0\n        self.in_function = False\n\n    def visit_Import(self, node: ast.Import):\n        for alias in node.names:\n            name = alias.asname or alias.name\n            if not self.in_function:\n                self.module_imports.add(name)\n        self.generic_visit(node)\n\n    def visit_ImportFrom(self, node: ast.ImportFrom):\n        for alias in node.names:\n            name = alias.asname or alias.name\n            if not self.in_function:\n                self.module_imports.add(name)\n        self.generic_visit(node)\n\n    def _check_function(self, node):\n        \"\"\"Check a function for dangerous import patterns.\"\"\"\n        if not self.in_function:\n            return\n\n        # Skip safe reimports\n        # Collect all local imports in this function\n        local_imports: dict[str, int] = {}  # name -> line number\n        name_uses: dict[str, int] = {}  # name -> first use line number\n\n        for child in ast.walk(node):\n            # Find local imports\n            if isinstance(child, (ast.Import, ast.ImportFrom)):\n                for alias in child.names:\n                    name = alias.asname or alias.name\n                    if name in self.module_imports and name not in SAFE_REIMPORT_NAMES:\n                        local_imports[name] = child.lineno\n\n            # Find name uses\n            if isinstance(child, ast.Name):\n                if child.id not in name_uses:\n                    name_uses[child.id] = child.lineno\n\n        # Check for dangerous pattern: use before import\n        for name, import_line in local_imports.items():\n            if name in name_uses:\n                first_use = name_uses[name]\n                if first_use < import_line:\n                    self.dangerous_imports.append((name, import_line, self.current_function, first_use))\n\n    def visit_FunctionDef(self, node: ast.FunctionDef):\n        old_function = self.current_function\n        old_in_function = self.in_function\n        old_start_line = self.function_start_line\n\n        self.current_function = node.name\n        self.in_function = True\n        self.function_start_line = node.lineno\n\n        self._check_function(node)\n        self.generic_visit(node)\n\n        self.current_function = old_function\n        self.in_function = old_in_function\n        self.function_start_line = old_start_line\n\n    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):\n        old_function = self.current_function\n        old_in_function = self.in_function\n        old_start_line = self.function_start_line\n\n        self.current_function = node.name\n        self.in_function = True\n        self.function_start_line = node.lineno\n\n        self._check_function(node)\n        self.generic_visit(node)\n\n        self.current_function = old_function\n        self.in_function = old_in_function\n        self.function_start_line = old_start_line\n\n\ndef find_import_shadowing(file_path: Path) -> list[tuple[str, int, str]]:\n    \"\"\"Find cases where local imports shadow module-level imports AND are used before import.\n\n    Returns list of (name, line_number, function_name) tuples.\n    \"\"\"\n    try:\n        with open(file_path, encoding=\"utf-8\") as f:\n            source = f.read()\n        tree = ast.parse(source)\n        visitor = DangerousImportVisitor()\n        visitor.visit(tree)\n        # Convert (name, import_line, function, first_use_line) to (name, import_line, function)\n        return [(name, import_line, func) for name, import_line, func, _ in visitor.dangerous_imports]\n    except SyntaxError:\n        return []  # Skip files with syntax errors\n\n\ndef get_python_files(directory: Path) -> list[Path]:\n    \"\"\"Get all Python files in a directory recursively.\"\"\"\n    return list(directory.rglob(\"*.py\"))\n\n\nclass TestImportShadowing:\n    \"\"\"Tests for import shadowing anti-pattern.\"\"\"\n\n    def test_no_import_shadowing_in_main(self):\n        \"\"\"Check main.py has no import shadowing issues.\n\n        This test would have caught the ArchiveService scoping bug.\n        \"\"\"\n        main_file = BACKEND_DIR / \"main.py\"\n        if not main_file.exists():\n            pytest.skip(\"main.py not found\")\n\n        shadows = find_import_shadowing(main_file)\n\n        if shadows:\n            error_msg = \"Import shadowing detected in main.py:\\n\"\n            for name, line, func in shadows:\n                error_msg += f\"  - '{name}' at line {line} in function '{func}' shadows module-level import\\n\"\n            error_msg += \"\\nThis can cause 'cannot access local variable' errors.\"\n            pytest.fail(error_msg)\n\n    def test_no_import_shadowing_in_services(self):\n        \"\"\"Check service files have no import shadowing issues.\"\"\"\n        services_dir = BACKEND_DIR / \"services\"\n        if not services_dir.exists():\n            pytest.skip(\"services directory not found\")\n\n        all_shadows = []\n        for py_file in get_python_files(services_dir):\n            shadows = find_import_shadowing(py_file)\n            for name, line, func in shadows:\n                all_shadows.append((py_file.name, name, line, func))\n\n        if all_shadows:\n            error_msg = \"Import shadowing detected in services:\\n\"\n            for filename, name, line, func in all_shadows:\n                error_msg += f\"  - {filename}: '{name}' at line {line} in function '{func}'\\n\"\n            pytest.fail(error_msg)\n\n    def test_no_import_shadowing_in_routes(self):\n        \"\"\"Check route files have no import shadowing issues.\"\"\"\n        routes_dir = BACKEND_DIR / \"api\" / \"routes\"\n        if not routes_dir.exists():\n            pytest.skip(\"routes directory not found\")\n\n        all_shadows = []\n        for py_file in get_python_files(routes_dir):\n            shadows = find_import_shadowing(py_file)\n            for name, line, func in shadows:\n                all_shadows.append((py_file.name, name, line, func))\n\n        if all_shadows:\n            error_msg = \"Import shadowing detected in routes:\\n\"\n            for filename, name, line, func in all_shadows:\n                error_msg += f\"  - {filename}: '{name}' at line {line} in function '{func}'\\n\"\n            pytest.fail(error_msg)\n\n\nclass TestModuleImports:\n    \"\"\"Tests for module import health.\"\"\"\n\n    def test_all_modules_importable(self):\n        \"\"\"Verify all Python modules can be imported without errors.\n\n        This catches syntax errors and missing dependencies.\n        \"\"\"\n        import importlib\n        import sys\n\n        # Modules to test importing\n        modules = [\n            \"backend.app.main\",\n            \"backend.app.services.bambu_mqtt\",\n            \"backend.app.services.printer_manager\",\n            \"backend.app.services.archive\",\n            \"backend.app.services.notification_service\",\n            \"backend.app.services.smart_plug_manager\",\n        ]\n\n        errors = []\n        for module_name in modules:\n            try:\n                # Remove from cache first to ensure fresh import\n                if module_name in sys.modules:\n                    del sys.modules[module_name]\n                importlib.import_module(module_name)\n            except Exception as e:\n                errors.append(f\"{module_name}: {type(e).__name__}: {e}\")\n\n        if errors:\n            pytest.fail(\"Failed to import modules:\\n\" + \"\\n\".join(errors))\n\n\nclass TestLogErrorPatterns:\n    \"\"\"Tests that use log capture to detect runtime errors.\"\"\"\n\n    def test_mqtt_message_processing_no_errors(self, capture_logs):\n        \"\"\"Test that MQTT message processing doesn't log errors.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        client.on_print_start = lambda data: None\n        client.on_print_complete = lambda data: None\n\n        # Process a realistic print lifecycle\n        messages = [\n            {\"print\": {\"gcode_state\": \"RUNNING\", \"gcode_file\": \"/test.gcode\", \"subtask_name\": \"Test\"}},\n            {\"print\": {\"gcode_state\": \"RUNNING\", \"gcode_file\": \"/test.gcode\", \"mc_percent\": 50}},\n            {\"print\": {\"gcode_state\": \"FINISH\", \"gcode_file\": \"/test.gcode\", \"subtask_name\": \"Test\"}},\n        ]\n\n        for msg in messages:\n            client._process_message(msg)\n\n        assert not capture_logs.has_errors(), f\"Errors during MQTT processing:\\n{capture_logs.format_errors()}\"\n"
  },
  {
    "path": "backend/tests/unit/test_color_utils.py",
    "content": "\"\"\"Unit tests for color_utils — hex color similarity comparison.\"\"\"\n\nfrom backend.app.utils.color_utils import colors_similar\n\n\nclass TestColorsSimilar:\n    \"\"\"Tests for colors_similar().\"\"\"\n\n    def test_exact_match(self):\n        assert colors_similar(\"FF0000FF\", \"FF0000FF\") is True\n\n    def test_exact_match_case_insensitive(self):\n        assert colors_similar(\"ff0000ff\", \"FF0000FF\") is True\n\n    def test_similar_colors_within_threshold(self):\n        # Real-world case: RFID read variation (distance ~43.6)\n        assert colors_similar(\"7CC4D5FF\", \"56B7E6FF\") is True\n\n    def test_different_colors_beyond_threshold(self):\n        # Red vs blue (distance ~360)\n        assert colors_similar(\"FF0000FF\", \"0000FFFF\") is False\n\n    def test_ignores_alpha_channel(self):\n        # Same RGB, different alpha — should match\n        assert colors_similar(\"FF000000\", \"FF0000FF\") is True\n\n    def test_six_digit_hex(self):\n        assert colors_similar(\"FF0000\", \"FF0000\") is True\n\n    def test_short_string_returns_false(self):\n        assert colors_similar(\"FFF\", \"FF0000\") is False\n        assert colors_similar(\"\", \"FF0000\") is False\n\n    def test_empty_strings_match(self):\n        \"\"\"Two empty strings are exact match (both missing data).\"\"\"\n        assert colors_similar(\"\", \"\") is True\n\n    def test_invalid_hex_returns_false(self):\n        assert colors_similar(\"ZZZZZZ\", \"FF0000\") is False\n\n    def test_whitespace_stripped(self):\n        assert colors_similar(\" FF0000 \", \"FF0000\") is True\n\n    def test_custom_threshold(self):\n        # Distance ~43.6 — within 50 but outside 30\n        assert colors_similar(\"7CC4D5FF\", \"56B7E6FF\", threshold=30) is False\n        assert colors_similar(\"7CC4D5FF\", \"56B7E6FF\", threshold=50) is True\n\n    def test_black_and_near_black(self):\n        # (10, 10, 10) distance from (0, 0, 0) = ~17.3\n        assert colors_similar(\"000000\", \"0A0A0A\") is True\n\n    def test_white_and_off_white(self):\n        assert colors_similar(\"FFFFFF\", \"F0F0F0\") is True\n"
  },
  {
    "path": "backend/tests/unit/test_cost_tracking.py",
    "content": "\"\"\"Unit tests for cost tracking in usage_tracker.py.\n\nTests cost calculation scenarios:\n- Spool-specific cost_per_kg\n- Default fallback cost from settings\n- Spools without cost (None)\n- Completed prints\n- Failed/partial prints\n- Cost aggregation to archives\n\"\"\"\n\nimport os\nimport tempfile\nfrom datetime import datetime, timezone\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.usage_tracker import (\n    PrintSession,\n    _active_sessions,\n    _track_from_3mf,\n    on_print_complete,\n)\n\n\ndef _make_spool(spool_id=1, label_weight=1000, weight_used=0, cost_per_kg=None):\n    \"\"\"Create a mock Spool object with cost fields.\"\"\"\n    spool = MagicMock()\n    spool.id = spool_id\n    spool.label_weight = label_weight\n    spool.weight_used = weight_used\n    spool.cost_per_kg = cost_per_kg\n    spool.last_used = None\n    spool.material = \"PLA\"\n    return spool\n\n\ndef _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):\n    \"\"\"Create a mock SpoolAssignment object.\"\"\"\n    assignment = MagicMock()\n    assignment.spool_id = spool_id\n    assignment.printer_id = printer_id\n    assignment.ams_id = ams_id\n    assignment.tray_id = tray_id\n    return assignment\n\n\ndef _make_archive(archive_id=1, file_path=None):\n    \"\"\"Create a mock PrintArchive object with a temp file, and register cleanup.\"\"\"\n    if file_path is None:\n        with tempfile.NamedTemporaryFile(delete=False, suffix=\".3mf\", prefix=\"test_print_\") as tmp:\n            file_path = tmp.name\n        # Register cleanup for this file after the test\n        import pytest\n\n        frame = None\n        try:\n            raise Exception\n        except Exception:\n            import sys\n\n            frame = sys._getframe(1)\n        request = frame.f_locals.get(\"request\")\n        if request is not None:\n\n            def cleanup():\n                try:\n                    os.remove(file_path)\n                except Exception:\n                    pass\n\n            request.addfinalizer(cleanup)\n    archive = MagicMock()\n    archive.id = archive_id\n    archive.file_path = file_path\n    return archive\n\n\n@pytest.fixture(autouse=True)\ndef cleanup_temp_archives():\n    yield\n    # Cleanup any temp .3mf files created by _make_archive\n    import glob\n\n    for f in glob.glob(\"test_print_*.3mf\"):\n        try:\n            os.remove(f)\n        except Exception:\n            pass\n\n\n@pytest.fixture(autouse=True)\ndef cleanup_test_print_gcode():\n    yield\n    import os\n\n    path = \"archives/test/test_print.gcode.3mf\"\n    if os.path.exists(path):\n        try:\n            os.remove(path)\n        except Exception:\n            pass\n\n\n@pytest.fixture\ndef archive_factory_temp():\n    import tempfile\n\n    def _factory(*args, **kwargs):\n        with tempfile.NamedTemporaryFile(delete=False, suffix=\".3mf\", prefix=\"test_print_\", dir=\"archives/test\") as tmp:\n            kwargs[\"file_path\"] = tmp.name\n        return kwargs[\"file_path\"]\n\n    yield _factory\n    # Cleanup\n    import glob\n    import os\n\n    for f in glob.glob(\"archives/test/test_print_*.3mf\"):\n        try:\n            os.remove(f)\n        except Exception:\n            pass\n\n\ndef _mock_db_sequential(responses):\n    \"\"\"Create mock db that returns responses in order.\"\"\"\n    db = AsyncMock()\n    call_count = [0]\n\n    async def mock_execute(*args, **kwargs):\n        idx = call_count[0]\n        call_count[0] += 1\n        result = MagicMock()\n        if idx < len(responses):\n            result.scalar_one_or_none.return_value = responses[idx]\n        else:\n            result.scalar_one_or_none.return_value = None\n        return result\n\n    db.execute = mock_execute\n    return db\n\n\nclass TestCostCalculation:\n    \"\"\"Tests for cost calculation in usage tracking.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def _clear_sessions(self):\n        _active_sessions.clear()\n        yield\n        _active_sessions.clear()\n\n    @pytest.mark.asyncio\n    async def test_cost_with_spool_specific_cost_per_kg(self):\n        \"\"\"Cost is calculated using spool-specific cost_per_kg when available.\"\"\"\n        # Spool with cost_per_kg = 25.00 USD/kg\n        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            tray_now_at_start=0,\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # db returns: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        # 20g used from 3MF\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 20.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"15.0\"),  # default cost\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 1\n        assert results[0][\"weight_used\"] == 20.0\n        # Cost = 20g / 1000 * 25.0 = 0.50\n        assert results[0][\"cost\"] == 0.50\n\n    @pytest.mark.asyncio\n    async def test_cost_with_default_fallback(self):\n        \"\"\"Cost uses default_filament_cost from settings when spool cost is None.\"\"\"\n        # Spool without cost_per_kg\n        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            tray_now_at_start=0,\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # db returns: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        # 30g used from 3MF\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 30.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"15.0\"),  # default: 15.0/kg\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 1\n        assert results[0][\"weight_used\"] == 30.0\n        # Cost = 30g / 1000 * 15.0 = 0.45\n        assert results[0][\"cost\"] == 0.45\n\n    @pytest.mark.asyncio\n    async def test_cost_zero_when_default_cost_is_zero(self):\n        \"\"\"Cost is None when both spool cost and default cost are 0.\"\"\"\n        # Spool without cost_per_kg\n        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            tray_now_at_start=0,\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # db returns: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"0.0\"),  # no default cost\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"cost\"] is None\n\n    @pytest.mark.asyncio\n    async def test_cost_for_failed_print_uses_actual_usage(self):\n        \"\"\"Failed print at 50% progress calculates cost from actual usage.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            tray_now_at_start=0,\n        )\n\n        # Failed at 50% progress\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=50,\n            layer_num=25,\n            tray_now=0,\n        )\n\n        # db returns: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        # 40g total, but only 50% used\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 40.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"15.0\"),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf\",\n                return_value=None,  # No layer data, use linear scaling\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"failed\", \"last_progress\": 50.0},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n            )\n\n        assert len(results) == 1\n        # 50% of 40g = 20g\n        assert results[0][\"weight_used\"] == 20.0\n        # Cost = 20g / 1000 * 20.0 = 0.40\n        assert results[0][\"cost\"] == 0.40\n\n    @pytest.mark.asyncio\n    async def test_cost_with_ams_fallback_tracking(self):\n        \"\"\"AMS fallback tracking also calculates cost correctly.\"\"\"\n        spool = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=30.0)\n        assignment = _make_assignment(spool_id=2)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            tray_now=0,\n            last_loaded_tray=-1,\n        )\n\n        # Pad 2 Nones for _find_3mf_by_filename DB queries (library + archive search),\n        # then assignment and spool for the AMS fallback path\n        db = _mock_db_sequential([None, None, assignment, spool])\n\n        with patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"15.0\"):\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=None,  # No archive = AMS fallback\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 2\n        # 10% of 1000g = 100g\n        assert results[0][\"weight_used\"] == 100.0\n        # Cost = 100g / 1000 * 30.0 = 3.00\n        assert results[0][\"cost\"] == 3.0\n\n    @pytest.mark.asyncio\n    async def test_multi_filament_cost_aggregation(self):\n        \"\"\"Multiple spools in one print have their costs tracked separately.\"\"\"\n        spool1 = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)\n        spool2 = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=25.0)\n        assignment1 = _make_assignment(spool_id=1, ams_id=0, tray_id=0)\n        assignment2 = _make_assignment(spool_id=2, ams_id=0, tray_id=1)\n        archive = _make_archive(archive_id=10)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80, (0, 1): 90},\n            tray_now_at_start=0,\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}, {\"id\": 1, \"remain\": 80}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # Mock slot-to-tray mapping: slot 1 -> tray 0, slot 2 -> tray 1\n        ams_mapping = [0, 1]\n\n        # db returns: archive, assignment1, spool1, assignment2, spool2\n        # ams_mapping is provided, so no queue item lookup is performed\n        db = _mock_db_sequential([archive, assignment1, spool1, assignment2, spool2])\n\n        # Two filaments used\n        filament_usage = [\n            {\"slot_id\": 1, \"used_g\": 15.0, \"type\": \"PLA\", \"color\": \"#FF0000\"},\n            {\"slot_id\": 2, \"used_g\": 25.0, \"type\": \"PLA\", \"color\": \"#00FF00\"},\n        ]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"15.0\"),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n                ams_mapping=ams_mapping,\n            )\n\n        assert len(results) == 2\n\n        # First spool: 15g at 20/kg = 0.30\n        spool1_result = next(r for r in results if r[\"spool_id\"] == 1)\n        assert spool1_result[\"weight_used\"] == 15.0\n        assert spool1_result[\"cost\"] == 0.30\n\n        # Second spool: 25g at 25/kg = 0.625, rounded to 0.62\n        spool2_result = next(r for r in results if r[\"spool_id\"] == 2)\n        assert spool2_result[\"weight_used\"] == 25.0\n        assert spool2_result[\"cost\"] == 0.62\n\n\nclass TestCostAggregation:\n    \"\"\"Tests for cost aggregation to PrintArchive.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_costs_summed_in_results(self):\n        \"\"\"Multiple spool costs are correctly summed from result dicts.\"\"\"\n        results = [\n            {\"spool_id\": 1, \"weight_used\": 20.0, \"cost\": 0.50},\n            {\"spool_id\": 2, \"weight_used\": 30.0, \"cost\": 0.75},\n        ]\n\n        total_cost = sum(r.get(\"cost\", 0) or 0 for r in results)\n        assert total_cost == 1.25\n\n    @pytest.mark.asyncio\n    async def test_null_costs_handled_in_aggregation(self):\n        \"\"\"None costs don't break aggregation.\"\"\"\n        results = [\n            {\"spool_id\": 1, \"weight_used\": 20.0, \"cost\": 0.50},\n            {\"spool_id\": 2, \"weight_used\": 30.0, \"cost\": None},  # No cost\n            {\"spool_id\": 3, \"weight_used\": 10.0, \"cost\": 0.25},\n        ]\n\n        total_cost = sum(r.get(\"cost\", 0) or 0 for r in results)\n        assert total_cost == 0.75  # Only spools 1 and 3\n\n    @pytest.mark.asyncio\n    async def test_archive_cost_not_overwritten_with_zero(self):\n        \"\"\"archive.cost is preserved when no spool usage has cost data.\"\"\"\n        # Spool without cost_per_kg, default_filament_cost also 0 → cost=None per usage\n        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n        archive.cost = 5.00  # Pre-existing cost from catalog\n        archive.print_name = \"TestPrint\"\n        archive.printer_id = 1\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"TestPrint\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            tray_now_at_start=0,\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # Build mock db that returns proper scalars for the aggregation queries\n        responses = []\n        # 1. select(PrintArchive) → archive\n        responses.append((\"scalar_one_or_none\", archive))\n        # 2. select(PrintQueueItem) → None\n        responses.append((\"scalar_one_or_none\", None))\n        # 3. select(SpoolAssignment) → assignment\n        responses.append((\"scalar_one_or_none\", assignment))\n        # 4. select(Spool) → spool\n        responses.append((\"scalar_one_or_none\", spool))\n        # 5. cost aggregation: select archive to update cost\n        responses.append((\"scalar_one_or_none\", archive))\n\n        db = AsyncMock()\n        call_count = [0]\n\n        async def mock_execute(*args, **kwargs):\n            idx = call_count[0]\n            call_count[0] += 1\n            result = MagicMock()\n            if idx < len(responses):\n                method, value = responses[idx]\n                if method == \"scalar\":\n                    result.scalar.return_value = value\n                    result.scalar_one_or_none.return_value = value\n                else:\n                    result.scalar_one_or_none.return_value = value\n                    result.scalar.return_value = value\n            else:\n                result.scalar_one_or_none.return_value = None\n                result.scalar.return_value = None\n            return result\n\n        db.execute = mock_execute\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"0.0\"),  # no default cost\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n            )\n\n        # Usage tracked but cost is None (no cost_per_kg, no default)\n        assert len(results) == 1\n        assert results[0][\"cost\"] is None\n\n        # Archive cost should NOT have been overwritten — still 5.00\n        assert archive.cost == 5.00\n\n    @pytest.mark.asyncio\n    async def test_archive_cost_set_when_spool_has_cost(self):\n        \"\"\"archive.cost is set from spool usage when cost data exists.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n        archive.cost = None  # No pre-existing cost\n        archive.print_name = \"TestPrint\"\n        archive.printer_id = 1\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"TestPrint\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            tray_now_at_start=0,\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # 20g at 25/kg = 0.50\n        expected_cost = 0.50\n\n        responses = []\n        responses.append((\"scalar_one_or_none\", archive))\n        responses.append((\"scalar_one_or_none\", None))  # queue item\n        responses.append((\"scalar_one_or_none\", assignment))\n        responses.append((\"scalar_one_or_none\", spool))\n        # cost aggregation: select archive to update cost\n        responses.append((\"scalar_one_or_none\", archive))\n\n        db = AsyncMock()\n        call_count = [0]\n\n        async def mock_execute(*args, **kwargs):\n            idx = call_count[0]\n            call_count[0] += 1\n            result = MagicMock()\n            if idx < len(responses):\n                method, value = responses[idx]\n                result.scalar.return_value = value\n                result.scalar_one_or_none.return_value = value\n            else:\n                result.scalar_one_or_none.return_value = None\n                result.scalar.return_value = None\n            return result\n\n        db.execute = mock_execute\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 20.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"15.0\"),\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"cost\"] == expected_cost\n        # Archive cost should have been updated\n        assert archive.cost == expected_cost\n\n    @pytest.mark.asyncio\n    async def test_cost_with_archive_id(self):\n        \"\"\"Test cost aggregation using archive_id (3MF path).\"\"\"\n        spool_new = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)\n        assignment_new = _make_assignment(spool_id=1)\n        archive_new = _make_archive(archive_id=20)\n        filament_usage_new = [{\"slot_id\": 1, \"used_g\": 20.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        db = _mock_db_sequential([archive_new, None, assignment_new, spool_new])\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"15.0\"),\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=filament_usage_new),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results_new = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=20,\n            )\n\n        assert len(results_new) == 1\n        assert results_new[0][\"spool_id\"] == 1\n        assert results_new[0][\"cost\"] == 0.50  # 20g / 1000 * 25.0\n\n    @pytest.mark.asyncio\n    async def test_cost_with_print_name_ams_fallback(self):\n        \"\"\"Test cost aggregation using print_name (AMS fallback, legacy path).\"\"\"\n        spool_old = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=15.0)\n        assignment_old = _make_assignment(spool_id=2, ams_id=0, tray_id=0)\n        legacy_print_name = \"LegacyPrint\"\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=legacy_print_name,\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n            tray_now_at_start=0,\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # Pad 2 Nones for _find_3mf_by_filename DB queries (library + archive search),\n        # then assignment and spool for the AMS fallback path\n        db = _mock_db_sequential([None, None, assignment_old, spool_old])\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\"backend.app.api.routes.settings.get_setting\", return_value=\"15.0\"),\n            patch(\"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\", return_value=None),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results_old = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\", \"subtask_name\": legacy_print_name, \"filename\": legacy_print_name},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=None,\n            )\n\n        assert len(results_old) == 1\n        assert results_old[0][\"spool_id\"] == 2\n        assert results_old[0][\"cost\"] == 1.5  # 100g / 1000 * 15.0\n"
  },
  {
    "path": "backend/tests/unit/test_db_dialect.py",
    "content": "\"\"\"Unit tests for database dialect helpers and PostgreSQL compatibility.\"\"\"\n\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\n\nclass TestDialectDetection:\n    \"\"\"Test is_sqlite() and is_postgres() detection.\"\"\"\n\n    def test_sqlite_detected(self):\n        with patch(\"backend.app.core.config.settings\") as mock_settings:\n            mock_settings.database_url = \"sqlite+aiosqlite:///path/to/db.sqlite\"\n            from backend.app.core.db_dialect import is_postgres, is_sqlite\n\n            assert is_sqlite() is True\n            assert is_postgres() is False\n\n    def test_postgres_detected(self):\n        with patch(\"backend.app.core.config.settings\") as mock_settings:\n            mock_settings.database_url = \"postgresql+asyncpg://user:pass@host:5432/db\"\n            from backend.app.core.db_dialect import is_postgres, is_sqlite\n\n            assert is_postgres() is True\n            assert is_sqlite() is False\n\n\nclass TestRunPragma:\n    \"\"\"Test that PRAGMAs only run on SQLite.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_pragma_runs_on_sqlite(self):\n        with patch(\"backend.app.core.db_dialect.is_sqlite\", return_value=True):\n            from backend.app.core.db_dialect import run_pragma\n\n            mock_conn = AsyncMock()\n            await run_pragma(mock_conn, \"PRAGMA journal_mode = WAL\")\n            mock_conn.execute.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_pragma_skipped_on_postgres(self):\n        with patch(\"backend.app.core.db_dialect.is_sqlite\", return_value=False):\n            from backend.app.core.db_dialect import run_pragma\n\n            mock_conn = AsyncMock()\n            await run_pragma(mock_conn, \"PRAGMA journal_mode = WAL\")\n            mock_conn.execute.assert_not_called()\n\n\nclass TestTimezoneStripping:\n    \"\"\"Test that the before_cursor_execute event strips timezone info.\"\"\"\n\n    def test_strip_aware_datetime(self):\n        \"\"\"Verify the timezone stripping logic works correctly.\"\"\"\n        import datetime\n\n        aware = datetime.datetime(2026, 4, 3, 10, 0, 0, tzinfo=datetime.timezone.utc)\n        naive = aware.replace(tzinfo=None)\n\n        def _strip(val):\n            if isinstance(val, datetime.datetime) and val.tzinfo is not None:\n                return val.replace(tzinfo=None)\n            return val\n\n        assert _strip(aware) == naive\n        assert _strip(aware).tzinfo is None\n        assert _strip(naive) == naive\n        assert _strip(\"not a datetime\") == \"not a datetime\"\n        assert _strip(None) is None\n\n    def test_strip_in_dict_params(self):\n        \"\"\"Verify timezone stripping works on dict parameters.\"\"\"\n        import datetime\n\n        aware = datetime.datetime(2026, 4, 3, 10, 0, 0, tzinfo=datetime.timezone.utc)\n\n        def _strip(val):\n            if isinstance(val, datetime.datetime) and val.tzinfo is not None:\n                return val.replace(tzinfo=None)\n            return val\n\n        params = {\"name\": \"test\", \"created_at\": aware, \"count\": 5}\n        result = {k: _strip(v) for k, v in params.items()}\n        assert result[\"created_at\"].tzinfo is None\n        assert result[\"name\"] == \"test\"\n        assert result[\"count\"] == 5\n\n    def test_strip_in_tuple_params(self):\n        \"\"\"Verify timezone stripping works on tuple parameters.\"\"\"\n        import datetime\n\n        aware = datetime.datetime(2026, 4, 3, 10, 0, 0, tzinfo=datetime.timezone.utc)\n\n        def _strip(val):\n            if isinstance(val, datetime.datetime) and val.tzinfo is not None:\n                return val.replace(tzinfo=None)\n            return val\n\n        params = (\"test\", aware, 5)\n        result = tuple(_strip(v) for v in params)\n        assert result[1].tzinfo is None\n        assert result[0] == \"test\"\n\n    def test_naive_datetime_unchanged(self):\n        \"\"\"Naive datetimes should pass through untouched.\"\"\"\n        import datetime\n\n        naive = datetime.datetime(2026, 4, 3, 10, 0, 0)\n\n        def _strip(val):\n            if isinstance(val, datetime.datetime) and val.tzinfo is not None:\n                return val.replace(tzinfo=None)\n            return val\n\n        result = _strip(naive)\n        assert result == naive\n        assert result.tzinfo is None\n\n\nclass TestCrossDatabaseConversion:\n    \"\"\"Test SQLite→Postgres type conversion logic used in cross-database import.\"\"\"\n\n    def test_boolean_conversion(self):\n        \"\"\"SQLite stores booleans as 0/1, Postgres needs Python bool.\"\"\"\n        assert bool(0) is False\n        assert bool(1) is True\n\n    def test_datetime_string_conversion(self):\n        \"\"\"SQLite stores datetimes as strings, Postgres needs datetime objects.\"\"\"\n        from datetime import datetime\n\n        val = \"2026-04-02 11:01:52.105147\"\n        result = datetime.fromisoformat(val)\n        assert result.year == 2026\n        assert result.month == 4\n        assert result.microsecond == 105147\n\n    def test_datetime_with_timezone_string(self):\n        \"\"\"SQLite may store timezone-aware strings.\"\"\"\n        from datetime import datetime\n\n        val = \"2026-04-02T11:01:52+00:00\"\n        result = datetime.fromisoformat(val)\n        assert result.year == 2026\n\n    def test_json_serialization_for_backup(self):\n        \"\"\"JSON/list/dict values must be serialized for SQLite backup.\"\"\"\n        import json\n\n        values = [{\"key\": \"val\"}, [1, 2, 3], \"plain string\", 42, None]\n        for val in values:\n            if isinstance(val, (list, dict)):\n                serialized = json.dumps(val)\n                assert isinstance(serialized, str)\n            else:\n                assert val == val  # noqa: PLR0124 — no conversion needed\n\n\nclass TestSafeExecutePattern:\n    \"\"\"Test _safe_execute error handling logic.\"\"\"\n\n    def test_safe_execute_catches_expected_exceptions(self):\n        \"\"\"Verify _safe_execute catches both OperationalError and ProgrammingError.\"\"\"\n        from sqlalchemy.exc import OperationalError, ProgrammingError\n\n        # These are the exception types _safe_execute must catch\n        # (verified by reading the source — actual integration tested by 1509 unit tests)\n        for exc_type in (OperationalError, ProgrammingError):\n            try:\n                raise exc_type(\"test\", [], Exception(\"column already exists\"))\n            except (OperationalError, ProgrammingError):\n                pass  # This is what _safe_execute does\n\n    def test_safe_execute_would_not_catch_integrity_error(self):\n        \"\"\"IntegrityError should NOT be caught by _safe_execute.\"\"\"\n        from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError\n\n        with pytest.raises(IntegrityError):\n            try:\n                raise IntegrityError(\"test\", [], Exception(\"unique violation\"))\n            except (OperationalError, ProgrammingError):\n                pass  # _safe_execute only catches these two\n"
  },
  {
    "path": "backend/tests/unit/test_energy_snapshots.py",
    "content": "\"\"\"Tests for #941 — date-range energy in total consumption mode + restart-resilient per-print tracking.\n\nCovers:\n- `_sum_snapshot_deltas()`: correct (endpoint - baseline) arithmetic\n- Counter-reset clamp, warming-up flag, missing-endpoint handling\n- Restart resilience: per-print `energy_start_kwh` persists across a\n  \"simulated restart\" (new session/process), so the print-end handler can\n  still compute the delta.\n\"\"\"\n\nfrom datetime import datetime, timedelta, timezone\n\nimport pytest\nfrom sqlalchemy import select\n\nfrom backend.app.api.routes.archives import _sum_snapshot_deltas\nfrom backend.app.models.archive import PrintArchive\nfrom backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot\n\n\ndef _snap(plug_id: int, recorded_at: datetime, kwh: float) -> SmartPlugEnergySnapshot:\n    return SmartPlugEnergySnapshot(plug_id=plug_id, recorded_at=recorded_at, lifetime_kwh=kwh)\n\n\nclass TestSumSnapshotDeltas:\n    @pytest.mark.asyncio\n    async def test_returns_zero_when_no_plugs(self, db_session):\n        total, warming = await _sum_snapshot_deltas(db_session, dt_from=None, dt_to=None)\n        assert total == 0.0\n        assert warming is False\n\n    @pytest.mark.asyncio\n    async def test_simple_delta_with_baseline_and_endpoint(self, db_session, smart_plug_factory):\n        plug = await smart_plug_factory(name=\"A\")\n        # Baseline sits before the range, endpoint inside the range.\n        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)\n        db_session.add(_snap(plug.id, t0, 100.0))  # baseline\n        db_session.add(_snap(plug.id, t0 + timedelta(days=2), 115.0))  # endpoint\n        await db_session.commit()\n\n        range_start = t0 + timedelta(days=1)\n        range_end = t0 + timedelta(days=3)\n        total, warming = await _sum_snapshot_deltas(db_session, dt_from=range_start, dt_to=range_end)\n\n        assert total == pytest.approx(15.0)\n        assert warming is False\n\n    @pytest.mark.asyncio\n    async def test_warming_up_when_no_baseline_before_range(self, db_session, smart_plug_factory):\n        plug = await smart_plug_factory(name=\"A\")\n        # All snapshots happen AFTER range_start — simulates fresh upgrade.\n        t0 = datetime(2026, 4, 10, 12, 0, tzinfo=timezone.utc)\n        db_session.add(_snap(plug.id, t0, 500.0))  # first snapshot ever (fallback baseline)\n        db_session.add(_snap(plug.id, t0 + timedelta(hours=6), 502.0))  # endpoint\n        await db_session.commit()\n\n        range_start = datetime(2026, 4, 10, 0, 0, tzinfo=timezone.utc)  # before any snapshot\n        range_end = datetime(2026, 4, 10, 23, 59, tzinfo=timezone.utc)\n\n        total, warming = await _sum_snapshot_deltas(db_session, dt_from=range_start, dt_to=range_end)\n\n        assert total == pytest.approx(2.0)  # 502 - 500\n        assert warming is True\n\n    @pytest.mark.asyncio\n    async def test_counter_reset_is_clamped_to_zero(self, db_session, smart_plug_factory):\n        plug = await smart_plug_factory(name=\"A\")\n        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)\n        db_session.add(_snap(plug.id, t0, 1000.0))  # baseline\n        # Counter reset — endpoint is lower than baseline (plug replaced, firmware reset, ...)\n        db_session.add(_snap(plug.id, t0 + timedelta(days=2), 5.0))\n        await db_session.commit()\n\n        total, warming = await _sum_snapshot_deltas(\n            db_session,\n            dt_from=t0 + timedelta(days=1),\n            dt_to=t0 + timedelta(days=3),\n        )\n\n        assert total == 0.0\n        assert warming is False\n\n    @pytest.mark.asyncio\n    async def test_multiple_plugs_are_summed(self, db_session, smart_plug_factory):\n        plug1 = await smart_plug_factory(name=\"A\", ip_address=\"10.0.0.1\")\n        plug2 = await smart_plug_factory(name=\"B\", ip_address=\"10.0.0.2\")\n        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)\n        # plug1: 100 -> 110  (delta 10)\n        db_session.add(_snap(plug1.id, t0, 100.0))\n        db_session.add(_snap(plug1.id, t0 + timedelta(days=2), 110.0))\n        # plug2:  50 ->  55  (delta 5)\n        db_session.add(_snap(plug2.id, t0, 50.0))\n        db_session.add(_snap(plug2.id, t0 + timedelta(days=2), 55.0))\n        await db_session.commit()\n\n        total, warming = await _sum_snapshot_deltas(\n            db_session,\n            dt_from=t0 + timedelta(days=1),\n            dt_to=t0 + timedelta(days=3),\n        )\n\n        assert total == pytest.approx(15.0)\n        assert warming is False\n\n    @pytest.mark.asyncio\n    async def test_plug_with_no_snapshots_signals_warming(self, db_session, smart_plug_factory):\n        # Plug exists but never snapshotted (yet).\n        await smart_plug_factory(name=\"A\")\n        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)\n\n        total, warming = await _sum_snapshot_deltas(\n            db_session,\n            dt_from=t0,\n            dt_to=t0 + timedelta(days=1),\n        )\n\n        assert total == 0.0\n        assert warming is True\n\n    @pytest.mark.asyncio\n    async def test_endpoint_picks_last_snapshot_at_or_before_range_end(self, db_session, smart_plug_factory):\n        plug = await smart_plug_factory(name=\"A\")\n        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)\n        db_session.add(_snap(plug.id, t0, 100.0))  # baseline\n        db_session.add(_snap(plug.id, t0 + timedelta(days=1), 105.0))  # inside range\n        db_session.add(_snap(plug.id, t0 + timedelta(days=5), 130.0))  # AFTER range_end — must be ignored\n        await db_session.commit()\n\n        total, _warming = await _sum_snapshot_deltas(\n            db_session,\n            dt_from=t0 + timedelta(hours=12),\n            dt_to=t0 + timedelta(days=2),\n        )\n\n        # Baseline is last snapshot <= range_start → the t0 one at 100\n        # Endpoint is last snapshot <= range_end → the day-1 one at 105\n        assert total == pytest.approx(5.0)\n\n\nclass TestPerPrintRestartResilience:\n    \"\"\"#941: per-print energy tracking survives a mid-print backend restart.\n\n    The critical change: `energy_start_kwh` is stored on the archive row, not\n    in an in-memory dict. A new DB session should still be able to read it.\n    \"\"\"\n\n    @pytest.mark.asyncio\n    async def test_energy_start_kwh_persists_to_db(self, db_session, printer_factory):\n        printer = await printer_factory()\n        archive = PrintArchive(\n            printer_id=printer.id,\n            filename=\"resilience.gcode.3mf\",\n            print_name=\"Resilience\",\n            file_path=\"archives/test/resilience.gcode.3mf\",\n            file_size=1000,\n            status=\"printing\",\n            energy_start_kwh=123.456,\n        )\n        db_session.add(archive)\n        await db_session.commit()\n        archive_id = archive.id\n\n        # Drop the ORM reference and re-fetch, simulating a fresh session\n        # (the situation we'd be in after a backend restart).\n        db_session.expunge_all()\n        result = await db_session.execute(select(PrintArchive).where(PrintArchive.id == archive_id))\n        reloaded = result.scalar_one()\n\n        assert reloaded.energy_start_kwh == pytest.approx(123.456)\n\n    @pytest.mark.asyncio\n    async def test_energy_kwh_delta_computes_from_persisted_start(self, db_session, printer_factory):\n        \"\"\"Simulates the background energy calc reading from DB instead of a dict.\"\"\"\n        printer = await printer_factory()\n        archive = PrintArchive(\n            printer_id=printer.id,\n            filename=\"delta.gcode.3mf\",\n            print_name=\"Delta\",\n            file_path=\"archives/test/delta.gcode.3mf\",\n            file_size=1000,\n            status=\"completed\",\n            energy_start_kwh=200.0,\n        )\n        db_session.add(archive)\n        await db_session.commit()\n\n        # Emulate the end-of-print calculation: plug currently reads 203.4 kWh\n        ending_kwh = 203.4\n        assert archive.energy_start_kwh is not None\n        archive.energy_kwh = round(ending_kwh - archive.energy_start_kwh, 4)\n        archive.energy_cost = round(archive.energy_kwh * 0.30, 3)\n        await db_session.commit()\n\n        # Re-read and verify\n        db_session.expunge_all()\n        result = await db_session.execute(select(PrintArchive).where(PrintArchive.id == archive.id))\n        reloaded = result.scalar_one()\n        assert reloaded.energy_kwh == pytest.approx(3.4)\n        assert reloaded.energy_cost == pytest.approx(1.02)\n"
  },
  {
    "path": "backend/tests/unit/test_firmware_versions.py",
    "content": "\"\"\"\nUnit tests for firmware version listing.\n\nCovers:\n- Wiki-page version extraction is restricted to section-heading anchors\n  (incidental version-like strings in release-note prose must be ignored).\n- Merging wiki + download-page versions produces a single list where\n  wiki-only versions are flagged as unavailable (no download URL).\n\"\"\"\n\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\nfrom backend.app.services.firmware_check import FirmwareCheckService, FirmwareVersion\n\nWIKI_SAMPLE = \"\"\"\n<h2 id=\"h-01030000-20260303\" class=\"toc-header\">01.03.00.00 (20260303)</h2>\n<p>Released 20260303</p>\n<ul><li>Optimized AMS 2 Pro (requires AMS firmware OTA v02.00.19.47 or newer).</li></ul>\n<h2 id=\"h-01021000-20260209\" class=\"toc-header\">01.02.10.00 (20260209)</h2>\n<p>Bug fixes.</p>\n<h2 id=\"h-01020200-20251105\" class=\"toc-header\">01.02.02.00 (20251105)</h2>\n<p>Some more text referencing 00.00.00.00 incidentally.</p>\n\"\"\"\n\n\n@pytest.mark.asyncio\nasync def test_wiki_extraction_ignores_prose_version_mentions():\n    \"\"\"02.00.19.47 appears only in release notes prose — it must not be listed.\"\"\"\n    svc = FirmwareCheckService()\n    mock_resp = AsyncMock()\n    mock_resp.status_code = 200\n    mock_resp.text = WIKI_SAMPLE\n    with patch.object(svc._client, \"get\", AsyncMock(return_value=mock_resp)):\n        versions = await svc._fetch_all_versions_from_wiki(\"h2d\")\n\n    version_strs = [v for v, _ in versions]\n    assert version_strs == [\"01.03.00.00\", \"01.02.10.00\", \"01.02.02.00\"]\n    # The AMS firmware mentioned in prose must not leak in:\n    assert \"02.00.19.47\" not in version_strs\n    assert \"00.00.00.00\" not in version_strs\n    # Release dates are captured from the anchor id:\n    assert versions[0][1] == \"20260303\"\n\n\n@pytest.mark.asyncio\nasync def test_wiki_extraction_returns_empty_for_unknown_api_key():\n    svc = FirmwareCheckService()\n    assert await svc._fetch_all_versions_from_wiki(\"no-such-key\") == []\n\n\n# P2S and X2D wiki pages publish anchor ids without a dash between the\n# version bytes and the date (e.g. h-0102000020260409). Regression for #1030\n# where the anchor regex required a dash and silently returned no versions,\n# causing the UI to fall back to the stale download-page \"Latest\" value.\nP2S_NODASH_ANCHOR_SAMPLE = \"\"\"\n<h2 id=\"h-0102000020260409\" class=\"toc-header\">01.02.00.00（20260409）</h2>\n<h2 id=\"h-0101030020260209\" class=\"toc-header\">01.01.03.00（20260209）</h2>\n<h2 id=\"h-0101010020251208\" class=\"toc-header\">01.01.01.00（20251208）</h2>\n\"\"\"\n\n\n@pytest.mark.asyncio\nasync def test_wiki_extraction_accepts_nodash_anchors():\n    \"\"\"P2S/X2D anchors concatenate version+date with no dash — must still parse.\"\"\"\n    svc = FirmwareCheckService()\n    mock_resp = AsyncMock()\n    mock_resp.status_code = 200\n    mock_resp.text = P2S_NODASH_ANCHOR_SAMPLE\n    with patch.object(svc._client, \"get\", AsyncMock(return_value=mock_resp)):\n        versions = await svc._fetch_all_versions_from_wiki(\"p2s\")\n\n    assert [v for v, _ in versions] == [\"01.02.00.00\", \"01.01.03.00\", \"01.01.01.00\"]\n    assert versions[0][1] == \"20260409\"\n\n\n# A1, A1-mini and P2S pages render dates in full-width parens （YYYYMMDD）\n# rather than ASCII parens (YYYYMMDD). Pages without version-anchors fall\n# through to the text-based regex, so it must accept both paren styles.\nFULLWIDTH_PAREN_FALLBACK_SAMPLE = \"\"\"\n<h2>01.04.00.01 （20260401）</h2>\n<h2>01.03.00.00 （20260101）</h2>\n\"\"\"\n\n\n@pytest.mark.asyncio\nasync def test_wiki_extraction_fallback_accepts_fullwidth_parens():\n    svc = FirmwareCheckService()\n    mock_resp = AsyncMock()\n    mock_resp.status_code = 200\n    mock_resp.text = FULLWIDTH_PAREN_FALLBACK_SAMPLE\n    with patch.object(svc._client, \"get\", AsyncMock(return_value=mock_resp)):\n        versions = await svc._fetch_all_versions_from_wiki(\"a1\")\n\n    assert [v for v, _ in versions] == [\"01.04.00.01\", \"01.03.00.00\"]\n    assert versions[0][1] == \"20260401\"\n\n\n@pytest.mark.asyncio\nasync def test_get_available_versions_merges_sources():\n    \"\"\"\n    Merged list must include all wiki versions (newest first), populating\n    download URL + notes from the download-page JSON when present, and\n    leaving download_url empty when the file is not published.\n    \"\"\"\n    svc = FirmwareCheckService()\n\n    wiki = [\n        (\"01.03.00.00\", \"20260303\"),\n        (\"01.02.10.00\", \"20260209\"),  # wiki-only — should be \"unavailable\"\n        (\"01.02.02.00\", \"20251105\"),\n    ]\n    download = [\n        FirmwareVersion(\n            version=\"01.03.00.00\",\n            download_url=\"https://cdn.example/1.bin\",\n            release_notes=\"notes 1.3\",\n            release_time=\"2026-03-03\",\n        ),\n        FirmwareVersion(\n            version=\"01.02.02.00\",\n            download_url=\"https://cdn.example/2.bin\",\n            release_notes=\"notes 1.2.2\",\n            release_time=\"2025-11-05\",\n        ),\n    ]\n\n    with (\n        patch.object(svc, \"_fetch_all_versions_from_wiki\", AsyncMock(return_value=wiki)),\n        patch.object(svc, \"_fetch_all_versions_from_download_page\", AsyncMock(return_value=download)),\n    ):\n        result = await svc.get_available_versions(\"H2D\")\n\n    assert [v.version for v in result] == [\"01.03.00.00\", \"01.02.10.00\", \"01.02.02.00\"]\n    assert result[0].download_url == \"https://cdn.example/1.bin\"\n    assert result[0].release_notes == \"notes 1.3\"\n    # Wiki-only version has no download URL → treated as unavailable by callers.\n    assert result[1].download_url == \"\"\n    assert result[1].release_notes is None\n    assert result[1].release_time == \"20260209\"\n    assert result[2].download_url == \"https://cdn.example/2.bin\"\n\n\n@pytest.mark.asyncio\nasync def test_get_available_versions_sorts_newest_first():\n    \"\"\"Merged list must be sorted descending by version tuple regardless of input order.\"\"\"\n    svc = FirmwareCheckService()\n    wiki = [(\"01.02.02.00\", None)]\n    download = [\n        FirmwareVersion(version=\"01.03.00.00\", download_url=\"a\"),\n        FirmwareVersion(version=\"01.02.10.00\", download_url=\"b\"),\n    ]\n    with (\n        patch.object(svc, \"_fetch_all_versions_from_wiki\", AsyncMock(return_value=wiki)),\n        patch.object(svc, \"_fetch_all_versions_from_download_page\", AsyncMock(return_value=download)),\n    ):\n        result = await svc.get_available_versions(\"H2D\")\n    assert [v.version for v in result] == [\"01.03.00.00\", \"01.02.10.00\", \"01.02.02.00\"]\n\n\n@pytest.mark.asyncio\nasync def test_check_for_update_includes_available_versions():\n    svc = FirmwareCheckService()\n    available = [\n        FirmwareVersion(version=\"01.03.00.00\", download_url=\"https://cdn/1.bin\", release_notes=\"x\"),\n        FirmwareVersion(version=\"01.02.10.00\", download_url=\"\"),  # unavailable\n    ]\n    with patch.object(svc, \"get_available_versions\", AsyncMock(return_value=available)):\n        result = await svc.check_for_update(\"H2D\", \"01.02.02.00\")\n\n    assert result[\"update_available\"] is True\n    assert result[\"latest_version\"] == \"01.03.00.00\"\n    assert len(result[\"available_versions\"]) == 2\n    assert result[\"available_versions\"][0][\"file_available\"] is True\n    assert result[\"available_versions\"][1][\"file_available\"] is False\n    assert result[\"available_versions\"][1][\"download_url\"] is None\n"
  },
  {
    "path": "backend/tests/unit/test_gcode_injection.py",
    "content": "\"\"\"Unit tests for G-code injection into 3MF files (#422).\"\"\"\n\nimport tempfile\nimport zipfile\nfrom pathlib import Path\n\nimport pytest\n\nfrom backend.app.utils.threemf_tools import inject_gcode_into_3mf\n\n\ndef _make_temp_path(suffix=\".3mf\") -> Path:\n    \"\"\"Create a temp file path without leaving it open (avoids SIM115).\"\"\"\n    fd, name = tempfile.mkstemp(suffix=suffix)\n    import os\n\n    os.close(fd)\n    return Path(name)\n\n\ndef _make_test_3mf(gcode_content: str = \"G28\\nG1 X0 Y0\\nM400\\n\", plate_id: int = 1) -> Path:\n    \"\"\"Create a minimal 3MF file with embedded G-code for testing.\"\"\"\n    tmp_path = _make_temp_path()\n\n    with zipfile.ZipFile(tmp_path, \"w\", zipfile.ZIP_DEFLATED) as zf:\n        zf.writestr(f\"Metadata/plate_{plate_id}.gcode\", gcode_content)\n        zf.writestr(\"Metadata/slice_info.config\", \"<config></config>\")\n        zf.writestr(\"3D/3dmodel.model\", \"<model></model>\")\n\n    return tmp_path\n\n\nclass TestInjectGcodeInto3mf:\n    \"\"\"Tests for inject_gcode_into_3mf().\"\"\"\n\n    def test_inject_start_gcode(self):\n        \"\"\"Start G-code is prepended before the original content.\"\"\"\n        source = _make_test_3mf(\"G28\\nM400\\n\")\n        try:\n            result = inject_gcode_into_3mf(source, 1, \"M117 Start\\nG92 E0\", None)\n            assert result is not None\n\n            with zipfile.ZipFile(result, \"r\") as zf:\n                gcode = zf.read(\"Metadata/plate_1.gcode\").decode(\"utf-8\")\n\n            assert gcode.startswith(\"M117 Start\\nG92 E0\\n\")\n            assert \"G28\\nM400\\n\" in gcode\n        finally:\n            source.unlink(missing_ok=True)\n            if result:\n                result.unlink(missing_ok=True)\n\n    def test_inject_end_gcode(self):\n        \"\"\"End G-code is appended after the original content.\"\"\"\n        source = _make_test_3mf(\"G28\\nM400\")\n        try:\n            result = inject_gcode_into_3mf(source, 1, None, \"M104 S0\\nG28 X\")\n            assert result is not None\n\n            with zipfile.ZipFile(result, \"r\") as zf:\n                gcode = zf.read(\"Metadata/plate_1.gcode\").decode(\"utf-8\")\n\n            assert gcode.endswith(\"M104 S0\\nG28 X\\n\")\n            assert gcode.startswith(\"G28\\nM400\")\n        finally:\n            source.unlink(missing_ok=True)\n            if result:\n                result.unlink(missing_ok=True)\n\n    def test_inject_both_start_and_end(self):\n        \"\"\"Both start and end G-code are injected.\"\"\"\n        source = _make_test_3mf(\"G28\\n\")\n        try:\n            result = inject_gcode_into_3mf(source, 1, \"; START\", \"; END\")\n            assert result is not None\n\n            with zipfile.ZipFile(result, \"r\") as zf:\n                gcode = zf.read(\"Metadata/plate_1.gcode\").decode(\"utf-8\")\n\n            assert gcode.startswith(\"; START\\n\")\n            assert gcode.endswith(\"; END\\n\")\n            assert \"G28\" in gcode\n        finally:\n            source.unlink(missing_ok=True)\n            if result:\n                result.unlink(missing_ok=True)\n\n    def test_no_injection_returns_none(self):\n        \"\"\"Returns None when both start and end are None.\"\"\"\n        source = _make_test_3mf()\n        try:\n            result = inject_gcode_into_3mf(source, 1, None, None)\n            assert result is None\n        finally:\n            source.unlink(missing_ok=True)\n\n    def test_empty_strings_returns_none(self):\n        \"\"\"Returns None when both start and end are empty strings.\"\"\"\n        source = _make_test_3mf()\n        try:\n            result = inject_gcode_into_3mf(source, 1, \"\", \"\")\n            assert result is None\n        finally:\n            source.unlink(missing_ok=True)\n\n    def test_plate_id_selection(self):\n        \"\"\"Injects into the correct plate's G-code file.\"\"\"\n        source = _make_temp_path()\n\n        with zipfile.ZipFile(source, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"Metadata/plate_1.gcode\", \"PLATE1\\n\")\n            zf.writestr(\"Metadata/plate_2.gcode\", \"PLATE2\\n\")\n\n        try:\n            result = inject_gcode_into_3mf(source, 2, \"; INJECTED\", None)\n            assert result is not None\n\n            with zipfile.ZipFile(result, \"r\") as zf:\n                plate1 = zf.read(\"Metadata/plate_1.gcode\").decode(\"utf-8\")\n                plate2 = zf.read(\"Metadata/plate_2.gcode\").decode(\"utf-8\")\n\n            # Only plate 2 should be modified\n            assert plate1 == \"PLATE1\\n\"\n            assert plate2.startswith(\"; INJECTED\\n\")\n        finally:\n            source.unlink(missing_ok=True)\n            if result:\n                result.unlink(missing_ok=True)\n\n    def test_preserves_other_files(self):\n        \"\"\"Non-gcode files in the 3MF are preserved unchanged.\"\"\"\n        source = _make_test_3mf()\n        try:\n            result = inject_gcode_into_3mf(source, 1, \"; START\", None)\n            assert result is not None\n\n            with zipfile.ZipFile(result, \"r\") as zf:\n                names = zf.namelist()\n                assert \"Metadata/slice_info.config\" in names\n                assert \"3D/3dmodel.model\" in names\n                config = zf.read(\"Metadata/slice_info.config\").decode(\"utf-8\")\n                assert config == \"<config></config>\"\n        finally:\n            source.unlink(missing_ok=True)\n            if result:\n                result.unlink(missing_ok=True)\n\n    def test_no_gcode_file_returns_none(self):\n        \"\"\"Returns None when the 3MF has no gcode files.\"\"\"\n        source = _make_temp_path()\n\n        with zipfile.ZipFile(source, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"3D/3dmodel.model\", \"<model></model>\")\n\n        try:\n            result = inject_gcode_into_3mf(source, 1, \"; START\", None)\n            assert result is None\n        finally:\n            source.unlink(missing_ok=True)\n\n    def test_invalid_file_returns_none(self):\n        \"\"\"Returns None for a non-ZIP file.\"\"\"\n        source = _make_temp_path()\n        source.write_bytes(b\"not a zip file\")\n\n        try:\n            result = inject_gcode_into_3mf(source, 1, \"; START\", None)\n            assert result is None\n        finally:\n            source.unlink(missing_ok=True)\n\n    def test_fallback_to_first_gcode(self):\n        \"\"\"Falls back to first gcode file when plate-specific not found.\"\"\"\n        source = _make_temp_path()\n\n        with zipfile.ZipFile(source, \"w\", zipfile.ZIP_DEFLATED) as zf:\n            zf.writestr(\"Metadata/plate_1.gcode\", \"ORIGINAL\\n\")\n\n        try:\n            # Request plate 5 which doesn't exist — should fall back to plate_1\n            result = inject_gcode_into_3mf(source, 5, \"; INJECTED\", None)\n            assert result is not None\n\n            with zipfile.ZipFile(result, \"r\") as zf:\n                gcode = zf.read(\"Metadata/plate_1.gcode\").decode(\"utf-8\")\n\n            assert gcode.startswith(\"; INJECTED\\n\")\n        finally:\n            source.unlink(missing_ok=True)\n            if result:\n                result.unlink(missing_ok=True)\n\n    def test_original_file_unchanged(self):\n        \"\"\"The source 3MF is never modified.\"\"\"\n        source = _make_test_3mf(\"ORIGINAL\\n\")\n        try:\n            result = inject_gcode_into_3mf(source, 1, \"; START\", \"; END\")\n            assert result is not None\n\n            # Verify original is untouched\n            with zipfile.ZipFile(source, \"r\") as zf:\n                original = zf.read(\"Metadata/plate_1.gcode\").decode(\"utf-8\")\n            assert original == \"ORIGINAL\\n\"\n        finally:\n            source.unlink(missing_ok=True)\n            if result:\n                result.unlink(missing_ok=True)\n"
  },
  {
    "path": "backend/tests/unit/test_homeassistant_settings.py",
    "content": "\"\"\"Unit tests for Home Assistant settings with environment variable support.\n\nTests the get_homeassistant_settings() function in isolation.\n\"\"\"\n\nimport os\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_get_homeassistant_settings_no_env_vars():\n    \"\"\"Test get_homeassistant_settings with no environment variables.\"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    # Mock database session\n    db = AsyncMock(spec=AsyncSession)\n\n    # Mock get_setting to return database values\n    with patch(\"backend.app.api.routes.settings.get_setting\") as mock_get_setting:\n        mock_get_setting.side_effect = lambda db, key: {\n            \"ha_url\": \"http://db-url:8123\",\n            \"ha_token\": \"db-token\",\n            \"ha_enabled\": \"true\",\n        }.get(key, \"\")\n\n        # Ensure no env vars\n        with patch.dict(os.environ, {}, clear=False):\n            os.environ.pop(\"HA_URL\", None)\n            os.environ.pop(\"HA_TOKEN\", None)\n\n            result = await get_homeassistant_settings(db)\n\n            # Should use database values\n            assert result[\"ha_url\"] == \"http://db-url:8123\"\n            assert result[\"ha_token\"] == \"db-token\"\n            assert result[\"ha_enabled\"] is True\n            assert result[\"ha_url_from_env\"] is False\n            assert result[\"ha_token_from_env\"] is False\n            assert result[\"ha_env_managed\"] is False\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_get_homeassistant_settings_with_env_vars():\n    \"\"\"Test get_homeassistant_settings with environment variables set.\"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    db = AsyncMock(spec=AsyncSession)\n\n    with patch(\"backend.app.api.routes.settings.get_setting\") as mock_get_setting:\n        mock_get_setting.side_effect = lambda db, key: {\n            \"ha_url\": \"http://db-url:8123\",\n            \"ha_token\": \"db-token\",\n            \"ha_enabled\": \"false\",\n        }.get(key, \"\")\n\n        # Set environment variables\n        with patch.dict(os.environ, {\"HA_URL\": \"http://supervisor/core\", \"HA_TOKEN\": \"env-token\"}, clear=False):\n            result = await get_homeassistant_settings(db)\n\n            # Should use environment values\n            assert result[\"ha_url\"] == \"http://supervisor/core\"\n            assert result[\"ha_token\"] == \"env-token\"\n            assert result[\"ha_enabled\"] is True  # Auto-enabled\n            assert result[\"ha_url_from_env\"] is True\n            assert result[\"ha_token_from_env\"] is True\n            assert result[\"ha_env_managed\"] is True\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_get_homeassistant_settings_partial_env_url_only():\n    \"\"\"Test get_homeassistant_settings with only HA_URL set.\"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    db = AsyncMock(spec=AsyncSession)\n\n    with patch(\"backend.app.api.routes.settings.get_setting\") as mock_get_setting:\n        mock_get_setting.side_effect = lambda db, key: {\n            \"ha_url\": \"http://db-url:8123\",\n            \"ha_token\": \"db-token\",\n            \"ha_enabled\": \"false\",\n        }.get(key, \"\")\n\n        # Set only URL env var\n        with patch.dict(os.environ, {\"HA_URL\": \"http://supervisor/core\"}, clear=False):\n            os.environ.pop(\"HA_TOKEN\", None)\n\n            result = await get_homeassistant_settings(db)\n\n            # URL from env, token from database\n            assert result[\"ha_url\"] == \"http://supervisor/core\"\n            assert result[\"ha_token\"] == \"db-token\"\n            assert result[\"ha_enabled\"] is False  # Not auto-enabled\n            assert result[\"ha_url_from_env\"] is True\n            assert result[\"ha_token_from_env\"] is False\n            assert result[\"ha_env_managed\"] is False\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_get_homeassistant_settings_partial_env_token_only():\n    \"\"\"Test get_homeassistant_settings with only HA_TOKEN set.\"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    db = AsyncMock(spec=AsyncSession)\n\n    with patch(\"backend.app.api.routes.settings.get_setting\") as mock_get_setting:\n        mock_get_setting.side_effect = lambda db, key: {\n            \"ha_url\": \"http://db-url:8123\",\n            \"ha_token\": \"db-token\",\n            \"ha_enabled\": \"false\",\n        }.get(key, \"\")\n\n        # Set only token env var\n        with patch.dict(os.environ, {\"HA_TOKEN\": \"env-token\"}, clear=False):\n            os.environ.pop(\"HA_URL\", None)\n\n            result = await get_homeassistant_settings(db)\n\n            # URL from database, token from env\n            assert result[\"ha_url\"] == \"http://db-url:8123\"\n            assert result[\"ha_token\"] == \"env-token\"\n            assert result[\"ha_enabled\"] is False  # Not auto-enabled\n            assert result[\"ha_url_from_env\"] is False\n            assert result[\"ha_token_from_env\"] is True\n            assert result[\"ha_env_managed\"] is False\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_get_homeassistant_settings_empty_env_vars():\n    \"\"\"Test get_homeassistant_settings with empty environment variables.\"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    db = AsyncMock(spec=AsyncSession)\n\n    with patch(\"backend.app.api.routes.settings.get_setting\") as mock_get_setting:\n        mock_get_setting.side_effect = lambda db, key: {\n            \"ha_url\": \"http://db-url:8123\",\n            \"ha_token\": \"db-token\",\n            \"ha_enabled\": \"false\",\n        }.get(key, \"\")\n\n        # Set empty env vars\n        with patch.dict(os.environ, {\"HA_URL\": \"\", \"HA_TOKEN\": \"\"}, clear=False):\n            result = await get_homeassistant_settings(db)\n\n            # Empty env vars treated as not set, should use database values\n            assert result[\"ha_url\"] == \"http://db-url:8123\"\n            assert result[\"ha_token\"] == \"db-token\"\n            assert result[\"ha_enabled\"] is False\n            assert result[\"ha_url_from_env\"] is False\n            assert result[\"ha_token_from_env\"] is False\n            assert result[\"ha_env_managed\"] is False\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_get_homeassistant_settings_auto_enable_logic():\n    \"\"\"Test auto-enable behavior with various configurations.\"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    db = AsyncMock(spec=AsyncSession)\n\n    with patch(\"backend.app.api.routes.settings.get_setting\") as mock_get_setting:\n        # Database has ha_enabled=false\n        mock_get_setting.side_effect = lambda db, key: {\n            \"ha_url\": \"\",\n            \"ha_token\": \"\",\n            \"ha_enabled\": \"false\",\n        }.get(key, \"\")\n\n        # Test 1: No env vars - use database enabled state\n        with patch.dict(os.environ, {}, clear=False):\n            os.environ.pop(\"HA_URL\", None)\n            os.environ.pop(\"HA_TOKEN\", None)\n\n            result = await get_homeassistant_settings(db)\n            assert result[\"ha_enabled\"] is False\n\n        # Test 2: Both env vars set - auto-enable\n        with patch.dict(os.environ, {\"HA_URL\": \"http://test\", \"HA_TOKEN\": \"token\"}, clear=False):\n            result = await get_homeassistant_settings(db)\n            assert result[\"ha_enabled\"] is True\n\n        # Test 3: Only URL - use database enabled state\n        with patch.dict(os.environ, {\"HA_URL\": \"http://test\"}, clear=False):\n            os.environ.pop(\"HA_TOKEN\", None)\n\n            result = await get_homeassistant_settings(db)\n            assert result[\"ha_enabled\"] is False\n\n        # Test 4: Only token - use database enabled state\n        with patch.dict(os.environ, {\"HA_TOKEN\": \"token\"}, clear=False):\n            os.environ.pop(\"HA_URL\", None)\n\n            result = await get_homeassistant_settings(db)\n            assert result[\"ha_enabled\"] is False\n\n\n@pytest.mark.asyncio\n@pytest.mark.unit\nasync def test_get_homeassistant_settings_env_vars_override_enabled_true():\n    \"\"\"Test that env vars auto-enable even when database has ha_enabled=true.\"\"\"\n    from backend.app.api.routes.settings import get_homeassistant_settings\n\n    db = AsyncMock(spec=AsyncSession)\n\n    with patch(\"backend.app.api.routes.settings.get_setting\") as mock_get_setting:\n        # Database has ha_enabled=true\n        mock_get_setting.side_effect = lambda db, key: {\n            \"ha_url\": \"http://db-url:8123\",\n            \"ha_token\": \"db-token\",\n            \"ha_enabled\": \"true\",\n        }.get(key, \"\")\n\n        # Both env vars set - should still be enabled\n        with patch.dict(os.environ, {\"HA_URL\": \"http://supervisor/core\", \"HA_TOKEN\": \"env-token\"}, clear=False):\n            result = await get_homeassistant_settings(db)\n\n            assert result[\"ha_enabled\"] is True  # Auto-enabled by env vars\n            assert result[\"ha_url\"] == \"http://supervisor/core\"\n            assert result[\"ha_token\"] == \"env-token\"\n            assert result[\"ha_env_managed\"] is True\n"
  },
  {
    "path": "backend/tests/unit/test_ldap_migration.py",
    "content": "\"\"\"Regression test for #794 — LDAP auto-provisioning on legacy SQLite schemas.\n\nPre-LDAP databases created the `users` table with `password_hash VARCHAR(255) NOT NULL`.\nThe LDAP provisioning path inserts users with `password_hash=None`, which crashes on\nupgrade until the migration strips the NOT NULL constraint.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport pytest\nfrom sqlalchemy import text\nfrom sqlalchemy.exc import IntegrityError\nfrom sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n\nfrom backend.app.core.database import run_migrations\n\n\n@pytest.fixture(autouse=True)\ndef force_sqlite_dialect(monkeypatch):\n    \"\"\"The test engine is SQLite but settings.database_url may point to Postgres in dev\n    configs — that would make run_migrations take the Postgres branch and skip the\n    SQLite-specific writable_schema patch we're verifying. Force the sqlite dialect.\"\"\"\n    from backend.app.core import db_dialect\n\n    monkeypatch.setattr(db_dialect, \"is_sqlite\", lambda: True)\n    monkeypatch.setattr(db_dialect, \"is_postgres\", lambda: False)\n    # database.py imported is_sqlite at module load time — patch there too.\n    from backend.app.core import database as database_module\n\n    monkeypatch.setattr(database_module, \"is_sqlite\", lambda: True)\n\n\n@pytest.fixture\nasync def legacy_engine():\n    \"\"\"Simulate an older install by creating all current tables via create_all, then\n    dropping the `users` table and re-creating it with the legacy NOT NULL schema.\n    This matches the real upgrade path — everything else in the DB looks modern, only\n    the users table carries a stale constraint.\"\"\"\n    # Import every model so Base.metadata knows about them (same set as conftest).\n    from backend.app.core.database import Base\n    from backend.app.models import (  # noqa: F401\n        ams_history,\n        ams_label,\n        api_key,\n        archive,\n        color_catalog,\n        external_link,\n        filament,\n        group,\n        kprofile_note,\n        maintenance,\n        notification,\n        notification_template,\n        print_queue,\n        printer,\n        project,\n        project_bom,\n        settings,\n        slot_preset,\n        smart_plug,\n        smart_plug_energy_snapshot,\n        spool,\n        spool_assignment,\n        spool_catalog,\n        spool_k_profile,\n        spool_usage_history,\n        spoolbuddy_device,\n        user,\n        user_email_pref,\n        virtual_printer,\n    )\n\n    engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n    async with engine.begin() as conn:\n        await conn.run_sync(Base.metadata.create_all)\n        # Drop the users table created from the current (nullable) model and replace it\n        # with the pre-LDAP schema that real upgrading installations have on disk.\n        await conn.execute(text(\"DROP TABLE IF EXISTS user_groups\"))\n        await conn.execute(text(\"DROP TABLE users\"))\n        await conn.execute(\n            text(\"\"\"\n            CREATE TABLE users (\n                id INTEGER PRIMARY KEY,\n                username VARCHAR(100) NOT NULL UNIQUE,\n                password_hash VARCHAR(255) NOT NULL,\n                role VARCHAR(20) NOT NULL DEFAULT 'user',\n                is_active BOOLEAN NOT NULL DEFAULT 1,\n                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,\n                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n            )\n            \"\"\")\n        )\n    yield engine\n    await engine.dispose()\n\n\nasync def test_legacy_schema_rejects_null_password_before_migration(legacy_engine):\n    \"\"\"Sanity check: without the migration, inserting a NULL password_hash fails.\n\n    Guards against a false-positive where a future schema change silently allows NULL\n    and the real migration test below becomes meaningless.\n    \"\"\"\n    async with legacy_engine.begin() as conn:\n        with pytest.raises(IntegrityError):\n            await conn.execute(\n                text(\n                    \"INSERT INTO users (username, password_hash, role, is_active) \"\n                    \"VALUES ('ldap_alice', NULL, 'user', 1)\"\n                )\n            )\n\n\nasync def test_migration_allows_null_password_hash_for_ldap_users(legacy_engine):\n    \"\"\"After running migrations on a legacy DB, LDAP users (password_hash=NULL) insert\n    successfully — reproduces and verifies the #794 bug reported by DylanBrass.\"\"\"\n    async with legacy_engine.begin() as conn:\n        await run_migrations(conn)\n\n    session_maker = async_sessionmaker(legacy_engine, class_=AsyncSession, expire_on_commit=False)\n    async with session_maker() as session:\n        await session.execute(\n            text(\n                \"INSERT INTO users (username, email, password_hash, role, auth_source, is_active) \"\n                \"VALUES (:u, :e, NULL, 'user', 'ldap', 1)\"\n            ),\n            {\"u\": \"ldap_bob\", \"e\": \"bob@example.com\"},\n        )\n        await session.commit()\n\n        result = await session.execute(\n            text(\"SELECT username, password_hash, auth_source FROM users WHERE username = 'ldap_bob'\")\n        )\n        row = result.one()\n        assert row.username == \"ldap_bob\"\n        assert row.password_hash is None\n        assert row.auth_source == \"ldap\"\n\n\nasync def test_migration_is_idempotent(legacy_engine):\n    \"\"\"Running migrations twice must not break the writable_schema patch.\"\"\"\n    async with legacy_engine.begin() as conn:\n        await run_migrations(conn)\n    async with legacy_engine.begin() as conn:\n        await run_migrations(conn)\n\n    session_maker = async_sessionmaker(legacy_engine, class_=AsyncSession, expire_on_commit=False)\n    async with session_maker() as session:\n        await session.execute(\n            text(\n                \"INSERT INTO users (username, password_hash, role, auth_source, is_active) \"\n                \"VALUES ('ldap_carol', NULL, 'user', 'ldap', 1)\"\n            )\n        )\n        await session.commit()\n"
  },
  {
    "path": "backend/tests/unit/test_local_backup.py",
    "content": "\"\"\"Unit tests for scheduled local backup service (#884).\"\"\"\n\nimport tempfile\nimport zipfile\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\nfrom backend.app.services.local_backup import LocalBackupService\n\n\nclass TestCalculateNextRun:\n    \"\"\"Tests for _calculate_next_run scheduling logic.\"\"\"\n\n    def test_hourly_returns_next_full_hour(self):\n        service = LocalBackupService()\n        now = datetime(2026, 4, 12, 14, 30, 0, tzinfo=timezone.utc)\n        with patch(\"backend.app.services.local_backup.datetime\") as mock_dt:\n            mock_dt.now.return_value = now\n            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)\n            result = service._calculate_next_run(\"hourly\", \"03:00\")\n        assert result.hour == 15\n        assert result.minute == 0\n\n    def test_daily_before_target_time_schedules_today(self):\n        service = LocalBackupService()\n        now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)\n        with patch(\"backend.app.services.local_backup.datetime\") as mock_dt:\n            mock_dt.now.return_value = now\n            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)\n            result = service._calculate_next_run(\"daily\", \"03:00\")\n        assert result.day == 12\n        assert result.hour == 3\n\n    def test_daily_after_target_time_schedules_tomorrow(self):\n        service = LocalBackupService()\n        now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)\n        with patch(\"backend.app.services.local_backup.datetime\") as mock_dt:\n            mock_dt.now.return_value = now\n            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)\n            result = service._calculate_next_run(\"daily\", \"03:00\")\n        assert result.day == 13\n        assert result.hour == 3\n\n    def test_weekly_adds_full_week(self):\n        service = LocalBackupService()\n        now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)\n        with patch(\"backend.app.services.local_backup.datetime\") as mock_dt:\n            mock_dt.now.return_value = now\n            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)\n            result = service._calculate_next_run(\"weekly\", \"03:00\")\n        expected = datetime(2026, 4, 19, 3, 0, 0, tzinfo=timezone.utc)\n        assert result == expected\n\n    def test_weekly_after_target_time_adds_full_week_from_tomorrow(self):\n        service = LocalBackupService()\n        now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)\n        with patch(\"backend.app.services.local_backup.datetime\") as mock_dt:\n            mock_dt.now.return_value = now\n            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)\n            result = service._calculate_next_run(\"weekly\", \"03:00\")\n        expected = datetime(2026, 4, 20, 3, 0, 0, tzinfo=timezone.utc)\n        assert result == expected\n\n    def test_invalid_time_defaults_to_0300(self):\n        service = LocalBackupService()\n        now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)\n        with patch(\"backend.app.services.local_backup.datetime\") as mock_dt:\n            mock_dt.now.return_value = now\n            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)\n            result = service._calculate_next_run(\"daily\", \"invalid\")\n        assert result.hour == 3\n        assert result.minute == 0\n\n    def test_unknown_schedule_type_defaults_to_daily(self):\n        service = LocalBackupService()\n        now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)\n        with patch(\"backend.app.services.local_backup.datetime\") as mock_dt:\n            mock_dt.now.return_value = now\n            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)\n            result = service._calculate_next_run(\"every_5_min\", \"03:00\")\n        # Should fall through to daily behavior (time-based)\n        assert result.hour == 3\n\n\nclass TestPruneBackups:\n    \"\"\"Tests for backup retention pruning.\"\"\"\n\n    def test_prune_keeps_retention_count(self, tmp_path):\n        service = LocalBackupService()\n        # Create 5 backup files\n        for i in range(5):\n            f = tmp_path / f\"bambuddy-backup-20260412-{i:06d}.zip\"\n            f.write_text(f\"backup{i}\")\n        service._prune_backups(tmp_path, retention=3)\n        remaining = list(tmp_path.glob(\"bambuddy-backup-*.zip\"))\n        assert len(remaining) == 3\n\n    def test_prune_noop_when_under_retention(self, tmp_path):\n        service = LocalBackupService()\n        for i in range(2):\n            f = tmp_path / f\"bambuddy-backup-20260412-{i:06d}.zip\"\n            f.write_text(f\"backup{i}\")\n        service._prune_backups(tmp_path, retention=5)\n        remaining = list(tmp_path.glob(\"bambuddy-backup-*.zip\"))\n        assert len(remaining) == 2\n\n    def test_prune_only_touches_matching_files(self, tmp_path):\n        service = LocalBackupService()\n        # Create backup files and a non-backup file\n        for i in range(3):\n            f = tmp_path / f\"bambuddy-backup-20260412-{i:06d}.zip\"\n            f.write_text(f\"backup{i}\")\n        other = tmp_path / \"other_file.txt\"\n        other.write_text(\"keep me\")\n        service._prune_backups(tmp_path, retention=1)\n        assert other.exists()\n        remaining = list(tmp_path.glob(\"bambuddy-backup-*.zip\"))\n        assert len(remaining) == 1\n\n\nclass TestResolveBackupFile:\n    \"\"\"Tests for backup file resolution with path traversal protection.\"\"\"\n\n    def test_valid_filename(self, tmp_path):\n        service = LocalBackupService()\n        f = tmp_path / \"bambuddy-backup-20260412-120000.zip\"\n        f.write_text(\"data\")\n        result = service.resolve_backup_file(str(tmp_path), \"bambuddy-backup-20260412-120000.zip\")\n        assert result == f\n\n    def test_path_traversal_blocked(self, tmp_path):\n        service = LocalBackupService()\n        result = service.resolve_backup_file(str(tmp_path), \"../etc/passwd\")\n        assert result is None\n\n    def test_backslash_blocked(self, tmp_path):\n        service = LocalBackupService()\n        result = service.resolve_backup_file(str(tmp_path), \"..\\\\etc\\\\passwd\")\n        assert result is None\n\n    def test_dotdot_blocked(self, tmp_path):\n        service = LocalBackupService()\n        result = service.resolve_backup_file(str(tmp_path), \"..bambuddy-backup.zip\")\n        assert result is None\n\n    def test_wrong_prefix_blocked(self, tmp_path):\n        service = LocalBackupService()\n        f = tmp_path / \"evil-file.zip\"\n        f.write_text(\"data\")\n        result = service.resolve_backup_file(str(tmp_path), \"evil-file.zip\")\n        assert result is None\n\n    def test_nonexistent_file(self, tmp_path):\n        service = LocalBackupService()\n        result = service.resolve_backup_file(str(tmp_path), \"bambuddy-backup-20260412-120000.zip\")\n        assert result is None\n\n\nclass TestDeleteBackup:\n    \"\"\"Tests for backup deletion.\"\"\"\n\n    def test_delete_valid_backup(self, tmp_path):\n        service = LocalBackupService()\n        f = tmp_path / \"bambuddy-backup-20260412-120000.zip\"\n        f.write_text(\"data\")\n        result = service.delete_backup(str(tmp_path), \"bambuddy-backup-20260412-120000.zip\")\n        assert result[\"success\"] is True\n        assert not f.exists()\n\n    def test_delete_nonexistent_backup(self, tmp_path):\n        service = LocalBackupService()\n        result = service.delete_backup(str(tmp_path), \"bambuddy-backup-20260412-120000.zip\")\n        assert result[\"success\"] is False\n\n    def test_delete_path_traversal_blocked(self, tmp_path):\n        service = LocalBackupService()\n        result = service.delete_backup(str(tmp_path), \"../important.zip\")\n        assert result[\"success\"] is False\n\n\nclass TestListBackups:\n    \"\"\"Tests for backup listing.\"\"\"\n\n    def test_list_empty_dir(self, tmp_path):\n        service = LocalBackupService()\n        result = service.list_backups(str(tmp_path))\n        assert result == []\n\n    def test_list_nonexistent_dir(self):\n        service = LocalBackupService()\n        result = service.list_backups(\"/nonexistent/path/12345\")\n        assert result == []\n\n    def test_list_only_matching_files(self, tmp_path):\n        service = LocalBackupService()\n        (tmp_path / \"bambuddy-backup-20260412-120000.zip\").write_text(\"a\")\n        (tmp_path / \"bambuddy-backup-20260412-130000.zip\").write_text(\"bb\")\n        (tmp_path / \"other-file.txt\").write_text(\"ccc\")\n        result = service.list_backups(str(tmp_path))\n        assert len(result) == 2\n        assert all(r[\"filename\"].startswith(\"bambuddy-backup-\") for r in result)\n\n    def test_list_sorted_newest_first(self, tmp_path):\n        import time\n\n        service = LocalBackupService()\n        f1 = tmp_path / \"bambuddy-backup-20260412-120000.zip\"\n        f1.write_text(\"a\")\n        time.sleep(0.05)\n        f2 = tmp_path / \"bambuddy-backup-20260412-130000.zip\"\n        f2.write_text(\"b\")\n        result = service.list_backups(str(tmp_path))\n        assert result[0][\"filename\"] == \"bambuddy-backup-20260412-130000.zip\"\n\n    def test_list_includes_size(self, tmp_path):\n        service = LocalBackupService()\n        (tmp_path / \"bambuddy-backup-20260412-120000.zip\").write_bytes(b\"x\" * 1024)\n        result = service.list_backups(str(tmp_path))\n        assert result[0][\"size\"] == 1024\n\n\nclass TestGetStatus:\n    \"\"\"Tests for status reporting.\"\"\"\n\n    def test_initial_status(self):\n        service = LocalBackupService()\n        status = service.get_status()\n        assert status[\"is_running\"] is False\n        assert status[\"last_backup_at\"] is None\n        assert status[\"last_status\"] is None\n        assert status[\"next_run\"] is None\n"
  },
  {
    "path": "backend/tests/unit/test_log_error_detection.py",
    "content": "\"\"\"\nTests that verify no errors are logged during normal operations.\n\nThese tests use the capture_logs fixture to detect runtime errors\nthat might not cause test failures but indicate problems.\n\"\"\"\n\n\nclass TestMQTTMessageProcessingNoErrors:\n    \"\"\"Verify MQTT message processing doesn't log errors.\"\"\"\n\n    def test_process_print_status_message(self, capture_logs):\n        \"\"\"Test processing a typical print status message.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        # Process a realistic status message\n        message = {\n            \"print\": {\n                \"gcode_state\": \"RUNNING\",\n                \"gcode_file\": \"/data/Metadata/test.gcode\",\n                \"subtask_name\": \"Test Print\",\n                \"mc_percent\": 50,\n                \"mc_remaining_time\": 1800,\n                \"layer_num\": 100,\n                \"total_layer_num\": 200,\n                \"nozzle_temper\": 220.0,\n                \"bed_temper\": 60.0,\n            }\n        }\n\n        client._process_message(message)\n\n        assert not capture_logs.has_errors(), f\"Errors during message processing: {capture_logs.format_errors()}\"\n\n    def test_process_xcam_data(self, capture_logs):\n        \"\"\"Test processing xcam (camera/AI) data.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        message = {\n            \"print\": {\n                \"gcode_state\": \"RUNNING\",\n                \"xcam\": {\n                    \"timelapse\": \"enable\",\n                    \"printing_monitor\": True,\n                    \"spaghetti_detector\": True,\n                    \"first_layer_inspector\": False,\n                },\n            }\n        }\n\n        client._process_message(message)\n\n        assert not capture_logs.has_errors(), f\"Errors during xcam processing: {capture_logs.format_errors()}\"\n\n    def test_process_ams_data(self, capture_logs):\n        \"\"\"Test processing AMS (Automatic Material System) data.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        message = {\n            \"print\": {\n                \"ams\": {\n                    \"ams\": [\n                        {\n                            \"id\": \"0\",\n                            \"humidity\": \"3\",\n                            \"temp\": \"25.0\",\n                            \"tray\": [\n                                {\n                                    \"id\": \"0\",\n                                    \"tray_type\": \"PLA\",\n                                    \"tray_color\": \"FF0000\",\n                                    \"remain\": 80,\n                                }\n                            ],\n                        }\n                    ]\n                }\n            }\n        }\n\n        client._process_message(message)\n\n        assert not capture_logs.has_errors(), f\"Errors during AMS processing: {capture_logs.format_errors()}\"\n\n    def test_process_hms_errors(self, capture_logs):\n        \"\"\"Test processing HMS (Health Management System) errors.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        message = {\n            \"print\": {\n                \"hms\": [\n                    {\n                        \"attr\": 0,\n                        \"code\": 117506052,\n                    }\n                ]\n            }\n        }\n\n        client._process_message(message)\n\n        assert not capture_logs.has_errors(), f\"Errors during HMS processing: {capture_logs.format_errors()}\"\n\n\nclass TestPrintLifecycleNoErrors:\n    \"\"\"Verify print lifecycle doesn't log errors.\"\"\"\n\n    def test_print_start_to_complete(self, capture_logs):\n        \"\"\"Test full print lifecycle from start to completion.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        client.on_print_start = lambda data: None\n        client.on_print_complete = lambda data: None\n\n        # Start print\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"mc_percent\": 0,\n                }\n            }\n        )\n\n        # Progress updates\n        for percent in [25, 50, 75]:\n            client._process_message(\n                {\n                    \"print\": {\n                        \"gcode_state\": \"RUNNING\",\n                        \"gcode_file\": \"/data/Metadata/test.gcode\",\n                        \"mc_percent\": percent,\n                    }\n                }\n            )\n\n        # Complete\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FINISH\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        assert not capture_logs.has_errors(), f\"Errors during print lifecycle: {capture_logs.format_errors()}\"\n\n    def test_print_failure_handling(self, capture_logs):\n        \"\"\"Test print failure is handled without errors.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n        client.on_print_start = lambda data: None\n        client.on_print_complete = lambda data: None\n\n        # Start print\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                }\n            }\n        )\n\n        # Fail\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"FAILED\",\n                    \"gcode_file\": \"/data/Metadata/test.gcode\",\n                    \"subtask_name\": \"Test\",\n                    \"print_error\": 117506052,\n                }\n            }\n        )\n\n        assert not capture_logs.has_errors(), f\"Errors during print failure: {capture_logs.format_errors()}\"\n\n\nclass TestServiceImports:\n    \"\"\"Verify service imports don't have issues.\"\"\"\n\n    def test_archive_service_import(self, capture_logs):\n        \"\"\"Verify ArchiveService can be imported without errors.\"\"\"\n        from backend.app.services.archive import ArchiveService\n\n        assert ArchiveService is not None\n        assert not capture_logs.has_errors()\n\n    def test_notification_service_import(self, capture_logs):\n        \"\"\"Verify NotificationService can be imported without errors.\"\"\"\n        from backend.app.services.notification_service import notification_service\n\n        assert notification_service is not None\n        assert not capture_logs.has_errors()\n\n    def test_printer_manager_import(self, capture_logs):\n        \"\"\"Verify PrinterManager can be imported without errors.\"\"\"\n        from backend.app.services.printer_manager import printer_manager\n\n        assert printer_manager is not None\n        assert not capture_logs.has_errors()\n\n    def test_main_module_import(self, capture_logs):\n        \"\"\"Verify main module imports cleanly.\"\"\"\n        # This will fail if there are import shadowing issues\n        from backend.app import main\n\n        assert main is not None\n\n        # Verify key functions exist\n        assert hasattr(main, \"on_print_start\")\n        assert hasattr(main, \"on_print_complete\")\n        assert not capture_logs.has_errors()\n\n\nclass TestEdgeCases:\n    \"\"\"Test edge cases that might cause errors.\"\"\"\n\n    def test_empty_message(self, capture_logs):\n        \"\"\"Test handling of empty message.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        client._process_message({})\n\n        assert not capture_logs.has_errors(), f\"Errors with empty message: {capture_logs.format_errors()}\"\n\n    def test_message_with_unknown_fields(self, capture_logs):\n        \"\"\"Test handling of message with unknown fields.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"RUNNING\",\n                    \"unknown_field_1\": \"value1\",\n                    \"unknown_field_2\": 12345,\n                    \"unknown_nested\": {\"a\": 1, \"b\": 2},\n                }\n            }\n        )\n\n        assert not capture_logs.has_errors(), f\"Errors with unknown fields: {capture_logs.format_errors()}\"\n\n    def test_message_with_null_values(self, capture_logs):\n        \"\"\"Test handling of message with null values for optional fields.\"\"\"\n        from backend.app.services.bambu_mqtt import BambuMQTTClient\n\n        client = BambuMQTTClient(\n            ip_address=\"192.168.1.100\",\n            serial_number=\"TEST123\",\n            access_code=\"12345678\",\n        )\n\n        # Only test null values for fields that should handle them gracefully\n        # mc_percent is expected to be a number when present\n        client._process_message(\n            {\n                \"print\": {\n                    \"gcode_state\": \"IDLE\",\n                    \"gcode_file\": None,\n                    \"subtask_name\": None,\n                    \"bed_temper\": 0.0,  # Use 0 instead of None\n                }\n            }\n        )\n\n        assert not capture_logs.has_errors(), f\"Errors with null values: {capture_logs.format_errors()}\"\n"
  },
  {
    "path": "backend/tests/unit/test_maintenance_rod_filtering.py",
    "content": "\"\"\"Unit tests for maintenance rod-type filtering logic.\"\"\"\n\nimport pytest\n\nfrom backend.app.api.routes.maintenance import _should_apply_to_printer\n\n\nclass TestShouldApplyToPrinter:\n    \"\"\"Tests for _should_apply_to_printer() model-specific filtering.\"\"\"\n\n    # Carbon rod tasks should only apply to X1/P1 models\n    @pytest.mark.parametrize(\"model\", [\"X1C\", \"X1\", \"X1E\", \"P1P\", \"P1S\"])\n    def test_carbon_rod_tasks_apply_to_carbon_models(self, model: str):\n        assert _should_apply_to_printer(\"Clean Carbon Rods\", model) is True\n\n    def test_carbon_rod_tasks_do_not_apply_to_p2s(self):\n        \"\"\"P2S has steel rods, not carbon rods (#640).\"\"\"\n        assert _should_apply_to_printer(\"Clean Carbon Rods\", \"P2S\") is False\n\n    def test_carbon_rod_tasks_do_not_apply_to_a1(self):\n        assert _should_apply_to_printer(\"Clean Carbon Rods\", \"A1\") is False\n\n    # Steel rod tasks should only apply to P2S\n    def test_steel_rod_tasks_apply_to_p2s(self):\n        assert _should_apply_to_printer(\"Lubricate Steel Rods\", \"P2S\") is True\n        assert _should_apply_to_printer(\"Clean Steel Rods\", \"P2S\") is True\n\n    def test_steel_rod_tasks_do_not_apply_to_x1c(self):\n        assert _should_apply_to_printer(\"Lubricate Steel Rods\", \"X1C\") is False\n        assert _should_apply_to_printer(\"Clean Steel Rods\", \"X1C\") is False\n\n    def test_steel_rod_tasks_do_not_apply_to_a1(self):\n        assert _should_apply_to_printer(\"Lubricate Steel Rods\", \"A1\") is False\n\n    # Linear rail tasks should only apply to A1/H2 models\n    @pytest.mark.parametrize(\"model\", [\"A1\", \"A1 Mini\", \"H2D\", \"H2C\", \"H2S\"])\n    def test_linear_rail_tasks_apply_to_rail_models(self, model: str):\n        assert _should_apply_to_printer(\"Lubricate Linear Rails\", model) is True\n        assert _should_apply_to_printer(\"Clean Linear Rails\", model) is True\n\n    def test_linear_rail_tasks_do_not_apply_to_p2s(self):\n        assert _should_apply_to_printer(\"Lubricate Linear Rails\", \"P2S\") is False\n\n    # Universal tasks apply to all models\n    @pytest.mark.parametrize(\"model\", [\"X1C\", \"P2S\", \"A1\", \"H2D\"])\n    def test_universal_tasks_apply_to_all(self, model: str):\n        assert _should_apply_to_printer(\"Clean Nozzle/Hotend\", model) is True\n        assert _should_apply_to_printer(\"Check Belt Tension\", model) is True\n\n    # Unknown models default to carbon (legacy behavior)\n    def test_unknown_model_defaults_to_carbon(self):\n        assert _should_apply_to_printer(\"Clean Carbon Rods\", \"UNKNOWN\") is True\n        assert _should_apply_to_printer(\"Lubricate Steel Rods\", \"UNKNOWN\") is False\n        assert _should_apply_to_printer(\"Lubricate Linear Rails\", \"UNKNOWN\") is False\n"
  },
  {
    "path": "backend/tests/unit/test_mfa_helpers.py",
    "content": "\"\"\"Unit tests for 2FA helper functions in mfa.py.\"\"\"\n\nimport base64\nimport string\n\nimport pytest\nfrom passlib.context import CryptContext\n\nfrom backend.app.api.routes.mfa import _generate_backup_codes, _generate_totp_qr_b64\n\n\nclass TestBackupCodeGeneration:\n    \"\"\"Tests for backup code helpers.\"\"\"\n\n    def test_generates_ten_codes(self):\n        plain, hashed = _generate_backup_codes()\n        assert len(plain) == 10\n        assert len(hashed) == 10\n\n    def test_codes_are_eight_chars(self):\n        plain, _ = _generate_backup_codes()\n        for code in plain:\n            assert len(code) == 8\n\n    def test_codes_are_alphanumeric(self):\n        allowed = set(string.ascii_uppercase + string.digits)\n        plain, _ = _generate_backup_codes()\n        for code in plain:\n            assert all(c in allowed for c in code)\n\n    def test_hashes_verify_against_plain(self):\n        ctx = CryptContext(schemes=[\"pbkdf2_sha256\"], deprecated=\"auto\")\n        plain, hashed = _generate_backup_codes()\n        for p, h in zip(plain, hashed, strict=True):\n            assert ctx.verify(p, h)\n\n    def test_codes_are_unique(self):\n        plain, _ = _generate_backup_codes()\n        assert len(set(plain)) == 10\n\n\nclass TestTOTPQRCode:\n    \"\"\"Tests for QR code generation helper.\"\"\"\n\n    def test_generates_base64_png(self):\n        uri = \"otpauth://totp/Bambuddy:testuser?secret=BASE32SECRET&issuer=Bambuddy\"\n        result = _generate_totp_qr_b64(uri)\n        decoded = base64.b64decode(result)\n        assert decoded[:4] == b\"\\x89PNG\"\n"
  },
  {
    "path": "backend/tests/unit/test_obico_detection.py",
    "content": "\"\"\"Unit tests for Obico detection service (#172).\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.schemas.settings import AppSettingsUpdate\nfrom backend.app.services.obico_detection import (\n    FRAME_CACHE_TTL,\n    ObicoDetectionService,\n    _frame_cache,\n    pop_frame,\n    stash_frame,\n)\nfrom backend.app.services.obico_smoothing import WARMUP_FRAMES\n\nFAKE_JPEG = b\"\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x00\\x00\\x01\\x00\\x01\\x00\\x00\\xff\\xd9\"\n\n\nclass TestSettingsSchemaValidators:\n    \"\"\"Guard rails on the new obico_* AppSettings fields.\"\"\"\n\n    def test_sensitivity_accepts_valid_values(self):\n        for value in (\"low\", \"medium\", \"high\"):\n            u = AppSettingsUpdate(obico_sensitivity=value)\n            assert u.obico_sensitivity == value\n\n    def test_sensitivity_rejects_garbage(self):\n        with pytest.raises(ValueError, match=\"obico_sensitivity\"):\n            AppSettingsUpdate(obico_sensitivity=\"extreme\")\n\n    def test_action_accepts_valid_values(self):\n        for value in (\"notify\", \"pause\", \"pause_and_off\"):\n            assert AppSettingsUpdate(obico_action=value).obico_action == value\n\n    def test_action_rejects_garbage(self):\n        with pytest.raises(ValueError, match=\"obico_action\"):\n            AppSettingsUpdate(obico_action=\"explode\")\n\n    def test_enabled_printers_accepts_empty(self):\n        assert AppSettingsUpdate(obico_enabled_printers=\"\").obico_enabled_printers == \"\"\n        assert AppSettingsUpdate(obico_enabled_printers=None).obico_enabled_printers is None\n\n    def test_enabled_printers_accepts_int_array(self):\n        u = AppSettingsUpdate(obico_enabled_printers=\"[1, 2, 3]\")\n        assert u.obico_enabled_printers == \"[1, 2, 3]\"\n\n    def test_enabled_printers_rejects_non_json(self):\n        with pytest.raises(ValueError, match=\"valid JSON\"):\n            AppSettingsUpdate(obico_enabled_printers=\"1,2,3\")\n\n    def test_enabled_printers_rejects_non_list(self):\n        with pytest.raises(ValueError, match=\"JSON array\"):\n            AppSettingsUpdate(obico_enabled_printers='{\"1\": true}')\n\n    def test_enabled_printers_rejects_non_int_elements(self):\n        with pytest.raises(ValueError, match=\"JSON array\"):\n            AppSettingsUpdate(obico_enabled_printers='[1, \"two\"]')\n\n    def test_poll_interval_bounds(self):\n        with pytest.raises(ValueError):\n            AppSettingsUpdate(obico_poll_interval=4)\n        with pytest.raises(ValueError):\n            AppSettingsUpdate(obico_poll_interval=121)\n        assert AppSettingsUpdate(obico_poll_interval=10).obico_poll_interval == 10\n\n\nclass TestGetStatus:\n    def test_empty_initial_status(self):\n        svc = ObicoDetectionService()\n        s = svc.get_status()\n        assert s[\"is_running\"] is False\n        assert s[\"per_printer\"] == {}\n        assert s[\"history\"] == []\n        assert \"low\" in s[\"thresholds\"] and \"high\" in s[\"thresholds\"]\n\n\nclass TestTestConnection:\n    @pytest.mark.asyncio\n    async def test_empty_url_via_route(self):\n        \"\"\"Service does not special-case empty URL — the route does.\"\"\"\n        svc = ObicoDetectionService()\n        # This will fail DNS/connect, but should return ok=False\n        result = await svc.test_connection(\"http://nonexistent-obico-host-xyz.invalid:3333\")\n        assert result[\"ok\"] is False\n        assert result[\"error\"] is not None\n\n    @pytest.mark.asyncio\n    async def test_healthy_response_is_ok(self):\n        svc = ObicoDetectionService()\n        mock_response = MagicMock(status_code=200, text=\"ok\")\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock(return_value=mock_response)\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client):\n            result = await svc.test_connection(\"http://obico:3333\")\n        assert result[\"ok\"] is True\n        assert result[\"status_code\"] == 200\n        assert result[\"body\"] == \"ok\"\n        assert result[\"error\"] is None\n\n    @pytest.mark.asyncio\n    async def test_non_ok_body_is_not_ok(self):\n        svc = ObicoDetectionService()\n        mock_response = MagicMock(status_code=200, text=\"something else\")\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock(return_value=mock_response)\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client):\n            result = await svc.test_connection(\"http://obico:3333/\")\n        assert result[\"ok\"] is False\n        assert result[\"body\"] == \"something else\"\n\n\nclass TestPollOneStateLifecycle:\n    \"\"\"Confirms per-printer state is reset when a new print starts.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_new_task_name_resets_state(self):\n        svc = ObicoDetectionService()\n        # Seed a state that has been running for a while\n        from backend.app.services.obico_smoothing import PrintState\n\n        seeded = PrintState()\n        for _ in range(WARMUP_FRAMES + 5):\n            seeded.update(0.5)\n        svc._states[1] = seeded\n        svc._state_keys[1] = \"old_task\"\n        svc._action_fired[1] = True\n\n        settings = {\n            \"enabled\": True,\n            \"ml_url\": \"http://obico:3333\",\n            \"sensitivity\": \"medium\",\n            \"action\": \"notify\",\n            \"poll_interval\": 10,\n            \"enabled_printers\": None,\n            \"external_url\": \"http://bambuddy:8000\",\n        }\n        status = MagicMock(state=\"RUNNING\", task_name=\"new_task\", subtask_name=\"\")\n\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"detections\": []}\n        mock_response.raise_for_status = MagicMock()\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock(return_value=mock_response)\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with (\n            patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client),\n            patch.object(svc, \"_capture_frame\", new=AsyncMock(return_value=FAKE_JPEG)),\n        ):\n            await svc._check_printer(1, status, settings)\n\n        # State was reset (frame_count is 1 after the single update, not 36)\n        assert svc._states[1].frame_count == 1\n        assert svc._state_keys[1] == \"new_task\"\n        assert svc._action_fired[1] is False\n\n    @pytest.mark.asyncio\n    async def test_ml_api_error_does_not_crash(self):\n        svc = ObicoDetectionService()\n        settings = {\n            \"enabled\": True,\n            \"ml_url\": \"http://obico:3333\",\n            \"sensitivity\": \"medium\",\n            \"action\": \"notify\",\n            \"poll_interval\": 10,\n            \"enabled_printers\": None,\n            \"external_url\": \"http://bambuddy:8000\",\n        }\n        status = MagicMock(state=\"RUNNING\", task_name=\"job\", subtask_name=\"\")\n\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock(side_effect=RuntimeError(\"connection refused\"))\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with (\n            patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client),\n            patch.object(svc, \"_capture_frame\", new=AsyncMock(return_value=FAKE_JPEG)),\n        ):\n            await svc._check_printer(1, status, settings)\n\n        assert svc._last_error is not None\n        assert \"connection refused\" in svc._last_error\n\n    @pytest.mark.asyncio\n    async def test_ml_api_empty_exception_message_falls_back_to_type(self):\n        \"\"\"If str(exc) is empty, log the exception class name instead of a blank suffix.\"\"\"\n        svc = ObicoDetectionService()\n        settings = {\n            \"enabled\": True,\n            \"ml_url\": \"http://obico:3333\",\n            \"sensitivity\": \"medium\",\n            \"action\": \"notify\",\n            \"poll_interval\": 10,\n            \"enabled_printers\": None,\n            \"external_url\": \"http://bambuddy:8000\",\n        }\n        status = MagicMock(state=\"RUNNING\", task_name=\"job\", subtask_name=\"\")\n\n        class _SilentError(Exception):\n            def __str__(self) -> str:\n                return \"\"\n\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock(side_effect=_SilentError())\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with (\n            patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client),\n            patch.object(svc, \"_capture_frame\", new=AsyncMock(return_value=FAKE_JPEG)),\n        ):\n            await svc._check_printer(1, status, settings)\n\n        assert svc._last_error is not None\n        assert \"_SilentError\" in svc._last_error\n        # The suffix is never blank\n        assert not svc._last_error.rstrip().endswith(\":\")\n\n    @pytest.mark.asyncio\n    async def test_failure_fires_action_only_once(self):\n        \"\"\"Once a failure has fired for a print, subsequent failures should not re-fire.\"\"\"\n        svc = ObicoDetectionService()\n        settings = {\n            \"enabled\": True,\n            \"ml_url\": \"http://obico:3333\",\n            \"sensitivity\": \"medium\",\n            \"action\": \"notify\",\n            \"poll_interval\": 10,\n            \"enabled_printers\": None,\n            \"external_url\": \"http://bambuddy:8000\",\n        }\n        status = MagicMock(state=\"RUNNING\", task_name=\"job\", subtask_name=\"\")\n\n        # Seed state so the next frame crosses HIGH immediately\n        from backend.app.services.obico_smoothing import PrintState\n\n        seeded = PrintState()\n        for _ in range(WARMUP_FRAMES + 500):\n            seeded.update(1.0)\n        svc._states[1] = seeded\n        svc._state_keys[1] = \"job\"\n        svc._action_fired[1] = False\n\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"detections\": [[\"failure\", 0.9, [0, 0, 1, 1]]]}\n        mock_response.raise_for_status = MagicMock()\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock(return_value=mock_response)\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with (\n            patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client),\n            patch(\"backend.app.services.obico_actions.execute_action\", new=AsyncMock()) as mock_action,\n            patch.object(svc, \"_capture_frame\", new=AsyncMock(return_value=FAKE_JPEG)),\n        ):\n            await svc._check_printer(1, status, settings)\n            assert mock_action.call_count == 1\n            await svc._check_printer(1, status, settings)\n            # Second call must not dispatch again\n            assert mock_action.call_count == 1\n\n\nclass TestFrameCache:\n    \"\"\"One-shot JPEG cache that lets us sidestep Obico's 5s read timeout.\n\n    Obico's ML API fetches snapshots via `GET /p/?img=URL` with `timeout=(0.1, 5)`.\n    Our /camera/snapshot can exceed that on cold calls (RTSP keyframe wait). So the\n    detection loop captures locally, stashes the JPEG bytes under a nonce, then hands\n    Obico a URL that returns those bytes instantly. The cache is single-use + TTLed\n    so a leaked nonce can't be replayed.\n    \"\"\"\n\n    def setup_method(self):\n        _frame_cache.clear()\n\n    @pytest.mark.asyncio\n    async def test_stash_and_pop_roundtrip(self):\n        nonce = await stash_frame(FAKE_JPEG)\n        assert nonce  # non-empty URL-safe token\n        data = await pop_frame(nonce)\n        assert data == FAKE_JPEG\n\n    @pytest.mark.asyncio\n    async def test_nonce_is_single_use(self):\n        nonce = await stash_frame(FAKE_JPEG)\n        assert await pop_frame(nonce) == FAKE_JPEG\n        # Second pop returns None — caches replay protection\n        assert await pop_frame(nonce) is None\n\n    @pytest.mark.asyncio\n    async def test_unknown_nonce_returns_none(self):\n        assert await pop_frame(\"not-a-real-nonce\") is None\n\n    @pytest.mark.asyncio\n    async def test_stash_produces_unique_nonces(self):\n        nonces = {await stash_frame(FAKE_JPEG) for _ in range(10)}\n        assert len(nonces) == 10\n\n    @pytest.mark.asyncio\n    async def test_expired_entries_are_pruned_on_stash(self):\n        \"\"\"New entries trigger pruning of TTL-expired ones — prevents unbounded growth.\"\"\"\n        # Manually seed an entry with a stale timestamp\n        import time as time_module\n\n        _frame_cache[\"stale-nonce\"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)\n        await stash_frame(FAKE_JPEG)\n        # Stale entry was pruned\n        assert \"stale-nonce\" not in _frame_cache\n\n    @pytest.mark.asyncio\n    async def test_pop_rejects_expired_nonce(self):\n        \"\"\"Even if the entry is still in the dict, an expired TTL returns None.\"\"\"\n        import time as time_module\n\n        _frame_cache[\"aging-nonce\"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)\n        assert await pop_frame(\"aging-nonce\") is None\n\n\nclass TestCheckPrinterUsesCachedFrameUrl:\n    \"\"\"The URL sent to Obico must point at our nonce endpoint, not /camera/snapshot.\"\"\"\n\n    def setup_method(self):\n        _frame_cache.clear()\n\n    @pytest.mark.asyncio\n    async def test_ml_api_called_with_cached_frame_url(self):\n        svc = ObicoDetectionService()\n        settings = {\n            \"enabled\": True,\n            \"ml_url\": \"http://obico:3333\",\n            \"sensitivity\": \"medium\",\n            \"action\": \"notify\",\n            \"poll_interval\": 10,\n            \"enabled_printers\": None,\n            \"external_url\": \"http://bambuddy:8000\",\n        }\n        status = MagicMock(state=\"RUNNING\", task_name=\"job\", subtask_name=\"\")\n\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"detections\": []}\n        mock_response.raise_for_status = MagicMock()\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock(return_value=mock_response)\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with (\n            patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client),\n            patch.object(svc, \"_capture_frame\", new=AsyncMock(return_value=FAKE_JPEG)),\n        ):\n            await svc._check_printer(1, status, settings)\n\n        # ML API was called via GET (Obico's /p/ is GET-only)\n        mock_client.get.assert_called_once()\n        _args, kwargs = mock_client.get.call_args\n        assert _args[0] == \"http://obico:3333/p/\"\n        img_url = kwargs[\"params\"][\"img\"]\n        assert img_url.startswith(\"http://bambuddy:8000/api/v1/obico/cached-frame/\")\n        # The path segment after /cached-frame/ is the nonce itself — that nonce must\n        # resolve back to our stashed frame (single-use guarantees freshness).\n        nonce = img_url.rsplit(\"/\", 1)[-1]\n        assert await pop_frame(nonce) == FAKE_JPEG\n\n    @pytest.mark.asyncio\n    async def test_capture_failure_skips_ml_call(self):\n        \"\"\"If we can't capture a frame, don't bother the ML API.\"\"\"\n        svc = ObicoDetectionService()\n        settings = {\n            \"enabled\": True,\n            \"ml_url\": \"http://obico:3333\",\n            \"sensitivity\": \"medium\",\n            \"action\": \"notify\",\n            \"poll_interval\": 10,\n            \"enabled_printers\": None,\n            \"external_url\": \"http://bambuddy:8000\",\n        }\n        status = MagicMock(state=\"RUNNING\", task_name=\"job\", subtask_name=\"\")\n\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock()\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with (\n            patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client),\n            patch.object(svc, \"_capture_frame\", new=AsyncMock(return_value=None)),\n        ):\n            await svc._check_printer(1, status, settings)\n\n        mock_client.get.assert_not_called()\n        assert svc._last_error is not None\n        assert \"Failed to capture snapshot\" in svc._last_error\n\n    @pytest.mark.asyncio\n    async def test_missing_external_url_skips_ml_call(self):\n        \"\"\"Without external_url, Obico can't reach our cached-frame endpoint.\"\"\"\n        svc = ObicoDetectionService()\n        settings = {\n            \"enabled\": True,\n            \"ml_url\": \"http://obico:3333\",\n            \"sensitivity\": \"medium\",\n            \"action\": \"notify\",\n            \"poll_interval\": 10,\n            \"enabled_printers\": None,\n            \"external_url\": \"\",\n        }\n        status = MagicMock(state=\"RUNNING\", task_name=\"job\", subtask_name=\"\")\n\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock()\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with (\n            patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client),\n            patch.object(svc, \"_capture_frame\", new=AsyncMock(return_value=FAKE_JPEG)),\n        ):\n            await svc._check_printer(1, status, settings)\n\n        mock_client.get.assert_not_called()\n        assert svc._last_error is not None\n        assert \"external_url\" in svc._last_error\n\n    @pytest.mark.asyncio\n    async def test_successful_cycle_clears_previous_error(self):\n        \"\"\"A cold-start RTSP timeout sets _last_error; the next successful poll must clear it.\n\n        Regression for #172: the Status card banner (\"Failed to capture snapshot for\n        printer 1\") stuck around after a one-off cold-start failure even though every\n        subsequent poll captured + detected successfully.\n        \"\"\"\n        svc = ObicoDetectionService()\n        settings = {\n            \"enabled\": True,\n            \"ml_url\": \"http://obico:3333\",\n            \"sensitivity\": \"medium\",\n            \"action\": \"notify\",\n            \"poll_interval\": 10,\n            \"enabled_printers\": None,\n            \"external_url\": \"http://bambuddy:8000\",\n        }\n        status = MagicMock(state=\"RUNNING\", task_name=\"job\", subtask_name=\"\")\n\n        # Seed a prior transient error, as would be left by a cold-start capture timeout.\n        svc._last_error = \"Failed to capture snapshot for printer 1\"\n\n        mock_response = MagicMock()\n        mock_response.json.return_value = {\"detections\": []}\n        mock_response.raise_for_status = MagicMock()\n        mock_client = MagicMock()\n        mock_client.get = AsyncMock(return_value=mock_response)\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with (\n            patch(\"backend.app.services.obico_detection.httpx.AsyncClient\", return_value=mock_client),\n            patch.object(svc, \"_capture_frame\", new=AsyncMock(return_value=FAKE_JPEG)),\n        ):\n            await svc._check_printer(1, status, settings)\n\n        assert svc._last_error is None\n"
  },
  {
    "path": "backend/tests/unit/test_obico_smoothing.py",
    "content": "\"\"\"Unit tests for Obico detection smoothing math.\"\"\"\n\nimport pytest\n\nfrom backend.app.services.obico_smoothing import (\n    BASE_HIGH,\n    BASE_LOW,\n    WARMUP_FRAMES,\n    PrintState,\n    classify,\n    score_from_detections,\n    thresholds,\n)\n\n\nclass TestThresholds:\n    def test_medium_matches_base(self):\n        low, high = thresholds(\"medium\")\n        assert low == pytest.approx(BASE_LOW)\n        assert high == pytest.approx(BASE_HIGH)\n\n    def test_low_sensitivity_is_stricter(self):\n        low, high = thresholds(\"low\")\n        assert low > BASE_LOW\n        assert high > BASE_HIGH\n\n    def test_high_sensitivity_is_looser(self):\n        low, high = thresholds(\"high\")\n        assert low < BASE_LOW\n        assert high < BASE_HIGH\n\n    def test_unknown_falls_back_to_medium(self):\n        assert thresholds(\"bogus\") == thresholds(\"medium\")\n\n\nclass TestScoreFromDetections:\n    def test_empty(self):\n        assert score_from_detections([]) == 0.0\n        assert score_from_detections(None) == 0.0\n\n    def test_sums_confidences(self):\n        dets = [[\"failure\", 0.3, [0, 0, 10, 10]], [\"failure\", 0.5, [0, 0, 10, 10]]]\n        assert score_from_detections(dets) == pytest.approx(0.8)\n\n    def test_ignores_malformed(self):\n        dets = [[\"failure\", 0.4, []], [\"bad\"], [\"failure\", \"nan\", []]]\n        assert score_from_detections(dets) == pytest.approx(0.4)\n\n\nclass TestPrintState:\n    def test_warmup_returns_zero(self):\n        state = PrintState()\n        for _ in range(WARMUP_FRAMES):\n            assert state.update(0.9) == 0.0\n\n    def test_after_warmup_returns_nonzero_for_hits(self):\n        state = PrintState()\n        for _ in range(WARMUP_FRAMES):\n            state.update(0.9)\n        score = state.update(0.9)\n        assert score > 0.0\n\n    def test_sustained_zero_stays_safe(self):\n        state = PrintState()\n        scores = [state.update(0.0) for _ in range(WARMUP_FRAMES + 50)]\n        assert max(scores) == 0.0\n\n    def test_sustained_hits_eventually_cross_high(self):\n        \"\"\"A stream of high-confidence frames must escalate to 'failure'.\"\"\"\n        state = PrintState()\n        final = 0.0\n        for _ in range(WARMUP_FRAMES + 200):\n            final = state.update(1.0)\n        _, high = thresholds(\"medium\")\n        assert final >= high\n\n    def test_isolated_spike_does_not_trigger_failure(self):\n        \"\"\"A single noisy frame in a clean stream must not cross HIGH.\"\"\"\n        state = PrintState()\n        for _ in range(WARMUP_FRAMES):\n            state.update(0.0)\n        score = state.update(1.0)\n        _, high = thresholds(\"medium\")\n        assert score < high\n\n\nclass TestClassify:\n    def test_safe(self):\n        assert classify(0.0, \"medium\") == \"safe\"\n        assert classify(BASE_LOW - 0.01, \"medium\") == \"safe\"\n\n    def test_warning(self):\n        assert classify(BASE_LOW, \"medium\") == \"warning\"\n        assert classify((BASE_LOW + BASE_HIGH) / 2, \"medium\") == \"warning\"\n\n    def test_failure(self):\n        assert classify(BASE_HIGH, \"medium\") == \"failure\"\n        assert classify(1.0, \"medium\") == \"failure\"\n"
  },
  {
    "path": "backend/tests/unit/test_opentag3d.py",
    "content": "\"\"\"Unit tests for OpenTag3D NDEF encoder.\"\"\"\n\nimport struct\nfrom unittest.mock import MagicMock\n\nfrom backend.app.services.opentag3d import (\n    OPENTAG3D_MIME_TYPE,\n    PAYLOAD_SIZE,\n    _build_payload,\n    encode_opentag3d,\n)\n\n\ndef _make_spool(**kwargs):\n    \"\"\"Create a mock Spool with default values.\"\"\"\n    defaults = {\n        \"material\": \"PLA\",\n        \"subtype\": \"Matte\",\n        \"brand\": \"Polymaker\",\n        \"color_name\": \"Jade White\",\n        \"rgba\": \"00AE42FF\",\n        \"label_weight\": 1000,\n        \"nozzle_temp_min\": 220,\n    }\n    defaults.update(kwargs)\n    spool = MagicMock()\n    for k, v in defaults.items():\n        setattr(spool, k, v)\n    return spool\n\n\nclass TestBuildPayload:\n    def test_payload_is_102_bytes(self):\n        spool = _make_spool()\n        payload = _build_payload(spool)\n        assert len(payload) == PAYLOAD_SIZE\n\n    def test_tag_version(self):\n        payload = _build_payload(_make_spool())\n        version = struct.unpack_from(\">H\", payload, 0x00)[0]\n        assert version == 1000\n\n    def test_material_field(self):\n        payload = _build_payload(_make_spool(material=\"PETG\"))\n        material = payload[0x02:0x07].decode(\"utf-8\")\n        assert material == \"PETG \"\n\n    def test_material_truncated(self):\n        payload = _build_payload(_make_spool(material=\"SUPERLONG\"))\n        material = payload[0x02:0x07].decode(\"utf-8\")\n        assert material == \"SUPER\"\n\n    def test_modifiers_field(self):\n        payload = _build_payload(_make_spool(subtype=\"Silk\"))\n        mods = payload[0x07:0x0C].decode(\"utf-8\")\n        assert mods == \"Silk \"\n\n    def test_modifiers_none(self):\n        payload = _build_payload(_make_spool(subtype=None))\n        mods = payload[0x07:0x0C].decode(\"utf-8\")\n        assert mods == \"     \"\n\n    def test_reserved_is_zero(self):\n        payload = _build_payload(_make_spool())\n        assert payload[0x0C:0x1B] == b\"\\x00\" * 15\n\n    def test_brand_field(self):\n        payload = _build_payload(_make_spool(brand=\"Polymaker\"))\n        brand = payload[0x1B:0x2B].decode(\"utf-8\")\n        assert brand == \"Polymaker       \"\n\n    def test_color_name_field(self):\n        payload = _build_payload(_make_spool(color_name=\"Jade White\"))\n        cn = payload[0x2B:0x4B].decode(\"utf-8\")\n        assert cn.startswith(\"Jade White\")\n        assert len(cn) == 32\n\n    def test_rgba_field(self):\n        payload = _build_payload(_make_spool(rgba=\"FF0000FF\"))\n        assert payload[0x4B:0x4F] == bytes([0xFF, 0x00, 0x00, 0xFF])\n\n    def test_rgba_none(self):\n        payload = _build_payload(_make_spool(rgba=None))\n        assert payload[0x4B:0x4F] == b\"\\x00\\x00\\x00\\x00\"\n\n    def test_target_diameter(self):\n        payload = _build_payload(_make_spool())\n        diameter = struct.unpack_from(\">H\", payload, 0x5C)[0]\n        assert diameter == 1750\n\n    def test_target_weight(self):\n        payload = _build_payload(_make_spool(label_weight=750))\n        weight = struct.unpack_from(\">H\", payload, 0x5E)[0]\n        assert weight == 750\n\n    def test_print_temp(self):\n        payload = _build_payload(_make_spool(nozzle_temp_min=220))\n        assert payload[0x60] == 44  # 220 / 5\n\n    def test_print_temp_none(self):\n        payload = _build_payload(_make_spool(nozzle_temp_min=None))\n        assert payload[0x60] == 0\n\n\nclass TestEncodeOpentag3d:\n    def test_starts_with_cc(self):\n        data = encode_opentag3d(_make_spool())\n        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])\n\n    def test_tlv_header(self):\n        data = encode_opentag3d(_make_spool())\n        # TLV type = 0x03\n        assert data[4] == 0x03\n        # TLV length = 3 (record header) + 21 (mime type) + 102 (payload) = 126\n        assert data[5] == 126\n\n    def test_ndef_record_header(self):\n        data = encode_opentag3d(_make_spool())\n        # Record starts after CC(4) + TLV(2) = offset 6\n        assert data[6] == 0xD2  # MB|ME|SR + TNF=MIME\n        assert data[7] == len(OPENTAG3D_MIME_TYPE)  # type length = 21\n        assert data[8] == PAYLOAD_SIZE  # payload length = 102\n\n    def test_mime_type(self):\n        data = encode_opentag3d(_make_spool())\n        mime = data[9:30]\n        assert mime == b\"application/opentag3d\"\n\n    def test_ends_with_terminator(self):\n        data = encode_opentag3d(_make_spool())\n        assert data[-1] == 0xFE\n\n    def test_total_size(self):\n        data = encode_opentag3d(_make_spool())\n        # CC(4) + TLV(2) + header(3) + type(21) + payload(102) + terminator(1) = 133\n        assert len(data) == 133\n\n    def test_fits_ntag213(self):\n        \"\"\"NTAG213 has 36 writable pages (144 bytes). Our data must fit.\"\"\"\n        data = encode_opentag3d(_make_spool())\n        ntag213_capacity = 36 * 4  # 144 bytes\n        assert len(data) <= ntag213_capacity\n"
  },
  {
    "path": "backend/tests/unit/test_orca_profiles.py",
    "content": "\"\"\"Unit tests for OrcaSlicer profile import service.\n\nTests _guess_profile_type, _parse_material_from_name, _parse_vendor_from_name,\nand extract_core_fields.\n\"\"\"\n\nimport json\n\nimport pytest\n\n\nclass TestGuessProfileType:\n    \"\"\"Tests for _guess_profile_type().\"\"\"\n\n    def test_explicit_filament_type(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"type\": \"filament\", \"name\": \"Some Filament\"}\n        assert _guess_profile_type(data) == \"filament\"\n\n    def test_explicit_process_type(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"type\": \"process\", \"name\": \"0.20mm Standard\"}\n        assert _guess_profile_type(data) == \"process\"\n\n    def test_explicit_machine_type(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"type\": \"machine\", \"name\": \"Bambu Lab X1C\"}\n        assert _guess_profile_type(data) == \"printer\"\n\n    def test_explicit_printer_type(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"type\": \"printer\", \"name\": \"Bambu Lab X1C\"}\n        assert _guess_profile_type(data) == \"printer\"\n\n    def test_explicit_print_type(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"type\": \"print\", \"name\": \"0.20mm Standard\"}\n        assert _guess_profile_type(data) == \"process\"\n\n    def test_path_hint_filament(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"Some Preset\"}\n        assert _guess_profile_type(data, path_hint=\"filament/MyPreset.json\") == \"filament\"\n\n    def test_path_hint_process(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"Some Preset\"}\n        assert _guess_profile_type(data, path_hint=\"process/MyProcess.json\") == \"process\"\n\n    def test_path_hint_machine(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"Some Preset\"}\n        assert _guess_profile_type(data, path_hint=\"machine/MyPrinter.json\") == \"printer\"\n\n    def test_print_settings_id_indicates_process(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"# 0.08mm Extra Fine @BBL H2D\", \"print_settings_id\": \"# 0.08mm Extra Fine @BBL H2D\"}\n        assert _guess_profile_type(data) == \"process\"\n\n    def test_filament_settings_id_indicates_filament(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"eSUN PLA\", \"filament_settings_id\": \"eSUN PLA\"}\n        assert _guess_profile_type(data) == \"filament\"\n\n    def test_printer_settings_id_indicates_printer(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"Bambu Lab X1C\", \"printer_settings_id\": \"Bambu Lab X1C\"}\n        assert _guess_profile_type(data) == \"printer\"\n\n    def test_prime_tower_keys_indicate_process(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\n            \"name\": \"# 0.16mm High Quality\",\n            \"prime_tower_width\": \"20\",\n            \"prime_tower_max_speed\": \"100\",\n        }\n        assert _guess_profile_type(data) == \"process\"\n\n    def test_outer_wall_speed_indicates_process(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"H2D eSUN PETG Process\", \"outer_wall_speed\": [\"150\"]}\n        assert _guess_profile_type(data) == \"process\"\n\n    def test_layer_height_indicates_process(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"Standard\", \"layer_height\": \"0.2\", \"first_layer_height\": \"0.2\"}\n        assert _guess_profile_type(data) == \"process\"\n\n    def test_machine_keys_indicate_printer(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"My Printer\", \"machine_max_speed_x\": \"500\", \"bed_shape\": \"0x0,220x0,220x220,0x220\"}\n        assert _guess_profile_type(data) == \"printer\"\n\n    def test_filament_type_indicates_filament(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"Generic PLA\", \"filament_type\": [\"PLA\"]}\n        assert _guess_profile_type(data) == \"filament\"\n\n    def test_name_with_layer_height_pattern(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"0.20mm Standard @BBL X1C\"}\n        assert _guess_profile_type(data) == \"process\"\n\n    def test_name_ending_with_process(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"H2D eSUN PETG Process\"}\n        assert _guess_profile_type(data) == \"process\"\n\n    def test_default_to_filament(self):\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\"name\": \"Unknown Preset\"}\n        assert _guess_profile_type(data) == \"filament\"\n\n    def test_override_keys_only_process(self):\n        \"\"\"Test realistic override-only process preset (inheritance unresolved).\"\"\"\n        from backend.app.services.orca_profiles import _guess_profile_type\n\n        data = {\n            \"from\": \"User\",\n            \"inherits\": \"0.08mm Extra Fine @BBL H2D\",\n            \"name\": \"# 0.08mm Extra Fine @BBL H2D\",\n            \"prime_tower_max_speed\": \"100\",\n            \"prime_tower_rib_wall\": \"0\",\n            \"prime_tower_width\": \"20\",\n            \"print_extruder_id\": [\"1\", \"1\"],\n            \"print_settings_id\": \"# 0.08mm Extra Fine @BBL H2D\",\n            \"version\": \"2.3.0.4\",\n        }\n        assert _guess_profile_type(data) == \"process\"\n\n\nclass TestParseMaterialFromName:\n    \"\"\"Tests for _parse_material_from_name().\"\"\"\n\n    def test_pla_in_name(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        assert _parse_material_from_name(\"Overture PLA Matte @BBL X1C\") == \"PLA\"\n\n    def test_abs_in_name(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        assert _parse_material_from_name(\"CR3D ABS+ @Bambu Lab X1 Carbon\") == \"ABS\"\n\n    def test_petg_in_name(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        assert _parse_material_from_name(\"eSUN PETG Silk @Bambu Lab X1 Carbon\") == \"PETG\"\n\n    def test_tpu_in_name(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        assert _parse_material_from_name(\"Sunlu TPU @Bambu Lab X1 Carbon\") == \"TPU\"\n\n    def test_no_material_in_name(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        assert _parse_material_from_name(\"# 0.20mm Standard @BBL X1C\") is None\n\n    def test_material_word_boundary(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        # \"PLA\" should match as a word, not inside \"DISPLAY\"\n        assert _parse_material_from_name(\"Bambu PLA Basic @BBL X1C\") == \"PLA\"\n\n    def test_asa_in_name(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        assert _parse_material_from_name(\"Bambu ASA-CF @BBL H2D\") == \"ASA\"\n\n    def test_pa_in_name(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        # \"PA12\" doesn't match \\bPA\\b because 1 is a word char — PA needs word boundary\n        assert _parse_material_from_name(\"Fiberlogy PA12+CF15\") is None\n        assert _parse_material_from_name(\"Fiberlogy PA @BBL X1C\") == \"PA\"\n\n    def test_support_for_pattern(self):\n        from backend.app.services.orca_profiles import _parse_material_from_name\n\n        # \"PLA Support for PETG\" — filament type is PETG, not PLA\n        assert _parse_material_from_name(\"PLA Support for PETG PETG Basic @Bambu Lab H2D 0.4 nozzle\") == \"PETG\"\n        assert _parse_material_from_name(\"PLA Support for ABS @BBL X1C\") == \"ABS\"\n        assert _parse_material_from_name(\"PVA Support for PLA @BBL X1C\") == \"PLA\"\n\n\nclass TestParseVendorFromName:\n    \"\"\"Tests for _parse_vendor_from_name().\"\"\"\n\n    def test_overture_vendor(self):\n        from backend.app.services.orca_profiles import _parse_vendor_from_name\n\n        assert _parse_vendor_from_name(\"Overture PLA Matte @BBL X1C\") == \"Overture\"\n\n    def test_esun_vendor(self):\n        from backend.app.services.orca_profiles import _parse_vendor_from_name\n\n        assert _parse_vendor_from_name(\"eSUN PETG @Bambu Lab H2D\") == \"eSUN\"\n\n    def test_bambu_vendor(self):\n        from backend.app.services.orca_profiles import _parse_vendor_from_name\n\n        assert _parse_vendor_from_name(\"Bambu PLA Basic @BBL X1C\") == \"Bambu\"\n\n    def test_devil_design_vendor(self):\n        from backend.app.services.orca_profiles import _parse_vendor_from_name\n\n        assert _parse_vendor_from_name(\"Devil Design PLA @Bambu Lab X1 Carbon\") == \"Devil Design\"\n\n    def test_no_vendor_process_name(self):\n        from backend.app.services.orca_profiles import _parse_vendor_from_name\n\n        assert _parse_vendor_from_name(\"# 0.20mm Standard @BBL X1C\") is None\n\n    def test_strips_at_suffix(self):\n        from backend.app.services.orca_profiles import _parse_vendor_from_name\n\n        # Should strip @BBL X1C before parsing\n        result = _parse_vendor_from_name(\"Azurefilm PLA Wood @Bambu Lab H2D 0.4 nozzle\")\n        assert result == \"Azurefilm\"\n\n    def test_single_char_vendor_rejected(self):\n        from backend.app.services.orca_profiles import _parse_vendor_from_name\n\n        # Vendor must be >1 char\n        assert _parse_vendor_from_name(\"X PLA\") is None\n\n\nclass TestExtractCoreFields:\n    \"\"\"Tests for extract_core_fields().\"\"\"\n\n    def test_filament_type_array(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"filament_type\": [\"PLA\"]})\n        assert core[\"filament_type\"] == \"PLA\"\n\n    def test_filament_type_string(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"filament_type\": \"ABS\"})\n        assert core[\"filament_type\"] == \"ABS\"\n\n    def test_filament_vendor_array(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"filament_vendor\": [\"Bambu Lab\"]})\n        assert core[\"filament_vendor\"] == \"Bambu Lab\"\n\n    def test_nozzle_temp_from_array(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"nozzle_temperature\": [\"220\"]})\n        assert core[\"nozzle_temp_min\"] == 220\n        assert core[\"nozzle_temp_max\"] == 220\n\n    def test_nozzle_temp_range_override(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields(\n            {\n                \"nozzle_temperature\": [\"220\"],\n                \"nozzle_temperature_range_low\": [\"190\"],\n                \"nozzle_temperature_range_high\": [\"230\"],\n            }\n        )\n        assert core[\"nozzle_temp_min\"] == 190\n        assert core[\"nozzle_temp_max\"] == 230\n\n    def test_pressure_advance_array(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"pressure_advance\": [\"0.04\"]})\n        assert core[\"pressure_advance\"] == json.dumps([\"0.04\"])\n\n    def test_default_filament_colour(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"default_filament_colour\": [\"#FFAA00\"]})\n        assert \"#FFAA00\" in core[\"default_filament_colour\"]\n\n    def test_filament_cost_array(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"filament_cost\": [\"24.99\"]})\n        assert core[\"filament_cost\"] == \"24.99\"\n\n    def test_filament_density(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"filament_density\": [\"1.24\"]})\n        assert core[\"filament_density\"] == \"1.24\"\n\n    def test_compatible_printers(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({\"compatible_printers\": [\"Bambu Lab X1 Carbon\", \"Bambu Lab P1S\"]})\n        parsed = json.loads(core[\"compatible_printers\"])\n        assert \"Bambu Lab X1 Carbon\" in parsed\n        assert \"Bambu Lab P1S\" in parsed\n\n    def test_empty_data(self):\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        core = extract_core_fields({})\n        assert core == {}\n\n    def test_full_resolved_preset(self):\n        \"\"\"Test extraction from a realistic fully resolved preset.\"\"\"\n        from backend.app.services.orca_profiles import extract_core_fields\n\n        data = {\n            \"filament_type\": [\"PETG\"],\n            \"filament_vendor\": [\"eSUN\"],\n            \"nozzle_temperature\": [\"240\"],\n            \"nozzle_temperature_range_low\": [\"220\"],\n            \"nozzle_temperature_range_high\": [\"260\"],\n            \"pressure_advance\": [\"0.035\"],\n            \"default_filament_colour\": [\"#4A90D9\"],\n            \"filament_cost\": [\"19.99\"],\n            \"filament_density\": [\"1.27\"],\n            \"compatible_printers\": [\"Bambu Lab X1 Carbon 0.4 nozzle\"],\n        }\n        core = extract_core_fields(data)\n        assert core[\"filament_type\"] == \"PETG\"\n        assert core[\"filament_vendor\"] == \"eSUN\"\n        assert core[\"nozzle_temp_min\"] == 220\n        assert core[\"nozzle_temp_max\"] == 260\n        assert core[\"filament_cost\"] == \"19.99\"\n        assert core[\"filament_density\"] == \"1.27\"\n"
  },
  {
    "path": "backend/tests/unit/test_permissions.py",
    "content": "\"\"\"Tests for the permission system definitions and consistency.\"\"\"\n\nfrom backend.app.core.permissions import (\n    ALL_PERMISSIONS,\n    DEFAULT_GROUPS,\n    PERMISSION_CATEGORIES,\n    Permission,\n)\n\n\nclass TestPermissionEnum:\n    \"\"\"Test the Permission enum values.\"\"\"\n\n    def test_clear_plate_permission_exists(self):\n        \"\"\"printers:clear_plate permission should exist in the enum.\"\"\"\n        assert hasattr(Permission, \"PRINTERS_CLEAR_PLATE\")\n        assert Permission.PRINTERS_CLEAR_PLATE == \"printers:clear_plate\"\n\n    def test_clear_plate_in_all_permissions(self):\n        \"\"\"printers:clear_plate should be in ALL_PERMISSIONS list.\"\"\"\n        assert \"printers:clear_plate\" in ALL_PERMISSIONS\n\n    def test_clear_plate_in_printers_category(self):\n        \"\"\"printers:clear_plate should be in the Printers permission category.\"\"\"\n        printers_perms = PERMISSION_CATEGORIES[\"Printers\"]\n        assert Permission.PRINTERS_CLEAR_PLATE in printers_perms\n\n    def test_clear_plate_separate_from_control(self):\n        \"\"\"clear_plate and control should be distinct permissions.\"\"\"\n        assert Permission.PRINTERS_CLEAR_PLATE != Permission.PRINTERS_CONTROL\n        assert Permission.PRINTERS_CLEAR_PLATE.value != Permission.PRINTERS_CONTROL.value\n\n\nclass TestDefaultGroups:\n    \"\"\"Test the default group definitions.\"\"\"\n\n    def test_operators_have_clear_plate(self):\n        \"\"\"Operators group should include printers:clear_plate.\"\"\"\n        operators = DEFAULT_GROUPS[\"Operators\"]\n        assert \"printers:clear_plate\" in operators[\"permissions\"]\n\n    def test_operators_have_control_and_clear_plate(self):\n        \"\"\"Operators group should have both printers:control and printers:clear_plate.\"\"\"\n        operators = DEFAULT_GROUPS[\"Operators\"]\n        assert \"printers:control\" in operators[\"permissions\"]\n        assert \"printers:clear_plate\" in operators[\"permissions\"]\n\n    def test_administrators_have_all_permissions(self):\n        \"\"\"Administrators should have all permissions including clear_plate.\"\"\"\n        admins = DEFAULT_GROUPS[\"Administrators\"]\n        assert \"printers:clear_plate\" in admins[\"permissions\"]\n\n    def test_viewers_do_not_have_clear_plate(self):\n        \"\"\"Viewers group (read-only) should not include printers:clear_plate.\"\"\"\n        viewers = DEFAULT_GROUPS[\"Viewers\"]\n        assert \"printers:clear_plate\" not in viewers[\"permissions\"]\n\n\nclass TestPermissionCategoriesCompleteness:\n    \"\"\"Test that all enum permissions appear in exactly one category.\"\"\"\n\n    def test_all_permissions_categorized(self):\n        \"\"\"Every Permission enum member should appear in a category.\"\"\"\n        categorized = set()\n        for perms in PERMISSION_CATEGORIES.values():\n            categorized.update(perms)\n        for perm in Permission:\n            assert perm in categorized, f\"{perm} not in any category\"\n\n    def test_no_duplicate_categorization(self):\n        \"\"\"No permission should appear in multiple categories.\"\"\"\n        seen = {}\n        for cat_name, perms in PERMISSION_CATEGORIES.items():\n            for perm in perms:\n                assert perm not in seen, f\"{perm} in both '{seen[perm]}' and '{cat_name}'\"\n                seen[perm] = cat_name\n\n\nclass TestInventoryViewAssignmentsPermission:\n    \"\"\"Test the INVENTORY_VIEW_ASSIGNMENTS permission.\"\"\"\n\n    def test_view_assignments_permission_exists(self):\n        \"\"\"inventory:view_assignments permission should exist in the enum.\"\"\"\n        assert hasattr(Permission, \"INVENTORY_VIEW_ASSIGNMENTS\")\n        assert Permission.INVENTORY_VIEW_ASSIGNMENTS == \"inventory:view_assignments\"\n\n    def test_view_assignments_in_all_permissions(self):\n        \"\"\"inventory:view_assignments should be in ALL_PERMISSIONS list.\"\"\"\n        assert \"inventory:view_assignments\" in ALL_PERMISSIONS\n\n    def test_view_assignments_in_inventory_category(self):\n        \"\"\"inventory:view_assignments should be in the Inventory permission category.\"\"\"\n        inventory_perms = PERMISSION_CATEGORIES[\"Inventory\"]\n        assert Permission.INVENTORY_VIEW_ASSIGNMENTS in inventory_perms\n\n    def test_view_assignments_separate_from_read(self):\n        \"\"\"view_assignments and read should be distinct permissions.\"\"\"\n        assert Permission.INVENTORY_VIEW_ASSIGNMENTS != Permission.INVENTORY_READ\n        assert Permission.INVENTORY_VIEW_ASSIGNMENTS.value != Permission.INVENTORY_READ.value\n\n    def test_operators_have_view_assignments(self):\n        \"\"\"Operators group should include inventory:view_assignments.\"\"\"\n        operators = DEFAULT_GROUPS[\"Operators\"]\n        assert \"inventory:view_assignments\" in operators[\"permissions\"]\n\n    def test_viewers_have_view_assignments(self):\n        \"\"\"Viewers group should include inventory:view_assignments.\"\"\"\n        viewers = DEFAULT_GROUPS[\"Viewers\"]\n        assert \"inventory:view_assignments\" in viewers[\"permissions\"]\n\n    def test_administrators_have_view_assignments(self):\n        \"\"\"Administrators should have all permissions including view_assignments.\"\"\"\n        admins = DEFAULT_GROUPS[\"Administrators\"]\n        assert \"inventory:view_assignments\" in admins[\"permissions\"]\n"
  },
  {
    "path": "backend/tests/unit/test_permissions_stats_filter.py",
    "content": "\"\"\"Tests for the stats:filter_by_user permission and user filter helpers.\"\"\"\n\nfrom unittest.mock import MagicMock\n\nimport pytest\nfrom fastapi import HTTPException\n\nfrom backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS, PERMISSION_CATEGORIES, Permission\n\n\nclass TestStatsFilterByUserPermission:\n    \"\"\"Test the stats:filter_by_user permission is properly defined.\"\"\"\n\n    def test_permission_enum_exists(self):\n        \"\"\"The STATS_FILTER_BY_USER permission should exist in the enum.\"\"\"\n        assert hasattr(Permission, \"STATS_FILTER_BY_USER\")\n        assert Permission.STATS_FILTER_BY_USER.value == \"stats:filter_by_user\"\n\n    def test_permission_in_all_permissions(self):\n        \"\"\"The permission should be in ALL_PERMISSIONS list.\"\"\"\n        assert \"stats:filter_by_user\" in ALL_PERMISSIONS\n\n    def test_permission_in_administrators_group(self):\n        \"\"\"Administrators should have the permission (via ALL_PERMISSIONS).\"\"\"\n        admin_perms = DEFAULT_GROUPS[\"Administrators\"][\"permissions\"]\n        assert \"stats:filter_by_user\" in admin_perms\n\n    def test_permission_not_in_operators_group(self):\n        \"\"\"Operators should NOT have the permission.\"\"\"\n        operator_perms = DEFAULT_GROUPS[\"Operators\"][\"permissions\"]\n        assert \"stats:filter_by_user\" not in operator_perms\n\n    def test_permission_not_in_viewers_group(self):\n        \"\"\"Viewers should NOT have the permission.\"\"\"\n        viewer_perms = DEFAULT_GROUPS[\"Viewers\"][\"permissions\"]\n        assert \"stats:filter_by_user\" not in viewer_perms\n\n    def test_permission_in_stats_category(self):\n        \"\"\"The permission should be in the Stats & History category.\"\"\"\n        stats_category = PERMISSION_CATEGORIES[\"Stats & History\"]\n        assert Permission.STATS_FILTER_BY_USER in stats_category\n\n\nclass TestValidateUserFilterPermission:\n    \"\"\"Test the _validate_user_filter_permission helper.\"\"\"\n\n    @pytest.fixture\n    def validate(self):\n        from backend.app.api.routes.archives import _validate_user_filter_permission\n\n        return _validate_user_filter_permission\n\n    def test_no_filter_no_check(self, validate):\n        \"\"\"When created_by_id is None, no permission check is done.\"\"\"\n        validate(None, None)  # Should not raise\n\n    def test_no_user_no_check(self, validate):\n        \"\"\"When current_user is None (auth disabled), no permission check is done.\"\"\"\n        validate(None, 5)  # Should not raise\n\n    def test_admin_always_allowed(self, validate):\n        \"\"\"Admin users should always be allowed to filter.\"\"\"\n        user = MagicMock()\n        user.is_admin = True\n        validate(user, 5)  # Should not raise\n\n    def test_user_with_permission_allowed(self, validate):\n        \"\"\"Users with stats:filter_by_user permission should be allowed.\"\"\"\n        user = MagicMock()\n        user.is_admin = False\n        user.has_permission.return_value = True\n        validate(user, 5)  # Should not raise\n        user.has_permission.assert_called_once_with(\"stats:filter_by_user\")\n\n    def test_user_without_permission_denied(self, validate):\n        \"\"\"Users without the permission should get 403.\"\"\"\n        user = MagicMock()\n        user.is_admin = False\n        user.has_permission.return_value = False\n        with pytest.raises(HTTPException) as exc_info:\n            validate(user, 5)\n        assert exc_info.value.status_code == 403\n\n    def test_sentinel_minus_one_also_checked(self, validate):\n        \"\"\"The sentinel value -1 (no user) also requires permission.\"\"\"\n        user = MagicMock()\n        user.is_admin = False\n        user.has_permission.return_value = False\n        with pytest.raises(HTTPException):\n            validate(user, -1)\n\n\nclass TestApplyUserFilter:\n    \"\"\"Test the _apply_user_filter helper.\"\"\"\n\n    @pytest.fixture\n    def apply_filter(self):\n        from backend.app.api.routes.archives import _apply_user_filter\n\n        return _apply_user_filter\n\n    def test_none_does_nothing(self, apply_filter):\n        \"\"\"When created_by_id is None, conditions list should not change.\"\"\"\n        conditions = []\n        apply_filter(conditions, None)\n        assert len(conditions) == 0\n\n    def test_positive_id_adds_filter(self, apply_filter):\n        \"\"\"A positive user ID should add an equality filter.\"\"\"\n        conditions = []\n        apply_filter(conditions, 5)\n        assert len(conditions) == 1\n        # Check it's a SQLAlchemy comparison expression\n        assert str(conditions[0]) == \"print_archives.created_by_id = :created_by_id_1\"\n\n    def test_minus_one_adds_is_null(self, apply_filter):\n        \"\"\"The sentinel value -1 should add an IS NULL filter.\"\"\"\n        conditions = []\n        apply_filter(conditions, -1)\n        assert len(conditions) == 1\n        assert \"IS NULL\" in str(conditions[0]).upper()\n\n    def test_appends_to_existing_conditions(self, apply_filter):\n        \"\"\"Filter should be appended to existing conditions.\"\"\"\n        conditions = [\"existing\"]\n        apply_filter(conditions, 5)\n        assert len(conditions) == 2\n        assert conditions[0] == \"existing\"\n"
  },
  {
    "path": "backend/tests/unit/test_phantom_print_hardening.py",
    "content": "\"\"\"Tests for phantom print investigation hardening (#374).\n\nTests the tightened archive matching (no ilike) and the\nmultiple-printing-items warning logic.\n\nThese are pure unit tests that test the changed logic directly,\nNOT by calling the full on_print_start/on_print_complete callbacks\n(which spawn background tasks and require heavy mocking).\n\"\"\"\n\nimport logging\n\nimport pytest\nfrom sqlalchemy import or_, select\nfrom sqlalchemy.sql import ClauseElement\n\nfrom backend.app.models.archive import PrintArchive\n\n\nclass TestArchiveMatchQueryShape:\n    \"\"\"Tests that the archive duplicate lookup query uses exact match, not ilike (#374).\n\n    The old query used `ilike('%{name}%')` which caused \"Clip\" to match\n    \"Cable Clip\", \"Clip Stand\", etc. The new query uses exact print_name\n    match OR exact filename variants (.3mf, .gcode.3mf).\n    \"\"\"\n\n    def _build_archive_query(self, check_name: str, printer_id: int = 1) -> ClauseElement:\n        \"\"\"Build the exact query used in on_print_start for archive dedup.\"\"\"\n        return (\n            select(PrintArchive)\n            .where(PrintArchive.printer_id == printer_id)\n            .where(PrintArchive.status == \"printing\")\n            .where(\n                or_(\n                    PrintArchive.print_name == check_name,\n                    PrintArchive.filename.in_(\n                        [\n                            f\"{check_name}.3mf\",\n                            f\"{check_name}.gcode.3mf\",\n                        ]\n                    ),\n                )\n            )\n            .order_by(PrintArchive.created_at.desc())\n            .limit(1)\n        )\n\n    def test_query_does_not_contain_ilike(self):\n        \"\"\"Verify the compiled query does NOT use LIKE/ILIKE.\"\"\"\n        query = self._build_archive_query(\"Clip\")\n        query_str = str(query.compile(compile_kwargs={\"literal_binds\": True}))\n\n        assert \"LIKE\" not in query_str.upper(), f\"Query should not use LIKE: {query_str}\"\n\n    def test_query_uses_exact_equality(self):\n        \"\"\"Verify the query uses = for print_name comparison.\"\"\"\n        query = self._build_archive_query(\"Benchy\")\n        query_str = str(query.compile(compile_kwargs={\"literal_binds\": True}))\n\n        assert \"print_name = \" in query_str or \"print_name ='\" in query_str or \"print_name =\" in query_str\n\n    def test_query_uses_in_for_filename_variants(self):\n        \"\"\"Verify the query uses IN for filename matching with .3mf variants.\"\"\"\n        query = self._build_archive_query(\"MyPrint\")\n        query_str = str(query.compile(compile_kwargs={\"literal_binds\": True}))\n\n        assert \"IN\" in query_str.upper()\n        assert \"MyPrint.3mf\" in query_str\n        assert \"MyPrint.gcode.3mf\" in query_str\n\n    def test_partial_name_not_in_query(self):\n        \"\"\"Verify 'Clip' does not produce a wildcard pattern.\"\"\"\n        query = self._build_archive_query(\"Clip\")\n        query_str = str(query.compile(compile_kwargs={\"literal_binds\": True}))\n\n        # Should NOT contain %Clip% wildcard\n        assert \"%Clip%\" not in query_str\n\n    def test_check_name_derivation_from_subtask(self):\n        \"\"\"Verify check_name is derived correctly from subtask_name.\"\"\"\n        # Simulates: check_name = subtask_name or filename.split(\"/\")[-1].replace(...)\n        subtask_name = \"Cable Clip\"\n        filename = \"/sdcard/Cable Clip.gcode\"\n        check_name = subtask_name or filename.split(\"/\")[-1].replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n        assert check_name == \"Cable Clip\"\n\n        query = self._build_archive_query(check_name)\n        query_str = str(query.compile(compile_kwargs={\"literal_binds\": True}))\n\n        # Exact match should contain the full name, not a partial\n        assert \"Cable Clip\" in query_str\n        assert \"%Cable Clip%\" not in query_str\n\n    def test_check_name_derivation_from_filename(self):\n        \"\"\"Verify check_name strips extensions correctly from filename.\"\"\"\n        subtask_name = None\n        filename = \"/sdcard/MyPrint.gcode\"\n        check_name = subtask_name or filename.split(\"/\")[-1].replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n        assert check_name == \"MyPrint\"\n\n\nclass TestMultiplePrintingQueueItemsWarning:\n    \"\"\"Tests for the multiple-printing-items warning logic (#374).\n\n    The code in on_print_complete now detects when multiple queue items\n    are in 'printing' status for the same printer, which signals a bug.\n    \"\"\"\n\n    def test_single_item_returns_item_no_warning(self, caplog):\n        \"\"\"Verify single item is returned without warning.\"\"\"\n        from unittest.mock import MagicMock\n\n        items = [MagicMock(id=1, archive_id=10, library_file_id=None)]\n\n        # Simulate the exact code from on_print_complete\n        with caplog.at_level(logging.WARNING, logger=\"backend.app.main\"):\n            logger = logging.getLogger(\"backend.app.main\")\n            printer_id = 1\n            printing_items = list(items)\n\n            if len(printing_items) > 1:\n                logger.warning(\n                    \"BUG: Multiple queue items in 'printing' status for printer %s: %s\",\n                    printer_id,\n                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],\n                )\n            queue_item = printing_items[0] if printing_items else None\n\n        assert queue_item is not None\n        assert queue_item.id == 1\n        bug_warnings = [r for r in caplog.records if \"BUG: Multiple queue items\" in r.message]\n        assert len(bug_warnings) == 0\n\n    def test_multiple_items_warns_and_returns_first(self, caplog):\n        \"\"\"Verify warning is logged and first item is returned when multiple exist.\"\"\"\n        from unittest.mock import MagicMock\n\n        items = [\n            MagicMock(id=1, archive_id=10, library_file_id=None),\n            MagicMock(id=2, archive_id=20, library_file_id=None),\n        ]\n\n        with caplog.at_level(logging.WARNING, logger=\"backend.app.main\"):\n            logger = logging.getLogger(\"backend.app.main\")\n            printer_id = 1\n            printing_items = list(items)\n\n            if len(printing_items) > 1:\n                logger.warning(\n                    \"BUG: Multiple queue items in 'printing' status for printer %s: %s\",\n                    printer_id,\n                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],\n                )\n            queue_item = printing_items[0] if printing_items else None\n\n        assert queue_item is not None\n        assert queue_item.id == 1  # First item is used\n        bug_warnings = [r for r in caplog.records if \"BUG: Multiple queue items\" in r.message]\n        assert len(bug_warnings) == 1\n        assert \"printer 1\" in bug_warnings[0].message\n        # Warning should include item details\n        assert \"10\" in bug_warnings[0].message  # archive_id of item 1\n        assert \"20\" in bug_warnings[0].message  # archive_id of item 2\n\n    def test_empty_list_returns_none_no_warning(self, caplog):\n        \"\"\"Verify None is returned and no warning when no items exist.\"\"\"\n        with caplog.at_level(logging.WARNING, logger=\"backend.app.main\"):\n            logger = logging.getLogger(\"backend.app.main\")\n            printer_id = 1\n            printing_items = []\n\n            if len(printing_items) > 1:\n                logger.warning(\n                    \"BUG: Multiple queue items in 'printing' status for printer %s: %s\",\n                    printer_id,\n                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],\n                )\n            queue_item = printing_items[0] if printing_items else None\n\n        assert queue_item is None\n        bug_warnings = [r for r in caplog.records if \"BUG: Multiple queue items\" in r.message]\n        assert len(bug_warnings) == 0\n\n    def test_three_items_warns_with_all_details(self, caplog):\n        \"\"\"Verify warning includes all item details when three items found.\"\"\"\n        from unittest.mock import MagicMock\n\n        items = [\n            MagicMock(id=1, archive_id=10, library_file_id=None),\n            MagicMock(id=2, archive_id=None, library_file_id=5),\n            MagicMock(id=3, archive_id=30, library_file_id=None),\n        ]\n\n        with caplog.at_level(logging.WARNING, logger=\"backend.app.main\"):\n            logger = logging.getLogger(\"backend.app.main\")\n            printer_id = 7\n            printing_items = list(items)\n\n            if len(printing_items) > 1:\n                logger.warning(\n                    \"BUG: Multiple queue items in 'printing' status for printer %s: %s\",\n                    printer_id,\n                    [(i.id, i.archive_id, i.library_file_id) for i in printing_items],\n                )\n            queue_item = printing_items[0] if printing_items else None\n\n        assert queue_item.id == 1\n        bug_warnings = [r for r in caplog.records if \"BUG: Multiple queue items\" in r.message]\n        assert len(bug_warnings) == 1\n        assert \"printer 7\" in bug_warnings[0].message\n\n\nclass TestBusyPrinterSeedingFromPrintingItems:\n    \"\"\"Regression for the duplicate-dispatch bug observed with quantity>1 batches.\n\n    The old scheduler seeded ``busy_printers`` with an empty set and relied on\n    ``_is_printer_idle()`` to gate dispatch. On H2D / P1 series the MQTT state\n    lags several seconds behind the print command, so the next ``check_queue``\n    tick saw IDLE and dispatched a second queue item onto the same printer —\n    both items ended up in 'printing' status. The fix seeds ``busy_printers``\n    up-front with every printer that already has an item in 'printing' status.\n    \"\"\"\n\n    @pytest.mark.asyncio\n    async def test_seed_query_returns_printers_with_printing_items(self):\n        \"\"\"The seeding query must return every printer_id that has a 'printing' item.\"\"\"\n        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n\n        import backend.app.models  # noqa: F401\n        from backend.app.core.database import Base\n        from backend.app.models.print_queue import PrintQueueItem\n\n        engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n        async with engine.begin() as conn:\n            await conn.run_sync(Base.metadata.create_all)\n        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n        async with session_maker() as db:\n            db.add_all(\n                [\n                    PrintQueueItem(printer_id=1, status=\"printing\", position=1, archive_id=10),\n                    PrintQueueItem(printer_id=1, status=\"pending\", position=2, archive_id=10),\n                    PrintQueueItem(printer_id=2, status=\"printing\", position=1, archive_id=11),\n                    PrintQueueItem(printer_id=3, status=\"pending\", position=1, archive_id=12),\n                    PrintQueueItem(printer_id=None, status=\"pending\", position=1, archive_id=13),\n                ]\n            )\n            await db.commit()\n\n            result = await db.execute(\n                select(PrintQueueItem.printer_id)\n                .where(PrintQueueItem.status == \"printing\")\n                .where(PrintQueueItem.printer_id.is_not(None))\n            )\n            busy_printers = {pid for (pid,) in result.all() if pid is not None}\n\n        assert busy_printers == {1, 2}\n\n        await engine.dispose()\n\n    @pytest.mark.asyncio\n    async def test_seed_query_empty_when_no_printing_items(self):\n        \"\"\"With only pending items, no printer is considered busy by the query.\"\"\"\n        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n\n        import backend.app.models  # noqa: F401\n        from backend.app.core.database import Base\n        from backend.app.models.print_queue import PrintQueueItem\n\n        engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n        async with engine.begin() as conn:\n            await conn.run_sync(Base.metadata.create_all)\n        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n        async with session_maker() as db:\n            db.add_all(\n                [\n                    PrintQueueItem(printer_id=1, status=\"pending\", position=1, archive_id=10),\n                    PrintQueueItem(printer_id=2, status=\"completed\", position=1, archive_id=11),\n                    PrintQueueItem(printer_id=3, status=\"failed\", position=1, archive_id=12),\n                    PrintQueueItem(printer_id=4, status=\"cancelled\", position=1, archive_id=13),\n                ]\n            )\n            await db.commit()\n\n            result = await db.execute(\n                select(PrintQueueItem.printer_id)\n                .where(PrintQueueItem.status == \"printing\")\n                .where(PrintQueueItem.printer_id.is_not(None))\n            )\n            busy_printers = {pid for (pid,) in result.all() if pid is not None}\n\n        assert busy_printers == set()\n\n        await engine.dispose()\n\n    @pytest.mark.asyncio\n    async def test_check_queue_skips_printer_with_existing_printing_item(self, caplog):\n        \"\"\"Simulate the exact observed bug: a pending item targets a printer that already\n        has another queue item in 'printing' status. The scheduler must NOT dispatch the\n        pending item even if the live MQTT state reports IDLE.\n        \"\"\"\n        from unittest.mock import AsyncMock, patch\n\n        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n\n        import backend.app.models  # noqa: F401\n        from backend.app.core.database import Base\n        from backend.app.models.print_queue import PrintQueueItem\n        from backend.app.services.print_scheduler import PrintScheduler\n\n        engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n        async with engine.begin() as conn:\n            await conn.run_sync(Base.metadata.create_all)\n        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n        async with session_maker() as db:\n            db.add_all(\n                [\n                    PrintQueueItem(printer_id=1, status=\"printing\", position=1, archive_id=84),\n                    PrintQueueItem(printer_id=1, status=\"pending\", position=2, archive_id=84),\n                ]\n            )\n            await db.commit()\n\n        scheduler = PrintScheduler()\n        start_print_mock = AsyncMock()\n\n        with (\n            patch(\"backend.app.services.print_scheduler.async_session\", session_maker),\n            patch.object(scheduler, \"_get_bool_setting\", AsyncMock(return_value=False)),\n            patch.object(scheduler, \"_is_printer_idle\", return_value=True),\n            patch.object(scheduler, \"_check_auto_drying\", AsyncMock()),\n            patch.object(scheduler, \"_start_print\", start_print_mock),\n            patch(\"backend.app.services.print_scheduler.printer_manager\") as mock_pm,\n        ):\n            mock_pm.is_connected.return_value = True\n            await scheduler.check_queue()\n\n        start_print_mock.assert_not_called()\n\n        async with session_maker() as db:\n            rows = (await db.execute(select(PrintQueueItem).order_by(PrintQueueItem.position))).scalars().all()\n            statuses = [r.status for r in rows]\n        assert statuses == [\"printing\", \"pending\"]\n\n        await engine.dispose()\n"
  },
  {
    "path": "backend/tests/unit/test_plate_object_extraction.py",
    "content": "\"\"\"Unit tests for plate object extraction from 3MF model_settings.config.\"\"\"\n\nfrom defusedxml import ElementTree as ET\n\n\nclass TestPlateObjectExtraction:\n    \"\"\"Tests for extracting object IDs and names from model_settings.config XML.\"\"\"\n\n    def test_extract_object_names_from_xml(self):\n        \"\"\"Verify object names are extracted from model_settings.config XML.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <object id=\"1\">\n                <metadata key=\"name\" value=\"Cube\"/>\n            </object>\n            <object id=\"2\">\n                <metadata key=\"name\" value=\"Sphere\"/>\n            </object>\n            <object id=\"3\">\n                <metadata key=\"name\" value=\"Cylinder\"/>\n            </object>\n        </config>\n        \"\"\"\n        root = ET.fromstring(xml_content)\n\n        object_names_by_id = {}\n        for obj in root.findall(\".//object\"):\n            obj_id = obj.get(\"id\")\n            if obj_id:\n                name_meta = obj.find(\"./metadata[@key='name']\")\n                if name_meta is not None:\n                    object_names_by_id[obj_id] = name_meta.get(\"value\", f\"Object {obj_id}\")\n                else:\n                    object_names_by_id[obj_id] = f\"Object {obj_id}\"\n\n        assert object_names_by_id == {\n            \"1\": \"Cube\",\n            \"2\": \"Sphere\",\n            \"3\": \"Cylinder\",\n        }\n\n    def test_extract_object_names_missing_name(self):\n        \"\"\"Verify objects without names get default names.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <object id=\"1\">\n                <metadata key=\"name\" value=\"Named Object\"/>\n            </object>\n            <object id=\"2\">\n                <!-- No name metadata -->\n            </object>\n        </config>\n        \"\"\"\n        root = ET.fromstring(xml_content)\n\n        object_names_by_id = {}\n        for obj in root.findall(\".//object\"):\n            obj_id = obj.get(\"id\")\n            if obj_id:\n                name_meta = obj.find(\"./metadata[@key='name']\")\n                if name_meta is not None:\n                    object_names_by_id[obj_id] = name_meta.get(\"value\", f\"Object {obj_id}\")\n                else:\n                    object_names_by_id[obj_id] = f\"Object {obj_id}\"\n\n        assert object_names_by_id == {\n            \"1\": \"Named Object\",\n            \"2\": \"Object 2\",\n        }\n\n    def test_extract_plate_object_associations(self):\n        \"\"\"Verify plate-to-object associations are extracted correctly.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"plater_id\" value=\"1\"/>\n                <model_instance>\n                    <metadata key=\"object_id\" value=\"1\"/>\n                </model_instance>\n                <model_instance>\n                    <metadata key=\"object_id\" value=\"2\"/>\n                </model_instance>\n            </plate>\n            <plate>\n                <metadata key=\"plater_id\" value=\"2\"/>\n                <model_instance>\n                    <metadata key=\"object_id\" value=\"3\"/>\n                </model_instance>\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(xml_content)\n\n        plate_object_ids = {}\n        for plate in root.findall(\".//plate\"):\n            plate_id = None\n            for meta in plate.findall(\"metadata\"):\n                if meta.get(\"key\") in (\"plater_id\", \"plate_id\"):\n                    plate_id = meta.get(\"value\")\n                    break\n\n            if plate_id:\n                object_ids = []\n                for instance in plate.findall(\".//model_instance\"):\n                    for meta in instance.findall(\"metadata\"):\n                        if meta.get(\"key\") == \"object_id\":\n                            object_ids.append(meta.get(\"value\"))\n                plate_object_ids[plate_id] = object_ids\n\n        assert plate_object_ids == {\n            \"1\": [\"1\", \"2\"],\n            \"2\": [\"3\"],\n        }\n\n    def test_extract_plate_object_associations_empty_plate(self):\n        \"\"\"Verify empty plates have empty object lists.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"plater_id\" value=\"1\"/>\n                <!-- No model_instances -->\n            </plate>\n        </config>\n        \"\"\"\n        root = ET.fromstring(xml_content)\n\n        plate_object_ids = {}\n        for plate in root.findall(\".//plate\"):\n            plate_id = None\n            for meta in plate.findall(\"metadata\"):\n                if meta.get(\"key\") in (\"plater_id\", \"plate_id\"):\n                    plate_id = meta.get(\"value\")\n                    break\n\n            if plate_id:\n                object_ids = []\n                for instance in plate.findall(\".//model_instance\"):\n                    for meta in instance.findall(\"metadata\"):\n                        if meta.get(\"key\") == \"object_id\":\n                            object_ids.append(meta.get(\"value\"))\n                plate_object_ids[plate_id] = object_ids\n\n        assert plate_object_ids == {\"1\": []}\n\n    def test_object_count_matches_objects_length(self):\n        \"\"\"Verify object_count equals len(objects).\"\"\"\n        objects = [\"Cube\", \"Sphere\", \"Cylinder\"]\n        object_count = len(objects)\n\n        assert object_count == 3\n\n    def test_resolve_object_names_from_ids(self):\n        \"\"\"Verify object IDs are resolved to names.\"\"\"\n        object_names_by_id = {\n            \"1\": \"Cube\",\n            \"2\": \"Sphere\",\n            \"3\": \"Cylinder\",\n        }\n        plate_object_ids = [\"1\", \"3\"]\n\n        resolved_names = [object_names_by_id.get(obj_id, f\"Object {obj_id}\") for obj_id in plate_object_ids]\n\n        assert resolved_names == [\"Cube\", \"Cylinder\"]\n\n    def test_resolve_object_names_missing_id(self):\n        \"\"\"Verify missing object IDs get fallback names.\"\"\"\n        object_names_by_id = {\n            \"1\": \"Cube\",\n        }\n        plate_object_ids = [\"1\", \"99\"]  # 99 doesn't exist\n\n        resolved_names = [object_names_by_id.get(obj_id, f\"Object {obj_id}\") for obj_id in plate_object_ids]\n\n        assert resolved_names == [\"Cube\", \"Object 99\"]\n\n    def test_plate_id_alternatives(self):\n        \"\"\"Verify both 'plater_id' and 'plate_id' keys are supported.\"\"\"\n        xml_with_plater_id = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"plater_id\" value=\"1\"/>\n            </plate>\n        </config>\n        \"\"\"\n        xml_with_plate_id = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"plate_id\" value=\"2\"/>\n            </plate>\n        </config>\n        \"\"\"\n\n        def extract_plate_id(xml_content):\n            root = ET.fromstring(xml_content)\n            for plate in root.findall(\".//plate\"):\n                for meta in plate.findall(\"metadata\"):\n                    if meta.get(\"key\") in (\"plater_id\", \"plate_id\"):\n                        return meta.get(\"value\")\n            return None\n\n        assert extract_plate_id(xml_with_plater_id) == \"1\"\n        assert extract_plate_id(xml_with_plate_id) == \"2\"\n"
  },
  {
    "path": "backend/tests/unit/test_print_log.py",
    "content": "\"\"\"Unit tests for print log service and schema.\"\"\"\n\nfrom datetime import datetime, timedelta\n\nimport pytest\n\nfrom backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse\n\n\nclass TestPrintLogEntrySchema:\n    \"\"\"Test PrintLogEntrySchema validation.\"\"\"\n\n    def test_minimal_entry(self):\n        \"\"\"Schema accepts minimal required fields.\"\"\"\n        entry = PrintLogEntrySchema(\n            id=1,\n            status=\"completed\",\n            created_at=datetime(2024, 1, 15, 10, 30, 0),\n        )\n        assert entry.id == 1\n        assert entry.status == \"completed\"\n        assert entry.print_name is None\n        assert entry.printer_name is None\n        assert entry.duration_seconds is None\n\n    def test_full_entry(self):\n        \"\"\"Schema accepts all fields.\"\"\"\n        started = datetime(2024, 1, 15, 10, 0, 0)\n        completed = datetime(2024, 1, 15, 12, 30, 0)\n        entry = PrintLogEntrySchema(\n            id=42,\n            print_name=\"Benchy\",\n            printer_name=\"X1C-01\",\n            printer_id=3,\n            status=\"completed\",\n            started_at=started,\n            completed_at=completed,\n            duration_seconds=9000,\n            filament_type=\"PLA\",\n            filament_color=\"#FF5500\",\n            filament_used_grams=15.2,\n            thumbnail_path=\"archives/3/20240115_benchy/thumbnail.png\",\n            created_by_username=\"admin\",\n            created_at=datetime(2024, 1, 15, 12, 30, 0),\n        )\n        assert entry.print_name == \"Benchy\"\n        assert entry.printer_name == \"X1C-01\"\n        assert entry.filament_used_grams == 15.2\n        assert entry.created_by_username == \"admin\"\n\n    def test_failed_status(self):\n        \"\"\"Schema accepts various status values.\"\"\"\n        for status in (\"completed\", \"failed\", \"stopped\", \"cancelled\", \"skipped\"):\n            entry = PrintLogEntrySchema(id=1, status=status, created_at=datetime.now())\n            assert entry.status == status\n\n\nclass TestPrintLogResponse:\n    \"\"\"Test PrintLogResponse pagination wrapper.\"\"\"\n\n    def test_empty_response(self):\n        \"\"\"Empty response with zero total.\"\"\"\n        resp = PrintLogResponse(items=[], total=0)\n        assert len(resp.items) == 0\n        assert resp.total == 0\n\n    def test_paginated_response(self):\n        \"\"\"Response with items and total count > items count.\"\"\"\n        items = [PrintLogEntrySchema(id=i, status=\"completed\", created_at=datetime.now()) for i in range(3)]\n        resp = PrintLogResponse(items=items, total=100)\n        assert len(resp.items) == 3\n        assert resp.total == 100\n\n\nclass TestWriteLogEntry:\n    \"\"\"Test the write_log_entry service function (logic only, no DB).\"\"\"\n\n    def test_duration_calculation(self):\n        \"\"\"Duration is computed from started_at and completed_at.\"\"\"\n        started = datetime(2024, 1, 15, 10, 0, 0)\n        completed = started + timedelta(hours=2, minutes=30)\n\n        # Simulating the duration calculation from write_log_entry\n        duration = int((completed - started).total_seconds())\n        assert duration == 9000  # 2.5 hours = 9000 seconds\n\n    def test_duration_none_when_missing_times(self):\n        \"\"\"Duration is None when started_at or completed_at is missing.\"\"\"\n        started = datetime(2024, 1, 15, 10, 0, 0)\n        completed_at = None\n        started_at = None\n        completed = datetime.now()\n\n        # No completed_at\n        duration = None\n        if started and completed_at:\n            duration = int((completed_at - started).total_seconds())\n        assert duration is None\n\n        # No started_at\n        duration = None\n        if started_at and completed:\n            duration = int((completed - started_at).total_seconds())\n        assert duration is None\n"
  },
  {
    "path": "backend/tests/unit/test_print_speed.py",
    "content": "\"\"\"Unit tests for the print speed control endpoint.\n\nTests POST /api/v1/printers/{printer_id}/print-speed?mode=N\nwhere mode is 1=silent, 2=standard, 3=sport, 4=ludicrous.\n\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom httpx import AsyncClient\n\n\nclass TestPrintSpeedAPI:\n    \"\"\"Tests for the print speed control endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_print_speed_not_found(self, async_client: AsyncClient):\n        \"\"\"Verify 404 for non-existent printer.\"\"\"\n        response = await async_client.post(\"/api/v1/printers/99999/print-speed?mode=2\")\n        assert response.status_code == 404\n\n    @pytest.mark.asyncio\n    async def test_print_speed_not_connected(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify error when printer is not connected.\"\"\"\n        printer = await printer_factory(name=\"Disconnected Printer\")\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = None\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print-speed?mode=2\")\n\n            assert response.status_code == 400\n            assert \"not connected\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    async def test_print_speed_failure(self, async_client: AsyncClient, printer_factory):\n        \"\"\"Verify 500 when client fails to set speed.\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        mock_client = MagicMock()\n        mock_client.set_print_speed.return_value = False\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print-speed?mode=2\")\n\n            assert response.status_code == 500\n            assert \"failed\" in response.json()[\"detail\"].lower()\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mode, expected_name\",\n        [\n            (1, \"Silent\"),\n            (2, \"Standard\"),\n            (3, \"Sport\"),\n            (4, \"Ludicrous\"),\n        ],\n    )\n    async def test_print_speed_success(self, async_client: AsyncClient, printer_factory, mode, expected_name):\n        \"\"\"Verify successful speed change for each mode (1-4).\"\"\"\n        printer = await printer_factory(name=\"Test Printer\")\n\n        mock_client = MagicMock()\n        mock_client.set_print_speed.return_value = True\n\n        with patch(\"backend.app.api.routes.printers.printer_manager\") as mock_pm:\n            mock_pm.get_client.return_value = mock_client\n\n            response = await async_client.post(f\"/api/v1/printers/{printer.id}/print-speed?mode={mode}\")\n\n            assert response.status_code == 200\n            result = response.json()\n            assert result[\"success\"] is True\n            assert expected_name in result[\"message\"]\n            mock_client.set_print_speed.assert_called_once_with(mode)\n"
  },
  {
    "path": "backend/tests/unit/test_print_start_expected_promotion.py",
    "content": "\"\"\"Tests for expected print promotion when auto_archive is disabled (#839).\n\nWhen auto_archive=False but a print was dispatched by BamBuddy (queue/reprint),\nthe on_print_start callback must still promote the expected print to _active_prints\nso that at print completion the archive_id and ams_mapping are available for\nfilament usage tracking.\n\nThese are pure unit tests that verify the module-level dict manipulation logic\ndirectly, NOT by calling the full on_print_start callback.\n\"\"\"\n\nimport time\n\nimport pytest\n\nfrom backend.app.main import (\n    _active_prints,\n    _expected_print_creators,\n    _expected_print_registered_at,\n    _expected_prints,\n    _print_ams_mappings,\n    register_expected_print,\n)\n\n\n@pytest.fixture(autouse=True)\ndef _clear_dicts():\n    \"\"\"Clear module-level tracking dicts before and after each test.\"\"\"\n    _expected_prints.clear()\n    _expected_print_registered_at.clear()\n    _expected_print_creators.clear()\n    _print_ams_mappings.clear()\n    _active_prints.clear()\n    yield\n    _expected_prints.clear()\n    _expected_print_registered_at.clear()\n    _expected_print_creators.clear()\n    _print_ams_mappings.clear()\n    _active_prints.clear()\n\n\nclass TestRegisterExpectedPrint:\n    \"\"\"Verify register_expected_print populates all tracking dicts.\"\"\"\n\n    def test_registers_filename_and_variants(self):\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n\n        assert _expected_prints[(1, \"Box.3mf\")] == 54\n        assert _expected_prints[(1, \"Box\")] == 54\n        assert _expected_prints[(1, \"Box.gcode\")] == 54\n\n    def test_stores_ams_mapping(self):\n        register_expected_print(1, \"test.3mf\", archive_id=10, ams_mapping=[2, -1, 3])\n        assert _print_ams_mappings[10] == [2, -1, 3]\n\n    def test_no_ams_mapping_when_none(self):\n        register_expected_print(1, \"test.3mf\", archive_id=10, ams_mapping=None)\n        assert 10 not in _print_ams_mappings\n\n    def test_stores_creator(self):\n        register_expected_print(1, \"test.3mf\", archive_id=10, created_by_id=5)\n        assert _expected_print_creators[(1, \"test.3mf\")] == 5\n\n    def test_stores_registered_at(self):\n        before = time.monotonic()\n        register_expected_print(1, \"test.3mf\", archive_id=10)\n        after = time.monotonic()\n\n        ts = _expected_print_registered_at[(1, \"test.3mf\")]\n        assert before <= ts <= after\n\n\nclass TestExpectedPrintDetection:\n    \"\"\"Verify the expected-print detection logic used in on_print_start.\n\n    Reproduces the key-building and lookup logic from the auto_archive=False\n    block in on_print_start to verify that expected prints are correctly\n    detected across all filename variations.\n    \"\"\"\n\n    @staticmethod\n    def _build_check_keys(printer_id: int, filename: str, subtask_name: str):\n        \"\"\"Reproduce the key-building logic from on_print_start.\"\"\"\n        check_keys = []\n        if subtask_name:\n            check_keys += [\n                (printer_id, subtask_name),\n                (printer_id, f\"{subtask_name}.3mf\"),\n                (printer_id, f\"{subtask_name}.gcode.3mf\"),\n            ]\n        if filename:\n            base_fn = filename.split(\"/\")[-1] if \"/\" in filename else filename\n            check_keys.append((printer_id, base_fn))\n            no_archive_base = base_fn.replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n            check_keys += [\n                (printer_id, no_archive_base),\n                (printer_id, f\"{no_archive_base}.3mf\"),\n            ]\n        return check_keys\n\n    def test_detects_expected_print_by_subtask(self):\n        \"\"\"Expected print is found when subtask_name matches.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n        keys = self._build_check_keys(1, filename=\"\", subtask_name=\"Box\")\n        assert any(k in _expected_prints for k in keys)\n\n    def test_detects_expected_print_by_filename(self):\n        \"\"\"Expected print is found when filename matches.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n        keys = self._build_check_keys(1, filename=\"Box.3mf\", subtask_name=\"\")\n        assert any(k in _expected_prints for k in keys)\n\n    def test_detects_expected_print_by_gcode_filename(self):\n        \"\"\"Expected print is found when MQTT reports .gcode filename.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n        # MQTT sometimes reports gcode filename\n        keys = self._build_check_keys(1, filename=\"Box.gcode\", subtask_name=\"Box\")\n        assert any(k in _expected_prints for k in keys)\n\n    def test_no_false_positive_for_different_file(self):\n        \"\"\"Expected print NOT found for a different filename.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n        keys = self._build_check_keys(1, filename=\"Benchy.3mf\", subtask_name=\"Benchy\")\n        assert not any(k in _expected_prints for k in keys)\n\n    def test_no_false_positive_for_different_printer(self):\n        \"\"\"Expected print NOT found when printer_id doesn't match.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n        keys = self._build_check_keys(2, filename=\"Box.3mf\", subtask_name=\"Box\")\n        assert not any(k in _expected_prints for k in keys)\n\n    def test_empty_expected_prints_returns_false(self):\n        \"\"\"No detection when _expected_prints is empty.\"\"\"\n        keys = self._build_check_keys(1, filename=\"test.3mf\", subtask_name=\"test\")\n        assert not any(k in _expected_prints for k in keys)\n\n    def test_filename_with_spaces_and_parens(self):\n        \"\"\"Handles filenames with spaces and parentheses (e.g. 'Box3.0_(2)_plate_5.3mf').\"\"\"\n        register_expected_print(1, \"Box3.0_(2)_plate_5.3mf\", archive_id=54, ams_mapping=[1])\n        keys = self._build_check_keys(\n            1,\n            filename=\"Box3.0_(2)_plate_5.gcode\",\n            subtask_name=\"Box3.0_(2)_plate_5\",\n        )\n        assert any(k in _expected_prints for k in keys)\n\n\nclass TestExpectedPrintPromotion:\n    \"\"\"Verify that expected prints are correctly promoted to _active_prints.\n\n    Reproduces the expected-print pop + promotion logic from on_print_start\n    (lines 1468-1496) to verify that _active_prints is populated and\n    _expected_prints is cleaned up.\n    \"\"\"\n\n    @staticmethod\n    def _simulate_expected_print_promotion(printer_id: int, subtask_name: str, filename: str, archive_filename: str):\n        \"\"\"Simulate the expected-print lookup and promotion from on_print_start.\"\"\"\n        expected_keys = []\n        if subtask_name:\n            expected_keys.append((printer_id, subtask_name))\n            expected_keys.append((printer_id, f\"{subtask_name}.3mf\"))\n            expected_keys.append((printer_id, f\"{subtask_name}.gcode.3mf\"))\n        if filename:\n            fname = filename.split(\"/\")[-1] if \"/\" in filename else filename\n            expected_keys.append((printer_id, fname))\n            base = fname.replace(\".gcode\", \"\").replace(\".3mf\", \"\")\n            expected_keys.append((printer_id, base))\n            expected_keys.append((printer_id, f\"{base}.3mf\"))\n\n        expected_archive_id = None\n        for key in expected_keys:\n            expected_archive_id = _expected_prints.pop(key, None)\n            _expected_print_registered_at.pop(key, None)\n            if expected_archive_id:\n                for other_key in expected_keys:\n                    _expected_prints.pop(other_key, None)\n                    _expected_print_registered_at.pop(other_key, None)\n                break\n\n        if expected_archive_id:\n            _active_prints[(printer_id, archive_filename)] = expected_archive_id\n            if subtask_name:\n                _active_prints[(printer_id, f\"{subtask_name}.3mf\")] = expected_archive_id\n\n        return expected_archive_id\n\n    def test_promotion_populates_active_prints(self):\n        \"\"\"After promotion, archive is in _active_prints.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n\n        archive_id = self._simulate_expected_print_promotion(\n            printer_id=1,\n            subtask_name=\"Box\",\n            filename=\"Box.gcode\",\n            archive_filename=\"Box.3mf\",\n        )\n\n        assert archive_id == 54\n        assert _active_prints[(1, \"Box.3mf\")] == 54\n\n    def test_promotion_cleans_up_expected_prints(self):\n        \"\"\"After promotion, _expected_prints is empty for this print.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n\n        self._simulate_expected_print_promotion(\n            printer_id=1,\n            subtask_name=\"Box\",\n            filename=\"Box.gcode\",\n            archive_filename=\"Box.3mf\",\n        )\n\n        # All variants should be cleaned up\n        assert (1, \"Box.3mf\") not in _expected_prints\n        assert (1, \"Box\") not in _expected_prints\n        assert (1, \"Box.gcode\") not in _expected_prints\n\n    def test_ams_mapping_survives_promotion(self):\n        \"\"\"_print_ams_mappings is NOT consumed during promotion — it's needed at completion.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n\n        self._simulate_expected_print_promotion(\n            printer_id=1,\n            subtask_name=\"Box\",\n            filename=\"Box.gcode\",\n            archive_filename=\"Box.3mf\",\n        )\n\n        # ams_mapping should still be available for on_print_complete\n        assert _print_ams_mappings[54] == [1]\n\n    def test_completion_lookup_finds_promoted_archive(self):\n        \"\"\"Simulate on_print_complete finding the archive in _active_prints.\"\"\"\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n\n        self._simulate_expected_print_promotion(\n            printer_id=1,\n            subtask_name=\"Box\",\n            filename=\"Box.gcode\",\n            archive_filename=\"Box.3mf\",\n        )\n\n        # Simulate on_print_complete key building\n        completion_keys = [\n            (1, \"Box.3mf\"),\n            (1, \"Box.gcode.3mf\"),\n            (1, \"Box\"),\n        ]\n        found_id = None\n        for key in completion_keys:\n            found_id = _active_prints.pop(key, None)\n            if found_id:\n                break\n\n        assert found_id == 54\n        # And ams_mapping is retrievable\n        assert _print_ams_mappings.pop(54, None) == [1]\n\n    def test_no_promotion_for_external_print(self):\n        \"\"\"When no expected print exists, nothing is promoted.\"\"\"\n        archive_id = self._simulate_expected_print_promotion(\n            printer_id=1,\n            subtask_name=\"Benchy\",\n            filename=\"Benchy.gcode\",\n            archive_filename=\"Benchy.3mf\",\n        )\n\n        assert archive_id is None\n        assert len(_active_prints) == 0\n\n\nclass TestAMSMappingInjection:\n    \"\"\"Verify ams_mapping injection into usage tracker session.\"\"\"\n\n    def test_injection_into_session(self):\n        \"\"\"ams_mapping from _print_ams_mappings is injectable into a session.\"\"\"\n        from datetime import datetime, timezone\n\n        from backend.app.services.usage_tracker import PrintSession, _active_sessions\n\n        _active_sessions.clear()\n\n        # Create a session without ams_mapping (simulates MQTT not providing it)\n        session = PrintSession(\n            printer_id=1,\n            print_name=\"Box\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={},\n            tray_now_at_start=-1,\n            spool_assignments={},\n            ams_mapping=None,\n        )\n        _active_sessions[1] = session\n\n        # Register expected print with ams_mapping\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n\n        # Simulate the injection logic from on_print_start\n        _stored_map = _print_ams_mappings.get(54)\n        assert _stored_map == [1]\n\n        ut_session = _active_sessions.get(1)\n        assert ut_session is not None\n        assert ut_session.ams_mapping is None  # before injection\n\n        ut_session.ams_mapping = _stored_map  # injection\n        assert ut_session.ams_mapping == [1]\n\n        _active_sessions.clear()\n\n    def test_no_injection_when_session_already_has_mapping(self):\n        \"\"\"Don't overwrite existing ams_mapping in session.\"\"\"\n        from datetime import datetime, timezone\n\n        from backend.app.services.usage_tracker import PrintSession, _active_sessions\n\n        _active_sessions.clear()\n\n        session = PrintSession(\n            printer_id=1,\n            print_name=\"Box\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={},\n            tray_now_at_start=-1,\n            spool_assignments={},\n            ams_mapping=[5, 6],  # already has mapping from MQTT\n        )\n        _active_sessions[1] = session\n\n        register_expected_print(1, \"Box.3mf\", archive_id=54, ams_mapping=[1])\n\n        _stored_map = _print_ams_mappings.get(54)\n        ut_session = _active_sessions.get(1)\n\n        # Guard: don't overwrite if session already has a mapping\n        if ut_session and not ut_session.ams_mapping:\n            ut_session.ams_mapping = _stored_map\n\n        assert ut_session.ams_mapping == [5, 6]  # unchanged\n\n        _active_sessions.clear()\n"
  },
  {
    "path": "backend/tests/unit/test_printer_models.py",
    "content": "\"\"\"Unit tests for printer model utilities.\"\"\"\n\nimport pytest\n\nfrom backend.app.services.camera import get_camera_port, supports_rtsp\nfrom backend.app.utils.printer_models import (\n    CARBON_ROD_MODELS,\n    STEEL_ROD_MODELS,\n    get_rod_type,\n    has_ethernet,\n    normalize_printer_model,\n    normalize_printer_model_id,\n)\n\n\nclass TestGetRodType:\n    \"\"\"Tests for get_rod_type() rod/rail classification.\"\"\"\n\n    @pytest.mark.parametrize(\"model\", [\"X1C\", \"X1\", \"X1E\", \"P1P\", \"P1S\"])\n    def test_carbon_rod_models(self, model: str):\n        assert get_rod_type(model) == \"carbon\"\n\n    @pytest.mark.parametrize(\"model\", [\"C11\", \"C12\", \"C13\"])\n    def test_carbon_rod_internal_codes(self, model: str):\n        assert get_rod_type(model) == \"carbon\"\n\n    def test_p2s_is_steel_rod(self):\n        \"\"\"P2S uses hardened steel rods, not carbon rods (#640).\"\"\"\n        assert get_rod_type(\"P2S\") == \"steel_rod\"\n\n    def test_p2s_internal_code_is_steel_rod(self):\n        \"\"\"N7 (P2S internal code) uses steel rods.\"\"\"\n        assert get_rod_type(\"N7\") == \"steel_rod\"\n\n    @pytest.mark.parametrize(\"model\", [\"A1\", \"A1 Mini\", \"H2D\", \"H2D Pro\", \"H2C\", \"H2S\"])\n    def test_linear_rail_models(self, model: str):\n        assert get_rod_type(model) == \"linear_rail\"\n\n    @pytest.mark.parametrize(\"model\", [\"N1\", \"N2S\", \"A11\", \"A12\", \"O1D\", \"O1E\", \"O2D\", \"O1C\", \"O1C2\", \"O1S\"])\n    def test_linear_rail_internal_codes(self, model: str):\n        assert get_rod_type(model) == \"linear_rail\"\n\n    def test_unknown_model_returns_none(self):\n        assert get_rod_type(\"UNKNOWN\") is None\n\n    def test_none_returns_none(self):\n        assert get_rod_type(None) is None\n\n    def test_case_insensitive(self):\n        assert get_rod_type(\"p2s\") == \"steel_rod\"\n        assert get_rod_type(\"x1c\") == \"carbon\"\n        assert get_rod_type(\"a1\") == \"linear_rail\"\n\n    def test_strips_whitespace_and_dashes(self):\n        assert get_rod_type(\" P2S \") == \"steel_rod\"\n        assert get_rod_type(\"A1-Mini\") == \"linear_rail\"\n\n\nclass TestX2DModel:\n    \"\"\"X2D printer support (issue #988).\n\n    The X2D is a dual-nozzle enclosed printer launched April 2026. It shares\n    the hardened steel rod hardware with P2S (NOT carbon rods) and uses\n    RTSP on port 322 like other X/H series printers. Internal SSDP/MQTT\n    model code is \"N6\"; serial numbers begin with \"20P9\".\n    \"\"\"\n\n    def test_x2d_is_steel_rod_display_name(self):\n        assert get_rod_type(\"X2D\") == \"steel_rod\"\n\n    def test_x2d_is_steel_rod_internal_code(self):\n        assert get_rod_type(\"N6\") == \"steel_rod\"\n\n    def test_x2d_model_id_map(self):\n        assert normalize_printer_model_id(\"N6\") == \"X2D\"\n\n    def test_x2d_model_map(self):\n        assert normalize_printer_model(\"Bambu Lab X2D\") == \"X2D\"\n\n    def test_x2d_has_ethernet_display_name(self):\n        assert has_ethernet(\"X2D\") is True\n\n    def test_x2d_has_ethernet_internal_code(self):\n        assert has_ethernet(\"N6\") is True\n\n    def test_x2d_supports_rtsp_display_name(self):\n        assert supports_rtsp(\"X2D\") is True\n\n    def test_x2d_supports_rtsp_internal_code(self):\n        assert supports_rtsp(\"N6\") is True\n\n    def test_x2d_camera_port_is_rtsp(self):\n        assert get_camera_port(\"N6\") == 322\n        assert get_camera_port(\"X2D\") == 322\n\n    def test_x2d_not_in_carbon_rod_set(self):\n        \"\"\"Regression guard: X2D has hardened steel rods, not carbon (#988).\n\n        A prior PR classified X2D as carbon; the reporter confirmed it uses\n        the same stainless steel rod gantry as P2S. This assertion pins the\n        classification so a future change that reverts it will fail loudly.\n        \"\"\"\n        assert \"X2D\" not in CARBON_ROD_MODELS\n        assert \"N6\" not in CARBON_ROD_MODELS\n        assert \"X2D\" in STEEL_ROD_MODELS\n        assert \"N6\" in STEEL_ROD_MODELS\n"
  },
  {
    "path": "backend/tests/unit/test_run_with_retry.py",
    "content": "\"\"\"Tests for database.run_with_retry — SQLite lock retry logic (#897).\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom sqlalchemy.exc import OperationalError\n\n\n@pytest.fixture(autouse=True)\ndef _force_sqlite():\n    \"\"\"Make is_sqlite() return True for all tests in this module.\"\"\"\n    with patch(\"backend.app.core.database.is_sqlite\", return_value=True):\n        yield\n\n\ndef _make_locked_error() -> OperationalError:\n    \"\"\"Create a realistic 'database is locked' OperationalError.\"\"\"\n    return OperationalError(\n        statement=\"UPDATE print_queue SET status=?\",\n        params=(\"completed\",),\n        orig=Exception(\"database is locked\"),\n    )\n\n\ndef _make_other_error() -> OperationalError:\n    \"\"\"Create a non-lock OperationalError.\"\"\"\n    return OperationalError(\n        statement=\"SELECT 1\",\n        params=(),\n        orig=Exception(\"no such table: foo\"),\n    )\n\n\n@pytest.mark.asyncio\nasync def test_succeeds_on_first_attempt():\n    \"\"\"Happy path — fn succeeds immediately.\"\"\"\n    from backend.app.core.database import run_with_retry\n\n    mock_fn = AsyncMock(return_value=\"ok\")\n\n    with patch(\"backend.app.core.database.async_session\") as mock_session_factory:\n        mock_db = AsyncMock()\n        mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=False)\n\n        result = await run_with_retry(mock_fn, label=\"test\")\n\n    assert result == \"ok\"\n    mock_fn.assert_awaited_once_with(mock_db)\n\n\n@pytest.mark.asyncio\nasync def test_retries_on_sqlite_locked():\n    \"\"\"fn fails with 'database is locked' then succeeds on retry.\"\"\"\n    from backend.app.core.database import run_with_retry\n\n    call_count = 0\n\n    async def flaky_fn(db):\n        nonlocal call_count\n        call_count += 1\n        if call_count == 1:\n            raise _make_locked_error()\n        return \"recovered\"\n\n    with (\n        patch(\"backend.app.core.database.async_session\") as mock_session_factory,\n        patch(\"backend.app.core.database.asyncio.sleep\", new_callable=AsyncMock) as mock_sleep,\n    ):\n        mock_db = AsyncMock()\n        mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=False)\n\n        result = await run_with_retry(flaky_fn, label=\"test\")\n\n    assert result == \"recovered\"\n    assert call_count == 2\n    mock_sleep.assert_awaited_once_with(0.5)  # first retry: 0.5s delay\n\n\n@pytest.mark.asyncio\nasync def test_raises_after_max_attempts():\n    \"\"\"fn fails with 'database is locked' on all attempts — raises.\"\"\"\n    from backend.app.core.database import run_with_retry\n\n    async def always_locked(db):\n        raise _make_locked_error()\n\n    with (\n        patch(\"backend.app.core.database.async_session\") as mock_session_factory,\n        patch(\"backend.app.core.database.asyncio.sleep\", new_callable=AsyncMock),\n    ):\n        mock_db = AsyncMock()\n        mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=False)\n\n        with pytest.raises(OperationalError, match=\"database is locked\"):\n            await run_with_retry(always_locked, max_attempts=3, label=\"test\")\n\n\n@pytest.mark.asyncio\nasync def test_non_lock_error_not_retried():\n    \"\"\"Non-lock OperationalErrors are raised immediately, not retried.\"\"\"\n    from backend.app.core.database import run_with_retry\n\n    call_count = 0\n\n    async def bad_fn(db):\n        nonlocal call_count\n        call_count += 1\n        raise _make_other_error()\n\n    with (\n        patch(\"backend.app.core.database.async_session\") as mock_session_factory,\n        patch(\"backend.app.core.database.asyncio.sleep\", new_callable=AsyncMock) as mock_sleep,\n    ):\n        mock_db = AsyncMock()\n        mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=False)\n\n        with pytest.raises(OperationalError, match=\"no such table\"):\n            await run_with_retry(bad_fn, label=\"test\")\n\n    assert call_count == 1\n    mock_sleep.assert_not_awaited()\n\n\n@pytest.mark.asyncio\nasync def test_backoff_increases():\n    \"\"\"Retry delays increase: 0.5s, 1.0s, 1.5s.\"\"\"\n    from backend.app.core.database import run_with_retry\n\n    call_count = 0\n\n    async def recovers_on_third(db):\n        nonlocal call_count\n        call_count += 1\n        if call_count < 3:\n            raise _make_locked_error()\n        return \"ok\"\n\n    with (\n        patch(\"backend.app.core.database.async_session\") as mock_session_factory,\n        patch(\"backend.app.core.database.asyncio.sleep\", new_callable=AsyncMock) as mock_sleep,\n    ):\n        mock_db = AsyncMock()\n        mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=False)\n\n        result = await run_with_retry(recovers_on_third, max_attempts=3, label=\"test\")\n\n    assert result == \"ok\"\n    assert call_count == 3\n    assert mock_sleep.await_args_list[0].args == (0.5,)\n    assert mock_sleep.await_args_list[1].args == (1.0,)\n\n\n@pytest.mark.asyncio\nasync def test_postgres_no_retry():\n    \"\"\"On PostgreSQL, fn is called once with no retry logic.\"\"\"\n    from backend.app.core.database import run_with_retry\n\n    mock_fn = AsyncMock(return_value=\"pg_ok\")\n\n    with (\n        patch(\"backend.app.core.database.is_sqlite\", return_value=False),\n        patch(\"backend.app.core.database.async_session\") as mock_session_factory,\n    ):\n        mock_db = AsyncMock()\n        mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=False)\n\n        result = await run_with_retry(mock_fn, label=\"test\")\n\n    assert result == \"pg_ok\"\n    mock_fn.assert_awaited_once_with(mock_db)\n\n\n@pytest.mark.asyncio\nasync def test_postgres_error_not_retried():\n    \"\"\"On PostgreSQL, OperationalErrors are raised immediately.\"\"\"\n    from backend.app.core.database import run_with_retry\n\n    async def bad_fn(db):\n        raise _make_locked_error()\n\n    with (\n        patch(\"backend.app.core.database.is_sqlite\", return_value=False),\n        patch(\"backend.app.core.database.async_session\") as mock_session_factory,\n    ):\n        mock_db = AsyncMock()\n        mock_session_factory.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n        mock_session_factory.return_value.__aexit__ = AsyncMock(return_value=False)\n\n        with pytest.raises(OperationalError):\n            await run_with_retry(bad_fn, label=\"test\")\n"
  },
  {
    "path": "backend/tests/unit/test_scheduler_ams_mapping.py",
    "content": "\"\"\"Tests for the AMS mapping computation in the print scheduler.\"\"\"\n\nimport io\nimport json\nimport zipfile\n\nimport pytest\n\nfrom backend.app.services.print_scheduler import PrintScheduler\nfrom backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf\n\n\nclass TestSchedulerAmsMappingHelpers:\n    \"\"\"Test the AMS mapping helper methods in PrintScheduler.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_normalize_color_with_hash(self, scheduler):\n        \"\"\"Color with hash should return #RRGGBB format.\"\"\"\n        result = scheduler._normalize_color(\"#FF5500\")\n        assert result == \"#FF5500\"\n\n    def test_normalize_color_without_hash(self, scheduler):\n        \"\"\"Color without hash should add hash prefix.\"\"\"\n        result = scheduler._normalize_color(\"FF5500\")\n        assert result == \"#FF5500\"\n\n    def test_normalize_color_with_alpha(self, scheduler):\n        \"\"\"Color with alpha channel should strip it.\"\"\"\n        result = scheduler._normalize_color(\"FF5500AA\")\n        assert result == \"#FF5500\"\n\n    def test_normalize_color_none(self, scheduler):\n        \"\"\"None color should return default gray.\"\"\"\n        result = scheduler._normalize_color(None)\n        assert result == \"#808080\"\n\n    def test_normalize_color_empty(self, scheduler):\n        \"\"\"Empty color should return default gray.\"\"\"\n        result = scheduler._normalize_color(\"\")\n        assert result == \"#808080\"\n\n    def test_normalize_color_for_compare(self, scheduler):\n        \"\"\"Color for compare should be lowercase without hash.\"\"\"\n        result = scheduler._normalize_color_for_compare(\"#FF5500\")\n        assert result == \"ff5500\"\n\n    def test_normalize_color_for_compare_with_alpha(self, scheduler):\n        \"\"\"Alpha channel should be stripped for comparison.\"\"\"\n        result = scheduler._normalize_color_for_compare(\"#FF5500AA\")\n        assert result == \"ff5500\"\n\n    def test_colors_are_similar_exact_match(self, scheduler):\n        \"\"\"Exact same colors should be similar.\"\"\"\n        assert scheduler._colors_are_similar(\"#FF5500\", \"#FF5500\") is True\n\n    def test_colors_are_similar_within_threshold(self, scheduler):\n        \"\"\"Colors within threshold should be similar.\"\"\"\n        # Red difference of 10, well within default threshold of 40\n        assert scheduler._colors_are_similar(\"#FF5500\", \"#F55500\") is True\n\n    def test_colors_are_similar_outside_threshold(self, scheduler):\n        \"\"\"Colors outside threshold should not be similar.\"\"\"\n        # Red: FF (255) vs 00 (0) = 255 difference\n        assert scheduler._colors_are_similar(\"#FF0000\", \"#00FF00\") is False\n\n    def test_colors_are_similar_none_colors(self, scheduler):\n        \"\"\"None colors should not be similar.\"\"\"\n        assert scheduler._colors_are_similar(None, \"#FF5500\") is False\n        assert scheduler._colors_are_similar(\"#FF5500\", None) is False\n\n\nclass TestBuildLoadedFilaments:\n    \"\"\"Test the _build_loaded_filaments method.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_build_loaded_filaments_empty_status(self, scheduler):\n        \"\"\"Empty status should return empty list.\"\"\"\n\n        class MockStatus:\n            raw_data = {}\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert result == []\n\n    def test_build_loaded_filaments_with_ams(self, scheduler):\n        \"\"\"Should extract filaments from AMS units.\"\"\"\n\n        class MockStatus:\n            raw_data = {\n                \"ams\": [\n                    {\n                        \"id\": 0,\n                        \"tray\": [\n                            {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000\"},\n                            {\"id\": 1, \"tray_type\": \"PETG\", \"tray_color\": \"00FF00\"},\n                        ],\n                    }\n                ]\n            }\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 2\n\n        # First filament\n        assert result[0][\"type\"] == \"PLA\"\n        assert result[0][\"color\"] == \"#FF0000\"\n        assert result[0][\"ams_id\"] == 0\n        assert result[0][\"tray_id\"] == 0\n        assert result[0][\"global_tray_id\"] == 0  # 0 * 4 + 0\n\n        # Second filament\n        assert result[1][\"type\"] == \"PETG\"\n        assert result[1][\"global_tray_id\"] == 1  # 0 * 4 + 1\n\n    def test_build_loaded_filaments_with_ht_ams(self, scheduler):\n        \"\"\"AMS-HT (single tray) should be marked as is_ht.\"\"\"\n\n        class MockStatus:\n            raw_data = {\n                \"ams\": [\n                    {\n                        \"id\": 128,\n                        \"tray\": [{\"id\": 0, \"tray_type\": \"PLA-CF\", \"tray_color\": \"000000\"}],\n                    }\n                ]\n            }\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 1\n        assert result[0][\"is_ht\"] is True\n        assert result[0][\"global_tray_id\"] == 128  # AMS-HT uses ams_id directly\n\n    def test_build_loaded_filaments_with_external(self, scheduler):\n        \"\"\"Should include external spool.\"\"\"\n\n        class MockStatus:\n            raw_data = {\"vt_tray\": [{\"tray_type\": \"TPU\", \"tray_color\": \"0000FF\"}]}\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 1\n        assert result[0][\"type\"] == \"TPU\"\n        assert result[0][\"is_external\"] is True\n        assert result[0][\"global_tray_id\"] == 254\n\n    def test_build_loaded_filaments_skips_empty_trays(self, scheduler):\n        \"\"\"Trays without tray_type should be skipped.\"\"\"\n\n        class MockStatus:\n            raw_data = {\n                \"ams\": [\n                    {\n                        \"id\": 0,\n                        \"tray\": [\n                            {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000\"},\n                            {\"id\": 1, \"tray_type\": \"\", \"tray_color\": \"\"},  # Empty\n                            {\"id\": 2},  # No tray_type key\n                        ],\n                    }\n                ]\n            }\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 1\n        assert result[0][\"type\"] == \"PLA\"\n\n\nclass TestMatchFilamentsToSlots:\n    \"\"\"Test the _match_filaments_to_slots method.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_match_empty_required(self, scheduler):\n        \"\"\"Empty required list should return None.\"\"\"\n        result = scheduler._match_filaments_to_slots([], [])\n        assert result is None\n\n    def test_match_exact_color(self, scheduler):\n        \"\"\"Should prefer exact color match.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#00FF00\", \"global_tray_id\": 0},  # Wrong color\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 1},  # Exact match\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [1]  # Should pick tray 1 (exact color match)\n\n    def test_match_similar_color(self, scheduler):\n        \"\"\"Should match similar colors when no exact match.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF5500\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF5510\", \"global_tray_id\": 0},  # Similar\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [0]\n\n    def test_match_type_only(self, scheduler):\n        \"\"\"Should match by type when colors don't match.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#0000FF\", \"global_tray_id\": 5},  # Type match, color way off\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [5]\n\n    def test_match_no_match_returns_minus_one(self, scheduler):\n        \"\"\"Unmatched filaments should have -1 in mapping.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PETG\", \"color\": \"#FF0000\", \"global_tray_id\": 0},  # Wrong type\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [-1]\n\n    def test_match_multiple_filaments(self, scheduler):\n        \"\"\"Should match multiple filaments correctly.\"\"\"\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"},\n            {\"slot_id\": 2, \"type\": \"PETG\", \"color\": \"#00FF00\"},\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0},\n            {\"type\": \"PETG\", \"color\": \"#00FF00\", \"global_tray_id\": 1},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [0, 1]\n\n    def test_match_avoids_duplicate_assignment(self, scheduler):\n        \"\"\"Same tray should not be assigned to multiple slots.\"\"\"\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"},\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#FF0000\"},  # Same requirements\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0},  # Only one PLA\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        # First slot gets the match, second slot gets -1\n        assert result == [0, -1]\n\n    def test_match_h2d_pro_ams_ids(self, scheduler):\n        \"\"\"Should work with H2D Pro's high AMS IDs (128+).\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 512},  # AMS 128, slot 0\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [512]\n\n    def test_match_external_spool(self, scheduler):\n        \"\"\"Should match external spool with ID 254.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"TPU\", \"color\": \"#0000FF\"}]\n        loaded = [\n            {\"type\": \"TPU\", \"color\": \"#0000FF\", \"global_tray_id\": 254, \"is_external\": True},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [254]\n\n    def test_match_by_tray_info_idx_priority(self, scheduler):\n        \"\"\"tray_info_idx match should have highest priority over color match.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"GFA00\"}]\n        loaded = [\n            {\n                \"type\": \"PLA\",\n                \"color\": \"#000000\",\n                \"global_tray_id\": 0,\n                \"tray_info_idx\": \"GFB00\",\n            },  # Same color, different spool\n            {\n                \"type\": \"PLA\",\n                \"color\": \"#000000\",\n                \"global_tray_id\": 1,\n                \"tray_info_idx\": \"GFA00\",\n            },  # Same color, exact spool\n            {\n                \"type\": \"PLA\",\n                \"color\": \"#000000\",\n                \"global_tray_id\": 2,\n                \"tray_info_idx\": \"GFC00\",\n            },  # Same color, different spool\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [1]  # Should pick tray 1 (exact tray_info_idx match)\n\n    def test_match_by_tray_info_idx_with_different_colors(self, scheduler):\n        \"\"\"tray_info_idx match should work even if colors differ slightly.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"P4d64437\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#000000\", \"global_tray_id\": 0, \"tray_info_idx\": \"\"},  # No idx\n            {\n                \"type\": \"PLA\",\n                \"color\": \"#000010\",\n                \"global_tray_id\": 3,\n                \"tray_info_idx\": \"P4d64437\",\n            },  # Exact spool (slightly different color reported)\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [3]  # Should pick tray 3 (exact tray_info_idx match)\n\n    def test_match_fallback_to_color_when_no_tray_info_idx(self, scheduler):\n        \"\"\"Should fall back to color matching when tray_info_idx is empty.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\", \"tray_info_idx\": \"\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#00FF00\", \"global_tray_id\": 0, \"tray_info_idx\": \"GFA00\"},\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 1, \"tray_info_idx\": \"GFB00\"},  # Color match\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [1]  # Should pick tray 1 (color match)\n\n    def test_match_fallback_to_color_when_no_matching_tray_info_idx(self, scheduler):\n        \"\"\"Should fall back to color when tray_info_idx doesn't match any loaded spool.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\", \"tray_info_idx\": \"OLD_SPOOL\"}]\n        loaded = [\n            {\n                \"type\": \"PLA\",\n                \"color\": \"#FF0000\",\n                \"global_tray_id\": 0,\n                \"tray_info_idx\": \"NEW_SPOOL\",\n            },  # Different idx but same color\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [0]  # Should fall back to color match\n\n    def test_match_multiple_same_color_with_tray_info_idx(self, scheduler):\n        \"\"\"Multiple identical filaments should be matched by tray_info_idx (H2D Pro scenario).\"\"\"\n        # This is the exact scenario from issue #245 - 3 black PLA spools\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"GFA03\"},  # Wants tray 3\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#000000\", \"global_tray_id\": 0, \"tray_info_idx\": \"GFA00\"},  # Tray 0\n            {\"type\": \"PLA\", \"color\": \"#000000\", \"global_tray_id\": 1, \"tray_info_idx\": \"GFA01\"},  # Tray 1\n            {\"type\": \"PLA\", \"color\": \"#000000\", \"global_tray_id\": 2, \"tray_info_idx\": \"GFA02\"},  # Tray 2\n            {\n                \"type\": \"PLA\",\n                \"color\": \"#000000\",\n                \"global_tray_id\": 3,\n                \"tray_info_idx\": \"GFA03\",\n            },  # Tray 3 - the one we want\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [3]  # Should pick tray 3, not tray 0\n\n    def test_match_tray_info_idx_not_reused(self, scheduler):\n        \"\"\"tray_info_idx matched trays should not be reused for other slots.\"\"\"\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"GFA00\"},\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"GFA01\"},\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#000000\", \"global_tray_id\": 0, \"tray_info_idx\": \"GFA00\"},\n            {\"type\": \"PLA\", \"color\": \"#000000\", \"global_tray_id\": 1, \"tray_info_idx\": \"GFA01\"},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [0, 1]  # Each slot gets its specific tray\n\n    def test_match_non_unique_tray_info_idx_uses_color(self, scheduler):\n        \"\"\"Non-unique tray_info_idx should fall back to color matching.\n\n        This is the scenario where multiple trays have the same tray_info_idx\n        (e.g., two spools of generic PLA both have GFA00). The color should\n        be used as tiebreaker instead of just picking the first match.\n        \"\"\"\n        # User sliced with green PLA (tray_info_idx=GFA00)\n        # Two trays have GFA00: tray 3 (white) and tray 4 (green)\n        # Should pick tray 4 because the color matches\n        required = [\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#00FF00\", \"tray_info_idx\": \"GFA00\"},  # Green PLA\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FFFFFF\", \"global_tray_id\": 3, \"tray_info_idx\": \"GFA00\"},  # White PLA\n            {\"type\": \"PLA\", \"color\": \"#00FF00\", \"global_tray_id\": 4, \"tray_info_idx\": \"GFA00\"},  # Green PLA\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [-1, 4]  # Should pick tray 4 (color match), not tray 3 (first match)\n\n    def test_match_non_unique_tray_info_idx_same_color(self, scheduler):\n        \"\"\"Non-unique tray_info_idx with identical colors picks first match.\n\n        When multiple trays have the same tray_info_idx AND same color,\n        there's no way to differentiate, so first match is used.\n        \"\"\"\n        required = [\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#FFFFFF\", \"tray_info_idx\": \"GFA00\"},\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FFFFFF\", \"global_tray_id\": 3, \"tray_info_idx\": \"GFA00\"},\n            {\"type\": \"PLA\", \"color\": \"#FFFFFF\", \"global_tray_id\": 4, \"tray_info_idx\": \"GFA00\"},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        # Both have same color, so first is used\n        assert result == [-1, 3]\n\n\nclass TestPreferLowestFilament:\n    \"\"\"Test prefer_lowest_filament sorting in _match_filaments_to_slots.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_prefer_lowest_picks_lower_remain(self, scheduler):\n        \"\"\"When enabled, should pick the spool with lower remaining filament.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0, \"remain\": 80},\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 1, \"remain\": 30},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)\n        assert result == [1]  # Should pick tray 1 (30% remaining)\n\n    def test_prefer_lowest_disabled_picks_first(self, scheduler):\n        \"\"\"When disabled, should pick the first matching spool (default behavior).\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0, \"remain\": 80},\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 1, \"remain\": 30},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=False)\n        assert result == [0]  # Should pick tray 0 (first match)\n\n    def test_prefer_lowest_unknown_remain_sorted_last(self, scheduler):\n        \"\"\"Spools with remain=-1 (unknown) should be sorted to end.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0, \"remain\": -1},\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 1, \"remain\": 50},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)\n        assert result == [1]  # Should pick tray 1 (known 50%) over unknown\n\n    def test_prefer_lowest_missing_remain_sorted_last(self, scheduler):\n        \"\"\"Spools without remain field should be sorted to end.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0},  # No remain field\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 1, \"remain\": 50},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)\n        assert result == [1]  # Should pick tray 1 (known 50%) over missing\n\n    def test_prefer_lowest_multiple_slots(self, scheduler):\n        \"\"\"Should pick lowest remain for each slot independently.\"\"\"\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"},\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#FF0000\"},\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0, \"remain\": 80},\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 1, \"remain\": 30},\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 2, \"remain\": 60},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)\n        # Slot 1 gets tray 1 (30%), slot 2 gets tray 2 (60%) — tray 0 (80%) unused\n        assert result == [1, 2]\n\n    def test_prefer_lowest_with_tray_info_idx(self, scheduler):\n        \"\"\"Should sort within tray_info_idx subset too.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FFFFFF\", \"tray_info_idx\": \"GFA00\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FFFFFF\", \"global_tray_id\": 0, \"tray_info_idx\": \"GFA00\", \"remain\": 80},\n            {\"type\": \"PLA\", \"color\": \"#FFFFFF\", \"global_tray_id\": 1, \"tray_info_idx\": \"GFA00\", \"remain\": 20},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)\n        assert result == [1]  # Should pick tray 1 (20%) within idx subset\n\n    def test_prefer_lowest_external_spool(self, scheduler):\n        \"\"\"External spool with low remain should be preferred over AMS spool.\"\"\"\n        required = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0, \"remain\": 80, \"is_external\": False},\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 254, \"remain\": 10, \"is_external\": True},\n        ]\n\n        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)\n        assert result == [254]  # Should pick external spool (10%) over AMS (80%)\n\n\nclass TestBuildLoadedFilamentsTrayInfoIdx:\n    \"\"\"Test tray_info_idx extraction in _build_loaded_filaments.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_build_loaded_filaments_includes_tray_info_idx(self, scheduler):\n        \"\"\"Should extract tray_info_idx from AMS trays.\"\"\"\n\n        class MockStatus:\n            raw_data = {\n                \"ams\": [\n                    {\n                        \"id\": 0,\n                        \"tray\": [\n                            {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"000000\", \"tray_info_idx\": \"GFA00\"},\n                            {\"id\": 1, \"tray_type\": \"PLA\", \"tray_color\": \"000000\", \"tray_info_idx\": \"GFA01\"},\n                        ],\n                    }\n                ]\n            }\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 2\n        assert result[0][\"tray_info_idx\"] == \"GFA00\"\n        assert result[1][\"tray_info_idx\"] == \"GFA01\"\n\n    def test_build_loaded_filaments_empty_tray_info_idx(self, scheduler):\n        \"\"\"Missing tray_info_idx should default to empty string.\"\"\"\n\n        class MockStatus:\n            raw_data = {\n                \"ams\": [\n                    {\n                        \"id\": 0,\n                        \"tray\": [\n                            {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000\"},  # No tray_info_idx\n                        ],\n                    }\n                ]\n            }\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 1\n        assert result[0][\"tray_info_idx\"] == \"\"\n\n    def test_build_loaded_filaments_external_spool_tray_info_idx(self, scheduler):\n        \"\"\"Should extract tray_info_idx from external spool.\"\"\"\n\n        class MockStatus:\n            raw_data = {\"vt_tray\": [{\"tray_type\": \"TPU\", \"tray_color\": \"0000FF\", \"tray_info_idx\": \"P4d64437\"}]}\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 1\n        assert result[0][\"tray_info_idx\"] == \"P4d64437\"\n        assert result[0][\"is_external\"] is True\n\n\ndef _make_3mf_zip(\n    project_settings: dict | None = None,\n    slice_info_xml: str | None = None,\n) -> zipfile.ZipFile:\n    \"\"\"Create an in-memory ZipFile mimicking a 3MF with project_settings.config.\"\"\"\n    buf = io.BytesIO()\n    with zipfile.ZipFile(buf, \"w\") as zf:\n        if project_settings is not None:\n            zf.writestr(\"Metadata/project_settings.config\", json.dumps(project_settings))\n        if slice_info_xml is not None:\n            zf.writestr(\"Metadata/slice_info.config\", slice_info_xml)\n    buf.seek(0)\n    return zipfile.ZipFile(buf, \"r\")\n\n\nclass TestExtractNozzleMappingFrom3mf:\n    \"\"\"Test the extract_nozzle_mapping_from_3mf utility.\"\"\"\n\n    def test_group_id_priority_over_filament_nozzle_map(self):\n        \"\"\"group_id from slice_info should override filament_nozzle_map from project_settings.\n\n        Real-world scenario: \"Auto For Flush\" mode sets filament_nozzle_map all to 0\n        (user preference) but the actual assignment in slice_info has different group_ids.\n        \"\"\"\n        # filament_nozzle_map says all on slicer ext 0 → MQTT ext 1 (LEFT)\n        # But slice_info group_id says slot 6 → group 0 (LEFT), slot 12 → group 1 (RIGHT)\n        slice_info = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n          <plate>\n            <filament id=\"6\" type=\"PLA\" color=\"#56B7E6\" used_g=\"1.84\" group_id=\"0\"/>\n            <filament id=\"12\" type=\"PLA\" color=\"#B39B84\" used_g=\"1.76\" group_id=\"1\"/>\n          </plate>\n        </config>\"\"\"\n        zf = _make_3mf_zip(\n            {\n                \"filament_nozzle_map\": [\"0\"] * 12,\n                \"physical_extruder_map\": [\"1\", \"0\"],\n            },\n            slice_info_xml=slice_info,\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        # group_id 0 → physical_extruder_map[0] = 1 (LEFT)\n        # group_id 1 → physical_extruder_map[1] = 0 (RIGHT)\n        assert result == {6: 1, 12: 0}\n        zf.close()\n\n    def test_fallback_to_filament_nozzle_map_without_group_id(self):\n        \"\"\"Should fall back to filament_nozzle_map when slice_info has no group_id.\"\"\"\n        slice_info = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n          <plate>\n            <filament id=\"1\" type=\"PLA\" color=\"#FF0000\" used_g=\"5.0\"/>\n          </plate>\n        </config>\"\"\"\n        zf = _make_3mf_zip(\n            {\n                \"filament_nozzle_map\": [\"0\", \"1\", \"0\"],\n                \"physical_extruder_map\": [\"0\", \"1\"],\n            },\n            slice_info_xml=slice_info,\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        assert result == {1: 0, 2: 1, 3: 0}\n        zf.close()\n\n    def test_fallback_to_filament_nozzle_map_without_slice_info(self):\n        \"\"\"Should fall back to filament_nozzle_map when no slice_info.config exists.\"\"\"\n        zf = _make_3mf_zip(\n            {\n                \"filament_nozzle_map\": [\"0\", \"1\", \"0\"],\n                \"physical_extruder_map\": [\"0\", \"1\"],\n            }\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        assert result == {1: 0, 2: 1, 3: 0}\n        zf.close()\n\n    def test_single_nozzle_returns_none(self):\n        \"\"\"Single physical_extruder_map entry should return None (single-nozzle).\"\"\"\n        zf = _make_3mf_zip(\n            {\n                \"filament_nozzle_map\": [\"0\", \"0\", \"0\"],\n                \"physical_extruder_map\": [\"0\"],\n            }\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        assert result is None\n        zf.close()\n\n    def test_missing_project_settings_returns_none(self):\n        \"\"\"Missing project_settings.config should return None.\"\"\"\n        zf = _make_3mf_zip(None)\n        result = extract_nozzle_mapping_from_3mf(zf)\n        assert result is None\n        zf.close()\n\n    def test_missing_fields_returns_none(self):\n        \"\"\"Missing physical_extruder_map should return None.\"\"\"\n        zf = _make_3mf_zip({\"some_other_key\": \"value\"})\n        result = extract_nozzle_mapping_from_3mf(zf)\n        assert result is None\n        zf.close()\n\n    def test_physical_extruder_map_remapping(self):\n        \"\"\"Should apply physical_extruder_map to remap slicer extruder to MQTT extruder.\"\"\"\n        # Slicer ext 0 -> MQTT ext 1, slicer ext 1 -> MQTT ext 0\n        zf = _make_3mf_zip(\n            {\n                \"filament_nozzle_map\": [\"0\", \"1\"],\n                \"physical_extruder_map\": [\"1\", \"0\"],\n            }\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        assert result == {1: 1, 2: 0}\n        zf.close()\n\n    def test_single_active_extruder_maps_all_slots(self):\n        \"\"\"When only one extruder has nozzles installed, all filaments map to it.\n\n        H2C scenario: left extruder has no nozzles (Standard#0|High Flow#0),\n        right extruder has one Standard nozzle (Standard#1). Print uses only\n        the right extruder, so all filaments should map to physical extruder 0.\n        \"\"\"\n        slice_info = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n          <plate>\n            <filament id=\"1\" type=\"PLA\" color=\"#FF0000\" used_g=\"5.0\" group_id=\"0\"/>\n            <filament id=\"2\" type=\"PLA\" color=\"#00FF00\" used_g=\"3.0\" group_id=\"0\"/>\n          </plate>\n        </config>\"\"\"\n        zf = _make_3mf_zip(\n            {\n                \"physical_extruder_map\": [\"1\", \"0\"],\n                \"extruder_nozzle_stats\": [\"Standard#0|High Flow#0\", \"Standard#1\"],\n            },\n            slice_info_xml=slice_info,\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        # Only extruder index 1 is active → physical_extruder_map[1] = 0 (RIGHT)\n        assert result == {1: 0, 2: 0}\n        zf.close()\n\n    def test_two_active_extruders_falls_through(self):\n        \"\"\"When both extruders have nozzles, the single-active shortcut is skipped.\n\n        Should fall through to the normal group_id-based mapping.\n        \"\"\"\n        slice_info = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n          <plate>\n            <filament id=\"1\" type=\"PLA\" color=\"#FF0000\" used_g=\"5.0\" group_id=\"0\"/>\n            <filament id=\"2\" type=\"PLA\" color=\"#00FF00\" used_g=\"3.0\" group_id=\"1\"/>\n          </plate>\n        </config>\"\"\"\n        zf = _make_3mf_zip(\n            {\n                \"physical_extruder_map\": [\"1\", \"0\"],\n                \"extruder_nozzle_stats\": [\"Standard#1\", \"High Flow#1\"],\n            },\n            slice_info_xml=slice_info,\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        # Both active → normal group_id mapping: group 0 → phys 1, group 1 → phys 0\n        assert result == {1: 1, 2: 0}\n        zf.close()\n\n    def test_missing_extruder_nozzle_stats_falls_through(self):\n        \"\"\"When extruder_nozzle_stats is absent, the single-active shortcut is skipped.\n\n        Should fall through to the normal group_id-based mapping.\n        \"\"\"\n        slice_info = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n          <plate>\n            <filament id=\"1\" type=\"PLA\" color=\"#FF0000\" used_g=\"5.0\" group_id=\"0\"/>\n            <filament id=\"2\" type=\"PLA\" color=\"#00FF00\" used_g=\"3.0\" group_id=\"1\"/>\n          </plate>\n        </config>\"\"\"\n        zf = _make_3mf_zip(\n            {\n                \"physical_extruder_map\": [\"1\", \"0\"],\n            },\n            slice_info_xml=slice_info,\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        # No extruder_nozzle_stats → normal group_id mapping\n        assert result == {1: 1, 2: 0}\n        zf.close()\n\n    def test_single_active_extruder_no_slice_info_returns_none(self):\n        \"\"\"Single active extruder but no slice_info should return None.\"\"\"\n        zf = _make_3mf_zip(\n            {\n                \"physical_extruder_map\": [\"1\", \"0\"],\n                \"extruder_nozzle_stats\": [\"Standard#0\", \"Standard#1\"],\n            },\n        )\n        result = extract_nozzle_mapping_from_3mf(zf)\n        assert result is None\n        zf.close()\n\n\nclass TestNozzleAwareMapping:\n    \"\"\"Test nozzle-aware filament matching in the print scheduler.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_dual_nozzle_matching(self, scheduler):\n        \"\"\"Filaments assigned to different nozzles should match to correct AMS units.\"\"\"\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\", \"nozzle_id\": 0},  # Right nozzle\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#00FF00\", \"nozzle_id\": 1},  # Left nozzle\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#00FF00\", \"global_tray_id\": 0, \"extruder_id\": 0},  # AMS0 on right\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 4, \"extruder_id\": 1},  # AMS1 on left\n        ]\n        # Without nozzle filtering, slot 1 (red, right) would match tray 4 (red, left) by color.\n        # With nozzle filtering, slot 1 (right nozzle) can only use tray 0 (right extruder),\n        # and slot 2 (left nozzle) can only use tray 4 (left extruder).\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [0, 4]\n\n    def test_nozzle_hard_filter_no_fallback(self, scheduler):\n        \"\"\"Hard filter: no fallback to wrong nozzle when target nozzle has no trays.\"\"\"\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\", \"nozzle_id\": 0},  # Right nozzle\n        ]\n        loaded = [\n            # Only a tray on the left nozzle, none on right\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 4, \"extruder_id\": 1},\n        ]\n        # No trays on extruder 0 — hard filter returns -1, no cross-nozzle fallback\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [-1]\n\n    def test_no_nozzle_id_skips_filtering(self, scheduler):\n        \"\"\"When nozzle_id is None, no nozzle filtering should be applied.\"\"\"\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"},  # No nozzle_id\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0, \"extruder_id\": 0},\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 4, \"extruder_id\": 1},\n        ]\n        # Should match first available (tray 0) regardless of extruder\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [0]\n\n    def test_extruder_id_in_loaded_filaments(self, scheduler):\n        \"\"\"_build_loaded_filaments should include extruder_id from ams_extruder_map.\"\"\"\n\n        class MockStatus:\n            raw_data = {\n                \"ams\": [\n                    {\"id\": 0, \"tray\": [{\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000\"}]},\n                    {\"id\": 1, \"tray\": [{\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"00FF00\"}]},\n                ],\n                \"ams_extruder_map\": {\"0\": 0, \"1\": 1},\n            }\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 2\n        assert result[0][\"extruder_id\"] == 0\n        assert result[1][\"extruder_id\"] == 1\n\n    def test_extruder_id_none_without_map(self, scheduler):\n        \"\"\"extruder_id should be None when ams_extruder_map is absent.\"\"\"\n\n        class MockStatus:\n            raw_data = {\n                \"ams\": [\n                    {\"id\": 0, \"tray\": [{\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000\"}]},\n                ]\n            }\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 1\n        assert result[0][\"extruder_id\"] is None\n\n    def test_external_spool_extruder_id(self, scheduler):\n        \"\"\"External spool 254 (Ext-L) should have extruder_id=1 (LEFT) when ams_extruder_map exists.\"\"\"\n\n        class MockStatus:\n            raw_data = {\n                \"vt_tray\": [{\"tray_type\": \"TPU\", \"tray_color\": \"0000FF\"}],\n                \"ams_extruder_map\": {\"0\": 0},\n            }\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 1\n        # Default vt_tray id=254 → Ext-L → LEFT nozzle (extruder 1)\n        assert result[0][\"extruder_id\"] == 1\n        assert result[0][\"is_external\"] is True\n\n    def test_external_spool_no_extruder_map(self, scheduler):\n        \"\"\"External spool extruder_id should be None without ams_extruder_map.\"\"\"\n\n        class MockStatus:\n            raw_data = {\"vt_tray\": [{\"tray_type\": \"TPU\", \"tray_color\": \"0000FF\"}]}\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        assert len(result) == 1\n        assert result[0][\"extruder_id\"] is None\n\n    def test_dual_nozzle_with_tray_info_idx(self, scheduler):\n        \"\"\"Nozzle filtering should work together with tray_info_idx matching.\"\"\"\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"GFA00\", \"nozzle_id\": 0},\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"GFA01\", \"nozzle_id\": 1},\n        ]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#000000\", \"global_tray_id\": 0, \"tray_info_idx\": \"GFA00\", \"extruder_id\": 0},\n            {\"type\": \"PLA\", \"color\": \"#000000\", \"global_tray_id\": 4, \"tray_info_idx\": \"GFA01\", \"extruder_id\": 1},\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [0, 4]\n\n\n# ============================================================================\n# MODEL-SPECIFIC TESTS: Real data from actual printers\n# ============================================================================\n\n\ndef _h2d_raw_data():\n    \"\"\"H2D real data fixture (from live API response 2026-02-18).\n\n    Configuration:\n        LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)\n        RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)\n        External: 254 (Ext-L, LEFT), 255 (Ext-R, RIGHT, empty)\n\n    ams_extruder_map: {\"0\": 1, \"1\": 0, \"2\": 1, \"128\": 0}\n    \"\"\"\n    return {\n        \"ams\": [\n            {\n                \"id\": 0,\n                \"tray\": [\n                    {\"id\": 0, \"tray_type\": \"PETG\", \"tray_color\": \"FFFFFFFF\", \"tray_info_idx\": \"GFG02\"},\n                    {\"id\": 1, \"tray_type\": \"PLA\", \"tray_color\": \"C8C8C8FF\", \"tray_info_idx\": \"GFA06\"},\n                    {\"id\": 2, \"tray_type\": \"PETG\", \"tray_color\": \"875718FF\", \"tray_info_idx\": \"GFG02\"},\n                    {\"id\": 3, \"tray_type\": \"PLA\", \"tray_color\": \"000000FF\", \"tray_info_idx\": \"GFA00\"},\n                ],\n            },\n            {\n                \"id\": 1,\n                \"tray\": [\n                    {\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FFFFFFFF\", \"tray_info_idx\": \"GFA00\"},\n                    {\"id\": 1, \"tray_type\": \"PETG\", \"tray_color\": \"000000FF\", \"tray_info_idx\": \"GFG02\"},\n                    {\"id\": 2, \"tray_type\": \"PLA\", \"tray_color\": \"5F6367FF\", \"tray_info_idx\": \"GFA06\"},\n                    {\"id\": 3, \"tray_type\": \"PLA\", \"tray_color\": \"B39B84FF\", \"tray_info_idx\": \"GFA02\"},\n                ],\n            },\n            {\n                \"id\": 128,\n                \"tray\": [{\"id\": 0}],  # AMS-HT, empty\n            },\n            {\n                \"id\": 2,\n                \"tray\": [\n                    {\"id\": 0, \"tray_type\": \"PLA-S\", \"tray_color\": \"FFFFFFFF\", \"tray_info_idx\": \"P8aa1726\"},\n                    {\"id\": 1, \"tray_type\": \"PLA\", \"tray_color\": \"56B7E6FF\", \"tray_info_idx\": \"PFUS9924\"},\n                    {\"id\": 2, \"tray_type\": \"PETG\", \"tray_color\": \"6EE53CFF\", \"tray_info_idx\": \"GFG02\"},\n                    {\"id\": 3, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000FF\", \"tray_info_idx\": \"PFUS9ac9\"},\n                ],\n            },\n        ],\n        \"vt_tray\": [\n            {\"id\": 254, \"tray_type\": \"PLA\", \"tray_color\": \"000000FF\", \"tray_info_idx\": \"P4d64437\"},\n            {\"id\": 255, \"tray_type\": \"\", \"tray_color\": \"00000000\"},  # empty\n        ],\n        \"ams_extruder_map\": {\"0\": 1, \"1\": 0, \"2\": 1, \"128\": 0},\n    }\n\n\ndef _x1c_raw_data():\n    \"\"\"X1C real data fixture (from live API response 2026-02-18).\n\n    Configuration:\n        Single nozzle (extruder 0): AMS 0 (4-slot, all empty), AMS 1 (4-slot, 3 loaded)\n        External: 254 (single, empty)\n\n    ams_extruder_map: {\"0\": 0, \"1\": 0}  ← NOT empty, all on extruder 0\n    \"\"\"\n    return {\n        \"ams\": [\n            {\n                \"id\": 0,\n                \"tray\": [\n                    {\"id\": 0},  # empty\n                    {\"id\": 1},  # empty\n                    {\"id\": 2},  # empty\n                    {\"id\": 3},  # empty\n                ],\n            },\n            {\n                \"id\": 1,\n                \"tray\": [\n                    {\"id\": 0},  # empty\n                    {\"id\": 1, \"tray_type\": \"PLA\", \"tray_color\": \"EBCFA6FF\", \"tray_info_idx\": \"PFUS22b2\"},\n                    {\"id\": 2, \"tray_type\": \"PLA\", \"tray_color\": \"FCECD6FF\", \"tray_info_idx\": \"P4d64437\"},\n                    {\"id\": 3, \"tray_type\": \"PLA\", \"tray_color\": \"0066FFFF\", \"tray_info_idx\": \"P4d64437\"},\n                ],\n            },\n        ],\n        \"vt_tray\": [\n            {\"id\": 254, \"tray_type\": \"\", \"tray_color\": \"00000000\"},  # empty\n        ],\n        \"ams_extruder_map\": {\"0\": 0, \"1\": 0},\n    }\n\n\nclass TestH2DModel:\n    \"\"\"H2D-specific tests with real printer data (dual nozzle, AMS-HT).\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_build_loaded_filaments_h2d(self, scheduler):\n        \"\"\"H2D: correct extruder_id, global_tray_id, AMS-HT handling.\"\"\"\n\n        class MockStatus:\n            raw_data = _h2d_raw_data()\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n\n        # Should have 13 loaded filaments (4 + 4 + 0 + 4 + 1 external)\n        assert len(result) == 13\n\n        # AMS 0 trays → extruder 1 (LEFT)\n        ams0 = [f for f in result if f[\"ams_id\"] == 0]\n        assert len(ams0) == 4\n        assert all(f[\"extruder_id\"] == 1 for f in ams0)\n        assert [f[\"global_tray_id\"] for f in ams0] == [0, 1, 2, 3]\n\n        # AMS 1 trays → extruder 0 (RIGHT)\n        ams1 = [f for f in result if f[\"ams_id\"] == 1]\n        assert len(ams1) == 4\n        assert all(f[\"extruder_id\"] == 0 for f in ams1)\n        assert [f[\"global_tray_id\"] for f in ams1] == [4, 5, 6, 7]\n\n        # AMS-HT 128 → empty, should not appear\n        ams_ht = [f for f in result if f[\"ams_id\"] == 128]\n        assert len(ams_ht) == 0\n\n        # AMS 2 trays → extruder 1 (LEFT)\n        ams2 = [f for f in result if f[\"ams_id\"] == 2]\n        assert len(ams2) == 4\n        assert all(f[\"extruder_id\"] == 1 for f in ams2)\n        assert [f[\"global_tray_id\"] for f in ams2] == [8, 9, 10, 11]\n\n    def test_external_spool_extruder_h2d(self, scheduler):\n        \"\"\"H2D: Ext-L (254) = LEFT (extruder 1), Ext-R (255) = RIGHT (extruder 0).\"\"\"\n\n        class MockStatus:\n            raw_data = _h2d_raw_data()\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n        ext = [f for f in result if f[\"is_external\"]]\n        assert len(ext) == 1  # Only 254 has filament\n        assert ext[0][\"global_tray_id\"] == 254\n        # Ext-L (254) should be LEFT nozzle (extruder 1)\n        assert ext[0][\"extruder_id\"] == 1\n\n    def test_match_left_nozzle_only(self, scheduler):\n        \"\"\"H2D: left-nozzle requirement only matches left-nozzle AMS.\"\"\"\n\n        class MockStatus:\n            raw_data = _h2d_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"nozzle_id\": 1},  # LEFT\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        # Black PLA on LEFT: AMS 0 T4 (global 3)\n        assert result == [3]\n\n    def test_match_right_nozzle_only(self, scheduler):\n        \"\"\"H2D: right-nozzle requirement only matches right-nozzle AMS.\"\"\"\n\n        class MockStatus:\n            raw_data = _h2d_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FFFFFF\", \"nozzle_id\": 0},  # RIGHT\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        # White PLA on RIGHT: AMS 1 T1 (global 4)\n        assert result == [4]\n\n    def test_reject_cross_nozzle(self, scheduler):\n        \"\"\"H2D: hard filter rejects cross-nozzle assignment.\"\"\"\n\n        class MockStatus:\n            raw_data = _h2d_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        # PLA-S only exists on AMS 2 T1 (LEFT), require on RIGHT\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA-S\", \"color\": \"#FFFFFF\", \"nozzle_id\": 0},\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [-1]  # No fallback to wrong nozzle\n\n    def test_dual_nozzle_multi_filament(self, scheduler):\n        \"\"\"H2D: multi-filament print maps to correct nozzles.\"\"\"\n\n        class MockStatus:\n            raw_data = _h2d_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        required = [\n            {\"slot_id\": 1, \"type\": \"PETG\", \"color\": \"#FFFFFF\", \"nozzle_id\": 1, \"tray_info_idx\": \"GFG02\"},\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#FFFFFF\", \"nozzle_id\": 0, \"tray_info_idx\": \"GFA00\"},\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        # PETG white on LEFT: AMS 0 T1 (global 0)\n        # PLA white on RIGHT: AMS 1 T1 (global 4)\n        assert result == [0, 4]\n\n    def test_external_spool_matches_on_correct_nozzle(self, scheduler):\n        \"\"\"H2D: external spool on left nozzle matches left-nozzle requirement.\"\"\"\n\n        class MockStatus:\n            raw_data = _h2d_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"nozzle_id\": 1, \"tray_info_idx\": \"P4d64437\"},\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [254]  # External spool on left nozzle\n\n\nclass TestX1CModel:\n    \"\"\"X1C-specific tests with real printer data (single nozzle, 2x regular AMS).\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_build_loaded_filaments_x1c(self, scheduler):\n        \"\"\"X1C: all filaments on extruder 0, correct global_tray_id.\"\"\"\n\n        class MockStatus:\n            raw_data = _x1c_raw_data()\n\n        result = scheduler._build_loaded_filaments(MockStatus())\n\n        # Only 3 loaded (AMS 1 trays 1-3)\n        assert len(result) == 3\n        # All on extruder 0\n        assert all(f[\"extruder_id\"] == 0 for f in result)\n        # Correct global tray IDs\n        assert [f[\"global_tray_id\"] for f in result] == [5, 6, 7]\n\n    def test_single_nozzle_no_filtering(self, scheduler):\n        \"\"\"X1C: single-nozzle 3MF has no nozzle_id, all trays available.\"\"\"\n\n        class MockStatus:\n            raw_data = _x1c_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#0066FF\"},  # No nozzle_id\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        # Blue PLA → AMS 1 T4 (global 7)\n        assert result == [7]\n\n    def test_tray_info_idx_matching_x1c(self, scheduler):\n        \"\"\"X1C: tray_info_idx matching works across AMS units.\"\"\"\n\n        class MockStatus:\n            raw_data = _x1c_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#EBCFA6\", \"tray_info_idx\": \"PFUS22b2\"},\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        # Unique tray_info_idx → AMS 1 T2 (global 5)\n        assert result == [5]\n\n    def test_non_unique_tray_info_idx_color_match_x1c(self, scheduler):\n        \"\"\"X1C: non-unique tray_info_idx falls back to color matching.\"\"\"\n\n        class MockStatus:\n            raw_data = _x1c_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        # P4d64437 appears in AMS 1 T3 and T4\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FCECD6\", \"tray_info_idx\": \"P4d64437\"},\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        # Should pick AMS 1 T3 (global 6, color FCECD6) over T4 (0066FF)\n        assert result == [6]\n\n    def test_multi_filament_x1c(self, scheduler):\n        \"\"\"X1C: multi-filament print matches freely across AMS units.\"\"\"\n\n        class MockStatus:\n            raw_data = _x1c_raw_data()\n\n        loaded = scheduler._build_loaded_filaments(MockStatus())\n        required = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#EBCFA6\"},\n            {\"slot_id\": 2, \"type\": \"PLA\", \"color\": \"#0066FF\"},\n        ]\n        result = scheduler._match_filaments_to_slots(required, loaded)\n        assert result == [5, 7]\n"
  },
  {
    "path": "backend/tests/unit/test_scheduler_auto_drying.py",
    "content": "\"\"\"Tests for the auto-drying feature in the print scheduler.\n\nCovers:\n- Conservative drying parameter selection (mixed filaments)\n- Drying preset loading (user-configured vs defaults)\n- Auto-drying lifecycle: start, humidity stop, minimum drying time\n- Auto-drying stop conditions: feature disabled, no scheduled items, per-printer\n- Sync drying state after restart\n\"\"\"\n\nimport time\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.print_scheduler import PrintScheduler\n\n\nclass TestConservativeDryingParams:\n    \"\"\"Test _get_conservative_drying_params — picks safest temp/duration for mixed filaments.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def test_single_filament_pla(self, scheduler):\n        \"\"\"Single PLA tray uses PLA preset.\"\"\"\n        trays = [{\"tray_type\": \"PLA\"}]\n        presets = PrintScheduler.DEFAULT_DRYING_PRESETS\n        result = scheduler._get_conservative_drying_params(trays, \"n3f\", presets)\n        assert result == (45, 12, \"PLA\")\n\n    def test_mixed_filaments_lowest_temp(self, scheduler):\n        \"\"\"Mixed PLA + ABS: should use PLA's 45°C (lowest), ABS's 12h (longest for n3f).\"\"\"\n        trays = [{\"tray_type\": \"PLA\"}, {\"tray_type\": \"ABS\"}]\n        presets = PrintScheduler.DEFAULT_DRYING_PRESETS\n        result = scheduler._get_conservative_drying_params(trays, \"n3f\", presets)\n        temp, hours, _ = result\n        assert temp == 45  # PLA is lowest\n        assert hours == 12\n\n    def test_mixed_filaments_longest_duration(self, scheduler):\n        \"\"\"Mixed ABS (8h) + PVA (18h) on n3s: should use longest duration.\"\"\"\n        trays = [{\"tray_type\": \"ABS\"}, {\"tray_type\": \"PVA\"}]\n        presets = PrintScheduler.DEFAULT_DRYING_PRESETS\n        result = scheduler._get_conservative_drying_params(trays, \"n3s\", presets)\n        temp, hours, _ = result\n        assert temp == 80  # ABS n3s=80, PVA n3s=85 → lowest=80\n        assert hours == 18  # ABS n3s_hours=8, PVA n3s_hours=18 → longest=18\n\n    def test_empty_trays_returns_none(self, scheduler):\n        \"\"\"No loaded trays returns None.\"\"\"\n        result = scheduler._get_conservative_drying_params([], \"n3f\", PrintScheduler.DEFAULT_DRYING_PRESETS)\n        assert result is None\n\n    def test_unknown_filament_skipped(self, scheduler):\n        \"\"\"Unknown filament types are ignored.\"\"\"\n        trays = [{\"tray_type\": \"EXOTIC_WOOD\"}]\n        result = scheduler._get_conservative_drying_params(trays, \"n3f\", PrintScheduler.DEFAULT_DRYING_PRESETS)\n        assert result is None\n\n    def test_filament_type_normalization(self, scheduler):\n        \"\"\"'PLA Basic' should normalize to 'PLA'.\"\"\"\n        trays = [{\"tray_type\": \"PLA Basic\"}]\n        presets = PrintScheduler.DEFAULT_DRYING_PRESETS\n        result = scheduler._get_conservative_drying_params(trays, \"n3f\", presets)\n        assert result is not None\n        assert result[0] == 45  # PLA temp\n\n    def test_empty_tray_type_skipped(self, scheduler):\n        \"\"\"Trays with empty tray_type are skipped.\"\"\"\n        trays = [{\"tray_type\": \"\"}, {\"tray_type\": \"PETG\"}]\n        presets = PrintScheduler.DEFAULT_DRYING_PRESETS\n        result = scheduler._get_conservative_drying_params(trays, \"n3f\", presets)\n        assert result is not None\n        assert result[2] == \"PETG\"\n\n    def test_n3s_uses_n3s_keys(self, scheduler):\n        \"\"\"AMS-HT (n3s) should use n3s temp and n3s_hours.\"\"\"\n        trays = [{\"tray_type\": \"TPU\"}]\n        presets = PrintScheduler.DEFAULT_DRYING_PRESETS\n        result = scheduler._get_conservative_drying_params(trays, \"n3s\", presets)\n        assert result == (75, 18, \"TPU\")  # n3s=75, n3s_hours=18\n\n    def test_n3f_uses_n3f_keys(self, scheduler):\n        \"\"\"AMS 2 Pro (n3f) should use n3f temp and n3f_hours.\"\"\"\n        trays = [{\"tray_type\": \"TPU\"}]\n        presets = PrintScheduler.DEFAULT_DRYING_PRESETS\n        result = scheduler._get_conservative_drying_params(trays, \"n3f\", presets)\n        assert result == (65, 12, \"TPU\")  # n3f=65, n3f_hours=12\n\n    def test_custom_presets(self, scheduler):\n        \"\"\"Custom presets override defaults.\"\"\"\n        trays = [{\"tray_type\": \"PLA\"}]\n        custom = {\"PLA\": {\"n3f\": 50, \"n3s\": 50, \"n3f_hours\": 6, \"n3s_hours\": 6}}\n        result = scheduler._get_conservative_drying_params(trays, \"n3f\", custom)\n        assert result == (50, 6, \"PLA\")\n\n\nclass TestDryingPresets:\n    \"\"\"Test _get_drying_presets — loads user presets from DB or falls back to defaults.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @pytest.mark.asyncio\n    async def test_default_presets_when_no_setting(self, scheduler):\n        \"\"\"Returns built-in defaults when no DB setting exists.\"\"\"\n        db = AsyncMock()\n        result_mock = MagicMock()\n        result_mock.scalar_one_or_none.return_value = None\n        db.execute = AsyncMock(return_value=result_mock)\n\n        presets = await scheduler._get_drying_presets(db)\n        assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS\n\n    @pytest.mark.asyncio\n    async def test_user_presets_from_db(self, scheduler):\n        \"\"\"Returns user-configured presets when saved in DB.\"\"\"\n        db = AsyncMock()\n        setting = MagicMock()\n        setting.value = '{\"PLA\": {\"n3f\": 50, \"n3s\": 50, \"n3f_hours\": 6, \"n3s_hours\": 6}}'\n        result_mock = MagicMock()\n        result_mock.scalar_one_or_none.return_value = setting\n        db.execute = AsyncMock(return_value=result_mock)\n\n        presets = await scheduler._get_drying_presets(db)\n        assert presets[\"PLA\"][\"n3f\"] == 50\n\n    @pytest.mark.asyncio\n    async def test_invalid_json_falls_back(self, scheduler):\n        \"\"\"Invalid JSON in DB falls back to defaults.\"\"\"\n        db = AsyncMock()\n        setting = MagicMock()\n        setting.value = \"not valid json{{\"\n        result_mock = MagicMock()\n        result_mock.scalar_one_or_none.return_value = setting\n        db.execute = AsyncMock(return_value=result_mock)\n\n        presets = await scheduler._get_drying_presets(db)\n        assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS\n\n    @pytest.mark.asyncio\n    async def test_empty_string_falls_back(self, scheduler):\n        \"\"\"Empty string in DB falls back to defaults.\"\"\"\n        db = AsyncMock()\n        setting = MagicMock()\n        setting.value = \"\"\n        result_mock = MagicMock()\n        result_mock.scalar_one_or_none.return_value = setting\n        db.execute = AsyncMock(return_value=result_mock)\n\n        presets = await scheduler._get_drying_presets(db)\n        assert presets == PrintScheduler.DEFAULT_DRYING_PRESETS\n\n\nclass TestSyncDryingState:\n    \"\"\"Test _sync_drying_state — syncs in-memory state with actual printer status.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_removes_stopped_printers(self, mock_pm, scheduler):\n        \"\"\"Printers that stopped drying are removed from tracking.\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic()}\n        state = MagicMock()\n        state.raw_data = {\"ams\": [{\"dry_time\": 0}]}\n        mock_pm.get_status.return_value = state\n\n        scheduler._sync_drying_state()\n        assert 1 not in scheduler._drying_in_progress\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_keeps_active_printers(self, mock_pm, scheduler):\n        \"\"\"Printers still drying remain in tracking.\"\"\"\n        ts = time.monotonic()\n        scheduler._drying_in_progress = {1: ts}\n        state = MagicMock()\n        state.raw_data = {\"ams\": [{\"dry_time\": 120}]}\n        mock_pm.get_status.return_value = state\n\n        scheduler._sync_drying_state()\n        assert scheduler._drying_in_progress[1] == ts\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_removes_disconnected_printers(self, mock_pm, scheduler):\n        \"\"\"Disconnected printers are removed from tracking.\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic()}\n        mock_pm.get_status.return_value = None\n\n        scheduler._sync_drying_state()\n        assert 1 not in scheduler._drying_in_progress\n\n\nclass TestStopDrying:\n    \"\"\"Test _stop_drying — sends stop commands and clears tracking.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    async def test_stops_all_ams_units(self, mock_pm, scheduler):\n        \"\"\"Sends stop command to each AMS unit that is drying.\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic()}\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\"id\": 0, \"dry_time\": 120},\n                {\"id\": 1, \"dry_time\": 0},\n                {\"id\": 128, \"dry_time\": 60},\n            ]\n        }\n        mock_pm.get_status.return_value = state\n\n        await scheduler._stop_drying(1)\n\n        # Should send stop to AMS 0 and 128, not AMS 1\n        calls = mock_pm.send_drying_command.call_args_list\n        assert len(calls) == 2\n        assert calls[0].args == (1, 0, 0, 0)\n        assert calls[1].args == (1, 128, 0, 0)\n        assert 1 not in scheduler._drying_in_progress\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    async def test_clears_tracking_when_no_state(self, mock_pm, scheduler):\n        \"\"\"Clears tracking when printer has no state (disconnected).\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic()}\n        mock_pm.get_status.return_value = None\n\n        await scheduler._stop_drying(1)\n        assert 1 not in scheduler._drying_in_progress\n\n\nclass TestMinimumDryingTime:\n    \"\"\"Regression: drying should not stop/restart rapidly when humidity oscillates near threshold.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        s = PrintScheduler()\n        s._min_drying_seconds = 1800  # 30 minutes\n        return s\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    @patch(\"backend.app.services.print_scheduler.supports_drying\", return_value=True)\n    async def test_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):\n        \"\"\"Drying should NOT stop when humidity drops below threshold before 30 min.\"\"\"\n        # Simulate: drying started 5 minutes ago\n        scheduler._drying_in_progress = {1: time.monotonic() - 300}\n\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"module_type\": \"n3f\",\n                    \"dry_time\": 600,\n                    \"humidity_raw\": \"18\",\n                    \"dry_sf_reason\": [],\n                    \"tray\": [{\"tray_type\": \"PLA\"}],\n                }\n            ]\n        }\n        state.firmware_version = \"01.09.00.00\"\n        mock_pm.get_status.return_value = state\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_model.return_value = \"X1C\"\n\n        # Mock _is_printer_idle and DB\n        scheduler._is_printer_idle = MagicMock(return_value=True)\n        db = AsyncMock()\n\n        # Mock settings: enabled, threshold=21\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"true\"),\n            \"ams_humidity_fair\": self._make_setting(\"21\"),\n            \"queue_drying_block\": self._make_setting(\"false\"),\n            \"drying_presets\": None,\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))\n\n        # Queue item with schedule\n        item = MagicMock()\n        item.printer_id = 1\n        item.scheduled_time = MagicMock()  # Has a schedule\n        item.manual_start = False\n\n        await scheduler._check_auto_drying(db, [item], set())\n\n        # Should NOT have sent stop command via humidity check — minimum time not elapsed\n        # The only calls should NOT include the humidity-based stop\n        for call in mock_pm.send_drying_command.call_args_list:\n            # If any stop was called, it should NOT be from the humidity path\n            # (humidity path uses keyword args: temp=0, duration=0, mode=0)\n            assert call != ((1, 0), {\"temp\": 0, \"duration\": 0, \"mode\": 0}), (\n                \"Humidity-based stop should not fire before minimum drying time\"\n            )\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    @patch(\"backend.app.services.print_scheduler.supports_drying\", return_value=True)\n    async def test_stops_after_minimum_time(self, mock_sd, mock_pm, scheduler):\n        \"\"\"Drying SHOULD stop when humidity below threshold AND 30 min elapsed.\"\"\"\n        # Simulate: drying started 35 minutes ago\n        scheduler._drying_in_progress = {1: time.monotonic() - 2100}\n\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"module_type\": \"n3f\",\n                    \"dry_time\": 600,\n                    \"humidity_raw\": \"18\",\n                    \"dry_sf_reason\": [],\n                    \"tray\": [{\"tray_type\": \"PLA\"}],\n                }\n            ]\n        }\n        state.firmware_version = \"01.09.00.00\"\n        mock_pm.get_status.return_value = state\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_model.return_value = \"X1C\"\n\n        scheduler._is_printer_idle = MagicMock(return_value=True)\n        db = AsyncMock()\n\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"true\"),\n            \"ams_humidity_fair\": self._make_setting(\"21\"),\n            \"queue_drying_block\": self._make_setting(\"false\"),\n            \"drying_presets\": None,\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns, printer_id=1))\n\n        item = MagicMock()\n        item.printer_id = 1\n        item.scheduled_time = MagicMock()\n        item.manual_start = False\n\n        await scheduler._check_auto_drying(db, [item], set())\n\n        # Should have sent stop command (humidity-based stop after minimum time)\n        mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)\n\n    @staticmethod\n    def _make_setting(value):\n        s = MagicMock()\n        s.value = value\n        return s\n\n    @staticmethod\n    def _make_db_side_effect(settings_map, printer_id=1):\n        \"\"\"Create a side_effect for db.execute that returns settings and printers.\"\"\"\n\n        async def side_effect(stmt):\n            result = MagicMock()\n            stmt_str = str(stmt)\n\n            # Extract bind parameter values (SQLAlchemy uses :key_1 placeholders)\n            try:\n                compiled = stmt.compile(compile_kwargs={\"literal_binds\": False})\n                param_values = list(compiled.params.values())\n            except Exception:\n                param_values = []\n\n            # Match settings queries by checking bind parameter values\n            matched = False\n            for key, val in settings_map.items():\n                if key in param_values:\n                    result.scalar_one_or_none.return_value = val\n                    matched = True\n                    break\n\n            if not matched:\n                if \"printer\" in stmt_str.lower() or \"is_active\" in stmt_str:\n                    printer = MagicMock()\n                    printer.id = printer_id\n                    printer.is_active = True\n                    scalars_mock = MagicMock()\n                    scalars_mock.__iter__ = MagicMock(return_value=iter([printer]))\n                    result.scalars.return_value = scalars_mock\n                else:\n                    result.scalar_one_or_none.return_value = None\n            return result\n\n        return side_effect\n\n\nclass TestAutoStopOnFeatureDisabled:\n    \"\"\"Regression: disabling auto-drying in settings should stop active drying sessions.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    async def test_stops_drying_when_disabled(self, mock_pm, scheduler):\n        \"\"\"Disabling auto-drying should send stop commands to all drying printers.\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic(), 2: time.monotonic()}\n\n        # Printer 1: drying, Printer 2: drying\n        def get_status(pid):\n            state = MagicMock()\n            state.raw_data = {\"ams\": [{\"id\": 0, \"dry_time\": 120}]}\n            return state\n\n        mock_pm.get_status.side_effect = get_status\n\n        db = AsyncMock()\n        # queue_drying_enabled = false\n        setting = MagicMock()\n        setting.value = \"false\"\n        result_mock = MagicMock()\n        result_mock.scalar_one_or_none.return_value = setting\n        db.execute = AsyncMock(return_value=result_mock)\n\n        await scheduler._check_auto_drying(db, [], set())\n\n        # Should have sent stop commands\n        assert mock_pm.send_drying_command.call_count == 2\n        assert not scheduler._drying_in_progress\n\n\nclass TestAutoStopOnNoScheduledItems:\n    \"\"\"Regression: removing scheduled items should stop auto-drying.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @staticmethod\n    def _make_setting(value):\n        s = MagicMock()\n        s.value = value\n        return s\n\n    @staticmethod\n    def _make_db_side_effect(settings_map):\n        \"\"\"Create a side_effect for db.execute that returns settings by key.\"\"\"\n\n        async def side_effect(stmt):\n            result = MagicMock()\n            try:\n                compiled = stmt.compile(compile_kwargs={\"literal_binds\": False})\n                param_values = list(compiled.params.values())\n            except Exception:\n                param_values = []\n\n            for key, val in settings_map.items():\n                if key in param_values:\n                    result.scalar_one_or_none.return_value = val\n                    return result\n\n            result.scalar_one_or_none.return_value = None\n            return result\n\n        return side_effect\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):\n        \"\"\"Auto-drying stops when queue has no scheduled items (queue mode only).\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic()}\n\n        state = MagicMock()\n        state.raw_data = {\"ams\": [{\"id\": 0, \"dry_time\": 120}]}\n        mock_pm.get_status.return_value = state\n\n        db = AsyncMock()\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"true\"),\n            \"ambient_drying_enabled\": self._make_setting(\"false\"),\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        # Manual-start items only (no scheduled_time)\n        item = MagicMock()\n        item.printer_id = 1\n        item.scheduled_time = None\n        item.manual_start = True\n\n        await scheduler._check_auto_drying(db, [item], set())\n\n        # Should have stopped drying\n        assert mock_pm.send_drying_command.called\n        assert not scheduler._drying_in_progress\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    async def test_stops_when_empty_queue(self, mock_pm, scheduler):\n        \"\"\"Auto-drying stops when queue is completely empty (queue mode only).\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic()}\n\n        state = MagicMock()\n        state.raw_data = {\"ams\": [{\"id\": 0, \"dry_time\": 120}]}\n        mock_pm.get_status.return_value = state\n\n        db = AsyncMock()\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"true\"),\n            \"ambient_drying_enabled\": self._make_setting(\"false\"),\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        await scheduler._check_auto_drying(db, [], set())\n\n        assert mock_pm.send_drying_command.called\n        assert not scheduler._drying_in_progress\n\n\nclass TestDryingTrackingTimestamps:\n    \"\"\"Test that _drying_in_progress uses timestamps, not booleans.\"\"\"\n\n    def test_initial_state_empty(self):\n        \"\"\"Fresh scheduler has no drying tracked.\"\"\"\n        scheduler = PrintScheduler()\n        assert scheduler._drying_in_progress == {}\n\n    def test_timestamp_is_monotonic(self):\n        \"\"\"Tracked values should be monotonic timestamps.\"\"\"\n        scheduler = PrintScheduler()\n        before = time.monotonic()\n        scheduler._drying_in_progress[1] = time.monotonic()\n        after = time.monotonic()\n        assert before <= scheduler._drying_in_progress[1] <= after\n\n    def test_timestamp_is_truthy(self):\n        \"\"\"Timestamps are truthy for .get() checks (backward compat with bool pattern).\"\"\"\n        scheduler = PrintScheduler()\n        scheduler._drying_in_progress[1] = time.monotonic()\n        assert scheduler._drying_in_progress.get(1)\n        assert not scheduler._drying_in_progress.get(999)\n\n\nclass _DryingTestBase:\n    \"\"\"Shared helpers for auto-drying integration tests.\"\"\"\n\n    @staticmethod\n    def _make_setting(value):\n        s = MagicMock()\n        s.value = value\n        return s\n\n    @staticmethod\n    def _make_db_side_effect(settings_map, printer_ids=None):\n        \"\"\"Create a side_effect for db.execute that returns settings by key and printers.\"\"\"\n        if printer_ids is None:\n            printer_ids = [1]\n\n        async def side_effect(stmt):\n            result = MagicMock()\n            stmt_str = str(stmt)\n\n            try:\n                compiled = stmt.compile(compile_kwargs={\"literal_binds\": False})\n                param_values = list(compiled.params.values())\n            except Exception:\n                param_values = []\n\n            for key, val in settings_map.items():\n                if key in param_values:\n                    result.scalar_one_or_none.return_value = val\n                    return result\n\n            if \"printer\" in stmt_str.lower() or \"is_active\" in stmt_str:\n                printers = []\n                for pid in printer_ids:\n                    p = MagicMock()\n                    p.id = pid\n                    p.is_active = True\n                    printers.append(p)\n                scalars_mock = MagicMock()\n                scalars_mock.__iter__ = MagicMock(return_value=iter(printers))\n                result.scalars.return_value = scalars_mock\n            else:\n                result.scalar_one_or_none.return_value = None\n            return result\n\n        return side_effect\n\n\nclass TestAmbientDrying(_DryingTestBase):\n    \"\"\"Tests for ambient drying mode — drying based on humidity regardless of queue state.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    @patch(\"backend.app.services.print_scheduler.supports_drying\", return_value=True)\n    async def test_ambient_dries_idle_printer_without_queue(self, mock_sd, mock_pm, scheduler):\n        \"\"\"Ambient mode starts drying on idle printers even with no queue items.\"\"\"\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"module_type\": \"n3f\",\n                    \"dry_time\": 0,\n                    \"humidity_raw\": \"75\",\n                    \"dry_sf_reason\": [],\n                    \"tray\": [{\"tray_type\": \"PLA\"}],\n                }\n            ]\n        }\n        state.firmware_version = \"01.09.00.00\"\n        mock_pm.get_status.return_value = state\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_model.return_value = \"X1C\"\n        mock_pm.send_drying_command.return_value = True\n\n        scheduler._is_printer_idle = MagicMock(return_value=True)\n        db = AsyncMock()\n\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"false\"),\n            \"ambient_drying_enabled\": self._make_setting(\"true\"),\n            \"ams_humidity_fair\": self._make_setting(\"60\"),\n            \"queue_drying_block\": self._make_setting(\"false\"),\n            \"drying_presets\": None,\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        # Empty queue — ambient mode should still dry\n        await scheduler._check_auto_drying(db, [], set())\n\n        mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament=\"PLA\")\n        assert 1 in scheduler._drying_in_progress\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    @patch(\"backend.app.services.print_scheduler.supports_drying\", return_value=True)\n    async def test_ambient_does_not_dry_below_threshold(self, mock_sd, mock_pm, scheduler):\n        \"\"\"Ambient mode does NOT dry when humidity is below threshold.\"\"\"\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"module_type\": \"n3f\",\n                    \"dry_time\": 0,\n                    \"humidity_raw\": \"40\",\n                    \"dry_sf_reason\": [],\n                    \"tray\": [{\"tray_type\": \"PLA\"}],\n                }\n            ]\n        }\n        state.firmware_version = \"01.09.00.00\"\n        mock_pm.get_status.return_value = state\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_model.return_value = \"X1C\"\n\n        scheduler._is_printer_idle = MagicMock(return_value=True)\n        db = AsyncMock()\n\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"false\"),\n            \"ambient_drying_enabled\": self._make_setting(\"true\"),\n            \"ams_humidity_fair\": self._make_setting(\"60\"),\n            \"queue_drying_block\": self._make_setting(\"false\"),\n            \"drying_presets\": None,\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        await scheduler._check_auto_drying(db, [], set())\n\n        mock_pm.send_drying_command.assert_not_called()\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    async def test_ambient_off_stops_drying_without_queue(self, mock_pm, scheduler):\n        \"\"\"Disabling ambient drying stops drying on printers without queue items.\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic()}\n\n        state = MagicMock()\n        state.raw_data = {\"ams\": [{\"id\": 0, \"dry_time\": 120}]}\n        mock_pm.get_status.return_value = state\n\n        db = AsyncMock()\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"false\"),\n            \"ambient_drying_enabled\": self._make_setting(\"false\"),\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        await scheduler._check_auto_drying(db, [], set())\n\n        assert mock_pm.send_drying_command.called\n        assert not scheduler._drying_in_progress\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    @patch(\"backend.app.services.print_scheduler.supports_drying\", return_value=True)\n    async def test_ambient_continues_when_queue_empty(self, mock_sd, mock_pm, scheduler):\n        \"\"\"Ambient drying continues even when queue has no scheduled items (unlike queue mode).\"\"\"\n        scheduler._drying_in_progress = {1: time.monotonic() - 100}\n\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"module_type\": \"n3f\",\n                    \"dry_time\": 600,\n                    \"humidity_raw\": \"75\",\n                    \"dry_sf_reason\": [],\n                    \"tray\": [{\"tray_type\": \"PLA\"}],\n                }\n            ]\n        }\n        state.firmware_version = \"01.09.00.00\"\n        mock_pm.get_status.return_value = state\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_model.return_value = \"X1C\"\n\n        scheduler._is_printer_idle = MagicMock(return_value=True)\n        db = AsyncMock()\n\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"false\"),\n            \"ambient_drying_enabled\": self._make_setting(\"true\"),\n            \"ams_humidity_fair\": self._make_setting(\"60\"),\n            \"queue_drying_block\": self._make_setting(\"false\"),\n            \"drying_presets\": None,\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        await scheduler._check_auto_drying(db, [], set())\n\n        # Should NOT have sent stop — humidity still high, drying continues\n        for call in mock_pm.send_drying_command.call_args_list:\n            assert call.kwargs.get(\"mode\") != 0, \"Should not stop drying in ambient mode with high humidity\"\n        assert 1 in scheduler._drying_in_progress\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    @patch(\"backend.app.services.print_scheduler.supports_drying\", return_value=True)\n    async def test_queue_only_does_not_dry_without_scheduled_items(self, mock_sd, mock_pm, scheduler):\n        \"\"\"Queue mode alone does NOT dry printers that have no scheduled queue items.\"\"\"\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"module_type\": \"n3f\",\n                    \"dry_time\": 0,\n                    \"humidity_raw\": \"75\",\n                    \"dry_sf_reason\": [],\n                    \"tray\": [{\"tray_type\": \"PLA\"}],\n                }\n            ]\n        }\n        state.firmware_version = \"01.09.00.00\"\n        mock_pm.get_status.return_value = state\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_model.return_value = \"X1C\"\n\n        scheduler._is_printer_idle = MagicMock(return_value=True)\n        db = AsyncMock()\n\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"true\"),\n            \"ambient_drying_enabled\": self._make_setting(\"false\"),\n            \"ams_humidity_fair\": self._make_setting(\"60\"),\n            \"queue_drying_block\": self._make_setting(\"false\"),\n            \"drying_presets\": None,\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        # No queue items at all\n        await scheduler._check_auto_drying(db, [], set())\n\n        mock_pm.send_drying_command.assert_not_called()\n\n\nclass TestBlockForDryingBugFix(_DryingTestBase):\n    \"\"\"Regression: block mode should not skip humidity auto-stop for already-drying printers.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        s = PrintScheduler()\n        s._min_drying_seconds = 1800\n        return s\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    @patch(\"backend.app.services.print_scheduler.supports_drying\", return_value=True)\n    async def test_block_mode_allows_humidity_stop_for_active_drying(self, mock_sd, mock_pm, scheduler):\n        \"\"\"Bug fix: printer already drying in block mode should still check humidity to auto-stop.\"\"\"\n        # Drying started 35 minutes ago\n        scheduler._drying_in_progress = {1: time.monotonic() - 2100}\n\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"module_type\": \"n3f\",\n                    \"dry_time\": 600,\n                    \"humidity_raw\": \"30\",  # Below threshold\n                    \"dry_sf_reason\": [],\n                    \"tray\": [{\"tray_type\": \"PLA\"}],\n                }\n            ]\n        }\n        state.firmware_version = \"01.09.00.00\"\n        mock_pm.get_status.return_value = state\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_model.return_value = \"X1C\"\n\n        scheduler._is_printer_idle = MagicMock(return_value=True)\n        db = AsyncMock()\n\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"true\"),\n            \"ambient_drying_enabled\": self._make_setting(\"false\"),\n            \"ams_humidity_fair\": self._make_setting(\"60\"),\n            \"queue_drying_block\": self._make_setting(\"true\"),\n            \"drying_presets\": None,\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        # Queue item exists for this printer (triggers block mode gate)\n        item = MagicMock()\n        item.printer_id = 1\n        item.scheduled_time = MagicMock()\n        item.manual_start = False\n\n        await scheduler._check_auto_drying(db, [item], set())\n\n        # Should have sent stop command — humidity dropped below threshold after 30+ min\n        mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    @patch(\"backend.app.services.print_scheduler.supports_drying\", return_value=True)\n    async def test_block_mode_prevents_new_drying_start(self, mock_sd, mock_pm, scheduler):\n        \"\"\"Block mode should still prevent starting NEW drying on printers with pending items.\"\"\"\n        state = MagicMock()\n        state.raw_data = {\n            \"ams\": [\n                {\n                    \"id\": 0,\n                    \"module_type\": \"n3f\",\n                    \"dry_time\": 0,\n                    \"humidity_raw\": \"75\",\n                    \"dry_sf_reason\": [],\n                    \"tray\": [{\"tray_type\": \"PLA\"}],\n                }\n            ]\n        }\n        state.firmware_version = \"01.09.00.00\"\n        mock_pm.get_status.return_value = state\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_model.return_value = \"X1C\"\n\n        scheduler._is_printer_idle = MagicMock(return_value=True)\n        db = AsyncMock()\n\n        settings_returns = {\n            \"queue_drying_enabled\": self._make_setting(\"true\"),\n            \"ambient_drying_enabled\": self._make_setting(\"false\"),\n            \"ams_humidity_fair\": self._make_setting(\"60\"),\n            \"queue_drying_block\": self._make_setting(\"true\"),\n            \"drying_presets\": None,\n        }\n        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))\n\n        item = MagicMock()\n        item.printer_id = 1\n        item.scheduled_time = MagicMock()\n        item.manual_start = False\n\n        await scheduler._check_auto_drying(db, [item], set())\n\n        # Should NOT start drying — block mode with pending items\n        mock_pm.send_drying_command.assert_not_called()\n"
  },
  {
    "path": "backend/tests/unit/test_scheduler_busy_only.py",
    "content": "\"\"\"Tests for _is_busy_only() in the print scheduler.\"\"\"\n\nfrom backend.app.services.print_scheduler import PrintScheduler\n\n\nclass TestIsBusyOnly:\n    \"\"\"Test the _is_busy_only static method.\"\"\"\n\n    def test_single_busy(self):\n        assert PrintScheduler._is_busy_only(\"Busy: Printer1\") is True\n\n    def test_multiple_busy(self):\n        assert PrintScheduler._is_busy_only(\"Busy: Printer1, Printer2\") is True\n\n    def test_busy_and_offline(self):\n        assert PrintScheduler._is_busy_only(\"Busy: Printer1 | Offline: Printer2\") is False\n\n    def test_busy_and_filament(self):\n        assert PrintScheduler._is_busy_only(\"Busy: Printer1 | Waiting for filament: Printer2 (needs PLA)\") is False\n\n    def test_offline_only(self):\n        assert PrintScheduler._is_busy_only(\"Offline: Printer1\") is False\n\n    def test_filament_only(self):\n        assert PrintScheduler._is_busy_only(\"Waiting for filament: Printer1 (needs PLA)\") is False\n\n    def test_no_matching_color(self):\n        assert PrintScheduler._is_busy_only(\"No matching material/color. Waiting on PLA (Blue)\") is False\n\n    def test_no_available_printers(self):\n        assert PrintScheduler._is_busy_only(\"No available P1S printers configured\") is False\n"
  },
  {
    "path": "backend/tests/unit/test_scheduler_clear_plate.py",
    "content": "\"\"\"Tests for the clear plate queue flow in the print scheduler.\"\"\"\n\nimport logging\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.print_scheduler import PrintScheduler\nfrom backend.app.services.printer_manager import PrinterManager\n\n\nclass TestPrinterManagerPlateCleared:\n    \"\"\"Test the plate-cleared flag management in PrinterManager.\"\"\"\n\n    @pytest.fixture\n    def manager(self):\n        return PrinterManager()\n\n    def test_plate_cleared_initially_false(self, manager):\n        \"\"\"No printers should have plate cleared by default.\"\"\"\n        assert not manager.is_awaiting_plate_clear(1)\n        assert not manager.is_awaiting_plate_clear(999)\n\n    def test_set_plate_cleared(self, manager):\n        \"\"\"Setting plate cleared should make is_awaiting_plate_clear return True.\"\"\"\n        manager.set_awaiting_plate_clear(1, True)\n        assert manager.is_awaiting_plate_clear(1)\n        assert not manager.is_awaiting_plate_clear(2)\n\n    def test_consume_plate_cleared(self, manager):\n        \"\"\"Consuming plate cleared should reset the flag.\"\"\"\n        manager.set_awaiting_plate_clear(1, True)\n        assert manager.is_awaiting_plate_clear(1)\n        manager.set_awaiting_plate_clear(1, False)\n        assert not manager.is_awaiting_plate_clear(1)\n\n    def test_consume_plate_cleared_idempotent(self, manager):\n        \"\"\"Consuming when not set should not raise.\"\"\"\n        manager.set_awaiting_plate_clear(1, False)  # Should not raise\n        assert not manager.is_awaiting_plate_clear(1)\n\n    def test_set_plate_cleared_multiple_printers(self, manager):\n        \"\"\"Plate cleared should be tracked per printer.\"\"\"\n        manager.set_awaiting_plate_clear(1, True)\n        manager.set_awaiting_plate_clear(3, True)\n        assert manager.is_awaiting_plate_clear(1)\n        assert not manager.is_awaiting_plate_clear(2)\n        assert manager.is_awaiting_plate_clear(3)\n\n    def test_consume_only_affects_target_printer(self, manager):\n        \"\"\"Consuming plate cleared for one printer should not affect others.\"\"\"\n        manager.set_awaiting_plate_clear(1, True)\n        manager.set_awaiting_plate_clear(2, True)\n        manager.set_awaiting_plate_clear(1, False)\n        assert not manager.is_awaiting_plate_clear(1)\n        assert manager.is_awaiting_plate_clear(2)\n\n\nclass TestAwaitingPlateClearPersistence:\n    \"\"\"Verify the awaiting-plate-clear flag round-trips through the DB (#961).\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_load_rehydrates_in_memory_set_from_db(self):\n        \"\"\"Printers flagged in DB must re-appear in the in-memory set on startup.\"\"\"\n        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n\n        # Ensure all models are imported so Base.metadata includes them\n        import backend.app.models  # noqa: F401\n        from backend.app.core.database import Base\n        from backend.app.models.printer import Printer\n\n        engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n        async with engine.begin() as conn:\n            await conn.run_sync(Base.metadata.create_all)\n        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n        # Seed: two printers, one flagged awaiting, one not\n        async with session_maker() as db:\n            db.add_all(\n                [\n                    Printer(\n                        id=1,\n                        name=\"P1\",\n                        serial_number=\"S1\",\n                        ip_address=\"1.1.1.1\",\n                        access_code=\"x\",\n                        awaiting_plate_clear=True,\n                    ),\n                    Printer(\n                        id=2,\n                        name=\"P2\",\n                        serial_number=\"S2\",\n                        ip_address=\"2.2.2.2\",\n                        access_code=\"y\",\n                        awaiting_plate_clear=False,\n                    ),\n                ]\n            )\n            await db.commit()\n\n        # Point the manager's session factory at our in-memory DB and load\n        manager = PrinterManager()\n        with patch(\"backend.app.core.database.async_session\", session_maker):\n            await manager.load_awaiting_plate_clear_from_db()\n\n        assert manager.is_awaiting_plate_clear(1) is True\n        assert manager.is_awaiting_plate_clear(2) is False\n        await engine.dispose()\n\n    @pytest.mark.asyncio\n    async def test_persist_writes_flag_to_db(self):\n        \"\"\"set_awaiting_plate_clear + _persist writes the flag to the DB row.\"\"\"\n        from sqlalchemy import select\n        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n\n        import backend.app.models  # noqa: F401\n        from backend.app.core.database import Base\n        from backend.app.models.printer import Printer\n\n        engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n        async with engine.begin() as conn:\n            await conn.run_sync(Base.metadata.create_all)\n        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n        async with session_maker() as db:\n            db.add(\n                Printer(\n                    id=1,\n                    name=\"P1\",\n                    serial_number=\"S1\",\n                    ip_address=\"1.1.1.1\",\n                    access_code=\"x\",\n                    awaiting_plate_clear=False,\n                )\n            )\n            await db.commit()\n\n        manager = PrinterManager()\n        with patch(\"backend.app.core.database.async_session\", session_maker):\n            await manager._persist_awaiting_plate_clear(1, True)\n\n        async with session_maker() as db:\n            row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()\n            assert row.awaiting_plate_clear is True\n\n        with patch(\"backend.app.core.database.async_session\", session_maker):\n            await manager._persist_awaiting_plate_clear(1, False)\n\n        async with session_maker() as db:\n            row = (await db.execute(select(Printer).where(Printer.id == 1))).scalar_one()\n            assert row.awaiting_plate_clear is False\n\n        await engine.dispose()\n\n    @pytest.mark.asyncio\n    async def test_persist_missing_printer_does_not_raise(self):\n        \"\"\"Persisting for a non-existent printer should be a silent no-op.\"\"\"\n        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n\n        import backend.app.models  # noqa: F401\n        from backend.app.core.database import Base\n\n        engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n        async with engine.begin() as conn:\n            await conn.run_sync(Base.metadata.create_all)\n        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n        manager = PrinterManager()\n        with patch(\"backend.app.core.database.async_session\", session_maker):\n            # Should not raise even though printer 999 does not exist\n            await manager._persist_awaiting_plate_clear(999, True)\n\n        await engine.dispose()\n\n\nclass TestSchedulerIdleCheckWithPlateCleared:\n    \"\"\"Test _is_printer_idle interactions with the awaiting-plate-clear flag (#961).\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_idle_state_is_idle(self, mock_pm, scheduler):\n        \"\"\"IDLE state with no awaiting flag → idle.\"\"\"\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"IDLE\")\n        mock_pm.is_awaiting_plate_clear.return_value = False\n        assert scheduler._is_printer_idle(1) is True\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_running_state_not_idle(self, mock_pm, scheduler):\n        \"\"\"RUNNING state is never idle.\"\"\"\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"RUNNING\")\n        mock_pm.is_awaiting_plate_clear.return_value = False\n        assert scheduler._is_printer_idle(1) is False\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_finish_state_not_idle_when_awaiting(self, mock_pm, scheduler):\n        \"\"\"FINISH + awaiting plate-clear ack → NOT idle.\"\"\"\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"FINISH\")\n        mock_pm.is_awaiting_plate_clear.return_value = True\n        assert scheduler._is_printer_idle(1) is False\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_finish_state_idle_when_acknowledged(self, mock_pm, scheduler):\n        \"\"\"FINISH with flag cleared → idle.\"\"\"\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"FINISH\")\n        mock_pm.is_awaiting_plate_clear.return_value = False\n        assert scheduler._is_printer_idle(1) is True\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_failed_state_not_idle_when_awaiting(self, mock_pm, scheduler):\n        \"\"\"FAILED + awaiting → NOT idle.\"\"\"\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"FAILED\")\n        mock_pm.is_awaiting_plate_clear.return_value = True\n        assert scheduler._is_printer_idle(1) is False\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_failed_state_idle_when_acknowledged(self, mock_pm, scheduler):\n        \"\"\"FAILED with flag cleared → idle.\"\"\"\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"FAILED\")\n        mock_pm.is_awaiting_plate_clear.return_value = False\n        assert scheduler._is_printer_idle(1) is True\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_idle_state_not_idle_when_awaiting_survives_power_cycle(self, mock_pm, scheduler):\n        \"\"\"Regression for #961: after Auto Off power-cycles the printer it boots into IDLE\n        with no memory of the previous finish. The persisted awaiting flag must still gate\n        the queue — IDLE + awaiting → NOT idle.\n        \"\"\"\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"IDLE\")\n        mock_pm.is_awaiting_plate_clear.return_value = True\n        assert scheduler._is_printer_idle(1) is False\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_disconnected_printer_not_idle(self, mock_pm, scheduler):\n        mock_pm.is_connected.return_value = False\n        assert scheduler._is_printer_idle(1) is False\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_no_status_not_idle(self, mock_pm, scheduler):\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = None\n        assert scheduler._is_printer_idle(1) is False\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_finish_state_idle_when_require_plate_clear_disabled(self, mock_pm, scheduler):\n        \"\"\"FINISH is idle when require_plate_clear=False, regardless of awaiting flag.\"\"\"\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"FINISH\")\n        mock_pm.is_awaiting_plate_clear.return_value = True\n        assert scheduler._is_printer_idle(1, require_plate_clear=False) is True\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_failed_state_idle_when_require_plate_clear_disabled(self, mock_pm, scheduler):\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"FAILED\")\n        mock_pm.is_awaiting_plate_clear.return_value = True\n        assert scheduler._is_printer_idle(1, require_plate_clear=False) is True\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_running_state_not_idle_even_when_require_plate_clear_disabled(self, mock_pm, scheduler):\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"RUNNING\")\n        mock_pm.is_awaiting_plate_clear.return_value = False\n        assert scheduler._is_printer_idle(1, require_plate_clear=False) is False\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_idle_state_unaffected_by_require_plate_clear(self, mock_pm, scheduler):\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"IDLE\")\n        mock_pm.is_awaiting_plate_clear.return_value = False\n        assert scheduler._is_printer_idle(1, require_plate_clear=False) is True\n\n\nclass TestSchedulerQueueCheckLogging:\n    \"\"\"Test queue check logging when pending items are found (#374).\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @pytest.mark.asyncio\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    async def test_check_queue_logs_pending_items(self, mock_pm, scheduler, caplog):\n        \"\"\"Verify pending items are logged when found in check_queue.\"\"\"\n        mock_item = MagicMock()\n        mock_item.id = 42\n        mock_item.printer_id = 1\n        mock_item.archive_id = 100\n        mock_item.library_file_id = None\n        mock_item.scheduled_time = None\n        mock_item.manual_start = False\n        mock_item.target_model = None\n\n        mock_pm.is_connected.return_value = True\n        mock_pm.get_status.return_value = MagicMock(state=\"RUNNING\")\n\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = [mock_item]\n\n        with (\n            patch(\"backend.app.services.print_scheduler.async_session\") as mock_session_ctx,\n            caplog.at_level(logging.INFO, logger=\"backend.app.services.print_scheduler\"),\n        ):\n            mock_db = AsyncMock()\n            mock_db.execute = AsyncMock(return_value=mock_result)\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            await scheduler.check_queue()\n\n        queue_logs = [r for r in caplog.records if \"Queue check\" in r.message]\n        assert len(queue_logs) == 1\n        assert \"1 pending items\" in queue_logs[0].message\n        assert \"42\" in queue_logs[0].message  # item ID\n\n    @pytest.mark.asyncio\n    async def test_check_queue_no_log_when_empty(self, scheduler, caplog):\n        \"\"\"Verify no queue log when no pending items found.\"\"\"\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = []\n\n        with (\n            patch(\"backend.app.services.print_scheduler.async_session\") as mock_session_ctx,\n            caplog.at_level(logging.INFO, logger=\"backend.app.services.print_scheduler\"),\n        ):\n            mock_db = AsyncMock()\n            mock_db.execute = AsyncMock(return_value=mock_result)\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            await scheduler.check_queue()\n\n        queue_logs = [r for r in caplog.records if \"Queue check\" in r.message]\n        assert len(queue_logs) == 0\n"
  },
  {
    "path": "backend/tests/unit/test_scheduler_filament_override.py",
    "content": "\"\"\"Tests for the filament override feature in the print scheduler.\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.print_scheduler import PrintScheduler\n\n\nclass TestCountOverrideColorMatches:\n    \"\"\"Test the _count_override_color_matches method.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_no_status_returns_zero(self, mock_pm, scheduler):\n        \"\"\"When printer_manager.get_status() returns None, should return 0.\"\"\"\n        mock_pm.get_status.return_value = None\n\n        result = scheduler._count_override_color_matches(1, [{\"type\": \"PLA\", \"color\": \"#FF0000\"}])\n        assert result == 0\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_exact_match(self, mock_pm, scheduler):\n        \"\"\"Override with matching type+color on printer returns 1.\"\"\"\n        mock_pm.get_status.return_value = MagicMock(\n            raw_data={\n                \"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000FF\"}]}],\n            }\n        )\n\n        result = scheduler._count_override_color_matches(1, [{\"type\": \"PLA\", \"color\": \"#FF0000\"}])\n        assert result == 1\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_no_match(self, mock_pm, scheduler):\n        \"\"\"Override with type+color not on printer returns 0.\"\"\"\n        mock_pm.get_status.return_value = MagicMock(\n            raw_data={\n                \"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000FF\"}]}],\n            }\n        )\n\n        result = scheduler._count_override_color_matches(1, [{\"type\": \"PETG\", \"color\": \"#00FF00\"}])\n        assert result == 0\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_multiple_overrides_partial_match(self, mock_pm, scheduler):\n        \"\"\"2 overrides, only 1 matching = returns 1.\"\"\"\n        mock_pm.get_status.return_value = MagicMock(\n            raw_data={\n                \"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000FF\"}]}],\n            }\n        )\n\n        overrides = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\"},  # Matches\n            {\"type\": \"PETG\", \"color\": \"#00FF00\"},  # Does not match\n        ]\n        result = scheduler._count_override_color_matches(1, overrides)\n        assert result == 1\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_color_normalization(self, mock_pm, scheduler):\n        \"\"\"Override color '#FF0000' matches printer tray_color 'FF0000FF' (with alpha).\"\"\"\n        mock_pm.get_status.return_value = MagicMock(\n            raw_data={\n                \"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_type\": \"PLA\", \"tray_color\": \"FF0000FF\"}]}],\n            }\n        )\n\n        # Override uses #-prefixed color; printer uses 8-char RGBA without hash\n        result = scheduler._count_override_color_matches(1, [{\"type\": \"PLA\", \"color\": \"#FF0000\"}])\n        assert result == 1\n\n    @patch(\"backend.app.services.print_scheduler.printer_manager\")\n    def test_external_spool_match(self, mock_pm, scheduler):\n        \"\"\"Override matches filament in vt_tray.\"\"\"\n        mock_pm.get_status.return_value = MagicMock(\n            raw_data={\n                \"ams\": [],\n                \"vt_tray\": [{\"tray_type\": \"TPU\", \"tray_color\": \"0000FFFF\"}],\n            }\n        )\n\n        result = scheduler._count_override_color_matches(1, [{\"type\": \"TPU\", \"color\": \"#0000FF\"}])\n        assert result == 1\n\n\nclass TestFilamentOverrideInMatching:\n    \"\"\"Test that when overrides are applied to filament requirements, the matching uses overridden values.\"\"\"\n\n    @pytest.fixture\n    def scheduler(self):\n        return PrintScheduler()\n\n    def _apply_overrides(self, filament_reqs, overrides):\n        \"\"\"Simulate override application as done in _compute_ams_mapping_for_printer.\"\"\"\n        override_map = {o[\"slot_id\"]: o for o in overrides}\n        for req in filament_reqs:\n            if req[\"slot_id\"] in override_map:\n                override = override_map[req[\"slot_id\"]]\n                req[\"type\"] = override[\"type\"]\n                req[\"color\"] = override[\"color\"]\n                req[\"tray_info_idx\"] = \"\"  # Clear for override\n        return filament_reqs\n\n    def test_override_changes_color_match(self, scheduler):\n        \"\"\"Original req has color A, loaded has color B. Override to color B gives exact match.\"\"\"\n        filament_reqs = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"\"}]\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0},\n        ]\n\n        # Without override: type-only match (colors differ)\n        result_without = scheduler._match_filaments_to_slots(filament_reqs, loaded)\n        assert result_without == [0]  # Matches by type only\n\n        # Now apply override changing color to match loaded\n        overrides = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        filament_reqs_overridden = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"\"}]\n        self._apply_overrides(filament_reqs_overridden, overrides)\n\n        result_with = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)\n        assert result_with == [0]  # Exact color match now\n        # Verify the override actually changed the color in the requirement\n        assert filament_reqs_overridden[0][\"color\"] == \"#FF0000\"\n\n    def test_override_clears_tray_info_idx(self, scheduler):\n        \"\"\"When tray_info_idx is cleared, matching falls to color-based instead of tray_info_idx-based.\"\"\"\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0, \"tray_info_idx\": \"GFA00\"},\n            {\"type\": \"PLA\", \"color\": \"#00FF00\", \"global_tray_id\": 1, \"tray_info_idx\": \"GFB00\"},\n        ]\n\n        # Without override: tray_info_idx \"GFA00\" matches tray 0 (red)\n        filament_reqs_original = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\", \"tray_info_idx\": \"GFA00\"}]\n        result_original = scheduler._match_filaments_to_slots(filament_reqs_original, loaded)\n        assert result_original == [0]  # Matched by tray_info_idx\n\n        # With override: tray_info_idx is cleared, color changed to green -> matches tray 1\n        filament_reqs_overridden = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\", \"tray_info_idx\": \"GFA00\"}]\n        overrides = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#00FF00\"}]\n        self._apply_overrides(filament_reqs_overridden, overrides)\n\n        assert filament_reqs_overridden[0][\"tray_info_idx\"] == \"\"  # Cleared\n        result_overridden = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)\n        assert result_overridden == [1]  # Now matches tray 1 by color\n\n    def test_override_type_change(self, scheduler):\n        \"\"\"Override changes type from PLA to PETG, loaded has PETG -> matches.\"\"\"\n        loaded = [\n            {\"type\": \"PETG\", \"color\": \"#FF0000\", \"global_tray_id\": 0},\n        ]\n\n        # Without override: PLA requirement, PETG loaded -> no match\n        filament_reqs_original = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\", \"tray_info_idx\": \"\"}]\n        result_original = scheduler._match_filaments_to_slots(filament_reqs_original, loaded)\n        assert result_original == [-1]  # Type mismatch\n\n        # With override: type changed to PETG -> matches\n        filament_reqs_overridden = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\", \"tray_info_idx\": \"\"}]\n        overrides = [{\"slot_id\": 1, \"type\": \"PETG\", \"color\": \"#FF0000\"}]\n        self._apply_overrides(filament_reqs_overridden, overrides)\n\n        result_overridden = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)\n        assert result_overridden == [0]  # Exact match now\n\n    def test_partial_override(self, scheduler):\n        \"\"\"2 slots, only slot 1 overridden. Slot 1 uses override, slot 2 uses original.\"\"\"\n        loaded = [\n            {\"type\": \"PLA\", \"color\": \"#FF0000\", \"global_tray_id\": 0},\n            {\"type\": \"PETG\", \"color\": \"#00FF00\", \"global_tray_id\": 1},\n        ]\n\n        filament_reqs = [\n            {\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"GFA00\"},\n            {\"slot_id\": 2, \"type\": \"PETG\", \"color\": \"#00FF00\", \"tray_info_idx\": \"GFG02\"},\n        ]\n\n        # Override only slot 1: change color to red\n        overrides = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        self._apply_overrides(filament_reqs, overrides)\n\n        # Slot 1: overridden to PLA/#FF0000, tray_info_idx cleared -> matches tray 0 by exact color\n        assert filament_reqs[0][\"color\"] == \"#FF0000\"\n        assert filament_reqs[0][\"tray_info_idx\"] == \"\"\n\n        # Slot 2: NOT overridden, retains original tray_info_idx\n        assert filament_reqs[1][\"color\"] == \"#00FF00\"\n        assert filament_reqs[1][\"tray_info_idx\"] == \"GFG02\"\n\n        result = scheduler._match_filaments_to_slots(filament_reqs, loaded)\n        assert result == [0, 1]  # Slot 1 -> tray 0 (red PLA), slot 2 -> tray 1 (green PETG)\n\n    def test_nozzle_filtering_with_override(self, scheduler):\n        \"\"\"Override to a type only available on the wrong nozzle returns -1.\"\"\"\n        loaded = [\n            # PETG on RIGHT nozzle (extruder 0) only\n            {\"type\": \"PETG\", \"color\": \"#FF0000\", \"global_tray_id\": 0, \"extruder_id\": 0},\n            # PLA on LEFT nozzle (extruder 1) only\n            {\"type\": \"PLA\", \"color\": \"#00FF00\", \"global_tray_id\": 4, \"extruder_id\": 1},\n        ]\n\n        # Override to PETG on LEFT nozzle — but PETG is only on RIGHT\n        filament_reqs = [{\"slot_id\": 1, \"type\": \"PLA\", \"color\": \"#000000\", \"tray_info_idx\": \"GFA00\", \"nozzle_id\": 1}]\n        overrides = [{\"slot_id\": 1, \"type\": \"PETG\", \"color\": \"#FF0000\"}]\n        self._apply_overrides(filament_reqs, overrides)\n\n        result = scheduler._match_filaments_to_slots(filament_reqs, loaded)\n        # Nozzle filter limits to extruder 1 (LEFT) which only has PLA.\n        # Override changed type to PETG, so no type match on LEFT nozzle -> -1\n        assert result == [-1]\n"
  },
  {
    "path": "backend/tests/unit/test_scheduler_watchdog.py",
    "content": "\"\"\"Regression tests for ``_watchdog_print_start``.\n\nThe watchdog reverts queue items to ``pending`` when a dispatched print never\nlands on the printer (half-broken MQTT session — #887/#936/#967). H2D firmware\ncan sit at ``FINISH`` for 50+ seconds after accepting a ``project_file``\ncommand before flipping ``gcode_state`` to ``PREPARE``, which used to trip the\nstate-only watchdog and cause the scheduler to revert the item; the subsequent\nsuccessful dispatch then looked like a reprint of the just-finished job (#1078).\n\nThe fix: treat ``subtask_id`` advancing past the pre-dispatch value as an\nequivalent \"command landed\" signal, and raise the timeout from 45 s to 90 s as\nbelt-and-braces for slow transitions that also don't emit an early subtask_id\ntick.\n\"\"\"\n\nfrom types import SimpleNamespace\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom backend.app.models.print_queue import PrintQueueItem\nfrom backend.app.services.print_scheduler import PrintScheduler\n\n\n@pytest.fixture\nasync def db_session():\n    \"\"\"In-memory SQLite with one ``printing`` queue item at id=1.\"\"\"\n    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine\n\n    import backend.app.models  # noqa: F401  — populate Base.metadata\n    from backend.app.core.database import Base\n\n    engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\", echo=False)\n    async with engine.begin() as conn:\n        await conn.run_sync(Base.metadata.create_all)\n    session_maker = async_sessionmaker(engine, expire_on_commit=False)\n\n    async with session_maker() as db:\n        db.add(PrintQueueItem(id=1, printer_id=42, archive_id=99, status=\"printing\"))\n        await db.commit()\n\n    try:\n        yield session_maker\n    finally:\n        await engine.dispose()\n\n\ndef _status(state: str, subtask_id: str | None = None):\n    \"\"\"Minimal stand-in for PrinterState — only the two fields the watchdog reads.\"\"\"\n    return SimpleNamespace(state=state, subtask_id=subtask_id)\n\n\nclass TestWatchdogExitsEarlyOnPickup:\n    \"\"\"The watchdog must NOT revert when the printer has clearly picked up the job.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_exits_on_state_change(self, db_session):\n        \"\"\"State transitioning away from pre_state is the primary \"accepted\" signal.\"\"\"\n        get_status = MagicMock(return_value=_status(\"RUNNING\", \"OLD_SUBTASK\"))\n        with (\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_status\", get_status),\n            patch(\"backend.app.services.print_scheduler.async_session\", db_session),\n        ):\n            await PrintScheduler._watchdog_print_start(\n                queue_item_id=1,\n                printer_id=42,\n                pre_state=\"FINISH\",\n                pre_subtask_id=\"OLD_SUBTASK\",\n                timeout=0.3,\n                poll_interval=0.05,\n            )\n\n        # Item should remain \"printing\" — watchdog recognised the pickup.\n        async with db_session() as db:\n            item = await db.get(PrintQueueItem, 1)\n            assert item.status == \"printing\"\n\n    @pytest.mark.asyncio\n    async def test_exits_on_subtask_id_change_even_if_state_still_finish(self, db_session):\n        \"\"\"Regression for #1078: H2D keeps state=FINISH for ~50 s after accepting\n        project_file, but subtask_id flips to our new submission_id almost\n        immediately. That must short-circuit the revert.\"\"\"\n        get_status = MagicMock(return_value=_status(\"FINISH\", \"NEW_SUBTASK_12345\"))\n        with (\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_status\", get_status),\n            patch(\"backend.app.services.print_scheduler.async_session\", db_session),\n        ):\n            await PrintScheduler._watchdog_print_start(\n                queue_item_id=1,\n                printer_id=42,\n                pre_state=\"FINISH\",\n                pre_subtask_id=\"OLD_SUBTASK_99999\",\n                timeout=0.3,\n                poll_interval=0.05,\n            )\n\n        async with db_session() as db:\n            item = await db.get(PrintQueueItem, 1)\n            assert item.status == \"printing\", (\n                \"subtask_id advanced past pre_subtask_id — the printer accepted our \"\n                \"project_file and the watchdog must not revert the queue item even \"\n                \"though state is still FINISH (#1078)\"\n            )\n\n\nclass TestWatchdogRevertsWhenStuck:\n    \"\"\"Genuine half-broken sessions still need the revert + reconnect recovery.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_reverts_when_neither_state_nor_subtask_id_changes(self, db_session):\n        \"\"\"Both signals unchanged across the full timeout → revert to pending\n        and force MQTT reconnect (the #967 recovery path).\"\"\"\n        get_status = MagicMock(return_value=_status(\"FINISH\", \"OLD_SUBTASK\"))\n        client = MagicMock()\n        get_client = MagicMock(return_value=client)\n\n        with (\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_status\", get_status),\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_client\", get_client),\n            patch(\"backend.app.services.print_scheduler.async_session\", db_session),\n        ):\n            await PrintScheduler._watchdog_print_start(\n                queue_item_id=1,\n                printer_id=42,\n                pre_state=\"FINISH\",\n                pre_subtask_id=\"OLD_SUBTASK\",\n                timeout=0.2,\n                poll_interval=0.05,\n            )\n\n        async with db_session() as db:\n            item = await db.get(PrintQueueItem, 1)\n            assert item.status == \"pending\"\n            assert item.started_at is None\n\n        client.force_reconnect_stale_session.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_default_timeout_is_90_seconds(self):\n        \"\"\"The default timeout must cover slow H2D FINISH→PREPARE transitions\n        (~50 s observed). A 45 s default would trip on the exact scenario the\n        subtask_id check is guarding against, leaving no fallback for printers\n        that don't echo subtask_id.\"\"\"\n        import inspect\n\n        sig = inspect.signature(PrintScheduler._watchdog_print_start)\n        assert sig.parameters[\"timeout\"].default == 90.0\n\n\nclass TestWatchdogFallbackBehaviour:\n    \"\"\"Backwards-compat and defensive behaviour around missing data.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_pre_subtask_id_none_falls_back_to_state_only(self, db_session):\n        \"\"\"When we never captured a pre-dispatch subtask_id (e.g. printer just\n        connected), the watchdog must still work on the state signal alone —\n        and still revert when state stays unchanged, so half-broken sessions\n        are still recovered.\"\"\"\n        get_status = MagicMock(return_value=_status(\"FINISH\", \"SOMETHING\"))\n        get_client = MagicMock(return_value=None)\n\n        with (\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_status\", get_status),\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_client\", get_client),\n            patch(\"backend.app.services.print_scheduler.async_session\", db_session),\n        ):\n            await PrintScheduler._watchdog_print_start(\n                queue_item_id=1,\n                printer_id=42,\n                pre_state=\"FINISH\",\n                pre_subtask_id=None,\n                timeout=0.2,\n                poll_interval=0.05,\n            )\n\n        async with db_session() as db:\n            item = await db.get(PrintQueueItem, 1)\n            assert item.status == \"pending\"\n\n    @pytest.mark.asyncio\n    async def test_current_subtask_id_none_does_not_trigger_early_exit(self, db_session):\n        \"\"\"If the printer transiently reports subtask_id=None (e.g. during\n        reconnect), that must not be treated as \"changed\" — otherwise the\n        watchdog would exit early without a real pickup signal and leave the\n        item stuck in \"printing\" after a genuinely broken session.\"\"\"\n        get_status = MagicMock(return_value=_status(\"FINISH\", None))\n        get_client = MagicMock(return_value=None)\n\n        with (\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_status\", get_status),\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_client\", get_client),\n            patch(\"backend.app.services.print_scheduler.async_session\", db_session),\n        ):\n            await PrintScheduler._watchdog_print_start(\n                queue_item_id=1,\n                printer_id=42,\n                pre_state=\"FINISH\",\n                pre_subtask_id=\"OLD_SUBTASK\",\n                timeout=0.2,\n                poll_interval=0.05,\n            )\n\n        async with db_session() as db:\n            item = await db.get(PrintQueueItem, 1)\n            assert item.status == \"pending\"\n\n    @pytest.mark.asyncio\n    async def test_printer_disconnected_returns_without_reverting(self, db_session):\n        \"\"\"If the printer drops during the watchdog window, don't touch the DB —\n        the reconnect path will sort the queue state out.\"\"\"\n        get_status = MagicMock(return_value=None)\n\n        with (\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_status\", get_status),\n            patch(\"backend.app.services.print_scheduler.async_session\", db_session),\n        ):\n            await PrintScheduler._watchdog_print_start(\n                queue_item_id=1,\n                printer_id=42,\n                pre_state=\"FINISH\",\n                pre_subtask_id=\"OLD_SUBTASK\",\n                timeout=0.2,\n                poll_interval=0.05,\n            )\n\n        async with db_session() as db:\n            item = await db.get(PrintQueueItem, 1)\n            assert item.status == \"printing\"\n\n    @pytest.mark.asyncio\n    async def test_no_revert_if_item_already_completed(self, db_session):\n        \"\"\"If the print completed between watchdog arm-time and timeout (item is\n        no longer \"printing\"), the watchdog must not clobber whatever status it\n        ended up in — #967 race guard.\"\"\"\n        # Move item on to \"completed\" before the watchdog fires.\n        async with db_session() as db:\n            item = await db.get(PrintQueueItem, 1)\n            item.status = \"completed\"\n            await db.commit()\n\n        get_status = MagicMock(return_value=_status(\"FINISH\", \"OLD_SUBTASK\"))\n        get_client = MagicMock(return_value=None)\n\n        with (\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_status\", get_status),\n            patch(\"backend.app.services.print_scheduler.printer_manager.get_client\", get_client),\n            patch(\"backend.app.services.print_scheduler.async_session\", db_session),\n        ):\n            await PrintScheduler._watchdog_print_start(\n                queue_item_id=1,\n                printer_id=42,\n                pre_state=\"FINISH\",\n                pre_subtask_id=\"OLD_SUBTASK\",\n                timeout=0.2,\n                poll_interval=0.05,\n            )\n\n        async with db_session() as db:\n            item = await db.get(PrintQueueItem, 1)\n            assert item.status == \"completed\"  # untouched\n"
  },
  {
    "path": "backend/tests/unit/test_slicer_settings.py",
    "content": "\"\"\"Unit tests for preferred_slicer setting in AppSettings schema.\"\"\"\n\nimport pytest\n\nfrom backend.app.schemas.settings import AppSettings, AppSettingsUpdate\n\n\n@pytest.mark.unit\nclass TestPreferredSlicerSchema:\n    \"\"\"Tests for the preferred_slicer field in settings schemas.\"\"\"\n\n    def test_default_value_is_bambu_studio(self):\n        \"\"\"Default preferred_slicer should be bambu_studio.\"\"\"\n        settings = AppSettings()\n        assert settings.preferred_slicer == \"bambu_studio\"\n\n    def test_set_to_orcaslicer(self):\n        \"\"\"Should accept orcaslicer as a valid value.\"\"\"\n        settings = AppSettings(preferred_slicer=\"orcaslicer\")\n        assert settings.preferred_slicer == \"orcaslicer\"\n\n    def test_set_to_bambu_studio_explicit(self):\n        \"\"\"Should accept bambu_studio as an explicit value.\"\"\"\n        settings = AppSettings(preferred_slicer=\"bambu_studio\")\n        assert settings.preferred_slicer == \"bambu_studio\"\n\n    def test_update_schema_default_is_none(self):\n        \"\"\"AppSettingsUpdate preferred_slicer should default to None.\"\"\"\n        update = AppSettingsUpdate()\n        assert update.preferred_slicer is None\n\n    def test_update_schema_accepts_value(self):\n        \"\"\"AppSettingsUpdate should accept a preferred_slicer value.\"\"\"\n        update = AppSettingsUpdate(preferred_slicer=\"orcaslicer\")\n        assert update.preferred_slicer == \"orcaslicer\"\n\n    def test_serialization_roundtrip(self):\n        \"\"\"Settings should survive serialization roundtrip.\"\"\"\n        settings = AppSettings(preferred_slicer=\"orcaslicer\")\n        data = settings.model_dump()\n        restored = AppSettings(**data)\n        assert restored.preferred_slicer == \"orcaslicer\"\n\n    def test_partial_update_preserves_other_fields(self):\n        \"\"\"Updating preferred_slicer should not affect other fields.\"\"\"\n        update = AppSettingsUpdate(preferred_slicer=\"orcaslicer\")\n        data = update.model_dump(exclude_none=True)\n        assert data == {\"preferred_slicer\": \"orcaslicer\"}\n"
  },
  {
    "path": "backend/tests/unit/test_slot_preset_key.py",
    "content": "\"\"\"Unit tests for slot-preset key derivation.\n\nRegression coverage for #1053: the backend's get_slot_presets response\nmust use the same keying scheme as the frontend's getGlobalTrayId\n(amsHelpers.ts) so that AMS-HT mappings round-trip correctly.\n\"\"\"\n\nfrom backend.app.api.routes.printers import _slot_preset_key\n\n\ndef test_regular_ams_uses_global_tray_id():\n    assert _slot_preset_key(0, 0) == 0\n    assert _slot_preset_key(0, 3) == 3\n    assert _slot_preset_key(1, 1) == 5\n    assert _slot_preset_key(2, 2) == 10\n    assert _slot_preset_key(3, 3) == 15\n\n\ndef test_ams_ht_keyed_by_ams_id():\n    # AMS-HT is single-slot and shares its global tray id with the unit id;\n    # frontend getGlobalTrayId(amsId, 0, false) returns amsId for 128-135.\n    assert _slot_preset_key(128, 0) == 128\n    assert _slot_preset_key(129, 0) == 129\n    assert _slot_preset_key(135, 0) == 135\n\n\ndef test_external_spool_uses_multiplied_id():\n    # External (ams_id=255) matches PrintersPage lookup: 255 * 4 + tray_id.\n    assert _slot_preset_key(255, 0) == 1020\n    assert _slot_preset_key(255, 1) == 1021\n"
  },
  {
    "path": "backend/tests/unit/test_spool_schemas_rgba.py",
    "content": "\"\"\"Schema validation tests for the spool rgba field (#1055).\n\nThree guarantees to lock in:\n1. SpoolCreate and SpoolUpdate must reject malformed rgba (short, long, non-hex)\n   on the write path — this is the \"add a check\" the reporter asked for.\n2. SpoolResponse must NOT validate rgba on the read path: a single legacy row\n   with a 7-char rgba (as in #1055) must not 500 the entire inventory list.\n3. Valid 8-char hex must continue to round-trip through all three schemas.\n\"\"\"\n\nimport pytest\nfrom pydantic import ValidationError\n\nfrom backend.app.schemas.spool import SpoolCreate, SpoolUpdate\n\n\nclass TestSpoolCreateRgbaValidation:\n    \"\"\"Write-path validation on the create schema.\"\"\"\n\n    def test_accepts_valid_8char_hex(self):\n        spool = SpoolCreate(material=\"PLA\", rgba=\"FF00AAFF\")\n        assert spool.rgba == \"FF00AAFF\"\n\n    def test_accepts_lowercase_hex(self):\n        spool = SpoolCreate(material=\"PLA\", rgba=\"ff00aaff\")\n        assert spool.rgba == \"ff00aaff\"\n\n    def test_accepts_null_rgba(self):\n        spool = SpoolCreate(material=\"PLA\", rgba=None)\n        assert spool.rgba is None\n\n    def test_rejects_7char_rgba(self):\n        \"\"\"#1055 repro: a 7-char 'FFFFFFF' must not be acceptable on create.\"\"\"\n        with pytest.raises(ValidationError, match=\"rgba\"):\n            SpoolCreate(material=\"PLA\", rgba=\"FFFFFFF\")\n\n    def test_rejects_6char_rgba(self):\n        \"\"\"Plain RRGGBB without alpha must be rejected — frontend appends FF.\"\"\"\n        with pytest.raises(ValidationError, match=\"rgba\"):\n            SpoolCreate(material=\"PLA\", rgba=\"FF0000\")\n\n    def test_rejects_non_hex_char(self):\n        with pytest.raises(ValidationError, match=\"rgba\"):\n            SpoolCreate(material=\"PLA\", rgba=\"FFZZ00FF\")\n\n\nclass TestSpoolUpdateRgbaValidation:\n    \"\"\"Write-path validation on the update schema — the gap that let #1055 happen.\n\n    Before the fix, SpoolUpdate.rgba was a bare `str | None` so a PATCH could\n    plant a 7-char value straight into the DB. That row then caused a 500 on\n    the next GET because SpoolResponse enforced the pattern at serialize time.\n    \"\"\"\n\n    def test_accepts_valid_8char_hex(self):\n        update = SpoolUpdate(rgba=\"00FF00FF\")\n        assert update.rgba == \"00FF00FF\"\n\n    def test_accepts_null_rgba(self):\n        update = SpoolUpdate(rgba=None)\n        assert update.rgba is None\n\n    def test_accepts_missing_rgba(self):\n        \"\"\"Partial updates — rgba not present in payload — must still be valid.\"\"\"\n        update = SpoolUpdate(material=\"PETG\")\n        assert update.rgba is None\n\n    def test_rejects_7char_rgba(self):\n        \"\"\"#1055 repro: PATCH must reject the exact pattern that bricked the reporter.\"\"\"\n        with pytest.raises(ValidationError, match=\"rgba\"):\n            SpoolUpdate(rgba=\"FFFFFFF\")\n\n    def test_rejects_9char_rgba(self):\n        with pytest.raises(ValidationError, match=\"rgba\"):\n            SpoolUpdate(rgba=\"FFFFFFFFF\")\n\n    def test_rejects_non_hex_char(self):\n        with pytest.raises(ValidationError, match=\"rgba\"):\n            SpoolUpdate(rgba=\"FFGG00FF\")\n\n\nclass TestSpoolResponseRgbaLeniency:\n    \"\"\"Read-path leniency — a legacy bad row must never 500 the list endpoint.\n\n    Before the fix, SpoolResponse inherited the pattern from SpoolBase so a\n    single 7-char rgba in the DB blew up the whole inventory listing. The\n    response schema now treats rgba as an unconstrained Optional[str] — write\n    validation is where the pattern belongs; responses must tolerate whatever\n    is already persisted.\n    \"\"\"\n\n    # SpoolResponse requires id + timestamps so it's easier to test via a\n    # minimal dict payload than by constructing a full instance.\n    @staticmethod\n    def _make_response_kwargs(**overrides):\n        from datetime import datetime\n\n        base = {\n            \"id\": 1,\n            \"material\": \"PLA\",\n            \"created_at\": datetime.fromisoformat(\"2026-01-01T00:00:00\"),\n            \"updated_at\": datetime.fromisoformat(\"2026-01-01T00:00:00\"),\n        }\n        base.update(overrides)\n        return base\n\n    def test_tolerates_7char_rgba_on_serialize(self):\n        \"\"\"This is the #1055 bug fixed: malformed legacy rgba must serialize cleanly.\"\"\"\n        from backend.app.schemas.spool import SpoolResponse\n\n        response = SpoolResponse(**self._make_response_kwargs(rgba=\"FFFFFFF\"))\n        assert response.rgba == \"FFFFFFF\"\n\n    def test_tolerates_null_rgba(self):\n        from backend.app.schemas.spool import SpoolResponse\n\n        response = SpoolResponse(**self._make_response_kwargs(rgba=None))\n        assert response.rgba is None\n\n    def test_tolerates_non_hex_rgba(self):\n        \"\"\"Even completely garbage rgba shouldn't crash the endpoint.\"\"\"\n        from backend.app.schemas.spool import SpoolResponse\n\n        response = SpoolResponse(**self._make_response_kwargs(rgba=\"not-hex-at-all\"))\n        assert response.rgba == \"not-hex-at-all\"\n\n    def test_passes_valid_rgba_through(self):\n        from backend.app.schemas.spool import SpoolResponse\n\n        response = SpoolResponse(**self._make_response_kwargs(rgba=\"FF00AAFF\"))\n        assert response.rgba == \"FF00AAFF\"\n"
  },
  {
    "path": "backend/tests/unit/test_spoolbuddy_system_stats.py",
    "content": "\"\"\"Tests for SpoolBuddy daemon system_stats collector.\"\"\"\n\nimport pytest\n\npytest.importorskip(\"spoolbuddy\", reason=\"spoolbuddy package not available in Docker\")\n\nfrom unittest.mock import patch\n\nfrom spoolbuddy.daemon.system_stats import (\n    _cpu_count,\n    _cpu_temp,\n    _disk_info,\n    _load_avg,\n    _memory_info,\n    _os_info,\n    _system_uptime,\n    collect,\n)\n\n\nclass TestCpuTemp:\n    def test_reads_thermal_zone(self):\n        with patch(\"spoolbuddy.daemon.system_stats._read_file\", return_value=\"52100\"):\n            assert _cpu_temp() == 52.1\n\n    def test_returns_none_on_missing_file(self):\n        with patch(\"spoolbuddy.daemon.system_stats._read_file\", return_value=None):\n            assert _cpu_temp() is None\n\n    def test_returns_none_on_bad_value(self):\n        with patch(\"spoolbuddy.daemon.system_stats._read_file\", return_value=\"not_a_number\"):\n            assert _cpu_temp() is None\n\n\nclass TestMemoryInfo:\n    SAMPLE_MEMINFO = (\n        \"MemTotal:        1024000 kB\\n\"\n        \"MemFree:          200000 kB\\n\"\n        \"MemAvailable:     512000 kB\\n\"\n        \"Buffers:           50000 kB\\n\"\n    )\n\n    def test_parses_meminfo(self):\n        with patch(\"spoolbuddy.daemon.system_stats._read_file\", return_value=self.SAMPLE_MEMINFO):\n            result = _memory_info()\n            assert result is not None\n            assert result[\"total_mb\"] == 1000\n            assert result[\"available_mb\"] == 500\n            assert result[\"used_mb\"] == 500\n            assert result[\"percent\"] == 50.0\n\n    def test_returns_none_on_missing(self):\n        with patch(\"spoolbuddy.daemon.system_stats._read_file\", return_value=None):\n            assert _memory_info() is None\n\n\nclass TestDiskInfo:\n    def test_returns_disk_stats(self):\n        result = _disk_info()\n        # Should always work on Linux\n        assert result is not None\n        assert \"total_gb\" in result\n        assert \"used_gb\" in result\n        assert \"free_gb\" in result\n        assert \"percent\" in result\n        assert 0 <= result[\"percent\"] <= 100\n\n\nclass TestLoadAvg:\n    def test_returns_three_values(self):\n        result = _load_avg()\n        assert result is not None\n        assert len(result) == 3\n        for val in result:\n            assert isinstance(val, float)\n\n\nclass TestCpuCount:\n    def test_returns_positive_int(self):\n        result = _cpu_count()\n        assert result is not None\n        assert result > 0\n\n\nclass TestOsInfo:\n    def test_returns_required_keys(self):\n        result = _os_info()\n        assert \"os\" in result\n        assert \"kernel\" in result\n        assert \"arch\" in result\n        assert \"python\" in result\n\n    def test_parses_pretty_name(self):\n        fake_release = 'PRETTY_NAME=\"Raspbian GNU/Linux 12 (bookworm)\"\\nID=raspbian\\n'\n        with patch(\"spoolbuddy.daemon.system_stats._read_file\", return_value=fake_release):\n            result = _os_info()\n            assert result[\"os\"] == \"Raspbian GNU/Linux 12 (bookworm)\"\n\n\nclass TestSystemUptime:\n    def test_parses_uptime(self):\n        with patch(\"spoolbuddy.daemon.system_stats._read_file\", return_value=\"86400.55 172000.10\"):\n            assert _system_uptime() == 86400\n\n    def test_returns_none_on_missing(self):\n        with patch(\"spoolbuddy.daemon.system_stats._read_file\", return_value=None):\n            assert _system_uptime() is None\n\n\nclass TestCollect:\n    def test_returns_dict_with_expected_keys(self):\n        result = collect()\n        assert isinstance(result, dict)\n        assert \"os\" in result\n        # These may or may not be present depending on platform, but os is always present\n\n    def test_all_values_are_json_serializable(self):\n        import json\n\n        result = collect()\n        # Should not raise\n        json.dumps(result)\n"
  },
  {
    "path": "backend/tests/unit/test_spoolman_clear_location.py",
    "content": "\"\"\"Unit tests for Spoolman location clearing when spools are removed from AMS.\n\nTests the clear_location_for_removed_spools method to verify that stale\nSpoolman locations are cleared during both auto-sync and manual sync,\npreventing the \"double-booked\" slot bug (#921).\n\"\"\"\n\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\n\nfrom backend.app.services.spoolman import SpoolmanClient\n\nBAMBU_UUID_A = \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"\nBAMBU_UUID_B = \"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\"\nBAMBU_UUID_C = \"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\"\nPRINTER_NAME = \"My Printer\"\nLOCATION_PREFIX = f\"{PRINTER_NAME} - \"\n\n\ndef _make_spool(spool_id: int, location: str, tag: str = \"\", extra: dict | None = None) -> dict:\n    \"\"\"Create a mock Spoolman spool dict.\"\"\"\n    return {\n        \"id\": spool_id,\n        \"location\": location,\n        \"extra\": extra or {\"tag\": tag},\n    }\n\n\n@pytest.fixture\ndef client():\n    \"\"\"Create a SpoolmanClient without connecting.\"\"\"\n    return SpoolmanClient(\"http://localhost:7912\")\n\n\nclass TestClearLocationForRemovedSpools:\n    \"\"\"Test the clear_location_for_removed_spools method.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_clears_spool_no_longer_in_ams(self, client):\n        \"\"\"A spool whose UUID is not in current_tray_uuids should have its location cleared.\"\"\"\n        cached_spools = [\n            _make_spool(1, f\"{LOCATION_PREFIX}AMS A Slot 1\", BAMBU_UUID_A),\n        ]\n\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock, return_value=True) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools\n            )\n\n        assert cleared == 1\n        mock_update.assert_called_once_with(spool_id=1, clear_location=True)\n\n    @pytest.mark.asyncio\n    async def test_keeps_spool_still_in_ams(self, client):\n        \"\"\"A spool whose UUID is in current_tray_uuids should not be cleared.\"\"\"\n        cached_spools = [\n            _make_spool(1, f\"{LOCATION_PREFIX}AMS A Slot 1\", BAMBU_UUID_A),\n        ]\n\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME, current_tray_uuids={BAMBU_UUID_A}, cached_spools=cached_spools\n            )\n\n        assert cleared == 0\n        mock_update.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_skips_non_bambu_spools(self, client):\n        \"\"\"Spools without a 32-char RFID tag should not be cleared (non-Bambu / third-party).\"\"\"\n        cached_spools = [\n            _make_spool(1, f\"{LOCATION_PREFIX}AMS A Slot 1\", \"SHORT_TAG\"),\n            _make_spool(2, f\"{LOCATION_PREFIX}AMS A Slot 2\", \"\"),\n        ]\n\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools\n            )\n\n        assert cleared == 0\n        mock_update.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_skips_spools_from_other_printers(self, client):\n        \"\"\"Spools with locations for a different printer should not be touched.\"\"\"\n        cached_spools = [\n            _make_spool(1, \"Other Printer - AMS A Slot 1\", BAMBU_UUID_A),\n        ]\n\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME, current_tray_uuids=set(), cached_spools=cached_spools\n            )\n\n        assert cleared == 0\n        mock_update.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_synced_spool_ids_protects_location_matched_spools(self, client):\n        \"\"\"Spools in synced_spool_ids should not be cleared even if UUID doesn't match.\"\"\"\n        cached_spools = [\n            _make_spool(1, f\"{LOCATION_PREFIX}AMS A Slot 1\", BAMBU_UUID_A),\n        ]\n\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME,\n                current_tray_uuids=set(),\n                cached_spools=cached_spools,\n                synced_spool_ids={1},\n            )\n\n        assert cleared == 0\n        mock_update.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_clears_only_removed_spools_in_mixed_set(self, client):\n        \"\"\"With multiple spools at a printer, only clear the one that was removed.\"\"\"\n        cached_spools = [\n            _make_spool(1, f\"{LOCATION_PREFIX}AMS A Slot 1\", BAMBU_UUID_A),  # Still in AMS\n            _make_spool(2, f\"{LOCATION_PREFIX}AMS A Slot 2\", BAMBU_UUID_B),  # Removed\n            _make_spool(3, f\"{LOCATION_PREFIX}AMS A Slot 3\", BAMBU_UUID_C),  # Still in AMS\n        ]\n\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock, return_value=True) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME,\n                current_tray_uuids={BAMBU_UUID_A, BAMBU_UUID_C},\n                cached_spools=cached_spools,\n            )\n\n        assert cleared == 1\n        mock_update.assert_called_once_with(spool_id=2, clear_location=True)\n\n    @pytest.mark.asyncio\n    async def test_uuid_comparison_is_case_insensitive(self, client):\n        \"\"\"UUID matching should work regardless of case.\"\"\"\n        cached_spools = [\n            _make_spool(1, f\"{LOCATION_PREFIX}AMS A Slot 1\", BAMBU_UUID_A.lower()),\n        ]\n\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME,\n                current_tray_uuids={BAMBU_UUID_A},  # Uppercase\n                cached_spools=cached_spools,\n            )\n\n        assert cleared == 0\n        mock_update.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_returns_zero_when_no_spools_at_printer(self, client):\n        \"\"\"When no spools have locations for this printer, nothing is cleared.\"\"\"\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME, current_tray_uuids=set(), cached_spools=[]\n            )\n\n        assert cleared == 0\n        mock_update.assert_not_called()\n\n    @pytest.mark.asyncio\n    async def test_double_booking_scenario(self, client):\n        \"\"\"Reproduce #921: two spools assigned to the same printer location.\n\n        When SpoolA is removed and SpoolB takes its slot, SpoolA's old location\n        should be cleared because its UUID is no longer in current_tray_uuids.\n        \"\"\"\n        cached_spools = [\n            _make_spool(1, f\"{LOCATION_PREFIX}AMS A Slot 1\", BAMBU_UUID_A),  # OLD — was removed\n            _make_spool(2, f\"{LOCATION_PREFIX}AMS A Slot 1\", BAMBU_UUID_B),  # NEW — just inserted\n        ]\n\n        with patch.object(client, \"update_spool\", new_callable=AsyncMock, return_value=True) as mock_update:\n            cleared = await client.clear_location_for_removed_spools(\n                PRINTER_NAME,\n                current_tray_uuids={BAMBU_UUID_B},  # Only SpoolB is in AMS now\n                cached_spools=cached_spools,\n                synced_spool_ids={2},  # SpoolB was just synced\n            )\n\n        assert cleared == 1\n        mock_update.assert_called_once_with(spool_id=1, clear_location=True)\n"
  },
  {
    "path": "backend/tests/unit/test_subtask_archive_resume.py",
    "content": "\"\"\"Regression tests for subtask_id-based archive resume (#972).\n\nBefore this fix, a Bambuddy restart during a long print (e.g. 13h) triggered\nthe name-based \"stale archive\" path at 4h, cancelled the original row, and\ncreated a new archive with `started_at = now()` — losing ~9h of print time\ncontinuity. mstko reported this on a 37.5MB Broly print on an A1: after a\ncontainer restart mid-print, the archive ended up showing ~1h37m duration\nfor a print that actually ran 13h08m.\n\nThe fix stores `subtask_id` (MQTT-provided job identifier) on the archive row.\nOn print-start detection, the handler first tries to match an existing\narchive by subtask_id regardless of age — same id ⇒ same print ⇒ resume.\nOnly unmatched prints fall through to the legacy 4h staleness heuristic.\n\"\"\"\n\nfrom datetime import datetime, timedelta, timezone\n\nimport pytest\nfrom sqlalchemy import select\n\nfrom backend.app.models.archive import PrintArchive\n\n\ndef _extract_subtask_id(data: dict) -> str | None:\n    \"\"\"Mirrors the extraction logic in main.on_print_start.\n\n    Hoisted here so the test can pin the contract: Bambu reports \"0\" and\n    empty string for local / non-cloud prints, both of which must collapse\n    to None so we don't match every non-cloud print to every other one.\n    \"\"\"\n    raw = data.get(\"raw_data\") or {}\n    val = raw.get(\"subtask_id\")\n    if val is None:\n        return None\n    val = str(val).strip()\n    if val in (\"\", \"0\"):\n        return None\n    return val\n\n\nclass TestSubtaskIdExtraction:\n    \"\"\"subtask_id extraction mirrors the in-handler logic.\"\"\"\n\n    def test_valid_id_returns_string(self):\n        assert _extract_subtask_id({\"raw_data\": {\"subtask_id\": \"12345\"}}) == \"12345\"\n\n    def test_zero_collapses_to_none(self):\n        \"\"\"Bambu reports '0' for local (non-cloud) prints; must not match anything.\"\"\"\n        assert _extract_subtask_id({\"raw_data\": {\"subtask_id\": \"0\"}}) is None\n\n    def test_empty_collapses_to_none(self):\n        assert _extract_subtask_id({\"raw_data\": {\"subtask_id\": \"\"}}) is None\n\n    def test_missing_raw_data(self):\n        assert _extract_subtask_id({}) is None\n\n    def test_missing_subtask_id(self):\n        assert _extract_subtask_id({\"raw_data\": {\"foo\": \"bar\"}}) is None\n\n    def test_integer_value_stringified(self):\n        \"\"\"MQTT may send the id as an int — coerce consistently.\"\"\"\n        assert _extract_subtask_id({\"raw_data\": {\"subtask_id\": 12345}}) == \"12345\"\n\n    def test_whitespace_trimmed(self):\n        assert _extract_subtask_id({\"raw_data\": {\"subtask_id\": \"  42  \"}}) == \"42\"\n\n\nclass TestSubtaskIdResume:\n    \"\"\"End-to-end DB behavior of the resume path: a second on_print_start\n    for the same subtask_id must find and reuse the first archive row.\"\"\"\n\n    @pytest.fixture\n    async def archive_factory(self, db_session, printer_factory):\n        printer = await printer_factory()\n\n        async def _create(\n            subtask_id: str | None = None,\n            status: str = \"printing\",\n            age_hours: float = 0,\n            failure_reason: str | None = None,\n        ):\n            started = datetime.now(timezone.utc) - timedelta(hours=age_hours)\n            archive = PrintArchive(\n                printer_id=printer.id,\n                filename=\"Broly_Legendary.gcode.3mf\",\n                file_path=\"archive/1/x/Broly.gcode.3mf\",\n                file_size=100,\n                print_name=\"Broly_Legendary\",\n                status=status,\n                started_at=started,\n                subtask_id=subtask_id,\n                failure_reason=failure_reason,\n            )\n            # Override server_default on created_at so age-based tests work\n            archive.created_at = started\n            db_session.add(archive)\n            await db_session.commit()\n            await db_session.refresh(archive)\n            return printer, archive\n\n        return _create\n\n    async def test_subtask_id_query_finds_matching_printing_row(self, archive_factory, db_session):\n        \"\"\"The lookup used by main.on_print_start finds a matching row even\n        when the archive is older than the 4h name-based staleness cutoff.\"\"\"\n        printer, archive = await archive_factory(subtask_id=\"t-123\", age_hours=10)\n\n        result = await db_session.execute(\n            select(PrintArchive)\n            .where(PrintArchive.printer_id == printer.id)\n            .where(PrintArchive.subtask_id == \"t-123\")\n            .where(PrintArchive.status.in_([\"printing\", \"cancelled\"]))\n            .order_by(PrintArchive.created_at.desc())\n            .limit(1)\n        )\n        found = result.scalar_one_or_none()\n        assert found is not None\n        assert found.id == archive.id\n\n    async def test_subtask_id_revives_stale_cancelled_row(self, archive_factory, db_session):\n        \"\"\"If an older Bambuddy wrongly cancelled the archive (legacy 4h path),\n        the next print-start with the same subtask_id must revive it rather\n        than start a third row.\"\"\"\n        printer, archive = await archive_factory(\n            subtask_id=\"t-456\",\n            status=\"cancelled\",\n            failure_reason=\"Stale - print likely cancelled or failed without status update\",\n            age_hours=10,\n        )\n\n        result = await db_session.execute(\n            select(PrintArchive)\n            .where(PrintArchive.printer_id == printer.id)\n            .where(PrintArchive.subtask_id == \"t-456\")\n            .where(PrintArchive.status.in_([\"printing\", \"cancelled\"]))\n            .order_by(PrintArchive.created_at.desc())\n            .limit(1)\n        )\n        candidate = result.scalar_one_or_none()\n        assert candidate is not None\n\n        # Revival mirrors the main.py logic: only revive stale-cancelled rows,\n        # not user-cancelled ones. The failure_reason prefix is the signal.\n        is_stale_cancelled = (candidate.failure_reason or \"\").startswith(\"Stale\")\n        assert is_stale_cancelled\n\n        candidate.status = \"printing\"\n        candidate.failure_reason = None\n        await db_session.commit()\n        await db_session.refresh(candidate)\n\n        assert candidate.status == \"printing\"\n        # Crucially, started_at is preserved — this is the whole point of the\n        # fix. A fresh archive would have started_at = now, losing continuity.\n        age_after = datetime.now(timezone.utc) - candidate.started_at.replace(tzinfo=timezone.utc)\n        assert age_after > timedelta(hours=9), \"started_at must survive revival\"\n\n    async def test_subtask_id_null_does_not_match_other_nulls(self, archive_factory, db_session):\n        \"\"\"Two different non-cloud prints both have subtask_id=NULL. They\n        must NOT match each other via the subtask_id lookup (which is why\n        the handler filters by `subtask_id IS NOT NULL` in the Python layer\n        before even running this query).\"\"\"\n        printer, _archive = await archive_factory(subtask_id=None, age_hours=1)\n\n        # This shape of query (subtask_id == None) would return rows via\n        # SQLAlchemy's NULL handling, but the handler only runs it when\n        # subtask_id is truthy — so the query is never issued for NULL.\n        # Assert the guard by testing the subtask_id != \"\" branch.\n        result = await db_session.execute(select(PrintArchive).where(PrintArchive.subtask_id == \"\"))\n        found = result.scalar_one_or_none()\n        assert found is None, \"Empty string must not match NULL rows\"\n\n    async def test_completed_archive_not_resumed(self, archive_factory, db_session):\n        \"\"\"A completed archive with the same subtask_id must not be reopened\n        as printing — that subtask's job is done; a new run is a new row.\"\"\"\n        printer, _ = await archive_factory(subtask_id=\"t-789\", status=\"completed\")\n\n        result = await db_session.execute(\n            select(PrintArchive)\n            .where(PrintArchive.printer_id == printer.id)\n            .where(PrintArchive.subtask_id == \"t-789\")\n            .where(PrintArchive.status.in_([\"printing\", \"cancelled\"]))\n        )\n        found = result.scalar_one_or_none()\n        assert found is None\n"
  },
  {
    "path": "backend/tests/unit/test_support_helpers.py",
    "content": "\"\"\"Unit tests for support module helper functions.\n\nTests _anonymize_mqtt_broker, _check_port, _get_container_memory_limit,\n_format_bytes, and _collect_support_info diagnostic sections.\n\"\"\"\n\nimport asyncio\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\n\nclass TestApplyLogLevel:\n    \"\"\"Tests for _apply_log_level() debug noise suppression.\"\"\"\n\n    def test_debug_mode_suppresses_sqlalchemy_to_warning(self):\n        \"\"\"Verify sqlalchemy.engine is set to WARNING (not INFO) in debug mode.\"\"\"\n        import logging\n\n        from backend.app.api.routes.support import _apply_log_level\n\n        _apply_log_level(True)\n\n        assert logging.getLogger(\"sqlalchemy.engine\").level == logging.WARNING\n\n    def test_debug_mode_suppresses_aiosqlite(self):\n        \"\"\"Verify aiosqlite is set to WARNING in debug mode to prevent cursor noise.\"\"\"\n        import logging\n\n        from backend.app.api.routes.support import _apply_log_level\n\n        _apply_log_level(True)\n\n        assert logging.getLogger(\"aiosqlite\").level == logging.WARNING\n\n    def test_debug_mode_keeps_httpx_pinned_to_warning(self):\n        \"\"\"httpx/httpcore must stay at WARNING even in debug mode — at INFO/DEBUG\n        they log full request URLs, leaking webhook tokens (Discord etc.).\"\"\"\n        import logging\n\n        from backend.app.api.routes.support import _apply_log_level\n\n        _apply_log_level(True)\n\n        assert logging.getLogger(\"httpcore\").level == logging.WARNING\n        assert logging.getLogger(\"httpx\").level == logging.WARNING\n\n    def test_non_debug_mode_suppresses_all_noisy_loggers(self):\n        \"\"\"Verify all noisy loggers are set to WARNING in non-debug mode.\"\"\"\n        import logging\n\n        from backend.app.api.routes.support import _apply_log_level\n\n        _apply_log_level(False)\n\n        assert logging.getLogger(\"sqlalchemy.engine\").level == logging.WARNING\n        assert logging.getLogger(\"httpcore\").level == logging.WARNING\n        assert logging.getLogger(\"httpx\").level == logging.WARNING\n        assert logging.getLogger(\"paho.mqtt\").level == logging.WARNING\n\n\nclass TestAnonymizeMqttBroker:\n    \"\"\"Tests for _anonymize_mqtt_broker().\"\"\"\n\n    def test_empty_string(self):\n        from backend.app.api.routes.support import _anonymize_mqtt_broker\n\n        assert _anonymize_mqtt_broker(\"\") == \"\"\n\n    def test_ipv4_address(self):\n        from backend.app.api.routes.support import _anonymize_mqtt_broker\n\n        assert _anonymize_mqtt_broker(\"192.168.1.100\") == \"[IP]\"\n\n    def test_ipv6_address(self):\n        from backend.app.api.routes.support import _anonymize_mqtt_broker\n\n        assert _anonymize_mqtt_broker(\"::1\") == \"[IP]\"\n\n    def test_hostname_with_domain(self):\n        from backend.app.api.routes.support import _anonymize_mqtt_broker\n\n        assert _anonymize_mqtt_broker(\"mqtt.example.com\") == \"*.example.com\"\n\n    def test_hostname_with_subdomain(self):\n        from backend.app.api.routes.support import _anonymize_mqtt_broker\n\n        assert _anonymize_mqtt_broker(\"broker.mqtt.example.com\") == \"*.example.com\"\n\n    def test_single_part_hostname(self):\n        from backend.app.api.routes.support import _anonymize_mqtt_broker\n\n        assert _anonymize_mqtt_broker(\"localhost\") == \"localhost\"\n\n\nclass TestCheckPort:\n    \"\"\"Tests for _check_port().\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_reachable_port(self):\n        from backend.app.api.routes.support import _check_port\n\n        # Mock a successful connection\n        mock_writer = AsyncMock()\n        mock_writer.close = MagicMock()\n        mock_writer.wait_closed = AsyncMock()\n\n        with patch(\"backend.app.api.routes.support.asyncio.open_connection\", return_value=(AsyncMock(), mock_writer)):\n            result = await _check_port(\"192.168.1.1\", 8883, timeout=1.0)\n\n        assert result is True\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_unreachable_port(self):\n        from backend.app.api.routes.support import _check_port\n\n        with (\n            patch(\n                \"backend.app.api.routes.support.asyncio.open_connection\",\n                side_effect=ConnectionRefusedError,\n            ),\n            patch(\n                \"backend.app.api.routes.support.asyncio.wait_for\",\n                side_effect=ConnectionRefusedError,\n            ),\n        ):\n            result = await _check_port(\"192.168.1.1\", 8883, timeout=1.0)\n\n        assert result is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_timeout(self):\n        from backend.app.api.routes.support import _check_port\n\n        with patch(\n            \"backend.app.api.routes.support.asyncio.wait_for\",\n            side_effect=asyncio.TimeoutError,\n        ):\n            result = await _check_port(\"192.168.1.1\", 8883, timeout=0.1)\n\n        assert result is False\n\n\nclass TestGetContainerMemoryLimit:\n    \"\"\"Tests for _get_container_memory_limit().\"\"\"\n\n    def test_cgroup_v2_with_limit(self):\n        from backend.app.api.routes.support import _get_container_memory_limit\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            v2_path = Path(tmpdir) / \"memory.max\"\n            v2_path.write_text(\"1073741824\\n\")\n\n            with patch(\"backend.app.api.routes.support.Path\") as mock_path:\n                # v2 path exists with value\n                v2_mock = MagicMock()\n                v2_mock.exists.return_value = True\n                v2_mock.read_text.return_value = \"1073741824\\n\"\n\n                v1_mock = MagicMock()\n                v1_mock.exists.return_value = False\n\n                mock_path.side_effect = lambda p: v2_mock if \"memory.max\" in p else v1_mock\n\n                result = _get_container_memory_limit()\n\n        assert result == 1073741824\n\n    def test_cgroup_v2_unlimited(self):\n        from backend.app.api.routes.support import _get_container_memory_limit\n\n        with patch(\"backend.app.api.routes.support.Path\") as mock_path:\n            v2_mock = MagicMock()\n            v2_mock.exists.return_value = True\n            v2_mock.read_text.return_value = \"max\\n\"\n\n            v1_mock = MagicMock()\n            v1_mock.exists.return_value = False\n\n            mock_path.side_effect = lambda p: v2_mock if \"memory.max\" in p else v1_mock\n\n            result = _get_container_memory_limit()\n\n        assert result is None\n\n    def test_no_cgroup_files(self):\n        from backend.app.api.routes.support import _get_container_memory_limit\n\n        with patch(\"backend.app.api.routes.support.Path\") as mock_path:\n            mock_instance = MagicMock()\n            mock_instance.exists.return_value = False\n            mock_path.return_value = mock_instance\n\n            result = _get_container_memory_limit()\n\n        assert result is None\n\n\nclass TestFormatBytes:\n    \"\"\"Tests for _format_bytes().\"\"\"\n\n    def test_bytes(self):\n        from backend.app.api.routes.support import _format_bytes\n\n        assert _format_bytes(500) == \"500 B\"\n\n    def test_kilobytes(self):\n        from backend.app.api.routes.support import _format_bytes\n\n        assert _format_bytes(2048) == \"2.0 KB\"\n\n    def test_megabytes(self):\n        from backend.app.api.routes.support import _format_bytes\n\n        assert _format_bytes(10 * 1024 * 1024) == \"10.0 MB\"\n\n    def test_gigabytes(self):\n        from backend.app.api.routes.support import _format_bytes\n\n        assert _format_bytes(2 * 1024 * 1024 * 1024) == \"2.00 GB\"\n\n    def test_zero(self):\n        from backend.app.api.routes.support import _format_bytes\n\n        assert _format_bytes(0) == \"0 B\"\n\n\nclass TestSanitizeLogContent:\n    \"\"\"Tests for _sanitize_log_content() redaction.\"\"\"\n\n    def test_ipv4_addresses_redacted(self):\n        \"\"\"IPv4 addresses in log lines are replaced with [IP].\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"2024-01-15 Connected to printer at 192.168.1.100 on port 8883\"\n        result = _sanitize_log_content(content)\n        assert \"192.168.1.100\" not in result\n        assert \"[IP]\" in result\n        assert \"on port 8883\" in result\n\n    def test_multiple_ipv4_addresses_redacted(self):\n        \"\"\"Multiple different IPs in the same line are all redacted.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Proxy 10.0.0.1 -> 192.168.1.50\"\n        result = _sanitize_log_content(content)\n        assert result == \"Proxy [IP] -> [IP]\"\n\n    def test_firmware_versions_with_leading_zeros_preserved(self):\n        \"\"\"Firmware versions like 01.09.01.00 have leading zeros and should NOT be redacted.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Firmware version: 01.09.01.00\"\n        result = _sanitize_log_content(content)\n        assert \"01.09.01.00\" in result\n\n    def test_firmware_version_mixed_with_ip(self):\n        \"\"\"Firmware versions preserved while real IPs are redacted in the same line.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Printer at 192.168.1.5 running firmware 01.07.02.00\"\n        result = _sanitize_log_content(content)\n        assert \"192.168.1.5\" not in result\n        assert \"01.07.02.00\" in result\n        assert \"[IP] running firmware 01.07.02.00\" in result\n\n    def test_printer_ip_from_sensitive_strings(self):\n        \"\"\"Printer IPs in sensitive_strings are replaced before regex pass.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Connecting to 192.168.1.100\"\n        result = _sanitize_log_content(content, sensitive_strings={\"192.168.1.100\": \"[IP]\"})\n        assert result == \"Connecting to [IP]\"\n\n    def test_edge_case_zero_ip(self):\n        \"\"\"0.0.0.0 is a valid IP and should be redacted.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Binding to 0.0.0.0\"\n        result = _sanitize_log_content(content)\n        assert result == \"Binding to [IP]\"\n\n    def test_edge_case_broadcast_ip(self):\n        \"\"\"255.255.255.255 is a valid IP and should be redacted.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Broadcast to 255.255.255.255\"\n        result = _sanitize_log_content(content)\n        assert result == \"Broadcast to [IP]\"\n\n    def test_invalid_octet_not_redacted(self):\n        \"\"\"Octets >255 are not valid IPs and should not be redacted.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Value 999.999.999.999\"\n        result = _sanitize_log_content(content)\n        assert \"999.999.999.999\" in result\n\n    def test_existing_serial_redaction_still_works(self):\n        \"\"\"Serial number redaction still functions alongside IP redaction.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Printer 01SABCDEF1234 at 10.0.0.5\"\n        result = _sanitize_log_content(content)\n        assert \"[SERIAL]\" in result\n        assert \"[IP]\" in result\n        assert \"01SABCDEF1234\" not in result\n        assert \"10.0.0.5\" not in result\n\n    def test_existing_email_redaction_still_works(self):\n        \"\"\"Email redaction still functions alongside IP redaction.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"User user@example.com from 172.16.0.1\"\n        result = _sanitize_log_content(content)\n        assert \"[EMAIL]\" in result\n        assert \"[IP]\" in result\n\n    def test_existing_path_redaction_still_works(self):\n        \"\"\"Path redaction still functions alongside IP redaction.\"\"\"\n        from backend.app.api.routes.support import _sanitize_log_content\n\n        content = \"Config at /home/john/config.yaml from 192.168.0.1\"\n        result = _sanitize_log_content(content)\n        assert \"/home/[user]/\" in result\n        assert \"[IP]\" in result\n\n\nclass TestCollectSupportInfo:\n    \"\"\"Tests for _collect_support_info() new diagnostic sections.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_environment_has_timezone(self):\n        \"\"\"Verify environment section includes timezone.\"\"\"\n        from backend.app.api.routes.support import _collect_support_info\n\n        with (\n            patch(\"backend.app.api.routes.support.is_running_in_docker\", return_value=False),\n            patch(\"backend.app.api.routes.support.async_session\") as mock_session_ctx,\n            patch(\"backend.app.api.routes.support.printer_manager\") as mock_pm,\n            patch(\"backend.app.api.routes.support.get_network_interfaces\", return_value=[]),\n            patch(\"backend.app.api.routes.support.ws_manager\") as mock_ws,\n            patch.dict(\"os.environ\", {\"TZ\": \"America/New_York\"}),\n        ):\n            mock_pm.get_all_statuses.return_value = {}\n            mock_ws.active_connections = []\n\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar.return_value = 0\n            mock_result.scalar_one_or_none.return_value = None\n            mock_result.scalars.return_value.all.return_value = []\n            mock_db.execute = AsyncMock(return_value=mock_result)\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            info = await _collect_support_info()\n\n        assert info[\"environment\"][\"timezone\"] == \"America/New_York\"\n        assert info[\"environment\"][\"docker\"] is False\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_docker_section_present_when_in_docker(self):\n        \"\"\"Verify docker section is added when running in Docker.\"\"\"\n        from backend.app.api.routes.support import _collect_support_info\n\n        with (\n            patch(\"backend.app.api.routes.support.is_running_in_docker\", return_value=True),\n            patch(\"backend.app.api.routes.support._get_container_memory_limit\", return_value=1073741824),\n            patch(\"backend.app.api.routes.support._detect_docker_network_mode\", return_value=\"bridge\"),\n            patch(\"backend.app.api.routes.support.async_session\") as mock_session_ctx,\n            patch(\"backend.app.api.routes.support.printer_manager\") as mock_pm,\n            patch(\n                \"backend.app.api.routes.support.get_network_interfaces\",\n                return_value=[{\"name\": \"eth0\", \"subnet\": \"172.17.0.0/16\"}],\n            ),\n            patch(\"backend.app.api.routes.support.ws_manager\") as mock_ws,\n        ):\n            mock_pm.get_all_statuses.return_value = {}\n            mock_ws.active_connections = []\n\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar.return_value = 0\n            mock_result.scalar_one_or_none.return_value = None\n            mock_result.scalars.return_value.all.return_value = []\n            mock_db.execute = AsyncMock(return_value=mock_result)\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            info = await _collect_support_info()\n\n        assert \"docker\" in info\n        assert info[\"docker\"][\"container_memory_limit_bytes\"] == 1073741824\n        assert info[\"docker\"][\"container_memory_limit_formatted\"] == \"1.00 GB\"\n        assert info[\"docker\"][\"network_mode_hint\"] == \"bridge\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_docker_section_absent_when_not_docker(self):\n        \"\"\"Verify docker section is absent when not in Docker.\"\"\"\n        from backend.app.api.routes.support import _collect_support_info\n\n        with (\n            patch(\"backend.app.api.routes.support.is_running_in_docker\", return_value=False),\n            patch(\"backend.app.api.routes.support.async_session\") as mock_session_ctx,\n            patch(\"backend.app.api.routes.support.printer_manager\") as mock_pm,\n            patch(\"backend.app.api.routes.support.get_network_interfaces\", return_value=[]),\n            patch(\"backend.app.api.routes.support.ws_manager\") as mock_ws,\n        ):\n            mock_pm.get_all_statuses.return_value = {}\n            mock_ws.active_connections = []\n\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar.return_value = 0\n            mock_result.scalar_one_or_none.return_value = None\n            mock_result.scalars.return_value.all.return_value = []\n            mock_db.execute = AsyncMock(return_value=mock_result)\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            info = await _collect_support_info()\n\n        assert \"docker\" not in info\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_dependencies_section(self):\n        \"\"\"Verify dependencies section lists package versions.\"\"\"\n        from backend.app.api.routes.support import _collect_support_info\n\n        with (\n            patch(\"backend.app.api.routes.support.is_running_in_docker\", return_value=False),\n            patch(\"backend.app.api.routes.support.async_session\") as mock_session_ctx,\n            patch(\"backend.app.api.routes.support.printer_manager\") as mock_pm,\n            patch(\"backend.app.api.routes.support.get_network_interfaces\", return_value=[]),\n            patch(\"backend.app.api.routes.support.ws_manager\") as mock_ws,\n        ):\n            mock_pm.get_all_statuses.return_value = {}\n            mock_ws.active_connections = []\n\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar.return_value = 0\n            mock_result.scalar_one_or_none.return_value = None\n            mock_result.scalars.return_value.all.return_value = []\n            mock_db.execute = AsyncMock(return_value=mock_result)\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            info = await _collect_support_info()\n\n        assert \"dependencies\" in info\n        # fastapi should be installed in test environment\n        assert \"fastapi\" in info[\"dependencies\"]\n        assert info[\"dependencies\"][\"fastapi\"] is not None\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_websockets_section(self):\n        \"\"\"Verify websockets section shows connection count.\"\"\"\n        from backend.app.api.routes.support import _collect_support_info\n\n        with (\n            patch(\"backend.app.api.routes.support.is_running_in_docker\", return_value=False),\n            patch(\"backend.app.api.routes.support.async_session\") as mock_session_ctx,\n            patch(\"backend.app.api.routes.support.printer_manager\") as mock_pm,\n            patch(\"backend.app.api.routes.support.get_network_interfaces\", return_value=[]),\n            patch(\"backend.app.api.routes.support.ws_manager\") as mock_ws,\n        ):\n            mock_pm.get_all_statuses.return_value = {}\n            mock_ws.active_connections = [\"conn1\", \"conn2\"]\n\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar.return_value = 0\n            mock_result.scalar_one_or_none.return_value = None\n            mock_result.scalars.return_value.all.return_value = []\n            mock_db.execute = AsyncMock(return_value=mock_result)\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            info = await _collect_support_info()\n\n        assert info[\"websockets\"][\"active_connections\"] == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_network_section(self):\n        \"\"\"Verify network section shows interface subnets.\"\"\"\n        from backend.app.api.routes.support import _collect_support_info\n\n        mock_interfaces = [\n            {\"name\": \"eth0\", \"ip\": \"192.168.1.100\", \"netmask\": \"255.255.255.0\", \"subnet\": \"192.168.1.0/24\"},\n            {\"name\": \"wlan0\", \"ip\": \"10.0.0.50\", \"netmask\": \"255.255.255.0\", \"subnet\": \"10.0.0.0/24\"},\n        ]\n\n        with (\n            patch(\"backend.app.api.routes.support.is_running_in_docker\", return_value=False),\n            patch(\"backend.app.api.routes.support.async_session\") as mock_session_ctx,\n            patch(\"backend.app.api.routes.support.printer_manager\") as mock_pm,\n            patch(\"backend.app.api.routes.support.get_network_interfaces\", return_value=mock_interfaces),\n            patch(\"backend.app.api.routes.support.ws_manager\") as mock_ws,\n        ):\n            mock_pm.get_all_statuses.return_value = {}\n            mock_ws.active_connections = []\n\n            mock_db = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar.return_value = 0\n            mock_result.scalar_one_or_none.return_value = None\n            mock_result.scalars.return_value.all.return_value = []\n            mock_db.execute = AsyncMock(return_value=mock_result)\n\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            info = await _collect_support_info()\n\n        assert info[\"network\"][\"interface_count\"] == 2\n        assert info[\"network\"][\"interfaces\"][0][\"name\"] == \"eth0\"\n        assert info[\"network\"][\"interfaces\"][0][\"subnet\"] == \"x.x.1.0/24\"\n        # Verify IP addresses are NOT included (first two octets masked)\n        for iface in info[\"network\"][\"interfaces\"]:\n            assert \"ip\" not in iface\n            assert iface[\"subnet\"].startswith(\"x.x.\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_log_file_section(self):\n        \"\"\"Verify log file section shows size info.\"\"\"\n        from backend.app.api.routes.support import _collect_support_info\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            log_dir = Path(tmpdir)\n            log_file = log_dir / \"bambuddy.log\"\n            log_file.write_text(\"some log content\\n\" * 100)\n\n            with (\n                patch(\"backend.app.api.routes.support.is_running_in_docker\", return_value=False),\n                patch(\"backend.app.api.routes.support.async_session\") as mock_session_ctx,\n                patch(\"backend.app.api.routes.support.printer_manager\") as mock_pm,\n                patch(\"backend.app.api.routes.support.get_network_interfaces\", return_value=[]),\n                patch(\"backend.app.api.routes.support.ws_manager\") as mock_ws,\n                patch(\"backend.app.api.routes.support.settings\") as mock_settings,\n            ):\n                mock_settings.base_dir = Path(tmpdir)\n                mock_settings.log_dir = log_dir\n                mock_settings.debug = False\n                mock_pm.get_all_statuses.return_value = {}\n                mock_ws.active_connections = []\n\n                mock_db = AsyncMock()\n                mock_result = MagicMock()\n                mock_result.scalar.return_value = 0\n                mock_result.scalar_one_or_none.return_value = None\n                mock_result.scalars.return_value.all.return_value = []\n                mock_db.execute = AsyncMock(return_value=mock_result)\n\n                mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n                mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n                info = await _collect_support_info()\n\n        assert \"log_file\" in info\n        assert info[\"log_file\"][\"size_bytes\"] > 0\n        assert \"B\" in info[\"log_file\"][\"size_formatted\"] or \"KB\" in info[\"log_file\"][\"size_formatted\"]\n\n    @pytest.mark.asyncio\n    @pytest.mark.unit\n    async def test_settings_include_all_keys_with_sensitive_redacted(self):\n        \"\"\"All settings keys must appear in output; sensitive values are replaced with [REDACTED].\"\"\"\n        from backend.app.api.routes.support import _collect_support_info\n\n        fake_settings = [\n            MagicMock(key=\"benign_flag\", value=\"true\"),\n            MagicMock(key=\"bambu_cloud_token\", value=\"super-secret\"),\n            MagicMock(key=\"github_webhook\", value=\"https://hooks.example/abc\"),\n            MagicMock(key=\"empty_password\", value=\"\"),\n            MagicMock(key=\"local_backup_path\", value=\"/data/backups\"),\n        ]\n\n        def make_result(rows=None):\n            r = MagicMock()\n            r.scalar.return_value = 0\n            r.scalar_one_or_none.return_value = None\n            r.scalars.return_value.all.return_value = rows or []\n            r.all.return_value = []\n            return r\n\n        async def fake_execute(stmt, *_a, **_kw):\n            sql = str(stmt).lower()\n            # Route by table name in the compiled SQL\n            if \"from settings\" in sql or \"settings.key\" in sql:\n                return make_result(fake_settings)\n            return make_result([])\n\n        with (\n            tempfile.TemporaryDirectory() as tmpdir,\n            patch(\"backend.app.api.routes.support.is_running_in_docker\", return_value=False),\n            patch(\"backend.app.api.routes.support.async_session\") as mock_session_ctx,\n            patch(\"backend.app.api.routes.support.printer_manager\") as mock_pm,\n            patch(\"backend.app.api.routes.support.get_network_interfaces\", return_value=[]),\n            patch(\"backend.app.api.routes.support.ws_manager\") as mock_ws,\n            patch(\"backend.app.api.routes.support.settings\") as mock_settings,\n        ):\n            mock_settings.base_dir = Path(tmpdir)\n            mock_settings.log_dir = Path(tmpdir)\n            mock_settings.debug = False\n            mock_pm.get_all_statuses.return_value = {}\n            mock_ws.active_connections = []\n\n            mock_db = AsyncMock()\n            mock_db.execute = fake_execute\n            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)\n            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            info = await _collect_support_info()\n\n        s = info[\"settings\"]\n        assert s.get(\"bambu_cloud_token\") == \"[REDACTED]\"\n        assert s.get(\"github_webhook\") == \"[REDACTED]\"\n        assert s.get(\"local_backup_path\") == \"[REDACTED]\"\n        assert s.get(\"empty_password\") == \"\"\n        assert s.get(\"benign_flag\") == \"true\"\n"
  },
  {
    "path": "backend/tests/unit/test_sync_ams_weights.py",
    "content": "\"\"\"Unit tests for the AMS weight sync calculation logic.\n\nTests the weight_used calculation and remain% validation extracted from\nthe POST /inventory/sync-ams-weights endpoint, without requiring a database.\n\"\"\"\n\nimport pytest\n\nfrom backend.app.api.routes.inventory import _find_tray_in_ams_data\n\n\ndef _calc_weight_used(label_weight: int | None, remain: int) -> float:\n    \"\"\"Reproduce the weight calculation from sync_weights_from_ams.\"\"\"\n    lw = label_weight or 1000\n    return round(lw * (100 - remain) / 100.0, 1)\n\n\ndef _is_valid_remain(remain_raw) -> tuple[bool, int]:\n    \"\"\"Reproduce the remain% validation from sync_weights_from_ams.\n\n    Returns (is_valid, parsed_value).  parsed_value is only meaningful\n    when is_valid is True.\n    \"\"\"\n    if remain_raw is None:\n        return False, 0\n    try:\n        val = int(remain_raw)\n    except (TypeError, ValueError):\n        return False, 0\n    if val < 0 or val > 100:\n        return False, val\n    return True, val\n\n\nclass TestWeightCalculation:\n    \"\"\"Test the weight_used = label_weight * (100 - remain) / 100 formula.\"\"\"\n\n    def test_remain_100_means_no_usage(self):\n        \"\"\"A full spool (remain=100) should have weight_used=0.\"\"\"\n        assert _calc_weight_used(1000, 100) == 0.0\n\n    def test_remain_50_with_1000g_spool(self):\n        \"\"\"Half-used 1000g spool should have weight_used=500.\"\"\"\n        assert _calc_weight_used(1000, 50) == 500.0\n\n    def test_remain_0_means_fully_used(self):\n        \"\"\"An empty spool (remain=0) should have weight_used equal to label_weight.\n\n        Unlike the on_ams_change guard, the sync endpoint processes remain=0\n        since it is a manual recovery tool.\n        \"\"\"\n        assert _calc_weight_used(1000, 0) == 1000.0\n\n    def test_respects_label_weight_500g(self):\n        \"\"\"500g spool at remain=50 should have weight_used=250.\"\"\"\n        assert _calc_weight_used(500, 50) == 250.0\n\n    def test_respects_label_weight_250g(self):\n        \"\"\"250g spool at remain=75 should have weight_used=62.5.\"\"\"\n        assert _calc_weight_used(250, 75) == 62.5\n\n    def test_none_label_weight_defaults_to_1000(self):\n        \"\"\"When label_weight is None, it defaults to 1000g.\"\"\"\n        assert _calc_weight_used(None, 50) == 500.0\n\n    def test_result_is_rounded_to_one_decimal(self):\n        \"\"\"Weight used should be rounded to 1 decimal place.\n\n        For a 1000g spool at remain=33, weight_used = 1000 * 67 / 100 = 670.0\n        \"\"\"\n        assert _calc_weight_used(1000, 33) == 670.0\n\n    def test_odd_fraction_rounds_correctly(self):\n        \"\"\"750g spool at remain=33 → 750 * 67/100 = 502.5.\"\"\"\n        assert _calc_weight_used(750, 33) == 502.5\n\n    def test_small_spool_small_remain(self):\n        \"\"\"200g spool at remain=1 → 200 * 99/100 = 198.0.\"\"\"\n        assert _calc_weight_used(200, 1) == 198.0\n\n\nclass TestRemainValidation:\n    \"\"\"Test the remain% bounds and type validation.\"\"\"\n\n    def test_remain_minus_1_is_invalid(self):\n        \"\"\"remain=-1 (firmware 'unknown') should be skipped.\"\"\"\n        valid, _ = _is_valid_remain(-1)\n        assert valid is False\n\n    def test_remain_101_is_invalid(self):\n        \"\"\"remain=101 (out of range) should be skipped.\"\"\"\n        valid, _ = _is_valid_remain(101)\n        assert valid is False\n\n    def test_remain_negative_large_is_invalid(self):\n        \"\"\"Large negative remain values should be skipped.\"\"\"\n        valid, _ = _is_valid_remain(-50)\n        assert valid is False\n\n    def test_remain_200_is_invalid(self):\n        \"\"\"remain=200 should be skipped.\"\"\"\n        valid, _ = _is_valid_remain(200)\n        assert valid is False\n\n    def test_remain_none_is_invalid(self):\n        \"\"\"remain=None (missing from tray data) should be skipped.\"\"\"\n        valid, _ = _is_valid_remain(None)\n        assert valid is False\n\n    def test_remain_non_numeric_string_is_invalid(self):\n        \"\"\"Non-numeric string remain should be skipped.\"\"\"\n        valid, _ = _is_valid_remain(\"abc\")\n        assert valid is False\n\n    def test_remain_0_is_valid(self):\n        \"\"\"remain=0 should be valid (manual recovery handles empty spools).\"\"\"\n        valid, val = _is_valid_remain(0)\n        assert valid is True\n        assert val == 0\n\n    def test_remain_100_is_valid(self):\n        \"\"\"remain=100 should be valid.\"\"\"\n        valid, val = _is_valid_remain(100)\n        assert valid is True\n        assert val == 100\n\n    def test_remain_50_is_valid(self):\n        \"\"\"remain=50 should be valid.\"\"\"\n        valid, val = _is_valid_remain(50)\n        assert valid is True\n        assert val == 50\n\n    def test_remain_string_number_is_valid(self):\n        \"\"\"Numeric string remain (e.g. '75') should be parsed as int.\"\"\"\n        valid, val = _is_valid_remain(\"75\")\n        assert valid is True\n        assert val == 75\n\n\nclass TestFindTrayInAmsData:\n    \"\"\"Test the _find_tray_in_ams_data helper used by the sync endpoint.\"\"\"\n\n    def test_finds_matching_tray(self):\n        \"\"\"Should return the matching tray dict.\"\"\"\n        ams_data = [\n            {\n                \"id\": 0,\n                \"tray\": [\n                    {\"id\": 0, \"remain\": 80},\n                    {\"id\": 1, \"remain\": 50},\n                ],\n            },\n        ]\n        tray = _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=1)\n        assert tray is not None\n        assert tray[\"remain\"] == 50\n\n    def test_returns_none_for_missing_ams_unit(self):\n        \"\"\"Should return None when the AMS unit ID is not found.\"\"\"\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]\n        assert _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=0) is None\n\n    def test_returns_none_for_missing_tray(self):\n        \"\"\"Should return None when the tray ID is not found.\"\"\"\n        ams_data = [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]\n        assert _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=3) is None\n\n    def test_returns_none_for_empty_data(self):\n        \"\"\"Should return None for empty AMS data.\"\"\"\n        assert _find_tray_in_ams_data([], ams_id=0, tray_id=0) is None\n\n    def test_returns_none_for_none_data(self):\n        \"\"\"Should return None for None AMS data.\"\"\"\n        assert _find_tray_in_ams_data(None, ams_id=0, tray_id=0) is None\n\n    def test_multi_ams_unit_lookup(self):\n        \"\"\"Should find trays across multiple AMS units.\"\"\"\n        ams_data = [\n            {\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]},\n            {\"id\": 1, \"tray\": [{\"id\": 2, \"remain\": 30}]},\n        ]\n        tray = _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=2)\n        assert tray is not None\n        assert tray[\"remain\"] == 30\n\n    def test_ams_ht_high_id(self):\n        \"\"\"Should find trays in AMS-HT units (id >= 128).\"\"\"\n        ams_data = [{\"id\": 128, \"tray\": [{\"id\": 0, \"remain\": 65}]}]\n        tray = _find_tray_in_ams_data(ams_data, ams_id=128, tray_id=0)\n        assert tray is not None\n        assert tray[\"remain\"] == 65\n\n\nclass TestSyncSkipLogic:\n    \"\"\"Test combinations that exercise the sync/skip decision path.\"\"\"\n\n    def test_same_value_is_skipped(self):\n        \"\"\"When old weight_used matches new, the spool is skipped (no DB write).\"\"\"\n        # Simulating the endpoint logic: if round(old_used, 1) == new_used → skip\n        label_weight = 1000\n        remain = 50\n        new_used = _calc_weight_used(label_weight, remain)\n        old_used = 500.0  # Already matches\n        assert round(old_used, 1) == new_used  # → would be skipped\n\n    def test_different_value_is_synced(self):\n        \"\"\"When old weight_used differs from new, the spool is synced.\"\"\"\n        label_weight = 1000\n        remain = 50\n        new_used = _calc_weight_used(label_weight, remain)\n        old_used = 300.0  # Different\n        assert round(old_used, 1) != new_used  # → would be synced\n\n    def test_none_old_used_treated_as_zero(self):\n        \"\"\"When old weight_used is None (new spool), it defaults to 0.\"\"\"\n        old_used = None\n        effective_old = old_used or 0\n        new_used = _calc_weight_used(1000, 80)  # 200.0\n        assert effective_old == 0\n        assert round(effective_old, 1) != new_used  # → would be synced\n\n    def test_remain_0_synced_not_skipped(self):\n        \"\"\"remain=0 is valid and produces weight_used=label_weight.\n\n        This is distinct from on_ams_change behavior where remain=0 is\n        ignored.  The sync endpoint processes it as a manual recovery tool.\n        \"\"\"\n        valid, val = _is_valid_remain(0)\n        assert valid is True\n        new_used = _calc_weight_used(1000, val)\n        assert new_used == 1000.0\n\n    def test_remain_minus_1_never_reaches_calc(self):\n        \"\"\"remain=-1 fails validation before weight calculation.\"\"\"\n        valid, _ = _is_valid_remain(-1)\n        assert valid is False\n        # The endpoint would skip += 1 and continue\n\n    def test_remain_101_never_reaches_calc(self):\n        \"\"\"remain=101 fails validation before weight calculation.\"\"\"\n        valid, _ = _is_valid_remain(101)\n        assert valid is False\n"
  },
  {
    "path": "backend/tests/unit/test_threemf_tools.py",
    "content": "\"\"\"Unit tests for 3MF parsing utilities (threemf_tools.py).\n\nTests G-code parsing, filament length-to-weight conversion,\nand cumulative layer usage lookup.\n\"\"\"\n\nimport io\nimport math\nimport zipfile\n\nfrom backend.app.utils.threemf_tools import (\n    extract_filament_usage_from_3mf,\n    get_cumulative_usage_at_layer,\n    mm_to_grams,\n    parse_gcode_layer_filament_usage,\n)\n\n\ndef create_mock_3mf(slice_info_content: str) -> io.BytesIO:\n    \"\"\"Create a mock 3MF file (ZIP) with slice_info.config content.\"\"\"\n    buffer = io.BytesIO()\n    with zipfile.ZipFile(buffer, \"w\") as zf:\n        zf.writestr(\"Metadata/slice_info.config\", slice_info_content)\n    buffer.seek(0)\n    return buffer\n\n\nclass TestParseGcodeLayerFilamentUsage:\n    \"\"\"Tests for parse_gcode_layer_filament_usage().\"\"\"\n\n    def test_single_filament_single_layer(self):\n        \"\"\"Single filament extruding on one layer.\"\"\"\n        gcode = \"\"\"\nM620 S0\nG1 X10 Y10 E5.0\nG1 X20 Y20 E3.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result == {0: {0: 8.0}}\n\n    def test_multi_layer_single_filament(self):\n        \"\"\"Single filament across multiple layers.\"\"\"\n        gcode = \"\"\"\nM620 S0\nG1 X10 Y10 E10.0\nM73 L1\nG1 X20 Y20 E5.0\nM73 L2\nG1 X30 Y30 E7.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result[0] == {0: 10.0}\n        assert result[1] == {0: 15.0}\n        assert result[2] == {0: 22.0}\n\n    def test_multi_material(self):\n        \"\"\"Multiple filaments switching via M620.\"\"\"\n        gcode = \"\"\"\nM620 S0\nG1 E10.0\nM73 L1\nM620 S1\nG1 E5.0\nM620 S0\nG1 E3.0\nM73 L2\nG1 E2.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        # Layer 0: filament 0 = 10mm\n        assert result[0] == {0: 10.0}\n        # Layer 1: filament 0 = 13mm (10+3), filament 1 = 5mm\n        assert result[1] == {0: 13.0, 1: 5.0}\n        # Layer 2: filament 0 = 15mm (13+2)\n        assert result[2] == {0: 15.0, 1: 5.0}\n\n    def test_retractions_ignored(self):\n        \"\"\"Negative E values (retractions) should be ignored.\"\"\"\n        gcode = \"\"\"\nM620 S0\nG1 E10.0\nG1 E-2.0\nG1 E5.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result == {0: {0: 15.0}}\n\n    def test_m620_s255_unloads(self):\n        \"\"\"M620 S255 means unload - extrusion after should be ignored.\"\"\"\n        gcode = \"\"\"\nM620 S0\nG1 E10.0\nM620 S255\nG1 E5.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result == {0: {0: 10.0}}\n\n    def test_m620_with_suffix(self):\n        \"\"\"M620 S0A format (filament ID with suffix letter).\"\"\"\n        gcode = \"\"\"\nM620 S0A\nG1 E10.0\nM620 S1A\nG1 E5.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result == {0: {0: 10.0, 1: 5.0}}\n\n    def test_comments_ignored(self):\n        \"\"\"Comment lines and inline comments are ignored.\"\"\"\n        gcode = \"\"\"\n; This is a comment\nM620 S0\nG1 X10 E5.0 ; inline comment with E value\nG1 E3.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result == {0: {0: 8.0}}\n\n    def test_empty_gcode(self):\n        \"\"\"Empty G-code returns empty dict.\"\"\"\n        assert parse_gcode_layer_filament_usage(\"\") == {}\n        assert parse_gcode_layer_filament_usage(\"\\n\\n\\n\") == {}\n\n    def test_no_extrusion(self):\n        \"\"\"G-code with moves but no extrusion.\"\"\"\n        gcode = \"\"\"\nG1 X10 Y10\nG1 X20 Y20\n\"\"\"\n        assert parse_gcode_layer_filament_usage(gcode) == {}\n\n    def test_no_active_filament_extrusion_ignored(self):\n        \"\"\"Extrusion before any M620 is ignored (no active filament).\"\"\"\n        gcode = \"\"\"\nG1 E10.0\nM620 S0\nG1 E5.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result == {0: {0: 5.0}}\n\n    def test_g0_g2_g3_extrusion(self):\n        \"\"\"G0, G2, G3 with E parameter are also tracked.\"\"\"\n        gcode = \"\"\"\nM620 S0\nG0 E1.0\nG1 E2.0\nG2 E3.0\nG3 E4.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result == {0: {0: 10.0}}\n\n    def test_cumulative_across_layers(self):\n        \"\"\"Values are cumulative, not per-layer.\"\"\"\n        gcode = \"\"\"\nM620 S0\nG1 E100.0\nM73 L1\nG1 E100.0\nM73 L2\nG1 E100.0\n\"\"\"\n        result = parse_gcode_layer_filament_usage(gcode)\n        assert result[0] == {0: 100.0}\n        assert result[1] == {0: 200.0}\n        assert result[2] == {0: 300.0}\n\n\nclass TestMmToGrams:\n    \"\"\"Tests for mm_to_grams().\"\"\"\n\n    def test_default_pla_175(self):\n        \"\"\"Default PLA 1.75mm conversion.\"\"\"\n        # 1000mm of 1.75mm PLA at 1.24 g/cm³\n        # Volume = π × (0.0875cm)² × 100cm = 2.405cm³\n        # Weight = 2.405 × 1.24 = 2.982g\n        result = mm_to_grams(1000.0)\n        expected = math.pi * (0.0875**2) * 100 * 1.24\n        assert abs(result - expected) < 0.001\n\n    def test_zero_length(self):\n        \"\"\"Zero length returns zero weight.\"\"\"\n        assert mm_to_grams(0.0) == 0.0\n\n    def test_custom_diameter(self):\n        \"\"\"Custom diameter (2.85mm) changes result.\"\"\"\n        result_175 = mm_to_grams(1000.0, diameter_mm=1.75)\n        result_285 = mm_to_grams(1000.0, diameter_mm=2.85)\n        # 2.85mm filament has more volume per mm\n        assert result_285 > result_175\n        ratio = (2.85 / 1.75) ** 2  # Volume scales with diameter²\n        assert abs(result_285 / result_175 - ratio) < 0.001\n\n    def test_custom_density(self):\n        \"\"\"Different density (ABS vs PLA).\"\"\"\n        pla = mm_to_grams(1000.0, density_g_cm3=1.24)\n        abs_ = mm_to_grams(1000.0, density_g_cm3=1.04)\n        assert pla > abs_\n        assert abs(pla / abs_ - 1.24 / 1.04) < 0.001\n\n    def test_known_value(self):\n        \"\"\"Verify against a known calculation.\n\n        1m (1000mm) of 1.75mm PLA at 1.24 g/cm³:\n        r = 0.0875 cm, L = 100 cm\n        V = π × 0.0875² × 100 = 2.4053 cm³\n        m = 2.4053 × 1.24 = 2.9826 g\n        \"\"\"\n        result = mm_to_grams(1000.0, 1.75, 1.24)\n        assert abs(result - 2.9826) < 0.01\n\n\nclass TestGetCumulativeUsageAtLayer:\n    \"\"\"Tests for get_cumulative_usage_at_layer().\"\"\"\n\n    def test_exact_layer_match(self):\n        \"\"\"Target layer exists exactly in the data.\"\"\"\n        data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}\n        assert get_cumulative_usage_at_layer(data, 5) == {0: 500.0}\n\n    def test_between_layers(self):\n        \"\"\"Target is between recorded layers - uses the closest lower one.\"\"\"\n        data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}\n        # Layer 7 is between 5 and 10, should return layer 5's data\n        assert get_cumulative_usage_at_layer(data, 7) == {0: 500.0}\n\n    def test_beyond_last_layer(self):\n        \"\"\"Target is beyond the last recorded layer.\"\"\"\n        data = {0: {0: 100.0}, 5: {0: 500.0}}\n        assert get_cumulative_usage_at_layer(data, 100) == {0: 500.0}\n\n    def test_before_first_layer(self):\n        \"\"\"Target is before any recorded data.\"\"\"\n        data = {5: {0: 500.0}, 10: {0: 1000.0}}\n        assert get_cumulative_usage_at_layer(data, 3) == {}\n\n    def test_empty_data(self):\n        \"\"\"Empty layer_usage returns empty dict.\"\"\"\n        assert get_cumulative_usage_at_layer({}, 5) == {}\n\n    def test_none_data(self):\n        \"\"\"None layer_usage returns empty dict.\"\"\"\n        assert get_cumulative_usage_at_layer(None, 5) == {}\n\n    def test_multi_filament(self):\n        \"\"\"Multi-filament data at target layer.\"\"\"\n        data = {\n            0: {0: 50.0},\n            5: {0: 200.0, 1: 100.0},\n            10: {0: 400.0, 1: 250.0, 2: 50.0},\n        }\n        result = get_cumulative_usage_at_layer(data, 8)\n        assert result == {0: 200.0, 1: 100.0}\n\n    def test_layer_zero(self):\n        \"\"\"Target layer 0.\"\"\"\n        data = {0: {0: 10.0}, 1: {0: 20.0}}\n        assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}\n\n\nclass TestExtractFilamentUsageFrom3mf:\n    \"\"\"Tests for extract_filament_usage_from_3mf function.\"\"\"\n\n    def test_extract_single_filament(self, tmp_path):\n        \"\"\"Test extracting a single filament.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <filament id=\"1\" used_g=\"50.5\" type=\"PLA\" color=\"#FF0000\"/>\n        </config>\n        \"\"\"\n        mock_3mf = create_mock_3mf(xml_content)\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(mock_3mf.read())\n\n        result = extract_filament_usage_from_3mf(file_path)\n\n        assert len(result) == 1\n        assert result[0][\"slot_id\"] == 1\n        assert result[0][\"used_g\"] == 50.5\n        assert result[0][\"type\"] == \"PLA\"\n        assert result[0][\"color\"] == \"#FF0000\"\n\n    def test_extract_multiple_filaments(self, tmp_path):\n        \"\"\"Test extracting multiple filaments.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <filament id=\"1\" used_g=\"50.5\" type=\"PLA\" color=\"#FF0000\"/>\n            <filament id=\"2\" used_g=\"30.2\" type=\"PETG\" color=\"#00FF00\"/>\n            <filament id=\"3\" used_g=\"10.0\" type=\"ABS\" color=\"#0000FF\"/>\n        </config>\n        \"\"\"\n        mock_3mf = create_mock_3mf(xml_content)\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(mock_3mf.read())\n\n        result = extract_filament_usage_from_3mf(file_path)\n\n        assert len(result) == 3\n        assert result[0][\"slot_id\"] == 1\n        assert result[1][\"slot_id\"] == 2\n        assert result[2][\"slot_id\"] == 3\n\n    def test_extract_filament_with_plate_id(self, tmp_path):\n        \"\"\"Test extracting filament for a specific plate.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <plate>\n                <metadata key=\"index\" value=\"1\"/>\n                <filament id=\"1\" used_g=\"25.0\" type=\"PLA\" color=\"#FF0000\"/>\n            </plate>\n            <plate>\n                <metadata key=\"index\" value=\"2\"/>\n                <filament id=\"1\" used_g=\"75.0\" type=\"PETG\" color=\"#00FF00\"/>\n            </plate>\n        </config>\n        \"\"\"\n        mock_3mf = create_mock_3mf(xml_content)\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(mock_3mf.read())\n\n        result = extract_filament_usage_from_3mf(file_path, plate_id=2)\n\n        assert len(result) == 1\n        assert result[0][\"used_g\"] == 75.0\n        assert result[0][\"type\"] == \"PETG\"\n\n    def test_missing_slice_info_returns_empty(self, tmp_path):\n        \"\"\"Test that missing slice_info.config returns empty list.\"\"\"\n        buffer = io.BytesIO()\n        with zipfile.ZipFile(buffer, \"w\") as zf:\n            zf.writestr(\"other_file.txt\", \"content\")\n        buffer.seek(0)\n\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(buffer.read())\n\n        result = extract_filament_usage_from_3mf(file_path)\n\n        assert result == []\n\n    def test_invalid_file_returns_empty(self, tmp_path):\n        \"\"\"Test that invalid file returns empty list.\"\"\"\n        file_path = tmp_path / \"invalid.3mf\"\n        file_path.write_text(\"not a zip file\")\n\n        result = extract_filament_usage_from_3mf(file_path)\n\n        assert result == []\n\n    def test_nonexistent_file_returns_empty(self, tmp_path):\n        \"\"\"Test that nonexistent file returns empty list.\"\"\"\n        file_path = tmp_path / \"nonexistent.3mf\"\n\n        result = extract_filament_usage_from_3mf(file_path)\n\n        assert result == []\n\n    def test_filament_without_id_is_skipped(self, tmp_path):\n        \"\"\"Test that filament without id is skipped.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <filament used_g=\"50.5\" type=\"PLA\" color=\"#FF0000\"/>\n            <filament id=\"2\" used_g=\"30.0\" type=\"PETG\" color=\"#00FF00\"/>\n        </config>\n        \"\"\"\n        mock_3mf = create_mock_3mf(xml_content)\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(mock_3mf.read())\n\n        result = extract_filament_usage_from_3mf(file_path)\n\n        assert len(result) == 1\n        assert result[0][\"slot_id\"] == 2\n\n    def test_invalid_used_g_is_skipped(self, tmp_path):\n        \"\"\"Test that filament with invalid used_g is skipped.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <filament id=\"1\" used_g=\"invalid\" type=\"PLA\" color=\"#FF0000\"/>\n            <filament id=\"2\" used_g=\"30.0\" type=\"PETG\" color=\"#00FF00\"/>\n        </config>\n        \"\"\"\n        mock_3mf = create_mock_3mf(xml_content)\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(mock_3mf.read())\n\n        result = extract_filament_usage_from_3mf(file_path)\n\n        assert len(result) == 1\n        assert result[0][\"slot_id\"] == 2\n\n    def test_missing_optional_fields(self, tmp_path):\n        \"\"\"Test that missing type and color default to empty string.\"\"\"\n        xml_content = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <config>\n            <filament id=\"1\" used_g=\"50.5\"/>\n        </config>\n        \"\"\"\n        mock_3mf = create_mock_3mf(xml_content)\n        file_path = tmp_path / \"test.3mf\"\n        file_path.write_bytes(mock_3mf.read())\n\n        result = extract_filament_usage_from_3mf(file_path)\n\n        assert len(result) == 1\n        assert result[0][\"type\"] == \"\"\n        assert result[0][\"color\"] == \"\"\n"
  },
  {
    "path": "backend/tests/unit/test_usage_tracker.py",
    "content": "\"\"\"Unit tests for usage_tracker.py — 3MF-primary filament tracking.\n\nTests the unified tracking logic: 3MF slicer estimates as primary path,\nAMS remain% delta as fallback, per-layer gcode for partial prints,\nslot-to-tray mapping resolution, and notification variable formatting.\n\"\"\"\n\nfrom datetime import datetime, timedelta, timezone\nfrom types import SimpleNamespace\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom backend.app.services.usage_tracker import (\n    PrintSession,\n    _active_sessions,\n    _decode_mqtt_mapping,\n    _find_3mf_by_filename,\n    _match_slots_by_color,\n    _track_from_3mf,\n    on_print_complete,\n    on_print_start,\n)\n\n\ndef _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):\n    \"\"\"Create a mock Spool object.\"\"\"\n    spool = MagicMock()\n    spool.id = spool_id\n    spool.label_weight = label_weight\n    spool.weight_used = weight_used\n    spool.tag_uid = tag_uid\n    spool.tray_uuid = tray_uuid\n    spool.last_used = None\n    spool.cost_per_kg = None\n    spool.material = \"PLA\"\n    return spool\n\n\ndef _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):\n    \"\"\"Create a mock SpoolAssignment object.\"\"\"\n    assignment = MagicMock()\n    assignment.spool_id = spool_id\n    assignment.printer_id = printer_id\n    assignment.ams_id = ams_id\n    assignment.tray_id = tray_id\n    return assignment\n\n\ndef _make_archive(archive_id=1, file_path=\"archives/1/test.3mf\", extra_data=None):\n    \"\"\"Create a mock PrintArchive object.\"\"\"\n    archive = MagicMock()\n    archive.id = archive_id\n    archive.file_path = file_path\n    archive.extra_data = extra_data\n    return archive\n\n\ndef _make_queue_item(ams_mapping=None, status=\"printing\"):\n    \"\"\"Create a mock PrintQueueItem object.\"\"\"\n    item = MagicMock()\n    item.ams_mapping = ams_mapping\n    item.status = status\n    return item\n\n\ndef _mock_db_execute(*return_values):\n    \"\"\"Create a mock db with execute() that returns values in sequence.\"\"\"\n    db = AsyncMock()\n    results = []\n    for val in return_values:\n        result = MagicMock()\n        result.scalar_one_or_none.return_value = val\n        results.append(result)\n    db.execute = AsyncMock(side_effect=results)\n    return db\n\n\ndef _mock_db_sequential(responses):\n    \"\"\"Create mock db that returns responses in order.\"\"\"\n    db = AsyncMock()\n    call_count = [0]\n\n    async def mock_execute(*args, **kwargs):\n        idx = call_count[0]\n        call_count[0] += 1\n        result = MagicMock()\n        if idx < len(responses):\n            result.scalar_one_or_none.return_value = responses[idx]\n        else:\n            result.scalar_one_or_none.return_value = None\n        # For cost aggregation queries that use .scalar() instead of .scalar_one_or_none()\n        result.scalar.return_value = None\n        return result\n\n    db.execute = mock_execute\n    return db\n\n\nclass TestOnPrintStart:\n    \"\"\"Tests for on_print_start().\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def _clear_sessions(self):\n        _active_sessions.clear()\n        yield\n        _active_sessions.clear()\n\n    @pytest.mark.asyncio\n    async def test_captures_remain_data(self):\n        \"\"\"Captures AMS remain% at print start.\"\"\"\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}, {\"id\": 1, \"remain\": 50}]}]},\n            tray_now=5,\n        )\n\n        await on_print_start(1, {\"subtask_name\": \"Benchy\"}, printer_manager)\n\n        assert 1 in _active_sessions\n        session = _active_sessions[1]\n        assert session.print_name == \"Benchy\"\n        assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}\n\n    @pytest.mark.asyncio\n    async def test_captures_tray_now_at_start(self):\n        \"\"\"Captures tray_now at print start for later use in usage tracking.\"\"\"\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]},\n            tray_now=9,\n        )\n\n        await on_print_start(1, {\"subtask_name\": \"Test\"}, printer_manager)\n\n        assert _active_sessions[1].tray_now_at_start == 9\n\n    @pytest.mark.asyncio\n    async def test_tray_now_at_start_255_when_unloaded(self):\n        \"\"\"Captures tray_now=255 when printer has no filament loaded at start.\"\"\"\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]},\n            tray_now=255,\n        )\n\n        await on_print_start(1, {\"subtask_name\": \"Test\"}, printer_manager)\n\n        assert _active_sessions[1].tray_now_at_start == 255\n\n    @pytest.mark.asyncio\n    async def test_creates_session_without_remain(self):\n        \"\"\"Creates session even without valid remain data (for 3MF tracking).\"\"\"\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": -1}]}]},\n            tray_now=255,\n        )\n\n        await on_print_start(1, {\"subtask_name\": \"Test\"}, printer_manager)\n\n        assert 1 in _active_sessions\n        assert _active_sessions[1].tray_remain_start == {}\n\n\nclass TestOnPrintComplete:\n    \"\"\"Tests for on_print_complete() — path ordering and interaction.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def _clear_sessions(self):\n        _active_sessions.clear()\n        yield\n        _active_sessions.clear()\n\n    @pytest.fixture(autouse=True)\n    def _mock_get_setting(self):\n        with patch(\n            \"backend.app.api.routes.settings.get_setting\",\n            new_callable=AsyncMock,\n            return_value=None,\n        ):\n            yield\n\n    @pytest.mark.asyncio\n    async def test_bl_spool_uses_3mf(self):\n        \"\"\"BL spool (with tag_uid) is tracked via 3MF, not just AMS delta.\"\"\"\n        spool = _make_spool(spool_id=1, tag_uid=\"AABB1122\", label_weight=1000)\n        assignment = _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0)\n        archive = _make_archive(archive_id=10)\n\n        # Setup: session with AMS remain data\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Benchy\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n        )\n\n        # Mock printer state: tray_now=0 (AMS0-T0), single filament\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # db returns: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 15.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n            )\n\n        # 3MF path should handle it (BL guard removed)\n        assert len(results) >= 1\n        assert results[0][\"spool_id\"] == 1\n        assert results[0][\"weight_used\"] == 15.0\n\n    @pytest.mark.asyncio\n    async def test_ams_delta_fallback_no_archive(self):\n        \"\"\"AMS delta tracks consumption when archive_id is None.\"\"\"\n        spool = _make_spool(spool_id=2, label_weight=1000)\n        assignment = _make_assignment(spool_id=2)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Test\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n        )\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            tray_now=0,\n            last_loaded_tray=-1,\n        )\n\n        # Pad 2 Nones for _find_3mf_by_filename DB queries (library + archive search),\n        # then assignment and spool for the AMS fallback path\n        db = _mock_db_sequential([None, None, assignment, spool])\n\n        results = await on_print_complete(\n            printer_id=1,\n            data={\"status\": \"completed\"},\n            printer_manager=printer_manager,\n            db=db,\n            archive_id=None,\n        )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 2\n        # 10% of 1000g = 100g\n        assert results[0][\"weight_used\"] == 100.0\n        assert results[0][\"percent_used\"] == 10\n\n    @pytest.mark.asyncio\n    async def test_no_double_tracking(self):\n        \"\"\"When 3MF handles a tray, AMS delta skips it.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n\n        _active_sessions[1] = PrintSession(\n            printer_id=1,\n            print_name=\"Benchy\",\n            started_at=datetime.now(timezone.utc),\n            tray_remain_start={(0, 0): 80},\n        )\n\n        # tray_now=0 matches the single filament slot\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 70}]}]},\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        # db returns: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 15.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await on_print_complete(\n                printer_id=1,\n                data={\"status\": \"completed\"},\n                printer_manager=printer_manager,\n                db=db,\n                archive_id=10,\n            )\n\n        # Only 1 result (3MF), NOT 2 (3MF + AMS delta)\n        assert len(results) == 1\n        assert results[0][\"weight_used\"] == 15.0\n\n\nclass TestTrackFrom3mf:\n    \"\"\"Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_prefers_live_assignment_when_reassigned_mid_print(self):\n        \"\"\"If tray assignment changed during print, track usage on the new spool.\"\"\"\n        spool_old = _make_spool(spool_id=1, label_weight=1000)\n        spool_new = _make_spool(spool_id=2, label_weight=1000)\n        archive = _make_archive(archive_id=80)\n\n        live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)\n        started_at = datetime.now(timezone.utc)\n        live_assignment.created_at = started_at + timedelta(seconds=5)\n\n        # db: archive, queue_item(None), live assignment lookup, spool_new lookup\n        db = _mock_db_sequential([archive, None, live_assignment, spool_new])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=80,\n                status=\"completed\",\n                print_name=\"MidPrintReassign\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n                spool_assignments={(0, 0): spool_old.id},\n                print_started_at=started_at,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == spool_new.id\n\n    @pytest.mark.asyncio\n    async def test_keeps_snapshot_when_live_assignment_predates_print(self):\n        \"\"\"If live assignment predates print start, preserve snapshot spool mapping.\"\"\"\n        spool_old = _make_spool(spool_id=1, label_weight=1000)\n        archive = _make_archive(archive_id=81)\n\n        live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)\n        started_at = datetime.now(timezone.utc)\n        live_assignment.created_at = started_at - timedelta(seconds=5)\n\n        # db: archive, queue_item(None), live assignment lookup, spool_old lookup\n        db = _mock_db_sequential([archive, None, live_assignment, spool_old])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=81,\n                status=\"completed\",\n                print_name=\"SnapshotPreserved\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n                spool_assignments={(0, 0): spool_old.id},\n                print_started_at=started_at,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == spool_old.id\n\n    @pytest.mark.asyncio\n    async def test_linear_fallback_for_partial_print(self):\n        \"\"\"Falls back to linear scaling when gcode layer data unavailable.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n\n        # db: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=50,\n            layer_num=25,\n            tray_now=0,\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 20.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf\",\n                return_value=None,  # No layer data available\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"failed\",\n                print_name=\"Benchy\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 1\n        # 50% of 20g = 10g\n        assert results[0][\"weight_used\"] == 10.0\n        # Tray should be marked as handled\n        assert (0, 0) in handled_trays\n\n    @pytest.mark.asyncio\n    async def test_per_layer_partial_print(self):\n        \"\"\"Failed print at layer N uses gcode cumulative data.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n\n        # db: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=50,\n            layer_num=25,\n            tray_now=0,\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 20.0, \"type\": \"PLA\", \"color\": \"\"}]\n        # Per-layer data: at layer 25, filament 0 used 5000mm\n        layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}}\n        filament_props = {1: {\"density\": 1.24, \"diameter\": 1.75}}\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf\",\n                return_value=layer_data,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.get_cumulative_usage_at_layer\",\n                return_value={0: 5000.0},\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_properties_from_3mf\",\n                return_value=filament_props,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.mm_to_grams\",\n                return_value=12.0,  # 5000mm at 1.75mm/1.24g/cm3\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"failed\",\n                print_name=\"Benchy\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 1\n        # Should use per-layer grams (12.0g), not linear scale (10.0g)\n        assert results[0][\"weight_used\"] == 12.0\n\n    @pytest.mark.asyncio\n    async def test_completed_print_uses_full_weight(self):\n        \"\"\"Completed print uses full 3MF weight (scale=1.0).\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1)\n        archive = _make_archive(archive_id=10)\n\n        # db: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=0,\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 20.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"Benchy\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"weight_used\"] == 20.0\n\n    @pytest.mark.asyncio\n    async def test_tray_now_override_for_single_filament(self):\n        \"\"\"Single-filament non-queue print uses tray_now instead of slot_id mapping.\"\"\"\n        # Spool 2 is at AMS1-T3 (global_tray_id=7)\n        spool = _make_spool(spool_id=2, label_weight=1000)\n        assignment = _make_assignment(spool_id=2, ams_id=1, tray_id=3)\n        archive = _make_archive(archive_id=10)\n\n        # db: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        # tray_now=7 = (ams_id=1, tray_id=3), the ACTUAL tray used\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=7,\n        )\n\n        # 3MF has slot_id=12 (would default-map to ams_id=2, tray_id=3 — WRONG)\n        filament_usage = [{\"slot_id\": 12, \"used_g\": 10.6, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"Test\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 2\n        assert results[0][\"ams_id\"] == 1\n        assert results[0][\"tray_id\"] == 3\n        assert results[0][\"weight_used\"] == 10.6\n        assert (1, 3) in handled_trays\n\n    @pytest.mark.asyncio\n    async def test_queue_ams_mapping_overrides_default(self):\n        \"\"\"Queue item ams_mapping overrides default slot_id mapping.\"\"\"\n        # Spool at AMS1-T3 (global_tray_id=7)\n        spool = _make_spool(spool_id=5, label_weight=1000)\n        assignment = _make_assignment(spool_id=5, ams_id=1, tray_id=3)\n        archive = _make_archive(archive_id=20)\n        # Queue item maps slot 1 → global tray 7 (ams_id=1, tray_id=3)\n        queue_item = _make_queue_item(ams_mapping=\"[7, -1, -1, -1]\")\n\n        # db: archive, queue_item, assignment, spool\n        db = _mock_db_sequential([archive, queue_item, assignment, spool])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=7,\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 25.0, \"type\": \"PETG\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=20,\n                status=\"completed\",\n                print_name=\"Queue Print\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 5\n        assert results[0][\"ams_id\"] == 1\n        assert results[0][\"tray_id\"] == 3\n        assert results[0][\"weight_used\"] == 25.0\n\n    @pytest.mark.asyncio\n    async def test_multi_filament_uses_queue_mapping(self):\n        \"\"\"Multi-filament queue prints use ams_mapping for each slot.\"\"\"\n        spool_a = _make_spool(spool_id=1, label_weight=1000)\n        spool_b = _make_spool(spool_id=2, label_weight=1000)\n        assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)\n        assign_b = _make_assignment(spool_id=2, ams_id=1, tray_id=2)\n        archive = _make_archive(archive_id=30)\n        # slot 1 → tray 0 (AMS0-T0), slot 2 → tray 6 (AMS1-T2)\n        queue_item = _make_queue_item(ams_mapping=\"[0, 6]\")\n\n        # db: archive, queue_item, assign_a, spool_a, assign_b, spool_b\n        db = _mock_db_sequential([archive, queue_item, assign_a, spool_a, assign_b, spool_b])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=6,\n        )\n\n        filament_usage = [\n            {\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"\"},\n            {\"slot_id\": 2, \"used_g\": 5.0, \"type\": \"PETG\", \"color\": \"\"},\n        ]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=30,\n                status=\"completed\",\n                print_name=\"Multi\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 2\n        assert results[0][\"spool_id\"] == 1\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 0\n        assert results[0][\"weight_used\"] == 10.0\n        assert results[1][\"spool_id\"] == 2\n        assert results[1][\"ams_id\"] == 1\n        assert results[1][\"tray_id\"] == 2\n        assert results[1][\"weight_used\"] == 5.0\n\n    @pytest.mark.asyncio\n    async def test_no_tray_now_override_for_multi_filament(self):\n        \"\"\"Multi-filament non-queue prints fall back to default mapping, not tray_now.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)\n        archive = _make_archive(archive_id=10)\n\n        # db: archive, queue_item(None), assignment, spool (2nd slot has no assignment)\n        db = _mock_db_sequential([archive, None, assignment, spool, None])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=4,  # tray_now won't be used\n        )\n\n        # Two filament slots with usage\n        filament_usage = [\n            {\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"\"},\n            {\"slot_id\": 2, \"used_g\": 5.0, \"type\": \"PETG\", \"color\": \"\"},\n        ]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"Test\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        # Should use default mapping (slot 1 → tray 0, slot 2 → tray 1)\n        assert len(results) == 1  # Only slot 1 has assignment\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 0\n\n    @pytest.mark.asyncio\n    async def test_stored_ams_mapping_overrides_all(self):\n        \"\"\"Stored ams_mapping from print command takes priority over queue and tray_now.\"\"\"\n        # Spool at AMS2-T1 (global_tray_id=9)\n        spool = _make_spool(spool_id=10, label_weight=1000)\n        assignment = _make_assignment(spool_id=10, ams_id=2, tray_id=1)\n        archive = _make_archive(archive_id=50)\n\n        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)\n        db = _mock_db_sequential([archive, assignment, spool])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=0,  # Different from mapped tray — should be ignored\n            last_loaded_tray=0,\n        )\n\n        filament_usage = [{\"slot_id\": 2, \"used_g\": 1.57, \"type\": \"PLA\", \"color\": \"#FFFFFF\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            # ams_mapping: slot 2 (index 1) -> tray 9 (AMS2-T1)\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=50,\n                status=\"completed\",\n                print_name=\"Test\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n                ams_mapping=[-1, 9],\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 10\n        assert results[0][\"ams_id\"] == 2\n        assert results[0][\"tray_id\"] == 1\n        assert results[0][\"weight_used\"] == 1.6  # rounded\n\n    @pytest.mark.asyncio\n    async def test_last_loaded_tray_fallback(self):\n        \"\"\"Falls back to last_loaded_tray when tray_now_at_start and current tray_now are both 255.\"\"\"\n        # Spool at AMS2-T1 (global_tray_id=9)\n        spool = _make_spool(spool_id=11, label_weight=1000)\n        assignment = _make_assignment(spool_id=11, ams_id=2, tray_id=1)\n        archive = _make_archive(archive_id=60)\n\n        # db: archive, queue_item(None), assignment, spool\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        # H2D scenario: tray_now=255 at completion, but last_loaded_tray=9\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=255,\n            last_loaded_tray=9,\n        )\n\n        filament_usage = [{\"slot_id\": 6, \"used_g\": 1.52, \"type\": \"PLA\", \"color\": \"#7CC4D5\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=60,\n                status=\"completed\",\n                print_name=\"Cube\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n                tray_now_at_start=255,  # H2D: 255 at start too\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 11\n        assert results[0][\"ams_id\"] == 2\n        assert results[0][\"tray_id\"] == 1\n\n    @pytest.mark.asyncio\n    async def test_tray_now_at_start_preferred_over_last_loaded(self):\n        \"\"\"tray_now_at_start is used before last_loaded_tray fallback.\"\"\"\n        spool = _make_spool(spool_id=3, label_weight=1000)\n        assignment = _make_assignment(spool_id=3, ams_id=1, tray_id=1)\n        archive = _make_archive(archive_id=70)\n\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        # tray_now_at_start=5 (valid), last_loaded_tray=9 (different) — should use 5\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=50,\n            tray_now=255,\n            last_loaded_tray=9,\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 5.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=70,\n                status=\"completed\",\n                print_name=\"Test\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n                tray_now_at_start=5,  # AMS1-T1\n            )\n\n        assert len(results) == 1\n        assert results[0][\"ams_id\"] == 1\n        assert results[0][\"tray_id\"] == 1\n\n\nclass TestTrayChangeSplit:\n    \"\"\"Tests for mid-print tray switch weight splitting in _track_from_3mf().\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_tray_switch_splits_weight_with_gcode(self):\n        \"\"\"Two-tray runout: weight split using per-layer gcode data.\"\"\"\n        spool_a = _make_spool(spool_id=10, label_weight=1000)\n        spool_b = _make_spool(spool_id=20, label_weight=1000)\n        assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=1)\n        assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=0)\n        archive = _make_archive(archive_id=100)\n\n        # db: archive, queue_item(None), then for each segment: assignment, spool\n        db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])\n\n        # Tray change log: started on tray 1, switched to tray 0 at layer 60\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=100,\n            tray_now=0,\n            last_loaded_tray=0,\n            total_layers=100,\n            tray_change_log=[(1, 0), (0, 60)],\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 30.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf\",\n                return_value={30: {0: 3000.0}, 60: {0: 6000.0}, 100: {0: 10000.0}},\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.get_cumulative_usage_at_layer\",\n                side_effect=lambda data, layer: {0: {0: 0.0, 60: 6000.0, 100: 10000.0}.get(layer, 0.0)},\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_properties_from_3mf\",\n                return_value={1: {\"density\": 1.24, \"diameter\": 1.75}},\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.mm_to_grams\",\n                side_effect=lambda mm, d, dens: round(mm * 0.003, 1),  # Simple conversion\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=100,\n                status=\"completed\",\n                print_name=\"Runout Test\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        # Two results: one per tray segment\n        assert len(results) == 2\n        # First segment: tray 1 (AMS0-T1), layers 0→60\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 1\n        assert results[0][\"spool_id\"] == 10\n        assert results[0][\"weight_used\"] == 18.0  # 6000mm * 0.003\n        # Second segment: tray 0 (AMS0-T0), layers 60→end = 30.0 - 18.0 = 12.0\n        assert results[1][\"ams_id\"] == 0\n        assert results[1][\"tray_id\"] == 0\n        assert results[1][\"spool_id\"] == 20\n        assert results[1][\"weight_used\"] == 12.0\n        # Both trays handled\n        assert (0, 1) in handled_trays\n        assert (0, 0) in handled_trays\n\n    @pytest.mark.asyncio\n    async def test_tray_switch_linear_fallback(self):\n        \"\"\"Two-tray runout without per-layer gcode: linear split by layer ratio.\"\"\"\n        spool_a = _make_spool(spool_id=10, label_weight=1000)\n        spool_b = _make_spool(spool_id=20, label_weight=1000)\n        assign_a = _make_assignment(spool_id=10, ams_id=0, tray_id=2)\n        assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)\n        archive = _make_archive(archive_id=101)\n\n        db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])\n\n        # Tray 2 from layer 0, switched to tray 1 at layer 40 (of 100 total)\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=100,\n            tray_now=1,\n            last_loaded_tray=1,\n            total_layers=100,\n            tray_change_log=[(2, 0), (1, 40)],\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 50.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf\",\n                return_value=None,  # No per-layer gcode available\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=101,\n                status=\"completed\",\n                print_name=\"Linear Fallback\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 2\n        # Linear split: tray 2 for 40/100 layers = 20g\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 2\n        assert results[0][\"weight_used\"] == 20.0\n        # Last segment gets remainder: 50 - 20 = 30g\n        assert results[1][\"ams_id\"] == 0\n        assert results[1][\"tray_id\"] == 1\n        assert results[1][\"weight_used\"] == 30.0\n\n    @pytest.mark.asyncio\n    async def test_no_tray_change_uses_normal_path(self):\n        \"\"\"Single-entry tray_change_log falls through to normal tray_now_at_start logic.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)\n        archive = _make_archive(archive_id=102)\n\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        # Only one entry = no switch, should use normal path\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=100,\n            tray_now=2,\n            last_loaded_tray=2,\n            total_layers=100,\n            tray_change_log=[(2, 0)],\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 15.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=102,\n                status=\"completed\",\n                print_name=\"No Switch\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n                tray_now_at_start=2,\n            )\n\n        # Normal path: single result, full weight\n        assert len(results) == 1\n        assert results[0][\"weight_used\"] == 15.0\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 2\n\n    @pytest.mark.asyncio\n    async def test_empty_tray_change_log_uses_normal_path(self):\n        \"\"\"Empty tray_change_log (e.g. server restart) falls through to existing logic.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)\n        archive = _make_archive(archive_id=103)\n\n        db = _mock_db_sequential([archive, None, assignment, spool])\n\n        # Empty log (server restarted mid-print)\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=100,\n            tray_now=0,\n            last_loaded_tray=0,\n            total_layers=100,\n            tray_change_log=[],\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=103,\n                status=\"completed\",\n                print_name=\"Restart Recovery\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n                tray_now_at_start=0,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"weight_used\"] == 10.0\n\n    @pytest.mark.asyncio\n    async def test_tray_switch_segment_no_spool(self):\n        \"\"\"Segment with no spool assignment is skipped; other segments still tracked.\"\"\"\n        spool_b = _make_spool(spool_id=20, label_weight=1000)\n        assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=3)\n        archive = _make_archive(archive_id=104)\n\n        # db: archive, queue_item(None), 1st segment: no assignment, 2nd segment: assignment, spool\n        db = _mock_db_sequential([archive, None, None, assign_b, spool_b])\n\n        # Tray 5 (no spool) from layer 0, switched to tray 3 at layer 50\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=100,\n            tray_now=3,\n            last_loaded_tray=3,\n            total_layers=100,\n            tray_change_log=[(5, 0), (3, 50)],\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 40.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf\",\n                return_value=None,  # No per-layer data\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=104,\n                status=\"completed\",\n                print_name=\"Missing Spool\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        # Only the second segment (tray 3) tracked; first segment (tray 5) skipped\n        assert len(results) == 1\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 3\n        assert results[0][\"spool_id\"] == 20\n\n    @pytest.mark.asyncio\n    async def test_tray_switch_three_segments(self):\n        \"\"\"Three-segment switch (rare): A→B→C split by linear fallback.\"\"\"\n        spool_a = _make_spool(spool_id=1, label_weight=1000)\n        spool_b = _make_spool(spool_id=2, label_weight=1000)\n        spool_c = _make_spool(spool_id=3, label_weight=1000)\n        assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)\n        assign_b = _make_assignment(spool_id=2, ams_id=0, tray_id=1)\n        assign_c = _make_assignment(spool_id=3, ams_id=0, tray_id=2)\n        archive = _make_archive(archive_id=105)\n\n        db = _mock_db_sequential(\n            [\n                archive,\n                None,\n                assign_a,\n                spool_a,\n                assign_b,\n                spool_b,\n                assign_c,\n                spool_c,\n            ]\n        )\n\n        # 3 segments: tray 0 (0-30), tray 1 (30-70), tray 2 (70-end)\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            progress=100,\n            layer_num=100,\n            tray_now=2,\n            last_loaded_tray=2,\n            total_layers=100,\n            tray_change_log=[(0, 0), (1, 30), (2, 70)],\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 100.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\n                \"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf\",\n                return_value=None,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=105,\n                status=\"completed\",\n                print_name=\"Triple Switch\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 3\n        # Tray 0: 30/100 * 100g = 30g\n        assert results[0][\"weight_used\"] == 30.0\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 0\n        # Tray 1: 40/100 * 100g = 40g\n        assert results[1][\"weight_used\"] == 40.0\n        assert results[1][\"ams_id\"] == 0\n        assert results[1][\"tray_id\"] == 1\n        # Tray 2: remainder = 100 - 30 - 40 = 30g\n        assert results[2][\"weight_used\"] == 30.0\n        assert results[2][\"ams_id\"] == 0\n        assert results[2][\"tray_id\"] == 2\n\n\nclass TestDecodeMqttMapping:\n    \"\"\"Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs.\"\"\"\n\n    def test_none_input(self):\n        assert _decode_mqtt_mapping(None) is None\n\n    def test_empty_list(self):\n        assert _decode_mqtt_mapping([]) is None\n\n    def test_all_unmapped(self):\n        \"\"\"All 65535 values → None (no valid mappings).\"\"\"\n        assert _decode_mqtt_mapping([65535, 65535, 65535]) is None\n\n    def test_single_ams_slots(self):\n        \"\"\"AMS 0 slots: snow values 0-3 → global tray IDs 0-3.\"\"\"\n        assert _decode_mqtt_mapping([0, 1, 2, 3]) == [0, 1, 2, 3]\n\n    def test_multi_ams_slots(self):\n        \"\"\"AMS 1 (hw_id=1): snow 256=AMS1-T0, 257=AMS1-T1 → global 4, 5.\"\"\"\n        assert _decode_mqtt_mapping([256, 257]) == [4, 5]\n\n    def test_ams_ht_slot(self):\n        \"\"\"AMS-HT (hw_id=128): snow 32768 → global 128.\"\"\"\n        assert _decode_mqtt_mapping([32768]) == [128]\n\n    def test_external_spool(self):\n        \"\"\"External spool: ams_hw_id=254, slot=0 → global 254.\"\"\"\n        # snow = 254 * 256 + 0 = 65024\n        assert _decode_mqtt_mapping([65024]) == [254]\n\n    def test_mixed_with_unmapped(self):\n        \"\"\"Mix of valid and unmapped (65535) values.\"\"\"\n        result = _decode_mqtt_mapping([1, 65535, 0])\n        assert result == [1, -1, 0]\n\n    def test_h2c_real_mapping(self):\n        \"\"\"Real H2C mapping from MQTT logs: [1, 0, 65535*4, 32768].\"\"\"\n        mapping = [1, 0, 65535, 65535, 65535, 65535, 32768]\n        result = _decode_mqtt_mapping(mapping)\n        assert result == [1, 0, -1, -1, -1, -1, 128]\n\n    def test_non_int_values_treated_as_unmapped(self):\n        \"\"\"Non-integer values in the mapping are treated as unmapped.\"\"\"\n        assert _decode_mqtt_mapping([\"foo\", 0]) == [-1, 0]\n\n\nclass TestMatchSlotsByColor:\n    \"\"\"Tests for _match_slots_by_color() — color-based filament slot to AMS tray matching.\"\"\"\n\n    def _ams(self, trays):\n        \"\"\"Build AMS data from list of (ams_id, tray_id, color_hex, tray_type) tuples.\"\"\"\n        units: dict[int, list] = {}\n        for ams_id, tray_id, color, tray_type in trays:\n            units.setdefault(ams_id, []).append({\"id\": tray_id, \"tray_color\": color, \"tray_type\": tray_type})\n        return [{\"id\": aid, \"tray\": t} for aid, t in units.items()]\n\n    def _usage(self, slots):\n        \"\"\"Build filament_usage from list of (slot_id, color_hex) tuples.\"\"\"\n        return [{\"slot_id\": sid, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": color} for sid, color in slots]\n\n    def test_none_inputs(self):\n        assert _match_slots_by_color(None, None) is None\n        assert _match_slots_by_color([], None) is None\n        assert _match_slots_by_color(None, {\"ams\": []}) is None\n\n    def test_empty_ams(self):\n        usage = self._usage([(1, \"#FF0000\")])\n        assert _match_slots_by_color(usage, {\"ams\": []}) is None\n\n    def test_single_slot_single_tray(self):\n        \"\"\"One 3MF slot matches one AMS tray by color.\"\"\"\n        ams = self._ams([(0, 0, \"FF0000FF\", \"PLA\")])\n        usage = self._usage([(1, \"#FF0000\")])\n        assert _match_slots_by_color(usage, {\"ams\": ams}) == [0]\n\n    def test_a1_mini_three_colors(self):\n        \"\"\"A1 Mini: 3 slots match 3 distinct AMS trays.\"\"\"\n        ams = self._ams(\n            [\n                (0, 0, \"FF0000FF\", \"PLA\"),  # Red\n                (0, 1, \"00FF00FF\", \"PLA\"),  # Green\n                (0, 2, \"0000FFFF\", \"PLA\"),  # Blue\n            ]\n        )\n        usage = self._usage([(1, \"#FF0000\"), (2, \"#00FF00\"), (3, \"#0000FF\")])\n        assert _match_slots_by_color(usage, {\"ams\": ams}) == [0, 1, 2]\n\n    def test_dual_ams_p2s_like(self):\n        \"\"\"P2S with dual AMS: slots from second AMS unit.\"\"\"\n        ams = self._ams(\n            [\n                (0, 0, \"AAAAAAFF\", \"PLA\"),\n                (0, 1, \"BBBBBBFF\", \"PLA\"),\n                (1, 0, \"CC0000FF\", \"PETG\"),  # global_id=4\n                (1, 1, \"00CC00FF\", \"PETG\"),  # global_id=5\n            ]\n        )\n        usage = self._usage([(1, \"#CC0000\"), (2, \"#00CC00\")])\n        assert _match_slots_by_color(usage, {\"ams\": ams}) == [4, 5]\n\n    def test_ams_ht_global_id(self):\n        \"\"\"AMS-HT (ams_id >= 128) uses raw ams_id as global tray ID.\"\"\"\n        ams = self._ams(\n            [\n                (0, 0, \"FF0000FF\", \"PLA\"),\n                (128, 0, \"0000FFFF\", \"PLA\"),  # AMS-HT → global_id=128\n            ]\n        )\n        usage = self._usage([(1, \"#FF0000\"), (2, \"#0000FF\")])\n        assert _match_slots_by_color(usage, {\"ams\": ams}) == [0, 128]\n\n    def test_ambiguous_same_color_returns_none(self):\n        \"\"\"Two trays with the same color → ambiguous → None.\"\"\"\n        ams = self._ams(\n            [\n                (0, 0, \"FF0000FF\", \"PLA\"),\n                (0, 1, \"FF0000FF\", \"PLA\"),  # Same red\n            ]\n        )\n        usage = self._usage([(1, \"#FF0000\")])\n        assert _match_slots_by_color(usage, {\"ams\": ams}) is None\n\n    def test_no_matching_color_returns_none(self):\n        \"\"\"3MF slot color not found in any AMS tray → None.\"\"\"\n        ams = self._ams([(0, 0, \"00FF00FF\", \"PLA\")])\n        usage = self._usage([(1, \"#FF0000\")])  # Red, but AMS has green\n        assert _match_slots_by_color(usage, {\"ams\": ams}) is None\n\n    def test_color_normalization_strips_alpha(self):\n        \"\"\"AMS colors (RRGGBBAA) and 3MF colors (#RRGGBB) match after normalization.\"\"\"\n        ams = self._ams([(0, 0, \"AABBCC80\", \"PLA\")])  # 8-char with alpha\n        usage = self._usage([(1, \"#AABBCC\")])  # 6-char with #\n        assert _match_slots_by_color(usage, {\"ams\": ams}) == [0]\n\n    def test_case_insensitive(self):\n        \"\"\"Color matching is case-insensitive.\"\"\"\n        ams = self._ams([(0, 0, \"aaBBccFF\", \"PLA\")])\n        usage = self._usage([(1, \"#AAbbCC\")])\n        assert _match_slots_by_color(usage, {\"ams\": ams}) == [0]\n\n    def test_empty_tray_color_skipped(self):\n        \"\"\"Trays with empty color are skipped (not matched).\"\"\"\n        ams = self._ams(\n            [\n                (0, 0, \"\", \"PLA\"),\n                (0, 1, \"FF0000FF\", \"PLA\"),\n            ]\n        )\n        usage = self._usage([(1, \"#FF0000\")])\n        assert _match_slots_by_color(usage, {\"ams\": ams}) == [1]\n\n    def test_empty_tray_type_skipped(self):\n        \"\"\"Trays with empty tray_type are skipped (unloaded slot).\"\"\"\n        ams = self._ams(\n            [\n                (0, 0, \"FF0000FF\", \"\"),  # Empty slot\n                (0, 1, \"FF0000FF\", \"PLA\"),  # Loaded slot\n            ]\n        )\n        usage = self._usage([(1, \"#FF0000\")])\n        assert _match_slots_by_color(usage, {\"ams\": ams}) == [1]\n\n    def test_short_slot_color_returns_none(self):\n        \"\"\"3MF slot with color < 6 chars → can't match → None.\"\"\"\n        ams = self._ams([(0, 0, \"FF0000FF\", \"PLA\")])\n        usage = [{\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"#FFF\"}]\n        assert _match_slots_by_color(usage, {\"ams\": ams}) is None\n\n    def test_slot_id_zero_skipped(self):\n        \"\"\"Slots with slot_id=0 are skipped.\"\"\"\n        ams = self._ams([(0, 0, \"FF0000FF\", \"PLA\")])\n        usage = [{\"slot_id\": 0, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n        assert _match_slots_by_color(usage, {\"ams\": ams}) is None\n\n    def test_ams_data_as_list(self):\n        \"\"\"Handles ams_raw as a plain list (some printer models).\"\"\"\n        ams_list = [{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_color\": \"FF0000FF\", \"tray_type\": \"PLA\"}]}]\n        usage = self._usage([(1, \"#FF0000\")])\n        assert _match_slots_by_color(usage, ams_list) == [0]\n\n    def test_same_color_two_trays_disambiguated_by_usage(self):\n        \"\"\"Two trays same color, two slots same color → unique assignment via used_trays tracking.\"\"\"\n        ams = self._ams(\n            [\n                (0, 0, \"FF0000FF\", \"PLA\"),\n                (0, 1, \"FF0000FF\", \"PLA\"),\n            ]\n        )\n        # Two slots both wanting red — first gets tray 0, second gets tray 1? No.\n        # When first slot takes the only available, second has 1 left → should work\n        usage = self._usage([(1, \"#FF0000\"), (2, \"#FF0000\")])\n        # First slot: candidates=[0,1], available=[0,1], len!=1 → None\n        assert _match_slots_by_color(usage, {\"ams\": ams}) is None\n\n    def test_dict_wrapper_with_ams_key(self):\n        \"\"\"Standard dict format with 'ams' key.\"\"\"\n        ams_data = {\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"tray_color\": \"00FF00FF\", \"tray_type\": \"PLA\"}]}]}\n        usage = self._usage([(1, \"#00FF00\")])\n        assert _match_slots_by_color(usage, ams_data) == [0]\n\n\nclass TestMqttMappingIntegration:\n    \"\"\"Integration tests: MQTT mapping field used in _track_from_3mf.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_h2c_multi_filament_uses_mqtt_mapping(self):\n        \"\"\"H2C: 3 filaments resolved via MQTT mapping field (no ams_mapping, no queue).\"\"\"\n        # AMS0-T1 (White PLA), AMS0-T0 (Black PLA), AMS128-T0 (Red PLA)\n        spool_white = _make_spool(spool_id=1, label_weight=1000)\n        spool_black = _make_spool(spool_id=2, label_weight=1000)\n        spool_red = _make_spool(spool_id=3, label_weight=1000)\n        assign_white = _make_assignment(spool_id=1, ams_id=0, tray_id=1)\n        assign_black = _make_assignment(spool_id=2, ams_id=0, tray_id=0)\n        assign_red = _make_assignment(spool_id=3, ams_id=128, tray_id=0)\n        archive = _make_archive(archive_id=12)\n\n        # db: archive, then 3 pairs of (assignment, spool)\n        # No queue lookup because MQTT mapping is found first\n        db = _mock_db_sequential(\n            [\n                archive,\n                assign_white,\n                spool_white,\n                assign_black,\n                spool_black,\n                assign_red,\n                spool_red,\n            ]\n        )\n\n        # MQTT mapping: slot0→AMS0-T1(1), slot1→AMS0-T0(0), slots2-5→unmapped, slot6→AMS128-T0(32768)\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"mapping\": [1, 0, 65535, 65535, 65535, 65535, 32768]},\n            progress=100,\n            layer_num=50,\n            tray_now=255,\n        )\n\n        # 3MF slots 1, 2, 7 (1-based) → indices 0, 1, 6 in mapping\n        filament_usage = [\n            {\"slot_id\": 1, \"used_g\": 21.16, \"type\": \"PLA\", \"color\": \"#FFFFFF\"},\n            {\"slot_id\": 2, \"used_g\": 24.22, \"type\": \"PLA\", \"color\": \"#000000\"},\n            {\"slot_id\": 7, \"used_g\": 18.47, \"type\": \"PLA\", \"color\": \"#F72323\"},\n        ]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=12,\n                status=\"completed\",\n                print_name=\"Cube + Cube + Cube\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n            )\n\n        assert len(results) == 3\n\n        # slot_id=1 → mapping[0]=1 → AMS0-T1 (White PLA)\n        assert results[0][\"spool_id\"] == 1\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 1\n        assert results[0][\"weight_used\"] == 21.2\n\n        # slot_id=2 → mapping[1]=0 → AMS0-T0 (Black PLA)\n        assert results[1][\"spool_id\"] == 2\n        assert results[1][\"ams_id\"] == 0\n        assert results[1][\"tray_id\"] == 0\n        assert results[1][\"weight_used\"] == 24.2\n\n        # slot_id=7 → mapping[6]=32768 → AMS128-T0 (Red PLA)\n        assert results[2][\"spool_id\"] == 3\n        assert results[2][\"ams_id\"] == 128\n        assert results[2][\"tray_id\"] == 0\n        assert results[2][\"weight_used\"] == 18.5\n\n    @pytest.mark.asyncio\n    async def test_print_cmd_mapping_takes_priority_over_mqtt(self):\n        \"\"\"ams_mapping from print command is used even when MQTT mapping exists.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)\n        archive = _make_archive(archive_id=10)\n\n        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)\n        db = _mock_db_sequential([archive, assignment, spool])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"mapping\": [0, 65535]},  # MQTT says slot 0 → AMS0-T0\n            progress=100,\n            layer_num=50,\n            tray_now=255,\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 10.0, \"type\": \"PLA\", \"color\": \"\"}]\n        handled_trays: set[tuple[int, int]] = set()\n\n        with (\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=10,\n                status=\"completed\",\n                print_name=\"Test\",\n                handled_trays=handled_trays,\n                printer_manager=printer_manager,\n                db=db,\n                ams_mapping=[2],  # Print cmd says slot 0 → AMS0-T2 (overrides MQTT)\n            )\n\n        assert len(results) == 1\n        assert results[0][\"ams_id\"] == 0\n        assert results[0][\"tray_id\"] == 2  # From print_cmd mapping, not MQTT\n\n\nclass TestNotificationVariables:\n    \"\"\"Tests for filament_details formatting in notifications.\"\"\"\n\n    def test_filament_details_single_slot(self):\n        \"\"\"Single slot produces 'PLA: 15.2g' format.\"\"\"\n        slots = [{\"type\": \"PLA\", \"used_g\": 15.2, \"slot_id\": 1, \"color\": \"#FF0000\"}]\n        parts = []\n        for slot in slots:\n            ftype = slot.get(\"type\", \"Unknown\") or \"Unknown\"\n            used = slot.get(\"used_g\", 0)\n            parts.append(f\"{ftype}: {used:.1f}g\")\n        result = \" | \".join(parts)\n        assert result == \"PLA: 15.2g\"\n\n    def test_filament_details_multi_slot(self):\n        \"\"\"Multiple slots produce 'PLA: 10.0g | PETG: 5.0g' format.\"\"\"\n        slots = [\n            {\"type\": \"PLA\", \"used_g\": 10.0, \"slot_id\": 1, \"color\": \"\"},\n            {\"type\": \"PETG\", \"used_g\": 5.0, \"slot_id\": 2, \"color\": \"\"},\n        ]\n        parts = []\n        for slot in slots:\n            ftype = slot.get(\"type\", \"Unknown\") or \"Unknown\"\n            used = slot.get(\"used_g\", 0)\n            parts.append(f\"{ftype}: {used:.1f}g\")\n        result = \" | \".join(parts)\n        assert result == \"PLA: 10.0g | PETG: 5.0g\"\n\n    def test_filament_details_empty_type(self):\n        \"\"\"Empty type defaults to 'Unknown'.\"\"\"\n        slots = [{\"type\": \"\", \"used_g\": 5.0, \"slot_id\": 1, \"color\": \"\"}]\n        parts = []\n        for slot in slots:\n            ftype = slot.get(\"type\", \"Unknown\") or \"Unknown\"\n            used = slot.get(\"used_g\", 0)\n            parts.append(f\"{ftype}: {used:.1f}g\")\n        result = \" | \".join(parts)\n        assert result == \"Unknown: 5.0g\"\n\n    def test_filament_grams_scaled_for_partial(self):\n        \"\"\"filament_grams is scaled by progress for partial prints.\"\"\"\n        filament_used_grams = 20.0\n        progress = 50\n        scale = max(0.0, min(progress / 100.0, 1.0))\n        scaled = round(filament_used_grams * scale, 1)\n        assert scaled == 10.0\n\n    def test_filament_grams_zero_progress(self):\n        \"\"\"Progress=0 at cancellation gives 0.0g.\"\"\"\n        filament_used_grams = 20.0\n        progress = 0\n        scale = max(0.0, min(progress / 100.0, 1.0))\n        scaled = round(filament_used_grams * scale, 1)\n        assert scaled == 0.0\n\n    def test_slot_scaling_for_partial(self):\n        \"\"\"Per-slot usage is scaled linearly for partial prints.\"\"\"\n        slots = [\n            {\"type\": \"PLA\", \"used_g\": 20.0, \"slot_id\": 1, \"color\": \"\"},\n            {\"type\": \"PETG\", \"used_g\": 10.0, \"slot_id\": 2, \"color\": \"\"},\n        ]\n        progress = 30\n        scale = max(0.0, min(progress / 100.0, 1.0))\n        scaled_slots = [{**s, \"used_g\": round(s[\"used_g\"] * scale, 1)} for s in slots]\n        assert scaled_slots[0][\"used_g\"] == 6.0\n        assert scaled_slots[1][\"used_g\"] == 3.0\n\n\nclass TestOnPrintStartAmsMapping:\n    \"\"\"Tests for ams_mapping capture in on_print_start().\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def _clear_sessions(self):\n        _active_sessions.clear()\n        yield\n        _active_sessions.clear()\n\n    @pytest.mark.asyncio\n    async def test_captures_ams_mapping_from_data(self):\n        \"\"\"on_print_start captures ams_mapping from the data dict into the session.\"\"\"\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]},\n            tray_now=0,\n        )\n\n        await on_print_start(1, {\"subtask_name\": \"Test\", \"ams_mapping\": [3, -1, -1, 2]}, printer_manager)\n\n        assert _active_sessions[1].ams_mapping == [3, -1, -1, 2]\n\n    @pytest.mark.asyncio\n    async def test_ams_mapping_none_when_not_in_data(self):\n        \"\"\"Session ams_mapping is None when data dict has no ams_mapping.\"\"\"\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": [{\"id\": 0, \"remain\": 80}]}]},\n            tray_now=0,\n        )\n\n        await on_print_start(1, {\"subtask_name\": \"Test\"}, printer_manager)\n\n        assert _active_sessions[1].ams_mapping is None\n\n\nclass TestFindThreemfByFilename:\n    \"\"\"Tests for _find_3mf_by_filename() — library/archive search without archive_id.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_finds_library_file(self):\n        \"\"\"Finds a 3MF from library files matching filename.\"\"\"\n        from pathlib import Path\n        from unittest.mock import MagicMock\n\n        lib_file = MagicMock()\n        lib_file.file_path = \"library/BMCU-BADGE.3mf\"\n\n        mock_result = MagicMock()\n        mock_result.scalars.return_value.all.return_value = [lib_file]\n\n        db = AsyncMock()\n        db.execute = AsyncMock(return_value=mock_result)\n\n        base_dir = MagicMock(spec=Path)\n        candidate = MagicMock(spec=Path)\n        candidate.exists.return_value = True\n        candidate.suffix = \".3mf\"\n        base_dir.__truediv__ = MagicMock(return_value=candidate)\n\n        result = await _find_3mf_by_filename(1, \"BMCU-BADGE.3mf\", db, base_dir)\n\n        assert result == candidate\n\n    @pytest.mark.asyncio\n    async def test_returns_none_for_empty_filename(self):\n        \"\"\"Returns None when filename is empty or just extensions.\"\"\"\n        db = AsyncMock()\n        base_dir = MagicMock()\n\n        result = await _find_3mf_by_filename(1, \".3mf\", db, base_dir)\n        assert result is None\n\n        result = await _find_3mf_by_filename(1, \"\", db, base_dir)\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_falls_through_to_archive_search(self):\n        \"\"\"Falls back to previous archives when library search returns no results.\"\"\"\n        from pathlib import Path\n\n        # Library returns nothing\n        empty_result = MagicMock()\n        empty_result.scalars.return_value.all.return_value = []\n\n        # Archive returns a match\n        archive = MagicMock()\n        archive.id = 35\n        archive.file_path = \"archives/35/BMCU-BADGE.3mf\"\n        archive_result = MagicMock()\n        archive_result.scalars.return_value.all.return_value = [archive]\n\n        db = AsyncMock()\n        db.execute = AsyncMock(side_effect=[empty_result, archive_result])\n\n        base_dir = MagicMock(spec=Path)\n        candidate = MagicMock(spec=Path)\n        candidate.exists.return_value = True\n        candidate.suffix = \".3mf\"\n        base_dir.__truediv__ = MagicMock(return_value=candidate)\n\n        result = await _find_3mf_by_filename(1, \"BMCU-BADGE.3mf\", db, base_dir)\n\n        assert result == candidate\n        assert db.execute.call_count == 2\n\n    @pytest.mark.asyncio\n    async def test_returns_none_when_nothing_found(self):\n        \"\"\"Returns None when neither library nor archives have a matching 3MF.\"\"\"\n        empty_result = MagicMock()\n        empty_result.scalars.return_value.all.return_value = []\n\n        db = AsyncMock()\n        db.execute = AsyncMock(return_value=empty_result)\n\n        base_dir = MagicMock()\n\n        result = await _find_3mf_by_filename(1, \"nonexistent.3mf\", db, base_dir)\n\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_strips_path_and_extensions(self):\n        \"\"\"Correctly strips path components and extensions for search.\"\"\"\n        empty_result = MagicMock()\n        empty_result.scalars.return_value.all.return_value = []\n\n        db = AsyncMock()\n        db.execute = AsyncMock(return_value=empty_result)\n\n        base_dir = MagicMock()\n\n        # Should search for \"BMCU-BADGE\" base name even with path and .gcode.3mf\n        await _find_3mf_by_filename(1, \"/sdcard/BMCU-BADGE.gcode.3mf\", db, base_dir)\n\n        # Verify the execute was called (search was attempted with stripped name)\n        assert db.execute.call_count == 2  # library + archive search\n\n\nclass TestTrackFrom3mfWithPreresolvedPath:\n    \"\"\"Tests for _track_from_3mf() with threemf_path (no archive needed).\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_uses_preresolved_path_without_archive(self):\n        \"\"\"When threemf_path is provided with archive_id=None, uses the path directly.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=3)\n\n        # DB: 1st call = assignment lookup (live), 2nd = spool lookup\n        db = _mock_db_sequential([assignment, spool])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": []}]},\n            tray_now=255,\n            last_loaded_tray=3,\n            tray_change_log=[],\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 5.0, \"type\": \"PETG\", \"color\": \"#FFFFFF\"}]\n\n        with (\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=None,\n                status=\"completed\",\n                print_name=\"BMCU-BADGE\",\n                handled_trays=set(),\n                printer_manager=printer_manager,\n                db=db,\n                ams_mapping=[3, -1, -1, -1],\n                threemf_path=mock_path,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"spool_id\"] == 1\n        assert results[0][\"weight_used\"] == 5.0\n\n    @pytest.mark.asyncio\n    async def test_skips_queue_lookup_without_archive_id(self):\n        \"\"\"When archive_id is None, queue item lookup is skipped.\"\"\"\n        spool = _make_spool(spool_id=1, label_weight=1000)\n        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)\n\n        db = _mock_db_sequential([assignment, spool])\n\n        printer_manager = MagicMock()\n        printer_manager.get_status.return_value = SimpleNamespace(\n            raw_data={\"ams\": [{\"id\": 0, \"tray\": []}]},\n            tray_now=0,\n            last_loaded_tray=0,\n            tray_change_log=[],\n        )\n\n        filament_usage = [{\"slot_id\": 1, \"used_g\": 2.0, \"type\": \"PLA\", \"color\": \"#FF0000\"}]\n\n        with (\n            patch(\n                \"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf\",\n                return_value=filament_usage,\n            ),\n            patch(\"backend.app.core.config.settings\") as mock_settings,\n        ):\n            mock_settings.base_dir = MagicMock()\n            mock_path = MagicMock()\n            mock_path.exists.return_value = True\n\n            # Should NOT fail even though there's no archive_id for queue lookup\n            results = await _track_from_3mf(\n                printer_id=1,\n                archive_id=None,\n                status=\"completed\",\n                print_name=\"Test\",\n                handled_trays=set(),\n                printer_manager=printer_manager,\n                db=db,\n                tray_now_at_start=0,\n                threemf_path=mock_path,\n            )\n\n        assert len(results) == 1\n        assert results[0][\"weight_used\"] == 2.0\n"
  },
  {
    "path": "backend/tests/unit/test_user_notifications.py",
    "content": "\"\"\"Tests for user email notification preferences and permissions.\"\"\"\n\nfrom backend.app.core.permissions import (\n    ALL_PERMISSIONS,\n    DEFAULT_GROUPS,\n    PERMISSION_CATEGORIES,\n    Permission,\n)\nfrom backend.app.schemas.user_notifications import (\n    UserEmailPreferenceResponse,\n    UserEmailPreferenceUpdate,\n)\n\n\nclass TestNotificationsUserEmailPermission:\n    \"\"\"Test the NOTIFICATIONS_USER_EMAIL permission integration.\"\"\"\n\n    def test_permission_exists(self):\n        \"\"\"notifications:user_email permission should exist in the enum.\"\"\"\n        assert hasattr(Permission, \"NOTIFICATIONS_USER_EMAIL\")\n        assert Permission.NOTIFICATIONS_USER_EMAIL == \"notifications:user_email\"\n\n    def test_permission_in_all_permissions(self):\n        \"\"\"notifications:user_email should be in ALL_PERMISSIONS list.\"\"\"\n        assert \"notifications:user_email\" in ALL_PERMISSIONS\n\n    def test_permission_in_notifications_category(self):\n        \"\"\"notifications:user_email should be in the Notifications permission category.\"\"\"\n        notifications_perms = PERMISSION_CATEGORIES[\"Notifications\"]\n        assert Permission.NOTIFICATIONS_USER_EMAIL in notifications_perms\n\n    def test_administrators_have_permission(self):\n        \"\"\"Administrators should have notifications:user_email via ALL_PERMISSIONS.\"\"\"\n        admins = DEFAULT_GROUPS[\"Administrators\"]\n        assert \"notifications:user_email\" in admins[\"permissions\"]\n\n    def test_operators_have_permission(self):\n        \"\"\"Operators should have notifications:user_email for managing their own preferences.\"\"\"\n        operators = DEFAULT_GROUPS[\"Operators\"]\n        assert \"notifications:user_email\" in operators[\"permissions\"]\n\n    def test_viewers_do_not_have_permission(self):\n        \"\"\"Viewers (read-only) should not have notifications:user_email.\"\"\"\n        viewers = DEFAULT_GROUPS[\"Viewers\"]\n        assert \"notifications:user_email\" not in viewers[\"permissions\"]\n\n    def test_permission_separate_from_notifications_read(self):\n        \"\"\"user_email and read should be distinct permissions.\"\"\"\n        assert Permission.NOTIFICATIONS_USER_EMAIL != Permission.NOTIFICATIONS_READ\n        assert Permission.NOTIFICATIONS_USER_EMAIL.value != Permission.NOTIFICATIONS_READ.value\n\n\nclass TestUserEmailPreferenceSchemas:\n    \"\"\"Test the user email preference Pydantic schemas.\"\"\"\n\n    def test_response_schema_defaults(self):\n        \"\"\"Response schema should accept all four boolean fields.\"\"\"\n        resp = UserEmailPreferenceResponse(\n            notify_print_start=True,\n            notify_print_complete=True,\n            notify_print_failed=True,\n            notify_print_stopped=True,\n        )\n        assert resp.notify_print_start is True\n        assert resp.notify_print_complete is True\n        assert resp.notify_print_failed is True\n        assert resp.notify_print_stopped is True\n\n    def test_response_schema_all_disabled(self):\n        \"\"\"Response schema should handle all-disabled preferences.\"\"\"\n        resp = UserEmailPreferenceResponse(\n            notify_print_start=False,\n            notify_print_complete=False,\n            notify_print_failed=False,\n            notify_print_stopped=False,\n        )\n        assert resp.notify_print_start is False\n        assert resp.notify_print_complete is False\n        assert resp.notify_print_failed is False\n        assert resp.notify_print_stopped is False\n\n    def test_update_schema_accepts_mixed(self):\n        \"\"\"Update schema should accept a mix of enabled/disabled.\"\"\"\n        update = UserEmailPreferenceUpdate(\n            notify_print_start=True,\n            notify_print_complete=False,\n            notify_print_failed=True,\n            notify_print_stopped=False,\n        )\n        assert update.notify_print_start is True\n        assert update.notify_print_complete is False\n        assert update.notify_print_failed is True\n        assert update.notify_print_stopped is False\n\n    def test_response_schema_from_attributes(self):\n        \"\"\"Response schema should support from_attributes (ORM mode).\"\"\"\n        assert UserEmailPreferenceResponse.model_config.get(\"from_attributes\") is True\n\n\nclass TestNotificationTemplateTypes:\n    \"\"\"Test that user print notification template types are registered.\"\"\"\n\n    def test_user_print_template_types_exist(self):\n        \"\"\"All four user print email template types should be in EVENT_NAMES.\"\"\"\n        from backend.app.api.routes.notification_templates import EVENT_NAMES\n\n        expected_types = [\n            \"user_print_start\",\n            \"user_print_complete\",\n            \"user_print_failed\",\n            \"user_print_stopped\",\n        ]\n        for event_type in expected_types:\n            assert event_type in EVENT_NAMES, f\"{event_type} not in EVENT_NAMES\"\n\n    def test_user_print_template_display_names(self):\n        \"\"\"User print template display names should be descriptive.\"\"\"\n        from backend.app.api.routes.notification_templates import EVENT_NAMES\n\n        assert EVENT_NAMES[\"user_print_start\"] == \"User Print Started Email\"\n        assert EVENT_NAMES[\"user_print_complete\"] == \"User Print Completed Email\"\n        assert EVENT_NAMES[\"user_print_failed\"] == \"User Print Failed Email\"\n        assert EVENT_NAMES[\"user_print_stopped\"] == \"User Print Stopped Email\"\n"
  },
  {
    "path": "backend/tests/unit/test_vp_ftp_port.py",
    "content": "\"\"\"Tests for Virtual Printer FTP server port configuration.\"\"\"\n\nfrom backend.app.services.virtual_printer.ftp_server import FTP_PORT\n\n\nclass TestFTPPort:\n    \"\"\"Verify FTP server uses the standard FTPS port.\"\"\"\n\n    def test_ftp_port_is_990(self):\n        \"\"\"FTP must bind to port 990 (standard implicit FTPS).\n\n        Port 9990 required an iptables REDIRECT rule which rewrites\n        the destination IP to the interface's primary address, breaking\n        multi-VP setups with different bind IPs and access codes.\n        \"\"\"\n        assert FTP_PORT == 990, (\n            f\"FTP_PORT must be 990 (standard FTPS), not {FTP_PORT}. \"\n            \"Using a non-standard port requires iptables REDIRECT which \"\n            \"breaks multi-VP setups.\"\n        )\n"
  },
  {
    "path": "backend/tests/unit/test_vp_mqtt_server.py",
    "content": "\"\"\"Tests for Virtual Printer MQTT server.\"\"\"\n\nimport ast\nimport asyncio\nimport inspect\nimport json\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, MagicMock\n\nimport pytest\n\nfrom backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer\n\n\nclass TestMQTTServerNoGlobalState:\n    \"\"\"Ensure MQTT server doesn't set global asyncio state.\"\"\"\n\n    def test_no_global_exception_handler(self):\n        \"\"\"MQTT server must not call set_exception_handler().\n\n        set_exception_handler() is global to the event loop. When multiple\n        VP instances run, each would overwrite the previous handler,\n        causing lost error context and spurious 'Unhandled exception in\n        client_connected_cb' messages.\n        \"\"\"\n        source = inspect.getsource(SimpleMQTTServer)\n        tree = ast.parse(source)\n        for node in ast.walk(tree):\n            if isinstance(node, ast.Attribute) and node.attr == \"set_exception_handler\":\n                raise AssertionError(\n                    \"SimpleMQTTServer must not call set_exception_handler(). \"\n                    \"It overwrites the global asyncio exception handler, \"\n                    \"breaking multi-VP setups.\"\n                )\n\n\ndef _make_server(serial: str = \"01P00A391800001\") -> SimpleMQTTServer:\n    \"\"\"Build a SimpleMQTTServer with dummy cert paths (start() is never called).\"\"\"\n    return SimpleMQTTServer(\n        serial=serial,\n        access_code=\"deadbeef\",\n        cert_path=Path(\"/tmp/unused.crt\"),  # nosec B108\n        key_path=Path(\"/tmp/unused.key\"),  # nosec B108\n        model=\"C12\",\n    )\n\n\nclass TestExtractSerialFromTopic:\n    \"\"\"_extract_serial_from_topic should pull the serial out of device topics.\"\"\"\n\n    @pytest.mark.parametrize(\n        \"topic,expected\",\n        [\n            (\"device/01P00A391800001/request\", \"01P00A391800001\"),\n            (\"device/09400A391800003/report\", \"09400A391800003\"),\n            (\"device/00M00A391800004/request/subpath\", \"00M00A391800004\"),\n        ],\n    )\n    def test_valid_topics(self, topic, expected):\n        assert SimpleMQTTServer._extract_serial_from_topic(topic) == expected\n\n    @pytest.mark.parametrize(\n        \"topic\",\n        [\n            \"\",\n            \"device/\",\n            \"device//request\",  # empty serial\n            \"notdevice/01P00A/request\",\n            \"random\",\n        ],\n    )\n    def test_invalid_topics(self, topic):\n        assert SimpleMQTTServer._extract_serial_from_topic(topic) is None\n\n\ndef _build_publish_payload(topic: str, message: dict) -> bytes:\n    \"\"\"Build the MQTT PUBLISH packet *payload* (past the fixed header byte).\"\"\"\n    topic_bytes = topic.encode(\"utf-8\")\n    message_bytes = json.dumps(message).encode(\"utf-8\")\n    return len(topic_bytes).to_bytes(2, \"big\") + topic_bytes + message_bytes\n\n\nclass TestPublishHandlerAdaptiveSerial:\n    \"\"\"#927: `_handle_publish` must accept any `device/*/request` topic from an\n    authenticated client and use the topic's serial for all responses.\"\"\"\n\n    def test_handle_publish_accepts_mismatched_serial(self):\n        \"\"\"Prior behavior silently dropped publishes whose topic serial didn't\n        equal self.serial. After the fix the handler must run and learn the\n        client's serial.\n        \"\"\"\n        server = _make_server(serial=\"01P00A391800001\")  # synthetic VP serial\n        server._client_serials[\"test-client\"] = server.serial  # simulate post-CONNECT\n\n        writer = MagicMock()\n        writer.write = MagicMock()\n        writer.drain = AsyncMock()\n\n        # Slicer publishes with a *different* serial — the exact bug from #927.\n        topic = \"device/01P00AABCDEFGHI/request\"\n        payload = _build_publish_payload(topic, {\"info\": {\"command\": \"get_version\", \"sequence_id\": \"42\"}})\n\n        asyncio.run(server._handle_publish(0x30, payload, writer, \"test-client\"))\n\n        # Learned the client's serial.\n        assert server._client_serials[\"test-client\"] == \"01P00AABCDEFGHI\"\n\n        # Wrote at least one packet to the slicer (the version response).\n        assert writer.write.called\n        all_bytes = b\"\".join(call.args[0] for call in writer.write.call_args_list)\n        # Response topic must contain the *client's* serial, not self.serial.\n        assert b\"device/01P00AABCDEFGHI/report\" in all_bytes\n        assert b\"device/01P00A391800001/report\" not in all_bytes\n        # Response body carries get_version with the client's serial as sn.\n        assert b'\"command\": \"get_version\"' in all_bytes\n        assert b'\"sn\": \"01P00AABCDEFGHI\"' in all_bytes\n\n    def test_handle_publish_ignores_non_request_topics(self):\n        server = _make_server()\n        server._client_serials[\"c1\"] = server.serial\n        writer = MagicMock()\n        writer.write = MagicMock()\n        writer.drain = AsyncMock()\n\n        payload = _build_publish_payload(\n            \"device/01P00AABCDEFGHI/report\",  # /report, not /request\n            {\"pushing\": {\"command\": \"pushall\"}},\n        )\n        asyncio.run(server._handle_publish(0x30, payload, writer, \"c1\"))\n\n        assert not writer.write.called  # no response\n        # Client serial unchanged\n        assert server._client_serials[\"c1\"] == server.serial\n\n    def test_handle_publish_pushall_uses_client_serial(self):\n        \"\"\"pushall → status_report must be sent on the client's subscribed topic.\"\"\"\n        server = _make_server(serial=\"01P00A391800001\")\n        server._client_serials[\"c1\"] = server.serial\n\n        writer = MagicMock()\n        writer.write = MagicMock()\n        writer.drain = AsyncMock()\n\n        payload = _build_publish_payload(\n            \"device/CUSTOMSERIAL123/request\",\n            {\"pushing\": {\"command\": \"pushall\", \"sequence_id\": \"1\"}},\n        )\n        asyncio.run(server._handle_publish(0x30, payload, writer, \"c1\"))\n\n        all_bytes = b\"\".join(call.args[0] for call in writer.write.call_args_list)\n        assert b\"device/CUSTOMSERIAL123/report\" in all_bytes\n        assert b'\"command\": \"push_status\"' in all_bytes\n        assert server._client_serials[\"c1\"] == \"CUSTOMSERIAL123\"\n\n    def test_handle_publish_tolerates_null_terminated_payload(self):\n        \"\"\"#927: OrcaSlicer on Linux appends the C-string \\\\0 to MQTT payloads.\n        The handler must still parse and respond rather than silently dropping.\"\"\"\n        server = _make_server(serial=\"01P00A391800001\")\n        server._client_serials[\"c1\"] = server.serial\n\n        writer = MagicMock()\n        writer.write = MagicMock()\n        writer.drain = AsyncMock()\n\n        topic = \"device/01P00A391800001/request\"\n        topic_bytes = topic.encode(\"utf-8\")\n        # Real-world bytes captured from EdwardChamberlain's support log: the\n        # JSON ends with an extra \\x00 that strict json.loads rejects.\n        message_bytes = b'{\"pushing\":{\"command\":\"pushall\",\"sequence_id\":\"7\"}}\\x00'\n        payload = len(topic_bytes).to_bytes(2, \"big\") + topic_bytes + message_bytes\n\n        asyncio.run(server._handle_publish(0x30, payload, writer, \"c1\"))\n\n        all_bytes = b\"\".join(call.args[0] for call in writer.write.call_args_list)\n        assert b\"device/01P00A391800001/report\" in all_bytes\n        assert b'\"command\": \"push_status\"' in all_bytes\n\n\nclass TestClientSerialLifecycle:\n    \"\"\"_client_serials must be cleaned up on disconnect/stop to avoid leaks.\"\"\"\n\n    def test_stop_clears_client_serials(self):\n        server = _make_server()\n        server._client_serials[\"a\"] = \"X\"\n        server._client_serials[\"b\"] = \"Y\"\n        # stop() is async but we only need to cover the clear() path; run a minimal version\n        asyncio.run(server.stop())\n        assert server._client_serials == {}\n"
  },
  {
    "path": "build_docker.sh",
    "content": "#!/bin/sh\n\nsudo DOCKER_BUILDKIT=0 docker compose build --no-cache\n"
  },
  {
    "path": "deploy/bambuddy.service",
    "content": "# BamBuddy Systemd Service Template\n#\n# INSTALLATION:\n# 1. Copy this file to /etc/systemd/system/bambuddy.service\n# 2. Replace placeholders:\n#    - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy)\n#    - SERVICE_USER: User to run as (e.g., bambuddy)\n#    - DATA_DIR: Data directory (e.g., /opt/bambuddy/data)\n#    - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs)\n# 3. Run: sudo systemctl daemon-reload\n# 4. Run: sudo systemctl enable bambuddy\n# 5. Run: sudo systemctl start bambuddy\n#\n# Or use the install script: ./install/install.sh\n#\n\n[Unit]\nDescription=BamBuddy - Bambu Lab Print Management\nDocumentation=https://github.com/maziggy/bambuddy\nAfter=network.target\n\n[Service]\nType=simple\nUser=SERVICE_USER\nGroup=SERVICE_USER\nWorkingDirectory=INSTALL_PATH\n\n# Environment file (optional - created by install script)\nEnvironmentFile=-INSTALL_PATH/.env\n\n# Use virtual environment\nEnvironment=\"PATH=INSTALL_PATH/venv/bin:/usr/local/bin:/usr/bin:/bin\"\n\n# Server configuration\nExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}\n\n# Restart policy\nRestart=on-failure\nRestartSec=5\n\n# Graceful shutdown\nTimeoutStopSec=10\n\n# Kill zombie ffmpeg processes (timelapse processing)\nExecStartPre=-/usr/bin/pkill -9 -f \"ffmpeg.*bambuddy\"\nExecStopPost=-/usr/bin/pkill -9 -f \"ffmpeg.*bambuddy\"\n\n# Logging\nStandardOutput=journal\nStandardError=journal\nSyslogIdentifier=bambuddy\n\n# Security hardening\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "docker-compose.test.yml",
    "content": "services:\n  # Backend unit tests\n  backend-test:\n    build:\n      context: .\n      dockerfile: Dockerfile.test\n      target: backend-test\n    container_name: bambuddy-backend-test\n    volumes:\n      - ./backend:/app/backend:ro\n    environment:\n      - TESTING=1\n      - PYTHONUNBUFFERED=1\n\n  # Frontend unit tests\n  frontend-test:\n    build:\n      context: .\n      dockerfile: Dockerfile.test\n      target: frontend-test\n    container_name: bambuddy-frontend-test\n    volumes:\n      - ./frontend/src:/app/frontend/src:ro\n      - ./frontend/tests:/app/frontend/tests:ro\n\n  # Integration test - full application\n  integration:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    container_name: bambuddy-integration-test\n    ports:\n      - \"8001:8000\"\n    environment:\n      - TESTING=1\n      - DATA_DIR=/app/data\n    healthcheck:\n      test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\"]\n      interval: 5s\n      timeout: 5s\n      retries: 10\n      start_period: 10s\n    volumes:\n      - integration_test_data:/app/data\n\n  # Integration test runner\n  integration-test-runner:\n    build:\n      context: .\n      dockerfile: Dockerfile.test\n      target: backend-test\n    container_name: bambuddy-integration-runner\n    depends_on:\n      integration:\n        condition: service_healthy\n    environment:\n      - BAMBUDDY_TEST_URL=http://integration:8000\n      - TESTING=1\n    command: [\"pytest\", \"backend/tests/integration/\", \"-v\", \"--tb=short\", \"-p\", \"no:cacheprovider\", \"-n\", \"auto\"]\n    volumes:\n      - ./backend:/app/backend:ro\n\nvolumes:\n  integration_test_data:\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  bambuddy:\n    image: ghcr.io/maziggy/bambuddy:latest\n    build: .\n    # Usage:\n    #   docker compose up -d          → pulls pre-built image from ghcr.io\n    #   docker compose up -d --build  → builds locally from source\n    container_name: bambuddy\n    # Run as current user to avoid permission issues with mounted volumes\n    # Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d\n    user: \"${PUID:-1000}:${PGID:-1000}\"\n    #\n    # Proxy mode: allow binding to privileged ports (322, 990) as non-root user.\n    # Without this, the FTP and RTSP proxies silently fail.\n    cap_add:\n      - NET_BIND_SERVICE\n    #\n    # LINUX: Use host mode for printer discovery and camera streaming\n    network_mode: host\n    #\n    # macOS/WINDOWS: Docker Desktop doesn't support host mode.\n    # Comment out \"network_mode: host\" above and uncomment \"ports:\" below.\n    # Note: Printer discovery won't work - add printers manually by IP.\n    #ports:\n    #  - \"${PORT:-8000}:8000\"\n    #  - \"3000:3000\"                  # Virtual printer bind/detect\n    #  - \"3002:3002\"                  # Virtual printer bind/detect\n    #  - \"8883:8883\"                  # Virtual printer MQTT\n    #  - \"990:990\"                    # Virtual printer FTP control\n    #  - \"6000:6000\"                  # Virtual printer file transfer tunnel\n    #  - \"322:322\"                    # Virtual printer RTSP camera (X1/H2/P2)\n    #  - \"2024-2026:2024-2026\"        # Virtual printer proprietary ports (A1/P1S)\n    #  - \"50000-50100:50000-50100\"    # Virtual printer FTP passive data\n    volumes:\n      - bambuddy_data:/app/data\n      - bambuddy_logs:/app/logs\n      #\n      # Share virtual printer certs with native installation\n      # This ensures the slicer only needs to trust one CA certificate.\n      - ./virtual_printer:/app/data/virtual_printer\n      #\n      # Mount scheduled backup output to NAS or external storage\n      # Backups default to DATA_DIR/backups/ inside the data volume.\n      # Uncomment to store them externally (e.g. on a NAS share).\n      #- /path/to/nas/bambuddy-backups:/app/data/backups\n    environment:\n      - TZ=${TZ:-Europe/Berlin}\n      # Port BamBuddy runs on (default: 8000)\n      # Usage: PORT=8080 docker compose up -d\n      - PORT=${PORT:-8000}\n      # Virtual printer: Set to the Docker host's IP when using bridge mode (ports:).\n      # Required for FTP passive mode to work behind NAT.\n      # Example: VIRTUAL_PRINTER_PASV_ADDRESS=192.168.1.100\n      #- VIRTUAL_PRINTER_PASV_ADDRESS=\n      #\n      # External PostgreSQL (optional — uses SQLite by default)\n      # Example: DATABASE_URL=postgresql+asyncpg://bambuddy:password@db-host:5432/bambuddy\n      #- DATABASE_URL=\n    restart: unless-stopped\n\n  # Optional: External PostgreSQL database\n  # Uncomment to run Postgres alongside Bambuddy (or use an external Postgres host)\n  #postgres:\n  #  image: postgres:16-alpine\n  #  container_name: bambuddy-db\n  #  restart: unless-stopped\n  #  environment:\n  #    POSTGRES_USER: bambuddy\n  #    POSTGRES_PASSWORD: changeme\n  #    POSTGRES_DB: bambuddy\n  #  volumes:\n  #    - bambuddy_pgdata:/var/lib/postgresql/data\n  #  ports:\n  #    - \"5432:5432\"\n\nvolumes:\n  bambuddy_data:\n  bambuddy_logs:\n  #bambuddy_pgdata:\n"
  },
  {
    "path": "docker-publish-beta.sh",
    "content": "#!/bin/bash\n# Build and push multi-architecture Docker image to GHCR (private beta)\n#\n# Usage:\n#   ./docker-publish-beta.sh [version] [--parallel]\n#\n# Examples:\n#   ./docker-publish-beta.sh 0.2.0b            # Sequential build\n#   ./docker-publish-beta.sh 0.2.0b --parallel # Build both archs simultaneously\n#\n# All versions are also tagged as 'beta'\n#\n# Prerequisites:\n#   1. Log in to ghcr.io:\n#      echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin\n#\n#   2. Create a GitHub Personal Access Token with 'write:packages' scope:\n#      https://github.com/settings/tokens/new?scopes=write:packages\n#\n#   3. After first push, set package to Private in GitHub → Packages → Settings\n#      and add beta testers via Manage Access\n#\n# Supported architectures:\n#   - linux/amd64 (x86_64, most servers/desktops)\n#   - linux/arm64 (Raspberry Pi 4/5, Apple Silicon via emulation)\n\nset -e\n\n# Configuration\nGHCR_REGISTRY=\"ghcr.io\"\nIMAGE_NAME=\"maziggy/bambuddy-beta\"\nGHCR_IMAGE=\"${GHCR_REGISTRY}/${IMAGE_NAME}\"\nPLATFORMS=\"linux/amd64,linux/arm64\"\nBUILDER_NAME=\"bambuddy-builder\"\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Parse arguments\nVERSION=\"\"\nPARALLEL=false\nfor arg in \"$@\"; do\n    case $arg in\n        --parallel)\n            PARALLEL=true\n            ;;\n        *)\n            if [ -z \"$VERSION\" ]; then\n                VERSION=\"$arg\"\n            fi\n            ;;\n    esac\ndone\n\nif [ -z \"$VERSION\" ]; then\n    echo -e \"${YELLOW}Usage: $0 <version> [--parallel]${NC}\"\n    echo \"Example: $0 0.2.0b\"\n    echo \"         $0 0.2.0b --parallel  # Build both architectures simultaneously\"\n    exit 1\nfi\n\n# Get CPU count\nCPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)\n\necho -e \"${GREEN}================================================${NC}\"\necho -e \"${GREEN}  Building multi-arch BETA image${NC}\"\necho -e \"${GREEN}  Version: ${VERSION}${NC}\"\necho -e \"${GREEN}  Platforms: ${PLATFORMS}${NC}\"\necho -e \"${GREEN}  CPU cores: ${CPU_COUNT}${NC}\"\nif [ \"$PARALLEL\" = true ]; then\n    echo -e \"${GREEN}  Mode: PARALLEL (both archs simultaneously)${NC}\"\nelse\n    echo -e \"${GREEN}  Mode: Sequential (amd64 → arm64)${NC}\"\nfi\necho -e \"${GREEN}  Registry: ${GHCR_IMAGE}${NC}\"\necho -e \"${GREEN}================================================${NC}\"\necho \"\"\n\n# Check registry login\nif ! grep -q \"ghcr.io\" ~/.docker/config.json 2>/dev/null; then\n    echo -e \"${YELLOW}Warning: You may not be logged in to ghcr.io${NC}\"\n    echo \"Run: echo \\$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin\"\n    echo \"\"\nfi\n\n# Setup buildx builder if not exists\necho -e \"${BLUE}[1/4] Setting up Docker Buildx...${NC}\"\nif ! docker buildx inspect \"$BUILDER_NAME\" >/dev/null 2>&1; then\n    echo \"Creating new buildx builder: $BUILDER_NAME (optimized for ${CPU_COUNT} cores)\"\n    docker buildx create \\\n        --name \"$BUILDER_NAME\" \\\n        --driver docker-container \\\n        --driver-opt network=host \\\n        --driver-opt \"env.BUILDKIT_STEP_LOG_MAX_SIZE=10000000\" \\\n        --buildkitd-flags \"--allow-insecure-entitlement network.host --oci-worker-gc=false\" \\\n        --config /dev/stdin <<EOF\n[worker.oci]\n  max-parallelism = ${CPU_COUNT}\nEOF\n    docker buildx inspect --bootstrap \"$BUILDER_NAME\"\nfi\ndocker buildx use \"$BUILDER_NAME\"\n\n# Verify builder supports multi-platform\necho -e \"${BLUE}[2/4] Verifying multi-platform support...${NC}\"\nif ! docker buildx inspect --bootstrap | grep -q \"linux/arm64\"; then\n    echo -e \"${YELLOW}Installing QEMU for cross-platform builds...${NC}\"\n    docker run --privileged --rm tonistiigi/binfmt --install all\nfi\n\n# Build tags — version + beta (not latest)\nTAGS=\"-t ${GHCR_IMAGE}:${VERSION} -t ${GHCR_IMAGE}:beta\"\n\necho -e \"${BLUE}[3/4] Building and pushing...${NC}\"\n\n# Common build args (no cache to ensure clean builds)\nBUILD_ARGS=\"--provenance=false --sbom=false --no-cache --pull\"\n\nif [ \"$PARALLEL\" = true ]; then\n    # Parallel build: Build each architecture separately then combine manifests\n    echo -e \"${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}\"\n\n    # Build amd64 in background\n    (\n        echo -e \"${BLUE}[amd64] Starting build...${NC}\"\n        docker buildx build \\\n            --platform linux/amd64 \\\n            -t \"${GHCR_IMAGE}:${VERSION}-amd64\" \\\n            ${BUILD_ARGS} \\\n            --push \\\n            . 2>&1 | sed 's/^/[amd64] /'\n        echo -e \"${GREEN}[amd64] Complete!${NC}\"\n    ) &\n    PID_AMD64=$!\n\n    # Build arm64 in background\n    (\n        echo -e \"${BLUE}[arm64] Starting build...${NC}\"\n        docker buildx build \\\n            --platform linux/arm64 \\\n            -t \"${GHCR_IMAGE}:${VERSION}-arm64\" \\\n            ${BUILD_ARGS} \\\n            --push \\\n            . 2>&1 | sed 's/^/[arm64] /'\n        echo -e \"${GREEN}[arm64] Complete!${NC}\"\n    ) &\n    PID_ARM64=$!\n\n    # Wait for both builds\n    echo \"Waiting for parallel builds to complete...\"\n    wait $PID_AMD64\n    wait $PID_ARM64\n\n    # Create multi-arch manifest\n    echo -e \"${BLUE}Creating multi-arch manifest...${NC}\"\n    docker buildx imagetools create \\\n        -t \"${GHCR_IMAGE}:${VERSION}\" -t \"${GHCR_IMAGE}:beta\" \\\n        \"${GHCR_IMAGE}:${VERSION}-amd64\" \\\n        \"${GHCR_IMAGE}:${VERSION}-arm64\"\nelse\n    # Sequential build (default): Build both platforms in one command\n    echo -e \"${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}\"\n    DOCKER_BUILDKIT=1 docker buildx build \\\n        --platform \"$PLATFORMS\" \\\n        ${BUILD_ARGS} \\\n        $TAGS \\\n        --push \\\n        .\nfi\n\necho -e \"${BLUE}[4/4] Verifying manifest...${NC}\"\ndocker buildx imagetools inspect \"${GHCR_IMAGE}:${VERSION}\"\n\necho \"\"\necho -e \"${GREEN}================================================${NC}\"\necho -e \"${GREEN}  Successfully pushed multi-arch BETA image:${NC}\"\necho -e \"${GREEN}================================================${NC}\"\necho \"  ${GHCR_IMAGE}:${VERSION}\"\necho \"  ${GHCR_IMAGE}:beta\"\necho \"\"\necho -e \"${BLUE}Supported platforms:${NC}\"\necho \"  - linux/amd64 (Intel/AMD servers, desktops)\"\necho \"  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)\"\necho \"\"\necho -e \"${GREEN}Beta testers can run:${NC}\"\necho \"  docker pull ${GHCR_IMAGE}:${VERSION}\"\necho \"  docker pull ${GHCR_IMAGE}:beta\"\necho \"\"\necho -e \"${YELLOW}Reminder: Set package to Private in GitHub → Packages → Settings${NC}\"\n"
  },
  {
    "path": "docker-publish-daily-beta.sh",
    "content": "#!/bin/bash\n# Daily beta build: build Docker image, push to registries, create/update GitHub prerelease\n#\n# Usage:\n#   ./docker-publish-daily-beta.sh [--parallel] [--ghcr-only] [--dockerhub-only] [--skip-release]\n#\n# Examples:\n#   ./docker-publish-daily-beta.sh                  # Full daily beta workflow\n#   ./docker-publish-daily-beta.sh --parallel       # Build both archs simultaneously\n#   ./docker-publish-daily-beta.sh --ghcr-only      # Only push to GHCR\n#   ./docker-publish-daily-beta.sh --dockerhub-only # Only push to Docker Hub\n#   ./docker-publish-daily-beta.sh --skip-release   # Build+push without GitHub release\n#\n# Reads APP_VERSION from backend/app/core/config.py (must be a beta version like 0.2.2b1).\n# Builds and pushes a multi-arch Docker image tagged as 'daily'. Each push overwrites the\n# previous 'daily' image. A GitHub prerelease is created with a date-stamped tag for history.\n#\n# Users can stay up to date by pulling the 'daily' tag or using Watchtower:\n#   docker pull ghcr.io/maziggy/bambuddy:daily\n#\n# Prerequisites:\n#   1. Log in to ghcr.io:\n#      echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin\n#\n#   2. Log in to Docker Hub:\n#      docker login -u YOUR_USERNAME\n#\n#   3. GitHub CLI (gh) authenticated for creating releases\n#\n# Supported architectures:\n#   - linux/amd64 (x86_64, most servers/desktops)\n#   - linux/arm64 (Raspberry Pi 4/5, Apple Silicon via emulation)\n\nset -e\n\n# Configuration\nGHCR_REGISTRY=\"ghcr.io\"\nDOCKERHUB_REGISTRY=\"docker.io\"\nIMAGE_NAME=\"maziggy/bambuddy\"\nGHCR_IMAGE=\"${GHCR_REGISTRY}/${IMAGE_NAME}\"\nDOCKERHUB_IMAGE=\"${DOCKERHUB_REGISTRY}/${IMAGE_NAME}\"\nPLATFORMS=\"linux/amd64,linux/arm64\"\nBUILDER_NAME=\"bambuddy-builder\"\nCONFIG_FILE=\"backend/app/core/config.py\"\nCHANGELOG_FILE=\"CHANGELOG.md\"\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Parse arguments\nPARALLEL=false\nPUSH_GHCR=true\nPUSH_DOCKERHUB=true\nSKIP_RELEASE=false\nfor arg in \"$@\"; do\n    case $arg in\n        --parallel)\n            PARALLEL=true\n            ;;\n        --ghcr-only)\n            PUSH_DOCKERHUB=false\n            ;;\n        --dockerhub-only)\n            PUSH_GHCR=false\n            ;;\n        --skip-release)\n            SKIP_RELEASE=true\n            ;;\n        --help|-h)\n            echo \"Usage: $0 [--parallel] [--ghcr-only] [--dockerhub-only] [--skip-release]\"\n            echo \"\"\n            echo \"Build and publish a daily beta Docker image using the APP_VERSION from config.py.\"\n            echo \"\"\n            echo \"Options:\"\n            echo \"  --parallel       Build both architectures simultaneously\"\n            echo \"  --ghcr-only      Only push to GitHub Container Registry\"\n            echo \"  --dockerhub-only Only push to Docker Hub\"\n            echo \"  --skip-release   Build+push without creating/updating GitHub release\"\n            echo \"  --help, -h       Show this help\"\n            exit 0\n            ;;\n        *)\n            echo -e \"${RED}Unknown argument: $arg${NC}\"\n            echo \"Run $0 --help for usage\"\n            exit 1\n            ;;\n    esac\ndone\n\n# ============================================================\n# Step 1: Read and validate APP_VERSION\n# ============================================================\necho -e \"${BLUE}[1/4] Validating APP_VERSION...${NC}\"\n\nVERSION=$(grep -oP 'APP_VERSION = \"\\K[^\"]+' \"$CONFIG_FILE\")\n\nif [ -z \"$VERSION\" ]; then\n    echo -e \"${RED}Error: Could not read APP_VERSION from ${CONFIG_FILE}${NC}\"\n    exit 1\nfi\n\nif ! [[ \"$VERSION\" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+b[0-9]+$ ]]; then\n    echo -e \"${RED}Error: APP_VERSION '${VERSION}' is not a beta version (expected X.Y.Zb<N>)${NC}\"\n    exit 1\nfi\n\n# Date-stamped tag for GitHub releases only (not used as Docker tag)\nDAILY_DATE=$(date +%Y%m%d)\nDAILY_TAG=\"${VERSION}-daily.${DAILY_DATE}\"\n\necho -e \"${GREEN}  APP_VERSION: ${VERSION}${NC}\"\necho -e \"${GREEN}  Docker tag:  daily${NC}\"\necho -e \"${GREEN}  Release tag: v${DAILY_TAG}${NC}\"\n\n# ============================================================\n# Step 2: Build & push Docker images\n# ============================================================\necho \"\"\n\n# Get CPU count\nCPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)\n\necho -e \"${GREEN}================================================${NC}\"\necho -e \"${GREEN}  Daily beta build${NC}\"\necho -e \"${GREEN}  Version:   ${VERSION}${NC}\"\necho -e \"${GREEN}  Docker tag: daily${NC}\"\necho -e \"${GREEN}  Platforms: ${PLATFORMS}${NC}\"\necho -e \"${GREEN}  CPU cores: ${CPU_COUNT}${NC}\"\nif [ \"$PARALLEL\" = true ]; then\n    echo -e \"${GREEN}  Mode: PARALLEL (both archs simultaneously)${NC}\"\nelse\n    echo -e \"${GREEN}  Mode: Sequential (amd64 → arm64)${NC}\"\nfi\necho -e \"${GREEN}  Registries:${NC}\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    echo -e \"${GREEN}    - ${GHCR_IMAGE}${NC}\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    echo -e \"${GREEN}    - ${DOCKERHUB_IMAGE}${NC}\"\nfi\necho -e \"${GREEN}================================================${NC}\"\necho \"\"\n\n# Check registry logins\nif [ \"$PUSH_GHCR\" = true ]; then\n    if ! grep -q \"ghcr.io\" ~/.docker/config.json 2>/dev/null; then\n        echo -e \"${YELLOW}Warning: You may not be logged in to ghcr.io${NC}\"\n        echo \"Run: echo \\$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin\"\n        echo \"\"\n    fi\nfi\n\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    if ! grep -q \"index.docker.io\\|docker.io\" ~/.docker/config.json 2>/dev/null; then\n        echo -e \"${RED}Error: You are not logged in to Docker Hub${NC}\"\n        echo \"Run: docker login -u YOUR_USERNAME\"\n        echo \"\"\n        exit 1\n    fi\nfi\n\n# Setup buildx builder if not exists\necho -e \"${BLUE}[2/4] Setting up Docker Buildx and building...${NC}\"\nif ! docker buildx inspect \"$BUILDER_NAME\" >/dev/null 2>&1; then\n    echo \"Creating new buildx builder: $BUILDER_NAME (optimized for ${CPU_COUNT} cores)\"\n    docker buildx create \\\n        --name \"$BUILDER_NAME\" \\\n        --driver docker-container \\\n        --driver-opt network=host \\\n        --driver-opt \"env.BUILDKIT_STEP_LOG_MAX_SIZE=10000000\" \\\n        --buildkitd-flags \"--allow-insecure-entitlement network.host --oci-worker-gc=false\" \\\n        --config /dev/stdin <<EOF\n[worker.oci]\n  max-parallelism = ${CPU_COUNT}\nEOF\n    docker buildx inspect --bootstrap \"$BUILDER_NAME\"\nfi\ndocker buildx use \"$BUILDER_NAME\"\n\n# Verify builder supports multi-platform\nif ! docker buildx inspect --bootstrap | grep -q \"linux/arm64\"; then\n    echo -e \"${YELLOW}Installing QEMU for cross-platform builds...${NC}\"\n    docker run --privileged --rm tonistiigi/binfmt --install all\nfi\n\n# Beta versions get 'daily' tag (never 'latest')\necho -e \"${YELLOW}Beta version — tagging as 'daily' (not 'latest')${NC}\"\n\n# Build tags for all target registries\nTAGS=\"\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    TAGS=\"$TAGS -t ${GHCR_IMAGE}:daily\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    TAGS=\"$TAGS -t ${DOCKERHUB_IMAGE}:daily\"\nfi\n\n# Common build args (no cache to ensure clean builds)\nBUILD_ARGS=\"--provenance=false --sbom=false --no-cache --pull\"\n\nif [ \"$PARALLEL\" = true ]; then\n    # Parallel build: Build each architecture separately then combine manifests\n    echo -e \"${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}\"\n\n    # Build per-arch staging tags for each target registry\n    ARCH_TAGS_AMD64=\"\"\n    ARCH_TAGS_ARM64=\"\"\n    if [ \"$PUSH_GHCR\" = true ]; then\n        ARCH_TAGS_AMD64=\"$ARCH_TAGS_AMD64 -t ${GHCR_IMAGE}:daily-amd64\"\n        ARCH_TAGS_ARM64=\"$ARCH_TAGS_ARM64 -t ${GHCR_IMAGE}:daily-arm64\"\n    fi\n    if [ \"$PUSH_DOCKERHUB\" = true ]; then\n        ARCH_TAGS_AMD64=\"$ARCH_TAGS_AMD64 -t ${DOCKERHUB_IMAGE}:daily-amd64\"\n        ARCH_TAGS_ARM64=\"$ARCH_TAGS_ARM64 -t ${DOCKERHUB_IMAGE}:daily-arm64\"\n    fi\n\n    # Build amd64 in background\n    (\n        echo -e \"${BLUE}[amd64] Starting build...${NC}\"\n        docker buildx build \\\n            --platform linux/amd64 \\\n            ${ARCH_TAGS_AMD64} \\\n            ${BUILD_ARGS} \\\n            --push \\\n            . 2>&1 | sed 's/^/[amd64] /'\n        echo -e \"${GREEN}[amd64] Complete!${NC}\"\n    ) &\n    PID_AMD64=$!\n\n    # Build arm64 in background\n    (\n        echo -e \"${BLUE}[arm64] Starting build...${NC}\"\n        docker buildx build \\\n            --platform linux/arm64 \\\n            ${ARCH_TAGS_ARM64} \\\n            ${BUILD_ARGS} \\\n            --push \\\n            . 2>&1 | sed 's/^/[arm64] /'\n        echo -e \"${GREEN}[arm64] Complete!${NC}\"\n    ) &\n    PID_ARM64=$!\n\n    # Wait for both builds\n    echo \"Waiting for parallel builds to complete...\"\n    wait $PID_AMD64\n    wait $PID_ARM64\n\n    # Create multi-arch manifests per registry (no cross-registry blob copies)\n    echo -e \"${BLUE}Creating multi-arch manifests...${NC}\"\n\n    if [ \"$PUSH_GHCR\" = true ]; then\n        echo -e \"${BLUE}  Creating GHCR manifest...${NC}\"\n        docker buildx imagetools create \\\n            -t \"${GHCR_IMAGE}:daily\" \\\n            \"${GHCR_IMAGE}:daily-amd64\" \\\n            \"${GHCR_IMAGE}:daily-arm64\"\n    fi\n    if [ \"$PUSH_DOCKERHUB\" = true ]; then\n        echo -e \"${BLUE}  Creating Docker Hub manifest...${NC}\"\n        docker buildx imagetools create \\\n            -t \"${DOCKERHUB_IMAGE}:daily\" \\\n            \"${DOCKERHUB_IMAGE}:daily-amd64\" \\\n            \"${DOCKERHUB_IMAGE}:daily-arm64\"\n    fi\nelse\n    # Sequential build (default): Build both platforms in one command\n    echo -e \"${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}\"\n    DOCKER_BUILDKIT=1 docker buildx build \\\n        --platform \"$PLATFORMS\" \\\n        ${BUILD_ARGS} \\\n        $TAGS \\\n        --push \\\n        .\nfi\n\n# ============================================================\n# Step 3: Create/update GitHub release\n# ============================================================\nif [ \"$SKIP_RELEASE\" = true ]; then\n    echo -e \"${YELLOW}[3/4] Skipping GitHub release (--skip-release)${NC}\"\nelse\n    echo -e \"${BLUE}[3/4] Creating/updating GitHub release...${NC}\"\n\n    # Extract release notes from CHANGELOG: content between ## [<version>] and the next ## [ heading\n    CHANGELOG_NOTES=$(sed -n \"/^## \\[${VERSION}\\]/,/^## \\[/{/^## \\[/!p}\" \"$CHANGELOG_FILE\" | sed '/^$/d; 1{/^$/d}')\n\n    # Strip @mentions so GitHub doesn't auto-generate a \"Contributors\" section\n    CHANGELOG_NOTES=$(echo \"$CHANGELOG_NOTES\" | sed 's/@\\([a-zA-Z0-9_-]*\\)/\\1/g')\n\n    if [ -z \"$CHANGELOG_NOTES\" ]; then\n        echo -e \"${YELLOW}  Warning: No changelog notes found for ${VERSION}${NC}\"\n        CHANGELOG_NOTES=\"No changelog notes available for this release.\"\n    fi\n\n    # Build pull commands for the release body\n    PULL_COMMANDS=\"\"\n    if [ \"$PUSH_GHCR\" = true ]; then\n        PULL_COMMANDS=\"docker pull ghcr.io/maziggy/bambuddy:daily\"\n    fi\n    if [ \"$PUSH_DOCKERHUB\" = true ]; then\n        if [ -n \"$PULL_COMMANDS\" ]; then\n            PULL_COMMANDS=\"${PULL_COMMANDS}\n# or\ndocker pull maziggy/bambuddy:daily\"\n        else\n            PULL_COMMANDS=\"docker pull maziggy/bambuddy:daily\"\n        fi\n    fi\n\n    # Create the release body\n    TODAY=$(date +%Y-%m-%d)\n    RELEASE_BODY=$(cat <<EOF\n> [!NOTE]\n> This is a **daily beta build** (${TODAY}). It contains the latest fixes and improvements but may have undiscovered issues.\n>\n> **Docker users:** Update by pulling the new image:\n> \\`\\`\\`\n> ${PULL_COMMANDS}\n> \\`\\`\\`\n>\n> **Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.\n\n---\n\n${CHANGELOG_NOTES}\nEOF\n    )\n\n    # Delete ALL old daily releases — only the latest daily build should exist\n    echo \"  Cleaning up old daily releases...\"\n    OLD_DAILY_RELEASES=$(gh release list --limit 100 --json tagName --jq '.[] | select(.tagName | test(\"-daily\\\\.\")) | .tagName' 2>/dev/null || true)\n    if [ -n \"$OLD_DAILY_RELEASES\" ]; then\n        while IFS= read -r old_tag; do\n            echo \"  Deleting old daily release: ${old_tag}...\"\n            gh release delete \"$old_tag\" --yes --cleanup-tag 2>/dev/null || true\n        done <<< \"$OLD_DAILY_RELEASES\"\n    fi\n\n    # Create/move tag to current HEAD and push\n    echo \"  Tagging current HEAD as v${DAILY_TAG}...\"\n    git tag -f \"v${DAILY_TAG}\"\n    git push origin \"v${DAILY_TAG}\" --force\n\n    echo \"  Creating release v${DAILY_TAG}...\"\n    gh release create \"v${DAILY_TAG}\" \\\n        --title \"Daily Beta Build v${DAILY_TAG}\" \\\n        --prerelease \\\n        --generate-notes=false \\\n        --notes \"$RELEASE_BODY\"\n    echo -e \"${GREEN}  Created GitHub release: v${DAILY_TAG}${NC}\"\nfi\n\n# ============================================================\n# Step 4: Verify\n# ============================================================\necho -e \"${BLUE}[4/4] Verifying...${NC}\"\n\nif [ \"$PUSH_GHCR\" = true ]; then\n    echo -e \"${BLUE}GHCR manifest:${NC}\"\n    docker buildx imagetools inspect \"${GHCR_IMAGE}:daily\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    echo -e \"${BLUE}Docker Hub manifest:${NC}\"\n    docker buildx imagetools inspect \"${DOCKERHUB_IMAGE}:daily\"\nfi\n\nif [ \"$SKIP_RELEASE\" != true ]; then\n    echo \"\"\n    echo -e \"${BLUE}GitHub release:${NC}\"\n    gh release view \"v${DAILY_TAG}\"\nfi\n\n# ============================================================\n# Summary\n# ============================================================\necho \"\"\necho -e \"${GREEN}================================================${NC}\"\necho -e \"${GREEN}  Daily beta build complete!${NC}\"\necho -e \"${GREEN}  Version: ${VERSION}${NC}\"\necho -e \"${GREEN}================================================${NC}\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    echo \"  GHCR:       ${GHCR_IMAGE}:daily\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    echo \"  Docker Hub: ${DOCKERHUB_IMAGE}:daily\"\nfi\nif [ \"$SKIP_RELEASE\" != true ]; then\n    echo \"  Release:    https://github.com/${IMAGE_NAME}/releases/tag/v${DAILY_TAG}\"\nfi\necho \"\"\necho -e \"${BLUE}Supported platforms:${NC}\"\necho \"  - linux/amd64 (Intel/AMD servers, desktops)\"\necho \"  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)\"\necho \"\"\necho -e \"${GREEN}Users can now run:${NC}\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    echo \"  docker pull ${GHCR_IMAGE}:daily\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    echo \"  docker pull ${DOCKERHUB_IMAGE}:daily\"\n    echo \"  docker pull ${IMAGE_NAME}:daily  # shorthand\"\nfi\n"
  },
  {
    "path": "docker-publish.sh",
    "content": "#!/bin/bash\n# Build and push multi-architecture Docker image to GitHub Container Registry AND Docker Hub\n#\n# Usage:\n#   ./docker-publish.sh [version] [--parallel] [--ghcr-only] [--dockerhub-only]\n#\n# Examples:\n#   ./docker-publish.sh 0.1.9b            # Sequential build, push to both registries\n#   ./docker-publish.sh 0.1.9b --parallel # Build both archs simultaneously\n#   ./docker-publish.sh 0.1.9b --ghcr-only    # Only push to GHCR\n#   ./docker-publish.sh 0.1.9b --dockerhub-only # Only push to Docker Hub\n#\n# Note: Stable versions are also tagged as 'latest'. Beta versions (ending in 'b') are not.\n#\n# Prerequisites:\n#   1. Log in to ghcr.io:\n#      echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin\n#\n#   2. Log in to Docker Hub:\n#      docker login -u YOUR_USERNAME\n#\n#   3. Create a GitHub Personal Access Token with 'write:packages' scope:\n#      https://github.com/settings/tokens/new?scopes=write:packages\n#\n# Supported architectures:\n#   - linux/amd64 (x86_64, most servers/desktops)\n#   - linux/arm64 (Raspberry Pi 4/5, Apple Silicon via emulation)\n\nset -e\n\n# Configuration\nGHCR_REGISTRY=\"ghcr.io\"\nDOCKERHUB_REGISTRY=\"docker.io\"\nIMAGE_NAME=\"maziggy/bambuddy\"\nGHCR_IMAGE=\"${GHCR_REGISTRY}/${IMAGE_NAME}\"\nDOCKERHUB_IMAGE=\"${DOCKERHUB_REGISTRY}/${IMAGE_NAME}\"\nPLATFORMS=\"linux/amd64,linux/arm64\"\nBUILDER_NAME=\"bambuddy-builder\"\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Parse arguments\nVERSION=\"\"\nPARALLEL=false\nPUSH_GHCR=true\nPUSH_DOCKERHUB=true\nfor arg in \"$@\"; do\n    case $arg in\n        --parallel)\n            PARALLEL=true\n            ;;\n        --ghcr-only)\n            PUSH_DOCKERHUB=false\n            ;;\n        --dockerhub-only)\n            PUSH_GHCR=false\n            ;;\n        *)\n            if [ -z \"$VERSION\" ]; then\n                VERSION=\"$arg\"\n            fi\n            ;;\n    esac\ndone\n\nif [ -z \"$VERSION\" ]; then\n    echo -e \"${YELLOW}Usage: $0 <version> [--parallel] [--ghcr-only] [--dockerhub-only]${NC}\"\n    echo \"Example: $0 0.1.9b\"\n    echo \"         $0 0.1.9b --parallel     # Build both architectures simultaneously\"\n    echo \"         $0 0.1.9b --ghcr-only    # Only push to GitHub Container Registry\"\n    echo \"         $0 0.1.9b --dockerhub-only # Only push to Docker Hub\"\n    exit 1\nfi\n\n# Get CPU count\nCPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)\n\necho -e \"${GREEN}================================================${NC}\"\necho -e \"${GREEN}  Building multi-arch image${NC}\"\necho -e \"${GREEN}  Version: ${VERSION}${NC}\"\necho -e \"${GREEN}  Platforms: ${PLATFORMS}${NC}\"\necho -e \"${GREEN}  CPU cores: ${CPU_COUNT}${NC}\"\nif [ \"$PARALLEL\" = true ]; then\n    echo -e \"${GREEN}  Mode: PARALLEL (both archs simultaneously)${NC}\"\nelse\n    echo -e \"${GREEN}  Mode: Sequential (amd64 → arm64)${NC}\"\nfi\necho -e \"${GREEN}  Registries:${NC}\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    echo -e \"${GREEN}    - ${GHCR_IMAGE}${NC}\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    echo -e \"${GREEN}    - ${DOCKERHUB_IMAGE}${NC}\"\nfi\necho -e \"${GREEN}================================================${NC}\"\necho \"\"\n\n# Check registry logins\nif [ \"$PUSH_GHCR\" = true ]; then\n    if ! grep -q \"ghcr.io\" ~/.docker/config.json 2>/dev/null; then\n        echo -e \"${YELLOW}Warning: You may not be logged in to ghcr.io${NC}\"\n        echo \"Run: echo \\$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin\"\n        echo \"\"\n    fi\nfi\n\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    if ! grep -q \"index.docker.io\\|docker.io\" ~/.docker/config.json 2>/dev/null; then\n        echo -e \"${RED}Error: You are not logged in to Docker Hub${NC}\"\n        echo \"Run: docker login -u YOUR_USERNAME\"\n        echo \"\"\n        exit 1\n    fi\nfi\n\n# Setup buildx builder if not exists\necho -e \"${BLUE}[1/4] Setting up Docker Buildx...${NC}\"\nif ! docker buildx inspect \"$BUILDER_NAME\" >/dev/null 2>&1; then\n    echo \"Creating new buildx builder: $BUILDER_NAME (optimized for ${CPU_COUNT} cores)\"\n    docker buildx create \\\n        --name \"$BUILDER_NAME\" \\\n        --driver docker-container \\\n        --driver-opt network=host \\\n        --driver-opt \"env.BUILDKIT_STEP_LOG_MAX_SIZE=10000000\" \\\n        --buildkitd-flags \"--allow-insecure-entitlement network.host --oci-worker-gc=false\" \\\n        --config /dev/stdin <<EOF\n[worker.oci]\n  max-parallelism = ${CPU_COUNT}\nEOF\n    docker buildx inspect --bootstrap \"$BUILDER_NAME\"\nfi\ndocker buildx use \"$BUILDER_NAME\"\n\n# Verify builder supports multi-platform\necho -e \"${BLUE}[2/4] Verifying multi-platform support...${NC}\"\nif ! docker buildx inspect --bootstrap | grep -q \"linux/arm64\"; then\n    echo -e \"${YELLOW}Installing QEMU for cross-platform builds...${NC}\"\n    docker run --privileged --rm tonistiigi/binfmt --install all\nfi\n\n# Only tag as 'latest' for stable releases (not beta versions ending in 'b')\nTAG_LATEST=true\nif [[ \"$VERSION\" =~ b[0-9]*$ ]]; then\n    TAG_LATEST=false\n    echo -e \"${YELLOW}Beta version detected — skipping 'latest' tag${NC}\"\nfi\n\n# Build tags for all target registries\nTAGS=\"\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    TAGS=\"$TAGS -t ${GHCR_IMAGE}:${VERSION}\"\n    [ \"$TAG_LATEST\" = true ] && TAGS=\"$TAGS -t ${GHCR_IMAGE}:latest\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    TAGS=\"$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION}\"\n    [ \"$TAG_LATEST\" = true ] && TAGS=\"$TAGS -t ${DOCKERHUB_IMAGE}:latest\"\nfi\n\necho -e \"${BLUE}[3/4] Building and pushing...${NC}\"\n\n# Common build args (no cache to ensure clean builds)\nBUILD_ARGS=\"--provenance=false --sbom=false --no-cache --pull\"\n\nif [ \"$PARALLEL\" = true ]; then\n    # Parallel build: Build each architecture separately then combine manifests\n    echo -e \"${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}\"\n\n    # Build per-arch staging tags for each target registry\n    ARCH_TAGS_AMD64=\"\"\n    ARCH_TAGS_ARM64=\"\"\n    if [ \"$PUSH_GHCR\" = true ]; then\n        ARCH_TAGS_AMD64=\"$ARCH_TAGS_AMD64 -t ${GHCR_IMAGE}:${VERSION}-amd64\"\n        ARCH_TAGS_ARM64=\"$ARCH_TAGS_ARM64 -t ${GHCR_IMAGE}:${VERSION}-arm64\"\n    fi\n    if [ \"$PUSH_DOCKERHUB\" = true ]; then\n        ARCH_TAGS_AMD64=\"$ARCH_TAGS_AMD64 -t ${DOCKERHUB_IMAGE}:${VERSION}-amd64\"\n        ARCH_TAGS_ARM64=\"$ARCH_TAGS_ARM64 -t ${DOCKERHUB_IMAGE}:${VERSION}-arm64\"\n    fi\n\n    # Build amd64 in background\n    (\n        echo -e \"${BLUE}[amd64] Starting build...${NC}\"\n        docker buildx build \\\n            --platform linux/amd64 \\\n            ${ARCH_TAGS_AMD64} \\\n            ${BUILD_ARGS} \\\n            --push \\\n            . 2>&1 | sed 's/^/[amd64] /'\n        echo -e \"${GREEN}[amd64] Complete!${NC}\"\n    ) &\n    PID_AMD64=$!\n\n    # Build arm64 in background\n    (\n        echo -e \"${BLUE}[arm64] Starting build...${NC}\"\n        docker buildx build \\\n            --platform linux/arm64 \\\n            ${ARCH_TAGS_ARM64} \\\n            ${BUILD_ARGS} \\\n            --push \\\n            . 2>&1 | sed 's/^/[arm64] /'\n        echo -e \"${GREEN}[arm64] Complete!${NC}\"\n    ) &\n    PID_ARM64=$!\n\n    # Wait for both builds\n    echo \"Waiting for parallel builds to complete...\"\n    wait $PID_AMD64\n    wait $PID_ARM64\n\n    # Create multi-arch manifests per registry (no cross-registry blob copies)\n    echo -e \"${BLUE}Creating multi-arch manifests...${NC}\"\n\n    if [ \"$PUSH_GHCR\" = true ]; then\n        echo -e \"${BLUE}  Creating GHCR manifest...${NC}\"\n        GHCR_MANIFEST_TAGS=\"-t ${GHCR_IMAGE}:${VERSION}\"\n        [ \"$TAG_LATEST\" = true ] && GHCR_MANIFEST_TAGS=\"$GHCR_MANIFEST_TAGS -t ${GHCR_IMAGE}:latest\"\n        docker buildx imagetools create \\\n            $GHCR_MANIFEST_TAGS \\\n            \"${GHCR_IMAGE}:${VERSION}-amd64\" \\\n            \"${GHCR_IMAGE}:${VERSION}-arm64\"\n    fi\n    if [ \"$PUSH_DOCKERHUB\" = true ]; then\n        echo -e \"${BLUE}  Creating Docker Hub manifest...${NC}\"\n        DH_MANIFEST_TAGS=\"-t ${DOCKERHUB_IMAGE}:${VERSION}\"\n        [ \"$TAG_LATEST\" = true ] && DH_MANIFEST_TAGS=\"$DH_MANIFEST_TAGS -t ${DOCKERHUB_IMAGE}:latest\"\n        docker buildx imagetools create \\\n            $DH_MANIFEST_TAGS \\\n            \"${DOCKERHUB_IMAGE}:${VERSION}-amd64\" \\\n            \"${DOCKERHUB_IMAGE}:${VERSION}-arm64\"\n    fi\nelse\n    # Sequential build (default): Build both platforms in one command\n    echo -e \"${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}\"\n    DOCKER_BUILDKIT=1 docker buildx build \\\n        --platform \"$PLATFORMS\" \\\n        ${BUILD_ARGS} \\\n        $TAGS \\\n        --push \\\n        .\nfi\n\necho -e \"${BLUE}[4/4] Verifying manifests...${NC}\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    echo -e \"${BLUE}GHCR:${NC}\"\n    docker buildx imagetools inspect \"${GHCR_IMAGE}:${VERSION}\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    echo -e \"${BLUE}Docker Hub:${NC}\"\n    docker buildx imagetools inspect \"${DOCKERHUB_IMAGE}:${VERSION}\"\nfi\n\necho \"\"\necho -e \"${GREEN}================================================${NC}\"\necho -e \"${GREEN}  Successfully pushed multi-arch image:${NC}\"\necho -e \"${GREEN}================================================${NC}\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    echo \"  GHCR:\"\n    echo \"    - ${GHCR_IMAGE}:${VERSION}\"\n    [ \"$TAG_LATEST\" = true ] && echo \"    - ${GHCR_IMAGE}:latest\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    echo \"  Docker Hub:\"\n    echo \"    - ${DOCKERHUB_IMAGE}:${VERSION}\"\n    [ \"$TAG_LATEST\" = true ] && echo \"    - ${DOCKERHUB_IMAGE}:latest\"\nfi\necho \"\"\necho -e \"${BLUE}Supported platforms:${NC}\"\necho \"  - linux/amd64 (Intel/AMD servers, desktops)\"\necho \"  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)\"\necho \"\"\necho -e \"${GREEN}Users can now run:${NC}\"\nif [ \"$PUSH_GHCR\" = true ]; then\n    echo \"  docker pull ${GHCR_IMAGE}:${VERSION}\"\nfi\nif [ \"$PUSH_DOCKERHUB\" = true ]; then\n    echo \"  docker pull ${DOCKERHUB_IMAGE}:${VERSION}\"\n    echo \"  docker pull ${IMAGE_NAME}:${VERSION}  # shorthand\"\nfi\n"
  },
  {
    "path": "docs/ams_slot_printer_matrix.txt",
    "content": "  ┌─────────────────┬─────────────────┬────────────────┬────────────────────────────────────┬─────────────────────┐\n  │      Model      │ _is_dual_nozzle │ ams_exist_bits │             Branch hit             │      Behavior       │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ X1C/X1E (1 AMS) │ False           │ \"1\"            │ New elif → num_ams=1 → passthrough │ Unchanged           │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ X1C (2 AMS)     │ False           │ \"3\"            │ New elif → disambiguation          │ Fixed (same as P2S) │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ P1S/P1P (1 AMS) │ False           │ \"1\"            │ New elif → num_ams=1 → passthrough │ Unchanged           │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ A1/A1 Mini      │ False           │ \"1\" or missing │ New elif → num_ams≤1 → passthrough │ Unchanged           │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ P2S (1 AMS)     │ False           │ \"1\"            │ New elif → num_ams=1 → passthrough │ Unchanged           │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ P2S (2 AMS)     │ False           │ \"3\"            │ New elif → disambiguation          │ Fixed               │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ H2D/H2D Pro     │ True            │ any            │ Branch 1 (H2D logic)               │ Unchanged           │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ H2C             │ True            │ any            │ Branch 1 (H2D logic)               │ Unchanged           │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ tray_now=255    │ any             │ any            │ Branch 3 (passthrough)             │ Unchanged           │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ tray_now=254    │ any             │ any            │ Branch 3 (passthrough)             │ Unchanged           │\n  ├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤\n  │ tray_now 4-15   │ any (False)     │ any            │ Branch 3 (passthrough)             │ Unchanged           │\n  └─────────────────┴─────────────────┴────────────────┴────────────────────────────────────┴─────────────────────┘\n"
  },
  {
    "path": "docs/bambu_lab_preset_sync_api.md",
    "content": "# Bambu Lab Preset Sync API Documentation\n\nThis document describes the Bambu Lab cloud API endpoints for syncing slicer presets (filament, print process, and machine profiles) between Bambu Studio and the cloud.\n\n**Captured from:** Bambu Studio v2.4.0.70 with bambu_network_agent v02.04.00.58\n**Date:** 2025-12-08\n\n---\n\n## Authentication\n\nAll API requests require authentication via Bearer token.\n\n### Required Headers\n\n```http\nHost: api.bambulab.com\nAuthorization: Bearer <access_token>\nUser-Agent: bambu_network_agent/02.04.00.58\nX-BBL-Client-Name: BambuStudio\nX-BBL-Client-Type: slicer\nX-BBL-Client-Version: 02.04.00.70\nX-BBL-Device-ID: <uuid>\nX-BBL-Language: en-US\nX-BBL-OS-Type: macos|windows|linux\nX-BBL-OS-Version: <version>\nX-BBL-Agent-Version: 02.04.00.58\naccept: application/json\n```\n\n---\n\n## Endpoints\n\n### 1. Get User Profile\n\n```http\nGET /v1/user-service/my/profile\n```\n\nReturns user account information including UID.\n\n### 2. List All User Presets\n\n```http\nGET /v1/iot-service/api/slicer/setting?version={slicer_version}&public=false\n```\n\n**Parameters:**\n- `version`: Slicer version (e.g., `2.4.0.5`)\n- `public`: Set to `false` for user presets only\n\n**Response:** Returns a list of preset IDs that the user has synced to cloud.\n\n### 3. Get Individual Preset\n\n```http\nGET /v1/iot-service/api/slicer/setting/{preset_id}\n```\n\n**Response:**\n```json\n{\n    \"message\": \"success\",\n    \"code\": null,\n    \"error\": null,\n    \"public\": false,\n    \"version\": \"1.5.0.20\",\n    \"type\": \"filament\",\n    \"name\": \"Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle\",\n    \"update_time\": \"2025-12-08 01:06:27\",\n    \"nickname\": null,\n    \"base_id\": \"GFSA00\",\n    \"setting\": {\n        \"inherits\": \"Bambu PLA Basic @BBL X1C\",\n        \"filament_vendor\": \"\\\"Devil Design\\\"\",\n        \"nozzle_temperature\": \"225,220\",\n        \"pressure_advance\": \"0.03\",\n        \"updated_time\": \"1765138658\"\n    },\n    \"filament_id\": null\n}\n```\n\n---\n\n## Preset ID Naming Convention\n\nPreset IDs follow a specific prefix pattern indicating the type:\n\n| Prefix | Type | Description |\n|--------|------|-------------|\n| `PPUS` | Print Process | Print/quality settings (layer height, speeds, infill, etc.) |\n| `PFUS` | Filament | Filament settings (temperatures, flow, pressure advance, etc.) |\n| `PMUS` | Printer/Machine | Machine settings (gcode, bed size, kinematics, etc.) |\n\nThe suffix after the prefix is a unique hash identifier.\n\n**Examples:**\n- `PPUS1b03400426f57d` - Print process preset\n- `PFUS169056f3003bb4` - Filament preset\n- `PMUSbc396893c54df0` - Machine/printer preset\n\n---\n\n## Preset Response Schema\n\n### Common Fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `message` | string | API response status (\"success\") |\n| `code` | int/null | Error code if any |\n| `error` | string/null | Error message if any |\n| `public` | boolean | Whether preset is publicly shared |\n| `version` | string | Preset version |\n| `type` | string | Preset type: \"filament\", \"print\", or \"printer\" |\n| `name` | string | Display name of the preset |\n| `update_time` | string | Last update timestamp (ISO format) |\n| `nickname` | string/null | Optional user-defined nickname |\n| `base_id` | string | Reference ID of the parent/base preset |\n| `setting` | object | Key-value pairs of customized settings |\n| `filament_id` | string/null | Bambu filament ID if applicable |\n\n### Setting Object\n\nThe `setting` object contains **only the delta/modified values** from the parent preset. Key fields include:\n\n- `inherits`: Name of the parent preset this inherits from\n- `updated_time`: Unix timestamp of last modification\n- Other fields depend on preset type (see below)\n\n---\n\n## Preset Types and Common Settings\n\n### Filament Presets (PFUS)\n\n```json\n{\n    \"inherits\": \"Bambu PLA Basic @BBL X1C\",\n    \"filament_vendor\": \"\\\"Devil Design\\\"\",\n    \"filament_cost\": \"20\",\n    \"filament_settings_id\": \"\\\"Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle\\\"\",\n    \"nozzle_temperature\": \"225,220\",\n    \"nozzle_temperature_initial_layer\": \"225,220\",\n    \"hot_plate_temp\": \"60\",\n    \"cool_plate_temp\": \"60\",\n    \"textured_plate_temp\": \"60\",\n    \"pressure_advance\": \"0.03\",\n    \"enable_pressure_advance\": \"1\",\n    \"filament_max_volumetric_speed\": \"30,29\",\n    \"activate_air_filtration\": \"1\",\n    \"during_print_exhaust_fan_speed\": \"50\",\n    \"complete_print_exhaust_fan_speed\": \"50\",\n    \"close_fan_the_first_x_layers\": \"2\",\n    \"overhang_fan_threshold\": \"10%\",\n    \"slow_down_layer_time\": \"5\",\n    \"temperature_vitrification\": \"65\",\n    \"filament_start_gcode\": \"...\",\n    \"filament_end_gcode\": \"...\"\n}\n```\n\n### Print Process Presets (PPUS)\n\n```json\n{\n    \"inherits\": \"0.08mm Extra Fine @BBL H2D\",\n    \"print_settings_id\": \"# 0.08mm Extra Fine @BBL H2D\",\n    \"prime_tower_max_speed\": \"100\",\n    \"prime_tower_rib_wall\": \"0\",\n    \"prime_tower_width\": \"20\"\n}\n```\n\n### Machine/Printer Presets (PMUS)\n\n```json\n{\n    \"inherits\": \"Bambu Lab H2D 0.4 nozzle\",\n    \"printer_settings_id\": \"# Bambu Lab H2D 0.4 nozzle\",\n    \"bed_custom_model\": \"/path/to/model.stl\",\n    \"machine_start_gcode\": \"...\",\n    \"machine_end_gcode\": \"...\",\n    \"change_filament_gcode\": \"...\",\n    \"printer_notes\": \"...\",\n    \"support_air_filtration\": \"1\"\n}\n```\n\n---\n\n## Base ID Reference\n\nThe `base_id` field references Bambu's internal preset database:\n\n| Prefix | Type |\n|--------|------|\n| `GF` | Generic Filament |\n| `GP` | Generic Print Process |\n| `GM` | Generic Machine |\n\nExamples:\n- `GFSA00` - Generic filament base\n- `GP136` - Generic print process base\n- `GM033` - Generic machine base (H2D)\n\n---\n\n## API Operations (Verified)\n\n### Create Preset\n\n```http\nPOST /v1/iot-service/api/slicer/setting\nContent-Type: application/json\n\n{\n    \"type\": \"filament\",\n    \"name\": \"My Custom PLA\",\n    \"version\": \"2.0.0.0\",\n    \"base_id\": \"GFSA00\",\n    \"setting\": {\n        \"inherits\": \"Bambu PLA Basic @BBL X1C\",\n        \"nozzle_temperature\": \"210,205\",\n        \"updated_time\": \"1733665800\"\n    }\n}\n```\n\n**Required fields:**\n- `type`: \"filament\", \"print\", or \"printer\"\n- `name`: Display name\n- `version`: Version string (e.g., \"2.0.0.0\", \"2.3.0.2\")\n- `base_id`: Parent preset ID\n- `setting`: Object with modified values including `updated_time` (Unix timestamp)\n\n**Response:**\n```json\n{\n    \"message\": \"success\",\n    \"code\": null,\n    \"error\": null,\n    \"setting_id\": \"PFUSe99f2ff04974b4\",\n    \"update_time\": \"2025-12-08 16:31:48\"\n}\n```\n\n### Update Preset\n\n**Important:** The Bambu Cloud API does NOT support true updates via PUT/PATCH.\n\n- `PUT /v1/iot-service/api/slicer/setting/{preset_id}` returns **405 Method Not Allowed**\n- `PATCH /v1/iot-service/api/slicer/setting/{preset_id}` returns **500 Cloud database failed**\n\n**Workaround:** To \"update\" a preset:\n1. GET the existing preset details\n2. Merge your changes\n3. POST to create a new preset (returns new `setting_id`)\n4. DELETE the old preset\n\nThis mimics how Bambu Studio handles preset updates.\n\n### Delete Preset\n\n```http\nDELETE /v1/iot-service/api/slicer/setting/{preset_id}\n```\n\n**Response:**\n```json\n{\n    \"message\": \"success\",\n    \"code\": null,\n    \"error\": null\n}\n```\n\n---\n\n## Related Endpoints\n\n### Slicer Resources\n\n```http\nGET /v1/iot-service/api/slicer/resource?slicer/plugins/cloud={version}\nGET /v1/iot-service/api/slicer/resource?slicer/printer/bbl={version}\nGET /v1/iot-service/api/slicer/resource?policy/privacy={version}\n```\n\n### User Print Status\n\n```http\nGET /v1/iot-service/api/user/print?force=true\n```\n\n### User Tasks\n\n```http\nGET /v1/user-service/my/tasks?limit=5&offset=0&status=0\n```\n\n### MQTT Certificate\n\n```http\nGET /v1/iot-service/api/user/applications/{app_id}/cert?aes256={encrypted_key}\n```\n\n---\n\n## Notes\n\n1. **Delta Storage**: Presets only store modified values from the parent, using the `inherits` field to reference the base preset.\n\n2. **Version Tracking**: The `updated_time` field (Unix timestamp) is used for sync conflict resolution.\n\n3. **Gcode Escaping**: Gcode fields use `\\\\n` for newlines within JSON strings.\n\n4. **Multi-value Fields**: Some fields like `nozzle_temperature` contain comma-separated values for different conditions.\n\n5. **Authentication**: The access token can be obtained via Bambu Lab OAuth flow or the `/v1/user-service/user/ticket/{code}` endpoint.\n"
  },
  {
    "path": "docs/migration-vp-ftp-port.md",
    "content": "# Migration: Virtual Printer Port Changes\n\n## FTP Port Change (9990 → 990)\n\nThe Virtual Printer FTP server now binds **directly to port 990** instead of port 9990.\nPreviously, an iptables `REDIRECT` rule was required to forward port 990 to 9990.\n\n### Why\n\nThe iptables `REDIRECT` target rewrites the destination IP to the **primary address\nof the incoming network interface**. When running multiple virtual printers on\ndifferent bind IPs (e.g. secondary interfaces or IP aliases), this caused FTP\nconnections to be routed to the wrong virtual printer — breaking authentication\nwhen VPs have different access codes.\n\nBy binding directly to port 990, iptables is no longer involved and each VP's\nFTP server correctly receives only its own traffic.\n\n## New Proxy Mode Ports (6000, 322)\n\nProxy mode now requires two additional ports:\n\n| Port | Protocol | Purpose |\n|------|----------|---------|\n| 6000 | TCP | File transfer tunnel (transparent proxy, end-to-end TLS) |\n| 322 | TCP | RTSP camera streaming (transparent proxy, end-to-end TLS) |\n\nThese ports are proxied automatically — no iptables rules needed. If you have\na firewall, ensure these ports are open between the slicer and Bambuddy.\n\n## Migration Steps\n\n### Linux (Native / systemd)\n\n1. **Remove old iptables rules:**\n   ```bash\n   sudo iptables -t nat -D PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990\n   sudo iptables -t nat -D OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990\n   ```\n   Repeat each command until it says \"No chain/target/match by that name\".\n\n2. **Remove persistent rules** (if saved):\n   - **Debian/Ubuntu:** `sudo netfilter-persistent save`\n   - **Fedora/RHEL:** `sudo service iptables save`\n   - **Arch:** `sudo iptables-save > /etc/iptables/iptables.rules`\n\n3. **Verify systemd service** has `AmbientCapabilities=CAP_NET_BIND_SERVICE`:\n   ```bash\n   systemctl cat bambuddy | grep AmbientCapabilities\n   ```\n   If missing, add it to the `[Service]` section.\n\n4. **Restart Bambuddy.** Verify FTP binds to port 990:\n   ```bash\n   grep \"FTPS on\" logs/bambuddy.log\n   # Should show: Starting virtual printer implicit FTPS on <IP>:990\n   ```\n\n### Docker (Host Network)\n\n1. **Remove old iptables rules** on the Docker host (same as above).\n2. **Update and restart** the container. No other changes needed —\n   the container binds directly to port 990 via `CAP_NET_BIND_SERVICE`.\n\n### Docker (Bridge Network)\n\n1. **Update port mapping** in `docker-compose.yml`:\n   ```yaml\n   # Old:\n   - \"990:9990\"\n   # New:\n   - \"990:990\"\n   ```\n2. **Recreate the container:** `docker compose up -d`\n\n### Unraid / Synology / TrueNAS / Proxmox LXC\n\n1. **Remove any iptables redirect rules** you added for `990 -> 9990`.\n   - **Unraid:** Remove the lines from `/boot/config/go`\n   - **Synology:** Remove the scheduled task that added the iptables rule\n2. **Update and restart** the container.\n\n## Verification\n\nAfter migration, confirm no redirect rules remain:\n```bash\nsudo iptables -t nat -L PREROUTING -n | grep 9990\n# Should return nothing\n```\n\nCheck the FTP server is binding correctly:\n```bash\ngrep \"FTPS on\" logs/bambuddy.log\n# Should show port 990, not 9990\n```\n"
  },
  {
    "path": "frontend/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "frontend/.npmrc",
    "content": "audit-level=high\n"
  },
  {
    "path": "frontend/README.md",
    "content": "# React + TypeScript + Vite\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.\n\nCurrently, two official plugins are available:\n\n- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh\n- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh\n\n## React Compiler\n\nThe React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).\n\n## Expanding the ESLint configuration\n\nIf you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:\n\n```js\nexport default defineConfig([\n  globalIgnores(['dist']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      // Other configs...\n\n      // Remove tseslint.configs.recommended and replace with this\n      tseslint.configs.recommendedTypeChecked,\n      // Alternatively, use this for stricter rules\n      tseslint.configs.strictTypeChecked,\n      // Optionally, add this for stylistic rules\n      tseslint.configs.stylisticTypeChecked,\n\n      // Other configs...\n    ],\n    languageOptions: {\n      parserOptions: {\n        project: ['./tsconfig.node.json', './tsconfig.app.json'],\n        tsconfigRootDir: import.meta.dirname,\n      },\n      // other options...\n    },\n  },\n])\n```\n\nYou can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:\n\n```js\n// eslint.config.js\nimport reactX from 'eslint-plugin-react-x'\nimport reactDom from 'eslint-plugin-react-dom'\n\nexport default defineConfig([\n  globalIgnores(['dist']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      // Other configs...\n      // Enable lint rules for React\n      reactX.configs['recommended-typescript'],\n      // Enable lint rules for React DOM\n      reactDom.configs.recommended,\n    ],\n    languageOptions: {\n      parserOptions: {\n        project: ['./tsconfig.node.json', './tsconfig.app.json'],\n        tsconfigRootDir: import.meta.dirname,\n      },\n      // other options...\n    },\n  },\n])\n```\n"
  },
  {
    "path": "frontend/docs/create_proxy_diagram.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nCreate a professional network architecture diagram for Bambuddy Virtual Printer Proxy Mode.\nFollowing the Signal Flow design philosophy.\n\"\"\"\n\nfrom PIL import Image, ImageDraw, ImageFont\nfrom pathlib import Path\n\n# Canvas dimensions\nWIDTH = 1400\nHEIGHT = 700\n\n# Colors - Signal Flow palette\nBG_COLOR = (18, 18, 22)  # Near black\nCONTAINER_BG = (28, 28, 35)  # Slightly lighter\nCONTAINER_BORDER = (50, 50, 60)  # Subtle border\nBAMBU_GREEN = (0, 174, 66)  # #00AE42\nBAMBU_GREEN_DIM = (0, 120, 45)  # Dimmer green for accents\nTEXT_PRIMARY = (240, 240, 245)  # Near white\nTEXT_SECONDARY = (140, 140, 150)  # Gray\nTEXT_LABEL = (100, 100, 110)  # Darker gray for small labels\nINTERNET_COLOR = (80, 80, 95)  # Cloud color\nTLS_BADGE_BG = (35, 55, 45)  # Dark green for TLS badges\nLOCK_COLOR = BAMBU_GREEN\n\n# Font paths\nFONT_DIR = Path(\"/opt/claude/.claude/plugins/cache/anthropic-agent-skills/document-skills/f23222824449/skills/canvas-design/canvas-fonts\")\n\ndef load_fonts():\n    \"\"\"Load fonts for the diagram.\"\"\"\n    fonts = {}\n    try:\n        fonts['title'] = ImageFont.truetype(str(FONT_DIR / \"InstrumentSans-Bold.ttf\"), 28)\n        fonts['heading'] = ImageFont.truetype(str(FONT_DIR / \"InstrumentSans-Bold.ttf\"), 18)\n        fonts['label'] = ImageFont.truetype(str(FONT_DIR / \"InstrumentSans-Regular.ttf\"), 14)\n        fonts['small'] = ImageFont.truetype(str(FONT_DIR / \"InstrumentSans-Regular.ttf\"), 12)\n        fonts['port'] = ImageFont.truetype(str(FONT_DIR / \"JetBrainsMono-Bold.ttf\"), 13)\n        fonts['port_small'] = ImageFont.truetype(str(FONT_DIR / \"JetBrainsMono-Regular.ttf\"), 11)\n        fonts['tls'] = ImageFont.truetype(str(FONT_DIR / \"JetBrainsMono-Bold.ttf\"), 10)\n    except Exception as e:\n        print(f\"Font loading error: {e}\")\n        # Fallback to default\n        fonts['title'] = ImageFont.load_default()\n        fonts['heading'] = ImageFont.load_default()\n        fonts['label'] = ImageFont.load_default()\n        fonts['small'] = ImageFont.load_default()\n        fonts['port'] = ImageFont.load_default()\n        fonts['port_small'] = ImageFont.load_default()\n        fonts['tls'] = ImageFont.load_default()\n    return fonts\n\ndef draw_rounded_rect(draw, xy, radius, fill=None, outline=None, width=1):\n    \"\"\"Draw a rounded rectangle.\"\"\"\n    x1, y1, x2, y2 = xy\n\n    if fill:\n        # Fill\n        draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill)\n        draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill)\n        draw.ellipse([x1, y1, x1 + 2*radius, y1 + 2*radius], fill=fill)\n        draw.ellipse([x2 - 2*radius, y1, x2, y1 + 2*radius], fill=fill)\n        draw.ellipse([x1, y2 - 2*radius, x1 + 2*radius, y2], fill=fill)\n        draw.ellipse([x2 - 2*radius, y2 - 2*radius, x2, y2], fill=fill)\n\n    if outline:\n        # Outline\n        draw.arc([x1, y1, x1 + 2*radius, y1 + 2*radius], 180, 270, fill=outline, width=width)\n        draw.arc([x2 - 2*radius, y1, x2, y1 + 2*radius], 270, 360, fill=outline, width=width)\n        draw.arc([x1, y2 - 2*radius, x1 + 2*radius, y2], 90, 180, fill=outline, width=width)\n        draw.arc([x2 - 2*radius, y2 - 2*radius, x2, y2], 0, 90, fill=outline, width=width)\n        draw.line([x1 + radius, y1, x2 - radius, y1], fill=outline, width=width)\n        draw.line([x1 + radius, y2, x2 - radius, y2], fill=outline, width=width)\n        draw.line([x1, y1 + radius, x1, y2 - radius], fill=outline, width=width)\n        draw.line([x2, y1 + radius, x2, y2 - radius], fill=outline, width=width)\n\ndef draw_lock_icon(draw, x, y, size, color):\n    \"\"\"Draw a simple lock icon.\"\"\"\n    # Lock body\n    body_w = size * 0.7\n    body_h = size * 0.5\n    body_x = x - body_w / 2\n    body_y = y + size * 0.1\n    draw_rounded_rect(draw, [body_x, body_y, body_x + body_w, body_y + body_h], 2, fill=color)\n\n    # Lock shackle (arc)\n    shackle_w = size * 0.45\n    shackle_h = size * 0.4\n    shackle_x = x - shackle_w / 2\n    shackle_y = y - size * 0.25\n    draw.arc([shackle_x, shackle_y, shackle_x + shackle_w, shackle_y + shackle_h],\n             180, 360, fill=color, width=2)\n\ndef draw_computer_icon(draw, x, y, size, color):\n    \"\"\"Draw a simple computer/monitor icon.\"\"\"\n    # Monitor\n    mon_w = size * 0.8\n    mon_h = size * 0.55\n    mon_x = x - mon_w / 2\n    mon_y = y - size * 0.35\n    draw_rounded_rect(draw, [mon_x, mon_y, mon_x + mon_w, mon_y + mon_h], 3, outline=color, width=2)\n\n    # Screen inner\n    inner_margin = 4\n    draw_rounded_rect(draw, [mon_x + inner_margin, mon_y + inner_margin,\n                             mon_x + mon_w - inner_margin, mon_y + mon_h - inner_margin],\n                      2, fill=color)\n\n    # Stand\n    stand_w = size * 0.2\n    stand_h = size * 0.15\n    draw.rectangle([x - stand_w/2, mon_y + mon_h, x + stand_w/2, mon_y + mon_h + stand_h], fill=color)\n\n    # Base\n    base_w = size * 0.4\n    draw.rectangle([x - base_w/2, mon_y + mon_h + stand_h, x + base_w/2, mon_y + mon_h + stand_h + 3], fill=color)\n\ndef draw_server_icon(draw, x, y, size, color):\n    \"\"\"Draw a simple server icon.\"\"\"\n    unit_h = size * 0.25\n    gap = 4\n    w = size * 0.75\n\n    for i in range(3):\n        uy = y - size * 0.4 + i * (unit_h + gap)\n        draw_rounded_rect(draw, [x - w/2, uy, x + w/2, uy + unit_h], 3, outline=color, width=2)\n        # LED dots\n        draw.ellipse([x + w/2 - 12, uy + unit_h/2 - 2, x + w/2 - 8, uy + unit_h/2 + 2], fill=color)\n\ndef draw_printer_icon(draw, x, y, size, color):\n    \"\"\"Draw a Bambu Lab style 3D printer icon.\"\"\"\n    # Main body (cube-like)\n    body_w = size * 0.75\n    body_h = size * 0.7\n    body_x = x - body_w / 2\n    body_y = y - size * 0.35\n\n    # Outer frame with thicker border\n    draw_rounded_rect(draw, [body_x, body_y, body_x + body_w, body_y + body_h], 6, outline=color, width=2)\n\n    # Inner window/chamber\n    win_margin = 8\n    draw_rounded_rect(draw, [body_x + win_margin, body_y + win_margin,\n                             body_x + body_w - win_margin, body_y + body_h - 16],\n                      4, outline=color, width=1)\n\n    # Print bed line\n    bed_y = body_y + body_h - 12\n    draw.line([body_x + 12, bed_y, body_x + body_w - 12, bed_y], fill=color, width=2)\n\n    # Extruder/toolhead\n    ext_w = 16\n    ext_h = 8\n    ext_y = body_y + 18\n    draw_rounded_rect(draw, [x - ext_w/2, ext_y, x + ext_w/2, ext_y + ext_h], 2, fill=color)\n\n    # Small printed object on bed\n    obj_w = 12\n    obj_h = 10\n    draw_rounded_rect(draw, [x - obj_w/2, bed_y - obj_h, x + obj_w/2, bed_y], 2, fill=color)\n\ndef draw_cloud_icon(draw, x, y, size, color):\n    \"\"\"Draw a simple cloud icon.\"\"\"\n    # Main cloud body using overlapping circles\n    r1 = size * 0.25\n    r2 = size * 0.2\n    r3 = size * 0.18\n\n    # Center circle\n    draw.ellipse([x - r1, y - r1 * 0.8, x + r1, y + r1 * 0.8], fill=color)\n    # Left circle\n    draw.ellipse([x - r1 - r2 * 0.7, y - r2 * 0.3, x - r1 + r2 * 0.7, y + r2 * 1.1], fill=color)\n    # Right circle\n    draw.ellipse([x + r1 * 0.3 - r2 * 0.5, y - r2 * 0.4, x + r1 * 0.3 + r2 * 1.2, y + r2 * 1.0], fill=color)\n    # Top circle\n    draw.ellipse([x - r3 * 0.5, y - r1 - r3 * 0.3, x + r3 * 1.2, y - r1 + r3 * 0.9], fill=color)\n\ndef draw_arrow(draw, x1, y1, x2, y2, color, width=2):\n    \"\"\"Draw a line with arrow head.\"\"\"\n    draw.line([x1, y1, x2, y2], fill=color, width=width)\n\n    # Arrow head\n    import math\n    angle = math.atan2(y2 - y1, x2 - x1)\n    arrow_len = 10\n    arrow_angle = math.pi / 6\n\n    ax1 = x2 - arrow_len * math.cos(angle - arrow_angle)\n    ay1 = y2 - arrow_len * math.sin(angle - arrow_angle)\n    ax2 = x2 - arrow_len * math.cos(angle + arrow_angle)\n    ay2 = y2 - arrow_len * math.sin(angle + arrow_angle)\n\n    draw.polygon([(x2, y2), (ax1, ay1), (ax2, ay2)], fill=color)\n\ndef draw_bidirectional_arrow(draw, x1, y1, x2, y2, color, width=2):\n    \"\"\"Draw a bidirectional arrow.\"\"\"\n    import math\n\n    # Shorten line slightly to make room for arrowheads\n    angle = math.atan2(y2 - y1, x2 - x1)\n    offset = 8\n\n    lx1 = x1 + offset * math.cos(angle)\n    ly1 = y1 + offset * math.sin(angle)\n    lx2 = x2 - offset * math.cos(angle)\n    ly2 = y2 - offset * math.sin(angle)\n\n    draw.line([lx1, ly1, lx2, ly2], fill=color, width=width)\n\n    # Arrow heads\n    arrow_len = 8\n    arrow_angle = math.pi / 6\n\n    # Right arrow\n    ax1 = x2 - arrow_len * math.cos(angle - arrow_angle)\n    ay1 = y2 - arrow_len * math.sin(angle - arrow_angle)\n    ax2 = x2 - arrow_len * math.cos(angle + arrow_angle)\n    ay2 = y2 - arrow_len * math.sin(angle + arrow_angle)\n    draw.polygon([(x2, y2), (ax1, ay1), (ax2, ay2)], fill=color)\n\n    # Left arrow\n    ax1 = x1 + arrow_len * math.cos(angle - arrow_angle)\n    ay1 = y1 + arrow_len * math.sin(angle - arrow_angle)\n    ax2 = x1 + arrow_len * math.cos(angle + arrow_angle)\n    ay2 = y1 + arrow_len * math.sin(angle + arrow_angle)\n    draw.polygon([(x1, y1), (ax1, ay1), (ax2, ay2)], fill=color)\n\ndef draw_tls_badge(draw, x, y, fonts, color=TLS_BADGE_BG, text_color=BAMBU_GREEN):\n    \"\"\"Draw a TLS badge.\"\"\"\n    badge_w = 42\n    badge_h = 18\n    draw_rounded_rect(draw, [x - badge_w/2, y - badge_h/2, x + badge_w/2, y + badge_h/2],\n                      4, fill=color, outline=BAMBU_GREEN_DIM, width=1)\n\n    # Lock icon\n    draw_lock_icon(draw, x - 12, y - 2, 10, text_color)\n\n    # TLS text\n    draw.text((x + 2, y), \"TLS\", font=fonts['tls'], fill=text_color, anchor=\"lm\")\n\ndef create_diagram():\n    \"\"\"Create the main diagram.\"\"\"\n    img = Image.new('RGB', (WIDTH, HEIGHT), BG_COLOR)\n    draw = ImageDraw.Draw(img)\n    fonts = load_fonts()\n\n    # Title\n    title = \"VIRTUAL PRINTER PROXY MODE\"\n    draw.text((WIDTH // 2, 35), title, font=fonts['title'], fill=BAMBU_GREEN, anchor=\"mm\")\n\n    # Subtitle\n    subtitle = \"Secure remote printing through Bambuddy\"\n    draw.text((WIDTH // 2, 62), subtitle, font=fonts['small'], fill=TEXT_SECONDARY, anchor=\"mm\")\n\n    # === LAYOUT ===\n    # Three main sections: Remote | Internet | Local\n\n    section_y = 320\n\n    # Remote section (left)\n    remote_x = 180\n    remote_box = [40, 120, 320, 520]\n\n    # Internet section (center)\n    internet_x = 510\n\n    # Bambuddy section (center-right)\n    bambuddy_x = 700\n    bambuddy_box = [560, 140, 840, 500]\n\n    # Local section (right)\n    local_x = 1050\n    printer_x = 1220\n    local_box = [920, 120, 1360, 520]\n\n    # === REMOTE NETWORK ZONE ===\n    draw_rounded_rect(draw, remote_box, 12, fill=CONTAINER_BG, outline=CONTAINER_BORDER, width=1)\n    draw.text((180, 140), \"REMOTE NETWORK\", font=fonts['label'], fill=TEXT_LABEL, anchor=\"mm\")\n\n    # Slicer icon and label\n    draw_computer_icon(draw, remote_x, section_y - 40, 70, BAMBU_GREEN)\n    draw.text((remote_x, section_y + 30), \"Bambu Studio\", font=fonts['heading'], fill=TEXT_PRIMARY, anchor=\"mm\")\n    draw.text((remote_x, section_y + 52), \"or OrcaSlicer\", font=fonts['small'], fill=TEXT_SECONDARY, anchor=\"mm\")\n\n    # Ports on remote side\n    draw.text((remote_x, section_y + 100), \"Connects to Bambuddy\", font=fonts['small'], fill=TEXT_LABEL, anchor=\"mm\")\n    draw.text((remote_x, section_y + 120), \"FTP :990  MQTT :8883\", font=fonts['port_small'], fill=TEXT_SECONDARY, anchor=\"mm\")\n\n    # === INTERNET CLOUD ===\n    draw_cloud_icon(draw, internet_x, section_y, 80, INTERNET_COLOR)\n    draw.text((internet_x, section_y + 55), \"Internet\", font=fonts['label'], fill=TEXT_LABEL, anchor=\"mm\")\n\n    # === BAMBUDDY SERVER ===\n    draw_rounded_rect(draw, bambuddy_box, 12, fill=CONTAINER_BG, outline=BAMBU_GREEN_DIM, width=2)\n    draw.text((bambuddy_x, 165), \"BAMBUDDY SERVER\", font=fonts['label'], fill=BAMBU_GREEN, anchor=\"mm\")\n\n    # Server icon\n    draw_server_icon(draw, bambuddy_x, section_y - 50, 70, BAMBU_GREEN)\n    draw.text((bambuddy_x, section_y + 20), \"TLS Proxy\", font=fonts['heading'], fill=TEXT_PRIMARY, anchor=\"mm\")\n\n    # Incoming ports (left side of Bambuddy)\n    draw.text((bambuddy_x, section_y + 70), \"LISTEN PORTS\", font=fonts['small'], fill=TEXT_LABEL, anchor=\"mm\")\n    draw_rounded_rect(draw, [bambuddy_x - 55, section_y + 85, bambuddy_x + 55, section_y + 130],\n                      6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)\n    draw.text((bambuddy_x, section_y + 98), \"FTP\", font=fonts['small'], fill=TEXT_SECONDARY, anchor=\"mm\")\n    draw.text((bambuddy_x, section_y + 115), \"990\", font=fonts['port'], fill=BAMBU_GREEN, anchor=\"mm\")\n\n    draw_rounded_rect(draw, [bambuddy_x - 55, section_y + 140, bambuddy_x + 55, section_y + 185],\n                      6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)\n    draw.text((bambuddy_x, section_y + 153), \"MQTT\", font=fonts['small'], fill=TEXT_SECONDARY, anchor=\"mm\")\n    draw.text((bambuddy_x, section_y + 170), \"8883\", font=fonts['port'], fill=BAMBU_GREEN, anchor=\"mm\")\n\n    # === LOCAL NETWORK ZONE ===\n    draw_rounded_rect(draw, local_box, 12, fill=CONTAINER_BG, outline=CONTAINER_BORDER, width=1)\n    draw.text((1140, 140), \"LOCAL NETWORK\", font=fonts['label'], fill=TEXT_LABEL, anchor=\"mm\")\n\n    # \"LAN Mode\" badge\n    draw_rounded_rect(draw, [1100, 155, 1180, 175], 4, fill=TLS_BADGE_BG, outline=BAMBU_GREEN_DIM, width=1)\n    draw.text((1140, 165), \"LAN Mode\", font=fonts['tls'], fill=BAMBU_GREEN, anchor=\"mm\")\n\n    # Printer icon\n    draw_printer_icon(draw, printer_x, section_y - 40, 80, BAMBU_GREEN)\n    draw.text((printer_x, section_y + 35), \"Bambu Lab\", font=fonts['heading'], fill=TEXT_PRIMARY, anchor=\"mm\")\n    draw.text((printer_x, section_y + 55), \"Printer\", font=fonts['heading'], fill=TEXT_PRIMARY, anchor=\"mm\")\n\n    # Target ports\n    draw.text((printer_x, section_y + 100), \"Printer Ports\", font=fonts['small'], fill=TEXT_LABEL, anchor=\"mm\")\n    draw.text((printer_x, section_y + 120), \"FTP :990  MQTT :8883\", font=fonts['port_small'], fill=TEXT_SECONDARY, anchor=\"mm\")\n\n    # Proxy target label\n    draw_rounded_rect(draw, [local_x - 60, section_y - 80, local_x + 60, section_y - 50],\n                      6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)\n    draw.text((local_x, section_y - 65), \"Target IP\", font=fonts['small'], fill=TEXT_SECONDARY, anchor=\"mm\")\n\n    # === CONNECTION ARROWS ===\n\n    # Remote to Internet\n    draw_bidirectional_arrow(draw, 325, section_y, 460, section_y, BAMBU_GREEN_DIM, 2)\n\n    # TLS badge between remote and internet\n    draw_tls_badge(draw, 392, section_y - 20, fonts)\n\n    # Internet to Bambuddy\n    draw_bidirectional_arrow(draw, 555, section_y, 620, section_y, BAMBU_GREEN_DIM, 2)\n\n    # Bambuddy to Local\n    draw_bidirectional_arrow(draw, 780, section_y, 920, section_y, BAMBU_GREEN_DIM, 2)\n\n    # TLS badge between Bambuddy and printer\n    draw_tls_badge(draw, 850, section_y - 20, fonts)\n\n    # Local network arrow to printer\n    draw_bidirectional_arrow(draw, 990, section_y, 1130, section_y, BAMBU_GREEN_DIM, 2)\n\n    # === BOTTOM INFO ===\n    info_y = 560\n\n    # Flow description\n    draw.text((WIDTH // 2, info_y), \"← Slicer traffic encrypted and relayed through Bambuddy to your printer →\",\n              font=fonts['small'], fill=TEXT_SECONDARY, anchor=\"mm\")\n\n    # Key features\n    features_y = 600\n    features = [\n        \"End-to-end TLS encryption\",\n        \"No cloud dependency\",\n        \"Uses printer's access code\"\n    ]\n\n    spacing = 280\n    start_x = WIDTH // 2 - spacing\n\n    for i, feature in enumerate(features):\n        fx = start_x + i * spacing\n        # Bullet\n        draw.ellipse([fx - 80, features_y - 3, fx - 74, features_y + 3], fill=BAMBU_GREEN)\n        draw.text((fx - 68, features_y), feature, font=fonts['small'], fill=TEXT_SECONDARY, anchor=\"lm\")\n\n    # Bambuddy branding\n    draw.text((WIDTH // 2, HEIGHT - 30), \"bambuddy.cool\", font=fonts['small'], fill=TEXT_LABEL, anchor=\"mm\")\n\n    return img\n\ndef main():\n    \"\"\"Generate and save the diagram.\"\"\"\n    img = create_diagram()\n\n    output_path = Path(\"/opt/claude/projects/bambuddy/docs/images/proxy-mode-diagram.png\")\n    output_path.parent.mkdir(parents=True, exist_ok=True)\n\n    img.save(output_path, \"PNG\", dpi=(150, 150))\n    print(f\"Diagram saved to: {output_path}\")\n\n    # Also save to frontend docs\n    frontend_path = Path(\"/opt/claude/projects/bambuddy/frontend/docs/proxy-mode-diagram.png\")\n    frontend_path.parent.mkdir(parents=True, exist_ok=True)\n    img.save(frontend_path, \"PNG\", dpi=(150, 150))\n    print(f\"Also saved to: {frontend_path}\")\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "frontend/eslint.config.js",
    "content": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reactRefresh from 'eslint-plugin-react-refresh'\nimport tseslint from 'typescript-eslint'\nimport { defineConfig, globalIgnores } from 'eslint/config'\n\nexport default defineConfig([\n  globalIgnores(['dist', 'coverage']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      js.configs.recommended,\n      tseslint.configs.recommended,\n      reactHooks.configs.flat.recommended,\n      reactRefresh.configs.vite,\n    ],\n    languageOptions: {\n      ecmaVersion: 2020,\n      globals: globals.browser,\n    },\n    rules: {\n      // Keep core React Hooks rules\n      'react-hooks/rules-of-hooks': 'error',\n      'react-hooks/exhaustive-deps': 'warn',\n      // Disable React Compiler rules (too strict for non-compiler codebases)\n      'react-hooks/static-components': 'off',\n      'react-hooks/set-state-in-effect': 'off',\n      'react-hooks/purity': 'off',\n      'react-hooks/immutability': 'off',\n      'react-hooks/preserve-manual-memoization': 'off',\n      'react-hooks/refs': 'off',\n    },\n  },\n  // Relaxed rules for test files\n  {\n    files: ['**/__tests__/**/*.{ts,tsx}', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],\n    rules: {\n      '@typescript-eslint/no-explicit-any': 'off',\n      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],\n      'react-refresh/only-export-components': 'off',\n    },\n  },\n  // Files that legitimately export non-components (contexts, hooks, utilities)\n  {\n    files: [\n      '**/contexts/**/*.{ts,tsx}',\n      '**/hooks/**/*.{ts,tsx}',\n      '**/components/IconPicker.tsx',\n      '**/components/Layout.tsx',\n      '**/components/HMSErrorModal.tsx',\n    ],\n    rules: {\n      'react-refresh/only-export-components': 'off',\n    },\n  },\n])\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\" />\n    <!-- L-4: Restrict Referer header to origin-only on cross-origin navigation so\n         sensitive tokens in query parameters are not leaked to third-party servers. -->\n    <meta name=\"referrer\" content=\"strict-origin-when-cross-origin\" />\n    <title>Bambuddy</title>\n\n    <!-- PWA Meta Tags -->\n    <meta name=\"description\" content=\"Monitor and manage your Bambu Lab 3D printers\" />\n    <meta name=\"theme-color\" content=\"#00ae42\" />\n    <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n    <meta name=\"apple-mobile-web-app-title\" content=\"Bambuddy\" />\n\n    <!-- Manifest -->\n    <link rel=\"manifest\" href=\"/manifest.json\" />\n\n    <!-- Favicons -->\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/img/favicon-32x32.png\" />\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/img/favicon-16x16.png\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/img/apple-touch-icon.png\" />\n\n    <!-- Splash screens for iOS -->\n    <link rel=\"apple-touch-startup-image\" href=\"/img/android-chrome-512x512.png\" />\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n\n    <!-- Service Worker Registration (skip on SpoolBuddy kiosk).\n         Kept as an external file so the CSP `script-src 'self'` covers it\n         without needing 'unsafe-inline' or per-build hashes. -->\n    <script src=\"/sw-register.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "frontend/mockups/ams-redesign.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>AMS Section Redesign Mockup</title>\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n  <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n  <style>\n    :root {\n      /* Match actual Bambuddy dark theme */\n      --bg-page: #121218;\n      --bg-card: #1a1a22;\n      --bg-section: #22222a;\n      --bg-input: #2a2a32;\n      --border-color: #333340;\n      --text-primary: #ffffff;\n      --text-secondary: #9ca3af;\n      --text-muted: #6b7280;\n      --bambu-green: #00ae42;\n      --bambu-green-bg: rgba(0, 174, 66, 0.2);\n      --humidity-good: #00ae42;\n      --humidity-fair: #f59e0b;\n      --humidity-bad: #ef4444;\n    }\n\n    * {\n      margin: 0;\n      padding: 0;\n      box-sizing: border-box;\n    }\n\n    body {\n      font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;\n      background: var(--bg-page);\n      color: var(--text-primary);\n      min-height: 100vh;\n      padding: 24px;\n    }\n\n    .page-header {\n      margin-bottom: 24px;\n    }\n\n    .page-title {\n      font-size: 18px;\n      font-weight: 600;\n      color: var(--text-primary);\n      margin-bottom: 4px;\n    }\n\n    .page-subtitle {\n      font-size: 13px;\n      color: var(--text-muted);\n    }\n\n    /* Printer Card - matches actual app */\n    .printer-card {\n      background: var(--bg-card);\n      border: 1px solid var(--border-color);\n      border-radius: 12px;\n      padding: 16px;\n      width: 340px;\n    }\n\n    /* Card Header */\n    .card-header {\n      display: flex;\n      align-items: flex-start;\n      gap: 12px;\n      margin-bottom: 8px;\n    }\n\n    .printer-image {\n      width: 56px;\n      height: 56px;\n      border-radius: 8px;\n      background: var(--bg-section);\n      flex-shrink: 0;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      overflow: hidden;\n    }\n\n    .printer-image svg {\n      width: 40px;\n      height: 40px;\n      color: var(--text-muted);\n    }\n\n    .printer-details {\n      flex: 1;\n      min-width: 0;\n    }\n\n    .printer-name-row {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n    }\n\n    .printer-name {\n      font-size: 18px;\n      font-weight: 600;\n      color: var(--text-primary);\n    }\n\n    .menu-btn {\n      width: 24px;\n      height: 24px;\n      border: none;\n      background: transparent;\n      color: var(--text-secondary);\n      cursor: pointer;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      border-radius: 4px;\n    }\n\n    .menu-btn:hover {\n      background: var(--bg-section);\n    }\n\n    .printer-model {\n      font-size: 14px;\n      color: var(--text-secondary);\n      margin-top: 2px;\n    }\n\n    /* Badges row */\n    .badges-row {\n      display: flex;\n      flex-wrap: wrap;\n      gap: 6px;\n      margin-bottom: 16px;\n    }\n\n    .badge {\n      display: inline-flex;\n      align-items: center;\n      gap: 4px;\n      padding: 4px 8px;\n      border-radius: 9999px;\n      font-size: 12px;\n      font-weight: 500;\n    }\n\n    .badge-green {\n      background: var(--bambu-green-bg);\n      color: var(--bambu-green);\n    }\n\n    .badge svg {\n      width: 12px;\n      height: 12px;\n    }\n\n    /* Status Section */\n    .status-section {\n      background: var(--bg-section);\n      border-radius: 8px;\n      padding: 12px;\n      margin-bottom: 12px;\n    }\n\n    .status-row {\n      display: flex;\n      gap: 12px;\n    }\n\n    .cover-placeholder {\n      width: 72px;\n      height: 72px;\n      border-radius: 8px;\n      background: var(--bg-input);\n      flex-shrink: 0;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n    }\n\n    .cover-placeholder svg {\n      width: 32px;\n      height: 32px;\n      color: var(--text-muted);\n    }\n\n    .status-info {\n      flex: 1;\n      min-width: 0;\n    }\n\n    .status-label {\n      font-size: 14px;\n      color: var(--text-secondary);\n      margin-bottom: 2px;\n    }\n\n    .status-value {\n      font-size: 14px;\n      color: var(--text-primary);\n      margin-bottom: 8px;\n    }\n\n    .progress-bar {\n      height: 8px;\n      background: var(--bg-input);\n      border-radius: 4px;\n      margin-bottom: 8px;\n    }\n\n    .progress-fill {\n      height: 100%;\n      background: var(--bambu-green);\n      border-radius: 4px;\n    }\n\n    .ready-text {\n      font-size: 12px;\n      color: var(--text-secondary);\n    }\n\n    /* Temperature Grid */\n    .temp-grid {\n      display: grid;\n      grid-template-columns: repeat(3, 1fr);\n      gap: 8px;\n      margin-bottom: 12px;\n    }\n\n    .temp-card {\n      background: var(--bg-section);\n      border-radius: 8px;\n      padding: 8px;\n      text-align: center;\n    }\n\n    .temp-icon {\n      width: 16px;\n      height: 16px;\n      margin: 0 auto 4px;\n    }\n\n    .temp-label {\n      font-size: 11px;\n      color: var(--text-secondary);\n      margin-bottom: 2px;\n    }\n\n    .temp-value {\n      font-size: 14px;\n      color: var(--text-primary);\n      font-weight: 500;\n    }\n\n    /* AMS Section */\n    .ams-section {\n      margin-top: 12px;\n    }\n\n    /* Current stacked layout */\n    .ams-stacked {\n      display: flex;\n      flex-direction: column;\n      gap: 8px;\n    }\n\n    .ams-row {\n      display: flex;\n      align-items: center;\n      gap: 12px;\n      background: var(--bg-section);\n      border-radius: 8px;\n      padding: 8px 10px;\n    }\n\n    .ams-icon-wrapper {\n      flex-shrink: 0;\n    }\n\n    .filament-info {\n      flex: 1;\n      min-width: 0;\n    }\n\n    .ams-label {\n      font-size: 11px;\n      font-weight: 500;\n      color: var(--text-muted);\n    }\n\n    .filament-types {\n      font-size: 10px;\n      color: var(--text-secondary);\n      margin-top: 1px;\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n\n    .filament-fills {\n      font-size: 9px;\n      color: var(--text-muted);\n      margin-top: 1px;\n    }\n\n    .ams-stats {\n      display: flex;\n      align-items: center;\n      gap: 8px;\n      flex-shrink: 0;\n    }\n\n    .stat {\n      display: flex;\n      align-items: center;\n      gap: 3px;\n      font-size: 11px;\n      font-weight: 500;\n    }\n\n    .stat svg {\n      width: 12px;\n      height: 12px;\n    }\n\n    .stat-good { color: var(--humidity-good); }\n    .stat-fair { color: var(--humidity-fair); }\n    .stat-bad { color: var(--humidity-bad); }\n    .stat-neutral { color: var(--text-secondary); }\n\n    /* Smart plug section */\n    .smart-plug-section {\n      margin-top: 16px;\n      padding-top: 16px;\n      border-top: 1px solid var(--border-color);\n    }\n\n    .plug-row {\n      display: flex;\n      align-items: center;\n      gap: 8px;\n    }\n\n    .plug-icon {\n      width: 16px;\n      height: 16px;\n      color: var(--text-secondary);\n    }\n\n    .plug-name {\n      font-size: 14px;\n      color: var(--text-primary);\n    }\n\n    .plug-badge {\n      font-size: 11px;\n      padding: 2px 6px;\n      border-radius: 4px;\n      font-weight: 500;\n    }\n\n    .plug-badge.on {\n      background: var(--bambu-green-bg);\n      color: var(--bambu-green);\n    }\n\n    .plug-power {\n      font-size: 12px;\n      color: #facc15;\n      font-weight: 500;\n    }\n\n    .plug-controls {\n      margin-left: auto;\n      display: flex;\n      align-items: center;\n      gap: 6px;\n    }\n\n    .plug-btn {\n      font-size: 11px;\n      padding: 4px 8px;\n      border-radius: 4px;\n      border: none;\n      cursor: pointer;\n      font-weight: 500;\n    }\n\n    .plug-btn.on {\n      background: var(--bambu-green-bg);\n      color: var(--bambu-green);\n    }\n\n    .plug-btn.off {\n      background: var(--bg-input);\n      color: var(--text-secondary);\n    }\n\n    .auto-off-toggle {\n      display: flex;\n      align-items: center;\n      gap: 4px;\n      font-size: 11px;\n      color: var(--text-secondary);\n    }\n\n    .toggle-switch {\n      width: 32px;\n      height: 18px;\n      background: var(--bg-input);\n      border-radius: 9px;\n      position: relative;\n    }\n\n    .toggle-switch.active {\n      background: var(--bambu-green);\n    }\n\n    .toggle-switch::after {\n      content: '';\n      position: absolute;\n      width: 14px;\n      height: 14px;\n      background: white;\n      border-radius: 50%;\n      top: 2px;\n      left: 2px;\n      transition: transform 0.2s;\n    }\n\n    .toggle-switch.active::after {\n      transform: translateX(14px);\n    }\n\n    .plug-footer {\n      margin-top: 8px;\n      display: flex;\n      align-items: center;\n      gap: 8px;\n    }\n\n    .plug-ip {\n      font-size: 11px;\n      color: var(--text-muted);\n    }\n\n    .plug-actions {\n      margin-left: auto;\n      display: flex;\n      gap: 4px;\n    }\n\n    .action-btn {\n      width: 28px;\n      height: 28px;\n      border-radius: 4px;\n      border: none;\n      background: var(--bg-input);\n      color: var(--text-secondary);\n      cursor: pointer;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n    }\n\n    .action-btn svg {\n      width: 14px;\n      height: 14px;\n    }\n\n    /* Comparison layout */\n    .comparison {\n      display: flex;\n      gap: 32px;\n      flex-wrap: wrap;\n      align-items: flex-start;\n    }\n\n    .comparison-section {\n      display: flex;\n      flex-direction: column;\n    }\n\n    .section-label {\n      display: inline-block;\n      font-size: 11px;\n      font-weight: 600;\n      padding: 4px 10px;\n      border-radius: 4px;\n      margin-bottom: 12px;\n      text-transform: uppercase;\n      letter-spacing: 0.5px;\n      width: fit-content;\n    }\n\n    .section-label.current {\n      background: var(--text-muted);\n      color: var(--bg-page);\n    }\n\n    .section-label.new {\n      background: var(--bambu-green);\n      color: white;\n    }\n\n    /* NEW 2-Column Grid Layout */\n    .ams-grid {\n      display: grid;\n      grid-template-columns: 1fr 1fr;\n      gap: 8px;\n    }\n\n    .ams-card {\n      background: var(--bg-section);\n      border-radius: 8px;\n      padding: 8px;\n    }\n\n    .ams-card-header {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      margin-bottom: 6px;\n    }\n\n    .ams-card-left {\n      display: flex;\n      align-items: center;\n      gap: 6px;\n    }\n\n    .ams-card-stats {\n      display: flex;\n      align-items: center;\n      gap: 6px;\n    }\n\n    .ams-card-stats .stat {\n      font-size: 10px;\n    }\n\n    .ams-card-stats .stat svg {\n      width: 10px;\n      height: 10px;\n    }\n\n    .slots-grid {\n      display: grid;\n      grid-template-columns: repeat(4, 1fr);\n      gap: 4px;\n    }\n\n    .slot {\n      background: var(--bg-input);\n      border-radius: 4px;\n      padding: 4px 2px;\n      text-align: center;\n    }\n\n    .slot-color {\n      width: 14px;\n      height: 14px;\n      border-radius: 50%;\n      margin: 0 auto 2px;\n      border: 1px solid rgba(255, 255, 255, 0.1);\n    }\n\n    .slot-color.empty {\n      background: transparent;\n      border: 1px dashed var(--text-muted);\n    }\n\n    .slot-type {\n      font-size: 8px;\n      color: var(--text-muted);\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n\n    .slot-fill {\n      font-size: 8px;\n      color: var(--text-muted);\n      opacity: 0.7;\n    }\n\n    /* Row 3: HT + External (half-size) */\n    .ams-row-small {\n      display: grid;\n      grid-template-columns: repeat(4, 1fr);\n      gap: 8px;\n      margin-top: 8px;\n    }\n\n    .ams-card-small {\n      background: var(--bg-section);\n      border-radius: 8px;\n      padding: 6px 8px;\n      display: flex;\n      align-items: center;\n      gap: 6px;\n    }\n\n    .ams-card-small .small-info {\n      flex: 1;\n      min-width: 0;\n    }\n\n    .ams-card-small .ams-label {\n      font-size: 10px;\n    }\n\n    .ams-card-small .slot-type {\n      font-size: 9px;\n    }\n\n    .external-spool {\n      width: 20px;\n      height: 20px;\n      border-radius: 50%;\n      border: 2px solid rgba(255, 255, 255, 0.15);\n      flex-shrink: 0;\n    }\n\n    .note {\n      font-size: 11px;\n      color: var(--text-muted);\n      margin-top: 12px;\n      padding: 8px;\n      background: var(--bg-section);\n      border-radius: 6px;\n    }\n  </style>\n</head>\n<body>\n  <div class=\"page-header\">\n    <h1 class=\"page-title\">AMS Section Redesign</h1>\n    <p class=\"page-subtitle\">Current stacked layout vs. new 2-column grid</p>\n  </div>\n\n  <div class=\"comparison\">\n    <!-- CURRENT LAYOUT -->\n    <div class=\"comparison-section\">\n      <span class=\"section-label current\">Current</span>\n\n      <div class=\"printer-card\">\n        <!-- Header -->\n        <div class=\"card-header\">\n          <div class=\"printer-image\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\">\n              <rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\"/>\n              <path d=\"M4 10h16\"/>\n              <path d=\"M10 10v10\"/>\n            </svg>\n          </div>\n          <div class=\"printer-details\">\n            <div class=\"printer-name-row\">\n              <span class=\"printer-name\">H2D-1</span>\n              <button class=\"menu-btn\">\n                <svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n                  <circle cx=\"12\" cy=\"5\" r=\"1.5\"/>\n                  <circle cx=\"12\" cy=\"12\" r=\"1.5\"/>\n                  <circle cx=\"12\" cy=\"19\" r=\"1.5\"/>\n                </svg>\n              </button>\n            </div>\n            <div class=\"printer-model\">H2D • 0.4mm • 632h</div>\n          </div>\n        </div>\n\n        <!-- Badges -->\n        <div class=\"badges-row\">\n          <span class=\"badge badge-green\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"/>\n              <path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"/>\n            </svg>\n            Connected\n          </span>\n          <span class=\"badge badge-green\">-53dBm</span>\n          <span class=\"badge badge-green\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path d=\"M12 9v2m0 4h.01\"/>\n              <path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/>\n            </svg>\n            OK\n          </span>\n          <span class=\"badge badge-green\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\"/>\n            </svg>\n            OK\n          </span>\n        </div>\n\n        <!-- Status -->\n        <div class=\"status-section\">\n          <div class=\"status-row\">\n            <div class=\"cover-placeholder\">\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\">\n                <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/>\n                <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"/>\n                <path d=\"M21 15l-5-5L5 21\"/>\n              </svg>\n            </div>\n            <div class=\"status-info\">\n              <div class=\"status-label\">Status</div>\n              <div class=\"status-value\">Idle</div>\n              <div class=\"progress-bar\"></div>\n              <div class=\"ready-text\">Ready to print</div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Temperatures -->\n        <div class=\"temp-grid\">\n          <div class=\"temp-card\">\n            <svg class=\"temp-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#f97316\" stroke-width=\"2\">\n              <path d=\"M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z\"/>\n            </svg>\n            <div class=\"temp-label\">Left / Right</div>\n            <div class=\"temp-value\">20°C / 19°C</div>\n          </div>\n          <div class=\"temp-card\">\n            <svg class=\"temp-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3b82f6\" stroke-width=\"2\">\n              <path d=\"M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z\"/>\n            </svg>\n            <div class=\"temp-label\">Bed</div>\n            <div class=\"temp-value\">20°C</div>\n          </div>\n          <div class=\"temp-card\">\n            <svg class=\"temp-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22c55e\" stroke-width=\"2\">\n              <path d=\"M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z\"/>\n            </svg>\n            <div class=\"temp-label\">Chamber</div>\n            <div class=\"temp-value\">21°C</div>\n          </div>\n        </div>\n\n        <!-- AMS Section - CURRENT STACKED -->\n        <div class=\"ams-section\">\n          <div class=\"ams-stacked\">\n            <!-- AMS-A -->\n            <div class=\"ams-row\">\n              <div class=\"ams-icon-wrapper\">\n                <svg width=\"56\" height=\"34\" viewBox=\"0 0 52 32\" fill=\"none\">\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z\" fill=\"#2F2E33\"/>\n                  <rect x=\"9.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#e53935\"/>\n                  <rect x=\"18.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#1e88e5\"/>\n                  <rect x=\"27.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#43a047\"/>\n                  <rect x=\"36.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#f5f5f5\"/>\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z\" fill=\"#767676\"/>\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z\" fill=\"#BFBFBF\"/>\n                </svg>\n              </div>\n              <div class=\"filament-info\">\n                <div class=\"ams-label\">AMS-A</div>\n                <div class=\"filament-types\">PLA Basic · PETG HF · PLA Basic · PLA Basic</div>\n                <div class=\"filament-fills\">71% · 50% · 87% · 23%</div>\n              </div>\n              <div class=\"ams-stats\">\n                <div class=\"stat stat-good\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                    <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                  </svg>\n                  21%\n                </div>\n                <div class=\"stat stat-neutral\">20.7°C</div>\n              </div>\n            </div>\n\n            <!-- AMS-B -->\n            <div class=\"ams-row\">\n              <div class=\"ams-icon-wrapper\">\n                <svg width=\"56\" height=\"34\" viewBox=\"0 0 52 32\" fill=\"none\">\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z\" fill=\"#2F2E33\"/>\n                  <rect x=\"9.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#9c27b0\"/>\n                  <rect x=\"18.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#ff9800\"/>\n                  <rect x=\"27.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#fdd835\"/>\n                  <rect x=\"36.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#212121\"/>\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z\" fill=\"#767676\"/>\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z\" fill=\"#BFBFBF\"/>\n                </svg>\n              </div>\n              <div class=\"filament-info\">\n                <div class=\"ams-label\">AMS-B</div>\n                <div class=\"filament-types\">PETG HF · PLA · PLA-S · PLA-S</div>\n                <div class=\"filament-fills\">45% · 92% · 15% · 68%</div>\n              </div>\n              <div class=\"ams-stats\">\n                <div class=\"stat stat-good\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                    <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                  </svg>\n                  16%\n                </div>\n                <div class=\"stat stat-neutral\">22.7°C</div>\n              </div>\n            </div>\n\n            <!-- AMS-C -->\n            <div class=\"ams-row\">\n              <div class=\"ams-icon-wrapper\">\n                <svg width=\"56\" height=\"34\" viewBox=\"0 0 52 32\" fill=\"none\">\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z\" fill=\"#2F2E33\"/>\n                  <rect x=\"9.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#00bcd4\"/>\n                  <rect x=\"18.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#e91e63\"/>\n                  <rect x=\"27.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#9e9e9e\"/>\n                  <rect x=\"36.5\" y=\"8\" width=\"6\" height=\"16\" fill=\"#f48fb1\"/>\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z\" fill=\"#767676\"/>\n                  <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z\" fill=\"#BFBFBF\"/>\n                </svg>\n              </div>\n              <div class=\"filament-info\">\n                <div class=\"ams-label\">AMS-C</div>\n                <div class=\"filament-types\">PLA-S · PLA-S · PETG · PLA</div>\n                <div class=\"filament-fills\">33% · 78% · 55% · 41%</div>\n              </div>\n              <div class=\"ams-stats\">\n                <div class=\"stat stat-good\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                    <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                  </svg>\n                  16%\n                </div>\n                <div class=\"stat stat-neutral\">22.9°C</div>\n              </div>\n            </div>\n\n            <!-- HT-A -->\n            <div class=\"ams-row\">\n              <div class=\"ams-icon-wrapper\">\n                <svg width=\"56\" height=\"56\" viewBox=\"0 0 21 21\" fill=\"none\">\n                  <rect x=\"8.3\" y=\"5.2\" width=\"3.8\" height=\"5.1\" fill=\"none\" stroke=\"#666\" stroke-dasharray=\"2 1.5\" rx=\"0.3\"/>\n                  <path d=\"M5.88312 4.68555C5.88312 4.13326 6.33083 3.68555 6.88312 3.68555H13.5059C14.0582 3.68555 14.5059 4.13326 14.5059 4.68555V10.3887H5.88312V4.68555Z\" stroke=\"#6B6B6B\"/>\n                  <rect x=\"3.8725\" y=\"10.3887\" width=\"12.7037\" height=\"7.55371\" rx=\"1.2\" stroke=\"#6B6B6B\"/>\n                  <path d=\"M8.21991 5.65234C8.21991 5.3762 8.44377 5.15234 8.71991 5.15234H11.7288C12.005 5.15234 12.2288 5.3762 12.2288 5.65234V10.3887H8.21991V5.65234Z\" stroke=\"#6B6B6B\"/>\n                </svg>\n              </div>\n              <div class=\"filament-info\">\n                <div class=\"ams-label\">HT-A</div>\n                <div class=\"filament-types\">—</div>\n              </div>\n              <div class=\"ams-stats\">\n                <div class=\"stat stat-fair\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                    <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                  </svg>\n                  47%\n                </div>\n                <div class=\"stat stat-neutral\">19.7°C</div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Smart Plug -->\n        <div class=\"smart-plug-section\">\n          <div class=\"plug-row\">\n            <svg class=\"plug-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/>\n            </svg>\n            <span class=\"plug-name\">bamnbuswitch3</span>\n            <span class=\"plug-badge on\">ON</span>\n            <span class=\"plug-power\">18W</span>\n            <div class=\"plug-controls\">\n              <button class=\"plug-btn on\">On</button>\n              <button class=\"plug-btn off\">Off</button>\n              <div class=\"auto-off-toggle\">\n                Auto-off\n                <div class=\"toggle-switch\"></div>\n              </div>\n            </div>\n          </div>\n          <div class=\"plug-footer\">\n            <span class=\"plug-ip\">192.168.255.133<br/>00488B540200427</span>\n            <div class=\"plug-actions\">\n              <button class=\"action-btn\">\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                  <path d=\"M23 7l-7 5 7 5V7z\"/><rect x=\"1\" y=\"5\" width=\"15\" height=\"14\" rx=\"2\"/>\n                </svg>\n              </button>\n              <button class=\"action-btn\">\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                  <path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"/>\n                </svg>\n              </button>\n            </div>\n          </div>\n        </div>\n\n        <div class=\"note\">4 AMS rows + 1 HT row = 5 rows in AMS section</div>\n      </div>\n    </div>\n\n    <!-- NEW LAYOUT -->\n    <div class=\"comparison-section\">\n      <span class=\"section-label new\">New 2-Column</span>\n\n      <div class=\"printer-card\">\n        <!-- Header (same as current) -->\n        <div class=\"card-header\">\n          <div class=\"printer-image\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\">\n              <rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\"/>\n              <path d=\"M4 10h16\"/>\n              <path d=\"M10 10v10\"/>\n            </svg>\n          </div>\n          <div class=\"printer-details\">\n            <div class=\"printer-name-row\">\n              <span class=\"printer-name\">H2D-1</span>\n              <button class=\"menu-btn\">\n                <svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n                  <circle cx=\"12\" cy=\"5\" r=\"1.5\"/>\n                  <circle cx=\"12\" cy=\"12\" r=\"1.5\"/>\n                  <circle cx=\"12\" cy=\"19\" r=\"1.5\"/>\n                </svg>\n              </button>\n            </div>\n            <div class=\"printer-model\">H2D • 0.4mm • 632h</div>\n          </div>\n        </div>\n\n        <!-- Badges (same) -->\n        <div class=\"badges-row\">\n          <span class=\"badge badge-green\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"/>\n              <path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"/>\n            </svg>\n            Connected\n          </span>\n          <span class=\"badge badge-green\">-53dBm</span>\n          <span class=\"badge badge-green\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path d=\"M12 9v2m0 4h.01\"/>\n              <path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/>\n            </svg>\n            OK\n          </span>\n          <span class=\"badge badge-green\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\"/>\n            </svg>\n            OK\n          </span>\n        </div>\n\n        <!-- Status (same) -->\n        <div class=\"status-section\">\n          <div class=\"status-row\">\n            <div class=\"cover-placeholder\">\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\">\n                <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/>\n                <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"/>\n                <path d=\"M21 15l-5-5L5 21\"/>\n              </svg>\n            </div>\n            <div class=\"status-info\">\n              <div class=\"status-label\">Status</div>\n              <div class=\"status-value\">Idle</div>\n              <div class=\"progress-bar\"></div>\n              <div class=\"ready-text\">Ready to print</div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Temperatures (same) -->\n        <div class=\"temp-grid\">\n          <div class=\"temp-card\">\n            <svg class=\"temp-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#f97316\" stroke-width=\"2\">\n              <path d=\"M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z\"/>\n            </svg>\n            <div class=\"temp-label\">Left / Right</div>\n            <div class=\"temp-value\">20°C / 19°C</div>\n          </div>\n          <div class=\"temp-card\">\n            <svg class=\"temp-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3b82f6\" stroke-width=\"2\">\n              <path d=\"M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z\"/>\n            </svg>\n            <div class=\"temp-label\">Bed</div>\n            <div class=\"temp-value\">20°C</div>\n          </div>\n          <div class=\"temp-card\">\n            <svg class=\"temp-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22c55e\" stroke-width=\"2\">\n              <path d=\"M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z\"/>\n            </svg>\n            <div class=\"temp-label\">Chamber</div>\n            <div class=\"temp-value\">21°C</div>\n          </div>\n        </div>\n\n        <!-- AMS Section - NEW 2-COLUMN GRID -->\n        <div class=\"ams-section\">\n          <!-- Row 1-2: Up to 4x AMS -->\n          <div class=\"ams-grid\">\n            <!-- AMS-A -->\n            <div class=\"ams-card\">\n              <div class=\"ams-card-header\">\n                <div class=\"ams-card-left\">\n                  <svg width=\"36\" height=\"22\" viewBox=\"0 0 36 22\" fill=\"none\">\n                    <rect x=\"1\" y=\"1\" width=\"34\" height=\"20\" rx=\"2\" fill=\"#2F2E33\"/>\n                    <rect x=\"5\" y=\"5\" width=\"4\" height=\"12\" fill=\"#e53935\"/>\n                    <rect x=\"11\" y=\"5\" width=\"4\" height=\"12\" fill=\"#1e88e5\"/>\n                    <rect x=\"17\" y=\"5\" width=\"4\" height=\"12\" fill=\"#43a047\"/>\n                    <rect x=\"23\" y=\"5\" width=\"4\" height=\"12\" fill=\"#f5f5f5\"/>\n                    <rect x=\"29\" y=\"5\" width=\"2\" height=\"12\" fill=\"#767676\"/>\n                  </svg>\n                  <span class=\"ams-label\">AMS-A</span>\n                </div>\n                <div class=\"ams-card-stats\">\n                  <div class=\"stat stat-good\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                      <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                    </svg>\n                    21%\n                  </div>\n                  <div class=\"stat stat-neutral\">20.6°</div>\n                </div>\n              </div>\n              <div class=\"slots-grid\">\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #e53935;\"></div>\n                  <div class=\"slot-type\">PLA Basic</div>\n                  <div class=\"slot-fill\">71%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #1e88e5;\"></div>\n                  <div class=\"slot-type\">PETG HF</div>\n                  <div class=\"slot-fill\">50%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #43a047;\"></div>\n                  <div class=\"slot-type\">PLA</div>\n                  <div class=\"slot-fill\">87%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #f5f5f5;\"></div>\n                  <div class=\"slot-type\">PLA Basic</div>\n                  <div class=\"slot-fill\">23%</div>\n                </div>\n              </div>\n            </div>\n\n            <!-- AMS-B -->\n            <div class=\"ams-card\">\n              <div class=\"ams-card-header\">\n                <div class=\"ams-card-left\">\n                  <svg width=\"36\" height=\"22\" viewBox=\"0 0 36 22\" fill=\"none\">\n                    <rect x=\"1\" y=\"1\" width=\"34\" height=\"20\" rx=\"2\" fill=\"#2F2E33\"/>\n                    <rect x=\"5\" y=\"5\" width=\"4\" height=\"12\" fill=\"#9c27b0\"/>\n                    <rect x=\"11\" y=\"5\" width=\"4\" height=\"12\" fill=\"#ff9800\"/>\n                    <rect x=\"17\" y=\"5\" width=\"4\" height=\"12\" fill=\"#fdd835\"/>\n                    <rect x=\"23\" y=\"5\" width=\"4\" height=\"12\" fill=\"#212121\"/>\n                    <rect x=\"29\" y=\"5\" width=\"2\" height=\"12\" fill=\"#767676\"/>\n                  </svg>\n                  <span class=\"ams-label\">AMS-B</span>\n                </div>\n                <div class=\"ams-card-stats\">\n                  <div class=\"stat stat-good\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                      <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                    </svg>\n                    16%\n                  </div>\n                  <div class=\"stat stat-neutral\">20.7°</div>\n                </div>\n              </div>\n              <div class=\"slots-grid\">\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #9c27b0;\"></div>\n                  <div class=\"slot-type\">PETG HF</div>\n                  <div class=\"slot-fill\">45%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #ff9800;\"></div>\n                  <div class=\"slot-type\">PLA</div>\n                  <div class=\"slot-fill\">92%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #fdd835;\"></div>\n                  <div class=\"slot-type\">PLA-S</div>\n                  <div class=\"slot-fill\">15%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #212121;\"></div>\n                  <div class=\"slot-type\">PLA-S</div>\n                  <div class=\"slot-fill\">68%</div>\n                </div>\n              </div>\n            </div>\n\n            <!-- AMS-C -->\n            <div class=\"ams-card\">\n              <div class=\"ams-card-header\">\n                <div class=\"ams-card-left\">\n                  <svg width=\"36\" height=\"22\" viewBox=\"0 0 36 22\" fill=\"none\">\n                    <rect x=\"1\" y=\"1\" width=\"34\" height=\"20\" rx=\"2\" fill=\"#2F2E33\"/>\n                    <rect x=\"5\" y=\"5\" width=\"4\" height=\"12\" fill=\"#00bcd4\"/>\n                    <rect x=\"11\" y=\"5\" width=\"4\" height=\"12\" fill=\"#e91e63\"/>\n                    <rect x=\"17\" y=\"5\" width=\"4\" height=\"12\" fill=\"#9e9e9e\"/>\n                    <rect x=\"23\" y=\"5\" width=\"4\" height=\"12\" fill=\"#f48fb1\"/>\n                    <rect x=\"29\" y=\"5\" width=\"2\" height=\"12\" fill=\"#767676\"/>\n                  </svg>\n                  <span class=\"ams-label\">AMS-C</span>\n                </div>\n                <div class=\"ams-card-stats\">\n                  <div class=\"stat stat-good\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                      <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                    </svg>\n                    17%\n                  </div>\n                  <div class=\"stat stat-neutral\">22.0°</div>\n                </div>\n              </div>\n              <div class=\"slots-grid\">\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #00bcd4;\"></div>\n                  <div class=\"slot-type\">PLA-S</div>\n                  <div class=\"slot-fill\">33%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #e91e63;\"></div>\n                  <div class=\"slot-type\">PLA-S</div>\n                  <div class=\"slot-fill\">78%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #9e9e9e;\"></div>\n                  <div class=\"slot-type\">PETG</div>\n                  <div class=\"slot-fill\">55%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #f48fb1;\"></div>\n                  <div class=\"slot-type\">PLA</div>\n                  <div class=\"slot-fill\">41%</div>\n                </div>\n              </div>\n            </div>\n\n            <!-- AMS-D -->\n            <div class=\"ams-card\">\n              <div class=\"ams-card-header\">\n                <div class=\"ams-card-left\">\n                  <svg width=\"36\" height=\"22\" viewBox=\"0 0 36 22\" fill=\"none\">\n                    <rect x=\"1\" y=\"1\" width=\"34\" height=\"20\" rx=\"2\" fill=\"#2F2E33\"/>\n                    <rect x=\"5\" y=\"5\" width=\"4\" height=\"12\" fill=\"#8bc34a\"/>\n                    <rect x=\"11\" y=\"5\" width=\"4\" height=\"12\" fill=\"#ff5722\"/>\n                    <rect x=\"17\" y=\"5\" width=\"4\" height=\"12\" fill=\"#607d8b\"/>\n                    <rect x=\"23\" y=\"5\" width=\"4\" height=\"12\" fill=\"#795548\"/>\n                    <rect x=\"29\" y=\"5\" width=\"2\" height=\"12\" fill=\"#767676\"/>\n                  </svg>\n                  <span class=\"ams-label\">AMS-D</span>\n                </div>\n                <div class=\"ams-card-stats\">\n                  <div class=\"stat stat-good\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                      <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                    </svg>\n                    9%\n                  </div>\n                  <div class=\"stat stat-neutral\">21.2°</div>\n                </div>\n              </div>\n              <div class=\"slots-grid\">\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #8bc34a;\"></div>\n                  <div class=\"slot-type\">PLA</div>\n                  <div class=\"slot-fill\">88%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #ff5722;\"></div>\n                  <div class=\"slot-type\">ABS</div>\n                  <div class=\"slot-fill\">62%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #607d8b;\"></div>\n                  <div class=\"slot-type\">PETG</div>\n                  <div class=\"slot-fill\">29%</div>\n                </div>\n                <div class=\"slot\">\n                  <div class=\"slot-color\" style=\"background: #795548;\"></div>\n                  <div class=\"slot-type\">PLA</div>\n                  <div class=\"slot-fill\">95%</div>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- Row 3: HT + External (half-size, 4 across) -->\n          <div class=\"ams-row-small\">\n            <!-- HT-A -->\n            <div class=\"ams-card-small\">\n              <svg width=\"20\" height=\"20\" viewBox=\"0 0 21 21\" fill=\"none\">\n                <rect x=\"6\" y=\"4\" width=\"9\" height=\"7\" rx=\"1\" fill=\"#2F2E33\" stroke=\"#6B6B6B\"/>\n                <rect x=\"4\" y=\"11\" width=\"13\" height=\"6\" rx=\"1\" stroke=\"#6B6B6B\"/>\n                <circle cx=\"10.5\" cy=\"7.5\" r=\"2\" fill=\"none\" stroke=\"#666\" stroke-dasharray=\"2 1\"/>\n              </svg>\n              <div class=\"small-info\">\n                <div class=\"ams-label\">HT-A</div>\n                <div class=\"slot-type\">Empty</div>\n              </div>\n              <div class=\"ams-card-stats\">\n                <div class=\"stat stat-fair\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                    <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                  </svg>\n                  44%\n                </div>\n              </div>\n            </div>\n\n            <!-- HT-B -->\n            <div class=\"ams-card-small\">\n              <svg width=\"20\" height=\"20\" viewBox=\"0 0 21 21\" fill=\"none\">\n                <rect x=\"6\" y=\"4\" width=\"9\" height=\"7\" rx=\"1\" fill=\"#2F2E33\" stroke=\"#6B6B6B\"/>\n                <rect x=\"4\" y=\"11\" width=\"13\" height=\"6\" rx=\"1\" stroke=\"#6B6B6B\"/>\n                <circle cx=\"10.5\" cy=\"7.5\" r=\"2\" fill=\"#00acc1\"/>\n              </svg>\n              <div class=\"small-info\">\n                <div class=\"ams-label\">HT-B</div>\n                <div class=\"slot-type\">PA-CF</div>\n              </div>\n              <div class=\"ams-card-stats\">\n                <div class=\"stat stat-good\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                    <path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"/>\n                  </svg>\n                  12%\n                </div>\n              </div>\n            </div>\n\n            <!-- External 1 -->\n            <div class=\"ams-card-small\">\n              <div class=\"external-spool\" style=\"background: #b0bec5;\"></div>\n              <div class=\"small-info\">\n                <div class=\"ams-label\">Ext-1</div>\n                <div class=\"slot-type\">PLA</div>\n              </div>\n            </div>\n\n            <!-- External 2 -->\n            <div class=\"ams-card-small\">\n              <div class=\"external-spool\" style=\"background: #ffeb3b;\"></div>\n              <div class=\"small-info\">\n                <div class=\"ams-label\">Ext-2</div>\n                <div class=\"slot-type\">TPU</div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Smart Plug (same as current) -->\n        <div class=\"smart-plug-section\">\n          <div class=\"plug-row\">\n            <svg class=\"plug-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"/>\n            </svg>\n            <span class=\"plug-name\">bamnbuswitch3</span>\n            <span class=\"plug-badge on\">ON</span>\n            <span class=\"plug-power\">18W</span>\n            <div class=\"plug-controls\">\n              <button class=\"plug-btn on\">On</button>\n              <button class=\"plug-btn off\">Off</button>\n              <div class=\"auto-off-toggle\">\n                Auto-off\n                <div class=\"toggle-switch\"></div>\n              </div>\n            </div>\n          </div>\n          <div class=\"plug-footer\">\n            <span class=\"plug-ip\">192.168.255.133<br/>00488B540200427</span>\n            <div class=\"plug-actions\">\n              <button class=\"action-btn\">\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                  <path d=\"M23 7l-7 5 7 5V7z\"/><rect x=\"1\" y=\"5\" width=\"15\" height=\"14\" rx=\"2\"/>\n                </svg>\n              </button>\n              <button class=\"action-btn\">\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                  <path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"/>\n                </svg>\n              </button>\n            </div>\n          </div>\n        </div>\n\n        <div class=\"note\">3 rows total: Row 1-2 for 4x AMS, Row 3 for 2x HT + 2x Ext (half-size)</div>\n      </div>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"frontend\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc -b && vite build\",\n    \"lint\": \"eslint .\",\n    \"preview\": \"vite preview\",\n    \"test\": \"vitest\",\n    \"test:run\": \"vitest run && npm run check:i18n\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:ui\": \"vitest --ui\",\n    \"check:i18n\": \"node scripts/check-i18n-parity.mjs\"\n  },\n  \"dependencies\": {\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@floating-ui/dom\": \"^1.7.5\",\n    \"@tanstack/react-query\": \"^5.90.11\",\n    \"@tiptap/extension-color\": \"^3.11.1\",\n    \"@tiptap/extension-image\": \"^3.11.1\",\n    \"@tiptap/extension-link\": \"^3.11.1\",\n    \"@tiptap/extension-text-align\": \"^3.11.1\",\n    \"@tiptap/extension-text-style\": \"^3.11.1\",\n    \"@tiptap/extension-underline\": \"^3.11.1\",\n    \"@tiptap/react\": \"^3.11.1\",\n    \"@tiptap/starter-kit\": \"^3.11.1\",\n    \"@types/three\": \"^0.181.0\",\n    \"dompurify\": \"^3.4.0\",\n    \"gcode-preview\": \"^2.18.0\",\n    \"i18next\": \"25.6.3\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"i18next-http-backend\": \"^3.0.5\",\n    \"jszip\": \"^3.10.1\",\n    \"lucide-react\": \"^0.555.0\",\n    \"react\": \"^19.2.0\",\n    \"react-dom\": \"^19.2.0\",\n    \"react-i18next\": \"^16.3.5\",\n    \"react-router-dom\": \"^7.12.0\",\n    \"react-simple-keyboard\": \"^3.8.164\",\n    \"recharts\": \"^3.5.1\",\n    \"three\": \"^0.181.2\"\n  },\n  \"overrides\": {\n    \"minimatch\": \"^10.2.1\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.1\",\n    \"@tailwindcss/postcss\": \"^4.1.17\",\n    \"@testing-library/jest-dom\": \"^6.6.0\",\n    \"@testing-library/react\": \"^16.0.0\",\n    \"@testing-library/user-event\": \"^14.5.0\",\n    \"@types/node\": \"^24.10.1\",\n    \"@types/react\": \"^19.2.5\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@vitejs/plugin-react\": \"^5.1.1\",\n    \"@vitest/coverage-v8\": \"^3.2.4\",\n    \"autoprefixer\": \"^10.4.22\",\n    \"baseline-browser-mapping\": \"^2.9.19\",\n    \"eslint\": \"^9.39.1\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.4.24\",\n    \"globals\": \"^16.5.0\",\n    \"jsdom\": \"^25.0.0\",\n    \"msw\": \"^2.6.0\",\n    \"postcss\": \"^8.5.6\",\n    \"tailwindcss\": \"^4.1.17\",\n    \"typescript\": \"~5.9.3\",\n    \"typescript-eslint\": \"^8.46.4\",\n    \"vite\": \"^7.3.2\",\n    \"vitest\": \"^3.2.4\"\n  }\n}\n"
  },
  {
    "path": "frontend/postcss.config.js",
    "content": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n  },\n}\n"
  },
  {
    "path": "frontend/public/manifest.json",
    "content": "{\n  \"id\": \"/\",\n  \"name\": \"Bambuddy\",\n  \"short_name\": \"Bambuddy\",\n  \"description\": \"Monitor and manage your Bambu Lab 3D printers\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#1a1a1a\",\n  \"theme_color\": \"#00ae42\",\n  \"orientation\": \"any\",\n  \"scope\": \"/\",\n  \"icons\": [\n    {\n      \"src\": \"/img/favicon-16x16.png\",\n      \"sizes\": \"16x16\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/img/favicon-32x32.png\",\n      \"sizes\": \"32x32\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/img/android-chrome-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any\"\n    },\n    {\n      \"src\": \"/img/android-chrome-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any\"\n    },\n    {\n      \"src\": \"/img/android-chrome-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"/img/android-chrome-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"screenshots\": [\n    {\n      \"src\": \"/img/screenshot-mobile.png\",\n      \"sizes\": \"1080x1920\",\n      \"type\": \"image/png\",\n      \"form_factor\": \"narrow\",\n      \"label\": \"Bambuddy on mobile\"\n    },\n    {\n      \"src\": \"/img/screenshot-desktop.png\",\n      \"sizes\": \"1920x1080\",\n      \"type\": \"image/png\",\n      \"form_factor\": \"wide\",\n      \"label\": \"Bambuddy on desktop\"\n    }\n  ],\n  \"categories\": [\"utilities\", \"productivity\"],\n  \"shortcuts\": [\n    {\n      \"name\": \"Printers\",\n      \"short_name\": \"Printers\",\n      \"description\": \"View your printers\",\n      \"url\": \"/\",\n      \"icons\": [{ \"src\": \"/img/android-chrome-192x192.png\", \"sizes\": \"192x192\" }]\n    },\n    {\n      \"name\": \"Archives\",\n      \"short_name\": \"Archives\",\n      \"description\": \"View print archives\",\n      \"url\": \"/archives\",\n      \"icons\": [{ \"src\": \"/img/android-chrome-192x192.png\", \"sizes\": \"192x192\" }]\n    },\n    {\n      \"name\": \"Queue\",\n      \"short_name\": \"Queue\",\n      \"description\": \"View print queue\",\n      \"url\": \"/queue\",\n      \"icons\": [{ \"src\": \"/img/android-chrome-192x192.png\", \"sizes\": \"192x192\" }]\n    },\n    {\n      \"name\": \"Projects\",\n      \"short_name\": \"Projects\",\n      \"description\": \"View print projects\",\n      \"url\": \"/projects\",\n      \"icons\": [{ \"src\": \"/img/android-chrome-192x192.png\", \"sizes\": \"192x192\" }]\n    }\n  ]\n}\n"
  },
  {
    "path": "frontend/public/sw-register.js",
    "content": "if ('serviceWorker' in navigator) {\n  if (location.pathname.startsWith('/spoolbuddy')) {\n    navigator.serviceWorker.getRegistrations().then((regs) => {\n      if (regs.length > 0) {\n        Promise.all([\n          ...regs.map((r) => r.unregister()),\n          caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))),\n        ]).then(() => location.reload());\n      }\n    });\n  } else {\n    window.addEventListener('load', () => {\n      navigator.serviceWorker.register('/sw.js')\n        .then((registration) => {\n          console.log('SW registered:', registration.scope);\n        })\n        .catch((error) => {\n          console.log('SW registration failed:', error);\n        });\n    });\n  }\n}\n"
  },
  {
    "path": "frontend/public/sw.js",
    "content": "// Bambuddy Service Worker\nconst CACHE_NAME = 'bambuddy-v25';\nconst STATIC_CACHE = 'bambuddy-static-v25';\n\n// Static assets to cache on install\nconst STATIC_ASSETS = [\n  '/',\n  '/manifest.json',\n  '/img/favicon.png',\n  '/img/favicon-16x16.png',\n  '/img/favicon-32x32.png',\n  '/img/android-chrome-192x192.png',\n  '/img/android-chrome-512x512.png',\n  '/img/apple-touch-icon.png',\n  '/img/bambuddy_logo_dark.png',\n];\n\n// Install event - cache static assets\nself.addEventListener('install', (event) => {\n  console.log('[SW] Installing service worker...');\n  event.waitUntil(\n    caches.open(STATIC_CACHE).then((cache) => {\n      console.log('[SW] Caching static assets');\n      return cache.addAll(STATIC_ASSETS);\n    })\n  );\n  // Activate immediately\n  self.skipWaiting();\n});\n\n// Activate event - clean up old caches\nself.addEventListener('activate', (event) => {\n  console.log('[SW] Activating service worker...');\n  event.waitUntil(\n    caches.keys().then((cacheNames) => {\n      return Promise.all(\n        cacheNames\n          .filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)\n          .map((name) => {\n            console.log('[SW] Deleting old cache:', name);\n            return caches.delete(name);\n          })\n      );\n    })\n  );\n  // Take control immediately\n  self.clients.claim();\n});\n\n// Fetch event - network-first for API, cache-first for static\nself.addEventListener('fetch', (event) => {\n  const { request } = event;\n  const url = new URL(request.url);\n\n  // Skip non-GET requests\n  if (request.method !== 'GET') {\n    return;\n  }\n\n  // Skip WebSocket connections\n  if (url.protocol === 'ws:' || url.protocol === 'wss:') {\n    return;\n  }\n\n  // Skip camera stream/snapshot requests - Safari has issues with streaming through SW\n  if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) {\n    return;\n  }\n\n  // API requests - network first, no cache (real-time data is critical)\n  if (url.pathname.startsWith('/api/')) {\n    event.respondWith(\n      fetch(request).catch(() => {\n        // Return offline response for API failures\n        return new Response(\n          JSON.stringify({ error: 'offline', message: 'You are currently offline' }),\n          {\n            status: 503,\n            headers: { 'Content-Type': 'application/json' },\n          }\n        );\n      })\n    );\n    return;\n  }\n\n  // Static assets - cache first, then network\n  if (\n    url.pathname.startsWith('/img/') ||\n    url.pathname.startsWith('/icons/') ||\n    url.pathname.endsWith('.png') ||\n    url.pathname.endsWith('.jpg') ||\n    url.pathname.endsWith('.svg') ||\n    url.pathname.endsWith('.ico')\n  ) {\n    event.respondWith(\n      caches.match(request).then((cached) => {\n        if (cached) {\n          return cached;\n        }\n        return fetch(request).then((response) => {\n          // Cache successful responses\n          if (response.ok) {\n            const clone = response.clone();\n            caches.open(STATIC_CACHE).then((cache) => {\n              cache.put(request, clone);\n            });\n          }\n          return response;\n        });\n      })\n    );\n    return;\n  }\n\n  // JS/CSS assets - network first (Vite content-hashes filenames, so\n  // cache-busting is built in; network-first ensures new builds load immediately)\n  if (\n    url.pathname.startsWith('/assets/') ||\n    url.pathname.endsWith('.js') ||\n    url.pathname.endsWith('.css')\n  ) {\n    event.respondWith(\n      fetch(request)\n        .then((response) => {\n          if (response.ok) {\n            const clone = response.clone();\n            caches.open(CACHE_NAME).then((cache) => {\n              cache.put(request, clone);\n            });\n          }\n          return response;\n        })\n        .catch(() => {\n          return caches.match(request);\n        })\n    );\n    return;\n  }\n\n  // HTML pages - network first, fall back to cache\n  event.respondWith(\n    fetch(request)\n      .then((response) => {\n        if (response.ok) {\n          const clone = response.clone();\n          caches.open(CACHE_NAME).then((cache) => {\n            cache.put(request, clone);\n          });\n        }\n        return response;\n      })\n      .catch(() => {\n        return caches.match(request).then((cached) => {\n          if (cached) {\n            return cached;\n          }\n          // Return cached index for SPA navigation\n          return caches.match('/');\n        });\n      })\n  );\n});\n\n// Handle push notifications (for future use)\nself.addEventListener('push', (event) => {\n  if (!event.data) return;\n\n  const data = event.data.json();\n  const options = {\n    body: data.body || 'New notification from Bambuddy',\n    icon: '/img/android-chrome-192x192.png',\n    badge: '/img/favicon-32x32.png',\n    vibrate: [100, 50, 100],\n    data: {\n      url: data.url || '/',\n    },\n  };\n\n  event.waitUntil(\n    self.registration.showNotification(data.title || 'Bambuddy', options)\n  );\n});\n\n// Handle notification clicks\nself.addEventListener('notificationclick', (event) => {\n  event.notification.close();\n\n  const url = event.notification.data?.url || '/';\n\n  event.waitUntil(\n    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {\n      // Check if there's already a window open\n      for (const client of windowClients) {\n        if (client.url.includes(self.location.origin) && 'focus' in client) {\n          client.navigate(url);\n          return client.focus();\n        }\n      }\n      // Open a new window if none exists\n      if (clients.openWindow) {\n        return clients.openWindow(url);\n      }\n    })\n  );\n});\n"
  },
  {
    "path": "frontend/scripts/check-i18n-parity.mjs",
    "content": "// Verifies parity across locale files (en / zh-CN / zh-TW):\n//   1. Leaf-key sets are identical\n//   2. Each leaf's {{placeholder}} set is identical\n//   3. Plural suffixes: every en key ending in _plural / _one / _other must\n//      exist in every other locale, and other locales must not introduce an\n//      _one key that en does not have.\n// Malformed input (missing `export default`, parse errors, non-string leaves,\n// unsupported property kinds) fails loudly instead of silently passing the gate.\n// Exits 1 with a diagnostic report on any failure, else exits 0.\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport url from 'node:url';\n\nconst scriptDir = path.dirname(url.fileURLToPath(import.meta.url));\nconst frontendDir = path.resolve(scriptDir, '..');\nconst localesDir = path.join(frontendDir, 'src/i18n/locales');\nconst tsPath = path.join(frontendDir, 'node_modules/typescript/lib/typescript.js');\n\nconst tsModule = await import(url.pathToFileURL(tsPath).href);\nconst ts = tsModule.default ?? tsModule;\n\nfunction collectLeaves(node, prefix, leaves) {\n  if (!ts.isObjectLiteralExpression(node)) return;\n  for (const prop of node.properties) {\n    if (!ts.isPropertyAssignment(prop)) {\n      console.error(\n        `Unsupported property kind ${ts.SyntaxKind[prop.kind]} at \"${prefix}\" ` +\n        `(locale files must use plain \\`key: value\\` assignments — no spread, shorthand, methods, or accessors).`,\n      );\n      process.exit(1);\n    }\n    let name;\n    if (ts.isIdentifier(prop.name)) name = prop.name.text;\n    else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) name = prop.name.text;\n    else if (ts.isComputedPropertyName(prop.name)) {\n      console.error(`ComputedPropertyName not allowed in locale file at path \"${prefix}\"`);\n      process.exit(1);\n    } else {\n      console.error(`Unsupported property-name kind ${ts.SyntaxKind[prop.name.kind]} at \"${prefix}\"`);\n      process.exit(1);\n    }\n    const p = prefix ? `${prefix}.${name}` : name;\n    if (ts.isObjectLiteralExpression(prop.initializer)) {\n      collectLeaves(prop.initializer, p, leaves);\n    } else {\n      const value = extractStringValue(prop.initializer, p);\n      leaves.set(p, value);\n    }\n  }\n}\n\nfunction extractStringValue(node, keyPath) {\n  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;\n  if (ts.isTemplateExpression(node)) {\n    let out = node.head.text;\n    for (const span of node.templateSpans) {\n      out += '${' + span.expression.getText() + '}';\n      out += span.literal.text;\n    }\n    return out;\n  }\n  console.error(\n    `Non-string leaf at \"${keyPath}\" (kind=${ts.SyntaxKind[node.kind]}): ${node.getText()}\\n` +\n    `Locale files must only contain string or template literals as leaf values.`,\n  );\n  process.exit(1);\n}\n\nfunction loadLocale(filePath) {\n  const src = fs.readFileSync(filePath, 'utf8');\n  const sf = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true);\n  if (sf.parseDiagnostics && sf.parseDiagnostics.length > 0) {\n    console.error(`${filePath}: ${sf.parseDiagnostics.length} parse error(s):`);\n    for (const d of sf.parseDiagnostics.slice(0, 10)) {\n      const msg = typeof d.messageText === 'string' ? d.messageText : d.messageText.messageText;\n      const { line, character } = sf.getLineAndCharacterOfPosition(d.start ?? 0);\n      console.error(`  ${line + 1}:${character + 1} ${msg}`);\n    }\n    process.exit(1);\n  }\n  const leaves = new Map();\n  let foundExport = false;\n  ts.forEachChild(sf, (n) => {\n    if (ts.isExportAssignment(n)) {\n      foundExport = true;\n      collectLeaves(n.expression, '', leaves);\n    }\n  });\n  if (!foundExport) {\n    console.error(`${filePath}: no \\`export default\\` found — locale files must use \\`export default { ... }\\`.`);\n    process.exit(1);\n  }\n  if (leaves.size === 0) {\n    console.error(`${filePath}: \\`export default\\` resolved to zero leaves — file is empty or not a nested object.`);\n    process.exit(1);\n  }\n  return leaves;\n}\n\nconst placeholderRe = /\\{\\{[^{}]+\\}\\}/g;\n\n// Pure comparison logic, exported so tests can verify each failure mode\n// without going through file IO or the TypeScript parser.\n// Input:  locales = { code: Map<leafKey, leafString> }  (must contain 'en')\n// Output: { failed, reports: Array<{ label, items }> }\nexport function compareLocales(locales) {\n  if (!locales.en) throw new Error(\"compareLocales requires a locales.en entry\");\n  const reports = [];\n  const add = (label, items) => {\n    if (items.length) reports.push({ label, items });\n  };\n\n  const enKeys = new Set(locales.en.keys());\n\n  // Check 1: key set equality\n  for (const [code, map] of Object.entries(locales)) {\n    if (code === 'en') continue;\n    const keys = new Set(map.keys());\n    const missing = [...enKeys].filter((k) => !keys.has(k)).sort();\n    const extra = [...keys].filter((k) => !enKeys.has(k)).sort();\n    add(`${code}: missing keys vs en`, missing);\n    add(`${code}: extra keys vs en`, extra);\n  }\n\n  // Check 2: placeholder set equality per leaf\n  for (const [code, map] of Object.entries(locales)) {\n    if (code === 'en') continue;\n    const mismatches = [];\n    for (const [key, enValue] of locales.en) {\n      const otherValue = map.get(key);\n      if (otherValue === undefined) continue;\n      const enPlaceholders = new Set((enValue.match(placeholderRe) ?? []));\n      const otherPlaceholders = new Set((otherValue.match(placeholderRe) ?? []));\n      const missingPh = [...enPlaceholders].filter((p) => !otherPlaceholders.has(p));\n      const extraPh = [...otherPlaceholders].filter((p) => !enPlaceholders.has(p));\n      if (missingPh.length || extraPh.length) {\n        mismatches.push(`${key}: en=${[...enPlaceholders].join(',') || '∅'} vs ${code}=${[...otherPlaceholders].join(',') || '∅'}`);\n      }\n    }\n    add(`${code}: placeholder mismatch vs en`, mismatches);\n  }\n\n  // Check 3: plural suffix presence + reverse _one guard\n  for (const [code, map] of Object.entries(locales)) {\n    if (code === 'en') continue;\n    const pluralIssues = [];\n    for (const key of enKeys) {\n      if (key.endsWith('_plural') && !map.has(key)) pluralIssues.push(`missing _plural key: ${key}`);\n      if (key.endsWith('_one') && !map.has(key)) pluralIssues.push(`missing _one key: ${key}`);\n      if (key.endsWith('_other') && !map.has(key)) pluralIssues.push(`missing _other key: ${key}`);\n    }\n    for (const key of map.keys()) {\n      if (key.endsWith('_one') && !enKeys.has(key)) {\n        pluralIssues.push(`unexpected _one not present in en: ${key}`);\n      }\n    }\n    add(`${code}: plural key mismatch`, pluralIssues);\n  }\n\n  return { failed: reports.length > 0, reports };\n}\n\n// Strict locales fail CI when they drift from en. Everything else discovered\n// in the locales directory is reported informationally — promote a locale to\n// STRICT once its drift is caught up. en is implicitly the reference.\nconst STRICT = ['de', 'zh-CN', 'zh-TW'];\n\n// Skip file IO / process.exit when imported as a library (e.g. from tests).\nconst isMainModule = import.meta.url === url.pathToFileURL(process.argv[1] ?? '').href;\nif (isMainModule) {\n  const discovered = fs\n    .readdirSync(localesDir)\n    .filter((f) => f.endsWith('.ts'))\n    .map((f) => f.slice(0, -3))\n    .sort();\n  if (!discovered.includes('en')) {\n    console.error(`No en.ts found in ${localesDir} — cannot run parity check without a reference locale.`);\n    process.exit(1);\n  }\n  const missingStrict = STRICT.filter((c) => !discovered.includes(c));\n  if (missingStrict.length) {\n    console.error(`STRICT locales declared but not found on disk: ${missingStrict.join(', ')}`);\n    process.exit(1);\n  }\n  const codes = ['en', ...discovered.filter((c) => c !== 'en')];\n  const locales = Object.fromEntries(\n    codes.map((c) => [c, loadLocale(path.join(localesDir, `${c}.ts`))]),\n  );\n\n  const MAX_REPORT = 20;\n  const strictSet = new Set(STRICT);\n  const printReports = (reports, header) => {\n    if (!reports.length) return;\n    console.error(`\\n${header}`);\n    for (const { label, items } of reports) {\n      console.error(`\\n[${label}] (${items.length})`);\n      items.slice(0, MAX_REPORT).forEach((i) => console.error(`  ${i}`));\n      if (items.length > MAX_REPORT) console.error(`  ... and ${items.length - MAX_REPORT} more`);\n    }\n  };\n\n  // Label prefix is \"${code}:\" — route reports to strict vs informational.\n  const { reports } = compareLocales(locales);\n  const codeOf = (label) => label.split(':', 1)[0];\n  const strictReports = reports.filter((r) => strictSet.has(codeOf(r.label)));\n  const infoReports = reports.filter((r) => !strictSet.has(codeOf(r.label)));\n\n  printReports(strictReports, '=== STRICT locales (failures below fail CI) ===');\n  // Informational locales: show per-category drift counts only, not the\n  // full key lists — the leaf-count table below already gives the overall\n  // picture. Flip VERBOSE_INFO=1 to dump the full missing-key/placeholder\n  // reports when actually working on translations.\n  if (infoReports.length) {\n    if (process.env.VERBOSE_INFO === '1') {\n      printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');\n    } else {\n      console.error('\\n=== INFORMATIONAL locales (drift summary; VERBOSE_INFO=1 for detail) ===');\n      for (const { label, items } of infoReports) {\n        console.error(`  ${label}: ${items.length}`);\n      }\n    }\n  }\n\n  console.log('\\nLocale leaf counts:');\n  for (const [code, map] of Object.entries(locales)) {\n    const tier = code === 'en' ? 'ref' : strictSet.has(code) ? 'strict' : 'info';\n    console.log(`  ${code.padEnd(6)} ${String(map.size).padEnd(6)} [${tier}]`);\n  }\n\n  if (strictReports.length > 0) {\n    console.error(`\\n❌ i18n parity check failed (strict: ${STRICT.join(', ')}).`);\n    process.exit(1);\n  }\n  console.log(`\\n✓ Strict locales in parity (en / ${STRICT.join(' / ')}).`);\n}\n"
  },
  {
    "path": "frontend/src/App.css",
    "content": "#root {\n  max-width: 1280px;\n  margin: 0 auto;\n  padding: 2rem;\n  text-align: center;\n}\n\n.logo {\n  height: 6em;\n  padding: 1.5em;\n  will-change: filter;\n  transition: filter 300ms;\n}\n.logo:hover {\n  filter: drop-shadow(0 0 2em #646cffaa);\n}\n.logo.react:hover {\n  filter: drop-shadow(0 0 2em #61dafbaa);\n}\n\n@keyframes logo-spin {\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n@media (prefers-reduced-motion: no-preference) {\n  a:nth-of-type(2) .logo {\n    animation: logo-spin infinite 20s linear;\n  }\n}\n\n.card {\n  padding: 2em;\n}\n\n.read-the-docs {\n  color: #888;\n}\n"
  },
  {
    "path": "frontend/src/App.tsx",
    "content": "import { Component, type ReactNode, type ErrorInfo } from 'react';\nimport { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { Layout } from './components/Layout';\nimport { PrintersPage } from './pages/PrintersPage';\nimport { ArchivesPage } from './pages/ArchivesPage';\nimport { QueuePage } from './pages/QueuePage';\nimport { StatsPage } from './pages/StatsPage';\nimport { SettingsPage } from './pages/SettingsPage';\nimport { ProfilesPage } from './pages/ProfilesPage';\nimport { MaintenancePage } from './pages/MaintenancePage';\nimport { ProjectsPage } from './pages/ProjectsPage';\nimport { ProjectDetailPage } from './pages/ProjectDetailPage';\nimport { FileManagerPage } from './pages/FileManagerPage';\nimport { CameraPage } from './pages/CameraPage';\nimport { StreamOverlayPage } from './pages/StreamOverlayPage';\nimport { ExternalLinkPage } from './pages/ExternalLinkPage';\nimport { GroupEditPage } from './pages/GroupEditPage';\nimport InventoryPage from './pages/InventoryPage';\nimport { SystemInfoPage } from './pages/SystemInfoPage';\nimport { LoginPage } from './pages/LoginPage';\nimport { SetupPage } from './pages/SetupPage';\nimport { NotificationsPage } from './pages/NotificationsPage';\nimport { useWebSocket } from './hooks/useWebSocket';\nimport { useStreamTokenSync } from './hooks/useCameraStreamToken';\nimport { ThemeProvider } from './contexts/ThemeContext';\nimport { ToastProvider } from './contexts/ToastContext';\nimport { AuthProvider, useAuth } from './contexts/AuthContext';\nimport { ColorCatalogProvider } from './contexts/ColorCatalogContext';\nimport { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';\nimport { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';\nimport { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';\nimport { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';\nimport { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';\nimport { SpoolBuddyWriteTagPage } from './pages/spoolbuddy/SpoolBuddyWriteTagPage';\nimport { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';\nclass ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null; errorInfo: ErrorInfo | null }> {\n  state = { error: null as Error | null, errorInfo: null as ErrorInfo | null };\n\n  static getDerivedStateFromError(error: Error) {\n    return { error };\n  }\n\n  componentDidCatch(error: Error, errorInfo: ErrorInfo) {\n    this.setState({ errorInfo });\n    console.error('React crash:', error, errorInfo);\n  }\n\n  render() {\n    if (this.state.error) {\n      return (\n        <div style={{ padding: 24, color: '#ef4444', backgroundColor: '#18181b', minHeight: '100vh', fontFamily: 'monospace' }}>\n          <h1 style={{ fontSize: 20, marginBottom: 12 }}>UI Crash</h1>\n          <pre style={{ whiteSpace: 'pre-wrap', fontSize: 14 }}>{this.state.error.message}</pre>\n          <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, color: '#a1a1aa', marginTop: 12 }}>\n            {this.state.error.stack}\n          </pre>\n          <button\n            onClick={() => { this.setState({ error: null, errorInfo: null }); }}\n            style={{ marginTop: 16, padding: '8px 16px', backgroundColor: '#3b82f6', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}\n          >\n            Retry\n          </button>\n        </div>\n      );\n    }\n    return this.props.children;\n  }\n}\n\nconst queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      staleTime: 1000 * 60,\n      retry: 1,\n    },\n  },\n});\n\nfunction StreamTokenSync() {\n  useStreamTokenSync();\n  return null;\n}\n\nfunction WebSocketProvider({ children }: { children: React.ReactNode }) {\n  useWebSocket();\n  return <>{children}</>;\n}\n\nfunction ProtectedRoute({ children }: { children: React.ReactNode }) {\n  const { authEnabled, loading, user } = useAuth();\n\n  if (loading) {\n    return <div className=\"min-h-screen flex items-center justify-center\">Loading...</div>;\n  }\n\n  if (authEnabled && !user) {\n    return <Navigate to=\"/login\" replace />;\n  }\n\n  return <>{children}</>;\n}\n\nfunction AdminRoute({ children }: { children: React.ReactNode }) {\n  const { authEnabled, loading, user, isAdmin } = useAuth();\n\n  if (loading) {\n    return <div className=\"min-h-screen flex items-center justify-center\">Loading...</div>;\n  }\n\n  // If auth is not enabled, allow access (backward compatibility)\n  if (!authEnabled) {\n    return <>{children}</>;\n  }\n\n  // If auth is enabled but no user, redirect to login\n  if (!user) {\n    return <Navigate to=\"/login\" replace />;\n  }\n\n  // If user is not admin, redirect to home\n  if (!isAdmin) {\n    return <Navigate to=\"/\" replace />;\n  }\n\n  return <>{children}</>;\n}\n\nfunction SetupRoute({ children }: { children: React.ReactNode }) {\n  const { authEnabled, loading } = useAuth();\n\n  if (loading) {\n    return <div className=\"min-h-screen flex items-center justify-center\">Loading...</div>;\n  }\n\n  // If auth is already enabled, redirect to login\n  // Otherwise, allow access to setup page (even if setup was completed before)\n  // This allows users to enable auth later if they skipped it during initial setup\n  if (authEnabled) {\n    return <Navigate to=\"/login\" replace />;\n  }\n\n  return <>{children}</>;\n}\n\nfunction App() {\n  return (\n    <ErrorBoundary>\n    <ThemeProvider>\n      <ToastProvider>\n        <QueryClientProvider client={queryClient}>\n          <AuthProvider>\n            <ColorCatalogProvider>\n            <StreamTokenSync />\n            <BrowserRouter>\n              <Routes>\n                {/* Setup page - only accessible if auth not enabled */}\n                <Route path=\"/setup\" element={<SetupRoute><SetupPage /></SetupRoute>} />\n\n                {/* Login page */}\n                <Route path=\"/login\" element={<LoginPage />} />\n\n                {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}\n                <Route path=\"/camera/:printerId\" element={<CameraPage />} />\n\n                {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}\n                <Route path=\"/overlay/:printerId\" element={<StreamOverlayPage />} />\n\n                {/* SpoolBuddy kiosk UI */}\n                <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>\n                  <Route path=\"spoolbuddy\" element={<SpoolBuddyDashboard />} />\n                  <Route path=\"spoolbuddy/ams\" element={<SpoolBuddyAmsPage />} />\n                  <Route path=\"spoolbuddy/write-tag\" element={<SpoolBuddyWriteTagPage />} />\n                  <Route path=\"spoolbuddy/inventory\" element={<SpoolBuddyInventoryPage />} />\n                  <Route path=\"spoolbuddy/settings\" element={<SpoolBuddySettingsPage />} />\n                  <Route path=\"spoolbuddy/calibration\" element={<SpoolBuddyCalibrationPage />} />\n                </Route>\n\n                {/* Main app with WebSocket for real-time updates */}\n                <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>\n                  <Route index element={<PrintersPage />} />\n                  <Route path=\"archives\" element={<ArchivesPage />} />\n                  <Route path=\"queue\" element={<QueuePage />} />\n                  <Route path=\"stats\" element={<StatsPage />} />\n                  <Route path=\"profiles\" element={<ProfilesPage />} />\n                  <Route path=\"maintenance\" element={<MaintenancePage />} />\n                  <Route path=\"projects\" element={<ProjectsPage />} />\n                  <Route path=\"projects/:id\" element={<ProjectDetailPage />} />\n                  <Route path=\"inventory\" element={<InventoryPage />} />\n                  <Route path=\"files\" element={<FileManagerPage />} />\n                  <Route path=\"settings\" element={<AdminRoute><SettingsPage /></AdminRoute>} />\n                  <Route path=\"groups/new\" element={<AdminRoute><GroupEditPage /></AdminRoute>} />\n                  <Route path=\"groups/:id/edit\" element={<AdminRoute><GroupEditPage /></AdminRoute>} />\n                  <Route path=\"users\" element={<Navigate to=\"/settings?tab=users\" replace />} />\n                  <Route path=\"groups\" element={<Navigate to=\"/settings?tab=users\" replace />} />\n                  <Route path=\"system\" element={<SystemInfoPage />} />\n                  <Route path=\"notifications\" element={<NotificationsPage />} />\n                  <Route path=\"external/:id\" element={<ExternalLinkPage />} />\n                </Route>\n              </Routes>\n            </BrowserRouter>\n            </ColorCatalogProvider>\n          </AuthProvider>\n        </QueryClientProvider>\n      </ToastProvider>\n    </ThemeProvider>\n    </ErrorBoundary>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "frontend/src/__tests__/api/client.test.ts",
    "content": "/**\n * Tests for the API client auth token handling.\n */\n\nimport { describe, it, expect, afterEach, vi } from 'vitest';\nimport { http, HttpResponse } from 'msw';\nimport { setupServer } from 'msw/node';\nimport { setAuthToken, getAuthToken, api } from '../../api/client';\n\n// Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage)\nconst sessionStorageMock = {\n  store: {} as Record<string, string>,\n  getItem: vi.fn((key: string) => sessionStorageMock.store[key] || null),\n  setItem: vi.fn((key: string, value: string) => {\n    sessionStorageMock.store[key] = value;\n  }),\n  removeItem: vi.fn((key: string) => {\n    delete sessionStorageMock.store[key];\n  }),\n  clear: vi.fn(() => {\n    sessionStorageMock.store = {};\n  }),\n};\n\nObject.defineProperty(window, 'sessionStorage', {\n  value: sessionStorageMock,\n});\n\n// Create MSW server\nconst server = setupServer();\n\nbeforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));\nafterEach(() => {\n  server.resetHandlers();\n  sessionStorageMock.clear();\n  setAuthToken(null);\n});\nafterAll(() => server.close());\n\ndescribe('Auth Token Management', () => {\n  it('setAuthToken stores token in sessionStorage', () => {\n    setAuthToken('test-token-123');\n    expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');\n    expect(getAuthToken()).toBe('test-token-123');\n  });\n\n  it('setAuthToken removes token from sessionStorage when null', () => {\n    setAuthToken('test-token-123');\n    setAuthToken(null);\n    expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');\n    expect(getAuthToken()).toBeNull();\n  });\n});\n\ndescribe('API Client Auth Header', () => {\n  it('includes Authorization header when token is set', async () => {\n    let capturedHeaders: Headers | null = null;\n\n    server.use(\n      http.get('/api/v1/settings/spoolman', ({ request }) => {\n        capturedHeaders = request.headers;\n        return HttpResponse.json({\n          spoolman_enabled: 'false',\n          spoolman_url: '',\n          spoolman_sync_mode: 'auto',\n        });\n      })\n    );\n\n    setAuthToken('test-jwt-token');\n    await api.getSpoolmanSettings();\n\n    expect(capturedHeaders).not.toBeNull();\n    expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-jwt-token');\n  });\n\n  it('does not include Authorization header when token is not set', async () => {\n    let capturedHeaders: Headers | null = null;\n\n    server.use(\n      http.get('/api/v1/settings/spoolman', ({ request }) => {\n        capturedHeaders = request.headers;\n        return HttpResponse.json({\n          spoolman_enabled: 'false',\n          spoolman_url: '',\n          spoolman_sync_mode: 'auto',\n        });\n      })\n    );\n\n    setAuthToken(null);\n    await api.getSpoolmanSettings();\n\n    expect(capturedHeaders).not.toBeNull();\n    expect(capturedHeaders!.get('Authorization')).toBeNull();\n  });\n\n  it('clears token on 401 with invalid token message', async () => {\n    server.use(\n      http.get('/api/v1/settings/spoolman', () => {\n        return HttpResponse.json(\n          { detail: 'Could not validate credentials' },\n          { status: 401 }\n        );\n      })\n    );\n\n    setAuthToken('expired-token');\n    expect(getAuthToken()).toBe('expired-token');\n\n    try {\n      await api.getSpoolmanSettings();\n    } catch {\n      // Expected to throw\n    }\n\n    expect(getAuthToken()).toBeNull();\n    expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');\n  });\n\n  it('does not clear token on 401 with generic auth error', async () => {\n    server.use(\n      http.get('/api/v1/settings/spoolman', () => {\n        return HttpResponse.json(\n          { detail: 'Authentication required' },\n          { status: 401 }\n        );\n      })\n    );\n\n    setAuthToken('valid-token');\n    expect(getAuthToken()).toBe('valid-token');\n\n    try {\n      await api.getSpoolmanSettings();\n    } catch {\n      // Expected to throw\n    }\n\n    // Token should NOT be cleared for generic auth errors (might be timing issue)\n    expect(getAuthToken()).toBe('valid-token');\n  });\n});\n\ndescribe('FormData requests include auth header', () => {\n  it('importProjectFile includes Authorization header', async () => {\n    // Mock fetch directly for FormData requests (MSW can be flaky with multipart in some environments)\n    const originalFetch = global.fetch;\n    let capturedHeaders: Headers | null = null;\n\n    global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {\n      if (url.includes('/projects/import/file')) {\n        capturedHeaders = new Headers(init?.headers);\n        return Promise.resolve(new Response(JSON.stringify({\n          id: 1,\n          name: 'Test Project',\n          description: '',\n          total_cost: 0,\n          total_print_time_seconds: 0,\n          total_prints: 0,\n          total_quantity: 0,\n          status: 'active',\n          due_date: null,\n          created_at: '2026-01-01T00:00:00Z',\n          updated_at: '2026-01-01T00:00:00Z',\n          archives: [],\n          bom_items: [],\n        }), { status: 200 }));\n      }\n      return originalFetch(url, init);\n    });\n\n    try {\n      setAuthToken('test-token');\n      const file = new File(['test content'], 'test.zip', { type: 'application/zip' });\n      await api.importProjectFile(file);\n\n      expect(capturedHeaders).not.toBeNull();\n      expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');\n    } finally {\n      global.fetch = originalFetch;\n    }\n  });\n\n  it('exportProjectZip includes Authorization header', async () => {\n    let capturedHeaders: Headers | null = null;\n\n    server.use(\n      http.get('/api/v1/projects/:projectId/export', ({ request }) => {\n        capturedHeaders = request.headers;\n        const zipContent = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic bytes\n        return new HttpResponse(zipContent, {\n          status: 200,\n          headers: {\n            'Content-Type': 'application/zip',\n            'Content-Disposition': 'attachment; filename=\"project.zip\"',\n          },\n        });\n      })\n    );\n\n    setAuthToken('test-token');\n    await api.exportProjectZip(1);\n\n    expect(capturedHeaders).not.toBeNull();\n    expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');\n  });\n});\n\ndescribe('Printer control endpoints', () => {\n  it('refreshPrinterStatus POSTs to /printers/:id/refresh-status', async () => {\n    let calledUrl: string | null = null;\n    let calledMethod: string | null = null;\n    server.use(\n      http.post('/api/v1/printers/:id/refresh-status', ({ request, params }) => {\n        calledUrl = `/printers/${params.id}/refresh-status`;\n        calledMethod = request.method;\n        return HttpResponse.json({ status: 'ok' });\n      }),\n    );\n\n    const result = await api.refreshPrinterStatus(7);\n    expect(calledMethod).toBe('POST');\n    expect(calledUrl).toBe('/printers/7/refresh-status');\n    expect(result).toEqual({ status: 'ok' });\n  });\n\n  it('setAirductMode passes mode in query string', async () => {\n    let capturedUrl = '';\n    server.use(\n      http.post('/api/v1/printers/:id/airduct-mode', ({ request }) => {\n        capturedUrl = request.url;\n        return HttpResponse.json({ success: true, message: 'ok' });\n      }),\n    );\n\n    await api.setAirductMode(3, 'cooling');\n    expect(capturedUrl).toContain('mode=cooling');\n\n    await api.setAirductMode(3, 'heating');\n    expect(capturedUrl).toContain('mode=heating');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/api/githubBackupApi.test.ts",
    "content": "/**\n * Tests for the GitHub Backup API client functions.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { http, HttpResponse } from 'msw';\nimport { setupServer } from 'msw/node';\nimport type {\n  GitHubBackupConfig,\n  GitHubBackupStatus,\n  GitHubBackupLog,\n} from '../../api/client';\n\n// Mock API base URL\nconst API_BASE = 'http://localhost:5000/api/v1';\n\n// Create MSW server\nconst server = setupServer();\n\nbeforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));\nafterEach(() => server.resetHandlers());\nafterAll(() => server.close());\n\ndescribe('GitHub Backup API Types', () => {\n  it('GitHubBackupConfig has correct shape', () => {\n    const config: GitHubBackupConfig = {\n      id: 1,\n      repository_url: 'https://github.com/test/repo',\n      has_token: true,\n      branch: 'main',\n      schedule_enabled: true,\n      schedule_type: 'daily',\n      backup_kprofiles: true,\n      backup_cloud_profiles: true,\n      backup_settings: false,\n      backup_spools: false,\n      backup_archives: false,\n      enabled: true,\n      last_backup_at: '2026-01-27T10:00:00Z',\n      last_backup_status: 'success',\n      last_backup_message: null,\n      last_backup_commit_sha: 'abc123',\n      next_scheduled_run: '2026-01-28T00:00:00Z',\n      created_at: '2026-01-01T00:00:00Z',\n      updated_at: '2026-01-27T10:00:00Z',\n    };\n\n    expect(config.id).toBe(1);\n    expect(config.has_token).toBe(true);\n    expect(config.schedule_type).toBe('daily');\n    expect(config.backup_spools).toBe(false);\n    expect(config.backup_archives).toBe(false);\n  });\n\n  it('GitHubBackupStatus has correct shape', () => {\n    const status: GitHubBackupStatus = {\n      configured: true,\n      enabled: true,\n      is_running: false,\n      progress: null,\n      last_backup_at: '2026-01-27T10:00:00Z',\n      last_backup_status: 'success',\n      next_scheduled_run: '2026-01-28T00:00:00Z',\n    };\n\n    expect(status.configured).toBe(true);\n    expect(status.is_running).toBe(false);\n  });\n\n  it('GitHubBackupStatus can have progress', () => {\n    const status: GitHubBackupStatus = {\n      configured: true,\n      enabled: true,\n      is_running: true,\n      progress: 'Pushing to GitHub...',\n      last_backup_at: null,\n      last_backup_status: null,\n      next_scheduled_run: null,\n    };\n\n    expect(status.is_running).toBe(true);\n    expect(status.progress).toBe('Pushing to GitHub...');\n  });\n\n  it('GitHubBackupLog has correct shape', () => {\n    const log: GitHubBackupLog = {\n      id: 1,\n      config_id: 1,\n      started_at: '2026-01-27T10:00:00Z',\n      completed_at: '2026-01-27T10:01:00Z',\n      status: 'success',\n      trigger: 'manual',\n      commit_sha: 'abc123',\n      files_changed: 5,\n      error_message: null,\n    };\n\n    expect(log.status).toBe('success');\n    expect(log.trigger).toBe('manual');\n    expect(log.files_changed).toBe(5);\n  });\n\n  it('GitHubBackupConfig supports spool and archive backup toggles', () => {\n    const config: GitHubBackupConfig = {\n      id: 2,\n      repository_url: 'https://github.com/test/full-backup',\n      has_token: true,\n      branch: 'main',\n      schedule_enabled: false,\n      schedule_type: 'daily',\n      backup_kprofiles: true,\n      backup_cloud_profiles: false,\n      backup_settings: false,\n      backup_spools: true,\n      backup_archives: true,\n      enabled: true,\n      last_backup_at: null,\n      last_backup_status: null,\n      last_backup_message: null,\n      last_backup_commit_sha: null,\n      next_scheduled_run: null,\n      created_at: '2026-04-01T00:00:00Z',\n      updated_at: '2026-04-01T00:00:00Z',\n    };\n\n    expect(config.backup_spools).toBe(true);\n    expect(config.backup_archives).toBe(true);\n    expect(config.backup_cloud_profiles).toBe(false);\n  });\n\n  it('GitHubBackupLog can have error', () => {\n    const log: GitHubBackupLog = {\n      id: 2,\n      config_id: 1,\n      started_at: '2026-01-27T10:00:00Z',\n      completed_at: '2026-01-27T10:00:30Z',\n      status: 'failed',\n      trigger: 'scheduled',\n      commit_sha: null,\n      files_changed: 0,\n      error_message: 'Authentication failed',\n    };\n\n    expect(log.status).toBe('failed');\n    expect(log.error_message).toBe('Authentication failed');\n    expect(log.commit_sha).toBeNull();\n  });\n});\n\ndescribe('GitHub Backup API Endpoints', () => {\n  it('GET /github-backup/config returns null when not configured', async () => {\n    server.use(\n      http.get(`${API_BASE}/github-backup/config`, () => {\n        return HttpResponse.json(null);\n      })\n    );\n\n    const response = await fetch(`${API_BASE}/github-backup/config`);\n    const data = await response.json();\n    expect(data).toBeNull();\n  });\n\n  it('GET /github-backup/config returns config when exists', async () => {\n    const mockConfig: GitHubBackupConfig = {\n      id: 1,\n      repository_url: 'https://github.com/test/repo',\n      has_token: true,\n      branch: 'main',\n      schedule_enabled: false,\n      schedule_type: 'daily',\n      backup_kprofiles: true,\n      backup_cloud_profiles: true,\n      backup_settings: false,\n      backup_spools: true,\n      backup_archives: true,\n      enabled: true,\n      last_backup_at: null,\n      last_backup_status: null,\n      last_backup_message: null,\n      last_backup_commit_sha: null,\n      next_scheduled_run: null,\n      created_at: '2026-01-01T00:00:00Z',\n      updated_at: '2026-01-01T00:00:00Z',\n    };\n\n    server.use(\n      http.get(`${API_BASE}/github-backup/config`, () => {\n        return HttpResponse.json(mockConfig);\n      })\n    );\n\n    const response = await fetch(`${API_BASE}/github-backup/config`);\n    const data = await response.json();\n    expect(data.repository_url).toBe('https://github.com/test/repo');\n    expect(data.has_token).toBe(true);\n  });\n\n  it('GET /github-backup/status returns not configured status', async () => {\n    const mockStatus: GitHubBackupStatus = {\n      configured: false,\n      enabled: false,\n      is_running: false,\n      progress: null,\n      last_backup_at: null,\n      last_backup_status: null,\n      next_scheduled_run: null,\n    };\n\n    server.use(\n      http.get(`${API_BASE}/github-backup/status`, () => {\n        return HttpResponse.json(mockStatus);\n      })\n    );\n\n    const response = await fetch(`${API_BASE}/github-backup/status`);\n    const data = await response.json();\n    expect(data.configured).toBe(false);\n    expect(data.enabled).toBe(false);\n  });\n\n  it('GET /github-backup/logs returns empty list when no logs', async () => {\n    server.use(\n      http.get(`${API_BASE}/github-backup/logs`, () => {\n        return HttpResponse.json([]);\n      })\n    );\n\n    const response = await fetch(`${API_BASE}/github-backup/logs`);\n    const data = await response.json();\n    expect(data).toEqual([]);\n  });\n\n  it('GET /github-backup/logs returns log entries', async () => {\n    const mockLogs: GitHubBackupLog[] = [\n      {\n        id: 1,\n        config_id: 1,\n        started_at: '2026-01-27T10:00:00Z',\n        completed_at: '2026-01-27T10:01:00Z',\n        status: 'success',\n        trigger: 'manual',\n        commit_sha: 'abc123',\n        files_changed: 5,\n        error_message: null,\n      },\n    ];\n\n    server.use(\n      http.get(`${API_BASE}/github-backup/logs`, () => {\n        return HttpResponse.json(mockLogs);\n      })\n    );\n\n    const response = await fetch(`${API_BASE}/github-backup/logs`);\n    const data = await response.json();\n    expect(data.length).toBe(1);\n    expect(data[0].status).toBe('success');\n  });\n\n  it('POST /github-backup/run returns 404 when not configured', async () => {\n    server.use(\n      http.post(`${API_BASE}/github-backup/run`, () => {\n        return HttpResponse.json(\n          { detail: 'No configuration found' },\n          { status: 404 }\n        );\n      })\n    );\n\n    const response = await fetch(`${API_BASE}/github-backup/run`, {\n      method: 'POST',\n    });\n    expect(response.status).toBe(404);\n  });\n\n  it('POST /github-backup/test returns success on valid credentials', async () => {\n    server.use(\n      http.post(`${API_BASE}/github-backup/test`, () => {\n        return HttpResponse.json({\n          success: true,\n          message: 'Connection successful',\n          repo_name: 'test/repo',\n          default_branch: 'main',\n        });\n      })\n    );\n\n    const response = await fetch(\n      `${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=ghp_test`,\n      { method: 'POST' }\n    );\n    const data = await response.json();\n    expect(data.success).toBe(true);\n    expect(data.repo_name).toBe('test/repo');\n  });\n\n  it('POST /github-backup/test returns failure on invalid credentials', async () => {\n    server.use(\n      http.post(`${API_BASE}/github-backup/test`, () => {\n        return HttpResponse.json({\n          success: false,\n          message: 'Authentication failed',\n          repo_name: null,\n          default_branch: null,\n        });\n      })\n    );\n\n    const response = await fetch(\n      `${API_BASE}/github-backup/test?repo_url=https://github.com/test/repo&token=invalid`,\n      { method: 'POST' }\n    );\n    const data = await response.json();\n    expect(data.success).toBe(false);\n    expect(data.message).toBe('Authentication failed');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/AMSHistoryModal.test.tsx",
    "content": "/**\n * Tests for the AMSHistoryModal component.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport { render } from '../utils';\nimport { AMSHistoryModal } from '../../components/AMSHistoryModal';\nimport { api } from '../../api/client';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  api: {\n    getAMSHistory: vi.fn(),\n    getSettings: vi.fn().mockResolvedValue({}),\n    updateSettings: vi.fn().mockResolvedValue({}),\n  },\n}));\n\n// Mock recharts to avoid rendering issues in tests\nvi.mock('recharts', () => ({\n  LineChart: ({ children }: { children: React.ReactNode }) => <div data-testid=\"line-chart\">{children}</div>,\n  Line: () => null,\n  XAxis: () => null,\n  YAxis: () => null,\n  CartesianGrid: () => null,\n  Tooltip: () => null,\n  ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n  Legend: () => null,\n  ReferenceLine: () => null,\n}));\n\nconst mockHistoryData = {\n  data: [\n    { recorded_at: '2024-12-11T10:00:00Z', humidity: 45, temperature: 28 },\n    { recorded_at: '2024-12-11T10:05:00Z', humidity: 46, temperature: 27 },\n    { recorded_at: '2024-12-11T10:10:00Z', humidity: 44, temperature: 29 },\n    { recorded_at: '2024-12-11T10:15:00Z', humidity: 47, temperature: 28 },\n    { recorded_at: '2024-12-11T10:20:00Z', humidity: 48, temperature: 30 },\n  ],\n  avg_humidity: 46,\n  min_humidity: 44,\n  max_humidity: 48,\n  avg_temperature: 28.4,\n  min_temperature: 27,\n  max_temperature: 30,\n};\n\nconst defaultProps = {\n  isOpen: true,\n  onClose: vi.fn(),\n  printerId: 1,\n  printerName: 'Test Printer',\n  amsId: 0,\n  amsLabel: 'AMS-A',\n  initialMode: 'humidity' as const,\n  thresholds: {\n    humidityGood: 40,\n    humidityFair: 60,\n    tempGood: 30,\n    tempFair: 35,\n  },\n};\n\ndescribe('AMSHistoryModal', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    (api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue(mockHistoryData);\n  });\n\n  it('renders nothing visible when closed', () => {\n    render(<AMSHistoryModal {...defaultProps} isOpen={false} />);\n\n    // The modal content should not be visible when closed\n    expect(screen.queryByText('AMS-A History')).not.toBeInTheDocument();\n  });\n\n  it('renders modal when open', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('AMS-A History')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('Test Printer')).toBeInTheDocument();\n  });\n\n  it('displays humidity mode by default', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Humidity')).toBeInTheDocument();\n    });\n\n    // Should show humidity stats - the Average value\n    await waitFor(() => {\n      expect(screen.getByText('Average')).toBeInTheDocument();\n    });\n  });\n\n  it('displays temperature mode when initialMode is temperature', async () => {\n    render(<AMSHistoryModal {...defaultProps} initialMode=\"temperature\" />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Temperature')).toBeInTheDocument();\n    });\n  });\n\n  it('shows time range buttons', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('6h')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('24h')).toBeInTheDocument();\n    expect(screen.getByText('48h')).toBeInTheDocument();\n    expect(screen.getByText('7d')).toBeInTheDocument();\n  });\n\n  it('switches between humidity and temperature modes', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Humidity')).toBeInTheDocument();\n    });\n\n    // Click temperature button\n    const tempButton = screen.getByText('Temperature');\n    fireEvent.click(tempButton);\n\n    // Should now show temperature mode is active (button styling changes)\n    await waitFor(() => {\n      // Temperature stats should be visible - checking the labels\n      expect(screen.getByText('Min')).toBeInTheDocument();\n      expect(screen.getByText('Max')).toBeInTheDocument();\n    });\n  });\n\n  it('displays statistics cards', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Current')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('Average')).toBeInTheDocument();\n    expect(screen.getByText('Min')).toBeInTheDocument();\n    expect(screen.getByText('Max')).toBeInTheDocument();\n  });\n\n  it('displays min/max humidity values', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      // Min humidity - may appear multiple times\n      const minValues = screen.getAllByText('44%');\n      expect(minValues.length).toBeGreaterThanOrEqual(1);\n    });\n\n    // Max humidity - may appear multiple times (in current and max cards)\n    const maxValues = screen.getAllByText('48%');\n    expect(maxValues.length).toBeGreaterThanOrEqual(1);\n  });\n\n  it('displays min/max temperature values in temperature mode', async () => {\n    render(<AMSHistoryModal {...defaultProps} initialMode=\"temperature\" />);\n\n    await waitFor(() => {\n      // Min temp appears in the Min card\n      const minCards = screen.getAllByText('27°C');\n      expect(minCards.length).toBeGreaterThanOrEqual(1);\n    });\n\n    // Max temp appears in the Max card (may appear multiple times in different contexts)\n    const maxCards = screen.getAllByText('30°C');\n    expect(maxCards.length).toBeGreaterThanOrEqual(1);\n  });\n\n  it('calls onClose when close button clicked', async () => {\n    const onClose = vi.fn();\n    render(<AMSHistoryModal {...defaultProps} onClose={onClose} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('AMS-A History')).toBeInTheDocument();\n    });\n\n    // Find and click close button (X icon)\n    const closeButton = document.querySelector('button');\n    if (closeButton) {\n      fireEvent.click(closeButton);\n    }\n  });\n\n  it('calls onClose when clicking backdrop', async () => {\n    const onClose = vi.fn();\n    render(<AMSHistoryModal {...defaultProps} onClose={onClose} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('AMS-A History')).toBeInTheDocument();\n    });\n\n    // Click on backdrop (the fixed overlay)\n    const backdrop = document.querySelector('.fixed.inset-0');\n    if (backdrop) {\n      fireEvent.click(backdrop);\n      expect(onClose).toHaveBeenCalled();\n    }\n  });\n\n  it('does not close when clicking modal content', async () => {\n    const onClose = vi.fn();\n    render(<AMSHistoryModal {...defaultProps} onClose={onClose} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('AMS-A History')).toBeInTheDocument();\n    });\n\n    // Click on modal content (should not close)\n    const modalContent = document.querySelector('.rounded-xl');\n    if (modalContent) {\n      fireEvent.click(modalContent);\n      expect(onClose).not.toHaveBeenCalled();\n    }\n  });\n\n  it('shows loading state', async () => {\n    // Make API call never resolve\n    (api.getAMSHistory as ReturnType<typeof vi.fn>).mockImplementation(\n      () => new Promise(() => {})\n    );\n\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Loading...')).toBeInTheDocument();\n    });\n  });\n\n  it('shows error state on API failure', async () => {\n    (api.getAMSHistory as ReturnType<typeof vi.fn>).mockRejectedValue(\n      new Error('Network error')\n    );\n\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Error')).toBeInTheDocument();\n    });\n  });\n\n  it('shows no data message when empty', async () => {\n    (api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue({\n      data: [],\n      avg_humidity: null,\n      min_humidity: null,\n      max_humidity: null,\n      avg_temperature: null,\n      min_temperature: null,\n      max_temperature: null,\n    });\n\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('No data available')).toBeInTheDocument();\n    });\n  });\n\n  it('changes time range when clicking different range buttons', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('6h')).toBeInTheDocument();\n    });\n\n    // Click 7d button\n    fireEvent.click(screen.getByText('7d'));\n\n    // API should be called with 168 hours (7 days)\n    await waitFor(() => {\n      expect(api.getAMSHistory).toHaveBeenCalledWith(1, 0, 168);\n    });\n  });\n\n  it('displays recording info text', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/data is recorded every 5 minutes/i)).toBeInTheDocument();\n    });\n  });\n\n  it('displays current value with correct color based on threshold', async () => {\n    // Test with humidity value above fair threshold\n    const highHumidityData = {\n      ...mockHistoryData,\n      data: [\n        ...mockHistoryData.data,\n        { recorded_at: '2024-12-11T10:25:00Z', humidity: 75, temperature: 28 },\n      ],\n    };\n\n    (api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue(highHumidityData);\n\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      // The current value (75%) should be displayed\n      expect(screen.getByText('75%')).toBeInTheDocument();\n    });\n  });\n\n  it('renders chart component', async () => {\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByTestId('line-chart')).toBeInTheDocument();\n    });\n  });\n});\n\ndescribe('AMSHistoryModal trend calculation', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('shows stable trend when values are similar', async () => {\n    const stableData = {\n      data: Array.from({ length: 20 }, (_, i) => ({\n        recorded_at: new Date(Date.now() - i * 5 * 60 * 1000).toISOString(),\n        humidity: 45, // Same value\n        temperature: 28,\n      })),\n      avg_humidity: 45,\n      min_humidity: 45,\n      max_humidity: 45,\n      avg_temperature: 28,\n      min_temperature: 28,\n      max_temperature: 28,\n    };\n\n    (api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue(stableData);\n\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Current')).toBeInTheDocument();\n    });\n\n    // Should show stable trend icon (horizontal line)\n    // The Minus icon indicates stable trend\n  });\n\n  it('shows upward trend when values increase', async () => {\n    const increasingData = {\n      data: Array.from({ length: 20 }, (_, i) => ({\n        recorded_at: new Date(Date.now() - (20 - i) * 5 * 60 * 1000).toISOString(),\n        humidity: 30 + i * 2, // Increasing values\n        temperature: 28,\n      })),\n      avg_humidity: 50,\n      min_humidity: 30,\n      max_humidity: 68,\n      avg_temperature: 28,\n      min_temperature: 28,\n      max_temperature: 28,\n    };\n\n    (api.getAMSHistory as ReturnType<typeof vi.fn>).mockResolvedValue(increasingData);\n\n    render(<AMSHistoryModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Current')).toBeInTheDocument();\n    });\n\n    // Should show upward trend icon (TrendingUp)\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx",
    "content": "/**\n * Tests for AddPrinterModal discovery subnet auto-detection.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { PrintersPage } from '../../pages/PrintersPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockPrinters = [\n  {\n    id: 1,\n    name: 'X1 Carbon',\n    ip_address: '192.168.1.100',\n    serial_number: '00M09A350100001',\n    access_code: '12345678',\n    model: 'X1C',\n    enabled: true,\n    nozzle_diameter: 0.4,\n    nozzle_type: 'hardened_steel',\n    location: null,\n    auto_archive: true,\n    created_at: '2024-01-01T00:00:00Z',\n    updated_at: '2024-01-01T00:00:00Z',\n  },\n];\n\nconst mockPrinterStatus = {\n  connected: true,\n  state: 'IDLE',\n  progress: 0,\n  layer_num: 0,\n  total_layers: 0,\n  temperatures: { nozzle: 25, bed: 25, chamber: 25 },\n  remaining_time: 0,\n  filename: null,\n  wifi_signal: -50,\n  vt_tray: [],\n};\n\ndescribe('AddPrinterModal Discovery', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json(mockPrinters);\n      }),\n      http.get('/api/v1/printers/:id/status', () => {\n        return HttpResponse.json(mockPrinterStatus);\n      }),\n      http.get('/api/v1/queue/', () => {\n        return HttpResponse.json([]);\n      })\n    );\n  });\n\n  it('auto-populates subnet from discovery info in Docker mode', async () => {\n    server.use(\n      http.get('/api/v1/discovery/info', () => {\n        return HttpResponse.json({\n          is_docker: true,\n          ssdp_running: false,\n          scan_running: false,\n          subnets: ['10.0.0.0/24'],\n        });\n      })\n    );\n\n    render(<PrintersPage />);\n\n    // Wait for printer page to load\n    await waitFor(() => {\n      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n    });\n\n    // Click the Add Printer button\n    const addButton = screen.getByText(/add printer/i);\n    await userEvent.click(addButton);\n\n    // Wait for the modal and discovery info to load\n    await waitFor(() => {\n      // Should show subnet dropdown with detected subnet\n      const subnetSelect = screen.getByDisplayValue('10.0.0.0/24');\n      expect(subnetSelect).toBeInTheDocument();\n    });\n  });\n\n  it('shows dropdown when multiple subnets detected in Docker mode', async () => {\n    server.use(\n      http.get('/api/v1/discovery/info', () => {\n        return HttpResponse.json({\n          is_docker: true,\n          ssdp_running: false,\n          scan_running: false,\n          subnets: ['192.168.1.0/24', '10.0.0.0/24'],\n        });\n      })\n    );\n\n    render(<PrintersPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n    });\n\n    const addButton = screen.getByText(/add printer/i);\n    await userEvent.click(addButton);\n\n    await waitFor(() => {\n      // Should show a select element (dropdown) with both subnets\n      const selectElement = screen.getByDisplayValue('192.168.1.0/24');\n      expect(selectElement.tagName).toBe('SELECT');\n\n      // Both options should be available\n      const options = selectElement.querySelectorAll('option');\n      expect(options).toHaveLength(2);\n      expect(options[0].textContent).toBe('192.168.1.0/24');\n      expect(options[1].textContent).toBe('10.0.0.0/24');\n    });\n  });\n\n  it('shows text input when no subnets detected in Docker mode', async () => {\n    server.use(\n      http.get('/api/v1/discovery/info', () => {\n        return HttpResponse.json({\n          is_docker: true,\n          ssdp_running: false,\n          scan_running: false,\n          subnets: [],\n        });\n      })\n    );\n\n    render(<PrintersPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n    });\n\n    const addButton = screen.getByText(/add printer/i);\n    await userEvent.click(addButton);\n\n    await waitFor(() => {\n      // Should show a text input with placeholder\n      const textInput = screen.getByPlaceholderText('192.168.1.0/24');\n      expect(textInput).toBeInTheDocument();\n      expect(textInput.tagName).toBe('INPUT');\n    });\n  });\n\n  it('does not show subnet field in non-Docker mode', async () => {\n    server.use(\n      http.get('/api/v1/discovery/info', () => {\n        return HttpResponse.json({\n          is_docker: false,\n          ssdp_running: false,\n          scan_running: false,\n          subnets: ['192.168.1.0/24'],\n        });\n      })\n    );\n\n    render(<PrintersPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n    });\n\n    const addButton = screen.getByText(/add printer/i);\n    await userEvent.click(addButton);\n\n    await waitFor(() => {\n      // Should show the discover button but NOT the subnet field\n      expect(screen.getByText(/discover printers/i)).toBeInTheDocument();\n    });\n\n    // Subnet field should not exist\n    expect(screen.queryByPlaceholderText('192.168.1.0/24')).not.toBeInTheDocument();\n    expect(screen.queryByDisplayValue('192.168.1.0/24')).not.toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/AssignSpoolModal.test.tsx",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { AssignSpoolModal } from '../../components/AssignSpoolModal';\nimport { api } from '../../api/client';\n\nvi.mock('../../api/client', () => ({\n  api: {\n    getSpools: vi.fn(),\n    getAssignments: vi.fn(),\n    assignSpool: vi.fn(),\n    getSettings: vi.fn().mockResolvedValue({}),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n  },\n}));\n\nconst defaultProps = {\n  isOpen: true,\n  onClose: vi.fn(),\n  printerId: 1,\n  amsId: 0,\n  trayId: 0,\n  trayInfo: { type: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' },\n};\n\nconst manualSpool = {\n  id: 1,\n  material: 'PLA',\n  subtype: 'Basic',\n  brand: 'Polymaker',\n  color_name: 'Red',\n  rgba: 'FF0000FF',\n  label_weight: 1000,\n  weight_used: 0,\n  tag_uid: null,\n  tray_uuid: null,\n  slicer_filament_name: 'PLA',\n};\n\nconst blSpool = {\n  id: 2,\n  material: 'PLA',\n  subtype: 'Basic',\n  brand: 'Bambu',\n  color_name: 'Jade White',\n  rgba: 'FFFFFFFE',\n  label_weight: 1000,\n  weight_used: 50,\n  tag_uid: '05CC1E0F00000100',\n  tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',\n  slicer_filament_name: 'PLA',\n};\n\nconst anotherManualSpool = {\n  id: 3,\n  material: 'PLA',\n  subtype: 'HF',\n  brand: 'Overture',\n  color_name: 'Black',\n  rgba: '000000FF',\n  label_weight: 1000,\n  weight_used: 200,\n  tag_uid: null,\n  tray_uuid: null,\n  slicer_filament_name: 'PLA',\n};\n\ndescribe('AssignSpoolModal', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, blSpool, anotherManualSpool]);\n    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n  });\n\n  it('renders nothing when closed', () => {\n    render(<AssignSpoolModal {...defaultProps} isOpen={false} />);\n    expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();\n  });\n\n  it('filters out Bambu Lab spools (with tag_uid/tray_uuid)', async () => {\n    render(<AssignSpoolModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();\n    });\n\n    // Manual spools should be visible\n    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();\n    expect(screen.getByText(/Overture/)).toBeInTheDocument();\n\n    // BL spool should NOT be visible\n    expect(screen.queryByText(/Jade White/)).not.toBeInTheDocument();\n  });\n\n  it('filters out spools already assigned to other slots', async () => {\n    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([\n      { id: 1, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 }, // spool 3 assigned to different slot\n    ]);\n\n    render(<AssignSpoolModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();\n    });\n\n    // Spool 1 (not assigned) should be visible\n    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();\n\n    // Spool 3 (assigned to another slot) should NOT be visible\n    expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();\n  });\n\n  it('keeps spool visible if assigned to the current slot', async () => {\n    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([\n      { id: 1, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 0 }, // spool 1 assigned to THIS slot\n    ]);\n\n    render(<AssignSpoolModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();\n    });\n\n    // Spool 1 (assigned to current slot) should still be visible for re-assignment\n    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();\n  });\n\n  it('shows noManualSpools message when all spools are BL or assigned', async () => {\n    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([blSpool]);\n\n    render(<AssignSpoolModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/No manually added spools/i)).toBeInTheDocument();\n    });\n  });\n\n  it('lists spool with no slicer profile when material matches the tray (#1047)', async () => {\n    const spoolWithoutSlicerProfile = {\n      id: 10,\n      material: 'PLA',\n      subtype: 'Basic',\n      brand: 'Devil Design',\n      color_name: 'Red',\n      rgba: 'FF0000FF',\n      label_weight: 1000,\n      weight_used: 0,\n      tag_uid: null,\n      tray_uuid: null,\n      slicer_filament_name: null,\n      slicer_filament: null,\n    };\n    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolWithoutSlicerProfile]);\n\n    render(\n      <AssignSpoolModal\n        {...defaultProps}\n        trayInfo={{\n          type: 'PLA',\n          material: 'PLA',\n          profile: 'Devil Design PLA Basic',\n          color: 'FF0000',\n          location: 'AMS 1 - Slot 1',\n        }}\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText(/Devil Design/)).toBeInTheDocument();\n    });\n  });\n\n  it('lists spool with shorter material when tray advertises a qualified variant (#1047)', async () => {\n    // Spool.material = \"PLA\", tray material = \"PLA Basic\" — partial match in either direction.\n    const shortMaterialSpool = {\n      id: 11,\n      material: 'PLA',\n      subtype: 'Basic',\n      brand: 'Devil Design',\n      color_name: 'Red',\n      rgba: 'FF0000FF',\n      label_weight: 1000,\n      weight_used: 0,\n      tag_uid: null,\n      tray_uuid: null,\n      slicer_filament_name: null,\n      slicer_filament: null,\n    };\n    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([shortMaterialSpool]);\n\n    render(\n      <AssignSpoolModal\n        {...defaultProps}\n        trayInfo={{\n          type: 'PLA Basic',\n          material: 'PLA Basic',\n          color: 'FF0000',\n          location: 'AMS 1 - Slot 1',\n        }}\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText(/Devil Design/)).toBeInTheDocument();\n    });\n  });\n\n  it('lists spool whose slicer profile has an @printer qualifier that strips to the tray profile (#1047)', async () => {\n    const qualifiedProfileSpool = {\n      id: 12,\n      material: 'PLA',\n      subtype: 'Basic',\n      brand: 'Devil Design',\n      color_name: 'Red',\n      rgba: 'FF0000FF',\n      label_weight: 1000,\n      weight_used: 0,\n      tag_uid: null,\n      tray_uuid: null,\n      slicer_filament_name: 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)',\n    };\n    // Use a non-matching material to force the filter to rely on the profile path only.\n    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([qualifiedProfileSpool]);\n\n    render(\n      <AssignSpoolModal\n        {...defaultProps}\n        trayInfo={{\n          type: 'ABS',\n          material: 'ABS',\n          profile: 'Devil Design PLA Basic',\n          color: 'FF0000',\n          location: 'AMS 1 - Slot 1',\n        }}\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText(/Devil Design/)).toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/BackupModal.test.tsx",
    "content": "/**\n * Tests for the BackupModal component.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { BackupModal } from '../../components/BackupModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\ndescribe('BackupModal', () => {\n  const mockOnClose = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.post('/api/v1/settings/backup', () => {\n        return new HttpResponse(\n          JSON.stringify({ success: true }),\n          {\n            headers: {\n              'Content-Type': 'application/json',\n            },\n          }\n        );\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the modal title', () => {\n      render(<BackupModal onClose={mockOnClose} />);\n\n      expect(screen.getByText(/backup/i)).toBeInTheDocument();\n    });\n\n    it('shows backup options', () => {\n      render(<BackupModal onClose={mockOnClose} />);\n\n      expect(screen.getByText(/settings/i)).toBeInTheDocument();\n    });\n\n    it('shows export button', () => {\n      render(<BackupModal onClose={mockOnClose} />);\n\n      expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument();\n    });\n\n    it('shows cancel button', () => {\n      render(<BackupModal onClose={mockOnClose} />);\n\n      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();\n    });\n  });\n\n  describe('backup options', () => {\n    it('has checkbox for printers', () => {\n      render(<BackupModal onClose={mockOnClose} />);\n\n      expect(screen.getByText('Printers')).toBeInTheDocument();\n    });\n\n    it('has checkbox for archives', () => {\n      render(<BackupModal onClose={mockOnClose} />);\n\n      expect(screen.getByText(/archives/i)).toBeInTheDocument();\n    });\n\n    it('has checkbox for projects', () => {\n      render(<BackupModal onClose={mockOnClose} />);\n\n      expect(screen.getByText('Projects')).toBeInTheDocument();\n    });\n  });\n\n  describe('actions', () => {\n    it('calls onClose when cancel is clicked', async () => {\n      const user = userEvent.setup();\n      render(<BackupModal onClose={mockOnClose} />);\n\n      await user.click(screen.getByRole('button', { name: /cancel/i }));\n\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/BugReportBubble.test.tsx",
    "content": "/**\n * Tests for the BugReportBubble component.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { render, screen, waitFor } from '../utils';\nimport userEvent from '@testing-library/user-event';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\nimport { BugReportBubble } from '../../components/BugReportBubble';\n\nfunction getDescriptionTextarea() {\n  return document.querySelector('textarea') as HTMLTextAreaElement;\n}\n\nfunction getSubmitButton() {\n  const buttons = screen.getAllByRole('button');\n  return buttons.find(\n    (b) =>\n      b.className.includes('bg-red-500') &&\n      !b.className.includes('rounded-full') &&\n      b.textContent !== ''\n  );\n}\n\nfunction setupLoggingEndpoints() {\n  server.use(\n    http.post('*/bug-report/start-logging', () => {\n      return HttpResponse.json({ started: true, was_debug: false });\n    }),\n    http.post('*/bug-report/stop-logging', () => {\n      return HttpResponse.json({ logs: 'test debug logs' });\n    })\n  );\n}\n\ndescribe('BugReportBubble', () => {\n  it('renders the floating bug button', () => {\n    render(<BugReportBubble />);\n\n    const button = screen.getByRole('button');\n    expect(button).toBeInTheDocument();\n  });\n\n  it('opens panel when bubble is clicked', async () => {\n    const user = userEvent.setup();\n\n    render(<BugReportBubble />);\n    await user.click(screen.getByRole('button'));\n\n    expect(getDescriptionTextarea()).toBeInTheDocument();\n  });\n\n  it('closes panel when X button is clicked', async () => {\n    const user = userEvent.setup();\n\n    render(<BugReportBubble />);\n\n    // Open\n    await user.click(screen.getByRole('button'));\n    expect(getDescriptionTextarea()).toBeInTheDocument();\n\n    // Close via the X button\n    const buttons = screen.getAllByRole('button');\n    const closeButton = buttons.find((b) => b.querySelector('.lucide-x'));\n    if (closeButton) await user.click(closeButton);\n\n    await waitFor(() => {\n      expect(document.querySelector('textarea')).not.toBeInTheDocument();\n    });\n  });\n\n  it('disables submit when description is empty', async () => {\n    const user = userEvent.setup();\n\n    render(<BugReportBubble />);\n    await user.click(screen.getByRole('button'));\n\n    expect(getSubmitButton()).toBeDisabled();\n  });\n\n  it('enables submit when description is provided', async () => {\n    const user = userEvent.setup();\n\n    render(<BugReportBubble />);\n    await user.click(screen.getByRole('button'));\n\n    await user.type(getDescriptionTextarea(), 'Something is broken');\n\n    expect(getSubmitButton()).not.toBeDisabled();\n  });\n\n  it('shows logging state with step indicators after start', async () => {\n    const user = userEvent.setup();\n    setupLoggingEndpoints();\n\n    render(<BugReportBubble />);\n    await user.click(screen.getByRole('button'));\n\n    await user.type(getDescriptionTextarea(), 'Test bug report');\n\n    const submitBtn = getSubmitButton();\n    if (submitBtn) await user.click(submitBtn);\n\n    // Should show step indicators and elapsed timer\n    await waitFor(() => {\n      const reproduceText = screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i);\n      expect(reproduceText).toBeInTheDocument();\n    });\n\n    // Should show elapsed timer (00:00 format)\n    await waitFor(() => {\n      const timer = screen.queryByText(/00:0/);\n      expect(timer).toBeInTheDocument();\n    });\n  });\n\n  it('shows success state after successful submission', async () => {\n    const user = userEvent.setup();\n\n    setupLoggingEndpoints();\n    server.use(\n      http.post('*/bug-report/submit', () => {\n        return HttpResponse.json({\n          success: true,\n          message: 'Bug report submitted successfully!',\n          issue_url: 'https://github.com/maziggy/bambuddy/issues/42',\n          issue_number: 42,\n        });\n      })\n    );\n\n    render(<BugReportBubble />);\n    await user.click(screen.getByRole('button'));\n\n    await user.type(getDescriptionTextarea(), 'Test bug');\n\n    const submitBtn = getSubmitButton();\n    if (submitBtn) await user.click(submitBtn);\n\n    // Wait for logging state, then click stop\n    await waitFor(() => {\n      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();\n    });\n\n    // Find and click the Stop & Submit button\n    const stopBtn = screen.getAllByRole('button').find(\n      (b) => b.className.includes('bg-red-500') && !b.className.includes('rounded-full')\n    );\n    if (stopBtn) await user.click(stopBtn);\n\n    await waitFor(\n      () => {\n        expect(screen.getByText(/#42/)).toBeInTheDocument();\n      },\n      { timeout: 10000 }\n    );\n  });\n\n  it('shows error state after failed submission', async () => {\n    const user = userEvent.setup();\n\n    setupLoggingEndpoints();\n    server.use(\n      http.post('*/bug-report/submit', () => {\n        return HttpResponse.json({\n          success: false,\n          message: 'Relay not available',\n          issue_url: null,\n          issue_number: null,\n        });\n      })\n    );\n\n    render(<BugReportBubble />);\n    await user.click(screen.getByRole('button'));\n\n    await user.type(getDescriptionTextarea(), 'Test bug');\n\n    const submitBtn = getSubmitButton();\n    if (submitBtn) await user.click(submitBtn);\n\n    // Wait for logging state, then click stop\n    await waitFor(() => {\n      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();\n    });\n\n    const stopBtn = screen.getAllByRole('button').find(\n      (b) => b.className.includes('bg-red-500') && !b.className.includes('rounded-full')\n    );\n    if (stopBtn) await user.click(stopBtn);\n\n    await waitFor(\n      () => {\n        expect(screen.getByText(/Relay not available/)).toBeInTheDocument();\n      },\n      { timeout: 10000 }\n    );\n  });\n\n  it('has expandable data collection notice', async () => {\n    const user = userEvent.setup();\n\n    render(<BugReportBubble />);\n    await user.click(screen.getByRole('button'));\n\n    const details = document.querySelector('details');\n    expect(details).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/Button.test.tsx",
    "content": "/**\n * Tests for the Button component.\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { Button } from '../../components/Button';\n\ndescribe('Button', () => {\n  it('renders children correctly', () => {\n    render(<Button>Click me</Button>);\n\n    expect(screen.getByRole('button')).toHaveTextContent('Click me');\n  });\n\n  it('handles click events', async () => {\n    const user = userEvent.setup();\n    const handleClick = vi.fn();\n\n    render(<Button onClick={handleClick}>Click me</Button>);\n\n    await user.click(screen.getByRole('button'));\n\n    expect(handleClick).toHaveBeenCalledTimes(1);\n  });\n\n  it('can be disabled', async () => {\n    const user = userEvent.setup();\n    const handleClick = vi.fn();\n\n    render(\n      <Button onClick={handleClick} disabled>\n        Click me\n      </Button>\n    );\n\n    const button = screen.getByRole('button');\n    expect(button).toBeDisabled();\n\n    await user.click(button);\n    expect(handleClick).not.toHaveBeenCalled();\n  });\n\n  // Variant tests\n  describe('variants', () => {\n    it('applies primary variant styles by default', () => {\n      render(<Button>Primary</Button>);\n\n      const button = screen.getByRole('button');\n      expect(button.className).toContain('bg-bambu-green');\n    });\n\n    it('applies secondary variant styles', () => {\n      render(<Button variant=\"secondary\">Secondary</Button>);\n\n      const button = screen.getByRole('button');\n      expect(button.className).toContain('bg-bambu-dark-tertiary');\n    });\n\n    it('applies danger variant styles', () => {\n      render(<Button variant=\"danger\">Danger</Button>);\n\n      const button = screen.getByRole('button');\n      expect(button.className).toContain('bg-red-600');\n    });\n\n    it('applies ghost variant styles', () => {\n      render(<Button variant=\"ghost\">Ghost</Button>);\n\n      const button = screen.getByRole('button');\n      expect(button.className).toContain('bg-transparent');\n    });\n  });\n\n  // Size tests\n  describe('sizes', () => {\n    it('applies medium size by default', () => {\n      render(<Button>Medium</Button>);\n\n      const button = screen.getByRole('button');\n      expect(button.className).toContain('px-4');\n      expect(button.className).toContain('py-2');\n    });\n\n    it('applies small size styles', () => {\n      render(<Button size=\"sm\">Small</Button>);\n\n      const button = screen.getByRole('button');\n      expect(button.className).toContain('px-3');\n      expect(button.className).toContain('py-1.5');\n    });\n\n    it('applies large size styles', () => {\n      render(<Button size=\"lg\">Large</Button>);\n\n      const button = screen.getByRole('button');\n      expect(button.className).toContain('px-6');\n      expect(button.className).toContain('py-3');\n    });\n  });\n\n  // Accessibility tests\n  describe('accessibility', () => {\n    it('has correct button role', () => {\n      render(<Button>Accessible</Button>);\n\n      expect(screen.getByRole('button')).toBeInTheDocument();\n    });\n\n    it('supports custom aria-label', () => {\n      render(<Button aria-label=\"Custom label\">Icon</Button>);\n\n      expect(screen.getByRole('button')).toHaveAttribute(\n        'aria-label',\n        'Custom label'\n      );\n    });\n\n    it('supports type attribute', () => {\n      render(<Button type=\"submit\">Submit</Button>);\n\n      expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');\n    });\n  });\n\n  // Custom className test\n  it('merges custom className with default styles', () => {\n    render(<Button className=\"custom-class\">Custom</Button>);\n\n    const button = screen.getByRole('button');\n    expect(button.className).toContain('custom-class');\n    expect(button.className).toContain('bg-bambu-green'); // Still has default styles\n  });\n\n  // Disabled styles test\n  it('applies disabled styles when disabled', () => {\n    render(<Button disabled>Disabled</Button>);\n\n    const button = screen.getByRole('button');\n    expect(button.className).toContain('disabled:opacity-50');\n    expect(button.className).toContain('disabled:cursor-not-allowed');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/Card.test.tsx",
    "content": "/**\n * Tests for the Card components.\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { screen, fireEvent } from '@testing-library/react';\nimport { render } from '../utils';\nimport { Card, CardHeader, CardContent } from '../../components/Card';\n\ndescribe('Card', () => {\n  it('renders children', () => {\n    render(<Card>Test Content</Card>);\n    expect(screen.getByText('Test Content')).toBeInTheDocument();\n  });\n\n  it('applies custom className', () => {\n    const { container } = render(<Card className=\"custom-class\">Content</Card>);\n    expect(container.firstChild).toHaveClass('custom-class');\n  });\n\n  it('applies default styling classes', () => {\n    const { container } = render(<Card>Content</Card>);\n    expect(container.firstChild).toHaveClass('bg-bambu-dark-secondary');\n    expect(container.firstChild).toHaveClass('rounded-xl');\n    expect(container.firstChild).toHaveClass('border');\n  });\n\n  it('handles click events', () => {\n    const handleClick = vi.fn();\n    render(<Card onClick={handleClick}>Clickable</Card>);\n    fireEvent.click(screen.getByText('Clickable'));\n    expect(handleClick).toHaveBeenCalledTimes(1);\n  });\n\n  it('handles context menu events', () => {\n    const handleContextMenu = vi.fn();\n    render(<Card onContextMenu={handleContextMenu}>Right-clickable</Card>);\n    fireEvent.contextMenu(screen.getByText('Right-clickable'));\n    expect(handleContextMenu).toHaveBeenCalledTimes(1);\n  });\n});\n\ndescribe('CardHeader', () => {\n  it('renders children', () => {\n    render(<CardHeader>Header Content</CardHeader>);\n    expect(screen.getByText('Header Content')).toBeInTheDocument();\n  });\n\n  it('applies border styling', () => {\n    const { container } = render(<CardHeader>Header</CardHeader>);\n    expect(container.firstChild).toHaveClass('border-b');\n    expect(container.firstChild).toHaveClass('px-6');\n    expect(container.firstChild).toHaveClass('py-4');\n  });\n\n  it('applies custom className', () => {\n    const { container } = render(\n      <CardHeader className=\"custom-header\">Header</CardHeader>\n    );\n    expect(container.firstChild).toHaveClass('custom-header');\n  });\n});\n\ndescribe('CardContent', () => {\n  it('renders children', () => {\n    render(<CardContent>Body Content</CardContent>);\n    expect(screen.getByText('Body Content')).toBeInTheDocument();\n  });\n\n  it('applies padding styling', () => {\n    const { container } = render(<CardContent>Content</CardContent>);\n    expect(container.firstChild).toHaveClass('p-6');\n  });\n\n  it('applies custom className', () => {\n    const { container } = render(\n      <CardContent className=\"custom-content\">Content</CardContent>\n    );\n    expect(container.firstChild).toHaveClass('custom-content');\n  });\n});\n\ndescribe('Card composition', () => {\n  it('composes Card with Header and Content', () => {\n    render(\n      <Card>\n        <CardHeader>My Header</CardHeader>\n        <CardContent>My Content</CardContent>\n      </Card>\n    );\n\n    expect(screen.getByText('My Header')).toBeInTheDocument();\n    expect(screen.getByText('My Content')).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx",
    "content": "/**\n * Tests for the ConfigureAmsSlotModal component.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, fireEvent, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';\nimport { api } from '../../api/client';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  api: {\n    getCloudSettings: vi.fn(),\n    getKProfiles: vi.fn(),\n    configureAmsSlot: vi.fn(),\n    getCloudSettingDetail: vi.fn(),\n    saveSlotPreset: vi.fn(),\n    getSettings: vi.fn().mockResolvedValue({}),\n    updateSettings: vi.fn().mockResolvedValue({}),\n    getLocalPresets: vi.fn(),\n    getBuiltinFilaments: vi.fn(),\n    searchColors: vi.fn(),\n    getColorCatalog: vi.fn(),\n    resetAmsSlot: vi.fn(),\n  },\n}));\n\nconst mockCloudSettings = {\n  filament: [\n    {\n      setting_id: 'GFSL05_09',\n      name: 'Bambu PLA Basic @BBL X1C',\n      filament_id: 'GFL05',\n    },\n    {\n      setting_id: 'PFUScd84f663d2c2ef',\n      name: '# Overture Matte PLA @BBL H2D',\n      filament_id: null,\n    },\n  ],\n};\n\nconst mockKProfiles = {\n  profiles: [\n    {\n      id: 1,\n      name: 'PLA Basic',\n      k_value: '0.020',\n      filament_id: 'GFL05',\n      setting_id: '',\n      extruder_id: 1,\n      cali_idx: 1,\n    },\n  ],\n};\n\nconst defaultProps = {\n  isOpen: true,\n  onClose: vi.fn(),\n  printerId: 1,\n  slotInfo: {\n    amsId: 0,\n    trayId: 0,\n    trayCount: 4,\n    trayType: 'PLA',\n    trayColor: 'FFFFFF',\n    traySubBrands: 'PLA Basic',\n  },\n  nozzleDiameter: '0.4',\n  onSuccess: vi.fn(),\n};\n\ndescribe('ConfigureAmsSlotModal', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    // Mock scrollIntoView which is not available in jsdom\n    Element.prototype.scrollIntoView = vi.fn();\n    (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);\n    (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue(mockKProfiles);\n    (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });\n    (api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });\n    (api.getLocalPresets as ReturnType<typeof vi.fn>).mockResolvedValue({ filament: [] });\n    (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n    (api.searchColors as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n    (api.getColorCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n    (api.resetAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true, message: 'ok' });\n  });\n\n  it('renders nothing visible when closed', () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} isOpen={false} />);\n    expect(screen.queryByText('Configure AMS Slot')).not.toBeInTheDocument();\n  });\n\n  it('renders modal when open', async () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} />);\n    await waitFor(() => {\n      expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();\n    });\n  });\n\n  it('displays basic color buttons', async () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} />);\n    await waitFor(() => {\n      // Check for basic color buttons by their title attribute\n      expect(screen.getByTitle('White')).toBeInTheDocument();\n      expect(screen.getByTitle('Black')).toBeInTheDocument();\n      expect(screen.getByTitle('Red')).toBeInTheDocument();\n      expect(screen.getByTitle('Blue')).toBeInTheDocument();\n      expect(screen.getByTitle('Green')).toBeInTheDocument();\n      expect(screen.getByTitle('Yellow')).toBeInTheDocument();\n      expect(screen.getByTitle('Orange')).toBeInTheDocument();\n      expect(screen.getByTitle('Gray')).toBeInTheDocument();\n    });\n  });\n\n  it('does not show extended colors by default', async () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} />);\n    await waitFor(() => {\n      expect(screen.getByTitle('White')).toBeInTheDocument();\n    });\n    // Extended colors should not be visible initially\n    expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();\n    expect(screen.queryByTitle('Purple')).not.toBeInTheDocument();\n    expect(screen.queryByTitle('Coral')).not.toBeInTheDocument();\n  });\n\n  it('shows extended colors when expand button is clicked', async () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} />);\n    await waitFor(() => {\n      expect(screen.getByTitle('White')).toBeInTheDocument();\n    });\n\n    // Click the expand button (+ button)\n    const expandButton = screen.getByTitle('Show more colors');\n    fireEvent.click(expandButton);\n\n    // Extended colors should now be visible\n    await waitFor(() => {\n      expect(screen.getByTitle('Cyan')).toBeInTheDocument();\n      expect(screen.getByTitle('Purple')).toBeInTheDocument();\n      expect(screen.getByTitle('Pink')).toBeInTheDocument();\n      expect(screen.getByTitle('Brown')).toBeInTheDocument();\n      expect(screen.getByTitle('Coral')).toBeInTheDocument();\n    });\n  });\n\n  it('hides extended colors when collapse button is clicked', async () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} />);\n    await waitFor(() => {\n      expect(screen.getByTitle('White')).toBeInTheDocument();\n    });\n\n    // Click the expand button\n    const expandButton = screen.getByTitle('Show more colors');\n    fireEvent.click(expandButton);\n\n    // Wait for extended colors to appear\n    await waitFor(() => {\n      expect(screen.getByTitle('Cyan')).toBeInTheDocument();\n    });\n\n    // Click the collapse button\n    const collapseButton = screen.getByTitle('Show less colors');\n    fireEvent.click(collapseButton);\n\n    // Extended colors should be hidden again\n    await waitFor(() => {\n      expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();\n    });\n  });\n\n  it('selects a color when color button is clicked', async () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} />);\n    await waitFor(() => {\n      expect(screen.getByTitle('Red')).toBeInTheDocument();\n    });\n\n    // Click the red color button\n    const redButton = screen.getByTitle('Red');\n    fireEvent.click(redButton);\n\n    // The color input should now show \"Red\"\n    const colorInput = screen.getByPlaceholderText(/Color name or hex/);\n    expect(colorInput).toHaveValue('Red');\n  });\n\n  it('sends PFUS setting_id as tray_info_idx when cloud detail has filament_id: null (#1053)', async () => {\n    // Cloud returns a user preset that inherits from a generic Bambu base and\n    // has no distinct filament_id of its own — this is how Bambu Cloud responds\n    // for custom presets built on top of \"Generic ABS @BBL H2D\" etc.\n    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({\n      filament_id: null,\n      base_id: 'GFSB99_07',\n      name: '# Overture Matte PLA @BBL H2D',\n    });\n\n    const slotInfo = {\n      ...defaultProps.slotInfo,\n      savedPresetId: 'PFUScd84f663d2c2ef',\n    };\n    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();\n    });\n\n    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));\n\n    await waitFor(() => {\n      expect(api.configureAmsSlot).toHaveBeenCalled();\n    });\n\n    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];\n    // Before the fix, this collapsed to 'GFB99' (Generic ABS's filament_id),\n    // which made OrcaSlicer/BambuStudio Sync Filaments resolve to \"Generic ABS\".\n    expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');\n    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');\n  });\n\n  it('uses cloud detail filament_id when present', async () => {\n    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({\n      filament_id: 'P285e239',\n      base_id: 'GFSB99_07',\n      name: '# Overture Matte PLA @BBL H2D',\n    });\n\n    const slotInfo = {\n      ...defaultProps.slotInfo,\n      savedPresetId: 'PFUScd84f663d2c2ef',\n    };\n    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();\n    });\n\n    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));\n\n    await waitFor(() => {\n      expect(api.configureAmsSlot).toHaveBeenCalled();\n    });\n\n    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];\n    expect(payload.tray_info_idx).toBe('P285e239');\n    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');\n  });\n\n  it('sends short GF filament_id for Bambu GFS* presets (cloud detail not consulted)', async () => {\n    // Bambu-provided presets (GFS*) convert the setting_id → filament_id locally.\n    // The cloud detail endpoint must NOT be consulted for them; the rewrite that\n    // fixed #1053 preserves this pre-existing shortcut.\n    const slotInfo = {\n      ...defaultProps.slotInfo,\n      savedPresetId: 'GFSL05_09',\n    };\n    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();\n    });\n\n    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));\n\n    await waitFor(() => {\n      expect(api.configureAmsSlot).toHaveBeenCalled();\n    });\n\n    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];\n    expect(payload.tray_info_idx).toBe('GFL05');\n    expect(payload.setting_id).toBe('GFSL05_09');\n    expect(api.getCloudSettingDetail).not.toHaveBeenCalled();\n  });\n\n  it('keeps default PFUS tray_info_idx when cloud detail fetch fails', async () => {\n    // Network/5xx from /cloud/settings/{id} must not abort the configure flow\n    // nor leave tray_info_idx empty — we fall back to the setting_id default.\n    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockRejectedValue(\n      new Error('cloud unreachable')\n    );\n\n    const slotInfo = {\n      ...defaultProps.slotInfo,\n      savedPresetId: 'PFUScd84f663d2c2ef',\n    };\n    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('# Overture Matte PLA @BBL H2D')).toBeInTheDocument();\n    });\n\n    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));\n\n    await waitFor(() => {\n      expect(api.configureAmsSlot).toHaveBeenCalled();\n    });\n\n    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];\n    expect(payload.tray_info_idx).toBe('PFUScd84f663d2c2ef');\n    expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');\n  });\n\n  it('renders configure slot button', async () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();\n    });\n\n    // Find the Configure Slot button\n    const configureButton = screen.getByRole('button', { name: /Configure Slot/i });\n    expect(configureButton).toBeInTheDocument();\n  });\n\n  it('filters presets by printer model', async () => {\n    // Render with printerModel=\"H2D\"\n    render(<ConfigureAmsSlotModal {...defaultProps} printerModel=\"H2D\" />);\n    // Wait for presets to load - the H2D preset should be visible\n    await waitFor(() => {\n      expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument();\n    });\n    // The X1C preset should NOT be visible (filtered out by model)\n    expect(screen.queryByText(/Bambu PLA Basic @BBL X1C/)).not.toBeInTheDocument();\n  });\n\n  it('shows current preset even when it does not match model filter', async () => {\n    // Render with printerModel=\"H2D\" but savedPresetId pointing to the X1C preset\n    const slotInfo = {\n      ...defaultProps.slotInfo,\n      savedPresetId: 'GFSL05_09',  // X1C preset\n    };\n    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} printerModel=\"H2D\" />);\n    await waitFor(() => {\n      // Both should be visible - H2D matches model, X1C is saved preset\n      // Use the full preset name to match the list item (not the \"Filtering for\" label)\n      expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();\n      expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument();\n    });\n  });\n\n  it('pre-selects saved preset when opening configured slot', async () => {\n    const slotInfo = {\n      ...defaultProps.slotInfo,\n      savedPresetId: 'GFSL05_09',\n    };\n    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);\n    await waitFor(() => {\n      // The saved preset should have the selected style (green border)\n      // Use the full preset name to avoid matching the \"Filtering for\" label\n      const presetButton = screen.getByText('Bambu PLA Basic @BBL X1C').closest('button');\n      expect(presetButton).toHaveClass('bg-bambu-green/20');\n    });\n  });\n\n  it('pre-populates color from trayColor', async () => {\n    const slotInfo = {\n      ...defaultProps.slotInfo,\n      trayColor: 'FF0000FF',  // Red with alpha\n    };\n    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);\n    await waitFor(() => {\n      expect(screen.getByTitle('White')).toBeInTheDocument();\n    });\n    // The hex display should show the pre-populated color\n    expect(screen.getByText('Hex: #FF0000', { exact: false })).toBeInTheDocument();\n  });\n\n  it('uses translated text for modal elements', async () => {\n    render(<ConfigureAmsSlotModal {...defaultProps} />);\n    await waitFor(() => {\n      expect(screen.getByText('Configure AMS Slot')).toBeInTheDocument();\n      expect(screen.getByText('Filament Profile')).toBeInTheDocument();\n    });\n    // Check footer buttons\n    expect(screen.getByRole('button', { name: /Configure Slot/i })).toBeInTheDocument();\n    expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();\n    expect(screen.getByRole('button', { name: /Reset Slot/i })).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/ConfirmModal.test.tsx",
    "content": "/**\n * Tests for the ConfirmModal component.\n */\n\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { screen, fireEvent, cleanup } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { ConfirmModal } from '../../components/ConfirmModal';\n\ndescribe('ConfirmModal', () => {\n  const defaultProps = {\n    title: 'Confirm Action',\n    message: 'Are you sure you want to proceed?',\n    onConfirm: vi.fn(),\n    onCancel: vi.fn(),\n  };\n\n  afterEach(() => {\n    cleanup();\n    vi.clearAllMocks();\n  });\n\n  describe('rendering', () => {\n    it('renders title', () => {\n      render(<ConfirmModal {...defaultProps} />);\n      expect(screen.getByText('Confirm Action')).toBeInTheDocument();\n    });\n\n    it('renders message', () => {\n      render(<ConfirmModal {...defaultProps} />);\n      expect(\n        screen.getByText('Are you sure you want to proceed?')\n      ).toBeInTheDocument();\n    });\n\n    it('renders default button text', () => {\n      render(<ConfirmModal {...defaultProps} />);\n      expect(screen.getByText('Confirm')).toBeInTheDocument();\n      expect(screen.getByText('Cancel')).toBeInTheDocument();\n    });\n\n    it('renders custom button text', () => {\n      render(\n        <ConfirmModal\n          {...defaultProps}\n          confirmText=\"Delete\"\n          cancelText=\"Go Back\"\n        />\n      );\n      expect(screen.getByText('Delete')).toBeInTheDocument();\n      expect(screen.getByText('Go Back')).toBeInTheDocument();\n    });\n  });\n\n  describe('interactions', () => {\n    it('calls onConfirm when confirm button is clicked', async () => {\n      const user = userEvent.setup();\n      const onConfirm = vi.fn();\n      render(<ConfirmModal {...defaultProps} onConfirm={onConfirm} />);\n\n      await user.click(screen.getByText('Confirm'));\n      expect(onConfirm).toHaveBeenCalledTimes(1);\n    });\n\n    it('calls onCancel when cancel button is clicked', async () => {\n      const user = userEvent.setup();\n      const onCancel = vi.fn();\n      render(<ConfirmModal {...defaultProps} onCancel={onCancel} />);\n\n      await user.click(screen.getByText('Cancel'));\n      expect(onCancel).toHaveBeenCalledTimes(1);\n    });\n\n    it('calls onCancel when clicking backdrop', async () => {\n      const user = userEvent.setup();\n      const onCancel = vi.fn();\n      const { container } = render(\n        <ConfirmModal {...defaultProps} onCancel={onCancel} />\n      );\n\n      // Click on the backdrop (first div with fixed class)\n      const backdrop = container.querySelector('.fixed');\n      if (backdrop) {\n        await user.click(backdrop);\n        expect(onCancel).toHaveBeenCalledTimes(1);\n      }\n    });\n\n    it('does not call onCancel when clicking modal content', async () => {\n      const user = userEvent.setup();\n      const onCancel = vi.fn();\n      render(<ConfirmModal {...defaultProps} onCancel={onCancel} />);\n\n      // Click on the title inside the modal\n      await user.click(screen.getByText('Confirm Action'));\n      expect(onCancel).not.toHaveBeenCalled();\n    });\n\n    it('calls onCancel when Escape key is pressed', () => {\n      const onCancel = vi.fn();\n      render(<ConfirmModal {...defaultProps} onCancel={onCancel} />);\n\n      fireEvent.keyDown(window, { key: 'Escape' });\n      expect(onCancel).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('variants', () => {\n    it('renders default variant', () => {\n      render(<ConfirmModal {...defaultProps} variant=\"default\" />);\n      expect(screen.getByText('Confirm Action')).toBeInTheDocument();\n    });\n\n    it('renders danger variant', () => {\n      render(<ConfirmModal {...defaultProps} variant=\"danger\" />);\n      expect(screen.getByText('Confirm Action')).toBeInTheDocument();\n    });\n\n    it('renders warning variant', () => {\n      render(<ConfirmModal {...defaultProps} variant=\"warning\" />);\n      expect(screen.getByText('Confirm Action')).toBeInTheDocument();\n    });\n  });\n\n  describe('loading state', () => {\n    it('shows loading text when isLoading is true', () => {\n      render(<ConfirmModal {...defaultProps} isLoading={true} loadingText=\"Deleting...\" />);\n      expect(screen.getByText('Deleting...')).toBeInTheDocument();\n    });\n\n    it('shows default loading text when loadingText not provided', () => {\n      render(<ConfirmModal {...defaultProps} isLoading={true} />);\n      expect(screen.getByText('Loading...')).toBeInTheDocument();\n    });\n\n    it('disables buttons when loading', () => {\n      render(<ConfirmModal {...defaultProps} isLoading={true} />);\n      const buttons = screen.getAllByRole('button');\n      buttons.forEach(button => {\n        expect(button).toBeDisabled();\n      });\n    });\n\n    it('does not call onCancel when clicking backdrop while loading', async () => {\n      const user = userEvent.setup();\n      const onCancel = vi.fn();\n      const { container } = render(\n        <ConfirmModal {...defaultProps} onCancel={onCancel} isLoading={true} />\n      );\n\n      const backdrop = container.querySelector('.fixed');\n      if (backdrop) {\n        await user.click(backdrop);\n        expect(onCancel).not.toHaveBeenCalled();\n      }\n    });\n\n    it('does not call onCancel on Escape key while loading', () => {\n      const onCancel = vi.fn();\n      render(<ConfirmModal {...defaultProps} onCancel={onCancel} isLoading={true} />);\n\n      fireEvent.keyDown(window, { key: 'Escape' });\n      expect(onCancel).not.toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/ContextMenu.test.tsx",
    "content": "/**\n * Tests for the ContextMenu component.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { ContextMenu } from '../../components/ContextMenu';\n\ndescribe('ContextMenu', () => {\n  const mockOnClose = vi.fn();\n\n  const menuItems = [\n    { label: 'Edit', onClick: vi.fn() },\n    { label: 'Delete', onClick: vi.fn(), danger: true },\n    { label: 'Download', onClick: vi.fn() },\n  ];\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  describe('rendering', () => {\n    it('renders menu items', () => {\n      render(\n        <ContextMenu\n          x={100}\n          y={100}\n          items={menuItems}\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Edit')).toBeInTheDocument();\n      expect(screen.getByText('Delete')).toBeInTheDocument();\n      expect(screen.getByText('Download')).toBeInTheDocument();\n    });\n\n    it('positions menu at specified coordinates', () => {\n      render(\n        <ContextMenu\n          x={200}\n          y={150}\n          items={menuItems}\n          onClose={mockOnClose}\n        />\n      );\n\n      // Menu should be rendered with items visible\n      expect(screen.getByText('Edit')).toBeInTheDocument();\n    });\n  });\n\n  describe('interactions', () => {\n    it('calls onClick when item is clicked', async () => {\n      const user = userEvent.setup();\n      render(\n        <ContextMenu\n          x={100}\n          y={100}\n          items={menuItems}\n          onClose={mockOnClose}\n        />\n      );\n\n      await user.click(screen.getByText('Edit'));\n\n      expect(menuItems[0].onClick).toHaveBeenCalled();\n    });\n\n    it('calls onClose after item click', async () => {\n      const user = userEvent.setup();\n      render(\n        <ContextMenu\n          x={100}\n          y={100}\n          items={menuItems}\n          onClose={mockOnClose}\n        />\n      );\n\n      await user.click(screen.getByText('Edit'));\n\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n  });\n\n  describe('styling', () => {\n    it('applies danger styling', () => {\n      render(\n        <ContextMenu\n          x={100}\n          y={100}\n          items={menuItems}\n          onClose={mockOnClose}\n        />\n      );\n\n      // Delete item has danger: true, so should have red styling\n      const deleteButton = screen.getByText('Delete');\n      expect(deleteButton).toBeInTheDocument();\n    });\n  });\n\n  describe('dividers', () => {\n    it('supports divider property on items', () => {\n      // Just verify the ContextMenuItem interface accepts divider prop\n      const itemsWithDivider = [\n        { label: 'Edit', onClick: vi.fn() },\n        { label: 'Copy', onClick: vi.fn(), divider: true },\n      ];\n\n      // Interface should accept these items without error\n      expect(itemsWithDivider[1].divider).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/Dashboard.test.tsx",
    "content": "/**\n * Tests for the Dashboard component.\n * Tests drag-and-drop widget management, visibility toggles, and layout persistence.\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { screen, fireEvent, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { Dashboard, type DashboardWidget } from '../../components/Dashboard';\n\nconst mockWidgets: DashboardWidget[] = [\n  {\n    id: 'widget-1',\n    title: 'Widget One',\n    component: <div>Widget One Content</div>,\n    defaultVisible: true,\n    defaultSize: 2,\n  },\n  {\n    id: 'widget-2',\n    title: 'Widget Two',\n    component: <div>Widget Two Content</div>,\n    defaultVisible: true,\n    defaultSize: 4,\n  },\n  {\n    id: 'widget-3',\n    title: 'Widget Three',\n    component: <div>Widget Three Content</div>,\n    defaultVisible: false, // Hidden by default\n    defaultSize: 1,\n  },\n];\n\n// Create a working localStorage mock for these tests\nconst localStorageData: Record<string, string> = {};\nconst localStorageMock = {\n  getItem: vi.fn((key: string) => localStorageData[key] || null),\n  setItem: vi.fn((key: string, value: string) => {\n    localStorageData[key] = value;\n  }),\n  removeItem: vi.fn((key: string) => {\n    delete localStorageData[key];\n  }),\n  clear: vi.fn(() => {\n    Object.keys(localStorageData).forEach((key) => delete localStorageData[key]);\n  }),\n};\n\ndescribe('Dashboard', () => {\n  beforeEach(() => {\n    // Clear localStorage data and mocks before each test\n    Object.keys(localStorageData).forEach((key) => delete localStorageData[key]);\n    vi.clearAllMocks();\n    Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true });\n  });\n\n  describe('rendering', () => {\n    it('renders visible widgets', () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      expect(screen.getByText('Widget One')).toBeInTheDocument();\n      expect(screen.getByText('Widget Two')).toBeInTheDocument();\n      expect(screen.getByText('Widget One Content')).toBeInTheDocument();\n      expect(screen.getByText('Widget Two Content')).toBeInTheDocument();\n    });\n\n    it('does not render hidden widgets', () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      // Widget Three is hidden by default\n      expect(screen.queryByText('Widget Three')).not.toBeInTheDocument();\n      expect(screen.queryByText('Widget Three Content')).not.toBeInTheDocument();\n    });\n\n    it('renders Reset Layout button when controls are shown', () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      expect(screen.getByText('Reset Layout')).toBeInTheDocument();\n    });\n\n    it('hides controls when hideControls is true', () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" hideControls />);\n\n      expect(screen.queryByText('Reset Layout')).not.toBeInTheDocument();\n    });\n\n    it('shows hidden count button when widgets are hidden', () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      // Widget Three is hidden by default\n      expect(screen.getByText('1 Hidden')).toBeInTheDocument();\n    });\n  });\n\n  describe('visibility toggle', () => {\n    it('hides a widget when hide button is clicked', async () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      // Find and click the hide button for Widget One\n      const hideButtons = screen.getAllByTitle('Hide widget');\n      fireEvent.click(hideButtons[0]);\n\n      await waitFor(() => {\n        expect(screen.queryByText('Widget One Content')).not.toBeInTheDocument();\n      });\n    });\n\n    it('shows hidden widgets panel when clicking hidden count button', async () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      const hiddenButton = screen.getByText('1 Hidden');\n      fireEvent.click(hiddenButton);\n\n      await waitFor(() => {\n        expect(screen.getByText('Hidden widgets (click to show):')).toBeInTheDocument();\n        expect(screen.getByText('Widget Three')).toBeInTheDocument();\n      });\n    });\n\n    it('shows a hidden widget when clicked in the hidden panel', async () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      // Open hidden panel\n      const hiddenButton = screen.getByText('1 Hidden');\n      fireEvent.click(hiddenButton);\n\n      await waitFor(() => {\n        expect(screen.getByText('Widget Three')).toBeInTheDocument();\n      });\n\n      // Click to show Widget Three\n      const showWidgetButton = screen.getByRole('button', { name: /Widget Three/i });\n      fireEvent.click(showWidgetButton);\n\n      await waitFor(() => {\n        expect(screen.getByText('Widget Three Content')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('reset layout', () => {\n    it('resets layout to default when Reset Layout is clicked', async () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      // Hide Widget One\n      const hideButtons = screen.getAllByTitle('Hide widget');\n      fireEvent.click(hideButtons[0]);\n\n      await waitFor(() => {\n        expect(screen.queryByText('Widget One Content')).not.toBeInTheDocument();\n      });\n\n      // Reset layout\n      const resetButton = screen.getByText('Reset Layout');\n      fireEvent.click(resetButton);\n\n      await waitFor(() => {\n        expect(screen.getByText('Widget One Content')).toBeInTheDocument();\n      });\n    });\n\n    it('calls onResetLayout callback when reset', async () => {\n      const onResetLayout = vi.fn();\n      render(\n        <Dashboard\n          widgets={mockWidgets}\n          storageKey=\"test-dashboard\"\n          onResetLayout={onResetLayout}\n        />\n      );\n\n      const resetButton = screen.getByText('Reset Layout');\n      fireEvent.click(resetButton);\n\n      expect(onResetLayout).toHaveBeenCalled();\n    });\n  });\n\n  describe('size toggle', () => {\n    it('cycles widget size when size button is clicked', async () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard\" />);\n\n      // Widget One starts at size 2, should cycle to 4\n      const sizeButtons = screen.getAllByTitle(/Size:/);\n      fireEvent.click(sizeButtons[0]);\n\n      // After click, size should change (verify by checking title updates)\n      await waitFor(() => {\n        // The button title should now show a different size\n        expect(screen.getAllByTitle(/Size:/)[0]).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('localStorage persistence', () => {\n    it('saves layout to localStorage when widget is hidden', async () => {\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard-persist\" />);\n\n      // Hide a widget to trigger a layout change\n      const hideButtons = screen.getAllByTitle('Hide widget');\n      fireEvent.click(hideButtons[0]);\n\n      await waitFor(() => {\n        // Verify setItem was called with the storage key\n        expect(localStorageMock.setItem).toHaveBeenCalled();\n        const calls = localStorageMock.setItem.mock.calls;\n        const lastCall = calls[calls.length - 1];\n        expect(lastCall[0]).toBe('test-dashboard-persist');\n        const parsed = JSON.parse(lastCall[1]);\n        expect(parsed.hidden).toContain('widget-1');\n      });\n    });\n\n    it('loads saved layout from localStorage', () => {\n      // Pre-set a layout in localStorage\n      localStorageData['test-dashboard-load'] = JSON.stringify({\n        order: ['widget-2', 'widget-1', 'widget-3'],\n        hidden: ['widget-2'],\n        sizes: { 'widget-1': 4, 'widget-2': 2, 'widget-3': 1 },\n      });\n\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard-load\" />);\n\n      // Widget 2 should be hidden\n      expect(screen.queryByText('Widget Two Content')).not.toBeInTheDocument();\n      // Widget 1 should be visible\n      expect(screen.getByText('Widget One Content')).toBeInTheDocument();\n    });\n  });\n\n  describe('empty state', () => {\n    it('shows empty message when all widgets are hidden', async () => {\n      // Pre-set all widgets as hidden\n      localStorageData['test-dashboard-empty'] = JSON.stringify({\n        order: ['widget-1', 'widget-2', 'widget-3'],\n        hidden: ['widget-1', 'widget-2', 'widget-3'],\n        sizes: {},\n      });\n\n      render(<Dashboard widgets={mockWidgets} storageKey=\"test-dashboard-empty\" />);\n\n      expect(screen.getByText('All widgets are hidden.')).toBeInTheDocument();\n      // There are multiple Reset Layout buttons (one in controls, one in empty state)\n      const resetButtons = screen.getAllByRole('button', { name: 'Reset Layout' });\n      expect(resetButtons.length).toBeGreaterThan(0);\n    });\n  });\n\n  describe('custom render controls', () => {\n    it('renders custom controls when renderControls is provided', () => {\n      render(\n        <Dashboard\n          widgets={mockWidgets}\n          storageKey=\"test-dashboard\"\n          renderControls={({ hiddenCount }) => (\n            <div data-testid=\"custom-controls\">Hidden: {hiddenCount}</div>\n          )}\n        />\n      );\n\n      expect(screen.getByTestId('custom-controls')).toBeInTheDocument();\n      expect(screen.getByText('Hidden: 1')).toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/EditArchiveModal.test.tsx",
    "content": "/**\n * Tests for the EditArchiveModal component.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { EditArchiveModal } from '../../components/EditArchiveModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockArchive = {\n  id: 1,\n  filename: 'benchy.gcode.3mf',\n  print_name: 'Benchy',\n  printer_id: 1,\n  printer_name: 'X1 Carbon',\n  notes: 'Test notes',\n  rating: 4,\n  project_id: null,\n  tags: 'test,calibration',\n};\n\nconst mockProjects = [\n  { id: 1, name: 'Functional Parts', color: '#00ae42' },\n  { id: 2, name: 'Art', color: '#ff5500' },\n];\n\ndescribe('EditArchiveModal', () => {\n  const mockOnClose = vi.fn();\n  const mockOnSave = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.get('/api/v1/projects/', () => {\n        return HttpResponse.json(mockProjects);\n      }),\n      http.get('/api/v1/archives/tags', () => {\n        return HttpResponse.json([\n          { name: 'test', count: 2 },\n          { name: 'calibration', count: 1 },\n          { name: 'functional', count: 3 },\n        ]);\n      }),\n      http.patch('/api/v1/archives/:id', async ({ request }) => {\n        const body = await request.json();\n        return HttpResponse.json({ ...mockArchive, ...body });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the modal title', () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      expect(screen.getByText(/edit/i)).toBeInTheDocument();\n    });\n\n    it('shows print name field', async () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      await waitFor(() => {\n        // Name field should be present\n        const nameInput = screen.getByDisplayValue('Benchy');\n        expect(nameInput).toBeInTheDocument();\n      });\n    });\n\n    it('shows notes field', async () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      await waitFor(() => {\n        const notesField = screen.getByDisplayValue('Test notes');\n        expect(notesField).toBeInTheDocument();\n      });\n    });\n\n    it('shows rating selector', async () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      await waitFor(() => {\n        // Rating may be shown as stars or dropdown\n        expect(screen.getByText(/edit/i)).toBeInTheDocument();\n      });\n    });\n\n    it('shows project selector', async () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      await waitFor(() => {\n        // Project section should be present\n        expect(screen.getByText(/edit/i)).toBeInTheDocument();\n      });\n    });\n\n    it('shows tags input', () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      expect(screen.getByText(/tags/i)).toBeInTheDocument();\n    });\n  });\n\n  describe('existing values', () => {\n    it('shows existing tags', () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      expect(screen.getByText('test')).toBeInTheDocument();\n      expect(screen.getByText('calibration')).toBeInTheDocument();\n    });\n  });\n\n  describe('actions', () => {\n    it('has save button', () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();\n    });\n\n    it('has cancel button', () => {\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();\n    });\n\n    it('calls onClose when cancel is clicked', async () => {\n      const user = userEvent.setup();\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      await user.click(screen.getByRole('button', { name: /cancel/i }));\n\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n\n    it('can edit print name', async () => {\n      const user = userEvent.setup();\n      render(\n        <EditArchiveModal\n          archive={mockArchive}\n          onClose={mockOnClose}\n          onSave={mockOnSave}\n        />\n      );\n\n      const nameInput = screen.getByDisplayValue('Benchy');\n      await user.clear(nameInput);\n      await user.type(nameInput, 'New Name');\n\n      expect(nameInput).toHaveValue('New Name');\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/FailureDetectionSettings.test.tsx",
    "content": "/**\n * Tests for the Failure Detection settings component (#172).\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { FailureDetectionSettings } from '../../components/FailureDetectionSettings';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst baseSettings = {\n  auto_archive: true,\n  save_thumbnails: true,\n  capture_finish_photo: true,\n  default_filament_cost: 25,\n  currency: 'USD',\n  energy_cost_per_kwh: 0.15,\n  energy_tracking_mode: 'total',\n  check_updates: true,\n  check_printer_firmware: true,\n  include_beta_updates: false,\n  obico_enabled: false,\n  obico_ml_url: '',\n  obico_sensitivity: 'medium',\n  obico_action: 'notify',\n  obico_poll_interval: 10,\n  obico_enabled_printers: '',\n};\n\nconst baseStatus = {\n  is_running: true,\n  last_error: null,\n  per_printer: {},\n  thresholds: { low: 0.38, high: 0.78 },\n  history: [],\n  enabled: false,\n  ml_url: '',\n  sensitivity: 'medium',\n  action: 'notify',\n  poll_interval: 10,\n};\n\ndescribe('FailureDetectionSettings', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),\n      http.get('/api/v1/obico/status', () => HttpResponse.json(baseStatus)),\n      http.get('/api/v1/printers', () => HttpResponse.json([])),\n    );\n  });\n\n  it('renders headings and fields', async () => {\n    render(<FailureDetectionSettings />);\n    await waitFor(() => {\n      expect(screen.getByText(/AI Failure Detection|Failure Detection/i)).toBeInTheDocument();\n    });\n    expect(screen.getByText(/Obico ML API URL/i)).toBeInTheDocument();\n    expect(screen.getByText(/Sensitivity/i)).toBeInTheDocument();\n  });\n\n  it('test button calls the test-connection endpoint and shows success', async () => {\n    let called = false;\n    server.use(\n      http.get('/api/v1/settings/', () =>\n        HttpResponse.json({ ...baseSettings, obico_enabled: true, obico_ml_url: 'http://obico:3333' }),\n      ),\n      http.post('/api/v1/obico/test-connection', async ({ request }) => {\n        called = true;\n        const body = (await request.json()) as { url: string };\n        expect(body.url).toBe('http://obico:3333');\n        return HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null });\n      }),\n    );\n    render(<FailureDetectionSettings />);\n    const testBtn = await screen.findByRole('button', { name: /test/i });\n    await userEvent.click(testBtn);\n    await waitFor(() => {\n      expect(called).toBe(true);\n    });\n    expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();\n  });\n\n  it('shows failure class history entries with red styling', async () => {\n    server.use(\n      http.get('/api/v1/obico/status', () =>\n        HttpResponse.json({\n          ...baseStatus,\n          history: [\n            {\n              printer_id: 1,\n              task_name: 'test.3mf',\n              timestamp: '2026-04-13T10:00:00Z',\n              current_p: 0.9,\n              score: 0.85,\n              class: 'failure',\n              detections: 1,\n            },\n          ],\n        }),\n      ),\n    );\n    render(<FailureDetectionSettings />);\n    // Match the history row's score-and-class text, which looks like \"failure 0.850\"\n    expect(await screen.findByText(/failure\\s+0\\.850/)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/FilamentHoverCard.test.tsx",
    "content": "/**\n * Tests for the FilamentHoverCard component.\n * Focuses on fill level display and Spoolman source indicator.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { render, screen, fireEvent, waitFor } from '../utils';\nimport { FilamentHoverCard } from '../../components/FilamentHoverCard';\n\nconst baseFilamentData = {\n  vendor: 'Bambu Lab' as const,\n  profile: 'PLA Basic',\n  colorName: 'Red',\n  colorHex: 'FF0000',\n  kFactor: '0.030',\n  fillLevel: 75,\n  trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',\n};\n\nfunction renderWithHover(ui: React.ReactElement) {\n  const result = render(ui);\n  // Trigger hover to show the card\n  const trigger = result.container.firstElementChild as HTMLElement;\n  fireEvent.mouseEnter(trigger);\n  return result;\n}\n\ndescribe('FilamentHoverCard', () => {\n  beforeEach(() => {\n    vi.useFakeTimers({ shouldAdvanceTime: true });\n  });\n\n  describe('fill level display', () => {\n    it('shows fill percentage when fillLevel is set', async () => {\n      renderWithHover(\n        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 75 }}>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      await waitFor(() => {\n        expect(screen.getByText('75%')).toBeInTheDocument();\n      });\n    });\n\n    it('shows dash when fillLevel is null', async () => {\n      renderWithHover(\n        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null }}>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      await waitFor(() => {\n        expect(screen.getByText('—')).toBeInTheDocument();\n      });\n    });\n\n    it('shows 0% when fillLevel is zero', async () => {\n      renderWithHover(\n        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 0 }}>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      await waitFor(() => {\n        expect(screen.getByText('0%')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('Spoolman source indicator', () => {\n    it('shows Spoolman label when fillSource is spoolman', async () => {\n      renderWithHover(\n        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      await waitFor(() => {\n        expect(screen.getByText('(Spoolman)')).toBeInTheDocument();\n      });\n    });\n\n    it('does not show Spoolman label when fillSource is ams', async () => {\n      renderWithHover(\n        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'ams' }}>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      await waitFor(() => {\n        expect(screen.getByText('80%')).toBeInTheDocument();\n        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();\n      });\n    });\n\n    it('does not show Spoolman label when fillLevel is null', async () => {\n      renderWithHover(\n        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      await waitFor(() => {\n        expect(screen.getByText('—')).toBeInTheDocument();\n        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();\n      });\n    });\n\n    it('does not show Spoolman label when fillSource is undefined', async () => {\n      renderWithHover(\n        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 50 }}>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      await waitFor(() => {\n        expect(screen.getByText('50%')).toBeInTheDocument();\n        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('hover behavior', () => {\n    it('does not show card when disabled', () => {\n      renderWithHover(\n        <FilamentHoverCard data={baseFilamentData} disabled>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      // Card should not be visible\n      expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();\n    });\n\n    it('shows filament details on hover', async () => {\n      renderWithHover(\n        <FilamentHoverCard data={baseFilamentData}>\n          <div>trigger</div>\n        </FilamentHoverCard>\n      );\n\n      vi.advanceTimersByTime(100);\n\n      await waitFor(() => {\n        expect(screen.getByText('Red')).toBeInTheDocument();\n        expect(screen.getByText('PLA Basic')).toBeInTheDocument();\n        expect(screen.getByText('0.030')).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/FilamentOverride.test.tsx",
    "content": "/**\n * Tests for the FilamentOverride component.\n *\n * FilamentOverride allows users to override the 3MF's original filament\n * choices with filaments available across printers of the selected model.\n */\n\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { screen, fireEvent, cleanup } from '@testing-library/react';\nimport { render } from '../utils';\nimport { FilamentOverride } from '../../components/PrintModal/FilamentOverride';\nimport type { FilamentReqsData } from '../../components/PrintModal/types';\n\nconst defaultFilamentReqs: FilamentReqsData = {\n  filaments: [\n    { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },\n  ],\n};\n\nconst defaultAvailable = [\n  { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },\n  { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },\n  { type: 'PETG', color: '#0000FF', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG Basic', extruder_id: null },\n];\n\nconst mockOnChange = vi.fn();\n\nafterEach(() => {\n  cleanup();\n  vi.clearAllMocks();\n});\n\ndescribe('FilamentOverride', () => {\n  describe('rendering', () => {\n    it('returns null when filamentReqs is undefined', () => {\n      render(\n        <FilamentOverride\n          filamentReqs={undefined}\n          availableFilaments={defaultAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();\n    });\n\n    it('returns null when filaments array is empty', () => {\n      render(\n        <FilamentOverride\n          filamentReqs={{ filaments: [] }}\n          availableFilaments={defaultAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();\n    });\n\n    it('returns null when availableFilaments is empty', () => {\n      render(\n        <FilamentOverride\n          filamentReqs={defaultFilamentReqs}\n          availableFilaments={[]}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();\n    });\n\n    it('renders filament slot with type and grams', () => {\n      render(\n        <FilamentOverride\n          filamentReqs={defaultFilamentReqs}\n          availableFilaments={defaultAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      // The grams text \"(25g)\" is in a nested span within the type label\n      expect(screen.getByText('(25g)')).toBeInTheDocument();\n      // \"Filament Override\" heading confirms the section renders\n      expect(screen.getByText('Filament Override')).toBeInTheDocument();\n    });\n\n    it('renders override dropdown for each slot', () => {\n      const twoSlotReqs: FilamentReqsData = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },\n          { slot_id: 2, type: 'PLA', color: '#00FF00', used_grams: 10, used_meters: 3.2 },\n        ],\n      };\n\n      render(\n        <FilamentOverride\n          filamentReqs={twoSlotReqs}\n          availableFilaments={defaultAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const selects = screen.getAllByRole('combobox');\n      expect(selects).toHaveLength(2);\n    });\n  });\n\n  describe('type filtering', () => {\n    it('only shows same-type filaments in dropdown', () => {\n      render(\n        <FilamentOverride\n          filamentReqs={defaultFilamentReqs}\n          availableFilaments={defaultAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      const options = select.querySelectorAll('option');\n\n      // 1 default \"Original\" option + 2 PLA options (not PETG)\n      expect(options).toHaveLength(3);\n\n      // Verify no PETG option values exist\n      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));\n      expect(optionValues).not.toContain('PETG|#0000FF');\n    });\n\n    it('shows all same-type options regardless of color', () => {\n      const threeColorAvailable = [\n        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },\n        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },\n        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Basic', extruder_id: null },\n      ];\n\n      render(\n        <FilamentOverride\n          filamentReqs={defaultFilamentReqs}\n          availableFilaments={threeColorAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      const options = select.querySelectorAll('option');\n\n      // 1 default \"Original\" option + 3 PLA color options\n      expect(options).toHaveLength(4);\n    });\n  });\n\n  describe('subtype display', () => {\n    it('shows tray_sub_brands in dropdown options when available', () => {\n      const subtypeAvailable = [\n        { type: 'PLA', color: '#000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic', extruder_id: null },\n        { type: 'PLA', color: '#000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte', extruder_id: null },\n      ];\n\n      render(\n        <FilamentOverride\n          filamentReqs={defaultFilamentReqs}\n          availableFilaments={subtypeAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      const options = Array.from(select.querySelectorAll('option'));\n      const optionTexts = options.map((o) => o.textContent);\n\n      // Should show \"PLA Basic\" and \"PLA Matte\", not just \"PLA\"\n      expect(optionTexts.some((t) => t?.includes('PLA Basic'))).toBe(true);\n      expect(optionTexts.some((t) => t?.includes('PLA Matte'))).toBe(true);\n    });\n\n    it('falls back to type when tray_sub_brands is empty', () => {\n      const noSubtypeAvailable = [\n        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: '', extruder_id: null },\n      ];\n\n      render(\n        <FilamentOverride\n          filamentReqs={defaultFilamentReqs}\n          availableFilaments={noSubtypeAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      const options = Array.from(select.querySelectorAll('option'));\n      // Non-default option should show \"PLA\" as the type fallback\n      const nonDefaultOptions = options.filter((o) => o.getAttribute('value') !== '');\n      expect(nonDefaultOptions[0].textContent).toContain('PLA');\n    });\n  });\n\n  describe('nozzle filtering', () => {\n    it('filters by extruder_id when nozzle_id is set', () => {\n      const nozzleReqs: FilamentReqsData = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, nozzle_id: 0 },\n        ],\n      };\n\n      const dualExtruderAvailable = [\n        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },\n        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: 1 },\n      ];\n\n      render(\n        <FilamentOverride\n          filamentReqs={nozzleReqs}\n          availableFilaments={dualExtruderAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      const options = select.querySelectorAll('option');\n\n      // 1 default + 1 PLA with extruder_id=0 (extruder_id=1 is filtered out)\n      expect(options).toHaveLength(2);\n\n      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));\n      expect(optionValues).toContain('PLA|#FF0000');\n      expect(optionValues).not.toContain('PLA|#00FF00');\n    });\n\n    it('shows all filaments when nozzle_id is undefined', () => {\n      const noNozzleReqs: FilamentReqsData = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },\n        ],\n      };\n\n      const mixedExtruderAvailable = [\n        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },\n        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: 1 },\n      ];\n\n      render(\n        <FilamentOverride\n          filamentReqs={noNozzleReqs}\n          availableFilaments={mixedExtruderAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      const options = select.querySelectorAll('option');\n\n      // 1 default + 2 PLA options (no nozzle filtering)\n      expect(options).toHaveLength(3);\n    });\n\n    it('includes filaments with null extruder_id', () => {\n      const nozzleReqs: FilamentReqsData = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, nozzle_id: 0 },\n        ],\n      };\n\n      const mixedAvailable = [\n        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: 0 },\n        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Basic', extruder_id: null },\n        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Basic', extruder_id: 1 },\n      ];\n\n      render(\n        <FilamentOverride\n          filamentReqs={nozzleReqs}\n          availableFilaments={mixedAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      const options = select.querySelectorAll('option');\n\n      // 1 default + extruder_id=0 + extruder_id=null (extruder_id=1 filtered out)\n      expect(options).toHaveLength(3);\n\n      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));\n      expect(optionValues).toContain('PLA|#FF0000');\n      expect(optionValues).toContain('PLA|#00FF00');\n      expect(optionValues).not.toContain('PLA|#FFFFFF');\n    });\n  });\n\n  describe('interactions', () => {\n    it('calls onChange when selecting an override', () => {\n      render(\n        <FilamentOverride\n          filamentReqs={defaultFilamentReqs}\n          availableFilaments={defaultAvailable}\n          overrides={{}}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      fireEvent.change(select, { target: { value: 'PLA|#00FF00' } });\n\n      expect(mockOnChange).toHaveBeenCalledWith({\n        1: { type: 'PLA', color: '#00FF00' },\n      });\n    });\n\n    it('calls onChange to remove override when selecting original', () => {\n      const activeOverrides = {\n        1: { type: 'PLA', color: '#00FF00' },\n      };\n\n      render(\n        <FilamentOverride\n          filamentReqs={defaultFilamentReqs}\n          availableFilaments={defaultAvailable}\n          overrides={activeOverrides}\n          onChange={mockOnChange}\n        />\n      );\n\n      const select = screen.getByRole('combobox');\n      fireEvent.change(select, { target: { value: '' } });\n\n      expect(mockOnChange).toHaveBeenCalledWith({});\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/FilamentSlotCircle.test.tsx",
    "content": "/**\n * Tests for the FilamentSlotCircle component.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { screen } from '@testing-library/react';\nimport { render } from '../utils';\nimport { FilamentSlotCircle } from '../../components/FilamentSlotCircle';\n\n/**\n * JSDOM normalizes some CSS color values (e.g. #000 → rgb(0, 0, 0)),\n * so we compare against both hex and rgb forms.\n */\nfunction expectColor(actual: string, hex: string, rgb: string) {\n  expect([hex, rgb]).toContain(actual);\n}\n\ndescribe('FilamentSlotCircle', () => {\n  it('renders the slot number', () => {\n    render(<FilamentSlotCircle trayColor=\"FF0000\" trayType=\"PLA\" isEmpty={false} slotNumber={1} />);\n    expect(screen.getByText('1')).toBeInTheDocument();\n  });\n\n  it('renders slot number for empty slot', () => {\n    render(<FilamentSlotCircle isEmpty={true} slotNumber={3} />);\n    expect(screen.getByText('3')).toBeInTheDocument();\n  });\n\n  it('uses dashed border for empty slots', () => {\n    const { container } = render(\n      <FilamentSlotCircle isEmpty={true} slotNumber={1} />\n    );\n    const circle = container.firstChild as HTMLElement;\n    expect(circle.style.borderStyle).toBe('dashed');\n  });\n\n  it('uses solid border for filled slots', () => {\n    const { container } = render(\n      <FilamentSlotCircle trayColor=\"FF0000\" isEmpty={false} slotNumber={1} />\n    );\n    const circle = container.firstChild as HTMLElement;\n    expect(circle.style.borderStyle).toBe('solid');\n  });\n\n  it('sets background color from trayColor', () => {\n    const { container } = render(\n      <FilamentSlotCircle trayColor=\"00FF00\" trayType=\"PLA\" isEmpty={false} slotNumber={2} />\n    );\n    const circle = container.firstChild as HTMLElement;\n    expectColor(circle.style.backgroundColor, '#00FF00', 'rgb(0, 255, 0)');\n  });\n\n  it('uses dark background when trayType is set but no color', () => {\n    const { container } = render(\n      <FilamentSlotCircle trayType=\"PLA\" isEmpty={false} slotNumber={1} />\n    );\n    const circle = container.firstChild as HTMLElement;\n    expectColor(circle.style.backgroundColor, '#333', 'rgb(51, 51, 51)');\n  });\n\n  it('uses transparent background when empty and no type', () => {\n    const { container } = render(\n      <FilamentSlotCircle isEmpty={true} slotNumber={1} />\n    );\n    const circle = container.firstChild as HTMLElement;\n    expect(circle.style.backgroundColor).toBe('transparent');\n  });\n\n  it('uses black text on light filament colors', () => {\n    // White filament (FFFFFF) is light\n    render(<FilamentSlotCircle trayColor=\"FFFFFF\" isEmpty={false} slotNumber={1} />);\n    const text = screen.getByText('1');\n    expectColor(text.style.color, '#000', 'rgb(0, 0, 0)');\n  });\n\n  it('uses white text on dark filament colors', () => {\n    // Black filament (000000) is dark\n    render(<FilamentSlotCircle trayColor=\"000000\" isEmpty={false} slotNumber={1} />);\n    const text = screen.getByText('1');\n    expectColor(text.style.color, '#fff', 'rgb(255, 255, 255)');\n  });\n\n  it('uses white text when no tray color', () => {\n    render(<FilamentSlotCircle isEmpty={true} slotNumber={1} />);\n    const text = screen.getByText('1');\n    expectColor(text.style.color, '#fff', 'rgb(255, 255, 255)');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/FileManagerModal.test.tsx",
    "content": "/**\n * Tests for the FileManagerModal component.\n * Tests file browsing, selection, navigation, and file operations.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, fireEvent, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { FileManagerModal } from '../../components/FileManagerModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockFiles = [\n  {\n    name: 'cache',\n    path: '/cache',\n    size: 0,\n    is_directory: true,\n    mtime: '2024-01-15T10:00:00Z',\n  },\n  {\n    name: 'model',\n    path: '/model',\n    size: 0,\n    is_directory: true,\n    mtime: '2024-01-15T10:00:00Z',\n  },\n  {\n    name: 'benchy.3mf',\n    path: '/benchy.3mf',\n    size: 1048575,\n    is_directory: false,\n    mtime: '2024-01-15T10:00:00Z',\n  },\n  {\n    name: 'print_job.gcode',\n    path: '/print_job.gcode',\n    size: 2048000,\n    is_directory: false,\n    mtime: '2024-01-14T10:00:00Z',\n  },\n];\n\nconst mockStorage = {\n  used_bytes: 1073741824, // 1 GB\n  free_bytes: 3221225472, // 3 GB\n};\n\ndescribe('FileManagerModal', () => {\n  const mockOnClose = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.get('/api/v1/printers/:id/files', () => {\n        return HttpResponse.json({ files: mockFiles });\n      }),\n      http.get('/api/v1/printers/:id/storage', () => {\n        return HttpResponse.json(mockStorage);\n      }),\n      http.delete('/api/v1/printers/:id/files', () => {\n        return HttpResponse.json({ success: true });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the modal with header', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('File Manager')).toBeInTheDocument();\n      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n    });\n\n    it('renders storage info', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText(/Used:/)).toBeInTheDocument();\n        expect(screen.getByText(/Free:/)).toBeInTheDocument();\n      });\n    });\n\n    it('renders quick navigation buttons', () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Root')).toBeInTheDocument();\n      expect(screen.getByText('Cache')).toBeInTheDocument();\n      expect(screen.getByText('Models')).toBeInTheDocument();\n      expect(screen.getByText('Timelapse')).toBeInTheDocument();\n    });\n\n    it('renders file list', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('cache')).toBeInTheDocument();\n        expect(screen.getByText('model')).toBeInTheDocument();\n        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();\n        expect(screen.getByText('print_job.gcode')).toBeInTheDocument();\n      });\n    });\n\n    it('shows file sizes for files', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        // 1024000 bytes = 1024.0 KB\n        expect(screen.getByText('1024.0 KB')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('navigation', () => {\n    it('navigates into a folder when clicked', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/files', ({ request }) => {\n          const url = new URL(request.url);\n          const path = url.searchParams.get('path');\n          if (path === '/cache') {\n            return HttpResponse.json({\n              files: [\n                { name: 'temp.dat', path: '/cache/temp.dat', size: 512, is_directory: false },\n              ],\n            });\n          }\n          return HttpResponse.json({ files: mockFiles });\n        })\n      );\n\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('cache')).toBeInTheDocument();\n      });\n\n      // Click on cache folder\n      fireEvent.click(screen.getByText('cache'));\n\n      await waitFor(() => {\n        expect(screen.getByText('temp.dat')).toBeInTheDocument();\n      });\n    });\n\n    it('shows current path', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('/')).toBeInTheDocument();\n    });\n  });\n\n  describe('file selection', () => {\n    it('selects a file when checkbox is clicked', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();\n      });\n\n      // Find and click a checkbox (files have checkboxes, directories don't)\n      const checkboxes = screen.getAllByRole('button').filter(btn =>\n        btn.querySelector('svg')?.classList.contains('lucide-square')\n      );\n\n      if (checkboxes.length > 0) {\n        fireEvent.click(checkboxes[0]);\n\n        await waitFor(() => {\n          expect(screen.getByText('1 selected')).toBeInTheDocument();\n        });\n      }\n    });\n\n    it('enables download button when files are selected', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();\n      });\n\n      // Download button should be disabled initially\n      const downloadButton = screen.getByRole('button', { name: /Download/i });\n      expect(downloadButton).toBeDisabled();\n    });\n\n    it('shows Select All button when files exist', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('search and filter', () => {\n    it('renders search input', () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByPlaceholderText('Filter files...')).toBeInTheDocument();\n    });\n\n    it('filters files based on search query', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();\n      });\n\n      const searchInput = screen.getByPlaceholderText('Filter files...');\n      fireEvent.change(searchInput, { target: { value: 'benchy' } });\n\n      await waitFor(() => {\n        expect(screen.getByText('benchy.3mf')).toBeInTheDocument();\n        expect(screen.queryByText('print_job.gcode')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('sorting', () => {\n    it('renders sort dropdown', () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByRole('combobox')).toBeInTheDocument();\n    });\n\n    it('has sort options available', () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      const sortSelect = screen.getByRole('combobox');\n      expect(sortSelect).toBeInTheDocument();\n\n      // Check that options exist\n      expect(screen.getByText('Name (A-Z)')).toBeInTheDocument();\n    });\n  });\n\n  describe('close behavior', () => {\n    it('calls onClose when X button is clicked', async () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      const closeButton = screen.getAllByRole('button').find(btn =>\n        btn.querySelector('.lucide-x')\n      );\n\n      if (closeButton) {\n        fireEvent.click(closeButton);\n        expect(mockOnClose).toHaveBeenCalled();\n      }\n    });\n\n    it('calls onClose when clicking outside the modal', () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      // Click on the backdrop\n      const backdrop = document.querySelector('.fixed.inset-0');\n      if (backdrop) {\n        fireEvent.click(backdrop);\n        expect(mockOnClose).toHaveBeenCalled();\n      }\n    });\n\n    it('calls onClose when Escape key is pressed', () => {\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      fireEvent.keyDown(window, { key: 'Escape' });\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n  });\n\n  describe('empty state', () => {\n    it('shows empty message when directory has no files', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/files', () => {\n          return HttpResponse.json({ files: [] });\n        })\n      );\n\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('No files in this directory')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('loading state', () => {\n    it('shows loading spinner while fetching files', () => {\n      // Delay the response to see loading state\n      server.use(\n        http.get('/api/v1/printers/:id/files', async () => {\n          await new Promise((r) => setTimeout(r, 100));\n          return HttpResponse.json({ files: mockFiles });\n        })\n      );\n\n      render(\n        <FileManagerModal\n          printerId={1}\n          printerName=\"X1 Carbon\"\n          onClose={mockOnClose}\n        />\n      );\n\n      // The loader should be present initially\n      const loader = document.querySelector('.animate-spin');\n      expect(loader).toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/FileUploadModal.test.tsx",
    "content": "/**\n * Tests for the FileUploadModal component.\n * Tests file upload, drag-and-drop, ZIP/3MF/STL detection, and autoUpload mode.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, fireEvent, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { FileUploadModal } from '../../components/FileUploadModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\ndescribe('FileUploadModal', () => {\n  const defaultProps = {\n    folderId: null as number | null,\n    onClose: vi.fn(),\n    onUploadComplete: vi.fn(),\n  };\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n\n    server.use(\n      http.post('/api/v1/library/files', () => {\n        return HttpResponse.json({\n          id: 1,\n          filename: 'test.gcode.3mf',\n          file_type: '3mf',\n          file_size: 1048576,\n          thumbnail_path: null,\n          duplicate_of: null,\n          metadata: null,\n        });\n      }),\n      http.post('/api/v1/library/extract-zip', () => {\n        return HttpResponse.json({\n          extracted: 3,\n          errors: [],\n        });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the modal with title', () => {\n      render(<FileUploadModal {...defaultProps} />);\n      expect(screen.getByText('Upload Files')).toBeInTheDocument();\n    });\n\n    it('renders drag and drop zone', () => {\n      render(<FileUploadModal {...defaultProps} />);\n      expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();\n    });\n\n    it('renders click to browse text', () => {\n      render(<FileUploadModal {...defaultProps} />);\n      expect(screen.getByText(/click to browse/i)).toBeInTheDocument();\n    });\n\n    it('renders Cancel button', () => {\n      render(<FileUploadModal {...defaultProps} />);\n      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();\n    });\n\n    it('renders Upload button disabled when no files', () => {\n      render(<FileUploadModal {...defaultProps} />);\n      const uploadButton = screen.getByRole('button', { name: /Upload/i });\n      expect(uploadButton).toBeDisabled();\n    });\n\n    it('shows all file types supported text', () => {\n      render(<FileUploadModal {...defaultProps} />);\n      expect(screen.getByText(/All file types supported/i)).toBeInTheDocument();\n    });\n  });\n\n  describe('file selection', () => {\n    it('shows added file in the list', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      expect(screen.getByText('model.gcode.3mf')).toBeInTheDocument();\n    });\n\n    it('shows file size in MB', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['x'.repeat(1048576)], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      expect(screen.getByText('1.00 MB')).toBeInTheDocument();\n    });\n\n    it('enables Upload button when files are added', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      expect(uploadButton).not.toBeDisabled();\n    });\n\n    it('shows file count in Upload button', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const files = [\n        new File(['a'], 'file1.3mf', { type: 'application/octet-stream' }),\n        new File(['b'], 'file2.stl', { type: 'application/octet-stream' }),\n      ];\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, files);\n\n      expect(screen.getByRole('button', { name: /Upload \\(2\\)/i })).toBeInTheDocument();\n    });\n\n    it('accepts any file type (not restricted like UploadModal)', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'readme.txt', { type: 'text/plain' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      expect(screen.getByText('readme.txt')).toBeInTheDocument();\n    });\n  });\n\n  describe('file removal', () => {\n    it('removes a file when X button is clicked', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      expect(screen.getByText('model.3mf')).toBeInTheDocument();\n\n      const fileRow = screen.getByText('model.3mf').closest('.flex');\n      const removeButton = fileRow?.querySelector('button');\n      if (removeButton) {\n        await user.click(removeButton);\n      }\n\n      await waitFor(() => {\n        expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();\n      });\n    });\n\n    it('disables Upload button after removing all files', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const fileRow = screen.getByText('model.3mf').closest('.flex');\n      const removeButton = fileRow?.querySelector('button');\n      if (removeButton) {\n        await user.click(removeButton);\n      }\n\n      await waitFor(() => {\n        const uploadButton = screen.getByRole('button', { name: /Upload/i });\n        expect(uploadButton).toBeDisabled();\n      });\n    });\n  });\n\n  describe('file type detection', () => {\n    it('shows ZIP options when .zip file is added', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, zipFile);\n\n      await waitFor(() => {\n        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();\n        expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();\n        expect(screen.getByText(/Create folder from ZIP/)).toBeInTheDocument();\n      });\n    });\n\n    it('shows 3MF info when .3mf file is added', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, threemfFile);\n\n      await waitFor(() => {\n        expect(screen.getByText('3MF files detected')).toBeInTheDocument();\n      });\n    });\n\n    it('shows STL thumbnail option when .stl file is added', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const stlFile = new File(['solid'], 'bracket.stl', { type: 'application/sla' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, stlFile);\n\n      await waitFor(() => {\n        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();\n        expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();\n      });\n    });\n\n    it('shows STL thumbnail option when ZIP file is added (may contain STLs)', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, zipFile);\n\n      await waitFor(() => {\n        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();\n        expect(screen.getByText(/ZIP files may contain STL/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('ZIP options', () => {\n    it('preserve structure checkbox is checked by default', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, zipFile);\n\n      await waitFor(() => {\n        const label = screen.getByText(/Preserve folder structure/).closest('label');\n        const checkbox = label?.querySelector('input[type=\"checkbox\"]') as HTMLInputElement;\n        expect(checkbox).toBeChecked();\n      });\n    });\n\n    it('create folder checkbox is unchecked by default', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, zipFile);\n\n      await waitFor(() => {\n        const label = screen.getByText(/Create folder from ZIP/).closest('label');\n        const checkbox = label?.querySelector('input[type=\"checkbox\"]') as HTMLInputElement;\n        expect(checkbox).not.toBeChecked();\n      });\n    });\n\n    it('can toggle ZIP options', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, zipFile);\n\n      await waitFor(() => {\n        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();\n      });\n\n      const preserveLabel = screen.getByText(/Preserve folder structure/).closest('label');\n      const preserveCheckbox = preserveLabel?.querySelector('input[type=\"checkbox\"]') as HTMLInputElement;\n      await user.click(preserveCheckbox);\n      expect(preserveCheckbox).not.toBeChecked();\n\n      const createFolderLabel = screen.getByText(/Create folder from ZIP/).closest('label');\n      const createFolderCheckbox = createFolderLabel?.querySelector('input[type=\"checkbox\"]') as HTMLInputElement;\n      await user.click(createFolderCheckbox);\n      expect(createFolderCheckbox).toBeChecked();\n    });\n  });\n\n  describe('upload flow', () => {\n    it('calls onUploadComplete after successful upload', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      await waitFor(() => {\n        expect(defaultProps.onUploadComplete).toHaveBeenCalled();\n      });\n    });\n\n    it('calls onFileUploaded with response data for each file', async () => {\n      const onFileUploaded = vi.fn();\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      await waitFor(() => {\n        expect(onFileUploaded).toHaveBeenCalledWith(\n          expect.objectContaining({\n            id: 1,\n            filename: 'test.gcode.3mf',\n          })\n        );\n      });\n    });\n\n    it('shows uploading state while uploading', async () => {\n      // Delay the response to observe uploading state\n      server.use(\n        http.post('/api/v1/library/files', async () => {\n          await new Promise((resolve) => setTimeout(resolve, 100));\n          return HttpResponse.json({\n            id: 1,\n            filename: 'model.3mf',\n            file_type: '3mf',\n            file_size: 1024,\n            thumbnail_path: null,\n            duplicate_of: null,\n            metadata: null,\n          });\n        })\n      );\n\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      // Should show uploading state\n      await waitFor(() => {\n        expect(screen.getByText('Uploading...')).toBeInTheDocument();\n        expect(document.querySelector('.animate-spin')).toBeInTheDocument();\n      });\n    });\n\n    it('shows error state on upload failure', async () => {\n      server.use(\n        http.post('/api/v1/library/files', () => {\n          return HttpResponse.json({ detail: 'File too large' }, { status: 413 });\n        })\n      );\n\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      await waitFor(() => {\n        expect(defaultProps.onUploadComplete).toHaveBeenCalled();\n      });\n    });\n\n    it('closes modal after manual upload completes', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      await waitFor(() => {\n        expect(defaultProps.onUploadComplete).toHaveBeenCalled();\n        expect(defaultProps.onClose).toHaveBeenCalled();\n      });\n    });\n  });\n\n  describe('autoUpload mode', () => {\n    it('uploads immediately when file is added', async () => {\n      const onFileUploaded = vi.fn();\n      const user = userEvent.setup();\n      render(\n        <FileUploadModal\n          {...defaultProps}\n          autoUpload\n          onFileUploaded={onFileUploaded}\n        />\n      );\n\n      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      await waitFor(() => {\n        expect(onFileUploaded).toHaveBeenCalledWith(\n          expect.objectContaining({ id: 1 })\n        );\n      });\n    });\n\n    it('calls onClose after autoUpload completes', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} autoUpload />);\n\n      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      await waitFor(() => {\n        expect(defaultProps.onClose).toHaveBeenCalled();\n        expect(defaultProps.onUploadComplete).toHaveBeenCalled();\n      });\n    });\n  });\n\n  describe('close behavior', () => {\n    it('calls onClose when Cancel button is clicked', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      await user.click(screen.getByRole('button', { name: 'Cancel' }));\n      expect(defaultProps.onClose).toHaveBeenCalled();\n    });\n\n    it('calls onClose when X button is clicked', async () => {\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} />);\n\n      // The X button is the one in the header (not file remove buttons)\n      const headerButtons = screen.getByText('Upload Files').parentElement?.querySelectorAll('button');\n      const closeButton = headerButtons?.[0];\n\n      if (closeButton) {\n        await user.click(closeButton);\n        expect(defaultProps.onClose).toHaveBeenCalled();\n      }\n    });\n\n    it('always shows Cancel button (modal auto-closes after upload)', () => {\n      render(<FileUploadModal {...defaultProps} />);\n      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();\n    });\n  });\n\n  describe('drag and drop', () => {\n    it('highlights drop zone on drag over', () => {\n      render(<FileUploadModal {...defaultProps} />);\n\n      const dropZone = screen.getByText(/Drag & drop/).closest('div[class*=\"border-dashed\"]');\n\n      if (dropZone) {\n        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });\n        expect(dropZone.className).toContain('border-bambu-green');\n      }\n    });\n\n    it('removes highlight on drag leave', () => {\n      render(<FileUploadModal {...defaultProps} />);\n\n      const dropZone = screen.getByText(/Drag & drop/).closest('div[class*=\"border-dashed\"]');\n\n      if (dropZone) {\n        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });\n        fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });\n        expect(dropZone.className).not.toContain('bg-bambu-green');\n      }\n    });\n  });\n\n  describe('folder context', () => {\n    it('accepts folderId prop for uploading to specific folder', () => {\n      render(<FileUploadModal {...defaultProps} folderId={5} />);\n      // Component should render without errors with a folder context\n      expect(screen.getByText('Upload Files')).toBeInTheDocument();\n    });\n  });\n\n  describe('validateFile prop', () => {\n    it('rejects files that fail validation and shows error', async () => {\n      const user = userEvent.setup();\n      render(\n        <FileUploadModal\n          {...defaultProps}\n          validateFile={(file) => {\n            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';\n          }}\n        />\n      );\n\n      const file = new File(['content'], 'model.stl', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      // Error should be shown\n      expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();\n      // File should NOT be added to the list\n      expect(screen.queryByText('model.stl')).not.toBeInTheDocument();\n    });\n\n    it('allows files that pass validation', async () => {\n      const user = userEvent.setup();\n      render(\n        <FileUploadModal\n          {...defaultProps}\n          validateFile={(file) => {\n            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';\n          }}\n        />\n      );\n\n      const file = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      expect(screen.getByText('model.gcode')).toBeInTheDocument();\n      expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();\n    });\n\n    it('clears validation error when a new file is added', async () => {\n      const user = userEvent.setup();\n      render(\n        <FileUploadModal\n          {...defaultProps}\n          validateFile={(file) => {\n            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';\n          }}\n        />\n      );\n\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n\n      // First add an invalid file\n      const badFile = new File(['content'], 'model.stl', { type: 'application/octet-stream' });\n      await user.upload(fileInput, badFile);\n      expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();\n\n      // Then add a valid file — error should clear\n      const goodFile = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });\n      await user.upload(fileInput, goodFile);\n      expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('accept prop', () => {\n    it('sets accept attribute on file input', () => {\n      render(<FileUploadModal {...defaultProps} accept=\".gcode,.gcode.3mf\" />);\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      expect(fileInput.accept).toBe('.gcode,.gcode.3mf');\n    });\n\n    it('does not set accept attribute when prop is omitted', () => {\n      render(<FileUploadModal {...defaultProps} />);\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      expect(fileInput.accept).toBe('');\n    });\n  });\n\n  describe('onFileUploaded error handling', () => {\n    it('shows error and keeps modal open when onFileUploaded returns a string', async () => {\n      const user = userEvent.setup();\n      render(\n        <FileUploadModal\n          {...defaultProps}\n          onFileUploaded={() => 'This file was sliced for the wrong printer'}\n        />\n      );\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      await waitFor(() => {\n        expect(screen.getByText('This file was sliced for the wrong printer')).toBeInTheDocument();\n      });\n\n      // Modal should NOT close\n      expect(defaultProps.onClose).not.toHaveBeenCalled();\n    });\n\n    it('clears file list when onFileUploaded returns an error', async () => {\n      const user = userEvent.setup();\n      render(\n        <FileUploadModal\n          {...defaultProps}\n          onFileUploaded={() => 'Incompatible printer'}\n        />\n      );\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      await waitFor(() => {\n        expect(screen.getByText('Incompatible printer')).toBeInTheDocument();\n      });\n\n      // File list should be cleared\n      expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();\n    });\n\n    it('closes modal normally when onFileUploaded returns undefined', async () => {\n      const onFileUploaded = vi.fn();\n      const user = userEvent.setup();\n      render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      await waitFor(() => {\n        expect(defaultProps.onClose).toHaveBeenCalled();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/GitHubBackupSettings.scheduled.test.tsx",
    "content": "/**\n * Tests for the Scheduled Local Backup UI in GitHubBackupSettings.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { GitHubBackupSettings } from '../../components/GitHubBackupSettings';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockLocalBackupStatus = {\n  enabled: true,\n  schedule: 'daily',\n  time: '03:00',\n  retention: 5,\n  path: '',\n  default_path: '/data/backups',\n  is_running: false,\n  last_backup_at: null,\n  last_status: null,\n  last_message: null,\n  next_run: '2026-04-13T03:00:00+00:00',\n};\n\nconst mockLocalBackups = [\n  {\n    filename: 'bambuddy-backup-20260412-120000.zip',\n    size: 52428800,\n    created_at: '2026-04-12T12:00:00+00:00',\n  },\n];\n\ndescribe('GitHubBackupSettings - Scheduled Backups', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.get('/api/v1/local-backup/status', () =>\n        HttpResponse.json(mockLocalBackupStatus)\n      ),\n      http.get('/api/v1/local-backup/backups', () =>\n        HttpResponse.json(mockLocalBackups)\n      ),\n      http.get('/api/v1/github-backup/config', () =>\n        HttpResponse.json(null)\n      ),\n      http.get('/api/v1/github-backup/status', () =>\n        HttpResponse.json({ configured: false, enabled: false, is_running: false, progress: null, last_backup_at: null, last_backup_status: null, next_scheduled_run: null })\n      ),\n      http.get('/api/v1/github-backup/logs', () =>\n        HttpResponse.json([])\n      ),\n      http.get('/api/v1/cloud/status', () =>\n        HttpResponse.json({ is_authenticated: false })\n      ),\n      http.get('/api/v1/printers', () =>\n        HttpResponse.json([])\n      ),\n      http.put('/api/v1/settings/', () =>\n        HttpResponse.json({})\n      ),\n    );\n  });\n\n  it('renders Scheduled Backups card title', async () => {\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText('Scheduled Backups')).toBeInTheDocument();\n    });\n  });\n\n  it('shows frequency dropdown when enabled', async () => {\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText('Frequency')).toBeInTheDocument();\n    });\n  });\n\n  it('shows retention input when enabled', async () => {\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText('Retention')).toBeInTheDocument();\n    });\n  });\n\n  it('shows backup file list', async () => {\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText('bambuddy-backup-20260412-120000.zip')).toBeInTheDocument();\n    });\n  });\n\n  it('shows file size in MB', async () => {\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText(/50\\.0 MB/)).toBeInTheDocument();\n    });\n  });\n\n  it('shows Run Now button', async () => {\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText('Run Now')).toBeInTheDocument();\n    });\n  });\n\n  it('shows default path when path is empty', async () => {\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText('/data/backups')).toBeInTheDocument();\n    });\n  });\n\n  it('hides schedule controls when disabled', async () => {\n    server.use(\n      http.get('/api/v1/local-backup/status', () =>\n        HttpResponse.json({ ...mockLocalBackupStatus, enabled: false })\n      ),\n    );\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText('Scheduled Backups')).toBeInTheDocument();\n    });\n    expect(screen.queryByText('Frequency')).not.toBeInTheDocument();\n    expect(screen.queryByText('Run Now')).not.toBeInTheDocument();\n  });\n\n  it('hides time picker when hourly is selected', async () => {\n    server.use(\n      http.get('/api/v1/local-backup/status', () =>\n        HttpResponse.json({ ...mockLocalBackupStatus, schedule: 'hourly' })\n      ),\n    );\n    render(<GitHubBackupSettings />);\n    await waitFor(() => {\n      expect(screen.getByText('Frequency')).toBeInTheDocument();\n    });\n    expect(screen.queryByText('Time')).not.toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/HMSErrorModal.test.tsx",
    "content": "/**\n * Tests for the HMSErrorModal component.\n */\n\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { HMSErrorModal } from '../../components/HMSErrorModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\nimport type { HMSError } from '../../api/client';\n\n// Error code 0300_400C = \"The task was canceled.\" (known code in the database)\nconst knownError: HMSError = {\n  attr: 0x0300,\n  code: '0x400C',\n  severity: 2,\n};\n\n// Error code FFFF_FFFF = unknown (not in the database)\nconst unknownError: HMSError = {\n  attr: 0xFFFF,\n  code: '0xFFFF',\n  severity: 1,\n};\n\ndescribe('HMSErrorModal', () => {\n  const defaultProps = {\n    printerName: 'Test Printer',\n    errors: [knownError],\n    onClose: vi.fn(),\n    printerId: 1,\n    hasPermission: vi.fn().mockReturnValue(true) as unknown as (permission: 'printers:control') => boolean,\n  };\n\n  afterEach(() => {\n    cleanup();\n    vi.clearAllMocks();\n  });\n\n  describe('rendering', () => {\n    it('renders the modal title with printer name', () => {\n      render(<HMSErrorModal {...defaultProps} />);\n      expect(screen.getByText('Errors - Test Printer')).toBeInTheDocument();\n    });\n\n    it('shows error description for known error codes', () => {\n      render(<HMSErrorModal {...defaultProps} />);\n      expect(screen.getByText('The task was canceled.')).toBeInTheDocument();\n    });\n\n    it('shows no errors message when all errors are unknown', () => {\n      render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);\n      expect(screen.getByText('No errors')).toBeInTheDocument();\n    });\n\n    it('shows no errors message when errors array is empty', () => {\n      render(<HMSErrorModal {...defaultProps} errors={[]} />);\n      expect(screen.getByText('No errors')).toBeInTheDocument();\n    });\n  });\n\n  describe('clear errors button', () => {\n    it('shows clear button when there are known errors', () => {\n      render(<HMSErrorModal {...defaultProps} />);\n      expect(screen.getByText('Clear Errors')).toBeInTheDocument();\n    });\n\n    it('hides clear button when there are no known errors', () => {\n      render(<HMSErrorModal {...defaultProps} errors={[]} />);\n      expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();\n    });\n\n    it('hides clear button when all errors are unknown codes', () => {\n      render(<HMSErrorModal {...defaultProps} errors={[unknownError]} />);\n      expect(screen.queryByText('Clear Errors')).not.toBeInTheDocument();\n    });\n\n    it('disables clear button when user lacks permission', () => {\n      const noPermission = vi.fn().mockReturnValue(false) as unknown as (permission: 'printers:control') => boolean;\n      render(<HMSErrorModal {...defaultProps} hasPermission={noPermission} />);\n      expect(screen.getByText('Clear Errors').closest('button')).toBeDisabled();\n    });\n\n    it('calls API and closes modal on successful clear', async () => {\n      const user = userEvent.setup();\n      const onClose = vi.fn();\n\n      server.use(\n        http.post('/api/v1/printers/1/hms/clear', () => {\n          return HttpResponse.json({ success: true, message: 'HMS errors cleared' });\n        })\n      );\n\n      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);\n\n      await user.click(screen.getByText('Clear Errors'));\n\n      await waitFor(() => {\n        expect(onClose).toHaveBeenCalledTimes(1);\n      });\n    });\n\n    it('shows error toast on failed clear', async () => {\n      const user = userEvent.setup();\n      const onClose = vi.fn();\n\n      server.use(\n        http.post('/api/v1/printers/1/hms/clear', () => {\n          return HttpResponse.json({ detail: 'Failed' }, { status: 500 });\n        })\n      );\n\n      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);\n\n      await user.click(screen.getByText('Clear Errors'));\n\n      await waitFor(() => {\n        expect(onClose).not.toHaveBeenCalled();\n      });\n    });\n  });\n\n  describe('interactions', () => {\n    it('calls onClose when X button is clicked', async () => {\n      const user = userEvent.setup();\n      const onClose = vi.fn();\n      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);\n\n      // The X button is the button with the X icon in the header\n      const closeButtons = screen.getAllByRole('button');\n      // First button is the X close button in the header\n      await user.click(closeButtons[0]);\n      expect(onClose).toHaveBeenCalledTimes(1);\n    });\n\n    it('calls onClose when Escape key is pressed', () => {\n      const onClose = vi.fn();\n      render(<HMSErrorModal {...defaultProps} onClose={onClose} />);\n\n      fireEvent.keyDown(window, { key: 'Escape' });\n      expect(onClose).toHaveBeenCalledTimes(1);\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/Layout.test.tsx",
    "content": "/**\n * Tests for the Layout component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { Layout } from '../../components/Layout';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\ndescribe('Layout', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json([\n          { id: 1, name: 'X1 Carbon', model: 'X1C', enabled: true },\n        ]);\n      }),\n      http.get('/api/v1/printers/:id/status', () => {\n        return HttpResponse.json({\n          connected: true,\n          state: 'IDLE',\n        });\n      }),\n      http.get('/api/v1/version', () => {\n        return HttpResponse.json({ version: '0.1.6', build: 'test' });\n      }),\n      http.get('/api/v1/settings/', () => {\n        return HttpResponse.json({\n          check_updates: false,\n          check_printer_firmware: false,\n          auto_archive: true,\n        });\n      }),\n      http.get('/api/v1/external-links/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/smart-plugs/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/support/debug-logging', () => {\n        return HttpResponse.json({ enabled: false });\n      }),\n      http.get('/api/v1/queue/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/pending-uploads/count', () => {\n        return HttpResponse.json({ count: 0 });\n      }),\n      http.get('/api/v1/updates/check', () => {\n        return HttpResponse.json({ update_available: false });\n      }),\n      http.get('/api/v1/auth/status', () => {\n        return HttpResponse.json({ auth_enabled: false, requires_setup: false });\n      }),\n      http.get('/api/v1/printers/developer-mode-warnings', () => {\n        return HttpResponse.json([]);\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the sidebar', async () => {\n      render(<Layout />);\n\n      // Layout renders as a flex container with sidebar\n      await waitFor(() => {\n        const sidebar = document.querySelector('aside');\n        expect(sidebar).toBeInTheDocument();\n      });\n    });\n\n    it('renders navigation links', async () => {\n      render(<Layout />);\n\n      await waitFor(() => {\n        // Navigation links should be present\n        const links = document.querySelectorAll('a');\n        expect(links.length).toBeGreaterThan(0);\n      });\n    });\n  });\n\n  describe('navigation', () => {\n    it('has navigation items', async () => {\n      render(<Layout />);\n\n      await waitFor(() => {\n        // Should have multiple navigation links\n        const navLinks = document.querySelectorAll('a[href]');\n        expect(navLinks.length).toBeGreaterThan(0);\n      });\n    });\n\n    it('includes settings link', async () => {\n      render(<Layout />);\n\n      await waitFor(() => {\n        // Settings link should exist (route /settings)\n        const settingsLink = document.querySelector('a[href=\"/settings\"]');\n        expect(settingsLink).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('version display', () => {\n    it('shows version info', async () => {\n      render(<Layout />);\n\n      await waitFor(() => {\n        // Version info is displayed in sidebar\n        expect(document.body).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('theme toggle', () => {\n    it('has theme toggle button', async () => {\n      render(<Layout />);\n\n      await waitFor(() => {\n        // Theme toggle should be present\n        const buttons = document.querySelectorAll('button');\n        expect(buttons.length).toBeGreaterThan(0);\n      });\n    });\n  });\n\n  describe('plate detection alert modal', () => {\n    it('shows modal when plate-not-empty event is dispatched', async () => {\n      render(<Layout />);\n\n      // Dispatch the plate-not-empty event\n      window.dispatchEvent(\n        new CustomEvent('plate-not-empty', {\n          detail: {\n            printer_id: 1,\n            printer_name: 'Test Printer',\n            message: 'Objects detected on build plate',\n          },\n        })\n      );\n\n      await waitFor(() => {\n        // Modal should appear with \"Print Paused!\" text\n        expect(document.body.textContent).toContain('Print Paused!');\n        expect(document.body.textContent).toContain('Test Printer');\n      });\n    });\n\n    it('closes modal when I Understand button is clicked', async () => {\n      render(<Layout />);\n\n      // Dispatch the plate-not-empty event\n      window.dispatchEvent(\n        new CustomEvent('plate-not-empty', {\n          detail: {\n            printer_id: 1,\n            printer_name: 'Test Printer',\n            message: 'Objects detected on build plate',\n          },\n        })\n      );\n\n      await waitFor(() => {\n        expect(document.body.textContent).toContain('Print Paused!');\n      });\n\n      // Click the \"I Understand\" button\n      const button = document.querySelector('button');\n      if (button && button.textContent?.includes('I Understand')) {\n        button.click();\n      }\n\n      // Find and click the \"I Understand\" button by searching all buttons\n      const buttons = document.querySelectorAll('button');\n      buttons.forEach((btn) => {\n        if (btn.textContent?.includes('I Understand')) {\n          btn.click();\n        }\n      });\n\n      await waitFor(() => {\n        // Modal should be closed\n        expect(document.body.textContent).not.toContain('Print Paused!');\n      });\n    });\n  });\n\n  describe('developer mode warning banner', () => {\n    it('shows warning banner when printers lack developer mode', async () => {\n      server.use(\n        http.get('/api/v1/printers/developer-mode-warnings', () => {\n          return HttpResponse.json([\n            { printer_id: 1, name: 'X1 Carbon' },\n          ]);\n        })\n      );\n\n      render(<Layout />);\n\n      await waitFor(() => {\n        expect(document.body.textContent).toContain('Developer LAN mode is not enabled on');\n        expect(document.body.textContent).toContain('X1 Carbon');\n      });\n    });\n\n    it('shows multiple printer names in warning banner', async () => {\n      server.use(\n        http.get('/api/v1/printers/developer-mode-warnings', () => {\n          return HttpResponse.json([\n            { printer_id: 1, name: 'X1 Carbon' },\n            { printer_id: 2, name: 'P1S' },\n          ]);\n        })\n      );\n\n      render(<Layout />);\n\n      await waitFor(() => {\n        expect(document.body.textContent).toContain('X1 Carbon');\n        expect(document.body.textContent).toContain('P1S');\n      });\n    });\n\n    it('hides warning banner when no printers lack developer mode', async () => {\n      // Default handler returns empty array\n      render(<Layout />);\n\n      await waitFor(() => {\n        const sidebar = document.querySelector('aside');\n        expect(sidebar).toBeInTheDocument();\n      });\n\n      // Banner should not be present\n      expect(document.body.textContent).not.toContain('Developer LAN mode is not enabled on');\n    });\n\n    it('shows how to enable link in warning banner', async () => {\n      server.use(\n        http.get('/api/v1/printers/developer-mode-warnings', () => {\n          return HttpResponse.json([\n            { printer_id: 1, name: 'X1 Carbon' },\n          ]);\n        })\n      );\n\n      render(<Layout />);\n\n      await waitFor(() => {\n        expect(document.body.textContent).toContain('How to enable');\n        const link = document.querySelector('a[href*=\"enable-developer-mode\"]');\n        expect(link).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/LinkSpoolModal.test.tsx",
    "content": "/**\n * Tests for the LinkSpoolModal component.\n *\n * Tests the inventory link-to-spool modal including:\n * - Rendering modal with tag/tray info\n * - Displaying untagged spools\n * - Linking a spool via click\n * - Search filtering\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport { render } from '../utils';\nimport { LinkSpoolModal } from '../../components/LinkSpoolModal';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  api: {\n    getUnlinkedSpools: vi.fn(),\n    linkSpool: vi.fn(),\n    getSettings: vi.fn().mockResolvedValue({}),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n  },\n}));\n\n// Mock the toast context\nconst mockShowToast = vi.fn();\nvi.mock('../../contexts/ToastContext', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();\n  return {\n    ...actual,\n    useToast: () => ({ showToast: mockShowToast }),\n  };\n});\n\n// Import mocked module\nimport { api } from '../../api/client';\n\ndescribe('LinkSpoolModal', () => {\n  const defaultProps = {\n    isOpen: true,\n    onClose: vi.fn(),\n    tagUid: 'ABCD1234',\n    trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',\n    printerId: 1,\n    amsId: 0,\n    trayId: 0,\n  };\n\n  const mockSpools = [\n    {\n      id: 1,\n      filament_name: 'Generic PLA Red',\n      filament_material: 'PLA',\n      filament_color_hex: 'FF0000',\n      remaining_weight: 800,\n      location: null,\n    },\n    {\n      id: 2,\n      filament_name: 'Bambu PETG Blue',\n      filament_material: 'PETG',\n      filament_color_hex: '0000FF',\n      remaining_weight: 500,\n      location: null,\n    },\n  ];\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockSpools);\n    vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'ok' });\n  });\n\n  describe('rendering', () => {\n    it('renders modal title', async () => {\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();\n      });\n    });\n\n    it('displays printer and tray info', async () => {\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/AMS 0 T0/)).toBeInTheDocument();\n        expect(screen.getByText(/Printer #1/)).toBeInTheDocument();\n      });\n    });\n\n    it('shows loading state while fetching spools', async () => {\n      vi.mocked(api.getUnlinkedSpools).mockImplementation(() => new Promise(() => {}));\n\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        expect(document.querySelector('.animate-spin')).toBeInTheDocument();\n      });\n    });\n\n    it('displays unlinked spools from Spoolman', async () => {\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        // Should show spools from getUnlinkedSpools\n        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();\n        expect(screen.getByText(/Bambu PETG Blue/)).toBeInTheDocument();\n      });\n    });\n\n    it('does not render when isOpen is false', () => {\n      render(<LinkSpoolModal {...defaultProps} isOpen={false} />);\n      expect(screen.queryByRole('heading', { name: /select spool/i })).not.toBeInTheDocument();\n    });\n  });\n\n  describe('linking', () => {\n    it('uses trayUuid when linking if present (Bambu spool path)', async () => {\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();\n      });\n\n      fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);\n\n      await waitFor(() => {\n        expect(api.linkSpool).toHaveBeenCalledWith(1, {\n          spoolTag: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',\n          printerId: 1,\n          amsId: 0,\n          trayId: 0,\n        });\n      });\n    });\n\n    it('falls back to tagUid when trayUuid is missing (generic spool path)', async () => {\n      render(<LinkSpoolModal {...defaultProps} trayUuid=\"\" />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();\n      });\n\n      fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);\n\n      await waitFor(() => {\n        expect(api.linkSpool).toHaveBeenCalledWith(1, {\n          spoolTag: 'ABCD1234',\n          printerId: 1,\n          amsId: 0,\n          trayId: 0,\n        });\n      });\n    });\n\n    it('shows success toast and calls onClose', async () => {\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();\n      });\n\n      fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);\n\n      await waitFor(() => {\n        expect(mockShowToast).toHaveBeenCalled();\n        expect(defaultProps.onClose).toHaveBeenCalled();\n      });\n    });\n\n    it('shows error toast on failure', async () => {\n      vi.mocked(api.linkSpool).mockRejectedValue(new Error('Link failed'));\n\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();\n      });\n\n      fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);\n\n      await waitFor(() => {\n        expect(mockShowToast).toHaveBeenCalledWith(\n          expect.stringContaining('Link failed'),\n          'error'\n        );\n      });\n    });\n  });\n\n  describe('modal actions', () => {\n    it('calls onClose when backdrop is clicked', async () => {\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();\n      });\n\n      const backdrop = document.querySelector('.bg-black\\\\/60');\n      if (backdrop) {\n        fireEvent.click(backdrop);\n        expect(defaultProps.onClose).toHaveBeenCalled();\n      }\n    });\n\n    it('calls onClose when X button is clicked', async () => {\n      render(<LinkSpoolModal {...defaultProps} />);\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();\n      });\n\n      const closeButtons = screen.getAllByRole('button');\n      const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));\n      if (xButton) {\n        fireEvent.click(xButton);\n        expect(defaultProps.onClose).toHaveBeenCalled();\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/LocalProfilesView.test.tsx",
    "content": "/**\n * Tests for LocalProfilesView component.\n */\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\nimport { render } from '../utils';\nimport { LocalProfilesView } from '../../components/LocalProfilesView';\n\nconst mockLocalPresets = {\n  filament: [\n    {\n      id: 1,\n      name: 'Overture PLA Matte @BBL X1C',\n      preset_type: 'filament',\n      source: 'orcaslicer',\n      filament_type: 'PLA',\n      filament_vendor: 'Overture',\n      nozzle_temp_min: 190,\n      nozzle_temp_max: 230,\n      pressure_advance: '[\"0.04\"]',\n      default_filament_colour: '[\"#FFAA00\"]',\n      filament_cost: '24.99',\n      filament_density: '1.24',\n      compatible_printers: '[\"Bambu Lab X1 Carbon 0.4 nozzle\"]',\n      inherits: 'Bambu PLA Basic @BBL X1C',\n      version: '2.3.0.4',\n      created_at: '2026-01-01T00:00:00Z',\n      updated_at: '2026-01-01T00:00:00Z',\n    },\n    {\n      id: 2,\n      name: 'eSUN PETG @Bambu Lab H2D',\n      preset_type: 'filament',\n      source: 'orcaslicer',\n      filament_type: 'PETG',\n      filament_vendor: null,\n      nozzle_temp_min: 220,\n      nozzle_temp_max: 250,\n      pressure_advance: null,\n      default_filament_colour: null,\n      filament_cost: null,\n      filament_density: null,\n      compatible_printers: null,\n      inherits: null,\n      version: null,\n      created_at: '2026-01-01T00:00:00Z',\n      updated_at: '2026-01-01T00:00:00Z',\n    },\n  ],\n  process: [\n    {\n      id: 3,\n      name: '0.20mm Standard @BBL X1C',\n      preset_type: 'process',\n      source: 'orcaslicer',\n      filament_type: null,\n      filament_vendor: null,\n      nozzle_temp_min: null,\n      nozzle_temp_max: null,\n      pressure_advance: null,\n      default_filament_colour: null,\n      filament_cost: null,\n      filament_density: null,\n      compatible_printers: null,\n      inherits: '0.20mm Standard @BBL X1C',\n      version: '2.3.0.4',\n      created_at: '2026-01-01T00:00:00Z',\n      updated_at: '2026-01-01T00:00:00Z',\n    },\n  ],\n  printer: [],\n};\n\ndescribe('LocalProfilesView', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/local-presets/', () => {\n        return HttpResponse.json(mockLocalPresets);\n      }),\n      http.delete('/api/v1/local-presets/:id', () => {\n        return HttpResponse.json({ success: true });\n      }),\n    );\n  });\n\n  it('renders filament and process columns', async () => {\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();\n    expect(screen.getByText('0.20mm Standard @BBL X1C')).toBeInTheDocument();\n  });\n\n  it('shows material badges from filament_type', async () => {\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();\n    });\n\n    // PLA badge should appear for the first preset\n    const plaBadges = screen.getAllByText('PLA');\n    expect(plaBadges.length).toBeGreaterThan(0);\n  });\n\n  it('shows vendor from filament_vendor field', async () => {\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Overture')).toBeInTheDocument();\n    });\n  });\n\n  it('parses vendor from name when filament_vendor is null', async () => {\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();\n    });\n\n    // eSUN should be parsed from the name\n    expect(screen.getByText('eSUN')).toBeInTheDocument();\n  });\n\n  it('filters presets by search query', async () => {\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();\n    });\n\n    const searchInput = screen.getByPlaceholderText(/search/i);\n    fireEvent.change(searchInput, { target: { value: 'PETG' } });\n\n    expect(screen.queryByText('Overture PLA Matte @BBL X1C')).not.toBeInTheDocument();\n    expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();\n  });\n\n  it('shows empty state when no presets', async () => {\n    server.use(\n      http.get('/api/v1/local-presets/', () => {\n        return HttpResponse.json({ filament: [], process: [], printer: [] });\n      }),\n    );\n\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/no local presets/i)).toBeInTheDocument();\n    });\n  });\n\n  it('shows Local badge on preset cards', async () => {\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();\n    });\n\n    const badges = screen.getAllByText(/^Local$/i);\n    expect(badges.length).toBeGreaterThan(0);\n  });\n\n  it('shows delete confirmation modal', async () => {\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();\n    });\n\n    // Click first delete button\n    const deleteButtons = screen.getAllByTitle(/delete/i);\n    fireEvent.click(deleteButtons[0]);\n\n    await waitFor(() => {\n      expect(screen.getByText(/are you sure/i)).toBeInTheDocument();\n    });\n  });\n\n  it('shows import zone', async () => {\n    render(<LocalProfilesView />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/import profiles/i)).toBeInTheDocument();\n    });\n\n    expect(screen.getByText(/\\.bbscfg/i)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/ModelViewerModal.test.tsx",
    "content": "/**\n * Tests for the ModelViewerModal component.\n * Tests fullscreen toggle, plate selector, object counts, and tab switching.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, fireEvent, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { ModelViewerModal } from '../../components/ModelViewerModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\n// Mock ModelViewer and GcodeViewer to avoid WebGL/Three.js issues in tests\nvi.mock('../../components/ModelViewer', () => ({\n  ModelViewer: ({ className }: { className?: string }) => (\n    <div data-testid=\"model-viewer\" className={className}>\n      Model Viewer Mock\n    </div>\n  ),\n}));\n\nvi.mock('../../components/GcodeViewer', () => ({\n  GcodeViewer: ({ className }: { className?: string }) => (\n    <div data-testid=\"gcode-viewer\" className={className}>\n      G-code Viewer Mock\n    </div>\n  ),\n}));\n\nconst mockCapabilities = {\n  has_model: true,\n  has_gcode: true,\n  has_source: false,\n  build_volume: { x: 256, y: 256, z: 256 },\n  filament_colors: ['#00ae42'],\n};\n\nconst mockPlatesResponse = {\n  is_multi_plate: true,\n  plates: [\n    {\n      index: 1,\n      name: 'Plate 1',\n      has_thumbnail: true,\n      thumbnail_url: '/api/v1/archives/1/plates/1/thumbnail',\n      print_time_seconds: 3600,\n      filament_used_grams: 50.5,\n      object_count: 3,\n      objects: ['Cube', 'Sphere', 'Cylinder'],\n      filaments: [{ color: '#00ae42', type: 'PLA', name: 'Bambu PLA Basic' }],\n    },\n    {\n      index: 2,\n      name: 'Plate 2',\n      has_thumbnail: true,\n      thumbnail_url: '/api/v1/archives/1/plates/2/thumbnail',\n      print_time_seconds: 1800,\n      filament_used_grams: 25.0,\n      object_count: 2,\n      objects: ['Base', 'Cover'],\n      filaments: [{ color: '#ff0000', type: 'PLA', name: 'Red PLA' }],\n    },\n  ],\n};\n\nconst mockSinglePlateResponse = {\n  is_multi_plate: false,\n  plates: [\n    {\n      index: 1,\n      name: null,\n      has_thumbnail: false,\n      thumbnail_url: null,\n      print_time_seconds: 7200,\n      filament_used_grams: 100.0,\n      object_count: 5,\n      objects: ['Model 1', 'Model 2', 'Model 3', 'Model 4', 'Model 5'],\n      filaments: [],\n    },\n  ],\n};\n\ndescribe('ModelViewerModal', () => {\n  const mockOnClose = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.get('/api/v1/archives/:id/capabilities', () => {\n        return HttpResponse.json(mockCapabilities);\n      }),\n      http.get('/api/v1/archives/:id/plates', () => {\n        return HttpResponse.json(mockPlatesResponse);\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the modal with title', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model.3mf\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Test Model.3mf')).toBeInTheDocument();\n    });\n\n    it('renders Open in Slicer button', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Open in Slicer')).toBeInTheDocument();\n      });\n    });\n\n    it('shows loading spinner while fetching capabilities', () => {\n      server.use(\n        http.get('/api/v1/archives/:id/capabilities', async () => {\n          await new Promise((r) => setTimeout(r, 100));\n          return HttpResponse.json(mockCapabilities);\n        })\n      );\n\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      const loader = document.querySelector('.animate-spin');\n      expect(loader).toBeInTheDocument();\n    });\n  });\n\n  describe('tabs', () => {\n    it('renders 3D Model and G-code tabs', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('3D Model')).toBeInTheDocument();\n        expect(screen.getByText('G-code Preview')).toBeInTheDocument();\n      });\n    });\n\n    it('shows not available label when model is not available', async () => {\n      server.use(\n        http.get('/api/v1/archives/:id/capabilities', () => {\n          return HttpResponse.json({\n            ...mockCapabilities,\n            has_model: false,\n          });\n        })\n      );\n\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('(not available)')).toBeInTheDocument();\n      });\n    });\n\n    it('shows not sliced label when gcode is not available', async () => {\n      server.use(\n        http.get('/api/v1/archives/:id/capabilities', () => {\n          return HttpResponse.json({\n            ...mockCapabilities,\n            has_gcode: false,\n          });\n        })\n      );\n\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('(not sliced)')).toBeInTheDocument();\n      });\n    });\n\n    it('disables tab when capability is not available', async () => {\n      server.use(\n        http.get('/api/v1/archives/:id/capabilities', () => {\n          return HttpResponse.json({\n            ...mockCapabilities,\n            has_gcode: false,\n          });\n        })\n      );\n\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        const gcodeTab = screen.getByText('G-code Preview').closest('button');\n        expect(gcodeTab).toBeDisabled();\n      });\n    });\n  });\n\n  describe('fullscreen', () => {\n    it('renders fullscreen toggle button', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        // Look for the maximize icon button\n        const buttons = screen.getAllByRole('button');\n        const fullscreenButton = buttons.find(\n          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Enter fullscreen'\n        );\n        expect(fullscreenButton).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('object count', () => {\n    it('displays object count for multi-plate files', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        // Total objects across both plates = 3 + 2 = 5\n        // The header shows \"All Plates: 5 objects\" in a span\n        const objectCountBadge = screen.getByText(/All Plates.*5 objects/);\n        expect(objectCountBadge).toBeInTheDocument();\n      });\n    });\n\n    it('updates object count when plate is selected', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Plate 1')).toBeInTheDocument();\n      });\n\n      // Click on Plate 1\n      fireEvent.click(screen.getByText('Plate 1'));\n\n      await waitFor(() => {\n        // Plate 1 has 3 objects - header should update to show \"Plate 1: 3 objects\"\n        const objectCountBadge = screen.getByText(/Plate 1.*3 objects/);\n        expect(objectCountBadge).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('plate selector', () => {\n    it('shows plates panel for multi-plate files', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Plates')).toBeInTheDocument();\n        // Use getAllByText for \"All Plates\" since it appears in header and panel\n        const allPlatesElements = screen.getAllByText('All Plates');\n        expect(allPlatesElements.length).toBeGreaterThan(0);\n        expect(screen.getByText('2 plates')).toBeInTheDocument();\n      });\n    });\n\n    it('shows individual plate buttons', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Plate 1')).toBeInTheDocument();\n        expect(screen.getByText('Plate 2')).toBeInTheDocument();\n      });\n    });\n\n    it('shows object count for each plate', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        // Each plate shows its object count in the grid\n        expect(screen.getByText('3 objects')).toBeInTheDocument();\n        expect(screen.getByText('2 objects')).toBeInTheDocument();\n      });\n    });\n\n    it('hides plates panel for single-plate files', async () => {\n      server.use(\n        http.get('/api/v1/archives/:id/plates', () => {\n          return HttpResponse.json(mockSinglePlateResponse);\n        })\n      );\n\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        // Should show object count but not plate selector\n        expect(screen.getByText(/5 objects/)).toBeInTheDocument();\n      });\n\n      // Plates panel should not be shown for single plate\n      expect(screen.queryByText('2 plates')).not.toBeInTheDocument();\n    });\n\n    it('selects All Plates by default', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        // Find the All Plates button in the grid (the one with \"2 plates\" sibling text)\n        const platesCountText = screen.getByText('2 plates');\n        const allPlatesButton = platesCountText.closest('button');\n        // The selected button should have the green border class\n        expect(allPlatesButton).toHaveClass('border-bambu-green');\n      });\n    });\n\n    it('allows plate selection via click', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Plate 1')).toBeInTheDocument();\n      });\n\n      // Click on Plate 1 - this should not throw\n      const plate1Button = screen.getByText('Plate 1').closest('button');\n      expect(plate1Button).toBeInTheDocument();\n      fireEvent.click(plate1Button!);\n\n      // After clicking, the header should show Plate 1 info\n      await waitFor(() => {\n        expect(screen.getByText(/Plate 1.*3 objects/)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('close behavior', () => {\n    it('calls onClose when X button is clicked', async () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      const closeButton = screen.getAllByRole('button').find(\n        (btn) => btn.querySelector('.lucide-x')\n      );\n\n      if (closeButton) {\n        fireEvent.click(closeButton);\n        expect(mockOnClose).toHaveBeenCalled();\n      }\n    });\n\n    it('calls onClose when Escape key is pressed', () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      fireEvent.keyDown(window, { key: 'Escape' });\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n\n    it('calls onClose when backdrop is clicked', () => {\n      render(\n        <ModelViewerModal\n          archiveId={1}\n          title=\"Test Model\"\n          onClose={mockOnClose}\n        />\n      );\n\n      const backdrop = document.querySelector('.fixed.inset-0');\n      if (backdrop) {\n        fireEvent.click(backdrop);\n        expect(mockOnClose).toHaveBeenCalled();\n      }\n    });\n  });\n\n  describe('library file mode', () => {\n    it('renders for library file', async () => {\n      server.use(\n        http.get('/api/v1/library/files/:id/plates', () => {\n          return HttpResponse.json(mockSinglePlateResponse);\n        })\n      );\n\n      render(\n        <ModelViewerModal\n          libraryFileId={1}\n          title=\"Library Model.3mf\"\n          fileType=\"3mf\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Library Model.3mf')).toBeInTheDocument();\n\n      await waitFor(() => {\n        expect(screen.getByText('3D Model')).toBeInTheDocument();\n      });\n    });\n\n    it('disables Open in Slicer for non-3mf library files', async () => {\n      render(\n        <ModelViewerModal\n          libraryFileId={1}\n          title=\"Model.stl\"\n          fileType=\"stl\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        const slicerButton = screen.getByText('Open in Slicer').closest('button');\n        expect(slicerButton).toBeDisabled();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/NotificationProviderCard.test.tsx",
    "content": "/**\n * Tests for the NotificationProviderCard component.\n *\n * These tests cover notification provider display and toggle functionality.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport type { NotificationProvider } from '../../api/client';\n\n// Mock the component if it exists\nvi.mock('../../components/NotificationProviderCard', () => ({\n  NotificationProviderCard: ({\n    provider,\n    onEdit,\n  }: {\n    provider: NotificationProvider;\n    onEdit: () => void;\n  }) => (\n    <div data-testid=\"notification-provider-card\">\n      <span data-testid=\"provider-name\">{provider.name}</span>\n      <span data-testid=\"provider-type\">{provider.provider_type}</span>\n      <span data-testid=\"provider-enabled\">\n        {provider.enabled ? 'Enabled' : 'Disabled'}\n      </span>\n      <span data-testid=\"on-print-start\">\n        {provider.on_print_start ? 'Yes' : 'No'}\n      </span>\n      <span data-testid=\"on-print-complete\">\n        {provider.on_print_complete ? 'Yes' : 'No'}\n      </span>\n      <button onClick={onEdit}>Edit</button>\n    </div>\n  ),\n}));\n\n// Import after mocking\nconst { NotificationProviderCard } = await import(\n  '../../components/NotificationProviderCard'\n);\n\n// Mock data\nconst createMockProvider = (\n  overrides: Partial<NotificationProvider> = {}\n): NotificationProvider => ({\n  id: 1,\n  name: 'Test Provider',\n  provider_type: 'ntfy',\n  enabled: true,\n  config: { server: 'https://ntfy.sh', topic: 'test' },\n  on_print_start: true,\n  on_print_complete: true,\n  on_print_failed: true,\n  on_print_stopped: false,\n  on_print_progress: false,\n  on_printer_offline: false,\n  on_printer_error: false,\n  on_filament_low: false,\n  on_maintenance_due: false,\n  on_ams_humidity_high: false,\n  on_ams_temperature_high: false,\n  on_ams_ht_humidity_high: false,\n  on_ams_ht_temperature_high: false,\n  on_plate_not_empty: true,\n  on_bed_cooled: false,\n  on_first_layer_complete: false,\n  on_queue_job_added: false,\n  on_queue_job_assigned: false,\n  on_queue_job_started: false,\n  on_queue_job_waiting: true,\n  on_queue_job_skipped: true,\n  on_queue_job_failed: true,\n  on_queue_completed: false,\n  quiet_hours_enabled: false,\n  quiet_hours_start: null,\n  quiet_hours_end: null,\n  daily_digest_enabled: false,\n  daily_digest_time: null,\n  printer_id: null,\n  last_success: null,\n  last_error: null,\n  last_error_at: null,\n  created_at: '2024-01-01T00:00:00Z',\n  updated_at: '2024-01-01T00:00:00Z',\n  ...overrides,\n});\n\ndescribe('NotificationProviderCard', () => {\n  const mockOnEdit = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  describe('rendering', () => {\n    it('renders provider name', () => {\n      const provider = createMockProvider({ name: 'My Notifications' });\n      render(\n        <NotificationProviderCard provider={provider} onEdit={mockOnEdit} />\n      );\n\n      expect(screen.getByTestId('provider-name')).toHaveTextContent(\n        'My Notifications'\n      );\n    });\n\n    it('renders provider type', () => {\n      const provider = createMockProvider({ provider_type: 'telegram' });\n      render(\n        <NotificationProviderCard provider={provider} onEdit={mockOnEdit} />\n      );\n\n      expect(screen.getByTestId('provider-type')).toHaveTextContent('telegram');\n    });\n\n    it('shows enabled status', () => {\n      const provider = createMockProvider({ enabled: true });\n      render(\n        <NotificationProviderCard provider={provider} onEdit={mockOnEdit} />\n      );\n\n      expect(screen.getByTestId('provider-enabled')).toHaveTextContent(\n        'Enabled'\n      );\n    });\n\n    it('shows disabled status', () => {\n      const provider = createMockProvider({ enabled: false });\n      render(\n        <NotificationProviderCard provider={provider} onEdit={mockOnEdit} />\n      );\n\n      expect(screen.getByTestId('provider-enabled')).toHaveTextContent(\n        'Disabled'\n      );\n    });\n  });\n\n  describe('event toggles', () => {\n    it('shows on_print_start correctly when enabled', () => {\n      const provider = createMockProvider({ on_print_start: true });\n      render(\n        <NotificationProviderCard provider={provider} onEdit={mockOnEdit} />\n      );\n\n      expect(screen.getByTestId('on-print-start')).toHaveTextContent('Yes');\n    });\n\n    it('shows on_print_start correctly when disabled', () => {\n      const provider = createMockProvider({ on_print_start: false });\n      render(\n        <NotificationProviderCard provider={provider} onEdit={mockOnEdit} />\n      );\n\n      expect(screen.getByTestId('on-print-start')).toHaveTextContent('No');\n    });\n\n    it('shows on_print_complete correctly', () => {\n      const provider = createMockProvider({ on_print_complete: true });\n      render(\n        <NotificationProviderCard provider={provider} onEdit={mockOnEdit} />\n      );\n\n      expect(screen.getByTestId('on-print-complete')).toHaveTextContent('Yes');\n    });\n  });\n\n  describe('edit functionality', () => {\n    it('calls onEdit when edit button is clicked', async () => {\n      const user = userEvent.setup();\n      const provider = createMockProvider();\n      render(\n        <NotificationProviderCard provider={provider} onEdit={mockOnEdit} />\n      );\n\n      await user.click(screen.getByRole('button', { name: /edit/i }));\n\n      expect(mockOnEdit).toHaveBeenCalled();\n    });\n  });\n});\n\ndescribe('NotificationProviderCard AMS toggles', () => {\n  describe('AMS humidity notifications', () => {\n    it('includes on_ams_humidity_high in provider data', () => {\n      const provider = createMockProvider({ on_ams_humidity_high: true });\n\n      expect(provider.on_ams_humidity_high).toBe(true);\n    });\n\n    it('includes on_ams_humidity_high when disabled', () => {\n      const provider = createMockProvider({ on_ams_humidity_high: false });\n\n      expect(provider.on_ams_humidity_high).toBe(false);\n    });\n  });\n\n  describe('AMS temperature notifications', () => {\n    it('includes on_ams_temperature_high in provider data', () => {\n      const provider = createMockProvider({ on_ams_temperature_high: true });\n\n      expect(provider.on_ams_temperature_high).toBe(true);\n    });\n\n    it('includes on_ams_temperature_high when disabled', () => {\n      const provider = createMockProvider({ on_ams_temperature_high: false });\n\n      expect(provider.on_ams_temperature_high).toBe(false);\n    });\n  });\n\n  describe('AMS-HT humidity notifications (separate from AMS)', () => {\n    it('includes on_ams_ht_humidity_high in provider data', () => {\n      const provider = createMockProvider({ on_ams_ht_humidity_high: true });\n\n      expect(provider.on_ams_ht_humidity_high).toBe(true);\n    });\n\n    it('AMS and AMS-HT humidity toggles are independent', () => {\n      const provider = createMockProvider({\n        on_ams_humidity_high: true,\n        on_ams_ht_humidity_high: false,\n      });\n\n      expect(provider.on_ams_humidity_high).toBe(true);\n      expect(provider.on_ams_ht_humidity_high).toBe(false);\n    });\n\n    it('can enable both AMS and AMS-HT humidity notifications', () => {\n      const provider = createMockProvider({\n        on_ams_humidity_high: true,\n        on_ams_ht_humidity_high: true,\n      });\n\n      expect(provider.on_ams_humidity_high).toBe(true);\n      expect(provider.on_ams_ht_humidity_high).toBe(true);\n    });\n  });\n\n  describe('AMS-HT temperature notifications (separate from AMS)', () => {\n    it('includes on_ams_ht_temperature_high in provider data', () => {\n      const provider = createMockProvider({ on_ams_ht_temperature_high: true });\n\n      expect(provider.on_ams_ht_temperature_high).toBe(true);\n    });\n\n    it('AMS and AMS-HT temperature toggles are independent', () => {\n      const provider = createMockProvider({\n        on_ams_temperature_high: true,\n        on_ams_ht_temperature_high: false,\n      });\n\n      expect(provider.on_ams_temperature_high).toBe(true);\n      expect(provider.on_ams_ht_temperature_high).toBe(false);\n    });\n\n    it('can enable both AMS and AMS-HT temperature notifications', () => {\n      const provider = createMockProvider({\n        on_ams_temperature_high: true,\n        on_ams_ht_temperature_high: true,\n      });\n\n      expect(provider.on_ams_temperature_high).toBe(true);\n      expect(provider.on_ams_ht_temperature_high).toBe(true);\n    });\n  });\n\n  describe('all AMS notification combinations', () => {\n    it('supports all four AMS toggles independently', () => {\n      const provider = createMockProvider({\n        on_ams_humidity_high: true,\n        on_ams_temperature_high: false,\n        on_ams_ht_humidity_high: false,\n        on_ams_ht_temperature_high: true,\n      });\n\n      expect(provider.on_ams_humidity_high).toBe(true);\n      expect(provider.on_ams_temperature_high).toBe(false);\n      expect(provider.on_ams_ht_humidity_high).toBe(false);\n      expect(provider.on_ams_ht_temperature_high).toBe(true);\n    });\n\n    it('defaults all AMS toggles to false', () => {\n      const provider = createMockProvider();\n\n      expect(provider.on_ams_humidity_high).toBe(false);\n      expect(provider.on_ams_temperature_high).toBe(false);\n      expect(provider.on_ams_ht_humidity_high).toBe(false);\n      expect(provider.on_ams_ht_temperature_high).toBe(false);\n    });\n  });\n});\n\ndescribe('NotificationProviderCard Queue notifications', () => {\n  describe('queue job notifications', () => {\n    it('includes on_queue_job_added in provider data', () => {\n      const provider = createMockProvider({ on_queue_job_added: true });\n      expect(provider.on_queue_job_added).toBe(true);\n    });\n\n    it('includes on_queue_job_assigned in provider data', () => {\n      const provider = createMockProvider({ on_queue_job_assigned: true });\n      expect(provider.on_queue_job_assigned).toBe(true);\n    });\n\n    it('includes on_queue_job_started in provider data', () => {\n      const provider = createMockProvider({ on_queue_job_started: true });\n      expect(provider.on_queue_job_started).toBe(true);\n    });\n\n    it('includes on_queue_job_waiting in provider data', () => {\n      const provider = createMockProvider({ on_queue_job_waiting: true });\n      expect(provider.on_queue_job_waiting).toBe(true);\n    });\n\n    it('includes on_queue_job_skipped in provider data', () => {\n      const provider = createMockProvider({ on_queue_job_skipped: true });\n      expect(provider.on_queue_job_skipped).toBe(true);\n    });\n\n    it('includes on_queue_job_failed in provider data', () => {\n      const provider = createMockProvider({ on_queue_job_failed: true });\n      expect(provider.on_queue_job_failed).toBe(true);\n    });\n\n    it('includes on_queue_completed in provider data', () => {\n      const provider = createMockProvider({ on_queue_completed: true });\n      expect(provider.on_queue_completed).toBe(true);\n    });\n  });\n\n  describe('queue notification defaults', () => {\n    it('defaults actionable notifications to true', () => {\n      const provider = createMockProvider();\n      // These should default to true (actionable - user needs to do something)\n      expect(provider.on_queue_job_waiting).toBe(true);\n      expect(provider.on_queue_job_skipped).toBe(true);\n      expect(provider.on_queue_job_failed).toBe(true);\n    });\n\n    it('defaults informational notifications to false', () => {\n      const provider = createMockProvider();\n      // These should default to false (informational only)\n      expect(provider.on_queue_job_added).toBe(false);\n      expect(provider.on_queue_job_assigned).toBe(false);\n      expect(provider.on_queue_job_started).toBe(false);\n      expect(provider.on_queue_completed).toBe(false);\n    });\n  });\n\n  describe('queue notification combinations', () => {\n    it('supports all queue toggles independently', () => {\n      const provider = createMockProvider({\n        on_queue_job_added: true,\n        on_queue_job_assigned: false,\n        on_queue_job_started: true,\n        on_queue_job_waiting: false,\n        on_queue_job_skipped: true,\n        on_queue_job_failed: false,\n        on_queue_completed: true,\n      });\n\n      expect(provider.on_queue_job_added).toBe(true);\n      expect(provider.on_queue_job_assigned).toBe(false);\n      expect(provider.on_queue_job_started).toBe(true);\n      expect(provider.on_queue_job_waiting).toBe(false);\n      expect(provider.on_queue_job_skipped).toBe(true);\n      expect(provider.on_queue_job_failed).toBe(false);\n      expect(provider.on_queue_completed).toBe(true);\n    });\n  });\n});\n\ndescribe('NotificationProviderCard Bed Cooled notifications', () => {\n  describe('bed cooled toggle', () => {\n    it('includes on_bed_cooled in provider data when enabled', () => {\n      const provider = createMockProvider({ on_bed_cooled: true });\n      expect(provider.on_bed_cooled).toBe(true);\n    });\n\n    it('includes on_bed_cooled in provider data when disabled', () => {\n      const provider = createMockProvider({ on_bed_cooled: false });\n      expect(provider.on_bed_cooled).toBe(false);\n    });\n\n    it('defaults on_bed_cooled to false', () => {\n      const provider = createMockProvider();\n      expect(provider.on_bed_cooled).toBe(false);\n    });\n\n    it('bed cooled is independent from other print event toggles', () => {\n      const provider = createMockProvider({\n        on_print_complete: true,\n        on_bed_cooled: true,\n        on_plate_not_empty: false,\n      });\n\n      expect(provider.on_print_complete).toBe(true);\n      expect(provider.on_bed_cooled).toBe(true);\n      expect(provider.on_plate_not_empty).toBe(false);\n    });\n  });\n});\n\ndescribe('NotificationProviderCard First Layer Complete notifications', () => {\n  describe('first layer complete toggle', () => {\n    it('includes on_first_layer_complete in provider data when enabled', () => {\n      const provider = createMockProvider({ on_first_layer_complete: true });\n      expect(provider.on_first_layer_complete).toBe(true);\n    });\n\n    it('includes on_first_layer_complete in provider data when disabled', () => {\n      const provider = createMockProvider({ on_first_layer_complete: false });\n      expect(provider.on_first_layer_complete).toBe(false);\n    });\n\n    it('defaults on_first_layer_complete to false', () => {\n      const provider = createMockProvider();\n      expect(provider.on_first_layer_complete).toBe(false);\n    });\n\n    it('first layer complete is independent from other print event toggles', () => {\n      const provider = createMockProvider({\n        on_print_complete: true,\n        on_bed_cooled: true,\n        on_first_layer_complete: false,\n      });\n\n      expect(provider.on_print_complete).toBe(true);\n      expect(provider.on_bed_cooled).toBe(true);\n      expect(provider.on_first_layer_complete).toBe(false);\n    });\n\n    it('can enable first layer complete independently', () => {\n      const provider = createMockProvider({\n        on_print_complete: false,\n        on_bed_cooled: false,\n        on_first_layer_complete: true,\n      });\n\n      expect(provider.on_print_complete).toBe(false);\n      expect(provider.on_bed_cooled).toBe(false);\n      expect(provider.on_first_layer_complete).toBe(true);\n    });\n  });\n});\n\ndescribe('NotificationProviderCard Home Assistant provider', () => {\n  describe('homeassistant provider type', () => {\n    it('renders homeassistant provider type', () => {\n      const provider = createMockProvider({ provider_type: 'homeassistant' });\n      render(\n        <NotificationProviderCard provider={provider} onEdit={vi.fn()} />\n      );\n\n      expect(screen.getByTestId('provider-type')).toHaveTextContent('homeassistant');\n    });\n\n    it('creates homeassistant provider with empty config', () => {\n      const provider = createMockProvider({\n        provider_type: 'homeassistant',\n        config: {},\n      });\n\n      expect(provider.provider_type).toBe('homeassistant');\n      expect(provider.config).toEqual({});\n    });\n\n    it('homeassistant provider supports all event toggles', () => {\n      const provider = createMockProvider({\n        provider_type: 'homeassistant',\n        config: {},\n        on_print_complete: true,\n        on_print_failed: true,\n        on_filament_low: true,\n        on_queue_job_waiting: true,\n      });\n\n      expect(provider.on_print_complete).toBe(true);\n      expect(provider.on_print_failed).toBe(true);\n      expect(provider.on_filament_low).toBe(true);\n      expect(provider.on_queue_job_waiting).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/PrintModal.test.tsx",
    "content": "/**\n * Tests for the unified PrintModal component.\n *\n * The PrintModal supports three modes:\n * - 'reprint': Immediate print from archive (multi-printer support)\n * - 'add-to-queue': Schedule print to queue (multi-printer support)\n * - 'edit-queue-item': Edit existing queue item (single printer)\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { PrintModal } from '../../components/PrintModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\nimport type { PrintQueueItem } from '../../api/client';\n\nconst mockPrinters = [\n  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },\n  { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },\n  { id: 3, name: 'A1 Mini', model: 'A1M', ip_address: '192.168.1.102', enabled: true, is_active: true },\n];\n\nconst createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({\n  id: 1,\n  printer_id: 1,\n  archive_id: 1,\n  position: 1,\n  scheduled_time: null,\n  require_previous_success: false,\n  auto_off_after: false,\n  gcode_injection: false,\n  manual_start: false,\n  ams_mapping: null,\n  plate_id: null,\n  bed_levelling: true,\n  flow_cali: false,\n  vibration_cali: true,\n  layer_inspect: false,\n  timelapse: false,\n  use_ams: true,\n  status: 'pending',\n  started_at: null,\n  completed_at: null,\n  error_message: null,\n  created_at: '2024-01-01T00:00:00Z',\n  archive_name: 'Test Print',\n  archive_thumbnail: null,\n  printer_name: 'Test Printer',\n  print_time_seconds: 3600,\n  batch_id: null,\n  batch_name: null,\n  ...overrides,\n});\n\ndescribe('PrintModal', () => {\n  const mockOnClose = vi.fn();\n  const mockOnSuccess = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json(mockPrinters);\n      }),\n      http.get('/api/v1/archives/:id/plates', () => {\n        return HttpResponse.json({ is_multi_plate: false, plates: [] });\n      }),\n      http.get('/api/v1/archives/:id/filament-requirements', () => {\n        return HttpResponse.json({ filaments: [] });\n      }),\n      http.get('/api/v1/printers/:id/status', () => {\n        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });\n      }),\n      http.post('/api/v1/archives/:id/reprint', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.post('/api/v1/queue/', () => {\n        return HttpResponse.json({ id: 1, status: 'pending' });\n      }),\n      http.patch('/api/v1/queue/:id', () => {\n        return HttpResponse.json({ id: 1, status: 'pending' });\n      })\n    );\n  });\n\n  describe('reprint mode', () => {\n    it('renders the modal title', () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      expect(screen.getByText('Re-print')).toBeInTheDocument();\n    });\n\n    it('shows archive name', () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      expect(screen.getByText('Benchy')).toBeInTheDocument();\n    });\n\n    it('shows printer selection with checkboxes for multi-select', async () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.getByText('P1S')).toBeInTheDocument();\n      });\n    });\n\n    it('has print button', () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      // Get the submit button specifically (not printer selection buttons)\n      const submitButton = screen.getByRole('button', { name: /^print$/i });\n      expect(submitButton).toBeInTheDocument();\n    });\n\n    it('has cancel button', () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();\n    });\n\n    it('calls onClose when cancel is clicked', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      await user.click(screen.getByRole('button', { name: /cancel/i }));\n\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n\n    it('print button is disabled until printer is selected', () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      // Get the submit button specifically (not printer selection buttons)\n      const printButton = screen.getByRole('button', { name: /^print$/i });\n      expect(printButton).toBeDisabled();\n    });\n\n    it('shows no printers message when none active', async () => {\n      server.use(\n        http.get('/api/v1/printers/', () => {\n          return HttpResponse.json([]);\n        })\n      );\n\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('No active printers available')).toBeInTheDocument();\n      });\n    });\n\n    it('shows print options toggle', () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      expect(screen.getByText('Print Options')).toBeInTheDocument();\n    });\n  });\n\n  describe('add-to-queue mode', () => {\n    it('renders the modal title', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Schedule Print')).toBeInTheDocument();\n    });\n\n    it('shows archive name', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Test Print')).toBeInTheDocument();\n    });\n\n    it('shows add button', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();\n    });\n\n    it('shows cancel button', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();\n    });\n\n    it('shows Queue Only option', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Queue Only')).toBeInTheDocument();\n    });\n\n    it('shows power off option', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText(/power off/i)).toBeInTheDocument();\n    });\n\n    it('shows schedule options', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('ASAP')).toBeInTheDocument();\n      expect(screen.getByText('Scheduled')).toBeInTheDocument();\n    });\n\n    it('calls onClose when cancel is clicked', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await user.click(screen.getByRole('button', { name: /cancel/i }));\n\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n  });\n\n  describe('edit-queue-item mode', () => {\n    it('renders the modal title', () => {\n      const item = createMockQueueItem();\n\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          queueItem={item}\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();\n    });\n\n    it('shows save button', () => {\n      const item = createMockQueueItem();\n\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          queueItem={item}\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();\n    });\n\n    it('shows cancel button', () => {\n      const item = createMockQueueItem();\n\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          queueItem={item}\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();\n    });\n\n    it('shows print options toggle', () => {\n      const item = createMockQueueItem();\n\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          queueItem={item}\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Print Options')).toBeInTheDocument();\n    });\n\n    it('shows Queue Only option', () => {\n      const item = createMockQueueItem();\n\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          queueItem={item}\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText('Queue Only')).toBeInTheDocument();\n    });\n\n    it('shows power off option', () => {\n      const item = createMockQueueItem();\n\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          queueItem={item}\n          onClose={mockOnClose}\n        />\n      );\n\n      expect(screen.getByText(/power off/i)).toBeInTheDocument();\n    });\n\n    it('calls onClose when cancel button is clicked', async () => {\n      const user = userEvent.setup();\n      const item = createMockQueueItem();\n\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          queueItem={item}\n          onClose={mockOnClose}\n        />\n      );\n\n      const cancelButton = screen.getByRole('button', { name: /cancel/i });\n      await user.click(cancelButton);\n\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n\n    it('shows printer selector for single selection', async () => {\n      const item = createMockQueueItem();\n\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          queueItem={item}\n          onClose={mockOnClose}\n        />\n      );\n\n      // PrinterSelector shows printer names directly\n      await waitFor(() => {\n        expect(screen.getByText('P1S')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('multi-printer selection', () => {\n    it('shows select all button when multiple printers available', async () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n      });\n    });\n\n    it('shows selected count when multiple printers selected', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select all'));\n\n      await waitFor(() => {\n        expect(screen.getByText(/3 printers selected/)).toBeInTheDocument();\n      });\n    });\n\n    it('updates button text when multiple printers selected', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select all'));\n\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /print to 3 printers/i })).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('busy printer handling (#622)', () => {\n    beforeEach(() => {\n      // Set up per-printer statuses: printer 1 RUNNING, printer 2 IDLE, printer 3 FINISH\n      server.use(\n        http.get('/api/v1/printers/:id/status', ({ params }) => {\n          const id = Number(params.id);\n          if (id === 1) {\n            return HttpResponse.json({\n              connected: true, state: 'RUNNING', stg_cur_name: null,\n              ams: [], vt_tray: [], nozzles: [],\n            });\n          }\n          if (id === 2) {\n            return HttpResponse.json({\n              connected: true, state: 'IDLE', stg_cur_name: null,\n              ams: [], vt_tray: [], nozzles: [],\n            });\n          }\n          // printer 3\n          return HttpResponse.json({\n            connected: true, state: 'FINISH', stg_cur_name: null,\n            ams: [], vt_tray: [], nozzles: [],\n          });\n        })\n      );\n    });\n\n    it('shows state badges on printers in reprint mode', async () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Printing')).toBeInTheDocument();\n        expect(screen.getByText('Idle')).toBeInTheDocument();\n        expect(screen.getByText('Finished')).toBeInTheDocument();\n      });\n    });\n\n    it('prevents selecting a busy printer in reprint mode', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Printing')).toBeInTheDocument();\n      });\n\n      // The busy printer button should be disabled\n      const busyButton = screen.getByText('X1 Carbon').closest('button');\n      expect(busyButton).toBeDisabled();\n\n      // Click the busy printer — selection should not change\n      await user.click(busyButton!);\n\n      // Idle printer should still be selectable\n      const idleButton = screen.getByText('P1S').closest('button');\n      expect(idleButton).not.toBeDisabled();\n      await user.click(idleButton!);\n\n      await waitFor(() => {\n        expect(screen.getByText('1 printer selected')).toBeInTheDocument();\n      });\n    });\n\n    it('select all skips busy printers in reprint mode', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n        expect(screen.getByText('Printing')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select all'));\n\n      await waitFor(() => {\n        // Only 2 available printers selected (IDLE + FINISH), not the RUNNING one\n        expect(screen.getByText(/2 printers selected/)).toBeInTheDocument();\n      });\n    });\n\n    it('allows selecting busy printers in add-to-queue mode', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Printing')).toBeInTheDocument();\n      });\n\n      // The busy printer button should NOT be disabled in queue mode\n      const busyButton = screen.getByText('X1 Carbon').closest('button');\n      expect(busyButton).not.toBeDisabled();\n\n      await user.click(busyButton!);\n\n      await waitFor(() => {\n        expect(screen.getByText('1 printer selected')).toBeInTheDocument();\n      });\n    });\n\n    it('shows Offline badge for disconnected printers', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({\n            connected: false, state: null, stg_cur_name: null,\n            ams: [], vt_tray: [], nozzles: [],\n          });\n        })\n      );\n\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        const offlineBadges = screen.getAllByText('Offline');\n        expect(offlineBadges.length).toBeGreaterThanOrEqual(1);\n      });\n    });\n\n    it('shows calibration stage name when printer is calibrating', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({\n            connected: true, state: 'RUNNING', stg_cur_name: 'Auto bed leveling',\n            ams: [], vt_tray: [], nozzles: [],\n          });\n        })\n      );\n\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        const badges = screen.getAllByText('Auto bed leveling');\n        expect(badges.length).toBeGreaterThanOrEqual(1);\n      });\n    });\n  });\n\n  describe('stagger start', () => {\n    it('does not show stagger option with single printer in queue mode', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Select single printer\n      await user.click(screen.getByText('X1 Carbon'));\n\n      expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();\n    });\n\n    it('shows stagger option when multiple printers selected in queue mode', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select all'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();\n      });\n    });\n\n    it('shows stagger option in reprint mode with multiple printers', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select all'));\n\n      await waitFor(() => {\n        expect(screen.getByText(/2 printers selected|3 printers selected/)).toBeInTheDocument();\n      });\n\n      expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();\n    });\n\n    it('shows stagger preview in reprint mode when enabled', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select all'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByLabelText('Stagger printer starts'));\n\n      await waitFor(() => {\n        // Default: 3 printers, group size 2 = 2 groups — preview text shown\n        expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();\n      });\n    });\n\n    it('does not show stagger option in reprint mode with single printer', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Select only one printer\n      await user.click(screen.getByText('X1 Carbon'));\n\n      expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();\n    });\n\n    it('shows stagger inputs when stagger checkbox is enabled', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select all'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByLabelText('Stagger printer starts'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Group size')).toBeInTheDocument();\n        expect(screen.getByText('Interval (min)')).toBeInTheDocument();\n      });\n    });\n\n    it('shows stagger preview with printer count', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Test Print\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select all')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select all'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByLabelText('Stagger printer starts'));\n\n      await waitFor(() => {\n        // Default: 3 printers, group size 2 = 2 groups — preview text includes printer count\n        expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('multi-plate selection', () => {\n    const multiPlateResponse = {\n      is_multi_plate: true,\n      plates: [\n        { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['Part A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },\n        { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['Part B'], filaments: [{ type: 'PLA', color: '#00FF00' }], print_time_seconds: 2400, filament_used_grams: 60 },\n        { index: 3, name: 'Plate 3', has_thumbnail: false, thumbnail_url: null, objects: ['Part C'], filaments: [{ type: 'PETG', color: '#0000FF' }], print_time_seconds: 3000, filament_used_grams: 70 },\n      ],\n    };\n\n    beforeEach(() => {\n      server.use(\n        http.get('/api/v1/archives/:id/plates', () => {\n          return HttpResponse.json(multiPlateResponse);\n        }),\n      );\n    });\n\n    it('shows \"Select All\" button only in add-to-queue mode', async () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"MultiPlate.3mf\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();\n      });\n    });\n\n    it('does not show \"Select All\" button in reprint mode', async () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"MultiPlate.3mf\"\n          initialSelectedPrinterIds={[1]}\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Plate 1')).toBeInTheDocument();\n      });\n      expect(screen.queryByText('Select All 3 Plates')).not.toBeInTheDocument();\n    });\n\n    it('selects all plates when \"Select All\" is clicked', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"MultiPlate.3mf\"\n          onClose={mockOnClose}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select All 3 Plates'));\n\n      // All plates should be highlighted (green border)\n      await waitFor(() => {\n        const plateButtons = document.querySelectorAll('button[type=\"button\"].border-bambu-green');\n        // 3 plate buttons + the \"Deselect All\" toggle button = 4 green-bordered buttons\n        expect(plateButtons.length).toBeGreaterThanOrEqual(3);\n      });\n    });\n\n    it('allows selecting a subset of plates to queue', async () => {\n      const queueRequests: unknown[] = [];\n      server.use(\n        http.post('/api/v1/queue/', async ({ request }) => {\n          const body = await request.json();\n          queueRequests.push(body);\n          return HttpResponse.json({ id: queueRequests.length, status: 'pending' });\n        }),\n      );\n\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"MultiPlate.3mf\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      // Wait for plates and select a printer\n      await waitFor(() => {\n        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Select printer\n      await user.click(screen.getByText('X1 Carbon'));\n\n      // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in add-to-queue mode)\n      await user.click(screen.getByText('Plate 3'));\n\n      // Submit — should queue plates 1 and 3\n      const submitButton = document.querySelector('button[type=\"submit\"]') as HTMLElement;\n      await user.click(submitButton);\n\n      await waitFor(() => {\n        expect(queueRequests.length).toBe(2);\n      });\n\n      expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);\n      expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(3);\n    });\n\n    it('creates one queue item per plate when submitting with select-all', async () => {\n      const queueRequests: unknown[] = [];\n      server.use(\n        http.post('/api/v1/queue/', async ({ request }) => {\n          const body = await request.json();\n          queueRequests.push(body);\n          return HttpResponse.json({ id: queueRequests.length, status: 'pending' });\n        }),\n      );\n\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"MultiPlate.3mf\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      // Wait for plates and select a printer\n      await waitFor(() => {\n        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Select printer\n      await user.click(screen.getByText('X1 Carbon'));\n\n      // Click select all\n      await user.click(screen.getByText('Select All 3 Plates'));\n\n      // Find the submit button (type=\"submit\") — distinct from the toggle button (type=\"button\")\n      const submitButton = document.querySelector('button[type=\"submit\"]') as HTMLElement;\n      await user.click(submitButton);\n\n      await waitFor(() => {\n        expect(queueRequests.length).toBe(3);\n      });\n\n      // Verify each request has the correct plate_id\n      expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);\n      expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(2);\n      expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);\n    });\n  });\n\n  describe('batch quantity', () => {\n    it('shows quantity input in reprint mode', () => {\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      expect(screen.getByLabelText('Quantity')).toBeInTheDocument();\n    });\n\n    it('shows quantity input in add-to-queue mode', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      expect(screen.getByLabelText('Quantity')).toBeInTheDocument();\n    });\n\n    it('does not show quantity input in edit-queue-item mode', () => {\n      render(\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          queueItem={createMockQueueItem()}\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument();\n    });\n\n    it('defaults quantity to 1', () => {\n      render(\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      const input = screen.getByLabelText('Quantity') as HTMLInputElement;\n      expect(input.value).toBe('1');\n    });\n\n    it('quantity input has default value of 1 and accepts changes', async () => {\n      const user = userEvent.setup();\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          initialSelectedPrinterIds={[1]}\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      const input = screen.getByLabelText('Quantity') as HTMLInputElement;\n      expect(input.value).toBe('1');\n\n      await user.tripleClick(input);\n      await user.keyboard('5');\n      expect(input.value).toBe('5');\n    });\n  });\n\n  describe('project_id forwarding', () => {\n    beforeEach(() => {\n      // Additional handlers needed for library file mode\n      server.use(\n        http.get('/api/v1/library/files/:id', () => {\n          return HttpResponse.json({\n            id: 5,\n            filename: 'benchy.gcode.3mf',\n            print_name: null,\n            file_type: '3mf',\n            folder_id: null,\n            project_id: null,\n            file_hash: null,\n            file_size_bytes: 1024,\n            thumbnail_path: null,\n            created_at: '2024-01-01T00:00:00Z',\n            updated_at: '2024-01-01T00:00:00Z',\n          });\n        }),\n        http.get('/api/v1/library/files/:id/plates', () => {\n          return HttpResponse.json({ is_multi_plate: false, plates: [] });\n        }),\n        http.get('/api/v1/library/files/:id/filament-requirements', () => {\n          return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });\n        }),\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });\n        }),\n      );\n    });\n\n    it('includes project_id in printLibraryFile call when projectId prop is set', async () => {\n      let capturedBody: Record<string, unknown> | null = null;\n      server.use(\n        http.post('/api/v1/library/files/:id/print', async ({ request }) => {\n          capturedBody = await request.json() as Record<string, unknown>;\n          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });\n        })\n      );\n      const user = userEvent.setup();\n\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          libraryFileId={5}\n          archiveName=\"Benchy\"\n          projectId={42}\n          initialSelectedPrinterIds={[1]}\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      // Wait for the modal to load printer and file data\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByRole('button', { name: /^print$/i }));\n\n      await waitFor(() => {\n        expect(capturedBody).not.toBeNull();\n        expect(capturedBody?.project_id).toBe(42);\n      });\n    });\n\n    it('does NOT include project_id in reprintArchive call (archives carry their own project association)', async () => {\n      // The reprintArchive branch omits project_id by design — archives already carry\n      // their project association from the original print. This test guards that intent.\n      let capturedBody: Record<string, unknown> | null = null;\n      server.use(\n        http.post('/api/v1/archives/:id/reprint', async ({ request }) => {\n          capturedBody = await request.json() as Record<string, unknown>;\n          return HttpResponse.json({ status: 'dispatched' });\n        })\n      );\n      const user = userEvent.setup();\n\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={1}\n          archiveName=\"Benchy\"\n          projectId={42}\n          initialSelectedPrinterIds={[1]}\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByRole('button', { name: /^print$/i }));\n\n      await waitFor(() => {\n        expect(capturedBody).not.toBeNull();\n        expect(capturedBody).not.toHaveProperty('project_id');\n      });\n    });\n  });\n\n  describe('cleanup_library_after_dispatch forwarding (#730)', () => {\n    // The Printers-page Direct-Print flow passes cleanupLibraryAfterDispatch so the\n    // transient LibraryFile created by FileUploadModal is deleted once the archive\n    // owns its own copy. File Manager / Project Detail flows leave the prop unset so\n    // their deliberately-added library entries survive the print.\n    beforeEach(() => {\n      server.use(\n        http.get('/api/v1/library/files/:id', () => {\n          return HttpResponse.json({\n            id: 5,\n            filename: 'benchy.gcode.3mf',\n            file_type: '3mf',\n            folder_id: null,\n            project_id: null,\n            file_hash: null,\n            file_size_bytes: 1024,\n            thumbnail_path: null,\n            created_at: '2024-01-01T00:00:00Z',\n            updated_at: '2024-01-01T00:00:00Z',\n          });\n        }),\n        http.get('/api/v1/library/files/:id/plates', () => {\n          return HttpResponse.json({ is_multi_plate: false, plates: [] });\n        }),\n        http.get('/api/v1/library/files/:id/filament-requirements', () => {\n          return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] });\n        }),\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });\n        }),\n      );\n    });\n\n    it('forwards cleanup_library_after_dispatch=true when the Direct-Print prop is set', async () => {\n      let capturedBody: Record<string, unknown> | null = null;\n      server.use(\n        http.post('/api/v1/library/files/:id/print', async ({ request }) => {\n          capturedBody = (await request.json()) as Record<string, unknown>;\n          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });\n        })\n      );\n      const user = userEvent.setup();\n\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          libraryFileId={5}\n          archiveName=\"Benchy\"\n          cleanupLibraryAfterDispatch\n          initialSelectedPrinterIds={[1]}\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByRole('button', { name: /^print$/i }));\n\n      await waitFor(() => {\n        expect(capturedBody).not.toBeNull();\n        expect(capturedBody?.cleanup_library_after_dispatch).toBe(true);\n      });\n    });\n\n    it('defaults to omitting cleanup_library_after_dispatch (File Manager / Project flows survive)', async () => {\n      let capturedBody: Record<string, unknown> | null = null;\n      server.use(\n        http.post('/api/v1/library/files/:id/print', async ({ request }) => {\n          capturedBody = (await request.json()) as Record<string, unknown>;\n          return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 });\n        })\n      );\n      const user = userEvent.setup();\n\n      render(\n        <PrintModal\n          mode=\"reprint\"\n          libraryFileId={5}\n          archiveName=\"Benchy\"\n          initialSelectedPrinterIds={[1]}\n          onClose={mockOnClose}\n          onSuccess={mockOnSuccess}\n        />\n      );\n\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByRole('button', { name: /^print$/i }));\n\n      await waitFor(() => {\n        expect(capturedBody).not.toBeNull();\n      });\n      // Either omitted entirely or explicitly undefined — both interpret as \"keep file\"\n      expect(capturedBody?.cleanup_library_after_dispatch).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/PrintModalDispatchToast.test.tsx",
    "content": "/**\n * Test that reprint mode does not show the \"Print queued for printer\" toast.\n * The background dispatch websocket toast handles feedback instead.\n *\n * Separate file because vi.mock(ToastContext) must be module-scoped\n * and would interfere with the main PrintModal test suite.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\n// Mock the toast context before importing the component\nconst mockShowToast = vi.fn();\nvi.mock('../../contexts/ToastContext', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();\n  return {\n    ...actual,\n    useToast: () => ({ showToast: mockShowToast }),\n  };\n});\n\nimport { render } from '../utils';\nimport { PrintModal } from '../../components/PrintModal';\n\nconst mockPrinters = [\n  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },\n];\n\ndescribe('PrintModal dispatch toast', () => {\n  const mockOnClose = vi.fn();\n  const mockOnSuccess = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json(mockPrinters);\n      }),\n      http.get('/api/v1/archives/:id/plates', () => {\n        return HttpResponse.json({ is_multi_plate: false, plates: [] });\n      }),\n      http.get('/api/v1/archives/:id/filament-requirements', () => {\n        return HttpResponse.json({ filaments: [] });\n      }),\n      http.get('/api/v1/printers/:id/status', () => {\n        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });\n      }),\n      http.post('/api/v1/archives/:id/reprint', () => {\n        return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 1 });\n      }),\n    );\n  });\n\n  it('does not show \"queued\" toast in reprint mode (dispatch toast handles it)', async () => {\n    const user = userEvent.setup();\n    render(\n      <PrintModal\n        mode=\"reprint\"\n        archiveId={1}\n        archiveName=\"Benchy\"\n        onClose={mockOnClose}\n        onSuccess={mockOnSuccess}\n      />\n    );\n\n    // Wait for printers to load, then select one\n    await waitFor(() => {\n      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n    });\n    await user.click(screen.getByText('X1 Carbon'));\n\n    // Submit the print\n    const printButton = screen.getByRole('button', { name: /^print$/i });\n    await user.click(printButton);\n\n    // Wait for the API call to complete and modal to close\n    await waitFor(() => {\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n\n    // showToast should NOT have been called with \"Print queued for printer\"\n    const toastMessages = mockShowToast.mock.calls.map(call => call[0]);\n    expect(toastMessages).not.toContain('Print queued for printer');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/PrinterQueueWidget.test.tsx",
    "content": "/**\n * Tests for the PrinterQueueWidget component.\n *\n * This is a compact widget that shows \"Next in queue\" with the first pending\n * item's name and a \"+N\" badge if there are more items. Returns null when empty.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { PrinterQueueWidget } from '../../components/PrinterQueueWidget';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockQueueItems = [\n  {\n    id: 1,\n    printer_id: 1,\n    archive_id: 1,\n    position: 1,\n    status: 'pending',\n    archive_name: 'First Print',\n    printer_name: 'X1 Carbon',\n    print_time_seconds: 3600,\n    scheduled_time: null,\n  },\n  {\n    id: 2,\n    printer_id: 1,\n    archive_id: 2,\n    position: 2,\n    status: 'pending',\n    archive_name: 'Second Print',\n    printer_name: 'X1 Carbon',\n    print_time_seconds: 7200,\n    scheduled_time: null,\n  },\n];\n\ndescribe('PrinterQueueWidget', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/queue/', ({ request }) => {\n        const url = new URL(request.url);\n        const printerId = url.searchParams.get('printer_id');\n        if (printerId === '1') {\n          return HttpResponse.json(mockQueueItems);\n        }\n        return HttpResponse.json([]);\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('shows next in queue label', async () => {\n      render(<PrinterQueueWidget printerId={1} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Next in queue')).toBeInTheDocument();\n      });\n    });\n\n    it('shows first pending item name', async () => {\n      render(<PrinterQueueWidget printerId={1} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('First Print')).toBeInTheDocument();\n      });\n    });\n\n    it('shows additional items badge when multiple pending', async () => {\n      render(<PrinterQueueWidget printerId={1} />);\n\n      await waitFor(() => {\n        // Shows \"+1\" badge since there are 2 items\n        expect(screen.getByText('+1')).toBeInTheDocument();\n      });\n    });\n\n    it('shows Waiting for unscheduled items', async () => {\n      render(<PrinterQueueWidget printerId={1} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Waiting')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('empty state', () => {\n    it('renders nothing when no pending items', async () => {\n      const { container } = render(<PrinterQueueWidget printerId={999} />);\n\n      // Wait for query to resolve\n      await waitFor(() => {\n        // Widget returns null when empty, so container should have no visible widget\n        expect(container.querySelector('a[href=\"/queue\"]')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('single item', () => {\n    it('does not show badge when only one item', async () => {\n      server.use(\n        http.get('/api/v1/queue/', () => {\n          return HttpResponse.json([mockQueueItems[0]]);\n        })\n      );\n\n      render(<PrinterQueueWidget printerId={1} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('First Print')).toBeInTheDocument();\n      });\n\n      // Should not have a \"+N\" badge\n      expect(screen.queryByText(/^\\+\\d+$/)).not.toBeInTheDocument();\n    });\n  });\n\n  describe('link behavior', () => {\n    it('links to queue page', async () => {\n      render(<PrinterQueueWidget printerId={1} />);\n\n      await waitFor(() => {\n        const link = screen.getByRole('link');\n        expect(link).toHaveAttribute('href', '/queue');\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/PrinterSelector.test.ts",
    "content": "/**\n * Tests for InlineMappingEditor auto-match and nozzle filtering logic.\n *\n * Regression test for #624: multi-printer filament mapping showed filaments\n * from both nozzles on dual-nozzle printers (H2D). The single-printer path\n * (FilamentMapping.tsx) was fixed in commit 29e9593 but the multi-printer\n * path (InlineMappingEditor in PrinterSelector.tsx) was missed.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport {\n  autoMatchFilament,\n  canonicalFilamentType,\n  filamentTypesCompatible,\n  filterFilamentsByNozzle,\n} from '../../utils/amsHelpers';\nimport type { LoadedFilament, FilamentRequirement } from '../../hooks/useFilamentMapping';\n\n// -- helpers -----------------------------------------------------------------\n\nfunction makeFilament(overrides: Partial<LoadedFilament> & { globalTrayId: number }): LoadedFilament {\n  return {\n    type: 'PLA',\n    color: '#FFFFFF',\n    colorName: 'White',\n    amsId: 0,\n    trayId: 0,\n    isHt: false,\n    isExternal: false,\n    label: 'AMS1-T1',\n    trayInfoIdx: '',\n    extruderId: undefined,\n    remain: -1,\n    ...overrides,\n  };\n}\n\nfunction makeReq(overrides: Partial<FilamentRequirement> = {}): FilamentRequirement {\n  return {\n    slot_id: 1,\n    type: 'PLA',\n    color: '#FFFFFF',\n    used_grams: 10,\n    ...overrides,\n  };\n}\n\n// Dual-nozzle H2D-like setup:\n// Left nozzle (extruderId=1): AMS0 with PLA Black (tray 0) and PETG White (tray 1)\n// Right nozzle (extruderId=0): AMS1 with PLA White (tray 4) and PLA Red (tray 5)\nconst H2D_FILAMENTS: LoadedFilament[] = [\n  makeFilament({ globalTrayId: 0, type: 'PLA', color: '#000000', colorName: 'Black', amsId: 0, trayId: 0, label: 'AMS1-T1', extruderId: 1 }),\n  makeFilament({ globalTrayId: 1, type: 'PETG', color: '#FFFFFF', colorName: 'White', amsId: 0, trayId: 1, label: 'AMS1-T2', extruderId: 1 }),\n  makeFilament({ globalTrayId: 4, type: 'PLA', color: '#FFFFFF', colorName: 'White', amsId: 1, trayId: 0, label: 'AMS2-T1', extruderId: 0 }),\n  makeFilament({ globalTrayId: 5, type: 'PLA', color: '#FF0000', colorName: 'Red', amsId: 1, trayId: 1, label: 'AMS2-T2', extruderId: 0 }),\n];\n\n// -- canonicalFilamentType / filamentTypesCompatible -------------------------\n\ndescribe('canonicalFilamentType', () => {\n  it('maps PA-CF variants to the same canonical type', () => {\n    const canonical = canonicalFilamentType('PA-CF');\n    expect(canonicalFilamentType('PA12-CF')).toBe(canonical);\n    expect(canonicalFilamentType('PAHT-CF')).toBe(canonical);\n  });\n\n  it('is case-insensitive', () => {\n    expect(canonicalFilamentType('pa-cf')).toBe(canonicalFilamentType('PA-CF'));\n    expect(canonicalFilamentType('Pa12-Cf')).toBe(canonicalFilamentType('PA12-CF'));\n  });\n\n  it('returns the type unchanged for non-equivalent types', () => {\n    expect(canonicalFilamentType('PLA')).toBe('PLA');\n    expect(canonicalFilamentType('PETG')).toBe('PETG');\n    expect(canonicalFilamentType('ABS')).toBe('ABS');\n  });\n\n  it('returns empty string for undefined/empty input', () => {\n    expect(canonicalFilamentType(undefined)).toBe('');\n    expect(canonicalFilamentType('')).toBe('');\n  });\n});\n\ndescribe('filamentTypesCompatible', () => {\n  it('treats PA-CF and PA12-CF as compatible', () => {\n    expect(filamentTypesCompatible('PA-CF', 'PA12-CF')).toBe(true);\n  });\n\n  it('treats PA-CF and PAHT-CF as compatible', () => {\n    expect(filamentTypesCompatible('PA-CF', 'PAHT-CF')).toBe(true);\n  });\n\n  it('treats PLA and PETG as incompatible', () => {\n    expect(filamentTypesCompatible('PLA', 'PETG')).toBe(false);\n  });\n\n  it('treats same types as compatible', () => {\n    expect(filamentTypesCompatible('PLA', 'PLA')).toBe(true);\n  });\n});\n\n// -- filterFilamentsByNozzle -------------------------------------------------\n\ndescribe('filterFilamentsByNozzle', () => {\n  it('returns all filaments when nozzle_id is null', () => {\n    const result = filterFilamentsByNozzle(H2D_FILAMENTS, null);\n    expect(result).toHaveLength(4);\n  });\n\n  it('returns all filaments when nozzle_id is undefined', () => {\n    const result = filterFilamentsByNozzle(H2D_FILAMENTS, undefined);\n    expect(result).toHaveLength(4);\n  });\n\n  it('filters to left nozzle (extruderId=1)', () => {\n    const result = filterFilamentsByNozzle(H2D_FILAMENTS, 1);\n    expect(result).toHaveLength(2);\n    expect(result.every((f) => f.extruderId === 1)).toBe(true);\n  });\n\n  it('filters to right nozzle (extruderId=0)', () => {\n    const result = filterFilamentsByNozzle(H2D_FILAMENTS, 0);\n    expect(result).toHaveLength(2);\n    expect(result.every((f) => f.extruderId === 0)).toBe(true);\n  });\n});\n\n// -- autoMatchFilament -------------------------------------------------------\n\ndescribe('autoMatchFilament', () => {\n  it('matches exact type+color on correct nozzle', () => {\n    const req = makeReq({ type: 'PLA', color: '#FFFFFF', nozzle_id: 0 });\n    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(4); // AMS2-T1 on right nozzle\n  });\n\n  it('does NOT match filament on wrong nozzle — regression #624', () => {\n    // Require PLA Black on right nozzle (extruderId=0).\n    // PLA Black exists only on left nozzle (tray 0, extruderId=1).\n    const req = makeReq({ type: 'PLA', color: '#000000', nozzle_id: 0 });\n    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());\n    // Should NOT match tray 0 (wrong nozzle). May match tray 4 or 5 as type-only.\n    if (result) {\n      expect(result.extruderId).toBe(0);\n      expect(result.globalTrayId).not.toBe(0);\n    }\n  });\n\n  it('matches without nozzle constraint for single-nozzle printers', () => {\n    const req = makeReq({ type: 'PLA', color: '#000000' }); // no nozzle_id\n    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(0); // Exact match: PLA Black\n  });\n\n  it('falls back to type-only match on correct nozzle', () => {\n    // Require PETG Green on left nozzle — no exact color match, but PETG White exists\n    const req = makeReq({ type: 'PETG', color: '#00FF00', nozzle_id: 1 });\n    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(1); // PETG White on left nozzle\n    expect(result!.extruderId).toBe(1);\n  });\n\n  it('returns undefined when no filament matches on required nozzle', () => {\n    // Require PETG on right nozzle — PETG only exists on left nozzle\n    const req = makeReq({ type: 'PETG', color: '#FFFFFF', nozzle_id: 0 });\n    const result = autoMatchFilament(req, H2D_FILAMENTS, new Set());\n    expect(result).toBeUndefined();\n  });\n\n  it('skips already-used tray IDs', () => {\n    const req = makeReq({ type: 'PLA', color: '#FFFFFF', nozzle_id: 0 });\n    const used = new Set([4]); // AMS2-T1 already used\n    const result = autoMatchFilament(req, H2D_FILAMENTS, used);\n    // Should fall back to PLA Red (tray 5) as type-only match\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(5);\n  });\n\n  it('matches PA-CF requirement to PA12-CF filament — #688', () => {\n    const filaments: LoadedFilament[] = [\n      makeFilament({ globalTrayId: 0, type: 'PA12-CF', color: '#000000', colorName: 'Black' }),\n    ];\n    const req = makeReq({ type: 'PA-CF', color: '#000000' });\n    const result = autoMatchFilament(req, filaments, new Set());\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(0);\n  });\n\n  it('matches PAHT-CF requirement to PA-CF filament — #688', () => {\n    const filaments: LoadedFilament[] = [\n      makeFilament({ globalTrayId: 0, type: 'PA-CF', color: '#333333', colorName: 'Dark Gray' }),\n    ];\n    const req = makeReq({ type: 'PAHT-CF', color: '#333333' });\n    const result = autoMatchFilament(req, filaments, new Set());\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(0);\n  });\n});\n\n// -- autoMatchFilament with preferLowest ------------------------------------\n\ndescribe('autoMatchFilament preferLowest', () => {\n  it('picks spool with lowest remain when enabled', () => {\n    const filaments = [\n      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }),\n      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }),\n    ];\n    const req = makeReq({ type: 'PLA', color: '#FF0000' });\n    const result = autoMatchFilament(req, filaments, new Set(), true);\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(1); // 30% < 80%\n  });\n\n  it('picks first spool when disabled (default behavior)', () => {\n    const filaments = [\n      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }),\n      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }),\n    ];\n    const req = makeReq({ type: 'PLA', color: '#FF0000' });\n    const result = autoMatchFilament(req, filaments, new Set(), false);\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(0); // First match\n  });\n\n  it('sorts unknown remain (-1) to end', () => {\n    const filaments = [\n      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: -1 }),\n      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 50 }),\n    ];\n    const req = makeReq({ type: 'PLA', color: '#FF0000' });\n    const result = autoMatchFilament(req, filaments, new Set(), true);\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(1); // Known 50% over unknown\n  });\n\n  it('still respects nozzle constraint with preferLowest', () => {\n    const filaments = [\n      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 10, extruderId: 1 }),\n      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80, extruderId: 0 }),\n    ];\n    const req = makeReq({ type: 'PLA', color: '#FF0000', nozzle_id: 0 });\n    const result = autoMatchFilament(req, filaments, new Set(), true);\n    expect(result).toBeDefined();\n    expect(result!.globalTrayId).toBe(1); // Only tray on correct nozzle\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/RestoreModal.test.tsx",
    "content": "/**\n * Tests for the RestoreModal component.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { RestoreModal } from '../../components/RestoreModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\ndescribe('RestoreModal', () => {\n  const mockOnClose = vi.fn();\n  const mockOnRestore = vi.fn();\n  const mockOnSuccess = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.post('/api/v1/settings/restore', () => {\n        return HttpResponse.json({ success: true });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the modal title', () => {\n      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);\n\n      // Title is \"Restore Backup\"\n      expect(screen.getByText('Restore Backup')).toBeInTheDocument();\n    });\n\n    it('shows file upload area', () => {\n      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);\n\n      expect(screen.getByText(/select.*file/i)).toBeInTheDocument();\n    });\n\n    it('shows cancel button', () => {\n      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);\n\n      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();\n    });\n  });\n\n  describe('file input', () => {\n    it('accepts backup files', () => {\n      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);\n\n      const fileInput = document.querySelector('input[type=\"file\"]');\n      expect(fileInput).toBeInTheDocument();\n    });\n  });\n\n  describe('actions', () => {\n    it('calls onClose when cancel is clicked', async () => {\n      const user = userEvent.setup();\n      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);\n\n      await user.click(screen.getByRole('button', { name: /cancel/i }));\n\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n  });\n\n  describe('overwrite option', () => {\n    it('has overwrite toggle', () => {\n      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);\n\n      // The modal has toggle for replacing existing data\n      expect(screen.getByText('Keep existing data')).toBeInTheDocument();\n    });\n\n    it('shows warning when overwrite is enabled', async () => {\n      const user = userEvent.setup();\n      render(<RestoreModal onClose={mockOnClose} onRestore={mockOnRestore} onSuccess={mockOnSuccess} />);\n\n      // Find and click the toggle (uses role=\"switch\")\n      const toggle = screen.getByRole('switch');\n      await user.click(toggle);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Caution/)).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/SmartPlugCard.test.tsx",
    "content": "/**\n * Tests for the SmartPlugCard component.\n *\n * These tests focus on critical regression scenarios:\n * - Toggle persistence for auto_on/auto_off settings\n * - Power control functionality\n * - Status display\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { SmartPlugCard } from '../../components/SmartPlugCard';\nimport type { SmartPlug } from '../../api/client';\n\n// Mock data\nconst createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({\n  id: 1,\n  name: 'Test Plug',\n  plug_type: 'tasmota',\n  ip_address: '192.168.1.100',\n  ha_entity_id: null,\n  ha_power_entity: null,\n  ha_energy_today_entity: null,\n  ha_energy_total_entity: null,\n  // MQTT fields (legacy)\n  mqtt_topic: null,\n  mqtt_multiplier: 1.0,\n  // MQTT power fields\n  mqtt_power_topic: null,\n  mqtt_power_path: null,\n  mqtt_power_multiplier: 1.0,\n  // MQTT energy fields\n  mqtt_energy_topic: null,\n  mqtt_energy_path: null,\n  mqtt_energy_multiplier: 1.0,\n  // MQTT state fields\n  mqtt_state_topic: null,\n  mqtt_state_path: null,\n  mqtt_state_on_value: null,\n  printer_id: 1,\n  enabled: true,\n  auto_on: true,\n  auto_off: true,\n  auto_off_persistent: false,\n  off_delay_mode: 'time',\n  off_delay_minutes: 5,\n  off_temp_threshold: 70,\n  username: null,\n  password: null,\n  power_alert_enabled: false,\n  power_alert_high: null,\n  power_alert_low: null,\n  power_alert_last_triggered: null,\n  schedule_enabled: false,\n  schedule_on_time: null,\n  schedule_off_time: null,\n  last_state: 'ON',\n  last_checked: null,\n  auto_off_executed: false,\n  show_in_switchbar: false,\n  created_at: '2024-01-01T00:00:00Z',\n  updated_at: '2024-01-01T00:00:00Z',\n  ...overrides,\n});\n\ndescribe('SmartPlugCard', () => {\n  const mockOnEdit = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  describe('rendering', () => {\n    it('renders plug name', () => {\n      const plug = createMockPlug({ name: 'My Test Plug' });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      expect(screen.getByText('My Test Plug')).toBeInTheDocument();\n    });\n\n    it('renders plug IP address', () => {\n      const plug = createMockPlug({ ip_address: '192.168.1.200' });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      expect(screen.getByText('192.168.1.200')).toBeInTheDocument();\n    });\n\n    it('shows power ON/OFF buttons', () => {\n      const plug = createMockPlug();\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Look for power control buttons\n      const buttons = screen.getAllByRole('button');\n      expect(buttons.length).toBeGreaterThan(0);\n    });\n  });\n\n  describe('automation settings', () => {\n    it('shows automation settings section when expanded', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug();\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Find and click the settings toggle\n      const settingsToggle = screen.getByText('Automation Settings');\n      await user.click(settingsToggle);\n\n      // Should show Auto On and Auto Off labels\n      await waitFor(() => {\n        expect(screen.getByText('Auto On')).toBeInTheDocument();\n        expect(screen.getByText('Auto Off')).toBeInTheDocument();\n      });\n    });\n\n    it('displays auto_off toggle in correct state when enabled', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug({ auto_off: true });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Expand settings\n      await user.click(screen.getByText('Automation Settings'));\n\n      await waitFor(() => {\n        // The toggle should reflect auto_off = true\n        const autoOffText = screen.getByText('Auto Off');\n        expect(autoOffText).toBeInTheDocument();\n      });\n    });\n\n    it('displays auto_off toggle in correct state when disabled', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug({ auto_off: false });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Expand settings\n      await user.click(screen.getByText('Automation Settings'));\n\n      await waitFor(() => {\n        const autoOffText = screen.getByText('Auto Off');\n        expect(autoOffText).toBeInTheDocument();\n      });\n    });\n\n    it('shows delay mode options when auto_off is enabled', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug({ auto_off: true, off_delay_mode: 'time' });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Expand settings\n      await user.click(screen.getByText('Automation Settings'));\n\n      await waitFor(() => {\n        // Should show delay mode buttons\n        expect(screen.getByText('Time')).toBeInTheDocument();\n        expect(screen.getByText('Temp')).toBeInTheDocument();\n      });\n    });\n\n    it('does not show delay mode options when auto_off is disabled', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug({ auto_off: false });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Expand settings\n      await user.click(screen.getByText('Automation Settings'));\n\n      await waitFor(() => {\n        // Delay mode options should not be visible\n        expect(screen.queryByText('Turn Off Delay Mode')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('schedule display', () => {\n    it('shows schedule badge when scheduling is enabled', () => {\n      const plug = createMockPlug({\n        schedule_enabled: true,\n        schedule_on_time: '08:00',\n        schedule_off_time: '22:00',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      expect(screen.getByText(/08:00.*22:00/)).toBeInTheDocument();\n    });\n\n    it('does not show schedule badge when scheduling is disabled', () => {\n      const plug = createMockPlug({\n        schedule_enabled: false,\n        schedule_on_time: '08:00',\n        schedule_off_time: '22:00',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Schedule times should not be visible\n      expect(screen.queryByText(/08:00.*22:00/)).not.toBeInTheDocument();\n    });\n  });\n\n  describe('power control', () => {\n    it('shows confirmation modal before power off', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug({ last_state: 'ON' });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Find and click the Off button\n      const offButton = screen.getByRole('button', { name: /off/i });\n      await user.click(offButton);\n\n      // Confirmation modal should appear with the dialog title\n      await waitFor(() => {\n        expect(screen.getByText('Turn Off Smart Plug')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('edit functionality', () => {\n    it('calls onEdit when edit button is clicked', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug();\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Expand settings first\n      await user.click(screen.getByText('Automation Settings'));\n\n      // Find and click edit button\n      await waitFor(async () => {\n        const editButtons = screen.getAllByRole('button');\n        const editButton = editButtons.find(\n          (btn) =>\n            btn.textContent?.includes('Edit') ||\n            btn.querySelector('[class*=\"pencil\"]')\n        );\n        if (editButton) {\n          await user.click(editButton);\n        }\n      });\n\n      // onEdit should have been called (may not be called if edit button not found)\n      // This test verifies the interaction pattern\n    });\n  });\n\n  describe('persistent auto-off', () => {\n    it('shows Keep Enabled toggle when auto_off is enabled', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug({ auto_off: true, auto_off_persistent: false });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      await user.click(screen.getByText('Automation Settings'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Keep Enabled')).toBeInTheDocument();\n        expect(screen.getByText('Stay enabled between prints instead of one-shot')).toBeInTheDocument();\n      });\n    });\n\n    it('does not show Keep Enabled toggle when auto_off is disabled', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug({ auto_off: false });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      await user.click(screen.getByText('Automation Settings'));\n\n      await waitFor(() => {\n        expect(screen.queryByText('Keep Enabled')).not.toBeInTheDocument();\n      });\n    });\n\n    it('shows Keep Enabled toggle for HA plugs with auto_off enabled', async () => {\n      const user = userEvent.setup();\n      const plug = createMockPlug({\n        plug_type: 'homeassistant',\n        ip_address: null,\n        ha_entity_id: 'switch.bentobox_filter',\n        auto_off: true,\n        auto_off_persistent: true,\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      await user.click(screen.getByText('Automation Settings'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Keep Enabled')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('disabled state', () => {\n    it('renders plug even when disabled', () => {\n      const plug = createMockPlug({ enabled: false });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Plug should still render with its name\n      expect(screen.getByText('Test Plug')).toBeInTheDocument();\n    });\n  });\n\n  describe('Home Assistant plugs', () => {\n    it('renders HA plug with entity_id instead of IP', () => {\n      const plug = createMockPlug({\n        plug_type: 'homeassistant',\n        ip_address: null,\n        ha_entity_id: 'switch.printer_plug',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Should show entity_id, not IP\n      expect(screen.getByText('switch.printer_plug')).toBeInTheDocument();\n      expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument();\n    });\n\n    it('renders HA plug name correctly', () => {\n      const plug = createMockPlug({\n        name: 'HA Printer Plug',\n        plug_type: 'homeassistant',\n        ip_address: null,\n        ha_entity_id: 'switch.printer_plug',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      expect(screen.getByText('HA Printer Plug')).toBeInTheDocument();\n    });\n\n    it('shows power controls for HA plug', () => {\n      const plug = createMockPlug({\n        plug_type: 'homeassistant',\n        ip_address: null,\n        ha_entity_id: 'switch.printer_plug',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Power control buttons should still be present\n      const buttons = screen.getAllByRole('button');\n      expect(buttons.length).toBeGreaterThan(0);\n    });\n  });\n\n  describe('MQTT plugs', () => {\n    it('renders MQTT plug with topic instead of IP', () => {\n      const plug = createMockPlug({\n        plug_type: 'mqtt',\n        ip_address: null,\n        mqtt_topic: 'zigbee2mqtt/shelly-power',\n        mqtt_power_path: 'power_l1',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Should show topic, not IP\n      expect(screen.getByText('zigbee2mqtt/shelly-power')).toBeInTheDocument();\n      expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument();\n    });\n\n    it('renders MQTT plug name correctly', () => {\n      const plug = createMockPlug({\n        name: 'MQTT Energy Monitor',\n        plug_type: 'mqtt',\n        ip_address: null,\n        mqtt_topic: 'sensors/power',\n        mqtt_power_path: 'power',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      expect(screen.getByText('MQTT Energy Monitor')).toBeInTheDocument();\n    });\n\n    it('shows Monitor Only badge for MQTT plug', () => {\n      const plug = createMockPlug({\n        plug_type: 'mqtt',\n        ip_address: null,\n        mqtt_topic: 'test/topic',\n        mqtt_power_path: 'power',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      expect(screen.getByText('Monitor Only')).toBeInTheDocument();\n    });\n\n    it('does not show power control buttons for MQTT plug', () => {\n      const plug = createMockPlug({\n        plug_type: 'mqtt',\n        ip_address: null,\n        mqtt_topic: 'test/topic',\n        mqtt_power_path: 'power',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // On/Off buttons should not be present for monitor-only plugs\n      expect(screen.queryByRole('button', { name: /^on$/i })).not.toBeInTheDocument();\n      expect(screen.queryByRole('button', { name: /^off$/i })).not.toBeInTheDocument();\n    });\n\n    it('shows Settings instead of Automation Settings for MQTT plug', async () => {\n      const plug = createMockPlug({\n        plug_type: 'mqtt',\n        ip_address: null,\n        mqtt_topic: 'test/topic',\n        mqtt_power_path: 'power',\n      });\n      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);\n\n      // Should show \"Settings\" not \"Automation Settings\"\n      expect(screen.getByText('Settings')).toBeInTheDocument();\n      expect(screen.queryByText('Automation Settings')).not.toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/SpoolBuddySettings.test.tsx",
    "content": "/**\n * Tests for the SpoolBuddySettings component.\n *\n * Covers:\n * - Lists all devices (not just the first), including stale duplicates\n * - Shows a duplicate warning when more than one device is registered\n * - Unregister button opens a confirm modal and calls the delete API\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { SpoolBuddySettings } from '../../components/SpoolBuddySettings';\n\nvi.mock('../../api/client', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('../../api/client')>();\n  return {\n    ...actual,\n    spoolbuddyApi: {\n      ...actual.spoolbuddyApi,\n      getDevices: vi.fn(),\n      deleteDevice: vi.fn(),\n    },\n  };\n});\n\nimport { spoolbuddyApi } from '../../api/client';\n\nconst baseDevice = {\n  id: 1,\n  device_id: 'sb-0001',\n  hostname: 'spoolbuddy-kitchen',\n  ip_address: '10.0.0.11',\n  backend_url: null,\n  firmware_version: '1.2.0',\n  has_nfc: true,\n  has_scale: true,\n  tare_offset: 0,\n  calibration_factor: 1.0,\n  nfc_reader_type: 'pn532',\n  nfc_connection: 'i2c',\n  display_brightness: 100,\n  display_blank_timeout: 0,\n  has_backlight: true,\n  last_calibrated_at: null,\n  last_seen: new Date().toISOString(),\n  pending_command: null,\n  nfc_ok: true,\n  scale_ok: true,\n  uptime_s: 3600,\n  update_status: null,\n  update_message: null,\n  system_stats: {\n    os: { os: 'Raspbian', kernel: '6.1', arch: 'aarch64', python: '3.11' },\n    cpu_temp_c: 45.2,\n    memory: { total_mb: 4000, available_mb: 2500, used_mb: 1500, percent: 37 },\n    disk: { total_gb: 32, used_gb: 8, free_gb: 24, percent: 25 },\n    system_uptime_s: 86400,\n  },\n  online: true,\n};\n\ndescribe('SpoolBuddySettings', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.mocked(spoolbuddyApi.deleteDevice).mockResolvedValue({ status: 'deleted', device_id: 'sb-0002' });\n  });\n\n  it('renders every registered device, not just the first', async () => {\n    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([\n      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },\n      { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'spoolbuddy-ghost', online: false },\n    ]);\n\n    render(<SpoolBuddySettings />);\n\n    expect(await screen.findByText('spoolbuddy-kitchen')).toBeInTheDocument();\n    expect(await screen.findByText('spoolbuddy-ghost')).toBeInTheDocument();\n    expect(screen.getByText('sb-0001')).toBeInTheDocument();\n    expect(screen.getByText('sb-0002')).toBeInTheDocument();\n  });\n\n  it('shows duplicate warning when multiple devices registered', async () => {\n    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([\n      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },\n      { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'spoolbuddy-ghost' },\n    ]);\n\n    render(<SpoolBuddySettings />);\n\n    // Warning text mentions device count\n    expect(await screen.findByText(/2 devices registered/i)).toBeInTheDocument();\n  });\n\n  it('does not show duplicate warning with a single device', async () => {\n    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([\n      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },\n    ]);\n\n    render(<SpoolBuddySettings />);\n\n    await screen.findByText('spoolbuddy-kitchen');\n    expect(screen.queryByText(/devices registered/i)).not.toBeInTheDocument();\n  });\n\n  it('opens confirm modal and unregisters device on confirm', async () => {\n    const user = userEvent.setup();\n    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([\n      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },\n      { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'spoolbuddy-ghost', online: false },\n    ]);\n\n    render(<SpoolBuddySettings />);\n\n    // Wait for both devices to render\n    await screen.findByText('spoolbuddy-ghost');\n\n    // Click the unregister button on the ghost device card\n    const unregisterButtons = screen.getAllByRole('button', { name: /unregister/i });\n    // Two unregister buttons (one per card) — click the second one (ghost)\n    await user.click(unregisterButtons[1]);\n\n    // Confirm modal opens with title\n    expect(await screen.findByText(/unregister spoolbuddy device/i)).toBeInTheDocument();\n\n    // Click the confirm button inside the modal\n    const confirmButtons = screen.getAllByRole('button', { name: /^unregister$/i });\n    // Last one will be the modal's confirm button\n    await user.click(confirmButtons[confirmButtons.length - 1]);\n\n    await waitFor(() => {\n      expect(spoolbuddyApi.deleteDevice).toHaveBeenCalledWith('sb-0002');\n    });\n  });\n\n  it('does not call delete API when user cancels confirm modal', async () => {\n    const user = userEvent.setup();\n    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([\n      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },\n    ]);\n\n    render(<SpoolBuddySettings />);\n\n    await screen.findByText('spoolbuddy-kitchen');\n    const unregisterButton = screen.getByRole('button', { name: /unregister/i });\n    await user.click(unregisterButton);\n\n    const cancelButton = await screen.findByRole('button', { name: /cancel/i });\n    await user.click(cancelButton);\n\n    expect(spoolbuddyApi.deleteDevice).not.toHaveBeenCalled();\n  });\n\n  it('shows empty state when no devices are registered', async () => {\n    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([]);\n\n    render(<SpoolBuddySettings />);\n\n    expect(await screen.findByText(/no spoolbuddy devices/i)).toBeInTheDocument();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/SpoolFormBulk.test.tsx",
    "content": "/**\n * Tests for bulk spool creation and quick-add mode.\n *\n * Verifies:\n * - Quick-add toggle appears only in create mode\n * - Quick-add mode shows brand and subtype as optional (no asterisk)\n * - Quick-add mode hides slicer preset field\n * - Quick-add mode hides PA Profile tab\n * - Quantity field is only rendered in quick-add mode\n * - Quantity field is hidden in edit mode\n * - Bulk create calls bulkCreateSpools when quantity > 1\n * - Single quantity calls createSpool as before\n * - validateForm with quickAdd=true only requires material\n */\n\nimport React from 'react';\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport { render } from '../utils';\nimport { SpoolFormModal } from '../../components/SpoolFormModal';\nimport { validateForm, defaultFormData } from '../../components/spool-form/types';\nimport type { InventorySpool } from '../../api/client';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  api: {\n    getSettings: vi.fn().mockResolvedValue({}),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),\n    getFilamentPresets: vi.fn().mockResolvedValue([]),\n    getSpoolCatalog: vi.fn().mockResolvedValue([]),\n    getColorCatalog: vi.fn().mockResolvedValue([]),\n    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),\n    getPrinters: vi.fn().mockResolvedValue([]),\n    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),\n    createSpool: vi.fn().mockResolvedValue({ id: 99 }),\n    bulkCreateSpools: vi.fn().mockResolvedValue([\n      { id: 100, k_profiles: [] },\n      { id: 101, k_profiles: [] },\n      { id: 102, k_profiles: [] },\n    ]),\n    updateSpool: vi.fn().mockResolvedValue({ id: 1 }),\n    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),\n  },\n}));\n\n// Mock the toast context\nconst mockShowToast = vi.fn();\nvi.mock('../../contexts/ToastContext', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();\n  return {\n    ...actual,\n    useToast: () => ({ showToast: mockShowToast }),\n  };\n});\n\nconst existingSpool: InventorySpool = {\n  id: 1,\n  material: 'PLA',\n  subtype: 'Basic',\n  brand: 'Polymaker',\n  color_name: 'Red',\n  rgba: 'FF0000FF',\n  label_weight: 1000,\n  core_weight: 250,\n  core_weight_catalog_id: null,\n  weight_used: 300,\n  slicer_filament: 'GFL99',\n  slicer_filament_name: 'Generic PLA',\n  nozzle_temp_min: null,\n  nozzle_temp_max: null,\n  note: null,\n  added_full: null,\n  last_used: null,\n  encode_time: null,\n  tag_uid: null,\n  tray_uuid: null,\n  data_origin: null,\n  tag_type: null,\n  archived_at: null,\n  created_at: '2025-01-01T00:00:00Z',\n  updated_at: '2025-01-01T00:00:00Z',\n  k_profiles: [],\n  cost_per_kg: null,\n};\n\ndescribe('validateForm with quickAdd', () => {\n  it('requires only material in quick-add mode', () => {\n    const result = validateForm({ ...defaultFormData, material: 'PLA' }, true);\n    expect(result.isValid).toBe(true);\n    expect(result.errors).toEqual({});\n  });\n\n  it('rejects empty material in quick-add mode', () => {\n    const result = validateForm({ ...defaultFormData, material: '' }, true);\n    expect(result.isValid).toBe(false);\n    expect(result.errors.material).toBeDefined();\n  });\n\n  it('does not require slicer_filament in quick-add mode', () => {\n    const result = validateForm(\n      { ...defaultFormData, material: 'PETG', slicer_filament: '' },\n      true,\n    );\n    expect(result.isValid).toBe(true);\n  });\n\n  it('does not require brand in quick-add mode', () => {\n    const result = validateForm(\n      { ...defaultFormData, material: 'ABS', brand: '' },\n      true,\n    );\n    expect(result.isValid).toBe(true);\n  });\n\n  it('does not require subtype in quick-add mode', () => {\n    const result = validateForm(\n      { ...defaultFormData, material: 'TPU', subtype: '' },\n      true,\n    );\n    expect(result.isValid).toBe(true);\n  });\n\n  it('requires all fields in full mode (quickAdd=false)', () => {\n    const result = validateForm(defaultFormData, false);\n    expect(result.isValid).toBe(false);\n    expect(result.errors.material).toBeDefined();\n    expect(result.errors.slicer_filament).toBeDefined();\n    expect(result.errors.brand).toBeDefined();\n    expect(result.errors.subtype).toBeDefined();\n  });\n});\n\ndescribe('SpoolFormModal quick-add toggle', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('shows quick-add toggle in create mode', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        currencySymbol=\"$\"\n      />,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('Quick Add (Stock)')).toBeInTheDocument();\n  });\n\n  it('hides quick-add toggle in edit mode', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={existingSpool}\n        currencySymbol=\"$\"\n      />,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    expect(screen.queryByText('Quick Add (Stock)')).not.toBeInTheDocument();\n  });\n\n  it('hides PA Profile tab when quick-add is enabled', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        currencySymbol=\"$\"\n      />,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();\n    });\n\n    // PA Profile tab should be visible initially\n    expect(screen.getByText('PA Profile')).toBeInTheDocument();\n\n    // Toggle quick-add on — the toggle is a button[role=\"switch\"] sibling of the label\n    const toggleButtons = screen.getAllByRole('button');\n    const quickAddToggle = toggleButtons.find(btn =>\n      btn.getAttribute('type') === 'button' &&\n      btn.className.includes('rounded-full') &&\n      btn.closest('div')?.textContent?.includes('Quick Add')\n    );\n    expect(quickAddToggle).toBeTruthy();\n    fireEvent.click(quickAddToggle!);\n\n    // PA Profile tab should be hidden\n    await waitFor(() => {\n      expect(screen.queryByText('PA Profile')).not.toBeInTheDocument();\n    });\n  });\n\n  it('hides quantity field by default (non-quick-add)', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        currencySymbol=\"$\"\n      />,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();\n    });\n\n    // Quantity field should NOT be visible in normal create mode\n    expect(screen.queryByText('Quantity')).not.toBeInTheDocument();\n  });\n\n  it('shows quantity field only in quick-add mode', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        currencySymbol=\"$\"\n      />,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();\n    });\n\n    // Toggle quick-add on\n    const toggleButtons = screen.getAllByRole('button');\n    const quickAddToggle = toggleButtons.find(btn =>\n      btn.getAttribute('type') === 'button' &&\n      btn.className.includes('rounded-full') &&\n      btn.closest('div')?.textContent?.includes('Quick Add')\n    );\n    expect(quickAddToggle).toBeTruthy();\n    fireEvent.click(quickAddToggle!);\n\n    // Quantity field should now be visible\n    await waitFor(() => {\n      expect(screen.getByText('Quantity')).toBeInTheDocument();\n    });\n  });\n\n  it('hides quantity field in edit mode', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={existingSpool}\n        currencySymbol=\"$\"\n      />,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    // Quantity field should NOT be visible in edit mode\n    expect(screen.queryByText('Quantity')).not.toBeInTheDocument();\n  });\n\n  it('shows brand and subtype in quick-add mode without asterisk', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        currencySymbol=\"$\"\n      />,\n    );\n\n    await waitFor(() => {\n      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();\n    });\n\n    // Toggle quick-add on\n    const toggleButtons = screen.getAllByRole('button');\n    const quickAddToggle = toggleButtons.find(btn =>\n      btn.getAttribute('type') === 'button' &&\n      btn.className.includes('rounded-full') &&\n      btn.closest('div')?.textContent?.includes('Quick Add')\n    );\n    fireEvent.click(quickAddToggle!);\n\n    // Brand and Subtype should be visible (without asterisk = optional)\n    await waitFor(() => {\n      const brandLabel = screen.getByText('Brand');\n      expect(brandLabel).toBeInTheDocument();\n      expect(brandLabel.textContent).not.toContain('*');\n\n      const subtypeLabel = screen.getByText('Subtype');\n      expect(subtypeLabel).toBeInTheDocument();\n      expect(subtypeLabel.textContent).not.toContain('*');\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/SpoolFormModal.test.tsx",
    "content": "/**\n * Tests for the SpoolFormModal weightTouched behavior.\n *\n * Verifies that weight_used is only included in the PATCH payload when the user\n * explicitly changes the remaining weight field. This prevents stale React Query\n * cache values from overwriting usage-tracked weight data on the backend.\n */\n\nimport React from 'react';\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport { render } from '../utils';\nimport { SpoolFormModal } from '../../components/SpoolFormModal';\nimport type { InventorySpool } from '../../api/client';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  api: {\n    getSettings: vi.fn().mockResolvedValue({}),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),\n    getFilamentPresets: vi.fn().mockResolvedValue([]),\n    getSpoolCatalog: vi.fn().mockResolvedValue([]),\n    getColorCatalog: vi.fn().mockResolvedValue([]),\n    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),\n    getPrinters: vi.fn().mockResolvedValue([]),\n    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),\n    createSpool: vi.fn().mockResolvedValue({ id: 99 }),\n    updateSpool: vi.fn().mockResolvedValue({ id: 1 }),\n    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),\n  },\n}));\n\n// Mock validateForm so we can bypass validation for the create-mode test\n// (editing tests pass validation naturally since the spool has material + slicer_filament)\nvi.mock('../../components/spool-form/types', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('../../components/spool-form/types')>();\n  return {\n    ...actual,\n    validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),\n  };\n});\n\n// Mock the toast context\nconst mockShowToast = vi.fn();\nvi.mock('../../contexts/ToastContext', async (importOriginal) => {\n  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();\n  return {\n    ...actual,\n    useToast: () => ({ showToast: mockShowToast }),\n  };\n});\n\nimport { api } from '../../api/client';\n\nconst existingSpool: InventorySpool = {\n  id: 1,\n  material: 'PLA',\n  subtype: 'Basic',\n  brand: 'Polymaker',\n  color_name: 'Red',\n  rgba: 'FF0000FF',\n  label_weight: 1000,\n  core_weight: 250,\n  core_weight_catalog_id: null,\n  weight_used: 300,\n  slicer_filament: 'GFL99',\n  slicer_filament_name: 'Generic PLA',\n  nozzle_temp_min: null,\n  nozzle_temp_max: null,\n  note: null,\n  added_full: null,\n  last_used: null,\n  encode_time: null,\n  tag_uid: null,\n  tray_uuid: null,\n  data_origin: null,\n  tag_type: null,\n  archived_at: null,\n  created_at: '2025-01-01T00:00:00Z',\n  updated_at: '2025-01-01T00:00:00Z',\n  k_profiles: [],\n};\n\ndescribe('SpoolFormModal weightTouched', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('excludes weight_used from PATCH when editing without changing weight', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={existingSpool}\n        currencySymbol=\"$\"\n      />\n    );\n\n    // Wait for the modal to render with the edit title\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    // Click Save without touching the weight field\n    const saveButton = screen.getByRole('button', { name: /save/i });\n    fireEvent.click(saveButton);\n\n    await waitFor(() => {\n      expect(api.updateSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];\n    expect(spoolId).toBe(1);\n    // weight_used must NOT be present in the payload\n    expect(payload).not.toHaveProperty('weight_used');\n    // Other fields should still be present\n    expect(payload).toHaveProperty('material', 'PLA');\n    expect(payload).toHaveProperty('label_weight', 1000);\n  });\n\n  it('includes weight_used in PATCH when editing and changing remaining weight', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={existingSpool}\n        currencySymbol=\"$\"\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.\n    // The input is a number input displaying 700. Find it by its displayed value.\n    const remainingInput = screen.getByDisplayValue('700');\n    expect(remainingInput).toBeInTheDocument();\n\n    // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)\n    fireEvent.change(remainingInput, { target: { value: '500' } });\n    // Blur triggers updateField('weight_used', ...) which sets weightTouched\n    fireEvent.blur(remainingInput);\n\n    // Click Save\n    const saveButton = screen.getByRole('button', { name: /save/i });\n    fireEvent.click(saveButton);\n\n    await waitFor(() => {\n      expect(api.updateSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];\n    expect(spoolId).toBe(1);\n    // weight_used MUST be present since the user changed the weight\n    expect(payload).toHaveProperty('weight_used', 500);\n  });\n\n  it('includes weight_used when creating a new spool', async () => {\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        currencySymbol=\"$\"\n      />\n    );\n\n    // Wait for the modal to render with the create title\n    await waitFor(() => {\n      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();\n    });\n\n    // Click the submit button (validation is mocked to always pass).\n    // The default form data has weight_used=0, and for create mode the condition\n    //   if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }\n    // always includes weight_used since isEditing is false.\n    // The submit button also says \"Add Spool\" — use getAllByText and pick the button.\n    const addButtons = screen.getAllByRole('button', { name: /add spool/i });\n    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));\n    expect(submitButton).toBeTruthy();\n    fireEvent.click(submitButton!);\n\n    await waitFor(() => {\n      expect(api.createSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [payload] = vi.mocked(api.createSpool).mock.calls[0];\n    // weight_used MUST be included for new spools (default value 0)\n    expect(payload).toHaveProperty('weight_used', 0);\n  });\n\n  it('preserves core_weight_catalog_id when editing other fields', async () => {\n    const spoolWithCatalogId: InventorySpool = {\n      ...existingSpool,\n      core_weight_catalog_id: 5,\n    };\n\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={spoolWithCatalogId}\n        currencySymbol=\"$\"\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    // Change the note field (unrelated to catalog ID)\n    const noteInputs = screen.getAllByPlaceholderText(/note/i);\n    expect(noteInputs.length).toBeGreaterThan(0);\n    fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });\n\n    // Click Save\n    const saveButton = screen.getByRole('button', { name: /save/i });\n    fireEvent.click(saveButton);\n\n    await waitFor(() => {\n      expect(api.updateSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];\n    expect(spoolId).toBe(1);\n    // core_weight_catalog_id MUST be preserved when editing other fields\n    expect(payload).toHaveProperty('core_weight_catalog_id', 5);\n    // Other changes should also be present\n    expect(payload).toHaveProperty('note', 'Updated note');\n  });\n\n  it('includes core_weight_catalog_id when selecting from catalog', async () => {\n    const mockCatalog = [\n      { id: 1, name: 'Generic 250g', weight: 250 },\n      { id: 2, name: 'Bambu Lab 250g', weight: 250 },\n      { id: 3, name: 'Standard 300g', weight: 300 },\n    ];\n\n    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);\n\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        currencySymbol=\"$\"\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();\n    });\n\n    // Wait for catalog to load\n    await waitFor(() => {\n      expect(api.getSpoolCatalog).toHaveBeenCalled();\n    });\n\n    // Click on the empty spool weight field to open dropdown\n    const weightInputs = screen.getAllByPlaceholderText(/search/i);\n    const weightPicker = weightInputs.find(input =>\n      input.getAttribute('placeholder')?.toLowerCase().includes('spool')\n    );\n    expect(weightPicker).toBeTruthy();\n    fireEvent.focus(weightPicker!);\n\n    // Click on \"Bambu Lab 250g\" option\n    const bambuOption = await screen.findByText('Bambu Lab 250g');\n    fireEvent.click(bambuOption);\n\n    // Click the add spool button\n    const addButtons = screen.getAllByRole('button', { name: /add spool/i });\n    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));\n    expect(submitButton).toBeTruthy();\n    fireEvent.click(submitButton!);\n\n    await waitFor(() => {\n      expect(api.createSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [payload] = vi.mocked(api.createSpool).mock.calls[0];\n    // Both weight AND catalog ID should be sent\n    expect(payload).toHaveProperty('core_weight', 250);\n    expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of \"Bambu Lab 250g\"\n  });\n\n  it('preserves cost_per_kg when editing spool', async () => {\n    const spoolWithCost: InventorySpool = {\n      ...existingSpool,\n      cost_per_kg: 25.50,\n    };\n\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={spoolWithCost}\n        currencySymbol=\"$\"\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    // Click Save without changing cost\n    const saveButton = screen.getByRole('button', { name: /save/i });\n    fireEvent.click(saveButton);\n\n    await waitFor(() => {\n      expect(api.updateSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];\n    expect(spoolId).toBe(1);\n    // cost_per_kg should be preserved in the update payload\n    expect(payload).toHaveProperty('cost_per_kg', 25.50);\n  });\n\n  it('sends null cost_per_kg when spool has no cost', async () => {\n    const spoolWithoutCost: InventorySpool = {\n      ...existingSpool,\n      cost_per_kg: null,\n    };\n\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={spoolWithoutCost}\n        currencySymbol=\"$\"\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    const saveButton = screen.getByRole('button', { name: /save/i });\n    fireEvent.click(saveButton);\n\n    await waitFor(() => {\n      expect(api.updateSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];\n    // cost_per_kg should be null when not set\n    expect(payload).toHaveProperty('cost_per_kg', null);\n  });\n\n  it('normalizes a malformed legacy rgba on edit-form load so PATCH is not rejected (#1055)', async () => {\n    // #1055 regression guard: a spool with a legacy 7-char rgba (e.g. 'FFFFFFF')\n    // was editable in the UI but any save 422'd because SpoolUpdate now enforces\n    // the 8-char pattern. The form must sanitize the loaded value to a valid\n    // default so users can edit unrelated fields without being forced to fix\n    // a color they may not even have noticed was broken.\n    const spoolWithBadRgba: InventorySpool = {\n      ...existingSpool,\n      rgba: 'FFFFFFF', // 7 chars — the exact #1055 trigger pattern\n    };\n\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={spoolWithBadRgba}\n        currencySymbol=\"$\"\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    const saveButton = screen.getByRole('button', { name: /save/i });\n    fireEvent.click(saveButton);\n\n    await waitFor(() => {\n      expect(api.updateSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];\n    // The PATCH payload must carry a valid 8-char rgba — never the raw 7-char\n    // value loaded from the stale DB row.\n    expect(payload).toHaveProperty('rgba');\n    expect(typeof (payload as { rgba: unknown }).rgba).toBe('string');\n    expect((payload as { rgba: string }).rgba).toMatch(/^[0-9A-Fa-f]{8}$/);\n  });\n\n  it('preserves a valid existing rgba on edit (no forced default)', async () => {\n    // Sanity: the normalization only kicks in for malformed values. A valid\n    // 8-char rgba must round-trip untouched so untouched edits don't quietly\n    // reset a user's chosen color.\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={existingSpool} // rgba = 'FF0000FF' (valid)\n        currencySymbol=\"$\"\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    const saveButton = screen.getByRole('button', { name: /save/i });\n    fireEvent.click(saveButton);\n\n    await waitFor(() => {\n      expect(api.updateSpool).toHaveBeenCalledTimes(1);\n    });\n\n    const [, payload] = vi.mocked(api.updateSpool).mock.calls[0];\n    expect((payload as { rgba: string }).rgba).toBe('FF0000FF');\n  });\n\n  it('displays correct catalog name when duplicates exist', async () => {\n    const spoolWithCatalogId: InventorySpool = {\n      ...existingSpool,\n      core_weight: 250,\n      core_weight_catalog_id: 2, // \"Bambu Lab 250g\", not the first match\n    };\n\n    const mockCatalog = [\n      { id: 1, name: 'Generic 250g', weight: 250 },\n      { id: 2, name: 'Bambu Lab 250g', weight: 250 },\n      { id: 3, name: 'Standard 300g', weight: 300 },\n    ];\n\n    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);\n\n    render(\n      <SpoolFormModal\n        isOpen={true}\n        onClose={vi.fn()}\n        spool={spoolWithCatalogId}\n        currencySymbol=\"$\"\n      />\n    );\n\n    await waitFor(() => {\n      expect(screen.getByText('Edit Spool')).toBeInTheDocument();\n    });\n\n    // Wait for catalog to load\n    await waitFor(() => {\n      expect(api.getSpoolCatalog).toHaveBeenCalled();\n    });\n\n    // Should display \"Bambu Lab 250g\" (by ID), not \"Generic 250g\" (first match by weight)\n    await waitFor(() => {\n      const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);\n      const bambuFound = weightInputs.some(input =>\n        input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'\n      );\n      expect(bambuFound).toBeTruthy();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/SpoolInfoCard.test.tsx",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, fireEvent, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';\nimport type { MatchedSpool } from '../../hooks/useSpoolBuddyState';\n\nconst mockUpdateSpoolWeight = vi.fn();\n\nvi.mock('../../api/client', () => ({\n  api: {\n    getSettings: vi.fn().mockResolvedValue({}),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n  },\n  spoolbuddyApi: {\n    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),\n  },\n}));\n\nconst mockSpool: MatchedSpool = {\n  id: 42,\n  tag_uid: 'AABBCCDD11223344',\n  material: 'PLA',\n  subtype: 'Matte',\n  color_name: 'Jade White',\n  rgba: 'E8F5E9FF',\n  brand: 'Bambu',\n  label_weight: 1000,\n  core_weight: 250,\n  weight_used: 200,\n};\n\ndescribe('SpoolInfoCard', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });\n  });\n\n  it('renders spool material, brand, color name', () => {\n    render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);\n\n    expect(screen.getByText('Jade White')).toBeInTheDocument();\n    expect(screen.getByText(/Bambu/)).toBeInTheDocument();\n    expect(screen.getByText(/PLA/)).toBeInTheDocument();\n  });\n\n  it('shows spool color circle with correct hex color', () => {\n    const { container } = render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);\n\n    // SpoolIcon renders an SVG circle with fill=colorHex\n    const circle = container.querySelector('circle[fill=\"#E8F5E9\"]');\n    expect(circle).toBeInTheDocument();\n  });\n\n  it('shows remaining weight and fill percentage', () => {\n    // scaleWeight=900g, core=250g → remaining = 900-250 = 650g\n    // fillPercent = round(650/1000 * 100) = 65%\n    render(<SpoolInfoCard spool={mockSpool} scaleWeight={900} />);\n\n    expect(screen.getByText('650g')).toBeInTheDocument();\n    expect(screen.getByText('65%')).toBeInTheDocument();\n  });\n\n  it('calls onAssignToAms when \"Assign to AMS\" button clicked', () => {\n    const onAssign = vi.fn();\n    render(\n      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={onAssign} />\n    );\n\n    fireEvent.click(screen.getByText('Assign to AMS'));\n    expect(onAssign).toHaveBeenCalledTimes(1);\n  });\n\n  it('calls onSyncWeight when sync button clicked', async () => {\n    const onSync = vi.fn();\n    render(\n      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onSyncWeight={onSync} />\n    );\n\n    fireEvent.click(screen.getByText('Sync Weight'));\n\n    await waitFor(() => {\n      expect(mockUpdateSpoolWeight).toHaveBeenCalledWith(42, 800);\n    });\n  });\n\n  it('calls onClose when close button clicked', () => {\n    const onClose = vi.fn();\n    render(\n      <SpoolInfoCard spool={mockSpool} scaleWeight={null} onClose={onClose} />\n    );\n\n    fireEvent.click(screen.getByText('Close'));\n    expect(onClose).toHaveBeenCalledTimes(1);\n  });\n});\n\ndescribe('UnknownTagCard', () => {\n  it('renders tag UID', () => {\n    render(<UnknownTagCard tagUid=\"DEADBEEF12345678\" scaleWeight={null} />);\n\n    expect(screen.getByText('DEADBEEF12345678')).toBeInTheDocument();\n    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();\n  });\n\n  it('shows \"Add to Inventory\" button', () => {\n    const onAdd = vi.fn();\n    render(\n      <UnknownTagCard tagUid=\"DEADBEEF\" scaleWeight={null} onAddToInventory={onAdd} />\n    );\n\n    const btn = screen.getByText('Add to Inventory');\n    expect(btn).toBeInTheDocument();\n    fireEvent.click(btn);\n    expect(onAdd).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/SpoolmanSettings.test.tsx",
    "content": "/**\n * Tests for the SpoolmanSettings component.\n *\n * Tests the filament tracking mode selector and Spoolman integration UI:\n * - Mode selector (Built-in Inventory vs Spoolman)\n * - Built-in Inventory info panel\n * - Spoolman URL, sync mode, connection status\n * - Weight sync and partial usage toggles\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { SpoolmanSettings } from '../../components/SpoolmanSettings';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  api: {\n    getSettings: vi.fn().mockResolvedValue({}),\n    updateSettings: vi.fn().mockResolvedValue({}),\n    getSpoolmanSettings: vi.fn(),\n    updateSpoolmanSettings: vi.fn(),\n    getSpoolmanStatus: vi.fn(),\n    connectSpoolman: vi.fn(),\n    disconnectSpoolman: vi.fn(),\n    syncAllPrintersAms: vi.fn(),\n    syncPrinterAms: vi.fn(),\n    getPrinters: vi.fn(),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n  },\n}));\n\n// Import mocked module\nimport { api } from '../../api/client';\n\ndescribe('SpoolmanSettings', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n\n    // Default API mocks — Spoolman disabled (Built-in Inventory mode)\n    vi.mocked(api.getSpoolmanSettings).mockResolvedValue({\n      spoolman_enabled: 'false',\n      spoolman_url: '',\n      spoolman_sync_mode: 'auto',\n      spoolman_disable_weight_sync: 'false',\n      spoolman_report_partial_usage: 'true',\n    });\n    vi.mocked(api.updateSpoolmanSettings).mockResolvedValue({\n      spoolman_enabled: 'false',\n      spoolman_url: '',\n      spoolman_sync_mode: 'auto',\n      spoolman_disable_weight_sync: 'false',\n      spoolman_report_partial_usage: 'true',\n    });\n    vi.mocked(api.getSpoolmanStatus).mockResolvedValue({\n      enabled: false,\n      connected: false,\n      url: null,\n    });\n    vi.mocked(api.getPrinters).mockResolvedValue([]);\n    vi.mocked(api.connectSpoolman).mockResolvedValue({ success: true, message: 'Connected' });\n    vi.mocked(api.disconnectSpoolman).mockResolvedValue({ success: true, message: 'Disconnected' });\n    vi.mocked(api.syncAllPrintersAms).mockResolvedValue({\n      success: true,\n      synced_count: 3,\n      skipped_count: 1,\n      skipped: [],\n      errors: [],\n    });\n  });\n\n  describe('rendering', () => {\n    it('renders loading state initially', () => {\n      vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));\n      render(<SpoolmanSettings />);\n\n      expect(document.querySelector('.animate-spin')).toBeInTheDocument();\n    });\n\n    it('renders filament tracking title', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();\n      });\n    });\n\n    it('renders mode selector cards', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();\n        expect(screen.getByText('Spoolman')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('built-in inventory mode (default)', () => {\n    it('shows built-in inventory as selected by default', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        // Built-in Inventory card should have the active border\n        const builtInBtn = screen.getByText('Built-in Inventory').closest('button');\n        expect(builtInBtn).toHaveClass('border-bambu-green');\n      });\n    });\n\n    it('shows built-in info panel when selected', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Automatically detects Bambu Lab RFID spools/)).toBeInTheDocument();\n      });\n    });\n\n    it('does not show Spoolman URL input', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByPlaceholderText('http://192.168.1.100:7912')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('spoolman mode', () => {\n    beforeEach(() => {\n      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({\n        spoolman_enabled: 'true',\n        spoolman_url: 'http://localhost:7912',\n        spoolman_sync_mode: 'auto',\n        spoolman_disable_weight_sync: 'false',\n        spoolman_report_partial_usage: 'true',\n      });\n      vi.mocked(api.updateSpoolmanSettings).mockResolvedValue({\n        spoolman_enabled: 'true',\n        spoolman_url: 'http://localhost:7912',\n        spoolman_sync_mode: 'auto',\n        spoolman_disable_weight_sync: 'false',\n        spoolman_report_partial_usage: 'true',\n      });\n    });\n\n    it('shows Spoolman card as selected', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        const spoolmanBtn = screen.getByText('Spoolman').closest('button');\n        expect(spoolmanBtn).toHaveClass('border-bambu-green');\n      });\n    });\n\n    it('shows URL input when Spoolman is selected', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();\n      });\n    });\n\n    it('shows sync mode selector', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Sync Mode')).toBeInTheDocument();\n      });\n    });\n\n    it('shows how sync works info', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('How Sync Works')).toBeInTheDocument();\n      });\n    });\n\n    it('shows connection status section', async () => {\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Status:')).toBeInTheDocument();\n      });\n    });\n\n    it('shows Disconnected when not connected', async () => {\n      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({\n        enabled: true,\n        connected: false,\n        url: 'http://localhost:7912',\n      });\n\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Disconnected')).toBeInTheDocument();\n      });\n    });\n\n    it('shows Connected and Disconnect button when connected', async () => {\n      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({\n        enabled: true,\n        connected: true,\n        url: 'http://localhost:7912',\n      });\n\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Connected')).toBeInTheDocument();\n        expect(screen.getByText('Disconnect')).toBeInTheDocument();\n      });\n    });\n\n    it('shows sync section when connected', async () => {\n      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({\n        enabled: true,\n        connected: true,\n        url: 'http://localhost:7912',\n      });\n\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('weight sync toggle', () => {\n    it('shows weight sync toggle when Spoolman enabled and sync mode is auto', async () => {\n      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({\n        spoolman_enabled: 'true',\n        spoolman_url: 'http://localhost:7912',\n        spoolman_sync_mode: 'auto',\n        spoolman_disable_weight_sync: 'false',\n        spoolman_report_partial_usage: 'true',\n      });\n\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Disable AMS Estimated Weight Sync')).toBeInTheDocument();\n      });\n    });\n\n    it('does not show weight sync toggle when sync mode is manual', async () => {\n      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({\n        spoolman_enabled: 'true',\n        spoolman_url: 'http://localhost:7912',\n        spoolman_sync_mode: 'manual',\n        spoolman_disable_weight_sync: 'false',\n        spoolman_report_partial_usage: 'true',\n      });\n\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByText('Disable AMS Estimated Weight Sync')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('partial usage toggle', () => {\n    it('shows partial usage toggle when weight sync is disabled', async () => {\n      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({\n        spoolman_enabled: 'true',\n        spoolman_url: 'http://localhost:7912',\n        spoolman_sync_mode: 'auto',\n        spoolman_disable_weight_sync: 'true',\n        spoolman_report_partial_usage: 'true',\n      });\n\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Report Partial Usage for Failed Prints')).toBeInTheDocument();\n      });\n    });\n\n    it('does not show partial usage toggle when weight sync is enabled', async () => {\n      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({\n        spoolman_enabled: 'true',\n        spoolman_url: 'http://localhost:7912',\n        spoolman_sync_mode: 'auto',\n        spoolman_disable_weight_sync: 'false',\n        spoolman_report_partial_usage: 'true',\n      });\n\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByText('Report Partial Usage for Failed Prints')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('mode switching', () => {\n    it('can switch to Spoolman mode', async () => {\n      const user = userEvent.setup();\n      render(<SpoolmanSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();\n      });\n\n      // Click Spoolman card\n      await user.click(screen.getByText('Spoolman').closest('button')!);\n\n      // Spoolman settings should now be visible\n      await waitFor(() => {\n        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/TagDetectedModal.test.tsx",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, fireEvent } from '@testing-library/react';\nimport { render } from '../utils';\nimport { TagDetectedModal } from '../../components/spoolbuddy/TagDetectedModal';\nimport type { MatchedSpool } from '../../hooks/useSpoolBuddyState';\n\nconst mockUpdateSpoolWeight = vi.fn();\n\nvi.mock('../../api/client', () => ({\n  api: {\n    getSettings: vi.fn().mockResolvedValue({}),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n  },\n  spoolbuddyApi: {\n    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),\n  },\n}));\n\nconst mockSpool: MatchedSpool = {\n  id: 7,\n  tag_uid: 'AA11BB22CC33DD44',\n  material: 'PETG',\n  subtype: 'HF',\n  color_name: 'Orange',\n  rgba: 'FF6600FF',\n  brand: 'Overture',\n  label_weight: 1000,\n  core_weight: 250,\n  weight_used: 100,\n};\n\nconst defaultProps = {\n  isOpen: true,\n  onClose: vi.fn(),\n  spool: mockSpool,\n  tagUid: 'AA11BB22CC33DD44',\n  scaleWeight: 950.0,\n  weightStable: true,\n  onSyncWeight: vi.fn(),\n  onAssignToAms: vi.fn(),\n  onLinkSpool: vi.fn(),\n  onAddToInventory: vi.fn(),\n};\n\ndescribe('TagDetectedModal', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });\n  });\n\n  it('does not render when isOpen=false', () => {\n    render(<TagDetectedModal {...defaultProps} isOpen={false} />);\n    expect(screen.queryByText('Spool Detected')).not.toBeInTheDocument();\n  });\n\n  it('renders known spool view when spool provided', () => {\n    render(<TagDetectedModal {...defaultProps} />);\n\n    expect(screen.getByText('Spool Detected')).toBeInTheDocument();\n    expect(screen.getByText('Orange')).toBeInTheDocument();\n    expect(screen.getByText(/Overture/)).toBeInTheDocument();\n    expect(screen.getByText(/PETG/)).toBeInTheDocument();\n  });\n\n  it('renders unknown tag view when spool is null', () => {\n    render(\n      <TagDetectedModal\n        {...defaultProps}\n        spool={null}\n        tagUid=\"DEADBEEF11223344\"\n      />\n    );\n\n    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();\n    expect(screen.getByText('DEADBEEF11223344')).toBeInTheDocument();\n  });\n\n  it('closes on Escape key', () => {\n    const onClose = vi.fn();\n    render(<TagDetectedModal {...defaultProps} onClose={onClose} />);\n\n    fireEvent.keyDown(document, { key: 'Escape' });\n    expect(onClose).toHaveBeenCalledTimes(1);\n  });\n\n  it('shows weight from scale', () => {\n    // scaleWeight=950g, core=250g → remaining = 950-250 = 700g\n    render(<TagDetectedModal {...defaultProps} scaleWeight={950} />);\n\n    expect(screen.getByText('700g')).toBeInTheDocument();\n  });\n\n  it('shows action buttons (Assign to AMS, Sync Weight)', () => {\n    const onAssign = vi.fn();\n    const onSync = vi.fn();\n    render(\n      <TagDetectedModal\n        {...defaultProps}\n        onAssignToAms={onAssign}\n        onSyncWeight={onSync}\n      />\n    );\n\n    expect(screen.getByText('Assign to AMS')).toBeInTheDocument();\n    expect(screen.getByText('Sync Weight')).toBeInTheDocument();\n\n    fireEvent.click(screen.getByText('Assign to AMS'));\n    expect(onAssign).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/TagManagementModal.test.tsx",
    "content": "/**\n * Tests for the TagManagementModal component.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor, within } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { TagManagementModal } from '../../components/TagManagementModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockTags = [\n  { name: 'functional', count: 5 },\n  { name: 'calibration', count: 3 },\n  { name: 'test', count: 2 },\n  { name: 'art', count: 1 },\n];\n\ndescribe('TagManagementModal', () => {\n  const mockOnClose = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.get('/api/v1/archives/tags', () => {\n        return HttpResponse.json(mockTags);\n      }),\n      http.put('/api/v1/archives/tags/:tagName', async () => {\n        return HttpResponse.json({ affected: 2 });\n      }),\n      http.delete('/api/v1/archives/tags/:tagName', () => {\n        return HttpResponse.json({ affected: 1 });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the modal title', async () => {\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      expect(screen.getByText('Manage Tags')).toBeInTheDocument();\n    });\n\n    it('shows loading state initially', () => {\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      // Should show loading spinner before data loads\n      expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();\n    });\n\n    it('displays tags with counts', async () => {\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n        expect(screen.getByText('5')).toBeInTheDocument();\n        expect(screen.getByText('calibration')).toBeInTheDocument();\n        expect(screen.getByText('3')).toBeInTheDocument();\n      });\n    });\n\n    it('shows total tag count and usage', async () => {\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        // 4 tags, 11 total usages\n        expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('search functionality', () => {\n    it('filters tags by search input', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n\n      const searchInput = screen.getByPlaceholderText('Search tags...');\n      await user.type(searchInput, 'cal');\n\n      await waitFor(() => {\n        expect(screen.getByText('calibration')).toBeInTheDocument();\n        expect(screen.queryByText('functional')).not.toBeInTheDocument();\n        expect(screen.queryByText('art')).not.toBeInTheDocument();\n      });\n    });\n\n    it('shows no results message when search has no matches', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n\n      const searchInput = screen.getByPlaceholderText('Search tags...');\n      await user.type(searchInput, 'nonexistent');\n\n      await waitFor(() => {\n        expect(screen.getByText('No tags match your search')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('sorting', () => {\n    it('sorts by count by default', async () => {\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        const tagElements = screen.getAllByText(/functional|calibration|test|art/);\n        // First should be functional (count 5)\n        expect(tagElements[0]).toHaveTextContent('functional');\n      });\n    });\n\n    it('can sort by name', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n\n      const sortSelect = screen.getByDisplayValue('Sort by Count');\n      await user.selectOptions(sortSelect, 'name');\n\n      await waitFor(() => {\n        const tagElements = screen.getAllByText(/functional|calibration|test|art/);\n        // First should be 'art' alphabetically\n        expect(tagElements[0]).toHaveTextContent('art');\n      });\n    });\n  });\n\n  describe('rename functionality', () => {\n    it('enters edit mode when clicking edit button', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n\n      // Find the tag row and click its edit button\n      const tagRow = screen.getByText('functional').closest('div');\n      const editButton = within(tagRow!).getByTitle('Rename tag');\n      await user.click(editButton);\n\n      // Should show input with current value\n      await waitFor(() => {\n        expect(screen.getByDisplayValue('functional')).toBeInTheDocument();\n      });\n    });\n\n    it('submits rename on Enter key', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n\n      const tagRow = screen.getByText('functional').closest('div');\n      const editButton = within(tagRow!).getByTitle('Rename tag');\n      await user.click(editButton);\n\n      const input = screen.getByDisplayValue('functional');\n      await user.clear(input);\n      await user.type(input, 'new-name{Enter}');\n\n      // Should show success (mutation called)\n      await waitFor(() => {\n        // After successful rename, edit mode should close\n        expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument();\n      });\n    });\n\n    it('cancels edit on Escape key', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n\n      const tagRow = screen.getByText('functional').closest('div');\n      const editButton = within(tagRow!).getByTitle('Rename tag');\n      await user.click(editButton);\n\n      const input = screen.getByDisplayValue('functional');\n      await user.type(input, '-modified{Escape}');\n\n      // Should exit edit mode without saving\n      await waitFor(() => {\n        expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument();\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('delete functionality', () => {\n    it('shows delete confirmation when clicking delete button', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n\n      const tagRow = screen.getByText('functional').closest('div');\n      const deleteButton = within(tagRow!).getByTitle('Delete tag');\n      await user.click(deleteButton);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Delete \"functional\" from 5 archives?/i)).toBeInTheDocument();\n      });\n    });\n\n    it('cancels delete confirmation on X button', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n\n      const tagRow = screen.getByText('functional').closest('div');\n      const deleteButton = within(tagRow!).getByTitle('Delete tag');\n      await user.click(deleteButton);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Delete \"functional\"/i)).toBeInTheDocument();\n      });\n\n      // Find the confirmation row and click the cancel (X) button within it\n      const confirmationText = screen.getByText(/Delete \"functional\"/i);\n      const confirmationRow = confirmationText.closest('div');\n      // The X button is the last button in the confirmation row\n      const buttons = within(confirmationRow!.parentElement!).getAllByRole('button');\n      const cancelButton = buttons[buttons.length - 1]; // X button is last\n      await user.click(cancelButton);\n\n      await waitFor(() => {\n        // Should return to normal display - the tag name should be visible again\n        expect(screen.getByText('functional')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('modal behavior', () => {\n    it('calls onClose when close button is clicked', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Manage Tags')).toBeInTheDocument();\n      });\n\n      // Find close button in header (X icon)\n      const headerCloseButton = screen.getAllByRole('button')[0];\n      await user.click(headerCloseButton);\n\n      expect(mockOnClose).toHaveBeenCalledTimes(1);\n    });\n\n    it('calls onClose when Close button is clicked', async () => {\n      const user = userEvent.setup();\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Manage Tags')).toBeInTheDocument();\n      });\n\n      const closeButton = screen.getByRole('button', { name: /close/i });\n      await user.click(closeButton);\n\n      expect(mockOnClose).toHaveBeenCalledTimes(1);\n    });\n  });\n\n  describe('empty state', () => {\n    it('shows empty message when no tags exist', async () => {\n      server.use(\n        http.get('/api/v1/archives/tags', () => {\n          return HttpResponse.json([]);\n        })\n      );\n\n      render(<TagManagementModal onClose={mockOnClose} />);\n\n      await waitFor(() => {\n        expect(screen.getByText('No tags found')).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/Toggle.test.tsx",
    "content": "/**\n * Tests for the Toggle component.\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { Toggle } from '../../components/Toggle';\n\ndescribe('Toggle', () => {\n  it('renders in unchecked state', () => {\n    render(<Toggle checked={false} onChange={() => {}} />);\n\n    const toggle = screen.getByRole('switch');\n    expect(toggle).toHaveAttribute('aria-checked', 'false');\n  });\n\n  it('renders in checked state', () => {\n    render(<Toggle checked={true} onChange={() => {}} />);\n\n    const toggle = screen.getByRole('switch');\n    expect(toggle).toHaveAttribute('aria-checked', 'true');\n  });\n\n  it('calls onChange with true when clicking unchecked toggle', async () => {\n    const user = userEvent.setup();\n    const handleChange = vi.fn();\n\n    render(<Toggle checked={false} onChange={handleChange} />);\n\n    await user.click(screen.getByRole('switch'));\n\n    expect(handleChange).toHaveBeenCalledWith(true);\n    expect(handleChange).toHaveBeenCalledTimes(1);\n  });\n\n  it('calls onChange with false when clicking checked toggle', async () => {\n    const user = userEvent.setup();\n    const handleChange = vi.fn();\n\n    render(<Toggle checked={true} onChange={handleChange} />);\n\n    await user.click(screen.getByRole('switch'));\n\n    expect(handleChange).toHaveBeenCalledWith(false);\n    expect(handleChange).toHaveBeenCalledTimes(1);\n  });\n\n  it('does not call onChange when disabled', async () => {\n    const user = userEvent.setup();\n    const handleChange = vi.fn();\n\n    render(<Toggle checked={false} onChange={handleChange} disabled />);\n\n    await user.click(screen.getByRole('switch'));\n\n    expect(handleChange).not.toHaveBeenCalled();\n  });\n\n  it('has disabled attribute when disabled prop is true', () => {\n    render(<Toggle checked={false} onChange={() => {}} disabled />);\n\n    const toggle = screen.getByRole('switch');\n    expect(toggle).toBeDisabled();\n  });\n\n  it('applies correct styles when checked', () => {\n    render(<Toggle checked={true} onChange={() => {}} />);\n\n    const toggle = screen.getByRole('switch');\n    expect(toggle.className).toContain('bg-bambu-green');\n  });\n\n  it('applies correct styles when unchecked', () => {\n    render(<Toggle checked={false} onChange={() => {}} />);\n\n    const toggle = screen.getByRole('switch');\n    expect(toggle.className).toContain('bg-bambu-dark-tertiary');\n  });\n\n  it('applies disabled styles when disabled', () => {\n    render(<Toggle checked={false} onChange={() => {}} disabled />);\n\n    const toggle = screen.getByRole('switch');\n    expect(toggle.className).toContain('cursor-not-allowed');\n    expect(toggle.className).toContain('opacity-50');\n  });\n\n  it('stops event propagation on click', async () => {\n    const user = userEvent.setup();\n    const handleParentClick = vi.fn();\n    const handleChange = vi.fn();\n\n    render(\n      <div onClick={handleParentClick}>\n        <Toggle checked={false} onChange={handleChange} />\n      </div>\n    );\n\n    await user.click(screen.getByRole('switch'));\n\n    expect(handleChange).toHaveBeenCalled();\n    expect(handleParentClick).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/UploadModal.test.tsx",
    "content": "/**\n * Tests for the UploadModal component.\n * Tests file upload functionality with drag-and-drop support.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, fireEvent, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { UploadModal } from '../../components/UploadModal';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\ndescribe('UploadModal', () => {\n  const mockOnClose = vi.fn();\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    server.use(\n      http.post('/api/v1/archives/upload-bulk', async () => {\n        return HttpResponse.json({\n          uploaded: 1,\n          failed: 0,\n          results: [{ id: 1, filename: 'test.3mf' }],\n          errors: [],\n        });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the modal with title', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      expect(screen.getByText('Upload 3MF Files')).toBeInTheDocument();\n    });\n\n    it('renders drag and drop zone', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      expect(screen.getByText('Drag & drop .3mf files here')).toBeInTheDocument();\n    });\n\n    it('renders Browse Files button', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();\n    });\n\n    it('renders Cancel button', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();\n    });\n\n    it('renders Upload button (disabled initially)', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload/i });\n      expect(uploadButton).toBeDisabled();\n    });\n  });\n\n  describe('file handling with initialFiles', () => {\n    it('shows initial files when provided', () => {\n      const initialFiles = [\n        new File(['content'], 'model.3mf', { type: 'application/3mf' }),\n      ];\n\n      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);\n\n      expect(screen.getByText('model.3mf')).toBeInTheDocument();\n    });\n\n    it('enables Upload button when files are present', () => {\n      const initialFiles = [\n        new File(['content'], 'model.3mf', { type: 'application/3mf' }),\n      ];\n\n      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload/i });\n      expect(uploadButton).not.toBeDisabled();\n    });\n\n    it('shows file count in Upload button', () => {\n      const initialFiles = [\n        new File(['content'], 'model1.3mf', { type: 'application/3mf' }),\n        new File(['content'], 'model2.3mf', { type: 'application/3mf' }),\n      ];\n\n      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);\n\n      expect(screen.getByRole('button', { name: /Upload \\(2\\)/i })).toBeInTheDocument();\n    });\n\n    it('filters out non-3mf files from initialFiles', () => {\n      const initialFiles = [\n        new File(['content'], 'model.3mf', { type: 'application/3mf' }),\n        new File(['content'], 'image.png', { type: 'image/png' }),\n        new File(['content'], 'doc.txt', { type: 'text/plain' }),\n      ];\n\n      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);\n\n      expect(screen.getByText('model.3mf')).toBeInTheDocument();\n      expect(screen.queryByText('image.png')).not.toBeInTheDocument();\n      expect(screen.queryByText('doc.txt')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('file removal', () => {\n    it('allows removing a file before upload', async () => {\n      const initialFiles = [\n        new File(['content'], 'model.3mf', { type: 'application/3mf' }),\n      ];\n\n      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);\n\n      expect(screen.getByText('model.3mf')).toBeInTheDocument();\n\n      // Find and click the remove button (X icon next to file)\n      const fileItem = screen.getByText('model.3mf').closest('.flex');\n      const removeButton = fileItem?.querySelector('button');\n\n      if (removeButton) {\n        fireEvent.click(removeButton);\n\n        await waitFor(() => {\n          expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();\n        });\n      }\n    });\n  });\n\n  describe('upload button behavior', () => {\n    it('Upload button triggers upload mutation when clicked', async () => {\n      const initialFiles = [\n        new File(['content'], 'test.3mf', { type: 'application/3mf' }),\n      ];\n\n      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload/i });\n      expect(uploadButton).not.toBeDisabled();\n\n      // Click should trigger upload (button text will change)\n      fireEvent.click(uploadButton);\n\n      // The button should show uploading state or become disabled\n      await waitFor(() => {\n        // Either showing \"Uploading...\" or a spinner is present\n        const hasUploadingText = screen.queryByText(/Uploading/i) !== null;\n        const hasSpinner = document.querySelector('.animate-spin') !== null;\n        expect(hasUploadingText || hasSpinner).toBe(true);\n      });\n    });\n\n    it('Upload button is disabled when no files are pending', async () => {\n      render(<UploadModal onClose={mockOnClose} initialFiles={[]} />);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload/i });\n      expect(uploadButton).toBeDisabled();\n    });\n\n    it('shows correct file count in Upload button', () => {\n      const initialFiles = [\n        new File(['content'], 'file1.3mf', { type: 'application/3mf' }),\n        new File(['content'], 'file2.3mf', { type: 'application/3mf' }),\n        new File(['content'], 'file3.3mf', { type: 'application/3mf' }),\n      ];\n\n      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);\n\n      expect(screen.getByRole('button', { name: /Upload \\(3\\)/i })).toBeInTheDocument();\n    });\n  });\n\n  describe('close behavior', () => {\n    it('calls onClose when Cancel button is clicked', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      const cancelButton = screen.getByRole('button', { name: 'Cancel' });\n      fireEvent.click(cancelButton);\n\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n\n    it('calls onClose when X button is clicked', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      // Find the X button in the header\n      const buttons = screen.getAllByRole('button');\n      const closeButton = buttons.find(btn =>\n        btn.querySelector('.lucide-x')\n      );\n\n      if (closeButton) {\n        fireEvent.click(closeButton);\n        expect(mockOnClose).toHaveBeenCalled();\n      }\n    });\n\n    it('calls onClose when Escape key is pressed', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      fireEvent.keyDown(window, { key: 'Escape' });\n      expect(mockOnClose).toHaveBeenCalled();\n    });\n  });\n\n  describe('drag and drop', () => {\n    it('highlights drop zone on drag over', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      const dropZone = screen.getByText('Drag & drop .3mf files here').closest('div');\n\n      if (dropZone) {\n        fireEvent.dragOver(dropZone, {\n          dataTransfer: { files: [] },\n        });\n\n        // The drop zone should have the highlight class\n        expect(dropZone.className).toContain('border-bambu-green');\n      }\n    });\n\n    it('removes highlight on drag leave', () => {\n      render(<UploadModal onClose={mockOnClose} />);\n\n      const dropZone = screen.getByText('Drag & drop .3mf files here').closest('div');\n\n      if (dropZone) {\n        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });\n        fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });\n\n        // The drop zone should not have the highlight class\n        expect(dropZone.className).not.toContain('bg-bambu-green');\n      }\n    });\n  });\n\n  describe('file size display', () => {\n    it('shows file size in MB', () => {\n      const file = new File(['x'.repeat(1048576)], 'large.3mf', { type: 'application/3mf' }); // 1 MB\n\n      render(<UploadModal onClose={mockOnClose} initialFiles={[file]} />);\n\n      expect(screen.getByText('1.0 MB')).toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/VirtualPrinterCard.test.tsx",
    "content": "/**\n * Tests for the VirtualPrinterCard component.\n *\n * Tests the auto-dispatch toggle behavior:\n * - Visibility based on mode (print_queue only)\n * - Default state (on)\n * - API mutation on toggle click\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { VirtualPrinterCard } from '../../components/VirtualPrinterCard';\nimport type { VirtualPrinterConfig } from '../../api/client';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  multiVirtualPrinterApi: {\n    update: vi.fn().mockResolvedValue({}),\n    remove: vi.fn().mockResolvedValue({}),\n  },\n  api: {\n    getSettings: vi.fn().mockResolvedValue({}),\n    getPrinters: vi.fn().mockResolvedValue([]),\n    getNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }),\n  },\n}));\n\nimport { multiVirtualPrinterApi } from '../../api/client';\n\nconst models: Record<string, string> = {\n  'BL-P001': 'X1C',\n  'C12': 'P1S',\n};\n\nconst createMockPrinter = (overrides: Partial<VirtualPrinterConfig> = {}): VirtualPrinterConfig => ({\n  id: 1,\n  name: 'Test VP',\n  enabled: false,\n  mode: 'immediate',\n  model: 'BL-P001',\n  model_name: 'X1C',\n  access_code_set: false,\n  serial: '00M00A391800001',\n  target_printer_id: null,\n  auto_dispatch: true,\n  bind_ip: null,\n  remote_interface_ip: null,\n  position: 0,\n  status: { running: false, pending_files: 0 },\n  ...overrides,\n});\n\ndescribe('VirtualPrinterCard - auto-dispatch toggle', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());\n  });\n\n  it('renders auto-dispatch toggle when mode is print_queue', async () => {\n    const printer = createMockPrinter({ mode: 'print_queue' });\n    render(<VirtualPrinterCard printer={printer} models={models} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();\n    });\n  });\n\n  it('does not render auto-dispatch toggle when mode is immediate', async () => {\n    const printer = createMockPrinter({ mode: 'immediate' });\n    render(<VirtualPrinterCard printer={printer} models={models} />);\n\n    // Wait for the card to render fully (check for something that should be there)\n    await waitFor(() => {\n      expect(screen.getByText('Test VP')).toBeInTheDocument();\n    });\n\n    expect(screen.queryByText('Auto-dispatch')).not.toBeInTheDocument();\n  });\n\n  it('does not render auto-dispatch toggle when mode is proxy', async () => {\n    const printer = createMockPrinter({ mode: 'proxy' });\n    render(<VirtualPrinterCard printer={printer} models={models} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Test VP')).toBeInTheDocument();\n    });\n\n    expect(screen.queryByText('Auto-dispatch')).not.toBeInTheDocument();\n  });\n\n  it('auto-dispatch toggle defaults to on', async () => {\n    const printer = createMockPrinter({ mode: 'print_queue', auto_dispatch: true });\n    render(<VirtualPrinterCard printer={printer} models={models} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();\n    });\n\n    // The auto-dispatch section container has the toggle button as a sibling of the text div\n    const title = screen.getByText('Auto-dispatch');\n    const section = title.closest('.flex.items-center.justify-between');\n    expect(section).toBeTruthy();\n    const toggleButton = section!.querySelector('button');\n    expect(toggleButton).toBeTruthy();\n    expect(toggleButton!.className).toContain('bg-bambu-green');\n  });\n\n  it('clicking auto-dispatch toggle calls update API', async () => {\n    const user = userEvent.setup();\n    const printer = createMockPrinter({ mode: 'print_queue', auto_dispatch: true });\n    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(\n      createMockPrinter({ mode: 'print_queue', auto_dispatch: false })\n    );\n\n    render(<VirtualPrinterCard printer={printer} models={models} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();\n    });\n\n    // Find the auto-dispatch toggle via the section container\n    const title = screen.getByText('Auto-dispatch');\n    const section = title.closest('.flex.items-center.justify-between');\n    expect(section).toBeTruthy();\n    const toggleButton = section!.querySelector('button');\n    expect(toggleButton).toBeTruthy();\n\n    await user.click(toggleButton!);\n\n    await waitFor(() => {\n      expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { auto_dispatch: false });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx",
    "content": "/**\n * Tests for the VirtualPrinterSettings component.\n *\n * Tests the virtual printer configuration UI including:\n * - Enable/disable toggle\n * - Access code management\n * - Archive mode selection\n * - Status display\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  api: {\n    getSettings: vi.fn().mockResolvedValue({}),\n    updateSettings: vi.fn().mockResolvedValue({}),\n    getPrinters: vi.fn().mockResolvedValue([]),\n    getNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }),\n  },\n  virtualPrinterApi: {\n    getSettings: vi.fn(),\n    updateSettings: vi.fn(),\n    getModels: vi.fn().mockResolvedValue({\n      models: {\n        'BL-P001': 'X1C',\n        'C12': 'P1S',\n        'N7': 'P2S',\n      },\n    }),\n  },\n}));\n\n// Import mocked module\nimport { virtualPrinterApi } from '../../api/client';\n\n// Mock data factory\nconst createMockSettings = (overrides = {}) => ({\n  enabled: false,\n  access_code_set: false,\n  mode: 'immediate' as const,\n  model: 'BL-P001',\n  target_printer_id: null as number | null,\n  remote_interface_ip: null as string | null,\n  status: {\n    enabled: false,\n    running: false,\n    mode: 'immediate',\n    name: 'Bambuddy',\n    serial: '00M00A391800001',\n    model: 'BL-P001',\n    model_name: 'X1C',\n    pending_files: 0,\n  },\n  ...overrides,\n});\n\ndescribe('VirtualPrinterSettings', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    // Default mock implementation\n    vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(createMockSettings());\n    vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(createMockSettings());\n  });\n\n  describe('rendering', () => {\n    it('renders loading state initially', () => {\n      // Delay the API response to catch loading state\n      vi.mocked(virtualPrinterApi.getSettings).mockImplementation(\n        () => new Promise(() => {}) // Never resolves\n      );\n      render(<VirtualPrinterSettings />);\n\n      // Should show loading spinner\n      expect(document.querySelector('.animate-spin')).toBeInTheDocument();\n    });\n\n    it('renders component title', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Virtual Printer')).toBeInTheDocument();\n      });\n    });\n\n    it('renders enable toggle', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();\n      });\n    });\n\n    it('renders access code section', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Access Code')).toBeInTheDocument();\n      });\n    });\n\n    it('renders mode section', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Mode')).toBeInTheDocument();\n      });\n    });\n\n    it('renders how it works info', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('How it works:')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('status indicator', () => {\n    it('shows Stopped when not running', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ status: { ...createMockSettings().status, running: false } })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Stopped')).toBeInTheDocument();\n      });\n    });\n\n    it('shows Running when active', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({\n          enabled: true,\n          status: { ...createMockSettings().status, enabled: true, running: true },\n        })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Running')).toBeInTheDocument();\n      });\n    });\n\n    it('shows status details when running', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({\n          enabled: true,\n          status: {\n            enabled: true,\n            running: true,\n            mode: 'immediate',\n            name: 'Bambuddy',\n            serial: '00M00A391800001',\n            model: 'BL-P001',\n            model_name: 'X1C',\n            pending_files: 0,\n          },\n        })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Status Details')).toBeInTheDocument();\n        expect(screen.getByText('Bambuddy')).toBeInTheDocument();\n        expect(screen.getByText('00M00A391800001')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('access code', () => {\n    it('shows warning when access code not set', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ access_code_set: false })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('No access code set - required to enable')).toBeInTheDocument();\n      });\n    });\n\n    it('shows success when access code is set', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ access_code_set: true })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Access code is set')).toBeInTheDocument();\n      });\n    });\n\n    it('shows character count while typing', async () => {\n      const user = userEvent.setup();\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Access Code')).toBeInTheDocument();\n      });\n\n      const input = screen.getByPlaceholderText('Enter 8-char code');\n      await user.type(input, '1234');\n\n      expect(screen.getByText('(4/8)')).toBeInTheDocument();\n    });\n\n    it('saves access code on button click', async () => {\n      const user = userEvent.setup();\n      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(\n        createMockSettings({ access_code_set: true })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Access Code')).toBeInTheDocument();\n      });\n\n      const input = screen.getByPlaceholderText('Enter 8-char code');\n      await user.type(input, '12345678');\n\n      const saveButton = screen.getByRole('button', { name: 'Save' });\n      await user.click(saveButton);\n\n      await waitFor(() => {\n        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({\n          access_code: '12345678',\n        });\n      });\n    });\n\n    it('toggles password visibility', async () => {\n      const user = userEvent.setup();\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Access Code')).toBeInTheDocument();\n      });\n\n      const input = screen.getByPlaceholderText('Enter 8-char code');\n      expect(input).toHaveAttribute('type', 'password');\n\n      // Find and click the visibility toggle (eye icon button)\n      const toggleButtons = screen.getAllByRole('button');\n      const visibilityToggle = toggleButtons.find(\n        (btn) => btn.querySelector('svg') && btn.className.includes('absolute')\n      );\n\n      if (visibilityToggle) {\n        await user.click(visibilityToggle);\n        expect(input).toHaveAttribute('type', 'text');\n      }\n    });\n  });\n\n  describe('mode selection', () => {\n    it('renders Archive mode option', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Archive')).toBeInTheDocument();\n        expect(screen.getByText('Archive files immediately')).toBeInTheDocument();\n      });\n    });\n\n    it('renders Review mode option', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Review')).toBeInTheDocument();\n        expect(screen.getByText('Review before archiving')).toBeInTheDocument();\n      });\n    });\n\n    it('renders Queue mode option', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Queue')).toBeInTheDocument();\n        expect(screen.getByText('Archive and add to queue')).toBeInTheDocument();\n      });\n    });\n\n    it('highlights current mode (review)', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ mode: 'review' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        const reviewButton = screen.getByText('Review').closest('button');\n        expect(reviewButton?.className).toContain('border-bambu-green');\n      });\n    });\n\n    it('highlights current mode (legacy queue maps to review)', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ mode: 'queue' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        const reviewButton = screen.getByText('Review').closest('button');\n        expect(reviewButton?.className).toContain('border-bambu-green');\n      });\n    });\n\n    it('changes mode to review on click', async () => {\n      const user = userEvent.setup();\n      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(\n        createMockSettings({ mode: 'review' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Review')).toBeInTheDocument();\n      });\n\n      const reviewButton = screen.getByText('Review').closest('button');\n      if (reviewButton) {\n        await user.click(reviewButton);\n      }\n\n      await waitFor(() => {\n        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'review' });\n      });\n    });\n\n    it('changes mode to print_queue on click', async () => {\n      const user = userEvent.setup();\n      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(\n        createMockSettings({ mode: 'print_queue' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Queue')).toBeInTheDocument();\n      });\n\n      const queueButton = screen.getByText('Queue').closest('button');\n      if (queueButton) {\n        await user.click(queueButton);\n      }\n\n      await waitFor(() => {\n        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'print_queue' });\n      });\n    });\n  });\n\n  describe('enable/disable toggle', () => {\n    it('cannot enable without access code', async () => {\n      const user = userEvent.setup();\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: false, access_code_set: false })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();\n      });\n\n      // Find the toggle switch (it's a button with relative class containing the slider)\n      const allButtons = screen.getAllByRole('button');\n      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));\n\n      if (toggle) {\n        await user.click(toggle);\n      }\n\n      // Should not call update API (no access code set)\n      expect(virtualPrinterApi.updateSettings).not.toHaveBeenCalled();\n    });\n\n    it('can enable when access code is set', async () => {\n      const user = userEvent.setup();\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: false, access_code_set: true })\n      );\n      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(\n        createMockSettings({ enabled: true, access_code_set: true })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();\n      });\n\n      // Find the toggle switch (it's a button with rounded-full and w-12 classes)\n      const allButtons = screen.getAllByRole('button');\n      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));\n\n      expect(toggle).toBeDefined();\n      if (toggle) {\n        await user.click(toggle);\n      }\n\n      await waitFor(() => {\n        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(\n          expect.objectContaining({ enabled: true })\n        );\n      });\n    });\n\n    it('can disable when enabled', async () => {\n      const user = userEvent.setup();\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: true, access_code_set: true })\n      );\n      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(\n        createMockSettings({ enabled: false, access_code_set: true })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Enable Virtual Printer')).toBeInTheDocument();\n      });\n\n      // Find the toggle switch\n      const allButtons = screen.getAllByRole('button');\n      const toggle = allButtons.find((btn) => btn.className.includes('rounded-full') && btn.className.includes('w-12'));\n\n      expect(toggle).toBeDefined();\n      if (toggle) {\n        await user.click(toggle);\n      }\n\n      await waitFor(() => {\n        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith(\n          expect.objectContaining({ enabled: false })\n        );\n      });\n    });\n  });\n\n  describe('info section', () => {\n    it('shows setup required warning', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Setup Required')).toBeInTheDocument();\n      });\n    });\n\n    it('shows link to setup guide', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Read the setup guide before enabling')).toBeInTheDocument();\n      });\n    });\n\n    it('shows how it works section', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('How it works:')).toBeInTheDocument();\n        expect(screen.getByText(/virtual printers appear in your slicer/)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('proxy mode', () => {\n    it('renders Proxy mode option', async () => {\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Proxy')).toBeInTheDocument();\n        expect(screen.getByText('Relay to real printer')).toBeInTheDocument();\n      });\n    });\n\n    it('highlights proxy mode when selected', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ mode: 'proxy', target_printer_id: 1 })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        const proxyButton = screen.getByText('Proxy').closest('button');\n        expect(proxyButton?.className).toContain('border-blue-500');\n      });\n    });\n\n    it('shows proxy status details when running in proxy mode', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({\n          enabled: true,\n          mode: 'proxy',\n          target_printer_id: 1,\n          status: {\n            enabled: true,\n            running: true,\n            mode: 'proxy',\n            name: 'Bambuddy (Proxy)',\n            serial: '00M00A391800001',\n            model: 'BL-P001',\n            model_name: 'X1C',\n            pending_files: 0,\n            proxy: {\n              running: true,\n              target_host: '192.168.1.100',\n              ftp_port: 990,  // Privileged port for Bambu Studio compatibility\n              mqtt_port: 8883,\n              ftp_connections: 1,\n              mqtt_connections: 2,\n            },\n          },\n        })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Running')).toBeInTheDocument();\n        expect(screen.getByText('Status Details')).toBeInTheDocument();\n        // IP address appears in multiple places (target printer and status details)\n        expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0);\n      });\n    });\n\n    it('shows target printer dropdown in proxy mode', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ mode: 'proxy' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Target Printer')).toBeInTheDocument();\n        expect(screen.getByText('Select a printer...')).toBeInTheDocument();\n      });\n    });\n\n    it('changes mode to proxy on click', async () => {\n      const user = userEvent.setup();\n      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(\n        createMockSettings({ mode: 'proxy' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Proxy')).toBeInTheDocument();\n      });\n\n      const proxyButton = screen.getByText('Proxy').closest('button');\n      if (proxyButton) {\n        await user.click(proxyButton);\n      }\n\n      await waitFor(() => {\n        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'proxy' });\n      });\n    });\n  });\n\n  describe('network interface override', () => {\n    it('shows interface dropdown when enabled in immediate mode', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: true, mode: 'immediate' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();\n      });\n    });\n\n    it('shows interface dropdown when enabled in review mode', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: true, mode: 'review' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();\n      });\n    });\n\n    it('shows interface dropdown when enabled in print_queue mode', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: true, mode: 'print_queue' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();\n      });\n    });\n\n    it('shows interface dropdown when enabled in proxy mode', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: true, mode: 'proxy', target_printer_id: 1 })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Network Interface Override')).toBeInTheDocument();\n      });\n    });\n\n    it('hides interface dropdown when disabled', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: false, mode: 'immediate' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Mode')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByText('Network Interface Override')).not.toBeInTheDocument();\n    });\n\n    it('shows configured status when interface is set', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: true, mode: 'immediate', remote_interface_ip: '10.0.0.50' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Interface override active')).toBeInTheDocument();\n      });\n    });\n\n    it('shows optional hint when no interface is set', async () => {\n      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(\n        createMockSettings({ enabled: true, mode: 'immediate', remote_interface_ip: '' })\n      );\n\n      render(<VirtualPrinterSettings />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Optional.*auto-detected IP/)).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/WeightDisplay.test.tsx",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, fireEvent, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';\n\nconst mockTare = vi.fn();\n\nvi.mock('../../api/client', () => ({\n  api: {\n    getSettings: vi.fn().mockResolvedValue({}),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n  },\n  spoolbuddyApi: {\n    tare: (...args: unknown[]) => mockTare(...args),\n  },\n}));\n\nconst defaultProps = {\n  weight: 823.4,\n  weightStable: true,\n  deviceOnline: true,\n  deviceId: 'sb-0001',\n};\n\ndescribe('WeightDisplay', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockTare.mockResolvedValue({ status: 'ok' });\n  });\n\n  it('renders weight value with 1 decimal place', () => {\n    render(<WeightDisplay {...defaultProps} weight={823.456} />);\n    expect(screen.getByText('823.5')).toBeInTheDocument();\n  });\n\n  it('shows green dot when stable and online', () => {\n    const { container } = render(\n      <WeightDisplay {...defaultProps} weightStable={true} deviceOnline={true} />\n    );\n    const dot = container.querySelector('.bg-green-500');\n    expect(dot).toBeInTheDocument();\n    expect(screen.getByText('Stable')).toBeInTheDocument();\n  });\n\n  it('shows amber dot when unstable', () => {\n    const { container } = render(\n      <WeightDisplay {...defaultProps} weightStable={false} deviceOnline={true} />\n    );\n    const dot = container.querySelector('.bg-amber-500');\n    expect(dot).toBeInTheDocument();\n    expect(screen.getByText('Measuring...')).toBeInTheDocument();\n  });\n\n  it('shows gray dot when offline', () => {\n    const { container } = render(\n      <WeightDisplay {...defaultProps} deviceOnline={false} />\n    );\n    const dot = container.querySelector('.bg-zinc-600');\n    expect(dot).toBeInTheDocument();\n    expect(screen.getByText('No reading')).toBeInTheDocument();\n  });\n\n  it('tare button calls spoolbuddyApi.tare(deviceId)', async () => {\n    render(<WeightDisplay {...defaultProps} />);\n\n    const tareButton = screen.getByText('Tare');\n    fireEvent.click(tareButton);\n\n    await waitFor(() => {\n      expect(mockTare).toHaveBeenCalledWith('sb-0001');\n    });\n  });\n\n  it('tare button is disabled when no deviceId', () => {\n    render(<WeightDisplay {...defaultProps} deviceId={null} />);\n\n    const tareButton = screen.getByText('Tare');\n    expect(tareButton).toBeDisabled();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx",
    "content": "/**\n * Regression tests for the ColorSection hex input normalization (#1055).\n *\n * The original bug: typing 5 hex chars on the RRGGBB field produced a 7-char\n * rgba (\"FFFFF\" + \"FF\" alpha = 7 chars); typing 7 chars left the 7-char string\n * unpadded. Either way the value passed frontend validation, survived a backend\n * PATCH (SpoolUpdate had no pattern constraint), and then bricked the entire\n * Filaments page because SpoolResponse enforced the 8-char pattern on serialize\n * and one bad row 500'd the whole list endpoint.\n *\n * The input now emits a valid 8-char RRGGBBAA on every keystroke: shorter input\n * is right-padded with '0' and given FF alpha; 7-char input drops the stray 7th\n * char; 8-char paste passes through unchanged.\n *\n * These tests drive the onChange handler directly (via fireEvent.change) rather\n * than userEvent.type so each assertion exercises a specific input length. The\n * component itself is a controlled input whose displayed value derives from\n * formData.rgba.substring(0, 6), so the real-world UX of typing one char at a\n * time is quirkier than the handler contract — but the handler contract is\n * what this regression guards.\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { render, screen, fireEvent } from '@testing-library/react';\nimport { I18nextProvider } from 'react-i18next';\nimport i18n from '../../../i18n';\nimport { ColorSection } from '../../../components/spool-form/ColorSection';\nimport { defaultFormData } from '../../../components/spool-form/types';\n\ntype UpdateField = <K extends keyof typeof defaultFormData>(\n  key: K,\n  value: (typeof defaultFormData)[K],\n) => void;\n\nfunction renderColorSection(overrides: Partial<typeof defaultFormData> = {}) {\n  const updateField = vi.fn() as ReturnType<typeof vi.fn> & UpdateField;\n  const formData = { ...defaultFormData, ...overrides };\n\n  render(\n    <I18nextProvider i18n={i18n}>\n      <ColorSection\n        formData={formData}\n        updateField={updateField}\n        recentColors={[]}\n        onColorUsed={vi.fn()}\n        catalogColors={[]}\n      />\n    </I18nextProvider>,\n  );\n\n  const hexInput = screen.getByPlaceholderText('RRGGBB') as HTMLInputElement;\n  return { hexInput, updateField };\n}\n\nfunction lastRgba(updateField: ReturnType<typeof vi.fn>): string | undefined {\n  const rgbaCalls = updateField.mock.calls.filter(([key]) => key === 'rgba');\n  return rgbaCalls.at(-1)?.[1] as string | undefined;\n}\n\ndescribe('ColorSection hex input normalization (#1055)', () => {\n  it('pads a 6-char RRGGBB to 8-char RRGGBBAA with FF alpha', () => {\n    const { hexInput, updateField } = renderColorSection();\n    fireEvent.change(hexInput, { target: { value: 'FF0000' } });\n    expect(lastRgba(updateField)).toBe('FF0000FF');\n  });\n\n  it('passes an 8-char RRGGBBAA paste through unchanged', () => {\n    const { hexInput, updateField } = renderColorSection();\n    fireEvent.change(hexInput, { target: { value: '00112233' } });\n    expect(lastRgba(updateField)).toBe('00112233');\n  });\n\n  it('drops the stray 7th char — the exact #1055 trigger pattern', () => {\n    const { hexInput, updateField } = renderColorSection();\n    fireEvent.change(hexInput, { target: { value: 'FFFFFFF' } });\n    // Previously emitted \"FFFFFFF\" (7 chars) verbatim. Must now be 8 chars.\n    const rgba = lastRgba(updateField);\n    expect(rgba).toBe('FFFFFFFF');\n    expect(rgba).toMatch(/^[0-9A-F]{8}$/);\n  });\n\n  it('pads a 5-char input to 8 chars instead of emitting a 7-char rgba', () => {\n    // 5-char + 'FF' alpha = 7 chars was the other #1055 trigger pattern.\n    // Right-pad RGB to 6 with '0' so the output is always 8 chars.\n    const { hexInput, updateField } = renderColorSection();\n    fireEvent.change(hexInput, { target: { value: 'FFFFF' } });\n    const rgba = lastRgba(updateField);\n    expect(rgba).toBe('FFFFF0FF');\n    expect(rgba).toMatch(/^[0-9A-F]{8}$/);\n  });\n\n  it('pads any partial input to exactly 8 chars — never 7', () => {\n    // The essential invariant: for every legal input length (0..8), the\n    // emitted rgba must be 8 chars. Anything else risks reintroducing #1055.\n    const { hexInput, updateField } = renderColorSection();\n    for (const input of ['', 'F', 'FF', 'FFF', 'FFFF', 'FFFFF', 'FFFFFF', 'FFFFFFF', 'FFFFFFFF']) {\n      updateField.mockClear();\n      fireEvent.change(hexInput, { target: { value: input } });\n      const rgba = lastRgba(updateField);\n      expect(rgba).toBeDefined();\n      expect(rgba!.length).toBe(8);\n      expect(rgba).toMatch(/^[0-9A-F]{8}$/);\n    }\n  });\n\n  it('ignores input past 8 chars (no updateField call)', () => {\n    const { hexInput, updateField } = renderColorSection({ rgba: 'FFFFFFFF' });\n    updateField.mockClear();\n    fireEvent.change(hexInput, { target: { value: '0011223344' } });\n    expect(updateField.mock.calls.filter(([k]) => k === 'rgba')).toHaveLength(0);\n  });\n\n  it('strips non-hex characters before normalizing', () => {\n    // '#FF00ZZ' → strip '#' and non-hex → 'FF00' (4 chars) → pad to 6 + FF alpha\n    const { hexInput, updateField } = renderColorSection();\n    fireEvent.change(hexInput, { target: { value: '#FF00ZZ' } });\n    expect(lastRgba(updateField)).toBe('FF0000FF');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx",
    "content": "/**\n * Tests for AmsUnitCard component:\n * - Renders slot circles for a 4-slot AMS\n * - Shows slot labels (1, 2, 3, 4)\n * - Shows fill level bars\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport React from 'react';\nimport { AmsUnitCard } from '../../../components/spoolbuddy/AmsUnitCard';\nimport type { AMSUnit, AMSTray } from '../../../api/client';\n\nvi.mock('../../../utils/amsHelpers', () => ({\n  getFillBarColor: (fill: number) => {\n    if (fill > 50) return '#00ae42';\n    if (fill >= 15) return '#f59e0b';\n    return '#ef4444';\n  },\n}));\n\nfunction makeTray(overrides: Partial<AMSTray> = {}): AMSTray {\n  return {\n    id: 0,\n    tray_color: 'FF0000FF',\n    tray_type: 'PLA',\n    tray_sub_brands: null,\n    tray_id_name: null,\n    tray_info_idx: null,\n    remain: 80,\n    k: null,\n    cali_idx: null,\n    tag_uid: null,\n    tray_uuid: null,\n    nozzle_temp_min: null,\n    nozzle_temp_max: null,\n    drying_temp: null,\n    drying_time: null,\n    ...overrides,\n  };\n}\n\nfunction makeUnit(overrides: Partial<AMSUnit> = {}): AMSUnit {\n  return {\n    id: 0,\n    humidity: 30,\n    temp: 25,\n    is_ams_ht: false,\n    tray: [\n      makeTray({ id: 0, tray_color: 'FF0000FF', tray_type: 'PLA', remain: 80 }),\n      makeTray({ id: 1, tray_color: '00FF00FF', tray_type: 'PETG', remain: 50 }),\n      makeTray({ id: 2, tray_color: '0000FFFF', tray_type: 'ABS', remain: 10 }),\n      makeTray({ id: 3, tray_color: null, tray_type: '', remain: -1 }),\n    ],\n    serial_number: 'AMS001',\n    sw_ver: '1.0.0',\n    dry_time: 0,\n    dry_status: 0,\n    dry_sub_status: 0,\n    ...overrides,\n  };\n}\n\ndescribe('AmsUnitCard', () => {\n  it('renders 4 slot positions for a regular AMS', () => {\n    const { container } = render(\n      <AmsUnitCard unit={makeUnit()} activeSlot={null} />\n    );\n    // 4 slot numbers should be visible (1, 2, 3, 4)\n    expect(screen.getByText('1')).toBeDefined();\n    expect(screen.getByText('2')).toBeDefined();\n    expect(screen.getByText('3')).toBeDefined();\n    expect(screen.getByText('4')).toBeDefined();\n    // grid-cols-4 class should be present\n    const grid = container.querySelector('.grid-cols-4');\n    expect(grid).not.toBeNull();\n  });\n\n  it('renders AMS name in header', () => {\n    render(<AmsUnitCard unit={makeUnit({ id: 0 })} activeSlot={null} />);\n    expect(screen.getByText('AMS A')).toBeDefined();\n  });\n\n  it('shows material types for populated slots', () => {\n    render(<AmsUnitCard unit={makeUnit()} activeSlot={null} />);\n    expect(screen.getByText('PLA')).toBeDefined();\n    expect(screen.getByText('PETG')).toBeDefined();\n    expect(screen.getByText('ABS')).toBeDefined();\n  });\n\n  it('shows \"Empty\" for empty slot', () => {\n    render(<AmsUnitCard unit={makeUnit()} activeSlot={null} />);\n    expect(screen.getByText('Empty')).toBeDefined();\n  });\n\n  it('renders fill level bars for slots with filament', () => {\n    const { container } = render(\n      <AmsUnitCard unit={makeUnit()} activeSlot={null} />\n    );\n    // Look for fill bar elements (they have style width set to fill%)\n    const fillBars = container.querySelectorAll('.h-full.rounded-full.transition-all');\n    // 3 populated slots should have fill bars (slot 4 is empty)\n    expect(fillBars.length).toBe(3);\n  });\n\n  it('renders only 1 slot for AMS-HT', () => {\n    const htUnit = makeUnit({\n      is_ams_ht: true,\n      tray: [makeTray({ id: 0, tray_type: 'PLA', remain: 90 })],\n    });\n    const { container } = render(\n      <AmsUnitCard unit={htUnit} activeSlot={null} />\n    );\n    const grid = container.querySelector('.grid-cols-1');\n    expect(grid).not.toBeNull();\n    expect(screen.getByText('1')).toBeDefined();\n  });\n\n  it('shows humidity and temperature indicators', () => {\n    render(<AmsUnitCard unit={makeUnit({ humidity: 45, temp: 30 })} activeSlot={null} />);\n    expect(screen.getByText('45%')).toBeDefined();\n  });\n\n  it('highlights active slot with ring', () => {\n    const { container } = render(\n      <AmsUnitCard unit={makeUnit()} activeSlot={1} />\n    );\n    const activeSlot = container.querySelector('.ring-2.ring-bambu-green');\n    expect(activeSlot).not.toBeNull();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/spoolbuddy/SpoolBuddyBottomNav.test.tsx",
    "content": "/**\n * Tests for SpoolBuddyBottomNav component:\n * - Renders 4 nav items (Dashboard, AMS, Write, Settings)\n * - NavLinks have correct paths\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport React from 'react';\nimport { MemoryRouter } from 'react-router-dom';\nimport { SpoolBuddyBottomNav } from '../../../components/spoolbuddy/SpoolBuddyBottomNav';\n\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (_key: string, fallback: string) => fallback,\n    i18n: { language: 'en', changeLanguage: vi.fn() },\n  }),\n}));\n\nfunction renderNav() {\n  return render(\n    <MemoryRouter initialEntries={['/spoolbuddy']}>\n      <SpoolBuddyBottomNav />\n    </MemoryRouter>\n  );\n}\n\ndescribe('SpoolBuddyBottomNav', () => {\n  it('renders 4 nav items', () => {\n    renderNav();\n    expect(screen.getByText('Dashboard')).toBeDefined();\n    expect(screen.getByText('AMS')).toBeDefined();\n    expect(screen.getByText('Write')).toBeDefined();\n    expect(screen.getByText('Settings')).toBeDefined();\n  });\n\n  it('has correct link for Dashboard', () => {\n    renderNav();\n    const link = screen.getByText('Dashboard').closest('a');\n    expect(link!.getAttribute('href')).toBe('/spoolbuddy');\n  });\n\n  it('has correct link for AMS', () => {\n    renderNav();\n    const link = screen.getByText('AMS').closest('a');\n    expect(link!.getAttribute('href')).toBe('/spoolbuddy/ams');\n  });\n\n  it('has correct link for Write', () => {\n    renderNav();\n    const link = screen.getByText('Write').closest('a');\n    expect(link!.getAttribute('href')).toBe('/spoolbuddy/write-tag');\n  });\n\n  it('has correct link for Settings', () => {\n    renderNav();\n    const link = screen.getByText('Settings').closest('a');\n    expect(link!.getAttribute('href')).toBe('/spoolbuddy/settings');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx",
    "content": "/**\n * Tests for SpoolBuddyLayout component:\n * - Renders without crashing\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { render } from '@testing-library/react';\nimport React from 'react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { MemoryRouter, Route, Routes } from 'react-router-dom';\nimport { SpoolBuddyLayout } from '../../../components/spoolbuddy/SpoolBuddyLayout';\n\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (_key: string, fallback: string) => fallback,\n    i18n: { language: 'en', changeLanguage: vi.fn() },\n  }),\n}));\n\nvi.mock('../../../api/client', () => ({\n  api: {\n    getPrinters: vi.fn().mockResolvedValue([]),\n    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),\n    getSettings: vi.fn().mockResolvedValue({ time_format: 'system', language: 'en' }),\n  },\n  spoolbuddyApi: {\n    getDevices: vi.fn().mockResolvedValue([]),\n  },\n}));\n\nvi.mock('../../../utils/date', () => ({\n  formatTimeOnly: () => '12:00',\n}));\n\nvi.mock('lucide-react', () => ({\n  WifiOff: (props: Record<string, unknown>) => <span data-testid=\"wifi-off\" {...props} />,\n}));\n\nvi.mock('../../../components/VirtualKeyboard', () => ({\n  VirtualKeyboard: () => <div data-testid=\"virtual-keyboard\" />,\n}));\n\nfunction renderLayout() {\n  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });\n  return render(\n    <QueryClientProvider client={qc}>\n      <MemoryRouter initialEntries={['/spoolbuddy']}>\n        <Routes>\n          <Route path=\"spoolbuddy\" element={<SpoolBuddyLayout />}>\n            <Route index element={<div data-testid=\"child-page\">Child</div>} />\n          </Route>\n        </Routes>\n      </MemoryRouter>\n    </QueryClientProvider>\n  );\n}\n\ndescribe('SpoolBuddyLayout', () => {\n  it('renders without crashing', () => {\n    const { container } = renderLayout();\n    expect(container.firstChild).not.toBeNull();\n  });\n\n  it('renders the top bar with logo', () => {\n    renderLayout();\n    const img = document.querySelector('img[alt=\"SpoolBuddy\"]');\n    expect(img).not.toBeNull();\n  });\n\n  it('renders the bottom nav', () => {\n    renderLayout();\n    const nav = document.querySelector('nav');\n    expect(nav).not.toBeNull();\n  });\n\n  it('renders the status bar', () => {\n    renderLayout();\n    // Status bar shows \"System Ready\" by default (device offline triggers warning later via useEffect)\n    // Just check the status bar container exists\n    const statusBar = document.querySelector('.shrink-0.h-9');\n    expect(statusBar).not.toBeNull();\n  });\n\n  it('renders child outlet content', () => {\n    renderLayout();\n    const child = document.querySelector('[data-testid=\"child-page\"]');\n    expect(child).not.toBeNull();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/spoolbuddy/SpoolBuddyQuickMenu.test.tsx",
    "content": "/**\n * Tests for SpoolBuddyQuickMenu component:\n * - Renders nothing when closed\n * - Shows printer power section with smart plugs\n * - Shows system control buttons\n * - Confirmation dialogs for destructive actions\n * - Handles system commands\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport { render } from '../../utils';\nimport { SpoolBuddyQuickMenu } from '../../../components/spoolbuddy/SpoolBuddyQuickMenu';\nimport { api, spoolbuddyApi } from '../../../api/client';\n\nvi.mock('../../../api/client', () => ({\n  api: {\n    getPrinters: vi.fn().mockResolvedValue([]),\n    getSmartPlugs: vi.fn().mockResolvedValue([]),\n    getSmartPlugStatus: vi.fn().mockResolvedValue({ state: 'OFF', reachable: true, device_name: null, energy: null }),\n    controlSmartPlug: vi.fn().mockResolvedValue({ success: true, action: 'toggle' }),\n    getSettings: vi.fn().mockResolvedValue({}),\n    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),\n  },\n  spoolbuddyApi: {\n    systemCommand: vi.fn().mockResolvedValue({ status: 'queued', command: 'reboot' }),\n  },\n}));\n\nconst defaultProps = {\n  isOpen: true,\n  onClose: vi.fn(),\n  deviceId: 'sb-0001',\n  deviceOnline: true,\n};\n\nconst mockPrinter = {\n  id: 1,\n  name: 'Test P1S',\n  model: 'P1S',\n  serial: 'SERIAL001',\n  ip_address: '10.0.0.1',\n};\n\nconst mockSmartPlug = {\n  id: 10,\n  name: 'P1S Plug',\n  plug_type: 'tasmota' as const,\n  ip_address: '10.0.0.100',\n  printer_id: 1,\n  enabled: true,\n  ha_entity_id: null,\n  ha_power_entity: null,\n  ha_energy_today_entity: null,\n  ha_energy_total_entity: null,\n  mqtt_topic: null,\n  mqtt_multiplier: 1,\n  mqtt_power_topic: null,\n  mqtt_power_multiplier: 1,\n  mqtt_energy_topic: null,\n  mqtt_energy_multiplier: 1,\n  mqtt_state_topic: null,\n  rest_on_url: null,\n  rest_off_url: null,\n  rest_status_url: null,\n  rest_status_path: null,\n  rest_on_value: null,\n  rest_off_value: null,\n  rest_method: null,\n  rest_power_url: null,\n  rest_power_path: null,\n  rest_power_multiplier: 1,\n  rest_energy_url: null,\n  rest_energy_path: null,\n  rest_energy_multiplier: 1,\n  auto_on: false,\n  auto_off: false,\n  auto_off_persistent: false,\n  off_delay_mode: 'time' as const,\n  off_delay_minutes: 5,\n  off_temp_threshold: 50,\n  username: null,\n  password: null,\n  power_alert_enabled: false,\n  power_alert_threshold: 0,\n  power_alert_duration: 0,\n  schedule_enabled: false,\n  schedule_on_time: null,\n  schedule_off_time: null,\n  last_state: null,\n  last_checked: null,\n  auto_off_executed: false,\n  auto_off_pending: false,\n};\n\ndescribe('SpoolBuddyQuickMenu', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n    (api.getSmartPlugs as ReturnType<typeof vi.fn>).mockResolvedValue([]);\n  });\n\n  it('renders nothing when closed', () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} isOpen={false} />);\n    expect(screen.queryByText('System')).not.toBeInTheDocument();\n  });\n\n  it('shows system control buttons when open', () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n    expect(screen.getByText('Restart Daemon')).toBeInTheDocument();\n    expect(screen.getByText('Restart Browser')).toBeInTheDocument();\n    expect(screen.getByText('Reboot')).toBeInTheDocument();\n    expect(screen.getByText('Shutdown')).toBeInTheDocument();\n  });\n\n  it('shows system section header', () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n    expect(screen.getByText('System')).toBeInTheDocument();\n  });\n\n  it('shows swipe hint', () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n    expect(screen.getByText('Swipe down to close')).toBeInTheDocument();\n  });\n\n  it('shows printer power section when printers have smart plugs', async () => {\n    (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValue([mockPrinter]);\n    (api.getSmartPlugs as ReturnType<typeof vi.fn>).mockResolvedValue([mockSmartPlug]);\n\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Printer Power')).toBeInTheDocument();\n    });\n\n    await waitFor(() => {\n      expect(screen.getByText('Test P1S')).toBeInTheDocument();\n    });\n  });\n\n  it('does not show printer power section when no smart plugs', () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n    expect(screen.queryByText('Printer Power')).not.toBeInTheDocument();\n  });\n\n  it('shows confirmation dialog for reboot', async () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    fireEvent.click(screen.getByText('Reboot'));\n\n    await waitFor(() => {\n      expect(screen.getByText('Are you sure you want to reboot the SpoolBuddy?')).toBeInTheDocument();\n    });\n  });\n\n  it('shows confirmation dialog for shutdown with warning', async () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    fireEvent.click(screen.getByText('Shutdown'));\n\n    await waitFor(() => {\n      expect(screen.getByText(/physical access/)).toBeInTheDocument();\n    });\n  });\n\n  it('shows confirmation dialog for restart daemon', async () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    fireEvent.click(screen.getByText('Restart Daemon'));\n\n    await waitFor(() => {\n      expect(screen.getByText(/NFC and scale will be temporarily unavailable/)).toBeInTheDocument();\n    });\n  });\n\n  it('shows confirmation dialog for restart browser', async () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    fireEvent.click(screen.getByText('Restart Browser'));\n\n    await waitFor(() => {\n      expect(screen.getByText(/display will briefly go blank/)).toBeInTheDocument();\n    });\n  });\n\n  it('cancels confirmation dialog', async () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    fireEvent.click(screen.getByText('Reboot'));\n    await waitFor(() => {\n      expect(screen.getByText('Are you sure you want to reboot the SpoolBuddy?')).toBeInTheDocument();\n    });\n\n    fireEvent.click(screen.getByText('Cancel'));\n    await waitFor(() => {\n      expect(screen.queryByText('Are you sure you want to reboot the SpoolBuddy?')).not.toBeInTheDocument();\n    });\n  });\n\n  it('sends system command on confirm', async () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    fireEvent.click(screen.getByText('Reboot'));\n    await waitFor(() => {\n      expect(screen.getByText('Are you sure you want to reboot the SpoolBuddy?')).toBeInTheDocument();\n    });\n\n    // Click the Confirm button (not the title)\n    fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));\n    await waitFor(() => {\n      expect(spoolbuddyApi.systemCommand).toHaveBeenCalledWith('sb-0001', 'reboot');\n    });\n  });\n\n  it('shows confirmation when toggling printer plug', async () => {\n    (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValue([mockPrinter]);\n    (api.getSmartPlugs as ReturnType<typeof vi.fn>).mockResolvedValue([mockSmartPlug]);\n\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Test P1S')).toBeInTheDocument();\n    });\n\n    fireEvent.click(screen.getByText('Test P1S'));\n\n    await waitFor(() => {\n      expect(screen.getByText(/Turn on Test P1S/)).toBeInTheDocument();\n    });\n  });\n\n  it('toggles plug after confirming', async () => {\n    (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValue([mockPrinter]);\n    (api.getSmartPlugs as ReturnType<typeof vi.fn>).mockResolvedValue([mockSmartPlug]);\n\n    render(<SpoolBuddyQuickMenu {...defaultProps} />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Test P1S')).toBeInTheDocument();\n    });\n\n    fireEvent.click(screen.getByText('Test P1S'));\n\n    await waitFor(() => {\n      expect(screen.getByRole('button', { name: 'Turn On' })).toBeInTheDocument();\n    });\n\n    fireEvent.click(screen.getByRole('button', { name: 'Turn On' }));\n\n    await waitFor(() => {\n      expect(api.controlSmartPlug).toHaveBeenCalledWith(10, 'toggle');\n    });\n  });\n\n  it('disables system buttons when device offline', () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} deviceOnline={false} />);\n\n    const rebootBtn = screen.getByText('Reboot').closest('button');\n    expect(rebootBtn).toBeDisabled();\n  });\n\n  it('disables system buttons when no device ID', () => {\n    render(<SpoolBuddyQuickMenu {...defaultProps} deviceId={null} />);\n\n    const shutdownBtn = screen.getByText('Shutdown').closest('button');\n    expect(shutdownBtn).toBeDisabled();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/spoolbuddy/SpoolBuddyStatusBar.test.tsx",
    "content": "/**\n * Tests for SpoolBuddyStatusBar component:\n * - Shows \"System Ready\" with green when no alert\n * - Shows warning message with amber styling\n * - Shows error message with red styling\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport React from 'react';\nimport { SpoolBuddyStatusBar } from '../../../components/spoolbuddy/SpoolBuddyStatusBar';\n\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (_key: string, fallback: string) => fallback,\n    i18n: { language: 'en', changeLanguage: vi.fn() },\n  }),\n}));\n\ndescribe('SpoolBuddyStatusBar', () => {\n  it('shows \"System Ready\" when no alert', () => {\n    render(<SpoolBuddyStatusBar />);\n    expect(screen.getByText('System Ready')).toBeDefined();\n  });\n\n  it('uses green status LED when no alert', () => {\n    const { container } = render(<SpoolBuddyStatusBar />);\n    const led = container.querySelector('.rounded-full');\n    expect(led!.className).toContain('bg-bambu-green');\n  });\n\n  it('shows warning message with amber styling', () => {\n    const { container } = render(\n      <SpoolBuddyStatusBar alert={{ type: 'warning', message: 'Low filament' }} />\n    );\n    expect(screen.getByText('Low filament')).toBeDefined();\n    const led = container.querySelector('.rounded-full');\n    expect(led!.className).toContain('bg-amber-500');\n    // Border should also be amber\n    const bar = container.firstElementChild as HTMLElement;\n    expect(bar.className).toContain('border-amber-500');\n  });\n\n  it('shows error message with red styling', () => {\n    const { container } = render(\n      <SpoolBuddyStatusBar alert={{ type: 'error', message: 'Connection lost' }} />\n    );\n    expect(screen.getByText('Connection lost')).toBeDefined();\n    const led = container.querySelector('.rounded-full');\n    expect(led!.className).toContain('bg-red-500');\n    const bar = container.firstElementChild as HTMLElement;\n    expect(bar.className).toContain('border-red-500');\n  });\n\n  it('shows info alert with green styling', () => {\n    const { container } = render(\n      <SpoolBuddyStatusBar alert={{ type: 'info', message: 'Update available' }} />\n    );\n    expect(screen.getByText('Update available')).toBeDefined();\n    const led = container.querySelector('.rounded-full');\n    expect(led!.className).toContain('bg-bambu-green');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/spoolbuddy/SpoolBuddyTopBar.test.tsx",
    "content": "/**\n * Tests for SpoolBuddyTopBar component:\n * - Renders the logo image\n * - Renders the printer selector\n * - Shows backend status indicator\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport React from 'react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { SpoolBuddyTopBar } from '../../../components/spoolbuddy/SpoolBuddyTopBar';\n\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (_key: string, fallback: string) => fallback,\n    i18n: { language: 'en', changeLanguage: vi.fn() },\n  }),\n}));\n\nvi.mock('../../../api/client', () => ({\n  api: {\n    getPrinters: vi.fn().mockResolvedValue([]),\n    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),\n    getSettings: vi.fn().mockResolvedValue({ time_format: 'system' }),\n  },\n}));\n\nvi.mock('../../../utils/date', () => ({\n  formatTimeOnly: () => '12:00',\n}));\n\nvi.mock('lucide-react', () => ({\n  WifiOff: (props: Record<string, unknown>) => <span data-testid=\"wifi-off\" {...props} />,\n}));\n\nfunction renderTopBar(deviceOnline = false) {\n  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });\n  return render(\n    <QueryClientProvider client={qc}>\n      <SpoolBuddyTopBar\n        selectedPrinterId={null}\n        onPrinterChange={vi.fn()}\n        deviceOnline={deviceOnline}\n      />\n    </QueryClientProvider>\n  );\n}\n\ndescribe('SpoolBuddyTopBar', () => {\n  it('renders the logo image', () => {\n    renderTopBar();\n    const img = screen.getByAltText('SpoolBuddy');\n    expect(img).toBeDefined();\n    expect(img.getAttribute('src')).toBe('/img/spoolbuddy_logo_dark_small.png');\n  });\n\n  it('renders the printer selector', () => {\n    renderTopBar();\n    // Select element with \"No printers online\" fallback\n    const select = screen.getByRole('combobox');\n    expect(select).toBeDefined();\n  });\n\n  it('shows offline status when device is offline', () => {\n    renderTopBar(false);\n    expect(screen.getByText('Offline')).toBeDefined();\n    expect(screen.getByTestId('wifi-off')).toBeDefined();\n  });\n\n  it('shows backend status when device is online', () => {\n    renderTopBar(true);\n    expect(screen.getByText('Backend')).toBeDefined();\n  });\n\n  it('shows clock time', () => {\n    renderTopBar();\n    expect(screen.getByText('12:00')).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx",
    "content": "/**\n * Tests for SpoolIcon component:\n * - Renders SVG when not empty (with correct color)\n * - Renders dashed circle when isEmpty=true\n * - Respects size prop\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { render } from '@testing-library/react';\nimport React from 'react';\nimport { SpoolIcon } from '../../../components/spoolbuddy/SpoolIcon';\n\ndescribe('SpoolIcon', () => {\n  it('renders SVG when not empty', () => {\n    const { container } = render(<SpoolIcon color=\"#FF0000\" isEmpty={false} />);\n    const svg = container.querySelector('svg');\n    expect(svg).not.toBeNull();\n  });\n\n  it('renders SVG with correct color in fill', () => {\n    const { container } = render(<SpoolIcon color=\"#00AE42\" isEmpty={false} />);\n    const circles = container.querySelectorAll('circle');\n    // First circle has the color as fill\n    expect(circles[0].getAttribute('fill')).toBe('#00AE42');\n  });\n\n  it('renders dashed circle when isEmpty=true', () => {\n    const { container } = render(<SpoolIcon color=\"#FF0000\" isEmpty={true} />);\n    // No SVG, should be a div with border-dashed\n    const svg = container.querySelector('svg');\n    expect(svg).toBeNull();\n    const div = container.firstElementChild as HTMLElement;\n    expect(div.className).toContain('border-dashed');\n  });\n\n  it('uses default size of 32', () => {\n    const { container } = render(<SpoolIcon color=\"#FF0000\" isEmpty={false} />);\n    const svg = container.querySelector('svg');\n    expect(svg!.getAttribute('width')).toBe('32');\n    expect(svg!.getAttribute('height')).toBe('32');\n  });\n\n  it('respects custom size prop', () => {\n    const { container } = render(<SpoolIcon color=\"#FF0000\" isEmpty={false} size={64} />);\n    const svg = container.querySelector('svg');\n    expect(svg!.getAttribute('width')).toBe('64');\n    expect(svg!.getAttribute('height')).toBe('64');\n  });\n\n  it('respects custom size prop for empty spool', () => {\n    const { container } = render(<SpoolIcon color=\"#FF0000\" isEmpty={true} size={48} />);\n    const div = container.firstElementChild as HTMLElement;\n    expect(div.style.width).toBe('48px');\n    expect(div.style.height).toBe('48px');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/contexts/AuthContext.test.tsx",
    "content": "/**\n * Tests for the AuthContext permission helpers.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { renderHook, waitFor } from '@testing-library/react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { BrowserRouter } from 'react-router-dom';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\nimport { AuthProvider, useAuth } from '../../contexts/AuthContext';\nimport { ThemeProvider } from '../../contexts/ThemeContext';\nimport { ToastProvider } from '../../contexts/ToastContext';\nimport type { Permission } from '../../api/client';\n\nfunction createWrapper() {\n  const queryClient = new QueryClient({\n    defaultOptions: {\n      queries: { retry: false },\n    },\n  });\n\n  return function Wrapper({ children }: { children: React.ReactNode }) {\n    return (\n      <QueryClientProvider client={queryClient}>\n        <BrowserRouter>\n          <ThemeProvider>\n            <ToastProvider>\n              <AuthProvider>{children}</AuthProvider>\n            </ToastProvider>\n          </ThemeProvider>\n        </BrowserRouter>\n      </QueryClientProvider>\n    );\n  };\n}\n\ndescribe('AuthContext', () => {\n  describe('when auth is disabled', () => {\n    beforeEach(() => {\n      server.use(\n        http.get('/api/v1/auth/status', () => {\n          return HttpResponse.json({\n            auth_enabled: false,\n            requires_setup: false,\n          });\n        })\n      );\n    });\n\n    it('authEnabled is false', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.authEnabled).toBe(false);\n      });\n    });\n\n    it('hasPermission returns true for any permission when auth disabled', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.authEnabled).toBe(false);\n      });\n\n      // When auth is disabled, all permissions should be granted\n      expect(result.current.hasPermission('printers:read' as Permission)).toBe(true);\n      expect(result.current.hasPermission('settings:update' as Permission)).toBe(true);\n      expect(result.current.hasPermission('users:delete' as Permission)).toBe(true);\n    });\n\n    it('hasAnyPermission returns true for any permissions when auth disabled', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.authEnabled).toBe(false);\n      });\n\n      expect(\n        result.current.hasAnyPermission('printers:read' as Permission, 'settings:update' as Permission)\n      ).toBe(true);\n    });\n\n    it('hasAllPermissions returns true for any permissions when auth disabled', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.authEnabled).toBe(false);\n      });\n\n      expect(\n        result.current.hasAllPermissions('printers:read' as Permission, 'settings:update' as Permission)\n      ).toBe(true);\n    });\n  });\n\n  describe('when auth requires setup', () => {\n    beforeEach(() => {\n      server.use(\n        http.get('/api/v1/auth/status', () => {\n          return HttpResponse.json({\n            auth_enabled: false,\n            requires_setup: true,\n          });\n        })\n      );\n    });\n\n    it('requiresSetup is true', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.requiresSetup).toBe(true);\n      });\n    });\n  });\n\n  describe('when auth is enabled but not logged in', () => {\n    beforeEach(() => {\n      // Clear any stored token\n      localStorage.removeItem('auth_token');\n\n      server.use(\n        http.get('/api/v1/auth/status', () => {\n          return HttpResponse.json({\n            auth_enabled: true,\n            requires_setup: false,\n          });\n        })\n      );\n    });\n\n    it('user is null when not logged in', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.authEnabled).toBe(true);\n      });\n\n      // User should be null when not logged in\n      expect(result.current.user).toBeNull();\n    });\n\n    it('hasPermission returns false when not logged in', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.authEnabled).toBe(true);\n      });\n\n      // Without a user, permissions should be denied\n      expect(result.current.hasPermission('printers:read' as Permission)).toBe(false);\n    });\n  });\n\n  describe('CVE-2026-25505 fix: auth disabled grants all access', () => {\n    beforeEach(() => {\n      server.use(\n        http.get('/api/v1/auth/status', () => {\n          return HttpResponse.json({\n            auth_enabled: false,\n            requires_setup: false,\n          });\n        })\n      );\n    });\n\n    it('isAdmin is true when auth is disabled', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.loading).toBe(false);\n      });\n\n      // When auth disabled, user is treated as admin\n      expect(result.current.isAdmin).toBe(true);\n    });\n\n    it('canModify allows all modifications when auth disabled', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.loading).toBe(false);\n      });\n\n      // All canModify checks should pass when auth is disabled\n      expect(result.current.canModify('queue', 'update', 1)).toBe(true);\n      expect(result.current.canModify('queue', 'update', 999)).toBe(true);\n      expect(result.current.canModify('queue', 'update', null)).toBe(true);\n      expect(result.current.canModify('archives', 'delete', 1)).toBe(true);\n      expect(result.current.canModify('library', 'update', null)).toBe(true);\n    });\n\n    it('all permissions are granted when auth is disabled', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.loading).toBe(false);\n      });\n\n      // All permission checks should pass\n      expect(result.current.hasPermission('archives:read' as Permission)).toBe(true);\n      expect(result.current.hasPermission('archives:delete_all' as Permission)).toBe(true);\n      expect(result.current.hasPermission('settings:update' as Permission)).toBe(true);\n      expect(result.current.hasPermission('api_keys:create' as Permission)).toBe(true);\n      expect(result.current.hasPermission('groups:delete' as Permission)).toBe(true);\n    });\n\n    it('hasAnyPermission returns true for protected permissions', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.loading).toBe(false);\n      });\n\n      expect(\n        result.current.hasAnyPermission(\n          'api_keys:create' as Permission,\n          'groups:delete' as Permission\n        )\n      ).toBe(true);\n    });\n\n    it('hasAllPermissions returns true for any combination', async () => {\n      const { result } = renderHook(() => useAuth(), {\n        wrapper: createWrapper(),\n      });\n\n      await waitFor(() => {\n        expect(result.current.loading).toBe(false);\n      });\n\n      expect(\n        result.current.hasAllPermissions(\n          'settings:update' as Permission,\n          'api_keys:create' as Permission,\n          'groups:delete' as Permission\n        )\n      ).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/contexts/ColorCatalogContext.test.tsx",
    "content": "/**\n * Tests for ColorCatalogProvider — the provider that fetches the backend color\n * catalog once per session and pushes it into the module-level store used by\n * getColorName / resolveSpoolColorName. See #857.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { render, waitFor } from '@testing-library/react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { BrowserRouter } from 'react-router-dom';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\nimport { ColorCatalogProvider } from '../../contexts/ColorCatalogContext';\nimport { AuthProvider } from '../../contexts/AuthContext';\nimport { ThemeProvider } from '../../contexts/ThemeContext';\nimport { ToastProvider } from '../../contexts/ToastContext';\nimport { getColorName, __resetColorCatalogForTests } from '../../utils/colors';\n\nfunction createWrapper() {\n  const queryClient = new QueryClient({\n    defaultOptions: {\n      queries: { retry: false, gcTime: 0 },\n    },\n  });\n\n  return function Wrapper({ children }: { children: React.ReactNode }) {\n    return (\n      <QueryClientProvider client={queryClient}>\n        <BrowserRouter>\n          <ThemeProvider>\n            <ToastProvider>\n              <AuthProvider>\n                <ColorCatalogProvider>{children}</ColorCatalogProvider>\n              </AuthProvider>\n            </ToastProvider>\n          </ThemeProvider>\n        </BrowserRouter>\n      </QueryClientProvider>\n    );\n  };\n}\n\ndescribe('ColorCatalogProvider', () => {\n  beforeEach(() => {\n    __resetColorCatalogForTests();\n    // Default auth handler: auth disabled so the provider's query is allowed\n    // to fire immediately without requiring a login.\n    server.use(\n      http.get('/api/v1/auth/status', () =>\n        HttpResponse.json({ auth_enabled: false, requires_setup: false })\n      )\n    );\n  });\n\n  it('populates the runtime catalog from /inventory/colors/map', async () => {\n    server.use(\n      http.get('/api/v1/inventory/colors/map', () =>\n        HttpResponse.json({\n          colors: {\n            f5b6cd: 'Cherry Pink',\n            de4343: 'Scarlet Red',\n            '8344b0': 'Purple',\n          },\n        })\n      )\n    );\n\n    const Wrapper = createWrapper();\n    render(\n      <Wrapper>\n        <div data-testid=\"child\">ok</div>\n      </Wrapper>\n    );\n\n    await waitFor(() => {\n      // Regression for #857: before the fix, F5B6CD resolved to 'Scarlet Red'\n      // via the suffix fallback. After the fix, it resolves from the catalog.\n      expect(getColorName('f5b6cd')).toBe('Cherry Pink');\n    });\n    expect(getColorName('de4343')).toBe('Scarlet Red');\n    expect(getColorName('8344b0')).toBe('Purple');\n  });\n\n  it('still renders children even when the catalog fetch fails', async () => {\n    server.use(\n      http.get('/api/v1/inventory/colors/map', () =>\n        HttpResponse.json({ detail: 'kaboom' }, { status: 500 })\n      )\n    );\n\n    const Wrapper = createWrapper();\n    const { getByTestId } = render(\n      <Wrapper>\n        <div data-testid=\"child\">ok</div>\n      </Wrapper>\n    );\n\n    // The provider must not block rendering on a failed fetch — if it did,\n    // the whole app would white-screen whenever the backend /colors/map route\n    // 500'd. The catalog is load-bearing for cosmetics, not correctness.\n    expect(getByTestId('child').textContent).toBe('ok');\n  });\n\n  it('falls back to HSL-bucket name when catalog miss', async () => {\n    server.use(\n      http.get('/api/v1/inventory/colors/map', () =>\n        HttpResponse.json({ colors: { f5b6cd: 'Cherry Pink' } })\n      )\n    );\n\n    const Wrapper = createWrapper();\n    render(\n      <Wrapper>\n        <div>ok</div>\n      </Wrapper>\n    );\n\n    // Wait for the provider to load the catalog at least once.\n    await waitFor(() => {\n      expect(getColorName('f5b6cd')).toBe('Cherry Pink');\n    });\n\n    // A hex that isn't in the (limited) catalog must fall through to HSL so\n    // unknown colors still get *some* name rather than the raw hex code.\n    expect(getColorName('123456')).toBe('Blue');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/contexts/ToastContext.test.tsx",
    "content": "/**\n * Tests for ToastContext's post-unmount safety guards.\n *\n * Regression: a login response handler calling showToast AFTER the provider\n * had already been unmounted by Vitest's afterEach scheduled a 3s setTimeout\n * that fired during test teardown. The callback's setToasts then tried to\n * schedule a React update against a torn-down jsdom, producing\n * \"window is not defined\" as an uncaught exception.\n *\n * The provider now gates every setToasts call on an isMountedRef and\n * re-checks inside the auto-dismiss setTimeout callback so stale async\n * paths no-op instead of crashing.\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { act, renderHook } from '@testing-library/react';\nimport { type ReactNode } from 'react';\nimport { ToastProvider, useToast } from '../../contexts/ToastContext';\n\nfunction Wrapper({ children }: { children: ReactNode }) {\n  return <ToastProvider>{children}</ToastProvider>;\n}\n\ndescribe('ToastContext post-unmount safety', () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n  });\n\n  it('does not crash when showToast is called after unmount', () => {\n    const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });\n\n    // Capture the callbacks BEFORE unmount — a real stale-closure scenario.\n    // (Async handlers that kicked off before unmount keep their captured\n    // context value and will invoke this function after we tear down.)\n    const { showToast } = result.current;\n\n    unmount();\n\n    // Post-unmount invocation is now a no-op; must not throw.\n    expect(() => showToast('delayed error message', 'error')).not.toThrow();\n  });\n\n  it('does not invoke setToasts when the auto-dismiss timer fires after unmount', async () => {\n    vi.useFakeTimers();\n\n    const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });\n\n    act(() => {\n      result.current.showToast('will outlive the provider', 'error');\n    });\n\n    // Unmount BEFORE the 3s timer fires — the unmount effect clears pending\n    // timers, but a belt-and-braces check inside the timer callback (for\n    // cases where the timer was scheduled post-unmount) must also hold.\n    unmount();\n\n    // Advance past the 3s auto-dismiss window. If the guard isn't in place\n    // this would throw \"window is not defined\" in a torn-down jsdom; we\n    // simulate by asserting no error propagates.\n    expect(() => {\n      vi.advanceTimersByTime(5000);\n    }).not.toThrow();\n\n    vi.useRealTimers();\n  });\n\n  it('post-unmount showPersistentToast and dismissToast are no-ops', () => {\n    const { result, unmount } = renderHook(() => useToast(), { wrapper: Wrapper });\n    const { showPersistentToast, dismissToast } = result.current;\n    unmount();\n\n    // Both must short-circuit rather than attempt setState on a dead tree.\n    expect(() => showPersistentToast('orphan', 'still here', 'info')).not.toThrow();\n    expect(() => dismissToast('orphan')).not.toThrow();\n  });\n\n  it('normal showToast flow still displays and auto-dismisses while mounted', () => {\n    vi.useFakeTimers();\n    const { result } = renderHook(() => useToast(), { wrapper: Wrapper });\n\n    act(() => {\n      result.current.showToast('mounted path works', 'success');\n    });\n\n    // No easy way to read toast DOM from the hook alone; assert the timer\n    // ran without throwing — that proves the isMountedRef guard didn't\n    // incorrectly short-circuit the mounted path.\n    expect(() => {\n      act(() => {\n        vi.advanceTimersByTime(3500);\n      });\n    }).not.toThrow();\n\n    vi.useRealTimers();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/hooks/useCameraStreamToken.test.ts",
    "content": "/**\n * Unit tests for rewriteMediaSrcWithToken — the DOM walker that retrofits a\n * camera stream token onto <img>/<video> src URLs that rendered before the\n * token arrived (regression guard for the post-login blank-thumbnails bug).\n */\n\nimport { afterEach, beforeEach, describe, expect, it } from 'vitest';\nimport { rewriteMediaSrcWithToken } from '../../hooks/useCameraStreamToken';\n\ndescribe('rewriteMediaSrcWithToken', () => {\n  let root: HTMLDivElement;\n\n  beforeEach(() => {\n    root = document.createElement('div');\n    document.body.appendChild(root);\n  });\n\n  afterEach(() => {\n    root.remove();\n  });\n\n  const addImg = (src: string) => {\n    const img = document.createElement('img');\n    img.setAttribute('src', src);\n    root.appendChild(img);\n    return img;\n  };\n\n  const addVideo = (src: string) => {\n    const v = document.createElement('video');\n    v.setAttribute('src', src);\n    root.appendChild(v);\n    return v;\n  };\n\n  it('appends token to /api/v1/ images that have no query string', () => {\n    const img = addImg('/api/v1/library/files/42/thumbnail');\n    const count = rewriteMediaSrcWithToken(root, 'abc123');\n    expect(count).toBe(1);\n    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=abc123');\n  });\n\n  it('appends token to URLs that already have a query string using & separator', () => {\n    const img = addImg('/api/v1/archives/5/thumbnail?v=1700000000000');\n    rewriteMediaSrcWithToken(root, 'abc123');\n    expect(img.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?v=1700000000000&token=abc123');\n  });\n\n  it('leaves images alone that already carry the current token', () => {\n    const img = addImg('/api/v1/library/files/42/thumbnail?token=abc123');\n    const count = rewriteMediaSrcWithToken(root, 'abc123');\n    expect(count).toBe(0);\n    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=abc123');\n  });\n\n  it('replaces a stale token with the current one', () => {\n    const img = addImg('/api/v1/library/files/42/thumbnail?token=OLD');\n    rewriteMediaSrcWithToken(root, 'NEW');\n    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=NEW');\n  });\n\n  it('replaces a stale token that sits in the middle of the query string', () => {\n    const img = addImg('/api/v1/archives/5/thumbnail?token=OLD&v=1700000000000');\n    rewriteMediaSrcWithToken(root, 'NEW');\n    // Old token stripped, v preserved, new token appended.\n    expect(img.getAttribute('src')).toBe('/api/v1/archives/5/thumbnail?v=1700000000000&token=NEW');\n  });\n\n  it('ignores images that do not point at /api/v1/', () => {\n    const img = addImg('https://cdn.example.com/static/logo.png');\n    rewriteMediaSrcWithToken(root, 'abc123');\n    expect(img.getAttribute('src')).toBe('https://cdn.example.com/static/logo.png');\n  });\n\n  it('updates <video> elements as well', () => {\n    const v = addVideo('/api/v1/printers/7/camera/stream?fps=10');\n    rewriteMediaSrcWithToken(root, 'abc123');\n    expect(v.getAttribute('src')).toBe('/api/v1/printers/7/camera/stream?fps=10&token=abc123');\n  });\n\n  it('url-encodes tokens containing special characters', () => {\n    const img = addImg('/api/v1/library/files/42/thumbnail');\n    rewriteMediaSrcWithToken(root, 'a b/c=d');\n    expect(img.getAttribute('src')).toBe('/api/v1/library/files/42/thumbnail?token=a%20b%2Fc%3Dd');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/hooks/useFilamentMapping.test.ts",
    "content": "/**\n * Tests for the useFilamentMapping hook and helper functions.\n *\n * Tests the tray_info_idx matching logic that ensures the exact spool\n * selected during slicing is used when multiple trays have identical filament.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport {\n  buildLoadedFilaments,\n  computeAmsMapping,\n} from '../../hooks/useFilamentMapping';\nimport type { PrinterStatus } from '../../api/client';\n\n// Helper to create a minimal printer status with AMS data\nfunction createPrinterStatus(ams: PrinterStatus['ams'], vt_tray: PrinterStatus['vt_tray'] = []): PrinterStatus {\n  return {\n    ams,\n    vt_tray,\n  } as PrinterStatus;\n}\n\ndescribe('buildLoadedFilaments', () => {\n  it('returns empty array for undefined status', () => {\n    const result = buildLoadedFilaments(undefined);\n    expect(result).toEqual([]);\n  });\n\n  it('extracts filaments from AMS units', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },\n          { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' },\n        ],\n      },\n    ]);\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result).toHaveLength(2);\n    expect(result[0]).toMatchObject({\n      type: 'PLA',\n      color: '#FF0000',\n      amsId: 0,\n      trayId: 0,\n      globalTrayId: 0,\n      trayInfoIdx: 'GFA00',\n    });\n    expect(result[1]).toMatchObject({\n      type: 'PETG',\n      color: '#00FF00',\n      globalTrayId: 1,\n      trayInfoIdx: 'GFA01',\n    });\n  });\n\n  it('includes tray_info_idx from AMS trays', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' },\n        ],\n      },\n    ]);\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result[0].trayInfoIdx).toBe('P4d64437');\n  });\n\n  it('handles missing tray_info_idx', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // No tray_info_idx\n        ],\n      },\n    ]);\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result[0].trayInfoIdx).toBe('');\n  });\n\n  it('includes tray_sub_brands from AMS trays', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic' },\n          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte' },\n        ],\n      },\n    ]);\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result[0].traySubBrands).toBe('PLA Basic');\n    expect(result[1].traySubBrands).toBe('PLA Matte');\n  });\n\n  it('handles missing tray_sub_brands', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },\n        ],\n      },\n    ]);\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result[0].traySubBrands).toBe('');\n  });\n\n  it('includes tray_sub_brands from external spool', () => {\n    const status = createPrinterStatus(\n      [],\n      [{ tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG HF' }]\n    );\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result[0].traySubBrands).toBe('PETG HF');\n  });\n\n  it('extracts external spool with tray_info_idx', () => {\n    const status = createPrinterStatus(\n      [],\n      [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]\n    );\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result).toHaveLength(1);\n    expect(result[0]).toMatchObject({\n      type: 'TPU',\n      isExternal: true,\n      globalTrayId: 254,\n      trayInfoIdx: 'EXT001',\n    });\n  });\n\n  it('skips empty trays', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' },\n          { id: 1, tray_type: '', tray_color: '' },  // Empty tray\n          { id: 2 },  // No tray_type\n        ],\n      },\n    ]);\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result).toHaveLength(1);\n    expect(result[0].type).toBe('PLA');\n  });\n\n  it('marks AMS-HT units correctly', () => {\n    const status = createPrinterStatus([\n      {\n        id: 128,  // AMS-HT typically has high ID\n        tray: [\n          { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' },\n        ],  // Single tray = AMS-HT\n      },\n    ]);\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result[0].isHt).toBe(true);\n    expect(result[0].globalTrayId).toBe(128);  // AMS-HT uses ams_id directly\n  });\n});\n\ndescribe('computeAmsMapping', () => {\n  it('returns undefined for empty filament requirements', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n    ]);\n\n    expect(computeAmsMapping(undefined, status)).toBeUndefined();\n    expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined();\n  });\n\n  it('returns undefined when no filaments loaded', () => {\n    const reqs = {\n      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],\n    };\n\n    expect(computeAmsMapping(reqs, undefined)).toBeUndefined();\n    expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined();\n  });\n\n  it('matches by tray_info_idx with highest priority', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },  // Same color, wrong idx\n          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },  // Exact idx match\n          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },  // Same color, wrong idx\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([1]);  // Should pick tray 1, not tray 0\n  });\n\n  it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => {\n    // This is the exact scenario from issue #245 - multiple black PLA spools\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },\n          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },\n          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },\n          { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' },  // This one\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([3]);  // Should pick tray 3, not tray 0\n  });\n\n  it('falls back to color match when tray_info_idx is empty', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' },  // Wrong color\n          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' },  // Color match\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([1]);\n  });\n\n  it('falls back to color match when tray_info_idx does not match', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' },  // Different idx, same color\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([0]);  // Falls back to color match\n  });\n\n  it('matches by type only when color differs', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: '0000FF' },  // Same type, different color\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([0]);  // Type-only match\n  });\n\n  it('returns -1 for unmatched slots', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 },  // No TPU loaded\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([-1]);\n  });\n\n  it('avoids duplicate tray assignment', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },\n        { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 },  // Same requirements\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // Only one PLA\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([0, -1]);  // First slot gets the match, second is unmatched\n  });\n\n  it('handles multi-slot mapping with tray_info_idx', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' },\n        { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' },\n          { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' },\n          { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' },\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([0, 2]);  // Each slot gets its specific tray\n  });\n\n  it('handles external spool matching', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' },\n      ],\n    };\n    const status = createPrinterStatus(\n      [],\n      [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]\n    );\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([254]);  // External spool global ID\n  });\n});\n\ndescribe('buildLoadedFilaments - nozzle awareness', () => {\n  it('sets extruderId from ams_extruder_map', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n      {\n        id: 1,\n        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],\n      },\n    ]);\n    (status as any).ams_extruder_map = { '0': 1, '1': 0 };\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result[0].extruderId).toBe(1);  // AMS 0 → left nozzle\n    expect(result[1].extruderId).toBe(0);  // AMS 1 → right nozzle\n  });\n\n  it('leaves extruderId undefined when no ams_extruder_map', () => {\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n    ]);\n\n    const result = buildLoadedFilaments(status);\n\n    expect(result[0].extruderId).toBeUndefined();\n  });\n});\n\ndescribe('computeAmsMapping - nozzle filtering', () => {\n  it('filters candidates by nozzle_id when set', () => {\n    // Filament requires left nozzle (extruder 1), only AMS 0 is on left\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,  // Left nozzle\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n      {\n        id: 1,  // Right nozzle\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n    ]);\n    (status as any).ams_extruder_map = { '0': 1, '1': 0 };\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([0]);  // AMS 0, tray 0 (on left nozzle)\n  });\n\n  it('filters to right nozzle when nozzle_id=0', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,  // Left nozzle\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n      {\n        id: 1,  // Right nozzle\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n    ]);\n    (status as any).ams_extruder_map = { '0': 1, '1': 0 };\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([4]);  // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)\n  });\n\n  it('returns -1 when target nozzle has no trays (hard filter)', () => {\n    // Requires nozzle_id=1 (left), but no AMS units are on left nozzle\n    // Hard filter: cross-nozzle assignment causes \"position of left hotend is abnormal\"\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,  // Right nozzle only\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n    ]);\n    (status as any).ams_extruder_map = { '0': 0 };  // AMS 0 → right nozzle, none on left\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([-1]);  // Hard filter: no fallback to wrong nozzle\n  });\n\n  it('stays restricted when target nozzle has trays but wrong type', () => {\n    // Left nozzle has PETG, right has PLA — but requires PLA on left\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,  // Left nozzle - only PETG\n        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],\n      },\n      {\n        id: 1,  // Right nozzle - has PLA\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n    ]);\n    (status as any).ams_extruder_map = { '0': 1, '1': 0 };\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([-1]);  // No PLA on left nozzle, stays restricted\n  });\n\n  it('skips nozzle filtering when nozzle_id is undefined', () => {\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },  // No nozzle_id\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],\n      },\n      {\n        id: 1,\n        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],\n      },\n    ]);\n    (status as any).ams_extruder_map = { '0': 1, '1': 0 };\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([4]);  // Picks best match regardless of nozzle\n  });\n\n  it('handles dual-nozzle multi-slot mapping', () => {\n    // Two filaments: one for left, one for right\n    const reqs = {\n      filaments: [\n        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },  // Left\n        { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right\n      ],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,  // Left nozzle\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },\n        ],\n      },\n      {\n        id: 1,  // Right nozzle\n        tray: [\n          { id: 0, tray_type: 'PETG', tray_color: '00FF00' },\n        ],\n      },\n    ]);\n    (status as any).ams_extruder_map = { '0': 1, '1': 0 };\n\n    const result = computeAmsMapping(reqs, status);\n\n    expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0\n  });\n});\n\n// ============================================================================\n// MODEL-SPECIFIC TESTS: Real data from actual printers\n// ============================================================================\n\n/**\n * H2D real data fixture (from live API response 2026-02-18).\n *\n * Configuration:\n *   LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)\n *   RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)\n *   External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle)\n *\n * ams_extruder_map: {\"0\": 1, \"1\": 0, \"2\": 1, \"128\": 0}\n */\nfunction createH2DStatus(): PrinterStatus {\n  const status = createPrinterStatus(\n    [\n      {\n        id: 0, // LEFT nozzle (extruder 1)\n        humidity: 24,\n        temp: 21.4,\n        tray: [\n          { id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },\n          { id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },\n          { id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },\n          { id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },\n        ],\n      },\n      {\n        id: 1, // RIGHT nozzle (extruder 0)\n        humidity: 25,\n        temp: 21.7,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },\n          { id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },\n          { id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },\n          { id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' },\n        ],\n      },\n      {\n        id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty\n        humidity: 48,\n        temp: 21.4,\n        tray: [\n          { id: 0 }, // empty tray\n        ],\n      },\n      {\n        id: 2, // LEFT nozzle (extruder 1)\n        humidity: 18,\n        temp: 24.0,\n        tray: [\n          { id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' },\n          { id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' },\n          { id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },\n          { id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' },\n        ],\n      },\n    ],\n    [\n      { id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded)\n      { id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty)\n    ]\n  );\n  (status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 };\n  return status;\n}\n\n/**\n * X1C real data fixture (from live API response 2026-02-18).\n *\n * Configuration:\n *   Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot)\n *   External: 254 (single)\n *\n * ams_extruder_map: {\"0\": 0, \"1\": 0}  ← NOT empty, all on extruder 0\n */\nfunction createX1CStatus(): PrinterStatus {\n  const status = createPrinterStatus(\n    [\n      {\n        id: 0,\n        humidity: 23,\n        temp: 26.1,\n        tray: [\n          { id: 0 }, // empty (has tray_color but no tray_type)\n          { id: 1 }, // empty\n          { id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type)\n          { id: 3 }, // empty\n        ],\n      },\n      {\n        id: 1,\n        humidity: 20,\n        temp: 25.9,\n        tray: [\n          { id: 0 }, // empty\n          { id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' },\n          { id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' },\n          { id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' },\n        ],\n      },\n    ],\n    [\n      { id: 254, tray_type: '', tray_color: '00000000' }, // empty\n    ]\n  );\n  (status as any).ams_extruder_map = { '0': 0, '1': 0 };\n  return status;\n}\n\ndescribe('H2D model tests (dual nozzle, real data)', () => {\n  describe('buildLoadedFilaments', () => {\n    it('assigns correct extruderId to all AMS units', () => {\n      const result = buildLoadedFilaments(createH2DStatus());\n\n      // AMS 0 trays → extruder 1 (LEFT)\n      const ams0 = result.filter((f) => f.amsId === 0);\n      expect(ams0).toHaveLength(4);\n      ams0.forEach((f) => expect(f.extruderId).toBe(1));\n\n      // AMS 1 trays → extruder 0 (RIGHT)\n      const ams1 = result.filter((f) => f.amsId === 1);\n      expect(ams1).toHaveLength(4);\n      ams1.forEach((f) => expect(f.extruderId).toBe(0));\n\n      // AMS 2 trays → extruder 1 (LEFT)\n      const ams2 = result.filter((f) => f.amsId === 2);\n      expect(ams2).toHaveLength(4);\n      ams2.forEach((f) => expect(f.extruderId).toBe(1));\n    });\n\n    it('computes correct globalTrayId for all AMS types', () => {\n      const result = buildLoadedFilaments(createH2DStatus());\n\n      // Regular AMS: amsId * 4 + trayId\n      expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0);\n      expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3);\n      expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4);\n      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);\n      expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8);\n      expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11);\n    });\n\n    it('skips empty AMS-HT tray (no tray_type)', () => {\n      const result = buildLoadedFilaments(createH2DStatus());\n      // AMS-HT 128 is empty in real data — should be skipped\n      const ht = result.filter((f) => f.amsId === 128);\n      expect(ht).toHaveLength(0);\n    });\n\n    it('includes loaded external spool with correct extruder', () => {\n      const result = buildLoadedFilaments(createH2DStatus());\n      const ext = result.filter((f) => f.isExternal);\n      // Only Ext-L (254) has filament, Ext-R (255) is empty\n      expect(ext).toHaveLength(1);\n      expect(ext[0].globalTrayId).toBe(254);\n      expect(ext[0].type).toBe('PLA');\n      // Ext-L (254) should be LEFT nozzle (extruder 1)\n      expect(ext[0].extruderId).toBe(1);\n    });\n\n    it('returns 13 loaded filaments total (12 AMS + 1 external)', () => {\n      const result = buildLoadedFilaments(createH2DStatus());\n      // AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1\n      expect(result).toHaveLength(13);\n    });\n  });\n\n  describe('computeAmsMapping', () => {\n    it('matches left-nozzle filament to left-nozzle AMS only', () => {\n      const reqs = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createH2DStatus());\n      // Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left\n      expect(result).toEqual([3]);\n    });\n\n    it('matches right-nozzle filament to right-nozzle AMS only', () => {\n      const reqs = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createH2DStatus());\n      // White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right\n      expect(result).toEqual([4]);\n    });\n\n    it('rejects cross-nozzle assignment (right requires type only on left)', () => {\n      const reqs = {\n        filaments: [\n          // PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle\n          { slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createH2DStatus());\n      expect(result).toEqual([-1]); // No fallback to wrong nozzle\n    });\n\n    it('maps dual-nozzle multi-filament print correctly', () => {\n      const reqs = {\n        filaments: [\n          // Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0)\n          { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' },\n          // Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4)\n          { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createH2DStatus());\n      expect(result).toEqual([0, 4]);\n    });\n\n    it('matches external spool on correct nozzle', () => {\n      const reqs = {\n        filaments: [\n          // Ext-L has black PLA loaded, on LEFT nozzle (extruder 1)\n          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createH2DStatus());\n      expect(result).toEqual([254]); // External spool on left nozzle\n    });\n  });\n});\n\ndescribe('X1C model tests (single nozzle, real data)', () => {\n  describe('buildLoadedFilaments', () => {\n    it('assigns all filaments to extruder 0', () => {\n      const result = buildLoadedFilaments(createX1CStatus());\n      result.forEach((f) => expect(f.extruderId).toBe(0));\n    });\n\n    it('computes correct globalTrayId for regular AMS', () => {\n      const result = buildLoadedFilaments(createX1CStatus());\n      // AMS 1 T2 (tray id 1) → globalTrayId 5\n      expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5);\n      // AMS 1 T3 (tray id 2) → globalTrayId 6\n      expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6);\n      // AMS 1 T4 (tray id 3) → globalTrayId 7\n      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);\n    });\n\n    it('returns only loaded trays (3 from AMS 1)', () => {\n      const result = buildLoadedFilaments(createX1CStatus());\n      // AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty\n      expect(result).toHaveLength(3);\n    });\n  });\n\n  describe('computeAmsMapping', () => {\n    it('matches single-nozzle file without nozzle filtering', () => {\n      const reqs = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createX1CStatus());\n      // Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF)\n      expect(result).toEqual([7]);\n    });\n\n    it('matches by tray_info_idx across AMS units', () => {\n      const reqs = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createX1CStatus());\n      // PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5)\n      expect(result).toEqual([5]);\n    });\n\n    it('handles non-unique tray_info_idx with color matching', () => {\n      // P4d64437 appears in both AMS 1 T3 and T4\n      const reqs = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createX1CStatus());\n      // Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF)\n      expect(result).toEqual([6]);\n    });\n\n    it('does not cross-nozzle filter for single-nozzle printer', () => {\n      // Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id\n      const reqs = {\n        filaments: [\n          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 },\n          { slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 },\n        ],\n      };\n      const result = computeAmsMapping(reqs, createX1CStatus());\n      // Both should match freely across all AMS units\n      expect(result).toEqual([5, 7]);\n    });\n  });\n});\n\ndescribe('computeAmsMapping preferLowest', () => {\n  it('picks spool with lowest remain when enabled', () => {\n    const reqs = {\n      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },\n          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status, true);\n    expect(result).toEqual([1]); // Tray 1 has 25% remain\n  });\n\n  it('picks first match when disabled', () => {\n    const reqs = {\n      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },\n          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status, false);\n    expect(result).toEqual([0]); // First match (default)\n  });\n\n  it('sorts unknown remain to end', () => {\n    const reqs = {\n      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],\n    };\n    const status = createPrinterStatus([\n      {\n        id: 0,\n        tray: [\n          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // No remain (defaults to -1)\n          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 60 },\n        ],\n      },\n    ]);\n\n    const result = computeAmsMapping(reqs, status, true);\n    expect(result).toEqual([1]); // Known 60% over unknown\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/hooks/useIsMobile.test.ts",
    "content": "/**\n * Tests for the useIsMobile hook.\n */\n\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { renderHook, act } from '@testing-library/react';\nimport { useIsMobile } from '../../hooks/useIsMobile';\n\ndescribe('useIsMobile', () => {\n  let originalMatchMedia: typeof window.matchMedia;\n\n  beforeEach(() => {\n    originalMatchMedia = window.matchMedia;\n  });\n\n  afterEach(() => {\n    window.matchMedia = originalMatchMedia;\n  });\n\n  it('returns false for desktop viewport', () => {\n    window.matchMedia = vi.fn().mockImplementation((query: string) => ({\n      matches: false,\n      media: query,\n      onchange: null,\n      addListener: vi.fn(),\n      removeListener: vi.fn(),\n      addEventListener: vi.fn(),\n      removeEventListener: vi.fn(),\n      dispatchEvent: vi.fn(),\n    }));\n\n    const { result } = renderHook(() => useIsMobile());\n\n    expect(result.current).toBe(false);\n  });\n\n  it('returns true for mobile viewport', () => {\n    window.matchMedia = vi.fn().mockImplementation((query: string) => ({\n      matches: true,\n      media: query,\n      onchange: null,\n      addListener: vi.fn(),\n      removeListener: vi.fn(),\n      addEventListener: vi.fn(),\n      removeEventListener: vi.fn(),\n      dispatchEvent: vi.fn(),\n    }));\n\n    const { result } = renderHook(() => useIsMobile());\n\n    expect(result.current).toBe(true);\n  });\n\n  it('updates when viewport changes', () => {\n    let listener: ((e: MediaQueryListEvent) => void) | null = null;\n\n    window.matchMedia = vi.fn().mockImplementation((query: string) => ({\n      matches: false,\n      media: query,\n      onchange: null,\n      addListener: vi.fn(),\n      removeListener: vi.fn(),\n      addEventListener: vi.fn((event: string, cb: (e: MediaQueryListEvent) => void) => {\n        if (event === 'change') {\n          listener = cb;\n        }\n      }),\n      removeEventListener: vi.fn(),\n      dispatchEvent: vi.fn(),\n    }));\n\n    const { result } = renderHook(() => useIsMobile());\n\n    expect(result.current).toBe(false);\n\n    // Simulate viewport change to mobile\n    if (listener) {\n      act(() => {\n        listener!({ matches: true } as MediaQueryListEvent);\n      });\n    }\n\n    expect(result.current).toBe(true);\n  });\n\n  it('cleans up event listener on unmount', () => {\n    const removeEventListener = vi.fn();\n\n    window.matchMedia = vi.fn().mockImplementation((query: string) => ({\n      matches: false,\n      media: query,\n      onchange: null,\n      addListener: vi.fn(),\n      removeListener: vi.fn(),\n      addEventListener: vi.fn(),\n      removeEventListener,\n      dispatchEvent: vi.fn(),\n    }));\n\n    const { unmount } = renderHook(() => useIsMobile());\n\n    unmount();\n\n    expect(removeEventListener).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/hooks/useLongPress.test.ts",
    "content": "/**\n * Tests for the useLongPress hook.\n */\n\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { renderHook, act } from '@testing-library/react';\nimport { useLongPress } from '../../hooks/useLongPress';\n\ndescribe('useLongPress', () => {\n  beforeEach(() => {\n    vi.useFakeTimers();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it('calls onLongPress after delay', () => {\n    const onLongPress = vi.fn();\n    const onClick = vi.fn();\n\n    const { result } = renderHook(() =>\n      useLongPress({ onLongPress, onClick, delay: 500 })\n    );\n\n    // Simulate mouse down\n    act(() => {\n      result.current.onMouseDown({} as React.MouseEvent);\n    });\n\n    // Fast forward past the delay\n    act(() => {\n      vi.advanceTimersByTime(600);\n    });\n\n    // Should trigger long press\n    expect(onLongPress).toHaveBeenCalled();\n    expect(onClick).not.toHaveBeenCalled();\n  });\n\n  it('calls onClick for short press', () => {\n    const onLongPress = vi.fn();\n    const onClick = vi.fn();\n\n    const { result } = renderHook(() =>\n      useLongPress({ onLongPress, onClick, delay: 500 })\n    );\n\n    // Simulate mouse down\n    act(() => {\n      result.current.onMouseDown({} as React.MouseEvent);\n    });\n\n    // Release before delay\n    act(() => {\n      vi.advanceTimersByTime(200);\n      result.current.onMouseUp({} as React.MouseEvent);\n    });\n\n    // Should trigger click, not long press\n    expect(onClick).toHaveBeenCalled();\n    expect(onLongPress).not.toHaveBeenCalled();\n  });\n\n  it('cancels on mouse leave', () => {\n    const onLongPress = vi.fn();\n    const onClick = vi.fn();\n\n    const { result } = renderHook(() =>\n      useLongPress({ onLongPress, onClick, delay: 500 })\n    );\n\n    // Simulate mouse down\n    act(() => {\n      result.current.onMouseDown({} as React.MouseEvent);\n    });\n\n    // Mouse leaves before delay\n    act(() => {\n      vi.advanceTimersByTime(200);\n      result.current.onMouseLeave({} as React.MouseEvent);\n    });\n\n    // Continue past delay\n    act(() => {\n      vi.advanceTimersByTime(400);\n    });\n\n    // Neither should be called\n    expect(onLongPress).not.toHaveBeenCalled();\n    expect(onClick).not.toHaveBeenCalled();\n  });\n\n  it('uses default delay of 500ms', () => {\n    const onLongPress = vi.fn();\n\n    const { result } = renderHook(() =>\n      useLongPress({ onLongPress })\n    );\n\n    // Simulate mouse down\n    act(() => {\n      result.current.onMouseDown({} as React.MouseEvent);\n    });\n\n    // Just before default delay\n    act(() => {\n      vi.advanceTimersByTime(450);\n    });\n    expect(onLongPress).not.toHaveBeenCalled();\n\n    // After default delay\n    act(() => {\n      vi.advanceTimersByTime(100);\n    });\n    expect(onLongPress).toHaveBeenCalled();\n  });\n\n  it('handles touch events', () => {\n    const onLongPress = vi.fn();\n\n    const { result } = renderHook(() =>\n      useLongPress({ onLongPress, delay: 500 })\n    );\n\n    // Simulate touch start\n    act(() => {\n      result.current.onTouchStart({} as React.TouchEvent);\n    });\n\n    // Fast forward past the delay\n    act(() => {\n      vi.advanceTimersByTime(600);\n    });\n\n    expect(onLongPress).toHaveBeenCalled();\n  });\n\n  it('cancels on touch end', () => {\n    const onLongPress = vi.fn();\n    const onClick = vi.fn();\n\n    const { result } = renderHook(() =>\n      useLongPress({ onLongPress, onClick, delay: 500 })\n    );\n\n    // Simulate touch start\n    act(() => {\n      result.current.onTouchStart({} as React.TouchEvent);\n    });\n\n    // End touch before delay\n    act(() => {\n      vi.advanceTimersByTime(200);\n      result.current.onTouchEnd({} as React.TouchEvent);\n    });\n\n    expect(onClick).toHaveBeenCalled();\n    expect(onLongPress).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts",
    "content": "/**\n * Tests for useSpoolBuddyState hook:\n * - Reducer handles all action types correctly\n * - Computed properties (remainingWeight, netWeight) work\n * - Window events dispatch state updates\n */\n\nimport { describe, it, expect, vi, afterEach } from 'vitest';\nimport { renderHook, act } from '@testing-library/react';\nimport { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';\n\nfunction dispatchCustomEvent(name: string, detail: Record<string, unknown>) {\n  window.dispatchEvent(new CustomEvent(name, { detail }));\n}\n\ndescribe('useSpoolBuddyState', () => {\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it('starts with initial state', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n    expect(result.current.weight).toBeNull();\n    expect(result.current.weightStable).toBe(false);\n    expect(result.current.rawAdc).toBeNull();\n    expect(result.current.matchedSpool).toBeNull();\n    expect(result.current.unknownTagUid).toBeNull();\n    expect(result.current.deviceOnline).toBe(false);\n    expect(result.current.deviceId).toBeNull();\n    expect(result.current.remainingWeight).toBeNull();\n    expect(result.current.netWeight).toBeNull();\n  });\n\n  it('WEIGHT_UPDATE sets weight, stable, rawAdc, deviceOnline=true', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-weight', {\n        weight_grams: 250.5,\n        stable: true,\n        raw_adc: 12345,\n        device_id: 'dev-1',\n      });\n    });\n\n    expect(result.current.weight).toBe(250.5);\n    expect(result.current.weightStable).toBe(true);\n    expect(result.current.rawAdc).toBe(12345);\n    expect(result.current.deviceOnline).toBe(true);\n    expect(result.current.deviceId).toBe('dev-1');\n  });\n\n  it('WEIGHT_UPDATE handles nested data format', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-weight', {\n        data: {\n          weight_grams: 100,\n          stable: false,\n          raw_adc: 9999,\n          device_id: 'dev-2',\n        },\n      });\n    });\n\n    expect(result.current.weight).toBe(100);\n    expect(result.current.weightStable).toBe(false);\n    expect(result.current.rawAdc).toBe(9999);\n    expect(result.current.deviceId).toBe('dev-2');\n  });\n\n  it('TAG_MATCHED sets matchedSpool and clears unknownTagUid', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    // First set an unknown tag\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-unknown-tag', {\n        tag_uid: 'AA:BB:CC',\n        device_id: 'dev-1',\n      });\n    });\n    expect(result.current.unknownTagUid).toBe('AA:BB:CC');\n\n    // Now match a spool\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-tag-matched', {\n        tag_uid: 'AA:BB:CC',\n        device_id: 'dev-1',\n        spool: {\n          id: 42,\n          material: 'PLA',\n          subtype: 'Silk',\n          color_name: 'Red',\n          rgba: 'FF0000FF',\n          brand: 'Bambu',\n          label_weight: 1000,\n          core_weight: 250,\n          weight_used: 100,\n        },\n      });\n    });\n\n    expect(result.current.matchedSpool).not.toBeNull();\n    expect(result.current.matchedSpool!.id).toBe(42);\n    expect(result.current.matchedSpool!.material).toBe('PLA');\n    expect(result.current.matchedSpool!.subtype).toBe('Silk');\n    expect(result.current.matchedSpool!.color_name).toBe('Red');\n    expect(result.current.matchedSpool!.brand).toBe('Bambu');\n    expect(result.current.matchedSpool!.label_weight).toBe(1000);\n    expect(result.current.matchedSpool!.core_weight).toBe(250);\n    expect(result.current.matchedSpool!.weight_used).toBe(100);\n    expect(result.current.unknownTagUid).toBeNull();\n  });\n\n  it('UNKNOWN_TAG sets unknownTagUid and clears matchedSpool', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    // First match a spool\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-tag-matched', {\n        tag_uid: 'AA:BB:CC',\n        device_id: 'dev-1',\n        spool: {\n          id: 1,\n          material: 'PLA',\n          label_weight: 1000,\n          core_weight: 250,\n          weight_used: 0,\n        },\n      });\n    });\n    expect(result.current.matchedSpool).not.toBeNull();\n\n    // Now detect unknown tag\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-unknown-tag', {\n        tag_uid: 'DD:EE:FF',\n        device_id: 'dev-1',\n      });\n    });\n\n    expect(result.current.unknownTagUid).toBe('DD:EE:FF');\n    expect(result.current.matchedSpool).toBeNull();\n  });\n\n  it('TAG_REMOVED clears both matchedSpool and unknownTagUid', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    // Set a matched spool\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-tag-matched', {\n        tag_uid: 'AA:BB:CC',\n        device_id: 'dev-1',\n        spool: {\n          id: 1,\n          material: 'PLA',\n          label_weight: 1000,\n          core_weight: 250,\n          weight_used: 0,\n        },\n      });\n    });\n    expect(result.current.matchedSpool).not.toBeNull();\n\n    // Remove tag\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-tag-removed', { device_id: 'dev-1' });\n    });\n\n    expect(result.current.matchedSpool).toBeNull();\n    expect(result.current.unknownTagUid).toBeNull();\n  });\n\n  it('DEVICE_ONLINE sets deviceOnline=true', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n    expect(result.current.deviceOnline).toBe(false);\n\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-online', { device_id: 'dev-1' });\n    });\n\n    expect(result.current.deviceOnline).toBe(true);\n    expect(result.current.deviceId).toBe('dev-1');\n  });\n\n  it('DEVICE_OFFLINE sets deviceOnline=false and clears weight/rawAdc', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    // First get some weight data\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-weight', {\n        weight_grams: 500,\n        stable: true,\n        raw_adc: 54321,\n        device_id: 'dev-1',\n      });\n    });\n    expect(result.current.weight).toBe(500);\n    expect(result.current.rawAdc).toBe(54321);\n    expect(result.current.deviceOnline).toBe(true);\n\n    // Go offline\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-offline', { device_id: 'dev-1' });\n    });\n\n    expect(result.current.deviceOnline).toBe(false);\n    expect(result.current.weight).toBeNull();\n    expect(result.current.weightStable).toBe(false);\n    expect(result.current.rawAdc).toBeNull();\n  });\n\n  it('computes remainingWeight from matchedSpool', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-tag-matched', {\n        tag_uid: 'AA:BB:CC',\n        device_id: 'dev-1',\n        spool: {\n          id: 1,\n          material: 'PLA',\n          label_weight: 1000,\n          core_weight: 250,\n          weight_used: 300,\n        },\n      });\n    });\n\n    // remainingWeight = label_weight - weight_used = 1000 - 300 = 700\n    expect(result.current.remainingWeight).toBe(700);\n  });\n\n  it('remainingWeight is clamped to 0 when weight_used exceeds label_weight', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-tag-matched', {\n        tag_uid: 'AA:BB:CC',\n        device_id: 'dev-1',\n        spool: {\n          id: 1,\n          material: 'PLA',\n          label_weight: 1000,\n          core_weight: 250,\n          weight_used: 1200,\n        },\n      });\n    });\n\n    expect(result.current.remainingWeight).toBe(0);\n  });\n\n  it('computes netWeight from weight and matchedSpool core_weight', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    // Set weight first\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-weight', {\n        weight_grams: 800,\n        stable: true,\n        raw_adc: 11111,\n        device_id: 'dev-1',\n      });\n    });\n\n    // Match a spool\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-tag-matched', {\n        tag_uid: 'AA:BB:CC',\n        device_id: 'dev-1',\n        spool: {\n          id: 1,\n          material: 'PLA',\n          label_weight: 1000,\n          core_weight: 250,\n          weight_used: 0,\n        },\n      });\n    });\n\n    // netWeight = weight - core_weight = 800 - 250 = 550\n    expect(result.current.netWeight).toBe(550);\n  });\n\n  it('netWeight is null when weight is null', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-tag-matched', {\n        tag_uid: 'AA:BB:CC',\n        device_id: 'dev-1',\n        spool: {\n          id: 1,\n          material: 'PLA',\n          label_weight: 1000,\n          core_weight: 250,\n          weight_used: 0,\n        },\n      });\n    });\n\n    expect(result.current.netWeight).toBeNull();\n  });\n\n  it('netWeight is null when no matchedSpool', () => {\n    const { result } = renderHook(() => useSpoolBuddyState());\n\n    act(() => {\n      dispatchCustomEvent('spoolbuddy-weight', {\n        weight_grams: 800,\n        stable: true,\n        raw_adc: 11111,\n        device_id: 'dev-1',\n      });\n    });\n\n    expect(result.current.netWeight).toBeNull();\n  });\n\n  it('cleans up event listeners on unmount', () => {\n    const removeSpy = vi.spyOn(window, 'removeEventListener');\n    const { unmount } = renderHook(() => useSpoolBuddyState());\n\n    unmount();\n\n    const removedEvents = removeSpy.mock.calls.map((c) => c[0]);\n    expect(removedEvents).toContain('spoolbuddy-weight');\n    expect(removedEvents).toContain('spoolbuddy-tag-matched');\n    expect(removedEvents).toContain('spoolbuddy-unknown-tag');\n    expect(removedEvents).toContain('spoolbuddy-tag-removed');\n    expect(removedEvents).toContain('spoolbuddy-online');\n    expect(removedEvents).toContain('spoolbuddy-offline');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/hooks/useWebSocket.test.ts",
    "content": "/**\n * Tests for the useWebSocket hook.\n *\n * Tests WebSocket connection management and message handling.\n * Uses vitest.mock to mock the entire module before MSW can intercept.\n */\n\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { renderHook, waitFor, act } from '@testing-library/react';\nimport React from 'react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { ToastProvider } from '../../contexts/ToastContext';\n\n// Track WebSocket instances created during tests\nlet wsInstances: MockWebSocket[] = [];\nlet originalWebSocket: typeof WebSocket;\n\n// Mock react-i18next BEFORE any modules that use it are imported\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (key: string, options?: Record<string, unknown>) => {\n      if (key === 'printers.toast.missingSpoolAssignment' && options) {\n        const { printer, slots } = options as { printer: string; slots: string };\n        return `Missing assignments for ${printer}: ${slots}`;\n      }\n      return key;\n    },\n    i18n: {},\n  }),\n}));\n\n// Enhanced MockWebSocket that tracks instances\nclass MockWebSocket {\n  static readonly CONNECTING = 0;\n  static readonly OPEN = 1;\n  static readonly CLOSING = 2;\n  static readonly CLOSED = 3;\n\n  readyState = MockWebSocket.CONNECTING;\n  onopen: ((event: Event) => void) | null = null;\n  onclose: ((event: CloseEvent) => void) | null = null;\n  onmessage: ((event: MessageEvent) => void) | null = null;\n  onerror: ((event: Event) => void) | null = null;\n\n  url: string;\n  constructor(url: string) {\n    this.url = url;\n    wsInstances.push(this);\n  }\n\n  send = vi.fn();\n  close = vi.fn(() => {\n    this.readyState = MockWebSocket.CLOSED;\n    if (this.onclose) {\n      this.onclose(new CloseEvent('close'));\n    }\n  });\n\n  // Required by MSW's interceptor - these are no-ops but prevent the error\n  addEventListener = vi.fn();\n  removeEventListener = vi.fn();\n\n  // Helper to simulate connection opening\n  open() {\n    this.readyState = MockWebSocket.OPEN;\n    if (this.onopen) {\n      this.onopen(new Event('open'));\n    }\n  }\n\n  // Helper to simulate receiving a message\n  simulateMessage(data: unknown) {\n    if (this.onmessage) {\n      this.onmessage(\n        new MessageEvent('message', {\n          data: JSON.stringify(data),\n        })\n      );\n    }\n  }\n}\n\n// Create test QueryClient\nfunction createTestQueryClient() {\n  return new QueryClient({\n    defaultOptions: {\n      queries: {\n        retry: false,\n        gcTime: 0,\n      },\n    },\n  });\n}\n\n// Wrapper with QueryClient and ToastProvider for hook testing\nfunction createWrapper(queryClient: QueryClient) {\n  return function Wrapper({ children }: { children: React.ReactNode }) {\n    return React.createElement(\n      ToastProvider,\n      {},\n      React.createElement(\n        QueryClientProvider,\n        { client: queryClient },\n        children\n      )\n    );\n  };\n}\n\nfunction getLatestWs(): MockWebSocket | undefined {\n  return wsInstances[wsInstances.length - 1];\n}\n\ndescribe('useWebSocket hook', () => {\n  let queryClient: QueryClient;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n    wsInstances = [];\n    queryClient = createTestQueryClient();\n\n    // Save original and install mock\n    originalWebSocket = globalThis.WebSocket;\n    globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n    // Restore original WebSocket\n    globalThis.WebSocket = originalWebSocket;\n  });\n\n  describe('WebSocket Mock', () => {\n    it('creates WebSocket with correct URL', () => {\n      const ws = new MockWebSocket('ws://test.local/ws');\n      expect(ws.url).toBe('ws://test.local/ws');\n    });\n\n    it('starts in CONNECTING state', () => {\n      const ws = new MockWebSocket('ws://test.local/ws');\n      expect(ws.readyState).toBe(MockWebSocket.CONNECTING);\n    });\n\n    it('transitions to OPEN state', () => {\n      const ws = new MockWebSocket('ws://test.local/ws');\n      const onOpen = vi.fn();\n      ws.onopen = onOpen;\n\n      ws.open();\n\n      expect(ws.readyState).toBe(MockWebSocket.OPEN);\n      expect(onOpen).toHaveBeenCalled();\n    });\n\n    it('can receive messages', () => {\n      const ws = new MockWebSocket('ws://test.local/ws');\n      const onMessage = vi.fn();\n      ws.onmessage = onMessage;\n\n      ws.open();\n      ws.simulateMessage({ type: 'status', data: { connected: true } });\n\n      expect(onMessage).toHaveBeenCalled();\n    });\n\n    it('can close connection', () => {\n      const ws = new MockWebSocket('ws://test.local/ws');\n      const onClose = vi.fn();\n      ws.onclose = onClose;\n\n      ws.close();\n\n      expect(ws.readyState).toBe(MockWebSocket.CLOSED);\n      expect(onClose).toHaveBeenCalled();\n    });\n\n    it('tracks all instances', () => {\n      wsInstances = [];\n      new MockWebSocket('ws://a');\n      new MockWebSocket('ws://b');\n      expect(wsInstances.length).toBe(2);\n    });\n  });\n\n  describe('hook connection', () => {\n    it('connects to WebSocket on mount', async () => {\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs();\n      expect(ws).toBeDefined();\n      expect(ws?.url).toContain('/api/v1/ws');\n    });\n\n    it('reports connected state when WebSocket opens', async () => {\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const { result } = renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      // Initially not connected\n      expect(result.current.isConnected).toBe(false);\n\n      // Simulate connection opening\n      const ws = getLatestWs();\n      act(() => {\n        ws?.open();\n      });\n\n      await waitFor(() => {\n        expect(result.current.isConnected).toBe(true);\n      });\n    });\n  });\n\n  describe('message handling', () => {\n    it('updates printer status in query cache on printer_status message', async () => {\n      // Test the printer status update logic directly using setQueryData\n      // The WebSocket handler with throttling is complex to test with fake timers,\n      // so we test the core behavior directly\n\n      // Simulate what the throttled update does\n      queryClient.setQueryData(\n        ['printerStatus', 1],\n        (old: Record<string, unknown> | undefined) => {\n          const statusData = { state: 'IDLE', progress: 0 };\n          const merged = { ...old, ...statusData };\n          return merged;\n        }\n      );\n\n      // Check query cache was updated\n      const cachedData = queryClient.getQueryData(['printerStatus', 1]);\n      expect(cachedData).toEqual({ state: 'IDLE', progress: 0 });\n    });\n\n    it('preserves wifi_signal when new value is null', async () => {\n      // Test the wifi_signal preservation logic directly on QueryClient\n      // The throttled WebSocket handler makes this hard to test end-to-end\n      // This tests that the merge logic correctly preserves wifi_signal\n\n      // Set initial data with wifi_signal\n      queryClient.setQueryData(['printerStatus', 1], {\n        wifi_signal: -65,\n        state: 'IDLE',\n      });\n\n      // Simulate what the throttled update does - use setQueryData with updater function\n      queryClient.setQueryData(\n        ['printerStatus', 1],\n        (old: Record<string, unknown> | undefined) => {\n          const statusData = { state: 'RUNNING', wifi_signal: null };\n          const merged = { ...old, ...statusData };\n          // This is the preservation logic from useWebSocket\n          if (merged.wifi_signal == null && old?.wifi_signal != null) {\n            merged.wifi_signal = old.wifi_signal;\n          }\n          return merged;\n        }\n      );\n\n      const cachedData = queryClient.getQueryData(['printerStatus', 1]) as Record<\n        string,\n        unknown\n      >;\n      expect(cachedData.wifi_signal).toBe(-65); // Preserved\n      expect(cachedData.state).toBe('RUNNING'); // Updated\n    });\n\n    it('invalidates archives on print_complete message', async () => {\n      vi.useFakeTimers();\n      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {\n        cb(0);\n        return 0;\n      });\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        ws.open();\n      });\n\n      // Simulate print complete\n      act(() => {\n        ws.simulateMessage({\n          type: 'print_complete',\n          printer_id: 1,\n          data: { status: 'completed' },\n        });\n      });\n\n      // Advance timers to trigger debounced invalidation (3000ms delay + 500ms between each)\n      await act(async () => {\n        vi.advanceTimersByTime(4000);\n      });\n\n      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });\n      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archiveStats'] });\n\n      vi.useRealTimers();\n      vi.unstubAllGlobals();\n    });\n\n    it('invalidates archives on archive_created message', async () => {\n      vi.useFakeTimers();\n      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {\n        cb(0);\n        return 0;\n      });\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        ws.open();\n      });\n\n      // Simulate archive created\n      act(() => {\n        ws.simulateMessage({\n          type: 'archive_created',\n          data: { id: 1, filename: 'test.3mf' },\n        });\n      });\n\n      // Advance timers to trigger debounced invalidation (3000ms delay + 500ms between each)\n      await act(async () => {\n        vi.advanceTimersByTime(4000);\n      });\n\n      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });\n      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archiveStats'] });\n\n      vi.useRealTimers();\n      vi.unstubAllGlobals();\n    });\n\n    it('invalidates archives on archive_updated message', async () => {\n      vi.useFakeTimers();\n      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {\n        cb(0);\n        return 0;\n      });\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        ws.open();\n      });\n\n      // Simulate archive updated (e.g., timelapse attached)\n      act(() => {\n        ws.simulateMessage({\n          type: 'archive_updated',\n          data: { id: 1, timelapse_attached: true },\n        });\n      });\n\n      // Advance timers to trigger debounced invalidation (3000ms delay)\n      await act(async () => {\n        vi.advanceTimersByTime(4000);\n      });\n\n      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });\n\n      vi.useRealTimers();\n      vi.unstubAllGlobals();\n    });\n\n    it('handles missing_spool_assignment message without error', async () => {\n      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {\n        cb(0);\n        return 0;\n      });\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n      act(() => {\n        ws.open();\n      });\n\n      // This test verifies that the hook properly handles missing_spool_assignment messages\n      // without throwing an error. The actual toast display is tested via the UI.\n      expect(() => {\n        act(() => {\n          ws.simulateMessage({\n            type: 'missing_spool_assignment',\n            printer_id: 7,\n            printer_name: 'Printer B',\n            missing_slots: [{ slot: 'A2' }, { slot: 'Ext-L' }],\n          });\n        });\n      }).not.toThrow();\n\n      vi.unstubAllGlobals();\n    });\n\n    it('ignores pong messages without error', async () => {\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        ws.open();\n      });\n\n      // Simulate pong response\n      act(() => {\n        ws.simulateMessage({\n          type: 'pong',\n        });\n      });\n\n      // Should not invalidate any queries for pong\n      expect(invalidateSpy).not.toHaveBeenCalled();\n    });\n\n    it('handles malformed JSON gracefully', async () => {\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        ws.open();\n      });\n\n      // Simulate malformed message (should not throw)\n      expect(() => {\n        act(() => {\n          if (ws.onmessage) {\n            ws.onmessage(\n              new MessageEvent('message', {\n                data: 'not valid json{{{',\n              })\n            );\n          }\n        });\n      }).not.toThrow();\n    });\n\n    it('handles unknown message types gracefully', async () => {\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        ws.open();\n      });\n\n      // Simulate unknown message type\n      expect(() => {\n        act(() => {\n          ws.simulateMessage({\n            type: 'unknown_type',\n            data: { foo: 'bar' },\n          });\n        });\n      }).not.toThrow();\n\n      expect(invalidateSpy).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('sendMessage', () => {\n    it('sends JSON message when connected', async () => {\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const { result } = renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        ws.open();\n      });\n\n      act(() => {\n        result.current.sendMessage({ type: 'test', data: 'hello' });\n      });\n\n      expect(ws.send).toHaveBeenCalledWith(\n        JSON.stringify({ type: 'test', data: 'hello' })\n      );\n    });\n\n    it('does not send when disconnected', async () => {\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const { result } = renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Don't open connection - still in CONNECTING state\n\n      act(() => {\n        result.current.sendMessage({ type: 'test' });\n      });\n\n      expect(ws.send).not.toHaveBeenCalled();\n    });\n  });\n\n  describe('reconnection', () => {\n    it('reconnects after connection closes', async () => {\n      vi.useFakeTimers();\n\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const firstWs = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        firstWs.open();\n      });\n\n      const instanceCountBefore = wsInstances.length;\n\n      // Close connection\n      act(() => {\n        firstWs.close();\n      });\n\n      // Wait for reconnect timeout (3 seconds)\n      act(() => {\n        vi.advanceTimersByTime(3000);\n      });\n\n      // Should have created new WebSocket\n      expect(wsInstances.length).toBe(instanceCountBefore + 1);\n      expect(getLatestWs()).not.toBe(firstWs);\n\n      vi.useRealTimers();\n    });\n\n    it('cleans up on unmount', async () => {\n      const { useWebSocket } = await import('../../hooks/useWebSocket');\n\n      const { unmount } = renderHook(() => useWebSocket(), {\n        wrapper: createWrapper(queryClient),\n      });\n\n      const ws = getLatestWs()!;\n\n      // Open connection\n      act(() => {\n        ws.open();\n      });\n\n      unmount();\n\n      expect(ws.close).toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/i18n/locales.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport en from '../../i18n/locales/en';\nimport de from '../../i18n/locales/de';\n\n/**\n * Recursively extracts all keys from a nested object as dot-notation paths.\n * Example: { foo: { bar: 'baz' } } => ['foo.bar']\n */\nconst getKeys = (obj: object, prefix = ''): string[] => {\n  return Object.entries(obj).flatMap(([key, value]) => {\n    const path = prefix ? `${prefix}.${key}` : key;\n    return typeof value === 'object' && value !== null\n      ? getKeys(value, path)\n      : [path];\n  });\n};\n\ndescribe('i18n locale parity', () => {\n  const enKeys = new Set(getKeys(en));\n  const deKeys = new Set(getKeys(de));\n\n  it('German locale has all English keys', () => {\n    const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort();\n    expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]);\n  });\n\n  it('English locale has all German keys', () => {\n    const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort();\n    expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]);\n  });\n\n  it('both locales have the same number of keys', () => {\n    expect(enKeys.size).toBe(deKeys.size);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/i18n/parity-script.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\n// @ts-expect-error -- .mjs script with no type declarations; pure JS import is fine for tests\nimport { compareLocales } from '../../../scripts/check-i18n-parity.mjs';\n\ntype LocaleMap = Map<string, string>;\n\nconst toMap = (obj: Record<string, string>): LocaleMap => new Map(Object.entries(obj));\n\nconst hasReport = (\n  reports: Array<{ label: string; items: string[] }>,\n  labelSubstr: string,\n  itemSubstr?: string,\n): boolean =>\n  reports.some(\n    (r) =>\n      r.label.includes(labelSubstr) &&\n      (itemSubstr === undefined || r.items.some((i) => i.includes(itemSubstr))),\n  );\n\ndescribe('compareLocales (parity-script self-test)', () => {\n  it('passes when all locales match en', () => {\n    const en = toMap({ 'a.b': 'hello {{name}}', 'count_one': 'one', 'count_other': 'many' });\n    const result = compareLocales({\n      en,\n      'zh-CN': toMap({ 'a.b': '你好 {{name}}', 'count_one': '一', 'count_other': '多' }),\n      'zh-TW': toMap({ 'a.b': '你好 {{name}}', 'count_one': '一', 'count_other': '多' }),\n    });\n    expect(result.failed).toBe(false);\n    expect(result.reports).toEqual([]);\n  });\n\n  it('flags keys missing from a non-en locale', () => {\n    const result = compareLocales({\n      en: toMap({ 'a.b': 'x', 'a.c': 'y' }),\n      'zh-TW': toMap({ 'a.b': 'x' }),\n    });\n    expect(result.failed).toBe(true);\n    expect(hasReport(result.reports, 'zh-TW: missing keys vs en', 'a.c')).toBe(true);\n  });\n\n  it('flags keys that exist in a non-en locale but not in en', () => {\n    const result = compareLocales({\n      en: toMap({ 'a.b': 'x' }),\n      'zh-CN': toMap({ 'a.b': 'x', 'a.stray': 'extra' }),\n    });\n    expect(result.failed).toBe(true);\n    expect(hasReport(result.reports, 'zh-CN: extra keys vs en', 'a.stray')).toBe(true);\n  });\n\n  it('flags placeholder mismatch (missing placeholder in translation)', () => {\n    const result = compareLocales({\n      en: toMap({ greeting: 'Hello {{name}}!' }),\n      'zh-CN': toMap({ greeting: '你好!' }), // {{name}} dropped\n    });\n    expect(result.failed).toBe(true);\n    expect(hasReport(result.reports, 'placeholder mismatch', 'greeting')).toBe(true);\n  });\n\n  it('flags placeholder mismatch (translation introduces unknown placeholder)', () => {\n    // This is the exact class of bug the zh-CN sync caught:\n    // fileManager.uploadFailed had a stray {{count}} copied from a sibling key.\n    const result = compareLocales({\n      en: toMap({ uploadFailed: 'Upload failed' }),\n      'zh-CN': toMap({ uploadFailed: '{{count}} 个失败' }),\n    });\n    expect(result.failed).toBe(true);\n    expect(hasReport(result.reports, 'placeholder mismatch', 'uploadFailed')).toBe(true);\n  });\n\n  it('flags missing plural suffix keys in a non-en locale', () => {\n    const result = compareLocales({\n      en: toMap({ item_one: 'item', item_other: 'items' }),\n      'zh-TW': toMap({ item_one: '項目' }), // item_other missing\n    });\n    expect(result.failed).toBe(true);\n    expect(hasReport(result.reports, 'plural key mismatch', 'missing _other')).toBe(true);\n  });\n\n  it('flags a non-en _one key that does not exist in en', () => {\n    const result = compareLocales({\n      en: toMap({ item: 'item' }),\n      'zh-CN': toMap({ item: '项', item_one: '一项' }), // en never plural-gated this\n    });\n    expect(result.failed).toBe(true);\n    expect(\n      hasReport(result.reports, 'plural key mismatch', 'unexpected _one not present in en'),\n    ).toBe(true);\n  });\n\n  it('throws when the en locale is absent', () => {\n    expect(() =>\n      compareLocales({ 'zh-CN': toMap({ a: 'x' }) } as Record<string, LocaleMap>),\n    ).toThrow(/locales\\.en/);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/mocks/handlers.ts",
    "content": "/**\n * MSW request handlers for mocking API responses in tests.\n */\n\nimport { http, HttpResponse } from 'msw';\n\n// Sample data\nconst mockSmartPlugs = [\n  {\n    id: 1,\n    name: 'Test Plug',\n    ip_address: '192.168.1.100',\n    printer_id: 1,\n    enabled: true,\n    auto_on: true,\n    auto_off: true,\n    off_delay_mode: 'time',\n    off_delay_minutes: 5,\n    off_temp_threshold: 70,\n    username: null,\n    password: null,\n    power_alert_enabled: false,\n    power_alert_high: null,\n    power_alert_low: null,\n    power_alert_last_triggered: null,\n    schedule_enabled: false,\n    schedule_on_time: null,\n    schedule_off_time: null,\n    last_state: 'ON',\n    last_checked: null,\n    auto_off_executed: false,\n    auto_off_pending: false,\n    auto_off_pending_since: null,\n    created_at: '2024-01-01T00:00:00Z',\n    updated_at: '2024-01-01T00:00:00Z',\n  },\n];\n\nconst mockNotificationProviders = [\n  {\n    id: 1,\n    name: 'Test Webhook',\n    provider_type: 'webhook',\n    enabled: true,\n    config: { webhook_url: 'http://test.local/webhook' },\n    on_print_start: true,\n    on_print_complete: true,\n    on_print_failed: true,\n    on_print_stopped: false,\n    on_print_progress: false,\n    on_printer_offline: false,\n    on_printer_error: false,\n    on_filament_low: false,\n    on_maintenance_due: false,\n    on_ams_humidity_high: false,\n    on_ams_temperature_high: false,\n    on_ams_ht_humidity_high: false,\n    on_ams_ht_temperature_high: false,\n    quiet_hours_enabled: false,\n    quiet_hours_start: null,\n    quiet_hours_end: null,\n    daily_digest_enabled: false,\n    daily_digest_time: null,\n    printer_id: null,\n    last_success: null,\n    last_error: null,\n    last_error_at: null,\n    created_at: '2024-01-01T00:00:00Z',\n    updated_at: '2024-01-01T00:00:00Z',\n  },\n];\n\nconst mockPrinters = [\n  {\n    id: 1,\n    name: 'Test Printer',\n    serial_number: '00M09A000000000',\n    ip_address: '192.168.1.200',\n    is_active: true,\n    model: 'X1C',\n    nozzle_count: 1,\n    auto_archive: true,\n    location: null,\n    created_at: '2024-01-01T00:00:00Z',\n    updated_at: '2024-01-01T00:00:00Z',\n  },\n];\n\nexport const handlers = [\n  // ========================================================================\n  // Smart Plugs\n  // ========================================================================\n\n  http.get('/api/v1/smart-plugs/', () => {\n    return HttpResponse.json(mockSmartPlugs);\n  }),\n\n  http.get('/api/v1/smart-plugs/:id', ({ params }) => {\n    const plug = mockSmartPlugs.find((p) => p.id === Number(params.id));\n    if (!plug) {\n      return new HttpResponse(null, { status: 404 });\n    }\n    return HttpResponse.json(plug);\n  }),\n\n  http.post('/api/v1/smart-plugs/', async ({ request }) => {\n    const body = (await request.json()) as Record<string, unknown>;\n    const { id: _id, ...baseData } = mockSmartPlugs[0];\n    const newPlug = {\n      id: mockSmartPlugs.length + 1,\n      ...baseData,\n      ...body,\n    };\n    return HttpResponse.json(newPlug);\n  }),\n\n  http.patch('/api/v1/smart-plugs/:id', async ({ params, request }) => {\n    const body = (await request.json()) as Record<string, unknown>;\n    const plug = mockSmartPlugs.find((p) => p.id === Number(params.id));\n    if (!plug) {\n      return new HttpResponse(null, { status: 404 });\n    }\n    return HttpResponse.json({ ...plug, ...body });\n  }),\n\n  http.delete('/api/v1/smart-plugs/:id', ({ params }) => {\n    const index = mockSmartPlugs.findIndex((p) => p.id === Number(params.id));\n    if (index === -1) {\n      return new HttpResponse(null, { status: 404 });\n    }\n    return HttpResponse.json({ success: true });\n  }),\n\n  http.get('/api/v1/smart-plugs/:id/status', () => {\n    return HttpResponse.json({\n      state: 'ON',\n      reachable: true,\n      device_name: 'Test Plug',\n      energy: {\n        power: 150.5,\n        voltage: 120.0,\n        current: 1.25,\n        today: 2.5,\n        total: 100.0,\n      },\n    });\n  }),\n\n  http.post('/api/v1/smart-plugs/:id/control', async ({ request }) => {\n    const body = (await request.json()) as { action: string };\n    return HttpResponse.json({\n      success: true,\n      action: body.action,\n    });\n  }),\n\n  // ========================================================================\n  // Notification Providers\n  // ========================================================================\n\n  http.get('/api/v1/notifications/', () => {\n    return HttpResponse.json(mockNotificationProviders);\n  }),\n\n  http.get('/api/v1/notifications/:id', ({ params }) => {\n    const provider = mockNotificationProviders.find(\n      (p) => p.id === Number(params.id)\n    );\n    if (!provider) {\n      return new HttpResponse(null, { status: 404 });\n    }\n    return HttpResponse.json(provider);\n  }),\n\n  http.post('/api/v1/notifications/', async ({ request }) => {\n    const body = (await request.json()) as Record<string, unknown>;\n    const { id: _id, ...baseData } = mockNotificationProviders[0];\n    const newProvider = {\n      id: mockNotificationProviders.length + 1,\n      ...baseData,\n      ...body,\n    };\n    return HttpResponse.json(newProvider);\n  }),\n\n  http.patch('/api/v1/notifications/:id', async ({ params, request }) => {\n    const body = (await request.json()) as Record<string, unknown>;\n    const provider = mockNotificationProviders.find(\n      (p) => p.id === Number(params.id)\n    );\n    if (!provider) {\n      return new HttpResponse(null, { status: 404 });\n    }\n    return HttpResponse.json({ ...provider, ...body });\n  }),\n\n  http.delete('/api/v1/notifications/:id', ({ params }) => {\n    const index = mockNotificationProviders.findIndex(\n      (p) => p.id === Number(params.id)\n    );\n    if (index === -1) {\n      return new HttpResponse(null, { status: 404 });\n    }\n    return HttpResponse.json({ success: true });\n  }),\n\n  http.post('/api/v1/notifications/:id/test', () => {\n    return HttpResponse.json({\n      success: true,\n      message: 'Test notification sent',\n    });\n  }),\n\n  // ========================================================================\n  // Printers\n  // ========================================================================\n\n  http.get('/api/v1/printers/', () => {\n    return HttpResponse.json(mockPrinters);\n  }),\n\n  http.get('/api/v1/printers/:id', ({ params }) => {\n    const printer = mockPrinters.find((p) => p.id === Number(params.id));\n    if (!printer) {\n      return new HttpResponse(null, { status: 404 });\n    }\n    return HttpResponse.json(printer);\n  }),\n\n  http.get('/api/v1/printers/:id/status', ({ params }) => {\n    return HttpResponse.json({\n      id: Number(params.id),\n      name: 'Test Printer',\n      connected: true,\n      state: 'IDLE',\n      progress: 0,\n      layer_num: 0,\n      total_layers: 0,\n      temperatures: {\n        nozzle: 25,\n        bed: 25,\n        chamber: 25,\n      },\n      remaining_time: 0,\n      filename: null,\n    });\n  }),\n\n  // ========================================================================\n  // Settings\n  // ========================================================================\n\n  http.get('/api/v1/settings/', () => {\n    return HttpResponse.json({\n      auto_archive: true,\n      save_thumbnails: true,\n      capture_finish_photo: true,\n      default_filament_cost: 25.0,\n      currency: 'USD',\n      ams_humidity_good: 40,\n      ams_humidity_fair: 60,\n      ams_temp_good: 30,\n      ams_temp_fair: 35,\n    });\n  }),\n\n  http.patch('/api/v1/settings/', async ({ request }) => {\n    const body = (await request.json()) as Record<string, unknown>;\n    return HttpResponse.json(body);\n  }),\n\n  // ========================================================================\n  // Auth\n  // ========================================================================\n\n  http.get('*/api/v1/auth/status', () => {\n    return HttpResponse.json({\n      auth_enabled: false,\n      requires_setup: false,\n    });\n  }),\n\n  http.get('/api/v1/auth/me', () => {\n    return HttpResponse.json({\n      id: 1,\n      username: 'admin',\n      role: 'admin',\n      is_active: true,\n      is_admin: true,\n      groups: [{ id: 1, name: 'Administrators' }],\n      permissions: [],\n      created_at: '2024-01-01T00:00:00Z',\n    });\n  }),\n\n  // ========================================================================\n  // Groups\n  // ========================================================================\n\n  http.get('/api/v1/groups/', () => {\n    return HttpResponse.json([\n      {\n        id: 1,\n        name: 'Administrators',\n        description: 'Full access to all features',\n        permissions: ['printers:read', 'settings:update', 'users:create'],\n        is_system: true,\n        created_at: '2024-01-01T00:00:00Z',\n        updated_at: '2024-01-01T00:00:00Z',\n      },\n      {\n        id: 2,\n        name: 'Operators',\n        description: 'Control printers and manage content',\n        permissions: ['printers:read', 'printers:control'],\n        is_system: true,\n        created_at: '2024-01-01T00:00:00Z',\n        updated_at: '2024-01-01T00:00:00Z',\n      },\n      {\n        id: 3,\n        name: 'Viewers',\n        description: 'Read-only access',\n        permissions: ['printers:read'],\n        is_system: true,\n        created_at: '2024-01-01T00:00:00Z',\n        updated_at: '2024-01-01T00:00:00Z',\n      },\n    ]);\n  }),\n\n  http.get('/api/v1/groups/permissions', () => {\n    return HttpResponse.json({\n      'Printers': ['printers:read', 'printers:create', 'printers:update', 'printers:delete', 'printers:control'],\n      'Archives': ['archives:read', 'archives:create', 'archives:update', 'archives:delete'],\n      'Settings': ['settings:read', 'settings:update'],\n    });\n  }),\n\n  http.post('/api/v1/groups/', async ({ request }) => {\n    const body = (await request.json()) as Record<string, unknown>;\n    return HttpResponse.json({\n      id: 4,\n      ...body,\n      is_system: false,\n      created_at: '2024-01-01T00:00:00Z',\n      updated_at: '2024-01-01T00:00:00Z',\n    });\n  }),\n\n  http.patch('/api/v1/groups/:id', async ({ params, request }) => {\n    const body = (await request.json()) as Record<string, unknown>;\n    return HttpResponse.json({\n      id: Number(params.id),\n      name: 'Updated Group',\n      ...body,\n      is_system: false,\n      created_at: '2024-01-01T00:00:00Z',\n      updated_at: '2024-01-01T00:00:00Z',\n    });\n  }),\n\n  http.delete('/api/v1/groups/:id', () => {\n    return new HttpResponse(null, { status: 204 });\n  }),\n\n  // ========================================================================\n  // Discovery\n  // ========================================================================\n\n  http.get('/api/v1/discovery/info', () => {\n    return HttpResponse.json({\n      is_docker: false,\n      ssdp_running: false,\n      scan_running: false,\n      subnets: ['192.168.1.0/24'],\n    });\n  }),\n\n  // ========================================================================\n  // Version / Health\n  // ========================================================================\n\n  http.get('/api/v1/version', () => {\n    return HttpResponse.json({\n      version: '0.1.5',\n      build: 'test',\n    });\n  }),\n\n  http.get('/health', () => {\n    return HttpResponse.json({ status: 'healthy' });\n  }),\n\n  // ========================================================================\n  // Archives\n  // ========================================================================\n\n  http.get('/api/v1/archives/:id/plates', ({ params }) => {\n    const archiveId = Number(params.id);\n    return HttpResponse.json({\n      archive_id: Number.isFinite(archiveId) ? archiveId : 0,\n      filename: 'sample.3mf',\n      plates: [],\n      is_multi_plate: false,\n    });\n  }),\n\n  http.get('/api/v1/archives/:id/filament-requirements', () => {\n    return HttpResponse.json([]);\n  }),\n\n  // ========================================================================\n  // Library\n  // ========================================================================\n\n  http.get('/api/v1/library/stats', () => {\n    return HttpResponse.json({\n      total_files: 0,\n      total_size: 0,\n      total_folders: 0,\n    });\n  }),\n];\n"
  },
  {
    "path": "frontend/src/__tests__/mocks/server.ts",
    "content": "/**\n * MSW server configuration for tests.\n */\n\nimport { setupServer } from 'msw/node';\nimport { handlers } from './handlers';\n\n// Setup MSW server with default handlers\nexport const server = setupServer(...handlers);\n"
  },
  {
    "path": "frontend/src/__tests__/pages/ArchivesPage.test.tsx",
    "content": "/**\n * Tests for the ArchivesPage component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { ArchivesPage } from '../../pages/ArchivesPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockArchives = [\n  {\n    id: 1,\n    filename: 'benchy.gcode.3mf',\n    print_name: 'Benchy',\n    printer_id: 1,\n    printer_name: 'X1 Carbon',\n    print_time_seconds: 3600,\n    filament_used_grams: 15.5,\n    status: 'completed',\n    started_at: '2024-01-01T10:00:00Z',\n    completed_at: '2024-01-01T11:00:00Z',\n    thumbnail_path: '/thumbnails/1.png',\n    notes: 'Test print',\n    rating: 5,\n    project_id: null,\n    project_name: null,\n    project_color: null,\n    print_count: 3,\n    tags: 'test,calibration',\n    created_at: '2024-01-01T09:00:00Z',\n    updated_at: '2024-01-01T11:00:00Z',\n    has_f3d: false,\n  },\n  {\n    id: 2,\n    filename: 'bracket.gcode.3mf',\n    print_name: 'Bracket v2',\n    printer_id: 1,\n    printer_name: 'X1 Carbon',\n    print_time_seconds: 7200,\n    filament_used_grams: 45.0,\n    status: 'completed',\n    started_at: '2024-01-02T14:00:00Z',\n    completed_at: '2024-01-02T16:00:00Z',\n    thumbnail_path: '/thumbnails/2.png',\n    notes: null,\n    rating: null,\n    project_id: 1,\n    project_name: 'Functional Parts',\n    project_color: '#00ae42',\n    print_count: 1,\n    tags: '',\n    created_at: '2024-01-02T13:00:00Z',\n    updated_at: '2024-01-02T16:00:00Z',\n    has_f3d: true,\n  },\n];\n\nconst mockArchiveStats = {\n  total_archives: 10,\n  total_print_time_seconds: 36000,\n  total_filament_grams: 500,\n  prints_this_week: 5,\n  prints_this_month: 20,\n};\n\ndescribe('ArchivesPage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/archives/', () => {\n        return HttpResponse.json(mockArchives);\n      }),\n      http.get('/api/v1/archives/stats', () => {\n        return HttpResponse.json(mockArchiveStats);\n      }),\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json([{ id: 1, name: 'X1 Carbon' }]);\n      }),\n      http.get('/api/v1/projects/', () => {\n        return HttpResponse.json([{ id: 1, name: 'Functional Parts', color: '#00ae42' }]);\n      }),\n      http.get('/api/v1/archives/tags', () => {\n        return HttpResponse.json(['test', 'calibration', 'functional']);\n      }),\n      http.get('/api/v1/archives/:id/plates', ({ params }) => {\n        const archiveId = Number(params.id);\n        return HttpResponse.json({\n          archive_id: Number.isFinite(archiveId) ? archiveId : 0,\n          filename: 'sample.3mf',\n          plates: [],\n          is_multi_plate: false,\n        });\n      }),\n      http.get('/api/v1/archives/:id/filament-requirements', () => {\n        return HttpResponse.json([]);\n      }),\n      http.delete('/api/v1/archives/:id', () => {\n        return HttpResponse.json({ success: true });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page title', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Archives')).toBeInTheDocument();\n      });\n    });\n\n    it('shows archive cards', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n        expect(screen.getByText('Bracket v2')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('archive info', () => {\n    it('shows print time', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('1h 0m')).toBeInTheDocument();\n      });\n    });\n\n    it('shows printer name', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        const printerNames = screen.getAllByText('X1 Carbon');\n        expect(printerNames.length).toBeGreaterThan(0);\n      });\n    });\n\n    it('shows tags', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        // Tags may be truncated or displayed differently - just verify archives load\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n\n      // Tags are displayed in the archive cards\n      const testElements = screen.queryAllByText('test');\n      expect(testElements.length).toBeGreaterThanOrEqual(0);\n    });\n\n    it('shows print count badge', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        // Print count may be displayed as badge\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n    });\n\n    it('shows project badge', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Functional Parts')).toBeInTheDocument();\n      });\n    });\n\n    it('shows F3D indicator when file has F3D', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        // Bracket v2 has has_f3d: true\n        expect(screen.getByText('Bracket v2')).toBeInTheDocument();\n      });\n\n      // F3D files have cyan badge indicator - look for it by title or class\n      const f3dElements = document.querySelectorAll('[title*=\"F3D\"]');\n      expect(f3dElements.length).toBeGreaterThanOrEqual(0);\n    });\n  });\n\n  describe('search and filter', () => {\n    it('has search input', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();\n      });\n    });\n\n    it('has printer filter', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('All Printers')).toBeInTheDocument();\n      });\n    });\n\n    it('has project filter', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        // Project filter dropdown may have different default text\n        const projectSelect = screen.getAllByRole('combobox');\n        expect(projectSelect.length).toBeGreaterThan(0);\n      });\n    });\n  });\n\n  describe('view modes', () => {\n    it('has grid view option', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByTitle(/grid/i)).toBeInTheDocument();\n      });\n    });\n\n    it('has list view option', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByTitle(/list/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('empty state', () => {\n    it('shows empty state when no archives', async () => {\n      server.use(\n        http.get('/api/v1/archives/', () => {\n          return HttpResponse.json([]);\n        })\n      );\n\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/no archives/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('stats display', () => {\n    it('shows archives list', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        // Verify archives are loaded\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n        expect(screen.getByText('Bracket v2')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('rating display', () => {\n    it('shows rating stars', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        // Rating 5 shows stars\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('plate navigation', () => {\n    it('renders archive cards with thumbnails', async () => {\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        // Archive cards should render with their thumbnails\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n        // Thumbnail images should be present (archive cards have img elements)\n        const images = document.querySelectorAll('img[alt=\"Benchy\"]');\n        expect(images.length).toBeGreaterThanOrEqual(0);\n      });\n    });\n\n    it('fetches plate data for multi-plate archives on hover', async () => {\n      // Setup handler for plates endpoint\n      server.use(\n        http.get('/api/v1/archives/:id/plates', ({ params }) => {\n          return HttpResponse.json({\n            archive_id: Number(params.id),\n            filename: 'test.3mf',\n            plates: [\n              { index: 0, name: 'Plate 1', objects: ['Object A'], has_thumbnail: true, thumbnail_url: '/thumb1.png', print_time_seconds: 3600, filament_used_grams: 10, filaments: [] },\n              { index: 1, name: 'Plate 2', objects: ['Object B'], has_thumbnail: true, thumbnail_url: '/thumb2.png', print_time_seconds: 1800, filament_used_grams: 5, filaments: [] },\n            ],\n            is_multi_plate: true,\n          });\n        })\n      );\n\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n\n      // Archives with multi-plate support will show navigation on hover\n      // The plates API is called lazily when hovering\n    });\n  });\n\n  describe('timelapse management', () => {\n    it('shows upload timelapse menu item when no timelapse attached', async () => {\n      const archivesWithoutTimelapse = mockArchives.map(a => ({ ...a, timelapse_path: null }));\n      server.use(\n        http.get('/api/v1/archives/', () => {\n          return HttpResponse.json(archivesWithoutTimelapse);\n        })\n      );\n\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n\n      // Context menu items are rendered in the DOM even when not visible\n      // \"Upload Timelapse\" should be present for archives without timelapse\n      const uploadItems = screen.queryAllByText('Upload Timelapse');\n      expect(uploadItems.length).toBeGreaterThanOrEqual(0);\n    });\n\n    it('shows remove timelapse menu item when timelapse is attached', async () => {\n      const archivesWithTimelapse = mockArchives.map(a => ({\n        ...a,\n        timelapse_path: 'archives/1/timelapse.mp4',\n      }));\n      server.use(\n        http.get('/api/v1/archives/', () => {\n          return HttpResponse.json(archivesWithTimelapse);\n        })\n      );\n\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n\n      // \"Remove Timelapse\" should be present for archives with timelapse\n      const removeItems = screen.queryAllByText('Remove Timelapse');\n      expect(removeItems.length).toBeGreaterThanOrEqual(0);\n    });\n\n    it('disables scan for timelapse when timelapse is already attached', async () => {\n      const archivesWithTimelapse = mockArchives.map(a => ({\n        ...a,\n        timelapse_path: 'archives/1/timelapse.mp4',\n      }));\n      server.use(\n        http.get('/api/v1/archives/', () => {\n          return HttpResponse.json(archivesWithTimelapse);\n        })\n      );\n\n      render(<ArchivesPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n\n      // \"Scan for Timelapse\" buttons should be disabled when timelapse exists\n      // Upload Timelapse should also be disabled\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/CameraPage.test.tsx",
    "content": "/**\n * Tests for the CameraPage component.\n */\n\nimport { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { screen, waitFor, render as rtlRender } from '@testing-library/react';\nimport { CameraPage } from '../../pages/CameraPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\nimport { MemoryRouter, Route, Routes } from 'react-router-dom';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { ThemeProvider } from '../../contexts/ThemeContext';\nimport { ToastProvider } from '../../contexts/ToastContext';\nimport { AuthProvider } from '../../contexts/AuthContext';\nimport { I18nextProvider } from 'react-i18next';\nimport i18n from '../../i18n';\n\n// Mock navigator.sendBeacon which isn't available in jsdom\nvi.stubGlobal('navigator', {\n  ...navigator,\n  sendBeacon: vi.fn().mockReturnValue(true),\n});\n\nconst mockPrinter = {\n  id: 1,\n  name: 'X1 Carbon',\n  ip_address: '192.168.1.100',\n  serial_number: '00M09A350100001',\n  access_code: '12345678',\n  model: 'X1C',\n  enabled: true,\n};\n\n// Custom render for CameraPage which needs specific route params\nfunction renderCameraPage(printerId: number) {\n  const queryClient = new QueryClient({\n    defaultOptions: {\n      queries: { retry: false, gcTime: 0 },\n      mutations: { retry: false },\n    },\n  });\n\n  return rtlRender(\n    <QueryClientProvider client={queryClient}>\n      <I18nextProvider i18n={i18n}>\n        <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>\n          <ThemeProvider>\n            <AuthProvider>\n              <ToastProvider>\n                <Routes>\n                  <Route path=\"/cameras/:printerId\" element={<CameraPage />} />\n                </Routes>\n              </ToastProvider>\n            </AuthProvider>\n          </ThemeProvider>\n        </MemoryRouter>\n      </I18nextProvider>\n    </QueryClientProvider>\n  );\n}\n\ndescribe('CameraPage', () => {\n  const originalTitle = document.title;\n\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/printers/:id', () => {\n        return HttpResponse.json(mockPrinter);\n      }),\n      http.get('/api/v1/printers/:id/status', () => {\n        return HttpResponse.json({\n          connected: true,\n          state: 'IDLE',\n          progress: 0,\n        });\n      }),\n      http.post('/api/v1/printers/:id/camera/stop', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.get('/api/v1/printers/:id/camera/status', () => {\n        return HttpResponse.json({ active: true, stalled: false });\n      })\n    );\n  });\n\n  afterEach(() => {\n    document.title = originalTitle;\n  });\n\n  describe('rendering', () => {\n    it('renders camera page for printer', async () => {\n      renderCameraPage(1);\n\n      // Camera page should load - look for the header with camera icon\n      await waitFor(() => {\n        expect(screen.getByRole('heading')).toBeInTheDocument();\n      });\n    });\n\n    it('shows live and snapshot mode buttons', async () => {\n      renderCameraPage(1);\n\n      await waitFor(() => {\n        // Check for translation key or translated text\n        expect(screen.getByText(/Live|camera\\.live/)).toBeInTheDocument();\n        expect(screen.getByText(/Snapshot|camera\\.snapshot/)).toBeInTheDocument();\n      });\n    });\n\n    it('shows printer name in header', async () => {\n      renderCameraPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('camera controls', () => {\n    it('renders without crashing', async () => {\n      renderCameraPage(1);\n\n      // Just verify no crash during render\n      await waitFor(() => {\n        expect(document.body).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('stream token handling (#979)', () => {\n    it('does not render image src until stream token arrives when auth is enabled', async () => {\n      let resolveToken!: (value: unknown) => void;\n      const tokenPromise = new Promise((resolve) => {\n        resolveToken = resolve;\n      });\n\n      server.use(\n        http.get('*/api/v1/auth/status', () =>\n          HttpResponse.json({ auth_enabled: true, requires_setup: false })\n        ),\n        http.post('*/api/v1/printers/camera/stream-token', async () => {\n          await tokenPromise;\n          return HttpResponse.json({ token: 'tok-abc' });\n        })\n      );\n\n      renderCameraPage(1);\n\n      // Before the token resolves the <img> should not have a src pointing at\n      // the stream endpoint — otherwise the backend would 401 with the\n      // \"Valid camera stream token required\" error from #979.\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n      const img = document.querySelector('img') as HTMLImageElement | null;\n      expect(img).not.toBeNull();\n      expect(img?.getAttribute('src') || '').not.toContain('/camera/stream');\n\n      resolveToken(undefined);\n\n      // After the token resolves the image src picks it up as ?token=...\n      await waitFor(() => {\n        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';\n        expect(src).toContain('/camera/stream');\n        expect(src).toContain('token=tok-abc');\n      });\n    });\n\n    it('renders image src immediately when auth is disabled (no token required)', async () => {\n      renderCameraPage(1);\n\n      await waitFor(() => {\n        const src = (document.querySelector('img') as HTMLImageElement | null)?.getAttribute('src') || '';\n        expect(src).toContain(`/api/v1/printers/1/camera/stream`);\n        expect(src).not.toContain('token=');\n      });\n    });\n  });\n\n  describe('invalid printer', () => {\n    it('shows invalid printer message for ID 0', async () => {\n      renderCameraPage(0);\n\n      await waitFor(() => {\n        // Check for translation key or translated text\n        expect(screen.getByText(/Invalid printer ID|camera\\.invalidPrinterId/)).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/FileManagerExternalFolder.test.tsx",
    "content": "/**\n * Tests for External Folder functionality in FileManagerPage.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { FileManagerPage } from '../../pages/FileManagerPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\n// Mock data with external folder\nconst mockFoldersWithExternal = [\n  {\n    id: 1,\n    name: 'Regular Folder',\n    parent_id: null,\n    file_count: 3,\n    project_id: null,\n    archive_id: null,\n    project_name: null,\n    archive_name: null,\n    is_external: false,\n    external_path: null,\n    external_readonly: false,\n    children: [],\n  },\n  {\n    id: 2,\n    name: 'NAS Prints',\n    parent_id: null,\n    file_count: 5,\n    project_id: null,\n    archive_id: null,\n    project_name: null,\n    archive_name: null,\n    is_external: true,\n    external_path: '/mnt/nas/prints',\n    external_readonly: true,\n    children: [],\n  },\n  {\n    id: 3,\n    name: 'USB Drive',\n    parent_id: null,\n    file_count: 2,\n    project_id: null,\n    archive_id: null,\n    project_name: null,\n    archive_name: null,\n    is_external: true,\n    external_path: '/mnt/usb',\n    external_readonly: false,\n    children: [],\n  },\n];\n\nconst mockFiles = [\n  {\n    id: 1,\n    filename: 'benchy.3mf',\n    file_path: '/mnt/nas/prints/benchy.3mf',\n    file_size: 1048576,\n    file_type: '3mf',\n    folder_id: 2,\n    is_external: true,\n    thumbnail_path: null,\n    print_name: 'Benchy',\n    print_time_seconds: 3600,\n    print_count: 0,\n    duplicate_count: 0,\n    created_at: '2024-01-01T00:00:00Z',\n  },\n];\n\nconst mockStats = {\n  total_files: 10,\n  total_folders: 3,\n  total_size_bytes: 104857600,\n  disk_free_bytes: 10737418240,\n  disk_total_bytes: 107374182400,\n};\n\ndescribe('FileManagerPage - External Folders', () => {\n  beforeEach(() => {\n    localStorage.clear();\n\n    server.use(\n      http.get('/api/v1/library/folders', () => {\n        return HttpResponse.json(mockFoldersWithExternal);\n      }),\n      http.get('/api/v1/library/files', () => {\n        return HttpResponse.json(mockFiles);\n      }),\n      http.get('/api/v1/library/stats', () => {\n        return HttpResponse.json(mockStats);\n      }),\n      http.get('/api/v1/settings/', () => {\n        return HttpResponse.json({\n          check_updates: false,\n          check_printer_firmware: false,\n          library_disk_warning_gb: 5,\n        });\n      }),\n      http.post('/api/v1/library/folders/external', async ({ request }) => {\n        const body = await request.json() as { name: string; external_path: string };\n        return HttpResponse.json({\n          id: 10,\n          name: body.name,\n          parent_id: null,\n          is_external: true,\n          external_path: body.external_path,\n          external_readonly: true,\n          external_show_hidden: false,\n          file_count: 0,\n          created_at: '2024-01-01T00:00:00Z',\n          updated_at: '2024-01-01T00:00:00Z',\n        });\n      }),\n      http.post('/api/v1/library/folders/:id/scan', () => {\n        return HttpResponse.json({ status: 'success', added: 3, removed: 0 });\n      }),\n      http.get('/api/v1/projects/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/archives/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.delete('/api/v1/library/folders/:id', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.delete('/api/v1/library/files/:id', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.post('/api/v1/library/files/move', () => {\n        return HttpResponse.json({ success: true });\n      }),\n    );\n  });\n\n  describe('rendering', () => {\n    it('shows Link External button', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Link External')).toBeInTheDocument();\n      });\n    });\n\n    it('shows external folder in sidebar', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('NAS Prints')).toBeInTheDocument();\n        expect(screen.getByText('USB Drive')).toBeInTheDocument();\n      });\n    });\n\n    it('shows regular folder alongside external', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Regular Folder')).toBeInTheDocument();\n        expect(screen.getByText('NAS Prints')).toBeInTheDocument();\n      });\n    });\n\n    it('shows read-only indicator for readonly external folders', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        // NAS Prints is readonly, should have a lock icon title\n        const lockIcons = document.querySelectorAll('[title=\"Read Only\"]');\n        expect(lockIcons.length).toBeGreaterThan(0);\n      });\n    });\n  });\n\n  describe('external folder modal', () => {\n    it('opens modal when Link External clicked', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Link External')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Link External'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Link External Folder')).toBeInTheDocument();\n      });\n    });\n\n    it('modal has name and path fields', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Link External')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Link External'));\n\n      await waitFor(() => {\n        expect(screen.getByPlaceholderText('e.g., NAS Prints')).toBeInTheDocument();\n        expect(screen.getByPlaceholderText('/mnt/nas/3d-prints')).toBeInTheDocument();\n      });\n    });\n\n    it('modal has readonly checkbox checked by default', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Link External')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Link External'));\n\n      await waitFor(() => {\n        const readonlyCheckbox = screen.getByText('Read Only').previousElementSibling as HTMLInputElement;\n        expect(readonlyCheckbox).toBeChecked();\n      });\n    });\n\n    it('modal can be closed', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Link External')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Link External'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Link External Folder')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Cancel'));\n\n      await waitFor(() => {\n        expect(screen.queryByText('Link External Folder')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('external folder info bar', () => {\n    it('shows info bar when external folder selected', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('NAS Prints')).toBeInTheDocument();\n      });\n\n      // Click on NAS Prints folder - there are multiple elements, get the one in the sidebar\n      const folderElements = screen.getAllByText('NAS Prints');\n      await user.click(folderElements[0]);\n\n      await waitFor(() => {\n        expect(screen.getByText('External Folder')).toBeInTheDocument();\n        expect(screen.getByText('/mnt/nas/prints')).toBeInTheDocument();\n      });\n    });\n\n    it('shows scan button for external folders', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('NAS Prints')).toBeInTheDocument();\n      });\n\n      const folderElements = screen.getAllByText('NAS Prints');\n      await user.click(folderElements[0]);\n\n      await waitFor(() => {\n        expect(screen.getByText('Scan')).toBeInTheDocument();\n      });\n    });\n\n    it('does not show info bar for regular folders', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Regular Folder')).toBeInTheDocument();\n      });\n\n      const folderElements = screen.getAllByText('Regular Folder');\n      await user.click(folderElements[0]);\n\n      // External Folder label should NOT appear\n      await waitFor(() => {\n        expect(screen.queryByText('External Folder')).not.toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/FileManagerPage.test.tsx",
    "content": "/**\n * Tests for the FileManagerPage component.\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { FileManagerPage } from '../../pages/FileManagerPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\n// Mock data\nconst mockFolders = [\n  {\n    id: 1,\n    name: 'Functional Parts',\n    parent_id: null,\n    file_count: 5,\n    project_id: null,\n    archive_id: null,\n    project_name: null,\n    archive_name: null,\n    children: [\n      {\n        id: 2,\n        name: 'Brackets',\n        parent_id: 1,\n        file_count: 3,\n        project_id: null,\n        archive_id: null,\n        project_name: null,\n        archive_name: null,\n        children: [],\n      },\n    ],\n  },\n  {\n    id: 3,\n    name: 'Art Projects',\n    parent_id: null,\n    file_count: 2,\n    project_id: 1,\n    archive_id: null,\n    project_name: 'My Art Project',\n    archive_name: null,\n    children: [],\n  },\n];\n\nconst mockFiles = [\n  {\n    id: 1,\n    filename: 'benchy.gcode.3mf',\n    file_path: '/library/benchy.gcode.3mf',\n    file_size: 1048576,\n    file_type: '3mf',\n    folder_id: null,\n    thumbnail_path: '/thumbnails/1.png',\n    print_name: 'Benchy',\n    print_time_seconds: 3600,\n    print_count: 5,\n    duplicate_count: 0,\n    created_at: '2024-01-01T00:00:00Z',\n  },\n  {\n    id: 2,\n    filename: 'bracket.stl',\n    file_path: '/library/bracket.stl',\n    file_size: 524288,\n    file_type: 'stl',\n    folder_id: null,\n    thumbnail_path: null,\n    print_name: null,\n    print_time_seconds: null,\n    print_count: 0,\n    duplicate_count: 2,\n    created_at: '2024-01-02T00:00:00Z',\n  },\n  {\n    id: 3,\n    filename: 'cube.gcode.3mf',\n    file_path: '/library/cube.gcode.3mf',\n    file_size: 2048576,\n    file_type: '3mf',\n    folder_id: null,\n    thumbnail_path: '/thumbnails/3.png',\n    print_name: 'Cube',\n    print_time_seconds: 1800,\n    print_count: 2,\n    duplicate_count: 0,\n    created_at: '2024-01-03T00:00:00Z',\n  },\n];\n\nconst mockStats = {\n  total_files: 10,\n  total_folders: 3,\n  total_size_bytes: 104857600,\n  disk_free_bytes: 10737418240,\n  disk_total_bytes: 107374182400,\n};\n\ndescribe('FileManagerPage', () => {\n  beforeEach(() => {\n    // Clear localStorage to ensure consistent view mode\n    localStorage.clear();\n\n    server.use(\n      http.get('/api/v1/library/folders', () => {\n        return HttpResponse.json(mockFolders);\n      }),\n      http.get('/api/v1/library/files', () => {\n        return HttpResponse.json(mockFiles);\n      }),\n      http.get('/api/v1/library/stats', () => {\n        return HttpResponse.json(mockStats);\n      }),\n      http.get('/api/v1/settings/', () => {\n        return HttpResponse.json({\n          check_updates: false,\n          check_printer_firmware: false,\n          library_disk_warning_gb: 5,\n        });\n      }),\n      http.post('/api/v1/library/folders', async ({ request }) => {\n        const body = await request.json() as { name: string };\n        return HttpResponse.json({ id: 4, name: body.name, parent_id: null, children: [] });\n      }),\n      http.delete('/api/v1/library/folders/:id', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.delete('/api/v1/library/files/:id', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.post('/api/v1/library/files/move', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.post('/api/v1/library/files/add-to-queue', () => {\n        return HttpResponse.json({ added: [{ file_id: 1, queue_id: 1 }], errors: [] });\n      }),\n      http.get('/api/v1/projects/', () => {\n        return HttpResponse.json([{ id: 1, name: 'Test Project', color: '#00ae42' }]);\n      }),\n      http.get('/api/v1/archives/', () => {\n        return HttpResponse.json([{ id: 1, print_name: 'Test Archive', filename: 'test.3mf' }]);\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page title', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('File Manager')).toBeInTheDocument();\n      });\n    });\n\n    it('renders the page description', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Organize and manage your print files')).toBeInTheDocument();\n      });\n    });\n\n    it('shows New Folder button', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('New Folder')).toBeInTheDocument();\n      });\n    });\n\n    it('shows Upload button', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('stats display', () => {\n    it('shows file count', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Files:')).toBeInTheDocument();\n        expect(screen.getByText('10')).toBeInTheDocument();\n      });\n    });\n\n    it('shows folder count', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Folders:')).toBeInTheDocument();\n        // Folder count appears multiple places, just verify the label is present\n        const foldersLabel = screen.getByText('Folders:');\n        expect(foldersLabel.nextElementSibling?.textContent).toBe('3');\n      });\n    });\n\n    it('shows total size', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Size:')).toBeInTheDocument();\n        expect(screen.getByText('100.0 MB')).toBeInTheDocument();\n      });\n    });\n\n    it('shows free space', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Free:')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('folder sidebar', () => {\n    it('shows All Files option', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('All Files')).toBeInTheDocument();\n      });\n    });\n\n    it('shows folder tree', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Functional Parts')).toBeInTheDocument();\n        expect(screen.getByText('Art Projects')).toBeInTheDocument();\n      });\n    });\n\n    it('shows nested folders', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Brackets')).toBeInTheDocument();\n      });\n    });\n\n    it('shows linked folder indicator', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        // Art Projects has a project_id\n        expect(screen.getByText('Art Projects')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('file display', () => {\n    it('shows files in grid', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n    });\n\n    it('shows file type badges', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        // File type badges show uppercase type\n        expect(screen.getAllByText('3MF').length).toBeGreaterThan(0);\n        expect(screen.getAllByText('STL').length).toBeGreaterThan(0);\n      });\n    });\n\n    it('shows print count', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Printed 5x')).toBeInTheDocument();\n      });\n    });\n\n    it('shows duplicate badge', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        // Duplicate badge shows count, there may be multiple \"2\"s on the page\n        // so we check that at least one element with \"2\" exists\n        const elements = screen.getAllByText('2');\n        expect(elements.length).toBeGreaterThan(0);\n      });\n    });\n  });\n\n  describe('view modes', () => {\n    it('has grid view button', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByTitle('Grid view')).toBeInTheDocument();\n      });\n    });\n\n    it('has list view button', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByTitle('List view')).toBeInTheDocument();\n      });\n    });\n\n    it('can switch to list view', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      // Wait for files to load first\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n\n      // Both view mode buttons should be present and clickable\n      const gridButton = screen.getByTitle('Grid view');\n      const listButton = screen.getByTitle('List view');\n\n      expect(gridButton).toBeInTheDocument();\n      expect(listButton).toBeInTheDocument();\n\n      // Click list view button - verify no errors occur\n      await user.click(listButton);\n\n      // Clicking grid button should also work\n      await user.click(gridButton);\n\n      // Verify files are still displayed after toggling\n      expect(screen.getByText('Benchy')).toBeInTheDocument();\n    });\n  });\n\n  describe('search and filter', () => {\n    it('has search input', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByPlaceholderText('Search files...')).toBeInTheDocument();\n      });\n    });\n\n    it('has type filter', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('All types')).toBeInTheDocument();\n      });\n    });\n\n    it('has sort options', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        // Sort dropdown should show Name as default option (persisted to localStorage)\n        expect(screen.getByDisplayValue('Name')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('selection', () => {\n    it('shows select all button', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n    });\n\n    it('can select files', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n\n      // Click on the file card to select it\n      const fileCard = screen.getByText('Benchy').closest('div[class*=\"cursor-pointer\"]');\n      if (fileCard) {\n        await user.click(fileCard);\n      }\n\n      await waitFor(() => {\n        expect(screen.getByText('1 selected')).toBeInTheDocument();\n      });\n    });\n\n    it('shows bulk actions when files selected', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select All'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Move')).toBeInTheDocument();\n        expect(screen.getByText('Delete')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('new folder modal', () => {\n    it('opens new folder modal', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('New Folder')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('New Folder'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Folder Name')).toBeInTheDocument();\n        expect(screen.getByPlaceholderText('e.g., Functional Parts')).toBeInTheDocument();\n      });\n    });\n\n    it('can create a folder', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('New Folder')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('New Folder'));\n\n      await waitFor(() => {\n        expect(screen.getByPlaceholderText('e.g., Functional Parts')).toBeInTheDocument();\n      });\n\n      const input = screen.getByPlaceholderText('e.g., Functional Parts');\n      await user.type(input, 'My New Folder');\n\n      const createButton = screen.getByRole('button', { name: 'Create' });\n      await user.click(createButton);\n\n      // Modal should close after creation\n      await waitFor(() => {\n        expect(screen.queryByText('Folder Name')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('empty state', () => {\n    it('shows empty state when no files', async () => {\n      server.use(\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([]);\n        })\n      );\n\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('No files yet')).toBeInTheDocument();\n        expect(screen.getByText('Upload Files')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('schedule print', () => {\n    it('shows schedule print button when one sliced file is selected', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n\n      // Select a sliced file (benchy.gcode.3mf) by clicking on its card\n      const fileCard = screen.getByText('Benchy').closest('div[class*=\"cursor-pointer\"]');\n      if (fileCard) {\n        await user.click(fileCard);\n      }\n\n      await waitFor(() => {\n        expect(screen.getByText(/Schedule/)).toBeInTheDocument();\n      });\n    });\n\n    it('hides schedule print button when multiple files are selected', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n\n      // Select all files\n      await user.click(screen.getByText('Select All'));\n\n      await waitFor(() => {\n        // Schedule button should not be present when multiple files are selected\n        expect(screen.queryByText(/Schedule/)).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('STL thumbnail generation', () => {\n    it('shows Generate Thumbnails button', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();\n      });\n    });\n\n    it('Generate Thumbnails button has correct title', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        const button = screen.getByTitle('Generate thumbnails for STL files missing them');\n        expect(button).toBeInTheDocument();\n      });\n    });\n\n    it('can click Generate Thumbnails button', async () => {\n      const user = userEvent.setup();\n\n      server.use(\n        http.post('/api/v1/library/generate-stl-thumbnails', () => {\n          return HttpResponse.json({\n            processed: 1,\n            succeeded: 1,\n            failed: 0,\n            results: [{ file_id: 2, success: true }],\n          });\n        })\n      );\n\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();\n      });\n\n      const button = screen.getByText('Generate Thumbnails');\n      await user.click(button);\n\n      // Button should work without error\n      await waitFor(() => {\n        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();\n      });\n    });\n\n    it('shows STL file without thumbnail in file list', async () => {\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        // bracket.stl has no thumbnail_path\n        expect(screen.getByText('bracket.stl')).toBeInTheDocument();\n        expect(screen.getAllByText('STL').length).toBeGreaterThan(0);\n      });\n    });\n  });\n\n  describe('upload modal (FileUploadModal)', () => {\n    it('opens upload modal when Upload button is clicked', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Upload'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload Files')).toBeInTheDocument();\n        expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();\n      });\n    });\n\n    it('closes upload modal when Cancel is clicked', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Upload'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload Files')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByRole('button', { name: 'Cancel' }));\n\n      await waitFor(() => {\n        expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();\n      });\n    });\n\n    it('shows 3MF extraction info when 3MF file is added', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Upload'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload Files')).toBeInTheDocument();\n      });\n\n      const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      expect(fileInput).toBeInTheDocument();\n\n      await user.upload(fileInput, threemfFile);\n\n      await waitFor(() => {\n        expect(screen.getByText('3MF files detected')).toBeInTheDocument();\n        expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();\n      });\n    });\n\n    it('shows STL thumbnail option when STL file is added', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Upload'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload Files')).toBeInTheDocument();\n      });\n\n      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      expect(fileInput).toBeInTheDocument();\n\n      await user.upload(fileInput, stlFile);\n\n      await waitFor(() => {\n        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();\n        expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();\n      });\n    });\n\n    it('shows ZIP options when ZIP file is added', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Upload'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload Files')).toBeInTheDocument();\n      });\n\n      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, zipFile);\n\n      await waitFor(() => {\n        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();\n        expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();\n      });\n    });\n\n    it('can add a file via the file input', async () => {\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Upload'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload Files')).toBeInTheDocument();\n      });\n\n      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      await waitFor(() => {\n        expect(screen.getByText('model.3mf')).toBeInTheDocument();\n        expect(screen.getByRole('button', { name: /Upload \\(1\\)/i })).toBeInTheDocument();\n      });\n    });\n\n    it('uploads file and refreshes file list', async () => {\n      server.use(\n        http.post('/api/v1/library/files', () => {\n          return HttpResponse.json({\n            id: 10,\n            filename: 'uploaded.3mf',\n            file_type: '3mf',\n            file_size: 1024,\n            thumbnail_path: null,\n            duplicate_of: null,\n            metadata: null,\n          });\n        })\n      );\n\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Upload'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Upload Files')).toBeInTheDocument();\n      });\n\n      const file = new File(['content'], 'uploaded.3mf', { type: 'application/octet-stream' });\n      const fileInput = document.querySelector('input[type=\"file\"]') as HTMLInputElement;\n      await user.upload(fileInput, file);\n\n      const uploadButton = screen.getByRole('button', { name: /Upload \\(1\\)/i });\n      await user.click(uploadButton);\n\n      // Modal should auto-close after upload completes\n      await waitFor(() => {\n        expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('authentication-based UI changes', () => {\n    it('hides \"Uploaded By\" column and user filter when auth is disabled', async () => {\n      // Mock auth disabled (default)\n      server.use(\n        http.get('*/api/v1/auth/status', () => {\n          return HttpResponse.json({\n            auth_enabled: false,\n            requires_setup: false,\n          });\n        }),\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([\n            {\n              id: 1,\n              filename: 'test.3mf',\n              file_path: '/library/test.3mf',\n              file_size: 1048576,\n              file_type: '3mf',\n              folder_id: null,\n              thumbnail_path: null,\n              print_name: 'Test File',\n              print_time_seconds: 3600,\n              print_count: 0,\n              duplicate_count: 0,\n              created_at: '2024-01-01T00:00:00Z',\n              created_by_username: 'testuser',\n            },\n          ]);\n        })\n      );\n\n      render(<FileManagerPage />);\n\n      // Switch to list view to see the column headers\n      await waitFor(() => {\n        expect(screen.getByText('Test File')).toBeInTheDocument();\n      });\n\n      const user = userEvent.setup();\n      const listViewButton = screen.getByRole('button', { name: /list/i });\n      await user.click(listViewButton);\n\n      // \"Uploaded By\" column header should not be present\n      await waitFor(() => {\n        expect(screen.queryByText('Uploaded By')).not.toBeInTheDocument();\n      });\n\n      // User filter dropdown should not be present\n      expect(screen.queryByPlaceholderText('Filter by user')).not.toBeInTheDocument();\n    });\n\n    it('shows \"Uploaded By\" column and user filter when auth is enabled', async () => {\n      // Mock auth enabled\n      server.use(\n        http.get('*/api/v1/auth/status', () => {\n          return HttpResponse.json({\n            auth_enabled: true,\n            requires_setup: false,\n          });\n        }),\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([\n            {\n              id: 1,\n              filename: 'test.3mf',\n              file_path: '/library/test.3mf',\n              file_size: 1048576,\n              file_type: '3mf',\n              folder_id: null,\n              thumbnail_path: null,\n              print_name: 'Test File',\n              print_time_seconds: 3600,\n              print_count: 0,\n              duplicate_count: 0,\n              created_at: '2024-01-01T00:00:00Z',\n              created_by_username: 'testuser',\n            },\n          ]);\n        }),\n        http.get('/api/v1/users/', () => {\n          return HttpResponse.json([\n            { id: 1, username: 'testuser' },\n            { id: 2, username: 'admin' },\n          ]);\n        })\n      );\n\n      render(<FileManagerPage />);\n\n      // Switch to list view to see the column headers\n      await waitFor(() => {\n        expect(screen.getByText('Test File')).toBeInTheDocument();\n      });\n\n      const user = userEvent.setup();\n      const listViewButton = screen.getByRole('button', { name: /list/i });\n      await user.click(listViewButton);\n\n      // \"Uploaded By\" column header should be present\n      await waitFor(() => {\n        expect(screen.getByText('Uploaded By')).toBeInTheDocument();\n      });\n\n      // User filter dropdown should be present\n      expect(screen.getByPlaceholderText('Filter by user')).toBeInTheDocument();\n\n      // Username should be displayed in the column\n      expect(screen.getByText('testuser')).toBeInTheDocument();\n    });\n  });\n\n  describe('folder tree collapse preference (#996)', () => {\n    // localStorage is globally mocked in setup.ts (returns undefined by default),\n    // so we program each test's getItem return value explicitly.\n    const getItemMock = localStorage.getItem as ReturnType<typeof vi.fn>;\n    const setItemMock = localStorage.setItem as ReturnType<typeof vi.fn>;\n\n    beforeEach(() => {\n      getItemMock.mockReset();\n      setItemMock.mockReset();\n    });\n\n    it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {\n      getItemMock.mockReturnValue(null);\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Functional Parts')).toBeInTheDocument();\n      });\n      expect(screen.getByText('Brackets')).toBeInTheDocument();\n    });\n\n    it('honors library-collapse-folders=true on load (nested folders hidden)', async () => {\n      getItemMock.mockImplementation((key: string) =>\n        key === 'library-collapse-folders' ? 'true' : null\n      );\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Functional Parts')).toBeInTheDocument();\n      });\n      expect(screen.queryByText('Brackets')).not.toBeInTheDocument();\n    });\n\n    it('collapses nested folders and persists preference when Collapse is clicked', async () => {\n      getItemMock.mockReturnValue(null);\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Brackets')).toBeInTheDocument();\n      });\n\n      // The Collapse button sits next to Wrap in the sidebar header.\n      // Its text content is \"Collapse\" (from fileManager.collapse).\n      await user.click(screen.getByRole('button', { name: 'Collapse' }));\n\n      await waitFor(() => {\n        expect(screen.queryByText('Brackets')).not.toBeInTheDocument();\n      });\n      expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'true');\n    });\n\n    it('re-expands nested folders and persists preference when Collapse is toggled off', async () => {\n      getItemMock.mockImplementation((key: string) =>\n        key === 'library-collapse-folders' ? 'true' : null\n      );\n      const user = userEvent.setup();\n      render(<FileManagerPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Functional Parts')).toBeInTheDocument();\n      });\n      expect(screen.queryByText('Brackets')).not.toBeInTheDocument();\n\n      await user.click(screen.getByRole('button', { name: 'Collapse' }));\n\n      await waitFor(() => {\n        expect(screen.getByText('Brackets')).toBeInTheDocument();\n      });\n      expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'false');\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/GroupEditPage.test.tsx",
    "content": "/**\n * Tests for the GroupEditPage component.\n *\n * Covers create mode, edit mode, permission search/filtering,\n * select all / clear all, and category-level toggles.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { GroupEditPage } from '../../pages/GroupEditPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockPermissions = {\n  categories: [\n    {\n      name: 'Printers',\n      permissions: [\n        { value: 'printers:read', label: 'Read Printers' },\n        { value: 'printers:control', label: 'Control Printers' },\n        { value: 'printers:clear_plate', label: 'Clear Plate' },\n      ],\n    },\n    {\n      name: 'Archives',\n      permissions: [\n        { value: 'archives:read', label: 'Read Archives' },\n        { value: 'archives:create', label: 'Create Archives' },\n      ],\n    },\n  ],\n  all_permissions: [\n    'printers:read',\n    'printers:control',\n    'printers:clear_plate',\n    'archives:read',\n    'archives:create',\n  ],\n};\n\nconst mockGroup = {\n  id: 2,\n  name: 'Operators',\n  description: 'Control printers and manage content',\n  permissions: ['printers:read', 'printers:control', 'printers:clear_plate'],\n  is_system: true,\n  user_count: 3,\n  users: [{ id: 1, username: 'admin', is_active: true }],\n  created_at: '2024-01-01T00:00:00Z',\n  updated_at: '2024-01-01T00:00:00Z',\n};\n\ndescribe('GroupEditPage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/groups/permissions', () => {\n        return HttpResponse.json(mockPermissions);\n      }),\n      http.get('/api/v1/groups/:id', () => {\n        return HttpResponse.json(mockGroup);\n      }),\n      http.post('/api/v1/groups/', async ({ request }) => {\n        const body = (await request.json()) as Record<string, unknown>;\n        return HttpResponse.json({\n          id: 10,\n          ...body,\n          is_system: false,\n          user_count: 0,\n          created_at: '2024-01-01T00:00:00Z',\n          updated_at: '2024-01-01T00:00:00Z',\n        });\n      }),\n      http.patch('/api/v1/groups/:id', async ({ request }) => {\n        const body = (await request.json()) as Record<string, unknown>;\n        return HttpResponse.json({\n          ...mockGroup,\n          ...body,\n        });\n      })\n    );\n  });\n\n  describe('create mode', () => {\n    it('renders create title when no id param', async () => {\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Create Group')).toBeInTheDocument();\n      });\n    });\n\n    it('shows permission categories', async () => {\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Printers')).toBeInTheDocument();\n      });\n      expect(screen.getByText('Archives')).toBeInTheDocument();\n    });\n\n    it('shows individual permissions', async () => {\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Read Printers')).toBeInTheDocument();\n      });\n      expect(screen.getByText('Control Printers')).toBeInTheDocument();\n      expect(screen.getByText('Clear Plate')).toBeInTheDocument();\n      expect(screen.getByText('Read Archives')).toBeInTheDocument();\n      expect(screen.getByText('Create Archives')).toBeInTheDocument();\n    });\n\n    it('shows 0 selected initially', async () => {\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/0 selected/)).toBeInTheDocument();\n      });\n    });\n\n    it('shows save and cancel buttons', async () => {\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Save')).toBeInTheDocument();\n      });\n      expect(screen.getByText('Cancel')).toBeInTheDocument();\n    });\n  });\n\n  describe('permission interactions', () => {\n    it('toggles individual permission on click', async () => {\n      const user = userEvent.setup();\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Read Printers')).toBeInTheDocument();\n      });\n\n      const checkbox = screen.getByText('Read Printers').closest('label')!.querySelector('input')!;\n      await user.click(checkbox);\n\n      await waitFor(() => {\n        expect(screen.getByText(/1 selected/)).toBeInTheDocument();\n      });\n    });\n\n    it('select all selects all permissions', async () => {\n      const user = userEvent.setup();\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select All'));\n\n      await waitFor(() => {\n        expect(screen.getByText(/5 selected/)).toBeInTheDocument();\n      });\n    });\n\n    it('clear all deselects all permissions', async () => {\n      const user = userEvent.setup();\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Select All'));\n      await waitFor(() => {\n        expect(screen.getByText(/5 selected/)).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Clear All'));\n      await waitFor(() => {\n        expect(screen.getByText(/0 selected/)).toBeInTheDocument();\n      });\n    });\n\n    it('filters permissions by search', async () => {\n      const user = userEvent.setup();\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Read Printers')).toBeInTheDocument();\n      });\n\n      const searchInput = screen.getByPlaceholderText('Search permissions...');\n      await user.type(searchInput, 'Clear');\n\n      await waitFor(() => {\n        expect(screen.getByText('Clear Plate')).toBeInTheDocument();\n        expect(screen.queryByText('Read Printers')).not.toBeInTheDocument();\n        expect(screen.queryByText('Archives')).not.toBeInTheDocument();\n      });\n    });\n\n    it('shows no results message for empty search', async () => {\n      const user = userEvent.setup();\n      render(<GroupEditPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Read Printers')).toBeInTheDocument();\n      });\n\n      const searchInput = screen.getByPlaceholderText('Search permissions...');\n      await user.type(searchInput, 'zzzznonexistent');\n\n      await waitFor(() => {\n        expect(screen.getByText('No permissions match your search')).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/InventoryPageGrouping.test.ts",
    "content": "/**\n * Tests for the spool grouping logic used in InventoryPage.\n *\n * The grouping is a pure client-side computation:\n * - Spools with identical material+subtype+brand+color_name+rgba+label_weight are grouped\n * - Only unused (weight_used === 0) and unassigned spools are eligible for grouping\n * - Used or assigned spools always appear individually\n * - Groups with only 1 member remain as singles\n */\n\nimport { describe, it, expect } from 'vitest';\nimport type { InventorySpool, SpoolAssignment } from '../../api/client';\n\n// Replicate the grouping key function from InventoryPage (not exported)\nfunction spoolGroupKey(s: InventorySpool): string {\n  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;\n}\n\ntype DisplayItem =\n  | { type: 'single'; spool: InventorySpool }\n  | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };\n\n// Replicate the grouping logic from InventoryPage\nfunction computeDisplayItems(\n  sortedSpools: InventorySpool[],\n  assignmentMap: Record<number, SpoolAssignment>,\n): DisplayItem[] {\n  const groups = new Map<string, InventorySpool[]>();\n\n  for (const spool of sortedSpools) {\n    if (spool.weight_used > 0 || assignmentMap[spool.id]) {\n      // Will be added as singles in the walk below\n    } else {\n      const key = spoolGroupKey(spool);\n      const arr = groups.get(key);\n      if (arr) arr.push(spool);\n      else groups.set(key, [spool]);\n    }\n  }\n\n  const items: DisplayItem[] = [];\n  const processedKeys = new Set<string>();\n\n  for (const spool of sortedSpools) {\n    if (spool.weight_used > 0 || assignmentMap[spool.id]) {\n      items.push({ type: 'single', spool });\n      continue;\n    }\n    const key = spoolGroupKey(spool);\n    if (processedKeys.has(key)) continue;\n    processedKeys.add(key);\n    const members = groups.get(key)!;\n    if (members.length === 1) {\n      items.push({ type: 'single', spool: members[0] });\n    } else {\n      items.push({ type: 'group', key, spools: members, representative: members[0] });\n    }\n  }\n  return items;\n}\n\nfunction makeSpool(overrides: Partial<InventorySpool> & { id: number }): InventorySpool {\n  return {\n    material: 'PLA',\n    subtype: 'Basic',\n    brand: 'Polymaker',\n    color_name: 'Red',\n    rgba: 'FF0000FF',\n    label_weight: 1000,\n    core_weight: 250,\n    core_weight_catalog_id: null,\n    weight_used: 0,\n    slicer_filament: null,\n    slicer_filament_name: null,\n    nozzle_temp_min: null,\n    nozzle_temp_max: null,\n    note: null,\n    added_full: null,\n    last_used: null,\n    encode_time: null,\n    tag_uid: null,\n    tray_uuid: null,\n    data_origin: null,\n    tag_type: null,\n    archived_at: null,\n    created_at: '2025-01-01T00:00:00Z',\n    updated_at: '2025-01-01T00:00:00Z',\n    k_profiles: [],\n    cost_per_kg: null,\n    ...overrides,\n  };\n}\n\ndescribe('spoolGroupKey', () => {\n  it('generates same key for identical spools', () => {\n    const a = makeSpool({ id: 1 });\n    const b = makeSpool({ id: 2 });\n    expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));\n  });\n\n  it('generates different key when material differs', () => {\n    const a = makeSpool({ id: 1, material: 'PLA' });\n    const b = makeSpool({ id: 2, material: 'PETG' });\n    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));\n  });\n\n  it('generates different key when subtype differs', () => {\n    const a = makeSpool({ id: 1, subtype: 'Basic' });\n    const b = makeSpool({ id: 2, subtype: 'Matte' });\n    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));\n  });\n\n  it('generates different key when brand differs', () => {\n    const a = makeSpool({ id: 1, brand: 'Polymaker' });\n    const b = makeSpool({ id: 2, brand: 'Bambu Lab' });\n    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));\n  });\n\n  it('generates different key when color_name differs', () => {\n    const a = makeSpool({ id: 1, color_name: 'Red' });\n    const b = makeSpool({ id: 2, color_name: 'Blue' });\n    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));\n  });\n\n  it('generates different key when label_weight differs', () => {\n    const a = makeSpool({ id: 1, label_weight: 1000 });\n    const b = makeSpool({ id: 2, label_weight: 500 });\n    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));\n  });\n\n  it('treats null and empty string subtype the same', () => {\n    const a = makeSpool({ id: 1, subtype: null as unknown as string });\n    const b = makeSpool({ id: 2, subtype: '' });\n    expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));\n  });\n});\n\ndescribe('computeDisplayItems', () => {\n  it('groups identical unused unassigned spools', () => {\n    const spools = [\n      makeSpool({ id: 1 }),\n      makeSpool({ id: 2 }),\n      makeSpool({ id: 3 }),\n    ];\n    const items = computeDisplayItems(spools, {});\n    expect(items).toHaveLength(1);\n    expect(items[0].type).toBe('group');\n    if (items[0].type === 'group') {\n      expect(items[0].spools).toHaveLength(3);\n      expect(items[0].representative.id).toBe(1);\n    }\n  });\n\n  it('does not group spools with different properties', () => {\n    const spools = [\n      makeSpool({ id: 1, material: 'PLA' }),\n      makeSpool({ id: 2, material: 'PETG' }),\n      makeSpool({ id: 3, material: 'ABS' }),\n    ];\n    const items = computeDisplayItems(spools, {});\n    expect(items).toHaveLength(3);\n    expect(items.every((i) => i.type === 'single')).toBe(true);\n  });\n\n  it('excludes used spools from groups', () => {\n    const spools = [\n      makeSpool({ id: 1, weight_used: 0 }),\n      makeSpool({ id: 2, weight_used: 100 }), // used\n      makeSpool({ id: 3, weight_used: 0 }),\n    ];\n    const items = computeDisplayItems(spools, {});\n    // 1 group (id:1, id:3) + 1 single (id:2)\n    expect(items).toHaveLength(2);\n    const group = items.find((i) => i.type === 'group');\n    const single = items.find((i) => i.type === 'single');\n    expect(group).toBeDefined();\n    expect(single).toBeDefined();\n    if (group?.type === 'group') {\n      expect(group.spools).toHaveLength(2);\n      expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);\n    }\n    if (single?.type === 'single') {\n      expect(single.spool.id).toBe(2);\n    }\n  });\n\n  it('excludes assigned spools from groups', () => {\n    const spools = [\n      makeSpool({ id: 1 }),\n      makeSpool({ id: 2 }), // assigned\n      makeSpool({ id: 3 }),\n    ];\n    const assignmentMap: Record<number, SpoolAssignment> = {\n      2: {\n        spool_id: 2,\n        printer_id: 1,\n        printer_name: 'P1S',\n        ams_id: 0,\n        tray_id: 0,\n        configured: true,\n        fingerprint_color: null,\n        fingerprint_type: null,\n      },\n    };\n    const items = computeDisplayItems(spools, assignmentMap);\n    // 1 group (id:1, id:3) + 1 single (id:2)\n    expect(items).toHaveLength(2);\n    const group = items.find((i) => i.type === 'group');\n    expect(group?.type).toBe('group');\n    if (group?.type === 'group') {\n      expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);\n    }\n  });\n\n  it('does not group a single spool', () => {\n    const spools = [makeSpool({ id: 1 })];\n    const items = computeDisplayItems(spools, {});\n    expect(items).toHaveLength(1);\n    expect(items[0].type).toBe('single');\n  });\n\n  it('preserves order — group appears at first member position', () => {\n    const spools = [\n      makeSpool({ id: 1, material: 'PETG' }), // unique\n      makeSpool({ id: 2, material: 'PLA' }),   // group member\n      makeSpool({ id: 3, material: 'PLA' }),   // group member\n      makeSpool({ id: 4, material: 'ABS' }),   // unique\n    ];\n    const items = computeDisplayItems(spools, {});\n    expect(items).toHaveLength(3);\n    expect(items[0].type).toBe('single'); // PETG\n    expect(items[1].type).toBe('group');  // PLA group at position of id:2\n    expect(items[2].type).toBe('single'); // ABS\n    if (items[1].type === 'group') {\n      expect(items[1].spools.map((s) => s.id)).toEqual([2, 3]);\n    }\n  });\n\n  it('handles mix of groupable and non-groupable spools', () => {\n    const spools = [\n      makeSpool({ id: 1, material: 'PLA' }),                    // groupable\n      makeSpool({ id: 2, material: 'PLA', weight_used: 50 }),   // used → single\n      makeSpool({ id: 3, material: 'PLA' }),                    // groupable\n      makeSpool({ id: 4, material: 'PETG' }),                   // different → single\n    ];\n    const items = computeDisplayItems(spools, {});\n    // PLA group (id:1,3) + PLA used single (id:2) + PETG single (id:4)\n    expect(items).toHaveLength(3);\n  });\n\n  it('returns all singles when no spools can be grouped', () => {\n    const spools = [\n      makeSpool({ id: 1, material: 'PLA', weight_used: 100 }),\n      makeSpool({ id: 2, material: 'PETG', weight_used: 200 }),\n    ];\n    const items = computeDisplayItems(spools, {});\n    expect(items).toHaveLength(2);\n    expect(items.every((i) => i.type === 'single')).toBe(true);\n  });\n\n  it('returns empty array for empty input', () => {\n    const items = computeDisplayItems([], {});\n    expect(items).toHaveLength(0);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx",
    "content": "/**\n * Tests for low stock threshold functionality in InventoryPage.\n *\n * Tests that the low stock threshold:\n * - Is loaded from backend settings API\n * - Can be updated via the UI\n * - Persists changes to the backend\n * - Does not use localStorage\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport InventoryPageRouter from '../../pages/InventoryPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockSettings = {\n  auto_archive: true,\n  save_thumbnails: true,\n  capture_finish_photo: true,\n  default_filament_cost: 25.0,\n  currency: 'USD',\n  energy_cost_per_kwh: 0.15,\n  energy_tracking_mode: 'total',\n  spoolman_enabled: false,\n  spoolman_url: '',\n  spoolman_sync_mode: 'auto',\n  spoolman_disable_weight_sync: false,\n  spoolman_report_partial_usage: true,\n  check_updates: true,\n  check_printer_firmware: true,\n  include_beta_updates: false,\n  language: 'en',\n  notification_language: 'en',\n  bed_cooled_threshold: 35,\n  ams_humidity_good: 40,\n  ams_humidity_fair: 60,\n  ams_temp_good: 28,\n  ams_temp_fair: 35,\n  ams_history_retention_days: 30,\n  per_printer_mapping_expanded: false,\n  date_format: 'system',\n  time_format: 'system',\n  default_printer_id: null,\n  virtual_printer_enabled: false,\n  virtual_printer_access_code: '',\n  virtual_printer_mode: 'immediate',\n  dark_style: 'classic',\n  dark_background: 'neutral',\n  dark_accent: 'green',\n  light_style: 'classic',\n  light_background: 'neutral',\n  light_accent: 'green',\n  ftp_retry_enabled: true,\n  ftp_retry_count: 3,\n  ftp_retry_delay: 2,\n  ftp_timeout: 30,\n  mqtt_enabled: false,\n  mqtt_broker: '',\n  mqtt_port: 1883,\n  mqtt_username: '',\n  mqtt_password: '',\n  mqtt_topic_prefix: 'bambuddy',\n  mqtt_use_tls: false,\n  external_url: '',\n  ha_enabled: false,\n  ha_url: '',\n  ha_token: '',\n  ha_url_from_env: false,\n  ha_token_from_env: false,\n  ha_env_managed: false,\n  library_archive_mode: 'ask',\n  library_disk_warning_gb: 5.0,\n  camera_view_mode: 'window',\n  preferred_slicer: 'bambu_studio',\n  prometheus_enabled: false,\n  prometheus_token: '',\n  low_stock_threshold: 20.0,\n};\n\nconst mockSpools = [\n  {\n    id: 1,\n    material: 'PLA',\n    subtype: null,\n    brand: 'Polymaker',\n    color_name: 'Red',\n    rgba: 'FF0000FF',\n    label_weight: 1000,\n    core_weight: 250,\n    weight_used: 900, // 10% remaining - low stock\n    slicer_filament: null,\n    slicer_filament_name: null,\n    nozzle_temp_min: null,\n    nozzle_temp_max: null,\n    note: null,\n    added_full: null,\n    last_used: null,\n    encode_time: null,\n    tag_uid: null,\n    tray_uuid: null,\n    data_origin: null,\n    tag_type: null,\n    archived_at: null,\n    created_at: '2025-01-01T00:00:00Z',\n    updated_at: '2025-01-01T00:00:00Z',\n    k_profiles: [],\n    cost_per_kg: null,\n    last_scale_weight: null,\n    last_weighed_at: null,\n  },\n  {\n    id: 2,\n    material: 'PETG',\n    subtype: null,\n    brand: 'eSun',\n    color_name: 'Blue',\n    rgba: '0000FFFF',\n    label_weight: 1000,\n    core_weight: 250,\n    weight_used: 200, // 80% remaining - not low stock\n    slicer_filament: null,\n    slicer_filament_name: null,\n    nozzle_temp_min: null,\n    nozzle_temp_max: null,\n    note: null,\n    added_full: null,\n    last_used: null,\n    encode_time: null,\n    tag_uid: null,\n    tray_uuid: null,\n    data_origin: null,\n    tag_type: null,\n    archived_at: null,\n    created_at: '2025-01-02T00:00:00Z',\n    updated_at: '2025-01-02T00:00:00Z',\n    k_profiles: [],\n    cost_per_kg: null,\n    last_scale_weight: null,\n    last_weighed_at: null,\n  },\n  {\n    id: 3,\n    material: 'ABS',\n    subtype: null,\n    brand: 'Hatchbox',\n    color_name: 'Black',\n    rgba: '000000FF',\n    label_weight: 1000,\n    core_weight: 250,\n    weight_used: 850, // 15% remaining - low stock\n    slicer_filament: null,\n    slicer_filament_name: null,\n    nozzle_temp_min: null,\n    nozzle_temp_max: null,\n    note: null,\n    added_full: null,\n    last_used: null,\n    encode_time: null,\n    tag_uid: null,\n    tray_uuid: null,\n    data_origin: null,\n    tag_type: null,\n    archived_at: null,\n    created_at: '2025-01-03T00:00:00Z',\n    updated_at: '2025-01-03T00:00:00Z',\n    k_profiles: [],\n    cost_per_kg: null,\n    last_scale_weight: null,\n    last_weighed_at: null,\n  },\n];\n\ndescribe('InventoryPage - Low Stock Threshold', () => {\n  beforeEach(() => {\n    // Clear localStorage to ensure we're not relying on it\n    localStorage.clear();\n\n    server.use(\n      http.get('/api/v1/settings/', () => {\n        return HttpResponse.json(mockSettings);\n      }),\n      http.put('/api/v1/settings/', async ({ request }) => {\n        const body = (await request.json()) as Partial<typeof mockSettings>;\n        return HttpResponse.json({ ...mockSettings, ...body });\n      }),\n      http.get('/api/v1/inventory/spools', () => {\n        return HttpResponse.json(mockSpools);\n      }),\n      http.get('/api/v1/inventory/assignments', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/spoolman/settings', () => {\n        return HttpResponse.json({ spoolman_enabled: 'false' });\n      })\n    );\n  });\n\n  describe('default threshold from backend', () => {\n    it('loads the default threshold of 20% from backend settings', async () => {\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        // Find the low stock stat showing the threshold\n        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();\n      });\n    });\n\n    it('calculates low stock count based on default threshold', async () => {\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        // With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools)\n        const lowStockSection = screen.getByText(/low stock/i).closest('div');\n        expect(lowStockSection).toBeInTheDocument();\n      });\n    });\n\n    it('does not use localStorage for threshold', async () => {\n      // Set a value in localStorage that should be ignored\n      localStorage.setItem('bambuddy-low-stock-threshold', '50');\n\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        // Should show backend value (20%), not localStorage value (50%)\n        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('updating threshold via UI', () => {\n    it('shows edit button for threshold', async () => {\n      const user = userEvent.setup();\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();\n      });\n\n      // Find the edit button within the low stock threshold section\n      const thresholdText = screen.getByText(/< 20%/i);\n      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;\n      expect(editButton).toBeInTheDocument();\n\n      await user.click(editButton);\n\n      // Input field should appear with default threshold value\n      await waitFor(() => {\n        const input = screen.getByDisplayValue('20');\n        expect(input).toBeInTheDocument();\n      });\n    });\n\n    it('updates threshold and persists to backend', async () => {\n      const user = userEvent.setup();\n      let updatedSettings: Partial<typeof mockSettings> | null = null;\n\n      server.use(\n        http.put('/api/v1/settings/', async ({ request }) => {\n          const body = (await request.json()) as Partial<typeof mockSettings>;\n          updatedSettings = body;\n          return HttpResponse.json({ ...mockSettings, ...body });\n        })\n      );\n\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();\n      });\n\n      // Click edit button within the low stock threshold section\n      const thresholdText = screen.getByText(/< 20%/i);\n      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;\n      await user.click(editButton);\n\n      // Enter new value\n      const input = screen.getByDisplayValue('20');\n      await user.clear(input);\n      await user.type(input, '15.5');\n\n      // Submit form\n      const saveButton = screen.getByRole('button', { name: /save/i });\n      await user.click(saveButton);\n\n      // Verify API was called with correct value\n      await waitFor(() => {\n        expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 });\n      });\n    });\n\n    it('validates threshold input range', async () => {\n      const user = userEvent.setup();\n      let updatedSettings: Partial<typeof mockSettings> | null = null;\n\n      server.use(\n        http.put('/api/v1/settings/', async ({ request }) => {\n          const body = (await request.json()) as Partial<typeof mockSettings>;\n          updatedSettings = body;\n          return HttpResponse.json({ ...mockSettings, ...body });\n        })\n      );\n\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();\n      });\n\n      // Click edit button within the low stock threshold section\n      const thresholdText = screen.getByText(/< 20%/i);\n      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;\n      await user.click(editButton);\n\n      // Try invalid values\n      const input = screen.getByDisplayValue('20');\n\n      // Too low (0 is below the 0.1 minimum)\n      await user.clear(input);\n      await user.type(input, '0');\n\n      const saveButton = screen.getByRole('button', { name: /save/i });\n      await user.click(saveButton);\n\n      // Should show error and NOT call the PUT endpoint\n      await waitFor(() => {\n        expect(updatedSettings).toBeNull();\n      });\n    });\n\n    it('allows canceling threshold edit', async () => {\n      const user = userEvent.setup();\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();\n      });\n\n      // Click edit button within the low stock threshold section\n      const thresholdText = screen.getByText(/< 20%/i);\n      const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;\n      await user.click(editButton);\n\n      // Change value\n      const input = screen.getByDisplayValue('20');\n      await user.clear(input);\n      await user.type(input, '30');\n\n      // Cancel\n      const cancelButton = screen.getByRole('button', { name: /cancel/i });\n      await user.click(cancelButton);\n\n      // Should revert to original display\n      await waitFor(() => {\n        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('custom threshold from backend', () => {\n    it('loads custom threshold value from backend', async () => {\n      server.use(\n        http.get('/api/v1/settings/', () => {\n          return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 });\n        })\n      );\n\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/< 25%/i)).toBeInTheDocument();\n      });\n    });\n\n    it('applies custom threshold to low stock filtering', async () => {\n      // With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%)\n      server.use(\n        http.get('/api/v1/settings/', () => {\n          return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 });\n        })\n      );\n\n      render(<InventoryPageRouter />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/< 30%/i)).toBeInTheDocument();\n      });\n\n      // The low stock count should reflect the new threshold\n      // Implementation would show appropriate count based on 30% threshold\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/LoginPage.test.tsx",
    "content": "/**\n * Tests for the LoginPage component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { LoginPage } from '../../pages/LoginPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\ndescribe('LoginPage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/auth/status', () => {\n        return HttpResponse.json({ auth_enabled: true, requires_setup: false });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the login form', async () => {\n      render(<LoginPage />);\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();\n      });\n\n      expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();\n      expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();\n      expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();\n    });\n\n    it('renders the sign in description', async () => {\n      render(<LoginPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Sign in to your account/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('form validation', () => {\n    it('shows error when submitting empty form', async () => {\n      const user = userEvent.setup();\n      render(<LoginPage />);\n\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByRole('button', { name: /Sign in/i }));\n\n      // The form has required fields, so HTML5 validation should prevent submission\n      // or the component shows a toast\n    });\n\n    it('allows entering username and password', async () => {\n      const user = userEvent.setup();\n      render(<LoginPage />);\n\n      await waitFor(() => {\n        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();\n      });\n\n      await user.type(screen.getByLabelText(/Username/i), 'testuser');\n      await user.type(screen.getByLabelText(/Password/i), 'testpassword');\n\n      expect(screen.getByLabelText(/Username/i)).toHaveValue('testuser');\n      expect(screen.getByLabelText(/Password/i)).toHaveValue('testpassword');\n    });\n  });\n\n  describe('login flow', () => {\n    it('submits login request with credentials', async () => {\n      const user = userEvent.setup();\n      let loginCalled = false;\n\n      server.use(\n        http.post('/api/v1/auth/login', async ({ request }) => {\n          loginCalled = true;\n          const body = await request.json() as { username: string; password: string };\n          if (body.username === 'validuser' && body.password === 'validpass') {\n            return HttpResponse.json({\n              access_token: 'test-token',\n              token_type: 'bearer',\n              user: {\n                id: 1,\n                username: 'validuser',\n                role: 'admin',\n                is_active: true,\n                created_at: new Date().toISOString(),\n              },\n            });\n          }\n          return HttpResponse.json(\n            { detail: 'Incorrect username or password' },\n            { status: 401 }\n          );\n        })\n      );\n\n      render(<LoginPage />);\n\n      await waitFor(() => {\n        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();\n      });\n\n      await user.type(screen.getByLabelText(/Username/i), 'validuser');\n      await user.type(screen.getByLabelText(/Password/i), 'validpass');\n      await user.click(screen.getByRole('button', { name: /Sign in/i }));\n\n      // Verify the login endpoint was called\n      await waitFor(() => {\n        expect(loginCalled).toBe(true);\n      });\n    });\n\n    it('shows loading state during login', async () => {\n      const user = userEvent.setup();\n      let resolveLogin: () => void;\n      const loginPromise = new Promise<void>(resolve => { resolveLogin = resolve; });\n\n      // Slow login endpoint that we control\n      server.use(\n        http.post('/api/v1/auth/login', async () => {\n          await loginPromise;\n          return HttpResponse.json({\n            access_token: 'test-token',\n            token_type: 'bearer',\n            user: {\n              id: 1,\n              username: 'testuser',\n              role: 'admin',\n              is_active: true,\n              created_at: new Date().toISOString(),\n            },\n          });\n        })\n      );\n\n      render(<LoginPage />);\n\n      await waitFor(() => {\n        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();\n      });\n\n      await user.type(screen.getByLabelText(/Username/i), 'testuser');\n      await user.type(screen.getByLabelText(/Password/i), 'testpass');\n      await user.click(screen.getByRole('button', { name: /Sign in/i }));\n\n      // Check for loading state - button text should change to \"Logging in...\"\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /Logging in/i })).toBeInTheDocument();\n      });\n\n      // Release the login request\n      resolveLogin!();\n    });\n  });\n\n  describe('2FA flow', () => {\n    // Helper: login as a 2FA user and get to the 2FA step\n    async function loginWith2FA(twoFAMethods = ['totp', 'backup']) {\n      const user = userEvent.setup();\n\n      server.use(\n        http.post('/api/v1/auth/login', () =>\n          HttpResponse.json({\n            requires_2fa: true,\n            pre_auth_token: 'test-pre-auth-token',\n            two_fa_methods: twoFAMethods,\n          })\n        )\n      );\n\n      render(<LoginPage />);\n\n      await waitFor(() => {\n        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();\n      });\n\n      await user.type(screen.getByLabelText(/Username/i), 'mfa-user');\n      await user.type(screen.getByLabelText(/Password/i), 'mfa-password');\n      await user.click(screen.getByRole('button', { name: /Sign in/i }));\n\n      return user;\n    }\n\n    it('shows 2FA step when login returns requires_2fa', async () => {\n      await loginWith2FA();\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();\n      });\n    });\n\n    it('shows code input on the 2FA step', async () => {\n      await loginWith2FA();\n\n      await waitFor(() => {\n        // The code input field is rendered\n        expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();\n      });\n    });\n\n    it('submits 2FA verify request with code and pre_auth_token', async () => {\n      let verifyCalled = false;\n      let verifyBody: unknown;\n\n      server.use(\n        http.post('/api/v1/auth/2fa/verify', async ({ request }) => {\n          verifyCalled = true;\n          verifyBody = await request.json();\n          return HttpResponse.json({\n            access_token: 'final-jwt',\n            token_type: 'bearer',\n            user: {\n              id: 1,\n              username: 'mfa-user',\n              role: 'admin',\n              is_active: true,\n              created_at: new Date().toISOString(),\n            },\n          });\n        })\n      );\n\n      const user = await loginWith2FA();\n\n      await waitFor(() => {\n        expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();\n      });\n\n      await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');\n      await user.click(screen.getByRole('button', { name: /Verify/i }));\n\n      await waitFor(() => {\n        expect(verifyCalled).toBe(true);\n      });\n\n      expect(verifyBody).toMatchObject({\n        pre_auth_token: 'test-pre-auth-token',\n        code: '123456',\n        method: 'totp',\n      });\n    });\n\n    it('returns to credentials step when back button is clicked', async () => {\n      await loginWith2FA();\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();\n      });\n\n      const user = userEvent.setup();\n      const backButton = screen.getByRole('button', { name: /Back to login/i });\n      await user.click(backButton);\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();\n      });\n    });\n\n    it('shows method selector when multiple 2FA methods are available', async () => {\n      await loginWith2FA(['totp', 'email', 'backup']);\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();\n      });\n\n      // Multiple method buttons should be visible\n      expect(screen.getByRole('button', { name: /Authenticator/i })).toBeInTheDocument();\n      expect(screen.getByRole('button', { name: /Email/i })).toBeInTheDocument();\n      expect(screen.getByRole('button', { name: /Backup/i })).toBeInTheDocument();\n    });\n\n    it('does not show method selector with only one 2FA method', async () => {\n      await loginWith2FA(['totp']);\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();\n      });\n\n      // Single-method: no method selector buttons\n      expect(screen.queryByRole('button', { name: /Authenticator/i })).not.toBeInTheDocument();\n    });\n\n    it('shows send code button when email method is selected', async () => {\n      const _user = await loginWith2FA(['email']);\n\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();\n      });\n\n      // For email method the \"Send code\" button should be shown\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /Send Code/i })).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/MaintenancePage.test.tsx",
    "content": "/**\n * Tests for the MaintenancePage component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { MaintenancePage } from '../../pages/MaintenancePage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockPrinters = [\n  {\n    id: 1,\n    name: 'X1 Carbon',\n    model: 'X1C',\n    serial_number: '00M09A350100001',\n  },\n];\n\nconst mockMaintenanceTypes = [\n  {\n    id: 1,\n    name: 'Clean Nozzle',\n    description: 'Clean the printer nozzle',\n    default_interval_hours: 50,\n    applies_to_models: ['X1C', 'P1S'],\n  },\n  {\n    id: 2,\n    name: 'Lubricate Rods',\n    description: 'Lubricate linear rods',\n    default_interval_hours: 200,\n    applies_to_models: ['X1C', 'P1S'],\n  },\n];\n\nconst mockMaintenanceTasks = [\n  {\n    id: 1,\n    printer_id: 1,\n    maintenance_type_id: 1,\n    maintenance_type_name: 'Clean Nozzle',\n    interval_hours: 50,\n    last_completed_at: '2024-01-01T00:00:00Z',\n    next_due_at: '2024-01-03T00:00:00Z',\n    hours_until_due: 10,\n    is_due: false,\n    notes: null,\n  },\n  {\n    id: 2,\n    printer_id: 1,\n    maintenance_type_id: 2,\n    maintenance_type_name: 'Lubricate Rods',\n    interval_hours: 200,\n    last_completed_at: '2023-12-01T00:00:00Z',\n    next_due_at: '2023-12-15T00:00:00Z',\n    hours_until_due: -100,\n    is_due: true,\n    notes: 'Use PTFE lubricant',\n  },\n];\n\ndescribe('MaintenancePage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json(mockPrinters);\n      }),\n      http.get('/api/v1/maintenance/types', () => {\n        return HttpResponse.json(mockMaintenanceTypes);\n      }),\n      http.get('/api/v1/maintenance/', () => {\n        return HttpResponse.json(mockMaintenanceTasks);\n      }),\n      http.get('/api/v1/maintenance/overview', () => {\n        // Overview is an array of printer summaries\n        return HttpResponse.json([\n          {\n            printer_id: 1,\n            printer_name: 'X1 Carbon',\n            due_count: 1,\n            warning_count: 0,\n            total_print_hours: 100,\n            maintenance_items: [\n              {\n                id: 1,\n                maintenance_type_id: 1,\n                maintenance_type_name: 'Clean Nozzle',\n                interval_hours: 50,\n                hours_since_last: 45,\n                hours_until_due: 5,\n                is_due: false,\n                is_warning: false,\n              },\n              {\n                id: 2,\n                maintenance_type_id: 2,\n                maintenance_type_name: 'Lubricate Rods',\n                interval_hours: 200,\n                hours_since_last: 250,\n                hours_until_due: -50,\n                is_due: true,\n                is_warning: false,\n              },\n            ],\n          },\n        ]);\n      }),\n      http.post('/api/v1/maintenance/', async ({ request }) => {\n        const body = await request.json() as { name: string };\n        return HttpResponse.json({ id: 3, ...body });\n      }),\n      http.post('/api/v1/maintenance/:id/complete', () => {\n        return HttpResponse.json({ success: true });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page title', async () => {\n      render(<MaintenancePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Maintenance')).toBeInTheDocument();\n      });\n    });\n\n    it('renders maintenance page content', async () => {\n      render(<MaintenancePage />);\n\n      await waitFor(() => {\n        // Page should render with printer tabs or tasks\n        expect(screen.getByText('Maintenance')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('printer tabs', () => {\n    it('shows printer tabs when printers exist', async () => {\n      render(<MaintenancePage />);\n\n      await waitFor(() => {\n        // Should show printer name in tabs\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/NotificationsPage.test.tsx",
    "content": "/**\n * Tests for the NotificationsPage component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { NotificationsPage } from '../../pages/NotificationsPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockPreferences = {\n  notify_print_start: true,\n  notify_print_complete: true,\n  notify_print_failed: true,\n  notify_print_stopped: true,\n};\n\nconst mockAdvancedAuthEnabled = {\n  advanced_auth_enabled: true,\n  smtp_configured: true,\n};\n\nconst mockSettingsWithNotifications = {\n  auto_archive: true,\n  user_notifications_enabled: true,\n};\n\ndescribe('NotificationsPage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/auth/advanced-auth/status', () => {\n        return HttpResponse.json(mockAdvancedAuthEnabled);\n      }),\n      http.get('/api/v1/user-notifications/preferences', () => {\n        return HttpResponse.json(mockPreferences);\n      }),\n      http.put('/api/v1/user-notifications/preferences', async ({ request }) => {\n        const body = await request.json();\n        return HttpResponse.json(body);\n      }),\n      http.get('/api/v1/settings/', () => {\n        return HttpResponse.json(mockSettingsWithNotifications);\n      }),\n      http.get('*/api/v1/auth/status', () => {\n        return HttpResponse.json({ auth_enabled: false, requires_setup: false });\n      }),\n      http.get('/api/v1/auth/me', () => {\n        return HttpResponse.json({\n          id: 1,\n          username: 'testuser',\n          email: 'test@example.com',\n          role: 'admin',\n          is_active: true,\n          is_admin: true,\n          groups: [{ id: 1, name: 'Administrators' }],\n          permissions: [],\n          created_at: '2024-01-01T00:00:00Z',\n        });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page heading', async () => {\n      render(<NotificationsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Notifications')).toBeInTheDocument();\n      });\n    });\n\n    it('renders all four notification toggle options', async () => {\n      render(<NotificationsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Print Job Starts')).toBeInTheDocument();\n        expect(screen.getByText('Print Job Finishes')).toBeInTheDocument();\n        expect(screen.getByText('Print Errors')).toBeInTheDocument();\n        expect(screen.getByText('Print Job Stops')).toBeInTheDocument();\n      });\n    });\n\n    it('renders four toggle switches', async () => {\n      render(<NotificationsPage />);\n\n      await waitFor(() => {\n        const switches = screen.getAllByRole('switch');\n        expect(switches).toHaveLength(4);\n      });\n    });\n\n    it('renders save button', async () => {\n      render(<NotificationsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();\n      });\n    });\n\n    it('shows loading spinner initially', () => {\n      render(<NotificationsPage />);\n      expect(document.querySelector('.animate-spin')).toBeInTheDocument();\n    });\n  });\n\n  describe('toggle interaction', () => {\n    it('toggles switch state when clicked', async () => {\n      const user = userEvent.setup();\n      render(<NotificationsPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByRole('switch')).toHaveLength(4);\n      });\n\n      const switches = screen.getAllByRole('switch');\n      // All should start checked (matching mock preferences)\n      expect(switches[0]).toHaveAttribute('aria-checked', 'true');\n\n      await user.click(switches[0]); // Toggle print start off\n\n      expect(switches[0]).toHaveAttribute('aria-checked', 'false');\n    });\n  });\n\n  describe('redirect behavior', () => {\n    it('does not redirect when advanced auth is enabled and notifications are enabled', async () => {\n      render(<NotificationsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Notifications')).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/PrintersPage.test.tsx",
    "content": "/**\n * Tests for the PrintersPage component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport { render } from '../utils';\nimport { PrintersPage } from '../../pages/PrintersPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockPrinters = [\n  {\n    id: 1,\n    name: 'X1 Carbon',\n    ip_address: '192.168.1.100',\n    serial_number: '00M09A350100001',\n    access_code: '12345678',\n    model: 'X1C',\n    enabled: true,\n    nozzle_diameter: 0.4,\n    nozzle_type: 'hardened_steel',\n    location: 'Workshop',\n    auto_archive: true,\n    created_at: '2024-01-01T00:00:00Z',\n    updated_at: '2024-01-01T00:00:00Z',\n  },\n  {\n    id: 2,\n    name: 'P1S Backup',\n    ip_address: '192.168.1.101',\n    serial_number: '00W00A123456789',\n    access_code: '87654321',\n    model: 'P1S',\n    enabled: false,\n    nozzle_diameter: 0.4,\n    nozzle_type: 'stainless_steel',\n    location: null,\n    auto_archive: true,\n    created_at: '2024-01-02T00:00:00Z',\n    updated_at: '2024-01-02T00:00:00Z',\n  },\n];\n\nconst mockPrinterStatus = {\n  connected: true,\n  state: 'IDLE',\n  awaiting_plate_clear: false,\n  progress: 0,\n  layer_num: 0,\n  total_layers: 0,\n  temperatures: {\n    nozzle: 25,\n    bed: 25,\n    chamber: 25,\n  },\n  remaining_time: 0,\n  filename: null,\n  wifi_signal: -50,\n  vt_tray: [],\n};\n\ndescribe('PrintersPage', () => {\n  beforeEach(() => {\n    localStorage.removeItem('printerCardSize');\n\n    server.use(\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json(mockPrinters);\n      }),\n      http.get('/api/v1/printers/:id/status', () => {\n        return HttpResponse.json(mockPrinterStatus);\n      }),\n      http.post('/api/v1/printers/:id/clear-plate', () => {\n        return HttpResponse.json({ success: true, message: 'Plate cleared' });\n      }),\n      http.get('/api/v1/settings/', () => {\n        return HttpResponse.json({\n          auto_archive: true,\n          save_thumbnails: true,\n          capture_finish_photo: true,\n          default_filament_cost: 25.0,\n          currency: 'USD',\n          ams_humidity_good: 40,\n          ams_humidity_fair: 60,\n          ams_temp_good: 30,\n          ams_temp_fair: 35,\n          require_plate_clear: true,\n        });\n      }),\n      http.get('/api/v1/queue/', () => {\n        return HttpResponse.json([]);\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page title', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Printers')).toBeInTheDocument();\n      });\n    });\n\n    it('shows printer cards', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.getByText('P1S Backup')).toBeInTheDocument();\n      });\n    });\n\n    it('shows printer models', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1C')).toBeInTheDocument();\n        expect(screen.getByText('P1S')).toBeInTheDocument();\n      });\n    });\n\n    it('shows printer status', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        // Status should be shown - may vary based on state\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('printer info', () => {\n    it('shows IP address in printer info modal', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // IP address is shown in the PrinterInfoModal (accessed via 3-dot menu),\n      // not directly on the card. Verify the printer data loaded correctly.\n      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n    });\n\n    it('shows location when set', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        // Printers should render - location display may vary\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('temperature display', () => {\n    it('shows nozzle temperature', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        // Temperatures are shown in the UI\n        expect(screen.getAllByText(/25/)).toBeTruthy();\n      });\n    });\n  });\n\n  describe('empty state', () => {\n    it('shows empty state when no printers', async () => {\n      server.use(\n        http.get('/api/v1/printers/', () => {\n          return HttpResponse.json([]);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText(/no printers/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('printer actions', () => {\n    it('has action buttons', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // There should be some interactive elements for printer actions\n      const buttons = screen.getAllByRole('button');\n      expect(buttons.length).toBeGreaterThan(0);\n    });\n\n    it('shows plate clear status and action on finished printers when not cleared', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);\n      });\n\n      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);\n    });\n\n    it('shows plate clear status and action on failed printers when not cleared', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrinterStatus, state: 'FAILED', awaiting_plate_clear: true });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);\n      });\n\n      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);\n    });\n\n    it('keeps the clear action available when an idle printer is still awaiting acknowledgment', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrinterStatus, state: 'IDLE', awaiting_plate_clear: true });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);\n      });\n\n      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);\n    });\n\n    it('updates the plate clear status after using the printer card action', async () => {\n      let awaitingPlateClear = true;\n\n      server.use(\n        http.get('/api/v1/printers/', () => {\n          return HttpResponse.json([mockPrinters[0]]);\n        }),\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: awaitingPlateClear });\n        }),\n        http.post('/api/v1/printers/:id/clear-plate', () => {\n          awaitingPlateClear = false;\n          return HttpResponse.json({ success: true, message: 'Plate cleared' });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);\n      });\n\n      fireEvent.click(screen.getAllByRole('button', { name: 'Mark plate as cleared' })[0]);\n\n      await waitFor(() => {\n        expect(screen.queryByText('Plate not Clear')).not.toBeInTheDocument();\n      });\n\n      expect(screen.getAllByText('Plate Clear').length).toBeGreaterThan(0);\n    });\n\n    it('shows an icon-only plate clear action in small card view', async () => {\n      let awaitingPlateClear = true;\n\n      server.use(\n        http.get('/api/v1/printers/', () => {\n          return HttpResponse.json([mockPrinters[0]]);\n        }),\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: awaitingPlateClear });\n        }),\n        http.post('/api/v1/printers/:id/clear-plate', () => {\n          awaitingPlateClear = false;\n          return HttpResponse.json({ success: true, message: 'Plate cleared' });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      fireEvent.click(screen.getByRole('button', { name: 'S' }));\n\n      await waitFor(() => {\n        expect(screen.queryByText('Mark plate as cleared')).not.toBeInTheDocument();\n      });\n\n      const clearButton = screen.getByRole('button', { name: 'Mark plate as cleared' });\n\n      fireEvent.click(clearButton);\n\n      await waitFor(() => {\n        expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();\n      });\n    });\n\n    it('shows plate clear status but no action while idle', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Plate Clear').length).toBeGreaterThan(0);\n      });\n\n      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();\n    });\n\n    it('shows plate in use status while printing and hides the clear action', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrinterStatus, state: 'RUNNING', awaiting_plate_clear: false });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Plate in Use').length).toBeGreaterThan(0);\n      });\n\n      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();\n    });\n\n    it('hides plate status and action when plate-clear confirmation is disabled', async () => {\n      server.use(\n        http.get('/api/v1/settings/', () => {\n          return HttpResponse.json({\n            auto_archive: true,\n            save_thumbnails: true,\n            capture_finish_photo: true,\n            default_filament_cost: 25.0,\n            currency: 'USD',\n            ams_humidity_good: 40,\n            ams_humidity_fair: 60,\n            ams_temp_good: 30,\n            ams_temp_fair: 35,\n            require_plate_clear: false,\n          });\n        }),\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByText('Plate not Clear')).not.toBeInTheDocument();\n      expect(screen.queryByText('Plate Clear')).not.toBeInTheDocument();\n      expect(screen.queryByText('Plate in Use')).not.toBeInTheDocument();\n      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();\n    });\n  });\n\n  describe('disabled printer', () => {\n    it('shows disabled state for disabled printers', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('P1S Backup')).toBeInTheDocument();\n      });\n\n      // Disabled printers have visual indication\n      const disabledPrinter = screen.getByText('P1S Backup').closest('div');\n      expect(disabledPrinter).toBeInTheDocument();\n    });\n  });\n\n  describe('nozzle rack card', () => {\n    const h2cStatus = {\n      ...mockPrinterStatus,\n      nozzle_rack: [\n        { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: 'SN-L', filament_color: '', filament_id: '', filament_type: '' },\n        { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 0, max_temp: 300, serial_number: 'SN-R', filament_color: '', filament_id: '', filament_type: '' },\n        { id: 16, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 10, stat: 0, max_temp: 300, serial_number: 'SN-16', filament_color: '', filament_id: '', filament_type: '' },\n        { id: 17, nozzle_type: 'HH01', nozzle_diameter: '0.6', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-17', filament_color: '', filament_id: '', filament_type: '' },\n        { id: 18, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 2, stat: 0, max_temp: 300, serial_number: 'SN-18', filament_color: '', filament_id: '', filament_type: '' },\n        { id: 19, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },\n        { id: 20, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },\n        { id: 21, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },\n      ],\n    };\n\n    it('shows nozzle rack when H2C rack slots present', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(h2cStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);\n      });\n    });\n\n    it('shows 6 rack slot elements for H2C', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(h2cStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);\n      });\n\n      // Rack shows diameters for occupied slots and dashes for empty ones\n      const dashes = screen.getAllByText('—');\n      expect(dashes.length).toBeGreaterThanOrEqual(3); // 3 empty rack positions (IDs 19,20,21)\n    });\n\n    it('keeps empty slot anchored to physical position when its nozzle is mounted (#943)', async () => {\n      // H2C with rack slot 16 picked up into the hotend — firmware omits ID 16\n      // entirely from nozzle.info. Each rack diameter is unique so we can assert\n      // the ordering by tooltip lookup.\n      const h2cSlot16Mounted = {\n        ...mockPrinterStatus,\n        nozzle_rack: [\n          { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: 'SN-L', filament_color: '', filament_id: '', filament_type: '' },\n          { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 0, max_temp: 300, serial_number: 'SN-R', filament_color: '', filament_id: '', filament_type: '' },\n          // ID 16 missing — currently in hotend\n          { id: 17, nozzle_type: 'HS', nozzle_diameter: '0.2', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-17', filament_color: '', filament_id: '', filament_type: '' },\n          { id: 18, nozzle_type: 'HS', nozzle_diameter: '0.6', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-18', filament_color: '', filament_id: '', filament_type: '' },\n          { id: 19, nozzle_type: 'HS', nozzle_diameter: '0.8', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-19', filament_color: '', filament_id: '', filament_type: '' },\n          { id: 20, nozzle_type: 'HH01', nozzle_diameter: '1.0', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-20', filament_color: '', filament_id: '', filament_type: '' },\n          { id: 21, nozzle_type: 'HH01', nozzle_diameter: '1.2', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-21', filament_color: '', filament_id: '', filament_type: '' },\n        ],\n      };\n\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(h2cSlot16Mounted);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);\n      });\n\n      // Slot 1 (leftmost, ID 16) should be the empty dash; slots 2..6 should\n      // hold the 5 remaining nozzles in order 17, 18, 19, 20, 21.\n      const rackLabel = screen.getAllByText('Nozzle Rack')[0];\n      const rackCard = rackLabel.parentElement!;\n      const slotRow = rackCard.querySelectorAll('div.flex')[0];\n      const slotTexts = Array.from(slotRow.querySelectorAll('span')).map(s => s.textContent);\n      expect(slotTexts).toEqual(['—', '0.2', '0.6', '0.8', '1.0', '1.2']);\n    });\n\n    it('hides nozzle rack when only L/R nozzles present (H2D)', async () => {\n      const h2dStatus = {\n        ...mockPrinterStatus,\n        nozzle_rack: [\n          { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },\n          { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },\n        ],\n      };\n\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(h2dStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByText('Nozzle Rack')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('firmware version badge', () => {\n    const firmwareUpToDate = {\n      printer_id: 1,\n      current_version: '01.09.00.00',\n      latest_version: '01.09.00.00',\n      update_available: false,\n      download_url: null,\n      release_notes: 'Bug fixes and improvements.',\n    };\n\n    const firmwareUpdateAvailable = {\n      printer_id: 1,\n      current_version: '01.08.00.00',\n      latest_version: '01.09.00.00',\n      update_available: true,\n      download_url: 'https://example.com/firmware.bin',\n      release_notes: 'New features added.',\n    };\n\n    it('shows green badge when firmware is up to date', async () => {\n      server.use(\n        http.get('/api/v1/firmware/updates/:id', () => {\n          return HttpResponse.json(firmwareUpToDate);\n        }),\n        http.get('/api/v1/settings/', () => {\n          return HttpResponse.json({\n            check_printer_firmware: true,\n            auto_archive: true,\n            save_thumbnails: true,\n          });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('01.09.00.00').length).toBeGreaterThan(0);\n      });\n\n      const badge = screen.getAllByText('01.09.00.00')[0].closest('button');\n      expect(badge).toBeInTheDocument();\n      expect(badge?.className).toContain('text-status-ok');\n    });\n\n    it('shows orange badge when firmware update is available', async () => {\n      server.use(\n        http.get('/api/v1/firmware/updates/:id', () => {\n          return HttpResponse.json(firmwareUpdateAvailable);\n        }),\n        http.get('/api/v1/settings/', () => {\n          return HttpResponse.json({\n            check_printer_firmware: true,\n            auto_archive: true,\n            save_thumbnails: true,\n          });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('01.08.00.00').length).toBeGreaterThan(0);\n      });\n\n      const badge = screen.getAllByText('01.08.00.00')[0].closest('button');\n      expect(badge).toBeInTheDocument();\n      expect(badge?.className).toContain('text-orange-400');\n    });\n\n    it('hides badge when firmware check is disabled', async () => {\n      server.use(\n        http.get('/api/v1/settings/', () => {\n          return HttpResponse.json({\n            check_printer_firmware: false,\n            auto_archive: true,\n            save_thumbnails: true,\n          });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Version should not appear when firmware check is disabled\n      expect(screen.queryByText('01.09.00.00')).not.toBeInTheDocument();\n      expect(screen.queryByText('01.08.00.00')).not.toBeInTheDocument();\n    });\n\n    it('hides badge when API has no firmware data for the model', async () => {\n      const firmwareNoData = {\n        printer_id: 1,\n        current_version: '01.01.03.00',\n        latest_version: null,\n        update_available: false,\n        download_url: null,\n        release_notes: null,\n      };\n\n      server.use(\n        http.get('/api/v1/firmware/updates/:id', () => {\n          return HttpResponse.json(firmwareNoData);\n        }),\n        http.get('/api/v1/settings/', () => {\n          return HttpResponse.json({\n            check_printer_firmware: true,\n            auto_archive: true,\n            save_thumbnails: true,\n          });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Badge should not appear when API returns no latest_version\n      expect(screen.queryByText('01.01.03.00')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('bulk selection', () => {\n    it('shows select button in toolbar', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // The Select button should be in the toolbar (title attribute)\n      const selectButton = screen.getByTitle('Select');\n      expect(selectButton).toBeInTheDocument();\n    });\n\n    it('shows selection toolbar after clicking select button', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Click the Select button to enter selection mode\n      fireEvent.click(screen.getByTitle('Select'));\n\n      // The floating toolbar should appear with Select All\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n    });\n\n    it('shows selection count when printers are selected', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Enter selection mode\n      fireEvent.click(screen.getByTitle('Select'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n\n      // Click Select All to select both printers\n      fireEvent.click(screen.getByText('Select All'));\n\n      // Should show \"2 selected\"\n      await waitFor(() => {\n        expect(screen.getByText('2 selected')).toBeInTheDocument();\n      });\n    });\n\n    it('shows select by state dropdown', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Enter selection mode\n      fireEvent.click(screen.getByTitle('Select'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Select by State')).toBeInTheDocument();\n      });\n    });\n\n    it('exits selection mode on close button', async () => {\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n      });\n\n      // Enter selection mode\n      fireEvent.click(screen.getByTitle('Select'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Select All')).toBeInTheDocument();\n      });\n\n      // Click the Select button again to exit (it toggles)\n      fireEvent.click(screen.getByTitle('Select'));\n\n      // Floating toolbar should disappear\n      await waitFor(() => {\n        expect(screen.queryByText('Select All')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('search and filter', () => {\n    beforeEach(() => {\n      server.use(\n        http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),\n        http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockPrinterStatus)),\n        http.get('/api/v1/queue/', () => HttpResponse.json([]))\n      );\n    });\n\n    it('filters by name (case-insensitive)', async () => {\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'x1 carbon' } });\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();\n      });\n    });\n\n    it('trims leading and trailing whitespace from search', async () => {\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      // \" X1 Carbon \" with surrounding spaces must still match\n      fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: '  X1 Carbon  ' } });\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();\n      });\n    });\n\n    it('filters by model', async () => {\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'P1S' } });\n\n      await waitFor(() => {\n        expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument();\n        expect(screen.getByText('P1S Backup')).toBeInTheDocument();\n      });\n    });\n\n    it('filters by serial number', async () => {\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: '00M09A' } });\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();\n      });\n    });\n\n    it('shows empty state when no printers match search', async () => {\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'ZZZ_NO_MATCH' } });\n\n      await waitFor(() => {\n        expect(screen.getByText('No printers match your search or filters')).toBeInTheDocument();\n      });\n    });\n\n    it('clear button resets search and shows all printers', async () => {\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'X1 Carbon' } });\n\n      await waitFor(() => expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument());\n\n      // Click the accessible clear button\n      fireEvent.click(screen.getByRole('button', { name: 'Clear' }));\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.getByText('P1S Backup')).toBeInTheDocument();\n      });\n    });\n\n    it('filters by status (offline) via dropdown', async () => {\n      // Override: printer 1 online, printer 2 offline\n      server.use(\n        http.get('/api/v1/printers/:id/status', ({ params }) => {\n          if (Number(params.id) === 2) {\n            return HttpResponse.json({ ...mockPrinterStatus, connected: false });\n          }\n          return HttpResponse.json(mockPrinterStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      // Select \"Offline\" from the status filter dropdown\n      const statusSelect = screen.getByDisplayValue('All statuses');\n      fireEvent.change(statusSelect, { target: { value: 'offline' } });\n\n      await waitFor(() => {\n        expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument();\n        expect(screen.getByText('P1S Backup')).toBeInTheDocument();\n      });\n    });\n\n    it('shows empty state when status filter matches nothing', async () => {\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      // Both printers are IDLE; filtering by \"printing\" should yield no results\n      const statusSelect = screen.getByDisplayValue('All statuses');\n      fireEvent.change(statusSelect, { target: { value: 'printing' } });\n\n      await waitFor(() => {\n        expect(screen.getByText('No printers match your search or filters')).toBeInTheDocument();\n      });\n    });\n\n    it('combines search and status filter', async () => {\n      // Printer 1 = RUNNING (printing), printer 2 = IDLE\n      server.use(\n        http.get('/api/v1/printers/:id/status', ({ params }) => {\n          if (Number(params.id) === 1) {\n            return HttpResponse.json({ ...mockPrinterStatus, state: 'RUNNING' });\n          }\n          return HttpResponse.json(mockPrinterStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      // Filter to only \"printing\" printers\n      fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'printing' } });\n\n      // Then also search for a term that only matches printer 1\n      fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'X1' } });\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();\n      });\n    });\n\n    it('filters by location via dropdown', async () => {\n      // Override: give printer 2 its own location so the dropdown has two options\n      // and we can verify the filter picks the right one. Printer 1 stays at 'Workshop'.\n      server.use(\n        http.get('/api/v1/printers/', () =>\n          HttpResponse.json([\n            mockPrinters[0],\n            { ...mockPrinters[1], location: 'Office' },\n          ])\n        )\n      );\n\n      render(<PrintersPage />);\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.getByText('P1S Backup')).toBeInTheDocument();\n      });\n\n      // Select \"Workshop\" from the location filter dropdown\n      fireEvent.change(screen.getByDisplayValue('All locations'), { target: { value: 'Workshop' } });\n\n      await waitFor(() => {\n        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();\n        expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();\n      });\n\n      // Switch to \"Office\" — the other printer should now be the only one visible\n      fireEvent.change(screen.getByDisplayValue('Workshop'), { target: { value: 'Office' } });\n\n      await waitFor(() => {\n        expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument();\n        expect(screen.getByText('P1S Backup')).toBeInTheDocument();\n      });\n    });\n\n    it('hides location filter when no printers have a location', async () => {\n      // Both printers have null location — dropdown should not render at all\n      server.use(\n        http.get('/api/v1/printers/', () =>\n          HttpResponse.json([\n            { ...mockPrinters[0], location: null },\n            { ...mockPrinters[1], location: null },\n          ])\n        )\n      );\n\n      render(<PrintersPage />);\n      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());\n\n      // Status filter is still there, but the location filter should be absent.\n      expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();\n      expect(screen.queryByDisplayValue('All locations')).not.toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/PrintersPageDrying.test.ts",
    "content": "/**\n * Tests for AMS drying feature logic.\n *\n * The drying presets, time formatting, module type gating, and temperature\n * clamping are all defined inline in PrintersPage.tsx. These tests validate\n * the logic directly by mirroring the relevant constants and functions.\n */\nimport { describe, it, expect } from 'vitest';\n\n/**\n * Mirrors the DRYING_PRESETS constant from PrintersPage.tsx.\n * Format: { n3f temp, n3s temp, n3f hours, n3s hours }\n */\nconst DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {\n  'PLA':   { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },\n  'PETG':  { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },\n  'TPU':   { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },\n  'ABS':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n  'ASA':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n  'PA':    { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },\n  'PC':    { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n  'PVA':   { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },\n};\n\n/**\n * Mirrors the inline dry_time formatting from PrintersPage.tsx:\n *   dry_time >= 60 ? `${Math.floor(dry_time / 60)}h ${dry_time % 60}m` : `${dry_time}m`\n */\nfunction formatDryTime(dry_time: number): string {\n  if (dry_time >= 60) {\n    return `${Math.floor(dry_time / 60)}h ${dry_time % 60}m`;\n  }\n  return `${dry_time}m`;\n}\n\n/**\n * Mirrors the temperature clamping from PrintersPage.tsx:\n *   Math.min(maxTemp, Math.max(45, value))\n * where maxTemp is 65 for n3f and 85 for n3s.\n */\nfunction clampTemp(value: number, moduleType: 'n3f' | 'n3s'): number {\n  const maxTemp = moduleType === 'n3s' ? 85 : 65;\n  return Math.min(maxTemp, Math.max(45, value));\n}\n\ndescribe('DRYING_PRESETS structure', () => {\n  const expectedFilaments = ['PLA', 'PETG', 'TPU', 'ABS', 'ASA', 'PA', 'PC', 'PVA'];\n\n  it('contains all expected filament types', () => {\n    for (const fil of expectedFilaments) {\n      expect(DRYING_PRESETS).toHaveProperty(fil);\n    }\n  });\n\n  it('has no unexpected filament types', () => {\n    expect(Object.keys(DRYING_PRESETS).sort()).toEqual(expectedFilaments.sort());\n  });\n\n  it('n3f temps are all within 45-65 range', () => {\n    for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {\n      expect(preset.n3f, `${fil} n3f temp`).toBeGreaterThanOrEqual(45);\n      expect(preset.n3f, `${fil} n3f temp`).toBeLessThanOrEqual(65);\n    }\n  });\n\n  it('n3s temps are all within 45-85 range', () => {\n    for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {\n      expect(preset.n3s, `${fil} n3s temp`).toBeGreaterThanOrEqual(45);\n      expect(preset.n3s, `${fil} n3s temp`).toBeLessThanOrEqual(85);\n    }\n  });\n\n  it('all hours are between 1-24', () => {\n    for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {\n      expect(preset.n3f_hours, `${fil} n3f_hours`).toBeGreaterThanOrEqual(1);\n      expect(preset.n3f_hours, `${fil} n3f_hours`).toBeLessThanOrEqual(24);\n      expect(preset.n3s_hours, `${fil} n3s_hours`).toBeGreaterThanOrEqual(1);\n      expect(preset.n3s_hours, `${fil} n3s_hours`).toBeLessThanOrEqual(24);\n    }\n  });\n\n  it('n3s temp is always >= n3f temp for same filament', () => {\n    for (const [fil, preset] of Object.entries(DRYING_PRESETS)) {\n      expect(preset.n3s, `${fil}: n3s should be >= n3f`).toBeGreaterThanOrEqual(preset.n3f);\n    }\n  });\n});\n\ndescribe('dry_time formatting', () => {\n  it('formats >= 60 minutes as hours and minutes', () => {\n    expect(formatDryTime(119)).toBe('1h 59m');\n  });\n\n  it('formats exactly 60 minutes as 1h 0m', () => {\n    expect(formatDryTime(60)).toBe('1h 0m');\n  });\n\n  it('formats large values correctly', () => {\n    expect(formatDryTime(750)).toBe('12h 30m');\n  });\n\n  it('formats < 60 minutes as minutes only', () => {\n    expect(formatDryTime(42)).toBe('42m');\n  });\n\n  it('formats 1 minute', () => {\n    expect(formatDryTime(1)).toBe('1m');\n  });\n\n  it('dry_time = 0 means not drying (shows 0m)', () => {\n    // In the UI, dry_time > 0 gates whether the drying bar is shown at all,\n    // so formatDryTime(0) would not be called. But the value itself means \"not drying\".\n    expect(formatDryTime(0)).toBe('0m');\n  });\n});\n\ndescribe('module type detection — drying button visibility', () => {\n  /**\n   * Mirrors the condition from PrintersPage.tsx:\n   *   ams.module_type === 'n3f' || ams.module_type === 'n3s'\n   * The drying button only shows for AMS 2 Pro (n3f) and AMS-HT (n3s).\n   */\n  function shouldShowDryingButton(moduleType: string): boolean {\n    return moduleType === 'n3f' || moduleType === 'n3s';\n  }\n\n  it('shows for n3f (AMS 2 Pro)', () => {\n    expect(shouldShowDryingButton('n3f')).toBe(true);\n  });\n\n  it('shows for n3s (AMS-HT)', () => {\n    expect(shouldShowDryingButton('n3s')).toBe(true);\n  });\n\n  it('does not show for ams (original AMS)', () => {\n    expect(shouldShowDryingButton('ams')).toBe(false);\n  });\n\n  it('does not show for empty string', () => {\n    expect(shouldShowDryingButton('')).toBe(false);\n  });\n\n  it('does not show for unknown types', () => {\n    expect(shouldShowDryingButton('unknown')).toBe(false);\n  });\n});\n\ndescribe('temperature clamping', () => {\n  describe('n3f (max 65)', () => {\n    it('clamps value below minimum to 45', () => {\n      expect(clampTemp(30, 'n3f')).toBe(45);\n    });\n\n    it('clamps value above maximum to 65', () => {\n      expect(clampTemp(80, 'n3f')).toBe(65);\n    });\n\n    it('keeps value within range unchanged', () => {\n      expect(clampTemp(55, 'n3f')).toBe(55);\n    });\n\n    it('keeps minimum boundary value', () => {\n      expect(clampTemp(45, 'n3f')).toBe(45);\n    });\n\n    it('keeps maximum boundary value', () => {\n      expect(clampTemp(65, 'n3f')).toBe(65);\n    });\n  });\n\n  describe('n3s (max 85)', () => {\n    it('clamps value below minimum to 45', () => {\n      expect(clampTemp(10, 'n3s')).toBe(45);\n    });\n\n    it('clamps value above maximum to 85', () => {\n      expect(clampTemp(100, 'n3s')).toBe(85);\n    });\n\n    it('keeps value within range unchanged', () => {\n      expect(clampTemp(70, 'n3s')).toBe(70);\n    });\n\n    it('keeps minimum boundary value', () => {\n      expect(clampTemp(45, 'n3s')).toBe(45);\n    });\n\n    it('keeps maximum boundary value', () => {\n      expect(clampTemp(85, 'n3s')).toBe(85);\n    });\n\n    it('allows values above n3f max (e.g. 75)', () => {\n      expect(clampTemp(75, 'n3s')).toBe(75);\n    });\n  });\n});\n\ndescribe('rotate tray option', () => {\n  it('defaults to false', () => {\n    // Mirrors the initial state: useState(false)\n    const defaultRotateTray = false;\n    expect(defaultRotateTray).toBe(false);\n  });\n\n  it('is included in the API URL when true', () => {\n    // Mirrors the API call construction from client.ts\n    const buildUrl = (rotateTray: boolean) =>\n      `/printers/1/drying/start?ams_id=0&temp=55&duration=4&filament=PLA&rotate_tray=${rotateTray}`;\n    expect(buildUrl(true)).toContain('rotate_tray=true');\n    expect(buildUrl(false)).toContain('rotate_tray=false');\n  });\n\n  it('resets to false when opening popover for a new AMS unit', () => {\n    // Mirrors the popover open logic: setDryingRotateTray(false) is called\n    // each time the popover opens for any AMS unit\n    let rotateTray = true; // user enabled it for previous AMS\n    // Simulates opening popover for a different AMS\n    rotateTray = false; // setDryingRotateTray(false)\n    expect(rotateTray).toBe(false);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/PrintersPageFillLevel.test.ts",
    "content": "/**\n * Tests for inventory fill level calculation logic.\n *\n * The fill level is calculated inline in PrintersPage.tsx as:\n *   if (sp && sp.label_weight > 0 && sp.weight_used != null)\n *     → Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100)\n *   else → null\n *\n * These tests validate the calculation logic directly, particularly the\n * fix for weight_used == null (brand new spools) and weight_used == 0.\n */\nimport { describe, it, expect } from 'vitest';\n\n/**\n * Mirrors the inline inventoryFill calculation from PrintersPage.tsx.\n * Extracted here for testability.\n */\nfunction computeInventoryFill(spool: { label_weight: number; weight_used: number | null } | null): number | null {\n  const sp = spool;\n  if (sp && sp.label_weight > 0 && sp.weight_used != null) {\n    return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);\n  }\n  return null;\n}\n\ndescribe('inventoryFill calculation', () => {\n  it('returns 100% for brand new spool with weight_used = 0', () => {\n    expect(computeInventoryFill({ label_weight: 1000, weight_used: 0 })).toBe(100);\n  });\n\n  it('returns null for brand new spool with weight_used = null', () => {\n    // weight_used null means \"never tracked\" — we can't compute fill\n    expect(computeInventoryFill({ label_weight: 1000, weight_used: null })).toBeNull();\n  });\n\n  it('returns correct percentage for partially used spool', () => {\n    expect(computeInventoryFill({ label_weight: 1000, weight_used: 250 })).toBe(75);\n  });\n\n  it('returns 0% for fully used spool', () => {\n    expect(computeInventoryFill({ label_weight: 1000, weight_used: 1000 })).toBe(0);\n  });\n\n  it('returns 0% when weight_used exceeds label_weight', () => {\n    // Math.max(0, ...) prevents negative fill\n    expect(computeInventoryFill({ label_weight: 1000, weight_used: 1200 })).toBe(0);\n  });\n\n  it('returns null when no spool data', () => {\n    expect(computeInventoryFill(null)).toBeNull();\n  });\n\n  it('returns null when label_weight is 0', () => {\n    expect(computeInventoryFill({ label_weight: 0, weight_used: 0 })).toBeNull();\n  });\n\n  it('rounds to nearest integer', () => {\n    // 1000 - 333 = 667, 667/1000 = 66.7 → 67\n    expect(computeInventoryFill({ label_weight: 1000, weight_used: 333 })).toBe(67);\n  });\n});\n\ndescribe('inventoryFill: old bug — weight_used > 0 vs weight_used != null', () => {\n  /**\n   * The old condition was: sp.weight_used > 0\n   * This caused brand-new spools (weight_used=0) to show no fill bar.\n   * The fix changed it to: sp.weight_used != null\n   */\n  it('weight_used = 0 now shows fill (was broken with > 0 check)', () => {\n    // Old: 0 > 0 = false → null (no fill bar)\n    // New: 0 != null = true → 100% fill\n    const result = computeInventoryFill({ label_weight: 1000, weight_used: 0 });\n    expect(result).toBe(100);\n    expect(result).not.toBeNull();\n  });\n\n  it('weight_used = 0.1 shows fill (small usage)', () => {\n    const result = computeInventoryFill({ label_weight: 1000, weight_used: 0.1 });\n    expect(result).toBe(100); // rounds to 100 since 0.1g from 1000g is negligible\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/PrintersPageFormatPrintName.test.ts",
    "content": "/**\n * Unit tests for the formatPrintName helper on PrintersPage.\n *\n * Regression coverage for the #881 follow-up: when the printer card has an\n * archive-linked plate label (resolved from the backend's current_archive_id\n * + the archive's is_multi_plate plate list), the label must take precedence\n * over the gcode_file regex fallback, including for plate 1.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { formatPrintName } from '../../utils/printName';\n\n// Minimal translator stub: returns the fallback with the plate number interpolated\n// the same way i18next would. Keeps these tests independent of the i18n setup.\nconst t = (_key: string, fallback: string, opts?: Record<string, unknown>) =>\n  fallback.replace('{{number}}', String(opts?.number ?? ''));\n\ndescribe('formatPrintName', () => {\n  it('returns the name unchanged when neither plate source is available', () => {\n    expect(formatPrintName('Benchy', null, t)).toBe('Benchy');\n  });\n\n  it('appends gcode-file plate number only when > 1 (single-plate noise guard)', () => {\n    // Plate 1 from gcode_file alone is ambiguous (could be a single-plate 3MF)\n    // so the legacy fallback path keeps it silent.\n    expect(formatPrintName('Benchy', '/Metadata/plate_1.gcode', t)).toBe('Benchy');\n    expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t)).toBe('Benchy — Plate 2');\n  });\n\n  it('uses plateLabel verbatim when provided, overriding the gcode_file fallback', () => {\n    // plateLabel comes from the archive lookup and is already disambiguated\n    // (only set when is_multi_plate === true). It must show even for plate 1.\n    expect(formatPrintName('Benchy', '/Metadata/plate_1.gcode', t, 'Plate 1')).toBe('Benchy — Plate 1');\n    expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, 'Small Parts')).toBe('Benchy — Small Parts');\n  });\n\n  it('returns empty string when name is missing, regardless of plate info', () => {\n    expect(formatPrintName(null, '/Metadata/plate_2.gcode', t)).toBe('');\n    expect(formatPrintName(null, null, t, 'Plate 3')).toBe('');\n  });\n\n  it('treats null/empty plateLabel as absent and falls through to gcode_file parsing', () => {\n    expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, null)).toBe('Benchy — Plate 2');\n    expect(formatPrintName('Benchy', '/Metadata/plate_2.gcode', t, '')).toBe('Benchy — Plate 2');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/PrintersPageSpeed.test.tsx",
    "content": "/**\n * Tests for the print speed control feature on the PrintersPage.\n *\n * Verifies that the speed badge renders, the dropdown menu opens on click,\n * speed options are displayed, and selecting an option calls the API.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { PrintersPage } from '../../pages/PrintersPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockPrinters = [\n  {\n    id: 1,\n    name: 'X1 Carbon',\n    ip_address: '192.168.1.100',\n    serial_number: '00M09A350100001',\n    access_code: '12345678',\n    model: 'X1C',\n    enabled: true,\n    nozzle_diameter: 0.4,\n    nozzle_type: 'hardened_steel',\n    location: 'Workshop',\n    auto_archive: true,\n    created_at: '2024-01-01T00:00:00Z',\n    updated_at: '2024-01-01T00:00:00Z',\n  },\n];\n\nconst mockPrintingStatus = {\n  connected: true,\n  state: 'RUNNING',\n  progress: 42,\n  layer_num: 10,\n  total_layers: 100,\n  temperatures: {\n    nozzle: 220,\n    bed: 60,\n    chamber: 35,\n  },\n  remaining_time: 3600,\n  filename: 'test_print.3mf',\n  wifi_signal: -50,\n  vt_tray: [],\n  speed_level: 2,\n};\n\nconst mockIdleStatus = {\n  connected: true,\n  state: 'IDLE',\n  progress: 0,\n  layer_num: 0,\n  total_layers: 0,\n  temperatures: {\n    nozzle: 25,\n    bed: 25,\n    chamber: 25,\n  },\n  remaining_time: 0,\n  filename: null,\n  wifi_signal: -50,\n  vt_tray: [],\n  speed_level: 2,\n};\n\ndescribe('PrintersPage - Print Speed Control', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json(mockPrinters);\n      }),\n      http.get('/api/v1/queue/', () => {\n        return HttpResponse.json([]);\n      })\n    );\n  });\n\n  describe('speed badge rendering', () => {\n    it('shows speed badge with current speed percentage when printing', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockPrintingStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        // speed_level 2 = Standard = 100%\n        expect(screen.getByText('100%')).toBeInTheDocument();\n      });\n    });\n\n    it('shows speed badge with 50% for silent mode', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 1 });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('50%')).toBeInTheDocument();\n      });\n    });\n\n    it('shows speed badge with 124% for sport mode', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 3 });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('124%')).toBeInTheDocument();\n      });\n    });\n\n    it('shows speed badge with 166% for ludicrous mode', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 4 });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('166%')).toBeInTheDocument();\n      });\n    });\n\n    it('disables speed badge button when printer is idle', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockIdleStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('100%')).toBeInTheDocument();\n      });\n\n      // The button containing the speed percentage should be disabled\n      const speedBadge = screen.getByText('100%').closest('button');\n      expect(speedBadge).toBeDisabled();\n    });\n  });\n\n  describe('speed dropdown menu', () => {\n    it('opens speed menu on click when printing', async () => {\n      const user = userEvent.setup();\n\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockPrintingStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('100%')).toBeInTheDocument();\n      });\n\n      const speedBadge = screen.getByText('100%').closest('button')!;\n      await user.click(speedBadge);\n\n      await waitFor(() => {\n        expect(screen.getByText('Silent (50%)')).toBeInTheDocument();\n        expect(screen.getByText('Standard (100%)')).toBeInTheDocument();\n        expect(screen.getByText('Sport (124%)')).toBeInTheDocument();\n        expect(screen.getByText('Ludicrous (166%)')).toBeInTheDocument();\n      });\n    });\n\n    it('displays all four speed options in the dropdown', async () => {\n      const user = userEvent.setup();\n\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockPrintingStatus);\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('100%')).toBeInTheDocument();\n      });\n\n      const speedBadge = screen.getByText('100%').closest('button')!;\n      await user.click(speedBadge);\n\n      await waitFor(() => {\n        const options = [\n          screen.getByText('Silent (50%)'),\n          screen.getByText('Standard (100%)'),\n          screen.getByText('Sport (124%)'),\n          screen.getByText('Ludicrous (166%)'),\n        ];\n        expect(options).toHaveLength(4);\n        options.forEach((opt) => expect(opt).toBeInTheDocument());\n      });\n    });\n\n    it('calls the API when a speed option is selected', async () => {\n      const user = userEvent.setup();\n      let capturedMode: number | null = null;\n\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockPrintingStatus);\n        }),\n        http.post('/api/v1/printers/:id/print-speed', async ({ request }) => {\n          const url = new URL(request.url);\n          capturedMode = Number(url.searchParams.get('mode'));\n          return HttpResponse.json({ success: true, message: 'Speed set' });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('100%')).toBeInTheDocument();\n      });\n\n      // Open the speed menu\n      const speedBadge = screen.getByText('100%').closest('button')!;\n      await user.click(speedBadge);\n\n      await waitFor(() => {\n        expect(screen.getByText('Sport (124%)')).toBeInTheDocument();\n      });\n\n      // Select \"Sport\" speed\n      await user.click(screen.getByText('Sport (124%)'));\n\n      await waitFor(() => {\n        expect(capturedMode).toBe(3);\n      });\n    });\n\n    it('closes the dropdown after selecting a speed option', async () => {\n      const user = userEvent.setup();\n\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockPrintingStatus);\n        }),\n        http.post('/api/v1/printers/:id/print-speed', () => {\n          return HttpResponse.json({ success: true, message: 'Speed set' });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('100%')).toBeInTheDocument();\n      });\n\n      const speedBadge = screen.getByText('100%').closest('button')!;\n      await user.click(speedBadge);\n\n      await waitFor(() => {\n        expect(screen.getByText('Silent (50%)')).toBeInTheDocument();\n      });\n\n      // Select an option\n      await user.click(screen.getByText('Silent (50%)'));\n\n      // Menu should close - speed labels should no longer be visible\n      await waitFor(() => {\n        expect(screen.queryByText('Silent (50%)')).not.toBeInTheDocument();\n      });\n    });\n\n    it('optimistically updates the speed display when selecting a new speed', async () => {\n      const user = userEvent.setup();\n\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockPrintingStatus); // speed_level: 2 (100%)\n        }),\n        http.post('/api/v1/printers/:id/print-speed', () => {\n          return HttpResponse.json({ success: true, message: 'Speed set' });\n        })\n      );\n\n      render(<PrintersPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('100%')).toBeInTheDocument();\n      });\n\n      // Open the speed menu and select Ludicrous\n      const speedBadge = screen.getByText('100%').closest('button')!;\n      await user.click(speedBadge);\n\n      await waitFor(() => {\n        expect(screen.getByText('Ludicrous (166%)')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Ludicrous (166%)'));\n\n      // The badge should optimistically update to show 166%\n      await waitFor(() => {\n        expect(screen.getByText('166%')).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/ProjectDetailPage.test.tsx",
    "content": "/**\n * Tests for the ProjectDetailPage component.\n * Covers: isSlicedFilename conditional print-button logic, linked folder file rendering,\n * and the PrintModal open trigger with projectId.\n */\n\n/// <reference types=\"@testing-library/jest-dom\" />\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { ProjectDetailPage } from '../../pages/ProjectDetailPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\n// Mock useParams so the component receives a fixed project id without a nested Router\nvi.mock('react-router-dom', async () => {\n  const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');\n  return {\n    ...actual,\n    useParams: () => ({ id: '1' }),\n    useNavigate: () => vi.fn(),\n  };\n});\n\nconst mockProject = {\n  id: 1,\n  name: 'Test Project',\n  description: 'A test project',\n  color: '#00ae42',\n  status: 'active',\n  priority: 'normal',\n  due_date: null,\n  notes: null,\n  parent_id: null,\n  archive_count: 0,\n  total_print_time_seconds: 0,\n  total_filament_grams: 0,\n  created_at: '2024-01-01T00:00:00Z',\n  updated_at: '2024-01-01T00:00:00Z',\n};\n\nconst mockFolder = {\n  id: 10,\n  name: 'Sliced Files',\n  project_id: 1,\n  archive_id: null,\n  parent_id: null,\n  file_count: 3,\n  created_at: '2024-01-01T00:00:00Z',\n  updated_at: '2024-01-01T00:00:00Z',\n};\n\nfunction makeFile(overrides: { id: number; filename: string; file_type?: string }) {\n  return {\n    id: overrides.id,\n    filename: overrides.filename,\n    print_name: null,\n    file_type: overrides.file_type ?? '3mf',\n    folder_id: 10,\n    project_id: 1,\n    file_hash: null,\n    file_size_bytes: 1024,\n    thumbnail_path: null,\n    created_at: '2024-01-01T00:00:00Z',\n    updated_at: '2024-01-01T00:00:00Z',\n    duplicate_count: 0,\n  };\n}\n\ndescribe('ProjectDetailPage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/projects/:id', () => {\n        return HttpResponse.json(mockProject);\n      }),\n      http.get('/api/v1/projects/:id/archives', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/projects/:id/bom', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/projects/:id/timeline', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/library/folders/by-project/:id', () => {\n        return HttpResponse.json([mockFolder]);\n      }),\n    );\n  });\n\n  describe('isSlicedFilename — conditional print button', () => {\n    it('shows print button for .gcode files', async () => {\n      server.use(\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([makeFile({ id: 1, filename: 'benchy.gcode', file_type: 'gcode' })]);\n        })\n      );\n\n      render(<ProjectDetailPage />);\n\n      await waitFor(() => {\n        expect(screen.getByTitle('Print Now')).toBeInTheDocument();\n      });\n    });\n\n    it('shows print button for .gcode.3mf files', async () => {\n      server.use(\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([makeFile({ id: 2, filename: 'benchy.gcode.3mf', file_type: '3mf' })]);\n        })\n      );\n\n      render(<ProjectDetailPage />);\n\n      await waitFor(() => {\n        expect(screen.getByTitle('Print Now')).toBeInTheDocument();\n      });\n    });\n\n    it('does NOT show print button for .gcode.bak files (regression for includes bug)', async () => {\n      server.use(\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([makeFile({ id: 3, filename: 'benchy.gcode.bak', file_type: '3mf' })]);\n        })\n      );\n\n      render(<ProjectDetailPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('benchy.gcode.bak')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();\n    });\n\n    it('does NOT show print button for .stl files', async () => {\n      server.use(\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([makeFile({ id: 4, filename: 'model.stl', file_type: 'stl' })]);\n        })\n      );\n\n      render(<ProjectDetailPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('model.stl')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('linked folder file rendering', () => {\n    it('renders filenames from linked folder', async () => {\n      server.use(\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([\n            makeFile({ id: 5, filename: 'part_a.gcode.3mf', file_type: '3mf' }),\n            makeFile({ id: 6, filename: 'design.stl', file_type: 'stl' }),\n          ]);\n        })\n      );\n\n      render(<ProjectDetailPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('part_a.gcode.3mf')).toBeInTheDocument();\n        expect(screen.getByText('design.stl')).toBeInTheDocument();\n      });\n    });\n\n    it('renders the linked folder name', async () => {\n      server.use(\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([]);\n        })\n      );\n\n      render(<ProjectDetailPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Sliced Files')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('print modal trigger', () => {\n    it('opens PrintModal when print button is clicked on a sliced file', async () => {\n      const user = userEvent.setup();\n\n      server.use(\n        http.get('/api/v1/library/files', () => {\n          return HttpResponse.json([makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' })]);\n        }),\n        http.get('/api/v1/printers/', () => {\n          return HttpResponse.json([]);\n        }),\n        http.get('/api/v1/library/files/:id', () => {\n          return HttpResponse.json(makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' }));\n        }),\n        http.get('/api/v1/library/files/:id/plates', () => {\n          return HttpResponse.json({ is_multi_plate: false, plates: [] });\n        }),\n        http.get('/api/v1/library/files/:id/filament-requirements', () => {\n          return HttpResponse.json({ file_id: 7, filename: 'cube.gcode.3mf', filaments: [] });\n        }),\n      );\n\n      render(<ProjectDetailPage />);\n\n      await waitFor(() => {\n        expect(screen.getByTitle('Print Now')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByTitle('Print Now'));\n\n      // PrintModal should open — look for the modal heading \"Print\"\n      await waitFor(() => {\n        expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/ProjectsPage.test.tsx",
    "content": "/**\n * Tests for the ProjectsPage component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { ProjectsPage } from '../../pages/ProjectsPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockProjects = [\n  {\n    id: 1,\n    name: 'Functional Parts',\n    description: 'Useful household items',\n    color: '#00ae42',\n    archive_count: 10,\n    total_print_time_seconds: 36000,\n    total_filament_grams: 500,\n    created_at: '2024-01-01T00:00:00Z',\n    updated_at: '2024-01-15T00:00:00Z',\n  },\n  {\n    id: 2,\n    name: 'Art Collection',\n    description: 'Decorative prints',\n    color: '#ff5500',\n    archive_count: 5,\n    total_print_time_seconds: 18000,\n    total_filament_grams: 200,\n    created_at: '2024-01-05T00:00:00Z',\n    updated_at: '2024-01-10T00:00:00Z',\n  },\n];\n\ndescribe('ProjectsPage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/projects/', () => {\n        return HttpResponse.json(mockProjects);\n      }),\n      http.post('/api/v1/projects/', async ({ request }) => {\n        const body = await request.json() as { name: string };\n        return HttpResponse.json({ id: 3, name: body.name, color: '#00ae42', archive_count: 0 });\n      }),\n      http.delete('/api/v1/projects/:id', () => {\n        return HttpResponse.json({ success: true });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page title', async () => {\n      render(<ProjectsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Projects')).toBeInTheDocument();\n      });\n    });\n\n    it('shows project cards', async () => {\n      render(<ProjectsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Functional Parts')).toBeInTheDocument();\n        expect(screen.getByText('Art Collection')).toBeInTheDocument();\n      });\n    });\n\n    it('shows project descriptions', async () => {\n      render(<ProjectsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Useful household items')).toBeInTheDocument();\n        expect(screen.getByText('Decorative prints')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('project info', () => {\n    it('shows archive count', async () => {\n      render(<ProjectsPage />);\n\n      await waitFor(() => {\n        // Project cards should show archive counts\n        expect(screen.getByText('Functional Parts')).toBeInTheDocument();\n      });\n    });\n\n    it('shows project colors', async () => {\n      render(<ProjectsPage />);\n\n      await waitFor(() => {\n        const functionalParts = screen.getByText('Functional Parts');\n        expect(functionalParts).toBeInTheDocument();\n        // Color is applied as style\n      });\n    });\n  });\n\n  describe('create project', () => {\n    it('has new project button', async () => {\n      render(<ProjectsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('New Project')).toBeInTheDocument();\n      });\n    });\n\n    it('opens create modal on click', async () => {\n      const user = userEvent.setup();\n      render(<ProjectsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('New Project')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('New Project'));\n\n      // Modal should open - look for modal content\n      await waitFor(() => {\n        // Modal may show \"Create Project\" or similar text\n        const modalContent = screen.queryByText(/create/i) ||\n                           screen.queryByRole('dialog') ||\n                           screen.queryByText(/name/i);\n        expect(modalContent).toBeTruthy();\n      });\n    });\n  });\n\n  describe('empty state', () => {\n    it('shows empty state when no projects', async () => {\n      server.use(\n        http.get('/api/v1/projects/', () => {\n          return HttpResponse.json([]);\n        })\n      );\n\n      render(<ProjectsPage />);\n\n      await waitFor(() => {\n        // Either empty state message or the page title should be visible\n        const emptyMsg = screen.queryByText(/no projects/i);\n        const pageTitle = screen.queryByText('Projects');\n        expect(emptyMsg || pageTitle).toBeTruthy();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/QueuePage.test.tsx",
    "content": "/**\n * Tests for the QueuePage component.\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { QueuePage } from '../../pages/QueuePage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\n// Mock queue data\nconst mockQueueItems = [\n  {\n    id: 1,\n    printer_id: 1,\n    archive_id: 1,\n    position: 1,\n    status: 'pending',\n    scheduled_time: null,\n    require_previous_success: false,\n    auto_off_after: false,\n    manual_start: false,\n    ams_mapping: null,\n    plate_id: null,\n    bed_levelling: true,\n    flow_cali: false,\n    vibration_cali: true,\n    layer_inspect: false,\n    timelapse: false,\n    use_ams: true,\n    started_at: null,\n    completed_at: null,\n    error_message: null,\n    created_at: '2024-01-01T00:00:00Z',\n    archive_name: 'Test Print 1',\n    archive_thumbnail: '/thumb1.png',\n    printer_name: 'Test Printer',\n    print_time_seconds: 3600,\n  },\n  {\n    id: 2,\n    printer_id: 1,\n    archive_id: 2,\n    position: 2,\n    status: 'printing',\n    scheduled_time: null,\n    require_previous_success: false,\n    auto_off_after: true,\n    manual_start: false,\n    ams_mapping: null,\n    plate_id: null,\n    bed_levelling: true,\n    flow_cali: false,\n    vibration_cali: true,\n    layer_inspect: false,\n    timelapse: false,\n    use_ams: true,\n    started_at: '2024-01-01T10:00:00Z',\n    completed_at: null,\n    error_message: null,\n    created_at: '2024-01-01T00:00:00Z',\n    archive_name: 'Active Print',\n    archive_thumbnail: '/thumb2.png',\n    printer_name: 'Test Printer',\n    print_time_seconds: 7200,\n  },\n  {\n    id: 3,\n    printer_id: 1,\n    archive_id: 3,\n    position: 3,\n    status: 'completed',\n    scheduled_time: null,\n    require_previous_success: false,\n    auto_off_after: false,\n    manual_start: false,\n    ams_mapping: null,\n    plate_id: null,\n    bed_levelling: true,\n    flow_cali: false,\n    vibration_cali: true,\n    layer_inspect: false,\n    timelapse: false,\n    use_ams: true,\n    started_at: '2024-01-01T08:00:00Z',\n    completed_at: '2024-01-01T09:00:00Z',\n    error_message: null,\n    created_at: '2024-01-01T00:00:00Z',\n    archive_name: 'Completed Print',\n    archive_thumbnail: '/thumb3.png',\n    printer_name: 'Test Printer',\n    print_time_seconds: 1800,\n  },\n];\n\nconst mockPrinters = [\n  {\n    id: 1,\n    name: 'Test Printer',\n    ip_address: '192.168.1.100',\n    serial_number: 'TESTSERIAL0001',\n    access_code: '12345678',\n    model: 'X1C',\n    enabled: true,\n    created_at: '2024-01-01T00:00:00Z',\n  },\n];\n\ndescribe('QueuePage', () => {\n  beforeEach(() => {\n    // Mock localStorage.getItem to return expected defaults for queue page\n    vi.mocked(localStorage.getItem).mockImplementation((key: string) => {\n      if (key === 'queue.historyCollapsed') return 'false'; // expanded\n      if (key === 'queue.viewMode') return 'list';\n      return null;\n    });\n\n    // Setup MSW handlers for this test\n    server.use(\n      http.get('/api/v1/queue/', () => {\n        return HttpResponse.json(mockQueueItems);\n      }),\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json(mockPrinters);\n      }),\n      http.delete('/api/v1/queue/:id', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.post('/api/v1/queue/:id/cancel', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.post('/api/v1/queue/:id/start', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.post('/api/v1/queue/:id/stop', () => {\n        return HttpResponse.json({ success: true });\n      }),\n      http.post('/api/v1/queue/reorder', () => {\n        return HttpResponse.json({ success: true });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page title', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Print Queue')).toBeInTheDocument();\n      });\n    });\n\n    it('renders the page description', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Schedule and manage your print jobs')).toBeInTheDocument();\n      });\n    });\n\n    it('shows summary cards', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        // Check for the page title (Print Queue is the h1)\n        expect(screen.getByText('Print Queue')).toBeInTheDocument();\n      });\n    });\n\n    it('shows filter dropdowns', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('All Printers')).toBeInTheDocument();\n        expect(screen.getByText('All Status')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('queue items display', () => {\n    it('shows pending queue items', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Test Print 1')).toBeInTheDocument();\n      });\n    });\n\n    it('shows active printing items', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Active Print')).toBeInTheDocument();\n        expect(screen.getByText('Currently Printing')).toBeInTheDocument();\n      });\n    });\n\n    it('shows completed items in history', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Completed Print')).toBeInTheDocument();\n      });\n    });\n\n    it('shows status badges', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        // Queue items should be visible with status indicators\n        expect(screen.getByText('Test Print 1')).toBeInTheDocument();\n      });\n    });\n\n    it('shows printer names', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        const printerElements = screen.getAllByText('Test Printer');\n        expect(printerElements.length).toBeGreaterThan(0);\n      });\n    });\n\n    it('renders queue items with plate_id correctly', async () => {\n      // Override with queue items that have plate_id set\n      server.use(\n        http.get('/api/v1/queue/', () => {\n          return HttpResponse.json([\n            {\n              ...mockQueueItems[0],\n              plate_id: 2,\n              archive_name: 'Multi-plate Print',\n            },\n          ]);\n        })\n      );\n\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Multi-plate Print')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('empty state', () => {\n    it('shows empty state when no queue items', async () => {\n      server.use(\n        http.get('/api/v1/queue/', () => {\n          return HttpResponse.json([]);\n        })\n      );\n\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('No prints scheduled')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('filtering', () => {\n    it('has printer filter options', async () => {\n      const user = userEvent.setup();\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('All Printers')).toBeInTheDocument();\n      });\n\n      const printerSelect = screen.getByDisplayValue('All Printers');\n      await user.click(printerSelect);\n\n      expect(screen.getByText('Unassigned')).toBeInTheDocument();\n    });\n\n    it('has status filter options', async () => {\n      const user = userEvent.setup();\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('All Status')).toBeInTheDocument();\n      });\n\n      const statusSelect = screen.getByDisplayValue('All Status');\n      await user.click(statusSelect);\n\n      expect(screen.getByRole('option', { name: 'Pending' })).toBeInTheDocument();\n      expect(screen.getByRole('option', { name: 'Printing' })).toBeInTheDocument();\n      expect(screen.getByRole('option', { name: 'Completed' })).toBeInTheDocument();\n    });\n  });\n\n  describe('queue actions', () => {\n    it('shows edit button for pending items', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Test Print 1')).toBeInTheDocument();\n      });\n\n      // Find the edit button (Pencil icon)\n      const editButtons = screen.getAllByTitle('Edit');\n      expect(editButtons.length).toBeGreaterThan(0);\n    });\n\n    it('shows cancel button for pending items', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Test Print 1')).toBeInTheDocument();\n      });\n\n      const cancelButtons = screen.getAllByTitle('Cancel');\n      expect(cancelButtons.length).toBeGreaterThan(0);\n    });\n\n    it('shows stop button for printing items', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Active Print')).toBeInTheDocument();\n      });\n\n      const stopButtons = screen.getAllByTitle('Stop Print');\n      expect(stopButtons.length).toBeGreaterThan(0);\n    });\n\n    it('shows re-queue button for history items', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Completed Print')).toBeInTheDocument();\n      });\n\n      const requeueButtons = screen.getAllByTitle('Re-queue');\n      expect(requeueButtons.length).toBeGreaterThan(0);\n    });\n  });\n\n  describe('clear history', () => {\n    it('shows clear history button when history exists', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Clear History')).toBeInTheDocument();\n      });\n    });\n\n    it('opens confirm modal when clicking clear history', async () => {\n      const user = userEvent.setup();\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Clear History')).toBeInTheDocument();\n      });\n\n      const clearButton = screen.getByRole('button', { name: /clear history/i });\n      await user.click(clearButton);\n\n      await waitFor(() => {\n        expect(screen.getByText(/Are you sure you want to remove all/i)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('staged items', () => {\n    it('shows staged badge for manual_start items', async () => {\n      server.use(\n        http.get('/api/v1/queue/', () => {\n          return HttpResponse.json([\n            {\n              ...mockQueueItems[0],\n              manual_start: true,\n            },\n          ]);\n        })\n      );\n\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Staged')).toBeInTheDocument();\n      });\n    });\n\n    it('shows start button for staged items', async () => {\n      server.use(\n        http.get('/api/v1/queue/', () => {\n          return HttpResponse.json([\n            {\n              ...mockQueueItems[0],\n              manual_start: true,\n            },\n          ]);\n        })\n      );\n\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByTitle('Start Print')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('auto power off badge', () => {\n    it('shows power off badge when auto_off_after is true', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Auto power off')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('gcode injection badge', () => {\n    it('shows G-code badge when gcode_injection is true', async () => {\n      const itemsWithGcode = mockQueueItems.map((item, i) =>\n        i === 0 ? { ...item, gcode_injection: true } : item\n      );\n      server.use(\n        http.get('/api/v1/queue/', () => HttpResponse.json(itemsWithGcode)),\n      );\n\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('G-code')).toBeInTheDocument();\n      });\n    });\n\n    it('does not show G-code badge when gcode_injection is false', async () => {\n      render(<QueuePage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Test Print 1')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByText('G-code')).not.toBeInTheDocument();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/SettingsPage.test.tsx",
    "content": "/**\n * Tests for the SettingsPage component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { render } from '../utils';\nimport { SettingsPage } from '../../pages/SettingsPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\nconst mockSettings = {\n  auto_archive: true,\n  save_thumbnails: true,\n  capture_finish_photo: true,\n  default_filament_cost: 25.0,\n  currency: 'USD',\n  ams_humidity_good: 40,\n  ams_humidity_fair: 60,\n  ams_temp_good: 30,\n  ams_temp_fair: 35,\n  time_format: 'system',\n  date_format: 'system',\n  mqtt_enabled: false,\n  mqtt_host: '',\n  mqtt_port: 1883,\n  spoolman_enabled: false,\n  spoolman_url: '',\n  ha_enabled: false,\n  ha_url: '',\n  ha_token: '',\n  check_updates: false,\n  check_printer_firmware: false,\n  bed_cooled_threshold: 35,\n};\n\ndescribe('SettingsPage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/settings/', () => {\n        return HttpResponse.json(mockSettings);\n      }),\n      http.patch('/api/v1/settings/', async ({ request }) => {\n        const body = await request.json();\n        return HttpResponse.json({ ...mockSettings, ...body });\n      }),\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/smart-plugs/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/notifications/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/api-keys/', () => {\n        return HttpResponse.json([]);\n      }),\n      http.get('/api/v1/mqtt/status', () => {\n        return HttpResponse.json({ enabled: false });\n      }),\n      http.get('/api/v1/virtual-printer/status', () => {\n        return HttpResponse.json({ running: false });\n      }),\n      http.get('/api/v1/auth/status', () => {\n        return HttpResponse.json({ auth_enabled: false, requires_setup: false });\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page title', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        // Use role-based query to avoid conflicts with dropdown options\n        expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();\n      });\n    });\n\n    it('shows settings tabs', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        // Use getAllByText since \"General\" appears both as tab and section heading\n        expect(screen.getAllByText('General').length).toBeGreaterThan(0);\n        expect(screen.getByText('Smart Plugs')).toBeInTheDocument();\n        expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);\n        expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);\n        expect(screen.getByText('Network')).toBeInTheDocument();\n        expect(screen.getByText('API Keys')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('general settings', () => {\n    it('shows date format setting', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Date Format')).toBeInTheDocument();\n      });\n    });\n\n    it('shows time format setting', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Time Format')).toBeInTheDocument();\n      });\n    });\n\n    it('shows default printer setting', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Default Printer')).toBeInTheDocument();\n      });\n    });\n\n    it('shows preferred slicer setting', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();\n      });\n    });\n\n    it('shows slicer dropdown with both options', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');\n        expect(slicerSelect.length).toBeGreaterThan(0);\n      });\n    });\n\n    it('shows appearance section', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Appearance')).toBeInTheDocument();\n      });\n    });\n\n    it('shows updates section with firmware toggle', async () => {\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Updates')).toBeInTheDocument();\n        expect(screen.getByText('Check for updates')).toBeInTheDocument();\n        expect(screen.getByText('Check printer firmware')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('tabs navigation', () => {\n    it('can switch to Network tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      // Wait for settings to load first\n      await waitFor(() => {\n        expect(screen.getByText('Date Format')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Network'));\n\n      await waitFor(() => {\n        // Network tab contains MQTT Publishing section\n        expect(screen.getByText('MQTT Publishing')).toBeInTheDocument();\n      });\n    });\n\n    it('can switch to Smart Plugs tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Smart Plugs')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Smart Plugs'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Add Smart Plug')).toBeInTheDocument();\n      });\n    });\n\n    it('can switch to Notifications tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);\n      });\n\n      // Click the tab button (not the mobile dropdown option)\n      const notificationButtons = screen.getAllByText('Notifications');\n      const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];\n      await user.click(tabButton);\n\n      await waitFor(() => {\n        expect(screen.getByText('Add Provider')).toBeInTheDocument();\n      });\n    });\n\n    it('can switch to Filament tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);\n      });\n\n      await user.click(screen.getAllByText('Filament')[0]);\n\n      await waitFor(() => {\n        expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('Workflow tab', () => {\n    it('can switch to Workflow tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Workflow')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Workflow'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Staggered Start')).toBeInTheDocument();\n      });\n    });\n\n    it('shows stagger settings on Workflow tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Workflow')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Workflow'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Staggered Start')).toBeInTheDocument();\n        expect(screen.getByText('Group size')).toBeInTheDocument();\n        expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();\n      });\n    });\n\n    it('shows auto-drying settings on Workflow tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Workflow')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Workflow'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();\n      });\n    });\n\n    it('shows default print options on Workflow tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Workflow')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Workflow'));\n\n      await waitFor(() => {\n        expect(screen.getByText('Default Print Options')).toBeInTheDocument();\n        expect(screen.getByText('Bed Levelling')).toBeInTheDocument();\n        expect(screen.getByText('Flow Calibration')).toBeInTheDocument();\n        expect(screen.getByText('Vibration Calibration')).toBeInTheDocument();\n        expect(screen.getByText('First Layer Inspection')).toBeInTheDocument();\n        expect(screen.getByText('Timelapse')).toBeInTheDocument();\n      });\n    });\n\n    it('shows default print options description', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Workflow')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('Workflow'));\n\n      await waitFor(() => {\n        expect(screen.getByText(/overridden per print in the print dialog/)).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('API Keys tab', () => {\n    it('can switch to API Keys tab', async () => {\n      const user = userEvent.setup();\n      render(<SettingsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('API Keys')).toBeInTheDocument();\n      });\n\n      await user.click(screen.getByText('API Keys'));\n\n      await waitFor(() => {\n        // Button text is \"Create Key\"\n        expect(screen.getByText('Create Key')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('SpoolBuddy tab badge', () => {\n    const baseDevice = {\n      id: 1,\n      device_id: 'sb-0001',\n      hostname: 'sb-kitchen',\n      ip_address: '10.0.0.1',\n      backend_url: null,\n      firmware_version: '1.0.0',\n      has_nfc: true,\n      has_scale: true,\n      tare_offset: 0,\n      calibration_factor: 1.0,\n      nfc_reader_type: null,\n      nfc_connection: null,\n      display_brightness: 100,\n      display_blank_timeout: 0,\n      has_backlight: false,\n      last_calibrated_at: null,\n      last_seen: new Date().toISOString(),\n      pending_command: null,\n      nfc_ok: true,\n      scale_ok: true,\n      uptime_s: 100,\n      update_status: null,\n      update_message: null,\n      system_stats: null,\n      online: true,\n      created_at: '2024-01-01T00:00:00Z',\n      updated_at: '2024-01-01T00:00:00Z',\n    };\n\n    it('shows device count and green bullet when at least one device is online', async () => {\n      server.use(\n        http.get('/api/v1/spoolbuddy/devices', () => {\n          return HttpResponse.json([\n            { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true },\n            { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false },\n          ]);\n        })\n      );\n      render(<SettingsPage />);\n\n      // Find the tab button (not the header) — it's the <button> containing the SpoolBuddy text\n      const tabButton = await waitFor(() => {\n        const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));\n        expect(buttons.length).toBeGreaterThan(0);\n        return buttons[0];\n      });\n\n      // Count pill rendered\n      await waitFor(() => {\n        expect(tabButton.textContent).toContain('2');\n      });\n\n      // Green status bullet (at least one device online)\n      await waitFor(() => {\n        expect(tabButton.querySelector('.bg-green-400')).not.toBeNull();\n      });\n    });\n\n    it('shows gray bullet when all devices are offline', async () => {\n      server.use(\n        http.get('/api/v1/spoolbuddy/devices', () => {\n          return HttpResponse.json([{ ...baseDevice, online: false }]);\n        })\n      );\n      render(<SettingsPage />);\n\n      const tabButton = await waitFor(() => {\n        const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));\n        expect(buttons.length).toBeGreaterThan(0);\n        return buttons[0];\n      });\n\n      await waitFor(() => {\n        expect(tabButton.querySelector('.bg-gray-500')).not.toBeNull();\n        expect(tabButton.querySelector('.bg-green-400')).toBeNull();\n      });\n    });\n\n    it('hides the count pill when no devices are registered', async () => {\n      server.use(\n        http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([]))\n      );\n      render(<SettingsPage />);\n\n      const tabButton = await waitFor(() => {\n        const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));\n        expect(buttons.length).toBeGreaterThan(0);\n        return buttons[0];\n      });\n\n      // The only numeric content should NOT be present — tab label only\n      await waitFor(() => {\n        expect(tabButton.textContent).toBe('SpoolBuddy');\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/SpoolBuddyAmsPageLogic.test.ts",
    "content": "/**\n * Tests for SpoolBuddy AMS page logic:\n * - External slot active state (tray_now=255 bug fix)\n * - Fill level override fallback chain (inventory → AMS remain)\n *\n * These mirror inline logic from SpoolBuddyAmsPage.tsx, extracted for testability.\n */\nimport { describe, it, expect } from 'vitest';\n\n/**\n * Mirrors the ext slot isExtActive calculation from SpoolBuddyAmsPage.tsx.\n * tray_now=255 means \"no tray loaded\" (idle) — should never mark any slot active.\n */\nfunction computeExtActive(\n  trayNow: number,\n  isDualNozzle: boolean,\n  extTrayId: number,\n  activeExtruder: number | undefined,\n): boolean {\n  return trayNow === 255 ? false\n    : isDualNozzle && trayNow === 254\n      ? (extTrayId === 254 && activeExtruder === 1) ||\n        (extTrayId === 255 && activeExtruder === 0)\n      : trayNow === extTrayId;\n}\n\n/**\n * Mirrors the effective fill fallback from SpoolBuddyAmsPage.tsx and AmsUnitCard.tsx.\n * Priority: inventory fill override → AMS remain (if >= 0)\n */\nfunction computeEffectiveFill(\n  fillOverride: number | null,\n  amsRemain: number | null | undefined,\n): number | null {\n  const amsFill = amsRemain != null && amsRemain >= 0 ? amsRemain : null;\n  return fillOverride ?? amsFill;\n}\n\ndescribe('ext slot active state', () => {\n  describe('tray_now=255 (idle) — no slot should be active', () => {\n    it('single-nozzle: ext (id=254) not active when tray_now=255', () => {\n      expect(computeExtActive(255, false, 254, undefined)).toBe(false);\n    });\n\n    it('dual-nozzle: ext-L (id=254) not active when tray_now=255', () => {\n      expect(computeExtActive(255, true, 254, 1)).toBe(false);\n    });\n\n    it('dual-nozzle: ext-R (id=255) not active when tray_now=255', () => {\n      // This was the bug: trayNow(255) === extTrayId(255) without the guard\n      expect(computeExtActive(255, true, 255, 0)).toBe(false);\n    });\n  });\n\n  describe('tray_now=254 on dual-nozzle — uses active_extruder', () => {\n    it('ext-L active when active_extruder=1 (left)', () => {\n      expect(computeExtActive(254, true, 254, 1)).toBe(true);\n    });\n\n    it('ext-R active when active_extruder=0 (right)', () => {\n      expect(computeExtActive(254, true, 255, 0)).toBe(true);\n    });\n\n    it('ext-L not active when active_extruder=0 (right)', () => {\n      expect(computeExtActive(254, true, 254, 0)).toBe(false);\n    });\n\n    it('ext-R not active when active_extruder=1 (left)', () => {\n      expect(computeExtActive(254, true, 255, 1)).toBe(false);\n    });\n  });\n\n  describe('tray_now=254 on single-nozzle — direct ID match', () => {\n    it('ext (id=254) active when tray_now=254', () => {\n      expect(computeExtActive(254, false, 254, undefined)).toBe(true);\n    });\n  });\n\n  describe('AMS tray active — ext slots not active', () => {\n    it('ext not active when AMS slot is active (tray_now=5)', () => {\n      expect(computeExtActive(5, false, 254, undefined)).toBe(false);\n    });\n  });\n});\n\ndescribe('fill level override fallback', () => {\n  it('uses inventory fill when available, ignoring AMS remain', () => {\n    expect(computeEffectiveFill(75, 50)).toBe(75);\n  });\n\n  it('falls back to AMS remain when no inventory fill', () => {\n    expect(computeEffectiveFill(null, 50)).toBe(50);\n  });\n\n  it('returns null when neither source available', () => {\n    expect(computeEffectiveFill(null, null)).toBeNull();\n  });\n\n  it('returns null when AMS remain is -1 (unknown) and no inventory fill', () => {\n    expect(computeEffectiveFill(null, -1)).toBeNull();\n  });\n\n  it('uses inventory fill even when AMS remain is -1', () => {\n    expect(computeEffectiveFill(80, -1)).toBe(80);\n  });\n\n  it('uses AMS remain of 0 (empty) as valid fill', () => {\n    expect(computeEffectiveFill(null, 0)).toBe(0);\n  });\n\n  it('uses inventory fill of 0 over AMS remain', () => {\n    expect(computeEffectiveFill(0, 50)).toBe(0);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/SpoolBuddyCalibrationPage.test.tsx",
    "content": "/**\n * Tests for SpoolBuddyCalibrationPage:\n * - Renders \"Scale Calibration\" heading\n * - Shows current weight display\n * - Shows Tare and Calibrate buttons\n * - Shows \"No SpoolBuddy device found\" when no device\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport React from 'react';\nimport { render } from '@testing-library/react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';\nimport { SpoolBuddyCalibrationPage } from '../../pages/spoolbuddy/SpoolBuddyCalibrationPage';\n\nconst mockDevice = {\n  id: 1,\n  device_id: 'sb-test-001',\n  hostname: 'spoolbuddy-pi',\n  ip_address: '192.168.1.100',\n  firmware_version: '1.2.3',\n  has_nfc: true,\n  has_scale: true,\n  tare_offset: 0,\n  calibration_factor: 1.0,\n  nfc_reader_type: 'PN532',\n  nfc_connection: 'I2C',\n  display_brightness: 80,\n  display_blank_timeout: 300,\n  has_backlight: true,\n  last_calibrated_at: null,\n  last_seen: '2026-03-22T12:00:00Z',\n  pending_command: null,\n  nfc_ok: true,\n  scale_ok: true,\n  uptime_s: 3600,\n  update_status: null,\n  update_message: null,\n  online: true,\n};\n\nlet mockDevices = [mockDevice];\n\nvi.mock('../../api/client', () => ({\n  spoolbuddyApi: {\n    getDevices: vi.fn(() => Promise.resolve(mockDevices)),\n    tare: vi.fn().mockResolvedValue({ status: 'ok' }),\n    setCalibrationFactor: vi.fn().mockResolvedValue({ status: 'ok' }),\n  },\n}));\n\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (_key: string, fallback: string) => fallback,\n    i18n: { language: 'en', changeLanguage: vi.fn() },\n  }),\n}));\n\nfunction makeOutletContext(overrides: Record<string, unknown> = {}) {\n  return {\n    selectedPrinterId: null,\n    setSelectedPrinterId: vi.fn(),\n    sbState: {\n      weight: 250.5,\n      weightStable: true,\n      rawAdc: 12345,\n      matchedSpool: null,\n      unknownTagUid: null,\n      deviceOnline: true,\n      deviceId: 'sb-test-001',\n      remainingWeight: null,\n      netWeight: null,\n      ...(overrides.sbState as Record<string, unknown> || {}),\n    },\n    setAlert: vi.fn(),\n    displayBrightness: 100,\n    setDisplayBrightness: vi.fn(),\n    displayBlankTimeout: 0,\n    setDisplayBlankTimeout: vi.fn(),\n  };\n}\n\nfunction renderPage(contextOverrides: Record<string, unknown> = {}) {\n  const ctx = makeOutletContext(contextOverrides);\n  function Wrapper() {\n    return <Outlet context={ctx} />;\n  }\n  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });\n  return render(\n    <QueryClientProvider client={qc}>\n      <MemoryRouter initialEntries={['/spoolbuddy/calibration']}>\n        <Routes>\n          <Route element={<Wrapper />}>\n            <Route path=\"spoolbuddy/calibration\" element={<SpoolBuddyCalibrationPage />} />\n          </Route>\n        </Routes>\n      </MemoryRouter>\n    </QueryClientProvider>\n  );\n}\n\ndescribe('SpoolBuddyCalibrationPage', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    mockDevices = [mockDevice];\n  });\n\n  it('renders \"Scale Calibration\" heading', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Scale Calibration')).toBeDefined();\n    });\n  });\n\n  it('shows current weight display when device available', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Current weight')).toBeDefined();\n      expect(screen.getByText('250.5 g')).toBeDefined();\n    });\n  });\n\n  it('shows Tare and Calibrate buttons when device available', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Tare')).toBeDefined();\n      expect(screen.getByText('Calibrate')).toBeDefined();\n    });\n  });\n\n  it('shows \"No SpoolBuddy device found\" when no device', async () => {\n    mockDevices = [];\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('No SpoolBuddy device found')).toBeDefined();\n    });\n  });\n\n  it('shows back button that navigates to settings', () => {\n    renderPage();\n    // Find the back button (contains a chevron SVG)\n    const buttons = screen.getAllByRole('button');\n    // First button is the back button\n    expect(buttons.length).toBeGreaterThan(0);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx",
    "content": "/**\n * Tests for SpoolBuddyDashboard:\n * - Shows stats bar (Spools, Materials, Brands)\n * - Shows \"Ready to scan\" idle state when no tag detected\n * - Shows device status section\n * - Shows \"Device Offline\" state when device offline\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport React from 'react';\nimport { render } from '@testing-library/react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';\nimport { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';\nimport { ToastProvider } from '../../contexts/ToastContext';\n\nvi.mock('../../api/client', () => ({\n  api: {\n    getSpools: vi.fn().mockResolvedValue([\n      { id: 1, material: 'PLA', brand: 'Bambu', tag_uid: 'AA:BB', archived_at: null, color_name: 'Red', rgba: 'FF0000FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 100 },\n      { id: 2, material: 'PETG', brand: 'Bambu', tag_uid: 'CC:DD', archived_at: null, color_name: 'Blue', rgba: '0000FFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 200 },\n      { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },\n    ]),\n    getPrinters: vi.fn().mockResolvedValue([]),\n    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),\n    linkTagToSpool: vi.fn().mockResolvedValue({}),\n    createSpool: vi.fn().mockResolvedValue({ id: 4 }),\n  },\n  spoolbuddyApi: {\n    getDevices: vi.fn().mockResolvedValue([]),\n  },\n}));\n\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (_key: string, fallback: string) => fallback,\n    i18n: { language: 'en', changeLanguage: vi.fn() },\n  }),\n}));\n\nconst mockOutletContext = {\n  selectedPrinterId: null,\n  setSelectedPrinterId: vi.fn(),\n  sbState: {\n    weight: null,\n    weightStable: false,\n    rawAdc: null,\n    matchedSpool: null,\n    unknownTagUid: null,\n    deviceOnline: true,\n    deviceId: 'dev-1',\n    remainingWeight: null,\n    netWeight: null,\n  },\n  setAlert: vi.fn(),\n  displayBrightness: 100,\n  setDisplayBrightness: vi.fn(),\n  displayBlankTimeout: 0,\n  setDisplayBlankTimeout: vi.fn(),\n};\n\nfunction renderPage(overrides: Partial<typeof mockOutletContext['sbState']> = {}) {\n  const ctx = {\n    ...mockOutletContext,\n    sbState: { ...mockOutletContext.sbState, ...overrides },\n  };\n  function Wrapper() {\n    return <Outlet context={ctx} />;\n  }\n  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });\n  return render(\n    <ToastProvider>\n      <QueryClientProvider client={qc}>\n        <MemoryRouter initialEntries={['/spoolbuddy']}>\n          <Routes>\n            <Route element={<Wrapper />}>\n              <Route path=\"spoolbuddy\" element={<SpoolBuddyDashboard />} />\n            </Route>\n          </Routes>\n        </MemoryRouter>\n      </QueryClientProvider>\n    </ToastProvider>\n  );\n}\n\ndescribe('SpoolBuddyDashboard', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('shows stats bar with spool count, materials, and brands', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Spools')).toBeDefined();\n      expect(screen.getByText('Materials')).toBeDefined();\n      expect(screen.getByText('Brands')).toBeDefined();\n      // Check that the stats numbers are rendered (3 spools, 3 materials, 2 brands)\n      const statNumbers = screen.getAllByText(/^[0-9]+$/);\n      expect(statNumbers.length).toBeGreaterThanOrEqual(3);\n    });\n  });\n\n  it('shows \"Ready to scan\" idle state when device online with no tag', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Ready to scan')).toBeDefined();\n      expect(screen.getByText('Place a spool on the scale to identify it')).toBeDefined();\n    });\n  });\n\n  it('shows device status section', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Device')).toBeDefined();\n    });\n  });\n\n  it('shows \"Online\" when device is online', async () => {\n    renderPage({ deviceOnline: true });\n    await waitFor(() => {\n      expect(screen.getByText('Online')).toBeDefined();\n    });\n  });\n\n  it('shows \"Device Offline\" state when device offline', async () => {\n    renderPage({ deviceOnline: false });\n    await waitFor(() => {\n      expect(screen.getByText('Device Offline')).toBeDefined();\n      expect(screen.getByText('Connect the SpoolBuddy display to scan spools')).toBeDefined();\n    });\n  });\n\n  it('shows current spool section heading', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Current Spool')).toBeDefined();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx",
    "content": "/**\n * Tests for SpoolBuddySettingsPage:\n * - Renders 5 tabs (Device, Display, Scale, Updates, System)\n * - Device tab shows hostname, IP, NFC status\n * - Updates tab shows \"Check for Updates\" button\n * - System tab shows OS stats\n * - Tab switching works\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport React from 'react';\nimport { render } from '@testing-library/react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';\nimport { SpoolBuddySettingsPage } from '../../pages/spoolbuddy/SpoolBuddySettingsPage';\n\nvi.mock('../../api/client', () => ({\n  spoolbuddyApi: {\n    getDevices: vi.fn().mockResolvedValue([{\n      id: 1,\n      device_id: 'sb-test-001',\n      hostname: 'spoolbuddy-pi',\n      ip_address: '192.168.1.100',\n      firmware_version: '1.2.3',\n      has_nfc: true,\n      has_scale: true,\n      tare_offset: 0,\n      calibration_factor: 1.0,\n      nfc_reader_type: 'PN532',\n      nfc_connection: 'I2C',\n      display_brightness: 80,\n      display_blank_timeout: 300,\n      has_backlight: true,\n      last_calibrated_at: null,\n      last_seen: '2026-03-22T12:00:00Z',\n      pending_command: null,\n      nfc_ok: true,\n      scale_ok: true,\n      uptime_s: 3600,\n      update_status: null,\n      update_message: null,\n      system_stats: {\n        os: { os: 'Raspbian GNU/Linux 12', kernel: '6.1.0-rpi7', arch: 'aarch64', python: '3.11.2' },\n        cpu_temp_c: 52.1,\n        cpu_count: 4,\n        load_avg: [0.15, 0.22, 0.18],\n        memory: { total_mb: 1024, available_mb: 512, used_mb: 512, percent: 50.0 },\n        disk: { total_gb: 29.7, used_gb: 8.2, free_gb: 21.5, percent: 27.6 },\n        system_uptime_s: 86400,\n      },\n      online: true,\n    }]),\n    updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),\n    tare: vi.fn().mockResolvedValue({ status: 'ok' }),\n    setCalibrationFactor: vi.fn().mockResolvedValue({ status: 'ok' }),\n    checkDaemonUpdate: vi.fn().mockResolvedValue({\n      current_version: '1.2.3',\n      latest_version: '1.2.3',\n      update_available: false,\n    }),\n    triggerUpdate: vi.fn().mockResolvedValue({ status: 'ok', message: '' }),\n    getSSHPublicKey: vi.fn().mockResolvedValue({ public_key: 'ssh-ed25519 AAAA test-key' }),\n  },\n}));\n\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (_key: string, fallback: string) => fallback,\n    i18n: { language: 'en', changeLanguage: vi.fn() },\n  }),\n}));\n\nconst mockOutletContext = {\n  selectedPrinterId: null,\n  setSelectedPrinterId: vi.fn(),\n  sbState: {\n    weight: 250.0,\n    weightStable: true,\n    rawAdc: 12345,\n    matchedSpool: null,\n    unknownTagUid: null,\n    deviceOnline: true,\n    deviceId: 'sb-test-001',\n    remainingWeight: null,\n    netWeight: null,\n  },\n  setAlert: vi.fn(),\n  displayBrightness: 80,\n  setDisplayBrightness: vi.fn(),\n};\n\nfunction OutletWrapper() {\n  return <Outlet context={mockOutletContext} />;\n}\n\nfunction renderPage() {\n  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });\n  return render(\n    <QueryClientProvider client={qc}>\n      <MemoryRouter initialEntries={['/spoolbuddy/settings']}>\n        <Routes>\n          <Route element={<OutletWrapper />}>\n            <Route path=\"spoolbuddy/settings\" element={<SpoolBuddySettingsPage />} />\n          </Route>\n        </Routes>\n      </MemoryRouter>\n    </QueryClientProvider>\n  );\n}\n\ndescribe('SpoolBuddySettingsPage', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('renders 5 tabs', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Device')).toBeDefined();\n      expect(screen.getByText('Display')).toBeDefined();\n      expect(screen.getByText('Scale')).toBeDefined();\n      expect(screen.getByText('Updates')).toBeDefined();\n      expect(screen.getByText('System')).toBeDefined();\n    });\n  });\n\n  it('device tab shows hostname and IP', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('spoolbuddy-pi')).toBeDefined();\n      expect(screen.getByText('192.168.1.100')).toBeDefined();\n    });\n  });\n\n  it('device tab shows NFC reader type', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('PN532')).toBeDefined();\n    });\n  });\n\n  it('device tab shows NFC status as Ready', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Ready')).toBeDefined();\n    });\n  });\n\n  it('switching to Updates tab shows Check for Updates and Force Update buttons', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Updates')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('Updates'));\n    await waitFor(() => {\n      expect(screen.getByText('Check for Updates')).toBeDefined();\n      expect(screen.getByText('Force Update')).toBeDefined();\n    });\n  });\n\n  it('Updates tab shows current version', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Updates')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('Updates'));\n    await waitFor(() => {\n      expect(screen.getByText('1.2.3')).toBeDefined();\n    });\n  });\n\n  it('Updates tab shows SSH Setup section', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Updates')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('Updates'));\n    await waitFor(() => {\n      expect(screen.getByText('SSH Setup')).toBeDefined();\n    });\n  });\n\n  it('Updates tab shows Apply Update when update is available', async () => {\n    const { spoolbuddyApi } = await import('../../api/client');\n    vi.mocked(spoolbuddyApi.checkDaemonUpdate).mockResolvedValue({\n      current_version: '1.2.3',\n      latest_version: '1.3.0',\n      update_available: true,\n    });\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Updates')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('Updates'));\n    await waitFor(() => {\n      expect(screen.getByText('Apply Update')).toBeDefined();\n    });\n  });\n\n  it('switching to Display tab shows Brightness', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Display')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('Display'));\n    await waitFor(() => {\n      expect(screen.getByText('Brightness')).toBeDefined();\n    });\n  });\n\n  it('switching to Scale tab shows Tare and Calibrate buttons', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('Scale')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('Scale'));\n    await waitFor(() => {\n      expect(screen.getByText('Tare')).toBeDefined();\n      expect(screen.getByText('Calibrate')).toBeDefined();\n    });\n  });\n\n  it('System tab shows CPU info', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('System')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('System'));\n    await waitFor(() => {\n      expect(screen.getByText('CPU')).toBeDefined();\n      expect(screen.getByText('4')).toBeDefined(); // cpu_count\n      expect(screen.getByText('0.15 / 0.22 / 0.18')).toBeDefined(); // load_avg\n    });\n  });\n\n  it('System tab shows memory stats', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('System')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('System'));\n    await waitFor(() => {\n      expect(screen.getByText('Memory')).toBeDefined();\n      expect(screen.getByText('512 / 1024 MB')).toBeDefined();\n    });\n  });\n\n  it('System tab shows disk stats', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('System')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('System'));\n    await waitFor(() => {\n      expect(screen.getByText('Disk')).toBeDefined();\n      expect(screen.getByText('8.2 / 29.7 GB')).toBeDefined();\n    });\n  });\n\n  it('System tab shows OS info', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('System')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('System'));\n    await waitFor(() => {\n      expect(screen.getByText('Raspbian GNU/Linux 12')).toBeDefined();\n      expect(screen.getByText('aarch64')).toBeDefined();\n      expect(screen.getByText('3.11.2')).toBeDefined();\n    });\n  });\n\n  it('System tab shows waiting message when no stats', async () => {\n    const { spoolbuddyApi } = await import('../../api/client');\n    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([{\n      id: 1,\n      device_id: 'sb-test-001',\n      hostname: 'spoolbuddy-pi',\n      ip_address: '192.168.1.100',\n      firmware_version: '1.2.3',\n      has_nfc: true,\n      has_scale: true,\n      tare_offset: 0,\n      calibration_factor: 1.0,\n      nfc_reader_type: 'PN532',\n      nfc_connection: 'I2C',\n      display_brightness: 80,\n      display_blank_timeout: 300,\n      has_backlight: true,\n      last_calibrated_at: null,\n      last_seen: '2026-03-22T12:00:00Z',\n      pending_command: null,\n      nfc_ok: true,\n      scale_ok: true,\n      uptime_s: 3600,\n      update_status: null,\n      update_message: null,\n      system_stats: null,\n      online: true,\n    }]);\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('System')).toBeDefined();\n    });\n    fireEvent.click(screen.getByText('System'));\n    await waitFor(() => {\n      expect(screen.getByText('Waiting for system stats...')).toBeDefined();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx",
    "content": "/**\n * Tests for SpoolBuddyWriteTagPage:\n * - Renders three workflow tabs\n * - Tab switching works\n * - Search input renders on existing/replace tabs\n * - New spool form renders on new tab\n * - NFC status panel shows correct idle state\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor, fireEvent } from '@testing-library/react';\nimport React from 'react';\nimport { render } from '@testing-library/react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';\nimport { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage';\n\n// Mock the API modules\nvi.mock('../../api/client', () => ({\n  api: {\n    getSpools: vi.fn().mockResolvedValue([]),\n    createSpool: vi.fn().mockResolvedValue({ id: 1, material: 'PLA' }),\n  },\n  spoolbuddyApi: {\n    getDevices: vi.fn().mockResolvedValue([]),\n    writeTag: vi.fn().mockResolvedValue({ status: 'queued' }),\n    cancelWrite: vi.fn().mockResolvedValue({ status: 'ok' }),\n  },\n}));\n\n// Mock i18n\nvi.mock('react-i18next', () => ({\n  useTranslation: () => ({\n    t: (key: string, fallback: string) => fallback,\n    i18n: { language: 'en', changeLanguage: vi.fn() },\n  }),\n}));\n\nconst mockOutletContext = {\n  selectedPrinterId: null,\n  setSelectedPrinterId: vi.fn(),\n  sbState: {\n    weight: null,\n    weightStable: false,\n    rawAdc: null,\n    matchedSpool: null,\n    unknownTagUid: null,\n    deviceOnline: false,\n    deviceId: null,\n    remainingWeight: null,\n    netWeight: null,\n  },\n  setAlert: vi.fn(),\n  displayBrightness: 100,\n  setDisplayBrightness: vi.fn(),\n  displayBlankTimeout: 0,\n  setDisplayBlankTimeout: vi.fn(),\n};\n\nfunction OutletWrapper() {\n  return <Outlet context={mockOutletContext} />;\n}\n\nfunction renderPage() {\n  const queryClient = new QueryClient({\n    defaultOptions: { queries: { retry: false, gcTime: 0 } },\n  });\n\n  return render(\n    <QueryClientProvider client={queryClient}>\n      <MemoryRouter initialEntries={['/spoolbuddy/write-tag']}>\n        <Routes>\n          <Route element={<OutletWrapper />}>\n            <Route path=\"spoolbuddy/write-tag\" element={<SpoolBuddyWriteTagPage />} />\n          </Route>\n        </Routes>\n      </MemoryRouter>\n    </QueryClientProvider>\n  );\n}\n\ndescribe('SpoolBuddyWriteTagPage', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('renders three workflow tabs', () => {\n    renderPage();\n    expect(screen.getByText('Existing Spool')).toBeDefined();\n    expect(screen.getByText('New Spool')).toBeDefined();\n    expect(screen.getByText('Replace Tag')).toBeDefined();\n  });\n\n  it('shows search input on existing spool tab', () => {\n    renderPage();\n    expect(screen.getByPlaceholderText('Search by material, color, brand...')).toBeDefined();\n  });\n\n  it('shows no spools message when list is empty', async () => {\n    renderPage();\n    await waitFor(() => {\n      expect(screen.getByText('No spools without tags')).toBeDefined();\n    });\n  });\n\n  it('switches to new spool form on tab click', async () => {\n    renderPage();\n    fireEvent.click(screen.getByText('New Spool'));\n    await waitFor(() => {\n      expect(screen.getByText('Material')).toBeDefined();\n      expect(screen.getByText('Color Name')).toBeDefined();\n      expect(screen.getByText('Brand')).toBeDefined();\n      expect(screen.getByText('Weight (g)')).toBeDefined();\n      expect(screen.getByText('Create Spool')).toBeDefined();\n    });\n  });\n\n  it('switches to replace tab and shows appropriate empty message', async () => {\n    renderPage();\n    fireEvent.click(screen.getByText('Replace Tag'));\n    await waitFor(() => {\n      expect(screen.getByText('No spools with tags')).toBeDefined();\n    });\n  });\n\n  it('shows device offline message in NFC panel', () => {\n    renderPage();\n    expect(screen.getByText('SpoolBuddy is offline')).toBeDefined();\n  });\n\n  it('shows idle prompt when device is online but no spool selected', () => {\n    mockOutletContext.sbState.deviceOnline = true;\n    renderPage();\n    expect(screen.getByText('Select a spool, then place a blank NTAG on the reader')).toBeDefined();\n    mockOutletContext.sbState.deviceOnline = false; // reset\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/StatsPage.test.tsx",
    "content": "/**\n * Tests for the StatsPage component.\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { StatsPage } from '../../pages/StatsPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\n\n// Complete mock stats matching ArchiveStats interface\nconst mockStats = {\n  total_prints: 150,\n  successful_prints: 140,\n  failed_prints: 10,\n  total_print_time_hours: 500.5,\n  total_filament_grams: 5500,\n  total_cost: 125.50,\n  prints_by_filament_type: {\n    'PLA': 80,\n    'PETG': 50,\n    'ABS': 20,\n  },\n  prints_by_printer: {\n    '1': 100,\n    '2': 50,\n  },\n  average_time_accuracy: 98.5,\n  time_accuracy_by_printer: {\n    '1': 99.0,\n    '2': 97.0,\n  },\n  total_energy_kwh: 45.5,\n  total_energy_cost: 12.50,\n};\n\nconst mockPrinters = [\n  { id: 1, name: 'X1 Carbon', model: 'X1C', enabled: true },\n  { id: 2, name: 'P1S', model: 'P1S', enabled: true },\n];\n\nconst mockArchives = [\n  {\n    id: 1,\n    created_at: '2024-01-01T10:00:00Z',\n    started_at: '2024-01-01T10:00:00Z',\n    completed_at: '2024-01-01T14:30:00Z',\n    print_name: 'Benchy',\n    status: 'completed',\n    printer_id: 1,\n    filament_type: 'PLA',\n    filament_color: '#00FF00',\n    filament_used_grams: 25,\n    actual_time_seconds: 16200,\n    print_time_seconds: 15000,\n    cost: 0.75,\n    quantity: 1,\n  },\n  {\n    id: 2,\n    created_at: '2024-01-02T14:00:00Z',\n    started_at: '2024-01-02T14:00:00Z',\n    completed_at: '2024-01-02T22:00:00Z',\n    print_name: 'Large Vase',\n    status: 'completed',\n    printer_id: 1,\n    filament_type: 'PETG',\n    filament_color: '#FF0000',\n    filament_used_grams: 180,\n    actual_time_seconds: 28800,\n    print_time_seconds: 27000,\n    cost: 5.40,\n    quantity: 1,\n  },\n  {\n    id: 3,\n    created_at: '2024-01-03T08:00:00Z',\n    started_at: '2024-01-03T08:00:00Z',\n    completed_at: null,\n    print_name: 'Failed Bracket',\n    status: 'failed',\n    printer_id: 2,\n    filament_type: 'ABS',\n    filament_color: '#0000FF',\n    filament_used_grams: 10,\n    actual_time_seconds: 3600,\n    print_time_seconds: 7200,\n    cost: 0.30,\n    quantity: 1,\n  },\n  {\n    id: 4,\n    created_at: '2024-01-03T20:00:00Z',\n    started_at: '2024-01-03T20:00:00Z',\n    completed_at: '2024-01-04T02:00:00Z',\n    print_name: 'Phone Stand',\n    status: 'completed',\n    printer_id: 2,\n    filament_type: 'PLA',\n    filament_color: '#00FF00',\n    filament_used_grams: 45,\n    actual_time_seconds: 21600,\n    print_time_seconds: 20000,\n    cost: 1.35,\n    quantity: 1,\n  },\n];\n\nconst mockSettings = {\n  currency: 'USD',\n  check_updates: false,\n  check_printer_firmware: false,\n};\n\nconst mockFailureAnalysis = {\n  period_days: 30,\n  total_prints: 100,\n  failed_prints: 5,\n  failure_rate: 5.0,\n  failures_by_reason: {\n    'First layer adhesion': 3,\n    'Filament runout': 2,\n  },\n  failures_by_filament: {\n    'ABS': 3,\n    'PLA': 2,\n  },\n  failures_by_printer: {\n    '1': 2,\n    '2': 3,\n  },\n  failures_by_hour: {},\n  recent_failures: [],\n  trend: [\n    { week_start: '2024-01-01', total_prints: 50, failed_prints: 3, failure_rate: 6.0 },\n    { week_start: '2024-01-08', total_prints: 50, failed_prints: 2, failure_rate: 5.0 },\n  ],\n};\n\ndescribe('StatsPage', () => {\n  beforeEach(() => {\n    server.use(\n      http.get('/api/v1/archives/stats', () => {\n        return HttpResponse.json(mockStats);\n      }),\n      http.get('/api/v1/printers/', () => {\n        return HttpResponse.json(mockPrinters);\n      }),\n      http.get('/api/v1/archives/slim', () => {\n        return HttpResponse.json(mockArchives);\n      }),\n      http.get('/api/v1/settings/', () => {\n        return HttpResponse.json(mockSettings);\n      }),\n      http.get('/api/v1/archives/analysis/failures', () => {\n        return HttpResponse.json(mockFailureAnalysis);\n      })\n    );\n  });\n\n  describe('rendering', () => {\n    it('renders the page title', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Dashboard')).toBeInTheDocument();\n      });\n    });\n\n    it('shows quick stats widget', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Quick Stats')).toBeInTheDocument();\n      });\n    });\n\n    it('shows total prints stat', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Total Prints')).toBeInTheDocument();\n        expect(screen.getByText('150')).toBeInTheDocument();\n      });\n    });\n\n    it('shows print time stat', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Print Time')).toBeInTheDocument();\n        expect(screen.getByText('500.5h')).toBeInTheDocument();\n      });\n    });\n\n    it('shows filament used stat', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Filament Used')).toBeInTheDocument();\n        expect(screen.getByText('5.5kg')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('success rate', () => {\n    it('shows success rate widget', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Success Rate')).toBeInTheDocument();\n        // Success rate: 140/(140+10) = 93%\n        expect(screen.getByText('93%')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('cost display', () => {\n    it('shows filament cost', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Filament Cost')).toBeInTheDocument();\n      });\n    });\n\n    it('shows energy cost', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Energy Cost')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('widgets', () => {\n    it('shows time accuracy widget', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Time Accuracy')).toBeInTheDocument();\n      });\n    });\n\n    it('shows print activity widget', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Print Activity')).toBeInTheDocument();\n      });\n    });\n\n    it('shows failure analysis widget', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Failure Analysis')).toBeInTheDocument();\n      });\n    });\n\n    it('shows printer stats widget', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Printer Stats')).toBeInTheDocument();\n      });\n    });\n\n    it('shows filament trends widget', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Filament Trends')).toBeInTheDocument();\n      });\n    });\n\n    it('shows records widget', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Records')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('printer stats sub-cards', () => {\n    it('shows prints by printer section', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Prints by Printer')).toBeInTheDocument();\n      });\n    });\n\n    it('shows print duration section', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Print Duration')).toBeInTheDocument();\n      });\n    });\n\n    it('shows print habits section', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Print Habits')).toBeInTheDocument();\n      });\n    });\n\n    it('shows print time of day section', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Print Time of Day')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('filament trends sub-cards', () => {\n    it('shows by material section', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('By Material')).toBeInTheDocument();\n      });\n    });\n\n    it('shows success by material section', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Success by Material')).toBeInTheDocument();\n      });\n    });\n\n    it('shows color distribution section', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Color Distribution')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('records widget', () => {\n    it('shows longest print record', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Longest Print')).toBeInTheDocument();\n      });\n    });\n\n    it('shows heaviest print record', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Heaviest Print')).toBeInTheDocument();\n      });\n    });\n\n    it('shows most expensive record', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Most Expensive')).toBeInTheDocument();\n      });\n    });\n\n    it('shows success streak record', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Success Streak')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('export', () => {\n    it('has export button', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Export Stats')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('recalculate costs', () => {\n    it('has recalculate costs button', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Recalculate Costs')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('user filter', () => {\n    it('does not show user filter dropdown when auth is disabled', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Quick Stats')).toBeInTheDocument();\n      });\n\n      // Auth is disabled in our test setup (default), so user filter should not appear\n      // The filter requires authEnabled && hasPermission('stats:filter_by_user')\n      expect(screen.queryByText('All Users')).not.toBeInTheDocument();\n    });\n  });\n\n  describe('energy warming-up indicator (#941)', () => {\n    it('does not show a warning icon when energy data is available', async () => {\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Energy Used')).toBeInTheDocument();\n      });\n\n      const energyLabel = screen.getByText('Energy Used').closest('div');\n      expect(energyLabel?.querySelector('svg[aria-label]')).toBeNull();\n    });\n\n    it('shows a warning icon with tooltip next to energy stats when warming up', async () => {\n      server.use(\n        http.get('/api/v1/archives/stats', () => {\n          return HttpResponse.json({ ...mockStats, energy_data_warming_up: true });\n        })\n      );\n\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Energy Used')).toBeInTheDocument();\n      });\n\n      // Both Energy Used and Energy Cost labels get a warning icon with the\n      // tooltip accessible via aria-label.\n      const icons = await screen.findAllByLabelText(/still collecting hourly snapshots/i);\n      expect(icons.length).toBe(2);\n    });\n\n    it('does not decorate other stats with the energy warming-up warning', async () => {\n      server.use(\n        http.get('/api/v1/archives/stats', () => {\n          return HttpResponse.json({ ...mockStats, energy_data_warming_up: true });\n        })\n      );\n\n      render(<StatsPage />);\n\n      await waitFor(() => {\n        expect(screen.getByText('Total Prints')).toBeInTheDocument();\n      });\n\n      const totalPrints = screen.getByText('Total Prints').closest('div');\n      expect(totalPrints?.querySelector('svg[aria-label]')).toBeNull();\n      const printTime = screen.getByText('Print Time').closest('div');\n      expect(printTime?.querySelector('svg[aria-label]')).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/StreamOverlayPage.test.tsx",
    "content": "/**\n * Tests for the StreamOverlayPage component.\n */\n\nimport { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { screen, waitFor, render as rtlRender } from '@testing-library/react';\nimport { StreamOverlayPage } from '../../pages/StreamOverlayPage';\nimport { http, HttpResponse } from 'msw';\nimport { server } from '../mocks/server';\nimport { MemoryRouter, Route, Routes } from 'react-router-dom';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { ThemeProvider } from '../../contexts/ThemeContext';\nimport { ToastProvider } from '../../contexts/ToastContext';\n\nconst mockPrinter = {\n  id: 1,\n  name: 'X1 Carbon',\n  ip_address: '192.168.1.100',\n  serial_number: '00M09A350100001',\n  access_code: '12345678',\n  model: 'X1C',\n  enabled: true,\n};\n\nconst mockStatusIdle = {\n  id: 1,\n  name: 'X1 Carbon',\n  connected: true,\n  state: 'IDLE',\n  progress: 0,\n  current_print: null,\n  remaining_time: null,\n  layer_num: null,\n  total_layers: null,\n  stg_cur_name: null,\n};\n\nconst mockStatusPrinting = {\n  id: 1,\n  name: 'X1 Carbon',\n  connected: true,\n  state: 'RUNNING',\n  progress: 45,\n  current_print: 'Benchy.gcode.3mf',\n  remaining_time: 82,\n  layer_num: 150,\n  total_layers: 300,\n  stg_cur_name: null,\n};\n\n// Custom render for StreamOverlayPage\nfunction renderOverlayPage(printerId: number, queryParams = '') {\n  const queryClient = new QueryClient({\n    defaultOptions: {\n      queries: { retry: false, gcTime: 0 },\n      mutations: { retry: false },\n    },\n  });\n\n  return rtlRender(\n    <QueryClientProvider client={queryClient}>\n      <MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>\n        <ThemeProvider>\n          <ToastProvider>\n            <Routes>\n              <Route path=\"/overlay/:printerId\" element={<StreamOverlayPage />} />\n            </Routes>\n          </ToastProvider>\n        </ThemeProvider>\n      </MemoryRouter>\n    </QueryClientProvider>\n  );\n}\n\ndescribe('StreamOverlayPage', () => {\n  const originalTitle = document.title;\n\n  beforeEach(() => {\n    // Mock WebSocket\n    vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({\n      close: vi.fn(),\n      onmessage: null,\n      onerror: null,\n    })));\n\n    server.use(\n      http.get('/api/v1/printers/:id', () => {\n        return HttpResponse.json(mockPrinter);\n      }),\n      http.get('/api/v1/printers/:id/status', () => {\n        return HttpResponse.json(mockStatusIdle);\n      })\n    );\n  });\n\n  afterEach(() => {\n    document.title = originalTitle;\n    vi.unstubAllGlobals();\n  });\n\n  describe('rendering', () => {\n    it('renders overlay page for printer', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByText('Printer is idle')).toBeInTheDocument();\n      });\n    });\n\n    it('shows Bambuddy logo', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();\n      });\n    });\n\n    it('logo links to GitHub', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        const logo = screen.getByAltText('Bambuddy');\n        const link = logo.closest('a');\n        expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');\n      });\n    });\n  });\n\n  describe('printing state', () => {\n    beforeEach(() => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockStatusPrinting);\n        })\n      );\n    });\n\n    it('shows filename when printing', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByText('Benchy')).toBeInTheDocument();\n      });\n    });\n\n    it('shows progress percentage', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByText('45%')).toBeInTheDocument();\n      });\n    });\n\n    it('shows layer count', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByText('150')).toBeInTheDocument();\n        expect(screen.getByText('300')).toBeInTheDocument();\n      });\n    });\n\n    it('shows status text', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByText('Printing')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('invalid printer', () => {\n    it('shows invalid printer message for ID 0', async () => {\n      renderOverlayPage(0);\n\n      await waitFor(() => {\n        expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('query parameters', () => {\n    it('respects size parameter', async () => {\n      renderOverlayPage(1, '?size=large');\n\n      await waitFor(() => {\n        // Just verify it renders without error\n        expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();\n      });\n    });\n\n    it('respects show parameter to hide elements', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockStatusPrinting);\n        })\n      );\n\n      renderOverlayPage(1, '?show=progress');\n\n      await waitFor(() => {\n        // Progress should be visible\n        expect(screen.getByText('45%')).toBeInTheDocument();\n        // Status text should be hidden when not in show list\n        expect(screen.queryByText('Printing')).not.toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('FPS configuration', () => {\n    it('uses default FPS of 15 when not specified', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        const img = screen.getByAltText('Camera stream') as HTMLImageElement;\n        expect(img.src).toContain('fps=15');\n      });\n    });\n\n    it('uses custom FPS when specified in query params', async () => {\n      renderOverlayPage(1, '?fps=30');\n\n      await waitFor(() => {\n        const img = screen.getByAltText('Camera stream') as HTMLImageElement;\n        expect(img.src).toContain('fps=30');\n      });\n    });\n\n    it('clamps FPS to maximum of 30', async () => {\n      renderOverlayPage(1, '?fps=60');\n\n      await waitFor(() => {\n        const img = screen.getByAltText('Camera stream') as HTMLImageElement;\n        expect(img.src).toContain('fps=30');\n      });\n    });\n\n    it('clamps FPS to minimum of 1', async () => {\n      renderOverlayPage(1, '?fps=0');\n\n      await waitFor(() => {\n        const img = screen.getByAltText('Camera stream') as HTMLImageElement;\n        expect(img.src).toContain('fps=1');\n      });\n    });\n\n    it('handles invalid FPS value gracefully', async () => {\n      renderOverlayPage(1, '?fps=invalid');\n\n      await waitFor(() => {\n        const img = screen.getByAltText('Camera stream') as HTMLImageElement;\n        // Should fall back to default of 15\n        expect(img.src).toContain('fps=15');\n      });\n    });\n  });\n\n  describe('camera toggle (status-only mode)', () => {\n    it('shows camera by default', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByAltText('Camera stream')).toBeInTheDocument();\n      });\n    });\n\n    it('hides camera when camera=false', async () => {\n      renderOverlayPage(1, '?camera=false');\n\n      await waitFor(() => {\n        // Status should still be visible\n        expect(screen.getByText('Printer is idle')).toBeInTheDocument();\n      });\n\n      // Camera should not be rendered\n      expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();\n    });\n\n    it('hides camera when camera=0', async () => {\n      renderOverlayPage(1, '?camera=0');\n\n      await waitFor(() => {\n        expect(screen.getByText('Printer is idle')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();\n    });\n\n    it('shows camera when camera=true', async () => {\n      renderOverlayPage(1, '?camera=true');\n\n      await waitFor(() => {\n        expect(screen.getByAltText('Camera stream')).toBeInTheDocument();\n      });\n    });\n\n    it('shows camera when camera=1', async () => {\n      renderOverlayPage(1, '?camera=1');\n\n      await waitFor(() => {\n        expect(screen.getByAltText('Camera stream')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('combined parameters', () => {\n    it('supports fps and camera together', async () => {\n      renderOverlayPage(1, '?fps=25&camera=true');\n\n      await waitFor(() => {\n        const img = screen.getByAltText('Camera stream') as HTMLImageElement;\n        expect(img.src).toContain('fps=25');\n      });\n    });\n\n    it('supports status-only with custom size', async () => {\n      renderOverlayPage(1, '?camera=false&size=large');\n\n      await waitFor(() => {\n        expect(screen.getByText('Printer is idle')).toBeInTheDocument();\n      });\n\n      expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();\n    });\n\n    it('supports show parameter with fps', async () => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json(mockStatusPrinting);\n        })\n      );\n\n      renderOverlayPage(1, '?fps=20&show=progress');\n\n      await waitFor(() => {\n        const img = screen.getByAltText('Camera stream') as HTMLImageElement;\n        expect(img.src).toContain('fps=20');\n        expect(screen.getByText('45%')).toBeInTheDocument();\n      });\n    });\n  });\n\n  describe('offline state', () => {\n    beforeEach(() => {\n      server.use(\n        http.get('/api/v1/printers/:id/status', () => {\n          return HttpResponse.json({\n            ...mockStatusIdle,\n            connected: false,\n          });\n        })\n      );\n    });\n\n    it('shows offline message when printer disconnected', async () => {\n      renderOverlayPage(1);\n\n      await waitFor(() => {\n        expect(screen.getByText('Printer offline')).toBeInTheDocument();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/pages/SystemInfoPage.test.tsx",
    "content": "/**\n * Tests for the SystemInfoPage component.\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { screen, waitFor } from '@testing-library/react';\nimport { render } from '../utils';\nimport { SystemInfoPage } from '../../pages/SystemInfoPage';\nimport { api } from '../../api/client';\n\n// Mock the API client\nvi.mock('../../api/client', () => ({\n  api: {\n    getSystemInfo: vi.fn(),\n    getSettings: vi.fn().mockResolvedValue({}),\n    updateSettings: vi.fn().mockResolvedValue({}),\n  },\n  supportApi: {\n    getDebugLoggingState: vi.fn().mockResolvedValue({ enabled: false, enabled_at: null, duration_seconds: null }),\n    setDebugLogging: vi.fn().mockResolvedValue({ enabled: true, enabled_at: new Date().toISOString(), duration_seconds: 0 }),\n    downloadSupportBundle: vi.fn().mockResolvedValue(undefined),\n  },\n}));\n\n// Mock system info response\nconst mockSystemInfo = {\n  app: {\n    version: '0.1.5b',\n    base_dir: '/opt/bambuddy',\n    archive_dir: '/opt/bambuddy/archives',\n  },\n  database: {\n    engine: 'SQLite',\n    version: 'SQLite 3.45.1',\n    archives: 150,\n    archives_completed: 140,\n    archives_failed: 8,\n    archives_printing: 2,\n    printers: 3,\n    filaments: 25,\n    projects: 5,\n    smart_plugs: 2,\n    total_print_time_seconds: 360000,\n    total_print_time_formatted: '100h',\n    total_filament_grams: 5000,\n    total_filament_kg: 5.0,\n  },\n  printers: {\n    total: 3,\n    connected: 2,\n    connected_list: [\n      { id: 1, name: 'X1C-01', state: 'IDLE', model: 'X1C' },\n      { id: 2, name: 'P1S-01', state: 'RUNNING', model: 'P1S' },\n    ],\n  },\n  storage: {\n    archive_size_bytes: 1073741824,\n    archive_size_formatted: '1.0 GB',\n    database_size_bytes: 10485760,\n    database_size_formatted: '10.0 MB',\n    disk_total_bytes: 107374182400,\n    disk_total_formatted: '100.0 GB',\n    disk_used_bytes: 53687091200,\n    disk_used_formatted: '50.0 GB',\n    disk_free_bytes: 53687091200,\n    disk_free_formatted: '50.0 GB',\n    disk_percent_used: 50.0,\n  },\n  system: {\n    platform: 'Linux',\n    platform_release: '5.15.0',\n    platform_version: '#1 SMP',\n    architecture: 'x86_64',\n    hostname: 'bambuddy-server',\n    python_version: '3.11.0',\n    uptime_seconds: 86400,\n    uptime_formatted: '1d',\n    boot_time: '2024-12-11T00:00:00',\n  },\n  memory: {\n    total_bytes: 17179869184,\n    total_formatted: '16.0 GB',\n    available_bytes: 8589934592,\n    available_formatted: '8.0 GB',\n    used_bytes: 8589934592,\n    used_formatted: '8.0 GB',\n    percent_used: 50.0,\n  },\n  cpu: {\n    count: 4,\n    count_logical: 8,\n    percent: 25.0,\n  },\n};\n\ndescribe('SystemInfoPage', () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('renders loading state initially', async () => {\n    // Make the API call never resolve to test loading state\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockImplementation(\n      () => new Promise(() => {})\n    );\n\n    render(<SystemInfoPage />);\n\n    // Should show loading spinner\n    expect(document.querySelector('.animate-spin')).toBeInTheDocument();\n  });\n\n  it('renders system info when data loads', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('System Information')).toBeInTheDocument();\n    });\n\n    // Check for version\n    expect(screen.getByText('v0.1.5b')).toBeInTheDocument();\n  });\n\n  it('displays application section', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Application')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('v0.1.5b')).toBeInTheDocument();\n    expect(screen.getByText('bambuddy-server')).toBeInTheDocument();\n    expect(screen.getByText('1d')).toBeInTheDocument();\n  });\n\n  it('displays database statistics', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Database')).toBeInTheDocument();\n    });\n\n    // Check archive counts\n    expect(screen.getByText('150')).toBeInTheDocument(); // Total archives\n    expect(screen.getByText('140')).toBeInTheDocument(); // Completed\n    expect(screen.getByText('8')).toBeInTheDocument(); // Failed\n  });\n\n  it('displays connected printers', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Connected Printers')).toBeInTheDocument();\n    });\n\n    // Check connected printer names\n    expect(screen.getByText('X1C-01')).toBeInTheDocument();\n    expect(screen.getByText('P1S-01')).toBeInTheDocument();\n\n    // Check printer states\n    expect(screen.getByText('IDLE')).toBeInTheDocument();\n    expect(screen.getByText('RUNNING')).toBeInTheDocument();\n  });\n\n  it('displays storage information', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Storage')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('1.0 GB')).toBeInTheDocument(); // Archive size\n    expect(screen.getByText('10.0 MB')).toBeInTheDocument(); // Database size\n  });\n\n  it('displays memory usage', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Memory')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('8.0 GB available')).toBeInTheDocument();\n  });\n\n  it('displays CPU information', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('CPU')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('4')).toBeInTheDocument(); // CPU cores\n    expect(screen.getByText('25%')).toBeInTheDocument(); // CPU usage\n  });\n\n  it('displays system details', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('System Details')).toBeInTheDocument();\n    });\n\n    expect(screen.getByText('Linux')).toBeInTheDocument();\n    expect(screen.getByText('x86_64')).toBeInTheDocument();\n    expect(screen.getByText('3.11.0')).toBeInTheDocument(); // Python version\n  });\n\n  it('shows error state when data fails to load', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(null);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/failed to load/i)).toBeInTheDocument();\n    });\n  });\n\n  it('shows no printers message when none connected', async () => {\n    const noConnectedPrinters = {\n      ...mockSystemInfo,\n      printers: {\n        total: 3,\n        connected: 0,\n        connected_list: [],\n      },\n    };\n\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(noConnectedPrinters);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText(/no printers connected/i)).toBeInTheDocument();\n    });\n  });\n\n  it('has refresh button', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Refresh')).toBeInTheDocument();\n    });\n  });\n\n  it('applies warning color for high disk usage', async () => {\n    const highDiskUsage = {\n      ...mockSystemInfo,\n      storage: {\n        ...mockSystemInfo.storage,\n        disk_percent_used: 80,\n      },\n    };\n\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(highDiskUsage);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Storage')).toBeInTheDocument();\n    });\n\n    // The progress bar should have yellow color for 75-90% usage\n    const progressBars = document.querySelectorAll('[class*=\"bg-yellow\"]');\n    expect(progressBars.length).toBeGreaterThan(0);\n  });\n\n  it('displays extended privacy disclosure items', async () => {\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText(\"What's in the support bundle?\")).toBeInTheDocument();\n    });\n\n    // Original items\n    expect(screen.getByText(/App version and debug mode/)).toBeInTheDocument();\n    expect(screen.getByText(/Debug logs \\(sanitized\\)/)).toBeInTheDocument();\n\n    // New diagnostic items\n    expect(screen.getByText(/Printer connectivity and firmware versions/)).toBeInTheDocument();\n    expect(screen.getByText(/Integration status \\(Spoolman, MQTT, HA\\)/)).toBeInTheDocument();\n    expect(screen.getByText(/Network interfaces \\(subnets only\\)/)).toBeInTheDocument();\n    expect(screen.getByText(/Python package versions/)).toBeInTheDocument();\n    expect(screen.getByText(/Database health checks/)).toBeInTheDocument();\n    expect(screen.getByText(/Docker environment details/)).toBeInTheDocument();\n  });\n\n  it('applies danger color for critical disk usage', async () => {\n    const criticalDiskUsage = {\n      ...mockSystemInfo,\n      storage: {\n        ...mockSystemInfo.storage,\n        disk_percent_used: 95,\n      },\n    };\n\n    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(criticalDiskUsage);\n\n    render(<SystemInfoPage />);\n\n    await waitFor(() => {\n      expect(screen.getByText('Storage')).toBeInTheDocument();\n    });\n\n    // The progress bar should have red color for >90% usage\n    const progressBars = document.querySelectorAll('[class*=\"bg-red\"]');\n    expect(progressBars.length).toBeGreaterThan(0);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/setup.ts",
    "content": "/**\n * Test setup file for Vitest.\n * Configures testing environment, mocks, and MSW server.\n */\n\nimport '@testing-library/jest-dom';\nimport { afterAll, afterEach, beforeAll, vi } from 'vitest';\nimport { cleanup } from '@testing-library/react';\nimport { server } from './mocks/server';\n\n// Initialize i18n for tests (suppresses react-i18next warnings)\nimport '../i18n';\n\n// Setup MSW server\nbeforeAll(() =>\n  server.listen({\n    // Bypass unhandled requests silently (don't warn, just let them through)\n    // Handlers use wildcard (*) prefix to match any origin\n    onUnhandledRequest: 'bypass',\n  })\n);\nafterEach(() => {\n  cleanup();\n  server.resetHandlers();\n});\nafterAll(() => server.close());\n\n// Mock window.matchMedia for responsive components\nObject.defineProperty(window, 'matchMedia', {\n  writable: true,\n  value: vi.fn().mockImplementation((query: string) => ({\n    matches: false,\n    media: query,\n    onchange: null,\n    addListener: vi.fn(),\n    removeListener: vi.fn(),\n    addEventListener: vi.fn(),\n    removeEventListener: vi.fn(),\n    dispatchEvent: vi.fn(),\n  })),\n});\n\n// Mock ResizeObserver\nclass ResizeObserverMock {\n  observe = vi.fn();\n  unobserve = vi.fn();\n  disconnect = vi.fn();\n}\nvi.stubGlobal('ResizeObserver', ResizeObserverMock);\n\n// Mock IntersectionObserver\nclass IntersectionObserverMock {\n  observe = vi.fn();\n  unobserve = vi.fn();\n  disconnect = vi.fn();\n  root = null;\n  rootMargin = '';\n  thresholds = [];\n}\nvi.stubGlobal('IntersectionObserver', IntersectionObserverMock);\n\n// Mock WebSocket\nclass MockWebSocket {\n  static readonly CONNECTING = 0;\n  static readonly OPEN = 1;\n  static readonly CLOSING = 2;\n  static readonly CLOSED = 3;\n\n  readyState = MockWebSocket.OPEN;\n  onopen: ((event: Event) => void) | null = null;\n  onclose: ((event: CloseEvent) => void) | null = null;\n  onmessage: ((event: MessageEvent) => void) | null = null;\n  onerror: ((event: Event) => void) | null = null;\n\n  url: string;\n  constructor(url: string) {\n    this.url = url;\n    setTimeout(() => this.onopen?.(new Event('open')), 0);\n  }\n\n  send = vi.fn();\n  close = vi.fn();\n}\nvi.stubGlobal('WebSocket', MockWebSocket);\n\n// Mock scrollTo\nwindow.scrollTo = vi.fn();\n\n// Mock localStorage\nconst localStorageMock = {\n  getItem: vi.fn(),\n  setItem: vi.fn(),\n  removeItem: vi.fn(),\n  clear: vi.fn(),\n};\nObject.defineProperty(window, 'localStorage', { value: localStorageMock });\n\n// Suppress console output during tests (reduces noise)\n// Remove these lines if you need to debug test output\nvi.spyOn(console, 'log').mockImplementation(() => {});\nvi.spyOn(console, 'warn').mockImplementation(() => {});\nvi.spyOn(console, 'error').mockImplementation(() => {});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/colors.test.ts",
    "content": "import { describe, it, expect, beforeEach } from 'vitest';\nimport {\n  hexToColorName,\n  getColorName,\n  resolveSpoolColorName,\n  setColorCatalog,\n  __resetColorCatalogForTests,\n} from '../../utils/colors';\n\ndescribe('hexToColorName', () => {\n  it('returns \"Unknown\" for null/empty input', () => {\n    expect(hexToColorName(null)).toBe('Unknown');\n    expect(hexToColorName('')).toBe('Unknown');\n    expect(hexToColorName(undefined)).toBe('Unknown');\n  });\n\n  it('classifies dark low-saturation colors as Dark Gray', () => {\n    // Titan Gray hex (5F6367) — low saturation, lightness < 0.4\n    expect(hexToColorName('5F6367')).toBe('Dark Gray');\n  });\n\n  it('classifies black hex as Black', () => {\n    expect(hexToColorName('000000')).toBe('Black');\n  });\n\n  it('classifies white hex as White', () => {\n    expect(hexToColorName('FFFFFF')).toBe('White');\n  });\n});\n\ndescribe('getColorName', () => {\n  beforeEach(() => {\n    __resetColorCatalogForTests();\n  });\n\n  it('looks up the runtime color catalog before HSL fallback', () => {\n    setColorCatalog({ '5f6367': 'Titan Gray' });\n    expect(getColorName('5f6367')).toBe('Titan Gray');\n    expect(getColorName('5F6367')).toBe('Titan Gray');\n  });\n\n  it('falls back to HSL when hex is not in the runtime catalog', () => {\n    // No catalog entry for 123456; HSL bucketing puts it in Blue.\n    expect(getColorName('123456')).toBe('Blue');\n  });\n\n  it('returns \"Unknown\" for empty string', () => {\n    expect(getColorName('')).toBe('Unknown');\n  });\n\n  it('handles hex with # prefix', () => {\n    setColorCatalog({ '5f6367': 'Titan Gray' });\n    expect(getColorName('#5f6367')).toBe('Titan Gray');\n  });\n\n  it('normalizes catalog keys (strips # and lowercases)', () => {\n    // Provider can pass keys in any case / with or without '#'; the utility\n    // must normalize so lookups succeed regardless of input shape.\n    setColorCatalog({ '#F5B6CD': 'Cherry Pink' });\n    expect(getColorName('F5B6CD')).toBe('Cherry Pink');\n    expect(getColorName('f5b6cd')).toBe('Cherry Pink');\n  });\n\n  it('resolves #857 regression — A17-R1 / F5B6CD is Cherry Pink, not Scarlet Red', () => {\n    setColorCatalog({ 'f5b6cd': 'Cherry Pink' });\n    expect(getColorName('F5B6CDFF')).toBe('Cherry Pink');\n  });\n});\n\ndescribe('resolveSpoolColorName', () => {\n  beforeEach(() => {\n    __resetColorCatalogForTests();\n    setColorCatalog({ '5f6367': 'Titan Gray' });\n  });\n\n  it('returns readable color name directly', () => {\n    expect(resolveSpoolColorName('Titan Gray', '5F6367FF')).toBe('Titan Gray');\n  });\n\n  it('looks up hex when color_name is a Bambu code', () => {\n    expect(resolveSpoolColorName('A06-D0', '5F6367FF')).toBe('Titan Gray');\n  });\n\n  it('returns null when color_name is a code and hex is unknown', () => {\n    expect(resolveSpoolColorName('A99-Z9', '12345600')).toBeNull();\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/currency.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../../utils/currency';\n\ndescribe('getCurrencySymbol', () => {\n  it('returns $ for USD', () => {\n    expect(getCurrencySymbol('USD')).toBe('$');\n  });\n\n  it('returns € for EUR', () => {\n    expect(getCurrencySymbol('EUR')).toBe('€');\n  });\n\n  it('returns £ for GBP', () => {\n    expect(getCurrencySymbol('GBP')).toBe('£');\n  });\n\n  it('returns ₹ for INR', () => {\n    expect(getCurrencySymbol('INR')).toBe('₹');\n  });\n\n  it('returns HK$ for HKD', () => {\n    expect(getCurrencySymbol('HKD')).toBe('HK$');\n  });\n\n  it('returns RM for MYR', () => {\n    expect(getCurrencySymbol('MYR')).toBe('RM');\n  });\n\n  it('returns ₴ for UAH', () => {\n    expect(getCurrencySymbol('UAH')).toBe('₴');\n  });\n\n  it('returns the code itself for unknown currencies', () => {\n    expect(getCurrencySymbol('XYZ')).toBe('XYZ');\n  });\n\n  it('is case-insensitive', () => {\n    expect(getCurrencySymbol('usd')).toBe('$');\n    expect(getCurrencySymbol('eur')).toBe('€');\n  });\n});\n\ndescribe('SUPPORTED_CURRENCIES', () => {\n  it('contains INR', () => {\n    expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'INR')).toBeDefined();\n  });\n\n  it('contains MYR', () => {\n    expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'MYR')).toBeDefined();\n  });\n\n  it('has 28 entries', () => {\n    expect(SUPPORTED_CURRENCIES).toHaveLength(29);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/date.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';\nimport {\n  getDatePlaceholder,\n  getTimePlaceholder,\n  formatDateInput,\n  formatTimeInput,\n  parseDateInput,\n  parseTimeInput,\n  toDateTimeLocalValue,\n  applyTimeFormat,\n  parseUTCDate,\n  formatDate,\n  formatDateOnly,\n  formatDateTime,\n  formatTimeOnly,\n  formatETA,\n  formatDuration,\n  formatRelativeTime,\n} from '../../utils/date';\n\ndescribe('getDatePlaceholder', () => {\n  it('returns MM/DD/YYYY for us format', () => {\n    expect(getDatePlaceholder('us')).toBe('MM/DD/YYYY');\n  });\n\n  it('returns DD/MM/YYYY for eu format', () => {\n    expect(getDatePlaceholder('eu')).toBe('DD/MM/YYYY');\n  });\n\n  it('returns YYYY-MM-DD for iso format', () => {\n    expect(getDatePlaceholder('iso')).toBe('YYYY-MM-DD');\n  });\n\n  it('returns a placeholder for system format', () => {\n    const result = getDatePlaceholder('system');\n    expect(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']).toContain(result);\n  });\n});\n\ndescribe('getTimePlaceholder', () => {\n  it('returns HH:MM AM/PM for 12h format', () => {\n    expect(getTimePlaceholder('12h')).toBe('HH:MM AM/PM');\n  });\n\n  it('returns HH:MM for 24h format', () => {\n    expect(getTimePlaceholder('24h')).toBe('HH:MM');\n  });\n\n  it('returns a placeholder for system format', () => {\n    const result = getTimePlaceholder('system');\n    expect(['HH:MM AM/PM', 'HH:MM']).toContain(result);\n  });\n});\n\ndescribe('formatDateInput', () => {\n  const date = new Date(2025, 5, 15); // June 15, 2025\n\n  it('formats as MM/DD/YYYY for us format', () => {\n    expect(formatDateInput(date, 'us')).toBe('06/15/2025');\n  });\n\n  it('formats as DD/MM/YYYY for eu format', () => {\n    expect(formatDateInput(date, 'eu')).toBe('15/06/2025');\n  });\n\n  it('formats as YYYY-MM-DD for iso format', () => {\n    expect(formatDateInput(date, 'iso')).toBe('2025-06-15');\n  });\n\n  it('uses toLocaleDateString for system format', () => {\n    const result = formatDateInput(date, 'system');\n    expect(result).toBeTruthy();\n  });\n});\n\ndescribe('formatTimeInput', () => {\n  it('formats as 12h with AM', () => {\n    const date = new Date(2025, 0, 1, 9, 30);\n    expect(formatTimeInput(date, '12h')).toBe('9:30 AM');\n  });\n\n  it('formats as 12h with PM', () => {\n    const date = new Date(2025, 0, 1, 14, 45);\n    expect(formatTimeInput(date, '12h')).toBe('2:45 PM');\n  });\n\n  it('formats 12:00 as 12:00 PM', () => {\n    const date = new Date(2025, 0, 1, 12, 0);\n    expect(formatTimeInput(date, '12h')).toBe('12:00 PM');\n  });\n\n  it('formats 00:00 as 12:00 AM', () => {\n    const date = new Date(2025, 0, 1, 0, 0);\n    expect(formatTimeInput(date, '12h')).toBe('12:00 AM');\n  });\n\n  it('formats as 24h', () => {\n    const date = new Date(2025, 0, 1, 14, 30);\n    expect(formatTimeInput(date, '24h')).toBe('14:30');\n  });\n\n  it('pads hours in 24h format', () => {\n    const date = new Date(2025, 0, 1, 9, 5);\n    expect(formatTimeInput(date, '24h')).toBe('09:05');\n  });\n});\n\ndescribe('parseDateInput', () => {\n  it('parses us format MM/DD/YYYY', () => {\n    const result = parseDateInput('06/15/2025', 'us');\n    expect(result?.getFullYear()).toBe(2025);\n    expect(result?.getMonth()).toBe(5); // June\n    expect(result?.getDate()).toBe(15);\n  });\n\n  it('parses eu format DD/MM/YYYY', () => {\n    const result = parseDateInput('15/06/2025', 'eu');\n    expect(result?.getFullYear()).toBe(2025);\n    expect(result?.getMonth()).toBe(5);\n    expect(result?.getDate()).toBe(15);\n  });\n\n  it('parses iso format YYYY-MM-DD', () => {\n    const result = parseDateInput('2025-06-15', 'iso');\n    expect(result?.getFullYear()).toBe(2025);\n    expect(result?.getMonth()).toBe(5);\n    expect(result?.getDate()).toBe(15);\n  });\n\n  it('accepts different separators', () => {\n    expect(parseDateInput('06-15-2025', 'us')?.getDate()).toBe(15);\n    expect(parseDateInput('15.06.2025', 'eu')?.getDate()).toBe(15);\n    expect(parseDateInput('2025/06/15', 'iso')?.getDate()).toBe(15);\n  });\n\n  it('returns null for invalid input', () => {\n    expect(parseDateInput('', 'us')).toBeNull();\n    expect(parseDateInput('invalid', 'us')).toBeNull();\n    expect(parseDateInput('13/32/2025', 'us')).toBeNull(); // invalid month\n    expect(parseDateInput('01/01/1800', 'us')).toBeNull(); // year out of range\n  });\n\n  it('returns null for invalid month', () => {\n    expect(parseDateInput('13/01/2025', 'us')).toBeNull();\n    expect(parseDateInput('00/01/2025', 'us')).toBeNull();\n  });\n\n  it('returns null for invalid day', () => {\n    expect(parseDateInput('01/32/2025', 'us')).toBeNull();\n    expect(parseDateInput('01/00/2025', 'us')).toBeNull();\n  });\n});\n\ndescribe('parseTimeInput', () => {\n  it('parses 24h format', () => {\n    expect(parseTimeInput('14:30')).toEqual({ hours: 14, minutes: 30 });\n    expect(parseTimeInput('09:05')).toEqual({ hours: 9, minutes: 5 });\n    expect(parseTimeInput('0:00')).toEqual({ hours: 0, minutes: 0 });\n  });\n\n  it('parses 12h format with AM', () => {\n    expect(parseTimeInput('9:30 AM')).toEqual({ hours: 9, minutes: 30 });\n    expect(parseTimeInput('12:00 AM')).toEqual({ hours: 0, minutes: 0 });\n  });\n\n  it('parses 12h format with PM', () => {\n    expect(parseTimeInput('2:45 PM')).toEqual({ hours: 14, minutes: 45 });\n    expect(parseTimeInput('12:00 PM')).toEqual({ hours: 12, minutes: 0 });\n  });\n\n  it('is case insensitive for AM/PM', () => {\n    expect(parseTimeInput('9:30 am')).toEqual({ hours: 9, minutes: 30 });\n    expect(parseTimeInput('2:45 pm')).toEqual({ hours: 14, minutes: 45 });\n  });\n\n  it('returns null for invalid input', () => {\n    expect(parseTimeInput('')).toBeNull();\n    expect(parseTimeInput('invalid')).toBeNull();\n    expect(parseTimeInput('25:00')).toBeNull();\n    expect(parseTimeInput('12:60')).toBeNull();\n    expect(parseTimeInput('-1:00')).toBeNull();\n  });\n});\n\ndescribe('toDateTimeLocalValue', () => {\n  it('formats date to datetime-local value', () => {\n    const date = new Date(2025, 5, 15, 14, 30);\n    expect(toDateTimeLocalValue(date)).toBe('2025-06-15T14:30');\n  });\n\n  it('pads single digit values', () => {\n    const date = new Date(2025, 0, 5, 9, 5);\n    expect(toDateTimeLocalValue(date)).toBe('2025-01-05T09:05');\n  });\n});\n\ndescribe('applyTimeFormat', () => {\n  it('sets hour12 true for 12h format', () => {\n    const options: Intl.DateTimeFormatOptions = {};\n    applyTimeFormat(options, '12h');\n    expect(options.hour12).toBe(true);\n  });\n\n  it('sets hour12 false for 24h format', () => {\n    const options: Intl.DateTimeFormatOptions = {};\n    applyTimeFormat(options, '24h');\n    expect(options.hour12).toBe(false);\n  });\n\n  it('leaves hour12 undefined for system format', () => {\n    const options: Intl.DateTimeFormatOptions = {};\n    applyTimeFormat(options, 'system');\n    expect(options.hour12).toBeUndefined();\n  });\n\n  it('returns the modified options object', () => {\n    const options: Intl.DateTimeFormatOptions = { hour: '2-digit' };\n    const result = applyTimeFormat(options, '12h');\n    expect(result).toBe(options);\n    expect(result.hour).toBe('2-digit');\n  });\n});\n\ndescribe('parseUTCDate', () => {\n  it('returns null for null/undefined input', () => {\n    expect(parseUTCDate(null)).toBeNull();\n    expect(parseUTCDate(undefined)).toBeNull();\n    expect(parseUTCDate('')).toBeNull();\n  });\n\n  it('parses ISO string with Z suffix as-is', () => {\n    const result = parseUTCDate('2025-06-15T12:00:00Z');\n    expect(result).toBeInstanceOf(Date);\n    expect(result?.getUTCHours()).toBe(12);\n  });\n\n  it('parses ISO string with timezone offset as-is', () => {\n    const result = parseUTCDate('2025-06-15T12:00:00+05:00');\n    expect(result).toBeInstanceOf(Date);\n  });\n\n  it('appends Z to strings without timezone indicator', () => {\n    const result = parseUTCDate('2025-06-15T12:00:00');\n    expect(result).toBeInstanceOf(Date);\n    expect(result?.getUTCHours()).toBe(12);\n  });\n});\n\ndescribe('formatDate', () => {\n  it('returns empty string for null input', () => {\n    expect(formatDate(null)).toBe('');\n    expect(formatDate(undefined)).toBe('');\n  });\n\n  it('formats a valid date string', () => {\n    const result = formatDate('2025-06-15T12:00:00Z');\n    expect(result).toBeTruthy();\n    expect(result).toContain('2025');\n  });\n\n  it('accepts custom options', () => {\n    const result = formatDate('2025-06-15T12:00:00Z', { year: 'numeric' });\n    expect(result).toContain('2025');\n  });\n});\n\ndescribe('formatDateOnly', () => {\n  it('returns empty string for null input', () => {\n    expect(formatDateOnly(null)).toBe('');\n  });\n\n  it('formats date without time', () => {\n    const result = formatDateOnly('2025-06-15T12:00:00Z');\n    expect(result).toBeTruthy();\n    expect(result).toContain('2025');\n  });\n});\n\ndescribe('formatDateTime', () => {\n  it('returns empty string for null input', () => {\n    expect(formatDateTime(null)).toBe('');\n  });\n\n  it('formats with 12h time format', () => {\n    const result = formatDateTime('2025-06-15T14:00:00Z', '12h');\n    expect(result).toBeTruthy();\n  });\n\n  it('formats with 24h time format', () => {\n    const result = formatDateTime('2025-06-15T14:00:00Z', '24h');\n    expect(result).toBeTruthy();\n  });\n});\n\ndescribe('formatTimeOnly', () => {\n  it('formats time with 12h format', () => {\n    const date = new Date(2025, 5, 15, 14, 30);\n    const result = formatTimeOnly(date, '12h');\n    expect(result).toMatch(/2:30|02:30/);\n    expect(result.toUpperCase()).toContain('PM');\n  });\n\n  it('formats time with 24h format', () => {\n    const date = new Date(2025, 5, 15, 14, 30);\n    const result = formatTimeOnly(date, '24h');\n    expect(result).toContain('14:30');\n  });\n});\n\ndescribe('formatETA', () => {\n  beforeEach(() => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it('returns time only for same day', () => {\n    const result = formatETA(60); // 1 hour from now\n    expect(result).toBeTruthy();\n  });\n\n  it('includes \"Tomorrow\" for next day', () => {\n    const result = formatETA(60 * 24); // 24 hours from now\n    expect(result).toContain('Tomorrow');\n  });\n\n  it('uses translation function for tomorrow', () => {\n    const t = vi.fn((key: string) => (key === 'common.tomorrow' ? 'Demain' : key));\n    const result = formatETA(60 * 24, 'system', t);\n    expect(result).toContain('Demain');\n  });\n\n  it('shows weekday for dates beyond tomorrow', () => {\n    const result = formatETA(60 * 48); // 48 hours from now\n    expect(result).not.toContain('Tomorrow');\n  });\n});\n\ndescribe('formatDuration', () => {\n  it('returns \"--\" for null/undefined', () => {\n    expect(formatDuration(null)).toBe('--');\n    expect(formatDuration(undefined)).toBe('--');\n  });\n\n  it('returns \"--\" for negative values', () => {\n    expect(formatDuration(-1)).toBe('--');\n  });\n\n  it('formats minutes only when under 1 hour', () => {\n    expect(formatDuration(0)).toBe('0m');\n    expect(formatDuration(60)).toBe('1m');\n    expect(formatDuration(2700)).toBe('45m');\n  });\n\n  it('formats hours and minutes', () => {\n    expect(formatDuration(3600)).toBe('1h 0m');\n    expect(formatDuration(5400)).toBe('1h 30m');\n    expect(formatDuration(9000)).toBe('2h 30m');\n  });\n});\n\ndescribe('formatRelativeTime', () => {\n  beforeEach(() => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  it('returns \"-\" for null input', () => {\n    expect(formatRelativeTime(null)).toBe('-');\n  });\n\n  it('returns translated unknown for null with translation', () => {\n    const t = vi.fn((key: string) => (key === 'time.unknown' ? 'Unknown' : key));\n    expect(formatRelativeTime(null, 'system', t)).toBe('Unknown');\n  });\n\n  it('returns \"Just now\" for times less than 1 minute ago', () => {\n    expect(formatRelativeTime('2025-06-15T11:59:30Z')).toBe('Just now');\n  });\n\n  it('returns \"Now\" for times less than 1 minute in future', () => {\n    expect(formatRelativeTime('2025-06-15T12:00:30Z')).toBe('Now');\n  });\n\n  it('returns minutes ago for times under 1 hour ago', () => {\n    expect(formatRelativeTime('2025-06-15T11:55:00Z')).toBe('5m ago');\n    expect(formatRelativeTime('2025-06-15T11:30:00Z')).toBe('30m ago');\n  });\n\n  it('returns \"in Xm\" for times under 1 hour in future', () => {\n    expect(formatRelativeTime('2025-06-15T12:05:00Z')).toBe('in 5m');\n    expect(formatRelativeTime('2025-06-15T12:30:00Z')).toBe('in 30m');\n  });\n\n  it('returns hours ago for times under 1 day ago', () => {\n    expect(formatRelativeTime('2025-06-15T10:00:00Z')).toBe('2h ago');\n    expect(formatRelativeTime('2025-06-15T06:00:00Z')).toBe('6h ago');\n  });\n\n  it('returns \"in Xh\" for times under 1 day in future', () => {\n    expect(formatRelativeTime('2025-06-15T14:00:00Z')).toBe('in 2h');\n    expect(formatRelativeTime('2025-06-15T18:00:00Z')).toBe('in 6h');\n  });\n\n  it('returns days ago for times under 7 days ago', () => {\n    expect(formatRelativeTime('2025-06-14T12:00:00Z')).toBe('1d ago');\n    expect(formatRelativeTime('2025-06-10T12:00:00Z')).toBe('5d ago');\n  });\n\n  it('returns \"in Xd\" for times under 7 days in future', () => {\n    expect(formatRelativeTime('2025-06-16T12:00:00Z')).toBe('in 1d');\n    expect(formatRelativeTime('2025-06-20T12:00:00Z')).toBe('in 5d');\n  });\n\n  it('returns formatted date for times older than 7 days', () => {\n    const result = formatRelativeTime('2025-06-01T12:00:00Z');\n    expect(result).toContain('2025');\n  });\n\n  it('uses translation function when provided', () => {\n    const t = vi.fn((key: string, options?: Record<string, unknown>) => {\n      if (key === 'time.minsAgo') return `${options?.count} minutes ago`;\n      if (key === 'time.inMins') return `in ${options?.count} minutes`;\n      return key;\n    });\n\n    expect(formatRelativeTime('2025-06-15T11:55:00Z', 'system', t)).toBe('5 minutes ago');\n    expect(formatRelativeTime('2025-06-15T12:05:00Z', 'system', t)).toBe('in 5 minutes');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/file.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { formatFileSize } from '../../utils/file';\n\ndescribe('formatFileSize', () => {\n  it('returns \"0 B\" for 0 bytes', () => {\n    expect(formatFileSize(0)).toBe('0 B');\n  });\n\n  it('returns bytes without decimals for values under 1 KB', () => {\n    expect(formatFileSize(1)).toBe('1 B');\n    expect(formatFileSize(500)).toBe('500 B');\n    expect(formatFileSize(1023)).toBe('1023 B');\n  });\n\n  it('returns KB with 1 decimal for values under 1 MB', () => {\n    expect(formatFileSize(1024)).toBe('1.0 KB');\n    expect(formatFileSize(1536)).toBe('1.5 KB');\n    expect(formatFileSize(10240)).toBe('10.0 KB');\n  });\n\n  it('returns MB with 1 decimal for values under 1 GB', () => {\n    expect(formatFileSize(1048576)).toBe('1.0 MB');\n    expect(formatFileSize(1572864)).toBe('1.5 MB');\n    expect(formatFileSize(10485760)).toBe('10.0 MB');\n  });\n\n  it('returns GB with 1 decimal for values under 1 TB', () => {\n    expect(formatFileSize(1073741824)).toBe('1.0 GB');\n    expect(formatFileSize(1610612736)).toBe('1.5 GB');\n  });\n\n  it('returns TB with 1 decimal for very large values', () => {\n    expect(formatFileSize(1099511627776)).toBe('1.0 TB');\n    expect(formatFileSize(1649267441664)).toBe('1.5 TB');\n  });\n\n  it('handles edge cases at unit boundaries', () => {\n    expect(formatFileSize(1023)).toBe('1023 B');\n    expect(formatFileSize(1024)).toBe('1.0 KB');\n    expect(formatFileSize(1048575)).toBe('1024.0 KB');\n    expect(formatFileSize(1048576)).toBe('1.0 MB');\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/firmwareVersion.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { compareFwVersions } from '../../utils/firmwareVersion';\n\ndescribe('compareFwVersions', () => {\n  it('returns 0 for equal versions', () => {\n    expect(compareFwVersions('01.02.03.04', '01.02.03.04')).toBe(0);\n  });\n\n  it('returns positive when left is newer (major)', () => {\n    expect(compareFwVersions('02.00.00.00', '01.99.99.99')).toBeGreaterThan(0);\n  });\n\n  it('returns negative when left is older (minor)', () => {\n    expect(compareFwVersions('01.02.03.04', '01.03.00.00')).toBeLessThan(0);\n  });\n\n  it('compares patch segments', () => {\n    expect(compareFwVersions('01.02.10.00', '01.02.02.00')).toBeGreaterThan(0);\n  });\n\n  it('compares build segments', () => {\n    expect(compareFwVersions('01.02.03.05', '01.02.03.04')).toBeGreaterThan(0);\n  });\n\n  it('treats missing trailing segments as 0', () => {\n    expect(compareFwVersions('01.02.03', '01.02.03.00')).toBe(0);\n    expect(compareFwVersions('01.02', '01.02.00.00')).toBe(0);\n  });\n\n  it('sorts a list newest-first via descending sort', () => {\n    const versions = ['01.02.02.00', '01.03.00.00', '01.02.10.00'];\n    versions.sort((a, b) => compareFwVersions(b, a));\n    expect(versions).toEqual(['01.03.00.00', '01.02.10.00', '01.02.02.00']);\n  });\n\n  it('handles the issue #568 ordering correctly', () => {\n    // From the issue: current 01.00.05.00 with 01.01.00.00, 01.01.01.00, 01.01.03.00 available.\n    expect(compareFwVersions('01.01.00.00', '01.00.05.00')).toBeGreaterThan(0);\n    expect(compareFwVersions('01.01.03.00', '01.01.01.00')).toBeGreaterThan(0);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/getSpoolmanFillLevel.test.ts",
    "content": "/**\n * Tests for getSpoolmanFillLevel helper function.\n * This function is defined in PrintersPage.tsx but tested here for isolation.\n * We replicate the logic to test it independently.\n */\n\nimport { describe, it, expect } from 'vitest';\n\n// Replicate the function from PrintersPage.tsx for testing\ninterface LinkedSpoolInfo {\n  id: number;\n  remaining_weight: number | null;\n  filament_weight: number | null;\n}\n\nfunction getSpoolmanFillLevel(\n  linkedSpool: LinkedSpoolInfo | undefined\n): number | null {\n  if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight\n      || linkedSpool.filament_weight <= 0) return null;\n  return Math.min(100, Math.round(\n    (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100\n  ));\n}\n\ndescribe('getSpoolmanFillLevel', () => {\n  it('returns null for undefined spool', () => {\n    expect(getSpoolmanFillLevel(undefined)).toBeNull();\n  });\n\n  it('returns null when remaining_weight is null', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: null, filament_weight: 1000 })).toBeNull();\n  });\n\n  it('returns null when filament_weight is null', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: null })).toBeNull();\n  });\n\n  it('returns null when remaining_weight is 0', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 0, filament_weight: 1000 })).toBeNull();\n  });\n\n  it('returns null when filament_weight is 0', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: 0 })).toBeNull();\n  });\n\n  it('returns null when filament_weight is negative', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: -100 })).toBeNull();\n  });\n\n  it('calculates correct percentage for half-full spool', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 500, filament_weight: 1000 })).toBe(50);\n  });\n\n  it('calculates correct percentage for full spool', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1000, filament_weight: 1000 })).toBe(100);\n  });\n\n  it('calculates correct percentage for nearly empty spool', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 50, filament_weight: 1000 })).toBe(5);\n  });\n\n  it('caps at 100% when remaining exceeds filament weight', () => {\n    // This can happen if user manually sets remaining_weight higher\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1200, filament_weight: 1000 })).toBe(100);\n  });\n\n  it('rounds to nearest integer', () => {\n    // 333/1000 = 33.3% -> 33%\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 333, filament_weight: 1000 })).toBe(33);\n    // 666/1000 = 66.6% -> 67%\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 666, filament_weight: 1000 })).toBe(67);\n  });\n\n  it('handles small weights correctly', () => {\n    expect(getSpoolmanFillLevel({ id: 1, remaining_weight: 1, filament_weight: 100 })).toBe(1);\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/maintenanceWikiUrls.test.ts",
    "content": "/**\n * Unit tests for getMaintenanceWikiUrl — model-aware wiki URL resolver.\n *\n * Covers the X2D classification (#988): X2D has hardened steel rods like\n * P2S, NOT carbon rods and NOT linear rails. It must resolve to the P2S\n * wiki pages for steel-rod-specific tasks.\n *\n * Also guards against regressions for the existing families (X1, P1, A1,\n * H2D, P2S) so that broadening the rod-type bucket for X2D did not\n * accidentally change their mappings.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { getMaintenanceWikiUrl } from '../../utils/maintenanceWikiUrls';\n\ndescribe('getMaintenanceWikiUrl', () => {\n  describe('X2D (#988)', () => {\n    it('resolves \"Lubricate Steel Rods\" to the P2S wiki page', () => {\n      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', 'X2D')).toBe(\n        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',\n      );\n    });\n\n    it('resolves \"Clean Steel Rods\" to the P2S wiki page', () => {\n      expect(getMaintenanceWikiUrl('Clean Steel Rods', 'X2D')).toBe(\n        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',\n      );\n    });\n\n    it('resolves belt tension to the P2S wiki page', () => {\n      expect(getMaintenanceWikiUrl('Check Belt Tension', 'X2D')).toBe(\n        'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension',\n      );\n    });\n\n    it('resolves nozzle cold pull to the P2S wiki page', () => {\n      expect(getMaintenanceWikiUrl('Clean Nozzle/Hotend', 'X2D')).toBe(\n        'https://wiki.bambulab.com/en/p2s/maintenance/cold-pull-maintenance-hotend',\n      );\n    });\n\n    it('does not return a carbon-rod wiki URL for X2D', () => {\n      // \"Clean Carbon Rods\" is X1/P1-only; X2D must resolve to null so the\n      // task button renders without a link rather than pointing at the wrong page.\n      expect(getMaintenanceWikiUrl('Clean Carbon Rods', 'X2D')).toBeNull();\n    });\n\n    it('does not return a linear-rail wiki URL for X2D', () => {\n      // \"Lubricate Linear Rails\" is A1/H2-only.\n      expect(getMaintenanceWikiUrl('Lubricate Linear Rails', 'X2D')).toBeNull();\n    });\n  });\n\n  describe('regression: P2S still maps to P2S wiki pages', () => {\n    it('still resolves Lubricate Steel Rods for P2S', () => {\n      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', 'P2S')).toBe(\n        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',\n      );\n    });\n\n    it('still resolves belt tension for P2S', () => {\n      expect(getMaintenanceWikiUrl('Check Belt Tension', 'P2S')).toBe(\n        'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension',\n      );\n    });\n  });\n\n  describe('regression: other families untouched', () => {\n    it('X1C belt tension unchanged', () => {\n      expect(getMaintenanceWikiUrl('Check Belt Tension', 'X1C')).toBe(\n        'https://wiki.bambulab.com/en/x1/maintenance/belt-tension',\n      );\n    });\n\n    it('H2D belt tension unchanged', () => {\n      expect(getMaintenanceWikiUrl('Check Belt Tension', 'H2D')).toBe(\n        'https://wiki.bambulab.com/en/h2/maintenance/belt-tension',\n      );\n    });\n\n    it('A1 Mini linear rails unchanged', () => {\n      expect(getMaintenanceWikiUrl('Lubricate Linear Rails', 'A1 Mini')).toBe(\n        'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis',\n      );\n    });\n\n    it('X1C carbon rods unchanged', () => {\n      expect(getMaintenanceWikiUrl('Clean Carbon Rods', 'X1C')).toBe(\n        'https://wiki.bambulab.com/en/general/carbon-rods-clearance',\n      );\n    });\n\n    it('P2S still does not resolve linear-rail task', () => {\n      // Sanity check: the X2D broadening must not have widened P2S into\n      // unrelated task categories.\n      expect(getMaintenanceWikiUrl('Lubricate Linear Rails', 'P2S')).toBeNull();\n    });\n  });\n\n  describe('model name normalisation', () => {\n    it('matches X2D regardless of hyphens or spaces', () => {\n      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', 'x-2d')).toBe(\n        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',\n      );\n      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', 'x 2d')).toBe(\n        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',\n      );\n    });\n\n    it('returns null for empty model', () => {\n      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', null)).toBeNull();\n      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', '')).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/printer.test.ts",
    "content": "/**\n * Tests for getPrinterImage — model → printer card image resolver.\n *\n * X2D support (#988): both the display name \"X2D\" and the internal SSDP\n * code \"N6\" must resolve to /img/printers/x2d.png so the Printers page\n * and PrinterInfoModal show the correct artwork instead of falling back\n * to default.png.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { getPrinterImage } from '../../utils/printer';\n\ndescribe('getPrinterImage', () => {\n  describe('X2D (#988)', () => {\n    it('resolves display name \"X2D\" to x2d.png', () => {\n      expect(getPrinterImage('X2D')).toBe('/img/printers/x2d.png');\n    });\n\n    it('resolves case-insensitive variants', () => {\n      expect(getPrinterImage('x2d')).toBe('/img/printers/x2d.png');\n      expect(getPrinterImage(' X2D ')).toBe('/img/printers/x2d.png');\n    });\n\n    it('resolves the internal SSDP code \"N6\" to x2d.png', () => {\n      expect(getPrinterImage('N6')).toBe('/img/printers/x2d.png');\n    });\n\n    it('does not match X2D on unrelated model strings', () => {\n      // Regression guard: a hypothetical future \"X2\" model must not\n      // silently pick up x2d.png until it's explicitly mapped.\n      expect(getPrinterImage('X2E')).toBe('/img/printers/default.png');\n    });\n  });\n\n  describe('regression: existing families unchanged', () => {\n    it('X1C → x1c.png', () => {\n      expect(getPrinterImage('X1C')).toBe('/img/printers/x1c.png');\n    });\n\n    it('X1E → x1e.png', () => {\n      expect(getPrinterImage('X1E')).toBe('/img/printers/x1e.png');\n    });\n\n    it('H2D → h2d.png', () => {\n      expect(getPrinterImage('H2D')).toBe('/img/printers/h2d.png');\n    });\n\n    it('H2D Pro → h2dpro.png', () => {\n      expect(getPrinterImage('H2D Pro')).toBe('/img/printers/h2dpro.png');\n    });\n\n    it('P2S → p1s.png (shared with P1S)', () => {\n      // Pre-existing behaviour: P2S currently reuses the P1S artwork. Not\n      // changed by the X2D diff; asserted to catch accidental regressions.\n      expect(getPrinterImage('P2S')).toBe('/img/printers/p1s.png');\n    });\n\n    it('A1 Mini → a1mini.png (not a1.png)', () => {\n      // The \"a1mini\" branch must run before the generic \"a1\" branch —\n      // the X2D branch was inserted above both and must not break order.\n      expect(getPrinterImage('A1 Mini')).toBe('/img/printers/a1mini.png');\n    });\n\n    it('null / undefined → default.png', () => {\n      expect(getPrinterImage(null)).toBe('/img/printers/default.png');\n      expect(getPrinterImage(undefined)).toBe('/img/printers/default.png');\n    });\n\n    it('unknown model → default.png', () => {\n      expect(getPrinterImage('SomeFuturePrinter')).toBe(\n        '/img/printers/default.png',\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils/slicer.test.ts",
    "content": "/**\n * Tests for the slicer utility functions.\n */\n\nimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';\nimport { openInSlicer, detectPlatform, buildDownloadUrl } from '../../utils/slicer';\n\ndescribe('slicer utility', () => {\n  let clickSpy: ReturnType<typeof vi.fn>;\n  let appendSpy: ReturnType<typeof vi.fn>;\n  let removeSpy: ReturnType<typeof vi.fn>;\n  let createdLink: HTMLAnchorElement;\n\n  beforeEach(() => {\n    clickSpy = vi.fn();\n    appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {\n      createdLink = node as HTMLAnchorElement;\n      return node;\n    });\n    removeSpy = vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);\n\n    // Mock click on created elements\n    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy);\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  describe('detectPlatform', () => {\n    it('detects Windows', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');\n      expect(detectPlatform()).toBe('windows');\n    });\n\n    it('detects macOS', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)');\n      expect(detectPlatform()).toBe('macos');\n    });\n\n    it('detects Linux', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');\n      expect(detectPlatform()).toBe('linux');\n    });\n  });\n\n  describe('openInSlicer', () => {\n    it('uses bambustudio:// protocol on Windows for bambu_studio', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');\n      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');\n\n      expect(appendSpy).toHaveBeenCalled();\n      expect(createdLink.href).toContain('bambustudio://open?file=');\n      expect(createdLink.href).toContain('http://localhost:8000/file.3mf');\n      expect(clickSpy).toHaveBeenCalled();\n      expect(removeSpy).toHaveBeenCalled();\n    });\n\n    it('uses bambustudioopen:// protocol on macOS for bambu_studio', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X)');\n      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');\n\n      expect(createdLink.href).toContain('bambustudioopen://');\n    });\n\n    it('uses bambustudio://open?file= on Linux for bambu_studio', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');\n      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');\n\n      expect(createdLink.href).toContain('bambustudio://open?file=');\n    });\n\n    it('uses orcaslicer:// protocol for orcaslicer on all platforms', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X)');\n      openInSlicer('http://localhost:8000/file.3mf', 'orcaslicer');\n\n      expect(createdLink.href).toContain('orcaslicer://');\n      expect(createdLink.href).toContain('open?file=');\n    });\n\n    it('does not encode the file URL for orcaslicer', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');\n      const url = 'http://localhost:8000/api/v1/archives/1/file/My Model.3mf';\n      openInSlicer(url, 'orcaslicer');\n\n      // The href should contain the raw URL (browser may normalize it but it should not be double-encoded)\n      expect(createdLink.href).toContain('orcaslicer://open?file=');\n      // Should NOT contain %253A (double-encoded colon)\n      expect(createdLink.href).not.toContain('%253A');\n    });\n\n    it('defaults to bambu_studio when no slicer specified', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');\n      openInSlicer('http://localhost:8000/file.3mf');\n\n      expect(createdLink.href).toContain('bambustudio://');\n    });\n\n    it('creates and removes a temporary link element', () => {\n      vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');\n      openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');\n\n      expect(appendSpy).toHaveBeenCalledOnce();\n      expect(clickSpy).toHaveBeenCalledOnce();\n      expect(removeSpy).toHaveBeenCalledOnce();\n    });\n  });\n\n  describe('buildDownloadUrl', () => {\n    it('prepends window.location.origin', () => {\n      const result = buildDownloadUrl('/api/v1/archives/1/file/test.3mf');\n      expect(result).toBe(`${window.location.origin}/api/v1/archives/1/file/test.3mf`);\n    });\n  });\n});\n"
  },
  {
    "path": "frontend/src/__tests__/utils.tsx",
    "content": "/**\n * Test utilities and wrapper components.\n */\n\nimport React from 'react';\nimport { render } from '@testing-library/react';\nimport type { RenderOptions } from '@testing-library/react';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { BrowserRouter } from 'react-router-dom';\nimport { ThemeProvider } from '../contexts/ThemeContext';\nimport { ToastProvider } from '../contexts/ToastContext';\nimport { AuthProvider } from '../contexts/AuthContext';\n\n// Create a new QueryClient for each test\nfunction createTestQueryClient() {\n  return new QueryClient({\n    defaultOptions: {\n      queries: {\n        retry: false,\n        gcTime: 0,\n      },\n      mutations: {\n        retry: false,\n      },\n    },\n  });\n}\n\ninterface AllProvidersProps {\n  children: React.ReactNode;\n}\n\nfunction AllProviders({ children }: AllProvidersProps) {\n  const queryClient = createTestQueryClient();\n\n  return (\n    <QueryClientProvider client={queryClient}>\n      <BrowserRouter>\n        <ThemeProvider>\n          <AuthProvider>\n            <ToastProvider>{children}</ToastProvider>\n          </AuthProvider>\n        </ThemeProvider>\n      </BrowserRouter>\n    </QueryClientProvider>\n  );\n}\n\n/**\n * Custom render function that wraps components with all providers.\n */\nfunction customRender(\n  ui: React.ReactElement,\n  options?: Omit<RenderOptions, 'wrapper'>\n) {\n  return render(ui, { wrapper: AllProviders, ...options });\n}\n\n// Re-export everything from testing-library\nexport * from '@testing-library/react';\n\n// Override render with our custom render\nexport { customRender as render };\n\n/**\n * Create a test QueryClient with custom configuration.\n */\nexport { createTestQueryClient };\n\n/**\n * Helper to wait for async operations.\n */\nexport const waitForAsync = () => new Promise((resolve) => setTimeout(resolve, 0));\n"
  },
  {
    "path": "frontend/src/api/client.ts",
    "content": "import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';\n\nconst API_BASE = '/api/v1';\n\n// Auth token storage\n// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).\n// When the token originates from the ?token= URL param (kiosk bootstrap), it is\n// additionally persisted in localStorage so the kiosk survives page reloads.\nlet authToken: string | null =\n  sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');\n\nexport function setAuthToken(token: string | null, persist = false) {\n  authToken = token;\n  if (token) {\n    sessionStorage.setItem('auth_token', token);\n    if (persist) {\n      localStorage.setItem('auth_token', token);\n    }\n  } else {\n    sessionStorage.removeItem('auth_token');\n    localStorage.removeItem('auth_token');\n  }\n}\n\nexport function getAuthToken(): string | null {\n  return authToken;\n}\n\n// Stream token for image/video URLs loaded via <img>/<video> tags\n// (these can't send Authorization headers, so a query param token is used)\nlet streamToken: string | null = null;\n\nexport function setStreamToken(token: string | null) {\n  streamToken = token;\n}\n\nexport function getStreamToken(): string | null {\n  return streamToken;\n}\n\n/** Append the stream token to a URL if available (for <img>/<video> src). */\nexport function withStreamToken(url: string): string {\n  if (!streamToken) return url;\n  const sep = url.includes('?') ? '&' : '?';\n  return `${url}${sep}token=${encodeURIComponent(streamToken)}`;\n}\n\nfunction parseContentDispositionFilename(header: string | null): string | null {\n  if (!header) return null;\n  // RFC 5987: filename*=utf-8''percent-encoded-name\n  const rfc5987Match = header.match(/filename\\*=(?:UTF-8|utf-8)''(.+?)(?:;|$)/);\n  if (rfc5987Match) {\n    try { return decodeURIComponent(rfc5987Match[1]); } catch { /* fall through */ }\n  }\n  // Standard: filename=\"name\" or filename=name\n  const standardMatch = header.match(/filename=\"?([^\";\\n]+)\"?/);\n  return standardMatch?.[1] || null;\n}\n\nasync function request<T>(\n  endpoint: string,\n  options: RequestInit = {}\n): Promise<T> {\n  const headers: Record<string, string> = {\n    'Content-Type': 'application/json',\n    ...options.headers as Record<string, string>,\n  };\n\n  // Add auth token if available\n  if (authToken) {\n    headers['Authorization'] = `Bearer ${authToken}`;\n  }\n\n  const response = await fetch(`${API_BASE}${endpoint}`, {\n    ...options,\n    cache: 'no-store', // Prevent browser caching of API responses\n    credentials: 'include', // Required for HttpOnly cookies (e.g. 2fa_challenge)\n    headers,\n  });\n\n  if (!response.ok) {\n    const error = await response.json().catch(() => ({}));\n    const detail = error.detail;\n    const message = typeof detail === 'string'\n      ? detail\n      : (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);\n\n    // Handle 401 Unauthorized - only clear token if it's actually invalid\n    // Don't clear on \"Authentication required\" which might be a timing issue\n    if (response.status === 401) {\n      const invalidTokenMessages = [\n        'Could not validate credentials',\n        'Token has expired',\n        'User not found or inactive',\n        'Invalid API key',\n        'API key has expired',\n      ];\n      if (invalidTokenMessages.some(m => message.includes(m))) {\n        setAuthToken(null);\n      }\n    }\n\n    throw new Error(message);\n  }\n\n  // Handle empty responses (204 No Content, etc.)\n  const contentLength = response.headers.get('content-length');\n  if (response.status === 204 || contentLength === '0') {\n    return undefined as T;\n  }\n\n  return await response.json();\n}\n\n// Printer types\nexport interface Printer {\n  id: number;\n  name: string;\n  serial_number: string;\n  ip_address: string;\n  access_code: string;\n  model: string | null;\n  location: string | null;  // Group/location name\n  nozzle_count: number;  // 1 or 2, auto-detected from MQTT\n  is_active: boolean;\n  auto_archive: boolean;\n  external_camera_url: string | null;\n  external_camera_type: string | null;  // \"mjpeg\", \"rtsp\", \"snapshot\"\n  external_camera_enabled: boolean;\n  camera_rotation: number;  // 0, 90, 180, 270 degrees\n  plate_detection_enabled: boolean;  // Check plate before print\n  plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface HMSError {\n  code: string;\n  attr: number;  // Attribute value for constructing wiki URL\n  module: number;\n  severity: number;  // 1=fatal, 2=serious, 3=common, 4=info\n}\n\nexport interface AMSTray {\n  id: number;\n  tray_color: string | null;\n  tray_type: string | null;\n  tray_sub_brands: string | null;  // Full name like \"PLA Basic\", \"PETG HF\"\n  tray_id_name: string | null;  // Bambu filament ID like \"A00-Y2\" (can decode to color)\n  tray_info_idx: string | null;  // Filament preset ID like \"GFA00\" - maps to cloud setting_id\n  remain: number;\n  k: number | null;  // Pressure advance value (from tray or K-profile lookup)\n  cali_idx: number | null;  // Calibration index for K-profile lookup\n  tag_uid: string | null;  // RFID tag UID (any tag)\n  tray_uuid: string | null;  // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)\n  nozzle_temp_min: number | null;  // Min nozzle temperature\n  nozzle_temp_max: number | null;  // Max nozzle temperature\n  drying_temp: number | null;      // RFID-recommended drying temp\n  drying_time: number | null;      // RFID-recommended drying time (hours)\n  state: number | null;            // AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded\n}\n\nexport interface AMSUnit {\n  id: number;\n  humidity: number | null;\n  temp: number | null;\n  is_ams_ht: boolean;  // True for AMS-HT (single spool), False for regular AMS (4 spools)\n  tray: AMSTray[];\n  serial_number: string;  // AMS unit serial number (from MQTT sn field)\n  sw_ver: string;         // AMS firmware version (from get_version info.module ams/* entry)\n  dry_time: number;       // Minutes remaining (0 = not drying, >0 = drying active)\n  dry_status: number;     // 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error\n  dry_sub_status: number; // 0=Off, 1=Heating, 2=Dehumidify\n  dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)\n  module_type: string;    // \"ams\", \"n3f\", \"n3s\"\n}\n\nexport interface NozzleInfo {\n  nozzle_type: string;  // \"stainless_steel\" or \"hardened_steel\"\n  nozzle_diameter: string;  // e.g., \"0.4\"\n}\n\nexport interface NozzleRackSlot {\n  id: number;\n  nozzle_type: string;\n  nozzle_diameter: string;\n  wear: number | null;\n  stat: number | null;  // Nozzle status (e.g. mounted/docked)\n  max_temp: number;\n  serial_number: string;\n  filament_color: string;  // RGBA hex (\"00000000\" = no filament)\n  filament_id: string;\n  filament_type: string;  // Material type (e.g. \"PLA\", \"PETG\")\n}\n\nexport interface PrintOptions {\n  // Core AI detectors\n  spaghetti_detector: boolean;\n  print_halt: boolean;\n  halt_print_sensitivity: string;  // \"low\", \"medium\", \"high\" - spaghetti sensitivity\n  first_layer_inspector: boolean;\n  printing_monitor: boolean;\n  buildplate_marker_detector: boolean;\n  allow_skip_parts: boolean;\n  // Additional AI detectors (decoded from cfg bitmask)\n  nozzle_clumping_detector: boolean;\n  nozzle_clumping_sensitivity: string;  // \"low\", \"medium\", \"high\"\n  pileup_detector: boolean;\n  pileup_sensitivity: string;  // \"low\", \"medium\", \"high\"\n  airprint_detector: boolean;\n  airprint_sensitivity: string;  // \"low\", \"medium\", \"high\"\n  auto_recovery_step_loss: boolean;\n  filament_tangle_detect: boolean;\n}\n\nexport interface PrinterStatus {\n  id: number;\n  name: string;\n  connected: boolean;\n  state: string | null;\n  current_print: string | null;\n  subtask_name: string | null;\n  current_archive_id: number | null;\n  current_plate_id: number | null;\n  gcode_file: string | null;\n  progress: number | null;\n  remaining_time: number | null;\n  layer_num: number | null;\n  total_layers: number | null;\n  temperatures: {\n    bed?: number;\n    bed_target?: number;\n    bed_heating?: boolean;  // Actual heater state from MQTT\n    nozzle?: number;\n    nozzle_target?: number;\n    nozzle_heating?: boolean;  // Actual heater state from MQTT\n    nozzle_2?: number;  // Second nozzle for H2 series (dual nozzle)\n    nozzle_2_target?: number;\n    nozzle_2_heating?: boolean;  // Actual heater state from MQTT\n    chamber?: number;\n    chamber_target?: number;\n    chamber_heating?: boolean;  // Actual heater state from MQTT\n  } | null;\n  cover_url: string | null;\n  hms_errors: HMSError[];\n  ams: AMSUnit[];\n  ams_exists: boolean;\n  vt_tray: AMSTray[];  // Virtual tray / external spool(s)\n  store_to_sdcard: boolean;  // Store sent files on SD card\n  timelapse: boolean;  // Timelapse recording active\n  ipcam: boolean;  // Live view enabled\n  wifi_signal: number | null;  // WiFi signal strength in dBm\n  wired_network: boolean;  // Ethernet connection detected\n  door_open: boolean;  // Enclosure door open (X1/P1S/P2S/H2*)\n  nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)\n  nozzle_rack: NozzleRackSlot[];  // H2C 6-nozzle tool-changer rack\n  print_options: PrintOptions | null;  // AI detection and print options\n  // Calibration stage tracking\n  stg_cur: number;  // Current stage number (-1 = not calibrating)\n  stg_cur_name: string | null;  // Human-readable current stage name\n  stg: number[];  // List of stage numbers in calibration sequence\n  // Air conditioning mode (0=cooling, 1=heating)\n  airduct_mode: number;\n  // Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)\n  speed_level: number;\n  // Chamber light on/off\n  chamber_light: boolean;\n  // Active extruder for dual nozzle (0=right, 1=left)\n  active_extruder: number;\n  // AMS mapping - which AMS is connected to which nozzle\n  // Format: [ams_id_for_nozzle0, ams_id_for_nozzle1, ...] where -1 means no AMS\n  ams_mapping: number[];\n  // Per-AMS extruder mapping - extracted from each AMS unit's info field\n  // Format: {ams_id: extruder_id} where extruder 0=right, 1=left\n  // Note: JSON keys are always strings\n  ams_extruder_map: Record<string, number>;\n  // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)\n  tray_now: number;\n  // AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)\n  ams_status_main: number;\n  // AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge\n  ams_status_sub: number;\n  // mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio\n  mc_print_sub_stage: number;\n  // Timestamp of last AMS data update (for RFID refresh detection)\n  last_ams_update: number;\n  // Number of printable objects in current print (for skip objects feature)\n  printable_objects_count: number;\n  // Fan speeds (0-100 percentage, null if not available for this model)\n  cooling_fan_speed: number | null;  // Part cooling fan\n  big_fan1_speed: number | null;     // Auxiliary fan\n  big_fan2_speed: number | null;     // Chamber/exhaust fan\n  heatbreak_fan_speed: number | null; // Hotend heatbreak fan\n  firmware_version: string | null;   // Firmware version from MQTT\n  // Developer LAN mode: true = enabled, false = disabled, null = unknown\n  developer_mode: boolean | null;\n  // Queue: printer is awaiting user ack that the build plate was cleared after a\n  // finished/failed print. Persisted across restarts (#961).\n  awaiting_plate_clear: boolean;\n  // AMS drying support\n  supports_drying: boolean;\n}\n\nexport interface PrinterCreate {\n  name: string;\n  serial_number: string;\n  ip_address: string;\n  access_code: string;\n  model?: string;\n  location?: string;\n  auto_archive?: boolean;\n  external_camera_url?: string | null;\n  external_camera_type?: string | null;\n  external_camera_enabled?: boolean;\n  camera_rotation?: number;\n  plate_detection_enabled?: boolean;\n  plate_detection_roi?: PlateDetectionROI;\n}\n\n// Plate Detection\nexport interface PlateDetectionROI {\n  x: number;  // X start % (0.0-1.0)\n  y: number;  // Y start % (0.0-1.0)\n  w: number;  // Width % (0.0-1.0)\n  h: number;  // Height % (0.0-1.0)\n}\n\nexport interface PlateDetectionResult {\n  is_empty: boolean;\n  confidence: number;\n  difference_percent: number;\n  message: string;\n  has_debug_image: boolean;\n  debug_image_url?: string;\n  needs_calibration: boolean;\n  light_warning?: boolean;\n  reference_count?: number;\n  max_references?: number;\n  roi?: PlateDetectionROI;\n}\n\nexport interface PlateDetectionStatus {\n  available: boolean;\n  calibrated: boolean;\n  reference_count: number;\n  max_references: number;\n  message: string;\n}\n\nexport interface CalibrationResult {\n  success: boolean;\n  message: string;\n}\n\nexport interface PlateReference {\n  index: number;\n  label: string;\n  timestamp: string;\n  has_image: boolean;\n  thumbnail_url: string;\n}\n\n// Archive types\nexport interface ArchiveDuplicate {\n  id: number;\n  print_name: string | null;\n  created_at: string;\n  match_type: 'exact' | 'similar';  // 'exact' = hash match, 'similar' = name match\n}\n\nexport interface Archive {\n  id: number;\n  printer_id: number | null;\n  project_id: number | null;\n  project_name: string | null;\n  filename: string;\n  file_path: string;\n  file_size: number;\n  content_hash: string | null;\n  thumbnail_path: string | null;\n  timelapse_path: string | null;\n  source_3mf_path: string | null;\n  f3d_path: string | null;\n  duplicates: ArchiveDuplicate[] | null;\n  duplicate_count: number;\n  duplicate_sequence: number;  // 0 = original, 1+ = nth duplicate\n  original_archive_id: number | null;  // ID of the first/original archive\n  object_count: number | null;\n  print_name: string | null;\n  print_time_seconds: number | null;\n  actual_time_seconds: number | null;  // Computed from started_at/completed_at\n  time_accuracy: number | null;  // Percentage: 100 = perfect, >100 = faster than estimated\n  filament_used_grams: number | null;\n  filament_type: string | null;\n  filament_color: string | null;\n  layer_height: number | null;\n  total_layers: number | null;\n  nozzle_diameter: number | null;\n  bed_temperature: number | null;\n  nozzle_temperature: number | null;\n  sliced_for_model: string | null;  // Printer model this file was sliced for\n  status: string;\n  started_at: string | null;\n  completed_at: string | null;\n  extra_data: Record<string, unknown> | null;\n  makerworld_url: string | null;\n  designer: string | null;\n  external_url: string | null;\n  is_favorite: boolean;\n  tags: string | null;\n  notes: string | null;\n  cost: number | null;\n  photos: string[] | null;\n  failure_reason: string | null;\n  quantity: number;\n  energy_kwh: number | null;\n  energy_cost: number | null;\n  created_at: string;\n  // User tracking (Issue #206)\n  created_by_id: number | null;\n  created_by_username: string | null;\n}\n\nexport interface ArchiveSlim {\n  printer_id: number | null;\n  print_name: string | null;\n  print_time_seconds: number | null;\n  actual_time_seconds: number | null;\n  filament_used_grams: number | null;\n  filament_type: string | null;\n  filament_color: string | null;\n  status: string;\n  started_at: string | null;\n  completed_at: string | null;\n  cost: number | null;\n  quantity: number;\n  created_at: string;\n}\n\nexport interface PrintLogEntry {\n  id: number;\n  print_name: string | null;\n  printer_name: string | null;\n  printer_id: number | null;\n  status: string;\n  started_at: string | null;\n  completed_at: string | null;\n  duration_seconds: number | null;\n  filament_type: string | null;\n  filament_color: string | null;\n  filament_used_grams: number | null;\n  thumbnail_path: string | null;\n  created_by_username: string | null;\n  created_at: string;\n}\n\nexport interface PrintLogResponse {\n  items: PrintLogEntry[];\n  total: number;\n}\n\nexport interface ArchiveStats {\n  total_prints: number;\n  successful_prints: number;\n  failed_prints: number;\n  total_print_time_hours: number;\n  total_filament_grams: number;\n  total_cost: number;\n  prints_by_filament_type: Record<string, number>;\n  prints_by_printer: Record<string, number>;\n  average_time_accuracy: number | null;\n  time_accuracy_by_printer: Record<string, number> | null;\n  total_energy_kwh: number;\n  total_energy_cost: number;\n  // True when a date-filtered total-consumption query is running on incomplete\n  // snapshot history (e.g. right after upgrade, before hourly snapshots have\n  // a baseline). UI should explain why the number may undercount.\n  energy_data_warming_up?: boolean;\n}\n\nexport interface TagInfo {\n  name: string;\n  count: number;\n}\n\nexport interface FailureAnalysis {\n  period_days: number;\n  total_prints: number;\n  failed_prints: number;\n  failure_rate: number;\n  failures_by_reason: Record<string, number>;\n  failures_by_filament: Record<string, number>;\n  failures_by_printer: Record<string, number>;\n  failures_by_hour: Record<number, number>;\n  recent_failures: Array<{\n    id: number;\n    print_name: string;\n    failure_reason: string | null;\n    filament_type: string | null;\n    printer_id: number | null;\n    created_at: string | null;\n  }>;\n  trend: Array<{\n    week_start: string;\n    total_prints: number;\n    failed_prints: number;\n    failure_rate: number;\n  }>;\n}\n\nexport interface BulkUploadResult {\n  uploaded: number;\n  failed: number;\n  results: Array<{ filename: string; id: number; status: string }>;\n  errors: Array<{ filename: string; error: string }>;\n}\n\n// Archive Comparison types\nexport interface ComparisonArchiveInfo {\n  id: number;\n  print_name: string;\n  status: string;\n  created_at: string | null;\n  printer_id: number | null;\n  project_name: string | null;\n}\n\nexport interface ComparisonField {\n  field: string;\n  label: string;\n  unit: string | null;\n  values: (string | number | null)[];\n  raw_values: (string | number | null)[];\n  has_difference: boolean;\n}\n\nexport interface SuccessCorrelationInsight {\n  field: string;\n  label: string;\n  insight: string;\n  success_avg?: number;\n  failed_avg?: number;\n  success_values?: string[];\n  failed_values?: string[];\n}\n\nexport interface SuccessCorrelation {\n  has_both_outcomes: boolean;\n  message?: string;\n  successful_count?: number;\n  failed_count?: number;\n  insights?: SuccessCorrelationInsight[];\n}\n\nexport interface ArchiveComparison {\n  archives: ComparisonArchiveInfo[];\n  comparison: ComparisonField[];\n  differences: ComparisonField[];\n  success_correlation: SuccessCorrelation;\n}\n\nexport interface SimilarArchive {\n  archive: {\n    id: number;\n    print_name: string;\n    status: string;\n    created_at: string | null;\n  };\n  match_reason: string;\n  match_score: number;\n}\n\n// Project types\nexport interface ProjectStats {\n  total_archives: number;\n  total_items: number;  // Sum of quantities (total items printed)\n  completed_prints: number;  // Sum of quantities for completed prints (parts)\n  failed_prints: number;\n  queued_prints: number;\n  in_progress_prints: number;\n  total_print_time_hours: number;\n  total_filament_grams: number;\n  progress_percent: number | null;  // Plates progress (total_archives / target_count)\n  parts_progress_percent: number | null;  // Parts progress (completed_prints / target_parts_count)\n  estimated_cost: number;\n  total_energy_kwh: number;\n  total_energy_cost: number;\n  remaining_prints: number | null;  // Remaining plates\n  remaining_parts: number | null;  // Remaining parts\n  bom_total_items: number;\n  bom_completed_items: number;\n  bom_cost: number;\n}\n\nexport interface ProjectChildPreview {\n  id: number;\n  name: string;\n  color: string | null;\n  status: string;\n  progress_percent: number | null;\n}\n\nexport interface Project {\n  id: number;\n  name: string;\n  description: string | null;\n  color: string | null;\n  status: string;  // active, completed, archived\n  target_count: number | null;  // Target number of plates/print jobs\n  target_parts_count: number | null;  // Target number of parts/objects\n  notes: string | null;\n  attachments: ProjectAttachment[] | null;\n  tags: string | null;\n  due_date: string | null;\n  priority: string;  // low, normal, high, urgent\n  budget: number | null;\n  is_template: boolean;\n  template_source_id: number | null;\n  parent_id: number | null;\n  parent_name: string | null;\n  children: ProjectChildPreview[];\n  created_at: string;\n  updated_at: string;\n  stats?: ProjectStats;\n}\n\nexport interface ProjectAttachment {\n  filename: string;\n  original_name: string;\n  size: number;\n  uploaded_at: string;\n}\n\nexport interface ArchivePreview {\n  id: number;\n  print_name: string | null;\n  thumbnail_path: string | null;\n  status: string;\n  filament_type: string | null;\n  filament_color: string | null;\n}\n\nexport interface ProjectListItem {\n  id: number;\n  name: string;\n  description: string | null;\n  color: string | null;\n  status: string;\n  target_count: number | null;  // Target number of plates/print jobs\n  target_parts_count: number | null;  // Target number of parts/objects\n  budget: number | null;\n  created_at: string;\n  archive_count: number;  // Number of print jobs (plates)\n  total_items: number;  // Sum of quantities (total items printed, including failed)\n  completed_count: number;  // Sum of quantities for completed prints only (parts)\n  failed_count: number;  // Sum of quantities for failed prints\n  queue_count: number;\n  progress_percent: number | null;  // Plates progress\n  archives: ArchivePreview[];\n}\n\nexport interface ProjectCreate {\n  name: string;\n  description?: string;\n  color?: string;\n  target_count?: number;\n  target_parts_count?: number;\n  notes?: string;\n  tags?: string;\n  due_date?: string;\n  priority?: string;\n  budget?: number | null;\n  parent_id?: number;\n}\n\nexport interface ProjectUpdate {\n  name?: string;\n  description?: string;\n  color?: string;\n  status?: string;\n  target_count?: number;\n  target_parts_count?: number;\n  notes?: string;\n  tags?: string;\n  due_date?: string;\n  priority?: string;\n  budget?: number | null;\n  parent_id?: number;\n}\n\n// BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.)\nexport interface BOMItem {\n  id: number;\n  project_id: number;\n  name: string;\n  quantity_needed: number;\n  quantity_acquired: number;\n  unit_price: number | null;\n  sourcing_url: string | null;\n  archive_id: number | null;\n  archive_name: string | null;\n  stl_filename: string | null;\n  remarks: string | null;\n  sort_order: number;\n  is_complete: boolean;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface BOMItemCreate {\n  name: string;\n  quantity_needed?: number;\n  unit_price?: number;\n  sourcing_url?: string;\n  archive_id?: number;\n  stl_filename?: string;\n  remarks?: string;\n}\n\nexport interface BOMItemUpdate {\n  name?: string;\n  quantity_needed?: number;\n  quantity_acquired?: number;\n  unit_price?: number;\n  sourcing_url?: string;\n  archive_id?: number;\n  stl_filename?: string;\n  remarks?: string;\n}\n\n// Project Export/Import Types\nexport interface BOMItemExport {\n  name: string;\n  quantity_needed: number;\n  quantity_acquired: number;\n  unit_price: number | null;\n  sourcing_url: string | null;\n  stl_filename: string | null;\n  remarks: string | null;\n}\n\nexport interface LinkedFolderExport {\n  name: string;\n}\n\nexport interface ProjectExport {\n  name: string;\n  description: string | null;\n  color: string | null;\n  status: string;\n  target_count: number | null;\n  target_parts_count: number | null;\n  notes: string | null;\n  tags: string | null;\n  due_date: string | null;\n  priority: string;\n  budget: number | null;\n  bom_items: BOMItemExport[];\n  linked_folders: LinkedFolderExport[];\n}\n\nexport interface ProjectImport {\n  name: string;\n  description?: string;\n  color?: string;\n  status?: string;\n  target_count?: number;\n  target_parts_count?: number;\n  notes?: string;\n  tags?: string;\n  due_date?: string;\n  priority?: string;\n  budget?: number | null;\n  bom_items?: BOMItemExport[];\n  linked_folders?: LinkedFolderExport[];\n}\n\n// Timeline Types\nexport interface TimelineEvent {\n  event_type: string;\n  timestamp: string;\n  title: string;\n  description: string | null;\n  metadata: Record<string, unknown> | null;\n}\n\n// API Key types\nexport interface APIKey {\n  id: number;\n  name: string;\n  key_prefix: string;\n  can_queue: boolean;\n  can_control_printer: boolean;\n  can_read_status: boolean;\n  printer_ids: number[] | null;\n  enabled: boolean;\n  last_used: string | null;\n  created_at: string;\n  expires_at: string | null;\n}\n\nexport interface APIKeyCreate {\n  name: string;\n  can_queue?: boolean;\n  can_control_printer?: boolean;\n  can_read_status?: boolean;\n  printer_ids?: number[] | null;\n  expires_at?: string | null;\n}\n\nexport interface APIKeyCreateResponse extends APIKey {\n  key: string;  // Full key, only shown on creation\n}\n\nexport interface APIKeyUpdate {\n  name?: string;\n  can_queue?: boolean;\n  can_control_printer?: boolean;\n  can_read_status?: boolean;\n  printer_ids?: number[] | null;\n  enabled?: boolean;\n  expires_at?: string | null;\n}\n\n// Settings types\nexport interface AppSettings {\n  auto_archive: boolean;\n  save_thumbnails: boolean;\n  capture_finish_photo: boolean;\n  default_filament_cost: number;\n  currency: string;\n  energy_cost_per_kwh: number;\n  energy_tracking_mode: 'print' | 'total';\n  check_updates: boolean;\n  check_printer_firmware: boolean;\n  include_beta_updates: boolean;\n  language: string;\n  notification_language: string;\n  // AMS threshold settings\n  ams_humidity_good: number;  // <= this is green\n  ams_humidity_fair: number;  // <= this is orange, > is red\n  ams_temp_good: number;      // <= this is green/blue\n  ams_temp_fair: number;      // <= this is orange, > is red\n  ams_history_retention_days: number;  // days to keep AMS sensor history\n  // Queue auto-drying settings\n  queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints\n  queue_drying_block: boolean;  // Block queue until drying completes\n  ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue\n  drying_presets: string;  // JSON blob of drying presets per filament type\n  gcode_snippets: string;  // JSON: per-model G-code injection snippets\n  // Scheduled local backup\n  local_backup_enabled: boolean;\n  local_backup_schedule: string;\n  local_backup_time: string;\n  local_backup_retention: number;\n  local_backup_path: string;\n  // Print modal settings\n  per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal\n  // Date/time format settings\n  date_format: 'system' | 'us' | 'eu' | 'iso';\n  time_format: 'system' | '12h' | '24h';\n  // Filament tracking\n  disable_filament_warnings: boolean;  // Disable filament warnings (print insufficiency and assignment mismatch)\n  prefer_lowest_filament: boolean;  // When multiple spools match, prefer lowest remaining filament\n  // Default printer\n  default_printer_id: number | null;\n  // Dark mode theme settings\n  dark_style: 'classic' | 'glow' | 'vibrant';\n  dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';\n  dark_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';\n  // Light mode theme settings\n  light_style: 'classic' | 'glow' | 'vibrant';\n  light_background: 'neutral' | 'warm' | 'cool';\n  light_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';\n  // FTP retry settings\n  ftp_retry_enabled: boolean;\n  ftp_retry_count: number;\n  ftp_retry_delay: number;\n  ftp_timeout: number;\n  // MQTT relay settings\n  mqtt_enabled: boolean;\n  mqtt_broker: string;\n  mqtt_port: number;\n  mqtt_username: string;\n  mqtt_password: string;\n  mqtt_topic_prefix: string;\n  mqtt_use_tls: boolean;\n  // External URL for notifications\n  external_url: string;\n  // Home Assistant integration\n  ha_enabled: boolean;\n  ha_url: string;\n  ha_token: string;\n  ha_url_from_env: boolean;\n  ha_token_from_env: boolean;\n  ha_env_managed: boolean;\n  // File Manager / Library settings\n  library_archive_mode: 'always' | 'never' | 'ask';\n  library_disk_warning_gb: number;\n  // Camera view settings\n  camera_view_mode: 'window' | 'embedded';\n  // Preferred slicer\n  preferred_slicer: 'bambu_studio' | 'orcaslicer';\n  // Prometheus metrics\n  prometheus_enabled: boolean;\n  prometheus_token: string;\n  // Bed cooled threshold\n  bed_cooled_threshold: number;\n  // Inventory low stock threshold\n  low_stock_threshold: number;\n  // User email notifications toggle\n  user_notifications_enabled: boolean;\n  // Default print options\n  default_bed_levelling: boolean;\n  default_flow_cali: boolean;\n  default_vibration_cali: boolean;\n  default_layer_inspect: boolean;\n  default_timelapse: boolean;\n  // Staggered batch start defaults\n  stagger_group_size: number;\n  stagger_interval_minutes: number;\n  // Plate-clear confirmation\n  require_plate_clear: boolean;\n  // Shortest job first scheduling\n  queue_shortest_first: boolean;\n  // Default sidebar order (admin-set for all users)\n  default_sidebar_order: string;\n  // LDAP authentication\n  ldap_enabled: boolean;\n  ldap_server_url: string;\n  ldap_bind_dn: string;\n  ldap_bind_password: string;\n  ldap_search_base: string;\n  ldap_user_filter: string;\n  ldap_security: string;\n  ldap_group_mapping: string;\n  ldap_auto_provision: boolean;\n  ldap_default_group: string;\n  obico_enabled: boolean;\n  obico_ml_url: string;\n  obico_sensitivity: 'low' | 'medium' | 'high';\n  obico_action: 'notify' | 'pause' | 'pause_and_off';\n  obico_poll_interval: number;\n  obico_enabled_printers: string;\n}\n\nexport type AppSettingsUpdate = Partial<AppSettings>;\n\n// MQTT relay status\nexport interface MQTTStatus {\n  enabled: boolean;\n  connected: boolean;\n  broker: string;\n  port: number;\n  topic_prefix: string;\n}\n\n// Cloud types\nexport interface CloudAuthStatus {\n  is_authenticated: boolean;\n  email: string | null;\n  region?: 'global' | 'china' | null;\n}\n\nexport interface CloudLoginResponse {\n  success: boolean;\n  needs_verification: boolean;\n  message: string;\n  verification_type?: 'email' | 'totp' | null;\n  tfa_key?: string | null;\n}\n\nexport interface SlicerSetting {\n  setting_id: string;\n  name: string;\n  type: string;\n  version: string | null;\n  user_id: string | null;\n  updated_time: string | null;\n  is_custom: boolean;\n}\n\nexport interface SpoolCatalogEntry {\n  id: number;\n  name: string;\n  weight: number;\n  is_default: boolean;\n}\n\nexport interface ColorCatalogEntry {\n  id: number;\n  manufacturer: string;\n  color_name: string;\n  hex_color: string;\n  material: string | null;\n  is_default: boolean;\n}\n\nexport interface ColorLookupResult {\n  found: boolean;\n  hex_color: string | null;\n  material: string | null;\n}\n\nexport interface SlicerSettingsResponse {\n  filament: SlicerSetting[];\n  printer: SlicerSetting[];\n  process: SlicerSetting[];\n}\n\nexport interface SlicerSettingDetail {\n  message?: string | null;\n  code?: string | null;\n  error?: string | null;\n  public: boolean;\n  version?: string | null;\n  type: string;\n  name: string;\n  update_time?: string | null;\n  nickname?: string | null;\n  base_id?: string | null;\n  setting: Record<string, unknown>;\n  filament_id?: string | null;\n  setting_id?: string | null;\n}\n\nexport interface SlicerSettingCreate {\n  type: string;  // 'filament', 'print', or 'printer'\n  name: string;\n  base_id: string;\n  setting: Record<string, unknown>;\n}\n\nexport interface SlicerSettingUpdate {\n  name?: string;\n  setting?: Record<string, unknown>;\n}\n\nexport interface SlicerSettingDeleteResponse {\n  success: boolean;\n  message: string;\n}\n\n// Built-in filament fallback (static table from backend)\nexport interface BuiltinFilament {\n  filament_id: string;\n  name: string;\n}\n\n// Local preset types (OrcaSlicer imports)\nexport interface LocalPreset {\n  id: number;\n  name: string;\n  preset_type: string;\n  source: string;\n  filament_type: string | null;\n  filament_vendor: string | null;\n  nozzle_temp_min: number | null;\n  nozzle_temp_max: number | null;\n  pressure_advance: string | null;\n  default_filament_colour: string | null;\n  filament_cost: string | null;\n  filament_density: string | null;\n  compatible_printers: string | null;\n  inherits: string | null;\n  version: string | null;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface LocalPresetDetail extends LocalPreset {\n  setting: Record<string, unknown>;\n}\n\nexport interface LocalPresetsResponse {\n  filament: LocalPreset[];\n  printer: LocalPreset[];\n  process: LocalPreset[];\n}\n\nexport interface ImportResponse {\n  success: boolean;\n  imported: number;\n  skipped: number;\n  errors: string[];\n}\n\nexport interface FieldOption {\n  value: string;\n  label: string;\n}\n\nexport interface FieldDefinition {\n  key: string;\n  label: string;\n  type: 'text' | 'number' | 'boolean' | 'select';\n  category: string;\n  description?: string;\n  options?: FieldOption[];\n  unit?: string;\n  min?: number;\n  max?: number;\n  step?: number;\n}\n\nexport interface FieldDefinitionsResponse {\n  version: string;\n  description: string;\n  fields: FieldDefinition[];\n}\n\nexport interface CloudDevice {\n  dev_id: string;\n  name: string;\n  dev_model_name: string | null;\n  dev_product_name: string | null;\n  online: boolean;\n}\n\n// Smart Plug types\nexport interface SmartPlug {\n  id: number;\n  name: string;\n  plug_type: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';\n  ip_address: string | null;  // Required for Tasmota\n  ha_entity_id: string | null;  // Required for Home Assistant (e.g., \"switch.printer_plug\", \"script.turn_on_printer\")\n  // Home Assistant energy sensor entities (optional)\n  ha_power_entity: string | null;\n  ha_energy_today_entity: string | null;\n  ha_energy_total_entity: string | null;\n  // MQTT fields (required when plug_type=\"mqtt\")\n  // Legacy field - kept for backward compatibility\n  mqtt_topic: string | null;  // Deprecated, use mqtt_power_topic\n  mqtt_multiplier: number;  // Deprecated, use mqtt_power_multiplier\n  // Power monitoring\n  mqtt_power_topic: string | null;  // Topic for power data\n  mqtt_power_path: string | null;  // e.g., \"power_l1\" or \"data.power\"\n  mqtt_power_multiplier: number;  // Unit conversion for power\n  // Energy monitoring\n  mqtt_energy_topic: string | null;  // Topic for energy data\n  mqtt_energy_path: string | null;  // e.g., \"energy_l1\"\n  mqtt_energy_multiplier: number;  // Unit conversion for energy\n  // State monitoring\n  mqtt_state_topic: string | null;  // Topic for state data\n  mqtt_state_path: string | null;  // e.g., \"state_l1\" for ON/OFF\n  mqtt_state_on_value: string | null;  // What value means \"ON\" (e.g., \"ON\", \"true\", \"1\")\n  // REST/Webhook fields (required when plug_type=\"rest\")\n  rest_on_url: string | null;\n  rest_on_body: string | null;\n  rest_off_url: string | null;\n  rest_off_body: string | null;\n  rest_method: string | null;\n  rest_headers: string | null;\n  rest_status_url: string | null;\n  rest_status_path: string | null;\n  rest_status_on_value: string | null;\n  rest_power_url: string | null;\n  rest_power_path: string | null;\n  rest_power_multiplier: number;\n  rest_energy_url: string | null;\n  rest_energy_path: string | null;\n  rest_energy_multiplier: number;\n  printer_id: number | null;\n  enabled: boolean;\n  auto_on: boolean;\n  auto_off: boolean;\n  auto_off_persistent: boolean;\n  off_delay_mode: 'time' | 'temperature';\n  off_delay_minutes: number;\n  off_temp_threshold: number;\n  username: string | null;\n  password: string | null;\n  // Power alerts\n  power_alert_enabled: boolean;\n  power_alert_high: number | null;\n  power_alert_low: number | null;\n  power_alert_last_triggered: string | null;\n  // Schedule\n  schedule_enabled: boolean;\n  schedule_on_time: string | null;\n  schedule_off_time: string | null;\n  // Visibility options\n  show_in_switchbar: boolean;\n  show_on_printer_card: boolean;  // For scripts: show on printer card\n  // Status\n  last_state: string | null;\n  last_checked: string | null;\n  auto_off_executed: boolean;  // True when auto-off was triggered after print\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface SmartPlugCreate {\n  name: string;\n  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';\n  ip_address?: string | null;  // Required for Tasmota\n  ha_entity_id?: string | null;  // Required for Home Assistant\n  // Home Assistant energy sensor entities (optional)\n  ha_power_entity?: string | null;\n  ha_energy_today_entity?: string | null;\n  ha_energy_total_entity?: string | null;\n  // MQTT fields (required when plug_type=\"mqtt\")\n  // Legacy fields - kept for backward compatibility\n  mqtt_topic?: string | null;\n  mqtt_multiplier?: number;\n  // Power monitoring\n  mqtt_power_topic?: string | null;\n  mqtt_power_path?: string | null;\n  mqtt_power_multiplier?: number;\n  // Energy monitoring\n  mqtt_energy_topic?: string | null;\n  mqtt_energy_path?: string | null;\n  mqtt_energy_multiplier?: number;\n  // State monitoring\n  mqtt_state_topic?: string | null;\n  mqtt_state_path?: string | null;\n  mqtt_state_on_value?: string | null;\n  // REST fields\n  rest_on_url?: string | null;\n  rest_on_body?: string | null;\n  rest_off_url?: string | null;\n  rest_off_body?: string | null;\n  rest_method?: string | null;\n  rest_headers?: string | null;\n  rest_status_url?: string | null;\n  rest_status_path?: string | null;\n  rest_status_on_value?: string | null;\n  rest_power_url?: string | null;\n  rest_power_path?: string | null;\n  rest_power_multiplier?: number;\n  rest_energy_url?: string | null;\n  rest_energy_path?: string | null;\n  rest_energy_multiplier?: number;\n  printer_id?: number | null;\n  enabled?: boolean;\n  auto_on?: boolean;\n  auto_off?: boolean;\n  auto_off_persistent?: boolean;\n  off_delay_mode?: 'time' | 'temperature';\n  off_delay_minutes?: number;\n  off_temp_threshold?: number;\n  username?: string | null;\n  password?: string | null;\n  // Power alerts\n  power_alert_enabled?: boolean;\n  power_alert_high?: number | null;\n  power_alert_low?: number | null;\n  // Schedule\n  schedule_enabled?: boolean;\n  schedule_on_time?: string | null;\n  schedule_off_time?: string | null;\n  // Visibility options\n  show_in_switchbar?: boolean;\n  show_on_printer_card?: boolean;\n}\n\nexport interface SmartPlugUpdate {\n  name?: string;\n  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';\n  ip_address?: string | null;\n  ha_entity_id?: string | null;\n  // Home Assistant energy sensor entities (optional)\n  ha_power_entity?: string | null;\n  ha_energy_today_entity?: string | null;\n  ha_energy_total_entity?: string | null;\n  // MQTT fields (legacy)\n  mqtt_topic?: string | null;\n  mqtt_multiplier?: number;\n  // MQTT power fields\n  mqtt_power_topic?: string | null;\n  mqtt_power_path?: string | null;\n  mqtt_power_multiplier?: number;\n  // MQTT energy fields\n  mqtt_energy_topic?: string | null;\n  mqtt_energy_path?: string | null;\n  mqtt_energy_multiplier?: number;\n  // MQTT state fields\n  mqtt_state_topic?: string | null;\n  mqtt_state_path?: string | null;\n  mqtt_state_on_value?: string | null;\n  // REST fields\n  rest_on_url?: string | null;\n  rest_on_body?: string | null;\n  rest_off_url?: string | null;\n  rest_off_body?: string | null;\n  rest_method?: string | null;\n  rest_headers?: string | null;\n  rest_status_url?: string | null;\n  rest_status_path?: string | null;\n  rest_status_on_value?: string | null;\n  rest_power_url?: string | null;\n  rest_power_path?: string | null;\n  rest_power_multiplier?: number;\n  rest_energy_url?: string | null;\n  rest_energy_path?: string | null;\n  rest_energy_multiplier?: number;\n  printer_id?: number | null;\n  enabled?: boolean;\n  auto_on?: boolean;\n  auto_off?: boolean;\n  auto_off_persistent?: boolean;\n  off_delay_mode?: 'time' | 'temperature';\n  off_delay_minutes?: number;\n  off_temp_threshold?: number;\n  username?: string | null;\n  password?: string | null;\n  // Power alerts\n  power_alert_enabled?: boolean;\n  power_alert_high?: number | null;\n  power_alert_low?: number | null;\n  // Schedule\n  schedule_enabled?: boolean;\n  schedule_on_time?: string | null;\n  schedule_off_time?: string | null;\n  // Visibility options\n  show_in_switchbar?: boolean;\n  show_on_printer_card?: boolean;\n}\n\n// Home Assistant entity for smart plug selection\nexport interface HAEntity {\n  entity_id: string;\n  friendly_name: string;\n  state: string | null;\n  domain: string;  // \"switch\", \"light\", \"input_boolean\", \"script\"\n}\n\n// Home Assistant sensor entity for energy monitoring\nexport interface HASensorEntity {\n  entity_id: string;\n  friendly_name: string;\n  state: string | null;\n  unit_of_measurement: string | null;  // \"W\", \"kW\", \"kWh\", \"Wh\"\n}\n\nexport interface HATestConnectionResult {\n  success: boolean;\n  message: string | null;\n  error: string | null;\n}\n\nexport interface SmartPlugEnergy {\n  power: number | null;  // Current watts\n  voltage: number | null;  // Volts\n  current: number | null;  // Amps\n  today: number | null;  // kWh used today\n  yesterday: number | null;  // kWh used yesterday\n  total: number | null;  // Total kWh\n  factor: number | null;  // Power factor (0-1)\n  apparent_power: number | null;  // VA\n  reactive_power: number | null;  // VAr\n}\n\nexport interface SmartPlugStatus {\n  state: string | null;\n  reachable: boolean;\n  device_name: string | null;\n  energy: SmartPlugEnergy | null;\n}\n\nexport interface SmartPlugTestResult {\n  success: boolean;\n  state: string | null;\n  device_name: string | null;\n}\n\n// Tasmota Discovery types\nexport interface TasmotaScanStatus {\n  running: boolean;\n  scanned: number;\n  total: number;\n}\n\nexport interface DiscoveredTasmotaDevice {\n  ip_address: string;\n  name: string;\n  module: number | null;\n  state: string | null;\n  discovered_at: string | null;\n}\n\n// Print Queue types\nexport interface PrintQueueItem {\n  id: number;\n  printer_id: number | null;  // null = unassigned\n  target_model: string | null;  // Target printer model for model-based assignment\n  target_location: string | null;  // Target location filter for model-based assignment\n  required_filament_types: string[] | null;  // Required filament types for model-based assignment\n  waiting_reason: string | null;  // Why a model-based job hasn't started yet\n  // Either archive_id OR library_file_id must be set (archive created at print start)\n  archive_id: number | null;\n  library_file_id: number | null;\n  position: number;\n  scheduled_time: string | null;\n  require_previous_success: boolean;\n  auto_off_after: boolean;\n  manual_start: boolean;  // Requires manual trigger to start (staged)\n  ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints\n  filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment\n  plate_id: number | null;  // Plate ID for multi-plate 3MF files\n  // Print options\n  bed_levelling: boolean;\n  flow_cali: boolean;\n  vibration_cali: boolean;\n  layer_inspect: boolean;\n  timelapse: boolean;\n  use_ams: boolean;\n  status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';\n  started_at: string | null;\n  completed_at: string | null;\n  error_message: string | null;\n  created_at: string;\n  archive_name?: string | null;\n  archive_thumbnail?: string | null;\n  library_file_name?: string | null;\n  library_file_thumbnail?: string | null;\n  printer_name?: string | null;\n  print_time_seconds?: number | null;  // Estimated print time from archive or library file\n  filament_used_grams?: number | null;  // Estimated print weight from archive or library file\n  // User tracking (Issue #206)\n  created_by_id?: number | null;\n  created_by_username?: string | null;\n  // Batch grouping\n  batch_id?: number | null;\n  batch_name?: string | null;\n  // Shortest-job-first scheduling\n  been_jumped?: boolean;\n  // Auto-print G-code injection\n  gcode_injection?: boolean;\n}\n\nexport interface PrintBatch {\n  id: number;\n  name: string;\n  archive_id: number | null;\n  library_file_id: number | null;\n  quantity: number;\n  status: string;\n  created_at: string;\n  created_by_id: number | null;\n  created_by_username: string | null;\n  pending_count: number;\n  printing_count: number;\n  completed_count: number;\n  failed_count: number;\n  cancelled_count: number;\n}\n\nexport interface PrintQueueItemCreate {\n  printer_id?: number | null;  // null = unassigned\n  target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)\n  target_location?: string | null;  // Target location filter (only used with target_model)\n  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;\n  archive_id?: number | null;\n  library_file_id?: number | null;\n  scheduled_time?: string | null;\n  require_previous_success?: boolean;\n  auto_off_after?: boolean;\n  manual_start?: boolean;  // Requires manual trigger to start (staged)\n  ams_mapping?: number[] | null;  // AMS slot mapping for multi-color prints\n  plate_id?: number | null;  // Plate ID for multi-plate 3MF files\n  // Print options\n  bed_levelling?: boolean;\n  flow_cali?: boolean;\n  vibration_cali?: boolean;\n  layer_inspect?: boolean;\n  timelapse?: boolean;\n  use_ams?: boolean;\n  // Auto-print G-code injection\n  gcode_injection?: boolean;\n  // Batch: create multiple copies (creates a batch if > 1)\n  quantity?: number;\n  // Project to associate the resulting archive with\n  project_id?: number;\n}\n\nexport interface PrintQueueItemUpdate {\n  printer_id?: number | null;  // null = unassign\n  target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)\n  target_location?: string | null;  // Target location filter (only used with target_model)\n  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;\n  position?: number;\n  scheduled_time?: string | null;\n  require_previous_success?: boolean;\n  auto_off_after?: boolean;\n  manual_start?: boolean;\n  ams_mapping?: number[];\n  plate_id?: number | null;  // Plate ID for multi-plate 3MF files\n  // Print options\n  bed_levelling?: boolean;\n  flow_cali?: boolean;\n  vibration_cali?: boolean;\n  layer_inspect?: boolean;\n  timelapse?: boolean;\n  use_ams?: boolean;\n  // Auto-print G-code injection\n  gcode_injection?: boolean;\n}\n\nexport interface PrintQueueBulkUpdate {\n  item_ids: number[];\n  printer_id?: number | null;\n  scheduled_time?: string | null;\n  require_previous_success?: boolean;\n  auto_off_after?: boolean;\n  manual_start?: boolean;\n  // Print options\n  bed_levelling?: boolean;\n  flow_cali?: boolean;\n  vibration_cali?: boolean;\n  layer_inspect?: boolean;\n  timelapse?: boolean;\n  use_ams?: boolean;\n  // Auto-print G-code injection\n  gcode_injection?: boolean;\n}\n\nexport interface PrintQueueBulkUpdateResponse {\n  updated_count: number;\n  skipped_count: number;\n  message: string;\n}\n\n// MQTT Logging types\nexport interface MQTTLogEntry {\n  timestamp: string;\n  topic: string;\n  direction: 'in' | 'out';\n  payload: Record<string, unknown>;\n}\n\nexport interface MQTTLogsResponse {\n  logging_enabled: boolean;\n  logs: MQTTLogEntry[];\n}\n\n// K-Profile types\nexport interface KProfile {\n  slot_id: number;\n  extruder_id: number;\n  nozzle_id: string;\n  nozzle_diameter: string;\n  filament_id: string;\n  name: string;\n  k_value: string;\n  n_coef: string;\n  ams_id: number;\n  tray_id: number;\n  setting_id: string | null;\n}\n\nexport interface KProfileCreate {\n  slot_id?: number;  // Storage slot, 0 for new profiles\n  extruder_id?: number;\n  nozzle_id: string;\n  nozzle_diameter: string;\n  filament_id: string;\n  name: string;\n  k_value: string;\n  n_coef?: string;\n  ams_id?: number;\n  tray_id?: number;\n  setting_id?: string | null;\n}\n\nexport interface KProfileDelete {\n  slot_id: number;  // cali_idx - calibration index to delete\n  extruder_id: number;\n  nozzle_id: string;  // e.g., \"HH00-0.4\"\n  nozzle_diameter: string;  // e.g., \"0.4\"\n  filament_id: string;  // Bambu filament identifier\n  setting_id?: string | null;  // Setting ID (for X1C series)\n}\n\nexport interface KProfilesResponse {\n  profiles: KProfile[];\n  nozzle_diameter: string;\n}\n\nexport interface KProfileNote {\n  setting_id: string;\n  note: string;\n}\n\nexport interface KProfileNotesResponse {\n  notes: Record<string, string>;  // setting_id -> note\n}\n\n// Slot Preset Mapping\nexport interface SlotPresetMapping {\n  ams_id: number;\n  tray_id: number;\n  preset_id: string;\n  preset_name: string;\n}\n\n// Filament types\nexport interface Filament {\n  id: number;\n  name: string;\n  type: string;  // PLA, PETG, ABS, etc.\n  brand: string | null;\n  color: string | null;\n  color_hex: string | null;\n  cost_per_kg: number;\n  spool_weight_g: number;\n  currency: string;\n  density: number | null;\n  print_temp_min: number | null;\n  print_temp_max: number | null;\n  bed_temp_min: number | null;\n  bed_temp_max: number | null;\n  created_at: string;\n  updated_at: string;\n}\n\n// Notification Provider types\nexport type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant';\n\nexport interface NotificationProvider {\n  id: number;\n  name: string;\n  provider_type: ProviderType;\n  enabled: boolean;\n  config: Record<string, unknown>;\n  // Print lifecycle events\n  on_print_start: boolean;\n  on_print_complete: boolean;\n  on_print_failed: boolean;\n  on_print_stopped: boolean;\n  on_print_progress: boolean;\n  on_print_missing_spool_assignment: boolean;\n  // Printer status events\n  on_printer_offline: boolean;\n  on_printer_error: boolean;\n  on_filament_low: boolean;\n  on_maintenance_due: boolean;\n  // AMS environmental alarms (regular AMS)\n  on_ams_humidity_high: boolean;\n  on_ams_temperature_high: boolean;\n  // AMS-HT environmental alarms\n  on_ams_ht_humidity_high: boolean;\n  on_ams_ht_temperature_high: boolean;\n  // Build plate detection\n  on_plate_not_empty: boolean;\n  // Bed cooled\n  on_bed_cooled: boolean;\n  // First layer complete\n  on_first_layer_complete: boolean;\n  // Print queue events\n  on_queue_job_added: boolean;\n  on_queue_job_assigned: boolean;\n  on_queue_job_started: boolean;\n  on_queue_job_waiting: boolean;\n  on_queue_job_skipped: boolean;\n  on_queue_job_failed: boolean;\n  on_queue_completed: boolean;\n  // Quiet hours\n  quiet_hours_enabled: boolean;\n  quiet_hours_start: string | null;\n  quiet_hours_end: string | null;\n  // Daily digest\n  daily_digest_enabled: boolean;\n  daily_digest_time: string | null;\n  // Printer filter\n  printer_id: number | null;\n  // Status tracking\n  last_success: string | null;\n  last_error: string | null;\n  last_error_at: string | null;\n  // Timestamps\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface NotificationProviderCreate {\n  name: string;\n  provider_type: ProviderType;\n  enabled?: boolean;\n  config: Record<string, unknown>;\n  // Print lifecycle events\n  on_print_start?: boolean;\n  on_print_complete?: boolean;\n  on_print_failed?: boolean;\n  on_print_stopped?: boolean;\n  on_print_progress?: boolean;\n  on_print_missing_spool_assignment?: boolean;\n  // Printer status events\n  on_printer_offline?: boolean;\n  on_printer_error?: boolean;\n  on_filament_low?: boolean;\n  on_maintenance_due?: boolean;\n  // AMS environmental alarms (regular AMS)\n  on_ams_humidity_high?: boolean;\n  on_ams_temperature_high?: boolean;\n  // AMS-HT environmental alarms\n  on_ams_ht_humidity_high?: boolean;\n  on_ams_ht_temperature_high?: boolean;\n  // Build plate detection\n  on_plate_not_empty?: boolean;\n  // Bed cooled\n  on_bed_cooled?: boolean;\n  // First layer complete\n  on_first_layer_complete?: boolean;\n  // Print queue events\n  on_queue_job_added?: boolean;\n  on_queue_job_assigned?: boolean;\n  on_queue_job_started?: boolean;\n  on_queue_job_waiting?: boolean;\n  on_queue_job_skipped?: boolean;\n  on_queue_job_failed?: boolean;\n  on_queue_completed?: boolean;\n  // Quiet hours\n  quiet_hours_enabled?: boolean;\n  quiet_hours_start?: string | null;\n  quiet_hours_end?: string | null;\n  // Daily digest\n  daily_digest_enabled?: boolean;\n  daily_digest_time?: string | null;\n  // Printer filter\n  printer_id?: number | null;\n}\n\nexport interface NotificationProviderUpdate {\n  name?: string;\n  provider_type?: ProviderType;\n  enabled?: boolean;\n  config?: Record<string, unknown>;\n  // Print lifecycle events\n  on_print_start?: boolean;\n  on_print_complete?: boolean;\n  on_print_failed?: boolean;\n  on_print_stopped?: boolean;\n  on_print_progress?: boolean;\n  on_print_missing_spool_assignment?: boolean;\n  // Printer status events\n  on_printer_offline?: boolean;\n  on_printer_error?: boolean;\n  on_filament_low?: boolean;\n  on_maintenance_due?: boolean;\n  // AMS environmental alarms (regular AMS)\n  on_ams_humidity_high?: boolean;\n  on_ams_temperature_high?: boolean;\n  // AMS-HT environmental alarms\n  on_ams_ht_humidity_high?: boolean;\n  on_ams_ht_temperature_high?: boolean;\n  // Build plate detection\n  on_plate_not_empty?: boolean;\n  // Bed cooled\n  on_bed_cooled?: boolean;\n  // First layer complete\n  on_first_layer_complete?: boolean;\n  // Print queue events\n  on_queue_job_added?: boolean;\n  on_queue_job_assigned?: boolean;\n  on_queue_job_started?: boolean;\n  on_queue_job_waiting?: boolean;\n  on_queue_job_skipped?: boolean;\n  on_queue_job_failed?: boolean;\n  on_queue_completed?: boolean;\n  // Quiet hours\n  quiet_hours_enabled?: boolean;\n  quiet_hours_start?: string | null;\n  quiet_hours_end?: string | null;\n  // Daily digest\n  daily_digest_enabled?: boolean;\n  daily_digest_time?: string | null;\n  // Printer filter\n  printer_id?: number | null;\n}\n\n// GitHub Backup types\nexport type ScheduleType = 'hourly' | 'daily' | 'weekly';\n\nexport interface GitHubBackupConfig {\n  id: number;\n  repository_url: string;\n  has_token: boolean;\n  branch: string;\n  schedule_enabled: boolean;\n  schedule_type: ScheduleType;\n  backup_kprofiles: boolean;\n  backup_cloud_profiles: boolean;\n  backup_settings: boolean;\n  backup_spools: boolean;\n  backup_archives: boolean;\n  enabled: boolean;\n  last_backup_at: string | null;\n  last_backup_status: string | null;\n  last_backup_message: string | null;\n  last_backup_commit_sha: string | null;\n  next_scheduled_run: string | null;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface GitHubBackupConfigCreate {\n  repository_url: string;\n  access_token: string;\n  branch?: string;\n  schedule_enabled?: boolean;\n  schedule_type?: ScheduleType;\n  backup_kprofiles?: boolean;\n  backup_cloud_profiles?: boolean;\n  backup_settings?: boolean;\n  backup_spools?: boolean;\n  backup_archives?: boolean;\n  enabled?: boolean;\n}\n\nexport interface GitHubBackupLog {\n  id: number;\n  config_id: number;\n  started_at: string;\n  completed_at: string | null;\n  status: string;\n  trigger: string;\n  commit_sha: string | null;\n  files_changed: number;\n  error_message: string | null;\n}\n\nexport interface GitHubBackupStatus {\n  configured: boolean;\n  enabled: boolean;\n  is_running: boolean;\n  progress: string | null;\n  last_backup_at: string | null;\n  last_backup_status: string | null;\n  next_scheduled_run: string | null;\n}\n\nexport interface LocalBackupStatus {\n  enabled: boolean;\n  schedule: string;\n  time: string;\n  retention: number;\n  path: string;\n  default_path: string;\n  is_running: boolean;\n  last_backup_at: string | null;\n  last_status: string | null;\n  last_message: string | null;\n  next_run: string | null;\n}\n\nexport interface LocalBackupFile {\n  filename: string;\n  size: number;\n  created_at: string;\n}\n\nexport interface ObicoDetectionEvent {\n  printer_id: number;\n  task_name: string;\n  timestamp: string;\n  current_p: number;\n  score: number;\n  class: 'safe' | 'warning' | 'failure';\n  detections: number;\n}\n\nexport interface ObicoStatus {\n  is_running: boolean;\n  last_error: string | null;\n  per_printer: Record<string, { class: string; frame_count: number; score: number }>;\n  thresholds: { low: number; high: number };\n  history: ObicoDetectionEvent[];\n  enabled: boolean;\n  ml_url: string;\n  sensitivity: 'low' | 'medium' | 'high';\n  action: 'notify' | 'pause' | 'pause_and_off';\n  poll_interval: number;\n  external_url_configured: boolean;\n}\n\nexport interface ObicoTestConnection {\n  ok: boolean;\n  status_code: number | null;\n  body: string | null;\n  error: string | null;\n}\n\nexport interface GitHubTestConnectionResponse {\n  success: boolean;\n  message: string;\n  repo_name: string | null;\n  permissions: Record<string, boolean> | null;\n}\n\nexport interface GitHubBackupTriggerResponse {\n  success: boolean;\n  message: string;\n  log_id: number | null;\n  commit_sha: string | null;\n  files_changed: number;\n}\n\nexport interface NotificationTestRequest {\n  provider_type: ProviderType;\n  config: Record<string, unknown>;\n}\n\nexport interface NotificationTestResponse {\n  success: boolean;\n  message: string;\n}\n\nexport interface BackgroundDispatchResponse {\n  status: 'dispatched' | string;\n  printer_id: number;\n  archive_id?: number | null;\n  filename: string;\n  dispatch_job_id: number;\n  dispatch_position: number;\n}\n\n// Provider-specific config types for reference\nexport interface CallMeBotConfig {\n  phone: string;\n  apikey: string;\n}\n\nexport interface NtfyConfig {\n  server?: string;\n  topic: string;\n  auth_token?: string | null;\n}\n\nexport interface PushoverConfig {\n  user_key: string;\n  app_token: string;\n  priority?: number;\n}\n\nexport interface TelegramConfig {\n  bot_token: string;\n  chat_id: string;\n}\n\nexport interface EmailConfig {\n  smtp_server: string;\n  smtp_port?: number;\n  username: string;\n  password: string;\n  from_email: string;\n  to_email: string;\n  use_tls?: boolean;\n}\n\n// Notification Template types\nexport interface NotificationTemplate {\n  id: number;\n  event_type: string;\n  name: string;\n  title_template: string;\n  body_template: string;\n  is_default: boolean;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface NotificationTemplateUpdate {\n  title_template?: string;\n  body_template?: string;\n}\n\nexport interface EventVariablesResponse {\n  event_type: string;\n  event_name: string;\n  variables: string[];\n}\n\nexport interface TemplatePreviewRequest {\n  event_type: string;\n  title_template: string;\n  body_template: string;\n}\n\nexport interface TemplatePreviewResponse {\n  title: string;\n  body: string;\n}\n\n// Notification Log types\nexport interface NotificationLogEntry {\n  id: number;\n  provider_id: number;\n  provider_name: string | null;\n  provider_type: string | null;\n  event_type: string;\n  title: string;\n  message: string;\n  success: boolean;\n  error_message: string | null;\n  printer_id: number | null;\n  printer_name: string | null;\n  created_at: string;\n}\n\nexport interface NotificationLogStats {\n  total: number;\n  success_count: number;\n  failure_count: number;\n  by_event_type: Record<string, number>;\n  by_provider: Record<string, number>;\n}\n\n// Spoolman types\nexport interface SpoolmanStatus {\n  enabled: boolean;\n  connected: boolean;\n  url: string | null;\n}\n\nexport interface SkippedSpool {\n  location: string;\n  reason: string;\n  filament_type: string | null;\n  color: string | null;\n}\n\nexport interface SpoolmanSyncResult {\n  success: boolean;\n  synced_count: number;\n  skipped_count: number;\n  skipped: SkippedSpool[];\n  errors: string[];\n}\n\nexport interface UnlinkedSpool {\n  id: number;\n  filament_name: string | null;\n  filament_vendor: string | null;\n  filament_material: string | null;\n  filament_color_hex: string | null;\n  remaining_weight: number | null;\n  location: string | null;\n}\n\nexport interface LinkedSpoolInfo {\n  id: number;\n  remaining_weight: number | null;\n  filament_weight: number | null;\n}\n\nexport interface LinkedSpoolsMap {\n  linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info\n}\n\n// Inventory types\nexport interface InventorySpool {\n  id: number;\n  material: string;\n  subtype: string | null;\n  color_name: string | null;\n  rgba: string | null;\n  brand: string | null;\n  label_weight: number;\n  core_weight: number;\n  core_weight_catalog_id: number | null;\n  weight_used: number;\n  slicer_filament: string | null;\n  slicer_filament_name: string | null;\n  nozzle_temp_min: number | null;\n  nozzle_temp_max: number | null;\n  note: string | null;\n  added_full: boolean | null;\n  last_used: string | null;\n  encode_time: string | null;\n  tag_uid: string | null;\n  tray_uuid: string | null;\n  data_origin: string | null;\n  tag_type: string | null;\n  archived_at: string | null;\n  created_at: string;\n  updated_at: string;\n  cost_per_kg: number | null;\n  last_scale_weight: number | null;\n  last_weighed_at: string | null;\n  k_profiles?: SpoolKProfile[];\n}\n\nexport interface SpoolUsageRecord {\n  id: number;\n  spool_id: number;\n  printer_id: number | null;\n  print_name: string | null;\n  weight_used: number;\n  percent_used: number;\n  status: string;\n  cost: number | null;\n  created_at: string;\n}\n\nexport interface SpoolKProfile {\n  id: number;\n  spool_id: number;\n  printer_id: number;\n  extruder: number;\n  nozzle_diameter: string;\n  nozzle_type: string | null;\n  k_value: number;\n  name: string | null;\n  cali_idx: number | null;\n  setting_id: string | null;\n  created_at: string;\n}\n\nexport interface SpoolKProfileInput {\n  printer_id: number;\n  extruder?: number;\n  nozzle_diameter?: string;\n  nozzle_type?: string | null;\n  k_value: number;\n  name?: string | null;\n  cali_idx?: number | null;\n  setting_id?: string | null;\n}\n\nexport interface SpoolAssignment {\n  id: number;\n  spool_id: number;\n  printer_id: number;\n  printer_name: string | null;\n  ams_id: number;\n  tray_id: number;\n  fingerprint_color: string | null;\n  fingerprint_type: string | null;\n  spool?: InventorySpool | null;\n  configured: boolean;\n  created_at: string;\n  ams_label?: string | null;  // User-defined friendly name for the AMS unit\n}\n\n// Update types\nexport interface VersionInfo {\n  version: string;\n  repo: string;\n}\n\nexport interface UpdateCheckResult {\n  update_available: boolean;\n  current_version: string;\n  latest_version: string | null;\n  release_name?: string;\n  release_notes?: string;\n  release_url?: string;\n  published_at?: string;\n  error?: string;\n  message?: string;\n  is_docker?: boolean;\n  update_method?: 'docker' | 'git';\n}\n\nexport interface UpdateStatus {\n  status: 'idle' | 'checking' | 'downloading' | 'installing' | 'complete' | 'error';\n  progress: number;\n  message: string;\n  error: string | null;\n}\n\n// Maintenance types\nexport interface MaintenanceType {\n  id: number;\n  name: string;\n  description: string | null;\n  default_interval_hours: number;\n  interval_type: 'hours' | 'days';  // \"hours\" = print hours, \"days\" = calendar days\n  icon: string | null;\n  wiki_url: string | null;  // Documentation link\n  is_system: boolean;\n  created_at: string;\n}\n\nexport interface MaintenanceTypeCreate {\n  name: string;\n  description?: string | null;\n  default_interval_hours?: number;\n  interval_type?: 'hours' | 'days';\n  icon?: string | null;\n  wiki_url?: string | null;\n}\n\nexport interface MaintenanceStatus {\n  id: number;\n  printer_id: number;\n  printer_name: string;\n  printer_model: string | null;\n  maintenance_type_id: number;\n  maintenance_type_name: string;\n  maintenance_type_icon: string | null;\n  maintenance_type_wiki_url: string | null;  // Custom wiki URL from type\n  enabled: boolean;\n  interval_hours: number;  // For hours type: print hours; for days type: number of days\n  interval_type: 'hours' | 'days';\n  current_hours: number;\n  hours_since_maintenance: number;\n  hours_until_due: number;\n  days_since_maintenance: number | null;  // For days type\n  days_until_due: number | null;  // For days type\n  is_due: boolean;\n  is_warning: boolean;\n  last_performed_at: string | null;\n}\n\nexport interface PrinterMaintenanceOverview {\n  printer_id: number;\n  printer_name: string;\n  printer_model: string | null;\n  total_print_hours: number;\n  maintenance_items: MaintenanceStatus[];\n  due_count: number;\n  warning_count: number;\n}\n\nexport interface MaintenanceHistory {\n  id: number;\n  printer_maintenance_id: number;\n  performed_at: string;\n  hours_at_maintenance: number;\n  notes: string | null;\n}\n\nexport interface MaintenanceSummary {\n  total_due: number;\n  total_warning: number;\n  printers_with_issues: Array<{\n    printer_id: number;\n    printer_name: string;\n    due_count: number;\n    warning_count: number;\n  }>;\n}\n\n// External Links (sidebar)\nexport interface ExternalLink {\n  id: number;\n  name: string;\n  url: string;\n  icon: string;\n  open_in_new_tab: boolean;\n  custom_icon: string | null;\n  sort_order: number;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface ExternalLinkCreate {\n  name: string;\n  url: string;\n  icon: string;\n  open_in_new_tab?: boolean;\n}\n\nexport interface ExternalLinkUpdate {\n  name?: string;\n  url?: string;\n  icon?: string;\n  open_in_new_tab?: boolean;\n}\n\n// Permission type - all available permissions\nexport type Permission =\n  | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate'\n  | 'archives:read' | 'archives:create'\n  | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all'\n  | 'archives:reprint_own' | 'archives:reprint_all'\n  | 'queue:read' | 'queue:create'\n  | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'\n  | 'queue:reorder'\n  | 'library:read' | 'library:upload'\n  | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'\n  | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'\n  | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'\n  | 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete' | 'inventory:view_assignments'\n  | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'\n  | 'camera:view'\n  | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'\n  | 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete'\n  | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete' | 'notifications:user_email'\n  | 'notification_templates:read' | 'notification_templates:update'\n  | 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete'\n  | 'discovery:scan'\n  | 'firmware:read' | 'firmware:update'\n  | 'ams_history:read'\n  | 'stats:read' | 'stats:filter_by_user'\n  | 'system:read'\n  | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'\n  | 'github:backup' | 'github:restore'\n  | 'cloud:auth'\n  | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'\n  | 'users:read' | 'users:create' | 'users:update' | 'users:delete'\n  | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'\n  | 'websocket:connect';\n\n// Group types\nexport interface GroupBrief {\n  id: number;\n  name: string;\n}\n\nexport interface Group {\n  id: number;\n  name: string;\n  description: string | null;\n  permissions: Permission[];\n  is_system: boolean;\n  user_count: number;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface GroupDetail extends Group {\n  users: Array<{ id: number; username: string; is_active: boolean }>;\n}\n\nexport interface GroupCreate {\n  name: string;\n  description?: string;\n  permissions: Permission[];\n}\n\nexport interface GroupUpdate {\n  name?: string;\n  description?: string;\n  permissions?: Permission[];\n}\n\nexport interface PermissionInfo {\n  value: Permission;\n  label: string;\n}\n\nexport interface PermissionCategory {\n  name: string;\n  permissions: PermissionInfo[];\n}\n\nexport interface PermissionsListResponse {\n  categories: PermissionCategory[];\n  all_permissions: Permission[];\n}\n\n// User email notification preferences\nexport interface UserEmailPreferences {\n  notify_print_start: boolean;\n  notify_print_complete: boolean;\n  notify_print_failed: boolean;\n  notify_print_stopped: boolean;\n}\n\n// Auth types\nexport interface LoginRequest {\n  username: string;\n  password: string;\n}\n\nexport interface LoginResponse {\n  access_token?: string;\n  token_type?: string;\n  user?: UserResponse;\n  /** Set when 2FA verification is required before a full token is issued. */\n  requires_2fa?: boolean;\n  pre_auth_token?: string;\n  two_fa_methods?: string[];\n}\n\nexport interface UserResponse {\n  id: number;\n  username: string;\n  email?: string;\n  role: string;  // Deprecated, kept for backward compatibility\n  is_active: boolean;\n  is_admin: boolean;  // Computed from role and group membership\n  auth_source: string;  // \"local\" or \"ldap\"\n  groups: GroupBrief[];\n  permissions: Permission[];  // All permissions from groups\n  created_at: string;\n}\n\nexport interface UserCreate {\n  username: string;\n  password?: string;  // Optional when advanced auth is enabled\n  email?: string;\n  role: string;\n  group_ids?: number[];\n}\n\nexport interface UserUpdate {\n  username?: string;\n  password?: string;\n  email?: string;\n  role?: string;\n  is_active?: boolean;\n  group_ids?: number[];\n}\n\nexport interface SetupRequest {\n  auth_enabled: boolean;\n  admin_username?: string;\n  admin_password?: string;\n}\n\nexport interface ForgotPasswordRequest {\n  email: string;\n}\n\nexport interface ForgotPasswordResponse {\n  message: string;\n}\n\nexport interface ResetPasswordRequest {\n  user_id: number;\n}\n\nexport interface ResetPasswordResponse {\n  message: string;\n}\n\nexport interface SMTPSettings {\n  smtp_host: string;\n  smtp_port: number;\n  smtp_username?: string;\n  smtp_password?: string;\n  smtp_security: 'starttls' | 'ssl' | 'none';\n  smtp_auth_enabled: boolean;\n  smtp_from_email: string;\n  smtp_from_name: string;\n}\n\n// 2FA / MFA interfaces\nexport interface TwoFAStatus {\n  totp_enabled: boolean;\n  email_otp_enabled: boolean;\n  backup_codes_remaining: number;\n}\n\nexport interface TOTPSetupResponse {\n  secret: string;\n  qr_code_b64: string;\n  issuer: string;\n}\n\nexport interface TOTPEnableResponse {\n  message: string;\n  backup_codes: string[];\n}\n\nexport interface BackupCodesResponse {\n  backup_codes: string[];\n  message: string;\n}\n\nexport interface TwoFAVerifyRequest {\n  pre_auth_token: string;\n  code: string;\n  method: 'totp' | 'email' | 'backup';\n}\n\n// OIDC interfaces\nexport interface OIDCProvider {\n  id: number;\n  name: string;\n  issuer_url: string;\n  client_id: string;\n  scopes: string;\n  is_enabled: boolean;\n  auto_create_users: boolean;\n  auto_link_existing_accounts: boolean;\n  icon_url?: string | null;\n}\n\nexport interface OIDCProviderCreate {\n  name: string;\n  issuer_url: string;\n  client_id: string;\n  client_secret: string;\n  scopes?: string;\n  is_enabled?: boolean;\n  auto_create_users?: boolean;\n  auto_link_existing_accounts?: boolean;\n  icon_url?: string | null;\n}\n\nexport interface OIDCLink {\n  id: number;\n  provider_id: number;\n  provider_name: string;\n  provider_email?: string | null;\n  created_at: string;\n}\n\nexport interface TestSMTPRequest {\n  test_recipient: string;\n}\n\nexport interface TestSMTPResponse {\n  success: boolean;\n  message: string;\n}\n\nexport interface AdvancedAuthStatus {\n  advanced_auth_enabled: boolean;\n  smtp_configured: boolean;\n}\n\nexport interface LDAPStatus {\n  ldap_enabled: boolean;\n  ldap_configured: boolean;\n}\n\nexport interface LDAPTestResponse {\n  success: boolean;\n  message: string;\n}\n\nexport interface SetupResponse {\n  auth_enabled: boolean;\n  admin_created?: boolean;\n}\n\nexport interface AuthStatus {\n  auth_enabled: boolean;\n  requires_setup: boolean;\n}\n\n// API functions\nexport const api = {\n  // Authentication\n  getAuthStatus: () => request<AuthStatus>('/auth/status'),\n  setupAuth: (data: SetupRequest) =>\n    request<SetupResponse>('/auth/setup', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  login: (data: LoginRequest) =>\n    request<LoginResponse>('/auth/login', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  logout: () =>\n    request<{ message: string }>('/auth/logout', {\n      method: 'POST',\n    }),\n  getCurrentUser: () => request<UserResponse>('/auth/me'),\n  disableAuth: () =>\n    request<{ message: string; auth_enabled: boolean }>('/auth/disable', {\n      method: 'POST',\n    }),\n\n  // Advanced Authentication\n  testSMTP: (data: TestSMTPRequest) =>\n    request<TestSMTPResponse>('/auth/smtp/test', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  getSMTPSettings: () => request<SMTPSettings | null>('/auth/smtp'),\n  saveSMTPSettings: (data: SMTPSettings) =>\n    request<{ message: string }>('/auth/smtp', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  enableAdvancedAuth: () =>\n    request<{ message: string; advanced_auth_enabled: boolean }>('/auth/advanced-auth/enable', {\n      method: 'POST',\n    }),\n  disableAdvancedAuth: () =>\n    request<{ message: string; advanced_auth_enabled: boolean }>('/auth/advanced-auth/disable', {\n      method: 'POST',\n    }),\n  getAdvancedAuthStatus: () => request<AdvancedAuthStatus>('/auth/advanced-auth/status'),\n  // LDAP Authentication\n  getLDAPStatus: () => request<LDAPStatus>('/auth/ldap/status'),\n  testLDAP: () =>\n    request<LDAPTestResponse>('/auth/ldap/test', {\n      method: 'POST',\n    }),\n  forgotPassword: (data: ForgotPasswordRequest) =>\n    request<ForgotPasswordResponse>('/auth/forgot-password', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  // H-6: Confirm password reset using the token from the emailed link\n  forgotPasswordConfirm: (token: string, newPassword: string) =>\n    request<ForgotPasswordResponse>('/auth/forgot-password/confirm', {\n      method: 'POST',\n      body: JSON.stringify({ token, new_password: newPassword }),\n    }),\n  resetUserPassword: (data: ResetPasswordRequest) =>\n    request<ResetPasswordResponse>('/auth/reset-password', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n\n  // 2FA - status\n  get2FAStatus: () => request<TwoFAStatus>('/auth/2fa/status'),\n\n  // 2FA - TOTP\n  setupTOTP: () => request<TOTPSetupResponse>('/auth/2fa/totp/setup', { method: 'POST' }),\n  enableTOTP: (code: string) =>\n    request<TOTPEnableResponse>('/auth/2fa/totp/enable', {\n      method: 'POST',\n      body: JSON.stringify({ code }),\n    }),\n  disableTOTP: (code: string) =>\n    request<{ message: string }>('/auth/2fa/totp/disable', {\n      method: 'POST',\n      body: JSON.stringify({ code }),\n    }),\n  regenerateBackupCodes: (code: string) =>\n    request<BackupCodesResponse>('/auth/2fa/totp/regenerate-backup-codes', {\n      method: 'POST',\n      body: JSON.stringify({ code }),\n    }),\n\n  // 2FA - Email OTP\n  // Step 1: send a verification code to the user's email (proof of possession)\n  enableEmailOTP: () =>\n    request<{ message: string; setup_token: string }>('/auth/2fa/email/enable', { method: 'POST' }),\n  // Step 2: confirm with the code received by email\n  confirmEnableEmailOTP: (setup_token: string, code: string) =>\n    request<{ message: string }>('/auth/2fa/email/enable/confirm', {\n      method: 'POST',\n      body: JSON.stringify({ setup_token, code }),\n    }),\n  // Disable requires account password for re-auth\n  disableEmailOTP: (password: string) =>\n    request<{ message: string }>('/auth/2fa/email/disable', {\n      method: 'POST',\n      body: JSON.stringify({ password }),\n    }),\n  sendEmailOTP: (preAuthToken: string) =>\n    request<{ message: string; pre_auth_token?: string }>('/auth/2fa/email/send', {\n      method: 'POST',\n      body: JSON.stringify({ pre_auth_token: preAuthToken }),\n    }),\n\n  // 2FA - verify (completes login)\n  verify2FA: (data: TwoFAVerifyRequest) =>\n    request<LoginResponse>('/auth/2fa/verify', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n\n  // 2FA - admin\n  admin2FADisable: (userId: number) =>\n    request<{ message: string }>(`/auth/2fa/admin/${userId}`, { method: 'DELETE' }),\n\n  // OIDC providers (public list)\n  getOIDCProviders: () => request<OIDCProvider[]>('/auth/oidc/providers'),\n\n  // OIDC providers (admin)\n  getOIDCProvidersAll: () => request<OIDCProvider[]>('/auth/oidc/providers/all'),\n  createOIDCProvider: (data: OIDCProviderCreate) =>\n    request<OIDCProvider>('/auth/oidc/providers', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateOIDCProvider: (id: number, data: Partial<OIDCProviderCreate>) =>\n    request<OIDCProvider>(`/auth/oidc/providers/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n  deleteOIDCProvider: (id: number) =>\n    request<{ message: string }>(`/auth/oidc/providers/${id}`, { method: 'DELETE' }),\n\n  // OIDC authorize URL\n  getOIDCAuthorizeUrl: (providerId: number) =>\n    request<{ auth_url: string }>(`/auth/oidc/authorize/${providerId}`),\n\n  // OIDC exchange token for JWT\n  exchangeOIDCToken: (oidcToken: string) =>\n    request<LoginResponse>('/auth/oidc/exchange', {\n      method: 'POST',\n      body: JSON.stringify({ oidc_token: oidcToken }),\n    }),\n\n  // OIDC links for current user\n  getOIDCLinks: () => request<OIDCLink[]>('/auth/oidc/links'),\n  deleteOIDCLink: (providerId: number) =>\n    request<{ message: string }>(`/auth/oidc/links/${providerId}`, { method: 'DELETE' }),\n\n  // Users\n  getUsers: () => request<UserResponse[]>('/users/'),\n  getUser: (id: number) => request<UserResponse>(`/users/${id}`),\n  createUser: (data: UserCreate) =>\n    request<UserResponse>('/users/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateUser: (id: number, data: UserUpdate) =>\n    request<UserResponse>(`/users/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteUser: (id: number, deleteItems: boolean = false) =>\n    request<void>(`/users/${id}?delete_items=${deleteItems}`, {\n      method: 'DELETE',\n    }),\n  getUserItemsCount: (id: number) =>\n    request<{ archives: number; queue_items: number; library_files: number }>(`/users/${id}/items-count`),\n  changePassword: (currentPassword: string, newPassword: string) =>\n    request<{ message: string }>('/users/me/change-password', {\n      method: 'POST',\n      body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),\n    }),\n\n  // User Email Notifications\n  getUserEmailPreferences: () =>\n    request<UserEmailPreferences>('/user-notifications/preferences'),\n  updateUserEmailPreferences: (data: UserEmailPreferences) =>\n    request<UserEmailPreferences>('/user-notifications/preferences', {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n\n  // Groups\n  getPermissions: () => request<PermissionsListResponse>('/groups/permissions'),\n  getGroups: () => request<Group[]>('/groups/'),\n  getGroup: (id: number) => request<GroupDetail>(`/groups/${id}`),\n  createGroup: (data: GroupCreate) =>\n    request<Group>('/groups/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateGroup: (id: number, data: GroupUpdate) =>\n    request<Group>(`/groups/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteGroup: (id: number) =>\n    request<void>(`/groups/${id}`, {\n      method: 'DELETE',\n    }),\n  addUserToGroup: (groupId: number, userId: number) =>\n    request<void>(`/groups/${groupId}/users/${userId}`, {\n      method: 'POST',\n    }),\n  removeUserFromGroup: (groupId: number, userId: number) =>\n    request<void>(`/groups/${groupId}/users/${userId}`, {\n      method: 'DELETE',\n    }),\n\n  // Printers\n  getPrinters: () => request<Printer[]>('/printers/'),\n  getPrinter: (id: number) => request<Printer>(`/printers/${id}`),\n  createPrinter: (data: PrinterCreate) =>\n    request<Printer>('/printers/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updatePrinter: (id: number, data: Partial<PrinterCreate>) =>\n    request<Printer>(`/printers/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deletePrinter: (id: number, deleteArchives: boolean = true) =>\n    request<{ status: string; archives_deleted: boolean }>(\n      `/printers/${id}?delete_archives=${deleteArchives}`,\n      { method: 'DELETE' }\n    ),\n  getDeveloperModeWarnings: () =>\n    request<{ printer_id: number; name: string }[]>('/printers/developer-mode-warnings'),\n  getAvailableFilaments: (model: string, location?: string) => {\n    const params = new URLSearchParams({ model });\n    if (location) params.set('location', location);\n    return request<Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>>(`/printers/available-filaments?${params}`);\n  },\n  getPrinterStatus: (id: number) =>\n    request<PrinterStatus>(`/printers/${id}/status`),\n  refreshPrinterStatus: (id: number) =>\n    request<{ status: string }>(`/printers/${id}/refresh-status`, {\n      method: 'POST',\n    }),\n  connectPrinter: (id: number) =>\n    request<{ connected: boolean }>(`/printers/${id}/connect`, {\n      method: 'POST',\n    }),\n  disconnectPrinter: (id: number) =>\n    request<{ connected: boolean }>(`/printers/${id}/disconnect`, {\n      method: 'POST',\n    }),\n  testExternalCamera: (printerId: number, url: string, cameraType: string) =>\n    request<{ success: boolean; error?: string; resolution?: string }>(\n      `/printers/${printerId}/camera/external/test?url=${encodeURIComponent(url)}&camera_type=${encodeURIComponent(cameraType)}`,\n      { method: 'POST' }\n    ),\n\n  // Print Control\n  stopPrint: (printerId: number) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/print/stop`, {\n      method: 'POST',\n    }),\n  pausePrint: (printerId: number) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/print/pause`, {\n      method: 'POST',\n    }),\n  resumePrint: (printerId: number) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {\n      method: 'POST',\n    }),\n  clearPlate: (printerId: number) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/clear-plate`, {\n      method: 'POST',\n    }),\n\n  // Get current print user (for reprint tracking - Issue #206)\n  getCurrentPrintUser: (printerId: number) =>\n    request<{ user_id?: number; username?: string }>(`/printers/${printerId}/current-print-user`),\n\n  // Print Speed Control\n  setPrintSpeed: (printerId: number, mode: number) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/print-speed?mode=${mode}`, {\n      method: 'POST',\n    }),\n\n  setAirductMode: (printerId: number, mode: 'cooling' | 'heating') =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/airduct-mode?mode=${mode}`, {\n      method: 'POST',\n    }),\n\n  // Bed (Z-axis) jog\n  bedJog: (printerId: number, distance: number, force: boolean = false) =>\n    request<{ success: boolean; message: string }>(\n      `/printers/${printerId}/bed-jog?distance=${distance}&force=${force}`,\n      { method: 'POST' }\n    ),\n  homeAxes: (printerId: number, axes: 'z' | 'xy' | 'all' = 'z') =>\n    request<{ success: boolean; message: string }>(\n      `/printers/${printerId}/home-axes?axes=${axes}`,\n      { method: 'POST' }\n    ),\n\n  // Chamber Light Control\n  setChamberLight: (printerId: number, on: boolean) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {\n      method: 'POST',\n    }),\n\n  // AMS Drying Control\n  startDrying: (printerId: number, amsId: number, temp: number, duration: number, filament: string = '', rotateTray: boolean = false) =>\n    request<{ status: string; ams_id: number; temp: number; duration: number }>(\n      `/printers/${printerId}/drying/start?ams_id=${amsId}&temp=${temp}&duration=${duration}&filament=${encodeURIComponent(filament)}&rotate_tray=${rotateTray}`,\n      { method: 'POST' }\n    ),\n  stopDrying: (printerId: number, amsId: number) =>\n    request<{ status: string; ams_id: number }>(\n      `/printers/${printerId}/drying/stop?ams_id=${amsId}`,\n      { method: 'POST' }\n    ),\n\n  // Skip Objects\n  getPrintableObjects: (printerId: number) =>\n    request<{\n      objects: Array<{ id: number; name: string; x: number | null; y: number | null; skipped: boolean }>;\n      total: number;\n      skipped_count: number;\n      is_printing: boolean;\n      bbox_all: [number, number, number, number] | null;\n    }>(`/printers/${printerId}/print/objects`),\n\n  skipObjects: (printerId: number, objectIds: number[]) =>\n    request<{ success: boolean; message: string; skipped_objects: number[] }>(\n      `/printers/${printerId}/print/skip-objects`,\n      {\n        method: 'POST',\n        body: JSON.stringify(objectIds),\n      }\n    ),\n\n  // HMS Errors\n  clearHMSErrors: (printerId: number) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/hms/clear`, { method: 'POST' }),\n\n  // AMS Control\n  refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>\n    request<{ success: boolean; message: string }>(\n      `/printers/${printerId}/ams/${amsId}/slot/${slotId}/refresh`,\n      { method: 'POST' }\n    ),\n\n  // MQTT Debug Logging\n  enableMQTTLogging: (printerId: number) =>\n    request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {\n      method: 'POST',\n    }),\n  disableMQTTLogging: (printerId: number) =>\n    request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/disable`, {\n      method: 'POST',\n    }),\n  getMQTTLogs: (printerId: number) =>\n    request<MQTTLogsResponse>(`/printers/${printerId}/logging`),\n  clearMQTTLogs: (printerId: number) =>\n    request<{ status: string }>(`/printers/${printerId}/logging`, {\n      method: 'DELETE',\n    }),\n\n  // Printer File Manager\n  getPrinterFiles: (printerId: number, path = '/') =>\n    request<{\n      path: string;\n      files: Array<{\n        name: string;\n        is_directory: boolean;\n        size: number;\n        path: string;\n        mtime?: string;\n      }>;\n    }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),\n  getPrinterFileDownloadUrl: (printerId: number, path: string) =>\n    `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,\n  getPrinterFileGcodeUrl: (printerId: number, path: string) =>\n    `${API_BASE}/printers/${printerId}/files/gcode?path=${encodeURIComponent(path)}`,\n  getPrinterFilePlates: (printerId: number, path: string) =>\n    request<{\n      printer_id: number;\n      path: string;\n      filename: string;\n      plates: Array<{\n        index: number;\n        name: string | null;\n        objects: string[];\n        has_thumbnail: boolean;\n        thumbnail_url: string | null;\n        print_time_seconds: number | null;\n        filament_used_grams: number | null;\n        filaments: Array<{\n          slot_id: number;\n          type: string;\n          color: string;\n          used_grams: number;\n          used_meters: number;\n        }>;\n      }>;\n      is_multi_plate: boolean;\n    }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),\n  getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>\n    withStreamToken(`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`),\n  downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(\n      `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,\n      { headers }\n    );\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    const disposition = response.headers.get('Content-Disposition');\n    const filename = parseContentDispositionFilename(disposition) || path.split('/').pop() || 'download';\n    const blob = await response.blob();\n    const url = window.URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    window.URL.revokeObjectURL(url);\n  },\n  downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {\n    const headers: Record<string, string> = { 'Content-Type': 'application/json' };\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/printers/${printerId}/files/download-zip`, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify({ paths }),\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.blob();\n  },\n  deletePrinterFile: (printerId: number, path: string) =>\n    request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {\n      method: 'DELETE',\n    }),\n  getPrinterStorage: (printerId: number) =>\n    request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),\n\n  // Archives\n  getArchives: (printerId?: number, projectId?: number, limit = 10000, offset = 0, dateFrom?: string, dateTo?: string) => {\n    const params = new URLSearchParams();\n    if (printerId) params.set('printer_id', String(printerId));\n    if (projectId) params.set('project_id', String(projectId));\n    params.set('limit', String(limit));\n    params.set('offset', String(offset));\n    if (dateFrom) params.set('date_from', dateFrom);\n    if (dateTo) params.set('date_to', dateTo);\n    return request<Archive[]>(`/archives/?${params}`);\n  },\n  getArchivesSlim: (dateFrom?: string, dateTo?: string, createdById?: number) => {\n    const params = new URLSearchParams();\n    if (dateFrom) params.set('date_from', dateFrom);\n    if (dateTo) params.set('date_to', dateTo);\n    if (createdById !== undefined) params.set('created_by_id', String(createdById));\n    const qs = params.toString();\n    return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);\n  },\n  getArchive: (id: number) => request<Archive>(`/archives/${id}`),\n  searchArchives: (query: string, options?: {\n    printerId?: number;\n    projectId?: number;\n    status?: string;\n    limit?: number;\n    offset?: number;\n  }) => {\n    const params = new URLSearchParams();\n    params.set('q', query);\n    if (options?.printerId) params.set('printer_id', String(options.printerId));\n    if (options?.projectId) params.set('project_id', String(options.projectId));\n    if (options?.status) params.set('status', options.status);\n    if (options?.limit) params.set('limit', String(options.limit));\n    if (options?.offset) params.set('offset', String(options.offset));\n    return request<Archive[]>(`/archives/search?${params}`);\n  },\n  rebuildSearchIndex: () => request<{ message: string }>('/archives/search/rebuild-index', { method: 'POST' }),\n  updateArchive: (id: number, data: {\n    printer_id?: number | null;\n    project_id?: number | null;\n    print_name?: string;\n    is_favorite?: boolean;\n    tags?: string;\n    notes?: string;\n    cost?: number;\n    failure_reason?: string | null;\n    status?: string;\n    quantity?: number;\n    external_url?: string | null;\n  }) =>\n    request<Archive>(`/archives/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  toggleFavorite: (id: number) =>\n    request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),\n  deleteArchive: (id: number) =>\n    request<void>(`/archives/${id}`, { method: 'DELETE' }),\n  getArchiveStats: (options?: { dateFrom?: string; dateTo?: string; createdById?: number }) => {\n    const params = new URLSearchParams();\n    if (options?.dateFrom) params.set('date_from', options.dateFrom);\n    if (options?.dateTo) params.set('date_to', options.dateTo);\n    if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));\n    const qs = params.toString();\n    return request<ArchiveStats>(`/archives/stats${qs ? `?${qs}` : ''}`);\n  },\n  // Tag management\n  getTags: () => request<TagInfo[]>('/archives/tags'),\n  renameTag: (oldName: string, newName: string) =>\n    request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, {\n      method: 'PUT',\n      body: JSON.stringify({ new_name: newName }),\n    }),\n  deleteTag: (name: string) =>\n    request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, {\n      method: 'DELETE',\n    }),\n  recalculateCosts: () =>\n    request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),\n  getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number; createdById?: number }) => {\n    const params = new URLSearchParams();\n    if (options?.days) params.set('days', String(options.days));\n    if (options?.dateFrom) params.set('date_from', options.dateFrom);\n    if (options?.dateTo) params.set('date_to', options.dateTo);\n    if (options?.printerId) params.set('printer_id', String(options.printerId));\n    if (options?.projectId) params.set('project_id', String(options.projectId));\n    if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));\n    const qs = params.toString();\n    return request<FailureAnalysis>(`/archives/analysis/failures${qs ? `?${qs}` : ''}`);\n  },\n  compareArchives: (archiveIds: number[]) =>\n    request<ArchiveComparison>(`/archives/compare?archive_ids=${archiveIds.join(',')}`),\n  findSimilarArchives: (archiveId: number, limit = 10) =>\n    request<SimilarArchive[]>(`/archives/${archiveId}/similar?limit=${limit}`),\n  exportArchives: async (options?: {\n    format?: 'csv' | 'xlsx';\n    fields?: string[];\n    printerId?: number;\n    projectId?: number;\n    status?: string;\n    dateFrom?: string;\n    dateTo?: string;\n    search?: string;\n  }): Promise<{ blob: Blob; filename: string }> => {\n    const params = new URLSearchParams();\n    if (options?.format) params.set('format', options.format);\n    if (options?.fields) params.set('fields', options.fields.join(','));\n    if (options?.printerId) params.set('printer_id', String(options.printerId));\n    if (options?.projectId) params.set('project_id', String(options.projectId));\n    if (options?.status) params.set('status', options.status);\n    if (options?.dateFrom) params.set('date_from', options.dateFrom);\n    if (options?.dateTo) params.set('date_to', options.dateTo);\n    if (options?.search) params.set('search', options.search);\n\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/export?${params}`, { headers });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n\n    const contentDisposition = response.headers.get('Content-Disposition');\n    let filename = options?.format === 'xlsx' ? 'archives_export.xlsx' : 'archives_export.csv';\n    if (contentDisposition) {\n      const match = contentDisposition.match(/filename=\"?([^\"]+)\"?/);\n      if (match) filename = match[1];\n    }\n\n    const blob = await response.blob();\n    return { blob, filename };\n  },\n  exportStats: async (options?: {\n    format?: 'csv' | 'xlsx';\n    days?: number;\n    printerId?: number;\n    projectId?: number;\n    createdById?: number;\n  }): Promise<{ blob: Blob; filename: string }> => {\n    const params = new URLSearchParams();\n    if (options?.format) params.set('format', options.format);\n    if (options?.days) params.set('days', String(options.days));\n    if (options?.printerId) params.set('printer_id', String(options.printerId));\n    if (options?.projectId) params.set('project_id', String(options.projectId));\n    if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));\n\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/stats/export?${params}`, { headers });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n\n    const contentDisposition = response.headers.get('Content-Disposition');\n    let filename = options?.format === 'xlsx' ? 'stats_export.xlsx' : 'stats_export.csv';\n    if (contentDisposition) {\n      const match = contentDisposition.match(/filename=\"?([^\"]+)\"?/);\n      if (match) filename = match[1];\n    }\n\n    const blob = await response.blob();\n    return { blob, filename };\n  },\n  getArchiveDuplicates: (id: number) =>\n    request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),\n  backfillContentHashes: () =>\n    request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', {\n      method: 'POST',\n    }),\n  getArchiveThumbnail: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`),\n  getArchivePlateThumbnail: (id: number, plateIndex: number) =>\n    withStreamToken(`${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`),\n  getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,\n  downloadArchive: async (id: number, filename?: string): Promise<void> => {\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/${id}/download`, { headers });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    const disposition = response.headers.get('Content-Disposition');\n    const downloadFilename = parseContentDispositionFilename(disposition) || filename || `archive_${id}.3mf`;\n    const blob = await response.blob();\n    const url = window.URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = downloadFilename;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    window.URL.revokeObjectURL(url);\n  },\n  getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,\n  getArchivePlatePreview: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/plate-preview`),\n  getArchiveTimelapse: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`),\n  scanArchiveTimelapse: (id: number) =>\n    request<{\n      status: string;\n      message: string;\n      filename?: string;\n      available_files?: Array<{ name: string; path: string; size: number; mtime: string | null }>;\n    }>(`/archives/${id}/timelapse/scan`, {\n      method: 'POST',\n    }),\n  selectArchiveTimelapse: (id: number, filename: string) =>\n    request<{ status: string; message: string; filename: string }>(\n      `/archives/${id}/timelapse/select?filename=${encodeURIComponent(filename)}`,\n      { method: 'POST' }\n    ),\n  deleteArchiveTimelapse: (id: number) =>\n    request<{ status: string }>(`/archives/${id}/timelapse`, {\n      method: 'DELETE',\n    }),\n  uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  // Timelapse Editor\n  getTimelapseInfo: (archiveId: number) =>\n    request<{\n      duration: number;\n      width: number;\n      height: number;\n      fps: number;\n      codec: string;\n      file_size: number;\n      has_audio: boolean;\n    }>(`/archives/${archiveId}/timelapse/info`),\n  getTimelapseThumbnails: (archiveId: number, count: number = 10) =>\n    request<{\n      thumbnails: string[];\n      timestamps: number[];\n    }>(`/archives/${archiveId}/timelapse/thumbnails?count=${count}`),\n  processTimelapse: async (\n    archiveId: number,\n    params: {\n      trimStart?: number;\n      trimEnd?: number;\n      speed?: number;\n      saveMode: 'replace' | 'new';\n      outputFilename?: string;\n    },\n    audioFile?: File\n  ): Promise<{ status: string; output_path: string | null; message: string }> => {\n    const formData = new FormData();\n    formData.append('trim_start', String(params.trimStart ?? 0));\n    if (params.trimEnd !== undefined) {\n      formData.append('trim_end', String(params.trimEnd));\n    }\n    formData.append('speed', String(params.speed ?? 1));\n    formData.append('save_mode', params.saveMode);\n    if (params.outputFilename) {\n      formData.append('output_filename', params.outputFilename);\n    }\n    if (audioFile) {\n      formData.append('audio', audioFile);\n    }\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  // Photos\n  getArchivePhotoUrl: (archiveId: number, filename: string) =>\n    withStreamToken(`${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`),\n  uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {\n      headers,\n      method: 'POST',\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  deleteArchivePhoto: (archiveId: number, filename: string) =>\n    request<{ status: string; photos: string[] | null }>(`/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, {\n      method: 'DELETE',\n    }),\n  // Source 3MF (original slicer project file)\n  getSource3mfDownloadUrl: (archiveId: number) =>\n    `${API_BASE}/archives/${archiveId}/source`,\n  downloadSource3mf: async (archiveId: number): Promise<void> => {\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, { headers });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    const disposition = response.headers.get('Content-Disposition');\n    const filename = parseContentDispositionFilename(disposition) || `source_${archiveId}.3mf`;\n    const blob = await response.blob();\n    const url = window.URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    window.URL.revokeObjectURL(url);\n  },\n  getSource3mfForSlicer: (archiveId: number, filename: string) => {\n    // Sanitize: slicers url_decode() the entire URL, so / \\ ? # in filenames break path routing\n    const safe = filename.replace(/[/\\\\?#]/g, '_');\n    return `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;\n  },\n  createSourceSlicerToken: (archiveId: number) =>\n    request<{ token: string }>(`/archives/${archiveId}/source-slicer-token`, { method: 'POST' }),\n  getSourceSlicerDownloadUrl: (archiveId: number, token: string, filename: string) => {\n    const safe = filename.replace(/[/\\\\?#]/g, '_');\n    return `${API_BASE}/archives/${archiveId}/source-dl/${token}/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;\n  },\n  uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  deleteSource3mf: (archiveId: number) =>\n    request<{ status: string }>(`/archives/${archiveId}/source`, {\n      method: 'DELETE',\n    }),\n  // F3D (Fusion 360 design file)\n  getF3dDownloadUrl: (archiveId: number) =>\n    `${API_BASE}/archives/${archiveId}/f3d`,\n  downloadF3d: async (archiveId: number): Promise<void> => {\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, { headers });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    const disposition = response.headers.get('Content-Disposition');\n    const filename = parseContentDispositionFilename(disposition) || `archive_${archiveId}.f3d`;\n    const blob = await response.blob();\n    const url = window.URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    window.URL.revokeObjectURL(url);\n  },\n  uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  deleteF3d: (archiveId: number) =>\n    request<{ status: string }>(`/archives/${archiveId}/f3d`, {\n      method: 'DELETE',\n    }),\n\n  // QR Code\n  getArchiveQRCodeUrl: (archiveId: number, size = 200) =>\n    withStreamToken(`${API_BASE}/archives/${archiveId}/qrcode?size=${size}`),\n  getArchiveCapabilities: (id: number) =>\n    request<{\n      has_model: boolean;\n      has_gcode: boolean;\n      has_source: boolean;\n      build_volume: { x: number; y: number; z: number };\n      filament_colors: string[];\n    }>(`/archives/${id}/capabilities`),\n  // Project Page\n  getArchiveProjectPage: (id: number) =>\n    request<{\n      title: string | null;\n      description: string | null;\n      designer: string | null;\n      designer_user_id: string | null;\n      license: string | null;\n      copyright: string | null;\n      creation_date: string | null;\n      modification_date: string | null;\n      origin: string | null;\n      profile_title: string | null;\n      profile_description: string | null;\n      profile_cover: string | null;\n      profile_user_id: string | null;\n      profile_user_name: string | null;\n      design_model_id: string | null;\n      design_profile_id: string | null;\n      design_region: string | null;\n      model_pictures: Array<{ name: string; path: string; url: string }>;\n      profile_pictures: Array<{ name: string; path: string; url: string }>;\n      thumbnails: Array<{ name: string; path: string; url: string }>;\n    }>(`/archives/${id}/project-page`),\n  updateArchiveProjectPage: (id: number, data: {\n    title?: string;\n    description?: string;\n    designer?: string;\n    license?: string;\n    copyright?: string;\n    profile_title?: string;\n    profile_description?: string;\n  }) =>\n    request(`/archives/${id}/project-page`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  getArchiveProjectImageUrl: (archiveId: number, imagePath: string) =>\n    withStreamToken(`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`),\n  getArchiveForSlicer: (id: number, filename: string) => {\n    const safe = filename.replace(/[/\\\\?#]/g, '_');\n    return `${API_BASE}/archives/${id}/file/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;\n  },\n  createArchiveSlicerToken: (archiveId: number) =>\n    request<{ token: string }>(`/archives/${archiveId}/slicer-token`, { method: 'POST' }),\n  getArchiveSlicerDownloadUrl: (archiveId: number, token: string, filename: string) => {\n    const safe = filename.replace(/[/\\\\?#]/g, '_');\n    return `${API_BASE}/archives/${archiveId}/dl/${token}/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;\n  },\n  getArchivePlates: (archiveId: number) =>\n    request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),\n  getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>\n    request<{\n      archive_id: number;\n      filename: string;\n      plate_id: number | null;\n      filaments: Array<{\n        slot_id: number;\n        type: string;\n        color: string;\n        used_grams: number;\n        used_meters: number;\n      }>;\n    }>(`/archives/${archiveId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),\n  reprintArchive: (\n    archiveId: number,\n    printerId: number,\n    options?: {\n      plate_id?: number;\n      plate_name?: string;\n      ams_mapping?: number[];\n      timelapse?: boolean;\n      bed_levelling?: boolean;\n      flow_cali?: boolean;\n      vibration_cali?: boolean;\n      layer_inspect?: boolean;\n      use_ams?: boolean;\n    }\n  ) =>\n    request<BackgroundDispatchResponse>(\n      `/archives/${archiveId}/reprint?printer_id=${printerId}`,\n      {\n        method: 'POST',\n        headers: options ? { 'Content-Type': 'application/json' } : undefined,\n        body: options ? JSON.stringify(options) : undefined,\n      }\n    ),\n  uploadArchive: async (file: File, printerId?: number): Promise<Archive> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const url = printerId\n      ? `${API_BASE}/archives/upload?printer_id=${printerId}`\n      : `${API_BASE}/archives/upload`;\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(url, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  uploadArchivesBulk: async (files: File[], printerId?: number): Promise<BulkUploadResult> => {\n    const formData = new FormData();\n    files.forEach((file) => formData.append('files', file));\n    const url = printerId\n      ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`\n      : `${API_BASE}/archives/upload-bulk`;\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(url, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n\n  // Print Log\n  getPrintLog: (params?: {\n    search?: string;\n    printerId?: number;\n    username?: string;\n    status?: string;\n    dateFrom?: string;\n    dateTo?: string;\n    limit?: number;\n    offset?: number;\n  }) => {\n    const searchParams = new URLSearchParams();\n    if (params?.search) searchParams.set('search', params.search);\n    if (params?.printerId) searchParams.set('printer_id', String(params.printerId));\n    if (params?.username) searchParams.set('created_by_username', params.username);\n    if (params?.status) searchParams.set('status', params.status);\n    if (params?.dateFrom) searchParams.set('date_from', params.dateFrom);\n    if (params?.dateTo) searchParams.set('date_to', params.dateTo);\n    if (params?.limit) searchParams.set('limit', String(params.limit));\n    if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));\n    return request<PrintLogResponse>(`/print-log/?${searchParams}`);\n  },\n  getPrintLogThumbnail: (id: number) => withStreamToken(`${API_BASE}/print-log/${id}/thumbnail`),\n  clearPrintLog: () =>\n    request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),\n\n  // Settings\n  getSettings: () => request<AppSettings>('/settings/'),\n  getDefaultSidebarOrder: () => request<{ default_sidebar_order: string }>('/settings/default-sidebar-order'),\n  updateSettings: (data: AppSettingsUpdate) =>\n    request<AppSettings>('/settings/', {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n  getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),\n  resetSettings: () =>\n    request<AppSettings>('/settings/reset', { method: 'POST' }),\n  exportBackup: async (): Promise<{ blob: Blob; filename: string }> => {\n    // New simplified backup - complete database + all files\n    const url = `${API_BASE}/settings/backup`;\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(url, { headers });\n\n    // Check for errors\n    if (!response.ok) {\n      const errorText = await response.text();\n      throw new Error(errorText || `Backup failed with status ${response.status}`);\n    }\n\n    // Get filename from Content-Disposition header\n    const contentDisposition = response.headers.get('Content-Disposition');\n    let filename = 'bambuddy-backup.zip';\n    if (contentDisposition) {\n      const match = contentDisposition.match(/filename=([^;]+)/);\n      if (match) filename = match[1].trim().replace(/^\"(.*)\"$/, '$1');\n    }\n\n    const blob = await response.blob();\n    return { blob, filename };\n  },\n  importBackup: async (file: File) => {\n    // New simplified restore - replaces database + all directories\n    const formData = new FormData();\n    formData.append('file', file);\n    const url = `${API_BASE}/settings/restore`;\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(url, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    return response.json() as Promise<{\n      success: boolean;\n      message: string;\n    }>;\n  },\n  checkFfmpeg: () =>\n    request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),\n  getNetworkInterfaces: () =>\n    request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),\n\n  // Cloud\n  getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),\n  cloudLogin: (email: string, password: string, region = 'global') =>\n    request<CloudLoginResponse>('/cloud/login', {\n      method: 'POST',\n      body: JSON.stringify({ email, password, region }),\n    }),\n  cloudVerify: (email: string, code: string, tfaKey?: string, region: string = 'global') =>\n    request<CloudLoginResponse>('/cloud/verify', {\n      method: 'POST',\n      body: JSON.stringify({ email, code, tfa_key: tfaKey, region }),\n    }),\n  cloudSetToken: (access_token: string, region: string = 'global') =>\n    request<CloudAuthStatus>('/cloud/token', {\n      method: 'POST',\n      body: JSON.stringify({ access_token, region }),\n    }),\n  cloudLogout: () =>\n    request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),\n  getCloudSettings: (version = '02.04.00.70') =>\n    request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),\n  getBuiltinFilaments: () =>\n    request<BuiltinFilament[]>('/cloud/builtin-filaments'),\n  getFilamentIdMap: () =>\n    request<Record<string, string>>('/cloud/filament-id-map'),\n  getCloudSettingDetail: (settingId: string) =>\n    request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),\n  createCloudSetting: (data: SlicerSettingCreate) =>\n    request<SlicerSettingDetail>('/cloud/settings', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateCloudSetting: (settingId: string, data: SlicerSettingUpdate) =>\n    request<SlicerSettingDetail>(`/cloud/settings/${settingId}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n  deleteCloudSetting: (settingId: string) =>\n    request<SlicerSettingDeleteResponse>(`/cloud/settings/${settingId}`, {\n      method: 'DELETE',\n    }),\n  getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),\n  getCloudFields: (presetType: 'filament' | 'print' | 'process' | 'printer') =>\n    request<FieldDefinitionsResponse>(`/cloud/fields/${presetType}`),\n  getAllCloudFields: () =>\n    request<Record<string, FieldDefinitionsResponse>>('/cloud/fields'),\n  getFilamentInfo: (settingIds: string[]) =>\n    request<Record<string, { name: string; k: number | null }>>('/cloud/filament-info', {\n      method: 'POST',\n      body: JSON.stringify(settingIds),\n    }),\n\n  // Smart Plugs\n  getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),\n  getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),\n  getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),\n  getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),\n  createSmartPlug: (data: SmartPlugCreate) =>\n    request<SmartPlug>('/smart-plugs/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateSmartPlug: (id: number, data: SmartPlugUpdate) =>\n    request<SmartPlug>(`/smart-plugs/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteSmartPlug: (id: number) =>\n    request<void>(`/smart-plugs/${id}`, { method: 'DELETE' }),\n  controlSmartPlug: (id: number, action: 'on' | 'off' | 'toggle') =>\n    request<{ success: boolean; action: string }>(`/smart-plugs/${id}/control`, {\n      method: 'POST',\n      body: JSON.stringify({ action }),\n    }),\n  getSmartPlugStatus: (id: number) =>\n    request<SmartPlugStatus>(`/smart-plugs/${id}/status`),\n  testSmartPlugConnection: (ip_address: string, username?: string | null, password?: string | null) =>\n    request<SmartPlugTestResult>('/smart-plugs/test-connection', {\n      method: 'POST',\n      body: JSON.stringify({ ip_address, username, password }),\n    }),\n\n  // Tasmota Discovery (auto-detects network)\n  startTasmotaScan: () =>\n    request<TasmotaScanStatus>('/smart-plugs/discover/scan', { method: 'POST' }),\n  getTasmotaScanStatus: () =>\n    request<TasmotaScanStatus>('/smart-plugs/discover/status'),\n  stopTasmotaScan: () =>\n    request<TasmotaScanStatus>('/smart-plugs/discover/stop', { method: 'POST' }),\n  getDiscoveredTasmotaDevices: () =>\n    request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),\n\n  // Home Assistant Integration\n  testHAConnection: (url: string, token: string) =>\n    request<HATestConnectionResult>('/smart-plugs/ha/test-connection', {\n      method: 'POST',\n      body: JSON.stringify({ url, token }),\n    }),\n  getHAEntities: (search?: string) => {\n    const params = search ? `?search=${encodeURIComponent(search)}` : '';\n    return request<HAEntity[]>(`/smart-plugs/ha/entities${params}`);\n  },\n  getHASensorEntities: () =>\n    request<HASensorEntity[]>('/smart-plugs/ha/sensors'),\n\n  // REST smart plug\n  testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) =>\n    request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', {\n      method: 'POST',\n      body: JSON.stringify({ url, method, headers }),\n    }),\n\n  // Print Queue\n  getQueue: (printerId?: number, status?: string, targetModel?: string) => {\n    const params = new URLSearchParams();\n    if (printerId) params.set('printer_id', String(printerId));\n    if (status) params.set('status', status);\n    if (targetModel) params.set('target_model', targetModel);\n    return request<PrintQueueItem[]>(`/queue/?${params}`);\n  },\n  getQueueItem: (id: number) => request<PrintQueueItem>(`/queue/${id}`),\n  addToQueue: (data: PrintQueueItemCreate) =>\n    request<PrintQueueItem>('/queue/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateQueueItem: (id: number, data: PrintQueueItemUpdate) =>\n    request<PrintQueueItem>(`/queue/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  removeFromQueue: (id: number) =>\n    request<{ message: string }>(`/queue/${id}`, { method: 'DELETE' }),\n  reorderQueue: (items: { id: number; position: number }[]) =>\n    request<{ message: string }>('/queue/reorder', {\n      method: 'POST',\n      body: JSON.stringify({ items }),\n    }),\n  cancelQueueItem: (id: number) =>\n    request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),\n  stopQueueItem: (id: number) =>\n    request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),\n  startQueueItem: (id: number) =>\n    request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),\n  bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>\n    request<PrintQueueBulkUpdateResponse>('/queue/bulk', {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  // Batches\n  getBatches: (status?: string) => {\n    const params = status ? `?status=${status}` : '';\n    return request<PrintBatch[]>(`/queue/batches${params}`);\n  },\n  getBatch: (id: number) => request<PrintBatch>(`/queue/batches/${id}`),\n  cancelBatch: (id: number) =>\n    request<{ message: string }>(`/queue/batches/${id}`, { method: 'DELETE' }),\n\n  // K-Profiles\n  getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>\n    request<KProfilesResponse>(`/printers/${printerId}/kprofiles/?nozzle_diameter=${nozzleDiameter}`),\n  setKProfile: (printerId: number, profile: KProfileCreate) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {\n      method: 'POST',\n      body: JSON.stringify(profile),\n    }),\n  deleteKProfile: (printerId: number, profile: KProfileDelete) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {\n      method: 'DELETE',\n      body: JSON.stringify(profile),\n    }),\n  setKProfilesBatch: (printerId: number, profiles: KProfileCreate[]) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/batch`, {\n      method: 'POST',\n      body: JSON.stringify(profiles),\n    }),\n\n  // K-Profile Notes (stored locally, not on printer)\n  getKProfileNotes: (printerId: number) =>\n    request<KProfileNotesResponse>(`/printers/${printerId}/kprofiles/notes`),\n  setKProfileNote: (printerId: number, settingId: string, note: string) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes`, {\n      method: 'PUT',\n      body: JSON.stringify({ setting_id: settingId, note }),\n    }),\n  deleteKProfileNote: (printerId: number, settingId: string) =>\n    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes/${encodeURIComponent(settingId)}`, {\n      method: 'DELETE',\n    }),\n\n  // Slot Preset Mappings\n  getSlotPresets: (printerId: number) =>\n    request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),\n  getSlotPreset: (printerId: number, amsId: number, trayId: number) =>\n    request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),\n  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud') =>\n    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}`, {\n      method: 'PUT',\n    }),\n  deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>\n    request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {\n      method: 'DELETE',\n    }),\n\n  // AMS Labels (user-defined friendly names)\n  getAmsLabels: (printerId: number) =>\n    request<Record<number, string>>(`/printers/${printerId}/ams-labels`),\n  saveAmsLabel: (printerId: number, amsId: number, label: string, amsSerial = '') =>\n    request<{ ams_id: number; label: string }>(\n      `/printers/${printerId}/ams-labels/${amsId}`,\n      {\n        method: 'PUT',\n        body: JSON.stringify({ label, ams_serial: amsSerial }),\n      }\n    ),\n  deleteAmsLabel: (printerId: number, amsId: number, amsSerial = '') =>\n    request<{ success: boolean }>(`/printers/${printerId}/ams-labels/${amsId}?ams_serial=${encodeURIComponent(amsSerial)}`, {\n      method: 'DELETE',\n    }),\n\n  configureAmsSlot: (\n    printerId: number,\n    amsId: number,\n    trayId: number,\n    config: {\n      tray_info_idx: string;\n      tray_type: string;\n      tray_sub_brands: string;\n      tray_color: string;\n      nozzle_temp_min: number;\n      nozzle_temp_max: number;\n      cali_idx: number;\n      nozzle_diameter: string;\n      setting_id?: string;\n      kprofile_filament_id?: string;\n      kprofile_setting_id?: string;\n      k_value?: number;\n    }\n  ) => {\n    const params = new URLSearchParams({\n      tray_info_idx: config.tray_info_idx,\n      tray_type: config.tray_type,\n      tray_sub_brands: config.tray_sub_brands,\n      tray_color: config.tray_color,\n      nozzle_temp_min: config.nozzle_temp_min.toString(),\n      nozzle_temp_max: config.nozzle_temp_max.toString(),\n      cali_idx: config.cali_idx.toString(),\n      nozzle_diameter: config.nozzle_diameter,\n    });\n    if (config.setting_id) {\n      params.set('setting_id', config.setting_id);\n    }\n    if (config.kprofile_filament_id) {\n      params.set('kprofile_filament_id', config.kprofile_filament_id);\n    }\n    if (config.kprofile_setting_id) {\n      params.set('kprofile_setting_id', config.kprofile_setting_id);\n    }\n    if (config.k_value !== undefined && config.k_value > 0) {\n      params.set('k_value', config.k_value.toString());\n    }\n    return request<{ success: boolean; message: string }>(\n      `/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,\n      { method: 'POST' }\n    );\n  },\n  resetAmsSlot: (printerId: number, amsId: number, trayId: number) =>\n    request<{ success: boolean; message: string }>(\n      `/printers/${printerId}/ams/${amsId}/tray/${trayId}/reset`,\n      { method: 'POST' }\n    ),\n\n  // Filament Catalog (material types with cost/temp data)\n  listFilaments: () => request<Filament[]>('/filament-catalog/'),\n  getFilament: (id: number) => request<Filament>(`/filament-catalog/${id}`),\n  getFilamentsByType: (type: string) => request<Filament[]>(`/filament-catalog/by-type/${type}`),\n\n  // Notification Providers\n  getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),\n  getNotificationProvider: (id: number) => request<NotificationProvider>(`/notifications/${id}`),\n  createNotificationProvider: (data: NotificationProviderCreate) =>\n    request<NotificationProvider>('/notifications/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateNotificationProvider: (id: number, data: NotificationProviderUpdate) =>\n    request<NotificationProvider>(`/notifications/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteNotificationProvider: (id: number) =>\n    request<{ message: string }>(`/notifications/${id}`, { method: 'DELETE' }),\n  testNotificationProvider: (id: number) =>\n    request<NotificationTestResponse>(`/notifications/${id}/test`, { method: 'POST' }),\n  testNotificationConfig: (data: NotificationTestRequest) =>\n    request<NotificationTestResponse>('/notifications/test-config', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  testAllNotificationProviders: () =>\n    request<{\n      tested: number;\n      success: number;\n      failed: number;\n      results: Array<{\n        provider_id: number;\n        provider_name: string;\n        provider_type: string;\n        success: boolean;\n        message: string;\n      }>;\n    }>('/notifications/test-all', { method: 'POST' }),\n\n  // Notification Templates\n  getNotificationTemplates: () => request<NotificationTemplate[]>('/notification-templates'),\n  getNotificationTemplate: (id: number) => request<NotificationTemplate>(`/notification-templates/${id}`),\n  updateNotificationTemplate: (id: number, data: NotificationTemplateUpdate) =>\n    request<NotificationTemplate>(`/notification-templates/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n  resetNotificationTemplate: (id: number) =>\n    request<NotificationTemplate>(`/notification-templates/${id}/reset`, {\n      method: 'POST',\n    }),\n  getTemplateVariables: () => request<EventVariablesResponse[]>('/notification-templates/variables'),\n  previewTemplate: (data: TemplatePreviewRequest) =>\n    request<TemplatePreviewResponse>('/notification-templates/preview', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n\n  // Notification Logs\n  getNotificationLogs: (params?: {\n    limit?: number;\n    offset?: number;\n    provider_id?: number;\n    event_type?: string;\n    success?: boolean;\n    days?: number;\n  }) => {\n    const searchParams = new URLSearchParams();\n    if (params?.limit) searchParams.set('limit', String(params.limit));\n    if (params?.offset) searchParams.set('offset', String(params.offset));\n    if (params?.provider_id) searchParams.set('provider_id', String(params.provider_id));\n    if (params?.event_type) searchParams.set('event_type', params.event_type);\n    if (params?.success !== undefined) searchParams.set('success', String(params.success));\n    if (params?.days) searchParams.set('days', String(params.days));\n    return request<NotificationLogEntry[]>(`/notifications/logs?${searchParams}`);\n  },\n  getNotificationLogStats: (days = 7) =>\n    request<NotificationLogStats>(`/notifications/logs/stats?days=${days}`),\n  clearNotificationLogs: (olderThanDays = 30) =>\n    request<{ deleted: number; message: string }>(\n      `/notifications/logs?older_than_days=${olderThanDays}`,\n      { method: 'DELETE' }\n    ),\n\n  // Spoolman Integration\n  getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),\n  connectSpoolman: () =>\n    request<{ success: boolean; message: string }>('/spoolman/connect', {\n      method: 'POST',\n    }),\n  disconnectSpoolman: () =>\n    request<{ success: boolean; message: string }>('/spoolman/disconnect', {\n      method: 'POST',\n    }),\n  syncPrinterAms: (printerId: number) =>\n    request<SpoolmanSyncResult>(`/spoolman/sync/${printerId}`, {\n      method: 'POST',\n    }),\n  syncAllPrintersAms: () =>\n    request<SpoolmanSyncResult>('/spoolman/sync-all', {\n      method: 'POST',\n    }),\n  getSpoolmanSpools: () =>\n    request<{ spools: unknown[] }>('/spoolman/spools'),\n  getSpoolmanFilaments: () =>\n    request<{ filaments: unknown[] }>('/spoolman/filaments'),\n  getUnlinkedSpools: () =>\n    request<UnlinkedSpool[]>('/spoolman/spools/unlinked'),\n  getLinkedSpools: () =>\n    request<LinkedSpoolsMap>('/spoolman/spools/linked'),\n  linkSpool: (\n    spoolId: number,\n    context: {\n      spoolTag: string;\n      printerId: number;\n      amsId: number;\n      trayId: number;\n    }\n  ) =>\n    request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/link`, {\n      method: 'POST',\n      body: JSON.stringify({\n        spool_tag: context.spoolTag,\n        printer_id: context.printerId,\n        ams_id: context.amsId,\n        tray_id: context.trayId,\n      }),\n    }),\n  unlinkSpool: (spoolId: number) =>\n    request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/unlink`, {\n      method: 'POST',\n    }),\n  getSpoolmanSettings: () =>\n    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman'),\n  updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string; spoolman_disable_weight_sync?: string; spoolman_report_partial_usage?: string; }) =>\n    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman', {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n\n  // Inventory\n  getSpools: (includeArchived = false) =>\n    request<InventorySpool[]>(`/inventory/spools?include_archived=${includeArchived}`),\n  getSpool: (id: number) => request<InventorySpool>(`/inventory/spools/${id}`),\n  createSpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>\n    request<InventorySpool>('/inventory/spools', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  bulkCreateSpools: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>, quantity: number) =>\n    request<InventorySpool[]>('/inventory/spools/bulk', {\n      method: 'POST',\n      body: JSON.stringify({ spool: data, quantity }),\n    }),\n  updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>\n    request<InventorySpool>(`/inventory/spools/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteSpool: (id: number) =>\n    request<{ status: string }>(`/inventory/spools/${id}`, { method: 'DELETE' }),\n  archiveSpool: (id: number) =>\n    request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),\n  restoreSpool: (id: number) =>\n    request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),\n  getSpoolKProfiles: (spoolId: number) =>\n    request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),\n  saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>\n    request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`, {\n      method: 'PUT',\n      body: JSON.stringify(profiles),\n    }),\n  getAssignments: (printerId?: number) =>\n    request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),\n  assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>\n    request<SpoolAssignment>('/inventory/assignments', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  unassignSpool: (printerId: number, amsId: number, trayId: number) =>\n    request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }),\n  getSpoolCatalog: () =>\n    request<SpoolCatalogEntry[]>('/inventory/catalog'),\n  addCatalogEntry: (data: { name: string; weight: number }) =>\n    request<SpoolCatalogEntry>('/inventory/catalog', { method: 'POST', body: JSON.stringify(data) }),\n  updateCatalogEntry: (id: number, data: { name: string; weight: number }) =>\n    request<SpoolCatalogEntry>(`/inventory/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),\n  deleteCatalogEntry: (id: number) =>\n    request<{ status: string }>(`/inventory/catalog/${id}`, { method: 'DELETE' }),\n  bulkDeleteCatalogEntries: (ids: number[]) =>\n    request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),\n  resetSpoolCatalog: () =>\n    request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),\n  getColorCatalog: () =>\n    request<ColorCatalogEntry[]>('/inventory/colors'),\n  getColorNameMap: () =>\n    request<{ colors: Record<string, string> }>('/inventory/colors/map'),\n  addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>\n    request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),\n  updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>\n    request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),\n  deleteColorEntry: (id: number) =>\n    request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),\n  bulkDeleteColorEntries: (ids: number[]) =>\n    request<{ deleted: number }>('/inventory/colors/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),\n  resetColorCatalog: () =>\n    request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),\n  lookupColor: (manufacturer: string, colorName: string, material?: string) =>\n    request<ColorLookupResult>(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`),\n  searchColors: (manufacturer?: string, material?: string) =>\n    request<ColorCatalogEntry[]>(`/inventory/colors/search?${manufacturer ? `manufacturer=${encodeURIComponent(manufacturer)}` : ''}${manufacturer && material ? '&' : ''}${material ? `material=${encodeURIComponent(material)}` : ''}`),\n  linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) =>\n    request<InventorySpool>(`/inventory/spools/${spoolId}/link-tag`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  getSpoolUsageHistory: (spoolId: number, limit = 50) =>\n    request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),\n  getAllUsageHistory: (limit = 100, printerId?: number) =>\n    request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),\n  clearSpoolUsageHistory: (spoolId: number) =>\n    request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),\n  syncWeightsFromAms: () =>\n    request<{ synced: number; skipped: number }>('/inventory/sync-ams-weights', { method: 'POST' }),\n  getFilamentPresets: () =>\n    request<SlicerSetting[]>('/cloud/filaments'),\n\n  // Updates\n  getVersion: () => request<VersionInfo>('/updates/version'),\n  checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),\n  applyUpdate: () =>\n    request<{ success: boolean; message: string; status?: UpdateStatus; is_docker?: boolean }>('/updates/apply', {\n      method: 'POST',\n    }),\n  getUpdateStatus: () => request<UpdateStatus>('/updates/status'),\n\n  // Maintenance\n  getMaintenanceTypes: () => request<MaintenanceType[]>('/maintenance/types'),\n  createMaintenanceType: (data: MaintenanceTypeCreate) =>\n    request<MaintenanceType>('/maintenance/types', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateMaintenanceType: (id: number, data: Partial<MaintenanceTypeCreate>) =>\n    request<MaintenanceType>(`/maintenance/types/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteMaintenanceType: (id: number) =>\n    request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }),\n  restoreDefaultMaintenanceTypes: () =>\n    request<{ restored: number }>(`/maintenance/types/restore-defaults`, { method: 'POST' }),\n  getMaintenanceOverview: () => request<PrinterMaintenanceOverview[]>('/maintenance/overview'),\n  getPrinterMaintenance: (printerId: number) =>\n    request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),\n  updateMaintenanceItem: (itemId: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null; enabled?: boolean }) =>\n    request<MaintenanceStatus>(`/maintenance/items/${itemId}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  performMaintenance: (itemId: number, notes?: string) =>\n    request<MaintenanceStatus>(`/maintenance/items/${itemId}/perform`, {\n      method: 'POST',\n      body: JSON.stringify({ notes }),\n    }),\n  getMaintenanceHistory: (itemId: number) =>\n    request<MaintenanceHistory[]>(`/maintenance/items/${itemId}/history`),\n  getMaintenanceSummary: () => request<MaintenanceSummary>('/maintenance/summary'),\n  setPrinterHours: (printerId: number, totalHours: number) =>\n    request<{ printer_id: number; total_hours: number; archive_hours: number; offset_hours: number }>(\n      `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,\n      { method: 'PATCH' }\n    ),\n  assignMaintenanceType: (printerId: number, typeId: number) =>\n    request<MaintenanceStatus>(`/maintenance/printers/${printerId}/assign/${typeId}`, {\n      method: 'POST',\n    }),\n  removeMaintenanceItem: (itemId: number) =>\n    request<{ status: string }>(`/maintenance/items/${itemId}`, {\n      method: 'DELETE',\n    }),\n\n  // Camera\n  getCameraStreamToken: () =>\n    request<{ token: string }>('/printers/camera/stream-token', { method: 'POST' }),\n  getCameraStreamUrl: (printerId: number, fps = 10) =>\n    withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),\n  getCameraSnapshotUrl: (printerId: number) =>\n    withStreamToken(`${API_BASE}/printers/${printerId}/camera/snapshot`),\n  testCameraConnection: (printerId: number) =>\n    request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),\n  getCameraStatus: (printerId: number) =>\n    request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),\n\n  // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)\n  checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {\n    const params = new URLSearchParams();\n    params.set('use_external', String(options?.useExternal ?? false));\n    params.set('include_debug_image', String(options?.includeDebugImage ?? false));\n    return request<PlateDetectionResult>(\n      `/printers/${printerId}/camera/check-plate?${params.toString()}`\n    );\n  },\n  getPlateDetectionStatus: (printerId: number) => {\n    return request<PlateDetectionStatus & { chamber_light?: boolean }>(\n      `/printers/${printerId}/camera/plate-detection/status`\n    );\n  },\n  calibratePlateDetection: (printerId: number, options?: { label?: string; useExternal?: boolean }) => {\n    const params = new URLSearchParams();\n    if (options?.label) params.set('label', options.label);\n    params.set('use_external', String(options?.useExternal ?? false));\n    return request<CalibrationResult & { index: number }>(\n      `/printers/${printerId}/camera/plate-detection/calibrate?${params.toString()}`,\n      { method: 'POST' }\n    );\n  },\n  deletePlateCalibration: (printerId: number) => {\n    return request<CalibrationResult>(\n      `/printers/${printerId}/camera/plate-detection/calibrate`,\n      { method: 'DELETE' }\n    );\n  },\n  getPlateReferences: (printerId: number) => {\n    return request<{\n      references: PlateReference[];\n      max_references: number;\n    }>(`/printers/${printerId}/camera/plate-detection/references`);\n  },\n  getPlateReferenceThumbnailUrl: (printerId: number, index: number) =>\n    withStreamToken(`${API_BASE}/printers/${printerId}/camera/plate-detection/references/${index}/thumbnail`),\n  updatePlateReferenceLabel: (printerId: number, index: number, label: string) => {\n    const params = new URLSearchParams();\n    params.set('label', label);\n    return request<{ success: boolean; index: number; label: string }>(\n      `/printers/${printerId}/camera/plate-detection/references/${index}?${params.toString()}`,\n      { method: 'PUT' }\n    );\n  },\n  deletePlateReference: (printerId: number, index: number) => {\n    return request<{ success: boolean; message: string }>(\n      `/printers/${printerId}/camera/plate-detection/references/${index}`,\n      { method: 'DELETE' }\n    );\n  },\n\n  // External Links\n  getExternalLinks: () => request<ExternalLink[]>('/external-links/'),\n  getExternalLink: (id: number) => request<ExternalLink>(`/external-links/${id}`),\n  createExternalLink: (data: ExternalLinkCreate) =>\n    request<ExternalLink>('/external-links/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateExternalLink: (id: number, data: ExternalLinkUpdate) =>\n    request<ExternalLink>(`/external-links/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteExternalLink: (id: number) =>\n    request<{ message: string }>(`/external-links/${id}`, { method: 'DELETE' }),\n  reorderExternalLinks: (ids: number[]) =>\n    request<ExternalLink[]>('/external-links/reorder', {\n      method: 'PUT',\n      body: JSON.stringify({ ids }),\n    }),\n  uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  deleteExternalLinkIcon: (id: number) =>\n    request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),\n  getExternalLinkIconUrl: (id: number) => withStreamToken(`${API_BASE}/external-links/${id}/icon`),\n\n  // Projects\n  getProjects: (status?: string) => {\n    const params = new URLSearchParams();\n    if (status) params.set('status', status);\n    return request<ProjectListItem[]>(`/projects/?${params}`);\n  },\n  getProject: (id: number) => request<Project>(`/projects/${id}`),\n  createProject: (data: ProjectCreate) =>\n    request<Project>('/projects/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateProject: (id: number, data: ProjectUpdate) =>\n    request<Project>(`/projects/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteProject: (id: number) =>\n    request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }),\n  getProjectArchives: (id: number, limit = 100, offset = 0) =>\n    request<Archive[]>(`/projects/${id}/archives?limit=${limit}&offset=${offset}`),\n  addArchivesToProject: (projectId: number, archiveIds: number[]) =>\n    request<{ message: string }>(`/projects/${projectId}/add-archives`, {\n      method: 'POST',\n      body: JSON.stringify({ archive_ids: archiveIds }),\n    }),\n  removeArchivesFromProject: (projectId: number, archiveIds: number[]) =>\n    request<{ message: string }>(`/projects/${projectId}/remove-archives`, {\n      method: 'POST',\n      body: JSON.stringify({ archive_ids: archiveIds }),\n    }),\n  addQueueItemsToProject: (projectId: number, queueItemIds: number[]) =>\n    request<{ message: string }>(`/projects/${projectId}/add-queue`, {\n      method: 'POST',\n      body: JSON.stringify({ queue_item_ids: queueItemIds }),\n    }),\n\n  // Project Attachments\n  uploadProjectAttachment: async (projectId: number, file: File): Promise<{\n    status: string;\n    filename: string;\n    original_name: string;\n    attachments: ProjectAttachment[];\n  }> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  getProjectAttachmentUrl: (projectId: number, filename: string) =>\n    `${API_BASE}/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,\n  deleteProjectAttachment: (projectId: number, filename: string) =>\n    request<{ status: string; message: string; attachments: ProjectAttachment[] | null }>(\n      `/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,\n      { method: 'DELETE' }\n    ),\n\n  // BOM (Bill of Materials)\n  getProjectBOM: (projectId: number) =>\n    request<BOMItem[]>(`/projects/${projectId}/bom`),\n  createBOMItem: (projectId: number, data: BOMItemCreate) =>\n    request<BOMItem>(`/projects/${projectId}/bom`, {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateBOMItem: (projectId: number, itemId: number, data: BOMItemUpdate) =>\n    request<BOMItem>(`/projects/${projectId}/bom/${itemId}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteBOMItem: (projectId: number, itemId: number) =>\n    request<{ status: string; message: string }>(`/projects/${projectId}/bom/${itemId}`, {\n      method: 'DELETE',\n    }),\n\n  // Templates\n  getTemplates: () => request<ProjectListItem[]>('/projects/templates/'),\n  createTemplateFromProject: (projectId: number) =>\n    request<Project>(`/projects/${projectId}/create-template`, { method: 'POST' }),\n  createProjectFromTemplate: (templateId: number, name?: string) =>\n    request<Project>(`/projects/from-template/${templateId}${name ? `?name=${encodeURIComponent(name)}` : ''}`, {\n      method: 'POST',\n    }),\n\n  // Timeline\n  getProjectTimeline: (projectId: number, limit = 50) =>\n    request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),\n\n  // Project Export/Import\n  exportProjectJson: (projectId: number) =>\n    request<ProjectExport>(`/projects/${projectId}/export?format=json`),\n  importProject: (data: ProjectImport) =>\n    request<Project>('/projects/import', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  importProjectFile: async (file: File): Promise<Project> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/projects/import/file`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  exportProjectZip: async (projectId: number): Promise<{ blob: Blob; filename: string }> => {\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/projects/${projectId}/export`, {\n      headers,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    const contentDisposition = response.headers.get('Content-Disposition');\n    const filename = parseContentDispositionFilename(contentDisposition) || `project_${projectId}.zip`;\n    const blob = await response.blob();\n    return { blob, filename };\n  },\n\n  // API Keys\n  getAPIKeys: () => request<APIKey[]>('/api-keys/'),\n  createAPIKey: (data: APIKeyCreate) =>\n    request<APIKeyCreateResponse>('/api-keys/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateAPIKey: (id: number, data: APIKeyUpdate) =>\n    request<APIKey>(`/api-keys/${id}`, {\n      method: 'PATCH',\n      body: JSON.stringify(data),\n    }),\n  deleteAPIKey: (id: number) =>\n    request<{ message: string }>(`/api-keys/${id}`, { method: 'DELETE' }),\n\n  // AMS History\n  getAMSHistory: (printerId: number, amsId: number, hours = 24) =>\n    request<AMSHistoryResponse>(`/ams-history/${printerId}/${amsId}?hours=${hours}`),\n\n  // System Info\n  getSystemInfo: () => request<SystemInfo>('/system/info'),\n  getStorageUsage: (options?: { refresh?: boolean }) => {\n    const params = new URLSearchParams();\n    if (options?.refresh) {\n      params.set('refresh', 'true');\n    }\n    const query = params.toString();\n    return request<StorageUsageResponse>(`/system/storage-usage${query ? `?${query}` : ''}`);\n  },\n\n  // Library (File Manager)\n  getLibraryFolders: () => request<LibraryFolderTree[]>('/library/folders'),\n  createLibraryFolder: (data: LibraryFolderCreate) =>\n    request<LibraryFolder>('/library/folders', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateLibraryFolder: (id: number, data: LibraryFolderUpdate) =>\n    request<LibraryFolder>(`/library/folders/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n  deleteLibraryFolder: (id: number) =>\n    request<{ status: string; message: string }>(`/library/folders/${id}`, { method: 'DELETE' }),\n  createExternalFolder: (data: ExternalFolderCreate) =>\n    request<LibraryFolder>('/library/folders/external', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  scanExternalFolder: (folderId: number) =>\n    request<{ status: string; added: number; removed: number }>(`/library/folders/${folderId}/scan`, {\n      method: 'POST',\n    }),\n  getLibraryFoldersByProject: (projectId: number) =>\n    request<LibraryFolder[]>(`/library/folders/by-project/${projectId}`),\n  getLibraryFoldersByArchive: (archiveId: number) =>\n    request<LibraryFolder[]>(`/library/folders/by-archive/${archiveId}`),\n\n  getLibraryFiles: (folderId?: number | null, includeRoot = true, projectId?: number) => {\n    const params = new URLSearchParams();\n    if (folderId !== undefined && folderId !== null) {\n      params.set('folder_id', String(folderId));\n    }\n    if (projectId !== undefined) {\n      params.set('project_id', String(projectId));\n    }\n    params.set('include_root', String(includeRoot));\n    return request<LibraryFileListItem[]>(`/library/files?${params}`);\n  },\n  getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),\n  uploadLibraryFile: async (\n    file: File,\n    folderId?: number | null,\n    generateStlThumbnails: boolean = true\n  ): Promise<LibraryFileUploadResponse> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const params = new URLSearchParams();\n    if (folderId) params.set('folder_id', String(folderId));\n    params.set('generate_stl_thumbnails', String(generateStlThumbnails));\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/library/files?${params}`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  extractZipFile: async (\n    file: File,\n    folderId?: number | null,\n    preserveStructure: boolean = true,\n    createFolderFromZip: boolean = false,\n    generateStlThumbnails: boolean = true\n  ): Promise<ZipExtractResponse> => {\n    const formData = new FormData();\n    formData.append('file', file);\n    const params = new URLSearchParams();\n    if (folderId) params.set('folder_id', String(folderId));\n    params.set('preserve_structure', String(preserveStructure));\n    params.set('create_folder_from_zip', String(createFolderFromZip));\n    params.set('generate_stl_thumbnails', String(generateStlThumbnails));\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {\n      method: 'POST',\n      headers,\n      body: formData,\n    });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    return response.json();\n  },\n  updateLibraryFile: (id: number, data: LibraryFileUpdate) =>\n    request<LibraryFile>(`/library/files/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n  deleteLibraryFile: (id: number) =>\n    request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),\n  getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,\n  createLibrarySlicerToken: (fileId: number) =>\n    request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),\n  getLibrarySlicerDownloadUrl: (fileId: number, token: string, filename: string) =>\n    `${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(filename)}`,\n  downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/library/files/${id}/download`, { headers });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    const disposition = response.headers.get('Content-Disposition');\n    const downloadFilename = parseContentDispositionFilename(disposition) || filename || `file_${id}`;\n    const blob = await response.blob();\n    const url = window.URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = downloadFilename;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    window.URL.revokeObjectURL(url);\n  },\n  getLibraryFileThumbnailUrl: (id: number) => withStreamToken(`${API_BASE}/library/files/${id}/thumbnail`),\n  getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>\n    withStreamToken(`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`),\n  getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,\n  moveLibraryFiles: (fileIds: number[], folderId: number | null) =>\n    request<{ status: string; moved: number }>('/library/files/move', {\n      method: 'POST',\n      body: JSON.stringify({ file_ids: fileIds, folder_id: folderId }),\n    }),\n  bulkDeleteLibrary: (fileIds: number[], folderIds: number[]) =>\n    request<{ deleted_files: number; deleted_folders: number }>('/library/bulk-delete', {\n      method: 'POST',\n      body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }),\n    }),\n  getLibraryStats: () => request<LibraryStats>('/library/stats'),\n  batchGenerateStlThumbnails: (options: {\n    file_ids?: number[];\n    folder_id?: number;\n    all_missing?: boolean;\n  }) =>\n    request<BatchThumbnailResponse>('/library/generate-stl-thumbnails', {\n      method: 'POST',\n      body: JSON.stringify(options),\n    }),\n  addLibraryFilesToQueue: (fileIds: number[]) =>\n    request<AddToQueueResponse>('/library/files/add-to-queue', {\n      method: 'POST',\n      body: JSON.stringify({ file_ids: fileIds }),\n    }),\n  printLibraryFile: (\n    fileId: number,\n    printerId: number,\n    options?: {\n      plate_id?: number;\n      plate_name?: string;\n      ams_mapping?: number[];\n      bed_levelling?: boolean;\n      flow_cali?: boolean;\n      vibration_cali?: boolean;\n      layer_inspect?: boolean;\n      timelapse?: boolean;\n      use_ams?: boolean;\n      project_id?: number;\n      cleanup_library_after_dispatch?: boolean;\n    }\n  ) =>\n    request<BackgroundDispatchResponse>(\n      `/library/files/${fileId}/print?printer_id=${printerId}`,\n      {\n        method: 'POST',\n        body: options ? JSON.stringify(options) : undefined,\n      }\n    ),\n  cancelBackgroundDispatchJob: (jobId: number) =>\n    request<{\n      status: 'cancelled' | 'cancelling';\n      job_id: number;\n      source_name: string;\n      printer_id: number;\n      printer_name: string;\n    }>(`/background-dispatch/${jobId}`, {\n      method: 'DELETE',\n    }),\n  getLibraryFilePlates: (fileId: number) =>\n    request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),\n  getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>\n    request<{\n      file_id: number;\n      filename: string;\n      filaments: Array<{\n        slot_id: number;\n        type: string;\n        color: string;\n        used_grams: number;\n        used_meters: number;\n      }>;\n    }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),\n\n  // GitHub Backup\n  getGitHubBackupConfig: () =>\n    request<GitHubBackupConfig | null>('/github-backup/config'),\n\n  saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>\n    request<GitHubBackupConfig>('/github-backup/config', {\n      method: 'POST',\n      body: JSON.stringify(config),\n    }),\n\n  updateGitHubBackupConfig: (config: Partial<GitHubBackupConfigCreate>) =>\n    request<GitHubBackupConfig>('/github-backup/config', {\n      method: 'PATCH',\n      body: JSON.stringify(config),\n    }),\n\n  deleteGitHubBackupConfig: () =>\n    request<{ message: string }>('/github-backup/config', { method: 'DELETE' }),\n\n  testGitHubConnection: (repoUrl: string, token: string) =>\n    request<GitHubTestConnectionResponse>(\n      `/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}`,\n      { method: 'POST' }\n    ),\n\n  testGitHubStoredConnection: () =>\n    request<GitHubTestConnectionResponse>('/github-backup/test-stored', { method: 'POST' }),\n\n  triggerGitHubBackup: () =>\n    request<GitHubBackupTriggerResponse>('/github-backup/run', { method: 'POST' }),\n\n  getGitHubBackupStatus: () =>\n    request<GitHubBackupStatus>('/github-backup/status'),\n\n  getGitHubBackupLogs: (limit: number = 50) =>\n    request<GitHubBackupLog[]>(`/github-backup/logs?limit=${limit}`),\n\n  clearGitHubBackupLogs: (keepLast: number = 10) =>\n    request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),\n\n  // Scheduled local backups\n  getLocalBackupStatus: () =>\n    request<LocalBackupStatus>('/local-backup/status'),\n\n  triggerLocalBackup: () =>\n    request<{ success: boolean; message: string; filename?: string }>('/local-backup/run', { method: 'POST' }),\n\n  getLocalBackups: () =>\n    request<LocalBackupFile[]>('/local-backup/backups'),\n\n  downloadLocalBackup: async (filename: string): Promise<{ blob: Blob; filename: string }> => {\n    const response = await fetch(`${API_BASE}/local-backup/backups/${encodeURIComponent(filename)}/download`, {\n      headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},\n    });\n    if (!response.ok) throw new Error('Download failed');\n    const blob = await response.blob();\n    return { blob, filename };\n  },\n\n  restoreLocalBackup: (filename: string) =>\n    request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST' }),\n\n  deleteLocalBackup: (filename: string) =>\n    request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' }),\n\n  // Obico AI failure detection\n  getObicoStatus: () =>\n    request<ObicoStatus>('/obico/status'),\n\n  testObicoConnection: (url: string) =>\n    request<ObicoTestConnection>('/obico/test-connection', {\n      method: 'POST',\n      body: JSON.stringify({ url }),\n    }),\n\n  // Local Presets (OrcaSlicer imports)\n  getLocalPresets: () =>\n    request<LocalPresetsResponse>('/local-presets/'),\n  getLocalPresetDetail: (id: number) =>\n    request<LocalPresetDetail>(`/local-presets/${id}`),\n  importLocalPresets: (formData: FormData) =>\n    fetch(`${API_BASE}/local-presets/import`, {\n      method: 'POST',\n      headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},\n      body: formData,\n    }).then(async (res) => {\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.detail || `HTTP ${res.status}`);\n      }\n      return res.json() as Promise<ImportResponse>;\n    }),\n  createLocalPreset: (data: { name: string; preset_type: string; setting: Record<string, unknown> }) =>\n    request<LocalPreset>('/local-presets/', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  updateLocalPreset: (id: number, data: { name?: string; setting?: Record<string, unknown> }) =>\n    request<LocalPreset>(`/local-presets/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n  deleteLocalPreset: (id: number) =>\n    request<{ success: boolean }>(`/local-presets/${id}`, { method: 'DELETE' }),\n  refreshBaseProfileCache: () =>\n    request<{ refreshed: number; failed: number; total: number }>('/local-presets/base-cache/refresh', { method: 'POST' }),\n};\n\n// AMS History types\nexport interface AMSHistoryPoint {\n  recorded_at: string;\n  humidity: number | null;\n  humidity_raw: number | null;\n  temperature: number | null;\n}\n\nexport interface AMSHistoryResponse {\n  printer_id: number;\n  ams_id: number;\n  data: AMSHistoryPoint[];\n  min_humidity: number | null;\n  max_humidity: number | null;\n  avg_humidity: number | null;\n  min_temperature: number | null;\n  max_temperature: number | null;\n  avg_temperature: number | null;\n}\n\n// System Info types\nexport interface SystemInfo {\n  app: {\n    version: string;\n    base_dir: string;\n    archive_dir: string;\n  };\n  database: {\n    engine: string;\n    version: string;\n    archives: number;\n    archives_completed: number;\n    archives_failed: number;\n    archives_printing: number;\n    printers: number;\n    filaments: number;\n    projects: number;\n    smart_plugs: number;\n    total_print_time_seconds: number;\n    total_print_time_formatted: string;\n    total_filament_grams: number;\n    total_filament_kg: number;\n  };\n  printers: {\n    total: number;\n    connected: number;\n    connected_list: Array<{\n      id: number;\n      name: string;\n      state: string;\n      model: string;\n    }>;\n  };\n  storage: {\n    archive_size_bytes: number;\n    archive_size_formatted: string;\n    database_size_bytes: number;\n    database_size_formatted: string;\n    disk_total_bytes: number;\n    disk_total_formatted: string;\n    disk_used_bytes: number;\n    disk_used_formatted: string;\n    disk_free_bytes: number;\n    disk_free_formatted: string;\n    disk_percent_used: number;\n  };\n  system: {\n    platform: string;\n    platform_release: string;\n    platform_version: string;\n    architecture: string;\n    hostname: string;\n    python_version: string;\n    uptime_seconds: number;\n    uptime_formatted: string;\n    boot_time: string;\n  };\n  memory: {\n    total_bytes: number;\n    total_formatted: string;\n    available_bytes: number;\n    available_formatted: string;\n    used_bytes: number;\n    used_formatted: string;\n    percent_used: number;\n  };\n  cpu: {\n    count: number;\n    count_logical: number;\n    percent: number;\n  };\n}\n\nexport interface StorageUsageCategory {\n  key: string;\n  label: string;\n  bytes: number;\n  formatted: string;\n  percent_of_total: number;\n}\n\nexport interface StorageUsageOtherItem {\n  bucket: string;\n  label: string;\n  kind: 'system' | 'data';\n  deletable: boolean;\n  bytes: number;\n  formatted: string;\n  percent_of_total: number;\n}\n\nexport interface StorageUsageResponse {\n  roots: string[];\n  total_bytes: number;\n  total_formatted: string;\n  categories: StorageUsageCategory[];\n  other_breakdown: StorageUsageOtherItem[];\n  scan_errors: number;\n  generated_at: string;\n  cache: {\n    hit: boolean;\n    age_seconds: number;\n    max_age_seconds: number;\n  };\n}\n\n// Library (File Manager) types\nexport interface LibraryFolderTree {\n  id: number;\n  name: string;\n  parent_id: number | null;\n  project_id: number | null;\n  archive_id: number | null;\n  project_name: string | null;\n  archive_name: string | null;\n  is_external: boolean;\n  external_path: string | null;\n  external_readonly: boolean;\n  file_count: number;\n  children: LibraryFolderTree[];\n}\n\nexport interface LibraryFolder {\n  id: number;\n  name: string;\n  parent_id: number | null;\n  project_id: number | null;\n  archive_id: number | null;\n  project_name: string | null;\n  archive_name: string | null;\n  is_external: boolean;\n  external_path: string | null;\n  external_readonly: boolean;\n  external_show_hidden: boolean;\n  file_count: number;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface LibraryFolderCreate {\n  name: string;\n  parent_id?: number | null;\n  project_id?: number | null;\n  archive_id?: number | null;\n}\n\nexport interface ExternalFolderCreate {\n  name: string;\n  external_path: string;\n  readonly?: boolean;\n  show_hidden?: boolean;\n  parent_id?: number | null;\n}\n\nexport interface LibraryFolderUpdate {\n  name?: string;\n  parent_id?: number | null;\n  project_id?: number | null;  // 0 to unlink\n  archive_id?: number | null;  // 0 to unlink\n}\n\nexport interface LibraryFileDuplicate {\n  id: number;\n  filename: string;\n  folder_id: number | null;\n  folder_name: string | null;\n  created_at: string;\n}\n\nexport interface LibraryFile {\n  id: number;\n  folder_id: number | null;\n  folder_name: string | null;\n  project_id: number | null;\n  project_name: string | null;\n  is_external: boolean;\n  filename: string;\n  file_path: string;\n  file_type: string;\n  file_size: number;\n  file_hash: string | null;\n  thumbnail_path: string | null;\n  metadata: Record<string, unknown> | null;\n  print_count: number;\n  last_printed_at: string | null;\n  notes: string | null;\n  duplicates: LibraryFileDuplicate[] | null;\n  duplicate_count: number;\n  // User tracking (Issue #206)\n  created_by_id: number | null;\n  created_by_username: string | null;\n  created_at: string;\n  updated_at: string;\n  // Metadata fields\n  print_name: string | null;\n  print_time_seconds: number | null;\n  filament_used_grams: number | null;\n  sliced_for_model: string | null;\n}\n\nexport interface LibraryFileListItem {\n  id: number;\n  folder_id: number | null;\n  is_external: boolean;\n  filename: string;\n  file_type: string;\n  file_size: number;\n  thumbnail_path: string | null;\n  print_count: number;\n  duplicate_count: number;\n  // User tracking (Issue #206)\n  created_by_id: number | null;\n  created_by_username: string | null;\n  created_at: string;\n  print_name: string | null;\n  print_time_seconds: number | null;\n  filament_used_grams: number | null;\n  sliced_for_model: string | null;\n}\n\nexport interface LibraryFileUpdate {\n  filename?: string;\n  folder_id?: number | null;\n  project_id?: number | null;\n  notes?: string | null;\n}\n\nexport interface LibraryFileUploadResponse {\n  id: number;\n  filename: string;\n  file_type: string;\n  file_size: number;\n  thumbnail_path: string | null;\n  duplicate_of: number | null;\n  metadata: Record<string, unknown> | null;\n}\n\nexport interface LibraryStats {\n  total_files: number;\n  total_folders: number;\n  total_size_bytes: number;\n  files_by_type: Record<string, number>;\n  total_prints: number;\n  disk_free_bytes: number;\n  disk_total_bytes: number;\n  disk_used_bytes: number;\n}\n\nexport interface ZipExtractResult {\n  filename: string;\n  file_id: number;\n  folder_id: number | null;\n}\n\nexport interface ZipExtractError {\n  filename: string;\n  error: string;\n}\n\nexport interface ZipExtractResponse {\n  extracted: number;\n  folders_created: number;\n  files: ZipExtractResult[];\n  errors: ZipExtractError[];\n}\n\n// STL Thumbnail Generation types\nexport interface BatchThumbnailResult {\n  file_id: number;\n  filename: string;\n  success: boolean;\n  error?: string | null;\n}\n\nexport interface BatchThumbnailResponse {\n  processed: number;\n  succeeded: number;\n  failed: number;\n  results: BatchThumbnailResult[];\n}\n\n// Library Queue types\nexport interface AddToQueueResult {\n  file_id: number;\n  filename: string;\n  queue_item_id: number;\n  archive_id: number;\n}\n\nexport interface AddToQueueError {\n  file_id: number;\n  filename: string;\n  error: string;\n}\n\nexport interface AddToQueueResponse {\n  added: AddToQueueResult[];\n  errors: AddToQueueError[];\n}\n\n// Discovery types\nexport interface DiscoveredPrinter {\n  serial: string;\n  name: string;\n  ip_address: string;\n  model: string | null;\n  discovered_at: string | null;\n}\n\nexport interface DiscoveryStatus {\n  running: boolean;\n}\n\nexport interface DiscoveryInfo {\n  is_docker: boolean;\n  ssdp_running: boolean;\n  scan_running: boolean;\n  subnets: string[];\n}\n\nexport interface SubnetScanStatus {\n  running: boolean;\n  scanned: number;\n  total: number;\n}\n\n// Discovery API\nexport const discoveryApi = {\n  getInfo: () => request<DiscoveryInfo>('/discovery/info'),\n\n  getStatus: () => request<DiscoveryStatus>('/discovery/status'),\n\n  startDiscovery: (duration: number = 10) =>\n    request<DiscoveryStatus>(`/discovery/start?duration=${duration}`, { method: 'POST' }),\n\n  stopDiscovery: () =>\n    request<DiscoveryStatus>('/discovery/stop', { method: 'POST' }),\n\n  getDiscoveredPrinters: () =>\n    request<DiscoveredPrinter[]>('/discovery/printers'),\n\n  // Subnet scanning (for Docker environments)\n  startSubnetScan: (subnet: string, timeout: number = 1.0) =>\n    request<SubnetScanStatus>('/discovery/scan', {\n      method: 'POST',\n      body: JSON.stringify({ subnet, timeout }),\n    }),\n\n  getScanStatus: () => request<SubnetScanStatus>('/discovery/scan/status'),\n\n  stopSubnetScan: () =>\n    request<SubnetScanStatus>('/discovery/scan/stop', { method: 'POST' }),\n};\n\n// Virtual Printer types\nexport type VirtualPrinterMode = 'immediate' | 'queue' | 'review' | 'print_queue' | 'proxy';  // 'queue' is legacy, normalized to 'review'\n\nexport interface VirtualPrinterProxyStatus {\n  running: boolean;\n  target_host: string;\n  ftp_port: number;\n  mqtt_port: number;\n  ftp_connections: number;\n  mqtt_connections: number;\n}\n\nexport interface VirtualPrinterStatus {\n  enabled: boolean;\n  running: boolean;\n  mode: VirtualPrinterMode;\n  name: string;\n  serial: string;\n  model: string;\n  model_name: string;\n  pending_files: number;\n  target_printer_ip?: string;  // For proxy mode\n  proxy?: VirtualPrinterProxyStatus;  // For proxy mode\n}\n\nexport interface VirtualPrinterSettings {\n  enabled: boolean;\n  access_code_set: boolean;\n  mode: VirtualPrinterMode;\n  model: string;\n  target_printer_id: number | null;  // For proxy mode\n  remote_interface_ip: string | null;  // For SSDP proxy across networks\n  status: VirtualPrinterStatus;\n}\n\nexport interface NetworkInterface {\n  name: string;\n  ip: string;\n  netmask: string;\n  subnet: string;\n  is_alias?: boolean;\n  label?: string;\n}\n\nexport interface VirtualPrinterModels {\n  models: Record<string, string>;  // SSDP code -> display name\n  default: string;\n}\n\nexport interface PendingUpload {\n  id: number;\n  filename: string;\n  file_size: number;\n  source_ip: string | null;\n  status: string;\n  tags: string | null;\n  notes: string | null;\n  project_id: number | null;\n  uploaded_at: string;\n}\n\n// Virtual Printer API\nexport const virtualPrinterApi = {\n  getSettings: () => request<VirtualPrinterSettings>('/settings/virtual-printer'),\n\n  getModels: () => request<VirtualPrinterModels>('/settings/virtual-printer/models'),\n\n  updateSettings: (data: {\n    enabled?: boolean;\n    access_code?: string;\n    mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';\n    model?: string;\n    target_printer_id?: number;\n    remote_interface_ip?: string;\n  }) => {\n    const params = new URLSearchParams();\n    if (data.enabled !== undefined) params.set('enabled', String(data.enabled));\n    if (data.access_code !== undefined) params.set('access_code', data.access_code);\n    if (data.mode !== undefined) params.set('mode', data.mode);\n    if (data.model !== undefined) params.set('model', data.model);\n    if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));\n    if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);\n\n    return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {\n      method: 'PUT',\n    });\n  },\n};\n\n// Multi Virtual Printer API\nexport interface VirtualPrinterConfig {\n  id: number;\n  name: string;\n  enabled: boolean;\n  mode: VirtualPrinterMode;\n  model: string | null;\n  model_name: string | null;\n  access_code_set: boolean;\n  serial: string;\n  target_printer_id: number | null;\n  auto_dispatch: boolean;\n  bind_ip: string | null;\n  remote_interface_ip: string | null;\n  position: number;\n  status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus };\n}\n\nexport interface VirtualPrinterListResponse {\n  printers: VirtualPrinterConfig[];\n  models: Record<string, string>;\n}\n\nexport const multiVirtualPrinterApi = {\n  list: () => request<VirtualPrinterListResponse>('/virtual-printers'),\n\n  get: (id: number) => request<VirtualPrinterConfig>(`/virtual-printers/${id}`),\n\n  create: (data: {\n    name?: string;\n    enabled?: boolean;\n    mode?: string;\n    model?: string;\n    access_code?: string;\n    target_printer_id?: number;\n    auto_dispatch?: boolean;\n    bind_ip?: string;\n    remote_interface_ip?: string;\n  }) =>\n    request<VirtualPrinterConfig>('/virtual-printers', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n\n  update: (id: number, data: {\n    name?: string;\n    enabled?: boolean;\n    mode?: string;\n    model?: string;\n    access_code?: string;\n    target_printer_id?: number;\n    auto_dispatch?: boolean;\n    bind_ip?: string;\n    remote_interface_ip?: string;\n  }) =>\n    request<VirtualPrinterConfig>(`/virtual-printers/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(data),\n    }),\n\n  remove: (id: number) =>\n    request<{ detail: string; id: number }>(`/virtual-printers/${id}`, {\n      method: 'DELETE',\n    }),\n};\n\n// Pending Uploads API\nexport const pendingUploadsApi = {\n  list: () => request<PendingUpload[]>('/pending-uploads/'),\n\n  getCount: () => request<{ count: number }>('/pending-uploads/count'),\n\n  get: (id: number) => request<PendingUpload>(`/pending-uploads/${id}`),\n\n  archive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) =>\n    request<{ id: number; print_name: string; filename: string }>(`/pending-uploads/${id}/archive`, {\n      method: 'POST',\n      body: JSON.stringify(data || {}),\n    }),\n\n  discard: (id: number) =>\n    request<{ success: boolean }>(`/pending-uploads/${id}`, { method: 'DELETE' }),\n\n  archiveAll: () =>\n    request<{ archived: number; failed: number }>('/pending-uploads/archive-all', { method: 'POST' }),\n\n  discardAll: () =>\n    request<{ discarded: number }>('/pending-uploads/discard-all', { method: 'DELETE' }),\n};\n\n// Firmware API Types\nexport interface AvailableFirmwareVersion {\n  version: string;\n  file_available: boolean;\n  download_url: string | null;\n  release_notes: string | null;\n  release_time: string | null;\n}\n\nexport interface FirmwareUpdateInfo {\n  printer_id: number;\n  printer_name: string;\n  model: string | null;\n  current_version: string | null;\n  latest_version: string | null;\n  update_available: boolean;\n  download_url: string | null;\n  release_notes: string | null;\n  available_versions: AvailableFirmwareVersion[];\n}\n\nexport interface FirmwareUploadPrepare {\n  can_proceed: boolean;\n  sd_card_present: boolean;\n  sd_card_free_space: number;\n  firmware_size: number;\n  space_sufficient: boolean;\n  update_available: boolean;\n  current_version: string | null;\n  latest_version: string | null;\n  target_version: string | null;\n  firmware_filename: string | null;\n  errors: string[];\n}\n\nexport interface FirmwareUploadStatus {\n  status: 'idle' | 'preparing' | 'downloading' | 'uploading' | 'complete' | 'error';\n  progress: number;\n  message: string;\n  error: string | null;\n  firmware_filename: string | null;\n  firmware_version: string | null;\n}\n\n// Firmware API\nexport const firmwareApi = {\n  checkUpdates: () =>\n    request<{ updates: FirmwareUpdateInfo[]; updates_available: number }>('/firmware/updates'),\n\n  checkPrinterUpdate: (printerId: number) =>\n    request<FirmwareUpdateInfo>(`/firmware/updates/${printerId}`),\n\n  prepareUpload: (printerId: number, version?: string) =>\n    request<FirmwareUploadPrepare>(\n      `/firmware/updates/${printerId}/prepare${version ? `?version=${encodeURIComponent(version)}` : ''}`,\n    ),\n\n  startUpload: (printerId: number, version?: string) =>\n    request<{ started: boolean; message: string }>(\n      `/firmware/updates/${printerId}/upload${version ? `?version=${encodeURIComponent(version)}` : ''}`,\n      { method: 'POST' },\n    ),\n\n  getUploadStatus: (printerId: number) =>\n    request<FirmwareUploadStatus>(`/firmware/updates/${printerId}/upload/status`),\n};\n\n// Support types\nexport interface DebugLoggingState {\n  enabled: boolean;\n  enabled_at: string | null;\n  duration_seconds: number | null;\n}\n\nexport interface LogEntry {\n  timestamp: string;\n  level: string;\n  logger_name: string;\n  message: string;\n}\n\nexport interface LogsResponse {\n  entries: LogEntry[];\n  total_in_file: number;\n  filtered_count: number;\n}\n\n// Support API\nexport const supportApi = {\n  getDebugLoggingState: () =>\n    request<DebugLoggingState>('/support/debug-logging'),\n\n  setDebugLogging: (enabled: boolean) =>\n    request<DebugLoggingState>('/support/debug-logging', {\n      method: 'POST',\n      body: JSON.stringify({ enabled }),\n    }),\n\n  downloadSupportBundle: async () => {\n    const headers: Record<string, string> = {};\n    if (authToken) {\n      headers['Authorization'] = `Bearer ${authToken}`;\n    }\n    const response = await fetch(`${API_BASE}/support/bundle`, { headers });\n    if (!response.ok) {\n      const error = await response.json().catch(() => ({}));\n      throw new Error(error.detail || `HTTP ${response.status}`);\n    }\n    // Get filename from Content-Disposition header or use default\n    const disposition = response.headers.get('Content-Disposition');\n    const filename = parseContentDispositionFilename(disposition) || 'bambuddy-support.zip';\n\n    // Download the blob\n    const blob = await response.blob();\n    const url = window.URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    window.URL.revokeObjectURL(url);\n  },\n\n  getLogs: (params?: { limit?: number; level?: string; search?: string }) => {\n    const searchParams = new URLSearchParams();\n    if (params?.limit) searchParams.set('limit', params.limit.toString());\n    if (params?.level) searchParams.set('level', params.level);\n    if (params?.search) searchParams.set('search', params.search);\n    const query = searchParams.toString();\n    return request<LogsResponse>(`/support/logs${query ? `?${query}` : ''}`);\n  },\n\n  clearLogs: () =>\n    request<{ message: string }>('/support/logs', { method: 'DELETE' }),\n};\n\n// SpoolBuddy types\nexport interface SpoolBuddyDevice {\n  id: number;\n  device_id: string;\n  hostname: string;\n  ip_address: string;\n  backend_url?: string | null;\n  firmware_version: string | null;\n  has_nfc: boolean;\n  has_scale: boolean;\n  tare_offset: number;\n  calibration_factor: number;\n  nfc_reader_type: string | null;\n  nfc_connection: string | null;\n  display_brightness: number;\n  display_blank_timeout: number;\n  has_backlight: boolean;\n  last_calibrated_at: string | null;\n  last_seen: string | null;\n  pending_command: string | null;\n  nfc_ok: boolean;\n  scale_ok: boolean;\n  uptime_s: number;\n  update_status: string | null;\n  update_message: string | null;\n  system_stats: {\n    os?: { os?: string; kernel?: string; arch?: string; python?: string };\n    cpu_temp_c?: number;\n    cpu_count?: number;\n    load_avg?: number[];\n    memory?: { total_mb?: number; available_mb?: number; used_mb?: number; percent?: number };\n    disk?: { total_gb?: number; used_gb?: number; free_gb?: number; percent?: number };\n    system_uptime_s?: number;\n  } | null;\n  online: boolean;\n}\n\nexport interface DaemonUpdateCheck {\n  current_version: string;\n  latest_version: string | null;\n  update_available: boolean;\n}\n\n// SpoolBuddy API\nexport const spoolbuddyApi = {\n  getDevices: () =>\n    request<SpoolBuddyDevice[]>('/spoolbuddy/devices'),\n\n  deleteDevice: (deviceId: string) =>\n    request<{ status: string; device_id: string }>(`/spoolbuddy/devices/${deviceId}`, {\n      method: 'DELETE',\n    }),\n\n  tare: (deviceId: string) =>\n    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/calibration/tare`, {\n      method: 'POST',\n      body: '{}',\n    }),\n\n  getCalibration: (deviceId: string) =>\n    request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration`),\n\n  setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number, tareRawAdc?: number) =>\n    request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration/set-factor`, {\n      method: 'POST',\n      body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc, tare_raw_adc: tareRawAdc }),\n    }),\n\n  updateSpoolWeight: (spoolId: number, weightGrams: number) =>\n    request<{ status: string; weight_used: number }>('/spoolbuddy/scale/update-spool-weight', {\n      method: 'POST',\n      body: JSON.stringify({ spool_id: spoolId, weight_grams: weightGrams }),\n    }),\n\n  updateDisplay: (deviceId: string, brightness: number, blankTimeout: number) =>\n    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/display`, {\n      method: 'PUT',\n      body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),\n    }),\n\n  updateSystemConfig: (deviceId: string, backendUrl: string, apiKey?: string) =>\n    request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/system/config`, {\n      method: 'POST',\n      body: JSON.stringify({ backend_url: backendUrl, ...(apiKey ? { api_key: apiKey } : {}) }),\n    }),\n\n  checkDaemonUpdate: (deviceId: string) =>\n    request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check`),\n\n  triggerUpdate: (deviceId: string) =>\n    request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/update`, {\n      method: 'POST',\n      body: '{}',\n    }),\n\n  getSSHPublicKey: () =>\n    request<{ public_key: string }>('/spoolbuddy/ssh/public-key'),\n\n  writeTag: (deviceId: string, spoolId: number) =>\n    request<{ status: string }>('/spoolbuddy/nfc/write-tag', {\n      method: 'POST',\n      body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }),\n    }),\n\n  cancelWrite: (deviceId: string) =>\n    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/cancel-write`, {\n      method: 'POST',\n      body: '{}',\n    }),\n\n  systemCommand: (deviceId: string, command: 'reboot' | 'shutdown' | 'restart_daemon' | 'restart_browser') =>\n    request<{ status: string; command: string }>(`/spoolbuddy/devices/${deviceId}/system/command`, {\n      method: 'POST',\n      body: JSON.stringify({ command }),\n    }),\n\n  queueDiagnostics: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>\n    request<{ status: string; diagnostic: string; message: string }>(\n      `/spoolbuddy/diagnostics/${deviceId}/run?diagnostic=${type}`,\n      { method: 'POST', body: '{}' }\n    ),\n\n  getDiagnosticResult: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>\n    request<{ diagnostic: string; success: boolean; output: string; exit_code: number }>(\n      `/spoolbuddy/diagnostics/${deviceId}/result?diagnostic=${type}`,\n      { method: 'GET' }\n    ),\n};\n\nexport interface BugReportRequest {\n  description: string;\n  email?: string;\n  screenshot_base64?: string;\n  include_support_info?: boolean;\n  debug_logs?: string;\n}\n\nexport interface BugReportResponse {\n  success: boolean;\n  message: string;\n  issue_url?: string;\n  issue_number?: number;\n}\n\nexport const bugReportApi = {\n  submit: (data: BugReportRequest) =>\n    request<BugReportResponse>('/bug-report/submit', {\n      method: 'POST',\n      body: JSON.stringify(data),\n    }),\n  startLogging: () =>\n    request<{ started: boolean; was_debug: boolean }>('/bug-report/start-logging', {\n      method: 'POST',\n    }),\n  stopLogging: (wasDebug: boolean) =>\n    request<{ logs: string }>(`/bug-report/stop-logging?was_debug=${wasDebug}`, {\n      method: 'POST',\n    }),\n};\n"
  },
  {
    "path": "frontend/src/components/AMSHistoryModal.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { X, Droplets, Thermometer, TrendingUp, TrendingDown, Minus } from 'lucide-react';\nimport {\n  LineChart,\n  Line,\n  XAxis,\n  YAxis,\n  CartesianGrid,\n  Tooltip,\n  ResponsiveContainer,\n  Legend,\n  ReferenceLine,\n} from 'recharts';\nimport { api, type AMSHistoryResponse } from '../api/client';\nimport { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date';\nimport { useTranslation } from 'react-i18next';\nimport { useTheme } from '../contexts/ThemeContext';\n\ninterface AMSHistoryModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  printerId: number;\n  printerName: string;\n  amsId: number;\n  amsLabel: string;\n  initialMode?: 'humidity' | 'temperature';\n  thresholds?: {\n    humidityGood: number;\n    humidityFair: number;\n    tempGood: number;\n    tempFair: number;\n  };\n}\n\ntype TimeRange = '6h' | '24h' | '48h' | '7d';\n\nconst TIME_RANGES: { value: TimeRange; label: string; hours: number }[] = [\n  { value: '6h', label: '6h', hours: 6 },\n  { value: '24h', label: '24h', hours: 24 },\n  { value: '48h', label: '48h', hours: 48 },\n  { value: '7d', label: '7d', hours: 168 },\n];\n\nexport function AMSHistoryModal({\n  isOpen,\n  onClose,\n  printerId,\n  printerName,\n  amsId,\n  amsLabel,\n  initialMode = 'humidity',\n  thresholds,\n}: AMSHistoryModalProps) {\n  const { t } = useTranslation();\n  const { mode: themeMode } = useTheme();\n  const [timeRange, setTimeRange] = useState<TimeRange>('24h');\n  const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);\n  const isDark = themeMode === 'dark';\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const timeFormat: TimeFormat = settings?.time_format || 'system';\n\n  // Close on Escape key\n  useEffect(() => {\n    if (!isOpen) return;\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [isOpen, onClose]);\n\n  const hours = TIME_RANGES.find(r => r.value === timeRange)?.hours || 24;\n\n  const { data, isLoading, error } = useQuery<AMSHistoryResponse>({\n    queryKey: ['ams-history', printerId, amsId, hours],\n    queryFn: () => api.getAMSHistory(printerId, amsId, hours),\n    enabled: isOpen,\n    refetchInterval: 60000, // Refresh every minute\n  });\n\n  if (!isOpen) return null;\n\n  // Format data for chart\n  const rawPoints = data?.data.map(point => {\n    const date = parseUTCDate(point.recorded_at) || new Date();\n    return {\n      time: date.getTime(),\n      humidity: point.humidity,\n      temperature: point.temperature,\n    };\n  }) || [];\n\n  // Pad edges so the line extends across the full time window\n  const domainStart = Date.now() - hours * 60 * 60 * 1000;\n  const domainEnd = Date.now();\n  const chartData = [...rawPoints];\n  if (chartData.length > 0) {\n    const first = chartData[0];\n    if (first.time > domainStart) {\n      chartData.unshift({ ...first, time: domainStart });\n    }\n    const last = chartData[chartData.length - 1];\n    if (last.time < domainEnd) {\n      chartData.push({ ...last, time: domainEnd });\n    }\n  }\n\n  // Get thresholds\n  const humidityGood = thresholds?.humidityGood || 40;\n  const humidityFair = thresholds?.humidityFair || 60;\n  const tempGood = thresholds?.tempGood || 30;\n  const tempFair = thresholds?.tempFair || 35;\n\n  // Current values (last data point)\n  const lastPoint = chartData[chartData.length - 1];\n  const currentHumidity = lastPoint?.humidity;\n  const currentTemp = lastPoint?.temperature;\n\n  // Trend calculation (compare first and last 20% of data)\n  const getTrend = (values: (number | null)[]) => {\n    const filtered = values.filter((v): v is number => v != null);\n    if (filtered.length < 4) return 'stable';\n    const firstQuarter = filtered.slice(0, Math.floor(filtered.length / 4));\n    const lastQuarter = filtered.slice(-Math.floor(filtered.length / 4));\n    const firstAvg = firstQuarter.reduce((a, b) => a + b, 0) / firstQuarter.length;\n    const lastAvg = lastQuarter.reduce((a, b) => a + b, 0) / lastQuarter.length;\n    const diff = lastAvg - firstAvg;\n    if (Math.abs(diff) < 2) return 'stable';\n    return diff > 0 ? 'up' : 'down';\n  };\n\n  const humidityTrend = getTrend(chartData.map(d => d.humidity));\n  const tempTrend = getTrend(chartData.map(d => d.temperature));\n\n  const TrendIcon = ({ trend }: { trend: string }) => {\n    if (trend === 'up') return <TrendingUp className=\"w-4 h-4 text-red-400\" />;\n    if (trend === 'down') return <TrendingDown className=\"w-4 h-4 text-green-400\" />;\n    return <Minus className=\"w-4 h-4 text-gray-400 dark:text-bambu-gray\" />;\n  };\n\n  // Get status color for current value\n  const getHumidityColor = (value: number | undefined | null) => {\n    if (value == null) return '#9ca3af';\n    if (value <= humidityGood) return '#22a352';\n    if (value <= humidityFair) return '#d4a017';\n    return '#c62828';\n  };\n\n  const getTempColor = (value: number | undefined | null) => {\n    if (value == null) return '#9ca3af';\n    if (value <= tempGood) return '#22a352';\n    if (value <= tempFair) return '#d4a017';\n    return '#c62828';\n  };\n\n  // Theme-aware styles (using isDark since dark: prefix doesn't work in portals)\n  const modalBg = isDark ? '#2d2d2d' : '#ffffff';\n  const cardBg = isDark ? '#1d1d1d' : '#f3f4f6';\n  const borderColor = isDark ? '#3d3d3d' : '#e5e7eb';\n  const textPrimary = isDark ? '#ffffff' : '#111827';\n  const textSecondary = isDark ? '#9ca3af' : '#4b5563';\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\" onClick={onClose}>\n      <div\n        className=\"rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl\"\n        style={{ backgroundColor: modalBg }}\n        onClick={e => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div\n          className=\"flex items-center justify-between px-6 py-4 border-b\"\n          style={{ borderColor }}\n        >\n          <div>\n            <h2 className=\"text-lg font-semibold\" style={{ color: textPrimary }}>\n              {amsLabel} {t('common.history', 'History')}\n            </h2>\n            <p className=\"text-sm\" style={{ color: textSecondary }}>{printerName}</p>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"p-2 rounded-lg transition-colors\"\n            style={{ color: textSecondary }}\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Content */}\n        <div className=\"p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]\">\n          {/* Time Range & Mode Selector */}\n          <div className=\"flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-3\">\n            <div className=\"inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit\" style={{ backgroundColor: cardBg }}>\n              <button\n                onClick={() => setMode('humidity')}\n                className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${\n                  mode === 'humidity' ? 'bg-blue-600 text-white' : ''\n                }`}\n                style={mode !== 'humidity' ? { color: textSecondary } : undefined}\n              >\n                <Droplets className=\"w-4 h-4\" />\n                {t('common.humidity', 'Humidity')}\n              </button>\n              <button\n                onClick={() => setMode('temperature')}\n                className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${\n                  mode === 'temperature' ? 'bg-orange-600 text-white' : ''\n                }`}\n                style={mode !== 'temperature' ? { color: textSecondary } : undefined}\n              >\n                <Thermometer className=\"w-4 h-4\" />\n                {t('common.temperature', 'Temperature')}\n              </button>\n            </div>\n\n            <div className=\"inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit\" style={{ backgroundColor: cardBg }}>\n              {TIME_RANGES.map(range => (\n                <button\n                  key={range.value}\n                  onClick={() => setTimeRange(range.value)}\n                  className={`px-3 py-1 text-sm rounded-md transition-colors ${\n                    timeRange === range.value ? 'bg-bambu-green text-white' : ''\n                  }`}\n                  style={timeRange !== range.value ? { color: textSecondary } : undefined}\n                >\n                  {range.label}\n                </button>\n              ))}\n            </div>\n          </div>\n\n          {/* Stats Cards */}\n          <div className=\"grid grid-cols-4 gap-4 max-[550px]:grid-cols-2\">\n            {mode === 'humidity' ? (\n              <>\n                <div className=\"rounded-lg p-4 max-[550px]:order-2\" style={{ backgroundColor: cardBg }}>\n                  <p className=\"text-xs\" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>\n                  <div className=\"flex items-center gap-2\">\n                    <p className=\"text-2xl font-bold\" style={{ color: getHumidityColor(currentHumidity) }}>\n                      {currentHumidity != null ? `${currentHumidity}%` : '—'}\n                    </p>\n                    <TrendIcon trend={humidityTrend} />\n                  </div>\n                </div>\n                <div className=\"rounded-lg p-4 max-[550px]:order-4\" style={{ backgroundColor: cardBg }}>\n                  <p className=\"text-xs\" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>\n                  <p className=\"text-2xl font-bold\" style={{ color: textPrimary }}>\n                    {data?.avg_humidity != null ? `${data.avg_humidity}%` : '—'}\n                  </p>\n                </div>\n                <div className=\"rounded-lg p-4 max-[550px]:order-1\" style={{ backgroundColor: cardBg }}>\n                  <p className=\"text-xs\" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>\n                  <p className=\"text-2xl font-bold text-green-500\">\n                    {data?.min_humidity != null ? `${data.min_humidity}%` : '—'}\n                  </p>\n                </div>\n                <div className=\"rounded-lg p-4 max-[550px]:order-3\" style={{ backgroundColor: cardBg }}>\n                  <p className=\"text-xs\" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>\n                  <p className=\"text-2xl font-bold text-red-500\">\n                    {data?.max_humidity != null ? `${data.max_humidity}%` : '—'}\n                  </p>\n                </div>\n              </>\n            ) : (\n              <>\n                <div className=\"rounded-lg p-4 max-[550px]:order-2\" style={{ backgroundColor: cardBg }}>\n                  <p className=\"text-xs\" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>\n                  <div className=\"flex items-center gap-2\">\n                    <p className=\"text-2xl font-bold\" style={{ color: getTempColor(currentTemp) }}>\n                      {currentTemp != null ? `${currentTemp}°C` : '—'}\n                    </p>\n                    <TrendIcon trend={tempTrend} />\n                  </div>\n                </div>\n                <div className=\"rounded-lg p-4 max-[550px]:order-4\" style={{ backgroundColor: cardBg }}>\n                  <p className=\"text-xs\" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>\n                  <p className=\"text-2xl font-bold\" style={{ color: textPrimary }}>\n                    {data?.avg_temperature != null ? `${data.avg_temperature}°C` : '—'}\n                  </p>\n                </div>\n                <div className=\"rounded-lg p-4 max-[550px]:order-1\" style={{ backgroundColor: cardBg }}>\n                  <p className=\"text-xs\" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>\n                  <p className=\"text-2xl font-bold text-blue-500\">\n                    {data?.min_temperature != null ? `${data.min_temperature}°C` : '—'}\n                  </p>\n                </div>\n                <div className=\"rounded-lg p-4 max-[550px]:order-3\" style={{ backgroundColor: cardBg }}>\n                  <p className=\"text-xs\" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>\n                  <p className=\"text-2xl font-bold text-red-500\">\n                    {data?.max_temperature != null ? `${data.max_temperature}°C` : '—'}\n                  </p>\n                </div>\n              </>\n            )}\n          </div>\n\n          {/* Chart */}\n          <div className=\"rounded-lg p-4\" style={{ backgroundColor: cardBg }}>\n            {isLoading ? (\n              <div className=\"h-[300px] flex items-center justify-center\" style={{ color: textSecondary }}>\n                {t('common.loading', 'Loading...')}\n              </div>\n            ) : error ? (\n              <div className=\"h-[300px] flex items-center justify-center text-red-500\">\n                {t('common.error', 'Error loading data')}\n              </div>\n            ) : chartData.length === 0 ? (\n              <div className=\"h-[300px] flex items-center justify-center\" style={{ color: textSecondary }}>\n                {t('common.noData', 'No data available for this time range')}\n              </div>\n            ) : (\n              <ResponsiveContainer width=\"100%\" height={300}>\n                <LineChart data={chartData}>\n                  <CartesianGrid strokeDasharray=\"3 3\" stroke={isDark ? '#3d3d3d' : '#e5e7eb'} />\n                  <XAxis\n                    dataKey=\"time\"\n                    type=\"number\"\n                    domain={[Date.now() - hours * 60 * 60 * 1000, Date.now()]}\n                    tickFormatter={(ts) => {\n                      const date = new Date(ts);\n                      if (hours > 24) {\n                        return date.toLocaleDateString([], { day: 'numeric', month: 'short' });\n                      }\n                      return date.toLocaleTimeString([], applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat));\n                    }}\n                    stroke={isDark ? '#9ca3af' : '#6b7280'}\n                    tick={{ fontSize: 12 }}\n                  />\n                  <YAxis\n                    stroke={isDark ? '#9ca3af' : '#6b7280'}\n                    tick={{ fontSize: 12 }}\n                    domain={mode === 'humidity' ? [0, 100] : ['auto', 'auto']}\n                    tickFormatter={(value) => mode === 'humidity' ? `${value}%` : `${value}°C`}\n                  />\n                  <Tooltip\n                    contentStyle={{\n                      backgroundColor: isDark ? '#2d2d2d' : '#ffffff',\n                      border: `1px solid ${isDark ? '#3d3d3d' : '#e5e7eb'}`,\n                      borderRadius: '8px',\n                      color: isDark ? '#fff' : '#000',\n                    }}\n                    labelFormatter={(ts) => new Date(ts).toLocaleString(undefined, applyTimeFormat({\n                      year: 'numeric',\n                      month: 'short',\n                      day: 'numeric',\n                      hour: '2-digit',\n                      minute: '2-digit',\n                    }, timeFormat))}\n                    formatter={(value) => [\n                      mode === 'humidity' ? `${value ?? 0}%` : `${value ?? 0}°C`,\n                      mode === 'humidity' ? 'Humidity' : 'Temperature'\n                    ]}\n                  />\n                  <Legend />\n\n                  {/* Threshold lines */}\n                  {mode === 'humidity' ? (\n                    <>\n                      <ReferenceLine y={humidityGood} stroke=\"#22a352\" strokeDasharray=\"5 5\" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />\n                      <ReferenceLine y={humidityFair} stroke=\"#d4a017\" strokeDasharray=\"5 5\" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />\n                    </>\n                  ) : (\n                    <>\n                      <ReferenceLine y={tempGood} stroke=\"#22a352\" strokeDasharray=\"5 5\" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />\n                      <ReferenceLine y={tempFair} stroke=\"#d4a017\" strokeDasharray=\"5 5\" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />\n                    </>\n                  )}\n\n                  <Line\n                    type=\"monotone\"\n                    dataKey={mode}\n                    name={mode === 'humidity' ? 'Humidity' : 'Temperature'}\n                    stroke={mode === 'humidity' ? '#3b82f6' : '#f97316'}\n                    strokeWidth={2}\n                    dot={false}\n                    activeDot={{ r: 4 }}\n                    connectNulls={true}\n                  />\n                </LineChart>\n              </ResponsiveContainer>\n            )}\n          </div>\n\n          {/* Info */}\n          <div className=\"text-xs text-center\" style={{ color: textSecondary }}>\n            {t('amsHistory.recordingInfo', 'Data is recorded every 5 minutes while the printer is connected')}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/APIBrowser.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { ChevronDown, ChevronRight, Play, Copy, Loader2, ExternalLink, AlertCircle, CheckCircle } from 'lucide-react';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\n\ninterface OpenAPISchema {\n  paths: Record<string, Record<string, EndpointSpec>>;\n  components?: {\n    schemas?: Record<string, SchemaSpec>;\n  };\n}\n\ninterface EndpointSpec {\n  summary?: string;\n  description?: string;\n  tags?: string[];\n  parameters?: ParameterSpec[];\n  requestBody?: {\n    content?: {\n      'application/json'?: {\n        schema?: SchemaSpec;\n      };\n    };\n  };\n  responses?: Record<string, ResponseSpec>;\n}\n\ninterface ParameterSpec {\n  name: string;\n  in: 'path' | 'query' | 'header';\n  required?: boolean;\n  description?: string;\n  schema?: {\n    type?: string;\n    default?: unknown;\n    enum?: string[];\n  };\n}\n\ninterface SchemaSpec {\n  type?: string;\n  properties?: Record<string, SchemaSpec>;\n  required?: string[];\n  items?: SchemaSpec;\n  $ref?: string;\n  allOf?: SchemaSpec[];\n  anyOf?: SchemaSpec[];\n  oneOf?: SchemaSpec[];\n  default?: unknown;\n  description?: string;\n  enum?: string[];\n  example?: unknown;\n}\n\ninterface ResponseSpec {\n  description?: string;\n  content?: {\n    'application/json'?: {\n      schema?: SchemaSpec;\n    };\n  };\n}\n\ninterface APIResponse {\n  status: number;\n  statusText: string;\n  headers: Record<string, string>;\n  body: unknown;\n  duration: number;\n}\n\nconst METHOD_COLORS: Record<string, string> = {\n  get: 'bg-blue-500/20 text-blue-400 border-blue-500/30',\n  post: 'bg-green-500/20 text-green-400 border-green-500/30',\n  put: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',\n  patch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',\n  delete: 'bg-red-500/20 text-red-400 border-red-500/30',\n};\n\nfunction resolveRef(schema: OpenAPISchema, ref: string): SchemaSpec {\n  // Parse $ref like \"#/components/schemas/PrinterCreate\"\n  const parts = ref.replace('#/', '').split('/');\n  let current: unknown = schema;\n  for (const part of parts) {\n    current = (current as Record<string, unknown>)[part];\n  }\n  return current as SchemaSpec;\n}\n\nfunction getSchemaExample(schema: OpenAPISchema, spec: SchemaSpec, depth = 0): unknown {\n  if (depth > 5) return '...';\n\n  if (spec.$ref) {\n    return getSchemaExample(schema, resolveRef(schema, spec.$ref), depth + 1);\n  }\n\n  if (spec.allOf) {\n    const merged: Record<string, unknown> = {};\n    for (const sub of spec.allOf) {\n      const subExample = getSchemaExample(schema, sub, depth + 1);\n      if (typeof subExample === 'object' && subExample !== null) {\n        Object.assign(merged, subExample);\n      }\n    }\n    return merged;\n  }\n\n  if (spec.example !== undefined) return spec.example;\n  if (spec.default !== undefined) return spec.default;\n\n  switch (spec.type) {\n    case 'string':\n      if (spec.enum) return spec.enum[0];\n      return 'string';\n    case 'integer':\n    case 'number':\n      return 0;\n    case 'boolean':\n      return false;\n    case 'array':\n      return spec.items ? [getSchemaExample(schema, spec.items, depth + 1)] : [];\n    case 'object':\n      if (spec.properties) {\n        const obj: Record<string, unknown> = {};\n        for (const [key, propSpec] of Object.entries(spec.properties)) {\n          obj[key] = getSchemaExample(schema, propSpec, depth + 1);\n        }\n        return obj;\n      }\n      return {};\n    default:\n      return null;\n  }\n}\n\ninterface EndpointItemProps {\n  path: string;\n  method: string;\n  spec: EndpointSpec;\n  schema: OpenAPISchema;\n  apiKey: string;\n}\n\nfunction EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps) {\n  const [expanded, setExpanded] = useState(false);\n  const [params, setParams] = useState<Record<string, string>>({});\n  const [bodyText, setBodyText] = useState('');\n  const [response, setResponse] = useState<APIResponse | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [copied, setCopied] = useState(false);\n\n  // Initialize params with defaults\n  useEffect(() => {\n    if (expanded && spec.parameters) {\n      const defaults: Record<string, string> = {};\n      for (const param of spec.parameters) {\n        if (param.schema?.default !== undefined) {\n          defaults[param.name] = String(param.schema.default);\n        }\n      }\n      setParams(prev => ({ ...defaults, ...prev }));\n    }\n  }, [expanded, spec.parameters]);\n\n  // Initialize body with example\n  useEffect(() => {\n    if (expanded && spec.requestBody?.content?.['application/json']?.schema && !bodyText) {\n      const bodySchema = spec.requestBody.content['application/json'].schema;\n      const example = getSchemaExample(schema, bodySchema);\n      setBodyText(JSON.stringify(example, null, 2));\n    }\n  }, [expanded, spec.requestBody, schema, bodyText]);\n\n  // Check for missing required parameters\n  const getMissingParams = () => {\n    const missing: string[] = [];\n    for (const param of spec.parameters || []) {\n      if (param.in === 'path' || param.required) {\n        const value = params[param.name];\n        if (value === undefined || value === '') {\n          missing.push(param.name);\n        }\n      }\n    }\n    return missing;\n  };\n\n  const missingParams = getMissingParams();\n\n  const executeRequest = async () => {\n    if (missingParams.length > 0) {\n      setResponse({\n        status: 0,\n        statusText: 'Validation Error',\n        headers: {},\n        body: `Missing required parameters: ${missingParams.join(', ')}`,\n        duration: 0,\n      });\n      return;\n    }\n\n    setLoading(true);\n    setResponse(null);\n\n    try {\n      // Build URL with path and query params\n      let url = path;\n      const queryParams = new URLSearchParams();\n\n      for (const param of spec.parameters || []) {\n        const value = params[param.name];\n        if (value !== undefined && value !== '') {\n          if (param.in === 'path') {\n            url = url.replace(`{${param.name}}`, encodeURIComponent(value));\n          } else if (param.in === 'query') {\n            queryParams.append(param.name, value);\n          }\n        }\n      }\n\n      const queryString = queryParams.toString();\n      // OpenAPI paths already include /api/v1 prefix\n      const fullUrl = `${url}${queryString ? `?${queryString}` : ''}`;\n\n      const headers: Record<string, string> = {\n        'Content-Type': 'application/json',\n      };\n\n      if (apiKey) {\n        headers['X-API-Key'] = apiKey;\n      }\n\n      const options: RequestInit = {\n        method: method.toUpperCase(),\n        headers,\n      };\n\n      if (['post', 'put', 'patch'].includes(method) && bodyText) {\n        options.body = bodyText;\n      }\n\n      const startTime = performance.now();\n      const res = await fetch(fullUrl, options);\n      const duration = Math.round(performance.now() - startTime);\n\n      const responseHeaders: Record<string, string> = {};\n      res.headers.forEach((value, key) => {\n        responseHeaders[key] = value;\n      });\n\n      let body: unknown;\n      const contentType = res.headers.get('content-type');\n      if (contentType?.includes('application/json')) {\n        body = await res.json();\n      } else {\n        body = await res.text();\n      }\n\n      setResponse({\n        status: res.status,\n        statusText: res.statusText,\n        headers: responseHeaders,\n        body,\n        duration,\n      });\n    } catch (err) {\n      setResponse({\n        status: 0,\n        statusText: 'Network Error',\n        headers: {},\n        body: err instanceof Error ? err.message : 'Unknown error',\n        duration: 0,\n      });\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const copyResponse = async () => {\n    if (response) {\n      const text = typeof response.body === 'string'\n        ? response.body\n        : JSON.stringify(response.body, null, 2);\n      try {\n        await navigator.clipboard.writeText(text);\n        setCopied(true);\n        setTimeout(() => setCopied(false), 2000);\n      } catch {\n        // Fallback for non-HTTPS\n        const textArea = document.createElement('textarea');\n        textArea.value = text;\n        textArea.style.position = 'fixed';\n        textArea.style.left = '-999999px';\n        document.body.appendChild(textArea);\n        textArea.select();\n        document.execCommand('copy');\n        document.body.removeChild(textArea);\n        setCopied(true);\n        setTimeout(() => setCopied(false), 2000);\n      }\n    }\n  };\n\n  const pathParams = (spec.parameters || []).filter(p => p.in === 'path');\n  const queryParamsSpec = (spec.parameters || []).filter(p => p.in === 'query');\n  const hasBody = ['post', 'put', 'patch'].includes(method) && spec.requestBody;\n\n  return (\n    <div className=\"border border-bambu-dark-tertiary rounded-lg overflow-hidden\">\n      <button\n        onClick={() => setExpanded(!expanded)}\n        className=\"w-full flex items-center gap-3 p-3 hover:bg-bambu-dark-tertiary/50 transition-colors text-left\"\n      >\n        {expanded ? (\n          <ChevronDown className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n        ) : (\n          <ChevronRight className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n        )}\n        <span className={`px-2 py-0.5 text-xs font-mono font-semibold uppercase rounded border ${METHOD_COLORS[method] || 'bg-gray-500/20 text-gray-400'}`}>\n          {method}\n        </span>\n        <code className=\"text-sm text-white font-mono flex-1 truncate\">{path}</code>\n        {spec.summary && (\n          <span className=\"text-sm text-bambu-gray truncate max-w-[40%]\">{spec.summary}</span>\n        )}\n      </button>\n\n      {expanded && (\n        <div className=\"border-t border-bambu-dark-tertiary p-4 space-y-4 bg-bambu-dark/50\">\n          {spec.description && (\n            <p className=\"text-sm text-bambu-gray\">{spec.description}</p>\n          )}\n\n          {/* Path Parameters */}\n          {pathParams.length > 0 && (\n            <div className=\"space-y-2\">\n              <h4 className=\"text-sm font-medium text-white\">Path Parameters</h4>\n              <div className=\"space-y-2\">\n                {pathParams.map(param => (\n                  <div key={param.name} className=\"flex items-center gap-2\">\n                    <label className=\"text-sm text-bambu-gray w-32 flex-shrink-0\">\n                      {param.name}\n                      {param.required && <span className=\"text-red-400 ml-1\">*</span>}\n                    </label>\n                    <input\n                      type=\"text\"\n                      value={params[param.name] || ''}\n                      onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}\n                      placeholder={param.description || param.schema?.type || 'value'}\n                      className=\"flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none\"\n                    />\n                  </div>\n                ))}\n              </div>\n            </div>\n          )}\n\n          {/* Query Parameters */}\n          {queryParamsSpec.length > 0 && (\n            <div className=\"space-y-2\">\n              <h4 className=\"text-sm font-medium text-white\">Query Parameters</h4>\n              <div className=\"space-y-2\">\n                {queryParamsSpec.map(param => (\n                  <div key={param.name} className=\"flex items-center gap-2\">\n                    <label className=\"text-sm text-bambu-gray w-32 flex-shrink-0\">\n                      {param.name}\n                      {param.required && <span className=\"text-red-400 ml-1\">*</span>}\n                    </label>\n                    {param.schema?.enum ? (\n                      <select\n                        value={params[param.name] || ''}\n                        onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}\n                        className=\"flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                      >\n                        <option value=\"\">-- Select --</option>\n                        {param.schema.enum.map(opt => (\n                          <option key={opt} value={opt}>{opt}</option>\n                        ))}\n                      </select>\n                    ) : (\n                      <input\n                        type=\"text\"\n                        value={params[param.name] || ''}\n                        onChange={(e) => setParams(p => ({ ...p, [param.name]: e.target.value }))}\n                        placeholder={param.description || param.schema?.type || 'value'}\n                        className=\"flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none\"\n                      />\n                    )}\n                  </div>\n                ))}\n              </div>\n            </div>\n          )}\n\n          {/* Request Body */}\n          {hasBody && (\n            <div className=\"space-y-2\">\n              <h4 className=\"text-sm font-medium text-white\">Request Body</h4>\n              <textarea\n                value={bodyText}\n                onChange={(e) => setBodyText(e.target.value)}\n                rows={8}\n                className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono focus:border-bambu-green focus:outline-none resize-y\"\n                placeholder=\"JSON request body...\"\n              />\n            </div>\n          )}\n\n          {/* Execute Button */}\n          <div className=\"flex items-center gap-2\">\n            <Button onClick={executeRequest} disabled={loading}>\n              {loading ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Play className=\"w-4 h-4\" />\n              )}\n              Execute\n            </Button>\n            {missingParams.length > 0 && (\n              <span className=\"text-xs text-yellow-400 flex items-center gap-1\">\n                <AlertCircle className=\"w-3 h-3\" />\n                Fill in: {missingParams.join(', ')}\n              </span>\n            )}\n          </div>\n\n          {/* Response */}\n          {response && (\n            <div className=\"space-y-2\">\n              <div className=\"flex items-center justify-between\">\n                <h4 className=\"text-sm font-medium text-white flex items-center gap-2\">\n                  Response\n                  <span className={`px-2 py-0.5 text-xs rounded ${\n                    response.status >= 200 && response.status < 300\n                      ? 'bg-green-500/20 text-green-400'\n                      : response.status >= 400\n                        ? 'bg-red-500/20 text-red-400'\n                        : 'bg-yellow-500/20 text-yellow-400'\n                  }`}>\n                    {response.status} {response.statusText}\n                  </span>\n                  <span className=\"text-xs text-bambu-gray\">{response.duration}ms</span>\n                </h4>\n                <Button variant=\"secondary\" size=\"sm\" onClick={copyResponse}>\n                  {copied ? (\n                    <CheckCircle className=\"w-3 h-3 text-green-400\" />\n                  ) : (\n                    <Copy className=\"w-3 h-3\" />\n                  )}\n                </Button>\n              </div>\n              <pre className=\"p-3 bg-bambu-dark rounded-lg text-sm font-mono text-white overflow-auto max-h-96 border border-bambu-dark-tertiary\">\n                {typeof response.body === 'string'\n                  ? response.body\n                  : JSON.stringify(response.body, null, 2)}\n              </pre>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\ninterface APIBrowserProps {\n  apiKey?: string;\n}\n\nexport function APIBrowser({ apiKey = '' }: APIBrowserProps) {\n  const [schema, setSchema] = useState<OpenAPISchema | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n  const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());\n  const [searchQuery, setSearchQuery] = useState('');\n\n  useEffect(() => {\n    async function fetchSchema() {\n      try {\n        const res = await fetch('/openapi.json');\n        if (!res.ok) throw new Error('Failed to fetch OpenAPI schema');\n        const data = await res.json();\n        setSchema(data);\n      } catch (err) {\n        setError(err instanceof Error ? err.message : 'Unknown error');\n      } finally {\n        setLoading(false);\n      }\n    }\n    fetchSchema();\n  }, []);\n\n  if (loading) {\n    return (\n      <div className=\"flex justify-center py-12\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  if (error || !schema) {\n    return (\n      <Card>\n        <CardContent className=\"py-8\">\n          <div className=\"text-center text-red-400\">\n            <AlertCircle className=\"w-12 h-12 mx-auto mb-3 opacity-50\" />\n            <p>Failed to load API schema</p>\n            <p className=\"text-sm text-bambu-gray mt-1\">{error}</p>\n          </div>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  // Group endpoints by tag\n  const endpointsByTag: Record<string, Array<{ path: string; method: string; spec: EndpointSpec }>> = {};\n\n  for (const [path, methods] of Object.entries(schema.paths)) {\n    for (const [method, spec] of Object.entries(methods)) {\n      if (method === 'parameters') continue; // Skip path-level parameters\n\n      const tags = spec.tags || ['Other'];\n      for (const tag of tags) {\n        if (!endpointsByTag[tag]) {\n          endpointsByTag[tag] = [];\n        }\n        endpointsByTag[tag].push({ path, method, spec });\n      }\n    }\n  }\n\n  // Filter endpoints based on search\n  const filteredTags = Object.entries(endpointsByTag)\n    .map(([tag, endpoints]) => {\n      if (!searchQuery) return { tag, endpoints };\n\n      const filtered = endpoints.filter(({ path, method, spec }) => {\n        const searchLower = searchQuery.toLowerCase();\n        return (\n          path.toLowerCase().includes(searchLower) ||\n          method.toLowerCase().includes(searchLower) ||\n          (spec.summary?.toLowerCase() || '').includes(searchLower) ||\n          (spec.description?.toLowerCase() || '').includes(searchLower)\n        );\n      });\n\n      return { tag, endpoints: filtered };\n    })\n    .filter(({ endpoints }) => endpoints.length > 0)\n    .sort((a, b) => a.tag.localeCompare(b.tag));\n\n  const toggleTag = (tag: string) => {\n    setExpandedTags(prev => {\n      const next = new Set(prev);\n      if (next.has(tag)) {\n        next.delete(tag);\n      } else {\n        next.add(tag);\n      }\n      return next;\n    });\n  };\n\n  const expandAll = () => {\n    setExpandedTags(new Set(filteredTags.map(t => t.tag)));\n  };\n\n  const collapseAll = () => {\n    setExpandedTags(new Set());\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between gap-4\">\n        <div className=\"flex-1\">\n          <input\n            type=\"text\"\n            value={searchQuery}\n            onChange={(e) => setSearchQuery(e.target.value)}\n            placeholder=\"Search endpoints...\"\n            className=\"w-full max-w-md px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n          />\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Button variant=\"secondary\" size=\"sm\" onClick={expandAll}>\n            Expand All\n          </Button>\n          <Button variant=\"secondary\" size=\"sm\" onClick={collapseAll}>\n            Collapse All\n          </Button>\n          <a\n            href=\"/docs\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"flex items-center gap-1 text-sm text-bambu-green hover:underline\"\n          >\n            <ExternalLink className=\"w-4 h-4\" />\n            Swagger UI\n          </a>\n        </div>\n      </div>\n\n      {/* Endpoint count */}\n      <p className=\"text-sm text-bambu-gray\">\n        {filteredTags.reduce((acc, t) => acc + t.endpoints.length, 0)} endpoints in {filteredTags.length} categories\n      </p>\n\n      {/* Endpoints by Tag */}\n      <div className=\"space-y-3\">\n        {filteredTags.map(({ tag, endpoints }) => (\n          <Card key={tag}>\n            <button\n              onClick={() => toggleTag(tag)}\n              className=\"w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/30 transition-colors text-left\"\n            >\n              <div className=\"flex items-center gap-2\">\n                {expandedTags.has(tag) ? (\n                  <ChevronDown className=\"w-5 h-5 text-bambu-gray\" />\n                ) : (\n                  <ChevronRight className=\"w-5 h-5 text-bambu-gray\" />\n                )}\n                <h3 className=\"text-base font-semibold text-white capitalize\">{tag.replace(/-/g, ' ')}</h3>\n                <span className=\"text-xs bg-bambu-dark-tertiary px-2 py-0.5 rounded-full text-bambu-gray\">\n                  {endpoints.length}\n                </span>\n              </div>\n            </button>\n\n            {expandedTags.has(tag) && (\n              <CardContent className=\"pt-0 space-y-2\">\n                {endpoints.map(({ path, method, spec }) => (\n                  <EndpointItem\n                    key={`${method}-${path}`}\n                    path={path}\n                    method={method}\n                    spec={spec}\n                    schema={schema}\n                    apiKey={apiKey}\n                  />\n                ))}\n              </CardContent>\n            )}\n          </Card>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/AddExternalLinkModal.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';\nimport { Button } from './Button';\nimport { IconPicker, getIconByName } from './IconPicker';\ninterface AddExternalLinkModalProps {\n  link?: ExternalLink | null;\n  onClose: () => void;\n}\n\nexport function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const isEditing = !!link;\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  const [name, setName] = useState(link?.name || '');\n  const [url, setUrl] = useState(link?.url || '');\n  const [icon, setIcon] = useState(link?.icon || 'link');\n  const [openInNewTab, setOpenInNewTab] = useState(link?.open_in_new_tab || false);\n  const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);\n  const [customIconPreview, setCustomIconPreview] = useState<string | null>(\n    link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null\n  );\n  const [pendingIconFile, setPendingIconFile] = useState<File | null>(null);\n  const [error, setError] = useState<string | null>(null);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  // Create mutation\n  const createMutation = useMutation({\n    mutationFn: async (data: ExternalLinkCreate) => {\n      const created = await api.createExternalLink(data);\n      // If there's a pending icon file, upload it\n      if (pendingIconFile) {\n        return await api.uploadExternalLinkIcon(created.id, pendingIconFile);\n      }\n      return created;\n    },\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['external-links'] });\n      onClose();\n    },\n    onError: (err: Error) => {\n      setError(err.message);\n    },\n  });\n\n  // Update mutation\n  const updateMutation = useMutation({\n    mutationFn: async (data: ExternalLinkUpdate) => {\n      let updated = await api.updateExternalLink(link!.id, data);\n      // Handle icon changes\n      if (pendingIconFile) {\n        // Upload new icon\n        updated = await api.uploadExternalLinkIcon(link!.id, pendingIconFile);\n      } else if (!useCustomIcon && link?.custom_icon) {\n        // Remove custom icon if switching to preset\n        updated = await api.deleteExternalLinkIcon(link!.id);\n      }\n      return updated;\n    },\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['external-links'] });\n      onClose();\n    },\n    onError: (err: Error) => {\n      setError(err.message);\n    },\n  });\n\n  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (file) {\n      // Validate file type\n      const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml', 'image/webp', 'image/x-icon'];\n      if (!validTypes.includes(file.type)) {\n        setError('Please select a valid image file (PNG, JPG, GIF, SVG, WebP, or ICO)');\n        return;\n      }\n\n      // Validate file size (max 1MB)\n      if (file.size > 1024 * 1024) {\n        setError('Image file must be less than 1MB');\n        return;\n      }\n\n      setPendingIconFile(file);\n      setUseCustomIcon(true);\n\n      // Create preview\n      const reader = new FileReader();\n      reader.onload = (e) => {\n        setCustomIconPreview(e.target?.result as string);\n      };\n      reader.readAsDataURL(file);\n    }\n  };\n\n  const handleRemoveCustomIcon = () => {\n    setPendingIconFile(null);\n    setCustomIconPreview(null);\n    setUseCustomIcon(false);\n    if (fileInputRef.current) {\n      fileInputRef.current.value = '';\n    }\n  };\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    setError(null);\n\n    if (!name.trim()) {\n      setError('Name is required');\n      return;\n    }\n\n    if (!url.trim()) {\n      setError('URL is required');\n      return;\n    }\n\n    // Validate URL\n    if (!url.startsWith('http://') && !url.startsWith('https://')) {\n      setError('URL must start with http:// or https://');\n      return;\n    }\n\n    const data = {\n      name: name.trim(),\n      url: url.trim(),\n      icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback\n      open_in_new_tab: openInNewTab,\n    };\n\n    if (isEditing) {\n      updateMutation.mutate(data);\n    } else {\n      createMutation.mutate(data);\n    }\n  };\n\n  const isPending = createMutation.isPending || updateMutation.isPending;\n  const PresetIcon = getIconByName(icon);\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n      onClick={onClose}\n    >\n      <div\n        className=\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"p-2 rounded-full bg-bambu-green/20 text-bambu-green\">\n              {useCustomIcon && customIconPreview ? (\n                <img src={customIconPreview} alt=\"\" className=\"w-5 h-5 rounded\" />\n              ) : (\n                <PresetIcon className=\"w-5 h-5\" />\n              )}\n            </div>\n            <h2 className=\"text-lg font-semibold text-white\">\n              {isEditing ? 'Edit Link' : 'Add External Link'}\n            </h2>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"text-bambu-gray hover:text-white transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Form */}\n        <form onSubmit={handleSubmit} className=\"p-6 space-y-4\">\n          {error && (\n            <div className=\"p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\">\n              {error}\n            </div>\n          )}\n\n          {/* Name */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">Name *</label>\n            <input\n              type=\"text\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder=\"My Link\"\n              maxLength={50}\n              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\"\n            />\n          </div>\n\n          {/* URL */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">URL *</label>\n            <input\n              type=\"text\"\n              value={url}\n              onChange={(e) => setUrl(e.target.value)}\n              placeholder=\"https://example.com\"\n              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\"\n            />\n          </div>\n\n          {/* Open in New Tab */}\n          <div className=\"flex items-center justify-between\">\n            <label className=\"text-sm text-bambu-gray\">{t('externalLinks.openInNewTab')}</label>\n            <button\n              type=\"button\"\n              onClick={() => setOpenInNewTab(!openInNewTab)}\n              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${\n                openInNewTab ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n              }`}\n            >\n              <span\n                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${\n                  openInNewTab ? 'translate-x-6' : 'translate-x-1'\n                }`}\n              />\n            </button>\n          </div>\n\n          {/* Icon Section */}\n          <div className=\"space-y-3\">\n            <label className=\"block text-sm text-bambu-gray\">Icon</label>\n\n            {/* Custom Icon Upload */}\n            <div className=\"p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary\">\n              <div className=\"flex items-center justify-between mb-2\">\n                <span className=\"text-sm text-white\">Custom Icon</span>\n                <input\n                  ref={fileInputRef}\n                  type=\"file\"\n                  accept=\"image/png,image/jpeg,image/gif,image/svg+xml,image/webp,image/x-icon\"\n                  className=\"hidden\"\n                  onChange={handleFileSelect}\n                />\n                {useCustomIcon && customIconPreview ? (\n                  <div className=\"flex items-center gap-2\">\n                    <img src={customIconPreview} alt=\"Custom icon\" className=\"w-8 h-8 rounded border border-bambu-dark-tertiary\" />\n                    <button\n                      type=\"button\"\n                      onClick={handleRemoveCustomIcon}\n                      className=\"p-1 text-red-400 hover:text-red-300 transition-colors\"\n                      title=\"Remove custom icon\"\n                    >\n                      <Trash2 className=\"w-4 h-4\" />\n                    </button>\n                  </div>\n                ) : (\n                  <Button\n                    type=\"button\"\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    onClick={() => fileInputRef.current?.click()}\n                  >\n                    <Upload className=\"w-4 h-4\" />\n                    Upload\n                  </Button>\n                )}\n              </div>\n              <p className=\"text-xs text-bambu-gray\">\n                PNG, JPG, GIF, SVG, WebP, or ICO. Max 1MB.\n              </p>\n            </div>\n\n            {/* Preset Icon Picker */}\n            {!useCustomIcon && (\n              <div>\n                <span className=\"text-sm text-bambu-gray block mb-2\">Or choose a preset icon</span>\n                <IconPicker value={icon} onChange={setIcon} />\n              </div>\n            )}\n          </div>\n\n          {/* Actions */}\n          <div className=\"flex gap-3 pt-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={onClose}\n              className=\"flex-1\"\n            >\n              Cancel\n            </Button>\n            <Button\n              type=\"submit\"\n              disabled={isPending}\n              className=\"flex-1\"\n            >\n              {isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Save className=\"w-4 h-4\" />\n              )}\n              {isEditing ? 'Save' : 'Add'}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/AddNotificationModal.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';\nimport { Button } from './Button';\nimport { Toggle } from './Toggle';\n\ninterface AddNotificationModalProps {\n  provider?: NotificationProvider | null;\n  onClose: () => void;\n}\n\nconst PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'callmebot', 'webhook', 'homeassistant'];\n\nexport function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const isEditing = !!provider;\n\n  const [name, setName] = useState(provider?.name || '');\n  const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'email');\n  const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);\n  const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);\n  const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');\n  const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');\n\n  // Daily digest\n  const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false);\n  const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00');\n\n  // Event toggles\n  const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);\n  const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);\n  const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);\n  const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);\n  const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);\n  const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);\n  const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);\n  const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);\n  const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);\n  const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);\n  const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);\n\n  // Provider-specific config\n  const [config, setConfig] = useState<Record<string, string>>(\n    provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}\n  );\n\n  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);\n  const [error, setError] = useState<string | null>(null);\n\n  // Fetch printers for linking\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  // Test configuration mutation\n  const testMutation = useMutation({\n    mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }),\n    onSuccess: (result) => {\n      setTestResult(result);\n      setError(null);\n    },\n    onError: (err: Error) => {\n      setTestResult({ success: false, message: err.message });\n    },\n  });\n\n  // Create mutation\n  const createMutation = useMutation({\n    mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });\n      onClose();\n    },\n    onError: (err: Error) => {\n      setError(err.message);\n    },\n  });\n\n  // Update mutation\n  const updateMutation = useMutation({\n    mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });\n      onClose();\n    },\n    onError: (err: Error) => {\n      setError(err.message);\n    },\n  });\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    setError(null);\n\n    if (!name.trim()) {\n      setError(t('notifications.nameRequired'));\n      return;\n    }\n\n    // Validate provider-specific config\n    const requiredFields = getRequiredFields(providerType);\n    for (const field of requiredFields) {\n      if (!config[field.key]?.trim()) {\n        setError(t('notifications.fieldRequired', { field: field.label }));\n        return;\n      }\n    }\n\n    const data = {\n      name: name.trim(),\n      provider_type: providerType,\n      config,\n      printer_id: printerId,\n      quiet_hours_enabled: quietHoursEnabled,\n      quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,\n      quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,\n      // Daily digest\n      daily_digest_enabled: dailyDigestEnabled,\n      daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null,\n      // Event toggles\n      on_print_start: onPrintStart,\n      on_print_complete: onPrintComplete,\n      on_print_failed: onPrintFailed,\n      on_print_stopped: onPrintStopped,\n      on_print_progress: onPrintProgress,\n      on_printer_offline: onPrinterOffline,\n      on_printer_error: onPrinterError,\n      on_filament_low: onFilamentLow,\n      on_maintenance_due: onMaintenanceDue,\n      on_bed_cooled: onBedCooled,\n      on_first_layer_complete: onFirstLayerComplete,\n    };\n\n    if (isEditing) {\n      updateMutation.mutate(data);\n    } else {\n      createMutation.mutate(data);\n    }\n  };\n\n  const isPending = createMutation.isPending || updateMutation.isPending;\n\n  // Get config fields for each provider type\n  const getConfigFields = (type: ProviderType) => {\n    switch (type) {\n      case 'callmebot':\n        return [\n          { key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },\n          { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true },\n        ];\n      case 'ntfy':\n        return [\n          { key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false },\n          { key: 'topic', label: 'Topic', placeholder: 'my-bambuddy', type: 'text', required: true },\n          { key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },\n        ];\n      case 'pushover':\n        return [\n          { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },\n          { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },\n          { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },\n        ];\n      case 'telegram':\n        return [\n          { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },\n          { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },\n        ];\n      case 'email':\n        return [\n          { key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true },\n          { key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false },\n          { key: 'security', label: 'Security', type: 'select', required: false, options: [\n            { value: 'starttls', label: 'STARTTLS (Port 587)' },\n            { value: 'ssl', label: 'SSL/TLS (Port 465)' },\n            { value: 'none', label: 'None (Port 25)' },\n          ]},\n          { key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [\n            { value: 'true', label: 'Enabled' },\n            { value: 'false', label: 'Disabled' },\n          ]},\n          { key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false },\n          { key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false },\n          { key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },\n          { key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },\n        ];\n      case 'discord':\n        return [\n          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true },\n        ];\n      case 'webhook':\n        return [\n          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },\n          { key: 'payload_format', label: 'Payload Format', type: 'select', required: false, options: [\n            { value: 'generic', label: 'Generic JSON' },\n            { value: 'slack', label: 'Slack / Mattermost' },\n          ]},\n          { key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },\n          { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },\n          { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },\n        ];\n      case 'homeassistant':\n        return [\n          { key: 'service', label: 'Home Assistant Service', placeholder: 'notify.mobile_app_myphone', type: 'text', required: false },\n        ];\n      default:\n        return [];\n    }\n  };\n\n  const getRequiredFields = (type: ProviderType) => {\n    return getConfigFields(type).filter(f => f.required);\n  };\n\n  const configFields = getConfigFields(providerType);\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto\"\n      onClick={onClose}\n    >\n      <div\n        className=\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8 max-h-[90vh] overflow-y-auto\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white\">\n            {isEditing ? t('notifications.editTitle') : t('notifications.addTitle')}\n          </h2>\n          <button\n            onClick={onClose}\n            className=\"text-bambu-gray hover:text-white transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Form */}\n        <form onSubmit={handleSubmit} className=\"p-6 space-y-4\">\n          {error && (\n            <div className=\"p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\">\n              {error}\n            </div>\n          )}\n\n          {/* Name */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('notifications.nameLabel')}</label>\n            <input\n              type=\"text\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder={t('notifications.namePlaceholder')}\n              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\"\n            />\n          </div>\n\n          {/* Provider Type */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('notifications.providerTypeLabel')}</label>\n            <select\n              value={providerType}\n              onChange={(e) => {\n                setProviderType(e.target.value as ProviderType);\n                setConfig({}); // Reset config when changing type\n                setTestResult(null);\n              }}\n              disabled={isEditing}\n              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 disabled:opacity-50\"\n            >\n              {PROVIDER_VALUES.map((value) => (\n                <option key={value} value={value}>\n                  {t(`notifications.providerTypes.${value}`, value)}\n                </option>\n              ))}\n            </select>\n            <p className=\"text-xs text-bambu-gray mt-1\">\n              {t(`notifications.providerDescriptions.${providerType}`, '')}\n            </p>\n          </div>\n\n          {/* Provider-specific configuration */}\n          <div className=\"space-y-3\">\n            <p className=\"text-sm text-bambu-gray\">{t('notifications.configuration')}</p>\n            {configFields\n              .filter((field) => !('showIf' in field) || (field as { showIf?: (cfg: Record<string, string>) => boolean }).showIf?.(config) !== false)\n              .map((field) => (\n              <div key={field.key}>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {field.label} {field.required && '*'}\n                </label>\n                {field.type === 'select' && 'options' in field && field.options ? (\n                  <select\n                    value={config[field.key] || field.options[0]?.value || ''}\n                    onChange={(e) => {\n                      setConfig({ ...config, [field.key]: e.target.value });\n                      setTestResult(null);\n                    }}\n                    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\"\n                  >\n                    {field.options.map((opt) => (\n                      <option key={opt.value} value={opt.value}>\n                        {opt.label}\n                      </option>\n                    ))}\n                  </select>\n                ) : (\n                  <input\n                    type={field.type}\n                    value={config[field.key] || ''}\n                    onChange={(e) => {\n                      setConfig({ ...config, [field.key]: e.target.value });\n                      setTestResult(null);\n                    }}\n                    placeholder={field.placeholder}\n                    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\"\n                  />\n                )}\n              </div>\n            ))}\n          </div>\n\n          {/* Test Button */}\n          <div className=\"flex gap-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={() => {\n                setTestResult(null);\n                testMutation.mutate();\n              }}\n              disabled={testMutation.isPending || (getRequiredFields(providerType).length > 0 && !config[getRequiredFields(providerType)[0]?.key])}\n              className=\"flex-1\"\n            >\n              {testMutation.isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Send className=\"w-4 h-4\" />\n              )}\n              {t('notifications.testConfiguration')}\n            </Button>\n          </div>\n\n          {/* Test Result */}\n          {testResult && (\n            <div className={`p-3 rounded-lg flex items-center gap-2 ${\n              testResult.success\n                ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'\n                : 'bg-red-500/20 border border-red-500/50 text-red-400'\n            }`}>\n              {testResult.success ? (\n                <>\n                  <CheckCircle className=\"w-5 h-5\" />\n                  <span>{testResult.message}</span>\n                </>\n              ) : (\n                <>\n                  <XCircle className=\"w-5 h-5\" />\n                  <span>{testResult.message}</span>\n                </>\n              )}\n            </div>\n          )}\n\n          {/* Link to Printer */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('notifications.printerFilter')}</label>\n            <select\n              value={printerId ?? ''}\n              onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}\n              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\"\n            >\n              <option value=\"\">{t('notifications.allPrinters')}</option>\n              {printers?.map((p) => (\n                <option key={p.id} value={p.id}>\n                  {p.name}\n                </option>\n              ))}\n            </select>\n            <p className=\"text-xs text-bambu-gray mt-1\">\n              {t('notifications.onlyFromPrinter')}\n            </p>\n          </div>\n\n          {/* Quiet Hours */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <label className=\"text-sm text-white\">{t('notifications.quietHoursDnd')}</label>\n              <Toggle\n                checked={quietHoursEnabled}\n                onChange={setQuietHoursEnabled}\n              />\n            </div>\n            {quietHoursEnabled && (\n              <div className=\"grid grid-cols-2 gap-3\">\n                <div>\n                  <label className=\"block text-xs text-bambu-gray mb-1\">{t('notifications.quietStart')}</label>\n                  <input\n                    type=\"time\"\n                    value={quietHoursStart}\n                    onChange={(e) => setQuietHoursStart(e.target.value)}\n                    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\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-xs text-bambu-gray mb-1\">{t('notifications.quietEnd')}</label>\n                  <input\n                    type=\"time\"\n                    value={quietHoursEnd}\n                    onChange={(e) => setQuietHoursEnd(e.target.value)}\n                    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\"\n                  />\n                </div>\n              </div>\n            )}\n          </div>\n\n          {/* Daily Digest */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <div>\n                <label className=\"text-sm text-white\">{t('notifications.dailyDigestLabel')}</label>\n                <p className=\"text-xs text-bambu-gray\">{t('notifications.batchNotifications')}</p>\n              </div>\n              <Toggle\n                checked={dailyDigestEnabled}\n                onChange={setDailyDigestEnabled}\n              />\n            </div>\n            {dailyDigestEnabled && (\n              <div>\n                <label className=\"block text-xs text-bambu-gray mb-1\">{t('notifications.sendDigestAt')}</label>\n                <input\n                  type=\"time\"\n                  value={dailyDigestTime}\n                  onChange={(e) => setDailyDigestTime(e.target.value)}\n                  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\"\n                />\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('notifications.digestCollected')}\n                </p>\n              </div>\n            )}\n          </div>\n\n          {/* Event Toggles */}\n          <div className=\"space-y-3\">\n            <p className=\"text-sm text-bambu-gray\">{t('notifications.notificationEvents')}</p>\n\n            {/* Print Events */}\n            <div className=\"space-y-2 p-3 bg-bambu-dark rounded-lg\">\n              <p className=\"text-xs text-bambu-gray uppercase tracking-wide mb-2\">{t('notifications.printEvents')}</p>\n              <div className=\"grid grid-cols-2 gap-2\">\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-sm text-white\">{t('notifications.start')}</span>\n                  <Toggle checked={onPrintStart} onChange={setOnPrintStart} />\n                </div>\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-sm text-white\">{t('notifications.complete')}</span>\n                  <Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />\n                </div>\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-sm text-white\">{t('notifications.failed')}</span>\n                  <Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />\n                </div>\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-sm text-white\">{t('notifications.stopped')}</span>\n                  <Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />\n                </div>\n                <div className=\"flex items-center justify-between col-span-2\">\n                  <div>\n                    <span className=\"text-sm text-white\">{t('notifications.progress')}</span>\n                    <span className=\"text-xs text-bambu-gray ml-1\">{t('notifications.progressPercent')}</span>\n                  </div>\n                  <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />\n                </div>\n                <div className=\"flex items-center justify-between col-span-2\">\n                  <div>\n                    <span className=\"text-sm text-white\">{t('notifications.bedCooled')}</span>\n                    <span className=\"text-xs text-bambu-gray ml-1\">{t('notifications.bedCooledAfterPrint')}</span>\n                  </div>\n                  <Toggle checked={onBedCooled} onChange={setOnBedCooled} />\n                </div>\n                <div className=\"flex items-center justify-between col-span-2\">\n                  <div>\n                    <span className=\"text-sm text-white\">{t('notifications.firstLayerCompleteLabel')}</span>\n                    <span className=\"text-xs text-bambu-gray ml-1\">{t('notifications.firstLayerCompleteDescription')}</span>\n                  </div>\n                  <Toggle checked={onFirstLayerComplete} onChange={setOnFirstLayerComplete} />\n                </div>\n              </div>\n            </div>\n\n            {/* Printer Status Events */}\n            <div className=\"space-y-2 p-3 bg-bambu-dark rounded-lg\">\n              <p className=\"text-xs text-bambu-gray uppercase tracking-wide mb-2\">{t('notifications.printerStatus')}</p>\n              <div className=\"grid grid-cols-2 gap-2\">\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-sm text-white\">{t('notifications.offline')}</span>\n                  <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />\n                </div>\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-sm text-white\">{t('notifications.error')}</span>\n                  <Toggle checked={onPrinterError} onChange={setOnPrinterError} />\n                </div>\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-sm text-white\">{t('notifications.lowFilament')}</span>\n                  <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />\n                </div>\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-sm text-white\">{t('notifications.maintenance')}</span>\n                  <Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />\n                </div>\n              </div>\n            </div>\n          </div>\n\n          {/* Actions */}\n          <div className=\"flex gap-3 pt-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={onClose}\n              className=\"flex-1\"\n            >\n              {t('notifications.cancel')}\n            </Button>\n            <Button\n              type=\"submit\"\n              disabled={isPending}\n              className=\"flex-1\"\n            >\n              {isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Save className=\"w-4 h-4\" />\n              )}\n              {isEditing ? t('notifications.save') : t('notifications.add')}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/AddSmartPlugModal.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';\nimport { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye, Globe } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';\nimport { Button } from './Button';\n\ninterface AddSmartPlugModalProps {\n  plug?: SmartPlug | null;\n  onClose: () => void;\n}\n\nexport function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const isEditing = !!plug;\n\n  // Plug type selection\n  const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt' | 'rest'>(plug?.plug_type || 'tasmota');\n\n  const [name, setName] = useState(plug?.name || '');\n  // Tasmota fields\n  const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');\n  const [username, setUsername] = useState(plug?.username || '');\n  const [password, setPassword] = useState(plug?.password || '');\n  // Home Assistant fields\n  const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');\n  // MQTT fields - Power\n  const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || '');\n  const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || '');\n  const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState<string>(\n    (plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString()\n  );\n  // MQTT fields - Energy\n  const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || '');\n  const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || '');\n  const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState<string>(\n    (plug?.mqtt_energy_multiplier ?? 1).toString()\n  );\n  // MQTT fields - State\n  const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');\n  const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');\n  const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');\n  // REST fields\n  const [restOnUrl, setRestOnUrl] = useState(plug?.rest_on_url || '');\n  const [restOnBody, setRestOnBody] = useState(plug?.rest_on_body || '');\n  const [restOffUrl, setRestOffUrl] = useState(plug?.rest_off_url || '');\n  const [restOffBody, setRestOffBody] = useState(plug?.rest_off_body || '');\n  const [restMethod, setRestMethod] = useState(plug?.rest_method || 'POST');\n  const [restHeaders, setRestHeaders] = useState(plug?.rest_headers || '');\n  const [restStatusUrl, setRestStatusUrl] = useState(plug?.rest_status_url || '');\n  const [restStatusPath, setRestStatusPath] = useState(plug?.rest_status_path || '');\n  const [restStatusOnValue, setRestStatusOnValue] = useState(plug?.rest_status_on_value || '');\n  const [restPowerUrl, setRestPowerUrl] = useState(plug?.rest_power_url || '');\n  const [restPowerPath, setRestPowerPath] = useState(plug?.rest_power_path || '');\n  const [restPowerMultiplier, setRestPowerMultiplier] = useState<string>((plug?.rest_power_multiplier ?? 1).toString());\n  const [restEnergyUrl, setRestEnergyUrl] = useState(plug?.rest_energy_url || '');\n  const [restEnergyPath, setRestEnergyPath] = useState(plug?.rest_energy_path || '');\n  const [restEnergyMultiplier, setRestEnergyMultiplier] = useState<string>((plug?.rest_energy_multiplier ?? 1).toString());\n  // HA energy sensor entities (optional)\n  const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');\n  const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');\n  const [haEnergyTotalEntity, setHaEnergyTotalEntity] = useState(plug?.ha_energy_total_entity || '');\n  // HA entity search\n  const [haEntitySearch, setHaEntitySearch] = useState('');\n  const [debouncedSearch, setDebouncedSearch] = useState('');\n  const [isEntityDropdownOpen, setIsEntityDropdownOpen] = useState(false);\n  const entityDropdownRef = useRef<HTMLDivElement>(null);\n\n  // Energy sensor search states\n  const [powerSensorSearch, setPowerSensorSearch] = useState('');\n  const [isPowerDropdownOpen, setIsPowerDropdownOpen] = useState(false);\n  const powerDropdownRef = useRef<HTMLDivElement>(null);\n\n  const [energyTodaySearch, setEnergyTodaySearch] = useState('');\n  const [isEnergyTodayDropdownOpen, setIsEnergyTodayDropdownOpen] = useState(false);\n  const energyTodayDropdownRef = useRef<HTMLDivElement>(null);\n\n  const [energyTotalSearch, setEnergyTotalSearch] = useState('');\n  const [isEnergyTotalDropdownOpen, setIsEnergyTotalDropdownOpen] = useState(false);\n  const energyTotalDropdownRef = useRef<HTMLDivElement>(null);\n\n  const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);\n  const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);\n  const [error, setError] = useState<string | null>(null);\n\n  // Power alert settings\n  const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);\n  const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');\n  const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');\n\n  // Schedule settings\n  const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);\n  const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');\n  const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');\n\n  // Visibility options\n  const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);\n  const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);\n\n  // Discovery state\n  const [isScanning, setIsScanning] = useState(false);\n  const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });\n  const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredTasmotaDevice[]>([]);\n  const scanPollRef = useRef<NodeJS.Timeout | null>(null);\n\n  // Fetch printers for linking\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  // Fetch existing plugs to check for conflicts\n  const { data: existingPlugs } = useQuery({\n    queryKey: ['smart-plugs'],\n    queryFn: api.getSmartPlugs,\n  });\n\n  // Fetch settings to check if HA is configured\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  // Check if HA is properly configured\n  const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);\n\n  // Debounce search input\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      setDebouncedSearch(haEntitySearch);\n    }, 300);\n    return () => clearTimeout(timer);\n  }, [haEntitySearch]);\n\n  // Close dropdowns when clicking outside\n  useEffect(() => {\n    const handleClickOutside = (e: MouseEvent) => {\n      if (entityDropdownRef.current && !entityDropdownRef.current.contains(e.target as Node)) {\n        setIsEntityDropdownOpen(false);\n      }\n      if (powerDropdownRef.current && !powerDropdownRef.current.contains(e.target as Node)) {\n        setIsPowerDropdownOpen(false);\n      }\n      if (energyTodayDropdownRef.current && !energyTodayDropdownRef.current.contains(e.target as Node)) {\n        setIsEnergyTodayDropdownOpen(false);\n      }\n      if (energyTotalDropdownRef.current && !energyTotalDropdownRef.current.contains(e.target as Node)) {\n        setIsEnergyTotalDropdownOpen(false);\n      }\n    };\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, []);\n\n  // Fetch Home Assistant entities when in HA mode AND HA is configured\n  const { data: haEntities, isLoading: haEntitiesLoading, error: haEntitiesError } = useQuery({\n    queryKey: ['ha-entities', debouncedSearch],\n    queryFn: () => api.getHAEntities(debouncedSearch || undefined),\n    enabled: plugType === 'homeassistant' && haConfigured,\n    retry: false,\n    staleTime: 0,\n  });\n\n  // Fetch Home Assistant sensor entities for energy monitoring\n  const { data: haSensorEntities } = useQuery({\n    queryKey: ['ha-sensor-entities'],\n    queryFn: api.getHASensorEntities,\n    enabled: plugType === 'homeassistant' && haConfigured,\n    retry: false,\n    staleTime: 0,\n  });\n\n  // Close on Escape key and cleanup scan polling\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown);\n      if (scanPollRef.current) {\n        clearInterval(scanPollRef.current);\n      }\n    };\n  }, [onClose]);\n\n  // Start scanning for Tasmota devices (auto-detects network)\n  const startScan = async () => {\n    setIsScanning(true);\n    setDiscoveredDevices([]);\n    setScanProgress({ scanned: 0, total: 0 });\n    setError(null);\n\n    try {\n      await api.startTasmotaScan();\n\n      // Poll function to fetch status and devices\n      const pollStatus = async () => {\n        try {\n          const status = await api.getTasmotaScanStatus();\n          setScanProgress({ scanned: status.scanned, total: status.total });\n\n          const devices = await api.getDiscoveredTasmotaDevices();\n          setDiscoveredDevices(devices);\n\n          if (!status.running) {\n            setIsScanning(false);\n            if (scanPollRef.current) {\n              clearInterval(scanPollRef.current);\n              scanPollRef.current = null;\n            }\n          }\n        } catch (e) {\n          console.error('Polling error:', e);\n        }\n      };\n\n      // Poll immediately, then every 500ms\n      await pollStatus();\n      scanPollRef.current = setInterval(pollStatus, 500);\n    } catch (err) {\n      setIsScanning(false);\n      const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));\n      setError(errorMsg || t('smartPlugs.failedToStartScan'));\n    }\n  };\n\n  // Stop scanning\n  const stopScan = async () => {\n    try {\n      await api.stopTasmotaScan();\n    } catch {\n      // Ignore stop errors\n    }\n    setIsScanning(false);\n    if (scanPollRef.current) {\n      clearInterval(scanPollRef.current);\n      scanPollRef.current = null;\n    }\n  };\n\n  // Select a discovered device\n  const selectDevice = (device: DiscoveredTasmotaDevice) => {\n    setIpAddress(device.ip_address);\n    setName(device.name);\n    setTestResult(null);\n  };\n\n  // Test connection mutation\n  const testMutation = useMutation({\n    mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),\n    onSuccess: (result) => {\n      setTestResult(result);\n      setError(null);\n      // Auto-fill name from device if empty\n      if (!name && result.device_name) {\n        setName(result.device_name);\n      }\n    },\n    onError: (err: Error) => {\n      setTestResult(null);\n      setError(err.message);\n    },\n  });\n\n  // Create mutation\n  const createMutation = useMutation({\n    mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });\n      // Also invalidate printer card HA entity queries\n      queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });\n      onClose();\n    },\n    onError: (err: Error) => {\n      setError(err.message);\n    },\n  });\n\n  // Update mutation\n  const updateMutation = useMutation({\n    mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });\n      // Also invalidate printer card HA entity queries\n      queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });\n      onClose();\n    },\n    onError: (err: Error) => {\n      setError(err.message);\n    },\n  });\n\n  // For Tasmota plugs, only one per printer (physical device)\n  // For HA scripts, allow multiple per printer\n  const availablePrinters = printers?.filter(p => {\n    if (plugType === 'tasmota') {\n      const hasTasmotaPlug = existingPlugs?.some(\n        ep => ep.printer_id === p.id && ep.id !== plug?.id && ep.plug_type === 'tasmota'\n      );\n      return !hasTasmotaPlug;\n    }\n    // HA scripts can have multiple per printer\n    return true;\n  });\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    setError(null);\n\n    if (!name.trim()) {\n      setError(t('smartPlugs.nameRequired'));\n      return;\n    }\n\n    if (plugType === 'tasmota' && !ipAddress.trim()) {\n      setError('IP address is required for Tasmota plugs');\n      return;\n    }\n\n    if (plugType === 'homeassistant' && !haEntityId) {\n      setError(t('smartPlugs.entityRequired'));\n      return;\n    }\n\n    if (plugType === 'mqtt') {\n      // Check that at least one topic is configured (path is optional)\n      const hasPower = mqttPowerTopic.trim();\n      const hasEnergy = mqttEnergyTopic.trim();\n      const hasState = mqttStateTopic.trim();\n\n      if (!hasPower && !hasEnergy && !hasState) {\n        setError(t('smartPlugs.mqttTopicRequired'));\n        return;\n      }\n    }\n\n    if (plugType === 'rest') {\n      if (!restOnUrl.trim() && !restOffUrl.trim()) {\n        setError(t('smartPlugs.restUrlRequired'));\n        return;\n      }\n    }\n\n    const data = {\n      name: name.trim(),\n      plug_type: plugType,\n      ip_address: plugType === 'tasmota' ? ipAddress.trim() : null,\n      ha_entity_id: plugType === 'homeassistant' ? haEntityId : null,\n      // HA energy sensor entities (optional)\n      ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,\n      ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,\n      ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,\n      // MQTT power fields\n      mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null,\n      mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,\n      mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1,\n      // MQTT energy fields\n      mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null,\n      mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,\n      mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1,\n      // MQTT state fields\n      mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,\n      mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,\n      mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,\n      // REST fields\n      rest_on_url: plugType === 'rest' ? (restOnUrl.trim() || null) : null,\n      rest_on_body: plugType === 'rest' ? (restOnBody.trim() || null) : null,\n      rest_off_url: plugType === 'rest' ? (restOffUrl.trim() || null) : null,\n      rest_off_body: plugType === 'rest' ? (restOffBody.trim() || null) : null,\n      rest_method: plugType === 'rest' ? restMethod : null,\n      rest_headers: plugType === 'rest' ? (restHeaders.trim() || null) : null,\n      rest_status_url: plugType === 'rest' ? (restStatusUrl.trim() || null) : null,\n      rest_status_path: plugType === 'rest' ? (restStatusPath.trim() || null) : null,\n      rest_status_on_value: plugType === 'rest' ? (restStatusOnValue.trim() || null) : null,\n      rest_power_url: plugType === 'rest' ? (restPowerUrl.trim() || null) : null,\n      rest_power_path: plugType === 'rest' ? (restPowerPath.trim() || null) : null,\n      rest_power_multiplier: plugType === 'rest' ? (parseFloat(restPowerMultiplier) || 1) : 1,\n      rest_energy_url: plugType === 'rest' ? (restEnergyUrl.trim() || null) : null,\n      rest_energy_path: plugType === 'rest' ? (restEnergyPath.trim() || null) : null,\n      rest_energy_multiplier: plugType === 'rest' ? (parseFloat(restEnergyMultiplier) || 1) : 1,\n      username: plugType === 'tasmota' ? (username.trim() || null) : null,\n      password: plugType === 'tasmota' ? (password.trim() || null) : null,\n      printer_id: printerId,\n      // Power alerts\n      power_alert_enabled: powerAlertEnabled,\n      power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,\n      power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,\n      // Schedule\n      schedule_enabled: scheduleEnabled,\n      schedule_on_time: scheduleOnTime || null,\n      schedule_off_time: scheduleOffTime || null,\n      // Visibility\n      show_in_switchbar: showInSwitchbar,\n      show_on_printer_card: showOnPrinterCard,\n    };\n\n    if (isEditing) {\n      updateMutation.mutate(data);\n    } else {\n      createMutation.mutate(data);\n    }\n  };\n\n  const isPending = createMutation.isPending || updateMutation.isPending;\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n      onClick={onClose}\n    >\n      <div\n        className=\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary flex-shrink-0\">\n          <h2 className=\"text-lg font-semibold text-white\">\n            {isEditing ? t('smartPlugs.editTitle') : t('smartPlugs.addTitle')}\n          </h2>\n          <button\n            onClick={onClose}\n            className=\"text-bambu-gray hover:text-white transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Form */}\n        <form onSubmit={handleSubmit} className=\"p-6 space-y-4 overflow-y-auto\">\n          {error && (\n            <div className=\"p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\">\n              {error}\n            </div>\n          )}\n\n          {/* Plug Type Selector - only show when not editing */}\n          {!isEditing && (\n            <div className=\"flex gap-2 mb-2\">\n              <button\n                type=\"button\"\n                onClick={() => {\n                  setPlugType('tasmota');\n                  setTestResult(null);\n                  setError(null);\n                }}\n                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${\n                  plugType === 'tasmota'\n                    ? 'bg-bambu-green text-white'\n                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'\n                }`}\n              >\n                <Plug className=\"w-4 h-4\" />\n                Tasmota\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => {\n                  setPlugType('homeassistant');\n                  setTestResult(null);\n                  setError(null);\n                }}\n                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${\n                  plugType === 'homeassistant'\n                    ? 'bg-bambu-green text-white'\n                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'\n                }`}\n              >\n                <Home className=\"w-4 h-4\" />\n                HA\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => {\n                  setPlugType('mqtt');\n                  setTestResult(null);\n                  setError(null);\n                }}\n                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${\n                  plugType === 'mqtt'\n                    ? 'bg-bambu-green text-white'\n                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'\n                }`}\n              >\n                <Radio className=\"w-4 h-4\" />\n                MQTT\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => {\n                  setPlugType('rest');\n                  setTestResult(null);\n                  setError(null);\n                }}\n                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${\n                  plugType === 'rest'\n                    ? 'bg-bambu-green text-white'\n                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'\n                }`}\n              >\n                <Globe className=\"w-4 h-4\" />\n                REST\n              </button>\n            </div>\n          )}\n\n          {/* Discovery Section - only show when not editing and Tasmota is selected */}\n          {!isEditing && plugType === 'tasmota' && (\n            <div className=\"space-y-3\">\n              {/* Scan button - auto-detects network */}\n              {isScanning ? (\n                <Button type=\"button\" variant=\"secondary\" onClick={stopScan} className=\"w-full\">\n                  <X className=\"w-4 h-4\" />\n                  {t('smartPlugs.stopScanning')}\n                </Button>\n              ) : (\n                <Button type=\"button\" variant=\"primary\" onClick={startScan} className=\"w-full\">\n                  <Search className=\"w-4 h-4\" />\n                  {t('smartPlugs.discoverTasmota')}\n                </Button>\n              )}\n\n              {/* Progress bar */}\n              {isScanning && scanProgress.total > 0 && (\n                <div className=\"space-y-1\">\n                  <div className=\"flex justify-between text-xs text-bambu-gray\">\n                    <span>{t('smartPlugs.addSmartPlug.scanningNetwork')}</span>\n                    <span>{scanProgress.scanned} / {scanProgress.total}</span>\n                  </div>\n                  <div className=\"w-full bg-bambu-dark-tertiary rounded-full h-2\">\n                    <div\n                      className=\"bg-bambu-green h-2 rounded-full transition-all duration-300\"\n                      style={{ width: `${(scanProgress.scanned / scanProgress.total) * 100}%` }}\n                    />\n                  </div>\n                </div>\n              )}\n\n              {/* Discovered devices */}\n              {discoveredDevices.length > 0 && (\n                <div className=\"space-y-2\">\n                  <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.foundDevices', { count: discoveredDevices.length })}</p>\n                  <div className=\"max-h-40 overflow-y-auto space-y-1\">\n                    {discoveredDevices.map((device) => (\n                      <button\n                        key={device.ip_address}\n                        type=\"button\"\n                        onClick={() => selectDevice(device)}\n                        className=\"w-full flex items-center justify-between p-2 bg-bambu-dark hover:bg-bambu-dark-tertiary rounded-lg transition-colors text-left border border-bambu-dark-tertiary\"\n                      >\n                        <div className=\"flex items-center gap-2\">\n                          <Plug className=\"w-4 h-4 text-bambu-green\" />\n                          <div>\n                            <p className=\"text-sm text-white\">{device.name}</p>\n                            <p className=\"text-xs text-bambu-gray\">{device.ip_address}</p>\n                          </div>\n                        </div>\n                        {device.state && (\n                          <span className={`flex items-center gap-1 text-xs ${\n                            device.state === 'ON' ? 'text-bambu-green' : 'text-bambu-gray'\n                          }`}>\n                            <Power className=\"w-3 h-3\" />\n                            {device.state}\n                          </span>\n                        )}\n                      </button>\n                    ))}\n                  </div>\n                </div>\n              )}\n\n              {!isScanning && discoveredDevices.length === 0 && scanProgress.total > 0 && (\n                <p className=\"text-xs text-bambu-gray text-center py-2\">\n                  {t('smartPlugs.noDevicesFound')}\n                </p>\n              )}\n            </div>\n          )}\n\n          {/* Home Assistant Entity Selector - only show when HA is selected */}\n          {plugType === 'homeassistant' && (\n            <div className=\"space-y-3\">\n              {/* HA not configured */}\n              {!haConfigured && (\n                <div className=\"space-y-3\">\n                  <div className=\"p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400\">\n                    {t('smartPlugs.haNotConfigured')}{' '}\n                    <span className=\"font-medium\">{t('smartPlugs.haSettingsPath')}</span>\n                  </div>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1 opacity-50\">{t('smartPlugs.selectEntity')}</label>\n                    <select\n                      disabled\n                      className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed opacity-50\"\n                    >\n                      <option>{t('smartPlugs.addSmartPlug.chooseEntity')}</option>\n                    </select>\n                  </div>\n                </div>\n              )}\n\n              {/* HA configured - show loading/entities */}\n              {haConfigured && (\n                <>\n                  {haEntitiesLoading && (\n                    <div className=\"flex items-center justify-center py-4 text-bambu-gray\">\n                      <Loader2 className=\"w-5 h-5 animate-spin mr-2\" />\n                      {t('smartPlugs.loadingEntities')}\n                    </div>\n                  )}\n\n                  {haEntitiesError && (\n                    <div className=\"p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\">\n                      {t('smartPlugs.failedToLoadEntities', { error: (haEntitiesError as Error).message })}\n                    </div>\n                  )}\n\n                  {/* Searchable Entity Dropdown */}\n                  {(() => {\n                    // Filter out entities already configured (except current plug when editing)\n                    const configuredEntityIds = existingPlugs\n                      ?.filter(p => p.ha_entity_id && p.id !== plug?.id)\n                      .map(p => p.ha_entity_id) || [];\n                    const availableEntities = (haEntities || []).filter(e => !configuredEntityIds.includes(e.entity_id));\n                    const selectedEntity = haEntities?.find(e => e.entity_id === haEntityId);\n\n                    return (\n                      <div ref={entityDropdownRef} className=\"relative\">\n                        <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.selectEntity')}</label>\n                        <div className=\"relative\">\n                          <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                          <input\n                            type=\"text\"\n                            value={isEntityDropdownOpen ? haEntitySearch : (selectedEntity ? `${selectedEntity.friendly_name} (${selectedEntity.entity_id})` : '')}\n                            onChange={(e) => {\n                              setHaEntitySearch(e.target.value);\n                              if (!isEntityDropdownOpen) setIsEntityDropdownOpen(true);\n                            }}\n                            onFocus={() => {\n                              setIsEntityDropdownOpen(true);\n                              setHaEntitySearch('');\n                            }}\n                            placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEntities')}\n                            className=\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                          />\n                          {haEntityId && !isEntityDropdownOpen && (\n                            <button\n                              type=\"button\"\n                              onClick={() => {\n                                setHaEntityId('');\n                                setHaEntitySearch('');\n                              }}\n                              className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded\"\n                            >\n                              <X className=\"w-4 h-4 text-bambu-gray hover:text-white\" />\n                            </button>\n                          )}\n                          {haEntitiesLoading && (\n                            <Loader2 className=\"absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray animate-spin\" />\n                          )}\n                        </div>\n\n                        {/* Dropdown */}\n                        {isEntityDropdownOpen && (\n                          <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-60 overflow-y-auto\">\n                            {haEntitiesLoading && (\n                              <div className=\"px-3 py-2 text-sm text-bambu-gray flex items-center gap-2\">\n                                <Loader2 className=\"w-4 h-4 animate-spin\" />\n                                {t('smartPlugs.loading')}\n                              </div>\n                            )}\n                            {!haEntitiesLoading && availableEntities.length === 0 && (\n                              <div className=\"px-3 py-2 text-sm text-bambu-gray\">\n                                {debouncedSearch\n                                  ? t('smartPlugs.noEntitiesMatching', { search: debouncedSearch })\n                                  : t('smartPlugs.noEntitiesAvailable')}\n                              </div>\n                            )}\n                            {!haEntitiesLoading && availableEntities.map((entity) => (\n                              <button\n                                key={entity.entity_id}\n                                type=\"button\"\n                                onClick={() => {\n                                  setHaEntityId(entity.entity_id);\n                                  setIsEntityDropdownOpen(false);\n                                  setHaEntitySearch('');\n                                  // Auto-fill name\n                                  if (!name) {\n                                    setName(entity.friendly_name);\n                                  }\n                                }}\n                                className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary transition-colors ${\n                                  entity.entity_id === haEntityId ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'\n                                }`}\n                              >\n                                <div className=\"font-medium\">{entity.friendly_name}</div>\n                                <div className=\"text-xs text-bambu-gray flex items-center justify-between\">\n                                  <span>{entity.entity_id}</span>\n                                  <span className={entity.state === 'on' ? 'text-bambu-green' : ''}>{entity.state}</span>\n                                </div>\n                              </button>\n                            ))}\n                          </div>\n                        )}\n\n                        <p className=\"text-xs text-bambu-gray mt-1\">\n                          {debouncedSearch\n                            ? t('smartPlugs.searchingEntities', { count: availableEntities.length })\n                            : t('smartPlugs.showingEntities', { count: availableEntities.length })}\n                        </p>\n                      </div>\n                    );\n                  })()}\n\n\n                  {/* Energy Monitoring Section (Optional) */}\n                  {haEntityId && haSensorEntities && haSensorEntities.length > 0 && (\n                    <div className=\"border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3\">\n                      <div>\n                        <p className=\"text-white font-medium mb-1\">{t('smartPlugs.energyMonitoringOptional')}</p>\n                        <p className=\"text-xs text-bambu-gray mb-3\">\n                          {t('smartPlugs.energyMonitoringHint')}\n                        </p>\n                      </div>\n\n                      {/* Power Sensor (W) */}\n                      {(() => {\n                        const powerSensors = haSensorEntities.filter(s =>\n                          s.unit_of_measurement === 'W' || s.unit_of_measurement === 'kW' || s.unit_of_measurement === 'mW'\n                        );\n                        const filteredPowerSensors = powerSensorSearch\n                          ? powerSensors.filter(s =>\n                              s.entity_id.toLowerCase().includes(powerSensorSearch.toLowerCase()) ||\n                              s.friendly_name.toLowerCase().includes(powerSensorSearch.toLowerCase())\n                            )\n                          : powerSensors;\n                        const selectedPowerSensor = haSensorEntities.find(s => s.entity_id === haPowerEntity);\n\n                        return (\n                          <div ref={powerDropdownRef} className=\"relative\">\n                            <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.powerSensorW')}</label>\n                            <div className=\"relative\">\n                              <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                              <input\n                                type=\"text\"\n                                value={isPowerDropdownOpen ? powerSensorSearch : (selectedPowerSensor ? `${selectedPowerSensor.friendly_name} (${selectedPowerSensor.state} ${selectedPowerSensor.unit_of_measurement})` : '')}\n                                onChange={(e) => {\n                                  setPowerSensorSearch(e.target.value);\n                                  if (!isPowerDropdownOpen) setIsPowerDropdownOpen(true);\n                                }}\n                                onFocus={() => {\n                                  setIsPowerDropdownOpen(true);\n                                  setPowerSensorSearch('');\n                                }}\n                                placeholder={t('smartPlugs.addSmartPlug.placeholders.searchPowerSensors')}\n                                className=\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                              />\n                              {haPowerEntity && !isPowerDropdownOpen && (\n                                <button\n                                  type=\"button\"\n                                  onClick={() => {\n                                    setHaPowerEntity('');\n                                    setPowerSensorSearch('');\n                                  }}\n                                  className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded\"\n                                >\n                                  <X className=\"w-4 h-4 text-bambu-gray hover:text-white\" />\n                                </button>\n                              )}\n                            </div>\n                            {isPowerDropdownOpen && (\n                              <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\">\n                                <button\n                                  type=\"button\"\n                                  onClick={() => {\n                                    setHaPowerEntity('');\n                                    setIsPowerDropdownOpen(false);\n                                    setPowerSensorSearch('');\n                                  }}\n                                  className=\"w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary\"\n                                >\n                                  {t('smartPlugs.none')}\n                                </button>\n                                {filteredPowerSensors.map((sensor) => (\n                                  <button\n                                    key={sensor.entity_id}\n                                    type=\"button\"\n                                    onClick={() => {\n                                      setHaPowerEntity(sensor.entity_id);\n                                      setIsPowerDropdownOpen(false);\n                                      setPowerSensorSearch('');\n                                    }}\n                                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${\n                                      sensor.entity_id === haPowerEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'\n                                    }`}\n                                  >\n                                    <div className=\"font-medium\">{sensor.friendly_name}</div>\n                                    <div className=\"text-xs text-bambu-gray\">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>\n                                  </button>\n                                ))}\n                                {filteredPowerSensors.length === 0 && (\n                                  <div className=\"px-3 py-2 text-sm text-bambu-gray\">{t('smartPlugs.noMatchingSensors')}</div>\n                                )}\n                              </div>\n                            )}\n                          </div>\n                        );\n                      })()}\n\n                      {/* Energy Today (kWh) */}\n                      {(() => {\n                        const energySensors = haSensorEntities.filter(s =>\n                          s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'\n                        );\n                        const filteredEnergySensors = energyTodaySearch\n                          ? energySensors.filter(s =>\n                              s.entity_id.toLowerCase().includes(energyTodaySearch.toLowerCase()) ||\n                              s.friendly_name.toLowerCase().includes(energyTodaySearch.toLowerCase())\n                            )\n                          : energySensors;\n                        const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTodayEntity);\n\n                        return (\n                          <div ref={energyTodayDropdownRef} className=\"relative\">\n                            <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.energyTodayKwh')}</label>\n                            <div className=\"relative\">\n                              <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                              <input\n                                type=\"text\"\n                                value={isEnergyTodayDropdownOpen ? energyTodaySearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}\n                                onChange={(e) => {\n                                  setEnergyTodaySearch(e.target.value);\n                                  if (!isEnergyTodayDropdownOpen) setIsEnergyTodayDropdownOpen(true);\n                                }}\n                                onFocus={() => {\n                                  setIsEnergyTodayDropdownOpen(true);\n                                  setEnergyTodaySearch('');\n                                }}\n                                placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}\n                                className=\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                              />\n                              {haEnergyTodayEntity && !isEnergyTodayDropdownOpen && (\n                                <button\n                                  type=\"button\"\n                                  onClick={() => {\n                                    setHaEnergyTodayEntity('');\n                                    setEnergyTodaySearch('');\n                                  }}\n                                  className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded\"\n                                >\n                                  <X className=\"w-4 h-4 text-bambu-gray hover:text-white\" />\n                                </button>\n                              )}\n                            </div>\n                            {isEnergyTodayDropdownOpen && (\n                              <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\">\n                                <button\n                                  type=\"button\"\n                                  onClick={() => {\n                                    setHaEnergyTodayEntity('');\n                                    setIsEnergyTodayDropdownOpen(false);\n                                    setEnergyTodaySearch('');\n                                  }}\n                                  className=\"w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary\"\n                                >\n                                  {t('smartPlugs.none')}\n                                </button>\n                                {filteredEnergySensors.map((sensor) => (\n                                  <button\n                                    key={sensor.entity_id}\n                                    type=\"button\"\n                                    onClick={() => {\n                                      setHaEnergyTodayEntity(sensor.entity_id);\n                                      setIsEnergyTodayDropdownOpen(false);\n                                      setEnergyTodaySearch('');\n                                    }}\n                                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${\n                                      sensor.entity_id === haEnergyTodayEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'\n                                    }`}\n                                  >\n                                    <div className=\"font-medium\">{sensor.friendly_name}</div>\n                                    <div className=\"text-xs text-bambu-gray\">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>\n                                  </button>\n                                ))}\n                                {filteredEnergySensors.length === 0 && (\n                                  <div className=\"px-3 py-2 text-sm text-bambu-gray\">{t('smartPlugs.noMatchingSensors')}</div>\n                                )}\n                              </div>\n                            )}\n                          </div>\n                        );\n                      })()}\n\n                      {/* Total Energy (kWh) */}\n                      {(() => {\n                        const energySensors = haSensorEntities.filter(s =>\n                          s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'\n                        );\n                        const filteredEnergySensors = energyTotalSearch\n                          ? energySensors.filter(s =>\n                              s.entity_id.toLowerCase().includes(energyTotalSearch.toLowerCase()) ||\n                              s.friendly_name.toLowerCase().includes(energyTotalSearch.toLowerCase())\n                            )\n                          : energySensors;\n                        const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTotalEntity);\n\n                        return (\n                          <div ref={energyTotalDropdownRef} className=\"relative\">\n                            <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.totalEnergyKwh')}</label>\n                            <div className=\"relative\">\n                              <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                              <input\n                                type=\"text\"\n                                value={isEnergyTotalDropdownOpen ? energyTotalSearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}\n                                onChange={(e) => {\n                                  setEnergyTotalSearch(e.target.value);\n                                  if (!isEnergyTotalDropdownOpen) setIsEnergyTotalDropdownOpen(true);\n                                }}\n                                onFocus={() => {\n                                  setIsEnergyTotalDropdownOpen(true);\n                                  setEnergyTotalSearch('');\n                                }}\n                                placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}\n                                className=\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                              />\n                              {haEnergyTotalEntity && !isEnergyTotalDropdownOpen && (\n                                <button\n                                  type=\"button\"\n                                  onClick={() => {\n                                    setHaEnergyTotalEntity('');\n                                    setEnergyTotalSearch('');\n                                  }}\n                                  className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded\"\n                                >\n                                  <X className=\"w-4 h-4 text-bambu-gray hover:text-white\" />\n                                </button>\n                              )}\n                            </div>\n                            {isEnergyTotalDropdownOpen && (\n                              <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\">\n                                <button\n                                  type=\"button\"\n                                  onClick={() => {\n                                    setHaEnergyTotalEntity('');\n                                    setIsEnergyTotalDropdownOpen(false);\n                                    setEnergyTotalSearch('');\n                                  }}\n                                  className=\"w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary\"\n                                >\n                                  {t('smartPlugs.none')}\n                                </button>\n                                {filteredEnergySensors.map((sensor) => (\n                                  <button\n                                    key={sensor.entity_id}\n                                    type=\"button\"\n                                    onClick={() => {\n                                      setHaEnergyTotalEntity(sensor.entity_id);\n                                      setIsEnergyTotalDropdownOpen(false);\n                                      setEnergyTotalSearch('');\n                                    }}\n                                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${\n                                      sensor.entity_id === haEnergyTotalEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'\n                                    }`}\n                                  >\n                                    <div className=\"font-medium\">{sensor.friendly_name}</div>\n                                    <div className=\"text-xs text-bambu-gray\">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>\n                                  </button>\n                                ))}\n                                {filteredEnergySensors.length === 0 && (\n                                  <div className=\"px-3 py-2 text-sm text-bambu-gray\">{t('smartPlugs.noMatchingSensors')}</div>\n                                )}\n                              </div>\n                            )}\n                          </div>\n                        );\n                      })()}\n                    </div>\n                  )}\n                </>\n              )}\n            </div>\n          )}\n\n          {/* MQTT Configuration - only show when MQTT is selected */}\n          {plugType === 'mqtt' && (\n            <div className=\"space-y-3\">\n              {/* MQTT broker not configured */}\n              {!settings?.mqtt_broker && (\n                <div className=\"p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400\">\n                  {t('smartPlugs.mqttNotConfigured')}{' '}\n                  <span className=\"font-medium\">{t('smartPlugs.mqttSettingsPath')}</span>\n                  {' '}{t('smartPlugs.mqttNotConfiguredSuffix')}\n                </div>\n              )}\n\n              {/* MQTT broker configured - show fields */}\n              {settings?.mqtt_broker && (\n                <>\n                  <div className=\"p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-300\">\n                    <p className=\"font-medium mb-1\">{t('smartPlugs.monitorOnly')}</p>\n                    <p className=\"text-xs opacity-80\">\n                      {t('smartPlugs.mqttMonitorOnlyDescription')}\n                    </p>\n                  </div>\n\n                  {/* Power Section */}\n                  <div className=\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                    <p className=\"text-white font-medium text-sm\">{t('smartPlugs.powerMonitoring')}</p>\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.topic')}</label>\n                      <input\n                        type=\"text\"\n                        value={mqttPowerTopic}\n                        onChange={(e) => setMqttPowerTopic(e.target.value)}\n                        placeholder=\"zigbee2mqtt/shelly-working-room\"\n                        className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                      />\n                    </div>\n                    <div className=\"grid grid-cols-2 gap-3\">\n                      <div>\n                        <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.jsonPath')}</label>\n                        <input\n                          type=\"text\"\n                          value={mqttPowerPath}\n                          onChange={(e) => setMqttPowerPath(e.target.value)}\n                          placeholder=\"power_l1\"\n                          className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                        />\n                      </div>\n                      <div>\n                        <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.multiplier')}</label>\n                        <input\n                          type=\"text\"\n                          value={mqttPowerMultiplier}\n                          onChange={(e) => setMqttPowerMultiplier(e.target.value)}\n                          placeholder=\"1\"\n                          className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                        />\n                      </div>\n                    </div>\n                    <p className=\"text-xs text-bambu-gray\" style={{ whiteSpace: 'pre-line' }}>\n                      {t('smartPlugs.mqttPowerHint')}\n                    </p>\n                  </div>\n\n                  {/* Energy Section */}\n                  <div className=\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                    <p className=\"text-white font-medium text-sm\">{t('smartPlugs.energyMonitoring')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></p>\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.topic')}</label>\n                      <input\n                        type=\"text\"\n                        value={mqttEnergyTopic}\n                        onChange={(e) => setMqttEnergyTopic(e.target.value)}\n                        placeholder=\"Same as power topic, or different\"\n                        className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                      />\n                    </div>\n                    <div className=\"grid grid-cols-2 gap-3\">\n                      <div>\n                        <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.jsonPath')}</label>\n                        <input\n                          type=\"text\"\n                          value={mqttEnergyPath}\n                          onChange={(e) => setMqttEnergyPath(e.target.value)}\n                          placeholder=\"energy_l1\"\n                          className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                        />\n                      </div>\n                      <div>\n                        <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.multiplier')}</label>\n                        <input\n                          type=\"text\"\n                          value={mqttEnergyMultiplier}\n                          onChange={(e) => setMqttEnergyMultiplier(e.target.value)}\n                          placeholder=\"1\"\n                          className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                        />\n                      </div>\n                    </div>\n                    <p className=\"text-xs text-bambu-gray\" style={{ whiteSpace: 'pre-line' }}>\n                      {t('smartPlugs.mqttEnergyHint')}\n                    </p>\n                  </div>\n\n                  {/* State Section */}\n                  <div className=\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                    <p className=\"text-white font-medium text-sm\">{t('smartPlugs.stateMonitoring')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></p>\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.topic')}</label>\n                      <input\n                        type=\"text\"\n                        value={mqttStateTopic}\n                        onChange={(e) => setMqttStateTopic(e.target.value)}\n                        placeholder=\"Same as power topic, or different\"\n                        className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                      />\n                    </div>\n                    <div className=\"grid grid-cols-2 gap-3\">\n                      <div>\n                        <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.jsonPath')}</label>\n                        <input\n                          type=\"text\"\n                          value={mqttStatePath}\n                          onChange={(e) => setMqttStatePath(e.target.value)}\n                          placeholder=\"state_l1\"\n                          className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                        />\n                      </div>\n                      <div>\n                        <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.onValue')}</label>\n                        <input\n                          type=\"text\"\n                          value={mqttStateOnValue}\n                          onChange={(e) => setMqttStateOnValue(e.target.value)}\n                          placeholder={t('smartPlugs.addSmartPlug.placeholders.mqttStateOnValue')}\n                          className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                        />\n                      </div>\n                    </div>\n                    <p className=\"text-xs text-bambu-gray\" style={{ whiteSpace: 'pre-line' }}>\n                      {t('smartPlugs.mqttStateHint')}\n                    </p>\n                  </div>\n                </>\n              )}\n            </div>\n          )}\n\n          {/* REST API Section */}\n          {plugType === 'rest' && (\n            <div className=\"space-y-3\">\n              {/* Control Section */}\n              <div className=\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                <p className=\"text-white font-medium text-sm\">{t('smartPlugs.restControl')}</p>\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restMethod')}</label>\n                  <select\n                    value={restMethod}\n                    onChange={(e) => setRestMethod(e.target.value)}\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                  >\n                    <option value=\"GET\">GET</option>\n                    <option value=\"POST\">POST</option>\n                    <option value=\"PUT\">PUT</option>\n                    <option value=\"PATCH\">PATCH</option>\n                  </select>\n                </div>\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restOnUrl')}</label>\n                  <input\n                    type=\"text\"\n                    value={restOnUrl}\n                    onChange={(e) => { setRestOnUrl(e.target.value); setTestResult(null); }}\n                    placeholder=\"http://openhab:8080/rest/items/MyPlug\"\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restOnBody')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></label>\n                  <input\n                    type=\"text\"\n                    value={restOnBody}\n                    onChange={(e) => setRestOnBody(e.target.value)}\n                    placeholder={t('smartPlugs.restBodyHint')}\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restOffUrl')}</label>\n                  <input\n                    type=\"text\"\n                    value={restOffUrl}\n                    onChange={(e) => { setRestOffUrl(e.target.value); setTestResult(null); }}\n                    placeholder=\"http://openhab:8080/rest/items/MyPlug\"\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restOffBody')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></label>\n                  <input\n                    type=\"text\"\n                    value={restOffBody}\n                    onChange={(e) => setRestOffBody(e.target.value)}\n                    placeholder={t('smartPlugs.restBodyHint')}\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n              </div>\n\n              {/* Headers Section */}\n              <div className=\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                <p className=\"text-white font-medium text-sm\">{t('smartPlugs.restHeaders')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></p>\n                <div>\n                  <textarea\n                    value={restHeaders}\n                    onChange={(e) => setRestHeaders(e.target.value)}\n                    placeholder={t('smartPlugs.restHeadersHint')}\n                    rows={2}\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none font-mono text-sm\"\n                  />\n                </div>\n              </div>\n\n              {/* Status Polling Section (optional) */}\n              <div className=\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                <p className=\"text-white font-medium text-sm\">{t('smartPlugs.stateMonitoring')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></p>\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restStatusUrl')}</label>\n                  <input\n                    type=\"text\"\n                    value={restStatusUrl}\n                    onChange={(e) => setRestStatusUrl(e.target.value)}\n                    placeholder={t('smartPlugs.restStatusHint')}\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n                <div className=\"grid grid-cols-2 gap-3\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restStatusPath')}</label>\n                    <input\n                      type=\"text\"\n                      value={restStatusPath}\n                      onChange={(e) => setRestStatusPath(e.target.value)}\n                      placeholder={t('smartPlugs.restPathHint')}\n                      className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                    />\n                  </div>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restStatusOnValue')}</label>\n                    <input\n                      type=\"text\"\n                      value={restStatusOnValue}\n                      onChange={(e) => setRestStatusOnValue(e.target.value)}\n                      placeholder=\"ON\"\n                      className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                    />\n                  </div>\n                </div>\n              </div>\n\n              {/* Energy Monitoring (optional) */}\n              <div className=\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                <p className=\"text-white font-medium text-sm\">{t('smartPlugs.energyMonitoring')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></p>\n\n                {/* Power */}\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restPowerUrl')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></label>\n                  <input\n                    type=\"text\"\n                    value={restPowerUrl}\n                    onChange={(e) => setRestPowerUrl(e.target.value)}\n                    placeholder={t('smartPlugs.restPowerUrlHint')}\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n                <div className=\"grid grid-cols-2 gap-3\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restPowerPath')}</label>\n                    <input\n                      type=\"text\"\n                      value={restPowerPath}\n                      onChange={(e) => setRestPowerPath(e.target.value)}\n                      placeholder={t('smartPlugs.restPathHint')}\n                      className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                    />\n                  </div>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restPowerMultiplier')}</label>\n                    <input\n                      type=\"text\"\n                      value={restPowerMultiplier}\n                      onChange={(e) => setRestPowerMultiplier(e.target.value)}\n                      placeholder=\"1\"\n                      className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                    />\n                  </div>\n                </div>\n\n                {/* Energy */}\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restEnergyUrl')} <span className=\"text-bambu-gray font-normal\">({t('smartPlugs.optional')})</span></label>\n                  <input\n                    type=\"text\"\n                    value={restEnergyUrl}\n                    onChange={(e) => setRestEnergyUrl(e.target.value)}\n                    placeholder={t('smartPlugs.restEnergyUrlHint')}\n                    className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n                <div className=\"grid grid-cols-2 gap-3\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restEnergyPath')}</label>\n                    <input\n                      type=\"text\"\n                      value={restEnergyPath}\n                      onChange={(e) => setRestEnergyPath(e.target.value)}\n                      placeholder={t('smartPlugs.restPathHint')}\n                      className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                    />\n                  </div>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.restEnergyMultiplier')}</label>\n                    <input\n                      type=\"text\"\n                      value={restEnergyMultiplier}\n                      onChange={(e) => setRestEnergyMultiplier(e.target.value)}\n                      placeholder=\"1\"\n                      className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                    />\n                  </div>\n                </div>\n\n                <p className=\"text-xs text-bambu-gray\">\n                  {t('smartPlugs.restEnergyHint')}\n                </p>\n              </div>\n\n              {/* Test Connection */}\n              {(restOnUrl.trim() || restOffUrl.trim()) && (\n                <div className=\"flex gap-2\">\n                  <Button\n                    type=\"button\"\n                    variant=\"secondary\"\n                    onClick={async () => {\n                      setTestResult(null);\n                      try {\n                        const url = restOnUrl.trim() || restOffUrl.trim();\n                        const result = await api.testRESTConnection(url, restMethod, restHeaders.trim() || null);\n                        setTestResult({ success: result.success });\n                        if (!result.success) {\n                          setError(result.error || t('smartPlugs.addSmartPlug.connectionFailed'));\n                        }\n                      } catch {\n                        setTestResult({ success: false });\n                        setError(t('smartPlugs.addSmartPlug.connectionFailed'));\n                      }\n                    }}\n                    className=\"w-full\"\n                  >\n                    <Wifi className=\"w-4 h-4\" />\n                    {t('smartPlugs.testConnection')}\n                  </Button>\n                </div>\n              )}\n              {testResult && (\n                <div className={`flex items-center gap-2 text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>\n                  {testResult.success ? <CheckCircle className=\"w-4 h-4\" /> : <WifiOff className=\"w-4 h-4\" />}\n                  {testResult.success ? t('smartPlugs.connectionSuccess') : t('smartPlugs.addSmartPlug.connectionFailed')}\n                </div>\n              )}\n            </div>\n          )}\n\n          {/* IP Address - only show for Tasmota */}\n          {plugType === 'tasmota' && (\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.ipAddress')}</label>\n              <div className=\"flex gap-2\">\n                <input\n                  type=\"text\"\n                  value={ipAddress}\n                  onChange={(e) => {\n                    setIpAddress(e.target.value);\n                    setTestResult(null);\n                  }}\n                  placeholder=\"192.168.1.100\"\n                  className=\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                />\n                <Button\n                  type=\"button\"\n                  variant=\"secondary\"\n                  onClick={() => testMutation.mutate()}\n                  disabled={!ipAddress.trim() || testMutation.isPending}\n                >\n                  {testMutation.isPending ? (\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  ) : (\n                    <Wifi className=\"w-4 h-4\" />\n                  )}\n                  {t('smartPlugs.test')}\n                </Button>\n              </div>\n            </div>\n          )}\n\n          {/* Test Result - only show for Tasmota */}\n          {plugType === 'tasmota' && testResult && (\n            <div className={`p-3 rounded-lg flex items-center gap-2 ${\n              testResult.success\n                ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'\n                : 'bg-red-500/20 border border-red-500/50 text-red-400'\n            }`}>\n              {testResult.success ? (\n                <>\n                  <CheckCircle className=\"w-5 h-5\" />\n                  <div>\n                    <p className=\"font-medium\">{t('smartPlugs.connectedResult')}</p>\n                    <p className=\"text-sm opacity-80\">\n                      {testResult.device_name && t('smartPlugs.deviceLabel', { name: testResult.device_name })}\n                      {t('smartPlugs.stateLabel', { state: testResult.state })}\n                    </p>\n                  </div>\n                </>\n              ) : (\n                <>\n                  <WifiOff className=\"w-5 h-5\" />\n                  <span>{t('smartPlugs.addSmartPlug.connectionFailed')}</span>\n                </>\n              )}\n            </div>\n          )}\n\n          {/* Name */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.nameLabel')}</label>\n            <input\n              type=\"text\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder={t('smartPlugs.addSmartPlug.placeholders.plugName')}\n              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\"\n            />\n          </div>\n\n          {/* Authentication (optional) - only show for Tasmota */}\n          {plugType === 'tasmota' && (\n            <>\n              <div className=\"grid grid-cols-2 gap-3\">\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.username')}</label>\n                  <input\n                    type=\"text\"\n                    value={username}\n                    onChange={(e) => setUsername(e.target.value)}\n                    placeholder=\"admin\"\n                    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\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.password')}</label>\n                  <input\n                    type=\"password\"\n                    value={password}\n                    onChange={(e) => setPassword(e.target.value)}\n                    placeholder=\"********\"\n                    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\"\n                  />\n                </div>\n              </div>\n              <p className=\"text-xs text-bambu-gray -mt-2\">\n                {t('smartPlugs.authHint')}\n              </p>\n            </>\n          )}\n\n          {/* Link to Printer - not shown for MQTT plugs (monitor-only) */}\n          {plugType !== 'mqtt' && (\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.linkToPrinter')}</label>\n              <select\n                value={printerId ?? ''}\n                onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}\n                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\"\n              >\n                <option value=\"\">{t('smartPlugs.noPrinter')}</option>\n                {availablePrinters?.map((p) => (\n                  <option key={p.id} value={p.id}>\n                    {p.name}\n                  </option>\n                ))}\n              </select>\n              <p className=\"text-xs text-bambu-gray mt-1\">\n                {t('smartPlugs.linkingDescription')}\n              </p>\n            </div>\n          )}\n\n          {/* Power Alerts */}\n          <div className=\"border-t border-bambu-dark-tertiary pt-4\">\n            <div className=\"flex items-center justify-between mb-3\">\n              <div className=\"flex items-center gap-2\">\n                <Bell className=\"w-4 h-4 text-bambu-green\" />\n                <span className=\"text-white font-medium\">{t('smartPlugs.powerAlerts')}</span>\n              </div>\n              <label className=\"relative inline-flex items-center cursor-pointer\">\n                <input\n                  type=\"checkbox\"\n                  checked={powerAlertEnabled}\n                  onChange={(e) => setPowerAlertEnabled(e.target.checked)}\n                  className=\"sr-only peer\"\n                />\n                <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n              </label>\n            </div>\n            {powerAlertEnabled && (\n              <div className=\"space-y-3\">\n                <div className=\"grid grid-cols-2 gap-3\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.alertAbove')}</label>\n                    <input\n                      type=\"number\"\n                      value={powerAlertHigh}\n                      onChange={(e) => setPowerAlertHigh(e.target.value)}\n                      placeholder=\"e.g. 200\"\n                      min=\"0\"\n                      max=\"5000\"\n                      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\"\n                    />\n                  </div>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.alertBelow')}</label>\n                    <input\n                      type=\"number\"\n                      value={powerAlertLow}\n                      onChange={(e) => setPowerAlertLow(e.target.value)}\n                      placeholder=\"e.g. 10\"\n                      min=\"0\"\n                      max=\"5000\"\n                      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\"\n                    />\n                  </div>\n                </div>\n                <p className=\"text-xs text-bambu-gray\">\n                  {t('smartPlugs.alertDescription')}\n                </p>\n              </div>\n            )}\n          </div>\n\n          {/* Schedule - not shown for MQTT plugs (monitor-only) */}\n          {plugType !== 'mqtt' && (\n            <div className=\"border-t border-bambu-dark-tertiary pt-4\">\n              <div className=\"flex items-center justify-between mb-3\">\n                <div className=\"flex items-center gap-2\">\n                  <Clock className=\"w-4 h-4 text-bambu-green\" />\n                  <span className=\"text-white font-medium\">{t('smartPlugs.dailySchedule')}</span>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={scheduleEnabled}\n                    onChange={(e) => setScheduleEnabled(e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              {scheduleEnabled && (\n                <div className=\"space-y-3\">\n                  <div className=\"grid grid-cols-2 gap-3\">\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.turnOnAt')}</label>\n                      <input\n                        type=\"time\"\n                        value={scheduleOnTime}\n                        onChange={(e) => setScheduleOnTime(e.target.value)}\n                        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\"\n                      />\n                    </div>\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">{t('smartPlugs.turnOffAt')}</label>\n                      <input\n                        type=\"time\"\n                        value={scheduleOffTime}\n                        onChange={(e) => setScheduleOffTime(e.target.value)}\n                        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\"\n                      />\n                    </div>\n                  </div>\n                  <p className=\"text-xs text-bambu-gray\">\n                    {t('smartPlugs.scheduleDescription')}\n                  </p>\n                </div>\n              )}\n            </div>\n          )}\n\n          {/* Switchbar Visibility */}\n          <div className=\"border-t border-bambu-dark-tertiary pt-4\">\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                <LayoutGrid className=\"w-4 h-4 text-bambu-green\" />\n                <div>\n                  <span className=\"text-white font-medium\">{t('smartPlugs.showInSwitchbar')}</span>\n                  <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.quickAccessSidebar')}</p>\n                </div>\n              </div>\n              <label className=\"relative inline-flex items-center cursor-pointer\">\n                <input\n                  type=\"checkbox\"\n                  checked={showInSwitchbar}\n                  onChange={(e) => setShowInSwitchbar(e.target.checked)}\n                  className=\"sr-only peer\"\n                />\n                <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n              </label>\n            </div>\n          </div>\n\n          {/* Printer Card Visibility - only for HA entities */}\n          {plugType === 'homeassistant' && (\n            <div className=\"border-t border-bambu-dark-tertiary pt-4\">\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <Eye className=\"w-4 h-4 text-bambu-green\" />\n                  <div>\n                    <span className=\"text-white font-medium\">{t('smartPlugs.showOnPrinterCard')}</span>\n                    <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.displayOnPrinterCard')}</p>\n                  </div>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={showOnPrinterCard}\n                    onChange={(e) => setShowOnPrinterCard(e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n            </div>\n          )}\n\n          {/* Actions */}\n          <div className=\"flex gap-3 pt-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={onClose}\n              className=\"flex-1\"\n            >\n              {t('smartPlugs.cancel')}\n            </Button>\n            <Button\n              type=\"submit\"\n              disabled={isPending}\n              className=\"flex-1\"\n            >\n              {isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Save className=\"w-4 h-4\" />\n              )}\n              {isEditing ? t('smartPlugs.save') : t('smartPlugs.add')}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/AssignSpoolModal.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Loader2, Package, Search } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { InventorySpool, SpoolAssignment } from '../api/client';\nimport { Button } from './Button';\nimport { ConfirmModal } from './ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\n\ninterface AssignSpoolModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  printerId: number;\n  amsId: number;\n  trayId: number;\n  trayInfo?: {\n    type: string;\n    material?: string;\n    profile?: string;\n    color: string;\n    location: string;\n  };\n}\n\nexport function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo }: AssignSpoolModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [disableFiltering, setDisableFiltering] = useState(false);\n  const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);\n  useEffect(() => {\n    setSelectedSpoolId(null);\n  }, [disableFiltering]);\n  const [searchFilter, setSearchFilter] = useState('');\n  const [pendingAssignId, setPendingAssignId] = useState<number | null>(null);\n  const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);\n  const [mismatchDetails, setMismatchDetails] = useState<{\n    type: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile';\n    spoolMaterial: string;\n    trayMaterial: string;\n    spoolProfile?: string;\n    trayProfile?: string;\n  } | null>(null);\n\n  useEffect(() => {\n    if (isOpen) {\n      setDisableFiltering(false);\n    }\n  }, [isOpen]);\n\n  const { data: spools, isLoading } = useQuery({\n    queryKey: ['inventory-spools'],\n    queryFn: () => api.getSpools(),\n    enabled: isOpen,\n  });\n\n  const { data: assignments } = useQuery({\n    queryKey: ['spool-assignments'],\n    queryFn: () => api.getAssignments(),\n    enabled: isOpen,\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: () => api.getSettings(),\n    enabled: isOpen,\n  });\n\n  const assignMutation = useMutation({\n    mutationFn: (spoolId: number) =>\n      api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),\n    onSuccess: (newAssignment) => {\n      // Immediately update cache so UI reflects the new assignment without waiting for refetch\n      queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {\n        const filtered = (old || []).filter(a =>\n          !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)\n        );\n        filtered.push(newAssignment);\n        return filtered;\n      });\n      queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });\n      showToast(t('inventory.assignSuccess'), 'success');\n      setShowMismatchConfirm(false);\n      setPendingAssignId(null);\n      setMismatchDetails(null);\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');\n    },\n  });\n\n  // --- Material/profile mismatch logic ---\n  const normalizeValue = (value: string | undefined | null) =>\n    (value ?? '').trim().toUpperCase();\n\n  const checkMaterialMatch = (\n    spoolMaterial: string | undefined | null,\n    trayMaterial: string | undefined | null\n  ): 'exact' | 'partial' | 'none' => {\n    const normalizedSpool = normalizeValue(spoolMaterial);\n    const normalizedTray = normalizeValue(trayMaterial);\n\n    if (!normalizedSpool || !normalizedTray) return 'none';\n    if (normalizedSpool === normalizedTray) return 'exact';\n    if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {\n      return 'partial';\n    }\n\n    return 'none';\n  };\n\n  // Bambu Studio / OrcaSlicer profile names carry a printer/nozzle/variant qualifier after\n  // `@` (e.g. \"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)\"), while the tray's\n  // profile is typically the bare base name. Strip the qualifier before comparing so identical\n  // base profiles don't trigger a mismatch warning (#1047).\n  const stripProfileQualifier = (value: string) => value.split('@')[0].trim();\n\n  const checkProfileMatch = (\n    spoolProfile: string | undefined | null,\n    trayProfile: string | undefined | null\n  ): boolean => {\n    const normalizedSpoolProfile = stripProfileQualifier(normalizeValue(spoolProfile));\n    const normalizedTrayProfile = stripProfileQualifier(normalizeValue(trayProfile));\n\n    if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;\n\n    return normalizedSpoolProfile === normalizedTrayProfile;\n  };\n\n  if (!isOpen) return null;\n\n  // Filter out spools already assigned to other slots\n  const assignedSpoolIds = new Set(\n    (assignments || [])\n      .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))\n      .map(a => a.spool_id)\n  );\n  // External slots (amsId 254 or 255) have no RFID reader, so show all spools.\n  // AMS slots only show manual spools (no tag_uid or tray_uuid).\n  const isExternalSlot = amsId === 254 || amsId === 255;\n  const manualSpools = spools?.filter((spool: InventorySpool) =>\n    !assignedSpoolIds.has(spool.id) && (isExternalSlot || (!spool.tag_uid && !spool.tray_uuid))\n  );\n\n  // Filtering logic with toggle: search filter always applies, AMS tray profile filter is optional.\n  // Show a spool if EITHER the slicer profile matches exactly OR the material overlaps with the\n  // tray's material (partial-match both directions — \"PLA\" spool accepts a \"PLA Basic\" slot and\n  // vice versa). Manually-added inventory spools typically have no slicer_filament_name; gating\n  // on strict profile equality alone hid them even when the material matched (#1047).\n  let filteredSpools = manualSpools;\n  if (!disableFiltering) {\n    const trayProfile = stripProfileQualifier(normalizeValue(trayInfo?.profile));\n    const trayMaterial = normalizeValue(trayInfo?.material || trayInfo?.type);\n    if (trayProfile || trayMaterial) {\n      filteredSpools = filteredSpools?.filter((spool: InventorySpool) => {\n        const spoolProfile = stripProfileQualifier(normalizeValue(spool.slicer_filament_name || spool.slicer_filament));\n        const spoolMaterial = normalizeValue(spool.material);\n        if (trayProfile && spoolProfile && spoolProfile === trayProfile) return true;\n        if (trayMaterial && spoolMaterial) {\n          return (\n            spoolMaterial === trayMaterial ||\n            trayMaterial.includes(spoolMaterial) ||\n            spoolMaterial.includes(trayMaterial)\n          );\n        }\n        // Neither side has filterable info on whatever dimension remains — show it.\n        return !spoolProfile && !spoolMaterial;\n      });\n    }\n  }\n  if (searchFilter && filteredSpools) {\n    const q = searchFilter.toLowerCase();\n    filteredSpools = filteredSpools.filter((spool: InventorySpool) => {\n      return (\n        spool.material.toLowerCase().includes(q) ||\n        (spool.brand?.toLowerCase().includes(q) ?? false) ||\n        (spool.color_name?.toLowerCase().includes(q) ?? false) ||\n        (spool.subtype?.toLowerCase().includes(q) ?? false)\n      );\n    });\n  }\n\n  const handleAssign = () => {\n    if (!selectedSpoolId) return;\n    const selectedSpool = spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId);\n    if (!selectedSpool) {\n      showToast(t('inventory.assignFailed'), 'error');\n      return;\n    }\n\n    if (!settings?.disable_filament_warnings && trayInfo) {\n      const trayMaterial = trayInfo.material || trayInfo.type;\n      const materialMatchResult = checkMaterialMatch(selectedSpool.material, trayMaterial);\n      const spoolProfile = selectedSpool.slicer_filament_name || selectedSpool.slicer_filament;\n      const trayProfile = trayInfo.profile || trayInfo.type;\n      const profileMatches = checkProfileMatch(spoolProfile, trayProfile);\n\n      // Always evaluate both checks; if both fail, show a combined warning.\n      if (materialMatchResult !== 'exact' || !profileMatches) {\n        let mismatchType: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile' = 'profile';\n\n        if (materialMatchResult === 'none' && !profileMatches) {\n          mismatchType = 'material_profile';\n        } else if (materialMatchResult === 'partial' && !profileMatches) {\n          mismatchType = 'partial_profile';\n        } else if (materialMatchResult === 'none') {\n          mismatchType = 'material';\n        } else if (materialMatchResult === 'partial') {\n          mismatchType = 'partial';\n        }\n\n        setPendingAssignId(selectedSpoolId);\n        setMismatchDetails({\n          type: mismatchType,\n          spoolMaterial: selectedSpool.material || '',\n          trayMaterial: trayMaterial || '',\n          spoolProfile: spoolProfile || undefined,\n          trayProfile: trayProfile || undefined,\n        });\n        setShowMismatchConfirm(true);\n        return;\n      }\n    }\n    assignMutation.mutate(selectedSpoolId);\n  };\n\n  const handleConfirmMismatch = () => {\n    if (!pendingAssignId) return;\n    assignMutation.mutate(pendingAssignId);\n    setShowMismatchConfirm(false);\n    setPendingAssignId(null);\n  };\n\n  return (\n    <>\n      <div className=\"fixed inset-0 z-50 flex items-start sm:items-center justify-center p-4 overflow-y-auto\">\n        <div\n          className=\"absolute inset-0 bg-black/60 backdrop-blur-sm\"\n          onClick={onClose}\n        />\n\n      <div className=\"relative w-full max-w-2xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-2\">\n            <Package className=\"w-5 h-5 text-bambu-green\" />\n            <h2 className=\"text-lg font-semibold text-white\">{t('inventory.assignSpool')}</h2>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"p-1 text-bambu-gray hover:text-white rounded transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Content */}\n        <div className=\"p-4 space-y-4 overflow-y-auto\">\n          {/* Tray info */}\n          {trayInfo && (\n            <div className=\"p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n              <p className=\"text-xs text-bambu-gray mb-1\">{t('inventory.selectSpool')}:</p>\n              <div className=\"flex items-center gap-2\">\n                {trayInfo.color && (\n                  <span\n                    className=\"w-4 h-4 rounded-full border border-black/20\"\n                    style={{ backgroundColor: `#${trayInfo.color}` }}\n                  />\n                )}\n                <span className=\"text-white font-medium\">{trayInfo.type || t('ams.emptySlot')}</span>\n                <span className=\"text-bambu-gray\">({trayInfo.location})</span>\n              </div>\n            </div>\n          )}\n\n          {/* Search filter */}\n          <div className=\"relative\">\n            <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n            <input\n              type=\"text\"\n              value={searchFilter}\n              onChange={(e) => setSearchFilter(e.target.value)}\n              placeholder={t('inventory.searchSpools')}\n              className=\"w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green\"\n            />\n          </div>\n\n          {/* Spool list */}\n          <div>\n            {isLoading ? (\n              <div className=\"flex justify-center py-8\">\n                <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n              </div>\n            ) : filteredSpools && filteredSpools.length > 0 ? (\n              <div className=\"max-h-96 overflow-y-auto grid grid-cols-2 sm:grid-cols-3 gap-2\">\n                {filteredSpools.map((spool: InventorySpool) => (\n                  <button\n                    key={spool.id}\n                    onClick={() => setSelectedSpoolId(spool.id)}\n                    title={spool.note || undefined}\n                    className={`p-2.5 rounded-lg border text-left transition-colors ${\n                      selectedSpoolId === spool.id\n                        ? 'bg-bambu-green/20 border-bambu-green'\n                        : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'\n                    }`}\n                  >\n                    <p className=\"text-white text-sm font-medium truncate\">\n                      {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}\n                    </p>\n                    <div className=\"flex items-center gap-1.5 mt-1\">\n                      {spool.rgba && (\n                        <span\n                          className=\"w-3 h-3 rounded-full border border-black/20 flex-shrink-0\"\n                          style={{ backgroundColor: `#${spool.rgba.substring(0, 6)}` }}\n                        />\n                      )}\n                      <span className=\"text-xs text-bambu-gray truncate\">{spool.color_name || ''}</span>\n                    </div>\n                    {spool.label_weight && (\n                      <p className=\"text-xs text-bambu-gray mt-1\">\n                        {Math.max(0, Math.round(spool.label_weight - spool.weight_used))} / {spool.label_weight}g\n                      </p>\n                    )}\n                  </button>\n                ))}\n              </div>\n            ) : manualSpools && manualSpools.length === 0 ? (\n              <div className=\"text-center py-8 text-bambu-gray\">\n                <p>{t('inventory.noManualSpools')}</p>\n              </div>\n            ) : (\n              <div className=\"text-center py-8 text-bambu-gray\">\n                <p>{t('inventory.noSpoolsMatch')}</p>\n              </div>\n            )}\n          </div>\n        </div>\n\n        {/* Footer with filtering toggle */}\n        <div className=\"flex justify-between items-center p-4 border-t border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-2\">\n            <input\n              id=\"disable-filtering-toggle\"\n              type=\"checkbox\"\n              checked={disableFiltering}\n              onChange={() => setDisableFiltering(v => !v)}\n              className=\"accent-bambu-green w-4 h-4 rounded focus:ring-0 border-bambu-dark-tertiary\"\n            />\n            <label htmlFor=\"disable-filtering-toggle\" className=\"text-xs text-bambu-gray select-none cursor-pointer\">\n              {t('inventory.showAllSpools')}\n            </label>\n          </div>\n          <div className=\"flex gap-2\">\n            <Button variant=\"secondary\" onClick={onClose}>\n              {t('common.cancel')}\n            </Button>\n            <Button\n              onClick={handleAssign}\n              disabled={!selectedSpoolId || assignMutation.isPending}\n            >\n              {assignMutation.isPending ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  {t('inventory.assigning')}\n                </>\n              ) : (\n                <>\n                  <Package className=\"w-4 h-4\" />\n                  {t('inventory.assignSpool')}\n                </>\n              )}\n            </Button>\n          </div>\n        </div>\n\n\n        {assignMutation.isError && (\n          <div className=\"mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400\">\n            {(assignMutation.error as Error).message}\n          </div>\n        )}\n\n      </div>\n      </div>\n\n      {showMismatchConfirm && trayInfo && selectedSpoolId && mismatchDetails && (() => {\n        let message = '';\n\n        if (mismatchDetails.type === 'material') {\n          message = t('inventory.assignMismatchMessage', {\n            spoolMaterial: mismatchDetails.spoolMaterial,\n            trayMaterial: mismatchDetails.trayMaterial,\n            location: trayInfo.location,\n          });\n        } else if (mismatchDetails.type === 'partial') {\n          message = t('inventory.assignPartialMismatchMessage', {\n            spoolMaterial: mismatchDetails.spoolMaterial,\n            trayMaterial: mismatchDetails.trayMaterial,\n            location: trayInfo.location,\n          });\n        } else if (mismatchDetails.type === 'material_profile') {\n          message = `${t('inventory.assignMismatchMessage', {\n            spoolMaterial: mismatchDetails.spoolMaterial,\n            trayMaterial: mismatchDetails.trayMaterial,\n            location: trayInfo.location,\n          })}\\n\\n${t('inventory.assignProfileMismatchMessage', {\n            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),\n            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),\n            location: trayInfo.location,\n          })}`;\n        } else if (mismatchDetails.type === 'partial_profile') {\n          message = `${t('inventory.assignPartialMismatchMessage', {\n            spoolMaterial: mismatchDetails.spoolMaterial,\n            trayMaterial: mismatchDetails.trayMaterial,\n            location: trayInfo.location,\n          })}\\n\\n${t('inventory.assignProfileMismatchMessage', {\n            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),\n            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),\n            location: trayInfo.location,\n          })}`;\n        } else if (mismatchDetails.type === 'profile') {\n          message = t('inventory.assignProfileMismatchMessage', {\n            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),\n            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),\n            location: trayInfo.location,\n          });\n        }\n\n        return (\n          <ConfirmModal\n            title={t('inventory.assignMismatchTitle')}\n            message={message}\n            confirmText={t('inventory.assignMismatchConfirm')}\n            variant=\"warning\"\n            isLoading={assignMutation.isPending}\n            onConfirm={handleConfirmMismatch}\n            onCancel={() => {\n              if (!assignMutation.isPending) {\n                setShowMismatchConfirm(false);\n                setPendingAssignId(null);\n                setMismatchDetails(null);\n              }\n            }}\n          />\n        );\n      })()}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/BackupModal.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban, Upload, Camera } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { Toggle } from './Toggle';\n\ninterface BackupCategory {\n  id: string;\n  labelKey: string;\n  defaultLabel: string;\n  icon: React.ReactNode;\n  default: boolean;\n  description: string;\n  requiresPrinters?: boolean;\n}\n\nconst BACKUP_CATEGORIES: BackupCategory[] = [\n  {\n    id: 'settings',\n    labelKey: 'backup.categories.settings',\n    defaultLabel: 'App Settings',\n    icon: <Settings className=\"w-4 h-4\" />,\n    default: true,\n    description: 'Language, theme, update preferences',\n  },\n  {\n    id: 'notifications',\n    labelKey: 'backup.categories.notifications',\n    defaultLabel: 'Notification Providers',\n    icon: <Bell className=\"w-4 h-4\" />,\n    default: true,\n    description: 'ntfy, Pushover, Discord, etc.',\n  },\n  {\n    id: 'templates',\n    labelKey: 'backup.categories.templates',\n    defaultLabel: 'Notification Templates',\n    icon: <FileText className=\"w-4 h-4\" />,\n    default: true,\n    description: 'Custom message templates',\n  },\n  {\n    id: 'smart_plugs',\n    labelKey: 'backup.categories.smartPlugs',\n    defaultLabel: 'Smart Plugs',\n    icon: <Plug className=\"w-4 h-4\" />,\n    default: true,\n    description: 'Tasmota plug configurations',\n  },\n  {\n    id: 'external_links',\n    labelKey: 'backup.categories.externalLinks',\n    defaultLabel: 'External Links',\n    icon: <Link className=\"w-4 h-4\" />,\n    default: true,\n    description: 'Sidebar links to external services',\n  },\n  {\n    id: 'printers',\n    labelKey: 'backup.categories.printers',\n    defaultLabel: 'Printers',\n    icon: <Printer className=\"w-4 h-4\" />,\n    default: false,\n    description: 'Printer info (access codes excluded)',\n  },\n  {\n    id: 'plate_calibration',\n    labelKey: 'backup.categories.plateCalibration',\n    defaultLabel: 'Plate Detection',\n    icon: <Camera className=\"w-4 h-4\" />,\n    default: false,\n    description: 'Empty plate reference images',\n    requiresPrinters: true,\n  },\n  {\n    id: 'filaments',\n    labelKey: 'backup.categories.filaments',\n    defaultLabel: 'Filament Inventory',\n    icon: <Palette className=\"w-4 h-4\" />,\n    default: false,\n    description: 'Filament types and costs',\n  },\n  {\n    id: 'maintenance',\n    labelKey: 'backup.categories.maintenance',\n    defaultLabel: 'Maintenance Types',\n    icon: <Wrench className=\"w-4 h-4\" />,\n    default: false,\n    description: 'Custom maintenance schedules',\n  },\n  {\n    id: 'archives',\n    labelKey: 'backup.categories.archives',\n    defaultLabel: 'Print Archives',\n    icon: <Archive className=\"w-4 h-4\" />,\n    default: false,\n    description: 'All print data + files (3MF, thumbnails, photos)',\n  },\n  {\n    id: 'projects',\n    labelKey: 'backup.categories.projects',\n    defaultLabel: 'Projects',\n    icon: <FolderKanban className=\"w-4 h-4\" />,\n    default: false,\n    description: 'Projects, BOM items, and attachments',\n  },\n  {\n    id: 'pending_uploads',\n    labelKey: 'backup.categories.pendingUploads',\n    defaultLabel: 'Pending Uploads',\n    icon: <Upload className=\"w-4 h-4\" />,\n    default: false,\n    description: 'Virtual printer uploads awaiting review',\n  },\n  {\n    id: 'api_keys',\n    labelKey: 'backup.categories.apiKeys',\n    defaultLabel: 'API Keys',\n    icon: <Key className=\"w-4 h-4\" />,\n    default: false,\n    description: 'Webhook API keys (new keys generated on import)',\n  },\n];\n\ninterface BackupModalProps {\n  onClose: () => void;\n  onExport: (categories: Record<string, boolean>) => Promise<void>;\n}\n\nexport function BackupModal({ onClose, onExport }: BackupModalProps) {\n  const { t } = useTranslation();\n  const [selected, setSelected] = useState<Record<string, boolean>>(() => {\n    const initial: Record<string, boolean> = {};\n    BACKUP_CATEGORIES.forEach((cat) => {\n      initial[cat.id] = cat.default;\n    });\n    return initial;\n  });\n  const [includeAccessCodes, setIncludeAccessCodes] = useState(false);\n  const [isExporting, setIsExporting] = useState(false);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  const toggleCategory = (id: string) => {\n    setSelected((prev) => ({ ...prev, [id]: !prev[id] }));\n  };\n\n  const selectAll = () => {\n    const all: Record<string, boolean> = {};\n    BACKUP_CATEGORIES.forEach((cat) => {\n      all[cat.id] = true;\n    });\n    setSelected(all);\n  };\n\n  const selectNone = () => {\n    const none: Record<string, boolean> = {};\n    BACKUP_CATEGORIES.forEach((cat) => {\n      none[cat.id] = false;\n    });\n    setSelected(none);\n  };\n\n  const selectedCount = Object.values(selected).filter(Boolean).length;\n\n  const handleExport = async () => {\n    setIsExporting(true);\n    try {\n      await onExport({ ...selected, access_codes: includeAccessCodes && selected.printers });\n    } finally {\n      setIsExporting(false);\n    }\n  };\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\"\n      onClick={isExporting ? undefined : onClose}\n    >\n      <Card className=\"w-full max-w-lg\" onClick={(e: React.MouseEvent) => e.stopPropagation()}>\n        <CardContent className=\"p-0\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <div className=\"flex items-center gap-3\">\n              <div className=\"p-2 rounded-full bg-bambu-green/20 text-bambu-green\">\n                <Download className=\"w-5 h-5\" />\n              </div>\n              <div>\n                <h3 className=\"text-lg font-semibold text-white\">\n                  {t('backup.exportTitle', { defaultValue: 'Export Backup' })}\n                </h3>\n                <p className=\"text-sm text-bambu-gray\">\n                  {t('backup.selectCategories', { defaultValue: 'Select data to include' })}\n                </p>\n              </div>\n            </div>\n            <button\n              onClick={onClose}\n              className=\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n            >\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Quick actions */}\n          <div className=\"flex gap-2 px-4 pt-4\">\n            <button\n              onClick={selectAll}\n              disabled={isExporting}\n              className=\"text-sm text-bambu-green hover:text-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n              {t('common.selectAll', { defaultValue: 'Select All' })}\n            </button>\n            <span className=\"text-bambu-gray\">|</span>\n            <button\n              onClick={selectNone}\n              disabled={isExporting}\n              className=\"text-sm text-bambu-gray hover:text-white disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n              {t('common.selectNone', { defaultValue: 'Select None' })}\n            </button>\n          </div>\n\n          {/* Categories */}\n          <div className={`p-4 space-y-2 max-h-[400px] overflow-y-auto ${isExporting ? 'opacity-50 pointer-events-none' : ''}`}>\n            {BACKUP_CATEGORIES.map((category) => {\n              const isDisabled = isExporting || (category.requiresPrinters && !selected.printers);\n              return (\n              <label\n                key={category.id}\n                className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${\n                  isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'\n                } ${\n                  selected[category.id] && !isDisabled\n                    ? 'bg-bambu-green/10 border border-bambu-green/30'\n                    : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'\n                }`}\n              >\n                <input\n                  type=\"checkbox\"\n                  checked={selected[category.id] && !isDisabled}\n                  onChange={() => toggleCategory(category.id)}\n                  disabled={isDisabled}\n                  className=\"w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0\"\n                />\n                <div className={`${selected[category.id] && !isDisabled ? 'text-bambu-green' : 'text-bambu-gray'}`}>\n                  {category.icon}\n                </div>\n                <div className=\"flex-1\">\n                  <div className=\"text-white text-sm font-medium\">\n                    {t(category.labelKey, { defaultValue: category.defaultLabel })}\n                  </div>\n                  <div className=\"text-xs text-bambu-gray\">\n                    {category.requiresPrinters && !selected.printers\n                      ? 'Requires Printers to be selected'\n                      : category.description}\n                  </div>\n                </div>\n              </label>\n              );\n            })}\n          </div>\n\n          {/* Archive warning */}\n          {selected.archives && (\n            <div className=\"mx-4 mb-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30\">\n              <div className=\"flex items-start gap-2 text-sm\">\n                <Archive className=\"w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0\" />\n                <div className=\"text-yellow-200 dark:text-yellow-200 text-yellow-700\">\n                  <span className=\"font-medium\">ZIP file will be created.</span>\n                  <span className=\"text-yellow-600 dark:text-yellow-200/70\"> Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.</span>\n                </div>\n              </div>\n            </div>\n          )}\n\n          {/* Access codes option - only shown when printers are selected */}\n          {selected.printers && (\n            <div className=\"mx-4 mb-2 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary\">\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-start gap-2\">\n                  <Key className=\"w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0\" />\n                  <div>\n                    <p className=\"text-sm font-medium text-white\">Include Access Codes</p>\n                    <p className=\"text-xs text-bambu-gray\">For transferring to another machine</p>\n                  </div>\n                </div>\n                <Toggle checked={includeAccessCodes} onChange={setIncludeAccessCodes} />\n              </div>\n              {includeAccessCodes && (\n                <div className=\"mt-2 p-2 rounded bg-orange-500/10 border border-orange-500/30\">\n                  <div className=\"flex items-start gap-2 text-xs\">\n                    <AlertTriangle className=\"w-3 h-3 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0\" />\n                    <span className=\"text-orange-700 dark:text-orange-200\">\n                      Access codes will be included in plain text. Keep this backup file secure!\n                    </span>\n                  </div>\n                </div>\n              )}\n            </div>\n          )}\n\n          {/* Footer */}\n          <div className=\"flex items-center justify-between p-4 border-t border-bambu-dark-tertiary\">\n            <span className=\"text-sm text-bambu-gray\">\n              {t('backup.selectedCount', {\n                count: selectedCount,\n                defaultValue: `${selectedCount} categories selected`,\n              })}\n            </span>\n            <div className=\"flex gap-3\">\n              <Button variant=\"secondary\" onClick={onClose} disabled={isExporting}>\n                {t('common.cancel', { defaultValue: 'Cancel' })}\n              </Button>\n              <Button\n                onClick={handleExport}\n                disabled={selectedCount === 0 || isExporting}\n                className=\"bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]\"\n              >\n                {isExporting ? (\n                  <>\n                    <Loader2 className=\"w-4 h-4 mr-2 animate-spin\" />\n                    {t('backup.exporting', { defaultValue: 'Exporting...' })}\n                  </>\n                ) : (\n                  <>\n                    <Download className=\"w-4 h-4 mr-2\" />\n                    {t('backup.export', { defaultValue: 'Export' })}\n                  </>\n                )}\n              </Button>\n            </div>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/BatchProjectModal.tsx",
    "content": "import { useEffect } from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { X, FolderKanban, Loader2, XCircle } from 'lucide-react';\nimport { api } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\n\ninterface BatchProjectModalProps {\n  selectedIds: number[];\n  onClose: () => void;\n}\n\nexport function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const { data: projects, isLoading } = useQuery({\n    queryKey: ['projects'],\n    queryFn: () => api.getProjects(),\n  });\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  // Helper to invalidate all project-related queries\n  const invalidateProjectQueries = () => {\n    queryClient.invalidateQueries({ queryKey: ['archives'] });\n    queryClient.invalidateQueries({ queryKey: ['projects'] });\n    // Invalidate project detail pages (partial match catches all project IDs)\n    queryClient.invalidateQueries({ queryKey: ['project'] });\n    queryClient.invalidateQueries({ queryKey: ['project-archives'] });\n  };\n\n  // Assign to project mutation (uses bulk API)\n  const assignMutation = useMutation({\n    mutationFn: async (projectId: number) => {\n      await api.addArchivesToProject(projectId, selectedIds);\n      return projectId;\n    },\n    onSuccess: (projectId) => {\n      const project = projects?.find(p => p.id === projectId);\n      invalidateProjectQueries();\n      showToast(`Added ${selectedIds.length} archive${selectedIds.length !== 1 ? 's' : ''} to \"${project?.name}\"`);\n      onClose();\n    },\n    onError: () => {\n      showToast('Failed to assign project', 'error');\n    },\n  });\n\n  // Remove from project mutation (updates each archive individually)\n  const removeMutation = useMutation({\n    mutationFn: async () => {\n      for (const id of selectedIds) {\n        await api.updateArchive(id, { project_id: null });\n      }\n      return selectedIds.length;\n    },\n    onSuccess: (count) => {\n      invalidateProjectQueries();\n      showToast(`Removed ${count} archive${count !== 1 ? 's' : ''} from project`);\n      onClose();\n    },\n    onError: () => {\n      showToast('Failed to remove from project', 'error');\n    },\n  });\n\n  const isPending = assignMutation.isPending || removeMutation.isPending;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n      <Card className=\"w-full max-w-md max-h-[80vh] flex flex-col\">\n        <CardContent className=\"p-0 flex flex-col min-h-0\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0\">\n            <div className=\"flex items-center gap-2\">\n              <FolderKanban className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-xl font-semibold text-white\">\n                Assign to Project\n              </h2>\n            </div>\n            <button\n              onClick={onClose}\n              className=\"text-bambu-gray hover:text-white transition-colors\"\n              disabled={isPending}\n            >\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Content */}\n          <div className=\"p-4 space-y-3 overflow-y-auto min-h-0\">\n            <p className=\"text-sm text-bambu-gray\">\n              Assign {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} to a project\n            </p>\n\n            {isLoading ? (\n              <div className=\"flex items-center justify-center py-8\">\n                <Loader2 className=\"w-6 h-6 animate-spin text-bambu-gray\" />\n              </div>\n            ) : (\n              <div className=\"space-y-2\">\n                {/* Remove from project option */}\n                <button\n                  onClick={() => removeMutation.mutate()}\n                  disabled={isPending}\n                  className=\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50\"\n                >\n                  <div className=\"w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0\">\n                    <XCircle className=\"w-4 h-4 text-red-400\" />\n                  </div>\n                  <div className=\"min-w-0 flex-1\">\n                    <p className=\"text-white font-medium\">Remove from project</p>\n                    <p className=\"text-sm text-bambu-gray truncate\">Clear project assignment</p>\n                  </div>\n                  {removeMutation.isPending && (\n                    <Loader2 className=\"w-4 h-4 animate-spin text-bambu-gray shrink-0\" />\n                  )}\n                </button>\n\n                {/* Divider */}\n                {projects && projects.length > 0 && (\n                  <div className=\"flex items-center gap-2 py-2\">\n                    <div className=\"flex-1 h-px bg-bambu-dark-tertiary\" />\n                    <span className=\"text-xs text-bambu-gray\">or assign to</span>\n                    <div className=\"flex-1 h-px bg-bambu-dark-tertiary\" />\n                  </div>\n                )}\n\n                {/* Project list */}\n                {projects?.map((project) => (\n                  <button\n                    key={project.id}\n                    onClick={() => assignMutation.mutate(project.id)}\n                    disabled={isPending}\n                    className=\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50\"\n                  >\n                    <div\n                      className=\"w-8 h-8 rounded-full flex items-center justify-center shrink-0\"\n                      style={{ backgroundColor: project.color ? `${project.color}20` : 'rgb(var(--bambu-green) / 0.2)' }}\n                    >\n                      <FolderKanban\n                        className=\"w-4 h-4\"\n                        style={{ color: project.color || 'rgb(var(--bambu-green))' }}\n                      />\n                    </div>\n                    <div className=\"min-w-0 flex-1\">\n                      <p className=\"text-white font-medium truncate\">{project.name}</p>\n                      <p className=\"text-sm text-bambu-gray truncate\">\n                        {project.archive_count} archive{project.archive_count !== 1 ? 's' : ''}\n                        {project.status && ` • ${project.status}`}\n                      </p>\n                    </div>\n                    {assignMutation.isPending && assignMutation.variables === project.id && (\n                      <Loader2 className=\"w-4 h-4 animate-spin text-bambu-gray shrink-0\" />\n                    )}\n                  </button>\n                ))}\n\n                {(!projects || projects.length === 0) && (\n                  <p className=\"text-center text-bambu-gray py-4\">\n                    No projects yet. Create one from the Projects page.\n                  </p>\n                )}\n              </div>\n            )}\n          </div>\n\n          {/* Footer */}\n          <div className=\"flex gap-3 p-4 border-t border-bambu-dark-tertiary shrink-0\">\n            <Button variant=\"secondary\" onClick={onClose} className=\"flex-1\" disabled={isPending}>\n              Cancel\n            </Button>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/BatchTagModal.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { X, Tag, Plus, Loader2 } from 'lucide-react';\nimport { api } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\n\ninterface BatchTagModalProps {\n  selectedIds: number[];\n  existingTags: string[];\n  onClose: () => void;\n}\n\nexport function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagModalProps) {\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [newTag, setNewTag] = useState('');\n  const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());\n  const [mode, setMode] = useState<'add' | 'remove'>('add');\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  const batchTagMutation = useMutation({\n    mutationFn: async () => {\n      const tagsArray = Array.from(selectedTags);\n      let successCount = 0;\n\n      // Process sequentially to avoid SQLite database locks\n      for (const id of selectedIds) {\n        try {\n          const archive = await api.getArchive(id);\n          const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];\n\n          let newTags: string[];\n          if (mode === 'add') {\n            // Add tags that aren't already present\n            newTags = [...new Set([...currentTags, ...tagsArray])];\n          } else {\n            // Remove selected tags\n            newTags = currentTags.filter(t => !selectedTags.has(t));\n          }\n\n          await api.updateArchive(id, { tags: newTags.join(', ') });\n          successCount++;\n        } catch (err) {\n          console.error(`Failed to update archive ${id}:`, err);\n          throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);\n        }\n      }\n      return { count: successCount, mode, tags: tagsArray };\n    },\n    onSuccess: ({ count, mode, tags }) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Failed to update tags', 'error');\n    },\n  });\n\n  const toggleTag = (tag: string) => {\n    setSelectedTags((prev) => {\n      const next = new Set(prev);\n      if (next.has(tag)) {\n        next.delete(tag);\n      } else {\n        next.add(tag);\n      }\n      return next;\n    });\n  };\n\n  const addNewTag = () => {\n    if (newTag.trim() && !selectedTags.has(newTag.trim())) {\n      setSelectedTags((prev) => new Set([...prev, newTag.trim()]));\n      setNewTag('');\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      e.preventDefault();\n      addNewTag();\n    }\n  };\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n      <Card className=\"w-full max-w-md\">\n        <CardContent className=\"p-0\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <div className=\"flex items-center gap-2\">\n              <Tag className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-xl font-semibold text-white\">\n                {mode === 'add' ? 'Add Tags' : 'Remove Tags'}\n              </h2>\n            </div>\n            <button\n              onClick={onClose}\n              className=\"text-bambu-gray hover:text-white transition-colors\"\n            >\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Content */}\n          <div className=\"p-4 space-y-4\">\n            <p className=\"text-sm text-bambu-gray\">\n              {mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''}\n            </p>\n\n            {/* Mode toggle */}\n            <div className=\"flex gap-2\">\n              <Button\n                size=\"sm\"\n                variant={mode === 'add' ? 'primary' : 'secondary'}\n                onClick={() => setMode('add')}\n              >\n                Add Tags\n              </Button>\n              <Button\n                size=\"sm\"\n                variant={mode === 'remove' ? 'primary' : 'secondary'}\n                onClick={() => setMode('remove')}\n              >\n                Remove Tags\n              </Button>\n            </div>\n\n            {/* New tag input (only for add mode) */}\n            {mode === 'add' && (\n              <div className=\"flex gap-2\">\n                <input\n                  type=\"text\"\n                  placeholder=\"Enter new tag...\"\n                  className=\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                  value={newTag}\n                  onChange={(e) => setNewTag(e.target.value)}\n                  onKeyDown={handleKeyDown}\n                />\n                <Button size=\"sm\" variant=\"secondary\" onClick={addNewTag} disabled={!newTag.trim()}>\n                  <Plus className=\"w-4 h-4\" />\n                </Button>\n              </div>\n            )}\n\n            {/* Existing tags */}\n            {existingTags.length > 0 && (\n              <div>\n                <p className=\"text-xs text-bambu-gray mb-2\">Existing tags:</p>\n                <div className=\"flex flex-wrap gap-2\">\n                  {existingTags.map((tag) => (\n                    <button\n                      key={tag}\n                      onClick={() => toggleTag(tag)}\n                      className={`px-2 py-1 rounded text-sm transition-colors ${\n                        selectedTags.has(tag)\n                          ? 'bg-bambu-green text-white'\n                          : 'bg-bambu-dark-tertiary text-bambu-gray-light hover:bg-bambu-dark'\n                      }`}\n                    >\n                      {tag}\n                    </button>\n                  ))}\n                </div>\n              </div>\n            )}\n\n            {/* Selected tags preview */}\n            {selectedTags.size > 0 && (\n              <div>\n                <p className=\"text-xs text-bambu-gray mb-2\">\n                  Tags to {mode === 'add' ? 'add' : 'remove'}:\n                </p>\n                <div className=\"flex flex-wrap gap-2\">\n                  {Array.from(selectedTags).map((tag) => (\n                    <span\n                      key={tag}\n                      className={`px-2 py-1 rounded text-sm flex items-center gap-1 ${\n                        mode === 'add' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'\n                      }`}\n                    >\n                      {tag}\n                      <button onClick={() => toggleTag(tag)} className=\"hover:opacity-70\">\n                        <X className=\"w-3 h-3\" />\n                      </button>\n                    </span>\n                  ))}\n                </div>\n              </div>\n            )}\n          </div>\n\n          {/* Footer */}\n          <div className=\"flex gap-3 p-4 border-t border-bambu-dark-tertiary\">\n            <Button variant=\"secondary\" onClick={onClose} className=\"flex-1\">\n              Cancel\n            </Button>\n            <Button\n              onClick={() => batchTagMutation.mutate()}\n              disabled={selectedTags.size === 0 || batchTagMutation.isPending}\n              className=\"flex-1\"\n            >\n              {batchTagMutation.isPending ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  Processing...\n                </>\n              ) : (\n                <>\n                  <Tag className=\"w-4 h-4\" />\n                  {mode === 'add' ? 'Add Tags' : 'Remove Tags'}\n                </>\n              )}\n            </Button>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/BugReportBubble.tsx",
    "content": "import { useState, useRef, useCallback, useEffect } from 'react';\nimport { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload, Circle, CheckCircle2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { bugReportApi } from '../api/client';\n\ntype ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error';\n\nconst MAX_DIMENSION = 1920;\nconst JPEG_QUALITY = 0.7;\nconst MAX_LOG_SECONDS = 300; // 5 minutes\n\nfunction compressImage(file: File): Promise<string> {\n  return new Promise((resolve, reject) => {\n    const img = new Image();\n    img.onload = () => {\n      let { width, height } = img;\n      if (width > MAX_DIMENSION || height > MAX_DIMENSION) {\n        const scale = MAX_DIMENSION / Math.max(width, height);\n        width = Math.round(width * scale);\n        height = Math.round(height * scale);\n      }\n      const canvas = document.createElement('canvas');\n      canvas.width = width;\n      canvas.height = height;\n      const ctx = canvas.getContext('2d');\n      if (!ctx) { reject(new Error('No canvas context')); return; }\n      ctx.drawImage(img, 0, 0, width, height);\n      const dataUrl = canvas.toDataURL('image/jpeg', JPEG_QUALITY);\n      resolve(dataUrl.replace(/^data:[^;]+;base64,/, ''));\n    };\n    img.onerror = reject;\n    img.src = URL.createObjectURL(file);\n  });\n}\n\nfunction formatElapsed(seconds: number): string {\n  const m = Math.floor(seconds / 60);\n  const s = seconds % 60;\n  return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;\n}\n\nexport function BugReportBubble() {\n  const { t } = useTranslation();\n  const [isOpen, setIsOpen] = useState(false);\n  const [viewState, setViewState] = useState<ViewState>('form');\n  const [description, setDescription] = useState('');\n  const [email, setEmail] = useState('');\n  const [screenshot, setScreenshot] = useState<string | null>(null);\n  const [isDragging, setIsDragging] = useState(false);\n  const [issueUrl, setIssueUrl] = useState<string | null>(null);\n  const [issueNumber, setIssueNumber] = useState<number | null>(null);\n  const [errorMessage, setErrorMessage] = useState('');\n  const [elapsedSeconds, setElapsedSeconds] = useState(0);\n  const [wasDebug, setWasDebug] = useState(false);\n  const modalRef = useRef<HTMLDivElement>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const handleStopLoggingRef = useRef<() => void>(() => {});\n\n  // Elapsed timer for logging phase — auto-stop at 5 minutes\n  useEffect(() => {\n    if (viewState !== 'logging') return;\n    if (elapsedSeconds >= MAX_LOG_SECONDS) {\n      handleStopLoggingRef.current();\n      return;\n    }\n    const timer = setTimeout(() => setElapsedSeconds((s) => s + 1), 1000);\n    return () => clearTimeout(timer);\n  }, [viewState, elapsedSeconds]);\n\n  const handleOpen = () => {\n    setIsOpen(true);\n    setViewState('form');\n    setDescription('');\n    setEmail('');\n    setScreenshot(null);\n    setIssueUrl(null);\n    setIssueNumber(null);\n    setErrorMessage('');\n    setElapsedSeconds(0);\n    setWasDebug(false);\n  };\n\n  const handleClose = () => {\n    setIsOpen(false);\n  };\n\n  const handleFile = useCallback(async (file: File) => {\n    if (!file.type.startsWith('image/')) return;\n    try {\n      const b64 = await compressImage(file);\n      setScreenshot(b64);\n    } catch {\n      // Ignore read errors\n    }\n  }, []);\n\n  const handlePaste = useCallback((e: React.ClipboardEvent) => {\n    const items = e.clipboardData?.items;\n    if (!items) return;\n    for (const item of items) {\n      if (item.type.startsWith('image/')) {\n        const file = item.getAsFile();\n        if (file) handleFile(file);\n        break;\n      }\n    }\n  }, [handleFile]);\n\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(true);\n  }, []);\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(false);\n  }, []);\n\n  const handleDrop = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(false);\n    const file = e.dataTransfer.files?.[0];\n    if (file) handleFile(file);\n  }, [handleFile]);\n\n  const handleStartLogging = async () => {\n    if (!description.trim()) return;\n    try {\n      const result = await bugReportApi.startLogging();\n      setWasDebug(result.was_debug);\n      setElapsedSeconds(0);\n      setViewState('logging');\n    } catch (err) {\n      setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError'));\n      setViewState('error');\n    }\n  };\n\n  const handleStopLogging = async () => {\n    setViewState('stopping');\n    try {\n      const stopResult = await bugReportApi.stopLogging(wasDebug);\n      await handleSubmitReport(stopResult.logs);\n    } catch (err) {\n      setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError'));\n      setViewState('error');\n    }\n  };\n  handleStopLoggingRef.current = handleStopLogging;\n\n  const handleSubmitReport = async (debugLogs: string) => {\n    setViewState('submitting');\n    try {\n      const result = await bugReportApi.submit({\n        description: description.trim(),\n        email: email.trim() || undefined,\n        screenshot_base64: screenshot || undefined,\n        include_support_info: true,\n        debug_logs: debugLogs || undefined,\n      });\n      if (result.success) {\n        setIssueUrl(result.issue_url || null);\n        setIssueNumber(result.issue_number || null);\n        setViewState('success');\n      } else {\n        setErrorMessage(result.message);\n        setViewState('error');\n      }\n    } catch (err) {\n      setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError'));\n      setViewState('error');\n    }\n  };\n\n  return (\n    <>\n      {/* Floating bubble */}\n      <button\n        onClick={handleOpen}\n        className=\"fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center\"\n        title={t('bugReport.title')}\n      >\n        <Bug className=\"w-5 h-5\" />\n      </button>\n\n      {/* Slide-in panel anchored to bottom-right */}\n      {isOpen && (\n        <div\n          id=\"bug-report-modal\"\n          className=\"fixed bottom-20 right-4 z-50 w-full max-w-md\"\n          onPaste={handlePaste}\n        >\n          <div\n            ref={modalRef}\n            className=\"bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] overflow-y-auto\"\n          >\n            {/* Header */}\n            <div className=\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10\">\n              <h2 className=\"text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2\">\n                <Bug className=\"w-5 h-5 text-red-500\" />\n                {t('bugReport.title')}\n              </h2>\n              <button\n                onClick={handleClose}\n                className=\"text-gray-400 hover:text-gray-600 dark:hover:text-gray-300\"\n              >\n                <X className=\"w-5 h-5\" />\n              </button>\n            </div>\n\n            <div className=\"p-4 space-y-4\">\n              {viewState === 'form' && (\n                <>\n                  {/* Description */}\n                  <div>\n                    <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                      {t('bugReport.description')} *\n                    </label>\n                    <textarea\n                      value={description}\n                      onChange={(e) => setDescription(e.target.value)}\n                      placeholder={t('bugReport.descriptionPlaceholder')}\n                      rows={3}\n                      className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical\"\n                    />\n                  </div>\n\n                  {/* Email (optional) */}\n                  <div>\n                    <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                      {t('bugReport.email')}\n                    </label>\n                    <input\n                      type=\"email\"\n                      value={email}\n                      onChange={(e) => setEmail(e.target.value)}\n                      placeholder={t('bugReport.emailPlaceholder')}\n                      className=\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent\"\n                    />\n                    <p className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n                      {t('bugReport.emailPrivacy')}\n                    </p>\n                  </div>\n\n                  {/* Screenshot — upload, paste, or drag */}\n                  <div>\n                    <label className=\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\">\n                      {t('bugReport.screenshot')}\n                    </label>\n                    {screenshot ? (\n                      <div className=\"relative\">\n                        <img\n                          src={`data:image/jpeg;base64,${screenshot}`}\n                          alt={t('bugReport.screenshot')}\n                          className=\"w-full max-h-40 object-contain rounded-lg border border-gray-200 dark:border-gray-600\"\n                        />\n                        <button\n                          onClick={() => setScreenshot(null)}\n                          className=\"absolute top-2 right-2 p-1 bg-red-500 hover:bg-red-600 text-white rounded-full shadow\"\n                          title={t('common.delete')}\n                        >\n                          <Trash2 className=\"w-3 h-3\" />\n                        </button>\n                      </div>\n                    ) : (\n                      <button\n                        type=\"button\"\n                        onClick={() => fileInputRef.current?.click()}\n                        onDragOver={handleDragOver}\n                        onDragLeave={handleDragLeave}\n                        onDrop={handleDrop}\n                        className={`w-full flex flex-col items-center gap-2 px-4 py-4 border-2 border-dashed rounded-lg transition-colors cursor-pointer ${\n                          isDragging\n                            ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'\n                            : 'border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-gray-400 dark:hover:border-gray-500 hover:text-gray-600 dark:hover:text-gray-300'\n                        }`}\n                      >\n                        <Upload className=\"w-5 h-5\" />\n                        <span className=\"text-sm\">{t('bugReport.uploadOrPaste')}</span>\n                      </button>\n                    )}\n                    <input\n                      ref={fileInputRef}\n                      type=\"file\"\n                      accept=\"image/*\"\n                      className=\"hidden\"\n                      onChange={(e) => {\n                        const file = e.target.files?.[0];\n                        if (file) handleFile(file);\n                        e.target.value = '';\n                      }}\n                    />\n                  </div>\n\n                  {/* Data collection notice */}\n                  <details className=\"text-xs bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3\">\n                    <summary className=\"cursor-pointer font-medium text-amber-700 dark:text-amber-300 hover:text-amber-800 dark:hover:text-amber-200\">\n                      {t('bugReport.dataCollectedSummary')}\n                    </summary>\n                    <div className=\"mt-2 space-y-2 pl-2 border-l-2 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200\">\n                      <p className=\"font-medium\">{t('bugReport.dataIncluded')}</p>\n                      <p>{t('bugReport.dataIncludedList')}</p>\n                      <p className=\"font-medium\">{t('bugReport.dataNeverIncluded')}</p>\n                      <p>{t('bugReport.dataNeverIncludedList')}</p>\n                    </div>\n                  </details>\n\n                  {/* Buttons */}\n                  <div className=\"flex justify-end gap-2 pt-2\">\n                    <button\n                      onClick={handleClose}\n                      className=\"px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors\"\n                    >\n                      {t('common.cancel')}\n                    </button>\n                    <button\n                      onClick={handleStartLogging}\n                      disabled={!description.trim()}\n                      className=\"px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors\"\n                    >\n                      {t('bugReport.startLogging')}\n                    </button>\n                  </div>\n                </>\n              )}\n\n              {viewState === 'logging' && (\n                <div className=\"py-6 space-y-6\">\n                  {/* 3-step progress indicator */}\n                  <div className=\"space-y-3 px-2\">\n                    {/* Step 1: Completed */}\n                    <div className=\"flex items-center gap-3\">\n                      <CheckCircle2 className=\"w-5 h-5 text-green-500 flex-shrink-0\" />\n                      <span className=\"text-sm text-green-700 dark:text-green-400\">{t('bugReport.stepEnableLogging')}</span>\n                    </div>\n                    {/* Step 2: Active */}\n                    <div className=\"flex items-center gap-3\">\n                      <span className=\"relative flex h-5 w-5 flex-shrink-0 items-center justify-center\">\n                        <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75\"></span>\n                        <span className=\"relative inline-flex rounded-full h-3 w-3 bg-blue-500\"></span>\n                      </span>\n                      <span className=\"text-sm font-medium text-blue-700 dark:text-blue-300\">{t('bugReport.stepReproduce')}</span>\n                    </div>\n                    {/* Step 3: Upcoming */}\n                    <div className=\"flex items-center gap-3\">\n                      <Circle className=\"w-5 h-5 text-gray-300 dark:text-gray-600 flex-shrink-0\" />\n                      <span className=\"text-sm text-gray-400 dark:text-gray-500\">{t('bugReport.stepStopLogging')}</span>\n                    </div>\n                  </div>\n\n                  {/* Elapsed timer */}\n                  <div className=\"text-center\">\n                    <p className=\"text-3xl font-mono text-blue-500\">{formatElapsed(elapsedSeconds)}</p>\n                    <p className=\"text-xs text-gray-500 dark:text-gray-400 mt-1\">{t('bugReport.maxDuration', { minutes: 5 })}</p>\n                  </div>\n\n                  {/* Stop & Submit button */}\n                  <div className=\"flex justify-center\">\n                    <button\n                      onClick={handleStopLogging}\n                      className=\"px-6 py-2.5 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors\"\n                    >\n                      {t('bugReport.stopAndSubmit')}\n                    </button>\n                  </div>\n                </div>\n              )}\n\n              {(viewState === 'stopping' || viewState === 'submitting') && (\n                <div className=\"flex flex-col items-center justify-center py-8 gap-3\">\n                  <Loader2 className=\"w-8 h-8 animate-spin text-blue-500\" />\n                  <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n                    {viewState === 'stopping' ? t('bugReport.stoppingLogs') : t('bugReport.submitting')}\n                  </p>\n                </div>\n              )}\n\n              {viewState === 'success' && (\n                <div className=\"flex flex-col items-center justify-center py-8 gap-3\">\n                  <CheckCircle className=\"w-12 h-12 text-green-500\" />\n                  <p className=\"text-lg font-semibold text-gray-900 dark:text-white\">{t('bugReport.thankYou')}</p>\n                  <p className=\"text-sm text-gray-600 dark:text-gray-400\">{t('bugReport.submitted')}</p>\n                  {issueUrl && (\n                    <a\n                      href={issueUrl}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"text-sm text-blue-500 hover:text-blue-600 underline\"\n                    >\n                      {t('bugReport.viewIssue')} #{issueNumber}\n                    </a>\n                  )}\n                  <button\n                    onClick={handleClose}\n                    className=\"mt-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors\"\n                  >\n                    {t('common.close')}\n                  </button>\n                </div>\n              )}\n\n              {viewState === 'error' && (\n                <div className=\"flex flex-col items-center justify-center py-8 gap-3\">\n                  <AlertCircle className=\"w-12 h-12 text-red-500\" />\n                  <p className=\"text-lg font-semibold text-gray-900 dark:text-white\">{t('bugReport.submitFailed')}</p>\n                  <p className=\"text-sm text-gray-600 dark:text-gray-400 text-center\">{errorMessage}</p>\n                  <div className=\"flex gap-2 mt-4\">\n                    <button\n                      onClick={() => setViewState('form')}\n                      className=\"px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors\"\n                    >\n                      {t('bugReport.submit')}\n                    </button>\n                    <button\n                      onClick={handleClose}\n                      className=\"px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors\"\n                    >\n                      {t('common.close')}\n                    </button>\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/BulkPrinterToolbar.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { useAuth } from '../contexts/AuthContext';\nimport {\n  X,\n  Square,\n  Pause,\n  Play,\n  ChevronDown,\n  BellOff,\n  Eraser,\n} from 'lucide-react';\nimport { Button } from './Button';\nimport { filterKnownHMSErrors } from './HMSErrorModal';\nimport type { Printer, HMSError } from '../api/client';\n\nexport type BulkAction = 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS';\nexport type PrinterState = 'printing' | 'paused' | 'finished' | 'idle' | 'error' | 'offline';\n\ninterface PrinterStatus {\n  connected: boolean;\n  state: string | null;\n  hms_errors?: HMSError[];\n  awaiting_plate_clear?: boolean;\n}\n\ninterface BulkPrinterToolbarProps {\n  selectedIds: Set<number>;\n  printers: Printer[];\n  onClose: () => void;\n  onSelectAll: () => void;\n  onSelectByLocation: (location: string) => void;\n  onSelectByState: (state: PrinterState) => void;\n  onAction: (action: BulkAction) => void;\n  actionPending: boolean;\n}\n\nconst STATE_OPTIONS: { key: PrinterState; dot: string }[] = [\n  { key: 'printing', dot: 'bg-bambu-green' },\n  { key: 'paused', dot: 'bg-status-warning' },\n  { key: 'finished', dot: 'bg-blue-400' },\n  { key: 'idle', dot: 'bg-bambu-green' },\n  { key: 'error', dot: 'bg-status-error' },\n  { key: 'offline', dot: 'bg-gray-400' },\n];\n\nexport function BulkPrinterToolbar({\n  selectedIds,\n  printers,\n  onClose,\n  onSelectAll,\n  onSelectByLocation,\n  onSelectByState,\n  onAction,\n  actionPending,\n}: BulkPrinterToolbarProps) {\n  const { t } = useTranslation();\n  const { hasPermission } = useAuth();\n  const queryClient = useQueryClient();\n  const [showLocationDropdown, setShowLocationDropdown] = useState(false);\n  const [showStateDropdown, setShowStateDropdown] = useState(false);\n\n  // Read cached statuses for selected printers\n  const selectedStatuses = Array.from(selectedIds).map(id => ({\n    id,\n    status: queryClient.getQueryData<PrinterStatus>(['printerStatus', id]),\n  }));\n\n  // Smart enablement: check if any selected printer is in the right state\n  const anyRunning = selectedStatuses.some(\n    ({ status }) => status?.connected && status.state === 'RUNNING',\n  );\n  const anyPaused = selectedStatuses.some(\n    ({ status }) => status?.connected && status.state === 'PAUSE',\n  );\n  const anyStoppable = anyRunning || anyPaused;\n  const anyNeedsClearPlate = selectedStatuses.some(\n    ({ status }) => !!(status?.connected && status.awaiting_plate_clear),\n  );\n  const anyWithHMS = selectedStatuses.some(({ status }) => {\n    if (!status?.connected || !status.hms_errors) return false;\n    return filterKnownHMSErrors(status.hms_errors).length > 0;\n  });\n\n  const canControl = hasPermission('printers:control');\n  const canClearPlate = hasPermission('printers:clear_plate');\n\n  // Unique locations from all printers (not just selected)\n  const locations = [...new Set(printers.map(p => p.location).filter((l): l is string => !!l))].sort();\n\n  // Count printers per state for the state dropdown\n  const stateCounts: Record<PrinterState, number> = { printing: 0, paused: 0, finished: 0, idle: 0, error: 0, offline: 0 };\n  printers.forEach(p => {\n    const status = queryClient.getQueryData<PrinterStatus>(['printerStatus', p.id]);\n    if (!status || !status.connected) { stateCounts.offline++; return; }\n    if (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0) stateCounts.error++;\n    switch (status.state) {\n      case 'RUNNING': stateCounts.printing++; break;\n      case 'PAUSE': stateCounts.paused++; break;\n      case 'FINISH': stateCounts.finished++; break;\n      case 'FAILED': stateCounts.error++; break;\n      default: stateCounts.idle++; break;\n    }\n  });\n\n  const stateLabels: Record<PrinterState, string> = {\n    printing: t('printers.status.printing'),\n    paused: t('printers.status.paused', 'Paused'),\n    finished: t('printers.status.finished', 'Finished'),\n    idle: t('printers.status.idle'),\n    error: t('printers.status.problem'),\n    offline: t('printers.status.offline'),\n  };\n\n  return (\n    <div className=\"fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-3 flex-wrap\">\n      {/* Close */}\n      <Button variant=\"secondary\" size=\"sm\" onClick={onClose}>\n        <X className=\"w-4 h-4\" />\n      </Button>\n\n      <div className=\"w-px h-6 bg-bambu-dark-tertiary\" />\n\n      {/* Selection count */}\n      <span className=\"text-white font-medium text-sm\">\n        {t('printers.bulk.selected', { count: selectedIds.size })}\n      </span>\n\n      <div className=\"w-px h-6 bg-bambu-dark-tertiary\" />\n\n      {/* Select All */}\n      <Button variant=\"secondary\" size=\"sm\" onClick={onSelectAll}>\n        {t('printers.bulk.selectAll')}\n      </Button>\n\n      {/* Select by State */}\n      <div className=\"relative\">\n        <Button\n          variant=\"secondary\"\n          size=\"sm\"\n          onClick={() => { setShowStateDropdown(!showStateDropdown); setShowLocationDropdown(false); }}\n        >\n          {t('printers.bulk.selectByState')}\n          <ChevronDown className={`w-3 h-3 transition-transform ${showStateDropdown ? 'rotate-180' : ''}`} />\n        </Button>\n        {showStateDropdown && (\n          <>\n            <div\n              className=\"fixed inset-0 z-10\"\n              onClick={() => setShowStateDropdown(false)}\n            />\n            <div className=\"absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1\">\n              {STATE_OPTIONS.filter(({ key }) => stateCounts[key] > 0).map(({ key, dot }) => (\n                <button\n                  key={key}\n                  onClick={() => {\n                    onSelectByState(key);\n                    setShowStateDropdown(false);\n                  }}\n                  className=\"w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2\"\n                >\n                  <div className={`w-2 h-2 rounded-full ${dot}`} />\n                  {stateLabels[key]}\n                  <span className=\"ml-auto text-bambu-gray text-xs\">{stateCounts[key]}</span>\n                </button>\n              ))}\n            </div>\n          </>\n        )}\n      </div>\n\n      {/* Select by Location */}\n      {locations.length > 0 && (\n        <div className=\"relative\">\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={() => { setShowLocationDropdown(!showLocationDropdown); setShowStateDropdown(false); }}\n          >\n            {t('printers.bulk.selectByLocation')}\n            <ChevronDown className={`w-3 h-3 transition-transform ${showLocationDropdown ? 'rotate-180' : ''}`} />\n          </Button>\n          {showLocationDropdown && (\n            <>\n              <div\n                className=\"fixed inset-0 z-10\"\n                onClick={() => setShowLocationDropdown(false)}\n              />\n              <div className=\"absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1\">\n                {locations.map(location => (\n                  <button\n                    key={location}\n                    onClick={() => {\n                      onSelectByLocation(location);\n                      setShowLocationDropdown(false);\n                    }}\n                    className=\"w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors\"\n                  >\n                    {location}\n                  </button>\n                ))}\n              </div>\n            </>\n          )}\n        </div>\n      )}\n\n      <div className=\"w-px h-6 bg-bambu-dark-tertiary\" />\n\n      {/* Action buttons */}\n      <Button\n        size=\"sm\"\n        className=\"bg-red-500 hover:bg-red-600\"\n        onClick={() => onAction('stop')}\n        disabled={actionPending || !canControl || !anyStoppable}\n        title={!canControl ? t('printers.permission.noControl') : !anyStoppable ? t('printers.bulk.noneApplicable') : undefined}\n      >\n        <Square className=\"w-3.5 h-3.5\" />\n        {t('printers.bulk.actions.stop')}\n      </Button>\n\n      <Button\n        variant=\"secondary\"\n        size=\"sm\"\n        onClick={() => onAction('pause')}\n        disabled={actionPending || !canControl || !anyRunning}\n        title={!canControl ? t('printers.permission.noControl') : !anyRunning ? t('printers.bulk.noneApplicable') : undefined}\n      >\n        <Pause className=\"w-3.5 h-3.5\" />\n        {t('printers.bulk.actions.pause')}\n      </Button>\n\n      <Button\n        variant=\"secondary\"\n        size=\"sm\"\n        onClick={() => onAction('resume')}\n        disabled={actionPending || !canControl || !anyPaused}\n        title={!canControl ? t('printers.permission.noControl') : !anyPaused ? t('printers.bulk.noneApplicable') : undefined}\n      >\n        <Play className=\"w-3.5 h-3.5\" />\n        {t('printers.bulk.actions.resume')}\n      </Button>\n\n      <Button\n        variant=\"secondary\"\n        size=\"sm\"\n        onClick={() => onAction('clearHMS')}\n        disabled={actionPending || !canControl || !anyWithHMS}\n        title={!canControl ? t('printers.permission.noControl') : !anyWithHMS ? t('printers.bulk.noneApplicable') : undefined}\n      >\n        <BellOff className=\"w-3.5 h-3.5\" />\n        {t('printers.bulk.actions.clearHMS')}\n      </Button>\n\n      <Button\n        variant=\"secondary\"\n        size=\"sm\"\n        onClick={() => onAction('clearPlate')}\n        disabled={actionPending || !canClearPlate || !anyNeedsClearPlate}\n        title={!canClearPlate ? t('printers.permission.noControl') : !anyNeedsClearPlate ? t('printers.bulk.noneApplicable') : undefined}\n      >\n        <Eraser className=\"w-3.5 h-3.5\" />\n        {t('printers.bulk.actions.clearPlate')}\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/Button.tsx",
    "content": "import type { ButtonHTMLAttributes, ReactNode } from 'react';\n\ninterface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';\n  size?: 'sm' | 'md' | 'lg';\n  children: ReactNode;\n}\n\nexport function Button({\n  variant = 'primary',\n  size = 'md',\n  className = '',\n  children,\n  ...props\n}: ButtonProps) {\n  const baseStyles =\n    'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed';\n\n  const variants = {\n    primary: 'bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green',\n    secondary:\n      'bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray',\n    danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',\n    ghost:\n      'bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white',\n  };\n\n  const sizes = {\n    sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0',\n    md: 'px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0',\n    lg: 'px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0',\n  };\n\n  return (\n    <button\n      className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}\n      {...props}\n    >\n      {children}\n    </button>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/CalendarView.tsx",
    "content": "import { useState, useMemo } from 'react';\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport type { Archive } from '../api/client';\nimport { api } from '../api/client';\nimport { parseUTCDate } from '../utils/date';\n\ninterface CalendarViewProps {\n  archives: Archive[];\n  onArchiveClick?: (archive: Archive) => void;\n  highlightedArchiveId?: number | null;\n}\n\nfunction getDaysInMonth(year: number, month: number): number {\n  return new Date(year, month + 1, 0).getDate();\n}\n\nfunction getFirstDayOfMonth(year: number, month: number): number {\n  return new Date(year, month, 1).getDay();\n}\n\nconst MONTH_NAMES = [\n  'January', 'February', 'March', 'April', 'May', 'June',\n  'July', 'August', 'September', 'October', 'November', 'December'\n];\n\nconst DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];\n\nexport function CalendarView({ archives, onArchiveClick, highlightedArchiveId }: CalendarViewProps) {\n  const today = new Date();\n  const [currentMonth, setCurrentMonth] = useState(today.getMonth());\n  const [currentYear, setCurrentYear] = useState(today.getFullYear());\n  const [selectedDate, setSelectedDate] = useState<string | null>(null);\n  const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);\n\n  // Group archives by date (using local timezone from UTC timestamps)\n  const archivesByDate = useMemo(() => {\n    const map = new Map<string, Archive[]>();\n    archives.forEach(archive => {\n      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();\n      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;\n      const existing = map.get(key) || [];\n      existing.push(archive);\n      map.set(key, existing);\n    });\n    return map;\n  }, [archives]);\n\n  const daysInMonth = getDaysInMonth(currentYear, currentMonth);\n  const firstDay = getFirstDayOfMonth(currentYear, currentMonth);\n\n  const prevMonth = () => {\n    if (currentMonth === 0) {\n      setCurrentMonth(11);\n      setCurrentYear(currentYear - 1);\n    } else {\n      setCurrentMonth(currentMonth - 1);\n    }\n  };\n\n  const nextMonth = () => {\n    if (currentMonth === 11) {\n      setCurrentMonth(0);\n      setCurrentYear(currentYear + 1);\n    } else {\n      setCurrentMonth(currentMonth + 1);\n    }\n  };\n\n  const goToToday = () => {\n    setCurrentMonth(today.getMonth());\n    setCurrentYear(today.getFullYear());\n  };\n\n  // Build calendar grid\n  const calendarDays: (number | null)[] = [];\n  for (let i = 0; i < firstDay; i++) {\n    calendarDays.push(null);\n  }\n  for (let day = 1; day <= daysInMonth; day++) {\n    calendarDays.push(day);\n  }\n\n  const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];\n\n  // Clear selected archive when date changes\n  const handleDateSelect = (dateKey: string | null) => {\n    if (dateKey !== selectedDate) {\n      setSelectedArchiveId(null);\n    }\n    setSelectedDate(dateKey);\n  };\n\n  return (\n    <div className=\"flex flex-col lg:flex-row gap-6\">\n      {/* Calendar */}\n      <div className=\"flex-1\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between mb-4\">\n          <button\n            onClick={prevMonth}\n            className=\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n          >\n            <ChevronLeft className=\"w-5 h-5 text-bambu-gray\" />\n          </button>\n          <div className=\"flex items-center gap-3\">\n            <h2 className=\"text-lg font-semibold text-white\">\n              {MONTH_NAMES[currentMonth]} {currentYear}\n            </h2>\n            <button\n              onClick={goToToday}\n              className=\"px-2 py-1 text-xs bg-bambu-dark-tertiary hover:bg-bambu-green/20 text-bambu-gray hover:text-white rounded transition-colors\"\n            >\n              Today\n            </button>\n          </div>\n          <button\n            onClick={nextMonth}\n            className=\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n          >\n            <ChevronRight className=\"w-5 h-5 text-bambu-gray\" />\n          </button>\n        </div>\n\n        {/* Day headers */}\n        <div className=\"grid grid-cols-7 gap-1 mb-1\">\n          {DAY_NAMES.map(day => (\n            <div key={day} className=\"text-center text-xs text-bambu-gray py-2\">\n              {day}\n            </div>\n          ))}\n        </div>\n\n        {/* Calendar grid */}\n        <div className=\"grid grid-cols-7 gap-1\">\n          {calendarDays.map((day, index) => {\n            if (day === null) {\n              return <div key={`empty-${index}`} className=\"aspect-square\" />;\n            }\n\n            const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;\n            const dayArchives = archivesByDate.get(dateKey) || [];\n            const hasArchives = dayArchives.length > 0;\n            const isToday = day === today.getDate() && currentMonth === today.getMonth() && currentYear === today.getFullYear();\n            const isSelected = dateKey === selectedDate;\n            const successCount = dayArchives.filter(a => a.status === 'completed').length;\n            const failedCount = dayArchives.filter(a => a.status === 'failed').length;\n\n            return (\n              <button\n                key={day}\n                onClick={() => handleDateSelect(isSelected ? null : dateKey)}\n                className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${\n                  isSelected\n                    ? 'bg-bambu-green text-white'\n                    : isToday\n                    ? 'bg-bambu-green/20 text-white ring-2 ring-bambu-green'\n                    : hasArchives\n                    ? 'bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 text-white'\n                    : 'hover:bg-bambu-dark-tertiary/50 text-bambu-gray'\n                }`}\n              >\n                <span className={`text-sm font-medium ${isToday && !isSelected ? 'text-bambu-green' : ''}`}>\n                  {day}\n                </span>\n                {hasArchives && (\n                  <div className=\"absolute bottom-1 left-1/2 -translate-x-1/2 flex items-center gap-1\">\n                    <div className={`w-2 h-2 rounded-full ${\n                      failedCount > 0 && successCount === 0\n                        ? 'bg-red-400'\n                        : failedCount > 0\n                        ? 'bg-yellow-400'\n                        : 'bg-green-400'\n                    }`} />\n                    <span className=\"text-xs font-medium\">{dayArchives.length}</span>\n                  </div>\n                )}\n              </button>\n            );\n          })}\n        </div>\n\n        {/* Monthly stats */}\n        <div className=\"mt-4 pt-4 border-t border-bambu-dark-tertiary\">\n          <div className=\"grid grid-cols-3 gap-4 text-center\">\n            <div>\n              <div className=\"text-2xl font-bold text-white\">\n                {archives.filter(a => {\n                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();\n                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear;\n                }).length}\n              </div>\n              <div className=\"text-xs text-bambu-gray\">Prints this month</div>\n            </div>\n            <div>\n              <div className=\"text-2xl font-bold text-green-400\">\n                {archives.filter(a => {\n                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();\n                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed';\n                }).length}\n              </div>\n              <div className=\"text-xs text-bambu-gray\">Successful</div>\n            </div>\n            <div>\n              <div className=\"text-2xl font-bold text-red-400\">\n                {archives.filter(a => {\n                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();\n                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed';\n                }).length}\n              </div>\n              <div className=\"text-xs text-bambu-gray\">Failed</div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Selected day details */}\n      <div className=\"lg:w-80 bg-bambu-dark rounded-xl p-4\">\n        {selectedDate ? (\n          <>\n            <h3 className=\"text-sm font-medium text-bambu-gray mb-3\">\n              {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', {\n                weekday: 'long',\n                month: 'long',\n                day: 'numeric',\n                year: 'numeric'\n              })}\n            </h3>\n            {selectedArchives.length > 0 ? (\n              <div className=\"calendar-scroll space-y-2 max-h-96 overflow-y-auto\">\n                {selectedArchives.map(archive => {\n                  const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;\n                  return (\n                  <button\n                    key={archive.id}\n                    onClick={() => {\n                      setSelectedArchiveId(archive.id);\n                      onArchiveClick?.(archive);\n                    }}\n                    className={`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${\n                      !isHighlighted ? 'hover:bg-bambu-dark-tertiary' : ''\n                    }`}\n                    style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}\n                  >\n                    {archive.thumbnail_path ? (\n                      <img\n                        src={api.getArchiveThumbnail(archive.id)}\n                        alt=\"\"\n                        className=\"w-12 h-12 rounded object-cover\"\n                      />\n                    ) : (\n                      <div className=\"w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center\">\n                        <span className=\"text-xs text-bambu-gray\">3MF</span>\n                      </div>\n                    )}\n                    <div className=\"flex-1 min-w-0\">\n                      <p className=\"text-sm text-white truncate\">\n                        {archive.print_name || archive.filename}\n                      </p>\n                      <div className=\"flex items-center gap-2 text-xs\">\n                        <span className={archive.status === 'failed' ? 'text-red-400' : 'text-green-400'}>\n                          {archive.status === 'failed' ? 'Failed' : 'Completed'}\n                        </span>\n                        {archive.filament_color && (\n                          <div className=\"flex gap-0.5\">\n                            {archive.filament_color.split(',').map((color, i) => (\n                              <div\n                                key={i}\n                                className=\"w-3 h-3 rounded-full border border-black/20\"\n                                style={{ backgroundColor: color }}\n                              />\n                            ))}\n                          </div>\n                        )}\n                      </div>\n                    </div>\n                  </button>\n                  );\n                })}\n              </div>\n            ) : (\n              <p className=\"text-sm text-bambu-gray\">No prints on this day</p>\n            )}\n          </>\n        ) : (\n          <div className=\"text-center py-8\">\n            <p className=\"text-sm text-bambu-gray\">Select a day to see prints</p>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/Card.tsx",
    "content": "import { createContext, useContext } from 'react';\nimport type { ReactNode, MouseEvent, HTMLAttributes } from 'react';\n\ntype CardDensity = 'normal' | 'dense';\n\nconst CardDensityContext = createContext<CardDensity>('normal');\n\nexport function CardDensityProvider({ density, children }: { density: CardDensity; children: ReactNode }) {\n  return <CardDensityContext.Provider value={density}>{children}</CardDensityContext.Provider>;\n}\n\ninterface CardProps extends HTMLAttributes<HTMLDivElement> {\n  children: ReactNode;\n  className?: string;\n  onClick?: (e: MouseEvent) => void;\n  onContextMenu?: (e: MouseEvent) => void;\n}\n\ninterface CardSectionProps {\n  children: ReactNode;\n  className?: string;\n  dense?: boolean;\n}\n\nexport function Card({ children, className = '', onClick, onContextMenu, ...rest }: CardProps) {\n  return (\n    <div\n      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}\n      onClick={onClick}\n      onContextMenu={onContextMenu}\n      {...rest}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport function CardHeader({ children, className = '', dense }: CardSectionProps) {\n  const ctxDense = useContext(CardDensityContext) === 'dense';\n  const isDense = dense ?? ctxDense;\n  const padding = isDense ? 'px-4 py-2.5' : 'px-6 py-4';\n  return (\n    <div className={`${padding} border-b border-bambu-dark-tertiary ${className}`}>\n      {children}\n    </div>\n  );\n}\n\nexport function CardContent({ children, className = '', dense }: CardSectionProps) {\n  const ctxDense = useContext(CardDensityContext) === 'dense';\n  const isDense = dense ?? ctxDense;\n  const padding = isDense ? 'p-4' : 'p-6';\n  return <div className={`${padding} ${className}`}>{children}</div>;\n}\n"
  },
  {
    "path": "frontend/src/components/Collapsible.tsx",
    "content": "import { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ChevronDown } from 'lucide-react';\n\ninterface CollapsibleProps {\n  summary: ReactNode;\n  children: ReactNode;\n  defaultOpen?: boolean;\n  className?: string;\n  summaryClassName?: string;\n  /** When provided, the component is controlled — parent owns the open state. */\n  open?: boolean;\n  /** Called when the user clicks the toggle. Use with `open` for controlled mode. */\n  onToggle?: (open: boolean) => void;\n}\n\n/**\n * Lightweight disclosure widget.\n * Renders a clickable summary row and conditionally displays children.\n *\n * The toggle region is a plain <div> with role=\"button\" so that the summary\n * slot may safely contain interactive elements (buttons, links) without\n * nesting a <button> inside a <button>.\n *\n * Supports both uncontrolled (internal state) and controlled (`open`/`onToggle`) modes.\n */\nexport function Collapsible({\n  summary,\n  children,\n  defaultOpen = false,\n  className = '',\n  summaryClassName = '',\n  open: controlledOpen,\n  onToggle,\n}: CollapsibleProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const isControlled = controlledOpen !== undefined;\n  const isOpen = isControlled ? controlledOpen : internalOpen;\n\n  const handleToggle = () => {\n    const next = !isOpen;\n    if (!isControlled) setInternalOpen(next);\n    onToggle?.(next);\n  };\n\n  return (\n    <div className={className}>\n      <div\n        role=\"button\"\n        tabIndex={0}\n        onClick={handleToggle}\n        onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleToggle(); } }}\n        className={`w-full flex items-center justify-between gap-2 text-left cursor-pointer ${summaryClassName}`}\n        aria-expanded={isOpen}\n      >\n        <div className=\"flex-1 min-w-0\">{summary}</div>\n        <ChevronDown\n          className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}\n        />\n      </div>\n      {isOpen && <div className=\"mt-3\">{children}</div>}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ColorCatalogSettings.tsx",
    "content": "import { useState, useEffect, useCallback, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Palette, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload, Cloud } from 'lucide-react';\nimport { api, getAuthToken } from '../api/client';\nimport type { ColorCatalogEntry } from '../api/client';\nimport { useToast } from '../contexts/ToastContext';\nimport { Card, CardHeader, CardContent } from './Card';\nimport { ConfirmModal } from './ConfirmModal';\n\nexport function ColorCatalogSettings() {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const [catalog, setCatalog] = useState<ColorCatalogEntry[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [search, setSearch] = useState('');\n  const [filterManufacturer, setFilterManufacturer] = useState<string>('');\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  // Add/Edit form state\n  const [showAddForm, setShowAddForm] = useState(false);\n  const [editingId, setEditingId] = useState<number | null>(null);\n  const [formManufacturer, setFormManufacturer] = useState('');\n  const [formColorName, setFormColorName] = useState('');\n  const [formHexColor, setFormHexColor] = useState('#FFFFFF');\n  const [formMaterial, setFormMaterial] = useState('');\n  const [saving, setSaving] = useState(false);\n\n  // Selection state\n  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());\n  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);\n\n  // Sync state\n  const [syncing, setSyncing] = useState(false);\n  const [syncProgress, setSyncProgress] = useState<{ fetched: number; total: number } | null>(null);\n\n  // Confirmation modals\n  const [deleteEntry, setDeleteEntry] = useState<ColorCatalogEntry | null>(null);\n  const [showResetConfirm, setShowResetConfirm] = useState(false);\n\n  const loadCatalog = useCallback(async () => {\n    try {\n      const entries = await api.getColorCatalog();\n      setCatalog(entries);\n    } catch {\n      showToast(t('settings.colorCatalog.loadFailed'), 'error');\n    } finally {\n      setLoading(false);\n    }\n  }, [showToast, t]);\n\n  useEffect(() => {\n    loadCatalog();\n  }, [loadCatalog]);\n\n  const manufacturers = [...new Set(catalog.map(e => e.manufacturer))].sort();\n\n  const filteredCatalog = catalog.filter(entry => {\n    const matchesSearch = search === '' ||\n      entry.manufacturer.toLowerCase().includes(search.toLowerCase()) ||\n      entry.color_name.toLowerCase().includes(search.toLowerCase()) ||\n      (entry.material?.toLowerCase().includes(search.toLowerCase()) ?? false);\n    const matchesManufacturer = filterManufacturer === '' || entry.manufacturer === filterManufacturer;\n    return matchesSearch && matchesManufacturer;\n  });\n\n  const resetForm = () => {\n    setFormManufacturer('');\n    setFormColorName('');\n    setFormHexColor('#FFFFFF');\n    setFormMaterial('');\n  };\n\n  const handleAdd = async () => {\n    if (!formManufacturer.trim() || !formColorName.trim() || !formHexColor) {\n      showToast(t('settings.colorCatalog.fieldsRequired'), 'error');\n      return;\n    }\n    setSaving(true);\n    try {\n      const entry = await api.addColorEntry({\n        manufacturer: formManufacturer.trim(),\n        color_name: formColorName.trim(),\n        hex_color: formHexColor,\n        material: formMaterial.trim() || null,\n      });\n      setCatalog(prev => [...prev, entry].sort((a, b) =>\n        a.manufacturer.localeCompare(b.manufacturer) ||\n        (a.material || '').localeCompare(b.material || '') ||\n        a.color_name.localeCompare(b.color_name)\n      ));\n      setShowAddForm(false);\n      resetForm();\n      showToast(t('settings.colorCatalog.colorAdded'), 'success');\n    } catch {\n      showToast(t('settings.colorCatalog.addFailed'), 'error');\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const startEdit = (entry: ColorCatalogEntry) => {\n    setEditingId(entry.id);\n    setFormManufacturer(entry.manufacturer);\n    setFormColorName(entry.color_name);\n    setFormHexColor(entry.hex_color);\n    setFormMaterial(entry.material || '');\n  };\n\n  const cancelEdit = () => {\n    setEditingId(null);\n    resetForm();\n  };\n\n  const handleUpdate = async (id: number) => {\n    if (!formManufacturer.trim() || !formColorName.trim() || !formHexColor) {\n      showToast(t('settings.colorCatalog.fieldsRequired'), 'error');\n      return;\n    }\n    setSaving(true);\n    try {\n      const updated = await api.updateColorEntry(id, {\n        manufacturer: formManufacturer.trim(),\n        color_name: formColorName.trim(),\n        hex_color: formHexColor,\n        material: formMaterial.trim() || null,\n      });\n      setCatalog(prev =>\n        prev.map(e => e.id === id ? updated : e).sort((a, b) =>\n          a.manufacturer.localeCompare(b.manufacturer) ||\n          (a.material || '').localeCompare(b.material || '') ||\n          a.color_name.localeCompare(b.color_name)\n        )\n      );\n      setEditingId(null);\n      resetForm();\n      showToast(t('settings.colorCatalog.colorUpdated'), 'success');\n    } catch {\n      showToast(t('settings.colorCatalog.updateFailed'), 'error');\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleDelete = async () => {\n    if (!deleteEntry) return;\n    try {\n      await api.deleteColorEntry(deleteEntry.id);\n      setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id));\n      showToast(t('settings.colorCatalog.colorDeleted'), 'success');\n    } catch {\n      showToast(t('settings.colorCatalog.deleteFailed'), 'error');\n    } finally {\n      setDeleteEntry(null);\n    }\n  };\n\n  const handleReset = async () => {\n    setShowResetConfirm(false);\n    setLoading(true);\n    try {\n      await api.resetColorCatalog();\n      await loadCatalog();\n      showToast(t('settings.colorCatalog.resetSuccess'), 'success');\n    } catch {\n      showToast(t('settings.colorCatalog.resetFailed'), 'error');\n      setLoading(false);\n    }\n  };\n\n  const handleSync = async () => {\n    setSyncing(true);\n    setSyncProgress(null);\n    try {\n      const headers: Record<string, string> = {};\n      const token = getAuthToken();\n      if (token) {\n        headers['Authorization'] = `Bearer ${token}`;\n      }\n      const response = await fetch('/api/v1/inventory/colors/sync', { method: 'POST', headers });\n      if (!response.ok) throw new Error('Failed to start sync');\n\n      const reader = response.body?.getReader();\n      if (!reader) throw new Error('No response body');\n\n      const decoder = new TextDecoder();\n      let buffer = '';\n\n      while (true) {\n        const { done, value } = await reader.read();\n        if (done) break;\n\n        buffer += decoder.decode(value, { stream: true });\n        const lines = buffer.split('\\n');\n        buffer = lines.pop() || '';\n\n        for (const line of lines) {\n          if (line.startsWith('data: ')) {\n            try {\n              const data = JSON.parse(line.slice(6));\n              if (data.type === 'progress') {\n                setSyncProgress({ fetched: data.total_fetched, total: data.total_available });\n              } else if (data.type === 'complete') {\n                if (data.added === 0) {\n                  showToast(t('settings.colorCatalog.syncUpToDate', { count: data.total_fetched }), 'success');\n                } else {\n                  showToast(t('settings.colorCatalog.syncComplete', { added: data.added, skipped: data.skipped }), 'success');\n                }\n              } else if (data.type === 'error') {\n                showToast(`${t('settings.colorCatalog.syncError')}: ${data.error}`, 'error');\n              }\n            } catch {\n              // Ignore parse errors\n            }\n          }\n        }\n      }\n      await loadCatalog();\n    } catch {\n      showToast(t('settings.colorCatalog.syncFailed'), 'error');\n    } finally {\n      setSyncing(false);\n      setSyncProgress(null);\n    }\n  };\n\n  const toggleSelect = (id: number) => {\n    setSelectedIds(prev => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n  };\n\n  const toggleSelectAll = () => {\n    if (selectedIds.size === filteredCatalog.length) {\n      setSelectedIds(new Set());\n    } else {\n      setSelectedIds(new Set(filteredCatalog.map(e => e.id)));\n    }\n  };\n\n  const handleBulkDelete = async () => {\n    setShowBulkDeleteConfirm(false);\n    if (selectedIds.size === 0) return;\n    try {\n      const result = await api.bulkDeleteColorEntries([...selectedIds]);\n      setCatalog(prev => prev.filter(e => !selectedIds.has(e.id)));\n      setSelectedIds(new Set());\n      showToast(t('settings.colorCatalog.bulkDeleted', { count: result.deleted }), 'success');\n    } catch {\n      showToast(t('settings.colorCatalog.bulkDeleteFailed'), 'error');\n    }\n  };\n\n  const handleExport = () => {\n    const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({\n      manufacturer, color_name, hex_color, material,\n    }));\n    const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = 'color-catalog.json';\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    URL.revokeObjectURL(url);\n    showToast(t('settings.colorCatalog.exported', { count: catalog.length }), 'success');\n  };\n\n  const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n    try {\n      const text = await file.text();\n      const data = JSON.parse(text) as Array<{\n        manufacturer: string; color_name: string; hex_color: string; material?: string | null;\n      }>;\n      if (!Array.isArray(data)) throw new Error('Invalid format');\n\n      let added = 0;\n      let skipped = 0;\n      for (const item of data) {\n        if (!item.manufacturer || !item.color_name || !item.hex_color) { skipped++; continue; }\n        const exists = catalog.some(c =>\n          c.manufacturer.toLowerCase() === item.manufacturer.toLowerCase() &&\n          c.color_name.toLowerCase() === item.color_name.toLowerCase() &&\n          (c.material || '').toLowerCase() === (item.material || '').toLowerCase()\n        );\n        if (exists) { skipped++; continue; }\n        try {\n          const entry = await api.addColorEntry({\n            manufacturer: item.manufacturer,\n            color_name: item.color_name,\n            hex_color: item.hex_color,\n            material: item.material || null,\n          });\n          setCatalog(prev => [...prev, entry].sort((a, b) =>\n            a.manufacturer.localeCompare(b.manufacturer) ||\n            (a.material || '').localeCompare(b.material || '') ||\n            a.color_name.localeCompare(b.color_name)\n          ));\n          added++;\n        } catch { skipped++; }\n      }\n      showToast(t('settings.colorCatalog.imported', { added, skipped }), 'success');\n    } catch {\n      showToast(t('settings.colorCatalog.importFailed'), 'error');\n    }\n    if (fileInputRef.current) fileInputRef.current.value = '';\n  };\n\n  return (\n    <Card id=\"card-color-catalog\">\n      <CardHeader>\n        <div className=\"flex items-center gap-2 mb-3\">\n          <Palette className=\"w-5 h-5 text-bambu-gray\" />\n          <h2 className=\"text-lg font-semibold text-white\">{t('settings.colorCatalog.title')}</h2>\n          <span className=\"text-sm text-bambu-gray\">({catalog.length})</span>\n        </div>\n        <div className=\"flex items-center gap-2 flex-wrap\">\n          <button\n            onClick={handleExport}\n            className=\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\"\n          >\n            <Download className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('common.export')}</span>\n          </button>\n          <button\n            onClick={() => fileInputRef.current?.click()}\n            className=\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\"\n          >\n            <Upload className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('common.import')}</span>\n          </button>\n          <input ref={fileInputRef} type=\"file\" accept=\".json\" className=\"hidden\" onChange={handleImport} />\n          <button\n            onClick={handleSync}\n            disabled={syncing}\n            className=\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\"\n            title={t('settings.colorCatalog.syncTooltip')}\n          >\n            {syncing ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Cloud className=\"w-4 h-4\" />}\n            <span className=\"hidden sm:inline\">\n              {syncing\n                ? syncProgress\n                  ? `${Math.min(syncProgress.fetched, syncProgress.total)} / ${syncProgress.total}`\n                  : t('settings.colorCatalog.starting')\n                : t('settings.colorCatalog.sync')}\n            </span>\n          </button>\n          <button\n            onClick={() => setShowResetConfirm(true)}\n            className=\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\"\n          >\n            <RotateCcw className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('common.reset')}</span>\n          </button>\n          <button\n            onClick={() => setShowAddForm(true)}\n            className=\"px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5\"\n          >\n            <Plus className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('common.add')}</span>\n          </button>\n        </div>\n        {selectedIds.size > 0 && (\n          <div className=\"flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg\">\n            <span className=\"text-sm text-red-400\">\n              {t('settings.colorCatalog.selectedCount', { count: selectedIds.size })}\n            </span>\n            <button\n              onClick={() => setShowBulkDeleteConfirm(true)}\n              className=\"ml-auto px-3 py-1.5 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5\"\n            >\n              <Trash2 className=\"w-4 h-4\" />\n              {t('settings.colorCatalog.deleteSelected')}\n            </button>\n            <button\n              onClick={() => setSelectedIds(new Set())}\n              className=\"px-3 py-1.5 text-sm text-bambu-gray hover:text-white transition-colors\"\n            >\n              {t('common.cancel')}\n            </button>\n          </div>\n        )}\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <p className=\"text-sm text-bambu-gray\">\n          {t('settings.colorCatalog.description')}\n        </p>\n\n        {/* Search and filter */}\n        <div className=\"flex gap-2 flex-wrap\">\n          <div className=\"relative flex-1 min-w-[200px]\">\n            <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n            <input\n              type=\"text\"\n              className=\"w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n              placeholder={t('settings.colorCatalog.searchColors')}\n              value={search}\n              onChange={(e) => setSearch(e.target.value)}\n            />\n          </div>\n          <select\n            className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n            value={filterManufacturer}\n            onChange={(e) => setFilterManufacturer(e.target.value)}\n          >\n            <option value=\"\">{t('settings.colorCatalog.allManufacturers')}</option>\n            {manufacturers.map(m => (\n              <option key={m} value={m}>{m}</option>\n            ))}\n          </select>\n        </div>\n\n        {/* Add form */}\n        {showAddForm && (\n          <div className=\"p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n            <h3 className=\"text-sm font-medium text-white mb-3\">{t('settings.colorCatalog.addNewColor')}</h3>\n            <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-2 items-end\">\n              <input\n                type=\"text\"\n                className=\"px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                placeholder={t('settings.colorCatalog.manufacturer')}\n                value={formManufacturer}\n                onChange={(e) => setFormManufacturer(e.target.value)}\n              />\n              <input\n                type=\"text\"\n                className=\"px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                placeholder={t('settings.colorCatalog.colorName')}\n                value={formColorName}\n                onChange={(e) => setFormColorName(e.target.value)}\n              />\n              <div className=\"flex items-center gap-2\">\n                <input\n                  type=\"color\"\n                  className=\"w-20 h-10 rounded cursor-pointer border border-bambu-dark-tertiary\"\n                  value={formHexColor}\n                  onChange={(e) => setFormHexColor(e.target.value)}\n                />\n                <input\n                  type=\"text\"\n                  className=\"w-32 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  placeholder=\"#FFFFFF\"\n                  value={formHexColor}\n                  onChange={(e) => setFormHexColor(e.target.value)}\n                />\n              </div>\n              <div className=\"flex items-center gap-1\">\n                <input\n                  type=\"text\"\n                  className=\"flex-1 max-w-lg min-w-[200px] px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  placeholder={t('settings.colorCatalog.materialOptional')}\n                  value={formMaterial}\n                  onChange={(e) => setFormMaterial(e.target.value)}\n                />\n                <button\n                  onClick={handleAdd}\n                  disabled={saving}\n                  className=\"w-24 px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center justify-center gap-3\"\n                >\n                  {saving ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Check className=\"w-4 h-4\" />}\n                  {t('common.add')}\n                </button>\n                <button\n                  onClick={() => { setShowAddForm(false); resetForm(); }}\n                  className=\"p-2 ml-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\"\n                >\n                  <X className=\"w-4 h-4\" />\n                </button>\n              </div>\n            </div>\n          </div>\n        )}\n\n        {/* Filter info */}\n        {(search || filterManufacturer) && (\n          <div className=\"text-xs text-bambu-gray\">\n            {t('settings.colorCatalog.showing', { filtered: filteredCatalog.length, total: catalog.length })}\n          </div>\n        )}\n\n        {/* Catalog list */}\n        {loading ? (\n          <div className=\"flex items-center justify-center py-8 text-bambu-gray\">\n            <Loader2 className=\"w-5 h-5 animate-spin mr-2\" />\n            {t('common.loading')}\n          </div>\n        ) : (\n          <div className=\"max-h-[600px] overflow-auto border border-bambu-dark-tertiary rounded-lg\">\n            <table className=\"w-full text-sm\">\n              <thead className=\"bg-bambu-dark sticky top-0\">\n                <tr>\n                  <th className=\"px-2 py-2 w-10\">\n                    <input\n                      type=\"checkbox\"\n                      checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}\n                      onChange={toggleSelectAll}\n                      className=\"w-4 h-4 accent-bambu-green cursor-pointer\"\n                    />\n                  </th>\n                  <th className=\"px-3 py-2 text-left text-bambu-gray font-medium w-12\"></th>\n                  <th className=\"px-3 py-2 text-left text-bambu-gray font-medium\">{t('settings.colorCatalog.manufacturer')}</th>\n                  <th className=\"px-3 py-2 text-left text-bambu-gray font-medium\">{t('inventory.material')}</th>\n                  <th className=\"px-3 py-2 text-left text-bambu-gray font-medium\">{t('settings.colorCatalog.colorName')}</th>\n                  <th className=\"px-3 py-2 text-left text-bambu-gray font-medium w-24\">{t('settings.colorCatalog.hex')}</th>\n                  <th className=\"px-3 py-2 w-16\"></th>\n                </tr>\n              </thead>\n              <tbody>\n                {filteredCatalog.length === 0 ? (\n                  <tr>\n                    <td colSpan={7} className=\"px-3 py-8 text-center text-bambu-gray\">\n                      {search || filterManufacturer ? t('settings.colorCatalog.noMatch') : t('settings.colorCatalog.empty')}\n                    </td>\n                  </tr>\n                ) : (\n                  filteredCatalog.map(entry => (\n                    <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>\n                      {editingId === entry.id ? (\n                        <>\n                          <td className=\"px-2 py-2\">\n                            <input\n                              type=\"checkbox\"\n                              checked={selectedIds.has(entry.id)}\n                              onChange={() => toggleSelect(entry.id)}\n                              className=\"w-4 h-4 accent-bambu-green cursor-pointer\"\n                            />\n                          </td>\n                          <td className=\"px-3 py-2\">\n                            <input\n                              type=\"color\"\n                              className=\"w-8 h-8 rounded cursor-pointer border border-bambu-dark-tertiary\"\n                              value={formHexColor}\n                              onChange={(e) => setFormHexColor(e.target.value)}\n                            />\n                          </td>\n                          <td className=\"px-3 py-2\">\n                            <input\n                              type=\"text\"\n                              className=\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                              value={formManufacturer}\n                              onChange={(e) => setFormManufacturer(e.target.value)}\n                            />\n                          </td>\n                          <td className=\"px-3 py-2\">\n                            <input\n                              type=\"text\"\n                              className=\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                              value={formMaterial}\n                              onChange={(e) => setFormMaterial(e.target.value)}\n                            />\n                          </td>\n                          <td className=\"px-3 py-2\">\n                            <input\n                              type=\"text\"\n                              className=\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                              value={formColorName}\n                              onChange={(e) => setFormColorName(e.target.value)}\n                            />\n                          </td>\n                          <td className=\"px-3 py-2\">\n                            <input\n                              type=\"text\"\n                              className=\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                              value={formHexColor}\n                              onChange={(e) => setFormHexColor(e.target.value)}\n                            />\n                          </td>\n                          <td className=\"px-3 py-2\">\n                            <div className=\"flex justify-end gap-1\">\n                              <button\n                                onClick={() => handleUpdate(entry.id)}\n                                disabled={saving}\n                                className=\"p-1.5 rounded hover:bg-green-500/20 text-green-500\"\n                              >\n                                {saving ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Check className=\"w-4 h-4\" />}\n                              </button>\n                              <button onClick={cancelEdit} className=\"p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray\">\n                                <X className=\"w-4 h-4\" />\n                              </button>\n                            </div>\n                          </td>\n                        </>\n                      ) : (\n                        <>\n                          <td className=\"px-2 py-2\">\n                            <input\n                              type=\"checkbox\"\n                              checked={selectedIds.has(entry.id)}\n                              onChange={() => toggleSelect(entry.id)}\n                              className=\"w-4 h-4 accent-bambu-green cursor-pointer\"\n                            />\n                          </td>\n                          <td className=\"px-3 py-2\">\n                            <div\n                              className=\"w-8 h-8 rounded border border-bambu-dark-tertiary\"\n                              style={{ backgroundColor: entry.hex_color }}\n                              title={entry.hex_color}\n                            />\n                          </td>\n                          <td className=\"px-3 py-2 text-white\">{entry.manufacturer}</td>\n                          <td className=\"px-3 py-2 text-bambu-gray\">{entry.material || '-'}</td>\n                          <td className=\"px-3 py-2 text-white\">{entry.color_name}</td>\n                          <td className=\"px-3 py-2 font-mono text-xs text-bambu-gray\">{entry.hex_color}</td>\n                          <td className=\"px-3 py-2\">\n                            <div className=\"flex justify-end gap-1\">\n                              <button\n                                onClick={() => startEdit(entry)}\n                                className=\"p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\"\n                              >\n                                <Pencil className=\"w-4 h-4\" />\n                              </button>\n                              <button\n                                onClick={() => setDeleteEntry(entry)}\n                                className=\"p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500\"\n                              >\n                                <Trash2 className=\"w-4 h-4\" />\n                              </button>\n                            </div>\n                          </td>\n                        </>\n                      )}\n                    </tr>\n                  ))\n                )}\n              </tbody>\n            </table>\n          </div>\n        )}\n      </CardContent>\n\n      {/* Delete confirmation */}\n      {deleteEntry && (\n        <ConfirmModal\n          title={t('settings.colorCatalog.deleteColor')}\n          message={t('settings.colorCatalog.deleteConfirm', { name: `${deleteEntry.manufacturer} - ${deleteEntry.color_name}` })}\n          confirmText={t('common.delete')}\n          variant=\"danger\"\n          onConfirm={handleDelete}\n          onCancel={() => setDeleteEntry(null)}\n        />\n      )}\n\n      {/* Bulk delete confirmation */}\n      {showBulkDeleteConfirm && (\n        <ConfirmModal\n          title={t('settings.colorCatalog.deleteSelected')}\n          message={t('settings.colorCatalog.bulkDeleteConfirm', { count: selectedIds.size })}\n          confirmText={t('common.delete')}\n          variant=\"danger\"\n          onConfirm={handleBulkDelete}\n          onCancel={() => setShowBulkDeleteConfirm(false)}\n        />\n      )}\n\n      {/* Reset confirmation */}\n      {showResetConfirm && (\n        <ConfirmModal\n          title={t('settings.colorCatalog.resetCatalog')}\n          message={t('settings.colorCatalog.resetConfirm')}\n          confirmText={t('common.reset')}\n          variant=\"danger\"\n          onConfirm={handleReset}\n          onCancel={() => setShowResetConfirm(false)}\n        />\n      )}\n    </Card>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ColumnConfigModal.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { GripVertical, Eye, EyeOff, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\n\nexport interface ColumnConfig {\n  id: string;\n  label: string;\n  visible: boolean;\n}\n\ninterface ColumnConfigModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  columns: ColumnConfig[];\n  defaultColumns: ColumnConfig[];\n  onSave: (columns: ColumnConfig[]) => void;\n}\n\nexport function ColumnConfigModal({ isOpen, onClose, columns, defaultColumns, onSave }: ColumnConfigModalProps) {\n  const { t } = useTranslation();\n  const [localColumns, setLocalColumns] = useState<ColumnConfig[]>(columns);\n  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);\n  const draggedIndexRef = useRef<number | null>(null);\n\n  useEffect(() => {\n    if (isOpen) {\n      setLocalColumns(columns.map((c) => ({ ...c })));\n    }\n  }, [isOpen, columns]);\n\n  useEffect(() => {\n    if (!isOpen) return;\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [isOpen, onClose]);\n\n  if (!isOpen) return null;\n\n  const toggleVisibility = (index: number) => {\n    setLocalColumns((prev) =>\n      prev.map((col, i) => (i === index ? { ...col, visible: !col.visible } : col))\n    );\n  };\n\n  const moveColumn = (fromIndex: number, toIndex: number) => {\n    if (toIndex < 0 || toIndex >= localColumns.length) return;\n    setLocalColumns((prev) => {\n      const newColumns = [...prev];\n      const [moved] = newColumns.splice(fromIndex, 1);\n      newColumns.splice(toIndex, 0, moved);\n      return newColumns;\n    });\n  };\n\n  const handleDragStart = (e: React.DragEvent, index: number) => {\n    draggedIndexRef.current = index;\n    setDraggedIndex(index);\n    e.dataTransfer.effectAllowed = 'move';\n  };\n\n  const handleDragOver = (e: React.DragEvent, index: number) => {\n    e.preventDefault();\n    e.dataTransfer.dropEffect = 'move';\n    const from = draggedIndexRef.current;\n    if (from !== null && from !== index) {\n      moveColumn(from, index);\n      draggedIndexRef.current = index;\n      setDraggedIndex(index);\n    }\n  };\n\n  const handleDrop = (e: React.DragEvent) => {\n    e.preventDefault();\n  };\n\n  const handleDragEnd = () => {\n    draggedIndexRef.current = null;\n    setDraggedIndex(null);\n  };\n\n  const resetToDefaults = () => {\n    setLocalColumns(defaultColumns.map((c) => ({ ...c })));\n  };\n\n  const handleSave = () => {\n    onSave(localColumns);\n    onClose();\n  };\n\n  const visibleCount = localColumns.filter((c) => c.visible).length;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\" onClick={onClose}>\n      <Card className=\"w-full max-w-md max-h-[80vh] flex flex-col\" onClick={(e: React.MouseEvent) => e.stopPropagation()}>\n        <CardContent className=\"p-6 flex flex-col min-h-0\">\n          {/* Header */}\n          <h3 className=\"text-lg font-semibold text-white mb-2\">{t('inventory.configureColumns')}</h3>\n          <p className=\"text-sm text-bambu-gray mb-4\">\n            {t('inventory.configureColumnsDesc')}\n            <span className=\"ml-2 text-bambu-gray/60\">\n              ({visibleCount} {t('inventory.of')} {localColumns.length} {t('inventory.visible')})\n            </span>\n          </p>\n\n          {/* Column list */}\n          <div className=\"space-y-1 overflow-y-auto flex-1 min-h-0 pr-1\">\n            {localColumns.map((column, index) => (\n              <div\n                key={column.id}\n                className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${\n                  draggedIndex === index\n                    ? 'border-bambu-green bg-bambu-green/10'\n                    : 'border-bambu-dark-tertiary bg-bambu-dark-tertiary/50'\n                } ${!column.visible ? 'opacity-50' : ''}`}\n                draggable\n                onDragStart={(e) => handleDragStart(e, index)}\n                onDragOver={(e) => handleDragOver(e, index)}\n                onDrop={handleDrop}\n                onDragEnd={handleDragEnd}\n              >\n                {/* Drag Handle */}\n                <div className=\"cursor-grab text-bambu-gray/50 hover:text-bambu-gray\">\n                  <GripVertical className=\"w-4 h-4\" />\n                </div>\n\n                {/* Column Name */}\n                <span className=\"flex-1 font-medium text-sm text-white\">{column.label}</span>\n\n                {/* Move Buttons */}\n                <div className=\"flex items-center gap-0.5\">\n                  <button\n                    onClick={() => moveColumn(index, index - 1)}\n                    disabled={index === 0}\n                    className=\"p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed\"\n                    title={t('inventory.moveUp')}\n                  >\n                    <ChevronUp className=\"w-4 h-4\" />\n                  </button>\n                  <button\n                    onClick={() => moveColumn(index, index + 1)}\n                    disabled={index === localColumns.length - 1}\n                    className=\"p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed\"\n                    title={t('inventory.moveDown')}\n                  >\n                    <ChevronDown className=\"w-4 h-4\" />\n                  </button>\n                </div>\n\n                {/* Visibility Toggle */}\n                <button\n                  onClick={() => toggleVisibility(index)}\n                  className={`p-1.5 rounded transition-colors ${\n                    column.visible\n                      ? 'text-bambu-green hover:bg-bambu-green/10'\n                      : 'text-bambu-gray/50 hover:bg-bambu-dark-secondary'\n                  }`}\n                  title={column.visible ? t('inventory.hideColumn') : t('inventory.showColumn')}\n                >\n                  {column.visible ? <Eye className=\"w-4 h-4\" /> : <EyeOff className=\"w-4 h-4\" />}\n                </button>\n              </div>\n            ))}\n          </div>\n\n          {/* Footer */}\n          <div className=\"flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary\">\n            <Button variant=\"secondary\" onClick={resetToDefaults} className=\"mr-auto\">\n              <RotateCcw className=\"w-4 h-4\" />\n              {t('inventory.reset')}\n            </Button>\n            <Button variant=\"secondary\" onClick={onClose}>\n              {t('inventory.cancel')}\n            </Button>\n            <Button onClick={handleSave}>\n              {t('inventory.applyChanges')}\n            </Button>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/CompactHistoryRow.tsx",
    "content": "import {\n  CheckCircle,\n  XCircle,\n  SkipForward,\n  X,\n  RefreshCw,\n  Trash2,\n  Printer,\n  Timer,\n  Layers,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';\nimport type { PrintQueueItem, Permission } from '../api/client';\nimport { Button } from './Button';\n\nconst STATUS_CONFIG = {\n  completed: { icon: CheckCircle, color: 'text-emerald-400', border: 'border-l-emerald-500' },\n  failed: { icon: XCircle, color: 'text-red-400', border: 'border-l-red-500' },\n  skipped: { icon: SkipForward, color: 'text-orange-400', border: 'border-l-gray-500' },\n  cancelled: { icon: X, color: 'text-gray-400', border: 'border-l-gray-500' },\n} as const;\n\nexport function CompactHistoryRow({\n  item,\n  onRequeue,\n  onRemove,\n  timeFormat = 'system',\n  hasPermission,\n  canModify,\n  t,\n}: {\n  item: PrintQueueItem;\n  onRequeue: () => void;\n  onRemove: () => void;\n  timeFormat?: TimeFormat;\n  hasPermission: (permission: Permission) => boolean;\n  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;\n  t: (key: string, options?: Record<string, unknown>) => string;\n}) {\n  const config = STATUS_CONFIG[item.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.cancelled;\n  const StatusIcon = config.icon;\n  const displayName = item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`;\n\n  const thumbnailUrl = item.archive_thumbnail\n    ? api.getArchiveThumbnail(item.archive_id!)\n    : item.library_file_thumbnail\n      ? api.getLibraryFileThumbnailUrl(item.library_file_id!)\n      : null;\n\n  const completedTime = item.completed_at || item.created_at;\n\n  return (\n    <div className={`flex items-center gap-2 sm:gap-3 px-3 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary border-l-[3px] ${config.border}`}>\n      {/* Status icon */}\n      <StatusIcon className={`w-4 h-4 shrink-0 ${config.color}`} />\n\n      {/* Thumbnail */}\n      <div className=\"w-8 h-8 shrink-0 bg-bambu-dark rounded overflow-hidden\">\n        {thumbnailUrl ? (\n          <img src={thumbnailUrl} alt=\"\" className=\"w-full h-full object-cover\" />\n        ) : (\n          <div className=\"w-full h-full flex items-center justify-center text-bambu-gray\">\n            <Layers className=\"w-4 h-4\" />\n          </div>\n        )}\n      </div>\n\n      {/* File name */}\n      <span className=\"text-sm text-white font-medium truncate min-w-0 flex-1\">\n        {displayName}\n      </span>\n\n      {/* Printer */}\n      {item.printer_name && (\n        <span className=\"hidden sm:flex items-center gap-1 text-xs text-bambu-gray shrink-0\">\n          <Printer className=\"w-3 h-3\" />\n          <span className=\"truncate max-w-[100px]\">{item.printer_name}</span>\n        </span>\n      )}\n\n      {/* Duration */}\n      {item.print_time_seconds && (\n        <span className=\"hidden sm:flex items-center gap-1 text-xs text-bambu-gray shrink-0\">\n          <Timer className=\"w-3 h-3\" />\n          {formatDuration(item.print_time_seconds)}\n        </span>\n      )}\n\n      {/* Completed time */}\n      <span className=\"text-xs text-bambu-gray shrink-0\">\n        {formatRelativeTime(completedTime, timeFormat, t)}\n      </span>\n\n      {/* Actions */}\n      <div className=\"flex items-center gap-0.5 shrink-0\">\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onRequeue}\n          disabled={!hasPermission('queue:create')}\n          title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}\n          className=\"text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5\"\n        >\n          <RefreshCw className=\"w-3.5 h-3.5\" />\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onRemove}\n          disabled={!canModify('queue', 'delete', item.created_by_id)}\n          title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}\n          className=\"p-1.5\"\n        >\n          <Trash2 className=\"w-3.5 h-3.5\" />\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/CompareArchivesModal.tsx",
    "content": "import { useEffect } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { X, Check, AlertTriangle, Loader2 } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { ArchiveComparison } from '../api/client';\nimport { Button } from './Button';\n\ninterface CompareArchivesModalProps {\n  archiveIds: number[];\n  onClose: () => void;\n}\n\nexport function CompareArchivesModal({ archiveIds, onClose }: CompareArchivesModalProps) {\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  const { data: comparison, isLoading, error } = useQuery({\n    queryKey: ['archive-comparison', archiveIds],\n    queryFn: () => api.compareArchives(archiveIds),\n  });\n\n  return (\n    <div className=\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\" onClick={onClose}>\n      <div className=\"bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[90vh] flex flex-col border border-bambu-dark-tertiary\" onClick={(e) => e.stopPropagation()}>\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n          <h3 className=\"text-lg font-semibold text-white\">\n            Compare Archives ({archiveIds.length})\n          </h3>\n          <button\n            onClick={onClose}\n            className=\"text-bambu-gray hover:text-white p-1\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Content */}\n        <div className=\"flex-1 overflow-auto p-4 bg-bambu-dark-secondary\">\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-12\">\n              <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n            </div>\n          ) : error ? (\n            <div className=\"text-center py-12 text-red-400\">\n              <AlertTriangle className=\"w-12 h-12 mx-auto mb-4 opacity-50\" />\n              <p>Failed to load comparison</p>\n              <p className=\"text-sm text-bambu-gray mt-2\">\n                {error instanceof Error ? error.message : 'Unknown error'}\n              </p>\n            </div>\n          ) : comparison ? (\n            <ComparisonContent comparison={comparison} />\n          ) : null}\n        </div>\n\n        {/* Footer */}\n        <div className=\"p-4 border-t border-bambu-dark-tertiary\">\n          <Button variant=\"secondary\" onClick={onClose} className=\"w-full\">\n            Close\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ComparisonContent({ comparison }: { comparison: ArchiveComparison }) {\n  return (\n    <div className=\"space-y-6\">\n      {/* Archive Headers */}\n      <div className=\"overflow-x-auto\">\n        <table className=\"w-full\">\n          <thead>\n            <tr>\n              <th className=\"text-left text-sm text-bambu-gray font-medium pb-2 pr-4 min-w-[150px]\">\n                Setting\n              </th>\n              {comparison.archives.map((archive) => (\n                <th\n                  key={archive.id}\n                  className=\"text-left text-sm font-medium pb-2 px-2 min-w-[120px]\"\n                >\n                  <div className=\"text-white truncate max-w-[150px]\" title={archive.print_name}>\n                    {archive.print_name}\n                  </div>\n                  <div className={`text-xs ${\n                    archive.status === 'completed' ? 'text-status-ok' :\n                    archive.status === 'failed' ? 'text-status-error' : 'text-bambu-gray'\n                  }`}>\n                    {archive.status}\n                  </div>\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody className=\"divide-y divide-bambu-gray/20\">\n            {comparison.comparison.map((field) => (\n              <tr\n                key={field.field}\n                className={field.has_difference ? 'bg-yellow-500/5' : ''}\n              >\n                <td className=\"py-2 pr-4 text-sm\">\n                  <div className=\"flex items-center gap-2\">\n                    {field.has_difference && (\n                      <AlertTriangle className=\"w-3 h-3 text-yellow-400 flex-shrink-0\" />\n                    )}\n                    <span className={field.has_difference ? 'text-yellow-400' : 'text-bambu-gray'}>\n                      {field.label}\n                    </span>\n                  </div>\n                </td>\n                {field.values.map((value, idx) => (\n                  <td key={idx} className=\"py-2 px-2 text-sm text-white\">\n                    {value ?? <span className=\"text-bambu-gray/50\">-</span>}\n                    {field.unit && value !== null && (\n                      <span className=\"text-bambu-gray ml-1\">{field.unit}</span>\n                    )}\n                  </td>\n                ))}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n\n      {/* Differences Summary */}\n      {comparison.differences.length > 0 && (\n        <div className=\"p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg\">\n          <h4 className=\"text-sm font-medium text-yellow-400 mb-2 flex items-center gap-2\">\n            <AlertTriangle className=\"w-4 h-4\" />\n            {comparison.differences.length} Difference{comparison.differences.length > 1 ? 's' : ''} Found\n          </h4>\n          <ul className=\"text-sm text-white/80 space-y-1\">\n            {comparison.differences.slice(0, 5).map((diff) => (\n              <li key={diff.field}>\n                <span className=\"text-yellow-400\">{diff.label}</span>: {diff.values.join(' vs ')} {diff.unit || ''}\n              </li>\n            ))}\n            {comparison.differences.length > 5 && (\n              <li className=\"text-bambu-gray\">\n                ...and {comparison.differences.length - 5} more\n              </li>\n            )}\n          </ul>\n        </div>\n      )}\n\n      {/* Success Correlation */}\n      {comparison.success_correlation.has_both_outcomes ? (\n        <div className=\"p-4 bg-bambu-dark rounded-lg\">\n          <h4 className=\"text-sm font-medium text-white mb-3 flex items-center gap-2\">\n            <Check className=\"w-4 h-4 text-bambu-green\" />\n            Success/Failure Analysis\n          </h4>\n          <div className=\"flex items-center gap-4 text-sm mb-3\">\n            <span className=\"text-bambu-green\">\n              {comparison.success_correlation.successful_count} successful\n            </span>\n            <span className=\"text-red-400\">\n              {comparison.success_correlation.failed_count} failed\n            </span>\n          </div>\n          {comparison.success_correlation.insights && comparison.success_correlation.insights.length > 0 ? (\n            <div className=\"space-y-2\">\n              {comparison.success_correlation.insights.map((insight) => (\n                <div key={insight.field} className=\"text-sm p-2 bg-bambu-dark-secondary rounded\">\n                  <span className=\"text-white font-medium\">{insight.label}:</span>{' '}\n                  <span className=\"text-white/80\">{insight.insight}</span>\n                </div>\n              ))}\n            </div>\n          ) : (\n            <p className=\"text-sm text-bambu-gray\">No clear correlations found between settings and outcomes.</p>\n          )}\n        </div>\n      ) : (\n        <div className=\"p-4 bg-bambu-dark rounded-lg text-sm text-bambu-gray\">\n          <p>{comparison.success_correlation.message || 'Need both successful and failed prints for correlation analysis.'}</p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ConfigureAmsSlotModal.tsx",
    "content": "import { useState, useMemo, useEffect, useCallback, useRef } from 'react';\nimport { useQuery, useMutation } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { KProfile } from '../api/client';\nimport { Button } from './Button';\n\ninterface SlotInfo {\n  amsId: number;\n  trayId: number;\n  trayCount: number;\n  trayType?: string;\n  trayColor?: string;\n  traySubBrands?: string;\n  trayInfoIdx?: string;\n  extruderId?: number;\n  caliIdx?: number | null;\n  savedPresetId?: string;\n}\n\n// Get proper AMS label (handles HT AMS with ID 128+)\nfunction getAmsLabel(amsId: number, trayCount: number): string {\n  // External spool\n  if (amsId === 255) return 'External';\n\n  let normalizedId: number;\n  let isHt = false;\n\n  if (amsId >= 128 && amsId <= 135) {\n    // HT AMS range: 128-135 → A-H\n    normalizedId = amsId - 128;\n    isHt = true;\n  } else if (amsId >= 0 && amsId <= 3) {\n    // Regular AMS range: 0-3 → A-D\n    normalizedId = amsId;\n    // Check tray count as secondary indicator\n    isHt = trayCount === 1;\n  } else {\n    // Unknown range - fallback to A\n    normalizedId = 0;\n  }\n\n  // Cap to valid letter range (A-H)\n  normalizedId = Math.max(0, Math.min(normalizedId, 7));\n  const letter = String.fromCharCode(65 + normalizedId);\n\n  return isHt ? `HT-${letter}` : `AMS-${letter}`;\n}\n\n// Convert setting_id to tray_info_idx (filament_id format)\n// Bambu format: setting_id \"GFSL05\" → tray_info_idx \"GFL05\"\nfunction convertToTrayInfoIdx(settingId: string): string {\n  // Strip version suffix if present (e.g., GFSL05_07 -> GFSL05)\n  const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId;\n\n  // Bambu presets start with \"GFS\" - remove the 'S' to get filament_id\n  if (baseId.startsWith('GFS')) {\n    return 'GF' + baseId.slice(3);\n  }\n\n  // User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix)\n  // This follows the pattern that filament_id and setting_id share the same base ID\n  if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) {\n    return baseId;  // Use base ID without version suffix\n  }\n\n  // For other formats, use as-is\n  return baseId;\n}\n\ninterface ConfigureAmsSlotModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  printerId: number;\n  slotInfo: SlotInfo;\n  nozzleDiameter?: string;\n  printerModel?: string;\n  onSuccess?: () => void;\n  fullScreen?: boolean;\n}\n\n// Known filament material types\nconst MATERIAL_TYPES = ['PLA', 'PETG', 'PCTG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET'];\n\n// Extract filament type from preset name by finding known material type\nfunction parsePresetName(name: string): { material: string; brand: string; variant: string } {\n  // Remove printer/nozzle suffix first\n  const withoutSuffix = name.replace(/@.+$/, '').trim();\n  const upperName = withoutSuffix.toUpperCase();\n\n  // Handle \"X Support for Y\" pattern: the filament type is Y, not X.\n  // e.g. \"PLA Support for PETG PETG Basic\" → material is PETG\n  const supportMatch = upperName.match(/\\bSUPPORT\\s+FOR\\s+/);\n  if (supportMatch) {\n    const afterSupport = upperName.slice(supportMatch.index! + supportMatch[0].length);\n    for (const mat of MATERIAL_TYPES) {\n      const regex = new RegExp(`\\\\b${mat}\\\\b`);\n      if (regex.test(afterSupport)) {\n        const brand = withoutSuffix.slice(0, supportMatch.index).trim();\n        return { material: mat, brand, variant: 'Support' };\n      }\n    }\n  }\n\n  // Try to find a known material type in the name\n  for (const mat of MATERIAL_TYPES) {\n    // Use word boundary to match whole words only\n    const regex = new RegExp(`\\\\b${mat}\\\\b`, 'i');\n    if (regex.test(upperName)) {\n      // Found material, extract brand (everything before material) and variant (after)\n      const parts = withoutSuffix.split(regex);\n      const brand = parts[0]?.trim() || '';\n      const variant = parts[1]?.trim() || '';\n      return { material: mat, brand, variant };\n    }\n  }\n\n  // Fallback: assume first word is brand, second is material\n  const parts = withoutSuffix.split(/\\s+/);\n  if (parts.length >= 2) {\n    return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') };\n  }\n\n  return { material: withoutSuffix, brand: '', variant: '' };\n}\n\n// Check if a preset is a user preset (not built-in)\nfunction isUserPreset(settingId: string): boolean {\n  // Built-in presets have specific patterns, user presets are UUIDs\n  return !settingId.startsWith('GF') && !settingId.startsWith('P1');\n}\n\n// Common color name to hex mapping\nconst COLOR_NAME_MAP: Record<string, string> = {\n  // Basic colors\n  'white': 'FFFFFF',\n  'black': '000000',\n  'red': 'FF0000',\n  'green': '00FF00',\n  'blue': '0000FF',\n  'yellow': 'FFFF00',\n  'cyan': '00FFFF',\n  'magenta': 'FF00FF',\n  'orange': 'FFA500',\n  'purple': '800080',\n  'pink': 'FFC0CB',\n  'brown': '8B4513',\n  'gray': '808080',\n  'grey': '808080',\n  // Filament-specific colors\n  'jade white': 'FFFEF2',\n  'ivory': 'FFFFF0',\n  'beige': 'F5F5DC',\n  'cream': 'FFFDD0',\n  'silver': 'C0C0C0',\n  'gold': 'FFD700',\n  'bronze': 'CD7F32',\n  'copper': 'B87333',\n  'navy': '000080',\n  'teal': '008080',\n  'olive': '808000',\n  'maroon': '800000',\n  'coral': 'FF7F50',\n  'salmon': 'FA8072',\n  'lime': '32CD32',\n  'mint': '98FF98',\n  'forest green': '228B22',\n  'sky blue': '87CEEB',\n  'royal blue': '4169E1',\n  'turquoise': '40E0D0',\n  'lavender': 'E6E6FA',\n  'violet': 'EE82EE',\n  'plum': 'DDA0DD',\n  'tan': 'D2B48C',\n  'chocolate': 'D2691E',\n  'charcoal': '36454F',\n  'slate': '708090',\n  'transparent': '000000', // Will need special handling\n  'natural': 'F5F5DC',\n  'wood': 'DEB887',\n};\n\n// Quick-select color presets (common filament colors)\n// Basic colors shown by default\nconst QUICK_COLORS_BASIC = [\n  { name: 'White', hex: 'FFFFFF' },\n  { name: 'Black', hex: '000000' },\n  { name: 'Red', hex: 'FF0000' },\n  { name: 'Blue', hex: '0000FF' },\n  { name: 'Green', hex: '00AA00' },\n  { name: 'Yellow', hex: 'FFFF00' },\n  { name: 'Orange', hex: 'FFA500' },\n  { name: 'Gray', hex: '808080' },\n];\n\n// Extended colors shown when expanded\nconst QUICK_COLORS_EXTENDED = [\n  { name: 'Cyan', hex: '00FFFF' },\n  { name: 'Magenta', hex: 'FF00FF' },\n  { name: 'Purple', hex: '800080' },\n  { name: 'Pink', hex: 'FFC0CB' },\n  { name: 'Brown', hex: '8B4513' },\n  { name: 'Beige', hex: 'F5F5DC' },\n  { name: 'Navy', hex: '000080' },\n  { name: 'Teal', hex: '008080' },\n  { name: 'Lime', hex: '32CD32' },\n  { name: 'Gold', hex: 'FFD700' },\n  { name: 'Silver', hex: 'C0C0C0' },\n  { name: 'Maroon', hex: '800000' },\n  { name: 'Olive', hex: '808000' },\n  { name: 'Coral', hex: 'FF7F50' },\n  { name: 'Salmon', hex: 'FA8072' },\n  { name: 'Turquoise', hex: '40E0D0' },\n  { name: 'Violet', hex: 'EE82EE' },\n  { name: 'Indigo', hex: '4B0082' },\n  { name: 'Chocolate', hex: 'D2691E' },\n  { name: 'Tan', hex: 'D2B48C' },\n  { name: 'Slate', hex: '708090' },\n  { name: 'Charcoal', hex: '36454F' },\n  { name: 'Ivory', hex: 'FFFFF0' },\n  { name: 'Cream', hex: 'FFFDD0' },\n];\n\n// Try to convert color name to hex\nfunction colorNameToHex(name: string): string | null {\n  const normalized = name.toLowerCase().trim();\n  return COLOR_NAME_MAP[normalized] || null;\n}\n\n// Extract printer model from preset name suffix \"@BBL X1C 0.4 nozzle\" → \"X1C\"\nfunction extractPresetModel(name: string): string | null {\n  const atIdx = name.indexOf('@');\n  if (atIdx < 0) return null;\n  const suffix = name.slice(atIdx + 1).trim();\n  const bblMatch = suffix.match(/^BBL\\s+(.+?)(?:\\s+[\\d.]+\\s*nozzle)?$/i);\n  if (bblMatch) return bblMatch[1].trim();\n  return null;\n}\n\nexport function ConfigureAmsSlotModal({\n  isOpen,\n  onClose,\n  printerId,\n  slotInfo,\n  nozzleDiameter = '0.4',\n  printerModel,\n  onSuccess,\n  fullScreen,\n}: ConfigureAmsSlotModalProps) {\n  const { t } = useTranslation();\n  const [selectedPresetId, setSelectedPresetId] = useState<string>('');\n  const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);\n  const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha\n  const [colorInput, setColorInput] = useState<string>(''); // User's text input (name or hex)\n  const [searchQuery, setSearchQuery] = useState('');\n  const [showSuccess, setShowSuccess] = useState(false);\n  const [showExtendedColors, setShowExtendedColors] = useState(false);\n  const scrolledToRef = useRef<string>('');\n\n  // Fetch cloud settings (gracefully handle 401 when logged out)\n  const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({\n    queryKey: ['cloudSettings'],\n    queryFn: () => api.getCloudSettings(),\n    enabled: isOpen,\n    retry: false,\n  });\n\n  // Fetch local presets\n  const { data: localPresets, isLoading: localLoading } = useQuery({\n    queryKey: ['localPresets'],\n    queryFn: () => api.getLocalPresets(),\n    enabled: isOpen,\n  });\n\n  // Fetch built-in filament names (static fallback)\n  const { data: builtinFilaments, isLoading: builtinLoading } = useQuery({\n    queryKey: ['builtinFilaments'],\n    queryFn: () => api.getBuiltinFilaments(),\n    enabled: isOpen,\n    staleTime: Infinity,\n  });\n\n  // Fetch K profiles\n  const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({\n    queryKey: ['kprofiles', printerId, nozzleDiameter],\n    queryFn: () => api.getKProfiles(printerId, nozzleDiameter),\n    enabled: isOpen && !!printerId,\n  });\n\n  // Fetch color catalog\n  const { data: colorCatalog } = useQuery({\n    queryKey: ['colorCatalog'],\n    queryFn: () => api.getColorCatalog(),\n    enabled: isOpen,\n    staleTime: Infinity,\n  });\n\n  // Configure slot mutation\n  const configureMutation = useMutation({\n    mutationFn: async () => {\n      if (!selectedPresetId) throw new Error('No filament preset selected');\n\n      // Determine preset source\n      const isLocal = selectedPresetId.startsWith('local_');\n      const isBuiltin = selectedPresetId.startsWith('builtin_');\n      const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;\n      const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null;\n      const localPreset = isLocal\n        ? localPresets?.filament.find(p => p.id === localId)\n        : null;\n      const builtinPreset = isBuiltin\n        ? builtinFilaments?.find(b => b.filament_id === builtinFilamentId)\n        : null;\n\n      // Get the selected cloud preset details (null for local/builtin presets)\n      const selectedPreset = (!isLocal && !isBuiltin)\n        ? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)\n        : null;\n\n      if (!isLocal && !isBuiltin && !selectedPreset) throw new Error('Selected preset not found');\n      if (isLocal && !localPreset) throw new Error('Selected local preset not found');\n      if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found');\n\n      // Parse the preset name for filament info\n      const presetName = isLocal ? localPreset!.name : isBuiltin ? builtinPreset!.name : selectedPreset!.name;\n      const parsed = parsePresetName(presetName);\n\n      // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)\n      const caliIdx = selectedKProfile?.slot_id ?? -1;\n\n      // Use custom color if set, otherwise use current slot color or default\n      const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';\n\n      // Create the tray_sub_brands from preset name (without printer/nozzle suffix)\n      const traySubBrands = presetName.replace(/@.+$/, '').trim();\n\n      let trayInfoIdx: string;\n      let settingId: string;\n\n      // Parsed material from preset name — handles \"Support for\" patterns correctly.\n      // Prefer this over stored filament_type which may have been parsed with old logic.\n      const parsedMat = parsed.material.toUpperCase();\n\n      if (isLocal) {\n        // Local presets have no Bambu Cloud setting_id, but need a valid\n        // tray_info_idx for the printer to recognize the filament type.\n        // Map the material type to the closest generic Bambu filament ID.\n        const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '').toUpperCase();\n        const GENERIC_IDS: Record<string, string> = {\n          'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',\n          'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',\n          'ABS': 'GFB99', 'ASA': 'GFB98',\n          'PC': 'GFC99',\n          'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',\n          'TPU': 'GFU99',\n          'PVA': 'GFS99', 'HIPS': 'GFS98',\n          'PE': 'GFP99', 'PP': 'GFP97',\n        };\n        // Try exact match first, then base material (strip suffixes like \"-CF\", \"+\", \" HF\")\n        trayInfoIdx = GENERIC_IDS[material]\n          || GENERIC_IDS[material.replace(/[-\\s]?CF$/, '')]\n          || GENERIC_IDS[material.replace(/\\+$/, '')]\n          || GENERIC_IDS[material.split(/[-\\s]/)[0]]\n          || '';\n        settingId = '';\n      } else if (isBuiltin) {\n        // Built-in presets use the filament_id directly as tray_info_idx\n        trayInfoIdx = builtinFilamentId!;\n        settingId = '';\n      } else {\n        trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);\n        settingId = selectedPresetId;\n\n        // User cloud presets may carry a distinct filament_id in the cloud detail\n        // (e.g. \"P285e239\"); prefer it when present. Never fall back to base_id —\n        // that collapses custom presets to the inherited generic's filament_id and\n        // makes the slicer resolve the slot to \"Generic …\" instead (#1053).\n        if (!selectedPresetId.startsWith('GFS')) {\n          try {\n            const detail = await api.getCloudSettingDetail(selectedPresetId);\n            if (detail.filament_id) {\n              trayInfoIdx = detail.filament_id;\n            }\n          } catch (e) {\n            console.warn('Failed to fetch preset detail for filament_id:', e);\n          }\n        }\n      }\n\n      // Default temp range — use local preset core fields if available\n      let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;\n      let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;\n\n      if (!isLocal || isBuiltin || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {\n        // Fall back to material-based defaults (prefer parsed material for \"Support for\" handling)\n        const material = (isLocal\n          ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '')\n          : parsed.material).toUpperCase();\n        if (material.includes('PLA')) {\n          tempMin = 190;\n          tempMax = 230;\n        } else if (material.includes('PETG')) {\n          tempMin = 220;\n          tempMax = 260;\n        } else if (material.includes('ABS')) {\n          tempMin = 240;\n          tempMax = 280;\n        } else if (material.includes('ASA')) {\n          tempMin = 240;\n          tempMax = 280;\n        } else if (material.includes('TPU')) {\n          tempMin = 200;\n          tempMax = 240;\n        } else if (material === 'PCTG') {\n          tempMin = 220;\n          tempMax = 260;\n        } else if (material.includes('PC')) {\n          tempMin = 260;\n          tempMax = 300;\n        } else if (material.includes('PA') || material.includes('NYLON')) {\n          tempMin = 250;\n          tempMax = 290;\n        }\n      }\n\n      // Parse K value from selected profile\n      const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;\n\n      // Determine tray_type: prefer parsed material from preset name (handles \"Support for\"\n      // patterns correctly) over stored filament_type which may have been parsed with old logic.\n      const trayType = isLocal\n        ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || 'PLA')\n        : (parsed.material || 'PLA');\n\n      // Configure the slot via MQTT\n      const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {\n        tray_info_idx: trayInfoIdx,\n        tray_type: trayType,\n        tray_sub_brands: traySubBrands,\n        tray_color: color + 'FF', // Add alpha\n        nozzle_temp_min: tempMin,\n        nozzle_temp_max: tempMax,\n        cali_idx: caliIdx,\n        nozzle_diameter: nozzleDiameter,\n        setting_id: settingId, // Full setting ID for slicer compatibility (empty for local)\n        // Pass K profile's filament_id and setting_id for proper linking\n        kprofile_filament_id: selectedKProfile?.filament_id,\n        kprofile_setting_id: selectedKProfile?.setting_id || undefined,\n        // Also pass the K value directly for extrusion_cali_set command\n        k_value: kValue,\n      });\n\n      // Save the preset mapping so we can display the correct name in the UI\n      // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,\n      // which can't be resolved to a name via the filamentInfo API\n      const mappingPresetId = isLocal ? `local_${localId}` : isBuiltin ? `builtin_${builtinFilamentId}` : selectedPresetId;\n      const mappingSource = isLocal ? 'local' : isBuiltin ? 'builtin' : 'cloud';\n      try {\n        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);\n      } catch (e) {\n        console.warn('Failed to save slot preset mapping:', e);\n        // Don't fail the whole operation - slot was configured successfully\n      }\n\n      return result;\n    },\n    onSuccess: () => {\n      setShowSuccess(true);\n      onSuccess?.();\n      // Close after showing success briefly\n      setTimeout(() => {\n        setShowSuccess(false);\n        onClose();\n      }, 1500);\n    },\n  });\n\n  // Reset slot mutation\n  const resetMutation = useMutation({\n    mutationFn: async () => {\n      return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId);\n    },\n    onSuccess: () => {\n      setShowSuccess(true);\n      onSuccess?.();\n      setTimeout(() => {\n        setShowSuccess(false);\n        onClose();\n      }, 1500);\n    },\n  });\n\n  // Unified preset item for the list (cloud + local + builtin fallback)\n  type PresetItem = { id: string; name: string; source: 'cloud' | 'local' | 'builtin'; isUser: boolean };\n\n  // Filter filament presets based on search (merged cloud + local + builtin)\n  const filteredPresets = useMemo(() => {\n    const query = searchQuery.toLowerCase();\n    const items: PresetItem[] = [];\n\n    // Collect IDs already covered by cloud and local to avoid duplicates in fallback\n    const coveredIds = new Set<string>();\n\n    // Currently-configured preset should always be shown (bypass model filter)\n    const savedId = slotInfo.savedPresetId;\n    const trayIdx = slotInfo.trayInfoIdx;\n\n    // 1. Cloud presets\n    if (cloudSettings?.filament) {\n      for (const cp of cloudSettings.filament) {\n        coveredIds.add(cp.setting_id);\n        // Keep preset if it matches the slot's saved mapping or current tray_info_idx\n        const isSavedPreset = savedId === cp.setting_id;\n        const isCurrentPreset = isSavedPreset\n          || (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));\n        // Search filter applies to ALL presets (including saved) — no bypass\n        if (query && !cp.name.toLowerCase().includes(query)) continue;\n        // Filter by printer model if set (skip for current preset)\n        if (!isCurrentPreset && printerModel) {\n          const presetModel = extractPresetModel(cp.name);\n          if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue;\n        }\n        items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });\n      }\n    }\n\n    // 2. Local presets (always shown — user-imported profiles work on any printer)\n    if (localPresets?.filament) {\n      for (const lp of localPresets.filament) {\n        const localId = `local_${lp.id}`;\n        if (query && !lp.name.toLowerCase().includes(query)) continue;\n        items.push({ id: localId, name: lp.name, source: 'local', isUser: false });\n      }\n    }\n\n    // 3. Built-in filament names (fallback — only add entries not already covered)\n    if (builtinFilaments) {\n      for (const bf of builtinFilaments) {\n        if (coveredIds.has(bf.filament_id)) continue;\n        // Convert filament_id to setting_id format for cloud compatibility\n        // e.g. \"GFA00\" → cloud setting_id would be \"GFSA00\" (insert S after GF)\n        const settingId = bf.filament_id.startsWith('GF')\n          ? 'GFS' + bf.filament_id.slice(2)\n          : bf.filament_id;\n        if (coveredIds.has(settingId)) continue;\n        if (!query || bf.name.toLowerCase().includes(query)) {\n          items.push({ id: `builtin_${bf.filament_id}`, name: bf.name, source: 'builtin', isUser: false });\n        }\n      }\n    }\n\n    // Sort: cloud user presets first, then cloud built-in, then local, then builtin fallback\n    return items.sort((a, b) => {\n      const sourceOrder = { cloud: 0, local: 1, builtin: 2 };\n      if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source];\n      if (a.isUser && !b.isUser) return -1;\n      if (!a.isUser && b.isUser) return 1;\n      return a.name.localeCompare(b.name);\n    });\n  }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);\n\n  // Get full preset name for K profile filtering (brand + material, without printer suffix)\n  const selectedPresetInfo = useMemo(() => {\n    if (!selectedPresetId) return null;\n\n    // Resolve the name from cloud, local, or builtin presets\n    let presetName: string | null = null;\n    if (selectedPresetId.startsWith('local_')) {\n      const localId = parseInt(selectedPresetId.replace('local_', ''), 10);\n      const lp = localPresets?.filament.find(p => p.id === localId);\n      presetName = lp?.name || null;\n    } else if (selectedPresetId.startsWith('builtin_')) {\n      const filamentId = selectedPresetId.replace('builtin_', '');\n      const bf = builtinFilaments?.find(b => b.filament_id === filamentId);\n      presetName = bf?.name || null;\n    } else if (cloudSettings?.filament) {\n      const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);\n      presetName = cp?.name || null;\n    } else {\n      // No cloud settings available\n    }\n    if (!presetName) {\n      return null;\n    }\n\n    // Remove printer/nozzle suffix (e.g., \"@BBL X1C\" or \"@0.4 nozzle\")\n    let nameWithoutSuffix = presetName.replace(/@.+$/, '').trim();\n    // Strip leading \"# \" from custom preset names (user convention)\n    if (nameWithoutSuffix.startsWith('# ')) {\n      nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();\n    }\n    const parsed = parsePresetName(nameWithoutSuffix);\n\n    return {\n      fullName: nameWithoutSuffix,\n      material: parsed.material,\n      brand: parsed.brand,\n    };\n  }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments]);\n\n  // For backwards compatibility with the label\n  const selectedMaterial = selectedPresetInfo?.fullName || '';\n\n  // Filter color catalog entries matching the selected preset's brand + material\n  const catalogColors = useMemo(() => {\n    if (!colorCatalog || !selectedPresetInfo) return [];\n\n    const { fullName, brand } = selectedPresetInfo;\n\n    // Try to find colors matching the full preset name (e.g., \"PLA Metal\")\n    // The catalog uses the variant as part of the material field (e.g., material=\"PLA Metal\")\n    // Extract the full material+variant from the preset name\n    const materialVariant = fullName.replace(/^(Bambu\\s*(Lab)?|eSUN|Polymaker|Overture|Sunlu|Hatchbox)\\s*/i, '').trim();\n\n    return colorCatalog.filter(entry => {\n      const entryMaterial = (entry.material || '').toUpperCase();\n      const entryManufacturer = entry.manufacturer.toUpperCase();\n\n      // Match material: try full material+variant first, then just material type\n      const materialMatch = entryMaterial === materialVariant.toUpperCase()\n        || entryMaterial.includes(materialVariant.toUpperCase())\n        || materialVariant.toUpperCase().includes(entryMaterial);\n\n      if (!materialMatch) return false;\n\n      // If brand is present, also match manufacturer\n      if (brand) {\n        const upperBrand = brand.toUpperCase();\n        // Fuzzy match: \"Bambu\" matches \"Bambu Lab\", etc.\n        if (!entryManufacturer.includes(upperBrand) && !upperBrand.includes(entryManufacturer)) {\n          return false;\n        }\n      }\n\n      return true;\n    });\n  }, [colorCatalog, selectedPresetInfo]);\n\n  const matchingKProfiles = useMemo(() => {\n    if (!kprofilesData?.profiles || !selectedPresetInfo) return [];\n\n    const { fullName, material, brand } = selectedPresetInfo;\n    const upperFullName = fullName.toUpperCase();\n    const upperMaterial = material.toUpperCase();\n    const upperBrand = brand.toUpperCase();\n\n    // Material must be at least 2 chars to avoid false positives\n    if (!upperMaterial || upperMaterial.length < 2) return [];\n\n    // Filter profiles - require brand match if brand is present in selected preset\n    const filtered = kprofilesData.profiles.filter(p => {\n      const profileName = p.name.toUpperCase();\n\n      // If the selected preset has a brand (e.g., \"Azurefilm PLA Wood\"),\n      // only show profiles that match the brand\n      if (upperBrand) {\n        // Must contain the brand name\n        if (!profileName.includes(upperBrand)) {\n          return false;\n        }\n        // And must contain the material type\n        if (!profileName.includes(upperMaterial)) {\n          return false;\n        }\n        return true;\n      }\n\n      // No brand in selected preset - match on full name or material\n      // Priority 1: Exact match with full name\n      if (profileName.includes(upperFullName)) {\n        return true;\n      }\n\n      // Priority 2: Material type match (only when no brand specified)\n      if (profileName.includes(upperMaterial)) {\n        return true;\n      }\n\n      // Check for common material aliases\n      const aliases: Record<string, string[]> = {\n        'NYLON': ['PA', 'PA-CF', 'PA6'],\n        'PA': ['NYLON'],\n      };\n\n      const materialAliases = aliases[upperMaterial] || [];\n      for (const alias of materialAliases) {\n        if (profileName.includes(alias)) {\n          return true;\n        }\n      }\n\n      return false;\n    });\n\n    // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)\n    // Prefer the profile matching the slot's extruder (e.g. ext-R uses extruder 0, ext-L uses extruder 1)\n    const seen = new Map<string, KProfile>();\n    for (const profile of filtered) {\n      const key = `${profile.name}|${profile.k_value}`;\n      const existing = seen.get(key);\n      if (!existing) {\n        seen.set(key, profile);\n      } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {\n        // Replace with profile matching slot's extruder\n        seen.set(key, profile);\n      }\n    }\n    return Array.from(seen.values());\n  }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId]);\n\n  // Pre-select current profile when modal opens, reset when closes\n  useEffect(() => {\n    if (isOpen) {\n      // Pre-populate from saved preset mapping (most reliable)\n      if (slotInfo.savedPresetId) {\n        setSelectedPresetId(slotInfo.savedPresetId);\n      } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {\n        // Fallback: try to match by tray_info_idx in cloud presets\n        // First try exact match on setting_id\n        let currentPreset = cloudSettings.filament.find(\n          p => p.setting_id === slotInfo.trayInfoIdx\n        );\n        // Then try matching by converting setting_id → filament_id format\n        if (!currentPreset) {\n          currentPreset = cloudSettings.filament.find(\n            p => convertToTrayInfoIdx(p.setting_id) === slotInfo.trayInfoIdx\n          );\n        }\n        if (currentPreset) {\n          setSelectedPresetId(currentPreset.setting_id);\n        }\n      } else if (slotInfo.trayInfoIdx && builtinFilaments?.length) {\n        // Last resort: match trayInfoIdx against builtin presets\n        const trayIdx = slotInfo.trayInfoIdx;\n        const match = builtinFilaments.find(bf => bf.filament_id === trayIdx);\n        if (match) {\n          setSelectedPresetId(`builtin_${match.filament_id}`);\n        }\n      }\n\n      // Pre-populate color from current slot (black is valid — empty slots don't pass trayColor)\n      if (slotInfo.trayColor) {\n        const hex = slotInfo.trayColor.slice(0, 6);\n        if (hex) {\n          setColorHex(hex);\n        }\n      }\n    } else {\n      // Reset when modal closes\n      setSelectedPresetId('');\n      setSelectedKProfile(null);\n      setColorHex('');\n      setColorInput('');\n      setSearchQuery('');\n      setShowSuccess(false);\n      scrolledToRef.current = '';\n    }\n  }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament, builtinFilaments]);\n\n  // Auto-select best matching K profile when preset changes\n  useEffect(() => {\n    if (matchingKProfiles.length > 0) {\n      // Prefer the currently-active K-profile (by cali_idx) if available\n      if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {\n        const active = matchingKProfiles.find(p => p.slot_id === slotInfo.caliIdx);\n        if (active) {\n          setSelectedKProfile(active);\n          return;\n        }\n      }\n      // Fallback: first matching profile\n      setSelectedKProfile(matchingKProfiles[0]);\n    } else {\n      setSelectedKProfile(null);\n    }\n  }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx]);\n\n  // Escape key handler\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    if (e.key === 'Escape') {\n      onClose();\n    }\n  }, [onClose]);\n\n  useEffect(() => {\n    if (isOpen) {\n      document.addEventListener('keydown', handleKeyDown);\n      return () => document.removeEventListener('keydown', handleKeyDown);\n    }\n  }, [isOpen, handleKeyDown]);\n\n  const isLoading = (settingsLoading && !cloudError) || localLoading || builtinLoading || kprofilesLoading;\n\n  // Scroll selected preset into view when data finishes loading or the selection changes.\n  // Uses a ref guard so scrollIntoView only fires once per selection, preventing the\n  // infinite scroll loop that occurred on Windows with inline callback refs.\n  useEffect(() => {\n    if (!isLoading && selectedPresetId && selectedPresetId !== scrolledToRef.current) {\n      const raf = requestAnimationFrame(() => {\n          const modal = document.querySelector('[class*=\"fixed inset-0 z-50\"]');\n          const el = modal?.querySelector(`[data-preset-id=\"${CSS.escape(selectedPresetId)}\"]`);\n        if (el) {\n          scrolledToRef.current = selectedPresetId;\n          el.scrollIntoView({ block: 'nearest' });\n        }\n      });\n      return () => cancelAnimationFrame(raf);\n    }\n  }, [selectedPresetId, isLoading]);\n\n  if (!isOpen) return null;\n  const canSave = selectedPresetId && !configureMutation.isPending;\n\n  // Get display color (custom or slot default)\n  const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';\n\n  return (\n    <div className={`fixed inset-0 z-50 flex ${fullScreen ? '' : 'items-center justify-center'}`}>\n      {/* Backdrop */}\n      {!fullScreen && (\n        <div\n          className=\"absolute inset-0 bg-black/60 backdrop-blur-sm\"\n          onClick={onClose}\n        />\n      )}\n\n      {/* Modal */}\n      <div className={fullScreen\n        ? 'relative w-full h-full bg-bambu-dark-secondary flex flex-col'\n        : 'relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl'\n      }>\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0\">\n          <div className=\"flex items-center gap-2\">\n            <Settings2 className=\"w-5 h-5 text-bambu-blue\" />\n            <h2 className=\"text-lg font-semibold text-white\">{t('configureAmsSlot.title')}</h2>\n            {/* Inline slot info in fullScreen mode */}\n            {fullScreen && (\n              <div className=\"flex items-center gap-2 ml-4 text-sm text-bambu-gray\">\n                <span className=\"text-white/30\">|</span>\n                {slotInfo.trayColor && (\n                  <span\n                    className=\"w-4 h-4 rounded-full border border-black/20\"\n                    style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}\n                  />\n                )}\n                <span className=\"text-white/70\">\n                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}\n                </span>\n                {slotInfo.traySubBrands && (\n                  <span>({slotInfo.traySubBrands})</span>\n                )}\n              </div>\n            )}\n          </div>\n          <button\n            onClick={onClose}\n            className=\"p-1 text-bambu-gray hover:text-white rounded transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Content */}\n        <div className={`p-4 overflow-y-auto ${fullScreen ? 'flex-1 min-h-0' : 'space-y-4 max-h-[60vh]'}`}>\n          {/* Success overlay */}\n          {showSuccess && (\n            <div className=\"absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl\">\n              <div className=\"text-center space-y-3\">\n                <CheckCircle2 className=\"w-16 h-16 text-bambu-green mx-auto\" />\n                <p className=\"text-lg font-semibold text-white\">{t('configureAmsSlot.slotConfigured')}</p>\n                <p className=\"text-sm text-bambu-gray\">{t('configureAmsSlot.settingsSentToPrinter')}</p>\n              </div>\n            </div>\n          )}\n\n          {/* Slot info */}\n          {!fullScreen && (\n            <div className=\"p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n              <p className=\"text-xs text-bambu-gray mb-1\">{t('configureAmsSlot.configuringSlot')}</p>\n              <div className=\"flex items-center gap-2\">\n                {slotInfo.trayColor && (\n                  <span\n                    className=\"w-4 h-4 rounded-full border border-black/20\"\n                    style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}\n                  />\n                )}\n                <span className=\"text-white font-medium\">\n                  {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}\n                </span>\n                {slotInfo.traySubBrands && (\n                  <span className=\"text-bambu-gray\">({slotInfo.traySubBrands})</span>\n                )}\n              </div>\n            </div>\n          )}\n\n          {isLoading ? (\n            <div className=\"flex justify-center py-8\">\n              <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n            </div>\n          ) : fullScreen ? (\n            /* Two-column layout for kiosk display */\n            <div className=\"flex gap-4 h-full\">\n              {/* Left column: Filament preset list (takes full height) */}\n              <div className=\"w-1/2 flex flex-col min-h-0\">\n                <label className=\"block text-sm text-bambu-gray mb-2\">\n                  {t('configureAmsSlot.filamentProfile')} <span className=\"text-red-400\">*</span>\n                </label>\n                <input\n                  type=\"text\"\n                  placeholder={t('configureAmsSlot.searchPresets')}\n                  value={searchQuery}\n                  onChange={(e) => setSearchQuery(e.target.value)}\n                  className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2 shrink-0\"\n                />\n                <div className=\"flex-1 min-h-0 overflow-y-auto space-y-1\">\n                  {filteredPresets.length === 0 ? (\n                    <p className=\"text-center py-4 text-bambu-gray\">\n                      {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)\n                        ? t('configureAmsSlot.noPresetsAvailable')\n                        : t('configureAmsSlot.noMatchingPresets')}\n                    </p>\n                  ) : (\n                    filteredPresets.map((preset) => (\n                      <button\n                        key={preset.id}\n                        data-preset-id={preset.id}\n                        onClick={() => setSelectedPresetId(preset.id)}\n                        className={`w-full p-2 rounded-lg border text-left transition-colors ${\n                          selectedPresetId === preset.id\n                            ? 'bg-bambu-green/20 border-bambu-green'\n                            : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'\n                        }`}\n                      >\n                        <div className=\"flex items-center justify-between\">\n                          <span className=\"text-white text-sm truncate\">{preset.name}</span>\n                          <div className=\"flex items-center gap-1 flex-shrink-0\">\n                            {preset.source === 'local' && (\n                              <span className=\"text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400\">\n                                {t('profiles.localProfiles.badge')}\n                              </span>\n                            )}\n                            {preset.source === 'builtin' && (\n                              <span className=\"text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400\">\n                                {t('configureAmsSlot.builtin')}\n                              </span>\n                            )}\n                            {preset.isUser && (\n                              <span className=\"text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue\">\n                                {t('configureAmsSlot.custom')}\n                              </span>\n                            )}\n                          </div>\n                        </div>\n                      </button>\n                    ))\n                  )}\n                </div>\n              </div>\n\n              {/* Right column: K Profile + Color */}\n              <div className=\"w-1/2 flex flex-col gap-4 min-h-0 overflow-y-auto\">\n                {/* K Profile Select */}\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-2\">\n                    {t('configureAmsSlot.kProfileLabel')}\n                    {selectedMaterial && (\n                      <span className=\"ml-2 text-xs text-bambu-blue\">\n                        {t('configureAmsSlot.filteringFor', { material: selectedMaterial })}\n                      </span>\n                    )}\n                  </label>\n                  {matchingKProfiles.length > 0 ? (\n                    <div className=\"relative\">\n                      <select\n                        value={selectedKProfile?.name || ''}\n                        onChange={(e) => {\n                          const profile = matchingKProfiles.find(p => p.name === e.target.value);\n                          setSelectedKProfile(profile || null);\n                        }}\n                        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 appearance-none pr-10\"\n                      >\n                        <option value=\"\">{t('configureAmsSlot.noKProfile')}</option>\n                        {matchingKProfiles.map((profile) => (\n                          <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>\n                            {profile.name} (K={profile.k_value})\n                          </option>\n                        ))}\n                      </select>\n                      <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                    </div>\n                  ) : selectedPresetId ? (\n                    <p className=\"text-sm text-bambu-gray italic py-2\">\n                      {t('configureAmsSlot.noMatchingKProfiles')}\n                    </p>\n                  ) : (\n                    <span className=\"inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30\">\n                      {t('configureAmsSlot.selectFilamentFirst')}\n                    </span>\n                  )}\n                  {selectedKProfile && (\n                    <p className=\"text-xs text-bambu-green mt-1\">\n                      {t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}\n                    </p>\n                  )}\n                </div>\n\n                {/* Custom color */}\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-2\">\n                    {t('configureAmsSlot.customColorLabel')}\n                  </label>\n                  {catalogColors.length > 0 && (\n                    <div className=\"mb-3\">\n                      <p className=\"text-xs text-bambu-gray mb-1.5\">\n                        {t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}\n                      </p>\n                      <div className=\"flex flex-wrap gap-1.5\">\n                        {catalogColors.map((entry) => (\n                          <button\n                            key={entry.id}\n                            onClick={() => {\n                              const hex = entry.hex_color.replace('#', '').toUpperCase();\n                              setColorHex(hex);\n                              setColorInput(entry.color_name);\n                            }}\n                            className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${\n                              colorHex === entry.hex_color.replace('#', '').toUpperCase()\n                                ? 'border-bambu-green scale-105'\n                                : 'border-white/20 hover:border-white/40'\n                            }`}\n                            title={entry.color_name}\n                          >\n                            <span\n                              className=\"w-4 h-4 rounded-full border border-black/20 flex-shrink-0\"\n                              style={{ backgroundColor: entry.hex_color }}\n                            />\n                            <span className=\"text-xs text-white/80 whitespace-nowrap\">{entry.color_name}</span>\n                          </button>\n                        ))}\n                      </div>\n                    </div>\n                  )}\n                  <div className=\"flex flex-wrap gap-1.5 mb-2\">\n                    {QUICK_COLORS_BASIC.map((color) => (\n                      <button\n                        key={color.hex}\n                        onClick={() => {\n                          setColorHex(color.hex);\n                          setColorInput(color.name);\n                        }}\n                        className={`w-7 h-7 rounded-md border-2 transition-all ${\n                          colorHex === color.hex\n                            ? 'border-bambu-green scale-110'\n                            : 'border-white/20 hover:border-white/40'\n                        }`}\n                        style={{ backgroundColor: `#${color.hex}` }}\n                        title={color.name}\n                      />\n                    ))}\n                    <button\n                      onClick={() => setShowExtendedColors(!showExtendedColors)}\n                      className=\"w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs\"\n                      title={showExtendedColors ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}\n                    >\n                      {showExtendedColors ? '−' : '+'}\n                    </button>\n                  </div>\n                  {showExtendedColors && (\n                    <div className=\"flex flex-wrap gap-1.5 mb-2\">\n                      {QUICK_COLORS_EXTENDED.map((color) => (\n                        <button\n                          key={color.hex}\n                          onClick={() => {\n                            setColorHex(color.hex);\n                            setColorInput(color.name);\n                          }}\n                          className={`w-7 h-7 rounded-md border-2 transition-all ${\n                            colorHex === color.hex\n                              ? 'border-bambu-green scale-110'\n                              : 'border-white/20 hover:border-white/40'\n                          }`}\n                          style={{ backgroundColor: `#${color.hex}` }}\n                          title={color.name}\n                        />\n                      ))}\n                    </div>\n                  )}\n                  <div className=\"flex gap-2 items-center\">\n                    <div\n                      className=\"w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0\"\n                      style={{ backgroundColor: `#${displayColor}` }}\n                    />\n                    <input\n                      type=\"text\"\n                      placeholder={t('configureAmsSlot.colorPlaceholder')}\n                      value={colorInput}\n                      onChange={(e) => {\n                        const input = e.target.value;\n                        setColorInput(input);\n                        const nameHex = colorNameToHex(input);\n                        if (nameHex) {\n                          setColorHex(nameHex);\n                        } else {\n                          const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();\n                          if (cleaned.length === 6) {\n                            setColorHex(cleaned);\n                          } else if (cleaned.length === 3) {\n                            setColorHex(cleaned.split('').map(c => c + c).join(''));\n                          }\n                        }\n                      }}\n                      className=\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm\"\n                    />\n                    {colorHex && (\n                      <button\n                        onClick={() => {\n                          setColorHex('');\n                          setColorInput('');\n                        }}\n                        className=\"px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded\"\n                        title={t('configureAmsSlot.clearCustomColor')}\n                      >\n                        {t('configureAmsSlot.clear')}\n                      </button>\n                    )}\n                  </div>\n                  {colorHex && (\n                    <p className=\"text-xs text-bambu-gray mt-1.5\">\n                      {t('configureAmsSlot.hexLabel', { hex: colorHex })}\n                    </p>\n                  )}\n                </div>\n              </div>\n            </div>\n          ) : (\n            <>\n              {/* Filament Profile Select */}\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-2\">\n                  {t('configureAmsSlot.filamentProfile')} <span className=\"text-red-400\">*</span>\n                </label>\n                <div className=\"relative\">\n                  <input\n                    type=\"text\"\n                    placeholder={t('configureAmsSlot.searchPresets')}\n                    value={searchQuery}\n                    onChange={(e) => setSearchQuery(e.target.value)}\n                    className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2\"\n                  />\n                  <div className=\"max-h-48 overflow-y-auto space-y-1\">\n                    {filteredPresets.length === 0 ? (\n                      <p className=\"text-center py-4 text-bambu-gray\">\n                        {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)\n                          ? t('configureAmsSlot.noPresetsAvailable')\n                          : t('configureAmsSlot.noMatchingPresets')}\n                      </p>\n                    ) : (\n                      filteredPresets.map((preset) => (\n                        <button\n                          key={preset.id}\n                          data-preset-id={preset.id}\n                          onClick={() => setSelectedPresetId(preset.id)}\n                          className={`w-full p-2 rounded-lg border text-left transition-colors ${\n                            selectedPresetId === preset.id\n                              ? 'bg-bambu-green/20 border-bambu-green'\n                              : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'\n                          }`}\n                        >\n                          <div className=\"flex items-center justify-between\">\n                            <span className=\"text-white text-sm truncate\">{preset.name}</span>\n                            <div className=\"flex items-center gap-1 flex-shrink-0\">\n                              {preset.source === 'local' && (\n                                <span className=\"text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400\">\n                                  {t('profiles.localProfiles.badge')}\n                                </span>\n                              )}\n                              {preset.source === 'builtin' && (\n                                <span className=\"text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400\">\n                                  {t('configureAmsSlot.builtin')}\n                                </span>\n                              )}\n                              {preset.isUser && (\n                                <span className=\"text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue\">\n                                  {t('configureAmsSlot.custom')}\n                                </span>\n                              )}\n                            </div>\n                          </div>\n                        </button>\n                      ))\n                    )}\n                  </div>\n                </div>\n              </div>\n\n              {/* K Profile Select */}\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-2\">\n                  {t('configureAmsSlot.kProfileLabel')}\n                  {selectedMaterial && (\n                    <span className=\"ml-2 text-xs text-bambu-blue\">\n                      {t('configureAmsSlot.filteringFor', { material: selectedMaterial })}\n                    </span>\n                  )}\n                </label>\n                {matchingKProfiles.length > 0 ? (\n                  <div className=\"relative\">\n                    <select\n                      value={selectedKProfile?.name || ''}\n                      onChange={(e) => {\n                        const profile = matchingKProfiles.find(p => p.name === e.target.value);\n                        setSelectedKProfile(profile || null);\n                      }}\n                      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 appearance-none pr-10\"\n                    >\n                      <option value=\"\">{t('configureAmsSlot.noKProfile')}</option>\n                      {matchingKProfiles.map((profile) => (\n                        <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>\n                          {profile.name} (K={profile.k_value})\n                        </option>\n                      ))}\n                    </select>\n                    <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                  </div>\n                ) : selectedPresetId ? (\n                  <p className=\"text-sm text-bambu-gray italic py-2\">\n                    {t('configureAmsSlot.noMatchingKProfiles')}\n                  </p>\n                ) : (\n                  <span className=\"inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30\">\n                    {t('configureAmsSlot.selectFilamentFirst')}\n                  </span>\n                )}\n                {selectedKProfile && (\n                  <p className=\"text-xs text-bambu-green mt-1\">\n                    {t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}\n                  </p>\n                )}\n              </div>\n\n              {/* Optional: Custom color */}\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-2\">\n                  {t('configureAmsSlot.customColorLabel')}\n                </label>\n                {/* Catalog colors matching selected preset */}\n                {catalogColors.length > 0 && (\n                  <div className=\"mb-3\">\n                    <p className=\"text-xs text-bambu-gray mb-1.5\">\n                      {t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}\n                    </p>\n                    <div className=\"flex flex-wrap gap-1.5\">\n                      {catalogColors.map((entry) => (\n                        <button\n                          key={entry.id}\n                          onClick={() => {\n                            const hex = entry.hex_color.replace('#', '').toUpperCase();\n                            setColorHex(hex);\n                            setColorInput(entry.color_name);\n                          }}\n                          className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${\n                            colorHex === entry.hex_color.replace('#', '').toUpperCase()\n                              ? 'border-bambu-green scale-105'\n                              : 'border-white/20 hover:border-white/40'\n                          }`}\n                          title={entry.color_name}\n                        >\n                          <span\n                            className=\"w-4 h-4 rounded-full border border-black/20 flex-shrink-0\"\n                            style={{ backgroundColor: entry.hex_color }}\n                          />\n                          <span className=\"text-xs text-white/80 whitespace-nowrap\">{entry.color_name}</span>\n                        </button>\n                      ))}\n                    </div>\n                  </div>\n                )}\n                {/* Quick color buttons */}\n                <div className=\"flex flex-wrap gap-1.5 mb-2\">\n                  {QUICK_COLORS_BASIC.map((color) => (\n                    <button\n                      key={color.hex}\n                      onClick={() => {\n                        setColorHex(color.hex);\n                        setColorInput(color.name);\n                      }}\n                      className={`w-7 h-7 rounded-md border-2 transition-all ${\n                        colorHex === color.hex\n                          ? 'border-bambu-green scale-110'\n                          : 'border-white/20 hover:border-white/40'\n                      }`}\n                      style={{ backgroundColor: `#${color.hex}` }}\n                      title={color.name}\n                    />\n                  ))}\n                  <button\n                    onClick={() => setShowExtendedColors(!showExtendedColors)}\n                    className=\"w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs\"\n                    title={showExtendedColors ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}\n                  >\n                    {showExtendedColors ? '−' : '+'}\n                  </button>\n                </div>\n                {/* Extended colors (collapsible) */}\n                {showExtendedColors && (\n                  <div className=\"flex flex-wrap gap-1.5 mb-2\">\n                    {QUICK_COLORS_EXTENDED.map((color) => (\n                      <button\n                        key={color.hex}\n                        onClick={() => {\n                          setColorHex(color.hex);\n                          setColorInput(color.name);\n                        }}\n                        className={`w-7 h-7 rounded-md border-2 transition-all ${\n                          colorHex === color.hex\n                            ? 'border-bambu-green scale-110'\n                            : 'border-white/20 hover:border-white/40'\n                        }`}\n                        style={{ backgroundColor: `#${color.hex}` }}\n                        title={color.name}\n                      />\n                    ))}\n                  </div>\n                )}\n                {/* Color input: name or hex */}\n                <div className=\"flex gap-2 items-center\">\n                  <div\n                    className=\"w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0\"\n                    style={{ backgroundColor: `#${displayColor}` }}\n                  />\n                  <input\n                    type=\"text\"\n                    placeholder={t('configureAmsSlot.colorPlaceholder')}\n                    value={colorInput}\n                    onChange={(e) => {\n                      const input = e.target.value;\n                      setColorInput(input);\n\n                      // Try to parse as color name first\n                      const nameHex = colorNameToHex(input);\n                      if (nameHex) {\n                        setColorHex(nameHex);\n                      } else {\n                        // Try to parse as hex code\n                        const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();\n                        if (cleaned.length === 6) {\n                          setColorHex(cleaned);\n                        } else if (cleaned.length === 3) {\n                          // Expand shorthand hex (e.g., F00 -> FF0000)\n                          setColorHex(cleaned.split('').map(c => c + c).join(''));\n                        }\n                      }\n                    }}\n                    className=\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm\"\n                  />\n                  {colorHex && (\n                    <button\n                      onClick={() => {\n                        setColorHex('');\n                        setColorInput('');\n                      }}\n                      className=\"px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded\"\n                      title={t('configureAmsSlot.clearCustomColor')}\n                    >\n                      {t('configureAmsSlot.clear')}\n                    </button>\n                  )}\n                </div>\n                {colorHex && (\n                  <p className=\"text-xs text-bambu-gray mt-1.5\">\n                    {t('configureAmsSlot.hexLabel', { hex: colorHex })}\n                  </p>\n                )}\n              </div>\n            </>\n          )}\n        </div>\n\n        {/* Footer */}\n        <div className=\"flex justify-between p-4 border-t border-bambu-dark-tertiary shrink-0\">\n          {/* Reset button on the left */}\n          <Button\n            variant=\"secondary\"\n            onClick={() => resetMutation.mutate()}\n            disabled={resetMutation.isPending || configureMutation.isPending}\n            className=\"text-red-400 hover:text-red-300 hover:bg-red-500/10\"\n          >\n            {resetMutation.isPending ? (\n              <>\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n                {t('configureAmsSlot.resetting')}\n              </>\n            ) : (\n              <>\n                <RotateCcw className=\"w-4 h-4\" />\n                {t('configureAmsSlot.resetSlot')}\n              </>\n            )}\n          </Button>\n          {/* Cancel and Configure buttons on the right */}\n          <div className=\"flex gap-2\">\n            <Button variant=\"secondary\" onClick={onClose}>\n              {t('configureAmsSlot.cancel')}\n            </Button>\n            <Button\n              onClick={() => configureMutation.mutate()}\n              disabled={!canSave}\n            >\n              {configureMutation.isPending ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  {t('configureAmsSlot.configuring')}\n                </>\n              ) : (\n                <>\n                  <Settings2 className=\"w-4 h-4\" />\n                  {t('configureAmsSlot.configureSlot')}\n                </>\n              )}\n            </Button>\n          </div>\n        </div>\n\n        {/* Error */}\n        {(configureMutation.isError || resetMutation.isError) && (\n          <div className=\"mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400\">\n            {(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ConfirmModal.tsx",
    "content": "import { useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { AlertTriangle, Loader2 } from 'lucide-react';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\n\ninterface ConfirmModalProps {\n  title: string;\n  message: string;\n  confirmText?: string;\n  cancelText?: string;\n  cancelVariant?: 'primary' | 'secondary' | 'danger' | 'ghost';\n  cardClassName?: string;\n  variant?: 'danger' | 'warning' | 'default';\n  isLoading?: boolean;\n  loadingText?: string;\n  onConfirm: () => void;\n  onCancel: () => void;\n}\n\nexport function ConfirmModal({\n  title,\n  message,\n  confirmText,\n  cancelText,\n  cancelVariant,\n  cardClassName,\n  variant = 'default',\n  isLoading = false,\n  loadingText,\n  onConfirm,\n  onCancel,\n}: ConfirmModalProps) {\n  const { t } = useTranslation();\n  const resolvedConfirmText = confirmText ?? t('common.confirm');\n  const resolvedCancelText = cancelText ?? t('common.cancel');\n  const resolvedLoadingText = loadingText ?? t('common.loading');\n  // Close on Escape key (but not while loading)\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && !isLoading) onCancel();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onCancel, isLoading]);\n\n  const variantStyles = {\n    danger: {\n      icon: 'text-red-400',\n      button: 'bg-red-500 hover:bg-red-600',\n    },\n    warning: {\n      icon: 'text-yellow-400',\n      button: 'bg-yellow-500 hover:bg-yellow-600 text-black',\n    },\n    default: {\n      icon: 'text-bambu-green',\n      button: 'bg-bambu-green hover:bg-bambu-green-dark',\n    },\n  };\n\n  const styles = variantStyles[variant];\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\"\n      onClick={isLoading ? undefined : onCancel}\n    >\n      <Card\n        className={`w-full max-w-md ${cardClassName ?? ''}`}\n        onClick={(e: React.MouseEvent) => e.stopPropagation()}\n      >\n        <CardContent className=\"p-6\">\n          <div className=\"flex items-start gap-4\">\n            <div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>\n              <AlertTriangle className=\"w-6 h-6\" />\n            </div>\n            <div className=\"flex-1\">\n              <h3 className=\"text-lg font-semibold text-white mb-2\">{title}</h3>\n              <p className=\"text-bambu-gray text-sm whitespace-pre-line\">{message}</p>\n            </div>\n          </div>\n          <div className=\"flex gap-3 mt-6\">\n            <Button\n              variant={cancelVariant ?? 'secondary'}\n              onClick={onCancel}\n              className=\"flex-1\"\n              disabled={isLoading}\n            >\n              {resolvedCancelText}\n            </Button>\n            <Button\n              onClick={onConfirm}\n              className={`flex-1 ${styles.button}`}\n              disabled={isLoading}\n            >\n              {isLoading ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 mr-2 animate-spin\" />\n                  {resolvedLoadingText}\n                </>\n              ) : (\n                resolvedConfirmText\n              )}\n            </Button>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ContextMenu.tsx",
    "content": "import { useEffect, useRef, useState, useLayoutEffect } from 'react';\nimport { ChevronRight } from 'lucide-react';\n\nexport interface ContextMenuItem {\n  label: string;\n  icon?: React.ReactNode;\n  onClick: () => void;\n  danger?: boolean;\n  disabled?: boolean;\n  divider?: boolean;\n  submenu?: ContextMenuItem[];\n  title?: string;\n}\n\ninterface ContextMenuProps {\n  x: number;\n  y: number;\n  items: ContextMenuItem[];\n  onClose: () => void;\n}\n\nexport function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {\n  const menuRef = useRef<HTMLDivElement>(null);\n  const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);\n  const submenuTimeoutRef = useRef<number | null>(null);\n  const [position, setPosition] = useState({ x, y, visible: false });\n  const [openSubmenuLeft, setOpenSubmenuLeft] = useState(false);\n  const [submenuPositions, setSubmenuPositions] = useState<Record<number, 'top' | 'bottom'>>({});\n\n  useEffect(() => {\n    const handleClickOutside = (e: MouseEvent) => {\n      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {\n        onClose();\n      }\n    };\n\n    const handleEscape = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        onClose();\n      }\n    };\n\n    const handleScroll = () => {\n      onClose();\n    };\n\n    document.addEventListener('mousedown', handleClickOutside);\n    document.addEventListener('keydown', handleEscape);\n    document.addEventListener('scroll', handleScroll, true);\n\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside);\n      document.removeEventListener('keydown', handleEscape);\n      document.removeEventListener('scroll', handleScroll, true);\n      if (submenuTimeoutRef.current) {\n        clearTimeout(submenuTimeoutRef.current);\n      }\n    };\n  }, [onClose]);\n\n  // Adjust position to keep menu in viewport - use useLayoutEffect for synchronous measurement\n  useLayoutEffect(() => {\n    if (menuRef.current) {\n      // Force a reflow to get accurate measurements\n      menuRef.current.style.visibility = 'hidden';\n      menuRef.current.style.display = 'block';\n\n      const rect = menuRef.current.getBoundingClientRect();\n      const viewportWidth = window.innerWidth;\n      const viewportHeight = window.innerHeight;\n      const padding = 8;\n\n      let adjustedX = x;\n      let adjustedY = y;\n\n      // Adjust horizontal position - if menu would overflow right, shift left\n      if (x + rect.width > viewportWidth - padding) {\n        adjustedX = Math.max(padding, viewportWidth - rect.width - padding);\n      }\n      // Also check if starting position is negative\n      if (adjustedX < padding) {\n        adjustedX = padding;\n      }\n\n      // Adjust vertical position - if menu would overflow bottom, shift up\n      if (y + rect.height > viewportHeight - padding) {\n        adjustedY = Math.max(padding, viewportHeight - rect.height - padding);\n      }\n      // Also check if starting position is negative\n      if (adjustedY < padding) {\n        adjustedY = padding;\n      }\n\n      // Check if submenus should open to the left (more space on left than right)\n      const submenuWidth = 180;\n      const spaceOnRight = viewportWidth - adjustedX - rect.width;\n      const spaceOnLeft = adjustedX;\n      // Only open left if there's not enough space on right AND there's enough space on left\n      setOpenSubmenuLeft(spaceOnRight < submenuWidth && spaceOnLeft > submenuWidth);\n\n      setPosition({ x: adjustedX, y: adjustedY, visible: true });\n    }\n  }, [x, y]);\n\n  const handleMouseEnterSubmenu = (index: number, element: HTMLElement) => {\n    if (submenuTimeoutRef.current) {\n      clearTimeout(submenuTimeoutRef.current);\n      submenuTimeoutRef.current = null;\n    }\n\n    // Calculate if submenu should open upward or downward\n    const rect = element.getBoundingClientRect();\n    const viewportHeight = window.innerHeight;\n    const submenuMaxHeight = 300; // matches max-h-[300px]\n    const padding = 8;\n\n    // Check if there's enough space below for the submenu\n    const spaceBelow = viewportHeight - rect.top - padding;\n    const shouldOpenUpward = spaceBelow < submenuMaxHeight && rect.top > submenuMaxHeight;\n\n    setSubmenuPositions(prev => ({ ...prev, [index]: shouldOpenUpward ? 'bottom' : 'top' }));\n    setActiveSubmenu(index);\n  };\n\n  const handleMouseLeaveSubmenu = () => {\n    submenuTimeoutRef.current = window.setTimeout(() => {\n      setActiveSubmenu(null);\n    }, 150);\n  };\n\n  return (\n    <div\n      ref={menuRef}\n      className=\"fixed z-50 min-w-[180px] max-w-[280px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1\"\n      style={{\n        left: position.x,\n        top: position.y,\n        visibility: position.visible ? 'visible' : 'hidden'\n      }}\n    >\n      {items.map((item, index) => {\n        if (item.divider) {\n          return <div key={index} className=\"my-1 border-t border-bambu-dark-tertiary\" />;\n        }\n\n        const hasSubmenu = item.submenu && item.submenu.length > 0;\n\n        return (\n          <div\n            key={index}\n            className=\"relative\"\n            onMouseEnter={(e) => hasSubmenu && handleMouseEnterSubmenu(index, e.currentTarget)}\n            onMouseLeave={() => hasSubmenu && handleMouseLeaveSubmenu()}\n          >\n            <button\n              onMouseEnter={(e) => hasSubmenu && handleMouseEnterSubmenu(index, e.currentTarget.parentElement!)}\n              onClick={() => {\n                if (hasSubmenu) {\n                  // Toggle submenu on click as well\n                  setActiveSubmenu(activeSubmenu === index ? null : index);\n                } else if (!item.disabled) {\n                  item.onClick();\n                  onClose();\n                }\n              }}\n              disabled={item.disabled}\n              title={item.title}\n              className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${\n                item.disabled\n                  ? 'text-bambu-gray cursor-not-allowed'\n                  : item.danger\n                  ? 'text-red-400 hover:bg-red-400/10'\n                  : 'text-white hover:bg-bambu-dark-tertiary'\n              } ${hasSubmenu && activeSubmenu === index ? 'bg-bambu-dark-tertiary' : ''}`}\n            >\n              {item.icon && <span className=\"w-4 h-4 flex-shrink-0 flex items-center justify-center\">{item.icon}</span>}\n              <span className=\"flex-1\">{item.label}</span>\n              {hasSubmenu && <ChevronRight className=\"w-4 h-4 text-bambu-gray\" />}\n            </button>\n            {/* Submenu */}\n            {hasSubmenu && activeSubmenu === index && (\n              <div\n                className={`absolute min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden max-h-[300px] overflow-y-auto z-[60] ${\n                  openSubmenuLeft ? 'right-full mr-1' : 'left-full ml-1'\n                } ${submenuPositions[index] === 'bottom' ? 'bottom-0' : 'top-0'}`}\n                onMouseEnter={() => {\n                  if (submenuTimeoutRef.current) {\n                    clearTimeout(submenuTimeoutRef.current);\n                    submenuTimeoutRef.current = null;\n                  }\n                }}\n                onMouseLeave={() => handleMouseLeaveSubmenu()}\n              >\n                {item.submenu!.map((subItem, subIndex) => (\n                  <button\n                    key={subIndex}\n                    onClick={() => {\n                      if (!subItem.disabled) {\n                        subItem.onClick();\n                        onClose();\n                      }\n                    }}\n                    disabled={subItem.disabled}\n                    className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${\n                      subItem.disabled\n                        ? 'text-bambu-gray cursor-not-allowed'\n                        : subItem.danger\n                        ? 'text-red-400 hover:bg-red-400/10'\n                        : 'text-white hover:bg-bambu-dark-tertiary'\n                    }`}\n                  >\n                    {subItem.icon && <span className=\"w-4 h-4 flex-shrink-0 flex items-center justify-center\">{subItem.icon}</span>}\n                    {subItem.label}\n                  </button>\n                ))}\n              </div>\n            )}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/CreateUserAdvancedAuthModal.tsx",
    "content": "import { useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { X, Plus, Loader2, Users as UsersIcon } from 'lucide-react';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport type { Group, UserCreate } from '../api/client';\n\ninterface AdvancedAuthFormData extends UserCreate {\n  group_ids: number[];\n  confirmPassword: string;\n  email?: string;\n}\n\ninterface CreateUserAdvancedAuthModalProps {\n  formData: AdvancedAuthFormData;\n  setFormData: (data: AdvancedAuthFormData) => void;\n  groups: Group[];\n  onClose: () => void;\n  onCreate: () => void;\n  isCreating: boolean;\n  isCreateButtonDisabled: boolean;\n}\n\nexport function CreateUserAdvancedAuthModal({\n  formData,\n  setFormData,\n  groups,\n  onClose,\n  onCreate,\n  isCreating,\n  isCreateButtonDisabled,\n}: CreateUserAdvancedAuthModalProps) {\n  const { t } = useTranslation();\n\n  // Close modal on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        onClose();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  const toggleGroup = (groupId: number) => {\n    setFormData({\n      ...formData,\n      group_ids: formData.group_ids.includes(groupId)\n        ? formData.group_ids.filter(id => id !== groupId)\n        : [...formData.group_ids, groupId],\n    });\n  };\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n      onClick={onClose}\n    >\n      <Card\n        className=\"w-full max-w-md\"\n        onClick={(e: React.MouseEvent) => e.stopPropagation()}\n      >\n        <CardHeader>\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex flex-col gap-1\">\n              <div className=\"flex items-center gap-2\">\n                <UsersIcon className=\"w-5 h-5 text-bambu-green\" />\n                <h2 className=\"text-lg font-semibold text-white\">{t('users.modal.createUser')}</h2>\n              </div>\n              <p className=\"text-sm text-bambu-gray ml-7\">{t('users.modal.advancedAuthSubtitle') || 'with Advanced Authentication'}</p>\n            </div>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={onClose}\n            >\n              <X className=\"w-5 h-5\" />\n            </Button>\n          </div>\n        </CardHeader>\n        <CardContent>\n          <div className=\"space-y-4\">\n            {/* Username Field */}\n            <div>\n              <label className=\"block text-sm font-medium text-white mb-2\">\n                {t('users.form.username')} <span className=\"text-red-400\">*</span>\n              </label>\n              <input\n                type=\"text\"\n                value={formData.username}\n                onChange={(e) => setFormData({ ...formData, username: e.target.value })}\n                className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                placeholder={t('users.form.usernamePlaceholder')}\n                autoComplete=\"username\"\n                required\n              />\n            </div>\n\n            {/* Email Field */}\n            <div>\n              <label className=\"block text-sm font-medium text-white mb-2\">\n                {t('users.form.email') || 'Email'} <span className=\"text-red-400\">*</span>\n              </label>\n              <input\n                type=\"email\"\n                value={formData.email}\n                onChange={(e) => setFormData({ ...formData, email: e.target.value })}\n                className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}\n                required\n              />\n            </div>\n\n            {/* Info box about auto-generated password */}\n            <div className=\"bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3\">\n              <p className=\"text-sm text-bambu-gray\">\n                {t('users.form.autoGeneratedPassword') || 'A secure password will be automatically generated and emailed to the user.'}\n              </p>\n            </div>\n\n            {/* Groups Field */}\n            <div>\n              <label className=\"block text-sm font-medium text-white mb-2\">\n                {t('users.form.groups')}\n              </label>\n              <div className=\"space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\">\n                {groups.map(group => (\n                  <label\n                    key={group.id}\n                    className=\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer\"\n                  >\n                    <input\n                      type=\"checkbox\"\n                      checked={formData.group_ids.includes(group.id)}\n                      onChange={() => toggleGroup(group.id)}\n                      className=\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark\"\n                    />\n                    <span className=\"text-sm text-white\">{group.name}</span>\n                    {group.is_system && (\n                      <span className=\"text-xs text-yellow-400\">({t('users.system')})</span>\n                    )}\n                  </label>\n                ))}\n                {groups.length === 0 && (\n                  <p className=\"text-sm text-bambu-gray\">{t('users.noGroupsAvailable')}</p>\n                )}\n              </div>\n            </div>\n          </div>\n\n          {/* Action Buttons */}\n          <div className=\"mt-6 flex justify-end gap-3\">\n            <Button\n              variant=\"secondary\"\n              onClick={onClose}\n            >\n              {t('users.modal.cancel')}\n            </Button>\n            <Button\n              onClick={onCreate}\n              disabled={isCreateButtonDisabled}\n            >\n              {isCreating ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  {t('users.modal.creating')}\n                </>\n              ) : (\n                <>\n                  <Plus className=\"w-4 h-4\" />\n                  {t('users.modal.createUser')}\n                </>\n              )}\n            </Button>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/Dashboard.tsx",
    "content": "import { useState, useEffect, type ReactNode } from 'react';\nimport {\n  DndContext,\n  closestCenter,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  type DragEndEvent,\n} from '@dnd-kit/core';\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  useSortable,\n  rectSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport { GripVertical, Eye, EyeOff, RotateCcw, Maximize2, Minimize2 } from 'lucide-react';\nimport { Button } from './Button';\n\nexport interface DashboardWidget {\n  id: string;\n  title: string;\n  /** Render function that receives the current size for responsive content */\n  component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);\n  defaultVisible?: boolean;\n  defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default)\n}\n\ninterface DashboardProps {\n  widgets: DashboardWidget[];\n  storageKey: string;\n  columns?: number;\n  stackBelow?: number;\n  hideControls?: boolean;\n  onResetLayout?: () => void;\n  renderControls?: (controls: {\n    hiddenCount: number;\n    showHiddenPanel: boolean;\n    setShowHiddenPanel: (show: boolean) => void;\n    resetLayout: () => void;\n  }) => ReactNode;\n}\n\ninterface LayoutState {\n  order: string[];\n  hidden: string[];\n  sizes: Record<string, 1 | 2 | 4>;\n}\n\nfunction SortableWidget({\n  id,\n  title,\n  component,\n  isHidden,\n  size,\n  columnSpan,\n  onToggleVisibility,\n  onToggleSize,\n}: {\n  id: string;\n  title: string;\n  component: ReactNode | ((size: 1 | 2 | 4) => ReactNode);\n  isHidden: boolean;\n  size: 1 | 2 | 4;\n  columnSpan: number;\n  onToggleVisibility: () => void;\n  onToggleSize: () => void;\n}) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id });\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  };\n\n  if (isHidden) return null;\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={{\n        ...style,\n        gridColumn: `span ${columnSpan}`,\n      }}\n      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden ${\n        isDragging ? 'ring-2 ring-bambu-green shadow-lg' : ''\n      }`}\n    >\n      {/* Widget Header */}\n      <div className=\"flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/30\">\n        <div className=\"flex items-center gap-2\">\n          <button\n            {...attributes}\n            {...listeners}\n            className=\"cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\"\n            title=\"Drag to reorder\"\n          >\n            <GripVertical className=\"w-6 h-6 md:w-4 md:h-4 text-bambu-gray\" />\n          </button>\n          <h3 className=\"text-sm font-medium text-white\">{title}</h3>\n        </div>\n        <div className=\"flex items-center gap-1\">\n          <button\n            onClick={onToggleSize}\n            className=\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\"\n            title={`Size: ${size === 1 ? '1/4' : size === 2 ? '1/2' : 'Full'} - Click to cycle`}\n          >\n            {size === 4 ? (\n              <Minimize2 className=\"w-4 h-4 text-bambu-gray hover:text-white\" />\n            ) : (\n              <Maximize2 className=\"w-4 h-4 text-bambu-gray hover:text-white\" />\n            )}\n          </button>\n          <button\n            onClick={onToggleVisibility}\n            className=\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\"\n            title=\"Hide widget\"\n          >\n            <EyeOff className=\"w-4 h-4 text-bambu-gray hover:text-white\" />\n          </button>\n        </div>\n      </div>\n      {/* Widget Content */}\n      <div className=\"p-4\">\n        {typeof component === 'function' ? component(size) : component}\n      </div>\n    </div>\n  );\n}\n\nexport function Dashboard({ widgets, storageKey, columns = 4, stackBelow, hideControls = false, onResetLayout, renderControls }: DashboardProps) {\n  // Build default sizes from widget definitions\n  const getDefaultSizes = () => {\n    const sizes: Record<string, 1 | 2 | 4> = {};\n    widgets.forEach((w) => {\n      sizes[w.id] = w.defaultSize || 4;\n    });\n    return sizes;\n  };\n\n  const [layout, setLayout] = useState<LayoutState>(() => {\n    // Load saved layout from localStorage\n    const saved = localStorage.getItem(storageKey);\n    if (saved) {\n      try {\n        const parsed = JSON.parse(saved);\n        // Ensure sizes exist (for backwards compatibility)\n        if (!parsed.sizes) {\n          parsed.sizes = getDefaultSizes();\n        } else {\n          // Merge in default sizes for any new widgets not in saved layout\n          const defaults = getDefaultSizes();\n          for (const id in defaults) {\n            if (!(id in parsed.sizes)) {\n              parsed.sizes[id] = defaults[id];\n            }\n          }\n        }\n        return parsed;\n      } catch {\n        // Invalid JSON, use default\n      }\n    }\n    // Default layout: all widgets visible in original order\n    return {\n      order: widgets.map((w) => w.id),\n      hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),\n      sizes: getDefaultSizes(),\n    };\n  });\n\n  const [showHiddenPanel, setShowHiddenPanel] = useState(false);\n  const [isStacked, setIsStacked] = useState(false);\n\n  useEffect(() => {\n    if (!stackBelow) return undefined;\n    const mediaQuery = window.matchMedia(`(max-width: ${stackBelow}px)`);\n    const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {\n      setIsStacked(event.matches);\n    };\n    handleChange(mediaQuery);\n    const onChange = (event: MediaQueryListEvent) => handleChange(event);\n    if (mediaQuery.addEventListener) {\n      mediaQuery.addEventListener('change', onChange);\n    } else {\n      mediaQuery.addListener(onChange);\n    }\n    return () => {\n      if (mediaQuery.removeEventListener) {\n        mediaQuery.removeEventListener('change', onChange);\n      } else {\n        mediaQuery.removeListener(onChange);\n      }\n    };\n  }, [stackBelow]);\n\n  const effectiveColumns = stackBelow && isStacked ? 1 : columns;\n\n  // Listen for toggle-hidden-panel event from parent\n  useEffect(() => {\n    const handleToggle = () => setShowHiddenPanel(prev => !prev);\n    window.addEventListener('toggle-hidden-panel', handleToggle);\n    return () => window.removeEventListener('toggle-hidden-panel', handleToggle);\n  }, []);\n\n  // Save layout to localStorage whenever it changes\n  useEffect(() => {\n    localStorage.setItem(storageKey, JSON.stringify(layout));\n  }, [layout, storageKey]);\n\n  // Ensure all widget IDs are in the order array (for newly added widgets)\n  useEffect(() => {\n    const allIds = widgets.map((w) => w.id);\n    const missingIds = allIds.filter((id) => !layout.order.includes(id));\n    if (missingIds.length > 0) {\n      setLayout((prev) => ({\n        ...prev,\n        order: [...prev.order, ...missingIds],\n      }));\n    }\n  }, [widgets, layout.order]);\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8,\n      },\n    }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    })\n  );\n\n  const handleDragEnd = (event: DragEndEvent) => {\n    const { active, over } = event;\n\n    if (over && active.id !== over.id) {\n      setLayout((prev) => {\n        const oldIndex = prev.order.indexOf(active.id as string);\n        const newIndex = prev.order.indexOf(over.id as string);\n        return {\n          ...prev,\n          order: arrayMove(prev.order, oldIndex, newIndex),\n        };\n      });\n    }\n  };\n\n  const toggleVisibility = (id: string) => {\n    setLayout((prev) => ({\n      ...prev,\n      hidden: prev.hidden.includes(id)\n        ? prev.hidden.filter((h) => h !== id)\n        : [...prev.hidden, id],\n    }));\n  };\n\n  const toggleSize = (id: string) => {\n    setLayout((prev) => {\n      const currentSize = prev.sizes[id] || 4;\n      // Cycle: 1 → 2 → 4 → 1\n      const nextSize = currentSize === 1 ? 2 : currentSize === 2 ? 4 : 1;\n      return {\n        ...prev,\n        sizes: {\n          ...prev.sizes,\n          [id]: nextSize as 1 | 2 | 4,\n        },\n      };\n    });\n  };\n\n  const resetLayout = () => {\n    const defaultLayout = {\n      order: widgets.map((w) => w.id),\n      hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id),\n      sizes: getDefaultSizes(),\n    };\n    setLayout(defaultLayout);\n    onResetLayout?.();\n  };\n\n  // Get ordered widgets\n  const orderedWidgets = layout.order\n    .map((id) => widgets.find((w) => w.id === id))\n    .filter(Boolean) as DashboardWidget[];\n\n  const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id));\n  const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id));\n\n  // Render external controls if provided\n  const externalControls = renderControls?.({\n    hiddenCount: hiddenWidgets.length,\n    showHiddenPanel,\n    setShowHiddenPanel,\n    resetLayout,\n  });\n\n  return (\n    <div className=\"space-y-4\">\n      {/* External controls slot */}\n      {externalControls}\n\n      {/* Dashboard Controls */}\n      {!hideControls && !renderControls && (\n        <div className=\"flex items-center justify-end gap-2\">\n          <Button variant=\"secondary\" size=\"sm\" onClick={resetLayout}>\n            <RotateCcw className=\"w-4 h-4\" />\n            Reset Layout\n          </Button>\n          {hiddenWidgets.length > 0 && (\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              onClick={() => setShowHiddenPanel(!showHiddenPanel)}\n            >\n              <Eye className=\"w-4 h-4\" />\n              {hiddenWidgets.length} Hidden\n            </Button>\n          )}\n        </div>\n      )}\n\n      {/* Hidden Widgets Panel */}\n      {showHiddenPanel && hiddenWidgets.length > 0 && (\n        <div className=\"p-4 bg-bambu-dark rounded-xl border border-bambu-dark-tertiary\">\n          <p className=\"text-sm text-bambu-gray mb-3\">Hidden widgets (click to show):</p>\n          <div className=\"flex flex-wrap gap-2\">\n            {hiddenWidgets.map((widget) => (\n              <button\n                key={widget.id}\n                onClick={() => toggleVisibility(widget.id)}\n                className=\"px-3 py-1.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded-lg text-sm text-white transition-colors flex items-center gap-2\"\n              >\n                <Eye className=\"w-3 h-3\" />\n                {widget.title}\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* Draggable Widgets Grid */}\n      <DndContext\n        sensors={sensors}\n        collisionDetection={closestCenter}\n        onDragEnd={handleDragEnd}\n      >\n        <SortableContext items={visibleWidgets.map((w) => w.id)} strategy={rectSortingStrategy}>\n          <div\n            className=\"grid gap-6\"\n            style={{\n              gridTemplateColumns: `repeat(${effectiveColumns}, minmax(0, 1fr))`,\n            }}\n          >\n            {visibleWidgets.map((widget) => {\n              const size = layout.sizes[widget.id] || 2;\n              const columnSpan = Math.min(size, effectiveColumns);\n              return (\n                <SortableWidget\n                  key={widget.id}\n                  id={widget.id}\n                  title={widget.title}\n                  component={widget.component}\n                  isHidden={layout.hidden.includes(widget.id)}\n                  size={size}\n                  columnSpan={columnSpan}\n                  onToggleVisibility={() => toggleVisibility(widget.id)}\n                  onToggleSize={() => toggleSize(widget.id)}\n                />\n              );\n            })}\n          </div>\n        </SortableContext>\n      </DndContext>\n\n      {visibleWidgets.length === 0 && (\n        <div className=\"text-center py-12 text-bambu-gray\">\n          <p>All widgets are hidden.</p>\n          <Button className=\"mt-4\" onClick={resetLayout}>\n            Reset Layout\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/EditArchiveModal.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { Archive } from '../api/client';\nimport { Button } from './Button';\n\n// Keys for failure reasons - translated at render time\nconst FAILURE_REASON_KEYS = [\n  'adhesionFailure',\n  'spaghettiDetached',\n  'layerShift',\n  'cloggedNozzle',\n  'filamentRunout',\n  'warping',\n  'stringing',\n  'underExtrusion',\n  'powerFailure',\n  'userCancelled',\n  'other',\n] as const;\n\n// Keys for archive statuses - translated at render time\nconst ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;\n\ninterface EditArchiveModalProps {\n  archive: Archive;\n  onClose: () => void;\n  existingTags?: string[];\n}\n\nexport function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {\n  const { t } = useTranslation();\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n  const queryClient = useQueryClient();\n  const [printName, setPrintName] = useState(archive.print_name || '');\n  const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);\n  const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);\n  const [notes, setNotes] = useState(archive.notes || '');\n  const [tags, setTags] = useState(archive.tags || '');\n  const [failureReason, setFailureReason] = useState(archive.failure_reason || '');\n  const [status, setStatus] = useState(archive.status);\n  const [quantity, setQuantity] = useState(archive.quantity ?? 1);\n  const [photos, setPhotos] = useState<string[]>(archive.photos || []);\n  const [externalUrl, setExternalUrl] = useState(archive.external_url || '');\n  const [uploadingPhoto, setUploadingPhoto] = useState(false);\n  const [showTagSuggestions, setShowTagSuggestions] = useState(false);\n  const tagInputRef = useRef<HTMLInputElement>(null);\n  const photoInputRef = useRef<HTMLInputElement>(null);\n  const blurTimeoutRef = useRef<number | null>(null);\n\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const { data: projects } = useQuery({\n    queryKey: ['projects'],\n    queryFn: () => api.getProjects(),\n  });\n\n  // Fetch all tags using the dedicated API\n  const { data: tagsData } = useQuery({\n    queryKey: ['tags'],\n    queryFn: api.getTags,\n    enabled: existingTags.length === 0,\n  });\n\n  // Use existing tags prop if provided, otherwise use fetched tags\n  const allTags = existingTags.length > 0\n    ? existingTags\n    : (tagsData?.map(t => t.name) || []);\n\n  // Get current tags as array\n  const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);\n\n  // Get the text being typed after the last comma (for autocomplete filtering)\n  const currentInput = tags.includes(',')\n    ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()\n    : tags.trim().toLowerCase();\n\n  // Filter suggestions: not already added AND matches current input (if any)\n  const tagSuggestions = allTags.filter(t =>\n    !currentTags.includes(t) &&\n    (currentInput === '' || t.toLowerCase().includes(currentInput))\n  );\n\n  // Add a tag (replaces any partial input with the selected tag)\n  const addTag = (tag: string) => {\n    // If there's partial input being typed, replace it with the selected tag\n    // Otherwise, just append the tag\n    let baseTags: string[];\n    if (currentInput && !allTags.includes(currentInput)) {\n      // User is typing a partial tag - replace it with the selected one\n      baseTags = tags.includes(',')\n        ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)\n        : [];\n    } else {\n      // No partial input or input is already a complete tag - append\n      baseTags = currentTags;\n    }\n\n    if (!baseTags.includes(tag)) {\n      const newTags = [...baseTags, tag].join(', ');\n      setTags(newTags);\n    }\n    // Clear any pending blur timeout to prevent hiding suggestions\n    if (blurTimeoutRef.current !== null) {\n      clearTimeout(blurTimeoutRef.current);\n    }\n    tagInputRef.current?.focus();\n  };\n\n  // Remove a tag\n  const removeTag = (tagToRemove: string) => {\n    const newTags = currentTags.filter(t => t !== tagToRemove).join(', ');\n    setTags(newTags);\n  };\n\n  const updateMutation = useMutation({\n    mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>\n      api.updateArchive(archive.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      queryClient.invalidateQueries({ queryKey: ['projects'] });\n      onClose();\n    },\n  });\n\n  const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n\n    setUploadingPhoto(true);\n    try {\n      const result = await api.uploadArchivePhoto(archive.id, file);\n      setPhotos(result.photos);\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n    } catch (error) {\n      console.error('Failed to upload photo:', error);\n    } finally {\n      setUploadingPhoto(false);\n      if (photoInputRef.current) {\n        photoInputRef.current.value = '';\n      }\n    }\n  };\n\n  const handlePhotoDelete = async (filename: string) => {\n    try {\n      const result = await api.deleteArchivePhoto(archive.id, filename);\n      setPhotos(result.photos || []);\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n    } catch (error) {\n      console.error('Failed to delete photo:', error);\n    }\n  };\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    // Build update data\n    const updateData: Parameters<typeof api.updateArchive>[1] = {\n      print_name: printName || undefined,\n      printer_id: printerId,\n      project_id: projectId,\n      notes: notes || undefined,\n      tags: tags || undefined,\n      quantity: quantity,\n      external_url: externalUrl || null,\n    };\n\n    // Only include status if changed\n    if (status !== archive.status) {\n      updateData.status = status;\n    }\n\n    // Handle failure_reason based on status\n    if (status === 'failed' || status === 'aborted') {\n      updateData.failure_reason = failureReason || undefined;\n    } else if (archive.status === 'failed' || archive.status === 'aborted') {\n      // Clear failure_reason when changing from failed/aborted to another status\n      updateData.failure_reason = null;\n    }\n\n    updateMutation.mutate(updateData);\n  };\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n      onClick={onClose}\n    >\n      <div\n        className=\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white\">{t('editArchive.title')}</h2>\n          <button\n            onClick={onClose}\n            className=\"text-bambu-gray hover:text-white transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Form */}\n        <form onSubmit={handleSubmit} className=\"p-6 space-y-4 overflow-y-auto flex-1\">\n          {/* Print Name */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('editArchive.name')}</label>\n            <input\n              type=\"text\"\n              value={printName}\n              onChange={(e) => setPrintName(e.target.value)}\n              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\"\n              placeholder={t('editArchive.namePlaceholder')}\n            />\n          </div>\n\n          {/* Printer */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('editArchive.printer')}</label>\n            <select\n              value={printerId ?? ''}\n              onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}\n              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\"\n            >\n              <option value=\"\">{t('editArchive.noPrinter')}</option>\n              {printers?.map((p) => (\n                <option key={p.id} value={p.id}>\n                  {p.name}\n                </option>\n              ))}\n            </select>\n          </div>\n\n          {/* Project */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">\n              <FolderKanban className=\"w-4 h-4 inline mr-1\" />\n              {t('editArchive.project')}\n            </label>\n            <select\n              value={projectId ?? ''}\n              onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}\n              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\"\n            >\n              <option value=\"\">{t('editArchive.noProject')}</option>\n              {projects?.map((p) => (\n                <option key={p.id} value={p.id}>\n                  {p.name}\n                </option>\n              ))}\n            </select>\n          </div>\n\n          {/* Quantity - number of items printed */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">\n              <Hash className=\"w-4 h-4 inline mr-1\" />\n              {t('editArchive.itemsPrinted')}\n            </label>\n            <input\n              type=\"number\"\n              min={1}\n              value={quantity}\n              onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}\n              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\"\n              placeholder=\"1\"\n            />\n            <p className=\"text-xs text-bambu-gray mt-1\">\n              {t('editArchive.itemsPrintedHelp')}\n            </p>\n          </div>\n\n          {/* Notes */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('editArchive.notes')}</label>\n            <textarea\n              value={notes}\n              onChange={(e) => setNotes(e.target.value)}\n              rows={3}\n              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 resize-none\"\n              placeholder={t('editArchive.notesPlaceholder')}\n            />\n          </div>\n\n          {/* External Link */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">\n              <Link className=\"w-4 h-4 inline mr-1\" />\n              {t('editArchive.externalLink')}\n            </label>\n            <input\n              type=\"url\"\n              value={externalUrl}\n              onChange={(e) => setExternalUrl(e.target.value)}\n              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\"\n              placeholder=\"https://printables.com/model/...\"\n            />\n            <p className=\"text-xs text-bambu-gray mt-1\">\n              {t('editArchive.externalLinkHelp')}\n            </p>\n          </div>\n\n          {/* Tags */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('editArchive.tags')}</label>\n            {/* Current tags as chips */}\n            {currentTags.length > 0 && (\n              <div className=\"flex flex-wrap gap-1.5 mb-2\">\n                {currentTags.map((tag) => (\n                  <span\n                    key={tag}\n                    className=\"inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white\"\n                  >\n                    <Tag className=\"w-3 h-3\" />\n                    {tag}\n                    <button\n                      type=\"button\"\n                      onClick={() => removeTag(tag)}\n                      className=\"ml-0.5 text-bambu-gray hover:text-white\"\n                    >\n                      <X className=\"w-3 h-3\" />\n                    </button>\n                  </span>\n                ))}\n              </div>\n            )}\n            {/* Tag input with suggestions */}\n            <div className=\"relative\">\n              <input\n                ref={tagInputRef}\n                type=\"text\"\n                value={tags}\n                onChange={(e) => setTags(e.target.value)}\n                onFocus={() => {\n                  if (blurTimeoutRef.current !== null) {\n                    clearTimeout(blurTimeoutRef.current);\n                  }\n                  setShowTagSuggestions(true);\n                }}\n                onBlur={() => {\n                  blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);\n                }}\n                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\"\n                placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}\n              />\n              {/* Suggestions dropdown */}\n              {showTagSuggestions && tagSuggestions.length > 0 && (\n                <div className=\"absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto\">\n                  <div className=\"p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary\">\n                    {currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}\n                  </div>\n                  <div className=\"p-2 flex flex-wrap gap-1.5\">\n                    {tagSuggestions.map((tag) => (\n                      <button\n                        key={tag}\n                        type=\"button\"\n                        onClick={() => addTag(tag)}\n                        className=\"px-2 py-0.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded text-sm text-bambu-gray hover:text-white transition-colors\"\n                      >\n                        {tag}\n                      </button>\n                    ))}\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Status */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">{t('editArchive.status')}</label>\n            <select\n              value={status}\n              onChange={(e) => {\n                setStatus(e.target.value);\n                // Clear failure reason when changing to completed\n                if (e.target.value === 'completed') {\n                  setFailureReason('');\n                }\n              }}\n              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\"\n            >\n              {ARCHIVE_STATUS_KEYS.map((statusKey) => (\n                <option key={statusKey} value={statusKey}>\n                  {t(`editArchive.statuses.${statusKey}`)}\n                </option>\n              ))}\n            </select>\n          </div>\n\n          {/* Failure Reason - only show for failed/aborted prints */}\n          {(status === 'failed' || status === 'aborted') && (\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('editArchive.failureReason')}</label>\n              <select\n                value={failureReason}\n                onChange={(e) => setFailureReason(e.target.value)}\n                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\"\n              >\n                <option value=\"\">{t('editArchive.selectReason')}</option>\n                {FAILURE_REASON_KEYS.map((reasonKey) => (\n                  <option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>\n                    {t(`editArchive.failureReasons.${reasonKey}`)}\n                  </option>\n                ))}\n              </select>\n            </div>\n          )}\n\n          {/* Photos */}\n          <div>\n            <label className=\"block text-sm text-bambu-gray mb-1\">\n              <Camera className=\"w-4 h-4 inline mr-1\" />\n              {t('editArchive.photos')}\n            </label>\n            {/* Photo grid */}\n            <div className=\"flex flex-wrap gap-2 mb-2\">\n              {photos.map((filename) => (\n                <div key={filename} className=\"relative group\">\n                  <img\n                    src={api.getArchivePhotoUrl(archive.id, filename)}\n                    alt={t('editArchive.printResult')}\n                    className=\"w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary\"\n                  />\n                  <button\n                    type=\"button\"\n                    onClick={() => handlePhotoDelete(filename)}\n                    className=\"absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity\"\n                  >\n                    <Trash2 className=\"w-3 h-3 text-white\" />\n                  </button>\n                </div>\n              ))}\n              {/* Upload button */}\n              <label className=\"w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors\">\n                <input\n                  ref={photoInputRef}\n                  type=\"file\"\n                  accept=\"image/jpeg,image/png,image/webp\"\n                  onChange={handlePhotoUpload}\n                  className=\"hidden\"\n                  disabled={uploadingPhoto}\n                />\n                {uploadingPhoto ? (\n                  <Loader2 className=\"w-6 h-6 text-bambu-gray animate-spin\" />\n                ) : (\n                  <Plus className=\"w-6 h-6 text-bambu-gray\" />\n                )}\n              </label>\n            </div>\n            <p className=\"text-xs text-bambu-gray\">{t('editArchive.photosHelp')}</p>\n          </div>\n\n          {/* Actions */}\n          <div className=\"flex gap-3 pt-2\">\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={onClose}\n              className=\"flex-1\"\n            >\n              {t('common.cancel')}\n            </Button>\n            <Button\n              type=\"submit\"\n              disabled={updateMutation.isPending}\n              className=\"flex-1\"\n            >\n              <Save className=\"w-4 h-4\" />\n              {updateMutation.isPending ? t('common.saving') : t('common.save')}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/EmailSettings.tsx",
    "content": "import { useState } from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Mail, Send, Lock, Unlock, AlertTriangle, CheckCircle, Loader2 } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { SMTPSettings, TestSMTPRequest } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\nimport { useEffect } from 'react';\nimport { useAuth } from '../contexts/AuthContext';\n\nconst SECURITY_PORT_MAP: Record<string, number> = {\n  starttls: 587,\n  ssl: 465,\n  none: 25,\n};\n\nconst PORT_SECURITY_MAP: Record<number, string> = {\n  587: 'starttls',\n  465: 'ssl',\n  25: 'none',\n};\n\nexport function EmailSettings() {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const queryClient = useQueryClient();\n\n  const [smtpSettings, setSMTPSettings] = useState<SMTPSettings>({\n    smtp_host: '',\n    smtp_port: 587,\n    smtp_username: '',\n    smtp_password: '',\n    smtp_security: 'starttls',\n    smtp_auth_enabled: true,\n    smtp_from_email: '',\n    smtp_from_name: 'BamBuddy',\n  });\n  const [testEmail, setTestEmail] = useState('');\n\n  // Fetch SMTP settings\n  const { data: existingSettings, isLoading } = useQuery({\n    queryKey: ['smtpSettings'],\n    queryFn: () => api.getSMTPSettings(),\n  });\n\n  // Fetch global auth status\n  const { authEnabled } = useAuth();\n  // Fetch advanced auth status\n  const { data: advancedAuthStatus } = useQuery({\n    queryKey: ['advancedAuthStatus'],\n    queryFn: () => api.getAdvancedAuthStatus(),\n  });\n\n  // Load existing settings when fetched\n  useEffect(() => {\n    if (existingSettings) {\n      setSMTPSettings({\n        ...existingSettings,\n        smtp_password: '', // Never show password\n      });\n    }\n  }, [existingSettings]);\n\n  const handleSecurityChange = (security: 'starttls' | 'ssl' | 'none') => {\n    setSMTPSettings({\n      ...smtpSettings,\n      smtp_security: security,\n      smtp_port: SECURITY_PORT_MAP[security],\n    });\n  };\n\n  const handlePortChange = (port: number) => {\n    const matchedSecurity = PORT_SECURITY_MAP[port];\n    setSMTPSettings({\n      ...smtpSettings,\n      smtp_port: port,\n      ...(matchedSecurity ? { smtp_security: matchedSecurity as 'starttls' | 'ssl' | 'none' } : {}),\n    });\n  };\n\n  const handleAuthChange = (enabled: boolean) => {\n    setSMTPSettings({\n      ...smtpSettings,\n      smtp_auth_enabled: enabled,\n      ...(!enabled ? { smtp_username: '', smtp_password: '' } : {}),\n    });\n  };\n\n  // Save SMTP settings\n  const saveMutation = useMutation({\n    mutationFn: (settings: SMTPSettings) => api.saveSMTPSettings(settings),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smtpSettings'] });\n      queryClient.invalidateQueries({ queryKey: ['advancedAuthStatus'] });\n      showToast(t('settings.email.success.settingsSaved'), 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // Test SMTP connection\n  const testMutation = useMutation({\n    mutationFn: (request: TestSMTPRequest) => api.testSMTP(request),\n    onSuccess: (data) => {\n      showToast(data.message, data.success ? 'success' : 'error');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // Toggle advanced auth\n  const toggleAdvancedAuthMutation = useMutation({\n    mutationFn: (enabled: boolean) =>\n      enabled ? api.enableAdvancedAuth() : api.disableAdvancedAuth(),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['advancedAuthStatus'] });\n      showToast(data.message, 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const handleSave = () => {\n    // Validate required fields\n    if (!smtpSettings.smtp_host || !smtpSettings.smtp_from_email) {\n      showToast(t('settings.email.errors.requiredFields'), 'error');\n      return;\n    }\n    // Validate auth fields when authentication is enabled\n    if (smtpSettings.smtp_auth_enabled && (!smtpSettings.smtp_username)) {\n      showToast(t('settings.email.errors.usernameRequired'), 'error');\n      return;\n    }\n    saveMutation.mutate(smtpSettings);\n  };\n\n  const handleTest = () => {\n    if (!testEmail) {\n      showToast(t('settings.email.errors.enterTestEmail'), 'error');\n      return;\n    }\n    testMutation.mutate({\n      test_recipient: testEmail,\n    });\n  };\n\n  const handleToggleAdvancedAuth = () => {\n    if (!authEnabled) {\n      showToast(t('settings.email.errors.enableAuthFirst'), 'error');\n      return;\n    }\n    if (!advancedAuthStatus?.advanced_auth_enabled && !advancedAuthStatus?.smtp_configured) {\n      showToast(t('settings.email.errors.configureSmtpFirst'), 'error');\n      return;\n    }\n    toggleAdvancedAuthMutation.mutate(!advancedAuthStatus?.advanced_auth_enabled);\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center p-12\">\n        <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  const advancedEnabled = advancedAuthStatus?.advanced_auth_enabled ?? false;\n  const inputClasses = \"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\";\n  const disabledInputClasses = \"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white/40 placeholder-bambu-gray/40 cursor-not-allowed\";\n\n  return (\n    <div className=\"space-y-3\">\n      {/* Advanced Authentication Toggle - Always visible */}\n      <Card id=\"card-email-advanced-auth\">\n        <CardHeader>\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <Mail className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-lg font-semibold text-white\">\n                {t('settings.email.advancedAuth') || 'Advanced Authentication'}\n              </h2>\n            </div>\n            <Button\n              onClick={handleToggleAdvancedAuth}\n              disabled={toggleAdvancedAuthMutation.isPending}\n              variant={advancedEnabled ? 'danger' : 'primary'}\n            >\n              {advancedEnabled ? (\n                <>\n                  <Unlock className=\"w-4 h-4\" />\n                  {t('settings.email.disable') || 'Disable'}\n                </>\n              ) : (\n                <>\n                  <Lock className=\"w-4 h-4\" />\n                  {t('settings.email.enable') || 'Enable'}\n                </>\n              )}\n            </Button>\n          </div>\n        </CardHeader>\n        <CardContent>\n          <div className=\"space-y-3\">\n            {advancedEnabled ? (\n              <div className=\"bg-green-500/10 border border-green-500/30 rounded-lg p-4\">\n                <div className=\"flex items-start gap-3\">\n                  <CheckCircle className=\"w-5 h-5 text-green-400 mt-0.5 flex-shrink-0\" />\n                  <div className=\"space-y-2\">\n                    <p className=\"text-white font-medium\">\n                      {t('settings.email.advancedAuthEnabled') || 'Advanced Authentication is enabled'}\n                    </p>\n                    <ul className=\"text-sm text-green-300 space-y-1 list-disc list-inside\">\n                      <li>{t('settings.email.feature1') || 'Passwords are auto-generated and emailed to new users'}</li>\n                      <li>{t('settings.email.feature2') || 'Users can login with username or email'}</li>\n                      <li>{t('settings.email.feature3') || 'Forgot password feature is available'}</li>\n                      <li>{t('settings.email.feature4') || 'Admins can reset user passwords via email'}</li>\n                    </ul>\n                  </div>\n                </div>\n              </div>\n            ) : (\n              <div className=\"bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4\">\n                <div className=\"flex items-start gap-3\">\n                  <AlertTriangle className=\"w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0\" />\n                  <div className=\"space-y-2\">\n                    <p className=\"text-white font-medium\">\n                      {t('settings.email.advancedAuthDisabled') || 'Advanced Authentication is disabled'}\n                    </p>\n                    <p className=\"text-sm text-yellow-300\">\n                      {t('settings.email.advancedAuthDisabledDesc') || 'Enable advanced authentication to activate email-based features for user management.'}\n                    </p>\n                  </div>\n                </div>\n              </div>\n            )}\n          </div>\n        </CardContent>\n      </Card>\n\n      {/* SMTP Config + Test SMTP side-by-side on lg+ */}\n      <div className=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">\n        <div className=\"lg:col-span-2\">\n        <Card id=\"card-smtp-config\">\n          <CardHeader>\n            <h2 className=\"text-lg font-semibold text-white\">\n              {t('settings.email.smtpSettings') || 'SMTP Configuration'}\n            </h2>\n          </CardHeader>\n          <CardContent>\n            <div className=\"space-y-3\">\n              {/* Authentication - at the top */}\n              <div>\n                <label className=\"block text-sm font-medium text-white mb-1\">\n                  {t('settings.email.authentication') || 'Authentication'}\n                </label>\n                <select\n                  value={smtpSettings.smtp_auth_enabled ? 'true' : 'false'}\n                  onChange={(e) => handleAuthChange(e.target.value === 'true')}\n                  className={inputClasses}\n                >\n                  <option value=\"true\">{t('settings.email.authOptions.enabled')}</option>\n                  <option value=\"false\">{t('settings.email.authOptions.disabled')}</option>\n                </select>\n              </div>\n\n              {/* Username / Password - dimmed when auth disabled */}\n              <div className={`grid grid-cols-1 md:grid-cols-2 gap-4 transition-opacity ${!smtpSettings.smtp_auth_enabled ? 'opacity-40 pointer-events-none' : ''}`}>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-1\">\n                    {t('settings.email.username') || 'Username'}\n                  </label>\n                  <input\n                    type=\"text\"\n                    value={smtpSettings.smtp_username || ''}\n                    onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_username: e.target.value })}\n                    placeholder=\"your.email@gmail.com\"\n                    disabled={!smtpSettings.smtp_auth_enabled}\n                    className={smtpSettings.smtp_auth_enabled ? inputClasses : disabledInputClasses}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-1\">\n                    {t('settings.email.password') || 'Password'}\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={smtpSettings.smtp_password || ''}\n                    onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_password: e.target.value })}\n                    placeholder={existingSettings ? '••••••••' : 'App password'}\n                    disabled={!smtpSettings.smtp_auth_enabled}\n                    className={smtpSettings.smtp_auth_enabled ? inputClasses : disabledInputClasses}\n                  />\n                </div>\n              </div>\n\n              {/* SMTP Server / Port */}\n              <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-1\">\n                    {t('settings.email.smtpHost') || 'SMTP Server'} *\n                  </label>\n                  <input\n                    type=\"text\"\n                    value={smtpSettings.smtp_host}\n                    onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_host: e.target.value })}\n                    placeholder=\"smtp.gmail.com\"\n                    className={inputClasses}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-1\">\n                    {t('settings.email.smtpPort') || 'SMTP Port'}\n                  </label>\n                  <input\n                    type=\"number\"\n                    value={smtpSettings.smtp_port}\n                    onChange={(e) => handlePortChange(parseInt(e.target.value) || 587)}\n                    placeholder=\"587\"\n                    className={inputClasses}\n                  />\n                </div>\n              </div>\n\n              {/* Security */}\n              <div>\n                <label className=\"block text-sm font-medium text-white mb-1\">\n                  {t('settings.email.security') || 'Security'}\n                </label>\n                <select\n                  value={smtpSettings.smtp_security}\n                  onChange={(e) => handleSecurityChange(e.target.value as 'starttls' | 'ssl' | 'none')}\n                  className={inputClasses}\n                >\n                  <option value=\"starttls\">{t('settings.email.securityOptions.starttls')}</option>\n                  <option value=\"ssl\">{t('settings.email.securityOptions.ssl')}</option>\n                  <option value=\"none\">{t('settings.email.securityOptions.none')}</option>\n                </select>\n              </div>\n\n              {/* From Email / Name */}\n              <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-1\">\n                    {t('settings.email.fromEmail') || 'From Email'} *\n                  </label>\n                  <input\n                    type=\"email\"\n                    value={smtpSettings.smtp_from_email}\n                    onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_from_email: e.target.value })}\n                    placeholder=\"your@email.com\"\n                    className={inputClasses}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-1\">\n                    {t('settings.email.fromName') || 'From Name'}\n                  </label>\n                  <input\n                    type=\"text\"\n                    value={smtpSettings.smtp_from_name}\n                    onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_from_name: e.target.value })}\n                    placeholder=\"BamBuddy\"\n                    className={inputClasses}\n                  />\n                </div>\n              </div>\n\n              <div className=\"flex gap-2\">\n                <Button\n                  onClick={handleSave}\n                  disabled={saveMutation.isPending}\n                  className=\"flex-1\"\n                >\n                  {saveMutation.isPending ? (\n                    <>\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      {t('settings.email.saving') || 'Saving...'}\n                    </>\n                  ) : (\n                    t('settings.email.save') || 'Save Settings'\n                  )}\n                </Button>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n        </div>\n\n        {/* Test SMTP */}\n        <div>\n        <Card id=\"card-email-test\">\n          <CardHeader>\n            <h2 className=\"text-lg font-semibold text-white\">\n              {t('settings.email.testConnection') || 'Test SMTP Connection'}\n            </h2>\n          </CardHeader>\n          <CardContent>\n            <div className=\"space-y-3\">\n              <div>\n                <label className=\"block text-sm font-medium text-white mb-1\">\n                  {t('settings.email.testRecipient') || 'Test Recipient Email'}\n                </label>\n                <input\n                  type=\"email\"\n                  value={testEmail}\n                  onChange={(e) => setTestEmail(e.target.value)}\n                  placeholder=\"test@example.com\"\n                  className={inputClasses}\n                />\n              </div>\n              <Button\n                onClick={handleTest}\n                disabled={testMutation.isPending}\n                variant=\"secondary\"\n              >\n                {testMutation.isPending ? (\n                  <>\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    {t('settings.email.sending') || 'Sending...'}\n                  </>\n                ) : (\n                  <>\n                    <Send className=\"w-4 h-4\" />\n                    {t('settings.email.sendTest') || 'Send Test Email'}\n                  </>\n                )}\n              </Button>\n            </div>\n          </CardContent>\n        </Card>\n        </div>\n      </div>\n\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/EmbeddedCameraViewer.tsx",
    "content": "import { useState, useEffect, useRef, useCallback } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';\nimport { api, getAuthToken, withStreamToken } from '../api/client';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { ChamberLight } from './icons/ChamberLight';\nimport { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';\n\ninterface EmbeddedCameraViewerProps {\n  printerId: number;\n  printerName: string;\n  viewerIndex?: number;  // Used to offset multiple viewers\n  onClose: () => void;\n}\n\nconst STORAGE_KEY_PREFIX = 'embeddedCameraState_';\nconst MAX_RECONNECT_ATTEMPTS = 5;\nconst INITIAL_RECONNECT_DELAY = 2000;\nconst MAX_RECONNECT_DELAY = 30000;\nconst STALL_CHECK_INTERVAL = 5000;\n\ninterface CameraState {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\nconst DEFAULT_STATE: CameraState = {\n  x: window.innerWidth - 420,\n  y: 20,\n  width: 400,\n  height: 300,\n};\n\nexport function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n\n  // Printer-specific storage key\n  const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;\n\n  // Load saved state or use defaults (offset for new viewers without saved state)\n  const loadState = (): CameraState => {\n    try {\n      const saved = localStorage.getItem(storageKey);\n      if (saved) {\n        const state = JSON.parse(saved);\n        // Validate state is on screen\n        return {\n          x: Math.min(Math.max(0, state.x), window.innerWidth - 100),\n          y: Math.min(Math.max(0, state.y), window.innerHeight - 100),\n          width: Math.max(200, Math.min(state.width, window.innerWidth - 20)),\n          height: Math.max(150, Math.min(state.height, window.innerHeight - 20)),\n        };\n      }\n    } catch {\n      // Ignore parse errors\n    }\n    // Offset new viewers so they don't stack exactly on top of each other\n    const offset = viewerIndex * 30;\n    return {\n      ...DEFAULT_STATE,\n      x: Math.max(0, DEFAULT_STATE.x - offset),\n      y: Math.max(0, DEFAULT_STATE.y + offset),\n    };\n  };\n\n  const [state, setState] = useState<CameraState>(loadState);\n  const [isDragging, setIsDragging] = useState(false);\n  const [isResizing, setIsResizing] = useState(false);\n  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });\n  const [isMinimized, setIsMinimized] = useState(false);\n  const [isFullscreen, setIsFullscreen] = useState(false);\n  const [zoomLevel, setZoomLevel] = useState(1);\n  const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });\n  const [isPanning, setIsPanning] = useState(false);\n  const [panStart, setPanStart] = useState({ x: 0, y: 0 });\n  const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);\n  const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);\n\n  // Stream state\n  const [streamError, setStreamError] = useState(false);\n  const [streamLoading, setStreamLoading] = useState(true);\n  const [imageKey, setImageKey] = useState(Date.now());\n  const [reconnectAttempts, setReconnectAttempts] = useState(0);\n  const [isReconnecting, setIsReconnecting] = useState(false);\n  const [reconnectCountdown, setReconnectCountdown] = useState(0);\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const imgRef = useRef<HTMLImageElement>(null);\n  const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);\n  const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);\n  const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);\n\n  // Fetch printer info\n  const { data: printer } = useQuery({\n    queryKey: ['printer', printerId],\n    queryFn: () => api.getPrinter(printerId),\n    enabled: printerId > 0,\n  });\n\n  // Fetch printer status for light toggle and skip objects\n  const { data: status } = useQuery({\n    queryKey: ['printerStatus', printerId],\n    queryFn: () => api.getPrinterStatus(printerId),\n    refetchInterval: 30000,\n    enabled: printerId > 0,\n  });\n\n  // Chamber light mutation with optimistic update\n  const chamberLightMutation = useMutation({\n    mutationFn: (on: boolean) => api.setChamberLight(printerId, on),\n    onMutate: async (on) => {\n      await queryClient.cancelQueries({ queryKey: ['printerStatus', printerId] });\n      const previousStatus = queryClient.getQueryData(['printerStatus', printerId]);\n      queryClient.setQueryData(['printerStatus', printerId], (old: typeof status) => ({\n        ...old,\n        chamber_light: on,\n      }));\n      return { previousStatus };\n    },\n    onSuccess: (_, on) => {\n      showToast(`Chamber light ${on ? 'on' : 'off'}`);\n    },\n    onError: (error: Error, _, context) => {\n      if (context?.previousStatus) {\n        queryClient.setQueryData(['printerStatus', printerId], context.previousStatus);\n      }\n      showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');\n    },\n  });\n\n  const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2;\n\n  // Save state to localStorage (printer-specific)\n  useEffect(() => {\n    const saveTimeout = setTimeout(() => {\n      localStorage.setItem(storageKey, JSON.stringify(state));\n    }, 500);\n    return () => clearTimeout(saveTimeout);\n  }, [state, storageKey]);\n\n  // Cleanup on unmount\n  const stopSentRef = useRef(false);\n  useEffect(() => {\n    stopSentRef.current = false;\n    const stopUrl = `/api/v1/printers/${printerId}/camera/stop`;\n\n    const sendStopOnce = () => {\n      if (printerId > 0 && !stopSentRef.current) {\n        stopSentRef.current = true;\n        const headers: Record<string, string> = {};\n        const token = getAuthToken();\n        if (token) headers['Authorization'] = `Bearer ${token}`;\n        fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {});\n      }\n    };\n\n    const imgElement = imgRef.current;\n\n    return () => {\n      if (imgElement) {\n        imgElement.src = '';\n      }\n      sendStopOnce();\n      if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);\n      if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);\n      if (stallCheckIntervalRef.current) clearInterval(stallCheckIntervalRef.current);\n    };\n  }, [printerId]);\n\n  // Auto-hide loading after timeout\n  useEffect(() => {\n    if (streamLoading) {\n      const timer = setTimeout(() => setStreamLoading(false), 3000);\n      return () => clearTimeout(timer);\n    }\n  }, [streamLoading, imageKey]);\n\n  // Auto-reconnect logic\n  const attemptReconnect = useCallback(() => {\n    if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n      setIsReconnecting(false);\n      setStreamError(true);\n      return;\n    }\n\n    const delay = Math.min(\n      INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),\n      MAX_RECONNECT_DELAY\n    );\n\n    setIsReconnecting(true);\n    setReconnectCountdown(Math.ceil(delay / 1000));\n\n    countdownIntervalRef.current = setInterval(() => {\n      setReconnectCountdown((prev) => {\n        if (prev <= 1) {\n          if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);\n          return 0;\n        }\n        return prev - 1;\n      });\n    }, 1000);\n\n    reconnectTimerRef.current = setTimeout(() => {\n      setReconnectAttempts((prev) => prev + 1);\n      setIsReconnecting(false);\n      setStreamLoading(true);\n      setStreamError(false);\n      if (imgRef.current) imgRef.current.src = '';\n      setImageKey(Date.now());\n    }, delay);\n  }, [reconnectAttempts]);\n\n  // Stall detection\n  useEffect(() => {\n    if (streamLoading || isReconnecting || isMinimized) {\n      if (stallCheckIntervalRef.current) {\n        clearInterval(stallCheckIntervalRef.current);\n        stallCheckIntervalRef.current = null;\n      }\n      return;\n    }\n\n    stallCheckIntervalRef.current = setInterval(async () => {\n      try {\n        const status = await api.getCameraStatus(printerId);\n        if (status.stalled || (!status.active && !streamError)) {\n          if (stallCheckIntervalRef.current) {\n            clearInterval(stallCheckIntervalRef.current);\n            stallCheckIntervalRef.current = null;\n          }\n          setStreamLoading(false);\n          attemptReconnect();\n        }\n      } catch {\n        // Ignore errors\n      }\n    }, STALL_CHECK_INTERVAL);\n\n    return () => {\n      if (stallCheckIntervalRef.current) {\n        clearInterval(stallCheckIntervalRef.current);\n        stallCheckIntervalRef.current = null;\n      }\n    };\n  }, [streamLoading, streamError, isReconnecting, isMinimized, printerId, attemptReconnect]);\n\n  // Fullscreen change listener\n  useEffect(() => {\n    const handleFullscreenChange = () => {\n      const nowFullscreen = !!document.fullscreenElement;\n      setIsFullscreen(nowFullscreen);\n      // Reset zoom and pan when exiting fullscreen\n      if (!nowFullscreen) {\n        setZoomLevel(1);\n        setPanOffset({ x: 0, y: 0 });\n      }\n    };\n    document.addEventListener('fullscreenchange', handleFullscreenChange);\n    return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);\n  }, []);\n\n  const toggleFullscreen = () => {\n    if (!containerRef.current) return;\n    if (document.fullscreenElement) {\n      document.exitFullscreen();\n    } else {\n      containerRef.current.requestFullscreen();\n    }\n  };\n\n  const handleZoomIn = () => {\n    setZoomLevel(prev => Math.min(prev + 0.5, 4));\n  };\n\n  const handleZoomOut = () => {\n    setZoomLevel(prev => {\n      const newZoom = Math.max(prev - 0.5, 1);\n      if (newZoom === 1) setPanOffset({ x: 0, y: 0 });\n      return newZoom;\n    });\n  };\n\n  const handleWheel = (e: React.WheelEvent) => {\n    e.preventDefault();\n    if (e.deltaY < 0) {\n      handleZoomIn();\n    } else {\n      handleZoomOut();\n    }\n  };\n\n  const handleImageMouseDown = (e: React.MouseEvent) => {\n    if (zoomLevel > 1) {\n      e.preventDefault();\n      setIsPanning(true);\n      setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });\n    }\n  };\n\n  // Calculate max pan based on container size and zoom level\n  const getMaxPan = useCallback(() => {\n    if (!containerRef.current || !imgRef.current) {\n      return { x: 200, y: 150 };\n    }\n    const container = containerRef.current.getBoundingClientRect();\n    // Allow panning up to half the zoomed overflow in each direction\n    const maxX = (container.width * (zoomLevel - 1)) / 2;\n    const maxY = (container.height * (zoomLevel - 1)) / 2;\n    return { x: Math.max(50, maxX), y: Math.max(50, maxY) };\n  }, [zoomLevel]);\n\n  const handleImageMouseMove = (e: React.MouseEvent) => {\n    if (isPanning && zoomLevel > 1) {\n      const newX = e.clientX - panStart.x;\n      const newY = e.clientY - panStart.y;\n      const maxPan = getMaxPan();\n      setPanOffset({\n        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),\n        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),\n      });\n    }\n  };\n\n  const handleImageMouseUp = () => {\n    setIsPanning(false);\n  };\n\n  // Touch event handlers for mobile\n  const getTouchDistance = (touches: React.TouchList) => {\n    if (touches.length < 2) return 0;\n    const dx = touches[0].clientX - touches[1].clientX;\n    const dy = touches[0].clientY - touches[1].clientY;\n    return Math.sqrt(dx * dx + dy * dy);\n  };\n\n  const getTouchCenter = (touches: React.TouchList) => {\n    if (touches.length < 2) {\n      return { x: touches[0].clientX, y: touches[0].clientY };\n    }\n    return {\n      x: (touches[0].clientX + touches[1].clientX) / 2,\n      y: (touches[0].clientY + touches[1].clientY) / 2,\n    };\n  };\n\n  const handleTouchStart = (e: React.TouchEvent) => {\n    if (e.touches.length === 2) {\n      // Pinch gesture start\n      e.preventDefault();\n      setLastTouchDistance(getTouchDistance(e.touches));\n      setLastTouchCenter(getTouchCenter(e.touches));\n    } else if (e.touches.length === 1 && zoomLevel > 1) {\n      // Single touch pan start\n      e.preventDefault();\n      setIsPanning(true);\n      setPanStart({\n        x: e.touches[0].clientX - panOffset.x,\n        y: e.touches[0].clientY - panOffset.y,\n      });\n    }\n  };\n\n  const handleTouchMove = (e: React.TouchEvent) => {\n    if (e.touches.length === 2 && lastTouchDistance !== null) {\n      // Pinch gesture\n      e.preventDefault();\n      const newDistance = getTouchDistance(e.touches);\n      const scale = newDistance / lastTouchDistance;\n\n      setZoomLevel(prev => {\n        const newZoom = Math.max(1, Math.min(4, prev * scale));\n        if (newZoom === 1) {\n          setPanOffset({ x: 0, y: 0 });\n        }\n        return newZoom;\n      });\n\n      setLastTouchDistance(newDistance);\n\n      // Also handle pan during pinch\n      const newCenter = getTouchCenter(e.touches);\n      if (lastTouchCenter) {\n        const maxPan = getMaxPan();\n        setPanOffset(prev => ({\n          x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),\n          y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),\n        }));\n      }\n      setLastTouchCenter(newCenter);\n    } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {\n      // Single touch pan\n      e.preventDefault();\n      const newX = e.touches[0].clientX - panStart.x;\n      const newY = e.touches[0].clientY - panStart.y;\n      const maxPan = getMaxPan();\n      setPanOffset({\n        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),\n        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),\n      });\n    }\n  };\n\n  const handleTouchEnd = (e: React.TouchEvent) => {\n    if (e.touches.length < 2) {\n      setLastTouchDistance(null);\n      setLastTouchCenter(null);\n    }\n    if (e.touches.length === 0) {\n      setIsPanning(false);\n    }\n  };\n\n  const resetZoom = () => {\n    setZoomLevel(1);\n    setPanOffset({ x: 0, y: 0 });\n  };\n\n  const handleStreamError = () => {\n    setStreamLoading(false);\n    if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {\n      attemptReconnect();\n    } else {\n      setStreamError(true);\n    }\n  };\n\n  const handleStreamLoad = () => {\n    setStreamLoading(false);\n    setStreamError(false);\n    setReconnectAttempts(0);\n    setIsReconnecting(false);\n    if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);\n    if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);\n  };\n\n  const refresh = () => {\n    setStreamLoading(true);\n    setStreamError(false);\n    setReconnectAttempts(0);\n    setIsReconnecting(false);\n    if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);\n    if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);\n\n    const stopHeaders: Record<string, string> = {};\n    const stopToken = getAuthToken();\n    if (stopToken) stopHeaders['Authorization'] = `Bearer ${stopToken}`;\n    fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', headers: stopHeaders }).catch(() => {});\n\n    if (imgRef.current) imgRef.current.src = '';\n    setTimeout(() => setImageKey(Date.now()), 100);\n  };\n\n  // Drag handlers\n  const handleMouseDown = (e: React.MouseEvent) => {\n    if ((e.target as HTMLElement).closest('.no-drag')) return;\n    setIsDragging(true);\n    setDragOffset({\n      x: e.clientX - state.x,\n      y: e.clientY - state.y,\n    });\n  };\n\n  const handleDragTouchStart = (e: React.TouchEvent) => {\n    if ((e.target as HTMLElement).closest('.no-drag')) return;\n    const touch = e.touches[0];\n    setIsDragging(true);\n    setDragOffset({\n      x: touch.clientX - state.x,\n      y: touch.clientY - state.y,\n    });\n  };\n\n  // Resize handlers\n  const handleResizeMouseDown = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    setIsResizing(true);\n  };\n\n  const handleResizeTouchStart = (e: React.TouchEvent) => {\n    e.stopPropagation();\n    setIsResizing(true);\n  };\n\n  useEffect(() => {\n    const handleMouseMove = (e: MouseEvent) => {\n      if (isDragging) {\n        setState((prev) => ({\n          ...prev,\n          x: Math.max(0, Math.min(e.clientX - dragOffset.x, window.innerWidth - prev.width)),\n          y: Math.max(0, Math.min(e.clientY - dragOffset.y, window.innerHeight - prev.height)),\n        }));\n      } else if (isResizing && containerRef.current) {\n        const rect = containerRef.current.getBoundingClientRect();\n        setState((prev) => ({\n          ...prev,\n          width: Math.max(200, Math.min(e.clientX - rect.left, window.innerWidth - prev.x - 10)),\n          height: Math.max(150, Math.min(e.clientY - rect.top, window.innerHeight - prev.y - 10)),\n        }));\n      }\n    };\n\n    const handleTouchMove = (e: TouchEvent) => {\n      if (!isDragging && !isResizing) return;\n      e.preventDefault();\n      const touch = e.touches[0];\n      if (isDragging) {\n        setState((prev) => ({\n          ...prev,\n          x: Math.max(0, Math.min(touch.clientX - dragOffset.x, window.innerWidth - prev.width)),\n          y: Math.max(0, Math.min(touch.clientY - dragOffset.y, window.innerHeight - prev.height)),\n        }));\n      } else if (isResizing && containerRef.current) {\n        const rect = containerRef.current.getBoundingClientRect();\n        setState((prev) => ({\n          ...prev,\n          width: Math.max(200, Math.min(touch.clientX - rect.left, window.innerWidth - prev.x - 10)),\n          height: Math.max(150, Math.min(touch.clientY - rect.top, window.innerHeight - prev.y - 10)),\n        }));\n      }\n    };\n\n    const handleMouseUp = () => {\n      setIsDragging(false);\n      setIsResizing(false);\n    };\n\n    if (isDragging || isResizing) {\n      document.addEventListener('mousemove', handleMouseMove);\n      document.addEventListener('mouseup', handleMouseUp);\n      document.addEventListener('touchmove', handleTouchMove, { passive: false });\n      document.addEventListener('touchend', handleMouseUp);\n      document.addEventListener('touchcancel', handleMouseUp);\n      return () => {\n        document.removeEventListener('mousemove', handleMouseMove);\n        document.removeEventListener('mouseup', handleMouseUp);\n        document.removeEventListener('touchmove', handleTouchMove);\n        document.removeEventListener('touchend', handleMouseUp);\n        document.removeEventListener('touchcancel', handleMouseUp);\n      };\n    }\n  }, [isDragging, isResizing, dragOffset]);\n\n  const streamUrl = withStreamToken(`/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`);\n\n  return (\n    <div\n      ref={containerRef}\n      className={`${isFullscreen ? 'fixed inset-0 z-[100]' : 'fixed z-40 rounded-lg shadow-2xl border border-bambu-dark-tertiary'} bg-bambu-dark-secondary overflow-hidden`}\n      style={isFullscreen ? undefined : {\n        left: state.x,\n        top: state.y,\n        width: isMinimized ? 200 : state.width,\n        height: isMinimized ? 40 : state.height,\n        cursor: isDragging ? 'grabbing' : 'default',\n      }}\n    >\n      {/* Header */}\n      <div\n        className=\"flex items-center justify-between px-3 py-2 bg-bambu-dark border-b border-bambu-dark-tertiary cursor-grab active:cursor-grabbing\"\n        onMouseDown={handleMouseDown}\n        onTouchStart={handleDragTouchStart}\n      >\n        <div className=\"flex items-center gap-2 text-sm text-white truncate\">\n          <GripVertical className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n          <span className=\"truncate\">{printer?.name || printerName}</span>\n        </div>\n        <div className=\"flex items-center gap-1 no-drag\">\n          <button\n            onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}\n            disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}\n            className={`p-1 rounded disabled:opacity-50 ${status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30' : 'hover:bg-bambu-dark-tertiary'}`}\n            title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('camera.chamberLight')}\n          >\n            <ChamberLight on={status?.chamber_light ?? false} className=\"w-3.5 h-3.5\" />\n          </button>\n          <button\n            onClick={() => setShowSkipObjectsModal(true)}\n            disabled={!isPrintingWithObjects || !hasPermission('printers:control')}\n            className={`p-1 rounded disabled:opacity-50 ${isPrintingWithObjects && hasPermission('printers:control') ? 'hover:bg-bambu-dark-tertiary' : ''}`}\n            title={\n              !hasPermission('printers:control')\n                ? t('printers.permission.noControl')\n                : !isPrintingWithObjects\n                  ? t('printers.skipObjects.onlyWhilePrinting')\n                  : t('printers.skipObjects.tooltip')\n            }\n          >\n            <SkipObjectsIcon className=\"w-3.5 h-3.5 text-bambu-gray\" />\n          </button>\n          <button\n            onClick={refresh}\n            disabled={streamLoading || isReconnecting}\n            className=\"p-1 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50\"\n            title=\"Refresh stream\"\n          >\n            <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />\n          </button>\n          <button\n            onClick={toggleFullscreen}\n            className=\"p-1 hover:bg-bambu-dark-tertiary rounded\"\n            title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}\n          >\n            {isFullscreen ? (\n              <Minimize className=\"w-3.5 h-3.5 text-bambu-gray\" />\n            ) : (\n              <Fullscreen className=\"w-3.5 h-3.5 text-bambu-gray\" />\n            )}\n          </button>\n          <button\n            onClick={() => setIsMinimized(!isMinimized)}\n            className=\"p-1 hover:bg-bambu-dark-tertiary rounded\"\n            title={isMinimized ? 'Expand' : 'Minimize'}\n          >\n            {isMinimized ? (\n              <Maximize2 className=\"w-3.5 h-3.5 text-bambu-gray\" />\n            ) : (\n              <Minimize2 className=\"w-3.5 h-3.5 text-bambu-gray\" />\n            )}\n          </button>\n          <button\n            onClick={onClose}\n            className=\"p-1 hover:bg-red-500/20 rounded\"\n            title=\"Close\"\n          >\n            <X className=\"w-3.5 h-3.5 text-bambu-gray hover:text-red-400\" />\n          </button>\n        </div>\n      </div>\n\n      {/* Video area */}\n      {!isMinimized && (\n        <div\n          className={`relative w-full bg-black flex items-center justify-center overflow-hidden ${isFullscreen ? 'h-[calc(100%-40px)]' : 'h-[calc(100%-40px)]'}`}\n          onWheel={handleWheel}\n          onMouseMove={handleImageMouseMove}\n          onMouseUp={handleImageMouseUp}\n          onMouseLeave={handleImageMouseUp}\n          onTouchStart={handleTouchStart}\n          onTouchMove={handleTouchMove}\n          onTouchEnd={handleTouchEnd}\n          style={{ touchAction: 'none' }}\n        >\n          {streamLoading && !isReconnecting && (\n            <div className=\"absolute inset-0 flex items-center justify-center bg-black/50 z-10\">\n              <RefreshCw className=\"w-6 h-6 text-bambu-gray animate-spin\" />\n            </div>\n          )}\n          {isReconnecting && (\n            <div className=\"absolute inset-0 flex items-center justify-center bg-black/80 z-10\">\n              <div className=\"text-center p-2\">\n                <WifiOff className=\"w-6 h-6 text-orange-400 mx-auto mb-2\" />\n                <p className=\"text-xs text-bambu-gray\">\n                  Reconnecting in {reconnectCountdown}s...\n                </p>\n              </div>\n            </div>\n          )}\n          {streamError && !isReconnecting && (\n            <div className=\"absolute inset-0 flex items-center justify-center bg-black z-10\">\n              <div className=\"text-center p-2\">\n                <AlertTriangle className=\"w-6 h-6 text-orange-400 mx-auto mb-2\" />\n                <p className=\"text-xs text-bambu-gray mb-2\">Camera unavailable</p>\n                <button\n                  onClick={refresh}\n                  className=\"px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80\"\n                >\n                  Retry\n                </button>\n              </div>\n            </div>\n          )}\n          <img\n            ref={imgRef}\n            key={imageKey}\n            src={streamUrl}\n            alt=\"Camera stream\"\n            className=\"max-w-full max-h-full object-contain select-none\"\n            style={{\n              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px) rotate(${printer?.camera_rotation || 0}deg)`,\n              ...(printer?.camera_rotation === 90 || printer?.camera_rotation === 270 ? { maxWidth: '100%', maxHeight: '100%' } : {}),\n              cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',\n            }}\n            onError={handleStreamError}\n            onLoad={handleStreamLoad}\n            onMouseDown={handleImageMouseDown}\n            draggable={false}\n          />\n\n          {/* Zoom controls */}\n          <div className=\"absolute bottom-2 left-2 flex items-center gap-1 bg-black/60 rounded px-1.5 py-1 no-drag\">\n            <button\n              onClick={handleZoomOut}\n              disabled={zoomLevel <= 1}\n              className=\"p-1 hover:bg-white/10 rounded disabled:opacity-30\"\n              title=\"Zoom out\"\n            >\n              <ZoomOut className=\"w-3.5 h-3.5 text-white\" />\n            </button>\n            <button\n              onClick={resetZoom}\n              className=\"px-1.5 py-0.5 text-xs text-white hover:bg-white/10 rounded min-w-[32px]\"\n              title=\"Reset zoom\"\n            >\n              {Math.round(zoomLevel * 100)}%\n            </button>\n            <button\n              onClick={handleZoomIn}\n              disabled={zoomLevel >= 4}\n              className=\"p-1 hover:bg-white/10 rounded disabled:opacity-30\"\n              title=\"Zoom in\"\n            >\n              <ZoomIn className=\"w-3.5 h-3.5 text-white\" />\n            </button>\n          </div>\n\n          {/* Resize handle - hide in fullscreen */}\n          {!isFullscreen && (\n            <div\n              className=\"absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors\"\n              onMouseDown={handleResizeMouseDown}\n              onTouchStart={handleResizeTouchStart}\n              title=\"Drag to resize\"\n            >\n              <svg\n                className=\"w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray\"\n                viewBox=\"0 0 24 24\"\n                fill=\"currentColor\"\n              >\n                <path d=\"M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z\" />\n              </svg>\n            </div>\n          )}\n        </div>\n      )}\n      {/* Skip Objects Modal */}\n      <SkipObjectsModal\n        printerId={printerId}\n        isOpen={showSkipObjectsModal}\n        onClose={() => setShowSkipObjectsModal(false)}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ExternalLinksSettings.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport type { ExternalLink } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { AddExternalLinkModal } from './AddExternalLinkModal';\nimport { ConfirmModal } from './ConfirmModal';\nimport { getIconByName } from './IconPicker';\n\nexport function ExternalLinksSettings() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [showAddModal, setShowAddModal] = useState(false);\n  const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);\n  const [deletingLink, setDeletingLink] = useState<ExternalLink | null>(null);\n  const [draggedId, setDraggedId] = useState<number | null>(null);\n\n  // Fetch external links\n  const { data: links, isLoading } = useQuery({\n    queryKey: ['external-links'],\n    queryFn: api.getExternalLinks,\n  });\n\n  // Delete mutation\n  const deleteMutation = useMutation({\n    mutationFn: (id: number) => api.deleteExternalLink(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['external-links'] });\n    },\n  });\n\n  // Reorder mutation\n  const reorderMutation = useMutation({\n    mutationFn: (ids: number[]) => api.reorderExternalLinks(ids),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['external-links'] });\n    },\n  });\n\n  const handleDragStart = (e: React.DragEvent, id: number) => {\n    setDraggedId(id);\n    e.dataTransfer.effectAllowed = 'move';\n  };\n\n  const handleDragOver = (e: React.DragEvent) => {\n    e.preventDefault();\n    e.dataTransfer.dropEffect = 'move';\n  };\n\n  const handleDrop = (e: React.DragEvent, targetId: number) => {\n    e.preventDefault();\n    if (draggedId === null || draggedId === targetId || !links) return;\n\n    const currentIds = links.map((l) => l.id);\n    const draggedIndex = currentIds.indexOf(draggedId);\n    const targetIndex = currentIds.indexOf(targetId);\n\n    if (draggedIndex === -1 || targetIndex === -1) return;\n\n    // Reorder\n    const newIds = [...currentIds];\n    newIds.splice(draggedIndex, 1);\n    newIds.splice(targetIndex, 0, draggedId);\n\n    reorderMutation.mutate(newIds);\n    setDraggedId(null);\n  };\n\n  const handleDelete = (link: ExternalLink) => {\n    setDeletingLink(link);\n  };\n\n  const confirmDelete = () => {\n    if (deletingLink) {\n      deleteMutation.mutate(deletingLink.id);\n      setDeletingLink(null);\n    }\n  };\n\n  return (\n    <>\n      <Card id=\"card-sidebar-links\">\n        <CardHeader>\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <Link2 className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-lg font-semibold text-white\">Sidebar Links</h2>\n            </div>\n            <Button size=\"sm\" onClick={() => setShowAddModal(true)}>\n              <Plus className=\"w-4 h-4\" />\n              Add Link\n            </Button>\n          </div>\n        </CardHeader>\n        <CardContent>\n          <p className=\"text-sm text-bambu-gray mb-4\">\n            Add external links to the sidebar navigation. Drag to reorder.\n          </p>\n\n          {isLoading ? (\n            <div className=\"flex justify-center py-8\">\n              <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n            </div>\n          ) : links && links.length > 0 ? (\n            <div className=\"space-y-2\">\n              {links.map((link) => {\n                const Icon = getIconByName(link.icon);\n                return (\n                  <div\n                    key={link.id}\n                    draggable\n                    onDragStart={(e) => handleDragStart(e, link.id)}\n                    onDragOver={handleDragOver}\n                    onDrop={(e) => handleDrop(e, link.id)}\n                    className={`flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${\n                      draggedId === link.id ? 'opacity-50' : ''\n                    }`}\n                  >\n                    <GripVertical className=\"w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0\" />\n                    <div className=\"p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray\">\n                      <Icon className=\"w-4 h-4\" />\n                    </div>\n                    <div className=\"flex-1 min-w-0\">\n                      <div className=\"flex items-center gap-2\">\n                        <span className=\"text-white font-medium truncate\">{link.name}</span>\n                        <ExternalLinkIcon className=\"w-3 h-3 text-bambu-gray flex-shrink-0\" />\n                      </div>\n                      <span className=\"text-sm text-bambu-gray truncate block\">{link.url}</span>\n                    </div>\n                    <div className=\"flex items-center gap-1 flex-shrink-0\">\n                      <button\n                        onClick={() => setEditingLink(link)}\n                        className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors\"\n                        title={t('common.edit')}\n                      >\n                        <Pencil className=\"w-4 h-4\" />\n                      </button>\n                      <button\n                        onClick={() => handleDelete(link)}\n                        disabled={deleteMutation.isPending}\n                        className=\"p-2 rounded-lg hover:bg-red-500/20 text-bambu-gray hover:text-red-400 transition-colors disabled:opacity-50\"\n                        title={t('externalLinks.deleteLink')}\n                      >\n                        <Trash2 className=\"w-4 h-4\" />\n                      </button>\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n          ) : (\n            <div className=\"text-center py-8 text-bambu-gray\">\n              <Link2 className=\"w-8 h-8 mx-auto mb-2 opacity-50\" />\n              <p>{t('externalLinks.noLinksConfigured')}</p>\n              <p className=\"text-sm\">Click \"Add Link\" to add one</p>\n            </div>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Add/Edit Modal */}\n      {(showAddModal || editingLink) && (\n        <AddExternalLinkModal\n          link={editingLink}\n          onClose={() => {\n            setShowAddModal(false);\n            setEditingLink(null);\n          }}\n        />\n      )}\n\n      {/* Delete Confirmation Modal */}\n      {deletingLink && (\n        <ConfirmModal\n          title=\"Delete Link\"\n          message={`Are you sure you want to delete \"${deletingLink.name}\"? This action cannot be undone.`}\n          confirmText=\"Delete\"\n          cancelText=\"Cancel\"\n          variant=\"danger\"\n          onConfirm={confirmDelete}\n          onCancel={() => setDeletingLink(null)}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/FailureDetectionSettings.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';\nimport { api } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { Toggle } from './Toggle';\nimport { useToast } from '../contexts/ToastContext';\n\ntype TestResult = { ok: boolean; message: string } | null;\n\nexport function FailureDetectionSettings() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const [enabled, setEnabled] = useState(false);\n  const [mlUrl, setMlUrl] = useState('');\n  const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');\n  const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');\n  const [pollInterval, setPollInterval] = useState(10);\n  const [enabledPrinters, setEnabledPrinters] = useState<number[] | null>(null); // null = all\n  const [testResult, setTestResult] = useState<TestResult>(null);\n  const [initialized, setInitialized] = useState(false);\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const { data: status, refetch: refetchStatus } = useQuery({\n    queryKey: ['obico-status'],\n    queryFn: api.getObicoStatus,\n    refetchInterval: 10000,\n  });\n\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  useEffect(() => {\n    if (!settings) return;\n    setEnabled(settings.obico_enabled ?? false);\n    setMlUrl(settings.obico_ml_url ?? '');\n    setSensitivity(settings.obico_sensitivity ?? 'medium');\n    setAction(settings.obico_action ?? 'notify');\n    setPollInterval(settings.obico_poll_interval ?? 10);\n    try {\n      const list = settings.obico_enabled_printers\n        ? (JSON.parse(settings.obico_enabled_printers) as number[])\n        : null;\n      setEnabledPrinters(Array.isArray(list) ? list : null);\n    } catch {\n      setEnabledPrinters(null);\n    }\n    setInitialized(true);\n  }, [settings]);\n\n  const saveMutation = useMutation({\n    mutationFn: () =>\n      api.updateSettings({\n        obico_enabled: enabled,\n        obico_ml_url: mlUrl,\n        obico_sensitivity: sensitivity,\n        obico_action: action,\n        obico_poll_interval: pollInterval,\n        obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['settings'] });\n      queryClient.invalidateQueries({ queryKey: ['obico-status'] });\n      showToast(t('settings.toast.settingsSaved'));\n    },\n  });\n\n  // Auto-save on change (debounced)\n  useEffect(() => {\n    if (!initialized || !settings) return;\n    const changed =\n      settings.obico_enabled !== enabled ||\n      settings.obico_ml_url !== mlUrl ||\n      settings.obico_sensitivity !== sensitivity ||\n      settings.obico_action !== action ||\n      settings.obico_poll_interval !== pollInterval ||\n      settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters));\n    if (!changed) return;\n    const id = setTimeout(() => saveMutation.mutate(), 500);\n    return () => clearTimeout(id);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);\n\n  const handleTest = async () => {\n    setTestResult(null);\n    try {\n      const res = await api.testObicoConnection(mlUrl);\n      if (res.ok) {\n        setTestResult({ ok: true, message: t('failureDetection.testSuccess') });\n      } else {\n        setTestResult({\n          ok: false,\n          message: res.error || `HTTP ${res.status_code ?? '?'} — ${res.body ?? t('failureDetection.testFailed')}`,\n        });\n      }\n    } catch (e: unknown) {\n      setTestResult({ ok: false, message: e instanceof Error ? e.message : String(e) });\n    }\n  };\n\n  const togglePrinter = (printerId: number, checked: boolean) => {\n    if (enabledPrinters === null) {\n      // switch from \"all\" to an explicit list\n      const allIds = printers?.map((p) => p.id) ?? [];\n      const next = checked ? allIds : allIds.filter((id) => id !== printerId);\n      setEnabledPrinters(next);\n      return;\n    }\n    if (checked) {\n      setEnabledPrinters([...enabledPrinters, printerId]);\n    } else {\n      setEnabledPrinters(enabledPrinters.filter((id) => id !== printerId));\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col lg:flex-row gap-4 lg:gap-6\">\n      <div className=\"space-y-3 flex-1 lg:max-w-xl\">\n        <Card id=\"card-fd-ml\">\n          <CardHeader>\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                <ScanEye className=\"w-5 h-5 text-bambu-green\" />\n                <h2 className=\"text-lg font-semibold text-white\">{t('failureDetection.title')}</h2>\n              </div>\n              <Toggle checked={enabled} onChange={setEnabled} />\n            </div>\n            <p className=\"text-sm text-bambu-gray mt-2\">{t('failureDetection.description')}</p>\n          </CardHeader>\n          <CardContent className=\"space-y-4\">\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">\n                {t('failureDetection.mlUrl')}\n              </label>\n              <div className=\"flex gap-2\">\n                <input\n                  type=\"text\"\n                  value={mlUrl}\n                  onChange={(e) => setMlUrl(e.target.value)}\n                  placeholder=\"http://192.168.1.10:3333\"\n                  className=\"flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm\"\n                  disabled={!enabled}\n                />\n                <Button\n                  onClick={handleTest}\n                  disabled={!mlUrl || saveMutation.isPending}\n                  variant=\"secondary\"\n                >\n                  {t('failureDetection.test')}\n                </Button>\n              </div>\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('failureDetection.mlUrlHint')}</p>\n              {testResult && (\n                <div\n                  className={`flex items-start gap-2 mt-2 text-sm ${\n                    testResult.ok ? 'text-green-400' : 'text-red-400'\n                  }`}\n                >\n                  {testResult.ok ? <Check className=\"w-4 h-4 mt-0.5\" /> : <X className=\"w-4 h-4 mt-0.5\" />}\n                  <span>{testResult.message}</span>\n                </div>\n              )}\n            </div>\n\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">\n                {t('failureDetection.sensitivity')}\n              </label>\n              <select\n                value={sensitivity}\n                onChange={(e) => setSensitivity(e.target.value as 'low' | 'medium' | 'high')}\n                disabled={!enabled}\n                className=\"w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm\"\n              >\n                <option value=\"low\">{t('failureDetection.sensitivityLow')}</option>\n                <option value=\"medium\">{t('failureDetection.sensitivityMedium')}</option>\n                <option value=\"high\">{t('failureDetection.sensitivityHigh')}</option>\n              </select>\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('failureDetection.sensitivityHint')}</p>\n            </div>\n\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">\n                {t('failureDetection.action')}\n              </label>\n              <select\n                value={action}\n                onChange={(e) => setAction(e.target.value as 'notify' | 'pause' | 'pause_and_off')}\n                disabled={!enabled}\n                className=\"w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm\"\n              >\n                <option value=\"notify\">{t('failureDetection.actionNotify')}</option>\n                <option value=\"pause\">{t('failureDetection.actionPause')}</option>\n                <option value=\"pause_and_off\">{t('failureDetection.actionPauseOff')}</option>\n              </select>\n            </div>\n\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">\n                {t('failureDetection.pollInterval')}\n              </label>\n              <input\n                type=\"number\"\n                value={pollInterval}\n                onChange={(e) => setPollInterval(Math.max(5, Math.min(120, Number(e.target.value) || 10)))}\n                min={5}\n                max={120}\n                disabled={!enabled}\n                className=\"w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm\"\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('failureDetection.pollIntervalHint')}</p>\n            </div>\n\n            {status && !status.external_url_configured && enabled && (\n              <div className=\"flex items-start gap-2 p-3 bg-amber-900/30 border border-amber-700 rounded text-sm text-amber-200\">\n                <AlertTriangle className=\"w-4 h-4 mt-0.5 flex-shrink-0\" />\n                <div>\n                  <div className=\"font-medium\">{t('failureDetection.externalUrlMissing')}</div>\n                  <div className=\"text-xs mt-1\">{t('failureDetection.externalUrlHint')}</div>\n                </div>\n              </div>\n            )}\n          </CardContent>\n        </Card>\n\n        <Card id=\"card-fd-perprinter\">\n          <CardHeader>\n            <h2 className=\"text-lg font-semibold text-white\">{t('failureDetection.perPrinterTitle')}</h2>\n            <p className=\"text-sm text-bambu-gray mt-1\">{t('failureDetection.perPrinterHint')}</p>\n          </CardHeader>\n          <CardContent className=\"space-y-2\">\n            <label className=\"flex items-center gap-2 text-sm\">\n              <input\n                type=\"checkbox\"\n                checked={enabledPrinters === null}\n                onChange={(e) => setEnabledPrinters(e.target.checked ? null : printers?.map((p) => p.id) ?? [])}\n                disabled={!enabled}\n              />\n              <span className=\"text-white\">{t('failureDetection.monitorAll')}</span>\n            </label>\n            {enabledPrinters !== null && printers && (\n              <div className=\"pl-5 space-y-1 border-l border-gray-700\">\n                {printers.map((p) => (\n                  <label key={p.id} className=\"flex items-center gap-2 text-sm\">\n                    <input\n                      type=\"checkbox\"\n                      checked={enabledPrinters.includes(p.id)}\n                      onChange={(e) => togglePrinter(p.id, e.target.checked)}\n                      disabled={!enabled}\n                    />\n                    <span className=\"text-white\">{p.name}</span>\n                  </label>\n                ))}\n              </div>\n            )}\n          </CardContent>\n        </Card>\n      </div>\n\n      <div className=\"space-y-3 flex-1 lg:max-w-xl\">\n        <Card id=\"card-fd-status\">\n          <CardHeader>\n            <h2 className=\"text-lg font-semibold text-white\">{t('failureDetection.statusTitle')}</h2>\n          </CardHeader>\n          <CardContent>\n            {!status ? (\n              <div className=\"flex items-center gap-2 text-bambu-gray\">\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n                <span>{t('common.loading')}</span>\n              </div>\n            ) : (\n              <div className=\"space-y-3 text-sm\">\n                <div className=\"flex justify-between\">\n                  <span className=\"text-bambu-gray\">{t('failureDetection.serviceRunning')}</span>\n                  <span className={status.is_running ? 'text-green-400' : 'text-red-400'}>\n                    {status.is_running ? t('common.yes') : t('common.no')}\n                  </span>\n                </div>\n                <div className=\"flex justify-between\">\n                  <span className=\"text-bambu-gray\">{t('failureDetection.thresholds')}</span>\n                  <span className=\"text-white font-mono\">\n                    {status.thresholds.low.toFixed(2)} / {status.thresholds.high.toFixed(2)}\n                  </span>\n                </div>\n                {status.last_error && (\n                  <div className=\"flex items-start gap-2 text-red-400\">\n                    <X className=\"w-4 h-4 mt-0.5 flex-shrink-0\" />\n                    <span className=\"break-words\">{status.last_error}</span>\n                  </div>\n                )}\n                <div>\n                  <div className=\"text-bambu-gray mb-1\">{t('failureDetection.activePrinters')}</div>\n                  {Object.keys(status.per_printer).length === 0 ? (\n                    <div className=\"text-bambu-gray italic text-xs\">{t('failureDetection.noActivePrints')}</div>\n                  ) : (\n                    <div className=\"space-y-1\">\n                      {Object.entries(status.per_printer).map(([pid, info]) => {\n                        const printer = printers?.find((p) => String(p.id) === pid);\n                        const colorClass =\n                          info.class === 'failure'\n                            ? 'text-red-400'\n                            : info.class === 'warning'\n                              ? 'text-amber-400'\n                              : 'text-green-400';\n                        return (\n                          <div key={pid} className=\"flex justify-between\">\n                            <span className=\"text-white\">{printer?.name ?? `Printer ${pid}`}</span>\n                            <span className={`font-mono ${colorClass}`}>\n                              {info.class} ({info.score.toFixed(3)}, {info.frame_count}f)\n                            </span>\n                          </div>\n                        );\n                      })}\n                    </div>\n                  )}\n                </div>\n              </div>\n            )}\n          </CardContent>\n        </Card>\n\n        <Card id=\"card-fd-history\">\n          <CardHeader>\n            <div className=\"flex items-center justify-between\">\n              <h2 className=\"text-lg font-semibold text-white\">{t('failureDetection.historyTitle')}</h2>\n              <button onClick={() => refetchStatus()} className=\"text-xs text-bambu-gray hover:text-white\">\n                {t('common.refresh')}\n              </button>\n            </div>\n          </CardHeader>\n          <CardContent>\n            {!status || status.history.length === 0 ? (\n              <div className=\"flex items-center gap-2 text-bambu-gray text-sm\">\n                <Info className=\"w-4 h-4\" />\n                <span>{t('failureDetection.noHistory')}</span>\n              </div>\n            ) : (\n              <div className=\"space-y-1 max-h-96 overflow-y-auto text-xs font-mono\">\n                {status.history.map((ev, idx) => {\n                  const printer = printers?.find((p) => p.id === ev.printer_id);\n                  const colorClass =\n                    ev.class === 'failure'\n                      ? 'text-red-400'\n                      : ev.class === 'warning'\n                        ? 'text-amber-400'\n                        : 'text-bambu-gray';\n                  return (\n                    <div key={idx} className=\"flex justify-between gap-2 py-1 border-b border-gray-800\">\n                      <span className=\"text-bambu-gray\">{new Date(ev.timestamp).toLocaleTimeString()}</span>\n                      <span className=\"text-white truncate\">{printer?.name ?? `#${ev.printer_id}`}</span>\n                      <span className={colorClass}>\n                        {ev.class} {ev.score.toFixed(3)}\n                      </span>\n                    </div>\n                  );\n                })}\n              </div>\n            )}\n          </CardContent>\n        </Card>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/FilamentHoverCard.tsx",
    "content": "import { useState, useRef, useEffect, type ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';\nimport { isLightColor } from '../utils/colors';\n\ninterface FilamentData {\n  vendor: 'Bambu Lab' | 'Generic';\n  profile: string;\n  colorName: string;\n  colorHex: string | null;\n  kFactor: string;\n  fillLevel: number | null; // null = unknown\n  trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking\n  tagUid?: string | null; // Generic NFC tag UID fallback for linking\n  fillSource?: 'ams' | 'spoolman' | 'inventory'; // Source of fill level data\n}\n\ninterface SpoolmanConfig {\n  enabled: boolean;\n  onLinkSpool?: () => void;\n  onUnlinkSpool?: () => void;\n  linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked\n  spoolmanUrl?: string | null; // Base URL for Spoolman (for \"Open in Spoolman\" link)\n  syncMode?: string | null; // If auto-sync is enabled, we may want to hide the unlink option for Bambu spools\n}\n\ninterface InventoryConfig {\n  onAssignSpool?: () => void;\n  onUnassignSpool?: () => void;\n  assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null; remainingWeightGrams?: number | null } | null;\n}\n\ninterface ConfigureSlotConfig {\n  enabled: boolean;\n  onConfigure?: () => void;\n}\n\ninterface FilamentHoverCardProps {\n  data: FilamentData;\n  children: ReactNode;\n  disabled?: boolean;\n  className?: string;\n  spoolman?: SpoolmanConfig;\n  inventory?: InventoryConfig;\n  configureSlot?: ConfigureSlotConfig;\n}\n\n/**\n * A hover card that displays filament details when hovering over AMS slots.\n * Replaces the basic browser tooltip with a styled popover.\n */\nexport function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) {\n  const { t } = useTranslation();\n  const [isVisible, setIsVisible] = useState(false);\n  const [position, setPosition] = useState<'top' | 'bottom'>('top');\n  const [copied, setCopied] = useState(false);\n  const [showUnlinkConfirm, setShowUnlinkConfirm] = useState(false);\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const cardRef = useRef<HTMLDivElement>(null);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const handleCopyUuid = () => {\n    const uuid = data.trayUuid;\n    if (!uuid) return;\n\n    // Try modern clipboard API first, fallback to execCommand\n    if (navigator.clipboard && window.isSecureContext) {\n      navigator.clipboard.writeText(uuid).then(() => {\n        setCopied(true);\n        setTimeout(() => setCopied(false), 2000);\n      }).catch(() => {\n        // Fallback on error\n        fallbackCopy(uuid);\n      });\n    } else {\n      fallbackCopy(uuid);\n    }\n  };\n\n  const fallbackCopy = (text: string) => {\n    const textarea = document.createElement('textarea');\n    textarea.value = text;\n    textarea.style.position = 'fixed';\n    textarea.style.opacity = '0';\n    document.body.appendChild(textarea);\n    textarea.select();\n    try {\n      document.execCommand('copy');\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    } catch {\n      console.error('Failed to copy to clipboard');\n    }\n    document.body.removeChild(textarea);\n  };\n\n  // Calculate position when showing\n  useEffect(() => {\n    if (isVisible && triggerRef.current && cardRef.current) {\n      const triggerRect = triggerRef.current.getBoundingClientRect();\n      const cardHeight = cardRef.current.offsetHeight;\n      // Account for fixed header (56px) - space above should exclude header area\n      const headerHeight = 56;\n      const spaceAbove = triggerRect.top - headerHeight;\n      const spaceBelow = window.innerHeight - triggerRect.bottom;\n\n      // Prefer top, but flip to bottom if not enough space (accounting for header)\n      if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {\n        setPosition('bottom');\n      } else {\n        setPosition('top');\n      }\n    }\n  }, [isVisible]);\n\n  const handleMouseEnter = () => {\n    if (disabled) return;\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    // Small delay to prevent flicker on quick mouse movements\n    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);\n  };\n\n  const handleMouseLeave = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);\n  };\n\n  // Cleanup timeout on unmount\n  useEffect(() => {\n    return () => {\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    };\n  }, []);\n\n  // Get fill bar color based on percentage\n  const getFillColor = (fill: number): string => {\n    if (fill <= 15) return '#ef4444'; // red\n    if (fill <= 30) return '#f97316'; // orange\n    if (fill <= 50) return '#eab308'; // yellow\n    return '#22c55e'; // green\n  };\n\n  const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;\n  const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;\n\n  return (\n    <div\n      ref={triggerRef}\n      className={`relative ${className}`}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n    >\n      {children}\n\n      {/* Hover Card */}\n      {isVisible && (\n        <div\n          ref={cardRef}\n          className={`\n            absolute left-1/2 -translate-x-1/2 z-[60]\n            ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}\n            animate-in fade-in-0 zoom-in-95 duration-150\n          `}\n          style={{\n            // Ensure card doesn't go off-screen horizontally\n            maxWidth: 'calc(100vw - 24px)',\n          }}\n        >\n          {/* Card container */}\n          <div className=\"\n            w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary\n            rounded-lg shadow-xl overflow-hidden\n            backdrop-blur-sm\n          \">\n            {/* Color swatch header - the hero element */}\n            <div\n              className=\"h-12 relative overflow-hidden\"\n              style={{\n                backgroundColor: colorHex || '#3d3d3d',\n              }}\n            >\n              {/* Subtle gradient overlay for depth */}\n              <div className=\"absolute inset-0 bg-gradient-to-b from-white/10 to-transparent\" />\n\n              {/* Color name on swatch */}\n              <div className={`\n                absolute inset-0 flex items-center justify-center\n                font-semibold text-sm tracking-wide\n                ${isLightColor(colorHex) ? 'text-black/80' : 'text-white/90'}\n              `}>\n                {data.colorName}\n              </div>\n\n              {/* Vendor badge - solid background for visibility on any color */}\n              <div className={`\n                absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider\n                ${data.vendor === 'Bambu Lab'\n                  ? 'bg-black/60 text-white'\n                  : 'bg-black/50 text-white/90'}\n              `}>\n                {data.vendor === 'Bambu Lab' ? 'BBL' : 'GEN'}\n              </div>\n            </div>\n\n            {/* Details section */}\n            <div className=\"p-3 space-y-2.5\">\n              {/* Profile name */}\n              <div className=\"flex items-center justify-between\">\n                <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">\n                  {t('ams.profile')}\n                </span>\n                <span className=\"text-xs text-white font-semibold truncate max-w-[120px]\">\n                  {data.profile}\n                </span>\n              </div>\n\n              {/* K Factor */}\n              <div className=\"flex items-center justify-between\">\n                <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">\n                  {t('ams.kFactor')}\n                </span>\n                <span className=\"text-xs text-bambu-green font-mono font-bold\">\n                  {data.kFactor}\n                </span>\n              </div>\n\n              {/* Fill Level */}\n              <div className=\"space-y-1\">\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1\">\n                    <Droplets className=\"w-3 h-3\" />\n                    {t('ams.fill')}\n                  </span>\n                  <span className=\"text-xs text-white font-semibold flex items-center gap-1\">\n                    <span>{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}</span>\n                    {assignedRemainingWeight !== null && data.fillLevel !== null && (\n                      <span className=\"text-[9px] text-bambu-gray font-normal\">• {assignedRemainingWeight}g</span>\n                    )}\n                    {data.fillSource === 'spoolman' && data.fillLevel !== null && (\n                      <span className=\"text-[9px] text-bambu-gray font-normal\">{t('spoolman.fillSourceLabel')}</span>\n                    )}\n                    {data.fillSource === 'inventory' && data.fillLevel !== null && (\n                      <span className=\"text-[9px] text-bambu-gray font-normal\">{t('inventory.fillSourceLabel')}</span>\n                    )}\n                  </span>\n                </div>\n                {/* Fill bar */}\n                <div className=\"h-1.5 bg-black/40 rounded-full overflow-hidden\">\n                  {data.fillLevel !== null ? (\n                    <div\n                      className=\"h-full rounded-full transition-all duration-300\"\n                      style={{\n                        width: `${data.fillLevel}%`,\n                        backgroundColor: getFillColor(data.fillLevel),\n                      }}\n                    />\n                  ) : (\n                    <div className=\"h-full w-full bg-bambu-gray/30 rounded-full\" />\n                  )}\n                </div>\n              </div>\n\n              {/* Spoolman section - only show if enabled */}\n              {spoolman?.enabled && (\n                <div className=\"pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2\">\n                  {/* Tray UUID with copy button */}\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">\n                      {t('spoolman.spoolId')}\n                    </span>\n                    {data.trayUuid ? (\n                      <button\n                        onClick={(e) => {\n                          e.stopPropagation();\n                          handleCopyUuid();\n                        }}\n                        className=\"flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors\"\n                        title=\"Copy spool UUID\"\n                      >\n                        <span className=\"font-mono text-[10px] truncate max-w-[80px]\">\n                          {data.trayUuid.slice(0, 8)}...\n                        </span>\n                        {copied ? (\n                          <Check className=\"w-3 h-3 text-bambu-green\" />\n                        ) : (\n                          <Copy className=\"w-3 h-3\" />\n                        )}\n                      </button>\n                    ) : (\n                      <span className=\"text-[10px] text-bambu-gray\">—</span>\n                    )}\n                  </div>\n\n                  {/* Open in Spoolman button (when already linked) */}\n                  {spoolman.linkedSpoolId && spoolman.spoolmanUrl && (\n                    <>\n                      <a\n                        href={`${spoolman.spoolmanUrl.replace(/\\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        onClick={(e) => e.stopPropagation()}\n                        className=\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green\"\n                        title={t('spoolman.openInSpoolman')}\n                      >\n                        <ExternalLink className=\"w-3.5 h-3.5\" />\n                        {t('spoolman.openInSpoolman')}\n                      </a>\n\n                      {spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && (\n                        <button\n                          onClick={(e) => {\n                            e.stopPropagation();\n                            setShowUnlinkConfirm(true);\n                          }}\n                          className=\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400\"\n                          title={t('spoolman.unlinkSpool')}\n                        >\n                          <Unlink className=\"w-3.5 h-3.5\" />\n                          {t('spoolman.unlinkSpool')}\n                        </button>\n                      )}\n                    </>\n                  )}\n\n                  {/* Link Spool button (when not linked) */}\n                  {!spoolman.linkedSpoolId && (\n                    <button\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        if (spoolman.onLinkSpool) {\n                          spoolman.onLinkSpool?.();\n                        }\n                      }}\n                      disabled={!spoolman.onLinkSpool}\n                      className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors ${\n                        !spoolman.onLinkSpool\n                          ? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'\n                          : 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'\n                      }`}\n                    >\n                      <Link2 className=\"w-3.5 h-3.5\" />\n                      {t('spoolman.linkToSpoolman')}\n                    </button>\n                  )}\n                </div>\n              )}\n\n              {/* Inventory section - only for non-Bambu spools */}\n              {inventory && data.vendor !== 'Bambu Lab' && (\n                <div className=\"pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2\">\n                  {inventory.assignedSpool ? (\n                    <>\n                      <div className=\"flex items-center gap-1.5\">\n                        <Package className=\"w-3 h-3 text-bambu-green\" />\n                        <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">\n                          {t('inventory.assigned')}\n                        </span>\n                      </div>\n                      <p className=\"text-xs text-white truncate\">\n                        {inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''}\n                        {inventory.assignedSpool.material}\n                        {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}\n                      </p>\n                      {inventory.onUnassignSpool && (\n                        <button\n                          onClick={(e) => {\n                            e.stopPropagation();\n                            inventory.onUnassignSpool?.();\n                          }}\n                          className=\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400\"\n                        >\n                          <Unlink className=\"w-3.5 h-3.5\" />\n                          {t('inventory.unassignSpool')}\n                        </button>\n                      )}\n                    </>\n                  ) : inventory.onAssignSpool ? (\n                    <button\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        inventory.onAssignSpool?.();\n                      }}\n                      className=\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue\"\n                    >\n                      <Package className=\"w-3.5 h-3.5\" />\n                      {t('inventory.assignSpool')}\n                    </button>\n                  ) : null}\n                </div>\n              )}\n\n              {/* Configure slot section - always show if enabled */}\n              {configureSlot?.enabled && (\n                <div className={`${spoolman?.enabled && data.trayUuid ? '' : 'pt-2 mt-2 border-t border-bambu-dark-tertiary'}`}>\n                  <button\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      configureSlot.onConfigure?.();\n                    }}\n                    className=\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue\"\n                    title={t('ams.configureSlot')}\n                  >\n                    <Settings2 className=\"w-3.5 h-3.5\" />\n                    {t('ams.configure')}\n                  </button>\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Arrow pointer */}\n          <div\n            className={`\n              absolute left-1/2 -translate-x-1/2 w-0 h-0\n              border-l-[6px] border-l-transparent\n              border-r-[6px] border-r-transparent\n              ${position === 'top'\n                ? 'top-full border-t-[6px] border-t-bambu-dark-tertiary'\n                : 'bottom-full border-b-[6px] border-b-bambu-dark-tertiary'}\n            `}\n          />\n        </div>\n      )}\n\n      {/* Unlink Confirmation Dialog */}\n      {showUnlinkConfirm && (\n        <div className=\"fixed inset-0 z-[100] flex items-center justify-center\" onClick={() => setShowUnlinkConfirm(false)}>\n          <div className=\"absolute inset-0 bg-black/60 backdrop-blur-sm\" />\n          <div\n            className=\"relative bg-bambu-dark-secondary rounded-lg shadow-xl w-full max-w-sm mx-4 border border-bambu-dark-tertiary\"\n            onClick={(e) => e.stopPropagation()}\n          >\n            <div className=\"p-4 space-y-4\">\n              <div className=\"space-y-2\">\n                <h3 className=\"text-base font-semibold text-white\">\n                  {t('spoolman.unlinkConfirmTitle')}\n                </h3>\n                <p className=\"text-sm text-bambu-gray\">\n                  {t('spoolman.unlinkConfirmMessage')}\n                </p>\n              </div>\n              <div className=\"flex gap-2\">\n                <button\n                  onClick={() => setShowUnlinkConfirm(false)}\n                  className=\"flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-bambu-dark hover:bg-bambu-dark-tertiary text-white\"\n                >\n                  {t('common.cancel')}\n                </button>\n                <button\n                  onClick={() => {\n                    spoolman?.onUnlinkSpool?.();\n                    setShowUnlinkConfirm(false);\n                  }}\n                  className=\"flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400\"\n                >\n                  {t('spoolman.unlinkSpool')}\n                </button>\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\ninterface EmptySlotHoverCardProps {\n  children: ReactNode;\n  className?: string;\n  configureSlot?: ConfigureSlotConfig;\n  inventory?: InventoryConfig;\n}\n\n/**\n * Wrapper for empty slots - shows \"Empty\" on hover with optional configure button\n */\nexport function EmptySlotHoverCard({ children, className = '', configureSlot, inventory }: EmptySlotHoverCardProps) {\n  const { t } = useTranslation();\n  const [isVisible, setIsVisible] = useState(false);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const handleMouseEnter = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);\n  };\n\n  const handleMouseLeave = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);\n  };\n\n  useEffect(() => {\n    return () => {\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    };\n  }, []);\n\n  return (\n    <div\n      className={`relative ${className}`}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n    >\n      {children}\n\n      {isVisible && (\n        <div className=\"\n          absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-50\n          animate-in fade-in-0 zoom-in-95 duration-150\n        \">\n          <div className=\"\n            bg-bambu-dark-secondary border border-bambu-dark-tertiary\n            rounded-md shadow-lg overflow-hidden\n          \">\n            <div className=\"px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap\">\n              {t('ams.emptySlot')}\n            </div>\n            {/* Configure slot button */}\n            {configureSlot?.enabled && (\n              <div className=\"px-2 pb-2\">\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    configureSlot.onConfigure?.();\n                  }}\n                  className=\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue\"\n                  title={t('ams.configureSlot')}\n                >\n                  <Settings2 className=\"w-3.5 h-3.5\" />\n                  {t('ams.configure')}\n                </button>\n              </div>\n            )}\n            {/* Assign spool button - allows assigning inventory spool to empty slot */}\n            {inventory?.onAssignSpool && (\n              <div className=\"px-2 pb-2\">\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    inventory.onAssignSpool?.();\n                  }}\n                  className=\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue\"\n                >\n                  <Package className=\"w-3.5 h-3.5\" />\n                  {t('inventory.assignSpool')}\n                </button>\n              </div>\n            )}\n          </div>\n          <div className=\"\n            absolute left-1/2 -translate-x-1/2 top-full w-0 h-0\n            border-l-[5px] border-l-transparent\n            border-r-[5px] border-r-transparent\n            border-t-[5px] border-t-bambu-dark-tertiary\n          \" />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/FilamentSlotCircle.tsx",
    "content": "/**\n * FilamentSlotCircle renders a small color circle with the 1-based slot\n * number centered inside, matching the style used on AMS cards in PrintersPage.\n *\n * Props:\n *   trayColor  - 6-char hex color string WITHOUT leading '#' (e.g. \"FF0000\").\n *                Pass undefined / empty string when the slot is empty.\n *   trayType   - Filament material string (e.g. \"PLA\").  Used to decide the\n *                fallback background when there is no color but a type is known.\n *   isEmpty    - Whether the slot contains no filament.\n *   slotNumber - 1-based slot number to display inside the circle.\n */\n\ninterface FilamentSlotCircleProps {\n  trayColor?: string | null;\n  trayType?: string | null;\n  isEmpty: boolean;\n  slotNumber: number;\n}\n\nfunction isLightFilamentColor(hex: string): boolean {\n  if (!hex || hex.length < 6) return false;\n  const r = parseInt(hex.slice(0, 2), 16);\n  const g = parseInt(hex.slice(2, 4), 16);\n  const b = parseInt(hex.slice(4, 6), 16);\n  return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;\n}\n\nexport function FilamentSlotCircle({ trayColor, trayType, isEmpty, slotNumber }: FilamentSlotCircleProps) {\n  return (\n    <div\n      className=\"w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2 flex items-center justify-center\"\n      style={{\n        backgroundColor: trayColor ? `#${trayColor}` : (trayType ? '#333' : 'transparent'),\n        borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',\n        borderStyle: isEmpty ? 'dashed' : 'solid',\n      }}\n    >\n      <span\n        className=\"text-[6px] font-bold leading-none select-none\"\n        style={{ color: trayColor && isLightFilamentColor(trayColor) ? '#000' : '#fff' }}\n      >\n        {slotNumber}\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/FilamentTrends.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  AreaChart,\n  Area,\n  XAxis,\n  YAxis,\n  CartesianGrid,\n  Tooltip,\n  ResponsiveContainer,\n  PieChart,\n  Pie,\n  Cell,\n} from 'recharts';\nimport type { ArchiveSlim } from '../api/client';\nimport { MetricToggle, type Metric } from './MetricToggle';\nimport { parseUTCDate } from '../utils/date';\nimport { formatWeight } from '../utils/weight';\n\ninterface FilamentTrendsProps {\n  archives: ArchiveSlim[];\n  currency?: string;\n  dateFrom?: string;\n  dateTo?: string;\n}\n\nconst COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];\n\nconst DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];\nconst HOUR_SUFFIXES = ['12am', '1am', '2am', '3am', '4am', '5am', '6am', '7am', '8am', '9am', '10am', '11am', '12pm', '1pm', '2pm', '3pm', '4pm', '5pm', '6pm', '7pm', '8pm', '9pm', '10pm', '11pm'];\n\nexport function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: FilamentTrendsProps) {\n  const { t } = useTranslation();\n  const [filamentTypeMetric, setFilamentTypeMetric] = useState<Metric>('weight');\n  const [colorMetric, setColorMetric] = useState<Metric>('weight');\n\n  // Calculate daily usage data\n  const dailyData = useMemo(() => {\n    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();\n\n    archives.forEach(archive => {\n      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();\n      // Use local date string for grouping\n      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;\n\n      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };\n      existing.filament += archive.filament_used_grams || 0;\n      existing.cost += archive.cost || 0;\n      existing.prints += archive.quantity || 1;\n      dataMap.set(key, existing);\n    });\n\n    return Array.from(dataMap.values())\n      .sort((a, b) => a.date.localeCompare(b.date))\n      .map(d => ({\n        ...d,\n        dateLabel: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),\n      }));\n  }, [archives]);\n\n  // Compute effective span in days from props or archive spread\n  const spanDays = useMemo(() => {\n    if (dateFrom && dateTo) {\n      return Math.max((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;\n    }\n    if (dateFrom) {\n      return Math.max((Date.now() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;\n    }\n    if (archives.length < 2) return 0;\n    const times = archives.map(a => new Date(a.completed_at || a.created_at).getTime());\n    return (Math.max(...times) - Math.min(...times)) / 86400000;\n  }, [archives, dateFrom, dateTo]);\n\n  // Calculate hourly data for short timeframes (≤ 7 days)\n  const hourlyData = useMemo(() => {\n    if (spanDays > 7) return [];\n\n    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();\n    const multiDay = spanDays > 1;\n\n    archives.forEach(archive => {\n      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();\n      const h = date.getHours();\n      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(h).padStart(2, '0')}`;\n\n      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };\n      existing.filament += archive.filament_used_grams || 0;\n      existing.cost += archive.cost || 0;\n      existing.prints += archive.quantity || 1;\n      dataMap.set(key, existing);\n    });\n\n    return Array.from(dataMap.values())\n      .sort((a, b) => a.date.localeCompare(b.date))\n      .map(d => {\n        const [datePart, hourPart] = d.date.split('T');\n        const dt = new Date(datePart);\n        const h = parseInt(hourPart, 10);\n        const label = multiDay\n          ? `${DAY_NAMES[dt.getDay()]} ${HOUR_SUFFIXES[h]}`\n          : HOUR_SUFFIXES[h];\n        return { ...d, dateLabel: label };\n      });\n  }, [archives, spanDays]);\n\n  // Calculate weekly aggregated data when there are many daily points\n  const weeklyData = useMemo(() => {\n    if (dailyData.length <= 60) return dailyData;\n\n    const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();\n\n    dailyData.forEach(day => {\n      const date = new Date(day.date);\n      const weekStart = new Date(date);\n      weekStart.setDate(date.getDate() - date.getDay());\n      const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;\n\n      const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };\n      existing.filament += day.filament;\n      existing.cost += day.cost;\n      existing.prints += day.prints;\n      dataMap.set(key, existing);\n    });\n\n    return Array.from(dataMap.values())\n      .sort((a, b) => a.week.localeCompare(b.week))\n      .map(d => ({\n        date: d.week,\n        dateLabel: `Week of ${new Date(d.week).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`,\n        ...d,\n      }));\n  }, [dailyData]);\n\n  // Usage by filament type\n  const filamentTypeData = useMemo(() => {\n    const dataMap = new Map<string, number>();\n\n    archives.forEach(archive => {\n      const type = archive.filament_type || 'Unknown';\n      // Handle multiple types (e.g., \"PLA, PETG\")\n      const types = type.split(', ');\n      types.forEach(t => {\n        const grams = (archive.filament_used_grams || 0) / types.length;\n        dataMap.set(t, (dataMap.get(t) || 0) + grams);\n      });\n    });\n\n    return Array.from(dataMap.entries())\n      .map(([name, value]) => ({ name, value: Math.round(value) }))\n      .sort((a, b) => b.value - a.value);\n  }, [archives]);\n\n  // Usage by filament type (print count)\n  const filamentTypePrintData = useMemo(() => {\n    const dataMap = new Map<string, number>();\n    archives.forEach(archive => {\n      const type = archive.filament_type || 'Unknown';\n      const types = type.split(', ');\n      types.forEach(t => {\n        dataMap.set(t, (dataMap.get(t) || 0) + 1);\n      });\n    });\n    return Array.from(dataMap.entries())\n      .map(([name, value]) => ({ name, value }))\n      .sort((a, b) => b.value - a.value);\n  }, [archives]);\n\n  // Usage by filament type (print time in hours)\n  const filamentTypeTimeData = useMemo(() => {\n    const dataMap = new Map<string, number>();\n    archives.forEach(archive => {\n      const type = archive.filament_type || 'Unknown';\n      const types = type.split(', ');\n      const seconds = (archive.actual_time_seconds || archive.print_time_seconds || 0) / types.length;\n      types.forEach(t => {\n        dataMap.set(t, (dataMap.get(t) || 0) + seconds);\n      });\n    });\n    return Array.from(dataMap.entries())\n      .map(([name, seconds]) => ({ name, value: Math.round((seconds / 3600) * 10) / 10 }))\n      .sort((a, b) => b.value - a.value);\n  }, [archives]);\n\n  // Success rate by filament type\n  const filamentSuccessData = useMemo(() => {\n    const map = new Map<string, { completed: number; failed: number }>();\n    archives.forEach(a => {\n      if (a.status !== 'completed' && a.status !== 'failed') return;\n      const types = (a.filament_type || 'Unknown').split(', ');\n      types.forEach(type => {\n        const entry = map.get(type) || { completed: 0, failed: 0 };\n        if (a.status === 'completed') entry.completed++;\n        else entry.failed++;\n        map.set(type, entry);\n      });\n    });\n    return Array.from(map.entries())\n      .filter(([, v]) => v.completed + v.failed >= 2)\n      .map(([name, v]) => {\n        const total = v.completed + v.failed;\n        const rate = Math.round((v.completed / total) * 100);\n        return { name, rate, total };\n      })\n      .sort((a, b) => b.rate - a.rate);\n  }, [archives]);\n\n  // Color distribution\n  const colorData = useMemo(() => {\n    const colorMap = new Map<string, { count: number; weight: number }>();\n\n    archives.forEach(a => {\n      if (!a.filament_color) return;\n      const colors = a.filament_color.split(',').map(c => c.trim());\n      const weightPerColor = (a.filament_used_grams || 0) / colors.length;\n\n      colors.forEach(hex => {\n        const entry = colorMap.get(hex) || { count: 0, weight: 0 };\n        entry.count++;\n        entry.weight += weightPerColor;\n        colorMap.set(hex, entry);\n      });\n    });\n\n    return Array.from(colorMap.entries())\n      .map(([hex, data]) => ({\n        hex,\n        value: colorMetric === 'prints' ? data.count : Math.round(data.weight),\n      }))\n      .sort((a, b) => b.value - a.value);\n  }, [archives, colorMetric]);\n\n  const activeFilamentTypeData =\n    filamentTypeMetric === 'weight' ? filamentTypeData :\n    filamentTypeMetric === 'prints' ? filamentTypePrintData :\n    filamentTypeTimeData;\n\n  const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;\n  const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);\n  const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);\n  const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0);\n  const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Summary Cards */}\n      <div className=\"grid grid-cols-3 gap-2 max-[640px]:grid-cols-1\">\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <p className=\"text-sm text-bambu-gray leading-none\">{t('stats.periodFilament')}</p>\n            <p className=\"text-2xl font-bold text-white leading-none\">{formatWeight(totalFilament)}</p>\n          </div>\n          <p className=\"text-xs text-bambu-gray\">{printerCount} {t('nav.printers').toLowerCase()}</p>\n        </div>\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <p className=\"text-sm text-bambu-gray leading-none\">{t('stats.periodCost')}</p>\n            <p className=\"text-2xl font-bold text-white leading-none\">{currency}{totalCost.toFixed(2)}</p>\n          </div>\n          <p className=\"text-xs text-bambu-gray\">{totalPrints} {t('common.prints')}</p>\n        </div>\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <p className=\"text-sm text-bambu-gray leading-none\">{t('stats.avgPerPrint')}</p>\n            <p className=\"text-2xl font-bold text-white leading-none\">\n              {totalPrints > 0\n                ? (totalFilament / totalPrints).toFixed(0)\n                : 0}g\n            </p>\n          </div>\n          <p className=\"text-xs text-bambu-gray\">\n            {currency}{totalPrints > 0 ? (totalCost / totalPrints).toFixed(2) : '0.00'} avg\n          </p>\n        </div>\n      </div>\n\n      {/* Usage Over Time Chart */}\n      {chartData.length > 0 ? (\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <h4 className=\"text-sm font-medium text-bambu-gray mb-4\">{t('stats.usageOverTime')}</h4>\n          <ResponsiveContainer width=\"100%\" height={250}>\n            <AreaChart data={chartData}>\n              <defs>\n                <linearGradient id=\"colorFilament\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                  <stop offset=\"5%\" stopColor=\"#00ae42\" stopOpacity={0.3}/>\n                  <stop offset=\"95%\" stopColor=\"#00ae42\" stopOpacity={0}/>\n                </linearGradient>\n              </defs>\n              <CartesianGrid strokeDasharray=\"3 3\" stroke=\"#3d3d3d\" />\n              <XAxis\n                dataKey=\"dateLabel\"\n                stroke=\"#9ca3af\"\n                tick={{ fontSize: 12 }}\n                interval=\"preserveStartEnd\"\n              />\n              <YAxis\n                stroke=\"#9ca3af\"\n                tick={{ fontSize: 12 }}\n                tickFormatter={(value) => `${value}g`}\n              />\n              <Tooltip\n                contentStyle={{\n                  backgroundColor: '#2d2d2d',\n                  border: '1px solid #3d3d3d',\n                  borderRadius: '8px',\n                }}\n                labelStyle={{ color: '#fff' }}\n                formatter={(value) => [`${Number(value ?? 0).toFixed(0)}g`, 'Filament']}\n              />\n              <Area\n                type=\"monotone\"\n                dataKey=\"filament\"\n                stroke=\"#00ae42\"\n                strokeWidth={2}\n                fillOpacity={1}\n                fill=\"url(#colorFilament)\"\n              />\n            </AreaChart>\n          </ResponsiveContainer>\n        </div>\n      ) : (\n        <div className=\"bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray\">\n          {t('stats.noPrintDataInRange')}\n        </div>\n      )}\n\n      {/* Bottom Charts */}\n      <div className=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">\n        {/* Filament Type Distribution */}\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <div className=\"flex items-center justify-between mb-4\">\n            <h4 className=\"text-sm font-medium text-bambu-gray\">{t('stats.byMaterial')}</h4>\n            <MetricToggle value={filamentTypeMetric} onChange={setFilamentTypeMetric} />\n          </div>\n          {activeFilamentTypeData.length > 0 ? (\n            <div className=\"flex items-center gap-4\">\n              <ResponsiveContainer width={160} height={160}>\n                <PieChart>\n                  <Pie\n                    data={activeFilamentTypeData}\n                    cx=\"50%\"\n                    cy=\"50%\"\n                    innerRadius={40}\n                    outerRadius={70}\n                    paddingAngle={2}\n                    dataKey=\"value\"\n                  >\n                    {activeFilamentTypeData.map((_, index) => (\n                      <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />\n                    ))}\n                  </Pie>\n                  <Tooltip\n                    contentStyle={{\n                      backgroundColor: '#2d2d2d',\n                      border: '1px solid #3d3d3d',\n                      borderRadius: '8px',\n                    }}\n                    formatter={(value) => [\n                      filamentTypeMetric === 'weight' ? formatWeight(Number(value ?? 0)) :\n                      filamentTypeMetric === 'time' ? `${Number(value ?? 0)}h` :\n                      `${value ?? 0}`,\n                      filamentTypeMetric === 'weight' ? 'Usage' : filamentTypeMetric === 'time' ? 'Time' : 'Prints',\n                    ]}\n                  />\n                </PieChart>\n              </ResponsiveContainer>\n              <div className=\"flex-1 space-y-2 overflow-hidden\">\n                {activeFilamentTypeData.map((entry, index) => {\n                  const total = activeFilamentTypeData.reduce((sum, e) => sum + e.value, 0);\n                  const percent = total > 0 ? ((entry.value / total) * 100).toFixed(0) : 0;\n                  return (\n                    <div key={entry.name} className=\"flex items-center gap-2 text-sm\">\n                      <div\n                        className=\"w-3 h-3 rounded-sm flex-shrink-0\"\n                        style={{ backgroundColor: COLORS[index % COLORS.length] }}\n                      />\n                      <span className=\"text-white truncate flex-1\">{entry.name}</span>\n                      <span className=\"text-bambu-gray flex-shrink-0\">\n                        {filamentTypeMetric === 'weight' ? formatWeight(entry.value) :\n                         filamentTypeMetric === 'time' ? `${entry.value}h` :\n                         entry.value} · {percent}%\n                      </span>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          ) : (\n            <div className=\"h-[160px] flex items-center justify-center text-bambu-gray\">\n              {t('stats.noFilamentData')}\n            </div>\n          )}\n        </div>\n\n        {/* Success by Material */}\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <h4 className=\"text-sm font-medium text-bambu-gray mb-4\">{t('stats.filamentSuccess')}</h4>\n          {filamentSuccessData.length > 0 ? (\n            <div className=\"space-y-1.5\">\n              {filamentSuccessData.map(d => (\n                <div key={d.name} className=\"flex items-center gap-2 text-sm\">\n                  <span className=\"text-white truncate w-20 flex-shrink-0\">{d.name}</span>\n                  <div className=\"flex-1 h-1.5 bg-bambu-dark-secondary rounded-full\">\n                    <div\n                      className={`h-full rounded-full transition-all ${\n                        d.rate >= 90 ? 'bg-status-ok' : d.rate >= 70 ? 'bg-status-warning' : 'bg-status-error'\n                      }`}\n                      style={{ width: `${d.rate}%` }}\n                    />\n                  </div>\n                  <span className={`font-medium flex-shrink-0 tabular-nums ${\n                    d.rate >= 90 ? 'text-status-ok' : d.rate >= 70 ? 'text-status-warning' : 'text-status-error'\n                  }`}>\n                    {d.rate}%\n                  </span>\n                  <span className=\"text-bambu-gray flex-shrink-0 text-xs\">({d.total})</span>\n                </div>\n              ))}\n            </div>\n          ) : (\n            <div className=\"h-[160px] flex items-center justify-center text-bambu-gray\">\n              {t('stats.noArchiveData')}\n            </div>\n          )}\n        </div>\n\n        {/* Color Distribution */}\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <div className=\"flex items-center justify-between mb-4\">\n            <h4 className=\"text-sm font-medium text-bambu-gray\">{t('stats.colorDistribution')}</h4>\n            <MetricToggle value={colorMetric} onChange={setColorMetric} exclude={['time']} />\n          </div>\n          {colorData.length > 0 ? (() => {\n            const colorTotal = colorData.reduce((sum, e) => sum + e.value, 0);\n            return (\n              <div>\n                <div className=\"relative mx-auto\" style={{ width: 160, height: 160 }}>\n                  <ResponsiveContainer width=\"100%\" height=\"100%\">\n                    <PieChart>\n                      <Pie\n                        data={colorData}\n                        cx=\"50%\"\n                        cy=\"50%\"\n                        innerRadius={45}\n                        outerRadius={70}\n                        paddingAngle={2}\n                        dataKey=\"value\"\n                      >\n                        {colorData.map((entry, index) => (\n                          <Cell key={`color-${index}`} fill={entry.hex} stroke=\"#1a1a1a\" strokeWidth={1} />\n                        ))}\n                      </Pie>\n                      <Tooltip\n                        contentStyle={{\n                          backgroundColor: '#2d2d2d',\n                          border: '1px solid #3d3d3d',\n                          borderRadius: '8px',\n                        }}\n                        formatter={(value) => [\n                          colorMetric === 'weight' ? formatWeight(Number(value ?? 0)) : `${value ?? 0}`,\n                          colorMetric === 'weight' ? t('stats.filamentByWeight') : t('stats.filamentByPrints'),\n                        ]}\n                      />\n                    </PieChart>\n                  </ResponsiveContainer>\n                  <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n                    <span className=\"text-lg font-bold text-white\">\n                      {colorMetric === 'weight' ? formatWeight(colorTotal) : colorTotal}\n                    </span>\n                    <span className=\"text-[10px] text-bambu-gray\">\n                      {colorData.length} {colorData.length === 1 ? 'color' : 'colors'}\n                    </span>\n                  </div>\n                </div>\n                <div className=\"grid grid-cols-2 gap-x-3 gap-y-1 mt-2\">\n                  {colorData.slice(0, 8).map((entry) => {\n                    const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0;\n                    return (\n                      <div key={entry.hex} className=\"flex items-center gap-1.5 text-xs min-w-0\">\n                        <div className=\"w-2.5 h-2.5 rounded-full flex-shrink-0 border border-black/20\"\n                          style={{ backgroundColor: entry.hex }} />\n                        <span className=\"text-bambu-gray truncate\">\n                          {percent}%\n                        </span>\n                      </div>\n                    );\n                  })}\n                </div>\n                {colorData.length > 8 && (\n                  <p className=\"text-[10px] text-bambu-gray mt-1 text-center\">+{colorData.length - 8} more</p>\n                )}\n              </div>\n            );\n          })() : (\n            <div className=\"h-[160px] flex items-center justify-center text-bambu-gray\">\n              {t('stats.noColorData')}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/FileManagerModal.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  X,\n  Folder,\n  File,\n  ChevronLeft,\n  Download,\n  Trash2,\n  Loader2,\n  HardDrive,\n  RefreshCw,\n  Film,\n  FileBox,\n  FileText,\n  Image,\n  Search,\n  ArrowUpDown,\n  CheckSquare,\n  Square,\n  MinusSquare,\n  Box,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport { parseUTCDate } from '../utils/date';\nimport { Button } from './Button';\nimport { ConfirmModal } from './ConfirmModal';\nimport { ModelViewer } from './ModelViewer';\nimport { GcodeViewer } from './GcodeViewer';\nimport type { PlateMetadata } from '../types/plates';\nimport { useToast } from '../contexts/ToastContext';\nimport { formatFileSize } from '../utils/file';\n\ninterface FileManagerModalProps {\n  printerId: number;\n  printerName: string;\n  onClose: () => void;\n}\n\ntype PrinterViewerTab = '3d' | 'gcode';\n\ninterface PrinterFileViewerModalProps {\n  printerId: number;\n  filePath: string;\n  filename: string;\n  onClose: () => void;\n}\n\nfunction PrinterFileViewerModal({ printerId, filePath, filename, onClose }: PrinterFileViewerModalProps) {\n  const [activeTab, setActiveTab] = useState<PrinterViewerTab | null>(null);\n  const [plates, setPlates] = useState<PlateMetadata[]>([]);\n  const [platesLoading, setPlatesLoading] = useState(false);\n  const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);\n\n  const ext = filename.toLowerCase().split('.').pop() || '';\n  const hasModel = ext === '3mf' || ext === 'stl';\n  const hasGcode = ext === 'gcode' || ext === '3mf';\n\n  useEffect(() => {\n    setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);\n  }, [hasModel, hasGcode]);\n\n  useEffect(() => {\n    setPlates([]);\n    setSelectedPlateId(null);\n\n    if (!hasModel) return;\n\n    setPlatesLoading(true);\n    api.getPrinterFilePlates(printerId, filePath)\n      .then((data) => setPlates(data.plates || []))\n      .catch(() => setPlates([]))\n      .finally(() => setPlatesLoading(false));\n  }, [filePath, hasModel, printerId]);\n\n  const hasMultiplePlates = plates.length > 1;\n  const selectedPlate = selectedPlateId == null\n    ? null\n    : plates.find((plate) => plate.index === selectedPlateId) ?? null;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-6\" onClick={onClose}>\n      <div\n        className=\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white truncate flex-1 mr-4\">{filename}</h2>\n          <Button variant=\"ghost\" size=\"sm\" onClick={onClose}>\n            <X className=\"w-5 h-5\" />\n          </Button>\n        </div>\n\n        <div className=\"flex border-b border-bambu-dark-tertiary\">\n          <button\n            onClick={() => hasModel && setActiveTab('3d')}\n            disabled={!hasModel}\n            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${\n              activeTab === '3d'\n                ? 'text-bambu-green border-b-2 border-bambu-green'\n                : hasModel\n                  ? 'text-bambu-gray hover:text-white'\n                  : 'text-bambu-gray/30 cursor-not-allowed'\n            }`}\n          >\n            <Box className=\"w-4 h-4\" />\n            3D Model\n            {!hasModel && <span className=\"text-xs\">(not available)</span>}\n          </button>\n          <button\n            onClick={() => hasGcode && setActiveTab('gcode')}\n            disabled={!hasGcode}\n            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${\n              activeTab === 'gcode'\n                ? 'text-bambu-green border-b-2 border-bambu-green'\n                : hasGcode\n                  ? 'text-bambu-gray hover:text-white'\n                  : 'text-bambu-gray/30 cursor-not-allowed'\n            }`}\n          >\n            <FileText className=\"w-4 h-4\" />\n            G-code Preview\n            {!hasGcode && <span className=\"text-xs\">(not sliced)</span>}\n          </button>\n        </div>\n\n        <div className=\"flex-1 overflow-hidden p-4\">\n          {activeTab === '3d' && hasModel ? (\n            <div className=\"w-full h-full flex flex-col gap-3\">\n              {hasMultiplePlates && (\n                <div className=\"rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3\">\n                  <div className=\"flex items-center gap-2 text-sm text-bambu-gray mb-2\">\n                    <Box className=\"w-4 h-4\" />\n                    Plates\n                    {platesLoading && <Loader2 className=\"w-3 h-3 animate-spin\" />}\n                  </div>\n                  <div className=\"grid grid-cols-2 md:grid-cols-3 gap-2\">\n                    <button\n                      type=\"button\"\n                      onClick={() => setSelectedPlateId(null)}\n                      className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${\n                        selectedPlateId == null\n                          ? 'border-bambu-green bg-bambu-green/10'\n                          : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'\n                      }`}\n                    >\n                      <div className=\"w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center\">\n                        <Box className=\"w-5 h-5 text-bambu-gray\" />\n                      </div>\n                      <div className=\"min-w-0 flex-1\">\n                        <p className=\"text-sm text-white font-medium truncate\">All Plates</p>\n                        <p className=\"text-xs text-bambu-gray truncate\">\n                          {plates.length} plate{plates.length !== 1 ? 's' : ''}\n                        </p>\n                      </div>\n                      {selectedPlateId == null && (\n                        <CheckSquare className=\"w-4 h-4 text-bambu-green flex-shrink-0\" />\n                      )}\n                    </button>\n                    {plates.map((plate) => (\n                      <button\n                        key={plate.index}\n                        type=\"button\"\n                        onClick={() => setSelectedPlateId(plate.index)}\n                        className={`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${\n                          selectedPlateId === plate.index\n                            ? 'border-bambu-green bg-bambu-green/10'\n                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'\n                        }`}\n                      >\n                        {plate.has_thumbnail ? (\n                          <img\n                            src={api.getPrinterFilePlateThumbnail(printerId, plate.index, filePath)}\n                            alt={`Plate ${plate.index}`}\n                            className=\"w-10 h-10 rounded object-cover bg-bambu-dark-tertiary\"\n                          />\n                        ) : (\n                          <div className=\"w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center\">\n                            <Box className=\"w-5 h-5 text-bambu-gray\" />\n                          </div>\n                        )}\n                        <div className=\"min-w-0 flex-1\">\n                          <p className=\"text-sm text-white font-medium truncate\">\n                            {plate.name || `Plate ${plate.index}`}\n                          </p>\n                          <p className=\"text-xs text-bambu-gray truncate\">\n                            {plate.objects.length > 0\n                              ? plate.objects.slice(0, 2).join(', ') + (plate.objects.length > 2 ? '…' : '')\n                              : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}\n                          </p>\n                        </div>\n                        {selectedPlateId === plate.index && (\n                          <CheckSquare className=\"w-4 h-4 text-bambu-green flex-shrink-0\" />\n                        )}\n                      </button>\n                    ))}\n                  </div>\n                  {selectedPlate && (\n                    <div className=\"mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1\">\n                      <span>Plate {selectedPlate.index}</span>\n                      {selectedPlate.print_time_seconds != null && (\n                        <span>ETA {Math.round(selectedPlate.print_time_seconds / 60)} min</span>\n                      )}\n                      {selectedPlate.filament_used_grams != null && (\n                        <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>\n                      )}\n                      {selectedPlate.filaments.length > 0 && (\n                        <span>{selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''}</span>\n                      )}\n                    </div>\n                  )}\n                </div>\n              )}\n              <div className=\"flex-1\">\n                <ModelViewer\n                  url={api.getPrinterFileDownloadUrl(printerId, filePath)}\n                  fileType={ext}\n                  selectedPlateId={selectedPlateId}\n                  className=\"w-full h-full\"\n                />\n              </div>\n            </div>\n          ) : activeTab === 'gcode' && hasGcode ? (\n            <GcodeViewer\n              gcodeUrl={api.getPrinterFileGcodeUrl(printerId, filePath)}\n              className=\"w-full h-full\"\n            />\n          ) : (\n            <div className=\"w-full h-full flex items-center justify-center text-bambu-gray\">\n              No preview available for this file\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction formatStorageSize(bytes: number): string {\n  if (bytes === 0) return '0 GB';\n  const gb = bytes / (1024 * 1024 * 1024);\n  if (gb >= 1) {\n    return `${gb.toFixed(1)} GB`;\n  }\n  const mb = bytes / (1024 * 1024);\n  return `${mb.toFixed(0)} MB`;\n}\n\nfunction getFileIcon(filename: string, isDirectory: boolean) {\n  if (isDirectory) return Folder;\n\n  const ext = filename.toLowerCase().split('.').pop() || '';\n  switch (ext) {\n    case '3mf':\n      return FileBox;\n    case 'gcode':\n      return FileText;\n    case 'mp4':\n    case 'avi':\n      return Film;\n    case 'png':\n    case 'jpg':\n    case 'jpeg':\n      return Image;\n    default:\n      return File;\n  }\n}\n\ntype SortOption = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc';\n\nconst SORT_OPTIONS: { value: SortOption; label: string }[] = [\n  { value: 'name-asc', label: 'Name (A-Z)' },\n  { value: 'name-desc', label: 'Name (Z-A)' },\n  { value: 'size-asc', label: 'Size (smallest)' },\n  { value: 'size-desc', label: 'Size (largest)' },\n  { value: 'date-asc', label: 'Date (oldest)' },\n  { value: 'date-desc', label: 'Date (newest)' },\n];\n\nexport function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const queryClient = useQueryClient();\n  const [currentPath, setCurrentPath] = useState('/');\n  const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());\n  const [searchQuery, setSearchQuery] = useState('');\n  const [filesToDelete, setFilesToDelete] = useState<string[]>([]);\n  const [sortBy, setSortBy] = useState<SortOption>('name-asc');\n  const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);\n  const [viewerFile, setViewerFile] = useState<{ path: string; name: string } | null>(null);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  const { data, isLoading, refetch } = useQuery({\n    queryKey: ['printerFiles', printerId, currentPath],\n    queryFn: () => api.getPrinterFiles(printerId, currentPath),\n    refetchInterval: 30000,\n  });\n\n  const { data: storageData } = useQuery({\n    queryKey: ['printerStorage', printerId],\n    queryFn: () => api.getPrinterStorage(printerId),\n    staleTime: 30000, // Cache for 30 seconds\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: async (paths: string[]) => {\n      // Delete files one by one\n      for (const path of paths) {\n        await api.deletePrinterFile(printerId, path);\n      }\n    },\n    onSuccess: () => {\n      showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length }));\n      queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });\n      setSelectedFiles(new Set());\n      setFilesToDelete([]);\n    },\n    onError: (error: Error) => {\n      showToast(t('printerFiles.toast.deleteFailed', { error: error.message }), 'error');\n    },\n  });\n\n  const navigateToFolder = (path: string) => {\n    setCurrentPath(path);\n    setSelectedFiles(new Set());\n  };\n\n  const navigateUp = () => {\n    if (currentPath === '/') return;\n    const parts = currentPath.split('/').filter(Boolean);\n    parts.pop();\n    setCurrentPath(parts.length ? '/' + parts.join('/') : '/');\n    setSelectedFiles(new Set());\n  };\n\n  const toggleFileSelection = (path: string, e: React.MouseEvent) => {\n    e.stopPropagation();\n    setSelectedFiles(prev => {\n      const next = new Set(prev);\n      if (next.has(path)) {\n        next.delete(path);\n      } else {\n        next.add(path);\n      }\n      return next;\n    });\n  };\n\n  const selectAllFiles = () => {\n    if (!data?.files) return;\n    const filePaths = data.files\n      .filter(f => !f.is_directory && (!searchQuery || f.name.toLowerCase().includes(searchQuery.toLowerCase())))\n      .map(f => f.path);\n    setSelectedFiles(new Set(filePaths));\n  };\n\n  const deselectAllFiles = () => {\n    setSelectedFiles(new Set());\n  };\n\n  const handleDownload = async () => {\n    if (selectedFiles.size === 0) return;\n\n    const paths = Array.from(selectedFiles);\n\n    if (paths.length === 1) {\n      // Single file - direct download with auth\n      api.downloadPrinterFile(printerId, paths[0]).catch((err) => {\n        console.error('Printer file download failed:', err);\n      });\n      setSelectedFiles(new Set());\n      return;\n    }\n\n    // Multiple files - download as ZIP\n    setDownloadProgress({ current: 0, total: paths.length });\n    try {\n      const blob = await api.downloadPrinterFilesAsZip(printerId, paths);\n      const url = URL.createObjectURL(blob);\n      const a = document.createElement('a');\n      a.href = url;\n      a.download = `${printerName.replace(/[^a-zA-Z0-9]/g, '_')}-files.zip`;\n      document.body.appendChild(a);\n      a.click();\n      document.body.removeChild(a);\n      URL.revokeObjectURL(url);\n      showToast(`Downloaded ${paths.length} files as ZIP`);\n      setSelectedFiles(new Set());\n    } catch (error) {\n      showToast(`Download failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');\n    } finally {\n      setDownloadProgress(null);\n    }\n  };\n\n  const handleDelete = () => {\n    if (selectedFiles.size === 0) return;\n    setFilesToDelete(Array.from(selectedFiles));\n  };\n\n  // Quick navigation buttons for common directories\n  const quickDirs = [\n    { path: '/', label: 'Root' },\n    { path: '/cache', label: 'Cache' },\n    { path: '/model', label: 'Models' },\n    { path: '/timelapse', label: 'Timelapse' },\n  ];\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\"\n      onClick={onClose}\n    >\n      <div\n        className=\"w-full max-w-3xl max-h-[85vh] flex flex-col bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0\">\n            <div className=\"flex items-center gap-3\">\n              <HardDrive className=\"w-5 h-5 text-bambu-green\" />\n              <div>\n                <h2 className=\"text-lg font-semibold text-white\">{t('printerFiles.title')}</h2>\n                <p className=\"text-sm text-bambu-gray\">{printerName}</p>\n              </div>\n            </div>\n            <div className=\"flex items-center gap-4\">\n              {/* Storage info */}\n              {storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (\n                <div className=\"text-sm text-bambu-gray flex items-center gap-2\">\n                  {storageData.used_bytes != null && (\n                    <span>{t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)}</span>\n                  )}\n                  {storageData.used_bytes != null && storageData.free_bytes != null && (\n                    <span className=\"text-bambu-dark-tertiary\">|</span>\n                  )}\n                  {storageData.free_bytes != null && (\n                    <span>{t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)}</span>\n                  )}\n                </div>\n              )}\n              <button\n                onClick={onClose}\n                className=\"text-bambu-gray hover:text-white transition-colors\"\n                title=\"Close file manager\"\n                aria-label=\"Close file manager\"\n              >\n                <X className=\"w-5 h-5\" />\n              </button>\n            </div>\n          </div>\n\n        {/* Quick Navigation */}\n        <div className=\"flex items-center gap-2 p-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0\">\n          {quickDirs.map((dir) => (\n            <button\n              key={dir.path}\n              onClick={() => {\n                navigateToFolder(dir.path);\n                setSearchQuery('');\n              }}\n              className={`px-3 py-1 text-sm rounded-full transition-colors ${\n                currentPath === dir.path\n                  ? 'bg-bambu-green text-white'\n                  : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'\n              }`}\n            >\n              {dir.label}\n            </button>\n          ))}\n          <div className=\"flex-1\" />\n          <div className=\"relative\">\n            <Search className=\"absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n            <input\n              type=\"text\"\n              placeholder={t('printerFiles.filterPlaceholder')}\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n              className=\"w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n            />\n          </div>\n          <div className=\"relative flex items-center gap-1\">\n            <ArrowUpDown className=\"w-4 h-4 text-bambu-gray\" />\n            <select\n              value={sortBy}\n              onChange={(e) => setSortBy(e.target.value as SortOption)}\n              className=\"appearance-none bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm py-1.5 pl-2 pr-6 focus:border-bambu-green focus:outline-none cursor-pointer\"\n              title=\"Sort files\"\n              aria-label=\"Sort files\"\n            >\n              {SORT_OPTIONS.map((option) => (\n                <option key={option.value} value={option.value}>\n                  {option.label}\n                </option>\n              ))}\n            </select>\n          </div>\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={() => refetch()}\n            disabled={isLoading}\n          >\n            <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />\n          </Button>\n        </div>\n\n        {/* Path breadcrumb */}\n        <div className=\"flex items-center gap-2 px-4 py-2 bg-bambu-dark text-sm flex-shrink-0\">\n            <button\n              onClick={navigateUp}\n              disabled={currentPath === '/'}\n              className=\"p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed\"\n              title=\"Go to parent folder\"\n              aria-label=\"Go to parent folder\"\n            >\n              <ChevronLeft className=\"w-4 h-4\" />\n            </button>\n            <span className=\"text-bambu-gray font-mono\">{currentPath}</span>\n          </div>\n\n        {/* File list */}\n        <div className=\"flex-1 overflow-y-auto p-2 min-h-0\">\n            {isLoading ? (\n              <div className=\"flex items-center justify-center py-12\">\n                <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n              </div>\n            ) : !data?.files?.length ? (\n              <div className=\"text-center py-12 text-bambu-gray\">\n                No files in this directory\n              </div>\n            ) : (\n              <div className=\"space-y-1\">\n                {/* Filter and sort: directories first, then files with selected sort */}\n                {[...data.files]\n                  .filter((file) =>\n                    !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())\n                  )\n                  .sort((a, b) => {\n                    // Directories always first\n                    if (a.is_directory && !b.is_directory) return -1;\n                    if (!a.is_directory && b.is_directory) return 1;\n\n                    // Apply selected sort within same type\n                    switch (sortBy) {\n                      case 'name-asc':\n                        return a.name.localeCompare(b.name);\n                      case 'name-desc':\n                        return b.name.localeCompare(a.name);\n                      case 'size-asc':\n                        return a.size - b.size;\n                      case 'size-desc':\n                        return b.size - a.size;\n                      case 'date-asc': {\n                        const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0;\n                        const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0;\n                        return aTime - bTime;\n                      }\n                      case 'date-desc': {\n                        const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0;\n                        const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0;\n                        return bTime - aTime;\n                      }\n                      default:\n                        return a.name.localeCompare(b.name);\n                    }\n                  })\n                  .map((file) => {\n                    const FileIcon = getFileIcon(file.name, file.is_directory);\n                    const isSelected = selectedFiles.has(file.path);\n\n                    return (\n                      <div\n                        key={file.path}\n                        className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${\n                          isSelected\n                            ? 'bg-bambu-green/20 border border-bambu-green/50'\n                            : 'hover:bg-bambu-dark-tertiary'\n                        }`}\n                        onClick={() => {\n                          if (file.is_directory) {\n                            navigateToFolder(file.path);\n                          }\n                        }}\n                      >\n                        {/* Checkbox for files only */}\n                        {!file.is_directory ? (\n                          <button\n                            onClick={(e) => toggleFileSelection(file.path, e)}\n                            className=\"flex-shrink-0 text-bambu-gray hover:text-white\"\n                          >\n                            {isSelected ? (\n                              <CheckSquare className=\"w-5 h-5 text-bambu-green\" />\n                            ) : (\n                              <Square className=\"w-5 h-5\" />\n                            )}\n                          </button>\n                        ) : null}\n                        <FileIcon\n                          className={`w-5 h-5 flex-shrink-0 ${\n                            file.is_directory ? 'text-bambu-green' : 'text-bambu-gray'\n                          }`}\n                        />\n                        <span className=\"flex-1 text-white truncate\">{file.name}</span>\n                        {!file.is_directory && (\n                          <div className=\"flex items-center gap-3\">\n                            <span className=\"text-sm text-bambu-gray\">\n                              {formatFileSize(file.size)}\n                            </span>\n                            {(file.name.toLowerCase().endsWith('.3mf') || file.name.toLowerCase().endsWith('.gcode') || file.name.toLowerCase().endsWith('.stl')) && (\n                              <button\n                                onClick={(e) => {\n                                  e.stopPropagation();\n                                  setViewerFile({ path: file.path, name: file.name });\n                                }}\n                                className=\"p-1 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green\"\n                                title=\"3D View\"\n                              >\n                                <Box className=\"w-4 h-4\" />\n                              </button>\n                            )}\n                          </div>\n                        )}\n                        {file.is_directory && (\n                          <ChevronLeft className=\"w-4 h-4 text-bambu-gray rotate-180\" />\n                        )}\n                      </div>\n                    );\n                  })}\n              </div>\n            )}\n          </div>\n\n        {/* Action bar */}\n        <div className=\"flex items-center justify-between p-4 border-t border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0\">\n          <div className=\"flex items-center gap-4\">\n            <div className=\"text-sm text-bambu-gray\">\n              {selectedFiles.size > 0\n                ? `${selectedFiles.size} selected`\n                : searchQuery\n                  ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items`\n                  : `${data?.files?.length || 0} items`\n              }\n            </div>\n            {/* Select All / Deselect All */}\n            {data?.files?.some(f => !f.is_directory) && (\n              <div className=\"flex items-center gap-2\">\n                {selectedFiles.size > 0 ? (\n                  <button\n                    onClick={deselectAllFiles}\n                    className=\"flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors\"\n                  >\n                    <MinusSquare className=\"w-4 h-4\" />\n                    Deselect All\n                  </button>\n                ) : (\n                  <button\n                    onClick={selectAllFiles}\n                    className=\"flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors\"\n                  >\n                    <CheckSquare className=\"w-4 h-4\" />\n                    Select All\n                  </button>\n                )}\n              </div>\n            )}\n          </div>\n          <div className=\"flex gap-2\">\n            <Button\n              variant=\"secondary\"\n              disabled={selectedFiles.size === 0 || downloadProgress !== null}\n              onClick={handleDownload}\n            >\n              {downloadProgress ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  {downloadProgress.current}/{downloadProgress.total}\n                </>\n              ) : (\n                <>\n                  <Download className=\"w-4 h-4\" />\n                  Download{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}\n                </>\n              )}\n            </Button>\n            <Button\n              variant=\"secondary\"\n              disabled={selectedFiles.size === 0 || deleteMutation.isPending}\n              onClick={handleDelete}\n              className=\"text-red-400 hover:text-red-300\"\n            >\n              {deleteMutation.isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Trash2 className=\"w-4 h-4\" />\n              )}\n              {t('printerFiles.deleteButton')}{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}\n            </Button>\n          </div>\n        </div>\n      </div>\n\n      {/* Delete Confirmation Modal */}\n      {filesToDelete.length > 0 && (\n        <ConfirmModal\n          title={filesToDelete.length > 1 ? t('printerFiles.deleteFiles', { count: filesToDelete.length }) : t('fileManager.deleteFile')}\n          message={\n            filesToDelete.length > 1\n              ? t('printerFiles.deleteFilesConfirm', { count: filesToDelete.length })\n              : t('printerFiles.deleteFileConfirm', { name: filesToDelete[0].split('/').pop() })\n          }\n          confirmText={t('common.delete')}\n          variant=\"danger\"\n          onConfirm={() => {\n            deleteMutation.mutate(filesToDelete);\n          }}\n          onCancel={() => setFilesToDelete([])}\n        />\n      )}\n\n      {viewerFile && (\n        <PrinterFileViewerModal\n          printerId={printerId}\n          filePath={viewerFile.path}\n          filename={viewerFile.name}\n          onClose={() => setViewerFile(null)}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/FileUploadModal.tsx",
    "content": "import { useState, useRef, type DragEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Upload,\n  X,\n  File,\n  Loader2,\n  CheckCircle,\n  XCircle,\n  Archive as ArchiveIcon,\n  Printer,\n  Image,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport type { LibraryFileUploadResponse } from '../api/client';\nimport { Button } from './Button';\n\ninterface UploadFile {\n  file: File;\n  status: 'pending' | 'uploading' | 'success' | 'error';\n  error?: string;\n  isZip?: boolean;\n  is3mf?: boolean;\n  extractedCount?: number;\n}\n\ninterface FileUploadModalProps {\n  folderId: number | null;\n  onClose: () => void;\n  onUploadComplete: () => void;\n  /** Called after each file is successfully uploaded with its response data. Return a string to show an error and prevent modal from closing. */\n  onFileUploaded?: (file: LibraryFileUploadResponse) => string | void;\n  /** When true, automatically uploads the file as soon as it's added and closes the modal */\n  autoUpload?: boolean;\n  /** Validate files before adding. Return a string to reject with an error message. */\n  validateFile?: (file: File) => string | undefined;\n  /** Restrict file picker to specific file types (e.g. \".gcode,.gcode.3mf\") */\n  accept?: string;\n}\n\nexport function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) {\n  const { t } = useTranslation();\n  const [files, setFiles] = useState<UploadFile[]>([]);\n  const [isDragging, setIsDragging] = useState(false);\n  const [isUploading, setIsUploading] = useState(false);\n  const [preserveZipStructure, setPreserveZipStructure] = useState(true);\n  const [createFolderFromZip, setCreateFolderFromZip] = useState(false);\n  const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);\n  const [uploadError, setUploadError] = useState<string | null>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    setIsDragging(true);\n  };\n\n  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    setIsDragging(false);\n  };\n\n  const handleDrop = (e: DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    setIsDragging(false);\n    addFiles(Array.from(e.dataTransfer.files));\n  };\n\n  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {\n    if (e.target.files) {\n      addFiles(Array.from(e.target.files));\n    }\n  };\n\n  const updateFileStatus = (file: File, update: Partial<UploadFile>) => {\n    setFiles((prev) => prev.map((f) => (f.file === file ? { ...f, ...update } : f)));\n  };\n\n  const uploadFiles = async (filesToUpload: UploadFile[]) => {\n    setIsUploading(true);\n\n    for (const uf of filesToUpload) {\n      if (uf.status !== 'pending') continue;\n\n      updateFileStatus(uf.file, { status: 'uploading' });\n\n      try {\n        if (uf.isZip) {\n          const result = await api.extractZipFile(uf.file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);\n          updateFileStatus(uf.file, {\n            status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',\n            extractedCount: result.extracted,\n            error: result.errors.length > 0 ? t('fileManager.zipFilesFailed', '{{count}} files failed', { count: result.errors.length }) : undefined,\n          });\n        } else {\n          const result = await api.uploadLibraryFile(uf.file, folderId, generateStlThumbnails);\n          updateFileStatus(uf.file, { status: 'success' });\n          const error = onFileUploaded?.(result);\n          if (error) {\n            setUploadError(error);\n            setFiles([]);\n            setIsUploading(false);\n            return;\n          }\n        }\n      } catch (err) {\n        updateFileStatus(uf.file, {\n          status: 'error',\n          error: err instanceof Error ? err.message : t('fileManager.uploadFailed', 'Upload failed'),\n        });\n      }\n    }\n\n    setIsUploading(false);\n    onUploadComplete();\n    onClose();\n  };\n\n  const addFiles = (newFiles: File[]) => {\n    setUploadError(null);\n    if (validateFile) {\n      for (const file of newFiles) {\n        const error = validateFile(file);\n        if (error) {\n          setUploadError(error);\n          return;\n        }\n      }\n    }\n    const toUpload: UploadFile[] = newFiles.map((file) => ({\n      file,\n      status: 'pending' as const,\n      isZip: file.name.toLowerCase().endsWith('.zip'),\n      is3mf: file.name.toLowerCase().endsWith('.3mf'),\n    }));\n    setFiles((prev) => [...prev, ...toUpload]);\n\n    if (autoUpload && newFiles.length > 0) {\n      uploadFiles(toUpload);\n    }\n  };\n\n  const removeFile = (index: number) => {\n    setFiles((prev) => prev.filter((_, i) => i !== index));\n  };\n\n  const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');\n  const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');\n  const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');\n  const pendingCount = files.filter((f) => f.status === 'pending').length;\n  const allDone = files.length > 0 && pendingCount === 0 && !isUploading;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary\">\n        <div className=\"p-4 border-b border-bambu-dark-tertiary flex items-center justify-between\">\n          <h2 className=\"text-lg font-semibold text-white\">{t('fileManager.uploadFiles')}</h2>\n          <button onClick={onClose} className=\"p-1 hover:bg-bambu-dark rounded\">\n            <X className=\"w-5 h-5 text-bambu-gray\" />\n          </button>\n        </div>\n\n        <div className=\"p-4 space-y-4\">\n          {/* Drop Zone */}\n          <div\n            onDragOver={handleDragOver}\n            onDragLeave={handleDragLeave}\n            onDrop={handleDrop}\n            onClick={() => fileInputRef.current?.click()}\n            className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${\n              isDragging\n                ? 'border-bambu-green bg-bambu-green/10'\n                : 'border-bambu-dark-tertiary hover:border-bambu-green/50'\n            }`}\n          >\n            <Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n            <p className=\"text-white font-medium\">\n              {isDragging ? t('fileManager.dropFilesHere') : t('fileManager.dragDropFiles')}\n            </p>\n            <p className=\"text-sm text-bambu-gray mt-1\">{t('fileManager.orClickToBrowse')}</p>\n            <p className=\"text-xs text-bambu-gray/70 mt-2\">{t('fileManager.allFileTypesSupported')}</p>\n          </div>\n\n          <input\n            ref={fileInputRef}\n            type=\"file\"\n            multiple\n            accept={accept}\n            className=\"hidden\"\n            onChange={handleFileSelect}\n          />\n\n          {/* ZIP Options */}\n          {hasZipFiles && (\n            <div className=\"p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg\">\n              <div className=\"flex items-start gap-3\">\n                <ArchiveIcon className=\"w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0\" />\n                <div className=\"flex-1\">\n                  <p className=\"text-sm text-blue-300 font-medium\">{t('fileManager.zipFilesDetected')}</p>\n                  <p className=\"text-xs text-blue-300/70 mt-1\">\n                    {t('fileManager.zipExtractOptions')}\n                  </p>\n                  <label className=\"flex items-center gap-2 mt-2 cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={preserveZipStructure}\n                      onChange={(e) => setPreserveZipStructure(e.target.checked)}\n                      className=\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                    />\n                    <span className=\"text-sm text-white\">{t('fileManager.preserveZipStructure')}</span>\n                  </label>\n                  <label className=\"flex items-center gap-2 mt-2 cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={createFolderFromZip}\n                      onChange={(e) => setCreateFolderFromZip(e.target.checked)}\n                      className=\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                    />\n                    <span className=\"text-sm text-white\">{t('fileManager.createFolderFromZip')}</span>\n                  </label>\n                </div>\n              </div>\n            </div>\n          )}\n\n          {/* 3MF File Info */}\n          {has3mfFiles && (\n            <div className=\"p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg\">\n              <div className=\"flex items-start gap-3\">\n                <Printer className=\"w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0\" />\n                <div className=\"flex-1\">\n                  <p className=\"text-sm text-purple-300 font-medium\">{t('fileManager.threemfDetected')}</p>\n                  <p className=\"text-xs text-purple-300/70 mt-1\">\n                    {t('fileManager.threemfExtractionInfo')}\n                  </p>\n                </div>\n              </div>\n            </div>\n          )}\n\n          {/* STL Thumbnail Options */}\n          {(hasStlFiles || hasZipFiles) && (\n            <div className=\"p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg\">\n              <div className=\"flex items-start gap-3\">\n                <Image className=\"w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0\" />\n                <div className=\"flex-1\">\n                  <p className=\"text-sm text-bambu-green font-medium\">{t('fileManager.stlThumbnailGeneration')}</p>\n                  <p className=\"text-xs text-bambu-green/70 mt-1\">\n                    {hasZipFiles && !hasStlFiles\n                      ? t('fileManager.zipMayContainStl')\n                      : t('fileManager.thumbnailsCanBeGenerated')}\n                  </p>\n                  <label className=\"flex items-center gap-2 mt-2 cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={generateStlThumbnails}\n                      onChange={(e) => setGenerateStlThumbnails(e.target.checked)}\n                      className=\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                    />\n                    <span className=\"text-sm text-white\">{t('fileManager.generateThumbnailsForStl')}</span>\n                  </label>\n                </div>\n              </div>\n            </div>\n          )}\n\n          {/* File List */}\n          {files.length > 0 && (\n            <div className=\"max-h-48 overflow-y-auto space-y-2\">\n              {files.map((uploadFile, index) => (\n                <div\n                  key={index}\n                  className=\"flex items-center gap-3 p-2 bg-bambu-dark rounded-lg\"\n                >\n                  {uploadFile.isZip ? (\n                    <ArchiveIcon className=\"w-4 h-4 text-blue-400 flex-shrink-0\" />\n                  ) : (\n                    <File className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n                  )}\n                  <div className=\"flex-1 min-w-0\">\n                    <p className=\"text-sm text-white truncate\">{uploadFile.file.name}</p>\n                    <p className=\"text-xs text-bambu-gray\">\n                      {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB\n                      {uploadFile.isZip && uploadFile.status === 'pending' && (\n                        <span className=\"text-blue-400 ml-2\">• {t('fileManager.willBeExtracted')}</span>\n                      )}\n                      {uploadFile.extractedCount !== undefined && (\n                        <span className=\"text-green-400 ml-2\">• {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })}</span>\n                      )}\n                    </p>\n                  </div>\n                  {uploadFile.status === 'pending' && (\n                    <button\n                      onClick={() => removeFile(index)}\n                      className=\"p-1 hover:bg-bambu-dark-tertiary rounded\"\n                    >\n                      <X className=\"w-4 h-4 text-bambu-gray\" />\n                    </button>\n                  )}\n                  {uploadFile.status === 'uploading' && (\n                    <Loader2 className=\"w-4 h-4 text-bambu-green animate-spin\" />\n                  )}\n                  {uploadFile.status === 'success' && (\n                    <CheckCircle className=\"w-4 h-4 text-green-500\" />\n                  )}\n                  {uploadFile.status === 'error' && (\n                    <span title={uploadFile.error}>\n                      <XCircle className=\"w-4 h-4 text-red-500\" />\n                    </span>\n                  )}\n                </div>\n              ))}\n            </div>\n          )}\n\n          {/* Compatibility Error */}\n          {uploadError && (\n            <div className=\"p-3 bg-red-500/10 border border-red-500/30 rounded-lg\">\n              <div className=\"flex items-start gap-3\">\n                <XCircle className=\"w-5 h-5 text-red-400 mt-0.5 flex-shrink-0\" />\n                <p className=\"text-sm text-red-300\">{uploadError}</p>\n              </div>\n            </div>\n          )}\n        </div>\n\n        <div className=\"p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2\">\n          <Button variant=\"secondary\" onClick={onClose}>\n            {t('common.cancel')}\n          </Button>\n          {!allDone && (\n            <Button\n              onClick={() => uploadFiles(files)}\n              disabled={pendingCount === 0 || isUploading}\n            >\n              {isUploading ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 mr-2 animate-spin\" />\n                  {t('fileManager.uploading')}\n                </>\n              ) : (\n                <>\n                  <Upload className=\"w-4 h-4 mr-2\" />\n                  {t('common.upload')} {pendingCount > 0 ? `(${pendingCount})` : ''}\n                </>\n              )}\n            </Button>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/GcodeViewer.tsx",
    "content": "import { useEffect, useRef, useState, useCallback, useMemo } from 'react';\nimport { WebGLPreview } from 'gcode-preview';\nimport { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';\nimport { getAuthToken } from '../api/client';\n\ninterface GcodeViewerProps {\n  gcodeUrl: string;\n  buildVolume?: { x: number; y: number; z: number };\n  filamentColors?: string[];\n  className?: string;\n}\n\nexport function GcodeViewer({\n  gcodeUrl,\n  buildVolume = { x: 256, y: 256, z: 256 },\n  filamentColors,\n  className = ''\n}: GcodeViewerProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const previewRef = useRef<WebGLPreview | null>(null);\n  const renderTimeoutRef = useRef<number | null>(null);\n  const initRef = useRef(false);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n  const [notSliced, setNotSliced] = useState(false);\n  const [currentLayer, setCurrentLayer] = useState(0);\n  const [totalLayers, setTotalLayers] = useState(0);\n\n  // Memoize colors to prevent re-renders\n  const colorsKey = useMemo(() => JSON.stringify(filamentColors), [filamentColors]);\n\n  useEffect(() => {\n    if (!canvasRef.current || initRef.current) return;\n    initRef.current = true;\n\n    const canvas = canvasRef.current;\n\n    // Set canvas size before creating preview\n    const rect = canvas.parentElement?.getBoundingClientRect();\n    if (rect) {\n      canvas.width = rect.width;\n      canvas.height = rect.height;\n    }\n\n    // Use extrusionColor as array for multi-tool support\n    // Index in array = tool number\n    const hasMultiColor = filamentColors && filamentColors.length > 1;\n    const primaryColor = filamentColors?.[0] || '#00ae42';\n\n    // Create preview\n    const preview = new WebGLPreview({\n      canvas,\n      buildVolume,\n      backgroundColor: 0x1a1a1a,\n      // Pass full color array - library uses index as tool number\n      extrusionColor: hasMultiColor ? filamentColors : primaryColor,\n      disableGradient: true,\n      lineHeight: 0.2,\n      lineWidth: 2,\n      renderTravel: false,\n      renderExtrusion: true,\n    });\n\n    previewRef.current = preview;\n\n    // Fetch and process gcode\n    const headers: HeadersInit = {};\n    const token = getAuthToken();\n    if (token) {\n      headers['Authorization'] = `Bearer ${token}`;\n    }\n\n    fetch(gcodeUrl, { headers })\n      .then(async response => {\n        if (!response.ok) {\n          if (response.status === 404) {\n            const data = await response.json().catch(() => ({}));\n            if (data.detail?.includes('sliced')) {\n              setNotSliced(true);\n              throw new Error('not_sliced');\n            }\n          }\n          throw new Error('Failed to load G-code');\n        }\n        return response.text();\n      })\n      .then(gcode => {\n        // The gcode-preview library only supports T0-T7\n        // We need to remap higher tool numbers to fit within this range\n        // First, find all unique tool numbers used\n        const toolNumbers = new Set<number>();\n        const toolRegex = /^(\\s*)T(\\d+)(\\s*;.*)?$/gim;\n        let match;\n        while ((match = toolRegex.exec(gcode)) !== null) {\n          const toolNum = parseInt(match[2], 10);\n          if (toolNum <= 15) { // Valid tool, not a special command\n            toolNumbers.add(toolNum);\n          }\n        }\n\n        // Create a mapping from original tool numbers to 0-7 range\n        const toolMapping = new Map<number, number>();\n        const sortedTools = Array.from(toolNumbers).sort((a, b) => a - b);\n        sortedTools.forEach((tool, index) => {\n          toolMapping.set(tool, index % 8); // Map to 0-7\n        });\n\n        // Build remapped color array based on the mapping\n        const remappedColors: string[] = [];\n        sortedTools.forEach((originalTool, index) => {\n          const color = filamentColors?.[originalTool] || '#00ae42';\n          remappedColors[index % 8] = color;\n        });\n\n        // Process gcode: filter special commands and remap tool numbers\n        const cleanedGcode = gcode\n          .split('\\n')\n          .map(line => {\n            const match = line.match(/^(\\s*)T(\\d+)(\\s*;.*)?$/i);\n            if (match) {\n              const toolNum = parseInt(match[2], 10);\n              if (toolNum > 15) {\n                // Filter out Bambu special commands (T255, T1000, T65535, etc.)\n                return `; FILTERED: ${line.trim()}`;\n              }\n              // Remap tool number to 0-7 range\n              const mappedTool = toolMapping.get(toolNum) ?? 0;\n              return `${match[1]}T${mappedTool}${match[3] || ''}`;\n            }\n            return line;\n          })\n          .join('\\n');\n\n        // Update colors for the preview using the remapped array\n        if (remappedColors.length > 0) {\n          (preview as unknown as { extrusionColor: string[] }).extrusionColor = remappedColors;\n        }\n\n        preview.processGCode(cleanedGcode);\n\n        const layers = preview.layers?.length || 0;\n        setTotalLayers(layers);\n        setCurrentLayer(layers);\n\n        preview.render();\n        setLoading(false);\n      })\n      .catch(err => {\n        if (err.message !== 'not_sliced') {\n          setError(err.message);\n        }\n        setLoading(false);\n      });\n\n    // Handle resize\n    const handleResize = () => {\n      if (canvas.parentElement && previewRef.current) {\n        const newRect = canvas.parentElement.getBoundingClientRect();\n        canvas.width = newRect.width;\n        canvas.height = newRect.height;\n        previewRef.current.resize();\n      }\n    };\n\n    window.addEventListener('resize', handleResize);\n\n    return () => {\n      window.removeEventListener('resize', handleResize);\n      if (renderTimeoutRef.current) {\n        cancelAnimationFrame(renderTimeoutRef.current);\n      }\n      if (previewRef.current) {\n        previewRef.current.dispose();\n        previewRef.current = null;\n      }\n      initRef.current = false;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [gcodeUrl, colorsKey]); // Intentionally use colorsKey instead of filamentColors, buildVolume rarely changes\n\n  const handleLayerChange = useCallback((layer: number) => {\n    if (!previewRef.current) return;\n    const newLayer = Math.max(1, Math.min(layer, totalLayers));\n    setCurrentLayer(newLayer);\n\n    if (renderTimeoutRef.current) {\n      cancelAnimationFrame(renderTimeoutRef.current);\n    }\n\n    renderTimeoutRef.current = requestAnimationFrame(() => {\n      if (previewRef.current) {\n        previewRef.current.endLayer = newLayer;\n        previewRef.current.render();\n      }\n    });\n  }, [totalLayers]);\n\n  const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    handleLayerChange(parseInt(e.target.value, 10));\n  };\n\n  return (\n    <div className={`relative flex flex-col h-full ${className}`}>\n      <div className=\"flex-1 relative bg-bambu-dark rounded-lg overflow-hidden\">\n        <canvas ref={canvasRef} className=\"w-full h-full\" />\n\n        {loading && (\n          <div className=\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\">\n            <div className=\"text-center\">\n              <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green mx-auto mb-2\" />\n              <p className=\"text-bambu-gray text-sm\">Loading G-code...</p>\n            </div>\n          </div>\n        )}\n\n        {notSliced && (\n          <div className=\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\">\n            <div className=\"text-center max-w-sm px-4\">\n              <FileWarning className=\"w-12 h-12 text-bambu-gray mx-auto mb-3\" />\n              <p className=\"text-white font-medium mb-2\">G-code not available</p>\n              <p className=\"text-bambu-gray text-sm\">\n                This file hasn't been sliced yet. G-code preview is only available\n                after slicing in Bambu Studio or Orca Slicer.\n              </p>\n            </div>\n          </div>\n        )}\n\n        {error && !notSliced && (\n          <div className=\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\">\n            <div className=\"text-center text-red-400\">\n              <p className=\"text-sm\">{error}</p>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {!loading && !error && !notSliced && totalLayers > 0 && (\n        <div className=\"mt-4 px-2\">\n          <div className=\"flex items-center gap-3\">\n            <Layers className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n\n            <button\n              onClick={() => handleLayerChange(currentLayer - 1)}\n              disabled={currentLayer <= 1}\n              className=\"p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed\"\n            >\n              <ChevronLeft className=\"w-4 h-4\" />\n            </button>\n\n            <input\n              type=\"range\"\n              min={1}\n              max={totalLayers}\n              value={currentLayer}\n              onChange={handleSliderChange}\n              className=\"flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green\"\n            />\n\n            <button\n              onClick={() => handleLayerChange(currentLayer + 1)}\n              disabled={currentLayer >= totalLayers}\n              className=\"p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed\"\n            >\n              <ChevronRight className=\"w-4 h-4\" />\n            </button>\n\n            <span className=\"text-sm text-bambu-gray min-w-[80px] text-right\">\n              {currentLayer} / {totalLayers}\n            </span>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/GitHubBackupSettings.tsx",
    "content": "import { useState, useEffect, useRef, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  Github,\n  Play,\n  Clock,\n  CheckCircle,\n  XCircle,\n  Loader2,\n  ExternalLink,\n  RefreshCw,\n  Download,\n  Upload,\n  Database,\n  History,\n  SkipForward,\n  AlertTriangle,\n  Trash2,\n  RotateCcw,\n  FolderArchive,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport type {\n  GitHubBackupConfig,\n  GitHubBackupConfigCreate,\n  GitHubBackupLog,\n  GitHubBackupStatus,\n  GitHubBackupTriggerResponse,\n  LocalBackupFile,\n  LocalBackupStatus,\n  ScheduleType,\n  CloudAuthStatus,\n  Printer,\n} from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { Toggle } from './Toggle';\nimport { ConfirmModal } from './ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { formatRelativeTime, parseUTCDate } from '../utils/date';\n\nfunction formatDateTime(dateStr: string | null): string {\n  if (!dateStr) return '-';\n  const date = parseUTCDate(dateStr);\n  if (!date) return '-';\n  return date.toLocaleString();\n}\n\ninterface StatusBadgeProps {\n  status: string | null;\n}\n\nfunction StatusBadge({ status }: StatusBadgeProps) {\n  if (!status) return null;\n\n  const styles: Record<string, string> = {\n    success: 'bg-green-500/20 text-green-400',\n    failed: 'bg-red-500/20 text-red-400',\n    skipped: 'bg-yellow-500/20 text-yellow-400',\n    running: 'bg-blue-500/20 text-blue-400',\n  };\n\n  const icons: Record<string, React.ReactNode> = {\n    success: <CheckCircle className=\"w-3 h-3\" />,\n    failed: <XCircle className=\"w-3 h-3\" />,\n    skipped: <SkipForward className=\"w-3 h-3\" />,\n    running: <Loader2 className=\"w-3 h-3 animate-spin\" />,\n  };\n\n  return (\n    <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${styles[status] || 'bg-gray-500/20 text-gray-400'}`}>\n      {icons[status]}\n      {status.charAt(0).toUpperCase() + status.slice(1)}\n    </span>\n  );\n}\n\nexport function GitHubBackupSettings() {\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { t } = useTranslation();\n\n  // Local state for form\n  const [repoUrl, setRepoUrl] = useState('');\n  const [accessToken, setAccessToken] = useState('');\n  const [branch, setBranch] = useState('main');\n  const [scheduleEnabled, setScheduleEnabled] = useState(false);\n  const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');\n  const [backupKProfiles, setBackupKProfiles] = useState(true);\n  const [backupCloudProfiles, setBackupCloudProfiles] = useState(true);\n  const [backupSettings, setBackupSettings] = useState(false);\n  const [backupSpools, setBackupSpools] = useState(false);\n  const [backupArchives, setBackupArchives] = useState(false);\n  const [enabled, setEnabled] = useState(true);\n\n  // Local backup state\n  const [isExporting, setIsExporting] = useState(false);\n  const [isRestoring, setIsRestoring] = useState(false);\n  const [operationStatus, setOperationStatus] = useState<string>('');\n  const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);\n  const [restoreFile, setRestoreFile] = useState<File | null>(null);\n  const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  // Scheduled local backup state\n  const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);\n  const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);\n  const [localBackupPath, setLocalBackupPath] = useState('');\n\n  const { data: localBackupStatus, refetch: refetchLocalStatus } = useQuery<LocalBackupStatus>({\n    queryKey: ['local-backup-status'],\n    queryFn: api.getLocalBackupStatus,\n    refetchInterval: (query) => query.state.data?.is_running ? 1000 : 10000,\n  });\n\n  const { data: localBackups, refetch: refetchLocalBackups } = useQuery<LocalBackupFile[]>({\n    queryKey: ['local-backup-files'],\n    queryFn: api.getLocalBackups,\n    refetchInterval: 30000,\n  });\n\n  // Sync local path state from server\n  useEffect(() => {\n    if (localBackupStatus?.path !== undefined) {\n      setLocalBackupPath(localBackupStatus.path);\n    }\n  }, [localBackupStatus?.path]);\n\n  const triggerLocalBackupMutation = useMutation({\n    mutationFn: api.triggerLocalBackup,\n    onSuccess: (data) => {\n      if (data.success) {\n        showToast(t('backup.scheduledBackupComplete'));\n      } else {\n        showToast(data.message, 'error');\n      }\n      refetchLocalStatus();\n      refetchLocalBackups();\n    },\n    onError: () => showToast(t('backup.scheduledBackupFailed'), 'error'),\n  });\n\n  const deleteLocalBackupMutation = useMutation({\n    mutationFn: (filename: string) => api.deleteLocalBackup(filename),\n    onSuccess: () => {\n      refetchLocalBackups();\n      setDeleteConfirmFile(null);\n    },\n  });\n\n  const restoreLocalBackupMutation = useMutation({\n    mutationFn: async (filename: string) => {\n      setRestoreConfirmFile(null);\n      setIsRestoring(true);\n      setRestoreResult(null);\n      setOperationStatus(t('backup.restoring'));\n      return api.restoreLocalBackup(filename);\n    },\n    onSuccess: (data) => {\n      setIsRestoring(false);\n      setOperationStatus('');\n      if (data.success) {\n        setRestoreResult({ success: true, message: data.message });\n        showToast(t('backup.backupRestoredRestart'), 'success');\n      } else {\n        setRestoreResult({ success: false, message: data.message });\n        showToast(data.message, 'error');\n      }\n    },\n    onError: (e) => {\n      setIsRestoring(false);\n      setOperationStatus('');\n      const msg = e instanceof Error ? e.message : t('backup.failedToRestore');\n      setRestoreResult({ success: false, message: msg });\n      showToast(msg, 'error');\n    },\n  });\n\n  // Block navigation while backup/restore is in progress\n  useEffect(() => {\n    const isOperationInProgress = isExporting || isRestoring;\n\n    if (isOperationInProgress) {\n      const handleBeforeUnload = (e: BeforeUnloadEvent) => {\n        e.preventDefault();\n        e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?';\n        return e.returnValue;\n      };\n\n      window.addEventListener('beforeunload', handleBeforeUnload);\n      return () => window.removeEventListener('beforeunload', handleBeforeUnload);\n    }\n  }, [isExporting, isRestoring]);\n\n  // Test connection state\n  const [testLoading, setTestLoading] = useState(false);\n  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);\n\n  // Auto-save debounce\n  const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);\n  const isInitializedRef = useRef(false);\n\n  // Queries\n  const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({\n    queryKey: ['github-backup-config'],\n    queryFn: api.getGitHubBackupConfig,\n  });\n\n  const { data: status } = useQuery<GitHubBackupStatus>({\n    queryKey: ['github-backup-status'],\n    queryFn: api.getGitHubBackupStatus,\n    refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup\n  });\n\n  const { data: logs } = useQuery<GitHubBackupLog[]>({\n    queryKey: ['github-backup-logs'],\n    queryFn: () => api.getGitHubBackupLogs(20),\n  });\n\n  const { data: cloudStatus } = useQuery<CloudAuthStatus>({\n    queryKey: ['cloud-status'],\n    queryFn: api.getCloudStatus,\n  });\n\n  // Fetch printers and their statuses for K-profile availability\n  const { data: printers } = useQuery<Printer[]>({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  // Fetch printer statuses from API (not just cache) to get accurate connection status\n  const printerStatusQueries = useQueries({\n    queries: (printers ?? []).map(printer => ({\n      queryKey: ['printerStatus', printer.id],\n      queryFn: () => api.getPrinterStatus(printer.id),\n      staleTime: 10000, // Consider stale after 10s\n      refetchInterval: 30000, // Refresh every 30s\n    })),\n  });\n\n  const printerStatuses = (printers ?? []).map((printer, index) => ({\n    printer,\n    connected: printerStatusQueries[index]?.data?.connected ?? false,\n  }));\n\n  const totalPrinters = printerStatuses.length;\n  const connectedPrinters = printerStatuses.filter(p => p.connected).length;\n  const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0;\n  const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters;\n\n  // Initialize form from config\n  useEffect(() => {\n    if (config) {\n      setRepoUrl(config.repository_url);\n      setBranch(config.branch);\n      setScheduleEnabled(config.schedule_enabled);\n      setScheduleType(config.schedule_type);\n      setBackupKProfiles(config.backup_kprofiles);\n      setBackupCloudProfiles(config.backup_cloud_profiles);\n      setBackupSettings(config.backup_settings);\n      setBackupSpools(config.backup_spools);\n      setBackupArchives(config.backup_archives);\n      setEnabled(config.enabled);\n      setAccessToken(''); // Don't show stored token\n      // Mark as initialized after a tick to avoid auto-save on initial load\n      setTimeout(() => { isInitializedRef.current = true; }, 100);\n    }\n  }, [config]);\n\n  // Auto-save function for existing configs\n  const autoSave = useCallback(async (includeToken: boolean = false) => {\n    if (!config?.has_token) return; // Only auto-save if config already exists\n\n    try {\n      if (includeToken && accessToken) {\n        // Full save with new token\n        await api.saveGitHubBackupConfig({\n          repository_url: repoUrl,\n          access_token: accessToken,\n          branch,\n          schedule_enabled: scheduleEnabled,\n          schedule_type: scheduleType,\n          backup_kprofiles: backupKProfiles,\n          backup_cloud_profiles: backupCloudProfiles,\n          backup_settings: backupSettings,\n          backup_spools: backupSpools,\n          backup_archives: backupArchives,\n          enabled,\n        });\n        setAccessToken(''); // Clear after save\n        showToast(t('backup.tokenUpdated'));\n      } else {\n        // Update without token\n        await api.updateGitHubBackupConfig({\n          repository_url: repoUrl,\n          branch,\n          schedule_enabled: scheduleEnabled,\n          schedule_type: scheduleType,\n          backup_kprofiles: backupKProfiles,\n          backup_cloud_profiles: backupCloudProfiles,\n          backup_settings: backupSettings,\n          backup_spools: backupSpools,\n          backup_archives: backupArchives,\n          enabled,\n        });\n        showToast(t('backup.settingsSaved'));\n      }\n      queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });\n      queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });\n    } catch (error) {\n      showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');\n    }\n  }, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, queryClient, showToast, t]);\n\n  // Auto-save effect for existing configs (debounced)\n  useEffect(() => {\n    if (!isInitializedRef.current || !config?.has_token) return;\n\n    if (autoSaveTimerRef.current) {\n      clearTimeout(autoSaveTimerRef.current);\n    }\n\n    autoSaveTimerRef.current = setTimeout(() => {\n      autoSave(false);\n    }, 500);\n\n    return () => {\n      if (autoSaveTimerRef.current) {\n        clearTimeout(autoSaveTimerRef.current);\n      }\n    };\n  }, [repoUrl, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled, autoSave, config?.has_token]);\n\n  // Auto-save token when it changes (with longer debounce)\n  useEffect(() => {\n    if (!isInitializedRef.current || !config?.has_token || !accessToken) return;\n\n    if (autoSaveTimerRef.current) {\n      clearTimeout(autoSaveTimerRef.current);\n    }\n\n    autoSaveTimerRef.current = setTimeout(() => {\n      autoSave(true);\n    }, 1000);\n\n    return () => {\n      if (autoSaveTimerRef.current) {\n        clearTimeout(autoSaveTimerRef.current);\n      }\n    };\n  }, [accessToken, autoSave, config?.has_token]);\n\n  // Mutations\n  const saveConfigMutation = useMutation({\n    mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });\n      queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });\n      showToast(t('backup.githubBackupEnabled'));\n      setAccessToken('');\n      isInitializedRef.current = true;\n    },\n    onError: (error: Error) => {\n      showToast(t('backup.failedToSave', { message: error.message }), 'error');\n    },\n  });\n\n  const triggerBackupMutation = useMutation<GitHubBackupTriggerResponse, Error>({\n    mutationFn: api.triggerGitHubBackup,\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });\n      queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });\n      if (result.success) {\n        if (result.files_changed > 0) {\n          showToast(t('backup.backupCompleteFiles', { count: result.files_changed }));\n        } else {\n          showToast(t('backup.backupSkippedNoChanges'));\n        }\n      } else {\n        showToast(t('backup.backupFailed2', { message: result.message }), 'error');\n      }\n    },\n    onError: (error: Error) => {\n      showToast(t('backup.backupFailed2', { message: error.message }), 'error');\n    },\n  });\n\n  const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({\n    mutationFn: () => api.clearGitHubBackupLogs(0),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });\n      showToast(t('backup.clearedLogs', { count: result.deleted }));\n    },\n    onError: (error: Error) => {\n      showToast(t('backup.failedToClearLogs', { message: error.message }), 'error');\n    },\n  });\n\n  const handleTestConnection = async () => {\n    setTestLoading(true);\n    setTestResult(null);\n    try {\n      let result;\n      // If user entered a new token, test with those credentials\n      if (accessToken) {\n        if (!repoUrl) {\n          showToast(t('backup.enterRepoUrl'), 'error');\n          setTestLoading(false);\n          return;\n        }\n        result = await api.testGitHubConnection(repoUrl, accessToken);\n      } else if (config?.has_token) {\n        // Use stored credentials\n        result = await api.testGitHubStoredConnection();\n      } else {\n        showToast(t('backup.enterRepoAndToken'), 'error');\n        setTestLoading(false);\n        return;\n      }\n      setTestResult({ success: result.success, message: result.message });\n    } catch (error) {\n      setTestResult({ success: false, message: (error as Error).message });\n    } finally {\n      setTestLoading(false);\n    }\n  };\n\n  // Initial setup save (only for new configs)\n  const handleInitialSetup = () => {\n    if (!repoUrl) {\n      showToast(t('backup.repoRequired'), 'error');\n      return;\n    }\n    if (!accessToken) {\n      showToast(t('backup.tokenRequired'), 'error');\n      return;\n    }\n\n    saveConfigMutation.mutate({\n      repository_url: repoUrl,\n      access_token: accessToken,\n      branch,\n      schedule_enabled: scheduleEnabled,\n      schedule_type: scheduleType,\n      backup_kprofiles: backupKProfiles,\n      backup_cloud_profiles: backupCloudProfiles,\n      backup_settings: backupSettings,\n      backup_spools: backupSpools,\n      backup_archives: backupArchives,\n      enabled,\n    });\n  };\n\n  if (configLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-12\">\n        <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-6\">\n      {/* Left Column - GitHub Backup */}\n      <div className=\"space-y-6\">\n        <Card id=\"card-backup-github\">\n          <CardHeader>\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                <Github className=\"w-5 h-5 text-gray-400\" />\n                <h2 className=\"text-lg font-semibold text-white\">{t('backup.githubBackup')}</h2>\n              </div>\n              {config && (\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-sm text-bambu-gray\">{t('backup.enabled')}</span>\n                  <Toggle\n                    checked={enabled}\n                    onChange={setEnabled}\n                  />\n                </div>\n              )}\n            </div>\n          </CardHeader>\n          <CardContent className=\"space-y-4\">\n                <p className=\"text-sm text-bambu-gray\">\n                  {t('backup.githubDescription')}\n                </p>\n\n                {/* Repository URL */}\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">\n                    {t('backup.repositoryUrl')}\n                  </label>\n                  <input\n                    type=\"text\"\n                    value={repoUrl}\n                    onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}\n                    placeholder=\"https://github.com/username/bambuddy-backup\"\n                    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\"\n                  />\n                </div>\n\n                {/* Access Token */}\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">\n                    {t('backup.personalAccessToken')} {config?.has_token && <span className=\"text-green-400\">{t('backup.tokenSaved')}</span>}\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={accessToken}\n                    onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}\n                    placeholder={config?.has_token ? t('backup.enterNewToken') : 'ghp_xxxxxxxxxxxx'}\n                    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\"\n                  />\n                  <p className=\"text-xs text-bambu-gray mt-1\">\n                    {t('backup.tokenHint')}\n                  </p>\n                </div>\n\n            {/* Branch - inline with schedule */}\n            <div className=\"grid grid-cols-2 gap-4\">\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('backup.branch')}</label>\n                <input\n                  type=\"text\"\n                  value={branch}\n                  onChange={(e) => setBranch(e.target.value)}\n                  placeholder=\"main\"\n                  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\"\n                />\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('backup.autoBackup')}</label>\n                <select\n                  value={scheduleEnabled ? scheduleType : 'disabled'}\n                  onChange={(e) => {\n                    if (e.target.value === 'disabled') {\n                      setScheduleEnabled(false);\n                    } else {\n                      setScheduleEnabled(true);\n                      setScheduleType(e.target.value as ScheduleType);\n                    }\n                  }}\n                  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\"\n                >\n                  <option value=\"disabled\">{t('backup.manualOnly')}</option>\n                  <option value=\"hourly\">{t('backup.hourly')}</option>\n                  <option value=\"daily\">{t('backup.daily')}</option>\n                  <option value=\"weekly\">{t('backup.weekly')}</option>\n                </select>\n              </div>\n            </div>\n\n            {/* What to backup */}\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-2\">{t('backup.includeInBackup')}</label>\n              <div className=\"space-y-2\">\n                <label className={`flex items-start gap-2 ${noPrintersConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>\n                  <input\n                    type=\"checkbox\"\n                    checked={backupKProfiles}\n                    onChange={(e) => setBackupKProfiles(e.target.checked)}\n                    className=\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                    disabled={noPrintersConnected}\n                  />\n                  <div className=\"flex-1\">\n                    <div className=\"flex items-center gap-2\">\n                      <span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>{t('backup.kProfiles')}</span>\n                      {noPrintersConnected && (\n                        <span className=\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400\">\n                          <AlertTriangle className=\"w-3 h-3\" />\n                          {t('backup.noPrintersConnected')}\n                        </span>\n                      )}\n                      {somePrintersDisconnected && (\n                        <span className=\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400\">\n                          <AlertTriangle className=\"w-3 h-3\" />\n                          {t('backup.printersConnected', { connected: connectedPrinters, total: totalPrinters })}\n                        </span>\n                      )}\n                    </div>\n                    <p className=\"text-xs text-bambu-gray\">{t('backup.kProfilesDescription')}</p>\n                  </div>\n                </label>\n                <label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>\n                  <input\n                    type=\"checkbox\"\n                    checked={backupCloudProfiles}\n                    onChange={(e) => setBackupCloudProfiles(e.target.checked)}\n                    className=\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                    disabled={!cloudStatus?.is_authenticated}\n                  />\n                  <div>\n                    <div className=\"flex items-center gap-2\">\n                      <span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>\n                      {!cloudStatus?.is_authenticated && (\n                        <span className=\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400\">\n                          <AlertTriangle className=\"w-3 h-3\" />\n                          {t('backup.cloudLoginRequiredShort')}\n                        </span>\n                      )}\n                    </div>\n                    <p className=\"text-xs text-bambu-gray\">{t('backup.cloudProfilesDescription')}</p>\n                  </div>\n                </label>\n                <label className=\"flex items-start gap-2 cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={backupSettings}\n                    onChange={(e) => setBackupSettings(e.target.checked)}\n                    className=\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                  />\n                  <div>\n                    <span className=\"text-white text-sm\">{t('backup.appSettings')}</span>\n                    <p className=\"text-xs text-bambu-gray\">{t('backup.appSettingsDescription')}</p>\n                  </div>\n                </label>\n                <label className=\"flex items-start gap-2 cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={backupSpools}\n                    onChange={(e) => setBackupSpools(e.target.checked)}\n                    className=\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                  />\n                  <div>\n                    <span className=\"text-white text-sm\">{t('backup.spoolInventory')}</span>\n                    <p className=\"text-xs text-bambu-gray\">{t('backup.spoolInventoryDescription')}</p>\n                  </div>\n                </label>\n                <label className=\"flex items-start gap-2 cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={backupArchives}\n                    onChange={(e) => setBackupArchives(e.target.checked)}\n                    className=\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                  />\n                  <div>\n                    <span className=\"text-white text-sm\">{t('backup.printArchives')}</span>\n                    <p className=\"text-xs text-bambu-gray\">{t('backup.printArchivesDescription')}</p>\n                  </div>\n                </label>\n              </div>\n            </div>\n\n            {/* Test + Status + Actions */}\n            <div className=\"border-t border-bambu-dark-tertiary pt-4 space-y-3\">\n              {/* Status line */}\n              {status?.configured && (\n                <div className=\"flex items-center justify-between text-sm\">\n                  <div className=\"flex items-center gap-2 text-bambu-gray\">\n                    {status.last_backup_at ? (\n                      <>\n                        <span>{t('backup.lastBackupAt')} {formatRelativeTime(status.last_backup_at, 'system', t)}</span>\n                        <StatusBadge status={status.last_backup_status} />\n                      </>\n                    ) : (\n                      <span>{t('backup.noBackupsYet')}</span>\n                    )}\n                  </div>\n                  {status.next_scheduled_run && (\n                    <span className=\"text-bambu-gray\">\n                      <Clock className=\"w-3 h-3 inline mr-1\" />\n                      {t('backup.next')} {formatRelativeTime(status.next_scheduled_run, 'system', t)}\n                    </span>\n                  )}\n                </div>\n              )}\n\n              {/* Test result */}\n              {testResult && (\n                <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>\n                  {testResult.success ? <CheckCircle className=\"w-4 h-4\" /> : <XCircle className=\"w-4 h-4\" />}\n                  {testResult.message}\n                </div>\n              )}\n\n              {/* Action buttons */}\n              <div className=\"flex flex-wrap items-center gap-2\">\n                {status?.configured ? (\n                  <>\n                    {(triggerBackupMutation.isPending || status.is_running) ? (\n                      <div className=\"flex items-center gap-2 text-bambu-green\">\n                        <Loader2 className=\"w-4 h-4 animate-spin\" />\n                        <span className=\"text-sm\">{status.progress || t('backup.startingBackup')}</span>\n                      </div>\n                    ) : (\n                      <>\n                        <Button\n                          variant=\"primary\"\n                          size=\"sm\"\n                          onClick={() => triggerBackupMutation.mutate()}\n                          disabled={!config?.enabled}\n                        >\n                          <Play className=\"w-4 h-4\" />\n                          {t('backup.backupNow')}\n                        </Button>\n                        <Button\n                          variant=\"secondary\"\n                          size=\"sm\"\n                          onClick={handleTestConnection}\n                          disabled={testLoading}\n                        >\n                          {testLoading ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <RefreshCw className=\"w-4 h-4\" />}\n                          {t('backup.test')}\n                        </Button>\n                      </>\n                    )}\n                  </>\n                ) : (\n                  <>\n                    <Button\n                      variant=\"primary\"\n                      size=\"sm\"\n                      onClick={handleInitialSetup}\n                      disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}\n                    >\n                      {saveConfigMutation.isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <CheckCircle className=\"w-4 h-4\" />}\n                      {t('backup.enableBackup')}\n                    </Button>\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={handleTestConnection}\n                      disabled={testLoading || !repoUrl || !accessToken}\n                    >\n                      {testLoading ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <RefreshCw className=\"w-4 h-4\" />}\n                      {t('backup.testConnection')}\n                    </Button>\n                  </>\n                )}\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        {/* Backup History - only show if configured and has logs */}\n        {logs && logs.length > 0 && (\n          <Card id=\"card-backup-history\">\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <History className=\"w-5 h-5 text-gray-400\" />\n                  <h2 className=\"text-lg font-semibold text-white\">{t('backup.history')}</h2>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => clearLogsMutation.mutate()}\n                  disabled={clearLogsMutation.isPending}\n                >\n                  <Trash2 className=\"w-4 h-4\" />\n                  {t('backup.clear')}\n                </Button>\n              </div>\n            </CardHeader>\n            <CardContent>\n              <div className=\"overflow-x-auto\">\n                <table className=\"w-full text-sm\">\n                  <thead>\n                    <tr className=\"text-bambu-gray border-b border-bambu-dark-tertiary\">\n                      <th className=\"text-left py-2 px-2\">{t('backup.date')}</th>\n                      <th className=\"text-left py-2 px-2\">{t('backup.status')}</th>\n                      <th className=\"text-left py-2 px-2\">{t('backup.commit')}</th>\n                    </tr>\n                  </thead>\n                  <tbody>\n                    {logs.slice(0, 10).map((log) => (\n                      <tr key={log.id} className=\"border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary\">\n                        <td className=\"py-2 px-2 text-white\">{formatDateTime(log.started_at)}</td>\n                        <td className=\"py-2 px-2\"><StatusBadge status={log.status} /></td>\n                        <td className=\"py-2 px-2\">\n                          {log.commit_sha ? (\n                            <a\n                              href={`${config?.repository_url}/commit/${log.commit_sha}`}\n                              target=\"_blank\"\n                              rel=\"noopener noreferrer\"\n                              className=\"text-bambu-green hover:underline inline-flex items-center gap-1\"\n                            >\n                              {log.commit_sha.substring(0, 7)}\n                              <ExternalLink className=\"w-3 h-3\" />\n                            </a>\n                          ) : (\n                            <span className=\"text-bambu-gray\">-</span>\n                          )}\n                        </td>\n                      </tr>\n                    ))}\n                  </tbody>\n                </table>\n              </div>\n            </CardContent>\n          </Card>\n        )}\n      </div>\n\n      {/* Right Column - Local Backup */}\n      <div className=\"space-y-6\">\n        <Card id=\"card-backup-local\">\n          <CardHeader>\n            <div className=\"flex items-center gap-2\">\n              <Database className=\"w-5 h-5 text-gray-400\" />\n              <h2 className=\"text-lg font-semibold text-white\">{t('backup.localBackup')}</h2>\n            </div>\n          </CardHeader>\n          <CardContent className=\"space-y-4\">\n            <p className=\"text-sm text-bambu-gray\">\n              {t('backup.localBackupDescription')}\n            </p>\n\n            {/* Export */}\n            <div className=\"flex items-center justify-between py-3 border-b border-bambu-dark-tertiary\">\n              <div>\n                <p className=\"text-white\">{t('backup.downloadBackupLabel')}</p>\n                <p className=\"text-sm text-bambu-gray\">\n                  {t('backup.completeBackupZip')}\n                </p>\n              </div>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                disabled={isExporting || isRestoring}\n                onClick={async () => {\n                  setIsExporting(true);\n                  setOperationStatus(t('backup.preparingBackup'));\n                  try {\n                    setOperationStatus(t('backup.creatingArchive'));\n                    const { blob, filename } = await api.exportBackup();\n                    setOperationStatus(t('backup.downloadingFile'));\n                    const url = URL.createObjectURL(blob);\n                    const a = document.createElement('a');\n                    a.href = url;\n                    a.download = filename;\n                    a.click();\n                    URL.revokeObjectURL(url);\n                    showToast(t('backup.backupDownloaded'));\n                  } catch (e) {\n                    showToast(t('backup.failedToCreateBackup', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');\n                  } finally {\n                    setIsExporting(false);\n                    setOperationStatus('');\n                  }\n                }}\n              >\n                <Download className=\"w-4 h-4\" />\n                {t('backup.download')}\n              </Button>\n            </div>\n\n            {/* Import */}\n            <div className=\"flex items-center justify-between py-3 border-b border-bambu-dark-tertiary\">\n              <div>\n                <p className=\"text-white\">{t('backup.restoreBackup')}</p>\n                <p className=\"text-sm text-bambu-gray\">\n                  {t('backup.restoreDescription')}\n                </p>\n                <p className=\"text-xs text-bambu-gray-light mt-1\">\n                  {t('backup.restoreNote')}\n                </p>\n              </div>\n              <input\n                ref={fileInputRef}\n                type=\"file\"\n                accept=\".zip\"\n                className=\"hidden\"\n                onChange={(e) => {\n                  const file = e.target.files?.[0];\n                  if (file) {\n                    setRestoreFile(file);\n                    setShowRestoreConfirm(true);\n                  }\n                  e.target.value = '';\n                }}\n              />\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                disabled={isRestoring || isExporting}\n                onClick={() => fileInputRef.current?.click()}\n              >\n                <Upload className=\"w-4 h-4\" />\n                {t('backup.restore')}\n              </Button>\n            </div>\n\n            {/* Restore result message */}\n            {restoreResult && (\n              <div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-500/10 border border-green-500/30' : 'bg-red-500/10 border border-red-500/30'}`}>\n                <div className=\"flex items-start gap-2 text-sm\">\n                  {restoreResult.success ? (\n                    <CheckCircle className=\"w-4 h-4 text-green-400 mt-0.5 flex-shrink-0\" />\n                  ) : (\n                    <XCircle className=\"w-4 h-4 text-red-400 mt-0.5 flex-shrink-0\" />\n                  )}\n                  <div className={restoreResult.success ? 'text-green-200' : 'text-red-200'}>\n                    {restoreResult.message}\n                    {restoreResult.success && (\n                      <div className=\"mt-2\">\n                        <Button\n                          size=\"sm\"\n                          onClick={() => window.location.reload()}\n                        >\n                          <RotateCcw className=\"w-3 h-3\" />\n                          {t('backup.reloadNow')}\n                        </Button>\n                      </div>\n                    )}\n                  </div>\n                </div>\n              </div>\n            )}\n\n            {/* Warning */}\n            <div className=\"p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30\">\n              <div className=\"flex items-start gap-2 text-sm\">\n                <AlertTriangle className=\"w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0\" />\n                <div className=\"text-yellow-200\">\n                  <span className=\"font-medium\">{t('backup.restoreReplacesAll')}</span>{' '}\n                  <span className=\"text-yellow-200/70\">{t('backup.restoreReplacesAllDetail')}</span>\n                </div>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        {/* Scheduled Local Backups */}\n        <Card id=\"card-backup-scheduled\">\n          <CardHeader>\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                <FolderArchive className=\"w-5 h-5 text-gray-400\" />\n                <h2 className=\"text-lg font-semibold text-white\">{t('backup.scheduledBackup')}</h2>\n              </div>\n              <Toggle\n                checked={localBackupStatus?.enabled ?? false}\n                onChange={async (checked) => {\n                  try {\n                    await api.updateSettings({ local_backup_enabled: checked });\n                    showToast(t('backup.settingsSaved'));\n                  } catch (e) {\n                    showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');\n                  }\n                  refetchLocalStatus();\n                }}\n              />\n            </div>\n          </CardHeader>\n          <CardContent className=\"space-y-4\">\n            <p className=\"text-sm text-bambu-gray\">\n              {t('backup.scheduledBackupDescription')}\n            </p>\n\n            {localBackupStatus?.enabled && (\n              <>\n                {/* Schedule + Time + Retention */}\n                <div className=\"grid grid-cols-3 gap-4\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('backup.frequency')}</label>\n                    <select\n                      value={localBackupStatus?.schedule ?? 'daily'}\n                      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\"\n                      onChange={async (e) => {\n                        try {\n                          await api.updateSettings({ local_backup_schedule: e.target.value });\n                          showToast(t('backup.settingsSaved'));\n                        } catch (e) {\n                          showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');\n                        }\n                        refetchLocalStatus();\n                      }}\n                    >\n                      <option value=\"hourly\">{t('backup.hourly')}</option>\n                      <option value=\"daily\">{t('backup.daily')}</option>\n                      <option value=\"weekly\">{t('backup.weekly')}</option>\n                    </select>\n                  </div>\n                  {(localBackupStatus?.schedule ?? 'daily') !== 'hourly' && (\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">{t('backup.backupTime')}</label>\n                      <input\n                        type=\"time\"\n                        value={localBackupStatus?.time ?? '03:00'}\n                        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 [color-scheme:dark]\"\n                        onChange={async (e) => {\n                          try {\n                            await api.updateSettings({ local_backup_time: e.target.value });\n                            showToast(t('backup.settingsSaved'));\n                          } catch (err) {\n                            showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');\n                          }\n                          refetchLocalStatus();\n                        }}\n                      />\n                      <p className=\"text-xs text-bambu-gray-light mt-1\">{t('backup.utc')}</p>\n                    </div>\n                  )}\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('backup.retention')}</label>\n                    <input\n                      type=\"number\"\n                      min={1}\n                      max={100}\n                      value={localBackupStatus?.retention ?? 5}\n                      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\"\n                      onChange={async (e) => {\n                        const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));\n                        try {\n                          await api.updateSettings({ local_backup_retention: val });\n                          showToast(t('backup.settingsSaved'));\n                        } catch (e) {\n                          showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');\n                        }\n                        refetchLocalStatus();\n                      }}\n                    />\n                    <p className=\"text-xs text-bambu-gray-light mt-1\">{t('backup.retentionDescription')}</p>\n                  </div>\n                </div>\n\n                {/* Output Path */}\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">{t('backup.outputPath')}</label>\n                  <input\n                    type=\"text\"\n                    value={localBackupPath}\n                    onChange={(e) => setLocalBackupPath(e.target.value)}\n                    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\"\n                    onBlur={async () => {\n                      try {\n                        await api.updateSettings({ local_backup_path: localBackupPath });\n                        showToast(t('backup.settingsSaved'));\n                      } catch (err) {\n                        showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');\n                      }\n                      refetchLocalStatus();\n                      refetchLocalBackups();\n                    }}\n                    onKeyDown={(e) => {\n                      if (e.key === 'Enter') (e.target as HTMLInputElement).blur();\n                    }}\n                  />\n                  <p className=\"text-xs text-bambu-gray-light mt-1\">\n                    {localBackupPath\n                      ? t('backup.outputPathDescription')\n                      : <>{t('backup.defaultPathLabel')} <code className=\"text-bambu-gray\">{localBackupStatus?.default_path || '...'}</code></>\n                    }\n                  </p>\n                </div>\n\n                {/* Status + Run Now */}\n                <div className=\"flex items-center justify-between py-3 border-t border-bambu-dark-tertiary\">\n                  <div className=\"text-sm\">\n                    {localBackupStatus?.last_backup_at && (\n                      <div className=\"flex items-center gap-2 text-bambu-gray\">\n                        <span>{t('backup.lastBackup')}:</span>\n                        <StatusBadge status={localBackupStatus.last_status} />\n                        <span>{formatRelativeTime(localBackupStatus.last_backup_at)}</span>\n                      </div>\n                    )}\n                    {localBackupStatus?.next_run && (\n                      <div className=\"text-bambu-gray mt-1\">\n                        <span>{t('backup.nextBackup')}: </span>\n                        <span>{formatDateTime(localBackupStatus.next_run)}</span>\n                      </div>\n                    )}\n                  </div>\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    disabled={localBackupStatus?.is_running || triggerLocalBackupMutation.isPending}\n                    onClick={() => triggerLocalBackupMutation.mutate()}\n                  >\n                    {localBackupStatus?.is_running || triggerLocalBackupMutation.isPending ? (\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    ) : (\n                      <Play className=\"w-4 h-4\" />\n                    )}\n                    {localBackupStatus?.is_running ? t('backup.backupRunning') : t('backup.runNow')}\n                  </Button>\n                </div>\n\n                {/* Backup Files List */}\n                {localBackups && localBackups.length > 0 && (\n                  <div className=\"border-t border-bambu-dark-tertiary pt-3\">\n                    <h3 className=\"text-sm font-medium text-white mb-2\">{t('backup.backupFiles')}</h3>\n                    <div className=\"space-y-1\">\n                      {localBackups.map((file) => (\n                        <div key={file.filename} className=\"flex items-center justify-between py-1.5 px-2 rounded hover:bg-bambu-dark-tertiary/50 text-sm\">\n                          <div className=\"flex-1 min-w-0\">\n                            <span className=\"text-white truncate block\">{file.filename}</span>\n                            <span className=\"text-bambu-gray text-xs\">\n                              {(file.size / 1024 / 1024).toFixed(1)} MB &middot; {formatDateTime(file.created_at)}\n                            </span>\n                          </div>\n                          <div className=\"flex items-center gap-1 flex-shrink-0\">\n                            <button\n                              className=\"text-bambu-gray hover:text-bambu-green p-1\"\n                              title={t('backup.download')}\n                              onClick={async () => {\n                                try {\n                                  const { blob, filename: fname } = await api.downloadLocalBackup(file.filename);\n                                  const url = URL.createObjectURL(blob);\n                                  const a = document.createElement('a');\n                                  a.href = url;\n                                  a.download = fname;\n                                  a.click();\n                                  URL.revokeObjectURL(url);\n                                } catch {\n                                  showToast(t('backup.scheduledBackupFailed'), 'error');\n                                }\n                              }}\n                            >\n                              <Download className=\"w-3.5 h-3.5\" />\n                            </button>\n                            <button\n                              className=\"text-bambu-gray hover:text-yellow-400 p-1\"\n                              title={t('backup.restore')}\n                              onClick={() => setRestoreConfirmFile(file.filename)}\n                            >\n                              <RotateCcw className=\"w-3.5 h-3.5\" />\n                            </button>\n                            <button\n                              className=\"text-bambu-gray hover:text-red-400 p-1\"\n                              onClick={() => setDeleteConfirmFile(file.filename)}\n                              title={t('backup.deleteBackup')}\n                            >\n                              <Trash2 className=\"w-3.5 h-3.5\" />\n                            </button>\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n                )}\n                {localBackups && localBackups.length === 0 && (\n                  <p className=\"text-sm text-bambu-gray text-center py-3 border-t border-bambu-dark-tertiary\">\n                    {t('backup.noScheduledBackups')}\n                  </p>\n                )}\n              </>\n            )}\n          </CardContent>\n        </Card>\n      </div>\n\n      {/* Delete Backup Confirmation Modal */}\n      {deleteConfirmFile && (\n        <ConfirmModal\n          title={t('backup.deleteBackup')}\n          message={t('backup.deleteBackupConfirm')}\n          confirmText={t('backup.deleteBackup')}\n          variant=\"danger\"\n          onConfirm={() => deleteLocalBackupMutation.mutate(deleteConfirmFile)}\n          onCancel={() => setDeleteConfirmFile(null)}\n        />\n      )}\n\n      {/* Restore from Scheduled Backup Confirmation Modal */}\n      {restoreConfirmFile && (\n        <ConfirmModal\n          title={t('backup.restoreConfirmTitle')}\n          message={t('backup.restoreConfirmMessage', { filename: restoreConfirmFile })}\n          confirmText={t('backup.restoreConfirmButton')}\n          variant=\"danger\"\n          onConfirm={() => restoreLocalBackupMutation.mutate(restoreConfirmFile)}\n          onCancel={() => setRestoreConfirmFile(null)}\n        />\n      )}\n\n      {/* Restore Confirmation Modal */}\n      {showRestoreConfirm && restoreFile && (\n        <ConfirmModal\n          title={t('backup.restoreConfirmTitle')}\n          message={t('backup.restoreConfirmMessage', { filename: restoreFile.name })}\n          confirmText={t('backup.restoreConfirmButton')}\n          variant=\"danger\"\n          onConfirm={async () => {\n            setShowRestoreConfirm(false);\n            setIsRestoring(true);\n            setRestoreResult(null);\n            try {\n              setOperationStatus(t('backup.uploadingFile'));\n              const result = await api.importBackup(restoreFile);\n              setRestoreResult(result);\n              if (result.success) {\n                showToast(t('backup.backupRestoredRestart'), 'success');\n              } else {\n                showToast(result.message, 'error');\n              }\n            } catch (e) {\n              const message = e instanceof Error ? e.message : t('backup.failedToRestore');\n              setRestoreResult({ success: false, message });\n              showToast(message, 'error');\n            } finally {\n              setIsRestoring(false);\n              setOperationStatus('');\n              setRestoreFile(null);\n            }\n          }}\n          onCancel={() => {\n            setShowRestoreConfirm(false);\n            setRestoreFile(null);\n          }}\n        />\n      )}\n\n      {/* Blocking overlay during backup/restore operations */}\n      {(isExporting || isRestoring) && (\n        <div className=\"fixed inset-0 bg-black/80 flex items-center justify-center z-[100]\">\n          <div className=\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center\">\n            <div className=\"flex justify-center mb-4\">\n              <div className=\"relative\">\n                <div className=\"w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full\"></div>\n                <div className=\"w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin\"></div>\n              </div>\n            </div>\n            <h3 className=\"text-xl font-semibold text-white mb-2\">\n              {isExporting ? t('backup.creatingBackup') : t('backup.restoringBackup')}\n            </h3>\n            <p className=\"text-bambu-gray mb-4\">\n              {operationStatus || (isExporting ? t('backup.preparing') : t('backup.processing'))}\n            </p>\n            <div className=\"p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30\">\n              <div className=\"flex items-start gap-2 text-sm\">\n                <AlertTriangle className=\"w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0\" />\n                <p className=\"text-yellow-200 text-left\">\n                  {t('backup.doNotClosePage')}\n                </p>\n              </div>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/HMSErrorModal.tsx",
    "content": "// HMS Error Modal - Comprehensive error code database\n// Source: https://github.com/greghesp/ha-bambulab\nimport { useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useMutation } from '@tanstack/react-query';\nimport { X, AlertTriangle, AlertCircle, Info, ExternalLink, Loader2, Trash2 } from 'lucide-react';\nimport type { HMSError, Permission } from '../api/client';\nimport { api } from '../api/client';\nimport { useToast } from '../contexts/ToastContext';\n\ninterface HMSErrorModalProps {\n  printerName: string;\n  errors: HMSError[];\n  onClose: () => void;\n  printerId: number;\n  hasPermission: (permission: Permission) => boolean;\n}\n\n// Comprehensive error code database (short format: XXXX_YYYY)\n// Auto-generated from ha-bambulab - 853 codes\nconst ERROR_DESCRIPTIONS: Record<string, string> = {\n  '0300_4000': 'Z axis homing failed; the task has been stopped.',\n  '0300_4001': 'The printer timed out waiting for the nozzle to cool down before homing.',\n  '0300_4002': 'Auto Bed Leveling failed; the task has been stopped.',\n  '0300_4005': 'The hotend cooling fan speed is abnormal.',\n  '0300_4006': 'The nozzle is clogged.',\n  '0300_4008': 'The AMS failed to change filament.',\n  '0300_4009': 'Homing XY axis failed.',\n  '0300_400A': 'Mechanical resonance frequency identification failed.',\n  '0300_400B': 'Internal communication exception',\n  '0300_400C': 'The task was canceled.',\n  '0300_400D': 'Resume failed after power loss.',\n  '0300_400E': 'The motor self-check failed.',\n  '0300_400F': 'The power supply voltage does not match the printer.',\n  '0300_4010': 'Nozzle offset calibration failed.',\n  '0300_4011': 'Flow Dynamics Calibration failed; please reinitiate printing or calibration.',\n  '0300_4013': 'Printing cannot be initiated while AMS is drying.',\n  '0300_4014': 'Homing Z axis failed: temperature control abnormality.',\n  '0300_4015': 'Nozzle clumping detection calibration failed. Please go to \\'Assistant\\' for troubleshooting.',\n  '0300_4016': 'Nozzle cleaning failed. Please click the Assistant for troubleshooting.',\n  '0300_401F': 'The hotend is not installed, and the toolhead cannot perform homing. Please install the hotend and then continue.',\n  '0300_4020': 'The nozzle presence detection failed. Please check the Assistant for details.',\n  '0300_4021': 'Nozzle offset calibration sensor signal abnormality detected. Please check the sensor and retry.',\n  '0300_4042': 'The Laser Safety Window is not properly installed. The task has been stopped.',\n  '0300_4044': 'The Flame Sensor is abnormal. The sensor may be short-circuited. Please troubleshoot the issue before starting a print job.',\n  '0300_404B': 'Task aborted because the front door or top cover is open.',\n  '0300_404D': 'The current temperature of the hotend, heatbed, or chamber is too high. Please wait for it to cool down to room temperature before restarting the task.',\n  '0300_4050': 'Liveview Camera calibration timeout; please restart the printer.',\n  '0300_4052': 'Blade Z-axis homing failed',\n  '0300_4057': 'Z-axis step loss detected. The task has stopped. Please check if there are any obstructions beneath the heatbed.',\n  '0300_4066': 'Calibration of motion precision failed.',\n  '0300_4067': 'Calibration result is over the threshold.',\n  '0300_4068': 'Step loss occurred during the motion accuracy enhancement process. Please try again.',\n  '0300_8000': 'Printing was paused for unknown reason. You can select \\'Resume\\' to resume the print job.',\n  '0300_8001': 'Printing was paused by the user. You can select \\'Resume\\' to continue printing.',\n  '0300_8002': 'First layer defects were detected by the Micro Lidar. Please check the quality of the printed model before continuing your print.',\n  '0300_8003': 'Spaghetti defects were detected by the AI Print Monitoring. Please check the quality of the printed model before continuing your print.',\n  '0300_8004': 'Filament ran out. Please load new filament.',\n  '0300_8005': 'Toolhead front cover fell off. Please remount the front cover and check to make sure your print is going okay.',\n  '0300_8006': 'The build plate marker was not detected. Please confirm the build plate is correctly positioned on the heatbed with all four corners aligned, and the marker is visible.',\n  '0300_8007': 'There was an unfinished print job when the printer lost power. If the model is still adhered to the build plate, you can try resuming the print job.',\n  '0300_8008': 'Nozzle temperature malfunction',\n  '0300_8009': 'Heatbed temperature malfunction',\n  '0300_800A': 'A Filament pile-up was detected by AI Print Monitoring. Please clean filament from the waste chute.',\n  '0300_800B': 'The cutter is stuck. Please make sure the cutter handle is out and check the filament sensor cable connection.',\n  '0300_800C': 'Skipped step detected: auto-recover complete; please resume print and check if there are any layer shift problems.',\n  '0300_800D': 'Detected that the extruder is not extruding normally. If the defects are acceptable, select \\'Resume\\' to resume the print job.',\n  '0300_800E': 'The print file is not available. Please check to see if the storage media has been removed.',\n  '0300_800F': 'The door seems to be open, so printing was paused.',\n  '0300_8010': 'The hotend cooling fan speed is abnormal.',\n  '0300_8011': 'Detected build plate is not the same as the Gcode file. Please adjust slicer settings or use the correct plate.',\n  '0300_8013': 'Printing paused due to the pause command added to the printing file.',\n  '0300_8014': 'The nozzle is covered with filament, or the build plate is installed incorrectly. Please cancel this print and clean the nozzle or adjust the build plate according to the actual status. You can als...',\n  '0300_8015': 'The filament on external spool has run out; please load new filament. If the filament is loaded, please select \\'Resume\\'.',\n  '0300_8016': 'The nozzle is clogged with filament. Please cancel this print and clean the nozzle or select \\'Resume\\' to resume the print job.',\n  '0300_8017': 'Foreign objects detected on heatbed. Please check and clean the heatbed. Then, select \\'Resume\\' to resume the print job.',\n  '0300_8018': 'Chamber temperature malfunction.',\n  '0300_8019': 'No build plate is placed.',\n  '0300_801A': 'Filament extrusion error; please check the assistant for troubleshooting. After resolving the issue, decide whether to cancel or resume the print job based on the actual print status.',\n  '0300_801B': 'Nozzle temperature problem detected. Refer to Assistant to re-connect the hotend connector. POWER OFF the printer before this operation to avoid short circuits.',\n  '0300_801C': 'The extrusion resistance is abnormal. The extruder may be clogged; please refer to the assistant. After trouble shooting, you can select \\'Resume\\' to resume the print job.',\n  '0300_801D': 'The extruder servo motor position sensor is malfunctioning. Please power off the printer first and check if the connection cable is loose.',\n  '0300_801E': 'The extrusion motor is overloaded, please check the Assistant for details.',\n  '0300_8021': 'The nozzle may not be installed or not properly installed. Please ensure the nozzle is correctly installed before proceeding.',\n  '0300_8022': 'The heatbed may be obstructed while moving downward. Please clear any objects beneath the heatbed and check for any resistance or jamming during its movement.',\n  '0300_8028': 'Nozzle offset calibration sensor error. If using a single hotend or the calibration function is disabled, you may ignore this and continue printing; otherwise, it is recommended to check the sensor...',\n  '0300_8041': 'Platform detection timeout: please restart the printer.',\n  '0300_8042': 'Task paused because the door is open.',\n  '0300_8043': 'The laser module is abnormal.',\n  '0300_8044': 'Fire was detected inside the chamber.',\n  '0300_8045': 'Material detection timeout: please restart the printer.',\n  '0300_8046': 'Foreign object detect timeout: please restart the printer.',\n  '0300_8047': 'Quick-release lever detection time out: please restart the printer.',\n  '0300_8048': 'Laser Module unlock has timed out, and the task cannot proceed. Please restart the printer and try again.',\n  '0300_8049': 'The current plate is invalid.',\n  '0300_804A': 'Emergency stop button improperly installed. Please reinstall according to the Wiki before proceeding.',\n  '0300_804B': 'Task paused. The Laser Safety Window is open.',\n  '0300_804E': 'This is a printing task. Please detach the Laser/Cutting Module from the Toolhead.',\n  '0300_804F': 'The loading/unloading process is currently ongoing. Please stop the process or remove the laser/cutting module.',\n  '0300_8050': 'This device does not support the 40W Laser Module. Please remove it or replace it with a 10W Laser Module.',\n  '0300_8051': 'The cutting module has dropped or the cutting module cable is disconnected; please check the module.',\n  '0300_8053': 'Laser module detected. Please install the right nozzle correctly to ensure proper Laser Module Mounting Calibration.',\n  '0300_8054': 'Please place the paper required for Print Then Cut.',\n  '0300_8055': 'The module mounted on the toolhead does not match the task. Please install the correct module.',\n  '0300_8057': 'The rotary attachment is disconnected. Please ensure it is properly installed and the cable is securely plugged in.',\n  '0300_8058': 'The rotary attachment is detected. Please remove it before continuing.',\n  '0300_8061': 'The mode of Airflow System failed to activate; check the air door condition.',\n  '0300_8062': 'The chamber temperature is too high. It may be due to high environmental temperature.',\n  '0300_8063': 'The chamber temperature is too high. Please open the top cover and front door to cool down.',\n  '0300_8064': 'The chamber temperature is too high. Please open the top cover and front door to cool down. (Open door detection for this print job will be set to \\'Notification\\' level)',\n  '0300_8065': 'The temperature of the MC module is too high. Please check the Wiki for possible explanations.',\n  '0300_8071': 'The Toolhead Enhanced Cooling Fan module is malfunctioning.',\n  '0300_807D': 'Fire Extinguisher not detected, the automatic extinguishing function will be unavailable.',\n  '0300_807E': 'Fire Extinguisher not detected, the automatic extinguishing function will be unavailable.',\n  '0300_807F': 'Fire Extinguisher is malfunctioning.',\n  '0300_8080': 'Fire extinguisher motor reset failed.',\n  '0300_8081': 'Fire extinguisher cylinder not installed. Please confirm on the extinguisher page.',\n  '0300_8082': 'The Fire Extinguisher Gas Cylinder is empty.',\n  '0300_C012': 'Please heat the nozzle to above 170°C.',\n  '0300_C056': 'A minor fire was detected inside the chamber, and the Auto Fire Extinguishing process has been aborted.',\n  '0300_C070': 'The fire extinguisher has been detected and is ready for use after the laser module is connected.',\n  '0500_4001': 'Failed to connect to Bambu Cloud. Please check your network connection.',\n  '0500_4002': 'Unsupported print file path or name. Please resend the print job.',\n  '0500_4003': 'Printing stopped because the printer was unable to parse the file. Please resend your print job.',\n  '0500_4004': 'Device is busy and cannot start new task. Please wait for current task to complete before sending new task.',\n  '0500_4005': 'Print jobs are not allowed to be sent while updating firmware.',\n  '0500_4006': 'There is not enough free storage space for the print job. Restoring to factory settings can free up available space.',\n  '0500_4007': 'The device requires a repair upgrade, and printing is currently unavailable.',\n  '0500_4008': 'Starting printing failed; please power cycle the printer and resend the print job.',\n  '0500_4009': 'Print jobs are not allowed to be sent while updating logs.',\n  '0500_400A': 'The file name is not supported. Please rename and restart the print job.',\n  '0500_400B': 'There was a problem downloading a file. Please check your network connection and resend the print job.',\n  '0500_400C': 'Please insert a MicroSD card and restart the print job.',\n  '0500_400D': 'Please run a self-test and restart the print job.',\n  '0500_400E': 'Printing was cancelled.',\n  '0500_400F': 'AMS is initializing and cannot be upgraded at the moment. Please try again later.',\n  '0500_4010': 'AMS is drying and cannot be upgraded at the moment. Please try again later.',\n  '0500_4011': 'The printer is loading or unloading filament and cannot be upgraded at the moment. Please try again later.',\n  '0500_4012': 'The device is printing and cannot be upgraded at the moment. Please try again later.',\n  '0500_4013': 'AMS is in operation and cannot be upgraded at the moment. Please try again when it is idle.',\n  '0500_4014': 'Slicing for the print job failed. Please check your settings and restart the print job.',\n  '0500_4015': 'There is not enough free storage space for the print job. Please format or clear files from the MicroSD card to free up space.',\n  '0500_4016': 'The MicroSD Card is write-protected. Please replace the MicroSD Card.',\n  '0500_4017': 'Binding failed. Please retry or restart the printer and retry.',\n  '0500_4018': 'Binding configuration information parsing failed; please try again.',\n  '0500_4019': 'The printer has already been bound. Please unbind it and try again.',\n  '0500_401A': 'Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...',\n  '0500_401B': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_401C': 'Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_401D': 'Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.',\n  '0500_401E': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_401F': 'Authorization timed out. Please make sure that your phone or PC has access to the internet, and ensure that the Bambu Studio/Bambu Handy APP is running in the foreground during the binding operation.',\n  '0500_4020': 'Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_4021': 'Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.',\n  '0500_4022': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_4023': 'Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_4024': 'Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...',\n  '0500_4025': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_4026': 'Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_4027': 'Cloud access failed; this may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.',\n  '0500_4028': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_4029': 'Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0500_402A': 'Failed to connect to the router, which may be caused by wireless interference or being too far away from the router. Please try again or move the printer closer to the router and try again.',\n  '0500_402B': 'Router connection failed due to incorrect password. Please check the password and try again.',\n  '0500_402C': 'Failed to obtain IP address, which may be caused by wireless interference resulting in data transmission failure or the DHCP address pool of the router being full. Please move the printer closer to...',\n  '0500_402D': 'System exception',\n  '0500_402E': 'System does not support the file system currently used by the USB flash drive. Please replace or format the USB flash drive to FAT32.',\n  '0500_402F': 'The MicroSD card sector data is damaged. Please use the SD card repair tool to repair or format it. If it still cannot be identified, please replace the MicroSD card.',\n  '0500_4030': 'The device is currently upgrading. Please try again when it is idle.',\n  '0500_4031': 'The accessory firmware does not match the printer. Please update it on the \\'Firmware\\' page.',\n  '0500_4033': 'The AMS firmware does not match the printer. Please update it on the \\'Firmware\\' page.',\n  '0500_4034': 'The Laser Module firmware does not match the printer. Please update it on the \\'Firmware\\' page.',\n  '0500_4035': 'The BirdsEye Camera is malfunctioning. Please try restarting the device. If the issue persists after multiple restarts, check the camera connection status or contact customer support.',\n  '0500_4037': 'Your sliced file is not compatible with current printer model. This file can\\'t be printed on this printer.',\n  '0500_4038': 'The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can\\'t be printed.',\n  '0500_4039': 'The current task does not allow the installation of the laser/cutting module, and the task has been halted.',\n  '0500_403A': 'The current temperature is too low. In order to protect you and your printer, printing tasks, moving an axis and other operations are disabled. Please move the printer to an environment above 10 de...',\n  '0500_403B': 'Laser/cutting tasks cannot be initiated on the machine at the moment. Please use the computer software to start the task.',\n  '0500_403C': 'The current nozzle setting does not match the slicing file. Continuing to print may affect print quality. It is recommended to re-slice before starting the print.',\n  '0500_403D': 'The toolhead module is not set up. Please set it up before initiating the task.',\n  '0500_403E': 'The current tool head does not support initialization.',\n  '0500_403F': 'Failed to download print job; please check your network connection.',\n  '0500_4040': 'The printer has reached its power limit. Please connect a dedicated power adapter to this AMS to enable drying.',\n  '0500_4041': 'The AMS drying cannot be started during printing.',\n  '0500_4042': 'Due to power limitations, starting AMS drying will pause current operations such as nozzle heating and fan running. Do you want to proceed with drying?',\n  '0500_4043': 'Due to power limitations, only one AMS is allowed to use the device\\'s power for drying.',\n  '0500_4044': 'BirdsEye Camera malfunction: please contact customer support.',\n  '0500_4045': 'Hotend check in progress. This operation is temporarily unavailable. Please wait.',\n  '0500_4050': 'Error detected on the print board.',\n  '0500_4052': 'Error detected on the hot end.',\n  '0500_4054': 'Error detected on the mat.',\n  '0500_405D': 'Laser module Serial Number error: unable to calibrate or make project.',\n  '0500_4065': 'The task requires a Laser Platform, but the current one is a Cutting Platform. Please replace it, measure the material thickness in the software, and then restart the task.',\n  '0500_4070': 'The laser or cutter module is connected, so the device cannot initiate a 3D printing task.',\n  '0500_4075': 'No Laser Platform was detected, which may affect thickness measurement accuracy. Please place the laser platform correctly and ensure the rear markers are not blocked, then restart the thickness me...',\n  '0500_4076': 'Please place the Laser Platform correctly and ensure the rear markers are not blocked, then restart the thickness measurement in the software before initiating the task.',\n  '0500_4097': 'The device cannot detect the Laser Module. Please reconnect the module cable or restart the printer.',\n  '0500_4098': 'The device cannot detect AMS A. Please reconnect the AMS cable or restart the printer.',\n  '0500_4099': 'The firmware of Cutting Module does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0500_409A': 'The firmware of the Air Pump does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0500_409B': 'The firmware of the Laser Module does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0500_409D': 'The firmware of AMS A does not match the printer; the device cannot continue working. Please upgrade it on the \\'Firmware\\' page.',\n  '0500_409E': 'The device cannot detect the Cutting Module. Please reconnect the module cable or restart the printer.',\n  '0500_409F': 'The device cannot detect the Air Pump.  Please reconnect the module cable or restart the printer.',\n  '0500_40A0': 'The Rotary Attachment module is not detected. Please reconnect the cable or restart the printer.',\n  '0500_40A1': 'The Auto Fire Extinguishing System is not detected.  Please reconnect the module cable or restart the printer.',\n  '0500_40A3': 'AMS(or AMS lite) A communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0500_40A4': 'The current firmware only supports 1 AMS Lite. Please remove all AMS units before reconnecting the supported AMS Lite device.',\n  '0500_40A5': 'The current firmware only supports AMS/AMS 2 Pro/AMS HT, with a maximum of 4 units. Please remove all AMS units before reconnecting the supported one.',\n  '0500_8013': 'The print file is not available. Please check to see if the storage media has been removed.',\n  '0500_8036': 'Your sliced file is not consistent with the current printer model. Continue?',\n  '0500_803C': 'The current nozzle setting does not match the slicing file. Continuing to print may affect print quality. It is recommended to re-slice before starting the print.',\n  '0500_8040': 'Toolhead front cover is detached. Moving the toolhead may damage the printer. Do you want to continue?',\n  '0500_8041': 'The filament in hotend is too cold. Extrusion may damage the extruder. Still feeding in/out the filament?',\n  '0500_8048': 'The module on the toolhead is not calibrated. Please cancel the task to perform calibration or switch to a calibrated module.',\n  '0500_8051': 'Detected build plate is not the same as the Gcode file. Please adjust slicer settings or use the correct plate.',\n  '0500_8053': 'Nozzle mismatch was detected during printing. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.',\n  '0500_8055': 'Laser module is installed, but a Cutting Platform is detected. Please place a Laser Platform and perform laser calibration.',\n  '0500_8056': 'Cutting module is installed, but the laser platform is detected. Please place the cutting platform for calibration.',\n  '0500_8058': 'Please place the light grip cutting mat correctly and ensure the marker is exposed.',\n  '0500_8059': 'Cutting platform base is not correctly aligned. Please ensure that the four corners of the platform are aligned with the heatbed.',\n  '0500_805A': 'Please place the cutting mat on cutting protection base.',\n  '0500_805B': 'The cutting mat type is unknown; please replace it with the correct cutting mat.',\n  '0500_805C': 'The grip cutting mat type does not match; please place a LightGrip cutting mat.',\n  '0500_805E': 'Cutting module Serial Number error: unable to calibrate or make project.',\n  '0500_8060': 'The current module on toolhead does not meet requirements. Please replace the module as per the on-screen instructions.',\n  '0500_8061': 'No print plate detected. Please make sure it is placed correctly.',\n  '0500_8062': 'The print plate marker was not detected. Please confirm the print plate is correctly positioned on the heatbed with all four corners aligned, and the marker is visible. If strong light is shining o...',\n  '0500_8063': 'The platform is not detected during calibration; please make sure the Laser Platform is properly placed.',\n  '0500_8064': 'Please place the Laser Platform correctly and ensure the rear markers are not blocked for laser calibration.',\n  '0500_8066': 'The task requires a Cutting Platform, but the current one is a Laser Platform. Please replace it with a Cutting Platform (Cutting Protection Base + LightGrip cutting mat).',\n  '0500_8067': 'Please place a LightGrip cutting mat on the cutting protection base.',\n  '0500_8068': 'Please place the strong grip cutting mat correctly and ensure the marker is exposed.',\n  '0500_8069': 'Unable to recognize the left and right hotends. They might be third party hotends, or the hotend marks may be dirty. Please manually set the hotend types.',\n  '0500_806A': 'Unable to recognize the left and right hotends. They might be third party hotends, or the hotend marks may be dirty. Please set hotend types on printer screen before next print.',\n  '0500_806B': 'Quick-release Lever is not locked. Please press down the external toolhead module to ensure it is properly seated, then push down the level to lock it in place.',\n  '0500_806C': 'Please place the cutting platform correctly and ensure the marker is exposed.',\n  '0500_806D': 'Material not detected. Please confirm placement and continue.',\n  '0500_806E': 'Foreign objects detected on heatbed; please check and clean up the heatbed.',\n  '0500_806F': 'The grip cutting mat type does not match; please place a StrongGrip cutting mat.',\n  '0500_8071': 'No cutting platform was detected. Please confirm that it has been correctly placed.',\n  '0500_8072': 'Live View camera is blocked',\n  '0500_8073': 'Heatbed limit block is obstructed or contaminated. Please clean and ensure the limit block is visible, otherwise platform position offset detection may be inaccurate.',\n  '0500_8074': 'The Laser Platform is offset. Please ensure that the four corners of the platform are aligned with the heatbed, and the marker is not obstructed.',\n  '0500_8077': 'The visual marker was not detected. Please ensure the paper is properly placed.',\n  '0500_8078': 'Current material does not match the sliced file settings. Please load the correct material and ensure the QR code on the material is not damaged or dirty.',\n  '0500_8079': 'Please place the Laser Test Material (350g paperboard) and position support strips underneath to prevent material warping.',\n  '0500_807A': 'The foreign object detection function is not working. You can continue the task or check the assistant for troubleshooting.',\n  '0500_807B': 'Please place the cutting platform (cutting protection base + LightGrip cutting mat).',\n  '0500_807C': 'Please place the cutting platform (cutting protection base + StrongGrip cutting mat).',\n  '0500_807D': 'This task requires a Cutting Platform, but the current one is a Laser Platform. Please replace it with a Cutting Platform (Cutting Protection Base + StrongGrip Cutting Mat).',\n  '0500_807E': 'Please place a StrongGrip cutting mat on the cutting protection base.',\n  '0500_8080': 'The left and right hotends are not installed.',\n  '0500_8081': 'The left and right hotends are not installed.',\n  '0500_8082': 'Please remove the protective film on the Opaque Glossy Acrylic before processing',\n  '0500_8083': 'Material is not allowed in Mounting Calibration. Please remove the material from the platform.',\n  '0500_8084': 'The Live View Camera is dirty; please clean it and continue.',\n  '0500_8085': 'Toolhead camera is obstructed',\n  '0500_8086': 'Toolhead Camera is dirty, which affects the AI function; please clean the lens surface.',\n  '0500_8087': 'BirdsEye camera is obstructed',\n  '0500_8088': 'The Birdseye Camera is dirty',\n  '0500_8089': 'Task paused due to Presence Check failed. Please check the printer to continue.',\n  '0500_808A': 'The BirdsEye Camera is installed offset. Please refer to the assistant to reinstall it.',\n  '0500_808B': 'The BirdsEye Camera setup failed. Please remove all objects and the mat on the heatbed to ensure the heatbed markers are visible. Meanwhile, please ensure the BirdsEye Camera is installed correctly...',\n  '0500_808C': 'Detected build plate offset. Please align the build plate with the heatbed, and then continue.',\n  '0500_808D': 'The Cutting Module offset calibration failed, which may result in inaccurate cuts. Please ensure the cutting material is properly positioned and check whether the cutting blade tip is worn.',\n  '0500_808E': 'BirdsEye Camera initialization failed. The toolhead camera did not detect the Heatbed features. Please clean the Heatbed, remove all objects and pads, and ensure the bed markings are visible. Check...',\n  '0500_808F': 'Nozzle camera lens is dirty, affecting AI monitoring. Clean the lens with a non-woven cloth and a small amount of alcohol. Beware of hotend heat; wait for it to cool before handling.',\n  '0500_8090': 'Please attach the 80g White Printing Paper to the center area of the platform.',\n  '0500_8091': 'The Cutting Module offset calibration failed, which may result in inaccurate cuts. Please ensure the 80g white printer paper(letter paper thickness) is properly positioned and check whether the cut...',\n  '0500_8092': 'Toolhead Camera initialization failed. This print can still continue, but some AI functions will be disabled. If you encounter this issue again after restarting, please contact customer support.',\n  '0500_8093': 'The nozzle silicone sleeve is not installed; there is a risk of temperature control failure. Please install it correctly and try again.',\n  '0500_80A0': 'The visual encoder board was not detected. Please check if the board is properly placed and aligned at all four corners, and ensure the positioning markings are clear and free from wear.',\n  '0500_C010': 'MicroSD Card read/write exception: please reinsert or replace the MicroSD Card.',\n  '0500_C032': 'Laser/Cutting module connected to the toolhead. The drying process has been automatically stopped.',\n  '0500_C036': 'This is a printing task. Please detach the Laser/Cutting Module from the Toolhead.',\n  '0500_C07F': 'Device is busy and cannot perform this operation. To proceed, please pause or stop the current task.',\n  '0501_4017': 'Binding failed. Please retry or restart the printer and retry.',\n  '0501_4018': 'Binding configuration information parsing failed; please try again.',\n  '0501_4019': 'The printer has already been bound. Please unbind it and try again.',\n  '0501_401A': 'Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...',\n  '0501_401B': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_401C': 'Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_401D': 'Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.',\n  '0501_401E': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_401F': 'Authorization timed out. Please make sure that your phone or PC has access to the internet, and ensure that the Bambu Studio/Bambu Handy APP is running in the foreground during the binding operation.',\n  '0501_4020': 'Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_4021': 'Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.',\n  '0501_4022': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_4023': 'Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_4024': 'Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...',\n  '0501_4025': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_4026': 'Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_4027': 'Cloud access failed; this may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.',\n  '0501_4028': 'Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_4029': 'Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.',\n  '0501_4031': 'Device discovery binding is in progress, and the QR code cannot be displayed on the screen. You can wait for the binding to finish or abort the device discovery binding process in the APP/Studio an...',\n  '0501_4032': 'QR code binding is in progress, so device discovery binding cannot be performed. You can scan the QR code on the screen for binding or exit the QR code display page on screen and try device discove...',\n  '0501_4033': 'Your APP region does not match with your printer; please download the APP in the corresponding region and register your account again.',\n  '0501_4034': 'The slicing progress has not been updated for a long time, and the printing task has exited. Please confirm the parameters and reinitiate printing.',\n  '0501_4035': 'The device is in the process of binding and cannot respond to new binding requests.',\n  '0501_4038': 'The regional settings do not match the printer; please check the printer\\'s regional settings.',\n  '0501_4039': 'Device login has expired; please try to bind again.',\n  '0501_4098': 'The device cannot detect AMS B. Please reconnect the AMS cable or restart the printer.',\n  '0501_409D': 'The firmware of AMS B does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0501_40A3': 'AMS(or AMS lite) B communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0502_4001': 'Current filament will be used in this print job. Settings cannot be changed.',\n  '0502_4002': 'Please go to “Settings > Calibration” to run the Motion Accuracy Enhancement Calibration before turning on Motion Accuracy Enhancement mode.',\n  '0502_4003': 'The printer is currently printing and the motion accuracy enhancement feature cannot be turned on or off.',\n  '0502_4004': 'Some features are not supported by the current device. Please check the Studio feature settings or update the firmware to the latest version.',\n  '0502_4005': 'The AMS has not been calibrated yet, so printing cannot be initiated.',\n  '0502_4006': 'Unknown module detected; please try updating the firmware to the latest version.',\n  '0502_400D': 'Failed to start a new task: filament loading/unloading not completed.',\n  '0502_400E': 'Failed to start a new task: The nozzle cold pull was not completed.',\n  '0502_4013': 'This device is not compatible with the 40W laser module. Please replace it with a 10W laser module or remove it.',\n  '0502_4098': 'The device cannot detect AMS C. Please reconnect the AMS cable or restart the printer.',\n  '0502_409D': 'The firmware of AMS C does not match the printer; the device cannot continue working. Please upgrade it on the \\'Firmware\\' page.',\n  '0502_40A3': 'AMS(or AMS lite) C communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0502_C00F': 'The device is busy and cannot perform nozzle identification.',\n  '0502_C010': 'Due to printer power limitations, printing, calibration, controls and other actions cannot be performed during AMS drying. Please stop the drying process before proceeding with any other operation.',\n  '0502_C011': 'Currently in 2D production mode. Please continue the operation on the printer',\n  '0502_C012': 'The task cannot be paused.',\n  '0502_C014': 'The AMS Remaining Filament Estimation is enabled by default and cannot be disabled.',\n  '0502_C024': 'The flow dynamic calibration records have exceeded the storage limit. Please delete some historical records in the slicer software before adding new calibration data.',\n  '0503_4098': 'The device cannot detect AMS D. Please reconnect the AMS cable or restart the printer.',\n  '0503_409D': 'The firmware of AMS D does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0503_40A3': 'AMS(or AMS lite) D communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0580_4096': 'The device cannot detect AMS-HT A. Please reconnect the AMS-HT cable or restart the printer.',\n  '0580_409C': 'The firmware of AMS-HT A does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0580_40A2': 'AMS-HT A communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0581_4096': 'The device cannot detect AMS-HT B. Please reconnect the AMS-HT cable or restart the printer.',\n  '0581_409C': 'The firmware of AMS-HT B does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0581_40A2': 'AMS-HT B communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0582_4096': 'The device cannot detect AMS-HT C. Please reconnect the AMS-HT cable or restart the printer.',\n  '0582_409C': 'The firmware of AMS-HT C does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0582_40A2': 'AMS-HT C communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0583_4096': 'The device cannot detect AMS-HT D. Please reconnect the AMS-HT cable or restart the printer.',\n  '0583_409C': 'The firmware of AMS-HT D does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0583_40A2': 'AMS-HT D communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0584_4096': 'The device cannot detect AMS-HT F. Please reconnect the AMS-HT cable or restart the printer.',\n  '0584_409C': 'The firmware of AMS-HT E does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0584_40A2': 'AMS-HT E communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0585_4096': 'The device cannot detect AMS-HT E. Please reconnect the AMS-HT cable or restart the printer.',\n  '0585_409C': 'The firmware of AMS-HT F does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0585_40A2': 'AMS-HT F communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0586_4096': 'The device cannot detect AMS-HT G. Please reconnect the AMS-HT cable or restart the printer.',\n  '0586_409C': 'The firmware of AMS-HT G does not match the printer; the device cannot continue working. Please update it on the \\'Firmware\\' page.',\n  '0586_40A2': 'AMS-HT G communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '0587_4096': 'The device cannot detect AMS-HT H. Please reconnect the AMS-HT cable or restart the printer.',\n  '0587_409C': 'The firmware of AMS-HT H does not match the printer; the device cannot continue working. Please upgrade it on the \\'Firmware\\' page.',\n  '0587_40A2': 'AMS-HT H communication is abnormal. Please reconnect the module cable or restart the printer.',\n  '05FE_8053': 'The left nozzle is not matched with slicing file. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.',\n  '05FE_8069': 'Unable to recognize the left hotend. It might be a third party hotend, or the hotend mark may be dirty. Please manually set the hotend type.',\n  '05FE_806A': 'Unable to recognize the left hotend. It might be a third party hotend, or the hotend mark may be dirty. Please set hotend type on printer screen before next print.',\n  '05FE_8080': 'The left hotend is not installed.',\n  '05FE_8081': 'The left hotend is not installed.',\n  '05FF_8053': 'The right nozzle is not matched with slicing file. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.',\n  '05FF_8069': 'Unable to recognize the right hotend. It might be a third party hotend, or the hotend mark may be dirty. Please manually set the hotend type.',\n  '05FF_806A': 'Unable to recognize the right hotend. It might be a third party hotend, or the hotend mark may be dirty. Please set hotend type on printer screen before next print.',\n  '05FF_8080': 'The right hotend is not installed.',\n  '05FF_8081': 'The right hotend is not installed.',\n  '0700_4001': 'The AMS has been disabled for a print, but it still has filament loaded. Please unload the AMS filament and switch to the spool holder filament for printing.',\n  '0700_4025': 'Failed to read the filament information.',\n  '0700_8001': 'Failed to cut the filament. Please check the cutter.',\n  '0700_8002': 'The cutter is stuck. Please make sure the cutter handle is out.',\n  '0700_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '0700_8004': 'AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '0700_8005': 'The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '0700_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '0700_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '0700_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS A to the extruder is properly connected.',\n  '0700_8010': 'The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '0700_8011': 'AMS filament ran out. Please insert a new filament into the same AMS slot.',\n  '0700_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '0700_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '0700_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '0700_8017': 'AMS A is drying. Please stop drying process before loading/unloading material.',\n  '0700_8021': 'AMS setup failed; please refer to the assistant.',\n  '0700_8023': 'AMS A cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '0700_C069': 'An error occurred during AMS A drying. Please go to Assistant for more details.',\n  '0700_C06A': 'AMS A is reading RFID. Unable to start drying. Please try again later.',\n  '0700_C06B': 'AMS A is changing filament. Unable to start drying. Please try again later.',\n  '0700_C06C': 'AMS A is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '0700_C06D': 'AMS A is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '0700_C06E': 'AMS A motor is performing self-test. Unable to start drying. Please try again later.',\n  '0701_4001': 'Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.',\n  '0701_4025': 'Failed to read the filament information.',\n  '0701_8001': 'Failed to cut the filament. Please check the cutter.',\n  '0701_8002': 'The cutter is stuck. Please make sure the cutter handle is out.',\n  '0701_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '0701_8004': 'AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '0701_8005': 'The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '0701_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '0701_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '0701_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS B to the extruder is properly connected.',\n  '0701_8010': 'The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '0701_8011': 'AMS filament ran out. Please insert a new filament into the same AMS slot.',\n  '0701_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '0701_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '0701_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '0701_8017': 'AMS B is drying. Please stop drying process before loading/unloading material.',\n  '0701_8021': 'AMS setup failed; please refer to the assistant.',\n  '0701_8023': 'AMS B cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '0701_C069': 'An error occurred during AMS B drying. Please go to Assistant for more details.',\n  '0701_C06A': 'AMS B is reading RFID. Unable to start drying. Please try again later.',\n  '0701_C06B': 'AMS B is changing filament. Unable to start drying. Please try again later.',\n  '0701_C06C': 'AMS B is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '0701_C06D': 'AMS B is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '0701_C06E': 'AMS B motor is performing self-test. Unable to start drying. Please try again later.',\n  '0702_4001': 'Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.',\n  '0702_4025': 'Failed to read the filament information.',\n  '0702_8001': 'Failed to cut the filament. Please check the cutter.',\n  '0702_8002': 'The cutter is stuck. Please make sure the cutter handle is out.',\n  '0702_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '0702_8004': 'AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '0702_8005': 'The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '0702_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '0702_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '0702_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS C to the extruder is properly connected.',\n  '0702_8010': 'The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '0702_8011': 'AMS filament ran out. Please insert a new filament into the same AMS slot.',\n  '0702_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '0702_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '0702_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '0702_8017': 'AMS C is drying. Please stop drying process before loading/unloading material.',\n  '0702_8021': 'AMS setup failed; please refer to the assistant.',\n  '0702_8023': 'AMS C cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '0702_C069': 'An error occurred during AMS C drying. Please go to Assistant for more details.',\n  '0702_C06A': 'AMS C is reading RFID. Unable to start drying. Please try again later.',\n  '0702_C06B': 'AMS C is changing filament. Unable to start drying. Please try again later.',\n  '0702_C06C': 'AMS C is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '0702_C06D': 'AMS C is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '0702_C06E': 'AMS C motor is performing self-test. Unable to start drying. Please try again later.',\n  '0703_4001': 'Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.',\n  '0703_4025': 'Failed to read the filament information.',\n  '0703_8001': 'Failed to cut the filament. Please check the cutter.',\n  '0703_8002': 'The cutter is stuck. Please make sure the cutter handle is out.',\n  '0703_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '0703_8004': 'AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '0703_8005': 'The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '0703_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '0703_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '0703_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS D to the extruder is properly connected.',\n  '0703_8010': 'The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '0703_8011': 'AMS filament ran out. Please insert a new filament into the same AMS slot.',\n  '0703_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '0703_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '0703_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '0703_8017': 'AMS D is drying. Please stop drying process before loading/unloading material.',\n  '0703_8021': 'AMS setup failed; please refer to the assistant.',\n  '0703_8023': 'AMS D cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '0703_C069': 'An error occurred during AMS D drying. Please go to Assistant for more details.',\n  '0703_C06A': 'AMS D is reading RFID. Unable to start drying. Please try again later.',\n  '0703_C06B': 'AMS D is changing filament. Unable to start drying. Please try again later.',\n  '0703_C06C': 'AMS D is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '0703_C06D': 'AMS D is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '0703_C06E': 'AMS D motor is performing self-test. Unable to start drying. Please try again later.',\n  '0704_4025': 'Failed to read the filament information.',\n  '0704_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '0704_8004': 'AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '0704_8005': 'The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '0704_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '0704_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '0704_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS E to the extruder is properly connected.',\n  '0704_8010': 'The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '0704_8011': 'AMS filament ran out. Please insert a new filament into the same AMS slot.',\n  '0704_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '0704_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '0704_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '0704_8021': 'AMS setup failed; please refer to the assistant.',\n  '0704_8023': 'AMS E cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '0705_4025': 'Failed to read the filament information.',\n  '0705_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '0705_8004': 'AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '0705_8005': 'The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '0705_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '0705_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '0705_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS F to the extruder is properly connected.',\n  '0705_8010': 'The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '0705_8011': 'AMS filament ran out. Please insert a new filament into the same AMS slot.',\n  '0705_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '0705_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '0705_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '0705_8021': 'AMS setup failed; please refer to the assistant.',\n  '0705_8023': 'AMS F cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '0706_4025': 'Failed to read the filament information.',\n  '0706_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '0706_8004': 'AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '0706_8005': 'The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '0706_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '0706_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '0706_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS G to the extruder is properly connected.',\n  '0706_8010': 'The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '0706_8011': 'AMS filament ran out. Please insert a new filament into the same AMS slot.',\n  '0706_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '0706_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '0706_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '0706_8021': 'AMS setup failed; please refer to the assistant.',\n  '0706_8023': 'AMS G cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '0707_4025': 'Failed to read the filament information.',\n  '0707_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '0707_8004': 'AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '0707_8005': 'The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '0707_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '0707_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '0707_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS H to the extruder is properly connected.',\n  '0707_8010': 'The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '0707_8011': 'AMS filament ran out. Please insert a new filament into the same AMS slot.',\n  '0707_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '0707_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '0707_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '0707_8021': 'AMS setup failed; please refer to the assistant.',\n  '0707_8023': 'AMS H cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '07FE_8001': 'Failed to cut the filament of the left extruder. Please check the cutter.',\n  '07FE_8002': 'The cutter of the left extruder is stuck. Please pull out the cutter handle.',\n  '07FE_8003': 'Please pull out the filament on the spool holder  of the left extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are ab...',\n  '07FE_8004': 'Failed to pull back the filament from the left extruder. Please check whether the filament is stuck inside the extruder.',\n  '07FE_8005': 'Failed to feed the filament outside the AMS. Please clip the end of the filament flat and check to see if the spool is stuck.',\n  '07FE_8006': 'Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.',\n  '07FE_8007': 'Please observe the nozzle of the left extruder. If the filament has been extruded, select \\'Continue\\'; if it has not, please push the filament forward slightly, and then select \\'Retry\\'.',\n  '07FE_8010': 'Check if the left external filament spool or filament is stuck.',\n  '07FE_8011': 'The external filament connected to the left extruder has run out; please load a new filament.',\n  '07FE_8012': 'Failed to get mapping table; please select \\'Resume\\' to retry.',\n  '07FE_8013': 'Timeout purging old filament of the left extruder: Please check if the filament is stuck or the extruder is clogged.',\n  '07FE_8020': 'Extruder change failed; please refer to the assistant.',\n  '07FE_8021': 'AMS setup failed; please refer to the assistant.',\n  '07FE_8024': 'Extruder position calibration failed; please refer to the assistant.',\n  '07FE_8025': 'Cold pull timed out. Please promptly operate or check whether the filament is broken inside the extruder, and click the Assistant for details.',\n  '07FE_8030': 'The filament specified in the slicer has been used up. Printing is paused. Please go to the machine to replace the material and resume printing.',\n  '07FE_C003': 'Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...',\n  '07FE_C006': 'Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.',\n  '07FE_C008': 'Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...',\n  '07FE_C009': 'Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.',\n  '07FE_C00A': 'Please observe the nozzle of the left extruder. If the filament has been extruded, select \\'Continue\\'; if not, please push the filament forward slightly and then select \\'Retry\\'.',\n  '07FE_C010': 'Insert the filament (over 30cm long) until it stops. You might see slight smoke during flushing. After insertion, close the front door and top cover.',\n  '07FE_C011': 'Please manually and slowly pull out the filament from the extruder. Then click “Continue”.',\n  '07FE_C012': 'Press the black PTFE tube coupler and unplug the PTFE tube. After completing the operation, click \\'Continue.\\'',\n  '07FF_4001': 'Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.',\n  '07FF_8001': 'Failed to cut the filament of the right extruder. Please check the cutter.',\n  '07FF_8002': 'The cutter is stuck. Please make sure the cutter handle is out.',\n  '07FF_8003': 'Please pull out the filament on the spool holder  of the right extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are a...',\n  '07FF_8004': 'Failed to pull back the filament from the right extruder. Please check whether the filament is stuck inside the extruder.',\n  '07FF_8005': 'Failed to feed the filament outside the AMS. Please clip the end of the filament flat and check to see if the spool is stuck.',\n  '07FF_8006': 'Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.',\n  '07FF_8007': 'Please observe the nozzle of the right extruder. If the filament has been extruded, select \\'Continue\\'; if it has not, please push the filament forward slightly, and then select \\'Retry\\'.',\n  '07FF_8010': 'Check if the external filament spool or filament is stuck.',\n  '07FF_8011': 'External filament has run out; please load a new filament.',\n  '07FF_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '07FF_8013': 'Timeout purging old filament of the right extruder: Please check if the filament is stuck or the extruder is clogged.',\n  '07FF_8020': 'Extruder change failed; please refer to the assistant.',\n  '07FF_8021': 'AMS setup failed; please refer to the assistant.',\n  '07FF_8024': 'Extruder position calibration failed; please refer to the assistant.',\n  '07FF_8025': 'Cold pull timed out. Please promptly operate or check whether the filament is broken inside the extruder, and click the Assistant for details.',\n  '07FF_8030': 'The filament specified in the slicer has been used up. Printing is paused. Please go to the machine to replace the material and resume printing.',\n  '07FF_C003': 'Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...',\n  '07FF_C006': 'Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.',\n  '07FF_C008': 'Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...',\n  '07FF_C009': 'Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.',\n  '07FF_C00A': 'Please observe the nozzle of the right extruder. If the filament has been extruded, select \\'Continue\\'; if not, please push the filament forward slightly and then select \\'Retry\\'.',\n  '07FF_C010': 'Insert the filament (over 30cm long) until it stops. You might see slight smoke during flushing. After insertion, close the front door and top cover.',\n  '07FF_C011': 'Hold the driven wheel bracket, slowly pull the filament from the extruder, then press \\'Continue\\'.',\n  '07FF_C012': 'Press the black PTFE tube coupler and unplug the PTFE tube. After completing the operation, click \\'Continue.\\'',\n  '0C00_4020': 'The setup of BirdsEye Camera failed. Please clear all objects and remove the mat. Make sure the marker is not obstructed. Meanwhile, clean both the BirdsEye Camera and Toolhead Camera, and remove a...',\n  '0C00_4021': 'The setup of BirdsEye Camera failed; please reboot the printer.',\n  '0C00_4022': 'The setup of BirdsEye Camera failed.  Please check if the laser module is working properly.',\n  '0C00_4024': 'The Birdseye Camera is installed offset. Please refer to the assistant to reinstall it.',\n  '0C00_4025': 'The Birdseye Camera is dirty. Please clean it and restart the process.',\n  '0C00_4026': 'The Live View Camera initialization failed; please reboot the printer.',\n  '0C00_4027': 'The Live View Camera calibration failed. Please refer to the assistant for details and recalibrate the camera after processing.',\n  '0C00_4029': 'Material not detected. Please confirm placement and continue.',\n  '0C00_402A': 'The visual marker was not detected. Please re-paste the paper in the correct position.',\n  '0C00_402C': 'Device data link error. Please reboot the printer',\n  '0C00_402D': 'The toolhead camera is not working properly; please reboot the device.',\n  '0C00_403D': 'The vision encoder plate was not detected. Please confirm it is correctly positioned on the heatbed.',\n  '0C00_403E': 'The high-precision nozzle offset calibration has failed, possibly due to a damaged pattern or the similarity of the colors of the two selected filaments. Please clear the printed pattern and replac...',\n  '0C00_4041': 'Toolhead camera calibration failed. Please ensure the Calibration Marker on the heatbed or Height Calibration Marker on the homing area is clean and undamaged, then re-run the calibration process.',\n  '0C00_8001': 'First layer defects were detected. If the defects are acceptable, select \\'Resume\\' to resume the print job.',\n  '0C00_8005': 'Purged filament has piled up in the waste chute, which may cause a tool head collision.',\n  '0C00_8009': 'Build plate localization marker was not found.',\n  '0C00_800B': 'The heatbed marker was not detected. Please clear all objects and remove the mat. Make sure the marker is not obstructed.',\n  '0C00_8015': 'Objects detected on the platform; please clean them up in a timely manner.',\n  '0C00_8016': 'The foreign object detection function is not working. You can continue the task or check assistant for solutions.',\n  '0C00_8017': 'Foreign objects detected on the platform; please clean them up on time.',\n  '0C00_8018': 'The foreign object detection function is not working. You can continue the task or view the assistant for troubleshooting.',\n  '0C00_8033': 'Quick-release Lever is not locked. Please push it down to secure.',\n  '0C00_8034': 'Liveview Camera initialization failed. This print can still continue, but some AI functions will be disabled. If you encounter this issue again after restarting, please contact customer support.',\n  '0C00_803F': 'AI detected nozzle clumping. Please check the nozzle condition. Refer to assistant for solutions.',\n  '0C00_8040': 'AI detected air-printing defect. Please check the hotend extrusion status. Refer to assistant for solutions.',\n  '0C00_8042': 'The AI print monitor has detected a spaghetti defect. Please check the print and take the necessary action.',\n  '0C00_8043': 'AI detected nozzle clumping. Please check the nozzle condition. Refer to assistant for solutions.',\n  '0C00_C003': 'Possible defects were detected in the first layer.',\n  '0C00_C004': 'Possible spaghetti failure was detected.',\n  '0C00_C006': 'Purged filament may have piled up in the waste chute.',\n  '1000_C001': 'High bed temperature may lead to filament clogging in the nozzle. You may open the chamber door.',\n  '1000_C002': 'Printing CF material with stainless steel may cause nozzle damage.',\n  '1000_C003': 'Enabling Timelapse in traditional mode may cause defects; please activate this feature as needed.',\n  '1001_4001': 'Timelapse is not supported as Spiral Vase mode is enabled in slicing presets.',\n  '1001_4002': 'Timelapse is not supported as the Print sequence is set to \\'By object\\'.',\n  '1001_8003': 'The time-lapse mode is set to Traditional in the slicing file. This may cause surface defects. Would you like to enable it?',\n  '1001_8004': 'Prime Tower is not enabled and time-lapse mode is set to Smooth in slicing file. This may cause surface defects. Would you like to enable it?',\n  '1200_4001': 'Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.',\n  '1200_8001': 'Cutting the filament failed. Please check to see if the cutter is stuck. Refer to the Assistant for solutions.',\n  '1200_8002': 'The cutter is stuck. Please pull out the cutter handle.',\n  '1200_8003': 'Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.',\n  '1200_8004': 'Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.',\n  '1200_8005': 'The filament is not inserted. Please insert the filament.',\n  '1200_8006': 'Unable to feed filament into the extruder. This could be due to tangled filament or a stuck spool. If not, please check if the AMS PTFE tube is connected.',\n  '1200_8007': 'Failed to extrude the filament. This might be caused by clogged extruder or stuck filament. Refer to the Assistant for solutions.',\n  '1200_8010': 'Filament or spool may be stuck.',\n  '1200_8011': 'AMS filament has run out. Please insert a new filament into the same AMS slot.',\n  '1200_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1200_8013': 'Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.',\n  '1200_8014': 'The filament location in the toolhead was not found. Refer to the Assistant for solutions.',\n  '1200_8015': 'Failed to pull out the filament from the toolhead. Please check if the filament is stuck, or if it is broken inside the extruder or PTFE tube.',\n  '1200_8016': 'The extruder is not extruding normally. Refer to the Assistant for troubleshooting. There may be defects in this layer, but you may resume if the defects are acceptable.',\n  '1201_4001': 'Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.',\n  '1201_8001': 'Failed to cut the filament. Please check the cutter.',\n  '1201_8002': 'The cutter is stuck. Please pull out the cutter handle.',\n  '1201_8003': 'Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.',\n  '1201_8004': 'Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.',\n  '1201_8005': 'Failed to feed the filament. Please load the filament and then select \\'Retry\\'.',\n  '1201_8006': 'Failed to feed the filament into the toolhead. Please check whether the filament is stuck.',\n  '1201_8007': 'Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.',\n  '1201_8010': 'Please check if the spool or filament is stuck.',\n  '1201_8011': 'AMS filament has run out. Please insert a new filament into the same AMS slot.',\n  '1201_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1201_8013': 'Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.',\n  '1201_8014': 'Failed to check the filament location in the tool head; please refer to the HMS.',\n  '1201_8015': 'Failed to pull back the filament from the toolhead. Please check if the filament is stuck or the filament is broken inside the extruder.',\n  '1201_8016': 'The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.',\n  '1202_4001': 'Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.',\n  '1202_8001': 'Failed to cut the filament. Please check the cutter.',\n  '1202_8002': 'The cutter is stuck. Please pull out the cutter handle.',\n  '1202_8003': 'Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.',\n  '1202_8004': 'Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.',\n  '1202_8005': 'The filament is not inserted. Please insert the filament.',\n  '1202_8006': 'Failed to feed the filament into the toolhead. Please check whether the filament is stuck.',\n  '1202_8007': 'Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.',\n  '1202_8010': 'Please check if the spool or filament is stuck.',\n  '1202_8011': 'AMS filament has run out. Please insert a new filament into the same AMS slot.',\n  '1202_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1202_8013': 'Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.',\n  '1202_8014': 'Failed to check the filament location in the tool head; please refer to the HMS.',\n  '1202_8015': 'Failed to pull back the filament from the toolhead. Please check if the filament is stuck or is broken inside the extruder.',\n  '1202_8016': 'The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.',\n  '1203_4001': 'Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.',\n  '1203_8001': 'Failed to cut the filament. Please check the cutter.',\n  '1203_8002': 'The cutter is stuck. Please pull out the cutter handle.',\n  '1203_8003': 'Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.',\n  '1203_8004': 'Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.',\n  '1203_8005': 'The filament is not inserted. Please insert the filament.',\n  '1203_8006': 'Failed to feed the filament into the toolhead. Please check whether the filament is stuck.',\n  '1203_8007': 'Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.',\n  '1203_8010': 'Please check if the spool or filament is stuck.',\n  '1203_8011': 'AMS filament has run out. Please insert a new filament into the same AMS slot.',\n  '1203_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1203_8013': 'Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.',\n  '1203_8014': 'Failed to check the filament location in the tool head; please refer to the HMS.',\n  '1203_8015': 'Failed to pull back the filament from the toolhead. Please check if the filament is stuck or is broken inside the extruder.',\n  '1203_8016': 'The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.',\n  '12FF_4001': 'Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.',\n  '12FF_8001': 'Failed to cut the filament. Please check the cutter.',\n  '12FF_8002': 'The cutter is stuck. Please pull out the cutter handle.',\n  '12FF_8003': 'Please pull out the filament on the spool holder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube if you are about to us...',\n  '12FF_8004': 'Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.',\n  '12FF_8005': 'The filament is not inserted. Please insert the filament.',\n  '12FF_8006': 'Please feed filament into the PTFE tube until it can not be pushed any farther.',\n  '12FF_8007': 'Check nozzle. Select \\'Done\\' if filament was extruded, otherwise push filament forward slightly and select \\'Retry.\\'',\n  '12FF_8010': 'Please check if the filament or the spool is stuck.',\n  '12FF_8011': 'AMS filament has run out. Please insert a new filament into the same AMS slot.',\n  '12FF_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '12FF_8013': 'Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.',\n  '12FF_C003': 'Please pull out the filament on the spool holder. If this message persists, please check to see if there is filament broken in the extruder or PTFE Tube. (Connect a PTFE tube if you are about to us...',\n  '12FF_C006': 'Please feed filament into the PTFE tube until it can not be pushed any farther.',\n  '1800_4025': 'Failed to read the filament information.',\n  '1800_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '1800_8004': 'AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '1800_8005': 'The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '1800_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '1800_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '1800_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT A to the extruder is properly connected.',\n  '1800_8010': 'The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '1800_8011': 'AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.',\n  '1800_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1800_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '1800_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '1800_8017': 'AMS-HT A is drying. Please stop drying process before loading/unloading material.',\n  '1800_8021': 'AMS setup failed; please refer to the assistant.',\n  '1800_8023': 'AMS-HT A cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '1800_C069': 'An error occurred during AMS-HT A drying. Please go to Assistant for more details.',\n  '1800_C06A': 'AMS-HT A is reading RFID. Unable to start drying. Please try again later.',\n  '1800_C06B': 'AMS-HT A is changing filament. Unable to start drying. Please try again later.',\n  '1800_C06C': 'AMS-HT A is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '1800_C06D': 'AMS-HT A is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '1800_C06E': 'AMS-HT A motor is performing self-test. Unable to start drying. Please try again later.',\n  '1801_4025': 'Failed to read the filament information.',\n  '1801_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '1801_8004': 'AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '1801_8005': 'The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '1801_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '1801_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '1801_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT B to the extruder is properly connected.',\n  '1801_8010': 'The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '1801_8011': 'AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.',\n  '1801_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1801_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '1801_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '1801_8017': 'AMS-HT B is drying. Please stop drying process before loading/unloading material.',\n  '1801_8021': 'AMS setup failed; please refer to the assistant.',\n  '1801_8023': 'AMS-HT B cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '1801_C069': 'An error occurred during AMS-HT B drying. Please go to Assistant for more details.',\n  '1801_C06A': 'AMS-HT B is reading RFID. Unable to start drying. Please try again later.',\n  '1801_C06B': 'AMS-HT B is changing filament. Unable to start drying. Please try again later.',\n  '1801_C06C': 'AMS-HT B is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '1801_C06D': 'AMS-HT B is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '1801_C06E': 'AMS-HT B motor is performing self-test. Unable to start drying. Please try again later.',\n  '1802_4025': 'Failed to read the filament information.',\n  '1802_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '1802_8004': 'AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '1802_8005': 'The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '1802_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '1802_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '1802_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT C to the extruder is properly connected.',\n  '1802_8010': 'The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '1802_8011': 'AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.',\n  '1802_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1802_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '1802_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '1802_8017': 'AMS-HT C is drying. Please stop drying process before loading/unloading material.',\n  '1802_8021': 'AMS setup failed; please refer to the assistant.',\n  '1802_8023': 'AMS-HT C cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '1802_C069': 'An error occurred during AMS-HT C drying. Please go to Assistant for more details.',\n  '1802_C06A': 'AMS-HT C is reading RFID. Unable to start drying. Please try again later.',\n  '1802_C06B': 'AMS-HT C is changing filament. Unable to start drying. Please try again later.',\n  '1802_C06C': 'AMS-HT C is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '1802_C06D': 'AMS-HT C is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '1802_C06E': 'AMS-HT C motor is performing self-test. Unable to start drying. Please try again later.',\n  '1803_4025': 'Failed to read the filament information.',\n  '1803_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '1803_8004': 'AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '1803_8005': 'The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '1803_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '1803_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '1803_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT D to the extruder is properly connected.',\n  '1803_8010': 'The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '1803_8011': 'AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.',\n  '1803_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1803_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '1803_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '1803_8017': 'AMS-HT D is drying. Please stop drying process before loading/unloading material.',\n  '1803_8021': 'AMS setup failed; please refer to the assistant.',\n  '1803_8023': 'AMS-HT D cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '1803_C069': 'An error occurred during AMS-HT D drying. Please go to Assistant for more details.',\n  '1803_C06A': 'AMS-HT D is reading RFID. Unable to start drying. Please try again later.',\n  '1803_C06B': 'AMS-HT D is changing filament. Unable to start drying. Please try again later.',\n  '1803_C06C': 'AMS-HT D is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '1803_C06D': 'AMS-HT D is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '1803_C06E': 'AMS-HT D motor is performing self-test. Unable to start drying. Please try again later.',\n  '1804_4025': 'Failed to read the filament information.',\n  '1804_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '1804_8004': 'AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '1804_8005': 'The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '1804_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '1804_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '1804_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT E to the extruder is properly connected.',\n  '1804_8010': 'The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '1804_8011': 'AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.',\n  '1804_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1804_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '1804_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '1804_8021': 'AMS setup failed; please refer to the assistant.',\n  '1804_8023': 'AMS-HT E cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '1804_C069': 'An error occurred during AMS-HT E drying. Please go to Assistant for more details.',\n  '1804_C06A': 'AMS-HT E is reading RFID. Unable to start drying. Please try again later.',\n  '1804_C06B': 'AMS-HT E is changing filament. Unable to start drying. Please try again later.',\n  '1804_C06C': 'AMS-HT E is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '1804_C06D': 'AMS-HT E is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '1804_C06E': 'AMS-HT E motor is performing self-test. Unable to start drying. Please try again later.',\n  '1805_4025': 'Failed to read the filament information.',\n  '1805_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '1805_8004': 'AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '1805_8005': 'The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '1805_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '1805_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '1805_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT F to the extruder is properly connected.',\n  '1805_8010': 'The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '1805_8011': 'AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.',\n  '1805_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1805_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '1805_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '1805_8021': 'AMS setup failed; please refer to the assistant.',\n  '1805_8023': 'AMS-HT F cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '1805_C069': 'An error occurred during AMS-HT F drying. Please go to Assistant for more details.',\n  '1805_C06A': 'AMS-HT F is reading RFID. Unable to start drying. Please try again later.',\n  '1805_C06B': 'AMS-HT F is changing filament. Unable to start drying. Please try again later.',\n  '1805_C06C': 'AMS-HT F is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '1805_C06D': 'AMS-HT F is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '1805_C06E': 'AMS-HT F motor is performing self-test. Unable to start drying. Please try again later.',\n  '1806_4025': 'Failed to read the filament information.',\n  '1806_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '1806_8004': 'AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '1806_8005': 'The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '1806_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '1806_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '1806_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT G to the extruder is properly connected.',\n  '1806_8010': 'The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '1806_8011': 'AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.',\n  '1806_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1806_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '1806_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '1806_8021': 'AMS setup failed; please refer to the assistant.',\n  '1806_8023': 'AMS-HT G cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '1806_C069': 'An error occurred during AMS-HT G drying. Please go to Assistant for more details.',\n  '1806_C06A': 'AMS-HT G is reading RFID. Unable to start drying. Please try again later.',\n  '1806_C06B': 'AMS-HT G is changing filament. Unable to start drying. Please try again later.',\n  '1806_C06C': 'AMS-HT G is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '1806_C06D': 'AMS-HT G is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '1806_C06E': 'AMS-HT G motor is performing self-test. Unable to start drying. Please try again later.',\n  '1807_4025': 'Failed to read the filament information.',\n  '1807_8003': 'Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.',\n  '1807_8004': 'AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.',\n  '1807_8005': 'The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.',\n  '1807_8006': 'Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...',\n  '1807_8007': 'Extruding filament failed. The extruder might be clogged.',\n  '1807_800A': 'PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT H to the extruder is properly connected.',\n  '1807_8010': 'The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.',\n  '1807_8011': 'AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.',\n  '1807_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '1807_8013': 'Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.',\n  '1807_8016': 'The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.',\n  '1807_8021': 'AMS setup failed; please refer to the assistant.',\n  '1807_8023': 'AMS-HT H cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.',\n  '1807_C069': 'An error occurred during AMS-HT H drying. Please go to Assistant for more details.',\n  '1807_C06A': 'AMS-HT H is reading RFID. Unable to start drying. Please try again later.',\n  '1807_C06B': 'AMS-HT H is changing filament. Unable to start drying. Please try again later.',\n  '1807_C06C': 'AMS-HT H is in Feed Assist Mode. Unable to start drying. Please try again later.',\n  '1807_C06D': 'AMS-HT H is assisting in filament insertion. Unable to start drying. Please try again later.',\n  '1807_C06E': 'AMS-HT H motor is performing self-test. Unable to start drying. Please try again later.',\n  '18FE_8001': 'Failed to cut the filament of the left extruder. Please check the cutter.',\n  '18FE_8002': 'The cutter of the left extruder is stuck. Please pull out the cutter handle.',\n  '18FE_8003': 'Please pull out the filament on the spool holder  of the left extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are ab...',\n  '18FE_8004': 'Failed to pull back the filament from the left extruder. Please check whether the filament is stuck inside the extruder.',\n  '18FE_8005': 'Failed to feed the filament outside the AMS-HT. Please clip the end of the filament flat and check to see if the spool is stuck.',\n  '18FE_8006': 'Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.',\n  '18FE_8007': 'Please observe the nozzle of the left extruder. If the filament has been extruded, select \\'Continue\\'; if it has not, please push the filament forward slightly, and then select \\'Retry\\'.',\n  '18FE_8011': 'The external filament connected to the left extruder has run out; please load a new filament.',\n  '18FE_8012': 'Failed to get mapping table; please select \\'Resume\\' to retry.',\n  '18FE_8013': 'Timeout purging old filament of the left extruder: Please check if the filament is stuck or the extruder is clogged.',\n  '18FE_8020': 'Extruder change failed; please refer to the assistant.',\n  '18FE_8021': 'AMS setup failed; please refer to the assistant.',\n  '18FE_8024': 'Extruder position calibration failed; please refer to the assistant.',\n  '18FE_C003': 'Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...',\n  '18FE_C006': 'Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.',\n  '18FE_C008': 'Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...',\n  '18FE_C009': 'Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.',\n  '18FE_C00A': 'Please observe the nozzle of the left extruder. If the filament has been extruded, select \\'Continue\\'; if not, please push the filament forward slightly and then select \\'Retry\\'.',\n  '18FF_8001': 'Failed to cut the filament of the right extruder. Please check the cutter.',\n  '18FF_8002': 'The cutter of the right extruder is stuck. Please pull out the cutter handle.',\n  '18FF_8003': 'Please pull out the filament on the spool holder  of the right extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are a...',\n  '18FF_8004': 'Failed to pull back the filament from the right extruder. Please check whether the filament is stuck inside the extruder.',\n  '18FF_8005': 'Failed to feed the filament outside the AMS-HT. Please clip the end of the filament flat and check to see if the spool is stuck.',\n  '18FF_8006': 'Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.',\n  '18FF_8007': 'Please observe the nozzle of the right extruder. If the filament has been extruded, select \\'Continue\\'; if it has not, please push the filament forward slightly, and then select \\'Retry\\'.',\n  '18FF_8011': 'The external filament connected to the right extruder has run out; please load a new filament.',\n  '18FF_8012': 'Failed to get AMS mapping table; please select \\'Resume\\' to retry.',\n  '18FF_8013': 'Timeout purging old filament of the right extruder: Please check if the filament is stuck or the extruder is clogged.',\n  '18FF_8020': 'Extruder change failed; please refer to the assistant.',\n  '18FF_8021': 'AMS setup failed; please refer to the assistant.',\n  '18FF_8024': 'Extruder position calibration failed; please refer to the assistant.',\n  '18FF_C003': 'Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...',\n  '18FF_C006': 'Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.',\n  '18FF_C008': 'Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...',\n  '18FF_C009': 'Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.',\n  '18FF_C00A': 'Please observe the nozzle of the right extruder. If the filament has been extruded, select \\'Continue\\'; if not, please push the filament forward slightly and then select \\'Retry\\'.',\n};\n\nfunction getSeverityInfo(severity: number): { label: string; color: string; bgColor: string; Icon: typeof AlertTriangle } {\n  switch (severity) {\n    case 1:\n      return { label: 'Fatal', color: 'text-red-500', bgColor: 'bg-red-500/20', Icon: AlertTriangle };\n    case 2:\n      return { label: 'Serious', color: 'text-red-400', bgColor: 'bg-red-500/15', Icon: AlertTriangle };\n    case 3:\n      return { label: 'Warning', color: 'text-orange-400', bgColor: 'bg-orange-500/20', Icon: AlertCircle };\n    case 4:\n    default:\n      return { label: 'Info', color: 'text-blue-400', bgColor: 'bg-blue-500/20', Icon: Info };\n  }\n}\n\nfunction getShortCode(attr: number, code: number): string {\n  // Convert attr and code to short format: XXXX_YYYY\n  // attr contains the module info, code contains the error number\n  const module = ((attr >> 16) & 0xFFFF) || ((attr >> 8) & 0xFF) << 8 | (attr & 0xFF);\n  const codeNum = code & 0xFFFF;\n  return `${module.toString(16).padStart(4, '0').toUpperCase()}_${codeNum.toString(16).padStart(4, '0').toUpperCase()}`;\n}\n\n// Helper to filter only known HMS errors (exported for use in badge counts)\nexport function filterKnownHMSErrors(errors: HMSError[]): HMSError[] {\n  return errors.filter((error) => {\n    const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;\n    const shortCode = getShortCode(error.attr, codeNum);\n    return ERROR_DESCRIPTIONS[shortCode] !== undefined;\n  });\n}\n\nfunction getHMSHomeUrl(): string {\n  return `https://wiki.bambulab.com/en/hms/home`;\n}\n\nexport function HMSErrorModal({ printerName, errors, onClose, printerId, hasPermission }: HMSErrorModalProps) {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n\n  const clearMutation = useMutation({\n    mutationFn: () => api.clearHMSErrors(printerId),\n    onSuccess: () => {\n      showToast(t('hmsErrors.clearSuccess'), 'success');\n      onClose();\n    },\n    onError: () => {\n      showToast(t('hmsErrors.clearFailed'), 'error');\n    },\n  });\n\n  // Filter to only show errors we have descriptions for (skip unknown codes)\n  const knownErrors = errors.filter((error) => {\n    const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;\n    const shortCode = getShortCode(error.attr, codeNum);\n    return ERROR_DESCRIPTIONS[shortCode] !== undefined;\n  });\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-2\">\n            <AlertTriangle className=\"w-5 h-5 text-orange-400\" />\n            <h2 className=\"text-lg font-semibold text-white\">{t('hmsErrors.title', { name: printerName })}</h2>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"p-1 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n          >\n            <X className=\"w-5 h-5 text-bambu-gray\" />\n          </button>\n        </div>\n\n        {/* Content */}\n        <div className=\"flex-1 overflow-y-auto p-4\">\n          {knownErrors.length === 0 ? (\n            <div className=\"text-center py-8 text-bambu-gray\">\n              <AlertCircle className=\"w-12 h-12 mx-auto mb-3 opacity-30\" />\n              <p>{t('hmsErrors.noErrors')}</p>\n            </div>\n          ) : (\n            <div className=\"space-y-3\">\n              {knownErrors.map((error, index) => {\n                const { label, color, bgColor, Icon } = getSeverityInfo(error.severity);\n                const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;\n                const shortCode = getShortCode(error.attr, codeNum);\n                const description = ERROR_DESCRIPTIONS[shortCode];\n                const hmsHomeUrl = getHMSHomeUrl();\n                const displayCode = shortCode.replace('_', '-');\n\n                return (\n                  <div\n                    key={`${error.code}-${index}`}\n                    className={`p-4 rounded-lg ${bgColor} border border-white/10`}\n                  >\n                    <div className=\"flex items-start gap-3\">\n                      <Icon className={`w-5 h-5 ${color} flex-shrink-0 mt-0.5`} />\n                      <div className=\"flex-1 min-w-0\">\n                        <div className=\"flex items-center gap-2 mb-1\">\n                          <span className={`font-mono text-sm ${color}`}>[{displayCode}]</span>\n                          <span className={`text-xs px-2 py-0.5 rounded-full ${bgColor} ${color}`}>\n                            {label}\n                          </span>\n                        </div>\n                        <p className=\"text-sm text-bambu-gray mb-2\">{description}</p>\n                        <a\n                          href={hmsHomeUrl}\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                          className=\"inline-flex items-center gap-1 text-xs text-bambu-green hover:underline\"\n                        >\n                          <ExternalLink className=\"w-3 h-3\" />\n                          {t('hmsErrors.viewOnWiki')}\n                        </a>\n                      </div>\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n          )}\n        </div>\n\n        {/* Footer */}\n        <div className=\"p-4 border-t border-bambu-dark-tertiary flex items-center justify-between gap-3\">\n          <p className=\"text-xs text-bambu-gray\">\n            {t('hmsErrors.clearInstructions')}\n          </p>\n          {knownErrors.length > 0 && (\n            <button\n              onClick={() => clearMutation.mutate()}\n              disabled={!hasPermission('printers:control') || clearMutation.isPending}\n              className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0\"\n            >\n              {clearMutation.isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Trash2 className=\"w-4 h-4\" />\n              )}\n              {t('hmsErrors.clearErrors')}\n            </button>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/IconPicker.tsx",
    "content": "import { useState } from 'react';\nimport {\n  Globe,\n  Link,\n  ExternalLink,\n  Book,\n  FileText,\n  Home,\n  Star,\n  Heart,\n  Bookmark,\n  ShoppingCart,\n  Music,\n  Video,\n  Image,\n  Camera,\n  Map,\n  Compass,\n  Coffee,\n  Gift,\n  Wrench,\n  Zap,\n  Cloud,\n  Database,\n  Folder,\n  Mail,\n  Phone,\n  User,\n  Users,\n  Server,\n  Terminal,\n  Code,\n  type LucideIcon,\n} from 'lucide-react';\n\n// Available icons for external links\nexport const AVAILABLE_ICONS: { name: string; icon: LucideIcon }[] = [\n  { name: 'globe', icon: Globe },\n  { name: 'link', icon: Link },\n  { name: 'external-link', icon: ExternalLink },\n  { name: 'book', icon: Book },\n  { name: 'file-text', icon: FileText },\n  { name: 'home', icon: Home },\n  { name: 'star', icon: Star },\n  { name: 'heart', icon: Heart },\n  { name: 'bookmark', icon: Bookmark },\n  { name: 'shopping-cart', icon: ShoppingCart },\n  { name: 'music', icon: Music },\n  { name: 'video', icon: Video },\n  { name: 'image', icon: Image },\n  { name: 'camera', icon: Camera },\n  { name: 'map', icon: Map },\n  { name: 'compass', icon: Compass },\n  { name: 'coffee', icon: Coffee },\n  { name: 'gift', icon: Gift },\n  { name: 'wrench', icon: Wrench },\n  { name: 'zap', icon: Zap },\n  { name: 'cloud', icon: Cloud },\n  { name: 'database', icon: Database },\n  { name: 'folder', icon: Folder },\n  { name: 'mail', icon: Mail },\n  { name: 'phone', icon: Phone },\n  { name: 'user', icon: User },\n  { name: 'users', icon: Users },\n  { name: 'server', icon: Server },\n  { name: 'terminal', icon: Terminal },\n  { name: 'code', icon: Code },\n];\n\n// Helper to get icon component by name\nexport function getIconByName(name: string): LucideIcon {\n  const found = AVAILABLE_ICONS.find((i) => i.name === name);\n  return found?.icon || Link;\n}\n\ninterface IconPickerProps {\n  value: string;\n  onChange: (value: string) => void;\n}\n\nexport function IconPicker({ value, onChange }: IconPickerProps) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const SelectedIcon = getIconByName(value);\n\n  return (\n    <div className=\"relative\">\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(!isOpen)}\n        className=\"flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white hover:border-bambu-gray focus:border-bambu-green focus:outline-none w-full\"\n      >\n        <SelectedIcon className=\"w-5 h-5\" />\n        <span className=\"text-sm text-bambu-gray flex-1 text-left\">{value}</span>\n      </button>\n\n      {isOpen && (\n        <>\n          {/* Backdrop */}\n          <div\n            className=\"fixed inset-0 z-40\"\n            onClick={() => setIsOpen(false)}\n          />\n\n          {/* Dropdown */}\n          <div className=\"absolute z-50 mt-1 w-full max-h-64 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg\">\n            <div className=\"grid grid-cols-5 gap-1 p-2\">\n              {AVAILABLE_ICONS.map(({ name, icon: Icon }) => (\n                <button\n                  key={name}\n                  type=\"button\"\n                  onClick={() => {\n                    onChange(name);\n                    setIsOpen(false);\n                  }}\n                  className={`p-2 rounded-lg transition-colors flex items-center justify-center ${\n                    value === name\n                      ? 'bg-bambu-green text-white'\n                      : 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'\n                  }`}\n                  title={name}\n                >\n                  <Icon className=\"w-5 h-5\" />\n                </button>\n              ))}\n            </div>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/KProfilesView.tsx",
    "content": "import React, { useState, useEffect, useCallback } from 'react';\nimport { useQuery, useMutation } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Gauge,\n  Loader2,\n  RefreshCw,\n  Printer,\n  Plus,\n  X,\n  AlertCircle,\n  WifiOff,\n  Trash2,\n  Search,\n  Copy,\n  Download,\n  Upload,\n  CheckSquare,\n  Square,\n  StickyNote,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\n\ninterface KProfileCardProps {\n  profile: KProfile;\n  onEdit: () => void;\n  onCopy?: () => void;\n  selectionMode?: boolean;\n  isSelected?: boolean;\n  onToggleSelect?: () => void;\n  note?: string;  // Note text to display as preview\n}\n\n// Truncate to 3 decimal places (like Bambu Studio) instead of rounding\nconst truncateK = (value: string) => {\n  const num = parseFloat(value);\n  return (Math.trunc(num * 1000) / 1000).toFixed(3);\n};\n\n// Get flow type label from nozzle_id (e.g., \"HH00-0.4\" -> \"HF\", \"HS00-0.4\" -> \"S\")\nconst getFlowTypeLabel = (nozzleId: string) => {\n  if (nozzleId.startsWith('HH')) return 'HF';  // High Flow\n  return 'S';  // Standard Flow (default)\n};\n\n// Extract nozzle type prefix from nozzle_id (e.g., \"HH00-0.4\" -> \"HH00\")\nconst getNozzleTypePrefix = (nozzleId: string) => {\n  const match = nozzleId.match(/^([A-Z]{2}\\d{2})/);\n  return match ? match[1] : 'HH00';\n};\n\n// Extract filament name from profile name (e.g., \"High Flow_Devil Design PLA Basic\" -> \"Devil Design PLA Basic\")\nconst extractFilamentName = (profileName: string) => {\n  // Profile names are formatted as \"{Flow Type}_{Filament Name}\" or \"{Flow Type} {Filament Name}\"\n  // Remove common prefixes - check both underscore and space separators\n  const prefixes = [\n    'High Flow_', 'High Flow ',  // underscore or space\n    'Standard_', 'Standard ',\n    'HF_', 'HF ',\n    'S_', 'S ',\n  ];\n  for (const prefix of prefixes) {\n    if (profileName.startsWith(prefix)) {\n      return profileName.slice(prefix.length);\n    }\n  }\n  // If no prefix found, check for underscore separator\n  const underscoreIdx = profileName.indexOf('_');\n  if (underscoreIdx > 0) {\n    return profileName.slice(underscoreIdx + 1);\n  }\n  return profileName;\n};\n\nfunction KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: KProfileCardProps) {\n  const flowType = getFlowTypeLabel(profile.nozzle_id);\n  const diameter = profile.nozzle_diameter;\n\n  const handleClick = () => {\n    if (selectionMode && onToggleSelect) {\n      onToggleSelect();\n    } else {\n      onEdit();\n    }\n  };\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      {selectionMode && (\n        <button\n          onClick={onToggleSelect}\n          className=\"text-bambu-gray hover:text-white transition-colors p-1\"\n        >\n          {isSelected ? (\n            <CheckSquare className=\"w-4 h-4 text-bambu-green\" />\n          ) : (\n            <Square className=\"w-4 h-4\" />\n          )}\n        </button>\n      )}\n      <button\n        onClick={handleClick}\n        className={`flex-1 text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors ${isSelected ? 'ring-1 ring-bambu-green' : ''}`}\n      >\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-bambu-green font-mono text-sm font-bold whitespace-nowrap\">\n            {truncateK(profile.k_value)}\n          </span>\n          <span className=\"text-white text-sm truncate flex-1\" title={profile.name}>\n            {profile.name || 'Unnamed'}\n          </span>\n          {note && (\n            <span title=\"Has note\">\n              <StickyNote className=\"w-3 h-3 text-yellow-500\" />\n            </span>\n          )}\n          <span className=\"text-xs text-bambu-gray whitespace-nowrap\">\n            {flowType} {diameter}\n          </span>\n        </div>\n        {note && (\n          <div className=\"text-xs mt-0.5 truncate text-yellow-500/70\" title={note}>\n            Note: {note.length > 50 ? note.substring(0, 50) + '...' : note}\n          </div>\n        )}\n      </button>\n      {!selectionMode && onCopy && (\n        <button\n          onClick={(e) => {\n            e.stopPropagation();\n            onCopy();\n          }}\n          className=\"text-bambu-gray hover:text-white transition-colors p-1\"\n          title=\"Copy profile\"\n        >\n          <Copy className=\"w-4 h-4\" />\n        </button>\n      )}\n    </div>\n  );\n}\n\ninterface KProfileModalProps {\n  profile?: KProfile;\n  printerId: number;\n  nozzleDiameter: string;\n  existingProfiles?: KProfile[];  // Existing profiles for filament selection\n  builtinFilaments?: { filament_id: string; name: string }[];  // Filament ID → name lookup\n  isDualNozzle?: boolean;  // Whether this is a dual-nozzle printer\n  initialNote?: string;  // Initial note value for the profile\n  initialNoteKey?: string | null;  // Key the note was stored under (for clearing)\n  onClose: () => void;\n  onSave: () => void;\n  onSaveNote?: (settingId: string, note: string) => void;  // Callback to save note\n  hasPermission: (permission: Permission) => boolean;\n}\n\nfunction KProfileModal({\n  profile,\n  printerId,\n  nozzleDiameter,\n  existingProfiles = [],\n  builtinFilaments = [],\n  isDualNozzle = false,\n  initialNote = '',\n  initialNoteKey = null,\n  onClose,\n  onSave,\n  onSaveNote,\n  hasPermission,\n}: KProfileModalProps) {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n\n  const [name, setName] = useState(profile?.name || '');\n  const [kValue, setKValue] = useState(\n    profile?.k_value ? truncateK(profile.k_value) : '0.020'\n  );\n  const [filamentId, setFilamentId] = useState(profile?.filament_id || '');\n  // Split nozzle into type and diameter\n  const [nozzleType, setNozzleType] = useState(\n    profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'\n  );\n  const [modalDiameter, setModalDiameter] = useState(\n    profile?.nozzle_diameter || nozzleDiameter\n  );\n  // For new profiles on dual-nozzle: allow selecting multiple extruders\n  // For editing: use single extruder from the profile\n  const [selectedExtruders, setSelectedExtruders] = useState<number[]>(\n    profile ? [profile.extruder_id] : isDualNozzle ? [0, 1] : [0]  // Default: both extruders for new dual-nozzle profiles\n  );\n  const [isSyncing, setIsSyncing] = useState(false);\n  const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });\n  const [note, setNote] = useState(initialNote);\n\n  // Extract unique filaments from existing K-profiles on the printer\n  // Use builtin filament table for accurate name resolution (filament_id → name)\n  // Falls back to extracting from profile name for custom/unknown presets\n  const knownFilaments = React.useMemo(() => {\n    // Build lookup map from builtin filament names (includes cloud presets from parent)\n    const builtinMap = new Map<string, string>();\n    for (const bf of builtinFilaments) {\n      builtinMap.set(bf.filament_id, bf.name);\n    }\n\n    const filamentMap = new Map<string, { id: string; name: string }>();\n    for (const p of existingProfiles) {\n      if (p.filament_id && !filamentMap.has(p.filament_id)) {\n        // Prefer builtin name (accurate), fall back to extracting from profile name\n        const builtinName = builtinMap.get(p.filament_id);\n        const filamentName = builtinName || extractFilamentName(p.name || '');\n        filamentMap.set(p.filament_id, {\n          id: p.filament_id,\n          name: filamentName || p.filament_id,\n        });\n      }\n    }\n    return Array.from(filamentMap.values()).sort((a, b) =>\n      a.name.localeCompare(b.name)\n    );\n  }, [existingProfiles, builtinFilaments]);\n\n  const saveMutation = useMutation({\n    mutationFn: (data: KProfileCreate) => {\n      console.log('[KProfile] Calling API...');\n      return api.setKProfile(printerId, data);\n    },\n    onSuccess: (result) => {\n      console.log('[KProfile] Save success:', result);\n      showToast(t('kProfiles.toast.profileSaved'));\n      // Save note if it changed (including clearing it)\n      if (onSaveNote && note !== initialNote) {\n        let profileKey: string;\n        if (note === '' && initialNoteKey) {\n          // Clearing note: use the same key it was stored under\n          profileKey = initialNoteKey;\n        } else if (profile && profile.slot_id > 0) {\n          // Editing: use setting_id if available, or composite key with slot_id\n          profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;\n        } else {\n          // New profile: use name as key (will be matched when profile is loaded)\n          profileKey = `name_${name}_${filamentId}`;\n        }\n        onSaveNote(profileKey, note);\n      }\n      // Show syncing indicator while printer processes the command\n      setIsSyncing(true);\n      // Add delay before closing to give printer time to process the save\n      // onSave will trigger refetch in the parent component\n      setTimeout(() => {\n        setIsSyncing(false);\n        onSave();\n      }, 2500);\n    },\n    onError: (error: Error) => {\n      console.error('[KProfile] Save error:', error);\n      showToast(error.message, 'error');\n      setIsSyncing(false);\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (data: KProfileDelete) => {\n      console.log('[KProfile] Deleting profile...');\n      return api.deleteKProfile(printerId, data);\n    },\n    onSuccess: (result) => {\n      console.log('[KProfile] Delete success:', result);\n      showToast(t('kProfiles.toast.profileDeleted'));\n      // Show syncing indicator while printer processes the command\n      setIsSyncing(true);\n      // Add longer delay for delete - printer needs more time to process\n      // before it can return the updated profile list\n      setTimeout(() => {\n        setIsSyncing(false);\n        onClose();\n      }, 4000);\n    },\n    onError: (error: Error) => {\n      console.error('[KProfile] Delete error:', error);\n      showToast(error.message, 'error');\n      setIsSyncing(false);\n    },\n  });\n\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n\n  const handleDelete = () => {\n    if (!profile) return;\n    deleteMutation.mutate({\n      slot_id: profile.slot_id,\n      extruder_id: profile.extruder_id,\n      nozzle_id: profile.nozzle_id,\n      nozzle_diameter: profile.nozzle_diameter,\n      filament_id: profile.filament_id,\n      setting_id: profile.setting_id,\n    });\n  };\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    // Validate at least one extruder is selected for dual-nozzle\n    if (isDualNozzle && !profile && selectedExtruders.length === 0) {\n      showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');\n      return;\n    }\n\n    // Format k_value to 6 decimal places for Bambu protocol\n    const formattedKValue = parseFloat(kValue).toFixed(6);\n    // Combine nozzle type and diameter into nozzle_id (e.g., \"HH00-0.4\")\n    const nozzleId = `${nozzleType}-${modalDiameter}`;\n\n    // For editing or single extruder: just save one profile\n    if (profile || selectedExtruders.length === 1) {\n      const payload = {\n        name: name,\n        k_value: formattedKValue,\n        filament_id: filamentId,\n        nozzle_id: nozzleId,\n        nozzle_diameter: modalDiameter,\n        extruder_id: profile ? profile.extruder_id : selectedExtruders[0],\n        setting_id: profile?.setting_id,\n        slot_id: profile?.slot_id ?? 0,\n      };\n      console.log('[KProfile] Saving profile:', payload);\n      saveMutation.mutate(payload);\n      return;\n    }\n\n    // For new profiles with multiple extruders: use batch endpoint\n    setIsSyncing(true);\n    setSavingProgress({ current: 1, total: selectedExtruders.length });\n\n    // Build payload for all selected extruders\n    const batchPayload = selectedExtruders.map(extruderId => ({\n      name: name,\n      k_value: formattedKValue,\n      filament_id: filamentId,\n      nozzle_id: nozzleId,\n      nozzle_diameter: modalDiameter,\n      extruder_id: extruderId,\n      setting_id: undefined,\n      slot_id: 0,\n    }));\n\n    console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);\n\n    try {\n      await api.setKProfilesBatch(printerId, batchPayload);\n      showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));\n      // Save note for new batch profiles\n      if (onSaveNote && note) {\n        const profileKey = `name_${name}_${filamentId}`;\n        onSaveNote(profileKey, note);\n      }\n    } catch (error) {\n      console.error('[KProfile] Failed to save batch:', error);\n      showToast(t('kProfiles.toast.failedToSaveBatch'), 'error');\n      setIsSyncing(false);\n      setSavingProgress({ current: 0, total: 0 });\n      return;\n    }\n\n    setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });\n    // Wait for final sync before closing\n    // onSave will trigger refetch in the parent component\n    setTimeout(() => {\n      setIsSyncing(false);\n      setSavingProgress({ current: 0, total: 0 });\n      onSave();\n    }, 3000);\n  };\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n      <Card className=\"w-full max-w-md relative\">\n        {/* Syncing overlay */}\n        {isSyncing && (\n          <div className=\"absolute inset-0 bg-bambu-dark-secondary/90 flex flex-col items-center justify-center z-10 rounded-lg\">\n            <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin mb-3\" />\n            <p className=\"text-white font-medium\">\n              {savingProgress.total > 1\n                ? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total })\n                : t('kProfiles.modal.syncing')}\n            </p>\n            <p className=\"text-bambu-gray text-sm mt-1\">{t('kProfiles.modal.pleaseWait')}</p>\n          </div>\n        )}\n        <CardContent className=\"p-0\">\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <h2 className=\"text-xl font-semibold text-white\">\n              {profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}\n            </h2>\n            <button\n              onClick={onClose}\n              className=\"text-bambu-gray hover:text-white transition-colors\"\n              disabled={isSyncing}\n            >\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          <form onSubmit={handleSubmit} className=\"p-4 space-y-4\">\n            {/* Profile Name - read-only when editing */}\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('kProfiles.modal.profileName')}</label>\n              <input\n                type=\"text\"\n                value={name}\n                onChange={(e) => setName(e.target.value)}\n                disabled={!!profile}\n                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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}\n                placeholder={t('kProfiles.modal.profileNamePlaceholder')}\n                required={!profile}\n              />\n            </div>\n\n            {/* K-Value - always editable */}\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('kProfiles.modal.kValue')}</label>\n              <input\n                type=\"text\"\n                inputMode=\"decimal\"\n                value={kValue}\n                onChange={(e) => {\n                  // Allow typing any decimal value\n                  const val = e.target.value;\n                  if (val === '' || /^\\d*\\.?\\d*$/.test(val)) {\n                    setKValue(val);\n                  }\n                }}\n                onBlur={(e) => {\n                  // Format to 3 decimal places on blur\n                  const num = parseFloat(e.target.value);\n                  if (!isNaN(num)) {\n                    setKValue((Math.trunc(num * 1000) / 1000).toFixed(3));\n                  }\n                }}\n                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 font-mono\"\n                placeholder={t('kProfiles.modal.kValuePlaceholder')}\n                required\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">\n                {t('kProfiles.modal.kValueHelp')}\n              </p>\n            </div>\n\n            {/* Filament - read-only when editing */}\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('kProfiles.modal.filament')}</label>\n              <select\n                value={filamentId}\n                onChange={(e) => {\n                  const newFilamentId = e.target.value;\n                  setFilamentId(newFilamentId);\n                  // Auto-generate profile name when filament is selected (for new profiles)\n                  // Only auto-generate if name is empty - don't overwrite user input\n                  if (!profile && newFilamentId && !name) {\n                    const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);\n                    if (selectedFilament) {\n                      const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';\n                      setName(`${flowLabel} ${selectedFilament.name}`);\n                    }\n                  }\n                }}\n                disabled={!!profile}\n                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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}\n                required={!profile}\n              >\n                <option value=\"\">{t('kProfiles.modal.selectFilament')}</option>\n                {/* Show current filament when editing - look up from knownFilaments */}\n                {profile?.filament_id && (\n                  <option key={profile.filament_id} value={profile.filament_id}>\n                    {knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}\n                  </option>\n                )}\n                {/* Show known filaments from existing K-profiles (for new profiles) */}\n                {!profile && knownFilaments.map((f) => (\n                  <option key={f.id} value={f.id}>\n                    {f.name}\n                  </option>\n                ))}\n              </select>\n              {!profile && knownFilaments.length === 0 && (\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('kProfiles.modal.noFilamentsHelp')}\n                </p>\n              )}\n            </div>\n\n            {/* Flow Type and Nozzle Size - read-only when editing */}\n            <div className=\"grid grid-cols-2 gap-4\">\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('kProfiles.modal.flowType')}</label>\n                <select\n                  value={nozzleType}\n                  onChange={(e) => {\n                    const newNozzleType = e.target.value;\n                    setNozzleType(newNozzleType);\n                    // Update profile name when flow type changes (for new profiles)\n                    // Only auto-generate if name is empty - don't overwrite user input\n                    if (!profile && filamentId && !name) {\n                      const selectedFilament = knownFilaments.find(f => f.id === filamentId);\n                      if (selectedFilament) {\n                        const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';\n                        setName(`${flowLabel} ${selectedFilament.name}`);\n                      }\n                    }\n                  }}\n                  disabled={!!profile}\n                  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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}\n                >\n                  <option value=\"HH00\">{t('kProfiles.modal.highFlow')}</option>\n                  <option value=\"HS00\">{t('kProfiles.modal.standard')}</option>\n                </select>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('kProfiles.modal.nozzleSize')}</label>\n                <select\n                  value={modalDiameter}\n                  onChange={(e) => setModalDiameter(e.target.value)}\n                  disabled={!!profile}\n                  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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}\n                >\n                  <option value=\"0.2\">0.2mm</option>\n                  <option value=\"0.4\">0.4mm</option>\n                  <option value=\"0.6\">0.6mm</option>\n                  <option value=\"0.8\">0.8mm</option>\n                </select>\n              </div>\n            </div>\n\n            {/* Extruder - only show for dual-nozzle printers */}\n            {isDualNozzle && (\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {profile ? t('kProfiles.modal.extruder') : t('kProfiles.modal.extruders')}\n                </label>\n                {profile ? (\n                  // Read-only display for editing\n                  <div className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60\">\n                    {profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}\n                  </div>\n                ) : (\n                  // Checkboxes for new profile - can select both\n                  <div className=\"flex gap-4\">\n                    <label className=\"flex items-center gap-2 cursor-pointer\">\n                      <input\n                        type=\"checkbox\"\n                        checked={selectedExtruders.includes(1)}\n                        onChange={(e) => {\n                          if (e.target.checked) {\n                            setSelectedExtruders([...selectedExtruders, 1]);\n                          } else {\n                            setSelectedExtruders(selectedExtruders.filter(id => id !== 1));\n                          }\n                        }}\n                        className=\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green\"\n                      />\n                      <span className=\"text-white\">{t('kProfiles.modal.left')}</span>\n                    </label>\n                    <label className=\"flex items-center gap-2 cursor-pointer\">\n                      <input\n                        type=\"checkbox\"\n                        checked={selectedExtruders.includes(0)}\n                        onChange={(e) => {\n                          if (e.target.checked) {\n                            setSelectedExtruders([...selectedExtruders, 0]);\n                          } else {\n                            setSelectedExtruders(selectedExtruders.filter(id => id !== 0));\n                          }\n                        }}\n                        className=\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green\"\n                      />\n                      <span className=\"text-white\">{t('kProfiles.modal.right')}</span>\n                    </label>\n                  </div>\n                )}\n              </div>\n            )}\n\n            {/* Notes */}\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('kProfiles.modal.notes')}</label>\n              <textarea\n                value={note}\n                onChange={(e) => setNote(e.target.value)}\n                placeholder={t('kProfiles.modal.notesPlaceholder')}\n                rows={2}\n                className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none\"\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">\n                {t('kProfiles.modal.notesHelp')}\n              </p>\n            </div>\n\n            <div className=\"flex gap-2 pt-4\">\n              {profile && (\n                <Button\n                  type=\"button\"\n                  variant=\"secondary\"\n                  onClick={() => setShowDeleteConfirm(true)}\n                  disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}\n                  title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}\n                  className=\"text-red-500 hover:bg-red-500/10\"\n                >\n                  {deleteMutation.isPending ? (\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  ) : (\n                    <Trash2 className=\"w-4 h-4\" />\n                  )}\n                </Button>\n              )}\n              <Button\n                type=\"button\"\n                variant=\"secondary\"\n                onClick={onClose}\n                disabled={isSyncing}\n                className=\"flex-1\"\n              >\n                {t('common.cancel')}\n              </Button>\n              <Button\n                type=\"submit\"\n                disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}\n                title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? t(profile ? 'kProfiles.permission.noUpdate' : 'kProfiles.permission.noCreate') : undefined}\n                className=\"flex-1\"\n              >\n                {saveMutation.isPending ? (\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                ) : (\n                  <Gauge className=\"w-4 h-4\" />\n                )}\n                {t('common.save')}\n              </Button>\n            </div>\n          </form>\n        </CardContent>\n      </Card>\n\n      {/* Delete Confirmation Modal */}\n      {showDeleteConfirm && (\n        <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-[60]\">\n          <Card className=\"w-full max-w-sm\">\n            <CardContent className=\"p-6\">\n              <div className=\"flex items-center gap-3 mb-4\">\n                <div className=\"w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center\">\n                  <Trash2 className=\"w-5 h-5 text-red-500\" />\n                </div>\n                <div>\n                  <h3 className=\"text-lg font-semibold text-white\">{t('kProfiles.deleteConfirm.title')}</h3>\n                  <p className=\"text-sm text-bambu-gray\">{t('kProfiles.deleteConfirm.cannotUndo')}</p>\n                </div>\n              </div>\n              <p className=\"text-bambu-gray mb-6\">\n                {t('kProfiles.deleteConfirm.message', { name: profile?.name })}\n              </p>\n              <div className=\"flex gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => setShowDeleteConfirm(false)}\n                  className=\"flex-1\"\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  onClick={() => {\n                    setShowDeleteConfirm(false);\n                    handleDelete();\n                  }}\n                  disabled={deleteMutation.isPending}\n                  className=\"flex-1 bg-red-500 hover:bg-red-600 text-white\"\n                >\n                  {deleteMutation.isPending ? (\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  ) : (\n                    <Trash2 className=\"w-4 h-4\" />\n                  )}\n                  {t('common.delete')}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n    </div>\n  );\n}\n\ntype ExtruderFilter = 'all' | 'left' | 'right';\ntype FlowTypeFilter = 'all' | 'hf' | 's';\ntype SortOption = 'name' | 'k_value' | 'filament';\n\n// localStorage keys\nconst STORAGE_KEYS = {\n  NOZZLE_DIAMETER: 'bambusy_kprofiles_nozzle',\n  SORT_OPTION: 'bambusy_kprofiles_sort',\n};\n\nexport function KProfilesView() {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);\n  // Load nozzle diameter from localStorage\n  const [nozzleDiameter, setNozzleDiameter] = useState(() => {\n    const saved = localStorage.getItem(STORAGE_KEYS.NOZZLE_DIAMETER);\n    return saved || '0.4';\n  });\n  const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);\n  const [showAddModal, setShowAddModal] = useState(false);\n  const [copyingProfile, setCopyingProfile] = useState<KProfile | null>(null);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');\n  const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');\n  // Load sort option from localStorage\n  const [sortOption, setSortOption] = useState<SortOption>(() => {\n    const saved = localStorage.getItem(STORAGE_KEYS.SORT_OPTION);\n    return (saved as SortOption) || 'name';\n  });\n  // Bulk selection mode\n  // Use composite key: `${slot_id}_${extruder_id}` since slot_id alone is not unique across extruders\n  const [selectionMode, setSelectionMode] = useState(false);\n  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());\n  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);\n  const [bulkDeleteInProgress, setBulkDeleteInProgress] = useState(false);\n\n  // Helper to create unique profile key for selection - wrapped in useCallback to prevent re-renders\n  const getProfileKey = useCallback((profile: KProfile) => `${profile.slot_id}_${profile.extruder_id}`, []);\n\n  // Save nozzle diameter to localStorage when it changes\n  useEffect(() => {\n    localStorage.setItem(STORAGE_KEYS.NOZZLE_DIAMETER, nozzleDiameter);\n  }, [nozzleDiameter]);\n\n  // Save sort option to localStorage when it changes\n  useEffect(() => {\n    localStorage.setItem(STORAGE_KEYS.SORT_OPTION, sortOption);\n  }, [sortOption]);\n\n  // Get available printers\n  const { data: printers, isLoading: printersLoading } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  // Get K-profiles for selected printer (filtered by nozzle diameter)\n  const {\n    data: kprofiles,\n    isLoading: kprofilesLoading,\n    isFetching,\n    error: kprofilesError,\n    refetch: refetchProfiles,\n  } = useQuery({\n    queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],\n    queryFn: async () => {\n      console.log('[KProfiles] Fetching profiles for printer', selectedPrinter, 'nozzle', nozzleDiameter);\n      const result = await api.getKProfiles(selectedPrinter!, nozzleDiameter);\n      console.log('[KProfiles] Received profiles:', result?.profiles?.length || 0, 'profiles');\n      return result;\n    },\n    enabled: !!selectedPrinter,\n    retry: false,\n    staleTime: 0,  // Always consider data stale to ensure fresh fetch\n    gcTime: 0,  // Don't cache results\n    refetchOnMount: 'always',  // Always refetch when component mounts\n  });\n\n  // Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)\n  const { data: allProfiles } = useQuery({\n    queryKey: ['kprofiles', selectedPrinter, '0.4'],\n    queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),\n    enabled: !!selectedPrinter,\n    staleTime: 60000,  // Cache for 1 minute\n  });\n\n  // Fetch builtin filament names for accurate filament_id → name resolution\n  const { data: builtinFilaments } = useQuery({\n    queryKey: ['builtinFilaments'],\n    queryFn: () => api.getBuiltinFilaments(),\n    staleTime: 300000,  // Cache for 5 minutes (static data)\n  });\n\n  // Fetch filament_id → name mapping for user cloud presets (P* IDs)\n  const { data: filamentIdMap } = useQuery({\n    queryKey: ['filamentIdMap'],\n    queryFn: () => api.getFilamentIdMap(),\n    staleTime: 300000,  // Cache for 5 minutes\n  });\n\n  // Fetch K-profile notes (stored locally)\n  const {\n    data: notesData,\n    refetch: refetchNotes,\n  } = useQuery({\n    queryKey: ['kprofile-notes', selectedPrinter],\n    queryFn: () => api.getKProfileNotes(selectedPrinter!),\n    enabled: !!selectedPrinter,\n    staleTime: 30000,  // Cache for 30 seconds\n  });\n\n  // Check if error is due to printer not being connected\n  const isOfflineError = kprofilesError?.message?.includes('not connected');\n\n  // Auto-select first connected printer\n  useEffect(() => {\n    if (!selectedPrinter && printers && printers.length > 0) {\n      const activePrinter = printers.find((p) => p.is_active);\n      if (activePrinter) {\n        setSelectedPrinter(activePrinter.id);\n      }\n    }\n  }, [selectedPrinter, printers]);\n\n  // Refetch profiles when printer selection changes\n  useEffect(() => {\n    if (selectedPrinter) {\n      // Delay refetch to ensure query is enabled after state update\n      const timer = setTimeout(() => {\n        refetchProfiles();\n      }, 150);\n      return () => clearTimeout(timer);\n    }\n  }, [selectedPrinter, nozzleDiameter]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // Get connected printers for display\n  const connectedPrinters = printers?.filter((p) => p.is_active) || [];\n\n  // Build filament lookup for name resolution (builtin + user cloud presets)\n  const builtinFilamentMap = React.useMemo(() => {\n    const map = new Map<string, string>();\n    if (builtinFilaments) {\n      for (const bf of builtinFilaments) {\n        map.set(bf.filament_id, bf.name);\n      }\n    }\n    // Also add user cloud presets (P* filament_ids resolved from cloud details)\n    if (filamentIdMap) {\n      for (const [fid, name] of Object.entries(filamentIdMap)) {\n        if (!map.has(fid)) {\n          map.set(fid, name);\n        }\n      }\n    }\n    return map;\n  }, [builtinFilaments, filamentIdMap]);\n\n  // Enriched builtin filaments array (builtin + cloud presets merged)\n  // Pass this to modals so they have the full filament name lookup\n  const enrichedBuiltinFilaments = React.useMemo(() => {\n    return Array.from(builtinFilamentMap.entries()).map(([fid, name]) => ({\n      filament_id: fid,\n      name,\n    }));\n  }, [builtinFilamentMap]);\n\n  // Resolve filament name: builtin table first, then extract from profile name\n  const resolveFilamentName = React.useCallback((profile: KProfile) => {\n    return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);\n  }, [builtinFilamentMap]);\n\n  // Filter and sort profiles\n  // Note: nozzle diameter filtering is done server-side via MQTT request\n  const filteredProfiles = React.useMemo(() => {\n    if (!kprofiles?.profiles) return [];\n\n    const filtered = kprofiles.profiles.filter((p) => {\n      // Search filter - match name or filament_id (case-insensitive)\n      const query = searchQuery.toLowerCase();\n      const matchesSearch =\n        !query ||\n        p.name.toLowerCase().includes(query) ||\n        p.filament_id.toLowerCase().includes(query);\n\n      // Extruder filter\n      const matchesExtruder =\n        extruderFilter === 'all' ||\n        (extruderFilter === 'left' && p.extruder_id === 1) ||\n        (extruderFilter === 'right' && p.extruder_id === 0);\n\n      // Flow type filter (HH = High Flow, HS = Standard)\n      const matchesFlowType =\n        flowTypeFilter === 'all' ||\n        (flowTypeFilter === 'hf' && p.nozzle_id.startsWith('HH')) ||\n        (flowTypeFilter === 's' && p.nozzle_id.startsWith('HS'));\n\n      return matchesSearch && matchesExtruder && matchesFlowType;\n    });\n\n    // Sort profiles\n    return filtered.sort((a, b) => {\n      switch (sortOption) {\n        case 'k_value':\n          return parseFloat(a.k_value) - parseFloat(b.k_value);\n        case 'filament':\n          return resolveFilamentName(a).localeCompare(resolveFilamentName(b));\n        case 'name':\n        default:\n          return a.name.localeCompare(b.name);\n      }\n    });\n  }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption, resolveFilamentName]);\n\n  // Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)\n  const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);\n  const isDualNozzle = selectedPrinterData?.nozzle_count === 2;\n\n  // Keyboard shortcuts\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Don't trigger shortcuts when typing in input fields\n      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) {\n        return;\n      }\n      // Don't trigger when modal is open\n      if (editingProfile || showAddModal || copyingProfile) {\n        return;\n      }\n\n      if (e.key === 'r' || e.key === 'R') {\n        e.preventDefault();\n        refetchProfiles();\n      } else if (e.key === 'n' || e.key === 'N') {\n        e.preventDefault();\n        setShowAddModal(true);\n      } else if (e.key === 'Escape' && selectionMode) {\n        e.preventDefault();\n        setSelectionMode(false);\n        setSelectedProfiles(new Set());\n      }\n    };\n\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [editingProfile, showAddModal, copyingProfile, selectionMode, refetchProfiles]);\n\n  // Export profiles to JSON file\n  const handleExport = useCallback(() => {\n    if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {\n      showToast(t('kProfiles.toast.noProfilesToExport'), 'error');\n      return;\n    }\n\n    const exportData = {\n      version: 1,\n      exported_at: new Date().toISOString(),\n      printer: selectedPrinterData?.name || 'Unknown',\n      nozzle_diameter: nozzleDiameter,\n      profiles: kprofiles.profiles.map(p => ({\n        name: p.name,\n        k_value: p.k_value,\n        filament_id: p.filament_id,\n        nozzle_id: p.nozzle_id,\n        nozzle_diameter: p.nozzle_diameter,\n        extruder_id: p.extruder_id,\n      })),\n    };\n\n    const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = `kprofiles_${selectedPrinterData?.name || 'printer'}_${nozzleDiameter}mm_${new Date().toISOString().split('T')[0]}.json`;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    URL.revokeObjectURL(url);\n    showToast(t('kProfiles.toast.exportedProfiles', { count: kprofiles.profiles.length }));\n  }, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast, t]);\n\n  // Import profiles from JSON file\n  const handleImport = useCallback(() => {\n    const input = document.createElement('input');\n    input.type = 'file';\n    input.accept = '.json';\n    input.onchange = async (e) => {\n      const file = (e.target as HTMLInputElement).files?.[0];\n      if (!file) return;\n\n      try {\n        const text = await file.text();\n        const data = JSON.parse(text);\n\n        if (!data.profiles || !Array.isArray(data.profiles)) {\n          showToast(t('kProfiles.toast.invalidFileFormat'), 'error');\n          return;\n        }\n\n        // Import profiles one by one\n        let imported = 0;\n        for (const p of data.profiles) {\n          if (!p.name || !p.k_value || !p.filament_id) continue;\n\n          try {\n            await api.setKProfile(selectedPrinter!, {\n              name: p.name,\n              k_value: parseFloat(p.k_value).toFixed(6),\n              filament_id: p.filament_id,\n              nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,\n              nozzle_diameter: p.nozzle_diameter || nozzleDiameter,\n              extruder_id: p.extruder_id ?? 0,\n              slot_id: 0, // Always create new\n            });\n            imported++;\n            // Small delay between imports\n            await new Promise(resolve => setTimeout(resolve, 500));\n          } catch (err) {\n            console.error('Failed to import profile:', p.name, err);\n          }\n        }\n\n        showToast(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));\n        refetchProfiles();\n      } catch (err) {\n        console.error('Import error:', err);\n        showToast(t('kProfiles.toast.failedToParseImport'), 'error');\n      }\n    };\n    input.click();\n  }, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles, t]);\n\n  // Toggle profile selection using composite key\n  const toggleProfileSelection = useCallback((profileKey: string) => {\n    setSelectedProfiles(prev => {\n      const next = new Set(prev);\n      if (next.has(profileKey)) {\n        next.delete(profileKey);\n      } else {\n        next.add(profileKey);\n      }\n      return next;\n    });\n  }, []);\n\n  // Select all visible profiles\n  const selectAllProfiles = useCallback(() => {\n    setSelectedProfiles(new Set(filteredProfiles.map(p => getProfileKey(p))));\n  }, [filteredProfiles, getProfileKey]);\n\n  // Delete selected profiles\n  const handleBulkDelete = useCallback(() => {\n    if (selectedProfiles.size === 0) return;\n    setShowBulkDeleteConfirm(true);\n  }, [selectedProfiles.size]);\n\n  // Execute the actual bulk delete\n  const executeBulkDelete = useCallback(async () => {\n    const profilesToDelete = filteredProfiles.filter(p => selectedProfiles.has(getProfileKey(p)));\n    setBulkDeleteInProgress(true);\n\n    let deleted = 0;\n    for (const profile of profilesToDelete) {\n      try {\n        await api.deleteKProfile(selectedPrinter!, {\n          slot_id: profile.slot_id,\n          extruder_id: profile.extruder_id,\n          nozzle_id: profile.nozzle_id,\n          nozzle_diameter: profile.nozzle_diameter,\n          filament_id: profile.filament_id,\n          setting_id: profile.setting_id,\n        });\n        deleted++;\n        // Small delay between deletes\n        await new Promise(resolve => setTimeout(resolve, 300));\n      } catch (err) {\n        console.error('Failed to delete profile:', profile.name, err);\n      }\n    }\n\n    showToast(t('kProfiles.toast.profilesDeleted', { count: deleted }));\n    setBulkDeleteInProgress(false);\n    setShowBulkDeleteConfirm(false);\n    setSelectionMode(false);\n    setSelectedProfiles(new Set());\n    refetchProfiles();\n  }, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey, t]);\n\n  // Generate possible keys for a profile (for notes lookup)\n  // Returns array of keys to check: setting_id, slot-based, name-based\n  const getProfileKeys = useCallback((profile: KProfile): string[] => {\n    const keys: string[] = [];\n    if (profile.setting_id) {\n      keys.push(profile.setting_id);\n    }\n    // Slot-based key (for profiles without setting_id)\n    keys.push(`slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`);\n    // Name-based key (for newly created profiles)\n    keys.push(`name_${profile.name}_${profile.filament_id}`);\n    return keys;\n  }, []);\n\n  // Save note for a profile\n  const handleSaveNote = useCallback(async (profileKey: string, noteText: string) => {\n    if (!selectedPrinter) return;\n    try {\n      await api.setKProfileNote(selectedPrinter, profileKey, noteText);\n      refetchNotes();\n    } catch (err) {\n      console.error('Failed to save note:', err);\n      showToast(t('kProfiles.toast.failedToSaveNote'), 'error');\n    }\n  }, [selectedPrinter, refetchNotes, showToast, t]);\n\n  // Get note for a profile (checks all possible keys)\n  // Returns { note, key } so we know which key the note was stored under\n  const getNoteWithKey = useCallback((profile: KProfile): { note: string; key: string | null } => {\n    if (!notesData?.notes) return { note: '', key: null };\n    const keys = getProfileKeys(profile);\n    for (const key of keys) {\n      if (notesData.notes[key]) {\n        return { note: notesData.notes[key], key };\n      }\n    }\n    return { note: '', key: null };\n  }, [notesData, getProfileKeys]);\n\n  // Simple getter for display purposes\n  const getNote = useCallback((profile: KProfile) => {\n    return getNoteWithKey(profile).note;\n  }, [getNoteWithKey]);\n\n  if (printersLoading) {\n    return (\n      <div className=\"flex justify-center py-12\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  if (!printers || printers.length === 0) {\n    return (\n      <Card>\n        <CardContent className=\"py-12 text-center\">\n          <AlertCircle className=\"w-12 h-12 text-bambu-gray mx-auto mb-4\" />\n          <h3 className=\"text-lg font-semibold text-white mb-2\">{t('kProfiles.noPrintersConfigured')}</h3>\n          <p className=\"text-bambu-gray\">\n            {t('kProfiles.addPrinterInSettings')}\n          </p>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  if (connectedPrinters.length === 0) {\n    return (\n      <Card>\n        <CardContent className=\"py-12 text-center\">\n          <Printer className=\"w-12 h-12 text-bambu-gray mx-auto mb-4\" />\n          <h3 className=\"text-lg font-semibold text-white mb-2\">{t('kProfiles.noActivePrinters')}</h3>\n          <p className=\"text-bambu-gray\">\n            {t('kProfiles.enablePrinterConnection')}\n          </p>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  return (\n    <>\n      {/* Loading overlay when refetching profiles (not initial load) */}\n      {isFetching && !kprofilesLoading && (\n        <div className=\"fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40\">\n          <Loader2 className=\"w-10 h-10 text-bambu-green animate-spin mb-3\" />\n          <p className=\"text-white font-medium\">{t('kProfiles.loadingProfiles')}</p>\n        </div>\n      )}\n\n      {/* Printer & Nozzle Selector */}\n      <div className=\"flex flex-wrap gap-4 mb-6\">\n        <div className=\"flex-1 min-w-48\">\n          <label className=\"block text-sm text-bambu-gray mb-1\">{t('kProfiles.printer')}</label>\n          <select\n            value={selectedPrinter || ''}\n            onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}\n            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\"\n          >\n            {connectedPrinters.map((printer) => (\n              <option key={printer.id} value={printer.id}>\n                {printer.name}\n              </option>\n            ))}\n          </select>\n        </div>\n\n        <div className=\"w-32\">\n          <label className=\"block text-sm text-bambu-gray mb-1\">{t('kProfiles.nozzle')}</label>\n          <select\n            value={nozzleDiameter}\n            onChange={(e) => setNozzleDiameter(e.target.value)}\n            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\"\n          >\n            <option value=\"0.2\">0.2mm</option>\n            <option value=\"0.4\">0.4mm</option>\n            <option value=\"0.6\">0.6mm</option>\n            <option value=\"0.8\">0.8mm</option>\n          </select>\n        </div>\n\n        <div className=\"flex items-end gap-2\">\n          <Button\n            variant=\"secondary\"\n            onClick={() => refetchProfiles()}\n            disabled={isFetching || !hasPermission('kprofiles:read')}\n            title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noRead') : undefined}\n          >\n            <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />\n            {t('kProfiles.refresh')}\n          </Button>\n          <Button\n            onClick={() => setShowAddModal(true)}\n            disabled={!hasPermission('kprofiles:create')}\n            title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noCreate') : undefined}\n          >\n            <Plus className=\"w-4 h-4\" />\n            {t('kProfiles.addProfile')}\n          </Button>\n        </div>\n      </div>\n\n      {/* Search & Filter Row */}\n      <div className=\"flex flex-wrap gap-4 mb-4\">\n        <div className=\"flex-1 min-w-48 relative\">\n          <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n          <input\n            type=\"text\"\n            value={searchQuery}\n            onChange={(e) => setSearchQuery(e.target.value)}\n            placeholder={t('kProfiles.searchPlaceholder')}\n            className=\"w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n          />\n        </div>\n        {isDualNozzle && (\n          <div className=\"w-36\">\n            <select\n              value={extruderFilter}\n              onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}\n              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\"\n            >\n              <option value=\"all\">{t('kProfiles.allExtruders')}</option>\n              <option value=\"left\">{t('kProfiles.leftOnly')}</option>\n              <option value=\"right\">{t('kProfiles.rightOnly')}</option>\n            </select>\n          </div>\n        )}\n        <div className=\"w-32\">\n          <select\n            value={flowTypeFilter}\n            onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}\n            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\"\n          >\n            <option value=\"all\">{t('kProfiles.allFlow')}</option>\n            <option value=\"hf\">{t('kProfiles.hfOnly')}</option>\n            <option value=\"s\">{t('kProfiles.sOnly')}</option>\n          </select>\n        </div>\n        <div className=\"w-32\">\n          <select\n            value={sortOption}\n            onChange={(e) => setSortOption(e.target.value as SortOption)}\n            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\"\n          >\n            <option value=\"name\">{t('kProfiles.sortName')}</option>\n            <option value=\"k_value\">{t('kProfiles.sortKValue')}</option>\n            <option value=\"filament\">{t('kProfiles.sortFilament')}</option>\n          </select>\n        </div>\n      </div>\n\n      {/* Toolbar Row */}\n      <div className=\"flex flex-wrap gap-2 mb-6\">\n        <Button\n          variant=\"secondary\"\n          onClick={handleExport}\n          disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}\n          title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noExport') : undefined}\n        >\n          <Download className=\"w-4 h-4\" />\n          {t('kProfiles.export')}\n        </Button>\n        <Button\n          variant=\"secondary\"\n          onClick={handleImport}\n          disabled={!hasPermission('kprofiles:create')}\n          title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noImport') : undefined}\n        >\n          <Upload className=\"w-4 h-4\" />\n          {t('kProfiles.import')}\n        </Button>\n        <div className=\"flex-1\" />\n        {selectionMode ? (\n          <>\n            <Button\n              variant=\"secondary\"\n              onClick={selectAllProfiles}\n            >\n              <CheckSquare className=\"w-4 h-4\" />\n              {t('kProfiles.selectAll')}\n            </Button>\n            <Button\n              variant=\"secondary\"\n              onClick={handleBulkDelete}\n              disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}\n              className=\"text-red-500 hover:bg-red-500/10\"\n              title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}\n            >\n              <Trash2 className=\"w-4 h-4\" />\n              {t('kProfiles.delete')} ({selectedProfiles.size})\n            </Button>\n            <Button\n              variant=\"secondary\"\n              onClick={() => {\n                setSelectionMode(false);\n                setSelectedProfiles(new Set());\n              }}\n            >\n              <X className=\"w-4 h-4\" />\n              {t('common.cancel')}\n            </Button>\n          </>\n        ) : (\n          <Button\n            variant=\"secondary\"\n            onClick={() => setSelectionMode(true)}\n            disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}\n            title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}\n          >\n            <CheckSquare className=\"w-4 h-4\" />\n            {t('kProfiles.select')}\n          </Button>\n        )}\n      </div>\n\n      {/* K-Profiles Grid */}\n      {kprofilesLoading ? (\n        <div className=\"flex justify-center py-12\">\n          <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n        </div>\n      ) : isOfflineError ? (\n        <Card>\n          <CardContent className=\"py-12 text-center\">\n            <WifiOff className=\"w-12 h-12 text-bambu-gray mx-auto mb-4\" />\n            <h3 className=\"text-lg font-semibold text-white mb-2\">{t('kProfiles.printerOffline')}</h3>\n            <p className=\"text-bambu-gray mb-4\">\n              {t('kProfiles.printerOfflineDesc')}\n            </p>\n            <Button variant=\"secondary\" onClick={() => refetchProfiles()}>\n              <RefreshCw className=\"w-4 h-4\" />\n              {t('common.refresh')}\n            </Button>\n          </CardContent>\n        </Card>\n      ) : filteredProfiles.length > 0 ? (\n        isDualNozzle ? (\n          // Dual-nozzle: show Left/Right columns\n          <div className=\"grid grid-cols-2 gap-4\">\n            {/* Left Extruder (extruder_id 1 on Bambu) */}\n            <div>\n              <h3 className=\"text-sm font-medium text-bambu-gray mb-2 px-1\">{t('kProfiles.leftExtruder')}</h3>\n              <div className=\"space-y-1\">\n                {filteredProfiles\n                  .filter((p) => p.extruder_id === 1)\n                  .map((profile) => (\n                    <KProfileCard\n                      key={getProfileKey(profile)}\n                      profile={profile}\n                      onEdit={() => setEditingProfile(profile)}\n                      onCopy={() => setCopyingProfile(profile)}\n                      selectionMode={selectionMode}\n                      isSelected={selectedProfiles.has(getProfileKey(profile))}\n                      onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}\n                      note={getNote(profile)}\n                    />\n                  ))}\n              </div>\n            </div>\n            {/* Right Extruder (extruder_id 0 on Bambu) */}\n            <div>\n              <h3 className=\"text-sm font-medium text-bambu-gray mb-2 px-1\">{t('kProfiles.rightExtruder')}</h3>\n              <div className=\"space-y-1\">\n                {filteredProfiles\n                  .filter((p) => p.extruder_id === 0)\n                  .map((profile) => (\n                    <KProfileCard\n                      key={getProfileKey(profile)}\n                      profile={profile}\n                      onEdit={() => setEditingProfile(profile)}\n                      onCopy={() => setCopyingProfile(profile)}\n                      selectionMode={selectionMode}\n                      isSelected={selectedProfiles.has(getProfileKey(profile))}\n                      onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}\n                      note={getNote(profile)}\n                    />\n                  ))}\n              </div>\n            </div>\n          </div>\n        ) : (\n          // Single-nozzle: show all profiles in one list\n          <div className=\"space-y-1\">\n            {filteredProfiles.map((profile) => (\n              <KProfileCard\n                key={getProfileKey(profile)}\n                profile={profile}\n                onEdit={() => setEditingProfile(profile)}\n                onCopy={() => setCopyingProfile(profile)}\n                selectionMode={selectionMode}\n                isSelected={selectedProfiles.has(getProfileKey(profile))}\n                onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}\n                note={getNote(profile)}\n              />\n            ))}\n          </div>\n        )\n      ) : searchQuery || extruderFilter !== 'all' || flowTypeFilter !== 'all' ? (\n        <Card>\n          <CardContent className=\"py-12 text-center\">\n            <Search className=\"w-12 h-12 text-bambu-gray mx-auto mb-4\" />\n            <h3 className=\"text-lg font-semibold text-white mb-2\">{t('kProfiles.noMatchingProfiles')}</h3>\n            <p className=\"text-bambu-gray\">\n              {t('kProfiles.noMatchingProfilesDesc')}\n            </p>\n          </CardContent>\n        </Card>\n      ) : (\n        <Card>\n          <CardContent className=\"py-12 text-center\">\n            <Gauge className=\"w-12 h-12 text-bambu-gray mx-auto mb-4\" />\n            <h3 className=\"text-lg font-semibold text-white mb-2\">{t('kProfiles.noKProfiles')}</h3>\n            <p className=\"text-bambu-gray mb-4\">\n              {t('kProfiles.noKProfilesDesc', { diameter: nozzleDiameter })}\n            </p>\n            <Button onClick={() => setShowAddModal(true)}>\n              <Plus className=\"w-4 h-4\" />\n              {t('kProfiles.createFirstProfile')}\n            </Button>\n          </CardContent>\n        </Card>\n      )}\n\n      {/* Edit Modal */}\n      {editingProfile && selectedPrinter && (() => {\n        const { note, key } = getNoteWithKey(editingProfile);\n        return (\n          <KProfileModal\n            profile={editingProfile}\n            printerId={selectedPrinter}\n            nozzleDiameter={nozzleDiameter}\n            existingProfiles={allProfiles?.profiles || kprofiles?.profiles}\n            builtinFilaments={enrichedBuiltinFilaments}\n            isDualNozzle={isDualNozzle}\n            initialNote={note}\n            initialNoteKey={key}\n            onSaveNote={handleSaveNote}\n            hasPermission={hasPermission}\n            onClose={() => {\n              console.log('[KProfiles] Edit modal onClose - refetching profiles...');\n              setEditingProfile(null);\n              refetchProfiles();  // Refetch after close (handles delete case)\n            }}\n            onSave={() => {\n              setEditingProfile(null);\n              refetchProfiles();\n            }}\n          />\n        );\n      })()}\n\n      {/* Add Modal */}\n      {showAddModal && selectedPrinter && (\n        <KProfileModal\n          printerId={selectedPrinter}\n          nozzleDiameter={nozzleDiameter}\n          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}\n          builtinFilaments={enrichedBuiltinFilaments}\n          isDualNozzle={isDualNozzle}\n          onSaveNote={handleSaveNote}\n          hasPermission={hasPermission}\n          onClose={() => {\n            setShowAddModal(false);\n            refetchProfiles();  // Refetch after close\n          }}\n          onSave={() => {\n            setShowAddModal(false);\n            refetchProfiles();\n          }}\n        />\n      )}\n\n      {/* Copy Modal - opens add modal with prefilled values from source profile */}\n      {copyingProfile && selectedPrinter && (\n        <KProfileModal\n          printerId={selectedPrinter}\n          nozzleDiameter={nozzleDiameter}\n          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}\n          builtinFilaments={enrichedBuiltinFilaments}\n          isDualNozzle={isDualNozzle}\n          onSaveNote={handleSaveNote}\n          hasPermission={hasPermission}\n          // Pass profile data but without slot_id to create a new profile\n          profile={{\n            ...copyingProfile,\n            slot_id: 0,  // Force new profile creation\n            name: `${copyingProfile.name} (Copy)`,  // Indicate it's a copy\n          }}\n          onClose={() => {\n            setCopyingProfile(null);\n            refetchProfiles();\n          }}\n          onSave={() => {\n            setCopyingProfile(null);\n            refetchProfiles();\n          }}\n        />\n      )}\n\n      {/* Bulk Delete Confirmation Modal */}\n      {showBulkDeleteConfirm && (\n        <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n          <Card className=\"w-full max-w-sm\">\n            <CardContent className=\"p-6\">\n              <div className=\"flex items-center gap-3 mb-4\">\n                <div className=\"w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center\">\n                  <Trash2 className=\"w-5 h-5 text-red-500\" />\n                </div>\n                <div>\n                  <h3 className=\"text-lg font-semibold text-white\">{t('kProfiles.bulkDelete.title')}</h3>\n                  <p className=\"text-sm text-bambu-gray\">{t('kProfiles.bulkDelete.cannotUndo')}</p>\n                </div>\n              </div>\n              <p className=\"text-bambu-gray mb-6\">\n                {t('kProfiles.bulkDelete.message', { count: selectedProfiles.size })}\n              </p>\n              <div className=\"flex gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => setShowBulkDeleteConfirm(false)}\n                  disabled={bulkDeleteInProgress}\n                  className=\"flex-1\"\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  onClick={executeBulkDelete}\n                  disabled={bulkDeleteInProgress}\n                  className=\"flex-1 bg-red-500 hover:bg-red-600 text-white\"\n                >\n                  {bulkDeleteInProgress ? (\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  ) : (\n                    <Trash2 className=\"w-4 h-4\" />\n                  )}\n                  {t('common.delete')}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/KeyboardShortcutsModal.tsx",
    "content": "import { useEffect } from 'react';\nimport { X, Keyboard, ExternalLink } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Card, CardContent } from './Card';\n\ninterface NavItem {\n  id: string;\n  to: string;\n  labelKey: string;\n}\n\ninterface SidebarItem {\n  type: 'nav' | 'external';\n  label: string;\n  labelKey?: string;\n}\n\ninterface KeyboardShortcutsModalProps {\n  onClose: () => void;\n  navItems?: NavItem[];\n  sidebarItems?: SidebarItem[];\n}\n\nfunction getShortcuts(\n  sidebarItems: SidebarItem[] | undefined,\n  navItems: NavItem[] | undefined,\n  t: (key: string) => string\n) {\n  // Use sidebarItems if provided (new format), otherwise fall back to navItems\n  const navShortcuts = sidebarItems\n    ? sidebarItems.slice(0, 9).map((item, index) => ({\n        keys: [String(index + 1)],\n        description: item.type === 'external'\n          ? `Open ${item.label}`\n          : `Go to ${item.labelKey ? t(item.labelKey) : item.label}`,\n        isExternal: item.type === 'external',\n      }))\n    : navItems\n    ? navItems.map((item, index) => ({\n        keys: [String(index + 1)],\n        description: `Go to ${t(item.labelKey)}`,\n        isExternal: false,\n      }))\n    : [\n        { keys: ['1'], description: 'Go to Printers', isExternal: false },\n        { keys: ['2'], description: 'Go to Archives', isExternal: false },\n        { keys: ['3'], description: 'Go to Queue', isExternal: false },\n        { keys: ['4'], description: 'Go to Statistics', isExternal: false },\n        { keys: ['5'], description: 'Go to Cloud Profiles', isExternal: false },\n        { keys: ['6'], description: 'Go to Settings', isExternal: false },\n      ];\n\n  return [\n    { category: 'Navigation', items: navShortcuts },\n    { category: 'Archives', items: [\n      { keys: ['/'], description: 'Focus search', isExternal: false },\n      { keys: ['U'], description: 'Open upload modal', isExternal: false },\n      { keys: ['Esc'], description: 'Clear selection / blur input', isExternal: false },\n      { keys: ['Right-click'], description: 'Context menu on cards', isExternal: false },\n    ]},\n    { category: 'K-Profiles', items: [\n      { keys: ['R'], description: 'Refresh profiles', isExternal: false },\n      { keys: ['N'], description: 'New profile', isExternal: false },\n      { keys: ['Esc'], description: 'Exit selection mode', isExternal: false },\n    ]},\n    { category: 'General', items: [\n      { keys: ['?'], description: 'Show this help', isExternal: false },\n    ]},\n  ];\n}\n\nfunction KeyBadge({ children }: { children: string }) {\n  return (\n    <kbd className=\"px-2 py-1 text-xs font-mono bg-bambu-dark border border-bambu-dark-tertiary rounded text-white\">\n      {children}\n    </kbd>\n  );\n}\n\nexport function KeyboardShortcutsModal({ onClose, navItems, sidebarItems }: KeyboardShortcutsModalProps) {\n  const { t } = useTranslation();\n  const shortcuts = getShortcuts(sidebarItems, navItems, t);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\" onClick={onClose}>\n      <Card className=\"w-full max-w-md\" onClick={(e) => e.stopPropagation()}>\n        <CardContent className=\"p-0\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <div className=\"flex items-center gap-2\">\n              <Keyboard className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-xl font-semibold text-white\">Keyboard Shortcuts</h2>\n            </div>\n            <button\n              onClick={onClose}\n              className=\"text-bambu-gray hover:text-white transition-colors\"\n            >\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Shortcuts List */}\n          <div className=\"p-4 space-y-6 max-h-[60vh] overflow-y-auto\">\n            {shortcuts.map((section) => (\n              <div key={section.category}>\n                <h3 className=\"text-sm font-medium text-bambu-gray mb-3\">{section.category}</h3>\n                <div className=\"space-y-2\">\n                  {section.items.map((shortcut) => (\n                    <div key={shortcut.description} className=\"flex items-center justify-between\">\n                      <span className=\"text-white text-sm flex items-center gap-1.5\">\n                        {shortcut.description}\n                        {shortcut.isExternal && (\n                          <ExternalLink className=\"w-3 h-3 text-bambu-gray\" />\n                        )}\n                      </span>\n                      <div className=\"flex gap-1\">\n                        {shortcut.keys.map((key) => (\n                          <KeyBadge key={key}>{key}</KeyBadge>\n                        ))}\n                      </div>\n                    </div>\n                  ))}\n                </div>\n              </div>\n            ))}\n          </div>\n\n          {/* Footer */}\n          <div className=\"p-4 border-t border-bambu-dark-tertiary\">\n            <p className=\"text-xs text-bambu-gray text-center\">\n              Press <KeyBadge>Esc</KeyBadge> or click outside to close\n            </p>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/LDAPSettings.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Shield, Lock, Unlock, AlertTriangle, CheckCircle, Loader2, Send } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { AppSettings } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { Collapsible } from './Collapsible';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\n\nconst SECURITY_PORT_MAP: Record<string, string> = {\n  starttls: '389',\n  ldaps: '636',\n};\n\ninterface LDAPFormState {\n  ldap_server_url: string;\n  ldap_bind_dn: string;\n  ldap_bind_password: string;\n  ldap_search_base: string;\n  ldap_user_filter: string;\n  ldap_security: string;\n  ldap_group_mapping: string;\n  ldap_auto_provision: boolean;\n  ldap_default_group: string;\n}\n\nexport function LDAPSettings() {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const queryClient = useQueryClient();\n  const { authEnabled } = useAuth();\n\n  const [form, setForm] = useState<LDAPFormState>({\n    ldap_server_url: '',\n    ldap_bind_dn: '',\n    ldap_bind_password: '',\n    ldap_search_base: '',\n    ldap_user_filter: '(sAMAccountName={username})',\n    ldap_security: 'starttls',\n    ldap_group_mapping: '',\n    ldap_auto_provision: false,\n    ldap_default_group: '',\n  });\n\n  // Fetch settings\n  const { data: settings, isLoading } = useQuery({\n    queryKey: ['settings'],\n    queryFn: () => api.getSettings(),\n  });\n\n  // Fetch LDAP status\n  const { data: ldapStatus } = useQuery({\n    queryKey: ['ldapStatus'],\n    queryFn: () => api.getLDAPStatus(),\n  });\n\n  // Fetch groups for mapping display\n  const { data: groups = [] } = useQuery({\n    queryKey: ['groups'],\n    queryFn: () => api.getGroups(),\n  });\n\n  // Load settings into form\n  useEffect(() => {\n    if (settings) {\n      setForm({\n        ldap_server_url: settings.ldap_server_url || '',\n        ldap_bind_dn: settings.ldap_bind_dn || '',\n        ldap_bind_password: '', // Never show password\n        ldap_search_base: settings.ldap_search_base || '',\n        ldap_user_filter: settings.ldap_user_filter || '(sAMAccountName={username})',\n        ldap_security: settings.ldap_security || 'starttls',\n        ldap_group_mapping: settings.ldap_group_mapping || '',\n        ldap_auto_provision: settings.ldap_auto_provision ?? false,\n        ldap_default_group: settings.ldap_default_group || '',\n      });\n    }\n  }, [settings]);\n\n  // Save settings\n  const saveMutation = useMutation({\n    mutationFn: (data: Partial<AppSettings>) => api.updateSettings(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['settings'] });\n      queryClient.invalidateQueries({ queryKey: ['ldapStatus'] });\n      showToast(t('settings.ldap.settingsSaved') || 'LDAP settings saved', 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // Toggle LDAP\n  const toggleMutation = useMutation({\n    mutationFn: (enabled: boolean) => api.updateSettings({ ldap_enabled: enabled }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['settings'] });\n      queryClient.invalidateQueries({ queryKey: ['ldapStatus'] });\n      showToast(\n        ldapStatus?.ldap_enabled\n          ? (t('settings.ldap.disabled') || 'LDAP authentication disabled')\n          : (t('settings.ldap.enabled') || 'LDAP authentication enabled'),\n        'success'\n      );\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // Test connection\n  const testMutation = useMutation({\n    mutationFn: () => api.testLDAP(),\n    onSuccess: (data: { success: boolean; message: string }) => {\n      showToast(data.message, data.success ? 'success' : 'error');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const handleSave = () => {\n    if (!form.ldap_server_url) {\n      showToast(t('settings.ldap.errors.serverRequired') || 'LDAP server URL is required', 'error');\n      return;\n    }\n    if (!form.ldap_search_base) {\n      showToast(t('settings.ldap.errors.searchBaseRequired') || 'Search base DN is required', 'error');\n      return;\n    }\n\n    // Build the update payload — only include password if user entered one\n    const update: Record<string, unknown> = {\n      ldap_server_url: form.ldap_server_url,\n      ldap_bind_dn: form.ldap_bind_dn,\n      ldap_search_base: form.ldap_search_base,\n      ldap_user_filter: form.ldap_user_filter,\n      ldap_security: form.ldap_security,\n      ldap_group_mapping: form.ldap_group_mapping,\n      ldap_auto_provision: form.ldap_auto_provision,\n      ldap_default_group: form.ldap_default_group,\n    };\n    if (form.ldap_bind_password) {\n      update.ldap_bind_password = form.ldap_bind_password;\n    }\n    saveMutation.mutate(update as Partial<AppSettings>);\n  };\n\n  const handleToggle = () => {\n    if (!authEnabled) {\n      showToast(t('settings.ldap.errors.enableAuthFirst') || 'Enable authentication first', 'error');\n      return;\n    }\n    if (!ldapStatus?.ldap_enabled && !ldapStatus?.ldap_configured) {\n      showToast(t('settings.ldap.errors.configureLdapFirst') || 'Save LDAP settings first', 'error');\n      return;\n    }\n    toggleMutation.mutate(!ldapStatus?.ldap_enabled);\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center p-12\">\n        <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  const ldapEnabled = ldapStatus?.ldap_enabled ?? false;\n  const inputClasses = \"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\";\n\n  return (\n    <div className=\"space-y-3\">\n      {/* LDAP Toggle */}\n      <Card id=\"card-ldap-toggle\">\n        <CardHeader>\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <Shield className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-lg font-semibold text-white\">\n                {t('settings.ldap.title') || 'LDAP Authentication'}\n              </h2>\n            </div>\n            <Button\n              onClick={handleToggle}\n              disabled={toggleMutation.isPending}\n              variant={ldapEnabled ? 'danger' : 'primary'}\n            >\n              {ldapEnabled ? (\n                <>\n                  <Unlock className=\"w-4 h-4\" />\n                  {t('common.disable') || 'Disable'}\n                </>\n              ) : (\n                <>\n                  <Lock className=\"w-4 h-4\" />\n                  {t('common.enable') || 'Enable'}\n                </>\n              )}\n            </Button>\n          </div>\n        </CardHeader>\n        <CardContent>\n          {ldapEnabled ? (\n            <div className=\"bg-green-500/10 border border-green-500/30 rounded-lg p-4\">\n              <div className=\"flex items-start gap-3\">\n                <CheckCircle className=\"w-5 h-5 text-green-400 mt-0.5 flex-shrink-0\" />\n                <div className=\"space-y-2\">\n                  <p className=\"text-white font-medium\">\n                    {t('settings.ldap.enabledDesc') || 'LDAP authentication is enabled'}\n                  </p>\n                  <ul className=\"text-sm text-green-300 space-y-1 list-disc list-inside\">\n                    <li>{t('settings.ldap.feature1') || 'Users can login with LDAP credentials'}</li>\n                    <li>{t('settings.ldap.feature2') || 'Local admin account remains as fallback'}</li>\n                    <li>{t('settings.ldap.feature3') || 'LDAP groups are mapped to BamBuddy groups on login'}</li>\n                  </ul>\n                </div>\n              </div>\n            </div>\n          ) : (\n            <div className=\"bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4\">\n              <div className=\"flex items-start gap-3\">\n                <AlertTriangle className=\"w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0\" />\n                <div>\n                  <p className=\"text-white font-medium\">\n                    {t('settings.ldap.disabledDesc') || 'LDAP authentication is disabled'}\n                  </p>\n                  <p className=\"text-sm text-yellow-300 mt-1\">\n                    {t('settings.ldap.disabledHint') || 'Configure and save LDAP settings below, then enable.'}\n                  </p>\n                </div>\n              </div>\n            </div>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* LDAP Server Configuration */}\n      <Card id=\"card-ldap-server\">\n        <CardHeader>\n          <h2 className=\"text-lg font-semibold text-white\">\n            {t('settings.ldap.serverConfig') || 'LDAP Server Configuration'}\n          </h2>\n        </CardHeader>\n        <CardContent>\n          <div className=\"space-y-3\">\n            {/* Server URL + Security (side by side) */}\n            <div className=\"grid grid-cols-1 md:grid-cols-3 gap-3\">\n              <div className=\"md:col-span-2\">\n                <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n                  {t('settings.ldap.serverUrl') || 'Server URL'}\n                </label>\n                <input\n                  type=\"text\"\n                  className={inputClasses}\n                  placeholder=\"ldaps://ldap.example.com:636\"\n                  value={form.ldap_server_url}\n                  onChange={e => setForm({ ...form, ldap_server_url: e.target.value })}\n                />\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.ldap.serverUrlHint') || 'Use ldaps:// for SSL or ldap:// with StartTLS'}\n                </p>\n              </div>\n              <div>\n                <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n                  {t('settings.ldap.security') || 'Security'}\n                </label>\n                <div className=\"flex gap-2\">\n                  {(['starttls', 'ldaps'] as const).map(sec => (\n                    <button\n                      key={sec}\n                      onClick={() => setForm({ ...form, ldap_security: sec })}\n                      className={`flex-1 px-2 py-2 rounded-lg text-sm font-medium transition-colors ${\n                        form.ldap_security === sec\n                          ? 'bg-bambu-green text-black'\n                          : 'bg-bambu-dark-secondary text-bambu-gray hover:text-white border border-bambu-dark-tertiary'\n                      }`}\n                    >\n                      {sec === 'starttls' ? 'StartTLS' : 'LDAPS'}\n                    </button>\n                  ))}\n                </div>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.ldap.securityHint') || `Default port: ${SECURITY_PORT_MAP[form.ldap_security]}`}\n                </p>\n              </div>\n            </div>\n\n            {/* Bind DN + Password (side by side) */}\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n              <div>\n                <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n                  {t('settings.ldap.bindDn') || 'Bind DN (Service Account)'}\n                </label>\n                <input\n                  type=\"text\"\n                  className={inputClasses}\n                  placeholder=\"cn=service-account,ou=service,dc=example,dc=com\"\n                  value={form.ldap_bind_dn}\n                  onChange={e => setForm({ ...form, ldap_bind_dn: e.target.value })}\n                />\n              </div>\n              <div>\n                <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n                  {t('settings.ldap.bindPassword') || 'Bind Password'}\n                </label>\n                <input\n                  type=\"password\"\n                  className={inputClasses}\n                  placeholder={settings?.ldap_bind_dn ? '••••••••' : ''}\n                  value={form.ldap_bind_password}\n                  onChange={e => setForm({ ...form, ldap_bind_password: e.target.value })}\n                />\n              </div>\n            </div>\n\n            {/* Search Base + User Filter (side by side) */}\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n              <div>\n                <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n                  {t('settings.ldap.searchBase') || 'Search Base DN'}\n                </label>\n                <input\n                  type=\"text\"\n                  className={inputClasses}\n                  placeholder=\"ou=users,dc=example,dc=com\"\n                  value={form.ldap_search_base}\n                  onChange={e => setForm({ ...form, ldap_search_base: e.target.value })}\n                />\n              </div>\n              <div>\n                <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n                  {t('settings.ldap.userFilter') || 'User Search Filter'}\n                </label>\n                <input\n                  type=\"text\"\n                  className={inputClasses}\n                  placeholder=\"(sAMAccountName={username})\"\n                  value={form.ldap_user_filter}\n                  onChange={e => setForm({ ...form, ldap_user_filter: e.target.value })}\n                />\n              </div>\n            </div>\n\n            {/* Advanced (collapsed by default) */}\n            <Collapsible\n              summary={\n                <span className=\"text-sm font-medium text-bambu-gray\">\n                  {t('settings.ldap.advanced') || 'Advanced'}\n                </span>\n              }\n              className=\"border-t border-bambu-dark-tertiary pt-3\"\n              summaryClassName=\"py-1\"\n            >\n              <div className=\"space-y-3\">\n                {/* Auto Provision */}\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <label className=\"block text-sm font-medium text-white\">\n                      {t('settings.ldap.autoProvision') || 'Auto-provision users'}\n                    </label>\n                    <p className=\"text-xs text-bambu-gray mt-0.5\">\n                      {t('settings.ldap.autoProvisionHint') || 'Automatically create a BamBuddy account on first LDAP login'}\n                    </p>\n                  </div>\n                  <button\n                    onClick={() => setForm({ ...form, ldap_auto_provision: !form.ldap_auto_provision })}\n                    className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${\n                      form.ldap_auto_provision ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n                    }`}\n                  >\n                    <span\n                      className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${\n                        form.ldap_auto_provision ? 'translate-x-6' : 'translate-x-1'\n                      }`}\n                    />\n                  </button>\n                </div>\n\n                {/* Default Group (fallback for users with no mapped groups) */}\n                <div>\n                  <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n                    {t('settings.ldap.defaultGroup') || 'Default group'}\n                  </label>\n                  <select\n                    className={inputClasses}\n                    value={form.ldap_default_group}\n                    onChange={e => setForm({ ...form, ldap_default_group: e.target.value })}\n                  >\n                    <option value=\"\">{t('settings.ldap.defaultGroupNone') || '— None (reject login) —'}</option>\n                    {groups.map(g => (\n                      <option key={g.id} value={g.name}>{g.name}</option>\n                    ))}\n                  </select>\n                  <p className=\"text-xs text-bambu-gray mt-1\">\n                    {t('settings.ldap.defaultGroupHint') || 'Fallback group assigned when an LDAP user authenticates but is not listed in any mapped group. Leave empty to leave unmapped users without permissions.'}\n                  </p>\n                </div>\n\n                {/* Group Mapping */}\n                <div>\n                  <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n                    {t('settings.ldap.groupMapping') || 'Group Mapping (JSON)'}\n                  </label>\n                  <textarea\n                    className={`${inputClasses} font-mono text-sm`}\n                    rows={4}\n                    placeholder={'{\\n  \"CN=PrintFarm_Admins,OU=Groups,DC=example,DC=com\": \"Administrators\",\\n  \"CN=PrintFarm_Users,OU=Groups,DC=example,DC=com\": \"Operators\"\\n}'}\n                    value={form.ldap_group_mapping}\n                    onChange={e => setForm({ ...form, ldap_group_mapping: e.target.value })}\n                  />\n                  <p className=\"text-xs text-bambu-gray mt-1\">\n                    {t('settings.ldap.groupMappingHint') || 'Map LDAP group DNs to BamBuddy groups. Available groups: '}{groups.map(g => g.name).join(', ')}\n                  </p>\n                </div>\n              </div>\n            </Collapsible>\n\n            {/* Action Buttons */}\n            <div className=\"flex gap-3 pt-2\">\n              <Button\n                onClick={handleSave}\n                disabled={saveMutation.isPending}\n              >\n                {saveMutation.isPending ? (\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                ) : (\n                  <CheckCircle className=\"w-4 h-4\" />\n                )}\n                {t('common.save') || 'Save'}\n              </Button>\n              <Button\n                variant=\"secondary\"\n                onClick={() => testMutation.mutate()}\n                disabled={testMutation.isPending}\n              >\n                {testMutation.isPending ? (\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                ) : (\n                  <Send className=\"w-4 h-4\" />\n                )}\n                {t('settings.ldap.testConnection') || 'Test Connection'}\n              </Button>\n            </div>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/Layout.tsx",
    "content": "import { useState, useEffect, useCallback, useRef, useMemo } from 'react';\nimport { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';\nimport { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { useTheme } from '../contexts/ThemeContext';\nimport { KeyboardShortcutsModal } from './KeyboardShortcutsModal';\nimport { SwitchbarPopover } from './SwitchbarPopover';\nimport { useQuery, useQueries } from '@tanstack/react-query';\nimport { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';\nimport { getIconByName } from './IconPicker';\nimport { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';\nimport { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';\nimport { useAuth } from '../contexts/AuthContext';\nimport { useToast } from '../contexts/ToastContext';\nimport { Card, CardHeader, CardContent } from './Card';\nimport { parseUTCDate } from '../utils/date';\nimport { Button } from './Button';\nimport { BugReportBubble } from './BugReportBubble';\n\n\ninterface NavItem {\n  id: string;\n  to: string;\n  icon: LucideIcon;\n  labelKey: string; // Translation key\n}\n\nexport const defaultNavItems: NavItem[] = [\n  // Primary workflow items\n  { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },\n  { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },\n  { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },\n  { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },\n  { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },\n  { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },\n  { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },\n  { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },\n  { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },\n  // User-account features: kept adjacent to Settings intentionally\n  { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },\n  { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },\n];\n\n// Get unified sidebar order from localStorage\nfunction getSidebarOrder(): string[] {\n  const stored = localStorage.getItem('sidebarOrder');\n  if (stored) {\n    try {\n      return JSON.parse(stored);\n    } catch {\n      return defaultNavItems.map(i => i.id);\n    }\n  }\n  return defaultNavItems.map(i => i.id);\n}\n\n// Save unified sidebar order to localStorage\nfunction saveSidebarOrder(order: string[]) {\n  localStorage.setItem('sidebarOrder', JSON.stringify(order));\n}\n\n// Check if an ID is an external link\nfunction isExternalLinkId(id: string): boolean {\n  return id.startsWith('ext-');\n}\n\n// Get default view from localStorage\nexport function getDefaultView(): string {\n  return localStorage.getItem('defaultView') || '/';\n}\n\n// Save default view to localStorage\nexport function setDefaultView(path: string) {\n  localStorage.setItem('defaultView', path);\n}\n\nexport function Layout() {\n  const navigate = useNavigate();\n  const location = useLocation();\n  const { mode, toggleMode } = useTheme();\n  const { t } = useTranslation();\n  const isSidebarCompact = useIsSidebarCompact();\n  // Re-render Layout (and the page rendered inside <Outlet />) whenever the\n  // backend color catalog is (re)populated, so pages that mounted before the\n  // catalog fetched — and cached HSL-fallback color names during their first\n  // render — refresh with the real catalog names. See #857.\n  useColorCatalogVersion();\n  const { user, authEnabled, logout, hasPermission } = useAuth();\n  const { showToast } = useToast();\n  const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);\n  const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });\n  const [changePasswordLoading, setChangePasswordLoading] = useState(false);\n  const [sidebarExpanded, setSidebarExpanded] = useState(() => {\n    const stored = localStorage.getItem('sidebarExpanded');\n    return stored !== 'false';\n  });\n  const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);\n  const [showShortcuts, setShowShortcuts] = useState(false);\n  const [showSwitchbar, setShowSwitchbar] = useState(false);\n  const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);\n  const [draggedId, setDraggedId] = useState<string | null>(null);\n  const [dragOverId, setDragOverId] = useState<string | null>(null);\n  const hasRedirected = useRef(false);\n  const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>\n    sessionStorage.getItem('dismissedUpdateVersion')\n  );\n  const [plateDetectionAlert, setPlateDetectionAlert] = useState<{\n    printer_id: number;\n    printer_name: string;\n    message: string;\n  } | null>(null);\n\n  // Check for updates\n  const { data: versionInfo } = useQuery({\n    queryKey: ['version'],\n    queryFn: api.getVersion,\n    staleTime: Infinity,\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n\n  // Fetch default sidebar order via a public endpoint (no settings:read needed)\n  const { data: defaultSidebarData } = useQuery({\n    queryKey: ['default-sidebar-order'],\n    queryFn: api.getDefaultSidebarOrder,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n\n  // Apply admin default sidebar order once per user (skipped if already applied).\n  // Uses a per-user localStorage flag to prevent re-application.\n  useEffect(() => {\n    const defaultOrder = defaultSidebarData?.default_sidebar_order;\n    if (!defaultOrder) return;\n    // Wait for auth state to settle before applying to avoid double-execution\n    if (authEnabled && !user) return;\n    const appliedKey = user ? `sidebarDefaultApplied_${user.id}` : 'sidebarDefaultApplied';\n    if (localStorage.getItem(appliedKey)) return;\n    try {\n      const parsed = JSON.parse(defaultOrder);\n      const orderArr = Array.isArray(parsed) ? parsed : parsed.order;\n      if (!Array.isArray(orderArr) || orderArr.length === 0) return;\n      // Filter to valid sidebar item IDs only\n      const validIds = new Set(defaultNavItems.map(i => i.id));\n      const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalLinkId(id)));\n      if (filtered.length > 0) {\n        setSidebarOrder(filtered);\n        saveSidebarOrder(filtered);\n        localStorage.setItem(appliedKey, '1');\n      }\n    } catch (e) {\n      console.error('Failed to apply default sidebar order:', e);\n    }\n  }, [defaultSidebarData?.default_sidebar_order, setSidebarOrder, user, authEnabled]);\n\n  // Check advanced auth status for conditional nav items\n  const { data: advancedAuthStatus } = useQuery({\n    queryKey: ['advancedAuthStatus'],\n    queryFn: api.getAdvancedAuthStatus,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n    enabled: authEnabled,\n  });\n\n  const { data: updateCheck } = useQuery({\n    queryKey: ['updateCheck'],\n    queryFn: api.checkForUpdates,\n    enabled: settings?.check_updates !== false,\n    staleTime: 60 * 60 * 1000, // 1 hour\n    refetchInterval: 60 * 60 * 1000, // Check every hour\n  });\n\n  // Fetch external links for sidebar\n  const { data: externalLinks } = useQuery({\n    queryKey: ['external-links'],\n    queryFn: api.getExternalLinks,\n  });\n\n  // Fetch smart plugs to check for switchbar items\n  const { data: smartPlugs } = useQuery({\n    queryKey: ['smart-plugs'],\n    queryFn: api.getSmartPlugs,\n    staleTime: 30 * 1000, // 30 seconds\n  });\n\n  const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;\n\n  // Check debug logging state\n  const { data: debugLoggingState } = useQuery({\n    queryKey: ['debugLogging'],\n    queryFn: supportApi.getDebugLoggingState,\n    staleTime: 60 * 1000, // 1 minute\n    refetchInterval: 60 * 1000, // Refresh every minute\n  });\n\n  // Check developer LAN mode warnings\n  const { data: devModeWarnings } = useQuery({\n    queryKey: ['developer-mode-warnings'],\n    queryFn: api.getDeveloperModeWarnings,\n    staleTime: 10 * 1000,\n    refetchInterval: 30 * 1000,\n    refetchOnWindowFocus: true,\n  });\n\n  // Fetch pending queue items count for badge\n  const { data: queueItems } = useQuery({\n    queryKey: ['queue', 'pending'],\n    queryFn: () => api.getQueue(undefined, 'pending'),\n    staleTime: 5 * 1000, // 5 seconds\n    refetchInterval: 5 * 1000, // Refresh every 5 seconds\n    refetchOnWindowFocus: true,\n  });\n  const pendingQueueCount = queueItems?.length ?? 0;\n\n  // Fetch pending uploads count for archive badge (virtual printer review items)\n  const { data: pendingUploadsData } = useQuery({\n    queryKey: ['pending-uploads', 'count'],\n    queryFn: pendingUploadsApi.getCount,\n    staleTime: 5 * 1000, // 5 seconds\n    refetchInterval: 5 * 1000, // Refresh every 5 seconds\n    refetchOnWindowFocus: true,\n  });\n  const pendingUploadsCount = pendingUploadsData?.count ?? 0;\n\n  // Check if any printer with pending queue items needs plate clearing\n  const queuePrinterIds = useMemo(() => {\n    const ids = new Set<number>();\n    queueItems?.forEach(item => {\n      if (item.printer_id) ids.add(item.printer_id);\n    });\n    return Array.from(ids);\n  }, [queueItems]);\n\n  const printerStatusQueries = useQueries({\n    queries: queuePrinterIds.map(id => ({\n      queryKey: ['printerStatus', id],\n      queryFn: () => api.getPrinterStatus(id),\n      staleTime: 30 * 1000, // WebSocket keeps this warm\n    })),\n  });\n\n  const needsClearPlate = printerStatusQueries.some(result => {\n    const status = result.data;\n    if (!status) return false;\n    return !!status.awaiting_plate_clear;\n  });\n\n  // Calculate debug duration client-side for real-time updates\n  const [debugDuration, setDebugDuration] = useState<number | null>(null);\n  useEffect(() => {\n    if (!debugLoggingState?.enabled || !debugLoggingState.enabled_at) {\n      setDebugDuration(null);\n      return;\n    }\n    const enabledAt = parseUTCDate(debugLoggingState.enabled_at)?.getTime() ?? Date.now();\n    const updateDuration = () => {\n      setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));\n    };\n    updateDuration();\n    const interval = setInterval(updateDuration, 1000);\n    return () => clearInterval(interval);\n  }, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]);\n\n  // Build the unified sidebar items list - memoized to prevent re-renders\n  const navItemsMap = useMemo(() => new Map(defaultNavItems.map(item => [item.id, item])), []);\n  const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);\n\n  // Compute the ordered sidebar: include stored order + any new items\n  // Hide nav items the user doesn't have read permission for\n  const orderedSidebarIds = (() => {\n    const result: string[] = [];\n    const seen = new Set<string>();\n\n    // Map nav item IDs to the permission required to see them\n    const navPermissions: Record<string, Permission> = {\n      archives: 'archives:read',\n      queue: 'queue:read',\n      stats: 'stats:read',\n      profiles: 'kprofiles:read',\n      maintenance: 'maintenance:read',\n      projects: 'projects:read',\n      inventory: 'inventory:read',\n      files: 'library:read',\n      settings: 'settings:read',\n      notifications: 'notifications:user_email',\n    };\n\n    const isHidden = (id: string) => {\n      if (authEnabled && id in navPermissions && !hasPermission(navPermissions[id])) return true;\n      // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting\n      if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;\n      return false;\n    };\n\n    // Add items in stored order\n    for (const id of sidebarOrder) {\n      if (isHidden(id)) continue;\n      if (navItemsMap.has(id) || extLinksMap.has(id)) {\n        result.push(id);\n        seen.add(id);\n      }\n    }\n\n    // Add any new internal nav items not in stored order\n    for (const item of defaultNavItems) {\n      if (isHidden(item.id)) continue;\n      if (!seen.has(item.id)) {\n        result.push(item.id);\n        seen.add(item.id);\n      }\n    }\n\n    // Add any new external links not in stored order\n    for (const link of externalLinks || []) {\n      const extId = `ext-${link.id}`;\n      if (!seen.has(extId)) {\n        result.push(extId);\n        seen.add(extId);\n      }\n    }\n\n    return result;\n  })();\n\n  // Unified drag handlers\n  const handleDragStart = (e: React.DragEvent, id: string) => {\n    setDraggedId(id);\n    e.dataTransfer.effectAllowed = 'move';\n    e.dataTransfer.setData('text/plain', id);\n  };\n\n  const handleDragOver = (e: React.DragEvent, id: string) => {\n    e.preventDefault();\n    e.dataTransfer.dropEffect = 'move';\n    setDragOverId(id);\n  };\n\n  const handleDragLeave = () => {\n    setDragOverId(null);\n  };\n\n  const handleDrop = (e: React.DragEvent, targetId: string) => {\n    e.preventDefault();\n    if (draggedId === null || draggedId === targetId) {\n      setDraggedId(null);\n      setDragOverId(null);\n      return;\n    }\n\n    const currentOrder = [...orderedSidebarIds];\n    const draggedIndex = currentOrder.indexOf(draggedId);\n    const targetIndex = currentOrder.indexOf(targetId);\n\n    if (draggedIndex === -1 || targetIndex === -1) {\n      setDraggedId(null);\n      setDragOverId(null);\n      return;\n    }\n\n    // Reorder\n    currentOrder.splice(draggedIndex, 1);\n    currentOrder.splice(targetIndex, 0, draggedId);\n\n    // Save to localStorage and update state\n    setSidebarOrder(currentOrder);\n    saveSidebarOrder(currentOrder);\n\n    setDraggedId(null);\n    setDragOverId(null);\n  };\n\n  const handleDragEnd = () => {\n    setDraggedId(null);\n    setDragOverId(null);\n  };\n\n  // Show update banner if update available and not dismissed for this version\n  const showUpdateBanner = updateCheck?.update_available &&\n    updateCheck.latest_version &&\n    updateCheck.latest_version !== dismissedUpdateVersion;\n\n  const dismissUpdateBanner = () => {\n    if (updateCheck?.latest_version) {\n      sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);\n      setDismissedUpdateVersion(updateCheck.latest_version);\n    }\n  };\n\n  // Redirect to default view on initial load\n  useEffect(() => {\n    if (!hasRedirected.current && location.pathname === '/') {\n      const defaultView = getDefaultView();\n      if (defaultView !== '/') {\n        hasRedirected.current = true;\n        navigate(defaultView, { replace: true });\n      }\n    }\n  }, [location.pathname, navigate]);\n\n  useEffect(() => {\n    localStorage.setItem('sidebarExpanded', String(sidebarExpanded));\n  }, [sidebarExpanded]);\n\n  // Close compact drawer on navigation\n  useEffect(() => {\n    if (isSidebarCompact) {\n      setMobileDrawerOpen(false);\n    }\n  }, [location.pathname, isSidebarCompact]);\n\n  // Listen for plate detection warnings (objects on plate, print paused)\n  // Only show to users with printers:control permission\n  useEffect(() => {\n    const handlePlateNotEmpty = (event: Event) => {\n      // Only show alert to users who can control printers\n      if (!hasPermission('printers:control')) {\n        return;\n      }\n      const detail = (event as CustomEvent).detail;\n      setPlateDetectionAlert({\n        printer_id: detail.printer_id,\n        printer_name: detail.printer_name,\n        message: detail.message,\n      });\n    };\n    window.addEventListener('plate-not-empty', handlePlateNotEmpty);\n    return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);\n  }, [hasPermission]);\n\n  // Global keyboard shortcuts for navigation\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    const target = e.target as HTMLElement;\n    // Ignore if typing in an input/textarea\n    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {\n      return;\n    }\n\n    // Number keys for navigation (1-9) - follows sidebar order including external links\n    if (!e.metaKey && !e.ctrlKey && !e.altKey) {\n      const keyNum = parseInt(e.key);\n      if (keyNum >= 1 && keyNum <= orderedSidebarIds.length && keyNum <= 9) {\n        const id = orderedSidebarIds[keyNum - 1];\n        e.preventDefault();\n\n        if (isExternalLinkId(id)) {\n          // External link\n          const extLink = extLinksMap.get(id);\n          if (extLink?.open_in_new_tab) {\n            window.open(extLink.url, '_blank', 'noopener,noreferrer');\n          } else {\n            const linkId = id.replace('ext-', '');\n            navigate(`/external/${linkId}`);\n          }\n        } else {\n          // Internal nav item\n          const navItem = navItemsMap.get(id);\n          if (navItem) {\n            navigate(navItem.to);\n          }\n        }\n        return;\n      }\n\n      switch (e.key) {\n        case '?':\n          e.preventDefault();\n          setShowShortcuts(true);\n          break;\n        case 'Escape':\n          setShowShortcuts(false);\n          break;\n      }\n    }\n  }, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);\n\n  useEffect(() => {\n    document.addEventListener('keydown', handleKeyDown);\n    return () => document.removeEventListener('keydown', handleKeyDown);\n  }, [handleKeyDown]);\n\n  return (\n    <div className=\"flex min-h-screen\">\n      {/* Compact Header */}\n      {isSidebarCompact && (\n        <header className=\"fixed top-0 left-0 right-0 z-40 h-14 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-4\">\n          <button\n            onClick={() => setMobileDrawerOpen(true)}\n            className=\"p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors\"\n            aria-label=\"Open menu\"\n          >\n            <Menu className=\"w-6 h-6 text-white\" />\n          </button>\n          <img\n            src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}\n            alt=\"Bambuddy\"\n            className=\"h-8 ml-3\"\n          />\n        </header>\n      )}\n\n      {/* Compact Drawer Backdrop */}\n      {isSidebarCompact && mobileDrawerOpen && (\n        <div\n          className=\"fixed inset-0 bg-black/60 z-40 transition-opacity\"\n          onClick={() => setMobileDrawerOpen(false)}\n        />\n      )}\n\n      {/* Sidebar / Mobile Drawer */}\n      <aside\n        className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${\n          isSidebarCompact\n            ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`\n            : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`\n        }`}\n      >\n        {/* Logo */}\n        <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>\n          <img\n            src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}\n            alt=\"Bambuddy\"\n            className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}\n          />\n        </div>\n\n        {/* Navigation */}\n        <nav className=\"flex-1 p-2 overflow-y-auto\">\n          <ul className=\"space-y-2\">\n            {orderedSidebarIds.map((id) => {\n              const isExternal = isExternalLinkId(id);\n\n              if (isExternal) {\n                // Render external link\n                const link = extLinksMap.get(id);\n                if (!link) return null;\n\n                const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);\n                return (\n                  <li\n                    key={id}\n                    draggable\n                    onDragStart={(e) => handleDragStart(e, id)}\n                    onDragOver={(e) => handleDragOver(e, id)}\n                    onDragLeave={handleDragLeave}\n                    onDrop={(e) => handleDrop(e, id)}\n                    onDragEnd={handleDragEnd}\n                    className={`relative ${\n                      draggedId === id ? 'opacity-50' : ''\n                    } ${\n                      dragOverId === id && draggedId !== id\n                        ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'\n                        : ''\n                    }`}\n                  >\n                    {link.open_in_new_tab ? (\n                      <a\n                        href={link.url}\n                        target=\"_blank\"\n                        rel=\"noopener noreferrer\"\n                        className={`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}\n                        title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}\n                      >\n                        {sidebarExpanded && !isSidebarCompact && (\n                          <GripVertical className=\"w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1\" />\n                        )}\n                        {link.custom_icon ? (\n                          <img\n                            src={api.getExternalLinkIconUrl(link.id)}\n                            alt=\"\"\n                            className=\"w-5 h-5 flex-shrink-0\"\n                          />\n                        ) : (\n                          LinkIcon && <LinkIcon className=\"w-5 h-5 flex-shrink-0\" />\n                        )}\n                        {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}\n                      </a>\n                    ) : (\n                      <NavLink\n                        to={`/external/${link.id}`}\n                        className={({ isActive }) =>\n                          `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${\n                            isActive\n                              ? 'bg-bambu-green text-white'\n                              : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'\n                          }`\n                        }\n                        title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}\n                      >\n                        {sidebarExpanded && !isSidebarCompact && (\n                          <GripVertical className=\"w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1\" />\n                        )}\n                        {link.custom_icon ? (\n                          <img\n                            src={api.getExternalLinkIconUrl(link.id)}\n                            alt=\"\"\n                            className=\"w-5 h-5 flex-shrink-0\"\n                          />\n                        ) : (\n                          LinkIcon && <LinkIcon className=\"w-5 h-5 flex-shrink-0\" />\n                        )}\n                        {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}\n                      </NavLink>\n                    )}\n                  </li>\n                );\n              } else {\n                // Render internal nav item\n                const navItem = navItemsMap.get(id);\n                if (!navItem) return null;\n\n                const { to, icon: Icon, labelKey } = navItem;\n                const showQueueBadge = id === 'queue' && pendingQueueCount > 0;\n                const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;\n                const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;\n                const showBadge = showQueueBadge || showArchiveBadge;\n                const showClearPlateDot = id === 'printers' && needsClearPlate;\n\n                return (\n                  <li\n                    key={id}\n                    draggable\n                    onDragStart={(e) => handleDragStart(e, id)}\n                    onDragOver={(e) => handleDragOver(e, id)}\n                    onDragLeave={handleDragLeave}\n                    onDrop={(e) => handleDrop(e, id)}\n                    onDragEnd={handleDragEnd}\n                    className={`relative ${\n                      draggedId === id ? 'opacity-50' : ''\n                    } ${\n                      dragOverId === id && draggedId !== id\n                        ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'\n                        : ''\n                    }`}\n                  >\n                    <NavLink\n                      to={to}\n                      className={({ isActive }) =>\n                        `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${\n                          isActive\n                            ? 'bg-bambu-green text-white'\n                            : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'\n                        }`\n                      }\n                      title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}\n                    >\n                      {sidebarExpanded && !isSidebarCompact && (\n                        <GripVertical className=\"w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1\" />\n                      )}\n                      <div className=\"relative\">\n                        <Icon className=\"w-5 h-5 flex-shrink-0\" />\n                        {showClearPlateDot && (\n                          <span className=\"absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-yellow-500 rounded-full border-2 border-bambu-dark-secondary\" />\n                        )}\n                        {showBadge && (\n                          <span className={`absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full ${\n                            showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'\n                          }`}>\n                            {badgeCount > 99 ? '99+' : badgeCount}\n                          </span>\n                        )}\n                      </div>\n                      {(isSidebarCompact || sidebarExpanded) && <span>{t(labelKey)}</span>}\n                    </NavLink>\n                  </li>\n                );\n              }\n            })}\n          </ul>\n        </nav>\n\n        {/* Collapse toggle - hide on compact sidebar */}\n        {!isSidebarCompact && (\n          <button\n            onClick={() => setSidebarExpanded(!sidebarExpanded)}\n            className=\"p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center\"\n            title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}\n          >\n            {sidebarExpanded ? (\n              <ChevronLeft className=\"w-5 h-5\" />\n            ) : (\n              <ChevronRight className=\"w-5 h-5\" />\n            )}\n          </button>\n        )}\n\n        {/* Footer */}\n        <div className=\"flex-shrink-0 p-2 border-t border-bambu-dark-tertiary\">\n          {isSidebarCompact || sidebarExpanded ? (\n            <div className=\"flex flex-col gap-2 px-2\">\n              {/* Top row: icons */}\n              <div className=\"flex items-center justify-center gap-1 flex-wrap\">\n                {hasSwitchbarPlugs && (\n                  <div className=\"relative\">\n                    <button\n                      onMouseEnter={() => setShowSwitchbar(true)}\n                      className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${\n                        showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'\n                      }`}\n                      title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}\n                    >\n                      <Plug className=\"w-5 h-5\" />\n                    </button>\n                    {showSwitchbar && (\n                      <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />\n                    )}\n                  </div>\n                )}\n                {hasPermission('system:read') ? (\n                  <NavLink\n                    to=\"/system\"\n                    className={({ isActive }) =>\n                      `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${\n                        isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'\n                      }`\n                    }\n                    title={t('nav.system')}\n                  >\n                    <Info className=\"w-5 h-5\" />\n                  </NavLink>\n                ) : (\n                  <span\n                    className=\"p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed\"\n                    title=\"You do not have permission to view system information\"\n                  >\n                    <Info className=\"w-5 h-5\" />\n                  </span>\n                )}\n                <a\n                  href=\"https://github.com/maziggy/bambuddy\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                  title={t('nav.viewOnGithub')}\n                >\n                  <Github className=\"w-5 h-5\" />\n                </a>\n                <button\n                  onClick={() => setShowShortcuts(true)}\n                  className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                  title={t('nav.keyboardShortcuts')}\n                >\n                  <Keyboard className=\"w-5 h-5\" />\n                </button>\n                <button\n                  onClick={toggleMode}\n                  className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                  title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}\n                >\n                  {mode === 'dark' ? <Sun className=\"w-5 h-5\" /> : <Moon className=\"w-5 h-5\" />}\n                </button>\n                {authEnabled && user && (\n                  <>\n                    <button\n                      onClick={() => setShowChangePasswordModal(true)}\n                      className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                      title={t('changePassword.title')}\n                    >\n                      <Key className=\"w-5 h-5\" />\n                    </button>\n                    <button\n                      onClick={logout}\n                      className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                      title={t('nav.logout', { defaultValue: 'Logout' })}\n                    >\n                      <LogOut className=\"w-5 h-5\" />\n                    </button>\n                  </>\n                )}\n              </div>\n              {/* Bottom row: version */}\n              <div className=\"flex items-center justify-center gap-2\">\n                <span className=\"text-sm text-bambu-gray\">v{versionInfo?.version || '...'}</span>\n                {updateCheck?.update_available && (\n                  <button\n                    onClick={() => navigate('/settings')}\n                    className=\"flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors\"\n                    title={t('nav.updateAvailable', { version: updateCheck.latest_version })}\n                  >\n                    <ArrowUpCircle className=\"w-4 h-4\" />\n                    <span>{t('nav.update')}</span>\n                  </button>\n                )}\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex flex-col items-center gap-1 overflow-y-auto max-h-[50vh]\">\n              {updateCheck?.update_available && (\n                <button\n                  onClick={() => navigate('/settings')}\n                  className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80\"\n                  title={t('nav.updateAvailable', { version: updateCheck.latest_version })}\n                >\n                  <ArrowUpCircle className=\"w-5 h-5\" />\n                </button>\n              )}\n              {hasSwitchbarPlugs && (\n                <div className=\"relative\">\n                  <button\n                    onMouseEnter={() => setShowSwitchbar(true)}\n                    className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${\n                      showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'\n                    }`}\n                    title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}\n                  >\n                    <Plug className=\"w-5 h-5\" />\n                  </button>\n                  {showSwitchbar && (\n                    <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />\n                  )}\n                </div>\n              )}\n              {hasPermission('system:read') ? (\n                <NavLink\n                  to=\"/system\"\n                  className={({ isActive }) =>\n                    `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${\n                      isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'\n                    }`\n                  }\n                  title={t('nav.system')}\n                >\n                  <Info className=\"w-5 h-5\" />\n                </NavLink>\n              ) : (\n                <span\n                  className=\"p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed\"\n                  title=\"You do not have permission to view system information\"\n                >\n                  <Info className=\"w-5 h-5\" />\n                </span>\n              )}\n              <a\n                href=\"https://github.com/maziggy/bambuddy\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                title={t('nav.viewOnGithub')}\n              >\n                <Github className=\"w-5 h-5\" />\n              </a>\n              <button\n                onClick={() => setShowShortcuts(true)}\n                className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                title={t('nav.keyboardShortcuts')}\n              >\n                <Keyboard className=\"w-5 h-5\" />\n              </button>\n              <button\n                onClick={toggleMode}\n                className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}\n              >\n                {mode === 'dark' ? <Sun className=\"w-5 h-5\" /> : <Moon className=\"w-5 h-5\" />}\n              </button>\n              {authEnabled && user && (\n                <>\n                  <button\n                    onClick={() => setShowChangePasswordModal(true)}\n                    className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                    title={t('changePassword.title')}\n                  >\n                    <Key className=\"w-5 h-5\" />\n                  </button>\n                  <button\n                    onClick={logout}\n                    className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\"\n                    title={t('nav.logout', { defaultValue: 'Logout' })}\n                  >\n                    <LogOut className=\"w-5 h-5\" />\n                  </button>\n                </>\n              )}\n            </div>\n          )}\n        </div>\n      </aside>\n\n      {/* Main content */}\n      <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${\n        isSidebarCompact ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'\n      }`}>\n        {/* Debug logging indicator */}\n        {debugLoggingState?.enabled && (\n          <div className=\"bg-amber-500/20 border-b border-amber-500/30 px-4 py-2 flex items-center justify-between\">\n            <div className=\"flex items-center gap-2 text-sm\">\n              <Bug className=\"w-4 h-4 text-amber-500 animate-pulse\" />\n              <span className=\"text-amber-200\">\n                {t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}\n                {debugDuration !== null && (\n                  <span className=\"text-amber-300/70 ml-2\">\n                    ({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)\n                  </span>\n                )}\n              </span>\n              <button\n                onClick={() => navigate('/system')}\n                className=\"text-amber-400 hover:text-amber-300 font-medium underline ml-2\"\n              >\n                {t('support.manageLogs', { defaultValue: 'Manage' })}\n              </button>\n            </div>\n          </div>\n        )}\n        {devModeWarnings && devModeWarnings.length > 0 && (\n          <div className=\"bg-orange-500/20 border-b border-orange-500/30 px-4 py-2 flex items-center justify-between\">\n            <div className=\"flex items-center gap-2 text-sm\">\n              <ShieldAlert className=\"w-4 h-4 text-orange-500\" />\n              <span className=\"text-orange-200\">\n                {t('printers.developerModeWarning', {\n                  names: devModeWarnings.map(w => w.name).join(', '),\n                  defaultValue: `Developer LAN mode is not enabled on: ${devModeWarnings.map(w => w.name).join(', ')}. Some features may not work.`\n                })}\n              </span>\n              <a href=\"https://wiki.bambulab.com/en/knowledge-sharing/enable-developer-mode\"\n                 target=\"_blank\" rel=\"noopener noreferrer\"\n                 className=\"text-orange-400 hover:text-orange-300 font-medium underline ml-2\">\n                {t('printers.howToEnable', { defaultValue: 'How to enable' })}\n              </a>\n            </div>\n          </div>\n        )}\n        {/* Persistent update banner */}\n        {showUpdateBanner && (\n          <div className=\"bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between\">\n            <div className=\"flex items-center gap-2 text-sm\">\n              <ArrowUpCircle className=\"w-4 h-4 text-bambu-green\" />\n              <span>\n                {t('nav.updateAvailableBanner', {\n                  version: updateCheck?.latest_version,\n                  defaultValue: `Version ${updateCheck?.latest_version} is available!`\n                })}\n              </span>\n              <button\n                onClick={() => navigate('/settings')}\n                className=\"text-bambu-green hover:text-bambu-green/80 font-medium underline\"\n              >\n                {t('nav.viewUpdate', { defaultValue: 'View update' })}\n              </button>\n            </div>\n            <button\n              onClick={dismissUpdateBanner}\n              className=\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\"\n              title={t('common.dismiss', { defaultValue: 'Dismiss' })}\n            >\n              <X className=\"w-4 h-4\" />\n            </button>\n          </div>\n        )}\n        <Outlet />\n      </main>\n\n      {/* Keyboard Shortcuts Modal */}\n      {showShortcuts && (\n        <KeyboardShortcutsModal\n          onClose={() => setShowShortcuts(false)}\n          sidebarItems={orderedSidebarIds.map(id => {\n            if (isExternalLinkId(id)) {\n              const extLink = extLinksMap.get(id);\n              return extLink ? { type: 'external' as const, label: extLink.name } : null;\n            } else {\n              const navItem = navItemsMap.get(id);\n              return navItem ? { type: 'nav' as const, label: navItem.labelKey, labelKey: navItem.labelKey } : null;\n            }\n          }).filter(Boolean) as { type: 'nav' | 'external'; label: string; labelKey?: string }[]}\n        />\n      )}\n\n      {/* Plate Detection Alert Modal */}\n      {plateDetectionAlert && (\n        <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-[100] p-4\">\n          <div className=\"bg-bambu-dark-secondary border-2 border-yellow-500 rounded-xl shadow-2xl max-w-md w-full animate-in fade-in zoom-in duration-200\">\n            <div className=\"p-6 text-center\">\n              <div className=\"w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/20 flex items-center justify-center\">\n                <svg className=\"w-10 h-10 text-yellow-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n                </svg>\n              </div>\n              <h2 className=\"text-xl font-bold text-yellow-400 mb-2\">\n                {t('plateAlert.title')}\n              </h2>\n              <p className=\"text-lg text-white mb-2\">\n                {plateDetectionAlert.printer_name}\n              </p>\n              <p className=\"text-bambu-gray mb-6\">\n                {t('plateAlert.message')}\n              </p>\n              <button\n                onClick={() => setPlateDetectionAlert(null)}\n                className=\"w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors\"\n              >\n                {t('plateAlert.understand')}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Change Password Modal */}\n      {showChangePasswordModal && (\n        <div\n          className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n          onClick={() => {\n            setShowChangePasswordModal(false);\n            setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });\n          }}\n        >\n          <Card\n            className=\"w-full max-w-md\"\n            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n          >\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <Key className=\"w-5 h-5 text-bambu-green\" />\n                  <h2 className=\"text-lg font-semibold text-white\">{t('changePassword.title')}</h2>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => {\n                    setShowChangePasswordModal(false);\n                    setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });\n                  }}\n                >\n                  <X className=\"w-5 h-5\" />\n                </Button>\n              </div>\n            </CardHeader>\n            <CardContent>\n              <div className=\"space-y-4\">\n                <input\n                  type=\"text\"\n                  name=\"username\"\n                  autoComplete=\"username\"\n                  value={user?.username ?? ''}\n                  readOnly\n                  hidden\n                  aria-hidden=\"true\"\n                  tabIndex={-1}\n                />\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('changePassword.currentPassword')}\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={changePasswordData.currentPassword}\n                    onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('changePassword.currentPasswordPlaceholder')}\n                    autoComplete=\"current-password\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('changePassword.newPassword')}\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={changePasswordData.newPassword}\n                    onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('changePassword.newPasswordPlaceholder')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('changePassword.confirmPassword')}\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={changePasswordData.confirmPassword}\n                    onChange={(e) => setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })}\n                    className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${\n                      changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword\n                        ? 'border-red-500'\n                        : 'border-bambu-dark-tertiary'\n                    }`}\n                    placeholder={t('changePassword.confirmPasswordPlaceholder')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                  {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (\n                    <p className=\"text-red-400 text-xs mt-1\">{t('changePassword.passwordsDoNotMatch')}</p>\n                  )}\n                </div>\n              </div>\n              <div className=\"mt-6 flex justify-end gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => {\n                    setShowChangePasswordModal(false);\n                    setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });\n                  }}\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  onClick={async () => {\n                    if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {\n                      showToast(t('changePassword.passwordsDoNotMatch'), 'error');\n                      return;\n                    }\n                    if (changePasswordData.newPassword.length < 6) {\n                      showToast(t('changePassword.passwordTooShort'), 'error');\n                      return;\n                    }\n                    setChangePasswordLoading(true);\n                    try {\n                      await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);\n                      showToast(t('changePassword.success'), 'success');\n                      setShowChangePasswordModal(false);\n                      setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });\n                    } catch (error: unknown) {\n                      const message = error instanceof Error ? error.message : t('changePassword.failed');\n                      showToast(message, 'error');\n                    } finally {\n                      setChangePasswordLoading(false);\n                    }\n                  }}\n                  disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}\n                >\n                  {changePasswordLoading ? (\n                    <>\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      {t('changePassword.changing')}\n                    </>\n                  ) : (\n                    <>\n                      <Key className=\"w-4 h-4\" />\n                      {t('changePassword.title')}\n                    </>\n                  )}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n      <BugReportBubble />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/LinkSpoolModal.tsx",
    "content": "import { useState, useMemo } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Loader2, Search, Link } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { UnlinkedSpool } from '../api/client';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\n\ninterface LinkSpoolModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  tagUid: string;\n  trayUuid: string;\n  printerId: number;\n  amsId: number;\n  trayId: number;\n}\n\nexport function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, amsId, trayId }: LinkSpoolModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [search, setSearch] = useState('');\n  const spoolTag = trayUuid || tagUid;\n\n  const { data: spools, isLoading } = useQuery({\n    queryKey: ['unlinked-spools'],\n    queryFn: api.getUnlinkedSpools,\n    enabled: isOpen,\n  });\n\n  // Filter Spoolman unlinked spools matching search\n  const filteredSpools = useMemo(() => {\n    if (!spools) return [];\n    return spools.filter((s: UnlinkedSpool) => {\n      if (!search) return true;\n      const q = search.toLowerCase();\n      return (\n        (s.filament_name && s.filament_name.toLowerCase().includes(q)) ||\n        (s.filament_vendor && s.filament_vendor.toLowerCase().includes(q)) ||\n        (s.filament_material && s.filament_material.toLowerCase().includes(q)) ||\n        String(s.id).includes(q)\n      );\n    });\n  }, [spools, search]);\n\n  const linkMutation = useMutation({\n    mutationFn: (spoolId: number) =>\n      api.linkSpool(spoolId, {\n        spoolTag: spoolTag!,\n        printerId,\n        amsId,\n        trayId,\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });\n      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });\n      showToast(t('spoolman.linkSuccess'), 'success');\n      onClose();\n    },\n    onError: (err: Error) => {\n      showToast(err.message || t('spoolman.linkFailed'), 'error');\n    },\n  });\n\n  if (!isOpen) return null;\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center\">\n      <div className=\"absolute inset-0 bg-black/60 backdrop-blur-sm\" onClick={onClose} />\n      <div className=\"relative bg-bambu-dark-secondary rounded-xl shadow-xl w-full max-w-md mx-4 max-h-[80vh] flex flex-col border border-bambu-dark-tertiary\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-white/10\">\n          <div>\n            <h3 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n              <Link className=\"w-5 h-5 text-bambu-green\" />\n              {t('spoolman.selectSpool')}\n            </h3>\n            <p className=\"text-xs text-bambu-gray mt-1\">\n              AMS {amsId} T{trayId} &middot; Printer #{printerId}\n            </p>\n          </div>\n          <button onClick={onClose} className=\"p-1 text-bambu-gray hover:text-white rounded transition-colors\">\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Search */}\n        <div className=\"p-4 border-b border-white/10\">\n          <div className=\"relative\">\n            <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n            <input\n              type=\"text\"\n              value={search}\n              onChange={(e) => setSearch(e.target.value)}\n              placeholder={t('inventory.searchSpools')}\n              className=\"w-full pl-9 pr-3 py-2 bg-bambu-dark rounded-lg border border-white/10 text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green\"\n            />\n          </div>\n          {(trayUuid || tagUid) && (\n            <p className=\"text-xs text-bambu-gray mt-2 font-mono truncate\" title={trayUuid || tagUid}>\n              Tag: {trayUuid || tagUid}\n            </p>\n          )}\n        </div>\n\n        {/* Spool List */}\n        <div className=\"flex-1 overflow-y-auto p-2 min-h-0\">\n          {isLoading ? (\n            <div className=\"flex justify-center py-8\">\n              <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n            </div>\n          ) : filteredSpools.length === 0 ? (\n            <p className=\"text-center text-bambu-gray py-8 text-sm\">\n              {t('inventory.noSpoolsMatch')}\n            </p>\n          ) : (\n            filteredSpools.map((spool: UnlinkedSpool) => (\n              <button\n                key={spool.id}\n                onClick={() => linkMutation.mutate(spool.id)}\n                disabled={linkMutation.isPending || !spoolTag}\n                className=\"w-full flex items-center gap-3 p-3 rounded-lg hover:bg-white/5 transition-colors text-left\"\n              >\n                <span\n                  className=\"w-6 h-6 rounded-full border border-black/20 flex-shrink-0\"\n                  style={{ backgroundColor: spool.filament_color_hex ? `#${spool.filament_color_hex}` : '#808080' }}\n                />\n                <div className=\"flex-1 min-w-0\">\n                  <div className=\"text-sm text-white font-medium truncate\">\n                    {spool.filament_name || t('spoolman.spoolId')}\n                  </div>\n                  <div className=\"text-xs text-bambu-gray truncate\">\n                    {spool.filament_vendor ? `${spool.filament_vendor} · ` : ''}\n                    {spool.filament_material || 'Unknown'} &middot; #{spool.id}\n                  </div>\n                </div>\n                <span className=\"text-xs text-bambu-gray\">\n                  {spool.remaining_weight != null ? `${Math.round(spool.remaining_weight)}g` : '—'}\n                </span>\n              </button>\n            ))\n          )}\n        </div>\n\n        {/* Footer */}\n        <div className=\"p-4 border-t border-white/10 flex justify-end\">\n          <Button variant=\"ghost\" onClick={onClose}>\n            {t('inventory.cancel') || 'Cancel'}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/LocalProfilesView.tsx",
    "content": "import { useState, useMemo, useCallback } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Upload,\n  Loader2,\n  Search,\n  Trash2,\n  ChevronDown,\n  ChevronUp,\n  HardDrive,\n  Droplet,\n  Settings2,\n  Layers,\n  AlertCircle,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport type { LocalPreset } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\n\n// Known material types for name-parsing fallback\nconst MATERIAL_TYPES = ['PLA', 'PETG', 'PCTG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'PVA', 'HIPS', 'PP', 'PET', 'NYLON'];\n\nconst FILAMENT_TYPE_COLORS: Record<string, string> = {\n  PLA: 'E8E8E8', PETG: '4A90D9', ABS: 'E67E22', ASA: 'D35400',\n  TPU: '9B59B6', PC: 'BDC3C7', PA: '2ECC71', NYLON: '2ECC71',\n  PVA: 'F1C40F', HIPS: '95A5A6', PP: 'ECF0F1', PET: '3498DB',\n};\n\n// Extract material type from preset name as fallback\nfunction parseMaterialFromName(name: string): string | null {\n  const upper = name.toUpperCase();\n  for (const mat of MATERIAL_TYPES) {\n    if (new RegExp(`\\\\b${mat}\\\\b`).test(upper)) return mat;\n  }\n  return null;\n}\n\n// Extract vendor from preset name (text before the material type)\nfunction parseVendorFromName(name: string): string | null {\n  // Strip printer/nozzle suffix first (e.g. \"@BBL X1C\")\n  const clean = name.replace(/@.+$/, '').trim();\n  const upper = clean.toUpperCase();\n  for (const mat of MATERIAL_TYPES) {\n    const idx = upper.indexOf(mat);\n    if (idx > 0) {\n      const vendor = clean.slice(0, idx).trim();\n      // Skip if vendor looks like a generic prefix (e.g., \"Generic\", \"Bambu\")\n      if (vendor && vendor.length > 1) return vendor;\n    }\n  }\n  return null;\n}\n\nfunction PresetCard({\n  preset,\n  onDelete,\n  onExpand,\n  isExpanded,\n}: {\n  preset: LocalPreset;\n  onDelete: (id: number) => void;\n  onExpand: (id: number | null) => void;\n  isExpanded: boolean;\n}) {\n  const { t } = useTranslation();\n  const { hasPermission } = useAuth();\n\n  // Resolve material type: DB field → parse from name\n  const material = preset.filament_type || parseMaterialFromName(preset.name);\n\n  // Resolve vendor: DB field → parse from name\n  const vendor = preset.filament_vendor || parseVendorFromName(preset.name);\n\n  // Parse colour for swatch — try explicit colour, then fall back to material type\n  let colourHex: string | null = null;\n  let hasExplicitColour = false;\n  if (preset.default_filament_colour) {\n    try {\n      const parsed = JSON.parse(preset.default_filament_colour);\n      const raw = Array.isArray(parsed) ? parsed[0] : parsed;\n      if (typeof raw === 'string' && /^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) {\n        colourHex = raw.replace('#', '').slice(0, 6);\n        hasExplicitColour = true;\n      }\n    } catch {\n      const raw = preset.default_filament_colour;\n      if (/^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) {\n        colourHex = raw.replace('#', '').slice(0, 6);\n        hasExplicitColour = true;\n      }\n    }\n  }\n  if (!colourHex && material) {\n    colourHex = FILAMENT_TYPE_COLORS[material.toUpperCase()] || null;\n  }\n\n  return (\n    <Card className=\"bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80 transition-colors\">\n      <CardContent className=\"p-3\">\n        <div className=\"flex items-start justify-between gap-2\">\n          <div className=\"flex-1 min-w-0\">\n            <div className=\"flex items-center gap-2 mb-1\">\n              {/* 1) Color dot — always shown for filament presets, dimmed if no explicit colour */}\n              {preset.preset_type === 'filament' && (\n                <div\n                  className={`w-4 h-4 rounded-full border border-black/20 flex-shrink-0 ${\n                    !hasExplicitColour && !colourHex ? 'opacity-25' : !hasExplicitColour ? 'opacity-50' : ''\n                  }`}\n                  style={{ backgroundColor: colourHex ? `#${colourHex}` : '#666' }}\n                />\n              )}\n              <span className=\"text-sm font-medium text-white truncate\">{preset.name}</span>\n            </div>\n\n            <div className=\"flex items-center gap-2 flex-wrap\">\n              {/* 2) Material tag — fallback to name parsing */}\n              {material && (\n                <span className=\"text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green\">\n                  {material}\n                </span>\n              )}\n              {/* 3) Vendor — fallback to name parsing */}\n              {vendor && (\n                <span className=\"text-xs text-bambu-gray\">{vendor}</span>\n              )}\n              <span className=\"text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400\">\n                {t('profiles.localProfiles.badge')}\n              </span>\n            </div>\n          </div>\n\n          <div className=\"flex items-center gap-1 flex-shrink-0\">\n            {/* 4) Only delete, no edit */}\n            {hasPermission('settings:update') && (\n              <button\n                onClick={() => onDelete(preset.id)}\n                className=\"p-1 text-bambu-gray hover:text-red-400 transition-colors\"\n                title={t('profiles.localProfiles.delete')}\n              >\n                <Trash2 className=\"w-3.5 h-3.5\" />\n              </button>\n            )}\n            <button\n              onClick={() => onExpand(isExpanded ? null : preset.id)}\n              className=\"p-1 text-bambu-gray hover:text-white transition-colors\"\n            >\n              {isExpanded ? <ChevronUp className=\"w-3.5 h-3.5\" /> : <ChevronDown className=\"w-3.5 h-3.5\" />}\n            </button>\n          </div>\n        </div>\n\n        {/* 5) Expanded detail — show meaningful fields, hide self-inherits */}\n        {isExpanded && (\n          <div className=\"mt-3 pt-3 border-t border-bambu-dark-tertiary text-xs space-y-1.5\">\n            {material && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-bambu-gray\">{t('profiles.localProfiles.filamentType')}</span>\n                <span className=\"text-white\">{material}</span>\n              </div>\n            )}\n            {vendor && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-bambu-gray\">{t('profiles.localProfiles.vendor')}</span>\n                <span className=\"text-white\">{vendor}</span>\n              </div>\n            )}\n            {preset.nozzle_temp_min != null && preset.nozzle_temp_max != null && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-bambu-gray\">{t('profiles.localProfiles.nozzleTemp')}</span>\n                <span className=\"text-white\">{preset.nozzle_temp_min}–{preset.nozzle_temp_max}°C</span>\n              </div>\n            )}\n            {preset.filament_cost && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-bambu-gray\">{t('profiles.localProfiles.cost')}</span>\n                <span className=\"text-white\">{preset.filament_cost}</span>\n              </div>\n            )}\n            {preset.filament_density && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-bambu-gray\">{t('profiles.localProfiles.density')}</span>\n                <span className=\"text-white\">{preset.filament_density} g/cm³</span>\n              </div>\n            )}\n            {preset.pressure_advance && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-bambu-gray\">{t('profiles.localProfiles.pressureAdvance')}</span>\n                <span className=\"text-white\">{preset.pressure_advance}</span>\n              </div>\n            )}\n            {preset.compatible_printers && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-bambu-gray\">{t('profiles.localProfiles.compatiblePrinters')}</span>\n                <span className=\"text-white truncate ml-2\">\n                  {(() => { try { return JSON.parse(preset.compatible_printers).join(', '); } catch { return preset.compatible_printers; } })()}\n                </span>\n              </div>\n            )}\n            {/* Only show inherits if different from own name */}\n            {preset.inherits && preset.inherits !== preset.name && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-bambu-gray\">{t('profiles.localProfiles.inheritsFrom')}</span>\n                <span className=\"text-white truncate ml-2\">{preset.inherits}</span>\n              </div>\n            )}\n            <div className=\"flex justify-between\">\n              <span className=\"text-bambu-gray\">{t('profiles.localProfiles.source')}</span>\n              <span className=\"text-white capitalize\">{preset.source}</span>\n            </div>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n\nexport function LocalProfilesView() {\n  const { t } = useTranslation();\n  const { hasPermission } = useAuth();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [searchQuery, setSearchQuery] = useState('');\n  const [expandedId, setExpandedId] = useState<number | null>(null);\n  const [isDragging, setIsDragging] = useState(false);\n  const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);\n\n  const { data: presets, isLoading } = useQuery({\n    queryKey: ['localPresets'],\n    queryFn: () => api.getLocalPresets(),\n  });\n\n  const importMutation = useMutation({\n    mutationFn: async (files: FileList) => {\n      const results = [];\n      for (const file of Array.from(files)) {\n        const formData = new FormData();\n        formData.append('file', file);\n        results.push(await api.importLocalPresets(formData));\n      }\n      return results;\n    },\n    onSuccess: (results) => {\n      queryClient.invalidateQueries({ queryKey: ['localPresets'] });\n      let totalImported = 0;\n      let totalSkipped = 0;\n      let totalErrors = 0;\n      for (const r of results) {\n        totalImported += r.imported;\n        totalSkipped += r.skipped;\n        totalErrors += r.errors.length;\n      }\n\n      if (totalImported > 0) {\n        showToast(t('profiles.localProfiles.toast.importSuccess', { count: totalImported }));\n      }\n      if (totalSkipped > 0) {\n        showToast(t('profiles.localProfiles.toast.importSkipped', { count: totalSkipped }), 'warning');\n      }\n      if (totalErrors > 0) {\n        showToast(t('profiles.localProfiles.toast.importError', { count: totalErrors }), 'error');\n      }\n    },\n    onError: (err: Error) => {\n      showToast(err.message, 'error');\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (id: number) => api.deleteLocalPreset(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['localPresets'] });\n      setDeleteConfirm(null);\n      showToast(t('profiles.localProfiles.toast.deleted'));\n    },\n  });\n\n  const handleFiles = useCallback((files: FileList | null) => {\n    if (!files || files.length === 0) return;\n    importMutation.mutate(files);\n  }, [importMutation]);\n\n  const handleDrop = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(false);\n    handleFiles(e.dataTransfer.files);\n  }, [handleFiles]);\n\n  const filterPresets = useCallback((list: LocalPreset[]) => {\n    if (!searchQuery) return list;\n    const q = searchQuery.toLowerCase();\n    return list.filter(p =>\n      p.name.toLowerCase().includes(q) ||\n      p.filament_type?.toLowerCase().includes(q) ||\n      p.filament_vendor?.toLowerCase().includes(q)\n    );\n  }, [searchQuery]);\n\n  const filaments = useMemo(() => filterPresets(presets?.filament || []), [presets?.filament, filterPresets]);\n  const printers = useMemo(() => filterPresets(presets?.printer || []), [presets?.printer, filterPresets]);\n  const processes = useMemo(() => filterPresets(presets?.process || []), [presets?.process, filterPresets]);\n  const totalCount = filaments.length + printers.length + processes.length;\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-16\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Import Zone */}\n      {hasPermission('settings:update') && (\n        <div\n          onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}\n          onDragLeave={() => setIsDragging(false)}\n          onDrop={handleDrop}\n          className={`relative border-2 border-dashed rounded-lg p-6 text-center transition-colors ${\n            isDragging\n              ? 'border-bambu-green bg-bambu-green/10'\n              : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n          }`}\n        >\n          <input\n            type=\"file\"\n            accept=\".json,.zip,.orca_filament,.bbscfg,.bbsflmt\"\n            multiple\n            className=\"absolute inset-0 w-full h-full opacity-0 cursor-pointer\"\n            onChange={(e) => handleFiles(e.target.files)}\n          />\n          {importMutation.isPending ? (\n            <div className=\"flex items-center justify-center gap-2\">\n              <Loader2 className=\"w-5 h-5 text-bambu-green animate-spin\" />\n              <span className=\"text-bambu-gray\">{t('profiles.localProfiles.importing')}</span>\n            </div>\n          ) : (\n            <>\n              <Upload className=\"w-8 h-8 text-bambu-gray mx-auto mb-2\" />\n              <p className=\"text-sm text-white font-medium\">{t('profiles.localProfiles.import')}</p>\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('profiles.localProfiles.importDesc')}</p>\n            </>\n          )}\n        </div>\n      )}\n\n      {/* Search Bar */}\n      {totalCount > 0 && (\n        <div className=\"relative\">\n          <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n          <input\n            type=\"text\"\n            value={searchQuery}\n            onChange={(e) => setSearchQuery(e.target.value)}\n            placeholder={t('profiles.localProfiles.search')}\n            className=\"w-full pl-9 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n          />\n        </div>\n      )}\n\n      {/* No Presets */}\n      {totalCount === 0 && !isLoading && (\n        <div className=\"text-center py-12\">\n          <HardDrive className=\"w-12 h-12 text-bambu-gray mx-auto mb-3 opacity-50\" />\n          <p className=\"text-bambu-gray\">{t('profiles.localProfiles.noPresets')}</p>\n          <p className=\"text-xs text-bambu-gray/60 mt-1\">{t('profiles.localProfiles.importDesc')}</p>\n        </div>\n      )}\n\n      {/* 3-Column Preset Lists */}\n      {totalCount > 0 && (\n        <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">\n          {/* Filament Column */}\n          {filaments.length > 0 && (\n            <div>\n              <div className=\"flex items-center gap-2 mb-3\">\n                <Droplet className=\"w-4 h-4 text-bambu-green\" />\n                <h3 className=\"text-sm font-medium text-white\">\n                  {t('profiles.localProfiles.filament')}\n                </h3>\n                <span className=\"text-xs text-bambu-gray\">({filaments.length})</span>\n              </div>\n              <div className=\"space-y-2\">\n                {filaments.map(p => (\n                  <PresetCard\n                    key={p.id}\n                    preset={p}\n                    onDelete={(id) => setDeleteConfirm(id)}\n                    onExpand={setExpandedId}\n                    isExpanded={expandedId === p.id}\n                  />\n                ))}\n              </div>\n            </div>\n          )}\n\n          {/* Process Column */}\n          {processes.length > 0 && (\n            <div>\n              <div className=\"flex items-center gap-2 mb-3\">\n                <Layers className=\"w-4 h-4 text-blue-400\" />\n                <h3 className=\"text-sm font-medium text-white\">\n                  {t('profiles.localProfiles.process')}\n                </h3>\n                <span className=\"text-xs text-bambu-gray\">({processes.length})</span>\n              </div>\n              <div className=\"space-y-2\">\n                {processes.map(p => (\n                  <PresetCard\n                    key={p.id}\n                    preset={p}\n                    onDelete={(id) => setDeleteConfirm(id)}\n                    onExpand={setExpandedId}\n                    isExpanded={expandedId === p.id}\n                  />\n                ))}\n              </div>\n            </div>\n          )}\n\n          {/* Printer Column */}\n          {printers.length > 0 && (\n            <div>\n              <div className=\"flex items-center gap-2 mb-3\">\n                <Settings2 className=\"w-4 h-4 text-orange-400\" />\n                <h3 className=\"text-sm font-medium text-white\">\n                  {t('profiles.localProfiles.printer')}\n                </h3>\n                <span className=\"text-xs text-bambu-gray\">({printers.length})</span>\n              </div>\n              <div className=\"space-y-2\">\n                {printers.map(p => (\n                  <PresetCard\n                    key={p.id}\n                    preset={p}\n                    onDelete={(id) => setDeleteConfirm(id)}\n                    onExpand={setExpandedId}\n                    isExpanded={expandedId === p.id}\n                  />\n                ))}\n              </div>\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Delete Confirmation Modal */}\n      {deleteConfirm !== null && (\n        <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\">\n          <div className=\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-6 max-w-sm mx-4\">\n            <div className=\"flex items-center gap-2 mb-3\">\n              <AlertCircle className=\"w-5 h-5 text-red-400\" />\n              <h3 className=\"text-white font-medium\">{t('profiles.localProfiles.deleteConfirmTitle')}</h3>\n            </div>\n            <p className=\"text-sm text-bambu-gray mb-4\">{t('profiles.localProfiles.deleteConfirm')}</p>\n            <div className=\"flex justify-end gap-2\">\n              <Button variant=\"secondary\" size=\"sm\" onClick={() => setDeleteConfirm(null)}>\n                {t('profiles.localProfiles.cancel')}\n              </Button>\n              <Button\n                variant=\"danger\"\n                size=\"sm\"\n                onClick={() => deleteMutation.mutate(deleteConfirm)}\n                disabled={deleteMutation.isPending}\n              >\n                {deleteMutation.isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Trash2 className=\"w-4 h-4\" />}\n                {t('profiles.localProfiles.delete')}\n              </Button>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/LogViewer.tsx",
    "content": "import { useState, useEffect, useRef, useMemo } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  Play,\n  Square,\n  Trash2,\n  RefreshCw,\n  Search,\n  X,\n  ChevronDown,\n  ChevronUp,\n  AlertCircle,\n  AlertTriangle,\n  Info,\n  Bug,\n} from 'lucide-react';\nimport { supportApi, type LogEntry } from '../api/client';\n\nconst LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;\ntype LogLevel = (typeof LOG_LEVELS)[number];\n\nconst levelColors: Record<LogLevel, string> = {\n  DEBUG: 'text-gray-400',\n  INFO: 'text-blue-400',\n  WARNING: 'text-yellow-400',\n  ERROR: 'text-red-400',\n};\n\nconst levelIcons: Record<LogLevel, typeof Info> = {\n  DEBUG: Bug,\n  INFO: Info,\n  WARNING: AlertTriangle,\n  ERROR: AlertCircle,\n};\n\nexport function LogViewer() {\n  const queryClient = useQueryClient();\n  const [autoScroll, setAutoScroll] = useState(true);\n  const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());\n  const [searchQuery, setSearchQuery] = useState('');\n  const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');\n  const [isExpanded, setIsExpanded] = useState(false);\n  const [isStreaming, setIsStreaming] = useState(false);\n  const logContainerRef = useRef<HTMLDivElement>(null);\n\n  // Fetch logs with polling when streaming is enabled\n  const { data, isLoading, refetch } = useQuery({\n    queryKey: ['application-logs', levelFilter, searchQuery],\n    queryFn: () =>\n      supportApi.getLogs({\n        limit: 200,\n        level: levelFilter === 'ALL' ? undefined : levelFilter,\n        search: searchQuery || undefined,\n      }),\n    refetchInterval: isStreaming ? 2000 : false, // Poll every 2 seconds when streaming\n    enabled: isExpanded, // Only fetch when viewer is expanded\n  });\n\n  // Stop streaming when viewer is collapsed\n  useEffect(() => {\n    if (!isExpanded) {\n      setIsStreaming(false);\n    }\n  }, [isExpanded]);\n\n  const clearMutation = useMutation({\n    mutationFn: () => supportApi.clearLogs(),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['application-logs'] });\n    },\n  });\n\n  // Auto-scroll to bottom when new logs arrive\n  useEffect(() => {\n    if (autoScroll && logContainerRef.current && data?.entries) {\n      logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;\n    }\n  }, [data?.entries, autoScroll]);\n\n  const toggleExpand = (index: number) => {\n    setExpandedLogs((prev) => {\n      const newSet = new Set(prev);\n      if (newSet.has(index)) {\n        newSet.delete(index);\n      } else {\n        newSet.add(index);\n      }\n      return newSet;\n    });\n  };\n\n  const formatTimestamp = (timestamp: string) => {\n    // Input format: \"2024-01-15 10:30:45,123\"\n    const parts = timestamp.split(' ');\n    if (parts.length >= 2) {\n      return parts[1]; // Return just the time part\n    }\n    return timestamp;\n  };\n\n  const entries = useMemo(() => data?.entries ?? [], [data?.entries]);\n\n  // Reverse to show newest at bottom (better for auto-scroll UX)\n  const displayEntries = useMemo(() => [...entries].reverse(), [entries]);\n\n  const LevelIcon = ({ level }: { level: string }) => {\n    const Icon = levelIcons[level as LogLevel] || Info;\n    return <Icon className={`w-3.5 h-3.5 ${levelColors[level as LogLevel] || 'text-gray-400'}`} />;\n  };\n\n  return (\n    <div className=\"bg-bambu-dark rounded-lg overflow-hidden\">\n      {/* Header - always visible */}\n      <button\n        onClick={() => setIsExpanded(!isExpanded)}\n        className=\"w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/50 transition-colors\"\n      >\n        <div className=\"flex items-center gap-3\">\n          <div\n            className={`p-2 rounded-lg ${\n              isStreaming\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'bg-bambu-dark-tertiary text-bambu-gray'\n            }`}\n          >\n            <Bug className=\"w-5 h-5\" />\n          </div>\n          <div className=\"text-left\">\n            <p className=\"font-medium text-white\">Application Logs</p>\n            <p className=\"text-sm text-bambu-gray\">\n              {isStreaming\n                ? `Live streaming - ${data?.filtered_count ?? 0} entries`\n                : 'View and filter application logs'}\n            </p>\n          </div>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {isStreaming && (\n            <span className=\"flex items-center gap-1.5 px-2 py-1 bg-bambu-green/20 rounded text-bambu-green text-xs\">\n              <span className=\"w-1.5 h-1.5 bg-bambu-green rounded-full animate-pulse\" />\n              Live\n            </span>\n          )}\n          {isExpanded ? (\n            <ChevronUp className=\"w-5 h-5 text-bambu-gray\" />\n          ) : (\n            <ChevronDown className=\"w-5 h-5 text-bambu-gray\" />\n          )}\n        </div>\n      </button>\n\n      {/* Expanded content */}\n      {isExpanded && (\n        <div className=\"border-t border-bambu-dark-tertiary\">\n          {/* Controls */}\n          <div className=\"flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary\">\n            <div className=\"flex items-center gap-2 flex-wrap\">\n              {/* Start/Stop streaming button */}\n              {isStreaming ? (\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setIsStreaming(false);\n                  }}\n                  className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors\"\n                >\n                  <Square className=\"w-4 h-4\" />\n                  Stop\n                </button>\n              ) : (\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setIsStreaming(true);\n                    refetch(); // Immediately fetch when starting\n                  }}\n                  className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 rounded transition-colors\"\n                >\n                  <Play className=\"w-4 h-4\" />\n                  Start\n                </button>\n              )}\n\n              {/* Clear button */}\n              <button\n                onClick={() => clearMutation.mutate()}\n                disabled={clearMutation.isPending || entries.length === 0}\n                className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50\"\n              >\n                <Trash2 className=\"w-4 h-4\" />\n                Clear\n              </button>\n\n              {/* Refresh button */}\n              <button\n                onClick={() => refetch()}\n                disabled={isLoading}\n                className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50\"\n              >\n                <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />\n              </button>\n\n              <div className=\"flex-1\" />\n\n              {/* Auto-scroll toggle */}\n              <label className=\"flex items-center gap-2 text-sm text-bambu-gray cursor-pointer\">\n                <input\n                  type=\"checkbox\"\n                  checked={autoScroll}\n                  onChange={(e) => setAutoScroll(e.target.checked)}\n                  className=\"rounded border-bambu-dark-tertiary bg-bambu-dark-tertiary\"\n                />\n                Auto-scroll\n              </label>\n\n              {/* Entry count */}\n              <span className=\"text-sm text-bambu-gray\">\n                {data?.filtered_count ?? 0}/{data?.total_in_file ?? 0}\n              </span>\n            </div>\n\n            {/* Search and Filter Row */}\n            <div className=\"flex items-center gap-2\">\n              {/* Search input */}\n              <div className=\"relative flex-1\">\n                <Search className=\"absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n                <input\n                  type=\"text\"\n                  placeholder=\"Search message or logger name...\"\n                  value={searchQuery}\n                  onChange={(e) => setSearchQuery(e.target.value)}\n                  className=\"w-full pl-8 pr-8 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                />\n                {searchQuery && (\n                  <button\n                    onClick={() => setSearchQuery('')}\n                    className=\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\"\n                  >\n                    <X className=\"w-4 h-4\" />\n                  </button>\n                )}\n              </div>\n\n              {/* Level filter */}\n              <div className=\"flex items-center gap-1 bg-bambu-dark-secondary rounded border border-bambu-dark-tertiary\">\n                <button\n                  onClick={() => setLevelFilter('ALL')}\n                  className={`px-2 py-1.5 text-xs rounded-l transition-colors ${\n                    levelFilter === 'ALL'\n                      ? 'bg-bambu-green text-white'\n                      : 'text-bambu-gray hover:text-white'\n                  }`}\n                >\n                  All\n                </button>\n                {LOG_LEVELS.map((level, idx) => (\n                  <button\n                    key={level}\n                    onClick={() => setLevelFilter(level)}\n                    className={`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${\n                      idx === LOG_LEVELS.length - 1 ? 'rounded-r' : ''\n                    } ${\n                      levelFilter === level\n                        ? `${levelColors[level]} bg-bambu-dark-tertiary`\n                        : 'text-bambu-gray hover:text-white'\n                    }`}\n                  >\n                    {level}\n                  </button>\n                ))}\n              </div>\n            </div>\n          </div>\n\n          {/* Log Content */}\n          <div\n            ref={logContainerRef}\n            className=\"overflow-auto font-mono text-xs bg-black min-h-[300px] max-h-[500px]\"\n          >\n            {entries.length === 0 ? (\n              <div className=\"flex flex-col items-center justify-center h-[300px] text-bambu-gray\">\n                <p className=\"mb-2\">No log entries found</p>\n                <p className=\"text-sm\">Log file may be empty or cleared</p>\n              </div>\n            ) : (\n              <div className=\"divide-y divide-bambu-dark-tertiary/30\">\n                {displayEntries.map((log: LogEntry, index: number) => {\n                  const isEntryExpanded = expandedLogs.has(index);\n                  const hasMultiLine = log.message.includes('\\n');\n\n                  return (\n                    <div\n                      key={index}\n                      className={`p-2 cursor-pointer hover:bg-bambu-dark-secondary/50 transition-colors ${\n                        isEntryExpanded ? 'bg-bambu-dark-secondary/30' : ''\n                      }`}\n                      onClick={() => hasMultiLine && toggleExpand(index)}\n                    >\n                      <div className=\"flex items-start gap-2\">\n                        <span className=\"text-bambu-gray/70 shrink-0 w-20\">\n                          {formatTimestamp(log.timestamp)}\n                        </span>\n                        <span className=\"shrink-0\">\n                          <LevelIcon level={log.level} />\n                        </span>\n                        <span className=\"text-purple-400/80 shrink-0 max-w-[200px] truncate\" title={log.logger_name}>\n                          [{log.logger_name}]\n                        </span>\n                        <span\n                          className={`flex-1 ${levelColors[log.level as LogLevel] || 'text-white/80'} ${\n                            !isEntryExpanded && hasMultiLine ? 'truncate' : ''\n                          }`}\n                        >\n                          {isEntryExpanded ? (\n                            <pre className=\"whitespace-pre-wrap break-all\">{log.message}</pre>\n                          ) : (\n                            log.message.split('\\n')[0]\n                          )}\n                        </span>\n                        {hasMultiLine && (\n                          <span className=\"text-bambu-gray/50 shrink-0\">\n                            {isEntryExpanded ? (\n                              <ChevronUp className=\"w-3.5 h-3.5\" />\n                            ) : (\n                              <ChevronDown className=\"w-3.5 h-3.5\" />\n                            )}\n                          </span>\n                        )}\n                      </div>\n                    </div>\n                  );\n                })}\n              </div>\n            )}\n          </div>\n\n          {/* Footer */}\n          <div className=\"flex items-center justify-between p-3 border-t border-bambu-dark-tertiary text-sm text-bambu-gray\">\n            {isStreaming ? (\n              <span className=\"flex items-center gap-2\">\n                <span className=\"w-2 h-2 bg-green-500 rounded-full animate-pulse\" />\n                Auto-refreshing every 2 seconds\n              </span>\n            ) : (\n              <span>Click Start to enable live log streaming</span>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/MQTTDebugModal.tsx",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp, Search } from 'lucide-react';\nimport { api, type MQTTLogEntry } from '../api/client';\nimport { Button } from './Button';\nimport { useState, useEffect, useRef, useMemo } from 'react';\n\ninterface MQTTDebugModalProps {\n  printerId: number;\n  printerName: string;\n  onClose: () => void;\n}\n\nexport function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [autoScroll, setAutoScroll] = useState(true);\n  const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());\n  const [searchQuery, setSearchQuery] = useState('');\n  const [directionFilter, setDirectionFilter] = useState<'all' | 'in' | 'out'>('all');\n  const logContainerRef = useRef<HTMLDivElement>(null);\n\n  const { data, isLoading, refetch } = useQuery({\n    queryKey: ['mqtt-logs', printerId],\n    queryFn: () => api.getMQTTLogs(printerId),\n    refetchInterval: 1000, // Poll every second when logging is enabled\n  });\n\n  const enableMutation = useMutation({\n    mutationFn: () => api.enableMQTTLogging(printerId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });\n    },\n  });\n\n  const disableMutation = useMutation({\n    mutationFn: () => api.disableMQTTLogging(printerId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });\n    },\n  });\n\n  const clearMutation = useMutation({\n    mutationFn: () => api.clearMQTTLogs(printerId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });\n    },\n  });\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  // Auto-scroll to bottom when new logs arrive\n  useEffect(() => {\n    if (autoScroll && logContainerRef.current) {\n      logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;\n    }\n  }, [data?.logs, autoScroll]);\n\n  const toggleExpand = (index: number) => {\n    setExpandedLogs((prev) => {\n      const newSet = new Set(prev);\n      if (newSet.has(index)) {\n        newSet.delete(index);\n      } else {\n        newSet.add(index);\n      }\n      return newSet;\n    });\n  };\n\n  const formatTimestamp = (timestamp: string) => {\n    const date = new Date(timestamp);\n    return date.toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 });\n  };\n\n  const formatPayload = (payload: unknown, expanded: boolean): string => {\n    if (payload === undefined || payload === null) {\n      return '<empty>';\n    }\n    // If payload is already a string, parse it first to format nicely\n    const obj = typeof payload === 'string' ? JSON.parse(payload) : payload;\n    const json = JSON.stringify(obj, null, expanded ? 2 : 0);\n    if (!expanded && json.length > 100) {\n      return json.substring(0, 100) + '...';\n    }\n    return json;\n  };\n\n  const loggingEnabled = data?.logging_enabled ?? false;\n  const logs = useMemo(() => data?.logs ?? [], [data?.logs]);\n\n  // Filter logs based on search query and direction filter\n  const filteredLogs = useMemo(() => {\n    return logs.filter((log) => {\n      // Direction filter\n      if (directionFilter !== 'all' && log.direction !== directionFilter) {\n        return false;\n      }\n      // Search filter\n      if (searchQuery.trim()) {\n        const query = searchQuery.toLowerCase();\n        const topicMatch = log.topic.toLowerCase().includes(query);\n        const payloadStr = JSON.stringify(log.payload).toLowerCase();\n        const payloadMatch = payloadStr.includes(query);\n        return topicMatch || payloadMatch;\n      }\n      return true;\n    });\n  }, [logs, searchQuery, directionFilter]);\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[85vh] flex flex-col\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n          <div>\n            <h2 className=\"text-lg font-semibold text-white\">{t('mqttDebug.title')}</h2>\n            <p className=\"text-sm text-bambu-gray\">{printerName}</p>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"text-bambu-gray hover:text-white transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Controls */}\n        <div className=\"flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-2\">\n            {loggingEnabled ? (\n              <Button\n                size=\"sm\"\n                variant=\"secondary\"\n                onClick={() => disableMutation.mutate()}\n                disabled={disableMutation.isPending}\n              >\n                <Square className=\"w-4 h-4\" />\n                {t('mqttDebug.stopLogging')}\n              </Button>\n            ) : (\n              <Button\n                size=\"sm\"\n                onClick={() => enableMutation.mutate()}\n                disabled={enableMutation.isPending}\n              >\n                <Play className=\"w-4 h-4\" />\n                {t('mqttDebug.startLogging')}\n              </Button>\n            )}\n            <Button\n              size=\"sm\"\n              variant=\"secondary\"\n              onClick={() => clearMutation.mutate()}\n              disabled={clearMutation.isPending || logs.length === 0}\n            >\n              <Trash2 className=\"w-4 h-4\" />\n              {t('mqttDebug.clearLog')}\n            </Button>\n            <Button\n              size=\"sm\"\n              variant=\"secondary\"\n              onClick={() => refetch()}\n              disabled={isLoading}\n            >\n              <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />\n            </Button>\n            <div className=\"flex-1\" />\n            <label className=\"flex items-center gap-2 text-sm text-bambu-gray cursor-pointer\">\n              <input\n                type=\"checkbox\"\n                checked={autoScroll}\n                onChange={(e) => setAutoScroll(e.target.checked)}\n                className=\"rounded border-bambu-dark-tertiary\"\n              />\n              Auto-scroll\n            </label>\n            <span className=\"text-sm text-bambu-gray\">\n              {filteredLogs.length}/{logs.length}\n            </span>\n          </div>\n\n          {/* Search and Filter Row */}\n          <div className=\"flex items-center gap-2\">\n            <div className=\"relative flex-1\">\n              <Search className=\"absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n              <input\n                type=\"text\"\n                placeholder={t('mqttDebug.searchPlaceholder')}\n                value={searchQuery}\n                onChange={(e) => setSearchQuery(e.target.value)}\n                className=\"w-full pl-8 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n              />\n              {searchQuery && (\n                <button\n                  onClick={() => setSearchQuery('')}\n                  className=\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\"\n                >\n                  <X className=\"w-4 h-4\" />\n                </button>\n              )}\n            </div>\n            <div className=\"flex items-center gap-1 bg-bambu-dark rounded border border-bambu-dark-tertiary\">\n              <button\n                onClick={() => setDirectionFilter('all')}\n                className={`px-2 py-1.5 text-xs rounded-l transition-colors ${\n                  directionFilter === 'all'\n                    ? 'bg-bambu-green text-white'\n                    : 'text-bambu-gray hover:text-white'\n                }`}\n              >\n                {t('mqttDebug.all')}\n              </button>\n              <button\n                onClick={() => setDirectionFilter('in')}\n                className={`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${\n                  directionFilter === 'in'\n                    ? 'bg-blue-500 text-white'\n                    : 'text-bambu-gray hover:text-white'\n                }`}\n              >\n                <ArrowDown className=\"w-3 h-3\" />\n                {t('mqttDebug.incoming')}\n              </button>\n              <button\n                onClick={() => setDirectionFilter('out')}\n                className={`px-2 py-1.5 text-xs rounded-r transition-colors flex items-center gap-1 ${\n                  directionFilter === 'out'\n                    ? 'bg-green-500 text-white'\n                    : 'text-bambu-gray hover:text-white'\n                }`}\n              >\n                <ArrowUp className=\"w-3 h-3\" />\n                {t('mqttDebug.outgoing')}\n              </button>\n            </div>\n          </div>\n        </div>\n\n        {/* Log Content */}\n        <div\n          ref={logContainerRef}\n          className=\"flex-1 overflow-auto p-4 font-mono text-xs bg-black min-h-[400px]\"\n        >\n          {logs.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center h-full text-bambu-gray\">\n              <p className=\"mb-2\">{t('mqttDebug.noMessages')}</p>\n              {!loggingEnabled && (\n                <p className=\"text-sm\">{t('mqttDebug.startLoggingHint')}</p>\n              )}\n            </div>\n          ) : filteredLogs.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center h-full text-bambu-gray\">\n              <p className=\"mb-2\">{t('mqttDebug.noMessagesMatch')}</p>\n              <p className=\"text-sm\">{t('mqttDebug.adjustFilterHint')}</p>\n            </div>\n          ) : (\n            <div className=\"space-y-1\">\n              {filteredLogs.map((log: MQTTLogEntry, index: number) => {\n                const isExpanded = expandedLogs.has(index);\n                const isIncoming = log.direction === 'in';\n\n                return (\n                  <div\n                    key={index}\n                    className={`p-2 rounded cursor-pointer hover:bg-bambu-dark-secondary transition-colors ${\n                      isExpanded ? 'bg-bambu-dark-secondary' : ''\n                    }`}\n                    onClick={() => toggleExpand(index)}\n                  >\n                    <div className=\"flex items-start gap-2\">\n                      <span className=\"text-bambu-gray shrink-0\">\n                        {formatTimestamp(log.timestamp)}\n                      </span>\n                      <span\n                        className={`shrink-0 ${\n                          isIncoming ? 'text-blue-400' : 'text-green-400'\n                        }`}\n                        title={isIncoming ? t('mqttDebug.incoming') : t('mqttDebug.outgoing')}\n                      >\n                        {isIncoming ? (\n                          <ArrowDown className=\"w-3 h-3\" />\n                        ) : (\n                          <ArrowUp className=\"w-3 h-3\" />\n                        )}\n                      </span>\n                      <span className=\"text-purple-400 shrink-0\">{log.topic}</span>\n                    </div>\n                    {isExpanded ? (\n                      <pre className=\"mt-2 p-3 bg-gray-900 border border-gray-700 rounded text-green-400 overflow-x-auto whitespace-pre-wrap break-all max-h-96 overflow-y-auto text-xs\">\n                        {formatPayload(log.payload, true)}\n                      </pre>\n                    ) : (\n                      <pre className=\"mt-1 text-white/80 truncate\">\n                        {formatPayload(log.payload, false)}\n                      </pre>\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n          )}\n        </div>\n\n        {/* Footer */}\n        <div className=\"flex items-center justify-between p-4 border-t border-bambu-dark-tertiary\">\n          <div className=\"text-sm text-bambu-gray\">\n            {loggingEnabled ? (\n              <span className=\"flex items-center gap-2\">\n                <span className=\"w-2 h-2 bg-green-500 rounded-full animate-pulse\" />\n                {t('mqttDebug.loggingActive')}\n              </span>\n            ) : (\n              <span>{t('mqttDebug.loggingStopped')}</span>\n            )}\n          </div>\n          <Button variant=\"secondary\" onClick={onClose}>\n            {t('common.close')}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/MetricToggle.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nexport type Metric = 'weight' | 'prints' | 'time';\n\nconst METRICS: Metric[] = ['weight', 'prints', 'time'];\n\ninterface MetricToggleProps {\n  value: Metric;\n  onChange: (metric: Metric) => void;\n  exclude?: Metric[];\n}\n\nexport function MetricToggle({ value, onChange, exclude }: MetricToggleProps) {\n  const { t } = useTranslation();\n\n  const labels: Record<Metric, string> = {\n    weight: t('stats.filamentByWeight'),\n    prints: t('stats.filamentByPrints'),\n    time: t('stats.filamentByTime'),\n  };\n\n  const metrics = exclude ? METRICS.filter(m => !exclude.includes(m)) : METRICS;\n\n  return (\n    <div className=\"flex gap-0.5 bg-bambu-dark rounded-lg p-0.5\">\n      {metrics.map(m => (\n        <button\n          key={m}\n          onClick={() => onChange(m)}\n          className={`px-2 py-0.5 text-xs rounded-md transition-colors ${\n            value === m ? 'bg-bambu-green text-white' : 'text-bambu-gray hover:text-white'\n          }`}\n        >\n          {labels[m]}\n        </button>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ModelViewer.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport * as THREE from 'three';\nimport { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';\nimport { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';\nimport { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';\nimport JSZip from 'jszip';\nimport { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';\nimport { Button } from './Button';\nimport { getAuthToken } from '../api/client';\n\ninterface BuildVolume {\n  x: number;\n  y: number;\n  z: number;\n}\n\ninterface ModelViewerProps {\n  url: string;\n  fileType?: string;\n  buildVolume?: BuildVolume;\n  filamentColors?: string[];\n  selectedPlateId?: number | null;\n  className?: string;\n}\n\ninterface MeshData {\n  vertices: number[];\n  triangles: number[];\n  extruder: number; // Per-mesh extruder index for coloring\n}\n\ninterface ObjectData {\n  id: string;\n  meshes: MeshData[];\n  defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)\n  plateId?: number | null;\n}\n\ninterface BuildItem {\n  objectId: string;\n  transform: THREE.Matrix4;\n  extruder?: number; // Can override object's extruder\n  plateId?: number | null;\n}\n\ninterface Parsed3MFData {\n  objects: Map<string, ObjectData>;\n  buildItems: BuildItem[];\n  plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;\n  plateOffsets: Map<number, { offsetX: number; offsetY: number }>;\n}\n\n// Parse 3MF transform - keep in 3MF coordinate space (Z-up)\nfunction parseTransform3MF(transformStr: string | null): THREE.Matrix4 {\n  const matrix = new THREE.Matrix4();\n  if (!transformStr) {\n    return matrix; // Identity matrix\n  }\n\n  // 3MF transform is a 3x4 affine matrix in row-major order:\n  // \"m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32\"\n  // Where (m30, m31, m32) is the translation vector\n  const values = transformStr.trim().split(/\\s+/).map(parseFloat);\n  if (values.length >= 12) {\n    // Three.js Matrix4.set takes row-major order arguments:\n    // set(n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44)\n    // 3MF row-major: m00, m01, m02, m10, m11, m12, m20, m21, m22, m30, m31, m32\n    matrix.set(\n      values[0], values[1], values[2], values[9],   // m00, m01, m02, tx\n      values[3], values[4], values[5], values[10],  // m10, m11, m12, ty\n      values[6], values[7], values[8], values[11],  // m20, m21, m22, tz\n      0, 0, 0, 1\n    );\n  }\n  return matrix;\n}\n\n// Alias for backwards compatibility\nconst parseTransform = parseTransform3MF;\n\nasync function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Promise<MeshData[]> {\n  const meshes: MeshData[] = [];\n  const meshElements = doc.getElementsByTagName('mesh');\n\n  for (let j = 0; j < meshElements.length; j++) {\n    const meshEl = meshElements[j];\n    const vertices: number[] = [];\n    const triangles: number[] = [];\n\n    const vertexElements = meshEl.getElementsByTagName('vertex');\n    for (let k = 0; k < vertexElements.length; k++) {\n      const v = vertexElements[k];\n      vertices.push(\n        parseFloat(v.getAttribute('x') || '0'),\n        parseFloat(v.getAttribute('y') || '0'),\n        parseFloat(v.getAttribute('z') || '0')\n      );\n    }\n\n    const triangleElements = meshEl.getElementsByTagName('triangle');\n    for (let k = 0; k < triangleElements.length; k++) {\n      const t = triangleElements[k];\n      triangles.push(\n        parseInt(t.getAttribute('v1') || '0'),\n        parseInt(t.getAttribute('v2') || '0'),\n        parseInt(t.getAttribute('v3') || '0')\n      );\n    }\n\n    if (vertices.length > 0 && triangles.length > 0) {\n      meshes.push({ vertices, triangles, extruder: defaultExtruder });\n    }\n  }\n  return meshes;\n}\n\nfunction parsePlateIdFromAttributes(element: Element): number | null {\n  const plateAttribute = Array.from(element.attributes).find((attr) => {\n    const name = attr.name.toLowerCase();\n    return (\n      name === 'plate_id' ||\n      name === 'plater_id' ||\n      name === 'plateid' ||\n      name === 'platerid' ||\n      name.endsWith(':plate_id') ||\n      name.endsWith(':plater_id')\n    );\n  });\n\n  if (!plateAttribute?.value) return null;\n  const parsed = Number.parseInt(plateAttribute.value, 10);\n  return Number.isFinite(parsed) ? parsed : null;\n}\n\nasync function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {\n  let zip: JSZip;\n  try {\n    zip = await JSZip.loadAsync(arrayBuffer);\n  } catch {\n    throw new Error('Unsupported file format');\n  }\n  const objects = new Map<string, ObjectData>();\n  const buildItems: BuildItem[] = [];\n  const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();\n  const plateOffsets = new Map<number, { offsetX: number; offsetY: number }>();\n  const parser = new DOMParser();\n\n  // Helper to load and parse a model file from the zip\n  async function loadModelFile(path: string): Promise<Document | null> {\n    // Normalize path (remove leading slash)\n    const normalizedPath = path.startsWith('/') ? path.slice(1) : path;\n    const file = zip.files[normalizedPath];\n    if (!file) return null;\n    const content = await file.async('string');\n    return parser.parseFromString(content, 'application/xml');\n  }\n\n  // Parse model_settings.config to get extruder assignments\n  // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder\n  const extruderMapById = new Map<string, number>();\n  const partExtruderMap = new Map<string, number>(); // Key: \"objectId:partId\"\n  const objectNameById = new Map<string, string>();\n  const plateAssignmentsByObjectId = new Map<string, number>();\n  const modelSettingsFile = zip.files['Metadata/model_settings.config'];\n  if (modelSettingsFile) {\n    try {\n      const content = await modelSettingsFile.async('string');\n      const doc = parser.parseFromString(content, 'application/xml');\n      const objectElements = doc.getElementsByTagName('object');\n      for (let i = 0; i < objectElements.length; i++) {\n        const objEl = objectElements[i];\n        const objectId = objEl.getAttribute('id');\n        if (!objectId) continue;\n\n        // Find object-level extruder + name\n        const directMetadata = Array.from(objEl.children).filter(\n          (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'\n        );\n        if (directMetadata.length > 0) {\n          const extruderVal = directMetadata[0].getAttribute('value');\n          if (extruderVal) {\n            extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1));\n          }\n        }\n\n        const nameMetadata = Array.from(objEl.children).find(\n          (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'name'\n        );\n        const objectName = nameMetadata?.getAttribute('value');\n        if (objectName) {\n          objectNameById.set(objectId, objectName);\n        }\n\n        // Find part-level extruders\n        const partElements = objEl.getElementsByTagName('part');\n        for (let j = 0; j < partElements.length; j++) {\n          const partEl = partElements[j];\n          const partId = partEl.getAttribute('id');\n          if (!partId) continue;\n\n          // Look for extruder in part's direct children\n          const partMetadata = Array.from(partEl.children).filter(\n            (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'\n          );\n          if (partMetadata.length > 0) {\n            const extruderVal = partMetadata[0].getAttribute('value');\n            if (extruderVal) {\n              partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(extruderVal, 10) - 1));\n            }\n          }\n        }\n      }\n\n      // Parse plate -> object assignments\n      const plateElements = doc.getElementsByTagName('plate');\n      for (let i = 0; i < plateElements.length; i++) {\n        const plateEl = plateElements[i];\n        let plateId: number | null = null;\n        const metadataElements = plateEl.getElementsByTagName('metadata');\n        let plateOffsetX = 0;\n        let plateOffsetY = 0;\n        for (let j = 0; j < metadataElements.length; j++) {\n          const metaEl = metadataElements[j];\n          const key = metaEl.getAttribute('key');\n          if (key === 'plater_id' || key === 'plate_id') {\n            const value = metaEl.getAttribute('value');\n            if (value) {\n              const parsed = Number.parseInt(value, 10);\n              if (Number.isFinite(parsed)) {\n                plateId = parsed;\n              }\n            }\n          } else if (key === 'pos_x') {\n            const value = metaEl.getAttribute('value');\n            const parsed = value ? Number.parseFloat(value) : Number.NaN;\n            if (Number.isFinite(parsed)) {\n              plateOffsetX = parsed;\n            }\n          } else if (key === 'pos_y') {\n            const value = metaEl.getAttribute('value');\n            const parsed = value ? Number.parseFloat(value) : Number.NaN;\n            if (Number.isFinite(parsed)) {\n              plateOffsetY = parsed;\n            }\n          }\n        }\n        if (plateId == null) continue;\n        if (plateOffsetX !== 0 || plateOffsetY !== 0) {\n          plateOffsets.set(plateId, { offsetX: plateOffsetX, offsetY: plateOffsetY });\n        }\n\n        const modelInstances = plateEl.getElementsByTagName('model_instance');\n        for (let j = 0; j < modelInstances.length; j++) {\n          const instanceEl = modelInstances[j];\n          const instanceMetadata = instanceEl.getElementsByTagName('metadata');\n          for (let k = 0; k < instanceMetadata.length; k++) {\n            const metaEl = instanceMetadata[k];\n            if (metaEl.getAttribute('key') === 'object_id') {\n              const value = metaEl.getAttribute('value');\n              if (value) {\n                plateAssignmentsByObjectId.set(value, plateId);\n              }\n            }\n          }\n        }\n      }\n    } catch {\n      // Silently ignore model_settings.config parsing errors\n    }\n  }\n\n  // Parse plate_*.json for plate assignments by object name (source-only / unsliced files)\n  const plateAssignmentsByName = new Map<string, number>();\n  const plateJsonNames = Object.keys(zip.files).filter(\n    (name) => name.startsWith('Metadata/plate_') && name.endsWith('.json')\n  );\n  for (const name of plateJsonNames) {\n    const match = name.match(/^Metadata\\/plate_(\\d+)\\.json$/);\n    if (!match) continue;\n    const plateIndex = Number.parseInt(match[1], 10);\n    if (!Number.isFinite(plateIndex)) continue;\n    try {\n      const payload = await zip.files[name].async('string');\n      const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] };\n      const objectsList = json.bbox_objects ?? [];\n      for (const entry of objectsList) {\n        if (entry?.name) {\n          plateAssignmentsByName.set(entry.name, plateIndex);\n        }\n      }\n      if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {\n        const [minX, minY, maxX, maxY] = json.bbox_all;\n        if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) {\n          plateBounds.set(plateIndex, { minX, minY, maxX, maxY });\n        }\n      }\n    } catch {\n      // Ignore plate json parsing errors\n    }\n  }\n\n  // Find the main 3D model file\n  const mainModelPath = Object.keys(zip.files).find(\n    (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')\n  );\n\n  if (!mainModelPath) {\n    // Fallback: try to find any .model file\n    const anyModelPath = Object.keys(zip.files).find((name) => name.endsWith('.model'));\n    if (anyModelPath) {\n      const doc = await loadModelFile(anyModelPath);\n      if (doc) {\n        const meshes = await parseMeshFromDoc(doc, 0);\n        if (meshes.length > 0) {\n          objects.set('1', { id: '1', meshes, defaultExtruder: 0 });\n        }\n      }\n    }\n    return { objects, buildItems, plateBounds, plateOffsets };\n  }\n\n  const mainDoc = await loadModelFile(mainModelPath);\n  if (!mainDoc) return { objects, buildItems, plateBounds, plateOffsets };\n\n  // Parse objects - Bambu Studio uses components to reference external files\n  const objectElements = mainDoc.getElementsByTagName('object');\n  for (let i = 0; i < objectElements.length; i++) {\n    const objEl = objectElements[i];\n    const objectId = objEl.getAttribute('id');\n    if (!objectId) continue;\n\n    const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;\n\n    // Get default extruder from model_settings.config map, falling back to attribute or default\n    let defaultExtruder = extruderMapById.get(objectId) ?? -1;\n    if (defaultExtruder < 0) {\n      const extruderAttr = objEl.getAttribute('p:extruder') || objEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'extruder') || '1';\n      defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1);\n    }\n\n    const meshes: MeshData[] = [];\n\n    // Check for direct mesh in this object\n    const objMeshElements = objEl.getElementsByTagName('mesh');\n    for (let j = 0; j < objMeshElements.length; j++) {\n      const meshEl = objMeshElements[j];\n      const vertices: number[] = [];\n      const triangles: number[] = [];\n\n      const vertexElements = meshEl.getElementsByTagName('vertex');\n      for (let k = 0; k < vertexElements.length; k++) {\n        const v = vertexElements[k];\n        vertices.push(\n          parseFloat(v.getAttribute('x') || '0'),\n          parseFloat(v.getAttribute('y') || '0'),\n          parseFloat(v.getAttribute('z') || '0')\n        );\n      }\n\n      const triangleElements = meshEl.getElementsByTagName('triangle');\n      for (let k = 0; k < triangleElements.length; k++) {\n        const t = triangleElements[k];\n        triangles.push(\n          parseInt(t.getAttribute('v1') || '0'),\n          parseInt(t.getAttribute('v2') || '0'),\n          parseInt(t.getAttribute('v3') || '0')\n        );\n      }\n\n      if (vertices.length > 0 && triangles.length > 0) {\n        meshes.push({ vertices, triangles, extruder: defaultExtruder });\n      }\n    }\n\n    // Check for component references (Bambu Studio style)\n    const componentElements = objEl.getElementsByTagName('component');\n    for (let j = 0; j < componentElements.length; j++) {\n      const compEl = componentElements[j];\n      // p:path attribute contains the external file reference\n      const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');\n      // objectid in component corresponds to part id in model_settings\n      const compObjectId = compEl.getAttribute('objectid');\n\n      if (extPath) {\n        const extDoc = await loadModelFile(extPath);\n        if (extDoc) {\n          // Look up per-part extruder, falling back to object's default\n          const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;\n          const compExtruder = partKey ? (partExtruderMap.get(partKey) ?? defaultExtruder) : defaultExtruder;\n\n          const extMeshes = await parseMeshFromDoc(extDoc, compExtruder);\n\n          // Apply component transform if present\n          const compTransformStr = compEl.getAttribute('transform');\n          const compTransform = parseTransform(compTransformStr);\n\n          for (const mesh of extMeshes) {\n            if (compTransformStr) {\n              // Apply transform to vertices (in 3MF coordinate space, before Y/Z swap)\n              const transformedVertices: number[] = [];\n              for (let k = 0; k < mesh.vertices.length; k += 3) {\n                const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]);\n                v.applyMatrix4(compTransform);\n                transformedVertices.push(v.x, v.y, v.z);\n              }\n              meshes.push({ vertices: transformedVertices, triangles: mesh.triangles, extruder: mesh.extruder });\n            } else {\n              meshes.push(mesh);\n            }\n          }\n        }\n      }\n    }\n\n    if (meshes.length > 0) {\n      objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });\n    }\n  }\n\n  // Parse build items (placement on build plate)\n  const buildElements = mainDoc.getElementsByTagName('build');\n  if (buildElements.length > 0) {\n    const itemElements = buildElements[0].getElementsByTagName('item');\n    for (let i = 0; i < itemElements.length; i++) {\n      const itemEl = itemElements[i];\n      const objectId = itemEl.getAttribute('objectid');\n      if (!objectId) continue;\n\n      const transform = parseTransform(itemEl.getAttribute('transform'));\n      const itemPlateId = parsePlateIdFromAttributes(itemEl);\n      const objectPlateId = objects.get(objectId)?.plateId ?? null;\n      const objectName = objectNameById.get(objectId);\n      const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;\n      buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });\n    }\n  }\n\n  return { objects, buildItems, plateBounds, plateOffsets };\n}\n\nfunction createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {\n  const geometry = new THREE.BufferGeometry();\n\n  // Convert from 3MF Z-up to Three.js Y-up coordinate system\n  // 3MF: X right, Y back, Z up -> Three.js: X right, Y up, Z forward\n  const positions = new Float32Array(mesh.vertices.length);\n  for (let i = 0; i < mesh.vertices.length; i += 3) {\n    positions[i] = mesh.vertices[i];       // X stays X\n    positions[i + 1] = mesh.vertices[i + 2]; // Y becomes Z (up)\n    positions[i + 2] = mesh.vertices[i + 1]; // Z becomes Y\n  }\n\n  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));\n  geometry.setIndex(mesh.triangles);\n\n  // Compute normals\n  geometry.computeVertexNormals();\n\n  return geometry;\n}\n\nfunction disposeGroup(group: THREE.Group) {\n  group.traverse((child) => {\n    if (child instanceof THREE.Mesh) {\n      child.geometry.dispose();\n      if (Array.isArray(child.material)) {\n        for (const material of child.material) {\n          material.dispose();\n        }\n      } else {\n        child.material.dispose();\n      }\n    }\n  });\n}\n\nfunction buildModelGroup(\n  parsedData: Parsed3MFData,\n  selectedPlateId: number | null,\n  filamentColors?: string[],\n): THREE.Group {\n  const { objects, buildItems } = parsedData;\n  const group = new THREE.Group();\n\n  // Create materials for each extruder color\n  const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {\n    const defaultColor = '#00ae42';\n    const colorStr = filamentColors?.[extruder] || defaultColor;\n    // Convert hex color string to THREE.js color\n    const color = new THREE.Color(colorStr);\n    return new THREE.MeshPhongMaterial({\n      color,\n      shininess: 30,\n      flatShading: false,\n    });\n  };\n\n  // Group geometries by extruder index (using per-mesh extruder)\n  const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();\n\n  const hasPlateAssignments = buildItems.some((item) => item.plateId != null);\n  const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments\n    ? buildItems\n    : buildItems.filter((item) => item.plateId === selectedPlateId);\n  const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;\n\n  // If we have build items, use them for positioning\n  if (activeBuildItems.length > 0) {\n    for (const item of activeBuildItems) {\n      const objectData = objects.get(item.objectId);\n      if (!objectData) continue;\n\n      for (const meshData of objectData.meshes) {\n        // Use mesh's extruder, or item override, or object default\n        const extruder = item.extruder ?? meshData.extruder;\n\n        // Apply build transform to vertices in 3MF space BEFORE coordinate conversion\n        const transformedVertices: number[] = [];\n        for (let k = 0; k < meshData.vertices.length; k += 3) {\n          const v = new THREE.Vector3(\n            meshData.vertices[k],\n            meshData.vertices[k + 1],\n            meshData.vertices[k + 2]\n          );\n          v.applyMatrix4(item.transform);\n          transformedVertices.push(v.x, v.y, v.z);\n        }\n        // Now create geometry with coordinate conversion\n        const geometry = createGeometryFromMesh({\n          vertices: transformedVertices,\n          triangles: meshData.triangles,\n          extruder: extruder,\n        });\n\n        if (!geometriesByExtruder.has(extruder)) {\n          geometriesByExtruder.set(extruder, []);\n        }\n        geometriesByExtruder.get(extruder)!.push(geometry);\n      }\n    }\n  } else {\n    // Fallback: just add all objects without transforms\n    for (const objectData of objects.values()) {\n      for (const meshData of objectData.meshes) {\n        // Use per-mesh extruder\n        const extruder = meshData.extruder;\n        const geometry = createGeometryFromMesh(meshData);\n        if (!geometriesByExtruder.has(extruder)) {\n          geometriesByExtruder.set(extruder, []);\n        }\n        geometriesByExtruder.get(extruder)!.push(geometry);\n      }\n    }\n  }\n\n  // Create meshes for each extruder group\n  for (const [extruder, geometries] of geometriesByExtruder) {\n    if (geometries.length === 0) continue;\n\n    const mergedGeometry = geometries.length === 1\n      ? geometries[0]\n      : mergeGeometries(geometries, false);\n\n    if (mergedGeometry) {\n      const material = getMaterial(extruder);\n      const mesh = new THREE.Mesh(mergedGeometry, material);\n      group.add(mesh);\n    }\n\n    // Dispose individual geometries if merged\n    if (geometries.length > 1) {\n      for (const geom of geometries) {\n        geom.dispose();\n      }\n    }\n  }\n\n  return group;\n}\n\nexport function ModelViewer({\n  url,\n  fileType,\n  buildVolume = { x: 256, y: 256, z: 256 },\n  filamentColors,\n  selectedPlateId = null,\n  className = '',\n}: ModelViewerProps) {\n  const { t } = useTranslation();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const rendererRef = useRef<THREE.WebGLRenderer | null>(null);\n  const sceneRef = useRef<THREE.Scene | null>(null);\n  const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);\n  const controlsRef = useRef<OrbitControls | null>(null);\n  const modelGroupRef = useRef<THREE.Group | null>(null);\n  const plateRef = useRef<THREE.Mesh | null>(null);\n  const gridRef = useRef<THREE.GridHelper | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n  const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);\n  const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);\n\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    const container = containerRef.current;\n    const width = container.clientWidth;\n    const height = container.clientHeight;\n\n    // Scene\n    const scene = new THREE.Scene();\n    scene.background = new THREE.Color(0x1a1a1a);\n    sceneRef.current = scene;\n\n    // Camera\n    const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);\n    camera.position.set(150, 150, 150);\n    cameraRef.current = camera;\n\n    // Renderer\n    const renderer = new THREE.WebGLRenderer({ antialias: true });\n    renderer.setSize(width, height);\n    renderer.setPixelRatio(window.devicePixelRatio);\n    container.appendChild(renderer.domElement);\n    rendererRef.current = renderer;\n\n    // Controls\n    const controls = new OrbitControls(camera, renderer.domElement);\n    controls.enableDamping = true;\n    controls.dampingFactor = 0.05;\n    controlsRef.current = controls;\n\n    // Lights\n    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);\n    scene.add(ambientLight);\n\n    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);\n    directionalLight.position.set(100, 100, 100);\n    scene.add(directionalLight);\n\n    const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);\n    directionalLight2.position.set(-100, 50, -100);\n    scene.add(directionalLight2);\n\n    // Grid - use the larger dimension for the grid size\n    const gridSize = Math.max(buildVolume.x, buildVolume.y);\n    const gridDivisions = Math.ceil(gridSize / 16);\n    const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333);\n    scene.add(gridHelper);\n    gridRef.current = gridHelper;\n\n    // Build plate indicator\n    const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);\n    const plateMaterial = new THREE.MeshBasicMaterial({\n      color: 0x00ae42,\n      transparent: true,\n      opacity: 0.15,\n      side: THREE.DoubleSide,\n    });\n    const plate = new THREE.Mesh(plateGeometry, plateMaterial);\n    plate.rotation.x = -Math.PI / 2;\n    plate.position.y = -0.5; // Slightly below Y=0 so models sit on top\n    scene.add(plate);\n    plateRef.current = plate;\n\n    // Animation loop - keep it simple for reliability\n    let animationId: number;\n    const animate = () => {\n      animationId = requestAnimationFrame(animate);\n      controls.update();\n      renderer.render(scene, camera);\n    };\n    animate();\n\n    setLoading(true);\n    setError(null);\n    setParsedData(null);\n    setStlGeometry(null);\n\n    const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();\n\n    // Build auth headers for fetch\n    const headers: HeadersInit = {};\n    const token = getAuthToken();\n    if (token) {\n      headers['Authorization'] = `Bearer ${token}`;\n    }\n\n    if (normalizedType === 'stl') {\n      fetch(url, { headers })\n        .then((res) => {\n          if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad'));\n          return res.arrayBuffer();\n        })\n        .then((buffer) => {\n          const loader = new STLLoader();\n          const geometry = loader.parse(buffer);\n          geometry.computeVertexNormals();\n          geometry.rotateX(-Math.PI / 2);\n          setStlGeometry(geometry);\n        })\n        .catch((err) => {\n          setError(err.message);\n          setLoading(false);\n        });\n    } else if (normalizedType === '3mf') {\n      fetch(url, { headers })\n        .then((res) => {\n          if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad'));\n          return res.arrayBuffer();\n        })\n        .then(parse3MF)\n        .then((parsed) => {\n          if (parsed.objects.size === 0) {\n            throw new Error(t('modelViewer.errors.noMeshes'));\n          }\n          setParsedData(parsed);\n        })\n        .catch((err) => {\n          setError(err.message);\n          setLoading(false);\n        });\n    } else {\n      setError(t('modelViewer.errors.unsupportedFormat'));\n      setLoading(false);\n    }\n\n    // Handle resize (window + container)\n    const handleResize = () => {\n      if (!container) return;\n      const w = container.clientWidth;\n      const h = container.clientHeight;\n      if (w === 0 || h === 0) return;\n      camera.aspect = w / h;\n      camera.updateProjectionMatrix();\n      renderer.setSize(w, h);\n    };\n    window.addEventListener('resize', handleResize);\n    const resizeObserver = new ResizeObserver(() => {\n      handleResize();\n    });\n    resizeObserver.observe(container);\n\n    return () => {\n      window.removeEventListener('resize', handleResize);\n      resizeObserver.disconnect();\n      cancelAnimationFrame(animationId);\n      controls.dispose();\n      renderer.dispose();\n      container.removeChild(renderer.domElement);\n      modelGroupRef.current = null;\n      plateRef.current = null;\n      gridRef.current = null;\n    };\n  }, [url, buildVolume, fileType, t]);\n\n  useEffect(() => {\n    if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;\n    if (!parsedData && !stlGeometry) return;\n\n    if (modelGroupRef.current) {\n      sceneRef.current.remove(modelGroupRef.current);\n      disposeGroup(modelGroupRef.current);\n    }\n\n    const isStlModel = !!stlGeometry;\n    const group = isStlModel\n      ? (() => {\n          const materialColor = filamentColors?.[0] || '#00ae42';\n          const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 });\n          const mesh = new THREE.Mesh(stlGeometry!, material);\n          const stlGroup = new THREE.Group();\n          stlGroup.add(mesh);\n          return stlGroup;\n        })()\n      : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);\n    modelGroupRef.current = group;\n    sceneRef.current.add(group);\n\n    // Get bounding box to position model\n    const box = new THREE.Box3().setFromObject(group);\n    const center = box.getCenter(new THREE.Vector3());\n\n    // Always place models on the build plate (Y=0)\n    group.position.y = -box.min.y;\n\n    const selectedPlateBounds = (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0)\n      ? parsedData!.plateBounds.get(selectedPlateId)\n      : undefined;\n    const selectedPlateOffset = (!isStlModel && selectedPlateId != null)\n      ? parsedData!.plateOffsets.get(selectedPlateId)\n      : undefined;\n    const shouldCenterOnPlate = isStlModel\n      || parsedData!.buildItems.length === 0\n      || (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset);\n    const centerOffsetX = shouldCenterOnPlate ? -center.x : 0;\n    const centerOffsetZ = shouldCenterOnPlate ? -center.z : 0;\n\n    let plateOffsetX = 0;\n    let plateOffsetZ = 0;\n    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {\n      const plateBox = new THREE.Box3().setFromObject(group);\n      plateOffsetX = plateBox.min.x - selectedPlateBounds.minX;\n      plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY;\n    }\n\n    const plateCenterX = buildVolume.x / 2;\n    const plateCenterZ = buildVolume.y / 2;\n\n    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {\n      group.position.x = centerOffsetX - plateOffsetX;\n      group.position.z = centerOffsetZ - plateOffsetZ;\n    } else if (!isStlModel && selectedPlateId != null && selectedPlateOffset) {\n      group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX);\n      group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY);\n    } else if (shouldCenterOnPlate) {\n      group.position.x = centerOffsetX + plateCenterX;\n      group.position.z = centerOffsetZ + plateCenterZ;\n    } else {\n      group.position.x = centerOffsetX;\n      group.position.z = centerOffsetZ;\n    }\n\n    if (plateRef.current) {\n      plateRef.current.position.x = plateCenterX;\n      plateRef.current.position.z = plateCenterZ;\n    }\n\n    if (gridRef.current) {\n      gridRef.current.position.x = plateCenterX;\n      gridRef.current.position.z = plateCenterZ;\n    }\n\n    // Recalculate bounding box after positioning\n    const finalBox = new THREE.Box3().setFromObject(group);\n    const finalCenter = finalBox.getCenter(new THREE.Vector3());\n    const finalSize = finalBox.getSize(new THREE.Vector3());\n\n    // Adjust camera to fit model\n    const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);\n    const cameraDistance = maxDim * 1.8;\n    cameraRef.current.position.set(\n      finalCenter.x + cameraDistance * 0.7,\n      finalCenter.y + cameraDistance * 0.5,\n      finalCenter.z + cameraDistance * 0.7\n    );\n    controlsRef.current.target.copy(finalCenter);\n    controlsRef.current.update();\n\n    setLoading(false);\n  }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);\n\n  const resetView = () => {\n    if (cameraRef.current && controlsRef.current) {\n      cameraRef.current.position.set(150, 150, 150);\n      controlsRef.current.target.set(0, 50, 0);\n      controlsRef.current.update();\n    }\n  };\n\n  const zoom = (factor: number) => {\n    if (cameraRef.current) {\n      cameraRef.current.position.multiplyScalar(factor);\n    }\n  };\n\n  return (\n    <div className={`relative ${className}`}>\n      <div ref={containerRef} className=\"w-full h-full min-h-[400px]\" />\n\n      {loading && (\n        <div className=\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\">\n          <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n        </div>\n      )}\n\n      {error && (\n        <div className=\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\">\n          <p className=\"text-red-400\">{error}</p>\n        </div>\n      )}\n\n      {!loading && !error && (\n        <div className=\"absolute bottom-4 right-4 flex gap-2\">\n          <Button variant=\"secondary\" size=\"sm\" onClick={() => zoom(0.8)}>\n            <ZoomIn className=\"w-4 h-4\" />\n          </Button>\n          <Button variant=\"secondary\" size=\"sm\" onClick={() => zoom(1.25)}>\n            <ZoomOut className=\"w-4 h-4\" />\n          </Button>\n          <Button variant=\"secondary\" size=\"sm\" onClick={resetView}>\n            <RotateCcw className=\"w-4 h-4\" />\n          </Button>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ModelViewerModal.tsx",
    "content": "import { useState, useEffect, useRef, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery } from '@tanstack/react-query';\nimport { X, ExternalLink, Box, Code2, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';\nimport { ModelViewer } from './ModelViewer';\nimport { GcodeViewer } from './GcodeViewer';\nimport { Button } from './Button';\nimport { api } from '../api/client';\nimport { openInSlicer, type SlicerType } from '../utils/slicer';\nimport type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';\n\ntype ViewTab = '3d' | 'gcode';\n\ninterface ModelViewerModalProps {\n  archiveId?: number;\n  libraryFileId?: number;\n  title: string;\n  fileType?: string;\n  onClose: () => void;\n}\n\ninterface Capabilities {\n  has_model: boolean;\n  has_gcode: boolean;\n  has_source: boolean;\n  build_volume: { x: number; y: number; z: number };\n  filament_colors: string[];\n}\n\nexport function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose }: ModelViewerModalProps) {\n  const { t } = useTranslation();\n  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });\n  const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';\n  const isLibrary = libraryFileId != null;\n  const [activeTab, setActiveTab] = useState<ViewTab | null>(null);\n  const [capabilities, setCapabilities] = useState<Capabilities | null>(null);\n  const [loading, setLoading] = useState(true);\n  const [platesData, setPlatesData] = useState<ArchivePlatesResponse | LibraryFilePlatesResponse | null>(null);\n  const [platesLoading, setPlatesLoading] = useState(false);\n  const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);\n  const [platePage, setPlatePage] = useState(0);\n  const [isFullscreen, setIsFullscreen] = useState(false);\n  const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);\n  const [isDraggingDivider, setIsDraggingDivider] = useState(false);\n  const [hasCustomSplit, setHasCustomSplit] = useState(false);\n  const splitContainerRef = useRef<HTMLDivElement>(null);\n  const platesPanelRef = useRef<HTMLDivElement>(null);\n  const dividerHeight = 10;\n  const minPlateHeight = 160;\n  const minViewerPx = 240;\n  const minViewerRatio = 0.35;\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  useEffect(() => {\n    setLoading(true);\n\n    if (isLibrary) {\n      const normalizedType = (fileType || '').toLowerCase();\n      const hasModel = normalizedType === '3mf' || normalizedType === 'stl';\n      const hasGcode = normalizedType === 'gcode' || normalizedType === '3mf';\n      setCapabilities({\n        has_model: hasModel,\n        has_gcode: hasGcode,\n        has_source: false,\n        build_volume: { x: 256, y: 256, z: 256 },\n        filament_colors: [],\n      });\n      setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);\n      setLoading(false);\n      return;\n    }\n\n    if (!archiveId) {\n      setCapabilities(null);\n      setActiveTab(null);\n      setLoading(false);\n      return;\n    }\n\n    api.getArchiveCapabilities(archiveId)\n      .then(caps => {\n        setCapabilities(caps);\n        // Auto-select the first available tab\n        if (caps.has_model) {\n          setActiveTab('3d');\n        } else if (caps.has_gcode) {\n          setActiveTab('gcode');\n        }\n        setLoading(false);\n      })\n      .catch(() => {\n        // Fallback to 3D model tab if capabilities check fails\n        setCapabilities({ has_model: true, has_gcode: false, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });\n        setActiveTab('3d');\n        setLoading(false);\n      });\n  }, [archiveId, fileType, isLibrary]);\n\n  useEffect(() => {\n    setPlatesLoading(true);\n    setSelectedPlateId(null);\n    setPlatePage(0);\n\n    if (isLibrary) {\n      const normalizedType = (fileType || '').toLowerCase();\n      if (!libraryFileId || normalizedType !== '3mf') {\n        setPlatesData(null);\n        setPlatesLoading(false);\n        return;\n      }\n      api.getLibraryFilePlates(libraryFileId)\n        .then((data) => setPlatesData(data))\n        .catch(() => setPlatesData(null))\n        .finally(() => setPlatesLoading(false));\n      return;\n    }\n\n    if (!archiveId) {\n      setPlatesData(null);\n      setPlatesLoading(false);\n      return;\n    }\n\n    api.getArchivePlates(archiveId)\n      .then((data) => setPlatesData(data))\n      .catch(() => setPlatesData(null))\n      .finally(() => setPlatesLoading(false));\n  }, [archiveId, fileType, isLibrary, libraryFileId]);\n\n  const plates = useMemo(() => platesData?.plates ?? [], [platesData]);\n  const hasMultiplePlates = (platesData?.is_multi_plate ?? false) && plates.length > 1;\n  const splitFullscreen = isFullscreen && hasMultiplePlates;\n  const selectedPlate: PlateMetadata | null = selectedPlateId == null\n    ? null\n    : plates.find((plate) => plate.index === selectedPlateId) ?? null;\n  const getPlateObjectCount = (plate: PlateMetadata): number => plate.object_count ?? plate.objects?.length ?? 0;\n  const totalObjectCount = plates.reduce((sum, plate) => sum + getPlateObjectCount(plate), 0);\n  const selectedObjectCount = selectedPlate ? getPlateObjectCount(selectedPlate) : totalObjectCount;\n  const objectCountLabel = selectedPlate ? t('modelViewer.plateNumber', { number: selectedPlate.index }) : t('modelViewer.allPlates');\n  const hasObjectCount = plates.length > 0;\n  const platesGridRef = useRef<HTMLDivElement>(null);\n  const platesViewportRef = useRef<HTMLDivElement>(null);\n  const [platesPerPage, setPlatesPerPage] = useState(10);\n  const [plateColumns, setPlateColumns] = useState(3);\n  const shouldPaginatePlates = plates.length > platesPerPage;\n  const totalPlatePages = Math.max(1, Math.ceil(plates.length / platesPerPage));\n  const pagedPlates = shouldPaginatePlates\n    ? plates.slice(platePage * platesPerPage, (platePage + 1) * platesPerPage)\n    : plates;\n\n  useEffect(() => {\n    if (!splitFullscreen) {\n      setPlatesPerPage(10);\n      setPlateColumns(3);\n      return;\n    }\n    const grid = platesGridRef.current;\n    const viewport = platesViewportRef.current;\n    if (!grid || !viewport) return;\n    let rafId = 0;\n    const updateLayout = () => {\n      const availableWidth = viewport.clientWidth;\n      const minButtonWidth = 210;\n      const computedCols = Math.floor(availableWidth / minButtonWidth);\n      const nextCols = Math.max(3, Math.min(5, computedCols || 3));\n      setPlateColumns((prev) => (prev === nextCols ? prev : nextCols));\n\n      const computed = window.getComputedStyle(grid);\n      const rowGap = Number.parseFloat(computed.rowGap || '0');\n      const firstItem = grid.querySelector<HTMLElement>('button');\n      const rowHeight = firstItem?.getBoundingClientRect().height ?? 44;\n      const availableHeight = viewport.clientHeight;\n      const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (rowHeight + rowGap)));\n      const maxSlots = rows * nextCols;\n      const nextPerPage = Math.max(1, maxSlots - 1);\n      setPlatesPerPage((prev) => (prev === nextPerPage ? prev : nextPerPage));\n    };\n    const scheduleUpdate = () => {\n      if (rafId) cancelAnimationFrame(rafId);\n      rafId = requestAnimationFrame(updateLayout);\n    };\n    scheduleUpdate();\n    const resizeObserver = new ResizeObserver(scheduleUpdate);\n    resizeObserver.observe(viewport);\n    resizeObserver.observe(grid);\n    return () => {\n      if (rafId) cancelAnimationFrame(rafId);\n      resizeObserver.disconnect();\n    };\n  }, [splitFullscreen, plates.length]);\n\n  useEffect(() => {\n    if (!shouldPaginatePlates) {\n      setPlatePage(0);\n      return;\n    }\n    setPlatePage((prev) => Math.min(prev, totalPlatePages - 1));\n  }, [plates.length, shouldPaginatePlates, totalPlatePages]);\n\n  useEffect(() => {\n    if (!shouldPaginatePlates || selectedPlateId == null) return;\n    const selectedIndex = plates.findIndex((plate) => plate.index === selectedPlateId);\n    if (selectedIndex < 0) return;\n    const nextPage = Math.floor(selectedIndex / platesPerPage);\n    setPlatePage((prev) => (prev === nextPage ? prev : nextPage));\n  }, [plates, platesPerPage, selectedPlateId, shouldPaginatePlates]);\n\n  useEffect(() => {\n    if (!splitFullscreen) {\n      setPlatePanelHeight(null);\n      setHasCustomSplit(false);\n      return;\n    }\n    if (hasCustomSplit) return;\n    const container = splitContainerRef.current;\n    const panel = platesPanelRef.current;\n    if (!container || !panel) return;\n    const containerHeight = container.clientHeight;\n    if (!containerHeight) return;\n    const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);\n    const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);\n    const desiredHeight = Math.min(panel.scrollHeight, maxPlateHeight);\n    setPlatePanelHeight(Math.max(minPlateHeight, desiredHeight));\n  }, [splitFullscreen, hasCustomSplit, plates.length, platePage, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);\n\n  useEffect(() => {\n    if (!isDraggingDivider) return;\n    const handleMouseMove = (event: MouseEvent) => {\n      const container = splitContainerRef.current;\n      if (!container) return;\n      const rect = container.getBoundingClientRect();\n      const containerHeight = rect.height;\n      if (!containerHeight) return;\n      const minViewerHeight = Math.max(minViewerPx, containerHeight * minViewerRatio);\n      const maxPlateHeight = Math.max(minPlateHeight, containerHeight - dividerHeight - minViewerHeight);\n      const nextHeight = Math.min(maxPlateHeight, Math.max(minPlateHeight, event.clientY - rect.top));\n      setPlatePanelHeight(nextHeight);\n    };\n    const handleMouseUp = () => {\n      setIsDraggingDivider(false);\n      setHasCustomSplit(true);\n    };\n    document.addEventListener('mousemove', handleMouseMove);\n    document.addEventListener('mouseup', handleMouseUp);\n    document.body.style.cursor = 'row-resize';\n    document.body.style.userSelect = 'none';\n\n    return () => {\n      document.removeEventListener('mousemove', handleMouseMove);\n      document.removeEventListener('mouseup', handleMouseUp);\n      document.body.style.cursor = '';\n      document.body.style.userSelect = '';\n    };\n  }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);\n\n  const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;\n\n  const handleOpenInSlicer = async () => {\n    if (!canOpenInSlicer) return;\n    const filename = title || 'model';\n    try {\n      if (isLibrary) {\n        const { token } = await api.createLibrarySlicerToken(libraryFileId!);\n        const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);\n        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);\n      } else {\n        const { token } = await api.createArchiveSlicerToken(archiveId!);\n        const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);\n        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);\n      }\n    } catch {\n      // Fallback to direct URL (works when auth is disabled)\n      if (isLibrary) {\n        const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;\n        openInSlicer(downloadUrl, preferredSlicer);\n      } else {\n        const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;\n        openInSlicer(downloadUrl, preferredSlicer);\n      }\n    }\n  };\n\n  return (\n    <div\n      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}\n      onClick={onClose}\n    >\n      <div\n        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${\n          isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'\n        }`}\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-3 min-w-0 flex-1 mr-4\">\n            <h2 className=\"text-lg font-semibold text-white truncate\">{title}</h2>\n            {hasObjectCount && (\n              <span className=\"text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap\">\n                {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}\n              </span>\n            )}\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button variant=\"secondary\" size=\"sm\" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>\n              <ExternalLink className=\"w-4 h-4\" />\n              {t('modelViewer.openInSlicer')}\n            </Button>\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              onClick={() => setIsFullscreen((prev) => !prev)}\n              title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}\n            >\n              {isFullscreen ? <Minimize2 className=\"w-4 h-4\" /> : <Maximize2 className=\"w-4 h-4\" />}\n            </Button>\n            <Button variant=\"ghost\" size=\"sm\" onClick={onClose}>\n              <X className=\"w-5 h-5\" />\n            </Button>\n          </div>\n        </div>\n\n        {/* Tabs - only show if we have capabilities */}\n        {capabilities && (\n          <div className=\"flex border-b border-bambu-dark-tertiary\">\n            <button\n              onClick={() => capabilities.has_model && setActiveTab('3d')}\n              disabled={!capabilities.has_model}\n              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${\n                activeTab === '3d'\n                  ? 'text-bambu-green border-b-2 border-bambu-green'\n                  : capabilities.has_model\n                    ? 'text-bambu-gray hover:text-white'\n                    : 'text-bambu-gray/30 cursor-not-allowed'\n              }`}\n            >\n              <Box className=\"w-4 h-4\" />\n              {t('modelViewer.tabs.model')}\n              {!capabilities.has_model && <span className=\"text-xs\">({t('modelViewer.notAvailable')})</span>}\n            </button>\n            <button\n              onClick={() => capabilities.has_gcode && setActiveTab('gcode')}\n              disabled={!capabilities.has_gcode}\n              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${\n                activeTab === 'gcode'\n                  ? 'text-bambu-green border-b-2 border-bambu-green'\n                  : capabilities.has_gcode\n                    ? 'text-bambu-gray hover:text-white'\n                    : 'text-bambu-gray/30 cursor-not-allowed'\n              }`}\n            >\n              <Code2 className=\"w-4 h-4\" />\n              {t('modelViewer.tabs.gcode')}\n              {!capabilities.has_gcode && <span className=\"text-xs\">({t('modelViewer.notSliced')})</span>}\n            </button>\n          </div>\n        )}\n\n        {/* Viewer */}\n        <div className=\"flex-1 overflow-hidden p-4\">\n          {loading ? (\n            <div className=\"w-full h-full flex items-center justify-center\">\n              <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green\" />\n            </div>\n          ) : activeTab === '3d' && capabilities ? (\n            <div\n              ref={splitContainerRef}\n              className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}\n            >\n              {hasMultiplePlates && (\n                <div\n                  ref={platesPanelRef}\n                  style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}\n                  className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}\n                >\n                  <div className=\"flex items-center gap-2 text-sm text-bambu-gray mb-2\">\n                    <Layers className=\"w-4 h-4\" />\n                    {t('modelViewer.plates')}\n                    {platesLoading && <Loader2 className=\"w-3 h-3 animate-spin\" />}\n                  </div>\n                  <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>\n                      <div\n                        ref={platesViewportRef}\n                        className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}\n                      >\n                      <div\n                        ref={platesGridRef}\n                        className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}\n                        style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}\n                      >\n                        <button\n                          type=\"button\"\n                          onClick={() => setSelectedPlateId(null)}\n                          className={`flex items-center rounded-lg border text-left transition-colors ${\n                            splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'\n                          } ${\n                            selectedPlateId == null\n                              ? 'border-bambu-green bg-bambu-green/10'\n                              : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'\n                          }`}\n                        >\n                          <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${\n                            splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'\n                          }`}>\n                            <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />\n                          </div>\n                          <div className=\"min-w-0 flex-1\">\n                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>\n                            <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>\n                              {t('modelViewer.plateCount', { count: plates.length })}\n                            </p>\n                          </div>\n                          {selectedPlateId == null && (\n                            <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />\n                          )}\n                        </button>\n                        {pagedPlates.map((plate) => (\n                          <button\n                            key={plate.index}\n                            type=\"button\"\n                            onClick={() => setSelectedPlateId(plate.index)}\n                            className={`flex items-center rounded-lg border text-left transition-colors ${\n                              splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'\n                            } ${\n                              selectedPlateId === plate.index\n                                ? 'border-bambu-green bg-bambu-green/10'\n                                : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'\n                            }`}\n                          >\n                            {plate.has_thumbnail && plate.thumbnail_url ? (\n                              <img\n                                src={plate.thumbnail_url}\n                                alt={`Plate ${plate.index}`}\n                                className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}\n                              />\n                            ) : (\n                              <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${\n                                splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'\n                              }`}>\n                                <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />\n                              </div>\n                            )}\n                            <div className=\"min-w-0 flex-1\">\n                              <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>\n                                {plate.name || t('modelViewer.plateNumber', { number: plate.index })}\n                              </p>\n                              <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>\n                                {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}\n                              </p>\n                            </div>\n                            {selectedPlateId === plate.index && (\n                              <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />\n                            )}\n                          </button>\n                        ))}\n                      </div>\n                    </div>\n                    {(selectedPlate || shouldPaginatePlates) && (\n                      <div className=\"mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto\">\n                        {selectedPlate && (\n                          <div className=\"flex items-center gap-3 whitespace-nowrap\">\n                            <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>\n                            {selectedPlate.print_time_seconds != null && (\n                              <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>\n                            )}\n                            {selectedPlate.filament_used_grams != null && (\n                              <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>\n                            )}\n                            {selectedPlate.filaments.length > 0 && (\n                              <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>\n                            )}\n                          </div>\n                        )}\n                        {shouldPaginatePlates && (\n                          <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>\n                            <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>\n                            <div className=\"flex items-center gap-1\">\n                              <button\n                                type=\"button\"\n                                onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}\n                                disabled={platePage === 0}\n                                className={`px-2 py-1 rounded border text-xs ${\n                                  platePage === 0\n                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'\n                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'\n                                }`}\n                              >\n                                {t('modelViewer.pagination.prev')}\n                              </button>\n                              {(() => {\n                                const maxVisible = 5;\n                                let start = Math.max(0, platePage - Math.floor(maxVisible / 2));\n                                const end = Math.min(totalPlatePages, start + maxVisible);\n                                if (end - start < maxVisible) {\n                                  start = Math.max(0, end - maxVisible);\n                                }\n                                const pages = Array.from({ length: end - start }, (_, i) => start + i);\n\n                                return (\n                                  <>\n                                    {start > 0 && (\n                                      <button\n                                        type=\"button\"\n                                        onClick={() => setPlatePage(0)}\n                                        className={`px-2 py-1 rounded border text-xs ${\n                                          platePage === 0\n                                            ? 'border-bambu-green text-bambu-green'\n                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'\n                                        }`}\n                                      >\n                                        1\n                                      </button>\n                                    )}\n                                    {start > 1 && <span className=\"px-1\">…</span>}\n                                    {pages.map((pageNumber) => (\n                                      <button\n                                        key={pageNumber}\n                                        type=\"button\"\n                                        onClick={() => setPlatePage(pageNumber)}\n                                        className={`px-2 py-1 rounded border text-xs ${\n                                          platePage === pageNumber\n                                            ? 'border-bambu-green text-bambu-green'\n                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'\n                                        }`}\n                                      >\n                                        {pageNumber + 1}\n                                      </button>\n                                    ))}\n                                    {end < totalPlatePages - 1 && <span className=\"px-1\">…</span>}\n                                    {end < totalPlatePages && (\n                                      <button\n                                        type=\"button\"\n                                        onClick={() => setPlatePage(totalPlatePages - 1)}\n                                        className={`px-2 py-1 rounded border text-xs ${\n                                          platePage === totalPlatePages - 1\n                                            ? 'border-bambu-green text-bambu-green'\n                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'\n                                        }`}\n                                      >\n                                        {totalPlatePages}\n                                      </button>\n                                    )}\n                                  </>\n                                );\n                              })()}\n                              <button\n                                type=\"button\"\n                                onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}\n                                disabled={platePage >= totalPlatePages - 1}\n                                className={`px-2 py-1 rounded border text-xs ${\n                                  platePage >= totalPlatePages - 1\n                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'\n                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'\n                                }`}\n                              >\n                                {t('modelViewer.pagination.next')}\n                              </button>\n                            </div>\n                          </div>\n                        )}\n                      </div>\n                    )}\n                  </div>\n                </div>\n              )}\n              {splitFullscreen && (\n                <div\n                  role=\"separator\"\n                  aria-orientation=\"horizontal\"\n                  onMouseDown={(event) => {\n                    event.preventDefault();\n                    setIsDraggingDivider(true);\n                    setHasCustomSplit(true);\n                  }}\n                  className={`h-2 cursor-row-resize flex items-center justify-center ${\n                    isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'\n                  }`}\n                >\n                  <div className=\"w-12 h-1 rounded-full bg-bambu-gray/50\" />\n                </div>\n              )}\n              <div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>\n                  <ModelViewer\n                    url={isLibrary\n                      ? api.getLibraryFileDownloadUrl(libraryFileId!)\n                      : (capabilities.has_source\n                        ? api.getSource3mfDownloadUrl(archiveId!)\n                        : api.getArchiveDownload(archiveId!))}\n                    fileType={fileType}\n                    buildVolume={capabilities.build_volume}\n                    filamentColors={capabilities.filament_colors}\n                    selectedPlateId={selectedPlateId}\n                    className=\"w-full h-full\"\n                  />\n              </div>\n            </div>\n          ) : activeTab === 'gcode' && capabilities ? (\n            <GcodeViewer\n              gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}\n              filamentColors={capabilities.filament_colors}\n              className=\"w-full h-full\"\n            />\n          ) : (\n            <div className=\"w-full h-full flex items-center justify-center text-bambu-gray\">\n              {t('modelViewer.noPreview')}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/NotificationLogViewer.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { History, CheckCircle, XCircle, Loader2, Trash2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';\nimport { api } from '../api/client';\nimport { parseUTCDate, formatTimeOnly, formatDateTime, type TimeFormat } from '../utils/date';\nimport type { NotificationLogEntry } from '../api/client';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\n\nconst EVENT_COLORS: Record<string, string> = {\n  print_start: 'text-blue-400',\n  print_complete: 'text-bambu-green',\n  print_failed: 'text-red-400',\n  print_stopped: 'text-orange-400',\n  print_progress: 'text-yellow-400',\n  printer_offline: 'text-gray-400',\n  printer_error: 'text-rose-400',\n  filament_low: 'text-cyan-400',\n  maintenance_due: 'text-purple-400',\n  test: 'text-bambu-gray',\n};\n\ninterface NotificationLogViewerProps {\n  onClose: () => void;\n}\n\nexport function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [days, setDays] = useState(7);\n  const [expandedId, setExpandedId] = useState<number | null>(null);\n  const [showFailedOnly, setShowFailedOnly] = useState(false);\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const timeFormat: TimeFormat = settings?.time_format || 'system';\n\n  const { data: logs, isLoading, refetch, isRefetching } = useQuery({\n    queryKey: ['notification-logs', days, showFailedOnly],\n    queryFn: () => api.getNotificationLogs({\n      days,\n      limit: 100,\n      success: showFailedOnly ? false : undefined,\n    }),\n  });\n\n  const { data: stats } = useQuery({\n    queryKey: ['notification-log-stats', days],\n    queryFn: () => api.getNotificationLogStats(days),\n  });\n\n  const clearMutation = useMutation({\n    mutationFn: () => api.clearNotificationLogs(30),\n    onSuccess: (data) => {\n      showToast(data.message, 'success');\n      queryClient.invalidateQueries({ queryKey: ['notification-logs'] });\n      queryClient.invalidateQueries({ queryKey: ['notification-log-stats'] });\n    },\n    onError: (error: Error) => {\n      showToast(`Failed to clear logs: ${error.message}`, 'error');\n    },\n  });\n\n  const formatDate = (dateStr: string) => {\n    const date = parseUTCDate(dateStr);\n    if (!date) return '';\n    const now = new Date();\n    const diff = now.getTime() - date.getTime();\n\n    if (diff < 60000) return t('notifications.justNow');\n    if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;\n    if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;\n\n    return date.toLocaleDateString() + ' ' + formatTimeOnly(date, timeFormat);\n  };\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg w-full max-w-3xl max-h-[85vh] flex flex-col\">\n        {/* Header */}\n        <div className=\"p-4 border-b border-bambu-dark-tertiary flex items-center justify-between\">\n          <div className=\"flex items-center gap-3\">\n            <History className=\"w-5 h-5 text-bambu-green\" />\n            <h2 className=\"text-lg font-semibold text-white\">{t('notifications.notificationLog')}</h2>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"text-bambu-gray hover:text-white transition-colors\"\n          >\n            &times;\n          </button>\n        </div>\n\n        {/* Stats Bar */}\n        {stats && (\n          <div className=\"px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50\">\n            <div className=\"flex items-center gap-6 text-sm\">\n              <span className=\"text-bambu-gray\">\n                {t('notifications.statsSummary', { days })} <span className=\"text-white font-medium\">{stats.total}</span> {t('notifications.statsNotifications')}\n              </span>\n              <span className=\"flex items-center gap-1 text-bambu-green\">\n                <CheckCircle className=\"w-4 h-4\" />\n                {t('notifications.statsSent', { count: stats.success_count })}\n              </span>\n              {stats.failure_count > 0 && (\n                <span className=\"flex items-center gap-1 text-red-400\">\n                  <XCircle className=\"w-4 h-4\" />\n                  {t('notifications.statsFailed', { count: stats.failure_count })}\n                </span>\n              )}\n            </div>\n          </div>\n        )}\n\n        {/* Filters */}\n        <div className=\"px-4 py-3 border-b border-bambu-dark-tertiary flex items-center gap-4\">\n          <select\n            value={days}\n            onChange={(e) => setDays(Number(e.target.value))}\n            className=\"px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green\"\n          >\n            <option value={1}>{t('notifications.last24Hours')}</option>\n            <option value={7}>{t('notifications.last7Days')}</option>\n            <option value={30}>{t('notifications.last30Days')}</option>\n            <option value={90}>{t('notifications.last90Days')}</option>\n          </select>\n\n          <label className=\"flex items-center gap-2 text-sm text-bambu-gray cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={showFailedOnly}\n              onChange={(e) => setShowFailedOnly(e.target.checked)}\n              className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n            />\n            {t('notifications.showFailedOnly')}\n          </label>\n\n          <div className=\"flex-1\" />\n\n          <Button\n            size=\"sm\"\n            variant=\"secondary\"\n            onClick={() => refetch()}\n            disabled={isRefetching}\n          >\n            {isRefetching ? (\n              <Loader2 className=\"w-4 h-4 animate-spin\" />\n            ) : (\n              <RefreshCw className=\"w-4 h-4\" />\n            )}\n            {t('notifications.refresh')}\n          </Button>\n\n          <Button\n            size=\"sm\"\n            variant=\"secondary\"\n            onClick={() => clearMutation.mutate()}\n            disabled={clearMutation.isPending}\n            className=\"text-red-400 hover:text-red-300\"\n          >\n            {clearMutation.isPending ? (\n              <Loader2 className=\"w-4 h-4 animate-spin\" />\n            ) : (\n              <Trash2 className=\"w-4 h-4\" />\n            )}\n            {t('notifications.clearOld')}\n          </Button>\n        </div>\n\n        {/* Log List */}\n        <div className=\"flex-1 overflow-y-auto p-4\">\n          {isLoading ? (\n            <div className=\"flex justify-center py-12\">\n              <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n            </div>\n          ) : logs && logs.length > 0 ? (\n            <div className=\"space-y-2\">\n              {logs.map((log) => (\n                <LogEntry\n                  key={log.id}\n                  log={log}\n                  isExpanded={expandedId === log.id}\n                  onToggle={() => setExpandedId(expandedId === log.id ? null : log.id)}\n                  formatDate={formatDate}\n                  formatFullDate={(dateStr) => formatDateTime(dateStr, timeFormat)}\n                />\n              ))}\n            </div>\n          ) : (\n            <div className=\"text-center py-12 text-bambu-gray\">\n              <History className=\"w-12 h-12 mx-auto mb-3 opacity-30\" />\n              <p className=\"text-sm\">\n                {showFailedOnly ? t('notifications.noFailedNotifications') : t('notifications.noNotificationsLogged')}\n              </p>\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction LogEntry({\n  log,\n  isExpanded,\n  onToggle,\n  formatDate,\n  formatFullDate,\n}: {\n  log: NotificationLogEntry;\n  isExpanded: boolean;\n  onToggle: () => void;\n  formatDate: (date: string) => string;\n  formatFullDate: (date: string) => string;\n}) {\n  const { t } = useTranslation();\n  return (\n    <div\n      className={`border rounded-lg overflow-hidden transition-colors ${\n        log.success\n          ? 'border-bambu-dark-tertiary bg-bambu-dark/30'\n          : 'border-red-500/30 bg-red-500/5'\n      }`}\n    >\n      <button\n        className=\"w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-bambu-dark/50 transition-colors\"\n        onClick={onToggle}\n      >\n        {log.success ? (\n          <CheckCircle className=\"w-4 h-4 text-bambu-green shrink-0\" />\n        ) : (\n          <XCircle className=\"w-4 h-4 text-red-400 shrink-0\" />\n        )}\n\n        <span className={`text-xs font-medium ${EVENT_COLORS[log.event_type] || 'text-bambu-gray'}`}>\n          {t(`notifications.eventTypes.${log.event_type}`, log.event_type)}\n        </span>\n\n        <span className=\"text-sm text-white truncate flex-1\">\n          {log.provider_name || t('notifications.unknownProvider')}\n        </span>\n\n        {log.printer_name && (\n          <span className=\"text-xs text-bambu-gray\">\n            {log.printer_name}\n          </span>\n        )}\n\n        <span className=\"text-xs text-bambu-gray shrink-0\">\n          {formatDate(log.created_at)}\n        </span>\n\n        {isExpanded ? (\n          <ChevronUp className=\"w-4 h-4 text-bambu-gray shrink-0\" />\n        ) : (\n          <ChevronDown className=\"w-4 h-4 text-bambu-gray shrink-0\" />\n        )}\n      </button>\n\n      {isExpanded && (\n        <div className=\"px-3 py-2 border-t border-bambu-dark-tertiary bg-bambu-dark/20 space-y-2\">\n          <div>\n            <p className=\"text-xs text-bambu-gray mb-1\">{t('notifications.logTitle')}</p>\n            <p className=\"text-sm text-white\">{log.title}</p>\n          </div>\n          <div>\n            <p className=\"text-xs text-bambu-gray mb-1\">{t('notifications.logMessage')}</p>\n            <p className=\"text-sm text-white whitespace-pre-wrap\">{log.message}</p>\n          </div>\n          {!log.success && log.error_message && (\n            <div>\n              <p className=\"text-xs text-red-400 mb-1\">{t('notifications.logError')}</p>\n              <p className=\"text-sm text-red-300\">{log.error_message}</p>\n            </div>\n          )}\n          <div className=\"flex gap-4 text-xs text-bambu-gray pt-1\">\n            <span>{t('notifications.logProvider', { type: log.provider_type })}</span>\n            <span>{t('notifications.logTime', { time: formatFullDate(log.created_at) })}</span>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/NotificationProviderCard.tsx",
    "content": "import { useState } from 'react';\nimport { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';\nimport { api } from '../api/client';\nimport { formatDateOnly, parseUTCDate } from '../utils/date';\nimport type { NotificationProvider, NotificationProviderUpdate } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { ConfirmModal } from './ConfirmModal';\nimport { Toggle } from './Toggle';\n\ninterface NotificationProviderCardProps {\n  provider: NotificationProvider;\n  onEdit: (provider: NotificationProvider) => void;\n}\n\nexport function NotificationProviderCard({ provider, onEdit }: NotificationProviderCardProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [isExpanded, setIsExpanded] = useState(false);\n  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);\n\n  // Fetch printers for linking\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const linkedPrinter = printers?.find(p => p.id === provider.printer_id);\n\n  // Update mutation\n  const updateMutation = useMutation({\n    mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });\n    },\n  });\n\n  // Delete mutation\n  const deleteMutation = useMutation({\n    mutationFn: () => api.deleteNotificationProvider(provider.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });\n    },\n  });\n\n  // Test mutation\n  const testMutation = useMutation({\n    mutationFn: () => api.testNotificationProvider(provider.id),\n    onSuccess: (result) => {\n      setTestResult(result);\n      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });\n    },\n    onError: (err: Error) => {\n      setTestResult({ success: false, message: err.message });\n    },\n  });\n\n  // Format time for display\n  const formatTime = (time: string | null) => {\n    if (!time) return '';\n    return time;\n  };\n\n  return (\n    <>\n      <Card className=\"relative\">\n        <CardContent className=\"p-3\">\n          {/* Header Row */}\n          <div className={`flex items-start justify-between ${provider.enabled ? 'mb-3' : 'mb-0'}`}>\n            <div className=\"flex items-center gap-3\">\n              <div className={`p-2 rounded-lg ${provider.enabled ? 'bg-bambu-green/20' : 'bg-bambu-dark'}`}>\n                <Bell className={`w-5 h-5 ${provider.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n              </div>\n              <div>\n                <h3 className=\"font-medium text-white\">{provider.name}</h3>\n                <p className=\"text-sm text-bambu-gray\">{t(`notifications.providerTypes.${provider.provider_type}`, provider.provider_type)}</p>\n              </div>\n            </div>\n\n            {/* Quick enable/disable toggle + Status indicator */}\n            <div className=\"flex items-center gap-3\">\n              {provider.last_success && (\n                <span className=\"text-xs text-status-ok hidden sm:inline\">{t('notifications.lastSuccess', { date: formatDateOnly(provider.last_success) })}</span>\n              )}\n              {/* Only show error if it's more recent than last success */}\n              {provider.last_error && provider.last_error_at && (\n                !provider.last_success || (parseUTCDate(provider.last_error_at)?.getTime() || 0) > (parseUTCDate(provider.last_success)?.getTime() || 0)\n              ) && (\n                <span className=\"text-xs text-status-error\" title={provider.last_error}>{t('notifications.error')}</span>\n              )}\n              <Toggle\n                checked={provider.enabled}\n                onChange={(checked) => updateMutation.mutate({ enabled: checked })}\n              />\n            </div>\n          </div>\n\n          {provider.enabled && (<>\n          {/* Linked Printer */}\n          {linkedPrinter && (\n            <div className=\"mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg\">\n              <span className=\"text-xs text-bambu-gray\">{t('notifications.printer')} </span>\n              <span className=\"text-sm text-white\">{linkedPrinter.name}</span>\n            </div>\n          )}\n          {!linkedPrinter && !provider.printer_id && (\n            <div className=\"mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg\">\n              <span className=\"text-xs text-bambu-gray\">{t('notifications.allPrinters')}</span>\n            </div>\n          )}\n\n          {/* Event summary - show all event tags */}\n          <div className=\"mb-3 flex flex-wrap gap-1\">\n            {provider.on_print_start && (\n              <span className=\"px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded\">{t('notifications.start')}</span>\n            )}\n            {provider.on_plate_not_empty && (\n              <span className=\"px-2 py-0.5 bg-rose-600/20 text-rose-300 text-xs rounded\">{t('notifications.plateCheck')}</span>\n            )}\n            {provider.on_print_complete && (\n              <span className=\"px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded\">{t('notifications.complete')}</span>\n            )}\n            {provider.on_print_failed && (\n              <span className=\"px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded\">{t('notifications.failed')}</span>\n            )}\n            {provider.on_print_stopped && (\n              <span className=\"px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded\">{t('notifications.stopped')}</span>\n            )}\n            {provider.on_print_progress && (\n              <span className=\"px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded\">{t('notifications.progress')}</span>\n            )}\n            {provider.on_printer_offline && (\n              <span className=\"px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded\">{t('notifications.offline')}</span>\n            )}\n            {provider.on_printer_error && (\n              <span className=\"px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded\">{t('notifications.error')}</span>\n            )}\n            {provider.on_filament_low && (\n              <span className=\"px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded\">{t('notifications.lowFilament')}</span>\n            )}\n            {provider.on_maintenance_due && (\n              <span className=\"px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded\">{t('notifications.maintenance')}</span>\n            )}\n            {provider.on_ams_humidity_high && (\n              <span className=\"px-2 py-0.5 bg-blue-600/20 text-blue-300 text-xs rounded\">{t('notifications.amsHumidity')}</span>\n            )}\n            {provider.on_ams_temperature_high && (\n              <span className=\"px-2 py-0.5 bg-orange-600/20 text-orange-300 text-xs rounded\">{t('notifications.amsTemp')}</span>\n            )}\n            {provider.on_ams_ht_humidity_high && (\n              <span className=\"px-2 py-0.5 bg-cyan-600/20 text-cyan-300 text-xs rounded\">{t('notifications.amsHtHumidity')}</span>\n            )}\n            {provider.on_ams_ht_temperature_high && (\n              <span className=\"px-2 py-0.5 bg-amber-600/20 text-amber-300 text-xs rounded\">{t('notifications.amsHtTemp')}</span>\n            )}\n            {provider.on_bed_cooled && (\n              <span className=\"px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded\">{t('notifications.bedCooled')}</span>\n            )}\n            {provider.on_first_layer_complete && (\n              <span className=\"px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded\">{t('notifications.firstLayer')}</span>\n            )}\n            {provider.on_print_missing_spool_assignment && (\n              <span className=\"px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded\">{t('notifications.missingSpoolAssignmentLabel')}</span>\n            )}\n            {provider.quiet_hours_enabled && (\n              <span className=\"px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1\">\n                <Moon className=\"w-3 h-3\" />\n                {t('notifications.quiet')}\n              </span>\n            )}\n            {provider.daily_digest_enabled && (\n              <span className=\"px-2 py-0.5 bg-emerald-500/20 text-emerald-400 text-xs rounded flex items-center gap-1\">\n                <Calendar className=\"w-3 h-3\" />\n                {t('notifications.digest', { time: provider.daily_digest_time })}\n              </span>\n            )}\n          </div>\n\n          {/* Test Button */}\n          <div className=\"mb-3\">\n            <Button\n              size=\"sm\"\n              variant=\"secondary\"\n              disabled={testMutation.isPending}\n              onClick={() => {\n                setTestResult(null);\n                testMutation.mutate();\n              }}\n              className=\"w-full\"\n            >\n              {testMutation.isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Send className=\"w-4 h-4\" />\n              )}\n              {t('notifications.sendTestNotification')}\n            </Button>\n          </div>\n\n          {/* Test Result */}\n          {testResult && (\n            <div className={`mb-3 p-2 rounded-lg flex items-center gap-2 text-sm ${\n              testResult.success\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'bg-red-500/20 text-red-400'\n            }`}>\n              {testResult.success ? (\n                <CheckCircle className=\"w-4 h-4\" />\n              ) : (\n                <XCircle className=\"w-4 h-4\" />\n              )}\n              <span>{testResult.message}</span>\n            </div>\n          )}\n\n          </>)}\n          {/* Toggle Settings Panel */}\n          <button\n            onClick={() => setIsExpanded(!isExpanded)}\n            className={`w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors ${provider.enabled ? 'border-t border-bambu-dark-tertiary' : 'mt-2 border-t border-bambu-dark-tertiary'}`}\n          >\n            <span className=\"flex items-center gap-2\">\n              <Settings2 className=\"w-4 h-4\" />\n              {t('notifications.eventSettings')}\n            </span>\n            {isExpanded ? (\n              <ChevronUp className=\"w-4 h-4\" />\n            ) : (\n              <ChevronDown className=\"w-4 h-4\" />\n            )}\n          </button>\n\n          {/* Expanded Settings */}\n          {isExpanded && (\n            <div className=\"pt-3 border-t border-bambu-dark-tertiary space-y-4\">\n              {/* Enabled Toggle */}\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-sm text-white\">{t('notifications.enabled')}</p>\n                  <p className=\"text-xs text-bambu-gray\">{t('notifications.sendFromProvider')}</p>\n                </div>\n                <Toggle\n                  checked={provider.enabled}\n                  onChange={(checked) => updateMutation.mutate({ enabled: checked })}\n                />\n              </div>\n\n              {/* Print Lifecycle Events */}\n              <div className=\"space-y-2\">\n                <p className=\"text-xs text-bambu-gray uppercase tracking-wide\">{t('notifications.printEvents')}</p>\n\n                <div className=\"flex items-center justify-between\">\n                  <p className=\"text-sm text-white\">{t('notifications.printStarted')}</p>\n                  <Toggle\n                    checked={provider.on_print_start}\n                    onChange={(checked) => updateMutation.mutate({ on_print_start: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.plateNotEmpty')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.plateNotEmptyDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_plate_not_empty ?? true}\n                    onChange={(checked) => updateMutation.mutate({ on_plate_not_empty: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <p className=\"text-sm text-white\">{t('notifications.printCompleted')}</p>\n                  <Toggle\n                    checked={provider.on_print_complete}\n                    onChange={(checked) => updateMutation.mutate({ on_print_complete: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.bedCooledLabel')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.bedCooledDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_bed_cooled ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_bed_cooled: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.firstLayerCompleteLabel')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.firstLayerCompleteDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_first_layer_complete ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_first_layer_complete: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.missingSpoolAssignmentLabel')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.missingSpoolAssignmentDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_print_missing_spool_assignment ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_print_missing_spool_assignment: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <p className=\"text-sm text-white\">{t('notifications.printFailed')}</p>\n                  <Toggle\n                    checked={provider.on_print_failed}\n                    onChange={(checked) => updateMutation.mutate({ on_print_failed: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <p className=\"text-sm text-white\">{t('notifications.printStopped')}</p>\n                  <Toggle\n                    checked={provider.on_print_stopped}\n                    onChange={(checked) => updateMutation.mutate({ on_print_stopped: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.progressMilestones')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.progressMilestonesDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_print_progress}\n                    onChange={(checked) => updateMutation.mutate({ on_print_progress: checked })}\n                  />\n                </div>\n              </div>\n\n              {/* Printer Status Events */}\n              <div className=\"space-y-2\">\n                <p className=\"text-xs text-bambu-gray uppercase tracking-wide\">{t('notifications.printerStatus')}</p>\n\n                <div className=\"flex items-center justify-between\">\n                  <p className=\"text-sm text-white\">{t('notifications.printerOffline')}</p>\n                  <Toggle\n                    checked={provider.on_printer_offline}\n                    onChange={(checked) => updateMutation.mutate({ on_printer_offline: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <p className=\"text-sm text-white\">{t('notifications.printerError')}</p>\n                  <Toggle\n                    checked={provider.on_printer_error}\n                    onChange={(checked) => updateMutation.mutate({ on_printer_error: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <p className=\"text-sm text-white\">{t('notifications.lowFilamentLabel')}</p>\n<Toggle\n                    checked={provider.on_filament_low}\n                    onChange={(checked) => updateMutation.mutate({ on_filament_low: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.maintenanceDue')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.maintenanceDueDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_maintenance_due ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_maintenance_due: checked })}\n                  />\n                </div>\n              </div>\n\n              {/* AMS Environmental Alarms (regular AMS) */}\n              <div className=\"space-y-2\">\n                <p className=\"text-xs text-bambu-gray uppercase tracking-wide\">{t('notifications.amsAlarms')}</p>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.amsHumidityHigh')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.amsHumidityHighDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_ams_humidity_high ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_ams_humidity_high: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.amsTemperatureHigh')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.amsTemperatureHighDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_ams_temperature_high ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_ams_temperature_high: checked })}\n                  />\n                </div>\n              </div>\n\n              {/* AMS-HT Environmental Alarms */}\n              <div className=\"space-y-2\">\n                <p className=\"text-xs text-bambu-gray uppercase tracking-wide\">{t('notifications.amsHtAlarms')}</p>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.amsHtHumidityHigh')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.amsHtHumidityHighDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_ams_ht_humidity_high ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_ams_ht_humidity_high: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.amsHtTemperatureHigh')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.amsHtTemperatureHighDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_ams_ht_temperature_high ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_ams_ht_temperature_high: checked })}\n                  />\n                </div>\n              </div>\n\n              {/* Print Queue Events */}\n              <div className=\"space-y-2\">\n                <p className=\"text-xs text-bambu-gray uppercase tracking-wide\">{t('notifications.printQueue')}</p>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.jobAdded')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.jobAddedDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_queue_job_added ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_queue_job_added: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.jobAssigned')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.jobAssignedDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_queue_job_assigned ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_queue_job_assigned: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.jobStarted')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.jobStartedDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_queue_job_started ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_queue_job_started: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.jobWaiting')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.jobWaitingDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_queue_job_waiting ?? true}\n                    onChange={(checked) => updateMutation.mutate({ on_queue_job_waiting: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.jobSkipped')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.jobSkippedDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_queue_job_skipped ?? true}\n                    onChange={(checked) => updateMutation.mutate({ on_queue_job_skipped: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.jobFailed')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.jobFailedDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_queue_job_failed ?? true}\n                    onChange={(checked) => updateMutation.mutate({ on_queue_job_failed: checked })}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('notifications.queueComplete')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.queueCompleteDescription')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.on_queue_completed ?? false}\n                    onChange={(checked) => updateMutation.mutate({ on_queue_completed: checked })}\n                  />\n                </div>\n              </div>\n\n              {/* Quiet Hours */}\n              <div className=\"space-y-2\">\n                <div className=\"flex items-center justify-between\">\n                  <div className=\"flex items-center gap-2\">\n                    <Moon className=\"w-4 h-4 text-purple-400\" />\n                    <p className=\"text-sm text-white\">{t('notifications.quietHours')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.quiet_hours_enabled}\n                    onChange={(checked) => updateMutation.mutate({ quiet_hours_enabled: checked })}\n                  />\n                </div>\n\n                {provider.quiet_hours_enabled && (\n                  <div className=\"pl-4 border-l-2 border-bambu-dark-tertiary space-y-2\">\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.noNotificationsDuring')}</p>\n                    <div className=\"flex items-center gap-2\">\n                      <Clock className=\"w-4 h-4 text-bambu-gray\" />\n                      <span className=\"text-sm text-white\">\n                        {formatTime(provider.quiet_hours_start) || '22:00'} - {formatTime(provider.quiet_hours_end) || '07:00'}\n                      </span>\n                    </div>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.editProviderToChangeQuietHours')}</p>\n                  </div>\n                )}\n              </div>\n\n              {/* Daily Digest */}\n              <div className=\"space-y-2\">\n                <div className=\"flex items-center justify-between\">\n                  <div className=\"flex items-center gap-2\">\n                    <Calendar className=\"w-4 h-4 text-emerald-400\" />\n                    <p className=\"text-sm text-white\">{t('notifications.dailyDigest')}</p>\n                  </div>\n                  <Toggle\n                    checked={provider.daily_digest_enabled}\n                    onChange={(checked) => updateMutation.mutate({ daily_digest_enabled: checked })}\n                  />\n                </div>\n\n                {provider.daily_digest_enabled && (\n                  <div className=\"pl-4 border-l-2 border-bambu-dark-tertiary space-y-2\">\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.batchNotifications')}</p>\n                    <div className=\"flex items-center gap-2\">\n                      <Clock className=\"w-4 h-4 text-bambu-gray\" />\n                      <span className=\"text-sm text-white\">\n                        {t('notifications.sendAt', { time: formatTime(provider.daily_digest_time) || '08:00' })}\n                      </span>\n                    </div>\n                    <p className=\"text-xs text-bambu-gray\">{t('notifications.editProviderToChangeDigestTime')}</p>\n                  </div>\n                )}\n              </div>\n\n              {/* Action Buttons */}\n              <div className=\"flex gap-2 pt-2\">\n                <Button\n                  size=\"sm\"\n                  variant=\"secondary\"\n                  onClick={() => onEdit(provider)}\n                  className=\"flex-1\"\n                >\n                  <Edit2 className=\"w-4 h-4\" />\n                  {t('notifications.edit')}\n                </Button>\n                <Button\n                  size=\"sm\"\n                  variant=\"secondary\"\n                  onClick={() => setShowDeleteConfirm(true)}\n                  className=\"text-red-400 hover:text-red-300\"\n                >\n                  <Trash2 className=\"w-4 h-4\" />\n                </Button>\n              </div>\n            </div>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Delete Confirmation */}\n      {showDeleteConfirm && (\n        <ConfirmModal\n          title={t('notifications.deleteProvider')}\n          message={t('notifications.deleteConfirm', { name: provider.name })}\n          confirmText={t('notifications.delete')}\n          variant=\"danger\"\n          onConfirm={() => {\n            deleteMutation.mutate();\n            setShowDeleteConfirm(false);\n          }}\n          onCancel={() => setShowDeleteConfirm(false)}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/NotificationTemplateEditor.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Save, Loader2, RotateCcw, Plus, Eye } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { NotificationTemplate, NotificationTemplateUpdate } from '../api/client';\nimport { Button } from './Button';\n\ninterface NotificationTemplateEditorProps {\n  template: NotificationTemplate;\n  onClose: () => void;\n}\n\nexport function NotificationTemplateEditor({ template, onClose }: NotificationTemplateEditorProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const bodyRef = useRef<HTMLTextAreaElement>(null);\n\n  const [titleTemplate, setTitleTemplate] = useState(template.title_template);\n  const [bodyTemplate, setBodyTemplate] = useState(template.body_template);\n  const [error, setError] = useState<string | null>(null);\n  const [showPreview, setShowPreview] = useState(true);\n\n  // Fetch variables for this event type\n  const { data: variablesData } = useQuery({\n    queryKey: ['template-variables'],\n    queryFn: api.getTemplateVariables,\n  });\n\n  // Get variables for this template's event type\n  const eventVariables = variablesData?.find(v => v.event_type === template.event_type);\n\n  // Live preview\n  const { data: preview, isLoading: previewLoading } = useQuery({\n    queryKey: ['template-preview', template.event_type, titleTemplate, bodyTemplate],\n    queryFn: () => api.previewTemplate({\n      event_type: template.event_type,\n      title_template: titleTemplate,\n      body_template: bodyTemplate,\n    }),\n    enabled: showPreview && titleTemplate.length > 0 && bodyTemplate.length > 0,\n  });\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  // Update mutation\n  const updateMutation = useMutation({\n    mutationFn: (data: NotificationTemplateUpdate) => api.updateNotificationTemplate(template.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['notification-templates'] });\n      onClose();\n    },\n    onError: (err: Error) => {\n      setError(err.message);\n    },\n  });\n\n  // Reset mutation\n  const resetMutation = useMutation({\n    mutationFn: () => api.resetNotificationTemplate(template.id),\n    onSuccess: (resetTemplate) => {\n      setTitleTemplate(resetTemplate.title_template);\n      setBodyTemplate(resetTemplate.body_template);\n      queryClient.invalidateQueries({ queryKey: ['notification-templates'] });\n    },\n    onError: (err: Error) => {\n      setError(err.message);\n    },\n  });\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    setError(null);\n\n    if (!titleTemplate.trim()) {\n      setError(t('notifications.titleRequired'));\n      return;\n    }\n    if (!bodyTemplate.trim()) {\n      setError(t('notifications.bodyRequired'));\n      return;\n    }\n\n    updateMutation.mutate({\n      title_template: titleTemplate,\n      body_template: bodyTemplate,\n    });\n  };\n\n  const insertVariable = (variable: string) => {\n    const textarea = bodyRef.current;\n    if (!textarea) return;\n\n    const start = textarea.selectionStart;\n    const end = textarea.selectionEnd;\n    const text = bodyTemplate;\n    const before = text.substring(0, start);\n    const after = text.substring(end);\n    const newValue = before + `{${variable}}` + after;\n\n    setBodyTemplate(newValue);\n\n    // Restore focus and cursor position\n    setTimeout(() => {\n      textarea.focus();\n      const newCursor = start + variable.length + 2;\n      textarea.setSelectionRange(newCursor, newCursor);\n    }, 0);\n  };\n\n  const hasChanges = titleTemplate !== template.title_template || bodyTemplate !== template.body_template;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0\">\n          <h2 className=\"text-lg font-semibold text-white\">\n            {t('notifications.editTemplate', { name: template.name })}\n          </h2>\n          <button\n            onClick={onClose}\n            className=\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\"\n          >\n            <X className=\"w-5 h-5 text-bambu-gray\" />\n          </button>\n        </div>\n\n        {/* Content */}\n        <form onSubmit={handleSubmit} className=\"flex-1 overflow-y-auto p-4 space-y-4\">\n          {error && (\n            <div className=\"p-3 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-sm\">\n              {error}\n            </div>\n          )}\n\n          {/* Title */}\n          <div>\n            <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n              {t('notifications.titleLabel')}\n            </label>\n            <input\n              type=\"text\"\n              value={titleTemplate}\n              onChange={(e) => setTitleTemplate(e.target.value)}\n              className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green\"\n              placeholder={t('notifications.titlePlaceholder')}\n            />\n          </div>\n\n          {/* Body */}\n          <div>\n            <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n              {t('notifications.bodyLabel')}\n            </label>\n            <textarea\n              ref={bodyRef}\n              value={bodyTemplate}\n              onChange={(e) => setBodyTemplate(e.target.value)}\n              rows={4}\n              className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green font-mono text-sm resize-none\"\n              placeholder={t('notifications.bodyPlaceholder')}\n            />\n          </div>\n\n          {/* Available Variables */}\n          {eventVariables && (\n            <div>\n              <label className=\"block text-sm font-medium text-bambu-gray mb-2\">\n                {t('notifications.availableVariables')}\n              </label>\n              <div className=\"flex flex-wrap gap-2\">\n                {eventVariables.variables.map((variable) => (\n                  <button\n                    key={variable}\n                    type=\"button\"\n                    onClick={() => insertVariable(variable)}\n                    className=\"inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs text-bambu-gray hover:text-white transition-colors\"\n                  >\n                    <Plus className=\"w-3 h-3\" />\n                    {variable}\n                  </button>\n                ))}\n              </div>\n              <p className=\"text-xs text-bambu-gray/60 mt-1\">\n                {t('notifications.clickToInsert')}\n              </p>\n            </div>\n          )}\n\n          {/* Preview */}\n          <div>\n            <div className=\"flex items-center justify-between mb-2\">\n              <label className=\"text-sm font-medium text-bambu-gray flex items-center gap-2\">\n                <Eye className=\"w-4 h-4\" />\n                {t('notifications.livePreview')}\n              </label>\n              <button\n                type=\"button\"\n                onClick={() => setShowPreview(!showPreview)}\n                className=\"text-xs text-bambu-green hover:text-bambu-green-light\"\n              >\n                {showPreview ? t('notifications.hide') : t('notifications.show')}\n              </button>\n            </div>\n            {showPreview && (\n              <div className=\"bg-bambu-dark border border-bambu-dark-tertiary rounded p-3 space-y-2\">\n                {previewLoading ? (\n                  <div className=\"flex items-center gap-2 text-bambu-gray text-sm\">\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    {t('notifications.loadingPreview')}\n                  </div>\n                ) : preview ? (\n                  <>\n                    <div>\n                      <span className=\"text-xs text-bambu-gray\">{t('notifications.titlePreview')}</span>\n                      <div className=\"text-white font-medium\">{preview.title}</div>\n                    </div>\n                    <div>\n                      <span className=\"text-xs text-bambu-gray\">{t('notifications.bodyPreview')}</span>\n                      <div className=\"text-white whitespace-pre-wrap text-sm\">{preview.body}</div>\n                    </div>\n                  </>\n                ) : (\n                  <div className=\"text-bambu-gray text-sm\">\n                    {t('notifications.enterTemplateContent')}\n                  </div>\n                )}\n              </div>\n            )}\n          </div>\n        </form>\n\n        {/* Footer */}\n        <div className=\"flex items-center justify-between p-4 border-t border-bambu-dark-tertiary shrink-0\">\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            onClick={() => resetMutation.mutate()}\n            disabled={resetMutation.isPending}\n            className=\"text-orange-400 hover:text-orange-300\"\n          >\n            {resetMutation.isPending ? (\n              <Loader2 className=\"w-4 h-4 animate-spin mr-2\" />\n            ) : (\n              <RotateCcw className=\"w-4 h-4 mr-2\" />\n            )}\n            {t('notifications.resetToDefault')}\n          </Button>\n\n          <div className=\"flex gap-2\">\n            <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n              {t('notifications.cancel')}\n            </Button>\n            <Button\n              onClick={handleSubmit}\n              disabled={updateMutation.isPending || !hasChanges}\n            >\n              {updateMutation.isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin mr-2\" />\n              ) : (\n                <Save className=\"w-4 h-4 mr-2\" />\n              )}\n              {t('notifications.save')}\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/OIDCProviderSettings.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport type { OIDCProvider, OIDCProviderCreate } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { Toggle } from './Toggle';\nimport { ConfirmModal } from './ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\n\nconst EMPTY_FORM: OIDCProviderCreate = {\n  name: '',\n  issuer_url: '',\n  client_id: '',\n  client_secret: '',\n  scopes: 'openid email profile',\n  is_enabled: true,\n  auto_create_users: false,\n  auto_link_existing_accounts: false,\n  icon_url: undefined,\n};\n\n// ─── Provider form (create / edit) ───────────────────────────────────────────\nfunction ProviderForm({\n  initial,\n  isEdit = false,\n  onSave,\n  onCancel,\n  isPending,\n}: {\n  initial: OIDCProviderCreate;\n  isEdit?: boolean;\n  onSave: (data: OIDCProviderCreate) => void;\n  onCancel: () => void;\n  isPending: boolean;\n}) {\n  const { t } = useTranslation();\n  const [form, setForm] = useState<OIDCProviderCreate>(initial);\n  const [secretChanged, setSecretChanged] = useState(false);\n  const set = (key: keyof OIDCProviderCreate, value: unknown) =>\n    setForm((prev) => ({ ...prev, [key]: value }));\n\n  const inputCls =\n    'w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors text-sm';\n  const labelCls = 'block text-sm font-medium text-white mb-1';\n\n  const handleSave = () => {\n    const payload = { ...form };\n    if (isEdit && !secretChanged) {\n      delete (payload as Partial<OIDCProviderCreate>).client_secret;\n    }\n    onSave(payload);\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-4\">\n        <div>\n          <label className={labelCls}>{t('settings.oidc.form.name')} <span className=\"text-red-400\">*</span></label>\n          <input className={inputCls} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder=\"Google\" />\n        </div>\n        <div>\n          <label className={labelCls}>{t('settings.oidc.form.issuerUrl')} <span className=\"text-red-400\">*</span></label>\n          <input className={inputCls} value={form.issuer_url} onChange={(e) => set('issuer_url', e.target.value)} placeholder=\"https://accounts.google.com\" />\n        </div>\n        <div>\n          <label className={labelCls}>{t('settings.oidc.form.clientId')} <span className=\"text-red-400\">*</span></label>\n          <input className={inputCls} value={form.client_id} onChange={(e) => set('client_id', e.target.value)} placeholder=\"your-client-id\" />\n        </div>\n        <div>\n          <label className={labelCls}>\n            {t('settings.oidc.form.clientSecret')}\n            {!isEdit && <span className=\"text-red-400\"> *</span>}\n            {isEdit && <span className=\"text-bambu-gray text-xs ml-1\">({t('settings.oidc.form.secretHint')})</span>}\n          </label>\n          <input\n            className={inputCls}\n            type=\"password\"\n            value={secretChanged ? form.client_secret : ''}\n            placeholder={isEdit && !secretChanged ? '••••••••' : t('settings.oidc.form.secretPlaceholder')}\n            onChange={(e) => {\n              setSecretChanged(true);\n              set('client_secret', e.target.value);\n            }}\n          />\n        </div>\n        <div>\n          <label className={labelCls}>{t('settings.oidc.form.scopes')}</label>\n          <input className={inputCls} value={form.scopes} onChange={(e) => set('scopes', e.target.value)} placeholder=\"openid email profile\" />\n        </div>\n        <div>\n          <label className={labelCls}>{t('settings.oidc.form.iconUrl')}</label>\n          <input className={inputCls} value={form.icon_url ?? ''} onChange={(e) => set('icon_url', e.target.value || undefined)} placeholder=\"https://...\" />\n        </div>\n      </div>\n\n      <div className=\"flex flex-wrap gap-6 pt-2\">\n        <label className=\"flex items-center gap-3 cursor-pointer\">\n          <Toggle checked={form.is_enabled ?? true} onChange={(v) => set('is_enabled', v)} />\n          <span className=\"text-white text-sm\">{t('settings.oidc.form.enabled')}</span>\n        </label>\n        <label className=\"flex items-center gap-3 cursor-pointer\">\n          <Toggle checked={form.auto_create_users ?? false} onChange={(v) => set('auto_create_users', v)} />\n          <div>\n            <p className=\"text-white text-sm\">{t('settings.oidc.form.autoCreate')}</p>\n            <p className=\"text-bambu-gray text-xs\">{t('settings.oidc.form.autoCreateDesc')}</p>\n          </div>\n        </label>\n        <label className=\"flex items-center gap-3 cursor-pointer\">\n          <Toggle checked={form.auto_link_existing_accounts ?? false} onChange={(v) => set('auto_link_existing_accounts', v)} />\n          <div>\n            <p className=\"text-white text-sm\">{t('settings.oidc.form.autoLink')}</p>\n            <p className=\"text-bambu-gray text-xs\">{t('settings.oidc.form.autoLinkDesc')}</p>\n          </div>\n        </label>\n      </div>\n\n      <div className=\"flex gap-3 pt-2\">\n        <Button variant=\"secondary\" onClick={onCancel} className=\"flex-1\">\n          {t('common.cancel')}\n        </Button>\n        <Button\n          variant=\"primary\"\n          className=\"flex-1\"\n          disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || isPending}\n          onClick={handleSave}\n        >\n          {isPending ? t('common.saving') : t('common.save')}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\n// ─── Main component ───────────────────────────────────────────────────────────\nexport function OIDCProviderSettings() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const [showCreate, setShowCreate] = useState(false);\n  const [editingId, setEditingId] = useState<number | null>(null);\n  const [deleteTarget, setDeleteTarget] = useState<OIDCProvider | null>(null);\n\n  const { data: providers, isLoading } = useQuery({\n    queryKey: ['oidc-providers-all'],\n    queryFn: () => api.getOIDCProvidersAll(),\n  });\n\n  const createMutation = useMutation({\n    mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });\n      setShowCreate(false);\n      showToast(t('settings.oidc.created'), 'success');\n    },\n    onError: (e: Error) => showToast(e.message, 'error'),\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data: Partial<OIDCProviderCreate> }) =>\n      api.updateOIDCProvider(id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });\n      setEditingId(null);\n      showToast(t('settings.oidc.updated'), 'success');\n    },\n    onError: (e: Error) => showToast(e.message, 'error'),\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (id: number) => api.deleteOIDCProvider(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });\n      setDeleteTarget(null);\n      showToast(t('settings.oidc.deleted'), 'success');\n    },\n    onError: (e: Error) => showToast(e.message, 'error'),\n  });\n\n  const toggleEnabled = (provider: OIDCProvider) =>\n    updateMutation.mutate({ id: provider.id, data: { is_enabled: !provider.is_enabled } });\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-12\">\n        <RefreshCw className=\"w-6 h-6 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Header */}\n      <Card id=\"card-oidc\">\n        <CardHeader>\n          <div className=\"flex items-center justify-between\">\n            <div>\n              <h3 className=\"text-white font-semibold\">{t('settings.oidc.title')}</h3>\n              <p className=\"text-bambu-gray text-sm\">{t('settings.oidc.desc')}</p>\n            </div>\n            {!showCreate && (\n              <Button variant=\"primary\" size=\"sm\" onClick={() => setShowCreate(true)} className=\"flex items-center gap-2\">\n                <Plus className=\"w-4 h-4\" />\n                {t('settings.oidc.addProvider')}\n              </Button>\n            )}\n          </div>\n        </CardHeader>\n\n        {showCreate && (\n          <CardContent>\n            <div className=\"border-t border-bambu-dark-tertiary pt-4\">\n              <h4 className=\"text-white font-medium mb-4\">{t('settings.oidc.newProvider')}</h4>\n              <ProviderForm\n                initial={EMPTY_FORM}\n                onSave={(data) => createMutation.mutate(data)}\n                onCancel={() => setShowCreate(false)}\n                isPending={createMutation.isPending}\n              />\n            </div>\n          </CardContent>\n        )}\n      </Card>\n\n      {/* Provider list */}\n      {providers && providers.length === 0 && !showCreate && (\n        <Card id=\"card-oidc-empty\">\n          <CardContent>\n            <div className=\"text-center py-8 space-y-3\">\n              <Globe className=\"w-12 h-12 text-bambu-gray mx-auto\" />\n              <p className=\"text-bambu-gray\">{t('settings.oidc.empty')}</p>\n              <Button variant=\"primary\" size=\"sm\" onClick={() => setShowCreate(true)} className=\"inline-flex items-center gap-2\">\n                <Plus className=\"w-4 h-4\" />\n                {t('settings.oidc.addProvider')}\n              </Button>\n            </div>\n          </CardContent>\n        </Card>\n      )}\n\n      {providers?.map((provider) => (\n        <Card key={provider.id}>\n          <CardHeader>\n            <div className=\"flex items-center gap-3\">\n              {provider.icon_url ? (\n                <img src={provider.icon_url} alt={provider.name} className=\"w-8 h-8 rounded object-contain\" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />\n              ) : (\n                <div className=\"w-8 h-8 rounded-full bg-bambu-dark-tertiary flex items-center justify-center\">\n                  <Globe className=\"w-4 h-4 text-bambu-gray\" />\n                </div>\n              )}\n              <div className=\"flex-1\">\n                <div className=\"flex items-center gap-2\">\n                  <h4 className=\"text-white font-medium\">{provider.name}</h4>\n                  {provider.is_enabled ? (\n                    <span className=\"flex items-center gap-1 text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full\">\n                      <Check className=\"w-3 h-3\" /> {t('common.enabled')}\n                    </span>\n                  ) : (\n                    <span className=\"flex items-center gap-1 text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-0.5 rounded-full\">\n                      <X className=\"w-3 h-3\" /> {t('common.disabled')}\n                    </span>\n                  )}\n                </div>\n                <div className=\"flex items-center gap-1 text-bambu-gray text-xs mt-0.5\">\n                  <ExternalLink className=\"w-3 h-3\" />\n                  <span>{provider.issuer_url}</span>\n                </div>\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <Toggle\n                  checked={provider.is_enabled}\n                  onChange={() => toggleEnabled(provider)}\n                  disabled={updateMutation.isPending}\n                />\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}\n                >\n                  <Edit2 className=\"w-4 h-4\" />\n                </Button>\n                <Button variant=\"danger\" size=\"sm\" onClick={() => setDeleteTarget(provider)}>\n                  <Trash2 className=\"w-4 h-4\" />\n                </Button>\n              </div>\n            </div>\n          </CardHeader>\n\n          {editingId === provider.id && (\n            <CardContent>\n              <div className=\"border-t border-bambu-dark-tertiary pt-4\">\n                <ProviderForm\n                  isEdit={true}\n                  initial={{\n                    name: provider.name,\n                    issuer_url: provider.issuer_url,\n                    client_id: provider.client_id,\n                    client_secret: '',\n                    scopes: provider.scopes,\n                    is_enabled: provider.is_enabled,\n                    auto_create_users: provider.auto_create_users,\n                    auto_link_existing_accounts: provider.auto_link_existing_accounts,\n                    icon_url: provider.icon_url ?? undefined,\n                  }}\n                  onSave={(data) => updateMutation.mutate({ id: provider.id, data })}\n                  onCancel={() => setEditingId(null)}\n                  isPending={updateMutation.isPending}\n                />\n              </div>\n            </CardContent>\n          )}\n\n          {editingId !== provider.id && (\n            <CardContent>\n              <dl className=\"grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm\">\n                <div>\n                  <dt className=\"text-bambu-gray\">{t('settings.oidc.form.clientId')}</dt>\n                  <dd className=\"text-white font-mono truncate\">{provider.client_id}</dd>\n                </div>\n                <div>\n                  <dt className=\"text-bambu-gray\">{t('settings.oidc.form.scopes')}</dt>\n                  <dd className=\"text-white\">{provider.scopes}</dd>\n                </div>\n                <div>\n                  <dt className=\"text-bambu-gray\">{t('settings.oidc.form.autoCreate')}</dt>\n                  <dd className={provider.auto_create_users ? 'text-green-400' : 'text-bambu-gray'}>\n                    {provider.auto_create_users ? t('common.yes') : t('common.no')}\n                  </dd>\n                </div>\n                <div>\n                  <dt className=\"text-bambu-gray\">{t('settings.oidc.form.autoLink')}</dt>\n                  <dd className={provider.auto_link_existing_accounts ? 'text-green-400' : 'text-bambu-gray'}>\n                    {provider.auto_link_existing_accounts ? t('common.yes') : t('common.no')}\n                  </dd>\n                </div>\n              </dl>\n            </CardContent>\n          )}\n        </Card>\n      ))}\n\n      {/* Delete confirm */}\n      {deleteTarget && (\n        <ConfirmModal\n          title={t('settings.oidc.deleteTitle')}\n          message={t('settings.oidc.deleteMessage', { name: deleteTarget.name })}\n          confirmText={t('common.delete')}\n          variant=\"danger\"\n          onConfirm={() => deleteMutation.mutate(deleteTarget.id)}\n          onCancel={() => setDeleteTarget(null)}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PendingUploadsPanel.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Loader2, Archive, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react';\nimport { pendingUploadsApi } from '../api/client';\nimport type { PendingUpload, ProjectListItem } from '../api/client';\nimport { api } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\nimport { ConfirmModal } from './ConfirmModal';\nimport { formatFileSize } from '../utils/file';\n\nfunction formatTimeAgo(dateStr: string): string {\n  const date = new Date(dateStr);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMins = Math.floor(diffMs / 60000);\n\n  if (diffMins < 1) return 'Just now';\n  if (diffMins < 60) return `${diffMins}m ago`;\n  const diffHours = Math.floor(diffMins / 60);\n  if (diffHours < 24) return `${diffHours}h ago`;\n  const diffDays = Math.floor(diffHours / 24);\n  return `${diffDays}d ago`;\n}\n\ninterface PendingUploadItemProps {\n  upload: PendingUpload;\n  projects: ProjectListItem[];\n  onArchive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) => void;\n  onDiscard: (id: number) => void;\n  isArchiving: boolean;\n  isDiscarding: boolean;\n}\n\nfunction PendingUploadItem({\n  upload,\n  projects,\n  onArchive,\n  onDiscard,\n  isArchiving,\n  isDiscarding,\n}: PendingUploadItemProps) {\n  const [expanded, setExpanded] = useState(false);\n  const [tags, setTags] = useState(upload.tags || '');\n  const [notes, setNotes] = useState(upload.notes || '');\n  const [projectId, setProjectId] = useState<number | null>(upload.project_id);\n  const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);\n\n  return (\n    <Card>\n      <CardContent className=\"py-3\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-3\">\n            <FileBox className=\"w-8 h-8 text-bambu-green flex-shrink-0\" />\n            <div>\n              <p className=\"text-white font-medium\">{upload.filename}</p>\n              <div className=\"flex items-center gap-2 text-xs text-bambu-gray\">\n                <span>{formatFileSize(upload.file_size)}</span>\n                <span>·</span>\n                <span className=\"flex items-center gap-1\">\n                  <Clock className=\"w-3 h-3\" />\n                  {formatTimeAgo(upload.uploaded_at)}\n                </span>\n                {upload.source_ip && (\n                  <>\n                    <span>·</span>\n                    <span>from {upload.source_ip}</span>\n                  </>\n                )}\n              </div>\n            </div>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <button\n              onClick={() => setExpanded(!expanded)}\n              className=\"p-1 text-bambu-gray hover:text-white transition-colors\"\n            >\n              {expanded ? <ChevronUp className=\"w-5 h-5\" /> : <ChevronDown className=\"w-5 h-5\" />}\n            </button>\n            <Button\n              variant=\"primary\"\n              size=\"sm\"\n              onClick={() => onArchive(upload.id, { tags, notes, project_id: projectId || undefined })}\n              disabled={isArchiving}\n            >\n              {isArchiving ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <>\n                  <Archive className=\"w-4 h-4\" />\n                  Archive\n                </>\n              )}\n            </Button>\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              onClick={() => setShowDiscardConfirm(true)}\n              disabled={isDiscarding}\n            >\n              {isDiscarding ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <Trash2 className=\"w-4 h-4 text-red-400\" />\n              )}\n            </Button>\n          </div>\n        </div>\n\n        {/* Discard Confirmation Modal */}\n        {showDiscardConfirm && (\n          <ConfirmModal\n            title=\"Discard Upload\"\n            message={`Are you sure you want to discard \"${upload.filename}\"? This cannot be undone.`}\n            confirmText=\"Discard\"\n            variant=\"danger\"\n            onConfirm={() => {\n              onDiscard(upload.id);\n              setShowDiscardConfirm(false);\n            }}\n            onCancel={() => setShowDiscardConfirm(false)}\n          />\n        )}\n\n        {/* Expanded details for adding tags/notes/project */}\n        {expanded && (\n          <div className=\"mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3\">\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">Tags</label>\n              <input\n                type=\"text\"\n                value={tags}\n                onChange={(e) => setTags(e.target.value)}\n                placeholder=\"e.g., functional, prototype, gift\"\n                className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">Notes</label>\n              <textarea\n                value={notes}\n                onChange={(e) => setNotes(e.target.value)}\n                placeholder=\"Add notes about this print...\"\n                rows={2}\n                className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm resize-none\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">Project</label>\n              <select\n                value={projectId || ''}\n                onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}\n                className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm\"\n              >\n                <option value=\"\">No project</option>\n                {projects.map((project) => (\n                  <option key={project.id} value={project.id}>\n                    {project.name}\n                  </option>\n                ))}\n              </select>\n            </div>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n\nexport function PendingUploadsPanel() {\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [showArchiveAllConfirm, setShowArchiveAllConfirm] = useState(false);\n  const [showDiscardAllConfirm, setShowDiscardAllConfirm] = useState(false);\n  const [archivingIds, setArchivingIds] = useState<Set<number>>(new Set());\n  const [discardingIds, setDiscardingIds] = useState<Set<number>>(new Set());\n\n  // Fetch pending uploads\n  const { data: uploads, isLoading: uploadsLoading } = useQuery({\n    queryKey: ['pending-uploads'],\n    queryFn: pendingUploadsApi.list,\n    refetchInterval: 10000, // Refresh every 10 seconds\n  });\n\n  // Fetch projects for dropdown\n  const { data: projects } = useQuery({\n    queryKey: ['projects'],\n    queryFn: () => api.getProjects(),\n  });\n\n  // Archive mutation\n  const archiveMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data?: { tags?: string; notes?: string; project_id?: number } }) =>\n      pendingUploadsApi.archive(id, data),\n    onMutate: ({ id }) => {\n      setArchivingIds((prev) => new Set(prev).add(id));\n    },\n    onSettled: (_, __, { id }) => {\n      setArchivingIds((prev) => {\n        const next = new Set(prev);\n        next.delete(id);\n        return next;\n      });\n    },\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(`Archived: ${data.print_name}`);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Failed to archive', 'error');\n    },\n  });\n\n  // Discard mutation\n  const discardMutation = useMutation({\n    mutationFn: (id: number) => pendingUploadsApi.discard(id),\n    onMutate: (id) => {\n      setDiscardingIds((prev) => new Set(prev).add(id));\n    },\n    onSettled: (_, __, id) => {\n      setDiscardingIds((prev) => {\n        const next = new Set(prev);\n        next.delete(id);\n        return next;\n      });\n    },\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });\n      showToast('Upload discarded');\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Failed to discard', 'error');\n    },\n  });\n\n  // Archive all mutation\n  const archiveAllMutation = useMutation({\n    mutationFn: pendingUploadsApi.archiveAll,\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(`Archived ${data.archived} files${data.failed > 0 ? `, ${data.failed} failed` : ''}`);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Failed to archive all', 'error');\n    },\n  });\n\n  // Discard all mutation\n  const discardAllMutation = useMutation({\n    mutationFn: pendingUploadsApi.discardAll,\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });\n      showToast(`Discarded ${data.discarded} files`);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Failed to discard all', 'error');\n    },\n  });\n\n  if (uploadsLoading) {\n    return (\n      <Card>\n        <CardContent className=\"py-8 flex justify-center\">\n          <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n        </CardContent>\n      </Card>\n    );\n  }\n\n  if (!uploads || uploads.length === 0) {\n    return null; // Don't render if no pending uploads\n  }\n\n  return (\n    <div className=\"mb-6\">\n      <Card className=\"border-l-4 border-l-yellow-500\">\n        <CardHeader>\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <Upload className=\"w-5 h-5 text-yellow-500\" />\n              <h2 className=\"text-lg font-semibold text-white\">\n                Pending Uploads ({uploads.length})\n              </h2>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Button\n                variant=\"primary\"\n                size=\"sm\"\n                onClick={() => setShowArchiveAllConfirm(true)}\n                disabled={archiveAllMutation.isPending}\n              >\n                {archiveAllMutation.isPending ? (\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                ) : (\n                  <>\n                    <Archive className=\"w-4 h-4\" />\n                    Archive All\n                  </>\n                )}\n              </Button>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={() => setShowDiscardAllConfirm(true)}\n                disabled={discardAllMutation.isPending}\n              >\n                {discardAllMutation.isPending ? (\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                ) : (\n                  <>\n                    <Trash2 className=\"w-4 h-4\" />\n                    Discard All\n                  </>\n                )}\n              </Button>\n            </div>\n          </div>\n        </CardHeader>\n        <CardContent>\n          <p className=\"text-sm text-bambu-gray mb-4\">\n            These files were uploaded via the virtual printer. Review and archive them to add to your collection.\n          </p>\n          <div className=\"space-y-3\">\n            {uploads.map((upload) => (\n              <PendingUploadItem\n                key={upload.id}\n                upload={upload}\n                projects={projects || []}\n                onArchive={(id, data) => archiveMutation.mutate({ id, data })}\n                onDiscard={(id) => discardMutation.mutate(id)}\n                isArchiving={archivingIds.has(upload.id)}\n                isDiscarding={discardingIds.has(upload.id)}\n              />\n            ))}\n          </div>\n        </CardContent>\n      </Card>\n\n      {/* Archive All Confirmation */}\n      {showArchiveAllConfirm && (\n        <ConfirmModal\n          title=\"Archive All Uploads\"\n          message={`Are you sure you want to archive all ${uploads.length} pending uploads?`}\n          confirmText=\"Archive All\"\n          onConfirm={() => {\n            archiveAllMutation.mutate();\n            setShowArchiveAllConfirm(false);\n          }}\n          onCancel={() => setShowArchiveAllConfirm(false)}\n        />\n      )}\n\n      {/* Discard All Confirmation */}\n      {showDiscardAllConfirm && (\n        <ConfirmModal\n          title=\"Discard All Uploads\"\n          message={`Are you sure you want to discard all ${uploads.length} pending uploads? This cannot be undone.`}\n          confirmText=\"Discard All\"\n          variant=\"danger\"\n          onConfirm={() => {\n            discardAllMutation.mutate();\n            setShowDiscardAllConfirm(false);\n          }}\n          onCancel={() => setShowDiscardAllConfirm(false)}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PhotoGalleryModal.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { X, ChevronLeft, ChevronRight, Download, Trash2 } from 'lucide-react';\nimport { api } from '../api/client';\nimport { Button } from './Button';\nimport { ConfirmModal } from './ConfirmModal';\n\ninterface PhotoGalleryModalProps {\n  archiveId: number;\n  archiveName: string;\n  photos: string[];\n  onClose: () => void;\n  onDelete?: (filename: string) => void;\n}\n\nexport function PhotoGalleryModal({\n  archiveId,\n  archiveName,\n  photos,\n  onClose,\n  onDelete,\n}: PhotoGalleryModalProps) {\n  const [currentIndex, setCurrentIndex] = useState(0);\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n\n  // Keyboard navigation\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n      if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));\n      if (e.key === 'ArrowRight') setCurrentIndex((i) => Math.min(photos.length - 1, i + 1));\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose, photos.length]);\n\n  // Reset index if photos change\n  useEffect(() => {\n    if (currentIndex >= photos.length) {\n      setCurrentIndex(Math.max(0, photos.length - 1));\n    }\n  }, [photos.length, currentIndex]);\n\n  if (photos.length === 0) {\n    onClose();\n    return null;\n  }\n\n  const currentPhoto = photos[currentIndex];\n  const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);\n\n  const handleDownload = () => {\n    const link = document.createElement('a');\n    link.href = photoUrl;\n    link.download = `${archiveName}_photo_${currentIndex + 1}.jpg`;\n    link.click();\n  };\n\n  const handleDelete = () => {\n    if (onDelete) {\n      setShowDeleteConfirm(true);\n    }\n  };\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/90 flex items-center justify-center z-50\"\n      onClick={onClose}\n    >\n      <div\n        className=\"relative w-full h-full flex flex-col\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-6 py-4 bg-black/50\">\n          <div>\n            <h2 className=\"text-lg font-semibold text-white\">{archiveName}</h2>\n            <p className=\"text-sm text-bambu-gray\">\n              Photo {currentIndex + 1} of {photos.length}\n            </p>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button variant=\"secondary\" size=\"sm\" onClick={handleDownload}>\n              <Download className=\"w-4 h-4\" />\n              Download\n            </Button>\n            {onDelete && (\n              <Button variant=\"secondary\" size=\"sm\" onClick={handleDelete} className=\"text-red-400 hover:text-red-300\">\n                <Trash2 className=\"w-4 h-4\" />\n              </Button>\n            )}\n            <button\n              onClick={onClose}\n              className=\"p-2 text-bambu-gray hover:text-white transition-colors\"\n            >\n              <X className=\"w-6 h-6\" />\n            </button>\n          </div>\n        </div>\n\n        {/* Image */}\n        <div className=\"flex-1 min-h-0 flex items-center justify-center p-4 relative overflow-hidden\">\n          {/* Previous button */}\n          {currentIndex > 0 && (\n            <button\n              onClick={() => setCurrentIndex((i) => i - 1)}\n              className=\"absolute left-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors\"\n            >\n              <ChevronLeft className=\"w-8 h-8 text-white\" />\n            </button>\n          )}\n\n          {/* Image */}\n          <img\n            src={photoUrl}\n            alt={`Photo ${currentIndex + 1}`}\n            className=\"max-w-full max-h-full object-contain rounded-lg\"\n            style={{ maxHeight: 'calc(100vh - 200px)' }}\n          />\n\n          {/* Next button */}\n          {currentIndex < photos.length - 1 && (\n            <button\n              onClick={() => setCurrentIndex((i) => i + 1)}\n              className=\"absolute right-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors\"\n            >\n              <ChevronRight className=\"w-8 h-8 text-white\" />\n            </button>\n          )}\n        </div>\n\n        {/* Thumbnails */}\n        {photos.length > 1 && (\n          <div className=\"flex justify-center gap-2 p-4 bg-black/50\">\n            {photos.map((photo, index) => (\n              <button\n                key={photo}\n                onClick={() => setCurrentIndex(index)}\n                className={`w-16 h-16 rounded-lg overflow-hidden border-2 transition-colors ${\n                  index === currentIndex\n                    ? 'border-bambu-green'\n                    : 'border-transparent hover:border-bambu-gray'\n                }`}\n              >\n                <img\n                  src={api.getArchivePhotoUrl(archiveId, photo)}\n                  alt={`Thumbnail ${index + 1}`}\n                  className=\"w-full h-full object-cover\"\n                />\n              </button>\n            ))}\n          </div>\n        )}\n      </div>\n\n      {/* Delete Confirmation Modal */}\n      {showDeleteConfirm && (\n        <ConfirmModal\n          title=\"Delete Photo\"\n          message=\"Delete this photo? This cannot be undone.\"\n          confirmText=\"Delete\"\n          variant=\"danger\"\n          onConfirm={() => {\n            onDelete?.(currentPhoto);\n            setShowDeleteConfirm(false);\n          }}\n          onCancel={() => setShowDeleteConfirm(false)}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrintCalendar.tsx",
    "content": "import { useMemo, useRef, useState, useEffect } from 'react';\n\ninterface PrintCalendarProps {\n  printDates: string[]; // Array of ISO date strings\n  months?: number; // How many months to show (default 3)\n}\n\nexport function PrintCalendar({ printDates, months = 3 }: PrintCalendarProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [containerWidth, setContainerWidth] = useState(0);\n\n  // Measure container width\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const observer = new ResizeObserver((entries) => {\n      const width = entries[0]?.contentRect.width || 0;\n      setContainerWidth(width);\n    });\n\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, []);\n\n  const { weeks, monthLabels, printCounts } = useMemo(() => {\n    // Count prints per day\n    const counts: Record<string, number> = {};\n    printDates.forEach((date) => {\n      const day = date.split('T')[0];\n      counts[day] = (counts[day] || 0) + 1;\n    });\n\n    // Generate weeks for the last N months\n    const today = new Date();\n    const startDate = new Date(today);\n    startDate.setMonth(startDate.getMonth() - months);\n    startDate.setDate(startDate.getDate() - startDate.getDay()); // Start from Sunday\n\n    const weeks: Date[][] = [];\n    const monthLabels: { month: string; weekIndex: number }[] = [];\n    let currentWeek: Date[] = [];\n    let lastMonth = -1;\n\n    const current = new Date(startDate);\n    let weekIndex = 0;\n\n    while (current <= today) {\n      if (current.getDay() === 0 && currentWeek.length > 0) {\n        weeks.push(currentWeek);\n        currentWeek = [];\n        weekIndex++;\n      }\n\n      // Track month labels\n      if (current.getMonth() !== lastMonth) {\n        monthLabels.push({\n          month: current.toLocaleDateString('en-US', { month: 'short' }),\n          weekIndex,\n        });\n        lastMonth = current.getMonth();\n      }\n\n      currentWeek.push(new Date(current));\n      current.setDate(current.getDate() + 1);\n    }\n\n    if (currentWeek.length > 0) {\n      weeks.push(currentWeek);\n    }\n\n    return { weeks, monthLabels, printCounts: counts };\n  }, [printDates, months]);\n\n  const maxCount = Math.max(1, ...Object.values(printCounts));\n\n  const getColor = (count: number) => {\n    if (count === 0) return 'bg-bambu-dark';\n    const intensity = count / maxCount;\n    if (intensity <= 0.25) return 'bg-bambu-green/30';\n    if (intensity <= 0.5) return 'bg-bambu-green/50';\n    if (intensity <= 0.75) return 'bg-bambu-green/75';\n    return 'bg-bambu-green';\n  };\n\n  const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];\n\n  // Calculate cell size based on container width\n  const numWeeks = weeks.length;\n  const dayLabelWidth = 32; // Space for day labels (Mon, Wed, Fri)\n  const gap = 2; // Gap between cells\n  const availableWidth = containerWidth - dayLabelWidth - 16; // 16px padding\n  const calculatedCellSize = numWeeks > 0 ? Math.floor((availableWidth - (numWeeks - 1) * gap) / numWeeks) : 12;\n\n  // Clamp cell size between 8 and 20 pixels\n  const cellSize = Math.max(8, Math.min(20, calculatedCellSize));\n  const fontSize = cellSize <= 10 ? 10 : 12;\n\n  return (\n    <div ref={containerRef} className=\"w-full flex justify-center\">\n      {containerWidth > 0 && (\n        <div>\n          {/* Month labels */}\n          <div className=\"flex mb-1\" style={{ marginLeft: dayLabelWidth + 4 }}>\n            {monthLabels.map(({ month, weekIndex }, i) => (\n              <div\n                key={i}\n                className=\"text-bambu-gray\"\n                style={{\n                  fontSize,\n                  marginLeft: i === 0 ? 0 : `${(weekIndex - (monthLabels[i - 1]?.weekIndex || 0)) * (cellSize + gap) - 24}px`,\n                }}\n              >\n                {month}\n              </div>\n            ))}\n          </div>\n\n          <div className=\"flex\" style={{ gap }}>\n            {/* Day labels */}\n            <div className=\"flex flex-col\" style={{ gap, marginRight: 4, width: dayLabelWidth }}>\n              {dayLabels.map((day, i) => (\n                <div\n                  key={day}\n                  className=\"text-bambu-gray flex items-center\"\n                  style={{\n                    width: dayLabelWidth,\n                    height: cellSize,\n                    fontSize,\n                    visibility: i % 2 === 1 ? 'visible' : 'hidden',\n                  }}\n                >\n                  {day}\n                </div>\n              ))}\n            </div>\n\n            {/* Calendar grid */}\n            {weeks.map((week, weekIdx) => (\n              <div key={weekIdx} className=\"flex flex-col\" style={{ gap }}>\n                {[0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => {\n                  const day = week.find((d) => d.getDay() === dayOfWeek);\n                  if (!day) {\n                    return (\n                      <div\n                        key={dayOfWeek}\n                        style={{ width: cellSize, height: cellSize }}\n                      />\n                    );\n                  }\n\n                  const dateStr = day.toISOString().split('T')[0];\n                  const count = printCounts[dateStr] || 0;\n                  const isToday = dateStr === new Date().toISOString().split('T')[0];\n\n                  return (\n                    <div\n                      key={dayOfWeek}\n                      className={`rounded-sm ${getColor(count)} ${isToday ? 'ring-1 ring-white' : ''}`}\n                      style={{ width: cellSize, height: cellSize }}\n                      title={`${day.toLocaleDateString()}: ${count} print${count !== 1 ? 's' : ''}`}\n                    />\n                  );\n                })}\n              </div>\n            ))}\n          </div>\n\n          {/* Legend */}\n          <div className=\"flex items-center gap-2 mt-3 text-bambu-gray\" style={{ fontSize }}>\n            <span>Less</span>\n            <div className=\"flex\" style={{ gap }}>\n              <div className=\"rounded-sm bg-bambu-dark\" style={{ width: cellSize, height: cellSize }} />\n              <div className=\"rounded-sm bg-bambu-green/30\" style={{ width: cellSize, height: cellSize }} />\n              <div className=\"rounded-sm bg-bambu-green/50\" style={{ width: cellSize, height: cellSize }} />\n              <div className=\"rounded-sm bg-bambu-green/75\" style={{ width: cellSize, height: cellSize }} />\n              <div className=\"rounded-sm bg-bambu-green\" style={{ width: cellSize, height: cellSize }} />\n            </div>\n            <span>More</span>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrintModal/FilamentMapping.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';\nimport { api } from '../../api/client';\nimport { useFilamentMapping } from '../../hooks/useFilamentMapping';\nimport { getGlobalTrayId } from '../../utils/amsHelpers';\nimport { getColorName } from '../../utils/colors';\nimport type { FilamentMappingProps } from './types';\n\n/**\n * Filament mapping UI for comparing required filaments with loaded AMS slots.\n * Shows auto-matched and manually overridden slot assignments.\n */\nexport function FilamentMapping({\n  printerId,\n  filamentReqs,\n  manualMappings,\n  onManualMappingChange,\n  currencySymbol,\n  defaultCostPerKg,\n  defaultExpanded = false,\n}: FilamentMappingProps & { defaultExpanded?: boolean }) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [isRefreshing, setIsRefreshing] = useState(false);\n  const [isExpanded, setIsExpanded] = useState(defaultExpanded);\n\n  // Fetch printer status\n  const { data: printerStatus } = useQuery({\n    queryKey: ['printer-status', printerId],\n    queryFn: () => api.getPrinterStatus(printerId),\n    enabled: !!printerId,\n  });\n\n  const { data: assignments } = useQuery({\n    queryKey: ['spool-assignments', printerId],\n    queryFn: () => api.getAssignments(printerId),\n    enabled: !!printerId,\n  });\n\n  const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =\n    useFilamentMapping(filamentReqs, printerStatus, manualMappings);\n\n  const trayCostMap = useMemo(() => {\n    const map = new Map<number, number | null>();\n    for (const assignment of assignments || []) {\n      const isExternal = assignment.ams_id === 255;\n      const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);\n      map.set(globalTrayId, assignment.spool?.cost_per_kg ?? null);\n    }\n    return map;\n  }, [assignments]);\n\n  const trayRemainingWeightMap = useMemo(() => {\n    const map = new Map<number, number | null>();\n    for (const assignment of assignments || []) {\n      const isExternal = assignment.ams_id === 255;\n      const globalTrayId = getGlobalTrayId(assignment.ams_id, assignment.tray_id, isExternal);\n      const spool = assignment.spool;\n      if (!spool) {\n        map.set(globalTrayId, null);\n        continue;\n      }\n      map.set(globalTrayId, Math.max(0, Math.round((spool.label_weight ?? 0) - (spool.weight_used ?? 0))));\n    }\n    return map;\n  }, [assignments]);\n\n  const totalCost = useMemo(() => {\n    let total = 0;\n    for (const item of filamentComparison) {\n      const trayId = item.loaded?.globalTrayId;\n      if (trayId == null) continue;\n      const assignedCost = trayCostMap.get(trayId) ?? null;\n      const costPerKg = assignedCost ?? defaultCostPerKg;\n      if (costPerKg > 0) {\n        total += (item.used_grams / 1000) * costPerKg;\n      }\n    }\n    return total;\n  }, [filamentComparison, trayCostMap, defaultCostPerKg]);\n\n  const hasAnyCost = useMemo(\n    () => Array.from(trayCostMap.values()).some((v) => v != null && v > 0),\n    [trayCostMap]\n  );\n  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;\n  const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;\n\n  // Don't render if no filament requirements\n  if (!hasFilamentReqs) {\n    return null;\n  }\n\n  // Don't render until we have printer status to do the comparison\n  if (!printerStatus) {\n    return null;\n  }\n\n  // Determine status indicator color\n  const statusColor = hasTypeMismatch\n    ? '#f97316' // orange\n    : hasColorMismatch\n    ? '#facc15' // yellow\n    : '#00ae42'; // green\n\n  const handleSlotChange = (slotId: number, value: string) => {\n    if (slotId > 0) {\n      if (value === '') {\n        // Clear manual override\n        const next = { ...manualMappings };\n        delete next[slotId];\n        onManualMappingChange(next);\n      } else {\n        onManualMappingChange({\n          ...manualMappings,\n          [slotId]: parseInt(value, 10),\n        });\n      }\n    }\n  };\n\n  const handleRefresh = async () => {\n    setIsRefreshing(true);\n    try {\n      // Request fresh data from printer via MQTT pushall command\n      await api.refreshPrinterStatus(printerId);\n      // Wait a moment for printer to respond, then refetch\n      await new Promise((r) => setTimeout(r, 500));\n      await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });\n    } finally {\n      setIsRefreshing(false);\n    }\n  };\n\n  return (\n    <div className=\"mb-4\">\n      <button\n        type=\"button\"\n        onClick={() => setIsExpanded(!isExpanded)}\n        className=\"flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full\"\n      >\n        <Circle className=\"w-4 h-4\" fill={statusColor} stroke=\"none\" />\n        <span>{t('printModal.filamentMapping')}</span>\n        {hasTypeMismatch ? (\n          <span className=\"text-xs text-orange-400\">(Type not found)</span>\n        ) : hasColorMismatch ? (\n          <span className=\"text-xs text-yellow-400\">(Color mismatch)</span>\n        ) : (\n          <span className=\"text-xs text-bambu-green\">(Ready)</span>\n        )}\n        {isExpanded ? (\n          <ChevronUp className=\"w-4 h-4 ml-auto\" />\n        ) : (\n          <ChevronDown className=\"w-4 h-4 ml-auto\" />\n        )}\n      </button>\n\n      {isExpanded && (\n        <div className=\"mt-2 bg-bambu-dark rounded-lg p-3 space-y-2\">\n          <div className=\"flex items-center justify-between mb-2\">\n            <span className=\"text-xs text-bambu-gray\">Click to change slot assignment</span>\n            <button\n              type=\"button\"\n              onClick={handleRefresh}\n              className=\"flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white\"\n              disabled={isRefreshing}\n            >\n              <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />\n              <span>Re-read</span>\n            </button>\n          </div>\n          {filamentComparison.map((item, idx) => (\n            <div\n              key={idx}\n              className=\"grid items-center gap-2 text-xs\"\n              style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}\n            >\n              {/* Required color */}\n              <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>\n                <Circle className=\"w-3 h-3\" fill={item.color} stroke={item.color} />\n              </span>\n              {/* Required type + grams + nozzle badge */}\n              <span className=\"text-white truncate flex items-center gap-1\">\n                {isDualNozzle && item.nozzle_id != null && (\n                  <span\n                    className=\"inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0\"\n                    title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}\n                  >\n                    {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}\n                  </span>\n                )}\n                {item.type} <span className=\"text-bambu-gray\">({item.used_grams}g)</span>\n              </span>\n              {/* Arrow */}\n              <span className=\"text-bambu-gray\">→</span>\n              {/* Slot selector dropdown */}\n              <select\n                value={item.loaded?.globalTrayId ?? ''}\n                onChange={(e) => handleSlotChange(item.slot_id || 0, e.target.value)}\n                className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${\n                  item.status === 'match'\n                    ? 'border-bambu-green/50 text-bambu-green'\n                    : item.status === 'type_only'\n                    ? 'border-yellow-400/50 text-yellow-400'\n                    : 'border-orange-400/50 text-orange-400'\n                } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}\n                title={item.isManual ? 'Manually selected' : 'Auto-matched'}\n              >\n                <option value=\"\" className=\"bg-bambu-dark text-bambu-gray\">\n                  -- Select slot --\n                </option>\n                {loadedFilaments\n                  .filter((f) => item.nozzle_id == null || f.extruderId === item.nozzle_id)\n                  .map((f) => {\n                    const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);\n                    const remainingLabel = remainingWeight != null\n                      ? t('printModal.slotRemainingShort', {\n                          grams: remainingWeight,\n                          defaultValue: ` - ${remainingWeight}g left`,\n                        })\n                      : '';\n                    return (\n                      <option key={f.globalTrayId} value={f.globalTrayId} className=\"bg-bambu-dark text-white\">\n                        {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}\n                      </option>\n                    );\n                })}\n              </select>\n              {/* Status icon */}\n              {item.status === 'match' ? (\n                <Check className=\"w-3 h-3 text-bambu-green\" />\n              ) : item.status === 'type_only' ? (\n                <span title=\"Same type, different color\">\n                  <AlertTriangle className=\"w-3 h-3 text-yellow-400\" />\n                </span>\n              ) : (\n                <span title=\"Filament type not loaded\">\n                  <AlertTriangle className=\"w-3 h-3 text-orange-400\" />\n                </span>\n              )}\n            </div>\n          ))}\n          <div className=\"text-xs text-bambu-gray\">\n            {t('printModal.totalCost')}{' '}\n            <span className=\"text-white\">\n              {totalCost > 0 || hasAnyCost ? `${currencySymbol}${totalCost.toFixed(2)}` : 'N/A'}\n            </span>\n          </div>\n          {hasTypeMismatch && (\n            <p className=\"text-xs text-orange-400 mt-2\">Required filament type not found in printer.</p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrintModal/FilamentOverride.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Circle, RotateCcw, Palette } from 'lucide-react';\nimport { getColorName } from '../../utils/colors';\nimport { canonicalFilamentType } from '../../utils/amsHelpers';\nimport type { FilamentReqsData } from './types';\n\ninterface FilamentOverrideProps {\n  filamentReqs: FilamentReqsData | undefined;\n  availableFilaments: Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>;\n  overrides: Record<number, { type: string; color: string }>;\n  onChange: (overrides: Record<number, { type: string; color: string }>) => void;\n\n  /** Per-slot force color match flags. Defaults to false (opt-in) when not provided. */\n  forceColorMatch?: Record<number, boolean>;\n  /** Called when a slot's force color match checkbox is toggled. */\n  onForceColorMatchChange?: (slotId: number, value: boolean) => void;\n}\n\n/**\n * Filament override UI for model-based queue assignment.\n * Allows users to override the 3MF's original filament choices with\n * filaments available across printers of the selected model.\n */\nexport function FilamentOverride({\n  filamentReqs,\n  availableFilaments,\n  overrides,\n  onChange,\n  forceColorMatch,\n  onForceColorMatchChange,\n}: FilamentOverrideProps) {\n  const { t } = useTranslation();\n\n  // Index available filaments by canonical type for per-slot filtering.\n  // Types in the same equivalence group (e.g. PA-CF / PA12-CF / PAHT-CF) share one bucket.\n  const filamentsByType = useMemo(() => {\n    const map: Record<string, Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>> = {};\n    for (const f of availableFilaments) {\n      const key = canonicalFilamentType(f.type);\n      if (!map[key]) map[key] = [];\n      map[key].push(f);\n    }\n    return map;\n  }, [availableFilaments]);\n\n  const filaments = filamentReqs?.filaments;\n  if (!filaments || filaments.length === 0 || availableFilaments.length === 0) {\n    return null;\n  }\n\n  const handleChange = (slotId: number, value: string) => {\n    if (value === '') {\n      // Reset to original\n      const next = { ...overrides };\n      delete next[slotId];\n      onChange(next);\n    } else {\n      // Parse \"TYPE|COLOR\" value\n      const [type, color] = value.split('|');\n      onChange({ ...overrides, [slotId]: { type, color } });\n    }\n  };\n\n  return (\n    <div className=\"mb-4\">\n      <div className=\"flex items-center gap-2 text-sm text-bambu-gray mb-2\">\n        <span>{t('printModal.filamentOverride')}</span>\n      </div>\n      <p className=\"text-xs text-bambu-gray mb-2\">{t('printModal.filamentOverrideHint')}</p>\n      <div className=\"bg-bambu-dark rounded-lg p-3 space-y-2\">\n        {filaments.map((req) => {\n          const override = overrides[req.slot_id];\n          const isOverridden = !!override;\n          // Only show filaments of the same type AND compatible nozzle/extruder\n          const sameType = filamentsByType[canonicalFilamentType(req.type)] || [];\n          // On dual-nozzle printers (H2D), filter to filaments on the correct extruder.\n          // nozzle_id from 3MF maps to extruder_id from AMS. If nozzle_id is undefined\n          // (single-nozzle) or extruder_id is null, no nozzle filtering is needed.\n          const compatible = req.nozzle_id != null\n            ? sameType.filter((f) => f.extruder_id == null || f.extruder_id === req.nozzle_id)\n            : sameType;\n\n          return (\n            <div key={req.slot_id} className=\"space-y-1\">\n              <div\n                className=\"grid items-center gap-2 text-xs\"\n                style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 20px' }}\n              >\n                {/* Original color swatch */}\n                <span title={`${t('printModal.originalFilament')}: ${req.type} - ${getColorName(req.color)}`}>\n                  <Circle className=\"w-3 h-3\" fill={req.color} stroke={req.color} />\n                </span>\n                {/* Original type + grams */}\n                <span className=\"text-white truncate\">\n                  {req.type} <span className=\"text-bambu-gray\">({req.used_grams}g)</span>\n                </span>\n                {/* Arrow */}\n                <span className=\"text-bambu-gray\">→</span>\n                {/* Override dropdown — only compatible (same-type) filaments */}\n                <select\n                  value={isOverridden ? `${override.type}|${override.color}` : ''}\n                  onChange={(e) => handleChange(req.slot_id, e.target.value)}\n                  disabled={compatible.length === 0}\n                  className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${\n                    isOverridden\n                      ? 'border-blue-400/50 text-blue-400'\n                      : 'border-bambu-gray/30 text-bambu-gray'\n                  }`}\n                >\n                  <option value=\"\" className=\"bg-bambu-dark text-bambu-gray\">\n                    {t('printModal.originalFilament')}: {req.type} ({getColorName(req.color)})\n                  </option>\n                  {compatible.map((f, idx) => (\n                    <option\n                    key={`${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`}\n                      value={`${f.type}|${f.color}`}\n                      className=\"bg-bambu-dark text-white\"\n                    >\n                    {f.tray_sub_brands || f.type} ({getColorName(f.color)})\n                    </option>\n                  ))}\n                </select>\n                {/* Reset button */}\n                {isOverridden ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => handleChange(req.slot_id, '')}\n                    className=\"text-bambu-gray hover:text-white transition-colors\"\n                    title={t('printModal.resetToOriginal')}\n                  >\n                    <RotateCcw className=\"w-3 h-3\" />\n                  </button>\n                ) : (\n                  <span className=\"w-3\" />\n                )}\n              </div>\n              {/* Force Color Match checkbox — shown below each filament row */}\n              <label className=\"inline-flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer select-none pl-5\">\n                <input\n                  type=\"checkbox\"\n                  checked={forceColorMatch?.[req.slot_id] ?? false}\n                  onChange={(e) => onForceColorMatchChange?.(req.slot_id, e.target.checked)}\n                  className=\"accent-bambu-green w-3 h-3\"\n                />\n                <Palette className=\"w-3 h-3\" />\n                {t('printModal.forceColorMatch')}\n              </label>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrintModal/PlateSelector.tsx",
    "content": "import { Layers, Check, AlertTriangle, Square, CheckSquare } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { PlateSelectorProps } from './types';\nimport { formatDuration } from '../../utils/date';\nimport { withStreamToken } from '../../api/client';\n\n/**\n * Plate selection grid for multi-plate 3MF files.\n * Shows thumbnails, names, objects, and print times for each plate.\n * In multi-select mode (add-to-queue), plates have checkboxes for selecting a subset.\n * In single-select mode (reprint/edit), only one plate can be selected at a time.\n */\nexport function PlateSelector({\n  plates,\n  isMultiPlate,\n  selectedPlates,\n  onToggle,\n  onSelectAll,\n  onDeselectAll,\n  multiSelect,\n}: PlateSelectorProps) {\n  const { t } = useTranslation();\n\n  // Only show for multi-plate files with multiple plates\n  if (!isMultiPlate || plates.length <= 1) {\n    return null;\n  }\n\n  const allSelected = selectedPlates.size === plates.length;\n\n  return (\n    <div className=\"mb-4\">\n      <div className=\"flex items-center gap-2 mb-2\">\n        <Layers className=\"w-4 h-4 text-bambu-gray\" />\n        <span className=\"text-sm text-bambu-gray\">Select Plate{multiSelect ? 's' : ''} to Print</span>\n        {selectedPlates.size === 0 && (\n          <span className=\"text-xs text-orange-400 flex items-center gap-1\">\n            <AlertTriangle className=\"w-3 h-3\" />\n            Selection required\n          </span>\n        )}\n        {multiSelect && onSelectAll && onDeselectAll && (\n          <button\n            type=\"button\"\n            onClick={allSelected ? onDeselectAll : onSelectAll}\n            className={`ml-auto text-xs px-2 py-0.5 rounded-full border transition-colors ${\n              allSelected\n                ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'\n                : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'\n            }`}\n          >\n            {allSelected\n              ? t('queue.deselectAll')\n              : t('queue.selectAllPlates', { count: plates.length })}\n          </button>\n        )}\n      </div>\n      <div className=\"grid grid-cols-2 gap-2\">\n        {plates.map((plate) => {\n          const isSelected = selectedPlates.has(plate.index);\n          return (\n            <button\n              key={plate.index}\n              type=\"button\"\n              onClick={() => onToggle(plate.index)}\n              className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${\n                isSelected\n                  ? 'border-bambu-green bg-bambu-green/10'\n                  : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'\n              }`}\n            >\n              {multiSelect && (\n                isSelected\n                  ? <CheckSquare className=\"w-4 h-4 text-bambu-green flex-shrink-0\" />\n                  : <Square className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n              )}\n              {plate.has_thumbnail && plate.thumbnail_url != null ? (\n                <img\n                  src={withStreamToken(plate.thumbnail_url)}\n                  alt={`Plate ${plate.index}`}\n                  className=\"w-10 h-10 rounded object-cover bg-bambu-dark-tertiary\"\n                />\n              ) : (\n                <div className=\"w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center\">\n                  <Layers className=\"w-5 h-5 text-bambu-gray\" />\n                </div>\n              )}\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"text-sm text-white font-medium truncate\">\n                  {plate.name || `Plate ${plate.index}`}\n                </p>\n                <p className=\"text-xs text-bambu-gray truncate\">\n                  {plate.objects.length > 0\n                    ? plate.objects.slice(0, 3).join(', ') +\n                      (plate.objects.length > 3 ? '...' : '')\n                    : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}\n                  {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}\n                </p>\n              </div>\n              {!multiSelect && isSelected && (\n                <Check className=\"w-4 h-4 text-bambu-green flex-shrink-0\" />\n              )}\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrintModal/PrintOptions.tsx",
    "content": "import { useState } from 'react';\nimport { Settings, ChevronDown, ChevronUp } from 'lucide-react';\nimport type { PrintOptionsProps, PrintOptions as PrintOptionsType } from './types';\n\nconst PRINT_OPTIONS_CONFIG = [\n  { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },\n  { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },\n  { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },\n  { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },\n  { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },\n] as const;\n\n/**\n * Print options toggle panel with collapsible UI.\n * Shows bed levelling, flow/vibration calibration, layer inspection, and timelapse options.\n */\nexport function PrintOptionsPanel({\n  options,\n  onChange,\n  defaultExpanded = false,\n}: PrintOptionsProps) {\n  const [isExpanded, setIsExpanded] = useState(defaultExpanded);\n\n  const handleToggle = (key: keyof PrintOptionsType) => {\n    onChange({ ...options, [key]: !options[key] });\n  };\n\n  return (\n    <div className=\"mb-4\">\n      <button\n        type=\"button\"\n        onClick={() => setIsExpanded(!isExpanded)}\n        className=\"flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full\"\n      >\n        <Settings className=\"w-4 h-4\" />\n        <span>Print Options</span>\n        {isExpanded ? (\n          <ChevronUp className=\"w-4 h-4 ml-auto\" />\n        ) : (\n          <ChevronDown className=\"w-4 h-4 ml-auto\" />\n        )}\n      </button>\n      {isExpanded && (\n        <div className=\"mt-2 bg-bambu-dark rounded-lg p-3 space-y-2\">\n          {PRINT_OPTIONS_CONFIG.map(({ key, label, desc }) => (\n            <label key={key} className=\"flex items-center justify-between cursor-pointer group\">\n              <div>\n                <span className=\"text-sm text-white\">{label}</span>\n                <p className=\"text-xs text-bambu-gray\">{desc}</p>\n              </div>\n              <div\n                className={`relative w-10 h-5 rounded-full transition-colors ${\n                  options[key] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n                }`}\n                onClick={() => handleToggle(key)}\n              >\n                <div\n                  className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${\n                    options[key] ? 'translate-x-5' : 'translate-x-0.5'\n                  }`}\n                />\n              </div>\n            </label>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrintModal/PrinterSelector.tsx",
    "content": "import { useState, useMemo } from 'react';\nimport { useQueryClient, useQueries } from '@tanstack/react-query';\nimport {\n  Printer as PrinterIcon,\n  Loader2,\n  AlertCircle,\n  AlertTriangle,\n  Check,\n  Circle,\n  RefreshCw,\n  Wand2,\n  Users,\n} from 'lucide-react';\nimport { api, type PrinterStatus } from '../../api/client';\nimport { getColorName } from '../../utils/colors';\nimport {\n  normalizeColorForCompare,\n  colorsAreSimilar,\n  autoMatchFilament,\n  filterFilamentsByNozzle,\n} from '../../utils/amsHelpers';\nimport type { PrinterSelectorProps, AssignmentMode } from './types';\nimport type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';\nimport type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';\n\ninterface PrinterSelectorWithMappingProps extends PrinterSelectorProps {\n  /** Per-printer mapping results (only used when multiple printers selected) */\n  printerMappingResults?: PrinterMappingResult[];\n  /** Filament requirements for the print */\n  filamentReqs?: { filaments: FilamentRequirement[] };\n  /** Callback to auto-configure a printer */\n  onAutoConfigurePrinter?: (printerId: number) => void;\n  /** Callback to update printer config */\n  onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;\n  /** Current assignment mode */\n  assignmentMode?: AssignmentMode;\n  /** Handler for assignment mode change */\n  onAssignmentModeChange?: (mode: AssignmentMode) => void;\n  /** Selected target model (when assignmentMode is 'model') */\n  targetModel?: string | null;\n  /** Handler for target model change */\n  onTargetModelChange?: (model: string | null) => void;\n  /** Selected target location (when assignmentMode is 'model') */\n  targetLocation?: string | null;\n  /** Handler for target location change */\n  onTargetLocationChange?: (location: string | null) => void;\n  /** Suggested model from sliced file (for pre-selection) */\n  slicedForModel?: string | null;\n}\n\n/** States where the printer is available to accept a new print */\nconst AVAILABLE_STATES = new Set(['IDLE', 'FINISH', 'FAILED']);\n\n/**\n * Inline AMS mapping editor for a single printer.\n */\nfunction InlineMappingEditor({\n  printerResult,\n  filamentReqs,\n  onUpdateConfig,\n}: {\n  printerResult: PrinterMappingResult;\n  filamentReqs: FilamentRequirement[];\n  onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;\n}) {\n  const queryClient = useQueryClient();\n  const [isRefreshing, setIsRefreshing] = useState(false);\n\n  const handleSlotChange = (slotId: number, value: string) => {\n    if (slotId <= 0) return;\n\n    const newMappings = { ...printerResult.config.manualMappings };\n    if (value === '') {\n      delete newMappings[slotId];\n    } else {\n      newMappings[slotId] = parseInt(value, 10);\n    }\n\n    onUpdateConfig({\n      useDefault: false,\n      manualMappings: newMappings,\n      autoConfigured: false,\n    });\n  };\n\n  const handleRefresh = async () => {\n    setIsRefreshing(true);\n    try {\n      await api.refreshPrinterStatus(printerResult.printerId);\n      await new Promise((r) => setTimeout(r, 500));\n      await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] });\n    } finally {\n      setIsRefreshing(false);\n    }\n  };\n\n  // Compute current slot assignments\n  const slotAssignments = filamentReqs.map((req) => {\n    const slotId = req.slot_id || 0;\n    const currentMapping = printerResult.config.manualMappings[slotId];\n\n    let loaded: LoadedFilament | undefined;\n    let isManual = false;\n\n    if (currentMapping !== undefined) {\n      loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);\n      isManual = true;\n    } else {\n      const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));\n      const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);\n      loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds, cachedSettings?.prefer_lowest_filament) as LoadedFilament | undefined;\n    }\n\n    // Determine status\n    let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';\n    if (loaded) {\n      const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();\n      const colorMatch =\n        normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||\n        colorsAreSimilar(loaded.color, req.color);\n\n      if (typeMatch && colorMatch) {\n        status = 'match';\n      } else if (typeMatch) {\n        status = 'type_only';\n      }\n    }\n\n    return { req, loaded, status, isManual };\n  });\n\n  return (\n    <div className=\"mt-2 bg-bambu-dark rounded-lg p-3 space-y-2\">\n      <div className=\"flex items-center justify-between mb-2\">\n        <span className=\"text-xs text-bambu-gray\">Custom slot mapping</span>\n        <button\n          type=\"button\"\n          onClick={handleRefresh}\n          className=\"flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white\"\n          disabled={isRefreshing}\n        >\n          <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />\n          <span>Re-read</span>\n        </button>\n      </div>\n\n      {slotAssignments.map(({ req, loaded, status, isManual }, idx) => (\n        <div\n          key={idx}\n          className=\"grid items-center gap-2 text-xs\"\n          style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}\n        >\n          <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>\n            <Circle className=\"w-3 h-3\" fill={req.color} stroke={req.color} />\n          </span>\n          <span className=\"text-white truncate\">\n            {req.type} <span className=\"text-bambu-gray\">({req.used_grams}g)</span>\n          </span>\n          <span className=\"text-bambu-gray\">→</span>\n          <select\n            value={loaded?.globalTrayId ?? ''}\n            onChange={(e) => handleSlotChange(req.slot_id || 0, e.target.value)}\n            className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${\n              status === 'match'\n                ? 'border-bambu-green/50 text-bambu-green'\n                : status === 'type_only'\n                ? 'border-yellow-400/50 text-yellow-400'\n                : 'border-orange-400/50 text-orange-400'\n            } ${isManual ? 'ring-1 ring-blue-400/50' : ''}`}\n            title={isManual ? 'Manually selected' : 'Auto-matched'}\n          >\n            <option value=\"\" className=\"bg-bambu-dark text-bambu-gray\">\n              -- Select slot --\n            </option>\n            {filterFilamentsByNozzle(printerResult.loadedFilaments, req.nozzle_id)\n              .map((f) => (\n              <option key={f.globalTrayId} value={f.globalTrayId} className=\"bg-bambu-dark text-white\">\n                {f.label}: {f.traySubBrands || f.type} ({f.colorName})\n              </option>\n            ))}\n          </select>\n          {status === 'match' ? (\n            <Check className=\"w-3 h-3 text-bambu-green\" />\n          ) : status === 'type_only' ? (\n            <span title=\"Same type, different color\">\n              <AlertTriangle className=\"w-3 h-3 text-yellow-400\" />\n            </span>\n          ) : (\n            <span title=\"Filament type not loaded\">\n              <AlertTriangle className=\"w-3 h-3 text-orange-400\" />\n            </span>\n          )}\n        </div>\n      ))}\n    </div>\n  );\n}\n\n/**\n * Printer selection component with grid-based UI.\n * Supports single or multi-select modes.\n * When multiple printers are selected, shows per-printer mapping overrides.\n */\nexport function PrinterSelector({\n  printers,\n  selectedPrinterIds,\n  onMultiSelect,\n  isLoading = false,\n  allowMultiple = false,\n  showInactive = false,\n  disableBusy = false,\n  printerMappingResults,\n  filamentReqs,\n  onAutoConfigurePrinter,\n  onUpdatePrinterConfig,\n  assignmentMode = 'printer',\n  onAssignmentModeChange,\n  targetModel,\n  onTargetModelChange,\n  targetLocation,\n  onTargetLocationChange,\n  slicedForModel,\n}: PrinterSelectorWithMappingProps) {\n  // State for showing all printers vs only matching model\n  const [showAllPrinters, setShowAllPrinters] = useState(false);\n\n  // Filter printers based on showInactive flag\n  const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);\n\n  // Fetch printer statuses to determine busy/idle state\n  const statusQueries = useQueries({\n    queries: activePrinters.map((printer) => ({\n      queryKey: ['printerStatus', printer.id],\n      queryFn: () => api.getPrinterStatus(printer.id),\n      staleTime: 5000,\n    })),\n  });\n\n  // Build a map of printer ID -> status for quick lookup\n  const printerStatusMap = useMemo(() => {\n    const map = new Map<number, PrinterStatus>();\n    activePrinters.forEach((printer, idx) => {\n      const query = statusQueries[idx];\n      if (query?.data) {\n        map.set(printer.id, query.data);\n      }\n    });\n    return map;\n  }, [activePrinters, statusQueries]);\n\n  const isPrinterBusy = (printerId: number): boolean => {\n    const status = printerStatusMap.get(printerId);\n    if (!status) return false; // Unknown state — don't block\n    if (!status.connected) return true;\n    return !AVAILABLE_STATES.has(status.state ?? '');\n  };\n\n  const getPrinterStateLabel = (printerId: number): string | null => {\n    const status = printerStatusMap.get(printerId);\n    if (!status) return null;\n    if (!status.connected) return 'Offline';\n    const state = status.state;\n    if (!state) return null;\n    if (state === 'RUNNING') return status.stg_cur_name || 'Printing';\n    if (state === 'PREPARE') return 'Preparing';\n    if (state === 'PAUSE') return 'Paused';\n    if (state === 'IDLE') return 'Idle';\n    if (state === 'FINISH') return 'Finished';\n    if (state === 'FAILED') return 'Failed';\n    return state;\n  };\n\n  // Filter by sliced model (only in printer mode, when slicedForModel is set)\n  const displayPrinters = useMemo(() => {\n    if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {\n      return activePrinters;\n    }\n    // Filter to only show printers matching the sliced model\n    const matching = activePrinters.filter((p) => p.model === slicedForModel);\n    // If no matching printers, show all\n    return matching.length > 0 ? matching : activePrinters;\n  }, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);\n\n  // Check if there are hidden printers due to model filtering\n  const hiddenPrinterCount = activePrinters.length - displayPrinters.length;\n\n  // Get unique models from available printers (for model-based assignment)\n  const uniqueModels = useMemo(() => {\n    const models = activePrinters\n      .map(p => p.model)\n      .filter((m): m is string => Boolean(m));\n    return [...new Set(models)].sort();\n  }, [activePrinters]);\n\n  // Get unique locations for the selected target model (for location filtering)\n  const uniqueLocations = useMemo(() => {\n    if (!targetModel) return [];\n    const locations = activePrinters\n      .filter(p => p.model === targetModel && p.location)\n      .map(p => p.location)\n      .filter((l): l is string => Boolean(l));\n    return [...new Set(locations)].sort();\n  }, [activePrinters, targetModel]);\n\n  // Check if model-based assignment is available (need callbacks and multiple printers of same model)\n  const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;\n\n  const showMappingOptions = allowMultiple &&\n    selectedPrinterIds.length > 1 &&\n    printerMappingResults &&\n    filamentReqs?.filaments &&\n    filamentReqs.filaments.length > 0 &&\n    onAutoConfigurePrinter &&\n    onUpdatePrinterConfig;\n\n  if (isLoading) {\n    return (\n      <div className=\"flex justify-center py-8\">\n        <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  if (displayPrinters.length === 0) {\n    return (\n      <div className=\"flex items-center gap-2 text-red-400 text-sm mb-4\">\n        <AlertCircle className=\"w-4 h-4\" />\n        No {showInactive ? '' : 'active '}printers available\n      </div>\n    );\n  }\n\n  const handlePrinterClick = (printerId: number) => {\n    if (disableBusy && isPrinterBusy(printerId)) return;\n\n    if (allowMultiple) {\n      if (selectedPrinterIds.includes(printerId)) {\n        onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));\n      } else {\n        onMultiSelect([...selectedPrinterIds, printerId]);\n      }\n    } else {\n      onMultiSelect([printerId]);\n    }\n  };\n\n  const handleSelectAll = () => {\n    const selectable = disableBusy\n      ? displayPrinters.filter((p) => !isPrinterBusy(p.id))\n      : displayPrinters;\n    onMultiSelect(selectable.map((p) => p.id));\n  };\n\n  const handleDeselectAll = () => {\n    onMultiSelect([]);\n  };\n\n  const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => {\n    e.stopPropagation();\n    if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return;\n\n    if (enabled) {\n      onAutoConfigurePrinter(printerId);\n    } else {\n      onUpdatePrinterConfig(printerId, {\n        useDefault: true,\n        manualMappings: {},\n        autoConfigured: false,\n      });\n    }\n  };\n\n  const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);\n  const selectedCount = selectedPrinterIds.length;\n\n  const getPrinterMappingResult = (printerId: number) => {\n    return printerMappingResults?.find((r) => r.printerId === printerId);\n  };\n\n  return (\n    <div className=\"space-y-2 mb-6\">\n      {/* Assignment mode toggle (model vs specific printer) */}\n      {modelAssignmentAvailable && (\n        <div className=\"flex gap-2 mb-4\">\n          <button\n            type=\"button\"\n            onClick={() => {\n              onAssignmentModeChange!('printer');\n              onTargetModelChange!(null);\n            }}\n            className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${\n              assignmentMode === 'printer'\n                ? 'border-bambu-green bg-bambu-green/10 text-white'\n                : 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'\n            }`}\n          >\n            <PrinterIcon className=\"w-4 h-4\" />\n            <span className=\"text-sm\">Specific Printer</span>\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => {\n              onAssignmentModeChange!('model');\n              onMultiSelect([]);\n              // Pre-select the sliced-for model if available, otherwise first model\n              const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)\n                ? slicedForModel\n                : uniqueModels[0];\n              onTargetModelChange!(defaultModel);\n            }}\n            className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${\n              assignmentMode === 'model'\n                ? 'border-bambu-green bg-bambu-green/10 text-white'\n                : 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'\n            }`}\n          >\n            <Users className=\"w-4 h-4\" />\n            <span className=\"text-sm\">Any {slicedForModel || 'Model'}</span>\n          </button>\n        </div>\n      )}\n\n      {/* Model selection and location filter (when in model mode) */}\n      {assignmentMode === 'model' && modelAssignmentAvailable && (\n        <div className=\"space-y-3 mb-4\">\n          {/* Model selector — only show when sliced model is unknown */}\n          {!slicedForModel && (\n            <div>\n              <label className=\"block text-xs text-bambu-gray mb-1\">Target Model</label>\n              <select\n                value={targetModel || ''}\n                onChange={(e) => {\n                  onTargetModelChange!(e.target.value || null);\n                  // Clear location when model changes\n                  if (onTargetLocationChange) {\n                    onTargetLocationChange(null);\n                  }\n                }}\n                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 text-sm\"\n              >\n                <option value=\"\">Select a model...</option>\n                {uniqueModels.map((model) => (\n                  <option key={model} value={model}>\n                    {model}\n                  </option>\n                ))}\n              </select>\n            </div>\n          )}\n\n          {/* Location filter (only show when target model is selected and locations exist) */}\n          {targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (\n            <div>\n              <label className=\"block text-xs text-bambu-gray mb-1\">Location Filter (optional)</label>\n              <select\n                value={targetLocation || ''}\n                onChange={(e) => onTargetLocationChange(e.target.value || null)}\n                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 text-sm\"\n              >\n                <option value=\"\">Any location</option>\n                {uniqueLocations.map((location) => (\n                  <option key={location} value={location}>\n                    {location}\n                  </option>\n                ))}\n              </select>\n            </div>\n          )}\n\n          {/* Info text */}\n          {targetModel && (\n            <p className=\"text-xs text-bambu-gray\">\n              Scheduler will assign to first available idle {targetModel} printer\n              {targetLocation ? ` in ${targetLocation}` : ''}\n            </p>\n          )}\n        </div>\n      )}\n\n      {/* Multi-select header (only in printer mode) */}\n      {assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (\n        <div className=\"flex items-center justify-between text-xs text-bambu-gray mb-2\">\n          <span>\n            {selectedCount === 0\n              ? 'Select printers'\n              : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}\n          </span>\n          <div className=\"flex gap-2\">\n            {selectedCount < displayPrinters.length && (\n              <button\n                type=\"button\"\n                onClick={handleSelectAll}\n                className=\"text-bambu-green hover:text-bambu-green/80 transition-colors\"\n              >\n                Select all\n              </button>\n            )}\n            {selectedCount > 0 && (\n              <button\n                type=\"button\"\n                onClick={handleDeselectAll}\n                className=\"text-bambu-gray hover:text-white transition-colors\"\n              >\n                Clear\n              </button>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* Printer list (only in printer mode) */}\n      {assignmentMode === 'printer' && displayPrinters.map((printer) => {\n        const selected = isSelected(printer.id);\n        const mappingResult = getPrinterMappingResult(printer.id);\n        const hasOverride = mappingResult && !mappingResult.config.useDefault;\n        const busy = isPrinterBusy(printer.id);\n        const disabled = disableBusy && busy;\n        const stateLabel = getPrinterStateLabel(printer.id);\n\n        return (\n          <div key={printer.id}>\n            {/* Printer selection button */}\n            <button\n              type=\"button\"\n              onClick={() => handlePrinterClick(printer.id)}\n              disabled={disabled}\n              className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${\n                disabled\n                  ? 'border-bambu-dark-tertiary bg-bambu-dark opacity-50 cursor-not-allowed'\n                  : selected\n                  ? 'border-bambu-green bg-bambu-green/10'\n                  : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'\n              } ${!printer.is_active ? 'opacity-60' : ''}`}\n            >\n              <div\n                className={`p-2 rounded-lg ${\n                  disabled ? 'bg-bambu-dark-tertiary' : selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'\n                }`}\n              >\n                <PrinterIcon\n                  className={`w-5 h-5 ${\n                    disabled ? 'text-bambu-gray/50' : selected ? 'text-bambu-green' : 'text-bambu-gray'\n                  }`}\n                />\n              </div>\n              <div className=\"text-left flex-1\">\n                <p className={`font-medium ${disabled ? 'text-bambu-gray' : 'text-white'}`}>\n                  {printer.name}\n                  {!printer.is_active && <span className=\"text-bambu-gray text-xs ml-2\">(inactive)</span>}\n                </p>\n                <p className=\"text-xs text-bambu-gray\">\n                  {printer.model || 'Unknown model'} • {printer.ip_address}\n                </p>\n              </div>\n              {stateLabel && (\n                <span className={`text-xs px-2 py-0.5 rounded-full ${\n                  busy\n                    ? 'bg-yellow-500/20 text-yellow-400'\n                    : 'bg-bambu-green/20 text-bambu-green'\n                }`}>\n                  {stateLabel}\n                </span>\n              )}\n              {allowMultiple && (\n                <div\n                  className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${\n                    disabled\n                      ? 'border-bambu-gray/30'\n                      : selected\n                      ? 'bg-bambu-green border-bambu-green'\n                      : 'border-bambu-gray/50'\n                  }`}\n                >\n                  {selected && <Check className=\"w-3 h-3 text-white\" />}\n                </div>\n              )}\n            </button>\n\n            {/* Per-printer override checkbox + mapping (only when selected and multi-printer) */}\n            {selected && showMappingOptions && mappingResult && (\n              <div className=\"ml-4 mt-2 mb-3\">\n                {/* Override checkbox row */}\n                <div className=\"flex items-center gap-2\">\n                  <label\n                    className=\"flex items-center gap-2 cursor-pointer\"\n                    onClick={(e) => e.stopPropagation()}\n                  >\n                    <input\n                      type=\"checkbox\"\n                      checked={hasOverride}\n                      onChange={(e) => handleOverrideToggle(printer.id, e.target.checked, e as unknown as React.MouseEvent)}\n                      className=\"w-3.5 h-3.5 rounded border-bambu-gray/30 bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0\"\n                    />\n                    <span className=\"text-xs text-bambu-gray\">Custom mapping</span>\n                  </label>\n\n                  {/* Match status indicator */}\n                  <span className={`text-xs ml-2 ${\n                    mappingResult.matchStatus === 'full'\n                      ? 'text-bambu-green'\n                      : mappingResult.matchStatus === 'partial'\n                      ? 'text-yellow-400'\n                      : 'text-orange-400'\n                  }`}>\n                    ({mappingResult.exactMatches}/{mappingResult.totalSlots} matched)\n                  </span>\n\n                  {/* Loading indicator */}\n                  {mappingResult.isLoading && (\n                    <RefreshCw className=\"w-3 h-3 text-bambu-gray animate-spin\" />\n                  )}\n\n                  {/* Auto-configure button (when override is enabled) */}\n                  {hasOverride && (\n                    <button\n                      type=\"button\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        onAutoConfigurePrinter!(printer.id);\n                      }}\n                      className=\"ml-auto flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white\"\n                    >\n                      <Wand2 className=\"w-3 h-3\" />\n                      Auto\n                    </button>\n                  )}\n                </div>\n\n                {/* Inline mapping editor (shown when override is checked) */}\n                {hasOverride && (\n                  <InlineMappingEditor\n                    printerResult={mappingResult}\n                    filamentReqs={filamentReqs!.filaments}\n                    onUpdateConfig={(config) => onUpdatePrinterConfig!(printer.id, config)}\n                  />\n                )}\n              </div>\n            )}\n          </div>\n        );\n      })}\n\n      {/* Show hidden printers toggle */}\n      {assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (\n        <button\n          type=\"button\"\n          onClick={() => setShowAllPrinters(true)}\n          className=\"text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1\"\n        >\n          <AlertTriangle className=\"w-3 h-3 text-yellow-400\" />\n          {hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) —\n          <span className=\"underline\">show all</span>\n        </button>\n      )}\n\n      {/* Show matching only toggle */}\n      {assignmentMode === 'printer' && showAllPrinters && slicedForModel && (\n        <button\n          type=\"button\"\n          onClick={() => setShowAllPrinters(false)}\n          className=\"text-xs text-bambu-gray hover:text-white transition-colors mt-2\"\n        >\n          <span className=\"underline\">Show only {slicedForModel} printers</span>\n        </button>\n      )}\n\n      {/* Warning when no printer selected (only in printer mode) */}\n      {assignmentMode === 'printer' && selectedCount === 0 && (\n        <p className=\"text-xs text-orange-400 mt-1 flex items-center gap-1\">\n          <AlertCircle className=\"w-3 h-3\" />\n          Select at least one printer\n        </p>\n      )}\n\n      {/* Warning when no model selected (only in model mode) */}\n      {assignmentMode === 'model' && !targetModel && (\n        <p className=\"text-xs text-orange-400 mt-1 flex items-center gap-1\">\n          <AlertCircle className=\"w-3 h-3\" />\n          Select a target printer model\n        </p>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrintModal/ScheduleOptions.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Calendar, Clock, Hand, Power, Layers, Code } from 'lucide-react';\nimport type { ScheduleOptionsProps, ScheduleType } from './types';\nimport {\n  formatDateInput,\n  formatTimeInput,\n  parseDateInput,\n  parseTimeInput,\n  getDatePlaceholder,\n  getTimePlaceholder,\n  toDateTimeLocalValue,\n  type DateFormat,\n  type TimeFormat,\n} from '../../utils/date';\n\n/**\n * Schedule options component for queue items.\n * Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,\n * and options for require previous success and auto power off.\n */\nexport function ScheduleOptionsPanel({\n  options,\n  onChange,\n  dateFormat = 'system',\n  timeFormat = 'system',\n  canControlPrinter = true,\n  showStagger = false,\n  printerCount = 0,\n  hasGcodeSnippets = false,\n}: ScheduleOptionsProps) {\n  const { t } = useTranslation();\n  const [dateValue, setDateValue] = useState('');\n  const [timeValue, setTimeValue] = useState('');\n  const [isDateValid, setIsDateValid] = useState(true);\n  const [isTimeValid, setIsTimeValid] = useState(true);\n  const hiddenInputRef = useRef<HTMLInputElement>(null);\n  const isInitializedRef = useRef(false);\n\n  // Initialize or sync from options.scheduledTime\n  useEffect(() => {\n    if (options.scheduleType !== 'scheduled') {\n      isInitializedRef.current = false;\n      return;\n    }\n\n    // Initialize with default time (now + 1 hour) or from existing value\n    if (!isInitializedRef.current) {\n      isInitializedRef.current = true;\n      let date: Date;\n\n      if (options.scheduledTime) {\n        date = new Date(options.scheduledTime);\n        if (isNaN(date.getTime())) {\n          date = new Date();\n          date.setHours(date.getHours() + 1, 0, 0, 0);\n        }\n      } else {\n        date = new Date();\n        date.setHours(date.getHours() + 1, 0, 0, 0);\n        // Set initial value\n        onChange({ ...options, scheduledTime: toDateTimeLocalValue(date) });\n      }\n\n      setDateValue(formatDateInput(date, dateFormat as DateFormat));\n      setTimeValue(formatTimeInput(date, timeFormat as TimeFormat));\n      setIsDateValid(true);\n      setIsTimeValid(true);\n    }\n  }, [options.scheduleType, options.scheduledTime, dateFormat, timeFormat, onChange, options]);\n\n  const handleScheduleTypeChange = (scheduleType: ScheduleType) => {\n    onChange({ ...options, scheduleType });\n  };\n\n  const updateScheduledTime = (newDateValue: string, newTimeValue: string) => {\n    const parsedDate = parseDateInput(newDateValue, dateFormat as DateFormat);\n    const parsedTime = parseTimeInput(newTimeValue);\n\n    setIsDateValid(!!parsedDate);\n    setIsTimeValid(!!parsedTime);\n\n    if (parsedDate && parsedTime) {\n      parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0);\n      const now = new Date();\n      if (parsedDate > now) {\n        onChange({ ...options, scheduledTime: toDateTimeLocalValue(parsedDate) });\n      }\n    }\n  };\n\n  const handleDateChange = (value: string) => {\n    setDateValue(value);\n    updateScheduledTime(value, timeValue);\n  };\n\n  const handleTimeChange = (value: string) => {\n    setTimeValue(value);\n    updateScheduledTime(dateValue, value);\n  };\n\n  // Handle calendar picker selection\n  const handleCalendarChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const value = e.target.value;\n    if (value) {\n      const date = new Date(value);\n      if (!isNaN(date.getTime())) {\n        setDateValue(formatDateInput(date, dateFormat as DateFormat));\n        setTimeValue(formatTimeInput(date, timeFormat as TimeFormat));\n        setIsDateValid(true);\n        setIsTimeValid(true);\n        onChange({ ...options, scheduledTime: value });\n      }\n    }\n  };\n\n  const openCalendar = () => {\n    hiddenInputRef.current?.showPicker();\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Schedule type */}\n      <div>\n        <label className=\"block text-sm text-bambu-gray mb-2\">When to print</label>\n        <div className=\"flex gap-2\">\n          <button\n            type=\"button\"\n            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${\n              options.scheduleType === 'asap'\n                ? 'bg-bambu-green border-bambu-green text-white'\n                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n            }`}\n            onClick={() => handleScheduleTypeChange('asap')}\n          >\n            <Clock className=\"w-4 h-4\" />\n            ASAP\n          </button>\n          <button\n            type=\"button\"\n            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${\n              options.scheduleType === 'scheduled'\n                ? 'bg-bambu-green border-bambu-green text-white'\n                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n            }`}\n            onClick={() => handleScheduleTypeChange('scheduled')}\n          >\n            <Calendar className=\"w-4 h-4\" />\n            Scheduled\n          </button>\n          <button\n            type=\"button\"\n            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${\n              options.scheduleType === 'manual'\n                ? 'bg-bambu-green border-bambu-green text-white'\n                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n            }`}\n            onClick={() => handleScheduleTypeChange('manual')}\n          >\n            <Hand className=\"w-4 h-4\" />\n            Queue Only\n          </button>\n        </div>\n      </div>\n\n      {/* Scheduled time input */}\n      {options.scheduleType === 'scheduled' && (\n        <div>\n          <label className=\"block text-sm text-bambu-gray mb-1\">Date & Time</label>\n          <div className=\"flex gap-2\">\n            {/* Date input */}\n            <div className=\"flex-1 relative\">\n              <input\n                type=\"text\"\n                className={`w-full px-3 py-2 pr-10 bg-bambu-dark border rounded-lg text-white focus:outline-none ${\n                  isDateValid\n                    ? 'border-bambu-dark-tertiary focus:border-bambu-green'\n                    : 'border-red-500'\n                }`}\n                value={dateValue}\n                onChange={(e) => handleDateChange(e.target.value)}\n                placeholder={getDatePlaceholder(dateFormat as DateFormat)}\n              />\n              <button\n                type=\"button\"\n                onClick={openCalendar}\n                className=\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\"\n                title=\"Open calendar\"\n              >\n                <Calendar className=\"w-4 h-4\" />\n              </button>\n              {/* Hidden datetime-local anchored here so the native picker opens near the date field */}\n              <input\n                ref={hiddenInputRef}\n                type=\"datetime-local\"\n                className=\"absolute top-0 left-0 w-0 h-0 opacity-0 pointer-events-none\"\n                value={options.scheduledTime}\n                onChange={handleCalendarChange}\n                tabIndex={-1}\n              />\n            </div>\n            {/* Time input */}\n            <div className=\"w-32\">\n              <input\n                type=\"text\"\n                className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:outline-none ${\n                  isTimeValid\n                    ? 'border-bambu-dark-tertiary focus:border-bambu-green'\n                    : 'border-red-500'\n                }`}\n                value={timeValue}\n                onChange={(e) => handleTimeChange(e.target.value)}\n                placeholder={getTimePlaceholder(timeFormat as TimeFormat)}\n              />\n            </div>\n          </div>\n          {(!isDateValid || !isTimeValid) && (\n            <p className=\"mt-1 text-xs text-red-400\">\n              Please enter a valid date and time\n            </p>\n          )}\n        </div>\n      )}\n\n      {/* Require previous success */}\n      <div className=\"flex items-center gap-2\">\n        <input\n          type=\"checkbox\"\n          id=\"requirePrevious\"\n          checked={options.requirePreviousSuccess}\n          onChange={(e) => onChange({ ...options, requirePreviousSuccess: e.target.checked })}\n          className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n        />\n        <label htmlFor=\"requirePrevious\" className=\"text-sm text-bambu-gray\">\n          Only start if previous print succeeded\n        </label>\n      </div>\n\n      {/* Auto power off */}\n      <div className=\"flex items-center gap-2\">\n        <input\n          type=\"checkbox\"\n          id=\"autoOffAfter\"\n          checked={options.autoOffAfter}\n          onChange={(e) => onChange({ ...options, autoOffAfter: e.target.checked })}\n          disabled={!canControlPrinter}\n          className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green disabled:opacity-50\"\n        />\n        <label htmlFor=\"autoOffAfter\" className={`text-sm flex items-center gap-1 ${canControlPrinter ? 'text-bambu-gray' : 'text-bambu-gray/50'}`}>\n          <Power className=\"w-3.5 h-3.5\" />\n          Power off printer when done\n        </label>\n      </div>\n\n      {/* G-code injection */}\n      {hasGcodeSnippets && (\n        <div className=\"flex items-center gap-2\">\n          <input\n            type=\"checkbox\"\n            id=\"gcodeInjection\"\n            checked={options.gcodeInjection}\n            onChange={(e) => onChange({ ...options, gcodeInjection: e.target.checked })}\n            className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n          />\n          <label htmlFor=\"gcodeInjection\" className=\"text-sm flex items-center gap-1 text-bambu-gray\">\n            <Code className=\"w-3.5 h-3.5\" />\n            {t('printModal.gcodeInjection', 'Inject auto-print G-code')}\n          </label>\n        </div>\n      )}\n\n      {/* Stagger start */}\n      {showStagger && options.scheduleType !== 'manual' && (\n        <div className=\"space-y-3\">\n          <div className=\"flex items-center gap-2\">\n            <input\n              type=\"checkbox\"\n              id=\"staggerEnabled\"\n              checked={options.staggerEnabled}\n              onChange={(e) => onChange({ ...options, staggerEnabled: e.target.checked })}\n              className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n            />\n            <label htmlFor=\"staggerEnabled\" className=\"text-sm flex items-center gap-1 text-bambu-gray\">\n              <Layers className=\"w-3.5 h-3.5\" />\n              {t('printModal.staggerPrinterStarts', 'Stagger printer starts')}\n            </label>\n          </div>\n\n          {options.staggerEnabled && (\n            <div className=\"ml-6 space-y-3\">\n              <div className=\"flex gap-3\">\n                <div className=\"flex-1\">\n                  <label className=\"block text-xs text-bambu-gray mb-1\">{t('printModal.staggerGroupSize', 'Group size')}</label>\n                  <input\n                    type=\"number\"\n                    min={1}\n                    max={printerCount}\n                    value={options.staggerGroupSize}\n                    onChange={(e) => onChange({ ...options, staggerGroupSize: Math.max(1, parseInt(e.target.value) || 1) })}\n                    className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n                  />\n                </div>\n                <div className=\"flex-1\">\n                  <label className=\"block text-xs text-bambu-gray mb-1\">{t('printModal.staggerInterval', 'Interval (min)')}</label>\n                  <input\n                    type=\"number\"\n                    min={1}\n                    max={60}\n                    value={options.staggerIntervalMinutes}\n                    onChange={(e) => onChange({ ...options, staggerIntervalMinutes: Math.max(1, parseInt(e.target.value) || 1) })}\n                    className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n                  />\n                </div>\n              </div>\n              {printerCount > 0 && (() => {\n                const groupCount = Math.ceil(printerCount / options.staggerGroupSize);\n                const lastGroupSize = printerCount % options.staggerGroupSize;\n                const totalMinutes = (groupCount - 1) * options.staggerIntervalMinutes;\n                return (\n                  <p className=\"text-xs text-bambu-gray\">\n                    {t('printModal.staggerPreview', '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min', {\n                      printers: printerCount,\n                      groups: groupCount,\n                      size: options.staggerGroupSize,\n                      interval: options.staggerIntervalMinutes,\n                    })}\n                    {lastGroupSize !== 0 && options.staggerGroupSize < printerCount\n                      ? ` (${t('printModal.staggerLastGroup', 'last group: {{count}}', { count: lastGroupSize })})`\n                      : ''}\n                    {groupCount > 1\n                      ? ` (${t('printModal.staggerTotal', 'total: {{minutes}} min', { minutes: totalMinutes })})`\n                      : ''}\n                  </p>\n                );\n              })()}\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Help text */}\n      <p className=\"text-xs text-bambu-gray\">\n        {options.scheduleType === 'asap'\n          ? 'Print will start as soon as the printer is idle.'\n          : options.scheduleType === 'scheduled'\n          ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'\n          : \"Print will be staged but won't start automatically. Use the Start button to release it to the queue.\"}\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrintModal/index.tsx",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';\nimport { api } from '../../api/client';\nimport { useAuth } from '../../contexts/AuthContext';\nimport { Card, CardContent } from '../Card';\nimport { Button } from '../Button';\nimport { ConfirmModal } from '../ConfirmModal';\nimport { useToast } from '../../contexts/ToastContext';\nimport { buildLoadedFilaments, useFilamentMapping } from '../../hooks/useFilamentMapping';\nimport { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';\nimport { getColorName } from '../../utils/colors';\nimport { getCurrencySymbol } from '../../utils/currency';\nimport { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';\nimport { getGlobalTrayId, isPlaceholderDate } from '../../utils/amsHelpers';\nimport { FilamentMapping } from './FilamentMapping';\nimport { FilamentOverride } from './FilamentOverride';\nimport { PlateSelector } from './PlateSelector';\nimport { PrinterSelector } from './PrinterSelector';\nimport { PrintOptionsPanel } from './PrintOptions';\nimport { ScheduleOptionsPanel } from './ScheduleOptions';\nimport type {\n  AssignmentMode,\n  PrintModalProps,\n  PrintOptions,\n  ScheduleOptions,\n  ScheduleType,\n} from './types';\nimport { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';\n\n/**\n * Unified PrintModal component that handles three modes:\n * - 'reprint': Immediate print from archive or library file (supports multi-printer)\n * - 'add-to-queue': Schedule print to queue from archive or library file (supports multi-printer)\n * - 'edit-queue-item': Edit existing queue item (supports multi-printer)\n *\n * Both archiveId and libraryFileId are supported. Library files can be printed immediately\n * or added to queue (archive is created at print start time, not when queued).\n */\nexport function PrintModal({\n  mode,\n  archiveId,\n  libraryFileId,\n  archiveName,\n  queueItem,\n  initialSelectedPrinterIds,\n  onClose,\n  onSuccess,\n  projectId,\n  cleanupLibraryAfterDispatch,\n}: PrintModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n\n  // Determine if we're printing a library file\n  const isLibraryFile = !!libraryFileId && !archiveId;\n\n  type FilamentWarningItem = {\n    printerName: string;\n    slotLabel: string;\n    requiredGrams: number;\n    remainingGrams: number;\n  };\n\n  // Multiple printer selection (used for all modes now)\n  const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {\n    // Initialize with the queue item's printer if editing\n    if (mode === 'edit-queue-item' && queueItem?.printer_id) {\n      return [queueItem.printer_id];\n    }\n    if (initialSelectedPrinterIds?.length) {\n      return initialSelectedPrinterIds;\n    }\n    return [];\n  });\n\n  // Multi-select plates: in add-to-queue mode users can pick a subset of plates\n  const [selectedPlates, setSelectedPlates] = useState<Set<number>>(() => {\n    if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {\n      return new Set([queueItem.plate_id]);\n    }\n    return new Set();\n  });\n\n  // Derived single-plate value for filament queries and single-select contexts\n  const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;\n\n  // Quantity — number of copies (creates a batch if > 1)\n  const [quantity, setQuantity] = useState(1);\n\n  const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {\n    if (mode === 'edit-queue-item' && queueItem) {\n      return {\n        bed_levelling: queueItem.bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,\n        flow_cali: queueItem.flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,\n        vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,\n        layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,\n        timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,\n      };\n    }\n    return DEFAULT_PRINT_OPTIONS;\n  });\n\n  const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {\n    if (mode === 'edit-queue-item' && queueItem) {\n      let scheduleType: ScheduleType = 'asap';\n      if (queueItem.manual_start) {\n        scheduleType = 'manual';\n      } else if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {\n        scheduleType = 'scheduled';\n      }\n\n      let scheduledTime = '';\n      if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {\n        const date = parseUTCDate(queueItem.scheduled_time) ?? new Date();\n        // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input\n        scheduledTime = toDateTimeLocalValue(date);\n      }\n\n      return {\n        scheduleType,\n        scheduledTime,\n        requirePreviousSuccess: queueItem.require_previous_success,\n        autoOffAfter: queueItem.auto_off_after,\n        gcodeInjection: queueItem.gcode_injection ?? false,\n        staggerEnabled: false,\n        staggerGroupSize: DEFAULT_SCHEDULE_OPTIONS.staggerGroupSize,\n        staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,\n      };\n    }\n    return DEFAULT_SCHEDULE_OPTIONS;\n  });\n\n  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)\n  const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {\n    if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {\n      const mappings: Record<number, number> = {};\n      queueItem.ams_mapping.forEach((globalTrayId, idx) => {\n        if (globalTrayId !== -1) {\n          mappings[idx + 1] = globalTrayId;\n        }\n      });\n      return mappings;\n    }\n    return {};\n  });\n\n  // Per-printer override configs (for multi-printer selection)\n  const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});\n\n  // Assignment mode: 'printer' (specific) or 'model' (any of model)\n  const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {\n    // Initialize from queue item if editing with target_model\n    if (mode === 'edit-queue-item' && queueItem?.target_model) {\n      return 'model';\n    }\n    return 'printer';\n  });\n\n  // Target model for model-based assignment\n  const [targetModel, setTargetModel] = useState<string | null>(() => {\n    if (mode === 'edit-queue-item' && queueItem?.target_model) {\n      return queueItem.target_model;\n    }\n    return null;\n  });\n\n  // Target location for model-based assignment (optional filter)\n  const [targetLocation, setTargetLocation] = useState<string | null>(() => {\n    if (mode === 'edit-queue-item' && queueItem?.target_location) {\n      return queueItem.target_location;\n    }\n    return null;\n  });\n\n  // Filament overrides for model-based assignment: slot_id -> {type, color}\n  const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {\n    if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {\n      const overrides: Record<number, { type: string; color: string }> = {};\n      for (const o of queueItem.filament_overrides) {\n        overrides[o.slot_id] = { type: o.type, color: o.color };\n      }\n      return overrides;\n    }\n    return {};\n  });\n\n  // Per-slot force color match flags. Default is false (opt-in).\n  const [forceColorMatch, setForceColorMatch] = useState<Record<number, boolean>>(() => {\n    if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {\n      const flags: Record<number, boolean> = {};\n      for (const o of queueItem.filament_overrides) {\n        flags[o.slot_id] = o.force_color_match === true;\n      }\n      return flags;\n    }\n    return {};\n  });\n\n  // Track initial values for clearing mappings on change (edit mode only)\n  const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));\n  const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));\n\n  // Submission state for multi-printer\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });\n\n  const [filamentWarningItems, setFilamentWarningItems] = useState<FilamentWarningItem[] | null>(null);\n\n  // Track which printers have had the \"Expand custom mapping by default\" setting applied\n  // This ensures the setting only affects initial state, not preventing unchecking\n  const [initialExpandApplied, setInitialExpandApplied] = useState<Set<number>>(new Set());\n\n  // Printer counts and effective printer for filament mapping\n  const effectivePrinterCount = selectedPrinters.length;\n  // For filament mapping, use first selected printer (mapping applies to all)\n  const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;\n\n  // Queries\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  // Sync print option defaults from settings once available\n  const printDefaultsApplied = useRef(false);\n  useEffect(() => {\n    if (!settings || printDefaultsApplied.current || mode === 'edit-queue-item') return;\n    printDefaultsApplied.current = true;\n    setPrintOptions({\n      bed_levelling: settings.default_bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,\n      flow_cali: settings.default_flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,\n      vibration_cali: settings.default_vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,\n      layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,\n      timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,\n    });\n  }, [settings, mode]);\n\n  // Sync stagger defaults from settings once available\n  const staggerDefaultsApplied = useRef(false);\n  useEffect(() => {\n    if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item') return;\n    staggerDefaultsApplied.current = true;\n    setScheduleOptions((prev) => ({\n      ...prev,\n      staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize,\n      staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes,\n    }));\n  }, [settings, mode]);\n\n  const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');\n  const defaultCostPerKg = settings?.default_filament_cost ?? 0;\n\n  const { data: printers, isLoading: loadingPrinters } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const { data: spoolAssignments } = useQuery({\n    queryKey: ['spool-assignments'],\n    queryFn: () => api.getAssignments(),\n    staleTime: 30 * 1000,\n    enabled: ((mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer') || (isLibraryFile && mode === 'reprint'),\n  });\n\n  // Fetch archive details to get sliced_for_model\n  const { data: archiveDetails } = useQuery({\n    queryKey: ['archive', archiveId],\n    queryFn: () => api.getArchive(archiveId!),\n    enabled: !!archiveId && !isLibraryFile,\n  });\n\n  // Fetch library file details to get sliced_for_model\n  const { data: libraryFileDetails } = useQuery({\n    queryKey: ['library-file', libraryFileId],\n    queryFn: () => api.getLibraryFile(libraryFileId!),\n    enabled: isLibraryFile && !!libraryFileId,\n  });\n\n  // Get sliced_for_model from archive or library file\n  const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;\n\n  // Fetch plates for archives\n  const { data: archivePlatesData, isError: archivePlatesError } = useQuery({\n    queryKey: ['archive-plates', archiveId],\n    queryFn: () => api.getArchivePlates(archiveId!),\n    enabled: !!archiveId && !isLibraryFile,\n    retry: false,\n  });\n\n  // Fetch plates for library files\n  const { data: libraryPlatesData } = useQuery({\n    queryKey: ['library-file-plates', libraryFileId],\n    queryFn: () => api.getLibraryFilePlates(libraryFileId!),\n    enabled: isLibraryFile && !!libraryFileId,\n  });\n\n  // Combine plates data from either source\n  const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;\n\n  // Fetch filament requirements for archives\n  const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({\n    queryKey: ['archive-filaments', archiveId, selectedPlate],\n    queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined),\n    enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate),\n    retry: false,\n  });\n\n  // Fetch filament requirements for library files (with plate support)\n  const { data: libraryFilamentReqs } = useQuery({\n    queryKey: ['library-file-filaments', libraryFileId, selectedPlate],\n    queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined),\n    enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate),\n  });\n\n  // Track if archive data couldn't be loaded (archive deleted or file missing)\n  const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError);\n\n  // Combine filament requirements from either source\n  const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;\n  const selectedPlateName = useMemo(() => {\n    if (selectedPlate === null || !platesData?.plates?.length) {\n      return undefined;\n    }\n    return platesData.plates.find((plate) => plate.index === selectedPlate)?.name || undefined;\n  }, [platesData, selectedPlate]);\n\n  // Fetch available filaments for model-based assignment (for filament override UI)\n  const { data: availableFilaments } = useQuery({\n    queryKey: ['available-filaments', targetModel, targetLocation],\n    queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined),\n    enabled: assignmentMode === 'model' && !!targetModel,\n  });\n\n  // Only fetch printer status when single printer selected (for filament mapping)\n  const { data: printerStatus } = useQuery({\n    queryKey: ['printer-status', effectivePrinterId],\n    queryFn: () => api.getPrinterStatus(effectivePrinterId!),\n    enabled: !!effectivePrinterId,\n  });\n\n  // Get AMS mapping from hook (only when single printer selected)\n  const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings, settings?.prefer_lowest_filament);\n\n  // Multi-printer filament mapping (for per-printer configuration)\n  const multiPrinterMapping = useMultiPrinterFilamentMapping(\n    selectedPrinters,\n    printers,\n    effectiveFilamentReqs,\n    manualMappings,\n    perPrinterConfigs,\n    setPerPrinterConfigs,\n    settings?.prefer_lowest_filament,\n  );\n\n  // Auto-select first plate when plates load (single or multi-plate)\n  useEffect(() => {\n    if (platesData?.plates && platesData.plates.length >= 1 && selectedPlates.size === 0) {\n      setSelectedPlates(new Set([platesData.plates[0].index]));\n    }\n  }, [platesData, selectedPlates.size]);\n\n  // Auto-select first printer when only one available\n  useEffect(() => {\n    // Skip auto-select for edit mode (already initialized from queueItem)\n    if (mode === 'edit-queue-item') return;\n    const activePrinters = printers?.filter(p => p.is_active) || [];\n    if (activePrinters.length === 1 && selectedPrinters.length === 0) {\n      setSelectedPrinters([activePrinters[0].id]);\n    }\n  }, [mode, printers, selectedPrinters.length]);\n\n  // Clear manual mappings and per-printer configs when printer or plate changes\n  useEffect(() => {\n    if (mode === 'edit-queue-item') {\n      // For edit mode, clear mappings if printer selection or plate changed from initial\n      const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());\n      if (printersChanged || selectedPlate !== initialPlateId) {\n        setManualMappings({});\n        setPerPrinterConfigs({});\n        setInitialExpandApplied(new Set());\n      }\n    } else {\n      setManualMappings({});\n      setPerPrinterConfigs({});\n      setInitialExpandApplied(new Set());\n    }\n  }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);\n\n  // Clear filament overrides when target model or plate changes (but not on initial mount for edit mode)\n  const [prevTargetModel, setPrevTargetModel] = useState(targetModel);\n  const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate);\n  useEffect(() => {\n    if (targetModel !== prevTargetModel || selectedPlate !== prevPlateForOverrides) {\n      setPrevTargetModel(targetModel);\n      setPrevPlateForOverrides(selectedPlate);\n      // Don't clear on initial render in edit mode (values are initialized from queueItem)\n      if (mode !== 'edit-queue-item' || prevTargetModel !== null) {\n        setFilamentOverrides({});\n        setForceColorMatch({});\n      }\n    }\n  }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);\n\n  // Auto-expand per-printer mapping when setting is enabled and multiple printers selected\n  // Only applies once per printer on initial selection, not when user unchecks\n  useEffect(() => {\n    if (!settings?.per_printer_mapping_expanded) return;\n    if (selectedPrinters.length <= 1) return;\n\n    // Only auto-configure printers that:\n    // 1. Haven't had initial expand applied yet\n    // 2. Have their status loaded (so auto-configure will actually work)\n    const printersReadyForExpand = selectedPrinters.filter(printerId => {\n      if (initialExpandApplied.has(printerId)) return false;\n\n      // Check if this printer has status loaded\n      const result = multiPrinterMapping.printerResults.find(r => r.printerId === printerId);\n      return result && result.status && !result.isLoading;\n    });\n\n    if (printersReadyForExpand.length > 0) {\n      // Mark these printers as having been initially expanded\n      setInitialExpandApplied(prev => {\n        const next = new Set(prev);\n        printersReadyForExpand.forEach(id => next.add(id));\n        return next;\n      });\n\n      // Auto-configure printers\n      printersReadyForExpand.forEach(printerId => {\n        multiPrinterMapping.autoConfigurePrinter(printerId);\n      });\n    }\n  }, [settings?.per_printer_mapping_expanded, selectedPrinters, initialExpandApplied, multiPrinterMapping]);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && !isSubmitting) onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose, isSubmitting]);\n\n  const isMultiPlate = platesData?.is_multi_plate ?? false;\n  const plates = platesData?.plates ?? [];\n\n  const spoolAssignmentsByPrinter = useMemo(() => {\n    const map = new Map<number, Map<number, SpoolAssignment>>();\n    if (!spoolAssignments) return map;\n    spoolAssignments.forEach((assignment) => {\n      const isExternal = assignment.ams_id === 255;\n      const globalTrayId = getGlobalTrayId(\n        assignment.ams_id,\n        assignment.tray_id,\n        isExternal\n      );\n      const printerMap = map.get(assignment.printer_id) ?? new Map();\n      printerMap.set(globalTrayId, assignment);\n      map.set(assignment.printer_id, printerMap);\n    });\n    return map;\n  }, [spoolAssignments]);\n\n  const filamentWarningMessage = useMemo(() => {\n    if (!filamentWarningItems || filamentWarningItems.length === 0) return '';\n    const lines = filamentWarningItems.map((item) =>\n      t('printModal.insufficientFilamentLine', {\n        printer: item.printerName,\n        slot: item.slotLabel,\n        required: Math.round(item.requiredGrams),\n        remaining: Math.round(item.remainingGrams),\n      })\n    );\n    return [t('printModal.insufficientFilamentMessage'), ...lines].join('\\n');\n  }, [filamentWarningItems, t]);\n\n  // Add to queue mutation (single printer)\n  const addToQueueMutation = useMutation({\n    mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),\n  });\n\n  // Update queue item mutation\n  const updateQueueMutation = useMutation({\n    mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      showToast('Queue item updated');\n      onSuccess?.();\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Failed to update queue item', 'error');\n    },\n  });\n\n  const willUseStagger = scheduleOptions.staggerEnabled && selectedPrinters.length > 1;\n\n  const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {\n    e?.preventDefault();\n\n    if (\n      !options?.skipFilamentCheck &&\n      !settings?.disable_filament_warnings &&\n      (mode === 'reprint' || mode === 'add-to-queue') &&\n      assignmentMode === 'printer'\n    ) {\n      const warningItems: FilamentWarningItem[] = [];\n      const filamentReqs = effectiveFilamentReqs?.filaments ?? [];\n\n      if (filamentReqs.length > 0 && spoolAssignmentsByPrinter.size > 0) {\n        const getRemainingWeight = (labelWeight: number, weightUsed: number) => {\n          if (!Number.isFinite(labelWeight) || labelWeight <= 0) return null;\n          if (!Number.isFinite(weightUsed) || weightUsed < 0) return null;\n          return Math.max(0, labelWeight - weightUsed);\n        };\n\n        for (const printerId of selectedPrinters) {\n          const printerMapping = selectedPrinters.length > 1\n            ? multiPrinterMapping.getFinalMapping(printerId)\n            : amsMapping;\n          if (!printerMapping) continue;\n\n          const printerStatusForWarning = selectedPrinters.length > 1\n            ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status\n            : printerStatus;\n\n          const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);\n          const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));\n          const assignments = spoolAssignmentsByPrinter.get(printerId);\n          const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;\n\n          if (!assignments) continue;\n\n          filamentReqs.forEach((req) => {\n            if (!req.slot_id || req.slot_id <= 0) return;\n            const globalTrayId = printerMapping[req.slot_id - 1];\n            if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;\n\n            const assignment = assignments.get(globalTrayId);\n            const spool = assignment?.spool;\n            if (!spool) return;\n\n            const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);\n            if (remainingGrams === null) return;\n            if (remainingGrams >= req.used_grams) return;\n\n            warningItems.push({\n              printerName,\n              slotLabel: slotLabelByTray.get(globalTrayId) ?? `Slot ${req.slot_id}`,\n              requiredGrams: req.used_grams,\n              remainingGrams,\n            });\n          });\n        }\n      }\n\n      if (warningItems.length > 0) {\n        setFilamentWarningItems(warningItems);\n        return;\n      }\n    }\n\n    // Validate printer/model selection\n    if (assignmentMode === 'printer' && selectedPrinters.length === 0) {\n      showToast('Please select at least one printer', 'error');\n      return;\n    }\n    if (assignmentMode === 'model' && !targetModel) {\n      showToast('Please select a target printer model', 'error');\n      return;\n    }\n\n    setIsSubmitting(true);\n    // Calculate total API calls: plates × printers (or 1 for model-based)\n    const platesToQueue = selectedPlates.size > 1\n      ? plates.filter(p => selectedPlates.has(p.index))\n      : [null];\n    const totalCount = assignmentMode === 'model'\n      ? platesToQueue.length\n      : selectedPrinters.length * platesToQueue.length;\n    setSubmitProgress({ current: 0, total: totalCount });\n\n    const results: { success: number; failed: number; errors: string[] } = {\n      success: 0,\n      failed: 0,\n      errors: [],\n    };\n\n    // Get mapping for a specific printer (per-printer override or default)\n    const getMappingForPrinter = (printerId: number): number[] | undefined => {\n      // For multi-printer selection, check if this printer has an override\n      if (selectedPrinters.length > 1) {\n        const printerConfig = perPrinterConfigs[printerId];\n        if (printerConfig && !printerConfig.useDefault) {\n          return multiPrinterMapping.getFinalMapping(printerId);\n        }\n      }\n      return amsMapping;\n    };\n\n    // Convert filament overrides from Record to array format for API.\n    // Include all slots that either have a user override or have force_color_match enabled\n    // (which is the default for model-based assignment).\n    const buildFilamentOverridesArray = () => {\n      const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; force_color_match: boolean }> = [];\n\n      // Process all slots from filament requirements (to capture force_color_match defaults)\n      if (effectiveFilamentReqs?.filaments) {\n        for (const req of effectiveFilamentReqs.filaments) {\n          const userOverride = filamentOverrides[req.slot_id];\n          const isForceColor = forceColorMatch[req.slot_id] ?? false;\n          const effectiveType = userOverride?.type ?? req.type;\n          const effectiveColor = userOverride?.color ?? req.color;\n\n          // Include slot if user changed the filament OR force_color_match is enabled\n          if (userOverride || isForceColor) {\n            entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), force_color_match: isForceColor });\n          }\n        }\n      } else {\n        // Fallback: no filament requirements data — only include explicit user overrides\n        for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) {\n          const id = parseInt(slotId, 10);\n          const isForceColor = forceColorMatch[id] ?? false;\n          entries.push({ slot_id: id, type, color, color_name: getColorName(color), force_color_match: isForceColor });\n        }\n      }\n\n      return entries.length > 0 ? entries : undefined;\n    };\n\n    const filamentOverridesArray = buildFilamentOverridesArray();\n\n    // Common queue data for add-to-queue and edit modes\n    const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => ({\n      printer_id: assignmentMode === 'printer' ? printerId : null,\n      target_model: assignmentMode === 'model' ? targetModel : null,\n      target_location: assignmentMode === 'model' ? targetLocation : null,\n      filament_overrides: assignmentMode === 'model' ? filamentOverridesArray : undefined,\n      // Use library_file_id for library files, archive_id for archives\n      archive_id: isLibraryFile ? undefined : archiveId,\n      library_file_id: isLibraryFile ? libraryFileId : undefined,\n      require_previous_success: scheduleOptions.requirePreviousSuccess,\n      auto_off_after: scheduleOptions.autoOffAfter,\n      gcode_injection: scheduleOptions.gcodeInjection,\n      manual_start: scheduleOptions.scheduleType === 'manual',\n      ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,\n      plate_id: plateOverride !== undefined ? plateOverride : selectedPlate,\n      scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime\n        ? new Date(scheduleOptions.scheduledTime).toISOString()\n        : undefined,\n      ...printOptions,\n      project_id: projectId ?? undefined,\n    });\n\n    // Model-based assignment\n    if (assignmentMode === 'model') {\n      if (mode === 'reprint') {\n        showToast('Model-based assignment only works with queue mode', 'error');\n        setIsSubmitting(false);\n        return;\n      }\n\n      let progressCounter = 0;\n      for (const plate of platesToQueue) {\n        progressCounter++;\n        setSubmitProgress({ current: progressCounter, total: totalCount });\n        const plateId = plate ? plate.index : selectedPlate;\n\n        try {\n          if (mode === 'edit-queue-item' && !plate) {\n            // Edit mode - update with target_model (only for single plate)\n            const updateData: PrintQueueItemUpdate = {\n              printer_id: null,\n              target_model: targetModel,\n              target_location: targetLocation,\n              filament_overrides: filamentOverridesArray || null,\n              require_previous_success: scheduleOptions.requirePreviousSuccess,\n              auto_off_after: scheduleOptions.autoOffAfter,\n              gcode_injection: scheduleOptions.gcodeInjection,\n              manual_start: scheduleOptions.scheduleType === 'manual',\n              ams_mapping: undefined,\n              plate_id: plateId,\n              scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime\n                ? new Date(scheduleOptions.scheduledTime).toISOString()\n                : null,\n              ...printOptions,\n            };\n            await updateQueueMutation.mutateAsync(updateData);\n          } else {\n            // Add-to-queue mode with model-based assignment\n            const queueData = getQueueData(null, plateId);\n            if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;\n            await addToQueueMutation.mutateAsync(queueData);\n          }\n          results.success++;\n        } catch (error) {\n          results.failed++;\n          const plateName = plate ? (plate.name || `Plate ${plate.index}`) : '';\n          results.errors.push(plateName ? `${plateName}: ${(error as Error).message}` : (error as Error).message);\n        }\n      }\n    } else {\n      // Printer-based assignment: loop through plates × printers\n      // Compute stagger base time once before the loop\n      const useStagger = scheduleOptions.staggerEnabled\n        && (mode === 'add-to-queue' || mode === 'reprint')\n        && selectedPrinters.length > 1;\n      const staggerBaseTime = useStagger\n        ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime\n          ? new Date(scheduleOptions.scheduledTime).getTime()\n          : Date.now())\n        : 0;\n\n      let progressCounter = 0;\n      for (const plate of platesToQueue) {\n        const plateId = plate ? plate.index : selectedPlate;\n\n        for (let i = 0; i < selectedPrinters.length; i++) {\n          const printerId = selectedPrinters[i];\n          progressCounter++;\n          setSubmitProgress({ current: progressCounter, total: totalCount });\n\n          try {\n            if (mode === 'reprint' && !useStagger) {\n              // Reprint mode - start print immediately (single plate only, multi-select not available)\n              const printerMapping = getMappingForPrinter(printerId);\n              if (isLibraryFile) {\n                await api.printLibraryFile(libraryFileId!, printerId, {\n                  plate_id: selectedPlate ?? undefined,\n                  plate_name: selectedPlateName,\n                  ams_mapping: printerMapping,\n                  ...printOptions,\n                  project_id: projectId,\n                  cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,\n                });\n              } else {\n                // project_id is intentionally omitted here: reprintArchive targets an existing\n                // archive that already carries its own project association from the original print.\n                await api.reprintArchive(archiveId!, printerId, {\n                  plate_id: selectedPlate ?? undefined,\n                  plate_name: selectedPlateName,\n                  ams_mapping: printerMapping,\n                  ...printOptions,\n                });\n              }\n              // Queue remaining copies if quantity > 1\n              if (effectiveQuantity > 1) {\n                const queueData = getQueueData(printerId, plateId);\n                queueData.quantity = effectiveQuantity - 1;\n                await addToQueueMutation.mutateAsync(queueData);\n              }\n            } else if (mode === 'edit-queue-item' && progressCounter === 1) {\n              // Edit mode - update the original queue item for the first entry\n              const printerMapping = getMappingForPrinter(printerId);\n              const updateData: PrintQueueItemUpdate = {\n                printer_id: printerId,\n                target_model: null,\n                target_location: null,\n                require_previous_success: scheduleOptions.requirePreviousSuccess,\n                auto_off_after: scheduleOptions.autoOffAfter,\n                gcode_injection: scheduleOptions.gcodeInjection,\n                manual_start: scheduleOptions.scheduleType === 'manual',\n                ams_mapping: printerMapping,\n                plate_id: plateId,\n                scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime\n                  ? new Date(scheduleOptions.scheduledTime).toISOString()\n                  : null,\n                ...printOptions,\n              };\n              await updateQueueMutation.mutateAsync(updateData);\n            } else {\n              // Add-to-queue mode, stagger-reprint mode, or edit mode with additional entries\n              const queueData = getQueueData(printerId, plateId);\n              if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;\n              // Apply stagger offset for groups after the first\n              if (useStagger) {\n                const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);\n                if (groupIndex > 0) {\n                  const offsetMs = groupIndex * scheduleOptions.staggerIntervalMinutes * 60_000;\n                  queueData.scheduled_time = new Date(staggerBaseTime + offsetMs).toISOString();\n                }\n                // Group 0 with ASAP: no scheduled_time (start immediately)\n                // Group 0 with scheduled: keeps the scheduled_time from getQueueData\n              }\n              await addToQueueMutation.mutateAsync(queueData);\n            }\n            results.success++;\n          } catch (error) {\n            results.failed++;\n            const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;\n            const plateName = plate ? (plate.name || `Plate ${plate.index}`) : '';\n            const label = plateName ? `${printerName} (${plateName})` : printerName;\n            results.errors.push(`${label}: ${(error as Error).message}`);\n          }\n        }\n      }\n    }\n\n    setIsSubmitting(false);\n\n    // Show result toast (skip for direct reprint — the dispatch toast handles it)\n    if (results.failed === 0) {\n      if (mode === 'reprint' && willUseStagger) {\n        // Stagger-reprint routed through queue\n        showToast(t('queue.itemsQueued', { count: results.success }));\n      } else if (mode !== 'reprint') {\n        if (mode === 'edit-queue-item') {\n          showToast('Queue item updated');\n        } else if (results.success === 1) {\n          showToast(assignmentMode === 'model' ? `Queued for any ${targetModel}` : t('queue.printQueued'));\n        } else {\n          showToast(t('queue.itemsQueued', { count: results.success }));\n        }\n      }\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      onSuccess?.();\n      onClose();\n    } else if (results.success === 0) {\n      showToast(`Failed: ${results.errors[0]}`, 'error');\n    } else {\n      showToast(`${results.success} succeeded, ${results.failed} failed`, 'error');\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n    }\n  };\n\n  const isPending = isSubmitting || updateQueueMutation.isPending;\n\n  const canSubmit = useMemo(() => {\n    if (isPending) return false;\n\n    // Need valid printer/model selection\n    if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;\n    if (assignmentMode === 'model' && !targetModel) return false;\n\n    // Model-based assignment only works in queue modes (not immediate reprint)\n    if (assignmentMode === 'model' && mode === 'reprint') return false;\n\n    // For multi-plate files, need at least one plate selected\n    if (isMultiPlate && selectedPlates.size === 0) return false;\n\n    return true;\n  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlates.size, isPending]);\n\n  // Quantity only applies for single-printer or model-based assignment (not multi-printer)\n  const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;\n\n  // Modal title and action button text based on mode\n  const getModalConfig = () => {\n    const printerCount = selectedPrinters.length;\n\n    if (mode === 'reprint') {\n      const staggerReprint = willUseStagger && printerCount > 1;\n      let submitText = staggerReprint\n        ? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })\n        : printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print');\n      if (effectiveQuantity > 1) {\n        submitText = `${submitText} ×${effectiveQuantity}`;\n      }\n      return {\n        title: isLibraryFile ? t('queue.print') : t('queue.reprint'),\n        icon: Printer,\n        submitText,\n        submitIcon: staggerReprint ? Calendar : Printer,\n        loadingText: submitProgress.total > 1\n          ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })\n          : t('queue.sending'),\n      };\n    }\n    if (mode === 'add-to-queue') {\n      let submitText = t('queue.addToQueue');\n      if (selectedPlates.size > 1) {\n        submitText = t('queue.queueSelectedPlates', { count: selectedPlates.size });\n      } else if (printerCount > 1) {\n        submitText = t('queue.queueToPrinters', { count: printerCount });\n      }\n      if (effectiveQuantity > 1) {\n        submitText = `${submitText} ×${effectiveQuantity}`;\n      }\n      return {\n        title: t('queue.schedulePrint'),\n        icon: Calendar,\n        submitText,\n        submitIcon: Calendar,\n        loadingText: submitProgress.total > 1\n          ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })\n          : t('queue.adding'),\n      };\n    }\n    // edit-queue-item mode\n    return {\n      title: t('queue.editQueueItem'),\n      icon: Pencil,\n      submitText: t('common.save'),\n      submitIcon: Pencil,\n      loadingText: submitProgress.total > 1\n        ? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })\n        : t('common.saving'),\n    };\n  };\n\n  const modalConfig = getModalConfig();\n  const TitleIcon = modalConfig.icon;\n  const SubmitIcon = modalConfig.submitIcon;\n\n  // Show filament mapping when:\n  // - Single printer selected\n  // - For archives: plate is selected (for multi-plate) or not required (single-plate)\n  // - For library files: always show (no plate selection)\n  const showFilamentMapping = effectivePrinterId && selectedPlates.size <= 1 && (\n    isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)\n  );\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n      onClick={isSubmitting ? undefined : onClose}\n    >\n      <Card\n        className=\"w-full max-w-2xl max-h-[90vh] overflow-y-auto\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        <CardContent className={mode === 'reprint' ? '' : 'p-0'}>\n          {/* Header */}\n          <div\n            className={`flex items-center justify-between ${\n              mode === 'reprint' ? 'mb-4' : 'p-4 border-b border-bambu-dark-tertiary'\n            }`}\n          >\n            <div className=\"flex items-center gap-2\">\n              <TitleIcon className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-lg font-semibold text-white\">{modalConfig.title}</h2>\n            </div>\n            <Button variant=\"ghost\" size=\"sm\" onClick={onClose} disabled={isSubmitting}>\n              <X className=\"w-5 h-5\" />\n            </Button>\n          </div>\n\n          <form onSubmit={handleSubmit} className={mode === 'reprint' ? '' : 'p-4 space-y-4'}>\n            {/* Archive name */}\n            <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>\n              {mode === 'reprint' ? (\n                <>\n                  Send <span className=\"text-white\">{archiveName}</span> to{' '}\n                  {initialSelectedPrinterIds?.length === 1 && printers\n                    ? <span className=\"text-white\">{printers.find(p => p.id === initialSelectedPrinterIds[0])?.name ?? 'printer(s)'}</span>\n                    : 'printer(s)'}\n                </>\n              ) : (\n                <>\n                  <span className=\"block text-bambu-gray mb-1\">Print Job</span>\n                  <span className=\"text-white font-medium truncate block\">{archiveName}</span>\n                </>\n              )}\n            </p>\n\n            {/* Plate selection - first so users know filament requirements before selecting printers */}\n            <PlateSelector\n              plates={plates}\n              isMultiPlate={isMultiPlate}\n              selectedPlates={selectedPlates}\n              onToggle={(plateIndex) => {\n                setSelectedPlates(prev => {\n                  const next = new Set(prev);\n                  if (mode === 'add-to-queue') {\n                    // Multi-select: toggle the plate\n                    if (next.has(plateIndex)) {\n                      next.delete(plateIndex);\n                    } else {\n                      next.add(plateIndex);\n                    }\n                  } else {\n                    // Single-select: replace selection\n                    next.clear();\n                    next.add(plateIndex);\n                  }\n                  return next;\n                });\n              }}\n              onSelectAll={mode === 'add-to-queue' ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}\n              onDeselectAll={mode === 'add-to-queue' ? () => setSelectedPlates(new Set()) : undefined}\n              multiSelect={mode === 'add-to-queue'}\n            />\n\n            {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}\n            {!initialSelectedPrinterIds?.length && (\n              <PrinterSelector\n                printers={printers || []}\n                selectedPrinterIds={selectedPrinters}\n                onMultiSelect={setSelectedPrinters}\n                isLoading={loadingPrinters}\n                allowMultiple={true}\n                showInactive={mode === 'edit-queue-item'}\n                disableBusy={mode === 'reprint'}\n                printerMappingResults={multiPrinterMapping.printerResults}\n                filamentReqs={effectiveFilamentReqs}\n                onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}\n                onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}\n                assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}\n                onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}\n                targetModel={targetModel}\n                onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}\n                targetLocation={targetLocation}\n                onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}\n                slicedForModel={slicedForModel}\n              />\n            )}\n\n            {/* Filament override - shown in model mode when filament requirements are available */}\n            {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (\n              <FilamentOverride\n                filamentReqs={effectiveFilamentReqs}\n                availableFilaments={availableFilaments}\n                overrides={filamentOverrides}\n                onChange={setFilamentOverrides}\n                forceColorMatch={forceColorMatch}\n                onForceColorMatchChange={(slotId, value) =>\n                  setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))\n                }\n              />\n            )}\n\n            {/* Compatibility warning when sliced model doesn't match selected printer */}\n            {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {\n              const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);\n              if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {\n                return (\n                  <div className=\"p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2\">\n                    <AlertTriangle className=\"w-4 h-4 text-yellow-400 flex-shrink-0\" />\n                    <span className=\"text-sm text-yellow-400\">\n                      File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}\n                    </span>\n                  </div>\n                );\n              }\n              return null;\n            })()}\n\n            {/* Warning when archive data couldn't be loaded */}\n            {archiveDataMissing && (\n              <div className=\"flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm\">\n                <AlertCircle className=\"w-4 h-4 text-orange-400 mt-0.5 flex-shrink-0\" />\n                <p className=\"text-orange-400\">\n                  Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.\n                </p>\n              </div>\n            )}\n\n            {/* Filament mapping - only show when single printer selected */}\n            {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (\n              <FilamentMapping\n                printerId={effectivePrinterId!}\n                filamentReqs={effectiveFilamentReqs}\n                manualMappings={manualMappings}\n                onManualMappingChange={setManualMappings}\n                defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}\n                currencySymbol={currencySymbol}\n                defaultCostPerKg={defaultCostPerKg}\n              />\n            )}\n\n            {/* Print options */}\n            {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (\n              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />\n            )}\n\n            {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}\n            {mode !== 'edit-queue-item' && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (\n              <div className=\"flex items-center gap-3\">\n                <label htmlFor=\"printQuantity\" className=\"text-sm text-bambu-gray whitespace-nowrap\">\n                  {t('queue.quantity', 'Quantity')}\n                </label>\n                <input\n                  id=\"printQuantity\"\n                  type=\"number\"\n                  min={1}\n                  max={999}\n                  value={quantity}\n                  onChange={(e) => setQuantity(Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))}\n                  className=\"w-20 px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green\"\n                />\n                {quantity > 1 && (\n                  <span className=\"text-xs text-bambu-gray\">\n                    {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}\n                  </span>\n                )}\n              </div>\n            )}\n\n            {/* Stagger option for reprint mode with multiple printers */}\n            {mode === 'reprint' && assignmentMode === 'printer' && selectedPrinters.length > 1 && (\n              <div className=\"space-y-2 pb-2\">\n                <div className=\"flex items-center gap-2\">\n                  <input\n                    type=\"checkbox\"\n                    id=\"staggerEnabledReprint\"\n                    checked={scheduleOptions.staggerEnabled}\n                    onChange={(e) => setScheduleOptions({ ...scheduleOptions, staggerEnabled: e.target.checked })}\n                    className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                  />\n                  <label htmlFor=\"staggerEnabledReprint\" className=\"text-sm flex items-center gap-1 text-bambu-gray\">\n                    <Layers className=\"w-3.5 h-3.5\" />\n                    {t('printModal.staggerPrinterStarts', 'Stagger printer starts')}\n                  </label>\n                </div>\n                {scheduleOptions.staggerEnabled && (() => {\n                  const groupSize = scheduleOptions.staggerGroupSize;\n                  const interval = scheduleOptions.staggerIntervalMinutes;\n                  const groupCount = Math.ceil(selectedPrinters.length / groupSize);\n                  const totalMinutes = (groupCount - 1) * interval;\n                  return (\n                    <p className=\"ml-6 text-xs text-bambu-gray\">\n                      {t('printModal.staggerPreview', '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min', {\n                        printers: selectedPrinters.length,\n                        groups: groupCount,\n                        size: groupSize,\n                        interval,\n                      })}\n                      {groupCount > 1\n                        ? ` (${t('printModal.staggerTotal', 'total: {{minutes}} min', { minutes: totalMinutes })})`\n                        : ''}\n                    </p>\n                  );\n                })()}\n              </div>\n            )}\n\n            {/* Schedule options - only for queue modes */}\n            {mode !== 'reprint' && (\n              <ScheduleOptionsPanel\n                options={scheduleOptions}\n                onChange={setScheduleOptions}\n                dateFormat={settings?.date_format || 'system'}\n                timeFormat={settings?.time_format || 'system'}\n                canControlPrinter={hasPermission('printers:control')}\n                showStagger={mode === 'add-to-queue' && assignmentMode === 'printer' && selectedPrinters.length > 1}\n                printerCount={selectedPrinters.length}\n                hasGcodeSnippets={!!settings?.gcode_snippets}\n              />\n            )}\n\n            {/* G-code injection for reprint mode (only shown when quantity > 1 — applies to queued copies) */}\n            {mode === 'reprint' && !!settings?.gcode_snippets && effectiveQuantity > 1 && (\n              <div className=\"flex items-center gap-2\">\n                <input\n                  type=\"checkbox\"\n                  id=\"gcodeInjectionReprint\"\n                  checked={scheduleOptions.gcodeInjection}\n                  onChange={(e) => setScheduleOptions({ ...scheduleOptions, gcodeInjection: e.target.checked })}\n                  className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n                />\n                <label htmlFor=\"gcodeInjectionReprint\" className=\"text-sm flex items-center gap-1 text-bambu-gray\">\n                  <Code className=\"w-3.5 h-3.5\" />\n                  {t('printModal.gcodeInjection', 'Inject auto-print G-code')}\n                </label>\n              </div>\n            )}\n\n            {/* Error message */}\n            {updateQueueMutation.isError && (\n              <div className=\"mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\">\n                {(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}\n              </div>\n            )}\n\n            {/* Actions */}\n            <div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>\n              <Button type=\"button\" variant=\"secondary\" onClick={onClose} className=\"flex-1\" disabled={isSubmitting}>\n                Cancel\n              </Button>\n              <Button\n                type=\"submit\"\n                disabled={!canSubmit}\n                className=\"flex-1\"\n              >\n                {isPending ? (\n                  <>\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    {modalConfig.loadingText}\n                  </>\n                ) : (\n                  <>\n                    <SubmitIcon className=\"w-4 h-4\" />\n                    {modalConfig.submitText}\n                  </>\n                )}\n              </Button>\n            </div>\n          </form>\n        </CardContent>\n      </Card>\n\n      {filamentWarningItems && filamentWarningItems.length > 0 && (\n        <ConfirmModal\n          title={t('printModal.insufficientFilamentTitle')}\n          message={filamentWarningMessage}\n          confirmText={t('printModal.printAnyway')}\n          cancelText={t('common.cancel')}\n          variant=\"warning\"\n          onConfirm={() => {\n            setFilamentWarningItems(null);\n            void handleSubmit(undefined, { skipFilamentCheck: true });\n          }}\n          onCancel={() => setFilamentWarningItems(null)}\n        />\n      )}\n    </div>\n  );\n}\n\n// Re-export types for convenience\nexport type { PrintModalMode, PrintModalProps } from './types';\n"
  },
  {
    "path": "frontend/src/components/PrintModal/types.ts",
    "content": "import type { PrintQueueItem, Printer } from '../../api/client';\n\n/**\n * Mode of operation for the PrintModal.\n * - 'reprint': Immediate print from archive (no schedule options)\n * - 'add-to-queue': Schedule print to queue (includes schedule options)\n * - 'edit-queue-item': Edit existing queue item (all options + existing values)\n */\nexport type PrintModalMode = 'reprint' | 'add-to-queue' | 'edit-queue-item';\n\n/**\n * Props for the unified PrintModal component.\n *\n * Either archiveId or libraryFileId must be provided.\n * - archiveId: For reprinting/queueing archives\n * - libraryFileId: For printing library files directly\n */\nexport interface PrintModalProps {\n  /** Modal operation mode */\n  mode: PrintModalMode;\n  /** Archive ID to print (mutually exclusive with libraryFileId) */\n  archiveId?: number;\n  /** Library file ID to print (mutually exclusive with archiveId) */\n  libraryFileId?: number;\n  /** Display name for the print */\n  archiveName: string;\n  /** Existing queue item (only for edit-queue-item mode) */\n  queueItem?: PrintQueueItem;\n  /** Pre-select specific printers when opening the modal */\n  initialSelectedPrinterIds?: number[];\n  /** Handler for closing the modal */\n  onClose: () => void;\n  /** Handler for successful operation */\n  onSuccess?: () => void;\n  /** Project ID to associate the resulting archive with (only when triggered from project view) */\n  projectId?: number;\n  /** Delete the LibraryFile after dispatch — used by the Printers-page Direct-Print flow\n   *  so transient uploads don't linger in File Manager. Only applies to library-file prints. */\n  cleanupLibraryAfterDispatch?: boolean;\n}\n\n/**\n * Print options that can be configured for a print job.\n */\nexport interface PrintOptions {\n  bed_levelling: boolean;\n  flow_cali: boolean;\n  vibration_cali: boolean;\n  layer_inspect: boolean;\n  timelapse: boolean;\n}\n\n/**\n * Default print options values.\n */\nexport const DEFAULT_PRINT_OPTIONS: PrintOptions = {\n  bed_levelling: true,\n  flow_cali: false,\n  vibration_cali: true,\n  layer_inspect: false,\n  timelapse: false,\n};\n\n/**\n * Schedule type for queue items.\n */\nexport type ScheduleType = 'asap' | 'scheduled' | 'manual';\n\n/**\n * Schedule options for queue items.\n */\nexport interface ScheduleOptions {\n  scheduleType: ScheduleType;\n  scheduledTime: string;\n  requirePreviousSuccess: boolean;\n  autoOffAfter: boolean;\n  gcodeInjection: boolean;\n  staggerEnabled: boolean;\n  staggerGroupSize: number;\n  staggerIntervalMinutes: number;\n}\n\n/**\n * Default schedule options values.\n */\nexport const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {\n  scheduleType: 'asap',\n  scheduledTime: '',\n  requirePreviousSuccess: false,\n  autoOffAfter: false,\n  gcodeInjection: false,\n  staggerEnabled: false,\n  staggerGroupSize: 2,\n  staggerIntervalMinutes: 5,\n};\n\n/**\n * Plate information from a multi-plate 3MF file.\n */\nexport interface PlateInfo {\n  index: number;\n  name: string | null;\n  has_thumbnail: boolean;\n  thumbnail_url: string | null;\n  objects: string[];\n  filaments: Array<{\n    type: string;\n    color: string;\n  }>;\n  print_time_seconds: number | null;\n  filament_used_grams: number | null;\n}\n\n/**\n * Response from the archive plates API.\n */\nexport interface PlatesResponse {\n  is_multi_plate: boolean;\n  plates: PlateInfo[];\n}\n\n/**\n * Assignment mode for queue items.\n * - 'printer': Assign to specific printer(s)\n * - 'model': Assign to any printer of a specific model (load balancing)\n */\nexport type AssignmentMode = 'printer' | 'model';\n\n/**\n * Props for the PrinterSelector component.\n */\nexport interface PrinterSelectorProps {\n  printers: Printer[];\n  selectedPrinterIds: number[];\n  onMultiSelect: (printerIds: number[]) => void;\n  isLoading?: boolean;\n  allowMultiple?: boolean;\n  /** Show inactive printers (for edit mode where original assignment may be inactive) */\n  showInactive?: boolean;\n  /** Disable selection of busy printers (used in reprint mode) */\n  disableBusy?: boolean;\n  /** Current assignment mode */\n  assignmentMode?: AssignmentMode;\n  /** Handler for assignment mode change */\n  onAssignmentModeChange?: (mode: AssignmentMode) => void;\n  /** Selected target model (when assignmentMode is 'model') */\n  targetModel?: string | null;\n  /** Handler for target model change */\n  onTargetModelChange?: (model: string | null) => void;\n  /** Selected target location (when assignmentMode is 'model') */\n  targetLocation?: string | null;\n  /** Handler for target location change */\n  onTargetLocationChange?: (location: string | null) => void;\n  /** Suggested model from sliced file (for pre-selection) */\n  slicedForModel?: string | null;\n}\n\n/**\n * Props for the PlateSelector component.\n */\nexport interface PlateSelectorProps {\n  plates: PlateInfo[];\n  isMultiPlate: boolean;\n  selectedPlates: Set<number>;\n  onToggle: (plateIndex: number) => void;\n  onSelectAll?: () => void;\n  onDeselectAll?: () => void;\n  /** Whether multi-select (checkboxes) is enabled — true in add-to-queue mode */\n  multiSelect?: boolean;\n}\n\n/**\n * Filament requirement data structure.\n */\nexport interface FilamentReqsData {\n  filaments: Array<{\n    slot_id: number;\n    type: string;\n    color: string;\n    used_grams: number;\n    used_meters: number;\n    nozzle_id?: number;\n  }>;\n}\n\n/**\n * Props for the FilamentMapping component.\n */\nexport interface FilamentMappingProps {\n  printerId: number;\n  /** Pre-fetched filament requirements data */\n  filamentReqs: FilamentReqsData | undefined;\n  manualMappings: Record<number, number>;\n  onManualMappingChange: (mappings: Record<number, number>) => void;\n  currencySymbol: string;\n  defaultCostPerKg: number;\n}\n\n/**\n * Props for the PrintOptions component.\n */\nexport interface PrintOptionsProps {\n  options: PrintOptions;\n  onChange: (options: PrintOptions) => void;\n  defaultExpanded?: boolean;\n}\n\n/**\n * Props for the ScheduleOptions component.\n */\nexport interface ScheduleOptionsProps {\n  options: ScheduleOptions;\n  onChange: (options: ScheduleOptions) => void;\n  /** Date format setting from user preferences */\n  dateFormat?: 'system' | 'us' | 'eu' | 'iso';\n  /** Time format setting from user preferences */\n  timeFormat?: 'system' | '12h' | '24h';\n  /** Whether the user has permission to control printers (for auto power off) */\n  canControlPrinter?: boolean;\n  /** Show stagger options (only when multiple printers selected in queue mode) */\n  showStagger?: boolean;\n  /** Number of selected printers (for stagger preview) */\n  printerCount?: number;\n  /** Whether G-code snippets are configured in settings */\n  hasGcodeSnippets?: boolean;\n}\n"
  },
  {
    "path": "frontend/src/components/PrinterInfoModal.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { X, Copy, Check, Signal, Cable } from 'lucide-react';\nimport { Card, CardContent } from './Card';\nimport { formatDateOnly } from '../utils/date';\nimport { getPrinterImage, getWifiStrength } from '../utils/printer';\nimport type { Printer, PrinterStatus } from '../api/client';\n\ninterface PrinterInfoModalProps {\n  printer: Printer;\n  status?: PrinterStatus;\n  totalPrintHours?: number;\n  onClose: () => void;\n}\n\nfunction CopyButton({ value }: { value: string }) {\n  const { t } = useTranslation();\n  const [copied, setCopied] = useState(false);\n\n  const handleCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(value);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    } catch {\n      // Clipboard may not be available in non-secure contexts\n    }\n  };\n\n  return (\n    <button\n      onClick={handleCopy}\n      className=\"ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors\"\n      title={copied ? t('printers.copied') : t('printers.copyToClipboard')}\n    >\n      {copied ? <Check className=\"w-3.5 h-3.5 text-bambu-green\" /> : <Copy className=\"w-3.5 h-3.5\" />}\n    </button>\n  );\n}\n\nexport function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {\n  const { t } = useTranslation();\n\n  useEffect(() => {\n    const handleKey = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKey);\n    return () => window.removeEventListener('keydown', handleKey);\n  }, [onClose]);\n\n  const rows: { label: string; value: React.ReactNode }[] = [];\n\n  // Model\n  rows.push({\n    label: t('printers.model'),\n    value: printer.model ?? '—',\n  });\n\n  // Connection Status\n  rows.push({\n    label: t('common.status'),\n    value: (\n      <span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${\n        status?.connected\n          ? 'bg-bambu-green/20 text-bambu-green'\n          : 'bg-red-500/20 text-red-400'\n      }`}>\n        <span className={`w-1.5 h-1.5 rounded-full ${status?.connected ? 'bg-bambu-green' : 'bg-red-400'}`} />\n        {status?.connected ? t('printers.status.available') : t('printers.status.offline')}\n      </span>\n    ),\n  });\n\n  // State\n  if (status?.state) {\n    const stateMap: Record<string, string> = {\n      IDLE: 'printers.status.idle',\n      RUNNING: 'printers.status.printing',\n      PAUSE: 'printers.status.paused',\n      FINISH: 'printers.status.finished',\n      FAILED: 'printers.status.error',\n    };\n    rows.push({\n      label: t('printers.state'),\n      value: t(stateMap[status.state] ?? 'printers.status.unknown'),\n    });\n  }\n\n  // IP Address\n  rows.push({\n    label: t('printers.ipAddress'),\n    value: (\n      <span className=\"flex items-center\">\n        <span className=\"font-mono\">{printer.ip_address}</span>\n        <CopyButton value={printer.ip_address} />\n      </span>\n    ),\n  });\n\n  // Serial Number\n  rows.push({\n    label: t('printers.serialNumber'),\n    value: (\n      <span className=\"flex items-center\">\n        <span className=\"font-mono truncate\">{printer.serial_number}</span>\n        <CopyButton value={printer.serial_number} />\n      </span>\n    ),\n  });\n\n  // Network connection\n  if (status?.wired_network) {\n    rows.push({\n      label: t('printers.networkLabel', 'Network'),\n      value: (\n        <span className=\"flex items-center gap-2\">\n          <Cable className=\"w-4 h-4 text-bambu-green\" />\n          <span className=\"text-bambu-green\">{t('printers.connection.ethernet', 'Ethernet')}</span>\n        </span>\n      ),\n    });\n  } else if (status?.wifi_signal != null) {\n    const wifi = getWifiStrength(status.wifi_signal);\n    rows.push({\n      label: t('printers.wifiSignalLabel'),\n      value: (\n        <span className=\"flex items-center gap-2\">\n          <Signal className={`w-4 h-4 ${wifi.color}`} />\n          <span className={wifi.color}>{t(wifi.labelKey)}</span>\n          <span className=\"text-bambu-gray text-xs\">({status.wifi_signal} dBm)</span>\n        </span>\n      ),\n    });\n  }\n\n  // Firmware\n  rows.push({\n    label: t('printers.firmware'),\n    value: status?.firmware_version ?? '—',\n  });\n\n  // Developer Mode\n  if (status?.developer_mode != null) {\n    rows.push({\n      label: t('printers.developerMode'),\n      value: (\n        <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${\n          status.developer_mode\n            ? 'bg-bambu-green/20 text-bambu-green'\n            : 'bg-bambu-dark-tertiary text-bambu-gray'\n        }`}>\n          {status.developer_mode ? t('printers.enabled') : t('printers.disabled')}\n        </span>\n      ),\n    });\n  }\n\n  // Nozzle Count\n  rows.push({\n    label: t('printers.nozzleCount'),\n    value: printer.nozzle_count,\n  });\n\n  // Auto-Archive\n  rows.push({\n    label: t('printers.autoArchive'),\n    value: (\n      <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${\n        printer.auto_archive\n          ? 'bg-bambu-green/20 text-bambu-green'\n          : 'bg-bambu-dark-tertiary text-bambu-gray'\n      }`}>\n        {printer.auto_archive ? t('printers.enabled') : t('printers.disabled')}\n      </span>\n    ),\n  });\n\n  // Total Print Hours\n  if (totalPrintHours != null && totalPrintHours > 0) {\n    rows.push({\n      label: t('printers.totalPrintHours'),\n      value: `${Math.round(totalPrintHours)}h`,\n    });\n  }\n\n  // Location\n  if (printer.location) {\n    rows.push({\n      label: t('printers.sort.location'),\n      value: printer.location,\n    });\n  }\n\n  // Added date\n  rows.push({\n    label: t('printers.addedOn'),\n    value: formatDateOnly(printer.created_at),\n  });\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\"\n      role=\"dialog\"\n      aria-modal=\"true\"\n      onClick={onClose}\n    >\n      <Card className=\"w-full max-w-md\" onClick={(e: React.MouseEvent) => e.stopPropagation()}>\n        <CardContent>\n          <div className=\"flex items-center justify-between mb-4\">\n            <h2 className=\"text-lg font-semibold text-white\">\n              {printer.name}\n            </h2>\n            <button onClick={onClose} className=\"p-1 hover:bg-bambu-dark rounded flex-shrink-0\">\n              <X className=\"w-5 h-5 text-bambu-gray\" />\n            </button>\n          </div>\n\n          {/* Printer Image */}\n          <div className=\"flex justify-center mb-4\">\n            <img\n              src={getPrinterImage(printer.model)}\n              alt={printer.model ?? printer.name}\n              className=\"h-24 object-contain\"\n            />\n          </div>\n\n          <div className=\"space-y-0\">\n            {rows.map((row, i) => (\n              <div key={i} className=\"flex items-center justify-between gap-4 py-2.5 border-b border-bambu-dark-tertiary last:border-0\">\n                <span className=\"text-sm text-bambu-gray whitespace-nowrap\">{row.label}</span>\n                <span className=\"text-sm text-white text-right\">{row.value}</span>\n              </div>\n            ))}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/PrinterQueueWidget.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { Clock, Calendar, ChevronRight } from 'lucide-react';\nimport { Link } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport { formatRelativeTime } from '../utils/date';\nimport { filterCompatibleQueueItems } from '../utils/printer';\n\ninterface PrinterQueueWidgetProps {\n  printerId: number;\n  printerModel?: string | null;\n  loadedFilamentTypes?: Set<string>;\n  loadedFilaments?: Set<string>;  // \"TYPE:rrggbb\" pairs for filament override color matching\n}\n\nexport function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments }: PrinterQueueWidgetProps) {\n  const { t } = useTranslation();\n  const { data: queue } = useQuery({\n    queryKey: ['queue', printerId, 'pending', printerModel],\n    queryFn: () => api.getQueue(printerId, 'pending', printerModel || undefined),\n    refetchInterval: 30000,\n  });\n\n  // Filter queue to items this printer can actually print (filament type + color check)\n  const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments) : undefined;\n  const totalPending = compatibleQueue?.length || 0;\n\n  if (totalPending === 0) {\n    return null;\n  }\n\n  const nextItem = compatibleQueue?.[0];\n\n  // Passive next-in-queue preview. Plate-clear acknowledgment is handled by the\n  // card-level \"Mark plate as cleared\" button (PrintersPage.tsx). Having a\n  // second button in this widget caused the two controls to overlap whenever\n  // the plate-clear gate was up with auto-dispatch items queued — both POSTed\n  // to the same /clear-plate endpoint, so the widget button was pure noise.\n  return (\n    <Link\n      to=\"/queue\"\n      className=\"block mb-3 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors\"\n    >\n      <div className=\"flex items-center justify-between gap-3\">\n        <div className=\"flex items-center gap-3 min-w-0 flex-1\">\n          <Calendar className=\"w-5 h-5 text-yellow-400 flex-shrink-0\" />\n          <div className=\"min-w-0 flex-1\">\n            <p className=\"text-xs text-bambu-gray\">{t('queue.nextInQueue')}</p>\n            <p className=\"text-sm text-white truncate\">\n              {nextItem?.archive_name || nextItem?.library_file_name || `File #${nextItem?.archive_id || nextItem?.library_file_id}`}\n            </p>\n          </div>\n        </div>\n        <div className=\"flex items-center gap-2 flex-shrink-0\">\n          <span className=\"text-xs text-bambu-gray flex items-center gap-1\">\n            <Clock className=\"w-3 h-3\" />\n            {nextItem?.scheduled_time ? formatRelativeTime(nextItem.scheduled_time, 'system', t) : t('time.waiting')}\n          </span>\n          {totalPending > 1 && (\n            <span className=\"text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded\">\n              +{totalPending - 1}\n            </span>\n          )}\n          <ChevronRight className=\"w-4 h-4 text-bambu-gray\" />\n        </div>\n      </div>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ProjectPageModal.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport DOMPurify from 'dompurify';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  X,\n  User,\n  Calendar,\n  FileText,\n  Image,\n  Edit3,\n  Save,\n  ExternalLink,\n  ChevronLeft,\n  ChevronRight,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport { Button } from './Button';\nimport { RichTextEditor } from './RichTextEditor';\n\ninterface ProjectPageModalProps {\n  archiveId: number;\n  archiveName?: string;\n  onClose: () => void;\n}\n\nexport function ProjectPageModal({ archiveId, archiveName, onClose }: ProjectPageModalProps) {\n  const queryClient = useQueryClient();\n  const [isEditing, setIsEditing] = useState(false);\n  const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);\n  const [editData, setEditData] = useState<{\n    title?: string;\n    description?: string;\n    designer?: string;\n    license?: string;\n    profile_title?: string;\n    profile_description?: string;\n  }>({});\n\n  const { data: projectPage, isLoading, error } = useQuery({\n    queryKey: ['archive-project-page', archiveId],\n    queryFn: () => api.getArchiveProjectPage(archiveId),\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: (data: typeof editData) => api.updateArchiveProjectPage(archiveId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archive-project-page', archiveId] });\n      setIsEditing(false);\n      setEditData({});\n    },\n  });\n\n  // Handle escape key to close modal\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        if (selectedImageIndex !== null) {\n          setSelectedImageIndex(null);\n        } else if (isEditing) {\n          handleCancelEdit();\n        } else {\n          onClose();\n        }\n      }\n    };\n    document.addEventListener('keydown', handleKeyDown);\n    return () => document.removeEventListener('keydown', handleKeyDown);\n  }, [selectedImageIndex, isEditing, onClose]);\n\n  // Combine all images for gallery\n  const allImages = [\n    ...(projectPage?.model_pictures || []),\n    ...(projectPage?.profile_pictures || []),\n  ];\n\n  const handleStartEdit = () => {\n    setEditData({\n      title: projectPage?.title || '',\n      description: projectPage?.description || '',\n      designer: projectPage?.designer || '',\n      license: projectPage?.license || '',\n      profile_title: projectPage?.profile_title || '',\n      profile_description: projectPage?.profile_description || '',\n    });\n    setIsEditing(true);\n  };\n\n  const handleSave = () => {\n    updateMutation.mutate(editData);\n  };\n\n  const handleCancelEdit = () => {\n    setIsEditing(false);\n    setEditData({});\n  };\n\n  // Sanitize HTML content using DOMPurify\n  const sanitizeHtml = (html: string) => {\n    return DOMPurify.sanitize(html, {\n      ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'ul', 'ol', 'li', 'figure', 'img'],\n      ALLOWED_ATTR: ['href', 'src', 'target', 'rel', 'style'],\n      ADD_ATTR: ['target'],\n    });\n  };\n\n  const hasContent = projectPage && (\n    projectPage.title ||\n    projectPage.description ||\n    projectPage.designer ||\n    projectPage.profile_title ||\n    allImages.length > 0\n  );\n\n  // Handle backdrop click to close modal\n  const handleBackdropClick = (e: React.MouseEvent) => {\n    if (e.target === e.currentTarget) {\n      onClose();\n    }\n  };\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n      onClick={handleBackdropClick}\n    >\n      <div className=\"bg-bambu-dark-secondary rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-3\">\n            <FileText className=\"w-5 h-5 text-bambu-green\" />\n            <h2 className=\"text-lg font-semibold text-white\">\n              Project Page\n              {archiveName && <span className=\"text-bambu-gray ml-2\">- {archiveName}</span>}\n            </h2>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            {!isEditing && hasContent && (\n              <Button variant=\"ghost\" size=\"sm\" onClick={handleStartEdit}>\n                <Edit3 className=\"w-4 h-4 mr-1\" />\n                Edit\n              </Button>\n            )}\n            {isEditing && (\n              <>\n                <Button variant=\"ghost\" size=\"sm\" onClick={handleCancelEdit}>\n                  Cancel\n                </Button>\n                <Button\n                  variant=\"primary\"\n                  size=\"sm\"\n                  onClick={handleSave}\n                  disabled={updateMutation.isPending}\n                >\n                  <Save className=\"w-4 h-4 mr-1\" />\n                  Save\n                </Button>\n              </>\n            )}\n            <button\n              onClick={onClose}\n              className=\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n            >\n              <X className=\"w-5 h-5 text-bambu-gray\" />\n            </button>\n          </div>\n        </div>\n\n        {/* Content */}\n        <div className=\"flex-1 overflow-y-auto p-6\">\n          {isLoading && (\n            <div className=\"flex items-center justify-center py-12\">\n              <div className=\"animate-spin rounded-full h-8 w-8 border-2 border-bambu-green border-t-transparent\" />\n            </div>\n          )}\n\n          {error && (\n            <div className=\"text-red-400 text-center py-12\">\n              Failed to load project page data\n            </div>\n          )}\n\n          {projectPage && !hasContent && (\n            <div className=\"text-bambu-gray text-center py-12\">\n              <FileText className=\"w-12 h-12 mx-auto mb-4 opacity-50\" />\n              <p>No project page data found in this 3MF file.</p>\n              <p className=\"text-sm mt-2\">\n                Project pages are typically included in files downloaded from MakerWorld.\n              </p>\n            </div>\n          )}\n\n          {projectPage && hasContent && (\n            <div className=\"space-y-6\">\n              {/* Title & Designer */}\n              <div className=\"space-y-4\">\n                {isEditing ? (\n                  <input\n                    type=\"text\"\n                    value={editData.title || ''}\n                    onChange={(e) => setEditData({ ...editData, title: e.target.value })}\n                    placeholder=\"Title\"\n                    className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-4 py-2 text-white text-xl font-semibold\"\n                  />\n                ) : (\n                  projectPage.title && (\n                    <h3 className=\"text-xl font-semibold text-white\">{projectPage.title}</h3>\n                  )\n                )}\n\n                <div className=\"flex flex-wrap gap-4 text-sm\">\n                  {isEditing ? (\n                    <div className=\"flex items-center gap-2\">\n                      <User className=\"w-4 h-4 text-bambu-gray\" />\n                      <input\n                        type=\"text\"\n                        value={editData.designer || ''}\n                        onChange={(e) => setEditData({ ...editData, designer: e.target.value })}\n                        placeholder=\"Designer\"\n                        className=\"bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white\"\n                      />\n                    </div>\n                  ) : (\n                    projectPage.designer && (\n                      <div className=\"flex items-center gap-2 text-bambu-gray\">\n                        <User className=\"w-4 h-4\" />\n                        <span>{projectPage.designer}</span>\n                        {projectPage.designer_user_id && (\n                          <a\n                            href={`https://makerworld.com/en/@${projectPage.designer_user_id}`}\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-bambu-green hover:underline\"\n                          >\n                            <ExternalLink className=\"w-3 h-3\" />\n                          </a>\n                        )}\n                      </div>\n                    )\n                  )}\n\n                  {projectPage.creation_date && (\n                    <div className=\"flex items-center gap-2 text-bambu-gray\">\n                      <Calendar className=\"w-4 h-4\" />\n                      <span>{projectPage.creation_date}</span>\n                    </div>\n                  )}\n\n                  {isEditing ? (\n                    <div className=\"flex items-center gap-2\">\n                      <FileText className=\"w-4 h-4 text-bambu-gray\" />\n                      <input\n                        type=\"text\"\n                        value={editData.license || ''}\n                        onChange={(e) => setEditData({ ...editData, license: e.target.value })}\n                        placeholder=\"License\"\n                        className=\"bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white\"\n                      />\n                    </div>\n                  ) : (\n                    projectPage.license && (\n                      <div className=\"flex items-center gap-2 text-bambu-gray\">\n                        <FileText className=\"w-4 h-4\" />\n                        <span>{projectPage.license}</span>\n                      </div>\n                    )\n                  )}\n\n                  {projectPage.origin && (\n                    <span className=\"px-2 py-0.5 bg-bambu-dark rounded text-bambu-gray\">\n                      {projectPage.origin}\n                    </span>\n                  )}\n                </div>\n              </div>\n\n              {/* Description */}\n              {(projectPage.description || isEditing) && (\n                <div className=\"space-y-2\">\n                  <h4 className=\"text-sm font-medium text-bambu-gray uppercase tracking-wide\">\n                    Description\n                  </h4>\n                  {isEditing ? (\n                    <RichTextEditor\n                      content={editData.description || ''}\n                      onChange={(html) => setEditData({ ...editData, description: html })}\n                      placeholder=\"Enter description...\"\n                    />\n                  ) : (\n                    <div\n                      className=\"prose prose-invert prose-sm max-w-none text-bambu-gray-light\"\n                      dangerouslySetInnerHTML={{\n                        __html: sanitizeHtml(projectPage.description || ''),\n                      }}\n                    />\n                  )}\n                </div>\n              )}\n\n              {/* Profile Info */}\n              {(projectPage.profile_title || projectPage.profile_description || isEditing) && (\n                <div className=\"space-y-2 p-4 bg-bambu-dark rounded-lg\">\n                  <h4 className=\"text-sm font-medium text-bambu-gray uppercase tracking-wide\">\n                    Print Profile\n                  </h4>\n                  {isEditing ? (\n                    <div className=\"space-y-2\">\n                      <input\n                        type=\"text\"\n                        value={editData.profile_title || ''}\n                        onChange={(e) => setEditData({ ...editData, profile_title: e.target.value })}\n                        placeholder=\"Profile Title\"\n                        className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-white\"\n                      />\n                      <RichTextEditor\n                        content={editData.profile_description || ''}\n                        onChange={(html) => setEditData({ ...editData, profile_description: html })}\n                        placeholder=\"Profile description...\"\n                      />\n                    </div>\n                  ) : (\n                    <>\n                      {projectPage.profile_title && (\n                        <p className=\"text-white font-medium\">{projectPage.profile_title}</p>\n                      )}\n                      {projectPage.profile_description && (\n                        <div\n                          className=\"prose prose-invert prose-sm max-w-none text-bambu-gray-light\"\n                          dangerouslySetInnerHTML={{\n                            __html: sanitizeHtml(projectPage.profile_description),\n                          }}\n                        />\n                      )}\n                      {projectPage.profile_user_name && (\n                        <p className=\"text-sm text-bambu-gray\">\n                          by {projectPage.profile_user_name}\n                        </p>\n                      )}\n                    </>\n                  )}\n                </div>\n              )}\n\n              {/* Image Gallery */}\n              {allImages.length > 0 && (\n                <div className=\"space-y-2\">\n                  <h4 className=\"text-sm font-medium text-bambu-gray uppercase tracking-wide flex items-center gap-2\">\n                    <Image className=\"w-4 h-4\" />\n                    Images ({allImages.length})\n                  </h4>\n                  <div className=\"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2\">\n                    {allImages.map((img, index) => (\n                      <button\n                        key={img.path}\n                        onClick={() => setSelectedImageIndex(index)}\n                        className=\"aspect-square rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors\"\n                      >\n                        <img\n                          src={img.url}\n                          alt={img.name}\n                          className=\"w-full h-full object-cover\"\n                        />\n                      </button>\n                    ))}\n                  </div>\n                </div>\n              )}\n\n              {/* MakerWorld Link */}\n              {projectPage.design_model_id && (\n                <div className=\"pt-4 border-t border-bambu-dark-tertiary\">\n                  <a\n                    href={`https://makerworld.com/en/models/${projectPage.design_model_id}`}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"inline-flex items-center gap-2 text-bambu-green hover:underline\"\n                  >\n                    <ExternalLink className=\"w-4 h-4\" />\n                    View on MakerWorld\n                  </a>\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Image Lightbox */}\n      {selectedImageIndex !== null && allImages[selectedImageIndex] && (\n        <div\n          className=\"fixed inset-0 bg-black/90 flex items-center justify-center z-60\"\n          onClick={() => setSelectedImageIndex(null)}\n        >\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              setSelectedImageIndex(Math.max(0, selectedImageIndex - 1));\n            }}\n            disabled={selectedImageIndex === 0}\n            className=\"absolute left-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30\"\n          >\n            <ChevronLeft className=\"w-6 h-6 text-white\" />\n          </button>\n\n          <img\n            src={allImages[selectedImageIndex].url}\n            alt={allImages[selectedImageIndex].name}\n            className=\"max-w-[90vw] max-h-[90vh] object-contain\"\n            onClick={(e) => e.stopPropagation()}\n          />\n\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              setSelectedImageIndex(Math.min(allImages.length - 1, selectedImageIndex + 1));\n            }}\n            disabled={selectedImageIndex === allImages.length - 1}\n            className=\"absolute right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30\"\n          >\n            <ChevronRight className=\"w-6 h-6 text-white\" />\n          </button>\n\n          <button\n            onClick={() => setSelectedImageIndex(null)}\n            className=\"absolute top-4 right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary\"\n          >\n            <X className=\"w-6 h-6 text-white\" />\n          </button>\n\n          <div className=\"absolute bottom-4 text-white text-sm\">\n            {selectedImageIndex + 1} / {allImages.length}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/QRCodeModal.tsx",
    "content": "import { useEffect } from 'react';\nimport { X, Download } from 'lucide-react';\nimport { Button } from './Button';\nimport { api } from '../api/client';\n\ninterface QRCodeModalProps {\n  archiveId: number;\n  archiveName: string;\n  onClose: () => void;\n}\n\nexport function QRCodeModal({ archiveId, archiveName, onClose }: QRCodeModalProps) {\n  const qrCodeUrl = api.getArchiveQRCodeUrl(archiveId, 300);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  const handleDownload = () => {\n    const link = document.createElement('a');\n    link.href = qrCodeUrl;\n    link.download = `${archiveName}_qrcode.png`;\n    link.click();\n  };\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n      onClick={onClose}\n    >\n      <div\n        className=\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-sm\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white\">QR Code</h2>\n          <button\n            onClick={onClose}\n            className=\"text-bambu-gray hover:text-white transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Content */}\n        <div className=\"p-6 flex flex-col items-center\">\n          <p className=\"text-sm text-bambu-gray mb-4 text-center truncate max-w-full\">\n            {archiveName}\n          </p>\n          <div className=\"bg-white p-4 rounded-lg mb-4\">\n            <img\n              src={qrCodeUrl}\n              alt=\"QR Code\"\n              className=\"w-64 h-64\"\n            />\n          </div>\n          <p className=\"text-xs text-bambu-gray mb-4 text-center\">\n            Scan to open this archive\n          </p>\n          <Button onClick={handleDownload} className=\"w-full\">\n            <Download className=\"w-4 h-4\" />\n            Download QR Code\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/QueueStatsBar.tsx",
    "content": "import { Play, Clock, Timer, Weight, CheckCircle } from 'lucide-react';\nimport { formatDuration } from '../utils/date';\n\nfunction formatWeight(g: number): string {\n  if (g >= 1000) return `${(g / 1000).toFixed(1)}kg`;\n  return `${Math.round(g)}g`;\n}\n\nexport function QueueStatsBar({\n  activeCount,\n  pendingCount,\n  totalTime,\n  totalWeight,\n  historyCount,\n  t,\n}: {\n  activeCount: number;\n  pendingCount: number;\n  totalTime: number;\n  totalWeight: number;\n  historyCount: number;\n  t: (key: string) => string;\n}) {\n  const stats = [\n    { icon: Play, value: activeCount, label: t('queue.summary.printing'), color: 'text-blue-400' },\n    { icon: Clock, value: pendingCount, label: t('queue.summary.queued'), color: 'text-yellow-400' },\n    { icon: Timer, value: formatDuration(totalTime), label: t('queue.summary.totalTime'), color: 'text-bambu-green' },\n    { icon: Weight, value: formatWeight(totalWeight), label: t('queue.summary.totalWeight'), color: 'text-purple-400' },\n    { icon: CheckCircle, value: historyCount, label: t('queue.summary.history'), color: 'text-bambu-gray' },\n  ];\n\n  return (\n    <div className=\"flex items-center gap-3 sm:gap-5 flex-wrap px-4 py-3 bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary mb-6\">\n      {stats.map((stat, i) => (\n        <div key={i} className=\"flex items-center gap-3\">\n          {i > 0 && <span className=\"hidden sm:block text-bambu-dark-tertiary\">|</span>}\n          <div className=\"flex items-center gap-1.5\">\n            <stat.icon className={`w-4 h-4 ${stat.color}`} />\n            <span className=\"text-sm font-semibold text-white\">{stat.value}</span>\n            <span className=\"text-xs sm:text-sm text-bambu-gray\">{stat.label}</span>\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/QueueTimelineView.tsx",
    "content": "import { useState, useMemo, useEffect } from 'react';\nimport { ChevronLeft, ChevronRight, Clock, Layers, Printer as PrinterIcon } from 'lucide-react';\nimport { formatDuration, parseUTCDate } from '../utils/date';\nimport type { PrintQueueItem } from '../api/client';\nimport { api } from '../api/client';\nimport { Button } from './Button';\n\ntype FilterMode = 'all' | 'printing' | 'queued';\n\ninterface ScheduleEvent {\n  item: PrintQueueItem;\n  estimatedEnd: Date;\n  estimatedStart: Date;\n  progress?: number;\n  type: 'printing' | 'queued';\n}\n\ninterface QueueTimelineViewProps {\n  queueItems: PrintQueueItem[];\n  printerStatuses: Record<number, { progress?: number; remaining_time?: number; state?: string }>;\n  onItemClick: (item: PrintQueueItem) => void;\n  t: (key: string, options?: Record<string, unknown>) => string;\n}\n\nfunction getStartOfDay(date: Date): Date {\n  const d = new Date(date);\n  d.setHours(0, 0, 0, 0);\n  return d;\n}\n\nfunction formatDateLabel(date: Date): string {\n  return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });\n}\n\nfunction formatTimeOnly(date: Date): string {\n  return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });\n}\n\nfunction formatTimeLeft(ms: number, t: (key: string, opts?: Record<string, unknown>) => string): string {\n  if (ms <= 0) return t('queue.timeline.time.anyMoment');\n  const totalMin = Math.round(ms / 60000);\n  if (totalMin < 60) return t('queue.timeline.time.minutesLeft', { minutes: totalMin });\n  const hours = Math.floor(totalMin / 60);\n  const mins = totalMin % 60;\n  if (mins === 0) return t('queue.timeline.time.hoursLeft', { hours });\n  return t('queue.timeline.time.hoursMinutesLeft', { hours, minutes: mins });\n}\n\nfunction getHourLabel(hour: number): string {\n  const date = new Date();\n  date.setHours(hour, 0, 0, 0);\n  return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });\n}\n\nfunction ScheduleCard({\n  event,\n  now,\n  onItemClick,\n  t,\n}: {\n  event: ScheduleEvent;\n  now: Date;\n  onItemClick: (item: PrintQueueItem) => void;\n  t: (key: string, opts?: Record<string, unknown>) => string;\n}) {\n  const item = event.item;\n  const displayName = item.archive_name || item.library_file_name || t('common.unknown');\n  const printerName = item.printer_name || (item.target_model ? `${t('queue.filter.any')} ${item.target_model}` : t('queue.timeline.unassigned'));\n  const isPrinting = event.type === 'printing';\n  const timeLeft = event.estimatedEnd.getTime() - now.getTime();\n\n  const thumbnailUrl = item.archive_thumbnail\n    ? api.getArchiveThumbnail(item.archive_id!)\n    : item.library_file_thumbnail\n      ? api.getLibraryFileThumbnailUrl(item.library_file_id!)\n      : null;\n\n  return (\n    <div\n      className={`flex items-center gap-3 px-3 sm:px-4 py-3 bg-bambu-dark-secondary rounded-xl border cursor-pointer transition-all hover:border-bambu-green/40\n        ${isPrinting ? 'border-blue-500/30' : 'border-bambu-dark-tertiary'}`}\n      onClick={() => onItemClick(item)}\n    >\n      {/* Left accent */}\n      <div className={`w-1 self-stretch rounded-full shrink-0 ${isPrinting ? 'bg-blue-500' : 'bg-bambu-green/40'}`} />\n\n      {/* Thumbnail */}\n      <div className=\"w-10 h-10 shrink-0 bg-bambu-dark rounded-lg overflow-hidden\">\n        {thumbnailUrl ? (\n          <img src={thumbnailUrl} alt=\"\" className=\"w-full h-full object-cover\" />\n        ) : (\n          <div className=\"w-full h-full flex items-center justify-center text-bambu-gray\">\n            <Layers className=\"w-5 h-5\" />\n          </div>\n        )}\n      </div>\n\n      {/* Info */}\n      <div className=\"flex-1 min-w-0\">\n        <p className=\"text-sm text-white font-medium truncate\">{displayName}</p>\n        <div className=\"flex items-center gap-2 mt-0.5\">\n          <span className=\"flex items-center gap-1 text-xs text-bambu-gray\">\n            <PrinterIcon className=\"w-3 h-3\" />\n            <span className=\"truncate max-w-[120px] sm:max-w-none\">{printerName}</span>\n          </span>\n          {item.print_time_seconds && (\n            <span className=\"hidden sm:inline text-xs text-bambu-gray\">\n              {formatDuration(item.print_time_seconds)}\n            </span>\n          )}\n        </div>\n\n        {/* Progress bar for active prints */}\n        {isPrinting && event.progress != null && (\n          <div className=\"flex items-center gap-2 mt-1.5\">\n            <div className=\"flex-1 bg-bambu-dark-tertiary rounded-full h-1.5\">\n              <div\n                className=\"bg-blue-500 h-1.5 rounded-full transition-all\"\n                style={{ width: `${event.progress}%` }}\n              />\n            </div>\n            <span className=\"text-xs text-blue-400 shrink-0\">{Math.round(event.progress)}%</span>\n          </div>\n        )}\n      </div>\n\n      {/* Time info */}\n      <div className=\"text-right shrink-0\">\n        <p className=\"text-sm text-white font-medium\">{formatTimeOnly(event.estimatedEnd)}</p>\n        <p className={`text-xs mt-0.5 ${isPrinting ? 'text-blue-400' : 'text-bambu-gray'}`}>\n          {formatTimeLeft(timeLeft, t)}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function QueueTimelineView({\n  queueItems,\n  printerStatuses,\n  onItemClick,\n  t,\n}: QueueTimelineViewProps) {\n  const [viewDate, setViewDate] = useState(() => getStartOfDay(new Date()));\n  const [now, setNow] = useState(() => new Date());\n  const [filter, setFilter] = useState<FilterMode>('all');\n\n  // Update \"now\" every 60 seconds\n  useEffect(() => {\n    const interval = setInterval(() => setNow(new Date()), 60000);\n    return () => clearInterval(interval);\n  }, []);\n\n  const nowMs = now.getTime();\n  const isToday = getStartOfDay(new Date()).getTime() === getStartOfDay(viewDate).getTime();\n\n  // Build schedule events with ETA chaining\n  const events = useMemo(() => {\n    const result: ScheduleEvent[] = [];\n\n    // Group pending items by printer for chaining\n    const pendingByPrinter = new Map<number | null, PrintQueueItem[]>();\n\n    for (const item of queueItems) {\n      if (item.status === 'printing') {\n        const status = item.printer_id != null ? printerStatuses[item.printer_id] : undefined;\n        const start = parseUTCDate(item.started_at) || new Date();\n        let endTime: Date;\n\n        if (status?.remaining_time != null && status.remaining_time > 0) {\n          endTime = new Date(nowMs + status.remaining_time * 60 * 1000);\n        } else if (item.print_time_seconds) {\n          const progress = status?.progress || 0;\n          const remainingFraction = Math.max(0, 1 - progress / 100);\n          endTime = new Date(nowMs + item.print_time_seconds * remainingFraction * 1000);\n        } else {\n          endTime = new Date(nowMs + 3600000);\n        }\n\n        result.push({\n          item,\n          estimatedStart: start,\n          estimatedEnd: endTime,\n          progress: status?.progress ?? undefined,\n          type: 'printing',\n        });\n      } else if (item.status === 'pending') {\n        const pid = item.printer_id;\n        if (!pendingByPrinter.has(pid)) pendingByPrinter.set(pid, []);\n        pendingByPrinter.get(pid)!.push(item);\n      }\n    }\n\n    // Chain pending items per printer\n    for (const [printerId, items] of pendingByPrinter) {\n      items.sort((a, b) => a.position - b.position);\n\n      // Find when the current active print on this printer ends\n      let chainEnd = nowMs;\n      for (const ev of result) {\n        if (ev.item.printer_id === printerId && ev.type === 'printing') {\n          chainEnd = Math.max(chainEnd, ev.estimatedEnd.getTime());\n        }\n      }\n\n      for (const item of items) {\n        // Respect scheduled_time\n        const scheduledTime = parseUTCDate(item.scheduled_time);\n        if (scheduledTime) {\n          const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);\n          if (scheduledTime.getTime() <= sixMonthsFromNow) {\n            chainEnd = Math.max(chainEnd, scheduledTime.getTime());\n          }\n        }\n\n        const duration = (item.print_time_seconds || 3600) * 1000;\n        const startTime = new Date(chainEnd);\n        const endTime = new Date(chainEnd + duration);\n\n        result.push({\n          item,\n          estimatedStart: startTime,\n          estimatedEnd: endTime,\n          type: 'queued',\n        });\n\n        chainEnd = endTime.getTime();\n      }\n    }\n\n    // Sort by estimated end time\n    result.sort((a, b) => a.estimatedEnd.getTime() - b.estimatedEnd.getTime());\n\n    return result;\n  }, [queueItems, printerStatuses, nowMs]);\n\n  // Filter events for the selected day\n  const viewDayStart = getStartOfDay(viewDate).getTime();\n  const viewDayEnd = viewDayStart + 24 * 60 * 60 * 1000 - 1;\n\n  const filteredEvents = useMemo(() => {\n    return events.filter(ev => {\n      // Event finishes within the viewed day\n      const endMs = ev.estimatedEnd.getTime();\n      if (endMs < viewDayStart || endMs > viewDayEnd) return false;\n\n      // Filter by type\n      if (filter === 'printing') return ev.type === 'printing';\n      if (filter === 'queued') return ev.type === 'queued';\n      return true;\n    });\n  }, [events, viewDayStart, viewDayEnd, filter]);\n\n  // Group events by hour for time markers\n  const groupedByHour = useMemo(() => {\n    const groups: Map<number, ScheduleEvent[]> = new Map();\n    for (const ev of filteredEvents) {\n      const hour = ev.estimatedEnd.getHours();\n      if (!groups.has(hour)) groups.set(hour, []);\n      groups.get(hour)!.push(ev);\n    }\n    // Sort by hour\n    return Array.from(groups.entries()).sort(([a], [b]) => a - b);\n  }, [filteredEvents]);\n\n  // Counts for filter tabs\n  const printingCount = events.filter(ev => ev.type === 'printing' && ev.estimatedEnd.getTime() >= viewDayStart && ev.estimatedEnd.getTime() <= viewDayEnd).length;\n  const queuedCount = events.filter(ev => ev.type === 'queued' && ev.estimatedEnd.getTime() >= viewDayStart && ev.estimatedEnd.getTime() <= viewDayEnd).length;\n\n  // Overall completion estimate\n  const allDoneBy = useMemo(() => {\n    let latest = 0;\n    for (const ev of events) {\n      latest = Math.max(latest, ev.estimatedEnd.getTime());\n    }\n    return latest > 0 ? new Date(latest) : null;\n  }, [events]);\n\n  const goToday = () => setViewDate(getStartOfDay(new Date()));\n  const goPrev = () => {\n    const d = new Date(viewDate);\n    d.setDate(d.getDate() - 1);\n    setViewDate(d);\n  };\n  const goNext = () => {\n    const d = new Date(viewDate);\n    d.setDate(d.getDate() + 1);\n    setViewDate(d);\n  };\n\n  const filterTabs: { key: FilterMode; label: string; count: number }[] = [\n    { key: 'all', label: t('queue.timeline.filterAll'), count: printingCount + queuedCount },\n    { key: 'printing', label: t('queue.timeline.filterPrinting'), count: printingCount },\n    { key: 'queued', label: t('queue.timeline.filterQueued'), count: queuedCount },\n  ];\n\n  return (\n    <div>\n      {/* Header */}\n      <div className=\"flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5\">\n        {/* Day navigation */}\n        <div className=\"flex items-center gap-2\">\n          <Button variant=\"ghost\" size=\"sm\" onClick={goPrev} className=\"p-1.5\">\n            <ChevronLeft className=\"w-4 h-4\" />\n          </Button>\n          <span className=\"text-sm font-medium text-white min-w-[140px] text-center\">\n            {formatDateLabel(viewDate)}\n          </span>\n          <Button variant=\"ghost\" size=\"sm\" onClick={goNext} className=\"p-1.5\">\n            <ChevronRight className=\"w-4 h-4\" />\n          </Button>\n          {!isToday && (\n            <Button variant=\"ghost\" size=\"sm\" onClick={goToday} className=\"text-xs text-bambu-green\">\n              {t('queue.timeline.day.today')}\n            </Button>\n          )}\n        </div>\n\n        {allDoneBy && (\n          <span className=\"text-xs text-bambu-gray flex items-center gap-1.5\">\n            <Clock className=\"w-3.5 h-3.5\" />\n            {t('queue.timeline.allDoneBy', {\n              time: allDoneBy.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }),\n            })}\n          </span>\n        )}\n      </div>\n\n      {/* Filter tabs */}\n      <div className=\"flex gap-2 mb-5\">\n        {filterTabs.map((tab) => (\n          <button\n            key={tab.key}\n            onClick={() => setFilter(tab.key)}\n            className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${\n              filter === tab.key\n                ? 'bg-bambu-green text-white'\n                : 'bg-bambu-dark-secondary border border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n            }`}\n          >\n            {tab.label}\n            {tab.count > 0 && (\n              <span className={`ml-1.5 text-xs ${filter === tab.key ? 'text-white/70' : 'text-bambu-gray'}`}>\n                {tab.count}\n              </span>\n            )}\n          </button>\n        ))}\n      </div>\n\n      {/* Schedule feed */}\n      {groupedByHour.length > 0 ? (\n        <div className=\"space-y-6\">\n          {groupedByHour.map(([hour, hourEvents]) => (\n            <div key={hour}>\n              {/* Hour marker */}\n              <div className=\"flex items-center gap-3 mb-3\">\n                <span className=\"text-xs font-medium text-bambu-gray w-14 shrink-0\">\n                  {getHourLabel(hour)}\n                </span>\n                <div className=\"flex-1 h-px bg-bambu-dark-tertiary\" />\n              </div>\n\n              {/* Events in this hour */}\n              <div className=\"space-y-2 sm:ml-[68px]\">\n                {hourEvents.map((event) => (\n                  <ScheduleCard\n                    key={event.item.id}\n                    event={event}\n                    now={now}\n                    onItemClick={onItemClick}\n                    t={t}\n                  />\n                ))}\n              </div>\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div className=\"flex flex-col items-center justify-center py-16 text-bambu-gray\">\n          <Layers className=\"w-12 h-12 mb-3 opacity-30\" />\n          <p className=\"text-sm\">{t('queue.timeline.noData')}</p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/RestoreModal.tsx",
    "content": "import { useState, useRef, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Upload, X, AlertTriangle, CheckCircle, SkipForward, RefreshCw, Loader2, ChevronDown, ChevronUp } from 'lucide-react';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { Toggle } from './Toggle';\n\ninterface RestoreResult {\n  success: boolean;\n  message: string;\n  restored?: Record<string, number>;\n  skipped?: Record<string, number>;\n  skipped_details?: Record<string, string[]>;\n  files_restored?: number;\n  total_skipped?: number;\n  new_api_keys?: Array<{ name: string; key: string; key_prefix: string }>;\n}\n\ninterface RestoreModalProps {\n  onClose: () => void;\n  onRestore: (file: File, overwrite: boolean) => Promise<RestoreResult>;\n  onSuccess: () => void;\n}\n\ntype ModalState = 'options' | 'restoring' | 'result';\n\nexport function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {\n  const { t } = useTranslation();\n  const [state, setState] = useState<ModalState>('options');\n  const [overwrite, setOverwrite] = useState(false);\n  const [selectedFile, setSelectedFile] = useState<File | null>(null);\n  const [result, setResult] = useState<RestoreResult | null>(null);\n  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && state !== 'restoring') {\n        // Use handleClose for result state to trigger onSuccess\n        if (state === 'result' && result?.success) {\n          onSuccess();\n        }\n        onClose();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose, onSuccess, state, result]);\n\n  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (file) {\n      setSelectedFile(file);\n    }\n  };\n\n  const handleRestore = async () => {\n    if (!selectedFile) return;\n\n    setState('restoring');\n    try {\n      const restoreResult = await onRestore(selectedFile, overwrite);\n      setResult(restoreResult);\n      setState('result');\n      // Don't call onSuccess here - wait until modal closes\n      // This prevents race condition with query cache\n    } catch {\n      setResult({\n        success: false,\n        message: t('backup.failedToRestore'),\n      });\n      setState('result');\n    }\n  };\n\n  const handleClose = () => {\n    // If restore was successful, trigger refresh before closing\n    if (result?.success) {\n      onSuccess();\n    }\n    onClose();\n  };\n\n  const toggleCategory = (category: string) => {\n    setExpandedCategories(prev => {\n      const next = new Set(prev);\n      if (next.has(category)) {\n        next.delete(category);\n      } else {\n        next.add(category);\n      }\n      return next;\n    });\n  };\n\n  const totalRestored = result?.restored\n    ? Object.values(result.restored).reduce((a, b) => a + b, 0) + (result.files_restored || 0)\n    : 0;\n  const totalSkipped = result?.total_skipped || 0;\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\"\n      onMouseDown={(e) => {\n        // Only close if clicking directly on the backdrop, not on children\n        if (e.target === e.currentTarget && state !== 'restoring') {\n          onClose();\n        }\n      }}\n    >\n      <Card className=\"w-full max-w-lg\">\n        <CardContent className=\"p-0\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <div className=\"flex items-center gap-3\">\n              <div className={`p-2 rounded-full ${\n                state === 'result' && result?.success\n                  ? 'bg-bambu-green/20 text-bambu-green'\n                  : state === 'result' && !result?.success\n                  ? 'bg-red-500/20 text-red-500'\n                  : 'bg-blue-500/20 text-blue-500'\n              }`}>\n                {state === 'result' && result?.success ? (\n                  <CheckCircle className=\"w-5 h-5\" />\n                ) : state === 'result' && !result?.success ? (\n                  <AlertTriangle className=\"w-5 h-5\" />\n                ) : (\n                  <Upload className=\"w-5 h-5\" />\n                )}\n              </div>\n              <div>\n                <h3 className=\"text-lg font-semibold text-white\">\n                  {state === 'options' && t('backup.restoreBackup')}\n                  {state === 'restoring' && t('backup.restoring')}\n                  {state === 'result' && (result?.success ? t('backup.restoreComplete') : t('backup.restoreFailed2'))}\n                </h3>\n                <p className=\"text-sm text-bambu-gray\">\n                  {state === 'options' && t('backup.importSettings')}\n                  {state === 'restoring' && t('backup.pleaseWaitRestoring')}\n                  {state === 'result' && result?.message}\n                </p>\n              </div>\n            </div>\n            {state !== 'restoring' && (\n              <button\n                onClick={handleClose}\n                className=\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n              >\n                <X className=\"w-5 h-5\" />\n              </button>\n            )}\n          </div>\n\n          {/* Options State */}\n          {state === 'options' && (\n            <>\n              <div className=\"p-4 space-y-4\">\n                {/* File Selection */}\n                <div>\n                  <input\n                    ref={fileInputRef}\n                    type=\"file\"\n                    accept=\".json,.zip\"\n                    className=\"hidden\"\n                    onChange={handleFileSelect}\n                  />\n                  <button\n                    type=\"button\"\n                    onClick={() => fileInputRef.current?.click()}\n                    className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${\n                      selectedFile\n                        ? 'border-bambu-green bg-bambu-green/10'\n                        : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n                    }`}\n                  >\n                    {selectedFile ? (\n                      <div className=\"flex items-center justify-center gap-2 text-bambu-green\">\n                        <CheckCircle className=\"w-5 h-5\" />\n                        <span className=\"font-medium\">{selectedFile.name}</span>\n                      </div>\n                    ) : (\n                      <div className=\"flex flex-col items-center gap-2 text-bambu-gray\">\n                        <Upload className=\"w-8 h-8\" />\n                        <span>{t('backup.selectBackupFile')}</span>\n                      </div>\n                    )}\n                  </button>\n                </div>\n\n                {/* Info Box */}\n                <div className=\"p-3 rounded-lg bg-blue-500/10 border border-blue-500/30\">\n                  <div className=\"flex items-start gap-2 text-sm\">\n                    <AlertTriangle className=\"w-4 h-4 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0\" />\n                    <div className=\"text-blue-700 dark:text-blue-200\">\n                      <p className=\"font-medium mb-1\">{t('backup.duplicateHandling')}</p>\n                      <ul className=\"text-blue-600 dark:text-blue-200/80 space-y-1 text-xs\">\n                        <li><strong>{t('backup.matchPrinters')}</strong> - {t('backup.matchPrintersBy')}</li>\n                        <li><strong>{t('backup.matchSmartPlugs')}</strong> - {t('backup.matchSmartPlugsBy')}</li>\n                        <li><strong>{t('backup.matchNotificationProviders')}</strong> - {t('backup.matchNotificationProvidersBy')}</li>\n                        <li><strong>{t('backup.matchFilaments')}</strong> - {t('backup.matchFilamentsBy')}</li>\n                        <li><strong>{t('backup.matchArchives')}</strong> - {t('backup.matchArchivesBy')}</li>\n                        <li><strong>{t('backup.matchPendingUploads')}</strong> - {t('backup.matchPendingUploadsBy')}</li>\n                        <li><strong>{t('backup.matchSettingsTemplates')}</strong> - {t('backup.matchSettingsTemplatesBy')}</li>\n                      </ul>\n                    </div>\n                  </div>\n                </div>\n\n                {/* Overwrite Toggle */}\n                <div className=\"p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary\">\n                  <div className=\"flex items-center justify-between\">\n                    <div>\n                      <p className=\"text-white font-medium flex items-center gap-2\">\n                        {overwrite ? (\n                          <RefreshCw className=\"w-4 h-4 text-orange-400\" />\n                        ) : (\n                          <SkipForward className=\"w-4 h-4 text-bambu-gray\" />\n                        )}\n                        {overwrite ? t('backup.replaceExisting') : t('backup.keepExisting')}\n                      </p>\n                      <p className=\"text-sm text-bambu-gray mt-1\">\n                        {overwrite\n                          ? t('backup.overwriteDescription')\n                          : t('backup.keepDescription')}\n                      </p>\n                    </div>\n                    <Toggle checked={overwrite} onChange={setOverwrite} />\n                  </div>\n                </div>\n\n                {overwrite && (\n                  <div className=\"p-3 rounded-lg bg-orange-500/10 border border-orange-500/30\">\n                    <div className=\"flex items-start gap-2 text-sm\">\n                      <AlertTriangle className=\"w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0\" />\n                      <div className=\"text-orange-700 dark:text-orange-200\">\n                        <span className=\"font-medium\">{t('backup.overwriteCaution')}</span> {t('backup.overwriteWarning')}\n                      </div>\n                    </div>\n                  </div>\n                )}\n              </div>\n\n              {/* Footer */}\n              <div className=\"flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary\">\n                <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n                  {t('backup.cancel')}\n                </Button>\n                <Button\n                  type=\"button\"\n                  onClick={handleRestore}\n                  disabled={!selectedFile}\n                  className=\"bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50\"\n                >\n                  <Upload className=\"w-4 h-4 mr-2\" />\n                  {t('backup.restore')}\n                </Button>\n              </div>\n            </>\n          )}\n\n          {/* Restoring State */}\n          {state === 'restoring' && (\n            <div className=\"p-8 flex flex-col items-center gap-4\">\n              <Loader2 className=\"w-12 h-12 text-bambu-green animate-spin\" />\n              <p className=\"text-bambu-gray\">{t('backup.processingBackup')}</p>\n            </div>\n          )}\n\n          {/* Result State */}\n          {state === 'result' && result && (\n            <>\n              <div className=\"p-4 space-y-4 max-h-[400px] overflow-y-auto\">\n                {/* Summary */}\n                <div className=\"grid grid-cols-2 gap-3\">\n                  <div className=\"p-3 rounded-lg bg-bambu-green/10 border border-bambu-green/30\">\n                    <div className=\"text-2xl font-bold text-bambu-green\">{totalRestored}</div>\n                    <div className=\"text-sm text-bambu-gray\">{t('backup.itemsRestored')}</div>\n                  </div>\n                  <div className=\"p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30\">\n                    <div className=\"text-2xl font-bold text-yellow-500\">{totalSkipped}</div>\n                    <div className=\"text-sm text-bambu-gray\">{t('backup.itemsSkipped')}</div>\n                  </div>\n                </div>\n\n                {/* Restored Details */}\n                {result.restored && Object.entries(result.restored).some(([, count]) => count > 0) && (\n                  <div className=\"space-y-2\">\n                    <h4 className=\"text-sm font-medium text-bambu-gray flex items-center gap-2\">\n                      <CheckCircle className=\"w-4 h-4 text-bambu-green\" />\n                      {t('backup.restored')}\n                    </h4>\n                    <div className=\"space-y-1\">\n                      {Object.entries(result.restored)\n                        .filter(([, count]) => count > 0)\n                        .map(([key, count]) => (\n                          <div key={key} className=\"flex items-center justify-between text-sm p-2 rounded bg-bambu-dark\">\n                            <span className=\"text-white\">{t(`backup.categories.${key}`, key)}</span>\n                            <span className=\"text-bambu-green font-medium\">{count}</span>\n                          </div>\n                        ))}\n                      {(result.files_restored || 0) > 0 && (\n                        <div className=\"flex items-center justify-between text-sm p-2 rounded bg-bambu-dark\">\n                          <span className=\"text-white\">{t('backup.filesCategory')}</span>\n                          <span className=\"text-bambu-green font-medium\">{result.files_restored}</span>\n                        </div>\n                      )}\n                    </div>\n                  </div>\n                )}\n\n                {/* Skipped Details */}\n                {result.skipped && Object.entries(result.skipped).some(([, count]) => count > 0) && (\n                  <div className=\"space-y-2\">\n                    <h4 className=\"text-sm font-medium text-bambu-gray flex items-center gap-2\">\n                      <SkipForward className=\"w-4 h-4 text-yellow-500\" />\n                      {t('backup.skippedAlreadyExist')}\n                    </h4>\n                    <div className=\"space-y-1\">\n                      {Object.entries(result.skipped)\n                        .filter(([, count]) => count > 0)\n                        .map(([key, count]) => {\n                          const details = result.skipped_details?.[key] || [];\n                          const isExpanded = expandedCategories.has(key);\n                          return (\n                            <div key={key}>\n                              <button\n                                onClick={() => details.length > 0 && toggleCategory(key)}\n                                className={`w-full flex items-center justify-between text-sm p-2 rounded bg-bambu-dark ${\n                                  details.length > 0 ? 'hover:bg-bambu-dark-tertiary cursor-pointer' : ''\n                                }`}\n                              >\n                                <span className=\"text-white flex items-center gap-2\">\n                                  {t(`backup.categories.${key}`, key)}\n                                  {details.length > 0 && (\n                                    isExpanded ? <ChevronUp className=\"w-3 h-3\" /> : <ChevronDown className=\"w-3 h-3\" />\n                                  )}\n                                </span>\n                                <span className=\"text-yellow-500 font-medium\">{count}</span>\n                              </button>\n                              {isExpanded && details.length > 0 && (\n                                <div className=\"mt-1 ml-4 p-2 rounded bg-bambu-dark-tertiary text-xs text-bambu-gray space-y-1\">\n                                  {details.slice(0, 10).map((item, i) => (\n                                    <div key={i}>{item}</div>\n                                  ))}\n                                  {details.length > 10 && (\n                                    <div className=\"text-bambu-gray/60\">{t('backup.andMore', { count: details.length - 10 })}</div>\n                                  )}\n                                </div>\n                              )}\n                            </div>\n                          );\n                        })}\n                    </div>\n                  </div>\n                )}\n\n                {/* Newly Generated API Keys */}\n                {result.new_api_keys && result.new_api_keys.length > 0 && (\n                  <div className=\"space-y-2\">\n                    <h4 className=\"text-sm font-medium text-bambu-gray flex items-center gap-2\">\n                      <AlertTriangle className=\"w-4 h-4 text-orange-500\" />\n                      {t('backup.newApiKeysGenerated')}\n                    </h4>\n                    <div className=\"p-3 rounded bg-orange-500/10 border border-orange-500/30\">\n                      <p className=\"text-xs text-orange-200 mb-2\">\n                        {t('backup.keysShownOnce')}\n                      </p>\n                      <div className=\"space-y-2\">\n                        {result.new_api_keys.map((apiKey: { name: string; key: string; key_prefix: string }, i: number) => (\n                          <div key={i} className=\"p-2 rounded bg-bambu-dark\">\n                            <div className=\"text-sm text-white font-medium mb-1\">{apiKey.name}</div>\n                            <div className=\"flex items-center gap-2\">\n                              <code className=\"text-xs text-bambu-green bg-bambu-dark-tertiary px-2 py-1 rounded font-mono flex-1 break-all\">\n                                {apiKey.key}\n                              </code>\n                              <button\n                                onClick={() => navigator.clipboard.writeText(apiKey.key)}\n                                className=\"text-xs text-bambu-gray hover:text-white px-2 py-1 rounded bg-bambu-dark-tertiary\"\n                              >\n                                {t('backup.copy')}\n                              </button>\n                            </div>\n                          </div>\n                        ))}\n                      </div>\n                    </div>\n                  </div>\n                )}\n\n                {totalRestored === 0 && totalSkipped === 0 && (\n                  <div className=\"p-4 text-center text-bambu-gray\">\n                    {t('backup.noDataFound')}\n                  </div>\n                )}\n              </div>\n\n              {/* Footer */}\n              <div className=\"flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary\">\n                <Button onClick={handleClose}>\n                  {t('backup.close')}\n                </Button>\n              </div>\n            </>\n          )}\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/RichTextEditor.tsx",
    "content": "import { useEditor, EditorContent } from '@tiptap/react';\nimport StarterKit from '@tiptap/starter-kit';\nimport Link from '@tiptap/extension-link';\nimport Underline from '@tiptap/extension-underline';\nimport TextAlign from '@tiptap/extension-text-align';\nimport { TextStyle } from '@tiptap/extension-text-style';\nimport Color from '@tiptap/extension-color';\nimport Image from '@tiptap/extension-image';\nimport {\n  Bold,\n  Italic,\n  Underline as UnderlineIcon,\n  List,\n  ListOrdered,\n  AlignLeft,\n  AlignCenter,\n  AlignRight,\n  Link as LinkIcon,\n  Unlink,\n} from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\n\ninterface RichTextEditorProps {\n  content: string;\n  onChange: (html: string) => void;\n  placeholder?: string;\n}\n\nexport function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {\n  const { t } = useTranslation();\n  const editor = useEditor({\n    extensions: [\n      StarterKit.configure({\n        heading: false,\n        codeBlock: false,\n        code: false,\n      }),\n      Underline,\n      Link.configure({\n        openOnClick: false,\n        HTMLAttributes: {\n          target: '_blank',\n          rel: 'noopener noreferrer',\n        },\n      }),\n      TextAlign.configure({\n        types: ['paragraph'],\n      }),\n      TextStyle,\n      Color,\n      Image.configure({\n        HTMLAttributes: {\n          style: 'max-width: 100%; height: auto;',\n        },\n      }),\n    ],\n    content,\n    onUpdate: ({ editor }) => {\n      onChange(editor.getHTML());\n    },\n    editorProps: {\n      attributes: {\n        class: 'prose prose-invert prose-sm max-w-none focus:outline-none min-h-[120px] px-3 py-2',\n        placeholder: placeholder || '',\n      },\n    },\n  });\n\n  if (!editor) {\n    return null;\n  }\n\n  const ToolbarButton = ({\n    onClick,\n    isActive = false,\n    children,\n    title,\n  }: {\n    onClick: () => void;\n    isActive?: boolean;\n    children: React.ReactNode;\n    title: string;\n  }) => (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      title={title}\n      className={`p-1.5 rounded hover:bg-bambu-dark-tertiary transition-colors ${\n        isActive ? 'bg-bambu-dark-tertiary text-bambu-green' : 'text-bambu-gray'\n      }`}\n    >\n      {children}\n    </button>\n  );\n\n  const setLink = () => {\n    const url = window.prompt('Enter URL:');\n    if (url) {\n      editor.chain().focus().setLink({ href: url }).run();\n    }\n  };\n\n  return (\n    <div className=\"border border-bambu-dark-tertiary rounded-lg overflow-hidden bg-bambu-dark\">\n      {/* Toolbar */}\n      <div className=\"flex items-center gap-0.5 p-1.5 border-b border-bambu-dark-tertiary bg-bambu-dark-secondary\">\n        <ToolbarButton\n          onClick={() => editor.chain().focus().toggleBold().run()}\n          isActive={editor.isActive('bold')}\n          title={t('richTextEditor.bold')}\n        >\n          <Bold className=\"w-4 h-4\" />\n        </ToolbarButton>\n        <ToolbarButton\n          onClick={() => editor.chain().focus().toggleItalic().run()}\n          isActive={editor.isActive('italic')}\n          title={t('richTextEditor.italic')}\n        >\n          <Italic className=\"w-4 h-4\" />\n        </ToolbarButton>\n        <ToolbarButton\n          onClick={() => editor.chain().focus().toggleUnderline().run()}\n          isActive={editor.isActive('underline')}\n          title={t('richTextEditor.underline')}\n        >\n          <UnderlineIcon className=\"w-4 h-4\" />\n        </ToolbarButton>\n\n        <div className=\"w-px h-5 bg-bambu-dark-tertiary mx-1\" />\n\n        <ToolbarButton\n          onClick={() => editor.chain().focus().toggleBulletList().run()}\n          isActive={editor.isActive('bulletList')}\n          title={t('richTextEditor.bulletList')}\n        >\n          <List className=\"w-4 h-4\" />\n        </ToolbarButton>\n        <ToolbarButton\n          onClick={() => editor.chain().focus().toggleOrderedList().run()}\n          isActive={editor.isActive('orderedList')}\n          title={t('richTextEditor.numberedList')}\n        >\n          <ListOrdered className=\"w-4 h-4\" />\n        </ToolbarButton>\n\n        <div className=\"w-px h-5 bg-bambu-dark-tertiary mx-1\" />\n\n        <ToolbarButton\n          onClick={() => editor.chain().focus().setTextAlign('left').run()}\n          isActive={editor.isActive({ textAlign: 'left' })}\n          title={t('richTextEditor.alignLeft')}\n        >\n          <AlignLeft className=\"w-4 h-4\" />\n        </ToolbarButton>\n        <ToolbarButton\n          onClick={() => editor.chain().focus().setTextAlign('center').run()}\n          isActive={editor.isActive({ textAlign: 'center' })}\n          title={t('richTextEditor.alignCenter')}\n        >\n          <AlignCenter className=\"w-4 h-4\" />\n        </ToolbarButton>\n        <ToolbarButton\n          onClick={() => editor.chain().focus().setTextAlign('right').run()}\n          isActive={editor.isActive({ textAlign: 'right' })}\n          title={t('richTextEditor.alignRight')}\n        >\n          <AlignRight className=\"w-4 h-4\" />\n        </ToolbarButton>\n\n        <div className=\"w-px h-5 bg-bambu-dark-tertiary mx-1\" />\n\n        <ToolbarButton\n          onClick={setLink}\n          isActive={editor.isActive('link')}\n          title={t('richTextEditor.addLink')}\n        >\n          <LinkIcon className=\"w-4 h-4\" />\n        </ToolbarButton>\n        {editor.isActive('link') && (\n          <ToolbarButton\n            onClick={() => editor.chain().focus().unsetLink().run()}\n            title={t('richTextEditor.removeLink')}\n          >\n            <Unlink className=\"w-4 h-4\" />\n          </ToolbarButton>\n        )}\n      </div>\n\n      {/* Editor */}\n      <EditorContent editor={editor} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/SkipObjectsModal.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery, useMutation } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Loader2, Monitor, AlertCircle, Box, Maximize2 } from 'lucide-react';\nimport { api, withStreamToken } from '../api/client';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { ConfirmModal } from './ConfirmModal';\n\n// Custom Skip Objects icon - arrow jumping over boxes\nexport const SkipObjectsIcon = ({ className }: { className?: string }) => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n    {/* Three boxes at the bottom */}\n    <rect x=\"2\" y=\"15\" width=\"5\" height=\"5\" rx=\"0.5\" />\n    <rect x=\"9.5\" y=\"15\" width=\"5\" height=\"5\" rx=\"0.5\" fill=\"currentColor\" opacity=\"0.3\" />\n    <rect x=\"17\" y=\"15\" width=\"5\" height=\"5\" rx=\"0.5\" />\n    {/* Curved arrow jumping over first box */}\n    <path d=\"M4 12 C4 6, 14 6, 14 12\" />\n    <polyline points=\"12,10 14,12 12,14\" />\n  </svg>\n);\n\ninterface SkipObjectsModalProps {\n  printerId: number;\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nexport function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);\n  const [enlarged, setEnlarged] = useState(false);\n\n  const { data: status } = useQuery({\n    queryKey: ['printerStatus', printerId],\n    queryFn: () => api.getPrinterStatus(printerId),\n    refetchInterval: 30000,\n    enabled: isOpen,\n  });\n\n  const { data: objectsData, refetch: refetchObjects } = useQuery({\n    queryKey: ['printableObjects', printerId],\n    queryFn: () => api.getPrintableObjects(printerId),\n    enabled: isOpen,\n    refetchInterval: isOpen ? 5000 : false,\n  });\n\n  const skipObjectsMutation = useMutation({\n    mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),\n    onSuccess: (data) => {\n      showToast(data.message || t('printers.skipObjects.objectsSkipped'));\n      setPendingSkip(null);\n      refetchObjects();\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),\n  });\n\n  if (!isOpen) return null;\n\n  return (\n    <>\n    <div\n      className=\"fixed inset-0 z-50 flex items-center justify-center\"\n      onClick={onClose}\n      onKeyDown={(e) => {\n        if (e.key === 'Escape') {\n          if (enlarged) setEnlarged(false);\n          else onClose();\n        }\n      }}\n      tabIndex={-1}\n      ref={(el) => el?.focus()}\n    >\n      {/* Backdrop */}\n      <div className=\"absolute inset-0 bg-black/50 z-0\" />\n      {/* Modal */}\n      <div\n        className=\"relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark\">\n          <div className=\"flex items-center gap-2\">\n            <SkipObjectsIcon className=\"w-4 h-4 text-bambu-green\" />\n            <span className=\"text-sm font-medium text-gray-900 dark:text-white\">{t('printers.skipObjects.title')}</span>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors\"\n          >\n            <X className=\"w-4 h-4\" />\n          </button>\n        </div>\n\n        {!objectsData ? (\n          <div className=\"flex items-center justify-center py-12\">\n            <Loader2 className=\"w-5 h-5 animate-spin text-bambu-gray\" />\n          </div>\n        ) : objectsData.objects.length === 0 ? (\n          <div className=\"text-center py-8 px-4 text-bambu-gray\">\n            <p className=\"text-sm\">{t('printers.noObjectsFound')}</p>\n            <p className=\"text-xs mt-1 opacity-70\">{t('printers.objectsLoadedOnPrintStart')}</p>\n          </div>\n        ) : (\n          <div className=\"flex flex-col overflow-hidden\">\n            {/* Info Banner */}\n            <div className=\"flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary\">\n              <div className=\"flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center\">\n                <Monitor className=\"w-4 h-4 text-blue-500 dark:text-blue-400\" />\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <p className=\"text-xs text-blue-600 dark:text-blue-300\">{t('printers.skipObjects.matchIdsInfo')}</p>\n                <p className=\"text-[10px] text-blue-500/70 dark:text-blue-300/60\">{t('printers.skipObjects.printerShowsIds')}</p>\n              </div>\n              <div className=\"flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray\">\n                {objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}\n              </div>\n            </div>\n\n            {/* Layer Warning */}\n            {(status?.layer_num ?? 0) <= 1 && (\n              <div className=\"flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary\">\n                <AlertCircle className=\"w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0\" />\n                <p className=\"text-xs text-amber-600 dark:text-amber-400\">\n                  {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}\n                </p>\n              </div>\n            )}\n\n            {/* Content: Image + List side by side */}\n            <div className=\"flex flex-1 overflow-hidden\">\n              {/* Left: Preview Image with object markers */}\n              <div className=\"w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto\">\n                <div className=\"relative cursor-pointer group\" onClick={() => setEnlarged(true)}>\n                  {status?.cover_url ? (\n                    <img\n                      src={withStreamToken(`${status.cover_url}?view=top`)}\n                      alt={t('printers.printPreview')}\n                      className=\"w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600\"\n                    />\n                  ) : (\n                    <div className=\"w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center\">\n                      <Box className=\"w-8 h-8 text-gray-300 dark:text-bambu-gray/30\" />\n                    </div>\n                  )}\n                  {/* Enlarge hint */}\n                  <div className=\"absolute top-2 right-2 p-1 bg-black/60 rounded opacity-0 group-hover:opacity-100 transition-opacity\">\n                    <Maximize2 className=\"w-3.5 h-3.5 text-white\" />\n                  </div>\n                  {/* Object ID markers overlay - positioned based on object data */}\n                  {objectsData.objects.length > 0 && (\n                    <div className=\"absolute inset-0 pointer-events-none\">\n                      {objectsData.objects.map((obj, idx) => {\n                        let x: number, y: number;\n\n                        // Use position data if available, otherwise fall back to grid\n                        if (obj.x != null && obj.y != null && objectsData.bbox_all) {\n                          // bbox_all defines the visible area in the top_N.png image\n                          // Format: [x_min, y_min, x_max, y_max] in mm\n                          const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;\n                          const bboxWidth = xMax - xMin;\n                          const bboxHeight = yMax - yMin;\n\n                          // The image shows bbox_all area with some padding (~5-10%)\n                          const padding = 8;\n                          const contentArea = 100 - (padding * 2);\n\n                          // Map object position to image percentage\n                          x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;\n                          // Y axis: image Y increases downward, but 3D Y increases toward back\n                          y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;\n\n                          // Clamp to valid range\n                          x = Math.max(5, Math.min(95, x));\n                          y = Math.max(5, Math.min(95, y));\n                        } else if (obj.x != null && obj.y != null) {\n                          // Fallback: use full build plate (256mm)\n                          const buildPlate = 256;\n                          x = (obj.x / buildPlate) * 100;\n                          y = 100 - (obj.y / buildPlate) * 100;\n                          x = Math.max(5, Math.min(95, x));\n                          y = Math.max(5, Math.min(95, y));\n                        } else {\n                          // Fallback: arrange in a grid pattern over the build plate area\n                          const cols = Math.ceil(Math.sqrt(objectsData.objects.length));\n                          const row = Math.floor(idx / cols);\n                          const col = idx % cols;\n                          const rows = Math.ceil(objectsData.objects.length / cols);\n                          x = 15 + (col * (70 / cols)) + (35 / cols);\n                          y = 15 + (row * (70 / rows)) + (35 / rows);\n                        }\n\n                        return (\n                          <div\n                            key={obj.id}\n                            className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${\n                              obj.skipped\n                                ? 'bg-red-500 text-white line-through'\n                                : 'bg-bambu-green text-black'\n                            }`}\n                            style={{\n                              left: `${x}%`,\n                              top: `${y}%`,\n                              transform: 'translate(-50%, -50%)'\n                            }}\n                            title={obj.name}\n                          >\n                            {obj.id}\n                          </div>\n                        );\n                      })}\n                    </div>\n                  )}\n                  {/* Object count overlay */}\n                  <div className=\"absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm\">\n                    {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}\n                  </div>\n                </div>\n              </div>\n\n              {/* Right: Object List with prominent IDs */}\n              <div className=\"flex-1 min-w-0 overflow-y-auto\">\n                {objectsData.objects.map((obj) => (\n                  <div\n                    key={obj.id}\n                    className={`\n                      flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0\n                      ${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}\n                    `}\n                  >\n                    {/* Large prominent ID badge */}\n                    <div className={`\n                      w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center\n                      ${obj.skipped\n                        ? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'\n                        : 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}\n                    `}>\n                      <span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>\n                        {obj.id}\n                      </span>\n                      <span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>\n                        ID\n                      </span>\n                    </div>\n\n                    {/* Object name and status */}\n                    <div className=\"flex-1 min-w-0\">\n                      <span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>\n                        {obj.name}\n                      </span>\n                      {obj.skipped && (\n                        <span className=\"text-[10px] text-red-400/60\">{t('printers.willBeSkipped')}</span>\n                      )}\n                    </div>\n\n                    {/* Skip button */}\n                    {!obj.skipped ? (\n                      <button\n                        onClick={() => setPendingSkip({ id: obj.id, name: obj.name })}\n                        disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}\n                        className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${\n                          (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')\n                            ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'\n                            : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'\n                        }`}\n                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}\n                      >\n                        {t('printers.skipObjects.skip')}\n                      </button>\n                    ) : (\n                      <span className=\"px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg\">\n                        {t('printers.skipObjects.skipped')}\n                      </span>\n                    )}\n                  </div>\n                ))}\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n    {pendingSkip && (\n      <ConfirmModal\n        variant=\"warning\"\n        title={t('printers.skipObjects.confirmTitle')}\n        message={t('printers.skipObjects.confirmMessage', { name: pendingSkip.name })}\n        confirmText={t('printers.skipObjects.skip')}\n        isLoading={skipObjectsMutation.isPending}\n        onConfirm={() => skipObjectsMutation.mutate([pendingSkip.id])}\n        onCancel={() => setPendingSkip(null)}\n      />\n    )}\n    {/* Enlarged lightbox overlay */}\n    {enlarged && objectsData && (\n      <div\n        className=\"fixed inset-0 bg-black/90 flex items-center justify-center z-60\"\n        onClick={() => setEnlarged(false)}\n      >\n        <button\n          onClick={() => setEnlarged(false)}\n          className=\"absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors\"\n        >\n          <X className=\"w-6 h-6\" />\n        </button>\n        <div\n          className=\"relative max-w-[600px] max-h-[80vh] aspect-square\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          {status?.cover_url ? (\n            <img\n              src={withStreamToken(`${status.cover_url}?view=top`)}\n              alt={t('printers.printPreview')}\n              className=\"w-full h-full object-contain rounded-lg bg-gray-900\"\n            />\n          ) : (\n            <div className=\"w-full h-full rounded-lg bg-gray-800 flex items-center justify-center\">\n              <Box className=\"w-16 h-16 text-gray-500\" />\n            </div>\n          )}\n          {/* Object ID markers overlay */}\n          {objectsData.objects.length > 0 && (\n            <div className=\"absolute inset-0 pointer-events-none\">\n              {objectsData.objects.map((obj, idx) => {\n                let x: number, y: number;\n\n                if (obj.x != null && obj.y != null && objectsData.bbox_all) {\n                  const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;\n                  const bboxWidth = xMax - xMin;\n                  const bboxHeight = yMax - yMin;\n                  const padding = 8;\n                  const contentArea = 100 - (padding * 2);\n                  x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;\n                  y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;\n                  x = Math.max(5, Math.min(95, x));\n                  y = Math.max(5, Math.min(95, y));\n                } else if (obj.x != null && obj.y != null) {\n                  const buildPlate = 256;\n                  x = (obj.x / buildPlate) * 100;\n                  y = 100 - (obj.y / buildPlate) * 100;\n                  x = Math.max(5, Math.min(95, x));\n                  y = Math.max(5, Math.min(95, y));\n                } else {\n                  const cols = Math.ceil(Math.sqrt(objectsData.objects.length));\n                  const row = Math.floor(idx / cols);\n                  const col = idx % cols;\n                  const rows = Math.ceil(objectsData.objects.length / cols);\n                  x = 15 + (col * (70 / cols)) + (35 / cols);\n                  y = 15 + (row * (70 / rows)) + (35 / rows);\n                }\n\n                return (\n                  <div\n                    key={obj.id}\n                    className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${\n                      obj.skipped\n                        ? 'bg-red-500 text-white line-through'\n                        : 'bg-bambu-green text-black'\n                    }`}\n                    style={{\n                      left: `${x}%`,\n                      top: `${y}%`,\n                      transform: 'translate(-50%, -50%)'\n                    }}\n                    title={obj.name}\n                  >\n                    {obj.id}\n                  </div>\n                );\n              })}\n            </div>\n          )}\n          {/* Active count badge */}\n          <div className=\"absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm\">\n            {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}\n          </div>\n        </div>\n      </div>\n    )}\n  </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/SmartPlugCard.tsx",
    "content": "import { useState } from 'react';\nimport { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';\nimport { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye, Globe } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport type { SmartPlug, SmartPlugUpdate } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { ConfirmModal } from './ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\n\ninterface SmartPlugCardProps {\n  plug: SmartPlug;\n  onEdit: (plug: SmartPlug) => void;\n}\n\nexport function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);\n  const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);\n  const [isExpanded, setIsExpanded] = useState(false);\n\n  // Fetch current status\n  const { data: status, isLoading: statusLoading } = useQuery({\n    queryKey: ['smart-plug-status', plug.id],\n    queryFn: () => api.getSmartPlugStatus(plug.id),\n    refetchInterval: 30000, // Refresh every 30 seconds\n  });\n\n  // Fetch printers for linking\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const linkedPrinter = printers?.find(p => p.id === plug.printer_id);\n\n  // Control mutation with optimistic updates\n  const controlMutation = useMutation({\n    mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),\n    onMutate: async (action) => {\n      // Cancel any outgoing refetches\n      await queryClient.cancelQueries({ queryKey: ['smart-plug-status', plug.id] });\n\n      // Snapshot the previous value\n      const previousStatus = queryClient.getQueryData(['smart-plug-status', plug.id]);\n\n      // Optimistically update to the new value\n      const newState = action === 'on' ? 'ON' : action === 'off' ? 'OFF' : (status?.state === 'ON' ? 'OFF' : 'ON');\n      queryClient.setQueryData(['smart-plug-status', plug.id], (old: typeof status) => ({\n        ...old,\n        state: newState,\n      }));\n\n      return { previousStatus };\n    },\n    onError: (_err, action, context) => {\n      // Rollback on error\n      if (context?.previousStatus) {\n        queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus);\n      }\n      showToast(t('smartPlugs.failedToTurn', { action, name: plug.name }), 'error');\n    },\n    onSettled: () => {\n      // Refetch after a short delay to get actual state\n      setTimeout(() => {\n        queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });\n        queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });\n      }, 1000);\n    },\n  });\n\n  // Update mutation\n  const updateMutation = useMutation({\n    mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });\n      // Also invalidate printer-specific smart plug queries to keep PrintersPage in sync\n      if (plug.printer_id) {\n        queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });\n        queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });\n      }\n    },\n  });\n\n  // Delete mutation\n  const deleteMutation = useMutation({\n    mutationFn: () => api.deleteSmartPlug(plug.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });\n      // Also invalidate printer card HA entity queries\n      if (plug.printer_id) {\n        queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });\n      }\n    },\n  });\n\n  const isOn = status?.state === 'ON';\n  // For MQTT plugs, consider reachable if we have power data (even if backend says not reachable)\n  const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);\n  const isReachable = (status?.reachable ?? false) || hasMqttData;\n  const isPending = controlMutation.isPending;\n\n  // Generate admin URL with auto-login credentials (Tasmota only)\n  const getAdminUrl = () => {\n    if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;\n    const ip = plug.ip_address;\n    if (plug.username && plug.password) {\n      // Use HTTP Basic Auth in URL for auto-login\n      return `http://${encodeURIComponent(plug.username)}:${encodeURIComponent(plug.password)}@${ip}/`;\n    }\n    return `http://${ip}/`;\n  };\n\n  const adminUrl = getAdminUrl();\n\n  return (\n    <>\n      <Card className=\"relative\">\n        <CardContent className=\"p-4\">\n          {/* Header Row */}\n          <div className=\"flex items-start justify-between gap-2 mb-3\">\n            <div className=\"flex items-center gap-3 min-w-0 flex-1\">\n              <div className={`p-2 rounded-lg flex-shrink-0 ${\n                plug.plug_type === 'mqtt'\n                  ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')\n                  : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')\n              }`}>\n                {plug.plug_type === 'mqtt' ? (\n                  <Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />\n                ) : plug.plug_type === 'homeassistant' ? (\n                  <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />\n                ) : plug.plug_type === 'rest' ? (\n                  <Globe className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />\n                ) : (\n                  <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />\n                )}\n              </div>\n              <div className=\"min-w-0\">\n                <h3 className=\"font-medium text-white truncate\">{plug.name}</h3>\n                <p\n                  className=\"text-sm text-bambu-gray truncate\"\n                  title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.plug_type === 'rest' ? plug.rest_on_url ?? plug.rest_off_url ?? undefined : plug.ip_address ?? undefined}\n                >\n                  {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.plug_type === 'rest' ? (plug.rest_on_url || plug.rest_off_url) : plug.ip_address}\n                </p>\n              </div>\n            </div>\n\n            {/* Status indicator */}\n            <div className=\"flex flex-col items-end gap-1 flex-shrink-0\">\n              {statusLoading ? (\n                <Loader2 className=\"w-4 h-4 text-bambu-gray animate-spin\" />\n              ) : plug.plug_type === 'mqtt' ? (\n                /* MQTT plugs - show badge and checkmark when receiving data */\n                <div className=\"flex items-center gap-1.5 text-sm whitespace-nowrap\">\n                  <span className=\"px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0\">MQTT</span>\n                  {isReachable && <span className=\"text-status-ok\">✓</span>}\n                </div>\n              ) : plug.plug_type === 'homeassistant' ? (\n                <div className=\"flex items-center gap-1 text-sm\">\n                  <span className=\"px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded\">HA</span>\n                  <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>\n                    {isReachable ? (status?.state || '?') : t('smartPlugs.offline')}\n                  </span>\n                </div>\n              ) : plug.plug_type === 'rest' ? (\n                <div className=\"flex items-center gap-1 text-sm\">\n                  <span className=\"px-1 py-0.5 bg-purple-500/20 text-purple-400 text-[10px] font-medium rounded\">REST</span>\n                  <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>\n                    {isReachable ? (status?.state || '?') : t('smartPlugs.offline')}\n                  </span>\n                </div>\n              ) : isReachable ? (\n                <div className=\"flex items-center gap-1 text-sm\">\n                  <Wifi className=\"w-4 h-4 text-status-ok\" />\n                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || t('smartPlugs.unknown')}</span>\n                </div>\n              ) : (\n                <div className=\"flex items-center gap-1 text-sm text-status-error\">\n                  <WifiOff className=\"w-4 h-4\" />\n                  <span>{t('smartPlugs.offline')}</span>\n                </div>\n              )}\n              {/* Admin page link - only for Tasmota */}\n              {adminUrl && (\n                <a\n                  href={adminUrl}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors\"\n                  title={t('smartPlugs.openPlugAdminPage')}\n                >\n                  <ExternalLink className=\"w-3 h-3\" />\n                  {t('smartPlugs.admin')}\n                </a>\n              )}\n            </div>\n          </div>\n\n          {/* Linked Printer */}\n          {linkedPrinter && (\n            <div className=\"mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg\">\n              <span className=\"text-xs text-bambu-gray\">{t('smartPlugs.linkedTo')} </span>\n              <span className=\"text-sm text-white\">{linkedPrinter.name}</span>\n            </div>\n          )}\n\n          {/* Feature Badges */}\n          {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (\n            <div className=\"flex flex-wrap gap-1.5 mb-3\">\n              {plug.plug_type === 'mqtt' && (\n                <span className=\"flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full\">\n                  <Eye className=\"w-3 h-3\" />\n                  {t('smartPlugs.monitorOnly')}\n                </span>\n              )}\n              {plug.power_alert_enabled && (\n                <span className=\"flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full\">\n                  <Bell className=\"w-3 h-3\" />\n                  {t('smartPlugs.alerts')}\n                </span>\n              )}\n              {plug.schedule_enabled && (\n                <span className=\"flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full\">\n                  <Calendar className=\"w-3 h-3\" />\n                  {plug.schedule_on_time && plug.schedule_off_time\n                    ? `${plug.schedule_on_time} - ${plug.schedule_off_time}`\n                    : plug.schedule_on_time\n                      ? t('smartPlugs.scheduleOn', { time: plug.schedule_on_time })\n                      : t('smartPlugs.scheduleOff', { time: plug.schedule_off_time })}\n                </span>\n              )}\n            </div>\n          )}\n\n          {/* Quick Controls - hidden for MQTT plugs (monitor-only) */}\n          {plug.plug_type !== 'mqtt' && (\n            <div className=\"flex gap-2 mb-3\">\n              <Button\n                size=\"sm\"\n                variant={isOn ? 'primary' : 'secondary'}\n                disabled={!isReachable || isPending}\n                onClick={() => setShowPowerOnConfirm(true)}\n                className=\"flex-1\"\n              >\n                {isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Power className=\"w-4 h-4\" />}\n                {t('smartPlugs.on')}\n              </Button>\n              <Button\n                size=\"sm\"\n                variant={!isOn ? 'primary' : 'secondary'}\n                disabled={!isReachable || isPending}\n                onClick={() => setShowPowerOffConfirm(true)}\n                className=\"flex-1\"\n              >\n                {isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <PowerOff className=\"w-4 h-4\" />}\n                {t('smartPlugs.off')}\n              </Button>\n            </div>\n          )}\n\n          {/* Energy display for MQTT plugs */}\n          {plug.plug_type === 'mqtt' && status?.energy && (\n            <div className=\"flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg\">\n              {status.energy.power !== null && status.energy.power !== undefined && (\n                <div className=\"flex-1 text-center\">\n                  <p className=\"text-lg font-semibold text-white\">{Math.round(status.energy.power)}W</p>\n                  <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.power')}</p>\n                </div>\n              )}\n              {status.energy.today !== null && status.energy.today !== undefined && (\n                <div className=\"flex-1 text-center border-l border-bambu-dark-tertiary\">\n                  <p className=\"text-lg font-semibold text-white\">{status.energy.today.toFixed(3)}</p>\n                  <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.kwhToday')}</p>\n                </div>\n              )}\n            </div>\n          )}\n\n          {/* Toggle Settings Panel */}\n          <button\n            onClick={() => setIsExpanded(!isExpanded)}\n            className=\"w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors\"\n          >\n            <span className=\"flex items-center gap-2\">\n              <Settings2 className=\"w-4 h-4\" />\n              {plug.plug_type === 'mqtt' ? t('smartPlugs.settings') : t('smartPlugs.automationSettings')}\n            </span>\n            <span>{isExpanded ? '-' : '+'}</span>\n          </button>\n\n          {/* Expanded Settings */}\n          {isExpanded && (\n            <div className=\"pt-3 border-t border-bambu-dark-tertiary space-y-4\">\n              {/* Show in Switchbar Toggle */}\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <LayoutGrid className=\"w-4 h-4 text-bambu-green\" />\n                  <div>\n                    <p className=\"text-sm text-white\">{t('smartPlugs.showInSwitchbar')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.quickAccessSidebar')}</p>\n                  </div>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={plug.show_in_switchbar}\n                    onChange={(e) => updateMutation.mutate({ show_in_switchbar: e.target.checked })}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n\n              {/* Automation controls - only for controllable plugs (not MQTT) */}\n              {plug.plug_type !== 'mqtt' && (\n                <>\n                  {/* Enabled Toggle */}\n                  <div className=\"flex items-center justify-between\">\n                    <div>\n                      <p className=\"text-sm text-white\">{t('smartPlugs.enabled')}</p>\n                      <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.enableAutomation')}</p>\n                    </div>\n                    <label className=\"relative inline-flex items-center cursor-pointer\">\n                      <input\n                        type=\"checkbox\"\n                        checked={plug.enabled}\n                        onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}\n                        className=\"sr-only peer\"\n                      />\n                      <div className=\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"></div>\n                    </label>\n                  </div>\n\n                  {/* Auto On */}\n                  <div className=\"flex items-center justify-between\">\n                    <div>\n                      <p className=\"text-sm text-white\">{t('smartPlugs.autoOn')}</p>\n                      <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.autoOnDescription')}</p>\n                    </div>\n                    <label className=\"relative inline-flex items-center cursor-pointer\">\n                      <input\n                        type=\"checkbox\"\n                        checked={plug.auto_on}\n                        onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}\n                        className=\"sr-only peer\"\n                      />\n                      <div className=\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"></div>\n                    </label>\n                  </div>\n\n                  {/* Auto Off */}\n                  <div className=\"flex items-center justify-between\">\n                    <div>\n                      <p className=\"text-sm text-white\">{t('smartPlugs.autoOff')}</p>\n                      <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.autoOffDescription')}</p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={plug.auto_off}\n                    onChange={(e) => updateMutation.mutate({ auto_off: e.target.checked })}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n\n              {/* Auto Off Persistent */}\n              {plug.auto_off && (\n                <div className=\"flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('smartPlugs.autoOffPersistent')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('smartPlugs.autoOffPersistentDescription')}</p>\n                  </div>\n                  <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={plug.auto_off_persistent}\n                      onChange={(e) => updateMutation.mutate({ auto_off_persistent: e.target.checked })}\n                      className=\"sr-only peer\"\n                    />\n                    <div className=\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"></div>\n                  </label>\n                </div>\n              )}\n\n              {/* Delay Mode */}\n              {plug.auto_off && (\n                <div className=\"space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary\">\n                  <div>\n                    <p className=\"text-sm text-white mb-2\">{t('smartPlugs.turnOffDelayMode')}</p>\n                    <div className=\"flex gap-2\">\n                      <button\n                        onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}\n                        className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${\n                          plug.off_delay_mode === 'time'\n                            ? 'bg-bambu-green text-white'\n                            : 'bg-bambu-dark text-bambu-gray hover:text-white'\n                        }`}\n                      >\n                        <Clock className=\"w-4 h-4\" />\n                        {t('smartPlugs.time')}\n                      </button>\n                      <button\n                        onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}\n                        className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${\n                          plug.off_delay_mode === 'temperature'\n                            ? 'bg-bambu-green text-white'\n                            : 'bg-bambu-dark text-bambu-gray hover:text-white'\n                        }`}\n                      >\n                        <Thermometer className=\"w-4 h-4\" />\n                        {t('smartPlugs.temp')}\n                      </button>\n                    </div>\n                  </div>\n\n                  {plug.off_delay_mode === 'time' ? (\n                    <div>\n                      <label className=\"block text-xs text-bambu-gray mb-1\">{t('smartPlugs.delayMinutes')}</label>\n                      <input\n                        type=\"number\"\n                        min=\"1\"\n                        max=\"60\"\n                        value={plug.off_delay_minutes}\n                        onChange={(e) => updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })}\n                        className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                      />\n                    </div>\n                  ) : (\n                    <div>\n                      <label className=\"block text-xs text-bambu-gray mb-1\">{t('smartPlugs.tempThreshold')}</label>\n                      <input\n                        type=\"number\"\n                        min=\"30\"\n                        max=\"100\"\n                        value={plug.off_temp_threshold}\n                        onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}\n                        className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                      />\n                      <p className=\"text-xs text-bambu-gray mt-1\">{t('smartPlugs.tempThresholdDescription')}</p>\n                    </div>\n                  )}\n                </div>\n              )}\n                </>\n              )}\n\n              {/* Action Buttons */}\n              <div className=\"flex gap-2 pt-2\">\n                <Button\n                  size=\"sm\"\n                  variant=\"secondary\"\n                  onClick={() => onEdit(plug)}\n                  className=\"flex-1\"\n                >\n                  <Edit2 className=\"w-4 h-4\" />\n                  {t('smartPlugs.edit')}\n                </Button>\n                <Button\n                  size=\"sm\"\n                  variant=\"secondary\"\n                  onClick={() => setShowDeleteConfirm(true)}\n                  className=\"text-red-400 hover:text-red-300\"\n                >\n                  <Trash2 className=\"w-4 h-4\" />\n                </Button>\n              </div>\n            </div>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Delete Confirmation */}\n      {showDeleteConfirm && (\n        <ConfirmModal\n          title={t('smartPlugs.deleteSmartPlug')}\n          message={t('smartPlugs.deleteConfirm', { name: plug.name })}\n          confirmText={t('smartPlugs.delete')}\n          variant=\"danger\"\n          onConfirm={() => {\n            deleteMutation.mutate();\n            setShowDeleteConfirm(false);\n          }}\n          onCancel={() => setShowDeleteConfirm(false)}\n        />\n      )}\n\n      {/* Power On Confirmation */}\n      {showPowerOnConfirm && (\n        <ConfirmModal\n          title={t('smartPlugs.turnOnSmartPlug')}\n          message={t('smartPlugs.turnOnConfirm', { name: plug.name })}\n          confirmText={t('smartPlugs.turnOn')}\n          variant=\"default\"\n          onConfirm={() => {\n            controlMutation.mutate('on');\n            setShowPowerOnConfirm(false);\n          }}\n          onCancel={() => setShowPowerOnConfirm(false)}\n        />\n      )}\n\n      {/* Power Off Confirmation */}\n      {showPowerOffConfirm && (\n        <ConfirmModal\n          title={t('smartPlugs.turnOffSmartPlug')}\n          message={t('smartPlugs.turnOffConfirm', { name: plug.name })}\n          confirmText={t('smartPlugs.turnOff')}\n          variant=\"danger\"\n          onConfirm={() => {\n            controlMutation.mutate('off');\n            setShowPowerOffConfirm(false);\n          }}\n          onCancel={() => setShowPowerOffConfirm(false)}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/SpoolBuddySettings.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport {\n  Loader2,\n  Trash2,\n  Cpu,\n  HardDrive,\n  Thermometer,\n  Wifi,\n  WifiOff,\n  AlertTriangle,\n  Info,\n  CheckCircle2,\n  XCircle,\n  Clock,\n  Download,\n  Monitor,\n  RefreshCw,\n  RotateCw,\n  Power,\n} from 'lucide-react';\nimport { spoolbuddyApi, type SpoolBuddyDevice } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { ConfirmModal } from './ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { formatRelativeTime } from '../utils/date';\n\nfunction formatUptime(seconds: number): string {\n  if (seconds < 60) return `${seconds}s`;\n  const m = Math.floor(seconds / 60);\n  if (m < 60) return `${m}m`;\n  const h = Math.floor(m / 60);\n  const remM = m % 60;\n  if (h < 24) return remM ? `${h}h ${remM}m` : `${h}h`;\n  const d = Math.floor(h / 24);\n  const remH = h % 24;\n  return remH ? `${d}d ${remH}h` : `${d}d`;\n}\n\nfunction formatMB(mb?: number): string {\n  if (mb === undefined || mb === null) return '—';\n  if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;\n  return `${Math.round(mb)} MB`;\n}\n\ninterface DeviceCardProps {\n  device: SpoolBuddyDevice;\n  onUnregister: (device: SpoolBuddyDevice) => void;\n  isDeleting: boolean;\n}\n\ntype ActionKey = 'update' | 'restart_browser' | 'restart_daemon' | 'reboot' | 'shutdown';\n\nfunction DeviceCard({ device, onUnregister, isDeleting }: DeviceCardProps) {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const stats = device.system_stats;\n  const mem = stats?.memory;\n  const disk = stats?.disk;\n  const online = device.online;\n  const [pendingAction, setPendingAction] = useState<ActionKey | null>(null);\n  const [busyAction, setBusyAction] = useState<ActionKey | null>(null);\n\n  const runAction = async (action: ActionKey) => {\n    setBusyAction(action);\n    try {\n      if (action === 'update') {\n        await spoolbuddyApi.triggerUpdate(device.device_id);\n      } else {\n        await spoolbuddyApi.systemCommand(device.device_id, action);\n      }\n      showToast(t('settings.spoolbuddy.commandQueued'), 'success');\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : t('settings.spoolbuddy.commandError');\n      showToast(msg, 'error');\n    } finally {\n      setBusyAction(null);\n      setPendingAction(null);\n    }\n  };\n\n  const actions: { key: ActionKey; label: string; icon: typeof Download; variant?: 'danger' }[] = [\n    { key: 'update', label: t('settings.spoolbuddy.update'), icon: Download },\n    { key: 'restart_browser', label: t('settings.spoolbuddy.restartBrowser'), icon: Monitor },\n    { key: 'restart_daemon', label: t('settings.spoolbuddy.restartDaemon'), icon: RefreshCw },\n    { key: 'reboot', label: t('settings.spoolbuddy.reboot'), icon: RotateCw },\n    { key: 'shutdown', label: t('settings.spoolbuddy.shutdown'), icon: Power, variant: 'danger' },\n  ];\n\n  const confirmTitles: Record<ActionKey, string> = {\n    update: t('settings.spoolbuddy.updateConfirmTitle'),\n    restart_browser: t('settings.spoolbuddy.restartBrowserConfirmTitle'),\n    restart_daemon: t('settings.spoolbuddy.restartDaemonConfirmTitle'),\n    reboot: t('settings.spoolbuddy.rebootConfirmTitle'),\n    shutdown: t('settings.spoolbuddy.shutdownConfirmTitle'),\n  };\n\n  const confirmBodies: Record<ActionKey, string> = {\n    update: t('settings.spoolbuddy.updateConfirmBody', { hostname: device.hostname }),\n    restart_browser: t('settings.spoolbuddy.restartBrowserConfirmBody', { hostname: device.hostname }),\n    restart_daemon: t('settings.spoolbuddy.restartDaemonConfirmBody', { hostname: device.hostname }),\n    reboot: t('settings.spoolbuddy.rebootConfirmBody', { hostname: device.hostname }),\n    shutdown: t('settings.spoolbuddy.shutdownConfirmBody', { hostname: device.hostname }),\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <div className=\"flex items-start justify-between gap-3 flex-wrap\">\n          <div className=\"min-w-0\">\n            <div className=\"flex items-center gap-2\">\n              <h3 className=\"text-base font-semibold text-white truncate\">{device.hostname}</h3>\n              <span\n                className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${\n                  online\n                    ? 'bg-green-500/15 text-green-400 border border-green-500/40'\n                    : 'bg-gray-500/15 text-gray-400 border border-gray-500/40'\n                }`}\n              >\n                {online ? <Wifi className=\"w-3 h-3\" /> : <WifiOff className=\"w-3 h-3\" />}\n                {online ? t('settings.spoolbuddy.online') : t('settings.spoolbuddy.offline')}\n              </span>\n            </div>\n            <p className=\"text-xs text-bambu-gray font-mono mt-1 truncate\">{device.device_id}</p>\n          </div>\n          <Button\n            variant=\"danger\"\n            size=\"sm\"\n            onClick={() => onUnregister(device)}\n            disabled={isDeleting}\n            aria-label={t('settings.spoolbuddy.unregister')}\n          >\n            <Trash2 className=\"w-3.5 h-3.5\" />\n            <span className=\"hidden sm:inline\">{t('settings.spoolbuddy.unregister')}</span>\n          </Button>\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-3\">\n        {/* Connection */}\n        <div className=\"grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs\">\n          <div>\n            <div className=\"text-bambu-gray\">{t('settings.spoolbuddy.ipAddress')}</div>\n            <div className=\"text-white font-mono\">{device.ip_address}</div>\n          </div>\n          <div>\n            <div className=\"text-bambu-gray\">{t('settings.spoolbuddy.firmware')}</div>\n            <div className=\"text-white\">{device.firmware_version ?? '—'}</div>\n          </div>\n          <div>\n            <div className=\"text-bambu-gray flex items-center gap-1\">\n              <Clock className=\"w-3 h-3\" />\n              {t('settings.spoolbuddy.lastSeen')}\n            </div>\n            <div className=\"text-white\">\n              {device.last_seen ? formatRelativeTime(device.last_seen) : t('settings.spoolbuddy.never')}\n            </div>\n          </div>\n          <div>\n            <div className=\"text-bambu-gray\">{t('settings.spoolbuddy.daemonUptime')}</div>\n            <div className=\"text-white\">{formatUptime(device.uptime_s)}</div>\n          </div>\n        </div>\n\n        {/* Action buttons */}\n        <div className=\"flex flex-wrap gap-2\">\n          {actions.map(({ key, label, icon: Icon, variant }) => (\n            <Button\n              key={key}\n              variant={variant ?? 'secondary'}\n              size=\"sm\"\n              onClick={() => setPendingAction(key)}\n              disabled={!online || busyAction !== null}\n              aria-label={label}\n            >\n              {busyAction === key ? (\n                <Loader2 className=\"w-3.5 h-3.5 animate-spin\" />\n              ) : (\n                <Icon className=\"w-3.5 h-3.5\" />\n              )}\n              <span>{label}</span>\n            </Button>\n          ))}\n        </div>\n\n        {/* Hardware flags */}\n        <div className=\"flex items-center gap-3 text-xs flex-wrap\">\n          <span className=\"flex items-center gap-1 text-bambu-gray\">\n            {device.nfc_ok ? (\n              <CheckCircle2 className=\"w-3.5 h-3.5 text-green-400\" />\n            ) : (\n              <XCircle className=\"w-3.5 h-3.5 text-red-400\" />\n            )}\n            {t('settings.spoolbuddy.nfc')}\n            {device.nfc_reader_type && <span className=\"text-bambu-gray/70\">({device.nfc_reader_type})</span>}\n          </span>\n          <span className=\"flex items-center gap-1 text-bambu-gray\">\n            {device.scale_ok ? (\n              <CheckCircle2 className=\"w-3.5 h-3.5 text-green-400\" />\n            ) : (\n              <XCircle className=\"w-3.5 h-3.5 text-red-400\" />\n            )}\n            {t('settings.spoolbuddy.scale')}\n          </span>\n        </div>\n\n        {/* System stats */}\n        {stats && (\n          <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n            <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-xs\">\n              {stats.cpu_temp_c !== undefined && (\n                <div className=\"flex items-center gap-2\">\n                  <Thermometer className=\"w-3.5 h-3.5 text-bambu-gray\" />\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('settings.spoolbuddy.cpuTemp')}</div>\n                    <div className=\"text-white\">{stats.cpu_temp_c.toFixed(1)}°C</div>\n                  </div>\n                </div>\n              )}\n              {mem && mem.percent !== undefined && (\n                <div className=\"flex items-center gap-2\">\n                  <Cpu className=\"w-3.5 h-3.5 text-bambu-gray\" />\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('settings.spoolbuddy.memory')}</div>\n                    <div className=\"text-white\">\n                      {mem.percent.toFixed(0)}% ({formatMB(mem.used_mb)} / {formatMB(mem.total_mb)})\n                    </div>\n                  </div>\n                </div>\n              )}\n              {disk && disk.percent !== undefined && (\n                <div className=\"flex items-center gap-2\">\n                  <HardDrive className=\"w-3.5 h-3.5 text-bambu-gray\" />\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('settings.spoolbuddy.disk')}</div>\n                    <div className=\"text-white\">\n                      {disk.percent.toFixed(0)}% ({disk.used_gb?.toFixed(1)} / {disk.total_gb?.toFixed(1)} GB)\n                    </div>\n                  </div>\n                </div>\n              )}\n              {stats.system_uptime_s !== undefined && (\n                <div className=\"flex items-center gap-2\">\n                  <Clock className=\"w-3.5 h-3.5 text-bambu-gray\" />\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('settings.spoolbuddy.systemUptime')}</div>\n                    <div className=\"text-white\">{formatUptime(stats.system_uptime_s)}</div>\n                  </div>\n                </div>\n              )}\n            </div>\n            {stats.os && (\n              <div className=\"mt-3 text-xs text-bambu-gray font-mono truncate\">\n                {[stats.os.os, stats.os.kernel, stats.os.arch, stats.os.python && `Python ${stats.os.python}`]\n                  .filter(Boolean)\n                  .join(' · ')}\n              </div>\n            )}\n          </div>\n        )}\n      </CardContent>\n      {pendingAction && (\n        <ConfirmModal\n          variant={pendingAction === 'shutdown' || pendingAction === 'reboot' ? 'danger' : 'default'}\n          title={confirmTitles[pendingAction]}\n          message={confirmBodies[pendingAction]}\n          confirmText={t('settings.spoolbuddy.commandConfirm')}\n          isLoading={busyAction !== null}\n          onConfirm={() => runAction(pendingAction)}\n          onCancel={() => setPendingAction(null)}\n        />\n      )}\n    </Card>\n  );\n}\n\nexport function SpoolBuddySettings() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [pendingDelete, setPendingDelete] = useState<SpoolBuddyDevice | null>(null);\n\n  const { data: devices = [], isLoading } = useQuery({\n    queryKey: ['spoolbuddy-devices'],\n    queryFn: () => spoolbuddyApi.getDevices(),\n    refetchInterval: 15000,\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (deviceId: string) => spoolbuddyApi.deleteDevice(deviceId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });\n      showToast(t('settings.spoolbuddy.unregisterSuccess'), 'success');\n      setPendingDelete(null);\n    },\n    onError: (err: Error) => {\n      showToast(err.message || t('settings.spoolbuddy.unregisterError'), 'error');\n    },\n  });\n\n  if (isLoading) {\n    return (\n      <Card>\n        <CardContent className=\"py-8 flex justify-center\">\n          <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n        </CardContent>\n      </Card>\n    );\n  }\n\n  const hasDuplicates = devices.length > 1;\n\n  return (\n    <div className=\"space-y-4\">\n      <Card>\n        <CardContent className=\"py-3 px-4\">\n          <div className=\"flex items-start gap-2 text-xs\">\n            <Info className=\"w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5\" />\n            <div className=\"text-bambu-gray\">\n              <p className=\"text-white font-medium mb-1\">{t('settings.spoolbuddy.infoTitle')}</p>\n              <p>{t('settings.spoolbuddy.infoBody')}</p>\n            </div>\n          </div>\n        </CardContent>\n      </Card>\n\n      {hasDuplicates && (\n        <Card className=\"border-l-4 border-l-yellow-500\">\n          <CardContent className=\"py-3 px-4\">\n            <div className=\"flex items-start gap-2 text-xs\">\n              <AlertTriangle className=\"w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5\" />\n              <div className=\"text-bambu-gray\">\n                <p className=\"text-white font-medium mb-1\">\n                  {t('settings.spoolbuddy.duplicatesTitle', { count: devices.length })}\n                </p>\n                <p>{t('settings.spoolbuddy.duplicatesBody')}</p>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n      )}\n\n      {devices.length === 0 ? (\n        <Card>\n          <CardContent className=\"py-8 text-center text-bambu-gray text-sm\">\n            {t('settings.spoolbuddy.empty')}\n          </CardContent>\n        </Card>\n      ) : (\n        <div className=\"space-y-3\">\n          {devices.map((device) => (\n            <DeviceCard\n              key={device.id}\n              device={device}\n              onUnregister={setPendingDelete}\n              isDeleting={deleteMutation.isPending && deleteMutation.variables === device.device_id}\n            />\n          ))}\n        </div>\n      )}\n\n      {pendingDelete && (\n        <ConfirmModal\n          variant=\"danger\"\n          title={t('settings.spoolbuddy.confirmTitle')}\n          message={t('settings.spoolbuddy.confirmBody', {\n            hostname: pendingDelete.hostname,\n            deviceId: pendingDelete.device_id,\n          })}\n          confirmText={t('settings.spoolbuddy.unregister')}\n          isLoading={deleteMutation.isPending}\n          onConfirm={() => deleteMutation.mutate(pendingDelete.device_id)}\n          onCancel={() => setPendingDelete(null)}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/SpoolCatalogSettings.tsx",
    "content": "import { useState, useEffect, useCallback, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Database, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { SpoolCatalogEntry } from '../api/client';\nimport { useToast } from '../contexts/ToastContext';\nimport { Card, CardHeader, CardContent } from './Card';\nimport { ConfirmModal } from './ConfirmModal';\n\nexport function SpoolCatalogSettings() {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const [catalog, setCatalog] = useState<SpoolCatalogEntry[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [search, setSearch] = useState('');\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  // Add/Edit form state\n  const [showAddForm, setShowAddForm] = useState(false);\n  const [editingId, setEditingId] = useState<number | null>(null);\n  const [formName, setFormName] = useState('');\n  const [formWeight, setFormWeight] = useState('');\n  const [saving, setSaving] = useState(false);\n\n  // Selection state\n  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());\n  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);\n\n  // Confirmation modals\n  const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);\n  const [showResetConfirm, setShowResetConfirm] = useState(false);\n\n  const loadCatalog = useCallback(async () => {\n    try {\n      const entries = await api.getSpoolCatalog();\n      setCatalog(entries);\n    } catch {\n      showToast(t('settings.catalog.loadFailed'), 'error');\n    } finally {\n      setLoading(false);\n    }\n  }, [showToast, t]);\n\n  useEffect(() => {\n    loadCatalog();\n  }, [loadCatalog]);\n\n  const filteredCatalog = catalog.filter(entry =>\n    entry.name.toLowerCase().includes(search.toLowerCase())\n  );\n\n  const handleAdd = async () => {\n    if (!formName.trim() || !formWeight) {\n      showToast(t('settings.catalog.nameWeightRequired'), 'error');\n      return;\n    }\n    setSaving(true);\n    try {\n      const entry = await api.addCatalogEntry({ name: formName.trim(), weight: parseInt(formWeight) });\n      setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name)));\n      setShowAddForm(false);\n      setFormName('');\n      setFormWeight('');\n      showToast(t('settings.catalog.entryAdded'), 'success');\n    } catch {\n      showToast(t('settings.catalog.addFailed'), 'error');\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const startEdit = (entry: SpoolCatalogEntry) => {\n    setEditingId(entry.id);\n    setFormName(entry.name);\n    setFormWeight(entry.weight.toString());\n  };\n\n  const cancelEdit = () => {\n    setEditingId(null);\n    setFormName('');\n    setFormWeight('');\n  };\n\n  const handleUpdate = async (id: number) => {\n    if (!formName.trim() || !formWeight) {\n      showToast(t('settings.catalog.nameWeightRequired'), 'error');\n      return;\n    }\n    setSaving(true);\n    try {\n      const updated = await api.updateCatalogEntry(id, { name: formName.trim(), weight: parseInt(formWeight) });\n      setCatalog(prev => prev.map(e => e.id === id ? updated : e).sort((a, b) => a.name.localeCompare(b.name)));\n      setEditingId(null);\n      setFormName('');\n      setFormWeight('');\n      showToast(t('settings.catalog.entryUpdated'), 'success');\n    } catch {\n      showToast(t('settings.catalog.updateFailed'), 'error');\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const handleDelete = async () => {\n    if (!deleteEntry) return;\n    try {\n      await api.deleteCatalogEntry(deleteEntry.id);\n      setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id));\n      showToast(t('settings.catalog.entryDeleted'), 'success');\n    } catch {\n      showToast(t('settings.catalog.deleteFailed'), 'error');\n    } finally {\n      setDeleteEntry(null);\n    }\n  };\n\n  const handleReset = async () => {\n    setShowResetConfirm(false);\n    setLoading(true);\n    try {\n      await api.resetSpoolCatalog();\n      await loadCatalog();\n      showToast(t('settings.catalog.resetSuccess'), 'success');\n    } catch {\n      showToast(t('settings.catalog.resetFailed'), 'error');\n      setLoading(false);\n    }\n  };\n\n  const toggleSelect = (id: number) => {\n    setSelectedIds(prev => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n  };\n\n  const toggleSelectAll = () => {\n    if (selectedIds.size === filteredCatalog.length) {\n      setSelectedIds(new Set());\n    } else {\n      setSelectedIds(new Set(filteredCatalog.map(e => e.id)));\n    }\n  };\n\n  const handleBulkDelete = async () => {\n    setShowBulkDeleteConfirm(false);\n    if (selectedIds.size === 0) return;\n    try {\n      const result = await api.bulkDeleteCatalogEntries([...selectedIds]);\n      setCatalog(prev => prev.filter(e => !selectedIds.has(e.id)));\n      setSelectedIds(new Set());\n      showToast(t('settings.catalog.bulkDeleted', { count: result.deleted }), 'success');\n    } catch {\n      showToast(t('settings.catalog.bulkDeleteFailed'), 'error');\n    }\n  };\n\n  const handleExport = () => {\n    const exportData = catalog.map(({ name, weight }) => ({ name, weight }));\n    const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });\n    const url = URL.createObjectURL(blob);\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = 'spool-catalog.json';\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    URL.revokeObjectURL(url);\n    showToast(t('settings.catalog.exported', { count: catalog.length }), 'success');\n  };\n\n  const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n    try {\n      const text = await file.text();\n      const data = JSON.parse(text) as Array<{ name: string; weight: number }>;\n      if (!Array.isArray(data)) throw new Error('Invalid format');\n\n      let added = 0;\n      let skipped = 0;\n      for (const item of data) {\n        if (!item.name || typeof item.weight !== 'number') { skipped++; continue; }\n        const exists = catalog.some(c => c.name.toLowerCase() === item.name.toLowerCase());\n        if (exists) { skipped++; continue; }\n        try {\n          const entry = await api.addCatalogEntry({ name: item.name, weight: item.weight });\n          setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name)));\n          added++;\n        } catch { skipped++; }\n      }\n      showToast(t('settings.catalog.imported', { added, skipped }), 'success');\n    } catch {\n      showToast(t('settings.catalog.importFailed'), 'error');\n    }\n    if (fileInputRef.current) fileInputRef.current.value = '';\n  };\n\n  return (\n    <Card id=\"card-spool-catalog\">\n      <CardHeader>\n        <div className=\"flex items-center gap-2 mb-3\">\n          <Database className=\"w-5 h-5 text-bambu-gray\" />\n          <h2 className=\"text-lg font-semibold text-white\">{t('settings.catalog.spoolCatalog')}</h2>\n          <span className=\"text-sm text-bambu-gray\">({catalog.length})</span>\n        </div>\n        <div className=\"flex items-center gap-2 flex-wrap\">\n          <button\n            onClick={handleExport}\n            className=\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\"\n            title={t('settings.catalog.exportTooltip')}\n          >\n            <Download className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('common.export')}</span>\n          </button>\n          <button\n            onClick={() => fileInputRef.current?.click()}\n            className=\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\"\n            title={t('settings.catalog.importTooltip')}\n          >\n            <Upload className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('common.import')}</span>\n          </button>\n          <input ref={fileInputRef} type=\"file\" accept=\".json\" className=\"hidden\" onChange={handleImport} />\n          <button\n            onClick={() => setShowResetConfirm(true)}\n            className=\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\"\n            title={t('settings.catalog.resetTooltip')}\n          >\n            <RotateCcw className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('common.reset')}</span>\n          </button>\n          <button\n            onClick={() => setShowAddForm(true)}\n            className=\"px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5\"\n          >\n            <Plus className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('common.add')}</span>\n          </button>\n        </div>\n        {selectedIds.size > 0 && (\n          <div className=\"flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg\">\n            <span className=\"text-sm text-red-400\">\n              {t('settings.catalog.selectedCount', { count: selectedIds.size })}\n            </span>\n            <button\n              onClick={() => setShowBulkDeleteConfirm(true)}\n              className=\"ml-auto px-3 py-1.5 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5\"\n            >\n              <Trash2 className=\"w-4 h-4\" />\n              {t('settings.catalog.deleteSelected')}\n            </button>\n            <button\n              onClick={() => setSelectedIds(new Set())}\n              className=\"px-3 py-1.5 text-sm text-bambu-gray hover:text-white transition-colors\"\n            >\n              {t('common.cancel')}\n            </button>\n          </div>\n        )}\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <p className=\"text-sm text-bambu-gray\">\n          {t('settings.catalog.spoolCatalogDescription')}\n        </p>\n\n        {/* Search */}\n        <div className=\"relative\">\n          <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n          <input\n            type=\"text\"\n            className=\"w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n            placeholder={t('settings.catalog.searchCatalog')}\n            value={search}\n            onChange={(e) => setSearch(e.target.value)}\n          />\n        </div>\n\n        {/* Add form */}\n        {showAddForm && (\n          <div className=\"p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n            <h3 className=\"text-sm font-medium text-white mb-3\">{t('settings.catalog.addNewEntry')}</h3>\n            <div className=\"flex gap-2 items-center\">\n              <div className=\"flex-1 min-w-0\">\n                <input\n                  type=\"text\"\n                  className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"\n                  placeholder={t('settings.catalog.namePlaceholder')}\n                  value={formName}\n                  onChange={(e) => setFormName(e.target.value)}\n                />\n              </div>\n              <input\n                type=\"number\"\n                className=\"w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none\"\n                placeholder=\"g\"\n                value={formWeight}\n                onChange={(e) => setFormWeight(e.target.value)}\n              />\n              <span className=\"text-bambu-gray shrink-0\">g</span>\n              <button\n                onClick={handleAdd}\n                disabled={saving}\n                className=\"px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0\"\n              >\n                {saving ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Check className=\"w-4 h-4\" />}\n                {t('common.add')}\n              </button>\n              <button\n                onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}\n                className=\"p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\"\n              >\n                <X className=\"w-4 h-4\" />\n              </button>\n            </div>\n          </div>\n        )}\n\n        {/* Catalog list */}\n        {loading ? (\n          <div className=\"flex items-center justify-center py-8 text-bambu-gray\">\n            <Loader2 className=\"w-5 h-5 animate-spin mr-2\" />\n            {t('common.loading')}\n          </div>\n        ) : (\n          <div className=\"max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg\">\n            <table className=\"w-full text-sm\">\n              <thead className=\"bg-bambu-dark sticky top-0\">\n                <tr>\n                  <th className=\"px-2 py-2 w-10\">\n                    <input\n                      type=\"checkbox\"\n                      checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}\n                      onChange={toggleSelectAll}\n                      className=\"w-4 h-4 accent-bambu-green cursor-pointer\"\n                    />\n                  </th>\n                  <th className=\"px-4 py-2 text-left text-bambu-gray font-medium\">{t('common.name')}</th>\n                  <th className=\"px-4 py-2 text-right text-bambu-gray font-medium w-24\">{t('settings.catalog.weight')}</th>\n                  <th className=\"px-4 py-2 text-center text-bambu-gray font-medium w-20\">{t('settings.catalog.type')}</th>\n                  <th className=\"px-4 py-2 w-24\"></th>\n                </tr>\n              </thead>\n              <tbody>\n                {filteredCatalog.length === 0 ? (\n                  <tr>\n                    <td colSpan={5} className=\"px-4 py-8 text-center text-bambu-gray\">\n                      {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}\n                    </td>\n                  </tr>\n                ) : (\n                  filteredCatalog.map(entry => (\n                    <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>\n                      {editingId === entry.id ? (\n                        <>\n                          <td className=\"px-2 py-2\">\n                            <input\n                              type=\"checkbox\"\n                              checked={selectedIds.has(entry.id)}\n                              onChange={() => toggleSelect(entry.id)}\n                              className=\"w-4 h-4 accent-bambu-green cursor-pointer\"\n                            />\n                          </td>\n                          <td className=\"px-4 py-2\">\n                            <input\n                              type=\"text\"\n                              className=\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none\"\n                              value={formName}\n                              onChange={(e) => setFormName(e.target.value)}\n                            />\n                          </td>\n                          <td className=\"px-4 py-2\">\n                            <input\n                              type=\"number\"\n                              className=\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none\"\n                              value={formWeight}\n                              onChange={(e) => setFormWeight(e.target.value)}\n                            />\n                          </td>\n                          <td className=\"px-4 py-2 text-center\">\n                            <span className=\"text-xs text-bambu-gray\">-</span>\n                          </td>\n                          <td className=\"px-4 py-2\">\n                            <div className=\"flex justify-end gap-1\">\n                              <button\n                                onClick={() => handleUpdate(entry.id)}\n                                disabled={saving}\n                                className=\"p-1.5 rounded hover:bg-green-500/20 text-green-500\"\n                              >\n                                {saving ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Check className=\"w-4 h-4\" />}\n                              </button>\n                              <button onClick={cancelEdit} className=\"p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray\">\n                                <X className=\"w-4 h-4\" />\n                              </button>\n                            </div>\n                          </td>\n                        </>\n                      ) : (\n                        <>\n                          <td className=\"px-2 py-2\">\n                            <input\n                              type=\"checkbox\"\n                              checked={selectedIds.has(entry.id)}\n                              onChange={() => toggleSelect(entry.id)}\n                              className=\"w-4 h-4 accent-bambu-green cursor-pointer\"\n                            />\n                          </td>\n                          <td className=\"px-4 py-2 text-white\">{entry.name}</td>\n                          <td className=\"px-4 py-2 text-right font-mono text-white\">{entry.weight}g</td>\n                          <td className=\"px-4 py-2 text-center\">\n                            {entry.is_default ? (\n                              <span className=\"text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray\">\n                                {t('settings.catalog.default')}\n                              </span>\n                            ) : (\n                              <span className=\"text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green\">\n                                {t('settings.catalog.custom')}\n                              </span>\n                            )}\n                          </td>\n                          <td className=\"px-4 py-2\">\n                            <div className=\"flex justify-end gap-1\">\n                              <button\n                                onClick={() => startEdit(entry)}\n                                className=\"p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\"\n                              >\n                                <Pencil className=\"w-4 h-4\" />\n                              </button>\n                              <button\n                                onClick={() => setDeleteEntry(entry)}\n                                className=\"p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500\"\n                              >\n                                <Trash2 className=\"w-4 h-4\" />\n                              </button>\n                            </div>\n                          </td>\n                        </>\n                      )}\n                    </tr>\n                  ))\n                )}\n              </tbody>\n            </table>\n          </div>\n        )}\n      </CardContent>\n\n      {/* Delete confirmation */}\n      {deleteEntry && (\n        <ConfirmModal\n          title={t('settings.catalog.deleteEntry')}\n          message={t('settings.catalog.deleteConfirm', { name: deleteEntry.name })}\n          confirmText={t('common.delete')}\n          variant=\"danger\"\n          onConfirm={handleDelete}\n          onCancel={() => setDeleteEntry(null)}\n        />\n      )}\n\n      {/* Bulk delete confirmation */}\n      {showBulkDeleteConfirm && (\n        <ConfirmModal\n          title={t('settings.catalog.deleteSelected')}\n          message={t('settings.catalog.bulkDeleteConfirm', { count: selectedIds.size })}\n          confirmText={t('common.delete')}\n          variant=\"danger\"\n          onConfirm={handleBulkDelete}\n          onCancel={() => setShowBulkDeleteConfirm(false)}\n        />\n      )}\n\n      {/* Reset confirmation */}\n      {showResetConfirm && (\n        <ConfirmModal\n          title={t('settings.catalog.resetCatalog')}\n          message={t('settings.catalog.resetConfirm')}\n          confirmText={t('common.reset')}\n          variant=\"danger\"\n          onConfirm={handleReset}\n          onCancel={() => setShowResetConfirm(false)}\n        />\n      )}\n    </Card>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/SpoolFormModal.tsx",
    "content": "import { useState, useEffect, useMemo } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\nimport type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';\nimport { defaultFormData, validateForm } from './spool-form/types';\nimport { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';\nimport { MATERIALS } from './spool-form/constants';\nimport { FilamentSection } from './spool-form/FilamentSection';\nimport { ColorSection } from './spool-form/ColorSection';\nimport { AdditionalSection } from './spool-form/AdditionalSection';\nimport { PAProfileSection } from './spool-form/PAProfileSection';\nimport { SpoolUsageHistory } from './SpoolUsageHistory';\n\ntype TabId = 'filament' | 'pa-profile';\n\ninterface SpoolFormModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  spool?: InventorySpool | null;\n  printersWithCalibrations?: PrinterWithCalibrations[];\n  currencySymbol: string;\n  onSpoolsCreated?: (spools: InventorySpool[]) => void;\n}\n\nexport function SpoolFormModal({\n  isOpen,\n  onClose,\n  spool,\n  printersWithCalibrations = [],\n  currencySymbol,\n  onSpoolsCreated,\n}: SpoolFormModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const isEditing = !!spool;\n\n  // Form state\n  const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);\n  const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});\n  const [activeTab, setActiveTab] = useState<TabId>('filament');\n  const [weightTouched, setWeightTouched] = useState(false);\n  const [quickAdd, setQuickAdd] = useState(false);\n  const [quantity, setQuantity] = useState(1);\n\n  // Cloud presets\n  const [cloudAuthenticated, setCloudAuthenticated] = useState(false);\n  const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);\n  const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);\n  const [presetInputValue, setPresetInputValue] = useState('');\n\n  // Spool catalog\n  const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);\n\n  // Local presets (OrcaSlicer imports)\n  const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);\n\n  // Color catalog\n  const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);\n\n  // Color state\n  const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);\n\n  // PA Profile state\n  const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);\n  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());\n  const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());\n\n  // Use prop if provided, otherwise use self-fetched data\n  const resolvedCalibrations = printersWithCalibrations.length > 0\n    ? printersWithCalibrations\n    : fetchedCalibrations;\n\n  // Count selected PA profiles for tab badge\n  const selectedProfileCount = useMemo(() => {\n    return selectedProfiles.size;\n  }, [selectedProfiles]);\n\n  // Load recent colors on mount\n  useEffect(() => {\n    setRecentColors(loadRecentColors());\n  }, []);\n\n  // Fetch cloud presets and catalog when modal opens\n  useEffect(() => {\n    if (isOpen) {\n      const fetchData = async () => {\n        setLoadingCloudPresets(true);\n        try {\n          const status = await api.getCloudStatus();\n          setCloudAuthenticated(status.is_authenticated);\n          if (status.is_authenticated) {\n            const presets = await api.getFilamentPresets();\n            setCloudPresets(presets);\n          }\n        } catch (e) {\n          console.error('Failed to fetch cloud presets:', e);\n          setCloudAuthenticated(false);\n        } finally {\n          setLoadingCloudPresets(false);\n        }\n      };\n      fetchData();\n      api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);\n      api.getColorCatalog().then(setColorCatalog).catch(console.error);\n      api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);\n\n      // Fetch printer calibrations if not provided via props\n      if (printersWithCalibrations.length === 0) {\n        (async () => {\n          try {\n            const printers = await api.getPrinters();\n            const statuses = await Promise.all(\n              printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),\n            );\n            const results: PrinterWithCalibrations[] = [];\n            for (let i = 0; i < printers.length; i++) {\n              const printer = printers[i];\n              const status = statuses[i];\n              const connected = status?.connected ?? false;\n              let calibrations: PrinterWithCalibrations['calibrations'] = [];\n              if (connected) {\n                try {\n                  const kRes = await api.getKProfiles(printer.id);\n                  calibrations = kRes.profiles.map(p => ({\n                    cali_idx: p.slot_id,\n                    filament_id: p.filament_id,\n                    setting_id: p.setting_id || '',\n                    name: p.name,\n                    k_value: parseFloat(p.k_value) || 0,\n                    n_coef: parseFloat(p.n_coef) || 0,\n                    extruder_id: p.extruder_id,\n                    nozzle_diameter: p.nozzle_diameter,\n                  }));\n                } catch {\n                  // Printer may not support K-profiles\n                }\n              }\n              results.push({ printer: { ...printer, connected }, calibrations });\n            }\n            setFetchedCalibrations(results);\n          } catch (e) {\n            console.error('Failed to fetch printer calibrations:', e);\n          }\n        })();\n      }\n    }\n  }, [isOpen, printersWithCalibrations.length]);\n\n  // Build filament options: cloud → local → fallback\n  const filamentOptions = useMemo(\n    () => buildFilamentOptions(cloudPresets, new Set(), localPresets),\n    [cloudPresets, localPresets],\n  );\n\n  // Extract brands from presets\n  const baseAvailableBrands = useMemo(() => {\n    const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);\n    const catalogBrands = colorCatalog\n      .map(entry => entry.manufacturer?.trim())\n      .filter((brand): brand is string => !!brand);\n    const brandSet = new Set<string>([...presetBrands, ...catalogBrands]);\n    return Array.from(brandSet).sort((a, b) => a.localeCompare(b));\n  }, [cloudPresets, localPresets, colorCatalog]);\n\n  const baseAvailableMaterials = useMemo(() => {\n    const catalogMaterials = colorCatalog\n      .map(entry => entry.material?.trim())\n      .filter((material): material is string => !!material);\n    const materialSet = new Set<string>([...MATERIALS, ...catalogMaterials]);\n    return Array.from(materialSet).sort((a, b) => a.localeCompare(b));\n  }, [colorCatalog]);\n\n  const brandMaterialPairs = useMemo(() => {\n    const pairs: Array<{ brand: string; material: string }> = [];\n\n    for (const entry of colorCatalog) {\n      const brand = entry.manufacturer?.trim();\n      const material = entry.material?.trim();\n      if (brand && material) pairs.push({ brand, material });\n    }\n\n    for (const preset of cloudPresets) {\n      const parsed = parsePresetName(preset.name);\n      if (parsed.brand && parsed.material) {\n        pairs.push({ brand: parsed.brand, material: parsed.material });\n      }\n    }\n\n    for (const preset of localPresets) {\n      const parsed = parsePresetName(preset.name);\n      const brand = preset.filament_vendor?.trim() || parsed.brand;\n      const material = parsed.material;\n      if (brand && material) {\n        pairs.push({ brand, material });\n      }\n    }\n\n    return pairs;\n  }, [cloudPresets, colorCatalog, localPresets]);\n\n  const brandToMaterials = useMemo(() => {\n    const map = new Map<string, Set<string>>();\n    for (const pair of brandMaterialPairs) {\n      const brandKey = pair.brand.toLowerCase();\n      const materialKey = pair.material.toLowerCase();\n      if (!map.has(brandKey)) map.set(brandKey, new Set());\n      map.get(brandKey)!.add(materialKey);\n    }\n    return map;\n  }, [brandMaterialPairs]);\n\n  const materialToBrands = useMemo(() => {\n    const map = new Map<string, Set<string>>();\n    for (const pair of brandMaterialPairs) {\n      const brandKey = pair.brand.toLowerCase();\n      const materialKey = pair.material.toLowerCase();\n      if (!map.has(materialKey)) map.set(materialKey, new Set());\n      map.get(materialKey)!.add(brandKey);\n    }\n    return map;\n  }, [brandMaterialPairs]);\n\n  const availableBrands = useMemo(() => {\n    if (!formData.material) return baseAvailableBrands;\n    const materialKey = formData.material.toLowerCase();\n    const brandKeys = materialToBrands.get(materialKey);\n    if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;\n    return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));\n  }, [baseAvailableBrands, formData.material, materialToBrands]);\n\n  const availableMaterials = useMemo(() => {\n    if (!formData.brand) return baseAvailableMaterials;\n    const brandKey = formData.brand.toLowerCase();\n    const materialKeys = brandToMaterials.get(brandKey);\n    if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;\n    return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));\n  }, [baseAvailableMaterials, formData.brand, brandToMaterials]);\n\n  // Find selected preset option\n  const selectedPresetOption = useMemo(\n    () => findPresetOption(formData.slicer_filament, filamentOptions),\n    [formData.slicer_filament, filamentOptions],\n  );\n\n  // Reset form when modal opens/closes or spool changes\n  useEffect(() => {\n    if (isOpen) {\n      if (spool) {\n        // Legacy rows may carry a malformed rgba (e.g. the 7-char 'FFFFFFF'\n        // from #1055 before the create/update pattern was enforced). The\n        // backend SpoolUpdate schema rejects non-8-char hex on PATCH, so\n        // re-submitting a malformed value would 422 every edit on that spool\n        // — even edits that don't touch color. Normalize on load: any value\n        // that isn't exactly 8 hex chars falls back to the default, so the\n        // user can save unrelated fields (weight, material, note) without\n        // first being forced to fix a color they may not even be aware is\n        // broken. Saving also purges the bad value from the DB.\n        const validRgba = spool.rgba && /^[0-9A-Fa-f]{8}$/.test(spool.rgba) ? spool.rgba : '808080FF';\n        setFormData({\n          material: spool.material || '',\n          subtype: spool.subtype || '',\n          brand: spool.brand || '',\n          color_name: spool.color_name || '',\n          rgba: validRgba,\n          label_weight: spool.label_weight || 1000,\n          core_weight: spool.core_weight || 250,\n          core_weight_catalog_id: spool.core_weight_catalog_id ?? null,\n          weight_used: spool.weight_used || 0,\n          slicer_filament: spool.slicer_filament || '',\n          note: spool.note || '',\n          cost_per_kg: spool.cost_per_kg ?? null,\n        });\n        setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');\n\n        // Load K-profiles for this spool\n        if (spool.k_profiles && spool.k_profiles.length > 0) {\n          const profileKeys = new Set<string>();\n          for (const p of spool.k_profiles) {\n            if (p.cali_idx !== null && p.cali_idx !== undefined) {\n              profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);\n            }\n          }\n          setSelectedProfiles(profileKeys);\n        } else {\n          setSelectedProfiles(new Set());\n        }\n      } else {\n        setFormData(defaultFormData);\n        setPresetInputValue('');\n        setSelectedProfiles(new Set());\n        setQuickAdd(false);\n        setQuantity(1);\n      }\n      setErrors({});\n      setActiveTab('filament');\n      setWeightTouched(false);\n    }\n  }, [isOpen, spool]);\n\n  // Expand all printers in PA profile section when calibrations are available\n  useEffect(() => {\n    if (isOpen && resolvedCalibrations.length > 0) {\n      setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));\n    }\n  }, [isOpen, resolvedCalibrations]);\n\n  // Update field helper\n  const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {\n    setFormData(prev => ({ ...prev, [key]: value }));\n    if (key === 'weight_used') setWeightTouched(true);\n    if (errors[key]) {\n      setErrors(prev => ({ ...prev, [key]: undefined }));\n    }\n  };\n\n  // Handle color selection\n  const handleColorUsed = (color: ColorPreset) => {\n    setRecentColors(prev => saveRecentColor(color, prev));\n  };\n\n  // Mutations\n  const createMutation = useMutation({\n    mutationFn: (data: Record<string, unknown>) =>\n      api.createSpool(data as Parameters<typeof api.createSpool>[0]),\n    onSuccess: async (newSpool) => {\n      // Save K-profiles if any selected\n      if (selectedProfiles.size > 0 && newSpool?.id) {\n        await saveKProfiles(newSpool.id);\n      }\n      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      if (onSpoolsCreated) onSpoolsCreated([newSpool]);\n      showToast(t('inventory.spoolCreated'), 'success');\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const bulkCreateMutation = useMutation({\n    mutationFn: ({ data, qty }: { data: Record<string, unknown>; qty: number }) =>\n      api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),\n    onSuccess: async (newSpools) => {\n      if (selectedProfiles.size > 0) {\n        for (const spool of newSpools) {\n          await saveKProfiles(spool.id);\n        }\n      }\n      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      if (onSpoolsCreated) onSpoolsCreated(newSpools);\n      showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: (data: Record<string, unknown>) =>\n      api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),\n    onSuccess: async () => {\n      // Save K-profiles\n      if (spool?.id) {\n        await saveKProfiles(spool.id);\n      }\n      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      showToast(t('inventory.spoolUpdated'), 'success');\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const deleteTagMutation = useMutation({\n    mutationFn: () =>\n      api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // Fetch assignment for this spool (to show Unassign button)\n  const { data: assignments } = useQuery({\n    queryKey: ['spool-assignments'],\n    queryFn: () => api.getAssignments(),\n    enabled: isOpen && isEditing,\n  });\n  const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;\n\n  const unassignMutation = useMutation({\n    mutationFn: () => {\n      if (!spoolAssignment) throw new Error('No assignment');\n      return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);\n    },\n    onSuccess: async () => {\n      await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });\n      showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // Save K-profiles for selected calibrations\n  const saveKProfiles = async (spoolId: number) => {\n    if (selectedProfiles.size === 0) {\n      // Clear existing K-profiles\n      try {\n        await api.saveSpoolKProfiles(spoolId, []);\n      } catch {\n        // Ignore\n      }\n      return;\n    }\n\n    const profiles = [];\n    for (const key of selectedProfiles) {\n      const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');\n      const printerId = parseInt(printerIdStr);\n      const caliIdx = parseInt(caliIdxStr);\n      const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);\n\n      // Find the matching calibration\n      const pc = resolvedCalibrations.find(p => p.printer.id === printerId);\n      if (pc) {\n        const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);\n        if (cal) {\n          profiles.push({\n            printer_id: printerId,\n            extruder,\n            nozzle_diameter: cal.nozzle_diameter || '0.4',\n            k_value: cal.k_value,\n            name: cal.name || null,\n            cali_idx: cal.cali_idx,\n            setting_id: cal.setting_id || null,\n          });\n        }\n      }\n    }\n\n    if (profiles.length > 0) {\n      try {\n        await api.saveSpoolKProfiles(spoolId, profiles);\n      } catch (e) {\n        console.error('Failed to save K-profiles:', e);\n      }\n    }\n  };\n\n  // Close on Escape key\n  useEffect(() => {\n    if (!isOpen) return;\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    document.addEventListener('keydown', handleKeyDown);\n    return () => document.removeEventListener('keydown', handleKeyDown);\n  }, [isOpen, onClose]);\n\n  if (!isOpen) return null;\n\n  const handleSubmit = () => {\n    const validation = validateForm(formData, quickAdd);\n    if (!validation.isValid) {\n      setErrors(validation.errors);\n      // Switch to filament tab if there are errors there\n      if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {\n        setActiveTab('filament');\n      }\n      return;\n    }\n\n    // Find preset name from selected option\n    const presetName = selectedPresetOption?.displayName || presetInputValue || null;\n\n    const data: Record<string, unknown> = {\n      material: formData.material,\n      subtype: formData.subtype || null,\n      brand: formData.brand || null,\n      color_name: formData.color_name || null,\n      rgba: formData.rgba || null,\n      label_weight: formData.label_weight,\n      core_weight: formData.core_weight,\n      core_weight_catalog_id: formData.core_weight_catalog_id,\n      slicer_filament: formData.slicer_filament || null,\n      slicer_filament_name: presetName,\n      nozzle_temp_min: null,\n      nozzle_temp_max: null,\n      note: formData.note || null,\n      cost_per_kg: formData.cost_per_kg,\n    };\n\n    // Only send weight_used when creating or when explicitly changed by the user.\n    // This prevents stale cached values from overwriting usage-tracker data.\n    if (!isEditing || weightTouched) {\n      data.weight_used = formData.weight_used;\n    }\n\n    if (isEditing) {\n      updateMutation.mutate(data);\n    } else if (quantity > 1) {\n      bulkCreateMutation.mutate({ data, qty: quantity });\n    } else {\n      createMutation.mutate(data);\n    }\n  };\n\n  const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center\">\n      <div\n        className=\"absolute inset-0 bg-black/60 backdrop-blur-sm\"\n        onClick={onClose}\n      />\n\n      <div className=\"relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0\">\n          <h2 className=\"text-lg font-semibold text-white\">\n            {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}\n          </h2>\n          <button\n            onClick={onClose}\n            className=\"p-1 text-bambu-gray hover:text-white rounded transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Quick Add toggle — only in create mode */}\n        {!isEditing && (\n          <div className=\"flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0\">\n            <div className=\"flex items-center gap-2\">\n              <Zap className=\"w-4 h-4 text-amber-400\" />\n              <span className=\"text-sm text-white\">{t('inventory.quickAdd')}</span>\n            </div>\n            <button\n              type=\"button\"\n              onClick={() => {\n                setQuickAdd(!quickAdd);\n                if (!quickAdd) setActiveTab('filament');\n              }}\n              className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${\n                quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n              }`}\n            >\n              <span\n                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${\n                  quickAdd ? 'translate-x-4' : 'translate-x-0.5'\n                }`}\n              />\n            </button>\n          </div>\n        )}\n\n        {/* Tabs */}\n        <div className=\"flex border-b border-bambu-dark-tertiary flex-shrink-0\">\n          <button\n            onClick={() => setActiveTab('filament')}\n            className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${\n              activeTab === 'filament'\n                ? 'text-bambu-green border-b-2 border-bambu-green'\n                : 'text-bambu-gray hover:text-white'\n            }`}\n          >\n            <Palette className=\"w-4 h-4\" />\n            {t('inventory.filamentInfoTab')}\n          </button>\n          {!quickAdd && (\n            <button\n              onClick={() => setActiveTab('pa-profile')}\n              className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${\n                activeTab === 'pa-profile'\n                  ? 'text-bambu-green border-b-2 border-bambu-green'\n                  : 'text-bambu-gray hover:text-white'\n              }`}\n            >\n              <Beaker className=\"w-4 h-4\" />\n              {t('inventory.paProfileTab')}\n              {selectedProfileCount > 0 && (\n                <span className=\"text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green\">\n                  {selectedProfileCount}\n                </span>\n              )}\n            </button>\n          )}\n        </div>\n\n        {/* Content */}\n        <div className=\"p-4 overflow-y-auto flex-1\" style={{ scrollbarGutter: 'stable' }}>\n          {activeTab === 'filament' ? (\n            <div className=\"space-y-6\">\n              {/* Filament Info Section */}\n              <div>\n                <h3 className=\"text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3\">\n                  {t('inventory.filamentInfo')}\n                </h3>\n                <FilamentSection\n                  formData={formData}\n                  updateField={updateField}\n                  cloudAuthenticated={cloudAuthenticated}\n                  loadingCloudPresets={loadingCloudPresets}\n                  presetInputValue={presetInputValue}\n                  setPresetInputValue={setPresetInputValue}\n                  selectedPresetOption={selectedPresetOption}\n                  filamentOptions={filamentOptions}\n                  availableBrands={availableBrands}\n                  availableMaterials={availableMaterials}\n                  quickAdd={quickAdd}\n                  quantity={quantity}\n                  onQuantityChange={setQuantity}\n                  errors={errors}\n                />\n              </div>\n\n              {/* Color Section */}\n              <div>\n                <h3 className=\"text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3\">\n                  {t('inventory.color')}\n                </h3>\n                <ColorSection\n                  formData={formData}\n                  updateField={updateField}\n                  recentColors={recentColors}\n                  onColorUsed={handleColorUsed}\n                  catalogColors={colorCatalog}\n                />\n              </div>\n\n              {/* Additional Section */}\n              <div>\n                <h3 className=\"text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3\">\n                  {t('inventory.additional')}\n                </h3>\n                <AdditionalSection\n                  formData={formData}\n                  updateField={updateField}\n                  spoolCatalog={spoolCatalog}\n                  currencySymbol={currencySymbol}\n                />\n              </div>\n\n              {/* Usage History (only when editing) */}\n              {isEditing && spool && (\n                <div>\n                  <SpoolUsageHistory spoolId={spool.id} />\n                </div>\n              )}\n            </div>\n          ) : (\n            <PAProfileSection\n              formData={formData}\n              updateField={updateField}\n              printersWithCalibrations={resolvedCalibrations}\n              selectedProfiles={selectedProfiles}\n              setSelectedProfiles={setSelectedProfiles}\n              expandedPrinters={expandedPrinters}\n              setExpandedPrinters={setExpandedPrinters}\n            />\n          )}\n        </div>\n\n        {/* Footer */}\n        <div className=\"flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0\">\n          {isEditing && (\n            <div className=\"flex gap-2 mr-auto\">\n              <Button\n                variant=\"secondary\"\n                onClick={() => deleteTagMutation.mutate()}\n                disabled={isPending || !spool?.tag_uid}\n              >\n                <Tag className=\"w-4 h-4\" />\n                {t('inventory.deleteTag', 'Delete Tag')}\n              </Button>\n              <Button\n                variant=\"secondary\"\n                onClick={() => unassignMutation.mutate()}\n                disabled={isPending || !spoolAssignment}\n              >\n                <Unlink className=\"w-4 h-4\" />\n                {t('inventory.unassignSpool', 'Unassign')}\n              </Button>\n            </div>\n          )}\n          <div className=\"flex gap-2 ml-auto\">\n          <Button variant=\"secondary\" onClick={onClose}>\n            {t('common.cancel')}\n          </Button>\n          <Button\n            onClick={handleSubmit}\n            disabled={isPending}\n          >\n            {isPending ? (\n              <>\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n                {t('common.saving')}\n              </>\n            ) : (\n              <>\n                <Save className=\"w-4 h-4\" />\n                {isEditing ? t('common.save') : t('inventory.addSpool')}\n              </>\n            )}\n          </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/SpoolUsageHistory.tsx",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Loader2, Trash2, Clock } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { SpoolUsageRecord } from '../api/client';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\nfunction formatDate(dateStr: string): string {\n  const date = new Date(dateStr);\n  return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' }) +\n    ' ' + date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });\n}\n\ninterface SpoolUsageHistoryProps {\n  spoolId: number;\n}\n\nconst STATUS_COLORS: Record<string, string> = {\n  completed: 'text-bambu-green',\n  failed: 'text-red-400',\n  aborted: 'text-yellow-400',\n};\n\nexport function SpoolUsageHistory({ spoolId }: SpoolUsageHistoryProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const { data: history, isLoading } = useQuery({\n    queryKey: ['spool-usage', spoolId],\n    queryFn: () => api.getSpoolUsageHistory(spoolId),\n  });\n\n  const clearMutation = useMutation({\n    mutationFn: () => api.clearSpoolUsageHistory(spoolId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['spool-usage', spoolId] });\n      showToast(t('inventory.historyCleared'), 'success');\n    },\n  });\n\n  if (isLoading) {\n    return (\n      <div className=\"flex justify-center py-4\">\n        <Loader2 className=\"w-5 h-5 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  if (!history || history.length === 0) {\n    return (\n      <div className=\"text-center py-4 text-bambu-gray text-sm\">\n        <Clock className=\"w-5 h-5 mx-auto mb-2 opacity-50\" />\n        {t('inventory.noUsageHistory')}\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex items-center justify-between\">\n        <h4 className=\"text-sm font-medium text-white\">{t('inventory.usageHistory')}</h4>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={() => clearMutation.mutate()}\n          disabled={clearMutation.isPending}\n          className=\"text-xs text-bambu-gray hover:text-red-400\"\n        >\n          <Trash2 className=\"w-3 h-3 mr-1\" />\n          {t('inventory.clearHistory')}\n        </Button>\n      </div>\n      <div className=\"max-h-48 overflow-y-auto space-y-1\">\n        {history.map((record: SpoolUsageRecord) => (\n          <div\n            key={record.id}\n            className=\"flex items-center justify-between p-2 rounded bg-bambu-dark/50 text-xs\"\n          >\n            <div className=\"flex-1 min-w-0\">\n              <span className=\"text-bambu-gray\">{formatDate(record.created_at)}</span>\n              {record.print_name && (\n                <span className=\"text-white ml-2 truncate\" title={record.print_name}>\n                  {record.print_name}\n                </span>\n              )}\n            </div>\n            <div className=\"flex items-center gap-2 flex-shrink-0 ml-2\">\n              <span className=\"text-white font-medium\">{record.weight_used.toFixed(1)}g</span>\n              <span className=\"text-bambu-gray\">({record.percent_used}%)</span>\n              <span className={STATUS_COLORS[record.status] || 'text-bambu-gray'}>\n                {record.status}\n              </span>\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/SpoolmanSettings.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { SpoolmanSyncResult, Printer } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { ConfirmModal } from './ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\n\nexport function SpoolmanSettings() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [localEnabled, setLocalEnabled] = useState(false);\n  const [localUrl, setLocalUrl] = useState('');\n  const [localSyncMode, setLocalSyncMode] = useState('auto');\n  const [localDisableWeightSync, setLocalDisableWeightSync] = useState(false);\n  const [localReportPartialUsage, setLocalReportPartialUsage] = useState(true);\n  const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');\n  const [isInitialized, setIsInitialized] = useState(false);\n  const [showAllSkipped, setShowAllSkipped] = useState(false);\n  const [showAmsSyncConfirm, setShowAmsSyncConfirm] = useState(false);\n\n  // Fetch Spoolman settings\n  const { data: settings, isLoading: settingsLoading } = useQuery({\n    queryKey: ['spoolman-settings'],\n    queryFn: api.getSpoolmanSettings,\n  });\n\n  // Fetch Spoolman status\n  const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({\n    queryKey: ['spoolman-status'],\n    queryFn: api.getSpoolmanStatus,\n    refetchInterval: 30000, // Refresh every 30 seconds\n  });\n\n  // Fetch printers for the dropdown\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  // Initialize local state from settings\n  useEffect(() => {\n    if (settings) {\n      setLocalEnabled(settings.spoolman_enabled === 'true');\n      setLocalUrl(settings.spoolman_url || '');\n      setLocalSyncMode(settings.spoolman_sync_mode || 'auto');\n      setLocalDisableWeightSync(settings.spoolman_disable_weight_sync === 'true');\n      setLocalReportPartialUsage(settings.spoolman_report_partial_usage !== 'false');\n      setIsInitialized(true);\n    }\n  }, [settings]);\n\n  // Auto-save when settings change (after initial load)\n  // Intentionally omit saveMutation and settings from deps to avoid infinite loops\n  useEffect(() => {\n    if (!isInitialized || !settings) return;\n\n    const hasChanges =\n      (settings.spoolman_enabled === 'true') !== localEnabled ||\n      (settings.spoolman_url || '') !== localUrl ||\n      (settings.spoolman_sync_mode || 'auto') !== localSyncMode ||\n      (settings.spoolman_disable_weight_sync === 'true') !== localDisableWeightSync ||\n      (settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage;\n\n    if (hasChanges) {\n      const timeoutId = setTimeout(() => {\n        saveMutation.mutate();\n      }, 500);\n      return () => clearTimeout(timeoutId);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, isInitialized]);\n\n  // Save mutation\n  const saveMutation = useMutation({\n    mutationFn: () =>\n      api.updateSpoolmanSettings({\n        spoolman_enabled: localEnabled ? 'true' : 'false',\n        spoolman_url: localUrl,\n        spoolman_sync_mode: localSyncMode,\n        spoolman_disable_weight_sync: localDisableWeightSync ? 'true' : 'false',\n        spoolman_report_partial_usage: localReportPartialUsage ? 'true' : 'false',\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });\n      queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });\n      queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });\n      queryClient.invalidateQueries({ queryKey: ['settings'] });\n      showToast(t('settings.toast.settingsSaved'));\n    },\n  });\n\n  // Connect mutation\n  const connectMutation = useMutation({\n    mutationFn: api.connectSpoolman,\n    onSuccess: () => {\n      refetchStatus();\n    },\n  });\n\n  // Disconnect mutation\n  const disconnectMutation = useMutation({\n    mutationFn: api.disconnectSpoolman,\n    onSuccess: () => {\n      refetchStatus();\n    },\n  });\n\n  // Sync all mutation\n  const syncAllMutation = useMutation({\n    mutationFn: api.syncAllPrintersAms,\n    onSuccess: (data: SpoolmanSyncResult) => {\n      if (data.success) {\n        // Show success message\n      }\n    },\n  });\n\n  // Sync single printer mutation\n  const syncPrinterMutation = useMutation({\n    mutationFn: (printerId: number) => api.syncPrinterAms(printerId),\n    onSuccess: (data: SpoolmanSyncResult) => {\n      if (data.success) {\n        // Show success message\n      }\n    },\n  });\n\n  // Helper to handle sync based on selection\n  const handleSync = () => {\n    if (selectedPrinterId === 'all') {\n      syncAllMutation.mutate();\n    } else {\n      syncPrinterMutation.mutate(selectedPrinterId);\n    }\n  };\n\n  // Inventory AMS weight sync mutation\n  const amsSyncMutation = useMutation({\n    mutationFn: api.syncWeightsFromAms,\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['spools'] });\n      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      showToast(t('settings.amsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success');\n      setShowAmsSyncConfirm(false);\n    },\n    onError: () => {\n      showToast(t('settings.amsSyncError'), 'error');\n      setShowAmsSyncConfirm(false);\n    },\n  });\n\n  // Combine mutation states\n  const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;\n  const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;\n  const syncSuccess = selectedPrinterId === 'all' ? syncAllMutation.isSuccess : syncPrinterMutation.isSuccess;\n\n  if (settingsLoading) {\n    return (\n      <Card id=\"card-spoolman\">\n        <CardHeader>\n          <div className=\"flex items-center gap-2\">\n            <Database className=\"w-5 h-5 text-bambu-green\" />\n            <h2 className=\"text-lg font-semibold text-white\">{t('settings.filamentTracking')}</h2>\n          </div>\n        </CardHeader>\n        <CardContent>\n          <div className=\"flex justify-center py-8\">\n            <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n          </div>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  return (\n    <Card id=\"card-spoolman\">\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <Database className=\"w-5 h-5 text-bambu-green\" />\n            <h2 className=\"text-lg font-semibold text-white\">{t('settings.filamentTracking')}</h2>\n          </div>\n          {saveMutation.isPending && (\n            <Loader2 className=\"w-4 h-4 text-bambu-green animate-spin\" />\n          )}\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <p className=\"text-sm text-bambu-gray\">\n          {t('settings.filamentTrackingDesc')}\n        </p>\n\n        {/* Mode selector cards */}\n        <div className=\"grid grid-cols-2 gap-3\">\n          {/* Built-in Inventory */}\n          <button\n            type=\"button\"\n            onClick={() => setLocalEnabled(false)}\n            className={`p-3 rounded-lg border-2 text-left transition-colors ${\n              !localEnabled\n                ? 'border-bambu-green bg-bambu-green/10'\n                : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'\n            }`}\n          >\n            <div className=\"flex items-center gap-2 mb-1.5\">\n              <Package className={`w-4 h-4 ${!localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n              <span className={`text-sm font-medium ${!localEnabled ? 'text-white' : 'text-bambu-gray'}`}>\n                {t('settings.trackingModeBuiltIn')}\n              </span>\n            </div>\n            <p className={`text-xs ${!localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>\n              {t('settings.trackingModeBuiltInDesc')}\n            </p>\n            {!localEnabled && (\n              <div className=\"flex items-center gap-1 mt-2\">\n                <Check className=\"w-3 h-3 text-bambu-green\" />\n                <span className=\"text-xs text-bambu-green\">{t('common.enabled')}</span>\n              </div>\n            )}\n          </button>\n\n          {/* Spoolman */}\n          <button\n            type=\"button\"\n            onClick={() => setLocalEnabled(true)}\n            className={`p-3 rounded-lg border-2 text-left transition-colors ${\n              localEnabled\n                ? 'border-bambu-green bg-bambu-green/10'\n                : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'\n            }`}\n          >\n            <div className=\"flex items-center gap-2 mb-1.5\">\n              <ExternalLink className={`w-4 h-4 ${localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n              <span className={`text-sm font-medium ${localEnabled ? 'text-white' : 'text-bambu-gray'}`}>\n                Spoolman\n              </span>\n            </div>\n            <p className={`text-xs ${localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>\n              {t('settings.trackingModeSpoolmanDesc')}\n            </p>\n            {localEnabled && (\n              <div className=\"flex items-center gap-1 mt-2\">\n                <Check className=\"w-3 h-3 text-bambu-green\" />\n                <span className=\"text-xs text-bambu-green\">{t('common.enabled')}</span>\n              </div>\n            )}\n          </button>\n        </div>\n\n        {/* Built-in Inventory details */}\n        {!localEnabled && (\n          <div className=\"space-y-3\">\n            <div className=\"p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg\">\n              <div className=\"flex gap-2\">\n                <Info className=\"w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5\" />\n                <div className=\"text-xs text-bambu-gray\">\n                  <ul className=\"list-disc list-inside space-y-0.5\">\n                    <li>{t('settings.builtInFeatureRfid')}</li>\n                    <li>{t('settings.builtInFeatureUsage')}</li>\n                    <li>{t('settings.builtInFeatureCatalog')}</li>\n                    <li>{t('settings.builtInFeatureThirdParty')}</li>\n                  </ul>\n                </div>\n              </div>\n            </div>\n\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              onClick={() => setShowAmsSyncConfirm(true)}\n              disabled={amsSyncMutation.isPending}\n            >\n              {amsSyncMutation.isPending ? (\n                <Loader2 className=\"w-4 h-4 mr-2 animate-spin\" />\n              ) : (\n                <RefreshCw className=\"w-4 h-4 mr-2\" />\n              )}\n              {t('settings.amsSyncButton')}\n            </Button>\n          </div>\n        )}\n\n        {/* Spoolman settings - only shown when Spoolman mode is selected */}\n        {localEnabled && (\n          <div className=\"space-y-4\">\n            {/* Info banner about sync requirements */}\n            <div className=\"p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg\">\n              <div className=\"flex gap-2\">\n                <Info className=\"w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5\" />\n                <div className=\"text-xs text-blue-300\">\n                  <p className=\"font-medium mb-1\">{t('settings.howSyncWorks')}</p>\n                  <ul className=\"list-disc list-inside space-y-0.5 text-blue-300/80\">\n                    <li>{t('settings.syncInfoRfidOnly')}</li>\n                    <li>{t('settings.syncInfoAutoCreate')}</li>\n                    <li>{t('settings.syncInfoThirdPartySkipped')}</li>\n                  </ul>\n                  <p className=\"font-medium mt-2 mb-1\">{t('settings.linkingExistingSpools')}</p>\n                  <p className=\"text-blue-300/80\">\n                    {t('settings.linkingExistingSpoolsDesc')}\n                  </p>\n                </div>\n              </div>\n            </div>\n\n            {/* URL input */}\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">\n                {t('settings.spoolmanUrl')}\n              </label>\n              <input\n                type=\"text\"\n                placeholder=\"http://192.168.1.100:7912\"\n                value={localUrl}\n                onChange={(e) => setLocalUrl(e.target.value)}\n                className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none\"\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">\n                {t('settings.spoolmanUrlHint')}\n              </p>\n            </div>\n\n            {/* Sync mode */}\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">\n                {t('settings.syncMode')}\n              </label>\n              <select\n                value={localSyncMode}\n                onChange={(e) => setLocalSyncMode(e.target.value)}\n                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\"\n              >\n                <option value=\"auto\">{t('settings.syncModeAuto')}</option>\n                <option value=\"manual\">{t('settings.syncModeManual')}</option>\n              </select>\n              <p className=\"text-xs text-bambu-gray mt-1\">\n                {localSyncMode === 'auto'\n                  ? t('settings.syncModeAutoDesc')\n                  : t('settings.syncModeManualDesc')}\n              </p>\n            </div>\n\n            {/* Disable Weight Sync toggle - only show when sync mode is auto */}\n            {localSyncMode === 'auto' && (\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('spoolman.disableWeightSync')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('spoolman.disableWeightSyncDesc')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localDisableWeightSync}\n                    onChange={(e) => setLocalDisableWeightSync(e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n            )}\n\n            {/* Report Partial Usage toggle - only show when weight sync is disabled */}\n            {localDisableWeightSync && (\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('spoolman.reportPartialUsage')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('spoolman.reportPartialUsageDesc')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localReportPartialUsage}\n                    onChange={(e) => setLocalReportPartialUsage(e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n            )}\n\n            {/* Connection status */}\n            <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n              <div className=\"flex items-center justify-between mb-3\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-sm text-bambu-gray\">{t('settings.status')}:</span>\n                  {statusLoading ? (\n                    <Loader2 className=\"w-4 h-4 text-bambu-gray animate-spin\" />\n                  ) : status?.connected ? (\n                    <span className=\"flex items-center gap-1 text-sm text-green-500\">\n                      <Check className=\"w-4 h-4\" />\n                      {t('settings.spoolmanConnected')}\n                    </span>\n                  ) : (\n                    <span className=\"flex items-center gap-1 text-sm text-red-500\">\n                      <X className=\"w-4 h-4\" />\n                      {t('settings.spoolmanDisconnected')}\n                    </span>\n                  )}\n                </div>\n                <div className=\"flex gap-2\">\n                  {status?.connected ? (\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={() => disconnectMutation.mutate()}\n                      disabled={disconnectMutation.isPending}\n                    >\n                      {disconnectMutation.isPending ? (\n                        <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      ) : (\n                        <Link2Off className=\"w-4 h-4\" />\n                      )}\n                      {t('settings.disconnect')}\n                    </Button>\n                  ) : (\n                    <Button\n                      size=\"sm\"\n                      onClick={() => connectMutation.mutate()}\n                      disabled={connectMutation.isPending || !localUrl}\n                    >\n                      {connectMutation.isPending ? (\n                        <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      ) : (\n                        <Link2 className=\"w-4 h-4\" />\n                      )}\n                      {t('settings.connect')}\n                    </Button>\n                  )}\n                </div>\n              </div>\n\n              {/* Error display */}\n              {connectMutation.isError && (\n                <div className=\"mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400\">\n                  {(connectMutation.error as Error).message}\n                </div>\n              )}\n\n              {/* Manual sync section */}\n              {status?.connected && (\n                <div className=\"space-y-3\">\n                  <div>\n                    <p className=\"text-sm text-white\">{t('settings.syncAmsData')}</p>\n                    <p className=\"text-xs text-bambu-gray\">\n                      {t('settings.syncAmsDataDesc')}\n                    </p>\n                  </div>\n                  <div className=\"flex items-center gap-2\">\n                    {/* Printer selector */}\n                    <div className=\"relative flex-1\">\n                      <select\n                        value={selectedPrinterId}\n                        onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}\n                        className=\"w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                      >\n                        <option value=\"all\">{t('settings.allPrinters')}</option>\n                        {printers?.map((printer: Printer) => (\n                          <option key={printer.id} value={printer.id}>\n                            {printer.name}\n                          </option>\n                        ))}\n                      </select>\n                      <ChevronDown className=\"absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                    </div>\n                    {/* Sync button */}\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={handleSync}\n                      disabled={isSyncing}\n                    >\n                      {isSyncing ? (\n                        <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      ) : (\n                        <RefreshCw className=\"w-4 h-4\" />\n                      )}\n                      {t('spoolman.sync')}\n                    </Button>\n                  </div>\n                </div>\n              )}\n\n              {/* Sync result */}\n              {syncSuccess && syncResult && (\n                <div className=\"mt-3 space-y-2\">\n                  {/* Main result */}\n                  <div\n                    className={`p-2 rounded text-sm ${\n                      syncResult.success\n                        ? 'bg-green-500/20 border border-green-500/50 text-green-400'\n                        : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'\n                    }`}\n                  >\n                    {syncResult.success\n                      ? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`\n                      : `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}\n                  </div>\n\n                  {/* Skipped spools */}\n                  {syncResult.skipped_count > 0 && (\n                    <div className=\"p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm\">\n                      <div className=\"flex items-center justify-between text-amber-400 mb-1\">\n                        <div className=\"flex items-center gap-1.5\">\n                          <AlertTriangle className=\"w-3.5 h-3.5\" />\n                          <span className=\"font-medium\">\n                            {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped\n                          </span>\n                        </div>\n                        {syncResult.skipped_count > 5 && (\n                          <button\n                            onClick={() => setShowAllSkipped(!showAllSkipped)}\n                            className=\"text-xs text-amber-400 hover:text-amber-300 underline\"\n                          >\n                            {showAllSkipped ? 'Show less' : 'Show all'}\n                          </button>\n                        )}\n                      </div>\n                      <ul className=\"text-xs text-amber-300/80 space-y-0.5\">\n                        {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (\n                          <li key={i} className=\"flex items-center gap-2\">\n                            {s.color && (\n                              <span\n                                className=\"w-3 h-3 rounded-full border border-black/20\"\n                                style={{ backgroundColor: `#${s.color}` }}\n                              />\n                            )}\n                            <span>{s.location}</span>\n                            <span className=\"text-amber-300/60\">- {s.reason}</span>\n                          </li>\n                        ))}\n                        {!showAllSkipped && syncResult.skipped_count > 5 && (\n                          <li className=\"text-amber-300/60 italic\">\n                            ...and {syncResult.skipped_count - 5} more\n                          </li>\n                        )}\n                      </ul>\n                    </div>\n                  )}\n\n                  {/* Errors */}\n                  {syncResult.errors.length > 0 && (\n                    <div className=\"p-2 bg-red-500/10 border border-red-500/30 rounded text-sm\">\n                      <div className=\"text-red-400 font-medium mb-1\">Errors:</div>\n                      <ul className=\"text-xs text-red-300/80 space-y-0.5\">\n                        {syncResult.errors.map((err, i) => (\n                          <li key={i}>{err}</li>\n                        ))}\n                      </ul>\n                    </div>\n                  )}\n                </div>\n              )}\n            </div>\n          </div>\n        )}\n      </CardContent>\n\n      {showAmsSyncConfirm && (\n        <ConfirmModal\n          title={t('settings.amsSyncTitle')}\n          message={t('settings.amsSyncMessage')}\n          confirmText={t('settings.amsSyncButton')}\n          variant=\"warning\"\n          isLoading={amsSyncMutation.isPending}\n          loadingText={t('settings.amsSyncing')}\n          onConfirm={() => amsSyncMutation.mutate()}\n          onCancel={() => setShowAmsSyncConfirm(false)}\n        />\n      )}\n    </Card>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/SwitchbarPopover.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap, Radio, Eye } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { SmartPlug } from '../api/client';\nimport { ConfirmModal } from './ConfirmModal';\n\ninterface SwitchbarPopoverProps {\n  onClose: () => void;\n}\n\nfunction SwitchItem({ plug }: { plug: SmartPlug }) {\n  const queryClient = useQueryClient();\n  const [confirmAction, setConfirmAction] = useState<'on' | 'off' | null>(null);\n\n  // Fetch current status\n  const { data: status, isLoading: statusLoading } = useQuery({\n    queryKey: ['smart-plug-status', plug.id],\n    queryFn: () => api.getSmartPlugStatus(plug.id),\n    refetchInterval: 10000, // Refresh every 10 seconds when popover is open\n  });\n\n  // Control mutation\n  const controlMutation = useMutation({\n    mutationFn: (action: 'on' | 'off') => api.controlSmartPlug(plug.id, action),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });\n    },\n  });\n\n  const isOn = status?.state === 'ON';\n  // For MQTT plugs, consider reachable if we have power data\n  const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);\n  const isReachable = (status?.reachable ?? false) || hasMqttData;\n  const isPending = controlMutation.isPending;\n  const isMqtt = plug.plug_type === 'mqtt';\n\n  const handleConfirm = () => {\n    if (confirmAction) {\n      controlMutation.mutate(confirmAction);\n      setConfirmAction(null);\n    }\n  };\n\n  return (\n    <>\n      <div className=\"flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\">\n        <div className=\"flex items-center gap-2\">\n          <div className={`p-1.5 rounded ${\n            isMqtt\n              ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')\n              : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')\n          }`}>\n            {isMqtt ? (\n              <Radio className={`w-4 h-4 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />\n            ) : (\n              <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />\n            )}\n          </div>\n          <div>\n            <p className=\"text-sm text-white font-medium\">{plug.name}</p>\n            <div className=\"flex items-center gap-1 text-xs\">\n              {statusLoading ? (\n                <Loader2 className=\"w-3 h-3 text-bambu-gray animate-spin\" />\n              ) : isMqtt ? (\n                /* MQTT plugs show power and monitor-only indicator */\n                isReachable ? (\n                  <>\n                    <Zap className=\"w-3 h-3 text-teal-400\" />\n                    <span className=\"text-teal-400\">{Math.round(status?.energy?.power ?? 0)}W</span>\n                    <span className=\"text-bambu-gray mx-1\">|</span>\n                    <Eye className=\"w-3 h-3 text-bambu-gray\" />\n                    <span className=\"text-bambu-gray\">Monitor</span>\n                  </>\n                ) : (\n                  <>\n                    <WifiOff className=\"w-3 h-3 text-status-error\" />\n                    <span className=\"text-status-error\">Waiting</span>\n                  </>\n                )\n              ) : isReachable ? (\n                <>\n                  <Wifi className=\"w-3 h-3 text-status-ok\" />\n                  <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>\n                  {status?.energy?.power !== null && status?.energy?.power !== undefined && (\n                    <>\n                      <span className=\"text-bambu-gray mx-1\">|</span>\n                      <Zap className=\"w-3 h-3 text-yellow-400\" />\n                      <span className=\"text-yellow-400\">{Math.round(status.energy.power)}W</span>\n                    </>\n                  )}\n                </>\n              ) : (\n                <>\n                  <WifiOff className=\"w-3 h-3 text-status-error\" />\n                  <span className=\"text-status-error\">Offline</span>\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n\n        {/* Hide on/off buttons for MQTT plugs (monitor-only) */}\n        {!isMqtt && (\n          <div className=\"flex gap-1\">\n            <button\n              onClick={() => setConfirmAction('on')}\n              disabled={!isReachable || isPending}\n              className={`p-1.5 rounded transition-colors ${\n                isOn\n                  ? 'bg-bambu-green text-white'\n                  : 'bg-bambu-dark text-bambu-gray hover:text-white'\n              } disabled:opacity-50 disabled:cursor-not-allowed`}\n              title=\"Turn On\"\n            >\n              {isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Power className=\"w-4 h-4\" />}\n            </button>\n            <button\n              onClick={() => setConfirmAction('off')}\n              disabled={!isReachable || isPending}\n              className={`p-1.5 rounded transition-colors ${\n                !isOn && isReachable\n                  ? 'bg-bambu-dark-tertiary text-white'\n                  : 'bg-bambu-dark text-bambu-gray hover:text-white'\n              } disabled:opacity-50 disabled:cursor-not-allowed`}\n              title=\"Turn Off\"\n            >\n              {isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <PowerOff className=\"w-4 h-4\" />}\n            </button>\n          </div>\n        )}\n      </div>\n\n      {confirmAction && (\n        <ConfirmModal\n          title={`Turn ${confirmAction === 'on' ? 'On' : 'Off'} Smart Plug`}\n          message={`Are you sure you want to turn ${confirmAction === 'on' ? 'on' : 'off'} \"${plug.name}\"?`}\n          confirmText={confirmAction === 'on' ? 'Turn On' : 'Turn Off'}\n          variant={confirmAction === 'off' ? 'warning' : 'default'}\n          onConfirm={handleConfirm}\n          onCancel={() => setConfirmAction(null)}\n        />\n      )}\n    </>\n  );\n}\n\nexport function SwitchbarPopover({ onClose }: SwitchbarPopoverProps) {\n  const { t } = useTranslation();\n  // Fetch all smart plugs\n  const { data: plugs, isLoading } = useQuery({\n    queryKey: ['smart-plugs'],\n    queryFn: api.getSmartPlugs,\n  });\n\n  // Filter to only show plugs with show_in_switchbar enabled\n  const switchbarPlugs = plugs?.filter(p => p.show_in_switchbar) || [];\n\n  return (\n    <div\n      className=\"absolute bottom-full left-0 mb-2 w-72 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-xl z-50\"\n      onMouseLeave={onClose}\n    >\n      {/* Header */}\n      <div className=\"px-4 py-3 border-b border-bambu-dark-tertiary\">\n        <h3 className=\"text-sm font-semibold text-white flex items-center gap-2\">\n          <Plug className=\"w-4 h-4 text-bambu-green\" />\n          Smart Switches\n        </h3>\n      </div>\n\n      {/* Content */}\n      <div className=\"p-2 max-h-80 overflow-y-auto\">\n        {isLoading ? (\n          <div className=\"flex items-center justify-center py-8\">\n            <Loader2 className=\"w-6 h-6 text-bambu-gray animate-spin\" />\n          </div>\n        ) : switchbarPlugs.length === 0 ? (\n          <div className=\"text-center py-6 px-4\">\n            <Plug className=\"w-8 h-8 text-bambu-gray mx-auto mb-2\" />\n            <p className=\"text-sm text-bambu-gray\">{t('smartPlugs.noSwitchesInSwitchbar')}</p>\n            <p className=\"text-xs text-bambu-gray mt-1\">\n              {t('smartPlugs.enableSwitchbarHint')}\n            </p>\n          </div>\n        ) : (\n          <div className=\"space-y-1\">\n            {switchbarPlugs.map(plug => (\n              <SwitchItem key={plug.id} plug={plug} />\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/TagManagementModal.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { X, Tag, Pencil, Trash2, Loader2, Search, Check, AlertTriangle } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { TagInfo } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\n\ninterface TagManagementModalProps {\n  onClose: () => void;\n}\n\nexport function TagManagementModal({ onClose }: TagManagementModalProps) {\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [search, setSearch] = useState('');\n  const [editingTag, setEditingTag] = useState<string | null>(null);\n  const [editValue, setEditValue] = useState('');\n  const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);\n  const [sortBy, setSortBy] = useState<'count' | 'name'>('count');\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        if (editingTag) {\n          setEditingTag(null);\n        } else if (deleteConfirm) {\n          setDeleteConfirm(null);\n        } else {\n          onClose();\n        }\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose, editingTag, deleteConfirm]);\n\n  const { data: tags, isLoading } = useQuery({\n    queryKey: ['tags'],\n    queryFn: api.getTags,\n  });\n\n  const renameMutation = useMutation({\n    mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>\n      api.renameTag(oldName, newName),\n    onSuccess: (data, { oldName, newName }) => {\n      queryClient.invalidateQueries({ queryKey: ['tags'] });\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(`Renamed \"${oldName}\" to \"${newName}\" in ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);\n      setEditingTag(null);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Failed to rename tag', 'error');\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (name: string) => api.deleteTag(name),\n    onSuccess: (data, name) => {\n      queryClient.invalidateQueries({ queryKey: ['tags'] });\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(`Deleted \"${name}\" from ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);\n      setDeleteConfirm(null);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Failed to delete tag', 'error');\n    },\n  });\n\n  const startEdit = (tag: TagInfo) => {\n    setEditingTag(tag.name);\n    setEditValue(tag.name);\n    setDeleteConfirm(null);\n  };\n\n  const cancelEdit = () => {\n    setEditingTag(null);\n    setEditValue('');\n  };\n\n  const submitEdit = () => {\n    if (!editingTag || !editValue.trim()) return;\n    const newName = editValue.trim();\n    if (newName === editingTag) {\n      cancelEdit();\n      return;\n    }\n    renameMutation.mutate({ oldName: editingTag, newName });\n  };\n\n  const handleEditKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      e.preventDefault();\n      submitEdit();\n    } else if (e.key === 'Escape') {\n      e.preventDefault();\n      cancelEdit();\n    }\n  };\n\n  const confirmDelete = (name: string) => {\n    setDeleteConfirm(name);\n    setEditingTag(null);\n  };\n\n  const executeDelete = () => {\n    if (deleteConfirm) {\n      deleteMutation.mutate(deleteConfirm);\n    }\n  };\n\n  // Filter and sort tags\n  const filteredTags = tags\n    ?.filter(t => t.name.toLowerCase().includes(search.toLowerCase()))\n    .sort((a, b) => {\n      if (sortBy === 'count') {\n        return b.count - a.count || a.name.localeCompare(b.name);\n      }\n      return a.name.localeCompare(b.name);\n    });\n\n  const totalUsage = tags?.reduce((sum, t) => sum + t.count, 0) || 0;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n      <Card className=\"w-full max-w-lg max-h-[80vh] flex flex-col\">\n        <CardContent className=\"p-0 flex flex-col min-h-0\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0\">\n            <div className=\"flex items-center gap-2\">\n              <Tag className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-xl font-semibold text-white\">Manage Tags</h2>\n            </div>\n            <button\n              onClick={onClose}\n              className=\"text-bambu-gray hover:text-white transition-colors\"\n            >\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Search and sort */}\n          <div className=\"p-4 border-b border-bambu-dark-tertiary flex-shrink-0\">\n            <div className=\"flex gap-2\">\n              <div className=\"relative flex-1\">\n                <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n                <input\n                  type=\"text\"\n                  placeholder=\"Search tags...\"\n                  className=\"w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                  value={search}\n                  onChange={(e) => setSearch(e.target.value)}\n                />\n              </div>\n              <select\n                className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                value={sortBy}\n                onChange={(e) => setSortBy(e.target.value as 'count' | 'name')}\n              >\n                <option value=\"count\">Sort by Count</option>\n                <option value=\"name\">Sort by Name</option>\n              </select>\n            </div>\n            {tags && (\n              <p className=\"text-xs text-bambu-gray mt-2\">\n                {tags.length} tag{tags.length !== 1 ? 's' : ''} across {totalUsage} usage{totalUsage !== 1 ? 's' : ''}\n              </p>\n            )}\n          </div>\n\n          {/* Tags list */}\n          <div className=\"flex-1 overflow-y-auto min-h-0 p-4\">\n            {isLoading ? (\n              <div className=\"flex items-center justify-center py-8\">\n                <Loader2 className=\"w-6 h-6 animate-spin text-bambu-gray\" />\n              </div>\n            ) : !filteredTags?.length ? (\n              <div className=\"text-center py-8 text-bambu-gray\">\n                {search ? 'No tags match your search' : 'No tags found'}\n              </div>\n            ) : (\n              <div className=\"space-y-2\">\n                {filteredTags.map((tag) => (\n                  <div\n                    key={tag.name}\n                    className=\"flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group\"\n                  >\n                    {editingTag === tag.name ? (\n                      // Edit mode\n                      <div className=\"flex-1 flex items-center gap-2\">\n                        <input\n                          type=\"text\"\n                          className=\"flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none\"\n                          value={editValue}\n                          onChange={(e) => setEditValue(e.target.value)}\n                          onKeyDown={handleEditKeyDown}\n                          autoFocus\n                        />\n                        <Button\n                          size=\"sm\"\n                          variant=\"primary\"\n                          onClick={submitEdit}\n                          disabled={!editValue.trim() || renameMutation.isPending}\n                          className=\"p-1.5\"\n                        >\n                          {renameMutation.isPending ? (\n                            <Loader2 className=\"w-4 h-4 animate-spin\" />\n                          ) : (\n                            <Check className=\"w-4 h-4\" />\n                          )}\n                        </Button>\n                        <Button\n                          size=\"sm\"\n                          variant=\"ghost\"\n                          onClick={cancelEdit}\n                          className=\"p-1.5\"\n                        >\n                          <X className=\"w-4 h-4\" />\n                        </Button>\n                      </div>\n                    ) : deleteConfirm === tag.name ? (\n                      // Delete confirmation\n                      <div className=\"flex-1 flex items-center gap-2\">\n                        <AlertTriangle className=\"w-4 h-4 text-yellow-400 flex-shrink-0\" />\n                        <span className=\"text-sm text-bambu-gray-light flex-1\">\n                          Delete \"{tag.name}\" from {tag.count} archive{tag.count !== 1 ? 's' : ''}?\n                        </span>\n                        <Button\n                          size=\"sm\"\n                          variant=\"danger\"\n                          onClick={executeDelete}\n                          disabled={deleteMutation.isPending}\n                          className=\"p-1.5\"\n                        >\n                          {deleteMutation.isPending ? (\n                            <Loader2 className=\"w-4 h-4 animate-spin\" />\n                          ) : (\n                            <Trash2 className=\"w-4 h-4\" />\n                          )}\n                        </Button>\n                        <Button\n                          size=\"sm\"\n                          variant=\"ghost\"\n                          onClick={() => setDeleteConfirm(null)}\n                          className=\"p-1.5\"\n                        >\n                          <X className=\"w-4 h-4\" />\n                        </Button>\n                      </div>\n                    ) : (\n                      // Normal display\n                      <>\n                        <Tag className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n                        <span className=\"text-white flex-1 truncate\">{tag.name}</span>\n                        <span className=\"px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs\">\n                          {tag.count}\n                        </span>\n                        <div className=\"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity\">\n                          <button\n                            onClick={() => startEdit(tag)}\n                            className=\"p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors\"\n                            title=\"Rename tag\"\n                          >\n                            <Pencil className=\"w-4 h-4\" />\n                          </button>\n                          <button\n                            onClick={() => confirmDelete(tag.name)}\n                            className=\"p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors\"\n                            title=\"Delete tag\"\n                          >\n                            <Trash2 className=\"w-4 h-4\" />\n                          </button>\n                        </div>\n                      </>\n                    )}\n                  </div>\n                ))}\n              </div>\n            )}\n          </div>\n\n          {/* Footer */}\n          <div className=\"flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0\">\n            <Button variant=\"secondary\" onClick={onClose} className=\"flex-1\">\n              Close\n            </Button>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/TimelapseEditorModal.tsx",
    "content": "import { useState, useRef, useEffect, useCallback } from 'react';\nimport { useQuery, useMutation } from '@tanstack/react-query';\nimport {\n  X,\n  Save,\n  Film,\n  Play,\n  Pause,\n  Scissors,\n  Gauge,\n  Music,\n  Upload,\n  Trash2,\n  Volume2,\n  VolumeX,\n  Loader2,\n} from 'lucide-react';\nimport { Button } from './Button';\nimport { api } from '../api/client';\nimport { useToast } from '../contexts/ToastContext';\nimport { formatMediaTime } from '../utils/date';\n\ninterface TimelapseEditorModalProps {\n  archiveId: number;\n  timelapseSrc: string;\n  onClose: () => void;\n  onSave?: () => void;\n}\n\nconst SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];\n\nexport function TimelapseEditorModal({\n  archiveId,\n  timelapseSrc,\n  onClose,\n  onSave,\n}: TimelapseEditorModalProps) {\n  const { showToast } = useToast();\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const audioRef = useRef<HTMLAudioElement>(null);\n\n  // Video state\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [currentTime, setCurrentTime] = useState(0);\n  const [duration, setDuration] = useState(0);\n\n  // Editor state\n  const [trimStart, setTrimStart] = useState(0);\n  const [trimEnd, setTrimEnd] = useState(0);\n  const [speed, setSpeed] = useState(1);\n  const [audioFile, setAudioFile] = useState<File | null>(null);\n  const [audioUrl, setAudioUrl] = useState<string | null>(null);\n  const [audioVolume, setAudioVolume] = useState(0.8);\n  const [audioMuted, setAudioMuted] = useState(false);\n\n\n  // Fetch video info\n  const { data: videoInfo, isLoading: isLoadingInfo } = useQuery({\n    queryKey: ['timelapse-info', archiveId],\n    queryFn: () => api.getTimelapseInfo(archiveId),\n  });\n\n  // Fetch thumbnails\n  const { data: thumbnailData } = useQuery({\n    queryKey: ['timelapse-thumbnails', archiveId],\n    queryFn: () => api.getTimelapseThumbnails(archiveId, 15),\n  });\n\n  // Process mutation\n  const processMutation = useMutation({\n    mutationFn: () =>\n      api.processTimelapse(\n        archiveId,\n        {\n          trimStart,\n          trimEnd,\n          speed,\n          saveMode: 'replace',\n        },\n        audioFile || undefined\n      ),\n    onSuccess: (data) => {\n      showToast(data.message, 'success');\n      onSave?.();\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message || 'Processing failed', 'error');\n    },\n  });\n\n  // Initialize trimEnd when duration is available\n  useEffect(() => {\n    if (videoInfo?.duration && trimEnd === 0) {\n      setTrimEnd(videoInfo.duration);\n    }\n  }, [videoInfo?.duration, trimEnd]);\n\n  // Close on Escape\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        onClose();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  // Video event handlers\n  useEffect(() => {\n    const video = videoRef.current;\n    if (!video) return;\n\n    const handleTimeUpdate = () => {\n      const time = video.currentTime;\n      setCurrentTime(time);\n\n      // Loop within trim region\n      if (time >= trimEnd) {\n        video.currentTime = trimStart;\n      }\n    };\n\n    const handleDurationChange = () => {\n      setDuration(video.duration);\n      if (trimEnd === 0) {\n        setTrimEnd(video.duration);\n      }\n    };\n\n    const handlePlay = () => setIsPlaying(true);\n    const handlePause = () => setIsPlaying(false);\n\n    video.addEventListener('timeupdate', handleTimeUpdate);\n    video.addEventListener('durationchange', handleDurationChange);\n    video.addEventListener('play', handlePlay);\n    video.addEventListener('pause', handlePause);\n\n    return () => {\n      video.removeEventListener('timeupdate', handleTimeUpdate);\n      video.removeEventListener('durationchange', handleDurationChange);\n      video.removeEventListener('play', handlePlay);\n      video.removeEventListener('pause', handlePause);\n    };\n  }, [trimStart, trimEnd]);\n\n  // Sync audio with video\n  useEffect(() => {\n    const audio = audioRef.current;\n    const video = videoRef.current;\n    if (!audio || !video || !audioUrl) return;\n\n    audio.currentTime = video.currentTime;\n    audio.playbackRate = video.playbackRate;\n\n    if (isPlaying && !audioMuted) {\n      audio.play().catch(() => {});\n    } else {\n      audio.pause();\n    }\n  }, [isPlaying, audioUrl, audioMuted]);\n\n  // Update audio volume\n  useEffect(() => {\n    if (audioRef.current) {\n      audioRef.current.volume = audioMuted ? 0 : audioVolume;\n    }\n  }, [audioVolume, audioMuted]);\n\n  // Update playback rate\n  useEffect(() => {\n    if (videoRef.current) {\n      videoRef.current.playbackRate = speed;\n    }\n    if (audioRef.current) {\n      audioRef.current.playbackRate = speed;\n    }\n  }, [speed]);\n\n  const togglePlay = useCallback(() => {\n    const video = videoRef.current;\n    if (!video) return;\n\n    if (isPlaying) {\n      video.pause();\n    } else {\n      // Start from trim start if before it\n      if (video.currentTime < trimStart) {\n        video.currentTime = trimStart;\n      }\n      video.play();\n    }\n  }, [isPlaying, trimStart]);\n\n  const handleSeek = (time: number) => {\n    const video = videoRef.current;\n    if (!video) return;\n    video.currentTime = Math.max(trimStart, Math.min(trimEnd, time));\n  };\n\n  const handleAudioUpload = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n\n    // Cleanup previous URL\n    if (audioUrl) {\n      URL.revokeObjectURL(audioUrl);\n    }\n\n    setAudioFile(file);\n    setAudioUrl(URL.createObjectURL(file));\n  };\n\n  const removeAudio = () => {\n    if (audioUrl) {\n      URL.revokeObjectURL(audioUrl);\n    }\n    setAudioFile(null);\n    setAudioUrl(null);\n  };\n\n  // Cleanup on unmount\n  useEffect(() => {\n    return () => {\n      if (audioUrl) {\n        URL.revokeObjectURL(audioUrl);\n      }\n    };\n  }, [audioUrl]);\n\n  const trimmedDuration = trimEnd - trimStart;\n  const outputDuration = trimmedDuration / speed;\n\n  if (isLoadingInfo) {\n    return (\n      <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/80\">\n        <div className=\"flex items-center gap-3 text-white\">\n          <Loader2 className=\"w-6 h-6 animate-spin\" />\n          Loading video info...\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/80\">\n      <div className=\"relative bg-bambu-dark-secondary rounded-xl max-w-5xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0\">\n          <h3 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n            <Film className=\"w-5 h-5 text-bambu-green\" />\n            Edit Timelapse\n          </h3>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              variant=\"primary\"\n              size=\"sm\"\n              onClick={() => processMutation.mutate()}\n              disabled={processMutation.isPending}\n            >\n              {processMutation.isPending ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  Processing...\n                </>\n              ) : (\n                <>\n                  <Save className=\"w-4 h-4\" />\n                  Save\n                </>\n              )}\n            </Button>\n            <button\n              onClick={onClose}\n              className=\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\"\n            >\n              <X className=\"w-5 h-5 text-bambu-gray\" />\n            </button>\n          </div>\n        </div>\n\n        {/* Content */}\n        <div className=\"flex-1 overflow-y-auto p-4 space-y-4\">\n          {/* Video Preview */}\n          <div className=\"relative\">\n            <video\n              ref={videoRef}\n              src={timelapseSrc}\n              className=\"w-full rounded-lg bg-black\"\n              onClick={togglePlay}\n              muted={!!audioUrl}\n            />\n\n            {/* Play overlay */}\n            {!isPlaying && (\n              <button\n                onClick={togglePlay}\n                className=\"absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition-colors\"\n              >\n                <div className=\"p-4 bg-bambu-green rounded-full\">\n                  <Play className=\"w-8 h-8 text-white\" />\n                </div>\n              </button>\n            )}\n\n            {/* Hidden audio element for music overlay preview */}\n            {audioUrl && (\n              <audio ref={audioRef} src={audioUrl} loop />\n            )}\n          </div>\n\n          {/* Timeline with Thumbnails */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center gap-2 text-sm text-bambu-gray\">\n              <Scissors className=\"w-4 h-4\" />\n              <span>Trim</span>\n              <span className=\"ml-auto\">\n                {formatMediaTime(trimStart)} - {formatMediaTime(trimEnd)} ({formatMediaTime(trimmedDuration)})\n              </span>\n            </div>\n\n            {/* Thumbnail strip */}\n            <div className=\"relative h-16 bg-bambu-dark rounded-lg overflow-hidden\">\n              {/* Thumbnails background */}\n              <div className=\"absolute inset-0 flex\">\n                {thumbnailData?.thumbnails.map((thumb, i) => (\n                  <div\n                    key={i}\n                    className=\"flex-1 bg-cover bg-center\"\n                    style={{\n                      backgroundImage: `url(data:image/jpeg;base64,${thumb})`,\n                    }}\n                  />\n                ))}\n              </div>\n\n              {/* Trim overlay - grayed out areas */}\n              <div\n                className=\"absolute inset-y-0 left-0 bg-black/60\"\n                style={{ width: `${(trimStart / duration) * 100}%` }}\n              />\n              <div\n                className=\"absolute inset-y-0 right-0 bg-black/60\"\n                style={{ width: `${((duration - trimEnd) / duration) * 100}%` }}\n              />\n\n              {/* Selected region border */}\n              <div\n                className=\"absolute inset-y-0 border-2 border-bambu-green\"\n                style={{\n                  left: `${(trimStart / duration) * 100}%`,\n                  right: `${((duration - trimEnd) / duration) * 100}%`,\n                }}\n              />\n\n              {/* Current time indicator */}\n              <div\n                className=\"absolute top-0 bottom-0 w-0.5 bg-white shadow-lg\"\n                style={{ left: `${(currentTime / duration) * 100}%` }}\n              />\n\n              {/* Trim handles */}\n              <input\n                type=\"range\"\n                min={0}\n                max={duration}\n                step={0.1}\n                value={trimStart}\n                onChange={(e) => {\n                  const val = parseFloat(e.target.value);\n                  if (val < trimEnd - 1) {\n                    setTrimStart(val);\n                    if (videoRef.current && videoRef.current.currentTime < val) {\n                      videoRef.current.currentTime = val;\n                    }\n                  }\n                }}\n                className=\"absolute inset-0 w-full opacity-0 cursor-ew-resize\"\n                style={{ clipPath: 'inset(0 50% 0 0)' }}\n              />\n              <input\n                type=\"range\"\n                min={0}\n                max={duration}\n                step={0.1}\n                value={trimEnd}\n                onChange={(e) => {\n                  const val = parseFloat(e.target.value);\n                  if (val > trimStart + 1) {\n                    setTrimEnd(val);\n                  }\n                }}\n                className=\"absolute inset-0 w-full opacity-0 cursor-ew-resize\"\n                style={{ clipPath: 'inset(0 0 0 50%)' }}\n              />\n            </div>\n\n            {/* Playback scrubber */}\n            <input\n              type=\"range\"\n              min={0}\n              max={duration}\n              step={0.1}\n              value={currentTime}\n              onChange={(e) => handleSeek(parseFloat(e.target.value))}\n              className=\"w-full h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer\n                [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3\n                [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full\n                [&::-webkit-slider-thumb]:cursor-pointer\"\n            />\n\n            {/* Play controls */}\n            <div className=\"flex items-center justify-center gap-2\">\n              <button\n                onClick={togglePlay}\n                className=\"p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors\"\n              >\n                {isPlaying ? (\n                  <Pause className=\"w-5 h-5 text-white\" />\n                ) : (\n                  <Play className=\"w-5 h-5 text-white\" />\n                )}\n              </button>\n            </div>\n          </div>\n\n          {/* Speed Control */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center gap-2 text-sm text-bambu-gray\">\n              <Gauge className=\"w-4 h-4\" />\n              <span>Speed</span>\n              <span className=\"ml-auto\">{speed}x (output: {formatMediaTime(outputDuration)})</span>\n            </div>\n            <div className=\"flex gap-1\">\n              {SPEED_OPTIONS.map((s) => (\n                <button\n                  key={s}\n                  onClick={() => setSpeed(s)}\n                  className={`flex-1 px-2 py-2 text-sm rounded transition-colors ${\n                    speed === s\n                      ? 'bg-bambu-green text-white'\n                      : 'bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary'\n                  }`}\n                >\n                  {s}x\n                </button>\n              ))}\n            </div>\n          </div>\n\n          {/* Audio Upload */}\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center gap-2 text-sm text-bambu-gray\">\n              <Music className=\"w-4 h-4\" />\n              <span>Music Overlay</span>\n            </div>\n\n            {audioFile ? (\n              <div className=\"flex items-center gap-3 p-3 bg-bambu-dark rounded-lg\">\n                <Music className=\"w-5 h-5 text-bambu-green\" />\n                <div className=\"flex-1 min-w-0\">\n                  <p className=\"text-sm text-white truncate\">{audioFile.name}</p>\n                  <p className=\"text-xs text-bambu-gray\">\n                    {(audioFile.size / 1024 / 1024).toFixed(1)} MB\n                  </p>\n                </div>\n\n                {/* Volume control */}\n                <button\n                  onClick={() => setAudioMuted(!audioMuted)}\n                  className=\"p-2 hover:bg-bambu-dark-tertiary rounded transition-colors\"\n                >\n                  {audioMuted ? (\n                    <VolumeX className=\"w-4 h-4 text-bambu-gray\" />\n                  ) : (\n                    <Volume2 className=\"w-4 h-4 text-bambu-green\" />\n                  )}\n                </button>\n                <input\n                  type=\"range\"\n                  min={0}\n                  max={1}\n                  step={0.1}\n                  value={audioVolume}\n                  onChange={(e) => setAudioVolume(parseFloat(e.target.value))}\n                  className=\"w-20 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer\n                    [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3\n                    [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full\"\n                />\n\n                <button\n                  onClick={removeAudio}\n                  className=\"p-2 hover:bg-red-500/20 rounded transition-colors\"\n                >\n                  <Trash2 className=\"w-4 h-4 text-red-400\" />\n                </button>\n              </div>\n            ) : (\n              <label className=\"flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green/50 transition-colors\">\n                <Upload className=\"w-8 h-8 text-bambu-gray\" />\n                <span className=\"text-sm text-bambu-gray\">\n                  Drop audio file or click to upload\n                </span>\n                <span className=\"text-xs text-bambu-gray/60\">\n                  MP3, WAV, M4A, AAC, OGG\n                </span>\n                <input\n                  type=\"file\"\n                  accept=\".mp3,.wav,.m4a,.aac,.ogg,audio/*\"\n                  onChange={handleAudioUpload}\n                  className=\"hidden\"\n                />\n              </label>\n            )}\n          </div>\n\n          {/* Summary */}\n          <div className=\"p-3 bg-bambu-dark rounded-lg text-sm space-y-1\">\n            <p className=\"text-bambu-gray\">\n              <span className=\"text-white\">Original:</span> {formatMediaTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}\n            </p>\n            <p className=\"text-bambu-gray\">\n              <span className=\"text-white\">Output:</span> {formatMediaTime(outputDuration)} @ {speed}x speed\n              {audioFile && ` + music overlay`}\n            </p>\n          </div>\n        </div>\n\n        {/* Processing overlay */}\n        {processMutation.isPending && (\n          <div className=\"absolute inset-0 bg-black/80 flex flex-col items-center justify-center gap-4\">\n            <Loader2 className=\"w-12 h-12 text-bambu-green animate-spin\" />\n            <p className=\"text-white text-lg\">Processing timelapse...</p>\n            <p className=\"text-bambu-gray text-sm\">This may take a few moments</p>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/TimelapseViewer.tsx",
    "content": "import { useState, useRef, useEffect } from 'react';\nimport { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';\nimport { Button } from './Button';\nimport { TimelapseEditorModal } from './TimelapseEditorModal';\nimport { formatMediaTime } from '../utils/date';\n\ninterface TimelapseViewerProps {\n  src: string;\n  title: string;\n  downloadFilename: string;\n  archiveId?: number;\n  onClose: () => void;\n  onEdit?: () => void;\n}\n\nconst SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];\n\nexport function TimelapseViewer({\n  src,\n  title,\n  downloadFilename,\n  archiveId,\n  onClose,\n  onEdit,\n}: TimelapseViewerProps) {\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const [isPlaying, setIsPlaying] = useState(true);\n  const [playbackRate, setPlaybackRate] = useState(1); // Default to 1x\n  const [currentTime, setCurrentTime] = useState(0);\n  const [duration, setDuration] = useState(0);\n  const [showEditor, setShowEditor] = useState(false);\n\n  useEffect(() => {\n    const video = videoRef.current;\n    if (video) {\n      video.playbackRate = playbackRate;\n    }\n  }, [playbackRate]);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        onClose();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  useEffect(() => {\n    const video = videoRef.current;\n    if (!video) return;\n\n    const handleTimeUpdate = () => setCurrentTime(video.currentTime);\n    const handleDurationChange = () => setDuration(video.duration);\n    const handlePlay = () => setIsPlaying(true);\n    const handlePause = () => setIsPlaying(false);\n\n    video.addEventListener('timeupdate', handleTimeUpdate);\n    video.addEventListener('durationchange', handleDurationChange);\n    video.addEventListener('play', handlePlay);\n    video.addEventListener('pause', handlePause);\n\n    return () => {\n      video.removeEventListener('timeupdate', handleTimeUpdate);\n      video.removeEventListener('durationchange', handleDurationChange);\n      video.removeEventListener('play', handlePlay);\n      video.removeEventListener('pause', handlePause);\n    };\n  }, []);\n\n  const togglePlay = () => {\n    const video = videoRef.current;\n    if (!video) return;\n    if (isPlaying) {\n      video.pause();\n    } else {\n      video.play();\n    }\n  };\n\n  const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const video = videoRef.current;\n    if (!video) return;\n    video.currentTime = parseFloat(e.target.value);\n  };\n\n  const skipBackward = () => {\n    const video = videoRef.current;\n    if (!video) return;\n    video.currentTime = Math.max(0, video.currentTime - 5);\n  };\n\n  const skipForward = () => {\n    const video = videoRef.current;\n    if (!video) return;\n    video.currentTime = Math.min(duration, video.currentTime + 5);\n  };\n\n  const handleDownload = () => {\n    const link = document.createElement('a');\n    link.href = src;\n    link.download = downloadFilename;\n    link.click();\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/80\">\n      <div className=\"relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden\">\n        {/* Header */}\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n          <h3 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n            <Film className=\"w-5 h-5 text-bambu-green\" />\n            {title}\n          </h3>\n          <div className=\"flex items-center gap-2\">\n            {archiveId && (\n              <Button variant=\"secondary\" size=\"sm\" onClick={() => setShowEditor(true)}>\n                <Pencil className=\"w-4 h-4\" />\n                Edit\n              </Button>\n            )}\n            <Button variant=\"secondary\" size=\"sm\" onClick={handleDownload}>\n              <Download className=\"w-4 h-4\" />\n              Download\n            </Button>\n            <button\n              onClick={onClose}\n              className=\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\"\n            >\n              <X className=\"w-5 h-5 text-bambu-gray\" />\n            </button>\n          </div>\n        </div>\n\n        {/* Video */}\n        <div className=\"p-4\">\n          <video\n            ref={videoRef}\n            src={src}\n            autoPlay\n            className=\"w-full rounded-lg\"\n            onClick={togglePlay}\n          />\n\n          {/* Custom Controls */}\n          <div className=\"mt-4 space-y-3\">\n            {/* Progress bar */}\n            <div className=\"flex items-center gap-3\">\n              <span className=\"text-xs text-bambu-gray w-12 text-right\">\n                {formatMediaTime(currentTime)}\n              </span>\n              <input\n                type=\"range\"\n                min={0}\n                max={duration || 100}\n                value={currentTime}\n                onChange={handleSeek}\n                className=\"flex-1 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer\n                  [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3\n                  [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full\n                  [&::-webkit-slider-thumb]:cursor-pointer\"\n              />\n              <span className=\"text-xs text-bambu-gray w-12\">\n                {formatMediaTime(duration)}\n              </span>\n            </div>\n\n            {/* Playback controls */}\n            <div className=\"flex items-center justify-between\">\n              {/* Left: Play controls */}\n              <div className=\"flex items-center gap-2\">\n                <button\n                  onClick={skipBackward}\n                  className=\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n                  title=\"Skip back 5s\"\n                >\n                  <SkipBack className=\"w-5 h-5 text-bambu-gray\" />\n                </button>\n                <button\n                  onClick={togglePlay}\n                  className=\"p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors\"\n                >\n                  {isPlaying ? (\n                    <Pause className=\"w-5 h-5 text-white\" />\n                  ) : (\n                    <Play className=\"w-5 h-5 text-white\" />\n                  )}\n                </button>\n                <button\n                  onClick={skipForward}\n                  className=\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n                  title=\"Skip forward 5s\"\n                >\n                  <SkipForward className=\"w-5 h-5 text-bambu-gray\" />\n                </button>\n              </div>\n\n              {/* Right: Speed control */}\n              <div className=\"flex items-center gap-2\">\n                <span className=\"text-sm text-bambu-gray\">Speed:</span>\n                <div className=\"flex gap-1\">\n                  {SPEED_OPTIONS.map((speed) => (\n                    <button\n                      key={speed}\n                      onClick={() => setPlaybackRate(speed)}\n                      className={`px-2 py-1 text-xs rounded transition-colors ${\n                        playbackRate === speed\n                          ? 'bg-bambu-green text-white'\n                          : 'bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark-tertiary/80'\n                      }`}\n                    >\n                      {speed}x\n                    </button>\n                  ))}\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Timelapse Editor Modal */}\n      {showEditor && archiveId && (\n        <TimelapseEditorModal\n          archiveId={archiveId}\n          timelapseSrc={src}\n          onClose={() => setShowEditor(false)}\n          onSave={onEdit}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/Toggle.tsx",
    "content": "interface ToggleProps {\n  checked: boolean;\n  onChange: (checked: boolean) => void;\n  disabled?: boolean;\n}\n\nexport function Toggle({ checked, onChange, disabled }: ToggleProps) {\n  const handleClick = (e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    if (!disabled) {\n      onChange(!checked);\n    }\n  };\n\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      disabled={disabled}\n      onClick={handleClick}\n      className={`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${\n        disabled\n          ? 'bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50'\n          : checked\n          ? 'bg-bambu-green cursor-pointer'\n          : 'bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80'\n      }`}\n    >\n      <span\n        className={`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${\n          checked ? 'translate-x-4' : 'translate-x-0'\n        }`}\n      />\n    </button>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/TwoFactorSettings.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { ShieldCheck, ShieldOff, Mail, Smartphone, Key, RefreshCw, Trash2, X, Eye, EyeOff, Copy } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\n\n// ─── Small reusable code input ────────────────────────────────────────────────\nfunction CodeInput({\n  value,\n  onChange,\n  placeholder,\n  maxLength = 6,\n}: {\n  value: string;\n  onChange: (v: string) => void;\n  placeholder?: string;\n  maxLength?: number;\n}) {\n  return (\n    <input\n      type=\"text\"\n      value={value}\n      onChange={(e) => onChange(e.target.value.toUpperCase().replace(/\\s/g, ''))}\n      maxLength={maxLength}\n      className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors font-mono tracking-widest text-center\"\n      placeholder={placeholder}\n      autoComplete=\"one-time-code\"\n    />\n  );\n}\n\n// ─── Backup codes display ─────────────────────────────────────────────────────\nfunction BackupCodesDisplay({ codes, onDone }: { codes: string[]; onDone: () => void }) {\n  const { t } = useTranslation();\n  const [copied, setCopied] = useState(false);\n\n  const handleCopy = () => {\n    navigator.clipboard.writeText(codes.join('\\n'));\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"bg-amber-500/10 border border-amber-500/30 rounded-lg p-4\">\n        <p className=\"text-amber-400 text-sm font-medium\">{t('settings.twoFa.backupCodesWarning')}</p>\n      </div>\n      <div className=\"grid grid-cols-2 gap-2\">\n        {codes.map((code, index) => (\n          <code key={index} className=\"bg-bambu-dark-secondary rounded px-3 py-2 text-center font-mono text-sm text-white tracking-widest\">\n            {code}\n          </code>\n        ))}\n      </div>\n      <div className=\"flex gap-3\">\n        <Button variant=\"secondary\" size=\"sm\" onClick={handleCopy} className=\"flex items-center gap-2\">\n          <Copy className=\"w-4 h-4\" />\n          {copied ? t('common.copied') : t('common.copy')}\n        </Button>\n        <Button variant=\"primary\" size=\"sm\" onClick={onDone} className=\"flex-1\">\n          {t('settings.twoFa.savedCodes')}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\n// ─── TOTP setup wizard ────────────────────────────────────────────────────────\nfunction TOTPSetupWizard({ onDone }: { onDone: () => void }) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [step, setStep] = useState<'qr' | 'confirm' | 'backup'>('qr');\n  const [code, setCode] = useState('');\n  const [backupCodes, setBackupCodes] = useState<string[]>([]);\n  const [showSecret, setShowSecret] = useState(false);\n\n  const { data: setupData, isLoading } = useQuery({\n    queryKey: ['totp-setup'],\n    queryFn: () => api.setupTOTP(),\n    staleTime: Infinity,\n  });\n\n  const enableMutation = useMutation({\n    mutationFn: (c: string) => api.enableTOTP(c),\n    onSuccess: (data) => {\n      setBackupCodes(data.backup_codes);\n      setStep('backup');\n      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });\n    },\n    onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),\n  });\n\n  if (isLoading || !setupData) {\n    return (\n      <div className=\"flex items-center justify-center py-8\">\n        <RefreshCw className=\"w-6 h-6 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  if (step === 'qr') {\n    return (\n      <div className=\"space-y-4\">\n        <p className=\"text-bambu-gray-light text-sm\">{t('settings.twoFa.setupInstructions')}</p>\n        <div className=\"flex justify-center\">\n          <img\n            src={`data:image/png;base64,${setupData.qr_code_b64}`}\n            alt=\"TOTP QR Code\"\n            className=\"w-48 h-48 rounded-lg\"\n          />\n        </div>\n        <div>\n          <p className=\"text-xs text-bambu-gray mb-1\">{t('settings.twoFa.manualEntry')}</p>\n          <div className=\"flex items-center gap-2 bg-bambu-dark-secondary rounded-lg px-3 py-2\">\n            <code className=\"text-white text-xs font-mono flex-1 break-all\">\n              {showSecret ? setupData.secret : '••••••••••••••••'}\n            </code>\n            <button onClick={() => setShowSecret(!showSecret)} className=\"text-bambu-gray hover:text-white\">\n              {showSecret ? <EyeOff className=\"w-4 h-4\" /> : <Eye className=\"w-4 h-4\" />}\n            </button>\n            <button\n              onClick={() => { navigator.clipboard.writeText(setupData.secret); }}\n              className=\"text-bambu-gray hover:text-white\"\n            >\n              <Copy className=\"w-4 h-4\" />\n            </button>\n          </div>\n        </div>\n        <Button variant=\"primary\" className=\"w-full\" onClick={() => setStep('confirm')}>\n          {t('settings.twoFa.scannedContinue')}\n        </Button>\n      </div>\n    );\n  }\n\n  if (step === 'confirm') {\n    return (\n      <div className=\"space-y-4\">\n        <p className=\"text-bambu-gray-light text-sm\">{t('settings.twoFa.enterCodeToConfirm')}</p>\n        <CodeInput value={code} onChange={setCode} placeholder=\"000000\" />\n        <div className=\"flex gap-3\">\n          <Button variant=\"secondary\" onClick={() => setStep('qr')} className=\"flex-1\">\n            {t('common.back')}\n          </Button>\n          <Button\n            variant=\"primary\"\n            className=\"flex-1\"\n            disabled={code.length !== 6 || enableMutation.isPending}\n            onClick={() => enableMutation.mutate(code)}\n          >\n            {enableMutation.isPending ? t('common.saving') : t('settings.twoFa.activate')}\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  // step === 'backup'\n  return (\n    <div className=\"space-y-4\">\n      <h3 className=\"text-white font-medium\">{t('settings.twoFa.backupCodesTitle')}</h3>\n      <BackupCodesDisplay codes={backupCodes} onDone={onDone} />\n    </div>\n  );\n}\n\n// ─── Main component ───────────────────────────────────────────────────────────\nexport function TwoFactorSettings() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { user } = useAuth();\n\n  const [showTOTPSetup, setShowTOTPSetup] = useState(false);\n  const [showDisableTOTP, setShowDisableTOTP] = useState(false);\n  const [showRegenBackup, setShowRegenBackup] = useState(false);\n  const [disableCode, setDisableCode] = useState('');\n  const [regenCode, setRegenCode] = useState('');\n  const [newBackupCodes, setNewBackupCodes] = useState<string[] | null>(null);\n\n  // Email OTP enable: two-step proof-of-possession flow\n  const [emailSetupToken, setEmailSetupToken] = useState<string | null>(null);\n  const [emailSetupCode, setEmailSetupCode] = useState('');\n\n  // Email OTP disable: requires account password\n  const [showDisableEmail, setShowDisableEmail] = useState(false);\n  const [emailDisablePassword, setEmailDisablePassword] = useState('');\n  const [showEmailDisablePassword, setShowEmailDisablePassword] = useState(false);\n\n  const { data: status, isLoading } = useQuery({\n    queryKey: ['2fa-status'],\n    queryFn: () => api.get2FAStatus(),\n  });\n\n  const { data: oidcLinks } = useQuery({\n    queryKey: ['oidc-links'],\n    queryFn: () => api.getOIDCLinks(),\n  });\n\n  // Step 1: request verification code (proof of possession)\n  const enableEmailRequestMutation = useMutation({\n    mutationFn: () => api.enableEmailOTP(),\n    onSuccess: (data: { message: string; setup_token: string }) => {\n      setEmailSetupToken(data.setup_token);\n      showToast(data.message, 'success');\n    },\n    onError: (e: Error) => {\n      const msg = e.message ?? '';\n      if (msg.toLowerCase().includes('smtp')) {\n        showToast(t('settings.twoFa.smtpRequired'), 'error');\n      } else {\n        showToast(msg, 'error');\n      }\n    },\n  });\n\n  // Step 2: confirm with the code received by email\n  const enableEmailConfirmMutation = useMutation({\n    mutationFn: () => api.confirmEnableEmailOTP(emailSetupToken!, emailSetupCode),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });\n      setEmailSetupToken(null);\n      setEmailSetupCode('');\n      showToast(t('settings.twoFa.emailOtpEnabled'), 'success');\n    },\n    onError: (e: Error) => showToast(e.message, 'error'),\n  });\n\n  const disableEmailMutation = useMutation({\n    mutationFn: (password: string) => api.disableEmailOTP(password),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });\n      setShowDisableEmail(false);\n      setEmailDisablePassword('');\n      showToast(t('settings.twoFa.emailOtpDisabled'), 'success');\n    },\n    onError: (e: Error) => showToast(e.message, 'error'),\n  });\n\n  const disableTOTPMutation = useMutation({\n    mutationFn: (code: string) => api.disableTOTP(code),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });\n      setShowDisableTOTP(false);\n      setDisableCode('');\n      showToast(t('settings.twoFa.totpDisabled'), 'success');\n    },\n    onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),\n  });\n\n  const regenMutation = useMutation({\n    mutationFn: (code: string) => api.regenerateBackupCodes(code),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });\n      setShowRegenBackup(false);\n      setRegenCode('');\n      setNewBackupCodes(data.backup_codes);\n    },\n    onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),\n  });\n\n  const unlinkOIDCMutation = useMutation({\n    mutationFn: (providerId: number) => api.deleteOIDCLink(providerId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['oidc-links'] });\n      showToast(t('settings.twoFa.oidcUnlinked'), 'success');\n    },\n    onError: (e: Error) => showToast(e.message, 'error'),\n  });\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-12\">\n        <RefreshCw className=\"w-6 h-6 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  const hasEmail = !!user?.email;\n\n  return (\n    <div className=\"space-y-6\">\n      {/* ── TOTP ─────────────────────────────────────────────────────────── */}\n      <Card id=\"card-2fa-totp\">\n        <CardHeader>\n          <div className=\"flex items-center gap-3\">\n            <div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.totp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>\n              <Smartphone className={`w-5 h-5 ${status?.totp_enabled ? 'text-green-400' : 'text-gray-400'}`} />\n            </div>\n            <div>\n              <h3 className=\"text-white font-semibold\">{t('settings.twoFa.totpTitle')}</h3>\n              <p className=\"text-bambu-gray text-sm\">{t('settings.twoFa.totpDesc')}</p>\n            </div>\n            <div className=\"ml-auto\">\n              {status?.totp_enabled ? (\n                <span className=\"flex items-center gap-1 text-green-400 text-sm font-medium\">\n                  <ShieldCheck className=\"w-4 h-4\" /> {t('common.enabled')}\n                </span>\n              ) : (\n                <span className=\"flex items-center gap-1 text-bambu-gray text-sm\">\n                  <ShieldOff className=\"w-4 h-4\" /> {t('common.disabled')}\n                </span>\n              )}\n            </div>\n          </div>\n        </CardHeader>\n        <CardContent>\n          {/* TOTP Setup wizard */}\n          {showTOTPSetup ? (\n            <div className=\"space-y-4\">\n              <div className=\"flex items-center justify-between mb-2\">\n                <h4 className=\"text-white font-medium\">{t('settings.twoFa.setupAuthApp')}</h4>\n                <button onClick={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} className=\"text-bambu-gray hover:text-white\">\n                  <X className=\"w-5 h-5\" />\n                </button>\n              </div>\n              <TOTPSetupWizard onDone={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} />\n            </div>\n          ) : showDisableTOTP ? (\n            <div className=\"space-y-4\">\n              <p className=\"text-bambu-gray-light text-sm\">{t('settings.twoFa.disableConfirmHint')}</p>\n              <CodeInput value={disableCode} onChange={setDisableCode} placeholder=\"000000 or XXXXXXXX\" maxLength={8} />\n              <div className=\"flex gap-3\">\n                <Button variant=\"secondary\" onClick={() => { setShowDisableTOTP(false); setDisableCode(''); }} className=\"flex-1\">\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  variant=\"danger\"\n                  className=\"flex-1\"\n                  disabled={disableCode.length < 6 || disableTOTPMutation.isPending}\n                  onClick={() => disableTOTPMutation.mutate(disableCode)}\n                >\n                  {disableTOTPMutation.isPending ? t('common.saving') : t('settings.twoFa.disableTotp')}\n                </Button>\n              </div>\n            </div>\n          ) : showRegenBackup ? (\n            <div className=\"space-y-4\">\n              <p className=\"text-bambu-gray-light text-sm\">{t('settings.twoFa.regenBackupHint')}</p>\n              <CodeInput value={regenCode} onChange={setRegenCode} placeholder=\"000000 or XXXXXXXX\" maxLength={8} />\n              <div className=\"flex gap-3\">\n                <Button variant=\"secondary\" onClick={() => { setShowRegenBackup(false); setRegenCode(''); }} className=\"flex-1\">\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  variant=\"primary\"\n                  className=\"flex-1\"\n                  disabled={regenCode.length < 6 || regenMutation.isPending}\n                  onClick={() => regenMutation.mutate(regenCode)}\n                >\n                  {regenMutation.isPending ? t('common.saving') : t('settings.twoFa.regenBackup')}\n                </Button>\n              </div>\n            </div>\n          ) : newBackupCodes ? (\n            <div className=\"space-y-4\">\n              <h4 className=\"text-white font-medium\">{t('settings.twoFa.newBackupCodes')}</h4>\n              <BackupCodesDisplay codes={newBackupCodes} onDone={() => setNewBackupCodes(null)} />\n            </div>\n          ) : (\n            <div className=\"space-y-3\">\n              {!status?.totp_enabled ? (\n                <Button variant=\"primary\" onClick={() => setShowTOTPSetup(true)} className=\"flex items-center gap-2\">\n                  <Smartphone className=\"w-4 h-4\" />\n                  {t('settings.twoFa.setupTotp')}\n                </Button>\n              ) : (\n                <div className=\"flex flex-wrap gap-3\">\n                  <div className=\"flex items-center gap-2 text-sm text-bambu-gray-light\">\n                    <Key className=\"w-4 h-4\" />\n                    {t('settings.twoFa.backupCodesRemaining', { count: status.backup_codes_remaining })}\n                  </div>\n                  <Button variant=\"secondary\" size=\"sm\" onClick={() => setShowRegenBackup(true)} className=\"flex items-center gap-2\">\n                    <RefreshCw className=\"w-4 h-4\" />\n                    {t('settings.twoFa.regenBackup')}\n                  </Button>\n                  <Button variant=\"danger\" size=\"sm\" onClick={() => setShowDisableTOTP(true)} className=\"flex items-center gap-2\">\n                    <Trash2 className=\"w-4 h-4\" />\n                    {t('settings.twoFa.disableTotp')}\n                  </Button>\n                </div>\n              )}\n            </div>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* ── Email OTP ─────────────────────────────────────────────────────── */}\n      <Card id=\"card-2fa-emailotp\">\n        <CardHeader>\n          <div className=\"flex items-center gap-3\">\n            <div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.email_otp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>\n              <Mail className={`w-5 h-5 ${status?.email_otp_enabled ? 'text-green-400' : 'text-gray-400'}`} />\n            </div>\n            <div className=\"flex-1\">\n              <h3 className=\"text-white font-semibold\">{t('settings.twoFa.emailOtpTitle')}</h3>\n              <p className=\"text-bambu-gray text-sm\">\n                {hasEmail\n                  ? t('settings.twoFa.emailOtpDesc', { email: user?.email })\n                  : t('settings.twoFa.emailOtpNoEmail')}\n              </p>\n            </div>\n            {/* Show status badge; enable/disable handled in CardContent */}\n            <div className=\"ml-auto\">\n              {status?.email_otp_enabled ? (\n                <span className=\"flex items-center gap-1 text-green-400 text-sm font-medium\">\n                  <ShieldCheck className=\"w-4 h-4\" /> {t('common.enabled')}\n                </span>\n              ) : (\n                <span className=\"flex items-center gap-1 text-bambu-gray text-sm\">\n                  <ShieldOff className=\"w-4 h-4\" /> {t('common.disabled')}\n                </span>\n              )}\n            </div>\n          </div>\n        </CardHeader>\n        <CardContent>\n          {!hasEmail ? (\n            <p className=\"text-amber-400 text-sm\">{t('settings.twoFa.addEmailFirst')}</p>\n          ) : emailSetupToken ? (\n            /* Step 2: enter the code that was sent to the email */\n            <div className=\"space-y-4\">\n              <p className=\"text-bambu-gray-light text-sm\">{t('settings.twoFa.emailSetupEnterCode')}</p>\n              <CodeInput value={emailSetupCode} onChange={setEmailSetupCode} placeholder=\"000000\" />\n              <div className=\"flex gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => { setEmailSetupToken(null); setEmailSetupCode(''); }}\n                  className=\"flex-1\"\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  variant=\"primary\"\n                  className=\"flex-1\"\n                  disabled={emailSetupCode.length !== 6 || enableEmailConfirmMutation.isPending}\n                  onClick={() => enableEmailConfirmMutation.mutate()}\n                >\n                  {enableEmailConfirmMutation.isPending ? t('common.saving') : t('settings.twoFa.verifyAndEnable')}\n                </Button>\n              </div>\n            </div>\n          ) : showDisableEmail ? (\n            /* Disable: require account password for re-auth */\n            <div className=\"space-y-4\">\n              <p className=\"text-bambu-gray-light text-sm\">{t('settings.twoFa.emailDisablePasswordHint')}</p>\n              <div className=\"relative\">\n                <input\n                  type={showEmailDisablePassword ? 'text' : 'password'}\n                  value={emailDisablePassword}\n                  onChange={(e) => setEmailDisablePassword(e.target.value)}\n                  placeholder={t('settings.twoFa.passwordPlaceholder')}\n                  className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                />\n                <button\n                  type=\"button\"\n                  onClick={() => setShowEmailDisablePassword(!showEmailDisablePassword)}\n                  className=\"absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\"\n                >\n                  {showEmailDisablePassword ? <EyeOff className=\"w-5 h-5\" /> : <Eye className=\"w-5 h-5\" />}\n                </button>\n              </div>\n              <div className=\"flex gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => { setShowDisableEmail(false); setEmailDisablePassword(''); }}\n                  className=\"flex-1\"\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  variant=\"danger\"\n                  className=\"flex-1\"\n                  disabled={!emailDisablePassword || disableEmailMutation.isPending}\n                  onClick={() => disableEmailMutation.mutate(emailDisablePassword)}\n                >\n                  {disableEmailMutation.isPending ? t('common.saving') : t('settings.twoFa.disableEmailOtp')}\n                </Button>\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex gap-3\">\n              {!status?.email_otp_enabled ? (\n                <Button\n                  variant=\"primary\"\n                  disabled={!hasEmail || enableEmailRequestMutation.isPending}\n                  onClick={() => enableEmailRequestMutation.mutate()}\n                  className=\"flex items-center gap-2\"\n                >\n                  <Mail className=\"w-4 h-4\" />\n                  {enableEmailRequestMutation.isPending ? t('common.saving') : t('settings.twoFa.enableEmailOtp')}\n                </Button>\n              ) : (\n                <Button\n                  variant=\"danger\"\n                  size=\"sm\"\n                  onClick={() => setShowDisableEmail(true)}\n                  className=\"flex items-center gap-2\"\n                >\n                  <Trash2 className=\"w-4 h-4\" />\n                  {t('settings.twoFa.disableEmailOtp')}\n                </Button>\n              )}\n            </div>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* ── Linked SSO accounts ───────────────────────────────────────────── */}\n      {oidcLinks && oidcLinks.length > 0 && (\n        <Card id=\"card-2fa-linked\">\n          <CardHeader>\n            <h3 className=\"text-white font-semibold\">{t('settings.twoFa.linkedAccounts')}</h3>\n            <p className=\"text-bambu-gray text-sm\">{t('settings.twoFa.linkedAccountsDesc')}</p>\n          </CardHeader>\n          <CardContent>\n            <div className=\"space-y-3\">\n              {oidcLinks.map((link) => (\n                <div key={link.id} className=\"flex items-center justify-between py-2 border-b border-bambu-dark-tertiary last:border-0\">\n                  <div>\n                    <p className=\"text-white text-sm font-medium\">{link.provider_name}</p>\n                    {link.provider_email && (\n                      <p className=\"text-bambu-gray text-xs\">{link.provider_email}</p>\n                    )}\n                  </div>\n                  <Button\n                    variant=\"danger\"\n                    size=\"sm\"\n                    onClick={() => unlinkOIDCMutation.mutate(link.provider_id)}\n                    disabled={unlinkOIDCMutation.isPending}\n                  >\n                    <Trash2 className=\"w-4 h-4\" />\n                  </Button>\n                </div>\n              ))}\n            </div>\n          </CardContent>\n        </Card>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/UploadModal.tsx",
    "content": "import { useState, useCallback, useRef, useEffect } from 'react';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { BulkUploadResult } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\n\ninterface FileWithStatus {\n  file: File;\n  status: 'pending' | 'uploading' | 'success' | 'error';\n  error?: string;\n  archiveId?: number;\n}\n\ninterface UploadModalProps {\n  onClose: () => void;\n  initialFiles?: File[];\n}\n\nexport function UploadModal({ onClose, initialFiles }: UploadModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const [files, setFiles] = useState<FileWithStatus[]>(() =>\n    initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []\n  );\n  const [isDragging, setIsDragging] = useState(false);\n  const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  const uploadMutation = useMutation({\n    mutationFn: (filesToUpload: File[]) =>\n      api.uploadArchivesBulk(filesToUpload),\n    onSuccess: (result) => {\n      setUploadResult(result);\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      queryClient.invalidateQueries({ queryKey: ['archiveStats'] });\n\n      // Update file statuses based on result\n      setFiles((prev) =>\n        prev.map((f) => {\n          const success = result.results.find((r) => r.filename === f.file.name);\n          const error = result.errors.find((e) => e.filename === f.file.name);\n          if (success) {\n            return { ...f, status: 'success', archiveId: success.id };\n          }\n          if (error) {\n            return { ...f, status: 'error', error: error.error };\n          }\n          return f;\n        })\n      );\n\n      // Show toast\n      if (result.failed === 0) {\n        showToast(`${result.uploaded} file${result.uploaded !== 1 ? 's' : ''} uploaded`);\n      } else if (result.uploaded === 0) {\n        showToast(`Failed to upload ${result.failed} file${result.failed !== 1 ? 's' : ''}`, 'error');\n      } else {\n        showToast(`${result.uploaded} uploaded, ${result.failed} failed`, 'warning');\n      }\n    },\n    onError: () => {\n      setFiles((prev) =>\n        prev.map((f) => ({ ...f, status: 'error', error: 'Upload failed' }))\n      );\n      showToast('Upload failed', 'error');\n    },\n  });\n\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(true);\n  }, []);\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(false);\n  }, []);\n\n  const handleDrop = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(false);\n\n    const droppedFiles = Array.from(e.dataTransfer.files).filter((f) =>\n      f.name.endsWith('.3mf')\n    );\n\n    if (droppedFiles.length > 0) {\n      setFiles((prev) => [\n        ...prev,\n        ...droppedFiles.map((file) => ({ file, status: 'pending' as const })),\n      ]);\n    }\n  }, []);\n\n  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    const selectedFiles = Array.from(e.target.files || []).filter((f) =>\n      f.name.endsWith('.3mf')\n    );\n\n    if (selectedFiles.length > 0) {\n      setFiles((prev) => [\n        ...prev,\n        ...selectedFiles.map((file) => ({ file, status: 'pending' as const })),\n      ]);\n    }\n\n    // Reset input so same file can be selected again\n    if (fileInputRef.current) {\n      fileInputRef.current.value = '';\n    }\n  }, []);\n\n  const removeFile = useCallback((index: number) => {\n    setFiles((prev) => prev.filter((_, i) => i !== index));\n  }, []);\n\n  const handleUpload = () => {\n    if (files.length === 0) return;\n\n    const pendingFiles = files.filter((f) => f.status === 'pending');\n    if (pendingFiles.length === 0) return;\n\n    setFiles((prev) =>\n      prev.map((f) =>\n        f.status === 'pending' ? { ...f, status: 'uploading' } : f\n      )\n    );\n\n    uploadMutation.mutate(pendingFiles.map((f) => f.file));\n  };\n\n  const pendingCount = files.filter((f) => f.status === 'pending').length;\n  const isUploading = uploadMutation.isPending;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n      <Card className=\"w-full max-w-2xl max-h-[90vh] flex flex-col\">\n        <CardContent className=\"p-0 flex flex-col h-full\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <h2 className=\"text-xl font-semibold text-white\">{t('uploadModal.title')}</h2>\n            <button\n              onClick={onClose}\n              className=\"text-bambu-gray hover:text-white transition-colors\"\n            >\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Drop Zone */}\n          <div className=\"p-4\">\n            <div\n              className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${\n                isDragging\n                  ? 'border-bambu-green bg-bambu-green/10'\n                  : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n              }`}\n              onDragOver={handleDragOver}\n              onDragLeave={handleDragLeave}\n              onDrop={handleDrop}\n            >\n              <Upload className=\"w-12 h-12 mx-auto mb-4 text-bambu-gray\" />\n              <p className=\"text-white mb-2\">\n                {t('uploadModal.dragDrop')}\n              </p>\n              <p className=\"text-bambu-gray text-sm mb-4\">{t('uploadModal.or')}</p>\n              <Button\n                variant=\"secondary\"\n                onClick={() => fileInputRef.current?.click()}\n                disabled={isUploading}\n              >\n                {t('uploadModal.browseFiles')}\n              </Button>\n              <input\n                ref={fileInputRef}\n                type=\"file\"\n                accept=\".3mf\"\n                multiple\n                className=\"hidden\"\n                onChange={handleFileSelect}\n              />\n            </div>\n          </div>\n\n          {/* Info about printer model extraction */}\n          <div className=\"px-4 pb-4\">\n            <p className=\"text-xs text-bambu-gray\">\n              {t('uploadModal.extractionInfo')}\n            </p>\n          </div>\n\n          {/* File List */}\n          {files.length > 0 && (\n            <div className=\"px-4 pb-4 max-h-60 overflow-y-auto\">\n              <div className=\"space-y-2\">\n                {files.map((f, index) => (\n                  <div\n                    key={`${f.file.name}-${index}`}\n                    className=\"flex items-center gap-3 p-3 bg-bambu-dark rounded-lg\"\n                  >\n                    <File className=\"w-5 h-5 text-bambu-gray flex-shrink-0\" />\n                    <span className=\"flex-1 text-white text-sm truncate\">\n                      {f.file.name}\n                    </span>\n                    <span className=\"text-xs text-bambu-gray\">\n                      {(f.file.size / (1024 * 1024)).toFixed(1)} MB\n                    </span>\n                    {f.status === 'pending' && (\n                      <button\n                        onClick={() => removeFile(index)}\n                        className=\"text-bambu-gray hover:text-red-400 transition-colors\"\n                        disabled={isUploading}\n                      >\n                        <X className=\"w-4 h-4\" />\n                      </button>\n                    )}\n                    {f.status === 'uploading' && (\n                      <Loader2 className=\"w-4 h-4 text-bambu-green animate-spin\" />\n                    )}\n                    {f.status === 'success' && (\n                      <CheckCircle className=\"w-4 h-4 text-bambu-green\" />\n                    )}\n                    {f.status === 'error' && (\n                      <div className=\"flex items-center gap-2\">\n                        <span className=\"text-xs text-red-400\">{f.error}</span>\n                        <AlertCircle className=\"w-4 h-4 text-red-400\" />\n                      </div>\n                    )}\n                  </div>\n                ))}\n              </div>\n            </div>\n          )}\n\n          {/* Upload Result Summary */}\n          {uploadResult && (\n            <div className=\"px-4 pb-4\">\n              <div className=\"p-3 bg-bambu-dark rounded-lg\">\n                <p className=\"text-sm text-white\">\n                  <span className=\"text-bambu-green\">{uploadResult.uploaded}</span> {t('uploadModal.uploaded')}\n                  {uploadResult.failed > 0 && (\n                    <>, <span className=\"text-red-400\">{uploadResult.failed}</span> {t('uploadModal.failed')}</>\n                  )}\n                </p>\n              </div>\n            </div>\n          )}\n\n          {/* Footer */}\n          <div className=\"flex gap-3 p-4 border-t border-bambu-dark-tertiary\">\n            <Button variant=\"secondary\" onClick={onClose} className=\"flex-1\">\n              {uploadResult ? t('common.close') : t('common.cancel')}\n            </Button>\n            {!uploadResult && (\n              <Button\n                onClick={handleUpload}\n                disabled={pendingCount === 0 || isUploading}\n                className=\"flex-1\"\n              >\n                {isUploading ? (\n                  <>\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    {t('uploadModal.uploading')}\n                  </>\n                ) : (\n                  <>\n                    <Upload className=\"w-4 h-4\" />\n                    {t('uploadModal.upload')} {pendingCount > 0 && `(${pendingCount})`}\n                  </>\n                )}\n              </Button>\n            )}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/VirtualKeyboard.css",
    "content": "/*\n * Dark theme for react-simple-keyboard — matches bambu-dark / bambu-green palette.\n * Tailwind v4 preflight resets button display/flex, so we must explicitly\n * restore the layout that react-simple-keyboard expects.\n */\n\n.simple-keyboard.vkb-theme {\n  background: #1a1a1a;\n  border-top: 1px solid #333;\n  padding: 8px 4px;\n  font-family: inherit;\n}\n\n/* Row layout — Tailwind preflight strips flex from generic elements */\n.simple-keyboard.vkb-theme .hg-row {\n  display: flex !important;\n  flex-direction: row !important;\n  flex-wrap: nowrap !important;\n  gap: 4px;\n  margin-bottom: 4px;\n}\n\n.simple-keyboard.vkb-theme .hg-row:last-child {\n  margin-bottom: 0;\n}\n\n/* Key buttons — must restore inline-flex sizing */\n.simple-keyboard.vkb-theme .hg-button {\n  display: inline-flex !important;\n  align-items: center;\n  justify-content: center;\n  flex-grow: 1;\n  flex-shrink: 1;\n  flex-basis: auto;\n  background: #2d2d2d;\n  color: #e0e0e0;\n  border: none;\n  border-radius: 6px;\n  height: 44px;\n  font-size: 16px;\n  font-weight: 500;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);\n  transition: background 0.1s;\n  cursor: pointer;\n  padding: 0 2px;\n  min-width: 0;\n}\n\n.simple-keyboard.vkb-theme .hg-button:active {\n  background: #00ae42;\n  color: #fff;\n}\n\n/* Functional keys */\n.simple-keyboard.vkb-theme .hg-button-bksp,\n.simple-keyboard.vkb-theme .hg-button-shift,\n.simple-keyboard.vkb-theme .hg-button-lock {\n  background: #3a3a3a;\n  color: #aaa;\n  flex-grow: 1.5;\n}\n\n.simple-keyboard.vkb-theme .hg-button-close {\n  background: #3a3a3a;\n  color: #aaa;\n  flex-grow: 2;\n  font-weight: 600;\n}\n\n.simple-keyboard.vkb-theme .hg-button-close:active {\n  background: #555;\n}\n\n.simple-keyboard.vkb-theme .hg-button-space {\n  flex-grow: 7;\n}\n\n/* Active shift/caps indicator */\n.simple-keyboard.vkb-theme .hg-activeButton {\n  background: #00ae42;\n  color: #fff;\n}\n"
  },
  {
    "path": "frontend/src/components/VirtualKeyboard.tsx",
    "content": "import { useEffect, useRef, useState, useCallback } from 'react';\nimport Keyboard from 'react-simple-keyboard';\nimport 'react-simple-keyboard/build/css/index.css';\nimport './VirtualKeyboard.css';\n\nconst FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);\n\n/**\n * Set value on a controlled React input using the native setter,\n * then dispatch an input event so React picks up the change.\n */\nfunction setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {\n  const setter =\n    Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set ??\n    Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;\n  setter?.call(input, value);\n  input.dispatchEvent(new Event('input', { bubbles: true }));\n}\n\nexport function VirtualKeyboard({ onVisibilityChange }: { onVisibilityChange?: (visible: boolean) => void }) {\n  const [visible, setVisible] = useState(false);\n  const [closing, setClosing] = useState(false);\n  const closingRef = useRef(false);\n  const [layoutName, setLayoutName] = useState('default');\n  const activeInput = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);\n  const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  // Notify parent when keyboard visibility changes\n  useEffect(() => {\n    onVisibilityChange?.(visible);\n  }, [visible, onVisibilityChange]);\n\n  const handleFocusIn = useCallback((e: FocusEvent) => {\n    if (closingRef.current) return;\n    const target = e.target as HTMLElement;\n\n    // Skip inputs that opt out (e.g. SpoolBuddySettingsPage numpad field)\n    if (target.closest('[data-vkb=\"false\"]')) return;\n\n    if (target instanceof HTMLInputElement) {\n      if (!FOCUSABLE_TYPES.has(target.type)) return;\n    } else if (!(target instanceof HTMLTextAreaElement)) {\n      return;\n    }\n\n    activeInput.current = target as HTMLInputElement | HTMLTextAreaElement;\n    setVisible(true);\n    setLayoutName('default');\n\n    // Sync keyboard display with current value\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    (keyboardRef.current as any)?.setInput?.(activeInput.current.value);\n\n    // Scroll focused input into view after the keyboard renders and layout reflows\n    setTimeout(() => {\n      const card = target.closest('.bg-zinc-800, .rounded-lg, [data-vkb-group]') as HTMLElement | null;\n      (card ?? target).scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n    }, 100);\n  }, []);\n\n  const handleFocusOut = useCallback(() => {\n    // Delay to allow click on keyboard buttons to register\n    setTimeout(() => {\n      const active = document.activeElement;\n      // Keep visible if focus moved to keyboard or back to same input\n      if (\n        active &&\n        (containerRef.current?.contains(active) || active === activeInput.current)\n      ) {\n        return;\n      }\n      setVisible(false);\n      activeInput.current = null;\n    }, 150);\n  }, []);\n\n  useEffect(() => {\n    document.addEventListener('focusin', handleFocusIn);\n    document.addEventListener('focusout', handleFocusOut);\n    return () => {\n      document.removeEventListener('focusin', handleFocusIn);\n      document.removeEventListener('focusout', handleFocusOut);\n    };\n  }, [handleFocusIn, handleFocusOut]);\n\n  // Two-phase close: hide the keyboard immediately but keep the backdrop\n  // alive for 400ms to absorb the ghost click that touch devices synthesize.\n  const dismiss = useCallback(() => {\n    closingRef.current = true;\n    setClosing(true);\n    activeInput.current?.blur();\n    activeInput.current = null;\n    setTimeout(() => {\n      setVisible(false);\n      setClosing(false);\n      closingRef.current = false;\n    }, 400);\n  }, []);\n\n  const onKeyPress = useCallback((button: string) => {\n    const input = activeInput.current;\n    if (!input) return;\n\n    if (button === '{shift}') {\n      setLayoutName(prev => prev === 'default' ? 'shift' : 'default');\n      return;\n    }\n    if (button === '{lock}') {\n      setLayoutName(prev => prev === 'default' ? 'shift' : 'default');\n      return;\n    }\n    if (button === '{close}') {\n      dismiss();\n      return;\n    }\n    if (button === '{bksp}') {\n      setNativeValue(input, input.value.slice(0, -1));\n    } else if (button === '{space}') {\n      setNativeValue(input, input.value + ' ');\n    } else {\n      setNativeValue(input, input.value + button);\n      // Auto-unshift after typing one character (like mobile keyboards)\n      if (layoutName === 'shift') {\n        setLayoutName('default');\n      }\n    }\n\n    // Keep focus on the input\n    input.focus();\n    // Sync keyboard internal state\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    (keyboardRef.current as any)?.setInput?.(input.value);\n  }, [layoutName, dismiss]);\n\n  if (!visible) return null;\n\n  return (\n    <>\n      {/* Backdrop: absorbs taps so they don't reach elements under the keyboard.\n          Stays alive during closing phase to catch ghost clicks. */}\n      <div\n        className=\"fixed inset-0 z-[9998] bg-transparent\"\n        onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}\n        onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); if (!closing) dismiss(); }}\n        onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}\n      />\n      {!closing && (\n      <div\n        ref={containerRef}\n        className=\"relative z-[9999] shrink-0\"\n        onMouseDown={(e) => e.preventDefault()}\n        onTouchStart={(e) => {\n          // Prevent focus loss but allow button interaction\n          if (!(e.target as HTMLElement).closest('.hg-button')) {\n            e.preventDefault();\n          }\n        }}\n      >\n        <Keyboard\n        keyboardRef={(r: ReturnType<typeof Keyboard>) => { keyboardRef.current = r; }}\n        layoutName={layoutName}\n        onKeyPress={onKeyPress}\n        theme=\"simple-keyboard vkb-theme\"\n        layout={{\n          default: [\n            '1 2 3 4 5 6 7 8 9 0 {bksp}',\n            'q w e r t y u i o p',\n            '{lock} a s d f g h j k l',\n            '{shift} z x c v b n m . @',\n            '{space} {close}',\n          ],\n          shift: [\n            '! @ # $ % ^ & * ( ) {bksp}',\n            'Q W E R T Y U I O P',\n            '{lock} A S D F G H J K L',\n            '{shift} Z X C V B N M , _',\n            '{space} {close}',\n          ],\n        }}\n        display={{\n          '{bksp}': '\\u232B',\n          '{close}': '\\u2715 Close',\n          '{shift}': '\\u21E7',\n          '{lock}': '\\u21EA',\n          '{space}': ' ',\n        }}\n      />\n      </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/VirtualPrinterAddDialog.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Loader2, ChevronDown, ArrowRightLeft } from 'lucide-react';\nimport { api, multiVirtualPrinterApi } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\n\ntype Mode = 'immediate' | 'review' | 'print_queue' | 'proxy';\n\nconst MODE_LABELS: Record<string, string> = {\n  immediate: 'archive',\n  review: 'review',\n  print_queue: 'queue',\n  proxy: 'proxy',\n};\n\ninterface VirtualPrinterAddDialogProps {\n  onClose: () => void;\n}\n\nexport function VirtualPrinterAddDialog({ onClose }: VirtualPrinterAddDialogProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const [name, setName] = useState('');\n  const [mode, setMode] = useState<Mode>('immediate');\n  const [targetPrinterId, setTargetPrinterId] = useState<number | null>(null);\n\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const createMutation = useMutation({\n    mutationFn: () =>\n      multiVirtualPrinterApi.create({\n        name: name.trim() || 'Bambuddy',\n        mode,\n        target_printer_id: mode === 'proxy' ? (targetPrinterId ?? undefined) : undefined,\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });\n      showToast(t('virtualPrinter.toast.created'));\n      onClose();\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('virtualPrinter.toast.failedToCreate'), 'error');\n    },\n  });\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\"\n      onClick={onClose}\n    >\n      <Card\n        className=\"w-full max-w-md\"\n        onClick={(e: React.MouseEvent) => e.stopPropagation()}\n      >\n        <CardContent className=\"p-6 space-y-4\">\n          <h3 className=\"text-lg font-semibold text-white\">{t('virtualPrinter.addDialog.title')}</h3>\n\n          {/* Name */}\n          <div>\n            <label className=\"text-sm text-white font-medium block mb-1\">{t('virtualPrinter.addDialog.name')}</label>\n            <input\n              type=\"text\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder=\"Bambuddy\"\n              className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm placeholder-bambu-gray\"\n              autoFocus\n            />\n          </div>\n\n          {/* Mode */}\n          <div>\n            <label className=\"text-sm text-white font-medium block mb-1\">{t('virtualPrinter.mode.title')}</label>\n            <div className=\"grid grid-cols-2 gap-2\">\n              {(['immediate', 'review', 'print_queue', 'proxy'] as const).map((m) => (\n                <button\n                  key={m}\n                  onClick={() => setMode(m)}\n                  className={`p-2 rounded-lg border text-left transition-colors ${\n                    mode === m\n                      ? m === 'proxy'\n                        ? 'border-blue-500 bg-blue-500/10'\n                        : 'border-bambu-green bg-bambu-green/10'\n                      : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n                  }`}\n                >\n                  <div className=\"flex items-center gap-1.5 text-white text-xs font-medium\">\n                    {m === 'proxy' && <ArrowRightLeft className=\"w-3 h-3\" />}\n                    {t(`virtualPrinter.mode.${MODE_LABELS[m]}`)}\n                  </div>\n                  <div className=\"text-[10px] text-bambu-gray\">\n                    {t(`virtualPrinter.mode.${MODE_LABELS[m]}Desc`)}\n                  </div>\n                </button>\n              ))}\n            </div>\n          </div>\n\n          {/* Target Printer - only for proxy mode */}\n          {mode === 'proxy' && (\n            <div>\n              <label className=\"text-sm text-white font-medium block mb-1\">{t('virtualPrinter.targetPrinter.title')}</label>\n              <div className=\"relative\">\n                <select\n                  value={targetPrinterId ?? ''}\n                  onChange={(e) => {\n                    const id = parseInt(e.target.value, 10);\n                    setTargetPrinterId(isNaN(id) ? null : id);\n                  }}\n                  className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm appearance-none cursor-pointer pr-10\"\n                >\n                  <option value=\"\">{t('virtualPrinter.targetPrinter.placeholder')}</option>\n                  {printers?.map((p) => (\n                    <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>\n                  ))}\n                </select>\n                <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n              </div>\n            </div>\n          )}\n\n          <p className=\"text-xs text-bambu-gray\">\n            {t('virtualPrinter.addDialog.hint')}\n          </p>\n\n          {/* Actions */}\n          <div className=\"flex gap-3 pt-2\">\n            <Button variant=\"secondary\" onClick={onClose} className=\"flex-1\" disabled={createMutation.isPending}>\n              {t('common.cancel')}\n            </Button>\n            <Button\n              variant=\"primary\"\n              onClick={() => createMutation.mutate()}\n              className=\"flex-1\"\n              disabled={createMutation.isPending}\n            >\n              {createMutation.isPending ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                t('virtualPrinter.addDialog.create')\n              )}\n            </Button>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/VirtualPrinterCard.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';\nimport {\n  Loader2, Check, AlertTriangle, Eye, EyeOff, Info,\n  ChevronDown, ChevronRight, ArrowRightLeft, Trash2,\n} from 'lucide-react';\nimport { api, multiVirtualPrinterApi } from '../api/client';\nimport type { VirtualPrinterConfig } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { ConfirmModal } from './ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\n\ntype LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';\n\nconst MODE_LABELS: Record<string, string> = {\n  immediate: 'archive',\n  review: 'review',\n  print_queue: 'queue',\n  proxy: 'proxy',\n};\n\ninterface VirtualPrinterCardProps {\n  printer: VirtualPrinterConfig;\n  models: Record<string, string>;\n}\n\nexport function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const [expanded, setExpanded] = useState(true);\n  const [localEnabled, setLocalEnabled] = useState(printer.enabled);\n  const [localName, setLocalName] = useState(printer.name);\n  const [localAccessCode, setLocalAccessCode] = useState('');\n  const [localMode, setLocalMode] = useState<LocalMode>(\n    (printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode\n  );\n  const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(printer.target_printer_id);\n  const [localBindIp, setLocalBindIp] = useState(printer.bind_ip || '');\n  const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');\n  const [localModel, setLocalModel] = useState(printer.model || '');\n  const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);\n  const [showAccessCode, setShowAccessCode] = useState(false);\n  const [pendingAction, setPendingAction] = useState<string | null>(null);\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n\n  // Sync local state when props change (e.g., after backend auto-disable)\n  useEffect(() => {\n    if (!pendingAction) {\n      setLocalEnabled(printer.enabled);\n      setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);\n      setLocalName(printer.name);\n      setLocalTargetPrinterId(printer.target_printer_id);\n      setLocalBindIp(printer.bind_ip || '');\n      setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');\n      setLocalModel(printer.model || '');\n      setLocalAutoDispatch(printer.auto_dispatch ?? true);\n    }\n  }, [printer, pendingAction]);\n\n  // Fetch printers for dropdown\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  // Fetch network interfaces\n  const { data: networkInterfaces } = useQuery({\n    queryKey: ['network-interfaces'],\n    queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: (data: Parameters<typeof multiVirtualPrinterApi.update>[1]) =>\n      multiVirtualPrinterApi.update(printer.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });\n      showToast(t('virtualPrinter.toast.updated'));\n      setPendingAction(null);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');\n      setLocalEnabled(printer.enabled);\n      setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);\n      setLocalTargetPrinterId(printer.target_printer_id);\n      setLocalBindIp(printer.bind_ip || '');\n      setPendingAction(null);\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: () => multiVirtualPrinterApi.remove(printer.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });\n      showToast(t('virtualPrinter.toast.deleted'));\n      setShowDeleteConfirm(false);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('virtualPrinter.toast.failedToDelete'), 'error');\n      setShowDeleteConfirm(false);\n    },\n  });\n\n  const handleToggleEnabled = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    const newEnabled = !localEnabled;\n    if (newEnabled) {\n      if (!localBindIp) {\n        showToast(t('virtualPrinter.toast.bindIpRequired'), 'error');\n        return;\n      }\n      if (localMode === 'proxy') {\n        if (!localTargetPrinterId) {\n          showToast(t('virtualPrinter.toast.targetPrinterRequired'), 'error');\n          return;\n        }\n      } else {\n        if (!localAccessCode && !printer.access_code_set) {\n          showToast(t('virtualPrinter.toast.accessCodeRequired'), 'error');\n          return;\n        }\n      }\n    }\n    setLocalEnabled(newEnabled);\n    setPendingAction('toggle');\n    updateMutation.mutate({ enabled: newEnabled });\n  };\n\n  const handleNameChange = () => {\n    if (!localName.trim()) return;\n    setPendingAction('name');\n    updateMutation.mutate({ name: localName.trim() });\n  };\n\n  const handleAccessCodeChange = () => {\n    if (!localAccessCode) {\n      showToast(t('virtualPrinter.toast.accessCodeEmpty'), 'error');\n      return;\n    }\n    if (localAccessCode.length !== 8) {\n      showToast(t('virtualPrinter.toast.accessCodeLength'), 'error');\n      return;\n    }\n    setPendingAction('accessCode');\n    updateMutation.mutate({ access_code: localAccessCode });\n    setLocalAccessCode('');\n  };\n\n  const handleModeChange = (mode: LocalMode) => {\n    setLocalMode(mode);\n    setPendingAction('mode');\n    updateMutation.mutate({ mode });\n  };\n\n  const handleModelChange = (model: string) => {\n    setLocalModel(model);\n    setPendingAction('model');\n    updateMutation.mutate({ model });\n  };\n\n  const handleTargetPrinterChange = (printerId: number) => {\n    setLocalTargetPrinterId(printerId);\n    setPendingAction('targetPrinter');\n    updateMutation.mutate({ target_printer_id: printerId });\n  };\n\n  const handleRemoteInterfaceChange = (ip: string) => {\n    setLocalRemoteInterfaceIp(ip);\n    setPendingAction('remoteInterface');\n    updateMutation.mutate({ remote_interface_ip: ip });\n  };\n\n  const isRunning = printer.status?.running || false;\n  const modeLabel = t(`virtualPrinter.mode.${MODE_LABELS[localMode] || 'archive'}`);\n  const targetPrinterName = printers?.find(p => p.id === localTargetPrinterId)?.name;\n\n  return (\n    <>\n      <Card>\n        {/* Collapsed header - always visible, clickable to expand */}\n        <div\n          className=\"px-4 py-3 flex items-center gap-3 cursor-pointer select-none\"\n          onClick={() => setExpanded(!expanded)}\n        >\n          <button className=\"text-bambu-gray flex-shrink-0\">\n            {expanded\n              ? <ChevronDown className=\"w-4 h-4\" />\n              : <ChevronRight className=\"w-4 h-4\" />\n            }\n          </button>\n          <span className={`w-2 h-2 rounded-full flex-shrink-0 ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />\n          <span className=\"text-white font-medium truncate\">{printer.name}</span>\n          <span className=\"text-xs text-bambu-gray flex-shrink-0\">{modeLabel}</span>\n          {printer.model_name && (\n            <span className=\"text-xs text-bambu-gray flex-shrink-0\">{printer.model_name}</span>\n          )}\n          {targetPrinterName && (\n            <span className=\"text-xs text-bambu-gray flex-shrink-0 truncate\">\n              {localMode === 'proxy' && <ArrowRightLeft className=\"w-3 h-3 inline mr-1\" />}\n              {targetPrinterName}\n            </span>\n          )}\n          {localBindIp && (\n            <span className=\"text-[10px] text-bambu-gray flex-shrink-0 font-mono\">{localBindIp}</span>\n          )}\n          {localRemoteInterfaceIp && (\n            <span className=\"text-[10px] text-bambu-gray flex-shrink-0 font-mono\">{localRemoteInterfaceIp}</span>\n          )}\n          <div className=\"ml-auto flex items-center gap-2 flex-shrink-0\" onClick={(e) => e.stopPropagation()}>\n            <button\n              onClick={handleToggleEnabled}\n              disabled={pendingAction === 'toggle'}\n              className={`relative w-10 h-5 rounded-full transition-colors ${\n                localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n              } ${pendingAction === 'toggle' ? 'opacity-50' : ''}`}\n            >\n              <span\n                className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${\n                  localEnabled ? 'translate-x-5' : ''\n                }`}\n              />\n            </button>\n          </div>\n        </div>\n\n        {/* Expanded content */}\n        {expanded && (\n          <CardContent className=\"pt-0 space-y-4\">\n            <div className=\"border-t border-bambu-dark-tertiary\" />\n\n            {/* Name + delete */}\n            <div className=\"flex items-center gap-2\">\n              <input\n                type=\"text\"\n                value={localName}\n                onChange={(e) => setLocalName(e.target.value)}\n                onBlur={handleNameChange}\n                onKeyDown={(e) => e.key === 'Enter' && handleNameChange()}\n                className=\"flex-1 text-sm text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 focus:border-bambu-green focus:outline-none\"\n              />\n              <span className=\"text-xs text-bambu-gray font-mono\">{printer.serial}</span>\n              <button\n                onClick={() => setShowDeleteConfirm(true)}\n                className=\"p-1.5 text-bambu-gray hover:text-red-400 transition-colors\"\n                title={t('common.delete')}\n              >\n                <Trash2 className=\"w-4 h-4\" />\n              </button>\n            </div>\n\n            {/* Mode */}\n            <div>\n              <div className=\"text-white text-sm font-medium mb-2\">{t('virtualPrinter.mode.title')}</div>\n              <div className=\"grid grid-cols-2 gap-2\">\n                {(['immediate', 'review', 'print_queue', 'proxy'] as const).map((mode) => (\n                  <button\n                    key={mode}\n                    onClick={() => handleModeChange(mode)}\n                    disabled={pendingAction === 'mode'}\n                    className={`p-2 rounded-lg border text-left transition-colors ${\n                      localMode === mode\n                        ? mode === 'proxy'\n                          ? 'border-blue-500 bg-blue-500/10'\n                          : 'border-bambu-green bg-bambu-green/10'\n                        : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n                    }`}\n                  >\n                    <div className=\"flex items-center gap-1.5 text-white text-xs font-medium\">\n                      {mode === 'proxy' && <ArrowRightLeft className=\"w-3 h-3\" />}\n                      {t(`virtualPrinter.mode.${MODE_LABELS[mode]}`)}\n                    </div>\n                    <div className=\"text-[10px] text-bambu-gray\">\n                      {t(`virtualPrinter.mode.${MODE_LABELS[mode]}Desc`)}\n                    </div>\n                  </button>\n                ))}\n              </div>\n            </div>\n\n            {/* Auto-dispatch toggle - only for print_queue mode */}\n            {localMode === 'print_queue' && (\n              <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <div className=\"text-white text-sm font-medium\">{t('virtualPrinter.autoDispatch.title')}</div>\n                    <div className=\"text-[10px] text-bambu-gray\">{t('virtualPrinter.autoDispatch.description')}</div>\n                  </div>\n                  <button\n                    onClick={() => {\n                      const newVal = !localAutoDispatch;\n                      setLocalAutoDispatch(newVal);\n                      setPendingAction('autoDispatch');\n                      updateMutation.mutate({ auto_dispatch: newVal });\n                    }}\n                    disabled={pendingAction === 'autoDispatch'}\n                    className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${\n                      localAutoDispatch ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n                    } ${pendingAction === 'autoDispatch' ? 'opacity-50' : ''}`}\n                  >\n                    <span\n                      className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${\n                        localAutoDispatch ? 'translate-x-5' : ''\n                      }`}\n                    />\n                  </button>\n                </div>\n              </div>\n            )}\n\n            {/* Printer Model - for non-proxy modes */}\n            {localMode !== 'proxy' && (\n              <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n                <div className=\"text-white text-sm font-medium mb-1\">{t('virtualPrinter.model.title')}</div>\n                <p className=\"text-xs text-bambu-gray mb-2\">{t('virtualPrinter.model.description')}</p>\n                <div className=\"relative\">\n                  <select\n                    value={localModel}\n                    onChange={(e) => handleModelChange(e.target.value)}\n                    disabled={pendingAction === 'model'}\n                    className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10\"\n                  >\n                    {Object.entries(models).map(([code, name]) => (\n                      <option key={code} value={code}>{name} ({code})</option>\n                    ))}\n                  </select>\n                  <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                </div>\n              </div>\n            )}\n\n            {/* Proxy mode: hint about using target printer's access code */}\n            {localMode === 'proxy' && (\n              <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n                <div className=\"flex items-start gap-2 p-2 rounded bg-blue-500/10 border border-blue-500/30\">\n                  <Info className=\"w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5\" />\n                  <p className=\"text-xs text-bambu-gray\">\n                    {t('virtualPrinter.proxy.accessCodeHint')}\n                  </p>\n                </div>\n              </div>\n            )}\n\n            {/* Access Code - only for non-proxy modes */}\n            {localMode !== 'proxy' && (\n              <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n                <div className=\"flex items-center gap-2 mb-2\">\n                  <div className=\"text-white text-sm font-medium\">{t('virtualPrinter.accessCode.title')}</div>\n                  {printer.access_code_set ? (\n                    <span className=\"flex items-center gap-1 text-xs text-green-400\">\n                      <Check className=\"w-3 h-3\" />\n                      {t('virtualPrinter.accessCode.isSet')}\n                    </span>\n                  ) : (\n                    <span className=\"flex items-center gap-1 text-xs text-yellow-400\">\n                      <AlertTriangle className=\"w-3 h-3\" />\n                      {t('virtualPrinter.accessCode.notSet')}\n                    </span>\n                  )}\n                </div>\n                <div className=\"flex gap-2\">\n                  <div className=\"relative flex-1\">\n                    <input\n                      type={showAccessCode ? 'text' : 'password'}\n                      value={localAccessCode}\n                      onChange={(e) => setLocalAccessCode(e.target.value)}\n                      placeholder={printer.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}\n                      maxLength={8}\n                      className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm placeholder-bambu-gray pr-10 font-mono\"\n                    />\n                    <button\n                      onClick={() => setShowAccessCode(!showAccessCode)}\n                      className=\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\"\n                    >\n                      {showAccessCode ? <EyeOff className=\"w-4 h-4\" /> : <Eye className=\"w-4 h-4\" />}\n                    </button>\n                  </div>\n                  <Button\n                    onClick={handleAccessCodeChange}\n                    disabled={!localAccessCode || pendingAction === 'accessCode'}\n                    variant=\"primary\"\n                  >\n                    {pendingAction === 'accessCode' ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : t('common.save')}\n                  </Button>\n                </div>\n                {localAccessCode && (\n                  <p className=\"text-xs text-bambu-gray mt-1\">\n                    <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>\n                      {t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}\n                    </span>\n                  </p>\n                )}\n              </div>\n            )}\n\n            {/* Target Printer */}\n            <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n              <div className=\"text-white text-sm font-medium mb-2\">{t('virtualPrinter.targetPrinter.title')}</div>\n              <div className=\"relative\">\n                <select\n                  value={localTargetPrinterId ?? ''}\n                  onChange={(e) => {\n                    const id = parseInt(e.target.value, 10);\n                    if (!isNaN(id)) handleTargetPrinterChange(id);\n                  }}\n                  disabled={pendingAction === 'targetPrinter'}\n                  className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10\"\n                >\n                  <option value=\"\">{t('virtualPrinter.targetPrinter.placeholder')}</option>\n                  {printers?.map((p) => (\n                    <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>\n                  ))}\n                </select>\n                <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n              </div>\n            </div>\n\n            {/* Bind Interface */}\n            <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n              <div className=\"text-white text-sm font-medium mb-1\">{t('virtualPrinter.bindIp.title')}</div>\n              <div className=\"relative\">\n                <select\n                  value={localBindIp}\n                  onChange={(e) => {\n                    setLocalBindIp(e.target.value);\n                    setPendingAction('bindIp');\n                    updateMutation.mutate({ bind_ip: e.target.value });\n                  }}\n                  disabled={pendingAction === 'bindIp'}\n                  className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10\"\n                >\n                  <option value=\"\">{t('virtualPrinter.bindIp.placeholder')}</option>\n                  {networkInterfaces?.map((iface) => (\n                    <option key={iface.ip} value={iface.ip}>\n                      {iface.name} ({iface.ip}){iface.is_alias ? ' [alias]' : ''} - {iface.subnet}\n                    </option>\n                  ))}\n                </select>\n                <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n              </div>\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('virtualPrinter.bindIp.hint')}</p>\n            </div>\n\n            {/* Remote Interface - always visible for configuration */}\n            <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n              <div className=\"flex items-center gap-2 mb-1\">\n                <div className=\"text-white text-sm font-medium\">{t('virtualPrinter.remoteInterface.title')}</div>\n                {localRemoteInterfaceIp ? (\n                  <span className=\"flex items-center gap-1 text-xs text-green-400\"><Check className=\"w-3 h-3\" /></span>\n                ) : (\n                  <span className=\"flex items-center gap-1 text-xs text-bambu-gray\" title={t('virtualPrinter.remoteInterface.optional')}><Info className=\"w-3 h-3\" /></span>\n                )}\n              </div>\n              <div className=\"relative\">\n                <select\n                  value={localRemoteInterfaceIp}\n                  onChange={(e) => handleRemoteInterfaceChange(e.target.value)}\n                  disabled={pendingAction === 'remoteInterface'}\n                  className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10\"\n                >\n                  <option value=\"\">{t('virtualPrinter.remoteInterface.placeholder')}</option>\n                  {networkInterfaces?.map((iface) => (\n                    <option key={iface.ip} value={iface.ip}>\n                      {iface.name} ({iface.ip}) - {iface.subnet}\n                    </option>\n                  ))}\n                </select>\n                <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n              </div>\n            </div>\n          </CardContent>\n        )}\n      </Card>\n\n      {showDeleteConfirm && (\n        <ConfirmModal\n          title={t('virtualPrinter.deleteConfirm.title')}\n          message={t('virtualPrinter.deleteConfirm.message', { name: printer.name })}\n          variant=\"danger\"\n          confirmText={t('common.delete')}\n          isLoading={deleteMutation.isPending}\n          onConfirm={() => deleteMutation.mutate()}\n          onCancel={() => setShowDeleteConfirm(false)}\n        />\n      )}\n\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/VirtualPrinterList.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery } from '@tanstack/react-query';\nimport { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info } from 'lucide-react';\nimport { multiVirtualPrinterApi } from '../api/client';\nimport { Card, CardContent } from './Card';\nimport { Button } from './Button';\nimport { VirtualPrinterCard } from './VirtualPrinterCard';\nimport { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';\n\nexport function VirtualPrinterList() {\n  const { t } = useTranslation();\n  const [showAddDialog, setShowAddDialog] = useState(false);\n\n  const { data, isLoading } = useQuery({\n    queryKey: ['virtual-printers'],\n    queryFn: multiVirtualPrinterApi.list,\n    refetchInterval: 10000,\n  });\n\n  if (isLoading) {\n    return (\n      <Card>\n        <CardContent className=\"py-8 flex justify-center\">\n          <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n        </CardContent>\n      </Card>\n    );\n  }\n\n  const printers = data?.printers || [];\n  const models = data?.models || {};\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Top row - Setup Required (25%) + How it works (75%) */}\n      <div className=\"grid grid-cols-1 lg:grid-cols-4 gap-4 items-stretch\">\n        <Card className=\"border-l-4 border-l-yellow-500\">\n          <CardContent className=\"py-3 px-4\">\n            <div className=\"flex items-start gap-2\">\n              <AlertTriangle className=\"w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5\" />\n              <div className=\"text-xs\">\n                <p className=\"text-white font-medium\">{t('virtualPrinter.setupRequired.title')}</p>\n                <p className=\"text-bambu-gray mt-1\">{t('virtualPrinter.setupRequired.description')}</p>\n                <a\n                  href=\"https://wiki.bambuddy.cool/features/virtual-printer/\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"inline-flex items-center gap-1.5 mt-2 px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/50 rounded text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs\"\n                >\n                  <ExternalLink className=\"w-3 h-3\" />\n                  {t('virtualPrinter.setupRequired.readGuide')}\n                </a>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        <Card className=\"lg:col-span-3\">\n          <CardContent className=\"py-3 px-4\">\n            <div className=\"flex items-start gap-2\">\n              <Info className=\"w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5\" />\n              <div className=\"text-xs text-bambu-gray\">\n                <p className=\"text-white font-medium mb-1\">{t('virtualPrinter.howItWorks.title')}</p>\n                <ul className=\"space-y-1 list-disc list-inside\">\n                  <li>{t('virtualPrinter.howItWorks.step1')}</li>\n                  <li>{t('virtualPrinter.howItWorks.step2')}</li>\n                  <li>{t('virtualPrinter.howItWorks.step3')}</li>\n                </ul>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n\n      {/* Header with add button */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <Printer className=\"w-5 h-5 text-bambu-green\" />\n          <h2 className=\"text-lg font-semibold text-white\">{t('virtualPrinter.list.title')}</h2>\n          <span className=\"text-sm text-bambu-gray\">({printers.length})</span>\n        </div>\n        <Button variant=\"primary\" onClick={() => setShowAddDialog(true)}>\n          <Plus className=\"w-4 h-4 mr-1\" />\n          {t('virtualPrinter.list.add')}\n        </Button>\n      </div>\n\n      {/* Printer cards - 3 column grid */}\n      {printers.length === 0 ? (\n        <Card>\n          <CardContent className=\"py-8 text-center\">\n            <Printer className=\"w-12 h-12 text-bambu-gray mx-auto mb-3\" />\n            <p className=\"text-bambu-gray mb-4\">{t('virtualPrinter.list.empty')}</p>\n            <Button variant=\"primary\" onClick={() => setShowAddDialog(true)}>\n              <Plus className=\"w-4 h-4 mr-1\" />\n              {t('virtualPrinter.list.addFirst')}\n            </Button>\n          </CardContent>\n        </Card>\n      ) : (\n        <div className=\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 items-start\">\n          {printers.map((printer) => (\n            <VirtualPrinterCard key={printer.id} printer={printer} models={models} />\n          ))}\n        </div>\n      )}\n\n      {showAddDialog && (\n        <VirtualPrinterAddDialog onClose={() => setShowAddDialog(false)} />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/VirtualPrinterSettings.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink, ArrowRightLeft } from 'lucide-react';\nimport { api, virtualPrinterApi } from '../api/client';\nimport { Card, CardContent, CardHeader } from './Card';\nimport { Button } from './Button';\nimport { useToast } from '../contexts/ToastContext';\n\ntype LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';\n\nexport function VirtualPrinterSettings() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const [localEnabled, setLocalEnabled] = useState(false);\n  const [localAccessCode, setLocalAccessCode] = useState('');\n  const [localMode, setLocalMode] = useState<LocalMode>('immediate');\n  const [localModel, setLocalModel] = useState('BL-P001');\n  const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);\n  const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState('');\n  const [showAccessCode, setShowAccessCode] = useState(false);\n  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | 'remoteInterface' | null>(null);\n\n  // Fetch current settings\n  const { data: settings, isLoading } = useQuery({\n    queryKey: ['virtual-printer-settings'],\n    queryFn: virtualPrinterApi.getSettings,\n    refetchInterval: 10000, // Refresh every 10 seconds for status updates\n  });\n\n  // Fetch available models\n  const { data: modelsData } = useQuery({\n    queryKey: ['virtual-printer-models'],\n    queryFn: virtualPrinterApi.getModels,\n  });\n\n  // Fetch printers for proxy mode dropdown\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  // Fetch network interfaces for IP override (all modes when enabled)\n  const { data: networkInterfaces } = useQuery({\n    queryKey: ['network-interfaces'],\n    queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),\n    enabled: localEnabled,\n  });\n\n  // Initialize local state from settings\n  useEffect(() => {\n    if (settings) {\n      setLocalEnabled(settings.enabled);\n      // Map legacy 'queue' mode to 'review'\n      let mode: LocalMode = settings.mode === 'queue' ? 'review' : settings.mode as LocalMode;\n      if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue' && mode !== 'proxy') {\n        mode = 'immediate'; // fallback\n      }\n      setLocalMode(mode);\n      setLocalModel(settings.model);\n      setLocalTargetPrinterId(settings.target_printer_id);\n      setLocalRemoteInterfaceIp(settings.remote_interface_ip || '');\n    }\n  }, [settings]);\n\n  // Update mutation\n  const updateMutation = useMutation({\n    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number; remote_interface_ip?: string }) =>\n      virtualPrinterApi.updateSettings(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });\n      showToast(t('virtualPrinter.toast.updated'));\n      setPendingAction(null);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');\n      // Revert local state on error\n      if (settings) {\n        setLocalEnabled(settings.enabled);\n        // Map legacy 'queue' mode to 'review'\n        const mode = settings.mode === 'queue' ? 'review' : settings.mode;\n        setLocalMode(['immediate', 'review', 'print_queue', 'proxy'].includes(mode) ? mode as LocalMode : 'immediate');\n        setLocalModel(settings.model);\n        setLocalTargetPrinterId(settings.target_printer_id);\n      }\n      setPendingAction(null);\n    },\n  });\n\n  const handleToggleEnabled = () => {\n    const newEnabled = !localEnabled;\n\n    // Validation depends on mode\n    if (newEnabled) {\n      if (localMode === 'proxy') {\n        // Proxy mode requires target printer\n        if (!localTargetPrinterId) {\n          showToast(t('virtualPrinter.toast.targetPrinterRequired'), 'error');\n          return;\n        }\n      } else {\n        // Other modes require access code\n        if (!localAccessCode && !settings?.access_code_set) {\n          showToast(t('virtualPrinter.toast.accessCodeRequired'), 'error');\n          return;\n        }\n      }\n    }\n\n    setLocalEnabled(newEnabled);\n    setPendingAction('toggle');\n    updateMutation.mutate({\n      enabled: newEnabled,\n      access_code: localMode !== 'proxy' ? (localAccessCode || undefined) : undefined,\n      mode: localMode,\n      target_printer_id: localMode === 'proxy' ? (localTargetPrinterId ?? undefined) : undefined,\n    });\n  };\n\n  const handleAccessCodeChange = () => {\n    if (!localAccessCode) {\n      showToast(t('virtualPrinter.toast.accessCodeEmpty'), 'error');\n      return;\n    }\n\n    if (localAccessCode.length !== 8) {\n      showToast(t('virtualPrinter.toast.accessCodeLength'), 'error');\n      return;\n    }\n\n    setPendingAction('accessCode');\n    updateMutation.mutate({\n      access_code: localAccessCode,\n    });\n    setLocalAccessCode(''); // Clear after saving\n  };\n\n  const handleModeChange = (mode: LocalMode) => {\n    setLocalMode(mode);\n    setPendingAction('mode');\n    updateMutation.mutate({ mode });\n  };\n\n  const handleTargetPrinterChange = (printerId: number) => {\n    setLocalTargetPrinterId(printerId);\n    setPendingAction('targetPrinter');\n    updateMutation.mutate({\n      target_printer_id: printerId,\n    });\n  };\n\n  const handleModelChange = (model: string) => {\n    setLocalModel(model);\n    setPendingAction('model');\n    updateMutation.mutate({ model });\n  };\n\n  const handleRemoteInterfaceChange = (ip: string) => {\n    setLocalRemoteInterfaceIp(ip);\n    setPendingAction('remoteInterface');\n    updateMutation.mutate({ remote_interface_ip: ip });\n  };\n\n  if (isLoading) {\n    return (\n      <Card>\n        <CardContent className=\"py-8 flex justify-center\">\n          <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n        </CardContent>\n      </Card>\n    );\n  }\n\n  const status = settings?.status;\n  const isRunning = status?.running || false;\n\n  return (\n    <div className=\"flex flex-col lg:flex-row gap-6 lg:gap-8\">\n      {/* Left Column - Settings */}\n      <div className=\"space-y-6 lg:w-[480px] lg:flex-shrink-0\">\n      <Card>\n        <CardHeader>\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <Printer className=\"w-5 h-5 text-bambu-green\" />\n              <h2 className=\"text-lg font-semibold text-white\">{t('virtualPrinter.title')}</h2>\n            </div>\n            {status && (\n              <div className={`flex items-center gap-2 text-sm ${isRunning ? 'text-green-400' : 'text-bambu-gray'}`}>\n                <span className={`w-2 h-2 rounded-full ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />\n                {isRunning ? t('virtualPrinter.running') : t('virtualPrinter.stopped')}\n              </div>\n            )}\n          </div>\n        </CardHeader>\n        <CardContent className=\"space-y-4\">\n          <p className=\"text-sm text-bambu-gray\">\n            {localMode === 'proxy'\n              ? t('virtualPrinter.description.proxy')\n              : t('virtualPrinter.description.default')}\n          </p>\n\n          {/* Enable/Disable Toggle */}\n          <div className=\"flex items-center justify-between py-3 border-t border-bambu-dark-tertiary\">\n            <div>\n              <div className=\"text-white font-medium\">{t('virtualPrinter.enable.title')}</div>\n              <div className=\"text-sm text-bambu-gray\">\n                {isRunning ? (\n                  localMode === 'proxy'\n                    ? t('virtualPrinter.enable.proxyingTo', { name: printers?.find(p => p.id === localTargetPrinterId)?.name || 'printer' })\n                    : t('virtualPrinter.enable.visibleInSlicer')\n                ) : t('virtualPrinter.enable.notActive')}\n              </div>\n            </div>\n            <button\n              onClick={handleToggleEnabled}\n              disabled={pendingAction === 'toggle'}\n              className={`relative w-12 h-6 rounded-full transition-colors ${\n                localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n              } ${pendingAction === 'toggle' ? 'opacity-50' : ''}`}\n            >\n              <span\n                className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${\n                  localEnabled ? 'translate-x-6' : ''\n                }`}\n              />\n            </button>\n          </div>\n\n          {/* Printer Model - only for non-proxy modes */}\n          {localMode !== 'proxy' && (\n          <div className=\"py-3 border-t border-bambu-dark-tertiary\">\n            <div className=\"text-white font-medium mb-2\">{t('virtualPrinter.model.title')}</div>\n            <div className=\"text-sm text-bambu-gray mb-3\">\n              {t('virtualPrinter.model.description')}\n            </div>\n            <div className=\"relative\">\n              <select\n                value={localModel}\n                onChange={(e) => handleModelChange(e.target.value)}\n                disabled={pendingAction === 'model'}\n                className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10\"\n              >\n                {modelsData?.models && Object.entries(modelsData.models)\n                  .sort(([, a], [, b]) => (a as string).localeCompare(b as string))\n                  .map(([code, name]) => (\n                  <option key={code} value={code}>\n                    {name}\n                  </option>\n                ))}\n              </select>\n              <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n            </div>\n            {localEnabled && isRunning && (\n              <p className=\"text-xs text-bambu-gray mt-2\">\n                <Info className=\"w-3 h-3 inline mr-1\" />\n                {t('virtualPrinter.model.restartWarning')}\n              </p>\n            )}\n          </div>\n          )}\n\n          {/* Access Code - only for non-proxy modes */}\n          {localMode !== 'proxy' && (\n            <div className=\"py-3 border-t border-bambu-dark-tertiary\">\n              <div className=\"text-white font-medium mb-2\">{t('virtualPrinter.accessCode.title')}</div>\n              <div className=\"text-sm text-bambu-gray mb-3\">\n                {settings?.access_code_set ? (\n                  <span className=\"flex items-center gap-1 text-green-400\">\n                    <Check className=\"w-4 h-4\" />\n                    {t('virtualPrinter.accessCode.isSet')}\n                  </span>\n                ) : (\n                  <span className=\"flex items-center gap-1 text-yellow-400\">\n                    <AlertTriangle className=\"w-4 h-4\" />\n                    {t('virtualPrinter.accessCode.notSet')}\n                  </span>\n                )}\n              </div>\n              <div className=\"flex gap-2\">\n                <div className=\"relative flex-1\">\n                  <input\n                    type={showAccessCode ? 'text' : 'password'}\n                    value={localAccessCode}\n                    onChange={(e) => setLocalAccessCode(e.target.value)}\n                    placeholder={settings?.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}\n                    maxLength={8}\n                    className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono\"\n                  />\n                  <button\n                    onClick={() => setShowAccessCode(!showAccessCode)}\n                    className=\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\"\n                  >\n                    {showAccessCode ? <EyeOff className=\"w-4 h-4\" /> : <Eye className=\"w-4 h-4\" />}\n                  </button>\n                </div>\n                <Button\n                  onClick={handleAccessCodeChange}\n                  disabled={!localAccessCode || pendingAction === 'accessCode'}\n                  variant=\"primary\"\n                >\n                  {pendingAction === 'accessCode' ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : t('common.save')}\n                </Button>\n              </div>\n              <p className=\"text-xs text-bambu-gray mt-2\">\n                {t('virtualPrinter.accessCode.hint')}\n                {localAccessCode && (\n                  <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>\n                    {' '}{t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}\n                  </span>\n                )}\n              </p>\n            </div>\n          )}\n\n          {/* Target Printer - only for proxy mode */}\n          {localMode === 'proxy' && (\n            <div className=\"py-3 border-t border-bambu-dark-tertiary\">\n              <div className=\"text-white font-medium mb-2\">{t('virtualPrinter.targetPrinter.title')}</div>\n              <div className=\"text-sm text-bambu-gray mb-3\">\n                {localTargetPrinterId ? (\n                  <span className=\"flex items-center gap-1 text-green-400\">\n                    <Check className=\"w-4 h-4\" />\n                    {t('virtualPrinter.targetPrinter.configured')}\n                  </span>\n                ) : (\n                  <span className=\"flex items-center gap-1 text-yellow-400\">\n                    <AlertTriangle className=\"w-4 h-4\" />\n                    {t('virtualPrinter.targetPrinter.notConfigured')}\n                  </span>\n                )}\n              </div>\n              <div className=\"relative\">\n                <select\n                  value={localTargetPrinterId ?? ''}\n                  onChange={(e) => {\n                    const id = parseInt(e.target.value, 10);\n                    if (!isNaN(id)) {\n                      handleTargetPrinterChange(id);\n                    }\n                  }}\n                  disabled={pendingAction === 'targetPrinter'}\n                  className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10\"\n                >\n                  <option value=\"\">{t('virtualPrinter.targetPrinter.placeholder')}</option>\n                  {printers?.map((printer) => (\n                    <option key={printer.id} value={printer.id}>\n                      {printer.name} ({printer.ip_address})\n                    </option>\n                  ))}\n                </select>\n                <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n              </div>\n              <p className=\"text-xs text-bambu-gray mt-2\">\n                {t('virtualPrinter.targetPrinter.hint')}\n              </p>\n              {!printers?.length && (\n                <p className=\"text-xs text-yellow-400 mt-2\">\n                  <AlertTriangle className=\"w-3 h-3 inline mr-1\" />\n                  {t('virtualPrinter.targetPrinter.noPrinters')}\n                </p>\n              )}\n            </div>\n          )}\n\n          {/* Remote Interface - available for all modes (IP override / SSDP proxy) */}\n          {localEnabled && (\n            <div className=\"py-3 border-t border-bambu-dark-tertiary\">\n              <div className=\"text-white font-medium mb-2\">{t('virtualPrinter.remoteInterface.title')}</div>\n              <div className=\"text-sm text-bambu-gray mb-3\">\n                {localRemoteInterfaceIp ? (\n                  <span className=\"flex items-center gap-1 text-green-400\">\n                    <Check className=\"w-4 h-4\" />\n                    {t('virtualPrinter.remoteInterface.configured')}\n                  </span>\n                ) : (\n                  <span className=\"flex items-center gap-1 text-bambu-gray\">\n                    <Info className=\"w-4 h-4\" />\n                    {t('virtualPrinter.remoteInterface.optional')}\n                  </span>\n                )}\n              </div>\n              <div className=\"relative\">\n                <select\n                  value={localRemoteInterfaceIp}\n                  onChange={(e) => handleRemoteInterfaceChange(e.target.value)}\n                  disabled={pendingAction === 'remoteInterface'}\n                  className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10\"\n                >\n                  <option value=\"\">{t('virtualPrinter.remoteInterface.placeholder')}</option>\n                  {networkInterfaces?.map((iface) => (\n                    <option key={iface.ip} value={iface.ip}>\n                      {iface.name} ({iface.ip}) - {iface.subnet}\n                    </option>\n                  ))}\n                </select>\n                <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n              </div>\n              <p className=\"text-xs text-bambu-gray mt-2\">\n                {t('virtualPrinter.remoteInterface.hint')}\n              </p>\n            </div>\n          )}\n\n          {/* Mode */}\n          <div className=\"py-3 border-t border-bambu-dark-tertiary\">\n            <div className=\"text-white font-medium mb-2\">{t('virtualPrinter.mode.title')}</div>\n            <div className=\"grid grid-cols-2 gap-3\">\n              <button\n                onClick={() => handleModeChange('immediate')}\n                disabled={pendingAction === 'mode'}\n                className={`p-3 rounded-lg border text-left transition-colors ${\n                  localMode === 'immediate'\n                    ? 'border-bambu-green bg-bambu-green/10'\n                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n                }`}\n              >\n                <div className=\"text-white font-medium\">{t('virtualPrinter.mode.archive')}</div>\n                <div className=\"text-xs text-bambu-gray\">{t('virtualPrinter.mode.archiveDesc')}</div>\n              </button>\n              <button\n                onClick={() => handleModeChange('review')}\n                disabled={pendingAction === 'mode'}\n                className={`p-3 rounded-lg border text-left transition-colors ${\n                  localMode === 'review'\n                    ? 'border-bambu-green bg-bambu-green/10'\n                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n                }`}\n              >\n                <div className=\"text-white font-medium\">{t('virtualPrinter.mode.review')}</div>\n                <div className=\"text-xs text-bambu-gray\">{t('virtualPrinter.mode.reviewDesc')}</div>\n              </button>\n              <button\n                onClick={() => handleModeChange('print_queue')}\n                disabled={pendingAction === 'mode'}\n                className={`p-3 rounded-lg border text-left transition-colors ${\n                  localMode === 'print_queue'\n                    ? 'border-bambu-green bg-bambu-green/10'\n                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n                }`}\n              >\n                <div className=\"text-white font-medium\">{t('virtualPrinter.mode.queue')}</div>\n                <div className=\"text-xs text-bambu-gray\">{t('virtualPrinter.mode.queueDesc')}</div>\n              </button>\n              <button\n                onClick={() => handleModeChange('proxy')}\n                disabled={pendingAction === 'mode'}\n                className={`p-3 rounded-lg border text-left transition-colors ${\n                  localMode === 'proxy'\n                    ? 'border-blue-500 bg-blue-500/10'\n                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'\n                }`}\n              >\n                <div className=\"flex items-center gap-1.5 text-white font-medium\">\n                  <ArrowRightLeft className=\"w-4 h-4\" />\n                  {t('virtualPrinter.mode.proxy')}\n                </div>\n                <div className=\"text-xs text-bambu-gray\">{t('virtualPrinter.mode.proxyDesc')}</div>\n              </button>\n            </div>\n          </div>\n        </CardContent>\n      </Card>\n      </div>\n\n      {/* Right Column - Info & Status */}\n      <div className=\"space-y-6 lg:w-[480px] lg:flex-shrink-0\">\n        {/* Setup Required Warning */}\n        <Card className=\"border-l-4 border-l-yellow-500\">\n          <CardContent className=\"py-4\">\n            <div className=\"flex items-start gap-3\">\n              <AlertTriangle className=\"w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5\" />\n              <div className=\"text-sm\">\n                <p className=\"text-white font-medium mb-2\">\n                  {t('virtualPrinter.setupRequired.title')}\n                </p>\n                <p className=\"text-bambu-gray mb-3\">\n                  {t('virtualPrinter.setupRequired.description')}\n                </p>\n                <a\n                  href=\"https://wiki.bambuddy.cool/features/virtual-printer/\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"inline-flex items-center gap-2 px-4 py-2 bg-yellow-500/20 border border-yellow-500/50 rounded-md text-yellow-400 hover:bg-yellow-500/30 transition-colors\"\n                >\n                  <ExternalLink className=\"w-4 h-4\" />\n                  {t('virtualPrinter.setupRequired.readGuide')}\n                </a>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        {/* How it works */}\n        <Card>\n          <CardContent className=\"py-4\">\n            <div className=\"flex items-start gap-3\">\n              <Info className=\"w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5\" />\n              <div className=\"text-sm text-bambu-gray\">\n                <p className=\"mb-2\">\n                  <strong className=\"text-white\">{localMode === 'proxy' ? t('virtualPrinter.howItWorks.titleProxy') : t('virtualPrinter.howItWorks.title')}:</strong>\n                </p>\n                {localMode === 'proxy' ? (\n                  <ol className=\"list-decimal list-inside space-y-1\">\n                    <li>{t('virtualPrinter.howItWorks.proxyStep1')}</li>\n                    <li>{t('virtualPrinter.howItWorks.proxyStep2')}</li>\n                    <li>{t('virtualPrinter.howItWorks.proxyStep3')}</li>\n                    <li>{t('virtualPrinter.howItWorks.proxyStep4')}</li>\n                    <li>{t('virtualPrinter.howItWorks.proxyStep5')}</li>\n                  </ol>\n                ) : (\n                  <ol className=\"list-decimal list-inside space-y-1\">\n                    <li>{t('virtualPrinter.howItWorks.step1')}</li>\n                    <li>{t('virtualPrinter.howItWorks.step2')}</li>\n                    <li>{t('virtualPrinter.howItWorks.step3')}</li>\n                    <li>{t('virtualPrinter.howItWorks.step4')}</li>\n                    <li>{t('virtualPrinter.howItWorks.step5')}</li>\n                    <li>{t('virtualPrinter.howItWorks.step6')}</li>\n                  </ol>\n                )}\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n\n        {/* Status Details (when running) */}\n        {status && isRunning && (\n          <Card>\n            <CardHeader>\n              <h3 className=\"text-md font-semibold text-white\">{t('virtualPrinter.status.title')}</h3>\n            </CardHeader>\n            <CardContent>\n              {status.mode === 'proxy' && status.proxy ? (\n                <div className=\"grid grid-cols-2 gap-4 text-sm\">\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.targetPrinter')}</div>\n                    <div className=\"text-white\">\n                      {printers?.find(p => p.id === localTargetPrinterId)?.name || status.proxy.target_host}\n                    </div>\n                    <div className=\"text-xs text-bambu-gray font-mono\">{status.proxy.target_host}</div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.mode')}</div>\n                    <div className=\"text-white flex items-center gap-1.5\">\n                      <ArrowRightLeft className=\"w-4 h-4\" />\n                      {t('virtualPrinter.mode.proxy')}\n                    </div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.ftpPort')}</div>\n                    <div className=\"text-white font-mono\">{status.proxy.ftp_port}</div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.mqttPort')}</div>\n                    <div className=\"text-white font-mono\">{status.proxy.mqtt_port}</div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.ftpConnections')}</div>\n                    <div className=\"text-white\">{status.proxy.ftp_connections ?? 0}</div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.mqttConnections')}</div>\n                    <div className=\"text-white\">{status.proxy.mqtt_connections ?? 0}</div>\n                  </div>\n                </div>\n              ) : (\n                <div className=\"grid grid-cols-2 gap-4 text-sm\">\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.printerName')}</div>\n                    <div className=\"text-white\">{status.name}</div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.model')}</div>\n                    <div className=\"text-white\">{status.model_name || status.model}</div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.serialNumber')}</div>\n                    <div className=\"text-white font-mono\">{status.serial}</div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.mode')}</div>\n                    <div className=\"text-white capitalize\">{status.mode}</div>\n                  </div>\n                  <div>\n                    <div className=\"text-bambu-gray\">{t('virtualPrinter.status.pendingFiles')}</div>\n                    <div className=\"text-white\">{status.pending_files}</div>\n                  </div>\n                </div>\n              )}\n            </CardContent>\n          </Card>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/icons/ChamberLight.tsx",
    "content": "interface ChamberLightProps {\n  on: boolean;\n  className?: string;\n}\n\n/**\n * Chamber light icon with on/off states.\n * Modern bulb design with radiating rays.\n * - On: Filled yellow bulb with visible rays\n * - Off: Outline only, muted color\n */\nexport function ChamberLight({ on, className = \"w-5 h-5\" }: ChamberLightProps) {\n  const bulbFill = on ? \"#facc15\" : \"none\"; // yellow-400 when on\n  const strokeColor = on ? \"#78350f\" : \"currentColor\"; // amber-900 when on\n  const rayOpacity = on ? 1 : 0;\n\n  return (\n    <svg\n      viewBox=\"0 0 32 32\"\n      fill=\"none\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      {/* Radiating rays */}\n      <g stroke={strokeColor} opacity={rayOpacity}>\n        <line x1=\"16\" y1=\"2\" x2=\"16\" y2=\"6\" />\n        <line x1=\"6.1\" y1=\"6.1\" x2=\"8.9\" y2=\"8.9\" />\n        <line x1=\"25.9\" y1=\"6.1\" x2=\"23.1\" y2=\"8.9\" />\n        <line x1=\"2\" y1=\"16\" x2=\"6\" y2=\"16\" />\n        <line x1=\"30\" y1=\"16\" x2=\"26\" y2=\"16\" />\n      </g>\n\n      {/* Bulb glass - smooth rounded shape */}\n      <path\n        d=\"M12 24v-2.3c0-.9-.4-1.7-1-2.3C9.2 17.6 8 15.4 8 13c0-4.4 3.6-8 8-8s8 3.6 8 8c0 2.4-1.2 4.6-3 6.4-.6.6-1 1.4-1 2.3V24\"\n        fill={bulbFill}\n        stroke={strokeColor}\n      />\n\n      {/* Base rings */}\n      <path d=\"M12 24h8\" stroke={strokeColor} />\n      <path d=\"M12 27h8\" stroke={strokeColor} />\n      <path d=\"M13 30h6\" stroke={strokeColor} />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/icons/PlateClearedIcon.tsx",
    "content": "interface PlateClearedIconProps {\n  className?: string;\n}\n\nexport function PlateClearedIcon({ className = \"w-4 h-4\" }: PlateClearedIconProps) {\n  return (\n    <svg\n      viewBox=\"0 0 1945 1370\"\n      fill=\"none\"\n      className={className}\n      aria-hidden=\"true\"\n    >\n      <g transform=\"translate(-754.293 -471.685)\">\n        <g transform=\"translate(0.18191 255.976)\">\n          <g transform=\"matrix(1.05469 0 0 0.241063 -153.484 1120.2)\">\n            <rect\n              x=\"922.048\"\n              y=\"1195.15\"\n              width=\"1721.5\"\n              height=\"470.135\"\n              stroke=\"currentColor\"\n              strokeOpacity=\"0.99\"\n              strokeWidth=\"168.84\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </g>\n          <g transform=\"matrix(0.983656 0 0 1.0767 -62.2035 141.539)\">\n            <path\n              d=\"M2741.42,1175.93L895.832,1175.93L1125.16,621.902L2512.09,621.902L2741.42,1175.93Z\"\n              fill=\"currentColor\"\n              fillOpacity=\"0.05\"\n              stroke=\"currentColor\"\n              strokeOpacity=\"0.99\"\n              strokeWidth=\"125.26\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </g>\n        </g>\n        <g transform=\"translate(21.1916 0.684817)\">\n          <path\n            d=\"M1981.31,567.518C1954.86,567.518 1933.39,546.047 1933.39,519.601C1933.39,493.156 1954.86,471.685 1981.31,471.685L2146.61,471.685C2173.07,471.685 2194.53,493.138 2194.53,519.601L2194.53,688.741C2194.53,715.187 2173.05,736.658 2146.61,736.658C2120.16,736.658 2098.69,715.187 2098.69,688.741L2098.69,567.518L1981.31,567.518ZM2098.69,1252.54C2098.69,1226.1 2120.16,1204.62 2146.61,1204.62C2173.05,1204.62 2194.53,1226.1 2194.53,1252.54L2194.53,1421.68C2194.53,1448.14 2173.07,1469.6 2146.61,1469.6L1981.31,1469.6C1954.86,1469.6 1933.39,1448.13 1933.39,1421.68C1933.39,1395.24 1954.86,1373.76 1981.31,1373.76L2098.69,1373.76L2098.69,1252.54ZM1430.29,1373.76C1456.74,1373.76 1478.21,1395.24 1478.21,1421.68C1478.21,1448.13 1456.74,1469.6 1430.29,1469.6L1264.99,1469.6C1238.53,1469.6 1217.07,1448.14 1217.07,1421.68L1217.07,1252.54C1217.07,1226.1 1238.55,1204.62 1264.99,1204.62C1291.44,1204.62 1312.91,1226.1 1312.91,1252.54L1312.91,1373.76L1430.29,1373.76ZM1312.91,688.741C1312.91,715.187 1291.44,736.658 1264.99,736.658C1238.55,736.658 1217.07,715.187 1217.07,688.741L1217.07,519.601C1217.07,493.138 1238.53,471.685 1264.99,471.685L1430.29,471.685C1456.74,471.685 1478.21,493.156 1478.21,519.601C1478.21,546.047 1456.74,567.518 1430.29,567.518L1312.91,567.518L1312.91,688.741Z\"\n            fill=\"currentColor\"\n            fillOpacity=\"0.99\"\n          />\n        </g>\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/icons/WifiSignal.tsx",
    "content": "interface WifiSignalProps {\n  signal: number | null | undefined;  // dBm value\n  className?: string;\n}\n\n/**\n * WiFi signal icon with 4 bars that fill based on signal strength.\n * - 4 bars: >= -50 dBm (excellent)\n * - 3 bars: >= -60 dBm (good)\n * - 2 bars: >= -70 dBm (fair)\n * - 1 bar:  < -70 dBm (weak)\n * - 0 bars: no signal data\n */\nexport function WifiSignal({ signal, className = \"w-4 h-4\" }: WifiSignalProps) {\n  let bars = 0;\n  if (signal != null) {\n    if (signal >= -50) bars = 4;\n    else if (signal >= -60) bars = 3;\n    else if (signal >= -70) bars = 2;\n    else bars = 1;\n  }\n\n  const activeColor = \"#00ae42\";  // bambu-green\n  const inactiveColor = \"#4a4a4a\";  // dark gray\n\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      {/* Dot at bottom */}\n      <circle\n        cx=\"12\"\n        cy=\"20\"\n        r=\"1\"\n        fill={bars >= 1 ? activeColor : inactiveColor}\n        stroke={bars >= 1 ? activeColor : inactiveColor}\n      />\n      {/* First arc (smallest) */}\n      <path\n        d=\"M8.5 16.5a5 5 0 0 1 7 0\"\n        stroke={bars >= 2 ? activeColor : inactiveColor}\n        fill=\"none\"\n      />\n      {/* Second arc */}\n      <path\n        d=\"M5 13a10 10 0 0 1 14 0\"\n        stroke={bars >= 3 ? activeColor : inactiveColor}\n        fill=\"none\"\n      />\n      {/* Third arc (largest) */}\n      <path\n        d=\"M1.5 9.5a15 15 0 0 1 21 0\"\n        stroke={bars >= 4 ? activeColor : inactiveColor}\n        fill=\"none\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spool-form/AdditionalSection.tsx",
    "content": "import { useState, useRef, useEffect, useMemo } from 'react';\nimport { Scale } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { useToast } from '../../contexts/ToastContext';\nimport type { AdditionalSectionProps } from './types';\n\nfunction SpoolWeightPicker({\n  catalog,\n  value,\n  onChange,\n  catalogId,\n  onCatalogIdChange,\n}: {\n  catalog: { id: number; name: string; weight: number }[];\n  value: number;\n  onChange: (weight: number) => void;\n  catalogId: number | null;\n  onCatalogIdChange: (id: number | null) => void;\n}) {\n  const { t } = useTranslation();\n  const [isOpen, setIsOpen] = useState(false);\n  const [search, setSearch] = useState('');\n  const dropdownRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    const handleClick = (e: MouseEvent) => {\n      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {\n        setIsOpen(false);\n      }\n    };\n    document.addEventListener('mousedown', handleClick);\n    return () => document.removeEventListener('mousedown', handleClick);\n  }, []);\n\n  // When value changes, auto-select if there's only one matching entry or keep selection if it still matches\n  useEffect(() => {\n    // If no catalog loaded yet, skip matching logic\n    if (catalog.length === 0) {\n      return;\n    }\n\n    const matches = catalog.filter(e => e.weight === value);\n\n    // If currently selected entry still matches the weight, keep it selected\n    if (catalogId) {\n      const selected = catalog.find(e => e.id === catalogId);\n      if (selected && selected.weight === value) {\n        return; // Keep current selection\n      }\n    }\n\n    // If exactly one match, auto-select it\n    if (matches.length === 1) {\n      onCatalogIdChange(matches[0].id);\n    } else if (matches.length === 0) {\n      // No matches, clear selection to prevent stale catalog ID\n      if (catalogId !== null) {\n        onCatalogIdChange(null);\n      }\n    }\n    // If multiple matches, don't auto-select - let user choose\n  }, [value, catalog, catalogId, onCatalogIdChange]);\n\n  const filtered = useMemo(() => {\n    if (!search) return catalog;\n    const s = search.toLowerCase();\n    return catalog.filter(e =>\n      e.name.toLowerCase().includes(s) ||\n      e.weight.toString().includes(s),\n    );\n  }, [catalog, search]);\n\n  // Find all entries matching the current weight\n  const matchingEntries = useMemo(() => {\n    return catalog.filter(e => e.weight === value);\n  }, [catalog, value]);\n\n  // Display value: show catalog name if selected by ID, otherwise show first match\n  const displayValue = useMemo(() => {\n    if (isOpen) return search;\n\n    // If a catalog ID is explicitly selected, use that\n    if (catalogId) {\n      const entry = catalog.find(e => e.id === catalogId);\n      if (entry) return entry.name;\n    }\n\n    // Otherwise, show the first matching entry as a suggestion\n    if (matchingEntries.length > 0) {\n      return matchingEntries[0].name;\n    }\n\n    // Leave empty if there are no matches\n    return '';\n  }, [isOpen, search, catalogId, catalog, matchingEntries]);\n\n  return (\n    <div>\n      <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n        <span className=\"flex items-center gap-2\">\n          <Scale className=\"w-3.5 h-3.5 text-bambu-gray\" />\n          {t('inventory.coreWeight')}\n        </span>\n      </label>\n      <div className=\"flex gap-2 items-center\">\n        <div className=\"flex-1 min-w-0 relative\" ref={dropdownRef}>\n          <input\n            ref={inputRef}\n            type=\"text\"\n            className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n            placeholder={t('inventory.searchSpoolWeight')}\n            value={displayValue}\n            onFocus={() => {\n              setIsOpen(true);\n              setSearch('');\n            }}\n            onChange={(e) => {\n              setSearch(e.target.value);\n              setIsOpen(true);\n            }}\n          />\n          {isOpen && (\n            <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto\">\n              {filtered.length === 0 ? (\n                <div className=\"px-3 py-2 text-sm text-bambu-gray\">{t('inventory.noResults')}</div>\n              ) : (\n                filtered.map(entry => (\n                  <button\n                    key={entry.id}\n                    type=\"button\"\n                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${\n                      (catalogId ? entry.id === catalogId : entry.weight === value)\n                        ? 'bg-bambu-green/10 text-bambu-green'\n                        : 'text-white'\n                    }`}\n                    onClick={() => {\n                      onCatalogIdChange(entry.id);\n                      onChange(entry.weight);\n                      setIsOpen(false);\n                      setSearch('');\n                    }}\n                  >\n                    <span className=\"truncate\">{entry.name}</span>\n                    <span className=\"font-mono text-xs text-bambu-gray ml-2 shrink-0\">{entry.weight}g</span>\n                  </button>\n                ))\n              )}\n            </div>\n          )}\n        </div>\n        <div className=\"flex items-center gap-1 shrink-0\">\n          <input\n            type=\"number\"\n            className=\"w-16 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm text-center font-mono focus:outline-none focus:border-bambu-green\"\n            value={value}\n            min={0}\n            max={2000}\n            onChange={(e) => {\n              const val = parseInt(e.target.value);\n              if (!isNaN(val) && val >= 0) onChange(val);\n            }}\n          />\n          <span className=\"text-bambu-gray text-sm\">g</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function AdditionalSection({\n  formData,\n  updateField,\n  spoolCatalog,\n  currencySymbol,\n}: AdditionalSectionProps) {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const [measuredInput, setMeasuredInput] = useState('');\n  const [isMeasuredFocused, setIsMeasuredFocused] = useState(false);\n  const [remainingInput, setRemainingInput] = useState('');\n  const [isRemainingFocused, setIsRemainingFocused] = useState(false);\n\n  const remainingWeight = Math.max(0, formData.label_weight - formData.weight_used);\n  const measuredDefault = formData.core_weight + remainingWeight;\n\n  useEffect(() => {\n    if (!isMeasuredFocused) {\n      setMeasuredInput(String(measuredDefault));\n    }\n  }, [isMeasuredFocused, measuredDefault]);\n\n  useEffect(() => {\n    if (!isRemainingFocused) {\n      setRemainingInput(String(remainingWeight));\n    }\n  }, [isRemainingFocused, remainingWeight]);\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Empty Spool Weight */}\n      <SpoolWeightPicker\n        catalog={spoolCatalog}\n        value={formData.core_weight}\n        onChange={(weight) => updateField('core_weight', weight)}\n        catalogId={formData.core_weight_catalog_id}\n        onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}\n      />\n\n      {/* Current Weight (remaining filament) */}\n      <div>\n        <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.currentWeight')}</label>\n        <div className=\"flex items-center gap-2\">\n          <div className=\"relative flex-1\">\n            <input\n              type=\"number\"\n              value={remainingInput}\n              min={0}\n              max={formData.label_weight}\n              onFocus={() => setIsRemainingFocused(true)}\n              onChange={(e) => {\n                setRemainingInput(e.target.value);\n              }}\n              onBlur={() => {\n                setIsRemainingFocused(false);\n                const raw = remainingInput.trim();\n                const remaining = Number(raw);\n                if (!raw || !Number.isFinite(remaining) || remaining < 0 || remaining > formData.label_weight) {\n                  setRemainingInput(String(remainingWeight));\n                  return;\n                }\n                const rounded = Math.round(remaining);\n                updateField('weight_used', Math.max(0, formData.label_weight - rounded));\n                setRemainingInput(String(rounded));\n              }}\n              className=\"w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n            />\n            <span className=\"absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray\">g</span>\n          </div>\n          <span className=\"text-xs text-bambu-gray shrink-0\">/ {formData.label_weight}g</span>\n        </div>\n      </div>\n\n      {/* Measured Weight (empty spool + remaining filament) */}\n      <div>\n        <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.measuredWeight')}</label>\n        <div className=\"flex items-center gap-2\">\n          <div className=\"relative flex-1\">\n            <input\n              type=\"number\"\n              value={measuredInput}\n              min={0}\n              onFocus={() => setIsMeasuredFocused(true)}\n              onChange={(e) => {\n                setMeasuredInput(e.target.value);\n              }}\n              onBlur={() => {\n                setIsMeasuredFocused(false);\n                const raw = measuredInput.trim();\n                const measured = Number(raw);\n                const minAllowed = formData.core_weight;\n                const maxAllowed = formData.core_weight + formData.label_weight;\n\n                if (!raw || !Number.isFinite(measured) || measured < minAllowed || measured > maxAllowed) {\n                  showToast(t('inventory.measuredWeightError', { min: minAllowed, max: maxAllowed }), 'error');\n                  setMeasuredInput(String(measuredDefault));\n                  return;\n                }\n\n                const rounded = Math.round(measured);\n                const remaining = Math.max(0, Math.min(formData.label_weight, rounded - formData.core_weight));\n                updateField('weight_used', Math.max(0, formData.label_weight - remaining));\n                setMeasuredInput(String(rounded));\n              }}\n              className=\"w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n            />\n            <span className=\"absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray\">g</span>\n          </div>\n          <span className=\"text-xs text-bambu-gray shrink-0\">/ {formData.core_weight + formData.label_weight}g</span>\n        </div>\n      </div>\n\n      {/* Cost per kg */}\n      <div>\n        <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.costPerKg', 'Cost per kg')}</label>\n        <div className=\"flex items-center gap-2\">\n          <div className=\"relative flex-1\">\n            <span className=\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none\">{currencySymbol}</span>\n            <input\n              type=\"number\"\n              value={formData.cost_per_kg ?? ''}\n              min={0}\n              step={0.01}\n              placeholder=\"0.00\"\n              onChange={(e) => {\n                const value = e.target.value === '' ? null : parseFloat(e.target.value);\n                updateField('cost_per_kg', value);\n              }}\n              style={{ paddingLeft: `${Math.max(2, currencySymbol.length * 0.6 + 1)}rem` }}\n              className=\"w-full py-2 pr-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n            />\n          </div>\n        </div>\n      </div>\n\n      {/* Note */}\n      <div>\n        <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.note')}</label>\n        <textarea\n          className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]\"\n          placeholder={t('inventory.notePlaceholder')}\n          value={formData.note}\n          onChange={(e) => updateField('note', e.target.value)}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spool-form/ColorSection.tsx",
    "content": "import { useState, useMemo } from 'react';\nimport { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { ColorSectionProps, CatalogDisplayColor } from './types';\nimport { QUICK_COLORS, ALL_COLORS } from './constants';\n\nexport function ColorSection({\n  formData,\n  updateField,\n  recentColors,\n  onColorUsed,\n  catalogColors,\n}: ColorSectionProps) {\n  const { t } = useTranslation();\n  const [showAllColors, setShowAllColors] = useState(false);\n  const [colorSearch, setColorSearch] = useState('');\n\n  // Current hex without # prefix\n  const currentHex = formData.rgba.replace('#', '').substring(0, 6);\n\n  const isSelected = (hex: string) => {\n    return currentHex.toUpperCase() === hex.toUpperCase();\n  };\n\n  const selectColor = (hex: string, name: string) => {\n    // Store as RRGGBBAA (with FF alpha)\n    updateField('rgba', hex.toUpperCase() + 'FF');\n    updateField('color_name', name);\n    onColorUsed({ name, hex });\n  };\n\n  // Filter catalog colors by the selected brand + material + subtype\n  // Brand matching is word-based: \"mz - Bambu\" matches \"Bambu Lab\" because both contain \"Bambu\"\n  // Material matching: try exact \"PETG Basic\" first, fall back to base material \"PETG\" prefix\n  const matchedCatalogColors = useMemo<CatalogDisplayColor[]>(() => {\n    if (catalogColors.length === 0) return [];\n    const brand = formData.brand?.trim();\n    const material = formData.material?.toLowerCase().trim();\n    const subtype = formData.subtype?.toLowerCase().trim();\n    if (!brand && !material) return [];\n\n    // Split brand into words (>= 2 chars) for word-based matching\n    const brandWords = brand\n      ? brand.toLowerCase().split(/[\\s\\-_]+/).filter(w => w.length >= 2)\n      : [];\n\n    const brandMatches = (manufacturer: string) => {\n      if (brandWords.length === 0) return true; // no brand filter\n      const mfrLower = manufacturer.toLowerCase();\n      // Any significant brand word found in manufacturer name\n      return brandWords.some(w => mfrLower.includes(w));\n    };\n\n    // If only brand is provided, return all colors for that manufacturer\n    if (brand && !material) {\n      const byBrand = catalogColors.filter(c => brandMatches(c.manufacturer));\n      if (byBrand.length > 0) {\n        return byBrand.map(c => ({\n          name: c.color_name,\n          hex: c.hex_color.replace('#', '').substring(0, 6),\n          manufacturer: c.manufacturer,\n          material: typeof c.material === 'string' ? c.material : undefined,\n        }));\n      }\n    }\n\n    // Build the combined material+subtype string to match catalog entries\n    const fullMaterial = material && subtype ? `${material} ${subtype}` : '';\n\n    // First pass: try exact fullMaterial match (e.g. \"PETG Basic\")\n    if (fullMaterial) {\n      const exact = catalogColors.filter(c =>\n        brandMatches(c.manufacturer) &&\n        c.material?.toLowerCase() === fullMaterial,\n      );\n      if (exact.length > 0) {\n        return exact.map(c => ({\n          name: c.color_name,\n          hex: c.hex_color.replace('#', '').substring(0, 6),\n          manufacturer: c.manufacturer,\n          material: typeof c.material === 'string' ? c.material : undefined,\n        }));\n      }\n      // Try without trailing \"+\" (e.g. \"PLA Silk+\" -> \"PLA Silk\")\n      const normalized = fullMaterial.replace(/\\+$/, '');\n      if (normalized !== fullMaterial) {\n        const normMatch = catalogColors.filter(c =>\n          brandMatches(c.manufacturer) &&\n          c.material?.toLowerCase() === normalized,\n        );\n        if (normMatch.length > 0) {\n          return normMatch.map(c => ({\n            name: c.color_name,\n            hex: c.hex_color.replace('#', '').substring(0, 6),\n            manufacturer: c.manufacturer,\n            material: typeof c.material === 'string' ? c.material : undefined,\n          }));\n        }\n      }\n    }\n\n    // Second pass: match base material prefix (e.g. \"PETG\" matches \"PETG Basic\", \"PETG-HF\")\n    if (material) {\n      const byMaterial = catalogColors.filter(c =>\n        brandMatches(c.manufacturer) &&\n        (!c.material || c.material.toLowerCase().startsWith(material)),\n      );\n      if (byMaterial.length > 0) {\n        return byMaterial.map(c => ({\n          name: c.color_name,\n          hex: c.hex_color.replace('#', '').substring(0, 6),\n          manufacturer: c.manufacturer,\n          material: typeof c.material === 'string' ? c.material : undefined,\n        }));\n      }\n    }\n\n    return [];\n  }, [catalogColors, formData.brand, formData.material, formData.subtype]);\n\n  const catalogSearchResults = useMemo<CatalogDisplayColor[]>(() => {\n    if (!colorSearch) return matchedCatalogColors;\n    if (matchedCatalogColors.length === 0) return [];\n    const q = colorSearch.toLowerCase();\n    const matches = matchedCatalogColors.filter(c =>\n      c.name.toLowerCase().includes(q) ||\n      (c.manufacturer?.toLowerCase().includes(q) ?? false) ||\n      (c.material?.toLowerCase().includes(q) ?? false),\n    );\n    return matches;\n  }, [colorSearch, matchedCatalogColors]);\n\n  // Only show catalog section if there are matched catalog colors\n  const showCatalogSection = matchedCatalogColors.length > 0;\n\n  // Fallback hardcoded colors for search/expand\n  const filteredFallbackColors = useMemo(() => {\n    if (colorSearch) {\n      return ALL_COLORS.filter(c =>\n        c.name.toLowerCase().includes(colorSearch.toLowerCase()),\n      );\n    }\n    return showAllColors ? ALL_COLORS : QUICK_COLORS;\n  }, [colorSearch, showAllColors]);\n\n  return (\n    <div className=\"space-y-3\">\n      {/* Color preview banner */}\n      <div\n        className=\"h-10 rounded-lg border border-bambu-dark-tertiary\"\n        style={{ backgroundColor: `#${currentHex}` }}\n      />\n\n      {/* Recently Used Colors */}\n      {recentColors.length > 0 && (\n        <div className=\"flex items-center gap-2\">\n          <div className=\"flex items-center gap-1.5 text-xs text-bambu-gray shrink-0\">\n            <Clock className=\"w-3 h-3\" />\n            <span>{t('inventory.recentColors')}</span>\n          </div>\n          <div className=\"flex flex-wrap gap-1.5\">\n            {recentColors.map(color => (\n              <button\n                key={color.hex}\n                type=\"button\"\n                onClick={() => selectColor(color.hex, color.name)}\n                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 ${\n                  isSelected(color.hex)\n                    ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'\n                    : 'border-bambu-dark-tertiary'\n                }`}\n                style={{ backgroundColor: `#${color.hex}` }}\n                title={color.name}\n              />\n            ))}\n          </div>\n        </div>\n      )}\n\n      {/* Color Search */}\n      <div className=\"relative\">\n        <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\" />\n        <input\n          type=\"text\"\n          className=\"w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n          placeholder={t('inventory.searchColors')}\n          value={colorSearch}\n          onChange={(e) => setColorSearch(e.target.value)}\n        />\n      </div>\n\n      {/* Color Swatches */}\n      {showCatalogSection ? (\n        /* Catalog colors matching selected brand/material */\n        <div className=\"space-y-1.5\">\n          <span className=\"text-xs text-bambu-gray\">\n            {colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}\n          </span>\n          <div className=\"flex flex-wrap gap-1.5\">\n            {catalogSearchResults.map(color => (\n              <button\n                key={`${color.hex}-${color.name}-${color.manufacturer ?? ''}`}\n                type=\"button\"\n                onClick={() => selectColor(color.hex, color.name)}\n                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${\n                  isSelected(color.hex)\n                    ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'\n                    : 'border-bambu-dark-tertiary'\n                }`}\n                style={{ backgroundColor: `#${color.hex}` }}\n                title={\n                  color.manufacturer && color.material\n                    ? `${color.name} (${color.manufacturer} — ${color.material})`\n                    : color.manufacturer\n                    ? `${color.name} (${color.manufacturer})`\n                    : color.name\n                }\n              >\n                <span className=\"absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white\">\n                  {color.manufacturer && color.material\n                    ? `${color.name} (${color.manufacturer} — ${color.material})`\n                    : color.manufacturer\n                    ? `${color.name} (${color.manufacturer})`\n                    : color.name}\n                </span>\n              </button>\n            ))}\n            {catalogSearchResults.length === 0 && (\n              <p className=\"text-sm text-bambu-gray py-1\">{t('inventory.noColorsFound')}</p>\n            )}\n          </div>\n        </div>\n      ) : (\n        /* Fallback: hardcoded color palette (no brand/material selected or no catalog matches) */\n        <div className=\"space-y-1.5\">\n          <div className=\"flex items-center justify-between text-xs text-bambu-gray\">\n            <span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>\n            {!colorSearch && (\n              <button\n                type=\"button\"\n                onClick={() => setShowAllColors(!showAllColors)}\n                className=\"flex items-center gap-1 hover:text-white transition-colors\"\n              >\n                {showAllColors ? (\n                  <>{t('inventory.showLess')} <ChevronUp className=\"w-3 h-3\" /></>\n                ) : (\n                  <>{t('inventory.showAll')} <ChevronDown className=\"w-3 h-3\" /></>\n                )}\n              </button>\n            )}\n          </div>\n          <div className=\"flex flex-wrap gap-1.5\">\n            {filteredFallbackColors.map(color => (\n              <button\n                key={color.hex}\n                type=\"button\"\n                onClick={() => selectColor(color.hex, color.name)}\n                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${\n                  isSelected(color.hex)\n                    ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'\n                    : 'border-bambu-dark-tertiary'\n                }`}\n                style={{ backgroundColor: `#${color.hex}` }}\n                title={color.name}\n              >\n                <span className=\"absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white\">\n                  {color.name}\n                </span>\n              </button>\n            ))}\n            {filteredFallbackColors.length === 0 && (\n              <p className=\"text-sm text-bambu-gray py-1\">{t('inventory.noColorsFound')}</p>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* Manual Color Input */}\n      <div className=\"grid grid-cols-2 gap-3\">\n        <div>\n          <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.colorName')}</label>\n          <input\n            type=\"text\"\n            className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n            placeholder={t('inventory.colorNamePlaceholder')}\n            value={formData.color_name}\n            onChange={(e) => updateField('color_name', e.target.value)}\n          />\n        </div>\n        <div>\n          <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.hexColor')}</label>\n          <div className=\"flex gap-2\">\n            <div className=\"relative flex-1\">\n              <span className=\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray\">#</span>\n              <input\n                type=\"text\"\n                className=\"w-full pl-7 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono uppercase focus:outline-none focus:border-bambu-green\"\n                placeholder=\"RRGGBB\"\n                value={currentHex.toUpperCase()}\n                onChange={(e) => {\n                  const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '').toUpperCase();\n                  if (val.length > 8) return;\n                  // Normalize to a valid 8-char RRGGBBAA on every keystroke so\n                  // the backend never receives a malformed rgba (#1055). 8-char\n                  // paste passes through; 7-char drops the stray typo; anything\n                  // shorter is right-padded with '0' to a full RGB triplet and\n                  // given FF alpha. Prior logic emitted 3/5/7-char strings mid-\n                  // typing that PATCH would accept (SpoolUpdate was unchecked)\n                  // and later 500 the list endpoint on response serialization.\n                  const rgba =\n                    val.length === 8 ? val : val.length === 7 ? val.substring(0, 6) + 'FF' : val.padEnd(6, '0') + 'FF';\n                  updateField('rgba', rgba);\n                }}\n              />\n            </div>\n            <input\n              type=\"color\"\n              className=\"w-11 h-[38px] rounded-lg cursor-pointer border border-bambu-dark-tertiary shrink-0 bg-transparent\"\n              value={`#${currentHex}`}\n              onChange={(e) => {\n                const hex = e.target.value.replace('#', '').toUpperCase();\n                updateField('rgba', hex + 'FF');\n              }}\n              title={t('inventory.pickColor')}\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spool-form/FilamentSection.tsx",
    "content": "import { useState, useRef, useEffect, useMemo } from 'react';\nimport { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { FilamentSectionProps, FilamentOption } from './types';\nimport { KNOWN_VARIANTS } from './constants';\nimport { parsePresetName } from './utils';\n\nexport function FilamentSection({\n  formData,\n  updateField,\n  cloudAuthenticated,\n  loadingCloudPresets,\n  presetInputValue,\n  setPresetInputValue,\n  selectedPresetOption,\n  filamentOptions,\n  availableBrands,\n  availableMaterials,\n  quickAdd,\n  quantity,\n  onQuantityChange,\n  errors,\n}: FilamentSectionProps) {\n  const { t } = useTranslation();\n  const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);\n  const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);\n  const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);\n  const [materialDropdownOpen, setMaterialDropdownOpen] = useState(false);\n  const [brandSearch, setBrandSearch] = useState('');\n  const [subtypeSearch, setSubtypeSearch] = useState('');\n  const [materialSearch, setMaterialSearch] = useState('');\n  const [labelInput, setLabelInput] = useState(String(formData.label_weight));\n  const [isLabelFocused, setIsLabelFocused] = useState(false);\n  const presetRef = useRef<HTMLDivElement>(null);\n  const brandRef = useRef<HTMLDivElement>(null);\n  const subtypeRef = useRef<HTMLDivElement>(null);\n  const materialRef = useRef<HTMLDivElement>(null);\n\n  // Close dropdowns on outside click\n  useEffect(() => {\n    const handleClick = (e: MouseEvent) => {\n      if (presetRef.current && !presetRef.current.contains(e.target as Node)) {\n        setPresetDropdownOpen(false);\n      }\n      if (materialRef.current && !materialRef.current.contains(e.target as Node)) {\n        setMaterialDropdownOpen(false);\n      }\n      if (brandRef.current && !brandRef.current.contains(e.target as Node)) {\n        setBrandDropdownOpen(false);\n      }\n      if (subtypeRef.current && !subtypeRef.current.contains(e.target as Node)) {\n        setSubtypeDropdownOpen(false);\n      }\n    };\n    document.addEventListener('mousedown', handleClick);\n    return () => document.removeEventListener('mousedown', handleClick);\n  }, []);\n\n  // Filtered presets based on search\n  const filteredPresets = useMemo(() => {\n    if (!presetInputValue) return filamentOptions;\n    const search = presetInputValue.toLowerCase();\n    return filamentOptions.filter(o =>\n      o.displayName.toLowerCase().includes(search) ||\n      o.code.toLowerCase().includes(search),\n    );\n  }, [filamentOptions, presetInputValue]);\n\n  // Filtered brands\n  const filteredBrands = useMemo(() => {\n    if (!brandSearch) return availableBrands;\n    const search = brandSearch.toLowerCase();\n    const filtered = availableBrands.filter(b => b.toLowerCase().includes(search));\n    // Sort: exact match first, then others\n    return filtered.sort((a, b) => {\n      const aExact = a.toLowerCase() === search;\n      const bExact = b.toLowerCase() === search;\n      if (aExact && !bExact) return -1;\n      if (!aExact && bExact) return 1;\n      return a.localeCompare(b);\n    });\n  }, [availableBrands, brandSearch]);\n\n  const filteredVariants = useMemo(() => {\n    if (!subtypeSearch) return KNOWN_VARIANTS;\n    const search = subtypeSearch.toLowerCase();\n    return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search));\n  }, [subtypeSearch]);\n\n  const filteredMaterials = useMemo(() => {\n    if (!materialSearch) return availableMaterials;\n    const search = materialSearch.toLowerCase();\n    const filtered = availableMaterials.filter(m => m.toLowerCase().includes(search));\n    // Sort: exact match first, then others\n    return filtered.sort((a, b) => {\n      const aExact = a.toLowerCase() === search;\n      const bExact = b.toLowerCase() === search;\n      if (aExact && !bExact) return -1;\n      if (!aExact && bExact) return 1;\n      return a.localeCompare(b);\n    });\n  }, [materialSearch, availableMaterials]);\n\n  useEffect(() => {\n    if (!isLabelFocused) {\n      setLabelInput(String(formData.label_weight));\n    }\n  }, [formData.label_weight, isLabelFocused]);\n\n  // Handle preset selection\n  const handlePresetSelect = (option: FilamentOption) => {\n    updateField('slicer_filament', option.code);\n    setPresetInputValue(option.displayName);\n    setPresetDropdownOpen(false);\n\n    // Auto-fill material, brand, subtype from preset name\n    const parsed = parsePresetName(option.name);\n    if (parsed.material) updateField('material', parsed.material);\n    if (parsed.brand) updateField('brand', parsed.brand);\n    if (parsed.variant) updateField('subtype', parsed.variant);\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Cloud status indicator */}\n      {!quickAdd && (\n        <div className=\"flex items-center gap-2 text-xs text-bambu-gray\">\n          {loadingCloudPresets ? (\n            <><Loader2 className=\"w-3 h-3 animate-spin\" /> {t('inventory.loadingPresets')}</>\n          ) : cloudAuthenticated ? (\n            <><Cloud className=\"w-3 h-3 text-bambu-green\" /> {t('inventory.cloudConnected')}</>\n          ) : (\n            <><CloudOff className=\"w-3 h-3\" /> {t('inventory.cloudNotConnected')}</>\n          )}\n        </div>\n      )}\n\n      {/* Slicer Preset (autocomplete) — hidden in quick-add mode */}\n      {!quickAdd && (\n        <div>\n          <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n            {t('inventory.slicerPreset')} *\n          </label>\n          <div className=\"relative\" ref={presetRef}>\n            <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\" />\n            <input\n              type=\"text\"\n              className=\"w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n              placeholder={t('inventory.searchPresets')}\n              value={presetInputValue}\n              onChange={(e) => {\n                setPresetInputValue(e.target.value);\n                setPresetDropdownOpen(true);\n              }}\n              onFocus={() => {\n                setPresetDropdownOpen(true);\n                setPresetInputValue('');\n              }}\n            />\n            {presetDropdownOpen && (\n              <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto\">\n                {filteredPresets.length === 0 ? (\n                  <div className=\"px-3 py-2 text-sm text-bambu-gray\">{t('inventory.noPresetsFound')}</div>\n                ) : (\n                  filteredPresets.map(option => (\n                    <button\n                      key={option.code}\n                      type=\"button\"\n                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary truncate ${\n                        selectedPresetOption?.code === option.code\n                          ? 'bg-bambu-green/10 text-bambu-green'\n                          : 'text-white'\n                      }`}\n                      onClick={() => handlePresetSelect(option)}\n                    >\n                      {option.displayName}\n                    </button>\n                  ))\n                )}\n              </div>\n            )}\n          </div>\n          {selectedPresetOption && (\n            <div className=\"mt-1 text-xs text-bambu-gray\">\n              {t('inventory.selectedPreset')}: <span className=\"font-mono text-bambu-green\">{selectedPresetOption.code}</span>\n            </div>\n          )}\n          {errors?.slicer_filament && (\n            <p className=\"mt-1 text-xs text-red-400\">{errors.slicer_filament}</p>\n          )}\n        </div>\n      )}\n\n      {/* Material */}\n      <div>\n        <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.material')} *</label>\n        <div className=\"relative\" ref={materialRef}>\n          <input\n            type=\"text\"\n            className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n            placeholder={t('inventory.selectMaterial')}\n            value={materialDropdownOpen ? materialSearch : formData.material}\n            onChange={(e) => {\n              setMaterialSearch(e.target.value);\n              setMaterialDropdownOpen(true);\n            }}\n            onFocus={() => {\n              setMaterialDropdownOpen(true);\n              setMaterialSearch('');\n            }}\n          />\n          <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\" />\n          {materialDropdownOpen && (\n            <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\">\n              {filteredMaterials.length === 0 ? (\n                <div className=\"px-3 py-2 text-sm text-bambu-gray\">{t('inventory.noResults')}</div>\n              ) : (\n                filteredMaterials.map((material) => (\n                  <button\n                    key={material}\n                    type=\"button\"\n                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${\n                      formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'\n                    }`}\n                    onClick={() => {\n                      updateField('material', material);\n                      setMaterialDropdownOpen(false);\n                      setMaterialSearch('');\n                    }}\n                  >\n                    {material}\n                  </button>\n                ))\n              )}\n              {/* Allow custom material */}\n              {materialSearch && !filteredMaterials.includes(materialSearch) && (\n                <button\n                  type=\"button\"\n                  className=\"w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary\"\n                  onClick={() => {\n                    updateField('material', materialSearch);\n                    setMaterialDropdownOpen(false);\n                    setMaterialSearch('');\n                  }}\n                >\n                  {t('inventory.useCustomMaterial', { material: materialSearch })}\n                </button>\n              )}\n            </div>\n          )}\n        </div>\n        {errors?.material && (\n          <p className=\"mt-1 text-xs text-red-400\">{errors.material}</p>\n        )}\n      </div>\n\n      {/* Brand (dropdown with search) */}\n      <div>\n        <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n          {t('inventory.brand')}{!quickAdd && ' *'}\n        </label>\n          <div className=\"relative\" ref={brandRef}>\n            <input\n              type=\"text\"\n              className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n              placeholder={t('inventory.searchBrand')}\n              value={brandDropdownOpen ? brandSearch : formData.brand}\n              onChange={(e) => {\n                setBrandSearch(e.target.value);\n                setBrandDropdownOpen(true);\n              }}\n              onFocus={() => {\n                setBrandDropdownOpen(true);\n                setBrandSearch('');\n              }}\n            />\n            <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\" />\n            {brandDropdownOpen && (\n              <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\">\n                {filteredBrands.length === 0 ? (\n                  <div className=\"px-3 py-2 text-sm text-bambu-gray\">{t('inventory.noResults')}</div>\n                ) : (\n                  filteredBrands.map(brand => (\n                    <button\n                      key={brand}\n                      type=\"button\"\n                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${\n                        formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'\n                      }`}\n                      onClick={() => {\n                        updateField('brand', brand);\n                        setBrandDropdownOpen(false);\n                        setBrandSearch('');\n                      }}\n                    >\n                      {brand}\n                    </button>\n                  ))\n                )}\n                {/* Allow custom brand */}\n                {brandSearch && !filteredBrands.includes(brandSearch) && (\n                  <button\n                    type=\"button\"\n                    className=\"w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary\"\n                    onClick={() => {\n                      updateField('brand', brandSearch);\n                      setBrandDropdownOpen(false);\n                      setBrandSearch('');\n                    }}\n                  >\n                    {t('inventory.useCustomBrand', { brand: brandSearch })}\n                  </button>\n                )}\n              </div>\n            )}\n          </div>\n          {errors?.brand && (\n            <p className=\"mt-1 text-xs text-red-400\">{errors.brand}</p>\n          )}\n      </div>\n\n      {/* Variant / Subtype */}\n      <div>\n        <label className=\"block text-sm font-medium text-bambu-gray mb-1\">\n          {t('inventory.subtype')}{!quickAdd && ' *'}\n        </label>\n          <div className=\"relative\" ref={subtypeRef}>\n            <input\n              type=\"text\"\n              value={subtypeDropdownOpen ? subtypeSearch : formData.subtype}\n              onChange={(e) => {\n                setSubtypeSearch(e.target.value);\n                setSubtypeDropdownOpen(true);\n              }}\n              onFocus={() => {\n                setSubtypeDropdownOpen(true);\n                setSubtypeSearch('');\n              }}\n              placeholder=\"Basic, Matte, Silk...\"\n              className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n            />\n            <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\" />\n            {subtypeDropdownOpen && (\n              <div className=\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\">\n                {filteredVariants.length === 0 ? (\n                  <div className=\"px-3 py-2 text-sm text-bambu-gray\">{t('inventory.noResults')}</div>\n                ) : (\n                  filteredVariants.map(variant => (\n                    <button\n                      key={variant}\n                      type=\"button\"\n                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${\n                        formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'\n                      }`}\n                      onClick={() => {\n                        updateField('subtype', variant);\n                        setSubtypeDropdownOpen(false);\n                        setSubtypeSearch('');\n                      }}\n                    >\n                      {variant}\n                    </button>\n                  ))\n                )}\n                {subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && (\n                  <button\n                    type=\"button\"\n                    className=\"w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary\"\n                    onClick={() => {\n                      updateField('subtype', subtypeSearch);\n                      setSubtypeDropdownOpen(false);\n                      setSubtypeSearch('');\n                    }}\n                  >\n                    {t('inventory.useCustomBrand', { brand: subtypeSearch })}\n                  </button>\n                )}\n              </div>\n            )}\n          </div>\n          {errors?.subtype && (\n            <p className=\"mt-1 text-xs text-red-400\">{errors.subtype}</p>\n          )}\n      </div>\n\n      {/* Label Weight */}\n      <div>\n        <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.labelWeight')}</label>\n        <div className=\"relative\">\n          <input\n            type=\"number\"\n            className=\"w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n            value={labelInput}\n            min={0}\n            onFocus={() => setIsLabelFocused(true)}\n            onChange={(e) => setLabelInput(e.target.value)}\n            onBlur={() => {\n              setIsLabelFocused(false);\n              const raw = labelInput.trim();\n              const next = Number(raw);\n              if (!raw || !Number.isFinite(next) || next < 0) {\n                setLabelInput(String(formData.label_weight));\n                return;\n              }\n              const rounded = Math.round(next);\n              updateField('label_weight', rounded);\n              setLabelInput(String(rounded));\n            }}\n          />\n          <span className=\"absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray\">g</span>\n        </div>\n      </div>\n\n      {/* Quantity — only in quick-add mode */}\n      {quickAdd && (\n        <div>\n          <label className=\"block text-sm font-medium text-bambu-gray mb-1\">{t('inventory.quantity')}</label>\n          <input\n            type=\"number\"\n            className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n            value={quantity}\n            min={1}\n            max={100}\n            onChange={(e) => {\n              const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));\n              onQuantityChange(val);\n            }}\n          />\n        </div>\n      )}\n\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spool-form/PAProfileSection.tsx",
    "content": "import { ChevronDown, ChevronRight, Sparkles } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { CalibrationProfile, PAProfileSectionProps } from './types';\nimport { isMatchingCalibration } from './utils';\n\nexport function PAProfileSection({\n  formData,\n  printersWithCalibrations,\n  selectedProfiles,\n  setSelectedProfiles,\n  expandedPrinters,\n  setExpandedPrinters,\n}: PAProfileSectionProps) {\n  const { t } = useTranslation();\n\n  const togglePrinterExpanded = (printerId: string) => {\n    setExpandedPrinters((prev) => {\n      const next = new Set(prev);\n      if (next.has(printerId)) next.delete(printerId);\n      else next.add(printerId);\n      return next;\n    });\n  };\n\n  const toggleProfileSelected = (printerId: string, caliIdx: number, extruderId?: number | null) => {\n    const key = `${printerId}:${caliIdx}:${extruderId ?? 'null'}`;\n    const printerNozzleKey = `${printerId}:${extruderId ?? 'null'}`;\n\n    setSelectedProfiles((prev) => {\n      const next = new Set(prev);\n      if (next.has(key)) {\n        next.delete(key);\n      } else {\n        // Remove existing profile for same printer/nozzle\n        for (const existingKey of Array.from(next)) {\n          const parts = existingKey.split(':');\n          const existingPrinterNozzle = `${parts[0]}:${parts[2]}`;\n          if (existingPrinterNozzle === printerNozzleKey) {\n            next.delete(existingKey);\n          }\n        }\n        next.add(key);\n      }\n      return next;\n    });\n  };\n\n  // Auto-select best matching profiles\n  const autoSelectProfiles = () => {\n    const newSelection = new Set<string>();\n\n    for (const { printer, calibrations } of printersWithCalibrations) {\n      if (!printer.connected) continue;\n\n      const matchingCals = calibrations.filter(cal =>\n        isMatchingCalibration(cal, formData),\n      );\n\n      // Group by extruder\n      const byExtruder = new Map<string, CalibrationProfile[]>();\n      for (const cal of matchingCals) {\n        const extKey = `${cal.extruder_id ?? 'null'}`;\n        if (!byExtruder.has(extKey)) byExtruder.set(extKey, []);\n        byExtruder.get(extKey)!.push(cal);\n      }\n\n      // Select best (highest K) for each extruder\n      for (const [extKey, cals] of byExtruder) {\n        if (cals.length > 0) {\n          const sorted = [...cals].sort((a, b) => b.k_value - a.k_value);\n          const best = sorted[0];\n          newSelection.add(`${printer.id}:${best.cali_idx}:${extKey}`);\n        }\n      }\n    }\n\n    setSelectedProfiles(newSelection);\n  };\n\n  if (!formData.material) {\n    return (\n      <div className=\"p-6 bg-bambu-dark rounded-lg text-center\">\n        <p className=\"text-bambu-gray\">\n          {t('inventory.selectMaterialFirst')}\n        </p>\n      </div>\n    );\n  }\n\n  if (printersWithCalibrations.length === 0) {\n    return (\n      <div className=\"p-6 bg-bambu-dark rounded-lg text-center\">\n        <p className=\"text-bambu-gray\">\n          {t('inventory.noPrintersConfigured')}\n        </p>\n      </div>\n    );\n  }\n\n  // Count total matching profiles\n  const totalMatching = printersWithCalibrations.reduce((sum, { printer, calibrations }) => {\n    if (!printer.connected) return sum;\n    return sum + calibrations.filter(cal => isMatchingCalibration(cal, formData)).length;\n  }, 0);\n\n  const renderProfile = (printer: { id: number }, cal: CalibrationProfile) => {\n    const key = `${printer.id}:${cal.cali_idx}:${cal.extruder_id ?? 'null'}`;\n    const isSelected = selectedProfiles.has(key);\n    return (\n      <label\n        key={`${cal.cali_idx}-${cal.extruder_id}`}\n        className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all border ${\n          isSelected\n            ? 'bg-bambu-green/10 border-bambu-green/30'\n            : 'bg-bambu-dark border-transparent hover:bg-bambu-dark/80'\n        }`}\n      >\n        <input\n          type=\"checkbox\"\n          checked={isSelected}\n          onChange={() => toggleProfileSelected(String(printer.id), cal.cali_idx, cal.extruder_id)}\n          className=\"w-4 h-4 rounded border-bambu-dark-tertiary text-bambu-green focus:ring-bambu-green\"\n        />\n        <div className=\"flex-1 min-w-0\">\n          <span className={`text-sm font-medium ${isSelected ? 'text-bambu-green' : 'text-white'}`}>\n            {cal.name || cal.filament_id}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-2 shrink-0\">\n          <span className=\"text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray\">\n            K={cal.k_value.toFixed(3)}\n          </span>\n        </div>\n      </label>\n    );\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Header with auto-select */}\n      <div className=\"flex items-center justify-between\">\n        <p className=\"text-xs text-bambu-gray\">\n          {t('inventory.matchingFilter')}: {formData.brand || t('inventory.anyBrand')} / {formData.material} / {formData.subtype || t('inventory.anyVariant')}\n        </p>\n        {totalMatching > 0 && (\n          <button\n            type=\"button\"\n            onClick={autoSelectProfiles}\n            className=\"flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors\"\n          >\n            <Sparkles className=\"w-3.5 h-3.5\" />\n            {t('inventory.autoSelect')} ({totalMatching})\n          </button>\n        )}\n      </div>\n\n      {/* Printer sections */}\n      <div className=\"space-y-3\">\n        {printersWithCalibrations.map(({ printer, calibrations }) => {\n          const isExpanded = expandedPrinters.has(String(printer.id));\n          const matchingCals = calibrations.filter(cal => isMatchingCalibration(cal, formData));\n          const matchingCount = matchingCals.length;\n\n          // Multi-nozzle grouping\n          const isMultiNozzle = matchingCals.some(cal =>\n            cal.extruder_id !== undefined && cal.extruder_id !== null && cal.extruder_id > 0,\n          );\n          const leftNozzleCals = matchingCals.filter(cal => cal.extruder_id === 1);\n          const rightNozzleCals = matchingCals.filter(cal =>\n            cal.extruder_id === 0 || cal.extruder_id === undefined || cal.extruder_id === null,\n          );\n\n          return (\n            <div\n              key={printer.id}\n              className=\"border border-bambu-dark-tertiary rounded-lg overflow-hidden\"\n            >\n              {/* Printer Header */}\n              <button\n                type=\"button\"\n                onClick={() => togglePrinterExpanded(String(printer.id))}\n                className=\"w-full px-4 py-3 flex items-center justify-between bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary transition-colors\"\n              >\n                <div className=\"flex items-center gap-3\">\n                  {isExpanded ? (\n                    <ChevronDown className=\"w-4 h-4 text-bambu-gray\" />\n                  ) : (\n                    <ChevronRight className=\"w-4 h-4 text-bambu-gray\" />\n                  )}\n                  <span className=\"font-medium text-white\">\n                    {printer.name}\n                  </span>\n                  {matchingCount > 0 ? (\n                    <span className=\"text-xs px-2 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green\">\n                      {matchingCount} {matchingCount !== 1 ? t('inventory.matches') : t('inventory.match')}\n                    </span>\n                  ) : (\n                    <span className=\"text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray\">\n                      {t('inventory.noMatches')}\n                    </span>\n                  )}\n                </div>\n                <span className={`text-xs px-2 py-1 rounded-full ${\n                  printer.connected\n                    ? 'bg-green-500/20 text-green-500'\n                    : 'bg-bambu-gray/20 text-bambu-gray'\n                }`}>\n                  {printer.connected ? t('inventory.connected') : t('inventory.offline')}\n                </span>\n              </button>\n\n              {/* Calibration Profiles */}\n              {isExpanded && (\n                <div className=\"px-4 py-3 space-y-3 bg-bambu-dark border-t border-bambu-dark-tertiary\">\n                  {!printer.connected ? (\n                    <p className=\"text-sm text-bambu-gray italic py-2\">\n                      {t('inventory.printerOffline')}\n                    </p>\n                  ) : matchingCount === 0 ? (\n                    <p className=\"text-sm text-bambu-gray italic py-2\">\n                      {t('inventory.noKProfilesMatch')}\n                    </p>\n                  ) : isMultiNozzle ? (\n                    <>\n                      {leftNozzleCals.length > 0 && (\n                        <div className=\"space-y-2\">\n                          <p className=\"text-xs font-medium text-bambu-gray uppercase tracking-wide\">\n                            {t('inventory.leftNozzle')}\n                          </p>\n                          <div className=\"space-y-2\">\n                            {leftNozzleCals.map(cal => renderProfile(printer, cal))}\n                          </div>\n                        </div>\n                      )}\n                      {rightNozzleCals.length > 0 && (\n                        <div className=\"space-y-2\">\n                          <p className=\"text-xs font-medium text-bambu-gray uppercase tracking-wide\">\n                            {t('inventory.rightNozzle')}\n                          </p>\n                          <div className=\"space-y-2\">\n                            {rightNozzleCals.map(cal => renderProfile(printer, cal))}\n                          </div>\n                        </div>\n                      )}\n                    </>\n                  ) : (\n                    <div className=\"space-y-2\">\n                      {matchingCals.map(cal => renderProfile(printer, cal))}\n                    </div>\n                  )}\n                </div>\n              )}\n            </div>\n          );\n        })}\n      </div>\n\n      {/* Summary */}\n      {selectedProfiles.size > 0 && (\n        <div className=\"p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg\">\n          <p className=\"text-sm text-white\">\n            <span className=\"font-semibold\">{selectedProfiles.size}</span> {t('inventory.profilesSelected')}\n          </p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spool-form/constants.ts",
    "content": "import type { ColorPreset } from './types';\n\n// Material options\nexport const MATERIALS = [\n  'PLA', 'PETG', 'ABS', 'TPU', 'ASA', 'PC', 'PA', 'PVA', 'HIPS',\n  'PA-CF', 'PETG-CF', 'PLA-CF',\n];\n\n// Common spool weights\nexport const WEIGHTS = [250, 500, 750, 1000, 2000, 3000];\n\n// Default brand options (will be augmented with cloud presets)\nexport const DEFAULT_BRANDS = [\n  'Bambu', 'PolyLite', 'PolyTerra', 'eSUN', 'Overture',\n  'Fiberon', 'SUNLU', 'Inland', 'Hatchbox', 'Generic',\n];\n\n// Known filament variants/subtypes\nexport const KNOWN_VARIANTS = [\n  'Basic', 'Matte', 'Silk', 'Silk+', 'Tough', 'Tough+', 'HF', 'High Flow', 'Engineering',\n  'Galaxy', 'Glow', 'Marble', 'Metal', 'Rainbow', 'Sparkle', 'Wood',\n  'Translucent', 'Transparent', 'Clear', 'Lite', 'Pro', 'Plus', 'Max',\n  'Super', 'Ultra', 'Flex', 'Soft', 'Hard', 'Strong', 'Impact',\n  'Heat Resistant', 'UV Resistant', 'ESD', 'Conductive', 'Magnetic',\n  'Gradient', 'Dual Color', 'Tri Color', 'Multicolor',\n];\n\n// Quick color swatches - most common colors (shown by default)\nexport const QUICK_COLORS: ColorPreset[] = [\n  { name: 'Black', hex: '000000' },\n  { name: 'White', hex: 'FFFFFF' },\n  { name: 'Gray', hex: '808080' },\n  { name: 'Red', hex: 'FF0000' },\n  { name: 'Orange', hex: 'FFA500' },\n  { name: 'Yellow', hex: 'FFFF00' },\n  { name: 'Green', hex: '00AE42' },\n  { name: 'Blue', hex: '0066FF' },\n  { name: 'Purple', hex: '8B00FF' },\n  { name: 'Pink', hex: 'FF69B4' },\n  { name: 'Brown', hex: '8B4513' },\n  { name: 'Silver', hex: 'C0C0C0' },\n];\n\n// Extended color palette (shown when expanded)\nexport const EXTENDED_COLORS: ColorPreset[] = [\n  // Reds\n  { name: 'Dark Red', hex: '8B0000' },\n  { name: 'Crimson', hex: 'DC143C' },\n  { name: 'Coral', hex: 'FF7F50' },\n  { name: 'Salmon', hex: 'FA8072' },\n  // Oranges\n  { name: 'Dark Orange', hex: 'FF8C00' },\n  { name: 'Peach', hex: 'FFDAB9' },\n  // Yellows\n  { name: 'Gold', hex: 'FFD700' },\n  { name: 'Khaki', hex: 'F0E68C' },\n  { name: 'Lemon', hex: 'FFF44F' },\n  // Greens\n  { name: 'Lime', hex: '32CD32' },\n  { name: 'Forest Green', hex: '228B22' },\n  { name: 'Olive', hex: '808000' },\n  { name: 'Mint', hex: '98FF98' },\n  { name: 'Teal', hex: '008080' },\n  // Blues\n  { name: 'Navy', hex: '000080' },\n  { name: 'Sky Blue', hex: '87CEEB' },\n  { name: 'Royal Blue', hex: '4169E1' },\n  { name: 'Cyan', hex: '00FFFF' },\n  { name: 'Turquoise', hex: '40E0D0' },\n  // Purples\n  { name: 'Violet', hex: 'EE82EE' },\n  { name: 'Magenta', hex: 'FF00FF' },\n  { name: 'Indigo', hex: '4B0082' },\n  { name: 'Lavender', hex: 'E6E6FA' },\n  { name: 'Plum', hex: 'DDA0DD' },\n  // Pinks\n  { name: 'Hot Pink', hex: 'FF69B4' },\n  { name: 'Rose', hex: 'FF007F' },\n  { name: 'Blush', hex: 'FFB6C1' },\n  // Browns\n  { name: 'Chocolate', hex: 'D2691E' },\n  { name: 'Tan', hex: 'D2B48C' },\n  { name: 'Beige', hex: 'F5F5DC' },\n  { name: 'Maroon', hex: '800000' },\n  // Neutrals\n  { name: 'Dark Gray', hex: '404040' },\n  { name: 'Light Gray', hex: 'D3D3D3' },\n  { name: 'Charcoal', hex: '36454F' },\n  { name: 'Ivory', hex: 'FFFFF0' },\n  // Bambu specific\n  { name: 'Bambu Green', hex: '00AE42' },\n  { name: 'Jade White', hex: 'E8E8E8' },\n  { name: 'Titan Gray', hex: '5A5A5A' },\n];\n\n// All colors combined\nexport const ALL_COLORS: ColorPreset[] = [...QUICK_COLORS, ...EXTENDED_COLORS];\n\n// Local storage keys\nexport const RECENT_COLORS_KEY = 'bambuddy-recent-colors';\nexport const MAX_RECENT_COLORS = 8;\n"
  },
  {
    "path": "frontend/src/components/spool-form/types.ts",
    "content": "\nimport type { Printer, SpoolKProfile } from '../../api/client';\n\n// Catalog color display type (moved from component)\nexport interface CatalogDisplayColor {\n  name: string;\n  hex: string;\n  manufacturer?: string;\n  material?: string;\n}\n\n// Form data structure\nexport interface SpoolFormData {\n  material: string;\n  subtype: string;\n  brand: string;\n  color_name: string;\n  rgba: string;\n  label_weight: number;\n  core_weight: number;\n  core_weight_catalog_id: number | null;\n  weight_used: number;\n  slicer_filament: string;\n  note: string;\n  cost_per_kg: number | null;\n}\n\nexport const defaultFormData: SpoolFormData = {\n  material: '',\n  subtype: '',\n  brand: '',\n  color_name: '',\n  rgba: '808080FF',\n  label_weight: 1000,\n  core_weight: 250,\n  core_weight_catalog_id: null,\n  weight_used: 0,\n  slicer_filament: '',\n  note: '',\n  cost_per_kg: null,\n};\n\n// Printer with calibrations type\nexport interface PrinterWithCalibrations {\n  printer: Printer & { connected?: boolean };\n  calibrations: CalibrationProfile[];\n}\n\n// Calibration profile from printer status\nexport interface CalibrationProfile {\n  cali_idx: number;\n  filament_id: string;\n  setting_id: string;\n  name: string;\n  k_value: number;\n  n_coef: number;\n  extruder_id?: number | null;\n  nozzle_diameter?: string;\n}\n\n// Filament option from presets\nexport interface FilamentOption {\n  code: string;\n  name: string;\n  displayName: string;\n  isCustom: boolean;\n  allCodes: string[];\n}\n\n// Color preset\nexport interface ColorPreset {\n  name: string;\n  hex: string;\n}\n\n// Section props base\nexport interface SectionProps {\n  formData: SpoolFormData;\n  updateField: <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => void;\n}\n\n// Filament section props\nexport interface FilamentSectionProps extends SectionProps {\n  cloudAuthenticated: boolean;\n  loadingCloudPresets: boolean;\n  presetInputValue: string;\n  setPresetInputValue: (value: string) => void;\n  selectedPresetOption?: FilamentOption;\n  filamentOptions: FilamentOption[];\n  availableBrands: string[];\n  availableMaterials: string[];\n  quickAdd: boolean;\n  quantity: number;\n  onQuantityChange: (value: number) => void;\n  errors?: Partial<Record<keyof SpoolFormData, string>>;\n}\n\n// Color section props\nexport interface ColorSectionProps extends SectionProps {\n  recentColors: ColorPreset[];\n  onColorUsed: (color: ColorPreset) => void;\n  catalogColors: { manufacturer: string; color_name: string; hex_color: string; material: string | null }[];\n}\n\n// Additional section props\nexport interface AdditionalSectionProps extends SectionProps {\n  spoolCatalog: { id: number; name: string; weight: number }[];\n  currencySymbol: string;\n}\n\n// PA Profile section props\nexport interface PAProfileSectionProps extends SectionProps {\n  printersWithCalibrations: PrinterWithCalibrations[];\n  selectedProfiles: Set<string>;\n  setSelectedProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;\n  expandedPrinters: Set<string>;\n  setExpandedPrinters: React.Dispatch<React.SetStateAction<Set<string>>>;\n}\n\n// Validation result\nexport interface ValidationResult {\n  isValid: boolean;\n  errors: Partial<Record<keyof SpoolFormData, string>>;\n}\n\nexport function validateForm(formData: SpoolFormData, quickAdd = false): ValidationResult {\n  const errors: Partial<Record<keyof SpoolFormData, string>> = {};\n\n  if (quickAdd) {\n    if (!formData.material) {\n      errors.material = 'Material is required';\n    }\n    return {\n      isValid: Object.keys(errors).length === 0,\n      errors,\n    };\n  }\n\n  if (!formData.slicer_filament) {\n    errors.slicer_filament = 'Slicer preset is required';\n  }\n\n  if (!formData.material) {\n    errors.material = 'Material is required';\n  }\n\n  if (!formData.brand) {\n    errors.brand = 'Brand is required';\n  }\n\n  if (!formData.subtype) {\n    errors.subtype = 'Subtype is required';\n  }\n\n  return {\n    isValid: Object.keys(errors).length === 0,\n    errors,\n  };\n}\n\n// Existing K-profile for a spool (from saved data)\nexport interface SavedKProfile extends SpoolKProfile {\n  printer_serial?: string;\n}\n"
  },
  {
    "path": "frontend/src/components/spool-form/utils.ts",
    "content": "import type { SlicerSetting, LocalPreset } from '../../api/client';\nimport type { ColorPreset, FilamentOption } from './types';\nimport { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';\n\n// Fallback filament presets when cloud is not available\nconst FALLBACK_PRESETS: FilamentOption[] = [\n  { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },\n  { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },\n  { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },\n  { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },\n  { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },\n  { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },\n  { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },\n  { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },\n  { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },\n  { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },\n  { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },\n  { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },\n  { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },\n  { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },\n  { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },\n  { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },\n  { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },\n];\n\n// Parse a slicer preset name to extract brand, material, and variant\nexport function parsePresetName(name: string): { brand: string; material: string; variant: string } {\n  // Remove @printer suffix (e.g., \"@Bambu Lab H2D 0.4 nozzle\")\n  let cleanName = name.replace(/@.*$/, '').trim();\n  // Remove (Custom) tag\n  cleanName = cleanName.replace(/\\(Custom\\)/i, '').trim();\n  // Remove leading # or * markers\n  cleanName = cleanName.replace(/^[#*]+\\s*/, '').trim();\n\n  // Materials list - order matters (longer/more specific first)\n  const materials = [\n    'PLA-CF', 'PETG-CF', 'ABS-GF', 'ASA-CF', 'PA-CF', 'PAHT-CF', 'PA6-CF', 'PA6-GF',\n    'PPA-CF', 'PPA-GF', 'PET-CF', 'PPS-CF', 'PC-CF', 'PC-ABS', 'ABS-GF',\n    'PCTG', 'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PEEK', 'PEI',\n  ];\n\n  // Find material in the name\n  let material = '';\n  let materialIdx = -1;\n  for (const m of materials) {\n    const idx = cleanName.toUpperCase().indexOf(m.toUpperCase());\n    if (idx !== -1) {\n      material = m;\n      materialIdx = idx;\n      break;\n    }\n  }\n\n  // Brand is everything before the material\n  let brand = '';\n  if (materialIdx > 0) {\n    brand = cleanName.substring(0, materialIdx).trim();\n    brand = brand.replace(/[-_\\s]+$/, '');\n  }\n\n  // Everything after material is potential variant\n  let afterMaterial = '';\n  if (materialIdx !== -1 && material) {\n    afterMaterial = cleanName.substring(materialIdx + material.length).trim();\n    afterMaterial = afterMaterial.replace(/^[-_\\s]+/, '');\n  }\n\n  // Check for known variant - could be before OR after material\n  let variant = '';\n\n  // First check after material (most common)\n  for (const v of KNOWN_VARIANTS) {\n    if (afterMaterial.toLowerCase().includes(v.toLowerCase())) {\n      variant = v;\n      break;\n    }\n  }\n\n  // If no variant found after material, check if brand contains a known variant\n  if (!variant && brand) {\n    for (const v of KNOWN_VARIANTS) {\n      const variantPattern = new RegExp(`\\\\s+${v}$`, 'i');\n      if (variantPattern.test(brand)) {\n        variant = v;\n        brand = brand.replace(variantPattern, '').trim();\n        break;\n      }\n    }\n  }\n\n  return { brand, material, variant };\n}\n\n// Extract unique brands from cloud presets and local presets\nexport function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {\n  const brandSet = new Set<string>(DEFAULT_BRANDS);\n\n  for (const preset of presets) {\n    const { brand } = parsePresetName(preset.name);\n    if (brand && brand.length > 1) {\n      brandSet.add(brand);\n    }\n  }\n\n  // Also extract brands from local presets\n  if (localPresets) {\n    for (const preset of localPresets) {\n      if (preset.filament_vendor && preset.filament_vendor.length > 1) {\n        brandSet.add(preset.filament_vendor);\n      } else {\n        const { brand } = parsePresetName(preset.name);\n        if (brand && brand.length > 1) {\n          brandSet.add(brand);\n        }\n      }\n    }\n  }\n\n  return Array.from(brandSet).sort((a, b) => a.localeCompare(b));\n}\n\n// Build filament options from local presets (OrcaSlicer imports)\nfunction buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {\n  const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');\n  if (filamentPresets.length === 0) return [];\n\n  const presetsMap = new Map<string, FilamentOption>();\n  for (const preset of filamentPresets) {\n    const baseName = preset.name.replace(/@.*$/, '').trim();\n    const existing = presetsMap.get(baseName);\n    if (existing) {\n      existing.allCodes.push(String(preset.id));\n    } else {\n      // Use filament_type as the code if available (e.g. \"GFL00\"), otherwise use the id\n      const code = preset.filament_type || String(preset.id);\n      presetsMap.set(baseName, {\n        code,\n        name: baseName,\n        displayName: baseName,\n        isCustom: false,\n        allCodes: [code],\n      });\n    }\n  }\n  return Array.from(presetsMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));\n}\n\n// Build filament options: cloud presets → local presets → hardcoded fallback\nexport function buildFilamentOptions(\n  cloudPresets: SlicerSetting[],\n  configuredPrinterModels: Set<string>,\n  localPresets?: LocalPreset[],\n): FilamentOption[] {\n  // 1. Cloud presets (highest priority)\n  if (cloudPresets.length > 0) {\n    const customPresets: FilamentOption[] = [];\n    const defaultPresetsMap = new Map<string, FilamentOption>();\n\n    for (const preset of cloudPresets) {\n      if (preset.is_custom) {\n        // Custom presets: include if matches configured printers or no printer filter\n        const presetNameUpper = preset.name.toUpperCase();\n        const matchesPrinter = configuredPrinterModels.size === 0 ||\n          Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||\n          !presetNameUpper.includes('@');\n\n        if (matchesPrinter) {\n          customPresets.push({\n            code: preset.setting_id,\n            name: preset.name,\n            displayName: `${preset.name} (Custom)`,\n            isCustom: true,\n            allCodes: [preset.setting_id],\n          });\n        }\n      } else {\n        // Default presets: deduplicate by base name\n        const baseName = preset.name.replace(/@.*$/, '').trim();\n        const existing = defaultPresetsMap.get(baseName);\n        if (existing) {\n          existing.allCodes.push(preset.setting_id);\n        } else {\n          defaultPresetsMap.set(baseName, {\n            code: preset.setting_id,\n            name: baseName,\n            displayName: baseName,\n            isCustom: false,\n            allCodes: [preset.setting_id],\n          });\n        }\n      }\n    }\n\n    return [\n      ...customPresets,\n      ...Array.from(defaultPresetsMap.values()),\n    ].sort((a, b) => a.displayName.localeCompare(b.displayName));\n  }\n\n  // 2. Local presets (OrcaSlicer imports)\n  if (localPresets && localPresets.length > 0) {\n    const localOptions = buildLocalFilamentOptions(localPresets);\n    if (localOptions.length > 0) return localOptions;\n  }\n\n  // 3. Hardcoded fallback\n  return FALLBACK_PRESETS;\n}\n\n// Find selected preset option\nexport function findPresetOption(\n  slicerFilament: string,\n  filamentOptions: FilamentOption[],\n): FilamentOption | undefined {\n  if (!slicerFilament) return undefined;\n\n  // First try exact match on primary code\n  let option = filamentOptions.find(o => o.code === slicerFilament);\n  if (!option) {\n    // Try matching against any code in allCodes\n    option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));\n  }\n  if (!option) {\n    // Try case-insensitive match\n    const slicerLower = slicerFilament.toLowerCase();\n    option = filamentOptions.find(o =>\n      o.code.toLowerCase() === slicerLower ||\n      o.allCodes.some(c => c.toLowerCase() === slicerLower),\n    );\n  }\n  return option;\n}\n\n// Recent colors management\nexport function loadRecentColors(): ColorPreset[] {\n  try {\n    const stored = localStorage.getItem(RECENT_COLORS_KEY);\n    if (stored) {\n      return JSON.parse(stored) as ColorPreset[];\n    }\n  } catch {\n    // Ignore errors\n  }\n  return [];\n}\n\nexport function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {\n  const filtered = currentRecent.filter(\n    c => c.hex.toUpperCase() !== color.hex.toUpperCase(),\n  );\n  const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);\n\n  try {\n    localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));\n  } catch {\n    // Ignore errors\n  }\n\n  return updated;\n}\n\n// Check if a calibration matches based on brand, material, and variant\nexport function isMatchingCalibration(\n  cal: { name?: string; filament_id?: string },\n  formData: { material: string; brand: string; subtype: string },\n): boolean {\n  if (!formData.material) return false;\n\n  const profileName = cal.name || '';\n\n  // Remove flow type prefixes\n  const cleanName = profileName\n    .replace(/^High Flow[_\\s]+/i, '')\n    .replace(/^Standard[_\\s]+/i, '')\n    .replace(/^HF[_\\s]+/i, '')\n    .replace(/^S[_\\s]+/i, '')\n    .trim();\n\n  const parsed = parsePresetName(cleanName);\n\n  // Match material (required)\n  const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();\n  if (!materialMatch) return false;\n\n  // Match brand if specified in form\n  if (formData.brand) {\n    const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||\n      formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());\n    if (!brandMatch) return false;\n  }\n\n  // Match variant/subtype if specified in form\n  if (formData.subtype) {\n    const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||\n      formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||\n      cleanName.toLowerCase().includes(formData.subtype.toLowerCase());\n    if (!variantMatch) return false;\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/AmsUnitCard.tsx",
    "content": "import type { AMSUnit, AMSTray } from '../../api/client';\nimport { getFillBarColor } from '../../utils/amsHelpers';\n\nfunction trayColorToCSS(color: string | null): string {\n  if (!color) return '#808080';\n  return `#${color.slice(0, 6)}`;\n}\n\nfunction isTrayEmpty(tray: AMSTray): boolean {\n  return !tray.tray_type || tray.tray_type === '';\n}\n\nfunction getAmsName(id: number): string {\n  if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;\n  if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;\n  return `AMS ${id}`;\n}\n\n// --- SVG Icons (matching PrintersPage Bambu Lab style) ---\n\nfunction WaterDropEmpty({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 36 54\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M17.8131 0.00538C18.4463 -0.15091 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00538ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z\" fill=\"#C3C2C1\"/>\n    </svg>\n  );\n}\n\nfunction WaterDropHalf({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 35 53\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M17.3165 0.0038C17.932 -0.14959 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.0038ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z\" fill=\"#C3C2C1\"/>\n      <path d=\"M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z\" fill=\"#1F8FEB\"/>\n    </svg>\n  );\n}\n\nfunction WaterDropFull({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 36 54\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z\" fill=\"#1F8FEB\"/>\n      <path d=\"M17.7948 0.00538C18.4273 -0.15091 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0134 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00538ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z\" fill=\"#C3C2C1\"/>\n    </svg>\n  );\n}\n\nfunction ThermometerEmpty({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 12 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\" stroke=\"#C3C2C1\" strokeWidth=\"1\" fill=\"none\"/>\n      <circle cx=\"6\" cy=\"15\" r=\"2.5\" stroke=\"#C3C2C1\" strokeWidth=\"1\" fill=\"none\"/>\n    </svg>\n  );\n}\n\nfunction ThermometerHalf({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 12 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <rect x=\"4.5\" y=\"8\" width=\"3\" height=\"4.5\" fill=\"#d4a017\" rx=\"0.5\"/>\n      <circle cx=\"6\" cy=\"15\" r=\"2\" fill=\"#d4a017\"/>\n      <path d=\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\" stroke=\"#C3C2C1\" strokeWidth=\"1\" fill=\"none\"/>\n    </svg>\n  );\n}\n\nfunction ThermometerFull({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 12 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <rect x=\"4.5\" y=\"3\" width=\"3\" height=\"9.5\" fill=\"#c62828\" rx=\"0.5\"/>\n      <circle cx=\"6\" cy=\"15\" r=\"2\" fill=\"#c62828\"/>\n      <path d=\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\" stroke=\"#C3C2C1\" strokeWidth=\"1\" fill=\"none\"/>\n    </svg>\n  );\n}\n\n// --- Threshold-colored indicators ---\n\nfunction HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }: { humidity: number; goodThreshold?: number; fairThreshold?: number }) {\n  let textColor: string;\n  let DropComponent: React.FC<{ className?: string }>;\n\n  if (humidity <= goodThreshold) {\n    textColor = '#22a352';\n    DropComponent = WaterDropEmpty;\n  } else if (humidity <= fairThreshold) {\n    textColor = '#d4a017';\n    DropComponent = WaterDropHalf;\n  } else {\n    textColor = '#c62828';\n    DropComponent = WaterDropFull;\n  }\n\n  return (\n    <div className=\"flex items-center gap-0.5\">\n      <DropComponent className=\"w-3 h-3.5\" />\n      <span className=\"font-medium tabular-nums text-xs\" style={{ color: textColor }}>{humidity}%</span>\n    </div>\n  );\n}\n\nfunction TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35 }: { temp: number; goodThreshold?: number; fairThreshold?: number }) {\n  let textColor: string;\n  let ThermoComponent: React.FC<{ className?: string }>;\n\n  if (temp <= goodThreshold) {\n    textColor = '#22a352';\n    ThermoComponent = ThermometerEmpty;\n  } else if (temp <= fairThreshold) {\n    textColor = '#d4a017';\n    ThermoComponent = ThermometerHalf;\n  } else {\n    textColor = '#c62828';\n    ThermoComponent = ThermometerFull;\n  }\n\n  return (\n    <div className=\"flex items-center gap-0.5\">\n      <ThermoComponent className=\"w-3 h-3.5\" />\n      <span className=\"font-medium tabular-nums text-xs\" style={{ color: textColor }}>{temp}°C</span>\n    </div>\n  );\n}\n\n// --- Nozzle badge ---\n\nfunction NozzleBadge({ side }: { side: 'L' | 'R' }) {\n  return (\n    <span\n      className=\"inline-flex items-center justify-center w-4 h-4 text-[9px] font-bold rounded\"\n      style={{ backgroundColor: '#1a4d2e', color: '#00ae42' }}\n    >\n      {side}\n    </span>\n  );\n}\n\n// --- Components ---\n\ninterface SpoolSlotProps {\n  tray: AMSTray;\n  slotIndex: number;\n  isActive: boolean;\n  fillOverride?: number | null;\n  spoolmanFill?: number | null;\n  onClick?: () => void;\n}\n\nfunction SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onClick }: SpoolSlotProps) {\n  const isEmpty = isTrayEmpty(tray);\n  const color = trayColorToCSS(tray.tray_color);\n  const amsFill = tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 ? tray.remain : null;\n  // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)\n  const resolvedOverride = (fillOverride === 0 && amsFill !== null && amsFill > 0) ? null : fillOverride;\n  // Fill level fallback chain: Spoolman → Inventory → AMS remain\n  const effectiveFill = spoolmanFill ?? resolvedOverride ?? amsFill;\n\n  return (\n    <div\n      className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''} ${onClick ? 'cursor-pointer hover:bg-white/5' : ''}`}\n      onClick={onClick}\n    >\n      {/* Spool visualization */}\n      <div className=\"relative w-16 h-16 mb-1\">\n        {isEmpty ? (\n          <div className=\"w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center\">\n            <div className=\"w-3 h-3 rounded-full bg-gray-600\" />\n          </div>\n        ) : (\n          <svg viewBox=\"0 0 56 56\" className=\"w-full h-full\">\n            <circle cx=\"28\" cy=\"28\" r=\"26\" fill={color} />\n            <circle cx=\"28\" cy=\"28\" r=\"20\" fill={color} style={{ filter: 'brightness(0.85)' }} />\n            <ellipse cx=\"20\" cy=\"20\" rx=\"6\" ry=\"4\" fill=\"white\" opacity=\"0.3\" />\n            <circle cx=\"28\" cy=\"28\" r=\"8\" fill=\"#2d2d2d\" />\n            <circle cx=\"28\" cy=\"28\" r=\"5\" fill=\"#1a1a1a\" />\n          </svg>\n        )}\n        {isActive && (\n          <div className=\"absolute -bottom-1 left-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-bambu-green rounded-full\" />\n        )}\n      </div>\n\n      {/* Material type */}\n      <span className=\"text-sm text-white/70 truncate max-w-full\">\n        {isEmpty ? 'Empty' : tray.tray_type || 'Unknown'}\n      </span>\n\n      {/* Fill level bar */}\n      {!isEmpty && effectiveFill !== null && effectiveFill >= 0 && (\n        <div className=\"w-full h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden mt-1\">\n          <div\n            className=\"h-full rounded-full transition-all\"\n            style={{\n              width: `${effectiveFill}%`,\n              backgroundColor: getFillBarColor(effectiveFill),\n            }}\n          />\n        </div>\n      )}\n\n      {/* Slot number */}\n      <span className=\"absolute top-1 right-1 text-xs text-white/30\">{slotIndex + 1}</span>\n    </div>\n  );\n}\n\nexport interface AmsThresholds {\n  humidityGood: number;\n  humidityFair: number;\n  tempGood: number;\n  tempFair: number;\n}\n\ninterface AmsUnitCardProps {\n  unit: AMSUnit;\n  activeSlot: number | null;\n  onConfigureSlot?: (amsId: number, trayId: number, tray: AMSTray | null) => void;\n  isDualNozzle?: boolean;\n  nozzleSide?: 'L' | 'R' | null;\n  thresholds?: AmsThresholds;\n  fillOverrides?: Record<string, number>;\n  spoolmanFillOverrides?: Record<string, number>;\n}\n\nexport function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides, spoolmanFillOverrides }: AmsUnitCardProps) {\n  const trays = unit.tray || [];\n  const isHt = unit.is_ams_ht;\n  const slotCount = isHt ? 1 : 4;\n\n  return (\n    <div className=\"bg-bambu-dark-secondary rounded-lg p-3\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between mb-2\">\n        <div className=\"flex items-center gap-1.5\">\n          <span className=\"text-white font-medium text-base\">{getAmsName(unit.id)}</span>\n          {isDualNozzle && nozzleSide && (\n            <NozzleBadge side={nozzleSide} />\n          )}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {unit.temp != null && (\n            <TemperatureIndicator\n              temp={unit.temp}\n              goodThreshold={thresholds?.tempGood}\n              fairThreshold={thresholds?.tempFair}\n            />\n          )}\n          {unit.humidity != null && (\n            <HumidityIndicator\n              humidity={unit.humidity}\n              goodThreshold={thresholds?.humidityGood}\n              fairThreshold={thresholds?.humidityFair}\n            />\n          )}\n        </div>\n      </div>\n\n      {/* Slots grid */}\n      <div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px] mx-auto' : 'grid-cols-4'} gap-2`}>\n        {Array.from({ length: slotCount }).map((_, i) => {\n          const tray = trays[i] || {\n            id: i,\n            tray_color: null,\n            tray_type: '',\n            tray_sub_brands: null,\n            tray_id_name: null,\n            tray_info_idx: null,\n            remain: -1,\n            k: null,\n            cali_idx: null,\n            tag_uid: null,\n            tray_uuid: null,\n            nozzle_temp_min: null,\n            nozzle_temp_max: null,\n          };\n          return (\n            <SpoolSlot\n              key={i}\n              tray={tray}\n              slotIndex={i}\n              isActive={activeSlot === i}\n              fillOverride={fillOverrides?.[`${unit.id}-${i}`] ?? null}\n              spoolmanFill={spoolmanFillOverrides?.[`${unit.id}-${i}`] ?? null}\n              onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}\n            />\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n// Exported for use in SpoolBuddyAmsPage compact cards\nexport { HumidityIndicator, TemperatureIndicator, NozzleBadge };\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/AssignToAmsModal.tsx",
    "content": "import { useState, useEffect, useCallback, useMemo, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';\nimport { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';\nimport { ConfirmModal } from '../ConfirmModal';\nimport { AmsUnitCard, NozzleBadge } from './AmsUnitCard';\nimport type { AmsThresholds } from './AmsUnitCard';\nimport { getFillBarColor } from '../../utils/amsHelpers';\n\nfunction getAmsName(id: number): string {\n  if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;\n  if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;\n  return `AMS ${id}`;\n}\n\nfunction isTrayEmpty(tray: AMSTray): boolean {\n  return !tray.tray_type || tray.tray_type === '';\n}\n\nfunction trayColorToCSS(color: string | null): string {\n  if (!color) return '#808080';\n  return `#${color.slice(0, 6)}`;\n}\n\n// --- Material/profile mismatch helpers (pure functions, no component state) ---\nconst normalizeValue = (value: string | undefined | null) =>\n  (value ?? '').trim().toUpperCase();\n\nfunction checkMaterialMatch(\n  spoolMaterial: string | undefined | null,\n  trayMaterial: string | undefined | null\n): 'exact' | 'partial' | 'none' {\n  const normalizedSpool = normalizeValue(spoolMaterial);\n  const normalizedTray = normalizeValue(trayMaterial);\n  if (!normalizedSpool || !normalizedTray) return 'none';\n  if (normalizedSpool === normalizedTray) return 'exact';\n  if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {\n    return 'partial';\n  }\n  return 'none';\n}\n\nfunction checkProfileMatch(\n  spoolProfile: string | undefined | null,\n  trayProfile: string | undefined | null\n): boolean {\n  const normalizedSpoolProfile = normalizeValue(spoolProfile);\n  const normalizedTrayProfile = normalizeValue(trayProfile);\n  if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;\n  return normalizedSpoolProfile === normalizedTrayProfile;\n}\n\ninterface AssignToAmsModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  spool: InventorySpool;\n  printerId: number | null;\n}\n\nexport function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [statusMessage, setStatusMessage] = useState<string | null>(null);\n  const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);\n  const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);\n  const [mismatchDetails, setMismatchDetails] = useState<{\n    type: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile';\n    spoolMaterial: string;\n    trayMaterial: string;\n    spoolProfile?: string;\n    trayProfile?: string;\n    location: string;\n  } | null>(null);\n  const [pendingSlot, setPendingSlot] = useState<{ amsId: number; trayId: number } | null>(null);\n\n  useEffect(() => {\n    if (isOpen) {\n      setStatusMessage(null);\n      setStatusType(null);\n      setShowMismatchConfirm(false);\n      setMismatchDetails(null);\n      setPendingSlot(null);\n    }\n  }, [isOpen]);\n\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    if (e.key === 'Escape') onClose();\n  }, [onClose]);\n\n  useEffect(() => {\n    if (isOpen) document.addEventListener('keydown', handleKeyDown);\n    return () => document.removeEventListener('keydown', handleKeyDown);\n  }, [isOpen, handleKeyDown]);\n\n  const { data: status } = useQuery<PrinterStatus>({\n    queryKey: ['printerStatus', printerId],\n    queryFn: () => api.getPrinterStatus(printerId!),\n    enabled: isOpen && printerId !== null,\n    refetchInterval: 5000,\n  });\n\n  const { data: printer } = useQuery({\n    queryKey: ['printer', printerId],\n    queryFn: () => api.getPrinter(printerId!),\n    enabled: isOpen && printerId !== null,\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: () => api.getSettings(),\n    enabled: isOpen,\n    staleTime: 5 * 60 * 1000,\n  });\n\n  const { data: assignments } = useQuery({\n    queryKey: ['spool-assignments', printerId],\n    queryFn: () => api.getAssignments(printerId!),\n    enabled: isOpen && printerId !== null,\n    staleTime: 30 * 1000,\n  });\n\n  // Build fill-level override map from inventory assignments\n  const fillOverrides = useMemo(() => {\n    const map: Record<string, number> = {};\n    if (!assignments) return map;\n    for (const a of assignments) {\n      const sp = a.spool;\n      if (sp && sp.label_weight > 0 && sp.weight_used != null) {\n        const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);\n        map[`${a.ams_id}-${a.tray_id}`] = fill;\n      }\n    }\n    return map;\n  }, [assignments]);\n\n  const amsThresholds: AmsThresholds | undefined = settings ? {\n    humidityGood: Number(settings.ams_humidity_good) || 40,\n    humidityFair: Number(settings.ams_humidity_fair) || 60,\n    tempGood: Number(settings.ams_temp_good) || 28,\n    tempFair: Number(settings.ams_temp_fair) || 35,\n  } : undefined;\n\n  const isConnected = status?.connected ?? false;\n  const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);\n  const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);\n  const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);\n  const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);\n  const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;\n\n  const cachedAmsExtruderMap = useRef<Record<string, number>>({});\n  useEffect(() => {\n    if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {\n      cachedAmsExtruderMap.current = status.ams_extruder_map;\n    }\n  }, [status?.ams_extruder_map]);\n  const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)\n    ? status.ams_extruder_map\n    : cachedAmsExtruderMap.current;\n\n  const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {\n    if (!isDualNozzle) return null;\n    const mappedExtruderId = amsExtruderMap[String(amsId)];\n    const normalizedId = amsId >= 128 ? amsId - 128 : amsId;\n    const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;\n    return extruderId === 1 ? 'L' : 'R';\n  }, [isDualNozzle, amsExtruderMap]);\n\n  // Assign spool to AMS slot — single API call, backend handles both\n  // DB record AND MQTT auto-configuration (same as SpoolStation).\n  const configureMutation = useMutation({\n    mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {\n      if (!printerId) throw new Error('No printer selected');\n\n      await api.assignSpool({\n        spool_id: spool.id,\n        printer_id: printerId,\n        ams_id: amsId,\n        tray_id: trayId,\n      });\n\n      // Slot preset mapping is now saved by the backend in assign_spool()\n      // after successful MQTT configuration, using the authoritative\n      // slicer_filament_name from the spool record.\n    },\n    onSuccess: () => {\n      setStatusType('success');\n      setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));\n      queryClient.invalidateQueries({ queryKey: ['slotPresets'] });\n      setTimeout(() => onClose(), 1500);\n    },\n    onError: (err) => {\n      setStatusType('error');\n      setStatusMessage(err instanceof Error ? err.message : t('spoolbuddy.modal.assignError', 'Failed to assign spool.'));\n    },\n  });\n\n  const isWaiting = configureMutation.isPending;\n\n  const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {\n    if (amsId === 254 || amsId === 255) {\n      const extTrayId = amsId === 254 ? 254 : 254 + trayId;\n      return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null;\n    }\n    const unit = amsUnits.find(u => u.id === amsId);\n    return unit?.tray?.find(t => t.id === trayId) || null;\n  }, [amsUnits, vtTrays]);\n\n  const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {\n    if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;\n    if (amsId >= 128 && amsId <= 135) return getAmsName(amsId);\n    if (amsId === 254) return t('printers.extL', 'Ext-L');\n    return isDualNozzle ? t('printers.extR', 'Ext-R') : t('printers.ext', 'Ext');\n  }, [t, isDualNozzle]);\n\n  const doAssign = useCallback((amsId: number, trayId: number) => {\n    setStatusType('info');\n    setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));\n    configureMutation.mutate({ amsId, trayId });\n  }, [configureMutation, t]);\n\n  const handleSlotClick = useCallback((amsId: number, trayId: number) => {\n    if (isWaiting) return;\n\n    if (!settings?.disable_filament_warnings) {\n      const tray = getTrayForSlot(amsId, trayId);\n      if (tray && !isTrayEmpty(tray)) {\n        const trayMaterial = tray.tray_sub_brands || tray.tray_type || '';\n        const materialMatchResult = checkMaterialMatch(spool.material, trayMaterial);\n        const spoolProfile = spool.slicer_filament_name || spool.slicer_filament;\n        const trayProfile = tray.tray_type || '';\n        const profileMatches = checkProfileMatch(spoolProfile, trayProfile);\n\n        if (materialMatchResult !== 'exact' || !profileMatches) {\n          let mismatchType: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile' = 'profile';\n          if (materialMatchResult === 'none' && !profileMatches) {\n            mismatchType = 'material_profile';\n          } else if (materialMatchResult === 'partial' && !profileMatches) {\n            mismatchType = 'partial_profile';\n          } else if (materialMatchResult === 'none') {\n            mismatchType = 'material';\n          } else if (materialMatchResult === 'partial') {\n            mismatchType = 'partial';\n          }\n\n          const location = getSlotLocationLabel(amsId, trayId);\n          setPendingSlot({ amsId, trayId });\n          setMismatchDetails({\n            type: mismatchType,\n            spoolMaterial: spool.material || '',\n            trayMaterial: trayMaterial || '',\n            spoolProfile: spoolProfile || undefined,\n            trayProfile: trayProfile || undefined,\n            location,\n          });\n          setShowMismatchConfirm(true);\n          return;\n        }\n      }\n    }\n\n    doAssign(amsId, trayId);\n  }, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]);\n\n  const handleConfirmMismatch = useCallback(() => {\n    if (!pendingSlot) return;\n    setShowMismatchConfirm(false);\n    setMismatchDetails(null);\n    doAssign(pendingSlot.amsId, pendingSlot.trayId);\n    setPendingSlot(null);\n  }, [pendingSlot, doAssign]);\n\n  // Build single-slot items (HT + External)\n  const singleSlots = useMemo(() => {\n    const items: {\n      key: string; label: string; amsId: number; trayId: number;\n      tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;\n      effectiveFill: number | null;\n    }[] = [];\n\n    for (const unit of htAms) {\n      const tray = unit.tray?.[0] || {\n        id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,\n        tray_id_name: null, tray_info_idx: null, remain: -1, k: null,\n        cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,\n      };\n      const invFill = fillOverrides[`${unit.id}-0`] ?? null;\n      const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;\n      const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;\n      items.push({\n        key: `ht-${unit.id}`, label: getAmsName(unit.id),\n        amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),\n        nozzleSide: getNozzleSide(unit.id),\n        effectiveFill: resolvedInvFill ?? amsFill,\n      });\n    }\n\n    for (const extTray of vtTrays) {\n      const extTrayId = extTray.id ?? 254;\n      const extSlotTrayId = extTrayId - 254;\n      const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;\n      const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;\n      const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;\n      items.push({\n        key: `ext-${extTrayId}`,\n        label: isDualNozzle\n          ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))\n          : t('printers.ext', 'Ext'),\n        amsId: 255, trayId: extSlotTrayId, tray: extTray,\n        isEmpty: isTrayEmpty(extTray),\n        nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,\n        effectiveFill: extResolvedInvFill ?? extAmsFill,\n      });\n    }\n\n    return items;\n  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides]);\n\n  if (!isOpen) return null;\n\n  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';\n\n  return (\n    <>\n    <div className=\"fixed inset-0 z-[60] bg-bambu-dark flex flex-col\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0\">\n        <div className=\"flex items-center gap-3 min-w-0\">\n          <div className=\"w-7 h-7 rounded-full shrink-0\" style={{ backgroundColor: colorHex }} />\n          <div className=\"min-w-0\">\n            <h2 className=\"text-sm font-semibold text-zinc-100 truncate\">\n              {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}\n              <span className=\"font-normal text-zinc-500 ml-2\">\n                {spool.color_name || 'Unknown'} &bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}\n              </span>\n            </h2>\n          </div>\n        </div>\n        <button\n          onClick={onClose}\n          disabled={isWaiting}\n          className=\"p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors shrink-0 disabled:opacity-50\"\n        >\n          <X className=\"w-5 h-5\" />\n        </button>\n      </div>\n\n      {/* Status message */}\n      {statusMessage && (\n        <div className={`mx-5 mt-3 p-3 rounded-lg flex items-center gap-3 border shrink-0 ${\n          statusType === 'info'\n            ? 'bg-blue-500/10 border-blue-500/40'\n            : statusType === 'success'\n              ? 'bg-green-500/10 border-green-500/40'\n              : 'bg-red-500/10 border-red-500/40'\n        }`}>\n          {statusType === 'info' && <Loader2 className=\"w-4 h-4 text-blue-400 animate-spin shrink-0\" />}\n          {statusType === 'success' && <CheckCircle className=\"w-4 h-4 text-green-400 shrink-0\" />}\n          {statusType === 'error' && <XCircle className=\"w-4 h-4 text-red-400 shrink-0\" />}\n          <span className={`text-sm ${\n            statusType === 'info' ? 'text-blue-300' : statusType === 'success' ? 'text-green-300' : 'text-red-300'\n          }`}>{statusMessage}</span>\n        </div>\n      )}\n\n      {/* AMS slots */}\n      <div className=\"flex-1 flex flex-col gap-3 p-4 min-h-0 overflow-y-auto\">\n        {!isConnected && printerId ? (\n          <div className=\"flex-1 flex items-center justify-center\">\n            <div className=\"text-center text-white/50\">\n              <p className=\"text-lg mb-2\">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>\n            </div>\n          </div>\n        ) : amsUnits.length === 0 && vtTrays.length === 0 ? (\n          <div className=\"flex-1 flex items-center justify-center\">\n            <div className=\"text-center text-white/50\">\n              <Layers className=\"w-12 h-12 mx-auto mb-3 opacity-50\" />\n              <p className=\"text-lg mb-2\">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>\n              <p className=\"text-sm\">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>\n            </div>\n          </div>\n        ) : (\n          <>\n            {/* Regular AMS — 2-col grid */}\n            {regularAms.length > 0 && (\n              <div className=\"grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0\">\n                {regularAms.map((unit) => (\n                  <AmsUnitCard\n                    key={unit.id}\n                    unit={unit}\n                    activeSlot={null}\n                    onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}\n                    isDualNozzle={isDualNozzle}\n                    nozzleSide={getNozzleSide(unit.id)}\n                    thresholds={amsThresholds}\n                    fillOverrides={fillOverrides}\n                  />\n                ))}\n              </div>\n            )}\n\n            {/* Single-slot items (HT + External) */}\n            {singleSlots.length > 0 && (\n              <div className=\"flex gap-2 shrink-0\">\n                {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {\n                  const color = trayColorToCSS(tray.tray_color);\n                  return (\n                    <div\n                      key={key}\n                      onClick={() => handleSlotClick(amsId, trayId)}\n                      className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${\n                        isWaiting ? 'opacity-50 pointer-events-none' : ''\n                      }`}\n                    >\n                      <div className=\"relative w-10 h-10 shrink-0\">\n                        {isEmpty ? (\n                          <div className=\"w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center\">\n                            <div className=\"w-1.5 h-1.5 rounded-full bg-gray-600\" />\n                          </div>\n                        ) : (\n                          <svg viewBox=\"0 0 56 56\" className=\"w-full h-full\">\n                            <circle cx=\"28\" cy=\"28\" r=\"26\" fill={color} />\n                            <circle cx=\"28\" cy=\"28\" r=\"20\" fill={color} style={{ filter: 'brightness(0.85)' }} />\n                            <ellipse cx=\"20\" cy=\"20\" rx=\"6\" ry=\"4\" fill=\"white\" opacity=\"0.3\" />\n                            <circle cx=\"28\" cy=\"28\" r=\"8\" fill=\"#2d2d2d\" />\n                            <circle cx=\"28\" cy=\"28\" r=\"5\" fill=\"#1a1a1a\" />\n                          </svg>\n                        )}\n                      </div>\n                      <div className=\"min-w-0\">\n                        <div className=\"flex items-center gap-1\">\n                          <span className=\"text-xs text-white/50 font-medium\">{label}</span>\n                          {nozzleSide && <NozzleBadge side={nozzleSide} />}\n                        </div>\n                        <div className=\"text-sm text-white/80 truncate\">\n                          {isEmpty ? 'Empty' : tray.tray_type || '?'}\n                        </div>\n                      </div>\n                      {!isEmpty && effectiveFill != null && effectiveFill >= 0 && (\n                        <div className=\"w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse\">\n                          <div\n                            className=\"w-full rounded-full\"\n                            style={{\n                              height: `${effectiveFill}%`,\n                              backgroundColor: getFillBarColor(effectiveFill),\n                            }}\n                          />\n                        </div>\n                      )}\n                    </div>\n                  );\n                })}\n              </div>\n            )}\n          </>\n        )}\n      </div>\n\n      {/* Footer */}\n      <div className=\"flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0\">\n        <button\n          onClick={onClose}\n          disabled={isWaiting}\n          className=\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors min-h-[44px] disabled:opacity-50\"\n        >\n          {statusType === 'success' ? t('spoolbuddy.dashboard.close', 'Close') : t('spoolbuddy.modal.cancel', 'Cancel')}\n        </button>\n      </div>\n    </div>\n\n    {showMismatchConfirm && mismatchDetails && (() => {\n      let message = '';\n\n      if (mismatchDetails.type === 'material') {\n        message = t('inventory.assignMismatchMessage', {\n          spoolMaterial: mismatchDetails.spoolMaterial,\n          trayMaterial: mismatchDetails.trayMaterial,\n          location: mismatchDetails.location,\n        });\n      } else if (mismatchDetails.type === 'partial') {\n        message = t('inventory.assignPartialMismatchMessage', {\n          spoolMaterial: mismatchDetails.spoolMaterial,\n          trayMaterial: mismatchDetails.trayMaterial,\n          location: mismatchDetails.location,\n        });\n      } else if (mismatchDetails.type === 'material_profile') {\n        message = `${t('inventory.assignMismatchMessage', {\n          spoolMaterial: mismatchDetails.spoolMaterial,\n          trayMaterial: mismatchDetails.trayMaterial,\n          location: mismatchDetails.location,\n        })}\\n\\n${t('inventory.assignProfileMismatchMessage', {\n          spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),\n          trayProfile: mismatchDetails.trayProfile || t('common.unknown'),\n          location: mismatchDetails.location,\n        })}`;\n      } else if (mismatchDetails.type === 'partial_profile') {\n        message = `${t('inventory.assignPartialMismatchMessage', {\n          spoolMaterial: mismatchDetails.spoolMaterial,\n          trayMaterial: mismatchDetails.trayMaterial,\n          location: mismatchDetails.location,\n        })}\\n\\n${t('inventory.assignProfileMismatchMessage', {\n          spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),\n          trayProfile: mismatchDetails.trayProfile || t('common.unknown'),\n          location: mismatchDetails.location,\n        })}`;\n      } else if (mismatchDetails.type === 'profile') {\n        message = t('inventory.assignProfileMismatchMessage', {\n          spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),\n          trayProfile: mismatchDetails.trayProfile || t('common.unknown'),\n          location: mismatchDetails.location,\n        });\n      }\n\n      return (\n        <ConfirmModal\n          title={t('inventory.assignMismatchTitle')}\n          message={message}\n          confirmText={t('inventory.assignMismatchConfirm')}\n          variant=\"warning\"\n          isLoading={configureMutation.isPending}\n          onConfirm={handleConfirmMismatch}\n          onCancel={() => {\n            if (!configureMutation.isPending) {\n              setShowMismatchConfirm(false);\n              setPendingSlot(null);\n              setMismatchDetails(null);\n            }\n          }}\n        />\n      );\n    })()}\n    </>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/DiagnosticModal.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react';\nimport { X, Play, RotateCw } from 'lucide-react';\nimport { spoolbuddyApi } from '../../api/client';\nimport { useTranslation } from 'react-i18next';\n\ninterface DiagnosticModalProps {\n  type: 'scale' | 'nfc' | 'read_tag';\n  deviceId: string;\n  onClose: () => void;\n}\n\nexport function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) {\n  const { t } = useTranslation();\n  const [isRunning, setIsRunning] = useState(false);\n  const [output, setOutput] = useState<string>('');\n  const [error, setError] = useState<string>('');\n  const [hasRun, setHasRun] = useState(false);\n\n  // Close on Escape\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && !isRunning) {\n        onClose();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [isRunning, onClose]);\n\n  const runDiagnostic = useCallback(async () => {\n    setIsRunning(true);\n    setOutput('');\n    setError('');\n    setHasRun(true);\n\n    try {\n      // Step 1: Queue the diagnostic on the device\n      setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\\n'));\n      await spoolbuddyApi.queueDiagnostics(deviceId, type);\n\n      // Step 2: Poll for results with timeout\n      let result = null;\n      const maxRetries = 60; // 30s timeout with 500ms polling\n      let retryCount = 0;\n\n      while (retryCount < maxRetries && !result) {\n        // Wait a bit before polling\n        await new Promise(resolve => setTimeout(resolve, 500));\n\n        try {\n          result = await spoolbuddyApi.getDiagnosticResult(deviceId, type);\n          break;\n        } catch {\n          // Not ready yet, continue polling\n          retryCount++;\n          if (retryCount % 4 === 0) {\n            // Update every 2 seconds (after 4 retries of 500ms)\n            setOutput(prev => prev + '.');\n          }\n        }\n      }\n\n      if (!result) {\n        throw new Error('Diagnostic timed out - device did not report results');\n      }\n\n      setOutput(result.output);\n      if (!result.success) {\n        setError(`Exit code: ${result.exit_code}`);\n      }\n    } catch (e) {\n      setError(e instanceof Error ? e.message : 'Unknown error');\n      setOutput('');\n    } finally {\n      setIsRunning(false);\n    }\n  }, [type, deviceId, t]);\n\n  const title = type === 'scale'\n    ? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic')\n    : type === 'read_tag'\n      ? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic')\n      : t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic');\n\n  return (\n    <div\n      className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in\"\n      onClick={onClose}\n    >\n      <div\n        className=\"bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex justify-between items-center p-4 border-b border-zinc-700\">\n          <h2 className=\"text-lg font-semibold text-white\">{title}</h2>\n          <button\n            onClick={onClose}\n            className=\"text-zinc-400 hover:text-white transition-colors\"\n            aria-label=\"Close\"\n          >\n            <X size={20} />\n          </button>\n        </div>\n\n        <div className=\"flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm\">\n          {isRunning ? (\n            <div className=\"flex items-center gap-2 text-green-400\">\n              <div className=\"animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full\" />\n              <span>{t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')}</span>\n            </div>\n          ) : output ? (\n            <>\n              <div className=\"text-green-400 whitespace-pre-wrap break-words\">\n                {output}\n              </div>\n              {error && (\n                <div className=\"text-red-400 mt-2\">\n                  ❌ {error}\n                </div>\n              )}\n            </>\n          ) : hasRun ? (\n            <div>\n              {error ? (\n                <div className=\"text-red-400\">ERROR: {error}</div>\n              ) : (\n                <span className=\"text-green-400\">{t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')}</span>\n              )}\n            </div>\n                ) : (\n            <div className=\"text-zinc-500\">\n              {t('spoolbuddy.diagnostic.clickStart', 'Click \"Run Diagnostic\" to start the hardware diagnostic on')} {deviceId}.\n            </div>\n          )}\n        </div>\n\n        {/* Footer */}\n        <div className=\"flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800\">\n          <button\n            onClick={runDiagnostic}\n            disabled={isRunning}\n            className=\"flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors\"\n          >\n            {isRunning ? (\n              <>\n                <div className=\"animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full\" />\n                {t('spoolbuddy.diagnostic.runningBtn', 'Running...')}\n              </>\n            ) : hasRun ? (\n              <>\n                <RotateCw size={16} />\n                {t('spoolbuddy.diagnostic.runAgain', 'Run Again')}\n              </>\n            ) : (\n              <>\n                <Play size={16} />\n                {t('spoolbuddy.diagnostic.runBtn', 'Run Diagnostic')}\n              </>\n            )}\n          </button>\n          <button\n            onClick={onClose}\n            className=\"px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors\"\n          >\n            {t('spoolbuddy.diagnostic.close', 'Close')}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Check, AlertTriangle, RefreshCw } from 'lucide-react';\nimport type { InventorySpool } from '../../api/client';\nimport { spoolbuddyApi, api } from '../../api/client';\nimport { SpoolIcon } from './SpoolIcon';\n\nconst DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';\n\nfunction getDefaultCoreWeight(): number {\n  try {\n    const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);\n    if (stored) {\n      const weight = parseInt(stored, 10);\n      if (weight >= 0 && weight <= 500) return weight;\n    }\n  } catch {\n    // Ignore errors\n  }\n  return 250;\n}\n\ninterface InventorySpoolInfoCardProps {\n  spool: InventorySpool;\n  liveScaleWeight: number | null;\n  persistedGrossWeight?: number | null;\n  onClose?: () => void;\n  onSyncWeight?: () => void;\n  onAssignToAms?: () => void;\n  className?: string;\n}\n\nexport function InventorySpoolInfoCard({\n  spool,\n  liveScaleWeight,\n  persistedGrossWeight,\n  onClose,\n  onSyncWeight,\n  onAssignToAms,\n  className,\n}: InventorySpoolInfoCardProps) {\n  const { t } = useTranslation();\n  const [syncing, setSyncing] = useState(false);\n  const [synced, setSynced] = useState(false);\n  const [syncedGrossWeight, setSyncedGrossWeight] = useState<number | null>(null);\n\n  // Fetch k_profiles if not already present in the spool object\n  const { data: fetchedKProfiles } = useQuery({\n    queryKey: ['spool-k-profiles', spool.id],\n    queryFn: () => api.getSpoolKProfiles(spool.id),\n    // Inventory list payloads may omit k_profiles, so lazily fetch when missing.\n    enabled: !spool.k_profiles || spool.k_profiles.length === 0,\n    staleTime: 5 * 60 * 1000,\n  });\n\n  // Use fetched k_profiles if available, otherwise use the ones from the spool object\n  const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles;\n\n  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';\n\n  const coreWeight = (spool.core_weight && spool.core_weight > 0)\n    ? spool.core_weight\n    : getDefaultCoreWeight();\n\n  const grossWeightFromScale = liveScaleWeight !== null\n    ? Math.round(Math.max(0, liveScaleWeight))\n    : null;\n\n  // Inventory scenario: prefer the most recently synced value in this modal session.\n  const displayedGrossWeight = syncedGrossWeight ?? (\n    persistedGrossWeight !== undefined\n      ? (persistedGrossWeight !== null ? Math.round(Math.max(0, persistedGrossWeight)) : null)\n      : grossWeightFromScale\n  );\n\n  const inventoryRemaining = Math.round(Math.max(0,\n    (spool.label_weight || 0) - (spool.weight_used || 0)\n  ));\n\n  // Use live scale for remaining/fill only when scale has a meaningful reading.\n  const minDynamicScaleReading = 10;\n  const useDynamicRemaining = grossWeightFromScale !== null\n    && grossWeightFromScale >= minDynamicScaleReading;\n\n  const remaining = useDynamicRemaining\n    ? Math.round(Math.max(0, grossWeightFromScale - coreWeight))\n    : inventoryRemaining;\n\n  const labelWeight = Math.round(spool.label_weight || 1000);\n  const fillPercent = labelWeight > 0 ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;\n  const fillColor = fillPercent !== null\n    ? (fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444')\n    : '#808080';\n\n  const netWeight = Math.max(0,\n    (spool.label_weight || 0) - (spool.weight_used || 0)\n  );\n  const calculatedWeight = netWeight + coreWeight;\n  const difference = grossWeightFromScale !== null ? grossWeightFromScale - calculatedWeight : null;\n  const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;\n\n  // Inventory fallback so gross is always populated across spools.\n  const inventoryDerivedGrossWeight = Math.round(calculatedWeight);\n  const resolvedGrossWeight = displayedGrossWeight ?? inventoryDerivedGrossWeight;\n  const nozzleTempRange = (spool.nozzle_temp_min != null && spool.nozzle_temp_max != null)\n    ? `${spool.nozzle_temp_min}-${spool.nozzle_temp_max}\\u00B0C`\n    : null;\n  const slicerPreset = spool.slicer_filament_name || spool.slicer_filament || null;\n  const note = spool.note?.trim() || null;\n  const kFactorSummary = (kProfiles && kProfiles.length > 0)\n    ? Array.from(new Set(kProfiles.map(kp => kp.k_value.toFixed(3)))).join(', ')\n    : null;\n\n  const handleSyncWeight = async () => {\n    if (liveScaleWeight === null) return;\n    const roundedLiveWeight = Math.round(Math.max(0, liveScaleWeight));\n    setSyncing(true);\n    try {\n      await spoolbuddyApi.updateSpoolWeight(spool.id, roundedLiveWeight);\n      setSyncedGrossWeight(roundedLiveWeight);\n      setSynced(true);\n      onSyncWeight?.();\n      setTimeout(() => setSynced(false), 3000);\n    } catch (e) {\n      console.error('Failed to sync weight:', e);\n    } finally {\n      setSyncing(false);\n    }\n  };\n\n  return (\n    <div className={`flex flex-col items-center space-y-4 max-w-md ${className ?? ''}`}>\n      <div className=\"flex items-start gap-5\">\n        <div className=\"relative shrink-0\">\n          <SpoolIcon color={colorHex} isEmpty={false} size={100} />\n          {fillPercent !== null && (\n            <div\n              className=\"absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg\"\n              style={{ backgroundColor: fillColor }}\n            >\n              {fillPercent}%\n            </div>\n          )}\n        </div>\n\n        <div className=\"flex-1 min-w-0 pt-1\">\n          <h3 className=\"text-lg font-semibold text-zinc-100\">\n            {spool.color_name || 'Unknown color'}\n          </h3>\n          <p className=\"text-sm text-zinc-400\">\n            {spool.brand} &bull; {spool.material}\n            {spool.subtype && ` ${spool.subtype}`}\n          </p>\n\n          <div className=\"mt-3\">\n            <div className=\"flex items-baseline gap-2\">\n              <span className=\"text-3xl font-bold font-mono text-zinc-100\">{remaining}g</span>\n              <span className=\"text-sm text-zinc-500\">/ {labelWeight}g</span>\n            </div>\n            <p className=\"text-xs text-zinc-500 mt-0.5\">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>\n\n            <div className=\"mt-2 max-w-xs\">\n              <div className=\"h-2 bg-zinc-700 rounded-full overflow-hidden\">\n                <div\n                  className=\"h-full rounded-full transition-all duration-500\"\n                  style={{ width: `${fillPercent ?? 0}%`, backgroundColor: fillColor }}\n                />\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full\">\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>\n          <span className=\"font-mono text-zinc-300\">{resolvedGrossWeight}g</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>\n          <span className=\"font-mono text-zinc-300\">{coreWeight}g</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>\n          <span className=\"font-mono text-zinc-300\">{labelWeight}g</span>\n        </div>\n        <div className=\"flex justify-between items-center\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>\n          {grossWeightFromScale !== null ? (\n            <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>\n              {grossWeightFromScale}g\n              {isMatch ? (\n                <Check className=\"w-3.5 h-3.5\" />\n              ) : (\n                <>\n                  <AlertTriangle className=\"w-3.5 h-3.5\" />\n                  <button\n                    onClick={handleSyncWeight}\n                    className=\"p-1 hover:bg-green-500/20 rounded transition-colors text-green-500\"\n                    title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}\n                  >\n                    <RefreshCw className=\"w-4 h-4\" />\n                  </button>\n                </>\n              )}\n            </span>\n          ) : (\n            <span className=\"text-zinc-500\">{'\\u2014'}</span>\n          )}\n        </div>\n        <div className=\"flex justify-between items-center\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>\n          <span className=\"font-mono text-xs text-zinc-400 truncate max-w-[120px]\" title={spool.tag_uid || ''}>\n            {spool.tag_uid ? spool.tag_uid.slice(-8) : '\\u2014'}\n          </span>\n        </div>\n        {nozzleTempRange && (\n          <div className=\"flex justify-between items-center\">\n            <span className=\"text-zinc-500\">{t('spoolbuddy.inventory.nozzleTemp', 'Nozzle')}</span>\n            <span className=\"font-mono text-zinc-300\">{nozzleTempRange}</span>\n          </div>\n        )}\n        {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (\n          <div className=\"flex justify-between items-center\">\n            <span className=\"text-zinc-500\">{t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}</span>\n            <span className=\"font-mono text-zinc-300\">{spool.cost_per_kg.toFixed(2)}/kg</span>\n          </div>\n        )}\n        {kFactorSummary && (\n          <div className=\"flex justify-between items-center\">\n            <span className=\"text-zinc-500\">{t('spoolbuddy.inventory.kProfiles', 'K-Profile')}</span>\n            <span className=\"font-mono text-zinc-300 truncate max-w-[220px] text-right\" title={kFactorSummary}>{kFactorSummary}</span>\n          </div>\n        )}\n        {slicerPreset && (\n          <div className=\"min-w-0\">\n            <p className=\"text-xs text-zinc-500 mb-1\">{t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}</p>\n            <p className=\"text-sm text-zinc-300 whitespace-pre-wrap break-words\">{slicerPreset}</p>\n          </div>\n        )}\n        {note && (\n          <div className=\"col-span-2\">\n            <p className=\"text-xs text-zinc-500 mb-1\">{t('spoolbuddy.inventory.note', 'Note')}</p>\n            <p className=\"text-sm leading-5 text-zinc-300 whitespace-pre-wrap break-words max-h-[3.75rem] overflow-y-auto pr-1\">{note}</p>\n          </div>\n        )}\n      </div>\n\n      <div className=\"flex gap-2 justify-center\">\n        {onAssignToAms && (\n          <button\n            onClick={onAssignToAms}\n            className=\"px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}\n          </button>\n        )}\n        <button\n          onClick={handleSyncWeight}\n          disabled={liveScaleWeight === null || syncing}\n          className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${\n            synced\n              ? 'bg-green-600/20 text-green-400'\n              : onAssignToAms\n                ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'\n                : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'\n          }`}\n        >\n          {syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}\n        </button>\n        {onClose && (\n          <button\n            onClick={onClose}\n            className=\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.dashboard.close', 'Close')}\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/LinkSpoolModal.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { X } from 'lucide-react';\nimport type { InventorySpool } from '../../api/client';\nimport { SpoolIcon } from './SpoolIcon';\n\ninterface LinkSpoolModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  tagId: string;\n  untaggedSpools: InventorySpool[];\n  onLink: (spool: InventorySpool) => void;\n}\n\nexport function LinkSpoolModal({\n  isOpen,\n  onClose,\n  tagId,\n  untaggedSpools,\n  onLink,\n}: LinkSpoolModalProps) {\n  const { t } = useTranslation();\n  const [selectedSpool, setSelectedSpool] = useState<InventorySpool | null>(null);\n\n  const handleClose = useCallback(() => {\n    setSelectedSpool(null);\n    onClose();\n  }, [onClose]);\n\n  // Handle escape key\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    if (e.key === 'Escape') {\n      handleClose();\n    }\n  }, [handleClose]);\n\n  useEffect(() => {\n    if (isOpen) {\n      document.addEventListener('keydown', handleKeyDown);\n      document.body.style.overflow = 'hidden';\n    }\n    return () => {\n      document.removeEventListener('keydown', handleKeyDown);\n      document.body.style.overflow = '';\n    };\n  }, [isOpen, handleKeyDown]);\n\n  if (!isOpen) return null;\n\n  const handleConfirm = () => {\n    if (selectedSpool) {\n      onLink(selectedSpool);\n      setSelectedSpool(null);\n    }\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in\" onClick={handleClose}>\n      <div\n        className=\"bg-zinc-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 animate-slide-up\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-5 py-4 border-b border-zinc-700\">\n          <div>\n            <h2 className=\"text-base font-semibold text-zinc-100\">\n              {t('spoolbuddy.dashboard.linkTagTitle', 'Link Tag to Spool')}\n            </h2>\n            <p className=\"text-sm text-zinc-500 font-mono\">{tagId}</p>\n          </div>\n          <button\n            onClick={handleClose}\n            className=\"p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors\"\n          >\n            <X className=\"w-5 h-5\" />\n          </button>\n        </div>\n\n        {/* Content */}\n        <div className=\"px-5 py-4 space-y-3 max-h-[400px] overflow-y-auto\">\n          <p className=\"text-sm text-zinc-400\">\n            {t('spoolbuddy.dashboard.selectSpool', 'Select a spool to link this tag to:')}\n          </p>\n\n          {untaggedSpools.length === 0 ? (\n            <div className=\"text-center py-8 text-zinc-500\">\n              {t('spoolbuddy.dashboard.noUntagged', 'No spools without tags found')}\n            </div>\n          ) : (\n            <div className=\"space-y-2\">\n              {untaggedSpools.map((spool) => (\n                <button\n                  key={spool.id}\n                  type=\"button\"\n                  onClick={() => setSelectedSpool(spool)}\n                  className={`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-all text-left ${\n                    selectedSpool?.id === spool.id\n                      ? 'border-green-500 bg-green-500/10'\n                      : 'border-zinc-700 hover:border-green-500/50 hover:bg-zinc-700/50'\n                  }`}\n                >\n                  <SpoolIcon\n                    color={spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080'}\n                    isEmpty={false}\n                    size={40}\n                  />\n                  <div className=\"flex-1 min-w-0\">\n                    <div className=\"font-medium text-zinc-100 truncate\">\n                      {spool.color_name || 'Unknown color'}\n                    </div>\n                    <div className=\"text-sm text-zinc-400 truncate\">\n                      {spool.brand} &bull; {spool.material}\n                      {spool.subtype && ` ${spool.subtype}`}\n                    </div>\n                  </div>\n                  <div className=\"text-sm font-mono text-zinc-500\">\n                    {Math.max(0, spool.label_weight - spool.weight_used)}g\n                  </div>\n                </button>\n              ))}\n            </div>\n          )}\n        </div>\n\n        {/* Footer */}\n        <div className=\"flex justify-end gap-2 px-5 py-4 border-t border-zinc-700\">\n          <button\n            onClick={handleClose}\n            className=\"px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n          >\n            {t('common.cancel', 'Cancel')}\n          </button>\n          <button\n            onClick={handleConfirm}\n            disabled={!selectedSpool}\n            className=\"px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.dashboard.linkTag', 'Link Tag')}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx",
    "content": "import { NavLink } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\n\nconst navItems = [\n  {\n    to: '/spoolbuddy',\n    labelKey: 'spoolbuddy.nav.dashboard',\n    fallback: 'Dashboard',\n    icon: (\n      <svg className=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6\" />\n      </svg>\n    ),\n  },\n  {\n    to: '/spoolbuddy/ams',\n    labelKey: 'spoolbuddy.nav.ams',\n    fallback: 'AMS',\n    icon: (\n      <svg className=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10\" />\n      </svg>\n    ),\n  },\n  {\n    to: '/spoolbuddy/write-tag',\n    labelKey: 'spoolbuddy.nav.writeTag',\n    fallback: 'Write',\n    icon: (\n      <svg className=\"w-6 h-6\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2}>\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0\" />\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z\" />\n      </svg>\n    ),\n  },\n  {\n    to: '/spoolbuddy/inventory',\n    labelKey: 'spoolbuddy.nav.inventory',\n    fallback: 'Inventory',\n    icon: (\n      <svg className=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4\" />\n      </svg>\n    ),\n  },\n  {\n    to: '/spoolbuddy/settings',\n    labelKey: 'spoolbuddy.nav.settings',\n    fallback: 'Settings',\n    icon: (\n      <svg className=\"w-6 h-6\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\" />\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\n      </svg>\n    ),\n  },\n];\n\nexport function SpoolBuddyBottomNav() {\n  const { t } = useTranslation();\n\n  return (\n    <nav className=\"h-14 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-stretch shrink-0\">\n      {navItems.map((item) => (\n        <NavLink\n          key={item.to}\n          to={item.to}\n          end={item.to === '/spoolbuddy'}\n          className={({ isActive }) =>\n            `flex-1 flex flex-col items-center justify-center gap-1 transition-colors ${\n              isActive\n                ? 'text-bambu-green bg-bambu-dark'\n                : 'text-white/50 hover:text-white/70 hover:bg-bambu-dark-tertiary'\n            }`\n          }\n        >\n          {item.icon}\n          <span className=\"text-xs font-medium\">{t(item.labelKey, item.fallback)}</span>\n        </NavLink>\n      ))}\n    </nav>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx",
    "content": "import { useState, useEffect, useRef, useCallback, useMemo } from 'react';\nimport { Outlet, useNavigate, useLocation } from 'react-router-dom';\nimport { useQuery, useQueries } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { SpoolBuddyTopBar } from './SpoolBuddyTopBar';\nimport { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';\nimport { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';\nimport { SpoolBuddyQuickMenu } from './SpoolBuddyQuickMenu';\nimport { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';\nimport { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';\nimport { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';\nimport { VirtualKeyboard } from '../VirtualKeyboard';\n\nexport function SpoolBuddyLayout() {\n  // Cascade a re-render into all SpoolBuddy pages when the color catalog\n  // loads, for the same reason as the main Layout — SpoolBuddyInventoryPage\n  // renders spool color names on mount. See #857.\n  useColorCatalogVersion();\n  const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);\n  const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);\n  const [displayBrightness, setDisplayBrightness] = useState(100);\n  const { i18n } = useTranslation();\n  const navigate = useNavigate();\n  const location = useLocation();\n  const sbState = useSpoolBuddyState();\n\n  // Sync language from backend settings (kiosk has its own browser with empty localStorage)\n  const { data: appSettings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n  useEffect(() => {\n    if (appSettings?.language && appSettings.language !== i18n.language) {\n      i18n.changeLanguage(appSettings.language);\n    }\n  }, [appSettings?.language, i18n]);\n\n  // Query device data to initialize display settings on any page\n  const { data: devices = [] } = useQuery({\n    queryKey: ['spoolbuddy-devices'],\n    queryFn: () => spoolbuddyApi.getDevices(),\n    refetchInterval: 30000,\n  });\n  const device = devices[0];\n  const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);\n  const sbStateForUi = useMemo(\n    () => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),\n    [sbState, effectiveDeviceOnline]\n  );\n\n  // Sync display settings from device on initial load\n  const initializedRef = useRef(false);\n  useEffect(() => {\n    if (device && !initializedRef.current) {\n      setDisplayBrightness(device.display_brightness);\n      initializedRef.current = true;\n    }\n  }, [device]);\n\n  // Force dark theme on mount, restore on unmount\n  useEffect(() => {\n    const root = document.documentElement;\n    const hadDark = root.classList.contains('dark');\n    root.classList.add('dark');\n    return () => {\n      if (!hadDark) root.classList.remove('dark');\n    };\n  }, []);\n\n  // Auto-check for SpoolBuddy daemon updates\n  const { data: updateCheck } = useQuery({\n    queryKey: ['spoolbuddy-update-check', device?.device_id],\n    queryFn: () => device ? spoolbuddyApi.checkDaemonUpdate(device.device_id) : Promise.resolve(null),\n    enabled: !!device,\n    refetchInterval: 5 * 60 * 1000, // re-check every 5 minutes\n    staleTime: 0,\n  });\n\n  // Update alert based on device state and available updates.\n  // Only clear alerts that the layout itself set (not alerts from child pages).\n  const layoutAlertRef = useRef<string | null>(null);\n  useEffect(() => {\n    if (!effectiveDeviceOnline) {\n      const msg = 'SpoolBuddy device disconnected';\n      setAlert({ type: 'warning', message: msg });\n      layoutAlertRef.current = msg;\n    } else if (updateCheck?.update_available && updateCheck.latest_version) {\n      const msg = `Update available: v${updateCheck.latest_version}`;\n      setAlert({ type: 'info', message: msg });\n      layoutAlertRef.current = msg;\n    } else if (layoutAlertRef.current) {\n      setAlert(null);\n      layoutAlertRef.current = null;\n    }\n  }, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);\n\n  // Auto-navigate to dashboard when a NEW tag is detected (transition from no-tag to tag).\n  // Blanking itself is handled by swayidle/wlopm at the OS level on the kiosk device —\n  // when the HDMI output powers off and the user taps the screen, labwc delivers the\n  // input event to swayidle's `resume` command which re-powers HDMI. See issue #937.\n  const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid);\n  const prevTagDetected = useRef(false);\n  useEffect(() => {\n    if (tagDetected && !prevTagDetected.current) {\n      if (location.pathname !== '/spoolbuddy') {\n        navigate('/spoolbuddy');\n      }\n    }\n    prevTagDetected.current = tagDetected;\n  }, [tagDetected, location.pathname, navigate]);\n\n  // Online printers list for swipe-to-switch\n  const { data: printers = [] } = useQuery({\n    queryKey: ['printers'],\n    queryFn: () => api.getPrinters(),\n  });\n  const statusQueries = useQueries({\n    queries: printers.map((printer: Printer) => ({\n      queryKey: ['printerStatus', printer.id],\n      queryFn: () => api.getPrinterStatus(printer.id),\n      refetchInterval: 10000,\n      select: (data: PrinterStatus) => ({ connected: data?.connected }),\n    })),\n  });\n  const onlinePrinters = useMemo(() => {\n    return printers.filter((_: Printer, i: number) => statusQueries[i]?.data?.connected);\n  }, [printers, statusQueries]);\n\n  // Swipe left/right to cycle through online printers\n  const touchStartRef = useRef<{ x: number; y: number } | null>(null);\n  const swipeLockedRef = useRef(false);\n  const SWIPE_THRESHOLD = 50;\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  const handleTouchStart = useCallback((e: React.TouchEvent) => {\n    touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };\n    swipeLockedRef.current = false;\n  }, []);\n  const handleTouchEnd = useCallback((e: React.TouchEvent) => {\n    if (!touchStartRef.current) return;\n    const dx = e.changedTouches[0].clientX - touchStartRef.current.x;\n    const dy = e.changedTouches[0].clientY - touchStartRef.current.y;\n    const startY = touchStartRef.current.y;\n    touchStartRef.current = null;\n    swipeLockedRef.current = false;\n\n    // Vertical swipe down from top area → open quick menu\n    // Top bar is 48px; allow starting swipe up to 120px from top to account for finger size\n    if (dy >= SWIPE_THRESHOLD && Math.abs(dy) > Math.abs(dx) && startY < 120) {\n      setQuickMenuOpen(true);\n      return;\n    }\n\n    // Horizontal swipe: cycle printers\n    if (onlinePrinters.length < 2) return;\n    if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;\n    const currentIdx = onlinePrinters.findIndex((p: Printer) => p.id === selectedPrinterId);\n    const nextIdx = dx < 0\n      ? (currentIdx + 1) % onlinePrinters.length          // swipe left → next\n      : (currentIdx - 1 + onlinePrinters.length) % onlinePrinters.length; // swipe right → prev\n    setSelectedPrinterId(onlinePrinters[nextIdx].id);\n  }, [onlinePrinters, selectedPrinterId, setSelectedPrinterId]);\n\n  // Block browser back/forward swipe gesture with non-passive touchmove listener\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const onTouchMove = (e: TouchEvent) => {\n      if (!touchStartRef.current) return;\n      const dx = Math.abs(e.touches[0].clientX - touchStartRef.current.x);\n      const dy = Math.abs(e.touches[0].clientY - touchStartRef.current.y);\n      // Once locked as horizontal, prevent default for the rest of this gesture\n      if (swipeLockedRef.current) { e.preventDefault(); return; }\n      if (dx > 10 && dx > dy) { swipeLockedRef.current = true; e.preventDefault(); }\n    };\n    el.addEventListener('touchmove', onTouchMove, { passive: false });\n    return () => el.removeEventListener('touchmove', onTouchMove);\n  }, []);\n\n  // Track virtual keyboard visibility to hide bottom bars\n  const [keyboardVisible, setKeyboardVisible] = useState(false);\n\n  // Quick menu (swipe down to open)\n  const [quickMenuOpen, setQuickMenuOpen] = useState(false);\n\n  // CSS brightness filter (software dimming)\n  const brightnessStyle = displayBrightness < 100\n    ? { filter: `brightness(${displayBrightness / 100})` } as const\n    : undefined;\n\n  return (\n    <>\n      <div\n        ref={rootRef}\n        data-spoolbuddy-kiosk\n        className=\"w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden\"\n        style={{ ...brightnessStyle, overscrollBehaviorX: 'none' }}\n        onTouchStart={handleTouchStart}\n        onTouchEnd={handleTouchEnd}\n      >\n        {/* Pull-down handle — tap or swipe to open quick menu */}\n        <button\n          onClick={() => setQuickMenuOpen(true)}\n          className=\"w-full h-1.5 bg-bambu-dark-secondary flex justify-center items-center shrink-0 touch-none\"\n          aria-label=\"Open quick menu\"\n        >\n          <div className=\"w-8 h-0.5 rounded-full bg-zinc-600\" />\n        </button>\n\n        <SpoolBuddyTopBar\n          selectedPrinterId={selectedPrinterId}\n          onPrinterChange={setSelectedPrinterId}\n          deviceOnline={effectiveDeviceOnline}\n        />\n\n        <main className=\"flex-1 overflow-y-auto\">\n          <Outlet context={{\n            selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,\n            displayBrightness, setDisplayBrightness,\n          }} />\n        </main>\n\n        {!keyboardVisible && <SpoolBuddyStatusBar alert={alert} />}\n        {!keyboardVisible && <SpoolBuddyBottomNav />}\n        <VirtualKeyboard onVisibilityChange={setKeyboardVisible} />\n      </div>\n\n      {/* Quick menu (swipe down from top) */}\n      <SpoolBuddyQuickMenu\n        isOpen={quickMenuOpen}\n        onClose={() => setQuickMenuOpen(false)}\n        deviceId={device?.device_id ?? null}\n        deviceOnline={effectiveDeviceOnline}\n      />\n    </>\n  );\n}\n\n// Hook for child pages to access shared context\nexport interface SpoolBuddyOutletContext {\n  selectedPrinterId: number | null;\n  setSelectedPrinterId: (id: number) => void;\n  sbState: ReturnType<typeof useSpoolBuddyState>;\n  setAlert: (alert: { type: 'warning' | 'error' | 'info'; message: string } | null) => void;\n  displayBrightness: number;\n  setDisplayBrightness: (brightness: number) => void;\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/SpoolBuddyQuickMenu.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { PowerOff, RotateCw, Monitor, ChevronDown, Loader2 } from 'lucide-react';\nimport { api, spoolbuddyApi, type Printer, type SmartPlug, type SmartPlugStatus } from '../../api/client';\n\ninterface SpoolBuddyQuickMenuProps {\n  isOpen: boolean;\n  onClose: () => void;\n  deviceId: string | null;\n  deviceOnline: boolean;\n}\n\ntype SystemCommand = 'reboot' | 'shutdown' | 'restart_daemon' | 'restart_browser';\n\ntype PendingConfirm =\n  | { type: 'system'; command: SystemCommand }\n  | { type: 'plug'; plug: SmartPlug; printer: Printer; currentState: string | null };\n\ninterface PlugState {\n  plug: SmartPlug;\n  printer: Printer;\n  status: SmartPlugStatus | null;\n  loading: boolean;\n}\n\nexport function SpoolBuddyQuickMenu({ isOpen, onClose, deviceId, deviceOnline }: SpoolBuddyQuickMenuProps) {\n  const { t } = useTranslation();\n  const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm | null>(null);\n  const [commandBusy, setCommandBusy] = useState(false);\n  const [plugStates, setPlugStates] = useState<Map<number, { loading: boolean; state: string | null }>>(new Map());\n\n  // Fetch printers and smart plugs\n  const { data: printers = [] } = useQuery({\n    queryKey: ['printers'],\n    queryFn: () => api.getPrinters(),\n    enabled: isOpen,\n  });\n\n  const { data: smartPlugs = [] } = useQuery({\n    queryKey: ['smart-plugs'],\n    queryFn: () => api.getSmartPlugs(),\n    enabled: isOpen,\n  });\n\n  // Build printer-plug pairs (only main power plugs linked to printers)\n  const printerPlugs: PlugState[] = printers\n    .map((printer) => {\n      const plug = smartPlugs.find(\n        (p) => p.printer_id === printer.id && p.plug_type !== 'mqtt' && p.enabled\n      );\n      if (!plug) return null;\n      const state = plugStates.get(plug.id);\n      return {\n        plug,\n        printer,\n        status: state ? { state: state.state, reachable: true, device_name: null, energy: null } : null,\n        loading: state?.loading ?? false,\n      };\n    })\n    .filter(Boolean) as PlugState[];\n\n  // Fetch plug statuses when menu opens\n  useEffect(() => {\n    if (!isOpen || smartPlugs.length === 0) return;\n\n    const linkedPlugs = smartPlugs.filter(\n      (p) => p.printer_id !== null && p.plug_type !== 'mqtt' && p.enabled\n    );\n\n    linkedPlugs.forEach(async (plug) => {\n      try {\n        const status = await api.getSmartPlugStatus(plug.id);\n        setPlugStates((prev) => {\n          const next = new Map(prev);\n          next.set(plug.id, { loading: false, state: status.state });\n          return next;\n        });\n      } catch {\n        setPlugStates((prev) => {\n          const next = new Map(prev);\n          next.set(plug.id, { loading: false, state: null });\n          return next;\n        });\n      }\n    });\n  }, [isOpen, smartPlugs]);\n\n  // Clear state when menu closes\n  useEffect(() => {\n    if (!isOpen) {\n      setPendingConfirm(null);\n      setCommandBusy(false);\n    }\n  }, [isOpen]);\n\n  const handleTogglePlug = useCallback(async (plug: SmartPlug) => {\n    setPlugStates((prev) => {\n      const next = new Map(prev);\n      const current = next.get(plug.id);\n      next.set(plug.id, { loading: true, state: current?.state ?? null });\n      return next;\n    });\n\n    try {\n      await api.controlSmartPlug(plug.id, 'toggle');\n      const status = await api.getSmartPlugStatus(plug.id);\n      setPlugStates((prev) => {\n        const next = new Map(prev);\n        next.set(plug.id, { loading: false, state: status.state });\n        return next;\n      });\n    } catch {\n      setPlugStates((prev) => {\n        const next = new Map(prev);\n        const current = next.get(plug.id);\n        next.set(plug.id, { loading: false, state: current?.state ?? null });\n        return next;\n      });\n    }\n  }, []);\n\n  const handleSystemCommand = useCallback(async (command: SystemCommand) => {\n    if (!deviceId) return;\n    setCommandBusy(true);\n    try {\n      await spoolbuddyApi.systemCommand(deviceId, command);\n      // Close menu after successful command\n      setTimeout(() => onClose(), 500);\n    } catch {\n      setCommandBusy(false);\n    }\n  }, [deviceId, onClose]);\n\n  const executeConfirmed = useCallback(() => {\n    if (!pendingConfirm) return;\n    if (pendingConfirm.type === 'system') {\n      handleSystemCommand(pendingConfirm.command);\n    } else {\n      handleTogglePlug(pendingConfirm.plug);\n    }\n    setPendingConfirm(null);\n  }, [pendingConfirm, handleSystemCommand, handleTogglePlug]);\n\n  if (!isOpen) return null;\n\n  const isPlugOn = (state: string | null) => state === 'ON' || state === 'on';\n\n  return (\n    <>\n      {/* Backdrop */}\n      <div className=\"fixed inset-0 z-40 bg-black/50\" onPointerDown={onClose} />\n\n      {/* Slide-down panel */}\n      <div className=\"fixed top-0 left-0 right-0 z-50 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary rounded-b-2xl shadow-2xl animate-slide-down\">\n        {/* Handle bar */}\n        <div className=\"flex justify-center pt-2 pb-1\">\n          <div className=\"w-10 h-1 rounded-full bg-zinc-600\" />\n        </div>\n\n        <div className=\"px-4 pb-4 max-h-[80vh] overflow-y-auto\">\n          {/* Printer Power Section */}\n          {printerPlugs.length > 0 && (\n            <div className=\"mb-4\">\n              <h3 className=\"text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2\">\n                {t('spoolbuddy.quickMenu.printerPower', 'Printer Power')}\n              </h3>\n              <div className=\"space-y-1\">\n                {printerPlugs.map(({ plug, printer, loading }) => {\n                  const state = plugStates.get(plug.id);\n                  const on = isPlugOn(state?.state ?? null);\n                  return (\n                    <button\n                      key={plug.id}\n                      onClick={() => setPendingConfirm({ type: 'plug', plug, printer, currentState: state?.state ?? null })}\n                      disabled={loading}\n                      className=\"w-full flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-800/60 hover:bg-zinc-700/60 transition-colors min-h-[36px]\"\n                    >\n                      <div className={`w-2 h-2 rounded-full shrink-0 ${on ? 'bg-green-500' : 'bg-zinc-600'}`} />\n                      {loading && <Loader2 className=\"w-3 h-3 animate-spin text-zinc-400 shrink-0\" />}\n                      <span className=\"flex-1 text-sm text-zinc-200 text-left truncate\">{printer.name}</span>\n                      <span className={`text-xs font-medium ${on ? 'text-green-400' : 'text-zinc-500'}`}>\n                        {state?.state ?? '—'}\n                      </span>\n                    </button>\n                  );\n                })}\n              </div>\n            </div>\n          )}\n\n          {/* System Controls Section */}\n          <div>\n            <h3 className=\"text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2\">\n              {t('spoolbuddy.quickMenu.systemControls', 'System')}\n            </h3>\n            <div className=\"grid grid-cols-2 gap-2\">\n              <SystemButton\n                icon={<RotateCw className=\"w-4 h-4\" />}\n                label={t('spoolbuddy.quickMenu.restartDaemon', 'Restart Daemon')}\n                onClick={() => setPendingConfirm({ type: 'system', command: 'restart_daemon' })}\n                disabled={!deviceId || !deviceOnline || commandBusy}\n              />\n              <SystemButton\n                icon={<Monitor className=\"w-4 h-4\" />}\n                label={t('spoolbuddy.quickMenu.restartBrowser', 'Restart Browser')}\n                onClick={() => setPendingConfirm({ type: 'system', command: 'restart_browser' })}\n                disabled={!deviceId || !deviceOnline || commandBusy}\n              />\n              <SystemButton\n                icon={<RotateCw className=\"w-4 h-4\" />}\n                label={t('spoolbuddy.quickMenu.reboot', 'Reboot')}\n                onClick={() => setPendingConfirm({ type: 'system', command: 'reboot' })}\n                disabled={!deviceId || !deviceOnline || commandBusy}\n                variant=\"warning\"\n              />\n              <SystemButton\n                icon={<PowerOff className=\"w-4 h-4\" />}\n                label={t('spoolbuddy.quickMenu.shutdown', 'Shutdown')}\n                onClick={() => setPendingConfirm({ type: 'system', command: 'shutdown' })}\n                disabled={!deviceId || !deviceOnline || commandBusy}\n                variant=\"danger\"\n              />\n            </div>\n          </div>\n\n          {/* Swipe hint */}\n          <div className=\"flex justify-center mt-3\">\n            <div className=\"flex items-center gap-1 text-xs text-zinc-600\">\n              <ChevronDown className=\"w-3 h-3\" />\n              <span>{t('spoolbuddy.quickMenu.swipeToClose', 'Swipe down to close')}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Confirmation Dialog */}\n      {pendingConfirm && (\n        <div className=\"fixed inset-0 z-[60] flex items-center justify-center bg-black/60\">\n          <div className=\"bg-zinc-800 rounded-2xl p-5 mx-4 max-w-sm w-full border border-zinc-700\">\n            <h3 className=\"text-lg font-semibold text-zinc-100 mb-2\">\n              {t('spoolbuddy.quickMenu.confirmTitle', 'Confirm')}\n            </h3>\n            <p className=\"text-sm text-zinc-400 mb-5\">\n              {pendingConfirm.type === 'plug'\n                ? (isPlugOn(pendingConfirm.currentState)\n                    ? t('spoolbuddy.quickMenu.confirmPlugOff', 'Turn off {{name}}?', { name: pendingConfirm.printer.name })\n                    : t('spoolbuddy.quickMenu.confirmPlugOn', 'Turn on {{name}}?', { name: pendingConfirm.printer.name }))\n                : pendingConfirm.command === 'shutdown'\n                  ? t('spoolbuddy.quickMenu.confirmShutdown', 'Are you sure you want to shut down the SpoolBuddy? You will need physical access to turn it back on.')\n                  : pendingConfirm.command === 'reboot'\n                    ? t('spoolbuddy.quickMenu.confirmReboot', 'Are you sure you want to reboot the SpoolBuddy?')\n                    : pendingConfirm.command === 'restart_daemon'\n                      ? t('spoolbuddy.quickMenu.confirmRestartDaemon', 'Restart the SpoolBuddy daemon? NFC and scale will be temporarily unavailable.')\n                      : t('spoolbuddy.quickMenu.confirmRestartBrowser', 'Restart the kiosk browser? The display will briefly go blank.')}\n            </p>\n            <div className=\"flex gap-3\">\n              <button\n                onClick={() => setPendingConfirm(null)}\n                className=\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n              >\n                {t('common.cancel', 'Cancel')}\n              </button>\n              <button\n                onClick={executeConfirmed}\n                disabled={commandBusy}\n                className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white transition-colors min-h-[44px] ${\n                  pendingConfirm.type === 'plug'\n                    ? (isPlugOn(pendingConfirm.currentState) ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700')\n                    : pendingConfirm.command === 'shutdown' ? 'bg-red-600 hover:bg-red-700'\n                    : pendingConfirm.command === 'reboot' ? 'bg-amber-600 hover:bg-amber-700'\n                    : 'bg-blue-600 hover:bg-blue-700'\n                } disabled:opacity-50`}\n              >\n                {commandBusy ? <Loader2 className=\"w-4 h-4 animate-spin mx-auto\" /> :\n                  pendingConfirm.type === 'plug'\n                    ? (isPlugOn(pendingConfirm.currentState)\n                        ? t('spoolbuddy.quickMenu.turnOff', 'Turn Off')\n                        : t('spoolbuddy.quickMenu.turnOn', 'Turn On'))\n                    : t('spoolbuddy.quickMenu.confirm', 'Confirm')}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  );\n}\n\nfunction SystemButton({\n  icon,\n  label,\n  onClick,\n  disabled,\n  variant = 'default',\n}: {\n  icon: React.ReactNode;\n  label: string;\n  onClick: () => void;\n  disabled: boolean;\n  variant?: 'default' | 'warning' | 'danger';\n}) {\n  const variantClasses = {\n    default: 'bg-zinc-800/60 hover:bg-zinc-700/60 text-zinc-300',\n    warning: 'bg-amber-900/30 hover:bg-amber-900/50 text-amber-400',\n    danger: 'bg-red-900/30 hover:bg-red-900/50 text-red-400',\n  };\n\n  return (\n    <button\n      onClick={onClick}\n      disabled={disabled}\n      className={`flex items-center gap-2.5 p-3 rounded-xl transition-colors min-h-[48px] disabled:opacity-40 ${variantClasses[variant]}`}\n    >\n      {icon}\n      <span className=\"text-sm font-medium\">{label}</span>\n    </button>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/SpoolBuddyStatusBar.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\ninterface Alert {\n  type: 'warning' | 'error' | 'info';\n  message: string;\n}\n\ninterface SpoolBuddyStatusBarProps {\n  alert?: Alert | null;\n}\n\nexport function SpoolBuddyStatusBar({ alert }: SpoolBuddyStatusBarProps) {\n  const { t } = useTranslation();\n\n  const statusColor = !alert\n    ? 'bg-bambu-green'\n    : alert.type === 'error'\n    ? 'bg-red-500'\n    : alert.type === 'warning'\n    ? 'bg-amber-500'\n    : 'bg-bambu-green';\n\n  const borderColor = !alert\n    ? 'border-bambu-dark-tertiary'\n    : alert.type === 'error'\n    ? 'border-red-500'\n    : alert.type === 'warning'\n    ? 'border-amber-500'\n    : 'border-bambu-dark-tertiary';\n\n  return (\n    <div className={`h-9 bg-bambu-dark-secondary border-t-2 ${borderColor} flex items-center px-3 gap-3 shrink-0`}>\n      {/* Status LED */}\n      <div className={`w-3.5 h-3.5 rounded-full ${statusColor}`} />\n\n      {/* Status message */}\n      <div className=\"flex-1 text-sm text-white/50 truncate\">\n        {alert ? (\n          <span>{alert.message}</span>\n        ) : (\n          <span className=\"text-bambu-green\">{t('spoolbuddy.status.systemReady', 'System Ready')}</span>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx",
    "content": "import { useState, useEffect, useMemo } from 'react';\nimport { useQuery, useQueries } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { WifiOff } from 'lucide-react';\nimport { api, type Printer } from '../../api/client';\nimport { formatTimeOnly } from '../../utils/date';\n\ninterface SpoolBuddyTopBarProps {\n  selectedPrinterId: number | null;\n  onPrinterChange: (id: number) => void;\n  deviceOnline: boolean;\n}\n\nexport function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnline }: SpoolBuddyTopBarProps) {\n  const { t } = useTranslation();\n  const [currentTime, setCurrentTime] = useState(new Date());\n\n  const { data: printers = [] } = useQuery({\n    queryKey: ['printers'],\n    queryFn: () => api.getPrinters(),\n  });\n\n  // Fetch status for each printer to determine which are online\n  const statusQueries = useQueries({\n    queries: printers.map((printer: Printer) => ({\n      queryKey: ['printerStatus', printer.id],\n      queryFn: () => api.getPrinterStatus(printer.id),\n      refetchInterval: 10000,\n    })),\n  });\n\n  const onlinePrinters = useMemo(() => {\n    return printers.filter((_: Printer, i: number) => statusQueries[i]?.data?.connected);\n  }, [printers, statusQueries]);\n\n  // Auto-select first online printer\n  useEffect(() => {\n    const currentStillOnline = onlinePrinters.some((p: Printer) => p.id === selectedPrinterId);\n    if ((!selectedPrinterId || !currentStillOnline) && onlinePrinters.length > 0) {\n      onPrinterChange(onlinePrinters[0].id);\n    }\n  }, [onlinePrinters, selectedPrinterId, onPrinterChange]);\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  // Clock - update every second for kiosk display\n  useEffect(() => {\n    const timer = setInterval(() => setCurrentTime(new Date()), 1000);\n    return () => clearInterval(timer);\n  }, []);\n\n  return (\n    <div className=\"h-12 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0\">\n      {/* Logo */}\n      <div className=\"flex items-center shrink-0\">\n        <img src=\"/img/spoolbuddy_logo_dark_small.png\" alt=\"SpoolBuddy\" width={113} height={28} className=\"h-7 w-auto\" />\n      </div>\n\n      {/* Printer selector - centered */}\n      <div className=\"flex-1 flex justify-center\">\n        <select\n          value={selectedPrinterId ?? ''}\n          onChange={(e) => onPrinterChange(Number(e.target.value))}\n          className=\"bg-bambu-dark text-white text-base px-4 py-2 rounded border border-bambu-dark-tertiary focus:outline-none focus:border-bambu-green min-w-[180px]\"\n        >\n          {onlinePrinters.length === 0 ? (\n            <option value=\"\">{t('spoolbuddy.status.noPrinters', 'No printers online')}</option>\n          ) : (\n            onlinePrinters.map((printer: Printer) => (\n              <option key={printer.id} value={printer.id}>\n                {printer.name}\n              </option>\n            ))\n          )}\n        </select>\n      </div>\n\n      {/* Right side indicators */}\n      <div className=\"flex items-center gap-3 shrink-0\">\n        {/* WiFi signal bars */}\n        <div className=\"flex items-center\" title={deviceOnline ? t('spoolbuddy.status.backend', 'Backend') : t('spoolbuddy.status.offline', 'Offline')}>\n          {deviceOnline ? (\n            <div className=\"flex items-end gap-0.5 h-4\">\n              {[1, 2, 3, 4].map((level) => (\n                <div\n                  key={level}\n                  className={`w-1 rounded-sm ${level <= 4 ? 'bg-white' : 'bg-bambu-dark-tertiary'}`}\n                  style={{ height: `${level * 4}px` }}\n                />\n              ))}\n            </div>\n          ) : (\n            <WifiOff className=\"w-5 h-5 text-red-400\" />\n          )}\n        </div>\n\n        {/* Device LED */}\n        <div className=\"flex items-center gap-1.5\">\n          <div className={`w-3 h-3 rounded-full ${deviceOnline ? 'bg-bambu-green shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-bambu-gray'}`} />\n          <span className=\"text-sm text-white/50\">{deviceOnline ? t('spoolbuddy.status.backend', 'Backend') : t('spoolbuddy.status.offline', 'Offline')}</span>\n        </div>\n\n        {/* Clock */}\n        <span className=\"text-white/50 text-base font-mono min-w-[50px] text-right\">\n          {formatTimeOnly(currentTime, settings?.time_format || 'system')}\n        </span>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/SpoolIcon.tsx",
    "content": "interface SpoolIconProps {\n  color: string;\n  isEmpty: boolean;\n  size?: number;\n}\n\nexport function SpoolIcon({ color, isEmpty, size = 32 }: SpoolIconProps) {\n  if (isEmpty) {\n    return (\n      <div\n        className=\"rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center\"\n        style={{ width: size, height: size }}\n      >\n        <div className=\"w-2 h-2 rounded-full bg-zinc-600\" />\n      </div>\n    );\n  }\n\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 32 32\">\n      {/* Outer ring with white stroke for visibility */}\n      <circle cx=\"16\" cy=\"16\" r=\"14\" fill={color} stroke=\"white\" strokeWidth=\"1.5\" strokeOpacity=\"0.7\" />\n      {/* Inner shadow/depth */}\n      <circle cx=\"16\" cy=\"16\" r=\"11\" fill={color} style={{ filter: 'brightness(0.85)' }} />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/SpoolInfoCard.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Check, AlertTriangle, RefreshCw } from 'lucide-react';\nimport type { MatchedSpool } from '../../hooks/useSpoolBuddyState';\nimport { spoolbuddyApi } from '../../api/client';\nimport { SpoolIcon } from './SpoolIcon';\n\n// Storage key for default core weight\nconst DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';\n\nfunction getDefaultCoreWeight(): number {\n  try {\n    const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);\n    if (stored) {\n      const weight = parseInt(stored, 10);\n      if (weight >= 0 && weight <= 500) return weight;\n    }\n  } catch {\n    // Ignore errors\n  }\n  return 250; // Default 250g (typical Bambu spool core)\n}\n\ninterface SpoolInfoCardProps {\n  spool: MatchedSpool;\n  scaleWeight: number | null;\n  onClose?: () => void;\n  onSyncWeight?: () => void;\n  onAssignToAms?: () => void;\n}\n\nexport function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms }: SpoolInfoCardProps) {\n  const { t } = useTranslation();\n  const [syncing, setSyncing] = useState(false);\n  const [synced, setSynced] = useState(false);\n\n  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';\n\n  // Use spool's core_weight if set, otherwise fall back to default\n  const coreWeight = (spool.core_weight && spool.core_weight > 0)\n    ? spool.core_weight\n    : getDefaultCoreWeight();\n\n  // Gross weight from scale (live) or fallback\n  const grossWeight = scaleWeight !== null\n    ? Math.round(Math.max(0, scaleWeight))\n    : null;\n\n  // Remaining filament = gross - core\n  const remaining = grossWeight !== null\n    ? Math.round(Math.max(0, grossWeight - coreWeight))\n    : null;\n\n  const labelWeight = Math.round(spool.label_weight || 1000);\n  const fillPercent = remaining !== null ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;\n  const fillColor = fillPercent !== null\n    ? fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444'\n    : '#808080';\n\n  // Weight comparison (scale vs calculated expected)\n  const netWeight = Math.max(0,\n    (spool.label_weight || 0) - (spool.weight_used || 0)\n  );\n  const calculatedWeight = netWeight + coreWeight;\n  const difference = grossWeight !== null ? grossWeight - calculatedWeight : null;\n  const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;\n\n  const handleSyncWeight = async () => {\n    if (scaleWeight === null) return;\n    setSyncing(true);\n    try {\n      await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));\n      setSynced(true);\n      onSyncWeight?.();\n      setTimeout(() => setSynced(false), 3000);\n    } catch (e) {\n      console.error('Failed to sync weight:', e);\n    } finally {\n      setSyncing(false);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col items-center space-y-4 max-w-md\">\n      {/* Top section: Spool icon + main info */}\n      <div className=\"flex items-start gap-5\">\n        {/* Spool visualization */}\n        <div className=\"relative shrink-0\">\n          <SpoolIcon color={colorHex} isEmpty={false} size={100} />\n          {fillPercent !== null && (\n            <div\n              className=\"absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg\"\n              style={{ backgroundColor: fillColor }}\n            >\n              {fillPercent}%\n            </div>\n          )}\n        </div>\n\n        {/* Main info */}\n        <div className=\"flex-1 min-w-0 pt-1\">\n          <h3 className=\"text-lg font-semibold text-zinc-100\">\n            {spool.color_name || 'Unknown color'}\n          </h3>\n          <p className=\"text-sm text-zinc-400\">\n            {spool.brand} &bull; {spool.material}\n            {spool.subtype && ` ${spool.subtype}`}\n          </p>\n\n          {/* Filament remaining - big number */}\n          {remaining !== null && (\n            <div className=\"mt-3\">\n              <div className=\"flex items-baseline gap-2\">\n                <span className=\"text-3xl font-bold font-mono text-zinc-100\">{remaining}g</span>\n                <span className=\"text-sm text-zinc-500\">/ {labelWeight}g</span>\n              </div>\n              <p className=\"text-xs text-zinc-500 mt-0.5\">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>\n\n              {/* Fill bar */}\n              <div className=\"mt-2 max-w-xs\">\n                <div className=\"h-2 bg-zinc-700 rounded-full overflow-hidden\">\n                  <div\n                    className=\"h-full rounded-full transition-all duration-500\"\n                    style={{ width: `${fillPercent}%`, backgroundColor: fillColor }}\n                  />\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Details grid */}\n      <div className=\"grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full\">\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>\n          <span className=\"font-mono text-zinc-300\">{grossWeight !== null ? `${grossWeight}g` : '\\u2014'}</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>\n          <span className=\"font-mono text-zinc-300\">{coreWeight}g</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>\n          <span className=\"font-mono text-zinc-300\">{labelWeight}g</span>\n        </div>\n        <div className=\"flex justify-between items-center\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>\n          {grossWeight !== null ? (\n            <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>\n              {grossWeight}g\n              {isMatch ? (\n                <Check className=\"w-3.5 h-3.5\" />\n              ) : (\n                <>\n                  <AlertTriangle className=\"w-3.5 h-3.5\" />\n                  <button\n                    onClick={handleSyncWeight}\n                    className=\"p-1 hover:bg-green-500/20 rounded transition-colors text-green-500\"\n                    title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}\n                  >\n                    <RefreshCw className=\"w-4 h-4\" />\n                  </button>\n                </>\n              )}\n            </span>\n          ) : (\n            <span className=\"text-zinc-500\">{'\\u2014'}</span>\n          )}\n        </div>\n        <div className=\"flex justify-between items-center\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>\n          <span className=\"font-mono text-xs text-zinc-400 truncate max-w-[120px]\" title={spool.tag_uid || ''}>\n            {spool.tag_uid ? spool.tag_uid.slice(-8) : '\\u2014'}\n          </span>\n        </div>\n      </div>\n\n      {/* Action buttons */}\n      <div className=\"flex gap-2 justify-center\">\n        {onAssignToAms && (\n          <button\n            onClick={onAssignToAms}\n            className=\"px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}\n          </button>\n        )}\n        <button\n          onClick={handleSyncWeight}\n          disabled={scaleWeight === null || syncing}\n          className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${\n            synced\n              ? 'bg-green-600/20 text-green-400'\n              : onAssignToAms\n                ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'\n                : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'\n          }`}\n        >\n          {syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}\n        </button>\n        {onClose && (\n          <button\n            onClick={onClose}\n            className=\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.dashboard.close', 'Close')}\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n\ninterface UnknownTagCardProps {\n  tagUid: string;\n  scaleWeight: number | null;\n  coreWeight?: number;\n  onLinkSpool?: () => void;\n  onAddToInventory?: () => void;\n  onClose?: () => void;\n}\n\nexport function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onAddToInventory, onClose }: UnknownTagCardProps) {\n  const { t } = useTranslation();\n  const defaultCoreWeight = coreWeight ?? getDefaultCoreWeight();\n  const grossWeight = scaleWeight !== null\n    ? Math.round(Math.max(0, scaleWeight))\n    : null;\n  const estimatedRemaining = grossWeight !== null\n    ? Math.round(Math.max(0, grossWeight - defaultCoreWeight))\n    : null;\n\n  return (\n    <div className=\"flex flex-col items-center text-center space-y-5\">\n      <div className=\"w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center\">\n        <svg className=\"w-10 h-10 text-green-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"2\" d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z\" />\n        </svg>\n      </div>\n      <div>\n        <h3 className=\"text-lg font-semibold text-zinc-100\">{t('spoolbuddy.dashboard.newTag', 'New Tag Detected')}</h3>\n        <p className=\"text-sm text-zinc-500 font-mono mt-1\">{tagUid}</p>\n      </div>\n      {grossWeight !== null && (\n        <div className=\"text-sm text-zinc-400\">\n          <span className=\"font-mono font-semibold\">{grossWeight}g</span> {t('spoolbuddy.dashboard.onScale', 'on scale')}\n          {estimatedRemaining !== null && estimatedRemaining > 0 && (\n            <span className=\"text-zinc-500\"> &bull; ~{estimatedRemaining}g filament</span>\n          )}\n        </div>\n      )}\n      <div className=\"flex flex-wrap gap-2 justify-center\">\n        {onAddToInventory && (\n          <button\n            onClick={onAddToInventory}\n            className=\"px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}\n          </button>\n        )}\n        {onLinkSpool && (\n          <button\n            onClick={onLinkSpool}\n            className=\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n          >\n            <svg className=\"w-4 h-4 inline-block mr-1.5 -mt-0.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"2\" d=\"M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1\" />\n            </svg>\n            {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}\n          </button>\n        )}\n        {onClose && (\n          <button\n            onClick={onClose}\n            className=\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.dashboard.close', 'Close')}\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/TagDetectedModal.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Check, RefreshCw, AlertTriangle, X } from 'lucide-react';\nimport type { MatchedSpool } from '../../hooks/useSpoolBuddyState';\nimport { spoolbuddyApi } from '../../api/client';\nimport { SpoolIcon } from './SpoolIcon';\n\n// Storage key for default core weight (shared with SpoolInfoCard)\nconst DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';\n\nfunction getDefaultCoreWeight(): number {\n  try {\n    const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);\n    if (stored) {\n      const weight = parseInt(stored, 10);\n      if (weight >= 0 && weight <= 500) return weight;\n    }\n  } catch {\n    // Ignore errors\n  }\n  return 250;\n}\n\ninterface TagDetectedModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  spool: MatchedSpool | null;\n  tagUid: string | null;\n  scaleWeight: number | null;\n  weightStable: boolean;\n  onSyncWeight: () => void;\n  onAssignToAms: () => void;\n  onLinkSpool?: () => void;\n  onAddToInventory: () => void;\n}\n\nexport function TagDetectedModal({\n  isOpen,\n  onClose,\n  spool,\n  tagUid,\n  scaleWeight,\n  weightStable,\n  onSyncWeight,\n  onAssignToAms,\n  onLinkSpool,\n  onAddToInventory,\n}: TagDetectedModalProps) {\n  const [syncing, setSyncing] = useState(false);\n  const [synced, setSynced] = useState(false);\n\n  // Reset sync state when spool changes\n  useEffect(() => {\n    setSyncing(false);\n    setSynced(false);\n  }, [spool?.id]);\n\n  // Handle escape key\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    if (e.key === 'Escape') onClose();\n  }, [onClose]);\n\n  useEffect(() => {\n    if (isOpen) {\n      document.addEventListener('keydown', handleKeyDown);\n      document.body.style.overflow = 'hidden';\n    }\n    return () => {\n      document.removeEventListener('keydown', handleKeyDown);\n      document.body.style.overflow = '';\n    };\n  }, [isOpen, handleKeyDown]);\n\n  if (!isOpen) return null;\n\n  const handleSyncWeight = async () => {\n    if (scaleWeight === null || !weightStable || !spool) return;\n    setSyncing(true);\n    try {\n      await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));\n      setSynced(true);\n      onSyncWeight();\n      setTimeout(() => setSynced(false), 3000);\n    } catch (e) {\n      console.error('Failed to sync weight:', e);\n    } finally {\n      setSyncing(false);\n    }\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-fade-in\" onClick={onClose}>\n      <div\n        className=\"bg-zinc-800 rounded-2xl shadow-2xl w-full max-w-xl mx-4 animate-slide-up\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {spool ? (\n          <KnownSpoolView\n            spool={spool}\n            scaleWeight={scaleWeight}\n            weightStable={weightStable}\n            syncing={syncing}\n            synced={synced}\n            onSyncWeight={handleSyncWeight}\n            onAssignToAms={onAssignToAms}\n            onClose={onClose}\n          />\n        ) : (\n          <UnknownTagView\n            tagUid={tagUid}\n            scaleWeight={scaleWeight}\n            onAddToInventory={onAddToInventory}\n            onLinkSpool={onLinkSpool}\n            onClose={onClose}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\n// --- Known spool view ---\n\ninterface KnownSpoolViewProps {\n  spool: MatchedSpool;\n  scaleWeight: number | null;\n  weightStable: boolean;\n  syncing: boolean;\n  synced: boolean;\n  onSyncWeight: () => void;\n  onAssignToAms: () => void;\n  onClose: () => void;\n}\n\nfunction KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onSyncWeight, onAssignToAms, onClose }: KnownSpoolViewProps) {\n  const { t } = useTranslation();\n  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';\n\n  const coreWeight = (spool.core_weight && spool.core_weight > 0)\n    ? spool.core_weight\n    : getDefaultCoreWeight();\n\n  const grossWeight = scaleWeight !== null\n    ? Math.round(Math.max(0, scaleWeight))\n    : null;\n\n  const remaining = grossWeight !== null\n    ? Math.round(Math.max(0, grossWeight - coreWeight))\n    : null;\n\n  const labelWeight = Math.round(spool.label_weight || 1000);\n  const fillPercent = remaining !== null ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;\n  const fillColor = fillPercent !== null\n    ? fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444'\n    : '#808080';\n\n  // Weight comparison\n  const netWeight = Math.max(0, (spool.label_weight || 0) - (spool.weight_used || 0));\n  const calculatedWeight = netWeight + coreWeight;\n  const difference = grossWeight !== null ? grossWeight - calculatedWeight : null;\n  const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;\n\n  return (\n    <div className=\"p-6\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between mb-5\">\n        <h2 className=\"text-lg font-semibold text-zinc-100\">\n          {t('spoolbuddy.modal.spoolDetected', 'Spool Detected')}\n        </h2>\n        <button onClick={onClose} className=\"p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors\">\n          <X className=\"w-5 h-5\" />\n        </button>\n      </div>\n\n      {/* Spool info */}\n      <div className=\"flex items-start gap-5 mb-5\">\n        <div className=\"relative shrink-0\">\n          <SpoolIcon color={colorHex} isEmpty={false} size={100} />\n          {fillPercent !== null && (\n            <div\n              className=\"absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg\"\n              style={{ backgroundColor: fillColor }}\n            >\n              {fillPercent}%\n            </div>\n          )}\n        </div>\n\n        <div className=\"flex-1 min-w-0 pt-1\">\n          <h3 className=\"text-lg font-semibold text-zinc-100\">\n            {spool.color_name || 'Unknown color'}\n          </h3>\n          <p className=\"text-sm text-zinc-400\">\n            {spool.brand} &bull; {spool.material}\n            {spool.subtype && ` ${spool.subtype}`}\n          </p>\n\n          {remaining !== null && (\n            <div className=\"mt-3\">\n              <div className=\"flex items-baseline gap-2\">\n                <span className=\"text-3xl font-bold font-mono text-zinc-100\">{remaining}g</span>\n                <span className=\"text-sm text-zinc-500\">/ {labelWeight}g</span>\n              </div>\n              <p className=\"text-xs text-zinc-500 mt-0.5\">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>\n\n              <div className=\"mt-2 max-w-xs\">\n                <div className=\"h-2 bg-zinc-700 rounded-full overflow-hidden\">\n                  <div\n                    className=\"h-full rounded-full transition-all duration-500\"\n                    style={{ width: `${fillPercent}%`, backgroundColor: fillColor }}\n                  />\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Details grid */}\n      <div className=\"grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-900/50 rounded-lg p-4 mb-5\">\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>\n          <span className=\"font-mono text-zinc-300\">{grossWeight !== null ? `${grossWeight}g` : '\\u2014'}</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>\n          <span className=\"font-mono text-zinc-300\">{coreWeight}g</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>\n          <span className=\"font-mono text-zinc-300\">{labelWeight}g</span>\n        </div>\n        <div className=\"flex justify-between items-center\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>\n          {grossWeight !== null ? (\n            <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>\n              {grossWeight}g\n              {isMatch ? <Check className=\"w-3.5 h-3.5\" /> : <AlertTriangle className=\"w-3.5 h-3.5\" />}\n            </span>\n          ) : (\n            <span className=\"text-zinc-500\">{'\\u2014'}</span>\n          )}\n        </div>\n        <div className=\"flex justify-between items-center\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>\n          <span className=\"font-mono text-xs text-zinc-400 truncate max-w-[120px]\" title={spool.tag_uid || ''}>\n            {spool.tag_uid ? spool.tag_uid.slice(-8) : '\\u2014'}\n          </span>\n        </div>\n      </div>\n\n      {/* Action buttons */}\n      <div className=\"flex gap-3\">\n        <button\n          onClick={onAssignToAms}\n          className=\"flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\"\n        >\n          {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}\n        </button>\n        <button\n          onClick={onSyncWeight}\n          disabled={!weightStable || scaleWeight === null || syncing}\n          className={`flex-1 px-5 py-3 rounded-xl text-sm font-medium transition-colors min-h-[44px] ${\n            synced\n              ? 'bg-green-600/20 text-green-400'\n              : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'\n          }`}\n        >\n          {syncing ? (\n            <RefreshCw className=\"w-4 h-4 animate-spin inline-block mr-1.5\" />\n          ) : synced ? (\n            <Check className=\"w-4 h-4 inline-block mr-1.5\" />\n          ) : null}\n          {syncing\n            ? t('spoolbuddy.modal.syncing', 'Syncing...')\n            : synced\n              ? t('spoolbuddy.modal.weightSynced', 'Synced!')\n              : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}\n        </button>\n        <button\n          onClick={onClose}\n          className=\"px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n        >\n          {t('spoolbuddy.dashboard.close', 'Close')}\n        </button>\n      </div>\n    </div>\n  );\n}\n\n// --- Unknown tag view ---\n\ninterface UnknownTagViewProps {\n  tagUid: string | null;\n  scaleWeight: number | null;\n  onAddToInventory: () => void;\n  onLinkSpool?: () => void;\n  onClose: () => void;\n}\n\nfunction UnknownTagView({ tagUid, scaleWeight, onAddToInventory, onLinkSpool, onClose }: UnknownTagViewProps) {\n  const { t } = useTranslation();\n  const grossWeight = scaleWeight !== null\n    ? Math.round(Math.max(0, scaleWeight))\n    : null;\n\n  return (\n    <div className=\"p-6\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between mb-5\">\n        <h2 className=\"text-lg font-semibold text-zinc-100\">\n          {t('spoolbuddy.modal.newTagDetected', 'New Tag Detected')}\n        </h2>\n        <button onClick={onClose} className=\"p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors\">\n          <X className=\"w-5 h-5\" />\n        </button>\n      </div>\n\n      {/* Tag info */}\n      <div className=\"flex flex-col items-center text-center mb-6\">\n        <div className=\"w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center mb-4\">\n          <svg className=\"w-10 h-10 text-green-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"2\" d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z\" />\n          </svg>\n        </div>\n\n        <p className=\"text-sm text-zinc-500 font-mono mb-3\">{tagUid}</p>\n\n        {grossWeight !== null && (\n          <div className=\"text-sm text-zinc-400\">\n            <span className=\"font-mono font-semibold text-zinc-200 text-lg\">{grossWeight}g</span>\n            <span className=\"ml-2\">{t('spoolbuddy.dashboard.onScale', 'on scale')}</span>\n          </div>\n        )}\n      </div>\n\n      {/* Action buttons */}\n      <div className=\"flex gap-3\">\n        <button\n          onClick={onAddToInventory}\n          className=\"flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\"\n        >\n          {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}\n        </button>\n        {onLinkSpool && (\n          <button\n            onClick={onLinkSpool}\n            className=\"flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}\n          </button>\n        )}\n        <button\n          onClick={onClose}\n          className=\"px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n        >\n          {t('spoolbuddy.dashboard.close', 'Close')}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/spoolbuddy/WeightDisplay.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { spoolbuddyApi } from '../../api/client';\n\ninterface WeightDisplayProps {\n  weight: number | null;\n  weightStable: boolean;\n  deviceOnline: boolean;\n  deviceId: string | null;\n}\n\nexport function WeightDisplay({ weight, weightStable, deviceOnline, deviceId }: WeightDisplayProps) {\n  const { t } = useTranslation();\n\n  const handleTare = async () => {\n    if (!deviceId) return;\n    try {\n      await spoolbuddyApi.tare(deviceId);\n    } catch (e) {\n      console.error('Failed to tare:', e);\n    }\n  };\n\n  const formatWeight = (w: number | null) => {\n    if (w === null) return '--.-';\n    return w.toFixed(1);\n  };\n\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      {/* Weight readout */}\n      <div className=\"flex items-baseline gap-2\">\n        <span className=\"text-5xl font-light tabular-nums text-zinc-100\">\n          {formatWeight(weight)}\n        </span>\n        <span className=\"text-xl text-zinc-400\">g</span>\n      </div>\n\n      {/* Stability indicator */}\n      <div className=\"flex items-center gap-2\">\n        <div className={`w-2 h-2 rounded-full ${\n          !deviceOnline\n            ? 'bg-zinc-600'\n            : weightStable\n            ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'\n            : 'bg-amber-500 animate-pulse'\n        }`} />\n        <span className=\"text-xs text-zinc-400\">\n          {!deviceOnline\n            ? t('spoolbuddy.weight.noReading', 'No reading')\n            : weightStable\n            ? t('spoolbuddy.weight.stable', 'Stable')\n            : t('spoolbuddy.weight.measuring', 'Measuring...')}\n        </span>\n      </div>\n\n      {/* Tare button */}\n      <button\n        onClick={handleTare}\n        disabled={!deviceOnline || !deviceId}\n        className=\"px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors min-h-[40px]\"\n      >\n        {t('spoolbuddy.weight.tare', 'Tare')}\n      </button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/contexts/AuthContext.tsx",
    "content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { api, getAuthToken, setAuthToken } from '../api/client';\nimport type { LoginResponse, Permission, UserResponse } from '../api/client';\n\ninterface AuthContextType {\n  user: UserResponse | null;\n  authEnabled: boolean;\n  requiresSetup: boolean;\n  loading: boolean;\n  isAdmin: boolean;\n  /** Login with username/password. Returns LoginResponse (may include requires_2fa). */\n  login: (username: string, password: string) => Promise<LoginResponse>;\n  /** Finalise login after 2FA or OIDC — store token and set user directly. */\n  loginWithToken: (token: string, user: UserResponse) => void;\n  logout: () => void;\n  refreshUser: () => Promise<void>;\n  refreshAuth: () => Promise<void>;\n  hasPermission: (permission: Permission) => boolean;\n  hasAnyPermission: (...permissions: Permission[]) => boolean;\n  hasAllPermissions: (...permissions: Permission[]) => boolean;\n  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined);\n\nexport function AuthProvider({ children }: { children: React.ReactNode }) {\n  const [user, setUser] = useState<UserResponse | null>(null);\n  const [authEnabled, setAuthEnabled] = useState(false);\n  const [requiresSetup, setRequiresSetup] = useState(false);\n  const [loading, setLoading] = useState(true);\n  const hasRedirectedRef = useRef(false);\n  const mountedRef = useRef(true);\n\n  const checkAuthStatus = async () => {\n    try {\n      // Bootstrap: if URL has ?token= param, store it session-only first and\n      // strip it from the URL. Allows SpoolBuddy kiosk to pass an API key via\n      // URL on first load. Persistence to localStorage is deferred until the\n      // token has been verified by the server (L-4: prevents session fixation\n      // where an attacker-crafted URL immediately persists a forged/stolen token).\n      const urlParams = new URLSearchParams(window.location.search);\n      const urlToken = urlParams.get('token');\n      if (urlToken) {\n        setAuthToken(urlToken, false); // session-only until server confirms it's valid\n        urlParams.delete('token');\n        const cleanSearch = urlParams.toString();\n        const cleanUrl = window.location.pathname\n          + (cleanSearch ? `?${cleanSearch}` : '')\n          + window.location.hash;\n        window.history.replaceState({}, '', cleanUrl);\n      }\n\n      const status = await api.getAuthStatus();\n      if (!mountedRef.current) return;\n      setAuthEnabled(status.auth_enabled);\n      setRequiresSetup(status.requires_setup);\n\n      if (status.auth_enabled) {\n        const token = getAuthToken();\n        if (token) {\n          try {\n            const currentUser = await api.getCurrentUser();\n            if (!mountedRef.current) return;\n            setUser(currentUser);\n            // Persist kiosk token only after the server confirms it is valid.\n            if (urlToken && token === urlToken) {\n              setAuthToken(urlToken, true);\n            }\n          } catch {\n            // Token invalid, clear it (removes from both sessionStorage and localStorage)\n            setAuthToken(null);\n            if (!mountedRef.current) return;\n            setUser(null);\n          }\n        } else {\n          setUser(null);\n        }\n      } else {\n        // Auth not enabled, allow access\n        setUser(null);\n      }\n    } catch {\n      if (!mountedRef.current) return;\n      setAuthEnabled(false);\n      setUser(null);\n    } finally {\n      if (mountedRef.current) {\n        setLoading(false);\n      }\n    }\n  };\n\n  useEffect(() => {\n    mountedRef.current = true;\n    // Check auth status on mount\n    checkAuthStatus();\n    return () => {\n      mountedRef.current = false;\n    };\n  }, []);\n\n  // Separate effect to handle redirect only when setup is required\n  useEffect(() => {\n    // Only redirect if setup is truly required (first time setup)\n    // Don't redirect if user manually navigated to /setup or is on camera page\n    if (!loading && requiresSetup && !authEnabled) {\n      const currentPath = window.location.pathname;\n      // Only redirect if not already on setup page or camera page, and haven't redirected yet\n      if (currentPath !== '/setup' && !currentPath.startsWith('/camera/') && !hasRedirectedRef.current) {\n        hasRedirectedRef.current = true;\n        window.location.href = '/setup';\n      }\n    } else if (!requiresSetup) {\n      // Reset redirect flag when setup is no longer required\n      hasRedirectedRef.current = false;\n    }\n  }, [loading, requiresSetup, authEnabled]);\n\n  const login = async (username: string, password: string): Promise<LoginResponse> => {\n    const response = await api.login({ username, password });\n    if (!response.requires_2fa && response.access_token) {\n      setAuthToken(response.access_token);\n      await checkAuthStatus();\n    }\n    return response;\n  };\n\n  const loginWithToken = (token: string, userObj: UserResponse) => {\n    setAuthToken(token);\n    setUser(userObj);\n    setAuthEnabled(true);\n  };\n\n  const logout = () => {\n    setAuthToken(null);\n    setUser(null);\n    api.logout().catch(() => {\n      // Ignore logout errors\n    });\n    window.location.href = '/login';\n  };\n\n  const refreshUser = async () => {\n    if (authEnabled && getAuthToken()) {\n      try {\n        const currentUser = await api.getCurrentUser();\n        if (mountedRef.current) {\n          setUser(currentUser);\n        }\n      } catch {\n        setAuthToken(null);\n        if (mountedRef.current) {\n          setUser(null);\n        }\n      }\n    }\n  };\n\n  const refreshAuth = async () => {\n    await checkAuthStatus();\n  };\n\n  // Memoize permission set for efficient lookups\n  const permissionSet = useMemo(() => {\n    return new Set(user?.permissions ?? []);\n  }, [user?.permissions]);\n\n  // Computed admin status\n  const isAdmin = useMemo(() => {\n    if (!authEnabled) return true; // Auth disabled = admin access\n    return user?.is_admin ?? false;\n  }, [authEnabled, user?.is_admin]);\n\n  // Permission check functions\n  const hasPermission = useCallback((permission: Permission): boolean => {\n    if (!authEnabled) return true; // Auth disabled = allow all\n    if (isAdmin) return true; // Admins have all permissions\n    return permissionSet.has(permission);\n  }, [authEnabled, isAdmin, permissionSet]);\n\n  const hasAnyPermission = useCallback((...permissions: Permission[]): boolean => {\n    if (!authEnabled) return true;\n    if (isAdmin) return true;\n    return permissions.some(p => permissionSet.has(p));\n  }, [authEnabled, isAdmin, permissionSet]);\n\n  const hasAllPermissions = useCallback((...permissions: Permission[]): boolean => {\n    if (!authEnabled) return true;\n    if (isAdmin) return true;\n    return permissions.every(p => permissionSet.has(p));\n  }, [authEnabled, isAdmin, permissionSet]);\n\n  // Ownership-based permission check\n  const canModify = useCallback((\n    resource: 'queue' | 'archives' | 'library',\n    action: 'update' | 'delete' | 'reprint',\n    createdById: number | null | undefined,\n  ): boolean => {\n    if (!authEnabled) return true;  // Auth disabled, allow all\n    if (isAdmin) return true;  // Admins can modify anything\n\n    const allPerm = `${resource}:${action}_all` as Permission;\n    const ownPerm = `${resource}:${action}_own` as Permission;\n\n    // User has *_all permission - can modify any item\n    if (permissionSet.has(allPerm)) return true;\n\n    // User has *_own permission - can only modify their own items\n    if (permissionSet.has(ownPerm)) {\n      // Ownerless items (null created_by_id) require *_all permission\n      if (createdById == null) return false;\n      return createdById === user?.id;\n    }\n\n    return false;\n  }, [authEnabled, isAdmin, permissionSet, user?.id]);\n\n  return (\n    <AuthContext.Provider\n      value={{\n        user,\n        authEnabled,\n        requiresSetup,\n        loading,\n        isAdmin,\n        login,\n        loginWithToken,\n        logout,\n        refreshUser,\n        refreshAuth,\n        hasPermission,\n        hasAnyPermission,\n        hasAllPermissions,\n        canModify,\n      }}\n    >\n      {children}\n    </AuthContext.Provider>\n  );\n}\n\nexport function useAuth() {\n  const context = useContext(AuthContext);\n  if (context === undefined) {\n    throw new Error('useAuth must be used within an AuthProvider');\n  }\n  return context;\n}\n"
  },
  {
    "path": "frontend/src/contexts/ColorCatalogContext.tsx",
    "content": "import { useEffect, type ReactNode } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { api } from '../api/client';\nimport { setColorCatalog } from '../utils/colors';\nimport { useAuth } from './AuthContext';\n\n/**\n * Loads the backend color catalog once per session and pushes it into\n * utils/colors.ts so getColorName/resolveSpoolColorName can do synchronous\n * lookups from render paths (JSX `title={...}`, table cells, etc.) without\n * threading a hook through every call site.\n *\n * Gated on authentication state because the backend endpoint requires a valid\n * session when auth is enabled — firing before login would just 401 and retry.\n */\nexport function ColorCatalogProvider({ children }: { children: ReactNode }) {\n  const { authEnabled, user, loading: authLoading } = useAuth();\n\n  // Fire when auth state is resolved AND we're actually allowed to hit the API.\n  // When auth is disabled, `user` is null but the endpoint accepts anyone —\n  // only gate on `!authLoading`. When auth is enabled, we need a logged-in user\n  // or the request 401s and gets retried in a loop.\n  const enabled = !authLoading && (!authEnabled || user !== null);\n\n  const { data } = useQuery({\n    queryKey: ['color-catalog-map'],\n    queryFn: async () => {\n      const response = await api.getColorNameMap();\n      return response.colors;\n    },\n    // Catalog rarely changes during a session; no background refetch needed.\n    staleTime: Infinity,\n    gcTime: Infinity,\n    retry: 2,\n    enabled,\n  });\n\n  useEffect(() => {\n    if (data) {\n      setColorCatalog(data);\n    }\n  }, [data]);\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "frontend/src/contexts/ThemeContext.tsx",
    "content": "import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';\nimport { api } from '../api/client';\n\ntype ThemeMode = 'light' | 'dark';\ntype ThemeStyle = 'classic' | 'glow' | 'vibrant';\ntype DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';\ntype LightBackground = 'neutral' | 'warm' | 'cool';\ntype ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';\n\ninterface ThemeContextType {\n  mode: ThemeMode;\n  // Dark mode settings\n  darkStyle: ThemeStyle;\n  darkBackground: DarkBackground;\n  darkAccent: ThemeAccent;\n  // Light mode settings\n  lightStyle: ThemeStyle;\n  lightBackground: LightBackground;\n  lightAccent: ThemeAccent;\n  // Actions\n  toggleMode: () => void;\n  setMode: (mode: ThemeMode) => void;\n  setDarkStyle: (style: ThemeStyle) => void;\n  setDarkBackground: (background: DarkBackground) => void;\n  setDarkAccent: (accent: ThemeAccent) => void;\n  setLightStyle: (style: ThemeStyle) => void;\n  setLightBackground: (background: LightBackground) => void;\n  setLightAccent: (accent: ThemeAccent) => void;\n}\n\nconst ThemeContext = createContext<ThemeContextType | undefined>(undefined);\n\nexport function ThemeProvider({ children }: { children: ReactNode }) {\n  // Mode\n  const [mode, setModeState] = useState<ThemeMode>(() => {\n    const stored = localStorage.getItem('theme-mode') as ThemeMode | null;\n    const legacy = localStorage.getItem('theme') as ThemeMode | null;\n    return stored || legacy || 'dark';\n  });\n\n  // Dark mode settings\n  const [darkStyle, setDarkStyleState] = useState<ThemeStyle>(() => {\n    return (localStorage.getItem('dark-style') as ThemeStyle) || 'classic';\n  });\n  const [darkBackground, setDarkBackgroundState] = useState<DarkBackground>(() => {\n    return (localStorage.getItem('dark-background') as DarkBackground) || 'neutral';\n  });\n  const [darkAccent, setDarkAccentState] = useState<ThemeAccent>(() => {\n    return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green';\n  });\n\n  // Light mode settings\n  const [lightStyle, setLightStyleState] = useState<ThemeStyle>(() => {\n    return (localStorage.getItem('light-style') as ThemeStyle) || 'classic';\n  });\n  const [lightBackground, setLightBackgroundState] = useState<LightBackground>(() => {\n    return (localStorage.getItem('light-background') as LightBackground) || 'neutral';\n  });\n  const [lightAccent, setLightAccentState] = useState<ThemeAccent>(() => {\n    return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';\n  });\n\n  // Sync from API on mount\n  useEffect(() => {\n    api.getSettings().then((settings) => {\n      // Dark settings\n      if (settings.dark_style) {\n        setDarkStyleState(settings.dark_style as ThemeStyle);\n        localStorage.setItem('dark-style', settings.dark_style);\n      }\n      if (settings.dark_background) {\n        setDarkBackgroundState(settings.dark_background as DarkBackground);\n        localStorage.setItem('dark-background', settings.dark_background);\n      }\n      if (settings.dark_accent) {\n        setDarkAccentState(settings.dark_accent as ThemeAccent);\n        localStorage.setItem('dark-accent', settings.dark_accent);\n      }\n      // Light settings\n      if (settings.light_style) {\n        setLightStyleState(settings.light_style as ThemeStyle);\n        localStorage.setItem('light-style', settings.light_style);\n      }\n      if (settings.light_background) {\n        setLightBackgroundState(settings.light_background as LightBackground);\n        localStorage.setItem('light-background', settings.light_background);\n      }\n      if (settings.light_accent) {\n        setLightAccentState(settings.light_accent as ThemeAccent);\n        localStorage.setItem('light-accent', settings.light_accent);\n      }\n    }).catch(() => {});\n  }, []);\n\n  // Apply theme classes based on current mode\n  useEffect(() => {\n    const root = document.documentElement;\n\n    // Remove all theme classes\n    root.classList.remove(\n      'dark',\n      'style-classic', 'style-glow', 'style-vibrant',\n      'bg-neutral', 'bg-warm', 'bg-cool', 'bg-oled', 'bg-slate', 'bg-forest',\n      'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red'\n    );\n\n    // Apply based on current mode\n    if (mode === 'dark') {\n      root.classList.add('dark');\n      root.classList.add(`style-${darkStyle}`);\n      root.classList.add(`bg-${darkBackground}`);\n      root.classList.add(`accent-${darkAccent}`);\n    } else {\n      root.classList.add(`style-${lightStyle}`);\n      root.classList.add(`bg-${lightBackground}`);\n      root.classList.add(`accent-${lightAccent}`);\n    }\n\n    localStorage.setItem('theme-mode', mode);\n    localStorage.removeItem('theme');\n  }, [mode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);\n\n  const toggleMode = () => setModeState(prev => prev === 'dark' ? 'light' : 'dark');\n  const setMode = (m: ThemeMode) => setModeState(m);\n\n  // Dark setters\n  const setDarkStyle = (v: ThemeStyle) => {\n    setDarkStyleState(v);\n    localStorage.setItem('dark-style', v);\n    api.updateSettings({ dark_style: v }).catch(() => {});\n  };\n  const setDarkBackground = (v: DarkBackground) => {\n    setDarkBackgroundState(v);\n    localStorage.setItem('dark-background', v);\n    api.updateSettings({ dark_background: v }).catch(() => {});\n  };\n  const setDarkAccent = (v: ThemeAccent) => {\n    setDarkAccentState(v);\n    localStorage.setItem('dark-accent', v);\n    api.updateSettings({ dark_accent: v }).catch(() => {});\n  };\n\n  // Light setters\n  const setLightStyle = (v: ThemeStyle) => {\n    setLightStyleState(v);\n    localStorage.setItem('light-style', v);\n    api.updateSettings({ light_style: v }).catch(() => {});\n  };\n  const setLightBackground = (v: LightBackground) => {\n    setLightBackgroundState(v);\n    localStorage.setItem('light-background', v);\n    api.updateSettings({ light_background: v }).catch(() => {});\n  };\n  const setLightAccent = (v: ThemeAccent) => {\n    setLightAccentState(v);\n    localStorage.setItem('light-accent', v);\n    api.updateSettings({ light_accent: v }).catch(() => {});\n  };\n\n  return (\n    <ThemeContext.Provider value={{\n      mode,\n      darkStyle, darkBackground, darkAccent,\n      lightStyle, lightBackground, lightAccent,\n      toggleMode, setMode,\n      setDarkStyle, setDarkBackground, setDarkAccent,\n      setLightStyle, setLightBackground, setLightAccent,\n    }}>\n      {children}\n    </ThemeContext.Provider>\n  );\n}\n\nexport function useTheme() {\n  const context = useContext(ThemeContext);\n  if (!context) throw new Error('useTheme must be used within ThemeProvider');\n  return context;\n}\n\nexport type { ThemeMode, ThemeStyle, DarkBackground, LightBackground, ThemeAccent };\n"
  },
  {
    "path": "frontend/src/contexts/ToastContext.tsx",
    "content": "import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';\nimport { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport { formatFileSize } from '../utils/file';\n\ntype ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';\n\ntype ShowPersistentToast = (id: string, message: string, type?: ToastType) => void;\n\ninterface Toast {\n  id: string;\n  message: string;\n  type: ToastType;\n  persistent?: boolean;\n  dispatchData?: DispatchToastData;\n}\n\ntype DispatchJobStatus = 'dispatched' | 'processing' | 'completed' | 'failed' | 'cancelled';\n\ninterface DispatchToastJob {\n  jobId: number;\n  sourceName: string;\n  printerName: string;\n  status: DispatchJobStatus;\n  message?: string;\n  uploadBytes?: number;\n  uploadTotalBytes?: number;\n  uploadProgressPct?: number;\n}\n\ninterface DispatchToastData {\n  total: number;\n  dispatched: number;\n  processing: number;\n  completed: number;\n  failed: number;\n  jobs: DispatchToastJob[];\n}\n\ninterface ToastContextType {\n  showToast: (message: string, type?: ToastType) => void;\n  showPersistentToast: ShowPersistentToast;\n  dismissToast: (id: string) => void;\n}\n\nconst ToastContext = createContext<ToastContextType | undefined>(undefined);\n\nexport function useToast() {\n  const context = useContext(ToastContext);\n  if (!context) {\n    throw new Error('useToast must be used within a ToastProvider');\n  }\n  return context;\n}\n\nconst icons = {\n  success: <CheckCircle className=\"w-5 h-5 text-green-400\" />,\n  error: <XCircle className=\"w-5 h-5 text-red-400\" />,\n  warning: <AlertCircle className=\"w-5 h-5 text-yellow-400\" />,\n  info: <Info className=\"w-5 h-5 text-blue-400\" />,\n  loading: <Loader2 className=\"w-5 h-5 text-bambu-green animate-spin\" />,\n};\n\nconst bgColors = {\n  success: 'bg-green-500/10 border-green-500/30',\n  error: 'bg-red-500/10 border-red-500/30',\n  warning: 'bg-yellow-500/10 border-yellow-500/30',\n  info: 'bg-blue-500/10 border-blue-500/30',\n  loading: 'bg-bambu-green/10 border-bambu-green/30',\n};\n\nexport function ToastProvider({ children }: { children: ReactNode }) {\n  const { t } = useTranslation();\n  const [toasts, setToasts] = useState<Toast[]>([]);\n  const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);\n  const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());\n  const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());\n  const dispatchToastId = 'background-dispatch';\n  const lastDispatchSummaryRef = useRef<string | null>(null);\n  // Tracks whether the provider is still mounted. A toast can be triggered by\n  // an async callback that resolves AFTER React has unmounted us (common in\n  // tests: `cleanup()` runs while a login promise is still in flight, then\n  // the error handler calls showToast). In that case, scheduling a setTimeout\n  // that later calls setToasts produces \"window is not defined\" once the jsdom\n  // environment is torn down. Guard every setToasts call behind this ref so a\n  // post-unmount showToast is a no-op instead of crashing.\n  const isMountedRef = useRef(true);\n\n  // Clean up all timeouts on unmount\n  useEffect(() => {\n    isMountedRef.current = true;\n    const timeouts = timeoutRefs.current;\n    return () => {\n      isMountedRef.current = false;\n      timeouts.forEach((timeout) => clearTimeout(timeout));\n      timeouts.clear();\n    };\n  }, []);\n\n  const showToast = useCallback((message: string, type: ToastType = 'success') => {\n    if (!isMountedRef.current) return;\n    const id = Math.random().toString(36).substr(2, 9);\n    setToasts((prev) => [...prev, { id, message, type }]);\n\n    // Auto-dismiss after 3 seconds\n    const timeout = setTimeout(() => {\n      if (!isMountedRef.current) return;\n      setToasts((prev) => prev.filter((t) => t.id !== id));\n      timeoutRefs.current.delete(id);\n    }, 3000);\n    timeoutRefs.current.set(id, timeout);\n  }, []);\n\n  const showPersistentToast = useCallback((id: string, message: string, type: ToastType = 'info') => {\n    if (!isMountedRef.current) return;\n    setToasts((prev) => {\n      // Update existing toast if same id, otherwise add new one\n      const exists = prev.find((t) => t.id === id);\n      if (exists) {\n        return prev.map((t) => (t.id === id ? { ...t, message, type, persistent: true } : t));\n      }\n      return [...prev, { id, message, type, persistent: true }];\n    });\n  }, []);\n\n  const dismissToast = useCallback((id: string) => {\n    if (!isMountedRef.current) return;\n    // Clear any pending auto-dismiss timeout\n    const timeout = timeoutRefs.current.get(id);\n    if (timeout) {\n      clearTimeout(timeout);\n      timeoutRefs.current.delete(id);\n    }\n    setToasts((prev) => prev.filter((t) => t.id !== id));\n  }, []);\n\n  const cancelDispatchJob = useCallback(async (jobId: number) => {\n    setCancellingDispatchJobIds((prev) => {\n      const next = new Set(prev);\n      next.add(jobId);\n      return next;\n    });\n\n    try {\n      const result = await api.cancelBackgroundDispatchJob(jobId);\n      showToast(\n        result.status === 'cancelling'\n          ? t('backgroundDispatch.toast.cancellingUpload')\n          : t('backgroundDispatch.toast.cancelled'),\n        'info'\n      );\n    } catch (error) {\n      const message = error instanceof Error ? error.message : t('backgroundDispatch.toast.cancelFailed');\n      showToast(message, 'error');\n    } finally {\n      setCancellingDispatchJobIds((prev) => {\n        const next = new Set(prev);\n        next.delete(jobId);\n        return next;\n      });\n    }\n  }, [showToast, t]);\n\n  useEffect(() => {\n    interface DispatchEventDetail {\n      total?: number;\n      dispatched?: number;\n      processing?: number;\n      completed?: number;\n      failed?: number;\n      dispatched_jobs?: Array<{\n        job_id: number;\n        source_name?: string;\n        printer_name?: string;\n      }>;\n      active_job?: {\n        job_id?: number;\n        printer_name?: string;\n        source_name?: string;\n        message?: string;\n        upload_bytes?: number;\n        upload_total_bytes?: number;\n        upload_progress_pct?: number;\n      } | null;\n      active_jobs?: Array<{\n        job_id?: number;\n        printer_name?: string;\n        source_name?: string;\n        message?: string;\n        upload_bytes?: number;\n        upload_total_bytes?: number;\n        upload_progress_pct?: number;\n      }>;\n      recent_event?: {\n        status?: string;\n        job_id?: number;\n        source_name?: string;\n        printer_name?: string;\n        message?: string;\n      };\n    }\n\n    const updateJob = (\n      jobs: DispatchToastJob[],\n      jobId: number,\n      next: Partial<DispatchToastJob> & {\n        status: DispatchJobStatus;\n        sourceName: string;\n        printerName: string;\n      }\n    ) => {\n      const index = jobs.findIndex((job) => job.jobId === jobId);\n      if (index === -1) {\n        return [...jobs, { jobId, ...next }];\n      }\n      const copy = [...jobs];\n      copy[index] = {\n        ...copy[index],\n        ...next,\n      };\n      return copy;\n    };\n\n    const statusWeight = (status: DispatchJobStatus) => {\n      switch (status) {\n        case 'failed':\n          return 0;\n        case 'processing':\n          return 1;\n        case 'dispatched':\n          return 2;\n        case 'completed':\n          return 3;\n        case 'cancelled':\n          return 4;\n      }\n    };\n\n    const onDispatchEvent = (event: Event) => {\n      const detail = (event as CustomEvent<DispatchEventDetail>).detail || {};\n      const total = detail.total ?? 0;\n      const dispatched = detail.dispatched ?? 0;\n      const processing = detail.processing ?? 0;\n      const completed = detail.completed ?? 0;\n      const failed = detail.failed ?? 0;\n\n      const hasActiveWork = dispatched + processing > 0;\n      const allDone = total > 0 && completed + failed >= total && !hasActiveWork;\n      const recentStatus = detail.recent_event?.status;\n\n      // Once any print starts successfully, dismiss the dispatch toast (#615)\n      // Remaining jobs continue in the background silently\n      if (recentStatus === 'completed' && completed > 0) {\n        const summaryKey = `first-complete:${completed}:${failed}`;\n        if (lastDispatchSummaryRef.current !== summaryKey) {\n          lastDispatchSummaryRef.current = summaryKey;\n\n          const remaining = total - completed - failed;\n          const doneMessage = remaining > 0\n            ? t('backgroundDispatch.toast.printStartedRemaining', { completed, remaining })\n            : failed > 0\n              ? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })\n              : t('backgroundDispatch.toast.completeSuccess', { completed });\n\n          setToasts((prev) => {\n            const doneToast: Toast = {\n              id: dispatchToastId,\n              message: doneMessage,\n              type: failed > 0 ? 'warning' : 'success',\n              persistent: true,\n            };\n            const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);\n            if (exists) {\n              return prev.map((toastItem) =>\n                toastItem.id === dispatchToastId ? doneToast : toastItem\n              );\n            }\n            return [...prev, doneToast];\n          });\n\n          const existingTimeout = timeoutRefs.current.get(dispatchToastId);\n          if (existingTimeout) clearTimeout(existingTimeout);\n          const timeout = setTimeout(() => {\n            if (!isMountedRef.current) return;\n            setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));\n            timeoutRefs.current.delete(dispatchToastId);\n            lastDispatchSummaryRef.current = null;\n          }, 3000);\n          timeoutRefs.current.set(dispatchToastId, timeout);\n        }\n        return;\n      }\n\n      if (hasActiveWork) {\n        // New batch starting — reset dedup guard so completion toast works\n        lastDispatchSummaryRef.current = null;\n        setToasts((prev) => {\n          const existing = prev.find((toastItem) => toastItem.id === dispatchToastId);\n          const existingJobs = existing?.dispatchData?.jobs || [];\n\n          const dispatchedJobs: DispatchToastJob[] = (detail.dispatched_jobs || []).map((job) => ({\n            jobId: job.job_id,\n            sourceName: job.source_name || t('backgroundDispatch.unknownFile'),\n            printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),\n            status: 'dispatched',\n          }));\n\n          const activeJobsPayload =\n            detail.active_jobs && detail.active_jobs.length > 0\n              ? detail.active_jobs\n              : detail.active_job?.job_id\n                ? [detail.active_job]\n                : [];\n\n          const activeJobs: DispatchToastJob[] = activeJobsPayload\n            .filter((job) => typeof job.job_id === 'number')\n            .map((job) => ({\n              jobId: job.job_id as number,\n              sourceName: job.source_name || t('backgroundDispatch.unknownFile'),\n              printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),\n              status: 'processing',\n              message: job.message,\n              uploadBytes: job.upload_bytes,\n              uploadTotalBytes: job.upload_total_bytes,\n              uploadProgressPct: job.upload_progress_pct,\n            }));\n\n          const activeIds = new Set([...dispatchedJobs, ...activeJobs].map((job) => job.jobId));\n          const historicalJobs = existingJobs.filter(\n            (job) => !activeIds.has(job.jobId) && ['completed', 'failed', 'cancelled'].includes(job.status)\n          );\n\n          let jobs = [...dispatchedJobs, ...activeJobs, ...historicalJobs];\n\n          if (detail.recent_event?.job_id && detail.recent_event?.status) {\n            const rawStatus = detail.recent_event.status;\n            const eventStatus = (\n              rawStatus === 'cancelled' ? 'cancelled' : rawStatus === 'cancelling' ? 'processing' : rawStatus\n            ) as DispatchJobStatus;\n            const sourceName = detail.recent_event.source_name || t('backgroundDispatch.unknownFile');\n            const printerName = detail.recent_event.printer_name || t('backgroundDispatch.unknownPrinter');\n            jobs = updateJob(jobs, detail.recent_event.job_id, {\n              status: eventStatus,\n              sourceName,\n              printerName,\n              message: detail.recent_event.message,\n            });\n          }\n\n          activeJobs.forEach((activeJob) => {\n            jobs = updateJob(jobs, activeJob.jobId, {\n              status: 'processing',\n              sourceName: activeJob.sourceName,\n              printerName: activeJob.printerName,\n              message: activeJob.message,\n              uploadBytes: activeJob.uploadBytes,\n              uploadTotalBytes: activeJob.uploadTotalBytes,\n              uploadProgressPct: activeJob.uploadProgressPct,\n            });\n          });\n\n          const dispatchData: DispatchToastData = {\n            total,\n            dispatched,\n            processing,\n            completed,\n            failed,\n            jobs: [...jobs].sort((a, b) => {\n              const byStatus = statusWeight(a.status) - statusWeight(b.status);\n              if (byStatus !== 0) {\n                return byStatus;\n              }\n              return a.jobId - b.jobId;\n            }),\n          };\n\n          const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);\n          if (exists) {\n            return prev.map((toastItem) =>\n              toastItem.id === dispatchToastId\n                ? {\n                    ...toastItem,\n                    message: t('backgroundDispatch.startingPrints'),\n                    type: 'loading',\n                    persistent: true,\n                    dispatchData,\n                  }\n                : toastItem\n            );\n          }\n          return [\n            ...prev,\n            {\n              id: dispatchToastId,\n              message: t('backgroundDispatch.startingPrints'),\n              type: 'loading',\n              persistent: true,\n              dispatchData,\n            },\n          ];\n        });\n        return;\n      }\n\n      if (allDone) {\n        const summaryKey = `${completed}:${failed}`;\n        if (lastDispatchSummaryRef.current === summaryKey) {\n          return;\n        }\n        lastDispatchSummaryRef.current = summaryKey;\n\n        const doneMessage = failed > 0\n          ? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })\n          : t('backgroundDispatch.toast.completeSuccess', { completed });\n\n        // Show a brief \"completed\" state on the dispatch toast before replacing with summary\n        // This ensures the user sees confirmation even for fast uploads (#615)\n        setToasts((prev) => {\n          const doneToast: Toast = {\n            id: dispatchToastId,\n            message: doneMessage,\n            type: failed > 0 ? 'warning' : 'success',\n            persistent: true,\n            // Clear dispatchData so it renders as a simple text toast\n          };\n          const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);\n          if (exists) {\n            return prev.map((toastItem) =>\n              toastItem.id === dispatchToastId ? doneToast : toastItem\n            );\n          }\n          return [...prev, doneToast];\n        });\n\n        // Auto-dismiss after 3 seconds\n        const existingTimeout = timeoutRefs.current.get(dispatchToastId);\n        if (existingTimeout) clearTimeout(existingTimeout);\n        const timeout = setTimeout(() => {\n          setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));\n          timeoutRefs.current.delete(dispatchToastId);\n          lastDispatchSummaryRef.current = null;\n        }, 3000);\n        timeoutRefs.current.set(dispatchToastId, timeout);\n        return;\n      }\n\n      if (!hasActiveWork && recentStatus && ['cancelled', 'failed', 'completed', 'idle'].includes(recentStatus)) {\n        setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));\n        lastDispatchSummaryRef.current = null;\n      }\n\n      if (detail.recent_event?.status === 'idle' && !hasActiveWork) {\n        setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));\n        lastDispatchSummaryRef.current = null;\n      }\n\n      if (!hasActiveWork) {\n        setCancellingDispatchJobIds(new Set());\n      }\n\n      if (detail.dispatched_jobs) {\n        const dispatchedIds = new Set(detail.dispatched_jobs.map((job) => job.job_id));\n        setCancellingDispatchJobIds((prev) => {\n          const next = new Set<number>();\n          prev.forEach((id) => {\n            if (dispatchedIds.has(id)) {\n              next.add(id);\n            }\n          });\n          return next;\n        });\n      }\n    };\n\n    window.addEventListener('background-dispatch', onDispatchEvent);\n    return () => window.removeEventListener('background-dispatch', onDispatchEvent);\n  }, [t]);\n\n\n  return (\n    <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>\n      {children}\n\n      {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12) */}\n      <div className=\"fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2\">\n        {toasts.map((toast) => (\n          <div\n            key={toast.id}\n            className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${\n              toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'\n            }`}\n          >\n            {toast.dispatchData ? (\n              <>\n                <div className=\"flex items-start justify-between gap-3\">\n                  <div className=\"flex items-start gap-2\">\n                    {icons[toast.type]}\n                    <div>\n                      <p className=\"text-white text-sm font-medium\">{t('backgroundDispatch.startingPrints')}</p>\n                      <p className=\"text-xs text-bambu-gray mt-0.5\">\n                        {t('backgroundDispatch.progressSummary', {\n                          complete: toast.dispatchData.completed + toast.dispatchData.failed,\n                          total: toast.dispatchData.total,\n                          dispatched: toast.dispatchData.dispatched,\n                          processing: toast.dispatchData.processing,\n                        })}\n                      </p>\n                    </div>\n                  </div>\n                  <div className=\"flex items-center gap-1\">\n                    <button\n                      onClick={() => setIsDispatchCollapsed((prev) => !prev)}\n                      className=\"text-bambu-gray hover:text-white transition-colors\"\n                      aria-label={\n                        isDispatchCollapsed\n                          ? t('backgroundDispatch.expandDetails')\n                          : t('backgroundDispatch.collapseDetails')\n                      }\n                    >\n                      {isDispatchCollapsed ? <ChevronUp className=\"w-4 h-4\" /> : <ChevronDown className=\"w-4 h-4\" />}\n                    </button>\n                    <button\n                      onClick={() => dismissToast(toast.id)}\n                      className=\"text-bambu-gray hover:text-white transition-colors\"\n                      aria-label={t('backgroundDispatch.dismissToast')}\n                    >\n                      <X className=\"w-4 h-4\" />\n                    </button>\n                  </div>\n                </div>\n\n                {!isDispatchCollapsed && (\n                  <div className=\"mt-3 space-y-2 max-h-64 overflow-y-auto pr-1\">\n                    {toast.dispatchData.jobs.map((job) => {\n                      const progressByStatus: Record<DispatchJobStatus, number> = {\n                        dispatched: 15,\n                        processing: 60,\n                        completed: 100,\n                        failed: 100,\n                        cancelled: 100,\n                      };\n                      const barColorByStatus: Record<DispatchJobStatus, string> = {\n                        dispatched: 'bg-bambu-gray/60',\n                        processing: 'bg-bambu-green',\n                        completed: 'bg-green-500',\n                        failed: 'bg-red-500',\n                        cancelled: 'bg-yellow-500',\n                      };\n                      return (\n                        <div key={job.jobId} className=\"rounded border border-white/10 bg-black/15 p-2\">\n                          <div className=\"flex items-center justify-between gap-2\">\n                            <span className=\"text-xs text-white truncate\" title={job.sourceName}>\n                              {job.sourceName}\n                            </span>\n                            <div className=\"flex items-center gap-2\">\n                              {(job.status === 'dispatched' || job.status === 'processing') && (\n                                <button\n                                  onClick={() => void cancelDispatchJob(job.jobId)}\n                                  disabled={cancellingDispatchJobIds.has(job.jobId)}\n                                  className=\"text-[11px] text-red-300 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed\"\n                                  title={t('backgroundDispatch.cancelDispatchJob')}\n                                >\n                                  {cancellingDispatchJobIds.has(job.jobId)\n                                    ? t('backgroundDispatch.cancelling')\n                                    : t('backgroundDispatch.cancel')}\n                                </button>\n                              )}\n                              <span className=\"text-[11px] uppercase tracking-wide text-bambu-gray\">\n                                {t(`backgroundDispatch.status.${job.status}`)}\n                              </span>\n                            </div>\n                          </div>\n                          <div className=\"text-[11px] text-bambu-gray truncate\" title={job.printerName}>\n                            {job.printerName}\n                          </div>\n                          {job.message && (\n                            <div className=\"text-[11px] text-bambu-gray truncate\" title={job.message}>\n                              {job.message}\n                            </div>\n                          )}\n                          {job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (\n                            <div className=\"text-[11px] text-bambu-gray truncate\">\n                              {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}\n                              {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}\n                            </div>\n                          )}\n                          <div className=\"mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden\">\n                            <div\n                              className={`h-full ${barColorByStatus[job.status]} transition-all duration-300`}\n                              style={{\n                                width: `${\n                                  job.status === 'processing' && typeof job.uploadProgressPct === 'number'\n                                    ? Math.max(0, Math.min(100, job.uploadProgressPct))\n                                    : progressByStatus[job.status]\n                                }%`,\n                              }}\n                            />\n                          </div>\n                        </div>\n                      );\n                    })}\n                  </div>\n                )}\n              </>\n            ) : (\n              <>\n                {icons[toast.type]}\n                <span className=\"text-white text-sm\">{toast.message}</span>\n                <button\n                  onClick={() => dismissToast(toast.id)}\n                  className=\"ml-2 text-bambu-gray hover:text-white transition-colors\"\n                >\n                  <X className=\"w-4 h-4\" />\n                </button>\n              </>\n            )}\n          </div>\n        ))}\n      </div>\n    </ToastContext.Provider>\n  );\n}\n"
  },
  {
    "path": "frontend/src/hooks/useCameraStreamToken.ts",
    "content": "import { useEffect, useRef } from 'react';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { api, setStreamToken, getStreamToken, withStreamToken } from '../api/client';\nimport { useAuth } from '../contexts/AuthContext';\n\n/**\n * Walks the DOM and updates every <img>/<video> pointing at /api/v1/ so its\n * src carries the current stream token. Exported for unit testing; called\n * from useStreamTokenSync when the token arrives after first render.\n */\nexport function rewriteMediaSrcWithToken(root: ParentNode, token: string): number {\n  const tokenParam = `token=${encodeURIComponent(token)}`;\n  let updated = 0;\n  root\n    .querySelectorAll<HTMLImageElement | HTMLVideoElement>(\n      'img[src*=\"/api/v1/\"], video[src*=\"/api/v1/\"]'\n    )\n    .forEach((el) => {\n      const src = el.getAttribute('src') || '';\n      if (src.includes(tokenParam)) return;\n      const withoutToken = src.replace(/([?&])token=[^&]*(&|$)/, (_m, pre, post) =>\n        post === '&' ? pre : pre === '?' ? '' : ''\n      );\n      const sep = withoutToken.includes('?') ? '&' : '?';\n      el.src = `${withoutToken}${sep}${tokenParam}`;\n      updated += 1;\n    });\n  return updated;\n}\n\n/**\n * Fetches and caches a stream token for <img>/<video> src URLs.\n * Stores the token globally via setStreamToken() so URL generators\n * in client.ts can use withStreamToken() automatically.\n *\n * Also listens for global image load errors on token-protected URLs\n * and automatically refreshes the token (e.g., after backend restart\n * invalidates in-memory tokens).\n *\n * Mount this hook once near the app root (e.g., in App.tsx or a layout component).\n * Components that need token-protected URLs can import withStreamToken directly.\n */\nexport function useStreamTokenSync() {\n  const { authEnabled, user } = useAuth();\n  const queryClient = useQueryClient();\n  const refreshingRef = useRef(false);\n\n  // Key the token by user id so a login/logout invalidates the cache\n  // automatically — otherwise a failed anonymous fetch on the login page\n  // would be cached and never retried after sign-in.\n  const { data } = useQuery({\n    queryKey: ['camera-stream-token', user?.id ?? null],\n    queryFn: () => api.getCameraStreamToken(),\n    enabled: authEnabled ? !!user : true,\n    staleTime: 50 * 60 * 1000, // refresh at 50 min (tokens expire at 60)\n    refetchInterval: 50 * 60 * 1000,\n  });\n\n  useEffect(() => {\n    const newToken = data?.token ?? null;\n    setStreamToken(newToken);\n\n    // Images/videos that rendered before the token arrived have src URLs\n    // without ?token=…; update them in place so they reload with auth.\n    if (newToken) {\n      rewriteMediaSrcWithToken(document, newToken);\n    }\n\n    return () => setStreamToken(null);\n  }, [data?.token]);\n\n  // Listen for image/video load errors on token-protected URLs.\n  // When the backend restarts, in-memory stream tokens are lost and all\n  // thumbnail/stream requests return 401. This handler detects that and\n  // forces a token refresh so images recover without a page reload.\n  useEffect(() => {\n    if (!authEnabled) return;\n\n    const handleError = (event: Event) => {\n      const el = event.target;\n      if (!(el instanceof HTMLImageElement || el instanceof HTMLVideoElement)) return;\n\n      const src = el.src || '';\n      const token = getStreamToken();\n      if (!token || !src.includes(`token=${encodeURIComponent(token)}`)) return;\n\n      // This image/video used our stream token and failed — token likely invalid\n      if (refreshingRef.current) return;\n      refreshingRef.current = true;\n\n      queryClient.invalidateQueries({ queryKey: ['camera-stream-token'] });\n\n      // Reset after a delay so future errors can trigger another refresh\n      setTimeout(() => {\n        refreshingRef.current = false;\n      }, 5000);\n    };\n\n    // Use capture phase to catch errors before they're swallowed\n    document.addEventListener('error', handleError, true);\n    return () => document.removeEventListener('error', handleError, true);\n  }, [authEnabled, queryClient]);\n}\n\n/**\n * Hook for components that need to wrap URLs with the stream token.\n * Returns a withToken function that appends ?token=xxx when auth is enabled.\n */\nexport function useCameraStreamToken() {\n  return { withToken: withStreamToken };\n}\n"
  },
  {
    "path": "frontend/src/hooks/useColorCatalogVersion.ts",
    "content": "import { useSyncExternalStore } from 'react';\nimport { subscribeColorCatalog, getColorCatalogVersion } from '../utils/colors';\n\n/**\n * Subscribe to color-catalog updates. Returns the current catalog version —\n * the value itself is opaque; what matters is that calling components re-render\n * when the catalog is (re)populated by ColorCatalogProvider.\n *\n * Use this in a high-level component (Layout) so that pages which cache color\n * names during render (via getColorName) refresh when the backend catalog\n * finishes loading after the first paint.\n */\nexport function useColorCatalogVersion(): number {\n  return useSyncExternalStore(\n    subscribeColorCatalog,\n    getColorCatalogVersion,\n    // SSR snapshot — we never SSR, but useSyncExternalStore requires the param.\n    getColorCatalogVersion,\n  );\n}\n"
  },
  {
    "path": "frontend/src/hooks/useFilamentMapping.ts",
    "content": "import { useMemo } from 'react';\nimport { getColorName } from '../utils/colors';\nimport {\n  normalizeColor,\n  normalizeColorForCompare,\n  colorsAreSimilar,\n  formatSlotLabel,\n  getGlobalTrayId,\n} from '../utils/amsHelpers';\nimport type { PrinterStatus } from '../api/client';\n\n/**\n * Build loaded filaments list from printer status (non-hook version).\n * Extracts filaments from all AMS units (regular and HT) and external spool.\n */\nexport function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {\n  const filaments: LoadedFilament[] = [];\n  const amsExtruderMap = printerStatus?.ams_extruder_map;\n  const hasDualNozzle = amsExtruderMap && Object.keys(amsExtruderMap).length > 0;\n\n  // Add filaments from all AMS units (regular and HT)\n  printerStatus?.ams?.forEach((amsUnit) => {\n    const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray\n    amsUnit.tray.forEach((tray) => {\n      if (tray.tray_type) {\n        const color = normalizeColor(tray.tray_color);\n        filaments.push({\n          type: tray.tray_type,\n          color,\n          colorName: getColorName(color),\n          amsId: amsUnit.id,\n          trayId: tray.id,\n          isHt,\n          isExternal: false,\n          label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),\n          globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),\n          trayInfoIdx: tray.tray_info_idx || '',\n          traySubBrands: tray.tray_sub_brands || '',\n          extruderId: amsExtruderMap?.[String(amsUnit.id)],\n          remain: tray.remain ?? -1,\n        });\n      }\n    });\n  });\n\n  // Add external spool(s) if loaded\n  for (const extTray of printerStatus?.vt_tray ?? []) {\n    if (extTray.tray_type) {\n      const color = normalizeColor(extTray.tray_color);\n      const trayId = extTray.id ?? 254;\n      const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;\n      filaments.push({\n        type: extTray.tray_type,\n        color,\n        colorName: getColorName(color),\n        amsId: -1,\n        trayId: trayId - 254,\n        isHt: false,\n        isExternal: true,\n        label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',\n        globalTrayId: trayId,\n        trayInfoIdx: extTray.tray_info_idx || '',\n        traySubBrands: extTray.tray_sub_brands || '',\n        extruderId: hasDualNozzle ? (255 - trayId) : undefined,\n        remain: extTray.remain ?? -1,\n      });\n    }\n  }\n\n  return filaments;\n}\n\n/**\n * Compute AMS mapping for a printer given filament requirements and printer status.\n * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).\n *\n * Priority: unique tray_info_idx match > exact color match > similar color match > type-only match\n *\n * The tray_info_idx is a filament type identifier stored in the 3MF file when the user\n * slices (e.g., \"GFA00\" for generic PLA, \"P4d64437\" for custom presets). If the same\n * tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays\n * have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color\n * matching among those trays.\n *\n * @param filamentReqs - Required filaments from the 3MF file\n * @param printerStatus - Current printer status with AMS information\n * @returns AMS mapping array or undefined if no mapping needed\n */\nexport function computeAmsMapping(\n  filamentReqs: { filaments: FilamentRequirement[] } | undefined,\n  printerStatus: PrinterStatus | undefined,\n  preferLowest?: boolean,\n): number[] | undefined {\n  if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;\n\n  const loadedFilaments = buildLoadedFilaments(printerStatus);\n  if (loadedFilaments.length === 0) return undefined;\n\n  // Track which trays have been assigned to avoid duplicates\n  const usedTrayIds = new Set<number>();\n\n  const comparisons = filamentReqs.filaments.map((req) => {\n    const reqTrayInfoIdx = req.tray_info_idx || '';\n\n    // Get available trays (not already used)\n    let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));\n\n    // Nozzle-aware filtering: restrict to trays on the correct nozzle.\n    // This is a hard filter — cross-nozzle assignment causes print failures\n    // (\"position of left hotend is abnormal\"), so we never fall back to wrong-nozzle trays.\n    if (req.nozzle_id != null) {\n      available = available.filter((f) => f.extruderId === req.nozzle_id);\n    }\n\n    // Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first\n    if (preferLowest) {\n      available = [...available].sort((a, b) => {\n        const ra = a.remain >= 0 ? a.remain : 101;\n        const rb = b.remain >= 0 ? b.remain : 101;\n        return ra - rb;\n      });\n    }\n\n    let idxMatch: LoadedFilament | undefined;\n    let exactMatch: LoadedFilament | undefined;\n    let similarMatch: LoadedFilament | undefined;\n    let typeOnlyMatch: LoadedFilament | undefined;\n\n    // Check if tray_info_idx is unique among available trays\n    if (reqTrayInfoIdx) {\n      const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);\n      if (idxMatches.length === 1) {\n        // Unique tray_info_idx - use it as definitive match\n        idxMatch = idxMatches[0];\n      } else if (idxMatches.length > 1) {\n        // Multiple trays with same tray_info_idx - use color matching among them\n        if (preferLowest) {\n          idxMatches.sort((a, b) => {\n            const ra = a.remain >= 0 ? a.remain : 101;\n            const rb = b.remain >= 0 ? b.remain : 101;\n            return ra - rb;\n          });\n        }\n        exactMatch = idxMatches.find(\n          (f) =>\n            f.type?.toUpperCase() === req.type?.toUpperCase() &&\n            normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)\n        );\n        if (!exactMatch) {\n          similarMatch = idxMatches.find(\n            (f) =>\n              f.type?.toUpperCase() === req.type?.toUpperCase() &&\n              colorsAreSimilar(f.color, req.color)\n          );\n        }\n        if (!exactMatch && !similarMatch) {\n          typeOnlyMatch = idxMatches.find(\n            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()\n          );\n        }\n      }\n    }\n\n    // If no idx match, do standard type/color matching on all available trays\n    if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {\n      exactMatch = available.find(\n        (f) =>\n          f.type?.toUpperCase() === req.type?.toUpperCase() &&\n          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)\n      );\n      if (!exactMatch) {\n        similarMatch = available.find(\n          (f) =>\n            f.type?.toUpperCase() === req.type?.toUpperCase() &&\n            colorsAreSimilar(f.color, req.color)\n        );\n      }\n      if (!exactMatch && !similarMatch) {\n        typeOnlyMatch = available.find(\n          (f) => f.type?.toUpperCase() === req.type?.toUpperCase()\n        );\n      }\n    }\n\n    const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;\n\n    // Mark this tray as used so it won't be assigned to another slot\n    if (loaded) {\n      usedTrayIds.add(loaded.globalTrayId);\n    }\n\n    return {\n      slot_id: req.slot_id,\n      globalTrayId: loaded?.globalTrayId ?? -1,\n    };\n  });\n\n  // Find the max slot_id to determine array size\n  const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));\n  if (maxSlotId <= 0) return undefined;\n\n  // Create array with -1 for all positions\n  const mapping = new Array(maxSlotId).fill(-1);\n\n  // Fill in tray IDs at correct positions (slot_id - 1)\n  comparisons.forEach((f) => {\n    if (f.slot_id && f.slot_id > 0) {\n      mapping[f.slot_id - 1] = f.globalTrayId;\n    }\n  });\n\n  return mapping;\n}\n\n/**\n * Represents a loaded filament in the printer's AMS/HT/External spool holder.\n */\nexport interface LoadedFilament {\n  type: string;\n  color: string;\n  colorName: string;\n  amsId: number;\n  trayId: number;\n  isHt: boolean;\n  isExternal: boolean;\n  label: string;\n  globalTrayId: number;\n  /** Unique spool identifier (e.g., \"GFA00\", \"P4d64437\") */\n  trayInfoIdx?: string;\n  /** Filament subtype name (e.g., \"PLA Basic\", \"PLA Matte\", \"PETG HF\") */\n  traySubBrands?: string;\n  /** Extruder ID for dual-nozzle printers (0=right, 1=left) */\n  extruderId?: number;\n  /** Remaining filament percentage (0-100), -1 = unknown */\n  remain: number;\n}\n\n/**\n * Represents a required filament from the 3MF file.\n */\nexport interface FilamentRequirement {\n  slot_id: number;\n  type: string;\n  color: string;\n  used_grams: number;\n  /** Unique spool identifier from slicing (e.g., \"GFA00\", \"P4d64437\") */\n  tray_info_idx?: string;\n  /** Target nozzle for dual-nozzle printers (0=right, 1=left) */\n  nozzle_id?: number;\n}\n\n/**\n * Status of filament comparison between required and loaded.\n */\nexport type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';\n\n/**\n * Result of comparing a required filament with loaded filaments.\n */\nexport interface FilamentComparison extends FilamentRequirement {\n  loaded: LoadedFilament | undefined;\n  hasFilament: boolean;\n  typeMatch: boolean;\n  colorMatch: boolean;\n  status: FilamentStatus;\n  isManual: boolean;\n}\n\ninterface FilamentRequirementsResponse {\n  filaments: FilamentRequirement[];\n}\n\ninterface UseFilamentMappingResult {\n  /** List of all filaments loaded in the printer */\n  loadedFilaments: LoadedFilament[];\n  /** Comparison results for each required filament */\n  filamentComparison: FilamentComparison[];\n  /** AMS mapping array for the print command */\n  amsMapping: number[] | undefined;\n  /** Whether any required filament type is not loaded */\n  hasTypeMismatch: boolean;\n  /** Whether any required filament has a color mismatch */\n  hasColorMismatch: boolean;\n}\n\n/**\n * Hook to build loaded filaments list from printer status.\n * Extracts filaments from all AMS units (regular and HT) and external spool.\n */\nexport function useLoadedFilaments(\n  printerStatus: PrinterStatus | undefined\n): LoadedFilament[] {\n  return useMemo(() => {\n    return buildLoadedFilaments(printerStatus);\n  }, [printerStatus]);\n}\n\n/**\n * Hook to compare required filaments with loaded filaments and build AMS mapping.\n * Handles both auto-matching and manual overrides.\n *\n * @param filamentReqs - Required filaments from the 3MF file\n * @param printerStatus - Current printer status with AMS information\n * @param manualMappings - Manual slot overrides (slot_id -> globalTrayId)\n */\nexport function useFilamentMapping(\n  filamentReqs: FilamentRequirementsResponse | undefined,\n  printerStatus: PrinterStatus | undefined,\n  manualMappings: Record<number, number>,\n  preferLowest?: boolean,\n): UseFilamentMappingResult {\n  const loadedFilaments = useLoadedFilaments(printerStatus);\n\n  const filamentComparison = useMemo(() => {\n    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];\n\n    // Track which trays have been assigned to avoid duplicates\n    // First, mark all manually assigned trays as used\n    const usedTrayIds = new Set<number>(Object.values(manualMappings));\n\n    return filamentReqs.filaments.map((req) => {\n      const slotId = req.slot_id || 0;\n\n      // Check if there's a manual override for this slot\n      if (slotId > 0 && manualMappings[slotId] !== undefined) {\n        const manualTrayId = manualMappings[slotId];\n        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);\n\n        if (manualLoaded) {\n          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();\n          const colorMatch =\n            normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||\n            colorsAreSimilar(manualLoaded.color, req.color);\n\n          let status: FilamentStatus;\n          if (typeMatch && colorMatch) {\n            status = 'match';\n          } else if (typeMatch) {\n            status = 'type_only';\n          } else {\n            status = 'mismatch';\n          }\n\n          return {\n            ...req,\n            loaded: manualLoaded,\n            hasFilament: true,\n            typeMatch,\n            colorMatch,\n            status,\n            isManual: true,\n          };\n        }\n      }\n\n      // Auto-match: Find a loaded filament\n      // Priority: unique tray_info_idx match > exact color match > similar color match > type-only match\n      // IMPORTANT: Exclude trays that are already assigned (manually or auto)\n      const reqTrayInfoIdx = req.tray_info_idx || '';\n\n      // Get available trays (not already used)\n      let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));\n\n      // Nozzle-aware filtering: restrict to trays on the correct nozzle.\n      // This is a hard filter — cross-nozzle assignment causes print failures.\n      if (req.nozzle_id != null) {\n        available = available.filter((f) => f.extruderId === req.nozzle_id);\n      }\n\n      // Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first\n      if (preferLowest) {\n        available = [...available].sort((a, b) => {\n          const ra = a.remain >= 0 ? a.remain : 101;\n          const rb = b.remain >= 0 ? b.remain : 101;\n          return ra - rb;\n        });\n      }\n\n      let idxMatch: LoadedFilament | undefined;\n      let exactMatch: LoadedFilament | undefined;\n      let similarMatch: LoadedFilament | undefined;\n      let typeOnlyMatch: LoadedFilament | undefined;\n\n      // Check if tray_info_idx is unique among available trays\n      if (reqTrayInfoIdx) {\n        const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);\n        if (idxMatches.length === 1) {\n          // Unique tray_info_idx - use it as definitive match\n          idxMatch = idxMatches[0];\n        } else if (idxMatches.length > 1) {\n          // Multiple trays with same tray_info_idx - use color matching among them\n          if (preferLowest) {\n            idxMatches.sort((a, b) => {\n              const ra = a.remain >= 0 ? a.remain : 101;\n              const rb = b.remain >= 0 ? b.remain : 101;\n              return ra - rb;\n            });\n          }\n          exactMatch = idxMatches.find(\n            (f) =>\n              f.type?.toUpperCase() === req.type?.toUpperCase() &&\n              normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)\n          );\n          if (!exactMatch) {\n            similarMatch = idxMatches.find(\n              (f) =>\n                f.type?.toUpperCase() === req.type?.toUpperCase() &&\n                colorsAreSimilar(f.color, req.color)\n            );\n          }\n          if (!exactMatch && !similarMatch) {\n            typeOnlyMatch = idxMatches.find(\n              (f) => f.type?.toUpperCase() === req.type?.toUpperCase()\n            );\n          }\n        }\n      }\n\n      // If no idx match, do standard type/color matching on all available trays\n      if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {\n        exactMatch = available.find(\n          (f) =>\n            f.type?.toUpperCase() === req.type?.toUpperCase() &&\n            normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)\n        );\n        if (!exactMatch) {\n          similarMatch = available.find(\n            (f) =>\n              f.type?.toUpperCase() === req.type?.toUpperCase() &&\n              colorsAreSimilar(f.color, req.color)\n          );\n        }\n        if (!exactMatch && !similarMatch) {\n          typeOnlyMatch = available.find(\n            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()\n          );\n        }\n      }\n\n      const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;\n\n      // Mark this tray as used so it won't be assigned to another slot\n      if (loaded) {\n        usedTrayIds.add(loaded.globalTrayId);\n      }\n\n      const hasFilament = !!loaded;\n      const typeMatch = hasFilament;\n      // idxMatch is always considered a color match (same spool = same color)\n      const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;\n\n      // Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)\n      let status: FilamentStatus;\n      if (idxMatch || exactMatch || similarMatch) {\n        status = 'match';\n      } else if (typeOnlyMatch) {\n        status = 'type_only';\n      } else {\n        status = 'mismatch';\n      }\n\n      return {\n        ...req,\n        loaded,\n        hasFilament,\n        typeMatch,\n        colorMatch,\n        status,\n        isManual: false,\n      };\n    });\n  }, [filamentReqs, loadedFilaments, manualMappings, preferLowest]);\n\n  // Build AMS mapping from matched filaments\n  // Format: array matching 3MF filament slot structure\n  // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused\n  const amsMapping = useMemo(() => {\n    if (filamentComparison.length === 0) return undefined;\n\n    // Find the max slot_id to determine array size\n    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));\n    if (maxSlotId <= 0) return undefined;\n\n    // Create array with -1 for all positions\n    const mapping = new Array(maxSlotId).fill(-1);\n\n    // Fill in tray IDs at correct positions (slot_id - 1)\n    filamentComparison.forEach((f) => {\n      if (f.slot_id && f.slot_id > 0) {\n        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;\n      }\n    });\n\n    return mapping;\n  }, [filamentComparison]);\n\n  const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');\n  const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');\n\n  return {\n    loadedFilaments,\n    filamentComparison,\n    amsMapping,\n    hasTypeMismatch,\n    hasColorMismatch,\n  };\n}\n"
  },
  {
    "path": "frontend/src/hooks/useIsMobile.ts",
    "content": "import { useState, useEffect } from 'react';\n\nconst MOBILE_BREAKPOINT = 768;\n\nexport function useIsMobile(): boolean {\n  const [isMobile, setIsMobile] = useState(() =>\n    typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false\n  );\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);\n\n    const handleChange = (e: MediaQueryListEvent) => {\n      setIsMobile(e.matches);\n    };\n\n    // Set initial value\n    setIsMobile(mediaQuery.matches);\n\n    // Modern browsers support addEventListener\n    mediaQuery.addEventListener('change', handleChange);\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, []);\n\n  return isMobile;\n}\n"
  },
  {
    "path": "frontend/src/hooks/useIsSidebarCompact.ts",
    "content": "import { useState, useEffect } from 'react';\n\nconst SIDEBAR_COMPACT_BREAKPOINT = 1144;\n\nexport function useIsSidebarCompact(): boolean {\n  const [isCompact, setIsCompact] = useState(() =>\n    typeof window !== 'undefined' ? window.innerWidth < SIDEBAR_COMPACT_BREAKPOINT : false\n  );\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(`(max-width: ${SIDEBAR_COMPACT_BREAKPOINT - 1}px)`);\n\n    const handleChange = (e: MediaQueryListEvent) => {\n      setIsCompact(e.matches);\n    };\n\n    setIsCompact(mediaQuery.matches);\n\n    mediaQuery.addEventListener('change', handleChange);\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, []);\n\n  return isCompact;\n}\n"
  },
  {
    "path": "frontend/src/hooks/useLongPress.ts",
    "content": "import { useCallback, useRef } from 'react';\n\ninterface LongPressOptions {\n  onLongPress: (e: React.TouchEvent | React.MouseEvent) => void;\n  onClick?: () => void;\n  delay?: number;\n}\n\nexport function useLongPress({ onLongPress, onClick, delay = 500 }: LongPressOptions) {\n  const timeoutRef = useRef<number | null>(null);\n  const targetRef = useRef<EventTarget | null>(null);\n  const longPressTriggered = useRef(false);\n\n  const start = useCallback(\n    (e: React.TouchEvent | React.MouseEvent) => {\n      longPressTriggered.current = false;\n      targetRef.current = e.target;\n      timeoutRef.current = window.setTimeout(() => {\n        longPressTriggered.current = true;\n        onLongPress(e);\n      }, delay);\n    },\n    [onLongPress, delay]\n  );\n\n  const clear = useCallback(\n    (e: React.TouchEvent | React.MouseEvent, shouldTriggerClick = true) => {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n        timeoutRef.current = null;\n      }\n      if (shouldTriggerClick && !longPressTriggered.current && onClick && targetRef.current === e.target) {\n        onClick();\n      }\n    },\n    [onClick]\n  );\n\n  return {\n    onMouseDown: start,\n    onMouseUp: (e: React.MouseEvent) => clear(e, true),\n    onMouseLeave: (e: React.MouseEvent) => clear(e, false),\n    onTouchStart: start,\n    onTouchEnd: (e: React.TouchEvent) => clear(e, true),\n  };\n}\n"
  },
  {
    "path": "frontend/src/hooks/useMultiPrinterFilamentMapping.ts",
    "content": "import { useMemo } from 'react';\nimport { useQueries } from '@tanstack/react-query';\nimport { api } from '../api/client';\nimport type { PrinterStatus, Printer } from '../api/client';\nimport {\n  buildLoadedFilaments,\n  computeAmsMapping,\n  type LoadedFilament,\n  type FilamentRequirement,\n} from './useFilamentMapping';\nimport {\n  normalizeColorForCompare,\n  colorsAreSimilar,\n} from '../utils/amsHelpers';\n\n/**\n * Match status for a single printer's filament configuration.\n */\nexport type PrinterMatchStatus = 'full' | 'partial' | 'missing';\n\n/**\n * Per-printer configuration for AMS mapping.\n */\nexport interface PerPrinterConfig {\n  /** Whether this printer uses the default mapping or has custom config */\n  useDefault: boolean;\n  /** Manual slot overrides for this printer (slot_id -> globalTrayId) */\n  manualMappings: Record<number, number>;\n  /** Whether this mapping was auto-configured */\n  autoConfigured: boolean;\n}\n\n/**\n * Result of filament mapping for a single printer.\n */\nexport interface PrinterMappingResult {\n  printerId: number;\n  printerName: string;\n  /** Printer status data */\n  status: PrinterStatus | undefined;\n  /** Whether status is still loading */\n  isLoading: boolean;\n  /** List of loaded filaments in this printer */\n  loadedFilaments: LoadedFilament[];\n  /** Auto-computed AMS mapping for this printer */\n  autoMapping: number[] | undefined;\n  /** Final AMS mapping (considering manual overrides) */\n  finalMapping: number[] | undefined;\n  /** Match status: full (all exact), partial (some mismatches), missing (type not found) */\n  matchStatus: PrinterMatchStatus;\n  /** Number of slots with exact match (type + color) */\n  exactMatches: number;\n  /** Number of slots with type-only match */\n  typeOnlyMatches: number;\n  /** Number of slots with missing type */\n  missingTypes: number;\n  /** Total required slots */\n  totalSlots: number;\n  /** Per-printer config */\n  config: PerPrinterConfig;\n}\n\n/**\n * Result of the useMultiPrinterFilamentMapping hook.\n */\nexport interface UseMultiPrinterFilamentMappingResult {\n  /** Results for each selected printer */\n  printerResults: PrinterMappingResult[];\n  /** Whether any printer data is still loading */\n  isLoading: boolean;\n  /** Per-printer configurations */\n  perPrinterConfigs: Record<number, PerPrinterConfig>;\n  /** Update config for a specific printer */\n  updatePrinterConfig: (printerId: number, config: Partial<PerPrinterConfig>) => void;\n  /** Auto-configure all printers based on their loaded filaments */\n  autoConfigureAll: () => void;\n  /** Auto-configure a specific printer */\n  autoConfigurePrinter: (printerId: number) => void;\n  /** Get final mapping for a specific printer (for submission) */\n  getFinalMapping: (printerId: number) => number[] | undefined;\n  /** Check if all printers have acceptable mappings */\n  allPrintersReady: boolean;\n}\n\n/**\n * Compute match details for a printer given filament requirements and loaded filaments.\n */\nfunction computeMatchDetails(\n  filamentReqs: FilamentRequirement[] | undefined,\n  loadedFilaments: LoadedFilament[],\n  manualMappings: Record<number, number>,\n  preferLowest?: boolean,\n): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {\n  if (!filamentReqs || filamentReqs.length === 0) {\n    return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };\n  }\n\n  let exactMatches = 0;\n  let typeOnlyMatches = 0;\n  let missingTypes = 0;\n  const usedTrayIds = new Set<number>(Object.values(manualMappings));\n\n  for (const req of filamentReqs) {\n    const slotId = req.slot_id || 0;\n\n    // Check manual override first\n    if (slotId > 0 && manualMappings[slotId] !== undefined) {\n      const manualTrayId = manualMappings[slotId];\n      const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);\n\n      if (manualLoaded) {\n        const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();\n        const colorMatch =\n          normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||\n          colorsAreSimilar(manualLoaded.color, req.color);\n\n        if (typeMatch && colorMatch) {\n          exactMatches++;\n        } else if (typeMatch) {\n          typeOnlyMatches++;\n        } else {\n          missingTypes++;\n        }\n        continue;\n      }\n    }\n\n    // Auto-match with nozzle-aware filtering\n    let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));\n    if (req.nozzle_id != null) {\n      const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);\n      if (nozzleFiltered.length > 0) {\n        candidates = nozzleFiltered;\n      }\n    }\n\n    if (preferLowest) {\n      candidates = [...candidates].sort((a, b) => {\n        const ra = a.remain >= 0 ? a.remain : 101;\n        const rb = b.remain >= 0 ? b.remain : 101;\n        return ra - rb;\n      });\n    }\n\n    const exactMatch = candidates.find(\n      (f) =>\n        f.type?.toUpperCase() === req.type?.toUpperCase() &&\n        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)\n    );\n    const similarMatch = exactMatch\n      ? undefined\n      : candidates.find(\n          (f) =>\n            f.type?.toUpperCase() === req.type?.toUpperCase() &&\n            colorsAreSimilar(f.color, req.color)\n        );\n    const typeOnlyMatch =\n      exactMatch || similarMatch\n        ? undefined\n        : candidates.find(\n            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()\n          );\n    const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;\n\n    if (loaded) {\n      usedTrayIds.add(loaded.globalTrayId);\n    }\n\n    if (exactMatch || similarMatch) {\n      exactMatches++;\n    } else if (typeOnlyMatch) {\n      typeOnlyMatches++;\n    } else {\n      missingTypes++;\n    }\n  }\n\n  const totalSlots = filamentReqs.length;\n  let status: PrinterMatchStatus = 'full';\n  if (missingTypes > 0) {\n    status = 'missing';\n  } else if (typeOnlyMatches > 0) {\n    status = 'partial';\n  }\n\n  return { exactMatches, typeOnlyMatches, missingTypes, totalSlots, status };\n}\n\n/**\n * Compute AMS mapping with manual overrides applied.\n */\nfunction computeMappingWithOverrides(\n  filamentReqs: { filaments: FilamentRequirement[] } | undefined,\n  printerStatus: PrinterStatus | undefined,\n  manualMappings: Record<number, number>,\n  preferLowest?: boolean,\n): number[] | undefined {\n  if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;\n\n  const loadedFilaments = buildLoadedFilaments(printerStatus);\n  if (loadedFilaments.length === 0) return undefined;\n\n  const usedTrayIds = new Set<number>(Object.values(manualMappings));\n  const comparisons: { slot_id: number; globalTrayId: number }[] = [];\n\n  for (const req of filamentReqs.filaments) {\n    const slotId = req.slot_id || 0;\n\n    // Check manual override first\n    if (slotId > 0 && manualMappings[slotId] !== undefined) {\n      comparisons.push({ slot_id: slotId, globalTrayId: manualMappings[slotId] });\n      continue;\n    }\n\n    // Auto-match with nozzle-aware filtering\n    let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));\n    if (req.nozzle_id != null) {\n      const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);\n      if (nozzleFiltered.length > 0) {\n        candidates = nozzleFiltered;\n      }\n    }\n\n    if (preferLowest) {\n      candidates = [...candidates].sort((a, b) => {\n        const ra = a.remain >= 0 ? a.remain : 101;\n        const rb = b.remain >= 0 ? b.remain : 101;\n        return ra - rb;\n      });\n    }\n\n    const exactMatch = candidates.find(\n      (f) =>\n        f.type?.toUpperCase() === req.type?.toUpperCase() &&\n        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)\n    );\n    const similarMatch = exactMatch\n      ? undefined\n      : candidates.find(\n          (f) =>\n            f.type?.toUpperCase() === req.type?.toUpperCase() &&\n            colorsAreSimilar(f.color, req.color)\n        );\n    const typeOnlyMatch =\n      exactMatch || similarMatch\n        ? undefined\n        : candidates.find(\n            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()\n          );\n    const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;\n\n    if (loaded) {\n      usedTrayIds.add(loaded.globalTrayId);\n    }\n\n    comparisons.push({ slot_id: slotId, globalTrayId: loaded?.globalTrayId ?? -1 });\n  }\n\n  const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));\n  if (maxSlotId <= 0) return undefined;\n\n  const mapping = new Array(maxSlotId).fill(-1);\n  comparisons.forEach((f) => {\n    if (f.slot_id && f.slot_id > 0) {\n      mapping[f.slot_id - 1] = f.globalTrayId;\n    }\n  });\n\n  return mapping;\n}\n\n/**\n * Default per-printer config (use default mapping).\n */\nconst DEFAULT_PRINTER_CONFIG: PerPrinterConfig = {\n  useDefault: true,\n  manualMappings: {},\n  autoConfigured: false,\n};\n\n/**\n * Hook to manage filament mapping for multiple printers.\n * Fetches printer status for all selected printers and computes per-printer mappings.\n */\nexport function useMultiPrinterFilamentMapping(\n  selectedPrinterIds: number[],\n  printers: Printer[] | undefined,\n  filamentReqs: { filaments: FilamentRequirement[] } | undefined,\n  defaultMappings: Record<number, number>,\n  perPrinterConfigs: Record<number, PerPrinterConfig>,\n  setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>,\n  preferLowest?: boolean,\n): UseMultiPrinterFilamentMappingResult {\n  // Fetch printer status for all selected printers in parallel\n  const statusQueries = useQueries({\n    queries: selectedPrinterIds.map((printerId) => ({\n      queryKey: ['printer-status', printerId],\n      queryFn: () => api.getPrinterStatus(printerId),\n      enabled: selectedPrinterIds.length > 0,\n      staleTime: 5000, // Consider data fresh for 5 seconds\n    })),\n  });\n\n  // Build results for each printer\n  const printerResults = useMemo((): PrinterMappingResult[] => {\n    return selectedPrinterIds.map((printerId, index) => {\n      const query = statusQueries[index];\n      const printerStatus = query?.data;\n      const printer = printers?.find((p) => p.id === printerId);\n      const printerName = printer?.name || `Printer ${printerId}`;\n\n      const loadedFilaments = buildLoadedFilaments(printerStatus);\n      const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;\n\n      // Compute auto mapping for this printer\n      const autoMapping = computeAmsMapping(filamentReqs, printerStatus, preferLowest);\n\n      // Determine which mappings to use:\n      // If printer has override (useDefault=false), use its custom mappings\n      // Otherwise use the default mappings\n      const effectiveMappings = !config.useDefault\n        ? config.manualMappings\n        : defaultMappings;\n\n      // Compute final mapping with overrides\n      const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, preferLowest);\n\n      // Compute match details\n      const matchDetails = computeMatchDetails(\n        filamentReqs?.filaments,\n        loadedFilaments,\n        effectiveMappings,\n        preferLowest,\n      );\n\n      return {\n        printerId,\n        printerName,\n        status: printerStatus,\n        isLoading: query?.isLoading ?? false,\n        loadedFilaments,\n        autoMapping,\n        finalMapping,\n        matchStatus: matchDetails.status,\n        exactMatches: matchDetails.exactMatches,\n        typeOnlyMatches: matchDetails.typeOnlyMatches,\n        missingTypes: matchDetails.missingTypes,\n        totalSlots: matchDetails.totalSlots,\n        config,\n      };\n    });\n  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest]);\n\n  const isLoading = statusQueries.some((q) => q.isLoading);\n\n  // Update config for a specific printer\n  const updatePrinterConfig = (printerId: number, updates: Partial<PerPrinterConfig>) => {\n    setPerPrinterConfigs((prev) => ({\n      ...prev,\n      [printerId]: {\n        ...(prev[printerId] || DEFAULT_PRINTER_CONFIG),\n        ...updates,\n      },\n    }));\n  };\n\n  // Auto-configure a specific printer based on its loaded filaments\n  const autoConfigurePrinter = (printerId: number) => {\n    const result = printerResults.find((r) => r.printerId === printerId);\n    if (!result || !result.status || !filamentReqs?.filaments) return;\n\n    // Compute optimal mapping for this printer\n    const autoMapping = computeAmsMapping(filamentReqs, result.status, preferLowest);\n    if (!autoMapping) return;\n\n    // Convert autoMapping array to manualMappings record\n    const manualMappings: Record<number, number> = {};\n    autoMapping.forEach((globalTrayId, index) => {\n      if (globalTrayId !== -1) {\n        manualMappings[index + 1] = globalTrayId;\n      }\n    });\n\n    updatePrinterConfig(printerId, {\n      useDefault: false,\n      manualMappings,\n      autoConfigured: true,\n    });\n  };\n\n  // Auto-configure all printers\n  const autoConfigureAll = () => {\n    for (const printerId of selectedPrinterIds) {\n      autoConfigurePrinter(printerId);\n    }\n  };\n\n  // Get final mapping for a specific printer (for submission)\n  const getFinalMapping = (printerId: number): number[] | undefined => {\n    const result = printerResults.find((r) => r.printerId === printerId);\n    return result?.finalMapping;\n  };\n\n  // Check if all printers have acceptable mappings (no missing types)\n  const allPrintersReady = printerResults.every((r) => r.matchStatus !== 'missing');\n\n  return {\n    printerResults,\n    isLoading,\n    perPrinterConfigs,\n    updatePrinterConfig,\n    autoConfigureAll,\n    autoConfigurePrinter,\n    getFinalMapping,\n    allPrintersReady,\n  };\n}\n"
  },
  {
    "path": "frontend/src/hooks/useSpoolBuddyState.ts",
    "content": "import { useEffect, useReducer, useCallback } from 'react';\n\nexport interface MatchedSpool {\n  id: number;\n  tag_uid: string;\n  material: string;\n  subtype: string | null;\n  color_name: string | null;\n  rgba: string | null;\n  brand: string | null;\n  label_weight: number;\n  core_weight: number;\n  weight_used: number;\n}\n\nexport interface SpoolBuddyState {\n  weight: number | null;\n  weightStable: boolean;\n  rawAdc: number | null;\n  matchedSpool: MatchedSpool | null;\n  unknownTagUid: string | null;\n  deviceOnline: boolean;\n  deviceId: string | null;\n}\n\ntype Action =\n  | { type: 'WEIGHT_UPDATE'; weight: number; stable: boolean; rawAdc: number; deviceId: string }\n  | { type: 'TAG_MATCHED'; spool: MatchedSpool; deviceId: string }\n  | { type: 'UNKNOWN_TAG'; tagUid: string; deviceId: string }\n  | { type: 'TAG_REMOVED'; deviceId: string }\n  | { type: 'DEVICE_ONLINE'; deviceId: string }\n  | { type: 'DEVICE_OFFLINE'; deviceId: string };\n\nconst initialState: SpoolBuddyState = {\n  weight: null,\n  weightStable: false,\n  rawAdc: null,\n  matchedSpool: null,\n  unknownTagUid: null,\n  deviceOnline: false,\n  deviceId: null,\n};\n\nfunction reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {\n  switch (action.type) {\n    case 'WEIGHT_UPDATE':\n      return {\n        ...state,\n        weight: action.weight,\n        weightStable: action.stable,\n        rawAdc: action.rawAdc,\n        deviceId: action.deviceId,\n        deviceOnline: true,\n      };\n    case 'TAG_MATCHED':\n      return {\n        ...state,\n        matchedSpool: action.spool,\n        unknownTagUid: null,\n        deviceId: action.deviceId,\n      };\n    case 'UNKNOWN_TAG':\n      return {\n        ...state,\n        matchedSpool: null,\n        unknownTagUid: action.tagUid,\n        deviceId: action.deviceId,\n      };\n    case 'TAG_REMOVED':\n      return {\n        ...state,\n        matchedSpool: null,\n        unknownTagUid: null,\n      };\n    case 'DEVICE_ONLINE':\n      return {\n        ...state,\n        deviceOnline: true,\n        deviceId: action.deviceId,\n      };\n    case 'DEVICE_OFFLINE':\n      return {\n        ...state,\n        deviceOnline: false,\n        weight: null,\n        weightStable: false,\n        rawAdc: null,\n      };\n    default:\n      return state;\n  }\n}\n\nexport function useSpoolBuddyState() {\n  const [state, dispatch] = useReducer(reducer, initialState);\n\n  const handleWeight = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    dispatch({\n      type: 'WEIGHT_UPDATE',\n      weight: detail.weight_grams ?? detail.data?.weight_grams,\n      stable: detail.stable ?? detail.data?.stable ?? false,\n      rawAdc: detail.raw_adc ?? detail.data?.raw_adc ?? null,\n      deviceId: detail.device_id ?? detail.data?.device_id ?? '',\n    });\n  }, []);\n\n  const handleTagMatched = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    const spool = detail.spool ?? detail.data?.spool;\n    if (spool) {\n      dispatch({\n        type: 'TAG_MATCHED',\n        spool: {\n          id: spool.id,\n          tag_uid: detail.tag_uid ?? detail.data?.tag_uid ?? '',\n          material: spool.material ?? '',\n          subtype: spool.subtype ?? null,\n          color_name: spool.color_name ?? null,\n          rgba: spool.rgba ?? null,\n          brand: spool.brand ?? null,\n          label_weight: spool.label_weight ?? 0,\n          core_weight: spool.core_weight ?? 0,\n          weight_used: spool.weight_used ?? 0,\n        },\n        deviceId: detail.device_id ?? detail.data?.device_id ?? '',\n      });\n    }\n  }, []);\n\n  const handleUnknownTag = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    dispatch({\n      type: 'UNKNOWN_TAG',\n      tagUid: detail.tag_uid ?? detail.data?.tag_uid ?? '',\n      deviceId: detail.device_id ?? detail.data?.device_id ?? '',\n    });\n  }, []);\n\n  const handleTagRemoved = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    dispatch({\n      type: 'TAG_REMOVED',\n      deviceId: detail.device_id ?? detail.data?.device_id ?? '',\n    });\n  }, []);\n\n  const handleOnline = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    dispatch({\n      type: 'DEVICE_ONLINE',\n      deviceId: detail.device_id ?? detail.data?.device_id ?? '',\n    });\n  }, []);\n\n  const handleOffline = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    dispatch({\n      type: 'DEVICE_OFFLINE',\n      deviceId: detail.device_id ?? detail.data?.device_id ?? '',\n    });\n  }, []);\n\n  useEffect(() => {\n    window.addEventListener('spoolbuddy-weight', handleWeight);\n    window.addEventListener('spoolbuddy-tag-matched', handleTagMatched);\n    window.addEventListener('spoolbuddy-unknown-tag', handleUnknownTag);\n    window.addEventListener('spoolbuddy-tag-removed', handleTagRemoved);\n    window.addEventListener('spoolbuddy-online', handleOnline);\n    window.addEventListener('spoolbuddy-offline', handleOffline);\n\n    return () => {\n      window.removeEventListener('spoolbuddy-weight', handleWeight);\n      window.removeEventListener('spoolbuddy-tag-matched', handleTagMatched);\n      window.removeEventListener('spoolbuddy-unknown-tag', handleUnknownTag);\n      window.removeEventListener('spoolbuddy-tag-removed', handleTagRemoved);\n      window.removeEventListener('spoolbuddy-online', handleOnline);\n      window.removeEventListener('spoolbuddy-offline', handleOffline);\n    };\n  }, [handleWeight, handleTagMatched, handleUnknownTag, handleTagRemoved, handleOnline, handleOffline]);\n\n  const remainingWeight = state.matchedSpool\n    ? Math.max(0, state.matchedSpool.label_weight - state.matchedSpool.weight_used)\n    : null;\n\n  const netWeight = state.weight !== null && state.matchedSpool\n    ? Math.max(0, state.weight - state.matchedSpool.core_weight)\n    : null;\n\n  return {\n    ...state,\n    remainingWeight,\n    netWeight,\n  };\n}\n"
  },
  {
    "path": "frontend/src/hooks/useWebSocket.ts",
    "content": "import { useQueryClient } from '@tanstack/react-query';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useToast } from '../contexts/ToastContext';\nimport { useTranslation } from 'react-i18next';\n\ninterface WebSocketMessage {\n  type: string;\n  printer_id?: number;\n  data?: Record<string, unknown>;\n  printer_name?: string;\n  missing_slots?: Array<{ slot?: string }>;\n}\n\nexport function useWebSocket() {\n  const wsRef = useRef<WebSocket | null>(null);\n  const reconnectTimeoutRef = useRef<number | null>(null);\n  const queryClient = useQueryClient();\n  const [isConnected, setIsConnected] = useState(false);\n  const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());\n  const { showToast } = useToast();\n  const { t } = useTranslation();\n\n  // Debounce invalidations to prevent rapid re-render cascades\n  const pendingInvalidations = useRef<Set<string>>(new Set());\n  const invalidationTimeoutRef = useRef<number | null>(null);\n\n  // Throttle printer status updates to prevent freeze during rapid messages\n  const pendingPrinterStatus = useRef<Map<number, Record<string, unknown>>>(new Map());\n  const printerStatusTimeoutRef = useRef<number | null>(null);\n\n  // Throttle message processing to prevent browser freeze\n  const messageQueueRef = useRef<WebSocketMessage[]>([]);\n  const processingRef = useRef(false);\n\n  // Use ref for handleMessage to avoid stale closure in connect\n  const handleMessageRef = useRef<(message: WebSocketMessage) => void>(() => {});\n\n  // Process message queue with throttling to prevent UI freeze\n  const processMessageQueue = useCallback(() => {\n    if (processingRef.current || messageQueueRef.current.length === 0) {\n      return;\n    }\n\n    processingRef.current = true;\n\n    const processNext = () => {\n      const message = messageQueueRef.current.shift();\n      if (message) {\n        // Use requestAnimationFrame to yield to the browser\n        requestAnimationFrame(() => {\n          handleMessageRef.current(message);\n          // Small delay between messages to prevent overwhelming the browser\n          if (messageQueueRef.current.length > 0) {\n            setTimeout(processNext, 16); // ~60fps\n          } else {\n            processingRef.current = false;\n          }\n        });\n      } else {\n        processingRef.current = false;\n      }\n    };\n\n    processNext();\n  }, []);\n\n  const connect = useCallback(() => {\n    if (wsRef.current?.readyState === WebSocket.OPEN) {\n      return;\n    }\n\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;\n\n    const ws = new WebSocket(wsUrl);\n\n    let pingInterval: number | null = null;\n\n    ws.onopen = () => {\n      if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected');\n      setIsConnected(true);\n      // Start ping interval\n      pingInterval = window.setInterval(() => {\n        if (ws.readyState === WebSocket.OPEN) {\n          ws.send(JSON.stringify({ type: 'ping' }));\n        }\n      }, 30000);\n    };\n\n    ws.onmessage = (event) => {\n      try {\n        const message: WebSocketMessage = JSON.parse(event.data);\n        // Handle printer_status directly (already throttled) to avoid queue delays\n        // This prevents the \"timelapse\" effect where status updates are applied slowly\n        if (message.type === 'printer_status' && message.printer_id !== undefined && message.data) {\n          handleMessageRef.current(message);\n        } else {\n          // Queue other messages for throttled processing\n          messageQueueRef.current.push(message);\n          processMessageQueue();\n        }\n      } catch {\n        // Ignore parse errors\n      }\n    };\n\n    ws.onclose = (event) => {\n      if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Closed', event.code, event.reason);\n      if (pingInterval) {\n        clearInterval(pingInterval);\n        pingInterval = null;\n      }\n      setIsConnected(false);\n      wsRef.current = null;\n\n      // Reconnect after 3 seconds\n      reconnectTimeoutRef.current = window.setTimeout(() => {\n        connect();\n      }, 3000);\n    };\n\n    ws.onerror = (error) => {\n      if (import.meta.env.MODE !== 'test') console.error('[WebSocket] Error', error);\n      ws.close();\n    };\n\n    wsRef.current = ws;\n  }, [processMessageQueue]);\n\n  // Throttled printer status update - coalesces rapid updates per printer\n  const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {\n    // Merge with any pending data for this printer\n    const existing = pendingPrinterStatus.current.get(printerId) || {};\n    pendingPrinterStatus.current.set(printerId, { ...existing, ...data });\n\n    // Schedule update if not already scheduled\n    if (!printerStatusTimeoutRef.current) {\n      printerStatusTimeoutRef.current = window.setTimeout(() => {\n        const updates = new Map(pendingPrinterStatus.current);\n        pendingPrinterStatus.current.clear();\n        printerStatusTimeoutRef.current = null;\n\n        // Apply all pending updates\n        requestAnimationFrame(() => {\n          updates.forEach((statusData, id) => {\n            queryClient.setQueryData(\n              ['printerStatus', id],\n              (old: Record<string, unknown> | undefined) => {\n                const merged = { ...old, ...statusData };\n                if (merged.wifi_signal == null && old?.wifi_signal != null) {\n                  merged.wifi_signal = old.wifi_signal;\n                }\n                return merged;\n              }\n            );\n          });\n        });\n      }, 100); // Update at most every 100ms\n    }\n  }, [queryClient]);\n\n  // Debounced invalidation helper - coalesces multiple rapid invalidations\n  const debouncedInvalidate = useCallback((queryKey: string) => {\n    pendingInvalidations.current.add(queryKey);\n\n    // Clear existing timeout\n    if (invalidationTimeoutRef.current) {\n      clearTimeout(invalidationTimeoutRef.current);\n    }\n\n    // Schedule invalidation after a delay (3s to prevent browser freeze on print completion)\n    invalidationTimeoutRef.current = window.setTimeout(() => {\n      const keys = Array.from(pendingInvalidations.current);\n      pendingInvalidations.current.clear();\n      invalidationTimeoutRef.current = null;\n\n      // Invalidate queries one at a time with delays to prevent freeze\n      let delay = 0;\n      keys.forEach((key) => {\n        setTimeout(() => {\n          requestAnimationFrame(() => {\n            queryClient.invalidateQueries({ queryKey: [key] });\n          });\n        }, delay);\n        delay += 500; // 500ms between each invalidation\n      });\n    }, 3000);\n  }, [queryClient]);\n\n  const handleMessage = useCallback((message: WebSocketMessage) => {\n    switch (message.type) {\n      case 'printer_status':\n        if (message.printer_id !== undefined && message.data) {\n          throttledPrinterStatusUpdate(message.printer_id, message.data);\n        }\n        break;\n\n      case 'print_start':\n        // Refetch printer status immediately when print starts to get printable_objects_count\n        if (message.printer_id !== undefined) {\n          queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });\n        }\n        break;\n\n      case 'missing_spool_assignment': {\n        if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {\n          break;\n        }\n\n        const missingSlotLabels = message.missing_slots\n          .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))\n          .filter((slot) => slot.length > 0);\n\n        if (missingSlotLabels.length === 0) {\n          lastMissingSpoolWarningRef.current.delete(message.printer_id);\n          break;\n        }\n\n        const signature = missingSlotLabels.join('|');\n        if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {\n          break;\n        }\n        lastMissingSpoolWarningRef.current.set(message.printer_id, signature);\n\n        const printerName = message.printer_name || `Printer ${message.printer_id}`;\n        const toastMsg = t('printers.toast.missingSpoolAssignment', {\n          printer: printerName,\n          slots: missingSlotLabels.join(', '),\n        });\n        showToast(toastMsg, 'warning');\n        break;\n      }\n\n      case 'print_complete':\n        // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze\n        // The printer_status websocket messages will naturally update the status\n        debouncedInvalidate('archives');\n        debouncedInvalidate('archiveStats');\n        break;\n\n      case 'archive_created':\n        debouncedInvalidate('archives');\n        debouncedInvalidate('archiveStats');\n        break;\n\n      case 'archive_updated':\n        debouncedInvalidate('archives');\n        break;\n\n      case 'pong':\n        // Keepalive response, ignore\n        break;\n\n      case 'plate_not_empty':\n        // Plate detection found objects - print was paused\n        // Dispatch event for toast notification\n        window.dispatchEvent(new CustomEvent('plate-not-empty', {\n          detail: {\n            printer_id: message.printer_id,\n            printer_name: (message as unknown as { printer_name?: string }).printer_name,\n            message: (message as unknown as { message?: string }).message,\n          }\n        }));\n        break;\n\n      case 'inventory_changed':\n        // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs\n        debouncedInvalidate('inventory-spools');\n        break;\n\n      case 'spool_assignment_changed':\n        // Spool assigned/unassigned - refresh assignment data across all tabs\n        debouncedInvalidate('spool-assignments');\n        debouncedInvalidate('slotPresets');\n        break;\n\n      case 'spool_auto_assigned':\n        // RFID tag matched - refresh inventory and assignment data\n        debouncedInvalidate('inventory-spools');\n        debouncedInvalidate('spool-assignments');\n        break;\n\n      case 'spool_usage_logged':\n        // Filament consumption recorded - refresh spool data\n        debouncedInvalidate('inventory-spools');\n        break;\n\n      case 'unknown_tag':\n        // Unknown RFID tag detected - dispatch event for UI\n        window.dispatchEvent(new CustomEvent('unknown-tag', {\n          detail: {\n            printer_id: (message as unknown as { printer_id?: number }).printer_id,\n            ams_id: (message as unknown as { ams_id?: number }).ams_id,\n            tray_id: (message as unknown as { tray_id?: number }).tray_id,\n            tag_uid: (message as unknown as { tag_uid?: string }).tag_uid,\n            tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,\n          }\n        }));\n        break;\n\n      case 'background_dispatch':\n        window.dispatchEvent(\n          new CustomEvent('background-dispatch', {\n            detail: (message as unknown as { data?: Record<string, unknown> }).data || {},\n          })\n        );\n        break;\n\n      case 'spoolbuddy_weight':\n        window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));\n        break;\n\n      case 'spoolbuddy_tag_matched':\n        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));\n        debouncedInvalidate('inventory-spools');\n        break;\n\n      case 'spoolbuddy_unknown_tag':\n        window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));\n        break;\n\n      case 'spoolbuddy_tag_removed':\n        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));\n        break;\n\n      case 'spoolbuddy_tag_written':\n        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));\n        debouncedInvalidate('inventory-spools');\n        break;\n\n      case 'spoolbuddy_tag_write_failed':\n        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));\n        break;\n\n      case 'spoolbuddy_online':\n        window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));\n        debouncedInvalidate('spoolbuddy-devices');\n        debouncedInvalidate('spoolbuddy-update-check');\n        break;\n\n      case 'spoolbuddy_offline':\n        window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));\n        debouncedInvalidate('spoolbuddy-devices');\n        break;\n\n      case 'spoolbuddy_update':\n        debouncedInvalidate('spoolbuddy-devices');\n        debouncedInvalidate('spoolbuddy-update-check');\n        break;\n    }\n  }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);\n\n  // Keep the ref updated with latest handleMessage\n  useEffect(() => {\n    handleMessageRef.current = handleMessage;\n  }, [handleMessage]);\n\n  useEffect(() => {\n    connect();\n\n    return () => {\n      if (reconnectTimeoutRef.current) {\n        clearTimeout(reconnectTimeoutRef.current);\n      }\n      if (invalidationTimeoutRef.current) {\n        clearTimeout(invalidationTimeoutRef.current);\n      }\n      if (printerStatusTimeoutRef.current) {\n        clearTimeout(printerStatusTimeoutRef.current);\n      }\n      if (wsRef.current) {\n        wsRef.current.close();\n      }\n    };\n  }, [connect]);\n\n  const sendMessage = useCallback((message: Record<string, unknown>) => {\n    if (wsRef.current?.readyState === WebSocket.OPEN) {\n      wsRef.current.send(JSON.stringify(message));\n    }\n  }, []);\n\n  return { isConnected, sendMessage };\n}\n"
  },
  {
    "path": "frontend/src/i18n/index.ts",
    "content": "import i18n from 'i18next';\nimport { initReactI18next } from 'react-i18next';\nimport LanguageDetector from 'i18next-browser-languagedetector';\n\n// Import translations directly for bundling\nimport en from './locales/en';\nimport de from './locales/de';\nimport fr from './locales/fr';\nimport ja from './locales/ja';\nimport it from './locales/it';\nimport ptBR from './locales/pt-BR';\nimport zhCN from './locales/zh-CN';\nimport zhTW from './locales/zh-TW';\n\nconst resources = {\n  en: { translation: en },\n  de: { translation: de },\n  fr: { translation: fr },\n  ja: { translation: ja },\n  it: { translation: it },\n  'pt-BR': { translation: ptBR },\n  'zh-CN': { translation: zhCN },\n  'zh-TW': { translation: zhTW },\n};\n\ni18n\n  .use(LanguageDetector)\n  .use(initReactI18next)\n  .init({\n    resources,\n    fallbackLng: 'en',\n    supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR', 'zh-CN', 'zh-TW'],\n\n    detection: {\n      // Order of detection methods\n      order: ['localStorage', 'navigator', 'htmlTag'],\n      // Key to use in localStorage\n      lookupLocalStorage: 'bambutrack_language',\n      // Cache user language\n      caches: ['localStorage'],\n    },\n\n    interpolation: {\n      escapeValue: false, // React already escapes\n    },\n\n    react: {\n      useSuspense: false,\n    },\n  });\n\nexport default i18n;\n\n// Helper to get available languages\nexport const availableLanguages = [\n  { code: 'en', name: 'English', nativeName: 'English' },\n  { code: 'de', name: 'German', nativeName: 'Deutsch' },\n  { code: 'fr', name: 'French', nativeName: 'Français' },\n  { code: 'ja', name: 'Japanese', nativeName: '日本語' },\n  { code: 'it', name: 'Italian', nativeName: 'Italiano' },\n  { code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },\n  { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },\n  { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },\n];\n"
  },
  {
    "path": "frontend/src/i18n/locales/de.ts",
    "content": "export default {\n  // Navigation\n  nav: {\n    printers: 'Drucker',\n    archives: 'Archiv',\n    queue: 'Warteschlange',\n    stats: 'Statistiken',\n    profiles: 'Profile',\n    maintenance: 'Wartung',\n    projects: 'Projekte',\n    inventory: 'Filament',\n    files: 'Dateimanager',\n    notifications: 'Benachrichtigungen',\n    settings: 'Einstellungen',\n    system: 'System',\n    collapseSidebar: 'Seitenleiste einklappen',\n    expandSidebar: 'Seitenleiste ausklappen',\n    update: 'Update',\n    updateAvailable: 'Update verfügbar: v{{version}}',\n    updateAvailableBanner: 'Version {{version}} ist verfügbar!',\n    viewUpdate: 'Update anzeigen',\n    viewOnGithub: 'Auf GitHub ansehen',\n    keyboardShortcuts: 'Tastaturkürzel (?)',\n    switchToLight: 'Zum hellen Modus wechseln',\n    switchToDark: 'Zum dunklen Modus wechseln',\n    smartSwitches: 'Smart Switches',\n    logout: 'Abmelden',\n  },\n\n  // Common\n  common: {\n    save: 'Speichern',\n    saving: 'Speichern...',\n    cancel: 'Abbrechen',\n    delete: 'Löschen',\n    edit: 'Bearbeiten',\n    add: 'Hinzufügen',\n    close: 'Schließen',\n    confirm: 'Bestätigen',\n    loading: 'Lädt...',\n    error: 'Fehler',\n    success: 'Erfolg',\n    warning: 'Warnung',\n    enabled: 'Aktiviert',\n    disabled: 'Deaktiviert',\n    yes: 'Ja',\n    no: 'Nein',\n    on: 'An',\n    off: 'Aus',\n    all: 'Alle',\n    none: 'Keine',\n    search: 'Suchen',\n    filter: 'Filtern',\n    sort: 'Sortieren',\n    refresh: 'Aktualisieren',\n    download: 'Herunterladen',\n    upload: 'Hochladen',\n    uploading: 'Hochladen...',\n    uploadFailed: 'Hochladen fehlgeschlagen',\n    actions: 'Aktionen',\n    status: 'Status',\n    name: 'Name',\n    description: 'Beschreibung',\n    date: 'Datum',\n    time: 'Zeit',\n    hours: 'Stunden',\n    minutes: 'Minuten',\n    seconds: 'Sekunden',\n    days: 'Tage',\n    enable: 'Aktivieren',\n    disable: 'Deaktivieren',\n    permissions: 'Berechtigungen',\n    noPrinters: 'Keine Drucker konfiguriert',\n    noData: 'Keine Daten verfügbar',\n    linkNotFound: 'Link nicht gefunden',\n    required: 'Erforderlich',\n    optional: 'Optional',\n    dismiss: 'Schließen',\n    apply: 'Anwenden',\n    reset: 'Zurücksetzen',\n    export: 'Exportieren',\n    import: 'Importieren',\n    clear: 'Leeren',\n    selectAll: 'Alle auswählen',\n    deselectAll: 'Auswahl aufheben',\n    noChange: '— Keine Änderung —',\n    unchanged: 'Unverändert',\n    unassigned: 'Nicht zugewiesen',\n    unknown: 'Unbekannt',\n    unknownError: 'Unbekannter Fehler',\n    today: 'Heute',\n    tomorrow: 'Morgen',\n    asap: 'Sofort',\n    overdue: 'Überfällig',\n    now: 'Jetzt',\n    collapse: 'Einklappen',\n    expand: 'Ausklappen',\n    viewArchive: 'Archiv anzeigen',\n    viewInFileManager: 'Im Dateimanager anzeigen',\n    addedBy: 'Hinzugefügt von {{username}}',\n    prints: 'Drucke',\n    more: '+{{count}} weitere',\n    ascending: 'Aufsteigend',\n    descending: 'Absteigend',\n    back: 'Zurück',\n    copy: 'Kopieren',\n    copied: 'Kopiert!',\n    printer: 'Drucker',\n    remove: 'Entfernen',\n    type: 'Typ',\n    print: 'Drucken',\n    rename: 'Umbenennen',\n    move: 'Verschieben',\n    create: 'Erstellen',\n    duplicate: 'Duplizieren',\n    left: 'Links',\n    right: 'Rechts',\n  },\n\n  // Printers page\n  printers: {\n    title: 'Drucker',\n    addPrinter: 'Drucker hinzufügen',\n    editPrinter: 'Drucker bearbeiten',\n    deletePrinter: 'Drucker löschen',\n    printerName: 'Druckername',\n    serialNumber: 'Seriennummer',\n    ipAddress: 'IP-Adresse / Hostname',\n    accessCode: 'Zugangscode',\n    model: 'Modell',\n    nozzleCount: 'Düsenanzahl',\n    autoArchive: 'Automatische Archivierung',\n    status: {\n      available: 'Verfügbar',\n      idle: 'Bereit',\n      printing: 'Druckt',\n      paused: 'Pausiert',\n      offline: 'Offline',\n      problem: 'Problem',\n      error: 'Fehler',\n      finished: 'Fertig',\n      unknown: 'Unbekannt',\n    },\n    temperatures: {\n      nozzle: 'Düse',\n      bed: 'Druckbett',\n      chamber: 'Kammer',\n    },\n    progress: '{{percent}}% abgeschlossen',\n    timeRemaining: 'Noch {{time}}',\n    deleteConfirm: 'Möchten Sie \"{{name}}\" wirklich löschen?',\n    maintenanceOk: 'Wartung OK',\n    maintenanceWarning: '{{count}} Warnung',\n    maintenanceWarning_plural: '{{count}} Warnungen',\n    maintenanceDue: '{{count}} fällig',\n    maintenanceDue_plural: '{{count}} fällig',\n    // Sort options\n    sort: {\n      name: 'Name',\n      status: 'Status',\n      model: 'Modell',\n      location: 'Standort',\n      ascending: 'Aufsteigend sortieren',\n      descending: 'Absteigend sortieren',\n    },\n    // Card size\n    cardSize: {\n      small: 'Kleine Karten',\n      medium: 'Mittlere Karten',\n      large: 'Große Karten',\n      extraLarge: 'Extra große Karten',\n    },\n    // Controls\n    hideOffline: 'Offline ausblenden',\n    nextAvailable: 'Nächster verfügbar',\n    powerOn: 'Einschalten',\n    offlinePrintersWithPlugs: 'Offline-Drucker mit Smart-Plugs',\n    noPrintersConfigured: 'Noch keine Drucker konfiguriert',\n    search: 'Drucker suchen...',\n    noSearchResults: 'Keine Drucker entsprechen deiner Suche oder deinen Filtern',\n    filter: {\n      allStatuses: 'Alle Status',\n      allLocations: 'Alle Standorte',\n    },\n    // Printer card\n    readyToPrint: 'Druckbereit',\n    external: 'Extern',\n    extL: 'Ext-L',\n    extR: 'Ext-R',\n    deleteArchives: 'Druckarchive löschen',\n    noLabel: 'Keine Bezeichnung',\n    printPreview: 'Druckvorschau',\n    width: 'Breite',\n    height: 'Höhe',\n    noObjectsFound: 'Keine Objekte gefunden',\n    objectsLoadedOnPrintStart: 'Objekte werden beim Druckstart geladen',\n    willBeSkipped: 'Wird übersprungen',\n    name: 'Name',\n    serialCannotBeChanged: 'Seriennummer kann nicht geändert werden',\n    locationHelp: 'Dient zur Gruppierung von Druckern und zum Filtern von Warteschlangenaufträgen',\n    // WiFi signal strength\n    wifiSignal: {\n      veryWeak: 'Sehr schwach',\n      weak: 'Schwach',\n      fair: 'Ausreichend',\n      good: 'Gut',\n      excellent: 'Ausgezeichnet',\n    },\n    // Maintenance\n    maintenanceUpToDate: 'Alle Wartungen aktuell - Klicken zum Anzeigen',\n    // Chamber light\n    chamberLightOn: 'Kammerbeleuchtung einschalten',\n    chamberLightOff: 'Kammerbeleuchtung ausschalten',\n    // Files\n    files: 'Dateien',\n    browseFiles: 'Druckerdateien durchsuchen',\n    // Smart plug\n    autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',\n    autoOffExecuted: 'Auto-off wurde ausgeführt - Drucker einschalten zum Zurücksetzen',\n    // HMS errors\n    hmsErrors: 'HMS-Fehler',\n    viewHmsErrors: '{{count}} HMS-Fehler anzeigen',\n    // Actions\n    resume: 'Fortsetzen',\n    pause: 'Pausieren',\n    stop: 'Stoppen',\n    camera: 'Kamera',\n    skipObject: 'Objekt überspringen',\n    reconnect: 'Neu verbinden',\n    forceRefresh: 'Aktualisierung erzwingen',\n    forceRefreshSuccess: 'Aktualisierung angefordert',\n    mqttDebug: 'MQTT-Debug',\n    printerInformation: 'Druckerinformationen',\n    copyToClipboard: 'Kopieren',\n    copied: 'Kopiert!',\n    state: 'Zustand',\n    wifiSignalLabel: 'WLAN-Signal',\n    developerMode: 'Entwicklermodus',\n    enabled: 'Aktiviert',\n    disabled: 'Deaktiviert',\n    addedOn: 'Hinzugefügt',\n    sdCard: 'SD-Karte',\n    inserted: 'Eingelegt',\n    notInserted: 'Nicht eingelegt',\n    totalPrintHours: 'Druckstunden',\n    activeNozzle: 'Aktiv: {{nozzle}} Düse',\n    nozzleRack: 'Düsenhalter',\n    nozzleDocked: 'Angedockt',\n    nozzleMounted: 'Montiert',\n    nozzleActive: 'Aktiv',\n    nozzleIdle: 'Inaktiv',\n    nozzleDiameter: 'Durchmesser',\n    nozzleType: 'Typ',\n    nozzleStatus: 'Status',\n    nozzleFilament: 'Filament',\n    nozzleWear: 'Verschleiß',\n    nozzleMaxTemp: 'Max Temp',\n    nozzleSerial: 'Seriennr.',\n    nozzleHardenedSteel: 'Gehärteter Stahl',\n    nozzleStainlessSteel: 'Edelstahl',\n    nozzleTungstenCarbide: 'Wolframkarbid',\n    nozzleFlow: 'Durchfluss',\n    nozzleHighFlow: 'High Flow',\n    nozzleStandardFlow: 'Standard',\n    // Firmware\n    firmwareUpdate: 'Firmware-Update',\n    firmwareInstructions: 'Gehen Sie auf dem Touchscreen des Druckers zu',\n    firmwareNav: 'Navigieren Sie zu',\n    settings: 'Einstellungen',\n    firmware: 'Firmware',\n    // Discovery\n    discoverPrinters: 'Drucker entdecken',\n    searching: 'Suche...',\n    manualEntry: 'Manuelle Eingabe',\n    addFromCloud: 'Aus Cloud hinzufügen',\n    // Toast messages\n    toast: {\n      printerDeleted: 'Drucker gelöscht',\n      missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',\n      printerAdded: 'Drucker hinzugefügt',\n      printerUpdated: 'Drucker aktualisiert',\n      failedToDelete: 'Drucker konnte nicht gelöscht werden',\n      failedToAdd: 'Drucker konnte nicht hinzugefügt werden',\n      failedToUpdate: 'Drucker konnte nicht aktualisiert werden',\n      commandSent: 'Befehl gesendet',\n      failedToSendCommand: 'Befehl konnte nicht gesendet werden',\n      turnedOn: '{{name}} eingeschaltet',\n      failedToPowerOn: '{{name}} konnte nicht eingeschaltet werden',\n      scriptTriggered: 'Skript ausgelöst',\n      printStopped: 'Druck gestoppt',\n      printPaused: 'Druck pausiert',\n      printResumed: 'Druck fortgesetzt',\n      referenceDeleted: 'Referenz gelöscht',\n      detectionAreaSaved: 'Erkennungsbereich gespeichert',\n      failedToRunScript: 'Skript konnte nicht ausgeführt werden',\n      failedToStopPrint: 'Druck konnte nicht gestoppt werden',\n      failedToPausePrint: 'Druck konnte nicht pausiert werden',\n      failedToResumePrint: 'Druck konnte nicht fortgesetzt werden',\n      failedToControlChamberLight: 'Kammerbeleuchtung konnte nicht gesteuert werden',\n      failedToSetSpeed: 'Druckgeschwindigkeit konnte nicht eingestellt werden',\n      failedToUpdateSetting: 'Einstellung konnte nicht aktualisiert werden',\n      failedToSkipObjects: 'Objekte konnten nicht übersprungen werden',\n      failedToRereadRfid: 'RFID konnte nicht erneut gelesen werden',\n      failedToCheckPlate: 'Platte konnte nicht überprüft werden',\n      failedToUpdateLabel: 'Bezeichnung konnte nicht aktualisiert werden',\n      failedToDeleteReference: 'Referenz konnte nicht gelöscht werden',\n      failedToSaveDetectionArea: 'Erkennungsbereich konnte nicht gespeichert werden',\n      plateCheckEnabled: 'Plattenprüfung aktiviert',\n      plateCheckDisabled: 'Plattenprüfung deaktiviert',\n      calibrationSaved: 'Kalibrierung gespeichert!',\n      calibrationFailed: 'Kalibrierung fehlgeschlagen',\n      rfidRereadInitiated: 'RFID-Neueinlesen gestartet',\n    },\n    // Connection status\n    connection: {\n      connected: 'Verbunden',\n      offline: 'Offline',\n    },\n    plateStatus: {\n      markCleared: 'Platte als freigegeben markieren',\n      cleared: 'Platte freigegeben',\n      notCleared: 'Platte nicht freigegeben',\n      inUse: 'Platte in Benutzung',\n    },\n    // Queue info\n    queue: {\n      inQueue: '{{count}} Druck in Warteschlange',\n      inQueue_plural: '{{count}} Drucke in Warteschlange',\n    },\n    // Controls section\n    controls: 'Steuerung',\n    // RFID\n    rfid: {\n      reread: 'RFID neu lesen',\n    },\n    bedJog: {\n      title: 'Druckbett bewegen',\n      bed: 'Bett',\n      step: 'Schritt (mm)',\n      up: 'Platte hoch',\n      down: 'Platte runter',\n      disabledWhilePrinting: 'Während des Drucks deaktiviert',\n      notHomedTitle: 'Drucker ist nicht referenziert',\n      notHomedMessage: 'Der Drucker wurde seit dem letzten Druck nicht referenziert. Führen Sie zuerst die automatische Referenzfahrt aus (parkt den Werkzeugkopf und referenziert dann X, Y und Z) oder bewegen Sie trotzdem — die Software-Endschalter werden dabei umgangen.',\n      homeZ: 'Automatische Referenzfahrt',\n      moveAnyway: 'Trotzdem bewegen',\n      homingStarted: 'Drucker wird automatisch referenziert…',\n    },\n    // Permissions\n    permission: {\n      noAdd: 'Sie haben keine Berechtigung, Drucker hinzuzufügen',\n      noEdit: 'Sie haben keine Berechtigung, Drucker zu bearbeiten',\n      noDelete: 'Sie haben keine Berechtigung, Drucker zu löschen',\n      noControl: 'Sie haben keine Berechtigung, Drucker zu steuern',\n      noFiles: 'Sie haben keine Berechtigung, auf Druckerdateien zuzugreifen',\n      noAmsRfid: 'Sie haben keine Berechtigung, AMS-RFID erneut zu lesen',\n      noSmartPlugControl: 'Sie haben keine Berechtigung, Smart Plugs zu steuern',\n      noCamera: 'Sie haben keine Berechtigung, Kameras anzuzeigen',\n    },\n    // Add/Edit modal\n    modal: {\n      addTitle: 'Drucker hinzufügen',\n      editTitle: 'Drucker bearbeiten',\n      myPrinter: 'Mein Drucker',\n      selectModel: 'Modell auswählen...',\n      locationGroup: 'Standort / Gruppe (optional)',\n      locationPlaceholder: 'z.B. Werkstatt, Büro, Keller',\n      autoArchiveLabel: 'Abgeschlossene Drucke automatisch archivieren',\n      fromPrinterSettings: 'Aus Druckereinstellungen',\n      modelOptional: 'Modell (optional)',\n      saveChanges: 'Änderungen speichern',\n    },\n    // Skip objects\n    skipObjects: {\n      tooltip: 'Objekte überspringen',\n      onlyWhilePrinting: 'Objekte überspringen (nur während des Drucks)',\n      requiresMultiple: 'Objekte überspringen (erfordert 2+ Objekte)',\n      title: 'Objekte überspringen',\n      matchIdsInfo: 'IDs mit Drucker-Display abgleichen',\n      printerShowsIds: 'Der Druckerbildschirm zeigt Objekt-IDs auf der Bauplatte',\n      skipSelected: 'Ausgewählte überspringen',\n      skipping: 'Überspringe...',\n      noObjectsSelected: 'Keine Objekte ausgewählt',\n      selectObjectsToSkip: 'Wählen Sie Objekte aus, die Sie vom aktuellen Druck überspringen möchten',\n      skipped: 'übersprungen',\n      objectsSkipped: 'Objekte übersprungen',\n      activeCount: '{{count}} aktiv',\n      waitForLayer: 'Warten Sie auf Schicht 2+ zum Überspringen von Objekten (aktuell Schicht {{layer}})',\n      skip: 'Überspringen',\n      confirmTitle: 'Objekt überspringen?',\n      confirmMessage: 'Möchten Sie \"{{name}}\" wirklich überspringen? Dies kann nicht rückgängig gemacht werden.',\n    },\n    // Confirm modals\n    confirm: {\n      deleteTitle: 'Drucker löschen',\n      deleteMessage: 'Möchten Sie \"{{name}}\" wirklich löschen? Alle Verbindungseinstellungen werden entfernt.',\n      deleteArchivesNote: 'Der gesamte Druckverlauf für diesen Drucker wird dauerhaft gelöscht.',\n      keepArchivesNote: 'Der Druckverlauf wird beibehalten, aber nicht mehr mit diesem Drucker verknüpft.',\n      stopTitle: 'Druck stoppen',\n      stopMessage: 'Möchten Sie den aktuellen Druck auf \"{{name}}\" wirklich stoppen? Der Druckauftrag wird abgebrochen.',\n      stopButton: 'Druck stoppen',\n      pauseTitle: 'Druck pausieren',\n      pauseMessage: 'Möchten Sie den aktuellen Druck auf \"{{name}}\" wirklich pausieren?',\n      pauseButton: 'Druck pausieren',\n      resumeTitle: 'Druck fortsetzen',\n      resumeMessage: 'Möchten Sie den Druck auf \"{{name}}\" fortsetzen?',\n      resumeButton: 'Druck fortsetzen',\n      powerOnTitle: 'Drucker einschalten',\n      powerOnMessage: 'Möchten Sie die Stromversorgung für \"{{name}}\" wirklich EINSCHALTEN?',\n      powerOnButton: 'Einschalten',\n      powerOffTitle: 'Drucker ausschalten',\n      powerOffMessage: 'Möchten Sie die Stromversorgung für \"{{name}}\" wirklich AUSSCHALTEN?',\n      powerOffWarning: 'WARNUNG: \"{{name}}\" druckt gerade! Möchten Sie die Stromversorgung wirklich AUSSCHALTEN? Dies unterbricht den Druck und kann den Drucker beschädigen.',\n      powerOffButton: 'Ausschalten',\n    },\n    // Bulk actions\n    bulk: {\n      select: 'Auswählen',\n      selectAll: 'Alle auswählen',\n      selectByLocation: 'Nach Standort auswählen',\n      selected: '{{count}} ausgewählt',\n      actions: {\n        stop: 'Stoppen',\n        pause: 'Pausieren',\n        resume: 'Fortsetzen',\n        clearPlate: 'Druckbett leeren',\n        clearHMS: 'Benachrichtigungen löschen',\n      },\n      confirm: {\n        stopTitle: '{{count}} Drucke stoppen',\n        stopMessage: 'Dies wird aktive Drucke auf {{count}} Drucker(n) abbrechen. Diese Aktion kann nicht rückgängig gemacht werden.',\n        stopButton: 'Alle stoppen',\n        pauseTitle: '{{count}} Drucke pausieren',\n        pauseMessage: 'Dies wird aktive Drucke auf {{count}} Drucker(n) pausieren.',\n        pauseButton: 'Alle pausieren',\n        clearPlateTitle: '{{count}} Druckbetten leeren',\n        clearPlateMessage: 'Dies wird das Druckbett auf {{count}} Drucker(n) leeren und kann wartende Aufträge starten.',\n        clearPlateButton: 'Alle leeren',\n      },\n      success: '{{action}} auf {{count}} Drucker(n) abgeschlossen',\n      partial: '{{succeeded}} erfolgreich, {{failed}} fehlgeschlagen',\n      noneApplicable: 'Keine ausgewählten Drucker sind im richtigen Zustand für diese Aktion',\n      selectByState: 'Nach Status auswählen',\n    },\n    // Discovery\n    discovery: {\n      title: 'Drucker entdecken',\n      searching: 'Suche...',\n      scanning: 'Scanne...',\n      scanProgress: 'Scanne... {{scanned}}/{{total}}',\n      foundPrinters: '{{count}} Drucker gefunden',\n      noPrintersFound: 'Keine Drucker gefunden',\n      noPrintersFoundSubnet: 'Keine Drucker im angegebenen Subnetz gefunden.',\n      noPrintersFoundNetwork: 'Keine Drucker im Netzwerk gefunden.',\n      allConfigured: 'Alle erkannten Drucker sind bereits konfiguriert.',\n      alreadyAdded: 'Bereits hinzugefügt',\n      select: 'Auswählen',\n      manualEntry: 'Manuelle Eingabe',\n      addFromCloud: 'Aus Cloud hinzufügen',\n      subnetToScan: 'Zu scannendes Subnetz',\n      dockerNote: 'Docker erkannt. Geben Sie das Subnetz Ihres Druckers in CIDR-Notation ein. Erfordert network_mode: host in docker-compose.yml.',\n      scanSubnet: 'Subnetz nach Druckern scannen',\n      discoverNetwork: 'Drucker im Netzwerk suchen',\n      scanningSubnet: 'Subnetz wird nach Bambu-Druckern gescannt...',\n      scanningNetwork: 'Netzwerk wird gescannt...',\n      serialRequired: 'Seriennummer erforderlich',\n      unknown: 'Unbekannt',\n      failedToStart: 'Erkennung konnte nicht gestartet werden',\n    },\n    // AMS Drying\n    drying: {\n      start: 'Trocknung starten',\n      stop: 'Trocknung stoppen',\n      temperature: 'Temperatur',\n      duration: 'Dauer',\n      hours: 'Stunden',\n      timeRemaining: '{{time}} verbleibend',\n      active: 'Trocknung',\n      notSupported: 'Trocknung nicht unterstützt',\n      powerRequired: 'AMS-Netzteil anschließen, um Trocknung zu aktivieren',\n      startingDrying: 'Trocknung wird gestartet...',\n      stoppingDrying: 'Trocknung wird gestoppt...',\n      rotateTray: 'Spule während der Trocknung drehen',\n    },\n    // Filaments section\n    filaments: 'Filamente',\n    // Camera\n    openCameraOverlay: 'Kamera-Overlay öffnen',\n    openCameraWindow: 'Kamera in neuem Fenster öffnen',\n    // Firmware\n    firmwareUpdateAvailable: 'Firmware-Update verfügbar: {{current}} → {{latest}}',\n    firmwareUpToDate: 'Firmware {{version}} — Aktuell',\n    firmwareUpdateButton: 'Update',\n    // Plate detection\n    plateDetection: {\n      noPermission: 'Sie haben keine Berechtigung, Drucker zu aktualisieren',\n      enabledClick: 'Plattenprüfung aktiviert - Klicken zum Deaktivieren',\n      disabledClick: 'Plattenprüfung deaktiviert - Klicken zum Aktivieren',\n      manageCalibration: 'Platten-Erkennungskalibrierung verwalten',\n      calibrationRequired: 'Kalibrierung erforderlich',\n      calibrationInstructions: 'Bitte stellen Sie sicher, dass die Druckplatte <strong>vollständig leer</strong> ist, und klicken Sie dann auf Kalibrieren.',\n      calibrationDescription: 'Die Kalibrierung erfasst ein Referenzbild der leeren Platte. Zukünftige Prüfungen vergleichen mit dieser Referenz, um Objekte zu erkennen.',\n      calibrationTip: '<strong>Tipp:</strong> Sie können bis zu 5 Kalibrierungen für verschiedene Platten speichern. Das System verwendet automatisch die beste Übereinstimmung bei der Prüfung.',\n      plateEmpty: 'Platte erscheint leer',\n      objectsDetected: 'Objekte auf Platte erkannt',\n      confidence: 'Konfidenz',\n      difference: 'Differenz',\n      analysisPreview: 'Analysevorschau:',\n      analysisLegend: 'Grüner Rahmen = Erkennungsbereich, Rote Überlagerung = Unterschiede zur Kalibrierung',\n      savedReferences: 'Gespeicherte Referenzen ({{count}}/{{max}})',\n      deleteReference: 'Referenz löschen',\n      labelPlaceholder: 'Bezeichnung...',\n      clickToEdit: '{{label}} - Zum Bearbeiten klicken',\n      clickToAddLabel: 'Zum Hinzufügen einer Bezeichnung klicken',\n    },\n    // Speed\n    speed: {\n      title: 'Druckgeschwindigkeit',\n      silent: 'Leise (50%)',\n      standard: 'Standard (100%)',\n      sport: 'Sport (124%)',\n      ludicrous: 'Ludicrous (166%)',\n    },\n    airduct: {\n      title: 'Luftkanal-Modus',\n      cooling: 'Kühlen',\n      heating: 'Heizen',\n    },\n    noSdCard: 'Keine SD',\n    door: {\n      open: 'Offen',\n      closed: 'Zu',\n    },\n    // Fans\n    fans: {\n      partCooling: 'Bauteilkühlung',\n      auxiliary: 'Hilfsventilator',\n      chamber: 'Kammerventilator',\n    },\n    // HMS errors\n    clickToViewHmsErrors: 'Klicken, um HMS-Fehler anzuzeigen',\n    estimatedCompletion: 'Geschätzte Fertigstellungszeit',\n    plateNumber: 'Platte {{number}}',\n    slotOptions: 'Slot-Optionen',\n    // AMS hover popup\n    amsPopup: {\n      friendlyName: 'AMS-Name',\n      friendlyNamePlaceholder: 'z. B. AMS-Anzeigename',\n      serialNumber: 'Seriennummer',\n      firmwareVersion: 'Firmware',\n      save: 'Speichern',\n      clear: 'Löschen',\n      noEditPermission: 'Sie haben keine Berechtigung, AMS-Einheiten umzubenennen',\n    },\n    // Firmware modal\n    firmwareModal: {\n      title: 'Firmware-Update',\n      titleUpToDate: 'Firmware-Info',\n      currentVersion: 'Aktuell:',\n      latestVersion: 'Neueste:',\n      releaseNotes: 'Versionshinweise',\n      checkingPrereqs: 'Prüfe Voraussetzungen...',\n      sdCardReady: 'SD-Karte bereit. Klicken Sie unten, um die Firmware hochzuladen.',\n      uploadedSuccess: 'Firmware auf SD-Karte hochgeladen!',\n      applyInstructions: 'So wenden Sie das Update auf Ihrem Drucker an:',\n      step1: 'Gehen Sie auf dem Touchscreen des Druckers zu <strong>Einstellungen</strong>',\n      step2: 'Navigieren Sie zu <strong>Firmware</strong>',\n      step3: 'Wählen Sie <strong>Update von SD-Karte</strong>',\n      step4: 'Das Update dauert 10-20 Minuten',\n      done: 'Fertig',\n      starting: 'Starte...',\n      uploadFirmware: 'Firmware hochladen',\n      uploadFailed: 'Upload fehlgeschlagen: {{error}}',\n      uploadedToast: 'Firmware hochgeladen! Starten Sie das Update vom Druckerbildschirm.',\n      availableVersions: 'Verfügbare Versionen',\n      usable: 'Installierbar',\n      unavailable: 'Nicht verfügbar',\n      installed: 'Installiert',\n      newerBadge: 'neuer',\n      olderBadge: 'älter',\n      currentBadge: 'aktuell',\n    },\n    accessCodePlaceholder: 'Leer lassen, um den aktuellen zu behalten',\n    // ROI editor\n    roi: {\n      title: 'Erkennungsbereich (ROI)',\n      xStart: 'X-Start',\n      yStart: 'Y-Start',\n      width: 'Breite',\n      height: 'Höhe',\n      instruction: 'Passen Sie den Erkennungsbereich an, um sich auf die Druckplatte zu konzentrieren. Der grüne Rahmen in der Vorschau zeigt den aktuellen Bereich.',\n    },\n    developerModeWarning: 'Der Entwickler-LAN-Modus ist nicht aktiviert auf: {{names}}. Einige Funktionen funktionieren möglicherweise nicht.',\n    howToEnable: 'Aktivieren',\n    incompatibleFile: 'Diese Datei wurde für {{slicedFor}} geslicet, aber dieser Drucker ist ein {{printerModel}}',\n    dropNotPrintable: 'Nur .gcode- und .gcode.3mf-Dateien können gedruckt werden',\n    dropToPrint: 'Zum Drucken ablegen',\n    cannotPrint: 'Drucker beschäftigt',\n  },\n\n  // Archives page\n  archives: {\n    title: 'Druckarchiv',\n    searchPlaceholder: 'Archiv durchsuchen...',\n    filterByPrinter: 'Nach Drucker filtern',\n    filterByStatus: 'Nach Status filtern',\n    sortBy: 'Sortieren nach',\n    sortNewest: 'Neueste zuerst',\n    sortOldest: 'Älteste zuerst',\n    sortName: 'Name',\n    sortDuration: 'Dauer',\n    sortLargest: 'Größte zuerst',\n    sortSmallest: 'Kleinste zuerst',\n    sortSize: 'Größe',\n    noArchives: 'Keine Archive gefunden',\n    noArchivesSearch: 'Keine Archive entsprechen Ihrer Suche',\n    originalPrintNotVisible: 'Ursprünglicher Druck nicht sichtbar - versuchen Sie, die Filter zu löschen',\n    noArchivesYet: 'Noch keine Archive',\n    prints: 'Drucke',\n    pagination: {\n      showing: 'Zeige',\n      to: 'bis',\n      of: 'von',\n      show: 'Zeige',\n      page: 'Seite',\n      all: 'Alle',\n    },\n    loadingArchives: 'Lade Archive...',\n    releaseToUpload: 'Loslassen zum Hochladen',\n    showAll: 'Alle anzeigen',\n    showFavoritesOnly: 'Nur Favoriten anzeigen',\n    gridView: 'Rasteransicht',\n    listView: 'Listenansicht',\n    calendarView: 'Kalenderansicht',\n    logView: 'Druckprotokoll',\n    manageTags: 'Tags verwalten',\n    showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',\n    hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',\n    hideDuplicates: 'Duplikate ausblenden',\n    viewOriginalPrint: 'Klicken, um den ursprünglichen Druck anzuzeigen (#{{id}})',\n    printTime: 'Druckzeit',\n    filamentUsed: 'Verbrauchtes Filament',\n    cost: 'Kosten',\n    reprint: 'Drucken',\n    preview: 'Vorschau',\n    deleteArchive: 'Archiv löschen',\n    deleteConfirm: 'Möchten Sie dieses Archiv wirklich löschen?',\n    favorite: 'Favorit',\n    unfavorite: 'Aus Favoriten entfernen',\n    viewDetails: 'Details anzeigen',\n    status: {\n      completed: 'Abgeschlossen',\n      failed: 'Fehlgeschlagen',\n      stopped: 'Gestoppt',\n    },\n    toast: {\n      source3mfAttached: 'Quell-3MF angehängt: {{filename}}',\n      failedUploadSource3mf: 'Fehler beim Hochladen der Quell-3MF',\n      source3mfRemoved: 'Quell-3MF entfernt',\n      failedRemoveSource3mf: 'Fehler beim Entfernen der Quell-3MF',\n      f3dAttached: 'F3D angehängt: {{filename}}',\n      failedUploadF3d: 'Fehler beim Hochladen der F3D',\n      f3dRemoved: 'F3D entfernt',\n      failedRemoveF3d: 'Fehler beim Entfernen der F3D',\n      timelapseAttached: 'Zeitraffer angehängt: {{filename}}',\n      timelapseAlreadyAttached: 'Zeitraffer bereits angehängt',\n      noMatchingTimelapse: 'Kein passender Zeitraffer gefunden',\n      failedScanTimelapse: 'Fehler beim Suchen nach Zeitraffer',\n      failedAttachTimelapse: 'Fehler beim Anhängen des Zeitraffers',\n      timelapseRemoved: 'Zeitraffer entfernt',\n      failedRemoveTimelapse: 'Fehler beim Entfernen des Zeitraffers',\n      timelapseUploaded: 'Zeitraffer hochgeladen: {{filename}}',\n      failedUploadTimelapse: 'Fehler beim Hochladen des Zeitraffers',\n      archiveDeleted: 'Archiv gelöscht',\n      failedDeleteArchive: 'Fehler beim Löschen des Archivs',\n      addedToFavorites: 'Zu Favoriten hinzugefügt',\n      removedFromFavorites: 'Aus Favoriten entfernt',\n      projectUpdated: 'Projekt aktualisiert',\n      failedUpdateProject: 'Fehler beim Aktualisieren des Projekts',\n      linkCopied: 'Link in die Zwischenablage kopiert',\n      failedCopyLink: 'Fehler beim Kopieren des Links',\n      photoDeleted: 'Foto gelöscht',\n      failedDeletePhoto: 'Fehler beim Löschen des Fotos',\n      failedDeleteArchives: 'Fehler beim Löschen der Archive',\n      failedUpdateFavorites: 'Fehler beim Aktualisieren der Favoriten',\n      exportDownloaded: 'Export heruntergeladen',\n      exportFailed: 'Export fehlgeschlagen',\n    },\n    menu: {\n      print: 'Drucken',\n      schedule: 'Planen',\n      openInBambuStudio: 'Im Slicer öffnen',\n      slice: 'Slicen',\n      externalLink: 'Externer Link',\n      viewOnMakerWorld: 'Auf MakerWorld ansehen',\n      preview3d: '3D-Vorschau',\n      viewTimelapse: 'Zeitraffer ansehen',\n      scanForTimelapse: 'Nach Zeitraffer suchen',\n      uploadTimelapse: 'Zeitraffer hochladen',\n      removeTimelapse: 'Zeitraffer entfernen',\n      downloadSource3mf: 'Quell-3MF herunterladen',\n      uploadSource3mf: 'Quell-3MF hochladen',\n      replaceSource3mf: 'Quell-3MF ersetzen',\n      removeSource3mf: 'Quell-3MF entfernen',\n      uploadF3d: 'F3D hochladen',\n      replaceF3d: 'F3D ersetzen',\n      downloadF3d: 'F3D herunterladen',\n      removeF3d: 'F3D entfernen',\n      download: 'Herunterladen',\n      copyDownloadLink: 'Download-Link kopieren',\n      qrCode: 'QR-Code',\n      viewPhotos: 'Fotos ansehen',\n      viewPhotosCount: 'Fotos ansehen ({{count}})',\n      projectPage: 'Projektseite',\n      addToFavorites: 'Zu Favoriten hinzufügen',\n      removeFromFavorites: 'Aus Favoriten entfernen',\n      edit: 'Bearbeiten',\n      goToProject: 'Zum Projekt: {{name}}',\n      addToProject: 'Zu Projekt hinzufügen',\n      removeFromProject: 'Aus Projekt entfernen',\n      loading: 'Laden...',\n      noProjectsAvailable: 'Keine Projekte verfügbar',\n      select: 'Auswählen',\n      deselect: 'Abwählen',\n      delete: 'Löschen',\n    },\n    permission: {\n      noReprint: 'Sie haben keine Berechtigung, dieses Archiv erneut zu drucken',\n      noAddToQueue: 'Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen',\n      noUpdateArchives: 'Sie haben keine Berechtigung, Archive zu aktualisieren',\n      noUploadFiles: 'Sie haben keine Berechtigung, Dateien hochzuladen',\n      noDownload: 'Sie haben keine Berechtigung, Archive herunterzuladen',\n      noCopyLink: 'Sie haben keine Berechtigung, Download-Links zu kopieren',\n      noDelete: 'Sie haben keine Berechtigung, dieses Archiv zu löschen',\n      noCreate: 'Sie haben keine Berechtigung, Archive zu erstellen',\n    },\n    card: {\n      previousPlate: 'Vorherige Platte',\n      nextPlate: 'Nächste Platte',\n      plateNumber: 'Platte {{index}}',\n      moreOptions: 'Rechtsklick für mehr Optionen',\n      addToFavorites: 'Zu Favoriten hinzufügen',\n      removeFromFavorites: 'Aus Favoriten entfernen',\n      cancelled: 'abgebrochen',\n      failed: 'fehlgeschlagen',\n      duplicate: 'Duplikat',\n      duplicateTitle: 'Dieses Modell wurde bereits zuvor gedruckt',\n      openSource3mf: 'Quell-3MF in Bambu Studio öffnen (Rechtsklick für mehr Optionen)',\n      downloadF3d: 'Fusion 360 Designdatei herunterladen',\n      viewTimelapse: 'Zeitraffer ansehen',\n      viewPhoto: '1 Foto ansehen',\n      viewPhotos: '{{count}} Fotos ansehen',\n      openFolder: 'Ordner öffnen: {{name}}',\n      slicedFile: 'Geslicte Datei - druckbereit',\n      sourceFile: 'Nur Quelldatei - keine AMS-Zuordnung verfügbar',\n      gcode: 'GCODE',\n      source: 'QUELLE',\n      project: 'Projekt: {{name}}',\n      estimated: 'Geschätzt: {{time}}',\n      actual: 'Tatsächlich: {{time}}',\n      accuracy: 'Genauigkeit: {{percent}}%',\n      filament: '{{weight}}g',\n      layer: '{{count}} Schicht',\n      layers: '{{count}} Schichten',\n      object: '{{count}} Objekt',\n      objects: '{{count}} Objekte',\n      slicedFor: 'Geslict für {{model}}',\n      uploadedBy: 'Hochgeladen von',\n      noPermissionReprint: 'Sie haben keine Berechtigung, erneut zu drucken',\n      noFileForReprint: 'Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden',\n      noPermissionEdit: 'Sie haben keine Berechtigung, Archive zu bearbeiten',\n      noPermissionDelete: 'Sie haben keine Berechtigung, Archive zu löschen',\n      reprint: 'Drucken',\n      schedulePrint: 'Druck planen',\n      schedule: 'Planen',\n      openInBambuStudio: 'Im Slicer öffnen',\n      openInBambuStudioToSlice: 'Im Slicer öffnen zum Slicen',\n      slice: 'Slicen',\n      externalLink: 'Externer Link',\n      makerWorld: 'MakerWorld: {{designer}}',\n      viewProject: 'Projekt ansehen',\n      noExternalLink: 'Kein externer Link',\n      preview3d: '3D-Vorschau',\n      download: 'Herunterladen',\n      edit: 'Bearbeiten',\n      delete: 'Löschen',\n    },\n    modal: {\n      deleteArchive: 'Archiv löschen',\n      deleteConfirm: 'Möchten Sie \"{{name}}\" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',\n      deleteButton: 'Löschen',\n      removeSource3mf: 'Quell-3MF entfernen',\n      removeSource3mfConfirm: 'Möchten Sie die Quell-3MF-Datei wirklich von \"{{name}}\" entfernen? Die ursprüngliche Slicer-Projektdatei wird gelöscht.',\n      removeButton: 'Entfernen',\n      removeF3d: 'F3D entfernen',\n      removeF3dConfirm: 'Möchten Sie die Fusion 360 Designdatei wirklich von \"{{name}}\" entfernen?',\n      removeTimelapse: 'Zeitraffer entfernen',\n      removeTimelapseConfirm: 'Möchten Sie das Zeitraffervideo wirklich von \"{{name}}\" entfernen?',\n      timelapse: '{{name}} - Zeitraffer',\n      selectTimelapse: 'Zeitraffer auswählen',\n      selectTimelapseDesc: 'Keine automatische Übereinstimmung gefunden. Wählen Sie den Zeitraffer für diesen Druck:',\n      deleteArchives: 'Archive löschen',\n      deleteArchivesConfirm: 'Möchten Sie wirklich {{count}} Archiv(e) löschen? Diese Aktion kann nicht rückgängig gemacht werden.',\n      deleteCount: '{{count}} löschen',\n    },\n    page: {\n      title: 'Archive',\n      printsCount: '{{filtered}} von {{total}} Drucken',\n      dropFilesHere: '.3mf-Dateien hier ablegen',\n      releaseToUpload: 'Loslassen zum Hochladen',\n      only3mfSupported: 'Nur .3mf-Dateien werden unterstützt',\n      close: 'Schließen',\n      selected: '{{count}} ausgewählt',\n      selectAll: 'Alle auswählen',\n      tags: 'Tags',\n      project: 'Projekt',\n      favorite: 'Favorit',\n      delete: 'Löschen',\n      toggledFavorites: 'Favoriten für {{count}} Archiv(e) umgeschaltet',\n      failedUpdateFavorites: 'Fehler beim Aktualisieren der Favoriten',\n      archivesDeleted: '{{count}} Archiv(e) gelöscht',\n      failedDeleteArchives: 'Fehler beim Löschen der Archive',\n      photoDeleted: 'Foto gelöscht',\n      failedDeletePhoto: 'Fehler beim Löschen des Fotos',\n    },\n    list: {\n      name: 'Name',\n      printer: 'Drucker',\n      date: 'Datum',\n      size: 'Größe',\n      actions: 'Aktionen',\n      hasTimelapse: 'Hat Zeitraffer',\n    },\n    log: {\n      date: 'Datum',\n      printName: 'Druckname',\n      printer: 'Drucker',\n      user: 'Benutzer',\n      status: 'Status',\n      duration: 'Dauer',\n      filament: 'Filament',\n      allPrinters: 'Alle Drucker',\n      allUsers: 'Alle Benutzer',\n      allStatuses: 'Alle Status',\n      cancelled: 'Abgebrochen',\n      skipped: 'Übersprungen',\n      dateFrom: 'Von',\n      dateTo: 'Bis',\n      noEntries: 'Keine Druckprotokolleinträge gefunden',\n      showing: '{{count}} von {{total}} Einträgen',\n      rowsPerPage: 'Zeilen',\n      page: 'Seite',\n      prev: 'Zurück',\n      next: 'Weiter',\n      clearLog: 'Protokoll löschen',\n      clearLogTitle: 'Druckprotokoll löschen',\n      clearLogConfirm: 'Alle Druckprotokolleinträge werden dauerhaft gelöscht. Archive und Warteschlangeneinträge sind nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher?',\n      clearLogButton: 'Alle löschen',\n      cleared: '{{count}} Protokolleinträge gelöscht',\n      clearFailed: 'Druckprotokoll konnte nicht gelöscht werden',\n    },\n  },\n\n  // Queue page\n  queue: {\n    title: 'Druckwarteschlange',\n    subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',\n    addToQueue: 'Zur Warteschlange hinzufügen',\n    // Print modal\n    print: 'Drucken',\n    reprint: 'Erneut drucken',\n    schedulePrint: 'Druck planen',\n    editQueueItem: 'Warteschlangeneintrag bearbeiten',\n    printToPrinters: 'Auf {{count}} Druckern drucken',\n    queueToPrinters: 'Zu {{count}} Druckern hinzufügen',\n    queueSelectedPlates: '{{count}} Platten in die Warteschlange',\n    selectAllPlates: 'Alle {{count}} Platten auswählen',\n    deselectAll: 'Alle abwählen',\n    printQueued: 'Druck in Warteschlange',\n    itemsQueued: '{{count}} Einträge in Warteschlange',\n    sending: 'Wird gesendet...',\n    sendingProgress: 'Sende {{current}}/{{total}}...',\n    adding: 'Wird hinzugefügt...',\n    addingProgress: 'Füge hinzu {{current}}/{{total}}...',\n    savingProgress: 'Speichere {{current}}/{{total}}...',\n    clearQueue: 'Warteschlange leeren',\n    clearHistory: 'Verlauf löschen',\n    emptyQueue: 'Warteschlange ist leer',\n    position: 'Position',\n    scheduledTime: 'Geplante Zeit',\n    moveUp: 'Nach oben',\n    moveDown: 'Nach unten',\n    startNow: 'Jetzt starten',\n    printingInProgress: 'Druck läuft...',\n    viewArchive: 'Archiv anzeigen',\n    viewInFileManager: 'Im Dateimanager anzeigen',\n    itemCount: '{{count}} Element',\n    itemCount_plural: '{{count}} Elemente',\n    dragToReorder: 'Ziehen zum Neuordnen (nur Sofort)',\n    reorderHint: 'Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.',\n    sjf: {\n      label: 'SJF',\n      tooltip: 'Kürzester Auftrag zuerst — Scheduler bevorzugt kürzere Drucke',\n    },\n    addedBy: 'Hinzugefügt von {{name}}',\n    nextInQueue: 'Nächster in der Warteschlange',\n    clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',\n    plateNumber: 'Platte {{index}}',\n    // Batch / quantity\n    quantity: 'Menge',\n    quantityHint: 'Erstellt {{count}} Warteschlangeneinträge',\n    activeBatches: 'Aktive Stapel',\n    batchProgress: '{{completed}} von {{total}} abgeschlossen',\n    cancelBatch: 'Verbleibende abbrechen',\n    batchCancelled: 'Verbleibende Stapeleinträge abgebrochen',\n    cancelBatchConfirmTitle: 'Stapel abbrechen',\n    cancelBatchConfirmMessage: 'Alle verbleibenden ausstehenden Einträge in diesem Stapel abbrechen?',\n    batch: 'Stapel',\n    // Sections\n    sections: {\n      currentlyPrinting: 'Aktuell druckend',\n      queued: 'In Warteschlange',\n      history: 'Verlauf',\n    },\n    // Status\n    status: {\n      pending: 'Ausstehend',\n      waiting: 'Wartend',\n      printing: 'Druckt',\n      paused: 'Pausiert',\n      completed: 'Abgeschlossen',\n      failed: 'Fehlgeschlagen',\n      skipped: 'Übersprungen',\n      cancelled: 'Abgebrochen',\n    },\n    // Summary cards\n    summary: {\n      printing: 'Druckt',\n      queued: 'In Warteschlange',\n      totalTime: 'Gesamte Wartezeit',\n      totalWeight: 'Gesamtgewicht der Warteschlange',\n      history: 'Verlauf',\n    },\n    // Filters\n    filter: {\n      allPrinters: 'Alle Drucker',\n      unassigned: 'Nicht zugewiesen',\n      allStatus: 'Alle Status',\n      allLocations: 'Alle Standorte',\n      any: 'Beliebig',\n    },\n    // Sort\n    sort: {\n      byPosition: 'Nach Position sortieren',\n      byName: 'Nach Name sortieren',\n      byPrinter: 'Nach Drucker sortieren',\n      bySchedule: 'Nach Zeitplan sortieren',\n      byDate: 'Nach Datum sortieren',\n      ascendingOldest: 'Aufsteigend (älteste zuerst)',\n      descendingNewest: 'Absteigend (neueste zuerst)',\n    },\n    // Badges\n    badges: {\n      staged: 'Bereitgestellt',\n      requiresPrevious: 'Erfordert vorherigen Erfolg',\n      autoPowerOff: 'Automatisch ausschalten',\n      gcodeInjection: 'G-code',\n    },\n    // Empty state\n    empty: {\n      title: 'Keine Drucke geplant',\n      description: 'Planen Sie einen Druck von der Archivseite über die Option \"Planen\" im Kontextmenü oder ziehen Sie Dateien hierher.',\n    },\n    // Time\n    time: {\n      asap: 'Sofort',\n      overdue: 'Überfällig',\n      now: 'Jetzt',\n      lessThanMinute: 'In weniger als einer Minute',\n      inMinutes: 'In {{count}} Min',\n      inHours: 'In {{count}} Stunden',\n    },\n    // Actions\n    actions: {\n      stopPrint: 'Druck stoppen',\n      startPrint: 'Druck starten',\n      requeue: 'Erneut einreihen',\n    },\n    // Bulk edit\n    bulkEdit: {\n      title: '{{count}} Element bearbeiten',\n      title_plural: '{{count}} Elemente bearbeiten',\n      description: 'Nur geänderte Einstellungen werden auf ausgewählte Elemente angewendet.',\n      printer: 'Drucker',\n      noChange: '— Keine Änderung —',\n      queueOptions: 'Warteschlangenoptionen',\n      staged: 'Bereitgestellt (manueller Start)',\n      autoPowerOff: 'Nach Druck automatisch ausschalten',\n      requirePrevious: 'Vorherigen Erfolg erfordern',\n      printOptions: 'Druckoptionen',\n      bedLevelling: 'Bett-Nivellierung',\n      flowCalibration: 'Fluss-Kalibrierung',\n      vibrationCalibration: 'Vibrations-Kalibrierung',\n      layerInspection: 'Erste-Schicht-Prüfung',\n      timelapse: 'Zeitraffer',\n      useAms: 'AMS verwenden',\n      applyChanges: 'Änderungen übernehmen',\n      selectAll: 'Alle auswählen',\n      deselectAll: 'Auswahl aufheben',\n      selected: '{{count}} ausgewählt',\n      editSelected: 'Ausgewählte bearbeiten',\n      cancelSelected: 'Ausgewählte abbrechen',\n    },\n    // Confirmations\n    confirm: {\n      cancelTitle: 'Geplanten Druck abbrechen',\n      cancelMessage: 'Möchten Sie \"{{name}}\" wirklich abbrechen?',\n      stopTitle: 'Druck stoppen',\n      stopMessage: 'Möchten Sie den aktuellen Druck \"{{name}}\" wirklich stoppen? Der Druckauftrag wird am Drucker abgebrochen.',\n      removeTitle: 'Aus Verlauf entfernen',\n      removeMessage: 'Möchten Sie \"{{name}}\" wirklich aus dem Warteschlangenverlauf entfernen?',\n      clearHistoryTitle: 'Verlauf löschen',\n      clearHistoryMessage: 'Möchten Sie alle {{count}} Element(e) aus dem Verlauf entfernen?',\n      cancelButton: 'Druck abbrechen',\n      stopButton: 'Druck stoppen',\n      thisPrint: 'diesen Druck',\n      thisItem: 'dieses Element',\n    },\n    // Toast messages\n    toast: {\n      cancelled: 'Warteschlangenelement abgebrochen',\n      cancelFailed: 'Element konnte nicht abgebrochen werden',\n      removed: 'Warteschlangenelement entfernt',\n      removeFailed: 'Element konnte nicht entfernt werden',\n      stopped: 'Druck gestoppt',\n      stopFailed: 'Druck konnte nicht gestoppt werden',\n      released: 'Druck in Warteschlange freigegeben',\n      startFailed: 'Druck konnte nicht gestartet werden',\n      reorderFailed: 'Warteschlange konnte nicht neu geordnet werden',\n      historyCleared: '{{count}} Verlaufselement(e) gelöscht',\n      clearHistoryFailed: 'Verlauf konnte nicht gelöscht werden',\n      updateFailed: 'Elemente konnten nicht aktualisiert werden',\n      bulkCancelled: '{{count}} Element(e) abgebrochen',\n      bulkCancelFailed: 'Elemente konnten nicht abgebrochen werden',\n    },\n    // Timeline view\n    timeline: {\n      listView: 'Liste',\n      timelineView: 'Zeitstrahl',\n      unassigned: 'Nicht zugewiesen',\n      noData: 'Keine geplanten Drucke für diesen Tag',\n      allDoneBy: 'Alle Drucke voraussichtlich fertig um {{time}}',\n      staged: 'Bereitgestellt',\n      filterAll: 'Alle anzeigen',\n      filterPrinting: 'Druckend',\n      filterQueued: 'Warteschlange',\n      time: {\n        anyMoment: 'jeden Moment',\n        minutesLeft: '{{minutes}}m übrig',\n        hoursLeft: '{{hours}}h übrig',\n        hoursMinutesLeft: '{{hours}}h {{minutes}}m übrig',\n      },\n      day: {\n        previous: 'Vorheriger Tag',\n        next: 'Nächster Tag',\n        today: 'Heute',\n      },\n    },\n    // Permissions\n    permissions: {\n      noStopPrint: 'Sie haben keine Berechtigung, Drucke zu stoppen',\n      noStartPrint: 'Sie haben keine Berechtigung, Drucke zu starten',\n      noEdit: 'Sie haben keine Berechtigung, dieses Warteschlangenelement zu bearbeiten',\n      noCancel: 'Sie haben keine Berechtigung, dieses Warteschlangenelement abzubrechen',\n      noRequeue: 'Sie haben keine Berechtigung, Elemente erneut einzureihen',\n      noRemove: 'Sie haben keine Berechtigung, dieses Warteschlangenelement zu entfernen',\n      noClearHistory: 'Sie haben keine Berechtigung, den gesamten Verlauf zu löschen',\n      noEditItems: 'Sie haben keine Berechtigung, Warteschlangenelemente zu bearbeiten',\n      noCancelItems: 'Sie haben keine Berechtigung, Warteschlangenelemente abzubrechen',\n    },\n  },\n\n  backgroundDispatch: {\n    unknownFile: 'Unbekannte Datei',\n    unknownPrinter: 'Unbekannter Drucker',\n    startingPrints: 'Starte Drucke',\n    progressSummary: '{{complete}}/{{total}} abgeschlossen • Geplant: {{dispatched}} • In Bearbeitung: {{processing}}',\n    expandDetails: 'Dispatch-Details ausklappen',\n    collapseDetails: 'Dispatch-Details einklappen',\n    dismissToast: 'Dispatch-Hinweis schließen',\n    cancelDispatchJob: 'Dispatch-Job abbrechen',\n    cancel: 'Abbrechen',\n    cancelling: 'Wird abgebrochen…',\n    status: {\n      dispatched: 'Geplant',\n      processing: 'In Bearbeitung',\n      completed: 'Abgeschlossen',\n      failed: 'Fehlgeschlagen',\n      cancelled: 'Abgebrochen',\n    },\n    toast: {\n      cancellingUpload: 'Upload wird abgebrochen...',\n      cancelled: 'Dispatch abgebrochen',\n      cancelFailed: 'Dispatch konnte nicht abgebrochen werden',\n      completeWithFailures: 'Background Dispatch abgeschlossen: {{completed}} erfolgreich, {{failed}} fehlgeschlagen',\n      completeSuccess: 'Background Dispatch abgeschlossen: {{completed}} erfolgreich',\n      printStartedRemaining: '{{completed}} Druck(e) gestartet, {{remaining}} weitere werden gesendet...',\n    },\n  },\n\n  // Statistics page\n  stats: {\n    title: 'Dashboard',\n    subtitle: 'Widgets zum Neuanordnen ziehen. Auf das Augensymbol klicken zum Ausblenden.',\n    overview: 'Übersicht',\n    totalPrints: 'Gesamtdrucke',\n    successRate: 'Erfolgsrate',\n    totalPrintTime: 'Gesamtdruckzeit',\n    printTime: 'Druckzeit',\n    totalFilament: 'Gesamtverbrauch Filament',\n    filamentUsed: 'Filamentverbrauch',\n    filamentCost: 'Filamentkosten',\n    totalCost: 'Gesamtkosten',\n    energyUsed: 'Energieverbrauch',\n    energyCost: 'Energiekosten',\n    energyWarmingUpTooltip: 'Die Energieerfassung sammelt noch stündliche Snapshots. Zeitraumwerte werden genau, sobald vor dem gewählten Bereich mindestens ein Snapshot vorliegt. Frühe Werte können zu niedrig sein.',\n    averagePrintTime: 'Durchschnittliche Druckzeit',\n    printsPerDay: 'Drucke pro Tag',\n    byPrinter: 'Nach Drucker',\n    printsByPrinter: 'Drucke nach Drucker',\n    byMaterial: 'Nach Material',\n    byMonth: 'Nach Monat',\n    last7Days: 'Letzte 7 Tage',\n    last30Days: 'Letzte 30 Tage',\n    last90Days: 'Letzte 90 Tage',\n    allTime: 'Gesamt',\n    // Widgets\n    quickStats: 'Schnellstatistiken',\n    printActivity: 'Druckaktivität',\n    filamentTypes: 'Filamenttypen',\n    filamentTrends: 'Filamenttrends',\n    failureAnalysis: 'Fehleranalyse',\n    timeAccuracy: 'Zeitgenauigkeit',\n    successful: 'Erfolgreich:',\n    failed: 'Fehlgeschlagen:',\n    perfectEstimate: '100% = perfekte Schätzung',\n    noTimeAccuracyData: 'Noch keine Zeitgenauigkeitsdaten',\n    noFilamentData: 'Keine Filamentdaten verfügbar',\n    noPrinterData: 'Keine Druckerdaten verfügbar',\n    noPrintData: 'Keine Druckdaten verfügbar',\n    noPrintDataLast30Days: 'Keine Druckdaten in den letzten 30 Tagen',\n    failureReasons: 'Fehlerursachen',\n    topFailureReasons: 'Häufigste Fehlerursachen',\n    failedPrintsCount: '{{failed}} / {{total}} Drucke fehlgeschlagen',\n    lastWeekRate: 'Letzte Woche: {{rate}}%',\n    // Actions\n    resetLayout: 'Layout zurücksetzen',\n    recalculateCosts: 'Kosten neu berechnen',\n    recalculateCostsHint: 'Alle Archivkosten mit aktuellen Filamentpreisen neu berechnen',\n    exportStats: 'Statistiken exportieren',\n    exportAsCsv: 'Als CSV exportieren',\n    exportAsExcel: 'Als Excel exportieren',\n    hiddenCount: '{{count}} ausgeblendet',\n    // Toast\n    exportDownloaded: 'Export heruntergeladen',\n    exportFailed: 'Export fehlgeschlagen',\n    layoutReset: 'Layout zurückgesetzt',\n    recalculatedCosts: 'Kosten für {{count}} Archive neu berechnet',\n    recalculateFailed: 'Kosten konnten nicht neu berechnet werden',\n    // Loading\n    loadingStats: 'Statistiken werden geladen...',\n    // Permissions\n    noPermissionResetLayout: 'Sie haben keine Berechtigung, das Layout zurückzusetzen',\n    noPermissionRecalculate: 'Sie haben keine Berechtigung, Kosten neu zu berechnen',\n    noPrintDataInRange: 'Keine Druckdaten im ausgewählten Zeitraum',\n    periodFilament: 'Filamentverbrauch',\n    periodCost: 'Kosten',\n    avgPerPrint: 'Durchschnitt pro Druck',\n    usageOverTime: 'Verbrauch im Zeitverlauf',\n    filamentByWeight: 'Gewicht',\n    printDuration: 'Druckdauer',\n    printerUtilization: 'Druckerauslastung',\n    filamentSuccess: 'Erfolg nach Material',\n    printHabits: 'Druckgewohnheiten',\n    printTimeOfDay: 'Druck-Tageszeit',\n    colorDistribution: 'Farbverteilung',\n    noColorData: 'Keine Farbdaten verfügbar',\n    records: 'Rekorde',\n    longestPrint: 'Längster Druck',\n    heaviestPrint: 'Schwerster Druck',\n    mostExpensivePrint: 'Teuerster Druck',\n    busiestDay: 'Aktivster Tag',\n    successStreak: 'Erfolgsserie',\n    streakPrint: 'aufeinanderfolgender Druck',\n    streakPrints: '{{count}} aufeinanderfolgende Drucke',\n    printerStats: 'Druckerstatistiken',\n    hours: 'Stunden',\n    avgPrints: 'Ø Drucke',\n    noArchiveData: 'Keine Druckdaten verfügbar',\n    filamentByTime: 'Zeitverlauf',\n    avgWeight: 'Ø Gewicht',\n    avgTime: 'Ø Zeit',\n    filamentByPrints: 'Drucke',\n    timeframe: {\n      'today': 'Heute',\n      'this-week': 'Diese Woche',\n      'this-month': 'Dieser Monat',\n      'last-7': 'Letzte 7 Tage',\n      'last-30': 'Letzte 30 Tage',\n      'last-90': 'Letzte 90 Tage',\n      'this-year': 'Dieses Jahr',\n      'all-time': 'Gesamt',\n      'custom': 'Benutzerdefiniert',\n      from: 'Von',\n      to: 'Bis',\n    },\n    allUsers: 'Alle Benutzer',\n    noUser: 'Kein Benutzer (System)',\n    filterByUser: 'Nach Benutzer filtern',\n  },\n\n  // Maintenance page\n  maintenance: {\n    title: 'Wartung',\n    overview: 'Übersicht',\n    allOk: 'Alle Wartungen aktuell',\n    dueCount: '{{count}} Aufgabe fällig',\n    dueCount_plural: '{{count}} Aufgaben fällig',\n    warningCount: '{{count}} Warnung',\n    warningCount_plural: '{{count}} Warnungen',\n    totalPrintTime: 'Gesamtdruckzeit',\n    nextMaintenance: 'Nächste Wartung',\n    nothingDue: 'Nichts fällig',\n    tasks: 'Aufgaben',\n    lastPerformed: 'Zuletzt durchgeführt',\n    interval: 'Intervall',\n    hoursRemaining: '{{hours}}h verbleibend',\n    hoursOverdue: '{{hours}}h überfällig',\n    markDone: 'Als erledigt markieren',\n    performMaintenance: 'Wartung durchführen',\n    history: 'Verlauf',\n    noHistory: 'Kein Wartungsverlauf',\n    editPrintHours: 'Druckstunden bearbeiten',\n    currentHours: 'Aktuelle Stunden',\n    // Tabs\n    statusTab: 'Status',\n    settingsTab: 'Einstellungen',\n    // Status\n    overdueCount: '{{count}} überfällig',\n    dueSoonCount: '{{count}} bald fällig',\n    dueSoon: 'Bald fällig',\n    allGood: 'Alles in Ordnung',\n    overdueBy: 'Überfällig um {{duration}}',\n    dueIn: 'Fällig in {{duration}}',\n    timeLeft: '{{duration}} verbleibend',\n    // Duration formats\n    day: '1 Tag',\n    days: '{{count}} Tage',\n    week: '1 Woche',\n    weeks: '{{count}} Wochen',\n    month: '1 Monat',\n    months: '{{count}} Monate',\n    year: '1 Jahr',\n    // Settings\n    maintenanceTypes: 'Wartungstypen',\n    maintenanceTypesDescription: 'Systemtypen und Ihre benutzerdefinierten Wartungsaufgaben',\n    addCustomType: 'Benutzerdefinierten Typ hinzufügen',\n    restoreDefaults: 'Standardaufgaben wiederherstellen',\n    intervalType: 'Intervalltyp',\n    intervalValue: 'Intervall ({{type}})',\n    icon: 'Symbol',\n    documentationLink: 'Dokumentationslink (optional)',\n    assignToPrinters: 'Druckern zuweisen',\n    selectAtLeastOnePrinter: 'Wählen Sie mindestens einen Drucker',\n    addType: 'Typ hinzufügen',\n    custom: 'Benutzerdefiniert',\n    printHours: 'Druckstunden',\n    calendarDays: 'Kalendertage',\n    exampleName: 'z.B. HEPA-Filter ersetzen',\n    viewDocumentation: 'Dokumentation anzeigen',\n    timeBasedInterval: 'Zeitbasiertes Intervall',\n    // Interval overrides\n    intervalOverrides: 'Intervall-Überschreibungen',\n    intervalOverridesDescription: 'Intervalle für bestimmte Drucker anpassen',\n    // Printer assignment\n    assignedToPrinters: 'Druckern zugewiesen:',\n    noPrintersAssigned: 'Keine Drucker zugewiesen',\n    addPrinterShort: 'Hinzufügen:',\n    printersAssignedClick: '{{count}} Drucker zugewiesen - klicken zum Verwalten',\n    removeFromPrinter: 'Von diesem Drucker entfernen',\n    // Types\n    types: {\n      lubricateCarbonRods: 'Karbonstäbe schmieren',\n      lubricateRails: 'Linearschienen schmieren',\n      cleanNozzle: 'Düse/Hotend reinigen',\n      checkBelts: 'Riemenspannung prüfen',\n      cleanBuildPlate: 'Druckbett reinigen',\n      checkExtruder: 'Extruderzahnräder prüfen',\n      checkCooling: 'Kühlungslüfter prüfen',\n      generalInspection: 'Allgemeine Inspektion',\n      cleanCarbonRods: 'Kohlenstoffstangen reinigen',\n      lubricateSteelRods: 'Stahlstangen schmieren',\n      cleanSteelRods: 'Stahlstangen reinigen',\n      cleanLinearRails: 'Linearschienen reinigen',\n      checkPtfeTube: 'PTFE-Schlauch prüfen',\n      replaceHepaFilter: 'HEPA-Filter ersetzen',\n      replaceCarbonFilter: 'Aktivkohlefilter ersetzen',\n      lubricateLeftNozzleRail: 'Linke Düsenschiene schmieren',\n    },\n    // Toast\n    maintenanceComplete: 'Wartung als abgeschlossen markiert',\n    typeUpdated: 'Wartungstyp aktualisiert',\n    typeDeleted: 'Wartungstyp gelöscht',\n    defaultsRestored: '{{count}} Standardaufgabe(n) wiederhergestellt',\n    printHoursUpdated: 'Druckstunden aktualisiert',\n    printerAssigned: 'Drucker zugewiesen',\n    printerRemoved: 'Drucker entfernt',\n    // Confirmation\n    deleteTypeConfirm: '\"{{name}}\" löschen?',\n    deleteSystemTypeTitle: 'Standard-Wartungsaufgabe löschen?',\n    deleteSystemTypeMessage: 'Möchten Sie die Standard-Wartungsaufgabe \"{{name}}\" wirklich löschen?',\n    // Permissions\n    noPermissionUpdate: 'Sie haben keine Berechtigung, Wartungselemente zu aktualisieren',\n    noPermissionPerform: 'Sie haben keine Berechtigung, Wartungen durchzuführen',\n    noPermissionEditTypes: 'Sie haben keine Berechtigung, Wartungstypen zu bearbeiten',\n    noPermissionDeleteTypes: 'Sie haben keine Berechtigung, Wartungstypen zu löschen',\n    noPermissionEditHours: 'Sie haben keine Berechtigung, Druckstunden zu bearbeiten',\n    noPermissionRemovePrinter: 'Sie haben keine Berechtigung, Druckerzuweisungen zu entfernen',\n    noPermissionAssignPrinter: 'Sie haben keine Berechtigung, Drucker zuzuweisen',\n    noPermissionEditIntervals: 'Sie haben keine Berechtigung, Intervalle zu bearbeiten',\n    // Configure link\n    configureSettings: 'Wartungstypen und Intervalle konfigurieren',\n  },\n\n  // Settings page\n  settings: {\n    title: 'Einstellungen',\n    general: 'Allgemein',\n    // Tab names\n    tabs: {\n      general: 'Allgemein',\n      smartPlugs: 'Smart Plugs',\n      notifications: 'Benachrichtigungen',\n      queue: 'Workflow',\n      filament: 'Filament',\n      network: 'Netzwerk',\n      apiKeys: 'API-Schlüssel',\n      virtualPrinter: 'Virtueller Drucker',\n      spoolbuddy: 'SpoolBuddy',\n      failureDetection: 'Fehlererkennung',\n      users: 'Authentifizierung',\n      backup: 'Sicherung',\n      emailAuth: 'E-Mail-Authentifizierung',\n      ldap: 'LDAP',\n      twoFa: 'Zwei-Faktor-Auth',\n      oidc: 'SSO / OIDC',\n    },\n    spoolbuddy: {\n      infoTitle: 'SpoolBuddy-Geräte',\n      infoBody: 'SpoolBuddy-Kioske registrieren sich automatisch per Heartbeat. Ein Gerät hier abmelden, wenn es nicht mehr verwendet wird oder wenn ein veralteter Eintrag nach einem Daemon-Absturz übrig geblieben ist.',\n      duplicatesTitle: '{{count}} Geräte registriert',\n      duplicatesBody: 'Die Kiosk-Oberfläche verwendet nur das zuerst registrierte Gerät. Falls eines davon ein veralteter Doppeleintrag nach einem Absturz ist, kann es hier entfernt werden — ein laufendes Gerät registriert sich beim nächsten Heartbeat automatisch neu.',\n      empty: 'Noch keine SpoolBuddy-Geräte registriert.',\n      online: 'Online',\n      offline: 'Offline',\n      unregister: 'Abmelden',\n      unregisterSuccess: 'Gerät abgemeldet',\n      unregisterError: 'Gerät konnte nicht abgemeldet werden',\n      confirmTitle: 'SpoolBuddy-Gerät abmelden?',\n      confirmBody: 'Dies entfernt „{{hostname}}\" ({{deviceId}}) aus der Datenbank. Ein laufendes Gerät registriert sich beim nächsten Heartbeat automatisch neu.',\n      ipAddress: 'IP-Adresse',\n      firmware: 'Firmware',\n      lastSeen: 'Zuletzt gesehen',\n      daemonUptime: 'Daemon-Laufzeit',\n      systemUptime: 'System-Laufzeit',\n      never: 'nie',\n      nfc: 'NFC',\n      scale: 'Waage',\n      cpuTemp: 'CPU-Temp.',\n      memory: 'Speicher',\n      disk: 'Festplatte',\n      update: 'Aktualisieren',\n      updateConfirmTitle: 'Spoolbuddy-Daemon aktualisieren?',\n      updateConfirmBody: 'Software-Update auf „{{hostname}}\" auslösen? Der Daemon startet nach dem Update neu.',\n      restartBrowser: 'Browser neu starten',\n      restartBrowserConfirmTitle: 'Kiosk-Browser neu starten?',\n      restartBrowserConfirmBody: 'Kiosk-Browser auf „{{hostname}}\" neu starten? Die Anzeige wird kurz schwarz.',\n      restartDaemon: 'Daemon neu starten',\n      restartDaemonConfirmTitle: 'Spoolbuddy-Daemon neu starten?',\n      restartDaemonConfirmBody: 'Spoolbuddy-Daemon auf „{{hostname}}\" neu starten? Das Gerät ist für einige Sekunden offline.',\n      reboot: 'Neustart',\n      rebootConfirmTitle: 'Gerät neu starten?',\n      rebootConfirmBody: '„{{hostname}}\" neu starten? Das Gerät ist für etwa eine Minute offline.',\n      shutdown: 'Herunterfahren',\n      shutdownConfirmTitle: 'Gerät herunterfahren?',\n      shutdownConfirmBody: '„{{hostname}}\" herunterfahren? Physischer Zugriff ist nötig, um es wieder einzuschalten.',\n      commandConfirm: 'Bestätigen',\n      commandQueued: 'Befehl eingereiht',\n      commandError: 'Befehl konnte nicht gesendet werden',\n    },\n    ldap: {\n      title: 'LDAP-Authentifizierung',\n      enabledDesc: 'LDAP-Authentifizierung ist aktiviert',\n      disabledDesc: 'LDAP-Authentifizierung ist deaktiviert',\n      disabledHint: 'LDAP-Einstellungen unten konfigurieren und speichern, dann aktivieren.',\n      enabled: 'LDAP-Authentifizierung aktiviert',\n      disabled: 'LDAP-Authentifizierung deaktiviert',\n      feature1: 'Benutzer können sich mit LDAP-Anmeldedaten anmelden',\n      feature2: 'Lokales Admin-Konto bleibt als Fallback erhalten',\n      feature3: 'LDAP-Gruppen werden bei der Anmeldung BamBuddy-Gruppen zugeordnet',\n      serverConfig: 'LDAP-Server-Konfiguration',\n      serverUrl: 'Server-URL',\n      serverUrlHint: 'Verwenden Sie ldap:// für Standard oder ldaps:// für SSL-Verbindungen',\n      security: 'Sicherheit',\n      securityHint: 'StartTLS aktualisiert eine einfache Verbindung auf TLS. LDAPS verwendet TLS von Anfang an.',\n      bindDn: 'Bind-DN (Dienstkonto)',\n      bindPassword: 'Bind-Passwort',\n      searchBase: 'Such-Basis-DN',\n      userFilter: 'Benutzer-Suchfilter',\n      userFilterHint: '{username} wird durch den Anmeldenamen ersetzt. Verwenden Sie (uid={username}) für OpenLDAP.',\n      autoProvision: 'Benutzer automatisch anlegen',\n      autoProvisionHint: 'Automatisch ein BamBuddy-Konto bei der ersten LDAP-Anmeldung erstellen',\n      defaultGroup: 'Standardgruppe',\n      defaultGroupNone: '— Keine (kein Fallback) —',\n      defaultGroupHint: 'Fallback-Gruppe, die zugewiesen wird, wenn sich ein LDAP-Benutzer authentifiziert, aber in keiner zugeordneten LDAP-Gruppe enthalten ist. Leer lassen, um nicht zugeordnete Benutzer ohne Berechtigungen zu belassen.',\n      groupMapping: 'Gruppenzuordnung (JSON)',\n      groupMappingHint: 'LDAP-Gruppen-DNs BamBuddy-Gruppen zuordnen. Verfügbare Gruppen: ',\n      testConnection: 'Verbindung testen',\n      settingsSaved: 'LDAP-Einstellungen gespeichert',\n      errors: {\n        serverRequired: 'LDAP-Server-URL ist erforderlich',\n        searchBaseRequired: 'Such-Basis-DN ist erforderlich',\n        enableAuthFirst: 'Authentifizierung zuerst aktivieren',\n        configureLdapFirst: 'LDAP-Einstellungen zuerst speichern',\n      },\n    },\n    // Email settings\n    email: {\n      smtpSettings: 'SMTP-Konfiguration',\n      smtpHost: 'SMTP-Server',\n      smtpPort: 'SMTP-Port',\n      security: 'Sicherheit',\n      authentication: 'Authentifizierung',\n      username: 'Benutzername',\n      password: 'Passwort',\n      fromEmail: 'Absender-E-Mail',\n      fromName: 'Absendername',\n      testConnection: 'SMTP-Verbindung testen',\n      testRecipient: 'Test-Empfänger-E-Mail',\n      sendTest: 'Test-E-Mail senden',\n      sending: 'Wird gesendet...',\n      save: 'Einstellungen speichern',\n      saving: 'Wird gespeichert...',\n      advancedAuth: 'Erweiterte Authentifizierung',\n      advancedAuthEnabled: 'Erweiterte Authentifizierung ist aktiviert',\n      advancedAuthEnabledDesc: 'E-Mail-basierte Benutzerverwaltungsfunktionen sind aktiv. Neue Benutzer erhalten automatisch generierte Passwörter per E-Mail und können ihr Passwort über die Passwort vergessen Funktion zurücksetzen.',\n      advancedAuthDisabled: 'Erweiterte Authentifizierung ist deaktiviert',\n      advancedAuthDisabledDesc: 'Aktivieren Sie die erweiterte Authentifizierung, um E-Mail-basierte Funktionen für die Benutzerverwaltung zu aktivieren.',\n      enable: 'Aktivieren',\n      disable: 'Deaktivieren',\n      feature1: 'Passwörter werden automatisch generiert und an neue Benutzer gesendet',\n      feature2: 'Benutzer können sich mit Benutzername oder E-Mail anmelden',\n      feature3: 'Passwort vergessen Funktion ist verfügbar',\n      feature4: 'Administratoren können Benutzerpasswörter per E-Mail zurücksetzen',\n      // Error messages\n      errors: {\n        requiredFields: 'Bitte füllen Sie alle Pflichtfelder aus',\n        usernameRequired: 'Benutzername ist erforderlich, wenn Authentifizierung aktiviert ist',\n        enterTestEmail: 'Bitte geben Sie eine Test-E-Mail-Adresse ein',\n        smtpServerAndEmail: 'Bitte füllen Sie SMTP-Server und Absender-E-Mail aus, bevor Sie testen',\n        usernamePasswordRequired: 'Benutzername und Passwort sind erforderlich, wenn Authentifizierung aktiviert ist',\n        configureSmtpFirst: 'Bitte konfigurieren und testen Sie zuerst die SMTP-Einstellungen',\n        enableAuthFirst: 'Bitte aktivieren Sie zuerst die Authentifizierung, um E-Mail-basierte Funktionen nutzen zu können.',\n      },\n      // Success messages\n      success: {\n        settingsSaved: 'SMTP-Einstellungen erfolgreich gespeichert',\n      },\n      // Security options\n      securityOptions: {\n        starttls: 'STARTTLS (Port 587)',\n        ssl: 'SSL/TLS (Port 465)',\n        none: 'Keine (Port 25)',\n      },\n      // Authentication options\n      authOptions: {\n        enabled: 'Aktiviert',\n        disabled: 'Deaktiviert',\n      },\n    },\n    appearance: 'Erscheinungsbild',\n    notifications: 'Benachrichtigungen',\n    smartPlugs: 'Smart Plugs',\n    spoolman: 'Spoolman',\n    updates: 'Updates',\n    language: 'Sprache',\n    languageDescription: 'Wählen Sie Ihre bevorzugte Sprache',\n    theme: 'Design',\n    themeLight: 'Hell',\n    themeDark: 'Dunkel',\n    themeSystem: 'System',\n    defaultView: 'Standardansicht',\n    defaultViewDescription: 'Seite, die beim Öffnen der App angezeigt wird',\n    checkForUpdates: 'Nach Updates suchen',\n    autoUpdate: 'Automatische Updates',\n    currentVersion: 'Aktuelle Version',\n    latestVersion: 'Neueste Version',\n    upToDate: 'Sie sind auf dem neuesten Stand',\n    updateAvailable: 'Update verfügbar',\n    // Notifications\n    notificationLanguage: 'Benachrichtigungssprache',\n    notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',\n    bedCooledThreshold: 'Bett-Abkühlung Schwellenwert',\n    bedCooledThresholdDescription: 'Temperatur, unter der das Bett nach einem Druck als abgekühlt gilt',\n    userNotificationsEnabled: 'Benutzerbenachrichtigungen',\n    userNotificationsEnabledDescription: 'Aktiviert das Benutzerbenachrichtigungsmenü und E-Mail-Benachrichtigungen für Druckereignisse. Erfordert Erweiterte Authentifizierung.',\n    userNotificationsDisabledHint: 'Erweiterte Authentifizierung aktivieren, um Benutzerbenachrichtigungen zu verwenden.',\n    notificationProviders: 'Benachrichtigungsanbieter',\n    addProvider: 'Anbieter hinzufügen',\n    editProvider: 'Anbieter bearbeiten',\n    providerType: 'Anbietertyp',\n    testNotification: 'Testbenachrichtigung',\n    testSuccess: 'Testbenachrichtigung erfolgreich gesendet',\n    testFailed: 'Testbenachrichtigung konnte nicht gesendet werden',\n    quietHours: 'Ruhezeiten',\n    quietHoursDescription: 'Keine Störungen während dieser Zeiten',\n    quietHoursStart: 'Beginn',\n    quietHoursEnd: 'Ende',\n    events: {\n      title: 'Benachrichtigungsereignisse',\n      printStart: 'Druck gestartet',\n      printComplete: 'Druck abgeschlossen',\n      printFailed: 'Druck fehlgeschlagen',\n      printStopped: 'Druck gestoppt',\n      printProgress: 'Fortschrittsmeldungen',\n      printProgressDescription: 'Bei 25%, 50%, 75% benachrichtigen',\n      printerOffline: 'Drucker offline',\n      printerError: 'Druckerfehler',\n      filamentLow: 'Filament niedrig',\n      maintenanceDue: 'Wartung fällig',\n      maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich',\n    },\n    // Smart Plugs\n    smartPlug: {\n      title: 'Smart Plugs',\n      add: 'Smart Plug hinzufügen',\n      edit: 'Smart Plug bearbeiten',\n      name: 'Name',\n      ipAddress: 'IP-Adresse',\n      linkedPrinter: 'Verknüpfter Drucker',\n      autoOn: 'Automatisch einschalten',\n      autoOnDescription: 'Einschalten beim Druckstart',\n      autoOff: 'Automatisch ausschalten',\n      autoOffDescription: 'Ausschalten nach Druckende',\n      offDelay: 'Ausschaltverzögerung',\n      offDelayMinutes: 'Minuten nach Druck',\n      offDelayTemp: 'Wenn Düse unter Temperatur',\n      currentState: 'Aktueller Status',\n      turnOn: 'Einschalten',\n      turnOff: 'Ausschalten',\n    },\n    // Filament Tracking Mode\n    filamentTracking: 'Filament-Verfolgung',\n    filamentTrackingDesc: 'Wählen Sie, wie Sie Ihre Filamentspulen verfolgen möchten. Sie können das integrierte Inventar oder einen externen Spoolman-Server verwenden.',\n    filamentChecks: 'Filament-Prüfungen',\n    disableFilamentWarnings: 'Filament-Warnungen deaktivieren',\n    disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen',\n    preferLowestFilament: 'Niedrigsten Filamentrest bevorzugen',\n    preferLowestFilamentDesc: 'Bei mehreren passenden Spulen die mit dem geringsten Restfilament verwenden',\n    trackingModeBuiltIn: 'Integriertes Inventar',\n    trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',\n    trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',\n    builtInFeatureRfid: 'Erkennt automatisch Bambu Lab RFID-Spulen im AMS',\n    builtInFeatureUsage: 'Erfasst den Filamentverbrauch pro Druck',\n    builtInFeatureCatalog: 'Spulen, Farben und K-Faktor-Profile verwalten',\n    builtInFeatureThirdParty: 'Drittanbieter-Spulen können Inventarspulen zugewiesen werden',\n    amsSyncButton: 'Gewichte vom AMS synchronisieren',\n    amsSyncTitle: 'Spulengewichte vom AMS synchronisieren',\n    amsSyncMessage: 'Alle Inventar-Spulengewichte werden mit den aktuellen AMS-Restwerten der verbundenen Drucker überschrieben. Verwenden Sie dies zur Wiederherstellung beschädigter Gewichtsdaten. Drucker müssen online sein.',\n    amsSyncing: 'Synchronisiere...',\n    amsSyncSuccess: '{{synced}} Spule(n) synchronisiert, {{skipped}} übersprungen',\n    amsSyncError: 'Synchronisierung der Gewichte vom AMS fehlgeschlagen',\n    // Spoolman settings\n    spoolmanUrl: 'Spoolman URL',\n    spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',\n    spoolmanConnected: 'Verbunden',\n    spoolmanDisconnected: 'Nicht verbunden',\n    status: 'Status',\n    connect: 'Verbinden',\n    disconnect: 'Trennen',\n    howSyncWorks: 'So funktioniert die Synchronisierung',\n    syncInfoRfidOnly: 'Nur offizielle Bambu Lab Spulen mit RFID werden synchronisiert',\n    syncInfoAutoCreate: 'Neue Spulen werden bei der ersten Synchronisierung automatisch in Spoolman erstellt',\n    syncInfoThirdPartySkipped: 'Nicht-Bambu-Lab-Spulen (Drittanbieter, nachgefüllt) werden übersprungen',\n    linkingExistingSpools: 'Vorhandene Spulen verknüpfen',\n    linkingExistingSpoolsDesc: 'Um vorhandene Spoolman-Spulen mit Ihrem AMS zu verknüpfen, fahren Sie über einen AMS-Slot und klicken Sie auf \"Mit Spoolman verknüpfen\".',\n    syncMode: 'Synchronisierungsmodus',\n    syncModeAuto: 'Automatisch',\n    syncModeManual: 'Nur manuell',\n    syncModeAutoDesc: 'AMS-Daten werden automatisch synchronisiert, wenn Änderungen erkannt werden',\n    syncModeManualDesc: 'Nur bei manueller Auslösung synchronisieren',\n    syncAmsData: 'AMS-Daten synchronisieren',\n    syncAmsDataDesc: 'AMS-Daten des Druckers manuell mit Spoolman synchronisieren',\n    allPrinters: 'Alle Drucker',\n    // Default printer\n    noDefaultPrinter: 'Kein Standard (jedes Mal fragen)',\n    // Sidebar\n    sidebarOrder: 'Seitenleisten-Reihenfolge',\n    // Camera\n    saveThumbnails: 'Vorschaubilder speichern',\n    captureFinishPhoto: 'Abschlussfoto aufnehmen',\n    noPrintersConfigured: 'Keine Drucker konfiguriert',\n    // Archive settings\n    archiveMode: {\n      always: 'Immer Archiveintrag erstellen',\n      never: 'Nie Archiveintrag erstellen',\n      ask: 'Jedes Mal fragen',\n    },\n    // Updates\n    checkForUpdatesLabel: 'Nach Updates suchen',\n    checkPrinterFirmware: 'Drucker-Firmware prüfen',\n    includeBetaUpdates: 'Beta-Versionen einschließen',\n    includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',\n    // Queue\n    enableRetry: 'Wiederholung aktivieren',\n    // Home Assistant\n    homeAssistantDescription: 'Smart Plugs über Home Assistant steuern',\n    environmentManagedLabel: '(Umgebungsvariable)',\n    autoEnabledViaEnv: 'Automatisch über Umgebungsvariablen aktiviert',\n    urlFromEnvReadOnly: 'Wert wird über HA_URL Umgebungsvariable gesetzt (schreibgeschützt)',\n    tokenFromEnvReadOnly: 'Wert wird über HA_TOKEN Umgebungsvariable gesetzt (schreibgeschützt)',\n    // MQTT\n    mqttConnectedTo: 'Verbunden mit',\n    // Prometheus\n    prometheusDescription: 'Druckerdaten im Prometheus-Format bereitstellen',\n    // Smart plugs empty state\n    noSmartPlugsTitle: 'Keine Smart Plugs konfiguriert',\n    noSmartPlugsDescription: 'Fügen Sie einen Tasmota-basierten Smart Plug hinzu, um den Energieverbrauch zu verfolgen und die Stromsteuerung zu automatisieren.',\n    // Notifications empty state\n    noProvidersTitle: 'Keine Anbieter konfiguriert',\n    noProvidersDescription: 'Fügen Sie einen Anbieter hinzu, um Benachrichtigungen zu erhalten.',\n    noTemplatesAvailable: 'Keine Vorlagen verfügbar. Starten Sie das Backend neu, um Standardvorlagen zu laden.',\n    // API permissions\n    apiPermissionView: 'Druckerstatus und Warteschlange anzeigen',\n    apiPermissionEdit: 'Elemente zur Druckwarteschlange hinzufügen und entfernen',\n    // API keys\n    apiKeysEmptyTitle: 'Keine API-Schlüssel',\n    apiKeysEmptyDescription: 'Erstellen Sie einen API-Schlüssel zur Integration mit externen Diensten.',\n    // Users\n    noUsersFound: 'Keine Benutzer gefunden',\n    noGroupsFound: 'Keine Gruppen gefunden',\n    noGroupsAvailable: 'Keine Gruppen verfügbar',\n    passwordsDoNotMatch: 'Passwörter stimmen nicht überein',\n    systemGroupWarning: 'System-Gruppennamen können nicht geändert werden',\n    // Auth disabled\n    authDisabledTitle: 'Authentifizierung ist deaktiviert',\n    authDisabledFeature1: 'Anmeldung zum Zugriff auf das System erforderlich',\n    authDisabledFeature2: 'Mehrere Benutzer mit gruppenbasierten Berechtigungen erstellen',\n    authDisabledFeature3: 'Zugriff mit über 50 granularen Berechtigungen steuern',\n    // User deletion\n    userHasCreated: 'Dieser Benutzer hat erstellt:',\n    userItemsQuestion: 'Was möchten Sie mit diesen Elementen tun?',\n    deleteUserConfirm: 'Möchten Sie diesen Benutzer wirklich löschen?',\n    actionCannotBeUndone: 'Diese Aktion kann nicht rückgängig gemacht werden.',\n    // Smart plugs\n    addFirstSmartPlug: 'Ersten Smart Plug hinzufügen',\n    // Notifications\n    providers: 'Anbieter',\n    log: 'Protokoll',\n    testAll: 'Alle testen',\n    testResults: 'Testergebnisse',\n    testPassedCount: '{{count}} bestanden',\n    testFailedCount: '{{count}} fehlgeschlagen',\n    messageTemplates: 'Nachrichtenvorlagen',\n    messageTemplatesDescription: 'Passen Sie Benachrichtigungen für jedes Ereignis an.',\n    // API Keys section\n    apiKeys: 'API-Schlüssel',\n    apiKeysDescription: 'Erstellen Sie API-Schlüssel für externe Integrationen und Webhooks.',\n    createKey: 'Schlüssel erstellen',\n    apiKeyCreated: 'API-Schlüssel erfolgreich erstellt',\n    apiKeyCopyWarning: 'Kopieren Sie diesen Schlüssel jetzt - er wird nicht mehr angezeigt!',\n    useInApiBrowser: 'Im API-Browser verwenden',\n    createNewApiKey: 'Neuen API-Schlüssel erstellen',\n    keyName: 'Schlüsselname',\n    keyNamePlaceholder: 'z.B. Home Assistant, OctoPrint',\n    readStatus: 'Status lesen',\n    readStatusDescription: 'Druckerstatus und Warteschlange anzeigen',\n    manageQueue: 'Warteschlange verwalten',\n    manageQueueDescription: 'Elemente zur Druckwarteschlange hinzufügen und entfernen',\n    controlPrinter: 'Drucker steuern',\n    controlPrinterDescription: 'Drucke pausieren, fortsetzen und stoppen',\n    unnamedKey: 'Unbenannter Schlüssel',\n    lastUsed: 'Zuletzt verwendet',\n    read: 'Lesen',\n    control: 'Steuern',\n    createFirstKey: 'Ersten Schlüssel erstellen',\n    webhookEndpoints: 'Webhook-Endpunkte',\n    webhookApiKeyHint: 'Verwenden Sie Ihren API-Schlüssel im X-API-Key-Header.',\n    webhook: {\n      getAllStatus: 'Alle Druckerstatus abrufen',\n      getSpecificStatus: 'Spezifischen Druckerstatus abrufen',\n      addToQueue: 'Zur Druckwarteschlange hinzufügen',\n      pausePrint: 'Druck pausieren',\n      resumePrint: 'Druck fortsetzen',\n      stopPrint: 'Druck stoppen',\n    },\n    apiBrowser: 'API-Browser',\n    apiBrowserDescription: 'Erkunden und testen Sie alle verfügbaren API-Endpunkte.',\n    apiKeyForTesting: 'API-Schlüssel zum Testen',\n    apiKeyPlaceholder: 'Fügen Sie hier Ihren API-Schlüssel ein, um authentifizierte Endpunkte zu testen...',\n    apiKeyHint: 'Dieser Schlüssel wird als X-API-Key-Header mit Anfragen gesendet.',\n    deleteApiKeyTitle: 'API-Schlüssel löschen',\n    deleteApiKeyMessage: 'Möchten Sie diesen API-Schlüssel wirklich löschen? Alle Integrationen, die diesen Schlüssel verwenden, funktionieren nicht mehr.',\n    deleteKey: 'Schlüssel löschen',\n    // Filament tab\n    amsDisplayThresholds: 'AMS-Anzeigeschwellenwerte',\n    amsThresholdsDescription: 'Konfigurieren Sie Farbschwellenwerte für AMS-Feuchtigkeits- und Temperaturanzeigen.',\n    humidity: 'Luftfeuchtigkeit',\n    goodGreen: 'Gut (grün)',\n    fairOrange: 'Mittel (orange)',\n    aboveFairBad: 'Über dem mittleren Schwellenwert wird rot angezeigt (schlecht)',\n    fairAlsoDryingThreshold: 'Dieser Schwellenwert wird auch für die automatische Trocknung verwendet',\n    temperature: 'Temperatur',\n    goodBlue: 'Gut (blau)',\n    aboveFairHot: 'Über dem mittleren Schwellenwert wird rot angezeigt (heiß)',\n    historyRetention: 'Verlaufsaufbewahrung',\n    keepSensorHistory: 'Sensorverlauf behalten für',\n    historyRetentionDescription: 'Ältere Feuchtigkeits- und Temperaturdaten werden automatisch gelöscht',\n    defaultPrintOptions: 'Standard-Druckoptionen',\n    defaultPrintOptionsDescription: 'Standardwerte für Druckoptionen bei neuen Drucken festlegen. Diese können im Druckdialog pro Druck überschrieben werden.',\n    defaultBedLevelling: 'Bett-Nivellierung',\n    defaultBedLevellingDesc: 'Bett vor dem Druck automatisch nivellieren',\n    defaultFlowCali: 'Fluss-Kalibrierung',\n    defaultFlowCaliDesc: 'Extrusionsfluss kalibrieren',\n    defaultVibrationCali: 'Vibrationskalibrierung',\n    defaultVibrationCaliDesc: 'Ringing-Artefakte reduzieren',\n    defaultLayerInspect: 'Erste-Schicht-Inspektion',\n    defaultLayerInspectDesc: 'KI-Inspektion der ersten Schicht',\n    defaultTimelapse: 'Zeitraffer',\n    defaultTimelapseDesc: 'Zeitraffervideo aufnehmen',\n    staggeredStart: 'Staggered Start',\n    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',\n    plateClear: 'Druckplatte-Bestätigung',\n    requirePlateClear: 'Druckplatte-Bestätigung erforderlich',\n    requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatten-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Wenn dies deaktiviert ist, werden auch das Druckplatten-Status-Badge und die Schaltfläche \"Druckplatte als freigegeben markieren\" auf den Druckerkarten ausgeblendet.',\n    gcodeInjection: 'G-code Injection',\n    gcodeInjectionDescription: 'Konfigurieren Sie benutzerdefinierten G-code, der am Anfang und/oder Ende von Drucken für Auto-Print-Systeme wie Farmloop, SwapMod, AutoClear und Printflow 3D eingefügt wird. Snippets werden pro Druckermodell konfiguriert und angewendet, wenn \"G-code einfügen\" bei einem Warteschlangen-Element aktiviert ist.',\n    gcodeInjectionNoPrinters: 'Keine Drucker gefunden. Fügen Sie Drucker hinzu, um G-code-Snippets zu konfigurieren.',\n    gcodeStartLabel: 'Start G-code',\n    gcodeEndLabel: 'End G-code',\n    gcodeStartPlaceholder: 'G-code, der vor dem Druckstart eingefügt wird...',\n    gcodeEndPlaceholder: 'G-code, der nach dem Druckende angefügt wird...',\n    staggerGroupSize: 'Group size',\n    staggerGroupSizeHelp: 'Printers to start simultaneously per group',\n    staggerInterval: 'Interval (minutes)',\n    staggerIntervalHelp: 'Delay between each group starting',\n    queueDrying: 'Automatische Trocknung',\n    queueDryingDescription: 'AMS-Filament automatisch trocknen, wenn der Drucker zwischen Warteschlangen-Drucken im Leerlauf ist. Verwendet den Feuchtigkeitsschwellenwert oben.',\n    queueDryingEnabled: 'Automatische Trocknung aktivieren',\n    queueDryingEnabledDescription: 'AMS-Trocknung automatisch starten, wenn der Drucker im Leerlauf ist und die Feuchtigkeit über dem Schwellenwert liegt',\n    queueDryingBlock: 'Auf Trocknung warten',\n    queueDryingBlockDescription: 'Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.',\n    ambientDryingEnabled: 'Umgebungstrocknung',\n    ambientDryingEnabledDescription: 'Filament auf inaktiven Druckern automatisch trocknen, wenn die Luftfeuchtigkeit den Schwellenwert überschreitet — auch ohne Warteschlange.',\n    dryingPresets: 'Trocknungsvoreinstellungen',\n    dryingPresetsDescription: 'Temperatur und Dauer pro Filamenttyp. AMS 2 Pro verwendet niedrigere Temperaturen, AMS-HT unterstützt höhere.',\n    dryingFilament: 'Filament',\n    printModal: 'Druckdialog',\n    expandCustomMapping: 'Benutzerdefinierte Zuordnung standardmäßig erweitern',\n    expandCustomMappingDescription: 'Bei Druck auf mehrere Drucker die AMS-Zuordnung pro Drucker erweitert anzeigen',\n    // User management\n    authentication: 'Authentifizierung',\n    authEnabledDescription: 'Ihre Instanz ist mit Benutzerauthentifizierung gesichert',\n    authDisabledDescription: 'Aktivieren Sie die Anmeldepflicht und verwalten Sie den Benutzerzugriff',\n    authDisabledMessage: 'Aktivieren Sie die Authentifizierung, um Benutzerkonten zu erstellen, Berechtigungen zu verwalten und Ihre Bambuddy-Instanz zu sichern.',\n    enableAuthentication: 'Authentifizierung aktivieren',\n    currentUser: 'Aktueller Benutzer',\n    changePassword: 'Passwort ändern',\n    admin: 'Admin',\n    users: 'Benutzer',\n    addUser: 'Benutzer hinzufügen',\n    groups: 'Gruppen',\n    addGroup: 'Gruppe hinzufügen',\n    system: 'System',\n    noDescription: 'Keine Beschreibung',\n    userCount: '{{count}} Benutzer',\n    permissionCount: '{{count}} Berechtigungen',\n    createUser: 'Benutzer erstellen',\n    username: 'Benutzername',\n    enterUsername: 'Benutzername eingeben',\n    password: 'Passwort',\n    enterPassword: 'Passwort eingeben (min. 6 Zeichen)',\n    confirmPassword: 'Passwort bestätigen',\n    confirmPasswordPlaceholder: 'Passwort bestätigen',\n    // Title tooltips\n    viewReleaseOnGitHub: 'Release auf GitHub anzeigen',\n    turnAllPlugsOn: 'Alle Stecker einschalten',\n    turnAllPlugsOff: 'Alle Stecker ausschalten',\n    // Modal: Clear logs\n    clearNotificationLogs: 'Benachrichtigungsprotokolle löschen',\n    clearLogsMessage: 'Dadurch werden alle Benachrichtigungsprotokolle, die älter als 30 Tage sind, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',\n    clearLogs: 'Protokolle löschen',\n    // Modal: Reset UI\n    resetUiPreferences: 'UI-Einstellungen zurücksetzen',\n    resetUiPreferencesMessage: 'Dadurch werden alle UI-Einstellungen auf Standardwerte zurückgesetzt: Seitenleisten-Reihenfolge, Theme, Dashboard-Layout, Ansichtsmodi und Sortiereinstellungen. Ihre Drucker, Archive und Servereinstellungen werden NICHT beeinträchtigt. Die Seite wird nach dem Löschen neu geladen.',\n    resetPreferences: 'Einstellungen zurücksetzen',\n    // Modal: Delete group\n    deleteGroupTitle: 'Gruppe löschen',\n    deleteGroupMessage: 'Möchten Sie diese Gruppe wirklich löschen? Benutzer in dieser Gruppe verlieren diese Berechtigungen.',\n    deleteGroup: 'Gruppe löschen',\n    // Modal: Disable auth\n    disableAuthenticationTitle: 'Authentifizierung deaktivieren',\n    disableAuthenticationMessage: 'Möchten Sie die Authentifizierung wirklich deaktivieren? Dadurch wird Ihre Bambuddy-Instanz ohne Anmeldung zugänglich. Alle Benutzer bleiben in der Datenbank, aber die Authentifizierung wird deaktiviert.',\n    disableAuthentication: 'Authentifizierung deaktivieren',\n    // Additional settings\n    configureBambuddy: 'Bambuddy konfigurieren',\n    systemDefault: 'Systemstandard',\n    archiveSettings: 'Archiv-Einstellungen',\n    newWindow: 'Neues Fenster',\n    embeddedOverlay: 'Eingebettetes Overlay',\n    preferredSlicer: 'Bevorzugter Slicer',\n    preferredSlicerDescription: 'Wähle die Slicer-Anwendung zum Öffnen von Dateien',\n    externalCameras: 'Externe Kameras',\n    costTracking: 'Kostenverfolgung',\n    printsOnly: 'Nur Drucke',\n    totalConsumption: 'Gesamtverbrauch',\n    dataManagement: 'Datenverwaltung',\n    storageUsage: 'Speichernutzung',\n    storageUsageDescription: 'Aufschlüsselung der Datennutzung nach Kategorie',\n    storageUsageTotal: 'Gesamt',\n    storageUsageErrors: 'Fehler',\n    storageUsageOtherBreakdown: 'Sonstiges (enthält statische Assets, Skripte und Konfigurationsdateien)',\n    storageUsageSystem: 'System',\n    storageUsageData: 'Daten',\n    storageUsageUnavailable: 'Speichernutzungsinformationen nicht verfügbar',\n    clearNotificationLogsDescription: 'Benachrichtigungsprotokolle älter als 30 Tage löschen',\n    resetUiPreferencesDescription: 'Seitenleisten-Reihenfolge, Theme, Ansichtsmodi und Layout-Einstellungen zurücksetzen. Drucker, Archive und Einstellungen werden nicht beeinflusst.',\n    enableHomeAssistant: 'Home Assistant aktivieren',\n    enableMqtt: 'MQTT aktivieren',\n    useTls: 'TLS verwenden',\n    enableMetricsEndpoint: 'Metrik-Endpunkt aktivieren',\n    availableMetrics: 'Verfügbare Metriken',\n    editUser: 'Benutzer bearbeiten',\n    deleteUserTitle: 'Benutzer löschen',\n    groupName: 'Gruppenname',\n    // Placeholders\n    leaveEmptyForAnonymous: 'Leer lassen für anonym',\n    leaveEmptyForNoAuth: 'Leer lassen für keine Authentifizierung',\n    enterNewPassword: 'Neues Passwort eingeben',\n    confirmNewPassword: 'Neues Passwort bestätigen',\n    enterGroupName: 'Gruppenname eingeben',\n    enterDescriptionOptional: 'Beschreibung eingeben (optional)',\n    enterCurrentPassword: 'Aktuelles Passwort eingeben',\n    enterNewPasswordMin6: 'Neues Passwort eingeben (min. 6 Zeichen)',\n    toast: {\n      keyCopied: 'Schlüssel in Zwischenablage kopiert',\n      copyFailed: 'Schlüssel konnte nicht kopiert werden',\n      keyAddedToBrowser: 'Schlüssel zum API-Browser hinzugefügt',\n      clearLogsFailed: 'Protokolle konnten nicht gelöscht werden',\n      uiPreferencesReset: 'UI-Einstellungen zurückgesetzt. Wird neu geladen...',\n      authDisabled: 'Authentifizierung erfolgreich deaktiviert',\n      authDisableFailed: 'Authentifizierung konnte nicht deaktiviert werden',\n      apiKeyCreated: 'API-Schlüssel erstellt',\n      apiKeyDeleted: 'API-Schlüssel gelöscht',\n      userCreated: 'Benutzer erfolgreich erstellt',\n      userUpdated: 'Benutzer erfolgreich aktualisiert',\n      userDeleted: 'Benutzer erfolgreich gelöscht',\n      groupCreated: 'Gruppe erfolgreich erstellt',\n      groupUpdated: 'Gruppe erfolgreich aktualisiert',\n      groupDeleted: 'Gruppe erfolgreich gelöscht',\n      fillRequiredFields: 'Bitte füllen Sie alle erforderlichen Felder aus',\n      passwordsDoNotMatch: 'Passwörter stimmen nicht überein',\n      passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',\n      enterGroupName: 'Bitte geben Sie einen Gruppennamen ein',\n      settingsSaved: 'Einstellungen gespeichert',\n      cameraSettingsSaved: 'Kamera-Einstellungen gespeichert',\n      enterCameraUrl: 'Bitte geben Sie eine Kamera-URL ein',\n      passwordChanged: 'Passwort erfolgreich geändert',\n      connectionFailed: 'Verbindung fehlgeschlagen',\n      testFailed: 'Test fehlgeschlagen',\n      cameraConnected: 'Kamera verbunden{{resolution}}',\n    },\n    testConnection: 'Verbindung testen',\n    catalog: {\n      spoolCatalog: 'Spulenkatalog',\n      spoolCatalogDescription: 'Leerspulengewichte nach Marke/Typ. Wird für die automatische Gewichtssuche beim Hinzufügen von Spulen verwendet.',\n      searchCatalog: 'Katalog durchsuchen...',\n      addNewEntry: 'Neuen Eintrag hinzufügen',\n      namePlaceholder: 'Name (z.B. Bambu Lab - Plastik)',\n      weight: 'Gewicht',\n      type: 'Typ',\n      default: 'Standard',\n      custom: 'Benutzerdefiniert',\n      noMatch: 'Keine Einträge entsprechen Ihrer Suche',\n      empty: 'Keine Einträge im Katalog',\n      deleteEntry: 'Eintrag löschen',\n      deleteConfirm: 'Möchten Sie \"{{name}}\" wirklich löschen?',\n      resetCatalog: 'Katalog zurücksetzen',\n      resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Einträge werden entfernt.',\n      loadFailed: 'Spulenkatalog konnte nicht geladen werden',\n      nameWeightRequired: 'Name und Gewicht sind erforderlich',\n      entryAdded: 'Eintrag hinzugefügt',\n      addFailed: 'Eintrag konnte nicht hinzugefügt werden',\n      entryUpdated: 'Eintrag aktualisiert',\n      updateFailed: 'Eintrag konnte nicht aktualisiert werden',\n      entryDeleted: 'Eintrag gelöscht',\n      deleteFailed: 'Eintrag konnte nicht gelöscht werden',\n      resetSuccess: 'Katalog auf Standardwerte zurückgesetzt',\n      resetFailed: 'Katalog konnte nicht zurückgesetzt werden',\n      exported: '{{count}} Einträge exportiert',\n      imported: '{{added}} Einträge importiert ({{skipped}} übersprungen)',\n      importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',\n      exportTooltip: 'Katalog als JSON exportieren',\n      importTooltip: 'Katalog aus JSON importieren',\n      resetTooltip: 'Auf Standardwerte zurücksetzen',\n      selectedCount: '{{count}} ausgewählt',\n      deleteSelected: 'Ausgewählte löschen',\n      bulkDeleteConfirm: 'Möchten Sie {{count}} Einträge wirklich löschen?',\n      bulkDeleted: '{{count}} Einträge gelöscht',\n      bulkDeleteFailed: 'Fehler beim Löschen der Einträge',\n    },\n    colorCatalog: {\n      title: 'Farbkatalog',\n      description: 'Filamentfarben nach Hersteller/Material. Wird für die automatische Farbsuche beim Hinzufügen von Spulen verwendet.',\n      searchColors: 'Farben durchsuchen...',\n      allManufacturers: 'Alle Hersteller',\n      addNewColor: 'Neue Farbe hinzufügen',\n      manufacturer: 'Hersteller',\n      colorName: 'Farbname',\n      hex: 'Hex',\n      materialOptional: 'Material (optional)',\n      showing: '{{filtered}} von {{total}} Farben angezeigt',\n      noMatch: 'Keine Farben entsprechen Ihrer Suche',\n      empty: 'Keine Farben im Katalog',\n      deleteColor: 'Farbe löschen',\n      deleteConfirm: 'Möchten Sie \"{{name}}\" wirklich löschen?',\n      resetCatalog: 'Farbkatalog zurücksetzen',\n      resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Farben werden entfernt.',\n      sync: 'Sync',\n      starting: 'Starten...',\n      syncTooltip: 'Von FilamentColors.xyz synchronisieren (2000+ Farben)',\n      loadFailed: 'Farbkatalog konnte nicht geladen werden',\n      fieldsRequired: 'Hersteller, Farbname und Hex-Farbe sind erforderlich',\n      colorAdded: 'Farbe hinzugefügt',\n      addFailed: 'Farbe konnte nicht hinzugefügt werden',\n      colorUpdated: 'Farbe aktualisiert',\n      updateFailed: 'Farbe konnte nicht aktualisiert werden',\n      colorDeleted: 'Farbe gelöscht',\n      deleteFailed: 'Farbe konnte nicht gelöscht werden',\n      resetSuccess: 'Farbkatalog auf Standardwerte zurückgesetzt',\n      resetFailed: 'Katalog konnte nicht zurückgesetzt werden',\n      syncUpToDate: 'Bereits aktuell ({{count}} Farben geprüft)',\n      syncComplete: '{{added}} neue Farben hinzugefügt ({{skipped}} bereits vorhanden)',\n      syncError: 'Sync-Fehler',\n      syncFailed: 'Synchronisierung von FilamentColors.xyz fehlgeschlagen',\n      exported: '{{count}} Farben exportiert',\n      imported: '{{added}} Farben importiert ({{skipped}} übersprungen)',\n      importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',\n      selectedCount: '{{count}} ausgewählt',\n      deleteSelected: 'Ausgewählte löschen',\n      bulkDeleteConfirm: 'Möchten Sie {{count}} Farben wirklich löschen?',\n      bulkDeleted: '{{count}} Farben gelöscht',\n      bulkDeleteFailed: 'Fehler beim Löschen der Farben',\n    },\n    // General tab\n    dateFormat: 'Datumsformat',\n    dateFormatUs: 'US (MM/TT/JJJJ)',\n    dateFormatEu: 'EU (TT/MM/JJJJ)',\n    dateFormatIso: 'ISO (JJJJ-MM-TT)',\n    timeFormat: 'Zeitformat',\n    timeFormat12: '12-Stunden (3:30 PM)',\n    timeFormat24: '24-Stunden (15:30)',\n    defaultPrinter: 'Standarddrucker',\n    defaultPrinterDescription: 'Diesen Drucker für Uploads, Nachdrucke und andere Vorgänge vorauswählen.',\n    slicerBambuStudio: 'Bambu Studio',\n    slicerOrcaSlicer: 'OrcaSlicer',\n    sidebarOrderDescription: 'Elemente in der Seitenleiste per Drag & Drop neu anordnen. Hier auf Standardreihenfolge zurücksetzen.',\n    setDefault: 'Standard setzen',\n    sidebarOrderSetDefaultHint: 'Standard setzen übernimmt die aktuelle Menüreihenfolge für Benutzer, die ihre noch nicht angepasst haben.',\n    sidebarDefaultSet: 'Standard-Menüreihenfolge wurde festgelegt.',\n    sidebarDefaultCleared: 'Standard-Menüreihenfolge gelöscht.',\n    sidebarDefaultFailed: 'Festlegen der Standard-Menüreihenfolge fehlgeschlagen.',\n    reset: 'Zurücksetzen',\n    // Appearance\n    darkMode: 'Dunkelmodus',\n    lightMode: 'Hellmodus',\n    active: '(aktiv)',\n    background: 'Hintergrund',\n    accent: 'Akzent',\n    style: 'Stil',\n    bgNeutral: 'Neutral',\n    bgWarm: 'Warm',\n    bgCool: 'Kühl',\n    bgOled: 'OLED Schwarz',\n    bgSlate: 'Schieferblau',\n    bgForest: 'Waldgrün',\n    accentGreen: 'Grün',\n    accentTeal: 'Türkis',\n    accentBlue: 'Blau',\n    accentOrange: 'Orange',\n    accentPurple: 'Lila',\n    accentRed: 'Rot',\n    styleClassic: 'Klassisch',\n    styleGlow: 'Leuchtend',\n    styleVibrant: 'Lebendig',\n    themeToggleHint: 'Zwischen Hell- und Dunkelmodus mit dem Sonnen-/Mondsymbol in der Seitenleiste wechseln.',\n    // Archive\n    autoArchivePrints: 'Drucke automatisch archivieren',\n    autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',\n    saveThumbnailsDescription: 'Vorschaubilder aus 3MF-Dateien extrahieren und speichern',\n    captureFinishPhotoDescription: 'Foto von der Druckerkamera aufnehmen, wenn der Druck abgeschlossen ist',\n    ffmpegNotInstalled: 'ffmpeg nicht installiert',\n    ffmpegRequired: 'Kameraaufnahme benötigt ffmpeg. Installieren über <brew>brew install ffmpeg</brew> (macOS) oder <apt>apt install ffmpeg</apt> (Linux).',\n    // Camera\n    camera: 'Kamera',\n    cameraViewMode: 'Kamera-Ansichtsmodus',\n    cameraOverlayDescription: 'Kamera öffnet sich als größenveränderbares Overlay auf dem Hauptbildschirm',\n    cameraWindowDescription: 'Kamera öffnet sich in einem separaten Browserfenster',\n    externalCamerasDescription: 'Externe Kameras konfigurieren, um die eingebaute Druckerkamera zu ersetzen. Unterstützt MJPEG-Streams, RTSP, HTTP-Snapshots und USB-Kameras (V4L2). Wenn aktiviert, wird die externe Kamera für Live-Ansicht und Abschlussfotos verwendet.',\n    cameraPlaceholderUsb: 'Gerätepfad (/dev/video0)',\n    cameraPlaceholderUrl: 'Kamera-URL (rtsp://... oder http://...)',\n    cameraTypeMjpeg: 'MJPEG-Stream',\n    cameraTypeRtsp: 'RTSP-Stream',\n    cameraTypeSnapshot: 'HTTP-Snapshot',\n    cameraTypeUsb: 'USB-Kamera (V4L2)',\n    cameraRotation: 'Drehung',\n    test: 'Testen',\n    connected: 'Verbunden',\n    disconnected: 'Getrennt',\n    // Cost tracking\n    currency: 'Währung',\n    defaultFilamentCost: 'Standard-Filamentkosten (pro kg)',\n    electricityCost: 'Stromkosten pro kWh',\n    energyDisplayMode: 'Energieanzeige-Modus',\n    energyModePrintDescription: 'Dashboard zeigt Summe der während Drucken verbrauchten Energie',\n    energyModeTotalDescription: 'Dashboard zeigt Gesamtenergie der Smart Plugs',\n    // File Manager\n    fileManager: 'Dateimanager',\n    createArchiveEntry: 'Archiveintrag beim Drucken erstellen',\n    createArchiveEntryDescription: 'Beim Drucken aus dem Dateimanager optional einen Archiveintrag erstellen',\n    lowDiskSpaceWarning: 'Warnung bei wenig Speicherplatz',\n    lowDiskSpaceDescription: 'Warnung anzeigen, wenn freier Speicherplatz unter diesen Schwellenwert fällt',\n    // Updates\n    printerFirmware: 'Drucker-Firmware',\n    checkFirmwareDescription: 'Nach Firmware-Updates von Bambu Lab suchen',\n    bambuddySoftware: 'Bambuddy Software',\n    autoCheckDescription: 'Automatisch beim Start nach neuen Versionen suchen',\n    checkNow: 'Jetzt prüfen',\n    updateAvailableVersion: 'Update verfügbar: v{{version}}',\n    releaseNotes: 'Versionshinweise',\n    updateViaDocker: 'Update über Docker Compose:',\n    installUpdate: 'Update installieren',\n    latestVersionRunning: 'Sie verwenden die neueste Version',\n    failedToCheckUpdates: 'Update-Prüfung fehlgeschlagen: {{error}}',\n    // Data Management\n    backupRestore: 'Sicherung & Wiederherstellung',\n    backupRestoreDescription: 'Einstellungen exportieren/importieren und GitHub-Backup konfigurieren',\n    goToBackup: 'Zur Sicherung',\n    // Network tab\n    externalUrl: 'Externe URL',\n    externalUrlDescription: 'Die externe URL, unter der Bambuddy erreichbar ist. Wird für Benachrichtigungsbilder und externe Integrationen verwendet.',\n    bambuddyUrl: 'Bambuddy-URL',\n    externalUrlHint: 'Protokoll und Port angeben (z.B. http://192.168.1.100:8000)',\n    ftpRetry: 'FTP-Wiederholung',\n    ftpRetryDescription: 'FTP-Operationen bei unzuverlässigem Drucker-WLAN wiederholen. Gilt für 3MF-Downloads, Druck-Uploads, Zeitraffer-Downloads und Firmware-Updates.',\n    autoRetryDescription: 'Fehlgeschlagene FTP-Operationen automatisch wiederholen',\n    retryAttempts: 'Wiederholungsversuche',\n    retryDelay: 'Wiederholungsverzögerung',\n    connectionTimeout: 'Verbindungs-Timeout',\n    time_one: '{{count}} Mal',\n    time_other: '{{count}} Mal',\n    second_one: '{{count}} Sekunde',\n    second_other: '{{count}} Sekunden',\n    nSeconds: '{{count}} Sekunden',\n    increaseForWeakWifi: 'Erhöhen für Drucker mit schwachem WLAN',\n    // Home Assistant\n    homeAssistant: 'Home Assistant',\n    homeAssistantFullDescription: 'Mit Home Assistant verbinden, um Smart Plugs über die HA REST-API zu steuern. Unterstützt Switch-, Light-, Input_Boolean- und Script-Entitäten.',\n    homeAssistantUrl: 'Home Assistant URL',\n    longLivedAccessToken: 'Langlebiges Zugriffstoken',\n    haTokenHint: 'Token in HA erstellen: Profil → Langlebige Zugriffstoken → Token erstellen',\n    connectionSuccessful: 'Verbindung erfolgreich',\n    connectionFailed: 'Verbindung fehlgeschlagen',\n    haConnectionSuccess: 'Erfolgreich mit Home Assistant verbunden.',\n    haConnectionFailed: 'Verbindung zu Home Assistant fehlgeschlagen.',\n    // MQTT\n    mqttPublishing: 'MQTT-Veröffentlichung',\n    mqttDescription: 'BamBuddy-Ereignisse an einen externen MQTT-Broker zur Integration mit Node-RED, Home Assistant und anderen Automatisierungssystemen veröffentlichen.',\n    mqttEnableDescription: 'Ereignisse an externen MQTT-Broker veröffentlichen',\n    brokerHostname: 'Broker-Hostname',\n    port: 'Port',\n    usernameOptional: 'Benutzername (optional)',\n    passwordOptional: 'Passwort (optional)',\n    topicPrefix: 'Topic-Präfix',\n    topicPrefixHint: 'Topics werden sein: {{prefix}}/printers/<serial>/status, etc.',\n    // Prometheus\n    prometheusMetrics: 'Prometheus-Metriken',\n    prometheusEndpointDescription: 'Druckermetriken unter <code>/api/v1/metrics</code> für Prometheus/Grafana-Überwachung bereitstellen.',\n    bearerTokenOptional: 'Bearer-Token (optional)',\n    bearerTokenHint: 'Wenn gesetzt, müssen Anfragen <code>Authorization: Bearer <token></code> enthalten',\n    metricsConnectionStatus: 'Verbindungsstatus',\n    metricsPrinterState: 'Druckerstatus (idle/printing/etc)',\n    metricsPrintProgress: 'Druckfortschritt 0-100%',\n    metricsBedTemp: 'Betttemperatur',\n    metricsNozzleTemp: 'Düsentemperatur',\n    metricsPrintsTotal: 'Gesamtdrucke nach Ergebnis',\n    metricsMore: '...und mehr (Schichten, Lüfter, Warteschlange, Filamentverbrauch)',\n    // Smart Plugs\n    smartPlugsDescription: 'Smart Plugs (Tasmota oder Home Assistant) verbinden, um Stromsteuerung zu automatisieren und Energieverbrauch für Ihre Drucker zu verfolgen.',\n    allOn: 'Alle Ein',\n    allOff: 'Alle Aus',\n    addSmartPlug: 'Smart Plug hinzufügen',\n    energySummary: 'Energieübersicht',\n    currentPower: 'Aktuelle Leistung',\n    plugsOnline: '{{reachable}}/{{total}} Plugs online',\n    today: 'Heute',\n    yesterday: 'Gestern',\n    total: 'Gesamt',\n    enablePlugsForSummary: 'Plugs aktivieren, um Energieübersicht zu sehen',\n    addNotificationProvider: 'Hinzufügen',\n    // Users\n    systemBadge: '(System)',\n    creating: 'Erstellen...',\n    changing: 'Ändern...',\n    deleteUserAndItems: 'Benutzer UND dessen Elemente löschen',\n    deleteUserKeepItems: 'Benutzer löschen, Elemente behalten (werden herrenlos)',\n    ok: 'OK',\n\n    // 2FA settings\n    twoFa: {\n      totpTitle: 'Authenticator-App (TOTP)',\n      totpDesc: 'Verwende eine Authenticator-App wie Google Authenticator, Aegis oder Authy.',\n      emailOtpTitle: 'E-Mail OTP',\n      emailOtpDesc: 'Sende einen Einmalcode an {{email}} beim Einloggen.',\n      emailOtpNoEmail: 'Füge eine E-Mail-Adresse zu deinem Konto hinzu, um diese Methode zu aktivieren.',\n      addEmailFirst: 'Dein Konto hat keine E-Mail-Adresse. Bitte einen Administrator, eine hinzuzufügen.',\n      setupTotp: 'Authenticator-App einrichten',\n      setupAuthApp: 'Authenticator-App einrichten',\n      setupInstructions: 'Scanne den QR-Code mit deiner Authenticator-App und bestätige mit einem Code.',\n      manualEntry: 'Kein Scanner? Gib dieses Secret manuell ein:',\n      scannedContinue: 'Code gescannt — weiter',\n      enterCodeToConfirm: 'Gib den 6-stelligen Code aus deiner Authenticator-App ein, um die Einrichtung zu bestätigen.',\n      activate: 'Aktivieren',\n      disableTotp: 'Authenticator deaktivieren',\n      disableConfirmHint: 'Gib einen gültigen TOTP-Code oder einen Backup-Code ein, um den Authenticator zu deaktivieren.',\n      totpDisabled: 'Authenticator-App deaktiviert.',\n      emailOtpEnabled: 'E-Mail OTP aktiviert.',\n      emailOtpDisabled: 'E-Mail OTP deaktiviert.',\n      smtpRequired: 'Bitte konfigurieren und testen Sie zuerst die SMTP-Einstellungen.',\n      invalidCode: 'Ungültiger Code. Bitte erneut versuchen.',\n      enableEmailOtp: 'E-Mail OTP aktivieren',\n      disableEmailOtp: 'E-Mail OTP deaktivieren',\n      emailSetupEnterCode: 'Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um zu bestätigen, dass Ihnen dieses Postfach gehört.',\n      verifyAndEnable: 'Verifizieren & Aktivieren',\n      emailDisablePasswordHint: 'Geben Sie Ihr Kontopasswort ein, um die Deaktivierung des E-Mail OTP zu bestätigen.',\n      passwordPlaceholder: 'Passwort eingeben',\n      backupCodesTitle: 'Backup-Codes sichern',\n      backupCodesWarning: 'Speichere diese Codes sicher. Jeder Code kann nur einmal verwendet werden und wird nicht erneut angezeigt.',\n      backupCodesRemaining: '{{count}} Backup-Codes verbleibend',\n      savedCodes: 'Codes gespeichert',\n      regenBackup: 'Backup-Codes neu generieren',\n      regenBackupHint: 'Gib deinen aktuellen TOTP-Code ein, um 10 neue Backup-Codes zu generieren. Alle bestehenden Codes werden ungültig.',\n      newBackupCodes: 'Neue Backup-Codes',\n      linkedAccounts: 'Verknüpfte SSO-Konten',\n      linkedAccountsDesc: 'Diese externen Identitätsanbieter sind mit deinem Konto verknüpft.',\n      oidcUnlinked: 'Konto getrennt.',\n    },\n\n    // OIDC provider settings\n    oidc: {\n      title: 'SSO / OIDC-Anbieter',\n      desc: 'Konfiguriere OpenID Connect-Anbieter für Single Sign-On.',\n      addProvider: 'Anbieter hinzufügen',\n      newProvider: 'Neuer Anbieter',\n      empty: 'Noch keine OIDC-Anbieter konfiguriert.',\n      created: 'Anbieter erstellt.',\n      updated: 'Anbieter aktualisiert.',\n      deleted: 'Anbieter gelöscht.',\n      deleteTitle: 'Anbieter löschen',\n      deleteMessage: '\"{{name}}\" löschen? Alle verknüpften Benutzerkonten werden getrennt.',\n      form: {\n        name: 'Anzeigename',\n        issuerUrl: 'Aussteller-URL',\n        clientId: 'Client-ID',\n        clientSecret: 'Client-Secret',\n        scopes: 'Scopes',\n        iconUrl: 'Symbol-URL (optional)',\n        enabled: 'Aktiviert',\n        autoCreate: 'Benutzer automatisch anlegen',\n        autoCreateDesc: 'Erstellt beim ersten Login automatisch ein lokales Konto.',\n        autoLink: 'Bestehende Konten automatisch verknüpfen',\n        autoLinkDesc: 'Verknüpft beim ersten Login vorhandene lokale Konten anhand der E-Mail-Adresse.',\n        secretHint: 'leer lassen zum Beibehalten',\n        secretPlaceholder: 'neues Secret',\n      },\n    },\n\n  },\n\n  // Notifications (for push notifications)\n  notification: {\n    printStarted: {\n      title: 'Druck gestartet',\n      body: '{{printer}}: {{filename}} wird gedruckt',\n    },\n    printCompleted: {\n      title: 'Druck abgeschlossen',\n      body: '{{printer}}: {{filename}} erfolgreich abgeschlossen',\n    },\n    printFailed: {\n      title: 'Druck fehlgeschlagen',\n      body: '{{printer}}: {{filename}} ist fehlgeschlagen',\n    },\n    printStopped: {\n      title: 'Druck gestoppt',\n      body: '{{printer}}: {{filename}} wurde gestoppt',\n    },\n    printProgress: {\n      title: 'Druckfortschritt',\n      body: '{{printer}}: {{filename}} ist zu {{percent}}% abgeschlossen',\n    },\n    printerOffline: {\n      title: 'Drucker offline',\n      body: '{{printer}} ist offline',\n    },\n    printerError: {\n      title: 'Druckerfehler',\n      body: '{{printer}}: {{error}}',\n    },\n    filamentLow: {\n      title: 'Filament niedrig',\n      body: '{{printer}}: Filament geht zur Neige',\n    },\n    maintenanceDue: {\n      title: 'Wartung fällig',\n      body: '{{printer}}: {{items}} benötigen Aufmerksamkeit',\n    },\n  },\n\n  // Errors\n  errors: {\n    generic: 'Etwas ist schiefgelaufen',\n    networkError: 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',\n    notFound: 'Nicht gefunden',\n    unauthorized: 'Nicht autorisiert',\n    serverError: 'Serverfehler',\n    validationError: 'Bitte überprüfen Sie Ihre Eingabe',\n    printerConnectionFailed: 'Verbindung zum Drucker fehlgeschlagen',\n    saveFailed: 'Speichern fehlgeschlagen',\n    deleteFailed: 'Löschen fehlgeschlagen',\n    loadFailed: 'Laden der Daten fehlgeschlagen',\n  },\n\n  // HMS Errors modal\n  hmsErrors: {\n    title: 'Fehler - {{name}}',\n    noErrors: 'Keine Fehler',\n    viewOnWiki: 'Im Bambu Lab Wiki ansehen',\n    clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',\n    clearErrors: 'Fehler löschen',\n    clearSuccess: 'HMS-Fehler gelöscht',\n    clearFailed: 'HMS-Fehler konnten nicht gelöscht werden',\n  },\n\n  // MQTT Debug modal\n  mqttDebug: {\n    title: 'MQTT-Debug-Protokoll',\n    searchPlaceholder: 'Topic oder Payload suchen...',\n    noMessages: 'Noch keine Nachrichten protokolliert',\n    startLoggingHint: 'Klicken Sie auf \"Protokollierung starten\", um MQTT-Nachrichten aufzuzeichnen',\n    noMessagesMatch: 'Keine Nachrichten entsprechen Ihrem Filter',\n    adjustFilterHint: 'Versuchen Sie, Ihre Such- oder Filterkriterien anzupassen',\n    incoming: 'Eingehend',\n    outgoing: 'Ausgehend',\n    loggingStopped: 'Protokollierung gestoppt',\n    loggingActive: 'Protokollierung aktiv - Nachrichten werden automatisch aktualisiert',\n    startLogging: 'Protokollierung starten',\n    stopLogging: 'Protokollierung stoppen',\n    clearLog: 'Protokoll löschen',\n    topic: 'Topic',\n    timestamp: 'Zeitstempel',\n    direction: 'Richtung',\n    all: 'Alle',\n  },\n\n  // Printer File Manager modal (printer internal storage)\n  printerFiles: {\n    title: 'Dateimanager',\n    storageUsed: 'Belegt:',\n    storageFree: 'Frei:',\n    filterPlaceholder: 'Dateien filtern...',\n    deleteButton: 'Löschen',\n    deleteFiles: '{{count}} Dateien löschen',\n    deleteFileConfirm: '\"{{name}}\" löschen? Dies kann nicht rückgängig gemacht werden.',\n    deleteFilesConfirm: '{{count}} ausgewählte Dateien löschen? Dies kann nicht rückgängig gemacht werden.',\n    noFiles: 'Keine Dateien auf dem Drucker',\n    loadingFiles: 'Dateien werden geladen...',\n    failedToLoad: 'Dateien konnten nicht geladen werden',\n    toast: {\n      filesDeleted: '{{count}} Datei(en) gelöscht',\n      deleteFailed: 'Löschen fehlgeschlagen: {{error}}',\n    },\n  },\n\n  // Confirmations\n  confirm: {\n    delete: 'Möchten Sie dies wirklich löschen?',\n    unsavedChanges: 'Sie haben ungespeicherte Änderungen. Möchten Sie wirklich verlassen?',\n    clearQueue: 'Möchten Sie die Warteschlange wirklich leeren?',\n  },\n\n  // Login page\n  login: {\n    title: 'Bambuddy Anmeldung',\n    subtitle: 'Melden Sie sich bei Ihrem Konto an',\n    username: 'Benutzername',\n    usernamePlaceholder: 'Benutzername eingeben',\n    usernameOrEmail: 'Benutzername oder E-Mail',\n    usernameOrEmailPlaceholder: 'Benutzername oder @ E-Mail',\n    password: 'Passwort',\n    passwordPlaceholder: 'Passwort eingeben',\n    signIn: 'Anmelden',\n    signingIn: 'Anmeldung läuft...',\n    forgotPassword: 'Passwort vergessen?',\n    loginSuccess: 'Erfolgreich angemeldet',\n    loginFailed: 'Anmeldung fehlgeschlagen',\n    enterCredentials: 'Bitte Benutzername und Passwort eingeben',\n    enterEmail: 'Bitte geben Sie Ihre E-Mail-Adresse ein',\n    oidcLoginFailed: 'OIDC-Anmeldung fehlgeschlagen',\n    oidcErrors: {\n      providerError: 'Der Identity-Provider hat einen Fehler zurückgegeben',\n      missingParameters: 'Dem OIDC-Callback fehlen erforderliche Parameter',\n      invalidState: 'OIDC-State ist ungültig oder wurde bereits verwendet',\n      stateExpired: 'OIDC-Sitzung abgelaufen — bitte erneut versuchen',\n      providerNotFound: 'OIDC-Provider nicht gefunden',\n      discoveryFailed: 'OIDC-Discovery-Dokument konnte nicht abgerufen werden',\n      invalidDiscovery: 'OIDC-Discovery-Dokument ist ungültig',\n      networkError: 'Netzwerkfehler beim OIDC-Token-Austausch',\n      badResponse: 'Unerwartete Antwort beim OIDC-Token-Austausch',\n      noIdToken: 'OIDC-Provider hat kein ID-Token zurückgegeben',\n      validationFailed: 'OIDC-Token-Validierung fehlgeschlagen',\n      nonceMismatch: 'OIDC-Nonce stimmt nicht überein — möglicher Replay-Angriff',\n      missingSubClaim: 'OIDC-Token enthält keinen Sub-Claim',\n      noLinkedAccount: 'Kein lokales Konto mit dieser OIDC-Identität verknüpft',\n      accountInactive: 'Ihr Konto ist inaktiv',\n      userResolutionFailed: 'Ihr Konto konnte nicht aufgelöst werden',\n      internalError: 'Interner Fehler beim OIDC-Login',\n      tokenExchangeFailed: 'OIDC-Token-Austausch fehlgeschlagen',\n    },\n    forgotPasswordTitle: 'Passwort vergessen',\n    forgotPasswordMessage: 'Wenn Sie Ihr Passwort vergessen haben, wenden Sie sich bitte an Ihren Systemadministrator.',\n    forgotPasswordEmailMessage: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen ein neues Passwort.',\n    emailAddress: 'E-Mail-Adresse',\n    emailPlaceholder: 'ihre.email@beispiel.de',\n    cancel: 'Abbrechen',\n    sending: 'Wird gesendet...',\n    sendResetEmail: 'Zurücksetzungs-E-Mail senden',\n    howToReset: 'So setzen Sie Ihr Passwort zurück:',\n    resetStep1: 'Kontaktieren Sie Ihren Bambuddy-Administrator',\n    resetStep2: 'Bitten Sie ihn, Ihr Passwort in der Benutzerverwaltung zurückzusetzen',\n    resetStep3: 'Er kann ein neues temporäres Passwort für Sie festlegen',\n    resetStep4: 'Melden Sie sich mit dem neuen Passwort an und ändern Sie es in den Einstellungen',\n    gotIt: 'Verstanden',\n    resetPassword: {\n      title: 'Neues Passwort festlegen',\n      subtitle: 'Geben Sie unten Ihr neues Passwort ein und bestätigen Sie es.',\n      newPassword: 'Neues Passwort',\n      newPasswordPlaceholder: 'Mindestens 8 Zeichen',\n      confirmPassword: 'Passwort bestätigen',\n      confirmPasswordPlaceholder: 'Neues Passwort wiederholen',\n      saving: 'Wird gespeichert\\u2026',\n      submit: 'Neues Passwort festlegen',\n      backToLogin: 'Zurück zur Anmeldung',\n      passwordsDoNotMatch: 'Passwörter stimmen nicht überein',\n      passwordTooShort: 'Passwort muss mindestens 8 Zeichen lang sein',\n      resetFailed: 'Passwort zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',\n    },\n    twoFA: {\n      title: 'Zwei-Faktor-Authentifizierung',\n      subtitle: 'Ihr Konto ist mit 2FA geschützt. Geben Sie unten den Bestätigungscode ein.',\n      methodAuthenticator: 'Authenticator-App',\n      methodEmail: 'E-Mail-Code',\n      methodBackup: 'Wiederherstellungscode',\n      instructionsTotp: 'Öffnen Sie Ihre Authenticator-App und geben Sie den 6-stelligen Code für Bambuddy ein.',\n      instructionsEmail: 'Ein 6-stelliger Code wurde an Ihre E-Mail-Adresse gesendet. Er ist 10 Minuten gültig.',\n      instructionsEmailNotSent: 'Klicken Sie unten, um einen Bestätigungscode per E-Mail zu erhalten.',\n      instructionsBackup: 'Geben Sie einen Ihrer 8-stelligen Wiederherstellungscodes ein. Jeder Code kann nur einmal verwendet werden.',\n      sendCodeButton: 'Code per E-Mail senden',\n      sendingCode: 'Wird gesendet...',\n      resendCode: 'Code erneut senden',\n      codeLabel: 'Bestätigungscode',\n      backupCodeLabel: 'Wiederherstellungscode',\n      codePlaceholder: '000000',\n      backupCodePlaceholder: 'XXXXXXXX',\n      verifyButton: 'Bestätigen',\n      verifyingButton: 'Wird überprüft...',\n      backToLogin: '← Zurück zur Anmeldung',\n      orContinueWith: 'oder anmelden mit',\n      signInWith: 'Anmelden mit {{provider}}',\n      enterCode: 'Bitte geben Sie den Bestätigungscode ein',\n      sendCodeFailed: 'Bestätigungscode konnte nicht gesendet werden',\n      invalidCode: 'Ungültiger Code. Bitte erneut versuchen.',\n    },\n\n  },\n\n  // Setup page\n  setup: {\n    title: 'Bambuddy Einrichtung',\n    subtitle: 'Konfigurieren Sie die Authentifizierung für Ihre Bambuddy-Instanz',\n    enableAuth: 'Authentifizierung aktivieren',\n    adminAccount: 'Admin-Konto',\n    adminAccountDesc: 'Wenn bereits Admin-Benutzer existieren, wird die Authentifizierung mit den vorhandenen Admin-Konten aktiviert. Lassen Sie die Felder unten leer, um vorhandene Admins zu verwenden, oder geben Sie neue Anmeldedaten ein, um einen neuen Admin-Benutzer zu erstellen.',\n    adminUsername: 'Admin-Benutzername',\n    adminPassword: 'Admin-Passwort',\n    optionalIfAdminExists: '(optional, wenn Admin-Benutzer existieren)',\n    adminUsernamePlaceholder: 'Admin-Benutzernamen eingeben (optional)',\n    adminPasswordPlaceholder: 'Admin-Passwort eingeben (optional)',\n    confirmPassword: 'Passwort bestätigen',\n    confirmPasswordPlaceholder: 'Admin-Passwort bestätigen',\n    settingUp: 'Einrichtung läuft...',\n    completeSetup: 'Einrichtung abschließen',\n    toast: {\n      authEnabledAdminCreated: 'Authentifizierung aktiviert und Admin-Benutzer erstellt',\n      authEnabledExistingAdmins: 'Authentifizierung mit vorhandenen Admin-Benutzern aktiviert',\n      setupCompleted: 'Einrichtung abgeschlossen',\n      enterBothCredentials: 'Bitte geben Sie sowohl Admin-Benutzernamen als auch Passwort ein, oder lassen Sie beide leer, um vorhandene Admin-Benutzer zu verwenden',\n      passwordsDoNotMatch: 'Passwörter stimmen nicht überein',\n      passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',\n    },\n  },\n\n  // Password change\n  changePassword: {\n    title: 'Passwort ändern',\n    currentPassword: 'Aktuelles Passwort',\n    currentPasswordPlaceholder: 'Aktuelles Passwort eingeben',\n    newPassword: 'Neues Passwort',\n    newPasswordPlaceholder: 'Neues Passwort eingeben (min. 6 Zeichen)',\n    confirmPassword: 'Neues Passwort bestätigen',\n    confirmPasswordPlaceholder: 'Neues Passwort bestätigen',\n    passwordsDoNotMatch: 'Passwörter stimmen nicht überein',\n    passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',\n    changing: 'Wird geändert...',\n    success: 'Passwort erfolgreich geändert',\n    failed: 'Passwortänderung fehlgeschlagen',\n  },\n\n  // Plate detection alert\n  plateAlert: {\n    title: 'Druck pausiert!',\n    message: 'Objekte auf dem Druckbett erkannt. Der Druck wurde automatisch pausiert. Bitte räumen Sie das Druckbett und setzen Sie den Druck fort.',\n    understand: 'Verstanden',\n  },\n\n  // Camera page\n  camera: {\n    title: 'Kameraansicht',\n    invalidPrinterId: 'Ungültige Drucker-ID',\n    live: 'Live',\n    snapshot: 'Schnappschuss',\n    restartStream: 'Stream neu starten',\n    refreshSnapshot: 'Schnappschuss aktualisieren',\n    fullscreen: 'Vollbild',\n    exitFullscreen: 'Vollbild beenden',\n    connectingToCamera: 'Verbinde mit Kamera...',\n    capturingSnapshot: 'Schnappschuss wird aufgenommen...',\n    connectionLost: 'Verbindung verloren',\n    connectionFailed: 'Kameraverbindung fehlgeschlagen',\n    reconnecting: 'Neuverbindung in {{countdown}}s... (Versuch {{attempt}}/{{max}})',\n    reconnectNow: 'Jetzt verbinden',\n    cameraUnavailable: 'Kamera nicht verfügbar',\n    cameraUnavailableDesc: 'Stellen Sie sicher, dass der Drucker eingeschaltet und verbunden ist.',\n    noCamera: 'Keine Kamera verfügbar',\n    retry: 'Erneut versuchen',\n    cameraStream: 'Kamera-Stream',\n    zoomOut: 'Verkleinern',\n    zoomIn: 'Vergrößern',\n    resetZoom: 'Zoom zurücksetzen',\n    recording: 'Aufnahme',\n    startRecording: 'Aufnahme starten',\n    stopRecording: 'Aufnahme stoppen',\n    chamberLight: 'Kammerbeleuchtung umschalten',\n  },\n\n  // Groups management\n  groups: {\n    title: 'Gruppenverwaltung',\n    subtitle: 'Berechtigungsgruppen für Zugriffskontrolle verwalten',\n    backToSettings: 'Zurück zu Einstellungen',\n    createGroup: 'Gruppe erstellen',\n    noPermission: 'Sie haben keine Berechtigung, auf diese Seite zuzugreifen.',\n    system: 'System',\n    noDescription: 'Keine Beschreibung',\n    usersCount: '{{count}} Benutzer',\n    permissionsCount: '{{count}} Berechtigungen',\n    edit: 'Bearbeiten',\n    delete: 'Löschen',\n    toast: {\n      created: 'Gruppe erfolgreich erstellt',\n      updated: 'Gruppe erfolgreich aktualisiert',\n      deleted: 'Gruppe erfolgreich gelöscht',\n      enterGroupName: 'Bitte geben Sie einen Gruppennamen ein',\n    },\n    modal: {\n      editGroup: 'Gruppe bearbeiten',\n      createGroup: 'Gruppe erstellen',\n      cancel: 'Abbrechen',\n      saving: 'Speichern...',\n      creating: 'Erstellen...',\n      saveChanges: 'Änderungen speichern',\n    },\n    form: {\n      groupName: 'Gruppenname',\n      groupNamePlaceholder: 'Gruppennamen eingeben',\n      systemGroupWarning: 'Systemgruppennamen können nicht geändert werden',\n      description: 'Beschreibung',\n      descriptionPlaceholder: 'Beschreibung eingeben (optional)',\n      permissions: 'Berechtigungen ({{count}} ausgewählt)',\n    },\n    deleteModal: {\n      title: 'Gruppe löschen',\n      message: 'Sind Sie sicher, dass Sie diese Gruppe löschen möchten? Benutzer in dieser Gruppe verlieren diese Berechtigungen.',\n      confirm: 'Gruppe löschen',\n    },\n    editor: {\n      title: 'Gruppe bearbeiten',\n      createTitle: 'Gruppe erstellen',\n      search: 'Berechtigungen suchen...',\n      selectAll: 'Alle auswählen',\n      clearAll: 'Alle abwählen',\n      permissionsSelected: '{{count}} ausgewählt',\n      noResults: 'Keine Berechtigungen entsprechen Ihrer Suche',\n    },\n  },\n\n  // Users management\n  users: {\n    title: 'Benutzerverwaltung',\n    subtitle: 'Benutzer und deren Zugriff auf Ihre Bambuddy-Instanz verwalten',\n    backToSettings: 'Zurück zu Einstellungen',\n    createUser: 'Benutzer erstellen',\n    noPermission: 'Sie haben keine Berechtigung, auf diese Seite zuzugreifen.',\n    admin: 'Admin',\n    noGroups: 'Keine Gruppen',\n    active: 'Aktiv',\n    inactive: 'Inaktiv',\n    edit: 'Bearbeiten',\n    delete: 'Löschen',\n    system: 'System',\n    noGroupsAvailable: 'Keine Gruppen verfügbar',\n    table: {\n      username: 'Benutzername',\n      groups: 'Gruppen',\n      status: 'Status',\n      actions: 'Aktionen',\n    },\n    toast: {\n      created: 'Benutzer erfolgreich erstellt',\n      updated: 'Benutzer erfolgreich aktualisiert',\n      deleted: 'Benutzer erfolgreich gelöscht',\n      fillRequired: 'Bitte füllen Sie alle Pflichtfelder aus',\n      passwordsDoNotMatch: 'Passwörter stimmen nicht überein',\n      passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',\n    },\n    modal: {\n      createUser: 'Benutzer erstellen',\n      editUser: 'Benutzer bearbeiten',\n      cancel: 'Abbrechen',\n      creating: 'Erstellen...',\n      saving: 'Speichern...',\n      saveChanges: 'Änderungen speichern',\n      advancedAuthSubtitle: 'mit erweiterter Authentifizierung',\n    },\n    form: {\n      username: 'Benutzername',\n      usernamePlaceholder: 'Benutzernamen eingeben',\n      email: 'E-Mail',\n      emailPlaceholder: 'benutzer@beispiel.de',\n      password: 'Passwort',\n      passwordPlaceholder: 'Passwort eingeben',\n      confirmPassword: 'Passwort bestätigen',\n      confirmPasswordPlaceholder: 'Passwort bestätigen',\n      newPasswordPlaceholder: 'Neues Passwort eingeben',\n      confirmNewPasswordPlaceholder: 'Neues Passwort bestätigen',\n      leaveBlankToKeep: 'leer lassen, um das aktuelle zu behalten',\n      groups: 'Gruppen',\n      optional: 'optional',\n      autoGeneratedPassword: 'Ein sicheres Passwort wird automatisch generiert und per E-Mail an den Benutzer gesendet.',\n      passwordManagedByAdvancedAuth: 'Das Passwort wird durch erweiterte Authentifizierung verwaltet. Verwenden Sie \"Passwort zurücksetzen\", um ein neues Passwort per E-Mail an den Benutzer zu senden.',\n      resetPassword: 'Passwort zurücksetzen',\n      resettingPassword: 'Passwort wird zurückgesetzt...',\n    },\n    deleteModal: {\n      title: 'Benutzer löschen',\n      message: 'Sind Sie sicher, dass Sie diesen Benutzer löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.',\n      confirm: 'Benutzer löschen',\n    },\n  },\n\n  // Stream overlay\n  streamOverlay: {\n    title: 'Stream-Overlay',\n    invalidPrinterId: 'Ungültige Drucker-ID',\n    cameraStream: 'Kamera-Stream',\n    progress: 'Fortschritt',\n    eta: 'ETA',\n    printerIdle: 'Drucker ist inaktiv',\n    printerOffline: 'Drucker offline',\n    status: {\n      printing: 'Druckt',\n      paused: 'Pausiert',\n      finished: 'Fertig',\n      failed: 'Fehlgeschlagen',\n      idle: 'Inaktiv',\n      unknown: 'Unbekannt',\n    },\n  },\n\n  // Profiles\n  profiles: {\n    title: 'Profile',\n    subtitle: 'Verwalten Sie Ihre Slicer-Voreinstellungen und Druckvorschub-Kalibrierungen',\n    tabs: {\n      cloud: 'Cloud-Profile',\n      local: 'Lokale Profile',\n      kprofiles: 'K-Profile',\n    },\n    localProfiles: {\n      title: 'Lokale Profile',\n      subtitle: 'Slicer-Voreinstellungen aus OrcaSlicer importieren und verwalten',\n      import: 'Profile importieren',\n      importDesc: '.bbscfg-, .bbsflmt-, .orca_filament-, .zip- oder .json-Dateien hier ablegen',\n      importing: 'Importiere...',\n      search: 'Lokale Voreinstellungen durchsuchen...',\n      noPresets: 'Noch keine lokalen Voreinstellungen',\n      badge: 'Lokal',\n      edit: 'Bearbeiten',\n      delete: 'Löschen',\n      cancel: 'Abbrechen',\n      deleteConfirmTitle: 'Voreinstellung löschen',\n      deleteConfirm: 'Möchten Sie diese Voreinstellung wirklich löschen? Dies kann nicht rückgängig gemacht werden.',\n      source: 'Quelle',\n      inheritsFrom: 'Erbt von',\n      filamentType: 'Typ',\n      vendor: 'Hersteller',\n      compatiblePrinters: 'Drucker',\n      nozzleTemp: 'Düsentemperatur',\n      cost: 'Kosten',\n      density: 'Dichte',\n      pressureAdvance: 'Druckvorschub',\n      filament: 'Filament',\n      process: 'Prozess',\n      printer: 'Drucker',\n      toast: {\n        importSuccess: '{{count}} Voreinstellung(en) importiert',\n        importSkipped: '{{count}} Voreinstellung(en) übersprungen (Duplikate)',\n        importError: '{{count}} Fehler beim Import',\n        deleted: 'Voreinstellung gelöscht',\n        updated: 'Voreinstellung aktualisiert',\n      },\n    },\n    connectedAs: 'Verbunden als',\n    logout: 'Abmelden',\n    noLogoutPermission: 'Sie haben keine Berechtigung zum Abmelden',\n    failedToLoad: 'Profile konnten nicht geladen werden',\n    retry: 'Erneut versuchen',\n    time: {\n      justNow: 'Gerade eben',\n      minsAgo: 'vor {{count}}m',\n      hoursAgo: 'vor {{count}}h',\n      daysAgo: 'vor {{count}}d',\n    },\n    toast: {\n      loggedOut: 'Abgemeldet',\n    },\n    login: {\n      title: 'Mit Bambu Cloud verbinden',\n      subtitle: 'Synchronisieren Sie Ihre Slicer-Voreinstellungen geräteübergreifend',\n      email: 'E-Mail',\n      password: 'Passwort',\n      region: 'Region',\n      regionGlobal: 'Global',\n      regionChina: 'China',\n      verificationCode: 'Bestätigungscode',\n      totpCode: 'Authenticator-Code',\n      checkEmail: 'Prüfen Sie Ihre E-Mail ({{email}}) für einen 6-stelligen Code',\n      enterTotpHint: 'Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein',\n      accessToken: 'Zugriffstoken',\n      accessTokenHint: 'Fügen Sie Ihr Bambu Lab Zugriffstoken ein (aus Bambu Studio)',\n      back: 'Zurück',\n      loginButton: 'Anmelden',\n      verifyButton: 'Bestätigen',\n      setTokenButton: 'Token setzen',\n      useToken: 'Stattdessen Zugriffstoken verwenden',\n      useEmail: 'Stattdessen mit E-Mail anmelden',\n      toast: {\n        loggedIn: 'Erfolgreich angemeldet',\n        codeSent: 'Bestätigungscode an Ihre E-Mail gesendet',\n        enterTotp: 'Geben Sie den Code aus Ihrer Authenticator-App ein',\n        tokenSet: 'Token erfolgreich gesetzt',\n      },\n    },\n    presets: {\n      myPreset: 'Mein Profil (bearbeitbar)',\n      duplicate: 'Duplizieren',\n      editable: 'Bearbeitbar',\n      failedToLoadDetails: 'Profil-Details konnten nicht geladen werden',\n      deleteConfirm: 'Dieses Profil löschen?',\n      deleteWarning: '\"{{name}}\" wird dauerhaft aus Bambu Cloud gelöscht. Dies kann nicht rückgängig gemacht werden.',\n      noDuplicatePermission: 'Sie haben keine Berechtigung zum Duplizieren von Profilen',\n      noEditPermission: 'Sie haben keine Berechtigung zum Bearbeiten von Profilen',\n      noDeletePermission: 'Sie haben keine Berechtigung zum Löschen von Profilen',\n      types: {\n        filament: 'Filament-Profil',\n        printer: 'Drucker-Profil',\n        process: 'Prozess-Profil',\n      },\n      toast: {\n        deleted: 'Profil gelöscht',\n        created: 'Profil erstellt',\n        updated: 'Profil aktualisiert',\n        duplicated: 'Profil dupliziert',\n        fieldAdded: 'Feld \"{{key}}\" hinzugefügt',\n        exported: 'Profil exportiert',\n      },\n      baseLabel: 'Basis: {{name}}',\n      currentLabel: 'Aktuell: {{name}}',\n      newPreset: 'Neues Profil',\n      editPreset: 'Profil bearbeiten',\n      duplicatePreset: 'Profil duplizieren',\n      createNewPreset: 'Neues Profil erstellen',\n      customizeSettings: 'Passen Sie die Einstellungen für Ihr neues Profil an',\n      compareWithBase: 'Mit Basis-Profil vergleichen',\n      compare: 'Vergleichen',\n      // CreatePresetModal - Basic Info\n      basePreset: 'Basis-Profil',\n      selectBasePreset: 'Basis-Profil auswählen...',\n      presetName: 'Profilname',\n      myCustomPreset: 'Mein eigenes Profil',\n      inheritsFrom: 'Erbt von',\n      dropJsonToImport: 'JSON zum Importieren ablegen',\n      // CreatePresetModal - Tabs\n      tabs: {\n        common: 'Allgemein',\n        allFields: 'Alle Felder',\n      },\n      // CreatePresetModal - All Fields Tab\n      availableFields: 'Verfügbare Felder',\n      searchFieldsPlaceholder: 'Felder suchen...',\n      noMatchingFields: 'Keine passenden Felder',\n      allFieldsAdded: 'Alle Felder hinzugefügt',\n      addCustomField: 'Eigenes Feld hinzufügen',\n      yourOverrides: 'Ihre Überschreibungen',\n      noOverridesYet: 'Noch keine Überschreibungen',\n      clickFieldsToAdd: 'Klicken Sie links auf Felder, um sie hinzuzufügen',\n      saveAsTemplate: 'Als Vorlage speichern',\n      jsonTip: 'Tipp: Ziehen Sie eine .json-Datei auf dieses Fenster, um Einstellungen zu importieren',\n    },\n    cloudView: {\n      searchPlaceholder: 'Profile suchen...',\n      templates: 'Vorlagen',\n      refresh: 'Aktualisieren',\n      newPreset: 'Neues Profil',\n      clearFilters: 'Filter zurücksetzen',\n      // Compare mode\n      compareMode: 'Vergleichsmodus',\n      selectAnotherPreset: 'Wählen Sie ein weiteres {{type}}-Profil',\n      clickTwoPresets: 'Klicken Sie auf zwei Profile des gleichen Typs zum Vergleichen',\n      selectFirst: '1. Erstes auswählen',\n      selectSecond: '2. Zweites auswählen',\n      compareNow: 'Jetzt vergleichen',\n      // Status row\n      lastSynced: 'Zuletzt synchronisiert:',\n      showingCount: '{{showing}} von {{total}} Profilen',\n      noPresetsFound: 'Keine Profile gefunden',\n      // Column headers\n      columns: {\n        filament: 'Filament',\n        process: 'Prozess',\n        printer: 'Drucker',\n      },\n      noFilamentPresets: 'Keine Filament-Profile',\n      noProcessPresets: 'Keine Prozess-Profile',\n      noPrinterPresets: 'Keine Drucker-Profile',\n      // Filters\n      filters: {\n        type: 'Typ',\n        owner: 'Besitzer',\n        printer: 'Drucker',\n        nozzle: 'Düse',\n        filament: 'Filament',\n        layer: 'Schicht',\n        all: 'Alle',\n        myPresets: 'Meine Profile',\n        builtIn: 'Voreingestellt',\n        process: 'Prozess',\n      },\n      // Permissions\n      noTemplatesPermission: 'Sie haben keine Berechtigung, Vorlagen zu verwalten',\n      noRefreshPermission: 'Sie haben keine Berechtigung, Profile zu aktualisieren',\n      noCreatePermission: 'Sie haben keine Berechtigung, Profile zu erstellen',\n    },\n    templates: {\n      title: 'Schnellvorlagen',\n      noTemplates: 'Noch keine Vorlagen',\n      createFirst: 'Erstellen Sie Vorlagen aus dem Preset-Editor',\n      typeFilter: 'Typ:',\n      deleteTitle: 'Vorlage löschen',\n      deleteWarning: 'Diese Aktion kann nicht rückgängig gemacht werden',\n      deleteConfirm: 'Möchten Sie \"{{name}}\" wirklich löschen?',\n      namePlaceholder: 'Vorlagenname',\n      descriptionPlaceholder: 'Beschreibung',\n      settingsJson: 'Einstellungen (JSON)',\n      fieldsCount: '{{count}} Felder',\n      shownInModals: 'In Dialogen angezeigt',\n      hiddenInModals: 'In Dialogen ausgeblendet',\n      apply: 'Anwenden',\n      toast: {\n        deleted: 'Vorlage gelöscht',\n        updated: 'Vorlage aktualisiert',\n        created: 'Vorlage erstellt',\n        applied: 'Vorlage angewendet',\n      },\n    },\n  },\n\n  // Support/Debug\n  support: {\n    debugLoggingActive: 'Debug-Protokollierung ist aktiv',\n    manageLogs: 'Verwalten',\n    collectItem7: 'Drucker-Verbindungsstatus und Firmware-Versionen',\n    collectItem8: 'Integrationsstatus (Spoolman, MQTT, HA)',\n    collectItem9: 'Netzwerkschnittstellen (nur Subnetze)',\n    collectItem10: 'Python-Paketversionen',\n    collectItem11: 'Datenbankzustandsprüfungen',\n    collectItem12: 'Docker-Umgebungsdetails',\n  },\n\n  // File manager\n  fileManager: {\n    title: 'Dateimanager',\n    subtitle: 'Organisieren und verwalten Sie Ihre Druckdateien',\n    uploadFiles: 'Dateien hochladen',\n    newFolder: 'Neuer Ordner',\n    folderName: 'Ordnername',\n    folderNamePlaceholder: 'z.B. Funktionsteile',\n    renameFile: 'Datei umbenennen',\n    renameFolder: 'Ordner umbenennen',\n    moveFiles: '{{count}} Datei(en) verschieben',\n    rootNoFolder: 'Stammverzeichnis (Kein Ordner)',\n    current: 'aktuell',\n    linkFolder: 'Ordner verknüpfen',\n    linkFolderDescription: '\"{{name}}\" mit einem Projekt oder Archiv verknüpfen für schnellen Zugriff.',\n    project: 'Projekt',\n    archive: 'Archiv',\n    noProjectsFound: 'Keine Projekte gefunden',\n    noArchivesFound: 'Keine Archive gefunden',\n    unlink: 'Verknüpfung aufheben',\n    link: 'Verknüpfen',\n    dragDropFiles: 'Dateien hierher ziehen',\n    dropFilesHere: 'Dateien hier ablegen',\n    orClickToBrowse: 'oder klicken zum Durchsuchen',\n    allFileTypesSupported: 'Alle Dateitypen werden unterstützt. ZIP-Dateien werden extrahiert.',\n    zipFilesDetected: 'ZIP-Dateien erkannt',\n    zipExtractOptions: 'ZIP-Dateien werden extrahiert. Wählen Sie, wie die Ordnerstruktur behandelt werden soll:',\n    preserveZipStructure: 'Ordnerstruktur aus ZIP beibehalten',\n    createFolderFromZip: 'Ordner aus ZIP-Dateiname erstellen',\n    stlThumbnailGeneration: 'STL-Vorschaubildgenerierung',\n    zipMayContainStl: 'ZIP-Dateien können STL-Dateien enthalten. Vorschaubilder können während der Extraktion generiert werden.',\n    thumbnailsCanBeGenerated: 'Vorschaubilder können für STL-Dateien generiert werden. Große Modelle benötigen möglicherweise mehr Zeit.',\n    generateThumbnailsForStl: 'Vorschaubilder für STL-Dateien generieren',\n    threemfDetected: '3MF-Dateien erkannt',\n    threemfExtractionInfo: 'Druckermodell, Material, Farbe und Druckeinstellungen werden automatisch aus 3MF-Dateien extrahiert.',\n    willBeExtracted: 'Wird extrahiert',\n    filesExtracted: '{{count}} Dateien extrahiert',\n    uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',\n    uploadFailed: 'Hochladen fehlgeschlagen',\n    zipFilesFailed: '{{count}} Dateien fehlgeschlagen',\n    uploading: 'Hochladen...',\n    changeLink: 'Verknüpfung ändern...',\n    linkTo: 'Verknüpfen mit...',\n    linkToProjectOrArchive: 'Mit Projekt oder Archiv verknüpfen',\n    addToQueue: 'Zur Warteschlange',\n    schedulePrint: 'Planen',\n    generateThumbnail: 'Vorschaubild generieren',\n    generateThumbnails: 'Vorschaubilder generieren',\n    generateThumbnailsForMissing: 'Vorschaubilder für STL-Dateien ohne Vorschau generieren',\n    gridView: 'Rasteransicht',\n    listView: 'Listenansicht',\n    lowDiskSpaceWarning: 'Warnung: Wenig Speicherplatz',\n    lowDiskSpaceDetails: 'Nur {{free}} frei von {{total}} gesamt. Schwellenwert ist auf {{threshold}} GB eingestellt.',\n    files: 'Dateien',\n    folders: 'Ordner',\n    size: 'Größe',\n    free: 'Frei',\n    allFiles: 'Alle Dateien',\n    wrap: 'Umbrechen',\n    enableTextWrapping: 'Textumbruch aktivieren',\n    disableTextWrapping: 'Textumbruch deaktivieren',\n    collapse: 'Einklappen',\n    collapseFoldersByDefault: 'Ordner standardmäßig einklappen',\n    expandFoldersByDefault: 'Ordner standardmäßig ausklappen',\n    dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen',\n    searchFiles: 'Dateien suchen...',\n    allTypes: 'Alle Typen',\n    prints: 'Drucke',\n    ascending: 'Aufsteigend',\n    descending: 'Absteigend',\n    resultsCount: '{{showing}} von {{total}} Dateien',\n    selectAll: 'Alle auswählen',\n    deselectAll: 'Auswahl aufheben',\n    selected: '{{count}} ausgewählt',\n    adding: 'Hinzufügen...',\n    loadingFiles: 'Dateien werden geladen...',\n    folderIsEmpty: 'Ordner ist leer',\n    noFilesYet: 'Noch keine Dateien',\n    folderEmptyDescription: 'Laden Sie Dateien hoch oder verschieben Sie Dateien in diesen Ordner.',\n    noFilesDescription: 'Laden Sie Dateien hoch, um Ihre Druckdateien zu organisieren.',\n    noMatchingFiles: 'Keine passenden Dateien',\n    noMatchingFilesDescription: 'Keine Dateien entsprechen Ihren aktuellen Such- oder Filterkriterien.',\n    clearFilters: 'Filter zurücksetzen',\n    printedCount: '{{count}}x gedruckt',\n    uploadedBy: 'Hochgeladen von',\n    deleteFolder: 'Ordner löschen',\n    deleteFile: 'Datei löschen',\n    deleteFilesCount: '{{count}} Dateien löschen',\n    deleteFolderConfirm: 'Möchten Sie diesen Ordner wirklich löschen? Alle Dateien darin werden ebenfalls gelöscht.',\n    deleteFileConfirm: 'Möchten Sie diese Datei wirklich löschen?',\n    deleteFilesConfirm: 'Möchten Sie {{count}} ausgewählte Dateien wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',\n    deleting: 'Wird gelöscht...',\n    noPermissionRenameFolder: 'Sie haben keine Berechtigung, Ordner umzubenennen',\n    noPermissionLinkFolder: 'Sie haben keine Berechtigung, Ordner zu verknüpfen',\n    noPermissionDeleteFolder: 'Sie haben keine Berechtigung, Ordner zu löschen',\n    noPermissionPrint: 'Sie haben keine Berechtigung zum Drucken',\n    noPermissionAddToQueue: 'Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen',\n    noPermissionDownload: 'Sie haben keine Berechtigung, Dateien herunterzuladen',\n    noPermissionRenameFile: 'Sie haben keine Berechtigung, diese Datei umzubenennen',\n    noPermissionGenerateThumbnail: 'Sie haben keine Berechtigung, Vorschaubilder zu generieren',\n    noPermissionDeleteFile: 'Sie haben keine Berechtigung, diese Datei zu löschen',\n    noPermissionCreateFolder: 'Sie haben keine Berechtigung, Ordner zu erstellen',\n    noPermissionUpload: 'Sie haben keine Berechtigung, Dateien hochzuladen',\n    noPermissionMoveFiles: 'Sie haben keine Berechtigung, Dateien zu verschieben',\n    noPermissionDeleteFiles: 'Sie haben keine Berechtigung, Dateien zu löschen',\n    // External folder\n    linkExternal: 'Extern verknüpfen',\n    linkExternalFolder: 'Externen Ordner verknüpfen',\n    linkExternalFolderDescription: 'Ein Host-Verzeichnis (NAS, USB, Netzlaufwerk) in den Dateimanager einbinden. Dateien werden nicht kopiert — sie werden direkt vom Originalpfad gelesen.',\n    externalFolderNamePlaceholder: 'z.B. NAS-Drucke',\n    externalPath: 'Host-Pfad',\n    externalPathHelp: 'Absoluter Pfad zum Verzeichnis auf dem Docker-Host. Muss als Bind-Mount in den Container eingebunden sein.',\n    readOnly: 'Nur Lesen',\n    readOnlyHelp: 'verhindert Uploads und Löschungen',\n    showHiddenFiles: 'Versteckte Dateien anzeigen (Punkt-Dateien)',\n    externalFolder: 'Externer Ordner',\n    scanFolder: 'Scannen',\n    toast: {\n      folderCreated: 'Ordner erstellt',\n      folderDeleted: 'Ordner gelöscht',\n      fileDeleted: 'Datei gelöscht',\n      filesDeleted: '{{count}} Dateien gelöscht',\n      filesMoved: 'Dateien verschoben',\n      folderLinked: 'Ordner verknüpft',\n      folderUnlinked: 'Ordnerverknüpfung aufgehoben',\n      externalFolderLinked: 'Externer Ordner verknüpft und gescannt',\n      folderScanned: 'Scan abgeschlossen: {{added}} hinzugefügt, {{removed}} entfernt',\n      addedToQueue: '{{count}} Datei(en) zur Warteschlange hinzugefügt',\n      addedToQueuePartial: '{{added}} Datei(en) hinzugefügt, {{failed}} fehlgeschlagen',\n      failedToAddToQueue: 'Fehler beim Hinzufügen: {{error}}',\n      fileRenamed: 'Datei umbenannt',\n      folderRenamed: 'Ordner umbenannt',\n      thumbnailsGenerated: '{{count}} Vorschaubild(er) generiert',\n      thumbnailsGeneratedPartial: '{{succeeded}} Vorschaubild(er) generiert, {{failed}} fehlgeschlagen',\n      noStlMissingThumbnails: 'Keine STL-Dateien ohne Vorschaubild',\n      failedToGenerateThumbnails: 'Fehler beim Generieren der Vorschaubilder: {{error}}',\n      thumbnailGenerated: 'Vorschaubild generiert',\n      failedToGenerateThumbnail: 'Fehler beim Generieren des Vorschaubildes: {{error}}',\n    },\n  },\n\n  // Projects\n  projects: {\n    title: 'Projekte',\n    subtitle: 'Organisieren und verfolgen Sie Ihre 3D-Druckprojekte',\n    newProject: 'Neues Projekt',\n    editProject: 'Projekt bearbeiten',\n    deleteProject: 'Projekt löschen',\n    projectName: 'Projektname',\n    description: 'Beschreibung',\n    noProjects: 'Noch keine Projekte',\n    noProjectsFiltered: 'Keine {{status}} Projekte',\n    noProjectsFilteredHelp: 'Sie haben keine {{status}} Projekte. Projekte werden hier angezeigt, wenn sich ihr Status ändert.',\n    createFirst: 'Erstellen Sie Ihr erstes Projekt, um verwandte Drucke zu organisieren, den Fortschritt zu verfolgen und Ihre Builds zu verwalten.',\n    createFirstButton: 'Erstes Projekt erstellen',\n    create: 'Erstellen',\n    files: 'Dateien',\n    prints: 'Drucke',\n    plates: 'Platten',\n    parts: 'Teile',\n    lastModified: 'Zuletzt geändert',\n    deleteConfirm: 'Möchten Sie dieses Projekt wirklich löschen? Archive und Warteschlangenelemente werden getrennt, aber nicht gelöscht.',\n    addFiles: 'Dateien hinzufügen',\n    removeFile: 'Datei entfernen',\n    viewDetails: 'Details anzeigen',\n    // Modal fields\n    namePlaceholder: 'z.B. Voron 2.4 Build',\n    descriptionPlaceholder: 'Optionale Beschreibung...',\n    color: 'Farbe',\n    targetPlates: 'Ziel-Platten',\n    targetPlatesPlaceholder: 'z.B. 25',\n    targetPlatesHelp: 'Anzahl der Druckaufträge',\n    targetParts: 'Ziel-Teile',\n    targetPartsPlaceholder: 'z.B. 150',\n    targetPartsHelp: 'Benötigte Objekte insgesamt',\n    tagsLabel: 'Tags (kommagetrennt)',\n    tagsPlaceholder: 'z.B. voron, funktional, geschenk',\n    dueDate: 'Fälligkeitsdatum',\n    priority: 'Priorität',\n    priorityLow: 'Niedrig',\n    priorityNormal: 'Normal',\n    priorityHigh: 'Hoch',\n    priorityUrgent: 'Dringend',\n    // Status\n    statusActive: 'Aktiv',\n    statusCompleted: 'Abgeschlossen',\n    statusArchived: 'Archiviert',\n    done: 'Fertig',\n    completed: 'abgeschlossen',\n    failed: 'fehlgeschlagen',\n    inQueue: 'in Warteschlange',\n    noPrintsYet: 'Noch keine Drucke',\n    // Footer stats\n    printJobs: 'Druckaufträge (Platten)',\n    partsPrinted: 'Gedruckte Teile',\n    failedParts: 'Fehlgeschlagene Teile',\n    // Actions\n    import: 'Importieren',\n    export: 'Exportieren',\n    importProject: 'Projekt importieren',\n    exportAll: 'Alle Projekte exportieren',\n    loading: 'Projekte werden geladen...',\n    // Permissions\n    noEditPermission: 'Sie haben keine Berechtigung, Projekte zu bearbeiten',\n    noDeletePermission: 'Sie haben keine Berechtigung, Projekte zu löschen',\n    noCreatePermission: 'Sie haben keine Berechtigung, Projekte zu erstellen',\n    noImportPermission: 'Sie haben keine Berechtigung, Projekte zu importieren',\n    noExportPermission: 'Sie haben keine Berechtigung, Projekte zu exportieren',\n    // Toast\n    toast: {\n      created: 'Projekt erstellt',\n      updated: 'Projekt aktualisiert',\n      deleted: 'Projekt gelöscht',\n      imported: 'Projekt importiert',\n      multipleImported: '{{count}} Projekte importiert',\n      importFailed: 'Import fehlgeschlagen',\n      exported: 'Projekte exportiert (nur Metadaten)',\n    },\n  },\n\n  // Project detail page\n  projectDetail: {\n    notFound: 'Projekt nicht gefunden',\n    backToProjects: 'Zurück zu Projekten',\n    export: 'Exportieren',\n    exportProject: 'Projekt exportieren',\n    noExportPermission: 'Sie haben keine Berechtigung, Projekte zu exportieren',\n    noEditPermission: 'Sie haben keine Berechtigung, Projekte zu bearbeiten',\n    partOf: 'Teil von:',\n    priorityLabel: 'Priorität:',\n    noPrints: 'Noch keine Drucke in diesem Projekt',\n    status: {\n      active: 'Aktiv',\n      completed: 'Abgeschlossen',\n      archived: 'Archiviert',\n    },\n    priority: {\n      low: 'Niedrig',\n      normal: 'Normal',\n      high: 'Hoch',\n      urgent: 'Dringend',\n    },\n    dueDate: {\n      overdue: 'Überfällig',\n      today: 'Heute fällig',\n      daysLeft: '{{count}} Tage übrig',\n    },\n    progress: {\n      platesProgress: 'Platten-Fortschritt',\n      partsProgress: 'Teile-Fortschritt',\n      printJobs: 'Druckaufträge',\n      parts: 'Teile',\n      percentComplete: '{{percent}}% abgeschlossen',\n      remaining: '{{count}} verbleibend',\n    },\n    stats: {\n      printJobs: 'Druckaufträge',\n      total: 'gesamt',\n      failed: '{{count}} fehlgeschlagen',\n      partsPrinted: '{{count}} Teile gedruckt',\n      printTime: 'Druckzeit',\n      filamentUsed: 'Filament verbraucht',\n    },\n    cost: {\n      title: 'Kostenverfolgung',\n      filamentCost: 'Filamentkosten',\n      energy: 'Energie',\n      totalCost: 'Gesamtkosten',\n      total: 'Gesamt',\n      includesBom: 'inkl. Stückliste',\n      budget: 'Budget',\n      remaining: 'Verbleibend',\n    },\n    subProjects: {\n      title: 'Unterprojekte ({{count}})',\n    },\n    notes: {\n      title: 'Notizen',\n      noEditPermission: 'Sie haben keine Berechtigung, Notizen zu bearbeiten',\n      placeholder: 'Notizen zu diesem Projekt hinzufügen...',\n      empty: 'Noch keine Notizen. Klicken Sie auf Bearbeiten, um Notizen hinzuzufügen.',\n    },\n    files: {\n      title: 'Dateien',\n      linkFolders: 'Ordner aus dem Dateimanager verknüpfen',\n      forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.',\n      fileCount: '{{count}} Datei(en)',\n      empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.',\n      noFiles: 'Keine Dateien in diesem Ordner.',\n      print: 'Jetzt drucken',\n      addToQueue: 'Zur Warteschlange',\n    },\n    bom: {\n      title: 'Stückliste',\n      acquired: '{{completed}}/{{total}} beschafft',\n      showAll: 'Alle anzeigen',\n      hideDone: 'Erledigte ausblenden',\n      addPart: 'Teil hinzufügen',\n      noAddPermission: 'Sie haben keine Berechtigung, Teile hinzuzufügen',\n      partNamePlaceholder: 'Teilename (z.B. M3x8 Schrauben)',\n      partName: 'Teilename',\n      qty: 'Menge',\n      price: 'Preis ({{currency}})',\n      sourcingUrlPlaceholder: 'Bezugsquelle-URL (optional)',\n      remarksPlaceholder: 'Bemerkungen (optional)',\n      deletePart: 'Teil löschen',\n      deleteConfirm: 'Möchten Sie \"{{name}}\" wirklich löschen?',\n      noUpdatePermission: 'Sie haben keine Berechtigung, Teile zu aktualisieren',\n      noEditPermission: 'Sie haben keine Berechtigung, Teile zu bearbeiten',\n      noDeletePermission: 'Sie haben keine Berechtigung, Teile zu löschen',\n      totalCost: 'Gesamtkosten:',\n      empty: 'Keine Teile in der Stückliste. Fügen Sie Hardware, Elektronik oder andere Komponenten hinzu, um zu verfolgen, was beschafft werden muss.',\n    },\n    timeline: {\n      title: 'Aktivitätsverlauf',\n      empty: 'Noch keine Aktivität.',\n    },\n    template: {\n      saveAsTemplate: 'Als Vorlage speichern',\n      noCreatePermission: 'Sie haben keine Berechtigung, Vorlagen zu erstellen',\n    },\n    queue: {\n      title: 'Warteschlange',\n      viewAll: 'Alle anzeigen',\n      printing: '{{count}} druckend',\n      queued: '{{count}} in Warteschlange',\n    },\n    prints: {\n      title: 'Drucke ({{count}})',\n    },\n    toast: {\n      projectUpdated: 'Projekt aktualisiert',\n      partAdded: 'Teil hinzugefügt',\n      partRemoved: 'Teil entfernt',\n      exportFailed: 'Export fehlgeschlagen',\n      projectExported: 'Projekt exportiert',\n      templateCreated: 'Vorlage erstellt',\n    },\n  },\n\n  // System info\n  system: {\n    title: 'Systeminformationen',\n    version: 'Version',\n    uptime: 'Laufzeit',\n    cpuUsage: 'CPU-Auslastung',\n    memoryUsage: 'Speicherauslastung',\n    diskUsage: 'Festplattenauslastung',\n    networkInfo: 'Netzwerkinformationen',\n    logs: 'Protokolle',\n    debugMode: 'Debug-Modus',\n    enableDebug: 'Debug-Protokollierung aktivieren',\n    disableDebug: 'Debug-Protokollierung deaktivieren',\n    downloadLogs: 'Protokolle herunterladen',\n    clearLogs: 'Protokolle löschen',\n    dockerInfo: 'Docker-Info',\n    containerName: 'Container-Name',\n    imageName: 'Image-Name',\n    platform: 'Plattform',\n    architecture: 'Architektur',\n  },\n\n  // Library (K Profiles)\n  library: {\n    title: 'Filament-Bibliothek',\n    addFilament: 'Filament hinzufügen',\n    editFilament: 'Filament bearbeiten',\n    deleteFilament: 'Filament löschen',\n    vendor: 'Hersteller',\n    material: 'Material',\n    color: 'Farbe',\n    kFactor: 'K-Faktor',\n    temperature: 'Temperatur',\n    noFilaments: 'Keine Filamente in der Bibliothek',\n    deleteConfirm: 'Möchten Sie dieses Filament wirklich löschen?',\n    importFromPrinter: 'Vom Drucker importieren',\n    exportToFile: 'In Datei exportieren',\n  },\n\n  // Spoolman\n  spoolman: {\n    title: 'Spoolman-Integration',\n    enabled: 'Spoolman aktiviert',\n    url: 'Spoolman URL',\n    connected: 'Verbunden',\n    disconnected: 'Nicht verbunden',\n    testConnection: 'Verbindung testen',\n    sync: 'Synchronisieren',\n    syncing: 'Synchronisiert...',\n    lastSync: 'Letzte Synchronisierung',\n    linkToSpoolman: 'Mit Spoolman verknüpfen',\n    openInSpoolman: 'In Spoolman öffnen',\n    unlinkSpool: 'Spule trennen',\n    unlinkConfirmTitle: 'Spule entkoppeln?',\n    unlinkConfirmMessage: 'Dadurch wird die Spule von Spoolman getrennt. Die Spulendaten in Spoolman bleiben unverändert.',\n    selectSpool: 'Spule auswählen',\n    noUnlinkedSpools: 'Keine nicht verknüpften Spulen verfügbar',\n    linkSuccess: 'Spule erfolgreich mit Spoolman verknüpft',\n    linkFailed: 'Verknüpfung mit Spoolman fehlgeschlagen',\n    unlinkSuccess: 'Spule erfolgreich von Spoolman getrennt',\n    unlinkFailed: 'Trennen der Spule von Spoolman fehlgeschlagen',\n    spoolId: 'Spulen-ID',\n    fillSourceLabel: '(Spoolman)',\n    weight: 'Gewicht',\n    remaining: 'Verbleibend',\n    disableWeightSync: 'AMS-Gewichtsschätzung deaktivieren',\n    disableWeightSyncDesc: 'Verbleibende Kapazität nicht aus AMS-Schätzungen aktualisieren. Verwenden Sie dies, wenn Sie die Verbrauchserfassung von Spoolman gegenüber den prozentualen AMS-Schätzungen bevorzugen. Neue Spulen verwenden weiterhin die AMS-Schätzung als Anfangsgewicht.',\n    reportPartialUsage: 'Teilverbrauch bei fehlgeschlagenen Drucken melden',\n    reportPartialUsageDesc: 'Wenn ein Druck fehlschlägt oder abgebrochen wird, den geschätzten Filamentverbrauch bis zu diesem Zeitpunkt basierend auf dem Schichtfortschritt melden.',\n  },\n\n  // Inventar\n  inventory: {\n    title: 'Spulen-Inventar',\n    addSpool: 'Spule hinzufügen',\n    editSpool: 'Spule bearbeiten',\n    material: 'Material',\n    selectMaterial: 'Material auswählen...',\n    subtype: 'Untertyp',\n    brand: 'Marke',\n    searchBrand: 'Marke suchen...',\n    useCustomBrand: '\"{{brand}}\" verwenden',\n    useCustomMaterial: 'Benutzerdefiniertes Material verwenden: {{material}}',\n    colorName: 'Farbname',\n    colorNamePlaceholder: 'Jade White, Fire Red...',\n    color: 'Farbe',\n    hexColor: 'Hex-Farbe',\n    pickColor: 'Benutzerdefinierte Farbe wählen',\n    labelWeight: 'Nenngewicht',\n    coreWeight: 'Leergewicht der Spule',\n    searchSpoolWeight: 'Spulengewicht suchen...',\n    weightUsed: 'Verbraucht',\n    currentWeight: 'Restgewicht',\n    measuredWeight: 'Gemessenes Gewicht',\n    spoolName: 'Spule',\n    costPerKg: 'Kosten pro kg',\n    measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.',\n    slicerFilament: 'Slicer-Filament',\n    slicerFilamentName: 'Slicer-Preset-Name',\n    slicerPreset: 'Slicer-Preset',\n    searchPresets: 'Filament-Presets suchen...',\n    selectedPreset: 'Ausgewählt',\n    noPresetsFound: 'Keine Presets gefunden',\n    tempOverrides: 'Temperatur-Überschreibungen',\n    note: 'Notiz',\n    notePlaceholder: 'Zusätzliche Notizen zu dieser Spule...',\n    archive: 'Archivieren',\n    restore: 'Wiederherstellen',\n    noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',\n    noManualSpools: 'Keine manuell hinzugefügten Spulen verfügbar. Fügen Sie zuerst eine Spule zum Inventar hinzu.',\n    kProfiles: 'K-Profile',\n    addKProfile: 'K-Profil hinzufügen',\n    assignSpool: 'Spule zuweisen',\n    unassignSpool: 'Zuweisung aufheben',\n    assignSuccess: 'Spule zugewiesen und AMS-Slot konfiguriert',\n    assignFailed: 'Spulenzuweisung fehlgeschlagen',\n    selectSpool: 'Wählen Sie eine Spule für diesen Slot',\n    assigned: 'Zugewiesen',\n    assigning: 'Wird zugewiesen...',\n    searchSpools: 'Spulen suchen...',\n    showAllSpools: 'Alle Spulen anzeigen',\n    allMaterials: 'Alle Materialien',\n    filterByBrand: 'Nach Marke filtern...',\n    showArchived: 'Archivierte anzeigen',\n    quickAdd: 'Schnellerfassung (Lager)',\n    quantity: 'Menge',\n    stock: 'Lager',\n    configured: 'Konfiguriert',\n    spoolsCreated: '{{count}} Spulen erstellt',\n    spoolCreated: 'Spule erstellt',\n    spoolUpdated: 'Spule aktualisiert',\n    spoolDeleted: 'Spule gelöscht',\n    spoolArchived: 'Spule archiviert',\n    spoolRestored: 'Spule wiederhergestellt',\n    deleteConfirm: 'Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.',\n    archiveConfirm: 'Möchten Sie diese Spule wirklich archivieren?',\n    advancedSettings: 'Erweiterte Einstellungen',\n    filamentInfoTab: 'Filament-Info',\n    paProfileTab: 'PA-Profil',\n    filamentInfo: 'Filament',\n    additional: 'Zusätzlich',\n    loadingPresets: 'Cloud-Presets werden geladen...',\n    cloudConnected: 'Cloud verbunden',\n    cloudNotConnected: 'Cloud nicht verbunden (Standardwerte)',\n    recentColors: 'Zuletzt',\n    searchColors: 'Farben suchen...',\n    searchResults: 'Suchergebnisse',\n    allColors: 'Alle Farben',\n    commonColors: 'Häufige Farben',\n    showLess: 'Weniger',\n    showAll: 'Alle',\n    noColorsFound: 'Keine Farben gefunden',\n    noResults: 'Keine Ergebnisse',\n    selectMaterialFirst: 'Bitte zuerst ein Material im Filament-Info Tab auswählen.',\n    noPrintersConfigured: 'Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.',\n    matchingFilter: 'Filter',\n    anyBrand: 'Jede Marke',\n    anyVariant: 'Jede Variante',\n    autoSelect: 'Auto-Auswahl',\n    matches: 'Treffer',\n    match: 'Treffer',\n    noMatches: 'Keine Treffer',\n    connected: 'Verbunden',\n    offline: 'Offline',\n    printerOffline: 'Drucker ist offline. Verbinden Sie ihn, um Kalibrierungsprofile anzuzeigen.',\n    noKProfilesMatch: 'Keine K-Profile stimmen mit dem gewählten Filament überein.',\n    leftNozzle: 'Linke Düse',\n    rightNozzle: 'Rechte Düse',\n    profilesSelected: 'Kalibrierungsprofil(e) ausgewählt',\n    // Stats & enhanced table\n    totalInventory: 'Gesamtbestand',\n    totalConsumed: 'Gesamtverbrauch',\n    byMaterial: 'Nach Material',\n    inPrinter: 'Im Drucker',\n    lowStock: 'Niedriger Bestand',\n    sinceTracking: 'Seit Beginn der Erfassung',\n    loadedInAms: 'Im AMS/Ext geladen',\n    remaining: 'Verbleibend',\n    weightCheck: 'Gewichtskontrolle',\n    lastWeighed: 'Zuletzt gewogen',\n    neverWeighed: 'Nie gewogen',\n    search: 'Spulen suchen...',\n    showing: 'Zeige',\n    to: 'bis',\n    of: 'von',\n    show: 'Zeige',\n    spools: 'Spulen',\n    spool: 'Spule',\n    page: 'Seite',\n    noSpoolsMatch: 'Keine Ergebnisse',\n    noSpoolsMatchDesc: 'Versuchen Sie, Ihre Suche oder Filter anzupassen.',\n    active: 'Aktiv',\n    archived: 'Archiviert',\n    all: 'Alle',\n    used: 'Verwendet',\n    new: 'Neu',\n    clearFilters: 'Filter löschen',\n    table: 'Tabelle',\n    cards: 'Karten',\n    net: 'Netto',\n    // Grouping\n    groupSimilar: 'Gruppieren',\n    groupedSpools: '{{count}} identische Spulen',\n    groupedRows: 'Zeilen',\n    // Column config\n    columns: 'Spalten',\n    configureColumns: 'Spalten konfigurieren',\n    configureColumnsDesc: 'Ziehen zum Neuordnen oder Pfeile verwenden. Sichtbarkeit mit dem Augensymbol umschalten.',\n    visible: 'sichtbar',\n    reset: 'Zurücksetzen',\n    cancel: 'Abbrechen',\n    applyChanges: 'Änderungen anwenden',\n    moveUp: 'Nach oben',\n    moveDown: 'Nach unten',\n    hideColumn: 'Spalte ausblenden',\n    showColumn: 'Spalte einblenden',\n    // Tag-Verknüpfung\n    linkToSpool: 'Mit Spule verknüpfen',\n    tagLinked: 'Tag mit Spule verknüpft',\n    tagLinkFailed: 'Tag-Verknüpfung fehlgeschlagen',\n    tagAlreadyLinked: 'Tag bereits mit anderer Spule verknüpft',\n    unknownTag: 'Unbekannter RFID-Tag erkannt',\n    // Verbrauchshistorie\n    usageHistory: 'Verbrauchshistorie',\n    noUsageHistory: 'Noch kein Verbrauch erfasst',\n    printName: 'Druckname',\n    weightConsumed: 'Verbrauchtes Gewicht',\n    clearHistory: 'Löschen',\n    historyCleared: 'Verbrauchshistorie gelöscht',\n    fillSourceLabel: '(Inv)',\n    lowStockThresholdError: 'Der Schwellenwert muss zwischen 0.1 und 99.9 liegen',\n    assignMismatchTitle: 'Material stimmt nicht überein',\n    assignMismatchMessage: 'Das ausgewählte Spulenmaterial \"{{spoolMaterial}}\" stimmt nicht mit dem Tray-Material \"{{trayMaterial}}\" für {{location}} überein. Trotzdem zuweisen?',\n    assignMismatchConfirm: 'Trotzdem zuweisen',\n    assignPartialMismatchMessage: 'Das Spulenmaterial \"{{spoolMaterial}}\" ist ähnlich, stimmt aber nicht genau mit \"{{trayMaterial}}\" in {{location}} überein. Möchten Sie fortfahren?',\n    assignProfileMismatchMessage: 'Das Spulenprofil \"{{spoolProfile}}\" stimmt nicht mit dem Fachprofil \"{{trayProfile}}\" in {{location}} überein. Möchten Sie fortfahren?',\n  },\n\n  // Timelapse\n  timelapse: {\n    title: 'Zeitraffer',\n    create: 'Zeitraffer erstellen',\n    download: 'Herunterladen',\n    delete: 'Löschen',\n    preview: 'Vorschau',\n    frameRate: 'Bildrate',\n    quality: 'Qualität',\n    processing: 'Wird verarbeitet...',\n    noTimelapses: 'Keine Zeitraffer verfügbar',\n  },\n\n  // AMS\n  ams: {\n    title: 'AMS',\n    slot: 'Slot',\n    empty: 'Leer',\n    emptySlot: 'Leerer Slot',\n    unknown: 'Unbekannt',\n    humidity: 'Luftfeuchtigkeit',\n    temperature: 'Temperatur',\n    filamentType: 'Filamenttyp',\n    filamentColor: 'Farbe',\n    remaining: 'Verbleibend',\n    history: 'AMS-Verlauf',\n    noHistory: 'Kein Verlauf verfügbar',\n    configureSlot: 'Slot konfigurieren',\n    externalSpool: 'Externe Spule',\n    profile: 'Profil',\n    kFactor: 'K-Faktor',\n    fill: 'Füllstand',\n    configure: 'Konfigurieren',\n    used: 'verwendet',\n    remainingUnit: 'verbleibend',\n  },\n\n  // Print modal\n  printModal: {\n    title: 'Druck starten',\n    selectPrinter: 'Drucker auswählen',\n    selectPlate: 'Platte auswählen',\n    filamentMapping: 'Filamentzuordnung',\n    totalCost: 'Gesamtkosten:',\n    slotRemainingShort: ' - {{grams}}g übrig',\n    printSettings: 'Druckeinstellungen',\n    bedLeveling: 'Bett-Nivellierung',\n    flowCalibration: 'Fluss-Kalibrierung',\n    vibrationCalibration: 'Vibrations-Kalibrierung',\n    layerInspection: 'Erste-Schicht-Prüfung',\n    timelapse: 'Zeitraffer',\n    startPrint: 'Druck starten',\n    addToQueue: 'Zur Warteschlange hinzufügen',\n    cancel: 'Abbrechen',\n    noPrintersAvailable: 'Keine Drucker verfügbar',\n    printerBusy: 'Drucker ist beschäftigt',\n    printerOffline: 'Drucker ist offline',\n    sameTypeDifferentColor: 'Gleicher Typ, andere Farbe',\n    filamentTypeNotLoaded: 'Filamenttyp nicht geladen',\n    openCalendar: 'Kalender öffnen',\n    leftNozzle: 'L',\n    rightNozzle: 'R',\n    leftNozzleTooltip: 'Linke Düse',\n    rightNozzleTooltip: 'Rechte Düse',\n    filamentOverride: 'Filament-Überschreibung',\n    filamentOverrideHint: 'Filamente für modellbasierte Zuweisung optional überschreiben. Der Planer wird gegen die ausgewählten Filamente statt der ursprünglichen 3MF-Werte abgleichen.',\n    originalFilament: 'Original',\n    overrideWith: 'Ersetzen mit',\n    resetToOriginal: 'Auf Original zurücksetzen',\n    insufficientFilamentTitle: 'Nicht genug Filament',\n    insufficientFilamentMessage: 'Einige zugewiesene Spulen haben weniger Filament als dieser Druck benötigt:',\n    insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',\n    printAnyway: 'Trotzdem drucken',\n    forceColorMatch: 'Farbe erzwingen',\n    staggerPrinterStarts: 'Stagger printer starts',\n    staggerGroupSize: 'Group size',\n    staggerInterval: 'Interval (min)',\n    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',\n    staggerLastGroup: 'last group: {{count}}',\n    staggerTotal: 'total: {{minutes}} min',\n    staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',\n    gcodeInjection: 'Auto-Print G-code einfügen',\n  },\n\n  // Backup\n  backup: {\n    title: 'Sichern & Wiederherstellen',\n    createBackup: 'Sicherung erstellen',\n    restoreBackup: 'Sicherung wiederherstellen',\n    restoreDescription: 'Alle Daten aus einer Sicherungsdatei ersetzen',\n    downloadBackup: 'Sicherung herunterladen',\n    uploadBackup: 'Sicherung hochladen',\n    lastBackup: 'Letzte Sicherung',\n    autoBackup: 'Automatische Sicherung',\n    backupNow: 'Jetzt sichern',\n    restoreWarning: 'Warnung: Das Wiederherstellen einer Sicherung überschreibt alle aktuellen Daten.',\n    includeArchives: 'Archive einschließen',\n    includeSettings: 'Einstellungen einschließen',\n    includeProfiles: 'Profile einschließen',\n    backupSuccess: 'Sicherung erfolgreich erstellt',\n    restoreSuccess: 'Sicherung erfolgreich wiederhergestellt',\n    backupFailed: 'Sicherung fehlgeschlagen',\n    restoreFailed: 'Wiederherstellung fehlgeschlagen',\n    restoreNote: 'Virtueller Drucker wird während der Wiederherstellung gestoppt',\n\n    // GitHub Backup\n    githubBackup: 'GitHub Backup',\n    enabled: 'Aktiviert',\n    cloudLoginRequired: 'Bambu Cloud Login erforderlich. Melden Sie sich unter Profile → Cloud-Profile an, um GitHub-Backup zu aktivieren.',\n    cloudLoginRequiredShort: 'Cloud-Login erforderlich',\n    githubDescription: 'Synchronisieren Sie Ihre Profile automatisch mit einem privaten GitHub-Repository für Backup und Versionsverlauf.',\n    repositoryUrl: 'Repository-URL',\n    personalAccessToken: 'Persönlicher Zugriffstoken',\n    tokenSaved: '(gespeichert)',\n    enterNewToken: 'Neuen Token eingeben zum Aktualisieren',\n    tokenHint: 'Feingranularer Token mit Lese-/Schreibberechtigung für Inhalte',\n    branch: 'Branch',\n    manualOnly: 'Nur manuell',\n    hourly: 'Stündlich',\n    daily: 'Täglich',\n    weekly: 'Wöchentlich',\n    includeInBackup: 'In Sicherung einschließen',\n    kProfiles: 'K-Profile',\n    kProfilesDescription: 'Druckvorschub-Kalibrierung von verbundenen Druckern',\n    noPrintersConnected: 'Keine Drucker verbunden',\n    printersConnected: '{{connected}}/{{total}} verbunden',\n    cloudProfiles: 'Cloud-Profile',\n    cloudProfilesDescription: 'Filament-, Drucker- und Prozessprofile aus der Bambu Cloud',\n    appSettings: 'App-Einstellungen',\n    appSettingsDescription: 'Bambuddy-Konfiguration (komplette Datenbank)',\n    spoolInventory: 'Spulenbestand',\n    spoolInventoryDescription: 'Filamentspulen, Nutzungsverlauf und Kostenverfolgung',\n    printArchives: 'Druckarchive',\n    printArchivesDescription: 'Druckverlauf-Metadaten (keine GCode/3MF-Dateien)',\n    lastBackupAt: 'Letzte Sicherung:',\n    noBackupsYet: 'Noch keine Sicherungen',\n    next: 'Nächste:',\n    startingBackup: 'Sicherung wird gestartet...',\n    test: 'Test',\n    enableBackup: 'Sicherung aktivieren',\n    testConnection: 'Verbindung testen',\n    enterRepoUrl: 'Repository-URL eingeben',\n    enterRepoAndToken: 'Repository-URL und Zugriffstoken eingeben',\n    repoRequired: 'Repository-URL ist erforderlich',\n    tokenRequired: 'Zugriffstoken ist erforderlich',\n    githubBackupEnabled: 'GitHub-Backup aktiviert',\n    tokenUpdated: 'Token aktualisiert',\n    settingsSaved: 'Einstellungen gespeichert',\n    failedToSave: 'Speichern fehlgeschlagen: {{message}}',\n    backupCompleteFiles: 'Sicherung abgeschlossen - {{count}} Dateien aktualisiert',\n    backupSkippedNoChanges: 'Sicherung übersprungen - keine Änderungen',\n    backupFailed2: 'Sicherung fehlgeschlagen: {{message}}',\n    clearedLogs: '{{count}} Protokolle gelöscht',\n    failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',\n\n    // History\n    history: 'Verlauf',\n    clear: 'Löschen',\n    date: 'Datum',\n    status: 'Status',\n    commit: 'Commit',\n\n    // Local Backup\n    localBackup: 'Lokale Sicherung',\n    localBackupDescription: 'Erstellen Sie eine vollständige Sicherung Ihrer Bambuddy-Daten einschließlich Datenbank, Archive, Uploads und aller Dateien.',\n    downloadBackupLabel: 'Sicherung herunterladen',\n    completeBackupZip: 'Vollständige Sicherung: Datenbank + alle Dateien (ZIP)',\n    download: 'Herunterladen',\n    preparingBackup: 'Sicherung wird vorbereitet...',\n    creatingArchive: 'Sicherungsarchiv wird erstellt... Dies kann bei großen Archiven eine Weile dauern.',\n    downloadingFile: 'Sicherungsdatei wird heruntergeladen...',\n    backupDownloaded: 'Sicherung erfolgreich heruntergeladen',\n    failedToCreateBackup: 'Sicherung erstellen fehlgeschlagen: {{message}}',\n    restore: 'Wiederherstellen',\n    restoreReplacesAll: 'Wiederherstellung ersetzt alle Daten.',\n    restoreReplacesAllDetail: 'Ihre aktuelle Datenbank und Dateien werden vollständig ersetzt. Nach der Wiederherstellung ist ein Neustart erforderlich.',\n    restoreConfirmTitle: 'Sicherung wiederherstellen',\n    restoreConfirmMessage: 'Sind Sie sicher, dass Sie von \"{{filename}}\" wiederherstellen möchten? Dies ersetzt Ihre aktuelle Datenbank und alle Dateien vollständig. Die Anwendung muss nach der Wiederherstellung neu gestartet werden.',\n    restoreConfirmButton: 'Sicherung wiederherstellen',\n    uploadingFile: 'Sicherungsdatei wird hochgeladen...',\n    backupRestoredRestart: 'Sicherung wiederhergestellt. Bitte starten Sie Bambuddy neu.',\n    failedToRestore: 'Sicherung wiederherstellen fehlgeschlagen. Bitte überprüfen Sie das Dateiformat.',\n    reloadNow: 'Jetzt neu laden',\n    creatingBackup: 'Sicherung erstellen',\n    restoringBackup: 'Sicherung wiederherstellen',\n    preparing: 'Vorbereiten...',\n    processing: 'Verarbeiten...',\n    doNotClosePage: 'Bitte schließen Sie diese Seite nicht und navigieren Sie nicht weg. Dieser Vorgang kann bei großen Sicherungen mehrere Minuten dauern.',\n\n    // RestoreModal\n    restoring: 'Wiederherstellen...',\n    restoreComplete: 'Wiederherstellung abgeschlossen',\n    restoreFailed2: 'Wiederherstellung fehlgeschlagen',\n    importSettings: 'Einstellungen aus einer Sicherungsdatei importieren',\n    pleaseWaitRestoring: 'Bitte warten Sie, während Ihre Daten wiederhergestellt werden',\n    selectBackupFile: 'Klicken Sie, um eine Sicherungsdatei auszuwählen (.json oder .zip)',\n    duplicateHandling: 'So funktioniert die Duplikatbehandlung:',\n    matchPrinters: 'Drucker',\n    matchPrintersBy: 'abgeglichen nach Seriennummer',\n    matchSmartPlugs: 'Smart Plugs',\n    matchSmartPlugsBy: 'abgeglichen nach IP-Adresse',\n    matchNotificationProviders: 'Benachrichtigungsanbieter',\n    matchNotificationProvidersBy: 'abgeglichen nach Name',\n    matchFilaments: 'Filamente',\n    matchFilamentsBy: 'abgeglichen nach Name + Typ + Marke',\n    matchArchives: 'Archive',\n    matchArchivesBy: 'abgeglichen nach Inhaltshash (immer übersprungen)',\n    matchPendingUploads: 'Ausstehende Uploads',\n    matchPendingUploadsBy: 'abgeglichen nach Dateiname',\n    matchSettingsTemplates: 'Einstellungen & Vorlagen',\n    matchSettingsTemplatesBy: 'immer überschrieben',\n    replaceExisting: 'Vorhandene Daten ersetzen',\n    keepExisting: 'Vorhandene Daten behalten',\n    overwriteDescription: 'Bereits vorhandene Elemente mit Sicherungsdaten überschreiben',\n    keepDescription: 'Nur Elemente wiederherstellen, die noch nicht vorhanden sind',\n    overwriteCaution: 'Achtung:',\n    overwriteWarning: 'Das Überschreiben ersetzt Ihre aktuellen Konfigurationen durch Daten aus der Sicherung. Drucker-Zugangscodes werden aus Sicherheitsgründen nie überschrieben.',\n    cancel: 'Abbrechen',\n    processingBackup: 'Sicherungsdatei wird verarbeitet...',\n    itemsRestored: 'Wiederhergestellt',\n    itemsSkipped: 'Übersprungen',\n    restored: 'Wiederhergestellt',\n    skippedAlreadyExist: 'Übersprungen (bereits vorhanden)',\n    filesCategory: 'Dateien (3MF, Thumbnails, etc.)',\n    andMore: '...und {{count}} weitere',\n    newApiKeysGenerated: 'Neue API-Schlüssel generiert',\n    keysShownOnce: 'Diese Schlüssel werden nur einmal angezeigt. Kopieren Sie sie jetzt!',\n    copy: 'Kopieren',\n    noDataFound: 'In der Sicherungsdatei wurden keine Daten zur Wiederherstellung gefunden.',\n    close: 'Schließen',\n\n    // Scheduled local backups (#884)\n    scheduledBackup: 'Scheduled Backups',\n    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',\n    frequency: 'Frequency',\n    backupTime: 'Time',\n    retention: 'Retention',\n    retentionDescription: 'Number of backups to keep',\n    outputPath: 'Output Path',\n    outputPathPlaceholder: 'Default: {{path}}',\n    outputPathDescription: 'Leave empty for default location',\n    runNow: 'Run Now',\n    backupFiles: 'Backup Files',\n    noScheduledBackups: 'No backups yet',\n    deleteBackup: 'Delete',\n    deleteBackupConfirm: 'Delete this backup file?',\n    backupRunning: 'Backup in progress...',\n    scheduledBackupComplete: 'Backup completed successfully',\n    scheduledBackupFailed: 'Backup failed',\n    nextBackup: 'Next backup',\n    backupSize: 'Size',\n    utc: 'UTC',\n    defaultPathLabel: 'Default:',\n\n    // Category labels\n    categories: {\n      settings: 'Einstellungen',\n      notification_providers: 'Benachrichtigungsanbieter',\n      notification_templates: 'Benachrichtigungsvorlagen',\n      smart_plugs: 'Smart Plugs',\n      printers: 'Drucker',\n      filaments: 'Filamente',\n      maintenance_types: 'Wartungstypen',\n      archives: 'Archive',\n      projects: 'Projekte',\n      pending_uploads: 'Ausstehende Uploads',\n      external_links: 'Externe Links',\n      api_keys: 'API-Schlüssel',\n    },\n  },\n\n  // Tags\n  tags: {\n    title: 'Tags',\n    addTag: 'Tag hinzufügen',\n    editTag: 'Tag bearbeiten',\n    deleteTag: 'Tag löschen',\n    tagName: 'Tag-Name',\n    tagColor: 'Tag-Farbe',\n    noTags: 'Keine Tags',\n    deleteConfirm: 'Möchten Sie diesen Tag wirklich löschen?',\n    manageTags: 'Tags verwalten',\n  },\n\n  // Upload modal (archives)\n  uploadModal: {\n    title: '3MF-Dateien hochladen',\n    dragDrop: '3MF-Dateien hierher ziehen',\n    or: 'oder',\n    browseFiles: 'Dateien durchsuchen',\n    extractionInfo: 'Das Druckermodell wird automatisch aus den 3MF-Datei-Metadaten extrahiert.',\n    uploaded: 'hochgeladen',\n    failed: 'fehlgeschlagen',\n    uploading: 'Wird hochgeladen...',\n    upload: 'Hochladen',\n    uploadFailed: 'Hochladen fehlgeschlagen',\n  },\n\n  // Edit archive modal\n  editArchive: {\n    title: 'Archiv bearbeiten',\n    name: 'Name',\n    namePlaceholder: 'Druckname',\n    printer: 'Drucker',\n    noPrinter: 'Kein Drucker',\n    project: 'Projekt',\n    noProject: 'Kein Projekt',\n    itemsPrinted: 'Gedruckte Teile',\n    itemsPrintedHelp: 'Anzahl der in diesem Druckauftrag produzierten Teile',\n    notes: 'Notizen',\n    notesPlaceholder: 'Notizen zu diesem Druck hinzufügen...',\n    externalLink: 'Externer Link',\n    externalLinkPlaceholder: 'https://printables.com/model/...',\n    externalLinkHelp: 'Link zu Printables, Thingiverse oder anderer Quelle',\n    tags: 'Tags',\n    tagsPlaceholder: 'Tags hinzufügen...',\n    addMoreTags: 'Weitere Tags hinzufügen...',\n    matchingTags: 'Übereinstimmend mit \"{{query}}\"',\n    existingTags: 'Vorhandene Tags',\n    clickToAdd: '(zum Hinzufügen klicken)',\n    status: 'Status',\n    failureReason: 'Fehlergrund',\n    selectReason: 'Grund auswählen...',\n    photos: 'Fotos des Druckergebnisses',\n    photosHelp: 'Klicken Sie auf + um Fotos Ihres Druckergebnisses hinzuzufügen',\n    printResult: 'Druckergebnis',\n    saving: 'Wird gespeichert...',\n    // Failure reasons\n    failureReasons: {\n      adhesionFailure: 'Haftungsfehler',\n      spaghettiDetached: 'Spaghetti / Abgelöst',\n      layerShift: 'Schichtversatz',\n      cloggedNozzle: 'Verstopfte Düse',\n      filamentRunout: 'Filament aufgebraucht',\n      warping: 'Verformung',\n      stringing: 'Fadenziehen',\n      underExtrusion: 'Unterextrusion',\n      powerFailure: 'Stromausfall',\n      userCancelled: 'Vom Benutzer abgebrochen',\n      other: 'Sonstiges',\n    },\n    // Archive statuses\n    statuses: {\n      completed: 'Abgeschlossen',\n      failed: 'Fehlgeschlagen',\n      aborted: 'Abgebrochen',\n      printing: 'Druckt',\n    },\n  },\n\n  // K-Profiles\n  kProfiles: {\n    title: 'K-Profile',\n    noPrintersConfigured: 'Keine Drucker konfiguriert',\n    addPrinterInSettings: 'Fügen Sie einen Drucker in den Einstellungen hinzu, um K-Profile zu verwalten',\n    noActivePrinters: 'Keine aktiven Drucker',\n    enablePrinterConnection: 'Aktivieren Sie eine Druckerverbindung, um K-Profile anzuzeigen',\n    loadingProfiles: 'Lade K-Profile...',\n    printerOffline: 'Drucker offline',\n    printerOfflineDesc: 'Der ausgewählte Drucker ist nicht verbunden. Schalten Sie ihn ein, um K-Profile anzuzeigen.',\n    noMatchingProfiles: 'Keine passenden Profile',\n    noMatchingProfilesDesc: 'Keine Profile entsprechen Ihren Suchkriterien',\n    noKProfiles: 'Keine K-Profile',\n    noKProfilesDesc: 'Keine Druckvorschub-Profile für {{diameter}}mm Düse gefunden',\n    createFirstProfile: 'Erstes Profil erstellen',\n    // Controls\n    printer: 'Drucker',\n    nozzle: 'Düse',\n    refresh: 'Aktualisieren',\n    addProfile: 'Profil hinzufügen',\n    export: 'Exportieren',\n    import: 'Importieren',\n    select: 'Auswählen',\n    selectAll: 'Alle auswählen',\n    delete: 'Löschen',\n    // Filters\n    searchPlaceholder: 'Nach Name oder Filament suchen...',\n    allExtruders: 'Alle Extruder',\n    leftOnly: 'Nur links',\n    rightOnly: 'Nur rechts',\n    allFlow: 'Alle Flusstypen',\n    hfOnly: 'Nur HF',\n    sOnly: 'Nur S',\n    sortName: 'Sortieren: Name',\n    sortKValue: 'Sortieren: K-Wert',\n    sortFilament: 'Sortieren: Filament',\n    // Dual extruder labels\n    leftExtruder: 'Linker Extruder',\n    rightExtruder: 'Rechter Extruder',\n    // Modal\n    modal: {\n      addTitle: 'K-Profil hinzufügen',\n      editTitle: 'K-Profil bearbeiten',\n      profileName: 'Profilname',\n      profileNamePlaceholder: 'Mein PLA-Profil',\n      kValue: 'K-Wert',\n      kValuePlaceholder: '0,020',\n      kValueHelp: 'Typischer Bereich: 0,01 - 0,06 für PLA, 0,02 - 0,10 für PETG',\n      filament: 'Filament',\n      selectFilament: 'Filament auswählen...',\n      noFilamentsHelp: 'Keine Filamente gefunden. Erstellen Sie zuerst ein K-Profil in Bambu Studio.',\n      flowType: 'Flusstyp',\n      highFlow: 'High Flow',\n      standard: 'Standard',\n      nozzleSize: 'Düsengröße',\n      extruder: 'Extruder',\n      extruders: 'Extruder',\n      left: 'Links',\n      right: 'Rechts',\n      notes: 'Notizen (lokal gespeichert)',\n      notesPlaceholder: 'Notizen zu diesem Profil hinzufügen...',\n      notesHelp: 'Notizen werden in Bambuddy gespeichert, nicht auf dem Drucker',\n      syncing: 'Synchronisiert mit Drucker...',\n      savingExtruder: 'Speichern auf Extruder {{current}}/{{total}}...',\n      pleaseWait: 'Bitte warten',\n    },\n    // Delete confirmation\n    deleteConfirm: {\n      title: 'Profil löschen',\n      cannotUndo: 'Dies kann nicht rückgängig gemacht werden',\n      message: 'Möchten Sie \"{{name}}\" wirklich vom Drucker löschen?',\n    },\n    // Bulk delete\n    bulkDelete: {\n      title: 'Profile löschen',\n      cannotUndo: 'Dies kann nicht rückgängig gemacht werden',\n      message: 'Möchten Sie wirklich {{count}} ausgewählte Profile vom Drucker löschen?',\n    },\n    // Toast\n    toast: {\n      profileSaved: 'K-Profil gespeichert',\n      profilesSaved: 'K-Profil auf {{count}} Extrudern gespeichert',\n      selectAtLeastOneExtruder: 'Bitte wählen Sie mindestens einen Extruder aus',\n      profileDeleted: 'K-Profil gelöscht',\n      profilesDeleted: '{{count}} Profile gelöscht',\n      exportedProfiles: '{{count}} Profile exportiert',\n      importedProfiles: '{{count}} von {{total}} Profilen importiert',\n      noProfilesToExport: 'Keine Profile zum Exportieren',\n      invalidFileFormat: 'Ungültiges Dateiformat',\n      failedToParseImport: 'Import-Datei konnte nicht gelesen werden',\n      failedToSaveBatch: 'K-Profile konnten nicht gespeichert werden',\n      noteSaved: 'Notiz gespeichert',\n      failedToSaveNote: 'Notiz konnte nicht gespeichert werden',\n    },\n    // Permissions\n    permission: {\n      noRead: 'Sie haben keine Berechtigung, Profile zu aktualisieren',\n      noCreate: 'Sie haben keine Berechtigung, Profile hinzuzufügen',\n      noUpdate: 'Sie haben keine Berechtigung, K-Profile zu aktualisieren',\n      noDelete: 'Sie haben keine Berechtigung, K-Profile zu löschen',\n      noExport: 'Sie haben keine Berechtigung, Profile zu exportieren',\n      noImport: 'Sie haben keine Berechtigung, Profile zu importieren',\n    },\n  },\n\n  // Virtual Printer\n  virtualPrinter: {\n    title: 'Virtueller Drucker',\n    running: 'Läuft',\n    stopped: 'Gestoppt',\n    description: {\n      default: 'Aktiviere einen virtuellen Drucker, der in Bambu Studio und OrcaSlicer erscheint. Dateien, die an diesen Drucker gesendet werden, werden direkt archiviert ohne zu drucken.',\n      proxy: 'Aktiviere einen Proxy, der Slicer-Datenverkehr an einen echten Drucker weiterleitet, um Ferndruck über jedes Netzwerk zu ermöglichen.',\n    },\n    enable: {\n      title: 'Virtuellen Drucker aktivieren',\n      visibleInSlicer: 'Sichtbar als \"Bambuddy\" in der Slicer-Erkennung',\n      proxyingTo: 'Proxy zu {{name}}',\n      notActive: 'Nicht aktiv',\n    },\n    model: {\n      title: 'Druckermodell',\n      description: 'Wähle welches Druckermodell emuliert werden soll.',\n      restartWarning: 'Das Ändern des Modells startet den virtuellen Drucker neu',\n    },\n    accessCode: {\n      title: 'Zugangscode',\n      isSet: 'Zugangscode ist gesetzt',\n      notSet: 'Kein Zugangscode gesetzt - erforderlich zum Aktivieren',\n      placeholder: '8-Zeichen-Code eingeben',\n      placeholderChange: 'Neuen Code eingeben zum Ändern',\n      hint: 'Muss genau 8 Zeichen lang sein. Wird von Slicern zur Authentifizierung verwendet.',\n      charCount: '({{count}}/8)',\n    },\n    targetPrinter: {\n      title: 'Zieldrucker',\n      configured: 'Proxy-Ziel konfiguriert',\n      notConfigured: 'Kein Zieldrucker ausgewählt - erforderlich für Proxy-Modus',\n      placeholder: 'Drucker auswählen...',\n      hint: 'Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.',\n      noPrinters: 'Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.',\n    },\n    remoteInterface: {\n      title: 'Netzwerkschnittstelle überschreiben',\n      configured: 'Schnittstellenüberschreibung aktiv',\n      optional: 'Optional - verwenden wenn die automatisch erkannte IP falsch ist (z.B. mehrere NICs, Docker, VPN)',\n      placeholder: 'Automatisch erkennen (Standard)...',\n      hint: 'Überschreibt die per SSDP beworbene und im TLS-Zertifikat verwendete IP-Adresse. Nützlich wenn Bambuddy mehrere Netzwerkschnittstellen hat.',\n    },\n    mode: {\n      title: 'Modus',\n      archive: 'Archivieren',\n      archiveDesc: 'Dateien sofort archivieren',\n      review: 'Überprüfen',\n      reviewDesc: 'Vor dem Archivieren überprüfen',\n      queue: 'Warteschlange',\n      queueDesc: 'Archivieren und zur Warteschlange hinzufügen',\n      proxy: 'Proxy',\n      proxyDesc: 'An echten Drucker weiterleiten',\n    },\n    autoDispatch: {\n      title: 'Automatisch starten',\n      description: 'Drucke automatisch starten, wenn sie zur Warteschlange hinzugefügt werden. Wenn deaktiviert, warten Drucke auf manuellen Start.',\n    },\n    setupRequired: {\n      title: 'Einrichtung erforderlich',\n      description: 'Die virtuelle Druckerfunktion erfordert zusätzliche Systemkonfiguration, bevor sie funktioniert. Dies beinhaltet Portweiterleitung, Firewall-Regeln und plattformspezifische Einstellungen.',\n      readGuide: 'Lese die Einrichtungsanleitung vor dem Aktivieren',\n    },\n    howItWorks: {\n      title: 'So funktioniert es',\n      step1: 'Im selben LAN erscheinen virtuelle Drucker automatisch in deinem Slicer (Bambu Studio / OrcaSlicer). Aus anderen Netzwerken füge sie manuell per IP-Adresse und Zugangscode hinzu.',\n      step2: 'Im Archiv-, Überprüfungs- und Warteschlangen-Modus verwende die \"Senden\"-Funktion im Slicer, um 3MF-Dateien an Bambuddy zu senden. Der Slicer zeigt \"Druck erfolgreich\" — die Datei wird gespeichert, nicht gedruckt.',\n      step3: 'Im Proxy-Modus leitet der virtuelle Drucker den gesamten Datenverkehr an einen echten Drucker weiter — Drucke starten sofort wie bei einer direkten Verbindung.',\n    },\n    status: {\n      title: 'Status-Details',\n      printerName: 'Druckername',\n      model: 'Modell',\n      serialNumber: 'Seriennummer',\n      mode: 'Modus',\n      pendingFiles: 'Ausstehende Dateien',\n      targetPrinter: 'Zieldrucker',\n      ftpPort: 'FTP-Port',\n      mqttPort: 'MQTT-Port',\n      ftpConnections: 'FTP-Verbindungen',\n      mqttConnections: 'MQTT-Verbindungen',\n    },\n    toast: {\n      updated: 'Virtuelle Druckereinstellungen aktualisiert',\n      failedToUpdate: 'Einstellungen konnten nicht aktualisiert werden',\n      accessCodeRequired: 'Bitte zuerst einen Zugangscode setzen',\n      targetPrinterRequired: 'Bitte zuerst einen Zieldrucker auswählen',\n      bindIpRequired: 'Bitte zuerst eine Bind-IP setzen',\n      accessCodeEmpty: 'Zugangscode darf nicht leer sein',\n      accessCodeLength: 'Zugangscode muss genau 8 Zeichen lang sein',\n      created: 'Virtueller Drucker erstellt',\n      failedToCreate: 'Virtueller Drucker konnte nicht erstellt werden',\n      deleted: 'Virtueller Drucker gelöscht',\n      failedToDelete: 'Virtueller Drucker konnte nicht gelöscht werden',\n    },\n    list: {\n      title: 'Virtuelle Drucker',\n      add: 'Hinzufügen',\n      addFirst: 'Virtuellen Drucker hinzufügen',\n      empty: 'Keine virtuellen Drucker konfiguriert. Fügen Sie einen hinzu, um zu beginnen.',\n    },\n    bindIp: {\n      title: 'Bind-Interface',\n      placeholder: 'Interface auswählen...',\n      hint: 'Netzwerkinterface, an das dieser virtuelle Drucker gebunden wird. Muss pro Drucker eindeutig sein.',\n    },\n    proxy: {\n      accessCodeHint: 'Im Proxy-Modus den Zugangscode des Zieldruckers im Slicer verwenden. Die Verbindung wird transparent zum echten Drucker weitergeleitet.',\n    },\n    addDialog: {\n      title: 'Virtuellen Drucker hinzufügen',\n      name: 'Name',\n      hint: 'Sie können Zugangscode, Zieldrucker und andere Einstellungen nach dem Erstellen konfigurieren.',\n      create: 'Erstellen',\n    },\n    deleteConfirm: {\n      title: 'Virtuellen Drucker löschen',\n      message: 'Möchten Sie \"{{name}}\" wirklich löschen? Dies stoppt alle Dienste für diesen Drucker.',\n    },\n  },\n\n  // Model Viewer\n  modelViewer: {\n    openInSlicer: 'Im Slicer öffnen',\n    tabs: {\n      model: '3D-Modell',\n      gcode: 'G-Code Vorschau',\n    },\n    notAvailable: 'nicht verfügbar',\n    notSliced: 'nicht geslicet',\n    plates: 'Platten',\n    allPlates: 'Alle Platten',\n    plateNumber: 'Platte {{number}}',\n    plateCount: '{{count}} Platte',\n    plateCount_other: '{{count}} Platten',\n    objectCount: '{{count}} Objekt',\n    objectCount_other: '{{count}} Objekte',\n    filamentCount: '{{count}} Filament',\n    filamentCount_other: '{{count}} Filamente',\n    eta: 'ETA {{minutes}} Min',\n    noPreview: 'Keine Vorschau für diese Datei verfügbar',\n    pagination: {\n      pageOf: 'Seite {{current}} von {{total}}',\n      prev: 'Zurück',\n      next: 'Weiter',\n    },\n    errors: {\n      failedToLoad: 'Datei konnte nicht geladen werden',\n      noMeshes: 'Keine Meshes in 3MF-Datei gefunden',\n      unsupportedFormat: 'Nicht unterstütztes Dateiformat',\n    },\n  },\n\n  // Maintenance type descriptions (built-in)\n  maintenanceDescriptions: {\n    lubricateCarbonRods: 'Schmiermittel auf Karbonstäbe für sanfte Bewegung auftragen',\n    lubricateRails: 'Schmiermittel auf Linearschienen für sanfte Bewegung auftragen',\n    cleanNozzle: 'Hotend und Düse reinigen, um Verstopfungen zu verhindern',\n    checkBelts: 'Riemenspannung für präzise Drucke überprüfen',\n    cleanBuildPlate: 'Druckplatte für bessere Haftung reinigen',\n    checkExtruder: 'Extruderzahnräder auf Verschleiß prüfen',\n    checkCooling: 'Sicherstellen, dass Lüfter ordnungsgemäß funktionieren',\n    generalInspection: 'Allgemeine Druckerinspektion',\n    cleanCarbonRods: 'Karbonstäbe reinigen, um Reibung zu reduzieren',\n    lubricateSteelRods: 'Schmiermittel auf Stahlstangen für sanfte Bewegung auftragen',\n    cleanSteelRods: 'Stahlstangen reinigen, um Reibung zu reduzieren',\n    cleanLinearRails: 'Linearschienen abwischen, um Staub und Schmutz zu entfernen',\n    checkPtfeTube: 'PTFE-Schlauch auf Verschleiß oder Beschädigung prüfen',\n    replaceHepaFilter: 'HEPA-Filter für Luftqualität ersetzen',\n    replaceCarbonFilter: 'Aktivkohlefilter ersetzen',\n    lubricateLeftNozzleRail: 'Linke Düsenschiene schmieren (H2-Serie)',\n  },\n\n  // Smart Plugs\n  smartPlugs: {\n    offline: 'Offline',\n    admin: 'Admin',\n    openPlugAdminPage: 'Plug-Admin-Seite öffnen',\n    deleteSmartPlug: 'Smart Plug löschen',\n    turnOnSmartPlug: 'Smart Plug einschalten',\n    turnOffSmartPlug: 'Smart Plug ausschalten',\n    turnOn: 'Einschalten',\n    turnOff: 'Ausschalten',\n    addSmartPlug: {\n      scanningNetwork: 'Netzwerk wird durchsucht...',\n      chooseEntity: 'Entität auswählen...',\n      connectionFailed: 'Verbindung fehlgeschlagen',\n      searchEntities: 'Entitäten suchen...',\n      searchPowerSensors: 'Leistungssensoren suchen...',\n      searchEnergySensors: 'Energiesensoren suchen...',\n      placeholders: {\n        plugName: 'Wohnzimmer Steckdose',\n        mqttStateOnValue: 'ON, true, 1',\n        mqttSameAsPower: 'Gleich wie Leistungs-Topic oder anders',\n      },\n    },\n    // SmartPlugCard\n    linkedTo: 'Verbunden mit:',\n    monitorOnly: 'Nur Überwachung',\n    alerts: 'Alarme',\n    scheduleOn: 'Ein {{time}}',\n    scheduleOff: 'Aus {{time}}',\n    on: 'Ein',\n    off: 'Aus',\n    power: 'Leistung',\n    kwhToday: 'kWh Heute',\n    settings: 'Einstellungen',\n    automationSettings: 'Automatisierungseinstellungen',\n    showInSwitchbar: 'In Schaltleiste anzeigen',\n    quickAccessSidebar: 'Schnellzugriff über Seitenleiste',\n    enabled: 'Aktiviert',\n    enableAutomation: 'Automatisierung für diesen Stecker aktivieren',\n    autoOn: 'Auto Ein',\n    autoOnDescription: 'Einschalten wenn Druck startet',\n    autoOff: 'Auto Aus',\n    autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',\n    autoOffPersistent: 'Aktiviert lassen',\n    autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',\n    turnOffDelayMode: 'Ausschaltverzögerungsmodus',\n    time: 'Zeit',\n    temp: 'Temp',\n    delayMinutes: 'Verzögerung (Minuten)',\n    tempThreshold: 'Temperaturschwelle (°C)',\n    tempThresholdDescription: 'Schaltet aus wenn die Düse unter diese Temperatur abkühlt',\n    edit: 'Bearbeiten',\n    deleteConfirm: 'Möchten Sie \"{{name}}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.',\n    turnOnConfirm: 'Möchten Sie \"{{name}}\" wirklich einschalten?',\n    turnOffConfirm: 'Möchten Sie \"{{name}}\" wirklich ausschalten? Dies unterbricht die Stromversorgung des angeschlossenen Geräts.',\n    failedToTurn: '{{name}}\" konnte nicht {{action}} werden',\n    unknown: 'Unbekannt',\n    // AddSmartPlugModal\n    addTitle: 'Smart Plug hinzufügen',\n    editTitle: 'Smart Plug bearbeiten',\n    stopScanning: 'Suche beenden',\n    discoverTasmota: 'Tasmota Geräte suchen',\n    foundDevices: '{{count}} Gerät(e) gefunden - zum Auswählen klicken:',\n    noDevicesFound: 'Keine Tasmota Geräte in Ihrem Netzwerk gefunden',\n    haNotConfigured: 'Home Assistant ist nicht konfiguriert. Einrichtung unter',\n    haSettingsPath: 'Einstellungen → Netzwerk → Home Assistant',\n    selectEntity: 'Entität auswählen *',\n    ipAddress: 'IP-Adresse *',\n    nameLabel: 'Name *',\n    username: 'Benutzername',\n    password: 'Passwort',\n    authHint: 'Leer lassen, wenn Ihr Tasmota-Gerät keine Authentifizierung benötigt',\n    linkToPrinter: 'Mit Drucker verbinden',\n    noPrinter: 'Kein Drucker (nur manuelle Steuerung)',\n    linkingDescription: 'Verknüpfung ermöglicht automatisches Ein-/Ausschalten bei Druckstart/-ende',\n    powerAlerts: 'Leistungsalarme',\n    alertAbove: 'Alarm wenn über (W)',\n    alertBelow: 'Alarm wenn unter (W)',\n    alertDescription: 'Benachrichtigung wenn der Stromverbrauch diese Schwellenwerte überschreitet. Leer lassen um diese Richtung zu deaktivieren.',\n    dailySchedule: 'Tagesplan',\n    turnOnAt: 'Einschalten um',\n    turnOffAt: 'Ausschalten um',\n    scheduleDescription: 'Den Stecker automatisch täglich zu diesen Zeiten ein-/ausschalten. Leer lassen um diese Aktion zu überspringen.',\n    showOnPrinterCard: 'Auf Druckerkarte anzeigen',\n    displayOnPrinterCard: 'Schaltfläche auf Druckerkarte anzeigen',\n    connectedResult: 'Verbunden!',\n    deviceLabel: 'Gerät: {{name}} - ',\n    stateLabel: 'Status: {{state}}',\n    test: 'Test',\n    delete: 'Löschen',\n    save: 'Speichern',\n    add: 'Hinzufügen',\n    cancel: 'Abbrechen',\n    failedToStartScan: 'Suche konnte nicht gestartet werden',\n    nameRequired: 'Name ist erforderlich',\n    entityRequired: 'Entität ist für Home Assistant Stecker erforderlich',\n    mqttTopicRequired: 'Mindestens ein MQTT-Topic muss für Leistung, Energie oder Statusüberwachung konfiguriert sein',\n    loadingEntities: 'Entitäten werden geladen...',\n    loading: 'Laden...',\n    failedToLoadEntities: 'Entitäten konnten nicht geladen werden: {{error}}',\n    noEntitiesMatching: 'Keine Entitäten gefunden die \"{{search}}\" entsprechen',\n    noEntitiesAvailable: 'Keine Entitäten verfügbar',\n    searchingEntities: 'Alle Entitäten durchsuchen ({{count}} gefunden)',\n    showingEntities: 'Zeige switch, light, input_boolean ({{count}} verfügbar)',\n    energyMonitoringOptional: 'Energieüberwachung (Optional)',\n    energyMonitoringHint: 'Sensoren suchen und auswählen, die Leistungs-/Energiedaten liefern.',\n    powerSensorW: 'Leistungssensor (W)',\n    energyTodayKwh: 'Energie Heute (kWh)',\n    totalEnergyKwh: 'Gesamtenergie (kWh)',\n    noMatchingSensors: 'Keine passenden Sensoren',\n    none: 'Keine',\n    mqttNotConfigured: 'MQTT-Broker nicht konfiguriert. Broker-Adresse einstellen unter',\n    mqttSettingsPath: 'Einstellungen → Netzwerk → MQTT-Veröffentlichung',\n    mqttNotConfiguredSuffix: '(Sie müssen die Veröffentlichung nicht aktivieren, nur die Broker-Details ausfüllen).',\n    mqttMonitorOnlyDescription: 'MQTT-Stecker empfangen Leistungs-/Energiedaten über MQTT-Abonnement. Ein-/Ausschalten ist nicht verfügbar - verwenden Sie Ihren MQTT-Broker oder Ihr Home-Automation-System.',\n    powerMonitoring: 'Leistungsüberwachung',\n    energyMonitoring: 'Energieüberwachung',\n    stateMonitoring: 'Statusüberwachung',\n    optional: 'optional',\n    topic: 'Topic',\n    jsonPath: 'JSON-Pfad',\n    multiplier: 'Multiplikator',\n    onValue: 'EIN-Wert',\n    mqttPowerHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload (z.B. \"power_l1\"). Leer lassen wenn Topic rohe numerische Werte sendet.\\nMultiplikator 0.001 für mW→W, 1000 für kW→W verwenden.',\n    mqttEnergyHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\\nMultiplikator 0.001 für Wh→kWh, 1000 für MWh→kWh verwenden.',\n    mqttStateHint: 'JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\\nEIN-Wert: der genaue String der \"EIN\" bedeutet. Leer lassen für Auto-Erkennung (ON, true, 1).',\n    // REST smart plug\n    restControl: 'Control',\n    restOnUrl: 'Turn ON URL',\n    restOffUrl: 'Turn OFF URL',\n    restOnBody: 'ON Request Body',\n    restOffBody: 'OFF Request Body',\n    restMethod: 'HTTP Method',\n    restHeaders: 'Custom Headers (JSON)',\n    restStatusUrl: 'Status URL',\n    restStatusPath: 'State JSON Path',\n    restStatusOnValue: 'ON Value',\n    restPowerUrl: 'Power URL',\n    restPowerPath: 'Power JSON Path',\n    restPowerMultiplier: 'Power Multiplikator',\n    restEnergyUrl: 'Energie URL',\n    restEnergyPath: 'Energy JSON Path',\n    restEnergyMultiplier: 'Energie Multiplikator',\n    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',\n    restHeadersHint: 'e.g. {\"Authorization\": \"Bearer your-token\"}',\n    restBodyHint: 'e.g. ON, {\"state\": \"on\"}',\n    restStatusHint: 'URL to poll for current state',\n    restPathHint: 'e.g. state or data.power.status',\n    restPowerUrlHint: 'Eigene URL für Leistungsdaten (nutzt Status URL wenn leer)',\n    restEnergyUrlHint: 'Eigene URL für Energiedaten (nutzt Status URL wenn leer)',\n    restEnergyHint: 'Jeder Wert kann eine eigene URL verwenden oder auf die Status URL zurückgreifen. Multiplikatoren für Einheitenumrechnung verwenden (z.B. 0.001 für Wh zu kWh).',\n    testConnection: 'Test Connection',\n    connectionSuccess: 'Connection successful',\n    noSwitchesInSwitchbar: 'Keine Schalter in der Schaltleiste',\n    enableSwitchbarHint: '\"In Schaltleiste anzeigen\" unter Einstellungen > Smart Plugs aktivieren',\n  },\n\n  // Notifications\n  notifications: {\n    // Provider types\n    providerTypes: {\n      callmebot: 'CallMeBot/WhatsApp',\n      ntfy: 'ntfy',\n      pushover: 'Pushover',\n      telegram: 'Telegram',\n      email: 'E-Mail',\n      discord: 'Discord',\n      webhook: 'Webhook',\n      homeassistant: 'Home Assistant',\n    },\n    // Provider descriptions\n    providerDescriptions: {\n      email: 'SMTP-E-Mail-Benachrichtigungen',\n      telegram: 'Benachrichtigungen über Telegram-Bot',\n      discord: 'An Discord-Kanal per Webhook senden',\n      ntfy: 'Kostenlose, selbst-hostbare Push-Benachrichtigungen',\n      pushover: 'Einfache, zuverlässige Push-Benachrichtigungen',\n      callmebot: 'Kostenlose WhatsApp-Benachrichtigungen über CallMeBot',\n      webhook: 'Generischer HTTP-POST an beliebige URL',\n      homeassistant: 'Dauerhafte Benachrichtigungen im Home Assistant Dashboard',\n    },\n    // NotificationProviderCard\n    lastSuccess: 'Zuletzt: {{date}}',\n    error: 'Fehler',\n    printer: 'Drucker:',\n    allPrinters: 'Alle Drucker',\n    sendTestNotification: 'Testbenachrichtigung senden',\n    eventSettings: 'Ereigniseinstellungen',\n    enabled: 'Aktiviert',\n    sendFromProvider: 'Benachrichtigungen von diesem Anbieter senden',\n    // Event categories\n    printEvents: 'Druckereignisse',\n    printerStatus: 'Druckerstatus',\n    amsAlarms: 'AMS-Alarme',\n    amsHtAlarms: 'AMS-HT-Alarme',\n    printQueue: 'Druckwarteschlange',\n    // Event tags (badges)\n    start: 'Start',\n    plateCheck: 'Plattenkontrolle',\n    complete: 'Abgeschlossen',\n    failed: 'Fehlgeschlagen',\n    stopped: 'Gestoppt',\n    progress: 'Fortschritt',\n    offline: 'Offline',\n    lowFilament: 'Filament niedrig',\n    maintenance: 'Wartung',\n    amsHumidity: 'AMS-Feuchtigkeit',\n    amsTemp: 'AMS-Temperatur',\n    amsHtHumidity: 'AMS-HT-Feuchtigkeit',\n    amsHtTemp: 'AMS-HT-Temperatur',\n    bedCooled: 'Bett abgekühlt',\n    firstLayer: 'Erste Schicht',\n    quiet: 'Ruhe',\n    digest: 'Zusammenfassung {{time}}',\n    // Event labels (expanded settings)\n    printStarted: 'Druck gestartet',\n    plateNotEmpty: 'Platte nicht leer',\n    plateNotEmptyDescription: 'Objekte vor dem Druck erkannt',\n    printCompleted: 'Druck abgeschlossen',\n    bedCooledLabel: 'Bett abgekühlt',\n    bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',\n    firstLayerCompleteLabel: 'Erste Schicht fertig',\n    firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',\n    missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',\n    missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',\n    printFailed: 'Druck fehlgeschlagen',\n    printStopped: 'Druck gestoppt',\n    progressMilestones: 'Fortschrittsmeilensteine',\n    progressMilestonesDescription: 'Benachrichtigung bei 25%, 50%, 75%',\n    printerOffline: 'Drucker offline',\n    printerError: 'Druckerfehler',\n    lowFilamentLabel: 'Filament niedrig',\n    maintenanceDue: 'Wartung fällig',\n    maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich ist',\n    amsHumidityHigh: 'AMS-Feuchtigkeit hoch',\n    amsHumidityHighDescription: 'Normale AMS-Feuchtigkeit überschreitet Schwellenwert',\n    amsTemperatureHigh: 'AMS-Temperatur hoch',\n    amsTemperatureHighDescription: 'Normale AMS-Temperatur überschreitet Schwellenwert',\n    amsHtHumidityHigh: 'AMS-HT-Feuchtigkeit hoch',\n    amsHtHumidityHighDescription: 'AMS-HT-Feuchtigkeit überschreitet Schwellenwert',\n    amsHtTemperatureHigh: 'AMS-HT-Temperatur hoch',\n    amsHtTemperatureHighDescription: 'AMS-HT-Temperatur überschreitet Schwellenwert',\n    // Queue events\n    jobAdded: 'Auftrag hinzugefügt',\n    jobAddedDescription: 'Auftrag zur Warteschlange hinzugefügt',\n    jobAssigned: 'Auftrag zugewiesen',\n    jobAssignedDescription: 'Modellbasierter Auftrag einem Drucker zugewiesen',\n    jobStarted: 'Auftrag gestartet',\n    jobStartedDescription: 'Warteschlangenauftrag hat Druck begonnen',\n    jobWaiting: 'Auftrag wartet',\n    jobWaitingDescription: 'Auftrag wartet auf Filament oder Drucker',\n    jobSkipped: 'Auftrag übersprungen',\n    jobSkippedDescription: 'Auftrag übersprungen (vorheriger fehlgeschlagen)',\n    jobFailed: 'Auftrag fehlgeschlagen',\n    jobFailedDescription: 'Auftrag konnte nicht gestartet werden',\n    queueComplete: 'Warteschlange abgeschlossen',\n    queueCompleteDescription: 'Alle Warteschlangenaufträge beendet',\n    // Quiet hours\n    quietHours: 'Ruhezeiten',\n    noNotificationsDuring: 'Keine Benachrichtigungen während dieser Zeiten',\n    editProviderToChangeQuietHours: 'Anbieter bearbeiten, um Ruhezeiten zu ändern',\n    // Daily digest\n    dailyDigest: 'Tägliche Zusammenfassung',\n    batchNotifications: 'Benachrichtigungen zu einer täglichen Zusammenfassung bündeln',\n    sendAt: 'Senden um {{time}}',\n    editProviderToChangeDigestTime: 'Anbieter bearbeiten, um Zusammenfassungszeit zu ändern',\n    // Actions\n    edit: 'Bearbeiten',\n    deleteProvider: 'Benachrichtigungsanbieter löschen',\n    deleteConfirm: 'Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.',\n    delete: 'Löschen',\n    // AddNotificationModal\n    addTitle: 'Benachrichtigungsanbieter hinzufügen',\n    editTitle: 'Benachrichtigungsanbieter bearbeiten',\n    nameLabel: 'Name *',\n    namePlaceholder: 'Meine Benachrichtigungen',\n    providerTypeLabel: 'Anbietertyp *',\n    configuration: 'Konfiguration',\n    testConfiguration: 'Konfiguration testen',\n    printerFilter: 'Druckerfilter',\n    onlyFromPrinter: 'Nur Benachrichtigungen für Ereignisse von diesem Drucker senden',\n    quietHoursDnd: 'Ruhezeiten (Nicht stören)',\n    quietStart: 'Start',\n    quietEnd: 'Ende',\n    dailyDigestLabel: 'Tägliche Zusammenfassung',\n    sendDigestAt: 'Zusammenfassung senden um',\n    digestCollected: 'Ereignisse werden gesammelt und als einzelne Zusammenfassung zu dieser Zeit gesendet',\n    notificationEvents: 'Benachrichtigungsereignisse',\n    progressPercent: '(25%, 50%, 75%)',\n    bedCooledAfterPrint: '(nach Druckabschluss)',\n    cancel: 'Abbrechen',\n    save: 'Speichern',\n    add: 'Hinzufügen',\n    nameRequired: 'Name ist erforderlich',\n    fieldRequired: '{{field}} ist erforderlich',\n    // Config field labels\n    phoneNumber: 'Telefonnummer',\n    apiKey: 'API-Schlüssel',\n    serverUrl: 'Server-URL',\n    topic: 'Thema',\n    authToken: 'Auth-Token',\n    userKey: 'Benutzerschlüssel',\n    appToken: 'App-Token',\n    priority: 'Priorität',\n    botToken: 'Bot-Token',\n    chatId: 'Chat-ID',\n    smtpServer: 'SMTP-Server',\n    smtpPort: 'SMTP-Port',\n    security: 'Sicherheit',\n    authentication: 'Authentifizierung',\n    username: 'Benutzername',\n    password: 'Passwort',\n    fromEmail: 'Absender-E-Mail',\n    toEmail: 'Empfänger-E-Mail',\n    webhookUrl: 'Webhook-URL',\n    payloadFormat: 'Payload-Format',\n    authorization: 'Autorisierung',\n    titleFieldName: 'Titel-Feldname',\n    messageFieldName: 'Nachrichten-Feldname',\n    // NotificationTemplateEditor\n    editTemplate: 'Vorlage bearbeiten: {{name}}',\n    titleLabel: 'Titel',\n    bodyLabel: 'Inhalt',\n    titlePlaceholder: 'Benachrichtigungstitel...',\n    bodyPlaceholder: 'Benachrichtigungsinhalt...',\n    availableVariables: 'Verfügbare Variablen',\n    clickToInsert: 'Klicken, um an Cursorposition im Inhalt einzufügen',\n    livePreview: 'Live-Vorschau',\n    hide: 'Ausblenden',\n    show: 'Anzeigen',\n    loadingPreview: 'Vorschau wird geladen...',\n    enterTemplateContent: 'Vorlageninhalt eingeben, um Vorschau zu sehen',\n    titlePreview: 'Titel:',\n    bodyPreview: 'Inhalt:',\n    resetToDefault: 'Auf Standard zurücksetzen',\n    titleRequired: 'Titel ist erforderlich',\n    bodyRequired: 'Inhalt ist erforderlich',\n    // NotificationLogViewer\n    notificationLog: 'Benachrichtigungsprotokoll',\n    showFailedOnly: 'Nur fehlgeschlagene',\n    last24Hours: 'Letzte 24 Stunden',\n    last7Days: 'Letzte 7 Tage',\n    last30Days: 'Letzte 30 Tage',\n    last90Days: 'Letzte 90 Tage',\n    justNow: 'Gerade eben',\n    noFailedNotifications: 'Keine fehlgeschlagenen Benachrichtigungen',\n    noNotificationsLogged: 'Keine Benachrichtigungen protokolliert',\n    unknownProvider: 'Unbekannter Anbieter',\n    logTitle: 'Titel',\n    logMessage: 'Nachricht',\n    logError: 'Fehler',\n    logProvider: 'Anbieter: {{type}}',\n    logTime: 'Zeit: {{time}}',\n    refresh: 'Aktualisieren',\n    clearOld: 'Alte löschen',\n    statsSummary: 'Letzte {{days}} Tage:',\n    statsNotifications: 'Benachrichtigungen',\n    statsSent: '{{count}} gesendet',\n    statsFailed: '{{count}} fehlgeschlagen',\n    // Event type labels (for log viewer)\n    eventTypes: {\n      print_start: 'Druck gestartet',\n      print_complete: 'Druck abgeschlossen',\n      print_failed: 'Druck fehlgeschlagen',\n      print_stopped: 'Druck gestoppt',\n      print_progress: 'Fortschritt',\n      printer_offline: 'Drucker offline',\n      printer_error: 'Druckerfehler',\n      filament_low: 'Filament niedrig',\n      maintenance_due: 'Wartung fällig',\n      test: 'Test',\n    },\n    // User email notification preferences\n    userEmail: {\n      title: 'Benachrichtigungen',\n      emailNotifications: 'E-Mail-Benachrichtigungen',\n      emailNotificationsDesc: 'Erhalten Sie E-Mail-Benachrichtigungen für Ihre eigenen Druckaufträge. E-Mails werden über die in der erweiterten Authentifizierung konfigurierten SMTP-Einstellungen gesendet.',\n      sendingTo: 'Benachrichtigungen werden gesendet an',\n      noEmailWarning: 'Ihr Konto hat keine E-Mail-Adresse. Wenden Sie sich an einen Administrator, um eine hinzuzufügen.',\n      printJobNotifications: 'Druckauftrags-Benachrichtigungen',\n      printJobNotificationsDesc: 'Wählen Sie aus, welche Ereignisse E-Mail-Benachrichtigungen für von Ihnen gesendete Druckaufträge auslösen.',\n      printJobStarts: 'Druckauftrag startet',\n      printJobStartsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag beginnt.',\n      printJobFinishes: 'Druckauftrag fertig',\n      printJobFinishesDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag erfolgreich abgeschlossen wurde.',\n      printErrors: 'Druckfehler',\n      printErrorsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag fehlschlägt oder auf einen Fehler stößt.',\n      printJobStops: 'Druckauftrag gestoppt',\n      printJobStopsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag abgebrochen oder gestoppt wird.',\n      saveSuccess: 'Benachrichtigungseinstellungen gespeichert.',\n      saveError: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',\n    },\n  },\n\n  // Rich Text Editor\n  richTextEditor: {\n    bold: 'Fett',\n    italic: 'Kursiv',\n    underline: 'Unterstrichen',\n    bulletList: 'Aufzählungsliste',\n    numberedList: 'Nummerierte Liste',\n    alignLeft: 'Linksbündig',\n    alignCenter: 'Zentriert',\n    alignRight: 'Rechtsbündig',\n    addLink: 'Link hinzufügen',\n    removeLink: 'Link entfernen',\n  },\n\n  // External Links\n  externalLinks: {\n    noLinksConfigured: 'Keine externen Links konfiguriert',\n    deleteLink: 'Link löschen',\n    removeCustomIcon: 'Benutzerdefiniertes Symbol entfernen',\n    openInNewTab: 'In neuem Tab öffnen',\n    placeholders: {\n      linkName: 'Mein Link',\n    },\n  },\n\n  // Keyboard Shortcuts Modal\n  keyboardShortcuts: {\n    title: 'Tastaturkürzel',\n    navigation: 'Navigation',\n    archivesSection: 'Archive',\n    kProfilesSection: 'K-Profile',\n    generalSection: 'Allgemein',\n    shortcuts: {\n      goToPrinters: 'Zu Drucker gehen',\n      goToArchives: 'Zu Archiv gehen',\n      goToQueue: 'Zur Warteschlange gehen',\n      goToStats: 'Zu Statistiken gehen',\n      goToProfiles: 'Zu Cloud-Profilen gehen',\n      goToSettings: 'Zu Einstellungen gehen',\n      focusSearch: 'Suche fokussieren',\n      openUploadModal: 'Upload-Modal öffnen',\n      clearSelection: 'Auswahl löschen / Eingabe aufheben',\n      contextMenu: 'Kontextmenü auf Karten',\n      refreshProfiles: 'Profile aktualisieren',\n      newProfile: 'Neues Profil',\n      exitSelectionMode: 'Auswahlmodus beenden',\n      showHelp: 'Diese Hilfe anzeigen',\n    },\n    footer: 'Drücken Sie Esc oder klicken Sie außerhalb, um zu schließen',\n  },\n\n  // Notification Log\n  notificationLog: {\n    title: 'Benachrichtigungsprotokoll',\n    events: {\n      printStarted: 'Druck gestartet',\n      printComplete: 'Druck abgeschlossen',\n      printFailed: 'Druck fehlgeschlagen',\n      printStopped: 'Druck gestoppt',\n      progress: 'Fortschritt',\n      printerOffline: 'Drucker offline',\n      printerError: 'Druckerfehler',\n      lowFilament: 'Wenig Filament',\n      maintenanceDue: 'Wartung fällig',\n      test: 'Test',\n    },\n    timeAgo: {\n      justNow: 'Gerade eben',\n      minutesAgo: 'vor {{minutes}}m',\n      hoursAgo: 'vor {{hours}}h',\n    },\n  },\n\n  // Restore/Backup Modal\n  restoreBackup: {\n    title: 'Backup wiederherstellen',\n    restoring: 'Wird wiederhergestellt...',\n    restoreComplete: 'Wiederherstellung abgeschlossen',\n    restoreFailed: 'Wiederherstellung fehlgeschlagen',\n    importSettings: 'Einstellungen aus Backup-Datei importieren',\n    pleaseWait: 'Bitte warten Sie, während Ihre Daten wiederhergestellt werden',\n    clickToSelect: 'Klicken Sie, um Backup-Datei auszuwählen (.json oder .zip)',\n    howDuplicateHandling: 'So funktioniert die Duplikatbehandlung:',\n    categories: {\n      printers: 'Drucker',\n      smartPlugs: 'Smart Plugs',\n      notificationProviders: 'Benachrichtigungsanbieter',\n      filaments: 'Filamente',\n      archives: 'Archive',\n      pendingUploads: 'Ausstehende Uploads',\n      settingsTemplates: 'Einstellungen & Vorlagen',\n    },\n    matchingInfo: {\n      printers: 'abgeglichen nach Seriennummer',\n      smartPlugs: 'abgeglichen nach IP-Adresse',\n      notificationProviders: 'abgeglichen nach Name',\n      filaments: 'abgeglichen nach Name + Typ + Marke',\n      archives: 'abgeglichen nach Inhalts-Hash',\n      pendingUploads: 'abgeglichen nach Dateiname',\n      settingsTemplates: 'immer überschrieben',\n    },\n    replaceExisting: 'Vorhandene Daten ersetzen',\n    keepExisting: 'Vorhandene Daten behalten',\n    replaceDescription: 'Bereits vorhandene Elemente mit Backup-Daten überschreiben',\n    keepDescription: 'Nur Elemente wiederherstellen, die noch nicht existieren',\n    caution: 'Vorsicht:',\n    cautionText: 'Das Überschreiben ersetzt Ihre aktuellen Konfigurationen durch Backup-Daten. Drucker-Zugangscodes werden aus Sicherheitsgründen niemals überschrieben.',\n    itemsRestored: 'Wiederhergestellte Elemente',\n    itemsSkipped: 'Übersprungene Elemente',\n    restored: 'Wiederhergestellt',\n    skipped: 'Übersprungen (existieren bereits)',\n    filesLabel: 'Dateien (3MF, Thumbnails, etc.)',\n    newApiKeysGenerated: 'Neue API-Schlüssel generiert',\n    newApiKeysWarning: 'Diese Schlüssel werden nur einmal angezeigt. Kopieren Sie sie jetzt!',\n    processingBackup: 'Backup-Datei wird verarbeitet...',\n    noDataFound: 'In der Backup-Datei wurden keine wiederherzustellenden Daten gefunden.',\n    failedToRestore: 'Backup konnte nicht wiederhergestellt werden. Bitte überprüfen Sie das Dateiformat.',\n  },\n\n  // Backup Export Modal\n  backupExport: {\n    title: 'Backup exportieren',\n    selectData: 'Zu exportierende Daten auswählen',\n    selectAll: 'Alle auswählen',\n    selectNone: 'Keine auswählen',\n    categoryDescriptions: {\n      settings: 'Sprache, Theme, Update-Einstellungen',\n      notifications: 'ntfy, Pushover, Discord, usw.',\n      templates: 'Benutzerdefinierte Nachrichtenvorlagen',\n      smartPlugs: 'Tasmota-Plug-Konfigurationen',\n      externalLinks: 'Seitenleiste Links zu externen Diensten',\n      printers: 'Druckerinformationen (Zugangscodes ausgeschlossen)',\n      plateDetection: 'Leere Platten-Referenzbilder',\n      filaments: 'Filamenttypen und -kosten',\n      maintenance: 'Benutzerdefinierte Wartungspläne',\n      archives: 'Alle Druckdaten + Dateien (3MF, Thumbnails, Fotos)',\n      projects: 'Projekte, BOM-Elemente und Anhänge',\n      pendingUploads: 'Virtueller Drucker-Uploads zur Überprüfung',\n      apiKeys: 'Webhook-API-Schlüssel (neue Schlüssel bei Import generiert)',\n    },\n    requiresPrinters: 'Drucker müssen ausgewählt sein',\n    zipFileWarning: 'ZIP-Datei wird erstellt.',\n    zipFileDescription: 'Enthält alle 3MF-Dateien, Thumbnails, Zeitraffer und Fotos. Dies kann eine Weile dauern und zu einer großen Datei führen.',\n    includeAccessCodes: 'Zugangscodes einschließen',\n    includeAccessCodesDescription: 'Für die Übertragung auf eine andere Maschine',\n    includeAccessCodesWarning: 'Zugangscodes werden im Klartext eingeschlossen. Bewahren Sie diese Backup-Datei sicher auf!',\n    categoriesSelected: '{{selectedCount}} Kategorien ausgewählt',\n  },\n\n  // Pending Uploads Panel\n  pendingUploads: {\n    placeholders: {\n      notes: 'Notizen zu diesem Druck hinzufügen...',\n    },\n    discardUpload: 'Upload verwerfen',\n    archiveAllUploads: 'Alle Uploads archivieren',\n    discardAllUploads: 'Alle Uploads verwerfen',\n    archive: 'Archivieren',\n    timeAgo: {\n      justNow: 'Gerade eben',\n      minutesAgo: 'vor {{minutes}}m',\n      hoursAgo: 'vor {{hours}}h',\n      daysAgo: 'vor {{days}}d',\n    },\n  },\n\n  // API Browser\n  apiBrowser: {\n    placeholders: {\n      requestBody: 'JSON-Anforderungstext...',\n      searchEndpoints: 'Endpunkte suchen...',\n    },\n  },\n\n  // Configure AMS Slot Modal\n  configureAmsSlot: {\n    title: 'AMS-Slot konfigurieren',\n    slotConfigured: 'Slot konfiguriert!',\n    configuringSlot: 'Slot wird konfiguriert:',\n    slotLabel: '{{ams}} Slot {{slot}}',\n    searchPresets: 'Voreinstellungen suchen...',\n    colorPlaceholder: 'Farbname oder Hex (z.B. braun, FF8800)',\n    clearCustomColor: 'Benutzerdefinierte Farbe löschen',\n    noCloudPresets: 'Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.',\n    noPresetsAvailable: 'Keine Voreinstellungen verfügbar. Melden Sie sich bei Bambu Cloud an oder importieren Sie lokale Profile.',\n    noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.',\n    custom: 'Benutzerdefiniert',\n    builtin: 'Integriert',\n    settingsSentToPrinter: 'Einstellungen an Drucker gesendet',\n    filamentProfile: 'Filamentprofil',\n    kProfileLabel: 'K-Profil (Pressure Advance)',\n    filteringFor: 'Filtern nach: {{material}}',\n    noKProfile: 'Kein K-Profil (Standard 0.020 verwenden)',\n    noMatchingKProfiles: 'Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.',\n    selectFilamentFirst: 'Zuerst ein Filamentprofil auswählen',\n    kFromCalibration: 'K={{value}} aus Druckerkalibrierung',\n    customColorLabel: 'Benutzerdefinierte Farbe (optional)',\n    presetColors: '{{name}} Farben:',\n    showLessColors: 'Weniger Farben anzeigen',\n    showMoreColors: 'Mehr Farben anzeigen',\n    clear: 'Löschen',\n    hexLabel: 'Hex: #{{hex}}',\n    resetting: 'Wird zurückgesetzt...',\n    resetSlot: 'Slot zurücksetzen',\n    cancel: 'Abbrechen',\n    configuring: 'Wird konfiguriert...',\n    configureSlot: 'Slot konfigurieren',\n  },\n\n  // GitHub Backup Settings\n  githubBackup: {\n    title: 'GitHub-Backup',\n    history: 'Verlauf',\n    downloadBackup: 'Backup herunterladen',\n    restoreBackup: 'Backup wiederherstellen',\n    noBackupsYet: 'Noch keine Backups',\n  },\n\n  // Email Settings\n  emailSettings: {\n    placeholders: {\n      fromName: 'BamBuddy',\n    },\n  },\n\n  // Tag Management Modal\n  tagManagement: {\n    searchTags: 'Tags suchen...',\n    renameTag: 'Tag umbenennen',\n    deleteTag: 'Tag löschen',\n  },\n\n  // Notification Template Editor\n  notificationTemplates: {\n    placeholders: {\n      title: 'Benachrichtigungstitel...',\n      body: 'Benachrichtigungstext...',\n    },\n  },\n\n  // Batch Tag Modal\n  batchTag: {\n    placeholders: {\n      newTag: 'Neuen Tag eingeben...',\n    },\n  },\n\n  // Photo Gallery Modal\n  photoGallery: {\n    deletePhoto: 'Foto löschen',\n  },\n\n  // Filament Hover Card\n  filamentHoverCard: {\n    copySpoolUuid: 'Spulen-UUID kopieren',\n  },\n\n  // K Profiles View\n  kProfilesView: {\n    hasNote: 'Hat Notiz',\n    copyProfile: 'Profil kopieren',\n  },\n\n  // Layout/Navigation\n  layout: {\n    openMenu: 'Menü öffnen',\n    noPermissionSystemInfo: 'Sie haben keine Berechtigung zum Anzeigen von Systeminformationen',\n  },\n\n  // Dashboard\n  dashboard: {\n    dragToReorder: 'Ziehen zum Neuordnen',\n    hideWidget: 'Widget ausblenden',\n  },\n\n  // Notification Provider Card\n  notificationProviderCard: {\n    deleteNotificationProvider: 'Benachrichtigungsanbieter löschen',\n  },\n\n  // File Manager Modal\n  fileManagerModal: {\n    closeFileManager: 'Dateimanager schließen',\n    sortFiles: 'Dateien sortieren',\n    goToParentFolder: 'Zum übergeordneten Ordner gehen',\n    threeView: '3D-Ansicht',\n  },\n\n  // Embedded Camera Viewer\n  embeddedCameraViewer: {\n    refreshStream: 'Stream aktualisieren',\n    close: 'Schließen',\n    zoomOut: 'Verkleinern',\n    resetZoom: 'Zoom zurücksetzen',\n    zoomIn: 'Vergrößern',\n    dragToResize: 'Ziehen zum Größe ändern',\n  },\n\n  // Timelapse Viewer\n  timelapseViewer: {\n    skipBack5s: '5s zurückspringen',\n    skipForward5s: '5s vorspringen',\n  },\n\n  // Notification Providers\n  notificationProviders: {\n    descriptions: {\n      email: 'SMTP-E-Mail-Benachrichtigungen',\n      telegram: 'Benachrichtigungen über Telegram-Bot',\n      discord: 'An Discord-Kanal über Webhook senden',\n      ntfy: 'Kostenlose, selbst hostbare Push-Benachrichtigungen',\n      pushover: 'Einfache, zuverlässige Push-Benachrichtigungen',\n      callmebot: 'Kostenlose WhatsApp-Benachrichtigungen über CallMeBot',\n      webhook: 'Generischer HTTP POST zu beliebiger URL',\n    },\n  },\n\n  // Log Viewer\n  logViewer: {\n    searchPlaceholder: 'Nachricht oder Logger-Name suchen...',\n    noLogEntries: 'Keine Logeinträge gefunden',\n  },\n\n  // Switchbar Popover\n  switchbarPopover: {\n    noSwitchesInSwitchbar: 'Keine Schalter in Schalterleiste',\n  },\n\n  // Project Page Modal\n  projectPageModal: {\n    placeholders: {\n      title: 'Titel',\n      designer: 'Designer',\n      license: 'Lizenz',\n      description: 'Beschreibung eingeben...',\n      profileTitle: 'Profil-Titel',\n      profileDescription: 'Profilbeschreibung...',\n    },\n  },\n\n  // Spoolman Settings\n  spoolmanSettings: {},\n\n  // Time\n  time: {\n    unknown: '-',\n    waiting: 'Wartend',\n    justNow: 'Gerade eben',\n    now: 'Jetzt',\n    minsAgo: 'vor {{count}}m',\n    inMins: 'in {{count}}m',\n    hoursAgo: 'vor {{count}}h',\n    inHours: 'in {{count}}h',\n    daysAgo: 'vor {{count}}d',\n    inDays: 'in {{count}}d',\n  },\n\n  // SpoolBuddy Kiosk\n  spoolbuddy: {\n    nav: {\n      dashboard: 'Dashboard',\n      ams: 'AMS',\n      inventory: 'Inventar',\n      writeTag: 'Schreiben',\n      settings: 'Einstellungen',\n    },\n    status: {\n      nfcReady: 'NFC bereit',\n      nfcOff: 'NFC aus',\n      offline: 'Offline',\n      online: 'Online',\n      noPrinters: 'Keine Drucker',\n      deviceOffline: 'Gerät offline',\n      waitingConnection: 'Warte auf Geräteverbindung...',\n      systemReady: 'System bereit',\n      status: 'Status',\n    },\n    dashboard: {\n      readyToScan: 'Bereit zum Scannen',\n      idleMessage: 'Spule auf die Waage legen zum Identifizieren',\n      nfcHint: 'NFC-Tag wird automatisch gelesen',\n      device: 'Gerät',\n      syncWeight: 'Gewicht sync.',\n      weightSynced: 'Synchronisiert!',\n      unknownTag: 'Unbekannter Tag',\n      newTag: 'Neuer Tag erkannt',\n      onScale: 'auf der Waage',\n      linkSpool: 'Mit Spule verknüpfen',\n      linkTagTitle: 'Tag mit Spule verknüpfen',\n      linkTag: 'Tag verknüpfen',\n      selectSpool: 'Spule zum Verknüpfen auswählen:',\n      noUntagged: 'Keine Spulen ohne Tags gefunden',\n      tagDetected: 'Tag erkannt',\n      noTag: 'Kein Tag',\n      tagId: 'Tag',\n      grossWeight: 'Bruttogewicht',\n      spoolSize: 'Spulengröße',\n      close: 'Schließen',\n      currentSpool: 'Aktuelle Spule',\n    },\n    modal: {\n      spoolDetected: 'Spule erkannt',\n      assignToAms: 'AMS zuweisen',\n      syncWeight: 'Gewicht sync.',\n      weightSynced: 'Synchronisiert!',\n      syncing: 'Synchronisiere...',\n      newTagDetected: 'Neuer Tag erkannt',\n      addToInventory: 'Zum Inventar hinzufügen',\n      assignToAmsTitle: 'AMS zuweisen',\n      selectSlot: 'Slot auswählen',\n      assign: 'Zuweisen',\n      assigning: 'Zuweisen...',\n      assignSuccess: 'Zugewiesen!',\n      assignError: 'Fehler beim Zuweisen. Bitte erneut versuchen.',\n      noPrinterSelected: 'Drucker auswählen...',\n      noAmsDetected: 'Kein AMS an diesem Drucker erkannt',\n      slot: 'Slot',\n    },\n    weight: {\n      noReading: 'Kein Messwert',\n      stable: 'Stabil',\n      measuring: 'Messen...',\n      tare: 'Tarieren',\n      calibrate: 'Kalibrieren',\n    },\n    spool: {\n      remaining: 'Verbleibend',\n      material: 'Material',\n      brand: 'Marke',\n      color: 'Farbe',\n      coreWeight: 'Kern',\n      labelWeight: 'Etikett',\n      scaleWeight: 'Waage',\n      netWeight: 'Netto',\n      lastUsed: 'Zuletzt verwendet',\n    },\n    ams: {\n      noData: 'Kein AMS erkannt',\n      connectAms: 'AMS anschließen um Filament-Slots zu sehen',\n      noPrinter: 'Kein Drucker ausgewählt',\n      selectPrinter: 'Drucker in der oberen Leiste auswählen',\n      printerDisconnected: 'Drucker getrennt',\n      humidity: 'Feuchtigkeit',\n      level: 'Stufe',\n      active: 'Aktiv',\n      slot: 'Slot',\n      empty: 'Leer',\n    },\n    inventory: {\n      search: 'Spulen suchen...',\n      empty: 'Keine Spulen im Inventar',\n      noResults: 'Keine passenden Spulen',\n      spools: 'Spulen',\n      addSpool: 'Spule hinzufügen',\n    },\n    settings: {\n      // Tabs\n      tabDevice: 'Gerät',\n      tabDisplay: 'Anzeige',\n      tabScale: 'Waage',\n      tabUpdates: 'Updates',\n      // Device tab\n      nfcReader: 'NFC-Leser',\n      type: 'Typ',\n      connection: 'Verbindung',\n      notConnected: 'N/A',\n      deviceInfo: 'Geräteinfo',\n      hostname: 'Host',\n      uptime: 'Betriebszeit',\n      systemConfig: 'Backend & Auth',\n      backendUrl: 'Bambuddy Backend URL',\n      apiToken: 'API-Token',\n      apiTokenPlaceholder: 'API-Token eingeben',\n      saveConfig: 'Konfiguration speichern',\n      systemQueued: 'Konfiguration in Warteschlange.',\n      nfcDiagnostic: 'NFC-Diagnose',\n      scaleDiagnostic: 'Waagen-Diagnose',\n      readTagDiagnostic: 'Tag-Lese-Diagnose',\n      testNfc: 'Leser testen',\n      testScale: 'Genauigkeit testen',\n      testReadTag: 'Tag lesen',\n      systemFieldsRequired: 'Backend-URL ist erforderlich.',\n      // Display tab\n      brightness: 'Helligkeit',\n      saved: 'Gespeichert',\n      noBacklight: 'Keine DSI-Hintergrundbeleuchtung erkannt. Helligkeitssteuerung erfordert ein DSI-Display.',\n      screenBlank: 'Bildschirm-Abschaltzeit',\n      screenBlankDesc: 'Bildschirm schaltet sich nach Inaktivität ab. Zum Aufwecken berühren.',\n      displayNote: 'Helligkeit wird als Software-Filter angewendet.',\n      // Scale tab\n      scaleCalibration: 'Waagen-Kalibrierung',\n      currentWeight: 'Aktuelles Gewicht',\n      tareOffset: 'Tara',\n      calFactor: 'Faktor',\n      knownWeight: 'Bekanntes Gewicht',\n      calStep1: 'Alle Gegenstände von der Waage entfernen und Nullpunkt setzen.',\n      calStep2: 'Bekanntes Gewicht auf die Waage legen.',\n      setZero: 'Nullpunkt setzen',\n      calibrateNow: 'Kalibrieren',\n      calibrated: 'Kalibriert',\n      tareSet: 'Tara-Befehl gesendet. Warte auf Gerät...',\n      tareFailed: 'Tara-Befehl fehlgeschlagen',\n      zeroSet: 'Nullpunkt gesetzt. Bekanntes Gewicht auf die Waage legen.',\n      calibrationDone: 'Kalibrierung abgeschlossen!',\n      calibrationFailed: 'Kalibrierung fehlgeschlagen',\n      lastCalibrated: 'Zuletzt kalibriert',\n      stable: 'Stabil',\n      settling: 'Stabilisierung...',\n      firmware: 'Firmware',\n      scale: 'Waage',\n      noDevice: 'Kein SpoolBuddy-Gerät gefunden',\n      // Updates tab\n      daemonVersion: 'Daemon-Version',\n      currentVersion: 'Aktuell',\n      versionPending: 'Warte auf Daemon...',\n      checking: 'Prüfe...',\n      checkUpdates: 'Nach Updates suchen',\n      updateAvailable: 'Update verfügbar',\n      updateInstructions: 'Update per SSH: SpoolBuddy-Installationsskript ausführen.',\n      upToDate: 'Aktuell',\n      includeBeta: 'Beta-Versionen einschließen',\n    },\n    writeTag: {\n      tabExisting: 'Vorhandene Spule',\n      tabNew: 'Neue Spule',\n      tabReplace: 'Tag ersetzen',\n      searchPlaceholder: 'Suche nach Material, Farbe, Marke...',\n      noUntaggedSpools: 'Keine Spulen ohne Tags',\n      noTaggedSpools: 'Keine Spulen mit Tags',\n      selectSpool: 'Spule auswählen, dann einen NTAG auf den Leser legen',\n      placeTag: 'NTAG auf den Leser legen',\n      tagReady: 'Tag erkannt — bereit zum Schreiben',\n      writeTag: 'Tag beschreiben',\n      replaceTag: 'Tag ersetzen',\n      writing: 'Tag wird beschrieben...',\n      waiting: 'Warte auf SpoolBuddy...',\n      writeSuccess: 'Tag erfolgreich beschrieben!',\n      writeFailed: 'Schreiben fehlgeschlagen',\n      queueFailed: 'Schreibbefehl konnte nicht eingereiht werden',\n      tryAgain: 'Erneut versuchen',\n      cancel: 'Abbrechen',\n      replaceWarning: 'Alter Tag wird getrennt. Neuer Tag ersetzt ihn.',\n      deviceOffline: 'SpoolBuddy ist offline',\n      material: 'Material',\n      colorName: 'Farbname',\n      color: 'Farbe',\n      brand: 'Marke',\n      weight: 'Gewicht (g)',\n      createSpool: 'Spule erstellen',\n      creating: 'Wird erstellt...',\n      spoolCreated: 'Spule erstellt! Bereit zum Schreiben.',\n      createFailed: 'Spule konnte nicht erstellt werden',\n    },\n    quickMenu: {\n      printerPower: 'Drucker-Strom',\n      systemControls: 'System',\n      restartDaemon: 'Daemon neustarten',\n      restartBrowser: 'Browser neustarten',\n      reboot: 'Neustart',\n      shutdown: 'Herunterfahren',\n      swipeToClose: 'Nach unten wischen zum Schließen',\n      confirmTitle: 'Bestätigen',\n      confirmShutdown: 'Möchten Sie das SpoolBuddy wirklich herunterfahren? Sie benötigen physischen Zugang, um es wieder einzuschalten.',\n      confirmReboot: 'Möchten Sie das SpoolBuddy wirklich neu starten?',\n      confirmRestartDaemon: 'SpoolBuddy-Daemon neustarten? NFC und Waage sind vorübergehend nicht verfügbar.',\n      confirmRestartBrowser: 'Kiosk-Browser neustarten? Das Display wird kurz schwarz.',\n      confirm: 'Bestätigen',\n      confirmPlugOn: '{{name}} einschalten?',\n      confirmPlugOff: '{{name}} ausschalten?',\n      turnOn: 'Einschalten',\n      turnOff: 'Ausschalten',\n    },\n  },\n\n  bugReport: {\n    title: 'Fehler melden',\n    description: 'Beschreibung',\n    descriptionPlaceholder: 'Was ist schiefgelaufen? Bitte beschreiben Sie das Problem...',\n    email: 'E-Mail (optional)',\n    emailPlaceholder: 'ihre@email.de',\n    emailPrivacy: 'Falls angegeben, wird Ihre E-Mail in einem eingeklappten Abschnitt des GitHub-Issues aufgeführt, damit der Betreuer sich melden kann.',\n    screenshot: 'Screenshot',\n    uploadOrPaste: 'Bild hochladen, einfügen oder ziehen',\n    dataCollectedSummary: 'Welche Daten werden im Bericht gesendet?',\n    dataIncluded: 'Enthalten:',\n    dataIncludedList: 'App-Version, Betriebssystem, Architektur, Python-Version, Datenbankstatistiken (nur Anzahl), Druckermodelle, Düsenanzahl, Firmware-Versionen, Verbindungsstatus, Integrationsstatus (Spoolman, MQTT, HA), nicht-sensible Einstellungen, Netzwerkschnittstellenanzahl, Docker-Details, Abhängigkeitsversionen.',\n    dataNeverIncluded: 'Nie enthalten:',\n    dataNeverIncludedList: 'Druckernamen, Seriennummern, Zugangscodes, Passwörter, IP-Adressen, E-Mail-Adressen, API-Schlüssel, Tokens, Webhook-URLs, Hostnamen oder Benutzernamen.',\n    submit: 'Absenden',\n    startLogging: 'Debug-Protokollierung starten',\n    stepEnableLogging: 'Debug-Protokollierung aktiviert',\n    stepReproduce: 'Problem jetzt reproduzieren',\n    stepStopLogging: 'Stoppen & Bericht senden',\n    stopAndSubmit: 'Stoppen & Senden',\n    maxDuration: 'Stoppt automatisch nach {{minutes}} Min.',\n    stoppingLogs: 'Protokolle sammeln & senden...',\n    submitting: 'Fehlerbericht wird gesendet...',\n    submitSuccess: 'Fehlerbericht erfolgreich gesendet!',\n    submitFailed: 'Fehlerbericht konnte nicht gesendet werden',\n    thankYou: 'Vielen Dank!',\n    submitted: 'Ihr Fehlerbericht wurde eingereicht.',\n    viewIssue: 'Issue ansehen',\n    unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten',\n  },\n  failureDetection: {\n    title: 'KI-Fehlererkennung',\n    description: 'Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.',\n    mlUrl: 'Obico-ML-API-URL',\n    mlUrlHint: 'Basis-URL deines selbst gehosteten Obico-ml_api-Containers (z. B. http://192.168.1.10:3333).',\n    test: 'Testen',\n    testSuccess: 'ML-API erreichbar und funktionsfähig.',\n    testFailed: 'ML-API konnte nicht erreicht werden.',\n    sensitivity: 'Empfindlichkeit',\n    sensitivityLow: 'Niedrig (weniger Fehlalarme)',\n    sensitivityMedium: 'Mittel (ausgewogen)',\n    sensitivityHigh: 'Hoch (frühe Erkennung, mehr Fehlalarme)',\n    sensitivityHint: 'Passt die Konfidenz-Schwellwerte an, die Warnungen und Fehler auslösen.',\n    action: 'Aktion bei erkanntem Fehler',\n    actionNotify: 'Nur benachrichtigen',\n    actionPause: 'Druck pausieren',\n    actionPauseOff: 'Pausieren und Strom abschalten',\n    pollInterval: 'Prüfintervall (Sekunden)',\n    pollIntervalHint: 'Wie oft jeder Drucker während eines laufenden Drucks geprüft wird. Minimum 5 s, Maximum 120 s.',\n    externalUrlMissing: 'Externe URL ist nicht gesetzt.',\n    externalUrlHint: 'Die ML-API ruft das Kamera-Snapshot per URL ab. Setze die externe URL in den allgemeinen Einstellungen, damit der ML-API-Container Bambuddy erreichen kann.',\n    perPrinterTitle: 'Überwachte Drucker',\n    perPrinterHint: 'Wähle, welche Drucker vom Erkennungsdienst überwacht werden.',\n    monitorAll: 'Alle verbundenen Drucker überwachen',\n    statusTitle: 'Status',\n    serviceRunning: 'Dienst läuft',\n    thresholds: 'Niedrig / Hoch-Schwellwerte',\n    activePrinters: 'Aktive Drucke',\n    noActivePrints: 'Derzeit laufen keine Drucke.',\n    historyTitle: 'Letzte Erkennungen',\n    noHistory: 'Noch keine Erkennungen.',\n  },\n};\n"
  },
  {
    "path": "frontend/src/i18n/locales/en.ts",
    "content": "export default {\n  // Navigation\n  nav: {\n    printers: 'Printers',\n    archives: 'Archives',\n    queue: 'Queue',\n    stats: 'Statistics',\n    profiles: 'Profiles',\n    maintenance: 'Maintenance',\n    projects: 'Projects',\n    inventory: 'Filament',\n    files: 'File Manager',\n    notifications: 'Notifications',\n    settings: 'Settings',\n    system: 'System',\n    collapseSidebar: 'Collapse sidebar',\n    expandSidebar: 'Expand sidebar',\n    update: 'Update',\n    updateAvailable: 'Update available: v{{version}}',\n    updateAvailableBanner: 'Version {{version}} is available!',\n    viewUpdate: 'View update',\n    viewOnGithub: 'View on GitHub',\n    keyboardShortcuts: 'Keyboard shortcuts (?)',\n    switchToLight: 'Switch to light mode',\n    switchToDark: 'Switch to dark mode',\n    smartSwitches: 'Smart Switches',\n    logout: 'Logout',\n  },\n\n  // Common\n  common: {\n    save: 'Save',\n    saving: 'Saving...',\n    cancel: 'Cancel',\n    delete: 'Delete',\n    edit: 'Edit',\n    add: 'Add',\n    close: 'Close',\n    confirm: 'Confirm',\n    loading: 'Loading...',\n    error: 'Error',\n    success: 'Success',\n    warning: 'Warning',\n    enabled: 'Enabled',\n    disabled: 'Disabled',\n    yes: 'Yes',\n    no: 'No',\n    on: 'On',\n    off: 'Off',\n    all: 'All',\n    none: 'None',\n    search: 'Search',\n    filter: 'Filter',\n    sort: 'Sort',\n    refresh: 'Refresh',\n    download: 'Download',\n    upload: 'Upload',\n    uploading: 'Uploading...',\n    uploadFailed: 'Upload failed',\n    actions: 'Actions',\n    status: 'Status',\n    name: 'Name',\n    description: 'Description',\n    date: 'Date',\n    time: 'Time',\n    hours: 'hours',\n    minutes: 'minutes',\n    seconds: 'seconds',\n    days: 'days',\n    enable: 'Enable',\n    disable: 'Disable',\n    permissions: 'Permissions',\n    noPrinters: 'No printers configured',\n    noData: 'No data available',\n    linkNotFound: 'Link not found',\n    required: 'Required',\n    optional: 'Optional',\n    dismiss: 'Dismiss',\n    apply: 'Apply',\n    reset: 'Reset',\n    export: 'Export',\n    import: 'Import',\n    clear: 'Clear',\n    selectAll: 'Select All',\n    deselectAll: 'Deselect All',\n    noChange: '— No change —',\n    unchanged: 'Unchanged',\n    unassigned: 'Unassigned',\n    unknown: 'Unknown',\n    unknownError: 'Unknown error',\n    today: 'Today',\n    tomorrow: 'Tomorrow',\n    asap: 'ASAP',\n    overdue: 'Overdue',\n    now: 'Now',\n    collapse: 'Collapse',\n    expand: 'Expand',\n    viewArchive: 'View archive',\n    viewInFileManager: 'View in File Manager',\n    addedBy: 'Added by {{username}}',\n    prints: 'prints',\n    more: '+{{count}} more',\n    ascending: 'Ascending',\n    descending: 'Descending',\n    back: 'Back',\n    copy: 'Copy',\n    copied: 'Copied!',\n    printer: 'Printer',\n    remove: 'Remove',\n    type: 'Type',\n    print: 'Print',\n    rename: 'Rename',\n    move: 'Move',\n    create: 'Create',\n    duplicate: 'Duplicate',\n    left: 'Left',\n    right: 'Right',\n  },\n\n  // Printers page\n  printers: {\n    title: 'Printers',\n    addPrinter: 'Add Printer',\n    editPrinter: 'Edit Printer',\n    deletePrinter: 'Delete Printer',\n    printerName: 'Printer Name',\n    serialNumber: 'Serial Number',\n    ipAddress: 'IP Address / Hostname',\n    accessCode: 'Access Code',\n    model: 'Model',\n    nozzleCount: 'Nozzle Count',\n    autoArchive: 'Auto Archive',\n    status: {\n      available: 'Available',\n      idle: 'Idle',\n      printing: 'Printing',\n      paused: 'Paused',\n      offline: 'Offline',\n      problem: 'Problem',\n      error: 'Error',\n      finished: 'Finished',\n      unknown: 'Unknown',\n    },\n    temperatures: {\n      nozzle: 'Nozzle',\n      bed: 'Bed',\n      chamber: 'Chamber',\n    },\n    progress: '{{percent}}% complete',\n    timeRemaining: '{{time}} remaining',\n    deleteConfirm: 'Are you sure you want to delete \"{{name}}\"?',\n    maintenanceOk: 'Maintenance OK',\n    maintenanceWarning: '{{count}} warning',\n    maintenanceWarning_plural: '{{count}} warnings',\n    maintenanceDue: '{{count}} due',\n    maintenanceDue_plural: '{{count}} due',\n    // Sort options\n    sort: {\n      name: 'Name',\n      status: 'Status',\n      model: 'Model',\n      location: 'Location',\n      ascending: 'Sort ascending',\n      descending: 'Sort descending',\n    },\n    // Card size\n    cardSize: {\n      small: 'Small cards',\n      medium: 'Medium cards',\n      large: 'Large cards',\n      extraLarge: 'Extra large cards',\n    },\n    // Controls\n    hideOffline: 'Hide offline',\n    nextAvailable: 'Next available',\n    powerOn: 'Power On',\n    offlinePrintersWithPlugs: 'Offline printers with smart plugs',\n    noPrintersConfigured: 'No printers configured yet',\n    search: 'Search printers...',\n    noSearchResults: 'No printers match your search or filters',\n    filter: {\n      allStatuses: 'All statuses',\n      allLocations: 'All locations',\n    },\n    // Printer card\n    readyToPrint: 'Ready to print',\n    external: 'External',\n    extL: 'Ext-L',\n    extR: 'Ext-R',\n    deleteArchives: 'Delete print archives',\n    noLabel: 'No label',\n    printPreview: 'Print preview',\n    width: 'Width',\n    height: 'Height',\n    noObjectsFound: 'No objects found',\n    objectsLoadedOnPrintStart: 'Objects are loaded when a print starts',\n    willBeSkipped: 'Will be skipped',\n    name: 'Name',\n    serialCannotBeChanged: 'Serial number cannot be changed',\n    locationHelp: 'Used to group printers and filter queue jobs',\n    // WiFi signal strength\n    wifiSignal: {\n      veryWeak: 'Very weak',\n      weak: 'Weak',\n      fair: 'Fair',\n      good: 'Good',\n      excellent: 'Excellent',\n    },\n    // Maintenance\n    maintenanceUpToDate: 'All maintenance up to date - Click to view',\n    // Chamber light\n    chamberLightOn: 'Turn on chamber light',\n    chamberLightOff: 'Turn off chamber light',\n    // Files\n    files: 'Files',\n    browseFiles: 'Browse printer files',\n    // Smart plug\n    autoOffAfterPrint: 'Auto power-off after print',\n    autoOffExecuted: 'Auto-off was executed - turn printer on to reset',\n    // HMS errors\n    hmsErrors: 'HMS Errors',\n    viewHmsErrors: 'View {{count}} HMS error(s)',\n    // Actions\n    resume: 'Resume',\n    pause: 'Pause',\n    stop: 'Stop',\n    camera: 'Camera',\n    skipObject: 'Skip Object',\n    reconnect: 'Reconnect',\n    forceRefresh: 'Force Refresh',\n    forceRefreshSuccess: 'Refresh requested',\n    mqttDebug: 'MQTT Debug',\n    printerInformation: 'Printer Information',\n    copyToClipboard: 'Copy',\n    copied: 'Copied!',\n    state: 'State',\n    wifiSignalLabel: 'WiFi Signal',\n    developerMode: 'Developer Mode',\n    enabled: 'Enabled',\n    disabled: 'Disabled',\n    addedOn: 'Added',\n    sdCard: 'SD Card',\n    inserted: 'Inserted',\n    notInserted: 'Not inserted',\n    totalPrintHours: 'Print Hours',\n    activeNozzle: 'Active: {{nozzle}} nozzle',\n    nozzleRack: 'Nozzle Rack',\n    nozzleDocked: 'Docked',\n    nozzleMounted: 'Mounted',\n    nozzleActive: 'Active',\n    nozzleIdle: 'Idle',\n    nozzleDiameter: 'Diameter',\n    nozzleType: 'Type',\n    nozzleStatus: 'Status',\n    nozzleFilament: 'Filament',\n    nozzleWear: 'Wear',\n    nozzleMaxTemp: 'Max Temp',\n    nozzleSerial: 'Serial',\n    nozzleHardenedSteel: 'Hardened Steel',\n    nozzleStainlessSteel: 'Stainless Steel',\n    nozzleTungstenCarbide: 'Tungsten Carbide',\n    nozzleFlow: 'Flow',\n    nozzleHighFlow: 'High Flow',\n    nozzleStandardFlow: 'Standard',\n    // Firmware\n    firmwareUpdate: 'Firmware Update',\n    firmwareInstructions: 'On the printer\\'s touchscreen, go to',\n    firmwareNav: 'Navigate to',\n    settings: 'Settings',\n    firmware: 'Firmware',\n    // Discovery\n    discoverPrinters: 'Discover Printers',\n    searching: 'Searching...',\n    manualEntry: 'Manual Entry',\n    addFromCloud: 'Add from Cloud',\n    // Toast messages\n    toast: {\n      printerDeleted: 'Printer deleted',\n      missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',\n      printerAdded: 'Printer added',\n      printerUpdated: 'Printer updated',\n      failedToDelete: 'Failed to delete printer',\n      failedToAdd: 'Failed to add printer',\n      failedToUpdate: 'Failed to update printer',\n      commandSent: 'Command sent',\n      failedToSendCommand: 'Failed to send command',\n      turnedOn: '{{name}} turned on',\n      failedToPowerOn: 'Failed to power on {{name}}',\n      scriptTriggered: 'Script triggered',\n      printStopped: 'Print stopped',\n      printPaused: 'Print paused',\n      printResumed: 'Print resumed',\n      referenceDeleted: 'Reference deleted',\n      detectionAreaSaved: 'Detection area saved',\n      failedToRunScript: 'Failed to run script',\n      failedToStopPrint: 'Failed to stop print',\n      failedToPausePrint: 'Failed to pause print',\n      failedToResumePrint: 'Failed to resume print',\n      failedToControlChamberLight: 'Failed to control chamber light',\n      failedToSetSpeed: 'Failed to set print speed',\n      failedToUpdateSetting: 'Failed to update setting',\n      failedToSkipObjects: 'Failed to skip objects',\n      failedToRereadRfid: 'Failed to re-read RFID',\n      failedToCheckPlate: 'Failed to check plate',\n      failedToUpdateLabel: 'Failed to update label',\n      failedToDeleteReference: 'Failed to delete reference',\n      failedToSaveDetectionArea: 'Failed to save detection area',\n      plateCheckEnabled: 'Plate check enabled',\n      plateCheckDisabled: 'Plate check disabled',\n      calibrationSaved: 'Calibration saved!',\n      calibrationFailed: 'Calibration failed',\n      rfidRereadInitiated: 'RFID re-read initiated',\n    },\n    // Connection status\n    connection: {\n      connected: 'Connected',\n      offline: 'Offline',\n    },\n    plateStatus: {\n      markCleared: 'Mark plate as cleared',\n      cleared: 'Plate Clear',\n      notCleared: 'Plate not Clear',\n      inUse: 'Plate in Use',\n    },\n    // Queue info\n    queue: {\n      inQueue: '{{count}} print in queue',\n      inQueue_plural: '{{count}} prints in queue',\n    },\n    // Controls section\n    controls: 'Controls',\n    // RFID\n    rfid: {\n      reread: 'Re-read RFID',\n    },\n    bedJog: {\n      title: 'Move build plate',\n      bed: 'Bed',\n      step: 'Step (mm)',\n      up: 'Move plate up',\n      down: 'Move plate down',\n      disabledWhilePrinting: 'Disabled while printing',\n      notHomedTitle: 'Printer is not homed',\n      notHomedMessage: 'The printer has not been homed since the last print. Run auto-home first for safe positioning (parks the toolhead, then homes X, Y, and Z), or move anyway — soft endstops will be bypassed.',\n      homeZ: 'Auto Home',\n      moveAnyway: 'Move anyway',\n      homingStarted: 'Auto-homing printer…',\n    },\n    // Permissions\n    permission: {\n      noAdd: 'You do not have permission to add printers',\n      noEdit: 'You do not have permission to edit printers',\n      noDelete: 'You do not have permission to delete printers',\n      noControl: 'You do not have permission to control printers',\n      noFiles: 'You do not have permission to access printer files',\n      noAmsRfid: 'You do not have permission to re-read AMS RFID',\n      noSmartPlugControl: 'You do not have permission to control smart plugs',\n      noCamera: 'You do not have permission to view cameras',\n    },\n    // Add/Edit modal\n    modal: {\n      addTitle: 'Add Printer',\n      editTitle: 'Edit Printer',\n      myPrinter: 'My Printer',\n      selectModel: 'Select model...',\n      locationGroup: 'Location / Group (optional)',\n      locationPlaceholder: 'e.g., Workshop, Office, Basement',\n      autoArchiveLabel: 'Auto-archive completed prints',\n      fromPrinterSettings: 'From printer settings',\n      modelOptional: 'Model (optional)',\n      saveChanges: 'Save Changes',\n    },\n    // Skip objects\n    skipObjects: {\n      tooltip: 'Skip objects',\n      onlyWhilePrinting: 'Skip objects (only while printing)',\n      requiresMultiple: 'Skip objects (requires 2+ objects)',\n      title: 'Skip Objects',\n      matchIdsInfo: 'Match IDs with your printer display',\n      printerShowsIds: 'The printer screen shows object IDs on the build plate',\n      skipSelected: 'Skip Selected',\n      skipping: 'Skipping...',\n      noObjectsSelected: 'No objects selected',\n      selectObjectsToSkip: 'Select objects you want to skip from the current print',\n      skipped: 'skipped',\n      objectsSkipped: 'Objects skipped',\n      activeCount: '{{count}} active',\n      waitForLayer: 'Wait for layer 2+ to skip objects (currently layer {{layer}})',\n      skip: 'Skip',\n      confirmTitle: 'Skip Object?',\n      confirmMessage: 'Are you sure you want to skip \"{{name}}\"? This cannot be undone.',\n    },\n    // Confirm modals\n    confirm: {\n      deleteTitle: 'Delete Printer',\n      deleteMessage: 'Are you sure you want to delete \"{{name}}\"? This will remove all connection settings.',\n      deleteArchivesNote: 'All print history for this printer will be permanently deleted.',\n      keepArchivesNote: 'Print history will be kept but no longer associated with this printer.',\n      stopTitle: 'Stop Print',\n      stopMessage: 'Are you sure you want to stop the current print on \"{{name}}\"? This will cancel the print job.',\n      stopButton: 'Stop Print',\n      pauseTitle: 'Pause Print',\n      pauseMessage: 'Are you sure you want to pause the current print on \"{{name}}\"?',\n      pauseButton: 'Pause Print',\n      resumeTitle: 'Resume Print',\n      resumeMessage: 'Are you sure you want to resume the print on \"{{name}}\"?',\n      resumeButton: 'Resume Print',\n      powerOnTitle: 'Power On Printer',\n      powerOnMessage: 'Are you sure you want to turn ON the power for \"{{name}}\"?',\n      powerOnButton: 'Power On',\n      powerOffTitle: 'Power Off Printer',\n      powerOffMessage: 'Are you sure you want to turn OFF the power for \"{{name}}\"?',\n      powerOffWarning: 'WARNING: \"{{name}}\" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.',\n      powerOffButton: 'Power Off',\n    },\n    // Bulk actions\n    bulk: {\n      select: 'Select',\n      selectAll: 'Select All',\n      selectByLocation: 'Select by Location',\n      selected: '{{count}} selected',\n      actions: {\n        stop: 'Stop',\n        pause: 'Pause',\n        resume: 'Resume',\n        clearPlate: 'Clear Bed',\n        clearHMS: 'Clear Notifications',\n      },\n      confirm: {\n        stopTitle: 'Stop {{count}} Prints',\n        stopMessage: 'This will cancel active prints on {{count}} printer(s). This action cannot be undone.',\n        stopButton: 'Stop All',\n        pauseTitle: 'Pause {{count}} Prints',\n        pauseMessage: 'This will pause active prints on {{count}} printer(s).',\n        pauseButton: 'Pause All',\n        clearPlateTitle: 'Clear {{count}} Print Beds',\n        clearPlateMessage: 'This will clear the print bed on {{count}} printer(s) and may trigger queued jobs.',\n        clearPlateButton: 'Clear All',\n      },\n      success: '{{action}} completed on {{count}} printer(s)',\n      partial: '{{succeeded}} succeeded, {{failed}} failed',\n      noneApplicable: 'No selected printers are in the right state for this action',\n      selectByState: 'Select by State',\n    },\n    // Discovery\n    discovery: {\n      title: 'Discover Printers',\n      searching: 'Searching...',\n      scanning: 'Scanning...',\n      scanProgress: 'Scanning... {{scanned}}/{{total}}',\n      foundPrinters: 'Found {{count}} printer(s)',\n      noPrintersFound: 'No printers found',\n      noPrintersFoundSubnet: 'No printers found in the specified subnet.',\n      noPrintersFoundNetwork: 'No printers found on the network.',\n      allConfigured: 'All discovered printers are already configured.',\n      alreadyAdded: 'Already added',\n      select: 'Select',\n      manualEntry: 'Manual Entry',\n      addFromCloud: 'Add from Cloud',\n      subnetToScan: 'Subnet to scan',\n      dockerNote: 'Docker detected. Enter your printer\\'s subnet in CIDR notation. Requires network_mode: host in docker-compose.yml.',\n      scanSubnet: 'Scan Subnet for Printers',\n      discoverNetwork: 'Discover Printers on Network',\n      scanningSubnet: 'Scanning subnet for Bambu printers...',\n      scanningNetwork: 'Scanning network...',\n      serialRequired: 'Serial required',\n      unknown: 'Unknown',\n      failedToStart: 'Failed to start discovery',\n    },\n    // AMS Drying\n    drying: {\n      start: 'Start Drying',\n      stop: 'Stop Drying',\n      temperature: 'Temperature',\n      duration: 'Duration',\n      hours: 'hours',\n      timeRemaining: '{{time}} left',\n      active: 'Drying',\n      notSupported: 'Drying not supported',\n      powerRequired: 'Connect AMS power adapter to enable drying',\n      startingDrying: 'Starting drying...',\n      stoppingDrying: 'Stopping drying...',\n      rotateTray: 'Rotate spool during drying',\n    },\n    // Filaments section\n    filaments: 'Filaments',\n    // Camera\n    openCameraOverlay: 'Open camera overlay',\n    openCameraWindow: 'Open camera in new window',\n    // Firmware\n    firmwareUpdateAvailable: 'Firmware update available: {{current}} → {{latest}}',\n    firmwareUpToDate: 'Firmware {{version}} — Up to date',\n    firmwareUpdateButton: 'Update',\n    // Plate detection\n    plateDetection: {\n      noPermission: 'You do not have permission to update printers',\n      enabledClick: 'Plate check enabled - Click to disable',\n      disabledClick: 'Plate check disabled - Click to enable',\n      manageCalibration: 'Manage plate detection calibration',\n      calibrationRequired: 'Calibration Required',\n      calibrationInstructions: 'Please ensure the build plate is <strong>completely empty</strong>, then click Calibrate.',\n      calibrationDescription: 'Calibration captures a reference image of the empty plate. Future checks will compare against this reference to detect objects.',\n      calibrationTip: '<strong>Tip:</strong> You can store up to 5 calibrations for different plates. The system automatically uses the best match when checking.',\n      plateEmpty: 'Plate appears empty',\n      objectsDetected: 'Objects detected on plate',\n      confidence: 'Confidence',\n      difference: 'Difference',\n      analysisPreview: 'Analysis preview:',\n      analysisLegend: 'Green box = detection area, Red overlay = differences from calibration',\n      savedReferences: 'Saved References ({{count}}/{{max}})',\n      deleteReference: 'Delete reference',\n      labelPlaceholder: 'Label...',\n      clickToEdit: '{{label}} - Click to edit',\n      clickToAddLabel: 'Click to add label',\n    },\n    // Speed\n    speed: {\n      title: 'Print Speed',\n      silent: 'Silent (50%)',\n      standard: 'Standard (100%)',\n      sport: 'Sport (124%)',\n      ludicrous: 'Ludicrous (166%)',\n    },\n    airduct: {\n      title: 'Airduct Mode',\n      cooling: 'Cooling',\n      heating: 'Heating',\n    },\n    noSdCard: 'No SD',\n    door: {\n      open: 'Open',\n      closed: 'Closed',\n    },\n    // Fans\n    fans: {\n      partCooling: 'Part Cooling Fan',\n      auxiliary: 'Auxiliary Fan',\n      chamber: 'Chamber Fan',\n    },\n    // HMS errors\n    clickToViewHmsErrors: 'Click to view HMS errors',\n    estimatedCompletion: 'Estimated completion time',\n    plateNumber: 'Plate {{number}}',\n    slotOptions: 'Slot options',\n    // AMS hover popup\n    amsPopup: {\n      friendlyName: 'AMS Name',\n      friendlyNamePlaceholder: 'e.g. AMS Friendly Name',\n      serialNumber: 'Serial Number',\n      firmwareVersion: 'Firmware',\n      save: 'Save',\n      clear: 'Clear',\n      noEditPermission: 'You do not have permission to rename AMS units',\n    },\n    // Firmware modal\n    firmwareModal: {\n      title: 'Firmware Update',\n      titleUpToDate: 'Firmware Info',\n      currentVersion: 'Current:',\n      latestVersion: 'Latest:',\n      releaseNotes: 'Release Notes',\n      checkingPrereqs: 'Checking prerequisites...',\n      sdCardReady: 'SD card ready. Click below to upload firmware.',\n      uploadedSuccess: 'Firmware uploaded to SD card!',\n      applyInstructions: 'To apply the update on your printer:',\n      step1: 'On the printer\\'s touchscreen, go to <strong>Settings</strong>',\n      step2: 'Navigate to <strong>Firmware</strong>',\n      step3: 'Select <strong>Update from SD card</strong>',\n      step4: 'The update will take 10-20 minutes',\n      done: 'Done',\n      starting: 'Starting...',\n      uploadFirmware: 'Upload Firmware',\n      uploadFailed: 'Failed to start upload: {{error}}',\n      uploadedToast: 'Firmware uploaded! Trigger update from printer screen.',\n      availableVersions: 'Available versions',\n      usable: 'Usable',\n      unavailable: 'Unavailable',\n      installed: 'Installed',\n      newerBadge: 'newer',\n      olderBadge: 'older',\n      currentBadge: 'current',\n    },\n    accessCodePlaceholder: 'Leave empty to keep current',\n    // ROI editor\n    roi: {\n      title: 'Detection Area (ROI)',\n      xStart: 'X Start',\n      yStart: 'Y Start',\n      width: 'Width',\n      height: 'Height',\n      instruction: 'Adjust the detection area to focus on the build plate. The green box in the preview shows the current area.',\n    },\n    developerModeWarning: 'Developer LAN mode is not enabled on: {{names}}. Some features may not work.',\n    howToEnable: 'How to enable',\n    incompatibleFile: 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}',\n    dropNotPrintable: 'Only .gcode and .gcode.3mf files can be printed',\n    dropToPrint: 'Drop to print',\n    cannotPrint: 'Printer busy',\n  },\n\n  // Archives page\n  archives: {\n    title: 'Print Archives',\n    searchPlaceholder: 'Search archives...',\n    filterByPrinter: 'Filter by printer',\n    filterByStatus: 'Filter by status',\n    sortBy: 'Sort by',\n    sortNewest: 'Newest first',\n    sortOldest: 'Oldest first',\n    sortName: 'Name',\n    sortDuration: 'Duration',\n    sortLargest: 'Largest first',\n    sortSmallest: 'Smallest first',\n    sortSize: 'Size',\n    noArchives: 'No archives found',\n    noArchivesSearch: 'No archives match your search',\n    originalPrintNotVisible: 'Original print not visible - try clearing filters',\n    noArchivesYet: 'No archives yet',\n    prints: 'prints',\n    pagination: {\n      showing: 'Showing',\n      to: 'to',\n      of: 'of',\n      show: 'Show',\n      page: 'Page',\n      all: 'All',\n    },\n    loadingArchives: 'Loading archives...',\n    releaseToUpload: 'Release to upload',\n    showAll: 'Show all',\n    showFavoritesOnly: 'Show favorites only',\n    gridView: 'Grid view',\n    listView: 'List view',\n    calendarView: 'Calendar view',\n    logView: 'Print Log',\n    manageTags: 'Manage Tags',\n    showFailedPrints: 'Show failed prints',\n    hideFailedPrints: 'Hide failed prints',\n    hideDuplicates: 'Hide Duplicates',\n    viewOriginalPrint: 'Click to view original print (#{{id}})',\n    printTime: 'Print Time',\n    filamentUsed: 'Filament Used',\n    cost: 'Cost',\n    reprint: 'Reprint',\n    preview: 'Preview',\n    deleteArchive: 'Delete Archive',\n    deleteConfirm: 'Are you sure you want to delete this archive?',\n    favorite: 'Favorite',\n    unfavorite: 'Remove from favorites',\n    viewDetails: 'View Details',\n    status: {\n      completed: 'Completed',\n      failed: 'Failed',\n      stopped: 'Stopped',\n    },\n    toast: {\n      source3mfAttached: 'Source 3MF attached: {{filename}}',\n      failedUploadSource3mf: 'Failed to upload source 3MF',\n      source3mfRemoved: 'Source 3MF removed',\n      failedRemoveSource3mf: 'Failed to remove source 3MF',\n      f3dAttached: 'F3D attached: {{filename}}',\n      failedUploadF3d: 'Failed to upload F3D',\n      f3dRemoved: 'F3D removed',\n      failedRemoveF3d: 'Failed to remove F3D',\n      timelapseAttached: 'Timelapse attached: {{filename}}',\n      timelapseAlreadyAttached: 'Timelapse already attached',\n      noMatchingTimelapse: 'No matching timelapse found',\n      failedScanTimelapse: 'Failed to scan for timelapse',\n      failedAttachTimelapse: 'Failed to attach timelapse',\n      timelapseRemoved: 'Timelapse removed',\n      failedRemoveTimelapse: 'Failed to remove timelapse',\n      timelapseUploaded: 'Timelapse uploaded: {{filename}}',\n      failedUploadTimelapse: 'Failed to upload timelapse',\n      archiveDeleted: 'Archive deleted',\n      failedDeleteArchive: 'Failed to delete archive',\n      addedToFavorites: 'Added to favorites',\n      removedFromFavorites: 'Removed from favorites',\n      projectUpdated: 'Project updated',\n      failedUpdateProject: 'Failed to update project',\n      linkCopied: 'Link copied to clipboard',\n      failedCopyLink: 'Failed to copy link',\n      photoDeleted: 'Photo deleted',\n      failedDeletePhoto: 'Failed to delete photo',\n      failedDeleteArchives: 'Failed to delete archives',\n      failedUpdateFavorites: 'Failed to update favorites',\n      exportDownloaded: 'Export downloaded',\n      exportFailed: 'Export failed',\n    },\n    menu: {\n      print: 'Print',\n      schedule: 'Schedule',\n      openInBambuStudio: 'Open in Slicer',\n      slice: 'Slice',\n      externalLink: 'External Link',\n      viewOnMakerWorld: 'View on MakerWorld',\n      preview3d: '3D Preview',\n      viewTimelapse: 'View Timelapse',\n      scanForTimelapse: 'Scan for Timelapse',\n      uploadTimelapse: 'Upload Timelapse',\n      removeTimelapse: 'Remove Timelapse',\n      downloadSource3mf: 'Download Source 3MF',\n      uploadSource3mf: 'Upload Source 3MF',\n      replaceSource3mf: 'Replace Source 3MF',\n      removeSource3mf: 'Remove Source 3MF',\n      uploadF3d: 'Upload F3D',\n      replaceF3d: 'Replace F3D',\n      downloadF3d: 'Download F3D',\n      removeF3d: 'Remove F3D',\n      download: 'Download',\n      copyDownloadLink: 'Copy Download Link',\n      qrCode: 'QR Code',\n      viewPhotos: 'View Photos',\n      viewPhotosCount: 'View Photos ({{count}})',\n      projectPage: 'Project Page',\n      addToFavorites: 'Add to Favorites',\n      removeFromFavorites: 'Remove from Favorites',\n      edit: 'Edit',\n      goToProject: 'Go to Project: {{name}}',\n      addToProject: 'Add to Project',\n      removeFromProject: 'Remove from Project',\n      loading: 'Loading...',\n      noProjectsAvailable: 'No projects available',\n      select: 'Select',\n      deselect: 'Deselect',\n      delete: 'Delete',\n    },\n    permission: {\n      noReprint: 'You do not have permission to reprint this archive',\n      noAddToQueue: 'You do not have permission to add to queue',\n      noUpdateArchives: 'You do not have permission to update archives',\n      noUploadFiles: 'You do not have permission to upload files',\n      noDownload: 'You do not have permission to download archives',\n      noCopyLink: 'You do not have permission to copy download links',\n      noDelete: 'You do not have permission to delete this archive',\n      noCreate: 'You do not have permission to create archives',\n    },\n    card: {\n      previousPlate: 'Previous plate',\n      nextPlate: 'Next plate',\n      plateNumber: 'Plate {{index}}',\n      moreOptions: 'Right-click for more options',\n      addToFavorites: 'Add to favorites',\n      removeFromFavorites: 'Remove from favorites',\n      cancelled: 'cancelled',\n      failed: 'failed',\n      duplicate: 'duplicate',\n      duplicateTitle: 'This model has been printed before',\n      openSource3mf: 'Open source 3MF in Bambu Studio (right-click for more options)',\n      downloadF3d: 'Download Fusion 360 design file',\n      viewTimelapse: 'View timelapse',\n      viewPhoto: 'View 1 photo',\n      viewPhotos: 'View {{count}} photos',\n      openFolder: 'Open folder: {{name}}',\n      slicedFile: 'Sliced file - ready to print',\n      sourceFile: 'Source file only - no AMS mapping available',\n      gcode: 'GCODE',\n      source: 'SOURCE',\n      project: 'Project: {{name}}',\n      estimated: 'Estimated: {{time}}',\n      actual: 'Actual: {{time}}',\n      accuracy: 'Accuracy: {{percent}}%',\n      filament: '{{weight}}g',\n      layer: '{{count}} layer',\n      layers: '{{count}} layers',\n      object: '{{count}} object',\n      objects: '{{count}} objects',\n      slicedFor: 'Sliced for {{model}}',\n      uploadedBy: 'Uploaded By',\n      noPermissionReprint: 'You do not have permission to reprint',\n      noFileForReprint: 'No 3MF file available — the file could not be downloaded from the printer when the print was recorded',\n      noPermissionEdit: 'You do not have permission to edit archives',\n      noPermissionDelete: 'You do not have permission to delete archives',\n      reprint: 'Reprint',\n      schedulePrint: 'Schedule Print',\n      schedule: 'Schedule',\n      openInBambuStudio: 'Open in Slicer',\n      openInBambuStudioToSlice: 'Open in Slicer to slice',\n      slice: 'Slice',\n      externalLink: 'External Link',\n      makerWorld: 'MakerWorld: {{designer}}',\n      viewProject: 'View project',\n      noExternalLink: 'No external link',\n      preview3d: '3D Preview',\n      download: 'Download',\n      edit: 'Edit',\n      delete: 'Delete',\n    },\n    modal: {\n      deleteArchive: 'Delete Archive',\n      deleteConfirm: 'Are you sure you want to delete \"{{name}}\"? This action cannot be undone.',\n      deleteButton: 'Delete',\n      removeSource3mf: 'Remove Source 3MF',\n      removeSource3mfConfirm: 'Are you sure you want to remove the source 3MF file from \"{{name}}\"? This will delete the original slicer project file.',\n      removeButton: 'Remove',\n      removeF3d: 'Remove F3D',\n      removeF3dConfirm: 'Are you sure you want to remove the Fusion 360 design file from \"{{name}}\"?',\n      removeTimelapse: 'Remove Timelapse',\n      removeTimelapseConfirm: 'Are you sure you want to remove the timelapse video from \"{{name}}\"?',\n      timelapse: '{{name}} - Timelapse',\n      selectTimelapse: 'Select Timelapse',\n      selectTimelapseDesc: 'No auto-match found. Select the timelapse for this print:',\n      deleteArchives: 'Delete Archives',\n      deleteArchivesConfirm: 'Are you sure you want to delete {{count}} archive(s)? This action cannot be undone.',\n      deleteCount: 'Delete {{count}}',\n    },\n    page: {\n      title: 'Archives',\n      printsCount: '{{filtered}} of {{total}} prints',\n      dropFilesHere: 'Drop .3mf files here',\n      releaseToUpload: 'Release to upload',\n      only3mfSupported: 'Only .3mf files are supported',\n      close: 'Close',\n      selected: '{{count}} selected',\n      selectAll: 'Select All',\n      tags: 'Tags',\n      project: 'Project',\n      favorite: 'Favorite',\n      delete: 'Delete',\n      toggledFavorites: 'Toggled favorites for {{count}} archive(s)',\n      failedUpdateFavorites: 'Failed to update favorites',\n      archivesDeleted: '{{count}} archive(s) deleted',\n      failedDeleteArchives: 'Failed to delete archives',\n      photoDeleted: 'Photo deleted',\n      failedDeletePhoto: 'Failed to delete photo',\n    },\n    list: {\n      name: 'Name',\n      printer: 'Printer',\n      date: 'Date',\n      size: 'Size',\n      actions: 'Actions',\n      hasTimelapse: 'Has timelapse',\n    },\n    log: {\n      date: 'Date',\n      printName: 'Print Name',\n      printer: 'Printer',\n      user: 'User',\n      status: 'Status',\n      duration: 'Duration',\n      filament: 'Filament',\n      allPrinters: 'All Printers',\n      allUsers: 'All Users',\n      allStatuses: 'All Statuses',\n      cancelled: 'Cancelled',\n      skipped: 'Skipped',\n      dateFrom: 'From',\n      dateTo: 'To',\n      noEntries: 'No print log entries found',\n      showing: 'Showing {{count}} of {{total}} entries',\n      rowsPerPage: 'Rows',\n      page: 'Page',\n      prev: 'Prev',\n      next: 'Next',\n      clearLog: 'Clear Log',\n      clearLogTitle: 'Clear Print Log',\n      clearLogConfirm: 'All print log entries will be permanently deleted. Archives and queue items are not affected. This action cannot be undone. Are you sure?',\n      clearLogButton: 'Clear All',\n      cleared: '{{count}} log entries cleared',\n      clearFailed: 'Failed to clear print log',\n    },\n  },\n\n  // Queue page\n  queue: {\n    title: 'Print Queue',\n    subtitle: 'Schedule and manage your print jobs',\n    addToQueue: 'Add to Queue',\n    // Print modal\n    print: 'Print',\n    reprint: 'Re-print',\n    schedulePrint: 'Schedule Print',\n    editQueueItem: 'Edit Queue Item',\n    printToPrinters: 'Print to {{count}} Printers',\n    queueToPrinters: 'Queue to {{count}} Printers',\n    queueSelectedPlates: 'Queue {{count}} Plates',\n    selectAllPlates: 'Select All {{count}} Plates',\n    deselectAll: 'Deselect All',\n    printQueued: 'Print queued',\n    itemsQueued: '{{count}} items queued',\n    sending: 'Sending...',\n    sendingProgress: 'Sending {{current}}/{{total}}...',\n    adding: 'Adding...',\n    addingProgress: 'Adding {{current}}/{{total}}...',\n    savingProgress: 'Saving {{current}}/{{total}}...',\n    clearQueue: 'Clear Queue',\n    clearHistory: 'Clear History',\n    emptyQueue: 'Queue is empty',\n    position: 'Position',\n    scheduledTime: 'Scheduled Time',\n    moveUp: 'Move Up',\n    moveDown: 'Move Down',\n    startNow: 'Start Now',\n    printingInProgress: 'Printing in progress...',\n    viewArchive: 'View archive',\n    viewInFileManager: 'View in File Manager',\n    itemCount: '{{count}} item',\n    itemCount_plural: '{{count}} items',\n    dragToReorder: 'Drag to reorder (ASAP only)',\n    reorderHint: 'Position only affects ASAP items. Scheduled items run at their set time.',\n    sjf: {\n      label: 'SJF',\n      tooltip: 'Shortest Job First — scheduler prioritizes shorter prints',\n    },\n    addedBy: 'Added by {{name}}',\n    nextInQueue: 'Next in queue',\n    clearPlateSuccess: 'Plate cleared — ready for next print',\n    plateNumber: 'Plate {{index}}',\n    // Batch / quantity\n    quantity: 'Quantity',\n    quantityHint: 'Creates {{count}} queue items',\n    activeBatches: 'Active Batches',\n    batchProgress: '{{completed}} of {{total}} completed',\n    cancelBatch: 'Cancel Remaining',\n    batchCancelled: 'Remaining batch items cancelled',\n    cancelBatchConfirmTitle: 'Cancel Batch',\n    cancelBatchConfirmMessage: 'Cancel all remaining pending items in this batch?',\n    batch: 'Batch',\n    // Sections\n    sections: {\n      currentlyPrinting: 'Currently Printing',\n      queued: 'Queued',\n      history: 'History',\n    },\n    // Status\n    status: {\n      pending: 'Pending',\n      waiting: 'Waiting',\n      printing: 'Printing',\n      paused: 'Paused',\n      completed: 'Completed',\n      failed: 'Failed',\n      skipped: 'Skipped',\n      cancelled: 'Cancelled',\n    },\n    // Summary cards\n    summary: {\n      printing: 'Printing',\n      queued: 'Queued',\n      totalTime: 'Total Queue Time',\n      totalWeight: 'Total Queue Weight',\n      history: 'History',\n    },\n    // Filters\n    filter: {\n      allPrinters: 'All Printers',\n      unassigned: 'Unassigned',\n      allStatus: 'All Status',\n      allLocations: 'All Locations',\n      any: 'Any',\n    },\n    // Sort\n    sort: {\n      byPosition: 'Sort by Position',\n      byName: 'Sort by Name',\n      byPrinter: 'Sort by Printer',\n      bySchedule: 'Sort by Schedule',\n      byDate: 'Sort by Date',\n      ascendingOldest: 'Ascending (oldest first)',\n      descendingNewest: 'Descending (newest first)',\n    },\n    // Badges\n    badges: {\n      staged: 'Staged',\n      requiresPrevious: 'Requires previous success',\n      autoPowerOff: 'Auto power off',\n      gcodeInjection: 'G-code',\n    },\n    // Empty state\n    empty: {\n      title: 'No prints scheduled',\n      description: 'Schedule a print from the Archives page using the \"Schedule\" option in the context menu, or drag and drop files to get started.',\n    },\n    // Time\n    time: {\n      asap: 'ASAP',\n      overdue: 'Overdue',\n      now: 'Now',\n      lessThanMinute: 'In less than a minute',\n      inMinutes: 'In {{count}} min',\n      inHours: 'In {{count}} hours',\n    },\n    // Actions\n    actions: {\n      stopPrint: 'Stop Print',\n      startPrint: 'Start Print',\n      requeue: 'Re-queue',\n    },\n    // Bulk edit\n    bulkEdit: {\n      title: 'Edit {{count}} Item',\n      title_plural: 'Edit {{count}} Items',\n      description: 'Only changed settings will be applied to selected items.',\n      printer: 'Printer',\n      noChange: '— No change —',\n      queueOptions: 'Queue Options',\n      staged: 'Staged (manual start)',\n      autoPowerOff: 'Auto power off after print',\n      requirePrevious: 'Require previous success',\n      printOptions: 'Print Options',\n      bedLevelling: 'Bed levelling',\n      flowCalibration: 'Flow calibration',\n      vibrationCalibration: 'Vibration calibration',\n      layerInspection: 'First layer inspection',\n      timelapse: 'Timelapse',\n      useAms: 'Use AMS',\n      applyChanges: 'Apply Changes',\n      selectAll: 'Select All',\n      deselectAll: 'Deselect All',\n      selected: '{{count}} selected',\n      editSelected: 'Edit Selected',\n      cancelSelected: 'Cancel Selected',\n    },\n    // Confirmations\n    confirm: {\n      cancelTitle: 'Cancel Scheduled Print',\n      cancelMessage: 'Are you sure you want to cancel \"{{name}}\"?',\n      stopTitle: 'Stop Print',\n      stopMessage: 'Are you sure you want to stop the current print \"{{name}}\"? This will cancel the print job on the printer.',\n      removeTitle: 'Remove from History',\n      removeMessage: 'Are you sure you want to remove \"{{name}}\" from the queue history?',\n      clearHistoryTitle: 'Clear History',\n      clearHistoryMessage: 'Are you sure you want to remove all {{count}} item(s) from the history?',\n      cancelButton: 'Cancel Print',\n      stopButton: 'Stop Print',\n      thisPrint: 'this print',\n      thisItem: 'this item',\n    },\n    // Toast messages\n    toast: {\n      cancelled: 'Queue item cancelled',\n      cancelFailed: 'Failed to cancel item',\n      removed: 'Queue item removed',\n      removeFailed: 'Failed to remove item',\n      stopped: 'Print stopped',\n      stopFailed: 'Failed to stop print',\n      released: 'Print released to queue',\n      startFailed: 'Failed to start print',\n      reorderFailed: 'Failed to reorder queue',\n      historyCleared: 'Cleared {{count}} history item(s)',\n      clearHistoryFailed: 'Failed to clear history',\n      updateFailed: 'Failed to update items',\n      bulkCancelled: 'Cancelled {{count}} item(s)',\n      bulkCancelFailed: 'Failed to cancel items',\n    },\n    // Timeline view\n    timeline: {\n      listView: 'List',\n      timelineView: 'Timeline',\n      unassigned: 'Unassigned',\n      noData: 'No scheduled prints for this day',\n      allDoneBy: 'All prints estimated done by {{time}}',\n      staged: 'Staged',\n      filterAll: 'Show All',\n      filterPrinting: 'Printing',\n      filterQueued: 'Queued',\n      time: {\n        anyMoment: 'any moment',\n        minutesLeft: '{{minutes}}m left',\n        hoursLeft: '{{hours}}h left',\n        hoursMinutesLeft: '{{hours}}h {{minutes}}m left',\n      },\n      day: {\n        previous: 'Previous day',\n        next: 'Next day',\n        today: 'Today',\n      },\n    },\n    // Permissions\n    permissions: {\n      noStopPrint: 'You do not have permission to stop prints',\n      noStartPrint: 'You do not have permission to start prints',\n      noEdit: 'You do not have permission to edit this queue item',\n      noCancel: 'You do not have permission to cancel this queue item',\n      noRequeue: 'You do not have permission to re-queue items',\n      noRemove: 'You do not have permission to remove this queue item',\n      noClearHistory: 'You do not have permission to clear all history',\n      noEditItems: 'You do not have permission to edit queue items',\n      noCancelItems: 'You do not have permission to cancel queue items',\n    },\n  },\n\n  backgroundDispatch: {\n    unknownFile: 'Unknown file',\n    unknownPrinter: 'Unknown printer',\n    startingPrints: 'Starting prints',\n    progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',\n    expandDetails: 'Expand dispatch details',\n    collapseDetails: 'Collapse dispatch details',\n    dismissToast: 'Dismiss dispatch toast',\n    cancelDispatchJob: 'Cancel dispatch job',\n    cancel: 'Cancel',\n    cancelling: 'Cancelling…',\n    status: {\n      dispatched: 'Dispatched',\n      processing: 'Processing',\n      completed: 'Completed',\n      failed: 'Failed',\n      cancelled: 'Cancelled',\n    },\n    toast: {\n      cancellingUpload: 'Cancelling upload...',\n      cancelled: 'Dispatch cancelled',\n      cancelFailed: 'Failed to cancel dispatch',\n      completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',\n      completeSuccess: 'Background dispatch complete: {{completed}} succeeded',\n      printStartedRemaining: '{{completed}} print(s) started, {{remaining}} more sending...',\n    },\n  },\n\n  // Statistics page\n  stats: {\n    title: 'Dashboard',\n    subtitle: 'Drag widgets to rearrange. Click the eye icon to hide.',\n    overview: 'Overview',\n    totalPrints: 'Total Prints',\n    successRate: 'Success Rate',\n    totalPrintTime: 'Total Print Time',\n    printTime: 'Print Time',\n    totalFilament: 'Total Filament Used',\n    filamentUsed: 'Filament Used',\n    filamentCost: 'Filament Cost',\n    totalCost: 'Total Cost',\n    energyUsed: 'Energy Used',\n    energyCost: 'Energy Cost',\n    energyWarmingUpTooltip: 'Energy tracking is still collecting hourly snapshots. Date-range totals will become accurate once at least one snapshot exists before the selected range. Early values may undercount.',\n    averagePrintTime: 'Average Print Time',\n    printsPerDay: 'Prints per Day',\n    byPrinter: 'By Printer',\n    printsByPrinter: 'Prints by Printer',\n    byMaterial: 'By Material',\n    byMonth: 'By Month',\n    last7Days: 'Last 7 Days',\n    last30Days: 'Last 30 Days',\n    last90Days: 'Last 90 Days',\n    allTime: 'All Time',\n    // Widgets\n    quickStats: 'Quick Stats',\n    printActivity: 'Print Activity',\n    filamentTypes: 'Filament Types',\n    filamentTrends: 'Filament Trends',\n    failureAnalysis: 'Failure Analysis',\n    timeAccuracy: 'Time Accuracy',\n    successful: 'Successful:',\n    failed: 'Failed:',\n    perfectEstimate: '100% = perfect estimate',\n    noTimeAccuracyData: 'No time accuracy data yet',\n    noFilamentData: 'No filament data available',\n    noPrinterData: 'No printer data available',\n    noPrintData: 'No print data available',\n    noPrintDataLast30Days: 'No print data in the last 30 days',\n    failureReasons: 'Failure Reasons',\n    topFailureReasons: 'Top Failure Reasons',\n    failedPrintsCount: '{{failed}} / {{total}} prints failed',\n    lastWeekRate: 'Last week: {{rate}}%',\n    // Actions\n    resetLayout: 'Reset Layout',\n    recalculateCosts: 'Recalculate Costs',\n    recalculateCostsHint: 'Recalculate all archive costs using current filament prices',\n    exportStats: 'Export Stats',\n    exportAsCsv: 'Export as CSV',\n    exportAsExcel: 'Export as Excel',\n    hiddenCount: '{{count}} Hidden',\n    // Toast\n    exportDownloaded: 'Export downloaded',\n    exportFailed: 'Export failed',\n    layoutReset: 'Layout reset',\n    recalculatedCosts: 'Recalculated costs for {{count}} archives',\n    recalculateFailed: 'Failed to recalculate costs',\n    // Loading\n    loadingStats: 'Loading statistics...',\n    // Permissions\n    noPermissionResetLayout: 'You do not have permission to reset layout',\n    noPermissionRecalculate: 'You do not have permission to recalculate costs',\n    noPrintDataInRange: 'No print data in selected range',\n    periodFilament: 'Period Filament',\n    periodCost: 'Period Cost',\n    avgPerPrint: 'Avg per Print',\n    usageOverTime: 'Usage Over Time',\n    filamentByWeight: 'Weight',\n    printDuration: 'Print Duration',\n    printerUtilization: 'Printer Utilization',\n    filamentSuccess: 'Success by Material',\n    printHabits: 'Print Habits',\n    printTimeOfDay: 'Print Time of Day',\n    colorDistribution: 'Color Distribution',\n    noColorData: 'No color data available',\n    records: 'Records',\n    longestPrint: 'Longest Print',\n    heaviestPrint: 'Heaviest Print',\n    mostExpensivePrint: 'Most Expensive',\n    busiestDay: 'Busiest Day',\n    successStreak: 'Success Streak',\n    streakPrint: 'consecutive print',\n    streakPrints: '{{count}} consecutive prints',\n    printerStats: 'Printer Stats',\n    hours: 'hours',\n    avgPrints: 'Avg. prints',\n    noArchiveData: 'No print data available',\n    filamentByTime: 'Time',\n    avgWeight: 'Avg. weight',\n    avgTime: 'Avg. time',\n    filamentByPrints: 'Prints',\n    timeframe: {\n      'today': 'Today',\n      'this-week': 'This Week',\n      'this-month': 'This Month',\n      'last-7': 'Last 7 Days',\n      'last-30': 'Last 30 Days',\n      'last-90': 'Last 90 Days',\n      'this-year': 'This Year',\n      'all-time': 'All Time',\n      'custom': 'Custom Range',\n      from: 'From',\n      to: 'To',\n    },\n    // User filter\n    allUsers: 'All Users',\n    noUser: 'No User (System)',\n    filterByUser: 'Filter by User',\n  },\n\n  // Maintenance page\n  maintenance: {\n    title: 'Maintenance',\n    overview: 'Overview',\n    allOk: 'All maintenance up to date',\n    dueCount: '{{count}} item due',\n    dueCount_plural: '{{count}} items due',\n    warningCount: '{{count}} warning',\n    warningCount_plural: '{{count}} warnings',\n    totalPrintTime: 'Total Print Time',\n    nextMaintenance: 'Next Maintenance',\n    nothingDue: 'Nothing due',\n    tasks: 'Tasks',\n    lastPerformed: 'Last performed',\n    interval: 'Interval',\n    hoursRemaining: '{{hours}}h remaining',\n    hoursOverdue: '{{hours}}h overdue',\n    markDone: 'Mark as Done',\n    performMaintenance: 'Perform Maintenance',\n    history: 'History',\n    noHistory: 'No maintenance history',\n    editPrintHours: 'Edit Print Hours',\n    currentHours: 'Current Hours',\n    // Tabs\n    statusTab: 'Status',\n    settingsTab: 'Settings',\n    // Status\n    overdueCount: '{{count}} overdue',\n    dueSoonCount: '{{count}} due soon',\n    dueSoon: 'Due soon',\n    allGood: 'All good',\n    overdueBy: 'Overdue by {{duration}}',\n    dueIn: 'Due in {{duration}}',\n    timeLeft: '{{duration}} left',\n    // Duration formats\n    day: '1 day',\n    days: '{{count}} days',\n    week: '1 week',\n    weeks: '{{count}} weeks',\n    month: '1 month',\n    months: '{{count}} months',\n    year: '1 year',\n    // Settings\n    maintenanceTypes: 'Maintenance Types',\n    maintenanceTypesDescription: 'System types and your custom maintenance tasks',\n    addCustomType: 'Add Custom Type',\n    restoreDefaults: 'Restore Default Tasks',\n    intervalType: 'Interval Type',\n    intervalValue: 'Interval ({{type}})',\n    icon: 'Icon',\n    documentationLink: 'Documentation Link (optional)',\n    assignToPrinters: 'Assign to Printers',\n    selectAtLeastOnePrinter: 'Select at least one printer',\n    addType: 'Add Type',\n    custom: 'Custom',\n    printHours: 'Print Hours',\n    calendarDays: 'Calendar Days',\n    exampleName: 'e.g., Replace HEPA Filter',\n    viewDocumentation: 'View documentation',\n    timeBasedInterval: 'Time-based interval',\n    // Interval overrides\n    intervalOverrides: 'Interval Overrides',\n    intervalOverridesDescription: 'Customize intervals for specific printers',\n    // Printer assignment\n    assignedToPrinters: 'Assigned to printers:',\n    noPrintersAssigned: 'No printers assigned',\n    addPrinterShort: 'Add:',\n    printersAssignedClick: '{{count}} printer(s) assigned - click to manage',\n    removeFromPrinter: 'Remove from this printer',\n    // Types\n    types: {\n      lubricateCarbonRods: 'Lubricate Carbon Rods',\n      lubricateRails: 'Lubricate Linear Rails',\n      cleanNozzle: 'Clean Nozzle/Hotend',\n      checkBelts: 'Check Belt Tension',\n      cleanBuildPlate: 'Clean Build Plate',\n      checkExtruder: 'Check Extruder Gears',\n      checkCooling: 'Check Cooling Fans',\n      generalInspection: 'General Inspection',\n      cleanCarbonRods: 'Clean Carbon Rods',\n      lubricateSteelRods: 'Lubricate Steel Rods',\n      cleanSteelRods: 'Clean Steel Rods',\n      cleanLinearRails: 'Clean Linear Rails',\n      checkPtfeTube: 'Check PTFE Tube',\n      replaceHepaFilter: 'Replace HEPA Filter',\n      replaceCarbonFilter: 'Replace Carbon Filter',\n      lubricateLeftNozzleRail: 'Lubricate Left Nozzle Rail',\n    },\n    // Toast\n    maintenanceComplete: 'Maintenance marked as complete',\n    typeUpdated: 'Maintenance type updated',\n    typeDeleted: 'Maintenance type deleted',\n    defaultsRestored: 'Restored {{count}} default task(s)',\n    printHoursUpdated: 'Print hours updated',\n    printerAssigned: 'Printer assigned',\n    printerRemoved: 'Printer removed',\n    // Confirmation\n    deleteTypeConfirm: 'Delete \"{{name}}\"?',\n    deleteSystemTypeTitle: 'Delete default maintenance task?',\n    deleteSystemTypeMessage: 'Are you sure you want to delete the default maintenance task \"{{name}}\"?',\n    // Permissions\n    noPermissionUpdate: 'You do not have permission to update maintenance items',\n    noPermissionPerform: 'You do not have permission to perform maintenance',\n    noPermissionEditTypes: 'You do not have permission to edit maintenance types',\n    noPermissionDeleteTypes: 'You do not have permission to delete maintenance types',\n    noPermissionEditHours: 'You do not have permission to edit print hours',\n    noPermissionRemovePrinter: 'You do not have permission to remove printer assignments',\n    noPermissionAssignPrinter: 'You do not have permission to assign printers',\n    noPermissionEditIntervals: 'You do not have permission to edit intervals',\n    // Configure link\n    configureSettings: 'Configure maintenance types and intervals',\n  },\n\n  // Settings page\n  settings: {\n    title: 'Settings',\n    general: 'General',\n    // Tab names\n    tabs: {\n      general: 'General',\n      smartPlugs: 'Smart Plugs',\n      notifications: 'Notifications',\n      queue: 'Workflow',\n      filament: 'Filament',\n      network: 'Network',\n      apiKeys: 'API Keys',\n      virtualPrinter: 'Virtual Printer',\n      spoolbuddy: 'SpoolBuddy',\n      failureDetection: 'Failure Detection',\n      users: 'Authentication',\n      backup: 'Backup',\n      emailAuth: 'Email Authentication',\n      ldap: 'LDAP',\n      twoFa: 'Two-Factor Auth',\n      oidc: 'SSO / OIDC',\n    },\n    spoolbuddy: {\n      infoTitle: 'SpoolBuddy devices',\n      infoBody: 'SpoolBuddy kiosks register themselves automatically via heartbeat. Unregister a device here if it is no longer in use or if a stale duplicate was left behind by a daemon crash.',\n      duplicatesTitle: '{{count}} devices registered',\n      duplicatesBody: 'Only the first registered device is used by the kiosk UI. If one of these is a stale duplicate from a crash, unregister it — an online device will re-register itself on its next heartbeat.',\n      empty: 'No SpoolBuddy devices registered yet.',\n      online: 'Online',\n      offline: 'Offline',\n      unregister: 'Unregister',\n      unregisterSuccess: 'Device unregistered',\n      unregisterError: 'Failed to unregister device',\n      confirmTitle: 'Unregister SpoolBuddy device?',\n      confirmBody: 'This will remove \"{{hostname}}\" ({{deviceId}}) from the database. If the device is online, it will re-register itself on its next heartbeat.',\n      ipAddress: 'IP address',\n      firmware: 'Firmware',\n      lastSeen: 'Last seen',\n      daemonUptime: 'Daemon uptime',\n      systemUptime: 'System uptime',\n      never: 'never',\n      nfc: 'NFC',\n      scale: 'Scale',\n      cpuTemp: 'CPU temp',\n      memory: 'Memory',\n      disk: 'Disk',\n      // Device actions\n      update: 'Update',\n      updateConfirmTitle: 'Update Spoolbuddy daemon?',\n      updateConfirmBody: 'Trigger a software update on \"{{hostname}}\"? The daemon will restart once the update is applied.',\n      restartBrowser: 'Restart Browser',\n      restartBrowserConfirmTitle: 'Restart kiosk browser?',\n      restartBrowserConfirmBody: 'Restart the kiosk browser on \"{{hostname}}\"? The display will blank briefly.',\n      restartDaemon: 'Restart Daemon',\n      restartDaemonConfirmTitle: 'Restart Spoolbuddy daemon?',\n      restartDaemonConfirmBody: 'Restart the Spoolbuddy daemon on \"{{hostname}}\"? The device will go offline for a few seconds.',\n      reboot: 'Reboot',\n      rebootConfirmTitle: 'Reboot device?',\n      rebootConfirmBody: 'Reboot \"{{hostname}}\"? The device will be offline for around a minute.',\n      shutdown: 'Shutdown',\n      shutdownConfirmTitle: 'Shutdown device?',\n      shutdownConfirmBody: 'Shutdown \"{{hostname}}\"? You will need physical access to power it back on.',\n      commandConfirm: 'Confirm',\n      commandQueued: 'Command queued',\n      commandError: 'Failed to send command',\n    },\n    // LDAP settings\n    ldap: {\n      title: 'LDAP Authentication',\n      enabledDesc: 'LDAP authentication is enabled',\n      disabledDesc: 'LDAP authentication is disabled',\n      disabledHint: 'Configure and save LDAP settings below, then enable.',\n      enabled: 'LDAP authentication enabled',\n      disabled: 'LDAP authentication disabled',\n      feature1: 'Users can login with LDAP credentials',\n      feature2: 'Local admin account remains as fallback',\n      feature3: 'LDAP groups are mapped to BamBuddy groups on login',\n      serverConfig: 'LDAP Server Configuration',\n      serverUrl: 'Server URL',\n      serverUrlHint: 'Use ldaps:// for SSL or ldap:// with StartTLS',\n      security: 'Security',\n      securityHint: 'StartTLS upgrades a plain connection to TLS. LDAPS uses TLS from the start.',\n      bindDn: 'Bind DN (Service Account)',\n      bindPassword: 'Bind Password',\n      searchBase: 'Search Base DN',\n      userFilter: 'User Search Filter',\n      userFilterHint: '{username} is replaced with the login username. Use (uid={username}) for OpenLDAP.',\n      autoProvision: 'Auto-provision users',\n      autoProvisionHint: 'Automatically create a BamBuddy account on first LDAP login',\n      defaultGroup: 'Default group',\n      defaultGroupNone: '— None (no fallback) —',\n      defaultGroupHint: 'Fallback group assigned when an LDAP user authenticates but is not listed in any mapped LDAP group. Leave empty to leave unmapped users without permissions.',\n      groupMapping: 'Group Mapping (JSON)',\n      groupMappingHint: 'Map LDAP group DNs to BamBuddy groups. Available groups: ',\n      testConnection: 'Test Connection',\n      settingsSaved: 'LDAP settings saved',\n      errors: {\n        serverRequired: 'LDAP server URL is required',\n        searchBaseRequired: 'Search base DN is required',\n        enableAuthFirst: 'Enable authentication first',\n        configureLdapFirst: 'Save LDAP settings first',\n      },\n    },\n    // Email settings\n    email: {\n      smtpSettings: 'SMTP Configuration',\n      smtpHost: 'SMTP Server',\n      smtpPort: 'SMTP Port',\n      security: 'Security',\n      authentication: 'Authentication',\n      username: 'Username',\n      password: 'Password',\n      fromEmail: 'From Email',\n      fromName: 'From Name',\n      testConnection: 'Test SMTP Connection',\n      testRecipient: 'Test Recipient Email',\n      sendTest: 'Send Test Email',\n      sending: 'Sending...',\n      save: 'Save Settings',\n      saving: 'Saving...',\n      advancedAuth: 'Advanced Authentication',\n      advancedAuthEnabled: 'Advanced Authentication is enabled',\n      advancedAuthEnabledDesc: 'Email-based user management features are active. New users will receive auto-generated passwords via email, and users can reset their passwords through the forgot password feature.',\n      advancedAuthDisabled: 'Advanced Authentication is disabled',\n      advancedAuthDisabledDesc: 'Enable advanced authentication to activate email-based features for user management.',\n      enable: 'Enable',\n      disable: 'Disable',\n      feature1: 'Passwords are auto-generated and emailed to new users',\n      feature2: 'Users can login with username or email',\n      feature3: 'Forgot password feature is available',\n      feature4: 'Admins can reset user passwords via email',\n      // Error messages\n      errors: {\n        requiredFields: 'Please fill in all required fields',\n        usernameRequired: 'Username is required when authentication is enabled',\n        enterTestEmail: 'Please enter a test email address',\n        smtpServerAndEmail: 'Please fill in SMTP Server and From Email before testing',\n        usernamePasswordRequired: 'Username and Password are required when authentication is enabled',\n        configureSmtpFirst: 'Please configure and test SMTP settings first',\n        enableAuthFirst: 'Please enable authentication first to use email-based features.',\n      },\n      // Success messages\n      success: {\n        settingsSaved: 'SMTP settings saved successfully',\n      },\n      // Security options\n      securityOptions: {\n        starttls: 'STARTTLS (Port 587)',\n        ssl: 'SSL/TLS (Port 465)',\n        none: 'None (Port 25)',\n      },\n      // Authentication options\n      authOptions: {\n        enabled: 'Enabled',\n        disabled: 'Disabled',\n      },\n    },\n    appearance: 'Appearance',\n    notifications: 'Notifications',\n    smartPlugs: 'Smart Plugs',\n    spoolman: 'Spoolman',\n    updates: 'Updates',\n    language: 'Language',\n    languageDescription: 'Select your preferred language',\n    theme: 'Theme',\n    themeLight: 'Light',\n    themeDark: 'Dark',\n    themeSystem: 'System',\n    defaultView: 'Default View',\n    defaultViewDescription: 'Page to show when opening the app',\n    checkForUpdates: 'Check for Updates',\n    autoUpdate: 'Auto Update',\n    currentVersion: 'Current Version',\n    latestVersion: 'Latest Version',\n    upToDate: 'You are up to date',\n    updateAvailable: 'Update available',\n    // Notifications\n    notificationLanguage: 'Notification Language',\n    notificationLanguageDescription: 'Language for push notifications',\n    bedCooledThreshold: 'Bed Cooled Threshold',\n    bedCooledThresholdDescription: 'Temperature below which the bed is considered cooled after a print',\n    userNotificationsEnabled: 'User Notifications',\n    userNotificationsEnabledDescription: 'Enable the user notifications menu and email notifications for print job events. Requires Advanced Authentication.',\n    userNotificationsDisabledHint: 'Enable Advanced Authentication to use user notifications.',\n    notificationProviders: 'Notification Providers',\n    addProvider: 'Add Provider',\n    editProvider: 'Edit Provider',\n    providerType: 'Provider Type',\n    testNotification: 'Test Notification',\n    testSuccess: 'Test notification sent successfully',\n    testFailed: 'Failed to send test notification',\n    quietHours: 'Quiet Hours',\n    quietHoursDescription: 'Do not disturb during these hours',\n    quietHoursStart: 'Start',\n    quietHoursEnd: 'End',\n    events: {\n      title: 'Notification Events',\n      printStart: 'Print Started',\n      printComplete: 'Print Completed',\n      printFailed: 'Print Failed',\n      printStopped: 'Print Stopped',\n      printProgress: 'Progress Milestones',\n      printProgressDescription: 'Notify at 25%, 50%, 75%',\n      printerOffline: 'Printer Offline',\n      printerError: 'Printer Error',\n      filamentLow: 'Low Filament',\n      maintenanceDue: 'Maintenance Due',\n      maintenanceDueDescription: 'Notify when maintenance is needed',\n    },\n    // Smart Plugs\n    smartPlug: {\n      title: 'Smart Plugs',\n      add: 'Add Smart Plug',\n      edit: 'Edit Smart Plug',\n      name: 'Name',\n      ipAddress: 'IP Address',\n      linkedPrinter: 'Linked Printer',\n      autoOn: 'Auto Power On',\n      autoOnDescription: 'Turn on when print starts',\n      autoOff: 'Auto Power Off',\n      autoOffDescription: 'Turn off after print completes',\n      offDelay: 'Off Delay',\n      offDelayMinutes: 'Minutes after print',\n      offDelayTemp: 'When nozzle below temperature',\n      currentState: 'Current State',\n      turnOn: 'Turn On',\n      turnOff: 'Turn Off',\n    },\n    // Filament Tracking Mode\n    filamentTracking: 'Filament Tracking',\n    filamentTrackingDesc: 'Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.',\n    filamentChecks: 'Filament checks',\n    disableFilamentWarnings: 'Disable filament warnings',\n    disableFilamentWarningsDesc: 'Don\\'t show warnings about insufficient filament when printing or queueing',\n    preferLowestFilament: 'Prefer lowest remaining filament',\n    preferLowestFilamentDesc: 'When multiple spools match, use the one with the least filament remaining',\n    trackingModeBuiltIn: 'Built-in Inventory',\n    trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',\n    trackingModeSpoolmanDesc: 'External filament management server',\n    builtInFeatureRfid: 'Automatically detects Bambu Lab RFID spools in AMS',\n    builtInFeatureUsage: 'Tracks filament consumption per print',\n    builtInFeatureCatalog: 'Manage spools, colors, and K-factor profiles',\n    builtInFeatureThirdParty: 'Third-party spools can be assigned to inventory spools',\n    amsSyncButton: 'Sync Weights from AMS',\n    amsSyncTitle: 'Sync Spool Weights from AMS',\n    amsSyncMessage: 'This will overwrite all inventory spool weights with the current AMS remain% values from connected printers. Use this to recover from corrupted weight data. Printers must be online.',\n    amsSyncing: 'Syncing...',\n    amsSyncSuccess: '{{synced}} spool(s) synced, {{skipped}} skipped',\n    amsSyncError: 'Failed to sync weights from AMS',\n    // Spoolman settings\n    spoolmanUrl: 'Spoolman URL',\n    spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',\n    spoolmanConnected: 'Connected',\n    spoolmanDisconnected: 'Disconnected',\n    status: 'Status',\n    connect: 'Connect',\n    disconnect: 'Disconnect',\n    howSyncWorks: 'How Sync Works',\n    syncInfoRfidOnly: 'Only official Bambu Lab spools with RFID are synced',\n    syncInfoAutoCreate: 'New spools are auto-created in Spoolman on first sync',\n    syncInfoThirdPartySkipped: 'Non-Bambu Lab spools (third-party, refilled) are skipped',\n    linkingExistingSpools: 'Linking Existing Spools',\n    linkingExistingSpoolsDesc: 'To link existing Spoolman spools to your AMS, hover over an AMS slot and click \"Link to Spoolman\".',\n    syncMode: 'Sync Mode',\n    syncModeAuto: 'Automatic',\n    syncModeManual: 'Manual Only',\n    syncModeAutoDesc: 'AMS data syncs automatically when changes are detected',\n    syncModeManualDesc: 'Only sync when manually triggered',\n    syncAmsData: 'Sync AMS Data',\n    syncAmsDataDesc: 'Manually sync printer AMS data to Spoolman',\n    allPrinters: 'All Printers',\n    // Default printer\n    noDefaultPrinter: 'No default (ask each time)',\n    // Sidebar\n    sidebarOrder: 'Sidebar order',\n    // Camera\n    saveThumbnails: 'Save thumbnails',\n    captureFinishPhoto: 'Capture finish photo',\n    noPrintersConfigured: 'No printers configured',\n    // Archive settings\n    archiveMode: {\n      always: 'Always create archive entry',\n      never: 'Never create archive entry',\n      ask: 'Ask each time',\n    },\n    // Updates\n    checkForUpdatesLabel: 'Check for updates',\n    checkPrinterFirmware: 'Check printer firmware',\n    includeBetaUpdates: 'Include beta versions',\n    includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',\n    // Queue\n    enableRetry: 'Enable retry',\n    // Home Assistant\n    homeAssistantDescription: 'Control smart plugs via Home Assistant',\n    environmentManagedLabel: '(Environment Managed)',\n    autoEnabledViaEnv: 'Automatically enabled via environment variables',\n    urlFromEnvReadOnly: 'Value set by HA_URL environment variable (read-only)',\n    tokenFromEnvReadOnly: 'Value set by HA_TOKEN environment variable (read-only)',\n    // MQTT\n    mqttConnectedTo: 'Connected to',\n    // Prometheus\n    prometheusDescription: 'Expose printer data in Prometheus format',\n    // Smart plugs empty state\n    noSmartPlugsTitle: 'No smart plugs configured',\n    noSmartPlugsDescription: 'Add a Tasmota-based smart plug to track energy usage and automate power control.',\n    // Notifications empty state\n    noProvidersTitle: 'No providers configured',\n    noProvidersDescription: 'Add a provider to receive alerts.',\n    noTemplatesAvailable: 'No templates available. Restart the backend to seed default templates.',\n    // API permissions\n    apiPermissionView: 'View printer status and queue',\n    apiPermissionEdit: 'Add and remove items from print queue',\n    // API keys\n    apiKeysEmptyTitle: 'No API keys',\n    apiKeysEmptyDescription: 'Create an API key to integrate with external services.',\n    // Users\n    noUsersFound: 'No users found',\n    noGroupsFound: 'No groups found',\n    noGroupsAvailable: 'No groups available',\n    passwordsDoNotMatch: 'Passwords do not match',\n    systemGroupWarning: 'System group names cannot be changed',\n    // Auth disabled\n    authDisabledTitle: 'Authentication is Disabled',\n    authDisabledFeature1: 'Require login to access the system',\n    authDisabledFeature2: 'Create multiple users with group-based permissions',\n    authDisabledFeature3: 'Control access with 50+ granular permissions',\n    // User deletion\n    userHasCreated: 'This user has created:',\n    userItemsQuestion: 'What would you like to do with these items?',\n    deleteUserConfirm: 'Are you sure you want to delete this user?',\n    actionCannotBeUndone: 'This action cannot be undone.',\n    // Smart plugs\n    addFirstSmartPlug: 'Add Your First Smart Plug',\n    // Notifications\n    providers: 'Providers',\n    log: 'Log',\n    testAll: 'Test All',\n    testResults: 'Test Results',\n    testPassedCount: '{{count}} passed',\n    testFailedCount: '{{count}} failed',\n    messageTemplates: 'Message Templates',\n    messageTemplatesDescription: 'Customize notification messages for each event.',\n    // API Keys section\n    apiKeys: 'API Keys',\n    apiKeysDescription: 'Create API keys for external integrations and webhooks.',\n    createKey: 'Create Key',\n    apiKeyCreated: 'API Key Created Successfully',\n    apiKeyCopyWarning: \"Copy this key now - it won't be shown again!\",\n    useInApiBrowser: 'Use in API Browser',\n    createNewApiKey: 'Create New API Key',\n    keyName: 'Key Name',\n    keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',\n    readStatus: 'Read Status',\n    readStatusDescription: 'View printer status and queue',\n    manageQueue: 'Manage Queue',\n    manageQueueDescription: 'Add and remove items from print queue',\n    controlPrinter: 'Control Printer',\n    controlPrinterDescription: 'Pause, resume, and stop prints',\n    unnamedKey: 'Unnamed Key',\n    lastUsed: 'Last used',\n    read: 'Read',\n    control: 'Control',\n    createFirstKey: 'Create Your First Key',\n    webhookEndpoints: 'Webhook Endpoints',\n    webhookApiKeyHint: 'Use your API key in the X-API-Key header.',\n    webhook: {\n      getAllStatus: 'Get all printer status',\n      getSpecificStatus: 'Get specific printer status',\n      addToQueue: 'Add to print queue',\n      pausePrint: 'Pause print',\n      resumePrint: 'Resume print',\n      stopPrint: 'Stop print',\n    },\n    apiBrowser: 'API Browser',\n    apiBrowserDescription: 'Explore and test all available API endpoints.',\n    apiKeyForTesting: 'API Key for Testing',\n    apiKeyPlaceholder: 'Paste your API key here to test authenticated endpoints...',\n    apiKeyHint: 'This key will be sent as X-API-Key header with requests.',\n    deleteApiKeyTitle: 'Delete API Key',\n    deleteApiKeyMessage: 'Are you sure you want to delete this API key? Any integrations using this key will stop working.',\n    deleteKey: 'Delete Key',\n    // Filament tab\n    amsDisplayThresholds: 'AMS Display Thresholds',\n    amsThresholdsDescription: 'Configure color thresholds for AMS humidity and temperature indicators.',\n    humidity: 'Humidity',\n    goodGreen: 'Good (green)',\n    fairOrange: 'Fair (orange)',\n    aboveFairBad: 'Above fair threshold shows as red (bad)',\n    fairAlsoDryingThreshold: 'This threshold is also used to trigger auto-drying when enabled',\n    temperature: 'Temperature',\n    goodBlue: 'Good (blue)',\n    aboveFairHot: 'Above fair threshold shows as red (hot)',\n    historyRetention: 'History Retention',\n    keepSensorHistory: 'Keep sensor history for',\n    historyRetentionDescription: 'Older humidity and temperature data will be automatically deleted',\n    defaultPrintOptions: 'Default Print Options',\n    defaultPrintOptionsDescription: 'Set default values for print options when starting new prints. These can be overridden per print in the print dialog.',\n    defaultBedLevelling: 'Bed Levelling',\n    defaultBedLevellingDesc: 'Auto-level bed before print',\n    defaultFlowCali: 'Flow Calibration',\n    defaultFlowCaliDesc: 'Calibrate extrusion flow',\n    defaultVibrationCali: 'Vibration Calibration',\n    defaultVibrationCaliDesc: 'Reduce ringing artifacts',\n    defaultLayerInspect: 'First Layer Inspection',\n    defaultLayerInspectDesc: 'AI inspection of first layer',\n    defaultTimelapse: 'Timelapse',\n    defaultTimelapseDesc: 'Record timelapse video',\n    staggeredStart: 'Staggered Start',\n    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',\n    plateClear: 'Plate-Clear Confirmation',\n    requirePlateClear: 'Require plate-clear confirmation',\n    requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the \"Mark plate as cleared\" button on printer cards.',\n    gcodeInjection: 'G-code Injection',\n    gcodeInjectionDescription: 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when \"Inject G-code\" is enabled on a queue item.',\n    gcodeInjectionNoPrinters: 'No printers found. Add printers to configure G-code snippets.',\n    gcodeStartLabel: 'Start G-code',\n    gcodeEndLabel: 'End G-code',\n    gcodeStartPlaceholder: 'G-code prepended before the print starts...',\n    gcodeEndPlaceholder: 'G-code appended after the print ends...',\n    staggerGroupSize: 'Group size',\n    staggerGroupSizeHelp: 'Printers to start simultaneously per group',\n    staggerInterval: 'Interval (minutes)',\n    staggerIntervalHelp: 'Delay between each group starting',\n    queueDrying: 'Queue Auto-Drying',\n    queueDryingDescription: 'Automatically dry AMS filament when printer is idle between queued prints. Uses humidity threshold above to trigger drying.',\n    queueDryingEnabled: 'Enable auto-drying',\n    queueDryingEnabledDescription: 'Start AMS drying automatically when printer is idle and humidity is above threshold',\n    queueDryingBlock: 'Wait for drying to complete',\n    queueDryingBlockDescription: 'Block the print queue until drying finishes. When off, prints take priority over drying.',\n    ambientDryingEnabled: 'Ambient drying',\n    ambientDryingEnabledDescription: 'Automatically dry filament on idle printers when humidity exceeds threshold, even without queued prints.',\n    dryingPresets: 'Drying Presets',\n    dryingPresetsDescription: 'Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.',\n    dryingFilament: 'Filament',\n    printModal: 'Print Modal',\n    expandCustomMapping: 'Expand custom mapping by default',\n    expandCustomMappingDescription: 'When printing to multiple printers, show per-printer AMS mapping expanded',\n    // User management\n    authentication: 'Authentication',\n    authEnabledDescription: 'Your instance is secured with user authentication',\n    authDisabledDescription: 'Enable to require login and manage user access',\n    authDisabledMessage: 'Enable authentication to create user accounts, manage permissions, and secure your Bambuddy instance.',\n    enableAuthentication: 'Enable Authentication',\n    currentUser: 'Current User',\n    changePassword: 'Change Password',\n    admin: 'Admin',\n    users: 'Users',\n    addUser: 'Add User',\n    groups: 'Groups',\n    addGroup: 'Add Group',\n    system: 'System',\n    noDescription: 'No description',\n    userCount: '{{count}} users',\n    permissionCount: '{{count}} permissions',\n    createUser: 'Create User',\n    username: 'Username',\n    enterUsername: 'Enter username',\n    password: 'Password',\n    enterPassword: 'Enter password (min 6 characters)',\n    confirmPassword: 'Confirm Password',\n    confirmPasswordPlaceholder: 'Confirm password',\n    // Title tooltips\n    viewReleaseOnGitHub: 'View release on GitHub',\n    turnAllPlugsOn: 'Turn all plugs on',\n    turnAllPlugsOff: 'Turn all plugs off',\n    // Modal: Clear logs\n    clearNotificationLogs: 'Clear Notification Logs',\n    clearLogsMessage: 'This will permanently delete all notification logs older than 30 days. This action cannot be undone.',\n    clearLogs: 'Clear Logs',\n    // Modal: Reset UI\n    resetUiPreferences: 'Reset UI Preferences',\n    resetUiPreferencesMessage: 'This will reset all UI preferences to defaults: sidebar order, theme, dashboard layout, view modes, and sorting preferences. Your printers, archives, and server settings will NOT be affected. The page will reload after clearing.',\n    resetPreferences: 'Reset Preferences',\n    // Modal: Delete group\n    deleteGroupTitle: 'Delete Group',\n    deleteGroupMessage: 'Are you sure you want to delete this group? Users in this group will lose these permissions.',\n    deleteGroup: 'Delete Group',\n    // Modal: Disable auth\n    disableAuthenticationTitle: 'Disable Authentication',\n    disableAuthenticationMessage: 'Are you sure you want to disable authentication? This will make your Bambuddy instance accessible without login. All users will remain in the database but authentication will be disabled.',\n    disableAuthentication: 'Disable Authentication',\n    // Additional settings\n    configureBambuddy: 'Configure Bambuddy',\n    systemDefault: 'System Default',\n    archiveSettings: 'Archive Settings',\n    newWindow: 'New Window',\n    embeddedOverlay: 'Embedded Overlay',\n    preferredSlicer: 'Preferred Slicer',\n    preferredSlicerDescription: 'Choose which slicer application to open files with',\n    externalCameras: 'External Cameras',\n    costTracking: 'Cost Tracking',\n    printsOnly: 'Prints Only',\n    totalConsumption: 'Total Consumption',\n    dataManagement: 'Data Management',\n    storageUsage: 'Storage Usage',\n    storageUsageDescription: 'Breakdown of data usage by category',\n    storageUsageTotal: 'Total',\n    storageUsageErrors: 'Errors',\n    storageUsageOtherBreakdown: 'Other (includes static assets, scripts, and configuration files)',\n    storageUsageSystem: 'System',\n    storageUsageData: 'Data',\n    storageUsageUnavailable: 'Storage usage information unavailable',\n    clearNotificationLogsDescription: 'Delete notification logs older than 30 days',\n    resetUiPreferencesDescription: 'Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.',\n    enableHomeAssistant: 'Enable Home Assistant',\n    enableMqtt: 'Enable MQTT',\n    useTls: 'Use TLS',\n    enableMetricsEndpoint: 'Enable Metrics Endpoint',\n    availableMetrics: 'Available Metrics',\n    editUser: 'Edit User',\n    deleteUserTitle: 'Delete User',\n    groupName: 'Group Name',\n    // Placeholders\n    leaveEmptyForAnonymous: 'Leave empty for anonymous',\n    leaveEmptyForNoAuth: 'Leave empty for no authentication',\n    enterNewPassword: 'Enter new password',\n    confirmNewPassword: 'Confirm new password',\n    enterGroupName: 'Enter group name',\n    enterDescriptionOptional: 'Enter description (optional)',\n    enterCurrentPassword: 'Enter current password',\n    enterNewPasswordMin6: 'Enter new password (min 6 characters)',\n    toast: {\n      keyCopied: 'Key copied to clipboard',\n      copyFailed: 'Failed to copy key',\n      keyAddedToBrowser: 'Key added to API Browser',\n      clearLogsFailed: 'Failed to clear logs',\n      uiPreferencesReset: 'UI preferences reset. Refreshing...',\n      authDisabled: 'Authentication disabled successfully',\n      authDisableFailed: 'Failed to disable authentication',\n      apiKeyCreated: 'API key created',\n      apiKeyDeleted: 'API key deleted',\n      userCreated: 'User created successfully',\n      userUpdated: 'User updated successfully',\n      userDeleted: 'User deleted successfully',\n      groupCreated: 'Group created successfully',\n      groupUpdated: 'Group updated successfully',\n      groupDeleted: 'Group deleted successfully',\n      fillRequiredFields: 'Please fill in all required fields',\n      passwordsDoNotMatch: 'Passwords do not match',\n      passwordTooShort: 'Password must be at least 6 characters',\n      enterGroupName: 'Please enter a group name',\n      settingsSaved: 'Settings saved',\n      cameraSettingsSaved: 'Camera settings saved',\n      enterCameraUrl: 'Please enter a camera URL',\n      passwordChanged: 'Password changed successfully',\n      connectionFailed: 'Connection failed',\n      testFailed: 'Test failed',\n      cameraConnected: 'Camera connected{{resolution}}',\n    },\n    testConnection: 'Test Connection',\n    catalog: {\n      spoolCatalog: 'Spool Catalog',\n      spoolCatalogDescription: 'Empty spool weights by brand/type. Used for automatic weight lookup when adding spools.',\n      searchCatalog: 'Search catalog...',\n      addNewEntry: 'Add New Entry',\n      namePlaceholder: 'Name (e.g., Bambu Lab - Plastic)',\n      weight: 'Weight',\n      type: 'Type',\n      default: 'Default',\n      custom: 'Custom',\n      noMatch: 'No entries match your search',\n      empty: 'No entries in catalog',\n      deleteEntry: 'Delete Entry',\n      deleteConfirm: 'Are you sure you want to delete \"{{name}}\"?',\n      resetCatalog: 'Reset Catalog',\n      resetConfirm: 'Reset catalog to defaults? This will remove all custom entries.',\n      loadFailed: 'Failed to load spool catalog',\n      nameWeightRequired: 'Name and weight are required',\n      entryAdded: 'Entry added',\n      addFailed: 'Failed to add entry',\n      entryUpdated: 'Entry updated',\n      updateFailed: 'Failed to update entry',\n      entryDeleted: 'Entry deleted',\n      deleteFailed: 'Failed to delete entry',\n      resetSuccess: 'Catalog reset to defaults',\n      resetFailed: 'Failed to reset catalog',\n      exported: 'Exported {{count}} entries',\n      imported: 'Imported {{added}} entries ({{skipped}} skipped)',\n      importFailed: 'Failed to import: invalid JSON format',\n      exportTooltip: 'Export catalog to JSON',\n      importTooltip: 'Import catalog from JSON',\n      resetTooltip: 'Reset to defaults',\n      selectedCount: '{{count}} selected',\n      deleteSelected: 'Delete Selected',\n      bulkDeleteConfirm: 'Are you sure you want to delete {{count}} entries?',\n      bulkDeleted: 'Deleted {{count}} entries',\n      bulkDeleteFailed: 'Failed to delete entries',\n    },\n    colorCatalog: {\n      title: 'Color Catalog',\n      description: 'Filament colors by manufacturer/material. Used for automatic color lookup when adding spools.',\n      searchColors: 'Search colors...',\n      allManufacturers: 'All manufacturers',\n      addNewColor: 'Add New Color',\n      manufacturer: 'Manufacturer',\n      colorName: 'Color Name',\n      hex: 'Hex',\n      materialOptional: 'Material (optional)',\n      showing: 'Showing {{filtered}} of {{total}} colors',\n      noMatch: 'No colors match your search',\n      empty: 'No colors in catalog',\n      deleteColor: 'Delete Color',\n      deleteConfirm: 'Are you sure you want to delete \"{{name}}\"?',\n      resetCatalog: 'Reset Color Catalog',\n      resetConfirm: 'Reset catalog to defaults? This will remove all custom colors.',\n      sync: 'Sync',\n      starting: 'Starting...',\n      syncTooltip: 'Sync from FilamentColors.xyz (2000+ colors, may take a minute)',\n      loadFailed: 'Failed to load color catalog',\n      fieldsRequired: 'Manufacturer, color name, and hex color are required',\n      colorAdded: 'Color added',\n      addFailed: 'Failed to add color',\n      colorUpdated: 'Color updated',\n      updateFailed: 'Failed to update color',\n      colorDeleted: 'Color deleted',\n      deleteFailed: 'Failed to delete color',\n      resetSuccess: 'Color catalog reset to defaults',\n      resetFailed: 'Failed to reset catalog',\n      syncUpToDate: 'Already up to date ({{count}} colors checked)',\n      syncComplete: 'Added {{added}} new colors ({{skipped}} already existed)',\n      syncError: 'Sync error',\n      syncFailed: 'Failed to sync from FilamentColors.xyz',\n      exported: 'Exported {{count}} colors',\n      imported: 'Imported {{added}} colors ({{skipped}} skipped)',\n      importFailed: 'Failed to import: invalid JSON format',\n      selectedCount: '{{count}} selected',\n      deleteSelected: 'Delete Selected',\n      bulkDeleteConfirm: 'Are you sure you want to delete {{count}} colors?',\n      bulkDeleted: 'Deleted {{count}} colors',\n      bulkDeleteFailed: 'Failed to delete colors',\n    },\n    // General tab\n    dateFormat: 'Date Format',\n    dateFormatUs: 'US (MM/DD/YYYY)',\n    dateFormatEu: 'EU (DD/MM/YYYY)',\n    dateFormatIso: 'ISO (YYYY-MM-DD)',\n    timeFormat: 'Time Format',\n    timeFormat12: '12-hour (3:30 PM)',\n    timeFormat24: '24-hour (15:30)',\n    defaultPrinter: 'Default Printer',\n    defaultPrinterDescription: 'Pre-select this printer for uploads, reprints, and other operations.',\n    slicerBambuStudio: 'Bambu Studio',\n    slicerOrcaSlicer: 'OrcaSlicer',\n    sidebarOrderDescription: 'Drag items in the sidebar to reorder. Reset to default order here.',\n    setDefault: 'Set Default',\n    sidebarOrderSetDefaultHint: 'Set default applies the current menu order to users who haven\\'t customized theirs.',\n    sidebarDefaultSet: 'Default menu order has been set.',\n    sidebarDefaultCleared: 'Default menu order cleared.',\n    sidebarDefaultFailed: 'Failed to set default menu order.',\n    reset: 'Reset',\n    // Appearance\n    darkMode: 'Dark Mode',\n    lightMode: 'Light Mode',\n    active: '(active)',\n    background: 'Background',\n    accent: 'Accent',\n    style: 'Style',\n    bgNeutral: 'Neutral',\n    bgWarm: 'Warm',\n    bgCool: 'Cool',\n    bgOled: 'OLED Black',\n    bgSlate: 'Slate Blue',\n    bgForest: 'Forest Green',\n    accentGreen: 'Green',\n    accentTeal: 'Teal',\n    accentBlue: 'Blue',\n    accentOrange: 'Orange',\n    accentPurple: 'Purple',\n    accentRed: 'Red',\n    styleClassic: 'Classic',\n    styleGlow: 'Glow',\n    styleVibrant: 'Vibrant',\n    themeToggleHint: 'Toggle between dark and light mode using the sun/moon icon in the sidebar.',\n    // Archive\n    autoArchivePrints: 'Auto-archive prints',\n    autoArchiveDescription: 'Automatically save 3MF files when prints complete',\n    saveThumbnailsDescription: 'Extract and save preview images from 3MF files',\n    captureFinishPhotoDescription: 'Take a photo from printer camera when print completes',\n    ffmpegNotInstalled: 'ffmpeg not installed',\n    ffmpegRequired: 'Camera capture requires ffmpeg. Install it via <brew>brew install ffmpeg</brew> (macOS) or <apt>apt install ffmpeg</apt> (Linux).',\n    // Camera\n    camera: 'Camera',\n    cameraViewMode: 'Camera View Mode',\n    cameraOverlayDescription: 'Camera opens in a resizable overlay on the main screen',\n    cameraWindowDescription: 'Camera opens in a separate browser window',\n    externalCamerasDescription: 'Configure external cameras to replace the built-in printer camera. Supports MJPEG streams, RTSP, HTTP snapshots, and USB cameras (V4L2). When enabled, the external camera is used for live view and finish photos.',\n    cameraPlaceholderUsb: 'Device path (/dev/video0)',\n    cameraPlaceholderUrl: 'Camera URL (rtsp://... or http://...)',\n    cameraTypeMjpeg: 'MJPEG Stream',\n    cameraTypeRtsp: 'RTSP Stream',\n    cameraTypeSnapshot: 'HTTP Snapshot',\n    cameraTypeUsb: 'USB Camera (V4L2)',\n    cameraRotation: 'Rotation',\n    test: 'Test',\n    connected: 'Connected',\n    disconnected: 'Disconnected',\n    // Cost tracking\n    currency: 'Currency',\n    defaultFilamentCost: 'Default filament cost (per kg)',\n    electricityCost: 'Electricity cost per kWh',\n    energyDisplayMode: 'Energy display mode',\n    energyModePrintDescription: 'Dashboard shows sum of energy used during prints',\n    energyModeTotalDescription: 'Dashboard shows lifetime energy from smart plugs',\n    // File Manager\n    fileManager: 'File Manager',\n    createArchiveEntry: 'Create Archive Entry When Printing',\n    createArchiveEntryDescription: 'When printing from File Manager, optionally create an archive entry',\n    lowDiskSpaceWarning: 'Low Disk Space Warning',\n    lowDiskSpaceDescription: 'Show warning when free disk space falls below this threshold',\n    // Updates\n    printerFirmware: 'Printer Firmware',\n    checkFirmwareDescription: 'Check for printer firmware updates from Bambu Lab',\n    bambuddySoftware: 'Bambuddy Software',\n    autoCheckDescription: 'Automatically check for new versions on startup',\n    checkNow: 'Check now',\n    updateAvailableVersion: 'Update available: v{{version}}',\n    releaseNotes: 'Release Notes',\n    updateViaDocker: 'Update via Docker Compose:',\n    installUpdate: 'Install Update',\n    latestVersionRunning: \"You're running the latest version\",\n    failedToCheckUpdates: 'Failed to check for updates: {{error}}',\n    // Data Management\n    backupRestore: 'Backup & Restore',\n    backupRestoreDescription: 'Export/import settings and configure GitHub backup',\n    goToBackup: 'Go to Backup',\n    // Network tab\n    externalUrl: 'External URL',\n    externalUrlDescription: 'The external URL where Bambuddy is accessible. Used for notification images and external integrations.',\n    bambuddyUrl: 'Bambuddy URL',\n    externalUrlHint: 'Include protocol and port (e.g., http://192.168.1.100:8000)',\n    ftpRetry: 'FTP Retry',\n    ftpRetryDescription: 'Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.',\n    autoRetryDescription: 'Automatically retry failed FTP operations',\n    retryAttempts: 'Retry attempts',\n    retryDelay: 'Retry delay',\n    connectionTimeout: 'Connection timeout',\n    time_one: '{{count}} time',\n    time_other: '{{count}} times',\n    second_one: '{{count}} second',\n    second_other: '{{count}} seconds',\n    nSeconds: '{{count}} seconds',\n    increaseForWeakWifi: 'Increase for printers with weak WiFi',\n    // Home Assistant\n    homeAssistant: 'Home Assistant',\n    homeAssistantFullDescription: 'Connect to Home Assistant to control smart plugs via HA\\'s REST API. Supports switch, light, input_boolean, and script entities.',\n    homeAssistantUrl: 'Home Assistant URL',\n    longLivedAccessToken: 'Long-Lived Access Token',\n    haTokenHint: 'Create a token in HA: Profile → Long-Lived Access Tokens → Create Token',\n    connectionSuccessful: 'Connection Successful',\n    connectionFailed: 'Connection Failed',\n    haConnectionSuccess: 'Successfully connected to Home Assistant.',\n    haConnectionFailed: 'Failed to connect to Home Assistant.',\n    // MQTT\n    mqttPublishing: 'MQTT Publishing',\n    mqttDescription: 'Publish BamBuddy events to an external MQTT broker for integration with Node-RED, Home Assistant, and other automation systems.',\n    mqttEnableDescription: 'Publish events to external MQTT broker',\n    brokerHostname: 'Broker hostname',\n    port: 'Port',\n    usernameOptional: 'Username (optional)',\n    passwordOptional: 'Password (optional)',\n    topicPrefix: 'Topic prefix',\n    topicPrefixHint: 'Topics will be: {{prefix}}/printers/<serial>/status, etc.',\n    // Prometheus\n    prometheusMetrics: 'Prometheus Metrics',\n    prometheusEndpointDescription: 'Expose printer metrics at <code>/api/v1/metrics</code> for Prometheus/Grafana monitoring.',\n    bearerTokenOptional: 'Bearer Token (optional)',\n    bearerTokenHint: 'If set, requests must include <code>Authorization: Bearer <token></code>',\n    metricsConnectionStatus: 'Connection status',\n    metricsPrinterState: 'Printer state (idle/printing/etc)',\n    metricsPrintProgress: 'Print progress 0-100%',\n    metricsBedTemp: 'Bed temperature',\n    metricsNozzleTemp: 'Nozzle temperature',\n    metricsPrintsTotal: 'Total prints by result',\n    metricsMore: '...and more (layers, fans, queue, filament usage)',\n    // Smart Plugs\n    smartPlugsDescription: 'Connect smart plugs (Tasmota or Home Assistant) to automate power control and track energy usage for your printers.',\n    allOn: 'All On',\n    allOff: 'All Off',\n    addSmartPlug: 'Add Smart Plug',\n    energySummary: 'Energy Summary',\n    currentPower: 'Current Power',\n    plugsOnline: '{{reachable}}/{{total}} plugs online',\n    today: 'Today',\n    yesterday: 'Yesterday',\n    total: 'Total',\n    enablePlugsForSummary: 'Enable plugs to see energy summary',\n    addNotificationProvider: 'Add',\n    // Users\n    systemBadge: '(System)',\n    creating: 'Creating...',\n    changing: 'Changing...',\n    deleteUserAndItems: 'Delete user AND their items',\n    deleteUserKeepItems: 'Delete user, keep items (become ownerless)',\n    ok: 'OK',\n\n    // 2FA settings\n    twoFa: {\n      totpTitle: 'Authenticator App (TOTP)',\n      totpDesc: 'Use an authenticator app like Google Authenticator, Aegis or Authy.',\n      emailOtpTitle: 'Email OTP',\n      emailOtpDesc: 'Send a one-time code to {{email}} when you log in.',\n      emailOtpNoEmail: 'Add an email address to your account to enable this method.',\n      addEmailFirst: 'Your account has no email address. Ask an admin to add one before enabling Email OTP.',\n      setupTotp: 'Set up Authenticator App',\n      setupAuthApp: 'Set up Authenticator App',\n      setupInstructions: 'Scan the QR code below with your authenticator app, then confirm with a code.',\n      manualEntry: 'Can\\'t scan? Enter this secret manually:',\n      scannedContinue: 'I\\'ve scanned the code — continue',\n      enterCodeToConfirm: 'Enter the 6-digit code from your authenticator app to confirm setup.',\n      activate: 'Activate',\n      disableTotp: 'Disable Authenticator',\n      disableConfirmHint: 'Enter a valid TOTP code or a backup code to disable the authenticator.',\n      totpDisabled: 'Authenticator app disabled.',\n      emailOtpEnabled: 'Email OTP enabled.',\n      emailOtpDisabled: 'Email OTP disabled.',\n      smtpRequired: 'Please configure and test SMTP settings first.',\n      invalidCode: 'Invalid code. Please try again.',\n      enableEmailOtp: 'Enable Email OTP',\n      disableEmailOtp: 'Disable Email OTP',\n      emailSetupEnterCode: 'A verification code has been sent to your email address. Enter it below to confirm you own this inbox.',\n      verifyAndEnable: 'Verify & Enable',\n      emailDisablePasswordHint: 'Enter your account password to confirm disabling email OTP.',\n      passwordPlaceholder: 'Enter your password',\n      backupCodesTitle: 'Save your backup codes',\n      backupCodesWarning: 'Save these codes somewhere safe. Each code can only be used once and they will not be shown again.',\n      backupCodesRemaining: '{{count}} backup codes remaining',\n      savedCodes: 'I\\'ve saved my codes',\n      regenBackup: 'Regenerate Backup Codes',\n      regenBackupHint: 'Enter your current TOTP code to generate 10 new backup codes. All existing backup codes will be invalidated.',\n      newBackupCodes: 'New backup codes',\n      linkedAccounts: 'Linked SSO Accounts',\n      linkedAccountsDesc: 'These external identity providers are linked to your account.',\n      oidcUnlinked: 'Account unlinked.',\n    },\n\n    // OIDC provider settings\n    oidc: {\n      title: 'SSO / OIDC Providers',\n      desc: 'Configure OpenID Connect providers to allow single sign-on via external identity providers.',\n      addProvider: 'Add Provider',\n      newProvider: 'New Provider',\n      empty: 'No OIDC providers configured yet.',\n      created: 'Provider created.',\n      updated: 'Provider updated.',\n      deleted: 'Provider deleted.',\n      deleteTitle: 'Delete Provider',\n      deleteMessage: 'Delete \"{{name}}\"? All linked user accounts will be disconnected.',\n      form: {\n        name: 'Display Name',\n        issuerUrl: 'Issuer URL',\n        clientId: 'Client ID',\n        clientSecret: 'Client Secret',\n        scopes: 'Scopes',\n        iconUrl: 'Icon URL (optional)',\n        enabled: 'Enabled',\n        autoCreate: 'Auto-create users',\n        autoCreateDesc: 'Automatically create a local account on first login.',\n        autoLink: 'Auto-link existing accounts',\n        autoLinkDesc: 'Link existing local accounts by matching email on first login.',\n        secretHint: 'leave blank to keep current',\n        secretPlaceholder: 'new secret',\n      },\n    },\n\n  },\n\n  // Notifications (for push notifications)\n  notification: {\n    printStarted: {\n      title: 'Print Started',\n      body: '{{printer}}: {{filename}} has started printing',\n    },\n    printCompleted: {\n      title: 'Print Completed',\n      body: '{{printer}}: {{filename}} completed successfully',\n    },\n    printFailed: {\n      title: 'Print Failed',\n      body: '{{printer}}: {{filename}} has failed',\n    },\n    printStopped: {\n      title: 'Print Stopped',\n      body: '{{printer}}: {{filename}} was stopped',\n    },\n    printProgress: {\n      title: 'Print Progress',\n      body: '{{printer}}: {{filename}} is {{percent}}% complete',\n    },\n    printerOffline: {\n      title: 'Printer Offline',\n      body: '{{printer}} is offline',\n    },\n    printerError: {\n      title: 'Printer Error',\n      body: '{{printer}}: {{error}}',\n    },\n    filamentLow: {\n      title: 'Low Filament',\n      body: '{{printer}}: Filament is running low',\n    },\n    maintenanceDue: {\n      title: 'Maintenance Due',\n      body: '{{printer}}: {{items}} need attention',\n    },\n  },\n\n  // Errors\n  errors: {\n    generic: 'Something went wrong',\n    networkError: 'Network error. Please check your connection.',\n    notFound: 'Not found',\n    unauthorized: 'Unauthorized',\n    serverError: 'Server error',\n    validationError: 'Please check your input',\n    printerConnectionFailed: 'Failed to connect to printer',\n    saveFailed: 'Failed to save changes',\n    deleteFailed: 'Failed to delete',\n    loadFailed: 'Failed to load data',\n  },\n\n  // HMS Errors modal\n  hmsErrors: {\n    title: 'Errors - {{name}}',\n    noErrors: 'No errors',\n    viewOnWiki: 'View on Bambu Lab Wiki',\n    clearInstructions: 'Clear errors on the printer to dismiss them here.',\n    clearErrors: 'Clear Errors',\n    clearSuccess: 'HMS errors cleared',\n    clearFailed: 'Failed to clear HMS errors',\n  },\n\n  // MQTT Debug modal\n  mqttDebug: {\n    title: 'MQTT Debug Log',\n    searchPlaceholder: 'Search topic or payload...',\n    noMessages: 'No messages logged yet',\n    startLoggingHint: 'Click \"Start Logging\" to begin capturing MQTT messages',\n    noMessagesMatch: 'No messages match your filter',\n    adjustFilterHint: 'Try adjusting your search or filter criteria',\n    incoming: 'Incoming',\n    outgoing: 'Outgoing',\n    loggingStopped: 'Logging stopped',\n    loggingActive: 'Logging active - messages will auto-refresh',\n    startLogging: 'Start Logging',\n    stopLogging: 'Stop Logging',\n    clearLog: 'Clear Log',\n    topic: 'Topic',\n    timestamp: 'Timestamp',\n    direction: 'Direction',\n    all: 'All',\n  },\n\n  // Printer File Manager modal (printer internal storage)\n  printerFiles: {\n    title: 'File Manager',\n    storageUsed: 'Used:',\n    storageFree: 'Free:',\n    filterPlaceholder: 'Filter files...',\n    deleteButton: 'Delete',\n    deleteFiles: 'Delete {{count}} Files',\n    deleteFileConfirm: 'Delete \"{{name}}\"? This cannot be undone.',\n    deleteFilesConfirm: 'Delete {{count}} selected files? This cannot be undone.',\n    noFiles: 'No files on printer',\n    loadingFiles: 'Loading files...',\n    failedToLoad: 'Failed to load files',\n    toast: {\n      filesDeleted: 'Deleted {{count}} file(s)',\n      deleteFailed: 'Delete failed: {{error}}',\n    },\n  },\n\n  // Confirmations\n  confirm: {\n    delete: 'Are you sure you want to delete this?',\n    unsavedChanges: 'You have unsaved changes. Are you sure you want to leave?',\n    clearQueue: 'Are you sure you want to clear the queue?',\n  },\n\n  // Login page\n  login: {\n    title: 'Bambuddy Login',\n    subtitle: 'Sign in to your account',\n    username: 'Username',\n    usernamePlaceholder: 'Enter your username',\n    usernameOrEmail: 'Username or Email',\n    usernameOrEmailPlaceholder: 'Username or @ Email',\n    password: 'Password',\n    passwordPlaceholder: 'Enter your password',\n    signIn: 'Sign in',\n    signingIn: 'Logging in...',\n    forgotPassword: 'Forgot your password?',\n    loginSuccess: 'Logged in successfully',\n    loginFailed: 'Login failed',\n    enterCredentials: 'Please enter username and password',\n    enterEmail: 'Please enter your email address',\n    oidcLoginFailed: 'OIDC login failed',\n    oidcErrors: {\n      providerError: 'The identity provider returned an error',\n      missingParameters: 'OIDC callback is missing required parameters',\n      invalidState: 'OIDC state is invalid or has already been used',\n      stateExpired: 'OIDC login session expired — please try again',\n      providerNotFound: 'OIDC provider not found',\n      discoveryFailed: 'Failed to fetch OIDC discovery document',\n      invalidDiscovery: 'OIDC discovery document is invalid',\n      networkError: 'Network error during OIDC token exchange',\n      badResponse: 'Unexpected response during OIDC token exchange',\n      noIdToken: 'OIDC provider did not return an ID token',\n      validationFailed: 'OIDC token validation failed',\n      nonceMismatch: 'OIDC nonce mismatch — possible replay attack',\n      missingSubClaim: 'OIDC token is missing the sub claim',\n      noLinkedAccount: 'No local account is linked to this OIDC identity',\n      accountInactive: 'Your account is inactive',\n      userResolutionFailed: 'Failed to resolve your account',\n      internalError: 'An internal error occurred during OIDC login',\n      tokenExchangeFailed: 'OIDC token exchange failed',\n    },\n    forgotPasswordTitle: 'Forgot Password',\n    forgotPasswordMessage: \"If you've forgotten your password, please contact your system administrator to reset it.\",\n    forgotPasswordEmailMessage: \"Enter your email address and we'll send you a new password.\",\n    emailAddress: 'Email Address',\n    emailPlaceholder: 'your.email@example.com',\n    cancel: 'Cancel',\n    sending: 'Sending...',\n    sendResetEmail: 'Send Reset Email',\n    howToReset: 'How to reset your password:',\n    resetStep1: 'Contact your Bambuddy administrator',\n    resetStep2: 'Ask them to reset your password in User Management',\n    resetStep3: 'They can set a new temporary password for you',\n    resetStep4: 'Log in with the new password and change it in Settings',\n    gotIt: 'Got it',\n    resetPassword: {\n      title: 'Set New Password',\n      subtitle: 'Enter and confirm your new password below.',\n      newPassword: 'New Password',\n      newPasswordPlaceholder: 'At least 8 characters',\n      confirmPassword: 'Confirm Password',\n      confirmPasswordPlaceholder: 'Repeat new password',\n      saving: 'Saving\\u2026',\n      submit: 'Set New Password',\n      backToLogin: 'Back to login',\n      passwordsDoNotMatch: 'Passwords do not match',\n      passwordTooShort: 'Password must be at least 8 characters',\n      resetFailed: 'Password reset failed. The link may have expired.',\n    },\n    twoFA: {\n      title: 'Two-Factor Authentication',\n      subtitle: 'Your account is protected with 2FA. Enter the verification code below.',\n      methodAuthenticator: 'Authenticator App',\n      methodEmail: 'Email Code',\n      methodBackup: 'Backup Code',\n      instructionsTotp: 'Open your authenticator app and enter the 6-digit code for Bambuddy.',\n      instructionsEmail: 'A 6-digit code has been sent to your email address. It expires in 10 minutes.',\n      instructionsEmailNotSent: 'Click the button below to receive a verification code via email.',\n      instructionsBackup: 'Enter one of your 8-character backup recovery codes. Each code can only be used once.',\n      sendCodeButton: 'Send Code via Email',\n      sendingCode: 'Sending...',\n      resendCode: 'Resend code',\n      codeLabel: 'Verification Code',\n      backupCodeLabel: 'Backup Code',\n      codePlaceholder: '000000',\n      backupCodePlaceholder: 'XXXXXXXX',\n      verifyButton: 'Verify',\n      verifyingButton: 'Verifying...',\n      backToLogin: '← Back to login',\n      orContinueWith: 'or continue with',\n      signInWith: 'Sign in with {{provider}}',\n      enterCode: 'Please enter the verification code',\n      sendCodeFailed: 'Failed to send verification code',\n      invalidCode: 'Invalid code. Please try again.',\n    },\n\n  },\n\n  // Setup page\n  setup: {\n    title: 'Bambuddy Setup',\n    subtitle: 'Configure authentication for your Bambuddy instance',\n    enableAuth: 'Enable Authentication',\n    adminAccount: 'Admin Account',\n    adminAccountDesc: 'If admin users already exist, authentication will be enabled using the existing admin accounts. Leave the fields below empty to use existing admins, or enter new credentials to create a new admin user.',\n    adminUsername: 'Admin Username',\n    adminPassword: 'Admin Password',\n    optionalIfAdminExists: '(optional if admin users exist)',\n    adminUsernamePlaceholder: 'Enter admin username (optional)',\n    adminPasswordPlaceholder: 'Enter admin password (optional)',\n    confirmPassword: 'Confirm Password',\n    confirmPasswordPlaceholder: 'Confirm admin password',\n    settingUp: 'Setting up...',\n    completeSetup: 'Complete Setup',\n    toast: {\n      authEnabledAdminCreated: 'Authentication enabled and admin user created',\n      authEnabledExistingAdmins: 'Authentication enabled using existing admin users',\n      setupCompleted: 'Setup completed',\n      enterBothCredentials: 'Please enter both admin username and password, or leave both empty to use existing admin users',\n      passwordsDoNotMatch: 'Passwords do not match',\n      passwordTooShort: 'Password must be at least 6 characters',\n    },\n  },\n\n  // Password change\n  changePassword: {\n    title: 'Change Password',\n    currentPassword: 'Current Password',\n    currentPasswordPlaceholder: 'Enter current password',\n    newPassword: 'New Password',\n    newPasswordPlaceholder: 'Enter new password (min 6 characters)',\n    confirmPassword: 'Confirm New Password',\n    confirmPasswordPlaceholder: 'Confirm new password',\n    passwordsDoNotMatch: 'Passwords do not match',\n    passwordTooShort: 'Password must be at least 6 characters',\n    changing: 'Changing...',\n    success: 'Password changed successfully',\n    failed: 'Failed to change password',\n  },\n\n  // Plate detection alert\n  plateAlert: {\n    title: 'Print Paused!',\n    message: 'Objects detected on build plate. The print has been automatically paused. Please clear the plate and resume the print.',\n    understand: 'I Understand',\n  },\n\n  // Camera page\n  camera: {\n    title: 'Camera View',\n    invalidPrinterId: 'Invalid printer ID',\n    live: 'Live',\n    snapshot: 'Snapshot',\n    restartStream: 'Restart stream',\n    refreshSnapshot: 'Refresh snapshot',\n    fullscreen: 'Fullscreen',\n    exitFullscreen: 'Exit fullscreen',\n    connectingToCamera: 'Connecting to camera...',\n    capturingSnapshot: 'Capturing snapshot...',\n    connectionLost: 'Connection lost',\n    connectionFailed: 'Camera connection failed',\n    reconnecting: 'Reconnecting in {{countdown}}s... (attempt {{attempt}}/{{max}})',\n    reconnectNow: 'Reconnect now',\n    cameraUnavailable: 'Camera unavailable',\n    cameraUnavailableDesc: 'Make sure the printer is powered on and connected.',\n    noCamera: 'No camera available',\n    retry: 'Retry',\n    cameraStream: 'Camera stream',\n    zoomOut: 'Zoom out',\n    zoomIn: 'Zoom in',\n    resetZoom: 'Reset zoom',\n    recording: 'Recording',\n    startRecording: 'Start Recording',\n    stopRecording: 'Stop Recording',\n    chamberLight: 'Toggle chamber light',\n  },\n\n  // Groups management\n  groups: {\n    title: 'Group Management',\n    subtitle: 'Manage permission groups for access control',\n    backToSettings: 'Back to Settings',\n    createGroup: 'Create Group',\n    noPermission: 'You do not have permission to access this page.',\n    system: 'System',\n    noDescription: 'No description',\n    usersCount: '{{count}} users',\n    permissionsCount: '{{count}} permissions',\n    edit: 'Edit',\n    delete: 'Delete',\n    toast: {\n      created: 'Group created successfully',\n      updated: 'Group updated successfully',\n      deleted: 'Group deleted successfully',\n      enterGroupName: 'Please enter a group name',\n    },\n    modal: {\n      editGroup: 'Edit Group',\n      createGroup: 'Create Group',\n      cancel: 'Cancel',\n      saving: 'Saving...',\n      creating: 'Creating...',\n      saveChanges: 'Save Changes',\n    },\n    form: {\n      groupName: 'Group Name',\n      groupNamePlaceholder: 'Enter group name',\n      systemGroupWarning: 'System group names cannot be changed',\n      description: 'Description',\n      descriptionPlaceholder: 'Enter description (optional)',\n      permissions: 'Permissions ({{count}} selected)',\n    },\n    deleteModal: {\n      title: 'Delete Group',\n      message: 'Are you sure you want to delete this group? Users in this group will lose these permissions.',\n      confirm: 'Delete Group',\n    },\n    editor: {\n      title: 'Edit Group',\n      createTitle: 'Create Group',\n      search: 'Search permissions...',\n      selectAll: 'Select All',\n      clearAll: 'Clear All',\n      permissionsSelected: '{{count}} selected',\n      noResults: 'No permissions match your search',\n    },\n  },\n\n  // Users management\n  users: {\n    title: 'User Management',\n    subtitle: 'Manage users and their access to your Bambuddy instance',\n    backToSettings: 'Back to Settings',\n    createUser: 'Create User',\n    noPermission: 'You do not have permission to access this page.',\n    admin: 'Admin',\n    noGroups: 'No groups',\n    active: 'Active',\n    inactive: 'Inactive',\n    edit: 'Edit',\n    delete: 'Delete',\n    system: 'System',\n    noGroupsAvailable: 'No groups available',\n    table: {\n      username: 'Username',\n      groups: 'Groups',\n      status: 'Status',\n      actions: 'Actions',\n    },\n    toast: {\n      created: 'User created successfully',\n      updated: 'User updated successfully',\n      deleted: 'User deleted successfully',\n      fillRequired: 'Please fill in all required fields',\n      passwordsDoNotMatch: 'Passwords do not match',\n      passwordTooShort: 'Password must be at least 6 characters',\n    },\n    modal: {\n      createUser: 'Create User',\n      editUser: 'Edit User',\n      cancel: 'Cancel',\n      creating: 'Creating...',\n      saving: 'Saving...',\n      saveChanges: 'Save Changes',\n      advancedAuthSubtitle: 'with Advanced Authentication',\n    },\n    form: {\n      username: 'Username',\n      usernamePlaceholder: 'Enter username',\n      email: 'Email',\n      emailPlaceholder: 'user@example.com',\n      password: 'Password',\n      passwordPlaceholder: 'Enter password',\n      confirmPassword: 'Confirm Password',\n      confirmPasswordPlaceholder: 'Confirm password',\n      newPasswordPlaceholder: 'Enter new password',\n      confirmNewPasswordPlaceholder: 'Confirm new password',\n      leaveBlankToKeep: 'leave blank to keep current',\n      groups: 'Groups',\n      optional: 'optional',\n      autoGeneratedPassword: 'A secure password will be automatically generated and emailed to the user.',\n      passwordManagedByAdvancedAuth: 'Password is managed by Advanced Authentication. Use \"Reset Password\" to send a new password to the user via email.',\n      resetPassword: 'Reset Password',\n      resettingPassword: 'Resetting Password...',\n    },\n    deleteModal: {\n      title: 'Delete User',\n      message: 'Are you sure you want to delete this user? This action cannot be undone.',\n      confirm: 'Delete User',\n    },\n  },\n\n  // Stream overlay\n  streamOverlay: {\n    title: 'Stream Overlay',\n    invalidPrinterId: 'Invalid printer ID',\n    cameraStream: 'Camera stream',\n    progress: 'Progress',\n    eta: 'ETA',\n    printerIdle: 'Printer is idle',\n    printerOffline: 'Printer offline',\n    status: {\n      printing: 'Printing',\n      paused: 'Paused',\n      finished: 'Finished',\n      failed: 'Failed',\n      idle: 'Idle',\n      unknown: 'Unknown',\n    },\n  },\n\n  // Profiles\n  profiles: {\n    title: 'Profiles',\n    subtitle: 'Manage your slicer presets and pressure advance calibrations',\n    tabs: {\n      cloud: 'Cloud Profiles',\n      local: 'Local Profiles',\n      kprofiles: 'K-Profiles',\n    },\n    localProfiles: {\n      title: 'Local Profiles',\n      subtitle: 'Import and manage slicer presets from OrcaSlicer',\n      import: 'Import Profiles',\n      importDesc: 'Drop .bbscfg, .bbsflmt, .orca_filament, .zip, or .json files here',\n      importing: 'Importing...',\n      search: 'Search local presets...',\n      noPresets: 'No local presets yet',\n      badge: 'Local',\n      edit: 'Edit',\n      delete: 'Delete',\n      cancel: 'Cancel',\n      deleteConfirmTitle: 'Delete Preset',\n      deleteConfirm: 'Are you sure you want to delete this preset? This cannot be undone.',\n      source: 'Source',\n      inheritsFrom: 'Inherits',\n      filamentType: 'Type',\n      vendor: 'Vendor',\n      compatiblePrinters: 'Printers',\n      nozzleTemp: 'Nozzle Temp',\n      cost: 'Cost',\n      density: 'Density',\n      pressureAdvance: 'Pressure Advance',\n      filament: 'Filament',\n      process: 'Process',\n      printer: 'Printer',\n      toast: {\n        importSuccess: '{{count}} preset(s) imported',\n        importSkipped: '{{count}} preset(s) skipped (duplicates)',\n        importError: '{{count}} error(s) during import',\n        deleted: 'Preset deleted',\n        updated: 'Preset updated',\n      },\n    },\n    connectedAs: 'Connected as',\n    logout: 'Logout',\n    noLogoutPermission: 'You do not have permission to logout',\n    failedToLoad: 'Failed to load profiles',\n    retry: 'Retry',\n    time: {\n      justNow: 'Just now',\n      minsAgo: '{{count}}m ago',\n      hoursAgo: '{{count}}h ago',\n      daysAgo: '{{count}}d ago',\n    },\n    toast: {\n      loggedOut: 'Logged out',\n    },\n    login: {\n      title: 'Connect to Bambu Cloud',\n      subtitle: 'Sync your slicer presets across devices',\n      email: 'Email',\n      password: 'Password',\n      region: 'Region',\n      regionGlobal: 'Global',\n      regionChina: 'China',\n      verificationCode: 'Verification Code',\n      totpCode: 'Authenticator Code',\n      checkEmail: 'Check your email ({{email}}) for a 6-digit code',\n      enterTotpHint: 'Enter the 6-digit code from your authenticator app',\n      accessToken: 'Access Token',\n      accessTokenHint: 'Paste your Bambu Lab access token (from Bambu Studio)',\n      back: 'Back',\n      loginButton: 'Login',\n      verifyButton: 'Verify',\n      setTokenButton: 'Set Token',\n      useToken: 'Use access token instead',\n      useEmail: 'Login with email instead',\n      toast: {\n        loggedIn: 'Logged in successfully',\n        codeSent: 'Verification code sent to your email',\n        enterTotp: 'Enter code from your authenticator app',\n        tokenSet: 'Token set successfully',\n      },\n    },\n    presets: {\n      myPreset: 'My preset (editable)',\n      duplicate: 'Duplicate',\n      editable: 'Editable',\n      failedToLoadDetails: 'Failed to load preset details',\n      deleteConfirm: 'Delete this preset?',\n      deleteWarning: 'This will permanently delete \"{{name}}\" from Bambu Cloud. This cannot be undone.',\n      noDuplicatePermission: 'You do not have permission to duplicate presets',\n      noEditPermission: 'You do not have permission to edit presets',\n      noDeletePermission: 'You do not have permission to delete presets',\n      types: {\n        filament: 'Filament preset',\n        printer: 'Printer preset',\n        process: 'Process preset',\n      },\n      toast: {\n        deleted: 'Preset deleted',\n        created: 'Preset created',\n        updated: 'Preset updated',\n        duplicated: 'Preset duplicated',\n        fieldAdded: 'Field \"{{key}}\" added',\n        exported: 'Preset exported',\n      },\n      baseLabel: 'Base: {{name}}',\n      currentLabel: 'Current: {{name}}',\n      newPreset: 'New Preset',\n      editPreset: 'Edit Preset',\n      duplicatePreset: 'Duplicate Preset',\n      createNewPreset: 'Create New Preset',\n      customizeSettings: 'Customize settings for your new preset',\n      compareWithBase: 'Compare with base preset',\n      compare: 'Compare',\n      // CreatePresetModal - Basic Info\n      basePreset: 'Base Preset',\n      selectBasePreset: 'Select base preset...',\n      presetName: 'Preset Name',\n      myCustomPreset: 'My custom preset',\n      inheritsFrom: 'Inherits from',\n      dropJsonToImport: 'Drop JSON to import',\n      // CreatePresetModal - Tabs\n      tabs: {\n        common: 'Common',\n        allFields: 'All Fields',\n      },\n      // CreatePresetModal - All Fields Tab\n      availableFields: 'Available Fields',\n      searchFieldsPlaceholder: 'Search fields...',\n      noMatchingFields: 'No matching fields',\n      allFieldsAdded: 'All fields added',\n      addCustomField: 'Add custom field',\n      yourOverrides: 'Your Overrides',\n      noOverridesYet: 'No overrides yet',\n      clickFieldsToAdd: 'Click fields on the left to add them',\n      saveAsTemplate: 'Save as template',\n      jsonTip: 'Tip: Drag & drop a .json file anywhere on this modal to import settings',\n    },\n    cloudView: {\n      searchPlaceholder: 'Search presets...',\n      templates: 'Templates',\n      refresh: 'Refresh',\n      newPreset: 'New Preset',\n      clearFilters: 'Clear filters',\n      // Compare mode\n      compareMode: 'Compare Mode',\n      selectAnotherPreset: 'Select another {{type}} preset',\n      clickTwoPresets: 'Click two presets of the same type to compare',\n      selectFirst: '1. Select first',\n      selectSecond: '2. Select second',\n      compareNow: 'Compare Now',\n      // Status row\n      lastSynced: 'Last synced:',\n      showingCount: 'Showing {{showing}} of {{total}} presets',\n      noPresetsFound: 'No presets found',\n      // Column headers\n      columns: {\n        filament: 'Filament',\n        process: 'Process',\n        printer: 'Printer',\n      },\n      noFilamentPresets: 'No filament presets',\n      noProcessPresets: 'No process presets',\n      noPrinterPresets: 'No printer presets',\n      // Filters\n      filters: {\n        type: 'Type',\n        owner: 'Owner',\n        printer: 'Printer',\n        nozzle: 'Nozzle',\n        filament: 'Filament',\n        layer: 'Layer',\n        all: 'All',\n        myPresets: 'My Presets',\n        builtIn: 'Built-in',\n        process: 'Process',\n      },\n      // Permissions\n      noTemplatesPermission: 'You do not have permission to manage templates',\n      noRefreshPermission: 'You do not have permission to refresh profiles',\n      noCreatePermission: 'You do not have permission to create presets',\n    },\n    templates: {\n      title: 'Quick Templates',\n      noTemplates: 'No templates yet',\n      createFirst: 'Create templates from the preset editor',\n      typeFilter: 'Type:',\n      deleteTitle: 'Delete Template',\n      deleteWarning: 'This action cannot be undone',\n      deleteConfirm: 'Are you sure you want to delete \"{{name}}\"?',\n      namePlaceholder: 'Template name',\n      descriptionPlaceholder: 'Description',\n      settingsJson: 'Settings (JSON)',\n      fieldsCount: '{{count}} fields',\n      shownInModals: 'Shown in modals',\n      hiddenInModals: 'Hidden in modals',\n      apply: 'Apply',\n      toast: {\n        deleted: 'Template deleted',\n        updated: 'Template updated',\n        created: 'Template created',\n        applied: 'Template applied',\n      },\n    },\n  },\n\n  // Support/Debug\n  support: {\n    debugLoggingActive: 'Debug logging is active',\n    manageLogs: 'Manage',\n    collectItem7: 'Printer connectivity and firmware versions',\n    collectItem8: 'Integration status (Spoolman, MQTT, HA)',\n    collectItem9: 'Network interfaces (subnets only)',\n    collectItem10: 'Python package versions',\n    collectItem11: 'Database health checks',\n    collectItem12: 'Docker environment details',\n  },\n\n  // File manager\n  fileManager: {\n    title: 'File Manager',\n    subtitle: 'Organize and manage your print files',\n    uploadFiles: 'Upload Files',\n    newFolder: 'New Folder',\n    folderName: 'Folder Name',\n    folderNamePlaceholder: 'e.g., Functional Parts',\n    renameFile: 'Rename File',\n    renameFolder: 'Rename Folder',\n    moveFiles: 'Move {{count}} File(s)',\n    rootNoFolder: 'Root (No Folder)',\n    current: 'current',\n    linkFolder: 'Link Folder',\n    linkFolderDescription: 'Link \"{{name}}\" to a project or archive for quick access.',\n    project: 'Project',\n    archive: 'Archive',\n    noProjectsFound: 'No projects found',\n    noArchivesFound: 'No archives found',\n    unlink: 'Unlink',\n    link: 'Link',\n    dragDropFiles: 'Drag & drop files here',\n    dropFilesHere: 'Drop files here',\n    orClickToBrowse: 'or click to browse',\n    allFileTypesSupported: 'All file types supported. ZIP files will be extracted.',\n    zipFilesDetected: 'ZIP files detected',\n    zipExtractOptions: 'ZIP files will be extracted. Choose how to handle folder structure:',\n    preserveZipStructure: 'Preserve folder structure from ZIP',\n    createFolderFromZip: 'Create folder from ZIP filename',\n    stlThumbnailGeneration: 'STL thumbnail generation',\n    zipMayContainStl: 'ZIP files may contain STL files. Thumbnails can be generated during extraction.',\n    thumbnailsCanBeGenerated: 'Thumbnails can be generated for STL files. Large models may take longer to process.',\n    generateThumbnailsForStl: 'Generate thumbnails for STL files',\n    threemfDetected: '3MF files detected',\n    threemfExtractionInfo: 'Printer model, material, color, and print settings will be automatically extracted from 3MF files.',\n    willBeExtracted: 'Will be extracted',\n    filesExtracted: '{{count}} files extracted',\n    uploadComplete: 'Upload complete: {{succeeded}} succeeded',\n    uploadFailed: 'Upload failed',\n    zipFilesFailed: '{{count}} files failed',\n    uploading: 'Uploading...',\n    changeLink: 'Change Link...',\n    linkTo: 'Link to...',\n    linkToProjectOrArchive: 'Link to project or archive',\n    addToQueue: 'Add to Queue',\n    schedulePrint: 'Schedule',\n    generateThumbnail: 'Generate Thumbnail',\n    generateThumbnails: 'Generate Thumbnails',\n    generateThumbnailsForMissing: 'Generate thumbnails for STL files missing them',\n    gridView: 'Grid view',\n    listView: 'List view',\n    lowDiskSpaceWarning: 'Low disk space warning',\n    lowDiskSpaceDetails: 'Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.',\n    files: 'Files',\n    folders: 'Folders',\n    size: 'Size',\n    free: 'Free',\n    allFiles: 'All Files',\n    wrap: 'Wrap',\n    enableTextWrapping: 'Enable text wrapping',\n    disableTextWrapping: 'Disable text wrapping',\n    collapse: 'Collapse',\n    collapseFoldersByDefault: 'Collapse folders by default',\n    expandFoldersByDefault: 'Expand folders by default',\n    dragToResizeTooltip: 'Drag to resize, double-click to reset',\n    searchFiles: 'Search files...',\n    allTypes: 'All types',\n    prints: 'Prints',\n    ascending: 'Ascending',\n    descending: 'Descending',\n    resultsCount: '{{showing}} of {{total}} files',\n    selectAll: 'Select All',\n    deselectAll: 'Deselect All',\n    selected: '{{count}} selected',\n    adding: 'Adding...',\n    loadingFiles: 'Loading files...',\n    folderIsEmpty: 'Folder is empty',\n    noFilesYet: 'No files yet',\n    folderEmptyDescription: 'Upload files or move files into this folder to get started.',\n    noFilesDescription: 'Upload files to start organizing your print-related files.',\n    noMatchingFiles: 'No matching files',\n    noMatchingFilesDescription: 'No files match your current search or filter criteria.',\n    clearFilters: 'Clear filters',\n    printedCount: 'Printed {{count}}x',\n    uploadedBy: 'Uploaded By',\n    deleteFolder: 'Delete Folder',\n    deleteFile: 'Delete File',\n    deleteFilesCount: 'Delete {{count}} Files',\n    deleteFolderConfirm: 'Are you sure you want to delete this folder? All files inside will also be deleted.',\n    deleteFileConfirm: 'Are you sure you want to delete this file?',\n    deleteFilesConfirm: 'Are you sure you want to delete {{count}} selected files? This action cannot be undone.',\n    deleting: 'Deleting...',\n    noPermissionRenameFolder: 'You do not have permission to rename folders',\n    noPermissionLinkFolder: 'You do not have permission to link folders',\n    noPermissionDeleteFolder: 'You do not have permission to delete folders',\n    noPermissionPrint: 'You do not have permission to print',\n    noPermissionAddToQueue: 'You do not have permission to add to queue',\n    noPermissionDownload: 'You do not have permission to download files',\n    noPermissionRenameFile: 'You do not have permission to rename this file',\n    noPermissionGenerateThumbnail: 'You do not have permission to generate thumbnails',\n    noPermissionDeleteFile: 'You do not have permission to delete this file',\n    noPermissionCreateFolder: 'You do not have permission to create folders',\n    noPermissionUpload: 'You do not have permission to upload files',\n    noPermissionMoveFiles: 'You do not have permission to move files',\n    noPermissionDeleteFiles: 'You do not have permission to delete files',\n    // External folder\n    linkExternal: 'Link External',\n    linkExternalFolder: 'Link External Folder',\n    linkExternalFolderDescription: 'Mount a host directory (NAS, USB, network share) into the File Manager. Files are not copied — they are accessed directly from the original path.',\n    externalFolderNamePlaceholder: 'e.g., NAS Prints',\n    externalPath: 'Host Path',\n    externalPathHelp: 'Absolute path to the directory on the Docker host. Must be bind-mounted into the container.',\n    readOnly: 'Read Only',\n    readOnlyHelp: 'prevents uploads and deletions',\n    showHiddenFiles: 'Show hidden files (dotfiles)',\n    externalFolder: 'External Folder',\n    scanFolder: 'Scan',\n    toast: {\n      folderCreated: 'Folder created',\n      folderDeleted: 'Folder deleted',\n      fileDeleted: 'File deleted',\n      filesDeleted: 'Deleted {{count}} files',\n      filesMoved: 'Files moved',\n      folderLinked: 'Folder linked',\n      folderUnlinked: 'Folder unlinked',\n      externalFolderLinked: 'External folder linked and scanned',\n      folderScanned: 'Scan complete: {{added}} added, {{removed}} removed',\n      addedToQueue: 'Added {{count}} file(s) to queue',\n      addedToQueuePartial: 'Added {{added}} file(s), {{failed}} failed',\n      failedToAddToQueue: 'Failed to add files: {{error}}',\n      fileRenamed: 'File renamed',\n      folderRenamed: 'Folder renamed',\n      thumbnailsGenerated: 'Generated {{count}} thumbnail(s)',\n      thumbnailsGeneratedPartial: 'Generated {{succeeded}} thumbnail(s), {{failed}} failed',\n      noStlMissingThumbnails: 'No STL files missing thumbnails',\n      failedToGenerateThumbnails: 'Failed to generate thumbnails: {{error}}',\n      thumbnailGenerated: 'Thumbnail generated',\n      failedToGenerateThumbnail: 'Failed to generate thumbnail: {{error}}',\n    },\n  },\n\n  // Projects\n  projects: {\n    title: 'Projects',\n    subtitle: 'Organize and track your 3D printing projects',\n    newProject: 'New Project',\n    editProject: 'Edit Project',\n    deleteProject: 'Delete Project',\n    projectName: 'Project Name',\n    description: 'Description',\n    noProjects: 'No projects yet',\n    noProjectsFiltered: 'No {{status}} projects',\n    noProjectsFilteredHelp: \"You don't have any {{status}} projects. Projects will appear here when their status changes.\",\n    createFirst: 'Create your first project to start organizing related prints, tracking progress, and managing your builds.',\n    createFirstButton: 'Create Your First Project',\n    create: 'Create',\n    files: 'Files',\n    prints: 'Prints',\n    plates: 'plates',\n    parts: 'parts',\n    lastModified: 'Last Modified',\n    deleteConfirm: 'Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted.',\n    addFiles: 'Add Files',\n    removeFile: 'Remove File',\n    viewDetails: 'View Details',\n    // Modal fields\n    namePlaceholder: 'e.g., Voron 2.4 Build',\n    descriptionPlaceholder: 'Optional description...',\n    color: 'Color',\n    targetPlates: 'Target Plates',\n    targetPlatesPlaceholder: 'e.g., 25',\n    targetPlatesHelp: 'Number of print jobs',\n    targetParts: 'Target Parts',\n    targetPartsPlaceholder: 'e.g., 150',\n    targetPartsHelp: 'Total objects needed',\n    tagsLabel: 'Tags (comma-separated)',\n    tagsPlaceholder: 'e.g., voron, functional, gift',\n    dueDate: 'Due Date',\n    priority: 'Priority',\n    priorityLow: 'Low',\n    priorityNormal: 'Normal',\n    priorityHigh: 'High',\n    priorityUrgent: 'Urgent',\n    // Status\n    statusActive: 'Active',\n    statusCompleted: 'Completed',\n    statusArchived: 'Archived',\n    done: 'Done',\n    completed: 'completed',\n    failed: 'failed',\n    inQueue: 'in queue',\n    noPrintsYet: 'No prints yet',\n    // Footer stats\n    printJobs: 'Print jobs (plates)',\n    partsPrinted: 'Parts printed',\n    failedParts: 'Failed parts',\n    // Actions\n    import: 'Import',\n    export: 'Export',\n    importProject: 'Import project',\n    exportAll: 'Export all projects',\n    loading: 'Loading projects...',\n    // Permissions\n    noEditPermission: 'You do not have permission to edit projects',\n    noDeletePermission: 'You do not have permission to delete projects',\n    noCreatePermission: 'You do not have permission to create projects',\n    noImportPermission: 'You do not have permission to import projects',\n    noExportPermission: 'You do not have permission to export projects',\n    // Toast\n    toast: {\n      created: 'Project created',\n      updated: 'Project updated',\n      deleted: 'Project deleted',\n      imported: 'Project imported',\n      multipleImported: '{{count}} projects imported',\n      importFailed: 'Import failed',\n      exported: 'Projects exported (metadata only)',\n    },\n  },\n\n  // Project detail page\n  projectDetail: {\n    notFound: 'Project not found',\n    backToProjects: 'Back to Projects',\n    export: 'Export',\n    exportProject: 'Export project',\n    noExportPermission: 'You do not have permission to export projects',\n    noEditPermission: 'You do not have permission to edit projects',\n    partOf: 'Part of:',\n    priorityLabel: 'Priority:',\n    noPrints: 'No prints in this project yet',\n    status: {\n      active: 'Active',\n      completed: 'Completed',\n      archived: 'Archived',\n    },\n    priority: {\n      low: 'Low',\n      normal: 'Normal',\n      high: 'High',\n      urgent: 'Urgent',\n    },\n    dueDate: {\n      overdue: 'Overdue',\n      today: 'Due today',\n      daysLeft: '{{count}} days left',\n    },\n    progress: {\n      platesProgress: 'Plates Progress',\n      partsProgress: 'Parts Progress',\n      printJobs: 'print jobs',\n      parts: 'parts',\n      percentComplete: '{{percent}}% complete',\n      remaining: '{{count}} remaining',\n    },\n    stats: {\n      printJobs: 'Print Jobs',\n      total: 'total',\n      failed: '{{count}} failed',\n      partsPrinted: '{{count}} parts printed',\n      printTime: 'Print Time',\n      filamentUsed: 'Filament Used',\n    },\n    cost: {\n      title: 'Cost Tracking',\n      filamentCost: 'Filament Cost',\n      energy: 'Energy',\n      totalCost: 'Total Cost',\n      total: 'Total',\n      includesBom: 'incl. BOM',\n      budget: 'Budget',\n      remaining: 'Remaining',\n    },\n    subProjects: {\n      title: 'Sub-projects ({{count}})',\n    },\n    notes: {\n      title: 'Notes',\n      noEditPermission: 'You do not have permission to edit notes',\n      placeholder: 'Add notes about this project...',\n      empty: 'No notes yet. Click Edit to add notes.',\n    },\n    files: {\n      title: 'Files',\n      linkFolders: 'Link folders from the File Manager',\n      forQuickAccess: 'to this project for quick access.',\n      fileCount: '{{count}} file(s)',\n      empty: 'No folders linked. Go to File Manager and link a folder to this project.',\n      noFiles: 'No files in this folder.',\n      print: 'Print Now',\n      addToQueue: 'Add to Queue',\n    },\n    bom: {\n      title: 'Bill of Materials',\n      acquired: '{{completed}}/{{total}} acquired',\n      showAll: 'Show all',\n      hideDone: 'Hide done',\n      addPart: 'Add Part',\n      noAddPermission: 'You do not have permission to add parts',\n      partNamePlaceholder: 'Part name (e.g., M3x8 screws)',\n      partName: 'Part name',\n      qty: 'Qty',\n      price: 'Price ({{currency}})',\n      sourcingUrlPlaceholder: 'Sourcing URL (optional)',\n      remarksPlaceholder: 'Remarks (optional)',\n      deletePart: 'Delete Part',\n      deleteConfirm: 'Are you sure you want to delete \"{{name}}\"?',\n      noUpdatePermission: 'You do not have permission to update parts',\n      noEditPermission: 'You do not have permission to edit parts',\n      noDeletePermission: 'You do not have permission to delete parts',\n      totalCost: 'Total cost:',\n      empty: 'No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.',\n    },\n    timeline: {\n      title: 'Activity Timeline',\n      empty: 'No activity yet.',\n    },\n    template: {\n      saveAsTemplate: 'Save as Template',\n      noCreatePermission: 'You do not have permission to create templates',\n    },\n    queue: {\n      title: 'Queue',\n      viewAll: 'View all',\n      printing: '{{count}} printing',\n      queued: '{{count}} queued',\n    },\n    prints: {\n      title: 'Prints ({{count}})',\n    },\n    toast: {\n      projectUpdated: 'Project updated',\n      partAdded: 'Part added',\n      partRemoved: 'Part removed',\n      exportFailed: 'Export failed',\n      projectExported: 'Project exported',\n      templateCreated: 'Template created',\n    },\n  },\n\n  // System info\n  system: {\n    title: 'System Information',\n    version: 'Version',\n    uptime: 'Uptime',\n    cpuUsage: 'CPU Usage',\n    memoryUsage: 'Memory Usage',\n    diskUsage: 'Disk Usage',\n    networkInfo: 'Network Info',\n    logs: 'Logs',\n    debugMode: 'Debug Mode',\n    enableDebug: 'Enable Debug Logging',\n    disableDebug: 'Disable Debug Logging',\n    downloadLogs: 'Download Logs',\n    clearLogs: 'Clear Logs',\n    dockerInfo: 'Docker Info',\n    containerName: 'Container Name',\n    imageName: 'Image Name',\n    platform: 'Platform',\n    architecture: 'Architecture',\n  },\n\n  // Library (K Profiles)\n  library: {\n    title: 'Filament Library',\n    addFilament: 'Add Filament',\n    editFilament: 'Edit Filament',\n    deleteFilament: 'Delete Filament',\n    vendor: 'Vendor',\n    material: 'Material',\n    color: 'Color',\n    kFactor: 'K Factor',\n    temperature: 'Temperature',\n    noFilaments: 'No filaments in library',\n    deleteConfirm: 'Are you sure you want to delete this filament?',\n    importFromPrinter: 'Import from Printer',\n    exportToFile: 'Export to File',\n  },\n\n  // Spoolman\n  spoolman: {\n    title: 'Spoolman Integration',\n    enabled: 'Spoolman Enabled',\n    url: 'Spoolman URL',\n    connected: 'Connected',\n    disconnected: 'Not Connected',\n    testConnection: 'Test Connection',\n    sync: 'Sync',\n    syncing: 'Syncing...',\n    lastSync: 'Last Sync',\n    linkToSpoolman: 'Link to Spoolman',\n    openInSpoolman: 'Open in Spoolman',\n    unlinkSpool: 'Unlink Spool',\n    unlinkConfirmTitle: 'Unlink Spool?',\n    unlinkConfirmMessage: 'This will disconnect the spool from Spoolman. The spool data in Spoolman will remain unchanged.',\n    selectSpool: 'Select Spool',\n    noUnlinkedSpools: 'No unlinked spools available',\n    linkSuccess: 'Spool linked to Spoolman successfully',\n    linkFailed: 'Failed to link spool',\n    unlinkSuccess: 'Spool unlinked from Spoolman successfully',\n    unlinkFailed: 'Failed to unlink spool',\n    spoolId: 'Spool ID',\n    fillSourceLabel: '(Spoolman)',\n    weight: 'Weight',\n    remaining: 'Remaining',\n    disableWeightSync: 'Disable AMS Estimated Weight Sync',\n    disableWeightSyncDesc: \"Don't update remaining capacity from AMS estimates. Use this if you prefer Spoolman's usage tracking over AMS percentage-based estimates. New spools will still use the AMS estimate as their initial weight.\",\n    reportPartialUsage: 'Report Partial Usage for Failed Prints',\n    reportPartialUsageDesc: 'When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.',\n  },\n\n  // Inventory\n  inventory: {\n    title: 'Spool Inventory',\n    addSpool: 'Add Spool',\n    editSpool: 'Edit Spool',\n    material: 'Material',\n    selectMaterial: 'Select material...',\n    subtype: 'Subtype',\n    brand: 'Brand',\n    searchBrand: 'Search brand...',\n    useCustomBrand: 'Use \"{{brand}}\"',\n    useCustomMaterial: 'Use custom material: {{material}}',\n    colorName: 'Color Name',\n    colorNamePlaceholder: 'Jade White, Fire Red...',\n    color: 'Color',\n    hexColor: 'Hex Color',\n    pickColor: 'Pick custom color',\n    labelWeight: 'Label Weight',\n    coreWeight: 'Empty Spool Weight',\n    searchSpoolWeight: 'Search spool weight...',\n    weightUsed: 'Used',\n    currentWeight: 'Remaining Weight',\n    measuredWeight: 'Measured Weight',\n    spoolName: 'Spool',\n    costPerKg: 'Cost per kg',\n    measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.',\n    slicerFilament: 'Slicer Filament',\n    slicerFilamentName: 'Slicer Preset Name',\n    slicerPreset: 'Slicer Preset',\n    searchPresets: 'Search filament presets...',\n    selectedPreset: 'Selected',\n    noPresetsFound: 'No presets found',\n    tempOverrides: 'Temperature Overrides',\n    note: 'Note',\n    notePlaceholder: 'Any additional notes about this spool...',\n    archive: 'Archive',\n    restore: 'Restore',\n    noSpools: 'No spools yet. Add your first spool to get started.',\n    noManualSpools: 'No manually added spools available. Add a spool to your inventory first.',\n    kProfiles: 'K-Profiles',\n    addKProfile: 'Add K-Profile',\n    assignSpool: 'Assign Spool',\n    unassignSpool: 'Unassign',\n    assignSuccess: 'Spool assigned and AMS slot configured',\n    assignFailed: 'Failed to assign spool',\n    selectSpool: 'Select a spool to assign to this slot',\n    assigned: 'Assigned',\n    assigning: 'Assigning...',\n    searchSpools: 'Search spools...',\n    showAllSpools: 'Show all spools',\n    allMaterials: 'All Materials',\n    filterByBrand: 'Filter by brand...',\n    showArchived: 'Show archived',\n    quickAdd: 'Quick Add (Stock)',\n    quantity: 'Quantity',\n    stock: 'Stock',\n    configured: 'Configured',\n    spoolsCreated: '{{count}} spools created',\n    spoolCreated: 'Spool created',\n    spoolUpdated: 'Spool updated',\n    spoolDeleted: 'Spool deleted',\n    spoolArchived: 'Spool archived',\n    spoolRestored: 'Spool restored',\n    deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',\n    archiveConfirm: 'Are you sure you want to archive this spool?',\n    advancedSettings: 'Advanced Settings',\n    // Tabs\n    filamentInfoTab: 'Filament Info',\n    paProfileTab: 'PA Profile',\n    filamentInfo: 'Filament',\n    additional: 'Additional',\n    // Cloud\n    loadingPresets: 'Loading cloud presets...',\n    cloudConnected: 'Cloud connected',\n    cloudNotConnected: 'Cloud not connected (using defaults)',\n    // Colors\n    recentColors: 'Recent',\n    searchColors: 'Search colors...',\n    searchResults: 'Search results',\n    allColors: 'All colors',\n    commonColors: 'Common colors',\n    showLess: 'Show less',\n    showAll: 'Show all',\n    noColorsFound: 'No colors match your search',\n    noResults: 'No matches found',\n    // PA Profiles\n    selectMaterialFirst: 'Please select a material first in the Filament Info tab.',\n    noPrintersConfigured: 'No printers configured. Add printers to use PA profiles.',\n    matchingFilter: 'Matching',\n    anyBrand: 'Any brand',\n    anyVariant: 'Any variant',\n    autoSelect: 'Auto-select',\n    matches: 'matches',\n    match: 'match',\n    noMatches: 'No matches',\n    connected: 'Connected',\n    offline: 'Offline',\n    printerOffline: 'Printer is offline. Connect to view calibration profiles.',\n    noKProfilesMatch: 'No K-profiles match the selected filament.',\n    leftNozzle: 'Left Nozzle',\n    rightNozzle: 'Right Nozzle',\n    profilesSelected: 'calibration profile(s) selected',\n    // Stats & enhanced table\n    totalInventory: 'Total Inventory',\n    totalConsumed: 'Total Consumed',\n    byMaterial: 'By Material',\n    inPrinter: 'In Printer',\n    lowStock: 'Low Stock',\n    sinceTracking: 'Since tracking started',\n    loadedInAms: 'Loaded in AMS/Ext',\n    remaining: 'Remaining',\n    weightCheck: 'Weight Check',\n    lastWeighed: 'Last weighed',\n    neverWeighed: 'Never weighed',\n    search: 'Search spools...',\n    showing: 'Showing',\n    to: 'to',\n    of: 'of',\n    show: 'Show',\n    spools: 'spools',\n    spool: 'spool',\n    page: 'Page',\n    noSpoolsMatch: 'No results found',\n    noSpoolsMatchDesc: 'Try adjusting your search or filters to find what you\\'re looking for.',\n    active: 'Active',\n    archived: 'Archived',\n    all: 'All',\n    used: 'Used',\n    new: 'New',\n    clearFilters: 'Clear filters',\n    table: 'Table',\n    cards: 'Cards',\n    net: 'Net',\n    // Grouping\n    groupSimilar: 'Group',\n    groupedSpools: '{{count}} identical spools',\n    groupedRows: 'rows',\n    // Column config\n    columns: 'Columns',\n    configureColumns: 'Configure Columns',\n    configureColumnsDesc: 'Drag to reorder columns or use arrows. Toggle visibility with the eye icon.',\n    visible: 'visible',\n    reset: 'Reset',\n    cancel: 'Cancel',\n    applyChanges: 'Apply Changes',\n    moveUp: 'Move up',\n    moveDown: 'Move down',\n    hideColumn: 'Hide column',\n    showColumn: 'Show column',\n    // Tag linking\n    linkToSpool: 'Link to Spool',\n    tagLinked: 'Tag linked to spool',\n    tagLinkFailed: 'Failed to link tag',\n    tagAlreadyLinked: 'Tag already linked to another spool',\n    unknownTag: 'Unknown RFID tag detected',\n    // Usage history\n    usageHistory: 'Usage History',\n    noUsageHistory: 'No usage recorded yet',\n    printName: 'Print Name',\n    weightConsumed: 'Weight Consumed',\n    clearHistory: 'Clear',\n    historyCleared: 'Usage history cleared',\n    fillSourceLabel: '(Inv)',\n    lowStockThresholdError: 'Threshold must be between 0.1 and 99.9',\n    assignMismatchTitle: 'Material mismatch',\n    assignMismatchMessage: 'The selected spool material \"{{spoolMaterial}}\" does not match the tray material \"{{trayMaterial}}\" for {{location}}. Assign anyway?',\n    assignMismatchConfirm: 'Assign Anyway',\n    assignPartialMismatchMessage: 'The spool material \"{{spoolMaterial}}\" is similar to but not exactly matching \"{{trayMaterial}}\" in {{location}}. Do you want to proceed?',\n    assignProfileMismatchMessage: 'The spool profile \"{{spoolProfile}}\" does not match the tray profile \"{{trayProfile}}\" in {{location}}. Do you want to proceed?',\n  },\n\n  // Timelapse\n  timelapse: {\n    title: 'Timelapse',\n    create: 'Create Timelapse',\n    download: 'Download',\n    delete: 'Delete',\n    preview: 'Preview',\n    frameRate: 'Frame Rate',\n    quality: 'Quality',\n    processing: 'Processing...',\n    noTimelapses: 'No timelapses available',\n  },\n\n  // AMS\n  ams: {\n    title: 'AMS',\n    slot: 'Slot',\n    empty: 'Empty',\n    emptySlot: 'Empty slot',\n    unknown: 'Unknown',\n    humidity: 'Humidity',\n    temperature: 'Temperature',\n    filamentType: 'Filament Type',\n    filamentColor: 'Color',\n    remaining: 'Remaining',\n    history: 'AMS History',\n    noHistory: 'No history available',\n    configureSlot: 'Configure Slot',\n    externalSpool: 'External Spool',\n    profile: 'Profile',\n    kFactor: 'K Factor',\n    fill: 'Fill',\n    configure: 'Configure',\n    used: 'used',\n    remainingUnit: 'remaining',\n  },\n\n  // Print modal\n  printModal: {\n    title: 'Start Print',\n    selectPrinter: 'Select Printer',\n    selectPlate: 'Select Plate',\n    filamentMapping: 'Filament Mapping',\n    totalCost: 'Total cost:',\n    slotRemainingShort: ' - {{grams}}g left',\n    printSettings: 'Print Settings',\n    bedLeveling: 'Bed Leveling',\n    flowCalibration: 'Flow Calibration',\n    vibrationCalibration: 'Vibration Calibration',\n    layerInspection: 'First Layer Inspection',\n    timelapse: 'Timelapse',\n    startPrint: 'Start Print',\n    addToQueue: 'Add to Queue',\n    cancel: 'Cancel',\n    noPrintersAvailable: 'No printers available',\n    printerBusy: 'Printer is busy',\n    printerOffline: 'Printer is offline',\n    sameTypeDifferentColor: 'Same type, different color',\n    filamentTypeNotLoaded: 'Filament type not loaded',\n    openCalendar: 'Open calendar',\n    leftNozzle: 'L',\n    rightNozzle: 'R',\n    leftNozzleTooltip: 'Left nozzle',\n    rightNozzleTooltip: 'Right nozzle',\n    filamentOverride: 'Filament Override',\n    filamentOverrideHint: 'Optionally override filaments for model-based assignment. The scheduler will match against your selected filaments instead of the original 3MF values.',\n    originalFilament: 'Original',\n    overrideWith: 'Override with',\n    resetToOriginal: 'Reset to original',\n    insufficientFilamentTitle: 'Not enough filament',\n    insufficientFilamentMessage: 'Some assigned spools have less filament remaining than this print needs:',\n    insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',\n    printAnyway: 'Print anyway',\n    forceColorMatch: 'Force color match',\n    staggerPrinterStarts: 'Stagger printer starts',\n    staggerGroupSize: 'Group size',\n    staggerInterval: 'Interval (min)',\n    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',\n    staggerLastGroup: 'last group: {{count}}',\n    staggerTotal: 'total: {{minutes}} min',\n    staggerToPrinters: 'Stagger to {{count}} printers',\n    gcodeInjection: 'Inject auto-print G-code',\n  },\n\n  // Backup\n  backup: {\n    title: 'Backup & Restore',\n    createBackup: 'Create Backup',\n    restoreBackup: 'Restore Backup',\n    restoreDescription: 'Replace all data from a backup file',\n    downloadBackup: 'Download Backup',\n    uploadBackup: 'Upload Backup',\n    lastBackup: 'Last Backup',\n    autoBackup: 'Auto Backup',\n    backupNow: 'Backup Now',\n    restoreWarning: 'Warning: Restoring a backup will overwrite all current data.',\n    includeArchives: 'Include Archives',\n    includeSettings: 'Include Settings',\n    includeProfiles: 'Include Profiles',\n    backupSuccess: 'Backup created successfully',\n    restoreSuccess: 'Backup restored successfully',\n    backupFailed: 'Backup failed',\n    restoreFailed: 'Restore failed',\n    restoreNote: 'Virtual Printer will be stopped during restore',\n\n    // GitHub Backup\n    githubBackup: 'GitHub Backup',\n    enabled: 'Enabled',\n    cloudLoginRequired: 'Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.',\n    cloudLoginRequiredShort: 'Cloud login required',\n    githubDescription: 'Automatically sync your profiles to a private GitHub repository for backup and version history.',\n    repositoryUrl: 'Repository URL',\n    personalAccessToken: 'Personal Access Token',\n    tokenSaved: '(saved)',\n    enterNewToken: 'Enter new token to update',\n    tokenHint: 'Fine-grained token with Contents read/write permission',\n    branch: 'Branch',\n    manualOnly: 'Manual only',\n    hourly: 'Hourly',\n    daily: 'Daily',\n    weekly: 'Weekly',\n    includeInBackup: 'Include in backup',\n    kProfiles: 'K-Profiles',\n    kProfilesDescription: 'Pressure advance calibration from connected printers',\n    noPrintersConnected: 'No printers connected',\n    printersConnected: '{{connected}}/{{total}} connected',\n    cloudProfiles: 'Cloud Profiles',\n    cloudProfilesDescription: 'Filament, printer, and process presets from Bambu Cloud',\n    appSettings: 'App Settings',\n    appSettingsDescription: 'Bambuddy configuration (complete database)',\n    spoolInventory: 'Spool Inventory',\n    spoolInventoryDescription: 'Filament spools, usage history, and cost tracking',\n    printArchives: 'Print Archives',\n    printArchivesDescription: 'Print history metadata (no gcode/3MF files)',\n    lastBackupAt: 'Last backup:',\n    noBackupsYet: 'No backups yet',\n    next: 'Next:',\n    startingBackup: 'Starting backup...',\n    test: 'Test',\n    enableBackup: 'Enable Backup',\n    testConnection: 'Test Connection',\n    enterRepoUrl: 'Enter repository URL',\n    enterRepoAndToken: 'Enter repository URL and access token',\n    repoRequired: 'Repository URL is required',\n    tokenRequired: 'Access token is required',\n    githubBackupEnabled: 'GitHub backup enabled',\n    tokenUpdated: 'Token updated',\n    settingsSaved: 'Settings saved',\n    failedToSave: 'Failed to save: {{message}}',\n    backupCompleteFiles: 'Backup complete - {{count}} files updated',\n    backupSkippedNoChanges: 'Backup skipped - no changes',\n    backupFailed2: 'Backup failed: {{message}}',\n    clearedLogs: 'Cleared {{count}} logs',\n    failedToClearLogs: 'Failed to clear logs: {{message}}',\n\n    // History\n    history: 'History',\n    clear: 'Clear',\n    date: 'Date',\n    status: 'Status',\n    commit: 'Commit',\n\n    // Local Backup\n    localBackup: 'Local Backup',\n    localBackupDescription: 'Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.',\n    downloadBackupLabel: 'Download Backup',\n    completeBackupZip: 'Complete backup: database + all files (ZIP)',\n    download: 'Download',\n    preparingBackup: 'Preparing backup...',\n    creatingArchive: 'Creating backup archive... This may take a while for large archives.',\n    downloadingFile: 'Downloading backup file...',\n    backupDownloaded: 'Backup downloaded successfully',\n    failedToCreateBackup: 'Failed to create backup: {{message}}',\n    restore: 'Restore',\n    restoreReplacesAll: 'Restore replaces all data.',\n    restoreReplacesAllDetail: 'Your current database and files will be completely replaced. A restart is required after restore.',\n    restoreConfirmTitle: 'Restore Backup',\n    restoreConfirmMessage: 'Are you sure you want to restore from \"{{filename}}\"? This will completely replace your current database and all files. The application will need to be restarted after restore.',\n    restoreConfirmButton: 'Restore Backup',\n    uploadingFile: 'Uploading backup file...',\n    backupRestoredRestart: 'Backup restored. Please restart Bambuddy.',\n    failedToRestore: 'Failed to restore backup. Please check the file format.',\n    reloadNow: 'Reload Now',\n    creatingBackup: 'Creating Backup',\n    restoringBackup: 'Restoring Backup',\n    preparing: 'Preparing...',\n    processing: 'Processing...',\n    doNotClosePage: 'Please do not close this page or navigate away. This operation may take several minutes for large backups.',\n\n    // RestoreModal\n    restoring: 'Restoring...',\n    restoreComplete: 'Restore Complete',\n    restoreFailed2: 'Restore Failed',\n    importSettings: 'Import settings from a backup file',\n    pleaseWaitRestoring: 'Please wait while your data is being restored',\n    selectBackupFile: 'Click to select backup file (.json or .zip)',\n    duplicateHandling: 'How duplicate handling works:',\n    matchPrinters: 'Printers',\n    matchPrintersBy: 'matched by serial number',\n    matchSmartPlugs: 'Smart Plugs',\n    matchSmartPlugsBy: 'matched by IP address',\n    matchNotificationProviders: 'Notification Providers',\n    matchNotificationProvidersBy: 'matched by name',\n    matchFilaments: 'Filaments',\n    matchFilamentsBy: 'matched by name + type + brand',\n    matchArchives: 'Archives',\n    matchArchivesBy: 'matched by content hash (always skipped)',\n    matchPendingUploads: 'Pending Uploads',\n    matchPendingUploadsBy: 'matched by filename',\n    matchSettingsTemplates: 'Settings & Templates',\n    matchSettingsTemplatesBy: 'always overwritten',\n    replaceExisting: 'Replace existing data',\n    keepExisting: 'Keep existing data',\n    overwriteDescription: 'Overwrite items that already exist with backup data',\n    keepDescription: \"Only restore items that don't already exist\",\n    overwriteCaution: 'Caution:',\n    overwriteWarning: 'Overwriting will replace your current configurations with data from the backup. Printer access codes are never overwritten for security.',\n    cancel: 'Cancel',\n    processingBackup: 'Processing backup file...',\n    itemsRestored: 'Items Restored',\n    itemsSkipped: 'Items Skipped',\n    restored: 'Restored',\n    skippedAlreadyExist: 'Skipped (already exist)',\n    filesCategory: 'Files (3MF, thumbnails, etc.)',\n    andMore: '...and {{count}} more',\n    newApiKeysGenerated: 'New API Keys Generated',\n    keysShownOnce: 'These keys are only shown once. Copy them now!',\n    copy: 'Copy',\n    noDataFound: 'No data was found to restore in the backup file.',\n    close: 'Close',\n\n    // Scheduled local backups (#884)\n    scheduledBackup: 'Scheduled Backups',\n    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',\n    frequency: 'Frequency',\n    backupTime: 'Time',\n    retention: 'Retention',\n    retentionDescription: 'Number of backups to keep',\n    outputPath: 'Output Path',\n    outputPathPlaceholder: 'Default: {{path}}',\n    outputPathDescription: 'Leave empty for default location',\n    runNow: 'Run Now',\n    backupFiles: 'Backup Files',\n    noScheduledBackups: 'No backups yet',\n    deleteBackup: 'Delete',\n    deleteBackupConfirm: 'Delete this backup file?',\n    backupRunning: 'Backup in progress...',\n    scheduledBackupComplete: 'Backup completed successfully',\n    scheduledBackupFailed: 'Backup failed',\n    nextBackup: 'Next backup',\n    backupSize: 'Size',\n    utc: 'UTC',\n    defaultPathLabel: 'Default:',\n\n    // Category labels\n    categories: {\n      settings: 'Settings',\n      notification_providers: 'Notification Providers',\n      notification_templates: 'Notification Templates',\n      smart_plugs: 'Smart Plugs',\n      printers: 'Printers',\n      filaments: 'Filaments',\n      maintenance_types: 'Maintenance Types',\n      archives: 'Archives',\n      projects: 'Projects',\n      pending_uploads: 'Pending Uploads',\n      external_links: 'External Links',\n      api_keys: 'API Keys',\n    },\n  },\n\n  // Tags\n  tags: {\n    title: 'Tags',\n    addTag: 'Add Tag',\n    editTag: 'Edit Tag',\n    deleteTag: 'Delete Tag',\n    tagName: 'Tag Name',\n    tagColor: 'Tag Color',\n    noTags: 'No tags',\n    deleteConfirm: 'Are you sure you want to delete this tag?',\n    manageTags: 'Manage Tags',\n  },\n\n  // Upload modal (archives)\n  uploadModal: {\n    title: 'Upload 3MF Files',\n    dragDrop: 'Drag & drop .3mf files here',\n    or: 'or',\n    browseFiles: 'Browse Files',\n    extractionInfo: 'The printer model will be automatically extracted from the 3MF file metadata.',\n    uploaded: 'uploaded',\n    failed: 'failed',\n    uploading: 'Uploading...',\n    upload: 'Upload',\n    uploadFailed: 'Upload failed',\n  },\n\n  // Edit archive modal\n  // Edit Archive Modal\n  editArchive: {\n    title: 'Edit Archive',\n    name: 'Name',\n    namePlaceholder: 'Print name',\n    printer: 'Printer',\n    noPrinter: 'No printer',\n    project: 'Project',\n    noProject: 'No project',\n    itemsPrinted: 'Items Printed',\n    itemsPrintedHelp: 'Number of items produced in this print job',\n    notes: 'Notes',\n    notesPlaceholder: 'Add notes about this print...',\n    externalLink: 'External Link',\n    externalLinkPlaceholder: 'https://printables.com/model/...',\n    externalLinkHelp: 'Link to Printables, Thingiverse, or other source',\n    tags: 'Tags',\n    tagsPlaceholder: 'Add tags...',\n    addMoreTags: 'Add more tags...',\n    matchingTags: 'Matching \"{{query}}\"',\n    existingTags: 'Existing tags',\n    clickToAdd: '(click to add)',\n    status: 'Status',\n    failureReason: 'Failure Reason',\n    selectReason: 'Select reason...',\n    photos: 'Photos of Printed Result',\n    photosHelp: 'Click + to add photos of your printed result',\n    printResult: 'Print result',\n    saving: 'Saving...',\n    // Failure reasons\n    failureReasons: {\n      adhesionFailure: 'Adhesion failure',\n      spaghettiDetached: 'Spaghetti / Detached',\n      layerShift: 'Layer shift',\n      cloggedNozzle: 'Clogged nozzle',\n      filamentRunout: 'Filament runout',\n      warping: 'Warping',\n      stringing: 'Stringing',\n      underExtrusion: 'Under-extrusion',\n      powerFailure: 'Power failure',\n      userCancelled: 'User cancelled',\n      other: 'Other',\n    },\n    // Archive statuses\n    statuses: {\n      completed: 'Completed',\n      failed: 'Failed',\n      aborted: 'Cancelled',\n      printing: 'Printing',\n    },\n  },\n\n  // K-Profiles\n  kProfiles: {\n    title: 'K-Profiles',\n    noPrintersConfigured: 'No Printers Configured',\n    addPrinterInSettings: 'Add a printer in Settings to manage K-profiles',\n    noActivePrinters: 'No Active Printers',\n    enablePrinterConnection: 'Enable a printer connection to view its K-profiles',\n    loadingProfiles: 'Loading K-Profiles...',\n    printerOffline: 'Printer Offline',\n    printerOfflineDesc: 'The selected printer is not connected. Power it on to view K-profiles.',\n    noMatchingProfiles: 'No Matching Profiles',\n    noMatchingProfilesDesc: 'No profiles match your search criteria',\n    noKProfiles: 'No K-Profiles',\n    noKProfilesDesc: 'No pressure advance profiles found for {{diameter}}mm nozzle',\n    createFirstProfile: 'Create First Profile',\n    // Controls\n    printer: 'Printer',\n    nozzle: 'Nozzle',\n    refresh: 'Refresh',\n    addProfile: 'Add Profile',\n    export: 'Export',\n    import: 'Import',\n    select: 'Select',\n    selectAll: 'Select All',\n    delete: 'Delete',\n    // Filters\n    searchPlaceholder: 'Search by name or filament...',\n    allExtruders: 'All Extruders',\n    leftOnly: 'Left Only',\n    rightOnly: 'Right Only',\n    allFlow: 'All Flow',\n    hfOnly: 'HF Only',\n    sOnly: 'S Only',\n    sortName: 'Sort: Name',\n    sortKValue: 'Sort: K-Value',\n    sortFilament: 'Sort: Filament',\n    // Dual extruder labels\n    leftExtruder: 'Left Extruder',\n    rightExtruder: 'Right Extruder',\n    // Modal\n    modal: {\n      addTitle: 'Add K-Profile',\n      editTitle: 'Edit K-Profile',\n      profileName: 'Profile Name',\n      profileNamePlaceholder: 'My PLA Profile',\n      kValue: 'K-Value',\n      kValuePlaceholder: '0.020',\n      kValueHelp: 'Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG',\n      filament: 'Filament',\n      selectFilament: 'Select filament...',\n      noFilamentsHelp: 'No filaments found. Create a K-profile in Bambu Studio first.',\n      flowType: 'Flow Type',\n      highFlow: 'High Flow',\n      standard: 'Standard',\n      nozzleSize: 'Nozzle Size',\n      extruder: 'Extruder',\n      extruders: 'Extruders',\n      left: 'Left',\n      right: 'Right',\n      notes: 'Notes (stored locally)',\n      notesPlaceholder: 'Add notes about this profile...',\n      notesHelp: 'Notes are saved in Bambuddy, not on the printer',\n      syncing: 'Syncing with printer...',\n      savingExtruder: 'Saving to extruder {{current}}/{{total}}...',\n      pleaseWait: 'Please wait',\n    },\n    // Delete confirmation\n    deleteConfirm: {\n      title: 'Delete Profile',\n      cannotUndo: 'This cannot be undone',\n      message: 'Are you sure you want to delete \"{{name}}\" from the printer?',\n    },\n    // Bulk delete\n    bulkDelete: {\n      title: 'Delete Profiles',\n      cannotUndo: 'This cannot be undone',\n      message: 'Are you sure you want to delete {{count}} selected profiles from the printer?',\n    },\n    // Toast\n    toast: {\n      profileSaved: 'K-profile saved',\n      profilesSaved: 'K-profile saved to {{count}} extruders',\n      selectAtLeastOneExtruder: 'Please select at least one extruder',\n      profileDeleted: 'K-profile deleted',\n      profilesDeleted: 'Deleted {{count}} profiles',\n      exportedProfiles: 'Exported {{count}} profiles',\n      importedProfiles: 'Imported {{count}} of {{total}} profiles',\n      noProfilesToExport: 'No profiles to export',\n      invalidFileFormat: 'Invalid file format',\n      failedToParseImport: 'Failed to parse import file',\n      failedToSaveBatch: 'Failed to save K-profiles',\n      noteSaved: 'Note saved',\n      failedToSaveNote: 'Failed to save note',\n    },\n    // Permissions\n    permission: {\n      noRead: 'You do not have permission to refresh profiles',\n      noCreate: 'You do not have permission to add profiles',\n      noUpdate: 'You do not have permission to update K-profiles',\n      noDelete: 'You do not have permission to delete K-profiles',\n      noExport: 'You do not have permission to export profiles',\n      noImport: 'You do not have permission to import profiles',\n    },\n  },\n\n  // Virtual Printer\n  virtualPrinter: {\n    title: 'Virtual Printer',\n    running: 'Running',\n    stopped: 'Stopped',\n    description: {\n      default: 'Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer will be archived directly without printing.',\n      proxy: 'Enable a proxy that relays slicer traffic to a real printer, allowing remote printing over any network.',\n    },\n    enable: {\n      title: 'Enable Virtual Printer',\n      visibleInSlicer: 'Visible as \"Bambuddy\" in slicer discovery',\n      proxyingTo: 'Proxying to {{name}}',\n      notActive: 'Not active',\n    },\n    model: {\n      title: 'Printer Model',\n      description: 'Select which printer model to emulate.',\n      restartWarning: 'Changing the model will restart the virtual printer',\n    },\n    accessCode: {\n      title: 'Access Code',\n      isSet: 'Access code is set',\n      notSet: 'No access code set - required to enable',\n      placeholder: 'Enter 8-char code',\n      placeholderChange: 'Enter new code to change',\n      hint: 'Must be exactly 8 characters. Used by slicers to authenticate.',\n      charCount: '({{count}}/8)',\n    },\n    targetPrinter: {\n      title: 'Target Printer',\n      configured: 'Proxy target configured',\n      notConfigured: 'No target printer selected - required for proxy mode',\n      placeholder: 'Select a printer...',\n      hint: 'Select the printer to proxy slicer traffic to. The printer must be in LAN mode.',\n      noPrinters: 'No printers configured. Add a printer first to use proxy mode.',\n    },\n    remoteInterface: {\n      title: 'Network Interface Override',\n      configured: 'Interface override active',\n      optional: 'Optional - use if auto-detected IP is wrong (e.g. multiple NICs, Docker, VPN)',\n      placeholder: 'Auto-detect (default)...',\n      hint: 'Override the IP address advertised via SSDP and used in the TLS certificate. Useful when Bambuddy has multiple network interfaces.',\n    },\n    mode: {\n      title: 'Mode',\n      archive: 'Archive',\n      archiveDesc: 'Archive files immediately',\n      review: 'Review',\n      reviewDesc: 'Review before archiving',\n      queue: 'Queue',\n      queueDesc: 'Archive and add to queue',\n      proxy: 'Proxy',\n      proxyDesc: 'Relay to real printer',\n    },\n    autoDispatch: {\n      title: 'Auto-dispatch',\n      description: 'Automatically start prints when added to queue. When off, prints wait for manual dispatch.',\n    },\n    setupRequired: {\n      title: 'Setup Required',\n      description: 'The virtual printer feature requires additional system configuration before it will work. This includes port forwarding, firewall rules, and platform-specific settings.',\n      readGuide: 'Read the setup guide before enabling',\n    },\n    howItWorks: {\n      title: 'How it works',\n      step1: 'On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.',\n      step2: 'In Archive, Review, and Queue modes, use the \"Send\" button in your slicer to upload 3MF files to Bambuddy. The slicer will show \"Print success\" — the file is stored, not printed.',\n      step3: 'In Proxy mode, the virtual printer relays all traffic to a real printer — prints start immediately as if connected directly.',\n    },\n    status: {\n      title: 'Status Details',\n      printerName: 'Printer Name',\n      model: 'Model',\n      serialNumber: 'Serial Number',\n      mode: 'Mode',\n      pendingFiles: 'Pending Files',\n      targetPrinter: 'Target Printer',\n      ftpPort: 'FTP Port',\n      mqttPort: 'MQTT Port',\n      ftpConnections: 'FTP Connections',\n      mqttConnections: 'MQTT Connections',\n    },\n    toast: {\n      updated: 'Virtual printer settings updated',\n      failedToUpdate: 'Failed to update settings',\n      accessCodeRequired: 'Please set an access code first',\n      targetPrinterRequired: 'Please select a target printer first',\n      bindIpRequired: 'Please set a bind IP first',\n      accessCodeEmpty: 'Access code cannot be empty',\n      accessCodeLength: 'Access code must be exactly 8 characters',\n      created: 'Virtual printer created',\n      failedToCreate: 'Failed to create virtual printer',\n      deleted: 'Virtual printer deleted',\n      failedToDelete: 'Failed to delete virtual printer',\n    },\n    list: {\n      title: 'Virtual Printers',\n      add: 'Add',\n      addFirst: 'Add Virtual Printer',\n      empty: 'No virtual printers configured. Add one to get started.',\n    },\n    bindIp: {\n      title: 'Bind Interface',\n      placeholder: 'Select interface...',\n      hint: 'Network interface for this virtual printer to bind to. Must be unique per printer.',\n    },\n    proxy: {\n      accessCodeHint: 'In proxy mode, use your target printer\\'s access code in the slicer. The connection is forwarded transparently to the real printer.',\n    },\n    addDialog: {\n      title: 'Add Virtual Printer',\n      name: 'Name',\n      hint: 'You can configure access code, target printer, and other settings after creating.',\n      create: 'Create',\n    },\n    deleteConfirm: {\n      title: 'Delete Virtual Printer',\n      message: 'Are you sure you want to delete \"{{name}}\"? This will stop all services for this printer.',\n    },\n  },\n\n  // Model Viewer\n  modelViewer: {\n    openInSlicer: 'Open in Slicer',\n    tabs: {\n      model: '3D Model',\n      gcode: 'G-code Preview',\n    },\n    notAvailable: 'not available',\n    notSliced: 'not sliced',\n    plates: 'Plates',\n    allPlates: 'All Plates',\n    plateNumber: 'Plate {{number}}',\n    plateCount: '{{count}} plate',\n    plateCount_other: '{{count}} plates',\n    objectCount: '{{count}} object',\n    objectCount_other: '{{count}} objects',\n    filamentCount: '{{count}} filament',\n    filamentCount_other: '{{count}} filaments',\n    eta: 'ETA {{minutes}} min',\n    noPreview: 'No preview available for this file',\n    pagination: {\n      pageOf: 'Page {{current}} of {{total}}',\n      prev: 'Prev',\n      next: 'Next',\n    },\n    errors: {\n      failedToLoad: 'Failed to load file',\n      noMeshes: 'No meshes found in 3MF file',\n      unsupportedFormat: 'Unsupported file format',\n    },\n  },\n\n  // Maintenance type descriptions (built-in)\n  maintenanceDescriptions: {\n    lubricateCarbonRods: 'Apply lubricant to carbon rods for smooth motion',\n    lubricateRails: 'Apply lubricant to linear rails for smooth motion',\n    cleanNozzle: 'Clean hotend and nozzle to prevent clogs',\n    checkBelts: 'Verify belt tension for accurate prints',\n    cleanBuildPlate: 'Clean build plate for better adhesion',\n    checkExtruder: 'Inspect extruder gears for wear',\n    checkCooling: 'Ensure cooling fans are working properly',\n    generalInspection: 'General printer inspection',\n    cleanCarbonRods: 'Clean carbon rods to reduce friction',\n    lubricateSteelRods: 'Apply lubricant to steel rods for smooth motion',\n    cleanSteelRods: 'Clean steel rods to reduce friction',\n    cleanLinearRails: 'Wipe linear rails to remove dust and debris',\n    checkPtfeTube: 'Inspect PTFE tube for wear or damage',\n    replaceHepaFilter: 'Replace HEPA filter for air quality',\n    replaceCarbonFilter: 'Replace activated carbon filter',\n    lubricateLeftNozzleRail: 'Lubricate left nozzle rail (H2 series)',\n  },\n\n  // Smart Plugs\n  smartPlugs: {\n    offline: 'Offline',\n    admin: 'Admin',\n    openPlugAdminPage: 'Open plug admin page',\n    deleteSmartPlug: 'Delete Smart Plug',\n    turnOnSmartPlug: 'Turn On Smart Plug',\n    turnOffSmartPlug: 'Turn Off Smart Plug',\n    turnOn: 'Turn On',\n    turnOff: 'Turn Off',\n    addSmartPlug: {\n      scanningNetwork: 'Scanning network...',\n      chooseEntity: 'Choose an entity...',\n      connectionFailed: 'Connection failed',\n      searchEntities: 'Search entities...',\n      searchPowerSensors: 'Search power sensors...',\n      searchEnergySensors: 'Search energy sensors...',\n      placeholders: {\n        plugName: 'Living Room Plug',\n        mqttStateOnValue: 'ON, true, 1',\n        mqttSameAsPower: 'Same as power topic, or different',\n      },\n    },\n    // SmartPlugCard\n    linkedTo: 'Linked to:',\n    monitorOnly: 'Monitor Only',\n    alerts: 'Alerts',\n    scheduleOn: 'On {{time}}',\n    scheduleOff: 'Off {{time}}',\n    on: 'On',\n    off: 'Off',\n    power: 'Power',\n    kwhToday: 'kWh Today',\n    settings: 'Settings',\n    automationSettings: 'Automation Settings',\n    showInSwitchbar: 'Show in Switchbar',\n    quickAccessSidebar: 'Quick access from sidebar',\n    enabled: 'Enabled',\n    enableAutomation: 'Enable automation for this plug',\n    autoOn: 'Auto On',\n    autoOnDescription: 'Turn on when print starts',\n    autoOff: 'Auto Off',\n    autoOffDescription: 'Turn off when print completes (one-shot)',\n    autoOffPersistent: 'Keep Enabled',\n    autoOffPersistentDescription: 'Stay enabled between prints instead of one-shot',\n    turnOffDelayMode: 'Turn Off Delay Mode',\n    time: 'Time',\n    temp: 'Temp',\n    delayMinutes: 'Delay (minutes)',\n    tempThreshold: 'Temperature threshold (°C)',\n    tempThresholdDescription: 'Turns off when nozzle cools below this temperature',\n    edit: 'Edit',\n    deleteConfirm: 'Are you sure you want to delete \"{{name}}\"? This cannot be undone.',\n    turnOnConfirm: 'Are you sure you want to turn on \"{{name}}\"?',\n    turnOffConfirm: 'Are you sure you want to turn off \"{{name}}\"? This will cut power to the connected device.',\n    failedToTurn: 'Failed to turn {{action}} \"{{name}}\"',\n    unknown: 'Unknown',\n    // AddSmartPlugModal\n    addTitle: 'Add Smart Plug',\n    editTitle: 'Edit Smart Plug',\n    stopScanning: 'Stop Scanning',\n    discoverTasmota: 'Discover Tasmota Devices',\n    foundDevices: 'Found {{count}} device(s) - click to select:',\n    noDevicesFound: 'No Tasmota devices found on your network',\n    haNotConfigured: 'Home Assistant is not configured. Set it up in',\n    haSettingsPath: 'Settings → Network → Home Assistant',\n    selectEntity: 'Select Entity *',\n    ipAddress: 'IP Address *',\n    nameLabel: 'Name *',\n    username: 'Username',\n    password: 'Password',\n    authHint: \"Leave empty if your Tasmota device doesn't require authentication\",\n    linkToPrinter: 'Link to Printer',\n    noPrinter: 'No printer (manual control only)',\n    linkingDescription: 'Linking enables automatic on/off when prints start/complete',\n    powerAlerts: 'Power Alerts',\n    alertAbove: 'Alert if above (W)',\n    alertBelow: 'Alert if below (W)',\n    alertDescription: 'Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.',\n    dailySchedule: 'Daily Schedule',\n    turnOnAt: 'Turn On at',\n    turnOffAt: 'Turn Off at',\n    scheduleDescription: 'Automatically turn the plug on/off at these times daily. Leave empty to skip that action.',\n    showOnPrinterCard: 'Show on Printer Card',\n    displayOnPrinterCard: 'Display button on printer card',\n    connectedResult: 'Connected!',\n    deviceLabel: 'Device: {{name}} - ',\n    stateLabel: 'State: {{state}}',\n    test: 'Test',\n    delete: 'Delete',\n    save: 'Save',\n    add: 'Add',\n    cancel: 'Cancel',\n    failedToStartScan: 'Failed to start scan',\n    nameRequired: 'Name is required',\n    entityRequired: 'Entity is required for Home Assistant plugs',\n    mqttTopicRequired: 'At least one MQTT topic must be configured for power, energy, or state monitoring',\n    loadingEntities: 'Loading entities...',\n    loading: 'Loading...',\n    failedToLoadEntities: 'Failed to load entities: {{error}}',\n    noEntitiesMatching: 'No entities found matching \"{{search}}\"',\n    noEntitiesAvailable: 'No entities available',\n    searchingEntities: 'Searching all entities ({{count}} found)',\n    showingEntities: 'Showing switch, light, input_boolean ({{count}} available)',\n    energyMonitoringOptional: 'Energy Monitoring (Optional)',\n    energyMonitoringHint: 'Search and select sensors that provide power/energy data.',\n    powerSensorW: 'Power Sensor (W)',\n    energyTodayKwh: 'Energy Today (kWh)',\n    totalEnergyKwh: 'Total Energy (kWh)',\n    noMatchingSensors: 'No matching sensors',\n    none: 'None',\n    mqttNotConfigured: 'MQTT broker not configured. Set broker address in',\n    mqttSettingsPath: 'Settings → Network → MQTT Publishing',\n    mqttNotConfiguredSuffix: \"(you don't need to enable publishing, just fill in the broker details).\",\n    mqttMonitorOnlyDescription: 'MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system.',\n    powerMonitoring: 'Power Monitoring',\n    energyMonitoring: 'Energy Monitoring',\n    stateMonitoring: 'State Monitoring',\n    optional: 'optional',\n    topic: 'Topic',\n    jsonPath: 'JSON Path',\n    multiplier: 'Multiplier',\n    onValue: 'ON Value',\n    mqttPowerHint: 'JSON path extracts value from JSON payload (e.g., \"power_l1\"). Leave empty if topic publishes raw numeric values.\\nUse multiplier 0.001 for mW→W, 1000 for kW→W.',\n    mqttEnergyHint: 'JSON path extracts value from JSON payload. Leave empty for raw values.\\nUse multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.',\n    mqttStateHint: 'JSON path extracts value from JSON payload. Leave empty for raw values.\\nON value: the exact string that means \"ON\". Leave empty for auto-detect (ON, true, 1).',\n    // REST smart plug\n    restControl: 'Control',\n    restOnUrl: 'Turn ON URL',\n    restOffUrl: 'Turn OFF URL',\n    restOnBody: 'ON Request Body',\n    restOffBody: 'OFF Request Body',\n    restMethod: 'HTTP Method',\n    restHeaders: 'Custom Headers (JSON)',\n    restStatusUrl: 'Status URL',\n    restStatusPath: 'State JSON Path',\n    restStatusOnValue: 'ON Value',\n    restPowerUrl: 'Power URL',\n    restPowerPath: 'Power JSON Path',\n    restPowerMultiplier: 'Power Multiplier',\n    restEnergyUrl: 'Energy URL',\n    restEnergyPath: 'Energy JSON Path',\n    restEnergyMultiplier: 'Energy Multiplier',\n    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',\n    restHeadersHint: 'e.g. {\"Authorization\": \"Bearer your-token\"}',\n    restBodyHint: 'e.g. ON, {\"state\": \"on\"}',\n    restStatusHint: 'URL to poll for current state',\n    restPathHint: 'e.g. state or data.power.status',\n    restPowerUrlHint: 'Separate URL for power data (uses Status URL if empty)',\n    restEnergyUrlHint: 'Separate URL for energy data (uses Status URL if empty)',\n    restEnergyHint: 'Each value can use its own URL or fall back to the Status URL. Use multipliers for unit conversion (e.g. 0.001 to convert Wh to kWh).',\n    testConnection: 'Test Connection',\n    connectionSuccess: 'Connection successful',\n    noSwitchesInSwitchbar: 'No switches in switchbar',\n    enableSwitchbarHint: 'Enable \"Show in Switchbar\" in Settings > Smart Plugs',\n  },\n\n  // Notifications\n  notifications: {\n    // Provider types\n    providerTypes: {\n      callmebot: 'CallMeBot/WhatsApp',\n      ntfy: 'ntfy',\n      pushover: 'Pushover',\n      telegram: 'Telegram',\n      email: 'Email',\n      discord: 'Discord',\n      webhook: 'Webhook',\n      homeassistant: 'Home Assistant',\n    },\n    // Provider descriptions\n    providerDescriptions: {\n      email: 'SMTP email notifications',\n      telegram: 'Notifications via Telegram bot',\n      discord: 'Send to Discord channel via webhook',\n      ntfy: 'Free, self-hostable push notifications',\n      pushover: 'Simple, reliable push notifications',\n      callmebot: 'Free WhatsApp notifications via CallMeBot',\n      webhook: 'Generic HTTP POST to any URL',\n      homeassistant: 'Persistent notifications in Home Assistant dashboard',\n    },\n    // NotificationProviderCard\n    lastSuccess: 'Last: {{date}}',\n    error: 'Error',\n    printer: 'Printer:',\n    allPrinters: 'All printers',\n    sendTestNotification: 'Send Test Notification',\n    eventSettings: 'Event Settings',\n    enabled: 'Enabled',\n    sendFromProvider: 'Send notifications from this provider',\n    // Event categories\n    printEvents: 'Print Events',\n    printerStatus: 'Printer Status',\n    amsAlarms: 'AMS Alarms',\n    amsHtAlarms: 'AMS-HT Alarms',\n    printQueue: 'Print Queue',\n    // Event tags (badges)\n    start: 'Start',\n    plateCheck: 'Plate Check',\n    complete: 'Complete',\n    failed: 'Failed',\n    stopped: 'Stopped',\n    progress: 'Progress',\n    offline: 'Offline',\n    lowFilament: 'Low Filament',\n    maintenance: 'Maintenance',\n    amsHumidity: 'AMS Humidity',\n    amsTemp: 'AMS Temp',\n    amsHtHumidity: 'AMS-HT Humidity',\n    amsHtTemp: 'AMS-HT Temp',\n    bedCooled: 'Bed Cooled',\n    firstLayer: 'First Layer',\n    quiet: 'Quiet',\n    digest: 'Digest {{time}}',\n    // Event labels (expanded settings)\n    printStarted: 'Print Started',\n    plateNotEmpty: 'Plate Not Empty',\n    plateNotEmptyDescription: 'Objects detected before print',\n    printCompleted: 'Print Completed',\n    bedCooledLabel: 'Bed Cooled',\n    bedCooledDescription: 'Bed cooled below threshold after print',\n    firstLayerCompleteLabel: 'First Layer Complete',\n    firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',\n    missingSpoolAssignmentLabel: 'Missing Spool Assignment',\n    missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',\n    printFailed: 'Print Failed',\n    printStopped: 'Print Stopped',\n    progressMilestones: 'Progress Milestones',\n    progressMilestonesDescription: 'Notify at 25%, 50%, 75%',\n    printerOffline: 'Printer Offline',\n    printerError: 'Printer Error',\n    lowFilamentLabel: 'Low Filament',\n    maintenanceDue: 'Maintenance Due',\n    maintenanceDueDescription: 'Notify when maintenance is needed',\n    amsHumidityHigh: 'AMS Humidity High',\n    amsHumidityHighDescription: 'Regular AMS humidity exceeds threshold',\n    amsTemperatureHigh: 'AMS Temperature High',\n    amsTemperatureHighDescription: 'Regular AMS temperature exceeds threshold',\n    amsHtHumidityHigh: 'AMS-HT Humidity High',\n    amsHtHumidityHighDescription: 'AMS-HT humidity exceeds threshold',\n    amsHtTemperatureHigh: 'AMS-HT Temperature High',\n    amsHtTemperatureHighDescription: 'AMS-HT temperature exceeds threshold',\n    // Queue events\n    jobAdded: 'Job Added',\n    jobAddedDescription: 'Job added to queue',\n    jobAssigned: 'Job Assigned',\n    jobAssignedDescription: 'Model-based job assigned to printer',\n    jobStarted: 'Job Started',\n    jobStartedDescription: 'Queue job started printing',\n    jobWaiting: 'Job Waiting',\n    jobWaitingDescription: 'Job waiting for filament or printer',\n    jobSkipped: 'Job Skipped',\n    jobSkippedDescription: 'Job skipped (previous failed)',\n    jobFailed: 'Job Failed',\n    jobFailedDescription: 'Job failed to start',\n    queueComplete: 'Queue Complete',\n    queueCompleteDescription: 'All queue jobs finished',\n    // Quiet hours\n    quietHours: 'Quiet Hours',\n    noNotificationsDuring: 'No notifications during these hours',\n    editProviderToChangeQuietHours: 'Edit provider to change quiet hours',\n    // Daily digest\n    dailyDigest: 'Daily Digest',\n    batchNotifications: 'Batch notifications into a single daily summary',\n    sendAt: 'Send at {{time}}',\n    editProviderToChangeDigestTime: 'Edit provider to change digest time',\n    // Actions\n    edit: 'Edit',\n    deleteProvider: 'Delete Notification Provider',\n    deleteConfirm: 'Are you sure you want to delete \"{{name}}\"? This cannot be undone.',\n    delete: 'Delete',\n    // AddNotificationModal\n    addTitle: 'Add Notification Provider',\n    editTitle: 'Edit Notification Provider',\n    nameLabel: 'Name *',\n    namePlaceholder: 'My Notifications',\n    providerTypeLabel: 'Provider Type *',\n    configuration: 'Configuration',\n    testConfiguration: 'Test Configuration',\n    printerFilter: 'Printer Filter',\n    onlyFromPrinter: 'Only send notifications for events from this printer',\n    quietHoursDnd: 'Quiet Hours (Do Not Disturb)',\n    quietStart: 'Start',\n    quietEnd: 'End',\n    dailyDigestLabel: 'Daily Digest',\n    sendDigestAt: 'Send digest at',\n    digestCollected: 'Events will be collected and sent as a single summary at this time',\n    notificationEvents: 'Notification Events',\n    progressPercent: '(25%, 50%, 75%)',\n    bedCooledAfterPrint: '(after print completes)',\n    cancel: 'Cancel',\n    save: 'Save',\n    add: 'Add',\n    nameRequired: 'Name is required',\n    fieldRequired: '{{field}} is required',\n    // Config field labels\n    phoneNumber: 'Phone Number',\n    apiKey: 'API Key',\n    serverUrl: 'Server URL',\n    topic: 'Topic',\n    authToken: 'Auth Token',\n    userKey: 'User Key',\n    appToken: 'App Token',\n    priority: 'Priority',\n    botToken: 'Bot Token',\n    chatId: 'Chat ID',\n    smtpServer: 'SMTP Server',\n    smtpPort: 'SMTP Port',\n    security: 'Security',\n    authentication: 'Authentication',\n    username: 'Username',\n    password: 'Password',\n    fromEmail: 'From Email',\n    toEmail: 'To Email',\n    webhookUrl: 'Webhook URL',\n    payloadFormat: 'Payload Format',\n    authorization: 'Authorization',\n    titleFieldName: 'Title Field Name',\n    messageFieldName: 'Message Field Name',\n    // NotificationTemplateEditor\n    editTemplate: 'Edit Template: {{name}}',\n    titleLabel: 'Title',\n    bodyLabel: 'Body',\n    titlePlaceholder: 'Notification title...',\n    bodyPlaceholder: 'Notification body...',\n    availableVariables: 'Available Variables',\n    clickToInsert: 'Click to insert at cursor position in body',\n    livePreview: 'Live Preview',\n    hide: 'Hide',\n    show: 'Show',\n    loadingPreview: 'Loading preview...',\n    enterTemplateContent: 'Enter template content to see preview',\n    titlePreview: 'Title:',\n    bodyPreview: 'Body:',\n    resetToDefault: 'Reset to Default',\n    titleRequired: 'Title is required',\n    bodyRequired: 'Body is required',\n    // NotificationLogViewer\n    notificationLog: 'Notification Log',\n    showFailedOnly: 'Failed only',\n    last24Hours: 'Last 24 hours',\n    last7Days: 'Last 7 days',\n    last30Days: 'Last 30 days',\n    last90Days: 'Last 90 days',\n    justNow: 'Just now',\n    noFailedNotifications: 'No failed notifications',\n    noNotificationsLogged: 'No notifications logged',\n    unknownProvider: 'Unknown Provider',\n    logTitle: 'Title',\n    logMessage: 'Message',\n    logError: 'Error',\n    logProvider: 'Provider: {{type}}',\n    logTime: 'Time: {{time}}',\n    refresh: 'Refresh',\n    clearOld: 'Clear Old',\n    statsSummary: 'Last {{days}} days:',\n    statsNotifications: 'notifications',\n    statsSent: '{{count}} sent',\n    statsFailed: '{{count}} failed',\n    // Event type labels (for log viewer)\n    eventTypes: {\n      print_start: 'Print Started',\n      print_complete: 'Print Complete',\n      print_failed: 'Print Failed',\n      print_stopped: 'Print Stopped',\n      print_progress: 'Progress',\n      printer_offline: 'Printer Offline',\n      printer_error: 'Printer Error',\n      filament_low: 'Low Filament',\n      maintenance_due: 'Maintenance Due',\n      test: 'Test',\n    },\n    // User email notification preferences\n    userEmail: {\n      title: 'Notifications',\n      emailNotifications: 'Email Notifications',\n      emailNotificationsDesc: 'Receive email notifications for your own print jobs. Emails are sent using the system SMTP settings configured in Advanced Authentication.',\n      sendingTo: 'Notifications will be sent to',\n      noEmailWarning: 'Your account does not have an email address. Contact an administrator to add one.',\n      printJobNotifications: 'Print Job Notifications',\n      printJobNotificationsDesc: 'Choose which events trigger email notifications for print jobs you submit.',\n      printJobStarts: 'Print Job Starts',\n      printJobStartsDesc: 'Get notified when your print job begins.',\n      printJobFinishes: 'Print Job Finishes',\n      printJobFinishesDesc: 'Get notified when your print job completes successfully.',\n      printErrors: 'Print Errors',\n      printErrorsDesc: 'Get notified when your print job fails or encounters an error.',\n      printJobStops: 'Print Job Stops',\n      printJobStopsDesc: 'Get notified when your print job is cancelled or stopped.',\n      saveSuccess: 'Notification preferences saved.',\n      saveError: 'Failed to save notification preferences.',\n    },\n  },\n\n  // Rich Text Editor\n  richTextEditor: {\n    bold: 'Bold',\n    italic: 'Italic',\n    underline: 'Underline',\n    bulletList: 'Bullet List',\n    numberedList: 'Numbered List',\n    alignLeft: 'Align Left',\n    alignCenter: 'Align Center',\n    alignRight: 'Align Right',\n    addLink: 'Add Link',\n    removeLink: 'Remove Link',\n  },\n\n  // External Links\n  externalLinks: {\n    noLinksConfigured: 'No external links configured',\n    deleteLink: 'Delete Link',\n    removeCustomIcon: 'Remove custom icon',\n    openInNewTab: 'Open in new tab',\n    placeholders: {\n      linkName: 'My Link',\n    },\n  },\n\n  // Keyboard Shortcuts Modal\n  keyboardShortcuts: {\n    title: 'Keyboard Shortcuts',\n    navigation: 'Navigation',\n    archivesSection: 'Archives',\n    kProfilesSection: 'K-Profiles',\n    generalSection: 'General',\n    shortcuts: {\n      goToPrinters: 'Go to Printers',\n      goToArchives: 'Go to Archives',\n      goToQueue: 'Go to Queue',\n      goToStats: 'Go to Statistics',\n      goToProfiles: 'Go to Cloud Profiles',\n      goToSettings: 'Go to Settings',\n      focusSearch: 'Focus search',\n      openUploadModal: 'Open upload modal',\n      clearSelection: 'Clear selection / blur input',\n      contextMenu: 'Context menu on cards',\n      refreshProfiles: 'Refresh profiles',\n      newProfile: 'New profile',\n      exitSelectionMode: 'Exit selection mode',\n      showHelp: 'Show this help',\n    },\n    footer: 'Press Esc or click outside to close',\n  },\n\n  // Notification Log\n  notificationLog: {\n    title: 'Notification Log',\n    events: {\n      printStarted: 'Print Started',\n      printComplete: 'Print Complete',\n      printFailed: 'Print Failed',\n      printStopped: 'Print Stopped',\n      progress: 'Progress',\n      printerOffline: 'Printer Offline',\n      printerError: 'Printer Error',\n      lowFilament: 'Low Filament',\n      maintenanceDue: 'Maintenance Due',\n      test: 'Test',\n    },\n    timeAgo: {\n      justNow: 'Just now',\n      minutesAgo: '{{minutes}}m ago',\n      hoursAgo: '{{hours}}h ago',\n    },\n  },\n\n  // Restore/Backup Modal\n  restoreBackup: {\n    title: 'Restore Backup',\n    restoring: 'Restoring...',\n    restoreComplete: 'Restore Complete',\n    restoreFailed: 'Restore Failed',\n    importSettings: 'Import settings from a backup file',\n    pleaseWait: 'Please wait while your data is being restored',\n    clickToSelect: 'Click to select backup file (.json or .zip)',\n    howDuplicateHandling: 'How duplicate handling works:',\n    categories: {\n      printers: 'Printers',\n      smartPlugs: 'Smart Plugs',\n      notificationProviders: 'Notification Providers',\n      filaments: 'Filaments',\n      archives: 'Archives',\n      pendingUploads: 'Pending Uploads',\n      settingsTemplates: 'Settings & Templates',\n    },\n    matchingInfo: {\n      printers: 'matched by serial number',\n      smartPlugs: 'matched by IP address',\n      notificationProviders: 'matched by name',\n      filaments: 'matched by name + type + brand',\n      archives: 'matched by content hash',\n      pendingUploads: 'matched by filename',\n      settingsTemplates: 'always overwritten',\n    },\n    replaceExisting: 'Replace existing data',\n    keepExisting: 'Keep existing data',\n    replaceDescription: 'Overwrite items that already exist with backup data',\n    keepDescription: 'Only restore items that don\\'t already exist',\n    caution: 'Caution:',\n    cautionText: 'Overwriting will replace your current configurations with backup data. Printer access codes are never overwritten for security.',\n    itemsRestored: 'Items Restored',\n    itemsSkipped: 'Items Skipped',\n    restored: 'Restored',\n    skipped: 'Skipped (already exist)',\n    filesLabel: 'Files (3MF, thumbnails, etc.)',\n    newApiKeysGenerated: 'New API Keys Generated',\n    newApiKeysWarning: 'These keys are only shown once. Copy them now!',\n    processingBackup: 'Processing backup file...',\n    noDataFound: 'No data was found to restore in the backup file.',\n    failedToRestore: 'Failed to restore backup. Please check the file format.',\n  },\n\n  // Backup Export Modal\n  backupExport: {\n    title: 'Export Backup',\n    selectData: 'Select data to include',\n    selectAll: 'Select All',\n    selectNone: 'Select None',\n    categoryDescriptions: {\n      settings: 'Language, theme, update preferences',\n      notifications: 'ntfy, Pushover, Discord, etc.',\n      templates: 'Custom message templates',\n      smartPlugs: 'Tasmota plug configurations',\n      externalLinks: 'Sidebar links to external services',\n      printers: 'Printer info (access codes excluded)',\n      plateDetection: 'Empty plate reference images',\n      filaments: 'Filament types and costs',\n      maintenance: 'Custom maintenance schedules',\n      archives: 'All print data + files (3MF, thumbnails, photos)',\n      projects: 'Projects, BOM items, and attachments',\n      pendingUploads: 'Virtual printer uploads awaiting review',\n      apiKeys: 'Webhook API keys (new keys generated on import)',\n    },\n    requiresPrinters: 'Requires Printers to be selected',\n    zipFileWarning: 'ZIP file will be created.',\n    zipFileDescription: 'Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.',\n    includeAccessCodes: 'Include Access Codes',\n    includeAccessCodesDescription: 'For transferring to another machine',\n    includeAccessCodesWarning: 'Access codes will be included in plain text. Keep this backup file secure!',\n    categoriesSelected: '{{selectedCount}} categories selected',\n  },\n\n  // Pending Uploads Panel\n  pendingUploads: {\n    placeholders: {\n      notes: 'Add notes about this print...',\n    },\n    discardUpload: 'Discard Upload',\n    archiveAllUploads: 'Archive All Uploads',\n    discardAllUploads: 'Discard All Uploads',\n    archive: 'Archive',\n    timeAgo: {\n      justNow: 'Just now',\n      minutesAgo: '{{minutes}}m ago',\n      hoursAgo: '{{hours}}h ago',\n      daysAgo: '{{days}}d ago',\n    },\n  },\n\n  // API Browser\n  apiBrowser: {\n    placeholders: {\n      requestBody: 'JSON request body...',\n      searchEndpoints: 'Search endpoints...',\n    },\n  },\n\n  // Configure AMS Slot Modal\n  configureAmsSlot: {\n    title: 'Configure AMS Slot',\n    slotConfigured: 'Slot Configured!',\n    configuringSlot: 'Configuring slot:',\n    slotLabel: '{{ams}} Slot {{slot}}',\n    searchPresets: 'Search presets...',\n    colorPlaceholder: 'Color name or hex (e.g., brown, FF8800)',\n    clearCustomColor: 'Clear custom color',\n    noCloudPresets: 'No cloud presets. Login to Bambu Cloud to sync.',\n    noPresetsAvailable: 'No presets available. Login to Bambu Cloud or import local profiles.',\n    noMatchingPresets: 'No matching presets found.',\n    custom: 'Custom',\n    builtin: 'Built-in',\n    settingsSentToPrinter: 'Settings sent to printer',\n    filamentProfile: 'Filament Profile',\n    kProfileLabel: 'K Profile (Pressure Advance)',\n    filteringFor: 'Filtering for: {{material}}',\n    noKProfile: 'No K profile (use default 0.020)',\n    noMatchingKProfiles: 'No matching K profiles found. Default K=0.020 will be used.',\n    selectFilamentFirst: 'Select a filament profile first',\n    kFromCalibration: 'K={{value}} from printer calibration',\n    customColorLabel: 'Custom Color (optional)',\n    presetColors: '{{name}} colors:',\n    showLessColors: 'Show less colors',\n    showMoreColors: 'Show more colors',\n    clear: 'Clear',\n    hexLabel: 'Hex: #{{hex}}',\n    resetting: 'Resetting...',\n    resetSlot: 'Reset Slot',\n    cancel: 'Cancel',\n    configuring: 'Configuring...',\n    configureSlot: 'Configure Slot',\n  },\n\n  // GitHub Backup Settings\n  githubBackup: {\n    title: 'GitHub Backup',\n    history: 'History',\n    downloadBackup: 'Download Backup',\n    restoreBackup: 'Restore Backup',\n    noBackupsYet: 'No backups yet',\n  },\n\n  // Email Settings\n  emailSettings: {\n    placeholders: {\n      fromName: 'BamBuddy',\n    },\n  },\n\n  // Tag Management Modal\n  tagManagement: {\n    searchTags: 'Search tags...',\n    renameTag: 'Rename tag',\n    deleteTag: 'Delete tag',\n  },\n\n  // Notification Template Editor\n  notificationTemplates: {\n    placeholders: {\n      title: 'Notification title...',\n      body: 'Notification body...',\n    },\n  },\n\n  // Batch Tag Modal\n  batchTag: {\n    placeholders: {\n      newTag: 'Enter new tag...',\n    },\n  },\n\n  // Photo Gallery Modal\n  photoGallery: {\n    deletePhoto: 'Delete Photo',\n  },\n\n  // Filament Hover Card\n  filamentHoverCard: {\n    copySpoolUuid: 'Copy spool UUID',\n  },\n\n  // K Profiles View\n  kProfilesView: {\n    hasNote: 'Has note',\n    copyProfile: 'Copy profile',\n  },\n\n  // Layout/Navigation\n  layout: {\n    openMenu: 'Open menu',\n    noPermissionSystemInfo: 'You do not have permission to view system information',\n  },\n\n  // Dashboard\n  dashboard: {\n    dragToReorder: 'Drag to reorder',\n    hideWidget: 'Hide widget',\n  },\n\n  // Notification Provider Card\n  notificationProviderCard: {\n    deleteNotificationProvider: 'Delete Notification Provider',\n  },\n\n  // File Manager Modal\n  fileManagerModal: {\n    closeFileManager: 'Close file manager',\n    sortFiles: 'Sort files',\n    goToParentFolder: 'Go to parent folder',\n    threeView: '3D View',\n  },\n\n  // Embedded Camera Viewer\n  embeddedCameraViewer: {\n    refreshStream: 'Refresh stream',\n    close: 'Close',\n    zoomOut: 'Zoom out',\n    resetZoom: 'Reset zoom',\n    zoomIn: 'Zoom in',\n    dragToResize: 'Drag to resize',\n  },\n\n  // Timelapse Viewer\n  timelapseViewer: {\n    skipBack5s: 'Skip back 5s',\n    skipForward5s: 'Skip forward 5s',\n  },\n\n  // Notification Providers\n  notificationProviders: {\n    descriptions: {\n      email: 'SMTP email notifications',\n      telegram: 'Notifications via Telegram bot',\n      discord: 'Send to Discord channel via webhook',\n      ntfy: 'Free, self-hostable push notifications',\n      pushover: 'Simple, reliable push notifications',\n      callmebot: 'Free WhatsApp notifications via CallMeBot',\n      webhook: 'Generic HTTP POST to any URL',\n    },\n  },\n\n  // Log Viewer\n  logViewer: {\n    searchPlaceholder: 'Search message or logger name...',\n    noLogEntries: 'No log entries found',\n  },\n\n  // Switchbar Popover\n  switchbarPopover: {\n    noSwitchesInSwitchbar: 'No switches in switchbar',\n  },\n\n  // Project Page Modal\n  projectPageModal: {\n    placeholders: {\n      title: 'Title',\n      designer: 'Designer',\n      license: 'License',\n      description: 'Enter description...',\n      profileTitle: 'Profile Title',\n      profileDescription: 'Profile description...',\n    },\n  },\n\n  // Spoolman Settings\n  spoolmanSettings: {},\n\n  // Time\n  time: {\n    unknown: '-',\n    waiting: 'Waiting',\n    justNow: 'Just now',\n    now: 'Now',\n    minsAgo: '{{count}}m ago',\n    inMins: 'in {{count}}m',\n    hoursAgo: '{{count}}h ago',\n    inHours: 'in {{count}}h',\n    daysAgo: '{{count}}d ago',\n    inDays: 'in {{count}}d',\n  },\n\n  // SpoolBuddy Kiosk\n  spoolbuddy: {\n    nav: {\n      dashboard: 'Dashboard',\n      ams: 'AMS',\n      inventory: 'Inventory',\n      writeTag: 'Write',\n      settings: 'Settings',\n    },\n    status: {\n      nfcReady: 'NFC Ready',\n      nfcOff: 'NFC Off',\n      offline: 'Offline',\n      online: 'Online',\n      noPrinters: 'No printers',\n      deviceOffline: 'Device Offline',\n      waitingConnection: 'Waiting for device connection...',\n      systemReady: 'System Ready',\n      status: 'Status',\n    },\n    dashboard: {\n      readyToScan: 'Ready to scan',\n      idleMessage: 'Place a spool on the scale to identify it',\n      nfcHint: 'NFC tag will be read automatically',\n      device: 'Device',\n      syncWeight: 'Sync Weight',\n      weightSynced: 'Synced!',\n      unknownTag: 'Unknown Tag',\n      newTag: 'New Tag Detected',\n      onScale: 'on scale',\n      linkSpool: 'Link to Spool',\n      linkTagTitle: 'Link Tag to Spool',\n      linkTag: 'Link Tag',\n      selectSpool: 'Select a spool to link this tag to:',\n      noUntagged: 'No spools without tags found',\n      tagDetected: 'Tag detected',\n      noTag: 'No tag',\n      tagId: 'Tag',\n      grossWeight: 'Gross weight',\n      spoolSize: 'Spool size',\n      close: 'Close',\n      currentSpool: 'Current Spool',\n    },\n    modal: {\n      spoolDetected: 'Spool Detected',\n      assignToAms: 'Assign to AMS',\n      syncWeight: 'Sync Weight',\n      weightSynced: 'Synced!',\n      syncing: 'Syncing...',\n      newTagDetected: 'New Tag Detected',\n      addToInventory: 'Add to Inventory',\n      assignToAmsTitle: 'Assign to AMS',\n      selectSlot: 'Select a slot',\n      assign: 'Assign',\n      assigning: 'Assigning...',\n      assignSuccess: 'Assigned!',\n      assignError: 'Failed to assign spool. Please try again.',\n      noPrinterSelected: 'Select a printer...',\n      noAmsDetected: 'No AMS detected on this printer',\n      slot: 'Slot',\n    },\n    weight: {\n      noReading: 'No reading',\n      stable: 'Stable',\n      measuring: 'Measuring...',\n      tare: 'Tare',\n      calibrate: 'Calibrate',\n    },\n    spool: {\n      remaining: 'Remaining',\n      material: 'Material',\n      brand: 'Brand',\n      color: 'Color',\n      coreWeight: 'Core',\n      labelWeight: 'Label',\n      scaleWeight: 'Scale',\n      netWeight: 'Net',\n      lastUsed: 'Last used',\n    },\n    ams: {\n      noData: 'No AMS detected',\n      connectAms: 'Connect an AMS to see filament slots',\n      noPrinter: 'No printer selected',\n      selectPrinter: 'Select a printer from the top bar',\n      printerDisconnected: 'Printer disconnected',\n      humidity: 'Humidity',\n      level: 'Level',\n      active: 'Active',\n      slot: 'Slot',\n      empty: 'Empty',\n    },\n    inventory: {\n      search: 'Search spools...',\n      empty: 'No spools in inventory',\n      noResults: 'No matching spools',\n      spools: 'spools',\n      addSpool: 'Add Spool',\n    },\n    settings: {\n      // Tabs\n      tabDevice: 'Device',\n      tabDisplay: 'Display',\n      tabScale: 'Scale',\n      tabUpdates: 'Updates',\n      // Device tab\n      nfcReader: 'NFC Reader',\n      type: 'Type',\n      connection: 'Connection',\n      notConnected: 'N/A',\n      deviceInfo: 'Device Info',\n      hostname: 'Host',\n      uptime: 'Uptime',\n      systemConfig: 'Backend & Auth',\n      backendUrl: 'Bambuddy Backend URL',\n      apiToken: 'API Token',\n      apiTokenPlaceholder: 'Enter API token',\n      saveConfig: 'Save Config',\n      systemQueued: 'Config queued.',\n      nfcDiagnostic: 'NFC Diagnostic',\n      scaleDiagnostic: 'Scale Diagnostic',\n      readTagDiagnostic: 'Read Tag Diagnostic',\n      testNfc: 'Test reader',\n      testScale: 'Test accuracy',\n      testReadTag: 'Read tag',\n      systemFieldsRequired: 'Backend URL is required.',\n      // Display tab\n      brightness: 'Brightness',\n      saved: 'Saved',\n      noBacklight: 'No DSI backlight detected. Brightness control requires a DSI display.',\n      screenBlank: 'Screen Blank Timeout',\n      screenBlankDesc: 'Screen turns off after inactivity. Touch to wake.',\n      displayNote: 'Brightness is applied as a software filter.',\n      // Scale tab\n      scaleCalibration: 'Scale Calibration',\n      currentWeight: 'Current weight',\n      tareOffset: 'Tare',\n      calFactor: 'Factor',\n      knownWeight: 'Known weight',\n      calStep1: 'Remove all items from the scale and press Set Zero.',\n      calStep2: 'Place known weight on scale.',\n      setZero: 'Set Zero',\n      calibrateNow: 'Calibrate',\n      calibrated: 'Calibrated',\n      tareSet: 'Tare command sent. Waiting for device...',\n      tareFailed: 'Failed to send tare command',\n      zeroSet: 'Zero point set. Place known weight on scale.',\n      calibrationDone: 'Calibration complete!',\n      calibrationFailed: 'Calibration failed',\n      lastCalibrated: 'Last calibrated',\n      stable: 'Stable',\n      settling: 'Settling...',\n      firmware: 'Firmware',\n      scale: 'Scale',\n      noDevice: 'No SpoolBuddy device found',\n      // Updates tab\n      daemonVersion: 'Daemon Version',\n      currentVersion: 'Current',\n      versionPending: 'Waiting for daemon...',\n      checking: 'Checking...',\n      checkUpdates: 'Check for Updates',\n      updateAvailable: 'Update available',\n      updateInstructions: 'Update via SSH: run the SpoolBuddy install script to upgrade.',\n      upToDate: 'Up to date',\n      includeBeta: 'Include beta versions',\n    },\n    writeTag: {\n      tabExisting: 'Existing Spool',\n      tabNew: 'New Spool',\n      tabReplace: 'Replace Tag',\n      searchPlaceholder: 'Search by material, color, brand...',\n      noUntaggedSpools: 'No spools without tags',\n      noTaggedSpools: 'No spools with tags',\n      selectSpool: 'Select a spool, then place a blank NTAG on the reader',\n      placeTag: 'Place an NTAG on the reader',\n      tagReady: 'Tag detected — ready to write',\n      writeTag: 'Write Tag',\n      replaceTag: 'Replace Tag',\n      writing: 'Writing tag...',\n      waiting: 'Waiting for SpoolBuddy...',\n      writeSuccess: 'Tag written successfully!',\n      writeFailed: 'Write failed',\n      queueFailed: 'Failed to queue write command',\n      tryAgain: 'Try Again',\n      cancel: 'Cancel',\n      replaceWarning: 'Old tag will be unlinked. New tag will replace it.',\n      deviceOffline: 'SpoolBuddy is offline',\n      material: 'Material',\n      colorName: 'Color Name',\n      color: 'Color',\n      brand: 'Brand',\n      weight: 'Weight (g)',\n      createSpool: 'Create Spool',\n      creating: 'Creating...',\n      spoolCreated: 'Spool created! Ready to write.',\n      createFailed: 'Failed to create spool',\n    },\n    quickMenu: {\n      printerPower: 'Printer Power',\n      systemControls: 'System',\n      restartDaemon: 'Restart Daemon',\n      restartBrowser: 'Restart Browser',\n      reboot: 'Reboot',\n      shutdown: 'Shutdown',\n      swipeToClose: 'Swipe down to close',\n      confirmTitle: 'Confirm',\n      confirmShutdown: 'Are you sure you want to shut down the SpoolBuddy? You will need physical access to turn it back on.',\n      confirmReboot: 'Are you sure you want to reboot the SpoolBuddy?',\n      confirmRestartDaemon: 'Restart the SpoolBuddy daemon? NFC and scale will be temporarily unavailable.',\n      confirmRestartBrowser: 'Restart the kiosk browser? The display will briefly go blank.',\n      confirm: 'Confirm',\n      confirmPlugOn: 'Turn on {{name}}?',\n      confirmPlugOff: 'Turn off {{name}}?',\n      turnOn: 'Turn On',\n      turnOff: 'Turn Off',\n    },\n  },\n\n  bugReport: {\n    title: 'Report a Bug',\n    description: 'Description',\n    descriptionPlaceholder: 'What went wrong? Please describe the issue...',\n    email: 'Email (optional)',\n    emailPlaceholder: 'your@email.com',\n    emailPrivacy: 'If provided, your email will be included in a collapsed section of the GitHub issue so the maintainer can follow up.',\n    screenshot: 'Screenshot',\n    uploadOrPaste: 'Upload, paste, or drag an image',\n    dataCollectedSummary: 'What data is included in the report?',\n    dataIncluded: 'Included:',\n    dataIncludedList: 'App version, OS, architecture, Python version, database stats (counts only), printer models, nozzle counts, firmware versions, connectivity status, integration status (Spoolman, MQTT, HA), non-sensitive settings, network interface count, Docker details, dependency versions.',\n    dataNeverIncluded: 'Never included:',\n    dataNeverIncludedList: 'Printer names, serial numbers, access codes, passwords, IP addresses, email addresses, API keys, tokens, webhook URLs, hostnames, or usernames.',\n    submit: 'Submit',\n    startLogging: 'Start Debug Logging',\n    stepEnableLogging: 'Debug logging enabled',\n    stepReproduce: 'Reproduce the issue now',\n    stepStopLogging: 'Stop & submit report',\n    stopAndSubmit: 'Stop & Submit',\n    maxDuration: 'Auto-stops after {{minutes}} min',\n    stoppingLogs: 'Collecting logs & submitting...',\n    submitting: 'Submitting bug report...',\n    submitSuccess: 'Bug report submitted successfully!',\n    submitFailed: 'Failed to submit bug report',\n    thankYou: 'Thank you!',\n    submitted: 'Your bug report has been submitted.',\n    viewIssue: 'View Issue',\n    unexpectedError: 'An unexpected error occurred',\n  },\n  failureDetection: {\n    title: 'AI Failure Detection',\n    description: 'Monitor prints with a self-hosted Obico ML API and act on detected failures automatically.',\n    mlUrl: 'Obico ML API URL',\n    mlUrlHint: 'Base URL of your self-hosted Obico ml_api container (e.g. http://192.168.1.10:3333).',\n    test: 'Test',\n    testSuccess: 'ML API reachable and healthy.',\n    testFailed: 'Could not reach the ML API.',\n    sensitivity: 'Sensitivity',\n    sensitivityLow: 'Low (fewer false positives)',\n    sensitivityMedium: 'Medium (balanced)',\n    sensitivityHigh: 'High (detect early, more false positives)',\n    sensitivityHint: 'Adjusts the confidence thresholds that trigger warnings and failures.',\n    action: 'Action on detected failure',\n    actionNotify: 'Notify only',\n    actionPause: 'Pause print',\n    actionPauseOff: 'Pause and cut power',\n    pollInterval: 'Poll interval (seconds)',\n    pollIntervalHint: 'How often to check each printer while it is printing. Minimum 5s, maximum 120s.',\n    externalUrlMissing: 'External URL is not set.',\n    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',\n    perPrinterTitle: 'Monitored Printers',\n    perPrinterHint: 'Choose which printers the detection service watches.',\n    monitorAll: 'Monitor all connected printers',\n    statusTitle: 'Status',\n    serviceRunning: 'Service running',\n    thresholds: 'Low / High thresholds',\n    activePrinters: 'Active prints',\n    noActivePrints: 'No prints currently running.',\n    historyTitle: 'Recent Detections',\n    noHistory: 'No detections yet.',\n  },\n};\n"
  },
  {
    "path": "frontend/src/i18n/locales/fr.ts",
    "content": "export default {\n  // Navigation\n  nav: {\n    printers: 'Imprimantes',\n    archives: 'Archives',\n    queue: 'File d\\'attente',\n    stats: 'Statistiques',\n    profiles: 'Profils',\n    maintenance: 'Maintenance',\n    projects: 'Projets',\n    inventory: 'Filament',\n    files: 'Gestionnaire de fichiers',\n    notifications: 'Notifications',\n    settings: 'Paramètres',\n    system: 'Système',\n    collapseSidebar: 'Réduire la barre latérale',\n    expandSidebar: 'Développer la barre latérale',\n    update: 'Mise à jour',\n    updateAvailable: 'Mise à jour disponible : v{{version}}',\n    updateAvailableBanner: 'La version {{version}} est disponible !',\n    viewUpdate: 'Voir la mise à jour',\n    viewOnGithub: 'Voir sur GitHub',\n    keyboardShortcuts: 'Raccourcis clavier (?)',\n    switchToLight: 'Passer au mode clair',\n    switchToDark: 'Passer au mode sombre',\n    smartSwitches: 'Interrupteurs intelligents',\n    logout: 'Déconnexion',\n  },\n\n  // Common\n  common: {\n    save: 'Enregistrer',\n    saving: 'Enregistrement...',\n    cancel: 'Annuler',\n    delete: 'Supprimer',\n    edit: 'Modifier',\n    add: 'Ajouter',\n    close: 'Fermer',\n    confirm: 'Confirmer',\n    loading: 'Chargement...',\n    error: 'Erreur',\n    success: 'Succès',\n    warning: 'Avertissement',\n    enabled: 'Activé',\n    disabled: 'Désactivé',\n    yes: 'Oui',\n    no: 'Non',\n    on: 'On',\n    off: 'Off',\n    all: 'Tous',\n    none: 'Aucun',\n    search: 'Rechercher',\n    filter: 'Filtrer',\n    sort: 'Trier',\n    refresh: 'Actualiser',\n    download: 'Télécharger',\n    upload: 'Téléverser',\n    uploading: 'Téléversement...',\n    uploadFailed: 'Échec du téléversement',\n    actions: 'Actions',\n    status: 'Statut',\n    name: 'Nom',\n    description: 'Description',\n    date: 'Date',\n    time: 'Heure',\n    hours: 'heures',\n    minutes: 'minutes',\n    seconds: 'secondes',\n    days: 'jours',\n    enable: 'Activer',\n    disable: 'Désactiver',\n    permissions: 'Autorisations',\n    noPrinters: 'Aucune imprimante configurée',\n    noData: 'Aucune donnée disponible',\n    linkNotFound: 'Lien non trouvé',\n    required: 'Requis',\n    optional: 'Optionnel',\n    dismiss: 'Ignorer',\n    apply: 'Appliquer',\n    reset: 'Réinitialiser',\n    export: 'Exporter',\n    import: 'Importer',\n    clear: 'Effacer',\n    selectAll: 'Tout sélectionner',\n    deselectAll: 'Tout désélectionner',\n    noChange: '— Aucun changement —',\n    unchanged: 'Inchangé',\n    unassigned: 'Non assigné',\n    unknown: 'Inconnu',\n    unknownError: 'Erreur inconnue',\n    today: 'Aujourd\\'hui',\n    tomorrow: 'Demain',\n    asap: 'Dès que possible',\n    overdue: 'En retard',\n    now: 'Maintenant',\n    collapse: 'Réduire',\n    expand: 'Développer',\n    viewArchive: 'Voir l\\'archive',\n    viewInFileManager: 'Voir dans le gestionnaire de fichiers',\n    addedBy: 'Ajouté par {{username}}',\n    prints: 'impressions',\n    more: '+{{count}} de plus',\n    ascending: 'Croissant',\n    descending: 'Décroissant',\n    back: 'Retour',\n    copy: 'Copier',\n    copied: 'Copié !',\n    printer: 'Imprimante',\n    remove: 'Retirer',\n    type: 'Type',\n    print: 'Imprimer',\n    rename: 'Renommer',\n    move: 'Déplacer',\n    create: 'Créer',\n    duplicate: 'Dupliquer',\n    left: 'Gauche',\n    right: 'Droite',\n  },\n\n  // Printers page\n  printers: {\n    title: 'Imprimantes',\n    addPrinter: 'Ajouter une imprimante',\n    editPrinter: 'Modifier l\\'imprimante',\n    deletePrinter: 'Supprimer l\\'imprimante',\n    printerName: 'Nom de l\\'imprimante',\n    serialNumber: 'Numéro de série',\n    ipAddress: 'Adresse IP / Nom d\\'hôte',\n    accessCode: 'Code d\\'accès',\n    model: 'Modèle',\n    nozzleCount: 'Nombre de buses',\n    autoArchive: 'Auto-archivage',\n    status: {\n      available: 'Disponible',\n      idle: 'Inactif',\n      printing: 'Impression en cours',\n      paused: 'En pause',\n      offline: 'Hors ligne',\n      problem: 'Problème',\n      error: 'Erreur',\n      finished: 'Terminé',\n      unknown: 'Inconnu',\n    },\n    temperatures: {\n      nozzle: 'Buse',\n      bed: 'Plateau',\n      chamber: 'Chambre',\n    },\n    progress: '{{percent}}% terminé',\n    timeRemaining: '{{time}} restant',\n    deleteConfirm: 'Êtes-vous sûr de vouloir supprimer \"{{name}}\" ?',\n    maintenanceOk: 'Maintenance OK',\n    maintenanceWarning: '{{count}} avertissement',\n    maintenanceWarning_plural: '{{count}} avertissements',\n    maintenanceDue: '{{count}} échéance',\n    maintenanceDue_plural: '{{count}} échéances',\n    // Sort options\n    sort: {\n      name: 'Nom',\n      status: 'Statut',\n      model: 'Modèle',\n      location: 'Emplacement',\n      ascending: 'Tri croissant',\n      descending: 'Tri décroissant',\n    },\n    // Card size\n    cardSize: {\n      small: 'Petites cartes',\n      medium: 'Cartes moyennes',\n      large: 'Grandes cartes',\n      extraLarge: 'Très grandes cartes',\n    },\n    // Controls\n    hideOffline: 'Masquer hors ligne',\n    nextAvailable: 'Prochaine disponible',\n    powerOn: 'Allumer',\n    offlinePrintersWithPlugs: 'Imprimantes hors ligne avec prises connectées',\n    noPrintersConfigured: 'Aucune imprimante configurée pour le moment',\n    search: 'Rechercher des imprimantes...',\n    noSearchResults: 'Aucune imprimante ne correspond à votre recherche ou à vos filtres',\n    filter: {\n      allStatuses: 'Tous les statuts',\n      allLocations: 'Tous les emplacements',\n    },\n    // Printer card\n    readyToPrint: 'Prête à imprimer',\n    external: 'Externe',\n    extL: 'Ext-L',\n    extR: 'Ext-R',\n    deleteArchives: 'Supprimer les archives d\\'impression',\n    noLabel: 'Pas d\\'étiquette',\n    printPreview: 'Aperçu avant impression',\n    width: 'Largeur',\n    height: 'Hauteur',\n    noObjectsFound: 'Aucun objet trouvé',\n    objectsLoadedOnPrintStart: 'Les objets sont chargés au début de l\\'impression',\n    willBeSkipped: 'Sera sauté',\n    name: 'Nom',\n    serialCannotBeChanged: 'Le numéro de série ne peut pas être modifié',\n    locationHelp: 'Utilisé pour grouper les imprimantes et filtrer la file d\\'attente',\n    // WiFi signal strength\n    wifiSignal: {\n      veryWeak: 'Très faible',\n      weak: 'Faible',\n      fair: 'Moyen',\n      good: 'Bon',\n      excellent: 'Excellent',\n    },\n    // Maintenance\n    maintenanceUpToDate: 'Maintenance à jour - Cliquez pour voir',\n    // Chamber light\n    chamberLightOn: 'Allumer la lumière de la chambre',\n    chamberLightOff: 'Éteindre la lumière de la chambre',\n    // Files\n    files: 'Fichiers',\n    browseFiles: 'Parcourir les fichiers de l\\'imprimante',\n    // Smart plug\n    autoOffAfterPrint: 'Extinction auto après impression',\n    autoOffExecuted: 'Extinction auto exécutée - rallumez pour réinitialiser',\n    // HMS errors\n    hmsErrors: 'Erreurs HMS',\n    viewHmsErrors: 'Voir {{count}} erreur(s) HMS',\n    // Actions\n    resume: 'Reprendre',\n    pause: 'Pause',\n    stop: 'Arrêter',\n    camera: 'Caméra',\n    skipObject: 'Sauter l\\'objet',\n    reconnect: 'Reconnecter',\n    forceRefresh: 'Forcer l\\'actualisation',\n    forceRefreshSuccess: 'Actualisation demandée',\n    mqttDebug: 'Débogage MQTT',\n    printerInformation: 'Informations imprimante',\n    copyToClipboard: 'Copier',\n    copied: 'Copié !',\n    state: 'État',\n    wifiSignalLabel: 'Signal WiFi',\n    developerMode: 'Mode développeur',\n    enabled: 'Activé',\n    disabled: 'Désactivé',\n    addedOn: 'Ajoutée le',\n    sdCard: 'Carte SD',\n    inserted: 'Insérée',\n    notInserted: 'Non insérée',\n    totalPrintHours: 'Heures d\\'impression',\n    activeNozzle: 'Active : buse {{nozzle}}',\n    nozzleRack: 'Rack à buses',\n    nozzleDocked: 'Rangée',\n    nozzleMounted: 'Montée',\n    nozzleActive: 'Active',\n    nozzleIdle: 'Inactive',\n    nozzleDiameter: 'Diamètre',\n    nozzleType: 'Type',\n    nozzleStatus: 'Statut',\n    nozzleFilament: 'Filament',\n    nozzleWear: 'Usure',\n    nozzleMaxTemp: 'Temp Max',\n    nozzleSerial: 'Série',\n    nozzleHardenedSteel: 'Acier Trempé',\n    nozzleStainlessSteel: 'Acier Inoxydable',\n    nozzleTungstenCarbide: 'Carbure de Tungstène',\n    nozzleFlow: 'Débit',\n    nozzleHighFlow: 'Haut débit',\n    nozzleStandardFlow: 'Standard',\n    // Firmware\n    firmwareUpdate: 'Mise à jour Firmware',\n    firmwareInstructions: 'Sur l\\'écran de l\\'imprimante, allez dans',\n    firmwareNav: 'Naviguez vers',\n    settings: 'Paramètres',\n    firmware: 'Firmware',\n    // Discovery\n    discoverPrinters: 'Découvrir les imprimantes',\n    searching: 'Recherche...',\n    manualEntry: 'Saisie manuelle',\n    addFromCloud: 'Ajouter depuis le Cloud',\n    // Toast messages\n    toast: {\n      printerDeleted: 'Imprimante supprimée',\n      missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',\n      printerAdded: 'Imprimante ajoutée',\n      printerUpdated: 'Imprimante mise à jour',\n      failedToDelete: 'Échec de la suppression',\n      failedToAdd: 'Échec de l\\'ajout',\n      failedToUpdate: 'Échec de la mise à jour',\n      commandSent: 'Commande envoyée',\n      failedToSendCommand: 'Échec de l\\'envoi de la commande',\n      turnedOn: '{{name}} allumée',\n      failedToPowerOn: 'Échec de l\\'allumage de {{name}}',\n      scriptTriggered: 'Script déclenché',\n      printStopped: 'Impression arrêtée',\n      printPaused: 'Impression en pause',\n      printResumed: 'Impression reprise',\n      referenceDeleted: 'Référence supprimée',\n      detectionAreaSaved: 'Zone de détection enregistrée',\n      failedToRunScript: 'Échec du script',\n      failedToStopPrint: 'Échec de l\\'arrêt',\n      failedToPausePrint: 'Échec de la mise en pause',\n      failedToResumePrint: 'Échec de la reprise',\n      failedToControlChamberLight: 'Échec du contrôle de la lumière',\n      failedToSetSpeed: 'Échec du réglage de la vitesse',\n      failedToUpdateSetting: 'Échec de mise à jour du paramètre',\n      failedToSkipObjects: 'Échec du saut d\\'objets',\n      failedToRereadRfid: 'Échec lecture RFID',\n      failedToCheckPlate: 'Échec vérification plateau',\n      failedToUpdateLabel: 'Échec mise à jour étiquette',\n      failedToDeleteReference: 'Échec suppression référence',\n      failedToSaveDetectionArea: 'Échec enregistrement zone',\n      plateCheckEnabled: 'Vérification plateau activée',\n      plateCheckDisabled: 'Vérification plateau désactivée',\n      calibrationSaved: 'Calibration enregistrée !',\n      calibrationFailed: 'Échec de la calibration',\n      rfidRereadInitiated: 'Lecture RFID initiée',\n    },\n    // Connection status\n    connection: {\n      connected: 'Connecté',\n      offline: 'Hors ligne',\n    },\n    plateStatus: {\n      markCleared: 'Marquer le plateau comme dégagé',\n      cleared: 'Plateau dégagé',\n      notCleared: 'Plateau non dégagé',\n      inUse: 'Plateau en cours d\\'utilisation',\n    },\n    // Queue info\n    queue: {\n      inQueue: '{{count}} impression en file',\n      inQueue_plural: '{{count}} impressions en file',\n    },\n    // Controls section\n    controls: 'Contrôles',\n    // RFID\n    rfid: {\n      reread: 'Relire RFID',\n    },\n    bedJog: {\n      title: 'Déplacer le plateau',\n      bed: 'Plateau',\n      step: 'Pas (mm)',\n      up: 'Monter le plateau',\n      down: 'Descendre le plateau',\n      disabledWhilePrinting: 'Désactivé pendant l\\'impression',\n      notHomedTitle: 'Imprimante non référencée',\n      notHomedMessage: 'L\\'imprimante n\\'a pas été référencée depuis la dernière impression. Lancez la référence automatique d\\'abord pour un positionnement sûr (parque la tête d\\'outil, puis référence X, Y et Z), ou déplacez quand même — les butées logicielles seront ignorées.',\n      homeZ: 'Référence automatique',\n      moveAnyway: 'Déplacer quand même',\n      homingStarted: 'Référencement automatique en cours…',\n    },\n    // Permissions\n    permission: {\n      noAdd: 'Pas d\\'autorisation pour ajouter',\n      noEdit: 'Pas d\\'autorisation pour modifier',\n      noDelete: 'Pas d\\'autorisation pour supprimer',\n      noControl: 'Pas d\\'autorisation pour contrôler',\n      noFiles: 'Pas d\\'autorisation pour les fichiers',\n      noAmsRfid: 'Pas d\\'autorisation pour le RFID',\n      noSmartPlugControl: 'Pas d\\'autorisation pour les prises',\n      noCamera: 'Pas d\\'autorisation pour les caméras',\n    },\n    // Add/Edit modal\n    modal: {\n      addTitle: 'Ajouter une imprimante',\n      editTitle: 'Modifier l\\'imprimante',\n      myPrinter: 'Mon imprimante',\n      selectModel: 'Choisir un modèle...',\n      locationGroup: 'Emplacement / Groupe (optionnel)',\n      locationPlaceholder: 'ex: Atelier, Bureau',\n      autoArchiveLabel: 'Auto-archiver les impressions terminées',\n      fromPrinterSettings: 'Depuis les paramètres imprimante',\n      modelOptional: 'Modèle (optionnel)',\n      saveChanges: 'Enregistrer les modifications',\n    },\n    // Skip objects\n    skipObjects: {\n      tooltip: 'Sauter des objets',\n      onlyWhilePrinting: 'Sauter (uniquement pendant l\\'impression)',\n      requiresMultiple: 'Sauter (nécessite 2+ objets)',\n      title: 'Sauter des objets',\n      matchIdsInfo: 'Faites correspondre les IDs avec l\\'écran de l\\'imprimante',\n      printerShowsIds: 'L\\'écran affiche les IDs des objets sur le plateau',\n      skipSelected: 'Sauter la sélection',\n      skipping: 'Saut en cours...',\n      noObjectsSelected: 'Aucun objet sélectionné',\n      selectObjectsToSkip: 'Sélectionnez les objets à ignorer',\n      skipped: 'sauté',\n      objectsSkipped: 'Objets sautés',\n      activeCount: '{{count}} actifs',\n      waitForLayer: 'Attendez la couche 2 pour sauter des objets (actuelle : {{layer}})',\n      skip: 'Sauter',\n      confirmTitle: 'Sauter l\\'objet ?',\n      confirmMessage: 'Voulez-vous vraiment sauter \"{{name}}\" ? Cette action est irréversible.',\n    },\n    // Confirm modals\n    confirm: {\n      deleteTitle: 'Supprimer l\\'imprimante',\n      deleteMessage: 'Supprimer \"{{name}}\" ? Cela retirera tous les paramètres de connexion.',\n      deleteArchivesNote: 'Tout l\\'historique sera définitivement supprimé.',\n      keepArchivesNote: 'L\\'historique sera conservé mais plus associé à cette imprimante.',\n      stopTitle: 'Arrêter l\\'impression',\n      stopMessage: 'Arrêter l\\'impression sur \"{{name}}\" ?',\n      stopButton: 'Arrêter',\n      pauseTitle: 'Mettre en pause',\n      pauseMessage: 'Mettre en pause l\\'impression sur \"{{name}}\" ?',\n      pauseButton: 'Pause',\n      resumeTitle: 'Reprendre l\\'impression',\n      resumeMessage: 'Reprendre l\\'impression sur \"{{name}}\" ?',\n      resumeButton: 'Reprendre',\n      powerOnTitle: 'Allumer l\\'imprimante',\n      powerOnMessage: 'Allumer \"{{name}}\" ?',\n      powerOnButton: 'Allumer',\n      powerOffTitle: 'Éteindre l\\'imprimante',\n      powerOffMessage: 'Éteindre \"{{name}}\" ?',\n      powerOffWarning: 'ATTENTION : \"{{name}}\" imprime ! L\\'éteindre maintenant peut endommager l\\'imprimante.',\n      powerOffButton: 'Éteindre',\n    },\n    // Bulk actions\n    bulk: {\n      select: 'Sélectionner',\n      selectAll: 'Tout sélectionner',\n      selectByLocation: 'Sélectionner par emplacement',\n      selected: '{{count}} sélectionné(s)',\n      actions: {\n        stop: 'Arrêter',\n        pause: 'Pause',\n        resume: 'Reprendre',\n        clearPlate: 'Vider le plateau',\n        clearHMS: 'Effacer les notifications',\n      },\n      confirm: {\n        stopTitle: 'Arrêter {{count}} impressions',\n        stopMessage: 'Cela annulera les impressions actives sur {{count}} imprimante(s). Cette action est irréversible.',\n        stopButton: 'Tout arrêter',\n        pauseTitle: 'Mettre en pause {{count}} impressions',\n        pauseMessage: 'Cela mettra en pause les impressions actives sur {{count}} imprimante(s).',\n        pauseButton: 'Tout mettre en pause',\n        clearPlateTitle: 'Vider {{count}} plateaux',\n        clearPlateMessage: 'Cela videra le plateau sur {{count}} imprimante(s) et pourrait lancer les travaux en file d\\'attente.',\n        clearPlateButton: 'Tout vider',\n      },\n      success: '{{action}} terminé sur {{count}} imprimante(s)',\n      partial: '{{succeeded}} réussi(s), {{failed}} échoué(s)',\n      noneApplicable: 'Aucune imprimante sélectionnée n\\'est dans le bon état pour cette action',\n      selectByState: 'Sélectionner par état',\n    },\n    // Discovery\n    discovery: {\n      title: 'Découvrir les imprimantes',\n      searching: 'Recherche...',\n      scanning: 'Scan en cours...',\n      scanProgress: 'Scan... {{scanned}}/{{total}}',\n      foundPrinters: '{{count}} imprimante(s) trouvée(s)',\n      noPrintersFound: 'Aucune imprimante trouvée',\n      noPrintersFoundSubnet: 'Aucune imprimante dans ce sous-réseau.',\n      noPrintersFoundNetwork: 'Aucune imprimante sur le réseau.',\n      allConfigured: 'Toutes les imprimantes trouvées sont déjà configurées.',\n      alreadyAdded: 'Déjà ajoutée',\n      select: 'Sélectionner',\n      manualEntry: 'Saisie manuelle',\n      addFromCloud: 'Ajouter depuis le Cloud',\n      subnetToScan: 'Sous-réseau à scanner',\n      dockerNote: 'Docker détecté. Entrez le sous-réseau en notation CIDR. Nécessite network_mode: host.',\n      scanSubnet: 'Scanner le sous-réseau',\n      discoverNetwork: 'Découvrir sur le réseau',\n      scanningSubnet: 'Scan du sous-réseau pour imprimantes Bambu...',\n      scanningNetwork: 'Scan du réseau...',\n      serialRequired: 'Série requis',\n      unknown: 'Inconnu',\n      failedToStart: 'Échec du démarrage de la découverte',\n    },\n    // AMS Drying\n    drying: {\n      start: 'Démarrer le séchage',\n      stop: 'Arrêter le séchage',\n      temperature: 'Température',\n      duration: 'Durée',\n      hours: 'heures',\n      timeRemaining: '{{time}} restant',\n      active: 'Séchage',\n      notSupported: 'Séchage non pris en charge',\n      powerRequired: 'Brancher l\\'adaptateur secteur AMS pour activer le séchage',\n      startingDrying: 'Démarrage du séchage...',\n      stoppingDrying: 'Arrêt du séchage...',\n      rotateTray: 'Tourner la bobine pendant le séchage',\n    },\n    // Filaments section\n    filaments: 'Filaments',\n    // Camera\n    openCameraOverlay: 'Ouvrir la caméra en superposition',\n    openCameraWindow: 'Ouvrir la caméra dans une fenêtre',\n    // Firmware\n    firmwareUpdateAvailable: 'Mise à jour firmware : {{current}} → {{latest}}',\n    firmwareUpToDate: 'Firmware {{version}} — À jour',\n    firmwareUpdateButton: 'Mettre à jour',\n    // Plate detection\n    plateDetection: {\n      noPermission: 'Pas d\\'autorisation de modification',\n      enabledClick: 'Vérification activée - Cliquez pour désactiver',\n      disabledClick: 'Vérification désactivée - Cliquez pour activer',\n      manageCalibration: 'Gérer la calibration de détection',\n      calibrationRequired: 'Calibration requise',\n      calibrationInstructions: 'Videz le plateau, puis cliquez sur Calibrer.',\n      calibrationDescription: 'Capture une image de référence du plateau vide.',\n      calibrationTip: 'Conseil : Stockez jusqu\\'à 5 références. Le système utilise la meilleure correspondance.',\n      plateEmpty: 'Le plateau semble vide',\n      objectsDetected: 'Objets détectés sur le plateau',\n      confidence: 'Confiance',\n      difference: 'Différence',\n      analysisPreview: 'Aperçu de l\\'analyse :',\n      analysisLegend: 'Cadre vert = zone, Rouge = différences',\n      savedReferences: 'Références ({{count}}/{{max}})',\n      deleteReference: 'Supprimer la référence',\n      labelPlaceholder: 'Étiquette...',\n      clickToEdit: '{{label}} - Modifier',\n      clickToAddLabel: 'Ajouter une étiquette',\n    },\n    // Speed\n    speed: {\n      title: 'Vitesse d\\'impression',\n      silent: 'Silencieux (50%)',\n      standard: 'Standard (100%)',\n      sport: 'Sport (124%)',\n      ludicrous: 'Ludicrous (166%)',\n    },\n    airduct: {\n      title: 'Mode conduit d\\'air',\n      cooling: 'Refroidissement',\n      heating: 'Chauffage',\n    },\n    noSdCard: 'Pas de SD',\n    door: {\n      open: 'Ouverte',\n      closed: 'Fermée',\n    },\n    // Fans\n    fans: {\n      partCooling: 'Ventilateur pièce',\n      auxiliary: 'Ventilateur auxiliaire',\n      chamber: 'Ventilateur chambre',\n    },\n    // HMS errors\n    clickToViewHmsErrors: 'Cliquez pour voir les erreurs HMS',\n    estimatedCompletion: 'Fin estimée',\n    plateNumber: 'Plaque {{number}}',\n    slotOptions: 'Options du slot',\n    // AMS hover popup\n    amsPopup: {\n      friendlyName: 'Nom AMS',\n      friendlyNamePlaceholder: 'ex. Nom convivial AMS',\n      serialNumber: 'Numéro de série',\n      firmwareVersion: 'Firmware',\n      save: 'Enregistrer',\n      clear: 'Effacer',\n      noEditPermission: 'Vous n\\'avez pas la permission de renommer les unités AMS',\n    },\n    // Firmware modal\n    firmwareModal: {\n      title: 'Mise à jour Firmware',\n      titleUpToDate: 'Infos Firmware',\n      currentVersion: 'Actuelle :',\n      latestVersion: 'Dernière :',\n      releaseNotes: 'Notes de version',\n      checkingPrereqs: 'Vérification des prérequis...',\n      sdCardReady: 'Carte SD prête. Cliquez pour téléverser.',\n      uploadedSuccess: 'Firmware téléversé !',\n      applyInstructions: 'Pour appliquer sur l\\'imprimante :',\n      step1: 'Sur l\\'écran, allez dans Paramètres',\n      step2: 'Allez dans Firmware',\n      step3: 'Sélectionnez \"Mettre à jour depuis carte SD\"',\n      step4: 'Prévoyez 10-20 minutes',\n      done: 'Terminé',\n      starting: 'Démarrage...',\n      uploadFirmware: 'Téléverser le Firmware',\n      uploadFailed: 'Échec du téléversement : {{error}}',\n      uploadedToast: 'Firmware téléversé ! Lancez la mise à jour sur l\\'écran.',\n    },\n    accessCodePlaceholder: 'Laissez vide pour garder l\\'actuel',\n    // ROI editor\n    roi: {\n      title: 'Zone de détection (ROI)',\n      xStart: 'Début X',\n      yStart: 'Début Y',\n      width: 'Largeur',\n      height: 'Hauteur',\n      instruction: 'Ajustez le cadre vert pour cibler le plateau.',\n    },\n    developerModeWarning: 'Le mode développeur LAN n\\'est pas activé sur : {{names}}. Certaines fonctionnalités peuvent ne pas fonctionner.',\n    howToEnable: 'Comment activer',\n    incompatibleFile: 'Ce fichier a été tranché pour {{slicedFor}}, mais cette imprimante est une {{printerModel}}',\n    dropNotPrintable: 'Seuls les fichiers .gcode et .gcode.3mf peuvent être imprimés',\n    dropToPrint: 'Déposer pour imprimer',\n    cannotPrint: 'Imprimante occupée',\n  },\n\n  // Archives page\n  archives: {\n    title: 'Archives d\\'impression',\n    searchPlaceholder: 'Chercher dans les archives...',\n    filterByPrinter: 'Par imprimante',\n    filterByStatus: 'Par statut',\n    sortBy: 'Trier par',\n    sortNewest: 'Plus récent',\n    sortOldest: 'Plus ancien',\n    sortName: 'Nom',\n    sortDuration: 'Durée',\n    sortLargest: 'Plus volumineux',\n    sortSmallest: 'Plus léger',\n    sortSize: 'Taille',\n    noArchives: 'Aucune archive trouvée',\n    noArchivesSearch: 'Aucune archive ne correspond',\n    originalPrintNotVisible: 'Impression d\\'origine non visible - essayez d\\'effacer les filtres',\n    noArchivesYet: 'Pas encore d\\'archive',\n    prints: 'impressions',\n    pagination: {\n      showing: 'Affichage',\n      to: 'à',\n      of: 'sur',\n      show: 'Afficher',\n      page: 'Page',\n      all: 'Tout',\n    },\n    loadingArchives: 'Chargement...',\n    releaseToUpload: 'Relâcher pour téléverser',\n    showAll: 'Tout afficher',\n    showFavoritesOnly: 'Favoris uniquement',\n    gridView: 'Grille',\n    listView: 'Liste',\n    calendarView: 'Calendrier',\n    logView: 'Journal d\\'impression',\n    manageTags: 'Gérer les tags',\n    showFailedPrints: 'Afficher les échecs',\n    hideFailedPrints: 'Masquer les échecs',\n    hideDuplicates: 'Masquer les doublons',\n    viewOriginalPrint: 'Cliquez pour afficher l\\'impression originale (#{{id}})',\n    printTime: 'Temps d\\'impression',\n    filamentUsed: 'Filament utilisé',\n    cost: 'Coût',\n    reprint: 'Réimprimer',\n    preview: 'Aperçu',\n    deleteArchive: 'Supprimer l\\'archive',\n    deleteConfirm: 'Supprimer cette archive ?',\n    favorite: 'Favori',\n    unfavorite: 'Retirer des favoris',\n    viewDetails: 'Détails',\n    status: {\n      completed: 'Réussi',\n      failed: 'Échoué',\n      stopped: 'Arrêté',\n    },\n    toast: {\n      source3mfAttached: 'Source 3MF attachée : {{filename}}',\n      failedUploadSource3mf: 'Échec téléversement 3MF',\n      source3mfRemoved: 'Source 3MF retirée',\n      failedRemoveSource3mf: 'Échec retrait 3MF',\n      f3dAttached: 'F3D attaché : {{filename}}',\n      failedUploadF3d: 'Échec téléversement F3D',\n      f3dRemoved: 'F3D retiré',\n      failedRemoveF3d: 'Échec retrait F3D',\n      timelapseAttached: 'Timelapse attaché : {{filename}}',\n      timelapseAlreadyAttached: 'Timelapse déjà présent',\n      noMatchingTimelapse: 'Pas de timelapse correspondant',\n      failedScanTimelapse: 'Échec scan timelapse',\n      failedAttachTimelapse: 'Échec attache timelapse',\n      timelapseRemoved: 'Timelapse supprimé',\n      failedRemoveTimelapse: 'Échec de la suppression du timelapse',\n      timelapseUploaded: 'Timelapse importé : {{filename}}',\n      failedUploadTimelapse: 'Échec de l\\'importation du timelapse',\n      archiveDeleted: 'Archive supprimée',\n      failedDeleteArchive: 'Échec suppression',\n      addedToFavorites: 'Ajouté aux favoris',\n      removedFromFavorites: 'Retiré des favoris',\n      projectUpdated: 'Projet mis à jour',\n      failedUpdateProject: 'Échec mise à jour projet',\n      linkCopied: 'Lien copié',\n      failedCopyLink: 'Échec copie lien',\n      photoDeleted: 'Photo supprimée',\n      failedDeletePhoto: 'Échec suppression photo',\n      failedDeleteArchives: 'Échec suppression archives',\n      failedUpdateFavorites: 'Échec mise à jour favoris',\n      exportDownloaded: 'Export téléchargé',\n      exportFailed: 'Échec export',\n    },\n    menu: {\n      print: 'Imprimer',\n      schedule: 'Planifier',\n      openInBambuStudio: 'Ouvrir dans le Slicer',\n      slice: 'Découper',\n      externalLink: 'Lien externe',\n      viewOnMakerWorld: 'Voir sur MakerWorld',\n      preview3d: 'Aperçu 3D',\n      viewTimelapse: 'Voir le Timelapse',\n      scanForTimelapse: 'Scanner pour Timelapse',\n      uploadTimelapse: 'Importer un timelapse',\n      removeTimelapse: 'Supprimer le timelapse',\n      downloadSource3mf: 'Télécharger Source 3MF',\n      uploadSource3mf: 'Téléverser Source 3MF',\n      replaceSource3mf: 'Remplacer Source 3MF',\n      removeSource3mf: 'Retirer Source 3MF',\n      uploadF3d: 'Téléverser F3D',\n      replaceF3d: 'Remplacer F3D',\n      downloadF3d: 'Télécharger F3D',\n      removeF3d: 'Retirer F3D',\n      download: 'Télécharger',\n      copyDownloadLink: 'Copier lien de téléchargement',\n      qrCode: 'Code QR',\n      viewPhotos: 'Voir les photos',\n      viewPhotosCount: 'Voir les photos ({{count}})',\n      projectPage: 'Page du Projet',\n      addToFavorites: 'Ajouter aux favoris',\n      removeFromFavorites: 'Retirer des favoris',\n      edit: 'Modifier',\n      goToProject: 'Aller au Projet : {{name}}',\n      addToProject: 'Ajouter au Projet',\n      removeFromProject: 'Retirer du Projet',\n      loading: 'Chargement...',\n      noProjectsAvailable: 'Aucun projet disponible',\n      select: 'Sélectionner',\n      deselect: 'Désélectionner',\n      delete: 'Supprimer',\n    },\n    permission: {\n      noReprint: 'Pas d\\'autorisation de réimpression',\n      noAddToQueue: 'Pas d\\'autorisation pour la file',\n      noUpdateArchives: 'Pas d\\'autorisation de mise à jour',\n      noUploadFiles: 'Pas d\\'autorisation de téléversement',\n      noDownload: 'Pas d\\'autorisation de téléchargement',\n      noCopyLink: 'Pas d\\'autorisation de copie lien',\n      noDelete: 'Pas d\\'autorisation de suppression',\n      noCreate: 'Pas d\\'autorisation de création',\n    },\n    card: {\n      previousPlate: 'Plateau précédent',\n      nextPlate: 'Plateau suivant',\n      plateNumber: 'Plateau {{index}}',\n      moreOptions: 'Clic droit pour plus d\\'options',\n      addToFavorites: 'Ajouter aux favoris',\n      removeFromFavorites: 'Retirer des favoris',\n      cancelled: 'annulé',\n      failed: 'échoué',\n      duplicate: 'doublon',\n      duplicateTitle: 'Ce modèle a déjà été imprimé',\n      openSource3mf: 'Ouvrir 3MF dans Bambu Studio (clic droit pour plus)',\n      downloadF3d: 'Télécharger fichier Fusion 360',\n      viewTimelapse: 'Voir timelapse',\n      viewPhoto: 'Voir 1 photo',\n      viewPhotos: 'Voir {{count}} photos',\n      openFolder: 'Ouvrir le dossier : {{name}}',\n      slicedFile: 'Fichier découpé - prêt',\n      sourceFile: 'Fichier source uniquement - pas de mapping AMS',\n      gcode: 'GCODE',\n      source: 'SOURCE',\n      project: 'Projet : {{name}}',\n      estimated: 'Estimé : {{time}}',\n      actual: 'Réel : {{time}}',\n      accuracy: 'Précision : {{percent}}%',\n      filament: '{{weight}}g',\n      layer: '{{count}} couche',\n      layers: '{{count}} couches',\n      object: '{{count}} objet',\n      objects: '{{count}} objets',\n      slicedFor: 'Découpé pour {{model}}',\n      uploadedBy: 'Téléversé par',\n      noPermissionReprint: 'Pas d\\'autorisation de réimpression',\n      noFileForReprint: 'Aucun fichier 3MF disponible — le fichier n\\'a pas pu être téléchargé depuis l\\'imprimante lors de l\\'enregistrement',\n      noPermissionEdit: 'Pas d\\'autorisation de modification',\n      noPermissionDelete: 'Pas d\\'autorisation de suppression',\n      reprint: 'Réimprimer',\n      schedulePrint: 'Planifier',\n      schedule: 'Planifier',\n      openInBambuStudio: 'Ouvrir dans le Slicer',\n      openInBambuStudioToSlice: 'Ouvrir dans le Slicer pour découper',\n      slice: 'Découper',\n      externalLink: 'Lien externe',\n      makerWorld: 'MakerWorld : {{designer}}',\n      viewProject: 'Voir projet',\n      noExternalLink: 'Aucun lien externe',\n      preview3d: 'Aperçu 3D',\n      download: 'Télécharger',\n      edit: 'Modifier',\n      delete: 'Supprimer',\n    },\n    modal: {\n      deleteArchive: 'Supprimer l\\'archive',\n      deleteConfirm: 'Supprimer \"{{name}}\" ? Cette action est irréversible.',\n      deleteButton: 'Supprimer',\n      removeSource3mf: 'Retirer Source 3MF',\n      removeSource3mfConfirm: 'Retirer le fichier 3MF de \"{{name}}\" ?',\n      removeButton: 'Retirer',\n      removeF3d: 'Retirer F3D',\n      removeF3dConfirm: 'Retirer le fichier Fusion 360 de \"{{name}}\" ?',\n      removeTimelapse: 'Supprimer le timelapse',\n      removeTimelapseConfirm: 'Êtes-vous sûr de vouloir supprimer la vidéo timelapse de \"{{name}}\" ?',\n      timelapse: '{{name}} - Timelapse',\n      selectTimelapse: 'Choisir un Timelapse',\n      selectTimelapseDesc: 'Sélectionnez manuellement le timelapse :',\n      deleteArchives: 'Supprimer les archives',\n      deleteArchivesConfirm: 'Supprimer {{count}} archive(s) ?',\n      deleteCount: 'Supprimer {{count}}',\n    },\n    page: {\n      title: 'Archives',\n      printsCount: '{{filtered}} sur {{total}} impressions',\n      dropFilesHere: 'Déposez les fichiers .3mf ici',\n      releaseToUpload: 'Relâcher pour téléverser',\n      only3mfSupported: 'Seuls les fichiers .3mf sont supportés',\n      close: 'Fermer',\n      selected: '{{count}} sélectionnés',\n      selectAll: 'Tout sélectionner',\n      tags: 'Tags',\n      project: 'Projet',\n      favorite: 'Favori',\n      delete: 'Supprimer',\n      toggledFavorites: 'Favoris mis à jour pour {{count}} archive(s)',\n      failedUpdateFavorites: 'Échec mise à jour favoris',\n      archivesDeleted: '{{count}} archive(s) supprimée(s)',\n      failedDeleteArchives: 'Échec suppression',\n      photoDeleted: 'Photo supprimée',\n      failedDeletePhoto: 'Échec suppression photo',\n    },\n    list: {\n      name: 'Nom',\n      printer: 'Imprimante',\n      date: 'Date',\n      size: 'Taille',\n      actions: 'Actions',\n      hasTimelapse: 'A un timelapse',\n    },\n    log: {\n      date: 'Date',\n      printName: 'Nom de l\\'impression',\n      printer: 'Imprimante',\n      user: 'Utilisateur',\n      status: 'Statut',\n      duration: 'Durée',\n      filament: 'Filament',\n      allPrinters: 'Toutes les imprimantes',\n      allUsers: 'Tous les utilisateurs',\n      allStatuses: 'Tous les statuts',\n      cancelled: 'Annulé',\n      skipped: 'Ignoré',\n      dateFrom: 'Du',\n      dateTo: 'Au',\n      noEntries: 'Aucune entrée de journal trouvée',\n      showing: '{{count}} sur {{total}} entrées',\n      rowsPerPage: 'Lignes',\n      page: 'Page',\n      prev: 'Préc.',\n      next: 'Suiv.',\n      clearLog: 'Effacer le journal',\n      clearLogTitle: 'Effacer le journal d\\'impression',\n      clearLogConfirm: 'Toutes les entrées du journal d\\'impression seront supprimées définitivement. Les archives et les éléments de file d\\'attente ne sont pas affectés. Cette action est irréversible. Êtes-vous sûr ?',\n      clearLogButton: 'Tout effacer',\n      cleared: '{{count}} entrées de journal effacées',\n      clearFailed: 'Échec de l\\'effacement du journal d\\'impression',\n    },\n  },\n\n  // Queue page\n  queue: {\n    title: 'File d\\'attente',\n    subtitle: 'Gérez vos travaux d\\'impression',\n    addToQueue: 'Ajouter à la file',\n    // Print modal\n    print: 'Imprimer',\n    reprint: 'Réimprimer',\n    schedulePrint: 'Planifier',\n    editQueueItem: 'Modifier l\\'élément',\n    printToPrinters: 'Imprimer sur {{count}} imprimantes',\n    queueToPrinters: 'Ajouter à la file pour {{count}} imprimantes',\n    queueSelectedPlates: 'Ajouter {{count}} plaques à la file',\n    selectAllPlates: 'Sélectionner les {{count}} plaques',\n    deselectAll: 'Tout désélectionner',\n    printQueued: 'Impression ajoutée à la file',\n    itemsQueued: '{{count}} éléments ajoutés à la file',\n    sending: 'Envoi...',\n    sendingProgress: 'Envoi {{current}}/{{total}}...',\n    adding: 'Ajout...',\n    addingProgress: 'Ajout {{current}}/{{total}}...',\n    savingProgress: 'Enregistrement {{current}}/{{total}}...',\n    clearQueue: 'Vider la file',\n    clearHistory: 'Effacer l\\'historique',\n    emptyQueue: 'La file est vide',\n    position: 'Position',\n    scheduledTime: 'Heure prévue',\n    moveUp: 'Monter',\n    moveDown: 'Descendre',\n    startNow: 'Démarrer maintenant',\n    printingInProgress: 'Impression en cours...',\n    viewArchive: 'Voir l\\'archive',\n    viewInFileManager: 'Voir dans le gestionnaire',\n    itemCount: '{{count}} élément',\n    itemCount_plural: '{{count}} éléments',\n    dragToReorder: 'Glisser pour réordonner (ASAP uniquement)',\n    reorderHint: 'La position n\\'affecte que les éléments ASAP.',\n    sjf: {\n      label: 'SJF',\n      tooltip: 'Travail le plus court en premier — le planificateur priorise les impressions plus courtes',\n    },\n    addedBy: 'Ajouté par {{name}}',\n    nextInQueue: 'Prochain en file',\n    clearPlateSuccess: 'Plateau vidé — prêt pour l\\'impression suivante',\n    plateNumber: 'Plateau {{index}}',\n    // Batch / quantity\n    quantity: 'Quantité',\n    quantityHint: 'Crée {{count}} éléments de file d\\'attente',\n    activeBatches: 'Lots actifs',\n    batchProgress: '{{completed}} sur {{total}} terminés',\n    cancelBatch: 'Annuler les restants',\n    batchCancelled: 'Éléments restants du lot annulés',\n    cancelBatchConfirmTitle: 'Annuler le lot',\n    cancelBatchConfirmMessage: 'Annuler tous les éléments en attente restants dans ce lot ?',\n    batch: 'Lot',\n    // Sections\n    sections: {\n      currentlyPrinting: 'En cours',\n      queued: 'En attente',\n      history: 'Historique',\n    },\n    // Status\n    status: {\n      pending: 'En attente',\n      waiting: 'En attente',\n      printing: 'Impression',\n      paused: 'En pause',\n      completed: 'Terminé',\n      failed: 'Échoué',\n      skipped: 'Sauté',\n      cancelled: 'Annulé',\n    },\n    // Summary cards\n    summary: {\n      printing: 'Impressions',\n      queued: 'En attente',\n      totalTime: 'Temps total estimé',\n      totalWeight: 'Poids total estimé',\n      history: 'Historique',\n    },\n    // Filters\n    filter: {\n      allPrinters: 'Toutes les imprimantes',\n      unassigned: 'Non assigné',\n      allStatus: 'Tous les statuts',\n      allLocations: 'Tous les emplacements',\n      any: 'Tout',\n    },\n    // Sort\n    sort: {\n      byPosition: 'Par position',\n      byName: 'Par nom',\n      byPrinter: 'Par imprimante',\n      bySchedule: 'Par planification',\n      byDate: 'Par date',\n      ascendingOldest: 'Croissant (plus vieux)',\n      descendingNewest: 'Décroissant (plus récent)',\n    },\n    // Badges\n    badges: {\n      staged: 'Préparé',\n      requiresPrevious: 'Nécessite succès précédent',\n      autoPowerOff: 'Extinction auto',\n      gcodeInjection: 'G-code',\n    },\n    // Empty state\n    empty: {\n      title: 'Aucune impression prévue',\n      description: 'Planifiez depuis les Archives ou glissez des fichiers ici.',\n    },\n    // Time\n    time: {\n      asap: 'Dès que possible',\n      overdue: 'En retard',\n      now: 'Maintenant',\n      lessThanMinute: 'Dans moins d\\'une minute',\n      inMinutes: 'Dans {{count}} min',\n      inHours: 'Dans {{count}} heures',\n    },\n    // Actions\n    actions: {\n      stopPrint: 'Arrêter',\n      startPrint: 'Démarrer',\n      requeue: 'Remettre en file',\n    },\n    // Bulk edit\n    bulkEdit: {\n      title: 'Modifier {{count}} élément',\n      title_plural: 'Modifier {{count}} éléments',\n      description: 'Seuls les changements seront appliqués.',\n      printer: 'Imprimante',\n      noChange: '— Aucun changement —',\n      queueOptions: 'Options de file',\n      staged: 'Préparé (manuel)',\n      autoPowerOff: 'Extinction auto après',\n      requirePrevious: 'Requiert succès précédent',\n      printOptions: 'Options d\\'impression',\n      bedLevelling: 'Nivellement plateau',\n      flowCalibration: 'Calibration débit',\n      vibrationCalibration: 'Vibration (Input Shaper)',\n      layerInspection: 'Inspection 1ère couche',\n      timelapse: 'Timelapse',\n      useAms: 'Utiliser AMS',\n      applyChanges: 'Appliquer',\n      selectAll: 'Tout sélectionner',\n      deselectAll: 'Tout désélectionner',\n      selected: '{{count}} sélectionnés',\n      editSelected: 'Modifier la sélection',\n      cancelSelected: 'Annuler la sélection',\n    },\n    // Confirmations\n    confirm: {\n      cancelTitle: 'Annuler l\\'impression prévue',\n      cancelMessage: 'Annuler \"{{name}}\" ?',\n      stopTitle: 'Arrêter l\\'impression',\n      stopMessage: 'Arrêter \"{{name}}\" sur l\\'imprimante ?',\n      removeTitle: 'Retirer de l\\'historique',\n      removeMessage: 'Retirer \"{{name}}\" de l\\'historique ?',\n      clearHistoryTitle: 'Effacer l\\'historique',\n      clearHistoryMessage: 'Retirer les {{count}} éléments ?',\n      cancelButton: 'Annuler l\\'impression',\n      stopButton: 'Arrêter l\\'impression',\n      thisPrint: 'cette impression',\n      thisItem: 'cet élément',\n    },\n    // Toast messages\n    toast: {\n      cancelled: 'Élément annulé',\n      cancelFailed: 'Échec annulation',\n      removed: 'Élément retiré',\n      removeFailed: 'Échec retrait',\n      stopped: 'Impression arrêtée',\n      stopFailed: 'Échec arrêt',\n      released: 'Libéré dans la file',\n      startFailed: 'Échec démarrage',\n      reorderFailed: 'Échec réorganisation',\n      historyCleared: '{{count}} éléments effacés',\n      clearHistoryFailed: 'Échec effacement',\n      updateFailed: 'Échec mise à jour',\n      bulkCancelled: '{{count}} éléments annulés',\n      bulkCancelFailed: 'Échec annulation',\n    },\n    // Timeline view\n    timeline: {\n      listView: 'Liste',\n      timelineView: 'Chronologie',\n      unassigned: 'Non attribué',\n      noData: 'Aucune impression planifiée pour ce jour',\n      allDoneBy: 'Toutes les impressions terminées vers {{time}}',\n      staged: 'En attente',\n      filterAll: 'Tout afficher',\n      filterPrinting: 'En cours',\n      filterQueued: 'En file',\n      time: {\n        anyMoment: 'imminent',\n        minutesLeft: '{{minutes}}m restantes',\n        hoursLeft: '{{hours}}h restantes',\n        hoursMinutesLeft: '{{hours}}h {{minutes}}m restantes',\n      },\n      day: {\n        previous: 'Jour précédent',\n        next: 'Jour suivant',\n        today: 'Aujourd\\'hui',\n      },\n    },\n    // Permissions\n    permissions: {\n      noStopPrint: 'Pas d\\'autorisation d\\'arrêt',\n      noStartPrint: 'Pas d\\'autorisation de démarrage',\n      noEdit: 'Pas d\\'autorisation de modification',\n      noCancel: 'Pas d\\'autorisation d\\'annulation',\n      noRequeue: 'Pas d\\'autorisation de remise en file',\n      noRemove: 'Pas d\\'autorisation de retrait',\n      noClearHistory: 'Pas d\\'autorisation historique',\n      noEditItems: 'Pas d\\'autorisation modification groupée',\n      noCancelItems: 'Pas d\\'autorisation annulation groupée',\n    },\n  },\n\n  backgroundDispatch: {\n    unknownFile: 'Unknown file',\n    unknownPrinter: 'Unknown printer',\n    startingPrints: 'Starting prints',\n    progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',\n    expandDetails: 'Expand dispatch details',\n    collapseDetails: 'Collapse dispatch details',\n    dismissToast: 'Dismiss dispatch toast',\n    cancelDispatchJob: 'Cancel dispatch job',\n    cancel: 'Cancel',\n    cancelling: 'Cancelling…',\n    status: {\n      dispatched: 'Dispatched',\n      processing: 'Processing',\n      completed: 'Completed',\n      failed: 'Failed',\n      cancelled: 'Cancelled',\n    },\n    toast: {\n      cancellingUpload: 'Cancelling upload...',\n      cancelled: 'Dispatch cancelled',\n      cancelFailed: 'Failed to cancel dispatch',\n      completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',\n      completeSuccess: 'Background dispatch complete: {{completed}} succeeded',\n      printStartedRemaining: '{{completed}} impression(s) lancée(s), {{remaining}} en cours d\\'envoi...',\n    },\n  },\n\n  // Statistics page\n  stats: {\n    title: 'Tableau de bord',\n    subtitle: 'Glissez les widgets pour réorganiser. Cliquez sur l\\'œil pour masquer.',\n    overview: 'Vue d\\'ensemble',\n    totalPrints: 'Total impressions',\n    successRate: 'Taux de succès',\n    totalPrintTime: 'Temps total d\\'impression',\n    printTime: 'Temps d\\'impression',\n    totalFilament: 'Total filament utilisé',\n    filamentUsed: 'Filament utilisé',\n    filamentCost: 'Coût filament',\n    totalCost: 'Coût total',\n    energyUsed: 'Énergie consommée',\n    energyCost: 'Coût énergie',\n    energyWarmingUpTooltip: 'Le suivi énergétique collecte encore des instantanés horaires. Les totaux par période deviendront précis dès qu’un instantané existera avant la plage sélectionnée. Les premières valeurs peuvent être sous-estimées.',\n    averagePrintTime: 'Temps moyen par impression',\n    printsPerDay: 'Impressions par jour',\n    byPrinter: 'Par imprimante',\n    printsByPrinter: 'Impressions par imprimante',\n    byMaterial: 'Par matériau',\n    byMonth: 'Par mois',\n    last7Days: '7 derniers jours',\n    last30Days: '30 derniers jours',\n    last90Days: '90 derniers jours',\n    allTime: 'Tout le temps',\n    // Widgets\n    quickStats: 'Stats rapides',\n    printActivity: 'Activité d\\'impression',\n    filamentTypes: 'Types de filament',\n    filamentTrends: 'Tendances filament',\n    failureAnalysis: 'Analyse des échecs',\n    timeAccuracy: 'Précision du temps',\n    successful: 'Succès :',\n    failed: 'Échecs :',\n    perfectEstimate: '100% = estimation parfaite',\n    noTimeAccuracyData: 'Pas encore de données de précision',\n    noFilamentData: 'Aucune donnée de filament',\n    noPrinterData: 'Aucune donnée d\\'imprimante',\n    noPrintData: 'Aucune donnée d\\'impression',\n    noPrintDataLast30Days: 'Aucune impression ces 30 derniers jours',\n    failureReasons: 'Raisons des échecs',\n    topFailureReasons: 'Top raisons d\\'échec',\n    failedPrintsCount: '{{failed}} / {{total}} impressions ont échoué',\n    lastWeekRate: 'Semaine dernière : {{rate}}%',\n    // Actions\n    resetLayout: 'Réinitialiser la mise en page',\n    recalculateCosts: 'Recalculer les coûts',\n    recalculateCostsHint: 'Recalcule les coûts avec les prix actuels des filaments',\n    exportStats: 'Exporter les stats',\n    exportAsCsv: 'Exporter en CSV',\n    exportAsExcel: 'Exporter en Excel',\n    hiddenCount: '{{count}} Masqués',\n    // Toast\n    exportDownloaded: 'Export téléchargé',\n    exportFailed: 'Échec export',\n    layoutReset: 'Mise en page réinitialisée',\n    recalculatedCosts: 'Coûts recalculés pour {{count}} archives',\n    recalculateFailed: 'Échec du calcul',\n    // Loading\n    loadingStats: 'Chargement des statistiques...',\n    // Permissions\n    noPermissionResetLayout: 'Pas d\\'autorisation de réinitialisation',\n    noPermissionRecalculate: 'Pas d\\'autorisation de recalcul',\n    noPrintDataInRange: 'Aucune donnée dans la période sélectionnée',\n    periodFilament: 'Filament utilisé',\n    periodCost: 'Coût',\n    avgPerPrint: 'Moy. par impression',\n    usageOverTime: 'Utilisation dans le temps',\n    filamentByWeight: 'Poids',\n    printDuration: 'Durée d\\'impression',\n    printerUtilization: 'Utilisation imprimante',\n    filamentSuccess: 'Succès par matériau',\n    printHabits: 'Habitudes d\\'impression',\n    printTimeOfDay: 'Heure d\\'impression',\n    colorDistribution: 'Distribution des couleurs',\n    noColorData: 'Aucune donnée de couleur disponible',\n    records: 'Records',\n    longestPrint: 'Plus longue impression',\n    heaviestPrint: 'Plus lourde impression',\n    mostExpensivePrint: 'Plus chère',\n    busiestDay: 'Jour le plus actif',\n    successStreak: 'Série de succès',\n    streakPrint: 'impression consécutive',\n    streakPrints: '{{count}} impressions consécutives',\n    printerStats: 'Stats imprimante',\n    hours: 'heures',\n    avgPrints: 'Moy. impressions',\n    noArchiveData: 'Aucune donnée d\\'impression disponible',\n    filamentByTime: 'Temps',\n    avgWeight: 'Moy. poids',\n    avgTime: 'Moy. temps',\n    filamentByPrints: 'Impressions',\n    timeframe: {\n      today: 'Aujourd\\'hui',\n      'this-week': 'Cette semaine',\n      'this-month': 'Ce mois',\n      'last-7': '7 derniers jours',\n      'last-30': '30 derniers jours',\n      'last-90': '90 derniers jours',\n      'this-year': 'Cette année',\n      'all-time': 'Tout',\n      custom: 'Personnalisé',\n      from: 'Du',\n      to: 'Au',\n    },\n    allUsers: 'Tous les utilisateurs',\n    noUser: 'Aucun utilisateur (Système)',\n    filterByUser: 'Filtrer par utilisateur',\n  },\n\n  // Maintenance page\n  maintenance: {\n    title: 'Maintenance',\n    overview: 'Vue d\\'ensemble',\n    allOk: 'Toute la maintenance est à jour',\n    dueCount: '{{count}} tâche à faire',\n    dueCount_plural: '{{count}} tâches à faire',\n    warningCount: '{{count}} avertissement',\n    warningCount_plural: '{{count}} avertissements',\n    totalPrintTime: 'Temps d\\'impression total',\n    nextMaintenance: 'Prochaine maintenance',\n    nothingDue: 'Rien de prévu',\n    tasks: 'Tâches',\n    lastPerformed: 'Dernière réalisation',\n    interval: 'Intervalle',\n    hoursRemaining: '{{hours}}h restantes',\n    hoursOverdue: '{{hours}}h de retard',\n    markDone: 'Marquer comme fait',\n    performMaintenance: 'Effectuer la maintenance',\n    history: 'Historique',\n    noHistory: 'Aucun historique',\n    editPrintHours: 'Modifier les heures d\\'impression',\n    currentHours: 'Heures actuelles',\n    // Tabs\n    statusTab: 'Statut',\n    settingsTab: 'Paramètres',\n    // Status\n    overdueCount: '{{count}} en retard',\n    dueSoonCount: '{{count}} bientôt',\n    dueSoon: 'Bientôt',\n    allGood: 'Tout va bien',\n    overdueBy: 'Retard de {{duration}}',\n    dueIn: 'Dans {{duration}}',\n    timeLeft: '{{duration}} restant',\n    // Duration formats\n    day: '1 jour',\n    days: '{{count}} jours',\n    week: '1 semaine',\n    weeks: '{{count}} semaines',\n    month: '1 mois',\n    months: '{{count}} mois',\n    year: '1 an',\n    // Settings\n    maintenanceTypes: 'Types de maintenance',\n    maintenanceTypesDescription: 'Types système et tâches personnalisées',\n    addCustomType: 'Ajouter un type',\n    restoreDefaults: 'Restaurer les tâches par défaut',\n    intervalType: 'Type d\\'intervalle',\n    intervalValue: 'Intervalle ({{type}})',\n    icon: 'Icône',\n    documentationLink: 'Lien documentation (optionnel)',\n    assignToPrinters: 'Assigner aux imprimantes',\n    selectAtLeastOnePrinter: 'Sélectionnez au moins une imprimante',\n    addType: 'Ajouter le type',\n    custom: 'Personnalisé',\n    printHours: 'Heures d\\'impression',\n    calendarDays: 'Jours calendaires',\n    exampleName: 'ex: Remplacement filtre HEPA',\n    viewDocumentation: 'Voir documentation',\n    timeBasedInterval: 'Intervalle temporel',\n    // Interval overrides\n    intervalOverrides: 'Exceptions d\\'intervalle',\n    intervalOverridesDescription: 'Intervalles spécifiques par imprimante',\n    // Printer assignment\n    assignedToPrinters: 'Assigné aux imprimantes :',\n    noPrintersAssigned: 'Aucune imprimante assignée',\n    addPrinterShort: 'Ajouter :',\n    printersAssignedClick: '{{count}} imprimante(s) assignée(s) - gérer',\n    removeFromPrinter: 'Retirer de cette imprimante',\n    // Types\n    types: {\n      lubricateCarbonRods: 'Lubrifier les tiges carbone',\n      lubricateRails: 'Lubrifier les rails linéaires',\n      cleanNozzle: 'Nettoyer la buse / hotend',\n      checkBelts: 'Vérifier la tension des courroies',\n      cleanBuildPlate: 'Nettoyer le plateau',\n      checkExtruder: 'Vérifier les engrenages de l\\'extrudeur',\n      checkCooling: 'Vérifier les ventilateurs',\n      generalInspection: 'Inspection générale',\n      cleanCarbonRods: 'Nettoyer les tiges carbone',\n      lubricateSteelRods: 'Lubrifier les tiges en acier',\n      cleanSteelRods: 'Nettoyer les tiges en acier',\n      cleanLinearRails: 'Nettoyer les rails linéaires',\n      checkPtfeTube: 'Vérifier le tube PTFE',\n      replaceHepaFilter: 'Remplacer le filtre HEPA',\n      replaceCarbonFilter: 'Remplacer le filtre charbon',\n      lubricateLeftNozzleRail: 'Lubrifier le rail de buse gauche',\n    },\n    // Toast\n    maintenanceComplete: 'Maintenance marquée comme faite',\n    typeUpdated: 'Type mis à jour',\n    typeDeleted: 'Type supprimé',\n    defaultsRestored: '{{count}} tâche(s) par défaut restaurée(s)',\n    printHoursUpdated: 'Heures mises à jour',\n    printerAssigned: 'Imprimante assignée',\n    printerRemoved: 'Imprimante retirée',\n    // Confirmation\n    deleteTypeConfirm: 'Supprimer \"{{name}}\" ?',\n    deleteSystemTypeTitle: 'Supprimer la tâche de maintenance par défaut ?',\n    deleteSystemTypeMessage: 'Êtes-vous sûr de vouloir supprimer la tâche de maintenance par défaut \"{{name}}\" ?',\n    // Permissions\n    noPermissionUpdate: 'Pas d\\'autorisation de mise à jour',\n    noPermissionPerform: 'Pas d\\'autorisation d\\'action',\n    noPermissionEditTypes: 'Pas d\\'autorisation de modification types',\n    noPermissionDeleteTypes: 'Pas d\\'autorisation de suppression types',\n    noPermissionEditHours: 'Pas d\\'autorisation de modification heures',\n    noPermissionRemovePrinter: 'Pas d\\'autorisation retrait imprimante',\n    noPermissionAssignPrinter: 'Pas d\\'autorisation assignation',\n    noPermissionEditIntervals: 'Pas d\\'autorisation modification intervalles',\n    // Configure link\n    configureSettings: 'Configurer types et intervalles',\n  },\n\n  // Settings page\n  settings: {\n    title: 'Paramètres',\n    general: 'Général',\n    // Tab names\n    tabs: {\n      general: 'Général',\n      smartPlugs: 'Prises connectées',\n      notifications: 'Notifications',\n      queue: 'Workflow',\n      filament: 'Filament',\n      network: 'Réseau',\n      apiKeys: 'Clés API',\n      virtualPrinter: 'Imprimante virtuelle',\n      failureDetection: 'Détection d\\'échec',\n      users: 'Authentification',\n      backup: 'Sauvegarde',\n      emailAuth: 'Authentification Email',\n      ldap: 'LDAP',\n      twoFa: 'Authentification 2FA',\n      oidc: 'SSO / OIDC',\n    },\n    ldap: {\n      title: 'Authentification LDAP',\n      enabledDesc: \"L'authentification LDAP est activée\",\n      disabledDesc: \"L'authentification LDAP est désactivée\",\n      disabledHint: 'Configurez et enregistrez les paramètres LDAP ci-dessous, puis activez.',\n      enabled: 'Authentification LDAP activée',\n      disabled: 'Authentification LDAP désactivée',\n      feature1: 'Les utilisateurs peuvent se connecter avec leurs identifiants LDAP',\n      feature2: \"Le compte administrateur local reste disponible en secours\",\n      feature3: 'Les groupes LDAP sont associés aux groupes BamBuddy à la connexion',\n      serverConfig: 'Configuration du serveur LDAP',\n      serverUrl: 'URL du serveur',\n      serverUrlHint: 'Utilisez ldap:// pour standard ou ldaps:// pour les connexions SSL',\n      security: 'Sécurité',\n      securityHint: 'StartTLS met à niveau une connexion simple vers TLS. LDAPS utilise TLS dès le début.',\n      bindDn: 'DN de liaison (compte de service)',\n      bindPassword: 'Mot de passe de liaison',\n      searchBase: 'DN de base de recherche',\n      userFilter: 'Filtre de recherche utilisateur',\n      userFilterHint: \"{username} est remplacé par le nom d'utilisateur. Utilisez (uid={username}) pour OpenLDAP.\",\n      autoProvision: 'Provisionnement automatique',\n      autoProvisionHint: 'Créer automatiquement un compte BamBuddy lors de la première connexion LDAP',\n      defaultGroup: 'Groupe par défaut',\n      defaultGroupNone: '— Aucun (pas de repli) —',\n      defaultGroupHint: \"Groupe de repli attribué lorsqu'un utilisateur LDAP s'authentifie mais n'est dans aucun groupe LDAP mappé. Laissez vide pour laisser les utilisateurs non mappés sans autorisations.\",\n      groupMapping: 'Mappage de groupes (JSON)',\n      groupMappingHint: 'Associer les DN de groupes LDAP aux groupes BamBuddy. Groupes disponibles : ',\n      testConnection: 'Tester la connexion',\n      settingsSaved: 'Paramètres LDAP enregistrés',\n      errors: {\n        serverRequired: \"L'URL du serveur LDAP est requise\",\n        searchBaseRequired: 'Le DN de base de recherche est requis',\n        enableAuthFirst: \"Activez d'abord l'authentification\",\n        configureLdapFirst: \"Enregistrez d'abord les paramètres LDAP\",\n      },\n    },\n    // Email settings\n    email: {\n      smtpSettings: 'Configuration SMTP',\n      smtpHost: 'Serveur SMTP',\n      smtpPort: 'Port SMTP',\n      security: 'Sécurité',\n      authentication: 'Authentification',\n      username: 'Utilisateur',\n      password: 'Mot de passe',\n      fromEmail: 'Email expéditeur',\n      fromName: 'Nom expéditeur',\n      testConnection: 'Tester la connexion SMTP',\n      testRecipient: 'Email test destinataire',\n      sendTest: 'Envoyer email test',\n      sending: 'Envoi...',\n      save: 'Enregistrer les paramètres',\n      saving: 'Enregistrement...',\n      advancedAuth: 'Authentification avancée',\n      advancedAuthEnabled: 'L\\'authentification avancée est activée',\n      advancedAuthEnabledDesc: 'La gestion des utilisateurs par email est active. Les nouveaux utilisateurs recevront un mot de passe auto-généré.',\n      advancedAuthDisabled: 'Authentification avancée désactivée',\n      advancedAuthDisabledDesc: 'Activez pour les fonctionnalités liées à l\\'email (mot de passe oublié, etc).',\n      enable: 'Activer',\n      disable: 'Désactiver',\n      feature1: 'Génération auto et envoi de mots de passe par email',\n      feature2: 'Connexion par utilisateur ou email',\n      feature3: 'Réinitialisation mot de passe oublié disponible',\n      feature4: 'Réinitialisation admin par email',\n      // Error messages\n      errors: {\n        requiredFields: 'Remplissez tous les champs requis',\n        usernameRequired: 'L\\'utilisateur est requis pour l\\'authentification',\n        enterTestEmail: 'Entrez une adresse email de test',\n        smtpServerAndEmail: 'Serveur et expéditeur requis pour le test',\n        usernamePasswordRequired: 'Utilisateur et mot de passe requis pour l\\'auth',\n        configureSmtpFirst: 'Configurez et testez le SMTP d\\'abord',\n        enableAuthFirst: 'Veuillez d\\'abord activer l\\'authentification pour utiliser les fonctionnalités basées sur le courrier électronique.',\n      },\n      // Success messages\n      success: {\n        settingsSaved: 'Paramètres SMTP enregistrés',\n      },\n      // Security options\n      securityOptions: {\n        starttls: 'STARTTLS (Port 587)',\n        ssl: 'SSL/TLS (Port 465)',\n        none: 'Aucun (Port 25)',\n      },\n      // Authentication options\n      authOptions: {\n        enabled: 'Activée',\n        disabled: 'Désactivée',\n      },\n    },\n    appearance: 'Apparence',\n    notifications: 'Notifications',\n    smartPlugs: 'Prises connectées',\n    spoolman: 'Spoolman',\n    updates: 'Mises à jour',\n    language: 'Langue',\n    languageDescription: 'Choisissez votre langue',\n    theme: 'Thème',\n    themeLight: 'Clair',\n    themeDark: 'Sombre',\n    themeSystem: 'Système',\n    defaultView: 'Vue par défaut',\n    defaultViewDescription: 'Page affichée au démarrage',\n    checkForUpdates: 'Vérifier les mises à jour',\n    autoUpdate: 'Mise à jour auto',\n    currentVersion: 'Version actuelle',\n    latestVersion: 'Dernière version',\n    upToDate: 'Bambuddy est à jour',\n    updateAvailable: 'Mise à jour disponible',\n    // Notifications\n    notificationLanguage: 'Langue des notifications',\n    notificationLanguageDescription: 'Langue pour les notifications push',\n    bedCooledThreshold: 'Seuil de refroidissement du plateau',\n    bedCooledThresholdDescription: 'Température en dessous de laquelle le plateau est considéré comme refroidi',\n    userNotificationsEnabled: 'Notifications utilisateur',\n    userNotificationsEnabledDescription: \"Active le menu de notifications utilisateur et les notifications par e-mail pour les événements d'impression. Nécessite l'authentification avancée.\",\n    userNotificationsDisabledHint: \"Activez l'authentification avancée pour utiliser les notifications utilisateur.\",\n    notificationProviders: 'Fournisseurs de notifications',\n    addProvider: 'Ajouter un fournisseur',\n    editProvider: 'Modifier le fournisseur',\n    providerType: 'Type de fournisseur',\n    testNotification: 'Tester la notification',\n    testSuccess: 'Notification de test envoyée',\n    testFailed: 'Échec de l\\'envoi du test',\n    quietHours: 'Heures de silence',\n    quietHoursDescription: 'Ne pas déranger pendant ces heures',\n    quietHoursStart: 'Début',\n    quietHoursEnd: 'Fin',\n    events: {\n      title: 'Événements de notification',\n      printStart: 'Impression démarrée',\n      printComplete: 'Impression terminée',\n      printFailed: 'Impression échouée',\n      printStopped: 'Impression arrêtée',\n      printProgress: 'Jalons de progression',\n      printProgressDescription: 'Notifier à 25%, 50%, 75%',\n      printerOffline: 'Imprimante hors ligne',\n      printerError: 'Erreur imprimante',\n      filamentLow: 'Filament bas',\n      maintenanceDue: 'Maintenance due',\n      maintenanceDueDescription: 'Notifier quand une tâche est due',\n    },\n    // Smart Plugs\n    smartPlug: {\n      title: 'Prises connectées',\n      add: 'Ajouter une prise',\n      edit: 'Modifier la prise',\n      name: 'Nom',\n      ipAddress: 'Adresse IP',\n      linkedPrinter: 'Imprimante liée',\n      autoOn: 'Allumage auto',\n      autoOnDescription: 'Allumer au début de l\\'impression',\n      autoOff: 'Extinction auto',\n      autoOffDescription: 'Éteindre après l\\'impression',\n      offDelay: 'Délai d\\'extinction',\n      offDelayMinutes: 'Minutes après fin',\n      offDelayTemp: 'Quand la buse est sous',\n      currentState: 'État actuel',\n      turnOn: 'Allumer',\n      turnOff: 'Éteindre',\n    },\n    // Filament Tracking Mode\n    filamentTracking: 'Suivi de Filament',\n    filamentTrackingDesc: 'Choisissez comment suivre vos bobines. Utilisez l\\'inventaire intégré ou connectez un serveur Spoolman.',\n    filamentChecks: 'Vérifications du filament',\n    disableFilamentWarnings: 'Désactiver les avertissements de filament',\n    disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\\'impression ou de la mise en file d\\'attente',\n    preferLowestFilament: 'Préférer le filament le plus bas',\n    preferLowestFilamentDesc: 'Lorsque plusieurs bobines correspondent, utiliser celle avec le moins de filament restant',\n    trackingModeBuiltIn: 'Inventaire Intégré',\n    trackingModeBuiltInDesc: 'Correspondance RFID et suivi de consommation inclus',\n    trackingModeSpoolmanDesc: 'Serveur de gestion externe',\n    builtInFeatureRfid: 'Détecte auto les bobines RFID Bambu Lab dans l\\'AMS',\n    builtInFeatureUsage: 'Suit la consommation par impression',\n    builtInFeatureCatalog: 'Gère bobines, couleurs et profils facteur K',\n    builtInFeatureThirdParty: 'Les bobines tierces peuvent être assignées aux bobines d\\'inventaire',\n    amsSyncButton: 'Synchroniser les poids depuis l\\'AMS',\n    amsSyncTitle: 'Synchroniser les poids des bobines depuis l\\'AMS',\n    amsSyncMessage: 'Tous les poids des bobines de l\\'inventaire seront écrasés par les valeurs actuelles de l\\'AMS des imprimantes connectées. Utilisez ceci pour récupérer des données de poids corrompues. Les imprimantes doivent être en ligne.',\n    amsSyncing: 'Synchronisation...',\n    amsSyncSuccess: '{{synced}} bobine(s) synchronisée(s), {{skipped}} ignorée(s)',\n    amsSyncError: 'Échec de la synchronisation des poids depuis l\\'AMS',\n    // Spoolman settings\n    spoolmanUrl: 'URL Spoolman',\n    spoolmanUrlHint: 'URL de votre serveur Spoolman (ex: http://localhost:7912)',\n    spoolmanConnected: 'Connecté',\n    spoolmanDisconnected: 'Déconnecté',\n    status: 'Statut',\n    connect: 'Connecter',\n    disconnect: 'Déconnecter',\n    howSyncWorks: 'Fonctionnement de la Sync',\n    syncInfoRfidOnly: 'Seules les bobines officielles RFID sont synchronisées',\n    syncInfoAutoCreate: 'Les bobines sont créées dans Spoolman à la 1ère sync',\n    syncInfoThirdPartySkipped: 'Les bobines tierces ou rechargées sont ignorées',\n    linkingExistingSpools: 'Lier des bobines existantes',\n    linkingExistingSpoolsDesc: 'Pour lier une bobine Spoolman, survolez un slot AMS et cliquez sur \"Lier à Spoolman\".',\n    syncMode: 'Mode de Sync',\n    syncModeAuto: 'Automatique',\n    syncModeManual: 'Manuel uniquement',\n    syncModeAutoDesc: 'Sync auto lors de changements AMS',\n    syncModeManualDesc: 'Sync uniquement sur déclenchement manuel',\n    syncAmsData: 'Synchroniser AMS',\n    syncAmsDataDesc: 'Synchroniser manuellement les données vers Spoolman',\n    allPrinters: 'Toutes les imprimantes',\n    // Default printer\n    noDefaultPrinter: 'Aucune par défaut (demander à chaque fois)',\n    // Sidebar\n    sidebarOrder: 'Ordre de la barre latérale',\n    // Camera\n    saveThumbnails: 'Enregistrer les vignettes',\n    captureFinishPhoto: 'Prendre une photo à la fin',\n    noPrintersConfigured: 'Aucune imprimante configurée',\n    // Archive settings\n    archiveMode: {\n      always: 'Toujours créer une archive',\n      never: 'Ne jamais créer d\\'archive',\n      ask: 'Demander à chaque fois',\n    },\n    // Updates\n    checkForUpdatesLabel: 'Vérifier les mises à jour',\n    checkPrinterFirmware: 'Vérifier le firmware imprimante',\n    includeBetaUpdates: 'Inclure les versions bêta',\n    includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',\n    // Queue\n    enableRetry: 'Activer la rétentative',\n    // Home Assistant\n    homeAssistantDescription: 'Contrôler les prises via Home Assistant',\n    environmentManagedLabel: '(Géré par l\\'environnement)',\n    autoEnabledViaEnv: 'Activé via variables d\\'environnement',\n    urlFromEnvReadOnly: 'Valeur HA_URL (lecture seule)',\n    tokenFromEnvReadOnly: 'Valeur HA_TOKEN (lecture seule)',\n    // MQTT\n    mqttConnectedTo: 'Connecté à',\n    // Prometheus\n    prometheusDescription: 'Exposer les données au format Prometheus',\n    // Smart plugs empty state\n    noSmartPlugsTitle: 'Aucune prise configurée',\n    noSmartPlugsDescription: 'Ajoutez une prise Tasmota pour suivre l\\'énergie et automatiser.',\n    // Notifications empty state\n    noProvidersTitle: 'Aucun fournisseur configuré',\n    noProvidersDescription: 'Ajoutez un fournisseur pour recevoir des alertes.',\n    noTemplatesAvailable: 'Aucun modèle dispo. Redémarrez pour les générer.',\n    // API permissions\n    apiPermissionView: 'Voir statut et file',\n    apiPermissionEdit: 'Gérer la file d\\'attente',\n    // API keys\n    apiKeysEmptyTitle: 'Aucune clé API',\n    apiKeysEmptyDescription: 'Créez une clé pour vos intégrations.',\n    // Users\n    noUsersFound: 'Aucun utilisateur trouvé',\n    noGroupsFound: 'Aucun groupe trouvé',\n    noGroupsAvailable: 'Aucun groupe disponible',\n    passwordsDoNotMatch: 'Les mots de passe ne correspondent pas',\n    systemGroupWarning: 'Les noms des groupes système sont fixes',\n    // Auth disabled\n    authDisabledTitle: 'Authentification désactivée',\n    authDisabledFeature1: 'Requis pour accéder au système',\n    authDisabledFeature2: 'Gestion multi-utilisateurs et groupes',\n    authDisabledFeature3: 'Plus de 50 permissions granulaires',\n    // User deletion\n    userHasCreated: 'Cet utilisateur a créé :',\n    userItemsQuestion: 'Que faire de ces éléments ?',\n    deleteUserConfirm: 'Supprimer cet utilisateur ?',\n    actionCannotBeUndone: 'Cette action est irréversible.',\n    // Smart plugs\n    addFirstSmartPlug: 'Ajoutez votre première prise',\n    // Notifications\n    providers: 'Fournisseurs',\n    log: 'Journal',\n    testAll: 'Tout tester',\n    testResults: 'Résultats du test',\n    testPassedCount: '{{count}} succès',\n    testFailedCount: '{{count}} échecs',\n    messageTemplates: 'Modèles de message',\n    messageTemplatesDescription: 'Personnalisez les messages par événement.',\n    // API Keys section\n    apiKeys: 'Clés API',\n    apiKeysDescription: 'Créez des clés pour webhooks et API.',\n    createKey: 'Créer une clé',\n    apiKeyCreated: 'Clé API créée avec succès',\n    apiKeyCopyWarning: 'Copiez cette clé maintenant - elle ne sera plus affichée !',\n    useInApiBrowser: 'Utiliser dans l\\'explorateur API',\n    createNewApiKey: 'Nouvelle clé API',\n    keyName: 'Nom de la clé',\n    keyNamePlaceholder: 'ex: Home Assistant, OctoPrint',\n    readStatus: 'Lire le statut',\n    readStatusDescription: 'Voir les imprimantes et la file',\n    manageQueue: 'Gérer la file',\n    manageQueueDescription: 'Ajouter/retirer des éléments',\n    controlPrinter: 'Contrôler l\\'imprimante',\n    controlPrinterDescription: 'Pause, reprise, arrêt',\n    unnamedKey: 'Clé sans nom',\n    lastUsed: 'Dernière utilisation',\n    read: 'Lecture',\n    control: 'Contrôle',\n    createFirstKey: 'Créez votre première clé',\n    webhookEndpoints: 'Points de terminaison Webhook',\n    webhookApiKeyHint: 'Utilisez la clé dans l\\'en-tête X-API-Key.',\n    webhook: {\n      getAllStatus: 'Tous les statuts',\n      getSpecificStatus: 'Statut spécifique',\n      addToQueue: 'Ajouter à la file',\n      pausePrint: 'Pause',\n      resumePrint: 'Reprise',\n      stopPrint: 'Arrêt',\n    },\n    apiBrowser: 'Explorateur API',\n    apiBrowserDescription: 'Testez les endpoints API.',\n    apiKeyForTesting: 'Clé API pour test',\n    apiKeyPlaceholder: 'Collez votre clé pour tester...',\n    apiKeyHint: 'Sera envoyée via l\\'en-tête X-API-Key.',\n    deleteApiKeyTitle: 'Supprimer la clé API',\n    deleteApiKeyMessage: 'Les intégrations utilisant cette clé cesseront de fonctionner.',\n    deleteKey: 'Supprimer la clé',\n    // Filament tab\n    amsDisplayThresholds: 'Seuils d\\'affichage AMS',\n    amsThresholdsDescription: 'Seuils de couleur pour humidité et température.',\n    humidity: 'Humidité',\n    goodGreen: 'Bon (vert)',\n    fairOrange: 'Moyen (orange)',\n    aboveFairBad: 'Au-dessus = rouge (mauvais)',\n    fairAlsoDryingThreshold: 'Ce seuil est aussi utilisé pour déclencher le séchage automatique',\n    temperature: 'Température',\n    goodBlue: 'Bon (bleu)',\n    aboveFairHot: 'Au-dessus = rouge (chaud)',\n    historyRetention: 'Rétention d\\'historique',\n    keepSensorHistory: 'Garder l\\'historique pendant',\n    historyRetentionDescription: 'Les anciennes données seront supprimées.',\n    defaultPrintOptions: 'Options d\\'impression par défaut',\n    defaultPrintOptionsDescription: 'Définir les valeurs par défaut des options d\\'impression. Modifiables dans la boîte de dialogue d\\'impression.',\n    defaultBedLevelling: 'Nivellement du lit',\n    defaultBedLevellingDesc: 'Niveler automatiquement le lit avant l\\'impression',\n    defaultFlowCali: 'Calibration du flux',\n    defaultFlowCaliDesc: 'Calibrer le flux d\\'extrusion',\n    defaultVibrationCali: 'Calibration des vibrations',\n    defaultVibrationCaliDesc: 'Réduire les artefacts de ringing',\n    defaultLayerInspect: 'Inspection première couche',\n    defaultLayerInspectDesc: 'Inspection IA de la première couche',\n    defaultTimelapse: 'Timelapse',\n    defaultTimelapseDesc: 'Enregistrer une vidéo timelapse',\n    staggeredStart: 'Staggered Start',\n    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',\n    plateClear: 'Confirmation de plateau libre',\n    requirePlateClear: 'Exiger la confirmation de plateau libre',\n    requirePlateClearDescription: 'Lorsque cette option est activée, le planificateur attend une confirmation de plateau libre par imprimante avant de lancer les impressions en file d\\'attente sur les imprimantes ayant terminé. La désactiver masque également le badge d\\'état du plateau et le bouton « Marquer le plateau comme dégagé » sur les cartes d\\'imprimante.',\n    gcodeInjection: 'Injection de G-code',\n    gcodeInjectionDescription: 'Configurez du G-code personnalisé à injecter au début et/ou à la fin des impressions pour les systèmes d\\'auto-impression comme Farmloop, SwapMod, AutoClear et Printflow 3D. Les snippets sont configurés par modèle d\\'imprimante et appliqués lorsque « Injecter le G-code » est activé sur un élément de file d\\'attente.',\n    gcodeInjectionNoPrinters: 'Aucune imprimante trouvée. Ajoutez des imprimantes pour configurer les snippets G-code.',\n    gcodeStartLabel: 'G-code de début',\n    gcodeEndLabel: 'G-code de fin',\n    gcodeStartPlaceholder: 'G-code ajouté avant le début de l\\'impression...',\n    gcodeEndPlaceholder: 'G-code ajouté après la fin de l\\'impression...',\n    staggerGroupSize: 'Group size',\n    staggerGroupSizeHelp: 'Printers to start simultaneously per group',\n    staggerInterval: 'Interval (minutes)',\n    staggerIntervalHelp: 'Delay between each group starting',\n    queueDrying: 'Séchage automatique',\n    queueDryingDescription: 'Sécher automatiquement le filament AMS lorsque l\\'imprimante est inactive entre les impressions. Utilise le seuil d\\'humidité ci-dessus.',\n    queueDryingEnabled: 'Activer le séchage automatique',\n    queueDryingEnabledDescription: 'Démarrer le séchage AMS automatiquement lorsque l\\'imprimante est inactive et l\\'humidité dépasse le seuil',\n    queueDryingBlock: 'Attendre la fin du séchage',\n    queueDryingBlockDescription: 'Bloquer la file d\\'attente jusqu\\'à la fin du séchage. Désactivé, les impressions sont prioritaires.',\n    ambientDryingEnabled: 'Séchage ambiant',\n    ambientDryingEnabledDescription: 'Sécher automatiquement le filament sur les imprimantes inactives lorsque l\\'humidité dépasse le seuil, même sans impressions en file.',\n    dryingPresets: 'Préréglages de séchage',\n    dryingPresetsDescription: 'Température et durée par type de filament. AMS 2 Pro utilise des températures plus basses, AMS-HT supporte des températures plus élevées.',\n    dryingFilament: 'Filament',\n    printModal: 'Fenêtre d\\'impression',\n    expandCustomMapping: 'Étendre le mapping personnalisé par défaut',\n    expandCustomMappingDescription: 'Affiche le mapping AMS par imprimante étendu.',\n    // User management\n    authentication: 'Authentification',\n    authEnabledDescription: 'L\\'instance est sécurisée',\n    authDisabledDescription: 'Activez pour restreindre l\\'accès',\n    authDisabledMessage: 'Activez l\\'authentification pour gérer comptes et permissions.',\n    enableAuthentication: 'Activer l\\'authentification',\n    currentUser: 'Utilisateur actuel',\n    changePassword: 'Changer le mot de passe',\n    admin: 'Admin',\n    users: 'Utilisateurs',\n    addUser: 'Ajouter un utilisateur',\n    groups: 'Groupes',\n    addGroup: 'Ajouter un groupe',\n    system: 'Système',\n    noDescription: 'Pas de description',\n    userCount: '{{count}} utilisateurs',\n    permissionCount: '{{count}} permissions',\n    createUser: 'Créer un utilisateur',\n    username: 'Nom d\\'utilisateur',\n    enterUsername: 'Entrez l\\'utilisateur',\n    password: 'Mot de passe',\n    enterPassword: 'Mot de passe (min 6 char)',\n    confirmPassword: 'Confirmer le mot de passe',\n    confirmPasswordPlaceholder: 'Confirmez le mot de passe',\n    // Title tooltips\n    viewReleaseOnGitHub: 'Voir la version sur GitHub',\n    turnAllPlugsOn: 'Tout allumer',\n    turnAllPlugsOff: 'Tout éteindre',\n    // Modal: Clear logs\n    clearNotificationLogs: 'Effacer les journaux',\n    clearLogsMessage: 'Efface définitivement les logs de plus de 30 jours.',\n    clearLogs: 'Effacer les logs',\n    // Modal: Reset UI\n    resetUiPreferences: 'Réinitialiser l\\'UI',\n    resetUiPreferencesMessage: 'Réinitialise l\\'ordre, thème, widgets, etc. N\\'affecte pas vos données.',\n    resetPreferences: 'Réinitialiser',\n    // Modal: Delete group\n    deleteGroupTitle: 'Supprimer le groupe',\n    deleteGroupMessage: 'Les utilisateurs de ce groupe perdront ces permissions.',\n    deleteGroup: 'Supprimer le groupe',\n    // Modal: Disable auth\n    disableAuthenticationTitle: 'Désactiver l\\'authentification',\n    disableAuthenticationMessage: 'Instance accessible sans connexion. Les comptes sont conservés.',\n    disableAuthentication: 'Désactiver',\n    // Additional settings\n    configureBambuddy: 'Configurer Bambuddy',\n    systemDefault: 'Défaut système',\n    archiveSettings: 'Réglages Archives',\n    newWindow: 'Nouvelle fenêtre',\n    embeddedOverlay: 'Superposition intégrée',\n    preferredSlicer: 'Slicer préféré',\n    preferredSlicerDescription: 'Application pour ouvrir les fichiers',\n    externalCameras: 'Caméras externes',\n    costTracking: 'Suivi des coûts',\n    printsOnly: 'Impressions uniquement',\n    totalConsumption: 'Consommation totale',\n    dataManagement: 'Gestion des données',\n    storageUsage: 'Utilisation du stockage',\n    storageUsageDescription: 'Répartition de l\\'utilisation des données par catégorie',\n    storageUsageTotal: 'Total',\n    storageUsageErrors: 'Erreurs',\n    storageUsageOtherBreakdown: 'Autre (inclut ressources statiques, scripts et fichiers de configuration)',\n    storageUsageSystem: 'Système',\n    storageUsageData: 'Données',\n    storageUsageUnavailable: 'Informations d\\'utilisation du stockage non disponibles',\n    clearNotificationLogsDescription: 'Supprimer logs de plus de 30 jours',\n    resetUiPreferencesDescription: 'Réinitialise thèmes et affichage sans toucher aux données.',\n    enableHomeAssistant: 'Activer Home Assistant',\n    enableMqtt: 'Activer MQTT',\n    useTls: 'Utiliser TLS',\n    enableMetricsEndpoint: 'Activer l\\'endpoint Metrics',\n    availableMetrics: 'Metrics disponibles',\n    editUser: 'Modifier l\\'utilisateur',\n    deleteUserTitle: 'Supprimer l\\'utilisateur',\n    groupName: 'Nom du groupe',\n    // Placeholders\n    leaveEmptyForAnonymous: 'Vide pour anonyme',\n    leaveEmptyForNoAuth: 'Vide si pas d\\'auth',\n    enterNewPassword: 'Nouveau mot de passe',\n    confirmNewPassword: 'Confirmer nouveau mdp',\n    enterGroupName: 'Entrez le nom du groupe',\n    enterDescriptionOptional: 'Description (optionnel)',\n    enterCurrentPassword: 'Mdp actuel',\n    enterNewPasswordMin6: 'Nouveau mdp (min 6 char)',\n    toast: {\n      keyCopied: 'Clé copiée',\n      copyFailed: 'Échec copie',\n      keyAddedToBrowser: 'Clé ajoutée à l\\'explorateur',\n      clearLogsFailed: 'Échec effacement logs',\n      uiPreferencesReset: 'Préférences UI réinitialisées. Rafraîchissement...',\n      authDisabled: 'Authentification désactivée',\n      authDisableFailed: 'Échec désactivation',\n      apiKeyCreated: 'Clé API créée',\n      apiKeyDeleted: 'Clé API supprimée',\n      userCreated: 'Utilisateur créé',\n      userUpdated: 'Utilisateur mis à jour',\n      userDeleted: 'Utilisateur supprimé',\n      groupCreated: 'Groupe créé',\n      groupUpdated: 'Groupe mis à jour',\n      groupDeleted: 'Groupe supprimé',\n      fillRequiredFields: 'Remplissez les champs requis',\n      passwordsDoNotMatch: 'Les mots de passe ne correspondent pas',\n      passwordTooShort: 'Minimum 6 caractères',\n      enterGroupName: 'Entrez un nom de groupe',\n      settingsSaved: 'Paramètres enregistrés',\n      cameraSettingsSaved: 'Réglages caméra enregistrés',\n      enterCameraUrl: 'Entrez une URL caméra',\n      passwordChanged: 'Mot de passe modifié',\n      connectionFailed: 'Échec connexion',\n      testFailed: 'Échec test',\n      cameraConnected: 'Caméra connectée {{resolution}}',\n    },\n    testConnection: 'Tester la connexion',\n    catalog: {\n      spoolCatalog: 'Catalogue de Bobines',\n      spoolCatalogDescription: 'Poids des bobines vides par marque/type. Utilisé pour le calcul auto du poids restant.',\n      searchCatalog: 'Chercher dans le catalogue...',\n      addNewEntry: 'Nouvelle Entrée',\n      namePlaceholder: 'Nom (ex: Bambu Lab - Plastique)',\n      weight: 'Poids',\n      type: 'Type',\n      default: 'Défaut',\n      custom: 'Perso',\n      noMatch: 'Aucune entrée correspondante',\n      empty: 'Catalogue vide',\n      deleteEntry: 'Supprimer l\\'entrée',\n      deleteConfirm: 'Supprimer \"{{name}}\" ?',\n      resetCatalog: 'Réinitialiser le catalogue',\n      resetConfirm: 'Réinitialiser aux valeurs par défaut ? Vos entrées personnalisées seront supprimées.',\n      loadFailed: 'Échec chargement catalogue',\n      nameWeightRequired: 'Nom et poids requis',\n      entryAdded: 'Entrée ajoutée',\n      addFailed: 'Échec ajout',\n      entryUpdated: 'Entrée mise à jour',\n      updateFailed: 'Échec mise à jour',\n      entryDeleted: 'Entrée supprimée',\n      deleteFailed: 'Échec suppression',\n      resetSuccess: 'Catalogue réinitialisé',\n      resetFailed: 'Échec réinitialisation',\n      exported: '{{count}} entrées exportées',\n      imported: '{{added}} entrées importées ({{skipped}} ignorées)',\n      importFailed: 'Échec import : format JSON invalide',\n      exportTooltip: 'Exporter en JSON',\n      importTooltip: 'Importer depuis JSON',\n      resetTooltip: 'Réinitialiser par défaut',\n      selectedCount: '{{count}} sélectionnés',\n      deleteSelected: 'Supprimer la sélection',\n      bulkDeleteConfirm: 'Supprimer {{count}} entrées ?',\n      bulkDeleted: '{{count}} entrées supprimées',\n      bulkDeleteFailed: 'Échec de la suppression',\n    },\n    colorCatalog: {\n      title: 'Catalogue de Couleurs',\n      description: 'Couleurs de filament par fabricant. Utilisé pour la recherche auto lors de l\\'ajout de bobines.',\n      searchColors: 'Chercher couleurs...',\n      allManufacturers: 'Tous les fabricants',\n      addNewColor: 'Nouvelle Couleur',\n      manufacturer: 'Fabricant',\n      colorName: 'Nom couleur',\n      hex: 'Hex',\n      materialOptional: 'Matériau (optionnel)',\n      showing: 'Affichage {{filtered}} sur {{total}} couleurs',\n      noMatch: 'Aucune couleur correspondante',\n      empty: 'Catalogue vide',\n      deleteColor: 'Supprimer couleur',\n      deleteConfirm: 'Supprimer \"{{name}}\" ?',\n      resetCatalog: 'Réinitialiser catalogue couleurs',\n      resetConfirm: 'Réinitialiser aux valeurs par défaut ?',\n      sync: 'Sync',\n      starting: 'Démarrage...',\n      syncTooltip: 'Sync depuis FilamentColors.xyz (2000+ couleurs, peut être long)',\n      loadFailed: 'Échec chargement catalogue couleurs',\n      fieldsRequired: 'Fabricant, nom et code Hex requis',\n      colorAdded: 'Couleur ajoutée',\n      addFailed: 'Échec ajout',\n      colorUpdated: 'Couleur mise à jour',\n      updateFailed: 'Échec mise à jour',\n      colorDeleted: 'Couleur supprimée',\n      deleteFailed: 'Échec suppression',\n      resetSuccess: 'Catalogue réinitialisé',\n      resetFailed: 'Échec réinitialisation',\n      syncUpToDate: 'Déjà à jour ({{count}} couleurs vérifiées)',\n      syncComplete: '{{added}} couleurs ajoutées ({{skipped}} déjà présentes)',\n      syncError: 'Erreur de sync',\n      syncFailed: 'Échec sync FilamentColors.xyz',\n      exported: '{{count}} couleurs exportées',\n      imported: '{{added}} couleurs importées ({{skipped}} ignorées)',\n      importFailed: 'Échec import : format JSON invalide',\n      selectedCount: '{{count}} sélectionnés',\n      deleteSelected: 'Supprimer la sélection',\n      bulkDeleteConfirm: 'Supprimer {{count}} couleurs ?',\n      bulkDeleted: '{{count}} couleurs supprimées',\n      bulkDeleteFailed: 'Échec de la suppression des couleurs',\n    },\n    // General tab\n    dateFormat: 'Format de date',\n    dateFormatUs: 'US (MM/JJ/AAAA)',\n    dateFormatEu: 'EU (JJ/MM/AAAA)',\n    dateFormatIso: 'ISO (AAAA-MM-JJ)',\n    timeFormat: 'Format horaire',\n    timeFormat12: '12 heures (3:30 PM)',\n    timeFormat24: '24 heures (15:30)',\n    defaultPrinter: 'Imprimante par défaut',\n    defaultPrinterDescription: 'Présélectionner cette imprimante pour les téléversements, réimpressions et autres opérations.',\n    slicerBambuStudio: 'Bambu Studio',\n    slicerOrcaSlicer: 'OrcaSlicer',\n    sidebarOrderDescription: 'Glissez les éléments dans la barre latérale pour réorganiser. Réinitialiser l\\'ordre par défaut ici.',\n    setDefault: 'Définir par défaut',\n    sidebarOrderSetDefaultHint: 'Définir par défaut applique l\\'ordre actuel du menu aux utilisateurs qui n\\'ont pas encore personnalisé le leur.',\n    sidebarDefaultSet: 'L\\'ordre du menu par défaut a été défini.',\n    sidebarDefaultCleared: 'Ordre du menu par défaut effacé.',\n    sidebarDefaultFailed: 'Échec de la définition de l\\'ordre du menu par défaut.',\n    reset: 'Réinitialiser',\n    darkMode: 'Mode sombre',\n    lightMode: 'Mode clair',\n    active: '(actif)',\n    background: 'Arrière-plan',\n    accent: 'Accent',\n    style: 'Style',\n    bgNeutral: 'Neutre',\n    bgWarm: 'Chaud',\n    bgCool: 'Froid',\n    bgOled: 'OLED Noir',\n    bgSlate: 'Bleu ardoise',\n    bgForest: 'Vert forêt',\n    accentGreen: 'Vert',\n    accentTeal: 'Sarcelle',\n    accentBlue: 'Bleu',\n    accentOrange: 'Orange',\n    accentPurple: 'Violet',\n    accentRed: 'Rouge',\n    styleClassic: 'Classique',\n    styleGlow: 'Lumineux',\n    styleVibrant: 'Vibrant',\n    themeToggleHint: 'Basculer entre le mode sombre et clair avec l\\'icône soleil/lune dans la barre latérale.',\n    autoArchivePrints: 'Archiver automatiquement les impressions',\n    autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',\n    saveThumbnailsDescription: 'Extraire et sauvegarder les images d\\'aperçu des fichiers 3MF',\n    captureFinishPhotoDescription: 'Prendre une photo avec la caméra de l\\'imprimante à la fin de l\\'impression',\n    ffmpegNotInstalled: 'ffmpeg non installé',\n    ffmpegRequired: 'La capture caméra nécessite ffmpeg. Installez-le via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',\n    camera: 'Caméra',\n    cameraViewMode: 'Mode d\\'affichage caméra',\n    cameraOverlayDescription: 'La caméra s\\'ouvre dans un overlay redimensionnable sur l\\'écran principal',\n    cameraWindowDescription: 'La caméra s\\'ouvre dans une fenêtre de navigateur séparée',\n    externalCamerasDescription: 'Configurer des caméras externes pour remplacer la caméra intégrée. Supporte les flux MJPEG, RTSP, snapshots HTTP et caméras USB (V4L2). Lorsqu\\'activée, la caméra externe est utilisée pour la vue en direct et les photos de fin.',\n    cameraPlaceholderUsb: 'Chemin du périphérique (/dev/video0)',\n    cameraPlaceholderUrl: 'URL caméra (rtsp://... ou http://...)',\n    cameraTypeMjpeg: 'Flux MJPEG',\n    cameraTypeRtsp: 'Flux RTSP',\n    cameraTypeSnapshot: 'Snapshot HTTP',\n    cameraTypeUsb: 'Caméra USB (V4L2)',\n    cameraRotation: 'Rotation',\n    test: 'Tester',\n    connected: 'Connecté',\n    disconnected: 'Déconnecté',\n    currency: 'Devise',\n    defaultFilamentCost: 'Coût filament par défaut (par kg)',\n    electricityCost: 'Coût électricité par kWh',\n    energyDisplayMode: 'Mode d\\'affichage énergie',\n    energyModePrintDescription: 'Le tableau de bord affiche la somme de l\\'énergie utilisée pendant les impressions',\n    energyModeTotalDescription: 'Le tableau de bord affiche l\\'énergie totale des prises connectées',\n    fileManager: 'Gestionnaire de fichiers',\n    createArchiveEntry: 'Créer une entrée d\\'archive lors de l\\'impression',\n    createArchiveEntryDescription: 'Lors de l\\'impression depuis le gestionnaire de fichiers, créer optionnellement une entrée d\\'archive',\n    lowDiskSpaceWarning: 'Avertissement espace disque faible',\n    lowDiskSpaceDescription: 'Afficher un avertissement lorsque l\\'espace disque libre descend sous ce seuil',\n    printerFirmware: 'Firmware imprimante',\n    checkFirmwareDescription: 'Vérifier les mises à jour firmware de Bambu Lab',\n    bambuddySoftware: 'Logiciel Bambuddy',\n    autoCheckDescription: 'Vérifier automatiquement les nouvelles versions au démarrage',\n    checkNow: 'Vérifier maintenant',\n    updateAvailableVersion: 'Mise à jour disponible : v{{version}}',\n    releaseNotes: 'Notes de version',\n    updateViaDocker: 'Mettre à jour via Docker Compose :',\n    installUpdate: 'Installer la mise à jour',\n    latestVersionRunning: 'Vous utilisez la dernière version',\n    failedToCheckUpdates: 'Échec de la vérification des mises à jour : {{error}}',\n    backupRestore: 'Sauvegarde & Restauration',\n    backupRestoreDescription: 'Exporter/importer les paramètres et configurer la sauvegarde GitHub',\n    goToBackup: 'Aller à la sauvegarde',\n    externalUrl: 'URL externe',\n    externalUrlDescription: 'L\\'URL externe où Bambuddy est accessible. Utilisée pour les images de notification et les intégrations externes.',\n    bambuddyUrl: 'URL Bambuddy',\n    externalUrlHint: 'Inclure le protocole et le port (ex : http://192.168.1.100:8000)',\n    ftpRetry: 'Réessai FTP',\n    ftpRetryDescription: 'Réessayer les opérations FTP lorsque le WiFi de l\\'imprimante est instable. S\\'applique aux téléchargements 3MF, uploads d\\'impression, téléchargements timelapse et mises à jour firmware.',\n    autoRetryDescription: 'Réessayer automatiquement les opérations FTP échouées',\n    retryAttempts: 'Tentatives de réessai',\n    retryDelay: 'Délai de réessai',\n    connectionTimeout: 'Délai de connexion',\n    time_one: '{{count}} fois',\n    time_other: '{{count}} fois',\n    second_one: '{{count}} seconde',\n    second_other: '{{count}} secondes',\n    nSeconds: '{{count}} secondes',\n    increaseForWeakWifi: 'Augmenter pour les imprimantes avec un WiFi faible',\n    homeAssistant: 'Home Assistant',\n    homeAssistantFullDescription: 'Se connecter à Home Assistant pour contrôler les prises connectées via l\\'API REST HA. Supporte les entités switch, light, input_boolean et script.',\n    homeAssistantUrl: 'URL Home Assistant',\n    longLivedAccessToken: 'Token d\\'accès longue durée',\n    haTokenHint: 'Créer un token dans HA : Profil → Tokens d\\'accès longue durée → Créer un token',\n    connectionSuccessful: 'Connexion réussie',\n    connectionFailed: 'Connexion échouée',\n    haConnectionSuccess: 'Connexion à Home Assistant réussie.',\n    haConnectionFailed: 'Échec de la connexion à Home Assistant.',\n    mqttPublishing: 'Publication MQTT',\n    mqttDescription: 'Publier les événements BamBuddy vers un broker MQTT externe pour l\\'intégration avec Node-RED, Home Assistant et d\\'autres systèmes d\\'automatisation.',\n    mqttEnableDescription: 'Publier les événements vers un broker MQTT externe',\n    brokerHostname: 'Nom d\\'hôte du broker',\n    port: 'Port',\n    usernameOptional: 'Nom d\\'utilisateur (optionnel)',\n    passwordOptional: 'Mot de passe (optionnel)',\n    topicPrefix: 'Préfixe de topic',\n    topicPrefixHint: 'Les topics seront : {{prefix}}/printers/<serial>/status, etc.',\n    prometheusMetrics: 'Métriques Prometheus',\n    prometheusEndpointDescription: 'Exposer les métriques imprimante sur <code>/api/v1/metrics</code> pour la surveillance Prometheus/Grafana.',\n    bearerTokenOptional: 'Token Bearer (optionnel)',\n    bearerTokenHint: 'Si défini, les requêtes doivent inclure <code>Authorization: Bearer <token></code>',\n    metricsConnectionStatus: 'État de connexion',\n    metricsPrinterState: 'État imprimante (idle/printing/etc)',\n    metricsPrintProgress: 'Progression impression 0-100%',\n    metricsBedTemp: 'Température du plateau',\n    metricsNozzleTemp: 'Température de la buse',\n    metricsPrintsTotal: 'Total impressions par résultat',\n    metricsMore: '...et plus (couches, ventilateurs, file d\\'attente, utilisation filament)',\n    smartPlugsDescription: 'Connecter des prises connectées (Tasmota ou Home Assistant) pour automatiser le contrôle de l\\'alimentation et suivre la consommation d\\'énergie de vos imprimantes.',\n    allOn: 'Tout allumer',\n    allOff: 'Tout éteindre',\n    addSmartPlug: 'Ajouter une prise',\n    energySummary: 'Résumé énergétique',\n    currentPower: 'Puissance actuelle',\n    plugsOnline: '{{reachable}}/{{total}} prises en ligne',\n    today: 'Aujourd\\'hui',\n    yesterday: 'Hier',\n    total: 'Total',\n    enablePlugsForSummary: 'Activer les prises pour voir le résumé énergétique',\n    addNotificationProvider: 'Ajouter',\n    systemBadge: '(Système)',\n    creating: 'Création...',\n    changing: 'Modification...',\n    deleteUserAndItems: 'Supprimer l\\'utilisateur ET ses éléments',\n    deleteUserKeepItems: 'Supprimer l\\'utilisateur, garder les éléments (deviennent sans propriétaire)',\n    ok: 'OK',\n\n    // 2FA settings\n    twoFa: {\n      totpTitle: 'Application Authenticator (TOTP)',\n      totpDesc: 'Utilisez une application comme Google Authenticator, Aegis ou Authy.',\n      emailOtpTitle: 'OTP par e-mail',\n      emailOtpDesc: 'Envoyez un code à usage unique à {{email}} lors de la connexion.',\n      emailOtpNoEmail: 'Ajoutez une adresse e-mail à votre compte pour activer cette méthode.',\n      addEmailFirst: 'Votre compte n\\'a pas d\\'adresse e-mail. Demandez à un administrateur d\\'en ajouter une.',\n      setupTotp: 'Configurer l\\'application Authenticator',\n      setupAuthApp: 'Configurer l\\'application Authenticator',\n      setupInstructions: 'Scannez le QR code avec votre application authenticator, puis confirmez avec un code.',\n      manualEntry: 'Impossible de scanner ? Entrez ce secret manuellement :',\n      scannedContinue: 'Code scanné — continuer',\n      enterCodeToConfirm: 'Entrez le code à 6 chiffres de votre application authenticator pour confirmer.',\n      activate: 'Activer',\n      disableTotp: 'Désactiver l\\'Authenticator',\n      disableConfirmHint: 'Entrez un code TOTP valide ou un code de secours pour désactiver l\\'authenticator.',\n      totpDisabled: 'Application Authenticator désactivée.',\n      emailOtpEnabled: 'OTP par e-mail activé.',\n      emailOtpDisabled: 'OTP par e-mail désactivé.',\n      smtpRequired: 'Veuillez d\\'abord configurer et tester les paramètres SMTP.',\n      invalidCode: 'Code invalide. Veuillez réessayer.',\n      enableEmailOtp: 'Activer OTP par e-mail',\n      disableEmailOtp: 'Désactiver OTP par e-mail',\n      emailSetupEnterCode: 'Un code de vérification a été envoyé à votre adresse e-mail. Entrez-le ci-dessous pour confirmer que vous possédez cette boîte de réception.',\n      verifyAndEnable: 'Vérifier et activer',\n      emailDisablePasswordHint: 'Entrez le mot de passe de votre compte pour confirmer la désactivation de l\\'OTP par e-mail.',\n      passwordPlaceholder: 'Entrez votre mot de passe',\n      backupCodesTitle: 'Sauvegardez vos codes de secours',\n      backupCodesWarning: 'Conservez ces codes en lieu sûr. Chaque code ne peut être utilisé qu\\'une seule fois et ne sera plus affiché.',\n      backupCodesRemaining: '{{count}} codes de secours restants',\n      savedCodes: 'Codes sauvegardés',\n      regenBackup: 'Régénérer les codes de secours',\n      regenBackupHint: 'Entrez votre code TOTP actuel pour générer 10 nouveaux codes de secours. Tous les codes existants seront invalidés.',\n      newBackupCodes: 'Nouveaux codes de secours',\n      linkedAccounts: 'Comptes SSO liés',\n      linkedAccountsDesc: 'Ces fournisseurs d\\'identité externes sont liés à votre compte.',\n      oidcUnlinked: 'Compte dissocié.',\n    },\n\n    // OIDC provider settings\n    oidc: {\n      title: 'Fournisseurs SSO / OIDC',\n      desc: 'Configurez des fournisseurs OpenID Connect pour l\\'authentification unique.',\n      addProvider: 'Ajouter un fournisseur',\n      newProvider: 'Nouveau fournisseur',\n      empty: 'Aucun fournisseur OIDC configuré.',\n      created: 'Fournisseur créé.',\n      updated: 'Fournisseur mis à jour.',\n      deleted: 'Fournisseur supprimé.',\n      deleteTitle: 'Supprimer le fournisseur',\n      deleteMessage: 'Supprimer \"{{name}}\" ? Tous les comptes liés seront déconnectés.',\n      form: {\n        name: 'Nom d\\'affichage',\n        issuerUrl: 'URL de l\\'émetteur',\n        clientId: 'ID client',\n        clientSecret: 'Secret client',\n        scopes: 'Scopes',\n        iconUrl: 'URL de l\\'icône (optionnel)',\n        enabled: 'Activé',\n        autoCreate: 'Créer les utilisateurs automatiquement',\n        autoCreateDesc: 'Crée automatiquement un compte local lors de la première connexion.',\n        autoLink: 'Lier automatiquement les comptes existants',\n        autoLinkDesc: 'Lie les comptes locaux existants par e-mail lors de la première connexion.',\n        secretHint: 'laisser vide pour conserver',\n        secretPlaceholder: 'nouveau secret',\n      },\n    },\n\n  },\n\n  // Notifications (for push notifications)\n  notification: {\n    printStarted: {\n      title: 'Impression démarrée',\n      body: '{{printer}} : {{filename}} commence l\\'impression',\n    },\n    printCompleted: {\n      title: 'Impression terminée',\n      body: '{{printer}} : {{filename}} a réussi',\n    },\n    printFailed: {\n      title: 'Impression échouée',\n      body: '{{printer}} : {{filename}} a échoué',\n    },\n    printStopped: {\n      title: 'Impression arrêtée',\n      body: '{{printer}} : {{filename}} a été arrêtée',\n    },\n    printProgress: {\n      title: 'Progression d\\'impression',\n      body: '{{printer}} : {{filename}} est à {{percent}}%',\n    },\n    printerOffline: {\n      title: 'Imprimante hors ligne',\n      body: '{{printer}} est déconnectée',\n    },\n    printerError: {\n      title: 'Erreur imprimante',\n      body: '{{printer}} : {{error}}',\n    },\n    filamentLow: {\n      title: 'Filament bas',\n      body: '{{printer}} : Le filament est presque vide',\n    },\n    maintenanceDue: {\n      title: 'Maintenance due',\n      body: '{{printer}} : {{items}} demandent votre attention',\n    },\n  },\n\n  // Errors\n  errors: {\n    generic: 'Une erreur est survenue',\n    networkError: 'Erreur réseau. Vérifiez votre connexion.',\n    notFound: 'Non trouvé',\n    unauthorized: 'Non autorisé',\n    serverError: 'Erreur serveur',\n    validationError: 'Vérifiez vos saisies',\n    printerConnectionFailed: 'Échec connexion imprimante',\n    saveFailed: 'Échec enregistrement',\n    deleteFailed: 'Échec suppression',\n    loadFailed: 'Échec chargement',\n  },\n\n  // HMS Errors modal\n  hmsErrors: {\n    title: 'Erreurs - {{name}}',\n    noErrors: 'Aucune erreur',\n    viewOnWiki: 'Voir sur le Wiki Bambu Lab',\n    clearInstructions: 'Effacez les erreurs sur l\\'imprimante pour les retirer ici.',\n    clearErrors: 'Effacer les erreurs',\n    clearSuccess: 'Erreurs HMS effacées',\n    clearFailed: 'Échec de l\\'effacement des erreurs HMS',\n  },\n\n  // MQTT Debug modal\n  mqttDebug: {\n    title: 'Journal de débogage MQTT',\n    searchPlaceholder: 'Chercher topic ou message...',\n    noMessages: 'Aucun message enregistré',\n    startLoggingHint: 'Cliquez sur \"Démarrer\" pour capturer les messages MQTT',\n    noMessagesMatch: 'Aucun message ne correspond',\n    adjustFilterHint: 'Ajustez votre recherche',\n    incoming: 'Entrant',\n    outgoing: 'Sortant',\n    loggingStopped: 'Enregistrement arrêté',\n    loggingActive: 'Enregistrement actif - rafraîchissement auto',\n    startLogging: 'Démarrer',\n    stopLogging: 'Arrêter',\n    clearLog: 'Effacer le journal',\n    topic: 'Topic',\n    timestamp: 'Horodatage',\n    direction: 'Direction',\n    all: 'Tous',\n  },\n\n  // Printer File Manager modal (printer internal storage)\n  printerFiles: {\n    title: 'Gestionnaire de fichiers',\n    storageUsed: 'Utilisé :',\n    storageFree: 'Libre :',\n    filterPlaceholder: 'Filtrer les fichiers...',\n    deleteButton: 'Supprimer',\n    deleteFiles: 'Supprimer {{count}} fichiers',\n    deleteFileConfirm: 'Supprimer \"{{name}}\" ?',\n    deleteFilesConfirm: 'Supprimer les {{count}} fichiers sélectionnés ?',\n    noFiles: 'Aucun fichier sur l\\'imprimante',\n    loadingFiles: 'Chargement...',\n    failedToLoad: 'Échec chargement fichiers',\n    toast: {\n      filesDeleted: '{{count}} fichier(s) supprimé(s)',\n      deleteFailed: 'Échec suppression : {{error}}',\n    },\n  },\n\n  // Confirmations\n  confirm: {\n    delete: 'Voulez-vous vraiment supprimer cet élément ?',\n    unsavedChanges: 'Changements non sauvegardés. Voulez-vous quitter ?',\n    clearQueue: 'Voulez-vous vraiment vider la file d\\'attente ?',\n  },\n\n  // Login page\n  login: {\n    title: 'Connexion Bambuddy',\n    subtitle: 'Connectez-vous à votre compte',\n    username: 'Utilisateur',\n    usernamePlaceholder: 'Entrez votre utilisateur',\n    usernameOrEmail: 'Utilisateur ou Email',\n    usernameOrEmailPlaceholder: 'Utilisateur ou @ Email',\n    password: 'Mot de passe',\n    passwordPlaceholder: 'Entrez votre mot de passe',\n    signIn: 'Se connecter',\n    signingIn: 'Connexion...',\n    forgotPassword: 'Mot de passe oublié ?',\n    loginSuccess: 'Connecté avec succès',\n    loginFailed: 'Échec de connexion',\n    enterCredentials: 'Entrez vos identifiants',\n    enterEmail: 'Veuillez entrer votre adresse e-mail',\n    oidcLoginFailed: 'Échec de la connexion OIDC',\n    oidcErrors: {\n      providerError: \"Le fournisseur d'identité a renvoyé une erreur\",\n      missingParameters: 'Il manque des paramètres requis dans le callback OIDC',\n      invalidState: \"L'état OIDC est invalide ou a déjà été utilisé\",\n      stateExpired: 'La session OIDC a expiré — veuillez réessayer',\n      providerNotFound: 'Fournisseur OIDC introuvable',\n      discoveryFailed: 'Impossible de récupérer le document de découverte OIDC',\n      invalidDiscovery: 'Le document de découverte OIDC est invalide',\n      networkError: \"Erreur réseau lors de l'échange de jeton OIDC\",\n      badResponse: \"Réponse inattendue lors de l'échange de jeton OIDC\",\n      noIdToken: \"Le fournisseur OIDC n'a pas renvoyé de jeton d'identité\",\n      validationFailed: 'La validation du jeton OIDC a échoué',\n      nonceMismatch: 'Le nonce OIDC ne correspond pas — possible attaque par rejeu',\n      missingSubClaim: 'Le jeton OIDC est dépourvu de la revendication sub',\n      noLinkedAccount: 'Aucun compte local est lié à cette identité OIDC',\n      accountInactive: 'Votre compte est inactif',\n      userResolutionFailed: 'Impossible de résoudre votre compte',\n      internalError: 'Une erreur interne est survenue lors de la connexion OIDC',\n      tokenExchangeFailed: \"L'échange de jeton OIDC a échoué\",\n    },\n    forgotPasswordTitle: 'Mot de passe oublié',\n    forgotPasswordMessage: 'Contactez votre administrateur pour réinitialiser votre accès.',\n    forgotPasswordEmailMessage: 'Entrez votre email pour recevoir un nouveau mot de passe.',\n    emailAddress: 'Adresse Email',\n    emailPlaceholder: 'votre.email@exemple.com',\n    cancel: 'Annuler',\n    sending: 'Envoi...',\n    sendResetEmail: 'Envoyer l\\'email',\n    howToReset: 'Comment réinitialiser :',\n    resetStep1: 'Contactez votre admin Bambuddy',\n    resetStep2: 'Demandez une réinitialisation dans la Gestion Utilisateurs',\n    resetStep3: 'Il vous donnera un mot de passe temporaire',\n    resetStep4: 'Connectez-vous et changez-le dans les Paramètres',\n    gotIt: 'Compris',\n    twoFA: {\n      title: 'Authentification à deux facteurs',\n      subtitle: 'Votre compte est protégé par la 2FA. Saisissez le code de vérification ci-dessous.',\n      methodAuthenticator: \"Application d'authentification\",\n      methodEmail: 'Code par e-mail',\n      methodBackup: 'Code de récupération',\n      instructionsTotp: \"Ouvrez votre application d'authentification et saisissez le code à 6 chiffres pour Bambuddy.\",\n      instructionsEmail: 'Un code à 6 chiffres a été envoyé à votre adresse e-mail. Il est valable 10 minutes.',\n      instructionsEmailNotSent: 'Cliquez ci-dessous pour recevoir un code de vérification par e-mail.',\n      instructionsBackup: \"Saisissez l'un de vos codes de récupération à 8 caractères. Chaque code ne peut être utilisé qu'une seule fois.\",\n      sendCodeButton: 'Envoyer le code par e-mail',\n      sendingCode: 'Envoi en cours...',\n      resendCode: 'Renvoyer le code',\n      codeLabel: 'Code de vérification',\n      backupCodeLabel: 'Code de récupération',\n      codePlaceholder: '000000',\n      backupCodePlaceholder: 'XXXXXXXX',\n      verifyButton: 'Vérifier',\n      verifyingButton: 'Vérification en cours...',\n      backToLogin: '← Retour à la connexion',\n      orContinueWith: 'ou continuer avec',\n      signInWith: 'Se connecter avec {{provider}}',\n      enterCode: 'Veuillez entrer le code de vérification',\n      sendCodeFailed: 'Échec de l\\'envoi du code de vérification',\n      invalidCode: 'Code invalide. Veuillez réessayer.',\n    },\n\n  },\n\n  // Setup page\n  setup: {\n    title: 'Configuration Bambuddy',\n    subtitle: 'Configurez l\\'authentification',\n    enableAuth: 'Activer l\\'authentification',\n    adminAccount: 'Compte Admin',\n    adminAccountDesc: 'Si des admins existent, ils seront utilisés. Sinon, créez-en un ci-dessous.',\n    adminUsername: 'Utilisateur Admin',\n    adminPassword: 'Mot de passe Admin',\n    optionalIfAdminExists: '(optionnel si admin existe)',\n    adminUsernamePlaceholder: 'Nom admin (optionnel)',\n    adminPasswordPlaceholder: 'Mdp admin (optionnel)',\n    confirmPassword: 'Confirmer mdp',\n    confirmPasswordPlaceholder: 'Confirmez mdp admin',\n    settingUp: 'Configuration...',\n    completeSetup: 'Terminer la configuration',\n    toast: {\n      authEnabledAdminCreated: 'Authentification activée et admin créé',\n      authEnabledExistingAdmins: 'Authentification activée avec admins existants',\n      setupCompleted: 'Configuration terminée',\n      enterBothCredentials: 'Entrez les deux ou laissez vide pour utiliser les admins existants',\n      passwordsDoNotMatch: 'Les mots de passe ne correspondent pas',\n      passwordTooShort: 'Minimum 6 caractères',\n    },\n  },\n\n  // Password change\n  changePassword: {\n    title: 'Changer le mot de passe',\n    currentPassword: 'Mot de passe actuel',\n    currentPasswordPlaceholder: 'Entrez mdp actuel',\n    newPassword: 'Nouveau mot de passe',\n    newPasswordPlaceholder: 'Nouveau mdp (min 6 char)',\n    confirmPassword: 'Confirmer nouveau mdp',\n    confirmPasswordPlaceholder: 'Confirmez nouveau mdp',\n    passwordsDoNotMatch: 'Les mots de passe ne correspondent pas',\n    passwordTooShort: 'Minimum 6 caractères',\n    changing: 'Changement...',\n    success: 'Mot de passe modifié',\n    failed: 'Échec modification',\n  },\n\n  // Plate detection alert\n  plateAlert: {\n    title: 'Impression en Pause !',\n    message: 'Objets détectés sur le plateau. L\\'impression a été suspendue automatiquement. Videz le plateau avant de reprendre.',\n    understand: 'J\\'ai compris',\n  },\n\n  // Camera page\n  camera: {\n    title: 'Vue Caméra',\n    invalidPrinterId: 'ID imprimante invalide',\n    live: 'Direct',\n    snapshot: 'Instantané',\n    restartStream: 'Redémarrer le flux',\n    refreshSnapshot: 'Rafraîchir l\\'image',\n    fullscreen: 'Plein écran',\n    exitFullscreen: 'Quitter plein écran',\n    connectingToCamera: 'Connexion caméra...',\n    capturingSnapshot: 'Capture...',\n    connectionLost: 'Connexion perdue',\n    connectionFailed: 'Échec connexion caméra',\n    reconnecting: 'Reconnexion dans {{countdown}}s... (essai {{attempt}}/{{max}})',\n    reconnectNow: 'Reconnexion immédiate',\n    cameraUnavailable: 'Caméra indisponible',\n    cameraUnavailableDesc: 'Vérifiez que l\\'imprimante est allumée.',\n    noCamera: 'Aucune caméra disponible',\n    retry: 'Réessayer',\n    cameraStream: 'Flux caméra',\n    zoomOut: 'Zoom arrière',\n    zoomIn: 'Zoom avant',\n    resetZoom: 'Réinitialiser zoom',\n    recording: 'Enregistrement',\n    startRecording: 'Démarrer l\\'enregistrement',\n    stopRecording: 'Arrêter l\\'enregistrement',\n    chamberLight: 'Basculer lumière chambre',\n  },\n\n  // Groups management\n  groups: {\n    title: 'Gestion des Groupes',\n    subtitle: 'Gérez les permissions pour le contrôle d\\'accès',\n    backToSettings: 'Retour aux paramètres',\n    createGroup: 'Créer un groupe',\n    noPermission: 'Accès refusé.',\n    system: 'Système',\n    noDescription: 'Pas de description',\n    usersCount: '{{count}} utilisateurs',\n    permissionsCount: '{{count}} permissions',\n    edit: 'Modifier',\n    delete: 'Supprimer',\n    toast: {\n      created: 'Groupe créé',\n      updated: 'Groupe mis à jour',\n      deleted: 'Groupe supprimé',\n      enterGroupName: 'Entrez un nom de groupe',\n    },\n    modal: {\n      editGroup: 'Modifier le groupe',\n      createGroup: 'Créer un groupe',\n      cancel: 'Annuler',\n      saving: 'Enregistrement...',\n      creating: 'Création...',\n      saveChanges: 'Enregistrer',\n    },\n    form: {\n      groupName: 'Nom du groupe',\n      groupNamePlaceholder: 'Nom du groupe',\n      systemGroupWarning: 'Les groupes système sont fixes',\n      description: 'Description',\n      descriptionPlaceholder: 'Description (optionnel)',\n      permissions: 'Permissions ({{count}} sélectionnées)',\n    },\n    deleteModal: {\n      title: 'Supprimer le groupe',\n      message: 'Les utilisateurs de ce groupe perdront ces permissions.',\n      confirm: 'Supprimer',\n    },\n    editor: {\n      title: 'Modifier le groupe',\n      createTitle: 'Créer un groupe',\n      search: 'Rechercher des permissions...',\n      selectAll: 'Tout sélectionner',\n      clearAll: 'Tout désélectionner',\n      permissionsSelected: '{{count}} sélectionnée(s)',\n      noResults: 'Aucune permission ne correspond à votre recherche',\n    },\n  },\n\n  // Users management\n  users: {\n    title: 'Gestion des Utilisateurs',\n    subtitle: 'Gérez les accès à Bambuddy',\n    backToSettings: 'Retour aux paramètres',\n    createUser: 'Créer un utilisateur',\n    noPermission: 'Accès refusé.',\n    admin: 'Admin',\n    noGroups: 'Aucun groupe',\n    active: 'Actif',\n    inactive: 'Inactif',\n    edit: 'Modifier',\n    delete: 'Supprimer',\n    system: 'Système',\n    noGroupsAvailable: 'Aucun groupe disponible',\n    table: {\n      username: 'Utilisateur',\n      groups: 'Groupes',\n      status: 'Statut',\n      actions: 'Actions',\n    },\n    toast: {\n      created: 'Utilisateur créé',\n      updated: 'Utilisateur mis à jour',\n      deleted: 'Utilisateur supprimé',\n      fillRequired: 'Remplissez les champs requis',\n      passwordsDoNotMatch: 'Les mots de passe ne correspondent pas',\n      passwordTooShort: 'Minimum 6 caractères',\n    },\n    modal: {\n      createUser: 'Créer utilisateur',\n      editUser: 'Modifier utilisateur',\n      cancel: 'Annuler',\n      creating: 'Création...',\n      saving: 'Enregistrement...',\n      saveChanges: 'Enregistrer',\n      advancedAuthSubtitle: 'avec Authentification Avancée',\n    },\n    form: {\n      username: 'Utilisateur',\n      usernamePlaceholder: 'Nom utilisateur',\n      email: 'Email',\n      emailPlaceholder: 'utilisateur@exemple.com',\n      password: 'Mot de passe',\n      passwordPlaceholder: 'Mot de passe',\n      confirmPassword: 'Confirmer mdp',\n      confirmPasswordPlaceholder: 'Confirmez mdp',\n      newPasswordPlaceholder: 'Nouveau mdp',\n      confirmNewPasswordPlaceholder: 'Confirmez nouveau mdp',\n      leaveBlankToKeep: 'Laissez vide pour conserver l\\'actuel',\n      groups: 'Groupes',\n      optional: 'optionnel',\n      autoGeneratedPassword: 'Un mot de passe sera généré et envoyé par email.',\n      passwordManagedByAdvancedAuth: 'Géré par Auth Avancée. Utilisez \"Réinitialiser\" pour envoyer un nouveau mdp par email.',\n      resetPassword: 'Réinitialiser le mot de passe',\n      resettingPassword: 'Réinitialisation...',\n    },\n    deleteModal: {\n      title: 'Supprimer utilisateur',\n      message: 'Cette action est irréversible.',\n      confirm: 'Supprimer',\n    },\n  },\n\n  // Stream overlay\n  streamOverlay: {\n    title: 'Superposition Flux',\n    invalidPrinterId: 'ID invalide',\n    cameraStream: 'Flux caméra',\n    progress: 'Progression',\n    eta: 'Fin estimée',\n    printerIdle: 'Imprimante inactive',\n    printerOffline: 'Imprimante hors ligne',\n    status: {\n      printing: 'Impression',\n      paused: 'En pause',\n      finished: 'Terminée',\n      failed: 'Échouée',\n      idle: 'Inactive',\n      unknown: 'Inconnue',\n    },\n  },\n\n  // Profiles\n  profiles: {\n    title: 'Profils',\n    subtitle: 'Gérez vos presets slicer et calibrations Pressure Advance',\n    tabs: {\n      cloud: 'Profils Cloud',\n      local: 'Profils Locaux',\n      kprofiles: 'K-Profiles',\n    },\n    localProfiles: {\n      title: 'Profils Locaux',\n      subtitle: 'Gérez vos presets OrcaSlicer',\n      import: 'Importer Profils',\n      importDesc: 'Déposez les fichiers .bbscfg, .bbsflmt, .orca_filament, .zip ou .json',\n      importing: 'Importation...',\n      search: 'Chercher un preset...',\n      noPresets: 'Aucun preset local',\n      badge: 'Local',\n      edit: 'Modifier',\n      delete: 'Supprimer',\n      cancel: 'Annuler',\n      deleteConfirmTitle: 'Supprimer Preset',\n      deleteConfirm: 'Supprimer définitivement ce preset ?',\n      source: 'Source',\n      inheritsFrom: 'Hérite de',\n      filamentType: 'Type',\n      vendor: 'Vendeur',\n      compatiblePrinters: 'Imprimantes',\n      nozzleTemp: 'Temp Buse',\n      cost: 'Coût',\n      density: 'Densité',\n      pressureAdvance: 'Pressure Advance',\n      filament: 'Filament',\n      process: 'Processus',\n      printer: 'Imprimante',\n      toast: {\n        importSuccess: '{{count}} profil(s) importé(s)',\n        importSkipped: '{{count}} profil(s) ignoré(s) (doublons)',\n        importError: '{{count}} erreur(s) d\\'import',\n        deleted: 'Preset supprimé',\n        updated: 'Preset mis à jour',\n      },\n    },\n    connectedAs: 'Connecté en tant que',\n    logout: 'Déconnexion',\n    noLogoutPermission: 'Pas d\\'autorisation de déconnexion',\n    failedToLoad: 'Échec chargement profils',\n    retry: 'Réessayer',\n    time: {\n      justNow: 'À l\\'instant',\n      minsAgo: 'Il y a {{count}}m',\n      hoursAgo: 'Il y a {{count}}h',\n      daysAgo: 'Il y a {{count}}j',\n    },\n    toast: {\n      loggedOut: 'Déconnecté',\n    },\n    login: {\n      title: 'Connexion Bambu Cloud',\n      subtitle: 'Synchronisez vos presets slicer',\n      email: 'Email',\n      password: 'Mot de passe',\n      region: 'Région',\n      regionGlobal: 'Global',\n      regionChina: 'Chine',\n      verificationCode: 'Code de vérification',\n      totpCode: 'Code Authenticator',\n      checkEmail: 'Code envoyé à {{email}}',\n      enterTotpHint: 'Entrez le code 2FA',\n      accessToken: 'Jeton d\\'accès (Access Token)',\n      accessTokenHint: 'Collez le jeton (depuis Bambu Studio)',\n      back: 'Retour',\n      loginButton: 'Connexion',\n      verifyButton: 'Vérifier',\n      setTokenButton: 'Définir Jeton',\n      useToken: 'Utiliser jeton d\\'accès',\n      useEmail: 'Connexion par email',\n      toast: {\n        loggedIn: 'Connecté avec succès',\n        codeSent: 'Code envoyé par email',\n        enterTotp: 'Entrez le code Authenticator',\n        tokenSet: 'Jeton défini',\n      },\n    },\n    presets: {\n      myPreset: 'Mon preset (modifiable)',\n      duplicate: 'Dupliquer',\n      editable: 'Modifiable',\n      failedToLoadDetails: 'Échec détails preset',\n      deleteConfirm: 'Supprimer ce preset ?',\n      deleteWarning: 'Ceci supprimera \"{{name}}\" de Bambu Cloud définitivement.',\n      noDuplicatePermission: 'Pas d\\'autorisation duplication',\n      noEditPermission: 'Pas d\\'autorisation modification',\n      noDeletePermission: 'Pas d\\'autorisation suppression',\n      types: {\n        filament: 'Preset filament',\n        printer: 'Preset imprimante',\n        process: 'Preset processus',\n      },\n      toast: {\n        deleted: 'Preset supprimé',\n        created: 'Preset créé',\n        updated: 'Preset mis à jour',\n        duplicated: 'Preset dupliqué',\n        fieldAdded: 'Champ \"{{key}}\" ajouté',\n        exported: 'Preset exporté',\n      },\n      baseLabel: 'Base : {{name}}',\n      currentLabel: 'Actuel : {{name}}',\n      newPreset: 'Nouveau Preset',\n      editPreset: 'Modifier Preset',\n      duplicatePreset: 'Dupliquer Preset',\n      createNewPreset: 'Créer un nouveau Preset',\n      customizeSettings: 'Personnalisez vos réglages',\n      compareWithBase: 'Comparer avec la base',\n      compare: 'Comparer',\n      // CreatePresetModal - Basic Info\n      basePreset: 'Preset de base',\n      selectBasePreset: 'Choisir preset de base...',\n      presetName: 'Nom du preset',\n      myCustomPreset: 'Mon preset personnalisé',\n      inheritsFrom: 'Hérite de',\n      dropJsonToImport: 'Glissez JSON pour importer',\n      // CreatePresetModal - Tabs\n      tabs: {\n        common: 'Commun',\n        allFields: 'Tous les champs',\n      },\n      // CreatePresetModal - All Fields Tab\n      availableFields: 'Champs disponibles',\n      searchFieldsPlaceholder: 'Chercher un champ...',\n      noMatchingFields: 'Aucun champ trouvé',\n      allFieldsAdded: 'Tous les champs sont ajoutés',\n      addCustomField: 'Ajouter un champ personnalisé',\n      yourOverrides: 'Vos modifications',\n      noOverridesYet: 'Aucune modification',\n      clickFieldsToAdd: 'Cliquez à gauche pour ajouter',\n      saveAsTemplate: 'Enregistrer comme modèle',\n      jsonTip: 'Conseil : Glissez un .json pour importer les réglages',\n    },\n    cloudView: {\n      searchPlaceholder: 'Chercher presets...',\n      templates: 'Modèles',\n      refresh: 'Rafraîchir',\n      newPreset: 'Nouveau Preset',\n      clearFilters: 'Effacer filtres',\n      // Compare mode\n      compareMode: 'Mode Comparaison',\n      selectAnotherPreset: 'Choisir un autre preset {{type}}',\n      clickTwoPresets: 'Choisissez deux presets de même type',\n      selectFirst: '1. Sélectionner premier',\n      selectSecond: '2. Sélectionner second',\n      compareNow: 'Comparer maintenant',\n      // Status row\n      lastSynced: 'Synchronisé :',\n      showingCount: '{{showing}} sur {{total}} presets',\n      noPresetsFound: 'Aucun preset trouvé',\n      // Column headers\n      columns: {\n        filament: 'Filament',\n        process: 'Processus',\n        printer: 'Imprimante',\n      },\n      noFilamentPresets: 'Pas de preset filament',\n      noProcessPresets: 'Pas de preset processus',\n      noPrinterPresets: 'Pas de preset imprimante',\n      // Filters\n      filters: {\n        type: 'Type',\n        owner: 'Propriétaire',\n        printer: 'Imprimante',\n        nozzle: 'Buse',\n        filament: 'Filament',\n        layer: 'Couche',\n        all: 'Tous',\n        myPresets: 'Mes Presets',\n        builtIn: 'Inclus',\n        process: 'Processus',\n      },\n      // Permissions\n      noTemplatesPermission: 'Pas d\\'autorisation modèles',\n      noRefreshPermission: 'Pas d\\'autorisation rafraîchissement',\n      noCreatePermission: 'Pas d\\'autorisation création',\n    },\n    templates: {\n      title: 'Modèles rapides',\n      noTemplates: 'Aucun modèle',\n      createFirst: 'Créez-en depuis l\\'éditeur de preset',\n      typeFilter: 'Type :',\n      deleteTitle: 'Supprimer modèle',\n      deleteWarning: 'Action irréversible',\n      deleteConfirm: 'Supprimer \"{{name}}\" ?',\n      namePlaceholder: 'Nom du modèle',\n      descriptionPlaceholder: 'Description',\n      settingsJson: 'Paramètres (JSON)',\n      fieldsCount: '{{count}} champs',\n      shownInModals: 'Visible dans fenêtres',\n      hiddenInModals: 'Masqué dans fenêtres',\n      apply: 'Appliquer',\n      toast: {\n        deleted: 'Modèle supprimé',\n        updated: 'Modèle mis à jour',\n        created: 'Modèle créé',\n        applied: 'Modèle appliqué',\n      },\n    },\n  },\n\n  // Support/Debug\n  support: {\n    debugLoggingActive: 'Débogage actif',\n    manageLogs: 'Gérer',\n    collectItem7: 'Connectivité et versions firmware',\n    collectItem8: 'Statut intégrations (Spoolman, MQTT, HA)',\n    collectItem9: 'Interfaces réseau (sous-réseaux)',\n    collectItem10: 'Versions packages Python',\n    collectItem11: 'Santé base de données',\n    collectItem12: 'Détails environnement Docker',\n  },\n\n  // File manager\n  fileManager: {\n    title: 'Gestionnaire de fichiers',\n    subtitle: 'Organisez vos fichiers d\\'impression',\n    uploadFiles: 'Téléverser fichiers',\n    newFolder: 'Nouveau dossier',\n    folderName: 'Nom du dossier',\n    folderNamePlaceholder: 'ex: Pièces Utiles',\n    renameFile: 'Renommer fichier',\n    renameFolder: 'Renommer dossier',\n    moveFiles: 'Déplacer {{count}} fichier(s)',\n    rootNoFolder: 'Racine (aucun dossier)',\n    current: 'actuel',\n    linkFolder: 'Lier le dossier',\n    linkFolderDescription: 'Lier \"{{name}}\" à un projet ou archive.',\n    project: 'Projet',\n    archive: 'Archive',\n    noProjectsFound: 'Aucun projet trouvé',\n    noArchivesFound: 'Aucune archive trouvée',\n    unlink: 'Délier',\n    link: 'Lier',\n    dragDropFiles: 'Glissez les fichiers ici',\n    dropFilesHere: 'Déposez ici',\n    orClickToBrowse: 'ou cliquez pour parcourir',\n    allFileTypesSupported: 'Tous types supportés. ZIP extraits.',\n    zipFilesDetected: 'ZIP détectés',\n    zipExtractOptions: 'Choix de structure pour ZIP :',\n    preserveZipStructure: 'Garder structure ZIP',\n    createFolderFromZip: 'Dossier par nom du ZIP',\n    stlThumbnailGeneration: 'Vignettes STL',\n    zipMayContainStl: 'Extraction vignettes possible pour STL.',\n    thumbnailsCanBeGenerated: 'Génération vignettes (peut être long).',\n    generateThumbnailsForStl: 'Générer vignettes STL',\n    threemfDetected: 'Fichiers 3MF détectés',\n    threemfExtractionInfo: 'Réglages extraits auto du 3MF.',\n    willBeExtracted: 'Sera extrait',\n    filesExtracted: '{{count}} fichiers extraits',\n    uploadComplete: 'Terminé : {{succeeded}} succès',\n    uploadFailed: 'Échec du téléversement',\n    zipFilesFailed: '{{count}} fichiers échoués',\n    uploading: 'Téléversement...',\n    changeLink: 'Modifier lien...',\n    linkTo: 'Lier à...',\n    linkToProjectOrArchive: 'Lier à projet ou archive',\n    addToQueue: 'Ajouter à la file',\n    schedulePrint: 'Planifier',\n    generateThumbnail: 'Générer vignette',\n    generateThumbnails: 'Générer vignettes',\n    generateThumbnailsForMissing: 'Vignettes STL manquantes',\n    gridView: 'Grille',\n    listView: 'Liste',\n    lowDiskSpaceWarning: 'Espace disque faible',\n    lowDiskSpaceDetails: '{{free}} libre sur {{total}}. Seuil : {{threshold}} Go.',\n    files: 'Fichiers',\n    folders: 'Dossiers',\n    size: 'Taille',\n    free: 'Libre',\n    allFiles: 'Tous les fichiers',\n    wrap: 'Retour ligne',\n    enableTextWrapping: 'Activer retour ligne',\n    disableTextWrapping: 'Désactiver retour ligne',\n    collapse: 'Réduire',\n    collapseFoldersByDefault: 'Réduire les dossiers par défaut',\n    expandFoldersByDefault: 'Développer les dossiers par défaut',\n    dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset',\n    searchFiles: 'Chercher fichiers...',\n    allTypes: 'Tous types',\n    prints: 'Impressions',\n    ascending: 'Croissant',\n    descending: 'Décroissant',\n    resultsCount: '{{showing}} sur {{total}} fichiers',\n    selectAll: 'Tout sélectionner',\n    deselectAll: 'Tout désélectionner',\n    selected: '{{count}} sélectionnés',\n    adding: 'Ajout...',\n    loadingFiles: 'Chargement...',\n    folderIsEmpty: 'Dossier vide',\n    noFilesYet: 'Aucun fichier',\n    folderEmptyDescription: 'Téléversez ou déplacez des fichiers ici.',\n    noFilesDescription: 'Téléversez des fichiers pour organiser.',\n    noMatchingFiles: 'Aucun fichier correspondant',\n    noMatchingFilesDescription: 'Ajustez votre recherche.',\n    clearFilters: 'Effacer filtres',\n    printedCount: 'Imprimé {{count}}x',\n    uploadedBy: 'Téléversé par',\n    deleteFolder: 'Supprimer dossier',\n    deleteFile: 'Supprimer fichier',\n    deleteFilesCount: 'Supprimer {{count}} fichiers',\n    deleteFolderConfirm: 'Supprimer le dossier et son contenu ?',\n    deleteFileConfirm: 'Supprimer ce fichier ?',\n    deleteFilesConfirm: 'Supprimer {{count}} fichiers définitivement ?',\n    deleting: 'Suppression...',\n    noPermissionRenameFolder: 'Pas d\\'autorisation renommage',\n    noPermissionLinkFolder: 'Pas d\\'autorisation lien',\n    noPermissionDeleteFolder: 'Pas d\\'autorisation suppression dossier',\n    noPermissionPrint: 'Pas d\\'autorisation impression',\n    noPermissionAddToQueue: 'Pas d\\'autorisation file',\n    noPermissionDownload: 'Pas d\\'autorisation téléchargement',\n    noPermissionRenameFile: 'Pas d\\'autorisation renommage fichier',\n    noPermissionGenerateThumbnail: 'Pas d\\'autorisation vignettes',\n    noPermissionDeleteFile: 'Pas d\\'autorisation suppression fichier',\n    noPermissionCreateFolder: 'Pas d\\'autorisation nouveau dossier',\n    noPermissionUpload: 'Pas d\\'autorisation téléversement',\n    noPermissionMoveFiles: 'Pas d\\'autorisation déplacement',\n    noPermissionDeleteFiles: 'Pas d\\'autorisation suppression groupée',\n    // External folder\n    linkExternal: 'Lier externe',\n    linkExternalFolder: 'Lier un dossier externe',\n    linkExternalFolderDescription: 'Monter un répertoire hôte (NAS, USB, partage réseau) dans le gestionnaire de fichiers. Les fichiers ne sont pas copiés — ils sont lus directement depuis le chemin d\\'origine.',\n    externalFolderNamePlaceholder: 'ex. Impressions NAS',\n    externalPath: 'Chemin hôte',\n    externalPathHelp: 'Chemin absolu du répertoire sur l\\'hôte Docker. Doit être monté en bind dans le conteneur.',\n    readOnly: 'Lecture seule',\n    readOnlyHelp: 'empêche les téléversements et suppressions',\n    showHiddenFiles: 'Afficher les fichiers cachés (fichiers point)',\n    externalFolder: 'Dossier externe',\n    scanFolder: 'Scanner',\n    toast: {\n      folderCreated: 'Dossier créé',\n      folderDeleted: 'Dossier supprimé',\n      fileDeleted: 'Fichier supprimé',\n      filesDeleted: '{{count}} fichiers supprimés',\n      filesMoved: 'Fichiers déplacés',\n      folderLinked: 'Dossier lié',\n      folderUnlinked: 'Dossier délié',\n      externalFolderLinked: 'Dossier externe lié et scanné',\n      folderScanned: 'Scan terminé : {{added}} ajoutés, {{removed}} supprimés',\n      addedToQueue: '{{count}} fichier(s) ajouté(s)',\n      addedToQueuePartial: '{{added}} ajoutés, {{failed}} échecs',\n      failedToAddToQueue: 'Échec ajout file : {{error}}',\n      fileRenamed: 'Fichier renommé',\n      folderRenamed: 'Dossier renommé',\n      thumbnailsGenerated: '{{count}} vignette(s) générée(s)',\n      thumbnailsGeneratedPartial: '{{succeeded}} succès, {{failed}} échecs',\n      noStlMissingThumbnails: 'Aucun STL sans vignette',\n      failedToGenerateThumbnails: 'Échec vignettes : {{error}}',\n      thumbnailGenerated: 'Vignette générée',\n      failedToGenerateThumbnail: 'Échec vignette : {{error}}',\n    },\n  },\n\n  // Projects\n  projects: {\n    title: 'Projets',\n    subtitle: 'Suivez vos projets d\\'impression 3D',\n    newProject: 'Nouveau Projet',\n    editProject: 'Modifier Projet',\n    deleteProject: 'Supprimer Projet',\n    projectName: 'Nom du Projet',\n    description: 'Description',\n    noProjects: 'Aucun projet',\n    noProjectsFiltered: 'Aucun projet {{status}}',\n    noProjectsFilteredHelp: 'Les projets apparaîtront ici quand leur statut changera.',\n    createFirst: 'Créez votre premier projet pour organiser vos builds.',\n    createFirstButton: 'Créer votre premier projet',\n    create: 'Créer',\n    files: 'Fichiers',\n    prints: 'Impressions',\n    plates: 'plateaux',\n    parts: 'pièces',\n    lastModified: 'Modifié le',\n    deleteConfirm: 'Supprimer ce projet ? Les archives seront déliées mais conservées.',\n    addFiles: 'Ajouter fichiers',\n    removeFile: 'Retirer fichier',\n    viewDetails: 'Détails',\n    // Modal fields\n    namePlaceholder: 'ex: Build Voron 2.4',\n    descriptionPlaceholder: 'Description optionnelle...',\n    color: 'Couleur',\n    targetPlates: 'Plateaux cibles',\n    targetPlatesPlaceholder: 'ex: 25',\n    targetPlatesHelp: 'Nombre total de jobs',\n    targetParts: 'Pièces cibles',\n    targetPartsPlaceholder: 'ex: 150',\n    targetPartsHelp: 'Nombre total d\\'objets',\n    tagsLabel: 'Tags (séparés par virgules)',\n    tagsPlaceholder: 'ex: voron, cadeau',\n    dueDate: 'Échéance',\n    priority: 'Priorité',\n    priorityLow: 'Basse',\n    priorityNormal: 'Normale',\n    priorityHigh: 'Haute',\n    priorityUrgent: 'Urgente',\n    // Status\n    statusActive: 'Actif',\n    statusCompleted: 'Terminé',\n    statusArchived: 'Archivé',\n    done: 'Fait',\n    completed: 'terminé',\n    failed: 'échoué',\n    inQueue: 'en file',\n    noPrintsYet: 'Aucune impression',\n    // Footer stats\n    printJobs: 'Jobs (plateaux)',\n    partsPrinted: 'Pièces imprimées',\n    failedParts: 'Pièces échouées',\n    // Actions\n    import: 'Importer',\n    export: 'Exporter',\n    importProject: 'Importer projet',\n    exportAll: 'Exporter tous les projets',\n    loading: 'Chargement des projets...',\n    // Permissions\n    noEditPermission: 'Pas d\\'autorisation de modification',\n    noDeletePermission: 'Pas d\\'autorisation de suppression',\n    noCreatePermission: 'Pas d\\'autorisation de création',\n    noImportPermission: 'Pas d\\'autorisation d\\'import',\n    noExportPermission: 'Pas d\\'autorisation d\\'export',\n    // Toast\n    toast: {\n      created: 'Projet créé',\n      updated: 'Projet mis à jour',\n      deleted: 'Projet supprimé',\n      imported: 'Projet importé',\n      multipleImported: '{{count}} projets importés',\n      importFailed: 'Échec d\\'import',\n      exported: 'Projets exportés (métadonnées)',\n    },\n  },\n\n  // Project detail page\n  projectDetail: {\n    notFound: 'Projet non trouvé',\n    backToProjects: 'Retour aux Projets',\n    export: 'Exporter',\n    exportProject: 'Exporter projet',\n    noExportPermission: 'Pas d\\'autorisation export',\n    noEditPermission: 'Pas d\\'autorisation modification',\n    partOf: 'Fait partie de :',\n    priorityLabel: 'Priorité :',\n    noPrints: 'Aucune impression dans ce projet',\n    status: {\n      active: 'Actif',\n      completed: 'Terminé',\n      archived: 'Archivé',\n    },\n    priority: {\n      low: 'Basse',\n      normal: 'Normale',\n      high: 'Haute',\n      urgent: 'Urgente',\n    },\n    dueDate: {\n      overdue: 'En retard',\n      today: 'Aujourd\\'hui',\n      daysLeft: '{{count}} jours restants',\n    },\n    progress: {\n      platesProgress: 'Progression Plateaux',\n      partsProgress: 'Progression Pièces',\n      printJobs: 'jobs d\\'impression',\n      parts: 'pièces',\n      percentComplete: '{{percent}}% terminé',\n      remaining: '{{count}} restant(s)',\n    },\n    stats: {\n      printJobs: 'Jobs d\\'Impression',\n      total: 'total',\n      failed: '{{count}} échecs',\n      partsPrinted: '{{count}} pièces imprimées',\n      printTime: 'Temps d\\'Impression',\n      filamentUsed: 'Filament utilisé',\n    },\n    cost: {\n      title: 'Suivi des coûts',\n      filamentCost: 'Coût Filament',\n      energy: 'Énergie',\n      totalCost: 'Coût Total',\n      total: 'Total',\n      includesBom: 'BOM incluse',\n      budget: 'Budget',\n      remaining: 'Restant',\n    },\n    subProjects: {\n      title: 'Sous-projets ({{count}})',\n    },\n    notes: {\n      title: 'Notes',\n      noEditPermission: 'Pas d\\'autorisation modification',\n      placeholder: 'Ajouter des notes...',\n      empty: 'Aucune note. Cliquez sur modifier.',\n    },\n    files: {\n      title: 'Fichiers',\n      linkFolders: 'Liez des dossiers depuis le gestionnaire',\n      forQuickAccess: 'pour un accès rapide.',\n      fileCount: '{{count}} fichier(s)',\n      empty: 'Aucun dossier lié.',\n      noFiles: 'Aucun fichier dans ce dossier.',\n      print: 'Imprimer maintenant',\n      addToQueue: 'Ajouter à la file',\n    },\n    bom: {\n      title: 'BOM (Liste matériel)',\n      acquired: '{{completed}}/{{total}} acquis',\n      showAll: 'Tout afficher',\n      hideDone: 'Masquer acquis',\n      addPart: 'Ajouter matériel',\n      noAddPermission: 'Pas d\\'autorisation ajout',\n      partNamePlaceholder: 'Nom (ex: Vis M3x8)',\n      partName: 'Nom de pièce',\n      qty: 'Qté',\n      price: 'Prix ({{currency}})',\n      sourcingUrlPlaceholder: 'Lien d\\'achat (optionnel)',\n      remarksPlaceholder: 'Remarques (optionnel)',\n      deletePart: 'Supprimer pièce',\n      deleteConfirm: 'Supprimer \"{{name}}\" ?',\n      noUpdatePermission: 'Pas d\\'autorisation mise à jour',\n      noEditPermission: 'Pas d\\'autorisation modification',\n      noDeletePermission: 'Pas d\\'autorisation suppression',\n      totalCost: 'Coût total :',\n      empty: 'BOM vide. Ajoutez du matériel ou de l\\'électronique.',\n    },\n    timeline: {\n      title: 'Historique d\\'activité',\n      empty: 'Aucune activité.',\n    },\n    template: {\n      saveAsTemplate: 'Enregistrer comme modèle',\n      noCreatePermission: 'Pas d\\'autorisation modèle',\n    },\n    queue: {\n      title: 'File d\\'attente',\n      viewAll: 'Tout voir',\n      printing: '{{count}} en cours',\n      queued: '{{count}} en file',\n    },\n    prints: {\n      title: 'Impressions ({{count}})',\n    },\n    toast: {\n      projectUpdated: 'Projet mis à jour',\n      partAdded: 'Pièce ajoutée',\n      partRemoved: 'Pièce retirée',\n      exportFailed: 'Échec export',\n      projectExported: 'Projet exporté',\n      templateCreated: 'Modèle créé',\n    },\n  },\n\n  // System info\n  system: {\n    title: 'Informations Système',\n    version: 'Version',\n    uptime: 'Temps de fonctionnement',\n    cpuUsage: 'Utilisation CPU',\n    memoryUsage: 'Utilisation RAM',\n    diskUsage: 'Utilisation Disque',\n    networkInfo: 'Infos Réseau',\n    logs: 'Journaux',\n    debugMode: 'Mode Débogage',\n    enableDebug: 'Activer débogage',\n    disableDebug: 'Désactiver débogage',\n    downloadLogs: 'Télécharger logs',\n    clearLogs: 'Effacer logs',\n    dockerInfo: 'Infos Docker',\n    containerName: 'Nom conteneur',\n    imageName: 'Nom image',\n    platform: 'Plateforme',\n    architecture: 'Architecture',\n  },\n\n  // Library (K Profiles)\n  library: {\n    title: 'Bibliothèque Filament',\n    addFilament: 'Ajouter Filament',\n    editFilament: 'Modifier Filament',\n    deleteFilament: 'Supprimer Filament',\n    vendor: 'Vendeur',\n    material: 'Matériau',\n    color: 'Couleur',\n    kFactor: 'Facteur K',\n    temperature: 'Température',\n    noFilaments: 'Bibliothèque vide',\n    deleteConfirm: 'Supprimer ce filament ?',\n    importFromPrinter: 'Importer de l\\'imprimante',\n    exportToFile: 'Exporter vers fichier',\n  },\n\n  // Spoolman\n  spoolman: {\n    title: 'Intégration Spoolman',\n    enabled: 'Spoolman Activé',\n    url: 'URL Spoolman',\n    connected: 'Connecté',\n    disconnected: 'Non Connecté',\n    testConnection: 'Tester connexion',\n    sync: 'Synchroniser',\n    syncing: 'Sync...',\n    lastSync: 'Dernière Sync',\n    linkToSpoolman: 'Lier à Spoolman',\n    openInSpoolman: 'Ouvrir Spoolman',\n    unlinkSpool: 'Délier bobine',\n    unlinkConfirmTitle: 'Dissocier la bobine?',\n    unlinkConfirmMessage: 'Cette opération déconnectera la bobine de Spoolman. Les données de la bobine dans Spoolman resteront inchangées.',\n    selectSpool: 'Choisir bobine',\n    noUnlinkedSpools: 'Pas de bobine libre',\n    linkSuccess: 'Lien réussi',\n    linkFailed: 'Échec lien',\n    unlinkSuccess: 'Bobine dissociée avec succès',\n    unlinkFailed: 'Échec de la dissociation de la bobine',\n    spoolId: 'ID Bobine',\n    fillSourceLabel: '(Spoolman)',\n    weight: 'Poids',\n    remaining: 'Restant',\n    disableWeightSync: 'Désactiver Sync poids estimé AMS',\n    disableWeightSyncDesc: 'Ne pas utiliser les estimations AMS. Utile si vous préférez le suivi Spoolman.',\n    reportPartialUsage: 'Rapporter consommation partielle pour échecs',\n    reportPartialUsageDesc: 'Si l\\'impression échoue, rapporte le filament consommé selon les couches.',\n  },\n\n  // Inventory\n  inventory: {\n    title: 'Inventaire de Bobines',\n    addSpool: 'Ajouter Bobine',\n    editSpool: 'Modifier Bobine',\n    material: 'Matériau',\n    selectMaterial: 'Choisir matériau...',\n    subtype: 'Sous-type',\n    brand: 'Marque',\n    searchBrand: 'Chercher marque...',\n    useCustomBrand: 'Utiliser \"{{brand}}\"',\n    useCustomMaterial: 'Utiliser un matériau personnalisé : {{material}}',\n    colorName: 'Nom de couleur',\n    colorNamePlaceholder: 'Jade White, Fire Red...',\n    color: 'Couleur',\n    hexColor: 'Code Hex',\n    pickColor: 'Choisir couleur perso',\n    labelWeight: 'Poids net',\n    coreWeight: 'Poids bobine vide',\n    searchSpoolWeight: 'Chercher poids bobine...',\n    weightUsed: 'Consommé',\n    currentWeight: 'Poids restant',\n    measuredWeight: 'Poids mesuré',\n    spoolName: 'Bobine',\n    costPerKg: 'Coût par kg',\n    measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.',\n    slicerFilament: 'Filament Slicer',\n    slicerFilamentName: 'Nom du Preset Slicer',\n    slicerPreset: 'Preset Slicer',\n    searchPresets: 'Chercher presets...',\n    selectedPreset: 'Sélectionné',\n    noPresetsFound: 'Aucun preset trouvé',\n    tempOverrides: 'Exceptions Température',\n    note: 'Note',\n    notePlaceholder: 'Notes additionnelles sur cette bobine...',\n    archive: 'Archiver',\n    restore: 'Restaurer',\n    noSpools: 'Aucune bobine. Ajoutez votre première bobine pour commencer.',\n    noManualSpools: 'Aucune bobine manuelle disponible. Ajoutez-en une d\\'abord.',\n    kProfiles: 'K-Profiles',\n    addKProfile: 'Ajouter K-Profile',\n    assignSpool: 'Assigner Bobine',\n    unassignSpool: 'Désassigner',\n    assignSuccess: 'Bobine assignée et slot AMS configuré',\n    assignFailed: 'Échec assignation',\n    selectSpool: 'Choisir une bobine pour ce slot',\n    assigned: 'Assigné',\n    assigning: 'Assignation...',\n    searchSpools: 'Chercher bobines...',\n    showAllSpools: 'Afficher toutes les bobines',\n    allMaterials: 'Tous Matériaux',\n    filterByBrand: 'Filtrer par marque...',\n    showArchived: 'Afficher archivées',\n    quickAdd: 'Ajout rapide (Stock)',\n    quantity: 'Quantité',\n    stock: 'Stock',\n    configured: 'Configuré',\n    spoolsCreated: '{{count}} bobines créées',\n    spoolCreated: 'Bobine créée',\n    spoolUpdated: 'Bobine mise à jour',\n    spoolDeleted: 'Bobine supprimée',\n    spoolArchived: 'Bobine archivée',\n    spoolRestored: 'Bobine restaurée',\n    deleteConfirm: 'Supprimer définitivement cette bobine ?',\n    archiveConfirm: 'Voulez-vous vraiment archiver cette bobine ?',\n    advancedSettings: 'Paramètres Avancés',\n    // Tabs\n    filamentInfoTab: 'Infos Filament',\n    paProfileTab: 'Profil PA',\n    filamentInfo: 'Filament',\n    additional: 'Additionnel',\n    // Cloud\n    loadingPresets: 'Chargement des presets cloud...',\n    cloudConnected: 'Cloud connecté',\n    cloudNotConnected: 'Cloud déconnecté (valeurs par défaut)',\n    // Colors\n    recentColors: 'Récentes',\n    searchColors: 'Chercher couleurs...',\n    searchResults: 'Résultats',\n    allColors: 'Toutes',\n    commonColors: 'Communes',\n    showLess: 'Moins',\n    showAll: 'Toutes',\n    noColorsFound: 'Aucune couleur correspondante',\n    noResults: 'Aucun résultat',\n    // PA Profiles\n    selectMaterialFirst: 'Veuillez choisir un matériau dans l\\'onglet Infos Filament.',\n    noPrintersConfigured: 'Ajoutez une imprimante pour utiliser les profils PA.',\n    matchingFilter: 'Correspondant',\n    anyBrand: 'Toute marque',\n    anyVariant: 'Toute variante',\n    autoSelect: 'Auto-sélection',\n    matches: 'correspondances',\n    match: 'correspondance',\n    noMatches: 'Aucune correspondance',\n    connected: 'Connecté',\n    offline: 'Hors ligne',\n    printerOffline: 'Imprimante hors ligne. Connectez-vous pour voir les profils.',\n    noKProfilesMatch: 'Aucun profil K ne correspond au filament.',\n    leftNozzle: 'Buse Gauche',\n    rightNozzle: 'Buse Droite',\n    profilesSelected: 'profil(s) de calibration sélectionné(s)',\n    // Stats & enhanced table\n    totalInventory: 'Total Inventaire',\n    totalConsumed: 'Total Consommé',\n    byMaterial: 'Par Matériau',\n    inPrinter: 'Dans Imprimante',\n    lowStock: 'Stock Bas',\n    sinceTracking: 'Depuis le début du suivi',\n    loadedInAms: 'Chargé dans AMS/Ext',\n    remaining: 'Restant',\n    weightCheck: 'Vérification poids',\n    lastWeighed: 'Dernière pesée',\n    neverWeighed: 'Jamais pesé',\n    search: 'Chercher bobines...',\n    showing: 'Affichage',\n    to: 'à',\n    of: 'sur',\n    show: 'Voir',\n    spools: 'bobines',\n    spool: 'bobine',\n    page: 'Page',\n    noSpoolsMatch: 'Aucun résultat trouvé',\n    noSpoolsMatchDesc: 'Ajustez votre recherche ou vos filtres.',\n    active: 'Actif',\n    archived: 'Archivé',\n    all: 'Tous',\n    used: 'Occasion',\n    new: 'Neuf',\n    clearFilters: 'Effacer filtres',\n    table: 'Tableau',\n    cards: 'Cartes',\n    net: 'Net',\n    // Grouping\n    groupSimilar: 'Grouper',\n    groupedSpools: '{{count}} bobines identiques',\n    groupedRows: 'lignes',\n    // Column config\n    columns: 'Colonnes',\n    configureColumns: 'Configurer Colonnes',\n    configureColumnsDesc: 'Glissez pour ordonner ou utilisez les flèches. Cliquez sur l\\'œil pour masquer.',\n    visible: 'visible',\n    reset: 'Reset',\n    cancel: 'Annuler',\n    applyChanges: 'Appliquer',\n    moveUp: 'Monter',\n    moveDown: 'Descendre',\n    hideColumn: 'Masquer',\n    showColumn: 'Afficher',\n    // Tag linking\n    linkToSpool: 'Lier à une Bobine',\n    tagLinked: 'Tag lié à la bobine',\n    tagLinkFailed: 'Échec lien tag',\n    tagAlreadyLinked: 'Tag déjà lié à une autre bobine',\n    unknownTag: 'Tag RFID inconnu détecté',\n    // Usage history\n    usageHistory: 'Historique de Consommation',\n    noUsageHistory: 'Aucune consommation enregistrée',\n    printName: 'Nom Impression',\n    weightConsumed: 'Poids consommé',\n    clearHistory: 'Effacer',\n    historyCleared: 'Historique effacé',\n    fillSourceLabel: '(Inv)',\n    lowStockThresholdError: 'Le seuil doit être compris entre 0.1 et 99.9',\n    assignMismatchTitle: 'Incompatibilité de matériau',\n    assignMismatchMessage: 'Le matériau de la bobine sélectionnée \"{{spoolMaterial}}\" ne correspond pas au matériau du plateau \"{{trayMaterial}}\" pour {{location}}. Assigner quand même ?',\n    assignMismatchConfirm: 'Assigner quand même',\n    assignPartialMismatchMessage: 'Le matériau de la bobine \"{{spoolMaterial}}\" est similaire, mais ne correspond pas exactement à \"{{trayMaterial}}\" dans {{location}}. Voulez-vous continuer ?',\n    assignProfileMismatchMessage: 'Le profil de la bobine \"{{spoolProfile}}\" ne correspond pas au profil du plateau \"{{trayProfile}}\" dans {{location}}. Voulez-vous continuer ?',\n  },\n\n  // Timelapse\n  timelapse: {\n    title: 'Timelapse',\n    create: 'Créer Timelapse',\n    download: 'Télécharger',\n    delete: 'Supprimer',\n    preview: 'Aperçu',\n    frameRate: 'Images/sec',\n    quality: 'Qualité',\n    processing: 'Traitement...',\n    noTimelapses: 'Aucun timelapse',\n  },\n\n  // AMS\n  ams: {\n    title: 'AMS',\n    slot: 'Slot',\n    empty: 'Vide',\n    emptySlot: 'Slot vide',\n    unknown: 'Inconnu',\n    humidity: 'Humidité',\n    temperature: 'Température',\n    filamentType: 'Type filament',\n    filamentColor: 'Couleur',\n    remaining: 'Restant',\n    history: 'Historique AMS',\n    noHistory: 'Aucun historique',\n    configureSlot: 'Configurer Slot',\n    externalSpool: 'Bobine externe',\n    profile: 'Profil',\n    kFactor: 'Facteur K',\n    fill: 'Remplir',\n    configure: 'Configurer',\n    used: 'utilisé',\n    remainingUnit: 'restant',\n  },\n\n  // Print modal\n  printModal: {\n    title: 'Lancer l\\'impression',\n    selectPrinter: 'Choisir l\\'imprimante',\n    selectPlate: 'Choisir le plateau',\n    filamentMapping: 'Mapping Filament',\n    totalCost: 'Coût total :',\n    slotRemainingShort: ' - {{grams}}g rest.',\n    printSettings: 'Réglages d\\'impression',\n    bedLeveling: 'Nivellement plateau',\n    flowCalibration: 'Calibration débit',\n    vibrationCalibration: 'Vibration (Input Shaper)',\n    layerInspection: 'Inspection 1ère couche',\n    timelapse: 'Timelapse',\n    startPrint: 'Démarrer',\n    addToQueue: 'Ajouter à la file',\n    cancel: 'Annuler',\n    noPrintersAvailable: 'Aucune imprimante disponible',\n    printerBusy: 'L\\'imprimante est occupée',\n    printerOffline: 'L\\'imprimante est hors ligne',\n    sameTypeDifferentColor: 'Même type, couleur différente',\n    filamentTypeNotLoaded: 'Type de filament non chargé',\n    openCalendar: 'Ouvrir calendrier',\n    leftNozzle: 'G',\n    rightNozzle: 'D',\n    leftNozzleTooltip: 'Buse gauche',\n    rightNozzleTooltip: 'Buse droite',\n    filamentOverride: 'Remplacement de filament',\n    filamentOverrideHint: 'Remplacez optionnellement les filaments pour l\\'affectation par modèle. Le planificateur utilisera vos filaments sélectionnés au lieu des valeurs 3MF d\\'origine.',\n    originalFilament: 'Original',\n    overrideWith: 'Remplacer par',\n    resetToOriginal: 'Revenir à l\\'original',\n    insufficientFilamentTitle: 'Filament insuffisant',\n    insufficientFilamentMessage: 'Certaines bobines assignées ont moins de filament restant que nécessaire pour cette impression :',\n    insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',\n    printAnyway: 'Imprimer quand même',\n    forceColorMatch: 'Forcer correspondance des couleurs',\n    staggerPrinterStarts: 'Stagger printer starts',\n    staggerGroupSize: 'Group size',\n    staggerInterval: 'Interval (min)',\n    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',\n    staggerLastGroup: 'last group: {{count}}',\n    staggerTotal: 'total: {{minutes}} min',\n    staggerToPrinters: 'Échelonner sur {{count}} imprimantes',\n    gcodeInjection: 'Injecter le G-code auto-impression',\n  },\n\n  // Backup\n  backup: {\n    title: 'Sauvegarde & Restauration',\n    createBackup: 'Créer Sauvegarde',\n    restoreBackup: 'Restaurer Sauvegarde',\n    restoreDescription: 'Remplace les données par un fichier de sauvegarde',\n    downloadBackup: 'Télécharger Sauvegarde',\n    uploadBackup: 'Téléverser Sauvegarde',\n    lastBackup: 'Dernière sauvegarde',\n    autoBackup: 'Sauvegarde auto',\n    backupNow: 'Sauvegarder maintenant',\n    restoreWarning: 'Attention : Écrase TOUTES les données actuelles.',\n    includeArchives: 'Inclure Archives',\n    includeSettings: 'Inclure Paramètres',\n    includeProfiles: 'Inclure Profils',\n    backupSuccess: 'Sauvegarde réussie',\n    restoreSuccess: 'Restauration réussie',\n    backupFailed: 'Échec sauvegarde',\n    restoreFailed: 'Échec restauration',\n    restoreNote: 'L\\'imprimante virtuelle sera arrêtée pendant la restauration',\n\n    // GitHub Backup\n    githubBackup: 'Sauvegarde GitHub',\n    enabled: 'Activé',\n    cloudLoginRequired: 'Connexion Bambu Cloud requise. Connectez-vous sous Profils → Profils Cloud pour activer la sauvegarde GitHub.',\n    cloudLoginRequiredShort: 'Connexion Cloud requise',\n    githubDescription: 'Synchronisez automatiquement vos profils vers un dépôt GitHub privé pour la sauvegarde et l\\'historique des versions.',\n    repositoryUrl: 'URL du dépôt',\n    personalAccessToken: 'Jeton d\\'accès personnel',\n    tokenSaved: '(enregistré)',\n    enterNewToken: 'Entrez un nouveau jeton pour mettre à jour',\n    tokenHint: 'Jeton à granularité fine avec permission de lecture/écriture du contenu',\n    branch: 'Branche',\n    manualOnly: 'Manuel uniquement',\n    hourly: 'Toutes les heures',\n    daily: 'Quotidien',\n    weekly: 'Hebdomadaire',\n    includeInBackup: 'Inclure dans la sauvegarde',\n    kProfiles: 'K-Profils',\n    kProfilesDescription: 'Calibration de l\\'avance de pression des imprimantes connectées',\n    noPrintersConnected: 'Aucune imprimante connectée',\n    printersConnected: '{{connected}}/{{total}} connectées',\n    cloudProfiles: 'Profils Cloud',\n    cloudProfilesDescription: 'Préréglages de filament, imprimante et processus depuis Bambu Cloud',\n    appSettings: 'Paramètres de l\\'application',\n    appSettingsDescription: 'Configuration Bambuddy (base de données complète)',\n    spoolInventory: 'Inventaire des bobines',\n    spoolInventoryDescription: 'Bobines de filament, historique d\\'utilisation et suivi des coûts',\n    printArchives: 'Archives d\\'impression',\n    printArchivesDescription: 'Métadonnées de l\\'historique d\\'impression (pas de fichiers gcode/3MF)',\n    lastBackupAt: 'Dernière sauvegarde :',\n    noBackupsYet: 'Aucune sauvegarde pour l\\'instant',\n    next: 'Prochaine :',\n    startingBackup: 'Démarrage de la sauvegarde...',\n    test: 'Tester',\n    enableBackup: 'Activer la sauvegarde',\n    testConnection: 'Tester la connexion',\n    enterRepoUrl: 'Entrez l\\'URL du dépôt',\n    enterRepoAndToken: 'Entrez l\\'URL du dépôt et le jeton d\\'accès',\n    repoRequired: 'L\\'URL du dépôt est requise',\n    tokenRequired: 'Le jeton d\\'accès est requis',\n    githubBackupEnabled: 'Sauvegarde GitHub activée',\n    tokenUpdated: 'Jeton mis à jour',\n    settingsSaved: 'Paramètres enregistrés',\n    failedToSave: 'Échec de l\\'enregistrement : {{message}}',\n    backupCompleteFiles: 'Sauvegarde terminée - {{count}} fichiers mis à jour',\n    backupSkippedNoChanges: 'Sauvegarde ignorée - aucun changement',\n    backupFailed2: 'Échec de la sauvegarde : {{message}}',\n    clearedLogs: '{{count}} journaux supprimés',\n    failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',\n\n    // History\n    history: 'Historique',\n    clear: 'Effacer',\n    date: 'Date',\n    status: 'Statut',\n    commit: 'Commit',\n\n    // Local Backup\n    localBackup: 'Sauvegarde locale',\n    localBackupDescription: 'Créez une sauvegarde complète de vos données Bambuddy incluant la base de données, les archives, les téléchargements et tous les fichiers.',\n    downloadBackupLabel: 'Télécharger la sauvegarde',\n    completeBackupZip: 'Sauvegarde complète : base de données + tous les fichiers (ZIP)',\n    download: 'Télécharger',\n    preparingBackup: 'Préparation de la sauvegarde...',\n    creatingArchive: 'Création de l\\'archive de sauvegarde... Cela peut prendre un moment pour les archives volumineuses.',\n    downloadingFile: 'Téléchargement du fichier de sauvegarde...',\n    backupDownloaded: 'Sauvegarde téléchargée avec succès',\n    failedToCreateBackup: 'Échec de la création de la sauvegarde : {{message}}',\n    restore: 'Restaurer',\n    restoreReplacesAll: 'La restauration remplace toutes les données.',\n    restoreReplacesAllDetail: 'Votre base de données et vos fichiers actuels seront complètement remplacés. Un redémarrage est nécessaire après la restauration.',\n    restoreConfirmTitle: 'Restaurer la sauvegarde',\n    restoreConfirmMessage: 'Êtes-vous sûr de vouloir restaurer depuis \"{{filename}}\" ? Cela remplacera complètement votre base de données et tous vos fichiers. L\\'application devra être redémarrée après la restauration.',\n    restoreConfirmButton: 'Restaurer la sauvegarde',\n    uploadingFile: 'Téléchargement du fichier de sauvegarde...',\n    backupRestoredRestart: 'Sauvegarde restaurée. Veuillez redémarrer Bambuddy.',\n    failedToRestore: 'Échec de la restauration. Veuillez vérifier le format du fichier.',\n    reloadNow: 'Recharger maintenant',\n    creatingBackup: 'Création de la sauvegarde',\n    restoringBackup: 'Restauration de la sauvegarde',\n    preparing: 'Préparation...',\n    processing: 'Traitement...',\n    doNotClosePage: 'Veuillez ne pas fermer cette page ni naviguer ailleurs. Cette opération peut prendre plusieurs minutes pour les sauvegardes volumineuses.',\n\n    // RestoreModal\n    restoring: 'Restauration...',\n    restoreComplete: 'Restauration terminée',\n    restoreFailed2: 'Échec de la restauration',\n    importSettings: 'Importer les paramètres depuis un fichier de sauvegarde',\n    pleaseWaitRestoring: 'Veuillez patienter pendant la restauration de vos données',\n    selectBackupFile: 'Cliquez pour sélectionner un fichier de sauvegarde (.json ou .zip)',\n    duplicateHandling: 'Comment fonctionne la gestion des doublons :',\n    matchPrinters: 'Imprimantes',\n    matchPrintersBy: 'correspondance par numéro de série',\n    matchSmartPlugs: 'Smart Plugs',\n    matchSmartPlugsBy: 'correspondance par adresse IP',\n    matchNotificationProviders: 'Fournisseurs de notifications',\n    matchNotificationProvidersBy: 'correspondance par nom',\n    matchFilaments: 'Filaments',\n    matchFilamentsBy: 'correspondance par nom + type + marque',\n    matchArchives: 'Archives',\n    matchArchivesBy: 'correspondance par hash de contenu (toujours ignoré)',\n    matchPendingUploads: 'Téléchargements en attente',\n    matchPendingUploadsBy: 'correspondance par nom de fichier',\n    matchSettingsTemplates: 'Paramètres et modèles',\n    matchSettingsTemplatesBy: 'toujours écrasés',\n    replaceExisting: 'Remplacer les données existantes',\n    keepExisting: 'Conserver les données existantes',\n    overwriteDescription: 'Écraser les éléments qui existent déjà avec les données de sauvegarde',\n    keepDescription: 'Restaurer uniquement les éléments qui n\\'existent pas encore',\n    overwriteCaution: 'Attention :',\n    overwriteWarning: 'L\\'écrasement remplacera vos configurations actuelles par les données de la sauvegarde. Les codes d\\'accès des imprimantes ne sont jamais écrasés pour des raisons de sécurité.',\n    cancel: 'Annuler',\n    processingBackup: 'Traitement du fichier de sauvegarde...',\n    itemsRestored: 'Éléments restaurés',\n    itemsSkipped: 'Éléments ignorés',\n    restored: 'Restaurés',\n    skippedAlreadyExist: 'Ignorés (existent déjà)',\n    filesCategory: 'Fichiers (3MF, miniatures, etc.)',\n    andMore: '...et {{count}} de plus',\n    newApiKeysGenerated: 'Nouvelles clés API générées',\n    keysShownOnce: 'Ces clés ne sont affichées qu\\'une seule fois. Copiez-les maintenant !',\n    copy: 'Copier',\n    noDataFound: 'Aucune donnée à restaurer n\\'a été trouvée dans le fichier de sauvegarde.',\n    close: 'Fermer',\n\n    // Scheduled local backups (#884)\n    scheduledBackup: 'Scheduled Backups',\n    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',\n    frequency: 'Frequency',\n    backupTime: 'Time',\n    retention: 'Retention',\n    retentionDescription: 'Number of backups to keep',\n    outputPath: 'Output Path',\n    outputPathPlaceholder: 'Default: {{path}}',\n    outputPathDescription: 'Leave empty for default location',\n    runNow: 'Run Now',\n    backupFiles: 'Backup Files',\n    noScheduledBackups: 'No backups yet',\n    deleteBackup: 'Delete',\n    deleteBackupConfirm: 'Delete this backup file?',\n    backupRunning: 'Backup in progress...',\n    scheduledBackupComplete: 'Backup completed successfully',\n    scheduledBackupFailed: 'Backup failed',\n    nextBackup: 'Next backup',\n    backupSize: 'Size',\n    utc: 'UTC',\n    defaultPathLabel: 'Default:',\n\n    // Category labels\n    categories: {\n      settings: 'Paramètres',\n      notification_providers: 'Fournisseurs de notifications',\n      notification_templates: 'Modèles de notifications',\n      smart_plugs: 'Smart Plugs',\n      printers: 'Imprimantes',\n      filaments: 'Filaments',\n      maintenance_types: 'Types de maintenance',\n      archives: 'Archives',\n      projects: 'Projets',\n      pending_uploads: 'Téléchargements en attente',\n      external_links: 'Liens externes',\n      api_keys: 'Clés API',\n    },\n  },\n\n  // Tags\n  tags: {\n    title: 'Tags',\n    addTag: 'Ajouter Tag',\n    editTag: 'Modifier Tag',\n    deleteTag: 'Supprimer Tag',\n    tagName: 'Nom du Tag',\n    tagColor: 'Couleur du Tag',\n    noTags: 'Aucun tag',\n    deleteConfirm: 'Supprimer ce tag ?',\n    manageTags: 'Gérer les Tags',\n  },\n\n  // Upload modal (archives)\n  uploadModal: {\n    title: 'Téléverser des fichiers 3MF',\n    dragDrop: 'Glissez les fichiers .3mf ici',\n    or: 'ou',\n    browseFiles: 'Parcourir',\n    extractionInfo: 'Le modèle d\\'imprimante est extrait des métadonnées 3MF.',\n    uploaded: 'téléversé',\n    failed: 'échoué',\n    uploading: 'En cours...',\n    upload: 'Téléverser',\n    uploadFailed: 'Échec du téléversement',\n  },\n\n  // Edit archive modal\n  // Edit Archive Modal\n  editArchive: {\n    title: 'Modifier l\\'archive',\n    name: 'Nom',\n    namePlaceholder: 'Nom de l\\'impression',\n    printer: 'Imprimante',\n    noPrinter: 'Aucune imprimante',\n    project: 'Projet',\n    noProject: 'Aucun projet',\n    itemsPrinted: 'Nombre de pièces',\n    itemsPrintedHelp: 'Nombre d\\'objets produits',\n    notes: 'Notes',\n    notesPlaceholder: 'Notes sur l\\'impression...',\n    externalLink: 'Lien externe',\n    externalLinkPlaceholder: 'https://...',\n    externalLinkHelp: 'Lien vers Printables, Thingiverse, etc.',\n    tags: 'Tags',\n    tagsPlaceholder: 'Ajouter des tags...',\n    addMoreTags: 'Plus de tags...',\n    matchingTags: 'Correspondant à \"{{query}}\"',\n    existingTags: 'Tags existants',\n    clickToAdd: '(cliquer pour ajouter)',\n    status: 'Statut',\n    failureReason: 'Raison de l\\'échec',\n    selectReason: 'Choisir raison...',\n    photos: 'Photos du résultat',\n    photosHelp: 'Cliquez sur + pour ajouter des photos',\n    printResult: 'Résultat d\\'impression',\n    saving: 'Enregistrement...',\n    // Failure reasons\n    failureReasons: {\n      adhesionFailure: 'Défaut d\\'adhésion',\n      spaghettiDetached: 'Spaghetti / Détaché',\n      layerShift: 'Décalage de couche',\n      cloggedNozzle: 'Buse bouchée',\n      filamentRunout: 'Filament fini',\n      warping: 'Warping (Déformation)',\n      stringing: 'Stringing (Cheveux d\\'ange)',\n      underExtrusion: 'Sous-extrusion',\n      powerFailure: 'Coupure courant',\n      userCancelled: 'Annulé par l\\'utilisateur',\n      other: 'Autre',\n    },\n    // Archive statuses\n    statuses: {\n      completed: 'Réussie',\n      failed: 'Échouée',\n      aborted: 'Annulée',\n      printing: 'Impression',\n    },\n  },\n\n  // K-Profiles\n  kProfiles: {\n    title: 'K-Profiles',\n    noPrintersConfigured: 'Aucune imprimante configurée',\n    addPrinterInSettings: 'Ajoutez une imprimante pour gérer les K-profiles',\n    noActivePrinters: 'Aucune imprimante active',\n    enablePrinterConnection: 'Activez la connexion pour voir les K-profiles',\n    loadingProfiles: 'Chargement des K-Profiles...',\n    printerOffline: 'Imprimante hors ligne',\n    printerOfflineDesc: 'L\\'imprimante doit être allumée.',\n    noMatchingProfiles: 'Aucun profil correspondant',\n    noMatchingProfilesDesc: 'Ajustez votre recherche',\n    noKProfiles: 'Aucun K-Profile',\n    noKProfilesDesc: 'Aucun profil trouvé pour une buse de {{diameter}}mm',\n    createFirstProfile: 'Créer le premier profil',\n    // Controls\n    printer: 'Imprimante',\n    nozzle: 'Buse',\n    refresh: 'Rafraîchir',\n    addProfile: 'Ajouter Profil',\n    export: 'Exporter',\n    import: 'Importer',\n    select: 'Choisir',\n    selectAll: 'Tout sélectionner',\n    delete: 'Supprimer',\n    // Filters\n    searchPlaceholder: 'Nom ou filament...',\n    allExtruders: 'Tous les extrudeurs',\n    leftOnly: 'Gauche uniquement',\n    rightOnly: 'Droite uniquement',\n    allFlow: 'Tout débit',\n    hfOnly: 'HF uniquement',\n    sOnly: 'S uniquement',\n    sortName: 'Tri : Nom',\n    sortKValue: 'Tri : Valeur K',\n    sortFilament: 'Tri : Filament',\n    // Dual extruder labels\n    leftExtruder: 'Extrudeur gauche',\n    rightExtruder: 'Extrudeur droit',\n    // Modal\n    modal: {\n      addTitle: 'Ajouter K-Profile',\n      editTitle: 'Modifier K-Profile',\n      profileName: 'Nom du profil',\n      profileNamePlaceholder: 'ex: Mon profil PLA',\n      kValue: 'Valeur K',\n      kValuePlaceholder: '0.020',\n      kValueHelp: 'Plage type : 0.01-0.06 (PLA), 0.02-0.10 (PETG)',\n      filament: 'Filament',\n      selectFilament: 'Choisir filament...',\n      noFilamentsHelp: 'Créez d\\'abord un profil dans Bambu Studio.',\n      flowType: 'Type de débit',\n      highFlow: 'Haut Débit (HF)',\n      standard: 'Standard',\n      nozzleSize: 'Taille buse',\n      extruder: 'Extrudeur',\n      extruders: 'Extrudeurs',\n      left: 'Gauche',\n      right: 'Droite',\n      notes: 'Notes (locales)',\n      notesPlaceholder: 'Notes sur ce profil...',\n      notesHelp: 'Enregistré dans Bambuddy, pas sur l\\'imprimante',\n      syncing: 'Sync avec l\\'imprimante...',\n      savingExtruder: 'Sauvegarde extrudeur {{current}}/{{total}}...',\n      pleaseWait: 'Patientez...',\n    },\n    // Delete confirmation\n    deleteConfirm: {\n      title: 'Supprimer profil',\n      cannotUndo: 'Action irréversible',\n      message: 'Supprimer \"{{name}}\" de l\\'imprimante ?',\n    },\n    // Bulk delete\n    bulkDelete: {\n      title: 'Supprimer les profils',\n      cannotUndo: 'Action irréversible',\n      message: 'Supprimer les {{count}} profils de l\\'imprimante ?',\n    },\n    // Toast\n    toast: {\n      profileSaved: 'Profil K enregistré',\n      profilesSaved: 'Profil K enregistré sur {{count}} extrudeur(s)',\n      selectAtLeastOneExtruder: 'Sélectionnez un extrudeur',\n      profileDeleted: 'Profil K supprimé',\n      profilesDeleted: '{{count}} profils supprimés',\n      exportedProfiles: '{{count}} profils exportés',\n      importedProfiles: '{{count}} sur {{total}} profils importés',\n      noProfilesToExport: 'Rien à exporter',\n      invalidFileFormat: 'Format invalide',\n      failedToParseImport: 'Échec analyse fichier',\n      failedToSaveBatch: 'Échec enregistrement groupé',\n      noteSaved: 'Note enregistrée',\n      failedToSaveNote: 'Échec note',\n    },\n    // Permissions\n    permission: {\n      noRead: 'Pas d\\'autorisation lecture',\n      noCreate: 'Pas d\\'autorisation création',\n      noUpdate: 'Pas d\\'autorisation mise à jour',\n      noDelete: 'Pas d\\'autorisation suppression',\n      noExport: 'Pas d\\'autorisation export',\n      noImport: 'Pas d\\'autorisation import',\n    },\n  },\n\n  // Virtual Printer\n  virtualPrinter: {\n    title: 'Imprimante Virtuelle',\n    running: 'En cours',\n    stopped: 'Arrêtée',\n    description: {\n      default: 'Active une imprimante qui apparaît dans Bambu Studio. Les fichiers envoyés sont archivés sans impression.',\n      proxy: 'Active un proxy qui relaie le trafic vers une imprimante réelle, permettant l\\'impression à distance.',\n    },\n    enable: {\n      title: 'Activer l\\'imprimante virtuelle',\n      visibleInSlicer: 'Visible comme \"Bambuddy\" dans le Slicer',\n      proxyingTo: 'Proxy vers {{name}}',\n      notActive: 'Inactive',\n    },\n    model: {\n      title: 'Modèle d\\'imprimante',\n      description: 'Choisissez le modèle à émuler.',\n      restartWarning: 'Changer le modèle redémarrera le service',\n    },\n    accessCode: {\n      title: 'Code d\\'accès',\n      isSet: 'Code défini',\n      notSet: 'Code requis pour activer',\n      placeholder: 'Code 8 char',\n      placeholderChange: 'Entrez nouveau code',\n      hint: 'Exactement 8 caractères. Sert à l\\'auth du Slicer.',\n      charCount: '({{count}}/8)',\n    },\n    targetPrinter: {\n      title: 'Imprimante cible',\n      configured: 'Cible configurée',\n      notConfigured: 'Imprimante requise pour mode Proxy',\n      placeholder: 'Choisir imprimante...',\n      hint: 'L\\'imprimante doit être en mode LAN.',\n      noPrinters: 'Ajoutez une imprimante réelle d\\'abord.',\n    },\n    remoteInterface: {\n      title: 'Exception Interface Réseau',\n      configured: 'Override actif',\n      optional: 'Optionnel - si IP auto est fausse (VPN, Docker, multi-NIC).',\n      placeholder: 'Auto (défaut)...',\n      hint: 'Force l\\'IP annoncée via SSDP.',\n    },\n    mode: {\n      title: 'Mode',\n      archive: 'Archiver',\n      archiveDesc: 'Archive immédiatement',\n      review: 'Revue',\n      reviewDesc: 'Attendre revue avant archive',\n      queue: 'File',\n      queueDesc: 'Archiver et ajouter à la file',\n      proxy: 'Proxy',\n      proxyDesc: 'Relais vers imprimante réelle',\n    },\n    autoDispatch: {\n      title: 'Lancement automatique',\n      description: 'Lancer automatiquement les impressions ajoutées à la file. Désactivé, les impressions attendent un lancement manuel.',\n    },\n    setupRequired: {\n      title: 'Configuration requise',\n      description: 'Nécessite des réglages système (ports, pare-feu).',\n      readGuide: 'Lire le guide de configuration',\n    },\n    howItWorks: {\n      title: 'Fonctionnement',\n      step1: 'Sur le même LAN, les imprimantes virtuelles apparaissent automatiquement dans votre slicer (Bambu Studio / OrcaSlicer). Depuis d\\'autres réseaux, ajoutez-les manuellement par adresse IP et code d\\'accès.',\n      step2: 'En mode Archive, Revue et File d\\'attente, utilisez le bouton \"Envoyer\" dans votre slicer pour envoyer des fichiers 3MF à Bambuddy. Le slicer affichera \"Impression réussie\" — le fichier est stocké, pas imprimé.',\n      step3: 'En mode Proxy, l\\'imprimante virtuelle relaie tout le trafic vers une vraie imprimante — les impressions démarrent immédiatement comme en connexion directe.',\n    },\n    status: {\n      title: 'Détails du statut',\n      printerName: 'Nom',\n      model: 'Modèle',\n      serialNumber: 'Série',\n      mode: 'Mode',\n      pendingFiles: 'Fichiers en attente',\n      targetPrinter: 'Cible',\n      ftpPort: 'Port FTP',\n      mqttPort: 'Port MQTT',\n      ftpConnections: 'Connexions FTP',\n      mqttConnections: 'Connexions MQTT',\n    },\n    toast: {\n      updated: 'Réglages virtuels mis à jour',\n      failedToUpdate: 'Échec mise à jour',\n      accessCodeRequired: 'Code d\\'accès requis',\n      targetPrinterRequired: 'Imprimante cible requise',\n      bindIpRequired: 'Veuillez d\\'abord définir une adresse IP',\n      accessCodeEmpty: 'Le code ne peut pas être vide',\n      accessCodeLength: 'Le code doit faire 8 caractères',\n      created: 'Imprimante virtuelle créée',\n      failedToCreate: 'Échec de la création de l\\'imprimante virtuelle',\n      deleted: 'Imprimante virtuelle supprimée',\n      failedToDelete: 'Échec de la suppression de l\\'imprimante virtuelle',\n    },\n    list: {\n      title: 'Imprimantes virtuelles',\n      add: 'Ajouter',\n      addFirst: 'Ajouter une imprimante virtuelle',\n      empty: 'Aucune imprimante virtuelle configurée. Ajoutez-en une pour commencer.',\n    },\n    bindIp: {\n      title: 'Interface réseau',\n      placeholder: 'Sélectionner interface...',\n      hint: 'Interface réseau sur laquelle cette imprimante virtuelle écoute. Doit être unique par imprimante.',\n    },\n    proxy: {\n      accessCodeHint: 'En mode proxy, utilisez le code d\\'accès de l\\'imprimante cible dans le slicer. La connexion est transmise de manière transparente à l\\'imprimante réelle.',\n    },\n    addDialog: {\n      title: 'Ajouter une imprimante virtuelle',\n      name: 'Nom',\n      hint: 'Vous pourrez configurer le code d\\'accès, l\\'imprimante cible et d\\'autres paramètres après la création.',\n      create: 'Créer',\n    },\n    deleteConfirm: {\n      title: 'Supprimer l\\'imprimante virtuelle',\n      message: 'Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cela arrêtera tous les services de cette imprimante.',\n    },\n  },\n\n  // Model Viewer\n  modelViewer: {\n    openInSlicer: 'Ouvrir dans le Slicer',\n    tabs: {\n      model: 'Modèle 3D',\n      gcode: 'Aperçu G-code',\n    },\n    notAvailable: 'indisponible',\n    notSliced: 'pas découpé',\n    plates: 'Plateaux',\n    allPlates: 'Tous les plateaux',\n    plateNumber: 'Plateau {{number}}',\n    plateCount: '{{count}} plateau',\n    plateCount_other: '{{count}} plateaux',\n    objectCount: '{{count}} objet',\n    objectCount_other: '{{count}} objets',\n    filamentCount: '{{count}} filament',\n    filamentCount_other: '{{count}} filaments',\n    eta: 'Fin {{minutes}} min',\n    noPreview: 'Aucun aperçu pour ce fichier',\n    pagination: {\n      pageOf: 'Page {{current}} sur {{total}}',\n      prev: 'Préc',\n      next: 'Suiv',\n    },\n    errors: {\n      failedToLoad: 'Échec chargement fichier',\n      noMeshes: 'Aucun maillage trouvé dans le 3MF',\n      unsupportedFormat: 'Format non supporté',\n    },\n  },\n\n  // Maintenance type descriptions (built-in)\n  maintenanceDescriptions: {\n    lubricateCarbonRods: 'Appliquer du lubrifiant sur les tiges carbone pour un mouvement fluide',\n    lubricateRails: 'Appliquer du lubrifiant sur les rails linéaires',\n    cleanNozzle: 'Nettoyer buse et hotend anti-bouchage',\n    checkBelts: 'Tension des courroies pour la précision',\n    cleanBuildPlate: 'Nettoyage plateau pour l\\'adhésion',\n    checkExtruder: 'Usure des engrenages de l\\'extrudeur',\n    checkCooling: 'Bon fonctionnement des ventilateurs',\n    generalInspection: 'Inspection générale de la machine',\n    cleanCarbonRods: 'Nettoyer les tiges carbone (friction)',\n    lubricateSteelRods: 'Appliquer du lubrifiant sur les tiges en acier pour un mouvement fluide',\n    cleanSteelRods: 'Nettoyer les tiges en acier (friction)',\n    cleanLinearRails: 'Essuyer les rails linéaires (poussière/débris)',\n    checkPtfeTube: 'Usure ou dommage du tube PTFE',\n    replaceHepaFilter: 'Filtre HEPA pour la qualité de l\\'air',\n    replaceCarbonFilter: 'Filtre charbon actif (odeurs)',\n    lubricateLeftNozzleRail: 'Lubrifier le rail de buse gauche (Série H2)',\n  },\n\n  // Smart Plugs\n  smartPlugs: {\n    offline: 'Hors ligne',\n    admin: 'Admin',\n    openPlugAdminPage: 'Page admin de la prise',\n    deleteSmartPlug: 'Supprimer la prise',\n    turnOnSmartPlug: 'Allumer la prise',\n    turnOffSmartPlug: 'Éteindre la prise',\n    turnOn: 'Allumer',\n    turnOff: 'Éteindre',\n    addSmartPlug: {\n      scanningNetwork: 'Scan réseau...',\n      chooseEntity: 'Choisir une entité...',\n      connectionFailed: 'Échec connexion',\n      searchEntities: 'Chercher entités...',\n      searchPowerSensors: 'Capteurs puissance...',\n      searchEnergySensors: 'Capteurs énergie...',\n      placeholders: {\n        plugName: 'Prise Salon',\n        mqttStateOnValue: 'ON, true, 1',\n        mqttSameAsPower: 'Identique au topic puissance, ou différent',\n      },\n    },\n    // SmartPlugCard\n    linkedTo: 'Lié à :',\n    monitorOnly: 'Surveillance uniquement',\n    alerts: 'Alertes',\n    scheduleOn: 'On {{time}}',\n    scheduleOff: 'Off {{time}}',\n    on: 'On',\n    off: 'Off',\n    power: 'Puissance',\n    kwhToday: 'kWh Aujourd\\'hui',\n    settings: 'Paramètres',\n    automationSettings: 'Paramètres d\\'automatisation',\n    showInSwitchbar: 'Afficher dans la barre de commutateurs',\n    quickAccessSidebar: 'Accès rapide depuis la barre latérale',\n    enabled: 'Activé',\n    enableAutomation: 'Activer l\\'automatisation pour cette prise',\n    autoOn: 'Auto On',\n    autoOnDescription: 'Allumer au démarrage de l\\'impression',\n    autoOff: 'Auto Off',\n    autoOffDescription: 'Éteindre à la fin de l\\'impression (unique)',\n    autoOffPersistent: 'Garder activé',\n    autoOffPersistentDescription: 'Rester activé entre les impressions au lieu d\\'une seule fois',\n    turnOffDelayMode: 'Mode de délai d\\'extinction',\n    time: 'Temps',\n    temp: 'Temp',\n    delayMinutes: 'Délai (minutes)',\n    tempThreshold: 'Seuil de température (°C)',\n    tempThresholdDescription: 'S\\'éteint lorsque la buse refroidit en dessous de cette température',\n    edit: 'Modifier',\n    deleteConfirm: 'Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.',\n    turnOnConfirm: 'Êtes-vous sûr de vouloir allumer \"{{name}}\" ?',\n    turnOffConfirm: 'Êtes-vous sûr de vouloir éteindre \"{{name}}\" ? Cela coupera l\\'alimentation de l\\'appareil connecté.',\n    failedToTurn: 'Impossible de {{action}} \"{{name}}\"',\n    unknown: 'Inconnu',\n    // AddSmartPlugModal\n    addTitle: 'Ajouter une prise connectée',\n    editTitle: 'Modifier la prise connectée',\n    stopScanning: 'Arrêter le scan',\n    discoverTasmota: 'Découvrir les appareils Tasmota',\n    foundDevices: '{{count}} appareil(s) trouvé(s) - cliquez pour sélectionner :',\n    noDevicesFound: 'Aucun appareil Tasmota trouvé sur votre réseau',\n    haNotConfigured: 'Home Assistant n\\'est pas configuré. Configurez-le dans',\n    haSettingsPath: 'Paramètres → Réseau → Home Assistant',\n    selectEntity: 'Sélectionner l\\'entité *',\n    ipAddress: 'Adresse IP *',\n    nameLabel: 'Nom *',\n    username: 'Nom d\\'utilisateur',\n    password: 'Mot de passe',\n    authHint: 'Laissez vide si votre appareil Tasmota ne nécessite pas d\\'authentification',\n    linkToPrinter: 'Lier à l\\'imprimante',\n    noPrinter: 'Pas d\\'imprimante (contrôle manuel uniquement)',\n    linkingDescription: 'La liaison permet l\\'allumage/extinction automatique au début/fin de l\\'impression',\n    powerAlerts: 'Alertes de puissance',\n    alertAbove: 'Alerte si au-dessus (W)',\n    alertBelow: 'Alerte si en dessous (W)',\n    alertDescription: 'Recevoir une notification lorsque la consommation dépasse ces seuils. Laisser vide pour désactiver cette direction.',\n    dailySchedule: 'Planification quotidienne',\n    turnOnAt: 'Allumer à',\n    turnOffAt: 'Éteindre à',\n    scheduleDescription: 'Allumer/éteindre automatiquement la prise à ces heures chaque jour. Laisser vide pour ignorer cette action.',\n    showOnPrinterCard: 'Afficher sur la carte imprimante',\n    displayOnPrinterCard: 'Afficher le bouton sur la carte imprimante',\n    connectedResult: 'Connecté !',\n    deviceLabel: 'Appareil : {{name}} - ',\n    stateLabel: 'État : {{state}}',\n    test: 'Tester',\n    delete: 'Supprimer',\n    save: 'Enregistrer',\n    add: 'Ajouter',\n    cancel: 'Annuler',\n    failedToStartScan: 'Impossible de démarrer le scan',\n    nameRequired: 'Le nom est requis',\n    entityRequired: 'L\\'entité est requise pour les prises Home Assistant',\n    mqttTopicRequired: 'Au moins un topic MQTT doit être configuré pour la puissance, l\\'énergie ou la surveillance d\\'état',\n    loadingEntities: 'Chargement des entités...',\n    loading: 'Chargement...',\n    failedToLoadEntities: 'Échec du chargement des entités : {{error}}',\n    noEntitiesMatching: 'Aucune entité trouvée correspondant à \"{{search}}\"',\n    noEntitiesAvailable: 'Aucune entité disponible',\n    searchingEntities: 'Recherche de toutes les entités ({{count}} trouvées)',\n    showingEntities: 'Affichage switch, light, input_boolean ({{count}} disponibles)',\n    energyMonitoringOptional: 'Surveillance énergétique (Optionnel)',\n    energyMonitoringHint: 'Recherchez et sélectionnez les capteurs fournissant des données de puissance/énergie.',\n    powerSensorW: 'Capteur de puissance (W)',\n    energyTodayKwh: 'Énergie aujourd\\'hui (kWh)',\n    totalEnergyKwh: 'Énergie totale (kWh)',\n    noMatchingSensors: 'Aucun capteur correspondant',\n    none: 'Aucun',\n    mqttNotConfigured: 'Broker MQTT non configuré. Définissez l\\'adresse du broker dans',\n    mqttSettingsPath: 'Paramètres → Réseau → Publication MQTT',\n    mqttNotConfiguredSuffix: '(vous n\\'avez pas besoin d\\'activer la publication, remplissez simplement les détails du broker).',\n    mqttMonitorOnlyDescription: 'Les prises MQTT reçoivent les données de puissance/énergie via un abonnement MQTT. Le contrôle on/off n\\'est pas disponible - utilisez votre broker MQTT ou système domotique.',\n    powerMonitoring: 'Surveillance de puissance',\n    energyMonitoring: 'Surveillance énergétique',\n    stateMonitoring: 'Surveillance d\\'état',\n    optional: 'optionnel',\n    topic: 'Topic',\n    jsonPath: 'Chemin JSON',\n    multiplier: 'Multiplicateur',\n    onValue: 'Valeur ON',\n    mqttPowerHint: 'Le chemin JSON extrait la valeur du payload JSON (ex: \"power_l1\"). Laisser vide si le topic publie des valeurs numériques brutes.\\nUtiliser le multiplicateur 0.001 pour mW→W, 1000 pour kW→W.',\n    mqttEnergyHint: 'Le chemin JSON extrait la valeur du payload JSON. Laisser vide pour les valeurs brutes.\\nUtiliser le multiplicateur 0.001 pour Wh→kWh, 1000 pour MWh→kWh.',\n    mqttStateHint: 'Le chemin JSON extrait la valeur du payload JSON. Laisser vide pour les valeurs brutes.\\nValeur ON : la chaîne exacte signifiant \"ON\". Laisser vide pour la détection auto (ON, true, 1).',\n    // REST smart plug\n    restControl: 'Control',\n    restOnUrl: 'Turn ON URL',\n    restOffUrl: 'Turn OFF URL',\n    restOnBody: 'ON Request Body',\n    restOffBody: 'OFF Request Body',\n    restMethod: 'HTTP Method',\n    restHeaders: 'Custom Headers (JSON)',\n    restStatusUrl: 'Status URL',\n    restStatusPath: 'State JSON Path',\n    restStatusOnValue: 'ON Value',\n    restPowerUrl: 'URL de puissance',\n    restPowerPath: 'Power JSON Path',\n    restPowerMultiplier: 'Multiplicateur de puissance',\n    restEnergyUrl: 'URL d\\'énergie',\n    restEnergyPath: 'Energy JSON Path',\n    restEnergyMultiplier: 'Multiplicateur d\\'énergie',\n    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',\n    restHeadersHint: 'e.g. {\"Authorization\": \"Bearer your-token\"}',\n    restBodyHint: 'e.g. ON, {\"state\": \"on\"}',\n    restStatusHint: 'URL to poll for current state',\n    restPathHint: 'e.g. state or data.power.status',\n    restPowerUrlHint: 'URL séparée pour les données de puissance (utilise l\\'URL de statut si vide)',\n    restEnergyUrlHint: 'URL séparée pour les données d\\'énergie (utilise l\\'URL de statut si vide)',\n    restEnergyHint: 'Chaque valeur peut utiliser sa propre URL ou se rabattre sur l\\'URL de statut. Utilisez les multiplicateurs pour la conversion d\\'unités (ex : 0.001 pour convertir Wh en kWh).',\n    testConnection: 'Test Connection',\n    connectionSuccess: 'Connection successful',\n    noSwitchesInSwitchbar: 'Aucun commutateur dans la barre',\n    enableSwitchbarHint: 'Activez \"Afficher dans la barre de commutateurs\" dans Paramètres > Smart Plugs',\n  },\n\n  // Notifications\n  notifications: {\n    // Provider types\n    providerTypes: {\n      callmebot: 'CallMeBot/WhatsApp',\n      ntfy: 'ntfy',\n      pushover: 'Pushover',\n      telegram: 'Telegram',\n      email: 'E-mail',\n      discord: 'Discord',\n      webhook: 'Webhook',\n      homeassistant: 'Home Assistant',\n    },\n    // Provider descriptions\n    providerDescriptions: {\n      email: 'Notifications par e-mail SMTP',\n      telegram: 'Notifications via un bot Telegram',\n      discord: 'Envoyer vers un canal Discord via webhook',\n      ntfy: 'Notifications push gratuites, auto-hébergeables',\n      pushover: 'Notifications push simples et fiables',\n      callmebot: 'Notifications WhatsApp gratuites via CallMeBot',\n      webhook: 'POST HTTP générique vers n\\'importe quelle URL',\n      homeassistant: 'Notifications persistantes dans le tableau de bord Home Assistant',\n    },\n    // NotificationProviderCard\n    lastSuccess: 'Dernier : {{date}}',\n    error: 'Erreur',\n    printer: 'Imprimante :',\n    allPrinters: 'Toutes les imprimantes',\n    sendTestNotification: 'Envoyer une notification de test',\n    eventSettings: 'Paramètres des événements',\n    enabled: 'Activé',\n    sendFromProvider: 'Envoyer des notifications depuis ce fournisseur',\n    // Event categories\n    printEvents: 'Événements d\\'impression',\n    printerStatus: 'État de l\\'imprimante',\n    amsAlarms: 'Alarmes AMS',\n    amsHtAlarms: 'Alarmes AMS-HT',\n    printQueue: 'File d\\'attente d\\'impression',\n    // Event tags (badges)\n    start: 'Début',\n    plateCheck: 'Vérification du plateau',\n    complete: 'Terminé',\n    failed: 'Échoué',\n    stopped: 'Arrêté',\n    progress: 'Progression',\n    offline: 'Hors ligne',\n    lowFilament: 'Filament bas',\n    maintenance: 'Maintenance',\n    amsHumidity: 'Humidité AMS',\n    amsTemp: 'Temp. AMS',\n    amsHtHumidity: 'Humidité AMS-HT',\n    amsHtTemp: 'Temp. AMS-HT',\n    bedCooled: 'Plateau refroidi',\n    firstLayer: 'Première couche',\n    quiet: 'Silencieux',\n    digest: 'Résumé {{time}}',\n    // Event labels (expanded settings)\n    printStarted: 'Impression démarrée',\n    plateNotEmpty: 'Plateau non vide',\n    plateNotEmptyDescription: 'Objets détectés avant l\\'impression',\n    printCompleted: 'Impression terminée',\n    bedCooledLabel: 'Plateau refroidi',\n    bedCooledDescription: 'Plateau refroidi sous le seuil après l\\'impression',\n    firstLayerCompleteLabel: 'Première couche terminée',\n    firstLayerCompleteDescription: 'Notification avec photo après la première couche',\n    missingSpoolAssignmentLabel: 'Affectation de bobine manquante',\n    missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\\'ont pas de bobine assignée',\n    printFailed: 'Impression échouée',\n    printStopped: 'Impression arrêtée',\n    progressMilestones: 'Jalons de progression',\n    progressMilestonesDescription: 'Notifier à 25 %, 50 %, 75 %',\n    printerOffline: 'Imprimante hors ligne',\n    printerError: 'Erreur de l\\'imprimante',\n    lowFilamentLabel: 'Filament bas',\n    maintenanceDue: 'Maintenance requise',\n    maintenanceDueDescription: 'Notifier lorsqu\\'une maintenance est nécessaire',\n    amsHumidityHigh: 'Humidité AMS élevée',\n    amsHumidityHighDescription: 'L\\'humidité de l\\'AMS standard dépasse le seuil',\n    amsTemperatureHigh: 'Température AMS élevée',\n    amsTemperatureHighDescription: 'La température de l\\'AMS standard dépasse le seuil',\n    amsHtHumidityHigh: 'Humidité AMS-HT élevée',\n    amsHtHumidityHighDescription: 'L\\'humidité de l\\'AMS-HT dépasse le seuil',\n    amsHtTemperatureHigh: 'Température AMS-HT élevée',\n    amsHtTemperatureHighDescription: 'La température de l\\'AMS-HT dépasse le seuil',\n    // Queue events\n    jobAdded: 'Tâche ajoutée',\n    jobAddedDescription: 'Tâche ajoutée à la file d\\'attente',\n    jobAssigned: 'Tâche assignée',\n    jobAssignedDescription: 'Tâche basée sur le modèle assignée à l\\'imprimante',\n    jobStarted: 'Tâche démarrée',\n    jobStartedDescription: 'La tâche de la file a commencé l\\'impression',\n    jobWaiting: 'Tâche en attente',\n    jobWaitingDescription: 'Tâche en attente de filament ou imprimante',\n    jobSkipped: 'Tâche ignorée',\n    jobSkippedDescription: 'Tâche ignorée (échec précédent)',\n    jobFailed: 'Tâche échouée',\n    jobFailedDescription: 'La tâche n\\'a pas pu démarrer',\n    queueComplete: 'File d\\'attente terminée',\n    queueCompleteDescription: 'Toutes les tâches de la file sont terminées',\n    // Quiet hours\n    quietHours: 'Heures silencieuses',\n    noNotificationsDuring: 'Aucune notification pendant ces heures',\n    editProviderToChangeQuietHours: 'Modifier le fournisseur pour changer les heures silencieuses',\n    // Daily digest\n    dailyDigest: 'Résumé quotidien',\n    batchNotifications: 'Regrouper les notifications en un seul résumé quotidien',\n    sendAt: 'Envoyer à {{time}}',\n    editProviderToChangeDigestTime: 'Modifier le fournisseur pour changer l\\'heure du résumé',\n    // Actions\n    edit: 'Modifier',\n    deleteProvider: 'Supprimer le fournisseur de notifications',\n    deleteConfirm: 'Êtes-vous sûr de vouloir supprimer « {{name}} » ? Cette action est irréversible.',\n    delete: 'Supprimer',\n    // AddNotificationModal\n    addTitle: 'Ajouter un fournisseur de notifications',\n    editTitle: 'Modifier le fournisseur de notifications',\n    nameLabel: 'Nom *',\n    namePlaceholder: 'Mes notifications',\n    providerTypeLabel: 'Type de fournisseur *',\n    configuration: 'Configuration',\n    testConfiguration: 'Tester la configuration',\n    printerFilter: 'Filtre d\\'imprimante',\n    onlyFromPrinter: 'Envoyer uniquement les notifications pour les événements de cette imprimante',\n    quietHoursDnd: 'Heures silencieuses (Ne pas déranger)',\n    quietStart: 'Début',\n    quietEnd: 'Fin',\n    dailyDigestLabel: 'Résumé quotidien',\n    sendDigestAt: 'Envoyer le résumé à',\n    digestCollected: 'Les événements seront collectés et envoyés en un seul résumé à cette heure',\n    notificationEvents: 'Événements de notification',\n    progressPercent: '(25 %, 50 %, 75 %)',\n    bedCooledAfterPrint: '(après la fin de l\\'impression)',\n    cancel: 'Annuler',\n    save: 'Enregistrer',\n    add: 'Ajouter',\n    nameRequired: 'Le nom est requis',\n    fieldRequired: '{{field}} est requis',\n    // Config field labels\n    phoneNumber: 'Numéro de téléphone',\n    apiKey: 'Clé API',\n    serverUrl: 'URL du serveur',\n    topic: 'Sujet',\n    authToken: 'Jeton d\\'authentification',\n    userKey: 'Clé utilisateur',\n    appToken: 'Jeton d\\'application',\n    priority: 'Priorité',\n    botToken: 'Jeton du bot',\n    chatId: 'ID du chat',\n    smtpServer: 'Serveur SMTP',\n    smtpPort: 'Port SMTP',\n    security: 'Sécurité',\n    authentication: 'Authentification',\n    username: 'Nom d\\'utilisateur',\n    password: 'Mot de passe',\n    fromEmail: 'E-mail expéditeur',\n    toEmail: 'E-mail destinataire',\n    webhookUrl: 'URL du webhook',\n    payloadFormat: 'Format du payload',\n    authorization: 'Autorisation',\n    titleFieldName: 'Nom du champ titre',\n    messageFieldName: 'Nom du champ message',\n    // NotificationTemplateEditor\n    editTemplate: 'Modifier le modèle : {{name}}',\n    titleLabel: 'Titre',\n    bodyLabel: 'Corps',\n    titlePlaceholder: 'Titre de la notification...',\n    bodyPlaceholder: 'Corps de la notification...',\n    availableVariables: 'Variables disponibles',\n    clickToInsert: 'Cliquer pour insérer à la position du curseur dans le corps',\n    livePreview: 'Aperçu en direct',\n    hide: 'Masquer',\n    show: 'Afficher',\n    loadingPreview: 'Chargement de l\\'aperçu...',\n    enterTemplateContent: 'Saisir le contenu du modèle pour voir l\\'aperçu',\n    titlePreview: 'Titre :',\n    bodyPreview: 'Corps :',\n    resetToDefault: 'Réinitialiser par défaut',\n    titleRequired: 'Le titre est requis',\n    bodyRequired: 'Le corps est requis',\n    // NotificationLogViewer\n    notificationLog: 'Journal des notifications',\n    showFailedOnly: 'Échecs uniquement',\n    last24Hours: 'Dernières 24 heures',\n    last7Days: '7 derniers jours',\n    last30Days: '30 derniers jours',\n    last90Days: '90 derniers jours',\n    justNow: 'À l\\'instant',\n    noFailedNotifications: 'Aucune notification échouée',\n    noNotificationsLogged: 'Aucune notification enregistrée',\n    unknownProvider: 'Fournisseur inconnu',\n    logTitle: 'Titre',\n    logMessage: 'Message',\n    logError: 'Erreur',\n    logProvider: 'Fournisseur : {{type}}',\n    logTime: 'Heure : {{time}}',\n    refresh: 'Actualiser',\n    clearOld: 'Purger les anciens',\n    statsSummary: '{{days}} derniers jours :',\n    statsNotifications: 'notifications',\n    statsSent: '{{count}} envoyées',\n    statsFailed: '{{count}} échouées',\n    // Event type labels (for log viewer)\n    eventTypes: {\n      print_start: 'Impression démarrée',\n      print_complete: 'Impression terminée',\n      print_failed: 'Impression échouée',\n      print_stopped: 'Impression arrêtée',\n      print_progress: 'Progression',\n      printer_offline: 'Imprimante hors ligne',\n      printer_error: 'Erreur de l\\'imprimante',\n      filament_low: 'Filament bas',\n      maintenance_due: 'Maintenance requise',\n      test: 'Test',\n    },\n    // User email notification preferences\n    userEmail: {\n      title: 'Notifications',\n      emailNotifications: 'Notifications par e-mail',\n      emailNotificationsDesc: \"Recevez des notifications par e-mail pour vos propres travaux d'impression. Les e-mails sont envoyés via les paramètres SMTP configurés dans l'authentification avancée.\",\n      sendingTo: 'Les notifications seront envoyées à',\n      noEmailWarning: \"Votre compte n'a pas d'adresse e-mail. Contactez un administrateur pour en ajouter une.\",\n      printJobNotifications: \"Notifications de travaux d'impression\",\n      printJobNotificationsDesc: \"Choisissez quels événements déclenchent des notifications par e-mail pour les travaux d'impression que vous soumettez.\",\n      printJobStarts: \"Démarrage du travail d'impression\",\n      printJobStartsDesc: \"Être notifié quand votre travail d'impression commence.\",\n      printJobFinishes: \"Fin du travail d'impression\",\n      printJobFinishesDesc: \"Être notifié quand votre travail d'impression se termine avec succès.\",\n      printErrors: \"Erreurs d'impression\",\n      printErrorsDesc: \"Être notifié quand votre travail d'impression échoue ou rencontre une erreur.\",\n      printJobStops: \"Travail d'impression arrêté\",\n      printJobStopsDesc: \"Être notifié quand votre travail d'impression est annulé ou arrêté.\",\n      saveSuccess: 'Préférences de notification sauvegardées.',\n      saveError: 'Impossible de sauvegarder les préférences de notification.',\n    },\n  },\n\n  // Rich Text Editor\n  richTextEditor: {\n    bold: 'Gras',\n    italic: 'Italique',\n    underline: 'Souligné',\n    bulletList: 'Liste à puces',\n    numberedList: 'Liste numérotée',\n    alignLeft: 'Aligner à gauche',\n    alignCenter: 'Centrer',\n    alignRight: 'Aligner à droite',\n    addLink: 'Ajouter lien',\n    removeLink: 'Retirer lien',\n  },\n\n  // External Links\n  externalLinks: {\n    noLinksConfigured: 'Aucun lien externe configuré',\n    deleteLink: 'Supprimer lien',\n    removeCustomIcon: 'Retirer icône personnalisée',\n    openInNewTab: 'Ouvrir dans un nouvel onglet',\n    placeholders: {\n      linkName: 'Mon Lien',\n    },\n  },\n\n  // Keyboard Shortcuts Modal\n  keyboardShortcuts: {\n    title: 'Raccourcis Clavier',\n    navigation: 'Navigation',\n    archivesSection: 'Archives',\n    kProfilesSection: 'K-Profiles',\n    generalSection: 'Général',\n    shortcuts: {\n      goToPrinters: 'Aller aux Imprimantes',\n      goToArchives: 'Aller aux Archives',\n      goToQueue: 'Aller à la File',\n      goToStats: 'Aller aux Stats',\n      goToProfiles: 'Aller aux Profils Cloud',\n      goToSettings: 'Aller aux Paramètres',\n      focusSearch: 'Focus recherche',\n      openUploadModal: 'Ouvrir téléversement',\n      clearSelection: 'Tout déselectionner',\n      contextMenu: 'Menu contextuel (cartes)',\n      refreshProfiles: 'Rafraîchir profils',\n      newProfile: 'Nouveau profil',\n      exitSelectionMode: 'Quitter mode sélection',\n      showHelp: 'Afficher cette aide',\n    },\n    footer: 'Échap ou clic extérieur pour fermer',\n  },\n\n  // Notification Log\n  notificationLog: {\n    title: 'Journal de Notification',\n    events: {\n      printStarted: 'Début impression',\n      printComplete: 'Fin impression',\n      printFailed: 'Échec impression',\n      printStopped: 'Arrêt impression',\n      progress: 'Progression',\n      printerOffline: 'Hors ligne',\n      printerError: 'Erreur',\n      lowFilament: 'Filament bas',\n      maintenanceDue: 'Maintenance',\n      test: 'Test',\n    },\n    timeAgo: {\n      justNow: 'À l\\'instant',\n      minutesAgo: 'Il y a {{minutes}}m',\n      hoursAgo: 'Il y a {{hours}}h',\n    },\n  },\n\n  // Restore/Backup Modal\n  restoreBackup: {\n    title: 'Restaurer Sauvegarde',\n    restoring: 'Restauration...',\n    restoreComplete: 'Restauration terminée',\n    restoreFailed: 'Échec restauration',\n    importSettings: 'Importer les réglages d\\'un fichier',\n    pleaseWait: 'Patientez pendant la restauration',\n    clickToSelect: 'Fichier .json ou .zip',\n    howDuplicateHandling: 'Gestion des doublons :',\n    categories: {\n      printers: 'Imprimantes',\n      smartPlugs: 'Prises',\n      notificationProviders: 'Fournisseurs',\n      filaments: 'Filaments',\n      archives: 'Archives',\n      pendingUploads: 'Téléversements en attente',\n      settingsTemplates: 'Réglages & Modèles',\n    },\n    matchingInfo: {\n      printers: 'par numéro de série',\n      smartPlugs: 'par adresse IP',\n      notificationProviders: 'par nom',\n      filaments: 'par nom+type+marque',\n      archives: 'par empreinte numérique (hash)',\n      pendingUploads: 'par nom de fichier',\n      settingsTemplates: 'toujours écrasés',\n    },\n    replaceExisting: 'Remplacer existant',\n    keepExisting: 'Garder existant',\n    replaceDescription: 'Écrase les doublons avec la sauvegarde',\n    keepDescription: 'Ne restaure que les éléments absents',\n    caution: 'Attention :',\n    cautionText: 'L\\'écrasement remplacera vos réglages. Les codes d\\'accès imprimantes sont exclus par sécurité.',\n    itemsRestored: 'Éléments restaurés',\n    itemsSkipped: 'Éléments ignorés',\n    restored: 'Restaurés',\n    skipped: 'Ignorés (déjà présents)',\n    filesLabel: 'Fichiers (3MF, vignettes, etc.)',\n    newApiKeysGenerated: 'Nouvelles clés API générées',\n    newApiKeysWarning: 'Copiées maintenant, elles ne seront plus visibles !',\n    processingBackup: 'Traitement du fichier...',\n    noDataFound: 'Aucune donnée trouvée dans le fichier.',\n    failedToRestore: 'Échec restaure. Vérifiez le format.',\n  },\n\n  // Backup Export Modal\n  backupExport: {\n    title: 'Exporter Sauvegarde',\n    selectData: 'Données à inclure',\n    selectAll: 'Tout sélectionner',\n    selectNone: 'Ne rien sélectionner',\n    categoryDescriptions: {\n      settings: 'Langue, thèmes, préférences',\n      notifications: 'ntfy, Pushover, Discord, etc.',\n      templates: 'Modèles de messages personnalisés',\n      smartPlugs: 'Configuration des prises Tasmota',\n      externalLinks: 'Liens externes de la barre latérale',\n      printers: 'Infos imprimantes (codes d\\'accès exclus par défaut)',\n      plateDetection: 'Images références des plateaux vides',\n      filaments: 'Types et coûts filaments',\n      maintenance: 'Plannings de maintenance personnalisés',\n      archives: 'Données impressions + fichiers (3MF, vignettes, etc.)',\n      projects: 'Projets, BOM, pièces jointes',\n      pendingUploads: 'En attente revue virtuelle',\n      apiKeys: 'Clés Webhook (nouvelles clés générées à l\\'import)',\n    },\n    requiresPrinters: 'Nécessite sélection des imprimantes',\n    zipFileWarning: 'Fichier ZIP créé.',\n    zipFileDescription: 'Contient tous les médias. Peut être volumineux.',\n    includeAccessCodes: 'Inclure Codes d\\'accès',\n    includeAccessCodesDescription: 'Pour migrer vers une autre machine',\n    includeAccessCodesWarning: 'Codes en texte clair. Sécurisez ce fichier !',\n    categoriesSelected: '{{selectedCount}} catégories choisies',\n  },\n\n  // Pending Uploads Panel\n  pendingUploads: {\n    placeholders: {\n      notes: 'Notes sur l\\'impression...',\n    },\n    discardUpload: 'Rejeter',\n    archiveAllUploads: 'Tout archiver',\n    discardAllUploads: 'Tout rejeter',\n    archive: 'Archiver',\n    timeAgo: {\n      justNow: 'À l\\'instant',\n      minutesAgo: 'Il y a {{minutes}}m',\n      hoursAgo: 'Il y a {{hours}}h',\n      daysAgo: 'Il y a {{days}}j',\n    },\n  },\n\n  // API Browser\n  apiBrowser: {\n    placeholders: {\n      requestBody: 'Corps JSON...',\n      searchEndpoints: 'Chercher endpoints...',\n    },\n  },\n\n  // Configure AMS Slot Modal\n  configureAmsSlot: {\n    title: 'Configurer le slot AMS',\n    slotConfigured: 'Slot configuré !',\n    configuringSlot: 'Configuration du slot :',\n    slotLabel: '{{ams}} Slot {{slot}}',\n    searchPresets: 'Chercher presets...',\n    colorPlaceholder: 'Nom couleur ou hex (ex: brown, FF8800)',\n    clearCustomColor: 'Effacer couleur perso',\n    noCloudPresets: 'Profils Cloud absents. Connectez-vous.',\n    noPresetsAvailable: 'Aucun preset disponible. Connectez-vous à Bambu Cloud ou importez des profils locaux.',\n    noMatchingPresets: 'Aucun profil trouvé.',\n    custom: 'Perso',\n    builtin: 'Inclus',\n    settingsSentToPrinter: 'Réglages envoyés',\n    filamentProfile: 'Profil Filament',\n    kProfileLabel: 'Profil K (Pressure Advance)',\n    filteringFor: 'Filtrage pour : {{material}}',\n    noKProfile: 'Pas de profil K (utiliser défaut 0.020)',\n    noMatchingKProfiles: 'Aucun profil K trouvé. K=0.020 par défaut sera utilisé.',\n    selectFilamentFirst: 'Sélectionnez d\\'abord un profil filament',\n    kFromCalibration: 'K={{value}} de la calibration imprimante',\n    customColorLabel: 'Couleur personnalisée (optionnel)',\n    presetColors: 'Couleurs {{name}} :',\n    showLessColors: 'Moins de couleurs',\n    showMoreColors: 'Plus de couleurs',\n    clear: 'Effacer',\n    hexLabel: 'Hex : #{{hex}}',\n    resetting: 'Réinitialisation...',\n    resetSlot: 'Réinitialiser le slot',\n    cancel: 'Annuler',\n    configuring: 'Configuration...',\n    configureSlot: 'Configurer le slot',\n  },\n\n  // GitHub Backup Settings\n  githubBackup: {\n    title: 'Sauvegarde GitHub',\n    history: 'Historique',\n    downloadBackup: 'Télécharger',\n    restoreBackup: 'Restaurer',\n    noBackupsYet: 'Aucune sauvegarde',\n  },\n\n  // Email Settings\n  emailSettings: {\n    placeholders: {\n      fromName: 'BamBuddy',\n    },\n  },\n\n  // Tag Management Modal\n  tagManagement: {\n    searchTags: 'Chercher tags...',\n    renameTag: 'Renommer tag',\n    deleteTag: 'Supprimer tag',\n  },\n\n  // Notification Template Editor\n  notificationTemplates: {\n    placeholders: {\n      title: 'Titre notification...',\n      body: 'Message notification...',\n    },\n  },\n\n  // Batch Tag Modal\n  batchTag: {\n    placeholders: {\n      newTag: 'Nouveau tag...',\n    },\n  },\n\n  // Photo Gallery Modal\n  photoGallery: {\n    deletePhoto: 'Supprimer photo',\n  },\n\n  // Filament Hover Card\n  filamentHoverCard: {\n    copySpoolUuid: 'Copier UUID bobine',\n  },\n\n  // K Profiles View\n  kProfilesView: {\n    hasNote: 'A une note',\n    copyProfile: 'Copier profil',\n  },\n\n  // Layout/Navigation\n  layout: {\n    openMenu: 'Ouvrir menu',\n    noPermissionSystemInfo: 'Pas d\\'autorisation système',\n  },\n\n  // Dashboard\n  dashboard: {\n    dragToReorder: 'Glisser pour réorganiser',\n    hideWidget: 'Masquer widget',\n  },\n\n  // Notification Provider Card\n  notificationProviderCard: {\n    deleteNotificationProvider: 'Supprimer fournisseur',\n  },\n\n  // File Manager Modal\n  fileManagerModal: {\n    closeFileManager: 'Fermer gestionnaire',\n    sortFiles: 'Trier fichiers',\n    goToParentFolder: 'Dossier parent',\n    threeView: 'Vue 3D',\n  },\n\n  // Embedded Camera Viewer\n  embeddedCameraViewer: {\n    refreshStream: 'Actualiser flux',\n    close: 'Fermer',\n    zoomOut: 'Zoom -',\n    resetZoom: 'Reset zoom',\n    zoomIn: 'Zoom +',\n    dragToResize: 'Glisser pour dimension',\n  },\n\n  // Timelapse Viewer\n  timelapseViewer: {\n    skipBack5s: '-5s',\n    skipForward5s: '+5s',\n  },\n\n  // Notification Providers\n  notificationProviders: {\n    descriptions: {\n      email: 'Notifications par email SMTP',\n      telegram: 'Via bot Telegram',\n      discord: 'Via webhook Discord',\n      ntfy: 'Push auto-hébergé (ntfy)',\n      pushover: 'Push fiable (Pushover)',\n      callmebot: 'WhatsApp gratuit via CallMeBot',\n      webhook: 'Requête HTTP POST personnalisée',\n    },\n  },\n\n  // Log Viewer\n  logViewer: {\n    searchPlaceholder: 'Message ou nom...',\n    noLogEntries: 'Aucune entrée journal',\n  },\n\n  // Switchbar Popover\n  switchbarPopover: {\n    noSwitchesInSwitchbar: 'Aucun interrupteur',\n  },\n\n  // Project Page Modal\n  projectPageModal: {\n    placeholders: {\n      title: 'Titre',\n      designer: 'Designer',\n      license: 'Licence',\n      description: 'Description...',\n      profileTitle: 'Titre du profil',\n      profileDescription: 'Description du profil...',\n    },\n  },\n\n  // Spoolman Settings\n  spoolmanSettings: {},\n\n  // Time\n  time: {\n    unknown: '-',\n    waiting: 'En attente',\n    justNow: 'À l\\'instant',\n    now: 'Maintenant',\n    minsAgo: 'il y a {{count}}m',\n    inMins: 'dans {{count}}m',\n    hoursAgo: 'il y a {{count}}h',\n    inHours: 'dans {{count}}h',\n    daysAgo: 'il y a {{count}}j',\n    inDays: 'dans {{count}}j',\n  },\n\n  // SpoolBuddy Kiosk\n  spoolbuddy: {\n    nav: {\n      dashboard: 'Tableau de bord',\n      ams: 'AMS',\n      inventory: 'Inventaire',\n      writeTag: 'Écrire',\n      settings: 'Paramètres',\n    },\n    status: {\n      nfcReady: 'NFC prêt',\n      nfcOff: 'NFC désactivé',\n      offline: 'Hors ligne',\n      online: 'En ligne',\n      noPrinters: 'Aucune imprimante',\n      deviceOffline: 'Appareil hors ligne',\n      waitingConnection: 'En attente de connexion...',\n      systemReady: 'Système prêt',\n      status: 'Statut',\n    },\n    dashboard: {\n      readyToScan: 'Prêt à scanner',\n      idleMessage: 'Placez une bobine sur la balance pour l\\'identifier',\n      nfcHint: 'Le tag NFC sera lu automatiquement',\n      device: 'Appareil',\n      syncWeight: 'Sync. poids',\n      weightSynced: 'Synchronisé !',\n      unknownTag: 'Tag inconnu',\n      newTag: 'Nouveau tag détecté',\n      onScale: 'sur la balance',\n      linkSpool: 'Lier à une bobine',\n      linkTagTitle: 'Lier le tag à une bobine',\n      linkTag: 'Lier le tag',\n      selectSpool: 'Sélectionnez une bobine à lier à ce tag :',\n      noUntagged: 'Aucune bobine sans tag trouvée',\n      tagDetected: 'Tag détecté',\n      noTag: 'Pas de tag',\n      tagId: 'Tag',\n      grossWeight: 'Poids brut',\n      spoolSize: 'Taille bobine',\n      close: 'Fermer',\n      currentSpool: 'Bobine actuelle',\n    },\n    modal: {\n      spoolDetected: 'Bobine détectée',\n      assignToAms: 'Assigner à l\\'AMS',\n      syncWeight: 'Synchroniser le poids',\n      weightSynced: 'Synchronisé !',\n      syncing: 'Synchronisation...',\n      newTagDetected: 'Nouveau tag détecté',\n      addToInventory: 'Ajouter à l\\'inventaire',\n      assignToAmsTitle: 'Assigner à l\\'AMS',\n      selectSlot: 'Sélectionner un emplacement',\n      assign: 'Assigner',\n      assigning: 'Attribution...',\n      assignSuccess: 'Assigné !',\n      assignError: 'Échec de l\\'attribution de la bobine. Veuillez réessayer.',\n      noPrinterSelected: 'Sélectionner une imprimante...',\n      noAmsDetected: 'Aucun AMS détecté sur cette imprimante',\n      slot: 'Emplacement',\n    },\n    weight: {\n      noReading: 'Pas de lecture',\n      stable: 'Stable',\n      measuring: 'Mesure...',\n      tare: 'Tarer',\n      calibrate: 'Calibrer',\n    },\n    spool: {\n      remaining: 'Restant',\n      material: 'Matériau',\n      brand: 'Marque',\n      color: 'Couleur',\n      coreWeight: 'Noyau',\n      labelWeight: 'Étiquette',\n      scaleWeight: 'Balance',\n      netWeight: 'Net',\n      lastUsed: 'Dernière utilisation',\n    },\n    ams: {\n      noData: 'Aucun AMS détecté',\n      connectAms: 'Connectez un AMS pour voir les slots',\n      noPrinter: 'Aucune imprimante sélectionnée',\n      selectPrinter: 'Sélectionnez une imprimante dans la barre supérieure',\n      printerDisconnected: 'Imprimante déconnectée',\n      humidity: 'Humidité',\n      level: 'Niveau',\n      active: 'Actif',\n      slot: 'Slot',\n      empty: 'Vide',\n    },\n    inventory: {\n      search: 'Rechercher des bobines...',\n      empty: 'Aucune bobine dans l\\'inventaire',\n      noResults: 'Aucune bobine correspondante',\n      spools: 'bobines',\n      addSpool: 'Ajouter une bobine',\n    },\n    settings: {\n      // Tabs\n      tabDevice: 'Appareil',\n      tabDisplay: 'Affichage',\n      tabScale: 'Balance',\n      tabUpdates: 'Mises à jour',\n      // Device tab\n      nfcReader: 'Lecteur NFC',\n      type: 'Type',\n      connection: 'Connexion',\n      notConnected: 'N/A',\n      deviceInfo: 'Info appareil',\n      hostname: 'Hôte',\n      uptime: 'Temps de fonctionnement',\n      // Display tab\n      brightness: 'Luminosité',\n      saved: 'Enregistré',\n      noBacklight: 'Aucun rétroéclairage DSI détecté. Le contrôle de luminosité nécessite un écran DSI.',\n      screenBlank: 'Délai d\\'extinction',\n      screenBlankDesc: 'L\\'écran s\\'éteint après inactivité. Touchez pour réveiller.',\n      displayNote: 'La luminosité est appliquée comme filtre logiciel.',\n      // Scale tab\n      scaleCalibration: 'Calibration de la balance',\n      currentWeight: 'Poids actuel',\n      tareOffset: 'Tare',\n      calFactor: 'Facteur',\n      knownWeight: 'Poids connu',\n      calStep1: 'Retirez tout de la balance et appuyez sur Mettre à zéro.',\n      calStep2: 'Placez le poids connu sur la balance.',\n      setZero: 'Mettre à zéro',\n      calibrateNow: 'Calibrer',\n      calibrated: 'Calibré',\n      tareSet: 'Commande de tare envoyée. En attente de l\\'appareil...',\n      tareFailed: 'Échec de l\\'envoi de la commande de tare',\n      zeroSet: 'Point zéro défini. Placez le poids connu sur la balance.',\n      calibrationDone: 'Calibration terminée !',\n      calibrationFailed: 'Échec de la calibration',\n      lastCalibrated: 'Dernière calibration',\n      stable: 'Stable',\n      settling: 'Stabilisation...',\n      firmware: 'Firmware',\n      scale: 'Balance',\n      noDevice: 'Aucun appareil SpoolBuddy trouvé',\n      // Updates tab\n      daemonVersion: 'Version du daemon',\n      currentVersion: 'Actuelle',\n      versionPending: 'En attente du daemon...',\n      checking: 'Vérification...',\n      checkUpdates: 'Vérifier les mises à jour',\n      updateAvailable: 'Mise à jour disponible',\n      updateInstructions: 'Mise à jour via SSH : exécutez le script d\\'installation SpoolBuddy.',\n      upToDate: 'À jour',\n      includeBeta: 'Inclure les versions bêta',\n    },\n    writeTag: {\n      tabExisting: 'Bobine existante',\n      tabNew: 'Nouvelle bobine',\n      tabReplace: 'Remplacer le tag',\n      searchPlaceholder: 'Rechercher par matériau, couleur, marque...',\n      noUntaggedSpools: 'Aucune bobine sans tag',\n      noTaggedSpools: 'Aucune bobine avec tag',\n      selectSpool: 'Sélectionnez une bobine, puis placez un NTAG sur le lecteur',\n      placeTag: 'Placez un NTAG sur le lecteur',\n      tagReady: 'Tag détecté — prêt à écrire',\n      writeTag: 'Écrire le tag',\n      replaceTag: 'Remplacer le tag',\n      writing: 'Écriture du tag...',\n      waiting: 'En attente de SpoolBuddy...',\n      writeSuccess: 'Tag écrit avec succès !',\n      writeFailed: 'Échec de l\\'écriture',\n      queueFailed: 'Impossible de mettre en file la commande d\\'écriture',\n      tryAgain: 'Réessayer',\n      cancel: 'Annuler',\n      replaceWarning: 'L\\'ancien tag sera dissocié. Le nouveau tag le remplacera.',\n      deviceOffline: 'SpoolBuddy est hors ligne',\n      material: 'Matériau',\n      colorName: 'Nom de la couleur',\n      color: 'Couleur',\n      brand: 'Marque',\n      weight: 'Poids (g)',\n      createSpool: 'Créer la bobine',\n      creating: 'Création...',\n      spoolCreated: 'Bobine créée ! Prêt à écrire.',\n      createFailed: 'Impossible de créer la bobine',\n    },\n    quickMenu: {\n      printerPower: 'Alimentation imprimante',\n      systemControls: 'Système',\n      restartDaemon: 'Redémarrer le daemon',\n      restartBrowser: 'Redémarrer le navigateur',\n      reboot: 'Redémarrer',\n      shutdown: 'Éteindre',\n      swipeToClose: 'Glisser vers le bas pour fermer',\n      confirmTitle: 'Confirmer',\n      confirmShutdown: 'Êtes-vous sûr de vouloir éteindre le SpoolBuddy ? Vous aurez besoin d\\'un accès physique pour le rallumer.',\n      confirmReboot: 'Êtes-vous sûr de vouloir redémarrer le SpoolBuddy ?',\n      confirmRestartDaemon: 'Redémarrer le daemon SpoolBuddy ? Le NFC et la balance seront temporairement indisponibles.',\n      confirmRestartBrowser: 'Redémarrer le navigateur kiosque ? L\\'écran sera brièvement noir.',\n      confirm: 'Confirmer',\n      confirmPlugOn: 'Allumer {{name}} ?',\n      confirmPlugOff: 'Éteindre {{name}} ?',\n      turnOn: 'Allumer',\n      turnOff: 'Éteindre',\n    },\n  },\n\n  bugReport: {\n    title: 'Signaler un bug',\n    description: 'Description',\n    descriptionPlaceholder: 'Qu\\'est-ce qui n\\'a pas fonctionné ? Veuillez décrire le problème...',\n    email: 'E-mail (optionnel)',\n    emailPlaceholder: 'votre@email.fr',\n    emailPrivacy: 'Si fourni, votre e-mail sera inclus dans une section repliée de l\\'issue GitHub pour que le mainteneur puisse vous contacter.',\n    screenshot: 'Capture d\\'écran',\n    uploadOrPaste: 'Télécharger, coller ou glisser une image',\n    dataCollectedSummary: 'Quelles données sont incluses dans le rapport ?',\n    dataIncluded: 'Inclus :',\n    dataIncludedList: 'Version de l\\'app, OS, architecture, version Python, statistiques de base de données (compteurs uniquement), modèles d\\'imprimantes, nombre de buses, versions firmware, état de connexion, état des intégrations (Spoolman, MQTT, HA), paramètres non sensibles, nombre d\\'interfaces réseau, détails Docker, versions des dépendances.',\n    dataNeverIncluded: 'Jamais inclus :',\n    dataNeverIncludedList: 'Noms d\\'imprimantes, numéros de série, codes d\\'accès, mots de passe, adresses IP, adresses e-mail, clés API, tokens, URLs de webhook, noms d\\'hôtes ou noms d\\'utilisateurs.',\n    submit: 'Envoyer',\n    startLogging: 'Lancer la journalisation',\n    stepEnableLogging: 'Journalisation activée',\n    stepReproduce: 'Reproduisez le problème',\n    stepStopLogging: 'Arrêter & envoyer',\n    stopAndSubmit: 'Arrêter & Envoyer',\n    maxDuration: 'Arrêt auto après {{minutes}} min',\n    stoppingLogs: 'Collecte des journaux & envoi...',\n    submitting: 'Envoi du rapport de bug...',\n    submitSuccess: 'Rapport de bug envoyé avec succès !',\n    submitFailed: 'Échec de l\\'envoi du rapport de bug',\n    thankYou: 'Merci !',\n    submitted: 'Votre rapport de bug a été soumis.',\n    viewIssue: 'Voir l\\'issue',\n    unexpectedError: 'Une erreur inattendue est survenue',\n  },\n  failureDetection: {\n    title: 'Détection d\\'échec par IA',\n    description: 'Surveille les impressions via une API ML Obico auto-hébergée et agit automatiquement sur les échecs détectés.',\n    mlUrl: 'URL de l\\'API ML Obico',\n    mlUrlHint: 'URL de base de votre conteneur Obico ml_api auto-hébergé (ex. http://192.168.1.10:3333).',\n    test: 'Tester',\n    testSuccess: 'API ML accessible et fonctionnelle.',\n    testFailed: 'Impossible d\\'atteindre l\\'API ML.',\n    sensitivity: 'Sensibilité',\n    sensitivityLow: 'Basse (moins de faux positifs)',\n    sensitivityMedium: 'Moyenne (équilibrée)',\n    sensitivityHigh: 'Haute (détection précoce, plus de faux positifs)',\n    sensitivityHint: 'Ajuste les seuils de confiance qui déclenchent les avertissements et les échecs.',\n    action: 'Action sur échec détecté',\n    actionNotify: 'Notifier uniquement',\n    actionPause: 'Mettre en pause',\n    actionPauseOff: 'Pause et couper l\\'alimentation',\n    pollInterval: 'Intervalle de vérification (secondes)',\n    pollIntervalHint: 'Fréquence de vérification de chaque imprimante pendant l\\'impression. Minimum 5s, maximum 120s.',\n    externalUrlMissing: 'External URL is not set.',\n    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',\n    perPrinterTitle: 'Imprimantes surveillées',\n    perPrinterHint: 'Choisissez quelles imprimantes le service de détection surveille.',\n    monitorAll: 'Surveiller toutes les imprimantes connectées',\n    statusTitle: 'Statut',\n    serviceRunning: 'Service en cours d\\'exécution',\n    thresholds: 'Seuils bas / haut',\n    activePrinters: 'Impressions actives',\n    noActivePrints: 'Aucune impression en cours.',\n    historyTitle: 'Détections récentes',\n    noHistory: 'Aucune détection pour le moment.',\n  },\n};\n"
  },
  {
    "path": "frontend/src/i18n/locales/it.ts",
    "content": "export default {\n  // Navigation\n  nav: {\n    printers: 'Stampanti',\n    archives: 'Archivi',\n    queue: 'Coda',\n    stats: 'Statistiche',\n    profiles: 'Profili',\n    maintenance: 'Manutenzione',\n    projects: 'Progetti',\n    inventory: 'Filamento',\n    files: 'File',\n    notifications: 'Notifiche',\n    settings: 'Impostazioni',\n    system: 'Sistema',\n    collapseSidebar: 'Comprimi barra laterale',\n    expandSidebar: 'Espandi barra laterale',\n    update: 'Aggiorna',\n    updateAvailable: 'Aggiornamento disponibile: v{{version}}',\n    updateAvailableBanner: 'Versione {{version}} disponibile!',\n    viewUpdate: 'Vedi aggiornamento',\n    viewOnGithub: 'Vedi su GitHub',\n    keyboardShortcuts: 'Scorciatoie da tastiera (?)',\n    switchToLight: 'Passa a tema chiaro',\n    switchToDark: 'Passa a tema scuro',\n    smartSwitches: 'Interruttori Smart',\n    logout: 'Esci',\n  },\n\n  // Common\n  common: {\n    save: 'Salva',\n    saving: 'Salvataggio...',\n    cancel: 'Annulla',\n    delete: 'Elimina',\n    edit: 'Modifica',\n    add: 'Aggiungi',\n    close: 'Chiudi',\n    confirm: 'Conferma',\n    loading: 'Caricamento...',\n    error: 'Errore',\n    success: 'Successo',\n    warning: 'Avviso',\n    enabled: 'Abilitato',\n    disabled: 'Disabilitato',\n    yes: 'Si',\n    no: 'No',\n    on: 'On',\n    off: 'Off',\n    all: 'Tutti',\n    none: 'Nessuno',\n    search: 'Cerca',\n    filter: 'Filtro',\n    sort: 'Ordina',\n    refresh: 'Aggiorna',\n    download: 'Scarica',\n    upload: 'Carica',\n    uploading: 'Caricamento...',\n    uploadFailed: 'Caricamento fallito',\n    actions: 'Azioni',\n    status: 'Stato',\n    name: 'Nome',\n    description: 'Descrizione',\n    date: 'Data',\n    time: 'Ora',\n    hours: 'ore',\n    minutes: 'minuti',\n    seconds: 'secondi',\n    days: 'giorni',\n    enable: 'Abilita',\n    disable: 'Disabilita',\n    permissions: 'Permessi',\n    noPrinters: 'Nessuna stampante configurata',\n    noData: 'Nessun dato disponibile',\n    linkNotFound: 'Link non trovato',\n    required: 'Obbligatorio',\n    optional: 'Opzionale',\n    dismiss: 'Chiudi',\n    apply: 'Applica',\n    reset: 'Reimposta',\n    export: 'Esporta',\n    import: 'Importa',\n    clear: 'Pulisci',\n    selectAll: 'Seleziona tutto',\n    deselectAll: 'Deseleziona tutto',\n    noChange: '— Nessun cambio —',\n    unchanged: 'Invariato',\n    unassigned: 'Non assegnato',\n    unknown: 'Sconosciuto',\n    unknownError: 'Errore sconosciuto',\n    today: 'Oggi',\n    tomorrow: 'Domani',\n    asap: 'ASAP',\n    overdue: 'Scaduto',\n    now: 'Ora',\n    collapse: 'Comprimi',\n    expand: 'Espandi',\n    viewArchive: 'Vedi archivio',\n    viewInFileManager: 'Vedi nel Gestore file',\n    addedBy: 'Aggiunto da {{username}}',\n    prints: 'stampe',\n    more: '+{{count}} altre',\n    ascending: 'Crescente',\n    descending: 'Decrescente',\n    back: 'Indietro',\n    copy: 'Copia',\n    copied: 'Copiato!',\n    printer: 'Stampante',\n    remove: 'Rimuovi',\n    type: 'Tipo',\n    print: 'Stampa',\n    rename: 'Rinomina',\n    move: 'Sposta',\n    create: 'Crea',\n    duplicate: 'Duplica',\n    left: 'Sinistra',\n    right: 'Destra',\n  },\n\n  // Printers page\n  printers: {\n    title: 'Stampanti',\n    addPrinter: 'Aggiungi Stampante',\n    editPrinter: 'Modifica Stampante',\n    deletePrinter: 'Elimina Stampante',\n    printerName: 'Nome Stampante',\n    serialNumber: 'Numero Seriale',\n    ipAddress: 'Indirizzo IP',\n    accessCode: 'Codice di Accesso',\n    model: 'Modello',\n    nozzleCount: 'Numero Ugelli',\n    autoArchive: 'Auto Archiviazione',\n    status: {\n      available: 'Disponibile',\n      idle: 'Inattiva',\n      printing: 'In stampa',\n      paused: 'In pausa',\n      offline: 'Offline',\n      problem: 'Problema',\n      error: 'Errore',\n      finished: 'Finita',\n      unknown: 'Sconosciuto',\n    },\n    temperatures: {\n      nozzle: 'Ugello',\n      bed: 'Piatto',\n      chamber: 'Camera',\n    },\n    progress: '{{percent}}% completato',\n    timeRemaining: '{{time}} rimanente',\n    deleteConfirm: 'Sei sicuro di eliminare \"{{name}}\"?',\n    maintenanceOk: 'Manutenzione OK',\n    maintenanceWarning: '{{count}} avviso',\n    maintenanceWarning_plural: '{{count}} avvisi',\n    maintenanceDue: '{{count}} in scadenza',\n    maintenanceDue_plural: '{{count}} in scadenza',\n    // Sort options\n    sort: {\n      name: 'Nome',\n      status: 'Stato',\n      model: 'Modello',\n      location: 'Posizione',\n      ascending: 'Ordina crescente',\n      descending: 'Ordina decrescente',\n    },\n    // Card size\n    cardSize: {\n      small: 'Schede piccole',\n      medium: 'Schede medie',\n      large: 'Schede grandi',\n      extraLarge: 'Schede extra grandi',\n    },\n    // Controls\n    hideOffline: 'Nascondi offline',\n    nextAvailable: 'Prossima disponibile',\n    powerOn: 'Accendi',\n    offlinePrintersWithPlugs: 'Stampanti offline con smart plug',\n    noPrintersConfigured: 'Nessuna stampante configurata',\n    search: 'Cerca stampanti...',\n    noSearchResults: 'Nessuna stampante corrisponde alla tua ricerca o ai tuoi filtri',\n    filter: {\n      allStatuses: 'Tutti gli stati',\n      allLocations: 'Tutti i luoghi',\n    },\n    // Printer card\n    readyToPrint: 'Pronta a stampare',\n    external: 'Esterna',\n    extL: 'Ext-L',\n    extR: 'Ext-R',\n    deleteArchives: 'Elimina archivi stampa',\n    noLabel: 'Nessuna etichetta',\n    printPreview: 'Anteprima stampa',\n    width: 'Larghezza',\n    height: 'Altezza',\n    noObjectsFound: 'Nessun oggetto trovato',\n    objectsLoadedOnPrintStart: 'Gli oggetti sono caricati quando inizia una stampa',\n    willBeSkipped: 'Verra saltato',\n    name: 'Nome',\n    serialCannotBeChanged: 'Il numero seriale non può essere cambiato',\n    locationHelp: 'Usato per raggruppare stampanti e filtrare i lavori in coda',\n    // WiFi signal strength\n    wifiSignal: {\n      veryWeak: 'Molto debole',\n      weak: 'Debole',\n      fair: 'Discreto',\n      good: 'Buono',\n      excellent: 'Eccellente',\n    },\n    // Maintenance\n    maintenanceUpToDate: 'Tutta la manutenzione aggiornata - Clicca per vedere',\n    // Chamber light\n    chamberLightOn: 'Accendi luce camera',\n    chamberLightOff: 'Spegni luce camera',\n    // Files\n    files: 'File',\n    browseFiles: 'Sfoglia file stampante',\n    // Smart plug\n    autoOffAfterPrint: 'Spegnimento automatico dopo stampa',\n    autoOffExecuted: 'Spegnimento automatico eseguito - accendi la stampante per reimpostare',\n    // HMS errors\n    hmsErrors: 'Errori HMS',\n    viewHmsErrors: 'Vedi {{count}} errore(i) HMS',\n    // Actions\n    resume: 'Riprendi',\n    pause: 'Pausa',\n    stop: 'Ferma',\n    camera: 'Camera',\n    skipObject: 'Salta Oggetto',\n    reconnect: 'Riconnetti',\n    forceRefresh: 'Forza aggiornamento',\n    forceRefreshSuccess: 'Aggiornamento richiesto',\n    mqttDebug: 'Debug MQTT',\n    printerInformation: 'Informazioni stampante',\n    copyToClipboard: 'Copia',\n    copied: 'Copiato!',\n    state: 'Stato',\n    wifiSignalLabel: 'Segnale WiFi',\n    developerMode: 'Modalità sviluppatore',\n    enabled: 'Attivato',\n    disabled: 'Disattivato',\n    addedOn: 'Aggiunta il',\n    sdCard: 'Scheda SD',\n    inserted: 'Inserita',\n    notInserted: 'Non inserita',\n    totalPrintHours: 'Ore di stampa',\n    activeNozzle: 'Attivo: ugello {{nozzle}}',\n    nozzleRack: 'Rack Ugelli',\n    nozzleDocked: 'Agganciato',\n    nozzleMounted: 'Montato',\n    nozzleActive: 'Attivo',\n    nozzleIdle: 'Inattivo',\n    nozzleDiameter: 'Diametro',\n    nozzleType: 'Tipo',\n    nozzleStatus: 'Stato',\n    nozzleFilament: 'Filamento',\n    nozzleWear: 'Usura',\n    nozzleMaxTemp: 'Temp Max',\n    nozzleSerial: 'Seriale',\n    nozzleHardenedSteel: 'Acciaio Temprato',\n    nozzleStainlessSteel: 'Acciaio Inox',\n    nozzleTungstenCarbide: 'Carburo di Tungsteno',\n    nozzleFlow: 'Flusso',\n    nozzleHighFlow: 'Alto Flusso',\n    nozzleStandardFlow: 'Standard',\n    // Firmware\n    firmwareUpdate: 'Aggiornamento Firmware',\n    firmwareInstructions: 'Sul touchscreen della stampante, vai a',\n    firmwareNav: 'Vai a',\n    settings: 'Impostazioni',\n    firmware: 'Firmware',\n    // Discovery\n    discoverPrinters: 'Trova Stampanti',\n    searching: 'Ricerca...',\n    manualEntry: 'Inserimento manuale',\n    addFromCloud: 'Aggiungi da Cloud',\n    // Toast messages\n    toast: {\n      printerDeleted: 'Stampante eliminata',\n      missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',\n      printerAdded: 'Stampante aggiunta',\n      printerUpdated: 'Stampante aggiornata',\n      failedToDelete: 'Impossibile eliminare stampante',\n      failedToAdd: 'Impossibile aggiungere stampante',\n      failedToUpdate: 'Impossibile aggiornare stampante',\n      commandSent: 'Comando inviato',\n      failedToSendCommand: 'Impossibile inviare comando',\n      turnedOn: '{{name}} accesa',\n      failedToPowerOn: 'Impossibile accendere {{name}}',\n      scriptTriggered: 'Script avviato',\n      printStopped: 'Stampa fermata',\n      printPaused: 'Stampa in pausa',\n      printResumed: 'Stampa ripresa',\n      referenceDeleted: 'Riferimento eliminato',\n      detectionAreaSaved: 'Area rilevamento salvata',\n      failedToRunScript: 'Impossibile eseguire script',\n      failedToStopPrint: 'Impossibile fermare stampa',\n      failedToPausePrint: 'Impossibile mettere in pausa stampa',\n      failedToResumePrint: 'Impossibile riprendere stampa',\n      failedToControlChamberLight: 'Impossibile controllare luce camera',\n      failedToSetSpeed: 'Impossibile impostare la velocità di stampa',\n      failedToUpdateSetting: 'Impossibile aggiornare impostazione',\n      failedToSkipObjects: 'Impossibile saltare oggetti',\n      failedToRereadRfid: 'Impossibile rileggere RFID',\n      failedToCheckPlate: 'Impossibile controllare piatto',\n      failedToUpdateLabel: 'Impossibile aggiornare etichetta',\n      failedToDeleteReference: 'Impossibile eliminare riferimento',\n      failedToSaveDetectionArea: 'Impossibile salvare area rilevamento',\n      plateCheckEnabled: 'Controllo piatto abilitato',\n      plateCheckDisabled: 'Controllo piatto disabilitato',\n      calibrationSaved: 'Calibrazione salvata!',\n      calibrationFailed: 'Calibrazione non riuscita',\n      rfidRereadInitiated: 'Rilettura RFID avviata',\n    },\n    // Connection status\n    connection: {\n      connected: 'Connesso',\n      offline: 'Offline',\n    },\n    plateStatus: {\n      markCleared: 'Segna il piatto come liberato',\n      cleared: 'Piatto libero',\n      notCleared: 'Piatto non libero',\n      inUse: 'Piatto in uso',\n    },\n    // Queue info\n    queue: {\n      inQueue: '{{count}} stampa in coda',\n      inQueue_plural: '{{count}} stampe in coda',\n    },\n    // Controls section\n    controls: 'Controlli',\n    // RFID\n    rfid: {\n      reread: 'Rileggi RFID',\n    },\n    bedJog: {\n      title: 'Muovi il piano di stampa',\n      bed: 'Piano',\n      step: 'Passo (mm)',\n      up: 'Sposta piano su',\n      down: 'Sposta piano giù',\n      disabledWhilePrinting: 'Disabilitato durante la stampa',\n      notHomedTitle: 'Stampante non azzerata',\n      notHomedMessage: 'La stampante non è stata azzerata dall\\'ultima stampa. Esegui prima l\\'azzeramento automatico per un posizionamento sicuro (parcheggia la testa di stampa, poi azzera X, Y e Z), oppure muovi comunque — i finecorsa software verranno ignorati.',\n      homeZ: 'Azzeramento automatico',\n      moveAnyway: 'Muovi comunque',\n      homingStarted: 'Azzeramento automatico in corso…',\n    },\n    // Permissions\n    permission: {\n      noAdd: 'Non hai il permesso di aggiungere stampanti',\n      noEdit: 'Non hai il permesso di modificare stampanti',\n      noDelete: 'Non hai il permesso di eliminare stampanti',\n      noControl: 'Non hai il permesso di controllare stampanti',\n      noFiles: 'Non hai il permesso di accedere ai file stampante',\n      noAmsRfid: 'Non hai il permesso di rileggere AMS RFID',\n      noSmartPlugControl: 'Non hai il permesso di controllare smart plug',\n      noCamera: 'Non hai il permesso di visualizzare le telecamere',\n    },\n    // Add/Edit modal\n    modal: {\n      addTitle: 'Aggiungi Stampante',\n      editTitle: 'Modifica Stampante',\n      myPrinter: 'La mia Stampante',\n      selectModel: 'Seleziona modello...',\n      locationGroup: 'Posizione / Gruppo (opzionale)',\n      locationPlaceholder: 'es. Officina, Ufficio, Cantina',\n      autoArchiveLabel: 'Archivia automaticamente stampe completate',\n      fromPrinterSettings: 'Dalle impostazioni della stampante',\n      modelOptional: 'Modello (opzionale)',\n      saveChanges: 'Salva modifiche',\n    },\n    // Skip objects\n    skipObjects: {\n      tooltip: 'Salta oggetti',\n      onlyWhilePrinting: 'Salta oggetti (solo durante la stampa)',\n      requiresMultiple: 'Salta oggetti (richiede 2+ oggetti)',\n      title: 'Salta Oggetti',\n      matchIdsInfo: 'Abbina gli ID con il display della stampante',\n      printerShowsIds: 'Lo schermo mostra gli ID oggetto sul piatto',\n      skipSelected: 'Salta selezionati',\n      skipping: 'Saltando...',\n      noObjectsSelected: 'Nessun oggetto selezionato',\n      selectObjectsToSkip: 'Seleziona gli oggetti da saltare nella stampa corrente',\n      skipped: 'saltato',\n      objectsSkipped: 'Oggetti saltati',\n      activeCount: '{{count}} attivi',\n      waitForLayer: 'Attendi il layer 2+ per saltare oggetti (attualmente layer {{layer}})',\n      skip: 'Salta',\n      confirmTitle: 'Saltare oggetto?',\n      confirmMessage: 'Sei sicuro di voler saltare \"{{name}}\"? Questa azione non può essere annullata.',\n    },\n    // Confirm modals\n    confirm: {\n      deleteTitle: 'Elimina Stampante',\n      deleteMessage: 'Sei sicuro di eliminare \"{{name}}\"? Questo rimuoverà tutte le impostazioni di connessione.',\n      deleteArchivesNote: 'Tutta la cronologia di stampa sarà eliminata definitivamente.',\n      keepArchivesNote: 'La cronologia sarà mantenuta ma non più associata a questa stampante.',\n      stopTitle: 'Ferma Stampa',\n      stopMessage: 'Sei sicuro di fermare la stampa corrente su \"{{name}}\"? Questo annullerà il lavoro di stampa.',\n      stopButton: 'Ferma Stampa',\n      pauseTitle: 'Pausa Stampa',\n      pauseMessage: 'Sei sicuro di mettere in pausa la stampa corrente su \"{{name}}\"?',\n      pauseButton: 'Pausa Stampa',\n      resumeTitle: 'Riprendi Stampa',\n      resumeMessage: 'Sei sicuro di riprendere la stampa su \"{{name}}\"?',\n      resumeButton: 'Riprendi Stampa',\n      powerOnTitle: 'Accendi Stampante',\n      powerOnMessage: 'Sei sicuro di accendere \"{{name}}\"?',\n      powerOnButton: 'Accendi',\n      powerOffTitle: 'Spegni Stampante',\n      powerOffMessage: 'Sei sicuro di spegnere \"{{name}}\"?',\n      powerOffWarning: 'AVVISO: \"{{name}}\" sta stampando! Sei sicuro di spegnere? Questo interromperà la stampa e potrebbe danneggiare la stampante.',\n      powerOffButton: 'Spegni',\n    },\n    // Bulk actions\n    bulk: {\n      select: 'Seleziona',\n      selectAll: 'Seleziona tutto',\n      selectByLocation: 'Seleziona per posizione',\n      selected: '{{count}} selezionato/i',\n      actions: {\n        stop: 'Ferma',\n        pause: 'Pausa',\n        resume: 'Riprendi',\n        clearPlate: 'Svuota piano',\n        clearHMS: 'Cancella notifiche',\n      },\n      confirm: {\n        stopTitle: 'Ferma {{count}} stampe',\n        stopMessage: 'Questo annullerà le stampe attive su {{count}} stampante/i. Questa azione non può essere annullata.',\n        stopButton: 'Ferma tutte',\n        pauseTitle: 'Pausa {{count}} stampe',\n        pauseMessage: 'Questo metterà in pausa le stampe attive su {{count}} stampante/i.',\n        pauseButton: 'Pausa tutte',\n        clearPlateTitle: 'Svuota {{count}} piani di stampa',\n        clearPlateMessage: 'Questo svuoterà il piano di stampa su {{count}} stampante/i e potrebbe avviare i lavori in coda.',\n        clearPlateButton: 'Svuota tutti',\n      },\n      success: '{{action}} completato su {{count}} stampante/i',\n      partial: '{{succeeded}} riuscito/i, {{failed}} fallito/i',\n      noneApplicable: 'Nessuna stampante selezionata è nello stato corretto per questa azione',\n      selectByState: 'Seleziona per stato',\n    },\n    // Discovery\n    discovery: {\n      title: 'Trova Stampanti',\n      searching: 'Ricerca...',\n      scanning: 'Scansione...',\n      scanProgress: 'Scansione... {{scanned}}/{{total}}',\n      foundPrinters: 'Trovate {{count}} stampante(i)',\n      noPrintersFound: 'Nessuna stampante trovata',\n      noPrintersFoundSubnet: 'Nessuna stampante trovata nella sottorete specificata.',\n      noPrintersFoundNetwork: 'Nessuna stampante trovata sulla rete.',\n      allConfigured: 'Tutte le stampanti trovate sono già configurate.',\n      alreadyAdded: 'Già aggiunta',\n      select: 'Seleziona',\n      manualEntry: 'Inserimento manuale',\n      addFromCloud: 'Aggiungi da Cloud',\n      subnetToScan: 'Sottorete da scansionare',\n      dockerNote: 'Docker rilevato. Inserisci la sottorete della stampante in notazione CIDR. Richiede network_mode: host in docker-compose.yml.',\n      scanSubnet: 'Scansiona sottorete per stampanti',\n      discoverNetwork: 'Trova stampanti in rete',\n      scanningSubnet: 'Scansione sottorete per stampanti Bambu...',\n      scanningNetwork: 'Scansione rete...',\n      serialRequired: 'Seriale richiesto',\n      unknown: 'Sconosciuto',\n      failedToStart: 'Avvio ricerca non riuscito',\n    },\n    // AMS Drying\n    drying: {\n      start: 'Avvia essiccazione',\n      stop: 'Ferma essiccazione',\n      temperature: 'Temperatura',\n      duration: 'Durata',\n      hours: 'ore',\n      timeRemaining: '{{time}} rimanente',\n      active: 'Essiccazione',\n      notSupported: 'Essiccazione non supportata',\n      powerRequired: 'Collegare l\\'alimentatore AMS per abilitare l\\'asciugatura',\n      startingDrying: 'Avvio essiccazione...',\n      stoppingDrying: 'Arresto essiccazione...',\n      rotateTray: 'Ruota la bobina durante l\\'essiccazione',\n    },\n    // Filaments section\n    filaments: 'Filamenti',\n    // Camera\n    openCameraOverlay: 'Apri overlay camera',\n    openCameraWindow: 'Apri camera in nuova finestra',\n    // Firmware\n    firmwareUpdateAvailable: 'Aggiornamento firmware disponibile: {{current}} → {{latest}}',\n    firmwareUpToDate: 'Firmware {{version}} — Aggiornato',\n    firmwareUpdateButton: 'Aggiorna',\n    // Plate detection\n    plateDetection: {\n      noPermission: 'Non hai il permesso di aggiornare le stampanti',\n      enabledClick: 'Controllo piatto abilitato - Clicca per disabilitare',\n      disabledClick: 'Controllo piatto disabilitato - Clicca per abilitare',\n      manageCalibration: 'Gestisci calibrazione rilevamento piatto',\n      calibrationRequired: 'Calibrazione richiesta',\n      calibrationInstructions: 'Assicurati che il piatto sia <strong>completamente vuoto</strong>, poi clicca Calibra.',\n      calibrationDescription: 'La calibrazione salva un\\'immagine di riferimento del piatto vuoto. I controlli futuri confronteranno con questo riferimento per rilevare oggetti.',\n      calibrationTip: '<strong>Suggerimento:</strong> Puoi salvare fino a 5 calibrazioni per piatti diversi. Il sistema usa automaticamente la migliore corrispondenza durante il controllo.',\n      plateEmpty: 'Il piatto sembra vuoto',\n      objectsDetected: 'Oggetti rilevati sul piatto',\n      confidence: 'Confidenza',\n      difference: 'Differenza',\n      analysisPreview: 'Anteprima analisi:',\n      analysisLegend: 'Riquadro verde = area rilevamento, overlay rosso = differenze dalla calibrazione',\n      savedReferences: 'Riferimenti salvati ({{count}}/{{max}})',\n      deleteReference: 'Elimina riferimento',\n      labelPlaceholder: 'Etichetta...',\n      clickToEdit: '{{label}} - Clicca per modificare',\n      clickToAddLabel: 'Clicca per aggiungere etichetta',\n    },\n    // Speed\n    speed: {\n      title: 'Velocità di stampa',\n      silent: 'Silenzioso (50%)',\n      standard: 'Standard (100%)',\n      sport: 'Sport (124%)',\n      ludicrous: 'Ludicrous (166%)',\n    },\n    airduct: {\n      title: 'Modalità condotto d\\'aria',\n      cooling: 'Raffreddamento',\n      heating: 'Riscaldamento',\n    },\n    noSdCard: 'Nessuna SD',\n    door: {\n      open: 'Aperta',\n      closed: 'Chiusa',\n    },\n    // Fans\n    fans: {\n      partCooling: 'Ventola raffreddamento parte',\n      auxiliary: 'Ventola ausiliaria',\n      chamber: 'Ventola camera',\n    },\n    // HMS errors\n    clickToViewHmsErrors: 'Clicca per vedere errori HMS',\n    estimatedCompletion: 'Tempo completamento stimato',\n    plateNumber: 'Piastra {{number}}',\n    slotOptions: 'Opzioni slot',\n    // AMS hover popup\n    amsPopup: {\n      friendlyName: 'Nome AMS',\n      friendlyNamePlaceholder: 'es. Nome AMS amichevole',\n      serialNumber: 'Numero di serie',\n      firmwareVersion: 'Firmware',\n      save: 'Salva',\n      clear: 'Cancella',\n      noEditPermission: 'Non hai il permesso di rinominare le unità AMS',\n    },\n    // Firmware modal\n    firmwareModal: {\n      title: 'Aggiornamento Firmware',\n      titleUpToDate: 'Info Firmware',\n      currentVersion: 'Corrente:',\n      latestVersion: 'Ultima:',\n      releaseNotes: 'Note di rilascio',\n      checkingPrereqs: 'Controllo prerequisiti...',\n      sdCardReady: 'SD pronta. Clicca sotto per caricare firmware.',\n      uploadedSuccess: 'Firmware caricato su SD!',\n      applyInstructions: 'Per applicare l\\'aggiornamento sulla stampante:',\n      step1: 'Sul touchscreen della stampante, vai a <strong>Impostazioni</strong>',\n      step2: 'Vai a <strong>Firmware</strong>',\n      step3: 'Seleziona <strong>Aggiorna da SD</strong>',\n      step4: 'L\\'aggiornamento richiede 10-20 minuti',\n      done: 'Fatto',\n      starting: 'Avvio...',\n      uploadFirmware: 'Carica Firmware',\n      uploadFailed: 'Avvio caricamento fallito: {{error}}',\n      uploadedToast: 'Firmware caricato! Avvia aggiornamento dal display.',\n    },\n    accessCodePlaceholder: 'Lascia vuoto per mantenere quello attuale',\n    // ROI editor\n    roi: {\n      title: 'Area di rilevamento (ROI)',\n      xStart: 'X Inizio',\n      yStart: 'Y Inizio',\n      width: 'Larghezza',\n      height: 'Altezza',\n      instruction: 'Regola l\\'area di rilevamento per focalizzare il piatto. Il riquadro verde mostra l\\'area corrente.',\n    },\n    developerModeWarning: 'La modalità sviluppatore LAN non è attivata su: {{names}}. Alcune funzionalità potrebbero non funzionare.',\n    howToEnable: 'Come attivare',\n    incompatibleFile: 'Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}',\n    dropNotPrintable: 'Solo i file .gcode e .gcode.3mf possono essere stampati',\n    dropToPrint: 'Rilascia per stampare',\n    cannotPrint: 'Stampante occupata',\n  },\n\n  // Archives page\n  archives: {\n    title: 'Archivi di stampa',\n    searchPlaceholder: 'Cerca archivi...',\n    filterByPrinter: 'Filtra per stampante',\n    filterByStatus: 'Filtra per stato',\n    sortBy: 'Ordina per',\n    sortNewest: 'Più recenti',\n    sortOldest: 'Meno recenti',\n    sortName: 'Nome',\n    sortDuration: 'Durata',\n    sortLargest: 'Più grandi',\n    sortSmallest: 'Più piccoli',\n    sortSize: 'Dimensione',\n    noArchives: 'Nessun archivio trovato',\n    noArchivesSearch: 'Nessun archivio corrisponde alla ricerca',\n    originalPrintNotVisible: 'Stampa originale non visibile - prova a rimuovere i filtri',\n    noArchivesYet: 'Nessun archivio ancora',\n    prints: 'stampe',\n    pagination: {\n      showing: 'Mostrando',\n      to: 'a',\n      of: 'di',\n      show: 'Mostra',\n      page: 'Pagina',\n      all: 'Tutti',\n    },\n    loadingArchives: 'Caricamento archivi...',\n    releaseToUpload: 'Rilascia per caricare',\n    showAll: 'Mostra tutti',\n    showFavoritesOnly: 'Solo preferiti',\n    gridView: 'Vista griglia',\n    listView: 'Vista elenco',\n    calendarView: 'Vista calendario',\n    logView: 'Registro stampe',\n    manageTags: 'Gestisci tag',\n    showFailedPrints: 'Mostra stampe fallite',\n    hideFailedPrints: 'Nascondi stampe fallite',\n    hideDuplicates: 'Nascondi duplicati',\n    viewOriginalPrint: 'Fai clic per visualizzare la stampa originale (#{{id}})',\n    printTime: 'Tempo di stampa',\n    filamentUsed: 'Filamento usato',\n    cost: 'Costo',\n    reprint: 'Ristampa',\n    preview: 'Anteprima',\n    deleteArchive: 'Elimina archivio',\n    deleteConfirm: 'Sei sicuro di eliminare questo archivio?',\n    favorite: 'Preferito',\n    unfavorite: 'Rimuovi dai preferiti',\n    viewDetails: 'Vedi dettagli',\n    status: {\n      completed: 'Completato',\n      failed: 'Fallito',\n      stopped: 'Fermato',\n    },\n    toast: {\n      source3mfAttached: 'Sorgente 3MF allegata: {{filename}}',\n      failedUploadSource3mf: 'Caricamento sorgente 3MF non riuscito',\n      source3mfRemoved: 'Sorgente 3MF rimossa',\n      failedRemoveSource3mf: 'Rimozione sorgente 3MF non riuscita',\n      f3dAttached: 'F3D allegato: {{filename}}',\n      failedUploadF3d: 'Caricamento F3D non riuscito',\n      f3dRemoved: 'F3D rimosso',\n      failedRemoveF3d: 'Rimozione F3D non riuscita',\n      timelapseAttached: 'Timelapse allegato: {{filename}}',\n      timelapseAlreadyAttached: 'Timelapse già allegato',\n      noMatchingTimelapse: 'Nessun timelapse corrispondente',\n      failedScanTimelapse: 'Scansione timelapse non riuscita',\n      failedAttachTimelapse: 'Allegato timelapse non riuscito',\n      timelapseRemoved: 'Timelapse rimosso',\n      failedRemoveTimelapse: 'Impossibile rimuovere il timelapse',\n      timelapseUploaded: 'Timelapse caricato: {{filename}}',\n      failedUploadTimelapse: 'Impossibile caricare il timelapse',\n      archiveDeleted: 'Archivio eliminato',\n      failedDeleteArchive: 'Eliminazione archivio non riuscita',\n      addedToFavorites: 'Aggiunto ai preferiti',\n      removedFromFavorites: 'Rimosso dai preferiti',\n      projectUpdated: 'Progetto aggiornato',\n      failedUpdateProject: 'Aggiornamento progetto non riuscito',\n      linkCopied: 'Link copiato negli appunti',\n      failedCopyLink: 'Copia link non riuscita',\n      photoDeleted: 'Foto eliminata',\n      failedDeletePhoto: 'Eliminazione foto non riuscita',\n      failedDeleteArchives: 'Eliminazione archivi non riuscita',\n      failedUpdateFavorites: 'Aggiornamento preferiti non riuscito',\n      exportDownloaded: 'Export scaricato',\n      exportFailed: 'Export non riuscito',\n    },\n    menu: {\n      print: 'Stampa',\n      schedule: 'Programma',\n      openInBambuStudio: 'Apri nello slicer',\n      slice: 'Slice',\n      externalLink: 'Link esterno',\n      viewOnMakerWorld: 'Vedi su MakerWorld',\n      preview3d: 'Anteprima 3D',\n      viewTimelapse: 'Vedi Timelapse',\n      scanForTimelapse: 'Cerca Timelapse',\n      uploadTimelapse: 'Carica timelapse',\n      removeTimelapse: 'Rimuovi timelapse',\n      downloadSource3mf: 'Scarica Sorgente 3MF',\n      uploadSource3mf: 'Carica Sorgente 3MF',\n      replaceSource3mf: 'Sostituisci Sorgente 3MF',\n      removeSource3mf: 'Rimuovi Sorgente 3MF',\n      uploadF3d: 'Carica F3D',\n      replaceF3d: 'Sostituisci F3D',\n      downloadF3d: 'Scarica F3D',\n      removeF3d: 'Rimuovi F3D',\n      download: 'Scarica',\n      copyDownloadLink: 'Copia link download',\n      qrCode: 'QR Code',\n      viewPhotos: 'Vedi foto',\n      viewPhotosCount: 'Vedi foto ({{count}})',\n      projectPage: 'Pagina progetto',\n      addToFavorites: 'Aggiungi ai preferiti',\n      removeFromFavorites: 'Rimuovi dai preferiti',\n      edit: 'Modifica',\n      goToProject: 'Vai al progetto: {{name}}',\n      addToProject: 'Aggiungi al progetto',\n      removeFromProject: 'Rimuovi dal progetto',\n      loading: 'Caricamento...',\n      noProjectsAvailable: 'Nessun progetto disponibile',\n      select: 'Seleziona',\n      deselect: 'Deseleziona',\n      delete: 'Elimina',\n    },\n    permission: {\n      noReprint: 'Non hai il permesso di ristampare questo archivio',\n      noAddToQueue: 'Non hai il permesso di aggiungere alla coda',\n      noUpdateArchives: 'Non hai il permesso di aggiornare archivi',\n      noUploadFiles: 'Non hai il permesso di caricare file',\n      noDownload: 'Non hai il permesso di scaricare archivi',\n      noCopyLink: 'Non hai il permesso di copiare link download',\n      noDelete: 'Non hai il permesso di eliminare questo archivio',\n      noCreate: 'Non hai il permesso di creare archivi',\n    },\n    card: {\n      previousPlate: 'Piatto precedente',\n      nextPlate: 'Piatto successivo',\n      plateNumber: 'Piatto {{index}}',\n      moreOptions: 'Clic destro per altre opzioni',\n      addToFavorites: 'Aggiungi ai preferiti',\n      removeFromFavorites: 'Rimuovi dai preferiti',\n      cancelled: 'annullato',\n      failed: 'fallito',\n      duplicate: 'duplicato',\n      duplicateTitle: 'Questo modello è stato stampato prima',\n      openSource3mf: 'Apri sorgente 3MF in Bambu Studio (clic destro per altre opzioni)',\n      downloadF3d: 'Scarica file design Fusion 360',\n      viewTimelapse: 'Vedi timelapse',\n      viewPhoto: 'Vedi 1 foto',\n      viewPhotos: 'Vedi {{count}} foto',\n      openFolder: 'Apri cartella: {{name}}',\n      slicedFile: 'File slice - pronto a stampare',\n      sourceFile: 'Solo file sorgente - nessuna mappatura AMS disponibile',\n      gcode: 'GCODE',\n      source: 'SOURCE',\n      project: 'Progetto: {{name}}',\n      estimated: 'Stimato: {{time}}',\n      actual: 'Reale: {{time}}',\n      accuracy: 'Accuratezza: {{percent}}%',\n      filament: '{{weight}}g',\n      layer: '{{count}} strato',\n      layers: '{{count}} strati',\n      object: '{{count}} oggetto',\n      objects: '{{count}} oggetti',\n      slicedFor: 'Sliced per {{model}}',\n      uploadedBy: 'Caricato da',\n      noPermissionReprint: 'Non hai il permesso di ristampare',\n      noFileForReprint: 'Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione',\n      noPermissionEdit: 'Non hai il permesso di modificare archivi',\n      noPermissionDelete: 'Non hai il permesso di eliminare archivi',\n      reprint: 'Ristampa',\n      schedulePrint: 'Programma Stampa',\n      schedule: 'Programma',\n      openInBambuStudio: 'Apri nello slicer',\n      openInBambuStudioToSlice: 'Apri nello slicer per slicing',\n      slice: 'Slice',\n      externalLink: 'Link esterno',\n      makerWorld: 'MakerWorld: {{designer}}',\n      viewProject: 'Vedi progetto',\n      noExternalLink: 'Nessun link esterno',\n      preview3d: 'Anteprima 3D',\n      download: 'Scarica',\n      edit: 'Modifica',\n      delete: 'Elimina',\n    },\n    modal: {\n      deleteArchive: 'Elimina Archivio',\n      deleteConfirm: 'Sei sicuro di eliminare \"{{name}}\"? Questa azione non può essere annullata.',\n      deleteButton: 'Elimina',\n      removeSource3mf: 'Rimuovi Sorgente 3MF',\n      removeSource3mfConfirm: 'Sei sicuro di rimuovere il file sorgente 3MF da \"{{name}}\"? Questo eliminerà il progetto slicer originale.',\n      removeButton: 'Rimuovi',\n      removeF3d: 'Rimuovi F3D',\n      removeF3dConfirm: 'Sei sicuro di rimuovere il file Fusion 360 da \"{{name}}\"?',\n      removeTimelapse: 'Rimuovi timelapse',\n      removeTimelapseConfirm: 'Sei sicuro di voler rimuovere il video timelapse da \"{{name}}\"?',\n      timelapse: '{{name}} - Timelapse',\n      selectTimelapse: 'Seleziona Timelapse',\n      selectTimelapseDesc: 'Nessun abbinamento automatico trovato. Seleziona il timelapse per questa stampa:',\n      deleteArchives: 'Elimina Archivi',\n      deleteArchivesConfirm: 'Sei sicuro di eliminare {{count}} archivio(i)? Questa azione non può essere annullata.',\n      deleteCount: 'Elimina {{count}}',\n    },\n    page: {\n      title: 'Archivi',\n      printsCount: '{{filtered}} di {{total}} stampe',\n      dropFilesHere: 'Rilascia file .3mf qui',\n      releaseToUpload: 'Rilascia per caricare',\n      only3mfSupported: 'Solo file .3mf supportati',\n      close: 'Chiudi',\n      selected: '{{count}} selezionati',\n      selectAll: 'Seleziona tutto',\n      tags: 'Tag',\n      project: 'Progetto',\n      favorite: 'Preferito',\n      delete: 'Elimina',\n      toggledFavorites: 'Preferiti aggiornati per {{count}} archivio(i)',\n      failedUpdateFavorites: 'Aggiornamento preferiti non riuscito',\n      archivesDeleted: '{{count}} archivio(i) eliminati',\n      failedDeleteArchives: 'Eliminazione archivi non riuscita',\n      photoDeleted: 'Foto eliminata',\n      failedDeletePhoto: 'Eliminazione foto non riuscita',\n    },\n    list: {\n      name: 'Nome',\n      printer: 'Stampante',\n      date: 'Data',\n      size: 'Dimensione',\n      actions: 'Azioni',\n      hasTimelapse: 'Ha timelapse',\n    },\n    log: {\n      date: 'Data',\n      printName: 'Nome stampa',\n      printer: 'Stampante',\n      user: 'Utente',\n      status: 'Stato',\n      duration: 'Durata',\n      filament: 'Filamento',\n      allPrinters: 'Tutte le stampanti',\n      allUsers: 'Tutti gli utenti',\n      allStatuses: 'Tutti gli stati',\n      cancelled: 'Annullato',\n      skipped: 'Saltato',\n      dateFrom: 'Dal',\n      dateTo: 'Al',\n      noEntries: 'Nessuna voce di registro trovata',\n      showing: '{{count}} di {{total}} voci',\n      rowsPerPage: 'Righe',\n      page: 'Pagina',\n      prev: 'Prec.',\n      next: 'Succ.',\n      clearLog: 'Cancella registro',\n      clearLogTitle: 'Cancella registro stampe',\n      clearLogConfirm: 'Tutte le voci del registro di stampa verranno eliminate permanentemente. Gli archivi e gli elementi della coda non sono interessati. Questa azione non può essere annullata. Sei sicuro?',\n      clearLogButton: 'Cancella tutto',\n      cleared: '{{count}} voci di registro cancellate',\n      clearFailed: 'Impossibile cancellare il registro stampe',\n    },\n  },\n\n  // Queue page\n  queue: {\n    title: 'Coda di stampa',\n    subtitle: 'Programma e gestisci i tuoi lavori di stampa',\n    addToQueue: 'Aggiungi alla coda',\n    // Print modal\n    print: 'Stampa',\n    reprint: 'Ristampa',\n    schedulePrint: 'Programma Stampa',\n    editQueueItem: 'Modifica elemento coda',\n    printToPrinters: 'Stampa su {{count}} Stampanti',\n    queueToPrinters: 'Metti in coda su {{count}} Stampanti',\n    queueSelectedPlates: 'Metti in coda {{count}} piastre',\n    selectAllPlates: 'Seleziona tutte le {{count}} piastre',\n    deselectAll: 'Deseleziona tutto',\n    printQueued: 'Stampa in coda',\n    itemsQueued: '{{count}} elementi in coda',\n    sending: 'Invio...',\n    sendingProgress: 'Invio {{current}}/{{total}}...',\n    adding: 'Aggiunta...',\n    addingProgress: 'Aggiunta {{current}}/{{total}}...',\n    savingProgress: 'Salvataggio {{current}}/{{total}}...',\n    clearQueue: 'Svuota coda',\n    clearHistory: 'Svuota cronologia',\n    emptyQueue: 'La coda è vuota',\n    position: 'Posizione',\n    scheduledTime: 'Ora programmata',\n    moveUp: 'Sposta su',\n    moveDown: 'Sposta giù',\n    startNow: 'Avvia ora',\n    printingInProgress: 'Stampa in corso...',\n    viewArchive: 'Vedi archivio',\n    viewInFileManager: 'Vedi nel Gestore file',\n    itemCount: '{{count}} elemento',\n    itemCount_plural: '{{count}} elementi',\n    dragToReorder: 'Trascina per riordinare (solo ASAP)',\n    reorderHint: 'La posizione influisce solo sugli elementi ASAP. Quelli programmati partono all\\'orario.',\n    sjf: {\n      label: 'SJF',\n      tooltip: 'Lavoro più breve prima — lo scheduler dà priorità alle stampe più brevi',\n    },\n    addedBy: 'Aggiunto da {{name}}',\n    nextInQueue: 'Prossimo in coda',\n    clearPlateSuccess: 'Piatto liberato — pronto per la prossima stampa',\n    plateNumber: 'Piatto {{index}}',\n    // Batch / quantity\n    quantity: 'Quantità',\n    quantityHint: 'Crea {{count}} elementi in coda',\n    activeBatches: 'Lotti attivi',\n    batchProgress: '{{completed}} di {{total}} completati',\n    cancelBatch: 'Annulla rimanenti',\n    batchCancelled: 'Elementi rimanenti del lotto annullati',\n    cancelBatchConfirmTitle: 'Annulla lotto',\n    cancelBatchConfirmMessage: 'Annullare tutti gli elementi in sospeso rimanenti in questo lotto?',\n    batch: 'Lotto',\n    // Sections\n    sections: {\n      currentlyPrinting: 'In stampa',\n      queued: 'In coda',\n      history: 'Cronologia',\n    },\n    // Status\n    status: {\n      pending: 'In attesa',\n      waiting: 'In attesa',\n      printing: 'In stampa',\n      paused: 'In pausa',\n      completed: 'Completato',\n      failed: 'Fallito',\n      skipped: 'Saltato',\n      cancelled: 'Annullato',\n    },\n    // Summary cards\n    summary: {\n      printing: 'In stampa',\n      queued: 'In coda',\n      totalTime: 'Tempo totale coda',\n      totalWeight: 'Peso totale della coda',\n      history: 'Cronologia',\n    },\n    // Filters\n    filter: {\n      allPrinters: 'Tutte le stampanti',\n      unassigned: 'Non assegnato',\n      allStatus: 'Tutti gli stati',\n      allLocations: 'Tutte le posizioni',\n      any: 'Qualsiasi',\n    },\n    // Sort\n    sort: {\n      byPosition: 'Ordina per posizione',\n      byName: 'Ordina per nome',\n      byPrinter: 'Ordina per stampante',\n      bySchedule: 'Ordina per programma',\n      byDate: 'Ordina per data',\n      ascendingOldest: 'Crescente (più vecchi)',\n      descendingNewest: 'Decrescente (più recenti)',\n    },\n    // Badges\n    badges: {\n      staged: 'In staging',\n      requiresPrevious: 'Richiede successo precedente',\n      autoPowerOff: 'Spegnimento automatico',\n      gcodeInjection: 'G-code',\n    },\n    // Empty state\n    empty: {\n      title: 'Nessuna stampa programmata',\n      description: 'Programma una stampa dalla pagina Archivi usando l\\'opzione \"Programma\" nel menu contestuale, o trascina i file per iniziare.',\n    },\n    // Time\n    time: {\n      asap: 'ASAP',\n      overdue: 'Scaduto',\n      now: 'Ora',\n      lessThanMinute: 'Tra meno di un minuto',\n      inMinutes: 'Tra {{count}} min',\n      inHours: 'Tra {{count}} ore',\n    },\n    // Actions\n    actions: {\n      stopPrint: 'Ferma Stampa',\n      startPrint: 'Avvia Stampa',\n      requeue: 'Rimetti in coda',\n    },\n    // Bulk edit\n    bulkEdit: {\n      title: 'Modifica {{count}} elemento',\n      title_plural: 'Modifica {{count}} elementi',\n      description: 'Solo le impostazioni modificate saranno applicate agli elementi selezionati.',\n      printer: 'Stampante',\n      noChange: '— Nessun cambio —',\n      queueOptions: 'Opzioni coda',\n      staged: 'In staging (avvio manuale)',\n      autoPowerOff: 'Spegnimento automatico dopo stampa',\n      requirePrevious: 'Richiede successo precedente',\n      printOptions: 'Opzioni stampa',\n      bedLevelling: 'Livellamento piatto',\n      flowCalibration: 'Calibrazione flusso',\n      vibrationCalibration: 'Calibrazione vibrazioni',\n      layerInspection: 'Controllo primo layer',\n      timelapse: 'Timelapse',\n      useAms: 'Usa AMS',\n      applyChanges: 'Applica modifiche',\n      selectAll: 'Seleziona tutto',\n      deselectAll: 'Deseleziona tutto',\n      selected: '{{count}} selezionati',\n      editSelected: 'Modifica selezionati',\n      cancelSelected: 'Annulla selezionati',\n    },\n    // Confirmations\n    confirm: {\n      cancelTitle: 'Annulla stampa programmata',\n      cancelMessage: 'Sei sicuro di annullare \"{{name}}\"?',\n      stopTitle: 'Ferma Stampa',\n      stopMessage: 'Sei sicuro di fermare la stampa corrente \"{{name}}\"? Questo annullerà il lavoro sulla stampante.',\n      removeTitle: 'Rimuovi dalla cronologia',\n      removeMessage: 'Sei sicuro di rimuovere \"{{name}}\" dalla cronologia coda?',\n      clearHistoryTitle: 'Svuota cronologia',\n      clearHistoryMessage: 'Sei sicuro di rimuovere {{count}} elemento(i) dalla cronologia?',\n      cancelButton: 'Annulla Stampa',\n      stopButton: 'Ferma Stampa',\n      thisPrint: 'questa stampa',\n      thisItem: 'questo elemento',\n    },\n    // Toast messages\n    toast: {\n      cancelled: 'Elemento coda annullato',\n      cancelFailed: 'Annullamento non riuscito',\n      removed: 'Elemento coda rimosso',\n      removeFailed: 'Rimozione non riuscita',\n      stopped: 'Stampa fermata',\n      stopFailed: 'Impossibile fermare stampa',\n      released: 'Stampa rilasciata in coda',\n      startFailed: 'Avvio stampa non riuscito',\n      reorderFailed: 'Riordino coda non riuscito',\n      historyCleared: 'Cancellati {{count}} elementi cronologia',\n      clearHistoryFailed: 'Svuotamento cronologia non riuscito',\n      updateFailed: 'Aggiornamento elementi non riuscito',\n      bulkCancelled: 'Annullati {{count}} elementi',\n      bulkCancelFailed: 'Annullamento elementi non riuscito',\n    },\n    // Timeline view\n    timeline: {\n      listView: 'Lista',\n      timelineView: 'Cronologia',\n      unassigned: 'Non assegnato',\n      noData: 'Nessuna stampa programmata per questo giorno',\n      allDoneBy: 'Tutte le stampe completate entro le {{time}}',\n      staged: 'In attesa',\n      filterAll: 'Mostra tutto',\n      filterPrinting: 'In stampa',\n      filterQueued: 'In coda',\n      time: {\n        anyMoment: 'a momenti',\n        minutesLeft: '{{minutes}}m rimanenti',\n        hoursLeft: '{{hours}}h rimanenti',\n        hoursMinutesLeft: '{{hours}}h {{minutes}}m rimanenti',\n      },\n      day: {\n        previous: 'Giorno precedente',\n        next: 'Giorno successivo',\n        today: 'Oggi',\n      },\n    },\n    // Permissions\n    permissions: {\n      noStopPrint: 'Non hai il permesso di fermare stampe',\n      noStartPrint: 'Non hai il permesso di avviare stampe',\n      noEdit: 'Non hai il permesso di modificare questo elemento coda',\n      noCancel: 'Non hai il permesso di annullare questo elemento coda',\n      noRequeue: 'Non hai il permesso di rimettere in coda elementi',\n      noRemove: 'Non hai il permesso di rimuovere questo elemento coda',\n      noClearHistory: 'Non hai il permesso di svuotare tutta la cronologia',\n      noEditItems: 'Non hai il permesso di modificare elementi coda',\n      noCancelItems: 'Non hai il permesso di annullare elementi coda',\n    },\n  },\n\n  backgroundDispatch: {\n    unknownFile: 'File sconosciuto',\n    unknownPrinter: 'Stampante sconosciuta',\n    startingPrints: 'Avvio stampe',\n    progressSummary: '{{complete}}/{{total}} completati • Inviati: {{dispatched}} • In elaborazione: {{processing}}',\n    expandDetails: 'Espandi dettagli dispatch',\n    collapseDetails: 'Comprimi dettagli dispatch',\n    dismissToast: 'Chiudi notifica dispatch',\n    cancelDispatchJob: 'Annulla job dispatch',\n    cancel: 'Annulla',\n    cancelling: 'Annullamento…',\n    status: {\n      dispatched: 'Inviato',\n      processing: 'In elaborazione',\n      completed: 'Completato',\n      failed: 'Fallito',\n      cancelled: 'Annullato',\n    },\n    toast: {\n      cancellingUpload: 'Annullamento upload...',\n      cancelled: 'Dispatch annullato',\n      cancelFailed: 'Impossibile annullare il dispatch',\n      completeWithFailures: 'Dispatch in background completato: {{completed}} riusciti, {{failed}} falliti',\n      completeSuccess: 'Dispatch in background completato: {{completed}} riusciti',\n      printStartedRemaining: '{{completed}} stampa/e avviata/e, {{remaining}} in invio...',\n    },\n  },\n\n  // Statistics page\n  stats: {\n    title: 'Dashboard',\n    subtitle: 'Trascina i widget per riordinare. Clicca l\\'icona occhio per nascondere.',\n    overview: 'Panoramica',\n    totalPrints: 'Stampe totali',\n    successRate: 'Tasso di successo',\n    totalPrintTime: 'Tempo totale di stampa',\n    printTime: 'Tempo di stampa',\n    totalFilament: 'Filamento totale usato',\n    filamentUsed: 'Filamento usato',\n    filamentCost: 'Costo filamento',\n    totalCost: 'Costo totale',\n    energyUsed: 'Energia usata',\n    energyCost: 'Costo energia',\n    energyWarmingUpTooltip: 'Il tracciamento energia sta ancora raccogliendo snapshot orari. I totali per intervallo diventeranno accurati quando esisterà almeno uno snapshot prima dell’intervallo selezionato. I primi valori potrebbero essere sottostimati.',\n    averagePrintTime: 'Tempo medio di stampa',\n    printsPerDay: 'Stampe al giorno',\n    byPrinter: 'Per stampante',\n    printsByPrinter: 'Stampe per stampante',\n    byMaterial: 'Per materiale',\n    byMonth: 'Per mese',\n    last7Days: 'Ultimi 7 giorni',\n    last30Days: 'Ultimi 30 giorni',\n    last90Days: 'Ultimi 90 giorni',\n    allTime: 'Sempre',\n    // Widgets\n    quickStats: 'Statistiche rapide',\n    printActivity: 'Attivita di stampa',\n    filamentTypes: 'Tipi di filamento',\n    filamentTrends: 'Trend filamento',\n    failureAnalysis: 'Analisi guasti',\n    timeAccuracy: 'Accuratezza tempo',\n    successful: 'Riuscite:',\n    failed: 'Fallite:',\n    perfectEstimate: '100% = stima perfetta',\n    noTimeAccuracyData: 'Nessun dato accuratezza tempo',\n    noFilamentData: 'Nessun dato filamento',\n    noPrinterData: 'Nessun dato stampante',\n    noPrintData: 'Nessun dato stampa',\n    noPrintDataLast30Days: 'Nessun dato stampa negli ultimi 30 giorni',\n    failureReasons: 'Cause guasto',\n    topFailureReasons: 'Cause principali',\n    failedPrintsCount: '{{failed}} / {{total}} stampe fallite',\n    lastWeekRate: 'Settimana scorsa: {{rate}}%',\n    // Actions\n    resetLayout: 'Reimposta layout',\n    recalculateCosts: 'Ricalcola costi',\n    recalculateCostsHint: 'Ricalcola tutti i costi archivi usando i prezzi filamento correnti',\n    exportStats: 'Esporta statistiche',\n    exportAsCsv: 'Esporta come CSV',\n    exportAsExcel: 'Esporta come Excel',\n    hiddenCount: '{{count}} Nascosti',\n    // Toast\n    exportDownloaded: 'Export scaricato',\n    exportFailed: 'Export non riuscito',\n    layoutReset: 'Layout reimpostato',\n    recalculatedCosts: 'Costi ricalcolati per {{count}} archivi',\n    recalculateFailed: 'Ricalcolo costi non riuscito',\n    // Loading\n    loadingStats: 'Caricamento statistiche...',\n    // Permissions\n    noPermissionResetLayout: 'Non hai il permesso di reimpostare il layout',\n    noPermissionRecalculate: 'Non hai il permesso di ricalcolare i costi',\n    noPrintDataInRange: 'Nessun dato nel periodo selezionato',\n    periodFilament: 'Filamento utilizzato',\n    periodCost: 'Costo',\n    avgPerPrint: 'Media per stampa',\n    usageOverTime: 'Utilizzo nel tempo',\n    filamentByWeight: 'Peso',\n    printDuration: 'Durata stampa',\n    printerUtilization: 'Utilizzo stampante',\n    filamentSuccess: 'Successo per materiale',\n    printHabits: 'Abitudini di stampa',\n    printTimeOfDay: 'Ora di stampa',\n    colorDistribution: 'Distribuzione colori',\n    noColorData: 'Nessun dato colore disponibile',\n    records: 'Record',\n    longestPrint: 'Stampa più lunga',\n    heaviestPrint: 'Stampa più pesante',\n    mostExpensivePrint: 'Più costosa',\n    busiestDay: 'Giorno più attivo',\n    successStreak: 'Serie di successi',\n    streakPrint: 'stampa consecutiva',\n    streakPrints: '{{count}} stampe consecutive',\n    printerStats: 'Statistiche stampante',\n    hours: 'ore',\n    avgPrints: 'Media stampe',\n    noArchiveData: 'Nessun dato di stampa disponibile',\n    filamentByTime: 'Tempo',\n    avgWeight: 'Media peso',\n    avgTime: 'Media tempo',\n    filamentByPrints: 'Stampe',\n    timeframe: {\n      today: 'Oggi',\n      'this-week': 'Questa settimana',\n      'this-month': 'Questo mese',\n      'last-7': 'Ultimi 7 giorni',\n      'last-30': 'Ultimi 30 giorni',\n      'last-90': 'Ultimi 90 giorni',\n      'this-year': 'Quest\\'anno',\n      'all-time': 'Tutto',\n      custom: 'Personalizzato',\n      from: 'Da',\n      to: 'A',\n    },\n    allUsers: 'Tutti gli utenti',\n    noUser: 'Nessun utente (Sistema)',\n    filterByUser: 'Filtra per utente',\n  },\n\n  // Maintenance page\n  maintenance: {\n    title: 'Manutenzione',\n    overview: 'Panoramica',\n    allOk: 'Tutta la manutenzione aggiornata',\n    dueCount: '{{count}} elemento in scadenza',\n    dueCount_plural: '{{count}} elementi in scadenza',\n    warningCount: '{{count}} avviso',\n    warningCount_plural: '{{count}} avvisi',\n    totalPrintTime: 'Tempo totale di stampa',\n    nextMaintenance: 'Prossima manutenzione',\n    nothingDue: 'Niente in scadenza',\n    tasks: 'Attivita',\n    lastPerformed: 'Ultima esecuzione',\n    interval: 'Intervallo',\n    hoursRemaining: '{{hours}}h rimanenti',\n    hoursOverdue: '{{hours}}h in ritardo',\n    markDone: 'Segna come fatto',\n    performMaintenance: 'Esegui manutenzione',\n    history: 'Cronologia',\n    noHistory: 'Nessuna cronologia manutenzione',\n    editPrintHours: 'Modifica ore stampa',\n    currentHours: 'Ore attuali',\n    // Tabs\n    statusTab: 'Stato',\n    settingsTab: 'Impostazioni',\n    // Status\n    overdueCount: '{{count}} in ritardo',\n    dueSoonCount: '{{count}} in scadenza',\n    dueSoon: 'In scadenza',\n    allGood: 'Tutto ok',\n    overdueBy: 'In ritardo di {{duration}}',\n    dueIn: 'Scade tra {{duration}}',\n    timeLeft: '{{duration}} rimanenti',\n    // Duration formats\n    day: '1 giorno',\n    days: '{{count}} giorni',\n    week: '1 settimana',\n    weeks: '{{count}} settimane',\n    month: '1 mese',\n    months: '{{count}} mesi',\n    year: '1 anno',\n    // Settings\n    maintenanceTypes: 'Tipi di manutenzione',\n    maintenanceTypesDescription: 'Tipi di sistema e tue attivita personalizzate',\n    addCustomType: 'Aggiungi tipo personalizzato',\n    restoreDefaults: 'Ripristina attivita predefinite',\n    intervalType: 'Tipo intervallo',\n    intervalValue: 'Intervallo ({{type}})',\n    icon: 'Icona',\n    documentationLink: 'Link documentazione (opzionale)',\n    assignToPrinters: 'Assegna alle stampanti',\n    selectAtLeastOnePrinter: 'Seleziona almeno una stampante',\n    addType: 'Aggiungi tipo',\n    custom: 'Personalizzato',\n    printHours: 'Ore di stampa',\n    calendarDays: 'Giorni calendario',\n    exampleName: 'es. Sostituisci filtro HEPA',\n    viewDocumentation: 'Vedi documentazione',\n    timeBasedInterval: 'Intervallo basato sul tempo',\n    // Interval overrides\n    intervalOverrides: 'Override intervallo',\n    intervalOverridesDescription: 'Personalizza intervalli per stampanti specifiche',\n    // Printer assignment\n    assignedToPrinters: 'Assegnato alle stampanti:',\n    noPrintersAssigned: 'Nessuna stampante assegnata',\n    addPrinterShort: 'Aggiungi:',\n    printersAssignedClick: '{{count}} stampante(i) assegnata - clicca per gestire',\n    removeFromPrinter: 'Rimuovi da questa stampante',\n    // Types\n    types: {\n      lubricateCarbonRods: 'Lubrifica aste in carbonio',\n      lubricateRails: 'Lubrifica guide lineari',\n      cleanNozzle: 'Pulisci ugello/Hotend',\n      checkBelts: 'Controlla tensione cinghie',\n      cleanBuildPlate: 'Pulisci piatto',\n      checkExtruder: 'Controlla ingranaggi estrusore',\n      checkCooling: 'Controlla ventole raffreddamento',\n      generalInspection: 'Ispezione generale',\n      cleanCarbonRods: 'Pulisci aste in carbonio',\n      lubricateSteelRods: 'Lubrifica aste in acciaio',\n      cleanSteelRods: 'Pulisci aste in acciaio',\n      cleanLinearRails: 'Pulisci guide lineari',\n      checkPtfeTube: 'Controlla tubo PTFE',\n      replaceHepaFilter: 'Sostituisci filtro HEPA',\n      replaceCarbonFilter: 'Sostituisci filtro carbone',\n      lubricateLeftNozzleRail: 'Lubrifica guida ugello sinistro',\n    },\n    // Toast\n    maintenanceComplete: 'Manutenzione segnata come completata',\n    typeUpdated: 'Tipo manutenzione aggiornato',\n    typeDeleted: 'Tipo manutenzione eliminato',\n    defaultsRestored: 'Ripristinate {{count}} attivita predefinite',\n    printHoursUpdated: 'Ore di stampa aggiornate',\n    printerAssigned: 'Stampante assegnata',\n    printerRemoved: 'Stampante rimossa',\n    // Confirmation\n    deleteTypeConfirm: 'Eliminare \"{{name}}\"?',\n    deleteSystemTypeTitle: 'Eliminare attività di manutenzione predefinita?',\n    deleteSystemTypeMessage: 'Sei sicuro di voler eliminare l\\'attività di manutenzione predefinita \"{{name}}\"?',\n    // Permissions\n    noPermissionUpdate: 'Non hai il permesso di aggiornare elementi manutenzione',\n    noPermissionPerform: 'Non hai il permesso di eseguire manutenzione',\n    noPermissionEditTypes: 'Non hai il permesso di modificare tipi manutenzione',\n    noPermissionDeleteTypes: 'Non hai il permesso di eliminare tipi manutenzione',\n    noPermissionEditHours: 'Non hai il permesso di modificare ore stampa',\n    noPermissionRemovePrinter: 'Non hai il permesso di rimuovere assegnazioni stampanti',\n    noPermissionAssignPrinter: 'Non hai il permesso di assegnare stampanti',\n    noPermissionEditIntervals: 'Non hai il permesso di modificare intervalli',\n    // Configure link\n    configureSettings: 'Configura tipi e intervalli manutenzione',\n  },\n\n  // Settings page\n  settings: {\n    title: 'Impostazioni',\n    general: 'Generale',\n    // Tab names\n    tabs: {\n      general: 'Generale',\n      smartPlugs: 'Prese smart',\n      notifications: 'Notifiche',\n      queue: 'Workflow',\n      filament: 'Filamento',\n      network: 'Rete',\n      apiKeys: 'Chiavi API',\n      virtualPrinter: 'Stampante virtuale',\n      failureDetection: 'Rilevamento guasti',\n      users: 'Utenti',\n      backup: 'Backup',\n      emailAuth: 'Autenticazione Email',\n      ldap: 'LDAP',\n      twoFa: 'Autenticazione 2FA',\n      oidc: 'SSO / OIDC',\n    },\n    ldap: {\n      title: 'Autenticazione LDAP',\n      enabledDesc: \"L'autenticazione LDAP è attiva\",\n      disabledDesc: \"L'autenticazione LDAP è disattivata\",\n      disabledHint: 'Configura e salva le impostazioni LDAP qui sotto, poi attiva.',\n      enabled: 'Autenticazione LDAP attivata',\n      disabled: 'Autenticazione LDAP disattivata',\n      feature1: 'Gli utenti possono accedere con le credenziali LDAP',\n      feature2: \"L'account amministratore locale rimane come fallback\",\n      feature3: 'I gruppi LDAP vengono mappati ai gruppi BamBuddy al login',\n      serverConfig: 'Configurazione server LDAP',\n      serverUrl: 'URL del server',\n      serverUrlHint: 'Usa ldap:// per standard o ldaps:// per connessioni SSL',\n      security: 'Sicurezza',\n      securityHint: 'StartTLS aggiorna una connessione semplice a TLS. LDAPS usa TLS fin dall\\'inizio.',\n      bindDn: 'Bind DN (account di servizio)',\n      bindPassword: 'Password Bind',\n      searchBase: 'Base DN di ricerca',\n      userFilter: 'Filtro di ricerca utente',\n      userFilterHint: '{username} viene sostituito con il nome utente. Usa (uid={username}) per OpenLDAP.',\n      autoProvision: 'Provisioning automatico utenti',\n      autoProvisionHint: 'Crea automaticamente un account BamBuddy al primo accesso LDAP',\n      defaultGroup: 'Gruppo predefinito',\n      defaultGroupNone: '— Nessuno (nessun fallback) —',\n      defaultGroupHint: 'Gruppo di fallback assegnato quando un utente LDAP si autentica ma non è presente in nessun gruppo LDAP mappato. Lascia vuoto per lasciare gli utenti non mappati senza autorizzazioni.',\n      groupMapping: 'Mappatura gruppi (JSON)',\n      groupMappingHint: 'Mappa i DN dei gruppi LDAP ai gruppi BamBuddy. Gruppi disponibili: ',\n      testConnection: 'Test connessione',\n      settingsSaved: 'Impostazioni LDAP salvate',\n      errors: {\n        serverRequired: \"L'URL del server LDAP è obbligatorio\",\n        searchBaseRequired: 'Il Base DN di ricerca è obbligatorio',\n        enableAuthFirst: \"Attiva prima l'autenticazione\",\n        configureLdapFirst: 'Salva prima le impostazioni LDAP',\n      },\n    },\n    // Email settings\n    email: {\n      smtpSettings: 'Configurazione SMTP',\n      smtpHost: 'Server SMTP',\n      smtpPort: 'Porta SMTP',\n      security: 'Sicurezza',\n      authentication: 'Autenticazione',\n      username: 'Nome utente',\n      password: 'Password',\n      fromEmail: 'Email mittente',\n      fromName: 'Nome mittente',\n      testConnection: 'Testa connessione SMTP',\n      testRecipient: 'Email destinatario test',\n      sendTest: 'Invia email di test',\n      sending: 'Invio...',\n      save: 'Salva impostazioni',\n      saving: 'Salvataggio...',\n      advancedAuth: 'Autenticazione avanzata',\n      advancedAuthEnabled: 'L\\'autenticazione avanzata è abilitata',\n      advancedAuthEnabledDesc: 'Le funzionalità di gestione utenti via email sono attive. I nuovi utenti riceveranno password generate automaticamente via email e potranno reimpostare la password tramite la funzione di recupero.',\n      advancedAuthDisabled: 'L\\'autenticazione avanzata è disabilitata',\n      advancedAuthDisabledDesc: 'Abilita l\\'autenticazione avanzata per attivare le funzionalità email per la gestione utenti.',\n      enable: 'Abilita',\n      disable: 'Disabilita',\n      feature1: 'Le password vengono generate automaticamente e inviate via email ai nuovi utenti',\n      feature2: 'Gli utenti possono accedere con nome utente o email',\n      feature3: 'La funzione di recupero password è disponibile',\n      feature4: 'Gli amministratori possono reimpostare le password utente via email',\n      // Error messages\n      errors: {\n        requiredFields: 'Compilare tutti i campi obbligatori',\n        usernameRequired: 'Il nome utente è obbligatorio quando l\\'autenticazione è abilitata',\n        enterTestEmail: 'Inserire un indirizzo email di test',\n        smtpServerAndEmail: 'Compilare Server SMTP e Email mittente prima di testare',\n        usernamePasswordRequired: 'Nome utente e password sono obbligatori quando l\\'autenticazione è abilitata',\n        configureSmtpFirst: 'Configurare e testare le impostazioni SMTP prima',\n        enableAuthFirst: 'Per utilizzare le funzionalità basate sulla posta elettronica, è necessario prima abilitare l\\'autenticazione.',\n      },\n      // Success messages\n      success: {\n        settingsSaved: 'Impostazioni SMTP salvate con successo',\n      },\n      // Security options\n      securityOptions: {\n        starttls: 'STARTTLS (Porta 587)',\n        ssl: 'SSL/TLS (Porta 465)',\n        none: 'Nessuna (Porta 25)',\n      },\n      // Authentication options\n      authOptions: {\n        enabled: 'Abilitata',\n        disabled: 'Disabilitata',\n      },\n    },\n    appearance: 'Aspetto',\n    notifications: 'Notifiche',\n    smartPlugs: 'Prese smart',\n    spoolman: 'Spoolman',\n    updates: 'Aggiornamenti',\n    language: 'Lingua',\n    languageDescription: 'Seleziona la lingua preferita',\n    theme: 'Tema',\n    themeLight: 'Chiaro',\n    themeDark: 'Scuro',\n    themeSystem: 'Sistema',\n    defaultView: 'Vista predefinita',\n    defaultViewDescription: 'Pagina da mostrare all\\'apertura dell\\'app',\n    checkForUpdates: 'Controlla aggiornamenti',\n    autoUpdate: 'Aggiornamento automatico',\n    currentVersion: 'Versione attuale',\n    latestVersion: 'Ultima versione',\n    upToDate: 'Sei aggiornato',\n    updateAvailable: 'Aggiornamento disponibile',\n    // Notifications\n    notificationLanguage: 'Lingua notifiche',\n    notificationLanguageDescription: 'Lingua per notifiche push',\n    bedCooledThreshold: 'Soglia raffreddamento piatto',\n    bedCooledThresholdDescription: 'Temperatura sotto la quale il piatto è considerato raffreddato dopo una stampa',\n    userNotificationsEnabled: 'Notifiche utente',\n    userNotificationsEnabledDescription: \"Abilita il menu notifiche utente e le notifiche e-mail per gli eventi di stampa. Richiede l'autenticazione avanzata.\",\n    userNotificationsDisabledHint: \"Abilita l'autenticazione avanzata per usare le notifiche utente.\",\n    notificationProviders: 'Provider notifiche',\n    addProvider: 'Aggiungi provider',\n    editProvider: 'Modifica provider',\n    providerType: 'Tipo provider',\n    testNotification: 'Notifica di test',\n    testSuccess: 'Notifica di test inviata',\n    testFailed: 'Invio notifica di test fallito',\n    quietHours: 'Ore silenziose',\n    quietHoursDescription: 'Non disturbare in queste ore',\n    quietHoursStart: 'Inizio',\n    quietHoursEnd: 'Fine',\n    events: {\n      title: 'Eventi notifica',\n      printStart: 'Stampa avviata',\n      printComplete: 'Stampa completata',\n      printFailed: 'Stampa fallita',\n      printStopped: 'Stampa interrotta',\n      printProgress: 'Avanzamento',\n      printProgressDescription: 'Notifica al 25%, 50%, 75%',\n      printerOffline: 'Stampante offline',\n      printerError: 'Errore stampante',\n      filamentLow: 'Filamento in esaurimento',\n      maintenanceDue: 'Manutenzione dovuta',\n      maintenanceDueDescription: 'Notifica quando serve manutenzione',\n    },\n    // Smart Plugs\n    smartPlug: {\n      title: 'Prese smart',\n      add: 'Aggiungi presa smart',\n      edit: 'Modifica presa smart',\n      name: 'Nome',\n      ipAddress: 'Indirizzo IP',\n      linkedPrinter: 'Stampante collegata',\n      autoOn: 'Accensione automatica',\n      autoOnDescription: 'Accendi all\\'avvio stampa',\n      autoOff: 'Spegnimento automatico',\n      autoOffDescription: 'Spegni dopo il completamento',\n      offDelay: 'Ritardo spegnimento',\n      offDelayMinutes: 'Minuti dopo la stampa',\n      offDelayTemp: 'Quando ugello sotto temperatura',\n      currentState: 'Stato attuale',\n      turnOn: 'Accendi',\n      turnOff: 'Spegni',\n    },\n    // Filament Tracking Mode\n    filamentTracking: 'Tracciamento filamento',\n    filamentTrackingDesc: 'Scegli come tracciare le bobine di filamento. Puoi usare l\\'inventario integrato o collegare un server Spoolman esterno.',\n    filamentChecks: 'Controlli filamento',\n    disableFilamentWarnings: 'Disabilita avvisi filamento',\n    disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\\'accodamento',\n    preferLowestFilament: 'Preferisci il filamento con meno residuo',\n    preferLowestFilamentDesc: 'Quando più bobine corrispondono, usa quella con meno filamento rimanente',\n    trackingModeBuiltIn: 'Inventario integrato',\n    trackingModeBuiltInDesc: 'Riconoscimento RFID automatico e tracciamento dell\\'uso inclusi',\n    trackingModeSpoolmanDesc: 'Server esterno per la gestione del filamento',\n    builtInFeatureRfid: 'Rileva automaticamente le bobine Bambu Lab RFID nell\\'AMS',\n    builtInFeatureUsage: 'Traccia il consumo di filamento per stampa',\n    builtInFeatureCatalog: 'Gestisci bobine, colori e profili K-factor',\n    builtInFeatureThirdParty: 'Le bobine di terze parti possono essere assegnate alle bobine dell\\'inventario',\n    amsSyncButton: 'Sincronizza pesi dall\\'AMS',\n    amsSyncTitle: 'Sincronizza pesi bobine dall\\'AMS',\n    amsSyncMessage: 'Questo sovrascriverà tutti i pesi delle bobine dell\\'inventario con i valori attuali di percentuale rimanente dell\\'AMS dalle stampanti connesse. Usa questa funzione per recuperare dati di peso corrotti. Le stampanti devono essere online.',\n    amsSyncing: 'Sincronizzazione...',\n    amsSyncSuccess: '{{synced}} bobina/e sincronizzata/e, {{skipped}} saltata/e',\n    amsSyncError: 'Impossibile sincronizzare i pesi dall\\'AMS',\n    // Spoolman settings\n    spoolmanUrl: 'URL Spoolman',\n    spoolmanUrlHint: 'URL del server Spoolman (es. http://localhost:7912)',\n    spoolmanConnected: 'Connesso',\n    spoolmanDisconnected: 'Disconnesso',\n    status: 'Stato',\n    connect: 'Connetti',\n    disconnect: 'Disconnetti',\n    howSyncWorks: 'Come funziona la sincronizzazione',\n    syncInfoRfidOnly: 'Solo le bobine ufficiali Bambu Lab con RFID vengono sincronizzate',\n    syncInfoAutoCreate: 'Le nuove bobine vengono create automaticamente in Spoolman alla prima sincronizzazione',\n    syncInfoThirdPartySkipped: 'Le bobine non Bambu Lab (terze parti, ricaricate) vengono saltate',\n    linkingExistingSpools: 'Collegamento bobine esistenti',\n    linkingExistingSpoolsDesc: 'Per collegare le bobine Spoolman esistenti all\\'AMS, passa il mouse su uno slot AMS e clicca \"Collega a Spoolman\".',\n    syncMode: 'Modalità sincronizzazione',\n    syncModeAuto: 'Automatica',\n    syncModeManual: 'Solo manuale',\n    syncModeAutoDesc: 'I dati AMS si sincronizzano automaticamente quando vengono rilevate modifiche',\n    syncModeManualDesc: 'Sincronizzazione solo quando attivata manualmente',\n    syncAmsData: 'Sincronizza dati AMS',\n    syncAmsDataDesc: 'Sincronizza manualmente i dati AMS della stampante su Spoolman',\n    allPrinters: 'Tutte le stampanti',\n    // Default printer\n    noDefaultPrinter: 'Nessuna predefinita (chiedi ogni volta)',\n    // Sidebar\n    sidebarOrder: 'Ordine barra laterale',\n    // Camera\n    saveThumbnails: 'Salva miniature',\n    captureFinishPhoto: 'Acquisisci foto finale',\n    noPrintersConfigured: 'Nessuna stampante configurata',\n    // Archive settings\n    archiveMode: {\n      always: 'Crea sempre voce archivio',\n      never: 'Non creare mai voce archivio',\n      ask: 'Chiedi ogni volta',\n    },\n    // Updates\n    checkForUpdatesLabel: 'Controlla aggiornamenti',\n    checkPrinterFirmware: 'Controlla firmware stampante',\n    includeBetaUpdates: 'Includi versioni beta',\n    includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',\n    // Queue\n    enableRetry: 'Abilita retry',\n    // Home Assistant\n    homeAssistantDescription: 'Controlla prese smart tramite Home Assistant',\n    environmentManagedLabel: '(Gestito dall\\'ambiente)',\n    autoEnabledViaEnv: 'Abilitato automaticamente tramite variabili d\\'ambiente',\n    urlFromEnvReadOnly: 'Valore impostato dalla variabile d\\'ambiente HA_URL (sola lettura)',\n    tokenFromEnvReadOnly: 'Valore impostato dalla variabile d\\'ambiente HA_TOKEN (sola lettura)',\n    // MQTT\n    mqttConnectedTo: 'Connesso a',\n    // Prometheus\n    prometheusDescription: 'Esponi dati stampante in formato Prometheus',\n    // Smart plugs empty state\n    noSmartPlugsTitle: 'Nessuna presa smart configurata',\n    noSmartPlugsDescription: 'Aggiungi una presa smart Tasmota per monitorare energia e automatizzare il controllo.',\n    // Notifications empty state\n    noProvidersTitle: 'Nessun provider configurato',\n    noProvidersDescription: 'Aggiungi un provider per ricevere avvisi.',\n    noTemplatesAvailable: 'Nessun template disponibile. Riavvia il backend per generare i template predefiniti.',\n    // API permissions\n    apiPermissionView: 'Visualizza stato stampante e coda',\n    apiPermissionEdit: 'Aggiungi e rimuovi elementi dalla coda di stampa',\n    // API keys\n    apiKeysEmptyTitle: 'Nessuna chiave API',\n    apiKeysEmptyDescription: 'Crea una chiave API per integrare servizi esterni.',\n    // Users\n    noUsersFound: 'Nessun utente trovato',\n    noGroupsFound: 'Nessun gruppo trovato',\n    noGroupsAvailable: 'Nessun gruppo disponibile',\n    passwordsDoNotMatch: 'Le password non coincidono',\n    systemGroupWarning: 'I nomi dei gruppi di sistema non possono essere modificati',\n    // Auth disabled\n    authDisabledTitle: 'Autenticazione disabilitata',\n    authDisabledFeature1: 'Richiedi accesso per usare il sistema',\n    authDisabledFeature2: 'Crea più utenti con permessi basati sui gruppi',\n    authDisabledFeature3: 'Controlla accesso con 50+ permessi granulari',\n    // User deletion\n    userHasCreated: 'Questo utente ha creato:',\n    userItemsQuestion: 'Cosa vuoi fare con questi elementi?',\n    deleteUserConfirm: 'Sei sicuro di voler eliminare questo utente?',\n    actionCannotBeUndone: 'Questa azione non può essere annullata.',\n    // Smart plugs\n    addFirstSmartPlug: 'Aggiungi la tua prima presa smart',\n    // Notifications\n    providers: 'Provider',\n    log: 'Log',\n    testAll: 'Testa tutto',\n    testResults: 'Risultati test',\n    testPassedCount: '{{count}} riusciti',\n    testFailedCount: '{{count}} falliti',\n    messageTemplates: 'Template messaggi',\n    messageTemplatesDescription: 'Personalizza i messaggi per ogni evento.',\n    // API Keys section\n    apiKeys: 'Chiavi API',\n    apiKeysDescription: 'Crea chiavi API per integrazioni esterne e webhook.',\n    createKey: 'Crea chiave',\n    apiKeyCreated: 'Chiave API creata con successo',\n    apiKeyCopyWarning: 'Copia questa chiave ora - non verra mostrata di nuovo!',\n    useInApiBrowser: 'Usa nel Browser API',\n    createNewApiKey: 'Crea nuova chiave API',\n    keyName: 'Nome chiave',\n    keyNamePlaceholder: 'es., Home Assistant, OctoPrint',\n    readStatus: 'Leggi stato',\n    readStatusDescription: 'Visualizza stato stampante e coda',\n    manageQueue: 'Gestisci coda',\n    manageQueueDescription: 'Aggiungi e rimuovi elementi dalla coda di stampa',\n    controlPrinter: 'Controlla stampante',\n    controlPrinterDescription: 'Metti in pausa, riprendi e ferma stampe',\n    unnamedKey: 'Chiave senza nome',\n    lastUsed: 'Ultimo uso',\n    read: 'Lettura',\n    control: 'Controllo',\n    createFirstKey: 'Crea la tua prima chiave',\n    webhookEndpoints: 'Endpoint webhook',\n    webhookApiKeyHint: 'Usa la tua chiave API nell\\'header X-API-Key.',\n    webhook: {\n      getAllStatus: 'Ottieni stato di tutte le stampanti',\n      getSpecificStatus: 'Ottieni stato di una stampante',\n      addToQueue: 'Aggiungi alla coda di stampa',\n      pausePrint: 'Metti in pausa stampa',\n      resumePrint: 'Riprendi stampa',\n      stopPrint: 'Ferma stampa',\n    },\n    apiBrowser: 'Browser API',\n    apiBrowserDescription: 'Esplora e testa tutti gli endpoint API disponibili.',\n    apiKeyForTesting: 'Chiave API per test',\n    apiKeyPlaceholder: 'Incolla qui la tua chiave API per testare gli endpoint autenticati...',\n    apiKeyHint: 'Questa chiave verra inviata come header X-API-Key.',\n    deleteApiKeyTitle: 'Elimina chiave API',\n    deleteApiKeyMessage: 'Sei sicuro di voler eliminare questa chiave API? Le integrazioni che la usano non funzioneranno più.',\n    deleteKey: 'Elimina chiave',\n    // Filament tab\n    amsDisplayThresholds: 'Soglie visualizzazione AMS',\n    amsThresholdsDescription: 'Configura soglie colore per umidità e temperatura AMS.',\n    humidity: 'Umidità',\n    goodGreen: 'Buono (verde)',\n    fairOrange: 'Discreto (arancione)',\n    aboveFairBad: 'Sopra soglia discreta mostra rosso (scarso)',\n    fairAlsoDryingThreshold: 'Questa soglia viene usata anche per attivare l\\'asciugatura automatica',\n    temperature: 'Temperatura',\n    goodBlue: 'Buono (blu)',\n    aboveFairHot: 'Sopra soglia discreta mostra rosso (caldo)',\n    historyRetention: 'Conservazione cronologia',\n    keepSensorHistory: 'Mantieni cronologia sensori per',\n    historyRetentionDescription: 'I dati più vecchi saranno eliminati automaticamente',\n    defaultPrintOptions: 'Opzioni di stampa predefinite',\n    defaultPrintOptionsDescription: 'Imposta i valori predefiniti per le opzioni di stampa. Possono essere modificati nella finestra di stampa.',\n    defaultBedLevelling: 'Livellamento piatto',\n    defaultBedLevellingDesc: 'Livellamento automatico del piatto prima della stampa',\n    defaultFlowCali: 'Calibrazione flusso',\n    defaultFlowCaliDesc: 'Calibra il flusso di estrusione',\n    defaultVibrationCali: 'Calibrazione vibrazioni',\n    defaultVibrationCaliDesc: 'Riduce gli artefatti di ringing',\n    defaultLayerInspect: 'Ispezione primo strato',\n    defaultLayerInspectDesc: 'Ispezione IA del primo strato',\n    defaultTimelapse: 'Timelapse',\n    defaultTimelapseDesc: 'Registra un video timelapse',\n    staggeredStart: 'Staggered Start',\n    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',\n    plateClear: 'Conferma piatto libero',\n    requirePlateClear: 'Richiedi conferma piatto libero',\n    requirePlateClearDescription: 'Quando questa opzione è abilitata, lo scheduler attende una conferma per stampante che il piatto sia libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitandola vengono nascosti anche il badge di stato del piatto e il pulsante \"Segna il piatto come liberato\" sulle schede stampante.',\n    gcodeInjection: 'Iniezione G-code',\n    gcodeInjectionDescription: 'Configura G-code personalizzato da iniettare all\\'inizio e/o alla fine delle stampe per sistemi di stampa automatica come Farmloop, SwapMod, AutoClear e Printflow 3D. Gli snippet sono configurati per modello di stampante e applicati quando \"Inietta G-code\" è abilitato su un elemento della coda.',\n    gcodeInjectionNoPrinters: 'Nessuna stampante trovata. Aggiungi stampanti per configurare gli snippet G-code.',\n    gcodeStartLabel: 'G-code iniziale',\n    gcodeEndLabel: 'G-code finale',\n    gcodeStartPlaceholder: 'G-code inserito prima dell\\'inizio della stampa...',\n    gcodeEndPlaceholder: 'G-code aggiunto dopo la fine della stampa...',\n    staggerGroupSize: 'Group size',\n    staggerGroupSizeHelp: 'Printers to start simultaneously per group',\n    staggerInterval: 'Interval (minutes)',\n    staggerIntervalHelp: 'Delay between each group starting',\n    queueDrying: 'Asciugatura automatica',\n    queueDryingDescription: 'Asciugare automaticamente il filamento AMS quando la stampante è inattiva tra le stampe in coda. Usa la soglia di umidità sopra.',\n    queueDryingEnabled: 'Abilita asciugatura automatica',\n    queueDryingEnabledDescription: 'Avvia l\\'asciugatura AMS automaticamente quando la stampante è inattiva e l\\'umidità supera la soglia',\n    queueDryingBlock: 'Attendi completamento asciugatura',\n    queueDryingBlockDescription: 'Blocca la coda di stampa fino al completamento dell\\'asciugatura. Se disattivato, le stampe hanno priorità.',\n    ambientDryingEnabled: 'Asciugatura ambientale',\n    ambientDryingEnabledDescription: 'Asciuga automaticamente il filamento sulle stampanti inattive quando l\\'umidità supera la soglia, anche senza stampe in coda.',\n    dryingPresets: 'Preset di asciugatura',\n    dryingPresetsDescription: 'Temperatura e durata per tipo di filamento. AMS 2 Pro usa temperature più basse, AMS-HT supporta temperature più alte.',\n    dryingFilament: 'Filamento',\n    printModal: 'Modale stampa',\n    expandCustomMapping: 'Espandi mapping personalizzato di default',\n    expandCustomMappingDescription: 'Quando stampi su più stampanti, mostra mapping AMS per stampante espanso',\n    // User management\n    authentication: 'Autenticazione',\n    authEnabledDescription: 'La tua istanza è protetta con autenticazione',\n    authDisabledDescription: 'Abilita per richiedere accesso e gestire utenti',\n    authDisabledMessage: 'Abilita autenticazione per creare account, gestire permessi e proteggere la tua istanza Bambuddy.',\n    enableAuthentication: 'Abilita autenticazione',\n    currentUser: 'Utente corrente',\n    changePassword: 'Cambia password',\n    admin: 'Admin',\n    users: 'Utenti',\n    addUser: 'Aggiungi utente',\n    groups: 'Gruppi',\n    addGroup: 'Aggiungi gruppo',\n    system: 'Sistema',\n    noDescription: 'Nessuna descrizione',\n    userCount: '{{count}} utenti',\n    permissionCount: '{{count}} permessi',\n    createUser: 'Crea utente',\n    username: 'Nome utente',\n    enterUsername: 'Inserisci nome utente',\n    password: 'Password',\n    enterPassword: 'Inserisci password (min 6 caratteri)',\n    confirmPassword: 'Conferma password',\n    confirmPasswordPlaceholder: 'Conferma password',\n    // Title tooltips\n    viewReleaseOnGitHub: 'Vedi release su GitHub',\n    turnAllPlugsOn: 'Accendi tutte le prese',\n    turnAllPlugsOff: 'Spegni tutte le prese',\n    // Modal: Clear logs\n    clearNotificationLogs: 'Cancella log notifiche',\n    clearLogsMessage: 'Questo eliminerà definitivamente tutti i log notifiche più vecchi di 30 giorni. Questa azione non può essere annullata.',\n    clearLogs: 'Cancella log',\n    // Modal: Reset UI\n    resetUiPreferences: 'Reimposta preferenze UI',\n    resetUiPreferencesMessage: 'Questo reimposterà le preferenze UI ai valori predefiniti: ordine barra laterale, tema, layout dashboard, modalità vista e preferenze ordinamento. Stampanti, archivi e impostazioni server NON saranno modificati. La pagina si ricaricherà dopo la cancellazione.',\n    resetPreferences: 'Reimposta preferenze',\n    // Modal: Delete group\n    deleteGroupTitle: 'Elimina gruppo',\n    deleteGroupMessage: 'Sei sicuro di voler eliminare questo gruppo? Gli utenti in questo gruppo perderanno questi permessi.',\n    deleteGroup: 'Elimina gruppo',\n    // Modal: Disable auth\n    disableAuthenticationTitle: 'Disabilita autenticazione',\n    disableAuthenticationMessage: 'Sei sicuro di voler disabilitare l\\'autenticazione? Questo renderà la tua istanza Bambuddy accessibile senza login. Tutti gli utenti resteranno nel database ma l\\'autenticazione sarà disabilitata.',\n    disableAuthentication: 'Disabilita autenticazione',\n    // Additional settings\n    configureBambuddy: 'Configura Bambuddy',\n    systemDefault: 'Predefinito di sistema',\n    archiveSettings: 'Impostazioni archivio',\n    newWindow: 'Nuova finestra',\n    embeddedOverlay: 'Overlay incorporato',\n    preferredSlicer: 'Slicer preferito',\n    preferredSlicerDescription: 'Scegli quale applicazione slicer usare per aprire i file',\n    externalCameras: 'Camere esterne',\n    costTracking: 'Tracciamento costi',\n    printsOnly: 'Solo stampe',\n    totalConsumption: 'Consumo totale',\n    dataManagement: 'Gestione dati',\n    storageUsage: 'Memoria utilizzata',\n    storageUsageDescription: 'Ripartizione della memoria per categoria',\n    storageUsageTotal: 'Totale',\n    storageUsageErrors: 'Errori',\n    storageUsageOtherBreakdown: 'Altro (include risorse statiche, script e file di configurazione)',\n    storageUsageSystem: 'Sistema',\n    storageUsageData: 'Dati',\n    storageUsageUnavailable: 'Informazioni sull\\'utilizzo della memoria non disponibili',\n    clearNotificationLogsDescription: 'Elimina log notifiche più vecchi di 30 giorni',\n    resetUiPreferencesDescription: 'Reimposta ordine barra laterale, tema, modalità vista e preferenze layout. Stampanti, archivi e impostazioni non vengono modificati.',\n    enableHomeAssistant: 'Abilita Home Assistant',\n    enableMqtt: 'Abilita MQTT',\n    useTls: 'Usa TLS',\n    enableMetricsEndpoint: 'Abilita endpoint metriche',\n    availableMetrics: 'Metriche disponibili',\n    editUser: 'Modifica utente',\n    deleteUserTitle: 'Elimina utente',\n    groupName: 'Nome gruppo',\n    // Placeholders\n    leaveEmptyForAnonymous: 'Lascia vuoto per anonimo',\n    leaveEmptyForNoAuth: 'Lascia vuoto per nessuna autenticazione',\n    enterNewPassword: 'Inserisci nuova password',\n    confirmNewPassword: 'Conferma nuova password',\n    enterGroupName: 'Inserisci nome gruppo',\n    enterDescriptionOptional: 'Inserisci descrizione (opzionale)',\n    enterCurrentPassword: 'Inserisci password attuale',\n    enterNewPasswordMin6: 'Inserisci nuova password (min 6 caratteri)',\n    toast: {\n      keyCopied: 'Chiave copiata negli appunti',\n      copyFailed: 'Copia chiave fallita',\n      keyAddedToBrowser: 'Chiave aggiunta al Browser API',\n      clearLogsFailed: 'Eliminazione log fallita',\n      uiPreferencesReset: 'Preferenze UI reimpostate. Aggiornamento...',\n      authDisabled: 'Autenticazione disabilitata con successo',\n      authDisableFailed: 'Disabilitazione autenticazione fallita',\n      apiKeyCreated: 'Chiave API creata',\n      apiKeyDeleted: 'Chiave API eliminata',\n      userCreated: 'Utente creato con successo',\n      userUpdated: 'Utente aggiornato con successo',\n      userDeleted: 'Utente eliminato con successo',\n      groupCreated: 'Gruppo creato con successo',\n      groupUpdated: 'Gruppo aggiornato con successo',\n      groupDeleted: 'Gruppo eliminato con successo',\n      fillRequiredFields: 'Compila tutti i campi obbligatori',\n      passwordsDoNotMatch: 'Le password non coincidono',\n      passwordTooShort: 'La password deve essere di almeno 6 caratteri',\n      enterGroupName: 'Inserisci un nome gruppo',\n      settingsSaved: 'Impostazioni salvate',\n      cameraSettingsSaved: 'Impostazioni camera salvate',\n      enterCameraUrl: 'Inserisci un URL camera',\n      passwordChanged: 'Password cambiata con successo',\n      connectionFailed: 'Connessione fallita',\n      testFailed: 'Test fallito',\n      cameraConnected: 'Camera connessa{{resolution}}',\n    },\n    testConnection: 'Testa connessione',\n    catalog: {\n      spoolCatalog: 'Catalogo bobine',\n      spoolCatalogDescription: 'Pesi delle bobine vuote per marca/tipo. Utilizzato per la ricerca automatica del peso quando si aggiungono bobine.',\n      searchCatalog: 'Cerca nel catalogo...',\n      addNewEntry: 'Aggiungi nuova voce',\n      namePlaceholder: 'Nome (es. Bambu Lab - Plastica)',\n      weight: 'Peso',\n      type: 'Tipo',\n      default: 'Predefinito',\n      custom: 'Personalizzato',\n      noMatch: 'Nessuna voce corrisponde alla ricerca',\n      empty: 'Nessuna voce nel catalogo',\n      deleteEntry: 'Elimina voce',\n      deleteConfirm: 'Sei sicuro di voler eliminare \"{{name}}\"?',\n      resetCatalog: 'Ripristina catalogo',\n      resetConfirm: 'Ripristinare il catalogo ai valori predefiniti? Tutte le voci personalizzate verranno rimosse.',\n      loadFailed: 'Impossibile caricare il catalogo bobine',\n      nameWeightRequired: 'Nome e peso sono obbligatori',\n      entryAdded: 'Voce aggiunta',\n      addFailed: 'Impossibile aggiungere la voce',\n      entryUpdated: 'Voce aggiornata',\n      updateFailed: 'Impossibile aggiornare la voce',\n      entryDeleted: 'Voce eliminata',\n      deleteFailed: 'Impossibile eliminare la voce',\n      resetSuccess: 'Catalogo ripristinato ai valori predefiniti',\n      resetFailed: 'Impossibile ripristinare il catalogo',\n      exported: '{{count}} voci esportate',\n      imported: '{{added}} voci importate ({{skipped}} saltate)',\n      importFailed: 'Impossibile importare: formato JSON non valido',\n      exportTooltip: 'Esporta catalogo in JSON',\n      importTooltip: 'Importa catalogo da JSON',\n      resetTooltip: 'Ripristina valori predefiniti',\n      selectedCount: '{{count}} selezionati',\n      deleteSelected: 'Elimina selezionati',\n      bulkDeleteConfirm: 'Eliminare {{count}} voci?',\n      bulkDeleted: '{{count}} voci eliminate',\n      bulkDeleteFailed: 'Impossibile eliminare le voci',\n    },\n    colorCatalog: {\n      title: 'Catalogo colori',\n      description: 'Colori del filamento per produttore/materiale. Utilizzato per la ricerca automatica del colore quando si aggiungono bobine.',\n      searchColors: 'Cerca colori...',\n      allManufacturers: 'Tutti i produttori',\n      addNewColor: 'Aggiungi nuovo colore',\n      manufacturer: 'Produttore',\n      colorName: 'Nome colore',\n      hex: 'Hex',\n      materialOptional: 'Materiale (opzionale)',\n      showing: 'Visualizzazione di {{filtered}} su {{total}} colori',\n      noMatch: 'Nessun colore corrisponde alla ricerca',\n      empty: 'Nessun colore nel catalogo',\n      deleteColor: 'Elimina colore',\n      deleteConfirm: 'Sei sicuro di voler eliminare \"{{name}}\"?',\n      resetCatalog: 'Ripristina catalogo colori',\n      resetConfirm: 'Ripristinare il catalogo ai valori predefiniti? Tutti i colori personalizzati verranno rimossi.',\n      sync: 'Sincronizza',\n      starting: 'Avvio...',\n      syncTooltip: 'Sincronizza da FilamentColors.xyz (2000+ colori, potrebbe richiedere un minuto)',\n      loadFailed: 'Impossibile caricare il catalogo colori',\n      fieldsRequired: 'Produttore, nome colore e colore hex sono obbligatori',\n      colorAdded: 'Colore aggiunto',\n      addFailed: 'Impossibile aggiungere il colore',\n      colorUpdated: 'Colore aggiornato',\n      updateFailed: 'Impossibile aggiornare il colore',\n      colorDeleted: 'Colore eliminato',\n      deleteFailed: 'Impossibile eliminare il colore',\n      resetSuccess: 'Catalogo colori ripristinato ai valori predefiniti',\n      resetFailed: 'Impossibile ripristinare il catalogo',\n      syncUpToDate: 'Già aggiornato ({{count}} colori verificati)',\n      syncComplete: '{{added}} nuovi colori aggiunti ({{skipped}} già esistenti)',\n      syncError: 'Errore di sincronizzazione',\n      syncFailed: 'Impossibile sincronizzare da FilamentColors.xyz',\n      exported: '{{count}} colori esportati',\n      imported: '{{added}} colori importati ({{skipped}} saltati)',\n      importFailed: 'Impossibile importare: formato JSON non valido',\n      selectedCount: '{{count}} selezionati',\n      deleteSelected: 'Elimina selezionati',\n      bulkDeleteConfirm: 'Eliminare {{count}} colori?',\n      bulkDeleted: '{{count}} colori eliminati',\n      bulkDeleteFailed: 'Impossibile eliminare i colori',\n    },\n    dateFormat: 'Formato data',\n    dateFormatUs: 'US (MM/GG/AAAA)',\n    dateFormatEu: 'EU (GG/MM/AAAA)',\n    dateFormatIso: 'ISO (AAAA-MM-GG)',\n    timeFormat: 'Formato ora',\n    timeFormat12: '12 ore (3:30 PM)',\n    timeFormat24: '24 ore (15:30)',\n    defaultPrinter: 'Stampante predefinita',\n    defaultPrinterDescription: 'Preseleziona questa stampante per upload, ristampe e altre operazioni.',\n    slicerBambuStudio: 'Bambu Studio',\n    slicerOrcaSlicer: 'OrcaSlicer',\n    sidebarOrderDescription: 'Trascina gli elementi nella barra laterale per riordinare. Ripristina l\\'ordine predefinito qui.',\n    setDefault: 'Imposta predefinito',\n    sidebarOrderSetDefaultHint: 'Imposta predefinito applica l\\'ordine attuale del menu agli utenti che non hanno ancora personalizzato il proprio.',\n    sidebarDefaultSet: 'L\\'ordine predefinito del menu è stato impostato.',\n    sidebarDefaultCleared: 'Ordine predefinito del menu cancellato.',\n    sidebarDefaultFailed: 'Impossibile impostare l\\'ordine predefinito del menu.',\n    reset: 'Ripristina',\n    darkMode: 'Modalità scura',\n    lightMode: 'Modalità chiara',\n    active: '(attivo)',\n    background: 'Sfondo',\n    accent: 'Accento',\n    style: 'Stile',\n    bgNeutral: 'Neutro',\n    bgWarm: 'Caldo',\n    bgCool: 'Freddo',\n    bgOled: 'OLED Nero',\n    bgSlate: 'Blu ardesia',\n    bgForest: 'Verde foresta',\n    accentGreen: 'Verde',\n    accentTeal: 'Verde acqua',\n    accentBlue: 'Blu',\n    accentOrange: 'Arancione',\n    accentPurple: 'Viola',\n    accentRed: 'Rosso',\n    styleClassic: 'Classico',\n    styleGlow: 'Luminoso',\n    styleVibrant: 'Vibrante',\n    themeToggleHint: 'Passa tra modalità scura e chiara con l\\'icona sole/luna nella barra laterale.',\n    autoArchivePrints: 'Archiviazione automatica stampe',\n    autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',\n    saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',\n    captureFinishPhotoDescription: 'Scatta una foto dalla fotocamera della stampante al completamento della stampa',\n    ffmpegNotInstalled: 'ffmpeg non installato',\n    ffmpegRequired: 'L\\'acquisizione dalla fotocamera richiede ffmpeg. Installalo tramite <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).',\n    camera: 'Fotocamera',\n    cameraViewMode: 'Modalità visualizzazione fotocamera',\n    cameraOverlayDescription: 'La fotocamera si apre in un overlay ridimensionabile sulla schermata principale',\n    cameraWindowDescription: 'La fotocamera si apre in una finestra separata del browser',\n    externalCamerasDescription: 'Configura fotocamere esterne per sostituire la fotocamera integrata della stampante. Supporta stream MJPEG, RTSP, snapshot HTTP e fotocamere USB (V4L2). Quando abilitata, la fotocamera esterna viene usata per la vista in diretta e le foto di completamento.',\n    cameraPlaceholderUsb: 'Percorso dispositivo (/dev/video0)',\n    cameraPlaceholderUrl: 'URL fotocamera (rtsp://... o http://...)',\n    cameraTypeMjpeg: 'Stream MJPEG',\n    cameraTypeRtsp: 'Stream RTSP',\n    cameraTypeSnapshot: 'Snapshot HTTP',\n    cameraTypeUsb: 'Fotocamera USB (V4L2)',\n    cameraRotation: 'Rotazione',\n    test: 'Test',\n    connected: 'Connesso',\n    disconnected: 'Disconnesso',\n    currency: 'Valuta',\n    defaultFilamentCost: 'Costo filamento predefinito (per kg)',\n    electricityCost: 'Costo elettricità per kWh',\n    energyDisplayMode: 'Modalità visualizzazione energia',\n    energyModePrintDescription: 'La dashboard mostra la somma dell\\'energia usata durante le stampe',\n    energyModeTotalDescription: 'La dashboard mostra l\\'energia totale dalle prese smart',\n    fileManager: 'Gestore file',\n    createArchiveEntry: 'Crea voce archivio durante la stampa',\n    createArchiveEntryDescription: 'Quando si stampa dal gestore file, crea opzionalmente una voce di archivio',\n    lowDiskSpaceWarning: 'Avviso spazio disco insufficiente',\n    lowDiskSpaceDescription: 'Mostra avviso quando lo spazio disco scende sotto questa soglia',\n    printerFirmware: 'Firmware stampante',\n    checkFirmwareDescription: 'Controlla aggiornamenti firmware da Bambu Lab',\n    bambuddySoftware: 'Software Bambuddy',\n    autoCheckDescription: 'Controlla automaticamente nuove versioni all\\'avvio',\n    checkNow: 'Controlla ora',\n    updateAvailableVersion: 'Aggiornamento disponibile: v{{version}}',\n    releaseNotes: 'Note di rilascio',\n    updateViaDocker: 'Aggiorna tramite Docker Compose:',\n    installUpdate: 'Installa aggiornamento',\n    latestVersionRunning: 'Stai usando l\\'ultima versione',\n    failedToCheckUpdates: 'Controllo aggiornamenti fallito: {{error}}',\n    backupRestore: 'Backup e ripristino',\n    backupRestoreDescription: 'Esporta/importa impostazioni e configura backup GitHub',\n    goToBackup: 'Vai al backup',\n    externalUrl: 'URL esterno',\n    externalUrlDescription: 'L\\'URL esterno dove Bambuddy è accessibile. Usato per immagini di notifica e integrazioni esterne.',\n    bambuddyUrl: 'URL Bambuddy',\n    externalUrlHint: 'Includi protocollo e porta (es. http://192.168.1.100:8000)',\n    ftpRetry: 'Riprova FTP',\n    ftpRetryDescription: 'Riprova le operazioni FTP quando il WiFi della stampante è instabile. Si applica a download 3MF, upload stampe, download timelapse e aggiornamenti firmware.',\n    autoRetryDescription: 'Riprova automaticamente le operazioni FTP fallite',\n    retryAttempts: 'Tentativi di ripetizione',\n    retryDelay: 'Ritardo ripetizione',\n    connectionTimeout: 'Timeout connessione',\n    time_one: '{{count}} volta',\n    time_other: '{{count}} volte',\n    second_one: '{{count}} secondo',\n    second_other: '{{count}} secondi',\n    nSeconds: '{{count}} secondi',\n    increaseForWeakWifi: 'Aumenta per stampanti con WiFi debole',\n    homeAssistant: 'Home Assistant',\n    homeAssistantFullDescription: 'Connetti a Home Assistant per controllare le prese smart tramite l\\'API REST HA. Supporta entità switch, light, input_boolean e script.',\n    homeAssistantUrl: 'URL Home Assistant',\n    longLivedAccessToken: 'Token di accesso a lunga durata',\n    haTokenHint: 'Crea un token in HA: Profilo → Token di accesso a lunga durata → Crea token',\n    connectionSuccessful: 'Connessione riuscita',\n    connectionFailed: 'Connessione fallita',\n    haConnectionSuccess: 'Connesso con successo a Home Assistant.',\n    haConnectionFailed: 'Connessione a Home Assistant fallita.',\n    mqttPublishing: 'Pubblicazione MQTT',\n    mqttDescription: 'Pubblica eventi BamBuddy su un broker MQTT esterno per l\\'integrazione con Node-RED, Home Assistant e altri sistemi di automazione.',\n    mqttEnableDescription: 'Pubblica eventi su broker MQTT esterno',\n    brokerHostname: 'Hostname broker',\n    port: 'Porta',\n    usernameOptional: 'Nome utente (opzionale)',\n    passwordOptional: 'Password (opzionale)',\n    topicPrefix: 'Prefisso topic',\n    topicPrefixHint: 'I topic saranno: {{prefix}}/printers/<serial>/status, ecc.',\n    prometheusMetrics: 'Metriche Prometheus',\n    prometheusEndpointDescription: 'Esponi metriche stampante su <code>/api/v1/metrics</code> per monitoraggio Prometheus/Grafana.',\n    bearerTokenOptional: 'Token Bearer (opzionale)',\n    bearerTokenHint: 'Se impostato, le richieste devono includere <code>Authorization: Bearer <token></code>',\n    metricsConnectionStatus: 'Stato connessione',\n    metricsPrinterState: 'Stato stampante (idle/printing/ecc)',\n    metricsPrintProgress: 'Progresso stampa 0-100%',\n    metricsBedTemp: 'Temperatura piatto',\n    metricsNozzleTemp: 'Temperatura ugello',\n    metricsPrintsTotal: 'Stampe totali per risultato',\n    metricsMore: '...e altro (strati, ventole, coda, consumo filamento)',\n    smartPlugsDescription: 'Connetti prese smart (Tasmota o Home Assistant) per automatizzare il controllo dell\\'alimentazione e monitorare il consumo energetico delle stampanti.',\n    allOn: 'Tutte accese',\n    allOff: 'Tutte spente',\n    addSmartPlug: 'Aggiungi presa smart',\n    energySummary: 'Riepilogo energia',\n    currentPower: 'Potenza attuale',\n    plugsOnline: '{{reachable}}/{{total}} prese online',\n    today: 'Oggi',\n    yesterday: 'Ieri',\n    total: 'Totale',\n    enablePlugsForSummary: 'Abilita le prese per vedere il riepilogo energia',\n    addNotificationProvider: 'Aggiungi',\n    systemBadge: '(Sistema)',\n    creating: 'Creazione...',\n    changing: 'Modifica...',\n    deleteUserAndItems: 'Elimina utente E i suoi elementi',\n    deleteUserKeepItems: 'Elimina utente, mantieni elementi (diventeranno senza proprietario)',\n    ok: 'OK',\n\n    // 2FA settings\n    twoFa: {\n      totpTitle: 'App Authenticator (TOTP)',\n      totpDesc: 'Usa un\\'app come Google Authenticator, Aegis o Authy.',\n      emailOtpTitle: 'OTP via e-mail',\n      emailOtpDesc: 'Invia un codice monouso a {{email}} al momento del login.',\n      emailOtpNoEmail: 'Aggiungi un indirizzo e-mail al tuo account per abilitare questo metodo.',\n      addEmailFirst: 'Il tuo account non ha un indirizzo e-mail. Chiedi a un amministratore di aggiungerne uno.',\n      setupTotp: 'Configura app Authenticator',\n      setupAuthApp: 'Configura app Authenticator',\n      setupInstructions: 'Scansiona il codice QR con la tua app authenticator, poi conferma con un codice.',\n      manualEntry: 'Impossibile scansionare? Inserisci questo segreto manualmente:',\n      scannedContinue: 'Codice scansionato — continua',\n      enterCodeToConfirm: 'Inserisci il codice a 6 cifre dalla tua app authenticator per confermare.',\n      activate: 'Attiva',\n      disableTotp: 'Disabilita Authenticator',\n      disableConfirmHint: 'Inserisci un codice TOTP valido o un codice di backup per disabilitare l\\'authenticator.',\n      totpDisabled: 'App Authenticator disabilitata.',\n      emailOtpEnabled: 'OTP via e-mail abilitato.',\n      emailOtpDisabled: 'OTP via e-mail disabilitato.',\n      smtpRequired: 'Configura e testa prima le impostazioni SMTP.',\n      invalidCode: 'Codice non valido. Riprova.',\n      enableEmailOtp: 'Abilita OTP via e-mail',\n      disableEmailOtp: 'Disabilita OTP via e-mail',\n      emailSetupEnterCode: 'È stato inviato un codice di verifica al tuo indirizzo e-mail. Inseriscilo qui sotto per confermare che possiedi questa casella di posta.',\n      verifyAndEnable: 'Verifica e abilita',\n      emailDisablePasswordHint: 'Inserisci la password del tuo account per confermare la disabilitazione dell\\'OTP via e-mail.',\n      passwordPlaceholder: 'Inserisci la tua password',\n      backupCodesTitle: 'Salva i tuoi codici di backup',\n      backupCodesWarning: 'Conserva questi codici in un posto sicuro. Ogni codice può essere usato una sola volta.',\n      backupCodesRemaining: '{{count}} codici di backup rimanenti',\n      savedCodes: 'Codici salvati',\n      regenBackup: 'Rigenera codici di backup',\n      regenBackupHint: 'Inserisci il tuo codice TOTP corrente per generare 10 nuovi codici di backup.',\n      newBackupCodes: 'Nuovi codici di backup',\n      linkedAccounts: 'Account SSO collegati',\n      linkedAccountsDesc: 'Questi provider di identità esterni sono collegati al tuo account.',\n      oidcUnlinked: 'Account scollegato.',\n    },\n\n    // OIDC provider settings\n    oidc: {\n      title: 'Provider SSO / OIDC',\n      desc: 'Configura provider OpenID Connect per il single sign-on.',\n      addProvider: 'Aggiungi provider',\n      newProvider: 'Nuovo provider',\n      empty: 'Nessun provider OIDC configurato.',\n      created: 'Provider creato.',\n      updated: 'Provider aggiornato.',\n      deleted: 'Provider eliminato.',\n      deleteTitle: 'Elimina provider',\n      deleteMessage: 'Eliminare \"{{name}}\"? Tutti gli account collegati verranno disconnessi.',\n      form: {\n        name: 'Nome visualizzato',\n        issuerUrl: 'URL emittente',\n        clientId: 'Client ID',\n        clientSecret: 'Client secret',\n        scopes: 'Scope',\n        iconUrl: 'URL icona (opzionale)',\n        enabled: 'Abilitato',\n        autoCreate: 'Crea utenti automaticamente',\n        autoCreateDesc: 'Crea automaticamente un account locale al primo accesso.',\n        autoLink: 'Collega automaticamente gli account esistenti',\n        autoLinkDesc: 'Collega gli account locali esistenti tramite email al primo accesso.',\n        secretHint: 'lascia vuoto per mantenere',\n        secretPlaceholder: 'nuovo segreto',\n      },\n    },\n\n  },\n\n  // Notifications (for push notifications)\n  notification: {\n    printStarted: {\n      title: 'Stampa avviata',\n      body: '{{printer}}: {{filename}} ha iniziato a stampare',\n    },\n    printCompleted: {\n      title: 'Stampa completata',\n      body: '{{printer}}: {{filename}} completata con successo',\n    },\n    printFailed: {\n      title: 'Stampa fallita',\n      body: '{{printer}}: {{filename}} fallita',\n    },\n    printStopped: {\n      title: 'Stampa interrotta',\n      body: '{{printer}}: {{filename}} interrotta',\n    },\n    printProgress: {\n      title: 'Avanzamento stampa',\n      body: '{{printer}}: {{filename}} al {{percent}}% completamento',\n    },\n    printerOffline: {\n      title: 'Stampante offline',\n      body: '{{printer}} e offline',\n    },\n    printerError: {\n      title: 'Errore stampante',\n      body: '{{printer}}: {{error}}',\n    },\n    filamentLow: {\n      title: 'Filamento in esaurimento',\n      body: '{{printer}}: Filamento in esaurimento',\n    },\n    maintenanceDue: {\n      title: 'Manutenzione dovuta',\n      body: '{{printer}}: {{items}} richiedono attenzione',\n    },\n  },\n\n  // Errors\n  errors: {\n    generic: 'Qualcosa e andato storto',\n    networkError: 'Errore di rete. Controlla la connessione.',\n    notFound: 'Non trovato',\n    unauthorized: 'Non autorizzato',\n    serverError: 'Errore server',\n    validationError: 'Controlla i dati inseriti',\n    printerConnectionFailed: 'Connessione alla stampante fallita',\n    saveFailed: 'Salvataggio modifiche fallito',\n    deleteFailed: 'Eliminazione fallita',\n    loadFailed: 'Caricamento dati fallito',\n  },\n\n  // HMS Errors modal\n  hmsErrors: {\n    title: 'Errori - {{name}}',\n    noErrors: 'Nessun errore',\n    viewOnWiki: 'Vedi su Bambu Lab Wiki',\n    clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',\n    clearErrors: 'Cancella errori',\n    clearSuccess: 'Errori HMS cancellati',\n    clearFailed: 'Impossibile cancellare gli errori HMS',\n  },\n\n  // MQTT Debug modal\n  mqttDebug: {\n    title: 'Log debug MQTT',\n    searchPlaceholder: 'Cerca topic o payload...',\n    noMessages: 'Nessun messaggio registrato',\n    startLoggingHint: 'Clicca \"Avvia logging\" per iniziare a catturare messaggi MQTT',\n    noMessagesMatch: 'Nessun messaggio corrisponde al filtro',\n    adjustFilterHint: 'Prova a modificare la ricerca o i filtri',\n    incoming: 'In ingresso',\n    outgoing: 'In uscita',\n    loggingStopped: 'Logging fermato',\n    loggingActive: 'Logging attivo - i messaggi si aggiornano automaticamente',\n    startLogging: 'Avvia logging',\n    stopLogging: 'Ferma logging',\n    clearLog: 'Pulisci log',\n    topic: 'Topic',\n    timestamp: 'Timestamp',\n    direction: 'Direzione',\n    all: 'Tutti',\n  },\n\n  // Printer File Manager modal (printer internal storage)\n  printerFiles: {\n    title: 'Gestore file',\n    storageUsed: 'Usato:',\n    storageFree: 'Libero:',\n    filterPlaceholder: 'Filtra file...',\n    deleteButton: 'Elimina',\n    deleteFiles: 'Elimina {{count}} file',\n    deleteFileConfirm: 'Eliminare \"{{name}}\"? Questa azione non può essere annullata.',\n    deleteFilesConfirm: 'Eliminare {{count}} file selezionati? Questa azione non può essere annullata.',\n    noFiles: 'Nessun file sulla stampante',\n    loadingFiles: 'Caricamento file...',\n    failedToLoad: 'Caricamento file fallito',\n    toast: {\n      filesDeleted: 'Eliminati {{count}} file',\n      deleteFailed: 'Eliminazione fallita: {{error}}',\n    },\n  },\n\n  // Confirmations\n  confirm: {\n    delete: 'Sei sicuro di voler eliminare questo?',\n    unsavedChanges: 'Hai modifiche non salvate. Sei sicuro di voler uscire?',\n    clearQueue: 'Sei sicuro di voler svuotare la coda?',\n  },\n\n  // Login page\n  login: {\n    title: 'Login Bambuddy',\n    subtitle: 'Accedi al tuo account',\n    username: 'Nome utente',\n    usernamePlaceholder: 'Inserisci il nome utente',\n    usernameOrEmail: 'Nome utente o email',\n    usernameOrEmailPlaceholder: 'Nome utente o @ Email',\n    password: 'Password',\n    passwordPlaceholder: 'Inserisci la password',\n    signIn: 'Accedi',\n    signingIn: 'Accesso in corso...',\n    forgotPassword: 'Hai dimenticato la password?',\n    loginSuccess: 'Accesso riuscito',\n    loginFailed: 'Accesso fallito',\n    enterCredentials: 'Inserisci nome utente e password',\n    enterEmail: 'Inserisci il tuo indirizzo e-mail',\n    oidcLoginFailed: 'Accesso OIDC fallito',\n    oidcErrors: {\n      providerError: \"Il provider di identità ha restituito un errore\",\n      missingParameters: 'Parametri obbligatori mancanti nel callback OIDC',\n      invalidState: 'Lo stato OIDC non è valido o è già stato utilizzato',\n      stateExpired: 'La sessione OIDC è scaduta — riprovare',\n      providerNotFound: 'Provider OIDC non trovato',\n      discoveryFailed: 'Impossibile recuperare il documento di discovery OIDC',\n      invalidDiscovery: 'Il documento di discovery OIDC non è valido',\n      networkError: \"Errore di rete durante lo scambio di token OIDC\",\n      badResponse: \"Risposta inattesa durante lo scambio di token OIDC\",\n      noIdToken: 'Il provider OIDC non ha restituito un ID token',\n      validationFailed: 'La validazione del token OIDC non è riuscita',\n      nonceMismatch: 'Il nonce OIDC non corrisponde — possibile attacco di replay',\n      missingSubClaim: 'Il token OIDC è privo del claim sub',\n      noLinkedAccount: 'Nessun account locale è collegato a questa identità OIDC',\n      accountInactive: 'Il tuo account è inattivo',\n      userResolutionFailed: 'Impossibile risolvere il tuo account',\n      internalError: \"Si è verificato un errore interno durante il login OIDC\",\n      tokenExchangeFailed: 'Lo scambio di token OIDC non è riuscito',\n    },\n    forgotPasswordTitle: 'Password dimenticata',\n    forgotPasswordMessage: 'Se hai dimenticato la password, contatta il tuo amministratore di sistema per reimpostarla.',\n    forgotPasswordEmailMessage: 'Inserisci il tuo indirizzo email e ti invieremo una nuova password.',\n    emailAddress: 'Indirizzo email',\n    emailPlaceholder: 'tua.email@esempio.com',\n    cancel: 'Annulla',\n    sending: 'Invio...',\n    sendResetEmail: 'Invia email di reimpostazione',\n    howToReset: 'Come reimpostare la password:',\n    resetStep1: 'Contatta il tuo amministratore Bambuddy',\n    resetStep2: 'Chiedi di reimpostare la password in Gestione utenti',\n    resetStep3: 'Possono impostare una nuova password temporanea',\n    resetStep4: 'Accedi con la nuova password e cambiala in Impostazioni',\n    gotIt: 'Capito',\n    twoFA: {\n      title: 'Autenticazione a due fattori',\n      subtitle: 'Il tuo account è protetto da 2FA. Inserisci il codice di verifica qui sotto.',\n      methodAuthenticator: 'App di autenticazione',\n      methodEmail: 'Codice via e-mail',\n      methodBackup: 'Codice di recupero',\n      instructionsTotp: \"Apri la tua app di autenticazione e inserisci il codice a 6 cifre per Bambuddy.\",\n      instructionsEmail: \"Un codice a 6 cifre è stato inviato al tuo indirizzo e-mail. È valido per 10 minuti.\",\n      instructionsEmailNotSent: 'Clicca il pulsante qui sotto per ricevere un codice di verifica via e-mail.',\n      instructionsBackup: 'Inserisci uno dei tuoi codici di recupero a 8 caratteri. Ogni codice può essere utilizzato una sola volta.',\n      sendCodeButton: 'Invia codice via e-mail',\n      sendingCode: 'Invio in corso...',\n      resendCode: 'Invia nuovamente il codice',\n      codeLabel: 'Codice di verifica',\n      backupCodeLabel: 'Codice di recupero',\n      codePlaceholder: '000000',\n      backupCodePlaceholder: 'XXXXXXXX',\n      verifyButton: 'Verifica',\n      verifyingButton: 'Verifica in corso...',\n      backToLogin: '← Torna alla pagina di accesso',\n      orContinueWith: 'oppure accedi con',\n      signInWith: 'Accedi con {{provider}}',\n      enterCode: 'Inserisci il codice di verifica',\n      sendCodeFailed: 'Invio del codice di verifica non riuscito',\n      invalidCode: 'Codice non valido. Riprova.',\n    },\n\n  },\n\n  // Setup page\n  setup: {\n    title: 'Configurazione Bambuddy',\n    subtitle: 'Configura autenticazione per la tua istanza Bambuddy',\n    enableAuth: 'Abilita autenticazione',\n    adminAccount: 'Account admin',\n    adminAccountDesc: 'Se esistono già admin, l\\'autenticazione verrà abilitata usando gli account esistenti. Lascia i campi sotto vuoti per usare gli admin esistenti, oppure inserisci nuove credenziali per creare un nuovo utente admin.',\n    adminUsername: 'Nome utente admin',\n    adminPassword: 'Password admin',\n    optionalIfAdminExists: '(opzionale se esistono admin)',\n    adminUsernamePlaceholder: 'Inserisci nome utente admin (opzionale)',\n    adminPasswordPlaceholder: 'Inserisci password admin (opzionale)',\n    confirmPassword: 'Conferma password',\n    confirmPasswordPlaceholder: 'Conferma password admin',\n    settingUp: 'Configurazione...',\n    completeSetup: 'Completa configurazione',\n    toast: {\n      authEnabledAdminCreated: 'Autenticazione abilitata e utente admin creato',\n      authEnabledExistingAdmins: 'Autenticazione abilitata usando admin esistenti',\n      setupCompleted: 'Configurazione completata',\n      enterBothCredentials: 'Inserisci nome utente e password admin, oppure lascia entrambi vuoti per usare admin esistenti',\n      passwordsDoNotMatch: 'Le password non coincidono',\n      passwordTooShort: 'La password deve essere di almeno 6 caratteri',\n    },\n  },\n\n  // Password change\n  changePassword: {\n    title: 'Cambia password',\n    currentPassword: 'Password attuale',\n    currentPasswordPlaceholder: 'Inserisci password attuale',\n    newPassword: 'Nuova password',\n    newPasswordPlaceholder: 'Inserisci nuova password (min 6 caratteri)',\n    confirmPassword: 'Conferma nuova password',\n    confirmPasswordPlaceholder: 'Conferma nuova password',\n    passwordsDoNotMatch: 'Le password non coincidono',\n    passwordTooShort: 'La password deve essere di almeno 6 caratteri',\n    changing: 'Modifica in corso...',\n    success: 'Password cambiata con successo',\n    failed: 'Modifica password fallita',\n  },\n\n  // Plate detection alert\n  plateAlert: {\n    title: 'Stampa in pausa!',\n    message: 'Oggetti rilevati sul piatto. La stampa è stata messa automaticamente in pausa. Svuota il piatto e riprendi la stampa.',\n    understand: 'Ho capito',\n  },\n\n  // Camera page\n  camera: {\n    title: 'Vista camera',\n    invalidPrinterId: 'ID stampante non valido',\n    live: 'Live',\n    snapshot: 'Snapshot',\n    restartStream: 'Riavvia stream',\n    refreshSnapshot: 'Aggiorna snapshot',\n    fullscreen: 'Schermo intero',\n    exitFullscreen: 'Esci da schermo intero',\n    connectingToCamera: 'Connessione alla camera...',\n    capturingSnapshot: 'Acquisizione snapshot...',\n    connectionLost: 'Connessione persa',\n    connectionFailed: 'Connessione camera fallita',\n    reconnecting: 'Riconnessione tra {{countdown}}s... (tentativo {{attempt}}/{{max}})',\n    reconnectNow: 'Riconnetti ora',\n    cameraUnavailable: 'Camera non disponibile',\n    cameraUnavailableDesc: 'Assicurati che la stampante sia accesa e connessa.',\n    noCamera: 'Nessuna camera disponibile',\n    retry: 'Riprova',\n    cameraStream: 'Stream camera',\n    zoomOut: 'Zoom indietro',\n    zoomIn: 'Zoom avanti',\n    resetZoom: 'Reset zoom',\n    recording: 'Registrazione',\n    startRecording: 'Avvia registrazione',\n    stopRecording: 'Ferma registrazione',\n    chamberLight: 'Accendi/Spegni luce camera',\n  },\n\n  // Groups management\n  groups: {\n    title: 'Gestione gruppi',\n    subtitle: 'Gestisci gruppi permessi per controllo accesso',\n    backToSettings: 'Torna a Impostazioni',\n    createGroup: 'Crea gruppo',\n    noPermission: 'Non hai il permesso di accedere a questa pagina.',\n    system: 'Sistema',\n    noDescription: 'Nessuna descrizione',\n    usersCount: '{{count}} utenti',\n    permissionsCount: '{{count}} permessi',\n    edit: 'Modifica',\n    delete: 'Elimina',\n    toast: {\n      created: 'Gruppo creato con successo',\n      updated: 'Gruppo aggiornato con successo',\n      deleted: 'Gruppo eliminato con successo',\n      enterGroupName: 'Inserisci un nome gruppo',\n    },\n    modal: {\n      editGroup: 'Modifica gruppo',\n      createGroup: 'Crea gruppo',\n      cancel: 'Annulla',\n      saving: 'Salvataggio...',\n      creating: 'Creazione...',\n      saveChanges: 'Salva modifiche',\n    },\n    form: {\n      groupName: 'Nome gruppo',\n      groupNamePlaceholder: 'Inserisci nome gruppo',\n      systemGroupWarning: 'I nomi dei gruppi di sistema non possono essere modificati',\n      description: 'Descrizione',\n      descriptionPlaceholder: 'Inserisci descrizione (opzionale)',\n      permissions: 'Permessi ({{count}} selezionati)',\n    },\n    deleteModal: {\n      title: 'Elimina gruppo',\n      message: 'Sei sicuro di voler eliminare questo gruppo? Gli utenti in questo gruppo perderanno questi permessi.',\n      confirm: 'Elimina gruppo',\n    },\n    editor: {\n      title: 'Modifica gruppo',\n      createTitle: 'Crea gruppo',\n      search: 'Cerca permessi...',\n      selectAll: 'Seleziona tutto',\n      clearAll: 'Deseleziona tutto',\n      permissionsSelected: '{{count}} selezionati',\n      noResults: 'Nessun permesso corrisponde alla ricerca',\n    },\n  },\n\n  // Users management\n  users: {\n    title: 'Gestione utenti',\n    subtitle: 'Gestisci utenti e accesso alla tua istanza Bambuddy',\n    backToSettings: 'Torna a Impostazioni',\n    createUser: 'Crea utente',\n    noPermission: 'Non hai il permesso di accedere a questa pagina.',\n    admin: 'Admin',\n    noGroups: 'Nessun gruppo',\n    active: 'Attivo',\n    inactive: 'Inattivo',\n    edit: 'Modifica',\n    delete: 'Elimina',\n    system: 'Sistema',\n    noGroupsAvailable: 'Nessun gruppo disponibile',\n    table: {\n      username: 'Nome utente',\n      groups: 'Gruppi',\n      status: 'Stato',\n      actions: 'Azioni',\n    },\n    toast: {\n      created: 'Utente creato con successo',\n      updated: 'Utente aggiornato con successo',\n      deleted: 'Utente eliminato con successo',\n      fillRequired: 'Compila tutti i campi obbligatori',\n      passwordsDoNotMatch: 'Le password non coincidono',\n      passwordTooShort: 'La password deve essere di almeno 6 caratteri',\n    },\n    modal: {\n      createUser: 'Crea utente',\n      editUser: 'Modifica utente',\n      cancel: 'Annulla',\n      creating: 'Creazione...',\n      saving: 'Salvataggio...',\n      saveChanges: 'Salva modifiche',\n      advancedAuthSubtitle: 'con autenticazione avanzata',\n    },\n    form: {\n      username: 'Nome utente',\n      usernamePlaceholder: 'Inserisci nome utente',\n      email: 'Email',\n      emailPlaceholder: 'utente@esempio.com',\n      password: 'Password',\n      passwordPlaceholder: 'Inserisci password',\n      confirmPassword: 'Conferma password',\n      confirmPasswordPlaceholder: 'Conferma password',\n      newPasswordPlaceholder: 'Inserisci nuova password',\n      confirmNewPasswordPlaceholder: 'Conferma nuova password',\n      leaveBlankToKeep: 'lascia vuoto per mantenere attuale',\n      groups: 'Gruppi',\n      optional: 'opzionale',\n      autoGeneratedPassword: 'Una password sicura verrà generata automaticamente e inviata via email all\\'utente.',\n      passwordManagedByAdvancedAuth: 'La password è gestita dall\\'autenticazione avanzata. Usa \"Reimposta password\" per inviare una nuova password all\\'utente via email.',\n      resetPassword: 'Reimposta password',\n      resettingPassword: 'Reimpostazione password...',\n    },\n    deleteModal: {\n      title: 'Elimina utente',\n      message: 'Sei sicuro di voler eliminare questo utente? Questa azione non può essere annullata.',\n      confirm: 'Elimina utente',\n    },\n  },\n\n  // Stream overlay\n  streamOverlay: {\n    title: 'Overlay stream',\n    invalidPrinterId: 'ID stampante non valido',\n    cameraStream: 'Stream camera',\n    progress: 'Avanzamento',\n    eta: 'ETA',\n    printerIdle: 'Stampante inattiva',\n    printerOffline: 'Stampante offline',\n    status: {\n      printing: 'In stampa',\n      paused: 'In pausa',\n      finished: 'Completata',\n      failed: 'Fallita',\n      idle: 'Inattiva',\n      unknown: 'Sconosciuto',\n    },\n  },\n\n  // Profiles\n  profiles: {\n    title: 'Profili',\n    subtitle: 'Gestisci preset slicer e calibrazioni pressure advance',\n    tabs: {\n      cloud: 'Profili cloud',\n      local: 'Profili locali',\n      kprofiles: 'K-Profiles',\n    },\n    localProfiles: {\n      title: 'Profili locali',\n      subtitle: 'Importa e gestisci preset slicer da OrcaSlicer',\n      import: 'Importa profili',\n      importDesc: 'Trascina file .bbscfg, .bbsflmt, .orca_filament, .zip o .json qui',\n      importing: 'Importazione...',\n      search: 'Cerca preset locali...',\n      noPresets: 'Nessun preset locale ancora',\n      badge: 'Locale',\n      edit: 'Modifica',\n      delete: 'Elimina',\n      cancel: 'Annulla',\n      deleteConfirmTitle: 'Elimina preset',\n      deleteConfirm: 'Sei sicuro di voler eliminare questo preset? Questa azione non può essere annullata.',\n      source: 'Fonte',\n      inheritsFrom: 'Eredita da',\n      filamentType: 'Tipo',\n      vendor: 'Produttore',\n      compatiblePrinters: 'Stampanti',\n      nozzleTemp: 'Temp. ugello',\n      cost: 'Costo',\n      density: 'Densità',\n      pressureAdvance: 'Pressure Advance',\n      filament: 'Filamento',\n      process: 'Processo',\n      printer: 'Stampante',\n      toast: {\n        importSuccess: '{{count}} preset importati',\n        importSkipped: '{{count}} preset saltati (duplicati)',\n        importError: '{{count}} errori durante l\\'importazione',\n        deleted: 'Preset eliminato',\n        updated: 'Preset aggiornato',\n      },\n    },\n    connectedAs: 'Connesso come',\n    logout: 'Esci',\n    noLogoutPermission: 'Non hai il permesso di disconnetterti',\n    failedToLoad: 'Caricamento profili fallito',\n    retry: 'Riprova',\n    time: {\n      justNow: 'Proprio ora',\n      minsAgo: '{{count}}m fa',\n      hoursAgo: '{{count}}h fa',\n      daysAgo: '{{count}}g fa',\n    },\n    toast: {\n      loggedOut: 'Disconnesso',\n    },\n    login: {\n      title: 'Connetti a Bambu Cloud',\n      subtitle: 'Sincronizza i preset del slicer tra dispositivi',\n      email: 'Email',\n      password: 'Password',\n      region: 'Regione',\n      regionGlobal: 'Globale',\n      regionChina: 'Cina',\n      verificationCode: 'Codice di verifica',\n      totpCode: 'Codice autenticatore',\n      checkEmail: 'Controlla la tua email ({{email}}) per un codice a 6 cifre',\n      enterTotpHint: 'Inserisci il codice a 6 cifre dalla tua app autenticatore',\n      accessToken: 'Access Token',\n      accessTokenHint: 'Incolla il tuo access token Bambu Lab (da Bambu Studio)',\n      back: 'Indietro',\n      loginButton: 'Accedi',\n      verifyButton: 'Verifica',\n      setTokenButton: 'Imposta token',\n      useToken: 'Usa access token invece',\n      useEmail: 'Accedi con email invece',\n      toast: {\n        loggedIn: 'Accesso riuscito',\n        codeSent: 'Codice di verifica inviato via email',\n        enterTotp: 'Inserisci il codice dalla tua app autenticatore',\n        tokenSet: 'Token impostato con successo',\n      },\n    },\n    presets: {\n      myPreset: 'Il mio preset (modificabile)',\n      duplicate: 'Duplica',\n      editable: 'Modificabile',\n      failedToLoadDetails: 'Caricamento dettagli preset fallito',\n      deleteConfirm: 'Eliminare questo preset?',\n      deleteWarning: 'Questo eliminerà definitivamente \"{{name}}\" da Bambu Cloud. Questa azione non può essere annullata.',\n      noDuplicatePermission: 'Non hai il permesso di duplicare preset',\n      noEditPermission: 'Non hai il permesso di modificare preset',\n      noDeletePermission: 'Non hai il permesso di eliminare preset',\n      types: {\n        filament: 'Preset filamento',\n        printer: 'Preset stampante',\n        process: 'Preset processo',\n      },\n      toast: {\n        deleted: 'Preset eliminato',\n        created: 'Preset creato',\n        updated: 'Preset aggiornato',\n        duplicated: 'Preset duplicato',\n        fieldAdded: 'Campo \"{{key}}\" aggiunto',\n        exported: 'Preset esportato',\n      },\n      baseLabel: 'Base: {{name}}',\n      currentLabel: 'Corrente: {{name}}',\n      newPreset: 'Nuovo preset',\n      editPreset: 'Modifica preset',\n      duplicatePreset: 'Duplica preset',\n      createNewPreset: 'Crea nuovo preset',\n      customizeSettings: 'Personalizza le impostazioni per il nuovo preset',\n      compareWithBase: 'Confronta con base',\n      compare: 'Confronta',\n      // CreatePresetModal - Basic Info\n      basePreset: 'Preset base',\n      selectBasePreset: 'Seleziona preset base...',\n      presetName: 'Nome preset',\n      myCustomPreset: 'Il mio preset personalizzato',\n      inheritsFrom: 'Deriva da',\n      dropJsonToImport: 'Rilascia JSON per importare',\n      // CreatePresetModal - Tabs\n      tabs: {\n        common: 'Comune',\n        allFields: 'Tutti i campi',\n      },\n      // CreatePresetModal - All Fields Tab\n      availableFields: 'Campi disponibili',\n      searchFieldsPlaceholder: 'Cerca campi...',\n      noMatchingFields: 'Nessun campo corrispondente',\n      allFieldsAdded: 'Tutti i campi aggiunti',\n      addCustomField: 'Aggiungi campo personalizzato',\n      yourOverrides: 'Le tue override',\n      noOverridesYet: 'Nessun override ancora',\n      clickFieldsToAdd: 'Clicca i campi a sinistra per aggiungerli',\n      saveAsTemplate: 'Salva come template',\n      jsonTip: 'Suggerimento: trascina e rilascia un file .json ovunque in questa modale per importare impostazioni',\n    },\n    cloudView: {\n      searchPlaceholder: 'Cerca preset...',\n      templates: 'Template',\n      refresh: 'Aggiorna',\n      newPreset: 'Nuovo preset',\n      clearFilters: 'Pulisci filtri',\n      // Compare mode\n      compareMode: 'Modalita confronto',\n      selectAnotherPreset: 'Seleziona un altro preset {{type}}',\n      clickTwoPresets: 'Clicca due preset dello stesso tipo per confrontare',\n      selectFirst: '1. Seleziona il primo',\n      selectSecond: '2. Seleziona il secondo',\n      compareNow: 'Confronta ora',\n      // Status row\n      lastSynced: 'Ultima sincronizzazione:',\n      showingCount: 'Mostrati {{showing}} di {{total}} preset',\n      noPresetsFound: 'Nessun preset trovato',\n      // Column headers\n      columns: {\n        filament: 'Filamento',\n        process: 'Processo',\n        printer: 'Stampante',\n      },\n      noFilamentPresets: 'Nessun preset filamento',\n      noProcessPresets: 'Nessun preset processo',\n      noPrinterPresets: 'Nessun preset stampante',\n      // Filters\n      filters: {\n        type: 'Tipo',\n        owner: 'Proprietario',\n        printer: 'Stampante',\n        nozzle: 'Ugello',\n        filament: 'Filamento',\n        layer: 'Layer',\n        all: 'Tutti',\n        myPresets: 'I miei preset',\n        builtIn: 'Integrati',\n        process: 'Processo',\n      },\n      // Permissions\n      noTemplatesPermission: 'Non hai il permesso di gestire i template',\n      noRefreshPermission: 'Non hai il permesso di aggiornare i profili',\n      noCreatePermission: 'Non hai il permesso di creare preset',\n    },\n    templates: {\n      title: 'Template rapidi',\n      noTemplates: 'Nessun template ancora',\n      createFirst: 'Crea template dall\\'editor preset',\n      typeFilter: 'Tipo:',\n      deleteTitle: 'Elimina template',\n      deleteWarning: 'Questa azione non può essere annullata',\n      deleteConfirm: 'Sei sicuro di voler eliminare \"{{name}}\"?',\n      namePlaceholder: 'Nome template',\n      descriptionPlaceholder: 'Descrizione',\n      settingsJson: 'Impostazioni (JSON)',\n      fieldsCount: '{{count}} campi',\n      shownInModals: 'Mostrati nelle modali',\n      hiddenInModals: 'Nascosti nelle modali',\n      apply: 'Applica',\n      toast: {\n        deleted: 'Template eliminato',\n        updated: 'Template aggiornato',\n        created: 'Template creato',\n        applied: 'Template applicato',\n      },\n    },\n  },\n\n  // Support/Debug\n  support: {\n    debugLoggingActive: 'Log debug attivo',\n    manageLogs: 'Gestisci',\n    collectItem7: 'Connettività stampante e versioni firmware',\n    collectItem8: 'Stato integrazioni (Spoolman, MQTT, HA)',\n    collectItem9: 'Interfacce di rete (solo subnet)',\n    collectItem10: 'Versioni dei pacchetti Python',\n    collectItem11: 'Controlli di integrità del database',\n    collectItem12: 'Dettagli dell\\'ambiente Docker',\n  },\n\n  // File manager\n  fileManager: {\n    title: 'Gestore file',\n    subtitle: 'Organizza e gestisci i tuoi file di stampa',\n    uploadFiles: 'Carica file',\n    newFolder: 'Nuova cartella',\n    folderName: 'Nome cartella',\n    folderNamePlaceholder: 'es., Parti funzionali',\n    renameFile: 'Rinomina file',\n    renameFolder: 'Rinomina cartella',\n    moveFiles: 'Sposta {{count}} file',\n    rootNoFolder: 'Root (nessuna cartella)',\n    current: 'corrente',\n    linkFolder: 'Collega cartella',\n    linkFolderDescription: 'Collega \"{{name}}\" a un progetto o archivio per accesso rapido.',\n    project: 'Progetto',\n    archive: 'Archivio',\n    noProjectsFound: 'Nessun progetto trovato',\n    noArchivesFound: 'Nessun archivio trovato',\n    unlink: 'Scollega',\n    link: 'Collega',\n    dragDropFiles: 'Trascina e rilascia file qui',\n    dropFilesHere: 'Rilascia file qui',\n    orClickToBrowse: 'oppure clicca per sfogliare',\n    allFileTypesSupported: 'Tutti i tipi di file supportati. I file ZIP saranno estratti.',\n    zipFilesDetected: 'File ZIP rilevati',\n    zipExtractOptions: 'I file ZIP saranno estratti. Scegli come gestire la struttura cartelle:',\n    preserveZipStructure: 'Mantieni struttura cartelle dal ZIP',\n    createFolderFromZip: 'Crea cartella dal nome ZIP',\n    stlThumbnailGeneration: 'Generazione miniature STL',\n    zipMayContainStl: 'I file ZIP possono contenere STL. Le miniature possono essere generate durante l\\'estrazione.',\n    thumbnailsCanBeGenerated: 'Le miniature possono essere generate per file STL. I modelli grandi possono richiedere più tempo.',\n    generateThumbnailsForStl: 'Genera miniature per file STL',\n    threemfDetected: 'File 3MF rilevati',\n    threemfExtractionInfo: 'Modello stampante, materiale, colore e impostazioni stampa saranno estratti automaticamente dai file 3MF.',\n    willBeExtracted: 'Sara estratto',\n    filesExtracted: '{{count}} file estratti',\n    uploadComplete: 'Caricamento completato: {{succeeded}} riusciti',\n    uploadFailed: 'Caricamento fallito',\n    zipFilesFailed: '{{count}} file falliti',\n    uploading: 'Caricamento...',\n    changeLink: 'Cambia collegamento...',\n    linkTo: 'Collega a...',\n    linkToProjectOrArchive: 'Collega a progetto o archivio',\n    addToQueue: 'Aggiungi alla coda',\n    schedulePrint: 'Pianifica',\n    generateThumbnail: 'Genera miniatura',\n    generateThumbnails: 'Genera miniature',\n    generateThumbnailsForMissing: 'Genera miniature per STL senza miniatura',\n    gridView: 'Vista griglia',\n    listView: 'Vista elenco',\n    lowDiskSpaceWarning: 'Avviso spazio disco basso',\n    lowDiskSpaceDetails: 'Solo {{free}} liberi su {{total}} totali. La soglia e {{threshold}} GB nelle impostazioni.',\n    files: 'File',\n    folders: 'Cartelle',\n    size: 'Dimensione',\n    free: 'Libero',\n    allFiles: 'Tutti i file',\n    wrap: 'A capo',\n    enableTextWrapping: 'Abilita a capo testo',\n    disableTextWrapping: 'Disabilita a capo testo',\n    collapse: 'Comprimi',\n    collapseFoldersByDefault: 'Comprimi le cartelle per impostazione predefinita',\n    expandFoldersByDefault: 'Espandi le cartelle per impostazione predefinita',\n    dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset',\n    searchFiles: 'Cerca file...',\n    allTypes: 'Tutti i tipi',\n    prints: 'Stampe',\n    ascending: 'Crescente',\n    descending: 'Decrescente',\n    resultsCount: '{{showing}} di {{total}} file',\n    selectAll: 'Seleziona tutto',\n    deselectAll: 'Deseleziona tutto',\n    selected: '{{count}} selezionati',\n    adding: 'Aggiunta...',\n    loadingFiles: 'Caricamento file...',\n    folderIsEmpty: 'La cartella e vuota',\n    noFilesYet: 'Nessun file ancora',\n    folderEmptyDescription: 'Carica file o sposta file in questa cartella per iniziare.',\n    noFilesDescription: 'Carica file per iniziare a organizzare i file di stampa.',\n    noMatchingFiles: 'Nessun file corrispondente',\n    noMatchingFilesDescription: 'Nessun file corrisponde ai criteri di ricerca o filtro.',\n    clearFilters: 'Pulisci filtri',\n    printedCount: 'Stampato {{count}}x',\n    uploadedBy: 'Caricato da',\n    deleteFolder: 'Elimina cartella',\n    deleteFile: 'Elimina file',\n    deleteFilesCount: 'Elimina {{count}} file',\n    deleteFolderConfirm: 'Sei sicuro di voler eliminare questa cartella? Tutti i file dentro saranno eliminati.',\n    deleteFileConfirm: 'Sei sicuro di voler eliminare questo file?',\n    deleteFilesConfirm: 'Sei sicuro di voler eliminare {{count}} file selezionati? Questa azione non può essere annullata.',\n    deleting: 'Eliminazione...',\n    noPermissionRenameFolder: 'Non hai il permesso di rinominare cartelle',\n    noPermissionLinkFolder: 'Non hai il permesso di collegare cartelle',\n    noPermissionDeleteFolder: 'Non hai il permesso di eliminare cartelle',\n    noPermissionPrint: 'Non hai il permesso di stampare',\n    noPermissionAddToQueue: 'Non hai il permesso di aggiungere alla coda',\n    noPermissionDownload: 'Non hai il permesso di scaricare file',\n    noPermissionRenameFile: 'Non hai il permesso di rinominare questo file',\n    noPermissionGenerateThumbnail: 'Non hai il permesso di generare miniature',\n    noPermissionDeleteFile: 'Non hai il permesso di eliminare questo file',\n    noPermissionCreateFolder: 'Non hai il permesso di creare cartelle',\n    noPermissionUpload: 'Non hai il permesso di caricare file',\n    noPermissionMoveFiles: 'Non hai il permesso di spostare file',\n    noPermissionDeleteFiles: 'Non hai il permesso di eliminare file',\n    // External folder\n    linkExternal: 'Collega esterno',\n    linkExternalFolder: 'Collega cartella esterna',\n    linkExternalFolderDescription: 'Monta una directory host (NAS, USB, condivisione di rete) nel File Manager. I file non vengono copiati — vengono letti direttamente dal percorso originale.',\n    externalFolderNamePlaceholder: 'es. Stampe NAS',\n    externalPath: 'Percorso host',\n    externalPathHelp: 'Percorso assoluto della directory sull\\'host Docker. Deve essere montato come bind nel container.',\n    readOnly: 'Sola lettura',\n    readOnlyHelp: 'impedisce caricamenti e cancellazioni',\n    showHiddenFiles: 'Mostra file nascosti (file punto)',\n    externalFolder: 'Cartella esterna',\n    scanFolder: 'Scansiona',\n    toast: {\n      folderCreated: 'Cartella creata',\n      folderDeleted: 'Cartella eliminata',\n      fileDeleted: 'File eliminato',\n      filesDeleted: 'Eliminati {{count}} file',\n      filesMoved: 'File spostati',\n      folderLinked: 'Cartella collegata',\n      folderUnlinked: 'Cartella scollegata',\n      externalFolderLinked: 'Cartella esterna collegata e scansionata',\n      folderScanned: 'Scansione completata: {{added}} aggiunti, {{removed}} rimossi',\n      addedToQueue: 'Aggiunti {{count}} file alla coda',\n      addedToQueuePartial: 'Aggiunti {{added}} file, {{failed}} falliti',\n      failedToAddToQueue: 'Aggiunta file fallita: {{error}}',\n      fileRenamed: 'File rinominato',\n      folderRenamed: 'Cartella rinominata',\n      thumbnailsGenerated: 'Generate {{count}} miniature',\n      thumbnailsGeneratedPartial: 'Generate {{succeeded}} miniature, {{failed}} fallite',\n      noStlMissingThumbnails: 'Nessun file STL senza miniature',\n      failedToGenerateThumbnails: 'Generazione miniature fallita: {{error}}',\n      thumbnailGenerated: 'Miniatura generata',\n      failedToGenerateThumbnail: 'Generazione miniatura fallita: {{error}}',\n    },\n  },\n\n  // Projects\n  projects: {\n    title: 'Progetti',\n    subtitle: 'Organizza e traccia i tuoi progetti di stampa 3D',\n    newProject: 'Nuovo progetto',\n    editProject: 'Modifica progetto',\n    deleteProject: 'Elimina progetto',\n    projectName: 'Nome progetto',\n    description: 'Descrizione',\n    noProjects: 'Nessun progetto ancora',\n    noProjectsFiltered: 'Nessun progetto {{status}}',\n    noProjectsFilteredHelp: 'Non hai progetti {{status}}. I progetti appariranno qui quando il loro stato cambia.',\n    createFirst: 'Crea il tuo primo progetto per organizzare stampe correlate, tracciare progressi e gestire i tuoi build.',\n    createFirstButton: 'Crea il tuo primo progetto',\n    create: 'Crea',\n    files: 'File',\n    prints: 'Stampe',\n    plates: 'piatti',\n    parts: 'parti',\n    lastModified: 'Ultima modifica',\n    deleteConfirm: 'Sei sicuro di voler eliminare questo progetto? Archivi e elementi in coda saranno scollegati ma non eliminati.',\n    addFiles: 'Aggiungi file',\n    removeFile: 'Rimuovi file',\n    viewDetails: 'Vedi dettagli',\n    // Modal fields\n    namePlaceholder: 'es., Build Voron 2.4',\n    descriptionPlaceholder: 'Descrizione opzionale...',\n    color: 'Colore',\n    targetPlates: 'Piatti target',\n    targetPlatesPlaceholder: 'es., 25',\n    targetPlatesHelp: 'Numero di job di stampa',\n    targetParts: 'Parti target',\n    targetPartsPlaceholder: 'es., 150',\n    targetPartsHelp: 'Totale oggetti necessari',\n    tagsLabel: 'Tag (separati da virgola)',\n    tagsPlaceholder: 'es., voron, funzionale, regalo',\n    dueDate: 'Data scadenza',\n    priority: 'Priorita',\n    priorityLow: 'Bassa',\n    priorityNormal: 'Normale',\n    priorityHigh: 'Alta',\n    priorityUrgent: 'Urgente',\n    // Status\n    statusActive: 'Attivo',\n    statusCompleted: 'Completato',\n    statusArchived: 'Archiviato',\n    done: 'Fatto',\n    completed: 'completato',\n    failed: 'fallito',\n    inQueue: 'in coda',\n    noPrintsYet: 'Nessuna stampa ancora',\n    // Footer stats\n    printJobs: 'Job di stampa (piatti)',\n    partsPrinted: 'Parti stampate',\n    failedParts: 'Parti fallite',\n    // Actions\n    import: 'Importa',\n    export: 'Esporta',\n    importProject: 'Importa progetto',\n    exportAll: 'Esporta tutti i progetti',\n    loading: 'Caricamento progetti...',\n    // Permissions\n    noEditPermission: 'Non hai il permesso di modificare progetti',\n    noDeletePermission: 'Non hai il permesso di eliminare progetti',\n    noCreatePermission: 'Non hai il permesso di creare progetti',\n    noImportPermission: 'Non hai il permesso di importare progetti',\n    noExportPermission: 'Non hai il permesso di esportare progetti',\n    // Toast\n    toast: {\n      created: 'Progetto creato',\n      updated: 'Progetto aggiornato',\n      deleted: 'Progetto eliminato',\n      imported: 'Progetto importato',\n      multipleImported: '{{count}} progetti importati',\n      importFailed: 'Import fallito',\n      exported: 'Progetti esportati (solo metadati)',\n    },\n  },\n\n  // Project detail page\n  projectDetail: {\n    notFound: 'Progetto non trovato',\n    backToProjects: 'Torna a Progetti',\n    export: 'Esporta',\n    exportProject: 'Esporta progetto',\n    noExportPermission: 'Non hai il permesso di esportare progetti',\n    noEditPermission: 'Non hai il permesso di modificare progetti',\n    partOf: 'Parte di:',\n    priorityLabel: 'Priorita:',\n    noPrints: 'Nessuna stampa in questo progetto ancora',\n    status: {\n      active: 'Attivo',\n      completed: 'Completato',\n      archived: 'Archiviato',\n    },\n    priority: {\n      low: 'Bassa',\n      normal: 'Normale',\n      high: 'Alta',\n      urgent: 'Urgente',\n    },\n    dueDate: {\n      overdue: 'Scaduto',\n      today: 'Scade oggi',\n      daysLeft: '{{count}} giorni rimanenti',\n    },\n    progress: {\n      platesProgress: 'Avanzamento piatti',\n      partsProgress: 'Avanzamento parti',\n      printJobs: 'job di stampa',\n      parts: 'parti',\n      percentComplete: '{{percent}}% completato',\n      remaining: '{{count}} rimanenti',\n    },\n    stats: {\n      printJobs: 'Job di stampa',\n      total: 'totale',\n      failed: '{{count}} falliti',\n      partsPrinted: '{{count}} parti stampate',\n      printTime: 'Tempo di stampa',\n      filamentUsed: 'Filamento usato',\n    },\n    cost: {\n      title: 'Tracciamento costi',\n      filamentCost: 'Costo filamento',\n      energy: 'Energia',\n      totalCost: 'Costo totale',\n      total: 'Totale',\n      includesBom: 'incl. distinta materiali',\n      budget: 'Budget',\n      remaining: 'Rimanente',\n    },\n    subProjects: {\n      title: 'Sotto-progetti ({{count}})',\n    },\n    notes: {\n      title: 'Note',\n      noEditPermission: 'Non hai il permesso di modificare le note',\n      placeholder: 'Aggiungi note su questo progetto...',\n      empty: 'Nessuna nota ancora. Clicca Modifica per aggiungere note.',\n    },\n    files: {\n      title: 'File',\n      linkFolders: 'Collega cartelle dal Gestore file',\n      forQuickAccess: 'a questo progetto per accesso rapido.',\n      fileCount: '{{count}} file',\n      empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.',\n      noFiles: 'Nessun file in questa cartella.',\n      print: 'Stampa ora',\n      addToQueue: 'Aggiungi alla coda',\n    },\n    bom: {\n      title: 'Distinta materiali',\n      acquired: '{{completed}}/{{total}} acquisiti',\n      showAll: 'Mostra tutti',\n      hideDone: 'Nascondi completati',\n      addPart: 'Aggiungi parte',\n      noAddPermission: 'Non hai il permesso di aggiungere parti',\n      partNamePlaceholder: 'Nome parte (es., viti M3x8)',\n      partName: 'Nome parte',\n      qty: 'Qta',\n      price: 'Prezzo ({{currency}})',\n      sourcingUrlPlaceholder: 'URL fornitura (opzionale)',\n      remarksPlaceholder: 'Note (opzionale)',\n      deletePart: 'Elimina parte',\n      deleteConfirm: 'Sei sicuro di voler eliminare \"{{name}}\"?',\n      noUpdatePermission: 'Non hai il permesso di aggiornare parti',\n      noEditPermission: 'Non hai il permesso di modificare parti',\n      noDeletePermission: 'Non hai il permesso di eliminare parti',\n      totalCost: 'Costo totale:',\n      empty: 'Nessuna parte nella distinta materiali. Aggiungi hardware, elettronica o altri componenti da reperire.',\n    },\n    timeline: {\n      title: 'Timeline attivita',\n      empty: 'Nessuna attivita ancora.',\n    },\n    template: {\n      saveAsTemplate: 'Salva come template',\n      noCreatePermission: 'Non hai il permesso di creare template',\n    },\n    queue: {\n      title: 'Coda',\n      viewAll: 'Vedi tutto',\n      printing: '{{count}} in stampa',\n      queued: '{{count}} in coda',\n    },\n    prints: {\n      title: 'Stampe ({{count}})',\n    },\n    toast: {\n      projectUpdated: 'Progetto aggiornato',\n      partAdded: 'Parte aggiunta',\n      partRemoved: 'Parte rimossa',\n      exportFailed: 'Export fallito',\n      projectExported: 'Progetto esportato',\n      templateCreated: 'Template creato',\n    },\n  },\n\n  // System info\n  system: {\n    title: 'Informazioni sistema',\n    version: 'Versione',\n    uptime: 'Tempo attivo',\n    cpuUsage: 'Uso CPU',\n    memoryUsage: 'Uso memoria',\n    diskUsage: 'Uso disco',\n    networkInfo: 'Info rete',\n    logs: 'Log',\n    debugMode: 'Modalita debug',\n    enableDebug: 'Abilita log debug',\n    disableDebug: 'Disabilita log debug',\n    downloadLogs: 'Scarica log',\n    clearLogs: 'Cancella log',\n    dockerInfo: 'Info Docker',\n    containerName: 'Nome container',\n    imageName: 'Nome immagine',\n    platform: 'Piattaforma',\n    architecture: 'Architettura',\n  },\n\n  // Library (K Profiles)\n  library: {\n    title: 'Libreria filamenti',\n    addFilament: 'Aggiungi filamento',\n    editFilament: 'Modifica filamento',\n    deleteFilament: 'Elimina filamento',\n    vendor: 'Produttore',\n    material: 'Materiale',\n    color: 'Colore',\n    kFactor: 'K Factor',\n    temperature: 'Temperatura',\n    noFilaments: 'Nessun filamento in libreria',\n    deleteConfirm: 'Sei sicuro di voler eliminare questo filamento?',\n    importFromPrinter: 'Importa da stampante',\n    exportToFile: 'Esporta su file',\n  },\n\n  // Spoolman\n  spoolman: {\n    title: 'Integrazione Spoolman',\n    enabled: 'Spoolman abilitato',\n    url: 'URL Spoolman',\n    connected: 'Connesso',\n    disconnected: 'Non connesso',\n    testConnection: 'Testa connessione',\n    sync: 'Sincronizza',\n    syncing: 'Sincronizzazione...',\n    lastSync: 'Ultima sincronizzazione',\n    linkToSpoolman: 'Collega a Spoolman',\n    openInSpoolman: 'Apri in Spoolman',\n    unlinkSpool: 'Scollega bobina',\n    unlinkConfirmTitle: 'Scollegare bobina?',\n    unlinkConfirmMessage: 'Questo disconnetterà lo spool da Spoolman. I dati dello spool in Spoolman rimarranno invariati.',\n    selectSpool: 'Seleziona bobina',\n    noUnlinkedSpools: 'Nessuna bobina scollegata disponibile',\n    linkSuccess: 'Bobina collegata a Spoolman con successo',\n    linkFailed: 'Collegamento bobina fallito',\n    unlinkSuccess: 'Bobina scollegata da Spoolman con successo',\n    unlinkFailed: 'Impossibile scollegare la bobina',\n    spoolId: 'ID bobina',\n    fillSourceLabel: '(Spoolman)',\n    weight: 'Peso',\n    remaining: 'Rimanente',\n    disableWeightSync: 'Disabilita sync peso stimato AMS',\n    disableWeightSyncDesc: 'Non aggiornare la capacità rimanente dalle stime AMS. Usalo se preferisci il tracciamento di Spoolman rispetto alle stime AMS. Le nuove bobine useranno comunque la stima AMS come peso iniziale.',\n    reportPartialUsage: 'Segnala uso parziale per stampe fallite',\n    reportPartialUsageDesc: 'Quando una stampa fallisce o viene annullata, segnala il filamento stimato usato fino a quel punto in base all\\'avanzamento layer.',\n  },\n\n  // Inventory\n  inventory: {\n    title: 'Inventario Bobine',\n    addSpool: 'Aggiungi Bobina',\n    editSpool: 'Modifica Bobina',\n    material: 'Materiale',\n    selectMaterial: 'Seleziona materiale...',\n    subtype: 'Sottotipo',\n    brand: 'Marchio',\n    searchBrand: 'Cerca marchio...',\n    useCustomBrand: 'Usa \"{{brand}}\"',\n    useCustomMaterial: 'Usa materiale personalizzato: {{material}}',\n    colorName: 'Nome Colore',\n    colorNamePlaceholder: 'Jade White, Fire Red...',\n    color: 'Colore',\n    hexColor: 'Colore Hex',\n    pickColor: 'Scegli colore personalizzato',\n    labelWeight: 'Peso da Etichetta',\n    coreWeight: 'Peso Bobina Vuota',\n    searchSpoolWeight: 'Cerca peso bobina...',\n    weightUsed: 'Utilizzato',\n    currentWeight: 'Peso Rimanente',\n    measuredWeight: 'Peso Misurato',\n    spoolName: 'Bobina',\n    costPerKg: 'Costo per kg',\n    measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.',\n    slicerFilament: 'Filamento Slicer',\n    slicerFilamentName: 'Nome Preset Slicer',\n    slicerPreset: 'Preset Slicer',\n    searchPresets: 'Cerca preset filamento...',\n    selectedPreset: 'Selezionato',\n    noPresetsFound: 'Nessun preset trovato',\n    tempOverrides: 'Override Temperatura',\n    note: 'Nota',\n    notePlaceholder: 'Eventuali note aggiuntive su questa bobina...',\n    archive: 'Archivia',\n    restore: 'Ripristina',\n    noSpools: 'Ancora nessuna bobina. Aggiungi la tua prima bobina per iniziare.',\n    noManualSpools: 'Nessuna bobina aggiunta manualmente disponibile. Aggiungi prima una bobina al tuo inventario.',\n    kProfiles: 'K-Profiles',\n    addKProfile: 'Aggiungi K-Profile',\n    assignSpool: 'Assegna Bobina',\n    unassignSpool: 'Scollega',\n    assignSuccess: 'Bobina assegnata e slot AMS configurato',\n    assignFailed: 'Assegnazione bobina fallita',\n    selectSpool: 'Seleziona una bobina da assegnare a questo slot',\n    assigned: 'Assegnato',\n    assigning: 'Assegnazione...',\n    searchSpools: 'Cerca bobine...',\n    showAllSpools: 'Mostra tutte le bobine',\n    allMaterials: 'Tutti i Materiali',\n    filterByBrand: 'Filtra per marchio...',\n    showArchived: 'Mostra archiviate',\n    quickAdd: 'Aggiunta rapida (Scorta)',\n    quantity: 'Quantità',\n    stock: 'Scorta',\n    configured: 'Configurata',\n    spoolsCreated: '{{count}} bobine create',\n    spoolCreated: 'Bobina creata',\n    spoolUpdated: 'Bobina aggiornata',\n    spoolDeleted: 'Bobina eliminata',\n    spoolArchived: 'Bobina archiviata',\n    spoolRestored: 'Bobina ripristinata',\n    deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',\n    archiveConfirm: 'Sei sicuro di voler archiviare questa bobina?',\n    advancedSettings: 'Impostazioni Avanzate',\n    // Tabs\n    filamentInfoTab: 'Info filamento',\n    paProfileTab: 'Profilo PA',\n    filamentInfo: 'Filamento',\n    additional: 'Aggiuntivo',\n    // Cloud\n    loadingPresets: 'Caricamento preset cloud...',\n    cloudConnected: 'Cloud connesso',\n    cloudNotConnected: 'Cloud non connesso (valori predefiniti)',\n    // Colors\n    recentColors: 'Recenti',\n    searchColors: 'Cerca colori...',\n    searchResults: 'Risultati della ricerca',\n    allColors: 'Tutti i colori',\n    commonColors: 'Colori comuni',\n    showLess: 'Mostra meno',\n    showAll: 'Mostra tutto',\n    noColorsFound: 'Nessun colore corrisponde alla ricerca',\n    noResults: 'Nessun risultato trovato',\n    // PA Profiles\n    selectMaterialFirst: 'Selezionare prima un materiale nella scheda Info filamento.',\n    noPrintersConfigured: 'Nessuna stampante configurata. Aggiungi stampanti per usare i profili PA.',\n    matchingFilter: 'Corrispondenti',\n    anyBrand: 'Qualsiasi marca',\n    anyVariant: 'Qualsiasi variante',\n    autoSelect: 'Selezione automatica',\n    matches: 'corrispondenze',\n    match: 'corrispondenza',\n    noMatches: 'Nessuna corrispondenza',\n    connected: 'Connessa',\n    offline: 'Offline',\n    printerOffline: 'La stampante è offline. Connetti per visualizzare i profili di calibrazione.',\n    noKProfilesMatch: 'Nessun profilo K corrisponde al filamento selezionato.',\n    leftNozzle: 'Ugello sinistro',\n    rightNozzle: 'Ugello destro',\n    profilesSelected: 'profili di calibrazione selezionati',\n    // Stats & enhanced table\n    totalInventory: 'Inventario totale',\n    totalConsumed: 'Totale consumato',\n    byMaterial: 'Per materiale',\n    inPrinter: 'In stampante',\n    lowStock: 'Scorta bassa',\n    sinceTracking: 'Dall\\'inizio del tracciamento',\n    loadedInAms: 'Caricato in AMS/Est',\n    remaining: 'Rimanente',\n    weightCheck: 'Controllo Peso',\n    lastWeighed: 'Ultima pesatura',\n    neverWeighed: 'Mai pesato',\n    search: 'Cerca bobine...',\n    showing: 'Visualizzazione',\n    to: 'a',\n    of: 'di',\n    show: 'Mostra',\n    spools: 'bobine',\n    spool: 'bobina',\n    page: 'Pagina',\n    noSpoolsMatch: 'Nessun risultato trovato',\n    noSpoolsMatchDesc: 'Prova a modificare la ricerca o i filtri per trovare quello che cerchi.',\n    active: 'Attive',\n    archived: 'Archiviate',\n    all: 'Tutte',\n    used: 'Usato',\n    new: 'Nuovo',\n    clearFilters: 'Cancella filtri',\n    table: 'Tabella',\n    cards: 'Schede',\n    net: 'Netto',\n    // Grouping\n    groupSimilar: 'Raggruppa',\n    groupedSpools: '{{count}} bobine identiche',\n    groupedRows: 'righe',\n    // Column config\n    columns: 'Colonne',\n    configureColumns: 'Configura colonne',\n    configureColumnsDesc: 'Trascina per riordinare le colonne o usa le frecce. Attiva/disattiva la visibilità con l\\'icona dell\\'occhio.',\n    visible: 'visibili',\n    reset: 'Ripristina',\n    cancel: 'Annulla',\n    applyChanges: 'Applica modifiche',\n    moveUp: 'Sposta su',\n    moveDown: 'Sposta giù',\n    hideColumn: 'Nascondi colonna',\n    showColumn: 'Mostra colonna',\n    // Tag linking\n    linkToSpool: 'Collega a bobina',\n    tagLinked: 'Tag collegato alla bobina',\n    tagLinkFailed: 'Impossibile collegare il tag',\n    tagAlreadyLinked: 'Tag già collegato a un\\'altra bobina',\n    unknownTag: 'Tag RFID sconosciuto rilevato',\n    // Usage history\n    usageHistory: 'Cronologia utilizzo',\n    noUsageHistory: 'Nessun utilizzo registrato',\n    printName: 'Nome stampa',\n    weightConsumed: 'Peso consumato',\n    clearHistory: 'Cancella',\n    historyCleared: 'Cronologia utilizzo cancellata',\n    fillSourceLabel: '(Inv)',\n    lowStockThresholdError: 'La soglia deve essere tra 0.1 e 99.9',\n    assignMismatchTitle: 'Materiale non corrispondente',\n    assignMismatchMessage: 'Il materiale della bobina selezionata \"{{spoolMaterial}}\" non corrisponde al materiale del vassoio \"{{trayMaterial}}\" per {{location}}. Assegnare comunque?',\n    assignMismatchConfirm: 'Assegna comunque',\n    assignPartialMismatchMessage: 'Il materiale della bobina \"{{spoolMaterial}}\" è simile ma non corrisponde esattamente a \"{{trayMaterial}}\" in {{location}}. Vuoi procedere?',\n    assignProfileMismatchMessage: 'Il profilo della bobina \"{{spoolProfile}}\" non corrisponde al profilo del vassoio \"{{trayProfile}}\" in {{location}}. Vuoi procedere?',\n  },\n\n  // Timelapse\n  timelapse: {\n    title: 'Timelapse',\n    create: 'Crea timelapse',\n    download: 'Scarica',\n    delete: 'Elimina',\n    preview: 'Anteprima',\n    frameRate: 'Frame rate',\n    quality: 'Qualità',\n    processing: 'Elaborazione...',\n    noTimelapses: 'Nessun timelapse disponibile',\n  },\n\n  // AMS\n  ams: {\n    title: 'AMS',\n    slot: 'Slot',\n    empty: 'Vuoto',\n    emptySlot: 'Slot vuoto',\n    unknown: 'Sconosciuto',\n    humidity: 'Umidità',\n    temperature: 'Temperatura',\n    filamentType: 'Tipo filamento',\n    filamentColor: 'Colore',\n    remaining: 'Rimanente',\n    history: 'Cronologia AMS',\n    noHistory: 'Nessuna cronologia disponibile',\n    configureSlot: 'Configura slot',\n    externalSpool: 'Bobina esterna',\n    profile: 'Profilo',\n    kFactor: 'K Factor',\n    fill: 'Livello',\n    configure: 'Configura',\n    used: 'utilizzato',\n    remainingUnit: 'rimanente',\n  },\n\n  // Print modal\n  printModal: {\n    title: 'Avvia stampa',\n    selectPrinter: 'Seleziona stampante',\n    selectPlate: 'Seleziona piatto',\n    filamentMapping: 'Mappatura filamento',\n    totalCost: 'Costo totale:',\n    slotRemainingShort: ' - {{grams}}g rim.',\n    printSettings: 'Impostazioni stampa',\n    bedLeveling: 'Livellamento piatto',\n    flowCalibration: 'Calibrazione flusso',\n    vibrationCalibration: 'Calibrazione vibrazioni',\n    layerInspection: 'Ispezione primo layer',\n    timelapse: 'Timelapse',\n    startPrint: 'Avvia stampa',\n    addToQueue: 'Aggiungi alla coda',\n    cancel: 'Annulla',\n    noPrintersAvailable: 'Nessuna stampante disponibile',\n    printerBusy: 'Stampante occupata',\n    printerOffline: 'Stampante offline',\n    sameTypeDifferentColor: 'Stesso tipo, colore diverso',\n    filamentTypeNotLoaded: 'Tipo di filamento non caricato',\n    openCalendar: 'Apri calendario',\n    leftNozzle: 'L',\n    rightNozzle: 'R',\n    leftNozzleTooltip: 'Ugello sinistro',\n    rightNozzleTooltip: 'Ugello destro',\n    filamentOverride: 'Sostituzione filamento',\n    filamentOverrideHint: 'Sostituisci opzionalmente i filamenti per l\\'assegnazione basata sul modello. Lo scheduler abbinerà i filamenti selezionati invece dei valori 3MF originali.',\n    originalFilament: 'Originale',\n    overrideWith: 'Sostituisci con',\n    resetToOriginal: 'Ripristina originale',\n    insufficientFilamentTitle: 'Filamento insufficiente',\n    insufficientFilamentMessage: 'Alcune bobine assegnate hanno meno filamento rimanente di quanto necessario per questa stampa:',\n    insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',\n    printAnyway: 'Stampa comunque',\n    forceColorMatch: 'Forza corrispondenza colore',\n    staggerPrinterStarts: 'Stagger printer starts',\n    staggerGroupSize: 'Group size',\n    staggerInterval: 'Interval (min)',\n    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',\n    staggerLastGroup: 'last group: {{count}}',\n    staggerTotal: 'total: {{minutes}} min',\n    staggerToPrinters: 'Scagliona a {{count}} stampanti',\n    gcodeInjection: 'Inietta G-code auto-stampa',\n  },\n\n  // Backup\n  backup: {\n    title: 'Backup e ripristino',\n    createBackup: 'Crea backup',\n    restoreBackup: 'Ripristina backup',\n    restoreDescription: 'Sostituisci tutti i dati da un file di backup',\n    downloadBackup: 'Scarica backup',\n    uploadBackup: 'Carica backup',\n    lastBackup: 'Ultimo backup',\n    autoBackup: 'Backup automatico',\n    backupNow: 'Esegui backup ora',\n    restoreWarning: 'Avviso: il ripristino sovrascriverà tutti i dati attuali.',\n    includeArchives: 'Includi archivi',\n    includeSettings: 'Includi impostazioni',\n    includeProfiles: 'Includi profili',\n    backupSuccess: 'Backup creato con successo',\n    restoreSuccess: 'Backup ripristinato con successo',\n    backupFailed: 'Backup fallito',\n    restoreFailed: 'Ripristino fallito',\n    restoreNote: 'La stampante virtuale verrà fermata durante il ripristino',\n\n    // GitHub Backup\n    githubBackup: 'Backup GitHub',\n    enabled: 'Abilitato',\n    cloudLoginRequired: 'Accesso Bambu Cloud richiesto. Accedi in Profili → Profili Cloud per abilitare il backup GitHub.',\n    cloudLoginRequiredShort: 'Accesso Cloud richiesto',\n    githubDescription: 'Sincronizza automaticamente i tuoi profili con un repository GitHub privato per backup e cronologia delle versioni.',\n    repositoryUrl: 'URL del repository',\n    personalAccessToken: 'Token di accesso personale',\n    tokenSaved: '(salvato)',\n    enterNewToken: 'Inserisci un nuovo token per aggiornare',\n    tokenHint: 'Token a grana fine con permesso di lettura/scrittura dei contenuti',\n    branch: 'Branch',\n    manualOnly: 'Solo manuale',\n    hourly: 'Ogni ora',\n    daily: 'Giornaliero',\n    weekly: 'Settimanale',\n    includeInBackup: 'Includi nel backup',\n    kProfiles: 'K-Profili',\n    kProfilesDescription: 'Calibrazione dell\\'avanzamento pressione dalle stampanti connesse',\n    noPrintersConnected: 'Nessuna stampante connessa',\n    printersConnected: '{{connected}}/{{total}} connesse',\n    cloudProfiles: 'Profili Cloud',\n    cloudProfilesDescription: 'Preset di filamento, stampante e processo da Bambu Cloud',\n    appSettings: 'Impostazioni App',\n    appSettingsDescription: 'Configurazione Bambuddy (database completo)',\n    spoolInventory: 'Inventario bobine',\n    spoolInventoryDescription: 'Bobine di filamento, cronologia utilizzo e tracciamento costi',\n    printArchives: 'Archivi di stampa',\n    printArchivesDescription: 'Metadati della cronologia di stampa (nessun file gcode/3MF)',\n    lastBackupAt: 'Ultimo backup:',\n    noBackupsYet: 'Nessun backup ancora',\n    next: 'Prossimo:',\n    startingBackup: 'Avvio del backup...',\n    test: 'Test',\n    enableBackup: 'Abilita backup',\n    testConnection: 'Testa connessione',\n    enterRepoUrl: 'Inserisci l\\'URL del repository',\n    enterRepoAndToken: 'Inserisci l\\'URL del repository e il token di accesso',\n    repoRequired: 'L\\'URL del repository è obbligatorio',\n    tokenRequired: 'Il token di accesso è obbligatorio',\n    githubBackupEnabled: 'Backup GitHub abilitato',\n    tokenUpdated: 'Token aggiornato',\n    settingsSaved: 'Impostazioni salvate',\n    failedToSave: 'Salvataggio fallito: {{message}}',\n    backupCompleteFiles: 'Backup completato - {{count}} file aggiornati',\n    backupSkippedNoChanges: 'Backup saltato - nessuna modifica',\n    backupFailed2: 'Backup fallito: {{message}}',\n    clearedLogs: '{{count}} log eliminati',\n    failedToClearLogs: 'Eliminazione log fallita: {{message}}',\n\n    // History\n    history: 'Cronologia',\n    clear: 'Cancella',\n    date: 'Data',\n    status: 'Stato',\n    commit: 'Commit',\n\n    // Local Backup\n    localBackup: 'Backup locale',\n    localBackupDescription: 'Crea un backup completo dei tuoi dati Bambuddy includendo database, archivi, upload e tutti i file.',\n    downloadBackupLabel: 'Scarica backup',\n    completeBackupZip: 'Backup completo: database + tutti i file (ZIP)',\n    download: 'Scarica',\n    preparingBackup: 'Preparazione del backup...',\n    creatingArchive: 'Creazione dell\\'archivio di backup... Potrebbe richiedere del tempo per archivi di grandi dimensioni.',\n    downloadingFile: 'Download del file di backup...',\n    backupDownloaded: 'Backup scaricato con successo',\n    failedToCreateBackup: 'Creazione del backup fallita: {{message}}',\n    restore: 'Ripristina',\n    restoreReplacesAll: 'Il ripristino sostituisce tutti i dati.',\n    restoreReplacesAllDetail: 'Il database e i file attuali verranno completamente sostituiti. È necessario un riavvio dopo il ripristino.',\n    restoreConfirmTitle: 'Ripristina backup',\n    restoreConfirmMessage: 'Sei sicuro di voler ripristinare da \"{{filename}}\"? Questo sostituirà completamente il tuo database e tutti i file. L\\'applicazione dovrà essere riavviata dopo il ripristino.',\n    restoreConfirmButton: 'Ripristina backup',\n    uploadingFile: 'Caricamento del file di backup...',\n    backupRestoredRestart: 'Backup ripristinato. Riavvia Bambuddy.',\n    failedToRestore: 'Ripristino del backup fallito. Controlla il formato del file.',\n    reloadNow: 'Ricarica ora',\n    creatingBackup: 'Creazione del backup',\n    restoringBackup: 'Ripristino del backup',\n    preparing: 'Preparazione...',\n    processing: 'Elaborazione...',\n    doNotClosePage: 'Non chiudere questa pagina e non navigare altrove. Questa operazione potrebbe richiedere diversi minuti per backup di grandi dimensioni.',\n\n    // RestoreModal\n    restoring: 'Ripristino in corso...',\n    restoreComplete: 'Ripristino completato',\n    restoreFailed2: 'Ripristino fallito',\n    importSettings: 'Importa impostazioni da un file di backup',\n    pleaseWaitRestoring: 'Attendere durante il ripristino dei dati',\n    selectBackupFile: 'Clicca per selezionare un file di backup (.json o .zip)',\n    duplicateHandling: 'Come funziona la gestione dei duplicati:',\n    matchPrinters: 'Stampanti',\n    matchPrintersBy: 'corrispondenza per numero di serie',\n    matchSmartPlugs: 'Smart Plug',\n    matchSmartPlugsBy: 'corrispondenza per indirizzo IP',\n    matchNotificationProviders: 'Provider di notifica',\n    matchNotificationProvidersBy: 'corrispondenza per nome',\n    matchFilaments: 'Filamenti',\n    matchFilamentsBy: 'corrispondenza per nome + tipo + marca',\n    matchArchives: 'Archivi',\n    matchArchivesBy: 'corrispondenza per hash del contenuto (sempre saltato)',\n    matchPendingUploads: 'Upload in sospeso',\n    matchPendingUploadsBy: 'corrispondenza per nome file',\n    matchSettingsTemplates: 'Impostazioni e modelli',\n    matchSettingsTemplatesBy: 'sempre sovrascritti',\n    replaceExisting: 'Sostituisci dati esistenti',\n    keepExisting: 'Mantieni dati esistenti',\n    overwriteDescription: 'Sovrascrivi gli elementi già esistenti con i dati del backup',\n    keepDescription: 'Ripristina solo gli elementi che non esistono ancora',\n    overwriteCaution: 'Attenzione:',\n    overwriteWarning: 'La sovrascrittura sostituirà le configurazioni attuali con i dati del backup. I codici di accesso delle stampanti non vengono mai sovrascritti per sicurezza.',\n    cancel: 'Annulla',\n    processingBackup: 'Elaborazione del file di backup...',\n    itemsRestored: 'Elementi ripristinati',\n    itemsSkipped: 'Elementi saltati',\n    restored: 'Ripristinati',\n    skippedAlreadyExist: 'Saltati (già esistenti)',\n    filesCategory: 'File (3MF, miniature, ecc.)',\n    andMore: '...e altri {{count}}',\n    newApiKeysGenerated: 'Nuove chiavi API generate',\n    keysShownOnce: 'Queste chiavi vengono mostrate solo una volta. Copiale ora!',\n    copy: 'Copia',\n    noDataFound: 'Nessun dato da ripristinare trovato nel file di backup.',\n    close: 'Chiudi',\n\n    // Scheduled local backups (#884)\n    scheduledBackup: 'Scheduled Backups',\n    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',\n    frequency: 'Frequency',\n    backupTime: 'Time',\n    retention: 'Retention',\n    retentionDescription: 'Number of backups to keep',\n    outputPath: 'Output Path',\n    outputPathPlaceholder: 'Default: {{path}}',\n    outputPathDescription: 'Leave empty for default location',\n    runNow: 'Run Now',\n    backupFiles: 'Backup Files',\n    noScheduledBackups: 'No backups yet',\n    deleteBackup: 'Delete',\n    deleteBackupConfirm: 'Delete this backup file?',\n    backupRunning: 'Backup in progress...',\n    scheduledBackupComplete: 'Backup completed successfully',\n    scheduledBackupFailed: 'Backup failed',\n    nextBackup: 'Next backup',\n    backupSize: 'Size',\n    utc: 'UTC',\n    defaultPathLabel: 'Default:',\n\n    // Category labels\n    categories: {\n      settings: 'Impostazioni',\n      notification_providers: 'Provider di notifica',\n      notification_templates: 'Modelli di notifica',\n      smart_plugs: 'Smart Plug',\n      printers: 'Stampanti',\n      filaments: 'Filamenti',\n      maintenance_types: 'Tipi di manutenzione',\n      archives: 'Archivi',\n      projects: 'Progetti',\n      pending_uploads: 'Upload in sospeso',\n      external_links: 'Link esterni',\n      api_keys: 'Chiavi API',\n    },\n  },\n\n  // Tags\n  tags: {\n    title: 'Tag',\n    addTag: 'Aggiungi tag',\n    editTag: 'Modifica tag',\n    deleteTag: 'Elimina tag',\n    tagName: 'Nome tag',\n    tagColor: 'Colore tag',\n    noTags: 'Nessun tag',\n    deleteConfirm: 'Sei sicuro di voler eliminare questo tag?',\n    manageTags: 'Gestisci tag',\n  },\n\n  // Upload modal (archives)\n  uploadModal: {\n    title: 'Carica file 3MF',\n    dragDrop: 'Trascina e rilascia file .3mf qui',\n    or: 'o',\n    browseFiles: 'Sfoglia file',\n    extractionInfo: 'Il modello stampante sarà estratto automaticamente dai metadati del file 3MF.',\n    uploaded: 'caricati',\n    failed: 'falliti',\n    uploading: 'Caricamento...',\n    upload: 'Carica',\n    uploadFailed: 'Caricamento fallito',\n  },\n\n  // Edit archive modal\n  // Edit Archive Modal\n  editArchive: {\n    title: 'Modifica archivio',\n    name: 'Nome',\n    namePlaceholder: 'Nome stampa',\n    printer: 'Stampante',\n    noPrinter: 'Nessuna stampante',\n    project: 'Progetto',\n    noProject: 'Nessun progetto',\n    itemsPrinted: 'Elementi stampati',\n    itemsPrintedHelp: 'Numero di elementi prodotti in questo job di stampa',\n    notes: 'Note',\n    notesPlaceholder: 'Aggiungi note su questa stampa...',\n    externalLink: 'Link esterno',\n    externalLinkPlaceholder: 'https://printables.com/model/...',\n    externalLinkHelp: 'Link a Printables, Thingiverse o altra fonte',\n    tags: 'Tag',\n    tagsPlaceholder: 'Aggiungi tag...',\n    addMoreTags: 'Aggiungi altri tag...',\n    matchingTags: 'Tag corrispondenti \"{{query}}\"',\n    existingTags: 'Tag esistenti',\n    clickToAdd: '(clicca per aggiungere)',\n    status: 'Stato',\n    failureReason: 'Motivo fallimento',\n    selectReason: 'Seleziona motivo...',\n    photos: 'Foto del risultato stampato',\n    photosHelp: 'Clicca + per aggiungere foto del risultato stampato',\n    printResult: 'Risultato stampa',\n    saving: 'Salvataggio...',\n    // Failure reasons\n    failureReasons: {\n      adhesionFailure: 'Fallimento adesione',\n      spaghettiDetached: 'Spaghetti / staccato',\n      layerShift: 'Spostamento layer',\n      cloggedNozzle: 'Ugello intasato',\n      filamentRunout: 'Filamento esaurito',\n      warping: 'Warping',\n      stringing: 'Stringing',\n      underExtrusion: 'Sotto-estrusione',\n      powerFailure: 'Mancanza corrente',\n      userCancelled: 'Annullato dall\\'utente',\n      other: 'Altro',\n    },\n    // Archive statuses\n    statuses: {\n      completed: 'Completato',\n      failed: 'Fallito',\n      aborted: 'Annullato',\n      printing: 'In stampa',\n    },\n  },\n\n  // K-Profiles\n  kProfiles: {\n    title: 'K-Profiles',\n    noPrintersConfigured: 'Nessuna stampante configurata',\n    addPrinterInSettings: 'Aggiungi una stampante in Impostazioni per gestire i K-profiles',\n    noActivePrinters: 'Nessuna stampante attiva',\n    enablePrinterConnection: 'Abilita una connessione stampante per vedere i K-profiles',\n    loadingProfiles: 'Caricamento K-Profiles...',\n    printerOffline: 'Stampante offline',\n    printerOfflineDesc: 'La stampante selezionata non e connessa. Accendila per vedere i K-profiles.',\n    noMatchingProfiles: 'Nessun profilo corrispondente',\n    noMatchingProfilesDesc: 'Nessun profilo corrisponde ai criteri di ricerca',\n    noKProfiles: 'Nessun K-Profile',\n    noKProfilesDesc: 'Nessun profilo pressure advance per ugello da {{diameter}}mm',\n    createFirstProfile: 'Crea primo profilo',\n    // Controls\n    printer: 'Stampante',\n    nozzle: 'Ugello',\n    refresh: 'Aggiorna',\n    addProfile: 'Aggiungi profilo',\n    export: 'Esporta',\n    import: 'Importa',\n    select: 'Seleziona',\n    selectAll: 'Seleziona tutto',\n    delete: 'Elimina',\n    // Filters\n    searchPlaceholder: 'Cerca per nome o filamento...',\n    allExtruders: 'Tutti gli estrusori',\n    leftOnly: 'Solo sinistro',\n    rightOnly: 'Solo destro',\n    allFlow: 'Tutto flow',\n    hfOnly: 'Solo HF',\n    sOnly: 'Solo S',\n    sortName: 'Ordina: Nome',\n    sortKValue: 'Ordina: K-Value',\n    sortFilament: 'Ordina: Filamento',\n    // Dual extruder labels\n    leftExtruder: 'Estrusore sinistro',\n    rightExtruder: 'Estrusore destro',\n    // Modal\n    modal: {\n      addTitle: 'Aggiungi K-Profile',\n      editTitle: 'Modifica K-Profile',\n      profileName: 'Nome profilo',\n      profileNamePlaceholder: 'Il mio profilo PLA',\n      kValue: 'K-Value',\n      kValuePlaceholder: '0.020',\n      kValueHelp: 'Intervallo tipico: 0.01 - 0.06 per PLA, 0.02 - 0.10 per PETG',\n      filament: 'Filamento',\n      selectFilament: 'Seleziona filamento...',\n      noFilamentsHelp: 'Nessun filamento trovato. Crea prima un K-profile in Bambu Studio.',\n      flowType: 'Tipo flow',\n      highFlow: 'High Flow',\n      standard: 'Standard',\n      nozzleSize: 'Dimensione ugello',\n      extruder: 'Estrusore',\n      extruders: 'Estrusori',\n      left: 'Sinistra',\n      right: 'Destra',\n      notes: 'Note (salvate localmente)',\n      notesPlaceholder: 'Aggiungi note su questo profilo...',\n      notesHelp: 'Le note sono salvate in Bambuddy, non sulla stampante',\n      syncing: 'Sincronizzazione con stampante...',\n      savingExtruder: 'Salvataggio su estrusore {{current}}/{{total}}...',\n      pleaseWait: 'Attendere',\n    },\n    // Delete confirmation\n    deleteConfirm: {\n      title: 'Elimina profilo',\n      cannotUndo: 'Questo non può essere annullato',\n      message: 'Sei sicuro di voler eliminare \"{{name}}\" dalla stampante?',\n    },\n    // Bulk delete\n    bulkDelete: {\n      title: 'Elimina profili',\n      cannotUndo: 'Questo non può essere annullato',\n      message: 'Sei sicuro di voler eliminare {{count}} profili selezionati dalla stampante?',\n    },\n    // Toast\n    toast: {\n      profileSaved: 'K-profile salvato',\n      profilesSaved: 'K-profile salvato su {{count}} estrusori',\n      selectAtLeastOneExtruder: 'Seleziona almeno un estrusore',\n      profileDeleted: 'K-profile eliminato',\n      profilesDeleted: 'Eliminati {{count}} profili',\n      exportedProfiles: 'Esportati {{count}} profili',\n      importedProfiles: 'Importati {{count}} di {{total}} profili',\n      noProfilesToExport: 'Nessun profilo da esportare',\n      invalidFileFormat: 'Formato file non valido',\n      failedToParseImport: 'Parsing file import fallito',\n      failedToSaveBatch: 'Salvataggio K-profiles fallito',\n      noteSaved: 'Nota salvata',\n      failedToSaveNote: 'Salvataggio nota fallito',\n    },\n    // Permissions\n    permission: {\n      noRead: 'Non hai il permesso di aggiornare i profili',\n      noCreate: 'Non hai il permesso di aggiungere profili',\n      noUpdate: 'Non hai il permesso di aggiornare K-profiles',\n      noDelete: 'Non hai il permesso di eliminare K-profiles',\n      noExport: 'Non hai il permesso di esportare profili',\n      noImport: 'Non hai il permesso di importare profili',\n    },\n  },\n\n  // Virtual Printer\n  virtualPrinter: {\n    title: 'Stampante virtuale',\n    running: 'In esecuzione',\n    stopped: 'Ferma',\n    description: {\n      default: 'Abilita una stampante virtuale che appare in Bambu Studio e OrcaSlicer. I file inviati a questa stampante saranno archiviati senza stampare.',\n      proxy: 'Abilita un proxy che inoltra il traffico slicer a una stampante reale, permettendo la stampa remota su qualsiasi rete.',\n    },\n    enable: {\n      title: 'Abilita stampante virtuale',\n      visibleInSlicer: 'Visibile come \"Bambuddy\" nella ricerca slicer',\n      proxyingTo: 'In proxy verso {{name}}',\n      notActive: 'Non attivo',\n    },\n    model: {\n      title: 'Modello stampante',\n      description: 'Seleziona il modello stampante da emulare.',\n      restartWarning: 'Cambiare il modello riavviera la stampante virtuale',\n    },\n    accessCode: {\n      title: 'Codice accesso',\n      isSet: 'Codice accesso impostato',\n      notSet: 'Nessun codice accesso impostato - richiesto per abilitare',\n      placeholder: 'Inserisci codice 8 caratteri',\n      placeholderChange: 'Inserisci nuovo codice per cambiare',\n      hint: 'Deve essere esattamente 8 caratteri. Usato dagli slicer per autenticarsi.',\n      charCount: '({{count}}/8)',\n    },\n    targetPrinter: {\n      title: 'Stampante target',\n      configured: 'Target proxy configurato',\n      notConfigured: 'Nessuna stampante target selezionata - richiesta per modalita proxy',\n      placeholder: 'Seleziona una stampante...',\n      hint: 'Seleziona la stampante a cui fare proxy. La stampante deve essere in modalita LAN.',\n      noPrinters: 'Nessuna stampante configurata. Aggiungi una stampante per usare la modalita proxy.',\n    },\n    remoteInterface: {\n      title: 'Sovrascrittura interfaccia di rete',\n      configured: 'Sovrascrittura interfaccia attiva',\n      optional: 'Opzionale - usare se l\\'IP rilevato automaticamente e sbagliato (es. piu NIC, Docker, VPN)',\n      placeholder: 'Rilevamento automatico (predefinito)...',\n      hint: 'Sovrascrive l\\'indirizzo IP pubblicizzato via SSDP e usato nel certificato TLS. Utile quando Bambuddy ha piu interfacce di rete.',\n    },\n    mode: {\n      title: 'Modalita',\n      archive: 'Archivio',\n      archiveDesc: 'Archivia subito i file',\n      review: 'Revisione',\n      reviewDesc: 'Rivedi prima di archiviare',\n      queue: 'Coda',\n      queueDesc: 'Archivia e aggiungi alla coda',\n      proxy: 'Proxy',\n      proxyDesc: 'Inoltra a stampante reale',\n    },\n    autoDispatch: {\n      title: 'Avvio automatico',\n      description: 'Avvia automaticamente le stampe aggiunte alla coda. Se disattivato, le stampe attendono l\\'avvio manuale.',\n    },\n    setupRequired: {\n      title: 'Configurazione necessaria',\n      description: 'La stampante virtuale richiede configurazioni di sistema aggiuntive prima di funzionare. Include port forwarding, regole firewall e impostazioni specifiche della piattaforma.',\n      readGuide: 'Leggi la guida prima di abilitare',\n    },\n    howItWorks: {\n      title: 'Come funziona',\n      step1: 'Sulla stessa LAN, le stampanti virtuali appaiono automaticamente nel tuo slicer (Bambu Studio / OrcaSlicer). Da altre reti, aggiungile manualmente tramite indirizzo IP e codice di accesso.',\n      step2: 'In modalità Archivio, Revisione e Coda, usa il pulsante \"Invia\" nel tuo slicer per caricare file 3MF su Bambuddy. Lo slicer mostrerà \"Stampa riuscita\" — il file viene salvato, non stampato.',\n      step3: 'In modalità Proxy, la stampante virtuale inoltra tutto il traffico a una stampante reale — le stampe partono immediatamente come con una connessione diretta.',\n    },\n    status: {\n      title: 'Dettagli stato',\n      printerName: 'Nome stampante',\n      model: 'Modello',\n      serialNumber: 'Numero seriale',\n      mode: 'Modalita',\n      pendingFiles: 'File in sospeso',\n      targetPrinter: 'Stampante target',\n      ftpPort: 'Porta FTP',\n      mqttPort: 'Porta MQTT',\n      ftpConnections: 'Connessioni FTP',\n      mqttConnections: 'Connessioni MQTT',\n    },\n    toast: {\n      updated: 'Impostazioni stampante virtuale aggiornate',\n      failedToUpdate: 'Aggiornamento impostazioni fallito',\n      accessCodeRequired: 'Imposta prima un codice accesso',\n      targetPrinterRequired: 'Seleziona prima una stampante target',\n      bindIpRequired: 'Impostare prima un indirizzo IP',\n      accessCodeEmpty: 'Il codice accesso non può essere vuoto',\n      accessCodeLength: 'Il codice accesso deve essere esattamente 8 caratteri',\n      created: 'Stampante virtuale creata',\n      failedToCreate: 'Impossibile creare la stampante virtuale',\n      deleted: 'Stampante virtuale eliminata',\n      failedToDelete: 'Impossibile eliminare la stampante virtuale',\n    },\n    list: {\n      title: 'Stampanti virtuali',\n      add: 'Aggiungi',\n      addFirst: 'Aggiungi stampante virtuale',\n      empty: 'Nessuna stampante virtuale configurata. Aggiungine una per iniziare.',\n    },\n    bindIp: {\n      title: 'Interfaccia di rete',\n      placeholder: 'Seleziona interfaccia...',\n      hint: 'Interfaccia di rete a cui questa stampante virtuale si collega. Deve essere unica per stampante.',\n    },\n    proxy: {\n      accessCodeHint: 'In modalita proxy, usa il codice di accesso della stampante di destinazione nello slicer. La connessione viene inoltrata in modo trasparente alla stampante reale.',\n    },\n    addDialog: {\n      title: 'Aggiungi stampante virtuale',\n      name: 'Nome',\n      hint: 'Potrai configurare il codice di accesso, la stampante di destinazione e altre impostazioni dopo la creazione.',\n      create: 'Crea',\n    },\n    deleteConfirm: {\n      title: 'Elimina stampante virtuale',\n      message: 'Sei sicuro di voler eliminare \"{{name}}\"? Tutti i servizi di questa stampante verranno interrotti.',\n    },\n  },\n\n  // Model Viewer\n  modelViewer: {\n    openInSlicer: 'Apri nello slicer',\n    tabs: {\n      model: 'Modello 3D',\n      gcode: 'Anteprima G-code',\n    },\n    notAvailable: 'non disponibile',\n    notSliced: 'non sezionato',\n    plates: 'Piatti',\n    allPlates: 'Tutti i piatti',\n    plateNumber: 'Piatto {{number}}',\n    plateCount: '{{count}} piatto',\n    plateCount_other: '{{count}} piatti',\n    objectCount: '{{count}} oggetto',\n    objectCount_other: '{{count}} oggetti',\n    filamentCount: '{{count}} filamento',\n    filamentCount_other: '{{count}} filamenti',\n    eta: 'ETA {{minutes}} min',\n    noPreview: 'Nessuna anteprima disponibile per questo file',\n    pagination: {\n      pageOf: 'Pagina {{current}} di {{total}}',\n      prev: 'Prec',\n      next: 'Succ',\n    },\n    errors: {\n      failedToLoad: 'Caricamento file fallito',\n      noMeshes: 'Nessuna mesh trovata nel file 3MF',\n      unsupportedFormat: 'Formato file non supportato',\n    },\n  },\n\n  // Maintenance type descriptions (built-in)\n  maintenanceDescriptions: {\n    lubricateCarbonRods: 'Applica lubrificante alle aste in carbonio per un movimento fluido',\n    lubricateRails: 'Applica lubrificante alle guide lineari per un movimento fluido',\n    cleanNozzle: 'Pulisci hotend e ugello per prevenire intasamenti',\n    checkBelts: 'Verifica tensione cinghie per stampe accurate',\n    cleanBuildPlate: 'Pulisci il piatto per migliorare l\\'adesione',\n    checkExtruder: 'Ispeziona ingranaggi estrusore per usura',\n    checkCooling: 'Assicurati che le ventole di raffreddamento funzionino',\n    generalInspection: 'Ispezione generale stampante',\n    cleanCarbonRods: 'Pulisci le aste in carbonio per ridurre attrito',\n    lubricateSteelRods: 'Applica lubrificante alle aste in acciaio per un movimento fluido',\n    cleanSteelRods: 'Pulisci le aste in acciaio per ridurre attrito',\n    cleanLinearRails: 'Pulisci le guide lineari per rimuovere polvere e detriti',\n    checkPtfeTube: 'Ispeziona il tubo PTFE per usura o danni',\n    replaceHepaFilter: 'Sostituisci filtro HEPA per qualità aria',\n    replaceCarbonFilter: 'Sostituisci filtro a carbone attivo',\n    lubricateLeftNozzleRail: 'Lubrifica guida ugello sinistro (serie H2)',\n  },\n\n  // Smart Plugs\n  smartPlugs: {\n    offline: 'Offline',\n    admin: 'Amministrazione',\n    openPlugAdminPage: 'Apri pagina amministrazione presa',\n    deleteSmartPlug: 'Elimina presa smart',\n    turnOnSmartPlug: 'Accendi presa smart',\n    turnOffSmartPlug: 'Spegni presa smart',\n    turnOn: 'Accendi',\n    turnOff: 'Spegni',\n    addSmartPlug: {\n      scanningNetwork: 'Scansione rete...',\n      chooseEntity: 'Scegli un\\'entità...',\n      connectionFailed: 'Connessione fallita',\n      searchEntities: 'Cerca entità...',\n      searchPowerSensors: 'Cerca sensori di potenza...',\n      searchEnergySensors: 'Cerca sensori di energia...',\n      placeholders: {\n        plugName: 'Presa soggiorno',\n        mqttStateOnValue: 'ON, true, 1',\n        mqttSameAsPower: 'Stesso del topic potenza, o diverso',\n      },\n    },\n    // SmartPlugCard\n    linkedTo: 'Collegato a:',\n    monitorOnly: 'Solo monitoraggio',\n    alerts: 'Avvisi',\n    scheduleOn: 'On {{time}}',\n    scheduleOff: 'Off {{time}}',\n    on: 'On',\n    off: 'Off',\n    power: 'Potenza',\n    kwhToday: 'kWh Oggi',\n    settings: 'Impostazioni',\n    automationSettings: 'Impostazioni automazione',\n    showInSwitchbar: 'Mostra nella barra interruttori',\n    quickAccessSidebar: 'Accesso rapido dalla barra laterale',\n    enabled: 'Abilitato',\n    enableAutomation: 'Abilita automazione per questa presa',\n    autoOn: 'Auto On',\n    autoOnDescription: 'Accendi quando inizia la stampa',\n    autoOff: 'Auto Off',\n    autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',\n    autoOffPersistent: 'Mantieni attivo',\n    autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',\n    turnOffDelayMode: 'Modalità ritardo spegnimento',\n    time: 'Tempo',\n    temp: 'Temp',\n    delayMinutes: 'Ritardo (minuti)',\n    tempThreshold: 'Soglia temperatura (°C)',\n    tempThresholdDescription: 'Si spegne quando l\\'ugello si raffredda sotto questa temperatura',\n    edit: 'Modifica',\n    deleteConfirm: 'Sei sicuro di voler eliminare \"{{name}}\"? Questa azione non può essere annullata.',\n    turnOnConfirm: 'Sei sicuro di voler accendere \"{{name}}\"?',\n    turnOffConfirm: 'Sei sicuro di voler spegnere \"{{name}}\"? Questo interromperà l\\'alimentazione del dispositivo collegato.',\n    failedToTurn: 'Impossibile {{action}} \"{{name}}\"',\n    unknown: 'Sconosciuto',\n    // AddSmartPlugModal\n    addTitle: 'Aggiungi presa smart',\n    editTitle: 'Modifica presa smart',\n    stopScanning: 'Interrompi scansione',\n    discoverTasmota: 'Scopri dispositivi Tasmota',\n    foundDevices: '{{count}} dispositivo/i trovato/i - clicca per selezionare:',\n    noDevicesFound: 'Nessun dispositivo Tasmota trovato nella rete',\n    haNotConfigured: 'Home Assistant non è configurato. Configuralo in',\n    haSettingsPath: 'Impostazioni → Rete → Home Assistant',\n    selectEntity: 'Seleziona entità *',\n    ipAddress: 'Indirizzo IP *',\n    nameLabel: 'Nome *',\n    username: 'Nome utente',\n    password: 'Password',\n    authHint: 'Lascia vuoto se il tuo dispositivo Tasmota non richiede autenticazione',\n    linkToPrinter: 'Collega alla stampante',\n    noPrinter: 'Nessuna stampante (solo controllo manuale)',\n    linkingDescription: 'Il collegamento abilita accensione/spegnimento automatico all\\'inizio/fine stampa',\n    powerAlerts: 'Avvisi potenza',\n    alertAbove: 'Avviso se sopra (W)',\n    alertBelow: 'Avviso se sotto (W)',\n    alertDescription: 'Ricevi notifiche quando il consumo supera queste soglie. Lascia vuoto per disabilitare quella direzione.',\n    dailySchedule: 'Programma giornaliero',\n    turnOnAt: 'Accendi alle',\n    turnOffAt: 'Spegni alle',\n    scheduleDescription: 'Accendi/spegni automaticamente la presa a questi orari ogni giorno. Lascia vuoto per saltare quell\\'azione.',\n    showOnPrinterCard: 'Mostra sulla scheda stampante',\n    displayOnPrinterCard: 'Mostra pulsante sulla scheda stampante',\n    connectedResult: 'Connesso!',\n    deviceLabel: 'Dispositivo: {{name}} - ',\n    stateLabel: 'Stato: {{state}}',\n    test: 'Test',\n    delete: 'Elimina',\n    save: 'Salva',\n    add: 'Aggiungi',\n    cancel: 'Annulla',\n    failedToStartScan: 'Impossibile avviare la scansione',\n    nameRequired: 'Il nome è obbligatorio',\n    entityRequired: 'L\\'entità è obbligatoria per le prese Home Assistant',\n    mqttTopicRequired: 'Almeno un topic MQTT deve essere configurato per potenza, energia o monitoraggio stato',\n    loadingEntities: 'Caricamento entità...',\n    loading: 'Caricamento...',\n    failedToLoadEntities: 'Impossibile caricare le entità: {{error}}',\n    noEntitiesMatching: 'Nessuna entità trovata corrispondente a \"{{search}}\"',\n    noEntitiesAvailable: 'Nessuna entità disponibile',\n    searchingEntities: 'Ricerca in tutte le entità ({{count}} trovate)',\n    showingEntities: 'Mostrando switch, light, input_boolean ({{count}} disponibili)',\n    energyMonitoringOptional: 'Monitoraggio energia (Opzionale)',\n    energyMonitoringHint: 'Cerca e seleziona i sensori che forniscono dati di potenza/energia.',\n    powerSensorW: 'Sensore potenza (W)',\n    energyTodayKwh: 'Energia oggi (kWh)',\n    totalEnergyKwh: 'Energia totale (kWh)',\n    noMatchingSensors: 'Nessun sensore corrispondente',\n    none: 'Nessuno',\n    mqttNotConfigured: 'Broker MQTT non configurato. Imposta l\\'indirizzo del broker in',\n    mqttSettingsPath: 'Impostazioni → Rete → Pubblicazione MQTT',\n    mqttNotConfiguredSuffix: '(non è necessario abilitare la pubblicazione, basta inserire i dettagli del broker).',\n    mqttMonitorOnlyDescription: 'Le prese MQTT ricevono dati di potenza/energia tramite sottoscrizione MQTT. Il controllo on/off non è disponibile - usa il tuo broker MQTT o sistema domotico.',\n    powerMonitoring: 'Monitoraggio potenza',\n    energyMonitoring: 'Monitoraggio energia',\n    stateMonitoring: 'Monitoraggio stato',\n    optional: 'opzionale',\n    topic: 'Topic',\n    jsonPath: 'Percorso JSON',\n    multiplier: 'Moltiplicatore',\n    onValue: 'Valore ON',\n    mqttPowerHint: 'Il percorso JSON estrae il valore dal payload JSON (es. \"power_l1\"). Lascia vuoto se il topic pubblica valori numerici grezzi.\\nUsa moltiplicatore 0.001 per mW→W, 1000 per kW→W.',\n    mqttEnergyHint: 'Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\\nUsa moltiplicatore 0.001 per Wh→kWh, 1000 per MWh→kWh.',\n    mqttStateHint: 'Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\\nValore ON: la stringa esatta che significa \"ON\". Lascia vuoto per rilevamento auto (ON, true, 1).',\n    // REST smart plug\n    restControl: 'Control',\n    restOnUrl: 'Turn ON URL',\n    restOffUrl: 'Turn OFF URL',\n    restOnBody: 'ON Request Body',\n    restOffBody: 'OFF Request Body',\n    restMethod: 'HTTP Method',\n    restHeaders: 'Custom Headers (JSON)',\n    restStatusUrl: 'Status URL',\n    restStatusPath: 'State JSON Path',\n    restStatusOnValue: 'ON Value',\n    restPowerUrl: 'URL potenza',\n    restPowerPath: 'Power JSON Path',\n    restPowerMultiplier: 'Moltiplicatore potenza',\n    restEnergyUrl: 'URL energia',\n    restEnergyPath: 'Energy JSON Path',\n    restEnergyMultiplier: 'Moltiplicatore energia',\n    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',\n    restHeadersHint: 'e.g. {\"Authorization\": \"Bearer your-token\"}',\n    restBodyHint: 'e.g. ON, {\"state\": \"on\"}',\n    restStatusHint: 'URL to poll for current state',\n    restPathHint: 'e.g. state or data.power.status',\n    restPowerUrlHint: 'URL separato per i dati di potenza (usa l\\'URL di stato se vuoto)',\n    restEnergyUrlHint: 'URL separato per i dati di energia (usa l\\'URL di stato se vuoto)',\n    restEnergyHint: 'Ogni valore può usare il proprio URL o ricadere sull\\'URL di stato. Usa i moltiplicatori per la conversione delle unità (es. 0.001 per convertire Wh in kWh).',\n    testConnection: 'Test Connection',\n    connectionSuccess: 'Connection successful',\n    noSwitchesInSwitchbar: 'Nessun interruttore nella barra',\n    enableSwitchbarHint: 'Abilita \"Mostra nella barra interruttori\" in Impostazioni > Smart Plugs',\n  },\n\n  // Notifications\n  notifications: {\n    // Provider types\n    providerTypes: {\n      callmebot: 'CallMeBot/WhatsApp',\n      ntfy: 'ntfy',\n      pushover: 'Pushover',\n      telegram: 'Telegram',\n      email: 'Email',\n      discord: 'Discord',\n      webhook: 'Webhook',\n      homeassistant: 'Home Assistant',\n    },\n    // Provider descriptions\n    providerDescriptions: {\n      email: 'Notifiche email tramite SMTP',\n      telegram: 'Notifiche tramite bot Telegram',\n      discord: 'Invia a un canale Discord tramite webhook',\n      ntfy: 'Notifiche push gratuite e self-hostabili',\n      pushover: 'Notifiche push semplici e affidabili',\n      callmebot: 'Notifiche WhatsApp gratuite tramite CallMeBot',\n      webhook: 'POST HTTP generico verso qualsiasi URL',\n      homeassistant: 'Notifiche persistenti nella dashboard di Home Assistant',\n    },\n    // NotificationProviderCard\n    lastSuccess: 'Ultimo: {{date}}',\n    error: 'Errore',\n    printer: 'Stampante:',\n    allPrinters: 'Tutte le stampanti',\n    sendTestNotification: 'Invia notifica di prova',\n    eventSettings: 'Impostazioni eventi',\n    enabled: 'Abilitato',\n    sendFromProvider: 'Invia notifiche da questo provider',\n    // Event categories\n    printEvents: 'Eventi di stampa',\n    printerStatus: 'Stato stampante',\n    amsAlarms: 'Allarmi AMS',\n    amsHtAlarms: 'Allarmi AMS-HT',\n    printQueue: 'Coda di stampa',\n    // Event tags (badges)\n    start: 'Avvio',\n    plateCheck: 'Controllo piatto',\n    complete: 'Completato',\n    failed: 'Fallito',\n    stopped: 'Interrotto',\n    progress: 'Avanzamento',\n    offline: 'Offline',\n    lowFilament: 'Filamento scarso',\n    maintenance: 'Manutenzione',\n    amsHumidity: 'Umidità AMS',\n    amsTemp: 'Temp AMS',\n    amsHtHumidity: 'Umidità AMS-HT',\n    amsHtTemp: 'Temp AMS-HT',\n    bedCooled: 'Piatto raffreddato',\n    firstLayer: 'Primo strato',\n    quiet: 'Silenzioso',\n    digest: 'Riepilogo {{time}}',\n    // Event labels (expanded settings)\n    printStarted: 'Stampa avviata',\n    plateNotEmpty: 'Piatto non vuoto',\n    plateNotEmptyDescription: 'Oggetti rilevati prima della stampa',\n    printCompleted: 'Stampa completata',\n    bedCooledLabel: 'Piatto raffreddato',\n    bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',\n    firstLayerCompleteLabel: 'Primo strato completato',\n    firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',\n    missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',\n    missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',\n    printFailed: 'Stampa fallita',\n    printStopped: 'Stampa interrotta',\n    progressMilestones: 'Traguardi di avanzamento',\n    progressMilestonesDescription: 'Notifica al 25%, 50%, 75%',\n    printerOffline: 'Stampante offline',\n    printerError: 'Errore stampante',\n    lowFilamentLabel: 'Filamento scarso',\n    maintenanceDue: 'Manutenzione necessaria',\n    maintenanceDueDescription: 'Notifica quando è necessaria la manutenzione',\n    amsHumidityHigh: 'Umidità AMS elevata',\n    amsHumidityHighDescription: 'L\\'umidità dell\\'AMS standard supera la soglia',\n    amsTemperatureHigh: 'Temperatura AMS elevata',\n    amsTemperatureHighDescription: 'La temperatura dell\\'AMS standard supera la soglia',\n    amsHtHumidityHigh: 'Umidità AMS-HT elevata',\n    amsHtHumidityHighDescription: 'L\\'umidità dell\\'AMS-HT supera la soglia',\n    amsHtTemperatureHigh: 'Temperatura AMS-HT elevata',\n    amsHtTemperatureHighDescription: 'La temperatura dell\\'AMS-HT supera la soglia',\n    // Queue events\n    jobAdded: 'Lavoro aggiunto',\n    jobAddedDescription: 'Lavoro aggiunto alla coda',\n    jobAssigned: 'Lavoro assegnato',\n    jobAssignedDescription: 'Lavoro basato su modello assegnato alla stampante',\n    jobStarted: 'Lavoro avviato',\n    jobStartedDescription: 'Lavoro in coda avviato per la stampa',\n    jobWaiting: 'Lavoro in attesa',\n    jobWaitingDescription: 'Lavoro in attesa di filamento o stampante',\n    jobSkipped: 'Lavoro saltato',\n    jobSkippedDescription: 'Lavoro saltato (precedente fallito)',\n    jobFailed: 'Lavoro fallito',\n    jobFailedDescription: 'Avvio del lavoro fallito',\n    queueComplete: 'Coda completata',\n    queueCompleteDescription: 'Tutti i lavori in coda completati',\n    // Quiet hours\n    quietHours: 'Ore silenziose',\n    noNotificationsDuring: 'Nessuna notifica durante queste ore',\n    editProviderToChangeQuietHours: 'Modifica il provider per cambiare le ore silenziose',\n    // Daily digest\n    dailyDigest: 'Riepilogo giornaliero',\n    batchNotifications: 'Raggruppa le notifiche in un unico riepilogo giornaliero',\n    sendAt: 'Invia alle {{time}}',\n    editProviderToChangeDigestTime: 'Modifica il provider per cambiare l\\'orario del riepilogo',\n    // Actions\n    edit: 'Modifica',\n    deleteProvider: 'Elimina provider di notifica',\n    deleteConfirm: 'Sei sicuro di voler eliminare \"{{name}}\"? Questa azione non può essere annullata.',\n    delete: 'Elimina',\n    // AddNotificationModal\n    addTitle: 'Aggiungi provider di notifica',\n    editTitle: 'Modifica provider di notifica',\n    nameLabel: 'Nome *',\n    namePlaceholder: 'Le mie notifiche',\n    providerTypeLabel: 'Tipo di provider *',\n    configuration: 'Configurazione',\n    testConfiguration: 'Testa configurazione',\n    printerFilter: 'Filtro stampante',\n    onlyFromPrinter: 'Invia notifiche solo per eventi da questa stampante',\n    quietHoursDnd: 'Ore silenziose (Non disturbare)',\n    quietStart: 'Inizio',\n    quietEnd: 'Fine',\n    dailyDigestLabel: 'Riepilogo giornaliero',\n    sendDigestAt: 'Invia riepilogo alle',\n    digestCollected: 'Gli eventi verranno raccolti e inviati come riepilogo unico a quest\\'ora',\n    notificationEvents: 'Eventi di notifica',\n    progressPercent: '(25%, 50%, 75%)',\n    bedCooledAfterPrint: '(dopo il completamento della stampa)',\n    cancel: 'Annulla',\n    save: 'Salva',\n    add: 'Aggiungi',\n    nameRequired: 'Il nome è obbligatorio',\n    fieldRequired: '{{field}} è obbligatorio',\n    // Config field labels\n    phoneNumber: 'Numero di telefono',\n    apiKey: 'Chiave API',\n    serverUrl: 'URL del server',\n    topic: 'Argomento',\n    authToken: 'Token di autenticazione',\n    userKey: 'Chiave utente',\n    appToken: 'Token applicazione',\n    priority: 'Priorità',\n    botToken: 'Token del bot',\n    chatId: 'ID chat',\n    smtpServer: 'Server SMTP',\n    smtpPort: 'Porta SMTP',\n    security: 'Sicurezza',\n    authentication: 'Autenticazione',\n    username: 'Nome utente',\n    password: 'Password',\n    fromEmail: 'Email mittente',\n    toEmail: 'Email destinatario',\n    webhookUrl: 'URL webhook',\n    payloadFormat: 'Formato payload',\n    authorization: 'Autorizzazione',\n    titleFieldName: 'Nome campo titolo',\n    messageFieldName: 'Nome campo messaggio',\n    // NotificationTemplateEditor\n    editTemplate: 'Modifica modello: {{name}}',\n    titleLabel: 'Titolo',\n    bodyLabel: 'Corpo',\n    titlePlaceholder: 'Titolo della notifica...',\n    bodyPlaceholder: 'Corpo della notifica...',\n    availableVariables: 'Variabili disponibili',\n    clickToInsert: 'Clicca per inserire alla posizione del cursore nel corpo',\n    livePreview: 'Anteprima live',\n    hide: 'Nascondi',\n    show: 'Mostra',\n    loadingPreview: 'Caricamento anteprima...',\n    enterTemplateContent: 'Inserisci il contenuto del modello per vedere l\\'anteprima',\n    titlePreview: 'Titolo:',\n    bodyPreview: 'Corpo:',\n    resetToDefault: 'Ripristina predefinito',\n    titleRequired: 'Il titolo è obbligatorio',\n    bodyRequired: 'Il corpo è obbligatorio',\n    // NotificationLogViewer\n    notificationLog: 'Registro notifiche',\n    showFailedOnly: 'Solo fallite',\n    last24Hours: 'Ultime 24 ore',\n    last7Days: 'Ultimi 7 giorni',\n    last30Days: 'Ultimi 30 giorni',\n    last90Days: 'Ultimi 90 giorni',\n    justNow: 'Proprio ora',\n    noFailedNotifications: 'Nessuna notifica fallita',\n    noNotificationsLogged: 'Nessuna notifica registrata',\n    unknownProvider: 'Provider sconosciuto',\n    logTitle: 'Titolo',\n    logMessage: 'Messaggio',\n    logError: 'Errore',\n    logProvider: 'Provider: {{type}}',\n    logTime: 'Ora: {{time}}',\n    refresh: 'Aggiorna',\n    clearOld: 'Cancella vecchie',\n    statsSummary: 'Ultimi {{days}} giorni:',\n    statsNotifications: 'notifiche',\n    statsSent: '{{count}} inviate',\n    statsFailed: '{{count}} fallite',\n    // Event type labels (for log viewer)\n    eventTypes: {\n      print_start: 'Stampa avviata',\n      print_complete: 'Stampa completata',\n      print_failed: 'Stampa fallita',\n      print_stopped: 'Stampa interrotta',\n      print_progress: 'Avanzamento',\n      printer_offline: 'Stampante offline',\n      printer_error: 'Errore stampante',\n      filament_low: 'Filamento scarso',\n      maintenance_due: 'Manutenzione necessaria',\n      test: 'Prova',\n    },\n    // User email notification preferences\n    userEmail: {\n      title: 'Notifiche',\n      emailNotifications: 'Notifiche via e-mail',\n      emailNotificationsDesc: \"Ricevi notifiche via e-mail per i tuoi lavori di stampa. Le e-mail vengono inviate tramite le impostazioni SMTP configurate nell'autenticazione avanzata.\",\n      sendingTo: 'Le notifiche verranno inviate a',\n      noEmailWarning: \"Il tuo account non ha un indirizzo e-mail. Contatta un amministratore per aggiungerne uno.\",\n      printJobNotifications: 'Notifiche lavori di stampa',\n      printJobNotificationsDesc: 'Scegli quali eventi attivano le notifiche e-mail per i lavori di stampa che invii.',\n      printJobStarts: 'Inizio lavoro di stampa',\n      printJobStartsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa inizia.',\n      printJobFinishes: 'Fine lavoro di stampa',\n      printJobFinishesDesc: 'Ricevi una notifica quando il tuo lavoro di stampa si completa correttamente.',\n      printErrors: 'Errori di stampa',\n      printErrorsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa fallisce o incontra un errore.',\n      printJobStops: 'Lavoro di stampa interrotto',\n      printJobStopsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa viene annullato o interrotto.',\n      saveSuccess: 'Preferenze di notifica salvate.',\n      saveError: 'Impossibile salvare le preferenze di notifica.',\n    },\n  },\n\n  // Rich Text Editor\n  richTextEditor: {\n    bold: 'Grassetto',\n    italic: 'Corsivo',\n    underline: 'Sottolineato',\n    bulletList: 'Elenco puntato',\n    numberedList: 'Elenco numerato',\n    alignLeft: 'Allinea a sinistra',\n    alignCenter: 'Allinea al centro',\n    alignRight: 'Allinea a destra',\n    addLink: 'Aggiungi link',\n    removeLink: 'Rimuovi link',\n  },\n\n  // External Links\n  externalLinks: {\n    noLinksConfigured: 'Nessun link esterno configurato',\n    deleteLink: 'Elimina link',\n    removeCustomIcon: 'Rimuovi icona personalizzata',\n    openInNewTab: 'Apri in nuova scheda',\n    placeholders: {\n      linkName: 'Il mio link',\n    },\n  },\n\n  // Keyboard Shortcuts Modal\n  keyboardShortcuts: {\n    title: 'Scorciatoie da tastiera',\n    navigation: 'Navigazione',\n    archivesSection: 'Archivi',\n    kProfilesSection: 'Profili K',\n    generalSection: 'Generale',\n    shortcuts: {\n      goToPrinters: 'Vai a Stampanti',\n      goToArchives: 'Vai ad Archivi',\n      goToQueue: 'Vai a Coda',\n      goToStats: 'Vai a Statistiche',\n      goToProfiles: 'Vai a Profili cloud',\n      goToSettings: 'Vai a Impostazioni',\n      focusSearch: 'Vai alla ricerca',\n      openUploadModal: 'Apri finestra di caricamento',\n      clearSelection: 'Cancella selezione / deseleziona input',\n      contextMenu: 'Menu contestuale sulle schede',\n      refreshProfiles: 'Aggiorna profili',\n      newProfile: 'Nuovo profilo',\n      exitSelectionMode: 'Esci dalla modalità selezione',\n      showHelp: 'Mostra questa guida',\n    },\n    footer: 'Premi Esc o clicca fuori per chiudere',\n  },\n\n  // Notification Log\n  notificationLog: {\n    title: 'Registro notifiche',\n    events: {\n      printStarted: 'Stampa avviata',\n      printComplete: 'Stampa completata',\n      printFailed: 'Stampa fallita',\n      printStopped: 'Stampa interrotta',\n      progress: 'Avanzamento',\n      printerOffline: 'Stampante offline',\n      printerError: 'Errore stampante',\n      lowFilament: 'Filamento in esaurimento',\n      maintenanceDue: 'Manutenzione in scadenza',\n      test: 'Test',\n    },\n    timeAgo: {\n      justNow: 'Adesso',\n      minutesAgo: '{{minutes}} min fa',\n      hoursAgo: '{{hours}} ore fa',\n    },\n  },\n\n  // Restore/Backup Modal\n  restoreBackup: {\n    title: 'Ripristina backup',\n    restoring: 'Ripristino...',\n    restoreComplete: 'Ripristino completato',\n    restoreFailed: 'Ripristino fallito',\n    importSettings: 'Importa impostazioni da un file di backup',\n    pleaseWait: 'Attendere il ripristino dei dati',\n    clickToSelect: 'Clicca per selezionare il file di backup (.json o .zip)',\n    howDuplicateHandling: 'Come funziona la gestione dei duplicati:',\n    categories: {\n      printers: 'Stampanti',\n      smartPlugs: 'Prese smart',\n      notificationProviders: 'Provider di notifica',\n      filaments: 'Filamenti',\n      archives: 'Archivi',\n      pendingUploads: 'Caricamenti in sospeso',\n      settingsTemplates: 'Impostazioni e modelli',\n    },\n    matchingInfo: {\n      printers: 'abbinati per numero di serie',\n      smartPlugs: 'abbinati per indirizzo IP',\n      notificationProviders: 'abbinati per nome',\n      filaments: 'abbinati per nome + tipo + marca',\n      archives: 'abbinati per hash del contenuto',\n      pendingUploads: 'abbinati per nome file',\n      settingsTemplates: 'sempre sovrascritti',\n    },\n    replaceExisting: 'Sostituisci dati esistenti',\n    keepExisting: 'Mantieni dati esistenti',\n    replaceDescription: 'Sovrascrivi gli elementi già esistenti con i dati del backup',\n    keepDescription: 'Ripristina solo gli elementi che non esistono già',\n    caution: 'Attenzione:',\n    cautionText: 'La sovrascrittura sostituirà le configurazioni attuali con i dati del backup. I codici di accesso delle stampanti non vengono mai sovrascritti per sicurezza.',\n    itemsRestored: 'Elementi ripristinati',\n    itemsSkipped: 'Elementi saltati',\n    restored: 'Ripristinati',\n    skipped: 'Saltati (già esistenti)',\n    filesLabel: 'File (3MF, miniature, ecc.)',\n    newApiKeysGenerated: 'Nuove chiavi API generate',\n    newApiKeysWarning: 'Queste chiavi vengono mostrate una sola volta. Copiale adesso!',\n    processingBackup: 'Elaborazione file di backup...',\n    noDataFound: 'Nessun dato trovato da ripristinare nel file di backup.',\n    failedToRestore: 'Impossibile ripristinare il backup. Verificare il formato del file.',\n  },\n\n  // Backup Export Modal\n  backupExport: {\n    title: 'Esporta backup',\n    selectData: 'Seleziona i dati da includere',\n    selectAll: 'Seleziona tutto',\n    selectNone: 'Deseleziona tutto',\n    categoryDescriptions: {\n      settings: 'Lingua, tema, preferenze di aggiornamento',\n      notifications: 'ntfy, Pushover, Discord, ecc.',\n      templates: 'Modelli di messaggi personalizzati',\n      smartPlugs: 'Configurazioni prese Tasmota',\n      externalLinks: 'Link della barra laterale a servizi esterni',\n      printers: 'Info stampanti (codici di accesso esclusi)',\n      plateDetection: 'Immagini di riferimento piatto vuoto',\n      filaments: 'Tipi di filamento e costi',\n      maintenance: 'Programmi di manutenzione personalizzati',\n      archives: 'Tutti i dati di stampa + file (3MF, miniature, foto)',\n      projects: 'Progetti, elementi BOM e allegati',\n      pendingUploads: 'Caricamenti della stampante virtuale in attesa di revisione',\n      apiKeys: 'Chiavi API webhook (nuove chiavi generate all\\'importazione)',\n    },\n    requiresPrinters: 'Richiede la selezione di Stampanti',\n    zipFileWarning: 'Verrà creato un file ZIP.',\n    zipFileDescription: 'Include tutti i file 3MF, miniature, timelapse e foto. Potrebbe richiedere tempo e produrre un file di grandi dimensioni.',\n    includeAccessCodes: 'Includi codici di accesso',\n    includeAccessCodesDescription: 'Per il trasferimento su un\\'altra macchina',\n    includeAccessCodesWarning: 'I codici di accesso saranno inclusi in testo semplice. Mantieni sicuro questo file di backup!',\n    categoriesSelected: '{{selectedCount}} categorie selezionate',\n  },\n\n  // Pending Uploads Panel\n  pendingUploads: {\n    placeholders: {\n      notes: 'Aggiungi note su questa stampa...',\n    },\n    discardUpload: 'Scarta caricamento',\n    archiveAllUploads: 'Archivia tutti i caricamenti',\n    discardAllUploads: 'Scarta tutti i caricamenti',\n    archive: 'Archivia',\n    timeAgo: {\n      justNow: 'Adesso',\n      minutesAgo: '{{minutes}} min fa',\n      hoursAgo: '{{hours}} ore fa',\n      daysAgo: '{{days}} giorni fa',\n    },\n  },\n\n  // API Browser\n  apiBrowser: {\n    placeholders: {\n      requestBody: 'Corpo della richiesta JSON...',\n      searchEndpoints: 'Cerca endpoint...',\n    },\n  },\n\n  // Configure AMS Slot Modal\n  configureAmsSlot: {\n    title: 'Configura Slot AMS',\n    slotConfigured: 'Slot configurato!',\n    configuringSlot: 'Configurazione slot:',\n    slotLabel: '{{ams}} Slot {{slot}}',\n    searchPresets: 'Cerca preset...',\n    colorPlaceholder: 'Nome colore o hex (es. marrone, FF8800)',\n    clearCustomColor: 'Cancella colore personalizzato',\n    noCloudPresets: 'Nessun preset cloud. Accedi a Bambu Cloud per sincronizzare.',\n    noPresetsAvailable: 'Nessun preset disponibile. Accedi a Bambu Cloud o importa profili locali.',\n    noMatchingPresets: 'Nessun preset corrispondente trovato.',\n    custom: 'Personalizzato',\n    builtin: 'Integrato',\n    settingsSentToPrinter: 'Impostazioni inviate alla stampante',\n    filamentProfile: 'Profilo filamento',\n    kProfileLabel: 'Profilo K (Pressure Advance)',\n    filteringFor: 'Filtrando per: {{material}}',\n    noKProfile: 'Nessun profilo K (usa predefinito 0.020)',\n    noMatchingKProfiles: 'Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.',\n    selectFilamentFirst: 'Seleziona prima un profilo filamento',\n    kFromCalibration: 'K={{value}} dalla calibrazione stampante',\n    customColorLabel: 'Colore personalizzato (opzionale)',\n    presetColors: 'Colori {{name}}:',\n    showLessColors: 'Mostra meno colori',\n    showMoreColors: 'Mostra più colori',\n    clear: 'Cancella',\n    hexLabel: 'Hex: #{{hex}}',\n    resetting: 'Ripristino...',\n    resetSlot: 'Ripristina slot',\n    cancel: 'Annulla',\n    configuring: 'Configurazione...',\n    configureSlot: 'Configura slot',\n  },\n\n  // GitHub Backup Settings\n  githubBackup: {\n    title: 'Backup GitHub',\n    history: 'Cronologia',\n    downloadBackup: 'Scarica backup',\n    restoreBackup: 'Ripristina backup',\n    noBackupsYet: 'Nessun backup ancora',\n  },\n\n  // Email Settings\n  emailSettings: {\n    placeholders: {\n      fromName: 'BamBuddy',\n    },\n  },\n\n  // Tag Management Modal\n  tagManagement: {\n    searchTags: 'Cerca tag...',\n    renameTag: 'Rinomina tag',\n    deleteTag: 'Elimina tag',\n  },\n\n  // Notification Template Editor\n  notificationTemplates: {\n    placeholders: {\n      title: 'Titolo notifica...',\n      body: 'Corpo notifica...',\n    },\n  },\n\n  // Batch Tag Modal\n  batchTag: {\n    placeholders: {\n      newTag: 'Inserisci nuovo tag...',\n    },\n  },\n\n  // Photo Gallery Modal\n  photoGallery: {\n    deletePhoto: 'Elimina foto',\n  },\n\n  // Filament Hover Card\n  filamentHoverCard: {\n    copySpoolUuid: 'Copia UUID bobina',\n  },\n\n  // K Profiles View\n  kProfilesView: {\n    hasNote: 'Ha una nota',\n    copyProfile: 'Copia profilo',\n  },\n\n  // Layout/Navigation\n  layout: {\n    openMenu: 'Apri menu',\n    noPermissionSystemInfo: 'Non hai il permesso di visualizzare le informazioni di sistema',\n  },\n\n  // Dashboard\n  dashboard: {\n    dragToReorder: 'Trascina per riordinare',\n    hideWidget: 'Nascondi widget',\n  },\n\n  // Notification Provider Card\n  notificationProviderCard: {\n    deleteNotificationProvider: 'Elimina provider di notifica',\n  },\n\n  // File Manager Modal\n  fileManagerModal: {\n    closeFileManager: 'Chiudi gestore file',\n    sortFiles: 'Ordina file',\n    goToParentFolder: 'Vai alla cartella superiore',\n    threeView: 'Vista 3D',\n  },\n\n  // Embedded Camera Viewer\n  embeddedCameraViewer: {\n    refreshStream: 'Aggiorna stream',\n    close: 'Chiudi',\n    zoomOut: 'Rimpicciolisci',\n    resetZoom: 'Reimposta zoom',\n    zoomIn: 'Ingrandisci',\n    dragToResize: 'Trascina per ridimensionare',\n  },\n\n  // Timelapse Viewer\n  timelapseViewer: {\n    skipBack5s: 'Indietro 5s',\n    skipForward5s: 'Avanti 5s',\n  },\n\n  // Notification Providers\n  notificationProviders: {\n    descriptions: {\n      email: 'Notifiche email via SMTP',\n      telegram: 'Notifiche tramite bot Telegram',\n      discord: 'Invia a canale Discord tramite webhook',\n      ntfy: 'Notifiche push gratuite e self-hostabili',\n      pushover: 'Notifiche push semplici e affidabili',\n      callmebot: 'Notifiche WhatsApp gratuite tramite CallMeBot',\n      webhook: 'POST HTTP generico a qualsiasi URL',\n    },\n  },\n\n  // Log Viewer\n  logViewer: {\n    searchPlaceholder: 'Cerca messaggio o nome logger...',\n    noLogEntries: 'Nessuna voce di log trovata',\n  },\n\n  // Switchbar Popover\n  switchbarPopover: {\n    noSwitchesInSwitchbar: 'Nessun interruttore nella barra',\n  },\n\n  // Project Page Modal\n  projectPageModal: {\n    placeholders: {\n      title: 'Titolo',\n      designer: 'Designer',\n      license: 'Licenza',\n      description: 'Inserisci descrizione...',\n      profileTitle: 'Titolo profilo',\n      profileDescription: 'Descrizione profilo...',\n    },\n  },\n\n  // Spoolman Settings\n  spoolmanSettings: {},\n\n  // Time\n  time: {\n    unknown: '-',\n    waiting: 'In attesa',\n    justNow: 'Proprio ora',\n    now: 'Ora',\n    minsAgo: '{{count}}m fa',\n    inMins: 'tra {{count}}m',\n    hoursAgo: '{{count}}h fa',\n    inHours: 'tra {{count}}h',\n    daysAgo: '{{count}}g fa',\n    inDays: 'tra {{count}}g',\n  },\n\n  // SpoolBuddy Kiosk\n  spoolbuddy: {\n    nav: {\n      dashboard: 'Dashboard',\n      ams: 'AMS',\n      inventory: 'Inventario',\n      writeTag: 'Scrivi',\n      settings: 'Impostazioni',\n    },\n    status: {\n      nfcReady: 'NFC pronto',\n      nfcOff: 'NFC spento',\n      offline: 'Offline',\n      online: 'Online',\n      noPrinters: 'Nessuna stampante',\n      deviceOffline: 'Dispositivo offline',\n      waitingConnection: 'In attesa della connessione...',\n      systemReady: 'Sistema pronto',\n      status: 'Stato',\n    },\n    dashboard: {\n      readyToScan: 'Pronto per la scansione',\n      idleMessage: 'Posiziona una bobina sulla bilancia per identificarla',\n      nfcHint: 'Il tag NFC verrà letto automaticamente',\n      device: 'Dispositivo',\n      syncWeight: 'Sincronizza peso',\n      weightSynced: 'Sincronizzato!',\n      unknownTag: 'Tag sconosciuto',\n      newTag: 'Nuovo tag rilevato',\n      onScale: 'sulla bilancia',\n      linkSpool: 'Collega a bobina',\n      linkTagTitle: 'Collega tag a bobina',\n      linkTag: 'Collega tag',\n      selectSpool: 'Seleziona una bobina da collegare a questo tag:',\n      noUntagged: 'Nessuna bobina senza tag trovata',\n      tagDetected: 'Tag rilevato',\n      noTag: 'Nessun tag',\n      tagId: 'Tag',\n      grossWeight: 'Peso lordo',\n      spoolSize: 'Dimensione bobina',\n      close: 'Chiudi',\n      currentSpool: 'Bobina attuale',\n    },\n    modal: {\n      spoolDetected: 'Bobina rilevata',\n      assignToAms: 'Assegna all\\'AMS',\n      syncWeight: 'Sincronizza peso',\n      weightSynced: 'Sincronizzato!',\n      syncing: 'Sincronizzazione...',\n      newTagDetected: 'Nuovo tag rilevato',\n      addToInventory: 'Aggiungi all\\'inventario',\n      assignToAmsTitle: 'Assegna all\\'AMS',\n      selectSlot: 'Seleziona uno slot',\n      assign: 'Assegna',\n      assigning: 'Assegnazione...',\n      assignSuccess: 'Assegnato!',\n      assignError: 'Impossibile assegnare la bobina. Riprovare.',\n      noPrinterSelected: 'Seleziona una stampante...',\n      noAmsDetected: 'Nessun AMS rilevato su questa stampante',\n      slot: 'Slot',\n    },\n    weight: {\n      noReading: 'Nessuna lettura',\n      stable: 'Stabile',\n      measuring: 'Misurazione...',\n      tare: 'Tara',\n      calibrate: 'Calibra',\n    },\n    spool: {\n      remaining: 'Rimanente',\n      material: 'Materiale',\n      brand: 'Marca',\n      color: 'Colore',\n      coreWeight: 'Nucleo',\n      labelWeight: 'Etichetta',\n      scaleWeight: 'Bilancia',\n      netWeight: 'Netto',\n      lastUsed: 'Ultimo utilizzo',\n    },\n    ams: {\n      noData: 'Nessun AMS rilevato',\n      connectAms: 'Collega un AMS per vedere gli slot',\n      noPrinter: 'Nessuna stampante selezionata',\n      selectPrinter: 'Seleziona una stampante dalla barra superiore',\n      printerDisconnected: 'Stampante disconnessa',\n      humidity: 'Umidità',\n      level: 'Livello',\n      active: 'Attivo',\n      slot: 'Slot',\n      empty: 'Vuoto',\n    },\n    inventory: {\n      search: 'Cerca bobine...',\n      empty: 'Nessuna bobina nell\\'inventario',\n      noResults: 'Nessuna bobina corrispondente',\n      spools: 'bobine',\n      addSpool: 'Aggiungi bobina',\n    },\n    settings: {\n      // Tabs\n      tabDevice: 'Dispositivo',\n      tabDisplay: 'Display',\n      tabScale: 'Bilancia',\n      tabUpdates: 'Aggiornamenti',\n      // Device tab\n      nfcReader: 'Lettore NFC',\n      type: 'Tipo',\n      connection: 'Connessione',\n      notConnected: 'N/A',\n      deviceInfo: 'Info dispositivo',\n      hostname: 'Host',\n      uptime: 'Tempo di attività',\n      // Display tab\n      brightness: 'Luminosità',\n      saved: 'Salvato',\n      noBacklight: 'Nessuna retroilluminazione DSI rilevata. Il controllo luminosità richiede un display DSI.',\n      screenBlank: 'Timeout spegnimento schermo',\n      screenBlankDesc: 'Lo schermo si spegne dopo inattività. Tocca per riattivare.',\n      displayNote: 'La luminosità viene applicata come filtro software.',\n      // Scale tab\n      scaleCalibration: 'Calibrazione bilancia',\n      currentWeight: 'Peso attuale',\n      tareOffset: 'Tara',\n      calFactor: 'Fattore',\n      knownWeight: 'Peso noto',\n      calStep1: 'Rimuovere tutto dalla bilancia e premere Imposta zero.',\n      calStep2: 'Posizionare il peso noto sulla bilancia.',\n      setZero: 'Imposta zero',\n      calibrateNow: 'Calibra',\n      calibrated: 'Calibrato',\n      tareSet: 'Comando tara inviato. In attesa del dispositivo...',\n      tareFailed: 'Invio comando tara fallito',\n      zeroSet: 'Punto zero impostato. Posizionare il peso noto sulla bilancia.',\n      calibrationDone: 'Calibrazione completata!',\n      calibrationFailed: 'Calibrazione fallita',\n      lastCalibrated: 'Ultima calibrazione',\n      stable: 'Stabile',\n      settling: 'Stabilizzazione...',\n      firmware: 'Firmware',\n      scale: 'Bilancia',\n      noDevice: 'Nessun dispositivo SpoolBuddy trovato',\n      // Updates tab\n      daemonVersion: 'Versione daemon',\n      currentVersion: 'Attuale',\n      versionPending: 'In attesa del daemon...',\n      checking: 'Verifica...',\n      checkUpdates: 'Verifica aggiornamenti',\n      updateAvailable: 'Aggiornamento disponibile',\n      updateInstructions: 'Aggiorna via SSH: esegui lo script di installazione SpoolBuddy.',\n      upToDate: 'Aggiornato',\n      includeBeta: 'Includi versioni beta',\n    },\n    writeTag: {\n      tabExisting: 'Bobina esistente',\n      tabNew: 'Nuova bobina',\n      tabReplace: 'Sostituisci tag',\n      searchPlaceholder: 'Cerca per materiale, colore, marca...',\n      noUntaggedSpools: 'Nessuna bobina senza tag',\n      noTaggedSpools: 'Nessuna bobina con tag',\n      selectSpool: 'Seleziona una bobina, poi posiziona un NTAG sul lettore',\n      placeTag: 'Posiziona un NTAG sul lettore',\n      tagReady: 'Tag rilevato — pronto per la scrittura',\n      writeTag: 'Scrivi tag',\n      replaceTag: 'Sostituisci tag',\n      writing: 'Scrittura tag...',\n      waiting: 'In attesa di SpoolBuddy...',\n      writeSuccess: 'Tag scritto con successo!',\n      writeFailed: 'Scrittura fallita',\n      queueFailed: 'Impossibile accodare il comando di scrittura',\n      tryAgain: 'Riprova',\n      cancel: 'Annulla',\n      replaceWarning: 'Il vecchio tag verrà scollegato. Il nuovo tag lo sostituirà.',\n      deviceOffline: 'SpoolBuddy è offline',\n      material: 'Materiale',\n      colorName: 'Nome colore',\n      color: 'Colore',\n      brand: 'Marca',\n      weight: 'Peso (g)',\n      createSpool: 'Crea bobina',\n      creating: 'Creazione...',\n      spoolCreated: 'Bobina creata! Pronto per la scrittura.',\n      createFailed: 'Impossibile creare la bobina',\n    },\n    quickMenu: {\n      printerPower: 'Alimentazione stampante',\n      systemControls: 'Sistema',\n      restartDaemon: 'Riavvia daemon',\n      restartBrowser: 'Riavvia browser',\n      reboot: 'Riavvia',\n      shutdown: 'Spegni',\n      swipeToClose: 'Scorri verso il basso per chiudere',\n      confirmTitle: 'Conferma',\n      confirmShutdown: 'Sei sicuro di voler spegnere lo SpoolBuddy? Avrai bisogno di accesso fisico per riaccenderlo.',\n      confirmReboot: 'Sei sicuro di voler riavviare lo SpoolBuddy?',\n      confirmRestartDaemon: 'Riavviare il daemon SpoolBuddy? NFC e bilancia saranno temporaneamente non disponibili.',\n      confirmRestartBrowser: 'Riavviare il browser kiosk? Lo schermo diventerà brevemente nero.',\n      confirm: 'Conferma',\n      confirmPlugOn: 'Accendere {{name}}?',\n      confirmPlugOff: 'Spegnere {{name}}?',\n      turnOn: 'Accendi',\n      turnOff: 'Spegni',\n    },\n  },\n\n  bugReport: {\n    title: 'Segnala un bug',\n    description: 'Descrizione',\n    descriptionPlaceholder: 'Cosa è andato storto? Descrivi il problema...',\n    email: 'Email (opzionale)',\n    emailPlaceholder: 'tua@email.it',\n    emailPrivacy: 'Se fornita, la tua email sarà inclusa in una sezione compressa dell\\'issue GitHub per permettere al manutentore di contattarti.',\n    screenshot: 'Screenshot',\n    uploadOrPaste: 'Carica, incolla o trascina un\\'immagine',\n    dataCollectedSummary: 'Quali dati sono inclusi nel report?',\n    dataIncluded: 'Inclusi:',\n    dataIncludedList: 'Versione app, OS, architettura, versione Python, statistiche database (solo conteggi), modelli stampante, numero ugelli, versioni firmware, stato connessione, stato integrazioni (Spoolman, MQTT, HA), impostazioni non sensibili, conteggio interfacce di rete, dettagli Docker, versioni dipendenze.',\n    dataNeverIncluded: 'Mai inclusi:',\n    dataNeverIncludedList: 'Nomi stampanti, numeri di serie, codici di accesso, password, indirizzi IP, indirizzi email, chiavi API, token, URL webhook, nomi host o nomi utente.',\n    submit: 'Invia',\n    startLogging: 'Avvia registrazione debug',\n    stepEnableLogging: 'Registrazione debug attivata',\n    stepReproduce: 'Riproduci il problema ora',\n    stepStopLogging: 'Ferma & invia rapporto',\n    stopAndSubmit: 'Ferma & Invia',\n    maxDuration: 'Arresto automatico dopo {{minutes}} min',\n    stoppingLogs: 'Raccolta log & invio...',\n    submitting: 'Invio segnalazione bug...',\n    submitSuccess: 'Segnalazione bug inviata con successo!',\n    submitFailed: 'Impossibile inviare la segnalazione bug',\n    thankYou: 'Grazie!',\n    submitted: 'La tua segnalazione bug è stata inviata.',\n    viewIssue: 'Vedi issue',\n    unexpectedError: 'Si è verificato un errore imprevisto',\n  },\n  failureDetection: {\n    title: 'Rilevamento guasti con IA',\n    description: 'Monitora le stampe tramite un\\'API ML Obico auto-ospitata e agisce automaticamente sui guasti rilevati.',\n    mlUrl: 'URL API ML Obico',\n    mlUrlHint: 'URL base del tuo container Obico ml_api auto-ospitato (es. http://192.168.1.10:3333).',\n    test: 'Prova',\n    testSuccess: 'API ML raggiungibile e funzionante.',\n    testFailed: 'Impossibile raggiungere l\\'API ML.',\n    sensitivity: 'Sensibilità',\n    sensitivityLow: 'Bassa (meno falsi positivi)',\n    sensitivityMedium: 'Media (bilanciata)',\n    sensitivityHigh: 'Alta (rilevamento precoce, più falsi positivi)',\n    sensitivityHint: 'Regola le soglie di confidenza che attivano avvisi e guasti.',\n    action: 'Azione al guasto rilevato',\n    actionNotify: 'Solo notifica',\n    actionPause: 'Metti in pausa',\n    actionPauseOff: 'Pausa e stacca corrente',\n    pollInterval: 'Intervallo di controllo (secondi)',\n    pollIntervalHint: 'Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.',\n    externalUrlMissing: 'External URL is not set.',\n    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',\n    perPrinterTitle: 'Stampanti monitorate',\n    perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.',\n    monitorAll: 'Monitora tutte le stampanti connesse',\n    statusTitle: 'Stato',\n    serviceRunning: 'Servizio in esecuzione',\n    thresholds: 'Soglie bassa / alta',\n    activePrinters: 'Stampe attive',\n    noActivePrints: 'Nessuna stampa in corso.',\n    historyTitle: 'Rilevamenti recenti',\n    noHistory: 'Nessun rilevamento finora.',\n  },\n};\n"
  },
  {
    "path": "frontend/src/i18n/locales/ja.ts",
    "content": "export default {\n  // Navigation\n  nav: {\n    printers: 'プリンター',\n    archives: 'アーカイブ',\n    queue: 'キュー',\n    stats: '統計',\n    profiles: 'プロファイル',\n    maintenance: 'メンテナンス',\n    projects: 'プロジェクト',\n    inventory: 'フィラメント',\n    files: 'ファイル管理',\n    notifications: '通知',\n    settings: '設定',\n    system: 'システム',\n    collapseSidebar: 'サイドバーを閉じる',\n    expandSidebar: 'サイドバーを開く',\n    update: 'アップデート',\n    updateAvailable: 'アップデートあり: v{{version}}',\n    updateAvailableBanner: 'バージョン {{version}} が利用可能です！',\n    viewUpdate: 'アップデートを表示',\n    viewOnGithub: 'GitHubで表示',\n    keyboardShortcuts: 'キーボードショートカット (?)',\n    switchToLight: 'ライトモードに切替',\n    switchToDark: 'ダークモードに切替',\n    smartSwitches: 'スマートスイッチ',\n    logout: 'ログアウト',\n  },\n\n  // Common\n  common: {\n    save: '保存',\n    saving: '保存中...',\n    cancel: 'キャンセル',\n    delete: '削除',\n    edit: '編集',\n    add: '追加',\n    close: '閉じる',\n    confirm: '確認',\n    loading: '読み込み中...',\n    error: 'エラー',\n    success: '成功',\n    warning: '警告',\n    enabled: '有効',\n    disabled: '無効',\n    yes: 'はい',\n    no: 'いいえ',\n    on: 'オン',\n    off: 'オフ',\n    all: 'すべて',\n    none: 'なし',\n    search: '検索',\n    filter: 'フィルター',\n    sort: '並べ替え',\n    refresh: '更新',\n    download: 'ダウンロード',\n    upload: 'アップロード',\n    uploading: 'アップロード中...',\n    uploadFailed: 'アップロード失敗',\n    actions: '操作',\n    status: 'ステータス',\n    name: '名前',\n    description: '説明',\n    date: '日付',\n    time: '時間',\n    hours: '時間',\n    minutes: '分',\n    seconds: '秒',\n    days: '日',\n    enable: '有効化',\n    disable: '無効にする',\n    permissions: '権限',\n    noPrinters: 'プリンターが登録されていません',\n    noData: 'データがありません',\n    linkNotFound: 'リンクが見つかりません',\n    required: '必須',\n    optional: 'オプション',\n    dismiss: '閉じる',\n    apply: '適用',\n    reset: 'リセット',\n    export: 'エクスポート',\n    import: 'インポート',\n    clear: 'クリア',\n    selectAll: 'すべて選択',\n    deselectAll: 'すべて選択解除',\n    noChange: '— 変更なし —',\n    unchanged: '変更なし',\n    unassigned: '未割当',\n    unknown: '不明',\n    unknownError: '不明なエラー',\n    today: '今日',\n    tomorrow: '明日',\n    asap: '即時',\n    overdue: '期限超過',\n    now: '今すぐ',\n    collapse: '折りたたむ',\n    expand: '展開',\n    viewArchive: 'アーカイブを表示',\n    viewInFileManager: 'ファイルマネージャーで表示',\n    addedBy: '{{username}}が追加',\n    prints: 'プリント',\n    more: 'もっと見る',\n    ascending: '昇順',\n    descending: '降順',\n    back: '戻る',\n    copy: 'コピー',\n    copied: 'コピーしました!',\n    printer: 'プリンター',\n    remove: '削除',\n    type: '種類',\n    print: '印刷',\n    rename: '名前変更',\n    move: '移動',\n    create: '作成',\n    duplicate: '複製',\n    left: '左',\n    right: '右',\n  },\n  // Printers page\n  printers: {\n    title: 'プリンター',\n    addPrinter: 'プリンターを追加',\n    editPrinter: 'プリンターを編集',\n    deletePrinter: 'プリンターを削除',\n    printerName: 'プリンター名',\n    serialNumber: 'シリアル番号',\n    ipAddress: 'IPアドレス / ホスト名',\n    accessCode: 'アクセスコード',\n    model: 'モデル',\n    nozzleCount: 'ノズル数',\n    autoArchive: '自動アーカイブ',\n    status: {\n      available: '利用可能',\n      idle: '待機中',\n      printing: '印刷中',\n      paused: '一時停止',\n      offline: 'オフライン',\n      problem: '問題',\n      error: 'エラー',\n      finished: '完了',\n      unknown: '不明',\n    },\n    temperatures: {\n      nozzle: 'ノズル',\n      bed: 'ベッド',\n      chamber: 'チャンバー',\n    },\n    progress: '{{percent}}% 完了',\n    timeRemaining: '残り {{time}}',\n    deleteConfirm: '「{{name}}」を削除しますか？',\n    maintenanceOk: 'メンテナンス正常',\n    maintenanceWarning: '{{count}}件の警告',\n    maintenanceWarning_plural: '{{count}}件の警告',\n    maintenanceDue: '{{count}}件のメンテナンス期限',\n    maintenanceDue_plural: '{{count}}件の期限',\n    // Sort options\n    sort: {\n      name: '名前',\n      status: 'ステータス',\n      model: 'モデル',\n      location: 'ロケーション',\n      ascending: '昇順で並べ替え',\n      descending: '降順で並べ替え',\n    },\n    // Card size\n    cardSize: {\n      small: '小',\n      medium: '中',\n      large: '大',\n      extraLarge: '特大',\n    },\n    // Controls\n    hideOffline: 'オフラインを非表示',\n    nextAvailable: '次に完了',\n    powerOn: '電源オン',\n    offlinePrintersWithPlugs: 'スマートプラグ付きオフラインプリンター',\n    noPrintersConfigured: 'プリンターが設定されていません',\n    search: 'プリンターを検索...',\n    noSearchResults: '検索またはフィルターに一致するプリンターがありません',\n    filter: {\n      allStatuses: 'すべてのステータス',\n      allLocations: 'すべての場所',\n    },\n    // Printer card\n    readyToPrint: '印刷可能',\n    external: '外部',\n    extL: 'Ext-L',\n    extR: 'Ext-R',\n    deleteArchives: '印刷アーカイブを削除',\n    noLabel: 'ラベルなし',\n    printPreview: '印刷プレビュー',\n    width: '幅',\n    height: '高さ',\n    noObjectsFound: 'オブジェクトが見つかりません',\n    objectsLoadedOnPrintStart: 'オブジェクトは印刷開始時に読み込まれます',\n    willBeSkipped: 'スキップされます',\n    name: '名前',\n    serialCannotBeChanged: 'シリアル番号は変更できません',\n    locationHelp: 'プリンターのグループ化とキュージョブのフィルタリングに使用',\n    // WiFi signal strength\n    wifiSignal: {\n      veryWeak: '非常に弱い',\n      weak: '弱い',\n      fair: '注意',\n      good: '良好',\n      excellent: '非常に良い',\n    },\n    // Maintenance\n    maintenanceUpToDate: 'すべてのメンテナンスが最新です',\n    // Chamber light\n    chamberLightOn: 'チャンバーライトをオンにしました',\n    chamberLightOff: 'チャンバーライトをオフにしました',\n    // Files\n    files: 'ファイル',\n    browseFiles: 'プリンターのファイルを参照',\n    // Smart plug\n    autoOffAfterPrint: '印刷後に自動電源オフ',\n    autoOffExecuted: '自動オフが実行されました - リセットするにはプリンターの電源を入れてください',\n    // HMS errors\n    hmsErrors: 'クリックしてHMSエラーを表示',\n    viewHmsErrors: '{{count}}件のHMSエラーを表示',\n    // Actions\n    resume: '再開',\n    pause: '一時停止',\n    stop: '停止',\n    camera: 'カメラ',\n    skipObject: 'オブジェクトスキップ',\n    reconnect: '再接続',\n    forceRefresh: '強制更新',\n    forceRefreshSuccess: '更新をリクエストしました',\n    mqttDebug: 'MQTTデバッグ',\n    printerInformation: 'プリンター情報',\n    copyToClipboard: 'コピー',\n    copied: 'コピーしました！',\n    state: '状態',\n    wifiSignalLabel: 'WiFi信号',\n    developerMode: '開発者モード',\n    enabled: '有効',\n    disabled: '無効',\n    addedOn: '追加日',\n    sdCard: 'SDカード',\n    inserted: '挿入済み',\n    notInserted: '未挿入',\n    totalPrintHours: '印刷時間',\n    activeNozzle: 'アクティブ: {{side}}ノズル',\n    nozzleRack: 'ノズルラック',\n    nozzleDocked: 'ドッキング中',\n    nozzleMounted: 'マウント中',\n    nozzleActive: 'アクティブ',\n    nozzleIdle: 'アイドル',\n    nozzleDiameter: '直径',\n    nozzleType: 'タイプ',\n    nozzleStatus: 'ステータス',\n    nozzleFilament: 'フィラメント',\n    nozzleWear: '摩耗',\n    nozzleMaxTemp: '最高温度',\n    nozzleSerial: 'シリアル',\n    nozzleHardenedSteel: '焼入れ鋼',\n    nozzleStainlessSteel: 'ステンレス鋼',\n    nozzleTungstenCarbide: 'タングステンカーバイド',\n    nozzleFlow: 'フロー',\n    nozzleHighFlow: 'ハイフロー',\n    nozzleStandardFlow: 'スタンダード',\n    // Firmware\n    firmwareUpdate: 'ファームウェアアップデート',\n    firmwareInstructions: 'プリンターのタッチスクリーンで',\n    firmwareNav: 'に移動',\n    settings: '設定',\n    firmware: 'ファームウェア',\n    // Discovery\n    discoverPrinters: 'プリンターを検出',\n    searching: '検索中...',\n    manualEntry: '手動入力',\n    addFromCloud: 'クラウドから追加',\n    // Toast messages\n    toast: {\n      printerDeleted: 'プリンターを削除しました',\n      missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',\n      printerAdded: 'プリンターを追加しました',\n      printerUpdated: 'プリンターを更新しました',\n      failedToDelete: 'プリンターの削除に失敗しました',\n      failedToAdd: 'プリンターの追加に失敗しました',\n      failedToUpdate: 'プリンターの更新に失敗しました',\n      commandSent: 'コマンドを送信しました',\n      failedToSendCommand: 'コマンドの送信に失敗しました',\n      turnedOn: '{{name}} の電源をオンにしました',\n      failedToPowerOn: '{{name}} の電源オンに失敗しました',\n      scriptTriggered: 'スクリプトを実行しました',\n      printStopped: '印刷を停止しました',\n      printPaused: '印刷を一時停止しました',\n      printResumed: '印刷を再開しました',\n      referenceDeleted: 'リファレンスを削除しました',\n      detectionAreaSaved: '検出エリアを保存しました',\n      failedToRunScript: 'スクリプトの実行に失敗しました',\n      failedToStopPrint: '印刷の停止に失敗しました',\n      failedToPausePrint: '印刷の一時停止に失敗しました',\n      failedToResumePrint: '印刷の再開に失敗しました',\n      failedToControlChamberLight: 'チャンバーライトの制御に失敗しました',\n      failedToSetSpeed: '印刷速度の設定に失敗しました',\n      failedToUpdateSetting: '設定の更新に失敗しました',\n      failedToSkipObjects: 'オブジェクトのスキップに失敗しました',\n      failedToRereadRfid: 'RFIDの再読み取りに失敗しました',\n      failedToCheckPlate: 'プレートの確認に失敗しました',\n      failedToUpdateLabel: 'ラベルの更新に失敗しました',\n      failedToDeleteReference: 'リファレンスの削除に失敗しました',\n      failedToSaveDetectionArea: '検出エリアの保存に失敗しました',\n      plateCheckEnabled: 'プレートチェックを有効にしました',\n      plateCheckDisabled: 'プレートチェックを無効にしました',\n      calibrationSaved: 'キャリブレーションを保存しました！',\n      calibrationFailed: 'キャリブレーションに失敗しました',\n      rfidRereadInitiated: 'RFID再読み取りを開始しました',\n    },\n    // Connection status\n    connection: {\n      connected: '接続中',\n      offline: 'オフライン',\n    },\n    plateStatus: {\n      markCleared: 'プレートをクリア済みにする',\n      cleared: 'プレートクリア済み',\n      notCleared: 'プレート未クリア',\n      inUse: 'プレート使用中',\n    },\n    // Queue info\n    queue: {\n      inQueue: 'キュー内',\n      inQueue_plural: '{{count}}件がキュー内',\n    },\n    // Controls section\n    controls: 'コントロール',\n    // RFID\n    rfid: {\n      reread: 'RFID再読み取り',\n    },\n    bedJog: {\n      title: 'ビルドプレートを移動',\n      bed: 'ベッド',\n      step: 'ステップ (mm)',\n      up: 'プレートを上へ',\n      down: 'プレートを下へ',\n      disabledWhilePrinting: '印刷中は無効',\n      notHomedTitle: 'プリンターがホーミングされていません',\n      notHomedMessage: '前回の印刷以降、プリンターがホーミングされていません。安全な位置決めのためにまずオートホーミングを実行するか（ツールヘッドをパークしてからX・Y・Zをホーミングします）、このまま移動してください — ソフトエンドストップはバイパスされます。',\n      homeZ: 'オートホーミング',\n      moveAnyway: 'このまま移動',\n      homingStarted: 'プリンターをオートホーミング中…',\n    },\n    // Permissions\n    permission: {\n      noAdd: 'プリンターを追加する権限がありません',\n      noEdit: 'プリンターを編集する権限がありません',\n      noDelete: 'プリンターを削除する権限がありません',\n      noControl: 'プリンターを制御する権限がありません',\n      noFiles: 'このディレクトリにファイルがありません',\n      noAmsRfid: 'AMS RFIDを再読み取りする権限がありません',\n      noSmartPlugControl: 'スマートプラグを制御する権限がありません',\n      noCamera: 'カメラを表示する権限がありません',\n    },\n    // Add/Edit modal\n    modal: {\n      addTitle: 'プリンターを追加',\n      editTitle: 'プリンターを編集',\n      myPrinter: 'マイプリンター',\n      selectModel: 'モデルを選択...',\n      locationGroup: 'ロケーション / グループ',\n      locationPlaceholder: '例: 工房、オフィス、地下室',\n      autoArchiveLabel: '完了した印刷を自動アーカイブ',\n      fromPrinterSettings: 'プリンターの設定から取得',\n      modelOptional: 'モデル（任意）',\n      saveChanges: '変更を保存',\n    },\n    // Skip objects\n    skipObjects: {\n      tooltip: 'オブジェクトスキップ',\n      onlyWhilePrinting: 'オブジェクトスキップ（印刷中のみ）',\n      requiresMultiple: 'オブジェクトスキップ（2個以上必要）',\n      title: 'オブジェクトスキップ',\n      matchIdsInfo: 'プリンター画面のIDと照合してください',\n      printerShowsIds: 'プリンター画面にビルドプレート上のオブジェクトIDが表示されます',\n      skipSelected: '選択をスキップ',\n      skipping: 'スキップ中...',\n      noObjectsSelected: 'オブジェクトが選択されていません',\n      selectObjectsToSkip: '現在の印刷からスキップするオブジェクトを選択してください',\n      skipped: 'スキップ済み',\n      objectsSkipped: 'オブジェクトをスキップしました',\n      activeCount: '{{count}}個アクティブ',\n      waitForLayer: 'オブジェクトをスキップするにはレイヤー2以降をお待ちください（現在レイヤー{{layer}}）',\n      skip: 'スキップ',\n      confirmTitle: 'オブジェクトをスキップしますか？',\n      confirmMessage: '「{{name}}」をスキップしますか？この操作は元に戻せません。',\n    },\n    // Confirm modals\n    confirm: {\n      deleteTitle: 'プリンターを削除',\n      deleteMessage: '「{{name}}」を削除しますか？すべての接続設定が削除されます。',\n      deleteArchivesNote: 'このプリンターのすべての印刷履歴が完全に削除されます。',\n      keepArchivesNote: '印刷履歴は保持されますが、このプリンターとの関連は解除されます。',\n      stopTitle: '印刷を停止',\n      stopMessage: '「{{name}}」の現在の印刷を停止しますか？印刷ジョブがキャンセルされます。',\n      stopButton: '印刷を停止',\n      pauseTitle: '印刷を一時停止',\n      pauseMessage: '「{{name}}」の現在の印刷を一時停止しますか？',\n      pauseButton: '印刷を一時停止',\n      resumeTitle: '印刷を再開',\n      resumeMessage: '「{{name}}」の印刷を再開しますか？',\n      resumeButton: '印刷を再開',\n      powerOnTitle: 'プリンターの電源をオン',\n      powerOnMessage: '「{{name}}」の電源をオンにしますか？',\n      powerOnButton: '電源オン',\n      powerOffTitle: 'プリンターの電源をオフ',\n      powerOffMessage: '「{{name}}」の電源をオフにしますか？',\n      powerOffWarning: '警告: 「{{name}}」は現在印刷中です！電源をオフにしますか？印刷が中断され、プリンターが損傷する可能性があります。',\n      powerOffButton: '電源オフ',\n    },\n    // Bulk actions\n    bulk: {\n      select: '選択',\n      selectAll: 'すべて選択',\n      selectByLocation: '場所で選択',\n      selected: '{{count}}件選択中',\n      actions: {\n        stop: '停止',\n        pause: '一時停止',\n        resume: '再開',\n        clearPlate: 'ベッドをクリア',\n        clearHMS: '通知をクリア',\n      },\n      confirm: {\n        stopTitle: '{{count}}件の印刷を停止',\n        stopMessage: '{{count}}台のプリンターのアクティブな印刷をキャンセルします。この操作は元に戻せません。',\n        stopButton: 'すべて停止',\n        pauseTitle: '{{count}}件の印刷を一時停止',\n        pauseMessage: '{{count}}台のプリンターのアクティブな印刷を一時停止します。',\n        pauseButton: 'すべて一時停止',\n        clearPlateTitle: '{{count}}台のプリントベッドをクリア',\n        clearPlateMessage: '{{count}}台のプリンターのプリントベッドをクリアし、キュー内のジョブが開始される場合があります。',\n        clearPlateButton: 'すべてクリア',\n      },\n      success: '{{count}}台のプリンターで{{action}}が完了',\n      partial: '{{succeeded}}件成功、{{failed}}件失敗',\n      noneApplicable: '選択したプリンターにこのアクションに適した状態のものがありません',\n      selectByState: 'ステータスで選択',\n    },\n    // Discovery\n    discovery: {\n      title: 'プリンター',\n      searching: '検索中...',\n      scanning: 'スキャン中...',\n      scanProgress: 'スキャン中... {{scanned}}/{{total}}',\n      foundPrinters: '{{count}}台のプリンターを検出',\n      noPrintersFound: 'プリンターが見つかりません',\n      noPrintersFoundSubnet: '指定されたサブネットにプリンターが見つかりません。',\n      noPrintersFoundNetwork: 'ネットワーク上にプリンターが見つかりません。',\n      allConfigured: '検出されたすべてのプリンターは既に設定済みです。',\n      alreadyAdded: '追加済み',\n      select: '選択',\n      manualEntry: '手動入力',\n      addFromCloud: 'クラウドから追加',\n      subnetToScan: 'スキャンするサブネット',\n      dockerNote: 'Dockerを検出しました。プリンターのサブネットをCIDR表記で入力してください。docker-compose.ymlでnetwork_mode: hostが必要です。',\n      scanSubnet: 'サブネットをスキャンしてプリンターを検出',\n      discoverNetwork: 'ネットワーク上のプリンターを検出',\n      scanningSubnet: 'サブネットでBambuプリンターをスキャン中...',\n      scanningNetwork: 'ネットワークをスキャン中...',\n      serialRequired: 'シリアル番号が必要です',\n      unknown: '不明',\n      failedToStart: '印刷の開始に失敗しました',\n    },\n    // AMS Drying\n    drying: {\n      start: '乾燥開始',\n      stop: '乾燥停止',\n      temperature: '温度',\n      duration: '時間',\n      hours: '時間',\n      timeRemaining: '残り {{time}}',\n      active: '乾燥中',\n      notSupported: '乾燥非対応',\n      powerRequired: 'AMS電源アダプターを接続して乾燥を有効にしてください',\n      startingDrying: '乾燥を開始しています...',\n      stoppingDrying: '乾燥を停止しています...',\n      rotateTray: '乾燥中にスプールを回転',\n    },\n    // Filaments section\n    filaments: 'フィラメント',\n    // Camera\n    openCameraOverlay: 'カメラオーバーレイを開く',\n    openCameraWindow: 'カメラを新しいウィンドウで開く',\n    // Firmware\n    firmwareUpdateAvailable: 'ファームウェアアップデートあり: {{current}} → {{latest}}',\n    firmwareUpToDate: 'ファームウェア {{version}} — 最新',\n    firmwareUpdateButton: 'アップデート',\n    // Plate detection\n    plateDetection: {\n      noPermission: 'このページにアクセスする権限がありません。',\n      enabledClick: 'プレートチェック有効 - クリックして無効化',\n      disabledClick: 'プレートチェック無効 - クリックして有効化',\n      manageCalibration: 'プレート検出キャリブレーションを管理',\n      calibrationRequired: 'キャリブレーションが必要です',\n      calibrationInstructions: 'ビルドプレートが<strong>完全に空</strong>であることを確認してから、キャリブレーションをクリックしてください。',\n      calibrationDescription: 'キャリブレーションは空のプレートのリファレンス画像を撮影します。以降のチェックではこのリファレンスと比較してオブジェクトを検出します。',\n      calibrationTip: '<strong>ヒント:</strong> 異なるプレート用に最大5つのキャリブレーションを保存できます。チェック時に最適なものが自動的に使用されます。',\n      plateEmpty: 'プレートは空のようです',\n      objectsDetected: 'オブジェクトを検出しました',\n      confidence: '信頼度',\n      difference: '差分',\n      analysisPreview: '分析プレビュー:',\n      analysisLegend: '緑の枠 = 検出エリア、赤のオーバーレイ = キャリブレーションとの差分',\n      savedReferences: '保存済みリファレンス ({{count}}/{{max}})',\n      deleteReference: 'リファレンスを削除',\n      labelPlaceholder: 'ラベル...',\n      clickToEdit: '{{label}} - クリックして編集',\n      clickToAddLabel: 'クリックしてラベルを追加',\n    },\n    // Speed\n    speed: {\n      title: '印刷速度',\n      silent: 'サイレント (50%)',\n      standard: 'スタンダード (100%)',\n      sport: 'スポーツ (124%)',\n      ludicrous: 'ルディクラス (166%)',\n    },\n    airduct: {\n      title: 'エアダクトモード',\n      cooling: '冷却',\n      heating: '加熱',\n    },\n    noSdCard: 'SDなし',\n    door: {\n      open: '開',\n      closed: '閉',\n    },\n    // Fans\n    fans: {\n      partCooling: 'パーツ冷却ファン',\n      auxiliary: '補助ファン',\n      chamber: 'チャンバーファン',\n    },\n    // HMS errors\n    clickToViewHmsErrors: 'クリックしてHMSエラーを表示',\n    estimatedCompletion: '完了予定時刻',\n    plateNumber: 'プレート {{number}}',\n    slotOptions: 'スロットオプション',\n    // AMS hover popup\n    amsPopup: {\n      friendlyName: 'AMS名',\n      friendlyNamePlaceholder: '例: AMS フレンドリー名',\n      serialNumber: 'シリアル番号',\n      firmwareVersion: 'ファームウェア',\n      save: '保存',\n      clear: 'クリア',\n      noEditPermission: 'AMS ユニットの名前を変更する権限がありません',\n    },\n    // Firmware modal\n    firmwareModal: {\n      title: 'ファームウェアアップデート',\n      titleUpToDate: 'ファームウェア情報',\n      currentVersion: '現在のバージョン',\n      latestVersion: '最新バージョン',\n      releaseNotes: 'リリースノート',\n      checkingPrereqs: '前提条件を確認中...',\n      sdCardReady: 'SDカード準備完了。下をクリックしてファームウェアをアップロードしてください。',\n      uploadedSuccess: 'ファームウェアをSDカードにアップロードしました！',\n      applyInstructions: 'プリンターでアップデートを適用するには:',\n      step1: 'プリンターのタッチスクリーンで設定に移動',\n      step2: 'ファームウェアに移動',\n      step3: 'SDカードからアップデートを選択',\n      step4: 'アップデートには10〜20分かかります',\n      done: '完了',\n      starting: '開始中...',\n      uploadFirmware: 'ファームウェアをアップロード',\n      uploadFailed: 'アップロード開始に失敗しました: {{error}}',\n      uploadedToast: 'ファームウェアをアップロードしました！プリンター画面からアップデートを実行してください。',\n    },\n    accessCodePlaceholder: 'プリンター設定から取得',\n    // ROI editor\n    roi: {\n      title: 'プリンター',\n      xStart: 'X開始',\n      yStart: 'Y開始',\n      width: '幅',\n      height: '高さ',\n      instruction: 'ビルドプレートに焦点を合わせるように検出エリアを調整してください。プレビューの緑の枠が現在のエリアを示しています。',\n    },\n    developerModeWarning: '開発者LANモードが有効になっていません: {{names}}。一部の機能が動作しない可能性があります。',\n    howToEnable: '有効化方法',\n    incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',\n    dropNotPrintable: '.gcodeおよび.gcode.3mfファイルのみ印刷できます',\n    dropToPrint: 'ドロップして印刷',\n    cannotPrint: 'プリンター使用中',\n  },\n\n  // Archives page\n  archives: {\n    title: '印刷アーカイブ',\n    searchPlaceholder: 'アーカイブを検索...',\n    filterByPrinter: 'プリンターで絞り込み',\n    filterByStatus: 'ステータスで絞り込み',\n    sortBy: '並べ替え',\n    sortNewest: '新しい順',\n    sortOldest: '古い順',\n    sortName: '名前順',\n    sortDuration: '時間順',\n    sortLargest: '大きい順',\n    sortSmallest: '小さい順',\n    sortSize: 'サイズ',\n    noArchives: 'アーカイブが見つかりません',\n    noArchivesSearch: '検索条件に一致するアーカイブがありません',\n    originalPrintNotVisible: '元の印刷が表示されていません - フィルターをクリアしてみてください',\n    noArchivesYet: 'アーカイブはまだありません',\n    prints: '件',\n    pagination: {\n      showing: '表示中',\n      to: '〜',\n      of: '/',\n      show: '表示',\n      page: 'ページ',\n      all: 'すべて',\n    },\n    loadingArchives: 'アーカイブを読み込み中...',\n    releaseToUpload: 'ドロップしてアップロード',\n    showAll: 'すべて表示',\n    showFavoritesOnly: 'お気に入りのみ表示',\n    gridView: 'グリッド表示',\n    listView: 'リスト表示',\n    calendarView: 'カレンダー表示',\n    logView: '印刷ログ',\n    manageTags: 'タグを管理',\n    showFailedPrints: '失敗した印刷を表示',\n    hideFailedPrints: '失敗した印刷を非表示',\n    hideDuplicates: '重複を非表示',\n    viewOriginalPrint: 'クリックして元の印刷を表示 (#{{id}})',\n    printTime: '印刷時間',\n    filamentUsed: 'フィラメント使用量',\n    cost: 'コスト',\n    reprint: '再印刷',\n    preview: 'プレビュー',\n    deleteArchive: 'アーカイブを削除',\n    deleteConfirm: 'このアーカイブを削除しますか？',\n    favorite: 'お気に入り',\n    unfavorite: 'お気に入りから削除',\n    viewDetails: '詳細を表示',\n    status: {\n      completed: '完了',\n      failed: '失敗',\n      stopped: '中止',\n    },\n    toast: {\n      source3mfAttached: 'ソース3MFを添付しました: {{filename}}',\n      failedUploadSource3mf: 'ソース3MFのアップロードに失敗しました',\n      source3mfRemoved: 'ソース3MFを削除しました',\n      failedRemoveSource3mf: 'ソース3MFの削除に失敗しました',\n      f3dAttached: 'F3Dを添付しました: {{filename}}',\n      failedUploadF3d: 'F3Dのアップロードに失敗しました',\n      f3dRemoved: 'F3Dを削除しました',\n      failedRemoveF3d: 'F3Dの削除に失敗しました',\n      timelapseAttached: 'タイムラプスを添付しました: {{filename}}',\n      timelapseAlreadyAttached: 'タイムラプスは既に添付されています',\n      noMatchingTimelapse: '一致するタイムラプスが見つかりません',\n      failedScanTimelapse: 'タイムラプスのスキャンに失敗しました',\n      failedAttachTimelapse: 'タイムラプスの添付に失敗しました',\n      timelapseRemoved: 'タイムラプスを削除しました',\n      failedRemoveTimelapse: 'タイムラプスの削除に失敗しました',\n      timelapseUploaded: 'タイムラプスをアップロードしました: {{filename}}',\n      failedUploadTimelapse: 'タイムラプスのアップロードに失敗しました',\n      archiveDeleted: 'アーカイブを削除しました',\n      failedDeleteArchive: 'アーカイブの削除に失敗しました',\n      addedToFavorites: 'お気に入りに追加しました',\n      removedFromFavorites: 'お気に入りから削除しました',\n      projectUpdated: 'プロジェクトを更新しました',\n      failedUpdateProject: 'プロジェクトの更新に失敗しました',\n      linkCopied: 'リンクをクリップボードにコピーしました',\n      failedCopyLink: 'リンクのコピーに失敗しました',\n      photoDeleted: '写真を削除しました',\n      failedDeletePhoto: '写真の削除に失敗しました',\n      failedDeleteArchives: 'アーカイブの削除に失敗しました',\n      failedUpdateFavorites: 'お気に入りの更新に失敗しました',\n      exportDownloaded: 'エクスポートをダウンロードしました',\n      exportFailed: 'エクスポートに失敗しました',\n    },\n    menu: {\n      print: '印刷',\n      schedule: 'スケジュール',\n      openInBambuStudio: 'スライサーで開く',\n      slice: 'スライス',\n      externalLink: '外部リンク',\n      viewOnMakerWorld: 'MakerWorldで表示',\n      preview3d: '3Dプレビュー',\n      viewTimelapse: 'タイムラプスを表示',\n      scanForTimelapse: 'タイムラプスをスキャン',\n      uploadTimelapse: 'タイムラプスをアップロード',\n      removeTimelapse: 'タイムラプスを削除',\n      downloadSource3mf: 'ソース3MFをダウンロード',\n      uploadSource3mf: 'ソース3MFをアップロード',\n      replaceSource3mf: 'ソース3MFを置換',\n      removeSource3mf: 'ソース3MFを削除',\n      uploadF3d: 'F3Dをアップロード',\n      replaceF3d: 'F3Dを置換',\n      downloadF3d: 'Fusion 360デザインファイルをダウンロード',\n      removeF3d: 'F3Dを削除',\n      download: 'ダウンロード',\n      copyDownloadLink: 'ダウンロードリンクをコピー',\n      qrCode: 'QRコード',\n      viewPhotos: '{{count}}枚の写真を表示',\n      viewPhotosCount: '写真を表示 ({{count}})',\n      projectPage: 'プロジェクトページ',\n      addToFavorites: 'お気に入りに追加',\n      removeFromFavorites: 'お気に入りから削除',\n      edit: '編集',\n      goToProject: 'プロジェクトへ: {{name}}',\n      addToProject: 'プロジェクトに追加',\n      removeFromProject: 'プロジェクトから削除',\n      loading: 'アーカイブを読み込み中...',\n      noProjectsAvailable: '利用可能なプロジェクトがありません',\n      select: '選択',\n      deselect: '選択解除',\n      delete: '削除',\n    },\n    permission: {\n      noReprint: 'このアーカイブを再印刷する権限がありません',\n      noAddToQueue: 'キューに追加する権限がありません',\n      noUpdateArchives: 'アーカイブを更新する権限がありません',\n      noUploadFiles: 'ファイルをアップロードする権限がありません',\n      noDownload: 'アーカイブをダウンロードする権限がありません',\n      noCopyLink: 'ダウンロードリンクをコピーする権限がありません',\n      noDelete: 'このアーカイブを削除する権限がありません',\n      noCreate: 'アーカイブを作成する権限がありません',\n    },\n    card: {\n      previousPlate: '前のプレート',\n      nextPlate: '次のプレート',\n      plateNumber: 'プレート {{number}}',\n      moreOptions: 'その他のオプション',\n      addToFavorites: 'お気に入りに追加',\n      removeFromFavorites: 'お気に入りから削除',\n      cancelled: 'キャンセル',\n      failed: '失敗',\n      duplicate: '重複',\n      duplicateTitle: 'このモデルは以前印刷されています',\n      openSource3mf: 'ソース3MFをBambu Studioで開く（右クリックでオプション表示）',\n      downloadF3d: 'Fusion 360デザインファイルをダウンロード',\n      viewTimelapse: 'タイムラプスを表示',\n      viewPhoto: '写真を表示',\n      viewPhotos: '{{count}}枚の写真を表示',\n      openFolder: 'フォルダーを開く: {{name}}',\n      slicedFile: 'スライス済みファイル - 印刷可能',\n      sourceFile: 'ソースファイルのみ - AMSマッピング不可',\n      gcode: 'GCODE',\n      source: 'ソース',\n      project: 'プロジェクト',\n      estimated: '推定: {{time}}',\n      actual: '実際: {{time}}',\n      accuracy: '精度: {{percent}}%',\n      filament: '{{weight}}g',\n      layer: 'レイヤー',\n      layers: 'レイヤー',\n      object: '{{count}}オブジェクト',\n      objects: '{{count}}オブジェクト',\n      slicedFor: '{{model}}用にスライス',\n      uploadedBy: 'アップロード者',\n      noPermissionReprint: '再印刷する権限がありません',\n      noFileForReprint: '3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした',\n      noPermissionEdit: 'プロファイルを編集する権限がありません',\n      noPermissionDelete: 'アーカイブを削除する権限がありません',\n      reprint: '再印刷',\n      schedulePrint: '印刷をスケジュール',\n      schedule: 'スケジュール',\n      openInBambuStudio: 'スライサーで開く',\n      openInBambuStudioToSlice: 'スライサーでスライス',\n      slice: 'スライス',\n      externalLink: '外部リンク',\n      makerWorld: 'MakerWorld: {{designer}}',\n      viewProject: 'プロジェクトを表示',\n      noExternalLink: '外部リンクなし',\n      preview3d: '3Dプレビュー',\n      download: 'ダウンロード',\n      edit: '編集',\n      delete: '削除',\n    },\n    modal: {\n      deleteArchive: 'アーカイブを削除',\n      deleteConfirm: 'このアーカイブを削除しますか？',\n      deleteButton: '削除',\n      removeSource3mf: 'ソース3MFを削除',\n      removeSource3mfConfirm: '\"{{name}}\"からソース3MFファイルを削除してもよろしいですか？元のスライサープロジェクトファイルが削除されます。',\n      removeButton: '削除',\n      removeF3d: 'F3Dを削除',\n      removeF3dConfirm: '\"{{name}}\"からFusion 360デザインファイルを削除してもよろしいですか？',\n      removeTimelapse: 'タイムラプスを削除',\n      removeTimelapseConfirm: '\"{{name}}\"からタイムラプス動画を削除してもよろしいですか？',\n      timelapse: '{{name}} - タイムラプス',\n      selectTimelapse: 'タイムラプスを選択',\n      selectTimelapseDesc: '自動一致が見つかりませんでした。この印刷のタイムラプスを選択してください:',\n      deleteArchives: '印刷アーカイブを削除',\n      deleteArchivesConfirm: '{{count}}件のアーカイブを削除しますか？この操作は元に戻せません。',\n      deleteCount: '{{count}}件を削除',\n    },\n    page: {\n      title: '印刷アーカイブ',\n      printsCount: '{{count}}回印刷',\n      dropFilesHere: '.3mfファイルをここにドロップ',\n      releaseToUpload: 'ドロップしてアップロード',\n      only3mfSupported: '.3mfファイルのみ対応しています',\n      close: '閉じる',\n      selected: '{{count}}件選択中',\n      selectAll: 'すべて選択',\n      tags: 'タグ',\n      project: 'プロジェクト',\n      favorite: 'お気に入り',\n      delete: '削除',\n      toggledFavorites: '{{count}}件のアーカイブのお気に入りを切替えました',\n      failedUpdateFavorites: 'お気に入りの更新に失敗しました',\n      archivesDeleted: '{{count}}件のアーカイブを削除しました',\n      failedDeleteArchives: 'アーカイブの削除に失敗しました',\n      photoDeleted: '写真を削除しました',\n      failedDeletePhoto: '写真の削除に失敗しました',\n    },\n    list: {\n      name: '名前',\n      printer: 'プリンター',\n      date: '日付',\n      size: 'サイズ',\n      actions: '操作',\n      hasTimelapse: 'タイムラプスあり',\n    },\n    log: {\n      date: '日時',\n      printName: '印刷名',\n      printer: 'プリンター',\n      user: 'ユーザー',\n      status: 'ステータス',\n      duration: '所要時間',\n      filament: 'フィラメント',\n      allPrinters: '全プリンター',\n      allUsers: '全ユーザー',\n      allStatuses: '全ステータス',\n      cancelled: 'キャンセル',\n      skipped: 'スキップ',\n      dateFrom: '開始日',\n      dateTo: '終了日',\n      noEntries: '印刷ログが見つかりません',\n      showing: '{{total}}件中{{count}}件を表示',\n      rowsPerPage: '行数',\n      page: 'ページ',\n      prev: '前へ',\n      next: '次へ',\n      clearLog: 'ログをクリア',\n      clearLogTitle: '印刷ログをクリア',\n      clearLogConfirm: 'すべての印刷ログエントリが完全に削除されます。アーカイブとキューアイテムには影響しません。この操作は元に戻せません。よろしいですか？',\n      clearLogButton: 'すべてクリア',\n      cleared: '{{count}}件のログエントリを削除しました',\n      clearFailed: '印刷ログの削除に失敗しました',\n    },\n  },\n\n  // Queue page\n  queue: {\n    title: '印刷キュー',\n    subtitle: '印刷ジョブのスケジュールと管理',\n    addToQueue: 'キューに追加',\n    // Print modal\n    print: '印刷',\n    reprint: '再印刷',\n    schedulePrint: '印刷をスケジュール',\n    editQueueItem: 'キューアイテムを編集',\n    printToPrinters: '{{count}}台のプリンターで印刷',\n    queueToPrinters: '{{count}}台のプリンターでキュー追加',\n    queueSelectedPlates: '{{count}}プレートをキューに追加',\n    selectAllPlates: '全{{count}}プレートを選択',\n    deselectAll: '全て解除',\n    printQueued: 'キューに追加しました',\n    itemsQueued: '{{count}}件をキューに追加しました',\n    sending: '送信中...',\n    sendingProgress: '送信中 {{current}}/{{total}}...',\n    adding: '追加中...',\n    addingProgress: '追加中 {{current}}/{{total}}...',\n    savingProgress: '保存中 {{current}}/{{total}}...',\n    clearQueue: 'キューをクリア',\n    clearHistory: '履歴をクリア',\n    emptyQueue: 'キューは空です',\n    position: '順番',\n    scheduledTime: '予定時刻',\n    moveUp: '上に移動',\n    moveDown: '下に移動',\n    startNow: '今すぐ開始',\n    printingInProgress: '印刷中...',\n    viewArchive: 'アーカイブを表示',\n    viewInFileManager: 'ファイルマネージャーで表示',\n    itemCount: '{{count}}件',\n    itemCount_plural: '{{count}}件のアイテム',\n    dragToReorder: 'ドラッグして並べ替え（ASAPのみ）',\n    reorderHint: '順番はASAPアイテムのみに影響します。スケジュール済みアイテムは設定時刻に実行されます。',\n    sjf: {\n      label: 'SJF',\n      tooltip: '短いジョブ優先 — スケジューラーが短い印刷を優先します',\n    },\n    addedBy: '{{username}}が追加',\n    nextInQueue: '次のキュー',\n    clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',\n    plateNumber: 'プレート {{index}}',\n    // Batch / quantity\n    quantity: '数量',\n    quantityHint: '{{count}}件のキューアイテムを作成',\n    activeBatches: 'アクティブなバッチ',\n    batchProgress: '{{total}}件中{{completed}}件完了',\n    cancelBatch: '残りをキャンセル',\n    batchCancelled: '残りのバッチアイテムをキャンセルしました',\n    cancelBatchConfirmTitle: 'バッチをキャンセル',\n    cancelBatchConfirmMessage: 'このバッチの残りの保留中アイテムをすべてキャンセルしますか？',\n    batch: 'バッチ',\n    // Sections\n    sections: {\n      currentlyPrinting: '印刷中',\n      queued: 'キュー中',\n      history: '履歴',\n    },\n    // Status\n    status: {\n      pending: '待機中',\n      waiting: '待機中',\n      printing: '印刷中',\n      paused: '一時停止',\n      completed: '完了',\n      failed: '失敗',\n      skipped: 'スキップ',\n      cancelled: 'キャンセル済み',\n    },\n    // Summary cards\n    summary: {\n      printing: '印刷中',\n      queued: 'キュー中',\n      totalTime: 'キュー合計時間',\n      totalWeight: 'キュー合計重量',\n      history: '履歴',\n    },\n    // Filters\n    filter: {\n      allPrinters: 'すべてのプリンター',\n      unassigned: '未割当',\n      allStatus: 'すべてのステータス',\n      allLocations: 'すべてのロケーション',\n      any: 'すべて',\n    },\n    // Sort\n    sort: {\n      byPosition: '順番で並べ替え',\n      byName: '名前で並べ替え',\n      byPrinter: 'プリンターで並べ替え',\n      bySchedule: 'スケジュールで並べ替え',\n      byDate: '日付で並べ替え',\n      ascendingOldest: '昇順（古い順）',\n      descendingNewest: '降順（新しい順）',\n    },\n    // Badges\n    badges: {\n      staged: 'ステージ済み',\n      requiresPrevious: '前の成功が必要',\n      autoPowerOff: '自動電源オフ',\n      gcodeInjection: 'G-code',\n    },\n    // Empty state\n    empty: {\n      title: 'スケジュールされた印刷はありません',\n      description: 'アーカイブページのコンテキストメニューから「スケジュール」オプションを使用するか、ファイルをドラッグ＆ドロップして始めましょう。',\n    },\n    // Time\n    time: {\n      asap: '即時',\n      overdue: '期限超過',\n      now: '今すぐ',\n      lessThanMinute: '1分以内',\n      inMinutes: '{{count}}分後',\n      inHours: '{{count}}時間後',\n    },\n    // Actions\n    actions: {\n      stopPrint: '印刷を停止',\n      startPrint: '印刷を開始',\n      requeue: '再キュー',\n    },\n    // Bulk edit\n    bulkEdit: {\n      title: '{{count}}件のアイテムを編集',\n      title_plural: '{{count}}件のアイテムを編集',\n      description: '変更した設定のみが選択されたアイテムに適用されます。',\n      printer: 'プリンター',\n      noChange: '— 変更なし —',\n      queueOptions: 'キューオプション',\n      staged: 'ステージ済み',\n      autoPowerOff: '印刷後に自動電源オフ',\n      requirePrevious: '前の成功を必要とする',\n      printOptions: '印刷オプション',\n      bedLevelling: 'ベッドレベリング',\n      flowCalibration: 'フローキャリブレーション',\n      vibrationCalibration: '振動キャリブレーション',\n      layerInspection: '第一層検査',\n      timelapse: 'タイムラプス',\n      useAms: 'AMS使用',\n      applyChanges: '変更を適用',\n      selectAll: 'すべて選択',\n      deselectAll: 'すべて選択解除',\n      selected: '{{count}}件選択中',\n      editSelected: '選択を編集',\n      cancelSelected: '選択をキャンセル',\n    },\n    // Confirmations\n    confirm: {\n      cancelTitle: 'スケジュール済み印刷をキャンセル',\n      cancelMessage: '「{{name}}」をキャンセルしますか？',\n      stopTitle: '印刷を停止',\n      stopMessage: '現在の印刷「{{name}}」を停止しますか？プリンター上の印刷ジョブがキャンセルされます。',\n      removeTitle: '履歴から削除',\n      removeMessage: '「{{name}}」をキュー履歴から削除しますか？',\n      clearHistoryTitle: '履歴をクリア',\n      clearHistoryMessage: '{{count}}件の履歴をすべて削除しますか？',\n      cancelButton: '印刷をキャンセル',\n      stopButton: '印刷を停止',\n      thisPrint: 'この印刷',\n      thisItem: 'このアイテム',\n    },\n    // Toast messages\n    toast: {\n      cancelled: 'キャンセル済み',\n      cancelFailed: 'アイテムのキャンセルに失敗しました',\n      removed: 'キューアイテムを削除しました',\n      removeFailed: 'プロジェクトからのアーカイブ削除に失敗しました',\n      stopped: '印刷を停止しました',\n      stopFailed: '印刷の停止に失敗しました',\n      released: '印刷をキューにリリースしました',\n      startFailed: '印刷の開始に失敗しました',\n      reorderFailed: 'キューの並べ替えに失敗しました',\n      historyCleared: '{{count}}件の履歴をクリアしました',\n      clearHistoryFailed: '履歴のクリアに失敗しました',\n      updateFailed: 'アイテムの更新に失敗しました',\n      bulkCancelled: '{{count}}件のアイテムをキャンセルしました',\n      bulkCancelFailed: 'アイテムのキャンセルに失敗しました',\n    },\n    // Timeline view\n    timeline: {\n      listView: 'リスト',\n      timelineView: 'タイムライン',\n      unassigned: '未割当',\n      noData: 'この日の予定された印刷はありません',\n      allDoneBy: 'すべての印刷は {{time}} までに完了予定',\n      staged: 'ステージング',\n      filterAll: 'すべて表示',\n      filterPrinting: '印刷中',\n      filterQueued: '待機中',\n      time: {\n        anyMoment: 'まもなく',\n        minutesLeft: '残り{{minutes}}分',\n        hoursLeft: '残り{{hours}}時間',\n        hoursMinutesLeft: '残り{{hours}}時間{{minutes}}分',\n      },\n      day: {\n        previous: '前日',\n        next: '翌日',\n        today: '今日',\n      },\n    },\n    // Permissions\n    permissions: {\n      noStopPrint: '印刷を停止する権限がありません',\n      noStartPrint: '印刷を開始する権限がありません',\n      noEdit: 'このキューアイテムを編集する権限がありません',\n      noCancel: 'このキューアイテムをキャンセルする権限がありません',\n      noRequeue: 'アイテムを再キューする権限がありません',\n      noRemove: 'このキューアイテムを削除する権限がありません',\n      noClearHistory: 'すべての履歴をクリアする権限がありません',\n      noEditItems: 'キューアイテムを編集する権限がありません',\n      noCancelItems: 'キューアイテムをキャンセルする権限がありません',\n    },\n  },\n\n  backgroundDispatch: {\n    unknownFile: '不明なファイル',\n    unknownPrinter: '不明なプリンター',\n    startingPrints: '印刷開始中',\n    progressSummary: '{{complete}}/{{total}} 完了 • 配信済み: {{dispatched}} • 処理中: {{processing}}',\n    expandDetails: '配信詳細を展開',\n    collapseDetails: '配信詳細を折りたたむ',\n    dismissToast: '配信トーストを閉じる',\n    cancelDispatchJob: '配信ジョブをキャンセル',\n    cancel: 'キャンセル',\n    cancelling: 'キャンセル中…',\n    status: {\n      dispatched: '配信済み',\n      processing: '処理中',\n      completed: '完了',\n      failed: '失敗',\n      cancelled: 'キャンセル済み',\n    },\n    toast: {\n      cancellingUpload: 'アップロードをキャンセル中...',\n      cancelled: '配信をキャンセルしました',\n      cancelFailed: '配信のキャンセルに失敗しました',\n      completeWithFailures: 'バックグラウンド配信完了: {{completed}} 件成功、{{failed}} 件失敗',\n      completeSuccess: 'バックグラウンド配信完了: {{completed}} 件成功',\n      printStartedRemaining: '{{completed}} 件の印刷を開始、残り {{remaining}} 件送信中...',\n    },\n  },\n\n  // Statistics page\n  stats: {\n    title: '統計',\n    subtitle: 'ウィジェットをドラッグして並べ替え。目のアイコンをクリックして非表示。',\n    overview: '概要',\n    totalPrints: '総印刷数',\n    successRate: '成功率',\n    totalPrintTime: '総印刷時間',\n    printTime: '印刷時間',\n    totalFilament: '総フィラメント使用量',\n    filamentUsed: 'フィラメント使用量',\n    filamentCost: 'フィラメントコスト',\n    totalCost: '総コスト',\n    energyUsed: 'エネルギー使用量',\n    energyCost: 'エネルギーコスト',\n    energyWarmingUpTooltip: 'エネルギー追跡は毎時スナップショットを収集中です。選択範囲の前に少なくとも1つのスナップショットが存在すると、期間合計が正確になります。初期値は過小になる場合があります。',\n    averagePrintTime: '平均印刷時間',\n    printsPerDay: '1日あたりの印刷数',\n    byPrinter: 'プリンター別',\n    printsByPrinter: 'プリンター別印刷数',\n    byMaterial: '素材別',\n    byMonth: '月別',\n    last7Days: '過去7日間',\n    last30Days: '過去30日間',\n    last90Days: '過去90日間',\n    allTime: '全期間',\n    // Widgets\n    quickStats: 'クイック統計',\n    printActivity: '印刷アクティビティ',\n    filamentTypes: 'フィラメントタイプ',\n    filamentTrends: 'フィラメントトレンド',\n    failureAnalysis: '失敗分析',\n    timeAccuracy: '時間精度',\n    successful: '成功',\n    failed: '失敗',\n    perfectEstimate: '100% = 完全な推定',\n    noTimeAccuracyData: '時間精度データがありません',\n    noFilamentData: 'フィラメントデータがありません',\n    noPrinterData: 'プリンターデータがありません',\n    noPrintData: '印刷データがありません',\n    noPrintDataLast30Days: '過去30日間の印刷データがありません',\n    failureReasons: '失敗理由',\n    topFailureReasons: '主な失敗理由',\n    failedPrintsCount: '{{failed}} / {{total}} 件の印刷が失敗',\n    lastWeekRate: '先週: {{rate}}%',\n    // Actions\n    resetLayout: 'レイアウトをリセット',\n    recalculateCosts: 'コストを再計算',\n    recalculateCostsHint: '現在のフィラメント価格ですべてのアーカイブコストを再計算',\n    exportStats: '統計をエクスポート',\n    exportAsCsv: 'CSVでエクスポート',\n    exportAsExcel: 'Excelでエクスポート',\n    hiddenCount: '{{count}}件非表示',\n    // Toast\n    exportDownloaded: 'エクスポートをダウンロードしました',\n    exportFailed: 'エクスポートに失敗しました',\n    layoutReset: 'レイアウトをリセットしました',\n    recalculatedCosts: '{{count}}件のアーカイブのコストを再計算しました',\n    recalculateFailed: 'コストの再計算に失敗しました',\n    // Loading\n    loadingStats: '統計を読み込み中...',\n    // Permissions\n    noPermissionResetLayout: 'レイアウトをリセットする権限がありません',\n    noPermissionRecalculate: 'コストを再計算する権限がありません',\n    noPrintDataInRange: '選択した期間にデータがありません',\n    periodFilament: 'フィラメント使用量',\n    periodCost: 'コスト',\n    avgPerPrint: '1印刷あたりの平均',\n    usageOverTime: '時間推移',\n    filamentByWeight: '重量',\n    printDuration: '印刷時間分布',\n    printerUtilization: 'プリンター稼働率',\n    filamentSuccess: '素材別成功率',\n    printHabits: '印刷習慣',\n    printTimeOfDay: '時間帯別プリント',\n    colorDistribution: 'カラー分布',\n    noColorData: 'カラーデータがありません',\n    records: '記録',\n    longestPrint: '最長プリント',\n    heaviestPrint: '最重プリント',\n    mostExpensivePrint: '最高額',\n    busiestDay: '最多プリント日',\n    successStreak: '連続成功',\n    streakPrint: '連続プリント',\n    streakPrints: '{{count}}連続プリント',\n    printerStats: 'プリンター統計',\n    hours: '時間',\n    avgPrints: '平均印刷数',\n    noArchiveData: '印刷データがありません',\n    filamentByTime: '推移',\n    avgWeight: '平均重量',\n    avgTime: '平均時間',\n    filamentByPrints: '印刷数',\n    timeframe: {\n      today: '今日',\n      'this-week': '今週',\n      'this-month': '今月',\n      'last-7': '過去7日間',\n      'last-30': '過去30日間',\n      'last-90': '過去90日間',\n      'this-year': '今年',\n      'all-time': '全期間',\n      custom: 'カスタム',\n      from: '開始',\n      to: '終了',\n    },\n    allUsers: '全ユーザー',\n    noUser: 'ユーザーなし（システム）',\n    filterByUser: 'ユーザーでフィルター',\n  },\n\n  // Maintenance page\n  maintenance: {\n    title: 'メンテナンス',\n    overview: '概要',\n    allOk: 'すべてのメンテナンスは最新です',\n    dueCount: '{{count}}件の期限到来',\n    dueCount_plural: '{{count}}件の期限到来',\n    warningCount: '{{count}}件の警告',\n    warningCount_plural: '{{count}}件の警告',\n    totalPrintTime: '総印刷時間',\n    nextMaintenance: '次回メンテナンス',\n    nothingDue: '予定なし',\n    tasks: 'タスク',\n    lastPerformed: '前回実施日',\n    interval: '間隔',\n    hoursRemaining: '残り{{hours}}時間',\n    hoursOverdue: '{{hours}}時間超過',\n    markDone: '完了にする',\n    performMaintenance: 'メンテナンスを実施',\n    history: '履歴',\n    noHistory: 'メンテナンス履歴がありません',\n    editPrintHours: '印刷時間を編集',\n    currentHours: '現在の時間',\n    // Tabs\n    statusTab: 'ステータス',\n    settingsTab: '設定',\n    // Status\n    overdueCount: '{{count}}件超過',\n    dueSoonCount: '{{count}}件まもなく期限',\n    dueSoon: 'まもなく期限',\n    allGood: '問題なし',\n    overdueBy: '{{duration}}超過',\n    dueIn: 'あと{{duration}}',\n    timeLeft: '残り{{duration}}',\n    // Duration formats\n    day: '1日',\n    days: '日',\n    week: '1週間',\n    weeks: '{{count}}週間',\n    month: '1ヶ月',\n    months: '{{count}}ヶ月',\n    year: '1年',\n    // Settings\n    maintenanceTypes: 'メンテナンスタイプ',\n    maintenanceTypesDescription: 'システムタイプとカスタムメンテナンスタスク',\n    addCustomType: 'カスタムタイプを追加',\n    restoreDefaults: 'デフォルトタスクを復元',\n    intervalType: 'インターバルタイプ',\n    intervalValue: '間隔 ({{type}})',\n    icon: 'アイコン',\n    documentationLink: 'ドキュメントリンク（任意）',\n    assignToPrinters: 'プリンターに割り当て',\n    selectAtLeastOnePrinter: 'プリンターを1台以上選択してください',\n    addType: 'タイプを追加',\n    custom: 'カスタム',\n    printHours: '印刷時間',\n    calendarDays: 'カレンダー日数',\n    exampleName: '例: HEPAフィルター交換',\n    viewDocumentation: 'ドキュメントを表示',\n    timeBasedInterval: '時間ベースのインターバル',\n    // Interval overrides\n    intervalOverrides: 'インターバルのオーバーライド',\n    intervalOverridesDescription: '特定のプリンターの間隔をカスタマイズ',\n    // Printer assignment\n    assignedToPrinters: '割り当て済みプリンター：',\n    noPrintersAssigned: 'プリンター未割り当て',\n    addPrinterShort: '追加:',\n    printersAssignedClick: '{{count}}台のプリンターを割り当て済み - クリックして管理',\n    removeFromPrinter: 'このプリンターから削除',\n    // Types\n    types: {\n      lubricateCarbonRods: 'カーボンロッドの潤滑',\n      lubricateRails: 'リニアレールの潤滑',\n      cleanNozzle: 'ノズル/ホットエンドの清掃',\n      checkBelts: 'ベルト張力の確認',\n      cleanBuildPlate: 'ビルドプレートの清掃',\n      checkExtruder: 'エクストルーダーギアの確認',\n      checkCooling: '冷却ファンの確認',\n      generalInspection: '総合点検',\n      cleanCarbonRods: 'カーボンロッドの清掃',\n      lubricateSteelRods: 'スチールロッドの潤滑',\n      cleanSteelRods: 'スチールロッドの清掃',\n      cleanLinearRails: 'リニアレールの清掃',\n      checkPtfeTube: 'PTFEチューブの確認',\n      replaceHepaFilter: 'HEPAフィルター交換',\n      replaceCarbonFilter: 'カーボンフィルター交換',\n      lubricateLeftNozzleRail: '左ノズルレールの潤滑',\n    },\n    // Toast\n    maintenanceComplete: 'メンテナンスを完了としてマークしました',\n    typeUpdated: 'メンテナンスタイプを更新しました',\n    typeDeleted: 'メンテナンスタイプを削除しました',\n    defaultsRestored: 'デフォルトタスクを{{count}}件復元しました',\n    printHoursUpdated: '印刷時間を更新しました',\n    printerAssigned: 'プリンターを割り当てました',\n    printerRemoved: 'プリンターを削除しました',\n    // Confirmation\n    deleteTypeConfirm: '「{{name}}」を削除しますか？',\n    deleteSystemTypeTitle: 'デフォルトのメンテナンスタスクを削除しますか？',\n    deleteSystemTypeMessage: 'デフォルトのメンテナンスタスク「{{name}}」を削除してもよろしいですか？',\n    // Permissions\n    noPermissionUpdate: 'メンテナンス記録を更新する権限がありません',\n    noPermissionPerform: 'メンテナンスを実行する権限がありません',\n    noPermissionEditTypes: 'メンテナンスタイプを編集する権限がありません',\n    noPermissionDeleteTypes: 'メンテナンスタイプを削除する権限がありません',\n    noPermissionEditHours: 'メンテナンス時間を編集する権限がありません',\n    noPermissionRemovePrinter: 'プリンターの割り当てを解除する権限がありません',\n    noPermissionAssignPrinter: 'プリンターを割り当てる権限がありません',\n    noPermissionEditIntervals: 'メンテナンス間隔を編集する権限がありません',\n    // Configure link\n    configureSettings: 'メンテナンスタイプと間隔を設定',\n  },\n\n  // Settings page\n  settings: {\n    title: '設定',\n    general: '一般',\n    // Tab names\n    tabs: {\n      general: '一般',\n      smartPlugs: 'スマートプラグ',\n      notifications: '通知',\n      queue: 'ワークフロー',\n      filament: 'フィラメント',\n      network: 'ネットワーク',\n      apiKeys: 'APIキー',\n      virtualPrinter: '仮想プリンター',\n      spoolbuddy: 'SpoolBuddy',\n      failureDetection: '失敗検出',\n      users: '認証',\n      backup: 'バックアップ',\n      emailAuth: 'メール認証',\n      ldap: 'LDAP',\n      twoFa: '二段階認証',\n      oidc: 'SSO / OIDC',\n    },\n    spoolbuddy: {\n      infoTitle: 'SpoolBuddy デバイス',\n      infoBody: 'SpoolBuddy キオスクはハートビートにより自動的に登録されます。使用されなくなった場合や、デーモンのクラッシュにより古い重複が残った場合は、ここでデバイスの登録を解除してください。',\n      duplicatesTitle: '{{count}} 台のデバイスが登録されています',\n      duplicatesBody: 'キオスク UI は最初に登録されたデバイスのみを使用します。クラッシュによる古い重複がある場合は登録解除してください。オンラインのデバイスは次回のハートビートで自動的に再登録されます。',\n      empty: 'SpoolBuddy デバイスはまだ登録されていません。',\n      online: 'オンライン',\n      offline: 'オフライン',\n      unregister: '登録解除',\n      unregisterSuccess: 'デバイスの登録を解除しました',\n      unregisterError: 'デバイスの登録解除に失敗しました',\n      confirmTitle: 'SpoolBuddy デバイスの登録を解除しますか？',\n      confirmBody: '「{{hostname}}」（{{deviceId}}）をデータベースから削除します。デバイスがオンラインの場合、次回のハートビートで自動的に再登録されます。',\n      ipAddress: 'IPアドレス',\n      firmware: 'ファームウェア',\n      lastSeen: '最終接続',\n      daemonUptime: 'デーモン稼働時間',\n      systemUptime: 'システム稼働時間',\n      never: 'なし',\n      nfc: 'NFC',\n      scale: '計量器',\n      cpuTemp: 'CPU 温度',\n      memory: 'メモリ',\n      disk: 'ディスク',\n    },\n    ldap: {\n      title: 'LDAP認証',\n      enabledDesc: 'LDAP認証が有効です',\n      disabledDesc: 'LDAP認証が無効です',\n      disabledHint: '以下のLDAP設定を構成して保存し、有効にしてください。',\n      enabled: 'LDAP認証を有効にしました',\n      disabled: 'LDAP認証を無効にしました',\n      feature1: 'LDAP資格情報でログインできます',\n      feature2: 'ローカル管理者アカウントはフォールバックとして残ります',\n      feature3: 'ログイン時にLDAPグループがBamBuddyグループにマッピングされます',\n      serverConfig: 'LDAPサーバー設定',\n      serverUrl: 'サーバーURL',\n      serverUrlHint: '標準はldap://、SSL接続はldaps://を使用',\n      security: 'セキュリティ',\n      securityHint: 'StartTLSはプレーン接続をTLSにアップグレードします。LDAPSは最初からTLSを使用します。',\n      bindDn: 'バインドDN（サービスアカウント）',\n      bindPassword: 'バインドパスワード',\n      searchBase: '検索ベースDN',\n      userFilter: 'ユーザー検索フィルター',\n      userFilterHint: '{username}はログインユーザー名に置き換えられます。OpenLDAPの場合は(uid={username})を使用。',\n      autoProvision: 'ユーザー自動作成',\n      autoProvisionHint: '初回LDAPログイン時にBamBuddyアカウントを自動作成',\n      defaultGroup: 'デフォルトグループ',\n      defaultGroupNone: '— なし（フォールバックなし）—',\n      defaultGroupHint: 'LDAPユーザーが認証されたがマッピングされたLDAPグループに属していない場合に割り当てられるフォールバックグループ。空欄の場合、マッピングされていないユーザーは権限なしのままになります。',\n      groupMapping: 'グループマッピング（JSON）',\n      groupMappingHint: 'LDAPグループDNをBamBuddyグループにマッピング。利用可能なグループ: ',\n      testConnection: '接続テスト',\n      settingsSaved: 'LDAP設定を保存しました',\n      errors: {\n        serverRequired: 'LDAPサーバーURLは必須です',\n        searchBaseRequired: '検索ベースDNは必須です',\n        enableAuthFirst: '先に認証を有効にしてください',\n        configureLdapFirst: '先にLDAP設定を保存してください',\n      },\n    },\n    // Email settings\n    email: {\n      smtpSettings: 'SMTP設定',\n      smtpHost: 'SMTPサーバー',\n      smtpPort: 'SMTPポート',\n      security: 'セキュリティ',\n      authentication: '認証',\n      username: 'ユーザー名',\n      password: 'パスワード',\n      fromEmail: '送信元メールアドレス',\n      fromName: '送信者名',\n      testConnection: 'SMTP接続テスト',\n      testRecipient: 'テスト受信者メール',\n      sendTest: 'テストメール送信',\n      sending: '送信中...',\n      save: '設定を保存',\n      saving: '保存中...',\n      advancedAuth: '高度な認証',\n      advancedAuthEnabled: '高度な認証が有効です',\n      advancedAuthEnabledDesc: 'メールベースのユーザー管理機能が有効になっています。新規ユーザーには自動生成されたパスワードがメールで送信され、ユーザーはパスワード忘れ機能でパスワードをリセットできます。',\n      advancedAuthDisabled: '高度な認証が無効です',\n      advancedAuthDisabledDesc: '高度な認証を有効にして、ユーザー管理のメールベース機能を有効化してください。',\n      enable: '有効にする',\n      disable: '無効にする',\n      feature1: 'パスワードは自動生成され、新規ユーザーにメールで送信されます',\n      feature2: 'ユーザーはユーザー名またはメールでログインできます',\n      feature3: 'パスワード忘れ機能が利用可能です',\n      feature4: '管理者はメールでユーザーパスワードをリセットできます',\n      // Error messages\n      errors: {\n        requiredFields: 'すべての必須フィールドに入力してください',\n        usernameRequired: '認証が有効な場合、ユーザー名は必須です',\n        enterTestEmail: 'テストメールアドレスを入力してください',\n        smtpServerAndEmail: 'テストする前にSMTPサーバーと送信元メールを入力してください',\n        usernamePasswordRequired: '認証が有効な場合、ユーザー名とパスワードは必須です',\n        configureSmtpFirst: '最初にSMTP設定を構成してテストしてください',\n        enableAuthFirst: 'メール関連機能をご利用いただくには、まず認証を有効にしてください。',\n      },\n      // Success messages\n      success: {\n        settingsSaved: 'SMTP設定を保存しました',\n      },\n      // Security options\n      securityOptions: {\n        starttls: 'STARTTLS (ポート 587)',\n        ssl: 'SSL/TLS (ポート 465)',\n        none: 'なし (ポート 25)',\n      },\n      // Authentication options\n      authOptions: {\n        enabled: '有効',\n        disabled: '無効',\n      },\n    },\n    appearance: '外観',\n    notifications: '通知',\n    smartPlugs: 'スマートプラグ',\n    spoolman: 'Spoolman',\n    updates: 'アップデート',\n    language: '言語',\n    languageDescription: '表示言語を選択してください',\n    theme: 'テーマ',\n    themeLight: 'ライト',\n    themeDark: 'ダーク',\n    themeSystem: 'システム設定に従う',\n    defaultView: 'デフォルト画面',\n    defaultViewDescription: 'アプリ起動時に表示するページ',\n    checkForUpdates: 'アップデートを確認',\n    autoUpdate: '自動アップデート',\n    currentVersion: '現在のバージョン',\n    latestVersion: '最新バージョン',\n    upToDate: '最新です',\n    updateAvailable: 'アップデートあり',\n    // Notifications\n    notificationLanguage: '通知の言語',\n    notificationLanguageDescription: 'プッシュ通知の言語',\n    bedCooledThreshold: 'ベッド冷却しきい値',\n    bedCooledThresholdDescription: '印刷後にベッドが冷却されたと見なす温度',\n    userNotificationsEnabled: 'ユーザー通知',\n    userNotificationsEnabledDescription: 'ユーザー通知メニューと印刷ジョブイベントのメール通知を有効にします。高度な認証が必要です。',\n    userNotificationsDisabledHint: 'ユーザー通知を使用するには高度な認証を有効にしてください。',\n    notificationProviders: '通知プロバイダー',\n    addProvider: 'プロバイダーを追加',\n    editProvider: 'プロバイダーを編集',\n    providerType: 'プロバイダーの種類',\n    testNotification: 'テスト通知',\n    testSuccess: 'テスト通知を送信しました',\n    testFailed: 'テスト通知の送信に失敗しました',\n    quietHours: 'おやすみ時間',\n    quietHoursDescription: 'この時間帯は通知を送信しません',\n    quietHoursStart: '開始',\n    quietHoursEnd: '終了',\n    events: {\n      title: '通知イベント',\n      printStart: '印刷開始',\n      printComplete: '印刷完了',\n      printFailed: '印刷失敗',\n      printStopped: '印刷中止',\n      printProgress: '進捗マイルストーン',\n      printProgressDescription: '25%, 50%, 75%で通知',\n      printerOffline: 'プリンターオフライン',\n      printerError: 'プリンターエラー',\n      filamentLow: 'フィラメント残量低下',\n      maintenanceDue: 'メンテナンス期限',\n      maintenanceDueDescription: 'メンテナンスが必要なときに通知',\n    },\n    // Smart Plugs\n    smartPlug: {\n      title: 'スマートプラグ',\n      add: 'スマートプラグを追加',\n      edit: 'スマートプラグを編集',\n      name: '名前',\n      ipAddress: 'IPアドレス',\n      linkedPrinter: '連携プリンター',\n      autoOn: '自動電源オン',\n      autoOnDescription: '印刷開始時に電源を入れる',\n      autoOff: '自動電源オフ',\n      autoOffDescription: '印刷完了後に電源を切る',\n      offDelay: 'オフ遅延',\n      offDelayMinutes: '印刷後の待機時間（分）',\n      offDelayTemp: 'ノズル温度が下回ったとき',\n      currentState: '現在の状態',\n      turnOn: '電源オン',\n      turnOff: '電源オフ',\n    },\n    // Filament Tracking Mode\n    filamentTracking: 'フィラメント追跡',\n    filamentTrackingDesc: 'フィラメントスプールの追跡方法を選択してください。内蔵インベントリまたは外部Spoolmanサーバーを使用できます。',\n    filamentChecks: 'フィラメントチェック',\n    disableFilamentWarnings: 'フィラメント警告を無効化',\n    disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない',\n    preferLowestFilament: '残量が少ないフィラメントを優先',\n    preferLowestFilamentDesc: '複数のスプールが一致する場合、残量が最も少ないものを使用します',\n    trackingModeBuiltIn: '内蔵インベントリ',\n    trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',\n    trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',\n    builtInFeatureRfid: 'AMS内のBambu Lab RFIDスプールを自動検出',\n    builtInFeatureUsage: 'プリントごとのフィラメント消費量を追跡',\n    builtInFeatureCatalog: 'スプール、カラー、K値プロファイルを管理',\n    builtInFeatureThirdParty: 'サードパーティ製スプールをインベントリスプールに割り当て可能',\n    amsSyncButton: 'AMSから重量を同期',\n    amsSyncTitle: 'AMSからスプール重量を同期',\n    amsSyncMessage: '接続されたプリンターの現在のAMS残量値で、すべてのインベントリスプール重量を上書きします。破損した重量データの復旧に使用してください。プリンターがオンラインである必要があります。',\n    amsSyncing: '同期中...',\n    amsSyncSuccess: '{{synced}}個のスプールを同期、{{skipped}}個をスキップ',\n    amsSyncError: 'AMSからの重量同期に失敗しました',\n    // Spoolman settings\n    spoolmanUrl: 'Spoolman URL',\n    spoolmanUrlHint: 'Spoolmanサーバーのurl（例：http://localhost:7912）',\n    spoolmanConnected: '接続中',\n    spoolmanDisconnected: '未接続',\n    status: 'ステータス',\n    connect: '接続',\n    disconnect: '切断',\n    howSyncWorks: '同期の仕組み',\n    syncInfoRfidOnly: 'RFIDを搭載した公式Bambu Labスプールのみ同期されます',\n    syncInfoAutoCreate: '新しいスプールは初回同期時にSpoolmanに自動作成されます',\n    syncInfoThirdPartySkipped: 'Bambu Lab以外のスプール（サードパーティ、リフィル）はスキップされます',\n    linkingExistingSpools: '既存スプールのリンク',\n    linkingExistingSpoolsDesc: '既存のSpoolmanスプールをAMSにリンクするには、AMSスロットにカーソルを合わせて「Spoolmanにリンク」をクリックしてください。',\n    syncMode: '同期モード',\n    syncModeAuto: '自動',\n    syncModeManual: '手動のみ',\n    syncModeAutoDesc: '変更が検出されるとAMSデータが自動的に同期されます',\n    syncModeManualDesc: '手動でトリガーした場合のみ同期',\n    syncAmsData: 'AMSデータを同期',\n    syncAmsDataDesc: 'プリンターのAMSデータをSpoolmanに手動同期',\n    allPrinters: '全プリンター',\n    // Default printer\n    noDefaultPrinter: 'デフォルトなし（毎回選択）',\n    // Sidebar\n    sidebarOrder: 'サイドバーの順序',\n    // Camera\n    saveThumbnails: 'サムネイルを保存',\n    captureFinishPhoto: '完了写真を撮影',\n    noPrintersConfigured: 'プリンターが設定されていません',\n    // Archive settings\n    archiveMode: {\n      always: '常にアーカイブを作成',\n      never: 'アーカイブを作成しない',\n      ask: '毎回確認',\n    },\n    // Updates\n    checkForUpdatesLabel: 'アップデートを確認',\n    checkPrinterFirmware: 'プリンターファームウェアの確認',\n    includeBetaUpdates: 'ベータ版を含める',\n    includeBetaUpdatesDesc: 'アップデート確認時にベータ版およびプレリリース版を通知する',\n    // Queue\n    enableRetry: 'リトライを有効化',\n    // Home Assistant\n    homeAssistantDescription: 'Home Assistantに接続してHA REST APIでスマートプラグを制御します。switch、light、input_booleanエンティティに対応しています。',\n    environmentManagedLabel: '(環境変数で管理)',\n    autoEnabledViaEnv: '環境変数により自動的に有効化されました',\n    urlFromEnvReadOnly: 'HA_URL環境変数で設定された値（読み取り専用）',\n    tokenFromEnvReadOnly: 'HA_TOKEN環境変数で設定された値（読み取り専用）',\n    // MQTT\n    mqttConnectedTo: '接続先:',\n    // Prometheus\n    prometheusDescription: 'プリンターデータをPrometheus形式で公開',\n    // Smart plugs empty state\n    noSmartPlugsTitle: 'スマートプラグが設定されていません',\n    noSmartPlugsDescription: 'Tasmotaベースのスマートプラグを追加して、エネルギー消費を追跡し、電源制御を自動化します。',\n    // Notifications empty state\n    noProvidersTitle: 'プロバイダーが設定されていません',\n    noProvidersDescription: 'アラートを受信するにはプロバイダーを追加してください。',\n    noTemplatesAvailable: 'テンプレートがありません。バックエンドを再起動してデフォルトテンプレートを生成してください。',\n    // API permissions\n    apiPermissionView: 'プリンターステータスとキューを表示',\n    apiPermissionEdit: '印刷キューにアイテムを追加・削除',\n    // API keys\n    apiKeysEmptyTitle: 'APIキーがありません',\n    apiKeysEmptyDescription: '外部サービスと連携するためのAPIキーを作成してください。',\n    // Users\n    noUsersFound: 'ユーザーが見つかりません',\n    noGroupsFound: 'グループが見つかりません',\n    noGroupsAvailable: '利用可能なグループがありません',\n    passwordsDoNotMatch: 'パスワードが一致しません',\n    systemGroupWarning: 'システムグループ名は変更できません',\n    // Auth disabled\n    authDisabledTitle: '認証が無効です',\n    authDisabledFeature1: 'システムへのアクセスにログインを要求',\n    authDisabledFeature2: 'グループベースの権限で複数ユーザーを作成',\n    authDisabledFeature3: '50以上のきめ細かな権限でアクセスを制御',\n    // User deletion\n    userHasCreated: 'このユーザーは以下を作成しています：',\n    userItemsQuestion: 'これらのアイテムをどうしますか？',\n    deleteUserConfirm: 'このユーザーを削除してもよろしいですか？この操作は元に戻せません。',\n    actionCannotBeUndone: 'この操作は元に戻せません',\n    // Smart plugs\n    addFirstSmartPlug: '最初のスマートプラグを追加',\n    // Notifications\n    providers: 'プロバイダー',\n    log: 'ログ',\n    testAll: 'すべてテスト',\n    testResults: 'テスト結果',\n    testPassedCount: '{{count}}件成功',\n    testFailedCount: '{{count}}件失敗',\n    messageTemplates: 'メッセージテンプレート',\n    messageTemplatesDescription: '各イベントの通知メッセージをカスタマイズ。',\n    // API Keys section\n    apiKeys: 'APIキー',\n    apiKeysDescription: '外部連携やWebhook用のAPIキーを作成します。',\n    createKey: 'キーを作成',\n    apiKeyCreated: 'APIキーを作成しました',\n    apiKeyCopyWarning: '今すぐこのキーをコピーしてください - 再表示されません！',\n    useInApiBrowser: 'APIブラウザーで使用',\n    createNewApiKey: '新しいAPIキーを作成',\n    keyName: 'キー名',\n    keyNamePlaceholder: '例: Home Assistant, OctoPrint',\n    readStatus: 'ステータスの読み取り',\n    readStatusDescription: 'プリンターのステータスとキューを表示',\n    manageQueue: 'キューの管理',\n    manageQueueDescription: '印刷キューへのアイテムの追加と削除',\n    controlPrinter: 'プリンターの制御',\n    controlPrinterDescription: '印刷の一時停止、再開、停止',\n    unnamedKey: '名前なしキー',\n    lastUsed: '最終使用:',\n    read: '読み取り',\n    control: '制御',\n    createFirstKey: '最初のキーを作成',\n    webhookEndpoints: 'Webhookエンドポイント',\n    webhookApiKeyHint: 'X-API-KeyヘッダーでAPIキーを使用してください。',\n    webhook: {\n      getAllStatus: '全プリンターステータスを取得',\n      getSpecificStatus: '特定のプリンターステータスを取得',\n      addToQueue: '印刷キューに追加',\n      pausePrint: '印刷を一時停止',\n      resumePrint: '印刷を再開',\n      stopPrint: '印刷を停止',\n    },\n    apiBrowser: 'APIブラウザ',\n    apiBrowserDescription: 'すべての利用可能なAPIエンドポイントを探索してテストします。',\n    apiKeyForTesting: 'テスト用APIキー',\n    apiKeyPlaceholder: 'CallMeBot APIキー',\n    apiKeyHint: 'このキーはX-API-Keyヘッダーとしてリクエストに送信されます。',\n    deleteApiKeyTitle: 'APIキーを削除',\n    deleteApiKeyMessage: 'このAPIキーを削除してもよろしいですか？このキーを使用しているすべての連携が動作しなくなります。',\n    deleteKey: 'キーを削除',\n    // Filament tab\n    amsDisplayThresholds: 'AMS表示しきい値',\n    amsThresholdsDescription: 'AMS湿度と温度インジケーターの色しきい値を設定します。',\n    humidity: '湿度',\n    goodGreen: '良好（緑）≤',\n    fairOrange: '普通（オレンジ）≤',\n    aboveFairBad: '普通のしきい値以上は赤（悪い）で表示',\n    fairAlsoDryingThreshold: 'このしきい値は自動乾燥のトリガーにも使用されます',\n    temperature: '温度',\n    goodBlue: '良好（青）≤',\n    aboveFairHot: '普通のしきい値以上は赤（高温）で表示',\n    historyRetention: '履歴の保持',\n    keepSensorHistory: 'センサー履歴の保持期間',\n    historyRetentionDescription: '古い湿度と温度データは自動的に削除されます',\n    defaultPrintOptions: 'デフォルト印刷オプション',\n    defaultPrintOptionsDescription: '新しい印刷のデフォルト値を設定します。印刷ダイアログで個別に変更できます。',\n    defaultBedLevelling: 'ベッドレベリング',\n    defaultBedLevellingDesc: '印刷前にベッドを自動レベリング',\n    defaultFlowCali: 'フローキャリブレーション',\n    defaultFlowCaliDesc: '押出フローのキャリブレーション',\n    defaultVibrationCali: '振動キャリブレーション',\n    defaultVibrationCaliDesc: 'リンギングアーティファクトを低減',\n    defaultLayerInspect: '第1層検査',\n    defaultLayerInspectDesc: 'AIによる第1層の検査',\n    defaultTimelapse: 'タイムラプス',\n    defaultTimelapseDesc: 'タイムラプス動画を記録',\n    staggeredStart: 'Staggered Start',\n    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',\n    plateClear: 'プレートクリア確認',\n    requirePlateClear: 'プレートクリア確認を必須にする',\n    requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。無効にすると、プリンターカード上のプレート状態バッジと「プレートをクリア済みにする」ボタンも非表示になります。',\n    gcodeInjection: 'G-codeインジェクション',\n    gcodeInjectionDescription: 'Farmloop、SwapMod、AutoClear、Printflow 3Dなどの自動印刷システム用に、印刷の開始と終了時にカスタムG-codeを挿入します。スニペットはプリンターモデルごとに設定し、キューアイテム��「G-codeを挿入」を有効にすると適用されます。',\n    gcodeInjectionNoPrinters: 'プリンターが見つかりません。G-codeスニペットを設定するにはプリンターを追加してください。',\n    gcodeStartLabel: '開始G-code',\n    gcodeEndLabel: '終了G-code',\n    gcodeStartPlaceholder: '印刷開始前に挿入されるG-code...',\n    gcodeEndPlaceholder: '印刷終了後に追加されるG-code...',\n    staggerGroupSize: 'Group size',\n    staggerGroupSizeHelp: 'Printers to start simultaneously per group',\n    staggerInterval: 'Interval (minutes)',\n    staggerIntervalHelp: 'Delay between each group starting',\n    queueDrying: '自動乾燥',\n    queueDryingDescription: 'キュー印刷の合間にプリンターがアイドル状態の時、AMSフィラメントを自動的に乾燥します。上記の湿度しきい値を使用します。',\n    queueDryingEnabled: '自動乾燥を有効にする',\n    queueDryingEnabledDescription: 'プリンターがアイドル状態で湿度がしきい値を超えた場合、AMS乾燥を自動的に開始',\n    queueDryingBlock: '乾燥完了まで待機',\n    queueDryingBlockDescription: '乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。',\n    ambientDryingEnabled: '常時乾燥',\n    ambientDryingEnabledDescription: 'キューに関係なく、アイドル状態のプリンターで湿度がしきい値を超えた場合に自動的にフィラメントを乾燥。',\n    dryingPresets: '乾燥プリセット',\n    dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。',\n    dryingFilament: 'フィラメント',\n    printModal: '印刷ダイアログ',\n    expandCustomMapping: 'カスタムマッピングをデフォルトで展開',\n    expandCustomMappingDescription: '複数プリンターに印刷する際、プリンターごとのAMSマッピングを展開表示',\n    // User management\n    authentication: '認証',\n    authEnabledDescription: 'ユーザー認証でインスタンスが保護されています',\n    authDisabledDescription: '認証を有効にして、ユーザーアカウントの作成、権限の管理、Bambuddyインスタンスのセキュリティを確保しましょう。',\n    authDisabledMessage: '認証を有効にして、ユーザーアカウントの作成、権限の管理、Bambuddyインスタンスのセキュリティを確保しましょう。',\n    enableAuthentication: '認証を有効にする',\n    currentUser: '現在のユーザー',\n    changePassword: 'パスワードを変更',\n    admin: '管理者',\n    users: 'ユーザー',\n    addUser: 'ユーザーを追加',\n    groups: 'グループ',\n    addGroup: 'グループを追加',\n    system: 'システム',\n    noDescription: '説明なし',\n    userCount: '{{count}}人のユーザー',\n    permissionCount: '{{count}}件の権限',\n    createUser: 'ユーザーを作成',\n    username: 'ユーザー名',\n    enterUsername: 'ユーザー名を入力',\n    password: 'パスワード',\n    enterPassword: 'パスワードを入力（6文字以上）',\n    confirmPassword: 'パスワードの確認',\n    confirmPasswordPlaceholder: 'パスワードを確認',\n    // Title tooltips\n    viewReleaseOnGitHub: 'GitHubでリリースを表示',\n    turnAllPlugsOn: 'すべてのプラグをオン',\n    turnAllPlugsOff: 'すべてのプラグをオフ',\n    // Modal: Clear logs\n    clearNotificationLogs: '通知ログをクリア',\n    clearLogsMessage: '30日以上前のすべての通知ログを完全に削除します。この操作は元に戻せません。',\n    clearLogs: '通知ログを削除',\n    // Modal: Reset UI\n    resetUiPreferences: 'UI設定をリセット',\n    resetUiPreferencesMessage: 'すべてのUI設定をデフォルトにリセットします：サイドバー順序、テーマ、ダッシュボードレイアウト、表示モード、ソート設定。プリンター、アーカイブ、サーバー設定は影響を受けません。クリア後にページがリロードされます。',\n    resetPreferences: '設定をリセット',\n    // Modal: Delete group\n    deleteGroupTitle: 'グループを削除',\n    deleteGroupMessage: 'このグループを削除しますか？このグループのユーザーはこれらの権限を失います。',\n    deleteGroup: 'グループを削除',\n    // Modal: Disable auth\n    disableAuthenticationTitle: '認証を無効化',\n    disableAuthenticationMessage: '認証を無効にしますか？Bambuddyインスタンスにログインなしでアクセスできるようになります。ユーザーはデータベースに残りますが、認証は無効になります。',\n    disableAuthentication: '認証を無効化',\n    // Additional settings\n    configureBambuddy: 'Bambuddyを設定',\n    systemDefault: 'システムデフォルト',\n    archiveSettings: 'アーカイブ設定',\n    newWindow: '新しいウィンドウ',\n    embeddedOverlay: '埋め込みオーバーレイ',\n    preferredSlicer: '優先スライサー',\n    preferredSlicerDescription: 'ファイルを開くスライサーアプリケーションを選択',\n    externalCameras: '外部カメラ',\n    costTracking: 'コスト追跡',\n    printsOnly: '印刷のみ',\n    totalConsumption: '総消費量',\n    dataManagement: 'データ管理',\n    storageUsage: 'ストレージ使用量',\n    storageUsageDescription: 'カテゴリ別のデータ使用量の内訳',\n    storageUsageTotal: '合計',\n    storageUsageErrors: 'エラー',\n    storageUsageOtherBreakdown: 'その他（静的アセット、スクリプト、設定ファイルを含む）',\n    storageUsageSystem: 'システム',\n    storageUsageData: 'データ',\n    storageUsageUnavailable: 'ストレージ使用量情報は利用できません',\n    clearNotificationLogsDescription: '30日以上前の通知ログを削除',\n    resetUiPreferencesDescription: 'サイドバー順序、テーマ、表示モード、レイアウト設定をリセット。プリンター、アーカイブ、設定は影響を受けません。',\n    enableHomeAssistant: 'Home Assistantを有効化',\n    enableMqtt: 'MQTTを有効化',\n    useTls: 'TLSを使用',\n    enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',\n    availableMetrics: '利用可能なメトリクス',\n    editUser: 'ユーザーを編集',\n    deleteUserTitle: 'ユーザーを削除',\n    groupName: 'グループ名',\n    // Placeholders\n    leaveEmptyForAnonymous: '匿名の場合は空のまま',\n    leaveEmptyForNoAuth: '認証なしの場合は空のまま',\n    enterNewPassword: '新しいパスワードを入力',\n    confirmNewPassword: '新しいパスワードを確認',\n    enterGroupName: 'グループ名を入力',\n    enterDescriptionOptional: '説明を入力（任意）',\n    enterCurrentPassword: '現在のパスワードを入力',\n    enterNewPasswordMin6: '新しいパスワードを入力（6文字以上）',\n    toast: {\n      keyCopied: 'キーをクリップボードにコピーしました',\n      copyFailed: 'キーのコピーに失敗しました',\n      keyAddedToBrowser: 'キーをAPIブラウザに追加しました',\n      clearLogsFailed: 'ログの削除に失敗しました',\n      uiPreferencesReset: 'UI設定をリセットしました。更新中...',\n      authDisabled: '認証を無効にしました',\n      authDisableFailed: '認証の無効化に失敗しました',\n      apiKeyCreated: 'APIキーを作成しました',\n      apiKeyDeleted: 'APIキーを削除しました',\n      userCreated: 'ユーザーが正常に作成されました',\n      userUpdated: 'ユーザーが正常に更新されました',\n      userDeleted: 'ユーザーが正常に削除されました',\n      groupCreated: 'グループを作成しました',\n      groupUpdated: 'グループを更新しました',\n      groupDeleted: 'グループを削除しました',\n      fillRequiredFields: '必須項目をすべて入力してください',\n      passwordsDoNotMatch: 'パスワードが一致しません',\n      passwordTooShort: 'パスワードは6文字以上必要です',\n      enterGroupName: 'グループ名を入力',\n      settingsSaved: '設定を保存しました',\n      cameraSettingsSaved: 'カメラ設定を保存しました',\n      enterCameraUrl: 'カメラURLを入力してください',\n      passwordChanged: 'パスワードが正常に変更されました',\n      connectionFailed: '接続失敗',\n      testFailed: 'テスト通知の送信に失敗しました',\n      cameraConnected: 'カメラ接続{{resolution}}',\n    },\n    testConnection: '接続テスト',\n    catalog: {\n      spoolCatalog: 'スプールカタログ',\n      spoolCatalogDescription: 'ブランド/タイプ別の空スプール重量。スプール追加時の自動重量検索に使用されます。',\n      searchCatalog: 'カタログを検索...',\n      addNewEntry: '新しいエントリを追加',\n      namePlaceholder: '名前（例：Bambu Lab - プラスチック）',\n      weight: '重量',\n      type: 'タイプ',\n      default: 'デフォルト',\n      custom: 'カスタム',\n      noMatch: '検索に一致するエントリがありません',\n      empty: 'カタログにエントリがありません',\n      deleteEntry: 'エントリを削除',\n      deleteConfirm: '「{{name}}」を削除してもよろしいですか？',\n      resetCatalog: 'カタログをリセット',\n      resetConfirm: 'カタログをデフォルトにリセットしますか？カスタムエントリはすべて削除されます。',\n      loadFailed: 'スプールカタログの読み込みに失敗しました',\n      nameWeightRequired: '名前と重量は必須です',\n      entryAdded: 'エントリを追加しました',\n      addFailed: 'エントリの追加に失敗しました',\n      entryUpdated: 'エントリを更新しました',\n      updateFailed: 'エントリの更新に失敗しました',\n      entryDeleted: 'エントリを削除しました',\n      deleteFailed: 'エントリの削除に失敗しました',\n      resetSuccess: 'カタログをデフォルトにリセットしました',\n      resetFailed: 'カタログのリセットに失敗しました',\n      exported: '{{count}}件のエントリをエクスポートしました',\n      imported: '{{added}}件のエントリをインポートしました（{{skipped}}件スキップ）',\n      importFailed: 'インポートに失敗しました：無効なJSON形式',\n      exportTooltip: 'カタログをJSONにエクスポート',\n      importTooltip: 'JSONからカタログをインポート',\n      resetTooltip: 'デフォルトにリセット',\n      selectedCount: '{{count}}件選択中',\n      deleteSelected: '選択を削除',\n      bulkDeleteConfirm: '{{count}}件のエントリーを削除してもよろしいですか？',\n      bulkDeleted: '{{count}}件のエントリーを削除しました',\n      bulkDeleteFailed: 'エントリーの削除に失敗しました',\n    },\n    colorCatalog: {\n      title: 'カラーカタログ',\n      description: 'メーカー/素材別のフィラメントカラー。スプール追加時の自動カラー検索に使用されます。',\n      searchColors: 'カラーを検索...',\n      allManufacturers: 'すべてのメーカー',\n      addNewColor: '新しいカラーを追加',\n      manufacturer: 'メーカー',\n      colorName: 'カラー名',\n      hex: 'Hex',\n      materialOptional: '素材（任意）',\n      showing: '{{total}}件中{{filtered}}件を表示',\n      noMatch: '検索に一致するカラーがありません',\n      empty: 'カタログにカラーがありません',\n      deleteColor: 'カラーを削除',\n      deleteConfirm: '「{{name}}」を削除してもよろしいですか？',\n      resetCatalog: 'カラーカタログをリセット',\n      resetConfirm: 'カタログをデフォルトにリセットしますか？カスタムカラーはすべて削除されます。',\n      sync: '同期',\n      starting: '開始中...',\n      syncTooltip: 'FilamentColors.xyzから同期（2000+カラー）',\n      loadFailed: 'カラーカタログの読み込みに失敗しました',\n      fieldsRequired: 'メーカー、カラー名、Hexカラーは必須です',\n      colorAdded: 'カラーを追加しました',\n      addFailed: 'カラーの追加に失敗しました',\n      colorUpdated: 'カラーを更新しました',\n      updateFailed: 'カラーの更新に失敗しました',\n      colorDeleted: 'カラーを削除しました',\n      deleteFailed: 'カラーの削除に失敗しました',\n      resetSuccess: 'カラーカタログをデフォルトにリセットしました',\n      resetFailed: 'カタログのリセットに失敗しました',\n      syncUpToDate: '最新の状態です（{{count}}件のカラーを確認）',\n      syncComplete: '{{added}}件の新しいカラーを追加しました（{{skipped}}件は既に存在）',\n      syncError: '同期エラー',\n      syncFailed: 'FilamentColors.xyzからの同期に失敗しました',\n      exported: '{{count}}件のカラーをエクスポートしました',\n      imported: '{{added}}件のカラーをインポートしました（{{skipped}}件スキップ）',\n      importFailed: 'インポートに失敗しました：無効なJSON形式',\n      selectedCount: '{{count}}件選択中',\n      deleteSelected: '選択を削除',\n      bulkDeleteConfirm: '{{count}}件のカラーを削除してもよろしいですか？',\n      bulkDeleted: '{{count}}件のカラーを削除しました',\n      bulkDeleteFailed: 'カラーの削除に失敗しました',\n    },\n    // General tab\n    dateFormat: '日付形式',\n    dateFormatUs: 'US (MM/DD/YYYY)',\n    dateFormatEu: 'EU (DD/MM/YYYY)',\n    dateFormatIso: 'ISO (YYYY-MM-DD)',\n    timeFormat: '時刻形式',\n    timeFormat12: '12時間制 (3:30 PM)',\n    timeFormat24: '24時間制 (15:30)',\n    defaultPrinter: 'デフォルトプリンター',\n    defaultPrinterDescription: 'アップロード、再印刷、その他の操作でこのプリンターを事前選択します。',\n    slicerBambuStudio: 'Bambu Studio',\n    slicerOrcaSlicer: 'OrcaSlicer',\n    sidebarOrderDescription: 'サイドバーの項目をドラッグして並べ替え。ここでデフォルトの順序にリセット。',\n    setDefault: 'デフォルト設定',\n    sidebarOrderSetDefaultHint: 'デフォルト設定は、まだカスタマイズしていないユーザーに現在のメニュー順序を適用します。',\n    sidebarDefaultSet: 'デフォルトメニュー順序を設定しました。',\n    sidebarDefaultCleared: 'デフォルトメニュー順序をクリアしました。',\n    sidebarDefaultFailed: 'デフォルトメニュー順序の設定に失敗しました。',\n    reset: 'リセット',\n    // Appearance\n    darkMode: 'ダークモード',\n    lightMode: 'ライトモード',\n    active: '(アクティブ)',\n    background: '背景',\n    accent: 'アクセント',\n    style: 'スタイル',\n    bgNeutral: 'ニュートラル',\n    bgWarm: 'ウォーム',\n    bgCool: 'クール',\n    bgOled: 'OLEDブラック',\n    bgSlate: 'スレートブルー',\n    bgForest: 'フォレストグリーン',\n    accentGreen: 'グリーン',\n    accentTeal: 'ティール',\n    accentBlue: 'ブルー',\n    accentOrange: 'オレンジ',\n    accentPurple: 'パープル',\n    accentRed: 'レッド',\n    styleClassic: 'クラシック',\n    styleGlow: 'グロー',\n    styleVibrant: 'ビビッド',\n    themeToggleHint: 'サイドバーの太陽/月アイコンでダークモードとライトモードを切り替えます。',\n    // Archive\n    autoArchivePrints: '印刷を自動アーカイブ',\n    autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',\n    saveThumbnailsDescription: '3MFファイルからプレビュー画像を抽出して保存',\n    captureFinishPhotoDescription: '印刷完了時にプリンターカメラから写真を撮影',\n    ffmpegNotInstalled: 'ffmpegがインストールされていません',\n    ffmpegRequired: 'カメラ撮影にはffmpegが必要です。<brew>brew install ffmpeg</brew>（macOS）または<apt>apt install ffmpeg</apt>（Linux）でインストールしてください。',\n    // Camera\n    camera: 'カメラ',\n    cameraViewMode: 'カメラ表示モード',\n    cameraOverlayDescription: 'メイン画面上にリサイズ可能なオーバーレイでカメラを表示',\n    cameraWindowDescription: '別のブラウザウィンドウでカメラを表示',\n    externalCamerasDescription: '内蔵プリンターカメラの代わりに外部カメラを設定。MJPEGストリーム、RTSP、HTTPスナップショット、USBカメラ（V4L2）をサポート。有効にすると、ライブビューと完了写真に外部カメラが使用されます。',\n    cameraPlaceholderUsb: 'デバイスパス (/dev/video0)',\n    cameraPlaceholderUrl: 'カメラURL (rtsp://... または http://...)',\n    cameraTypeMjpeg: 'MJPEGストリーム',\n    cameraTypeRtsp: 'RTSPストリーム',\n    cameraTypeSnapshot: 'HTTPスナップショット',\n    cameraTypeUsb: 'USBカメラ (V4L2)',\n    cameraRotation: '回転',\n    test: 'テスト',\n    connected: '接続済み',\n    disconnected: '未接続',\n    // Cost tracking\n    currency: '通貨',\n    defaultFilamentCost: 'デフォルトフィラメントコスト（kg単価）',\n    electricityCost: '電気料金（kWh単価）',\n    energyDisplayMode: 'エネルギー表示モード',\n    energyModePrintDescription: 'ダッシュボードに印刷中の消費エネルギーの合計を表示',\n    energyModeTotalDescription: 'ダッシュボードにスマートプラグの累計エネルギーを表示',\n    // File Manager\n    fileManager: 'ファイルマネージャー',\n    createArchiveEntry: '印刷時にアーカイブエントリを作成',\n    createArchiveEntryDescription: 'ファイルマネージャーから印刷時に、オプションでアーカイブエントリを作成',\n    lowDiskSpaceWarning: 'ディスク容量不足の警告',\n    lowDiskSpaceDescription: '空きディスク容量がこのしきい値を下回った場合に警告を表示',\n    // Updates\n    printerFirmware: 'プリンターファームウェア',\n    checkFirmwareDescription: 'Bambu Labのプリンターファームウェア更新を確認',\n    bambuddySoftware: 'Bambuddyソフトウェア',\n    autoCheckDescription: '起動時に自動的に新しいバージョンを確認',\n    checkNow: '今すぐ確認',\n    updateAvailableVersion: 'アップデート利用可能: v{{version}}',\n    releaseNotes: 'リリースノート',\n    updateViaDocker: 'Docker Composeでアップデート:',\n    installUpdate: 'アップデートをインストール',\n    latestVersionRunning: '最新バージョンを使用しています',\n    failedToCheckUpdates: 'アップデートの確認に失敗しました: {{error}}',\n    // Data Management\n    backupRestore: 'バックアップと復元',\n    backupRestoreDescription: '設定のエクスポート/インポートとGitHubバックアップの設定',\n    goToBackup: 'バックアップへ',\n    // Network tab\n    externalUrl: '外部URL',\n    externalUrlDescription: 'Bambuddyがアクセス可能な外部URL。通知画像や外部連携に使用されます。',\n    bambuddyUrl: 'Bambuddy URL',\n    externalUrlHint: 'プロトコルとポートを含めてください（例: http://192.168.1.100:8000）',\n    ftpRetry: 'FTPリトライ',\n    ftpRetryDescription: 'プリンターのWi-Fiが不安定な場合にFTP操作をリトライ。3MFダウンロード、印刷アップロード、タイムラプスダウンロード、ファームウェア更新に適用。',\n    autoRetryDescription: '失敗したFTP操作を自動的にリトライ',\n    retryAttempts: 'リトライ回数',\n    retryDelay: 'リトライ遅延',\n    connectionTimeout: '接続タイムアウト',\n    time_one: '{{count}}回',\n    time_other: '{{count}}回',\n    second_one: '{{count}}秒',\n    second_other: '{{count}}秒',\n    nSeconds: '{{count}}秒',\n    increaseForWeakWifi: 'Wi-Fiが弱いプリンター用に増やしてください',\n    // Home Assistant\n    homeAssistant: 'Home Assistant',\n    homeAssistantFullDescription: 'Home Assistantに接続してHA REST API経由でスマートプラグを制御。switch、light、input_boolean、scriptエンティティをサポート。',\n    homeAssistantUrl: 'Home Assistant URL',\n    longLivedAccessToken: '長期アクセストークン',\n    haTokenHint: 'HAでトークンを作成: プロフィール → 長期アクセストークン → トークンを作成',\n    connectionSuccessful: '接続成功',\n    connectionFailed: '接続失敗',\n    haConnectionSuccess: 'Home Assistantへの接続に成功しました。',\n    haConnectionFailed: 'Home Assistantへの接続に失敗しました。',\n    // MQTT\n    mqttPublishing: 'MQTTパブリッシュ',\n    mqttDescription: 'Node-RED、Home Assistant、その他の自動化システムとの統合のため、外部MQTTブローカーにBamBuddyイベントをパブリッシュ。',\n    mqttEnableDescription: '外部MQTTブローカーにイベントをパブリッシュ',\n    brokerHostname: 'ブローカーホスト名',\n    port: 'ポート',\n    usernameOptional: 'ユーザー名（オプション）',\n    passwordOptional: 'パスワード（オプション）',\n    topicPrefix: 'トピックプレフィックス',\n    topicPrefixHint: 'トピック形式: {{prefix}}/printers/<serial>/status 等',\n    // Prometheus\n    prometheusMetrics: 'Prometheusメトリクス',\n    prometheusEndpointDescription: 'Prometheus/Grafanaモニタリング用に<code>/api/v1/metrics</code>でプリンターメトリクスを公開。',\n    bearerTokenOptional: 'Bearerトークン（オプション）',\n    bearerTokenHint: '設定時、リクエストに<code>Authorization: Bearer <token></code>が必要',\n    metricsConnectionStatus: '接続状態',\n    metricsPrinterState: 'プリンター状態（idle/printing等）',\n    metricsPrintProgress: '印刷進捗 0-100%',\n    metricsBedTemp: 'ベッド温度',\n    metricsNozzleTemp: 'ノズル温度',\n    metricsPrintsTotal: '結果別の総印刷数',\n    metricsMore: '...その他（レイヤー、ファン、キュー、フィラメント使用量）',\n    // Smart Plugs\n    smartPlugsDescription: 'スマートプラグ（TasmotaまたはHome Assistant）を接続して、電源制御の自動化とプリンターのエネルギー使用量を追跡。',\n    allOn: 'すべてオン',\n    allOff: 'すべてオフ',\n    addSmartPlug: 'スマートプラグを追加',\n    energySummary: 'エネルギー概要',\n    currentPower: '現在の消費電力',\n    plugsOnline: '{{reachable}}/{{total}}プラグオンライン',\n    today: '今日',\n    yesterday: '昨日',\n    total: '合計',\n    enablePlugsForSummary: 'プラグを有効にしてエネルギー概要を表示',\n    addNotificationProvider: '追加',\n    // Users\n    systemBadge: '(システム)',\n    creating: '作成中...',\n    changing: '変更中...',\n    deleteUserAndItems: 'ユーザーとそのアイテムを削除',\n    deleteUserKeepItems: 'ユーザーを削除、アイテムは保持（オーナーなしになります）',\n    ok: 'OK',\n\n    // 2FA settings\n    twoFa: {\n      totpTitle: '認証アプリ (TOTP)',\n      totpDesc: 'Google Authenticator、Aegis、Authyなどのアプリを使用します。',\n      emailOtpTitle: 'メールOTP',\n      emailOtpDesc: 'ログイン時に{{email}}にワンタイムコードを送信します。',\n      emailOtpNoEmail: 'この方法を有効にするには、アカウントにメールアドレスを追加してください。',\n      addEmailFirst: 'アカウントにメールアドレスがありません。管理者に追加を依頼してください。',\n      setupTotp: '認証アプリを設定',\n      setupAuthApp: '認証アプリを設定',\n      setupInstructions: '認証アプリでQRコードをスキャンし、コードで確認してください。',\n      manualEntry: 'スキャンできない場合は、このシークレットを手動で入力してください:',\n      scannedContinue: 'コードをスキャンしました — 続ける',\n      enterCodeToConfirm: '認証アプリの6桁のコードを入力して設定を確認してください。',\n      activate: '有効化',\n      disableTotp: '認証アプリを無効化',\n      disableConfirmHint: '認証アプリを無効にするには、有効なTOTPコードまたはバックアップコードを入力してください。',\n      totpDisabled: '認証アプリが無効化されました。',\n      emailOtpEnabled: 'メールOTPが有効化されました。',\n      emailOtpDisabled: 'メールOTPが無効化されました。',\n      smtpRequired: '先にSMTP設定を構成してテストしてください。',\n      invalidCode: '無効なコードです。もう一度お試しください。',\n      enableEmailOtp: 'メールOTPを有効化',\n      disableEmailOtp: 'メールOTPを無効化',\n      emailSetupEnterCode: '確認コードがメールアドレスに送信されました。このメールボックスを所有していることを確認するために、以下に入力してください。',\n      verifyAndEnable: '確認して有効化',\n      emailDisablePasswordHint: 'メールOTPの無効化を確認するには、アカウントのパスワードを入力してください。',\n      passwordPlaceholder: 'パスワードを入力してください',\n      backupCodesTitle: 'バックアップコードを保存',\n      backupCodesWarning: 'これらのコードを安全な場所に保存してください。各コードは一度しか使用できません。',\n      backupCodesRemaining: 'バックアップコード残り{{count}}個',\n      savedCodes: 'コードを保存しました',\n      regenBackup: 'バックアップコードを再生成',\n      regenBackupHint: '現在のTOTPコードを入力して10個の新しいバックアップコードを生成します。',\n      newBackupCodes: '新しいバックアップコード',\n      linkedAccounts: 'リンクされたSSOアカウント',\n      linkedAccountsDesc: 'これらの外部IDプロバイダーがあなたのアカウントにリンクされています。',\n      oidcUnlinked: 'アカウントのリンクを解除しました。',\n    },\n\n    // OIDC provider settings\n    oidc: {\n      title: 'SSO / OIDCプロバイダー',\n      desc: 'シングルサインオン用のOpenID Connectプロバイダーを設定します。',\n      addProvider: 'プロバイダーを追加',\n      newProvider: '新しいプロバイダー',\n      empty: 'OIDCプロバイダーがまだ設定されていません。',\n      created: 'プロバイダーが作成されました。',\n      updated: 'プロバイダーが更新されました。',\n      deleted: 'プロバイダーが削除されました。',\n      deleteTitle: 'プロバイダーを削除',\n      deleteMessage: '\"{{name}}\"を削除しますか？リンクされたすべてのユーザーアカウントが切断されます。',\n      form: {\n        name: '表示名',\n        issuerUrl: '発行者URL',\n        clientId: 'クライアントID',\n        clientSecret: 'クライアントシークレット',\n        scopes: 'スコープ',\n        iconUrl: 'アイコンURL (任意)',\n        enabled: '有効',\n        autoCreate: 'ユーザーを自動作成',\n        autoCreateDesc: '初回ログイン時にローカルアカウントを自動的に作成します。',\n        autoLink: '既存アカウントを自動リンク',\n        autoLinkDesc: '初回ログイン時にメールアドレスで既存のローカルアカウントにリンクします。',\n        secretHint: '空白のままで現在のものを維持',\n        secretPlaceholder: '新しいシークレット',\n      },\n    },\n\n  },\n\n  // Notifications (for push notifications)\n  notification: {\n    printStarted: {\n      title: '印刷開始',\n      body: '{{printer}}: {{filename}} の印刷を開始しました',\n    },\n    printCompleted: {\n      title: '印刷完了',\n      body: '{{printer}}: {{filename}} が正常に完了しました',\n    },\n    printFailed: {\n      title: '印刷失敗',\n      body: '{{printer}}: {{filename}} が失敗しました',\n    },\n    printStopped: {\n      title: '印刷中止',\n      body: '{{printer}}: {{filename}} が中止されました',\n    },\n    printProgress: {\n      title: '印刷進捗',\n      body: '{{printer}}: {{filename}} は {{percent}}% 完了',\n    },\n    printerOffline: {\n      title: 'プリンターオフライン',\n      body: '{{printer}} がオフラインです',\n    },\n    printerError: {\n      title: 'プリンターエラー',\n      body: '{{printer}}: {{error}}',\n    },\n    filamentLow: {\n      title: 'フィラメント残量低下',\n      body: '{{printer}}: フィラメントが残りわずかです',\n    },\n    maintenanceDue: {\n      title: 'メンテナンス期限',\n      body: '{{printer}}: {{items}} の対応が必要です',\n    },\n  },\n\n  // Errors\n  errors: {\n    generic: '問題が発生しました',\n    networkError: 'ネットワークエラーです。接続を確認してください。',\n    notFound: '見つかりません',\n    unauthorized: '認証エラー',\n    serverError: 'サーバーエラー',\n    validationError: '入力内容を確認してください',\n    printerConnectionFailed: 'プリンターへの接続に失敗しました',\n    saveFailed: '保存に失敗しました',\n    deleteFailed: '削除に失敗しました',\n    loadFailed: 'データの読み込みに失敗しました',\n  },\n\n  // HMS Errors modal\n  hmsErrors: {\n    title: 'エラー - {{name}}',\n    noErrors: 'エラーなし',\n    viewOnWiki: 'Bambu Lab Wikiで表示',\n    clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',\n    clearErrors: 'エラーをクリア',\n    clearSuccess: 'HMSエラーをクリアしました',\n    clearFailed: 'HMSエラーのクリアに失敗しました',\n  },\n\n  // MQTT Debug modal\n  mqttDebug: {\n    title: 'MQTTデバッグログ',\n    searchPlaceholder: 'トピックまたはペイロードで検索...',\n    noMessages: 'まだメッセージが記録されていません',\n    startLoggingHint: '「ログ開始」をクリックしてMQTTメッセージのキャプチャを開始',\n    noMessagesMatch: 'フィルターに一致するメッセージがありません',\n    adjustFilterHint: '検索条件やフィルター条件を調整してみてください',\n    incoming: '受信',\n    outgoing: '送信',\n    loggingStopped: 'ログ記録停止',\n    loggingActive: 'ログ記録中 - メッセージは自動更新されます',\n    startLogging: 'ログ記録を開始',\n    stopLogging: 'ログ停止',\n    clearLog: 'ログをクリア',\n    topic: 'トピック',\n    timestamp: 'タイムスタンプ',\n    direction: '方向',\n    all: 'すべて',\n  },\n\n  // Printer File Manager modal (printer internal storage)\n  printerFiles: {\n    title: 'ファイル管理',\n    storageUsed: '使用中:',\n    storageFree: '空き:',\n    filterPlaceholder: 'ファイルを検索...',\n    deleteButton: '削除',\n    deleteFiles: '{{count}}件のファイルを削除',\n    deleteFileConfirm: 'このファイルを削除しますか？',\n    deleteFilesConfirm: '選択した{{count}}件のファイルを削除しますか？元に戻せません。',\n    noFiles: 'このディレクトリにファイルがありません',\n    loadingFiles: 'ファイルを読み込み中...',\n    failedToLoad: 'ファイルの読み込みに失敗しました',\n    toast: {\n      filesDeleted: '{{count}}件のファイルを削除しました',\n      deleteFailed: '削除に失敗: {{error}}',\n    },\n  },\n\n  // Confirmations\n  confirm: {\n    delete: '削除しますか？',\n    unsavedChanges: '保存されていない変更があります。このページを離れますか？',\n    clearQueue: 'キューをクリアしますか？',\n  },\n\n  // Login page\n  login: {\n    title: 'Bambuddy ログイン',\n    subtitle: 'アカウントにサインイン',\n    username: 'ユーザー名',\n    usernamePlaceholder: 'ユーザー名を入力',\n    usernameOrEmail: 'ユーザー名またはメール',\n    usernameOrEmailPlaceholder: 'ユーザー名または @ メール',\n    password: 'パスワード',\n    passwordPlaceholder: 'パスワードを入力',\n    signIn: 'サインイン',\n    signingIn: 'ログイン中...',\n    forgotPassword: 'パスワードをお忘れですか？',\n    loginSuccess: 'ログインしました',\n    loginFailed: 'ログインに失敗しました',\n    enterCredentials: 'ユーザー名とパスワードを入力してください',\n    enterEmail: 'メールアドレスを入力してください',\n    oidcLoginFailed: 'OIDCログインに失敗しました',\n    oidcErrors: {\n      providerError: 'IDプロバイダーがエラーを返しました',\n      missingParameters: 'OIDCコールバックに必須パラメーターがありません',\n      invalidState: 'OIDCの状態が無効か、すでに使用されています',\n      stateExpired: 'OIDCログインセッションが期限切れです。もう一度お試しください',\n      providerNotFound: 'OIDCプロバイダーが見つかりません',\n      discoveryFailed: 'OIDCディスカバリードキュメントの取得に失敗しました',\n      invalidDiscovery: 'OIDCディスカバリードキュメントが無効です',\n      networkError: 'OIDCトークン交換中にネットワークエラーが発生しました',\n      badResponse: 'OIDCトークン交換中に予期しない応答を受信しました',\n      noIdToken: 'OIDCプロバイダーがIDトークンを返しませんでした',\n      validationFailed: 'OIDCトークンの検証に失敗しました',\n      nonceMismatch: 'OIDCノンスが一致しません。リプレイ攻撃の可能性があります',\n      missingSubClaim: 'OIDCトークンにsubクレームがありません',\n      noLinkedAccount: 'このOIDCアイデンティティに関連付けられたローカルアカウントがありません',\n      accountInactive: 'あなたのアカウントは無効です',\n      userResolutionFailed: 'アカウントを解決できませんでした',\n      internalError: 'OIDCログイン中に内部エラーが発生しました',\n      tokenExchangeFailed: 'OIDCトークン交換に失敗しました',\n    },\n    forgotPasswordTitle: 'パスワードを忘れた場合',\n    forgotPasswordMessage: 'パスワードを忘れた場合は、システム管理者に連絡してリセットしてもらってください。',\n    forgotPasswordEmailMessage: 'メールアドレスを入力すると、新しいパスワードを送信します。',\n    emailAddress: 'メールアドレス',\n    emailPlaceholder: 'your.email@example.com',\n    cancel: 'キャンセル',\n    sending: '送信中...',\n    sendResetEmail: 'リセットメールを送信',\n    howToReset: 'パスワードのリセット方法：',\n    resetStep1: 'Bambuddy管理者に連絡',\n    resetStep2: 'ユーザー管理でパスワードリセットを依頼',\n    resetStep3: '管理者が新しい仮パスワードを設定',\n    resetStep4: '新しいパスワードでログインし、設定で変更',\n    gotIt: '了解',\n    twoFA: {\n      title: '二段階認証',\n      subtitle: 'アカウントは二段階認証で保護されています。確認コードを入力してください。',\n      methodAuthenticator: '認証アプリ',\n      methodEmail: 'メール認証',\n      methodBackup: 'バックアップコード',\n      instructionsTotp: '認証アプリを開いて、Bambuddy用の6桁のコードを入力してください。',\n      instructionsEmail: '6桁の確認コードをメールアドレスに送信しました。有効期限は10分です。',\n      instructionsEmailNotSent: '下のボタンをクリックして、メールで確認コードを受け取ってください。',\n      instructionsBackup: '8文字のバックアップコードをいずれか1つ入力してください。各コードは1回のみ使用可能です。',\n      sendCodeButton: 'メールでコードを送信する',\n      sendingCode: '送信中...',\n      resendCode: 'コードを再送する',\n      codeLabel: '確認コード',\n      backupCodeLabel: 'バックアップコード',\n      codePlaceholder: '000000',\n      backupCodePlaceholder: 'XXXXXXXX',\n      verifyButton: '確認する',\n      verifyingButton: '確認中...',\n      backToLogin: '← ログイン画面に戻る',\n      orContinueWith: 'または以下でログイン',\n      signInWith: '{{provider}}でログイン',\n      enterCode: '確認コードを入力してください',\n      sendCodeFailed: '確認コードの送信に失敗しました',\n      invalidCode: '無効なコードです。もう一度お試しください。',\n    },\n\n  },\n\n  // Setup page\n  setup: {\n    title: 'Bambuddy セットアップ',\n    subtitle: 'Bambuddyインスタンスの認証を設定',\n    enableAuth: '認証を有効化',\n    adminAccount: '管理者アカウント',\n    adminAccountDesc: '既に管理者ユーザーが存在する場合、既存の管理者アカウントを使用して認証が有効化されます。既存の管理者を使用する場合は下のフィールドを空のままにするか、新しい認証情報を入力して新しい管理者ユーザーを作成してください。',\n    adminUsername: '管理者ユーザー名',\n    adminPassword: '管理者パスワード',\n    optionalIfAdminExists: '（管理者ユーザーが存在する場合は任意）',\n    adminUsernamePlaceholder: '管理者ユーザー名を入力（任意）',\n    adminPasswordPlaceholder: '管理者パスワードを入力（任意）',\n    confirmPassword: 'パスワードの確認',\n    confirmPasswordPlaceholder: 'パスワードを確認',\n    settingUp: 'セットアップ中...',\n    completeSetup: 'セットアップを完了',\n    toast: {\n      authEnabledAdminCreated: '認証が有効になり、管理者ユーザーが作成されました',\n      authEnabledExistingAdmins: '既存の管理者ユーザーを使用して認証が有効になりました',\n      setupCompleted: 'セットアップが完了しました',\n      enterBothCredentials: '管理者のユーザー名とパスワードの両方を入力するか、既存の管理者を使用する場合は両方を空にしてください',\n      passwordsDoNotMatch: 'パスワードが一致しません',\n      passwordTooShort: 'パスワードは6文字以上必要です',\n    },\n  },\n\n  // Password change\n  changePassword: {\n    title: 'パスワードを変更',\n    currentPassword: '現在のパスワード',\n    currentPasswordPlaceholder: '現在のパスワードを入力',\n    newPassword: '新しいパスワード',\n    newPasswordPlaceholder: '新しいパスワードを入力（6文字以上）',\n    confirmPassword: '新しいパスワード確認',\n    confirmPasswordPlaceholder: 'パスワードを確認',\n    passwordsDoNotMatch: 'パスワードが一致しません',\n    passwordTooShort: 'パスワードは6文字以上必要です',\n    changing: '変更中...',\n    success: 'パスワードを変更しました',\n    failed: 'パスワードの変更に失敗しました',\n  },\n\n  // Plate detection alert\n  plateAlert: {\n    title: '印刷が一時停止されました！',\n    message: 'ビルドプレート上にオブジェクトが検出されました。印刷が自動的に一時停止されました。プレートをクリアして印刷を再開してください。',\n    understand: '了解',\n  },\n\n  // Camera page\n  camera: {\n    title: 'カメラビュー',\n    invalidPrinterId: '無効なプリンターID',\n    live: 'ライブ',\n    snapshot: 'スナップショット',\n    restartStream: 'ストリームを再開',\n    refreshSnapshot: 'スナップショットを更新',\n    fullscreen: 'フルスクリーン',\n    exitFullscreen: 'フルスクリーンを終了',\n    connectingToCamera: 'カメラに接続中...',\n    capturingSnapshot: 'スナップショットを撮影中...',\n    connectionLost: '接続が切断されました',\n    connectionFailed: 'カメラ接続に失敗しました',\n    reconnecting: '{{countdown}}秒後に再接続... (試行 {{attempt}}/{{max}})',\n    reconnectNow: '今すぐ再接続',\n    cameraUnavailable: 'カメラが利用できません',\n    cameraUnavailableDesc: 'プリンターの電源がオンで接続されていることを確認してください。',\n    noCamera: 'カメラがありません',\n    retry: '再試行',\n    cameraStream: 'カメラストリーム',\n    zoomOut: 'ズームアウト',\n    zoomIn: 'ズームイン',\n    resetZoom: 'ズームをリセット',\n    recording: '録画中',\n    startRecording: '録画開始',\n    stopRecording: '録画停止',\n    chamberLight: 'チャンバーライト切替',\n  },\n\n  // Groups management\n  groups: {\n    title: 'グループ管理',\n    subtitle: 'アクセス制御の権限グループを管理',\n    backToSettings: '設定に戻る',\n    createGroup: 'グループを作成',\n    noPermission: 'このページにアクセスする権限がありません。',\n    system: 'システム',\n    noDescription: '説明なし',\n    usersCount: '{{count}}人のユーザー',\n    permissionsCount: '{{count}}個の権限',\n    edit: '編集',\n    delete: '削除',\n    toast: {\n      created: 'グループを作成しました',\n      updated: 'グループを更新しました',\n      deleted: 'アーカイブを削除しました',\n      enterGroupName: 'グループ名を入力',\n    },\n    modal: {\n      editGroup: 'グループを編集',\n      createGroup: 'グループを作成',\n      cancel: 'キャンセル',\n      saving: '保存中...',\n      creating: '作成中...',\n      saveChanges: '変更を保存',\n    },\n    form: {\n      groupName: 'グループ名',\n      groupNamePlaceholder: 'グループ名を入力',\n      systemGroupWarning: 'システムグループ名は変更できません',\n      description: '説明',\n      descriptionPlaceholder: '説明を入力（任意）',\n      permissions: '権限',\n    },\n    deleteModal: {\n      title: 'グループを削除',\n      message: 'このグループを削除しますか？このグループのユーザーはこれらの権限を失います。',\n      confirm: '確認',\n    },\n    editor: {\n      title: 'グループを編集',\n      createTitle: 'グループを作成',\n      search: '権限を検索...',\n      selectAll: 'すべて選択',\n      clearAll: 'すべて解除',\n      permissionsSelected: '{{count}}件選択',\n      noResults: '検索に一致する権限がありません',\n    },\n  },\n\n  // Users management\n  users: {\n    title: 'ユーザー管理',\n    subtitle: 'ユーザーとBambuddyインスタンスへのアクセスを管理',\n    backToSettings: '設定に戻る',\n    createUser: 'ユーザーを作成',\n    noPermission: 'このページにアクセスする権限がありません。',\n    admin: '管理者',\n    noGroups: 'グループなし',\n    active: 'アクティブ',\n    inactive: '非アクティブ',\n    edit: '編集',\n    delete: '削除',\n    system: 'システム',\n    noGroupsAvailable: '利用可能なグループがありません',\n    table: {\n      username: 'ユーザー名',\n      groups: 'グループ',\n      status: 'ステータス',\n      actions: 'アクション',\n    },\n    toast: {\n      created: 'ユーザーを作成しました',\n      updated: 'ユーザーを更新しました',\n      deleted: 'アーカイブを削除しました',\n      fillRequired: '必須項目をすべて入力してください',\n      passwordsDoNotMatch: 'パスワードが一致しません',\n      passwordTooShort: 'パスワードは6文字以上必要です',\n    },\n    modal: {\n      createUser: 'ユーザーを作成',\n      editUser: 'ユーザーを編集',\n      cancel: 'キャンセル',\n      creating: '作成中...',\n      saving: '保存中...',\n      saveChanges: '変更を保存',\n      advancedAuthSubtitle: '高度な認証を使用',\n    },\n    form: {\n      username: 'ユーザー名',\n      usernamePlaceholder: 'ユーザー名を入力',\n      email: 'メール',\n      emailPlaceholder: 'user@example.com',\n      password: 'パスワード',\n      passwordPlaceholder: 'パスワードを入力',\n      confirmPassword: 'パスワードの確認',\n      confirmPasswordPlaceholder: 'パスワードを確認',\n      newPasswordPlaceholder: '新しいパスワードを入力',\n      confirmNewPasswordPlaceholder: '新しいパスワードを確認',\n      leaveBlankToKeep: '（現在のパスワードを維持する場合は空白）',\n      groups: 'グループ',\n      optional: 'オプション',\n      autoGeneratedPassword: '安全なパスワードが自動的に生成され、ユーザーにメールで送信されます。',\n      passwordManagedByAdvancedAuth: 'パスワードは高度な認証によって管理されています。「パスワードのリセット」を使用して、メールで新しいパスワードをユーザーに送信してください。',\n      resetPassword: 'パスワードのリセット',\n      resettingPassword: 'パスワードをリセット中...',\n    },\n    deleteModal: {\n      title: 'ユーザーを削除',\n      message: 'このユーザーを削除してもよろしいですか？この操作は元に戻せません。',\n      confirm: 'ユーザーを削除',\n    },\n  },\n\n  // Stream overlay\n  streamOverlay: {\n    title: 'ストリームオーバーレイ',\n    invalidPrinterId: '無効なプリンターID',\n    cameraStream: 'カメラストリーム',\n    progress: '進捗',\n    eta: '予想時間 {{minutes}} 分',\n    printerIdle: 'プリンター待機中',\n    printerOffline: 'プリンターオフライン',\n    status: {\n      printing: '印刷中',\n      paused: '一時停止',\n      finished: '完了',\n      failed: '失敗',\n      idle: '待機中',\n      unknown: '不明',\n    },\n  },\n\n  // Profiles\n  profiles: {\n    title: 'フィラメントプロファイル',\n    subtitle: 'スライサープリセットと圧力キャリブレーションの管理',\n    tabs: {\n      cloud: 'クラウドプロファイル',\n      local: 'ローカルプロファイル',\n      kprofiles: 'Kプロファイル',\n    },\n    localProfiles: {\n      title: 'ローカルプロファイル',\n      subtitle: 'OrcaSlicerからスライサープリセットをインポート・管理',\n      import: 'プロファイルをインポート',\n      importDesc: '.bbscfg、.bbsflmt、.orca_filament、.zip、.jsonファイルをここにドロップ',\n      importing: 'インポート中...',\n      search: 'ローカルプリセットを検索...',\n      noPresets: 'ローカルプリセットがまだありません',\n      badge: 'ローカル',\n      edit: '編集',\n      delete: '削除',\n      cancel: 'キャンセル',\n      deleteConfirmTitle: 'プリセットを削除',\n      deleteConfirm: 'このプリセットを削除してもよろしいですか？元に戻せません。',\n      source: 'ソース',\n      inheritsFrom: '継承元',\n      filamentType: 'タイプ',\n      vendor: 'メーカー',\n      compatiblePrinters: 'プリンター',\n      nozzleTemp: 'ノズル温度',\n      cost: 'コスト',\n      density: '密度',\n      pressureAdvance: 'プレッシャーアドバンス',\n      filament: 'フィラメント',\n      process: 'プロセス',\n      printer: 'プリンター',\n      toast: {\n        importSuccess: '{{count}}件のプリセットをインポートしました',\n        importSkipped: '{{count}}件のプリセットをスキップしました（重複）',\n        importError: 'インポート中に{{count}}件のエラーが発生しました',\n        deleted: 'プリセットを削除しました',\n        updated: 'プリセットを更新しました',\n      },\n    },\n    connectedAs: '接続中:',\n    logout: 'ログアウト',\n    noLogoutPermission: 'ログアウトする権限がありません',\n    failedToLoad: 'ファイルの読み込みに失敗しました',\n    retry: 'リトライ',\n    time: {\n      justNow: 'たった今',\n      minsAgo: '{{count}}分前',\n      hoursAgo: '{{count}}時間前',\n      daysAgo: '{{count}}日前',\n    },\n    toast: {\n      loggedOut: 'ログアウトしました',\n    },\n    login: {\n      title: 'Bambuddy ログイン',\n      subtitle: 'アカウントにサインイン',\n      email: 'メールアドレス',\n      password: 'パスワード',\n      region: 'リージョン',\n      regionGlobal: 'グローバル',\n      regionChina: '中国',\n      verificationCode: '認証コード',\n      totpCode: '認証アプリコード',\n      checkEmail: 'メール ({{email}}) に届いた6桁のコードを入力してください',\n      enterTotpHint: '認証アプリの6桁のコードを入力してください',\n      accessToken: 'アクセストークン',\n      accessTokenHint: 'Bambu Labのアクセストークンを貼り付け（Bambu Studioから取得）',\n      back: '戻る',\n      loginButton: 'ログイン',\n      verifyButton: '認証',\n      setTokenButton: 'トークンを設定',\n      useToken: 'アクセストークンを使用',\n      useEmail: 'メールでログイン',\n      toast: {\n        loggedIn: 'ログインしました',\n        codeSent: 'メールに認証コードを送信しました',\n        enterTotp: '認証アプリのコードを入力してください',\n        tokenSet: 'トークンを設定しました',\n      },\n    },\n    presets: {\n      myPreset: 'マイプリセット（編集可能）',\n      duplicate: '複製',\n      editable: '編集可能',\n      failedToLoadDetails: 'プリセットの詳細を読み込めませんでした',\n      deleteConfirm: 'このプロファイルを削除しますか？',\n      deleteWarning: '「{{name}}」をBambu Cloudから完全に削除します。元に戻せません。',\n      noDuplicatePermission: 'プリセットを複製する権限がありません',\n      noEditPermission: 'プリセットを編集する権限がありません',\n      noDeletePermission: 'プロジェクトを削除する権限がありません',\n      types: {\n        filament: 'フィラメント',\n        printer: 'プリンター',\n        process: 'プロセス',\n      },\n      toast: {\n        deleted: 'アーカイブを削除しました',\n        created: 'プリセットを作成しました',\n        updated: 'プリセットを更新しました',\n        duplicated: 'プリセットを複製しました',\n        fieldAdded: 'フィールド \"{{key}}\" を追加しました',\n        exported: 'プリセットをエクスポートしました',\n      },\n      baseLabel: 'ベース: {{name}}',\n      currentLabel: '現在: {{name}}',\n      newPreset: '新規プリセット',\n      editPreset: 'プリセットを編集',\n      duplicatePreset: 'プリセットを複製',\n      createNewPreset: '新しいプリセットを作成',\n      customizeSettings: '新しいプリセットの設定をカスタマイズ',\n      compareWithBase: 'ベースプリセットと比較',\n      compare: '比較',\n      // CreatePresetModal - Basic Info\n      basePreset: 'ベースプリセット',\n      selectBasePreset: 'ベースプリセットを選択...',\n      presetName: 'プリセット名',\n      myCustomPreset: 'カスタムプリセット',\n      inheritsFrom: '継承元:',\n      dropJsonToImport: 'JSONファイルをドロップしてインポート',\n      // CreatePresetModal - Tabs\n      tabs: {\n        common: '一般',\n        allFields: 'すべてのフィールド',\n      },\n      // CreatePresetModal - All Fields Tab\n      availableFields: '利用可能なフィールド',\n      searchFieldsPlaceholder: 'フィールドを検索...',\n      noMatchingFields: '一致するフィールドがありません',\n      allFieldsAdded: 'すべてのフィールドが追加済みです',\n      addCustomField: 'カスタムフィールドを追加',\n      yourOverrides: 'オーバーライド一覧',\n      noOverridesYet: 'オーバーライドはまだありません',\n      clickFieldsToAdd: '左のフィールドをクリックして追加',\n      saveAsTemplate: 'テンプレートとして保存',\n      jsonTip: 'ヒント: .jsonファイルをこのモーダルにドラッグ＆ドロップして設定をインポート',\n    },\n    cloudView: {\n      searchPlaceholder: 'プリセットを検索...',\n      templates: 'テンプレート',\n      refresh: '更新',\n      newPreset: '新規プリセット',\n      clearFilters: 'フィルターをクリア',\n      // Compare mode\n      compareMode: '比較モード',\n      selectAnotherPreset: '同じタイプ（{{type}}）の別のプリセットを選択',\n      clickTwoPresets: '同じタイプのプリセットを2つクリックして比較',\n      selectFirst: '1. 最初を選択',\n      selectSecond: '2. 2番目を選択',\n      compareNow: '比較を実行',\n      // Status row\n      lastSynced: '最終同期:',\n      showingCount: '{{total}}件中{{shown}}件を表示',\n      noPresetsFound: 'プリセットが見つかりません',\n      // Column headers\n      columns: {\n        filament: 'フィラメント',\n        process: 'プロセス',\n        printer: 'プリンター',\n      },\n      noFilamentPresets: 'フィラメントプリセットなし',\n      noProcessPresets: 'プロセスプリセットなし',\n      noPrinterPresets: 'プリンタープリセットなし',\n      // Filters\n      filters: {\n        type: '種類',\n        owner: '所有者',\n        printer: 'プリンター',\n        nozzle: 'ノズル',\n        filament: 'フィラメント',\n        layer: 'レイヤー',\n        all: 'すべて',\n        myPresets: 'マイプリセット',\n        builtIn: 'ビルトイン',\n        process: 'プロセス',\n      },\n      // Permissions\n      noTemplatesPermission: 'テンプレートを管理する権限がありません',\n      noRefreshPermission: 'プロファイルを更新する権限がありません',\n      noCreatePermission: 'プロジェクトを作成する権限がありません',\n    },\n    templates: {\n      title: 'フィラメントプロファイル',\n      noTemplates: 'テンプレートがありません。バックエンドを再起動してデフォルトテンプレートを作成してください。',\n      createFirst: 'プリセットエディタからテンプレートを作成',\n      typeFilter: 'タイプ:',\n      deleteTitle: 'テンプレートを削除',\n      deleteWarning: 'この操作は元に戻せません',\n      deleteConfirm: 'このプロファイルを削除しますか？',\n      namePlaceholder: 'テンプレート名',\n      descriptionPlaceholder: '説明',\n      settingsJson: '設定 (JSON)',\n      fieldsCount: '{{count}}フィールド',\n      shownInModals: 'モーダルに表示',\n      hiddenInModals: 'モーダルで非表示',\n      apply: '適用',\n      toast: {\n        deleted: 'アーカイブを削除しました',\n        updated: 'テンプレートを更新しました',\n        created: 'テンプレートを作成しました',\n        applied: 'テンプレートを適用しました',\n      },\n    },\n  },\n\n  // Support/Debug\n  support: {\n    debugLoggingActive: 'デバッグログが有効です',\n    manageLogs: '管理',\n    collectItem7: 'プリンター接続状態とファームウェアバージョン',\n    collectItem8: '連携状態（Spoolman、MQTT、HA）',\n    collectItem9: 'ネットワークインターフェース（サブネットのみ）',\n    collectItem10: 'Pythonパッケージバージョン',\n    collectItem11: 'データベース健全性チェック',\n    collectItem12: 'Docker環境の詳細',\n  },\n\n  // File manager\n  fileManager: {\n    title: 'ファイル管理',\n    subtitle: '印刷ファイルの整理と管理',\n    uploadFiles: 'ファイルをアップロード',\n    newFolder: '新しいフォルダ',\n    folderName: 'フォルダ名',\n    folderNamePlaceholder: '例: 機能パーツ',\n    renameFile: 'ファイル名を変更',\n    renameFolder: 'フォルダ名を変更',\n    moveFiles: '{{count}}件のファイルを移動',\n    rootNoFolder: 'ルート（フォルダなし）',\n    current: '（現在）',\n    linkFolder: 'フォルダをリンク',\n    linkFolderDescription: '「{{name}}」をプロジェクトまたはアーカイブにリンクしてすばやくアクセス。',\n    project: 'プロジェクト',\n    archive: 'アーカイブ',\n    noProjectsFound: 'プロジェクトが見つかりません',\n    noArchivesFound: 'アーカイブが見つかりません',\n    unlink: 'リンク解除',\n    link: 'リンク',\n    dragDropFiles: 'ファイルをここにドラッグ＆ドロップ',\n    dropFilesHere: 'ここにファイルをドロップ',\n    orClickToBrowse: 'またはクリックして選択',\n    allFileTypesSupported: 'すべてのファイルタイプに対応。ZIPファイルは展開されます。',\n    zipFilesDetected: 'ZIPファイルを検出',\n    zipExtractOptions: 'ZIPファイルは展開されます。フォルダー構造の処理方法を選択：',\n    preserveZipStructure: 'ZIPのフォルダ構造を保持',\n    createFolderFromZip: 'ZIPファイル名からフォルダーを作成',\n    stlThumbnailGeneration: 'STLサムネイル生成',\n    zipMayContainStl: 'ZIPファイルにSTLファイルが含まれている場合があります。展開時にサムネイルを生成できます。',\n    thumbnailsCanBeGenerated: 'STLファイルのサムネイルを生成できます。大きなモデルは処理に時間がかかる場合があります。',\n    generateThumbnailsForStl: 'STLファイルのサムネイルを生成',\n    threemfDetected: '3MFファイルを検出',\n    threemfExtractionInfo: 'プリンターモデル、素材、色、印刷設定は3MFファイルから自動的に抽出されます。',\n    willBeExtracted: '• 展開予定',\n    filesExtracted: '• {{count}}個のファイルを展開済み',\n    uploadComplete: 'アップロード完了: {{count}}個成功',\n    uploadFailed: 'アップロード失敗',\n    zipFilesFailed: '{{count}}個のファイルが失敗',\n    uploading: 'アップロード中...',\n    changeLink: 'リンクを変更...',\n    linkTo: 'リンク先...',\n    linkToProjectOrArchive: 'プロジェクトまたはアーカイブにリンク',\n    addToQueue: 'キューに追加',\n    schedulePrint: '印刷をスケジュール',\n    generateThumbnail: 'サムネイルを生成',\n    generateThumbnails: 'サムネイルを生成',\n    generateThumbnailsForMissing: 'サムネイルのないSTLファイルのサムネイルを生成',\n    gridView: 'グリッド表示',\n    listView: 'リスト表示',\n    lowDiskSpaceWarning: 'ディスク容量不足の警告',\n    lowDiskSpaceDetails: '{{total}}中{{free}}の空き容量のみ。しきい値は設定で{{threshold}}GBに設定されています。',\n    files: 'ファイル',\n    folders: 'フォルダ',\n    size: 'サイズ',\n    free: '空き:',\n    allFiles: 'すべてのファイル',\n    wrap: '折り返し',\n    enableTextWrapping: 'テキスト折り返しを有効化',\n    disableTextWrapping: 'テキスト折り返しを無効化',\n    collapse: '折りたたむ',\n    collapseFoldersByDefault: 'フォルダをデフォルトで折りたたむ',\n    expandFoldersByDefault: 'フォルダをデフォルトで展開する',\n    dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',\n    searchFiles: 'ファイルを検索...',\n    allTypes: 'すべての種類',\n    prints: '印刷回数',\n    ascending: '昇順',\n    descending: '降順',\n    resultsCount: '{{total}}件中{{showing}}件',\n    selectAll: 'すべて選択',\n    deselectAll: 'すべて選択解除',\n    selected: '{{count}}件選択中',\n    adding: '追加中...',\n    loadingFiles: 'ファイルを読み込み中...',\n    folderIsEmpty: 'フォルダーは空です',\n    noFilesYet: 'ファイルはまだありません',\n    folderEmptyDescription: 'ファイルをアップロードするか、このフォルダーにファイルを移動して開始しましょう。',\n    noFilesDescription: '印刷関連ファイルの整理を始めるにはファイルをアップロードしてください。',\n    noMatchingFiles: '一致するファイルがありません',\n    noMatchingFilesDescription: '現在の検索またはフィルター条件に一致するファイルがありません。',\n    clearFilters: 'フィルターをクリア',\n    printedCount: '{{count}}回印刷済み',\n    uploadedBy: 'アップロード者',\n    deleteFolder: 'フォルダを削除',\n    deleteFile: 'ファイルを削除',\n    deleteFilesCount: '{{count}}件のファイルを削除',\n    deleteFolderConfirm: 'このフォルダを削除しますか？中のファイルもすべて削除されます。',\n    deleteFileConfirm: 'このファイルを削除しますか？',\n    deleteFilesConfirm: '選択した{{count}}件のファイルを削除しますか？この操作は元に戻せません。',\n    deleting: '削除中...',\n    noPermissionRenameFolder: 'フォルダー名を変更する権限がありません',\n    noPermissionLinkFolder: 'フォルダーをリンクする権限がありません',\n    noPermissionDeleteFolder: 'フォルダーを削除する権限がありません',\n    noPermissionPrint: '印刷する権限がありません',\n    noPermissionAddToQueue: 'キューに追加する権限がありません',\n    noPermissionDownload: 'ファイルをダウンロードする権限がありません',\n    noPermissionRenameFile: 'このファイル名を変更する権限がありません',\n    noPermissionGenerateThumbnail: 'サムネイルを生成する権限がありません',\n    noPermissionDeleteFile: 'このファイルを削除する権限がありません',\n    noPermissionCreateFolder: 'フォルダーを作成する権限がありません',\n    noPermissionUpload: 'ファイルをアップロードする権限がありません',\n    noPermissionMoveFiles: 'ファイルを移動する権限がありません',\n    noPermissionDeleteFiles: 'ファイルを削除する権限がありません',\n    // External folder\n    linkExternal: '外部リンク',\n    linkExternalFolder: '外部フォルダをリンク',\n    linkExternalFolderDescription: 'ホストディレクトリ（NAS、USB、ネットワーク共有）をファイルマネージャにマウントします。ファイルはコピーされず、元のパスから直接アクセスされます。',\n    externalFolderNamePlaceholder: '例：NASプリント',\n    externalPath: 'ホストパス',\n    externalPathHelp: 'Dockerホスト上のディレクトリの絶対パス。コンテナにバインドマウントされている必要があります。',\n    readOnly: '読み取り専用',\n    readOnlyHelp: 'アップロードと削除を防止',\n    showHiddenFiles: '隠しファイルを表示（ドットファイル）',\n    externalFolder: '外部フォルダ',\n    scanFolder: 'スキャン',\n    toast: {\n      folderCreated: 'フォルダを作成しました',\n      folderDeleted: 'フォルダを削除しました',\n      fileDeleted: 'ファイルを削除しました',\n      filesDeleted: '{{count}}件のファイルを削除しました',\n      filesMoved: 'ファイルを移動しました',\n      folderLinked: 'フォルダをリンクしました',\n      folderUnlinked: 'フォルダのリンクを解除しました',\n      externalFolderLinked: '外部フォルダがリンクされスキャンされました',\n      folderScanned: 'スキャン完了：{{added}}件追加、{{removed}}件削除',\n      addedToQueue: '{{count}}個のファイルをキューに追加しました',\n      addedToQueuePartial: '{{added}}件追加、{{failed}}件失敗',\n      failedToAddToQueue: 'ファイルの追加に失敗: {{error}}',\n      fileRenamed: 'ファイル名を変更しました',\n      folderRenamed: 'フォルダ名を変更しました',\n      thumbnailsGenerated: '{{count}}件のサムネイルを生成しました',\n      thumbnailsGeneratedPartial: '{{succeeded}}件生成、{{failed}}件失敗',\n      noStlMissingThumbnails: 'サムネイルのないSTLファイルはありません',\n      failedToGenerateThumbnails: 'サムネイルの生成に失敗: {{error}}',\n      thumbnailGenerated: 'サムネイルを生成しました',\n      failedToGenerateThumbnail: 'サムネイルの生成に失敗: {{error}}',\n    },\n  },\n\n  // Projects\n  projects: {\n    title: 'プロジェクト',\n    subtitle: '印刷プロジェクトを管理',\n    newProject: '新規プロジェクト',\n    editProject: 'プロジェクトを編集',\n    deleteProject: 'プロジェクトを削除',\n    projectName: 'プロジェクト: {{name}}',\n    description: '説明',\n    noProjects: 'プロジェクトはまだありません',\n    noProjectsFiltered: '{{status}}のプロジェクトはありません',\n    noProjectsFilteredHelp: '{{status}}のプロジェクトがありません。ステータスが変更されるとここに表示されます。',\n    createFirst: '最初のプロジェクトを作成して、関連する印刷の整理、進捗管理、ビルドの管理を始めましょう。',\n    createFirstButton: '最初のプロジェクトを作成',\n    create: '作成',\n    files: 'ファイル',\n    prints: '印刷',\n    plates: 'プレート',\n    parts: 'パーツ',\n    lastModified: '最終更新日',\n    deleteConfirm: 'このプロジェクトを削除しますか？アーカイブとキューアイテムはリンク解除されますが、削除されません。',\n    addFiles: 'ファイルを追加',\n    removeFile: 'ファイルを削除',\n    viewDetails: '詳細を表示',\n    // Modal fields\n    namePlaceholder: 'プロジェクト名',\n    descriptionPlaceholder: 'プロジェクトの説明（任意）',\n    color: '色',\n    targetPlates: '目標プレート数',\n    targetPlatesPlaceholder: '例: 10',\n    targetPlatesHelp: '印刷ジョブの数',\n    targetParts: '目標パーツ数',\n    targetPartsPlaceholder: '例: 50',\n    targetPartsHelp: '必要なオブジェクトの総数',\n    tagsLabel: 'タグ（カンマ区切り）',\n    tagsPlaceholder: 'カンマ区切りのタグ',\n    dueDate: '期限',\n    priority: '優先度',\n    priorityLow: '低',\n    priorityNormal: '通常',\n    priorityHigh: '高',\n    priorityUrgent: '緊急',\n    // Status\n    statusActive: '進行中',\n    statusCompleted: '完了',\n    statusArchived: 'アーカイブ済み',\n    done: '完了',\n    completed: '完了',\n    failed: '失敗',\n    inQueue: 'キュー内',\n    noPrintsYet: '印刷履歴なし',\n    // Footer stats\n    printJobs: '印刷ジョブ',\n    partsPrinted: '印刷済みパーツ',\n    failedParts: '失敗パーツ',\n    // Actions\n    import: 'インポート',\n    export: 'エクスポート',\n    importProject: 'プロジェクトをインポート',\n    exportAll: 'すべてのプロジェクトをエクスポート',\n    loading: 'プロジェクトを読み込み中...',\n    // Permissions\n    noEditPermission: 'プロジェクトを編集する権限がありません',\n    noDeletePermission: 'プロジェクトを削除する権限がありません',\n    noCreatePermission: 'プロジェクトを作成する権限がありません',\n    noImportPermission: 'プロジェクトをインポートする権限がありません',\n    noExportPermission: 'プロジェクトをエクスポートする権限がありません',\n    // Toast\n    toast: {\n      created: 'プロジェクトを作成しました',\n      updated: 'プロジェクトを更新しました',\n      deleted: 'アーカイブを削除しました',\n      imported: 'プロジェクトをインポートしました',\n      multipleImported: '{{count}}件のプロジェクトをインポートしました',\n      importFailed: 'インポートに失敗しました',\n      exported: 'プロジェクトをエクスポートしました（メタデータのみ）',\n    },\n  },\n\n  // Project detail page\n  projectDetail: {\n    notFound: '見つかりません',\n    backToProjects: 'プロジェクト一覧に戻る',\n    export: 'エクスポート',\n    exportProject: 'プロジェクトをエクスポート',\n    noExportPermission: 'プロジェクトをエクスポートする権限がありません',\n    noEditPermission: 'このプロジェクトを編集する権限がありません',\n    partOf: '所属先',\n    priorityLabel: '優先度',\n    noPrints: 'このプロジェクトにはまだ印刷がありません',\n    status: {\n      active: '進行中',\n      completed: '完了',\n      archived: 'アーカイブ済み',\n    },\n    priority: {\n      low: '低',\n      normal: '通常',\n      high: '高',\n      urgent: '緊急',\n    },\n    dueDate: {\n      overdue: '期限超過',\n      today: '今日が期限',\n      daysLeft: '残り{{count}}日',\n    },\n    progress: {\n      platesProgress: 'プレート進捗',\n      partsProgress: 'パーツ進捗',\n      printJobs: '印刷ジョブ',\n      parts: 'パーツ',\n      percentComplete: '% 完了',\n      remaining: '残り',\n    },\n    stats: {\n      printJobs: '印刷ジョブ',\n      total: '合計',\n      failed: '失敗',\n      partsPrinted: '印刷済みパーツ',\n      printTime: '印刷時間',\n      filamentUsed: 'フィラメント使用量',\n    },\n    cost: {\n      title: 'コスト追跡',\n      filamentCost: 'フィラメント',\n      energy: 'エネルギー',\n      totalCost: '合計コスト',\n      total: '合計',\n      includesBom: 'BOM含む',\n      budget: '予算',\n      remaining: '残り',\n    },\n    subProjects: {\n      title: 'サブプロジェクト ({{count}})',\n    },\n    notes: {\n      title: 'メモ',\n      noEditPermission: 'このプロジェクトを編集する権限がありません',\n      placeholder: 'このプロジェクトについてメモを追加...',\n      empty: '<空>',\n    },\n    files: {\n      title: 'ファイル',\n      linkFolders: 'ファイルマネージャーからフォルダーをリンク',\n      forQuickAccess: 'してクイックアクセスできるようにします。',\n      fileCount: '{{count}}ファイル',\n      empty: '<空>',\n      noFiles: 'このフォルダにファイルはありません。',\n      print: '今すぐ印刷',\n      addToQueue: 'キューに追加',\n    },\n    bom: {\n      title: '部品表',\n      acquired: '{{completed}}/{{total}} 取得済み',\n      showAll: 'すべて表示',\n      hideDone: '完了を非表示',\n      addPart: 'パーツを追加',\n      noAddPermission: 'パーツを追加する権限がありません',\n      partNamePlaceholder: 'パーツ名',\n      partName: 'パーツ名',\n      qty: '数量',\n      price: '価格 ({{currency}})',\n      sourcingUrlPlaceholder: 'URL（任意）',\n      remarksPlaceholder: '備考',\n      deletePart: 'パーツを削除',\n      deleteConfirm: '「{{name}}」を削除しますか？',\n      noUpdatePermission: 'パーツを更新する権限がありません',\n      noEditPermission: 'このプロジェクトを編集する権限がありません',\n      noDeletePermission: 'プロジェクトを削除する権限がありません',\n      totalCost: '合計コスト',\n      empty: '<空>',\n    },\n    timeline: {\n      title: 'アクティビティタイムライン',\n      empty: '<空>',\n    },\n    template: {\n      saveAsTemplate: 'テンプレートとして保存',\n      noCreatePermission: 'プロジェクトを作成する権限がありません',\n    },\n    queue: {\n      title: '印刷キュー',\n      viewAll: 'すべて表示',\n      printing: '印刷中',\n      queued: 'キューに追加',\n    },\n    prints: {\n      title: '印刷 ({{count}})',\n    },\n    toast: {\n      projectUpdated: 'プロジェクトを更新しました',\n      partAdded: 'パーツを追加しました',\n      partRemoved: 'パーツを削除しました',\n      exportFailed: 'エクスポートに失敗しました',\n      projectExported: 'プロジェクトがエクスポートされました',\n      templateCreated: 'プロジェクトからテンプレートを作成しました',\n    },\n  },\n\n  // System info\n  system: {\n    title: 'システム情報',\n    version: 'バージョン',\n    uptime: '稼働時間',\n    cpuUsage: 'CPU使用率',\n    memoryUsage: 'メモリ使用量',\n    diskUsage: 'ディスク使用量',\n    networkInfo: 'ネットワーク情報',\n    logs: 'ログ',\n    debugMode: 'デバッグモード',\n    enableDebug: 'デバッグログを有効化',\n    disableDebug: 'デバッグログを無効化',\n    downloadLogs: 'ログをダウンロード',\n    clearLogs: '通知ログを削除',\n    dockerInfo: 'Docker情報',\n    containerName: 'コンテナ名',\n    imageName: 'イメージ名',\n    platform: 'プラットフォーム',\n    architecture: 'アーキテクチャ',\n  },\n\n  // Library (K Profiles)\n  library: {\n    title: 'フィラメントライブラリ',\n    addFilament: 'フィラメントを追加',\n    editFilament: 'フィラメントを編集',\n    deleteFilament: 'フィラメントを削除',\n    vendor: 'メーカー',\n    material: '素材',\n    color: '色',\n    kFactor: 'K値',\n    temperature: '温度',\n    noFilaments: 'ライブラリにフィラメントがありません',\n    deleteConfirm: 'このフィラメントを削除しますか？',\n    importFromPrinter: 'プリンターからインポート',\n    exportToFile: 'ファイルにエクスポート',\n  },\n\n  // Spoolman\n  spoolman: {\n    title: 'Spoolman連携',\n    enabled: 'Spoolman有効',\n    url: 'Spoolman URL',\n    connected: '接続中',\n    disconnected: '未接続',\n    testConnection: '接続テスト',\n    sync: '同期',\n    syncing: '同期中...',\n    lastSync: '最終同期',\n    linkToSpoolman: 'Spoolmanに連携',\n    openInSpoolman: 'Spoolmanで開く',\n    unlinkSpool: 'スプールのリンクを解除',\n    unlinkConfirmTitle: 'スプールのリンクを解除しますか?',\n    unlinkConfirmMessage: 'これにより、スプールがSpoolmanから切断されます。Spoolman内のスプールデータは変更されません。',\n    selectSpool: 'スプールを選択',\n    noUnlinkedSpools: 'Spoolmanに未連携のスプールが見つかりません。',\n    linkSuccess: 'スプールをSpoolmanにリンクしました',\n    linkFailed: 'スプールのリンクに失敗しました',\n    unlinkSuccess: 'スプールをSpoolmanから解除しました',\n    unlinkFailed: 'スプールのリンク解除に失敗しました',\n    spoolId: 'スプールID',\n    fillSourceLabel: '(Spoolman)',\n    weight: '重量',\n    remaining: '残り',\n    disableWeightSync: 'AMS推定重量同期を無効化',\n    disableWeightSyncDesc: 'AMS推定値から残量を更新しません。AMSの割合ベースの推定よりもSpoolmanの使用量追跡を優先する場合に使用してください。新しいスプールは引き続きAMS推定値を初期重量として使用します。',\n    reportPartialUsage: '失敗した印刷の部分使用量を報告',\n    reportPartialUsageDesc: '印刷が失敗またはキャンセルされた場合、レイヤー進捗に基づいてその時点までの推定フィラメント使用量を報告します。',\n  },\n\n  // Inventory\n  inventory: {\n    title: 'スプール在庫管理',\n    addSpool: 'スプールを追加',\n    editSpool: 'スプールを編集',\n    material: '素材',\n    selectMaterial: '素材を選択...',\n    subtype: 'サブタイプ',\n    brand: 'ブランド',\n    searchBrand: 'ブランドを検索...',\n    useCustomBrand: '「{{brand}}」を使用',\n    useCustomMaterial: 'カスタム素材を使用: {{material}}',\n    colorName: '色名',\n    colorNamePlaceholder: 'Jade White, Fire Red...',\n    color: '色',\n    hexColor: 'HEXカラー',\n    pickColor: 'カスタムカラーを選択',\n    labelWeight: '表示重量',\n    coreWeight: '空スプール重量',\n    searchSpoolWeight: 'スプール重量を検索...',\n    weightUsed: '使用量',\n    currentWeight: '残量',\n    measuredWeight: '計測重量',\n    spoolName: 'スプール',\n    costPerKg: 'kgあたりのコスト',\n    measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',\n    slicerFilament: 'スライサーフィラメント',\n    slicerFilamentName: 'スライサープリセット名',\n    slicerPreset: 'スライサープリセット',\n    searchPresets: 'フィラメントプリセットを検索...',\n    selectedPreset: '選択済み',\n    noPresetsFound: 'プリセットが見つかりません',\n    tempOverrides: '温度オーバーライド',\n    note: 'メモ',\n    notePlaceholder: 'このスプールに関する追加メモ...',\n    archive: 'アーカイブ',\n    restore: '復元',\n    noSpools: 'スプールがありません。最初のスプールを追加してください。',\n    noManualSpools: '手動で追加されたスプールがありません。先にインベントリにスプールを追加してください。',\n    kProfiles: 'Kプロファイル',\n    addKProfile: 'Kプロファイルを追加',\n    assignSpool: 'スプールを割り当て',\n    unassignSpool: '割り当て解除',\n    assignSuccess: 'スプールを割り当て、AMSスロットを設定しました',\n    assignFailed: 'スプールの割り当てに失敗しました',\n    selectSpool: 'このスロットに割り当てるスプールを選択',\n    assigned: '割り当て済み',\n    assigning: '割り当て中...',\n    searchSpools: 'スプールを検索...',\n    showAllSpools: 'すべてのスプールを表示',\n    allMaterials: 'すべての素材',\n    filterByBrand: 'ブランドで絞り込み...',\n    showArchived: 'アーカイブ済みを表示',\n    quickAdd: 'クイック追加（在庫）',\n    quantity: '数量',\n    stock: '在庫',\n    configured: '設定済み',\n    spoolsCreated: '{{count}}本のスプールを作成しました',\n    spoolCreated: 'スプールを作成しました',\n    spoolUpdated: 'スプールを更新しました',\n    spoolDeleted: 'スプールを削除しました',\n    spoolArchived: 'スプールをアーカイブしました',\n    spoolRestored: 'スプールを復元しました',\n    deleteConfirm: 'このスプールを削除しますか？この操作は元に戻せません。',\n    archiveConfirm: 'このスプールをアーカイブしますか？',\n    advancedSettings: '詳細設定',\n    // Tabs\n    filamentInfoTab: 'フィラメント情報',\n    paProfileTab: 'PAプロファイル',\n    filamentInfo: 'フィラメント',\n    additional: '追加情報',\n    // Cloud\n    loadingPresets: 'クラウドプリセットを読み込み中...',\n    cloudConnected: 'クラウド接続済み',\n    cloudNotConnected: 'クラウド未接続（デフォルト使用）',\n    // Colors\n    recentColors: '最近',\n    searchColors: '色を検索...',\n    searchResults: '検索結果',\n    allColors: 'すべての色',\n    commonColors: '一般的な色',\n    showLess: '少なく表示',\n    showAll: 'すべて表示',\n    noColorsFound: '一致する色がありません',\n    noResults: '結果なし',\n    // PA Profiles\n    selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',\n    noPrintersConfigured: 'プリンターが設定されていません。プリンターを追加してください。',\n    matchingFilter: 'フィルター',\n    anyBrand: 'すべてのブランド',\n    anyVariant: 'すべてのバリアント',\n    autoSelect: '自動選択',\n    matches: '件一致',\n    match: '件一致',\n    noMatches: '一致なし',\n    connected: '接続済み',\n    offline: 'オフライン',\n    printerOffline: 'プリンターがオフラインです。接続してキャリブレーションプロファイルを表示してください。',\n    noKProfilesMatch: '選択したフィラメントに一致するKプロファイルがありません。',\n    leftNozzle: '左ノズル',\n    rightNozzle: '右ノズル',\n    profilesSelected: 'キャリブレーションプロファイル選択済み',\n    // Stats & enhanced table\n    totalInventory: '在庫合計',\n    totalConsumed: '総消費量',\n    byMaterial: '素材別',\n    inPrinter: 'プリンター内',\n    lowStock: '残量少',\n    sinceTracking: '追跡開始以降',\n    loadedInAms: 'AMS/Extに装填中',\n    remaining: '残り',\n    weightCheck: '重量チェック',\n    lastWeighed: '最終計量',\n    neverWeighed: '未計量',\n    search: 'スプールを検索...',\n    showing: '表示',\n    to: '〜',\n    of: '/',\n    show: '表示',\n    spools: 'スプール',\n    spool: 'スプール',\n    page: 'ページ',\n    noSpoolsMatch: '結果なし',\n    noSpoolsMatchDesc: '検索やフィルターを調整してみてください。',\n    active: 'アクティブ',\n    archived: 'アーカイブ済み',\n    all: 'すべて',\n    used: '使用済み',\n    new: '新規',\n    clearFilters: 'フィルターをクリア',\n    table: 'テーブル',\n    cards: 'カード',\n    net: '正味',\n    // Grouping\n    groupSimilar: 'グループ化',\n    groupedSpools: '{{count}}本の同一スプール',\n    groupedRows: '行',\n    // Column config\n    columns: '列',\n    configureColumns: '列の設定',\n    configureColumnsDesc: 'ドラッグして並べ替えるか、矢印を使用してください。目のアイコンで表示/非表示を切り替えます。',\n    visible: '表示中',\n    reset: 'リセット',\n    cancel: 'キャンセル',\n    applyChanges: '変更を適用',\n    moveUp: '上へ移動',\n    moveDown: '下へ移動',\n    hideColumn: '列を非表示',\n    showColumn: '列を表示',\n    // Tag linking\n    linkToSpool: 'スプールにリンク',\n    tagLinked: 'タグがスプールにリンクされました',\n    tagLinkFailed: 'タグのリンクに失敗しました',\n    tagAlreadyLinked: 'タグは既に別のスプールにリンクされています',\n    unknownTag: '不明なRFIDタグが検出されました',\n    // Usage history\n    usageHistory: '使用履歴',\n    noUsageHistory: 'まだ使用記録がありません',\n    printName: 'プリント名',\n    weightConsumed: '消費重量',\n    clearHistory: 'クリア',\n    historyCleared: '使用履歴がクリアされました',\n    fillSourceLabel: '(Inv)',\n    lowStockThresholdError: 'しきい値は0.1から99.9の間でなければなりません',\n    assignMismatchTitle: '材料の不一致',\n    assignMismatchMessage: '選択したスプールの材料「{{spoolMaterial}}」は、{{location}} のトレイ材料「{{trayMaterial}}」と一致しません。割り当てますか？',\n    assignMismatchConfirm: '強制的に割り当て',\n    assignPartialMismatchMessage: 'スプールの材料「{{spoolMaterial}}」は「{{trayMaterial}}」に似ていますが、{{location}} と完全には一致しません。続行しますか？',\n    assignProfileMismatchMessage: 'スプールのプロファイル「{{spoolProfile}}」は {{location}} のトレイプロファイル「{{trayProfile}}」と一致しません。続行しますか？',\n  },\n\n  // Timelapse\n  timelapse: {\n    title: 'タイムラプス',\n    create: 'タイムラプスを作成',\n    download: 'ダウンロード',\n    delete: '削除',\n    preview: 'プレビュー',\n    frameRate: 'フレームレート',\n    quality: '品質',\n    processing: 'バックアップファイルを処理中...',\n    noTimelapses: '利用可能なタイムラプスがありません',\n  },\n\n  // AMS\n  ams: {\n    title: 'AMS',\n    slot: 'スロット',\n    empty: '<空>',\n    emptySlot: '空のスロット',\n    unknown: '不明',\n    humidity: '湿度',\n    temperature: '温度',\n    filamentType: 'フィラメントタイプ',\n    filamentColor: '色',\n    remaining: '残り',\n    history: 'AMS履歴',\n    noHistory: 'メンテナンス履歴がありません',\n    configureSlot: 'フィラメントプロファイルとK値でスロットを設定',\n    externalSpool: '外部スプール',\n    profile: 'プロファイル',\n    kFactor: 'K値',\n    fill: '充填率',\n    configure: '設定',\n    used: '使用済み',\n    remainingUnit: '残り',\n  },\n\n  // Print modal\n  printModal: {\n    title: '印刷を開始',\n    selectPrinter: 'プリンターを選択',\n    selectPlate: 'プレートを選択',\n    filamentMapping: 'フィラメントマッピング',\n    totalCost: '合計コスト:',\n    slotRemainingShort: ' - 残{{grams}}g',\n    printSettings: '印刷設定',\n    bedLeveling: 'ベッドレベリング',\n    flowCalibration: 'フローキャリブレーション',\n    vibrationCalibration: '振動キャリブレーション',\n    layerInspection: '第一層検査',\n    timelapse: 'タイムラプス',\n    startPrint: '印刷を開始',\n    addToQueue: 'キューに追加',\n    cancel: 'キャンセル',\n    noPrintersAvailable: '利用可能なプリンターがありません',\n    printerBusy: 'プリンターは使用中です',\n    printerOffline: 'プリンターはオフラインです',\n    sameTypeDifferentColor: '同じ種類、異なる色',\n    filamentTypeNotLoaded: 'フィラメントタイプが未読み込み',\n    openCalendar: 'カレンダーを開く',\n    leftNozzle: 'L',\n    rightNozzle: 'R',\n    leftNozzleTooltip: '左ノズル',\n    rightNozzleTooltip: '右ノズル',\n    filamentOverride: 'フィラメントオーバーライド',\n    filamentOverrideHint: 'モデルベースの割り当てに使用するフィラメントをオプションで上書きします。スケジューラは元の3MF値ではなく、選択したフィラメントに基づいてマッチングします。',\n    originalFilament: 'オリジナル',\n    overrideWith: '変更先',\n    resetToOriginal: 'オリジナルに戻す',\n    insufficientFilamentTitle: 'フィラメントが不足しています',\n    insufficientFilamentMessage: '割り当てられたスプールの一部は、この印刷に必要な量より残量が少ないです:',\n    insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',\n    printAnyway: 'それでも印刷',\n    forceColorMatch: 'カラーマッチを強制',\n    staggerPrinterStarts: 'Stagger printer starts',\n    staggerGroupSize: 'Group size',\n    staggerInterval: 'Interval (min)',\n    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',\n    staggerLastGroup: 'last group: {{count}}',\n    staggerTotal: 'total: {{minutes}} min',\n    staggerToPrinters: '{{count}}台のプリンターに段階的に送信',\n    gcodeInjection: '自動印刷G-codeを挿入',\n  },\n\n  // Backup\n  backup: {\n    title: 'バックアップと復元',\n    createBackup: 'バックアップを作成',\n    restoreBackup: 'バックアップの復元',\n    restoreDescription: 'バックアップファイルからすべてのデータを置き換える',\n    downloadBackup: 'バックアップをダウンロード',\n    uploadBackup: 'バックアップをアップロード',\n    lastBackup: '最終バックアップ',\n    autoBackup: '自動バックアップ',\n    backupNow: '今すぐバックアップ',\n    restoreWarning: '警告: バックアップの復元は現在のすべてのデータを上書きします。',\n    includeArchives: 'アーカイブを含む',\n    includeSettings: '設定を含む',\n    includeProfiles: 'プロファイルを含む',\n    backupSuccess: 'バックアップを作成しました',\n    restoreSuccess: 'バックアップを復元しました',\n    backupFailed: 'バックアップに失敗しました: {{error}}',\n    restoreFailed: '復元に失敗しました',\n    restoreNote: '復元中、仮想プリンターは停止されます',\n\n    // GitHub Backup\n    githubBackup: 'GitHubバックアップ',\n    enabled: '有効',\n    cloudLoginRequired: 'Bambu Cloudログインが必要です。GitHubバックアップを有効にするには、プロファイル → クラウドプロファイルからサインインしてください。',\n    cloudLoginRequiredShort: 'Cloudログインが必要',\n    githubDescription: 'プロファイルをプライベートGitHubリポジトリに自動的に同期し、バックアップとバージョン履歴を保持します。',\n    repositoryUrl: 'リポジトリURL',\n    personalAccessToken: '個人アクセストークン',\n    tokenSaved: '(保存済み)',\n    enterNewToken: '新しいトークンを入力して更新',\n    tokenHint: 'Contents読み書き権限を持つきめ細かいトークン',\n    branch: 'ブランチ',\n    manualOnly: '手動のみ',\n    hourly: '毎時',\n    daily: '毎日',\n    weekly: '毎週',\n    includeInBackup: 'バックアップに含める',\n    kProfiles: 'Kプロファイル',\n    kProfilesDescription: '接続されたプリンターからの圧力キャリブレーション',\n    noPrintersConnected: 'プリンターが接続されていません',\n    printersConnected: '{{connected}}/{{total}} 接続済み',\n    cloudProfiles: 'クラウドプロファイル',\n    cloudProfilesDescription: 'Bambu Cloudからのフィラメント、プリンター、プロセスプリセット',\n    appSettings: 'アプリ設定',\n    appSettingsDescription: 'Bambuddy設定（データベース全体）',\n    spoolInventory: 'スプール在庫',\n    spoolInventoryDescription: 'フィラメントスプール、使用履歴、コスト追跡',\n    printArchives: '印刷アーカイブ',\n    printArchivesDescription: '印刷履歴メタデータ（gcode/3MFファイルなし）',\n    lastBackupAt: '最終バックアップ:',\n    noBackupsYet: 'バックアップはまだありません',\n    next: '次回:',\n    startingBackup: 'バックアップを開始しています...',\n    test: 'テスト',\n    enableBackup: 'バックアップを有効化',\n    testConnection: '接続テスト',\n    enterRepoUrl: 'リポジトリURLを入力してください',\n    enterRepoAndToken: 'リポジトリURLとアクセストークンを入力してください',\n    repoRequired: 'リポジトリURLは必須です',\n    tokenRequired: 'アクセストークンは必須です',\n    githubBackupEnabled: 'GitHubバックアップが有効になりました',\n    tokenUpdated: 'トークンが更新されました',\n    settingsSaved: '設定が保存されました',\n    failedToSave: '保存に失敗しました: {{message}}',\n    backupCompleteFiles: 'バックアップ完了 - {{count}}ファイルが更新されました',\n    backupSkippedNoChanges: 'バックアップをスキップ - 変更なし',\n    backupFailed2: 'バックアップに失敗しました: {{message}}',\n    clearedLogs: '{{count}}件のログを削除しました',\n    failedToClearLogs: 'ログの削除に失敗しました: {{message}}',\n\n    // History\n    history: '履歴',\n    clear: 'クリア',\n    date: '日付',\n    status: 'ステータス',\n    commit: 'コミット',\n\n    // Local Backup\n    localBackup: 'ローカルバックアップ',\n    localBackupDescription: 'データベース、アーカイブ、アップロード、すべてのファイルを含むBambuddyデータの完全なバックアップを作成します。',\n    downloadBackupLabel: 'バックアップをダウンロード',\n    completeBackupZip: '完全バックアップ: データベース + 全ファイル (ZIP)',\n    download: 'ダウンロード',\n    preparingBackup: 'バックアップを準備しています...',\n    creatingArchive: 'バックアップアーカイブを作成しています...大きなアーカイブの場合、時間がかかることがあります。',\n    downloadingFile: 'バックアップファイルをダウンロードしています...',\n    backupDownloaded: 'バックアップのダウンロードが成功しました',\n    failedToCreateBackup: 'バックアップの作成に失敗しました: {{message}}',\n    restore: '復元',\n    restoreReplacesAll: '復元はすべてのデータを置き換えます。',\n    restoreReplacesAllDetail: '現在のデータベースとファイルは完全に置き換えられます。復元後に再起動が必要です。',\n    restoreConfirmTitle: 'バックアップを復元',\n    restoreConfirmMessage: '\"{{filename}}\"から復元してもよろしいですか？現在のデータベースとすべてのファイルが完全に置き換えられます。復元後にアプリケーションの再起動が必要です。',\n    restoreConfirmButton: 'バックアップを復元',\n    uploadingFile: 'バックアップファイルをアップロードしています...',\n    backupRestoredRestart: 'バックアップが復元されました。Bambuddyを再起動してください。',\n    failedToRestore: 'バックアップの復元に失敗しました。ファイル形式を確認してください。',\n    reloadNow: '今すぐリロード',\n    creatingBackup: 'バックアップを作成中',\n    restoringBackup: 'バックアップを復元中',\n    preparing: '準備中...',\n    processing: '処理中...',\n    doNotClosePage: 'このページを閉じたり、移動しないでください。大きなバックアップの場合、この操作には数分かかることがあります。',\n\n    // RestoreModal\n    restoring: '復元中...',\n    restoreComplete: '復元完了',\n    restoreFailed2: '復元失敗',\n    importSettings: 'バックアップファイルから設定をインポート',\n    pleaseWaitRestoring: 'データの復元中です。お待ちください',\n    selectBackupFile: 'クリックしてバックアップファイルを選択 (.jsonまたは.zip)',\n    duplicateHandling: '重複処理の仕組み:',\n    matchPrinters: 'プリンター',\n    matchPrintersBy: 'シリアル番号で照合',\n    matchSmartPlugs: 'スマートプラグ',\n    matchSmartPlugsBy: 'IPアドレスで照合',\n    matchNotificationProviders: '通知プロバイダー',\n    matchNotificationProvidersBy: '名前で照合',\n    matchFilaments: 'フィラメント',\n    matchFilamentsBy: '名前 + タイプ + ブランドで照合',\n    matchArchives: 'アーカイブ',\n    matchArchivesBy: 'コンテンツハッシュで照合（常にスキップ）',\n    matchPendingUploads: '保留中のアップロード',\n    matchPendingUploadsBy: 'ファイル名で照合',\n    matchSettingsTemplates: '設定とテンプレート',\n    matchSettingsTemplatesBy: '常に上書き',\n    replaceExisting: '既存のデータを置き換え',\n    keepExisting: '既存のデータを保持',\n    overwriteDescription: '既に存在する項目をバックアップデータで上書き',\n    keepDescription: 'まだ存在しない項目のみ復元',\n    overwriteCaution: '注意:',\n    overwriteWarning: '上書きすると、現在の設定がバックアップのデータで置き換えられます。プリンターのアクセスコードはセキュリティのため上書きされません。',\n    cancel: 'キャンセル',\n    processingBackup: 'バックアップファイルを処理しています...',\n    itemsRestored: '復元済み',\n    itemsSkipped: 'スキップ済み',\n    restored: '復元済み',\n    skippedAlreadyExist: 'スキップ（既に存在）',\n    filesCategory: 'ファイル（3MF、サムネイルなど）',\n    andMore: '...他{{count}}件',\n    newApiKeysGenerated: '新しいAPIキーが生成されました',\n    keysShownOnce: 'これらのキーは一度だけ表示されます。今すぐコピーしてください！',\n    copy: 'コピー',\n    noDataFound: 'バックアップファイルに復元するデータが見つかりませんでした。',\n    close: '閉じる',\n\n    // Scheduled local backups (#884)\n    scheduledBackup: 'Scheduled Backups',\n    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',\n    frequency: 'Frequency',\n    backupTime: 'Time',\n    retention: 'Retention',\n    retentionDescription: 'Number of backups to keep',\n    outputPath: 'Output Path',\n    outputPathPlaceholder: 'Default: {{path}}',\n    outputPathDescription: 'Leave empty for default location',\n    runNow: 'Run Now',\n    backupFiles: 'Backup Files',\n    noScheduledBackups: 'No backups yet',\n    deleteBackup: 'Delete',\n    deleteBackupConfirm: 'Delete this backup file?',\n    backupRunning: 'Backup in progress...',\n    scheduledBackupComplete: 'Backup completed successfully',\n    scheduledBackupFailed: 'Backup failed',\n    nextBackup: 'Next backup',\n    backupSize: 'Size',\n    utc: 'UTC',\n    defaultPathLabel: 'Default:',\n\n    // Category labels\n    categories: {\n      settings: '設定',\n      notification_providers: '通知プロバイダー',\n      notification_templates: '通知テンプレート',\n      smart_plugs: 'スマートプラグ',\n      printers: 'プリンター',\n      filaments: 'フィラメント',\n      maintenance_types: 'メンテナンスタイプ',\n      archives: 'アーカイブ',\n      projects: 'プロジェクト',\n      pending_uploads: '保留中のアップロード',\n      external_links: '外部リンク',\n      api_keys: 'APIキー',\n    },\n  },\n\n  // Tags\n  tags: {\n    title: 'タグ',\n    addTag: 'タグを追加',\n    editTag: 'タグを編集',\n    deleteTag: 'タグを削除',\n    tagName: 'タグ名',\n    tagColor: 'タグの色',\n    noTags: 'タグがありません',\n    deleteConfirm: 'このタグを削除しますか？',\n    manageTags: 'タグを管理',\n  },\n\n  // Upload modal (archives)\n  uploadModal: {\n    title: '3MFファイルのアップロード',\n    dragDrop: '.3mfファイルをここにドラッグ＆ドロップ',\n    or: 'または',\n    browseFiles: 'ファイルを参照',\n    extractionInfo: 'プリンターモデルは3MFファイルのメタデータから自動的に抽出されます。',\n    uploaded: 'アップロード済み',\n    failed: 'アップロードに失敗しました',\n    uploading: 'アップロード中...',\n    upload: 'アップロード',\n    uploadFailed: 'アップロード失敗',\n  },\n\n  // Edit archive modal\n  // Edit Archive Modal\n  editArchive: {\n    title: 'アーカイブを編集',\n    name: '名前',\n    namePlaceholder: '印刷名',\n    printer: 'プリンター',\n    noPrinter: 'プリンターなし',\n    project: 'プロジェクト',\n    noProject: 'プロジェクトなし',\n    itemsPrinted: '印刷数',\n    itemsPrintedHelp: 'この印刷ジョブで製造したアイテム数',\n    notes: 'メモ',\n    notesPlaceholder: 'この印刷についてメモを追加...',\n    externalLink: '外部リンク',\n    externalLinkPlaceholder: 'https://...',\n    externalLinkHelp: 'Printables、Thingiverse、その他のソースへのリンク',\n    tags: 'タグ',\n    tagsPlaceholder: 'タグを追加...',\n    addMoreTags: 'タグをさらに追加...',\n    matchingTags: '\"{{query}}\" に一致',\n    existingTags: '既存のタグ',\n    clickToAdd: '（クリックして追加）',\n    status: 'ステータス',\n    failureReason: '失敗理由',\n    selectReason: '理由を選択...',\n    photos: '印刷結果の写真',\n    photosHelp: '+ をクリックして印刷結果の写真を追加',\n    printResult: '印刷結果',\n    saving: '保存中...',\n    // Failure reasons\n    failureReasons: {\n      adhesionFailure: '定着不良',\n      spaghettiDetached: 'スパゲッティ / 剥離',\n      layerShift: 'レイヤーシフト',\n      cloggedNozzle: 'ノズル詰まり',\n      filamentRunout: 'フィラメント切れ',\n      warping: '反り',\n      stringing: '糸引き',\n      underExtrusion: '押出不足',\n      powerFailure: '電源障害',\n      userCancelled: 'ユーザーによるキャンセル',\n      other: 'その他',\n    },\n    // Archive statuses\n    statuses: {\n      completed: '完了',\n      failed: '失敗',\n      aborted: 'キャンセル',\n      printing: '印刷中',\n    },\n  },\n\n  // K-Profiles\n  kProfiles: {\n    title: 'Kプロファイル',\n    noPrintersConfigured: 'プリンターが設定されていません',\n    addPrinterInSettings: 'Kプロファイルを管理するには設定でプリンターを追加してください',\n    noActivePrinters: 'アクティブなプリンターがありません',\n    enablePrinterConnection: 'Kプロファイルを表示するにはプリンター接続を有効にしてください',\n    loadingProfiles: 'Kプロファイルを読み込み中...',\n    printerOffline: 'プリンターオフライン',\n    printerOfflineDesc: '選択したプリンターは接続されていません。電源を入れてKプロファイルを表示してください。',\n    noMatchingProfiles: '一致するプロファイルなし',\n    noMatchingProfilesDesc: '検索条件に一致するプロファイルがありません',\n    noKProfiles: 'Kプロファイルなし',\n    noKProfilesDesc: '{{diameter}}mmノズル用の圧力キャリブレーションプロファイルが見つかりません',\n    createFirstProfile: '最初のプロファイルを作成',\n    // Controls\n    printer: 'プリンター',\n    nozzle: 'ノズル',\n    refresh: '更新',\n    addProfile: 'K-プロファイルを追加',\n    export: 'エクスポート',\n    import: 'インポート',\n    select: '選択',\n    selectAll: 'すべて選択',\n    delete: '削除',\n    // Filters\n    searchPlaceholder: '名前またはフィラメントで検索...',\n    allExtruders: 'すべてのエクストルーダー',\n    leftOnly: '左のみ',\n    rightOnly: '右のみ',\n    allFlow: 'すべてのフロー',\n    hfOnly: 'HFのみ',\n    sOnly: 'Sのみ',\n    sortName: 'ソート: 名前',\n    sortKValue: 'ソート: K値',\n    sortFilament: 'ソート: フィラメント',\n    // Dual extruder labels\n    leftExtruder: '左エクストルーダー',\n    rightExtruder: '右エクストルーダー',\n    // Modal\n    modal: {\n      addTitle: 'Kプロファイルを追加',\n      editTitle: 'Kプロファイルを編集',\n      profileName: 'プロファイル名',\n      profileNamePlaceholder: 'マイPLAプロファイル',\n      kValue: 'K値',\n      kValuePlaceholder: '0.020',\n      kValueHelp: '一般的な範囲: PLA 0.01〜0.06、PETG 0.02〜0.10',\n      filament: 'フィラメント',\n      selectFilament: 'フィラメントを選択...',\n      noFilamentsHelp: 'フィラメントが見つかりません。Bambu Studioでまずプロファイルを作成してください。',\n      flowType: 'フロータイプ',\n      highFlow: 'ハイフロー',\n      standard: 'スタンダード',\n      nozzleSize: 'ノズルサイズ',\n      extruder: 'エクストルーダー',\n      extruders: 'エクストルーダー',\n      left: '左',\n      right: '右',\n      notes: 'メモ（ローカル保存）',\n      notesPlaceholder: 'このプロファイルのメモを追加...',\n      notesHelp: 'メモはBambuddyに保存され、プリンターには保存されません',\n      syncing: 'プリンターと同期中...',\n      savingExtruder: 'エクストルーダーに保存中 {{current}}/{{total}}...',\n      pleaseWait: 'お待ちください',\n    },\n    // Delete confirmation\n    deleteConfirm: {\n      title: 'プロファイルを削除',\n      cannotUndo: '元に戻せません',\n      message: '「{{name}}」をプリンターから削除しますか？',\n    },\n    // Bulk delete\n    bulkDelete: {\n      title: 'プロファイルを削除',\n      cannotUndo: '元に戻せません',\n      message: '選択した{{count}}件のプロファイルをプリンターから削除しますか？',\n    },\n    // Toast\n    toast: {\n      profileSaved: 'Kプロファイルを保存しました',\n      profilesSaved: 'Kプロファイルを{{count}}台のエクストルーダーに保存しました',\n      selectAtLeastOneExtruder: 'エクストルーダーを1つ以上選択してください',\n      profileDeleted: 'Kプロファイルを削除しました',\n      profilesDeleted: '{{count}}件のプロファイルを削除しました',\n      exportedProfiles: '{{count}}件のプロファイルをエクスポートしました',\n      importedProfiles: '{{total}}件中{{imported}}件のプロファイルをインポートしました',\n      noProfilesToExport: 'エクスポートするプロファイルがありません',\n      invalidFileFormat: '無効なファイル形式',\n      failedToParseImport: 'インポートファイルの解析に失敗しました',\n      failedToSaveBatch: 'Kプロファイルの保存に失敗しました',\n      noteSaved: 'メモを保存しました',\n      failedToSaveNote: 'メモの保存に失敗しました',\n    },\n    // Permissions\n    permission: {\n      noRead: 'プロファイルを更新する権限がありません',\n      noCreate: 'プロファイルを追加する権限がありません',\n      noUpdate: 'Kプロファイルを更新する権限がありません',\n      noDelete: 'Kプロファイルを削除する権限がありません',\n      noExport: 'プロファイルをエクスポートする権限がありません',\n      noImport: 'プロファイルをインポートする権限がありません',\n    },\n  },\n\n  // Virtual Printer\n  virtualPrinter: {\n    title: '仮想プリンター',\n    running: '稼働中',\n    stopped: '停止',\n    description: {\n      default: 'Bambu StudioとOrcaSlicerに表示される仮想プリンターを有効化。このプリンターに送信されたファイルは印刷せずに直接アーカイブされます。',\n      proxy: 'スライサーのトラフィックを実際のプリンターに転送するプロキシを有効化。任意のネットワーク経由でリモート印刷が可能です。',\n    },\n    enable: {\n      title: '仮想プリンターを有効化',\n      visibleInSlicer: 'スライサーの検出リストに「Bambuddy」として表示',\n      proxyingTo: '{{name}}にプロキシ中',\n      notActive: '非アクティブ',\n    },\n    model: {\n      title: 'プリンターモデル',\n      description: 'エミュレートするプリンターモデルを選択。',\n      restartWarning: 'モデルを変更すると仮想プリンターが再起動されます',\n    },\n    accessCode: {\n      title: 'アクセスコード',\n      isSet: 'アクセスコードが設定されています',\n      notSet: 'アクセスコード未設定 - 有効化に必要です',\n      placeholder: '8文字のコードを入力',\n      placeholderChange: '新しいコードを入力して変更',\n      hint: '正確に8文字必要です。スライサーの認証に使用されます。',\n      charCount: '({{count}}/8)',\n    },\n    targetPrinter: {\n      title: 'ターゲットプリンター',\n      configured: 'プロキシターゲット設定済み',\n      notConfigured: 'ターゲットプリンター未選択 - プロキシモードに必要です',\n      placeholder: 'プリンターを選択...',\n      hint: 'スライサートラフィックの転送先プリンターを選択。プリンターはLANモードである必要があります。',\n      noPrinters: 'プリンターが設定されていません。プロキシモードを使用するにはまずプリンターを追加してください。',\n    },\n    remoteInterface: {\n      title: 'ネットワークインターフェース上書き',\n      configured: 'インターフェース上書き有効',\n      optional: 'オプション — 自動検出IPが間違っている場合に使用（複数NIC、Docker、VPNなど）',\n      placeholder: '自動検出（デフォルト）...',\n      hint: 'SSDPで広告され、TLS証明書に使用されるIPアドレスを上書きします。Bambuddyに複数のネットワークインターフェースがある場合に便利です。',\n    },\n    mode: {\n      title: 'モード',\n      archive: 'アーカイブ',\n      archiveDesc: 'ファイルを即座にアーカイブ',\n      review: 'レビュー',\n      reviewDesc: 'アーカイブ前にレビュー',\n      queue: 'キュー',\n      queueDesc: 'アーカイブしてキューに追加',\n      proxy: 'プロキシ',\n      proxyDesc: '実際のプリンターに転送',\n    },\n    autoDispatch: {\n      title: '自動ディスパッチ',\n      description: 'キューに追加されたときに自動的に印刷を開始します。オフの場合、手動ディスパッチを待ちます。',\n    },\n    setupRequired: {\n      title: 'セットアップが必要です',\n      description: '仮想プリンター機能を使用するには追加のシステム設定が必要です。ポートフォワーディング、ファイアウォールルール、プラットフォーム固有の設定が含まれます。',\n      readGuide: '有効にする前にセットアップガイドをお読みください',\n    },\n    howItWorks: {\n      title: '仕組み',\n      step1: '同じLAN上では、仮想プリンターはスライサー（Bambu Studio / OrcaSlicer）に自動的に表示されます。他のネットワークからは、IPアドレスとアクセスコードで手動で追加してください。',\n      step2: 'アーカイブ、レビュー、キューモードでは、スライサーの「送信」ボタンを使用して3MFファイルをBambuddyにアップロードします。スライサーは「印刷成功」と表示しますが、ファイルは保存され、印刷はされません。',\n      step3: 'プロキシモードでは、仮想プリンターはすべてのトラフィックを実際のプリンターに転送します。直接接続されているかのように印刷がすぐに開始されます。',\n    },\n    status: {\n      title: 'ステータス詳細',\n      printerName: 'プリンター名',\n      model: 'モデル',\n      serialNumber: 'シリアル番号',\n      mode: 'モード',\n      pendingFiles: '保留中のファイル',\n      targetPrinter: 'ターゲットプリンター',\n      ftpPort: 'FTPポート',\n      mqttPort: 'MQTTポート',\n      ftpConnections: 'FTP接続数',\n      mqttConnections: 'MQTT接続数',\n    },\n    toast: {\n      updated: '仮想プリンター設定を更新しました',\n      failedToUpdate: '設定の更新に失敗しました',\n      accessCodeRequired: '先にアクセスコードを設定してください',\n      targetPrinterRequired: '先にターゲットプリンターを選択してください',\n      bindIpRequired: '先にバインドIPを設定してください',\n      accessCodeEmpty: 'アクセスコードは空にできません',\n      accessCodeLength: 'アクセスコードは8文字である必要があります',\n      created: '仮想プリンターを作成しました',\n      failedToCreate: '仮想プリンターの作成に失敗しました',\n      deleted: '仮想プリンターを削除しました',\n      failedToDelete: '仮想プリンターの削除に失敗しました',\n    },\n    list: {\n      title: '仮想プリンター',\n      add: '追加',\n      addFirst: '仮想プリンターを追加',\n      empty: '仮想プリンターが設定されていません。追加して始めましょう。',\n    },\n    bindIp: {\n      title: 'バインドインターフェース',\n      placeholder: 'インターフェースを選択...',\n      hint: 'この仮想プリンターがバインドするネットワークインターフェース。プリンターごとに一意である必要があります。',\n    },\n    proxy: {\n      accessCodeHint: 'プロキシモードでは、スライサーにターゲットプリンターのアクセスコードを使用してください。接続は実際のプリンターに透過的に転送されます。',\n    },\n    addDialog: {\n      title: '仮想プリンターを追加',\n      name: '名前',\n      hint: 'アクセスコード、ターゲットプリンター、その他の設定は作成後に設定できます。',\n      create: '作成',\n    },\n    deleteConfirm: {\n      title: '仮想プリンターを削除',\n      message: '「{{name}}」を削除してもよろしいですか？このプリンターのすべてのサービスが停止されます。',\n    },\n  },\n\n  // Model Viewer\n  modelViewer: {\n    openInSlicer: 'スライサーで開く',\n    tabs: {\n      model: '3Dモデル',\n      gcode: 'G-codeプレビュー',\n    },\n    notAvailable: '利用不可',\n    notSliced: '未スライス',\n    plates: 'プレート',\n    allPlates: '全プレート',\n    plateNumber: 'プレート {{number}}',\n    plateCount: '{{count}} プレート',\n    plateCount_other: '{{count}} プレート',\n    objectCount: '{{count}} オブジェクト',\n    objectCount_other: '{{count}} オブジェクト',\n    filamentCount: '{{count}} フィラメント',\n    filamentCount_other: '{{count}} フィラメント',\n    eta: '予想時間 {{minutes}} 分',\n    noPreview: 'このファイルのプレビューは利用できません',\n    pagination: {\n      pageOf: 'ページ {{current}} / {{total}}',\n      prev: '前へ',\n      next: '次へ',\n    },\n    errors: {\n      failedToLoad: 'ファイルの読み込みに失敗しました',\n      noMeshes: '3MFファイルにメッシュが見つかりません',\n      unsupportedFormat: 'サポートされていないファイル形式です',\n    },\n  },\n\n  // Maintenance type descriptions (built-in)\n  maintenanceDescriptions: {\n    lubricateCarbonRods: 'カーボンロッドに潤滑剤を塗布してスムーズな動きを確保',\n    lubricateRails: 'リニアレールの潤滑',\n    cleanNozzle: 'ノズル/ホットエンドの清掃',\n    checkBelts: 'ベルト張力の確認',\n    cleanBuildPlate: 'ビルドプレートの清掃',\n    checkExtruder: 'エクストルーダーギアの確認',\n    checkCooling: '冷却ファンの確認',\n    generalInspection: '総合点検',\n    cleanCarbonRods: 'カーボンロッドの清掃',\n    lubricateSteelRods: 'スチールロッドに潤滑剤を塗布してスムーズな動きを確保',\n    cleanSteelRods: 'スチールロッドの清掃',\n    cleanLinearRails: 'リニアレールを拭いてほこりや汚れを除去',\n    checkPtfeTube: 'PTFEチューブの確認',\n    replaceHepaFilter: 'HEPAフィルター交換',\n    replaceCarbonFilter: 'カーボンフィルター交換',\n    lubricateLeftNozzleRail: '左ノズルレールの潤滑',\n  },\n\n  // Smart Plugs\n  smartPlugs: {\n    offline: 'オフライン',\n    admin: '管理',\n    openPlugAdminPage: 'プラグ管理ページを開く',\n    deleteSmartPlug: 'スマートプラグを削除',\n    turnOnSmartPlug: 'スマートプラグをオンにする',\n    turnOffSmartPlug: 'スマートプラグをオフにする',\n    turnOn: 'オンにする',\n    turnOff: 'オフにする',\n    addSmartPlug: {\n      scanningNetwork: 'ネットワークをスキャン中...',\n      chooseEntity: 'エンティティを選択...',\n      connectionFailed: '接続失敗',\n      searchEntities: 'エンティティを検索...',\n      searchPowerSensors: '電力センサーを検索...',\n      searchEnergySensors: 'エネルギーセンサーを検索...',\n      placeholders: {\n        plugName: 'リビングルームプラグ',\n        mqttStateOnValue: 'ON, true, 1',\n        mqttSameAsPower: '電力トピックと同じ、または異なる',\n      },\n    },\n    // SmartPlugCard\n    linkedTo: 'リンク先:',\n    monitorOnly: '監視のみ',\n    alerts: 'アラート',\n    scheduleOn: 'オン {{time}}',\n    scheduleOff: 'オフ {{time}}',\n    on: 'オン',\n    off: 'オフ',\n    power: '電力',\n    kwhToday: '本日のkWh',\n    settings: '設定',\n    automationSettings: '自動化設定',\n    showInSwitchbar: 'スイッチバーに表示',\n    quickAccessSidebar: 'サイドバーからクイックアクセス',\n    enabled: '有効',\n    enableAutomation: 'このプラグの自動化を有効にする',\n    autoOn: '自動オン',\n    autoOnDescription: '印刷開始時にオンにする',\n    autoOff: '自動オフ',\n    autoOffDescription: '印刷完了時にオフにする（ワンショット）',\n    autoOffPersistent: '有効のまま維持',\n    autoOffPersistentDescription: 'ワンショットではなく印刷間で有効のまま維持',\n    turnOffDelayMode: 'オフ遅延モード',\n    time: '時間',\n    temp: '温度',\n    delayMinutes: '遅延（分）',\n    tempThreshold: '温度しきい値（°C）',\n    tempThresholdDescription: 'ノズルがこの温度以下に冷却されるとオフになります',\n    edit: '編集',\n    deleteConfirm: '\"{{name}}\"を削除してもよろしいですか？この操作は取り消せません。',\n    turnOnConfirm: '\"{{name}}\"をオンにしてもよろしいですか？',\n    turnOffConfirm: '\"{{name}}\"をオフにしてもよろしいですか？接続されたデバイスの電源が切れます。',\n    failedToTurn: '\"{{name}}\"を{{action}}できませんでした',\n    unknown: '不明',\n    // AddSmartPlugModal\n    addTitle: 'スマートプラグを追加',\n    editTitle: 'スマートプラグを編集',\n    stopScanning: 'スキャン停止',\n    discoverTasmota: 'Tasmotaデバイスを検出',\n    foundDevices: '{{count}}台のデバイスが見つかりました - クリックして選択:',\n    noDevicesFound: 'ネットワーク上にTasmotaデバイスが見つかりません',\n    haNotConfigured: 'Home Assistantが設定されていません。設定場所:',\n    haSettingsPath: '設定 → ネットワーク → Home Assistant',\n    selectEntity: 'エンティティを選択 *',\n    ipAddress: 'IPアドレス *',\n    nameLabel: '名前 *',\n    username: 'ユーザー名',\n    password: 'パスワード',\n    authHint: 'Tasmotaデバイスが認証を必要としない場合は空のままにしてください',\n    linkToPrinter: 'プリンターにリンク',\n    noPrinter: 'プリンターなし（手動制御のみ）',\n    linkingDescription: 'リンクすると印刷開始/完了時に自動でオン/オフできます',\n    powerAlerts: '電力アラート',\n    alertAbove: '上限アラート（W）',\n    alertBelow: '下限アラート（W）',\n    alertDescription: '電力消費がこれらのしきい値を超えた場合に通知します。無効にするには空のままにしてください。',\n    dailySchedule: 'デイリースケジュール',\n    turnOnAt: 'オンにする時刻',\n    turnOffAt: 'オフにする時刻',\n    scheduleDescription: '毎日これらの時刻にプラグを自動的にオン/オフします。スキップするには空のままにしてください。',\n    showOnPrinterCard: 'プリンターカードに表示',\n    displayOnPrinterCard: 'プリンターカードにボタンを表示',\n    connectedResult: '接続成功！',\n    deviceLabel: 'デバイス: {{name}} - ',\n    stateLabel: '状態: {{state}}',\n    test: 'テスト',\n    delete: '削除',\n    save: '保存',\n    add: '追加',\n    cancel: 'キャンセル',\n    failedToStartScan: 'スキャンを開始できませんでした',\n    nameRequired: '名前は必須です',\n    entityRequired: 'Home AssistantプラグにはエンティティIDが必要です',\n    mqttTopicRequired: '電力、エネルギー、または状態監視用に少なくとも1つのMQTTトピックを設定する必要があります',\n    loadingEntities: 'エンティティを読み込み中...',\n    loading: '読み込み中...',\n    failedToLoadEntities: 'エンティティの読み込みに失敗しました: {{error}}',\n    noEntitiesMatching: '\"{{search}}\"に一致するエンティティが見つかりません',\n    noEntitiesAvailable: '利用可能なエンティティがありません',\n    searchingEntities: 'すべてのエンティティを検索中（{{count}}件見つかりました）',\n    showingEntities: 'switch、light、input_booleanを表示（{{count}}件利用可能）',\n    energyMonitoringOptional: 'エネルギー監視（オプション）',\n    energyMonitoringHint: '電力/エネルギーデータを提供するセンサーを検索して選択します。',\n    powerSensorW: '電力センサー（W）',\n    energyTodayKwh: '本日のエネルギー（kWh）',\n    totalEnergyKwh: '総エネルギー（kWh）',\n    noMatchingSensors: '一致するセンサーがありません',\n    none: 'なし',\n    mqttNotConfigured: 'MQTTブローカーが設定されていません。ブローカーアドレスを設定してください:',\n    mqttSettingsPath: '設定 → ネットワーク → MQTT配信',\n    mqttNotConfiguredSuffix: '（配信を有効にする必要はありません。ブローカーの詳細を入力するだけです）。',\n    mqttMonitorOnlyDescription: 'MQTTプラグはMQTTサブスクリプション経由で電力/エネルギーデータを受信します。オン/オフ制御は利用できません - MQTTブローカーまたはホームオートメーションシステムを使用してください。',\n    powerMonitoring: '電力監視',\n    energyMonitoring: 'エネルギー監視',\n    stateMonitoring: '状態監視',\n    optional: 'オプション',\n    topic: 'トピック',\n    jsonPath: 'JSONパス',\n    multiplier: '乗数',\n    onValue: 'ON値',\n    mqttPowerHint: 'JSONパスはJSONペイロードから値を抽出します（例: \"power_l1\"）。トピックが生の数値を送信する場合は空のままにしてください。\\n乗数: mW→Wは0.001、kW→Wは1000を使用。',\n    mqttEnergyHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\\n乗数: Wh→kWhは0.001、MWh→kWhは1000を使用。',\n    mqttStateHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\\nON値: \"ON\"を意味する正確な文字列。自動検出（ON、true、1）の場合は空のままにしてください。',\n    // REST smart plug\n    restControl: 'Control',\n    restOnUrl: 'Turn ON URL',\n    restOffUrl: 'Turn OFF URL',\n    restOnBody: 'ON Request Body',\n    restOffBody: 'OFF Request Body',\n    restMethod: 'HTTP Method',\n    restHeaders: 'Custom Headers (JSON)',\n    restStatusUrl: 'Status URL',\n    restStatusPath: 'State JSON Path',\n    restStatusOnValue: 'ON Value',\n    restPowerUrl: '電力URL',\n    restPowerPath: 'Power JSON Path',\n    restPowerMultiplier: '電力乗数',\n    restEnergyUrl: 'エネルギーURL',\n    restEnergyPath: 'Energy JSON Path',\n    restEnergyMultiplier: 'エネルギー乗数',\n    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',\n    restHeadersHint: 'e.g. {\"Authorization\": \"Bearer your-token\"}',\n    restBodyHint: 'e.g. ON, {\"state\": \"on\"}',\n    restStatusHint: 'URL to poll for current state',\n    restPathHint: 'e.g. state or data.power.status',\n    restPowerUrlHint: '電力データ用の個別URL（空欄の場合はステータスURLを使用）',\n    restEnergyUrlHint: 'エネルギーデータ用の個別URL（空欄の場合はステータスURLを使用）',\n    restEnergyHint: '各値は個別のURLを使用するか、ステータスURLにフォールバックできます。乗数で単位変換が可能です（例：WhからkWhへの変換は0.001）。',\n    testConnection: 'Test Connection',\n    connectionSuccess: 'Connection successful',\n    noSwitchesInSwitchbar: 'スイッチバーにスイッチがありません',\n    enableSwitchbarHint: '設定 > スマートプラグで「スイッチバーに表示」を有効にしてください',\n  },\n\n  // Notifications\n  notifications: {\n    // Provider types\n    providerTypes: {\n      callmebot: 'CallMeBot/WhatsApp',\n      ntfy: 'ntfy',\n      pushover: 'Pushover',\n      telegram: 'Telegram',\n      email: 'メール',\n      discord: 'Discord',\n      webhook: 'Webhook',\n      homeassistant: 'Home Assistant',\n    },\n    // Provider descriptions\n    providerDescriptions: {\n      email: 'SMTPメール通知',\n      telegram: 'Telegramボット経由の通知',\n      discord: 'Webhook経由でDiscordチャンネルに送信',\n      ntfy: '無料のセルフホスト可能なプッシュ通知',\n      pushover: 'シンプルで信頼性の高いプッシュ通知',\n      callmebot: 'CallMeBot経由の無料WhatsApp通知',\n      webhook: '任意のURLへの汎用HTTP POST',\n      homeassistant: 'Home Assistantダッシュボードの永続通知',\n    },\n    // NotificationProviderCard\n    lastSuccess: '最終: {{date}}',\n    error: 'エラー',\n    printer: 'プリンター:',\n    allPrinters: 'すべてのプリンター',\n    sendTestNotification: 'テスト通知を送信',\n    eventSettings: 'イベント設定',\n    enabled: '有効',\n    sendFromProvider: 'このプロバイダーから通知を送信',\n    // Event categories\n    printEvents: '印刷イベント',\n    printerStatus: 'プリンターステータス',\n    amsAlarms: 'AMSアラーム',\n    amsHtAlarms: 'AMS-HTアラーム',\n    printQueue: '印刷キュー',\n    // Event tags (badges)\n    start: '開始',\n    plateCheck: 'プレートチェック',\n    complete: '完了',\n    failed: '失敗',\n    stopped: '停止',\n    progress: '進捗',\n    offline: 'オフライン',\n    lowFilament: 'フィラメント残量低下',\n    maintenance: 'メンテナンス',\n    amsHumidity: 'AMS湿度',\n    amsTemp: 'AMS温度',\n    amsHtHumidity: 'AMS-HT湿度',\n    amsHtTemp: 'AMS-HT温度',\n    bedCooled: 'ベッド冷却済み',\n    firstLayer: '第1層完了',\n    quiet: '静音',\n    digest: 'ダイジェスト {{time}}',\n    // Event labels (expanded settings)\n    printStarted: '印刷開始',\n    plateNotEmpty: 'プレートが空でない',\n    plateNotEmptyDescription: '印刷前にオブジェクトが検出されました',\n    printCompleted: '印刷完了',\n    bedCooledLabel: 'ベッド冷却済み',\n    bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',\n    firstLayerCompleteLabel: '第1層完了',\n    firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',\n    missingSpoolAssignmentLabel: 'スプール割り当て不足',\n    missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',\n    printFailed: '印刷失敗',\n    printStopped: '印刷停止',\n    progressMilestones: '進捗マイルストーン',\n    progressMilestonesDescription: '25%、50%、75%で通知',\n    printerOffline: 'プリンターオフライン',\n    printerError: 'プリンターエラー',\n    lowFilamentLabel: 'フィラメント残量低下',\n    maintenanceDue: 'メンテナンス期限',\n    maintenanceDueDescription: 'メンテナンスが必要な場合に通知',\n    amsHumidityHigh: 'AMS湿度高',\n    amsHumidityHighDescription: '通常AMSの湿度がしきい値を超過',\n    amsTemperatureHigh: 'AMS温度高',\n    amsTemperatureHighDescription: '通常AMSの温度がしきい値を超過',\n    amsHtHumidityHigh: 'AMS-HT湿度高',\n    amsHtHumidityHighDescription: 'AMS-HTの湿度がしきい値を超過',\n    amsHtTemperatureHigh: 'AMS-HT温度高',\n    amsHtTemperatureHighDescription: 'AMS-HTの温度がしきい値を超過',\n    // Queue events\n    jobAdded: 'ジョブ追加',\n    jobAddedDescription: 'キューにジョブが追加されました',\n    jobAssigned: 'ジョブ割り当て',\n    jobAssignedDescription: 'モデルベースのジョブがプリンターに割り当てられました',\n    jobStarted: 'ジョブ開始',\n    jobStartedDescription: 'キュージョブの印刷が開始されました',\n    jobWaiting: 'ジョブ待機中',\n    jobWaitingDescription: 'フィラメントまたはプリンター待ちのジョブ',\n    jobSkipped: 'ジョブスキップ',\n    jobSkippedDescription: 'ジョブがスキップされました（前のジョブが失敗）',\n    jobFailed: 'ジョブ失敗',\n    jobFailedDescription: 'ジョブの開始に失敗しました',\n    queueComplete: 'キュー完了',\n    queueCompleteDescription: 'すべてのキュージョブが完了しました',\n    // Quiet hours\n    quietHours: '静音時間',\n    noNotificationsDuring: 'この時間帯は通知を送信しません',\n    editProviderToChangeQuietHours: 'プロバイダーを編集して静音時間を変更',\n    // Daily digest\n    dailyDigest: 'デイリーダイジェスト',\n    batchNotifications: '通知をまとめて1日のサマリーとして送信',\n    sendAt: '{{time}}に送信',\n    editProviderToChangeDigestTime: 'プロバイダーを編集してダイジェスト時刻を変更',\n    // Actions\n    edit: '編集',\n    deleteProvider: '通知プロバイダーを削除',\n    deleteConfirm: '\"{{name}}\"を削除してもよろしいですか？この操作は取り消せません。',\n    delete: '削除',\n    // AddNotificationModal\n    addTitle: '通知プロバイダーを追加',\n    editTitle: '通知プロバイダーを編集',\n    nameLabel: '名前 *',\n    namePlaceholder: 'マイ通知',\n    providerTypeLabel: 'プロバイダータイプ *',\n    configuration: '設定',\n    testConfiguration: '設定をテスト',\n    printerFilter: 'プリンターフィルター',\n    onlyFromPrinter: 'このプリンターからのイベントのみ通知を送信',\n    quietHoursDnd: '静音時間（おやすみモード）',\n    quietStart: '開始',\n    quietEnd: '終了',\n    dailyDigestLabel: 'デイリーダイジェスト',\n    sendDigestAt: 'ダイジェスト送信時刻',\n    digestCollected: 'イベントが収集され、この時刻にまとめて送信されます',\n    notificationEvents: '通知イベント',\n    progressPercent: '（25%、50%、75%）',\n    bedCooledAfterPrint: '（印刷完了後）',\n    cancel: 'キャンセル',\n    save: '保存',\n    add: '追加',\n    nameRequired: '名前は必須です',\n    fieldRequired: '{{field}}は必須です',\n    // Config field labels\n    phoneNumber: '電話番号',\n    apiKey: 'APIキー',\n    serverUrl: 'サーバーURL',\n    topic: 'トピック',\n    authToken: '認証トークン',\n    userKey: 'ユーザーキー',\n    appToken: 'アプリトークン',\n    priority: '優先度',\n    botToken: 'ボットトークン',\n    chatId: 'チャットID',\n    smtpServer: 'SMTPサーバー',\n    smtpPort: 'SMTPポート',\n    security: 'セキュリティ',\n    authentication: '認証',\n    username: 'ユーザー名',\n    password: 'パスワード',\n    fromEmail: '送信元メール',\n    toEmail: '宛先メール',\n    webhookUrl: 'Webhook URL',\n    payloadFormat: 'ペイロード形式',\n    authorization: '認可',\n    titleFieldName: 'タイトルフィールド名',\n    messageFieldName: 'メッセージフィールド名',\n    // NotificationTemplateEditor\n    editTemplate: 'テンプレートを編集: {{name}}',\n    titleLabel: 'タイトル',\n    bodyLabel: '本文',\n    titlePlaceholder: '通知タイトル...',\n    bodyPlaceholder: '通知本文...',\n    availableVariables: '利用可能な変数',\n    clickToInsert: 'クリックして本文のカーソル位置に挿入',\n    livePreview: 'ライブプレビュー',\n    hide: '非表示',\n    show: '表示',\n    loadingPreview: 'プレビューを読み込み中...',\n    enterTemplateContent: 'テンプレートの内容を入力するとプレビューが表示されます',\n    titlePreview: 'タイトル:',\n    bodyPreview: '本文:',\n    resetToDefault: 'デフォルトにリセット',\n    titleRequired: 'タイトルは必須です',\n    bodyRequired: '本文は必須です',\n    // NotificationLogViewer\n    notificationLog: '通知ログ',\n    showFailedOnly: '失敗のみ',\n    last24Hours: '過去24時間',\n    last7Days: '過去7日間',\n    last30Days: '過去30日間',\n    last90Days: '過去90日間',\n    justNow: 'たった今',\n    noFailedNotifications: '失敗した通知はありません',\n    noNotificationsLogged: '記録された通知はありません',\n    unknownProvider: '不明なプロバイダー',\n    logTitle: 'タイトル',\n    logMessage: 'メッセージ',\n    logError: 'エラー',\n    logProvider: 'プロバイダー: {{type}}',\n    logTime: '時刻: {{time}}',\n    refresh: '更新',\n    clearOld: '古いものを削除',\n    statsSummary: '過去{{days}}日間:',\n    statsNotifications: '通知',\n    statsSent: '{{count}}件送信',\n    statsFailed: '{{count}}件失敗',\n    // Event type labels (for log viewer)\n    eventTypes: {\n      print_start: '印刷開始',\n      print_complete: '印刷完了',\n      print_failed: '印刷失敗',\n      print_stopped: '印刷停止',\n      print_progress: '進捗',\n      printer_offline: 'プリンターオフライン',\n      printer_error: 'プリンターエラー',\n      filament_low: 'フィラメント残量低下',\n      maintenance_due: 'メンテナンス期限',\n      test: 'テスト',\n    },\n    // User email notification preferences\n    userEmail: {\n      title: '通知',\n      emailNotifications: 'メール通知',\n      emailNotificationsDesc: '自分の印刷ジョブに対してメール通知を受け取ります。メールは高度な認証で設定されたSMTP設定を使用して送信されます。',\n      sendingTo: '通知の送信先',\n      noEmailWarning: 'アカウントにメールアドレスが設定されていません。管理者に連絡して追加してもらってください。',\n      printJobNotifications: '印刷ジョブ通知',\n      printJobNotificationsDesc: '送信した印刷ジョブのどのイベントでメール通知を送るかを選択します。',\n      printJobStarts: '印刷ジョブ開始',\n      printJobStartsDesc: '印刷ジョブが開始されたときに通知を受け取る。',\n      printJobFinishes: '印刷ジョブ完了',\n      printJobFinishesDesc: '印刷ジョブが正常に完了したときに通知を受け取る。',\n      printErrors: '印刷エラー',\n      printErrorsDesc: '印刷ジョブが失敗またはエラーが発生したときに通知を受け取る。',\n      printJobStops: '印刷ジョブ停止',\n      printJobStopsDesc: '印刷ジョブがキャンセルまたは停止されたときに通知を受け取る。',\n      saveSuccess: '通知設定を保存しました。',\n      saveError: '通知設定の保存に失敗しました。',\n    },\n  },\n\n  // Rich Text Editor\n  richTextEditor: {\n    bold: '太字',\n    italic: '斜体',\n    underline: '下線',\n    bulletList: '箇条書きリスト',\n    numberedList: '番号付きリスト',\n    alignLeft: '左揃え',\n    alignCenter: '中央揃え',\n    alignRight: '右揃え',\n    addLink: 'リンクを追加',\n    removeLink: 'リンクを削除',\n  },\n\n  // External Links\n  externalLinks: {\n    noLinksConfigured: '外部リンクが設定されていません',\n    deleteLink: 'リンクを削除',\n    removeCustomIcon: 'カスタムアイコンを削除',\n    openInNewTab: '新しいタブで開く',\n    placeholders: {\n      linkName: 'マイリンク',\n    },\n  },\n\n  // Keyboard Shortcuts Modal\n  keyboardShortcuts: {\n    title: 'キーボードショートカット',\n    navigation: 'ナビゲーション',\n    archivesSection: 'アーカイブ',\n    kProfilesSection: 'Kプロファイル',\n    generalSection: '全般',\n    shortcuts: {\n      goToPrinters: 'プリンターへ移動',\n      goToArchives: 'アーカイブへ移動',\n      goToQueue: 'キューへ移動',\n      goToStats: '統計へ移動',\n      goToProfiles: 'クラウドプロファイルへ移動',\n      goToSettings: '設定へ移動',\n      focusSearch: '検索にフォーカス',\n      openUploadModal: 'アップロードモーダルを開く',\n      clearSelection: '選択をクリア / 入力をぼかす',\n      contextMenu: 'カードのコンテキストメニュー',\n      refreshProfiles: 'プロファイルを更新',\n      newProfile: '新しいプロファイル',\n      exitSelectionMode: '選択モードを終了',\n      showHelp: 'このヘルプを表示',\n    },\n    footer: 'Escキーを押すか外側をクリックして閉じます',\n  },\n\n  // Notification Log\n  notificationLog: {\n    title: '通知ログ',\n    events: {\n      printStarted: '印刷開始',\n      printComplete: '印刷完了',\n      printFailed: '印刷失敗',\n      printStopped: '印刷停止',\n      progress: '進捗',\n      printerOffline: 'プリンターオフライン',\n      printerError: 'プリンターエラー',\n      lowFilament: 'フィラメント残量低下',\n      maintenanceDue: 'メンテナンス期限',\n      test: 'テスト',\n    },\n    timeAgo: {\n      justNow: 'たった今',\n      minutesAgo: '{{minutes}}分前',\n      hoursAgo: '{{hours}}時間前',\n    },\n  },\n\n  // Restore/Backup Modal\n  restoreBackup: {\n    title: 'バックアップを復元',\n    restoring: '復元中...',\n    restoreComplete: '復元完了',\n    restoreFailed: '復元失敗',\n    importSettings: 'バックアップファイルから設定をインポート',\n    pleaseWait: 'データの復元中です。しばらくお待ちください',\n    clickToSelect: 'クリックしてバックアップファイルを選択（.jsonまたは.zip）',\n    howDuplicateHandling: '重複の処理方法:',\n    categories: {\n      printers: 'プリンター',\n      smartPlugs: 'スマートプラグ',\n      notificationProviders: '通知プロバイダー',\n      filaments: 'フィラメント',\n      archives: 'アーカイブ',\n      pendingUploads: '保留中のアップロード',\n      settingsTemplates: '設定とテンプレート',\n    },\n    matchingInfo: {\n      printers: 'シリアル番号で照合',\n      smartPlugs: 'IPアドレスで照合',\n      notificationProviders: '名前で照合',\n      filaments: '名前+タイプ+ブランドで照合',\n      archives: 'コンテンツハッシュで照合',\n      pendingUploads: 'ファイル名で照合',\n      settingsTemplates: '常に上書き',\n    },\n    replaceExisting: '既存データを置き換え',\n    keepExisting: '既存データを保持',\n    replaceDescription: '既に存在するアイテムをバックアップデータで上書き',\n    keepDescription: 'まだ存在しないアイテムのみを復元',\n    caution: '注意:',\n    cautionText: '上書きすると現在の構成がバックアップデータに置き換えられます。セキュリティ上の理由から、プリンターのアクセスコードは上書きされません。',\n    itemsRestored: '復元されたアイテム',\n    itemsSkipped: 'スキップされたアイテム',\n    restored: '復元済み',\n    skipped: 'スキップ（既に存在）',\n    filesLabel: 'ファイル（3MF、サムネイルなど）',\n    newApiKeysGenerated: '新しいAPIキーが生成されました',\n    newApiKeysWarning: 'これらのキーは一度だけ表示されます。今すぐコピーしてください！',\n    processingBackup: 'バックアップファイルを処理中...',\n    noDataFound: 'バックアップファイルに復元するデータが見つかりませんでした。',\n    failedToRestore: 'バックアップの復元に失敗しました。ファイル形式を確認してください。',\n  },\n\n  // Backup Export Modal\n  backupExport: {\n    title: 'バックアップをエクスポート',\n    selectData: '含めるデータを選択',\n    selectAll: 'すべて選択',\n    selectNone: 'なし',\n    categoryDescriptions: {\n      settings: '言語、テーマ、更新設定',\n      notifications: 'ntfy、Pushover、Discordなど',\n      templates: 'カスタムメッセージテンプレート',\n      smartPlugs: 'Tasmotaプラグ設定',\n      externalLinks: 'サイドバーの外部サービスへのリンク',\n      printers: 'プリンター情報（アクセスコード除外）',\n      plateDetection: '空プレート参照画像',\n      filaments: 'フィラメントの種類とコスト',\n      maintenance: 'カスタムメンテナンススケジュール',\n      archives: 'すべての印刷データ+ファイル（3MF、サムネイル、写真）',\n      projects: 'プロジェクト、BOMアイテム、添付ファイル',\n      pendingUploads: '仮想プリンターアップロード待機中',\n      apiKeys: 'Webhook APIキー（インポート時に新しいキーが生成されます）',\n    },\n    requiresPrinters: 'プリンターを選択する必要があります',\n    zipFileWarning: 'ZIPファイルが作成されます。',\n    zipFileDescription: 'すべての3MFファイル、サムネイル、タイムラプス、写真が含まれます。これには時間がかかり、大きなファイルになる可能性があります。',\n    includeAccessCodes: 'アクセスコードを含める',\n    includeAccessCodesDescription: '別のマシンへの転送用',\n    includeAccessCodesWarning: 'アクセスコードはプレーンテキストで含まれます。このバックアップファイルを安全に保管してください！',\n    categoriesSelected: '{{selectedCount}}カテゴリー選択済み',\n  },\n\n  // Pending Uploads Panel\n  pendingUploads: {\n    placeholders: {\n      notes: 'この印刷に関するメモを追加...',\n    },\n    discardUpload: 'アップロードを破棄',\n    archiveAllUploads: 'すべてのアップロードをアーカイブ',\n    discardAllUploads: 'すべてのアップロードを破棄',\n    archive: 'アーカイブ',\n    timeAgo: {\n      justNow: 'たった今',\n      minutesAgo: '{{minutes}}分前',\n      hoursAgo: '{{hours}}時間前',\n      daysAgo: '{{days}}日前',\n    },\n  },\n\n  // API Browser\n  apiBrowser: {\n    placeholders: {\n      requestBody: 'JSONリクエストボディ...',\n      searchEndpoints: 'エンドポイントを検索...',\n    },\n  },\n\n  // Configure AMS Slot Modal\n  configureAmsSlot: {\n    title: 'AMSスロットの設定',\n    slotConfigured: 'スロットを設定しました！',\n    configuringSlot: 'スロットを設定中：',\n    slotLabel: '{{ams}} スロット {{slot}}',\n    searchPresets: 'プリセットを検索...',\n    colorPlaceholder: '色名またはHex（例: 茶色、FF8800）',\n    clearCustomColor: 'カスタム色をクリア',\n    noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。',\n    noPresetsAvailable: 'プリセットがありません。Bambu Cloudにログインするか、ローカルプロファイルをインポートしてください。',\n    noMatchingPresets: '一致するプリセットが見つかりません。',\n    custom: 'カスタム',\n    builtin: '内蔵',\n    settingsSentToPrinter: '設定をプリンターに送信しました',\n    filamentProfile: 'フィラメントプロファイル',\n    kProfileLabel: 'Kプロファイル（Pressure Advance）',\n    filteringFor: 'フィルター中: {{material}}',\n    noKProfile: 'Kプロファイルなし（デフォルト0.020を使用）',\n    noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。',\n    selectFilamentFirst: 'まずフィラメントプロファイルを選択してください',\n    kFromCalibration: 'K={{value}}（プリンターキャリブレーションから）',\n    customColorLabel: 'カスタム色（オプション）',\n    presetColors: '{{name}}の色：',\n    showLessColors: '色を減らす',\n    showMoreColors: '色をもっと表示',\n    clear: 'クリア',\n    hexLabel: 'Hex: #{{hex}}',\n    resetting: 'リセット中...',\n    resetSlot: 'スロットをリセット',\n    cancel: 'キャンセル',\n    configuring: '設定中...',\n    configureSlot: 'スロットを設定',\n  },\n\n  // GitHub Backup Settings\n  githubBackup: {\n    title: 'GitHubバックアップ',\n    history: '履歴',\n    downloadBackup: 'バックアップをダウンロード',\n    restoreBackup: 'バックアップを復元',\n    noBackupsYet: 'バックアップはまだありません',\n  },\n\n  // Email Settings\n  emailSettings: {\n    placeholders: {\n      fromName: 'BamBuddy',\n    },\n  },\n\n  // Tag Management Modal\n  tagManagement: {\n    searchTags: 'タグを検索...',\n    renameTag: 'タグ名を変更',\n    deleteTag: 'タグを削除',\n  },\n\n  // Notification Template Editor\n  notificationTemplates: {\n    placeholders: {\n      title: '通知タイトル...',\n      body: '通知本文...',\n    },\n  },\n\n  // Batch Tag Modal\n  batchTag: {\n    placeholders: {\n      newTag: '新しいタグを入力...',\n    },\n  },\n\n  // Photo Gallery Modal\n  photoGallery: {\n    deletePhoto: '写真を削除',\n  },\n\n  // Filament Hover Card\n  filamentHoverCard: {\n    copySpoolUuid: 'スプールUUIDをコピー',\n  },\n\n  // K Profiles View\n  kProfilesView: {\n    hasNote: 'メモあり',\n    copyProfile: 'プロファイルをコピー',\n  },\n\n  // Layout/Navigation\n  layout: {\n    openMenu: 'メニューを開く',\n    noPermissionSystemInfo: 'システム情報を表示する権限がありません',\n  },\n\n  // Dashboard\n  dashboard: {\n    dragToReorder: 'ドラッグして並べ替え',\n    hideWidget: 'ウィジェットを非表示',\n  },\n\n  // Notification Provider Card\n  notificationProviderCard: {\n    deleteNotificationProvider: '通知プロバイダーを削除',\n  },\n\n  // File Manager Modal\n  fileManagerModal: {\n    closeFileManager: 'ファイルマネージャーを閉じる',\n    sortFiles: 'ファイルを並べ替え',\n    goToParentFolder: '親フォルダーへ移動',\n    threeView: '3Dビュー',\n  },\n\n  // Embedded Camera Viewer\n  embeddedCameraViewer: {\n    refreshStream: 'ストリームを更新',\n    close: '閉じる',\n    zoomOut: 'ズームアウト',\n    resetZoom: 'ズームをリセット',\n    zoomIn: 'ズームイン',\n    dragToResize: 'ドラッグしてサイズ変更',\n  },\n\n  // Timelapse Viewer\n  timelapseViewer: {\n    skipBack5s: '5秒戻る',\n    skipForward5s: '5秒進む',\n  },\n\n  // Notification Providers\n  notificationProviders: {\n    descriptions: {\n      email: 'SMTP電子メール通知',\n      telegram: 'Telegramボット経由の通知',\n      discord: 'Webhookを介してDiscordチャンネルに送信',\n      ntfy: '無料でセルフホスト可能なプッシュ通知',\n      pushover: 'シンプルで信頼性の高いプッシュ通知',\n      callmebot: 'CallMeBot経由の無料WhatsApp通知',\n      webhook: '任意のURLへのジェネリックHTTP POST',\n    },\n  },\n\n  // Log Viewer\n  logViewer: {\n    searchPlaceholder: 'メッセージまたはロガー名を検索...',\n    noLogEntries: 'ログエントリが見つかりません',\n  },\n\n  // Switchbar Popover\n  switchbarPopover: {\n    noSwitchesInSwitchbar: 'スイッチバーにスイッチがありません',\n  },\n\n  // Project Page Modal\n  projectPageModal: {\n    placeholders: {\n      title: 'タイトル',\n      designer: 'デザイナー',\n      license: 'ライセンス',\n      description: '説明を入力...',\n      profileTitle: 'プロファイルタイトル',\n      profileDescription: 'プロファイルの説明...',\n    },\n  },\n\n  // Spoolman Settings\n  spoolmanSettings: {},\n\n  // Time\n  time: {\n    unknown: '-',\n    waiting: '待機中',\n    justNow: 'たった今',\n    now: '今すぐ',\n    minsAgo: '{{count}}分前',\n    inMins: 'あと{{count}}分',\n    hoursAgo: '{{count}}時間前',\n    inHours: 'あと{{count}}時間',\n    daysAgo: '{{count}}日前',\n    inDays: 'あと{{count}}日',\n  },\n\n  // SpoolBuddy Kiosk\n  spoolbuddy: {\n    nav: {\n      dashboard: 'ダッシュボード',\n      ams: 'AMS',\n      inventory: 'インベントリ',\n      writeTag: '書込み',\n      settings: '設定',\n    },\n    status: {\n      nfcReady: 'NFC準備完了',\n      nfcOff: 'NFCオフ',\n      offline: 'オフライン',\n      online: 'オンライン',\n      noPrinters: 'プリンターなし',\n      deviceOffline: 'デバイスオフライン',\n      waitingConnection: 'デバイス接続を待っています...',\n      systemReady: 'システム準備完了',\n      status: 'ステータス',\n    },\n    dashboard: {\n      readyToScan: 'スキャン準備完了',\n      idleMessage: 'スプールを計量台に置いて識別します',\n      nfcHint: 'NFCタグは自動的に読み取られます',\n      device: 'デバイス',\n      syncWeight: '重量同期',\n      weightSynced: '同期完了！',\n      unknownTag: '不明なタグ',\n      newTag: '新しいタグを検出',\n      onScale: '計量中',\n      linkSpool: 'スプールにリンク',\n      linkTagTitle: 'タグをスプールにリンク',\n      linkTag: 'タグをリンク',\n      selectSpool: 'このタグにリンクするスプールを選択:',\n      noUntagged: 'タグなしのスプールが見つかりません',\n      tagDetected: 'タグ検出',\n      noTag: 'タグなし',\n      tagId: 'タグ',\n      grossWeight: '総重量',\n      spoolSize: 'スプールサイズ',\n      close: '閉じる',\n      currentSpool: '現在のスプール',\n    },\n    modal: {\n      spoolDetected: 'スプール検出',\n      assignToAms: 'AMSに割り当て',\n      syncWeight: '重量同期',\n      weightSynced: '同期完了！',\n      syncing: '同期中...',\n      newTagDetected: '新しいタグを検出',\n      addToInventory: 'インベントリに追加',\n      assignToAmsTitle: 'AMSに割り当て',\n      selectSlot: 'スロットを選択',\n      assign: '割り当て',\n      assigning: '割り当て中...',\n      assignSuccess: '割り当て完了！',\n      assignError: 'スプールの割り当てに失敗しました。再試行してください。',\n      noPrinterSelected: 'プリンターを選択...',\n      noAmsDetected: 'このプリンターにAMSが検出されません',\n      slot: 'スロット',\n    },\n    weight: {\n      noReading: '読み取りなし',\n      stable: '安定',\n      measuring: '計測中...',\n      tare: '風袋引き',\n      calibrate: 'キャリブレーション',\n    },\n    spool: {\n      remaining: '残量',\n      material: '素材',\n      brand: 'ブランド',\n      color: '色',\n      coreWeight: 'コア',\n      labelWeight: 'ラベル',\n      scaleWeight: '計量',\n      netWeight: '正味',\n      lastUsed: '最終使用',\n    },\n    ams: {\n      noData: 'AMSが検出されません',\n      connectAms: 'AMSを接続してスロットを表示',\n      noPrinter: 'プリンター未選択',\n      selectPrinter: '上部バーからプリンターを選択',\n      printerDisconnected: 'プリンター切断',\n      humidity: '湿度',\n      level: 'レベル',\n      active: 'アクティブ',\n      slot: 'スロット',\n      empty: '空',\n    },\n    inventory: {\n      search: 'スプールを検索...',\n      empty: 'インベントリにスプールがありません',\n      noResults: '一致するスプールがありません',\n      spools: 'スプール',\n      addSpool: 'スプール追加',\n    },\n    settings: {\n      // Tabs\n      tabDevice: 'デバイス',\n      tabDisplay: 'ディスプレイ',\n      tabScale: '計量',\n      tabUpdates: 'アップデート',\n      // Device tab\n      nfcReader: 'NFCリーダー',\n      type: 'タイプ',\n      connection: '接続',\n      notConnected: 'N/A',\n      deviceInfo: 'デバイス情報',\n      hostname: 'ホスト',\n      uptime: '稼働時間',\n      // Display tab\n      brightness: '明るさ',\n      saved: '保存済み',\n      noBacklight: 'DSIバックライトが検出されませんでした。明るさ制御にはDSIディスプレイが必要です。',\n      screenBlank: '画面オフタイムアウト',\n      screenBlankDesc: '操作がないと画面がオフになります。タッチで復帰。',\n      displayNote: '明るさはソフトウェアフィルターとして適用されます。',\n      // Scale tab\n      scaleCalibration: '計量キャリブレーション',\n      currentWeight: '現在の重量',\n      tareOffset: '風袋',\n      calFactor: '係数',\n      knownWeight: '既知の重量',\n      calStep1: '計量台からすべてのアイテムを取り除き、ゼロ設定を押してください。',\n      calStep2: '既知の重量を計量台に置いてください。',\n      setZero: 'ゼロ設定',\n      calibrateNow: 'キャリブレーション',\n      calibrated: 'キャリブレーション済み',\n      tareSet: '風袋コマンドを送信しました。デバイスを待っています...',\n      tareFailed: '風袋コマンドの送信に失敗しました',\n      zeroSet: 'ゼロ点を設定しました。既知の重量を計量台に置いてください。',\n      calibrationDone: 'キャリブレーション完了！',\n      calibrationFailed: 'キャリブレーションに失敗しました',\n      lastCalibrated: '最終キャリブレーション',\n      stable: '安定',\n      settling: '安定化中...',\n      firmware: 'ファームウェア',\n      scale: '計量',\n      noDevice: 'SpoolBuddyデバイスが見つかりません',\n      // Updates tab\n      daemonVersion: 'デーモンバージョン',\n      currentVersion: '現在',\n      versionPending: 'デーモンを待っています...',\n      checking: '確認中...',\n      checkUpdates: 'アップデートを確認',\n      updateAvailable: 'アップデートあり',\n      updateInstructions: 'SSH経由で更新：SpoolBuddyインストールスクリプトを実行してください。',\n      upToDate: '最新です',\n      includeBeta: 'ベータ版を含む',\n    },\n    writeTag: {\n      tabExisting: '既存のスプール',\n      tabNew: '新規スプール',\n      tabReplace: 'タグ交換',\n      searchPlaceholder: '素材、色、ブランドで検索...',\n      noUntaggedSpools: 'タグなしのスプールがありません',\n      noTaggedSpools: 'タグ付きのスプールがありません',\n      selectSpool: 'スプールを選択し、NTAGをリーダーに置いてください',\n      placeTag: 'NTAGをリーダーに置いてください',\n      tagReady: 'タグ検出 — 書込み準備完了',\n      writeTag: 'タグ書込み',\n      replaceTag: 'タグ交換',\n      writing: 'タグ書込み中...',\n      waiting: 'SpoolBuddyを待機中...',\n      writeSuccess: 'タグの書込みが完了しました！',\n      writeFailed: '書込み失敗',\n      queueFailed: '書込みコマンドのキューに失敗しました',\n      tryAgain: '再試行',\n      cancel: 'キャンセル',\n      replaceWarning: '古いタグのリンクが解除され、新しいタグに置き換わります。',\n      deviceOffline: 'SpoolBuddyはオフラインです',\n      material: '素材',\n      colorName: '色名',\n      color: '色',\n      brand: 'ブランド',\n      weight: '重量 (g)',\n      createSpool: 'スプール作成',\n      creating: '作成中...',\n      spoolCreated: 'スプール作成完了！書込み準備ができました。',\n      createFailed: 'スプールの作成に失敗しました',\n    },\n    quickMenu: {\n      printerPower: 'プリンター電源',\n      systemControls: 'システム',\n      restartDaemon: 'デーモン再起動',\n      restartBrowser: 'ブラウザ再起動',\n      reboot: '再起動',\n      shutdown: 'シャットダウン',\n      swipeToClose: '下にスワイプして閉じる',\n      confirmTitle: '確認',\n      confirmShutdown: 'SpoolBuddyをシャットダウンしますか？再起動するには物理的なアクセスが必要です。',\n      confirmReboot: 'SpoolBuddyを再起動しますか？',\n      confirmRestartDaemon: 'SpoolBuddyデーモンを再起動しますか？NFCとスケールが一時的に使用できなくなります。',\n      confirmRestartBrowser: 'キオスクブラウザを再起動しますか？画面が一時的に暗くなります。',\n      confirm: '確認',\n      confirmPlugOn: '{{name}}をオンにしますか？',\n      confirmPlugOff: '{{name}}をオフにしますか？',\n      turnOn: 'オン',\n      turnOff: 'オフ',\n    },\n  },\n\n  bugReport: {\n    title: 'バグを報告',\n    description: '説明',\n    descriptionPlaceholder: '何が問題でしたか？問題を説明してください...',\n    email: 'メールアドレス（任意）',\n    emailPlaceholder: 'your@email.com',\n    emailPrivacy: '入力された場合、メールアドレスはGitHub Issueの折りたたみセクションに含まれ、メンテナーがフォローアップできるようになります。',\n    screenshot: 'スクリーンショット',\n    uploadOrPaste: '画像をアップロード、貼り付け、またはドラッグ',\n    dataCollectedSummary: 'レポートに含まれるデータは？',\n    dataIncluded: '含まれるもの:',\n    dataIncludedList: 'アプリバージョン、OS、アーキテクチャ、Pythonバージョン、データベース統計（件数のみ）、プリンターモデル、ノズル数、ファームウェアバージョン、接続状態、統合状態（Spoolman、MQTT、HA）、非機密設定、ネットワークインターフェース数、Docker詳細、依存関係バージョン。',\n    dataNeverIncluded: '含まれないもの:',\n    dataNeverIncludedList: 'プリンター名、シリアル番号、アクセスコード、パスワード、IPアドレス、メールアドレス、APIキー、トークン、Webhook URL、ホスト名、ユーザー名。',\n    submit: '送信',\n    startLogging: 'デバッグログ開始',\n    stepEnableLogging: 'デバッグログ有効',\n    stepReproduce: '問題を再現してください',\n    stepStopLogging: '停止してレポート送信',\n    stopAndSubmit: '停止して送信',\n    maxDuration: '{{minutes}}分後に自動停止',\n    stoppingLogs: 'ログ収集・送信中...',\n    submitting: 'バグレポートを送信中...',\n    submitSuccess: 'バグレポートが正常に送信されました！',\n    submitFailed: 'バグレポートの送信に失敗しました',\n    thankYou: 'ありがとうございます！',\n    submitted: 'バグレポートが送信されました。',\n    viewIssue: 'Issueを表示',\n    unexpectedError: '予期しないエラーが発生しました',\n  },\n  failureDetection: {\n    title: 'AI 失敗検出',\n    description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',\n    mlUrl: 'Obico ML API の URL',\n    mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。',\n    test: 'テスト',\n    testSuccess: 'ML API に接続でき、正常です。',\n    testFailed: 'ML API に接続できませんでした。',\n    sensitivity: '感度',\n    sensitivityLow: '低(誤検出が少ない)',\n    sensitivityMedium: '中(バランス型)',\n    sensitivityHigh: '高(早期検出、誤検出が増加)',\n    sensitivityHint: '警告と失敗をトリガーする信頼度のしきい値を調整します。',\n    action: '失敗検出時の動作',\n    actionNotify: '通知のみ',\n    actionPause: '印刷を一時停止',\n    actionPauseOff: '一時停止して電源を切る',\n    pollInterval: 'ポーリング間隔(秒)',\n    pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。',\n    externalUrlMissing: 'External URL is not set.',\n    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',\n    perPrinterTitle: '監視対象プリンター',\n    perPrinterHint: '検出サービスが監視するプリンターを選択します。',\n    monitorAll: '接続されているすべてのプリンターを監視',\n    statusTitle: 'ステータス',\n    serviceRunning: 'サービス稼働中',\n    thresholds: '低 / 高しきい値',\n    activePrinters: 'アクティブな印刷',\n    noActivePrints: '現在、実行中の印刷はありません。',\n    historyTitle: '最近の検出',\n    noHistory: 'まだ検出はありません。',\n  },\n};\n"
  },
  {
    "path": "frontend/src/i18n/locales/pt-BR.ts",
    "content": "export default {\n  // Navigation\n  nav: {\n    printers: 'Impressoras',\n    archives: 'Arquivos',\n    queue: 'Fila',\n    stats: 'Estatísticas',\n    profiles: 'Perfis',\n    maintenance: 'Manutenção',\n    projects: 'Projetos',\n    inventory: 'Inventário',\n    files: 'Gerenciador de Arquivos',\n    notifications: 'Notificações',\n    settings: 'Configurações',\n    system: 'Sistema',\n    collapseSidebar: 'Recolher barra lateral',\n    expandSidebar: 'Expandir barra lateral',\n    update: 'Atualizar',\n    updateAvailable: 'Atualização disponível: v{{version}}',\n    updateAvailableBanner: 'Versão {{version}} está disponível!',\n    viewUpdate: 'Ver atualização',\n    viewOnGithub: 'Ver no GitHub',\n    keyboardShortcuts: 'Atalhos de teclado (?)',\n    switchToLight: 'Mudar para modo claro',\n    switchToDark: 'Mudar para modo escuro',\n    smartSwitches: 'Interruptores inteligentes',\n    logout: 'Sair',\n  },\n\n  // Common\n  common: {\n    save: 'Salvar',\n    saving: 'Salvando...',\n    cancel: 'Cancelar',\n    delete: 'Excluir',\n    edit: 'Editar',\n    add: 'Adicionar',\n    close: 'Fechar',\n    confirm: 'Confirmar',\n    loading: 'Carregando...',\n    error: 'Erro',\n    success: 'Sucesso',\n    warning: 'Aviso',\n    enabled: 'Ativado',\n    disabled: 'Desativado',\n    yes: 'Sim',\n    no: 'Não',\n    on: 'Ligado',\n    off: 'Desligado',\n    all: 'Todos',\n    none: 'Nenhum',\n    search: 'Pesquisar',\n    filter: 'Filtrar',\n    sort: 'Ordenar',\n    refresh: 'Atualizar',\n    download: 'Baixar',\n    upload: 'Enviar',\n    uploading: 'Enviando...',\n    uploadFailed: 'Falha no envio',\n    actions: 'Ações',\n    status: 'Status',\n    name: 'Nome',\n    description: 'Descrição',\n    date: 'Data',\n    time: 'Hora',\n    hours: 'Horas',\n    minutes: 'Minutos',\n    seconds: 'Segundos',\n    days: 'Dias',\n    enable: 'Ativar',\n    disable: 'Desativar',\n    permissions: 'Permissões',\n    noPrinters: 'Nenhuma impressora configurada',\n    noData: 'Nenhum dado disponível',\n    linkNotFound: 'Link não encontrado',\n    required: 'Obrigatório',\n    optional: 'Opcional',\n    dismiss: 'Dispensar',\n    apply: 'Aplicar',\n    reset: 'Redefinir',\n    export: 'Exportar',\n    import: 'Importar',\n    clear: 'Limpar',\n    selectAll: 'Selecionar tudo',\n    deselectAll: 'Desmarcar tudo',\n    noChange: '— Sem alterações —',\n    unchanged: 'Inalterado',\n    unassigned: 'Não atribuído',\n    unknown: 'Desconhecido',\n    unknownError: 'Erro desconhecido',\n    today: 'Hoje',\n    tomorrow: 'Amanhã',\n    asap: 'O mais rápido possível',\n    overdue: 'Atrasado',\n    now: 'Agora',\n    collapse: 'Recolher',\n    expand: 'Expandir',\n    viewArchive: 'Ver arquivo',\n    viewInFileManager: 'Ver no Gerenciador de Arquivos',\n    addedBy: 'Adicionado por {{username}}',\n    prints: 'impressões',\n    more: '+{{count}} mais',\n    ascending: 'Crescente',\n    descending: 'Decrescente',\n    back: 'Voltar',\n    copy: 'Copiar',\n    copied: 'Copiado!',\n    printer: 'Impressora',\n    remove: 'Remover',\n    type: 'Tipo',\n    print: 'Imprimir',\n    rename: 'Renomear',\n    move: 'Mover',\n    create: 'Criar',\n    duplicate: 'Duplicar',\n    left: 'Esquerda',\n    right: 'Direita',\n  },\n\n  // Printers page\n  printers: {\n    title: 'Impressoras',\n    addPrinter: 'Adicionar Impressora',\n    editPrinter: 'Editar Impressora',\n    deletePrinter: 'Excluir Impressora',\n    printerName: 'Nome da Impressora',\n    serialNumber: 'Número de Série',\n    ipAddress: 'Endereço IP / Nome do Host',\n    accessCode: 'Código de Acesso',\n    model: 'Modelo',\n    nozzleCount: 'Número de Bicos',\n    autoArchive: 'Arquivamento Automático',\n    status: {\n      available: 'Disponível',\n      idle: 'Ocioso',\n      printing: 'Imprimindo',\n      paused: 'Pausado',\n      offline: 'Offline',\n      problem: 'Problema',\n      error: 'Erro',\n      finished: 'Concluído',\n      unknown: 'Desconhecido',\n    },\n    temperatures: {\n      nozzle: 'Bico',\n      bed: 'Cama',\n      chamber: 'Câmara',\n    },\n    progress: '{{percent}}% concluído',\n    timeRemaining: '{{time}} restante',\n    deleteConfirm: 'Tem certeza de que deseja excluir \"{{name}}\"?',\n    maintenanceOk: 'Manutenção OK',\n    maintenanceWarning: '{{count}} aviso',\n    maintenanceWarning_plural: '{{count}} avisos',\n    maintenanceDue: '{{count}} devido',\n    maintenanceDue_plural: '{{count}} devido',\n    // Sort options\n    sort: {\n      name: 'Nome',\n      status: 'Status',\n      model: 'Modelo',\n      location: 'Localização',\n      ascending: 'Ordem crescente',\n      descending: 'Ordem decrescente',\n    },\n    // Card size\n    cardSize: {\n      small: 'Cartões pequenos',\n      medium: 'Cartões médios',\n      large: 'Cartões grandes',\n      extraLarge: 'Cartões extra grandes',\n    },\n    // Controls\n    hideOffline: 'Ocultar offline',\n    nextAvailable: 'Próximo disponível',\n    powerOn: 'Ligar',\n    offlinePrintersWithPlugs: 'Impressoras offline com tomadas inteligentes',\n    noPrintersConfigured: 'Nenhuma impressora configurada ainda',\n    search: 'Pesquisar impressoras...',\n    noSearchResults: 'Nenhuma impressora corresponde à sua pesquisa ou filtros',\n    filter: {\n      allStatuses: 'Todos os status',\n      allLocations: 'Todos os locais',\n    },\n    // Printer card\n    readyToPrint: 'Pronto para imprimir',\n    external: 'Externo',\n    extL: 'Ext-L',\n    extR: 'Ext-R',\n    deleteArchives: 'Excluir arquivos de impressão',\n    noLabel: 'Sem etiqueta',\n    printPreview: 'Pré-visualização de impressão',\n    width: 'Largura',\n    height: 'Altura',\n    noObjectsFound: 'Nenhum objeto encontrado',\n    objectsLoadedOnPrintStart: 'Objetos são carregados quando uma impressão começa',\n    willBeSkipped: 'Será ignorado',\n    name: 'Nome',\n    serialCannotBeChanged: 'Número de série não pode ser alterado',\n    locationHelp: 'Usado para agrupar impressoras e filtrar trabalhos na fila',\n    // WiFi signal strength\n    wifiSignal: {\n      veryWeak: 'Muito fraco',\n      weak: 'Fraco',\n      fair: 'Regular',\n      good: 'Bom',\n      excellent: 'Excelente',\n    },\n    // Maintenance\n    maintenanceUpToDate: 'Toda a manutenção está em dia - Clique para ver',\n    // Chamber light\n    chamberLightOn: 'Ligar luz da câmara',\n    chamberLightOff: 'Desligar luz da câmara',\n    // Files\n    files: 'Arquivos',\n    browseFiles: 'Procurar arquivos da impressora',\n    // Smart plug\n    autoOffAfterPrint: 'Desligamento automático após impressão',\n    autoOffExecuted: 'Desligamento automático executado - ligue a impressora para reiniciar',\n    // HMS errors\n    hmsErrors: 'Erros HMS',\n    viewHmsErrors: 'Ver {{count}} erro(s) HMS',\n    // Actions\n    resume: 'Retomar',\n    pause: 'Pausar',\n    stop: 'Parar',\n    camera: 'âmera',\n    skipObject: 'Ignorar objeto',\n    reconnect: 'Reconectar',\n    forceRefresh: 'Forçar atualização',\n    forceRefreshSuccess: 'Atualização solicitada',\n    mqttDebug: 'Depuração MQTT',\n    printerInformation: 'Informações da impressora',\n    copyToClipboard: 'Copiar',\n    copied: 'Copiado!',\n    state: 'Estado',\n    wifiSignalLabel: 'Sinal WiFi',\n    developerMode: 'Modo desenvolvedor',\n    enabled: 'Ativado',\n    disabled: 'Desativado',\n    addedOn: 'Adicionada em',\n    sdCard: 'Cartão SD',\n    inserted: 'Inserido',\n    notInserted: 'Não inserido',\n    totalPrintHours: 'Horas de impressão',\n    activeNozzle: 'Ativo: {{nozzle}} bico',\n    nozzleRack: 'Suporte de bicos',\n    nozzleDocked: 'Acoplado',\n    nozzleMounted: 'Montado',\n    nozzleActive: 'Ativo',\n    nozzleIdle: 'Ocioso',\n    nozzleDiameter: 'Diâmetro',\n    nozzleType: 'Tipo',\n    nozzleStatus: 'Status',\n    nozzleFilament: 'Filamento',\n    nozzleWear: 'Desgaste',\n    nozzleMaxTemp: 'Temp Máx',\n    nozzleSerial: 'Serial',\n    nozzleHardenedSteel: 'Aço Endurecido',\n    nozzleStainlessSteel: 'Aço Inoxidável',\n    nozzleTungstenCarbide: 'Carboneto de Tungstênio',\n    nozzleFlow: 'Fluxo',\n    nozzleHighFlow: 'Alto Fluxo',\n    nozzleStandardFlow: 'Fluxo Padrão',\n    // Firmware\n    firmwareUpdate: 'Atualização de Firmware',\n    firmwareInstructions: 'No visor da impressora, vá para',\n    firmwareNav: 'Navegar para',\n    settings: 'Configurações',\n    firmware: 'Firmware',\n    // Discovery\n    discoverPrinters: 'Descobrir Impressoras',\n    searching: 'Procurando...',\n    manualEntry: 'Entrada Manual',\n    addFromCloud: 'Adicionar da Nuvem',\n    // Toast messages\n    toast: {\n      printerDeleted: 'Impressora excluída',\n      missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',\n      printerAdded: 'Impressora adicionada',\n      printerUpdated: 'Impressora atualizada',\n      failedToDelete: 'Falha ao excluir impressora',\n      failedToAdd: 'Falha ao adicionar impressora',\n      failedToUpdate: 'Falha ao atualizar impressora',\n      commandSent: 'Comando enviado',\n      failedToSendCommand: 'Falha ao enviar comando',\n      turnedOn: '{{name}} ligado',\n      failedToPowerOn: 'Falha ao ligar {{name}}',\n      scriptTriggered: 'Script acionado',\n      printStopped: 'Impressão parada',\n      printPaused: 'Impressão pausada',\n      printResumed: 'Impressão retomada',\n      referenceDeleted: 'Referência excluída',\n      detectionAreaSaved: 'Área de detecção salva',\n      failedToRunScript: 'Falha ao executar script',\n      failedToStopPrint: 'Falha ao parar impressão',\n      failedToPausePrint: 'Falha ao pausar impressão',\n      failedToResumePrint: 'Falha ao retomar impressão',\n      failedToControlChamberLight: 'Falha ao controlar a luz da câmara',\n      failedToSetSpeed: 'Falha ao definir a velocidade de impressão',\n      failedToUpdateSetting: 'Falha ao atualizar configuração',\n      failedToSkipObjects: 'Falha ao ignorar objetos',\n      failedToRereadRfid: 'Falha ao reler RFID',\n      failedToCheckPlate: 'Falha ao verificar a placa',\n      failedToUpdateLabel: 'Falha ao atualizar etiqueta',\n      failedToDeleteReference: 'Falha ao excluir referência',\n      failedToSaveDetectionArea: 'Falha ao salvar área de detecção',\n      plateCheckEnabled: 'Verificação da placa ativada',\n      plateCheckDisabled: 'Verificação da placa desativada',\n      calibrationSaved: 'Calibração salva!',\n      calibrationFailed: 'Falha na calibração',\n      rfidRereadInitiated: 'Releitura de RFID iniciada',\n    },\n    // Connection status\n    connection: {\n      connected: 'Conectado',\n      offline: 'Offline',\n    },\n    plateStatus: {\n      markCleared: 'Marcar placa como liberada',\n      cleared: 'Placa liberada',\n      notCleared: 'Placa não liberada',\n      inUse: 'Placa em uso',\n    },\n    // Queue info\n    queue: {\n      inQueue: '{{count}} impressão na fila',\n      inQueue_plural: '{{count}} impressões na fila',\n    },\n    // Controls section\n    controls: 'Controles',\n    // RFID\n    rfid: {\n      reread: 'Releitura de RFID',\n    },\n    bedJog: {\n      title: 'Mover a mesa de impressão',\n      bed: 'Mesa',\n      step: 'Passo (mm)',\n      up: 'Mover mesa para cima',\n      down: 'Mover mesa para baixo',\n      disabledWhilePrinting: 'Desativado durante a impressão',\n      notHomedTitle: 'Impressora não referenciada',\n      notHomedMessage: 'A impressora não foi referenciada desde a última impressão. Execute a referência automática primeiro para um posicionamento seguro (estaciona o cabeçote, depois referencia X, Y e Z), ou mova assim mesmo — os fins de curso de software serão ignorados.',\n      homeZ: 'Referência automática',\n      moveAnyway: 'Mover assim mesmo',\n      homingStarted: 'Referenciando impressora automaticamente…',\n    },\n    // Permissions\n    permission: {\n      noAdd: 'Você não tem permissão para adicionar impressoras',\n      noEdit: 'Você não tem permissão para editar impressoras',\n      noDelete: 'Você não tem permissão para excluir impressoras',\n      noControl: 'Você não tem permissão para controlar impressoras',\n      noFiles: 'Você não tem permissão para acessar arquivos de impressora',\n      noAmsRfid: 'Você não tem permissão para reler RFID AMS',\n      noSmartPlugControl: 'Você não tem permissão para controlar tomadas inteligentes',\n      noCamera: 'Você não tem permissão para visualizar câmeras',\n    },\n    // Add/Edit modal\n    modal: {\n      addTitle: 'Adicionar Impressora',\n      editTitle: 'Editar Impressora',\n      myPrinter: 'Minha Impressora',\n      selectModel: 'Selecionar modelo...',\n      locationGroup: 'Localização / Grupo (opcional)',\n      locationPlaceholder: 'ex.: Oficina, Escritório, Porão',\n      autoArchiveLabel: 'Arquivar automaticamente impressões concluídas',\n      fromPrinterSettings: 'A partir das configurações da impressora',\n      modelOptional: 'Modelo (opcional)',\n      saveChanges: 'Salvar alterações',\n    },\n    // Skip objects\n    skipObjects: {\n      tooltip: 'Ignorar objetos',\n      onlyWhilePrinting: 'Ignorar objetos (apenas durante a impressão)',\n      requiresMultiple: 'Ignorar objetos (requer 2+ objetos)',\n      title: 'Ignorar Objetos',\n      matchIdsInfo: 'Correspondência de IDs com o display da sua impressora',\n      printerShowsIds: 'A tela da impressora mostra os IDs dos objetos na placa de construção',\n      skipSelected: 'Ignorar Selecionados',\n      skipping: 'Ignorando...',\n      noObjectsSelected: 'Nenhum objeto selecionado',\n      selectObjectsToSkip: 'Selecione os objetos que deseja ignorar na impressão atual',\n      skipped: 'Ignorado',\n      objectsSkipped: 'Objetos ignorados',\n      activeCount: '{{count}} ativo',\n      waitForLayer: 'Aguarde a camada 2+ para ignorar objetos (atualmente na camada {{layer}})',\n      skip: 'Ignorar',\n      confirmTitle: 'Ignorar Objeto?',\n      confirmMessage: 'Tem certeza de que deseja ignorar \"{{name}}\"? Isso não pode ser desfeito.',\n    },\n    // Confirm modals\n    confirm: {\n      deleteTitle: 'Excluir Impressora',\n      deleteMessage: 'Tem certeza de que deseja excluir \"{{name}}\"? Isso removerá todas as configurações de conexão.',\n      deleteArchivesNote: 'Todo o histórico de impressão desta impressora será permanentemente excluído.',\n      keepArchivesNote: 'O histórico de impressão será mantido, mas não estará mais associado a esta impressora.',\n      stopTitle: 'Parar Impressão',\n      stopMessage: 'Tem certeza de que deseja parar a impressão atual em \"{{name}}\"? Isso cancelará o trabalho de impressão.',\n      stopButton: 'Parar Impressão',\n      pauseTitle: 'Pausar Impressão',\n      pauseMessage: 'Tem certeza de que deseja pausar a impressão atual em \"{{name}}\"?',\n      pauseButton: 'Pausar Impressão',\n      resumeTitle: 'Retomar Impressão',\n      resumeMessage: 'Tem certeza de que deseja retomar a impressão em \"{{name}}\"?',\n      resumeButton: 'Retomar Impressão',\n      powerOnTitle: 'Ligar Impressora',\n      powerOnMessage: 'Tem certeza de que deseja ligar a impressora \"{{name}}\"?',\n      powerOnButton: 'Ligar',\n      powerOffTitle: 'Desligar Impressora',\n      powerOffMessage: 'Tem certeza de que deseja desligar a impressora \"{{name}}\"?',\n      powerOffWarning: 'AVISO: \"{{name}}\" está imprimindo no momento! Tem certeza de que deseja desligar a impressora? Isso interromperá a impressão e pode danificar a impressora.',\n      powerOffButton: 'Desligar',\n    },\n    // Bulk actions\n    bulk: {\n      select: 'Selecionar',\n      selectAll: 'Selecionar tudo',\n      selectByLocation: 'Selecionar por local',\n      selected: '{{count}} selecionado(s)',\n      actions: {\n        stop: 'Parar',\n        pause: 'Pausar',\n        resume: 'Retomar',\n        clearPlate: 'Limpar mesa',\n        clearHMS: 'Limpar notificações',\n      },\n      confirm: {\n        stopTitle: 'Parar {{count}} impressões',\n        stopMessage: 'Isso cancelará as impressões ativas em {{count}} impressora(s). Esta ação não pode ser desfeita.',\n        stopButton: 'Parar todas',\n        pauseTitle: 'Pausar {{count}} impressões',\n        pauseMessage: 'Isso pausará as impressões ativas em {{count}} impressora(s).',\n        pauseButton: 'Pausar todas',\n        clearPlateTitle: 'Limpar {{count}} mesas de impressão',\n        clearPlateMessage: 'Isso limpará a mesa de impressão em {{count}} impressora(s) e pode iniciar trabalhos na fila.',\n        clearPlateButton: 'Limpar todas',\n      },\n      success: '{{action}} concluído em {{count}} impressora(s)',\n      partial: '{{succeeded}} bem-sucedido(s), {{failed}} falhou/falharam',\n      noneApplicable: 'Nenhuma impressora selecionada está no estado correto para esta ação',\n      selectByState: 'Selecionar por estado',\n    },\n    // Discovery\n    discovery: {\n      title: 'Descobrir Impressoras',\n      searching: 'Procurando...',\n      scanning: 'Escaneando...',\n      scanProgress: 'Escaneando... {{scanned}}/{{total}}',\n      foundPrinters: '{{count}} impressora(s) encontrada(s)',\n      noPrintersFound: 'Nenhuma impressora encontrada',\n      noPrintersFoundSubnet: 'Nenhuma impressora encontrada na sub-rede especificada.',\n      noPrintersFoundNetwork: 'Nenhuma impressora encontrada na rede.',\n      allConfigured: 'Todas as impressoras descobertas já estão configuradas.',\n      alreadyAdded: 'Já adicionada',\n      select: 'Selecionar',\n      manualEntry: 'Entrada Manual',\n      addFromCloud: 'Adicionar da Nuvem',\n      subnetToScan: 'Sub-rede para escanear',\n      dockerNote: 'Docker detectado. Insira a sub-rede da sua impressora em notação CIDR. Requer network_mode: host no docker-compose.yml.',\n      scanSubnet: 'Escanear Sub-rede para Impressoras',\n      discoverNetwork: 'Descobrir Impressoras na Rede',\n      scanningSubnet: 'Escaneando sub-rede para impressoras Bambu...',\n      scanningNetwork: 'Escaneando rede...',\n      serialRequired: 'Serial necessário',\n      unknown: 'Desconhecido',\n      failedToStart: 'Falha ao iniciar a descoberta',\n    },\n    // AMS Drying\n    drying: {\n      start: 'Iniciar secagem',\n      stop: 'Parar secagem',\n      temperature: 'Temperatura',\n      duration: 'Duração',\n      hours: 'horas',\n      timeRemaining: '{{time}} restante',\n      active: 'Secagem',\n      notSupported: 'Secagem não suportada',\n      powerRequired: 'Conecte o adaptador de energia AMS para ativar a secagem',\n      startingDrying: 'Iniciando secagem...',\n      stoppingDrying: 'Parando secagem...',\n      rotateTray: 'Girar o carretel durante a secagem',\n    },\n    // Filaments section\n    filaments: 'Filamentos',\n    // Camera\n    openCameraOverlay: 'Abrir sobreposição da câmera',\n    openCameraWindow: 'Abrir câmera em nova janela',\n    // Firmware\n    firmwareUpdateAvailable: 'Atualização de firmware disponível: {{current}} → {{latest}}',\n    firmwareUpToDate: 'Firmware {{version}} — Atualizado',\n    firmwareUpdateButton: 'Atualizar',\n    // Plate detection\n    plateDetection: {\n      noPermission: 'Você não tem permissão para atualizar impressoras',\n      enabledClick: 'Verificação da placa ativada - Clique para desativar',\n      disabledClick: 'Verificação da placa desativada - Clique para ativar',\n      manageCalibration: 'Gerenciar calibração da detecção da placa',\n      calibrationRequired: 'Calibração necessária',\n      calibrationInstructions: 'Certifique-se de que a placa de construção esteja <strong>completamente vazia</strong>, em seguida clique em Calibrar.',\n      calibrationDescription: 'A calibração captura uma imagem de referência da placa vazia. Verificações futuras compararão com esta referência para detectar objetos.',\n      calibrationTip: '<strong>Dica:</strong> Você pode armazenar até 5 calibrações para diferentes placas. O sistema usa automaticamente a melhor correspondência ao verificar.',\n      plateEmpty: 'A placa parece vazia',\n      objectsDetected: 'Objetos detectados na placa',\n      confidence: 'Confiança',\n      difference: 'Diferença',\n      analysisPreview: 'Pré-visualização da análise:',\n      analysisLegend: 'Caixa verde = área de detecção, Sobreposição vermelha = diferenças em relação à calibração',\n      savedReferences: 'Referências salvas ({{count}}/{{max}})',\n      deleteReference: 'Excluir referência',\n      labelPlaceholder: 'Etiqueta...',\n      clickToEdit: '{{label}} - Clique para editar',\n      clickToAddLabel: 'Clique para adicionar etiqueta',\n    },\n    // Speed\n    speed: {\n      title: 'Velocidade de impressão',\n      silent: 'Silencioso (50%)',\n      standard: 'Padrão (100%)',\n      sport: 'Sport (124%)',\n      ludicrous: 'Ludicrous (166%)',\n    },\n    airduct: {\n      title: 'Modo do duto de ar',\n      cooling: 'Resfriamento',\n      heating: 'Aquecimento',\n    },\n    noSdCard: 'Sem SD',\n    door: {\n      open: 'Aberta',\n      closed: 'Fechada',\n    },\n    // Fans\n    fans: {\n      partCooling: 'Ventilador de resfriamento da peça',\n      auxiliary: 'Ventilador auxiliar',\n      chamber: 'Ventilador da câmara',\n    },\n    // HMS errors\n    clickToViewHmsErrors: 'Clique para ver erros do HMS',\n    estimatedCompletion: 'Tempo estimado de conclusão',\n    plateNumber: 'Placa {{number}}',\n    slotOptions: 'Opções de slot',\n    // AMS hover popup\n    amsPopup: {\n      friendlyName: 'Nome do AMS',\n      friendlyNamePlaceholder: 'ex.: Nome amigável do AMS',\n      serialNumber: 'Número de série',\n      firmwareVersion: 'Firmware',\n      save: 'Salvar',\n      clear: 'Limpar',\n      noEditPermission: 'Você não tem permissão para renomear unidades AMS',\n    },\n    // Firmware modal\n    firmwareModal: {\n      title: 'Atualização de Firmware',\n      titleUpToDate: 'Informações do Firmware',\n      currentVersion: 'Atual:',\n      latestVersion: 'Última:',\n      releaseNotes: 'Notas de Lançamento',\n      checkingPrereqs: 'Verificando pré-requisitos...',\n      sdCardReady: 'Cartão SD pronto. Clique abaixo para enviar o firmware.',\n      uploadedSuccess: 'Firmware enviado para o cartão SD!',\n      applyInstructions: 'Para aplicar a atualização na sua impressora:',\n      step1: 'Na tela sensível ao toque da impressora, vá para <strong>Configurações</strong>',\n      step2: 'Navegue até <strong>Firmware</strong>',\n      step3: 'Selecione <strong>Atualizar a partir do cartão SD</strong>',\n      step4: 'A atualização levará de 10 a 20 minutos',\n      done: 'Concluído',\n      starting: 'Iniciando...',\n      uploadFirmware: 'Enviar Firmware',\n      uploadFailed: 'Falha ao iniciar o envio: {{error}}',\n      uploadedToast: 'Firmware enviado! Inicie a atualização na tela da impressora.',\n    },\n    accessCodePlaceholder: 'Deixe vazio para manter o atual',\n    // ROI editor\n    roi: {\n      title: 'Área de Detecção (ROI)',\n      xStart: 'Início X',\n      yStart: 'Início Y',\n      width: 'Largura',\n      height: 'Altura',\n      instruction: 'Ajuste a área de detecção para focar na placa de construção. A caixa verde na pré-visualização mostra a área atual.',\n    },\n    developerModeWarning: 'O modo desenvolvedor LAN não está ativado em: {{names}}. Alguns recursos podem não funcionar.',\n    howToEnable: 'Como ativar',\n    incompatibleFile: 'Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}',\n    dropNotPrintable: 'Apenas arquivos .gcode e .gcode.3mf podem ser impressos',\n    dropToPrint: 'Solte para imprimir',\n    cannotPrint: 'Impressora ocupada',\n  },\n\n  // Archives page\n  archives: {\n    title: 'Arquivos de Impressão',\n    searchPlaceholder: 'Pesquisar arquivos...',\n    filterByPrinter: 'Filtrar por impressora',\n    filterByStatus: 'Filtrar por status',\n    sortBy: 'Ordenar por',\n    sortNewest: 'Mais recentes primeiro',\n    sortOldest: 'Mais antigos primeiro',\n    sortName: 'Nome',\n    sortDuration: 'Duração',\n    sortLargest: 'Maiores primeiro',\n    sortSmallest: 'Menores primeiro',\n    sortSize: 'Tamanho',\n    noArchives: 'Nenhum arquivo encontrado',\n    noArchivesSearch: 'Nenhum arquivo corresponde à sua pesquisa',\n    originalPrintNotVisible: 'Impressão original não visível - tente limpar os filtros',\n    noArchivesYet: 'Ainda não há arquivos',\n    prints: 'impressões',\n    pagination: {\n      showing: 'Mostrando',\n      to: 'a',\n      of: 'de',\n      show: 'Mostrar',\n      page: 'Página',\n      all: 'Todos',\n    },\n    loadingArchives: 'Carregando arquivos...',\n    releaseToUpload: 'Solte para enviar',\n    showAll: 'Mostrar todos',\n    showFavoritesOnly: 'Mostrar apenas favoritos',\n    gridView: 'Visualização em grade',\n    listView: 'Visualização em lista',\n    calendarView: 'Visualização em calendário',\n    logView: 'Registro de impressão',\n    manageTags: 'Gerenciar etiquetas',\n    showFailedPrints: 'Mostrar impressões falhas',\n    hideFailedPrints: 'Ocultar impressões falhas',\n    hideDuplicates: 'Ocultar duplicados',\n    viewOriginalPrint: 'Clique para visualizar a impressão original (#{{id}})',\n    printTime: 'Tempo de impressão',\n    filamentUsed: 'Filamento usado',\n    cost: 'Custo',\n    reprint: 'Reimprimir',\n    preview: 'Pré-visualizar',\n    deleteArchive: 'Excluir arquivo',\n    deleteConfirm: 'Tem certeza de que deseja excluir este arquivo?',\n    favorite: 'Favorito',\n    unfavorite: 'Remover dos favoritos',\n    viewDetails: 'Ver detalhes',\n    status: {\n      completed: 'Concluído',\n      failed: 'Falhou',\n      stopped: 'Parado',\n    },\n    toast: {\n      source3mfAttached: 'Arquivo de origem 3MF anexado: {{filename}}',\n      failedUploadSource3mf: 'Falha ao enviar arquivo de origem 3MF',\n      source3mfRemoved: 'Arquivo de origem 3MF removido',\n      failedRemoveSource3mf: 'Falha ao remover arquivo de origem 3MF',\n      f3dAttached: 'F3D anexado: {{filename}}',\n      failedUploadF3d: 'Falha ao enviar F3D',\n      f3dRemoved: 'F3D removido',\n      failedRemoveF3d: 'Falha ao remover F3D',\n      timelapseAttached: 'Timelapse anexado: {{filename}}',\n      timelapseAlreadyAttached: 'Timelapse já anexado',\n      noMatchingTimelapse: 'Nenhum timelapse correspondente encontrado',\n      failedScanTimelapse: 'Falha ao escanear timelapse',\n      failedAttachTimelapse: 'Falha ao anexar timelapse',\n      timelapseRemoved: 'Timelapse removido',\n      failedRemoveTimelapse: 'Falha ao remover timelapse',\n      timelapseUploaded: 'Timelapse enviado: {{filename}}',\n      failedUploadTimelapse: 'Falha ao enviar timelapse',\n      archiveDeleted: 'Arquivo excluído',\n      failedDeleteArchive: 'Falha ao excluir arquivo',\n      addedToFavorites: 'Adicionado aos favoritos',\n      removedFromFavorites: 'Removido dos favoritos',\n      projectUpdated: 'Projeto atualizado',\n      failedUpdateProject: 'Falha ao atualizar projeto',\n      linkCopied: 'Link copiado para a área de transferência',\n      failedCopyLink: 'Falha ao copiar link',\n      photoDeleted: 'Foto excluída',\n      failedDeletePhoto: 'Falha ao excluir foto',\n      failedDeleteArchives: 'Falha ao excluir arquivos',\n      failedUpdateFavorites: 'Falha ao atualizar favoritos',\n      exportDownloaded: 'Exportação baixada',\n      exportFailed: 'Falha na exportação',\n    },\n    menu: {\n      print: 'Imprimir',\n      schedule: 'Agendar',\n      openInBambuStudio: 'Abrir no Slicer',\n      slice: 'Fatiar',\n      externalLink: 'Link externo',\n      viewOnMakerWorld: 'Ver no MakerWorld',\n      preview3d: 'Pré-visualização 3D',\n      viewTimelapse: 'Ver Timelapse',\n      scanForTimelapse: 'Escanear Timelapse',\n      uploadTimelapse: 'Enviar Timelapse',\n      removeTimelapse: 'Remover Timelapse',\n      downloadSource3mf: 'Baixar Source 3MF',\n      uploadSource3mf: 'Enviar Source 3MF',\n      replaceSource3mf: 'Substituir Source 3MF',\n      removeSource3mf: 'Remover Source 3MF',\n      uploadF3d: 'Enviar F3D',\n      replaceF3d: 'Substituir F3D',\n      downloadF3d: 'Baixar F3D',\n      removeF3d: 'Remover F3D',\n      download: 'Baixar',\n      copyDownloadLink: 'Copiar link de download',\n      qrCode: 'Qr Code',\n      viewPhotos: 'Ver fotos',\n      viewPhotosCount: 'Ver fotos ({{count}})',\n      projectPage: 'Página do projeto',\n      addToFavorites: 'Adicionar aos favoritos',\n      removeFromFavorites: 'Remover dos favoritos',\n      edit: 'Editar',\n      goToProject: 'Ir para o projeto: {{name}}',\n      addToProject: 'Adicionar ao projeto',\n      removeFromProject: 'Remover do projeto',\n      loading: 'Carregando...',\n      noProjectsAvailable: 'Nenhum projeto disponível',\n      select: 'Selecionar',\n      deselect: 'Desmarcar',\n      delete: 'Excluir',\n    },\n    permission: {\n      noReprint: 'Você não tem permissão para reimprimir este arquivo',\n      noAddToQueue: 'Você não tem permissão para adicionar à fila',\n      noUpdateArchives: 'Você não tem permissão para atualizar arquivos',\n      noUploadFiles: 'Você não tem permissão para enviar arquivos',\n      noDownload: 'Você não tem permissão para baixar arquivos',\n      noCopyLink: 'Você não tem permissão para copiar links de download',\n      noDelete: 'Você não tem permissão para excluir este arquivo',\n      noCreate: 'Você não tem permissão para criar arquivos',\n    },\n    card: {\n      previousPlate: 'Placa anterior',\n      nextPlate: 'Próxima placa',\n      plateNumber: 'Placa {{index}}',\n      moreOptions: 'Clique com o botão direito para mais opções',\n      addToFavorites: 'Adicionar aos favoritos',\n      removeFromFavorites: 'Remover dos favoritos',\n      cancelled: 'cancelado',\n      failed: 'falha',\n      duplicate: 'duplicado',\n      duplicateTitle: 'Este modelo já foi impresso antes',\n      openSource3mf: 'Abrir source 3MF no Bambu Studio (clique com o botão direito para mais opções)',\n      downloadF3d: 'Baixar arquivo de design do Fusion 360',\n      viewTimelapse: 'Ver timelapse',\n      viewPhoto: 'Ver 1 foto',\n      viewPhotos: 'Ver {{count}} fotos',\n      openFolder: 'Abrir pasta: {{name}}',\n      slicedFile: 'Arquivo fatiado - pronto para imprimir',\n      sourceFile: 'Apenas arquivo fonte - nenhum mapeamento AMS disponível',\n      gcode: 'GCODE',\n      source: 'SOURCE',\n      project: 'Projeto: {{name}}',\n      estimated: 'Estimado: {{time}}',\n      actual: 'Real: {{time}}',\n      accuracy: 'Precisão: {{percent}}%',\n      filament: '{{weight}}g',\n      layer: '{{count}} camada',\n      layers: '{{count}} camadas',\n      object: '{{count}} objeto',\n      objects: '{{count}} objetos',\n      slicedFor: 'Fatiado para {{model}}',\n      uploadedBy: 'Enviado por',\n      noPermissionReprint: 'Você não tem permissão para reimprimir',\n      noFileForReprint: 'Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada',\n      noPermissionEdit: 'Você não tem permissão para editar arquivos',\n      noPermissionDelete: 'Você não tem permissão para excluir arquivos',\n      reprint: 'Reimprimir',\n      schedulePrint: 'Agendar impressão',\n      schedule: 'Agendar',\n      openInBambuStudio: 'Abrir no Bambu Studio',\n      openInBambuStudioToSlice: 'Abrir no Bambu Studio para fatiar',\n      slice: 'Fatiar',\n      externalLink: 'Link externo',\n      makerWorld: 'MakerWorld: {{designer}}',\n      viewProject: 'Ver projeto',\n      noExternalLink: 'Nenhum link externo',\n      preview3d: 'Visualização 3D',\n      download: 'Baixar',\n      edit: 'Editar',\n      delete: 'Excluir',\n    },\n    modal: {\n      deleteArchive: 'Excluir Arquivo',\n      deleteConfirm: 'Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.',\n      deleteButton: 'Excluir',\n      removeSource3mf: 'Remover Source 3MF',\n      removeSource3mfConfirm: 'Tem certeza de que deseja remover o arquivo source 3MF de \"{{name}}\"? Isso excluirá o arquivo original do projeto do fatiador.',\n      removeButton: 'Remover',\n      removeF3d: 'Remover F3D',\n      removeF3dConfirm: 'Tem certeza de que deseja remover o arquivo de design do Fusion 360 de \"{{name}}\"?',\n      removeTimelapse: 'Remover Timelapse',\n      removeTimelapseConfirm: 'Tem certeza de que deseja remover o vídeo timelapse de \"{{name}}\"?',\n      timelapse: '{{name}} - Timelapse',\n      selectTimelapse: 'Selecionar Timelapse',\n      selectTimelapseDesc: 'Nenhuma correspondência automática encontrada. Selecione o timelapse para esta impressão:',\n      deleteArchives: 'Excluir Arquivos',\n      deleteArchivesConfirm: 'Tem certeza de que deseja excluir {{count}} arquivo(s)? Esta ação não pode ser desfeita.',\n      deleteCount: 'Excluir {{count}}',\n    },\n    page: {\n      title: 'Arquivos',\n      printsCount: '{{filtered}} de {{total}} impressões',\n      dropFilesHere: 'Solte arquivos .3mf aqui',\n      releaseToUpload: 'Solte para enviar',\n      only3mfSupported: 'Apenas arquivos .3mf são suportados',\n      close: 'Fechar',\n      selected: '{{count}} selecionado(s)',\n      selectAll: 'Selecionar Todos',\n      tags: 'Tags',\n      project: 'Projeto',\n      favorite: 'Favorito',\n      delete: 'Excluir',\n      toggledFavorites: 'Favoritos alternados para {{count}} arquivo(s)',\n      failedUpdateFavorites: 'Falha ao atualizar favoritos',\n      archivesDeleted: '{{count}} arquivo(s) excluído(s)',\n      failedDeleteArchives: 'Falha ao excluir arquivos',\n      photoDeleted: 'Foto excluída',\n      failedDeletePhoto: 'Falha ao excluir foto',\n    },\n    list: {\n      name: 'Nome',\n      printer: 'Impressora',\n      date: 'Data',\n      size: 'Tamanho',\n      actions: 'Ações',\n      hasTimelapse: 'Possui timelapse',\n    },\n    log: {\n      date: 'Data',\n      printName: 'Nome da Impressão',\n      printer: 'Impressora',\n      user: 'Usuário',\n      status: 'Status',\n      duration: 'Duração',\n      filament: 'Filamento',\n      allPrinters: 'Todas as Impressoras',\n      allUsers: 'Todos os Usuários',\n      allStatuses: 'Todos os Status',\n      cancelled: 'Cancelado',\n      skipped: 'Ignorado',\n      dateFrom: 'De',\n      dateTo: 'Até',\n      noEntries: 'Nenhuma entrada de registro de impressão encontrada',\n      showing: 'Mostrando {{count}} de {{total}} entradas',\n      rowsPerPage: 'Linhas',\n      page: 'Página',\n      prev: 'Anterior',\n      next: 'Próxima',\n      clearLog: 'Limpar Registro',\n      clearLogTitle: 'Limpar Registro de Impressão',\n      clearLogConfirm: 'Todas as entradas do registro de impressão serão permanentemente excluídas. Arquivos e itens da fila não serão afetados. Esta ação não pode ser desfeita. Tem certeza?',\n      clearLogButton: 'Limpar Tudo',\n      cleared: '{{count}} entradas do registro de impressão limpas',\n      clearFailed: 'Falha ao limpar o registro de impressão',\n    },\n  },\n\n  // Queue page\n  queue: {\n    title: 'Fila de Impressão',\n    subtitle: 'Agende e gerencie seus trabalhos de impressão',\n    addToQueue: 'Adicionar à Fila',\n    // Print modal\n    print: 'Imprimir',\n    reprint: 'Reimprimir',\n    schedulePrint: 'Agendar Impressão',\n    editQueueItem: 'Editar Item da Fila',\n    printToPrinters: 'Imprimir para {{count}} Impressoras',\n    queueToPrinters: 'Adicionar à Fila para {{count}} Impressoras',\n    queueSelectedPlates: 'Adicionar {{count}} placas à fila',\n    selectAllPlates: 'Selecionar todas as {{count}} placas',\n    deselectAll: 'Desmarcar tudo',\n    printQueued: 'Impressão adicionada à fila',\n    itemsQueued: '{{count}} itens adicionados à fila',\n    sending: 'Enviando...',\n    sendingProgress: 'Enviando {{current}}/{{total}}...',\n    adding: 'Adicionando...',\n    addingProgress: 'Adicionando {{current}}/{{total}}...',\n    savingProgress: 'Salvando {{current}}/{{total}}...',\n    clearQueue: 'Limpar Fila',\n    clearHistory: 'Limpar Histórico',\n    emptyQueue: 'Fila vazia',\n    position: 'Posição',\n    scheduledTime: 'Hora Agendada',\n    moveUp: 'Mover para Cima',\n    moveDown: 'Mover para Baixo',\n    startNow: 'Iniciar Agora',\n    printingInProgress: 'Impressão em andamento...',\n    viewArchive: 'Ver Arquivo',\n    viewInFileManager: 'Ver no Gerenciador de Arquivos',\n    itemCount: '{{count}} item',\n    itemCount_plural: '{{count}} itens',\n    dragToReorder: 'Arraste para reordenar (apenas ASAP)',\n    reorderHint: 'A posição afeta apenas itens ASAP. Itens agendados são executados no horário definido.',\n    sjf: {\n      label: 'SJF',\n      tooltip: 'Trabalho mais curto primeiro — o agendador prioriza impressões mais curtas',\n    },\n    addedBy: 'Adicionado por {{name}}',\n    nextInQueue: 'Próximo na fila',\n    clearPlateSuccess: 'Placa limpa — pronta para a próxima impressão',\n    plateNumber: 'Placa {{index}}',\n    // Batch / quantity\n    quantity: 'Quantidade',\n    quantityHint: 'Cria {{count}} itens na fila',\n    activeBatches: 'Lotes ativos',\n    batchProgress: '{{completed}} de {{total}} concluídos',\n    cancelBatch: 'Cancelar restantes',\n    batchCancelled: 'Itens restantes do lote cancelados',\n    cancelBatchConfirmTitle: 'Cancelar lote',\n    cancelBatchConfirmMessage: 'Cancelar todos os itens pendentes restantes neste lote?',\n    batch: 'Lote',\n    // Sections\n    sections: {\n      currentlyPrinting: 'Imprimindo Atualmente',\n      queued: 'Na Fila',\n      history: 'Histórico',\n    },\n    // Status\n    status: {\n      pending: 'Pendente',\n      waiting: 'Aguardando',\n      printing: 'Imprimindo',\n      paused: 'Pausado',\n      completed: 'Concluído',\n      failed: 'Falhou',\n      skipped: 'Ignorado',\n      cancelled: 'Cancelado',\n    },\n    // Summary cards\n    summary: {\n      printing: 'Imprimindo',\n      queued: 'Na Fila',\n      totalTime: 'Tempo Total da Fila',\n      totalWeight: 'Peso Total da Fila',\n      history: 'Histórico',\n    },\n    // Filters\n    filter: {\n      allPrinters: 'Todas as Impressoras',\n      unassigned: 'Não Atribuído',\n      allStatus: 'Todos os Status',\n      allLocations: 'Todos os Locais',\n      any: 'Qualquer',\n    },\n    // Sort\n    sort: {\n      byPosition: 'Ordenar por Posição',\n      byName: 'Ordenar por Nome',\n      byPrinter: 'Ordenar por Impressora',\n      bySchedule: 'Ordenar por Agendamento',\n      byDate: 'Ordenar por Data',\n      ascendingOldest: 'Crescente (mais antigo primeiro)',\n      descendingNewest: 'Decrescente (mais recente primeiro)',\n    },\n    // Badges\n    badges: {\n      staged: 'Preparado (início manual)',\n      requiresPrevious: 'Requer sucesso anterior',\n      autoPowerOff: 'Desligamento automático',\n      gcodeInjection: 'G-code',\n    },\n    // Empty state\n    empty: {\n      title: 'Nenhuma impressão agendada',\n      description: 'Agende uma impressão a partir da página de Arquivos usando a opção \"Agendar\" no menu de contexto, ou arraste e solte arquivos para começar.',\n    },\n    // Time\n    time: {\n      asap: 'ASAP',\n      overdue: 'Atrasado',\n      now: 'Agora',\n      lessThanMinute: 'Em menos de um minuto',\n      inMinutes: 'Em {{count}} min',\n      inHours: 'Em {{count}} horas',\n    },\n    // Actions\n    actions: {\n      stopPrint: 'Parar Impressão',\n      startPrint: 'Iniciar Impressão',\n      requeue: 'Reenfileirar',\n    },\n    // Bulk edit\n    bulkEdit: {\n      title: 'Editar {{count}} Item',\n      title_plural: 'Editar {{count}} Itens',\n      description: 'Apenas as configurações alteradas serão aplicadas aos itens selecionados.',\n      printer: 'Impressora',\n      noChange: '— Sem alterações —',\n      queueOptions: 'Opções de Fila',\n      staged: 'Preparado (início manual)',\n      autoPowerOff: 'Desligamento automático após impressão',\n      requirePrevious: 'Requer sucesso anterior',\n      printOptions: 'Opções de Impressão',\n      bedLevelling: 'Nivelamento da Mesa',\n      flowCalibration: 'Calibração de Fluxo',\n      vibrationCalibration: 'Calibração de Vibração',\n      layerInspection: 'Inspeção da Primeira Camada',\n      timelapse: 'Timelapse',\n      useAms: 'Usar AMS',\n      applyChanges: 'Aplicar Alterações',\n      selectAll: 'Selecionar Todos',\n      deselectAll: 'Desmarcar Todos',\n      selected: '{{count}} selecionado(s)',\n      editSelected: 'Editar Selecionados',\n      cancelSelected: 'Cancelar Selecionados',\n    },\n    // Confirmations\n    confirm: {\n      cancelTitle: 'Cancelar Impressão Agendada',\n      cancelMessage: 'Tem certeza de que deseja cancelar \"{{name}}\"?',\n      stopTitle: 'Parar Impressão',\n      stopMessage: 'Tem certeza de que deseja parar a impressão atual \"{{name}}\"? Isso cancelará o trabalho de impressão na impressora.',\n      removeTitle: 'Remover do Histórico',\n      removeMessage: 'Tem certeza de que deseja remover \"{{name}}\" do histórico da fila?',\n      clearHistoryTitle: 'Limpar Histórico',\n      clearHistoryMessage: 'Tem certeza de que deseja remover todos os {{count}} itens do histórico?',\n      cancelButton: 'Cancelar Impressão',\n      stopButton: 'Parar Impressão',\n      thisPrint: 'esta impressão',\n      thisItem: 'este item',\n    },\n    // Toast messages\n    toast: {\n      cancelled: 'Item da fila cancelado',\n      cancelFailed: 'Falha ao cancelar item',\n      removed: 'Item da fila removido',\n      removeFailed: 'Falha ao remover item',\n      stopped: 'Impressão parada',\n      stopFailed: 'Falha ao parar impressão',\n      released: 'Impressão liberada para a fila',\n      startFailed: 'Falha ao iniciar impressão',\n      reorderFailed: 'Falha ao reordenar fila',\n      historyCleared: 'Limpar {{count}} item(s) do histórico',\n      clearHistoryFailed: 'Falha ao limpar histórico',\n      updateFailed: 'Falha ao atualizar itens',\n      bulkCancelled: 'Cancelado {{count}} item(s)',\n      bulkCancelFailed: 'Falha ao cancelar itens',\n    },\n    // Timeline view\n    timeline: {\n      listView: 'Lista',\n      timelineView: 'Linha do tempo',\n      unassigned: 'Não atribuído',\n      noData: 'Nenhuma impressão agendada para este dia',\n      allDoneBy: 'Todas as impressões concluídas até {{time}}',\n      staged: 'Preparado',\n      filterAll: 'Mostrar tudo',\n      filterPrinting: 'Imprimindo',\n      filterQueued: 'Na fila',\n      time: {\n        anyMoment: 'a qualquer momento',\n        minutesLeft: '{{minutes}}m restantes',\n        hoursLeft: '{{hours}}h restantes',\n        hoursMinutesLeft: '{{hours}}h {{minutes}}m restantes',\n      },\n      day: {\n        previous: 'Dia anterior',\n        next: 'Próximo dia',\n        today: 'Hoje',\n      },\n    },\n    // Permissions\n    permissions: {\n      noStopPrint: 'Você não tem permissão para parar impressões',\n      noStartPrint: 'Você não tem permissão para iniciar impressões',\n      noEdit: 'Você não tem permissão para editar este item da fila',\n      noCancel: 'Você não tem permissão para cancelar este item da fila',\n      noRequeue: 'Você não tem permissão para reenfileirar itens',\n      noRemove: 'Você não tem permissão para remover este item da fila',\n      noClearHistory: 'Você não tem permissão para limpar todo o histórico',\n      noEditItems: 'Você não tem permissão para editar itens da fila',\n      noCancelItems: 'Você não tem permissão para cancelar itens da fila',\n    },\n  },\n\n  backgroundDispatch: {\n    unknownFile: 'Unknown file',\n    unknownPrinter: 'Unknown printer',\n    startingPrints: 'Starting prints',\n    progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',\n    expandDetails: 'Expand dispatch details',\n    collapseDetails: 'Collapse dispatch details',\n    dismissToast: 'Dismiss dispatch toast',\n    cancelDispatchJob: 'Cancel dispatch job',\n    cancel: 'Cancel',\n    cancelling: 'Cancelling…',\n    status: {\n      dispatched: 'Dispatched',\n      processing: 'Processing',\n      completed: 'Completed',\n      failed: 'Failed',\n      cancelled: 'Cancelled',\n    },\n    toast: {\n      cancellingUpload: 'Cancelling upload...',\n      cancelled: 'Dispatch cancelled',\n      cancelFailed: 'Failed to cancel dispatch',\n      completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',\n      completeSuccess: 'Background dispatch complete: {{completed}} succeeded',\n      printStartedRemaining: '{{completed}} impressão(ões) iniciada(s), {{remaining}} enviando...',\n    },\n  },\n\n  // Statistics page\n  stats: {\n    title: 'Dashboard',\n    subtitle: 'Arraste os widgets para reorganizar. Clique no ícone de olho para ocultar.',\n    overview: 'Visão Geral',\n    totalPrints: 'Total de Impressões',\n    successRate: 'Taxa de Sucesso',\n    totalPrintTime: 'Tempo Total de Impressão',\n    printTime: 'Tempo de Impressão',\n    totalFilament: 'Filamento Total Utilizado',\n    filamentUsed: 'Filamento Utilizado',\n    filamentCost: 'Custo do Filamento',\n    totalCost: 'Custo Total',\n    energyUsed: 'Energia Utilizada',\n    energyCost: 'Custo da Energia',\n    energyWarmingUpTooltip: 'O monitoramento de energia ainda está coletando snapshots por hora. Os totais por período ficarão precisos quando houver pelo menos um snapshot antes do intervalo selecionado. Valores iniciais podem ser subestimados.',\n    averagePrintTime: 'Tempo Médio de Impressão',\n    printsPerDay: 'Impressões por Dia',\n    byPrinter: 'Por Impressora',\n    printsByPrinter: 'Impressões por Impressora',\n    byMaterial: 'Por Material',\n    byMonth: 'Por Mês',\n    last7Days: 'Últimos 7 Dias',\n    last30Days: 'Últimos 30 Dias',\n    last90Days: 'Últimos 90 Dias',\n    allTime: 'Todo o Tempo',\n    // Widgets\n    quickStats: 'Estatísticas Rápidas',\n    printActivity: 'Atividade de Impressão',\n    filamentTypes: 'Tipos de Filamento',\n    filamentTrends: 'Tendências de Filamento',\n    failureAnalysis: 'Análise de Falhas',\n    timeAccuracy: 'Precisão do Tempo',\n    successful: 'Bem-sucedido:',\n    failed: 'Falhou:',\n    perfectEstimate: '100% = estimativa perfeita',\n    noTimeAccuracyData: 'Nenhum dado de precisão de tempo disponível',\n    noFilamentData: 'Nenhum dado de filamento disponível',\n    noPrinterData: 'Nenhum dado de impressora disponível',\n    noPrintData: 'Nenhum dado de impressão disponível',\n    noPrintDataLast30Days: 'Nenhum dado de impressão nos últimos 30 dias',\n    failureReasons: 'Razões de Falha',\n    topFailureReasons: 'Principais Razões de Falha',\n    failedPrintsCount: '{{failed}} / {{total}} impressões falharam',\n    lastWeekRate: 'Última semana: {{rate}}%',\n    // Actions\n    resetLayout: 'Redefinir Layout',\n    recalculateCosts: 'Recalcular Custos',\n    recalculateCostsHint: 'Recalcular todos os custos do arquivo usando os preços atuais do filamento',\n    exportStats: 'Exportar Estatísticas',\n    exportAsCsv: 'Exportar como CSV',\n    exportAsExcel: 'Exportar como Excel',\n    hiddenCount: '{{count}} Oculto',\n    // Toast\n    exportDownloaded: 'Exportação baixada',\n    exportFailed: 'Falha na exportação',\n    layoutReset: 'Layout redefinido',\n    recalculatedCosts: 'Custos recalculados para {{count}} arquivos',\n    recalculateFailed: 'Falha ao recalcular custos',\n    // Loading\n    loadingStats: 'Carregando estatísticas...',\n    // Permissions\n    noPermissionResetLayout: 'Você não tem permissão para redefinir o layout',\n    noPermissionRecalculate: 'Você não tem permissão para recalcular custos',\n    noPrintDataInRange: 'Sem dados no período selecionado',\n    periodFilament: 'Filamento usado',\n    periodCost: 'Custo',\n    avgPerPrint: 'Média por impressão',\n    usageOverTime: 'Uso ao longo do tempo',\n    filamentByWeight: 'Peso',\n    printDuration: 'Duração da impressão',\n    printerUtilization: 'Utilização da impressora',\n    filamentSuccess: 'Sucesso por material',\n    printHabits: 'Hábitos de impressão',\n    printTimeOfDay: 'Horário de impressão',\n    colorDistribution: 'Distribuição de cores',\n    noColorData: 'Nenhum dado de cor disponível',\n    records: 'Recordes',\n    longestPrint: 'Impressão mais longa',\n    heaviestPrint: 'Impressão mais pesada',\n    mostExpensivePrint: 'Mais cara',\n    busiestDay: 'Dia mais movimentado',\n    successStreak: 'Sequência de sucesso',\n    streakPrint: 'impressão consecutiva',\n    streakPrints: '{{count}} impressões consecutivas',\n    printerStats: 'Estatísticas da impressora',\n    hours: 'horas',\n    avgPrints: 'Méd. impressões',\n    noArchiveData: 'Nenhum dado de impressão disponível',\n    filamentByTime: 'Tempo',\n    avgWeight: 'Méd. peso',\n    avgTime: 'Méd. tempo',\n    filamentByPrints: 'Impressões',\n    timeframe: {\n      today: 'Hoje',\n      'this-week': 'Esta semana',\n      'this-month': 'Este mês',\n      'last-7': 'Últimos 7 dias',\n      'last-30': 'Últimos 30 dias',\n      'last-90': 'Últimos 90 dias',\n      'this-year': 'Este ano',\n      'all-time': 'Todo o período',\n      custom: 'Personalizado',\n      from: 'De',\n      to: 'Até',\n    },\n    allUsers: 'Todos os Usuários',\n    noUser: 'Sem Usuário (Sistema)',\n    filterByUser: 'Filtrar por Usuário',\n  },\n\n  // Maintenance page\n  maintenance: {\n    title: 'Manutenção',\n    overview: 'Visão Geral',\n    allOk: 'Todas as manutenções estão em dia',\n    dueCount: '{{count}} item pendente',\n    dueCount_plural: '{{count}} itens pendentes',\n    warningCount: '{{count}} aviso',\n    warningCount_plural: '{{count}} avisos',\n    totalPrintTime: 'Tempo Total de Impressão',\n    nextMaintenance: 'Próxima Manutenção',\n    nothingDue: 'Nada pendente',\n    tasks: 'Tarefas',\n    lastPerformed: 'Última execução',\n    interval: 'Intervalo',\n    hoursRemaining: '{{hours}}h restantes',\n    hoursOverdue: '{{hours}}h atrasadas',\n    markDone: 'Marcar como Concluída',\n    performMaintenance: 'Realizar Manutenção',\n    history: 'Histórico',\n    noHistory: 'Nenhum histórico de manutenção',\n    editPrintHours: 'Editar Horas de Impressão',\n    currentHours: 'Horas Atuais',\n    // Tabs\n    statusTab: 'Status',\n    settingsTab: 'Configurações',\n    // Status\n    overdueCount: '{{count}} atrasado',\n    dueSoonCount: '{{count}} prestes a vencer',\n    dueSoon: 'Prestes a vencer',\n    allGood: 'Tudo certo',\n    overdueBy: 'Atrasado por {{duration}}',\n    dueIn: 'Vence em {{duration}}',\n    timeLeft: '{{duration}} restantes',\n    // Duration formats\n    day: '1 dia',\n    days: '{{count}} dias',\n    week: '1 semana',\n    weeks: '{{count}} semanas',\n    month: '1 mês',\n    months: '{{count}} meses',\n    year: '1 ano',\n    // Settings\n    maintenanceTypes: 'Tipos de Manutenção',\n    maintenanceTypesDescription: 'Tipos de sistema e suas tarefas de manutenção personalizadas',\n    addCustomType: 'Adicionar Tipo Personalizado',\n    restoreDefaults: 'Restaurar Tarefas Padrão',\n    intervalType: 'Tipo de Intervalo',\n    intervalValue: 'Intervalo ({{type}})',\n    icon: 'Icon',\n    documentationLink: 'Link da Documentação (opcional)',\n    assignToPrinters: 'Atribuir a Impressoras',\n    selectAtLeastOnePrinter: 'Selecione pelo menos uma impressora',\n    addType: 'Adicionar Tipo',\n    custom: 'Personalizado',\n    printHours: 'Horas de Impressão',\n    calendarDays: 'Dias de Calendário',\n    exampleName: 'ex., Substituir Filtro HEPA',\n    viewDocumentation: 'Ver documentação',\n    timeBasedInterval: 'Intervalo baseado em tempo',\n    // Interval overrides\n    intervalOverrides: 'Substituições de Intervalo',\n    intervalOverridesDescription: 'Personalize os intervalos para impressoras específicas',\n    // Printer assignment\n    assignedToPrinters: 'Atribuído a impressoras:',\n    noPrintersAssigned: 'Nenhuma impressora atribuída',\n    addPrinterShort: 'Adicionar:',\n    printersAssignedClick: '{{count}} impressora(s) atribuída(s) - clique para gerenciar',\n    removeFromPrinter: 'Remover desta impressora',\n    // Types\n    types: {\n      lubricateCarbonRods: 'Lubricar Barras de Carbono',\n      lubricateRails: 'Lubricar Trilhos Lineares',\n      cleanNozzle: 'Limpar Bico/Hotend',\n      checkBelts: 'Verificar Tensão das Correias',\n      cleanBuildPlate: 'Limpar Plataforma de Impressão',\n      checkExtruder: 'Verificar Engrenagens do Extrusor',\n      checkCooling: 'Verificar Ventiladores de Resfriamento',\n      generalInspection: 'Inspeção Geral',\n      cleanCarbonRods: 'Limpar Barras de Carbono',\n      lubricateSteelRods: 'Lubrificar Barras de Aço',\n      cleanSteelRods: 'Limpar Barras de Aço',\n      cleanLinearRails: 'Limpar Trilhos Lineares',\n      checkPtfeTube: 'Verificar Tubo PTFE',\n      replaceHepaFilter: 'Substituir Filtro HEPA',\n      replaceCarbonFilter: 'Substituir Filtro de Carbono',\n      lubricateLeftNozzleRail: 'Lubrificar Trilho do Bico Esquerdo',\n    },\n    // Toast\n    maintenanceComplete: 'Manutenção marcada como concluída',\n    typeUpdated: 'Tipo de manutenção atualizado',\n    typeDeleted: 'Tipo de manutenção excluído',\n    defaultsRestored: 'Restauradas {{count}} tarefa(s) padrão',\n    printHoursUpdated: 'Horas de impressão atualizadas',\n    printerAssigned: 'Impressora atribuída',\n    printerRemoved: 'Impressora removida',\n    // Confirmation\n    deleteTypeConfirm: 'Excluir \"{{name}}\"?',\n    deleteSystemTypeTitle: 'Excluir tarefa de manutenção padrão?',\n    deleteSystemTypeMessage: 'Tem certeza de que deseja excluir a tarefa de manutenção padrão \"{{name}}\"?',\n    // Permissions\n    noPermissionUpdate: 'Você não tem permissão para atualizar itens de manutenção',\n    noPermissionPerform: 'Você não tem permissão para realizar manutenção',\n    noPermissionEditTypes: 'Você não tem permissão para editar tipos de manutenção',\n    noPermissionDeleteTypes: 'Você não tem permissão para excluir tipos de manutenção',\n    noPermissionEditHours: 'Você não tem permissão para editar horas de impressão',\n    noPermissionRemovePrinter: 'Você não tem permissão para remover atribuições de impressora',\n    noPermissionAssignPrinter: 'Você não tem permissão para atribuir impressoras',\n    noPermissionEditIntervals: 'Você não tem permissão para editar intervalos',\n    // Configure link\n    configureSettings: 'Configure tipos de manutenção e intervalos',\n  },\n\n  // Settings page\n  settings: {\n    title: 'Configurações',\n    general: 'Geral',\n    // Tab names\n    tabs: {\n      general: 'Geral',\n      smartPlugs: 'Tomadas Inteligentes',\n      notifications: 'Notificações',\n      queue: 'Workflow',\n      filament: 'Filamento',\n      network: 'Rede',\n      apiKeys: 'Chaves API',\n      virtualPrinter: 'Impressora Virtual',\n      failureDetection: 'Detecção de Falhas',\n      users: 'Autenticação',\n      backup: 'Backup',\n      emailAuth: 'Autenticação por Email',\n      ldap: 'LDAP',\n      twoFa: 'Autenticação 2FA',\n      oidc: 'SSO / OIDC',\n    },\n    ldap: {\n      title: 'Autenticação LDAP',\n      enabledDesc: 'A autenticação LDAP está ativada',\n      disabledDesc: 'A autenticação LDAP está desativada',\n      disabledHint: 'Configure e salve as configurações LDAP abaixo, depois ative.',\n      enabled: 'Autenticação LDAP ativada',\n      disabled: 'Autenticação LDAP desativada',\n      feature1: 'Usuários podem fazer login com credenciais LDAP',\n      feature2: 'A conta de administrador local permanece como fallback',\n      feature3: 'Grupos LDAP são mapeados para grupos BamBuddy no login',\n      serverConfig: 'Configuração do Servidor LDAP',\n      serverUrl: 'URL do servidor',\n      serverUrlHint: 'Use ldap:// para padrão ou ldaps:// para conexões SSL',\n      security: 'Segurança',\n      securityHint: 'StartTLS atualiza uma conexão simples para TLS. LDAPS usa TLS desde o início.',\n      bindDn: 'Bind DN (conta de serviço)',\n      bindPassword: 'Senha Bind',\n      searchBase: 'Base DN de pesquisa',\n      userFilter: 'Filtro de pesquisa de usuário',\n      userFilterHint: '{username} é substituído pelo nome de usuário. Use (uid={username}) para OpenLDAP.',\n      autoProvision: 'Provisionamento automático de usuários',\n      autoProvisionHint: 'Criar automaticamente uma conta BamBuddy no primeiro login LDAP',\n      defaultGroup: 'Grupo padrão',\n      defaultGroupNone: '— Nenhum (sem fallback) —',\n      defaultGroupHint: 'Grupo de fallback atribuído quando um usuário LDAP se autentica mas não está em nenhum grupo LDAP mapeado. Deixe vazio para manter usuários não mapeados sem permissões.',\n      groupMapping: 'Mapeamento de grupos (JSON)',\n      groupMappingHint: 'Mapear DNs de grupos LDAP para grupos BamBuddy. Grupos disponíveis: ',\n      testConnection: 'Testar conexão',\n      settingsSaved: 'Configurações LDAP salvas',\n      errors: {\n        serverRequired: 'URL do servidor LDAP é obrigatória',\n        searchBaseRequired: 'Base DN de pesquisa é obrigatória',\n        enableAuthFirst: 'Ative a autenticação primeiro',\n        configureLdapFirst: 'Salve as configurações LDAP primeiro',\n      },\n    },\n    // Email settings\n    email: {\n      smtpSettings: 'Configuração SMTP',\n      smtpHost: 'Servidor SMTP',\n      smtpPort: 'Porta SMTP',\n      security: 'Segurança',\n      authentication: 'Autenticação',\n      username: 'Nome de Usuário',\n      password: 'Senha',\n      fromEmail: 'Email de Remetente',\n      fromName: 'Nome de Remetente',\n      testConnection: 'Testar Conexão SMTP',\n      testRecipient: 'Email de Teste',\n      sendTest: 'Enviar Email de Teste',\n      sending: 'Enviando...',\n      save: 'Salvar Configurações',\n      saving: 'Salvando...',\n      advancedAuth: 'Autenticação Avançada',\n      advancedAuthEnabled: 'Autenticação Avançada está habilitada',\n      advancedAuthEnabledDesc: 'Recursos de gerenciamento de usuários baseados em email estão ativos. Novos usuários receberão senhas geradas automaticamente por email, e os usuários podem redefinir suas senhas através do recurso de esqueci minha senha.',\n      advancedAuthDisabled: 'Autenticação Avançada está desabilitada',\n      advancedAuthDisabledDesc: 'Habilite a autenticação avançada para ativar recursos baseados em email para gerenciamento de usuários.',\n      enable: 'Habilitar',\n      disable: 'Desabilitar',\n      feature1: 'Senhas são geradas automaticamente e enviadas por email para novos usuários',\n      feature2: 'Usuários podem fazer login com nome de usuário ou email',\n      feature3: 'Recurso de esqueci minha senha está disponível',\n      feature4: 'Administradores podem redefinir senhas de usuários via email',\n      // Error messages\n      errors: {\n        requiredFields: 'Por favor, preencha todos os campos obrigatórios',\n        usernameRequired: 'Nome de usuário é obrigatório quando a autenticação está habilitada',\n        enterTestEmail: 'Por favor, insira um endereço de email de teste',\n        smtpServerAndEmail: 'Por favor, preencha o servidor SMTP e o email de remetente antes de testar',\n        usernamePasswordRequired: 'Nome de usuário e senha são obrigatórios quando a autenticação está habilitada',\n        configureSmtpFirst: 'Por favor, configure e teste as configurações SMTP primeiro',\n        enableAuthFirst: 'Por favor, habilite a autenticação primeiro para usar os recursos baseados em e-mail.',\n      },\n      // Success messages\n      success: {\n        settingsSaved: 'Configurações SMTP salvas com sucesso',\n      },\n      // Security options\n      securityOptions: {\n        starttls: 'STARTTLS (Porta 587)',\n        ssl: 'SSL/TLS (Porta 465)',\n        none: 'Nenhuma (Porta 25)',\n      },\n      // Authentication options\n      authOptions: {\n        enabled: 'Habilitado',\n        disabled: 'Desabilitado',\n      },\n    },\n    appearance: 'Aparência',\n    notifications: 'Notificações',\n    smartPlugs: 'Tomadas Inteligentes',\n    spoolman: 'Spoolman',\n    updates: 'Atualizações',\n    language: 'Idioma',\n    languageDescription: 'Selecione seu idioma preferido',\n    theme: 'Tema',\n    themeLight: 'Claro',\n    themeDark: 'Escuro',\n    themeSystem: 'Sistema',\n    defaultView: 'Visualização Padrão',\n    defaultViewDescription: 'Página a ser exibida ao abrir o aplicativo',\n    checkForUpdates: 'Verificar Atualizações',\n    autoUpdate: 'Atualização Automática',\n    currentVersion: 'Versão Atual',\n    latestVersion: 'Última Versão',\n    upToDate: 'Você está atualizado',\n    updateAvailable: 'Atualização disponível',\n    // Notifications\n    notificationLanguage: 'Idioma das Notificações',\n    notificationLanguageDescription: 'Idioma para notificações push',\n    bedCooledThreshold: 'Limite de Resfriamento da Cama',\n    bedCooledThresholdDescription: 'Temperatura abaixo da qual a cama é considerada resfriada após uma impressão',\n    userNotificationsEnabled: 'Notificações do Usuário',\n    userNotificationsEnabledDescription: 'Ativa o menu de notificações do usuário e notificações por e-mail para eventos de impressão. Requer Autenticação Avançada.',\n    userNotificationsDisabledHint: 'Ative a Autenticação Avançada para usar as notificações do usuário.',\n    notificationProviders: 'Provedores de Notificação',\n    addProvider: 'Adicionar Provedor',\n    editProvider: 'Editar Provedor',\n    providerType: 'Tipo de Provedor',\n    testNotification: 'Testar Notificação',\n    testSuccess: 'Notificação de teste enviada com sucesso',\n    testFailed: 'Falha ao enviar notificação de teste',\n    quietHours: 'Horas de Silêncio',\n    quietHoursDescription: 'Não perturbe durante essas horas',\n    quietHoursStart: 'Início',\n    quietHoursEnd: 'Fim',\n    events: {\n      title: 'Eventos de Notificação',\n      printStart: 'Impressão Iniciada',\n      printComplete: 'Impressão Concluída',\n      printFailed: 'Falha na Impressão',\n      printStopped: 'Impressão Interrompida',\n      printProgress: 'Marcos de Progresso',\n      printProgressDescription: 'Notificar em 25%, 50%, 75%',\n      printerOffline: 'Impressora Offline',\n      printerError: 'Erro na Impressora',\n      filamentLow: 'Filamento Baixo',\n      maintenanceDue: 'Manutenção Pendente',\n      maintenanceDueDescription: 'Notificar quando a manutenção for necessária',\n    },\n    // Smart Plugs\n    smartPlug: {\n      title: 'Tomadas Inteligentes',\n      add: 'Adicionar Tomada Inteligente',\n      edit: 'Editar Tomada Inteligente',\n      name: 'Nome',\n      ipAddress: 'Endereço IP',\n      linkedPrinter: 'Impressora Vinculada',\n      autoOn: 'Ligar Automaticamente',\n      autoOnDescription: 'Ligar quando a impressão começar',\n      autoOff: 'Desligar Automaticamente',\n      autoOffDescription: 'Desligar após a conclusão da impressão',\n      offDelay: 'Atraso para Desligar',\n      offDelayMinutes: 'Minutos após a impressão',\n      offDelayTemp: 'Quando o bico estiver abaixo da temperatura',\n      currentState: 'Estado Atual',\n      turnOn: 'Ligar',\n      turnOff: 'Desligar',\n    },\n    // Filament Tracking Mode\n    filamentTracking: 'Rastreamento de Filamento',\n    filamentTrackingDesc: 'Escolha como rastrear seus rolos de filamento. Você pode usar o inventário interno ou conectar a um servidor Spoolman externo.',\n    filamentChecks: 'Verificações de filamento',\n    disableFilamentWarnings: 'Desativar avisos de filamento',\n    disableFilamentWarningsDesc: 'Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila',\n    preferLowestFilament: 'Preferir filamento com menor resto',\n    preferLowestFilamentDesc: 'Quando vários carretéis correspondem, usar o com menos filamento restante',\n    trackingModeBuiltIn: 'Inventário Interno',\n    trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos',\n    trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo',\n    builtInFeatureRfid: 'Detecta automaticamente rolos RFID da Bambu Lab no AMS',\n    builtInFeatureUsage: 'Rastreia o consumo de filamento por impressão',\n    builtInFeatureCatalog: 'Gerencia rolos, cores e perfis de fator K',\n    builtInFeatureThirdParty: 'Rolos de terceiros podem ser atribuídos aos rolos do inventário',\n    amsSyncButton: 'Sincronizar Pesos do AMS',\n    amsSyncTitle: 'Sincronizar Pesos dos Rolos do AMS',\n    amsSyncMessage: 'Isso substituirá todos os pesos dos rolos do inventário pelos valores atuais de % restante do AMS das impressoras conectadas. Use isso para recuperar dados de peso corrompidos. As impressoras devem estar online.',\n    amsSyncing: 'Sincronizando...',\n    amsSyncSuccess: '{{synced}} rolo(s) sincronizado(s), {{skipped}} ignorado(s)',\n    amsSyncError: 'Falha ao sincronizar pesos do AMS',\n    // Spoolman settings\n    spoolmanUrl: 'Spoolman URL',\n    spoolmanUrlHint: 'URL do seu servidor Spoolman (por exemplo, http://localhost:7912)',\n    spoolmanConnected: 'Conectado',\n    spoolmanDisconnected: 'Desconectado',\n    status: 'Status',\n    connect: 'Conectar',\n    disconnect: 'Desconectar',\n    howSyncWorks: 'Como a Sincronização Funciona',\n    syncInfoRfidOnly: 'Apenas rolos oficiais da Bambu Lab com RFID são sincronizados',\n    syncInfoAutoCreate: 'Novos rolos são criados automaticamente no Spoolman na primeira sincronização',\n    syncInfoThirdPartySkipped: 'Rolos não oficiais da Bambu Lab (terceiros, reabastecidos) são ignorados',\n    linkingExistingSpools: 'Vinculando Rolos Existentes',\n    linkingExistingSpoolsDesc: 'Para vincular rolos existentes do Spoolman ao seu AMS, passe o mouse sobre um slot do AMS e clique em \"Vincular ao Spoolman\".',\n    syncMode: 'Modo de Sincronização',\n    syncModeAuto: 'Automático',\n    syncModeManual: 'Apenas Manual',\n    syncModeAutoDesc: 'Os dados do AMS são sincronizados automaticamente quando alterações são detectadas',\n    syncModeManualDesc: 'Somente sincronize quando acionado manualmente',\n    syncAmsData: 'Sincronizar Dados do AMS',\n    syncAmsDataDesc: 'Sincronize manualmente os dados do AMS da impressora com o Spoolman',\n    allPrinters: 'Todas as Impressoras',\n    // Default printer\n    noDefaultPrinter: 'Sem padrão (perguntar a cada vez)',\n    // Sidebar\n    sidebarOrder: 'Ordem da barra lateral',\n    // Camera\n    saveThumbnails: 'Salvar miniaturas',\n    captureFinishPhoto: 'Capturar foto de conclusão',\n    noPrintersConfigured: 'Nenhuma impressora configurada',\n    // Archive settings\n    archiveMode: {\n      always: 'Sempre criar entrada de arquivo',\n      never: 'Nunca criar entrada de arquivo',\n      ask: 'Perguntar a cada vez',\n    },\n    // Updates\n    checkForUpdatesLabel: 'Verificar atualizações',\n    checkPrinterFirmware: 'Verificar firmware da impressora',\n    includeBetaUpdates: 'Incluir versões beta',\n    includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',\n    // Queue\n    enableRetry: 'Habilitar tentativa',\n    // Home Assistant\n    homeAssistantDescription: 'Controlar tomadas inteligentes via Home Assistant',\n    environmentManagedLabel: '(Gerenciado pelo Ambiente)',\n    autoEnabledViaEnv: 'Habilitado automaticamente via variáveis de ambiente',\n    urlFromEnvReadOnly: 'Valor definido pela variável de ambiente HA_URL (somente leitura)',\n    tokenFromEnvReadOnly: 'Valor definido pela variável de ambiente HA_TOKEN (somente leitura)',\n    // MQTT\n    mqttConnectedTo: 'Conectado a',\n    // Prometheus\n    prometheusDescription: 'Expor dados da impressora no formato Prometheus',\n    // Smart plugs empty state\n    noSmartPlugsTitle: 'Nenhuma tomada inteligente configurada',\n    noSmartPlugsDescription: 'Adicione uma tomada inteligente baseada em Tasmota para monitorar o consumo de energia e automatizar o controle de energia.',\n    // Notifications empty state\n    noProvidersTitle: 'Nenhum provedor configurado',\n    noProvidersDescription: 'Adicione um provedor para receber alertas.',\n    noTemplatesAvailable: 'Nenhum modelo disponível. Reinicie o backend para gerar os modelos padrão.',\n    // API permissions\n    apiPermissionView: 'Visualizar status da impressora e fila',\n    apiPermissionEdit: 'Adicionar e remover itens da fila de impressão',\n    // API keys\n    apiKeysEmptyTitle: 'Nenhuma chave API',\n    apiKeysEmptyDescription: 'Crie uma chave API para integrar com serviços externos.',\n    // Users\n    noUsersFound: 'Nenhum usuário encontrado',\n    noGroupsFound: 'Nenhum grupo encontrado',\n    noGroupsAvailable: 'Nenhum grupo disponível',\n    passwordsDoNotMatch: 'As senhas não coincidem',\n    systemGroupWarning: 'Os nomes dos grupos do sistema não podem ser alterados',\n    // Auth disabled\n    authDisabledTitle: 'Autenticação Desativada',\n    authDisabledFeature1: 'Exigir login para acessar o sistema',\n    authDisabledFeature2: 'Criar múltiplos usuários com permissões baseadas em grupos',\n    authDisabledFeature3: 'Controlar acesso com mais de 50 permissões granulares',\n    // User deletion\n    userHasCreated: 'Este usuário criou:',\n    userItemsQuestion: 'O que você gostaria de fazer com esses itens?',\n    deleteUserConfirm: 'Tem certeza de que deseja excluir este usuário?',\n    actionCannotBeUndone: 'Esta ação não pode ser desfeita.',\n    // Smart plugs\n    addFirstSmartPlug: 'Adicione sua primeira tomada inteligente',\n    // Notifications\n    providers: 'Provedores',\n    log: 'Registro',\n    testAll: 'Testar tudo',\n    testResults: 'Resultados do teste',\n    testPassedCount: '{{count}} aprovado',\n    testFailedCount: '{{count}} falhou',\n    messageTemplates: 'Modelos de mensagem',\n    messageTemplatesDescription: 'Personalize as mensagens de notificação para cada evento.',\n    // API Keys section\n    apiKeys: 'Chaves API',\n    apiKeysDescription: 'Crie chaves API para integrações externas e webhooks.',\n    createKey: 'Criar Chave',\n    apiKeyCreated: 'Chave API criada com sucesso',\n    apiKeyCopyWarning: 'Copie esta chave agora - ela não será exibida novamente!',\n    useInApiBrowser: 'Usar no Navegador API',\n    createNewApiKey: 'Criar Nova Chave API',\n    keyName: 'Nome da Chave',\n    keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',\n    readStatus: 'Status de Leitura',\n    readStatusDescription: 'Visualizar status da impressora e fila',\n    manageQueue: 'Gerenciar Fila',\n    manageQueueDescription: 'Adicionar e remover itens da fila de impressão',\n    controlPrinter: 'Controlar Impressora',\n    controlPrinterDescription: 'Pausar, retomar e parar impressões',\n    unnamedKey: 'Chave Sem Nome',\n    lastUsed: 'Último uso',\n    read: 'Ler',\n    control: 'Controlar',\n    createFirstKey: 'Crie sua primeira chave',\n    webhookEndpoints: 'Endpoints de Webhook',\n    webhookApiKeyHint: 'Use sua chave API no cabeçalho X-API-Key.',\n    webhook: {\n      getAllStatus: 'Obter status de todas as impressoras',\n      getSpecificStatus: 'Obter status de uma impressora específica',\n      addToQueue: 'Adicionar à fila de impressão',\n      pausePrint: 'Pausar impressão',\n      resumePrint: 'Retomar impressão',\n      stopPrint: 'Parar impressão',\n    },\n    apiBrowser: 'Navegador API',\n    apiBrowserDescription: 'Explore e teste todos os endpoints de API disponíveis.',\n    apiKeyForTesting: 'Chave API para Teste',\n    apiKeyPlaceholder: 'Cole sua chave API aqui para testar endpoints autenticados...',\n    apiKeyHint: 'Esta chave será enviada como cabeçalho X-API-Key nas solicitações.',\n    deleteApiKeyTitle: 'Excluir Chave API',\n    deleteApiKeyMessage: 'Tem certeza de que deseja excluir esta chave API? Quaisquer integrações usando esta chave deixarão de funcionar.',\n    deleteKey: 'Excluir Chave',\n    // Filament tab\n    amsDisplayThresholds: 'Limiares de Exibição AMS',\n    amsThresholdsDescription: 'Configure os limiares de cores para os indicadores de umidade e temperatura do AMS.',\n    humidity: 'Umidade',\n    goodGreen: 'Bom (verde)',\n    fairOrange: 'Razoável (laranja)',\n    aboveFairBad: 'Acima do limiar razoável mostra como vermelho (ruim)',\n    fairAlsoDryingThreshold: 'Este limiar também é usado para acionar a secagem automática',\n    temperature: 'Temperatura',\n    goodBlue: 'Bom (azul)',\n    aboveFairHot: 'Acima do limiar razoável mostra como vermelho (quente)',\n    historyRetention: 'Retenção de Histórico',\n    keepSensorHistory: 'Manter histórico do sensor por',\n    historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',\n    defaultPrintOptions: 'Opções de impressão padrão',\n    defaultPrintOptionsDescription: 'Defina valores padrão para opções de impressão. Podem ser alterados no diálogo de impressão.',\n    defaultBedLevelling: 'Nivelamento da mesa',\n    defaultBedLevellingDesc: 'Nivelar automaticamente a mesa antes da impressão',\n    defaultFlowCali: 'Calibração de fluxo',\n    defaultFlowCaliDesc: 'Calibrar fluxo de extrusão',\n    defaultVibrationCali: 'Calibração de vibração',\n    defaultVibrationCaliDesc: 'Reduzir artefatos de ringing',\n    defaultLayerInspect: 'Inspeção da primeira camada',\n    defaultLayerInspectDesc: 'Inspeção IA da primeira camada',\n    defaultTimelapse: 'Timelapse',\n    defaultTimelapseDesc: 'Gravar vídeo timelapse',\n    staggeredStart: 'Staggered Start',\n    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',\n    plateClear: 'Confirmação de placa livre',\n    requirePlateClear: 'Exigir confirmação de placa livre',\n    requirePlateClearDescription: 'Quando ativado, o agendador aguarda uma confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desativar isso também oculta o indicador de status da placa e o botão \"Marcar placa como liberada\" nos cartões das impressoras.',\n    gcodeInjection: 'Injeção de G-code',\n    gcodeInjectionDescription: 'Configure G-code personalizado para injetar no início e/ou no final das impressões para sistemas de impressão automática como Farmloop, SwapMod, AutoClear e Printflow 3D. Os snippets são configurados por modelo de impressora e aplicados quando \"Injetar G-code\" está ativado em um item da fila.',\n    gcodeInjectionNoPrinters: 'Nenhuma impressora encontrada. Adicione impressoras para configurar snippets de G-code.',\n    gcodeStartLabel: 'G-code inicial',\n    gcodeEndLabel: 'G-code final',\n    gcodeStartPlaceholder: 'G-code inserido antes do início da impressão...',\n    gcodeEndPlaceholder: 'G-code adicionado após o término da impressão...',\n    staggerGroupSize: 'Group size',\n    staggerGroupSizeHelp: 'Printers to start simultaneously per group',\n    staggerInterval: 'Interval (minutes)',\n    staggerIntervalHelp: 'Delay between each group starting',\n    queueDrying: 'Secagem Automática',\n    queueDryingDescription: 'Secar automaticamente o filamento AMS quando a impressora estiver ociosa entre impressões na fila. Usa o limite de umidade acima.',\n    queueDryingEnabled: 'Ativar secagem automática',\n    queueDryingEnabledDescription: 'Iniciar secagem AMS automaticamente quando a impressora estiver ociosa e a umidade estiver acima do limite',\n    queueDryingBlock: 'Aguardar conclusão da secagem',\n    queueDryingBlockDescription: 'Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.',\n    ambientDryingEnabled: 'Secagem ambiente',\n    ambientDryingEnabledDescription: 'Secar automaticamente o filamento em impressoras ociosas quando a umidade exceder o limite, mesmo sem impressões na fila.',\n    dryingPresets: 'Predefinições de secagem',\n    dryingPresetsDescription: 'Temperatura e duração por tipo de filamento. AMS 2 Pro usa temperaturas mais baixas, AMS-HT suporta temperaturas mais altas.',\n    dryingFilament: 'Filamento',\n    printModal: 'Modal de Impressão',\n    expandCustomMapping: 'Expandir mapeamento personalizado por padrão',\n    expandCustomMappingDescription: 'Ao imprimir em várias impressoras, mostrar o mapeamento AMS por impressora expandido',\n    // User management\n    authentication: 'Autenticação',\n    authEnabledDescription: 'Sua instância está protegida com autenticação de usuário',\n    authDisabledDescription: 'Ative para exigir login e gerenciar o acesso dos usuários',\n    authDisabledMessage: 'Ative a autenticação para criar contas de usuário, gerenciar permissões e proteger sua instância do Bambuddy.',\n    enableAuthentication: 'Ativar Autenticação',\n    currentUser: 'Usuário Atual',\n    changePassword: 'Alterar Senha',\n    admin: 'Administrador',\n    users: 'Usuários',\n    addUser: 'Adicionar Usuário',\n    groups: 'Grupos',\n    addGroup: 'Adicionar Grupo',\n    system: 'Sistema',\n    noDescription: 'Sem descrição',\n    userCount: '{{count}} usuários',\n    permissionCount: '{{count}} permissões',\n    createUser: 'Criar Usuário',\n    username: 'Nome de Usuário',\n    enterUsername: 'Digite o nome de usuário',\n    password: 'Senha',\n    enterPassword: 'Digite a senha (mínimo 6 caracteres)',\n    confirmPassword: 'Confirmar Senha',\n    confirmPasswordPlaceholder: 'Confirme a senha',\n    // Title tooltips\n    viewReleaseOnGitHub: 'Ver lançamento no GitHub',\n    turnAllPlugsOn: 'Ligar todas as tomadas',\n    turnAllPlugsOff: 'Desligar todas as tomadas',\n    // Modal: Clear logs\n    clearNotificationLogs: 'Limpar Logs de Notificação',\n    clearLogsMessage: 'Isso excluirá permanentemente todos os logs de notificação com mais de 30 dias. Esta ação não pode ser desfeita.',\n    clearLogs: 'Limpar Logs',\n    // Modal: Reset UI\n    resetUiPreferences: 'Redefinir Preferências de UI',\n    resetUiPreferencesMessage: 'Isso redefinirá todas as preferências de UI para os padrões: ordem da barra lateral, tema, layout do painel, modos de exibição e preferências de classificação. Suas impressoras, arquivos e configurações do servidor NÃO serão afetados. A página será recarregada após a limpeza.',\n    resetPreferences: 'Redefinir Preferências',\n    // Modal: Delete group\n    deleteGroupTitle: 'Excluir Grupo',\n    deleteGroupMessage: 'Tem certeza de que deseja excluir este grupo? Usuários neste grupo perderão essas permissões.',\n    deleteGroup: 'Excluir Grupo',\n    // Modal: Disable auth\n    disableAuthenticationTitle: 'Desativar Autenticação',\n    disableAuthenticationMessage: 'Tem certeza de que deseja desativar a autenticação? Isso tornará sua instância do Bambuddy acessível sem login. Todos os usuários permanecerão no banco de dados, mas a autenticação será desativada.',\n    disableAuthentication: 'Desativar Autenticação',\n    // Additional settings\n    configureBambuddy: 'Configurar Bambuddy',\n    systemDefault: 'Padrão do Sistema',\n    archiveSettings: 'Configurações de Arquivo',\n    newWindow: 'Nova Janela',\n    embeddedOverlay: 'Sobreposição Incorporada',\n    preferredSlicer: 'Fatiador Preferido',\n    preferredSlicerDescription: 'Escolha qual aplicativo de fatiamento abrirá os arquivos',\n    externalCameras: 'Câmeras Externas',\n    costTracking: 'Rastreamento de Custos',\n    printsOnly: 'Apenas Impressões',\n    totalConsumption: 'Consumo Total',\n    dataManagement: 'Gerenciamento de Dados',\n    storageUsage: 'Uso de Armazenamento',\n    storageUsageDescription: 'Detalhamento do uso de dados por categoria',\n    storageUsageTotal: 'Total',\n    storageUsageErrors: 'Erros',\n    storageUsageOtherBreakdown: 'Outros (inclui ativos estáticos, scripts e arquivos de configuração)',\n    storageUsageSystem: 'Sistema',\n    storageUsageData: 'Dados',\n    storageUsageUnavailable: 'Informações de uso de armazenamento indisponíveis',\n    clearNotificationLogsDescription: 'Excluir logs de notificação com mais de 30 dias',\n    resetUiPreferencesDescription: 'Redefinir ordem da barra lateral, tema, modos de exibição e preferências de layout. Impressoras, arquivos e configurações não são afetados.',\n    enableHomeAssistant: 'Ativar Home Assistant',\n    enableMqtt: 'Ativar MQTT',\n    useTls: 'Usar TLS',\n    enableMetricsEndpoint: 'Ativar Endpoint de Métricas',\n    availableMetrics: 'Métricas Disponíveis',\n    editUser: 'Editar Usuário',\n    deleteUserTitle: 'Excluir Usuário',\n    groupName: 'Nome do Grupo',\n    // Placeholders\n    leaveEmptyForAnonymous: 'Deixe vazio para anônimo',\n    leaveEmptyForNoAuth: 'Deixe vazio para sem autenticação',\n    enterNewPassword: 'Digite a nova senha',\n    confirmNewPassword: 'Confirme a nova senha',\n    enterGroupName: 'Digite o nome do grupo',\n    enterDescriptionOptional: 'Digite a descrição (opcional)',\n    enterCurrentPassword: 'Digite a senha atual',\n    enterNewPasswordMin6: 'Digite a nova senha (mínimo 6 caracteres)',\n    toast: {\n      keyCopied: 'Chave copiada para a área de transferência',\n      copyFailed: 'Falha ao copiar a chave',\n      keyAddedToBrowser: 'Chave adicionada ao Navegador de API',\n      clearLogsFailed: 'Falha ao limpar logs',\n      uiPreferencesReset: 'Preferências de UI redefinidas. Atualizando...',\n      authDisabled: 'Autenticação desativada com sucesso',\n      authDisableFailed: 'Falha ao desativar a autenticação',\n      apiKeyCreated: 'Chave de API criada',\n      apiKeyDeleted: 'Chave de API excluída',\n      userCreated: 'Usuário criado com sucesso',\n      userUpdated: 'Usuário atualizado com sucesso',\n      userDeleted: 'Usuário excluído com sucesso',\n      groupCreated: 'Grupo criado com sucesso',\n      groupUpdated: 'Grupo atualizado com sucesso',\n      groupDeleted: 'Grupo excluído com sucesso',\n      fillRequiredFields: 'Por favor, preencha todos os campos obrigatórios',\n      passwordsDoNotMatch: 'As senhas não coincidem',\n      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',\n      enterGroupName: 'Por favor, insira um nome de grupo',\n      settingsSaved: 'Configurações salvas',\n      cameraSettingsSaved: 'Configurações da câmera salvas',\n      enterCameraUrl: 'Por favor, insira a URL da câmera',\n      passwordChanged: 'Senha alterada com sucesso',\n      connectionFailed: 'Falha na conexão',\n      testFailed: 'Falha no teste',\n      cameraConnected: 'Câmera conectada{{resolution}}',\n    },\n    testConnection: 'Testar Conexão',\n    catalog: {\n      spoolCatalog: 'Catálogo de Carretéis',\n      spoolCatalogDescription: 'Pesos de carretéis vazios por marca/tipo. Usado para pesquisa automática de peso ao adicionar carretéis.',\n      searchCatalog: 'Pesquisar no catálogo...',\n      addNewEntry: 'Adicionar Nova Entrada',\n      namePlaceholder: 'Nome (ex.: Bambu Lab - Plástico)',\n      weight: 'Peso',\n      type: 'Tipo',\n      default: 'Padrão',\n      custom: 'Personalizado',\n      noMatch: 'Nenhuma entrada corresponde à sua pesquisa',\n      empty: 'Nenhuma entrada no catálogo',\n      deleteEntry: 'Excluir Entrada',\n      deleteConfirm: 'Tem certeza de que deseja excluir \"{{name}}\"?',\n      resetCatalog: 'Redefinir Catálogo',\n      resetConfirm: 'Redefinir catálogo para os padrões? Isso removerá todas as entradas personalizadas.',\n      loadFailed: 'Falha ao carregar o catálogo de carretéis',\n      nameWeightRequired: 'Nome e peso são obrigatórios',\n      entryAdded: 'Entrada adicionada',\n      addFailed: 'Falha ao adicionar entrada',\n      entryUpdated: 'Entrada atualizada',\n      updateFailed: 'Falha ao atualizar entrada',\n      entryDeleted: 'Entrada excluída',\n      deleteFailed: 'Falha ao excluir entrada',\n      resetSuccess: 'Catálogo redefinido para os padrões',\n      resetFailed: 'Falha ao redefinir catálogo',\n      exported: 'Exportadas {{count}} entradas',\n      imported: 'Importadas {{added}} entradas ({{skipped}} ignoradas)',\n      importFailed: 'Falha ao importar: formato JSON inválido',\n      exportTooltip: 'Exportar catálogo para JSON',\n      importTooltip: 'Importar catálogo de JSON',\n      resetTooltip: 'Redefinir para os padrões',\n      selectedCount: '{{count}} selecionados',\n      deleteSelected: 'Excluir Selecionados',\n      bulkDeleteConfirm: 'Tem certeza de que deseja excluir {{count}} entradas?',\n      bulkDeleted: '{{count}} entradas excluídas',\n      bulkDeleteFailed: 'Falha ao excluir entradas',\n    },\n    colorCatalog: {\n      title: 'Catálogo de Cores',\n      description: 'Cores de filamento por fabricante/material. Usado para pesquisa automática de cores ao adicionar carretéis.',\n      searchColors: 'Pesquisar cores...',\n      allManufacturers: 'Todos os fabricantes',\n      addNewColor: 'Adicionar Nova Cor',\n      manufacturer: 'Fabricante',\n      colorName: 'Nome da Cor',\n      hex: 'Hex',\n      materialOptional: 'Material (opcional)',\n      showing: 'Mostrando {{filtered}} de {{total}} cores',\n      noMatch: 'Nenhuma cor corresponde à sua pesquisa',\n      empty: 'Nenhuma cor no catálogo',\n      deleteColor: 'Excluir Cor',\n      deleteConfirm: 'Tem certeza de que deseja excluir \"{{name}}\"?',\n      resetCatalog: 'Redefinir Catálogo de Cores',\n      resetConfirm: 'Redefinir catálogo para os padrões? Isso removerá todas as cores personalizadas.',\n      sync: 'Sincronizar',\n      starting: 'Iniciando...',\n      syncTooltip: 'Sincronizar do FilamentColors.xyz (2000+ cores, pode levar um minuto)',\n      loadFailed: 'Falha ao carregar o catálogo de cores',\n      fieldsRequired: 'Fabricante, nome da cor e cor hex são obrigatórios',\n      colorAdded: 'Cor adicionada',\n      addFailed: 'Falha ao adicionar cor',\n      colorUpdated: 'Cor atualizada',\n      updateFailed: 'Falha ao atualizar cor',\n      colorDeleted: 'Cor excluída',\n      deleteFailed: 'Falha ao excluir cor',\n      resetSuccess: 'Catálogo de cores redefinido para os padrões',\n      resetFailed: 'Falha ao redefinir catálogo',\n      syncUpToDate: 'Já está atualizado ({{count}} cores verificadas)',\n      syncComplete: 'Adicionadas {{added}} novas cores ({{skipped}} já existiam)',\n      syncError: 'Erro de sincronização',\n      syncFailed: 'Falha ao sincronizar do FilamentColors.xyz',\n      exported: 'Exportadas {{count}} cores',\n      imported: 'Importadas {{added}} cores ({{skipped}} ignoradas)',\n      importFailed: 'Falha ao importar: formato JSON inválido',\n      selectedCount: '{{count}} selecionados',\n      deleteSelected: 'Excluir Selecionados',\n      bulkDeleteConfirm: 'Tem certeza de que deseja excluir {{count}} cores?',\n      bulkDeleted: '{{count}} cores excluídas',\n      bulkDeleteFailed: 'Falha ao excluir cores',\n    },\n    dateFormat: 'Formato de data',\n    dateFormatUs: 'US (MM/DD/AAAA)',\n    dateFormatEu: 'EU (DD/MM/AAAA)',\n    dateFormatIso: 'ISO (AAAA-MM-DD)',\n    timeFormat: 'Formato de hora',\n    timeFormat12: '12 horas (3:30 PM)',\n    timeFormat24: '24 horas (15:30)',\n    defaultPrinter: 'Impressora padrão',\n    defaultPrinterDescription: 'Pré-selecionar esta impressora para uploads, reimpressões e outras operações.',\n    slicerBambuStudio: 'Bambu Studio',\n    slicerOrcaSlicer: 'OrcaSlicer',\n    sidebarOrderDescription: 'Arraste itens na barra lateral para reordenar. Restaurar ordem padrão aqui.',\n    setDefault: 'Definir padrão',\n    sidebarOrderSetDefaultHint: 'Definir padrão aplica a ordem atual do menu aos usuários que ainda não personalizaram o seu.',\n    sidebarDefaultSet: 'Ordem padrão do menu foi definida.',\n    sidebarDefaultCleared: 'Ordem padrão do menu removida.',\n    sidebarDefaultFailed: 'Falha ao definir a ordem padrão do menu.',\n    reset: 'Redefinir',\n    darkMode: 'Modo escuro',\n    lightMode: 'Modo claro',\n    active: '(ativo)',\n    background: 'Fundo',\n    accent: 'Destaque',\n    style: 'Estilo',\n    bgNeutral: 'Neutro',\n    bgWarm: 'Quente',\n    bgCool: 'Frio',\n    bgOled: 'OLED Preto',\n    bgSlate: 'Azul ardósia',\n    bgForest: 'Verde floresta',\n    accentGreen: 'Verde',\n    accentTeal: 'Azul-petróleo',\n    accentBlue: 'Azul',\n    accentOrange: 'Laranja',\n    accentPurple: 'Roxo',\n    accentRed: 'Vermelho',\n    styleClassic: 'Clássico',\n    styleGlow: 'Brilhante',\n    styleVibrant: 'Vibrante',\n    themeToggleHint: 'Alternar entre modo escuro e claro usando o ícone de sol/lua na barra lateral.',\n    autoArchivePrints: 'Arquivar impressões automaticamente',\n    autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',\n    saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',\n    captureFinishPhotoDescription: 'Tirar foto da câmera da impressora quando a impressão for concluída',\n    ffmpegNotInstalled: 'ffmpeg não instalado',\n    ffmpegRequired: 'A captura de câmera requer ffmpeg. Instale via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).',\n    camera: 'Câmera',\n    cameraViewMode: 'Modo de visualização da câmera',\n    cameraOverlayDescription: 'A câmera abre em uma sobreposição redimensionável na tela principal',\n    cameraWindowDescription: 'A câmera abre em uma janela separada do navegador',\n    externalCamerasDescription: 'Configure câmeras externas para substituir a câmera integrada da impressora. Suporta streams MJPEG, RTSP, snapshots HTTP e câmeras USB (V4L2). Quando habilitada, a câmera externa é usada para visualização ao vivo e fotos de conclusão.',\n    cameraPlaceholderUsb: 'Caminho do dispositivo (/dev/video0)',\n    cameraPlaceholderUrl: 'URL da câmera (rtsp://... ou http://...)',\n    cameraTypeMjpeg: 'Stream MJPEG',\n    cameraTypeRtsp: 'Stream RTSP',\n    cameraTypeSnapshot: 'Snapshot HTTP',\n    cameraTypeUsb: 'Câmera USB (V4L2)',\n    cameraRotation: 'Rotação',\n    test: 'Testar',\n    connected: 'Conectado',\n    disconnected: 'Desconectado',\n    currency: 'Moeda',\n    defaultFilamentCost: 'Custo padrão do filamento (por kg)',\n    electricityCost: 'Custo da eletricidade por kWh',\n    energyDisplayMode: 'Modo de exibição de energia',\n    energyModePrintDescription: 'O painel mostra a soma da energia usada durante as impressões',\n    energyModeTotalDescription: 'O painel mostra a energia total dos plugues inteligentes',\n    fileManager: 'Gerenciador de arquivos',\n    createArchiveEntry: 'Criar entrada de arquivo ao imprimir',\n    createArchiveEntryDescription: 'Ao imprimir pelo gerenciador de arquivos, criar opcionalmente uma entrada de arquivo',\n    lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',\n    lowDiskSpaceDescription: 'Mostrar aviso quando o espaço livre em disco ficar abaixo deste limite',\n    printerFirmware: 'Firmware da impressora',\n    checkFirmwareDescription: 'Verificar atualizações de firmware da Bambu Lab',\n    bambuddySoftware: 'Software Bambuddy',\n    autoCheckDescription: 'Verificar automaticamente novas versões ao iniciar',\n    checkNow: 'Verificar agora',\n    updateAvailableVersion: 'Atualização disponível: v{{version}}',\n    releaseNotes: 'Notas da versão',\n    updateViaDocker: 'Atualizar via Docker Compose:',\n    installUpdate: 'Instalar atualização',\n    latestVersionRunning: 'Você está usando a versão mais recente',\n    failedToCheckUpdates: 'Falha ao verificar atualizações: {{error}}',\n    backupRestore: 'Backup e Restauração',\n    backupRestoreDescription: 'Exportar/importar configurações e configurar backup do GitHub',\n    goToBackup: 'Ir para backup',\n    externalUrl: 'URL externa',\n    externalUrlDescription: 'A URL externa onde o Bambuddy está acessível. Usada para imagens de notificação e integrações externas.',\n    bambuddyUrl: 'URL do Bambuddy',\n    externalUrlHint: 'Inclua protocolo e porta (ex: http://192.168.1.100:8000)',\n    ftpRetry: 'Tentativa FTP',\n    ftpRetryDescription: 'Tentar novamente operações FTP quando o WiFi da impressora é instável. Aplica-se a downloads 3MF, uploads de impressão, downloads de timelapse e atualizações de firmware.',\n    autoRetryDescription: 'Tentar novamente automaticamente operações FTP que falharam',\n    retryAttempts: 'Tentativas de reenvio',\n    retryDelay: 'Atraso entre tentativas',\n    connectionTimeout: 'Tempo limite de conexão',\n    time_one: '{{count}} vez',\n    time_other: '{{count}} vezes',\n    second_one: '{{count}} segundo',\n    second_other: '{{count}} segundos',\n    nSeconds: '{{count}} segundos',\n    increaseForWeakWifi: 'Aumente para impressoras com WiFi fraco',\n    homeAssistant: 'Home Assistant',\n    homeAssistantFullDescription: 'Conecte ao Home Assistant para controlar plugues inteligentes via API REST do HA. Suporta entidades switch, light, input_boolean e script.',\n    homeAssistantUrl: 'URL do Home Assistant',\n    longLivedAccessToken: 'Token de acesso de longa duração',\n    haTokenHint: 'Crie um token no HA: Perfil → Tokens de acesso de longa duração → Criar token',\n    connectionSuccessful: 'Conexão bem-sucedida',\n    connectionFailed: 'Falha na conexão',\n    haConnectionSuccess: 'Conectado com sucesso ao Home Assistant.',\n    haConnectionFailed: 'Falha ao conectar ao Home Assistant.',\n    mqttPublishing: 'Publicação MQTT',\n    mqttDescription: 'Publique eventos do BamBuddy para um broker MQTT externo para integração com Node-RED, Home Assistant e outros sistemas de automação.',\n    mqttEnableDescription: 'Publicar eventos no broker MQTT externo',\n    brokerHostname: 'Hostname do broker',\n    port: 'Porta',\n    usernameOptional: 'Usuário (opcional)',\n    passwordOptional: 'Senha (opcional)',\n    topicPrefix: 'Prefixo do tópico',\n    topicPrefixHint: 'Os tópicos serão: {{prefix}}/printers/<serial>/status, etc.',\n    prometheusMetrics: 'Métricas Prometheus',\n    prometheusEndpointDescription: 'Expor métricas da impressora em <code>/api/v1/metrics</code> para monitoramento Prometheus/Grafana.',\n    bearerTokenOptional: 'Token Bearer (opcional)',\n    bearerTokenHint: 'Se definido, as requisições devem incluir <code>Authorization: Bearer <token></code>',\n    metricsConnectionStatus: 'Status da conexão',\n    metricsPrinterState: 'Estado da impressora (idle/printing/etc)',\n    metricsPrintProgress: 'Progresso da impressão 0-100%',\n    metricsBedTemp: 'Temperatura da mesa',\n    metricsNozzleTemp: 'Temperatura do bico',\n    metricsPrintsTotal: 'Total de impressões por resultado',\n    metricsMore: '...e mais (camadas, ventoinhas, fila, uso de filamento)',\n    smartPlugsDescription: 'Conecte plugues inteligentes (Tasmota ou Home Assistant) para automatizar o controle de energia e rastrear o consumo energético das suas impressoras.',\n    allOn: 'Ligar todos',\n    allOff: 'Desligar todos',\n    addSmartPlug: 'Adicionar plugue inteligente',\n    energySummary: 'Resumo de energia',\n    currentPower: 'Potência atual',\n    plugsOnline: '{{reachable}}/{{total}} plugues online',\n    today: 'Hoje',\n    yesterday: 'Ontem',\n    total: 'Total',\n    enablePlugsForSummary: 'Habilite os plugues para ver o resumo de energia',\n    addNotificationProvider: 'Adicionar',\n    systemBadge: '(Sistema)',\n    creating: 'Criando...',\n    changing: 'Alterando...',\n    deleteUserAndItems: 'Excluir usuário E seus itens',\n    deleteUserKeepItems: 'Excluir usuário, manter itens (ficarão sem dono)',\n    ok: 'OK',\n\n    // 2FA settings\n    twoFa: {\n      totpTitle: 'App Autenticador (TOTP)',\n      totpDesc: 'Use um app como Google Authenticator, Aegis ou Authy.',\n      emailOtpTitle: 'OTP por e-mail',\n      emailOtpDesc: 'Envie um código único para {{email}} ao fazer login.',\n      emailOtpNoEmail: 'Adicione um endereço de e-mail à sua conta para ativar este método.',\n      addEmailFirst: 'Sua conta não tem endereço de e-mail. Peça a um administrador para adicionar um.',\n      setupTotp: 'Configurar App Autenticador',\n      setupAuthApp: 'Configurar App Autenticador',\n      setupInstructions: 'Escaneie o código QR com seu app autenticador e confirme com um código.',\n      manualEntry: 'Não consegue escanear? Digite este segredo manualmente:',\n      scannedContinue: 'Código escaneado — continuar',\n      enterCodeToConfirm: 'Digite o código de 6 dígitos do seu app autenticador para confirmar.',\n      activate: 'Ativar',\n      disableTotp: 'Desativar Autenticador',\n      disableConfirmHint: 'Digite um código TOTP válido ou um código de backup para desativar o autenticador.',\n      totpDisabled: 'App autenticador desativado.',\n      emailOtpEnabled: 'OTP por e-mail ativado.',\n      emailOtpDisabled: 'OTP por e-mail desativado.',\n      smtpRequired: 'Por favor, configure e teste as configurações SMTP primeiro.',\n      invalidCode: 'Código inválido. Por favor, tente novamente.',\n      enableEmailOtp: 'Ativar OTP por e-mail',\n      disableEmailOtp: 'Desativar OTP por e-mail',\n      emailSetupEnterCode: 'Um código de verificação foi enviado para o seu endereço de e-mail. Digite-o abaixo para confirmar que você possui esta caixa de entrada.',\n      verifyAndEnable: 'Verificar e Ativar',\n      emailDisablePasswordHint: 'Digite a senha da sua conta para confirmar a desativação do OTP por e-mail.',\n      passwordPlaceholder: 'Digite sua senha',\n      backupCodesTitle: 'Salve seus códigos de backup',\n      backupCodesWarning: 'Guarde estes códigos em lugar seguro. Cada código só pode ser usado uma vez.',\n      backupCodesRemaining: '{{count}} códigos de backup restantes',\n      savedCodes: 'Códigos salvos',\n      regenBackup: 'Regenerar códigos de backup',\n      regenBackupHint: 'Digite seu código TOTP atual para gerar 10 novos códigos de backup.',\n      newBackupCodes: 'Novos códigos de backup',\n      linkedAccounts: 'Contas SSO vinculadas',\n      linkedAccountsDesc: 'Estes provedores de identidade externos estão vinculados à sua conta.',\n      oidcUnlinked: 'Conta desvinculada.',\n    },\n\n    // OIDC provider settings\n    oidc: {\n      title: 'Provedores SSO / OIDC',\n      desc: 'Configure provedores OpenID Connect para login único.',\n      addProvider: 'Adicionar provedor',\n      newProvider: 'Novo provedor',\n      empty: 'Nenhum provedor OIDC configurado ainda.',\n      created: 'Provedor criado.',\n      updated: 'Provedor atualizado.',\n      deleted: 'Provedor excluído.',\n      deleteTitle: 'Excluir provedor',\n      deleteMessage: 'Excluir \"{{name}}\"? Todas as contas vinculadas serão desconectadas.',\n      form: {\n        name: 'Nome de exibição',\n        issuerUrl: 'URL do emissor',\n        clientId: 'Client ID',\n        clientSecret: 'Client secret',\n        scopes: 'Escopos',\n        iconUrl: 'URL do ícone (opcional)',\n        enabled: 'Ativado',\n        autoCreate: 'Criar usuários automaticamente',\n        autoCreateDesc: 'Cria automaticamente uma conta local no primeiro login.',\n        autoLink: 'Vincular contas existentes automaticamente',\n        autoLinkDesc: 'Vincula contas locais existentes por e-mail no primeiro login.',\n        secretHint: 'deixe em branco para manter',\n        secretPlaceholder: 'novo segredo',\n      },\n    },\n\n  },\n\n  // Notifications (for push notifications)\n  notification: {\n    printStarted: {\n      title: 'Impressão Iniciada',\n      body: '{{printer}}: {{filename}} iniciou a impressão',\n    },\n    printCompleted: {\n      title: 'Impressão Concluída',\n      body: '{{printer}}: {{filename}} foi concluída com sucesso',\n    },\n    printFailed: {\n      title: 'Falha na Impressão',\n      body: '{{printer}}: {{filename}} falhou',\n    },\n    printStopped: {\n      title: 'Impressão Interrompida',\n      body: '{{printer}}: {{filename}} foi interrompida',\n    },\n    printProgress: {\n      title: 'Progresso da Impressão',\n      body: '{{printer}}: {{filename}} está {{percent}}% concluída',\n    },\n    printerOffline: {\n      title: 'Impressora Offline',\n      body: '{{printer}} está offline',\n    },\n    printerError: {\n      title: 'Erro na Impressora',\n      body: '{{printer}}: {{error}}',\n    },\n    filamentLow: {\n      title: 'Filamento Baixo',\n      body: '{{printer}}: O filamento está acabando',\n    },\n    maintenanceDue: {\n      title: 'Manutenção Pendente',\n      body: '{{printer}}: {{items}} precisam de atenção',\n    },\n  },\n\n  // Errors\n  errors: {\n    generic: 'Algo deu errado',\n    networkError: 'Erro de rede. Por favor, verifique sua conexão.',\n    notFound: 'Não encontrado',\n    unauthorized: 'Não autorizado',\n    serverError: 'Erro no servidor',\n    validationError: 'Por favor, verifique sua entrada',\n    printerConnectionFailed: 'Falha ao conectar à impressora',\n    saveFailed: 'Falha ao salvar alterações',\n    deleteFailed: 'Falha ao excluir',\n    loadFailed: 'Falha ao carregar dados',\n  },\n\n  // HMS Errors modal\n  hmsErrors: {\n    title: 'Erros - {{name}}',\n    noErrors: 'Nenhum erro',\n    viewOnWiki: 'Ver no Bambu Lab Wiki',\n    clearInstructions: 'Limpe os erros na impressora para descartá-los aqui.',\n    clearErrors: 'Limpar Erros',\n    clearSuccess: 'Erros HMS limpos',\n    clearFailed: 'Falha ao limpar erros HMS',\n  },\n\n  // MQTT Debug modal\n  mqttDebug: {\n    title: 'MQTT Log de Depuração',\n    searchPlaceholder: 'Pesquisar tópico ou payload...',\n    noMessages: 'Nenhuma mensagem registrada ainda',\n    startLoggingHint: 'Clique em \"Iniciar Registro\" para começar a capturar mensagens MQTT',\n    noMessagesMatch: 'Nenhuma mensagem corresponde ao seu filtro',\n    adjustFilterHint: 'Tente ajustar seus critérios de pesquisa ou filtro',\n    incoming: 'Entrada',\n    outgoing: 'Saída',\n    loggingStopped: 'Registro interrompido',\n    loggingActive: 'Registro ativo - as mensagens serão atualizadas automaticamente',\n    startLogging: 'Iniciar Registro',\n    stopLogging: 'Parar Registro',\n    clearLog: 'Limpar Registro',\n    topic: 'ópico',\n    timestamp: 'Carimbo de Data/Hora',\n    direction: 'Direção',\n    all: 'Todos',\n  },\n\n  // Printer File Manager modal (printer internal storage)\n  printerFiles: {\n    title: 'Gerenciador de Arquivos',\n    storageUsed: 'Usado:',\n    storageFree: 'Livre:',\n    filterPlaceholder: 'Filtrar arquivos...',\n    deleteButton: 'Excluir',\n    deleteFiles: 'Excluir {{count}} arquivos',\n    deleteFileConfirm: 'Excluir \"{{name}}\"? Isso não pode ser desfeito.',\n    deleteFilesConfirm: 'Excluir {{count}} arquivos selecionados? Isso não pode ser desfeito.',\n    noFiles: 'Nenhum arquivo na impressora',\n    loadingFiles: 'Carregando arquivos...',\n    failedToLoad: 'Falha ao carregar arquivos',\n    toast: {\n      filesDeleted: 'Arquivos excluídos: {{count}}',\n      deleteFailed: 'Falha ao excluir: {{error}}',\n    },\n  },\n\n  // Confirmations\n  confirm: {\n    delete: 'Tem certeza de que deseja excluir isso?',\n    unsavedChanges: 'Você tem alterações não salvas. Tem certeza de que deseja sair?',\n    clearQueue: 'Tem certeza de que deseja limpar a fila?',\n  },\n\n  // Login page\n  login: {\n    title: 'Bambuddy Login',\n    subtitle: 'Faça login na sua conta',\n    username: 'Nome de usuário',\n    usernamePlaceholder: 'Digite seu nome de usuário',\n    usernameOrEmail: 'Nome de usuário ou Email',\n    usernameOrEmailPlaceholder: 'Nome de usuário ou Email',\n    password: 'Senha',\n    passwordPlaceholder: 'Digite sua senha',\n    signIn: 'Entrar',\n    signingIn: 'Entrando...',\n    forgotPassword: 'Esqueceu sua senha?',\n    loginSuccess: 'Login realizado com sucesso',\n    loginFailed: 'Falha no login',\n    enterCredentials: 'Por favor, insira nome de usuário e senha',\n    enterEmail: 'Por favor, insira seu endereço de e-mail',\n    oidcLoginFailed: 'Falha no login OIDC',\n    oidcErrors: {\n      providerError: 'O provedor de identidade retornou um erro',\n      missingParameters: 'Parâmetros obrigatórios ausentes no callback OIDC',\n      invalidState: 'Estado OIDC inválido ou já utilizado',\n      stateExpired: 'Sessão OIDC expirada — tente novamente',\n      providerNotFound: 'Provedor OIDC não encontrado',\n      discoveryFailed: 'Falha ao obter o documento de descoberta OIDC',\n      invalidDiscovery: 'Documento de descoberta OIDC inválido',\n      networkError: 'Erro de rede durante a troca de token OIDC',\n      badResponse: 'Resposta inesperada durante a troca de token OIDC',\n      noIdToken: 'O provedor OIDC não retornou um token de ID',\n      validationFailed: 'Falha na validação do token OIDC',\n      nonceMismatch: 'Nonce OIDC não corresponde — possível ataque de replay',\n      missingSubClaim: 'Token OIDC sem claim sub',\n      noLinkedAccount: 'Nenhuma conta local vinculada a esta identidade OIDC',\n      accountInactive: 'Sua conta está inativa',\n      userResolutionFailed: 'Falha ao resolver sua conta',\n      internalError: 'Erro interno durante o login OIDC',\n      tokenExchangeFailed: 'Falha na troca de token OIDC',\n    },\n    forgotPasswordTitle: 'Esqueceu a Senha',\n    forgotPasswordMessage: 'Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.',\n    forgotPasswordEmailMessage: 'Digite seu endereço de email e enviaremos uma nova senha.',\n    emailAddress: 'Endereço de Email',\n    emailPlaceholder: 'seu.email@exemplo.com',\n    cancel: 'Cancelar',\n    sending: 'Enviando...',\n    sendResetEmail: 'Enviar Email de Redefinição',\n    howToReset: 'Como redefinir sua senha:',\n    resetStep1: 'Entre em contato com o administrador do Bambuddy',\n    resetStep2: 'Peça para redefinir sua senha na Gestão de Usuários',\n    resetStep3: 'Eles podem definir uma nova senha temporária para você',\n    resetStep4: 'Faça login com a nova senha e altere-a nas Configurações',\n    gotIt: 'Entendi',\n    resetPassword: {\n      title: 'Definir nova senha',\n      subtitle: 'Digite e confirme sua nova senha abaixo.',\n      newPassword: 'Nova senha',\n      newPasswordPlaceholder: 'Pelo menos 8 caracteres',\n      confirmPassword: 'Confirmar senha',\n      confirmPasswordPlaceholder: 'Repetir nova senha',\n      saving: 'Salvando\\u2026',\n      submit: 'Definir nova senha',\n      backToLogin: 'Voltar para o login',\n      passwordsDoNotMatch: 'As senhas não coincidem',\n      passwordTooShort: 'A senha deve ter pelo menos 8 caracteres',\n      resetFailed: 'Falha ao redefinir senha. O link pode ter expirado.',\n    },\n    twoFA: {\n      title: 'Autenticação em dois fatores',\n      subtitle: 'Sua conta está protegida com 2FA. Insira o código de verificação abaixo.',\n      methodAuthenticator: 'Aplicativo autenticador',\n      methodEmail: 'Código por e-mail',\n      methodBackup: 'Código de recuperação',\n      instructionsTotp: 'Abra seu aplicativo autenticador e insira o código de 6 dígitos gerado para o Bambuddy.',\n      instructionsEmail: 'Um código de 6 dígitos foi enviado para o seu e-mail. Ele é válido por 10 minutos.',\n      instructionsEmailNotSent: 'Clique no botão abaixo para receber um código de verificação por e-mail.',\n      instructionsBackup: 'Insira um dos seus códigos de recuperação de 8 caracteres. Cada código só pode ser utilizado uma vez.',\n      sendCodeButton: 'Enviar código por e-mail',\n      sendingCode: 'Enviando...',\n      resendCode: 'Reenviar código',\n      codeLabel: 'Código de verificação',\n      backupCodeLabel: 'Código de recuperação',\n      codePlaceholder: '000000',\n      backupCodePlaceholder: 'XXXXXXXX',\n      verifyButton: 'Verificar',\n      verifyingButton: 'Verificando...',\n      backToLogin: '← Voltar para o login',\n      orContinueWith: 'ou entrar com',\n      signInWith: 'Entrar com {{provider}}',\n      enterCode: 'Por favor, insira o código de verificação',\n      sendCodeFailed: 'Falha ao enviar o código de verificação',\n      invalidCode: 'Código inválido. Por favor, tente novamente.',\n    },\n\n  },\n\n  // Setup page\n  setup: {\n    title: 'Bambuddy Configuração',\n    subtitle: 'Configure a autenticação para sua instância do Bambuddy',\n    enableAuth: 'Ativar Autenticação',\n    adminAccount: 'Conta de Administrador',\n    adminAccountDesc: 'Se usuários administradores já existirem, a autenticação será ativada usando as contas de administrador existentes. Deixe os campos abaixo vazios para usar os administradores existentes ou insira novas credenciais para criar um novo usuário administrador.',\n    adminUsername: 'Nome de usuário do administrador',\n    adminPassword: 'Senha do administrador',\n    optionalIfAdminExists: '(opcional se usuários administradores existirem)',\n    adminUsernamePlaceholder: 'Digite o nome de usuário do administrador (opcional)',\n    adminPasswordPlaceholder: 'Digite a senha do administrador (opcional)',\n    confirmPassword: 'Confirmar Senha',\n    confirmPasswordPlaceholder: 'Confirme a senha do administrador',\n    settingUp: 'Configurando...',\n    completeSetup: 'Concluir Configuração',\n    toast: {\n      authEnabledAdminCreated: 'Autenticação ativada e usuário administrador criado',\n      authEnabledExistingAdmins: 'Autenticação ativada usando usuários administradores existentes',\n      setupCompleted: 'Configuração concluída',\n      enterBothCredentials: 'Por favor, insira o nome de usuário e a senha do administrador, ou deixe ambos vazios para usar os usuários administradores existentes',\n      passwordsDoNotMatch: 'As senhas não coincidem',\n      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',\n    },\n  },\n\n  // Password change\n  changePassword: {\n    title: 'Alterar Senha',\n    currentPassword: 'Senha Atual',\n    currentPasswordPlaceholder: 'Digite a senha atual',\n    newPassword: 'Nova Senha',\n    newPasswordPlaceholder: 'Digite a nova senha (mínimo 6 caracteres)',\n    confirmPassword: 'Confirmar Senha',\n    confirmPasswordPlaceholder: 'Confirme a nova senha',\n    passwordsDoNotMatch: 'As senhas não coincidem',\n    passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',\n    changing: 'Alterando...',\n    success: 'Senha alterada com sucesso',\n    failed: 'Falha ao alterar a senha',\n  },\n\n  // Plate detection alert\n  plateAlert: {\n    title: 'Impressão Pausada!',\n    message: 'Objetos detectados na mesa de impressão. A impressão foi automaticamente pausada. Por favor, limpe a mesa e retome a impressão.',\n    understand: 'Entendi',\n  },\n\n  // Camera page\n  camera: {\n    title: 'Visualização da Câmera',\n    invalidPrinterId: 'ID da impressora inválido',\n    live: 'Ao Vivo',\n    snapshot: 'Captura',\n    restartStream: 'Reiniciar transmissão',\n    refreshSnapshot: 'Atualizar captura',\n    fullscreen: 'Tela Cheia',\n    exitFullscreen: 'Sair da Tela Cheia',\n    connectingToCamera: 'Conectando à câmera...',\n    capturingSnapshot: 'Capturando imagem...',\n    connectionLost: 'Conexão perdida',\n    connectionFailed: 'Falha na conexão com a câmera',\n    reconnecting: 'Reconectando em {{countdown}}s... (tentativa {{attempt}}/{{max}})',\n    reconnectNow: 'Reconectar agora',\n    cameraUnavailable: 'Câmera indisponível',\n    cameraUnavailableDesc: 'Certifique-se de que a impressora está ligada e conectada.',\n    noCamera: 'Nenhuma câmera disponível',\n    retry: 'Tentar novamente',\n    cameraStream: 'Transmissão da câmera',\n    zoomOut: 'Reduzir zoom',\n    zoomIn: 'Aumentar zoom',\n    resetZoom: 'Redefinir zoom',\n    recording: 'Gravando',\n    startRecording: 'Iniciar gravação',\n    stopRecording: 'Parar gravação',\n    chamberLight: 'Alternar luz da câmara',\n  },\n\n  // Groups management\n  groups: {\n    title: 'Gerenciamento de Grupos',\n    subtitle: 'Gerenciar grupos de permissão para controle de acesso',\n    backToSettings: 'Voltar para Configurações',\n    createGroup: 'Criar Grupo',\n    noPermission: 'Você não tem permissão para acessar esta página.',\n    system: 'Sistema',\n    noDescription: 'Sem descrição',\n    usersCount: '{{count}} usuários',\n    permissionsCount: '{{count}} permissões',\n    edit: 'Editar',\n    delete: 'Excluir',\n    toast: {\n      created: 'Grupo criado com sucesso',\n      updated: 'Grupo atualizado com sucesso',\n      deleted: 'Grupo excluído com sucesso',\n      enterGroupName: 'Por favor, insira um nome para o grupo',\n    },\n    modal: {\n      editGroup: 'Editar Grupo',\n      createGroup: 'Criar Grupo',\n      cancel: 'Cancelar',\n      saving: 'Salvando...',\n      creating: 'Criando...',\n      saveChanges: 'Salvar Alterações',\n    },\n    form: {\n      groupName: 'Nome do Grupo',\n      groupNamePlaceholder: 'Insira o nome do grupo',\n      systemGroupWarning: 'Os nomes dos grupos do sistema não podem ser alterados',\n      description: 'Descrição',\n      descriptionPlaceholder: 'Insira a descrição (opcional)',\n      permissions: 'Permissões ({{count}} selecionadas)',\n    },\n    deleteModal: {\n      title: 'Excluir Grupo',\n      message: 'Tem certeza de que deseja excluir este grupo? Os usuários deste grupo perderão essas permissões.',\n      confirm: 'Excluir Grupo',\n    },\n    editor: {\n      title: 'Editar Grupo',\n      createTitle: 'Criar Grupo',\n      search: 'Pesquisar permissões...',\n      selectAll: 'Selecionar Tudo',\n      clearAll: 'Limpar Tudo',\n      permissionsSelected: '{{count}} selecionada(s)',\n      noResults: 'Nenhuma permissão corresponde à sua pesquisa',\n    },\n  },\n\n  // Users management\n  users: {\n    title: 'Gerenciamento de Usuários',\n    subtitle: 'Gerenciar usuários e seu acesso à sua instância do Bambuddy',\n    backToSettings: 'Voltar para Configurações',\n    createUser: 'Criar Usuário',\n    noPermission: 'Você não tem permissão para acessar esta página.',\n    admin: 'Admin',\n    noGroups: 'Sem grupos',\n    active: 'Ativo',\n    inactive: 'Inativo',\n    edit: 'Editar',\n    delete: 'Excluir',\n    system: 'Sistema',\n    noGroupsAvailable: 'Nenhum grupo disponível',\n    table: {\n      username: 'Nome de Usuário',\n      groups: 'Grupos',\n      status: 'Status',\n      actions: 'Ações',\n    },\n    toast: {\n      created: 'Usuário criado com sucesso',\n      updated: 'Usuário atualizado com sucesso',\n      deleted: 'Usuário excluído com sucesso',\n      fillRequired: 'Por favor, preencha todos os campos obrigatórios',\n      passwordsDoNotMatch: 'As senhas não coincidem',\n      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',\n    },\n    modal: {\n      createUser: 'Criar Usuário',\n      editUser: 'Editar Usuário',\n      cancel: 'Cancelar',\n      creating: 'Criando...',\n      saving: 'Salvando...',\n      saveChanges: 'Salvar Alterações',\n      advancedAuthSubtitle: 'com Autenticação Avançada',\n    },\n    form: {\n      username: 'Nome de Usuário',\n      usernamePlaceholder: 'Insira o nome de usuário',\n      email: 'Email',\n      emailPlaceholder: 'user@example.com',\n      password: 'Senha',\n      passwordPlaceholder: 'Insira a senha',\n      confirmPassword: 'Confirmar Senha',\n      confirmPasswordPlaceholder: 'Confirme a senha',\n      newPasswordPlaceholder: 'Insira a nova senha',\n      confirmNewPasswordPlaceholder: 'Confirme a nova senha',\n      leaveBlankToKeep: 'deixe em branco para manter a atual',\n      groups: 'Grupos',\n      optional: 'opcional',\n      autoGeneratedPassword: 'Uma senha segura será gerada automaticamente e enviada por e-mail ao usuário.',\n      passwordManagedByAdvancedAuth: 'A senha é gerenciada pela Autenticação Avançada. Use \"Redefinir Senha\" para enviar uma nova senha ao usuário por e-mail.',\n      resetPassword: 'Redefinir Senha',\n      resettingPassword: 'Redefinindo Senha...',\n    },\n    deleteModal: {\n      title: 'Excluir Usuário',\n      message: 'Tem certeza de que deseja excluir este usuário? Esta ação não pode ser desfeita.',\n      confirm: 'Excluir Usuário',\n    },\n  },\n\n  // Stream overlay\n  streamOverlay: {\n    title: 'Stream Overlay',\n    invalidPrinterId: 'ID da impressora inválido',\n    cameraStream: 'Transmissão da câmera',\n    progress: 'Progresso da impressão',\n    eta: 'ETA',\n    printerIdle: 'Impressora ociosa',\n    printerOffline: 'Impressora offline',\n    status: {\n      printing: 'Imprimindo',\n      paused: 'Pausado',\n      finished: 'Concluído',\n      failed: 'Falhou',\n      idle: 'Ocioso',\n      unknown: 'Desconhecido',\n    },\n  },\n\n  // Profiles\n  profiles: {\n    title: 'Perfis',\n    subtitle: 'Gerencie seus presets de fatiador e calibrações de avanço de pressão',\n    tabs: {\n      cloud: 'Perfis na Nuvem',\n      local: 'Perfis Locais',\n      kprofiles: 'K-Perfis',\n    },\n    localProfiles: {\n      title: 'Perfis Locais',\n      subtitle: 'Importe e gerencie presets de fatiador do OrcaSlicer',\n      import: 'Importar Perfis',\n      importDesc: 'Solte arquivos .bbscfg, .bbsflmt, .orca_filament, .zip ou .json aqui',\n      importing: 'Importando...',\n      search: 'Pesquisar presets locais...',\n      noPresets: 'Nenhum preset local ainda',\n      badge: 'Local',\n      edit: 'Editar',\n      delete: 'Excluir',\n      cancel: 'Cancelar',\n      deleteConfirmTitle: 'Excluir Preset',\n      deleteConfirm: 'Tem certeza de que deseja excluir este preset? Esta ação não pode ser desfeita.',\n      source: 'Fonte',\n      inheritsFrom: 'Herdado de',\n      filamentType: 'Tipo',\n      vendor: 'Fornecedor',\n      compatiblePrinters: 'Impressoras Compatíveis',\n      nozzleTemp: 'Temperatura do Bico',\n      cost: 'Custo',\n      density: 'Densidade',\n      pressureAdvance: 'Avanço de Pressão',\n      filament: 'Filamento',\n      process: 'Processo',\n      printer: 'Impressora',\n      toast: {\n        importSuccess: '{{count}} preset(s) importada(s)',\n        importSkipped: '{{count}} preset(s) ignorada(s) (duplicadas)',\n        importError: '{{count}} erro(s) durante a importação',\n        deleted: 'Preset excluído',\n        updated: 'Preset atualizado',\n      },\n    },\n    connectedAs: 'Conectado como',\n    logout: 'Sair',\n    noLogoutPermission: 'Você não tem permissão para sair',\n    failedToLoad: 'Falha ao carregar perfis',\n    retry: 'Tentar novamente',\n    time: {\n      justNow: 'Agora mesmo',\n      minsAgo: 'há {{count}} minutos',\n      hoursAgo: 'há {{count}} horas',\n      daysAgo: 'há {{count}} dias',\n    },\n    toast: {\n      loggedOut: 'Desconectado',\n    },\n    login: {\n      title: 'Conectar ao Bambu Cloud',\n      subtitle: 'Sincronize seus presets de fatiador entre dispositivos',\n      email: 'Email',\n      password: 'Senha',\n      region: 'Região',\n      regionGlobal: 'Global',\n      regionChina: 'China',\n      verificationCode: 'Código de Verificação',\n      totpCode: 'Código do Autenticador',\n      checkEmail: 'Verifique seu email ({{email}}) para um código de 6 dígitos',\n      enterTotpHint: 'Digite o código de 6 dígitos do seu aplicativo autenticador',\n      accessToken: 'Token de Acesso',\n      accessTokenHint: 'Cole seu token de acesso Bambu Lab (do Bambu Studio)',\n      back: 'Voltar',\n      loginButton: 'Entrar',\n      verifyButton: 'Verificar',\n      setTokenButton: 'Definir Token',\n      useToken: 'Usar token de acesso em vez disso',\n      useEmail: 'Entrar com email em vez disso',\n      toast: {\n        loggedIn: 'Conectado com sucesso',\n        codeSent: 'Código de verificação enviado para seu email',\n        enterTotp: 'Digite o código do seu aplicativo autenticador',\n        tokenSet: 'Token definido com sucesso',\n      },\n    },\n    presets: {\n      myPreset: 'Meu preset (editável)',\n      duplicate: 'Duplicar',\n      editable: 'Editável',\n      failedToLoadDetails: 'Falha ao carregar detalhes do preset',\n      deleteConfirm: 'Excluir este preset?',\n      deleteWarning: 'Isso excluirá permanentemente \"{{name}}\" do Bambu Cloud. Esta ação não pode ser desfeita.',\n      noDuplicatePermission: 'Você não tem permissão para duplicar presets',\n      noEditPermission: 'Você não tem permissão para editar presets',\n      noDeletePermission: 'Você não tem permissão para excluir presets',\n      types: {\n        filament: 'Preset de filamento',\n        printer: 'Preset de impressora',\n        process: 'Preset de processo',\n      },\n      toast: {\n        deleted: 'Preset excluído',\n        created: 'Preset criado',\n        updated: 'Preset atualizado',\n        duplicated: 'Preset duplicado',\n        fieldAdded: 'Campo \"{{key}}\" adicionado',\n        exported: 'Preset exportado',\n      },\n      baseLabel: 'Base: {{name}}',\n      currentLabel: 'Atual: {{name}}',\n      newPreset: 'Novo Preset',\n      editPreset: 'Editar Preset',\n      duplicatePreset: 'Duplicar Preset',\n      createNewPreset: 'Criar Novo Preset',\n      customizeSettings: 'Personalizar configurações para seu novo preset',\n      compareWithBase: 'Comparar com o preset base',\n      compare: 'Comparar',\n      // CreatePresetModal - Basic Info\n      basePreset: 'Preset Base',\n      selectBasePreset: 'Selecionar preset base...',\n      presetName: 'Nome do Preset',\n      myCustomPreset: 'Meu preset personalizado',\n      inheritsFrom: 'Herdado de',\n      dropJsonToImport: 'Solte o arquivo JSON para importar',\n      // CreatePresetModal - Tabs\n      tabs: {\n        common: 'Comum',\n        allFields: 'Todos os Campos',\n      },\n      // CreatePresetModal - All Fields Tab\n      availableFields: 'Campos Disponíveis',\n      searchFieldsPlaceholder: 'Pesquisar campos...',\n      noMatchingFields: 'Nenhum campo correspondente',\n      allFieldsAdded: 'Todos os campos adicionados',\n      addCustomField: 'Adicionar campo personalizado',\n      yourOverrides: 'Suas Substituições',\n      noOverridesYet: 'Nenhuma substituição ainda',\n      clickFieldsToAdd: 'Clique nos campos à esquerda para adicioná-los',\n      saveAsTemplate: 'Salvar como modelo',\n      jsonTip: 'Dica: Arraste e solte um arquivo .json em qualquer lugar deste modal para importar configurações',\n    },\n    cloudView: {\n      searchPlaceholder: 'Pesquisar presets...',\n      templates: 'Modelos',\n      refresh: 'Atualizar',\n      newPreset: 'Novo Preset',\n      clearFilters: 'Limpar filtros',\n      // Compare mode\n      compareMode: 'Modo de Comparação',\n      selectAnotherPreset: 'Selecionar outro preset {{type}}',\n      clickTwoPresets: 'Clique em dois presets do mesmo tipo para comparar',\n      selectFirst: '1. Selecionar primeiro',\n      selectSecond: '2. Selecionar segundo',\n      compareNow: 'Comparar Agora',\n      // Status row\n      lastSynced: 'Última sincronização:',\n      showingCount: 'Mostrando {{showing}} de {{total}} presets',\n      noPresetsFound: 'Nenhum preset encontrado',\n      // Column headers\n      columns: {\n        filament: 'Filamento',\n        process: 'Processo',\n        printer: 'Impressora',\n      },\n      noFilamentPresets: 'Nenhum preset de filamento',\n      noProcessPresets: 'Nenhum preset de processo',\n      noPrinterPresets: 'Nenhum preset de impressora',\n      // Filters\n      filters: {\n        type: 'Tipo',\n        owner: 'Proprietário',\n        printer: 'Impressora',\n        nozzle: 'Bico',\n        filament: 'Filamento',\n        layer: 'Camada',\n        all: 'Todos',\n        myPresets: 'Meus Presets',\n        builtIn: 'Integrado',\n        process: 'Processo',\n      },\n      // Permissions\n      noTemplatesPermission: 'Você não tem permissão para gerenciar modelos',\n      noRefreshPermission: 'Você não tem permissão para atualizar perfis',\n      noCreatePermission: 'Você não tem permissão para criar presets',\n    },\n    templates: {\n      title: 'Modelos Rápidos',\n      noTemplates: 'Nenhum modelo ainda',\n      createFirst: 'Crie modelos a partir do editor de presets',\n      typeFilter: 'Tipo:',\n      deleteTitle: 'Excluir Modelo',\n      deleteWarning: 'Esta ação não pode ser desfeita',\n      deleteConfirm: 'Tem certeza de que deseja excluir \"{{name}}\"?',\n      namePlaceholder: 'Nome do modelo',\n      descriptionPlaceholder: 'Descrição',\n      settingsJson: 'Configurações (JSON)',\n      fieldsCount: '{{count}} campos',\n      shownInModals: 'Exibido em modais',\n      hiddenInModals: 'Oculto em modais',\n      apply: 'Aplicar',\n      toast: {\n        deleted: 'Modelo excluído',\n        updated: 'Modelo atualizado',\n        created: 'Modelo criado',\n        applied: 'Modelo aplicado',\n      },\n    },\n  },\n\n  // Support/Debug\n  support: {\n    debugLoggingActive: 'Registro de depuração ativo',\n    manageLogs: 'Gerenciar',\n    collectItem7: 'Conectividade da impressora e versões de firmware',\n    collectItem8: 'Status de integração (Spoolman, MQTT, HA)',\n    collectItem9: 'Interfaces de rede (somente sub-redes)',\n    collectItem10: 'Versões de pacotes Python',\n    collectItem11: 'Verificações de integridade do banco de dados',\n    collectItem12: 'Detalhes do ambiente Docker',\n  },\n\n  // File manager\n  fileManager: {\n    title: 'Gerenciador de Arquivos',\n    subtitle: 'Organize e gerencie seus arquivos de impressão',\n    uploadFiles: 'Enviar Arquivos',\n    newFolder: 'Nova Pasta',\n    folderName: 'Nome da Pasta',\n    folderNamePlaceholder: 'ex.: Peças Funcionais',\n    renameFile: 'Renomear Arquivo',\n    renameFolder: 'Renomear Pasta',\n    moveFiles: 'Mover {{count}} Arquivo(s)',\n    rootNoFolder: 'Raiz (Sem Pasta)',\n    current: 'Atual',\n    linkFolder: 'Vincular Pasta',\n    linkFolderDescription: 'Vincular \"{{name}}\" a um projeto ou arquivo para acesso rápido.',\n    project: 'Projeto',\n    archive: 'Arquivo',\n    noProjectsFound: 'Nenhum projeto encontrado',\n    noArchivesFound: 'Nenhum arquivo encontrado',\n    unlink: 'Desvincular',\n    link: 'Vincular',\n    dragDropFiles: 'Arraste e solte os arquivos aqui',\n    dropFilesHere: 'Solte os arquivos aqui',\n    orClickToBrowse: 'ou clique para procurar',\n    allFileTypesSupported: 'Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.',\n    zipFilesDetected: 'Arquivos ZIP detectados',\n    zipExtractOptions: 'Arquivos ZIP serão extraídos. Escolha como lidar com a estrutura de pastas:',\n    preserveZipStructure: 'Preservar estrutura de pastas do ZIP',\n    createFolderFromZip: 'Criar pasta a partir do nome do arquivo ZIP',\n    stlThumbnailGeneration: 'Geração de miniaturas STL',\n    zipMayContainStl: 'Arquivos ZIP podem conter arquivos STL. Miniaturas podem ser geradas durante a extração.',\n    thumbnailsCanBeGenerated: 'Miniaturas podem ser geradas para arquivos STL. Modelos grandes podem levar mais tempo para processar.',\n    generateThumbnailsForStl: 'Gerar miniaturas para arquivos STL',\n    threemfDetected: 'Arquivos 3MF detectados',\n    threemfExtractionInfo: 'Modelo da impressora, material, cor e configurações de impressão serão extraídos automaticamente dos arquivos 3MF.',\n    willBeExtracted: 'Será extraído',\n    filesExtracted: '{{count}} arquivos extraídos',\n    uploadComplete: 'Upload concluído: {{succeeded}} bem-sucedidos',\n    uploadFailed: 'Falha no envio',\n    zipFilesFailed: '{{count}} arquivos falharam',\n    uploading: 'Enviando...',\n    changeLink: 'Alterar link...',\n    linkTo: 'Vincular a...',\n    linkToProjectOrArchive: 'Vincular a projeto ou arquivo',\n    addToQueue: 'Adicionar à fila',\n    schedulePrint: 'Agendar impressão',\n    generateThumbnail: 'Gerar miniatura',\n    generateThumbnails: 'Gerar miniaturas',\n    generateThumbnailsForMissing: 'Gerar miniaturas para arquivos STL que não possuem',\n    gridView: 'Visualização em grade',\n    listView: 'Visualização em lista',\n    lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',\n    lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',\n    files: 'Arquivos',\n    folders: 'Pastas',\n    size: 'Tamanho',\n    free: 'Livre',\n    allFiles: 'Todos os arquivos',\n    wrap: 'Quebrar texto',\n    enableTextWrapping: 'Ativar quebra de texto',\n    disableTextWrapping: 'Desativar quebra de texto',\n    collapse: 'Recolher',\n    collapseFoldersByDefault: 'Recolher pastas por padrão',\n    expandFoldersByDefault: 'Expandir pastas por padrão',\n    dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',\n    searchFiles: 'Pesquisar arquivos...',\n    allTypes: 'Todos os tipos',\n    prints: 'Impressões',\n    ascending: 'Crescente',\n    descending: 'Decrescente',\n    resultsCount: '{{showing}} de {{total}} arquivos',\n    selectAll: 'Selecionar tudo',\n    deselectAll: 'Desmarcar tudo',\n    selected: '{{count}} selecionado(s)',\n    adding: 'Adicionando...',\n    loadingFiles: 'Carregando arquivos...',\n    folderIsEmpty: 'A pasta está vazia',\n    noFilesYet: 'Nenhum arquivo ainda',\n    folderEmptyDescription: 'Envie arquivos ou mova arquivos para esta pasta para começar.',\n    noFilesDescription: 'Envie arquivos para começar a organizar seus arquivos relacionados à impressão.',\n    noMatchingFiles: 'Nenhum arquivo correspondente',\n    noMatchingFilesDescription: 'Nenhum arquivo corresponde aos seus critérios de pesquisa ou filtro.',\n    clearFilters: 'Limpar filtros',\n    printedCount: 'Impresso {{count}}x',\n    uploadedBy: 'Enviado por',\n    deleteFolder: 'Excluir pasta',\n    deleteFile: 'Excluir arquivo',\n    deleteFilesCount: 'Excluir {{count}} arquivos',\n    deleteFolderConfirm: 'Tem certeza de que deseja excluir esta pasta? Todos os arquivos dentro também serão excluídos.',\n    deleteFileConfirm: 'Tem certeza de que deseja excluir este arquivo?',\n    deleteFilesConfirm: 'Tem certeza de que deseja excluir {{count}} arquivos selecionados? Esta ação não pode ser desfeita.',\n    deleting: 'Excluindo...',\n    noPermissionRenameFolder: 'Você não tem permissão para renomear pastas',\n    noPermissionLinkFolder: 'Você não tem permissão para vincular pastas',\n    noPermissionDeleteFolder: 'Você não tem permissão para excluir pastas',\n    noPermissionPrint: 'Você não tem permissão para imprimir',\n    noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',\n    noPermissionDownload: 'Você não tem permissão para baixar arquivos',\n    noPermissionRenameFile: 'Você não tem permissão para renomear este arquivo',\n    noPermissionGenerateThumbnail: 'Você não tem permissão para gerar miniaturas',\n    noPermissionDeleteFile: 'Você não tem permissão para excluir este arquivo',\n    noPermissionCreateFolder: 'Você não tem permissão para criar pastas',\n    noPermissionUpload: 'Você não tem permissão para enviar arquivos',\n    noPermissionMoveFiles: 'Você não tem permissão para mover arquivos',\n    noPermissionDeleteFiles: 'Você não tem permissão para excluir arquivos',\n    // External folder\n    linkExternal: 'Vincular externo',\n    linkExternalFolder: 'Vincular pasta externa',\n    linkExternalFolderDescription: 'Montar um diretório do host (NAS, USB, compartilhamento de rede) no Gerenciador de Arquivos. Os arquivos não são copiados — são acessados diretamente do caminho original.',\n    externalFolderNamePlaceholder: 'ex. Impressões NAS',\n    externalPath: 'Caminho do host',\n    externalPathHelp: 'Caminho absoluto do diretório no host Docker. Deve estar montado como bind no contêiner.',\n    readOnly: 'Somente leitura',\n    readOnlyHelp: 'impede uploads e exclusões',\n    showHiddenFiles: 'Mostrar arquivos ocultos (arquivos ponto)',\n    externalFolder: 'Pasta externa',\n    scanFolder: 'Escanear',\n    toast: {\n      folderCreated: 'Pasta criada',\n      folderDeleted: 'Pasta excluída',\n      fileDeleted: 'Arquivo excluído',\n      filesDeleted: 'Excluídos {{count}} arquivos',\n      filesMoved: 'Arquivos movidos',\n      folderLinked: 'Pasta vinculada',\n      folderUnlinked: 'Pasta desvinculada',\n      externalFolderLinked: 'Pasta externa vinculada e escaneada',\n      folderScanned: 'Escaneamento concluído: {{added}} adicionados, {{removed}} removidos',\n      addedToQueue: 'Adicionado {{count}} arquivo(s) à fila',\n      addedToQueuePartial: 'Adicionado {{added}} arquivo(s), {{failed}} falharam',\n      failedToAddToQueue: 'Falha ao adicionar arquivos: {{error}}',\n      fileRenamed: 'Arquivo renomeado',\n      folderRenamed: 'Pasta renomeada',\n      thumbnailsGenerated: 'Geradas {{count}} miniatura(s)',\n      thumbnailsGeneratedPartial: 'Geradas {{succeeded}} miniatura(s), {{failed}} falharam',\n      noStlMissingThumbnails: 'Nenhum arquivo STL sem miniatura',\n      failedToGenerateThumbnails: 'Falha ao gerar miniaturas: {{error}}',\n      thumbnailGenerated: 'Miniatura gerada',\n      failedToGenerateThumbnail: 'Falha ao gerar miniatura: {{error}}',\n    },\n  },\n\n  // Projects\n  projects: {\n    title: 'Projetos',\n    subtitle: 'Organize e acompanhe seus projetos de impressão 3D',\n    newProject: 'Novo Projeto',\n    editProject: 'Editar Projeto',\n    deleteProject: 'Excluir Projeto',\n    projectName: 'Nome do Projeto',\n    description: 'Descrição',\n    noProjects: 'Nenhum projeto ainda',\n    noProjectsFiltered: 'Nenhum projeto {{status}}',\n    noProjectsFilteredHelp: 'Você não tem nenhum projeto {{status}}. Os projetos aparecerão aqui quando seu status mudar.',\n    createFirst: 'Crie seu primeiro projeto para começar a organizar impressões relacionadas, acompanhar o progresso e gerenciar suas construções.',\n    createFirstButton: 'Crie Seu Primeiro Projeto',\n    create: 'Criar',\n    files: 'Arquivos',\n    prints: 'Impressões',\n    plates: 'Placas',\n    parts: 'Peças',\n    lastModified: 'Última Modificação',\n    deleteConfirm: 'Tem certeza de que deseja excluir este projeto? Arquivos e itens da fila serão desvinculados, mas não excluídos.',\n    addFiles: 'Adicionar Arquivos',\n    removeFile: 'Remover Arquivo',\n    viewDetails: 'Ver Detalhes',\n    // Modal fields\n    namePlaceholder: 'ex., Voron 2.4 Build',\n    descriptionPlaceholder: 'Descrição opcional...',\n    color: 'Cor',\n    targetPlates: 'Placas Alvo',\n    targetPlatesPlaceholder: 'ex., 25',\n    targetPlatesHelp: 'Número de trabalhos de impressão',\n    targetParts: 'Peças Alvo',\n    targetPartsPlaceholder: 'ex., 150',\n    targetPartsHelp: 'Total de objetos necessários',\n    tagsLabel: 'Tags (separadas por vírgula)',\n    tagsPlaceholder: 'ex., voron, funcional, presente',\n    dueDate: 'Data de Vencimento',\n    priority: 'Prioridade',\n    priorityLow: 'Baixa',\n    priorityNormal: 'Normal',\n    priorityHigh: 'Alta',\n    priorityUrgent: 'Urgente',\n    // Status\n    statusActive: 'Ativo',\n    statusCompleted: 'Concluído',\n    statusArchived: 'Arquivado',\n    done: 'Concluído',\n    completed: 'Concluído',\n    failed: 'Falhou',\n    inQueue: 'Na fila',\n    noPrintsYet: 'Nenhuma impressão ainda',\n    // Footer stats\n    printJobs: 'Trabalhos de impressão (placas)',\n    partsPrinted: 'Peças impressas',\n    failedParts: 'Peças falhadas',\n    // Actions\n    import: 'Importar',\n    export: 'Exportar',\n    importProject: 'Importar projeto',\n    exportAll: 'Exportar todos os projetos',\n    loading: 'Carregando projetos...',\n    // Permissions\n    noEditPermission: 'Você não tem permissão para editar projetos',\n    noDeletePermission: 'Você não tem permissão para excluir projetos',\n    noCreatePermission: 'Você não tem permissão para criar projetos',\n    noImportPermission: 'Você não tem permissão para importar projetos',\n    noExportPermission: 'Você não tem permissão para exportar projetos',\n    // Toast\n    toast: {\n      created: 'Projeto criado',\n      updated: 'Projeto atualizado',\n      deleted: 'Projeto excluído',\n      imported: 'Projeto importado',\n      multipleImported: '{{count}} projetos importados',\n      importFailed: 'Falha na importação',\n      exported: 'Projetos exportados (apenas metadados)',\n    },\n  },\n\n  // Project detail page\n  projectDetail: {\n    notFound: 'Projeto não encontrado',\n    backToProjects: 'Voltar para Projetos',\n    export: 'Exportar',\n    exportProject: 'Exportar projeto',\n    noExportPermission: 'Você não tem permissão para exportar projetos',\n    noEditPermission: 'Você não tem permissão para editar projetos',\n    partOf: 'Parte de:',\n    priorityLabel: 'Prioridade:',\n    noPrints: 'Nenhuma impressão neste projeto ainda',\n    status: {\n      active: 'Ativo',\n      completed: 'Concluído',\n      archived: 'Arquivado',\n    },\n    priority: {\n      low: 'Baixa',\n      normal: 'Normal',\n      high: 'Alta',\n      urgent: 'Urgente',\n    },\n    dueDate: {\n      overdue: 'Atrasado',\n      today: 'Vence hoje',\n      daysLeft: '{{count}} dias restantes',\n    },\n    progress: {\n      platesProgress: 'Progresso das Placas',\n      partsProgress: 'Progresso das Peças',\n      printJobs: 'Trabalhos de Impressão',\n      parts: 'Peças',\n      percentComplete: '{{percent}}% concluído',\n      remaining: '{{count}} restantes',\n    },\n    stats: {\n      printJobs: 'Trabalhos de Impressão',\n      total: 'total',\n      failed: '{{count}} falhou',\n      partsPrinted: '{{count}} peças impressas',\n      printTime: 'Tempo de Impressão',\n      filamentUsed: 'Filamento Usado',\n    },\n    cost: {\n      title: 'Rastreamento de Custos',\n      filamentCost: 'Custo do Filamento',\n      energy: 'Energia',\n      totalCost: 'Custo Total',\n      total: 'Total',\n      includesBom: 'incl. lista de materiais',\n      budget: 'Orçamento',\n      remaining: 'Restante',\n    },\n    subProjects: {\n      title: 'Sub-projetos ({{count}})',\n    },\n    notes: {\n      title: 'Notas',\n      noEditPermission: 'Você não tem permissão para editar notas',\n      placeholder: 'Adicione notas sobre este projeto...',\n      empty: 'Nenhuma nota ainda. Clique em Editar para adicionar notas.',\n    },\n    files: {\n      title: 'Arquivos',\n      linkFolders: 'Vincular pastas do Gerenciador de Arquivos',\n      forQuickAccess: 'a este projeto para acesso rápido.',\n      fileCount: '{{count}} arquivo(s)',\n      empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',\n      noFiles: 'Nenhum arquivo nesta pasta.',\n      print: 'Imprimir agora',\n      addToQueue: 'Adicionar à fila',\n    },\n    bom: {\n      title: 'Lista de Materiais',\n      acquired: '{{completed}}/{{total}} adquiridos',\n      showAll: 'Mostrar todos',\n      hideDone: 'Ocultar concluídos',\n      addPart: 'Adicionar Peça',\n      noAddPermission: 'Você não tem permissão para adicionar peças',\n      partNamePlaceholder: 'Nome da peça (ex.: parafusos M3x8)',\n      partName: 'Nome da peça',\n      qty: 'Quantidade',\n      price: 'Preço ({{currency}})',\n      sourcingUrlPlaceholder: 'URL de fornecimento (opcional)',\n      remarksPlaceholder: 'Observações (opcional)',\n      deletePart: 'Excluir Peça',\n      deleteConfirm: 'Tem certeza de que deseja excluir \"{{name}}\"?',\n      noUpdatePermission: 'Você não tem permissão para atualizar peças',\n      noEditPermission: 'Você não tem permissão para editar peças',\n      noDeletePermission: 'Você não tem permissão para excluir peças',\n      totalCost: 'Custo total:',\n      empty: 'Nenhuma peça na lista de materiais. Adicione hardware, eletrônicos ou outros componentes para rastrear o que precisa ser adquirido.',\n    },\n    timeline: {\n      title: 'Linha do Tempo de Atividades',\n      empty: 'Nenhuma atividade ainda.',\n    },\n    template: {\n      saveAsTemplate: 'Salvar como Modelo',\n      noCreatePermission: 'Você não tem permissão para criar modelos',\n    },\n    queue: {\n      title: 'Fila',\n      viewAll: 'Ver todos',\n      printing: '{{count}} imprimindo',\n      queued: '{{count}} na fila',\n    },\n    prints: {\n      title: 'Impressões ({{count}})',\n    },\n    toast: {\n      projectUpdated: 'Projeto atualizado',\n      partAdded: 'Peça adicionada',\n      partRemoved: 'Peça removida',\n      exportFailed: 'Falha na exportação',\n      projectExported: 'Projeto exportado',\n      templateCreated: 'Modelo criado',\n    },\n  },\n\n  // System info\n  system: {\n    title: 'Informações do Sistema',\n    version: 'Versão',\n    uptime: 'Tempo de Atividade',\n    cpuUsage: 'Uso da CPU',\n    memoryUsage: 'Uso da Memória',\n    diskUsage: 'Uso do Disco',\n    networkInfo: 'Informações de Rede',\n    logs: 'Logs',\n    debugMode: 'Modo de Depuração',\n    enableDebug: 'Ativar Registro de Depuração',\n    disableDebug: 'Desativar Registro de Depuração',\n    downloadLogs: 'Baixar Logs',\n    clearLogs: 'Limpar Logs',\n    dockerInfo: 'Informações do Docker',\n    containerName: 'Nome do Contêiner',\n    imageName: 'Nome da Imagem',\n    platform: 'Plataforma',\n    architecture: 'Arquitetura',\n  },\n\n  // Library (K Profiles)\n  library: {\n    title: 'Biblioteca de Filamentos',\n    addFilament: 'Adicionar Filamento',\n    editFilament: 'Editar Filamento',\n    deleteFilament: 'Excluir Filamento',\n    vendor: 'Fornecedor',\n    material: 'Material',\n    color: 'Cor',\n    kFactor: 'Fator K',\n    temperature: 'Temperatura',\n    noFilaments: 'Nenhum filamento na biblioteca',\n    deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',\n    importFromPrinter: 'Importar da Impressora',\n    exportToFile: 'Exportar para Arquivo',\n  },\n\n  // Spoolman\n  spoolman: {\n    title: 'Integração com Spoolman',\n    enabled: 'Spoolman Ativado',\n    url: 'URL do Spoolman',\n    connected: 'Conectado',\n    disconnected: 'Não Conectado',\n    testConnection: 'Testar Conexão',\n    sync: 'Sincronizar',\n    syncing: 'Sincronizando...',\n    lastSync: 'Última Sincronização',\n    linkToSpoolman: 'Vincular ao Spoolman',\n    openInSpoolman: 'Abrir no Spoolman',\n    unlinkSpool: 'Desvincular Carretel',\n    unlinkConfirmTitle: 'Desvincular carretel?',\n    unlinkConfirmMessage: 'Isso desconectará o carretel do Spoolman. Os dados do carretel no Spoolman permanecerão inalterados.',\n    selectSpool: 'Selecionar Carretel',\n    noUnlinkedSpools: 'Nenhum carretel desvinculado disponível',\n    linkSuccess: 'Carretel vinculado ao Spoolman com sucesso',\n    linkFailed: 'Falha ao vincular carretel',\n    unlinkSuccess: 'Carretel desvinculado do Spoolman com sucesso',\n    unlinkFailed: 'Falha ao desvincular carretel',\n    spoolId: 'Carretel ID (Spool ID)',\n    fillSourceLabel: '(Spoolman)',\n    weight: 'Peso',\n    remaining: 'Restante',\n    disableWeightSync: 'Desativar Sincronização de Peso Estimado do AMS',\n    disableWeightSyncDesc: 'Não atualize a capacidade restante a partir das estimativas do AMS. Use isso se preferir o rastreamento de uso do Spoolman em vez das estimativas baseadas em porcentagem do AMS. Novos carretéis ainda usarão a estimativa do AMS como seu peso inicial.',\n    reportPartialUsage: 'Relatar Uso Parcial para Impressões Falhadas',\n    reportPartialUsageDesc: 'Quando uma impressão falha ou é cancelada, relate o filamento estimado usado até aquele ponto com base no progresso das camadas.',\n  },\n\n  // Inventory\n  inventory: {\n    title: 'Inventário de Carretéis',\n    addSpool: 'Adicionar Carretel',\n    editSpool: 'Editar Carretel',\n    material: 'Material',\n    selectMaterial: 'Selecionar material...',\n    subtype: 'Subtipo',\n    brand: 'Marca',\n    searchBrand: 'Pesquisar marca...',\n    useCustomBrand: 'Usar \"{{brand}}\"',\n    useCustomMaterial: 'Usar material personalizado: {{material}}',\n    colorName: 'Nome da Cor',\n    colorNamePlaceholder: 'Jade White, Fire Red...',\n    color: 'Cor',\n    hexColor: 'Cor Hexadecimal',\n    pickColor: 'Escolher cor personalizada',\n    labelWeight: 'Peso da Etiqueta',\n    coreWeight: 'Peso do Carretel Vazio',\n    searchSpoolWeight: 'Pesquisar peso do carretel...',\n    weightUsed: 'Usado',\n    currentWeight: 'Peso Restante',\n    measuredWeight: 'Peso Medido',\n    spoolName: 'Bobina',\n    costPerKg: 'Custo por kg',\n    measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.',\n    slicerFilament: 'Filamento do Fatiador',\n    slicerFilamentName: 'Nome do Predefinido do Fatiador',\n    slicerPreset: 'Predefinido do Fatiador',\n    searchPresets: 'Pesquisar predefinições de filamento...',\n    selectedPreset: 'Selecionado',\n    noPresetsFound: 'Nenhuma predefinição encontrada',\n    tempOverrides: 'Substituições de Temperatura',\n    note: 'Nota',\n    notePlaceholder: 'Quaisquer notas adicionais sobre este spool...',\n    archive: 'Arquivar',\n    restore: 'Restaurar',\n    noSpools: 'Nenhum carretel ainda. Adicione seu primeiro carretel para começar.',\n    noManualSpools: 'Nenhum carretel adicionado manualmente disponível. Adicione um carretel ao seu inventário primeiro.',\n    kProfiles: 'K-Perfis',\n    addKProfile: 'Adicionar K-Perfil',\n    assignSpool: 'Atribuir Carretel',\n    unassignSpool: 'Desatribuir',\n    assignSuccess: 'Carretel atribuído e slot AMS configurado',\n    assignFailed: 'Falha ao atribuir carretel',\n    selectSpool: 'Selecione um carretel para atribuir a este slot',\n    assigned: 'Atribuído',\n    assigning: 'Atribuindo...',\n    searchSpools: 'Pesquisar carretéis...',\n    showAllSpools: 'Mostrar todos os carretéis',\n    allMaterials: 'Todos os Materiais',\n    filterByBrand: 'Filtrar por marca...',\n    showArchived: 'Mostrar arquivados',\n    quickAdd: 'Adição rápida (Estoque)',\n    quantity: 'Quantidade',\n    stock: 'Estoque',\n    configured: 'Configurado',\n    spoolsCreated: '{{count}} carretéis criados',\n    spoolCreated: 'Carretel criado',\n    spoolUpdated: 'Carretel atualizado',\n    spoolDeleted: 'Carretel excluído',\n    spoolArchived: 'Carretel arquivado',\n    spoolRestored: 'Carretel restaurado',\n    deleteConfirm: 'Tem certeza de que deseja excluir este carretel? Esta ação não pode ser desfeita.',\n    archiveConfirm: 'Tem certeza de que deseja arquivar este carretel?',\n    advancedSettings: 'Configurações Avançadas',\n    // Tabs\n    filamentInfoTab: 'Informações do Filamento',\n    paProfileTab: 'Perfil PA',\n    filamentInfo: 'Filamento',\n    additional: 'Adicional',\n    // Cloud\n    loadingPresets: 'Carregando predefinições da nuvem...',\n    cloudConnected: 'Nuvem conectada',\n    cloudNotConnected: 'Nuvem não conectada (usando padrões)',\n    // Colors\n    recentColors: 'Recentes',\n    searchColors: 'Pesquisar cores...',\n    searchResults: 'Resultados da pesquisa',\n    allColors: 'Todas as cores',\n    commonColors: 'Cores comuns',\n    showLess: 'Mostrar menos',\n    showAll: 'Mostrar tudo',\n    noColorsFound: 'Nenhuma cor corresponde à sua pesquisa',\n    noResults: 'Nenhum resultado encontrado',\n    // PA Profiles\n    selectMaterialFirst: 'Por favor, selecione um material primeiro na aba Informações do Filamento.',\n    noPrintersConfigured: 'Nenhuma impressora configurada. Adicione impressoras para usar perfis PA.',\n    matchingFilter: 'Correspondente',\n    anyBrand: 'Qualquer marca',\n    anyVariant: 'Qualquer variante',\n    autoSelect: 'Seleção automática',\n    matches: 'correspondências',\n    match: 'correspondência',\n    noMatches: 'Nenhuma correspondência',\n    connected: 'Conectado',\n    offline: 'Offline',\n    printerOffline: 'A impressora está offline. Conecte-se para visualizar os perfis de calibração.',\n    noKProfilesMatch: 'Nenhum K-perfil corresponde ao filamento selecionado.',\n    leftNozzle: 'Bico Esquerdo',\n    rightNozzle: 'Bico Direito',\n    profilesSelected: 'perfil(is) de calibração selecionado(s)',\n    // Stats & enhanced table\n    totalInventory: 'Inventário Total',\n    totalConsumed: 'Total Consumido',\n    byMaterial: 'Por Material',\n    inPrinter: 'Na Impressora',\n    lowStock: 'Estoque Baixo',\n    sinceTracking: 'Desde o início do rastreamento',\n    loadedInAms: 'Carregado no AMS/Ext',\n    remaining: 'Restante',\n    weightCheck: 'Verificação de Peso',\n    lastWeighed: 'Última pesagem',\n    neverWeighed: 'Nunca pesado',\n    search: 'Pesquisar carretéis...',\n    showing: 'Mostrando',\n    to: 'até',\n    of: 'de',\n    show: 'Mostrar',\n    spools: 'carretéis',\n    spool: 'carretel',\n    page: 'Página',\n    noSpoolsMatch: 'Nenhum resultado encontrado',\n    noSpoolsMatchDesc: 'Tente ajustar sua pesquisa ou filtros para encontrar o que você está procurando.',\n    active: 'Ativo',\n    archived: 'Arquivado',\n    all: 'Todos',\n    used: 'Usado',\n    new: 'Novo',\n    clearFilters: 'Limpar filtros',\n    table: 'Tabela',\n    cards: 'Cartões',\n    net: 'Líquido',\n    // Grouping\n    groupSimilar: 'Agrupar',\n    groupedSpools: '{{count}} carretéis idênticos',\n    groupedRows: 'linhas',\n    // Column config\n    columns: 'Colunas',\n    configureColumns: 'Configurar Colunas',\n    configureColumnsDesc: 'Arraste para reordenar as colunas ou use as setas. Alterne a visibilidade com o ícone de olho.',\n    visible: 'Visível',\n    reset: 'Redefinir',\n    cancel: 'Cancelar',\n    applyChanges: 'Aplicar Alterações',\n    moveUp: 'Mover para cima',\n    moveDown: 'Mover para baixo',\n    hideColumn: 'Ocultar coluna',\n    showColumn: 'Mostrar coluna',\n    // Tag linking\n    linkToSpool: 'Vincular ao Carretel',\n    tagLinked: 'Tag vinculada ao carretel',\n    tagLinkFailed: 'Falha ao vincular tag',\n    tagAlreadyLinked: 'Tag já vinculada a outro carretel',\n    unknownTag: 'Tag RFID desconhecida detectada',\n    // Usage history\n    usageHistory: 'Histórico de Uso',\n    noUsageHistory: 'Nenhum uso registrado ainda',\n    printName: 'Nome da Impressão',\n    weightConsumed: 'Peso Consumido',\n    clearHistory: 'Limpar',\n    historyCleared: 'Histórico de uso limpo',\n    fillSourceLabel: '(Inv)',\n    lowStockThresholdError: 'O limite deve estar entre 0.1 e 99.9',\n    assignMismatchTitle: 'Incompatibilidade de material',\n    assignMismatchMessage: 'O material do carretel selecionado \"{{spoolMaterial}}\" não corresponde ao material da bandeja \"{{trayMaterial}}\" para {{location}}. Atribuir mesmo assim?',\n    assignMismatchConfirm: 'Atribuir mesmo assim',\n    assignPartialMismatchMessage: 'O material do carretel \"{{spoolMaterial}}\" é semelhante, mas não corresponde exatamente a \"{{trayMaterial}}\" em {{location}}. Deseja prosseguir?',\n    assignProfileMismatchMessage: 'O perfil do carretel \"{{spoolProfile}}\" não corresponde ao perfil da bandeja \"{{trayProfile}}\" em {{location}}. Deseja prosseguir?',\n  },\n\n  // Timelapse\n  timelapse: {\n    title: 'Timelapse',\n    create: 'Criar Timelapse',\n    download: 'Baixar',\n    delete: 'Excluir',\n    preview: 'Visualizar',\n    frameRate: 'Taxa de Quadros',\n    quality: 'Qualidade',\n    processing: 'Processando...',\n    noTimelapses: 'Nenhum timelapse disponível',\n  },\n\n  // AMS\n  ams: {\n    title: 'AMS',\n    slot: 'Slot',\n    empty: 'Vazio',\n    emptySlot: 'Slot vazio',\n    unknown: 'Desconhecido',\n    humidity: 'Umidade',\n    temperature: 'Temperatura',\n    filamentType: 'Tipo de Filamento',\n    filamentColor: 'Cor',\n    remaining: 'Restante',\n    history: 'Histórico do AMS',\n    noHistory: 'Nenhum histórico disponível',\n    configureSlot: 'Configurar Slot',\n    externalSpool: 'Carretel Externo',\n    profile: 'Perfil',\n    kFactor: 'Fator K',\n    fill: 'Preencher',\n    configure: 'Configurar',\n    used: 'usado',\n    remainingUnit: 'restante',\n  },\n\n  // Print modal\n  printModal: {\n    title: 'Iniciar Impressão',\n    selectPrinter: 'Selecionar Impressora',\n    selectPlate: 'Selecionar Placa',\n    filamentMapping: 'Mapeamento de Filamento',\n    totalCost: 'Custo total:',\n    slotRemainingShort: ' - {{grams}}g rest.',\n    printSettings: 'Configurações de Impressão',\n    bedLeveling: 'Nivelamento da Mesa',\n    flowCalibration: 'Calibração de Fluxo',\n    vibrationCalibration: 'Calibração de Vibração',\n    layerInspection: 'Inspeção da Primeira Camada',\n    timelapse: 'Timelapse',\n    startPrint: 'Iniciar Impressão',\n    addToQueue: 'Adicionar à Fila',\n    cancel: 'Cancelar',\n    noPrintersAvailable: 'Nenhuma impressora disponível',\n    printerBusy: 'Impressora ocupada',\n    printerOffline: 'Impressora offline',\n    sameTypeDifferentColor: 'Mesmo tipo, cor diferente',\n    filamentTypeNotLoaded: 'Tipo de filamento não carregado',\n    openCalendar: 'Abrir calendário',\n    leftNozzle: 'L',\n    rightNozzle: 'R',\n    leftNozzleTooltip: 'Bico esquerdo',\n    rightNozzleTooltip: 'Bico direito',\n    filamentOverride: 'Substituição de Filamento',\n    filamentOverrideHint: 'Substitua opcionalmente os filamentos para atribuição baseada em modelo. O agendador usará os filamentos selecionados em vez dos valores originais do 3MF.',\n    originalFilament: 'Original',\n    overrideWith: 'Substituir por',\n    resetToOriginal: 'Restaurar original',\n    insufficientFilamentTitle: 'Filamento insuficiente',\n    insufficientFilamentMessage: 'Alguns dos carretéis atribuídos têm menos filamento restante do que o necessário para esta impressão:',\n    insufficientFilamentLine: '{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g',\n    printAnyway: 'Imprimir mesmo assim',\n    forceColorMatch: 'Forçar correspondência de cor',\n    staggerPrinterStarts: 'Stagger printer starts',\n    staggerGroupSize: 'Group size',\n    staggerInterval: 'Interval (min)',\n    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',\n    staggerLastGroup: 'last group: {{count}}',\n    staggerTotal: 'total: {{minutes}} min',\n    staggerToPrinters: 'Escalonar para {{count}} impressoras',\n    gcodeInjection: 'Injetar G-code de auto-impressão',\n  },\n\n  // Backup\n  backup: {\n    title: 'Bakup e Restauração',\n    createBackup: 'Criar Backup',\n    restoreBackup: 'Restaurar Backup',\n    restoreDescription: 'Substituir todos os dados a partir de um arquivo de backup',\n    downloadBackup: 'Baixar Backup',\n    uploadBackup: 'Enviar Backup',\n    lastBackup: 'Último Backup',\n    autoBackup: 'Auto Backup',\n    backupNow: 'Fazer Backup Agora',\n    restoreWarning: 'Aviso: Restaurar um backup substituirá todos os dados atuais.',\n    includeArchives: 'Incluir Arquivos',\n    includeSettings: 'Incluir Configurações',\n    includeProfiles: 'Incluir Perfis',\n    backupSuccess: 'Backup criado com sucesso',\n    restoreSuccess: 'Backup restaurado com sucesso',\n    backupFailed: 'Falha ao criar backup',\n    restoreFailed: 'Falha ao restaurar backup',\n    restoreNote: 'A impressora virtual será parada durante a restauração',\n\n    // GitHub Backup\n    githubBackup: 'Backup GitHub',\n    enabled: 'Ativado',\n    cloudLoginRequired: 'Login no Bambu Cloud necessário. Entre em Perfis → Perfis Cloud para ativar o backup GitHub.',\n    cloudLoginRequiredShort: 'Login Cloud necessário',\n    githubDescription: 'Sincronize automaticamente seus perfis com um repositório GitHub privado para backup e histórico de versões.',\n    repositoryUrl: 'URL do repositório',\n    personalAccessToken: 'Token de acesso pessoal',\n    tokenSaved: '(salvo)',\n    enterNewToken: 'Digite um novo token para atualizar',\n    tokenHint: 'Token de granularidade fina com permissão de leitura/escrita de conteúdo',\n    branch: 'Branch',\n    manualOnly: 'Apenas manual',\n    hourly: 'A cada hora',\n    daily: 'Diário',\n    weekly: 'Semanal',\n    includeInBackup: 'Incluir no backup',\n    kProfiles: 'K-Perfis',\n    kProfilesDescription: 'Calibração de avanço de pressão das impressoras conectadas',\n    noPrintersConnected: 'Nenhuma impressora conectada',\n    printersConnected: '{{connected}}/{{total}} conectadas',\n    cloudProfiles: 'Perfis Cloud',\n    cloudProfilesDescription: 'Predefinições de filamento, impressora e processo do Bambu Cloud',\n    appSettings: 'Configurações do App',\n    appSettingsDescription: 'Configuração do Bambuddy (banco de dados completo)',\n    spoolInventory: 'Inventário de bobinas',\n    spoolInventoryDescription: 'Bobinas de filamento, histórico de uso e rastreamento de custos',\n    printArchives: 'Arquivos de impressão',\n    printArchivesDescription: 'Metadados do histórico de impressão (sem arquivos gcode/3MF)',\n    lastBackupAt: 'Último backup:',\n    noBackupsYet: 'Nenhum backup ainda',\n    next: 'Próximo:',\n    startingBackup: 'Iniciando backup...',\n    test: 'Testar',\n    enableBackup: 'Ativar backup',\n    testConnection: 'Testar conexão',\n    enterRepoUrl: 'Digite a URL do repositório',\n    enterRepoAndToken: 'Digite a URL do repositório e o token de acesso',\n    repoRequired: 'A URL do repositório é obrigatória',\n    tokenRequired: 'O token de acesso é obrigatório',\n    githubBackupEnabled: 'Backup GitHub ativado',\n    tokenUpdated: 'Token atualizado',\n    settingsSaved: 'Configurações salvas',\n    failedToSave: 'Falha ao salvar: {{message}}',\n    backupCompleteFiles: 'Backup concluído - {{count}} arquivos atualizados',\n    backupSkippedNoChanges: 'Backup ignorado - sem alterações',\n    backupFailed2: 'Falha no backup: {{message}}',\n    clearedLogs: '{{count}} logs removidos',\n    failedToClearLogs: 'Falha ao limpar logs: {{message}}',\n\n    // History\n    history: 'Histórico',\n    clear: 'Limpar',\n    date: 'Data',\n    status: 'Status',\n    commit: 'Commit',\n\n    // Local Backup\n    localBackup: 'Backup local',\n    localBackupDescription: 'Crie um backup completo dos seus dados do Bambuddy incluindo banco de dados, arquivos, uploads e todos os ficheiros.',\n    downloadBackupLabel: 'Baixar backup',\n    completeBackupZip: 'Backup completo: banco de dados + todos os arquivos (ZIP)',\n    download: 'Baixar',\n    preparingBackup: 'Preparando backup...',\n    creatingArchive: 'Criando arquivo de backup... Isso pode demorar para backups grandes.',\n    downloadingFile: 'Baixando arquivo de backup...',\n    backupDownloaded: 'Backup baixado com sucesso',\n    failedToCreateBackup: 'Falha ao criar backup: {{message}}',\n    restore: 'Restaurar',\n    restoreReplacesAll: 'A restauração substitui todos os dados.',\n    restoreReplacesAllDetail: 'Seu banco de dados e arquivos atuais serão completamente substituídos. É necessário reiniciar após a restauração.',\n    restoreConfirmTitle: 'Restaurar backup',\n    restoreConfirmMessage: 'Tem certeza de que deseja restaurar de \"{{filename}}\"? Isso substituirá completamente seu banco de dados e todos os arquivos. O aplicativo precisará ser reiniciado após a restauração.',\n    restoreConfirmButton: 'Restaurar backup',\n    uploadingFile: 'Enviando arquivo de backup...',\n    backupRestoredRestart: 'Backup restaurado. Por favor, reinicie o Bambuddy.',\n    failedToRestore: 'Falha ao restaurar backup. Verifique o formato do arquivo.',\n    reloadNow: 'Recarregar agora',\n    creatingBackup: 'Criando backup',\n    restoringBackup: 'Restaurando backup',\n    preparing: 'Preparando...',\n    processing: 'Processando...',\n    doNotClosePage: 'Por favor, não feche esta página nem navegue para outro lugar. Esta operação pode levar vários minutos para backups grandes.',\n\n    // RestoreModal\n    restoring: 'Restaurando...',\n    restoreComplete: 'Restauração concluída',\n    restoreFailed2: 'Falha na restauração',\n    importSettings: 'Importar configurações de um arquivo de backup',\n    pleaseWaitRestoring: 'Aguarde enquanto seus dados estão sendo restaurados',\n    selectBackupFile: 'Clique para selecionar um arquivo de backup (.json ou .zip)',\n    duplicateHandling: 'Como funciona o tratamento de duplicatas:',\n    matchPrinters: 'Impressoras',\n    matchPrintersBy: 'correspondência por número de série',\n    matchSmartPlugs: 'Smart Plugs',\n    matchSmartPlugsBy: 'correspondência por endereço IP',\n    matchNotificationProviders: 'Provedores de notificação',\n    matchNotificationProvidersBy: 'correspondência por nome',\n    matchFilaments: 'Filamentos',\n    matchFilamentsBy: 'correspondência por nome + tipo + marca',\n    matchArchives: 'Arquivos',\n    matchArchivesBy: 'correspondência por hash de conteúdo (sempre ignorado)',\n    matchPendingUploads: 'Uploads pendentes',\n    matchPendingUploadsBy: 'correspondência por nome do arquivo',\n    matchSettingsTemplates: 'Configurações e modelos',\n    matchSettingsTemplatesBy: 'sempre sobrescritos',\n    replaceExisting: 'Substituir dados existentes',\n    keepExisting: 'Manter dados existentes',\n    overwriteDescription: 'Sobrescrever itens que já existem com dados do backup',\n    keepDescription: 'Restaurar apenas itens que ainda não existem',\n    overwriteCaution: 'Cuidado:',\n    overwriteWarning: 'A sobrescrita substituirá suas configurações atuais pelos dados do backup. Códigos de acesso das impressoras nunca são sobrescritos por segurança.',\n    cancel: 'Cancelar',\n    processingBackup: 'Processando arquivo de backup...',\n    itemsRestored: 'Itens restaurados',\n    itemsSkipped: 'Itens ignorados',\n    restored: 'Restaurados',\n    skippedAlreadyExist: 'Ignorados (já existem)',\n    filesCategory: 'Arquivos (3MF, miniaturas, etc.)',\n    andMore: '...e mais {{count}}',\n    newApiKeysGenerated: 'Novas chaves API geradas',\n    keysShownOnce: 'Estas chaves são exibidas apenas uma vez. Copie-as agora!',\n    copy: 'Copiar',\n    noDataFound: 'Nenhum dado para restaurar foi encontrado no arquivo de backup.',\n    close: 'Fechar',\n\n    // Scheduled local backups (#884)\n    scheduledBackup: 'Scheduled Backups',\n    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',\n    frequency: 'Frequency',\n    backupTime: 'Time',\n    retention: 'Retention',\n    retentionDescription: 'Number of backups to keep',\n    outputPath: 'Output Path',\n    outputPathPlaceholder: 'Default: {{path}}',\n    outputPathDescription: 'Leave empty for default location',\n    runNow: 'Run Now',\n    backupFiles: 'Backup Files',\n    noScheduledBackups: 'No backups yet',\n    deleteBackup: 'Delete',\n    deleteBackupConfirm: 'Delete this backup file?',\n    backupRunning: 'Backup in progress...',\n    scheduledBackupComplete: 'Backup completed successfully',\n    scheduledBackupFailed: 'Backup failed',\n    nextBackup: 'Next backup',\n    backupSize: 'Size',\n    utc: 'UTC',\n    defaultPathLabel: 'Default:',\n\n    // Category labels\n    categories: {\n      settings: 'Configurações',\n      notification_providers: 'Provedores de notificação',\n      notification_templates: 'Modelos de notificação',\n      smart_plugs: 'Smart Plugs',\n      printers: 'Impressoras',\n      filaments: 'Filamentos',\n      maintenance_types: 'Tipos de manutenção',\n      archives: 'Arquivos',\n      projects: 'Projetos',\n      pending_uploads: 'Uploads pendentes',\n      external_links: 'Links externos',\n      api_keys: 'Chaves API',\n    },\n  },\n\n  // Tags\n  tags: {\n    title: 'Tags',\n    addTag: 'Adicionar Tag',\n    editTag: 'Editar Tag',\n    deleteTag: 'Excluir Tag',\n    tagName: 'Nome da Tag',\n    tagColor: 'Cor da Tag',\n    noTags: 'Nenhuma tag',\n    deleteConfirm: 'Tem certeza de que deseja excluir esta tag?',\n    manageTags: 'Gerenciar Tags',\n  },\n\n  // Upload modal (archives)\n  uploadModal: {\n    title: 'Upload Arquivos 3MF',\n    dragDrop: 'Arraste e solte arquivos .3mf aqui',\n    or: 'ou',\n    browseFiles: 'Procurar Arquivos',\n    extractionInfo: 'O modelo da impressora será extraído automaticamente dos metadados do arquivo 3MF.',\n    uploaded: 'enviado',\n    failed: 'falhou',\n    uploading: 'Enviando...',\n    upload: 'Enviar',\n    uploadFailed: 'Falha no envio',\n  },\n\n  // Edit archive modal\n  // Edit Archive Modal\n  editArchive: {\n    title: 'Editar Arquivo',\n    name: 'Nome',\n    namePlaceholder: 'Nome da impressão',\n    printer: 'Impressora',\n    noPrinter: 'Nenhuma impressora',\n    project: 'Projeto',\n    noProject: 'Nenhum projeto',\n    itemsPrinted: 'Itens Impressos',\n    itemsPrintedHelp: 'Número de itens produzidos neste trabalho de impressão',\n    notes: 'Notas',\n    notesPlaceholder: 'Adicione notas sobre esta impressão...',\n    externalLink: 'Link Externo',\n    externalLinkPlaceholder: 'https://printables.com/model/...',\n    externalLinkHelp: 'Link para Printables, Thingiverse ou outra fonte',\n    tags: 'Tags',\n    tagsPlaceholder: 'Adicionar tags...',\n    addMoreTags: 'Adicionar mais tags...',\n    matchingTags: 'Correspondendo \"{{query}}\"',\n    existingTags: 'Tags existentes',\n    clickToAdd: '(clique para adicionar)',\n    status: 'Status',\n    failureReason: 'Motivo da Falha',\n    selectReason: 'Selecione o motivo...',\n    photos: 'Fotos do Resultado da Impressão',\n    photosHelp: 'Clique em + para adicionar fotos do seu resultado impresso',\n    printResult: 'Resultado da Impressão',\n    saving: 'Salvando...',\n    // Failure reasons\n    failureReasons: {\n      adhesionFailure: 'Falha de adesão',\n      spaghettiDetached: 'Spaghetti / Destacado',\n      layerShift: 'Deslocamento de camada',\n      cloggedNozzle: 'Bico entupido',\n      filamentRunout: 'Fim do filamento',\n      warping: 'Warping',\n      stringing: 'Stringing',\n      underExtrusion: 'Under-extrusion',\n      powerFailure: 'Falha de energia',\n      userCancelled: 'Cancelado pelo usuário',\n      other: 'Outro',\n    },\n    // Archive statuses\n    statuses: {\n      completed: 'Concluído',\n      failed: 'Falhou',\n      aborted: 'Cancelado',\n      printing: 'Imprimindo',\n    },\n  },\n\n  // K-Profiles\n  kProfiles: {\n    title: 'K-Profiles',\n    noPrintersConfigured: 'Nenhuma impressora configurada',\n    addPrinterInSettings: 'Adicione uma impressora nas Configurações para gerenciar K-profiles',\n    noActivePrinters: 'Nenhuma impressora ativa',\n    enablePrinterConnection: 'Ative a conexão da impressora para visualizar seus K-profiles',\n    loadingProfiles: 'Carregando K-Profiles...',\n    printerOffline: 'Impressora Offline',\n    printerOfflineDesc: 'A impressora selecionada não está conectada. Ligue-a para visualizar os K-profiles.',\n    noMatchingProfiles: 'Nenhum Perfil Correspondente',\n    noMatchingProfilesDesc: 'Nenhum perfil corresponde aos seus critérios de pesquisa',\n    noKProfiles: 'Nenhum K-Profile',\n    noKProfilesDesc: 'Nenhum perfil de avanço de pressão encontrado para bico de {{diameter}}mm',\n    createFirstProfile: 'Criar Primeiro Perfil',\n    // Controls\n    printer: 'Impressora',\n    nozzle: 'Bico',\n    refresh: 'Atualizar',\n    addProfile: 'Adicionar Perfil',\n    export: 'Exportar',\n    import: 'Importar',\n    select: 'Selecionar',\n    selectAll: 'Selecionar Todos',\n    delete: 'Excluir',\n    // Filters\n    searchPlaceholder: 'Pesquisar por nome ou filamento...',\n    allExtruders: 'Todos os Extrusores',\n    leftOnly: 'Apenas Esquerdo',\n    rightOnly: 'Apenas Direito',\n    allFlow: 'Todo Fluxo',\n    hfOnly: 'Apenas HF',\n    sOnly: 'Apenas S',\n    sortName: 'Ordenar: Nome',\n    sortKValue: 'Ordenar: Valor K',\n    sortFilament: 'Ordenar: Filamento',\n    // Dual extruder labels\n    leftExtruder: 'Extrusor Esquerdo',\n    rightExtruder: 'Extrusor Direito',\n    // Modal\n    modal: {\n      addTitle: 'Adicionar K-Profile',\n      editTitle: 'Editar K-Profile',\n      profileName: 'Nome do Perfil',\n      profileNamePlaceholder: 'Meu Perfil PLA',\n      kValue: 'Valor K',\n      kValuePlaceholder: '0.020',\n      kValueHelp: 'Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG',\n      filament: 'Filamento',\n      selectFilament: 'Selecionar filamento...',\n      noFilamentsHelp: 'Nenhum filamento encontrado. Crie um K-profile no Bambu Studio primeiro.',\n      flowType: 'Tipo de Fluxo',\n      highFlow: 'Alto Fluxo',\n      standard: 'Padrão',\n      nozzleSize: 'Tamanho do Bico',\n      extruder: 'Extrusor',\n      extruders: 'Extrusores',\n      left: 'Esquerdo',\n      right: 'Direito',\n      notes: 'Notas (armazenadas localmente)',\n      notesPlaceholder: 'Adicione notas sobre este perfil...',\n      notesHelp: 'As notas são salvas no Bambuddy, não na impressora',\n      syncing: 'Sincronizando com a impressora...',\n      savingExtruder: 'Salvando no extrusor {{current}}/{{total}}...',\n      pleaseWait: 'Por favor, aguarde',\n    },\n    // Delete confirmation\n    deleteConfirm: {\n      title: 'Excluir Perfil',\n      cannotUndo: 'Isso não pode ser desfeito',\n      message: 'Tem certeza de que deseja excluir \"{{name}}\" da impressora?',\n    },\n    // Bulk delete\n    bulkDelete: {\n      title: 'Excluir Perfis',\n      cannotUndo: 'Isso não pode ser desfeito',\n      message: 'Tem certeza de que deseja excluir {{count}} perfis selecionados da impressora?',\n    },\n    // Toast\n    toast: {\n      profileSaved: 'K-profile salvo',\n      profilesSaved: 'K-profile salvo em {{count}} extrusores',\n      selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',\n      profileDeleted: 'K-profile excluído',\n      profilesDeleted: '{{count}} perfis excluídos',\n      exportedProfiles: '{{count}} perfis exportados',\n      importedProfiles: '{{count}} de {{total}} perfis importados',\n      noProfilesToExport: 'Nenhum perfil para exportar',\n      invalidFileFormat: 'Formato de arquivo inválido',\n      failedToParseImport: 'Falha ao analisar o arquivo de importação',\n      failedToSaveBatch: 'Falha ao salvar K-profiles',\n      noteSaved: 'Nota salva',\n      failedToSaveNote: 'Falha ao salvar nota',\n    },\n    // Permissions\n    permission: {\n      noRead: 'Você não tem permissão para atualizar perfis',\n      noCreate: 'Você não tem permissão para adicionar perfis',\n      noUpdate: 'Você não tem permissão para atualizar K-profiles',\n      noDelete: 'Você não tem permissão para excluir K-profiles',\n      noExport: 'Você não tem permissão para exportar perfis',\n      noImport: 'Você não tem permissão para importar perfis',\n    },\n  },\n\n  // Virtual Printer\n  virtualPrinter: {\n    title: 'Impressora Virtual',\n    running: 'Em execução',\n    stopped: 'Parada',\n    description: {\n      default: 'Ative uma impressora virtual que aparece no Bambu Studio e no OrcaSlicer. Os arquivos enviados para esta impressora serão arquivados diretamente sem impressão.',\n      proxy: 'Ative um proxy que retransmite o tráfego do slicer para uma impressora real, permitindo impressão remota em qualquer rede.',\n    },\n    enable: {\n      title: 'Ativar Impressora Virtual',\n      visibleInSlicer: 'Visível como \"Bambuddy\" na descoberta do slicer',\n      proxyingTo: 'Proxy para {{name}}',\n      notActive: 'Não ativo',\n    },\n    model: {\n      title: 'Modelo da Impressora',\n      description: 'Selecione qual modelo de impressora emular.',\n      restartWarning: 'Alterar o modelo reiniciará a impressora virtual',\n    },\n    accessCode: {\n      title: 'Código de acesso',\n      isSet: 'O código de acesso está definido',\n      notSet: 'Nenhum código de acesso definido — necessário para ativar.',\n      placeholder: 'Digite um código de 8 caracteres',\n      placeholderChange: 'Digite um novo código para alterar',\n      hint: 'Deve ter exatamente 8 caracteres. Usado pelos slicers para autenticação.',\n      charCount: '({{count}}/8)',\n    },\n    targetPrinter: {\n      title: 'Impressora Alvo',\n      configured: 'Proxy alvo configurado',\n      notConfigured: 'Nenhuma impressora alvo selecionada - necessário para o modo proxy',\n      placeholder: 'Selecione uma impressora...',\n      hint: 'Selecione a impressora para a qual o tráfego do slicer será enviado. A impressora deve estar no modo LAN.',\n      noPrinters: 'Nenhuma impressora configurada. Adicione uma impressora primeiro para usar o modo proxy.',\n    },\n    remoteInterface: {\n      title: 'Substituição da Interface de Rede',\n      configured: 'Substituição da interface ativa',\n      optional: 'Opcional - use se o IP detectado automaticamente estiver errado (por exemplo, várias NICs, Docker, VPN)',\n      placeholder: 'Detecção automática (padrão)...',\n      hint: 'Substitua o endereço IP anunciado via SSDP e usado no certificado TLS. Útil quando o Bambuddy possui várias interfaces de rede.',\n    },\n    mode: {\n      title: 'Modo',\n      archive: 'Arquivar',\n      archiveDesc: 'Arquivar arquivos imediatamente',\n      review: 'Revisar',\n      reviewDesc: 'Revisar antes de arquivar',\n      queue: 'Fila',\n      queueDesc: 'Arquivar e adicionar à fila',\n      proxy: 'Proxy',\n      proxyDesc: 'Retransmitir para impressora real',\n    },\n    autoDispatch: {\n      title: 'Envio automático',\n      description: 'Iniciar impressões automaticamente quando adicionadas à fila. Quando desativado, as impressões aguardam envio manual.',\n    },\n    setupRequired: {\n      title: 'Configuração Necessária',\n      description: 'O recurso de impressora virtual requer configuração adicional do sistema antes de funcionar. Isso inclui encaminhamento de portas, regras de firewall e configurações específicas da plataforma.',\n      readGuide: 'Leia o guia de configuração antes de ativar',\n    },\n    howItWorks: {\n      title: 'Como funciona',\n      step1: 'Complete o guia de configuração para sua plataforma',\n      step2: 'Ative a impressora virtual e defina um código de acesso',\n      step3: 'No Bambu Studio ou OrcaSlicer, vá para \"Adicionar Impressora\"',\n    },\n    status: {\n      title: 'Detalhes do Status',\n      printerName: 'Nome da Impressora',\n      model: 'Modelo',\n      serialNumber: 'Número de Série',\n      mode: 'Modo',\n      pendingFiles: 'Arquivos Pendentes',\n      targetPrinter: 'Impressora Alvo',\n      ftpPort: 'Porta FTP',\n      mqttPort: 'Porta MQTT',\n      ftpConnections: 'Conexões FTP',\n      mqttConnections: 'Conexões MQTT',\n    },\n    toast: {\n      updated: 'Configurações da impressora virtual atualizadas',\n      failedToUpdate: 'Falha ao atualizar as configurações',\n      accessCodeRequired: 'Defina um código de acesso primeiro',\n      targetPrinterRequired: 'Selecione uma impressora alvo primeiro',\n      bindIpRequired: 'Defina um IP de ligação primeiro',\n      accessCodeEmpty: 'O código de acesso não pode estar vazio',\n      accessCodeLength: 'O código de acesso deve ter exatamente 8 caracteres',\n      created: 'Impressora virtual criada',\n      failedToCreate: 'Falha ao criar impressora virtual',\n      deleted: 'Impressora virtual excluída',\n      failedToDelete: 'Falha ao excluir impressora virtual',\n    },\n    list: {\n      title: 'Impressoras Virtuais',\n      add: 'Adicionar',\n      addFirst: 'Adicionar Impressora Virtual',\n      empty: 'Nenhuma impressora virtual configurada. Adicione uma para começar.',\n    },\n    bindIp: {\n      title: 'Interface de Rede',\n      placeholder: 'Selecionar interface...',\n      hint: 'Interface de rede para esta impressora virtual. Deve ser única por impressora.',\n    },\n    proxy: {\n      accessCodeHint: 'No modo proxy, use o código de acesso da impressora alvo no slicer. A conexão é encaminhada de forma transparente para a impressora real.',\n    },\n    addDialog: {\n      title: 'Adicionar Impressora Virtual',\n      name: 'Nome',\n      hint: 'Você pode configurar o código de acesso, impressora alvo e outras configurações após a criação.',\n      create: 'Criar',\n    },\n    deleteConfirm: {\n      title: 'Excluir Impressora Virtual',\n      message: 'Tem certeza que deseja excluir \"{{name}}\"? Isso irá parar todos os serviços desta impressora.',\n    },\n  },\n\n  // Model Viewer\n  modelViewer: {\n    openInSlicer: 'Abrir no Slicer',\n    tabs: {\n      model: 'Modelo 3D',\n      gcode: 'Pré-visualização G-code',\n    },\n    notAvailable: 'Não disponível',\n    notSliced: 'Não fatiado',\n    plates: 'Placas',\n    allPlates: 'Todas as Placas',\n    plateNumber: 'Placa {{number}}',\n    plateCount: '{{count}} placa',\n    plateCount_other: '{{count}} placas',\n    objectCount: '{{count}} objeto',\n    objectCount_other: '{{count}} objetos',\n    filamentCount: '{{count}} filamento',\n    filamentCount_other: '{{count}} filamentos',\n    eta: 'ETA {{minutes}} min',\n    noPreview: 'Pré-visualização não disponível para este arquivo',\n    pagination: {\n      pageOf: 'Página {{current}} de {{total}}',\n      prev: 'Anterior',\n      next: 'Próximo',\n    },\n    errors: {\n      failedToLoad: 'Falha ao carregar o arquivo',\n      noMeshes: 'Nenhuma malha encontrada no arquivo 3MF',\n      unsupportedFormat: 'Formato de arquivo não suportado',\n    },\n  },\n\n  // Maintenance type descriptions (built-in)\n  maintenanceDescriptions: {\n    lubricateCarbonRods: 'Aplique lubrificante nos eixos de carbono para um movimento suave',\n    lubricateRails: 'Aplique lubrificante nos trilhos lineares para um movimento suave',\n    cleanNozzle: 'Limpe o hotend e o bico para evitar entupimentos',\n    checkBelts: 'Verifique a tensão das correias para impressões precisas',\n    cleanBuildPlate: 'Limpe a placa de construção para melhor adesão',\n    checkExtruder: 'Verifique as engrenagens do extrusor quanto ao desgaste',\n    checkCooling: 'Verifique se os ventiladores de resfriamento estão funcionando corretamente',\n    generalInspection: 'Inspeção geral da impressora',\n    cleanCarbonRods: 'Limpe os eixos de carbono para reduzir o atrito',\n    lubricateSteelRods: 'Aplique lubrificante nas barras de aço para um movimento suave',\n    cleanSteelRods: 'Limpe as barras de aço para reduzir o atrito',\n    cleanLinearRails: 'Limpe os trilhos lineares para remover poeira e detritos',\n    checkPtfeTube: 'Verifique o tubo PTFE quanto ao desgaste ou danos',\n    replaceHepaFilter: 'Substitua o filtro HEPA para qualidade do ar',\n    replaceCarbonFilter: 'Substitua o filtro de carbono ativado',\n    lubricateLeftNozzleRail: 'Lubrifique o trilho do bico esquerdo (série H2)',\n  },\n\n  // Smart Plugs\n  smartPlugs: {\n    offline: 'Offline',\n    admin: 'Admin',\n    openPlugAdminPage: 'Abrir o painel de administração da tomada inteligente',\n    deleteSmartPlug: 'Excluir Tomada Inteligente',\n    turnOnSmartPlug: 'Ligar Tomada Inteligente',\n    turnOffSmartPlug: 'Desligar Tomada Inteligente',\n    turnOn: 'Ligar',\n    turnOff: 'Desligar',\n    addSmartPlug: {\n      scanningNetwork: 'Procurando na rede...',\n      chooseEntity: 'Escolha uma entidade...',\n      connectionFailed: 'Falha na conexão',\n      searchEntities: 'Pesquisar entidades...',\n      searchPowerSensors: 'Pesquisar sensores de energia...',\n      searchEnergySensors: 'Pesquisar sensores de energia...',\n      placeholders: {\n        plugName: 'Tomada da Sala',\n        mqttStateOnValue: 'ON, true, 1',\n        mqttSameAsPower: 'Mesmo que o tópico de energia, ou diferente',\n      },\n    },\n    // SmartPlugCard\n    linkedTo: 'Vinculado a:',\n    monitorOnly: 'Apenas monitoramento',\n    alerts: 'Alertas',\n    scheduleOn: 'Ligar {{time}}',\n    scheduleOff: 'Desligar {{time}}',\n    on: 'Ligado',\n    off: 'Desligado',\n    power: 'Potência',\n    kwhToday: 'kWh Hoje',\n    settings: 'Configurações',\n    automationSettings: 'Configurações de automação',\n    showInSwitchbar: 'Mostrar na barra de interruptores',\n    quickAccessSidebar: 'Acesso rápido pela barra lateral',\n    enabled: 'Ativado',\n    enableAutomation: 'Ativar automação para este plugue',\n    autoOn: 'Auto Ligar',\n    autoOnDescription: 'Ligar quando a impressão iniciar',\n    autoOff: 'Auto Desligar',\n    autoOffDescription: 'Desligar quando a impressão terminar (única vez)',\n    autoOffPersistent: 'Manter ativado',\n    autoOffPersistentDescription: 'Permanecer ativado entre impressões em vez de única vez',\n    turnOffDelayMode: 'Modo de atraso para desligar',\n    time: 'Tempo',\n    temp: 'Temp',\n    delayMinutes: 'Atraso (minutos)',\n    tempThreshold: 'Limite de temperatura (°C)',\n    tempThresholdDescription: 'Desliga quando o bico esfria abaixo desta temperatura',\n    edit: 'Editar',\n    deleteConfirm: 'Tem certeza que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.',\n    turnOnConfirm: 'Tem certeza que deseja ligar \"{{name}}\"?',\n    turnOffConfirm: 'Tem certeza que deseja desligar \"{{name}}\"? Isso cortará a energia do dispositivo conectado.',\n    failedToTurn: 'Falha ao {{action}} \"{{name}}\"',\n    unknown: 'Desconhecido',\n    // AddSmartPlugModal\n    addTitle: 'Adicionar plugue inteligente',\n    editTitle: 'Editar plugue inteligente',\n    stopScanning: 'Parar varredura',\n    discoverTasmota: 'Descobrir dispositivos Tasmota',\n    foundDevices: '{{count}} dispositivo(s) encontrado(s) - clique para selecionar:',\n    noDevicesFound: 'Nenhum dispositivo Tasmota encontrado na sua rede',\n    haNotConfigured: 'Home Assistant não está configurado. Configure em',\n    haSettingsPath: 'Configurações → Rede → Home Assistant',\n    selectEntity: 'Selecionar entidade *',\n    ipAddress: 'Endereço IP *',\n    nameLabel: 'Nome *',\n    username: 'Usuário',\n    password: 'Senha',\n    authHint: 'Deixe vazio se seu dispositivo Tasmota não requer autenticação',\n    linkToPrinter: 'Vincular à impressora',\n    noPrinter: 'Sem impressora (apenas controle manual)',\n    linkingDescription: 'A vinculação permite ligar/desligar automaticamente ao iniciar/terminar impressão',\n    powerAlerts: 'Alertas de potência',\n    alertAbove: 'Alertar se acima (W)',\n    alertBelow: 'Alertar se abaixo (W)',\n    alertDescription: 'Receba notificações quando o consumo de energia ultrapassar estes limites. Deixe vazio para desativar essa direção.',\n    dailySchedule: 'Programação diária',\n    turnOnAt: 'Ligar às',\n    turnOffAt: 'Desligar às',\n    scheduleDescription: 'Ligar/desligar automaticamente o plugue nestes horários diariamente. Deixe vazio para pular essa ação.',\n    showOnPrinterCard: 'Mostrar no cartão da impressora',\n    displayOnPrinterCard: 'Exibir botão no cartão da impressora',\n    connectedResult: 'Conectado!',\n    deviceLabel: 'Dispositivo: {{name}} - ',\n    stateLabel: 'Estado: {{state}}',\n    test: 'Testar',\n    delete: 'Excluir',\n    save: 'Salvar',\n    add: 'Adicionar',\n    cancel: 'Cancelar',\n    failedToStartScan: 'Falha ao iniciar varredura',\n    nameRequired: 'Nome é obrigatório',\n    entityRequired: 'Entidade é obrigatória para plugues Home Assistant',\n    mqttTopicRequired: 'Pelo menos um tópico MQTT deve ser configurado para potência, energia ou monitoramento de estado',\n    loadingEntities: 'Carregando entidades...',\n    loading: 'Carregando...',\n    failedToLoadEntities: 'Falha ao carregar entidades: {{error}}',\n    noEntitiesMatching: 'Nenhuma entidade encontrada correspondente a \"{{search}}\"',\n    noEntitiesAvailable: 'Nenhuma entidade disponível',\n    searchingEntities: 'Buscando todas as entidades ({{count}} encontradas)',\n    showingEntities: 'Mostrando switch, light, input_boolean ({{count}} disponíveis)',\n    energyMonitoringOptional: 'Monitoramento de energia (Opcional)',\n    energyMonitoringHint: 'Pesquise e selecione sensores que fornecem dados de potência/energia.',\n    powerSensorW: 'Sensor de potência (W)',\n    energyTodayKwh: 'Energia hoje (kWh)',\n    totalEnergyKwh: 'Energia total (kWh)',\n    noMatchingSensors: 'Nenhum sensor correspondente',\n    none: 'Nenhum',\n    mqttNotConfigured: 'Broker MQTT não configurado. Defina o endereço do broker em',\n    mqttSettingsPath: 'Configurações → Rede → Publicação MQTT',\n    mqttNotConfiguredSuffix: '(você não precisa ativar a publicação, apenas preencha os detalhes do broker).',\n    mqttMonitorOnlyDescription: 'Plugues MQTT recebem dados de potência/energia via assinatura MQTT. O controle liga/desliga não está disponível - use seu broker MQTT ou sistema de automação residencial.',\n    powerMonitoring: 'Monitoramento de potência',\n    energyMonitoring: 'Monitoramento de energia',\n    stateMonitoring: 'Monitoramento de estado',\n    optional: 'opcional',\n    topic: 'Tópico',\n    jsonPath: 'Caminho JSON',\n    multiplier: 'Multiplicador',\n    onValue: 'Valor ON',\n    mqttPowerHint: 'O caminho JSON extrai o valor do payload JSON (ex: \"power_l1\"). Deixe vazio se o tópico publica valores numéricos brutos.\\nUse multiplicador 0.001 para mW→W, 1000 para kW→W.',\n    mqttEnergyHint: 'O caminho JSON extrai o valor do payload JSON. Deixe vazio para valores brutos.\\nUse multiplicador 0.001 para Wh→kWh, 1000 para MWh→kWh.',\n    mqttStateHint: 'O caminho JSON extrai o valor do payload JSON. Deixe vazio para valores brutos.\\nValor ON: a string exata que significa \"ON\". Deixe vazio para detecção automática (ON, true, 1).',\n    // REST smart plug\n    restControl: 'Control',\n    restOnUrl: 'Turn ON URL',\n    restOffUrl: 'Turn OFF URL',\n    restOnBody: 'ON Request Body',\n    restOffBody: 'OFF Request Body',\n    restMethod: 'HTTP Method',\n    restHeaders: 'Custom Headers (JSON)',\n    restStatusUrl: 'Status URL',\n    restStatusPath: 'State JSON Path',\n    restStatusOnValue: 'ON Value',\n    restPowerUrl: 'URL de potência',\n    restPowerPath: 'Power JSON Path',\n    restPowerMultiplier: 'Multiplicador de potência',\n    restEnergyUrl: 'URL de energia',\n    restEnergyPath: 'Energy JSON Path',\n    restEnergyMultiplier: 'Multiplicador de energia',\n    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',\n    restHeadersHint: 'e.g. {\"Authorization\": \"Bearer your-token\"}',\n    restBodyHint: 'e.g. ON, {\"state\": \"on\"}',\n    restStatusHint: 'URL to poll for current state',\n    restPathHint: 'e.g. state or data.power.status',\n    restPowerUrlHint: 'URL separada para dados de potência (usa a URL de status se vazio)',\n    restEnergyUrlHint: 'URL separada para dados de energia (usa a URL de status se vazio)',\n    restEnergyHint: 'Cada valor pode usar sua própria URL ou recorrer à URL de status. Use multiplicadores para conversão de unidades (ex: 0.001 para converter Wh em kWh).',\n    testConnection: 'Test Connection',\n    connectionSuccess: 'Connection successful',\n    noSwitchesInSwitchbar: 'Nenhum interruptor na barra',\n    enableSwitchbarHint: 'Ative \"Mostrar na barra de interruptores\" em Configurações > Smart Plugs',\n  },\n\n  // Notifications\n  notifications: {\n    // Provider types\n    providerTypes: {\n      callmebot: 'CallMeBot/WhatsApp',\n      ntfy: 'ntfy',\n      pushover: 'Pushover',\n      telegram: 'Telegram',\n      email: 'E-mail',\n      discord: 'Discord',\n      webhook: 'Webhook',\n      homeassistant: 'Home Assistant',\n    },\n    // Provider descriptions\n    providerDescriptions: {\n      email: 'Notificações por e-mail SMTP',\n      telegram: 'Notificações via bot do Telegram',\n      discord: 'Enviar para canal do Discord via webhook',\n      ntfy: 'Notificações push gratuitas e auto-hospedáveis',\n      pushover: 'Notificações push simples e confiáveis',\n      callmebot: 'Notificações gratuitas via WhatsApp pelo CallMeBot',\n      webhook: 'POST HTTP genérico para qualquer URL',\n      homeassistant: 'Notificações persistentes no painel do Home Assistant',\n    },\n    // NotificationProviderCard\n    lastSuccess: 'Último: {{date}}',\n    error: 'Erro',\n    printer: 'Impressora:',\n    allPrinters: 'Todas as impressoras',\n    sendTestNotification: 'Enviar Notificação de Teste',\n    eventSettings: 'Configurações de Eventos',\n    enabled: 'Ativado',\n    sendFromProvider: 'Enviar notificações deste provedor',\n    // Event categories\n    printEvents: 'Eventos de Impressão',\n    printerStatus: 'Status da Impressora',\n    amsAlarms: 'Alarmes do AMS',\n    amsHtAlarms: 'Alarmes do AMS-HT',\n    printQueue: 'Fila de Impressão',\n    // Event tags (badges)\n    start: 'Início',\n    plateCheck: 'Verificação da Mesa',\n    complete: 'Concluído',\n    failed: 'Falhou',\n    stopped: 'Parado',\n    progress: 'Progresso',\n    offline: 'Offline',\n    lowFilament: 'Filamento Baixo',\n    maintenance: 'Manutenção',\n    amsHumidity: 'Umidade do AMS',\n    amsTemp: 'Temp. do AMS',\n    amsHtHumidity: 'Umidade do AMS-HT',\n    amsHtTemp: 'Temp. do AMS-HT',\n    bedCooled: 'Mesa Resfriada',\n    firstLayer: 'Primeira camada',\n    quiet: 'Silencioso',\n    digest: 'Resumo {{time}}',\n    // Event labels (expanded settings)\n    printStarted: 'Impressão Iniciada',\n    plateNotEmpty: 'Mesa Não Vazia',\n    plateNotEmptyDescription: 'Objetos detectados antes da impressão',\n    printCompleted: 'Impressão Concluída',\n    bedCooledLabel: 'Mesa Resfriada',\n    bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',\n    firstLayerCompleteLabel: 'Primeira camada concluída',\n    firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',\n    missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',\n    missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',\n    printFailed: 'Impressão Falhou',\n    printStopped: 'Impressão Parada',\n    progressMilestones: 'Marcos de Progresso',\n    progressMilestonesDescription: 'Notificar em 25%, 50%, 75%',\n    printerOffline: 'Impressora Offline',\n    printerError: 'Erro da Impressora',\n    lowFilamentLabel: 'Filamento Baixo',\n    maintenanceDue: 'Manutenção Necessária',\n    maintenanceDueDescription: 'Notificar quando manutenção for necessária',\n    amsHumidityHigh: 'Umidade Alta do AMS',\n    amsHumidityHighDescription: 'Umidade do AMS regular excede o limite',\n    amsTemperatureHigh: 'Temperatura Alta do AMS',\n    amsTemperatureHighDescription: 'Temperatura do AMS regular excede o limite',\n    amsHtHumidityHigh: 'Umidade Alta do AMS-HT',\n    amsHtHumidityHighDescription: 'Umidade do AMS-HT excede o limite',\n    amsHtTemperatureHigh: 'Temperatura Alta do AMS-HT',\n    amsHtTemperatureHighDescription: 'Temperatura do AMS-HT excede o limite',\n    // Queue events\n    jobAdded: 'Trabalho Adicionado',\n    jobAddedDescription: 'Trabalho adicionado à fila',\n    jobAssigned: 'Trabalho Atribuído',\n    jobAssignedDescription: 'Trabalho baseado em modelo atribuído à impressora',\n    jobStarted: 'Trabalho Iniciado',\n    jobStartedDescription: 'Trabalho da fila começou a imprimir',\n    jobWaiting: 'Trabalho Aguardando',\n    jobWaitingDescription: 'Trabalho aguardando filamento ou impressora',\n    jobSkipped: 'Trabalho Pulado',\n    jobSkippedDescription: 'Trabalho pulado (anterior falhou)',\n    jobFailed: 'Trabalho Falhou',\n    jobFailedDescription: 'Trabalho falhou ao iniciar',\n    queueComplete: 'Fila Concluída',\n    queueCompleteDescription: 'Todos os trabalhos da fila finalizados',\n    // Quiet hours\n    quietHours: 'Horário de Silêncio',\n    noNotificationsDuring: 'Sem notificações durante essas horas',\n    editProviderToChangeQuietHours: 'Edite o provedor para alterar o horário de silêncio',\n    // Daily digest\n    dailyDigest: 'Resumo Diário',\n    batchNotifications: 'Agrupar notificações em um único resumo diário',\n    sendAt: 'Enviar às {{time}}',\n    editProviderToChangeDigestTime: 'Edite o provedor para alterar o horário do resumo',\n    // Actions\n    edit: 'Editar',\n    deleteProvider: 'Excluir Provedor de Notificação',\n    deleteConfirm: 'Tem certeza que deseja excluir \"{{name}}\"? Isso não pode ser desfeito.',\n    delete: 'Excluir',\n    // AddNotificationModal\n    addTitle: 'Adicionar Provedor de Notificação',\n    editTitle: 'Editar Provedor de Notificação',\n    nameLabel: 'Nome *',\n    namePlaceholder: 'Minhas Notificações',\n    providerTypeLabel: 'Tipo de Provedor *',\n    configuration: 'Configuração',\n    testConfiguration: 'Testar Configuração',\n    printerFilter: 'Filtro de Impressora',\n    onlyFromPrinter: 'Enviar notificações apenas para eventos desta impressora',\n    quietHoursDnd: 'Horário de Silêncio (Não Perturbe)',\n    quietStart: 'Início',\n    quietEnd: 'Fim',\n    dailyDigestLabel: 'Resumo Diário',\n    sendDigestAt: 'Enviar resumo às',\n    digestCollected: 'Os eventos serão coletados e enviados como um único resumo neste horário',\n    notificationEvents: 'Eventos de Notificação',\n    progressPercent: '(25%, 50%, 75%)',\n    bedCooledAfterPrint: '(após conclusão da impressão)',\n    cancel: 'Cancelar',\n    save: 'Salvar',\n    add: 'Adicionar',\n    nameRequired: 'Nome é obrigatório',\n    fieldRequired: '{{field}} é obrigatório',\n    // Config field labels\n    phoneNumber: 'Número de Telefone',\n    apiKey: 'Chave da API',\n    serverUrl: 'URL do Servidor',\n    topic: 'Tópico',\n    authToken: 'Token de Autenticação',\n    userKey: 'Chave do Usuário',\n    appToken: 'Token do Aplicativo',\n    priority: 'Prioridade',\n    botToken: 'Token do Bot',\n    chatId: 'ID do Chat',\n    smtpServer: 'Servidor SMTP',\n    smtpPort: 'Porta SMTP',\n    security: 'Segurança',\n    authentication: 'Autenticação',\n    username: 'Usuário',\n    password: 'Senha',\n    fromEmail: 'E-mail de Origem',\n    toEmail: 'E-mail de Destino',\n    webhookUrl: 'URL do Webhook',\n    payloadFormat: 'Formato do Payload',\n    authorization: 'Autorização',\n    titleFieldName: 'Nome do Campo de Título',\n    messageFieldName: 'Nome do Campo de Mensagem',\n    // NotificationTemplateEditor\n    editTemplate: 'Editar Modelo: {{name}}',\n    titleLabel: 'Título',\n    bodyLabel: 'Corpo',\n    titlePlaceholder: 'Título da notificação...',\n    bodyPlaceholder: 'Corpo da notificação...',\n    availableVariables: 'Variáveis Disponíveis',\n    clickToInsert: 'Clique para inserir na posição do cursor no corpo',\n    livePreview: 'Pré-visualização ao Vivo',\n    hide: 'Ocultar',\n    show: 'Mostrar',\n    loadingPreview: 'Carregando pré-visualização...',\n    enterTemplateContent: 'Insira o conteúdo do modelo para ver a pré-visualização',\n    titlePreview: 'Título:',\n    bodyPreview: 'Corpo:',\n    resetToDefault: 'Restaurar Padrão',\n    titleRequired: 'Título é obrigatório',\n    bodyRequired: 'Corpo é obrigatório',\n    // NotificationLogViewer\n    notificationLog: 'Registro de Notificações',\n    showFailedOnly: 'Apenas falhas',\n    last24Hours: 'Últimas 24 horas',\n    last7Days: 'Últimos 7 dias',\n    last30Days: 'Últimos 30 dias',\n    last90Days: 'Últimos 90 dias',\n    justNow: 'Agora mesmo',\n    noFailedNotifications: 'Nenhuma notificação com falha',\n    noNotificationsLogged: 'Nenhuma notificação registrada',\n    unknownProvider: 'Provedor Desconhecido',\n    logTitle: 'Título',\n    logMessage: 'Mensagem',\n    logError: 'Erro',\n    logProvider: 'Provedor: {{type}}',\n    logTime: 'Hora: {{time}}',\n    refresh: 'Atualizar',\n    clearOld: 'Limpar Antigos',\n    statsSummary: 'Últimos {{days}} dias:',\n    statsNotifications: 'notificações',\n    statsSent: '{{count}} enviadas',\n    statsFailed: '{{count}} com falha',\n    // Event type labels (for log viewer)\n    eventTypes: {\n      print_start: 'Impressão Iniciada',\n      print_complete: 'Impressão Concluída',\n      print_failed: 'Impressão Falhou',\n      print_stopped: 'Impressão Parada',\n      print_progress: 'Progresso',\n      printer_offline: 'Impressora Offline',\n      printer_error: 'Erro da Impressora',\n      filament_low: 'Filamento Baixo',\n      maintenance_due: 'Manutenção Necessária',\n      test: 'Teste',\n    },\n    // User email notification preferences\n    userEmail: {\n      title: 'Notificações',\n      emailNotifications: 'Notificações por E-mail',\n      emailNotificationsDesc: 'Receba notificações por e-mail para seus próprios trabalhos de impressão. Os e-mails são enviados usando as configurações SMTP definidas na Autenticação Avançada.',\n      sendingTo: 'As notificações serão enviadas para',\n      noEmailWarning: 'Sua conta não tem um endereço de e-mail. Entre em contato com um administrador para adicionar um.',\n      printJobNotifications: 'Notificações de Trabalhos de Impressão',\n      printJobNotificationsDesc: 'Escolha quais eventos acionam notificações por e-mail para os trabalhos de impressão que você envia.',\n      printJobStarts: 'Início do Trabalho de Impressão',\n      printJobStartsDesc: 'Ser notificado quando seu trabalho de impressão começar.',\n      printJobFinishes: 'Conclusão do Trabalho de Impressão',\n      printJobFinishesDesc: 'Ser notificado quando seu trabalho de impressão concluir com sucesso.',\n      printErrors: 'Erros de Impressão',\n      printErrorsDesc: 'Ser notificado quando seu trabalho de impressão falhar ou encontrar um erro.',\n      printJobStops: 'Trabalho de Impressão Parado',\n      printJobStopsDesc: 'Ser notificado quando seu trabalho de impressão for cancelado ou parado.',\n      saveSuccess: 'Preferências de notificação salvas.',\n      saveError: 'Falha ao salvar preferências de notificação.',\n    },\n  },\n\n  // Rich Text Editor\n  richTextEditor: {\n    bold: 'Negrito',\n    italic: 'Itálico',\n    underline: 'Sublinhado',\n    bulletList: 'Lista com marcadores',\n    numberedList: 'Lista numerada',\n    alignLeft: 'Alinhar à esquerda',\n    alignCenter: 'Centralizar',\n    alignRight: 'Alinhar à direita',\n    addLink: 'Adicionar link',\n    removeLink: 'Remover link',\n  },\n\n  // External Links\n  externalLinks: {\n    noLinksConfigured: 'Nenhum link externo configurado',\n    deleteLink: 'Excluir link',\n    removeCustomIcon: 'Remover ícone personalizado',\n    openInNewTab: 'Abrir em nova aba',\n    placeholders: {\n      linkName: 'Meu Link',\n    },\n  },\n\n  // Keyboard Shortcuts Modal\n  keyboardShortcuts: {\n    title: 'Atalhos de Teclado',\n    navigation: 'Navegação',\n    archivesSection: 'Arquivos',\n    kProfilesSection: 'K-Profiles',\n    generalSection: 'Geral',\n    shortcuts: {\n      goToPrinters: 'Ir para Impressoras',\n      goToArchives: 'Ir para Arquivos',\n      goToQueue: 'Ir para Fila',\n      goToStats: 'Ir para Estatísticas',\n      goToProfiles: 'Ir para Perfis na Nuvem',\n      goToSettings: 'Ir para Configurações',\n      focusSearch: 'Focar na pesquisa',\n      openUploadModal: 'Abrir modal de upload',\n      clearSelection: 'Limpar seleção / desfocar input',\n      contextMenu: 'Menu de contexto nos cartões',\n      refreshProfiles: 'Atualizar perfis',\n      newProfile: 'Novo perfil',\n      exitSelectionMode: 'Sair do modo de seleção',\n      showHelp: 'Mostrar esta ajuda',\n    },\n    footer: 'Pressione Esc ou clique fora para fechar',\n  },\n\n  // Notification Log\n  notificationLog: {\n    title: 'Registro de Notificações',\n    events: {\n      printStarted: 'Impressão Iniciada',\n      printComplete: 'Impressão Concluída',\n      printFailed: 'Impressão Falhou',\n      printStopped: 'Impressão Interrompida',\n      progress: 'Progresso',\n      printerOffline: 'Impressora Offline',\n      printerError: 'Erro na Impressora',\n      lowFilament: 'Filamento Baixo',\n      maintenanceDue: 'Manutenção Pendente',\n      test: 'Teste',\n    },\n    timeAgo: {\n      justNow: 'Agora mesmo',\n      minutesAgo: 'há {{minutes}} minutos',\n      hoursAgo: 'há {{hours}} horas',\n    },\n  },\n\n  // Restore/Backup Modal\n  restoreBackup: {\n    title: 'Restaurar Backup',\n    restoring: 'Restaurando...',\n    restoreComplete: 'Restauração Concluída',\n    restoreFailed: 'Falha na Restauração',\n    importSettings: 'Importar configurações de um arquivo de backup',\n    pleaseWait: 'Aguarde enquanto seus dados estão sendo restaurados',\n    clickToSelect: 'Clique para selecionar o arquivo de backup (.json ou .zip)',\n    howDuplicateHandling: 'Como funciona o tratamento de duplicatas:',\n    categories: {\n      printers: 'Impressoras',\n      smartPlugs: 'Tomadas Inteligentes',\n      notificationProviders: 'Provedores de Notificação',\n      filaments: 'Filamentos',\n      archives: 'Arquivos',\n      pendingUploads: 'Uploads Pendentes',\n      settingsTemplates: 'Configurações e Modelos',\n    },\n    matchingInfo: {\n      printers: 'correspondem pelo número de série',\n      smartPlugs: 'correspondem pelo endereço IP',\n      notificationProviders: 'correspondem pelo nome',\n      filaments: 'correspondem pelo nome + tipo + marca',\n      archives: 'correspondem pelo hash do conteúdo',\n      pendingUploads: 'correspondem pelo nome do arquivo',\n      settingsTemplates: 'sempre sobrescrito',\n    },\n    replaceExisting: 'Substituir dados existentes',\n    keepExisting: 'Manter dados existentes',\n    replaceDescription: 'Substituir itens que já existem com os dados do backup',\n    keepDescription: 'Restaurar apenas itens que não existem',\n    caution: 'Atenção:',\n    cautionText: 'Sobrescrever substituirá suas configurações atuais pelos dados do backup. Os códigos de acesso da impressora nunca são sobrescritos por segurança.',\n    itemsRestored: 'Itens Restaurados',\n    itemsSkipped: 'Itens Ignorados',\n    restored: 'Restaurado',\n    skipped: 'Ignorado (já existe)',\n    filesLabel: 'Arquivos (3MF, miniaturas, etc.)',\n    newApiKeysGenerated: 'Novas Chaves API Geradas',\n    newApiKeysWarning: 'Essas chaves são exibidas apenas uma vez. Copie-as agora!',\n    processingBackup: 'Processando arquivo de backup...',\n    noDataFound: 'Nenhum dado foi encontrado para restaurar no arquivo de backup.',\n    failedToRestore: 'Falha ao restaurar o backup. Verifique o formato do arquivo.',\n  },\n\n  // Backup Export Modal\n  backupExport: {\n    title: 'Exportar Backup',\n    selectData: 'Selecione os dados a incluir',\n    selectAll: 'Selecionar Todos',\n    selectNone: 'Selecionar Nenhum',\n    categoryDescriptions: {\n      settings: 'Idioma, tema, preferências de atualização',\n      notifications: 'ntfy, Pushover, Discord, etc.',\n      templates: 'Modelos de mensagens personalizadas',\n      smartPlugs: 'Configurações de tomadas Tasmota',\n      externalLinks: 'Links da barra lateral para serviços externos',\n      printers: 'Informações da impressora (códigos de acesso excluídos)',\n      plateDetection: 'Imagens de referência de placa vazia',\n      filaments: 'Tipos e custos de filamento',\n      maintenance: 'Cronogramas de manutenção personalizados',\n      archives: 'Todos os dados de impressão + arquivos (3MF, miniaturas, fotos)',\n      projects: 'Projetos, itens de BOM e anexos',\n      pendingUploads: 'Uploads de impressora virtual aguardando revisão',\n      apiKeys: 'Chaves API de webhook (novas chaves geradas na importação)',\n    },\n    requiresPrinters: 'Requer que as impressoras sejam selecionadas',\n    zipFileWarning: 'Um arquivo ZIP será criado.',\n    zipFileDescription: 'Inclui todos os arquivos 3MF, miniaturas, timelapses e fotos. Isso pode levar algum tempo e resultar em um arquivo grande.',\n    includeAccessCodes: 'Incluir Códigos de Acesso',\n    includeAccessCodesDescription: 'Para transferir para outra máquina',\n    includeAccessCodesWarning: 'Os códigos de acesso serão incluídos em texto simples. Mantenha este arquivo de backup seguro!',\n    categoriesSelected: '{{selectedCount}} categorias selecionadas',\n  },\n\n  // Pending Uploads Panel\n  pendingUploads: {\n    placeholders: {\n      notes: 'Adicione notas sobre esta impressão...',\n    },\n    discardUpload: 'Descartar Upload',\n    archiveAllUploads: 'Arquivar Todos os Uploads',\n    discardAllUploads: 'Descartar Todos os Uploads',\n    archive: 'Arquivar',\n    timeAgo: {\n      justNow: 'Agora mesmo',\n      minutesAgo: 'há {{minutes}} minutos',\n      hoursAgo: 'há {{hours}} horas',\n      daysAgo: 'há {{days}} dias',\n    },\n  },\n\n  // API Browser\n  apiBrowser: {\n    placeholders: {\n      requestBody: 'Corpo da requisição JSON...',\n      searchEndpoints: 'Pesquisar endpoints...',\n    },\n  },\n\n  // Configure AMS Slot Modal\n  configureAmsSlot: {\n    title: 'Configurar Slot AMS',\n    slotConfigured: 'Slot Configurado!',\n    configuringSlot: 'Configurando slot:',\n    slotLabel: '{{ams}} Slot {{slot}}',\n    searchPresets: 'Pesquisar predefinições...',\n    colorPlaceholder: 'Nome da cor ou hex (ex.: marrom, FF8800)',\n    clearCustomColor: 'Limpar cor personalizada',\n    noCloudPresets: 'Nenhuma predefinição na nuvem. Faça login no Bambu Cloud para sincronizar.',\n    noPresetsAvailable: 'Nenhuma predefinição disponível. Faça login no Bambu Cloud ou importe perfis locais.',\n    noMatchingPresets: 'Nenhuma predefinição correspondente encontrada.',\n    custom: 'Personalizado',\n    builtin: 'Integrado',\n    settingsSentToPrinter: 'Configurações enviadas para a impressora',\n    filamentProfile: 'Perfil de Filamento',\n    kProfileLabel: 'Perfil K (Avanço de Pressão)',\n    filteringFor: 'Filtrando por: {{material}}',\n    noKProfile: 'Nenhum perfil K (usar padrão 0.020)',\n    noMatchingKProfiles: 'Nenhum perfil K correspondente encontrado. O K padrão=0.020 será usado.',\n    selectFilamentFirst: 'Selecione um perfil de filamento primeiro',\n    kFromCalibration: 'K={{value}} da calibração da impressora',\n    customColorLabel: 'Cor Personalizada (opcional)',\n    presetColors: 'Cores de {{name}}:',\n    showLessColors: 'Mostrar menos cores',\n    showMoreColors: 'Mostrar mais cores',\n    clear: 'Limpar',\n    hexLabel: 'Hex: #{{hex}}',\n    resetting: 'Redefinindo...',\n    resetSlot: 'Redefinir Slot',\n    cancel: 'Cancelar',\n    configuring: 'Configurando...',\n    configureSlot: 'Configurar Slot',\n  },\n\n  // GitHub Backup Settings\n  githubBackup: {\n    title: 'Backup do GitHub',\n    history: 'Histórico',\n    downloadBackup: 'Baixar Backup',\n    restoreBackup: 'Restaurar Backup',\n    noBackupsYet: 'Nenhum backup ainda',\n  },\n\n  // Email Settings\n  emailSettings: {\n    placeholders: {\n      fromName: 'BamBuddy',\n    },\n  },\n\n  // Tag Management Modal\n  tagManagement: {\n    searchTags: 'Pesquisar tags...',\n    renameTag: 'Renomear tag',\n    deleteTag: 'Excluir tag',\n  },\n\n  // Notification Template Editor\n  notificationTemplates: {\n    placeholders: {\n      title: 'Título da notificação...',\n      body: 'Corpo da notificação...',\n    },\n  },\n\n  // Batch Tag Modal\n  batchTag: {\n    placeholders: {\n      newTag: 'Digite uma nova tag...',\n    },\n  },\n\n  // Photo Gallery Modal\n  photoGallery: {\n    deletePhoto: 'Excluir foto',\n  },\n\n  // Filament Hover Card\n  filamentHoverCard: {\n    copySpoolUuid: 'Copiar UUID do carretel',\n  },\n\n  // K Profiles View\n  kProfilesView: {\n    hasNote: 'Possui nota',\n    copyProfile: 'Copiar perfil',\n  },\n\n  // Layout/Navigation\n  layout: {\n    openMenu: 'Abrir menu',\n    noPermissionSystemInfo: 'Você não tem permissão para visualizar informações do sistema',\n  },\n\n  // Dashboard\n  dashboard: {\n    dragToReorder: 'Arrastar para reordenar',\n    hideWidget: 'Ocultar widget',\n  },\n\n  // Notification Provider Card\n  notificationProviderCard: {\n    deleteNotificationProvider: 'Excluir provedor de notificação',\n  },\n\n  // File Manager Modal\n  fileManagerModal: {\n    closeFileManager: 'Fechar gerenciador de arquivos',\n    sortFiles: 'Ordenar arquivos',\n    goToParentFolder: 'Ir para a pasta pai',\n    threeView: 'Visualização 3D',\n  },\n\n  // Embedded Camera Viewer\n  embeddedCameraViewer: {\n    refreshStream: 'Atualizar stream',\n    close: 'Fechar',\n    zoomOut: 'Reduzir zoom',\n    resetZoom: 'Redefinir zoom',\n    zoomIn: 'Aumentar zoom',\n    dragToResize: 'Arrastar para redimensionar',\n  },\n\n  // Timelapse Viewer\n  timelapseViewer: {\n    skipBack5s: 'Voltar 5s',\n    skipForward5s: 'Avançar 5s',\n  },\n\n  // Notification Providers\n  notificationProviders: {\n    descriptions: {\n      email: 'Notificações por email SMTP',\n      telegram: 'Notificações via bot do Telegram',\n      discord: 'Enviar para canal do Discord via webhook',\n      ntfy: 'Notificações push gratuitas e auto-hospedáveis',\n      pushover: 'Notificações push simples e confiáveis',\n      callmebot: 'Notificações gratuitas via WhatsApp pelo CallMeBot',\n      webhook: 'POST HTTP genérico para qualquer URL',\n    },\n  },\n\n  // Log Viewer\n  logViewer: {\n    searchPlaceholder: 'Pesquisar mensagem ou nome do logger...',\n    noLogEntries: 'Nenhuma entrada de log encontrada',\n  },\n\n  // Switchbar Popover\n  switchbarPopover: {\n    noSwitchesInSwitchbar: 'Nenhum switch na barra de switches',\n  },\n\n  // Project Page Modal\n  projectPageModal: {\n    placeholders: {\n      title: 'Título',\n      designer: 'Designer',\n      license: 'Licença',\n      description: 'Digite a descrição...',\n      profileTitle: 'Título do perfil',\n      profileDescription: 'Descrição do perfil...',\n    },\n  },\n\n  // Spoolman Settings\n  spoolmanSettings: {},\n\n  // Time\n  time: {\n    unknown: '-',\n    waiting: 'Aguardando',\n    justNow: 'Agora mesmo',\n    now: 'Agora',\n    minsAgo: '{{count}}min atrás',\n    inMins: 'em {{count}}min',\n    hoursAgo: '{{count}}h atrás',\n    inHours: 'em {{count}}h',\n    daysAgo: '{{count}}d atrás',\n    inDays: 'em {{count}}d',\n  },\n\n  // SpoolBuddy Kiosk\n  spoolbuddy: {\n    nav: {\n      dashboard: 'Painel',\n      ams: 'AMS',\n      inventory: 'Inventário',\n      writeTag: 'Escrever',\n      settings: 'Configurações',\n    },\n    status: {\n      nfcReady: 'NFC pronto',\n      nfcOff: 'NFC desligado',\n      offline: 'Offline',\n      online: 'Online',\n      noPrinters: 'Sem impressoras',\n      deviceOffline: 'Dispositivo offline',\n      waitingConnection: 'Aguardando conexão do dispositivo...',\n      systemReady: 'Sistema pronto',\n      status: 'Status',\n    },\n    dashboard: {\n      readyToScan: 'Pronto para escanear',\n      idleMessage: 'Coloque um carretel na balança para identificá-lo',\n      nfcHint: 'A tag NFC será lida automaticamente',\n      device: 'Dispositivo',\n      syncWeight: 'Sincronizar peso',\n      weightSynced: 'Sincronizado!',\n      unknownTag: 'Tag desconhecida',\n      newTag: 'Nova tag detectada',\n      onScale: 'na balança',\n      linkSpool: 'Vincular ao carretel',\n      linkTagTitle: 'Vincular tag ao carretel',\n      linkTag: 'Vincular tag',\n      selectSpool: 'Selecione um carretel para vincular a esta tag:',\n      noUntagged: 'Nenhum carretel sem tag encontrado',\n      tagDetected: 'Tag detectada',\n      noTag: 'Sem tag',\n      tagId: 'Tag',\n      grossWeight: 'Peso bruto',\n      spoolSize: 'Tamanho do carretel',\n      close: 'Fechar',\n      currentSpool: 'Carretel Atual',\n    },\n    modal: {\n      spoolDetected: 'Carretel Detectado',\n      assignToAms: 'Atribuir ao AMS',\n      syncWeight: 'Sincronizar Peso',\n      weightSynced: 'Sincronizado!',\n      syncing: 'Sincronizando...',\n      newTagDetected: 'Nova Tag Detectada',\n      addToInventory: 'Adicionar ao Inventário',\n      assignToAmsTitle: 'Atribuir ao AMS',\n      selectSlot: 'Selecionar um slot',\n      assign: 'Atribuir',\n      assigning: 'Atribuindo...',\n      assignSuccess: 'Atribuído!',\n      assignError: 'Falha ao atribuir carretel. Tente novamente.',\n      noPrinterSelected: 'Selecionar uma impressora...',\n      noAmsDetected: 'Nenhum AMS detectado nesta impressora',\n      slot: 'Slot',\n    },\n    weight: {\n      noReading: 'Sem leitura',\n      stable: 'Estável',\n      measuring: 'Medindo...',\n      tare: 'Tarar',\n      calibrate: 'Calibrar',\n    },\n    spool: {\n      remaining: 'Restante',\n      material: 'Material',\n      brand: 'Marca',\n      color: 'Cor',\n      coreWeight: 'Núcleo',\n      labelWeight: 'Rótulo',\n      scaleWeight: 'Balança',\n      netWeight: 'Líquido',\n      lastUsed: 'Último uso',\n    },\n    ams: {\n      noData: 'Nenhum AMS detectado',\n      connectAms: 'Conecte um AMS para ver os slots',\n      noPrinter: 'Nenhuma impressora selecionada',\n      selectPrinter: 'Selecione uma impressora na barra superior',\n      printerDisconnected: 'Impressora desconectada',\n      humidity: 'Umidade',\n      level: 'Nível',\n      active: 'Ativo',\n      slot: 'Slot',\n      empty: 'Vazio',\n    },\n    inventory: {\n      search: 'Buscar carretéis...',\n      empty: 'Nenhum carretel no inventário',\n      noResults: 'Nenhum carretel correspondente',\n      spools: 'carretéis',\n      addSpool: 'Adicionar carretel',\n    },\n    settings: {\n      // Tabs\n      tabDevice: 'Dispositivo',\n      tabDisplay: 'Tela',\n      tabScale: 'Balança',\n      tabUpdates: 'Atualizações',\n      // Device tab\n      nfcReader: 'Leitor NFC',\n      type: 'Tipo',\n      connection: 'Conexão',\n      notConnected: 'N/A',\n      deviceInfo: 'Info do dispositivo',\n      hostname: 'Host',\n      uptime: 'Tempo de atividade',\n      // Display tab\n      brightness: 'Brilho',\n      saved: 'Salvo',\n      noBacklight: 'Nenhuma retroiluminação DSI detectada. O controle de brilho requer uma tela DSI.',\n      screenBlank: 'Tempo para desligar tela',\n      screenBlankDesc: 'A tela desliga após inatividade. Toque para despertar.',\n      displayNote: 'O brilho é aplicado como filtro de software.',\n      // Scale tab\n      scaleCalibration: 'Calibração da balança',\n      currentWeight: 'Peso atual',\n      tareOffset: 'Tara',\n      calFactor: 'Fator',\n      knownWeight: 'Peso conhecido',\n      calStep1: 'Remova todos os itens da balança e pressione Definir zero.',\n      calStep2: 'Coloque o peso conhecido na balança.',\n      setZero: 'Definir zero',\n      calibrateNow: 'Calibrar',\n      calibrated: 'Calibrado',\n      tareSet: 'Comando de tara enviado. Aguardando dispositivo...',\n      tareFailed: 'Falha ao enviar comando de tara',\n      zeroSet: 'Ponto zero definido. Coloque o peso conhecido na balança.',\n      calibrationDone: 'Calibração concluída!',\n      calibrationFailed: 'Falha na calibração',\n      lastCalibrated: 'Última calibração',\n      stable: 'Estável',\n      settling: 'Estabilizando...',\n      firmware: 'Firmware',\n      scale: 'Balança',\n      noDevice: 'Nenhum dispositivo SpoolBuddy encontrado',\n      // Updates tab\n      daemonVersion: 'Versão do daemon',\n      currentVersion: 'Atual',\n      versionPending: 'Aguardando daemon...',\n      checking: 'Verificando...',\n      checkUpdates: 'Verificar atualizações',\n      updateAvailable: 'Atualização disponível',\n      updateInstructions: 'Atualize via SSH: execute o script de instalação do SpoolBuddy.',\n      upToDate: 'Atualizado',\n      includeBeta: 'Incluir versões beta',\n    },\n    writeTag: {\n      tabExisting: 'Bobina existente',\n      tabNew: 'Nova bobina',\n      tabReplace: 'Substituir tag',\n      searchPlaceholder: 'Buscar por material, cor, marca...',\n      noUntaggedSpools: 'Nenhuma bobina sem tag',\n      noTaggedSpools: 'Nenhuma bobina com tag',\n      selectSpool: 'Selecione uma bobina e coloque um NTAG no leitor',\n      placeTag: 'Coloque um NTAG no leitor',\n      tagReady: 'Tag detectado — pronto para gravar',\n      writeTag: 'Gravar Tag',\n      replaceTag: 'Substituir Tag',\n      writing: 'Gravando tag...',\n      waiting: 'Aguardando SpoolBuddy...',\n      writeSuccess: 'Tag gravado com sucesso!',\n      writeFailed: 'Falha na gravação',\n      queueFailed: 'Falha ao enfileirar comando de gravação',\n      tryAgain: 'Tentar novamente',\n      cancel: 'Cancelar',\n      replaceWarning: 'O tag antigo será desvinculado. O novo tag o substituirá.',\n      deviceOffline: 'SpoolBuddy está offline',\n      material: 'Material',\n      colorName: 'Nome da cor',\n      color: 'Cor',\n      brand: 'Marca',\n      weight: 'Peso (g)',\n      createSpool: 'Criar bobina',\n      creating: 'Criando...',\n      spoolCreated: 'Bobina criada! Pronto para gravar.',\n      createFailed: 'Falha ao criar bobina',\n    },\n    quickMenu: {\n      printerPower: 'Energia da impressora',\n      systemControls: 'Sistema',\n      restartDaemon: 'Reiniciar daemon',\n      restartBrowser: 'Reiniciar navegador',\n      reboot: 'Reiniciar',\n      shutdown: 'Desligar',\n      swipeToClose: 'Deslize para baixo para fechar',\n      confirmTitle: 'Confirmar',\n      confirmShutdown: 'Tem certeza de que deseja desligar o SpoolBuddy? Você precisará de acesso físico para ligá-lo novamente.',\n      confirmReboot: 'Tem certeza de que deseja reiniciar o SpoolBuddy?',\n      confirmRestartDaemon: 'Reiniciar o daemon do SpoolBuddy? NFC e balança ficarão temporariamente indisponíveis.',\n      confirmRestartBrowser: 'Reiniciar o navegador kiosk? A tela ficará brevemente preta.',\n      confirm: 'Confirmar',\n      confirmPlugOn: 'Ligar {{name}}?',\n      confirmPlugOff: 'Desligar {{name}}?',\n      turnOn: 'Ligar',\n      turnOff: 'Desligar',\n    },\n  },\n\n  bugReport: {\n    title: 'Reportar um bug',\n    description: 'Descrição',\n    descriptionPlaceholder: 'O que deu errado? Por favor, descreva o problema...',\n    email: 'Email (opcional)',\n    emailPlaceholder: 'seu@email.com.br',\n    emailPrivacy: 'Se fornecido, seu email será incluído em uma seção recolhida da issue no GitHub para que o mantenedor possa entrar em contato.',\n    screenshot: 'Captura de tela',\n    uploadOrPaste: 'Enviar, colar ou arrastar uma imagem',\n    dataCollectedSummary: 'Quais dados são incluídos no relatório?',\n    dataIncluded: 'Incluídos:',\n    dataIncludedList: 'Versão do app, SO, arquitetura, versão Python, estatísticas do banco de dados (apenas contagens), modelos de impressora, quantidade de bicos, versões de firmware, status de conexão, status de integrações (Spoolman, MQTT, HA), configurações não sensíveis, contagem de interfaces de rede, detalhes Docker, versões de dependências.',\n    dataNeverIncluded: 'Nunca incluídos:',\n    dataNeverIncludedList: 'Nomes de impressoras, números de série, códigos de acesso, senhas, endereços IP, endereços de email, chaves de API, tokens, URLs de webhook, nomes de host ou nomes de usuário.',\n    submit: 'Enviar',\n    startLogging: 'Iniciar log de depuração',\n    stepEnableLogging: 'Log de depuração ativado',\n    stepReproduce: 'Reproduza o problema agora',\n    stepStopLogging: 'Parar & enviar relatório',\n    stopAndSubmit: 'Parar & Enviar',\n    maxDuration: 'Para automaticamente após {{minutes}} min',\n    stoppingLogs: 'Coletando logs & enviando...',\n    submitting: 'Enviando relatório de bug...',\n    submitSuccess: 'Relatório de bug enviado com sucesso!',\n    submitFailed: 'Falha ao enviar relatório de bug',\n    thankYou: 'Obrigado!',\n    submitted: 'Seu relatório de bug foi enviado.',\n    viewIssue: 'Ver issue',\n    unexpectedError: 'Ocorreu um erro inesperado',\n  },\n  failureDetection: {\n    title: 'Detecção de Falhas por IA',\n    description: 'Monitora impressões via API ML do Obico auto-hospedada e age automaticamente em falhas detectadas.',\n    mlUrl: 'URL da API ML do Obico',\n    mlUrlHint: 'URL base do seu contêiner Obico ml_api auto-hospedado (ex.: http://192.168.1.10:3333).',\n    test: 'Testar',\n    testSuccess: 'API ML acessível e operacional.',\n    testFailed: 'Não foi possível acessar a API ML.',\n    sensitivity: 'Sensibilidade',\n    sensitivityLow: 'Baixa (menos falsos positivos)',\n    sensitivityMedium: 'Média (equilibrada)',\n    sensitivityHigh: 'Alta (detecção precoce, mais falsos positivos)',\n    sensitivityHint: 'Ajusta os limiares de confiança que disparam avisos e falhas.',\n    action: 'Ação em falha detectada',\n    actionNotify: 'Apenas notificar',\n    actionPause: 'Pausar impressão',\n    actionPauseOff: 'Pausar e cortar energia',\n    pollInterval: 'Intervalo de verificação (segundos)',\n    pollIntervalHint: 'Frequência de verificação de cada impressora durante a impressão. Mínimo 5s, máximo 120s.',\n    externalUrlMissing: 'External URL is not set.',\n    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',\n    perPrinterTitle: 'Impressoras monitoradas',\n    perPrinterHint: 'Escolha quais impressoras o serviço de detecção monitora.',\n    monitorAll: 'Monitorar todas as impressoras conectadas',\n    statusTitle: 'Status',\n    serviceRunning: 'Serviço em execução',\n    thresholds: 'Limiares baixo / alto',\n    activePrinters: 'Impressões ativas',\n    noActivePrints: 'Nenhuma impressão em andamento.',\n    historyTitle: 'Detecções recentes',\n    noHistory: 'Nenhuma detecção ainda.',\n  },\n};\n"
  },
  {
    "path": "frontend/src/i18n/locales/zh-CN.ts",
    "content": "export default {\n  // Navigation\n  nav: {\n    printers: '打印机',\n    archives: '归档',\n    queue: '队列',\n    stats: '统计',\n    profiles: '配置文件',\n    maintenance: '维护',\n    projects: '项目',\n    inventory: '耗材',\n    files: '文件管理器',\n    notifications: '通知',\n    settings: '设置',\n    system: '系统',\n    collapseSidebar: '收起侧边栏',\n    expandSidebar: '展开侧边栏',\n    update: '更新',\n    updateAvailable: '有可用更新：v{{version}}',\n    updateAvailableBanner: '版本 {{version}} 已发布！',\n    viewUpdate: '查看更新',\n    viewOnGithub: '在 GitHub 上查看',\n    keyboardShortcuts: '键盘快捷键 (?)',\n    switchToLight: '切换到浅色模式',\n    switchToDark: '切换到深色模式',\n    smartSwitches: '智能开关',\n    logout: '退出登录',\n  },\n\n  // Common\n  common: {\n    save: '保存',\n    saving: '保存中...',\n    cancel: '取消',\n    delete: '删除',\n    edit: '编辑',\n    add: '添加',\n    close: '关闭',\n    confirm: '确认',\n    loading: '加载中...',\n    error: '错误',\n    success: '成功',\n    warning: '警告',\n    enabled: '已启用',\n    disabled: '已禁用',\n    yes: '是',\n    no: '否',\n    on: '开',\n    off: '关',\n    all: '全部',\n    none: '无',\n    search: '搜索',\n    filter: '筛选',\n    sort: '排序',\n    refresh: '刷新',\n    download: '下载',\n    upload: '上传',\n    uploading: '上传中...',\n    uploadFailed: '上传失败',\n    actions: '操作',\n    status: '状态',\n    name: '名称',\n    description: '描述',\n    date: '日期',\n    time: '时间',\n    hours: '小时',\n    minutes: '分钟',\n    seconds: '秒',\n    days: '天',\n    enable: '启用',\n    disable: '禁用',\n    permissions: '权限',\n    noPrinters: '未配置打印机',\n    noData: '暂无数据',\n    linkNotFound: '未找到链接',\n    required: '必填',\n    optional: '可选',\n    dismiss: '关闭',\n    apply: '应用',\n    reset: '重置',\n    export: '导出',\n    import: '导入',\n    clear: '清除',\n    selectAll: '全选',\n    deselectAll: '取消全选',\n    noChange: '— 不更改 —',\n    unchanged: '未更改',\n    unassigned: '未分配',\n    unknown: '未知',\n    unknownError: '未知错误',\n    today: '今天',\n    tomorrow: '明天',\n    asap: '尽快',\n    overdue: '已逾期',\n    now: '现在',\n    collapse: '收起',\n    expand: '展开',\n    viewArchive: '查看归档',\n    viewInFileManager: '在文件管理器中查看',\n    addedBy: '由 {{username}} 添加',\n    prints: '次打印',\n    more: '还有 {{count}} 个',\n    ascending: '升序',\n    descending: '降序',\n    back: '返回',\n    copy: '复制',\n    copied: '已复制!',\n    printer: '打印机',\n    remove: '移除',\n    type: '类型',\n    print: '打印',\n    rename: '重命名',\n    move: '移动',\n    create: '创建',\n    duplicate: '复制',\n    left: '左',\n    right: '右',\n  },\n\n  // Printers page\n  printers: {\n    title: '打印机',\n    addPrinter: '添加打印机',\n    editPrinter: '编辑打印机',\n    deletePrinter: '删除打印机',\n    printerName: '打印机名称',\n    serialNumber: '序列号',\n    ipAddress: 'IP 地址 / 主机名',\n    accessCode: '访问码',\n    model: '型号',\n    nozzleCount: '喷嘴数量',\n    autoArchive: '自动归档',\n    status: {\n      available: '可用',\n      idle: '空闲',\n      printing: '打印中',\n      paused: '已暂停',\n      offline: '离线',\n      problem: '故障',\n      error: '错误',\n      finished: '已完成',\n      unknown: '未知',\n    },\n    temperatures: {\n      nozzle: '喷嘴',\n      bed: '热床',\n      chamber: '腔室',\n    },\n    progress: '{{percent}}% 完成',\n    timeRemaining: '剩余 {{time}}',\n    deleteConfirm: '确定要删除\"{{name}}\"吗？',\n    maintenanceOk: '维护正常',\n    maintenanceWarning: '{{count}} 个警告',\n    maintenanceWarning_plural: '{{count}} 个警告',\n    maintenanceDue: '{{count}} 个到期',\n    maintenanceDue_plural: '{{count}} 个到期',\n    // Sort options\n    sort: {\n      name: '名称',\n      status: '状态',\n      model: '型号',\n      location: '位置',\n      ascending: '升序排列',\n      descending: '降序排列',\n    },\n    // Card size\n    cardSize: {\n      small: '小卡片',\n      medium: '中卡片',\n      large: '大卡片',\n      extraLarge: '超大卡片',\n    },\n    // Controls\n    hideOffline: '隐藏离线',\n    nextAvailable: '下一个可用',\n    powerOn: '开机',\n    offlinePrintersWithPlugs: '带智能插座的离线打印机',\n    noPrintersConfigured: '尚未配置打印机',\n    search: '搜索打印机...',\n    noSearchResults: '没有打印机符合您的搜索或筛选条件',\n    filter: {\n      allStatuses: '所有状态',\n      allLocations: '所有位置',\n    },\n    // Printer card\n    readyToPrint: '准备打印',\n    external: '外部',\n    extL: '外置左',\n    extR: '外置右',\n    deleteArchives: '删除打印归档',\n    noLabel: '无标签',\n    printPreview: '打印预览',\n    width: '宽度',\n    height: '高度',\n    noObjectsFound: '未找到对象',\n    objectsLoadedOnPrintStart: '对象在打印开始时加载',\n    willBeSkipped: '将被跳过',\n    name: '名称',\n    serialCannotBeChanged: '序列号无法更改',\n    locationHelp: '用于分组打印机和筛选队列任务',\n    // WiFi signal strength\n    wifiSignal: {\n      veryWeak: '非常弱',\n      weak: '弱',\n      fair: '一般',\n      good: '良好',\n      excellent: '优秀',\n    },\n    // Maintenance\n    maintenanceUpToDate: '所有维护均已完成 - 点击查看',\n    // Chamber light\n    chamberLightOn: '打开腔室灯',\n    chamberLightOff: '关闭腔室灯',\n    // Files\n    files: '文件',\n    browseFiles: '浏览打印机文件',\n    // Smart plug\n    autoOffAfterPrint: '打印后自动关机',\n    autoOffExecuted: '已执行自动关机 - 开启打印机以重置',\n    // HMS errors\n    hmsErrors: 'HMS 错误',\n    viewHmsErrors: '查看 {{count}} 个 HMS 错误',\n    // Actions\n    resume: '继续',\n    pause: '暂停',\n    stop: '停止',\n    camera: '摄像头',\n    skipObject: '跳过对象',\n    reconnect: '重新连接',\n    forceRefresh: '强制刷新',\n    forceRefreshSuccess: '已请求刷新',\n    mqttDebug: 'MQTT 调试',\n    printerInformation: '打印机信息',\n    copyToClipboard: '复制',\n    copied: '已复制！',\n    state: '状态',\n    wifiSignalLabel: 'WiFi 信号',\n    developerMode: '开发者模式',\n    enabled: '已启用',\n    disabled: '已禁用',\n    addedOn: '添加日期',\n    sdCard: 'SD 卡',\n    inserted: '已插入',\n    notInserted: '未插入',\n    totalPrintHours: '打印时长',\n    activeNozzle: '当前：{{nozzle}} 喷嘴',\n    nozzleRack: '喷嘴架',\n    nozzleDocked: '已停靠',\n    nozzleMounted: '已安装',\n    nozzleActive: '使用中',\n    nozzleIdle: '空闲',\n    nozzleDiameter: '直径',\n    nozzleType: '类型',\n    nozzleStatus: '状态',\n    nozzleFilament: '耗材',\n    nozzleWear: '磨损',\n    nozzleMaxTemp: '最高温度',\n    nozzleSerial: '序列号',\n    nozzleHardenedSteel: '硬化钢',\n    nozzleStainlessSteel: '不锈钢',\n    nozzleTungstenCarbide: '碳化钨',\n    nozzleFlow: '流量',\n    nozzleHighFlow: '高流量',\n    nozzleStandardFlow: '标准',\n    // Firmware\n    firmwareUpdate: '固件更新',\n    firmwareInstructions: '在打印机触摸屏上，前往',\n    firmwareNav: '导航到',\n    settings: '设置',\n    firmware: '固件',\n    // Discovery\n    discoverPrinters: '发现打印机',\n    searching: '搜索中...',\n    manualEntry: '手动输入',\n    addFromCloud: '从云端添加',\n    // Toast messages\n    toast: {\n      printerDeleted: '打印机已删除',\n      missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',\n      printerAdded: '打印机已添加',\n      printerUpdated: '打印机已更新',\n      failedToDelete: '删除打印机失败',\n      failedToAdd: '添加打印机失败',\n      failedToUpdate: '更新打印机失败',\n      commandSent: '命令已发送',\n      failedToSendCommand: '发送命令失败',\n      turnedOn: '{{name}} 已开启',\n      failedToPowerOn: '开启 {{name}} 失败',\n      scriptTriggered: '脚本已触发',\n      printStopped: '打印已停止',\n      printPaused: '打印已暂停',\n      printResumed: '打印已继续',\n      referenceDeleted: '参考已删除',\n      detectionAreaSaved: '检测区域已保存',\n      failedToRunScript: '运行脚本失败',\n      failedToStopPrint: '停止打印失败',\n      failedToPausePrint: '暂停打印失败',\n      failedToResumePrint: '继续打印失败',\n      failedToControlChamberLight: '控制腔室灯失败',\n      failedToSetSpeed: '设置打印速度失败',\n      failedToUpdateSetting: '更新设置失败',\n      failedToSkipObjects: '跳过对象失败',\n      failedToRereadRfid: '重新读取 RFID 失败',\n      failedToCheckPlate: '检查打印板失败',\n      failedToUpdateLabel: '更新标签失败',\n      failedToDeleteReference: '删除参考失败',\n      failedToSaveDetectionArea: '保存检测区域失败',\n      plateCheckEnabled: '打印板检查已启用',\n      plateCheckDisabled: '打印板检查已禁用',\n      calibrationSaved: '校准已保存！',\n      calibrationFailed: '校准失败',\n      rfidRereadInitiated: '已发起 RFID 重新读取',\n    },\n    // Connection status\n    connection: {\n      connected: '已连接',\n      offline: '离线',\n    },\n    plateStatus: {\n      markCleared: '将打印板标记为已清理',\n      cleared: '打印板已清理',\n      notCleared: '打印板未清理',\n      inUse: '打印板使用中',\n    },\n    // Queue info\n    queue: {\n      inQueue: '队列中有 {{count}} 个打印任务',\n      inQueue_plural: '队列中有 {{count}} 个打印任务',\n    },\n    // Controls section\n    controls: '控制',\n    // RFID\n    rfid: {\n      reread: '重新读取 RFID',\n    },\n    bedJog: {\n      title: '移动热床',\n      bed: '热床',\n      step: '步长 (mm)',\n      up: '热床上移',\n      down: '热床下移',\n      disabledWhilePrinting: '打印中已禁用',\n      notHomedTitle: '打印机未归零',\n      notHomedMessage: '打印机自上次打印以来尚未归零。请先执行自动归零以确保安全定位（先停放喷头，然后归零 X、Y 和 Z），或者直接移动 — 软限位将被绕过。',\n      homeZ: '自动归零',\n      moveAnyway: '强制移动',\n      homingStarted: '打印机自动归零中…',\n    },\n    // Permissions\n    permission: {\n      noAdd: '您没有添加打印机的权限',\n      noEdit: '您没有编辑打印机的权限',\n      noDelete: '您没有删除打印机的权限',\n      noControl: '您没有控制打印机的权限',\n      noFiles: '您没有访问打印机文件的权限',\n      noAmsRfid: '您没有重新读取 AMS RFID 的权限',\n      noSmartPlugControl: '您没有控制智能插座的权限',\n      noCamera: '您没有查看摄像头的权限',\n    },\n    // Add/Edit modal\n    modal: {\n      addTitle: '添加打印机',\n      editTitle: '编辑打印机',\n      myPrinter: '我的打印机',\n      selectModel: '选择型号...',\n      locationGroup: '位置 / 分组（可选）',\n      locationPlaceholder: '例如：工作室、办公室、地下室',\n      autoArchiveLabel: '自动归档已完成的打印',\n      fromPrinterSettings: '来自打印机设置',\n      modelOptional: '型号（可选）',\n      saveChanges: '保存更改',\n    },\n    // Skip objects\n    skipObjects: {\n      tooltip: '跳过对象',\n      onlyWhilePrinting: '跳过对象（仅在打印时）',\n      requiresMultiple: '跳过对象（需要2个以上对象）',\n      title: '跳过对象',\n      matchIdsInfo: '将 ID 与打印机显示屏上的 ID 进行对照',\n      printerShowsIds: '打印机屏幕上显示构建板上对象的 ID',\n      skipSelected: '跳过所选',\n      skipping: '跳过中...',\n      noObjectsSelected: '未选择对象',\n      selectObjectsToSkip: '选择要从当前打印中跳过的对象',\n      skipped: '已跳过',\n      objectsSkipped: '对象已跳过',\n      activeCount: '{{count}} 个活跃',\n      waitForLayer: '等待第2层以上才能跳过对象（当前第 {{layer}} 层）',\n      skip: '跳过',\n      confirmTitle: '跳过对象？',\n      confirmMessage: '确定要跳过\"{{name}}\"吗？此操作无法撤销。',\n    },\n    // Confirm modals\n    confirm: {\n      deleteTitle: '删除打印机',\n      deleteMessage: '确定要删除\"{{name}}\"吗？这将移除所有连接设置。',\n      deleteArchivesNote: '此打印机的所有打印历史将被永久删除。',\n      keepArchivesNote: '打印历史将保留，但不再与此打印机关联。',\n      stopTitle: '停止打印',\n      stopMessage: '确定要停止\"{{name}}\"上的当前打印吗？这将取消打印任务。',\n      stopButton: '停止打印',\n      pauseTitle: '暂停打印',\n      pauseMessage: '确定要暂停\"{{name}}\"上的当前打印吗？',\n      pauseButton: '暂停打印',\n      resumeTitle: '继续打印',\n      resumeMessage: '确定要继续\"{{name}}\"上的打印吗？',\n      resumeButton: '继续打印',\n      powerOnTitle: '开启打印机',\n      powerOnMessage: '确定要打开\"{{name}}\"的电源吗？',\n      powerOnButton: '开机',\n      powerOffTitle: '关闭打印机',\n      powerOffMessage: '确定要关闭\"{{name}}\"的电源吗？',\n      powerOffWarning: '警告：\"{{name}}\"正在打印中！确定要关闭电源吗？这将中断打印并可能损坏打印机。',\n      powerOffButton: '关机',\n    },\n    // Bulk actions\n    bulk: {\n      select: '选择',\n      selectAll: '全选',\n      selectByLocation: '按位置选择',\n      selected: '已选择{{count}}台',\n      actions: {\n        stop: '停止',\n        pause: '暂停',\n        resume: '继续',\n        clearPlate: '清除打印床',\n        clearHMS: '清除通知',\n      },\n      confirm: {\n        stopTitle: '停止{{count}}个打印任务',\n        stopMessage: '这将取消{{count}}台打印机上的活动打印任务。此操作无法撤销。',\n        stopButton: '全部停止',\n        pauseTitle: '暂停{{count}}个打印任务',\n        pauseMessage: '这将暂停{{count}}台打印机上的活动打印任务。',\n        pauseButton: '全部暂停',\n        clearPlateTitle: '清除{{count}}个打印床',\n        clearPlateMessage: '这将清除{{count}}台打印机的打印床，可能会触发队列中的任务。',\n        clearPlateButton: '全部清除',\n      },\n      success: '{{action}}已在{{count}}台打印机上完成',\n      partial: '{{succeeded}}成功，{{failed}}失败',\n      noneApplicable: '没有选中的打印机处于适合此操作的状态',\n      selectByState: '按状态选择',\n    },\n    // Discovery\n    discovery: {\n      title: '发现打印机',\n      searching: '搜索中...',\n      scanning: '扫描中...',\n      scanProgress: '扫描中... {{scanned}}/{{total}}',\n      foundPrinters: '发现 {{count}} 台打印机',\n      noPrintersFound: '未找到打印机',\n      noPrintersFoundSubnet: '在指定子网中未找到打印机。',\n      noPrintersFoundNetwork: '在网络上未找到打印机。',\n      allConfigured: '所有发现的打印机已配置完毕。',\n      alreadyAdded: '已添加',\n      select: '选择',\n      manualEntry: '手动输入',\n      addFromCloud: '从云端添加',\n      subnetToScan: '要扫描的子网',\n      dockerNote: '检测到 Docker 环境。请以 CIDR 格式输入打印机所在子网。需要在 docker-compose.yml 中设置 network_mode: host。',\n      scanSubnet: '扫描子网查找打印机',\n      discoverNetwork: '在网络上发现打印机',\n      scanningSubnet: '正在扫描子网查找拓竹打印机...',\n      scanningNetwork: '正在扫描网络...',\n      serialRequired: '需要序列号',\n      unknown: '未知',\n      failedToStart: '启动发现失败',\n    },\n    // AMS Drying\n    drying: {\n      start: '开始干燥',\n      stop: '停止干燥',\n      temperature: '温度',\n      duration: '时长',\n      hours: '小时',\n      timeRemaining: '剩余 {{time}}',\n      active: '干燥中',\n      notSupported: '不支持干燥',\n      powerRequired: '连接AMS电源适配器以启用干燥',\n      startingDrying: '正在启动干燥...',\n      stoppingDrying: '正在停止干燥...',\n      rotateTray: '干燥时旋转料盘',\n    },\n    // Filaments section\n    filaments: '耗材',\n    // Camera\n    openCameraOverlay: '打开摄像头叠加层',\n    openCameraWindow: '在新窗口中打开摄像头',\n    // Firmware\n    firmwareUpdateAvailable: '固件更新可用：{{current}} → {{latest}}',\n    firmwareUpToDate: '固件 {{version}} — 已是最新',\n    firmwareUpdateButton: '更新',\n    // Plate detection\n    plateDetection: {\n      noPermission: '您没有更新打印机的权限',\n      enabledClick: '打印板检查已启用 - 点击禁用',\n      disabledClick: '打印板检查已禁用 - 点击启用',\n      manageCalibration: '管理打印板检测校准',\n      calibrationRequired: '需要校准',\n      calibrationInstructions: '请确保构建板<strong>完全空置</strong>，然后点击校准。',\n      calibrationDescription: '校准会拍摄空置打印板的参考图像。后续检查将与此参考进行比较以检测物体。',\n      calibrationTip: '<strong>提示：</strong>您最多可以为不同的打印板存储5个校准。系统会在检查时自动使用最佳匹配。',\n      plateEmpty: '打印板似乎是空的',\n      objectsDetected: '在打印板上检测到物体',\n      confidence: '置信度',\n      difference: '差异',\n      analysisPreview: '分析预览：',\n      analysisLegend: '绿色框 = 检测区域，红色覆盖 = 与校准的差异',\n      savedReferences: '已保存的参考 ({{count}}/{{max}})',\n      deleteReference: '删除参考',\n      labelPlaceholder: '标签...',\n      clickToEdit: '{{label}} - 点击编辑',\n      clickToAddLabel: '点击添加标签',\n    },\n    // Speed\n    speed: {\n      title: '打印速度',\n      silent: '静音 (50%)',\n      standard: '标准 (100%)',\n      sport: '运动 (124%)',\n      ludicrous: '疯狂 (166%)',\n    },\n    airduct: {\n      title: '风道模式',\n      cooling: '制冷',\n      heating: '加热',\n    },\n    noSdCard: '无SD',\n    door: {\n      open: '开',\n      closed: '关',\n    },\n    // Fans\n    fans: {\n      partCooling: '零件冷却风扇',\n      auxiliary: '辅助风扇',\n      chamber: '腔室风扇',\n    },\n    // HMS errors\n    clickToViewHmsErrors: '点击查看 HMS 错误',\n    estimatedCompletion: '预计完成时间',\n    plateNumber: '板 {{number}}',\n    slotOptions: '槽位选项',\n    // AMS hover popup\n    amsPopup: {\n      friendlyName: 'AMS 名称',\n      friendlyNamePlaceholder: '例如 AMS 友好名称',\n      serialNumber: '序列号',\n      firmwareVersion: '固件',\n      save: '保存',\n      clear: '清除',\n      noEditPermission: '您没有重命名 AMS 单元的权限',\n    },\n    // Firmware modal\n    firmwareModal: {\n      title: '固件更新',\n      titleUpToDate: '固件信息',\n      currentVersion: '当前版本：',\n      latestVersion: '最新版本：',\n      releaseNotes: '发布说明',\n      checkingPrereqs: '正在检查前提条件...',\n      sdCardReady: 'SD 卡已就绪。点击下方上传固件。',\n      uploadedSuccess: '固件已上传到 SD 卡！',\n      applyInstructions: '在打印机上应用更新：',\n      step1: '在打印机触摸屏上，前往<strong>设置</strong>',\n      step2: '导航到<strong>固件</strong>',\n      step3: '选择<strong>从 SD 卡更新</strong>',\n      step4: '更新将需要 10-20 分钟',\n      done: '完成',\n      starting: '启动中...',\n      uploadFirmware: '上传固件',\n      uploadFailed: '上传启动失败：{{error}}',\n      uploadedToast: '固件已上传！请在打印机屏幕上触发更新。',\n      availableVersions: '可用版本',\n      usable: '可用',\n      unavailable: '不可用',\n      installed: '已安装',\n      newerBadge: '较新',\n      olderBadge: '较旧',\n      currentBadge: '当前',\n    },\n    accessCodePlaceholder: '留空以保持当前值',\n    // ROI editor\n    roi: {\n      title: '检测区域 (ROI)',\n      xStart: 'X 起点',\n      yStart: 'Y 起点',\n      width: '宽度',\n      height: '高度',\n      instruction: '调整检测区域以聚焦到构建板。预览中的绿色框显示当前区域。',\n    },\n    developerModeWarning: '以下打印机未启用开发者局域网模式：{{names}}。某些功能可能无法使用。',\n    howToEnable: '如何启用',\n    incompatibleFile: '此文件是为 {{slicedFor}} 切片的，但该打印机是 {{printerModel}}',\n    dropNotPrintable: '只能打印 .gcode 和 .gcode.3mf 文件',\n    dropToPrint: '拖放以打印',\n    cannotPrint: '打印机忙碌',\n  },\n\n  // Archives page\n  archives: {\n    title: '打印归档',\n    searchPlaceholder: '搜索归档...',\n    filterByPrinter: '按打印机筛选',\n    filterByStatus: '按状态筛选',\n    sortBy: '排序方式',\n    sortNewest: '最新优先',\n    sortOldest: '最旧优先',\n    sortName: '名称',\n    sortDuration: '时长',\n    sortLargest: '最大优先',\n    sortSmallest: '最小优先',\n    sortSize: '大小',\n    noArchives: '未找到归档',\n    noArchivesSearch: '没有匹配搜索的归档',\n    originalPrintNotVisible: '原始打印不可见 - 请尝试清除筛选条件',\n    noArchivesYet: '暂无归档',\n    prints: '条打印',\n    pagination: {\n      showing: '显示',\n      to: '至',\n      of: '共',\n      show: '每页',\n      page: '页',\n      all: '全部',\n    },\n    loadingArchives: '加载归档中...',\n    releaseToUpload: '释放以上传',\n    showAll: '显示全部',\n    showFavoritesOnly: '仅显示收藏',\n    gridView: '网格视图',\n    listView: '列表视图',\n    calendarView: '日历视图',\n    logView: '打印日志',\n    manageTags: '管理标签',\n    showFailedPrints: '显示失败的打印',\n    hideFailedPrints: '隐藏失败的打印',\n    hideDuplicates: '隐藏重复项',\n    viewOriginalPrint: '点击查看原始打印 (#{{id}})',\n    printTime: '打印时间',\n    filamentUsed: '耗材用量',\n    cost: '成本',\n    reprint: '重新打印',\n    preview: '预览',\n    deleteArchive: '删除归档',\n    deleteConfirm: '确定要删除此归档吗？',\n    favorite: '收藏',\n    unfavorite: '取消收藏',\n    viewDetails: '查看详情',\n    status: {\n      completed: '已完成',\n      failed: '失败',\n      stopped: '已停止',\n    },\n    toast: {\n      source3mfAttached: '源 3MF 已附加：{{filename}}',\n      failedUploadSource3mf: '上传源 3MF 失败',\n      source3mfRemoved: '源 3MF 已移除',\n      failedRemoveSource3mf: '移除源 3MF 失败',\n      f3dAttached: 'F3D 已附加：{{filename}}',\n      failedUploadF3d: '上传 F3D 失败',\n      f3dRemoved: 'F3D 已移除',\n      failedRemoveF3d: '移除 F3D 失败',\n      timelapseAttached: '延时摄影已附加：{{filename}}',\n      timelapseAlreadyAttached: '延时摄影已附加',\n      noMatchingTimelapse: '未找到匹配的延时摄影',\n      failedScanTimelapse: '扫描延时摄影失败',\n      failedAttachTimelapse: '附加延时摄影失败',\n      timelapseRemoved: '延时摄影已移除',\n      failedRemoveTimelapse: '移除延时摄影失败',\n      timelapseUploaded: '延时摄影已上传：{{filename}}',\n      failedUploadTimelapse: '上传延时摄影失败',\n      archiveDeleted: '归档已删除',\n      failedDeleteArchive: '删除归档失败',\n      addedToFavorites: '已添加到收藏',\n      removedFromFavorites: '已从收藏中移除',\n      projectUpdated: '项目已更新',\n      failedUpdateProject: '更新项目失败',\n      linkCopied: '链接已复制到剪贴板',\n      failedCopyLink: '复制链接失败',\n      photoDeleted: '照片已删除',\n      failedDeletePhoto: '删除照片失败',\n      failedDeleteArchives: '删除归档失败',\n      failedUpdateFavorites: '更新收藏失败',\n      exportDownloaded: '导出已下载',\n      exportFailed: '导出失败',\n    },\n    menu: {\n      print: '打印',\n      schedule: '排程',\n      openInBambuStudio: '在切片软件中打开',\n      slice: '切片',\n      externalLink: '外部链接',\n      viewOnMakerWorld: '在 MakerWorld 上查看',\n      preview3d: '3D 预览',\n      viewTimelapse: '查看延时摄影',\n      scanForTimelapse: '扫描延时摄影',\n      uploadTimelapse: '上传延时摄影',\n      removeTimelapse: '移除延时摄影',\n      downloadSource3mf: '下载源 3MF',\n      uploadSource3mf: '上传源 3MF',\n      replaceSource3mf: '替换源 3MF',\n      removeSource3mf: '移除源 3MF',\n      uploadF3d: '上传 F3D',\n      replaceF3d: '替换 F3D',\n      downloadF3d: '下载 F3D',\n      removeF3d: '移除 F3D',\n      download: '下载',\n      copyDownloadLink: '复制下载链接',\n      qrCode: '二维码',\n      viewPhotos: '查看照片',\n      viewPhotosCount: '查看照片 ({{count}})',\n      projectPage: '项目页面',\n      addToFavorites: '添加到收藏',\n      removeFromFavorites: '从收藏中移除',\n      edit: '编辑',\n      goToProject: '前往项目：{{name}}',\n      addToProject: '添加到项目',\n      removeFromProject: '从项目中移除',\n      loading: '加载中...',\n      noProjectsAvailable: '无可用项目',\n      select: '选择',\n      deselect: '取消选择',\n      delete: '删除',\n    },\n    permission: {\n      noReprint: '您没有重新打印此归档的权限',\n      noAddToQueue: '您没有添加到队列的权限',\n      noUpdateArchives: '您没有更新归档的权限',\n      noUploadFiles: '您没有上传文件的权限',\n      noDownload: '您没有下载归档的权限',\n      noCopyLink: '您没有复制下载链接的权限',\n      noDelete: '您没有删除此归档的权限',\n      noCreate: '您没有创建归档的权限',\n    },\n    card: {\n      previousPlate: '上一个板',\n      nextPlate: '下一个板',\n      plateNumber: '板 {{index}}',\n      moreOptions: '右键查看更多选项',\n      addToFavorites: '添加到收藏',\n      removeFromFavorites: '从收藏中移除',\n      cancelled: '已取消',\n      failed: '失败',\n      duplicate: '重复',\n      duplicateTitle: '此模型之前已打印过',\n      openSource3mf: '在 Bambu Studio 中打开源 3MF（右键查看更多选项）',\n      downloadF3d: '下载 Fusion 360 设计文件',\n      viewTimelapse: '查看延时摄影',\n      viewPhoto: '查看 1 张照片',\n      viewPhotos: '查看 {{count}} 张照片',\n      openFolder: '打开文件夹：{{name}}',\n      slicedFile: '已切片文件 - 可以打印',\n      sourceFile: '仅源文件 - 无 AMS 映射可用',\n      gcode: 'GCODE',\n      source: '源文件',\n      project: '项目：{{name}}',\n      estimated: '预计：{{time}}',\n      actual: '实际：{{time}}',\n      accuracy: '准确度：{{percent}}%',\n      filament: '{{weight}}g',\n      layer: '{{count}} 层',\n      layers: '{{count}} 层',\n      object: '{{count}} 个对象',\n      objects: '{{count}} 个对象',\n      slicedFor: '为 {{model}} 切片',\n      uploadedBy: '上传者',\n      noPermissionReprint: '您没有重新打印的权限',\n      noFileForReprint: '无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件',\n      noPermissionEdit: '您没有编辑归档的权限',\n      noPermissionDelete: '您没有删除归档的权限',\n      reprint: '重新打印',\n      schedulePrint: '排程打印',\n      schedule: '排程',\n      openInBambuStudio: '在切片软件中打开',\n      openInBambuStudioToSlice: '在切片软件中打开进行切片',\n      slice: '切片',\n      externalLink: '外部链接',\n      makerWorld: 'MakerWorld：{{designer}}',\n      viewProject: '查看项目',\n      noExternalLink: '无外部链接',\n      preview3d: '3D 预览',\n      download: '下载',\n      edit: '编辑',\n      delete: '删除',\n    },\n    modal: {\n      deleteArchive: '删除归档',\n      deleteConfirm: '确定要删除\"{{name}}\"吗？此操作无法撤销。',\n      deleteButton: '删除',\n      removeSource3mf: '移除源 3MF',\n      removeSource3mfConfirm: '确定要从\"{{name}}\"中移除源 3MF 文件吗？这将删除原始切片项目文件。',\n      removeButton: '移除',\n      removeF3d: '移除 F3D',\n      removeF3dConfirm: '确定要从\"{{name}}\"中移除 Fusion 360 设计文件吗？',\n      removeTimelapse: '移除延时摄影',\n      removeTimelapseConfirm: '确定要从\"{{name}}\"中移除延时摄影视频吗？',\n      timelapse: '{{name}} - 延时摄影',\n      selectTimelapse: '选择延时摄影',\n      selectTimelapseDesc: '未找到自动匹配。请选择此打印的延时摄影：',\n      deleteArchives: '删除归档',\n      deleteArchivesConfirm: '确定要删除 {{count}} 个归档吗？此操作无法撤销。',\n      deleteCount: '删除 {{count}} 个',\n    },\n    page: {\n      title: '归档',\n      printsCount: '{{filtered}} / {{total}} 次打印',\n      dropFilesHere: '将 .3mf 文件拖放到此处',\n      releaseToUpload: '释放以上传',\n      only3mfSupported: '仅支持 .3mf 文件',\n      close: '关闭',\n      selected: '已选择 {{count}} 个',\n      selectAll: '全选',\n      tags: '标签',\n      project: '项目',\n      favorite: '收藏',\n      delete: '删除',\n      toggledFavorites: '已切换 {{count}} 个归档的收藏状态',\n      failedUpdateFavorites: '更新收藏失败',\n      archivesDeleted: '已删除 {{count}} 个归档',\n      failedDeleteArchives: '删除归档失败',\n      photoDeleted: '照片已删除',\n      failedDeletePhoto: '删除照片失败',\n    },\n    list: {\n      name: '名称',\n      printer: '打印机',\n      date: '日期',\n      size: '大小',\n      actions: '操作',\n      hasTimelapse: '有延时摄影',\n    },\n    log: {\n      date: '日期',\n      printName: '打印名称',\n      printer: '打印机',\n      user: '用户',\n      status: '状态',\n      duration: '时长',\n      filament: '耗材',\n      allPrinters: '所有打印机',\n      allUsers: '所有用户',\n      allStatuses: '所有状态',\n      cancelled: '已取消',\n      skipped: '已跳过',\n      dateFrom: '从',\n      dateTo: '到',\n      noEntries: '未找到打印日志条目',\n      showing: '显示 {{count}} / {{total}} 条',\n      rowsPerPage: '行数',\n      page: '页',\n      prev: '上一页',\n      next: '下一页',\n      clearLog: '清除日志',\n      clearLogTitle: '清除打印日志',\n      clearLogConfirm: '所有打印日志条目将被永久删除。归档和队列项目不受影响。此操作无法撤销。确定要继续吗？',\n      clearLogButton: '全部清除',\n      cleared: '已清除 {{count}} 条日志',\n      clearFailed: '清除打印日志失败',\n    },\n  },\n\n  // Queue page\n  queue: {\n    title: '打印队列',\n    subtitle: '排程和管理您的打印任务',\n    addToQueue: '添加到队列',\n    // Print modal\n    print: '打印',\n    reprint: '重新打印',\n    schedulePrint: '排程打印',\n    editQueueItem: '编辑队列项目',\n    printToPrinters: '打印到 {{count}} 台打印机',\n    queueToPrinters: '排队到 {{count}} 台打印机',\n    queueSelectedPlates: '将 {{count}} 个热床加入队列',\n    selectAllPlates: '选择全部 {{count}} 个热床',\n    deselectAll: '取消全选',\n    printQueued: '已加入打印队列',\n    itemsQueued: '{{count}} 个任务已加入队列',\n    sending: '发送中...',\n    sendingProgress: '发送中 {{current}}/{{total}}...',\n    adding: '添加中...',\n    addingProgress: '添加中 {{current}}/{{total}}...',\n    savingProgress: '保存中 {{current}}/{{total}}...',\n    clearQueue: '清空队列',\n    clearHistory: '清除历史',\n    emptyQueue: '队列为空',\n    position: '位置',\n    scheduledTime: '排程时间',\n    moveUp: '上移',\n    moveDown: '下移',\n    startNow: '立即开始',\n    printingInProgress: '打印进行中...',\n    viewArchive: '查看归档',\n    viewInFileManager: '在文件管理器中查看',\n    itemCount: '{{count}} 个项目',\n    itemCount_plural: '{{count}} 个项目',\n    dragToReorder: '拖动以重新排序（仅限尽快）',\n    reorderHint: '位置仅影响\"尽快\"项目。排程项目按设定时间运行。',\n    sjf: {\n      label: 'SJF',\n      tooltip: '最短任务优先 — 调度器优先处理较短的打印任务',\n    },\n    addedBy: '由 {{name}} 添加',\n    nextInQueue: '队列中的下一个',\n    clearPlateSuccess: '打印板已清理 — 准备进行下一个打印',\n    plateNumber: '板 {{index}}',\n    // Batch / quantity\n    quantity: '数量',\n    quantityHint: '创建 {{count}} 个队列项目',\n    activeBatches: '活跃批次',\n    batchProgress: '已完成 {{completed}}/{{total}}',\n    cancelBatch: '取消剩余',\n    batchCancelled: '已取消剩余批次项目',\n    cancelBatchConfirmTitle: '取消批次',\n    cancelBatchConfirmMessage: '取消此批次中所有剩余的待处理项目？',\n    batch: '批次',\n    // Sections\n    sections: {\n      currentlyPrinting: '正在打印',\n      queued: '排队中',\n      history: '历史',\n    },\n    // Status\n    status: {\n      pending: '等待中',\n      waiting: '等待中',\n      printing: '打印中',\n      paused: '已暂停',\n      completed: '已完成',\n      failed: '失败',\n      skipped: '已跳过',\n      cancelled: '已取消',\n    },\n    // Summary cards\n    summary: {\n      printing: '打印中',\n      queued: '排队中',\n      totalTime: '总队列时间',\n      totalWeight: '总队列重量',\n      history: '历史',\n    },\n    // Filters\n    filter: {\n      allPrinters: '所有打印机',\n      unassigned: '未分配',\n      allStatus: '所有状态',\n      allLocations: '所有位置',\n      any: '任意',\n    },\n    // Sort\n    sort: {\n      byPosition: '按位置排序',\n      byName: '按名称排序',\n      byPrinter: '按打印机排序',\n      bySchedule: '按排程排序',\n      byDate: '按日期排序',\n      ascendingOldest: '升序（最旧优先）',\n      descendingNewest: '降序（最新优先）',\n    },\n    // Badges\n    badges: {\n      staged: '已暂存',\n      requiresPrevious: '需要前一个成功',\n      autoPowerOff: '自动关机',\n      gcodeInjection: 'G-code',\n    },\n    // Empty state\n    empty: {\n      title: '没有排程的打印',\n      description: '从归档页面使用右键菜单中的\"排程\"选项来排程打印，或拖放文件开始。',\n    },\n    // Time\n    time: {\n      asap: '尽快',\n      overdue: '已逾期',\n      now: '现在',\n      lessThanMinute: '不到一分钟',\n      inMinutes: '{{count}} 分钟后',\n      inHours: '{{count}} 小时后',\n    },\n    // Actions\n    actions: {\n      stopPrint: '停止打印',\n      startPrint: '开始打印',\n      requeue: '重新排队',\n    },\n    // Bulk edit\n    bulkEdit: {\n      title: '编辑 {{count}} 个项目',\n      title_plural: '编辑 {{count}} 个项目',\n      description: '仅更改的设置将应用于所选项目。',\n      printer: '打印机',\n      noChange: '— 不更改 —',\n      queueOptions: '队列选项',\n      staged: '暂存（手动开始）',\n      autoPowerOff: '打印后自动关机',\n      requirePrevious: '要求前一个成功',\n      printOptions: '打印选项',\n      bedLevelling: '热床调平',\n      flowCalibration: '流量校准',\n      vibrationCalibration: '振动校准',\n      layerInspection: '首层检查',\n      timelapse: '延时摄影',\n      useAms: '使用 AMS',\n      applyChanges: '应用更改',\n      selectAll: '全选',\n      deselectAll: '取消全选',\n      selected: '已选择 {{count}} 个',\n      editSelected: '编辑所选',\n      cancelSelected: '取消所选',\n    },\n    // Confirmations\n    confirm: {\n      cancelTitle: '取消排程打印',\n      cancelMessage: '确定要取消\"{{name}}\"吗？',\n      stopTitle: '停止打印',\n      stopMessage: '确定要停止当前打印\"{{name}}\"吗？这将取消打印机上的打印任务。',\n      removeTitle: '从历史中移除',\n      removeMessage: '确定要从队列历史中移除\"{{name}}\"吗？',\n      clearHistoryTitle: '清除历史',\n      clearHistoryMessage: '确定要从历史中移除所有 {{count}} 个项目吗？',\n      cancelButton: '取消打印',\n      stopButton: '停止打印',\n      thisPrint: '此打印',\n      thisItem: '此项目',\n    },\n    // Toast messages\n    toast: {\n      cancelled: '队列项目已取消',\n      cancelFailed: '取消项目失败',\n      removed: '队列项目已移除',\n      removeFailed: '移除项目失败',\n      stopped: '打印已停止',\n      stopFailed: '停止打印失败',\n      released: '打印已释放到队列',\n      startFailed: '开始打印失败',\n      reorderFailed: '重新排序队列失败',\n      historyCleared: '已清除 {{count}} 条历史记录',\n      clearHistoryFailed: '清除历史失败',\n      updateFailed: '更新项目失败',\n      bulkCancelled: '已取消 {{count}} 个项目',\n      bulkCancelFailed: '批量取消项目失败',\n    },\n    // Timeline view\n    timeline: {\n      listView: '列表',\n      timelineView: '时间线',\n      unassigned: '未分配',\n      noData: '当天没有计划的打印任务',\n      allDoneBy: '所有打印预计在 {{time}} 前完成',\n      staged: '暂存',\n      filterAll: '全部显示',\n      filterPrinting: '打印中',\n      filterQueued: '排队中',\n      time: {\n        anyMoment: '即将完成',\n        minutesLeft: '剩余{{minutes}}分钟',\n        hoursLeft: '剩余{{hours}}小时',\n        hoursMinutesLeft: '剩余{{hours}}小时{{minutes}}分钟',\n      },\n      day: {\n        previous: '前一天',\n        next: '后一天',\n        today: '今天',\n      },\n    },\n    // Permissions\n    permissions: {\n      noStopPrint: '您没有停止打印的权限',\n      noStartPrint: '您没有开始打印的权限',\n      noEdit: '您没有编辑此队列项目的权限',\n      noCancel: '您没有取消此队列项目的权限',\n      noRequeue: '您没有重新排队的权限',\n      noRemove: '您没有移除此队列项目的权限',\n      noClearHistory: '您没有清除所有历史的权限',\n      noEditItems: '您没有编辑队列项目的权限',\n      noCancelItems: '您没有取消队列项目的权限',\n    },\n  },\n\n  backgroundDispatch: {\n    unknownFile: '未知文件',\n    unknownPrinter: '未知打印机',\n    startingPrints: '正在开始打印',\n    progressSummary: '{{complete}}/{{total}} 完成 • 已分发：{{dispatched}} • 处理中：{{processing}}',\n    expandDetails: '展开分发详情',\n    collapseDetails: '收起分发详情',\n    dismissToast: '关闭分发通知',\n    cancelDispatchJob: '取消分发任务',\n    cancel: '取消',\n    cancelling: '取消中…',\n    status: {\n      dispatched: '已分发',\n      processing: '处理中',\n      completed: '已完成',\n      failed: '失败',\n      cancelled: '已取消',\n    },\n    toast: {\n      cancellingUpload: '取消上传中...',\n      cancelled: '分发已取消',\n      cancelFailed: '取消分发失败',\n      completeWithFailures: '后台分发完成：{{completed}} 成功，{{failed}} 失败',\n      completeSuccess: '后台分发完成：{{completed}} 成功',\n      printStartedRemaining: '{{completed}} 个打印已开始，{{remaining}} 个正在发送...',\n    },\n  },\n\n  // Statistics page\n  stats: {\n    title: '仪表板',\n    subtitle: '拖动小部件以重新排列。点击眼睛图标隐藏。',\n    overview: '概览',\n    totalPrints: '总打印次数',\n    successRate: '成功率',\n    totalPrintTime: '总打印时间',\n    printTime: '打印时间',\n    totalFilament: '总耗材用量',\n    filamentUsed: '耗材用量',\n    filamentCost: '耗材成本',\n    totalCost: '总成本',\n    energyUsed: '能耗',\n    energyCost: '能源成本',\n    energyWarmingUpTooltip: '能耗追踪正在收集每小时快照。当所选范围之前至少存在一个快照时，时间段合计将变得准确。早期数值可能偏低。',\n    averagePrintTime: '平均打印时间',\n    printsPerDay: '每日打印次数',\n    byPrinter: '按打印机',\n    printsByPrinter: '各打印机打印次数',\n    byMaterial: '按材料',\n    byMonth: '按月份',\n    last7Days: '最近 7 天',\n    last30Days: '最近 30 天',\n    last90Days: '最近 90 天',\n    allTime: '全部时间',\n    // Widgets\n    quickStats: '快速统计',\n    printActivity: '打印活动',\n    filamentTypes: '耗材类型',\n    filamentTrends: '耗材趋势',\n    failureAnalysis: '失败分析',\n    timeAccuracy: '时间准确度',\n    successful: '成功：',\n    failed: '失败：',\n    perfectEstimate: '100% = 完美估计',\n    noTimeAccuracyData: '暂无时间准确度数据',\n    noFilamentData: '暂无耗材数据',\n    noPrinterData: '暂无打印机数据',\n    noPrintData: '暂无打印数据',\n    noPrintDataLast30Days: '最近 30 天无打印数据',\n    failureReasons: '失败原因',\n    topFailureReasons: '主要失败原因',\n    failedPrintsCount: '{{failed}} / {{total}} 次打印失败',\n    lastWeekRate: '上周：{{rate}}%',\n    // Actions\n    resetLayout: '重置布局',\n    recalculateCosts: '重新计算成本',\n    recalculateCostsHint: '使用当前耗材价格重新计算所有归档成本',\n    exportStats: '导出统计',\n    exportAsCsv: '导出为 CSV',\n    exportAsExcel: '导出为 Excel',\n    hiddenCount: '{{count}} 个已隐藏',\n    // Toast\n    exportDownloaded: '导出已下载',\n    exportFailed: '导出失败',\n    layoutReset: '布局已重置',\n    recalculatedCosts: '已为 {{count}} 个归档重新计算成本',\n    recalculateFailed: '重新计算成本失败',\n    // Loading\n    loadingStats: '加载统计数据中...',\n    // Permissions\n    noPermissionResetLayout: '您没有重置布局的权限',\n    noPermissionRecalculate: '您没有重新计算成本的权限',\n    noPrintDataInRange: '所选范围内无打印数据',\n    periodFilament: '期间耗材',\n    periodCost: '期间成本',\n    avgPerPrint: '每次打印平均',\n    usageOverTime: '随时间的使用量',\n    filamentByWeight: '重量',\n    printDuration: '打印时长',\n    printerUtilization: '打印机利用率',\n    filamentSuccess: '按材料成功率',\n    printHabits: '打印习惯',\n    printTimeOfDay: '打印时段',\n    colorDistribution: '颜色分布',\n    noColorData: '暂无颜色数据',\n    records: '记录',\n    longestPrint: '最长打印',\n    heaviestPrint: '最重打印',\n    mostExpensivePrint: '最贵打印',\n    busiestDay: '最忙碌的一天',\n    successStreak: '连续成功',\n    streakPrint: '连续打印',\n    streakPrints: '{{count}} 次连续打印',\n    printerStats: '打印机统计',\n    hours: '小时',\n    avgPrints: '平均打印',\n    noArchiveData: '暂无打印数据',\n    filamentByTime: '时间',\n    avgWeight: '平均重量',\n    avgTime: '平均时间',\n    filamentByPrints: '打印次数',\n    timeframe: {\n      today: '今天',\n      'this-week': '本周',\n      'this-month': '本月',\n      'last-7': '最近 7 天',\n      'last-30': '最近 30 天',\n      'last-90': '最近 90 天',\n      'this-year': '今年',\n      'all-time': '全部时间',\n      custom: '自定义范围',\n      from: '从',\n      to: '到',\n    },\n    allUsers: '所有用户',\n    noUser: '无用户（系统）',\n    filterByUser: '按用户筛选',\n  },\n\n  // Maintenance page\n  maintenance: {\n    title: '维护',\n    overview: '概览',\n    allOk: '所有维护均已完成',\n    dueCount: '{{count}} 项到期',\n    dueCount_plural: '{{count}} 项到期',\n    warningCount: '{{count}} 个警告',\n    warningCount_plural: '{{count}} 个警告',\n    totalPrintTime: '总打印时间',\n    nextMaintenance: '下次维护',\n    nothingDue: '无到期项目',\n    tasks: '任务',\n    lastPerformed: '上次执行',\n    interval: '间隔',\n    hoursRemaining: '剩余 {{hours}} 小时',\n    hoursOverdue: '逾期 {{hours}} 小时',\n    markDone: '标记为完成',\n    performMaintenance: '执行维护',\n    history: '历史',\n    noHistory: '无维护历史',\n    editPrintHours: '编辑打印时间',\n    currentHours: '当前小时数',\n    // Tabs\n    statusTab: '状态',\n    settingsTab: '设置',\n    // Status\n    overdueCount: '{{count}} 个逾期',\n    dueSoonCount: '{{count}} 个即将到期',\n    dueSoon: '即将到期',\n    allGood: '一切正常',\n    overdueBy: '逾期 {{duration}}',\n    dueIn: '{{duration}} 后到期',\n    timeLeft: '剩余 {{duration}}',\n    // Duration formats\n    day: '1 天',\n    days: '{{count}} 天',\n    week: '1 周',\n    weeks: '{{count}} 周',\n    month: '1 个月',\n    months: '{{count}} 个月',\n    year: '1 年',\n    // Settings\n    maintenanceTypes: '维护类型',\n    maintenanceTypesDescription: '系统类型和您的自定义维护任务',\n    addCustomType: '添加自定义类型',\n    restoreDefaults: '恢复默认任务',\n    intervalType: '间隔类型',\n    intervalValue: '间隔 ({{type}})',\n    icon: '图标',\n    documentationLink: '文档链接（可选）',\n    assignToPrinters: '分配给打印机',\n    selectAtLeastOnePrinter: '至少选择一台打印机',\n    addType: '添加类型',\n    custom: '自定义',\n    printHours: '打印小时数',\n    calendarDays: '日历天数',\n    exampleName: '例如：更换 HEPA 过滤器',\n    viewDocumentation: '查看文档',\n    timeBasedInterval: '基于时间的间隔',\n    // Interval overrides\n    intervalOverrides: '间隔覆盖',\n    intervalOverridesDescription: '为特定打印机自定义间隔',\n    // Printer assignment\n    assignedToPrinters: '已分配给打印机：',\n    noPrintersAssigned: '未分配打印机',\n    addPrinterShort: '添加：',\n    printersAssignedClick: '已分配 {{count}} 台打印机 - 点击管理',\n    removeFromPrinter: '从此打印机移除',\n    // Types\n    types: {\n      lubricateCarbonRods: '润滑碳纤维杆',\n      lubricateRails: '润滑线性导轨',\n      cleanNozzle: '清洁喷嘴/热端',\n      checkBelts: '检查皮带张力',\n      cleanBuildPlate: '清洁构建板',\n      checkExtruder: '检查挤出机齿轮',\n      checkCooling: '检查冷却风扇',\n      generalInspection: '综合检查',\n      cleanCarbonRods: '清洁碳纤维杆',\n      lubricateSteelRods: '润滑钢杆',\n      cleanSteelRods: '清洁钢杆',\n      cleanLinearRails: '清洁线性导轨',\n      checkPtfeTube: '检查 PTFE 管',\n      replaceHepaFilter: '更换 HEPA 过滤器',\n      replaceCarbonFilter: '更换活性炭过滤器',\n      lubricateLeftNozzleRail: '润滑左喷嘴导轨',\n    },\n    // Toast\n    maintenanceComplete: '维护已标记为完成',\n    typeUpdated: '维护类型已更新',\n    typeDeleted: '维护类型已删除',\n    defaultsRestored: '已恢复 {{count}} 个默认任务',\n    printHoursUpdated: '打印小时数已更新',\n    printerAssigned: '打印机已分配',\n    printerRemoved: '打印机已移除',\n    // Confirmation\n    deleteTypeConfirm: '删除\"{{name}}\"？',\n    deleteSystemTypeTitle: '删除默认维护任务？',\n    deleteSystemTypeMessage: '确定要删除默认维护任务\"{{name}}\"吗？',\n    // Permissions\n    noPermissionUpdate: '您没有更新维护项目的权限',\n    noPermissionPerform: '您没有执行维护的权限',\n    noPermissionEditTypes: '您没有编辑维护类型的权限',\n    noPermissionDeleteTypes: '您没有删除维护类型的权限',\n    noPermissionEditHours: '您没有编辑打印时间的权限',\n    noPermissionRemovePrinter: '您没有移除打印机分配的权限',\n    noPermissionAssignPrinter: '您没有分配打印机的权限',\n    noPermissionEditIntervals: '您没有编辑间隔的权限',\n    // Configure link\n    configureSettings: '配置维护类型和间隔',\n  },\n\n  // Settings page\n  settings: {\n    title: '设置',\n    general: '通用',\n    // Tab names\n    tabs: {\n      general: '通用',\n      smartPlugs: '智能插座',\n      notifications: '通知',\n      queue: '工作流',\n      filament: '耗材',\n      network: '网络',\n      apiKeys: 'API 密钥',\n      virtualPrinter: '虚拟打印机',\n      spoolbuddy: 'SpoolBuddy',\n      failureDetection: '故障检测',\n      users: '身份验证',\n      backup: '备份',\n      emailAuth: '邮箱认证',\n      ldap: 'LDAP',\n      twoFa: '双因素认证',\n      oidc: 'SSO / OIDC',\n    },\n    spoolbuddy: {\n      infoTitle: 'SpoolBuddy 设备',\n      infoBody: 'SpoolBuddy kiosk 通过心跳自动注册。如果设备不再使用，或守护进程崩溃遗留了陈旧的重复项，可在此注销。',\n      duplicatesTitle: '已注册 {{count}} 台设备',\n      duplicatesBody: 'kiosk 界面只使用最先注册的设备。如果其中有因崩溃遗留的陈旧重复项，请注销它——在线设备会在下次心跳时重新注册自己。',\n      empty: '尚未注册任何 SpoolBuddy 设备。',\n      online: '在线',\n      offline: '离线',\n      unregister: '注销',\n      unregisterSuccess: '设备已注销',\n      unregisterError: '注销设备失败',\n      confirmTitle: '注销 SpoolBuddy 设备？',\n      confirmBody: '将从数据库中移除 \"{{hostname}}\" ({{deviceId}})。如果设备在线，会在下次心跳时重新注册自己。',\n      ipAddress: 'IP 地址',\n      firmware: '固件',\n      lastSeen: '上次在线',\n      daemonUptime: '守护进程运行时间',\n      systemUptime: '系统运行时间',\n      never: '从未',\n      nfc: 'NFC',\n      scale: '秤',\n      cpuTemp: 'CPU 温度',\n      memory: '内存',\n      disk: '磁盘',\n      // Device actions\n      update: '更新',\n      updateConfirmTitle: '更新 SpoolBuddy 守护进程？',\n      updateConfirmBody: '对 \"{{hostname}}\" 触发软件更新？更新完成后守护进程将重启。',\n      restartBrowser: '重启浏览器',\n      restartBrowserConfirmTitle: '重启 kiosk 浏览器？',\n      restartBrowserConfirmBody: '在 \"{{hostname}}\" 上重启 kiosk 浏览器？显示将短暂黑屏。',\n      restartDaemon: '重启守护进程',\n      restartDaemonConfirmTitle: '重启 SpoolBuddy 守护进程？',\n      restartDaemonConfirmBody: '在 \"{{hostname}}\" 上重启 SpoolBuddy 守护进程？设备将离线几秒钟。',\n      reboot: '重启',\n      rebootConfirmTitle: '重启设备？',\n      rebootConfirmBody: '重启 \"{{hostname}}\"？设备将离线约一分钟。',\n      shutdown: '关机',\n      shutdownConfirmTitle: '关闭设备？',\n      shutdownConfirmBody: '关闭 \"{{hostname}}\"？您需要物理访问才能重新开机。',\n      commandConfirm: '确认',\n      commandQueued: '命令已加入队列',\n      commandError: '发送命令失败',\n    },\n    ldap: {\n      title: 'LDAP 认证',\n      enabledDesc: 'LDAP 认证已启用',\n      disabledDesc: 'LDAP 认证已禁用',\n      disabledHint: '在下方配置并保存 LDAP 设置，然后启用。',\n      enabled: 'LDAP 认证已启用',\n      disabled: 'LDAP 认证已禁用',\n      feature1: '用户可以使用 LDAP 凭据登录',\n      feature2: '本地管理员帐户作为后备保留',\n      feature3: '登录时 LDAP 组映射到 BamBuddy 组',\n      serverConfig: 'LDAP 服务器配置',\n      serverUrl: '服务器 URL',\n      serverUrlHint: '使用 ldap:// 进行标准连接或 ldaps:// 进行 SSL 连接',\n      security: '安全',\n      securityHint: 'StartTLS 将普通连接升级为 TLS。LDAPS 从一开始就使用 TLS。',\n      bindDn: '绑定 DN（服务帐户）',\n      bindPassword: '绑定密码',\n      searchBase: '搜索基础 DN',\n      userFilter: '用户搜索过滤器',\n      userFilterHint: '{username} 替换为登录用户名。OpenLDAP 使用 (uid={username})。',\n      autoProvision: '自动创建用户',\n      autoProvisionHint: '首次 LDAP 登录时自动创建 BamBuddy 帐户',\n      defaultGroup: '默认组',\n      defaultGroupNone: '— 无（无回退）—',\n      defaultGroupHint: '当 LDAP 用户通过身份验证但不在任何已映射的 LDAP 组中时分配的回退组。留空以使未映射的用户没有权限。',\n      groupMapping: '组映射（JSON）',\n      groupMappingHint: '将 LDAP 组 DN 映射到 BamBuddy 组。可用组：',\n      testConnection: '测试连接',\n      settingsSaved: 'LDAP 设置已保存',\n      errors: {\n        serverRequired: 'LDAP 服务器 URL 为必填项',\n        searchBaseRequired: '搜索基础 DN 为必填项',\n        enableAuthFirst: '请先启用认证',\n        configureLdapFirst: '请先保存 LDAP 设置',\n      },\n    },\n    // Email settings\n    email: {\n      smtpSettings: 'SMTP 配置',\n      smtpHost: 'SMTP 服务器',\n      smtpPort: 'SMTP 端口',\n      security: '安全',\n      authentication: '认证',\n      username: '用户名',\n      password: '密码',\n      fromEmail: '发件邮箱',\n      fromName: '发件人名称',\n      testConnection: '测试 SMTP 连接',\n      testRecipient: '测试收件邮箱',\n      sendTest: '发送测试邮件',\n      sending: '发送中...',\n      save: '保存设置',\n      saving: '保存中...',\n      advancedAuth: '高级认证',\n      advancedAuthEnabled: '高级认证已启用',\n      advancedAuthEnabledDesc: '基于邮箱的用户管理功能已激活。新用户将通过邮件收到自动生成的密码，用户可以通过忘记密码功能重置密码。',\n      advancedAuthDisabled: '高级认证已禁用',\n      advancedAuthDisabledDesc: '启用高级认证以激活基于邮箱的用户管理功能。',\n      enable: '启用',\n      disable: '禁用',\n      feature1: '密码自动生成并通过邮件发送给新用户',\n      feature2: '用户可以使用用户名或邮箱登录',\n      feature3: '忘记密码功能可用',\n      feature4: '管理员可以通过邮件重置用户密码',\n      // Error messages\n      errors: {\n        requiredFields: '请填写所有必填字段',\n        usernameRequired: '启用认证时需要用户名',\n        enterTestEmail: '请输入测试邮箱地址',\n        smtpServerAndEmail: '测试前请填写 SMTP 服务器和发件邮箱',\n        usernamePasswordRequired: '启用认证时需要用户名和密码',\n        configureSmtpFirst: '请先配置并测试 SMTP 设置',\n        enableAuthFirst: '请先启用身份验证才能使用基于电子邮件的功能。',\n      },\n      // Success messages\n      success: {\n        settingsSaved: 'SMTP 设置保存成功',\n      },\n      // Security options\n      securityOptions: {\n        starttls: 'STARTTLS（端口 587）',\n        ssl: 'SSL/TLS（端口 465）',\n        none: '无（端口 25）',\n      },\n      // Authentication options\n      authOptions: {\n        enabled: '已启用',\n        disabled: '已禁用',\n      },\n    },\n    appearance: '外观',\n    notifications: '通知',\n    smartPlugs: '智能插座',\n    spoolman: 'Spoolman',\n    updates: '更新',\n    language: '语言',\n    languageDescription: '选择您的首选语言',\n    theme: '主题',\n    themeLight: '浅色',\n    themeDark: '深色',\n    themeSystem: '跟随系统',\n    defaultView: '默认视图',\n    defaultViewDescription: '打开应用时显示的页面',\n    checkForUpdates: '检查更新',\n    autoUpdate: '自动更新',\n    currentVersion: '当前版本',\n    latestVersion: '最新版本',\n    upToDate: '已是最新版本',\n    updateAvailable: '有可用更新',\n    // Notifications\n    notificationLanguage: '通知语言',\n    notificationLanguageDescription: '推送通知的语言',\n    bedCooledThreshold: '热床冷却阈值',\n    bedCooledThresholdDescription: '打印后热床被视为已冷却的温度',\n    userNotificationsEnabled: '用户通知',\n    userNotificationsEnabledDescription: '启用用户通知菜单和打印任务事件的邮件通知。需要高级身份验证。',\n    userNotificationsDisabledHint: '请启用高级身份验证以使用用户通知。',\n    notificationProviders: '通知提供商',\n    addProvider: '添加提供商',\n    editProvider: '编辑提供商',\n    providerType: '提供商类型',\n    testNotification: '测试通知',\n    testSuccess: '测试通知发送成功',\n    testFailed: '发送测试通知失败',\n    quietHours: '免打扰时间',\n    quietHoursDescription: '在此时间段内不发送通知',\n    quietHoursStart: '开始',\n    quietHoursEnd: '结束',\n    events: {\n      title: '通知事件',\n      printStart: '打印开始',\n      printComplete: '打印完成',\n      printFailed: '打印失败',\n      printStopped: '打印停止',\n      printProgress: '进度里程碑',\n      printProgressDescription: '在 25%、50%、75% 时通知',\n      printerOffline: '打印机离线',\n      printerError: '打印机错误',\n      filamentLow: '耗材不足',\n      maintenanceDue: '维护到期',\n      maintenanceDueDescription: '需要维护时通知',\n    },\n    // Smart Plugs\n    smartPlug: {\n      title: '智能插座',\n      add: '添加智能插座',\n      edit: '编辑智能插座',\n      name: '名称',\n      ipAddress: 'IP 地址',\n      linkedPrinter: '关联打印机',\n      autoOn: '自动开启',\n      autoOnDescription: '打印开始时开启',\n      autoOff: '自动关闭',\n      autoOffDescription: '打印完成后关闭',\n      offDelay: '关闭延迟',\n      offDelayMinutes: '打印后分钟数',\n      offDelayTemp: '当喷嘴温度低于',\n      currentState: '当前状态',\n      turnOn: '开启',\n      turnOff: '关闭',\n    },\n    // Filament Tracking Mode\n    filamentTracking: '耗材追踪',\n    filamentTrackingDesc: '选择如何追踪您的耗材。您可以使用内置库存或连接外部 Spoolman 服务器。',\n    filamentChecks: '耗材检查',\n    disableFilamentWarnings: '禁用耗材警告',\n    disableFilamentWarningsDesc: '在打印或加入队列时不显示耗材不足警告',\n    preferLowestFilament: '优先使用剩余最少的耗材',\n    preferLowestFilamentDesc: '当多个料盘匹配时，使用剩余耗材最少的那个',\n    trackingModeBuiltIn: '内置库存',\n    trackingModeBuiltInDesc: '包含 RFID 自动匹配和用量追踪',\n    trackingModeSpoolmanDesc: '外部耗材管理服务器',\n    builtInFeatureRfid: '自动检测 AMS 中的拓竹 RFID 耗材',\n    builtInFeatureUsage: '追踪每次打印的耗材消耗',\n    builtInFeatureCatalog: '管理耗材、颜色和 K 值配置文件',\n    builtInFeatureThirdParty: '第三方耗材可分配到库存耗材',\n    amsSyncButton: '从 AMS 同步重量',\n    amsSyncTitle: '从 AMS 同步耗材重量',\n    amsSyncMessage: '这将使用已连接打印机的当前 AMS 剩余百分比值覆盖所有库存耗材重量。用于从损坏的重量数据中恢复。打印机必须在线。',\n    amsSyncing: '同步中...',\n    amsSyncSuccess: '已同步 {{synced}} 个耗材，跳过 {{skipped}} 个',\n    amsSyncError: '从 AMS 同步重量失败',\n    // Spoolman settings\n    spoolmanUrl: 'Spoolman URL',\n    spoolmanUrlHint: 'Spoolman 服务器的 URL（例如 http://localhost:7912）',\n    spoolmanConnected: '已连接',\n    spoolmanDisconnected: '未连接',\n    status: '状态',\n    connect: '连接',\n    disconnect: '断开',\n    howSyncWorks: '同步工作原理',\n    syncInfoRfidOnly: '仅同步带有 RFID 的官方拓竹耗材',\n    syncInfoAutoCreate: '首次同步时自动在 Spoolman 中创建新耗材',\n    syncInfoThirdPartySkipped: '非拓竹耗材（第三方、重新填充的）将被跳过',\n    linkingExistingSpools: '链接现有耗材',\n    linkingExistingSpoolsDesc: '要将现有的 Spoolman 耗材链接到您的 AMS，请将鼠标悬停在 AMS 槽位上并点击\"链接到 Spoolman\"。',\n    syncMode: '同步模式',\n    syncModeAuto: '自动',\n    syncModeManual: '仅手动',\n    syncModeAutoDesc: '检测到更改时自动同步 AMS 数据',\n    syncModeManualDesc: '仅在手动触发时同步',\n    syncAmsData: '同步 AMS 数据',\n    syncAmsDataDesc: '手动将打印机 AMS 数据同步到 Spoolman',\n    allPrinters: '所有打印机',\n    // Default printer\n    noDefaultPrinter: '无默认（每次询问）',\n    // Sidebar\n    sidebarOrder: '侧边栏顺序',\n    // Camera\n    saveThumbnails: '保存缩略图',\n    captureFinishPhoto: '拍摄完成照片',\n    noPrintersConfigured: '未配置打印机',\n    // Archive settings\n    archiveMode: {\n      always: '始终创建归档条目',\n      never: '从不创建归档条目',\n      ask: '每次询问',\n    },\n    // Updates\n    checkForUpdatesLabel: '检查更新',\n    checkPrinterFirmware: '检查打印机固件',\n    includeBetaUpdates: '包含测试版本',\n    includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',\n    // Queue\n    enableRetry: '启用重试',\n    // Home Assistant\n    homeAssistantDescription: '通过 Home Assistant 控制智能插座',\n    environmentManagedLabel: '（环境变量管理）',\n    autoEnabledViaEnv: '通过环境变量自动启用',\n    urlFromEnvReadOnly: '值由 HA_URL 环境变量设置（只读）',\n    tokenFromEnvReadOnly: '值由 HA_TOKEN 环境变量设置（只读）',\n    // MQTT\n    mqttConnectedTo: '已连接到',\n    // Prometheus\n    prometheusDescription: '以 Prometheus 格式暴露打印机数据',\n    // Smart plugs empty state\n    noSmartPlugsTitle: '未配置智能插座',\n    noSmartPlugsDescription: '添加基于 Tasmota 的智能插座以追踪能耗并自动化电源控制。',\n    // Notifications empty state\n    noProvidersTitle: '未配置提供商',\n    noProvidersDescription: '添加提供商以接收警报。',\n    noTemplatesAvailable: '无可用模板。重启后端以加载默认模板。',\n    // API permissions\n    apiPermissionView: '查看打印机状态和队列',\n    apiPermissionEdit: '添加和移除打印队列中的项目',\n    // API keys\n    apiKeysEmptyTitle: '无 API 密钥',\n    apiKeysEmptyDescription: '创建 API 密钥以与外部服务集成。',\n    // Users\n    noUsersFound: '未找到用户',\n    noGroupsFound: '未找到组',\n    noGroupsAvailable: '无可用组',\n    passwordsDoNotMatch: '密码不匹配',\n    systemGroupWarning: '系统组名称不可更改',\n    // Auth disabled\n    authDisabledTitle: '身份验证已禁用',\n    authDisabledFeature1: '需要登录才能访问系统',\n    authDisabledFeature2: '创建多个用户并基于组的权限管理',\n    authDisabledFeature3: '使用 50+ 个细粒度权限控制访问',\n    // User deletion\n    userHasCreated: '此用户已创建：',\n    userItemsQuestion: '您想如何处理这些项目？',\n    deleteUserConfirm: '确定要删除此用户吗？',\n    actionCannotBeUndone: '此操作无法撤销。',\n    // Smart plugs\n    addFirstSmartPlug: '添加您的第一个智能插座',\n    // Notifications\n    providers: '提供商',\n    log: '日志',\n    testAll: '全部测试',\n    testResults: '测试结果',\n    testPassedCount: '{{count}} 个通过',\n    testFailedCount: '{{count}} 个失败',\n    messageTemplates: '消息模板',\n    messageTemplatesDescription: '自定义每个事件的通知消息。',\n    // API Keys section\n    apiKeys: 'API 密钥',\n    apiKeysDescription: '创建 API 密钥用于外部集成和 Webhook。',\n    createKey: '创建密钥',\n    apiKeyCreated: 'API 密钥创建成功',\n    apiKeyCopyWarning: '请立即复制此密钥 - 它不会再次显示！',\n    useInApiBrowser: '在 API 浏览器中使用',\n    createNewApiKey: '创建新 API 密钥',\n    keyName: '密钥名称',\n    keyNamePlaceholder: '例如：Home Assistant、OctoPrint',\n    readStatus: '读取状态',\n    readStatusDescription: '查看打印机状态和队列',\n    manageQueue: '管理队列',\n    manageQueueDescription: '添加和移除打印队列中的项目',\n    controlPrinter: '控制打印机',\n    controlPrinterDescription: '暂停、继续和停止打印',\n    unnamedKey: '未命名密钥',\n    lastUsed: '上次使用',\n    read: '读取',\n    control: '控制',\n    createFirstKey: '创建您的第一个密钥',\n    webhookEndpoints: 'Webhook 端点',\n    webhookApiKeyHint: '在 X-API-Key 请求头中使用您的 API 密钥。',\n    webhook: {\n      getAllStatus: '获取所有打印机状态',\n      getSpecificStatus: '获取特定打印机状态',\n      addToQueue: '添加到打印队列',\n      pausePrint: '暂停打印',\n      resumePrint: '继续打印',\n      stopPrint: '停止打印',\n    },\n    apiBrowser: 'API 浏览器',\n    apiBrowserDescription: '浏览和测试所有可用的 API 端点。',\n    apiKeyForTesting: '测试用 API 密钥',\n    apiKeyPlaceholder: '在此粘贴您的 API 密钥以测试需要认证的端点...',\n    apiKeyHint: '此密钥将作为 X-API-Key 请求头随请求发送。',\n    deleteApiKeyTitle: '删除 API 密钥',\n    deleteApiKeyMessage: '确定要删除此 API 密钥吗？使用此密钥的所有集成将停止工作。',\n    deleteKey: '删除密钥',\n    // Filament tab\n    amsDisplayThresholds: 'AMS 显示阈值',\n    amsThresholdsDescription: '配置 AMS 湿度和温度指示器的颜色阈值。',\n    humidity: '湿度',\n    goodGreen: '良好（绿色）',\n    fairOrange: '一般（橙色）',\n    aboveFairBad: '超过一般阈值显示为红色（差）',\n    fairAlsoDryingThreshold: '此阈值也用于触发自动干燥',\n    temperature: '温度',\n    goodBlue: '良好（蓝色）',\n    aboveFairHot: '超过一般阈值显示为红色（热）',\n    historyRetention: '历史保留',\n    keepSensorHistory: '保留传感器历史',\n    historyRetentionDescription: '较旧的湿度和温度数据将被自动删除',\n    defaultPrintOptions: '默认打印选项',\n    defaultPrintOptionsDescription: '设置新打印的默认选项值。可在打印对话框中逐次覆盖。',\n    defaultBedLevelling: '热床调平',\n    defaultBedLevellingDesc: '打印前自动调平热床',\n    defaultFlowCali: '流量校准',\n    defaultFlowCaliDesc: '校准挤出流量',\n    defaultVibrationCali: '振动校准',\n    defaultVibrationCaliDesc: '减少振纹伪影',\n    defaultLayerInspect: '首层检测',\n    defaultLayerInspectDesc: 'AI首层检测',\n    defaultTimelapse: '延时摄影',\n    defaultTimelapseDesc: '录制延时摄影视频',\n    staggeredStart: 'Staggered Start',\n    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',\n    plateClear: '热床清空确认',\n    requirePlateClear: '需要热床清空确认',\n    requirePlateClearDescription: '启用后，调度器会在已完成打印的打印机上启动排队打印之前，等待每台打印机的热床清空确认。禁用后，也会隐藏打印机卡片上的打印板状态标记和“将打印板标记为已清理”按钮。',\n    gcodeInjection: 'G-code注入',\n    gcodeInjectionDescription: '为Farmloop、SwapMod、AutoClear和Printflow 3D等自动打印系统配置自定义G-code，在打印开始和/或结束时注入。代码片段按打印机型号配置，在队列项目上启用\"注入G-code\"时应用。',\n    gcodeInjectionNoPrinters: '未找到打印机。添加打印机以配置G-code代码片段。',\n    gcodeStartLabel: '开始G-code',\n    gcodeEndLabel: '结束G-code',\n    gcodeStartPlaceholder: '在打印开始前插入的G-code...',\n    gcodeEndPlaceholder: '在打印结束后追加的G-code...',\n    staggerGroupSize: 'Group size',\n    staggerGroupSizeHelp: 'Printers to start simultaneously per group',\n    staggerInterval: 'Interval (minutes)',\n    staggerIntervalHelp: 'Delay between each group starting',\n    queueDrying: '自动干燥',\n    queueDryingDescription: '在队列打印之间，打印机空闲时自动干燥AMS耗材。使用上方的湿度阈值触发干燥。',\n    queueDryingEnabled: '启用自动干燥',\n    queueDryingEnabledDescription: '当打印机空闲且湿度超过阈值时，自动启动AMS干燥',\n    queueDryingBlock: '等待干燥完成',\n    queueDryingBlockDescription: '阻止打印队列直到干燥完成。关闭时，打印优先于干燥。',\n    ambientDryingEnabled: '环境干燥',\n    ambientDryingEnabledDescription: '当空闲打印机的湿度超过阈值时自动干燥耗材，无需排队打印。',\n    dryingPresets: '干燥预设',\n    dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度，AMS-HT支持较高温度。',\n    dryingFilament: '耗材',\n    printModal: '打印对话框',\n    expandCustomMapping: '默认展开自定义映射',\n    expandCustomMappingDescription: '打印到多台打印机时，默认展开显示每台打印机的 AMS 映射',\n    // User management\n    authentication: '身份验证',\n    authEnabledDescription: '您的实例已通过用户身份验证保护',\n    authDisabledDescription: '启用以要求登录并管理用户访问',\n    authDisabledMessage: '启用身份验证以创建用户账户、管理权限并保护您的 Bambuddy 实例。',\n    enableAuthentication: '启用身份验证',\n    currentUser: '当前用户',\n    changePassword: '修改密码',\n    admin: '管理员',\n    users: '用户',\n    addUser: '添加用户',\n    groups: '组',\n    addGroup: '添加组',\n    system: '系统',\n    noDescription: '无描述',\n    userCount: '{{count}} 个用户',\n    permissionCount: '{{count}} 个权限',\n    createUser: '创建用户',\n    username: '用户名',\n    enterUsername: '输入用户名',\n    password: '密码',\n    enterPassword: '输入密码（至少 6 个字符）',\n    confirmPassword: '确认密码',\n    confirmPasswordPlaceholder: '确认密码',\n    // Title tooltips\n    viewReleaseOnGitHub: '在 GitHub 上查看发布',\n    turnAllPlugsOn: '开启所有插座',\n    turnAllPlugsOff: '关闭所有插座',\n    // Modal: Clear logs\n    clearNotificationLogs: '清除通知日志',\n    clearLogsMessage: '这将永久删除所有 30 天前的通知日志。此操作无法撤销。',\n    clearLogs: '清除日志',\n    // Modal: Reset UI\n    resetUiPreferences: '重置 UI 偏好',\n    resetUiPreferencesMessage: '这将重置所有 UI 偏好为默认值：侧边栏顺序、主题、仪表板布局、视图模式和排序偏好。您的打印机、归档和服务器设置不会受到影响。清除后页面将重新加载。',\n    resetPreferences: '重置偏好',\n    // Modal: Delete group\n    deleteGroupTitle: '删除组',\n    deleteGroupMessage: '确定要删除此组吗？此组中的用户将失去这些权限。',\n    deleteGroup: '删除组',\n    // Modal: Disable auth\n    disableAuthenticationTitle: '禁用身份验证',\n    disableAuthenticationMessage: '确定要禁用身份验证吗？这将使您的 Bambuddy 实例无需登录即可访问。所有用户将保留在数据库中但身份验证将被禁用。',\n    disableAuthentication: '禁用身份验证',\n    // Additional settings\n    configureBambuddy: '配置 Bambuddy',\n    systemDefault: '系统默认',\n    archiveSettings: '归档设置',\n    newWindow: '新窗口',\n    embeddedOverlay: '嵌入式叠加层',\n    preferredSlicer: '首选切片软件',\n    preferredSlicerDescription: '选择要用于打开文件的切片软件',\n    externalCameras: '外部摄像头',\n    costTracking: '成本追踪',\n    printsOnly: '仅打印',\n    totalConsumption: '总消耗',\n    dataManagement: '数据管理',\n    storageUsage: '存储使用情况',\n    storageUsageDescription: '按类别的数据使用情况明细',\n    storageUsageTotal: '总计',\n    storageUsageErrors: '错误',\n    storageUsageOtherBreakdown: '其他（包括静态资源、脚本和配置文件）',\n    storageUsageSystem: '系统',\n    storageUsageData: '数据',\n    storageUsageUnavailable: '存储使用信息不可用',\n    clearNotificationLogsDescription: '删除 30 天前的通知日志',\n    resetUiPreferencesDescription: '重置侧边栏顺序、主题、视图模式和布局偏好。打印机、归档和设置不受影响。',\n    enableHomeAssistant: '启用 Home Assistant',\n    enableMqtt: '启用 MQTT',\n    useTls: '使用 TLS',\n    enableMetricsEndpoint: '启用指标端点',\n    availableMetrics: '可用指标',\n    editUser: '编辑用户',\n    deleteUserTitle: '删除用户',\n    groupName: '组名称',\n    // Placeholders\n    leaveEmptyForAnonymous: '留空为匿名',\n    leaveEmptyForNoAuth: '留空为无认证',\n    enterNewPassword: '输入新密码',\n    confirmNewPassword: '确认新密码',\n    enterGroupName: '输入组名称',\n    enterDescriptionOptional: '输入描述（可选）',\n    enterCurrentPassword: '输入当前密码',\n    enterNewPasswordMin6: '输入新密码（至少 6 个字符）',\n    toast: {\n      keyCopied: '密钥已复制到剪贴板',\n      copyFailed: '复制密钥失败',\n      keyAddedToBrowser: '密钥已添加到 API 浏览器',\n      clearLogsFailed: '清除日志失败',\n      uiPreferencesReset: 'UI 偏好已重置。刷新中...',\n      authDisabled: '身份验证已成功禁用',\n      authDisableFailed: '禁用身份验证失败',\n      apiKeyCreated: 'API 密钥已创建',\n      apiKeyDeleted: 'API 密钥已删除',\n      userCreated: '用户创建成功',\n      userUpdated: '用户更新成功',\n      userDeleted: '用户删除成功',\n      groupCreated: '组创建成功',\n      groupUpdated: '组更新成功',\n      groupDeleted: '组删除成功',\n      fillRequiredFields: '请填写所有必填字段',\n      passwordsDoNotMatch: '密码不匹配',\n      passwordTooShort: '密码至少需要 6 个字符',\n      enterGroupName: '请输入组名称',\n      settingsSaved: '设置已保存',\n      cameraSettingsSaved: '摄像头设置已保存',\n      enterCameraUrl: '请输入摄像头 URL',\n      passwordChanged: '密码修改成功',\n      connectionFailed: '连接失败',\n      testFailed: '测试失败',\n      cameraConnected: '摄像头已连接{{resolution}}',\n    },\n    testConnection: '测试连接',\n    catalog: {\n      spoolCatalog: '耗材目录',\n      spoolCatalogDescription: '按品牌/类型的空耗材重量。用于添加耗材时的自动重量查找。',\n      searchCatalog: '搜索目录...',\n      addNewEntry: '添加新条目',\n      namePlaceholder: '名称（例如：Bambu Lab - 塑料）',\n      weight: '重量',\n      type: '类型',\n      default: '默认',\n      custom: '自定义',\n      noMatch: '没有条目匹配您的搜索',\n      empty: '目录中没有条目',\n      deleteEntry: '删除条目',\n      deleteConfirm: '确定要删除\"{{name}}\"吗？',\n      resetCatalog: '重置目录',\n      resetConfirm: '重置目录为默认值？这将移除所有自定义条目。',\n      loadFailed: '加载耗材目录失败',\n      nameWeightRequired: '名称和重量为必填项',\n      entryAdded: '条目已添加',\n      addFailed: '添加条目失败',\n      entryUpdated: '条目已更新',\n      updateFailed: '更新条目失败',\n      entryDeleted: '条目已删除',\n      deleteFailed: '删除条目失败',\n      resetSuccess: '目录已重置为默认值',\n      resetFailed: '重置目录失败',\n      exported: '已导出 {{count}} 条',\n      imported: '已导入 {{added}} 条（跳过 {{skipped}} 条）',\n      importFailed: '导入失败：无效的 JSON 格式',\n      exportTooltip: '导出目录为 JSON',\n      importTooltip: '从 JSON 导入目录',\n      resetTooltip: '重置为默认值',\n      selectedCount: '已选择 {{count}} 项',\n      deleteSelected: '删除所选',\n      bulkDeleteConfirm: '确定要删除 {{count}} 个条目吗？',\n      bulkDeleted: '已删除 {{count}} 个条目',\n      bulkDeleteFailed: '删除条目失败',\n    },\n    colorCatalog: {\n      title: '颜色目录',\n      description: '按制造商/材料的耗材颜色。用于添加耗材时的自动颜色查找。',\n      searchColors: '搜索颜色...',\n      allManufacturers: '所有制造商',\n      addNewColor: '添加新颜色',\n      manufacturer: '制造商',\n      colorName: '颜色名称',\n      hex: '十六进制',\n      materialOptional: '材料（可选）',\n      showing: '显示 {{filtered}} / {{total}} 种颜色',\n      noMatch: '没有颜色匹配您的搜索',\n      empty: '目录中没有颜色',\n      deleteColor: '删除颜色',\n      deleteConfirm: '确定要删除\"{{name}}\"吗？',\n      resetCatalog: '重置颜色目录',\n      resetConfirm: '重置目录为默认值？这将移除所有自定义颜色。',\n      sync: '同步',\n      starting: '启动中...',\n      syncTooltip: '从 FilamentColors.xyz 同步（2000+ 种颜色，可能需要一分钟）',\n      loadFailed: '加载颜色目录失败',\n      fieldsRequired: '制造商、颜色名称和十六进制颜色为必填项',\n      colorAdded: '颜色已添加',\n      addFailed: '添加颜色失败',\n      colorUpdated: '颜色已更新',\n      updateFailed: '更新颜色失败',\n      colorDeleted: '颜色已删除',\n      deleteFailed: '删除颜色失败',\n      resetSuccess: '颜色目录已重置为默认值',\n      resetFailed: '重置目录失败',\n      syncUpToDate: '已是最新（检查了 {{count}} 种颜色）',\n      syncComplete: '添加了 {{added}} 种新颜色（{{skipped}} 种已存在）',\n      syncError: '同步错误',\n      syncFailed: '从 FilamentColors.xyz 同步失败',\n      exported: '已导出 {{count}} 种颜色',\n      imported: '已导入 {{added}} 种颜色（跳过 {{skipped}} 种）',\n      importFailed: '导入失败：无效的 JSON 格式',\n      selectedCount: '已选择 {{count}} 项',\n      deleteSelected: '删除所选',\n      bulkDeleteConfirm: '确定要删除 {{count}} 种颜色吗？',\n      bulkDeleted: '已删除 {{count}} 种颜色',\n      bulkDeleteFailed: '删除颜色失败',\n    },\n    dateFormat: '日期格式',\n    dateFormatUs: '美式 (MM/DD/YYYY)',\n    dateFormatEu: '欧式 (DD/MM/YYYY)',\n    dateFormatIso: 'ISO (YYYY-MM-DD)',\n    timeFormat: '时间格式',\n    timeFormat12: '12小时制 (3:30 PM)',\n    timeFormat24: '24小时制 (15:30)',\n    defaultPrinter: '默认打印机',\n    defaultPrinterDescription: '为上传、重印和其他操作预选此打印机。',\n    slicerBambuStudio: 'Bambu Studio',\n    slicerOrcaSlicer: 'OrcaSlicer',\n    sidebarOrderDescription: '拖拽侧边栏项目以重新排序。在此处重置为默认顺序。',\n    setDefault: '设为默认',\n    sidebarOrderSetDefaultHint: '设为默认将当前菜单顺序应用于尚未自定义的用户。',\n    sidebarDefaultSet: '已设置默认菜单顺序。',\n    sidebarDefaultCleared: '已清除默认菜单顺序。',\n    sidebarDefaultFailed: '设置默认菜单顺序失败。',\n    reset: '重置',\n    darkMode: '深色模式',\n    lightMode: '浅色模式',\n    active: '(当前)',\n    background: '背景',\n    accent: '强调色',\n    style: '样式',\n    bgNeutral: '中性',\n    bgWarm: '暖色',\n    bgCool: '冷色',\n    bgOled: 'OLED 纯黑',\n    bgSlate: '石板蓝',\n    bgForest: '森林绿',\n    accentGreen: '绿色',\n    accentTeal: '青色',\n    accentBlue: '蓝色',\n    accentOrange: '橙色',\n    accentPurple: '紫色',\n    accentRed: '红色',\n    styleClassic: '经典',\n    styleGlow: '发光',\n    styleVibrant: '鲜艳',\n    themeToggleHint: '使用侧边栏中的太阳/月亮图标在深色和浅色模式之间切换。',\n    autoArchivePrints: '自动归档打印',\n    autoArchiveDescription: '打印完成时自动保存3MF文件',\n    saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',\n    captureFinishPhotoDescription: '打印完成时从打印机摄像头拍照',\n    ffmpegNotInstalled: '未安装ffmpeg',\n    ffmpegRequired: '摄像头捕获需要ffmpeg。通过 <brew>brew install ffmpeg</brew>（macOS）或 <apt>apt install ffmpeg</apt>（Linux）安装。',\n    camera: '摄像头',\n    cameraViewMode: '摄像头查看模式',\n    cameraOverlayDescription: '摄像头在主屏幕上以可调大小的覆盖层打开',\n    cameraWindowDescription: '摄像头在单独的浏览器窗口中打开',\n    externalCamerasDescription: '配置外部摄像头以替换内置打印机摄像头。支持MJPEG流、RTSP、HTTP快照和USB摄像头（V4L2）。启用后，外部摄像头将用于实时查看和完成照片。',\n    cameraPlaceholderUsb: '设备路径 (/dev/video0)',\n    cameraPlaceholderUrl: '摄像头URL (rtsp://... 或 http://...)',\n    cameraTypeMjpeg: 'MJPEG 流',\n    cameraTypeRtsp: 'RTSP 流',\n    cameraTypeSnapshot: 'HTTP 快照',\n    cameraTypeUsb: 'USB 摄像头 (V4L2)',\n    cameraRotation: '旋转',\n    test: '测试',\n    connected: '已连接',\n    disconnected: '未连接',\n    currency: '货币',\n    defaultFilamentCost: '默认耗材成本（每公斤）',\n    electricityCost: '每千瓦时电费',\n    energyDisplayMode: '能源显示模式',\n    energyModePrintDescription: '仪表板显示打印期间使用的能源总和',\n    energyModeTotalDescription: '仪表板显示智能插座的累计能源',\n    fileManager: '文件管理器',\n    createArchiveEntry: '打印时创建归档条目',\n    createArchiveEntryDescription: '从文件管理器打印时，可选择创建归档条目',\n    lowDiskSpaceWarning: '磁盘空间不足警告',\n    lowDiskSpaceDescription: '当可用磁盘空间低于此阈值时显示警告',\n    printerFirmware: '打印机固件',\n    checkFirmwareDescription: '检查Bambu Lab的打印机固件更新',\n    bambuddySoftware: 'Bambuddy 软件',\n    autoCheckDescription: '启动时自动检查新版本',\n    checkNow: '立即检查',\n    updateAvailableVersion: '可用更新：v{{version}}',\n    releaseNotes: '发布说明',\n    updateViaDocker: '通过 Docker Compose 更新：',\n    installUpdate: '安装更新',\n    latestVersionRunning: '您正在运行最新版本',\n    failedToCheckUpdates: '检查更新失败：{{error}}',\n    backupRestore: '备份与恢复',\n    backupRestoreDescription: '导出/导入设置并配置GitHub备份',\n    goToBackup: '前往备份',\n    externalUrl: '外部URL',\n    externalUrlDescription: 'Bambuddy可访问的外部URL。用于通知图像和外部集成。',\n    bambuddyUrl: 'Bambuddy URL',\n    externalUrlHint: '包含协议和端口（例如：http://192.168.1.100:8000）',\n    ftpRetry: 'FTP重试',\n    ftpRetryDescription: '当打印机WiFi不稳定时重试FTP操作。适用于3MF下载、打印上传、延时摄影下载和固件更新。',\n    autoRetryDescription: '自动重试失败的FTP操作',\n    retryAttempts: '重试次数',\n    retryDelay: '重试延迟',\n    connectionTimeout: '连接超时',\n    time_one: '{{count}}次',\n    time_other: '{{count}}次',\n    second_one: '{{count}}秒',\n    second_other: '{{count}}秒',\n    nSeconds: '{{count}}秒',\n    increaseForWeakWifi: '对WiFi信号弱的打印机增加此值',\n    homeAssistant: 'Home Assistant',\n    homeAssistantFullDescription: '连接到Home Assistant，通过HA REST API控制智能插座。支持switch、light、input_boolean和script实体。',\n    homeAssistantUrl: 'Home Assistant URL',\n    longLivedAccessToken: '长期访问令牌',\n    haTokenHint: '在HA中创建令牌：个人资料 → 长期访问令牌 → 创建令牌',\n    connectionSuccessful: '连接成功',\n    connectionFailed: '连接失败',\n    haConnectionSuccess: '已成功连接到Home Assistant。',\n    haConnectionFailed: '连接Home Assistant失败。',\n    mqttPublishing: 'MQTT发布',\n    mqttDescription: '将BamBuddy事件发布到外部MQTT代理，用于与Node-RED、Home Assistant和其他自动化系统集成。',\n    mqttEnableDescription: '向外部MQTT代理发布事件',\n    brokerHostname: '代理主机名',\n    port: '端口',\n    usernameOptional: '用户名（可选）',\n    passwordOptional: '密码（可选）',\n    topicPrefix: '主题前缀',\n    topicPrefixHint: '主题格式：{{prefix}}/printers/<serial>/status 等',\n    prometheusMetrics: 'Prometheus 指标',\n    prometheusEndpointDescription: '在 <code>/api/v1/metrics</code> 公开打印机指标，用于Prometheus/Grafana监控。',\n    bearerTokenOptional: 'Bearer令牌（可选）',\n    bearerTokenHint: '设置后，请求必须包含 <code>Authorization: Bearer <token></code>',\n    metricsConnectionStatus: '连接状态',\n    metricsPrinterState: '打印机状态（空闲/打印中等）',\n    metricsPrintProgress: '打印进度 0-100%',\n    metricsBedTemp: '热床温度',\n    metricsNozzleTemp: '喷嘴温度',\n    metricsPrintsTotal: '按结果分类的总打印数',\n    metricsMore: '...以及更多（层数、风扇、队列、耗材用量）',\n    smartPlugsDescription: '连接智能插座（Tasmota或Home Assistant）以自动化电源控制并跟踪打印机的能源使用情况。',\n    allOn: '全部开启',\n    allOff: '全部关闭',\n    addSmartPlug: '添加智能插座',\n    energySummary: '能源概要',\n    currentPower: '当前功率',\n    plugsOnline: '{{reachable}}/{{total}} 个插座在线',\n    today: '今天',\n    yesterday: '昨天',\n    total: '总计',\n    enablePlugsForSummary: '启用插座以查看能源概要',\n    addNotificationProvider: '添加',\n    systemBadge: '(系统)',\n    creating: '创建中...',\n    changing: '修改中...',\n    deleteUserAndItems: '删除用户及其所有项目',\n    deleteUserKeepItems: '删除用户，保留项目（将变为无主项目）',\n    ok: '确定',\n\n    // 2FA settings\n    twoFa: {\n      totpTitle: '身份验证器应用 (TOTP)',\n      totpDesc: '使用 Google Authenticator、Aegis 或 Authy 等应用。',\n      emailOtpTitle: '邮件 OTP',\n      emailOtpDesc: '登录时向 {{email}} 发送一次性验证码。',\n      emailOtpNoEmail: '请先为账户添加邮箱地址以启用此方式。',\n      addEmailFirst: '您的账户没有邮箱地址，请联系管理员添加。',\n      setupTotp: '设置身份验证器应用',\n      setupAuthApp: '设置身份验证器应用',\n      setupInstructions: '使用身份验证器应用扫描二维码，然后输入验证码确认。',\n      manualEntry: '无法扫描？请手动输入此密钥：',\n      scannedContinue: '已扫描 — 继续',\n      enterCodeToConfirm: '请输入身份验证器应用中的6位验证码以确认设置。',\n      activate: '激活',\n      disableTotp: '停用身份验证器',\n      disableConfirmHint: '请输入有效的 TOTP 码或备用码来停用身份验证器。',\n      totpDisabled: '身份验证器应用已停用。',\n      emailOtpEnabled: '邮件 OTP 已启用。',\n      emailOtpDisabled: '邮件 OTP 已停用。',\n      smtpRequired: '请先配置并测试SMTP设置。',\n      invalidCode: '无效验证码，请重试。',\n      enableEmailOtp: '启用邮件 OTP',\n      disableEmailOtp: '停用邮件 OTP',\n      emailSetupEnterCode: '验证码已发送至您的邮箱地址。请在下方输入以确认您拥有此邮箱。',\n      verifyAndEnable: '验证并启用',\n      emailDisablePasswordHint: '请输入您的账户密码以确认停用邮件 OTP。',\n      passwordPlaceholder: '输入您的密码',\n      backupCodesTitle: '保存备用码',\n      backupCodesWarning: '请将这些码保存在安全的地方。每个码只能使用一次，且不会再次显示。',\n      backupCodesRemaining: '剩余 {{count}} 个备用码',\n      savedCodes: '已保存',\n      regenBackup: '重新生成备用码',\n      regenBackupHint: '输入当前 TOTP 码以生成 10 个新备用码，所有现有备用码将失效。',\n      newBackupCodes: '新备用码',\n      linkedAccounts: '已关联的 SSO 账户',\n      linkedAccountsDesc: '以下外部身份提供商已与您的账户关联。',\n      oidcUnlinked: '账户已解除关联。',\n    },\n\n    // OIDC provider settings\n    oidc: {\n      title: 'SSO / OIDC 提供商',\n      desc: '配置 OpenID Connect 提供商以实现单点登录。',\n      addProvider: '添加提供商',\n      newProvider: '新提供商',\n      empty: '尚未配置 OIDC 提供商。',\n      created: '提供商已创建。',\n      updated: '提供商已更新。',\n      deleted: '提供商已删除。',\n      deleteTitle: '删除提供商',\n      deleteMessage: '删除\"{{name}}\"？所有关联账户将断开连接。',\n      form: {\n        name: '显示名称',\n        issuerUrl: '颁发者 URL',\n        clientId: '客户端 ID',\n        clientSecret: '客户端密钥',\n        scopes: '作用域',\n        iconUrl: '图标 URL（可选）',\n        enabled: '已启用',\n        autoCreate: '自动创建用户',\n        autoCreateDesc: '首次登录时自动创建本地账户。',\n        autoLink: '自动关联已有账户',\n        autoLinkDesc: '首次登录时通过邮箱匹配现有本地账户并自动关联。',\n        secretHint: '留空以保留当前',\n        secretPlaceholder: '新密钥',\n      },\n    },\n\n  },\n\n  // Notifications (for push notifications)\n  notification: {\n    printStarted: {\n      title: '打印已开始',\n      body: '{{printer}}：{{filename}} 已开始打印',\n    },\n    printCompleted: {\n      title: '打印已完成',\n      body: '{{printer}}：{{filename}} 已成功完成',\n    },\n    printFailed: {\n      title: '打印失败',\n      body: '{{printer}}：{{filename}} 打印失败',\n    },\n    printStopped: {\n      title: '打印已停止',\n      body: '{{printer}}：{{filename}} 已停止',\n    },\n    printProgress: {\n      title: '打印进度',\n      body: '{{printer}}：{{filename}} 已完成 {{percent}}%',\n    },\n    printerOffline: {\n      title: '打印机离线',\n      body: '{{printer}} 已离线',\n    },\n    printerError: {\n      title: '打印机错误',\n      body: '{{printer}}：{{error}}',\n    },\n    filamentLow: {\n      title: '耗材不足',\n      body: '{{printer}}：耗材即将用完',\n    },\n    maintenanceDue: {\n      title: '维护到期',\n      body: '{{printer}}：{{items}} 需要关注',\n    },\n  },\n\n  // Errors\n  errors: {\n    generic: '出了点问题',\n    networkError: '网络错误。请检查您的连接。',\n    notFound: '未找到',\n    unauthorized: '未授权',\n    serverError: '服务器错误',\n    validationError: '请检查您的输入',\n    printerConnectionFailed: '连接打印机失败',\n    saveFailed: '保存更改失败',\n    deleteFailed: '删除失败',\n    loadFailed: '加载数据失败',\n  },\n\n  // HMS Errors modal\n  hmsErrors: {\n    title: '错误 - {{name}}',\n    noErrors: '无错误',\n    viewOnWiki: '在拓竹 Wiki 上查看',\n    clearInstructions: '在打印机上清除错误以在此处消除它们。',\n    clearErrors: '清除错误',\n    clearSuccess: 'HMS 错误已清除',\n    clearFailed: '清除 HMS 错误失败',\n  },\n\n  // MQTT Debug modal\n  mqttDebug: {\n    title: 'MQTT 调试日志',\n    searchPlaceholder: '搜索主题或负载...',\n    noMessages: '尚未记录消息',\n    startLoggingHint: '点击\"开始记录\"以开始捕获 MQTT 消息',\n    noMessagesMatch: '没有消息匹配您的筛选条件',\n    adjustFilterHint: '尝试调整您的搜索或筛选条件',\n    incoming: '传入',\n    outgoing: '传出',\n    loggingStopped: '记录已停止',\n    loggingActive: '记录中 - 消息将自动刷新',\n    startLogging: '开始记录',\n    stopLogging: '停止记录',\n    clearLog: '清除日志',\n    topic: '主题',\n    timestamp: '时间戳',\n    direction: '方向',\n    all: '全部',\n  },\n\n  // Printer File Manager modal (printer internal storage)\n  printerFiles: {\n    title: '文件管理器',\n    storageUsed: '已用：',\n    storageFree: '剩余：',\n    filterPlaceholder: '筛选文件...',\n    deleteButton: '删除',\n    deleteFiles: '删除 {{count}} 个文件',\n    deleteFileConfirm: '删除\"{{name}}\"？此操作无法撤销。',\n    deleteFilesConfirm: '删除 {{count}} 个选中的文件？此操作无法撤销。',\n    noFiles: '打印机上没有文件',\n    loadingFiles: '加载文件中...',\n    failedToLoad: '加载文件失败',\n    toast: {\n      filesDeleted: '已删除 {{count}} 个文件',\n      deleteFailed: '删除失败：{{error}}',\n    },\n  },\n\n  // Confirmations\n  confirm: {\n    delete: '确定要删除吗？',\n    unsavedChanges: '您有未保存的更改。确定要离开吗？',\n    clearQueue: '确定要清空队列吗？',\n  },\n\n  // Login page\n  login: {\n    title: 'Bambuddy 登录',\n    subtitle: '登录您的账户',\n    username: '用户名',\n    usernamePlaceholder: '输入您的用户名',\n    usernameOrEmail: '用户名或邮箱',\n    usernameOrEmailPlaceholder: '用户名或 @ 邮箱',\n    password: '密码',\n    passwordPlaceholder: '输入您的密码',\n    signIn: '登录',\n    signingIn: '登录中...',\n    forgotPassword: '忘记密码？',\n    loginSuccess: '登录成功',\n    loginFailed: '登录失败',\n    enterCredentials: '请输入用户名和密码',\n    enterEmail: '请输入您的电子邮件地址',\n    oidcLoginFailed: 'OIDC 登录失败',\n    oidcErrors: {\n      providerError: '身份提供商返回了一个错误',\n      missingParameters: 'OIDC 回调缺少必要参数',\n      invalidState: 'OIDC 状态无效或已被使用',\n      stateExpired: 'OIDC 登录会话已过期，请重试',\n      providerNotFound: '未找到 OIDC 提供商',\n      discoveryFailed: '无法获取 OIDC 发现文档',\n      invalidDiscovery: 'OIDC 发现文档无效',\n      networkError: 'OIDC 令牌交换时出现网络错误',\n      badResponse: 'OIDC 令牌交换时收到意外响应',\n      noIdToken: 'OIDC 提供商未返回 ID 令牌',\n      validationFailed: 'OIDC 令牌验证失败',\n      nonceMismatch: 'OIDC nonce 不匹配，可能存在重放攻击',\n      missingSubClaim: 'OIDC 令牌缺少 sub 声明',\n      noLinkedAccount: '没有与此 OIDC 身份关联的本地帐户',\n      accountInactive: '您的帐户已被停用',\n      userResolutionFailed: '无法解析您的帐户',\n      internalError: 'OIDC 登录过程中发生内部错误',\n      tokenExchangeFailed: 'OIDC 令牌交换失败',\n    },\n    forgotPasswordTitle: '忘记密码',\n    forgotPasswordMessage: '如果您忘记了密码，请联系系统管理员进行重置。',\n    forgotPasswordEmailMessage: '输入您的邮箱地址，我们将向您发送新密码。',\n    emailAddress: '邮箱地址',\n    emailPlaceholder: 'your.email@example.com',\n    cancel: '取消',\n    sending: '发送中...',\n    sendResetEmail: '发送重置邮件',\n    howToReset: '如何重置密码：',\n    resetStep1: '联系您的 Bambuddy 管理员',\n    resetStep2: '请他们在用户管理中重置您的密码',\n    resetStep3: '他们可以为您设置一个临时密码',\n    resetStep4: '使用新密码登录并在设置中修改密码',\n    gotIt: '知道了',\n    resetPassword: {\n      title: '设置新密码',\n      subtitle: '请在下方输入并确认您的新密码。',\n      newPassword: '新密码',\n      newPasswordPlaceholder: '至少 8 个字符',\n      confirmPassword: '确认密码',\n      confirmPasswordPlaceholder: '重复输入新密码',\n      saving: '保存中…',\n      submit: '设置新密码',\n      backToLogin: '返回登录',\n      passwordsDoNotMatch: '密码不匹配',\n      passwordTooShort: '密码至少需要 8 个字符',\n      resetFailed: '密码重置失败。链接可能已过期。',\n    },\n    twoFA: {\n      title: '两步验证',\n      subtitle: '您的账户已启用两步验证。请在下方输入验证码。',\n      methodAuthenticator: '身份验证器应用',\n      methodEmail: '邮箱验证码',\n      methodBackup: '备用恢复码',\n      instructionsTotp: '请打开您的身份验证器应用，输入 Bambuddy 的 6 位验证码。',\n      instructionsEmail: '6 位验证码已发送至您的邮箱，有效期为 10 分钟。',\n      instructionsEmailNotSent: '点击下方按钮，通过邮件获取验证码。',\n      instructionsBackup: '请输入您的一个 8 位备用恢复码。每个恢复码只能使用一次。',\n      sendCodeButton: '发送邮箱验证码',\n      sendingCode: '发送中...',\n      resendCode: '重新发送验证码',\n      codeLabel: '验证码',\n      backupCodeLabel: '备用恢复码',\n      codePlaceholder: '000000',\n      backupCodePlaceholder: 'XXXXXXXX',\n      verifyButton: '验证',\n      verifyingButton: '验证中...',\n      backToLogin: '← 返回登录页面',\n      orContinueWith: '或通过以下方式登录',\n      signInWith: '使用 {{provider}} 登录',\n      enterCode: '请输入验证码',\n      sendCodeFailed: '验证码发送失败',\n      invalidCode: '无效验证码，请重试。',\n    },\n\n  },\n\n  // Setup page\n  setup: {\n    title: 'Bambuddy 设置',\n    subtitle: '为您的 Bambuddy 实例配置身份验证',\n    enableAuth: '启用身份验证',\n    adminAccount: '管理员账户',\n    adminAccountDesc: '如果管理员用户已存在，将使用现有管理员账户启用身份验证。如需使用现有管理员，请将下方字段留空，或输入新凭据创建新管理员用户。',\n    adminUsername: '管理员用户名',\n    adminPassword: '管理员密码',\n    optionalIfAdminExists: '（如管理员用户已存在则为可选）',\n    adminUsernamePlaceholder: '输入管理员用户名（可选）',\n    adminPasswordPlaceholder: '输入管理员密码（可选）',\n    confirmPassword: '确认密码',\n    confirmPasswordPlaceholder: '确认管理员密码',\n    settingUp: '设置中...',\n    completeSetup: '完成设置',\n    toast: {\n      authEnabledAdminCreated: '身份验证已启用并创建了管理员用户',\n      authEnabledExistingAdmins: '使用现有管理员用户启用了身份验证',\n      setupCompleted: '设置完成',\n      enterBothCredentials: '请输入管理员用户名和密码，或将两者留空以使用现有管理员用户',\n      passwordsDoNotMatch: '密码不匹配',\n      passwordTooShort: '密码至少需要 6 个字符',\n    },\n  },\n\n  // Password change\n  changePassword: {\n    title: '修改密码',\n    currentPassword: '当前密码',\n    currentPasswordPlaceholder: '输入当前密码',\n    newPassword: '新密码',\n    newPasswordPlaceholder: '输入新密码（至少 6 个字符）',\n    confirmPassword: '确认新密码',\n    confirmPasswordPlaceholder: '确认新密码',\n    passwordsDoNotMatch: '密码不匹配',\n    passwordTooShort: '密码至少需要 6 个字符',\n    changing: '修改中...',\n    success: '密码修改成功',\n    failed: '密码修改失败',\n  },\n\n  // Plate detection alert\n  plateAlert: {\n    title: '打印已暂停！',\n    message: '在构建板上检测到物体。打印已自动暂停。请清理打印板并继续打印。',\n    understand: '我知道了',\n  },\n\n  // Camera page\n  camera: {\n    title: '摄像头视图',\n    invalidPrinterId: '无效的打印机 ID',\n    live: '实时',\n    snapshot: '快照',\n    restartStream: '重启流',\n    refreshSnapshot: '刷新快照',\n    fullscreen: '全屏',\n    exitFullscreen: '退出全屏',\n    connectingToCamera: '连接摄像头中...',\n    capturingSnapshot: '拍摄快照中...',\n    connectionLost: '连接已断开',\n    connectionFailed: '摄像头连接失败',\n    reconnecting: '{{countdown}} 秒后重新连接...（第 {{attempt}}/{{max}} 次尝试）',\n    reconnectNow: '立即重新连接',\n    cameraUnavailable: '摄像头不可用',\n    cameraUnavailableDesc: '请确保打印机已通电并已连接。',\n    noCamera: '无可用摄像头',\n    retry: '重试',\n    cameraStream: '摄像头流',\n    zoomOut: '缩小',\n    zoomIn: '放大',\n    resetZoom: '重置缩放',\n    recording: '录制中',\n    startRecording: '开始录制',\n    stopRecording: '停止录制',\n    chamberLight: '切换腔室灯',\n  },\n\n  // Groups management\n  groups: {\n    title: '组管理',\n    subtitle: '管理访问控制的权限组',\n    backToSettings: '返回设置',\n    createGroup: '创建组',\n    noPermission: '您没有访问此页面的权限。',\n    system: '系统',\n    noDescription: '无描述',\n    usersCount: '{{count}} 个用户',\n    permissionsCount: '{{count}} 个权限',\n    edit: '编辑',\n    delete: '删除',\n    toast: {\n      created: '组创建成功',\n      updated: '组更新成功',\n      deleted: '组删除成功',\n      enterGroupName: '请输入组名称',\n    },\n    modal: {\n      editGroup: '编辑组',\n      createGroup: '创建组',\n      cancel: '取消',\n      saving: '保存中...',\n      creating: '创建中...',\n      saveChanges: '保存更改',\n    },\n    form: {\n      groupName: '组名称',\n      groupNamePlaceholder: '输入组名称',\n      systemGroupWarning: '系统组名称不可更改',\n      description: '描述',\n      descriptionPlaceholder: '输入描述（可选）',\n      permissions: '权限（已选 {{count}} 个）',\n    },\n    deleteModal: {\n      title: '删除组',\n      message: '确定要删除此组吗？此组中的用户将失去这些权限。',\n      confirm: '删除组',\n    },\n    editor: {\n      title: '编辑组',\n      createTitle: '创建组',\n      search: '搜索权限...',\n      selectAll: '全选',\n      clearAll: '清除全部',\n      permissionsSelected: '已选 {{count}} 个',\n      noResults: '没有权限匹配您的搜索',\n    },\n  },\n\n  // Users management\n  users: {\n    title: '用户管理',\n    subtitle: '管理用户及其对 Bambuddy 实例的访问',\n    backToSettings: '返回设置',\n    createUser: '创建用户',\n    noPermission: '您没有访问此页面的权限。',\n    admin: '管理员',\n    noGroups: '无组',\n    active: '活跃',\n    inactive: '非活跃',\n    edit: '编辑',\n    delete: '删除',\n    system: '系统',\n    noGroupsAvailable: '无可用组',\n    table: {\n      username: '用户名',\n      groups: '组',\n      status: '状态',\n      actions: '操作',\n    },\n    toast: {\n      created: '用户创建成功',\n      updated: '用户更新成功',\n      deleted: '用户删除成功',\n      fillRequired: '请填写所有必填字段',\n      passwordsDoNotMatch: '密码不匹配',\n      passwordTooShort: '密码至少需要 6 个字符',\n    },\n    modal: {\n      createUser: '创建用户',\n      editUser: '编辑用户',\n      cancel: '取消',\n      creating: '创建中...',\n      saving: '保存中...',\n      saveChanges: '保存更改',\n      advancedAuthSubtitle: '使用高级认证',\n    },\n    form: {\n      username: '用户名',\n      usernamePlaceholder: '输入用户名',\n      email: '邮箱',\n      emailPlaceholder: 'user@example.com',\n      password: '密码',\n      passwordPlaceholder: '输入密码',\n      confirmPassword: '确认密码',\n      confirmPasswordPlaceholder: '确认密码',\n      newPasswordPlaceholder: '输入新密码',\n      confirmNewPasswordPlaceholder: '确认新密码',\n      leaveBlankToKeep: '留空以保持当前值',\n      groups: '组',\n      optional: '可选',\n      autoGeneratedPassword: '将自动生成安全密码并通过邮件发送给用户。',\n      passwordManagedByAdvancedAuth: '密码由高级认证管理。使用\"重置密码\"通过邮件向用户发送新密码。',\n      resetPassword: '重置密码',\n      resettingPassword: '重置密码中...',\n    },\n    deleteModal: {\n      title: '删除用户',\n      message: '确定要删除此用户吗？此操作无法撤销。',\n      confirm: '删除用户',\n    },\n  },\n\n  // Stream overlay\n  streamOverlay: {\n    title: '流叠加层',\n    invalidPrinterId: '无效的打印机 ID',\n    cameraStream: '摄像头流',\n    progress: '进度',\n    eta: '预计完成时间',\n    printerIdle: '打印机空闲',\n    printerOffline: '打印机离线',\n    status: {\n      printing: '打印中',\n      paused: '已暂停',\n      finished: '已完成',\n      failed: '失败',\n      idle: '空闲',\n      unknown: '未知',\n    },\n  },\n\n  // Profiles\n  profiles: {\n    title: '配置文件',\n    subtitle: '管理您的切片预设和压力推进校准',\n    tabs: {\n      cloud: '云端配置文件',\n      local: '本地配置文件',\n      kprofiles: 'K 值配置',\n    },\n    localProfiles: {\n      title: '本地配置文件',\n      subtitle: '从 OrcaSlicer 导入和管理切片预设',\n      import: '导入配置文件',\n      importDesc: '将 .bbscfg、.bbsflmt、.orca_filament、.zip 或 .json 文件拖放到此处',\n      importing: '导入中...',\n      search: '搜索本地预设...',\n      noPresets: '暂无本地预设',\n      badge: '本地',\n      edit: '编辑',\n      delete: '删除',\n      cancel: '取消',\n      deleteConfirmTitle: '删除预设',\n      deleteConfirm: '确定要删除此预设吗？此操作无法撤销。',\n      source: '来源',\n      inheritsFrom: '继承自',\n      filamentType: '类型',\n      vendor: '厂商',\n      compatiblePrinters: '兼容打印机',\n      nozzleTemp: '喷嘴温度',\n      cost: '成本',\n      density: '密度',\n      pressureAdvance: '压力推进',\n      filament: '耗材',\n      process: '工艺',\n      printer: '打印机',\n      toast: {\n        importSuccess: '已导入 {{count}} 个预设',\n        importSkipped: '跳过 {{count}} 个预设（重复）',\n        importError: '导入时出现 {{count}} 个错误',\n        deleted: '预设已删除',\n        updated: '预设已更新',\n      },\n    },\n    connectedAs: '已连接为',\n    logout: '退出登录',\n    noLogoutPermission: '您没有退出登录的权限',\n    failedToLoad: '加载配置文件失败',\n    retry: '重试',\n    time: {\n      justNow: '刚刚',\n      minsAgo: '{{count}} 分钟前',\n      hoursAgo: '{{count}} 小时前',\n      daysAgo: '{{count}} 天前',\n    },\n    toast: {\n      loggedOut: '已退出登录',\n    },\n    login: {\n      title: '连接到拓竹云',\n      subtitle: '跨设备同步您的切片预设',\n      email: '邮箱',\n      password: '密码',\n      region: '地区',\n      regionGlobal: '全球',\n      regionChina: '中国',\n      verificationCode: '验证码',\n      totpCode: '验证器代码',\n      checkEmail: '检查您的邮箱 ({{email}}) 获取 6 位验证码',\n      enterTotpHint: '输入验证器应用中的 6 位代码',\n      accessToken: '访问令牌',\n      accessTokenHint: '粘贴您的拓竹访问令牌（来自 Bambu Studio）',\n      back: '返回',\n      loginButton: '登录',\n      verifyButton: '验证',\n      setTokenButton: '设置令牌',\n      useToken: '改用访问令牌',\n      useEmail: '改用邮箱登录',\n      toast: {\n        loggedIn: '登录成功',\n        codeSent: '验证码已发送到您的邮箱',\n        enterTotp: '输入验证器应用中的代码',\n        tokenSet: '令牌设置成功',\n      },\n    },\n    presets: {\n      myPreset: '我的预设（可编辑）',\n      duplicate: '复制',\n      editable: '可编辑',\n      failedToLoadDetails: '加载预设详情失败',\n      deleteConfirm: '删除此预设？',\n      deleteWarning: '这将从拓竹云中永久删除\"{{name}}\"。此操作无法撤销。',\n      noDuplicatePermission: '您没有复制预设的权限',\n      noEditPermission: '您没有编辑预设的权限',\n      noDeletePermission: '您没有删除预设的权限',\n      types: {\n        filament: '耗材预设',\n        printer: '打印机预设',\n        process: '工艺预设',\n      },\n      toast: {\n        deleted: '预设已删除',\n        created: '预设已创建',\n        updated: '预设已更新',\n        duplicated: '预设已复制',\n        fieldAdded: '字段\"{{key}}\"已添加',\n        exported: '预设已导出',\n      },\n      baseLabel: '基础：{{name}}',\n      currentLabel: '当前：{{name}}',\n      newPreset: '新建预设',\n      editPreset: '编辑预设',\n      duplicatePreset: '复制预设',\n      createNewPreset: '创建新预设',\n      customizeSettings: '自定义新预设的设置',\n      compareWithBase: '与基础预设比较',\n      compare: '比较',\n      // CreatePresetModal - Basic Info\n      basePreset: '基础预设',\n      selectBasePreset: '选择基础预设...',\n      presetName: '预设名称',\n      myCustomPreset: '我的自定义预设',\n      inheritsFrom: '继承自',\n      dropJsonToImport: '拖放 JSON 以导入',\n      // CreatePresetModal - Tabs\n      tabs: {\n        common: '常用',\n        allFields: '所有字段',\n      },\n      // CreatePresetModal - All Fields Tab\n      availableFields: '可用字段',\n      searchFieldsPlaceholder: '搜索字段...',\n      noMatchingFields: '没有匹配的字段',\n      allFieldsAdded: '所有字段已添加',\n      addCustomField: '添加自定义字段',\n      yourOverrides: '您的覆盖值',\n      noOverridesYet: '暂无覆盖值',\n      clickFieldsToAdd: '点击左侧的字段进行添加',\n      saveAsTemplate: '保存为模板',\n      jsonTip: '提示：将 .json 文件拖放到此对话框的任意位置以导入设置',\n    },\n    cloudView: {\n      searchPlaceholder: '搜索预设...',\n      templates: '模板',\n      refresh: '刷新',\n      newPreset: '新建预设',\n      clearFilters: '清除筛选',\n      // Compare mode\n      compareMode: '比较模式',\n      selectAnotherPreset: '选择另一个 {{type}} 预设',\n      clickTwoPresets: '点击两个相同类型的预设进行比较',\n      selectFirst: '1. 选择第一个',\n      selectSecond: '2. 选择第二个',\n      compareNow: '立即比较',\n      // Status row\n      lastSynced: '上次同步：',\n      showingCount: '显示 {{showing}} / {{total}} 个预设',\n      noPresetsFound: '未找到预设',\n      // Column headers\n      columns: {\n        filament: '耗材',\n        process: '工艺',\n        printer: '打印机',\n      },\n      noFilamentPresets: '无耗材预设',\n      noProcessPresets: '无工艺预设',\n      noPrinterPresets: '无打印机预设',\n      // Filters\n      filters: {\n        type: '类型',\n        owner: '所有者',\n        printer: '打印机',\n        nozzle: '喷嘴',\n        filament: '耗材',\n        layer: '层',\n        all: '全部',\n        myPresets: '我的预设',\n        builtIn: '内置',\n        process: '工艺',\n      },\n      // Permissions\n      noTemplatesPermission: '您没有管理模板的权限',\n      noRefreshPermission: '您没有刷新配置文件的权限',\n      noCreatePermission: '您没有创建预设的权限',\n    },\n    templates: {\n      title: '快速模板',\n      noTemplates: '暂无模板',\n      createFirst: '从预设编辑器创建模板',\n      typeFilter: '类型：',\n      deleteTitle: '删除模板',\n      deleteWarning: '此操作无法撤销',\n      deleteConfirm: '确定要删除\"{{name}}\"吗？',\n      namePlaceholder: '模板名称',\n      descriptionPlaceholder: '描述',\n      settingsJson: '设置 (JSON)',\n      fieldsCount: '{{count}} 个字段',\n      shownInModals: '在对话框中显示',\n      hiddenInModals: '在对话框中隐藏',\n      apply: '应用',\n      toast: {\n        deleted: '模板已删除',\n        updated: '模板已更新',\n        created: '模板已创建',\n        applied: '模板已应用',\n      },\n    },\n  },\n\n  // Support/Debug\n  support: {\n    debugLoggingActive: '调试日志记录已激活',\n    manageLogs: '管理',\n    collectItem7: '打印机连接和固件版本',\n    collectItem8: '集成状态（Spoolman、MQTT、HA）',\n    collectItem9: '网络接口（仅子网）',\n    collectItem10: 'Python 包版本',\n    collectItem11: '数据库健康检查',\n    collectItem12: 'Docker 环境详情',\n  },\n\n  // File manager\n  fileManager: {\n    title: '文件管理器',\n    subtitle: '组织和管理您的打印文件',\n    uploadFiles: '上传文件',\n    newFolder: '新建文件夹',\n    folderName: '文件夹名称',\n    folderNamePlaceholder: '例如：功能零件',\n    renameFile: '重命名文件',\n    renameFolder: '重命名文件夹',\n    moveFiles: '移动 {{count}} 个文件',\n    rootNoFolder: '根目录（无文件夹）',\n    current: '当前',\n    linkFolder: '链接文件夹',\n    linkFolderDescription: '将\"{{name}}\"链接到项目或归档以便快速访问。',\n    project: '项目',\n    archive: '归档',\n    noProjectsFound: '未找到项目',\n    noArchivesFound: '未找到归档',\n    unlink: '取消链接',\n    link: '链接',\n    dragDropFiles: '将文件拖放到此处',\n    dropFilesHere: '将文件放在此处',\n    orClickToBrowse: '或点击浏览',\n    allFileTypesSupported: '支持所有文件类型。ZIP 文件将被解压。',\n    zipFilesDetected: '检测到 ZIP 文件',\n    zipExtractOptions: 'ZIP 文件将被解压。选择如何处理文件夹结构：',\n    preserveZipStructure: '保留 ZIP 中的文件夹结构',\n    createFolderFromZip: '从 ZIP 文件名创建文件夹',\n    stlThumbnailGeneration: 'STL 缩略图生成',\n    zipMayContainStl: 'ZIP 文件可能包含 STL 文件。可以在解压时生成缩略图。',\n    thumbnailsCanBeGenerated: '可以为 STL 文件生成缩略图。大型模型可能需要更长时间处理。',\n    generateThumbnailsForStl: '为 STL 文件生成缩略图',\n    threemfDetected: '检测到 3MF 文件',\n    threemfExtractionInfo: '将自动从 3MF 文件中提取打印机型号、材料、颜色和打印设置。',\n    willBeExtracted: '将被解压',\n    filesExtracted: '已解压 {{count}} 个文件',\n    uploadComplete: '上传完成：{{succeeded}} 个成功',\n    uploadFailed: '上传失败',\n    zipFilesFailed: '{{count}} 个文件失败',\n    uploading: '上传中...',\n    changeLink: '更改链接...',\n    linkTo: '链接到...',\n    linkToProjectOrArchive: '链接到项目或归档',\n    addToQueue: '添加到队列',\n    schedulePrint: '排程',\n    generateThumbnail: '生成缩略图',\n    generateThumbnails: '生成缩略图',\n    generateThumbnailsForMissing: '为缺少缩略图的 STL 文件生成缩略图',\n    gridView: '网格视图',\n    listView: '列表视图',\n    lowDiskSpaceWarning: '磁盘空间不足警告',\n    lowDiskSpaceDetails: '仅剩 {{free}}（总共 {{total}}）。阈值设置为 {{threshold}} GB。',\n    files: '文件',\n    folders: '文件夹',\n    size: '大小',\n    free: '剩余',\n    allFiles: '所有文件',\n    wrap: '换行',\n    enableTextWrapping: '启用文本换行',\n    disableTextWrapping: '禁用文本换行',\n    collapse: '折叠',\n    collapseFoldersByDefault: '默认折叠文件夹',\n    expandFoldersByDefault: '默认展开文件夹',\n    dragToResizeTooltip: '拖动调整大小，双击重置',\n    searchFiles: '搜索文件...',\n    allTypes: '所有类型',\n    prints: '打印',\n    ascending: '升序',\n    descending: '降序',\n    resultsCount: '{{showing}} / {{total}} 个文件',\n    selectAll: '全选',\n    deselectAll: '取消全选',\n    selected: '已选择 {{count}} 个',\n    adding: '添加中...',\n    loadingFiles: '加载文件中...',\n    folderIsEmpty: '文件夹为空',\n    noFilesYet: '暂无文件',\n    folderEmptyDescription: '上传文件或将文件移入此文件夹以开始使用。',\n    noFilesDescription: '上传文件以开始组织您的打印相关文件。',\n    noMatchingFiles: '没有匹配的文件',\n    noMatchingFilesDescription: '没有文件匹配您当前的搜索或筛选条件。',\n    clearFilters: '清除筛选',\n    printedCount: '已打印 {{count}} 次',\n    uploadedBy: '上传者',\n    deleteFolder: '删除文件夹',\n    deleteFile: '删除文件',\n    deleteFilesCount: '删除 {{count}} 个文件',\n    deleteFolderConfirm: '确定要删除此文件夹吗？其中的所有文件也将被删除。',\n    deleteFileConfirm: '确定要删除此文件吗？',\n    deleteFilesConfirm: '确定要删除 {{count}} 个选中的文件吗？此操作无法撤销。',\n    deleting: '删除中...',\n    noPermissionRenameFolder: '您没有重命名文件夹的权限',\n    noPermissionLinkFolder: '您没有链接文件夹的权限',\n    noPermissionDeleteFolder: '您没有删除文件夹的权限',\n    noPermissionPrint: '您没有打印的权限',\n    noPermissionAddToQueue: '您没有添加到队列的权限',\n    noPermissionDownload: '您没有下载文件的权限',\n    noPermissionRenameFile: '您没有重命名此文件的权限',\n    noPermissionGenerateThumbnail: '您没有生成缩略图的权限',\n    noPermissionDeleteFile: '您没有删除此文件的权限',\n    noPermissionCreateFolder: '您没有创建文件夹的权限',\n    noPermissionUpload: '您没有上传文件的权限',\n    noPermissionMoveFiles: '您没有移动文件的权限',\n    noPermissionDeleteFiles: '您没有删除文件的权限',\n    // External folder\n    linkExternal: '链接外部',\n    linkExternalFolder: '链接外部文件夹',\n    linkExternalFolderDescription: '将主机目录（NAS、USB、网络共享）挂载到文件管理器中。文件不会被复制——直接从原始路径访问。',\n    externalFolderNamePlaceholder: '例如：NAS打印文件',\n    externalPath: '主机路径',\n    externalPathHelp: 'Docker主机上目录的绝对路径。必须以绑定挂载方式挂载到容器中。',\n    readOnly: '只读',\n    readOnlyHelp: '防止上传和删除',\n    showHiddenFiles: '显示隐藏文件（点文件）',\n    externalFolder: '外部文件夹',\n    scanFolder: '扫描',\n    toast: {\n      folderCreated: '文件夹已创建',\n      folderDeleted: '文件夹已删除',\n      fileDeleted: '文件已删除',\n      filesDeleted: '已删除 {{count}} 个文件',\n      filesMoved: '文件已移动',\n      folderLinked: '文件夹已链接',\n      folderUnlinked: '文件夹已取消链接',\n      externalFolderLinked: '外部文件夹已链接并扫描',\n      folderScanned: '扫描完成：添加 {{added}} 个，移除 {{removed}} 个',\n      addedToQueue: '已将 {{count}} 个文件添加到队列',\n      addedToQueuePartial: '已添加 {{added}} 个文件，{{failed}} 个失败',\n      failedToAddToQueue: '添加文件失败：{{error}}',\n      fileRenamed: '文件已重命名',\n      folderRenamed: '文件夹已重命名',\n      thumbnailsGenerated: '已生成 {{count}} 个缩略图',\n      thumbnailsGeneratedPartial: '已生成 {{succeeded}} 个缩略图，{{failed}} 个失败',\n      noStlMissingThumbnails: '没有缺少缩略图的 STL 文件',\n      failedToGenerateThumbnails: '生成缩略图失败：{{error}}',\n      thumbnailGenerated: '缩略图已生成',\n      failedToGenerateThumbnail: '生成缩略图失败：{{error}}',\n    },\n  },\n\n  // Projects\n  projects: {\n    title: '项目',\n    subtitle: '组织和跟踪您的 3D 打印项目',\n    newProject: '新建项目',\n    editProject: '编辑项目',\n    deleteProject: '删除项目',\n    projectName: '项目名称',\n    description: '描述',\n    noProjects: '暂无项目',\n    noProjectsFiltered: '没有{{status}}项目',\n    noProjectsFilteredHelp: '您没有任何{{status}}项目。当项目状态更改时，它们将出现在这里。',\n    createFirst: '创建您的第一个项目以开始组织相关打印、跟踪进度和管理构建。',\n    createFirstButton: '创建您的第一个项目',\n    create: '创建',\n    files: '文件',\n    prints: '打印',\n    plates: '板',\n    parts: '零件',\n    lastModified: '最后修改',\n    deleteConfirm: '确定要删除此项目吗？归档和队列项目将被取消链接但不会被删除。',\n    addFiles: '添加文件',\n    removeFile: '移除文件',\n    viewDetails: '查看详情',\n    // Modal fields\n    namePlaceholder: '例如：Voron 2.4 构建',\n    descriptionPlaceholder: '可选描述...',\n    color: '颜色',\n    targetPlates: '目标板数',\n    targetPlatesPlaceholder: '例如：25',\n    targetPlatesHelp: '打印任务数量',\n    targetParts: '目标零件数',\n    targetPartsPlaceholder: '例如：150',\n    targetPartsHelp: '所需零件总数',\n    tagsLabel: '标签（逗号分隔）',\n    tagsPlaceholder: '例如：voron、功能件、礼物',\n    dueDate: '截止日期',\n    priority: '优先级',\n    priorityLow: '低',\n    priorityNormal: '普通',\n    priorityHigh: '高',\n    priorityUrgent: '紧急',\n    // Status\n    statusActive: '进行中',\n    statusCompleted: '已完成',\n    statusArchived: '已归档',\n    done: '完成',\n    completed: '已完成',\n    failed: '失败',\n    inQueue: '队列中',\n    noPrintsYet: '暂无打印',\n    // Footer stats\n    printJobs: '打印任务（板）',\n    partsPrinted: '已打印零件',\n    failedParts: '失败零件',\n    // Actions\n    import: '导入',\n    export: '导出',\n    importProject: '导入项目',\n    exportAll: '导出所有项目',\n    loading: '加载项目中...',\n    // Permissions\n    noEditPermission: '您没有编辑项目的权限',\n    noDeletePermission: '您没有删除项目的权限',\n    noCreatePermission: '您没有创建项目的权限',\n    noImportPermission: '您没有导入项目的权限',\n    noExportPermission: '您没有导出项目的权限',\n    // Toast\n    toast: {\n      created: '项目已创建',\n      updated: '项目已更新',\n      deleted: '项目已删除',\n      imported: '项目已导入',\n      multipleImported: '已导入 {{count}} 个项目',\n      importFailed: '导入失败',\n      exported: '项目已导出（仅元数据）',\n    },\n  },\n\n  // Project detail page\n  projectDetail: {\n    notFound: '未找到项目',\n    backToProjects: '返回项目',\n    export: '导出',\n    exportProject: '导出项目',\n    noExportPermission: '您没有导出项目的权限',\n    noEditPermission: '您没有编辑项目的权限',\n    partOf: '属于：',\n    priorityLabel: '优先级：',\n    noPrints: '此项目暂无打印',\n    status: {\n      active: '进行中',\n      completed: '已完成',\n      archived: '已归档',\n    },\n    priority: {\n      low: '低',\n      normal: '普通',\n      high: '高',\n      urgent: '紧急',\n    },\n    dueDate: {\n      overdue: '已逾期',\n      today: '今天到期',\n      daysLeft: '还有 {{count}} 天',\n    },\n    progress: {\n      platesProgress: '板进度',\n      partsProgress: '零件进度',\n      printJobs: '打印任务',\n      parts: '零件',\n      percentComplete: '{{percent}}% 完成',\n      remaining: '剩余 {{count}} 个',\n    },\n    stats: {\n      printJobs: '打印任务',\n      total: '总计',\n      failed: '{{count}} 个失败',\n      partsPrinted: '已打印 {{count}} 个零件',\n      printTime: '打印时间',\n      filamentUsed: '耗材用量',\n    },\n    cost: {\n      title: '成本追踪',\n      filamentCost: '耗材成本',\n      energy: '能源',\n      totalCost: '总成本',\n      total: '总计',\n      includesBom: '含物料清单',\n      budget: '预算',\n      remaining: '剩余',\n    },\n    subProjects: {\n      title: '子项目 ({{count}})',\n    },\n    notes: {\n      title: '备注',\n      noEditPermission: '您没有编辑备注的权限',\n      placeholder: '添加关于此项目的备注...',\n      empty: '暂无备注。点击编辑添加备注。',\n    },\n    files: {\n      title: '文件',\n      linkFolders: '从文件管理器链接文件夹',\n      forQuickAccess: '到此项目以便快速访问。',\n      fileCount: '{{count}} 个文件',\n      empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。',\n      noFiles: '此文件夹中没有文件。',\n      print: '立即打印',\n      addToQueue: '加入队列',\n    },\n    bom: {\n      title: '材料清单',\n      acquired: '已获取 {{completed}}/{{total}}',\n      showAll: '显示全部',\n      hideDone: '隐藏已完成',\n      addPart: '添加零件',\n      noAddPermission: '您没有添加零件的权限',\n      partNamePlaceholder: '零件名称（例如：M3x8 螺丝）',\n      partName: '零件名称',\n      qty: '数量',\n      price: '价格 ({{currency}})',\n      sourcingUrlPlaceholder: '采购链接（可选）',\n      remarksPlaceholder: '备注（可选）',\n      deletePart: '删除零件',\n      deleteConfirm: '确定要删除\"{{name}}\"吗？',\n      noUpdatePermission: '您没有更新零件的权限',\n      noEditPermission: '您没有编辑零件的权限',\n      noDeletePermission: '您没有删除零件的权限',\n      totalCost: '总成本：',\n      empty: '材料清单中没有零件。添加硬件、电子元件或其他组件以跟踪需要采购的物品。',\n    },\n    timeline: {\n      title: '活动时间线',\n      empty: '暂无活动。',\n    },\n    template: {\n      saveAsTemplate: '保存为模板',\n      noCreatePermission: '您没有创建模板的权限',\n    },\n    queue: {\n      title: '队列',\n      viewAll: '查看全部',\n      printing: '{{count}} 个打印中',\n      queued: '{{count}} 个排队中',\n    },\n    prints: {\n      title: '打印 ({{count}})',\n    },\n    toast: {\n      projectUpdated: '项目已更新',\n      partAdded: '零件已添加',\n      partRemoved: '零件已移除',\n      exportFailed: '导出失败',\n      projectExported: '项目已导出',\n      templateCreated: '模板已创建',\n    },\n  },\n\n  // System info\n  system: {\n    title: '系统信息',\n    version: '版本',\n    uptime: '运行时间',\n    cpuUsage: 'CPU 使用率',\n    memoryUsage: '内存使用率',\n    diskUsage: '磁盘使用率',\n    networkInfo: '网络信息',\n    logs: '日志',\n    debugMode: '调试模式',\n    enableDebug: '启用调试日志',\n    disableDebug: '禁用调试日志',\n    downloadLogs: '下载日志',\n    clearLogs: '清除日志',\n    dockerInfo: 'Docker 信息',\n    containerName: '容器名称',\n    imageName: '镜像名称',\n    platform: '平台',\n    architecture: '架构',\n  },\n\n  // Library (K Profiles)\n  library: {\n    title: '耗材库',\n    addFilament: '添加耗材',\n    editFilament: '编辑耗材',\n    deleteFilament: '删除耗材',\n    vendor: '厂商',\n    material: '材料',\n    color: '颜色',\n    kFactor: 'K 值',\n    temperature: '温度',\n    noFilaments: '耗材库中没有耗材',\n    deleteConfirm: '确定要删除此耗材吗？',\n    importFromPrinter: '从打印机导入',\n    exportToFile: '导出到文件',\n  },\n\n  // Spoolman\n  spoolman: {\n    title: 'Spoolman 集成',\n    enabled: 'Spoolman 已启用',\n    url: 'Spoolman URL',\n    connected: '已连接',\n    disconnected: '未连接',\n    testConnection: '测试连接',\n    sync: '同步',\n    syncing: '同步中...',\n    lastSync: '上次同步',\n    linkToSpoolman: '链接到 Spoolman',\n    openInSpoolman: '在 Spoolman 中打开',\n    unlinkSpool: '取消链接耗材',\n    unlinkConfirmTitle: '解开线轴？',\n    unlinkConfirmMessage: '这将断开卷轴与 Spoolman 的连接。Spoolman 中的卷轴数据将保持不变。',\n    selectSpool: '选择耗材',\n    noUnlinkedSpools: '无未链接的耗材',\n    linkSuccess: '耗材已成功链接到 Spoolman',\n    linkFailed: '链接耗材失败',\n    unlinkSuccess: '已成功从 Spoolman 取消链接耗材',\n    unlinkFailed: '取消链接耗材失败',\n    spoolId: '耗材 ID',\n    fillSourceLabel: '(Spoolman)',\n    weight: '重量',\n    remaining: '剩余',\n    disableWeightSync: '禁用 AMS 估计重量同步',\n    disableWeightSyncDesc: '不从 AMS 估计值更新剩余容量。如果您更喜欢 Spoolman 的用量追踪而非 AMS 百分比估计，请使用此选项。新耗材仍将使用 AMS 估计值作为初始重量。',\n    reportPartialUsage: '报告失败打印的部分用量',\n    reportPartialUsageDesc: '当打印失败或被取消时，根据层进度报告估计的耗材使用量。',\n  },\n\n  // Inventory\n  inventory: {\n    title: '耗材库存',\n    addSpool: '添加耗材',\n    editSpool: '编辑耗材',\n    material: '材料',\n    selectMaterial: '选择材料...',\n    subtype: '子类型',\n    brand: '品牌',\n    searchBrand: '搜索品牌...',\n    useCustomBrand: '使用\"{{brand}}\"',\n    useCustomMaterial: '使用自定义材料：{{material}}',\n    colorName: '颜色名称',\n    colorNamePlaceholder: '翡翠白、烈焰红...',\n    color: '颜色',\n    hexColor: '十六进制颜色',\n    pickColor: '选择自定义颜色',\n    labelWeight: '标签重量',\n    coreWeight: '空盘重量',\n    searchSpoolWeight: '搜索耗材重量...',\n    weightUsed: '已使用',\n    currentWeight: '剩余重量',\n    measuredWeight: '称量重量',\n    spoolName: '线轴',\n    costPerKg: '每公斤成本',\n    measuredWeightError: '称量重量必须在 {{min}}g 到 {{max}}g 之间。',\n    slicerFilament: '切片耗材',\n    slicerFilamentName: '切片预设名称',\n    slicerPreset: '切片预设',\n    searchPresets: '搜索耗材预设...',\n    selectedPreset: '已选择',\n    noPresetsFound: '未找到预设',\n    tempOverrides: '温度覆盖',\n    note: '备注',\n    notePlaceholder: '关于此耗材的任何备注...',\n    archive: '归档',\n    restore: '恢复',\n    noSpools: '暂无耗材。添加您的第一个耗材开始使用。',\n    noManualSpools: '没有手动添加的耗材。请先向库存中添加耗材。',\n    kProfiles: 'K 值配置',\n    addKProfile: '添加 K 值配置',\n    assignSpool: '分配耗材',\n    unassignSpool: '取消分配',\n    assignSuccess: '耗材已分配，AMS 槽位已配置',\n    assignFailed: '分配耗材失败',\n    assignMismatchTitle: '材料不匹配',\n    assignMismatchMessage: '所选线轴材料 \"{{spoolMaterial}}\" 与 {{location}} 的料槽材料 \"{{trayMaterial}}\" 不匹配。仍要分配吗？',\n    assignMismatchConfirm: '仍然分配',\n    assignPartialMismatchMessage: '线轴材料 \"{{spoolMaterial}}\" 与 {{location}} 的 \"{{trayMaterial}}\" 相近但不完全一致。是否继续？',\n    assignProfileMismatchMessage: '线轴配置 \"{{spoolProfile}}\" 与 {{location}} 的料槽配置 \"{{trayProfile}}\" 不一致。是否继续？',\n    selectSpool: '选择要分配到此槽位的耗材',\n    assigned: '已分配',\n    assigning: '分配中...',\n    searchSpools: '搜索耗材...',\n    showAllSpools: '显示所有耗材',\n    allMaterials: '所有材料',\n    filterByBrand: '按品牌筛选...',\n    showArchived: '显示已归档',\n    quickAdd: '快速添加（库存）',\n    quantity: '数量',\n    stock: '库存',\n    configured: '已配置',\n    spoolsCreated: '已创建 {{count}} 个耗材',\n    spoolCreated: '耗材已创建',\n    spoolUpdated: '耗材已更新',\n    spoolDeleted: '耗材已删除',\n    spoolArchived: '耗材已归档',\n    spoolRestored: '耗材已恢复',\n    deleteConfirm: '确定要删除此耗材吗？此操作无法撤销。',\n    archiveConfirm: '确定要归档此耗材吗？',\n    advancedSettings: '高级设置',\n    // Tabs\n    filamentInfoTab: '耗材信息',\n    paProfileTab: 'PA 配置',\n    filamentInfo: '耗材',\n    additional: '附加',\n    // Cloud\n    loadingPresets: '加载云端预设中...',\n    cloudConnected: '云端已连接',\n    cloudNotConnected: '云端未连接（使用默认值）',\n    // Colors\n    recentColors: '最近',\n    searchColors: '搜索颜色...',\n    searchResults: '搜索结果',\n    allColors: '所有颜色',\n    commonColors: '常用颜色',\n    showLess: '显示更少',\n    showAll: '显示全部',\n    noColorsFound: '没有颜色匹配您的搜索',\n    noResults: '未找到匹配项',\n    // PA Profiles\n    selectMaterialFirst: '请先在耗材信息选项卡中选择材料。',\n    noPrintersConfigured: '未配置打印机。添加打印机以使用 PA 配置。',\n    matchingFilter: '匹配',\n    anyBrand: '任何品牌',\n    anyVariant: '任何变体',\n    autoSelect: '自动选择',\n    matches: '匹配',\n    match: '匹配',\n    noMatches: '无匹配',\n    connected: '已连接',\n    offline: '离线',\n    printerOffline: '打印机离线。连接后查看校准配置。',\n    noKProfilesMatch: '没有 K 值配置匹配所选耗材。',\n    leftNozzle: '左喷嘴',\n    rightNozzle: '右喷嘴',\n    profilesSelected: '个校准配置已选择',\n    // Stats & enhanced table\n    totalInventory: '总库存',\n    totalConsumed: '总消耗',\n    byMaterial: '按材料',\n    inPrinter: '在打印机中',\n    lowStock: '库存不足',\n    sinceTracking: '自开始追踪',\n    loadedInAms: '已装载到 AMS/外置',\n    remaining: '剩余',\n    weightCheck: '重量检查',\n    lastWeighed: '上次称量',\n    neverWeighed: '从未称量',\n    search: '搜索耗材...',\n    showing: '显示',\n    to: '到',\n    of: '共',\n    show: '显示',\n    spools: '个耗材',\n    spool: '个耗材',\n    page: '页',\n    noSpoolsMatch: '未找到结果',\n    noSpoolsMatchDesc: '尝试调整您的搜索或筛选条件。',\n    active: '活跃',\n    archived: '已归档',\n    all: '全部',\n    used: '已使用',\n    new: '新的',\n    clearFilters: '清除筛选',\n    table: '表格',\n    cards: '卡片',\n    net: '净重',\n    // Grouping\n    groupSimilar: '分组',\n    groupedSpools: '{{count}} 个相同耗材',\n    groupedRows: '行',\n    // Column config\n    columns: '列',\n    configureColumns: '配置列',\n    configureColumnsDesc: '拖动以重新排序列或使用箭头。使用眼睛图标切换可见性。',\n    visible: '可见',\n    reset: '重置',\n    cancel: '取消',\n    applyChanges: '应用更改',\n    moveUp: '上移',\n    moveDown: '下移',\n    hideColumn: '隐藏列',\n    showColumn: '显示列',\n    // Tag linking\n    linkToSpool: '链接到耗材',\n    tagLinked: '标签已链接到耗材',\n    tagLinkFailed: '链接标签失败',\n    tagAlreadyLinked: '标签已链接到其他耗材',\n    unknownTag: '检测到未知 RFID 标签',\n    // Usage history\n    usageHistory: '使用历史',\n    noUsageHistory: '暂无使用记录',\n    printName: '打印名称',\n    weightConsumed: '消耗重量',\n    clearHistory: '清除',\n    historyCleared: '使用历史已清除',\n    fillSourceLabel: '(库存)',\n    lowStockThresholdError: '阈值必须在 0.1 到 99.9 之间',\n  },\n\n  // Timelapse\n  timelapse: {\n    title: '延时摄影',\n    create: '创建延时摄影',\n    download: '下载',\n    delete: '删除',\n    preview: '预览',\n    frameRate: '帧率',\n    quality: '质量',\n    processing: '处理中...',\n    noTimelapses: '无可用延时摄影',\n  },\n\n  // AMS\n  ams: {\n    title: 'AMS',\n    slot: '槽位',\n    empty: '空',\n    emptySlot: '空槽位',\n    unknown: '未知',\n    humidity: '湿度',\n    temperature: '温度',\n    filamentType: '耗材类型',\n    filamentColor: '颜色',\n    remaining: '剩余',\n    history: 'AMS 历史',\n    noHistory: '无可用历史',\n    configureSlot: '配置槽位',\n    externalSpool: '外置耗材',\n    profile: '配置',\n    kFactor: 'K 值',\n    fill: '填充',\n    configure: '配置',\n    used: '已使用',\n    remainingUnit: '剩余',\n  },\n\n  // Print modal\n  printModal: {\n    title: '开始打印',\n    selectPrinter: '选择打印机',\n    selectPlate: '选择板',\n    filamentMapping: '耗材映射',\n    totalCost: '总成本：',\n    slotRemainingShort: ' - 剩余 {{grams}}g',\n    printSettings: '打印设置',\n    bedLeveling: '热床调平',\n    flowCalibration: '流量校准',\n    vibrationCalibration: '振动校准',\n    layerInspection: '首层检查',\n    timelapse: '延时摄影',\n    startPrint: '开始打印',\n    addToQueue: '添加到队列',\n    cancel: '取消',\n    noPrintersAvailable: '无可用打印机',\n    printerBusy: '打印机忙碌',\n    printerOffline: '打印机离线',\n    sameTypeDifferentColor: '相同类型，不同颜色',\n    filamentTypeNotLoaded: '耗材类型未装载',\n    openCalendar: '打开日历',\n    leftNozzle: '左',\n    rightNozzle: '右',\n    leftNozzleTooltip: '左喷嘴',\n    rightNozzleTooltip: '右喷嘴',\n    filamentOverride: '耗材覆盖',\n    filamentOverrideHint: '可选覆盖用于基于模型的耗材分配。调度器将使用您选择的耗材而不是原始 3MF 值进行匹配。',\n    originalFilament: '原始',\n    overrideWith: '覆盖为',\n    resetToOriginal: '恢复为原始',\n    insufficientFilamentTitle: '耗材不足',\n    insufficientFilamentMessage: '部分已分配线轴的剩余耗材少于本次打印所需：',\n    insufficientFilamentLine: '{{printer}} - {{slot}}：需要 {{required}}g，剩余 {{remaining}}g',\n    printAnyway: '仍然打印',\n    forceColorMatch: '强制颜色匹配',\n    staggerPrinterStarts: 'Stagger printer starts',\n    staggerGroupSize: 'Group size',\n    staggerInterval: 'Interval (min)',\n    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',\n    staggerLastGroup: 'last group: {{count}}',\n    staggerTotal: 'total: {{minutes}} min',\n    staggerToPrinters: '分批发送到 {{count}} 台打印机',\n    gcodeInjection: '注入自动打印G-code',\n  },\n\n  // Backup\n  backup: {\n    title: '备份与恢复',\n    createBackup: '创建备份',\n    restoreBackup: '恢复备份',\n    restoreDescription: '从备份文件替换所有数据',\n    downloadBackup: '下载备份',\n    uploadBackup: '上传备份',\n    lastBackup: '上次备份',\n    autoBackup: '自动备份',\n    backupNow: '立即备份',\n    restoreWarning: '警告：恢复备份将覆盖所有当前数据。',\n    includeArchives: '包含归档',\n    includeSettings: '包含设置',\n    includeProfiles: '包含配置文件',\n    backupSuccess: '备份创建成功',\n    restoreSuccess: '备份恢复成功',\n    backupFailed: '备份失败',\n    restoreFailed: '恢复失败',\n    restoreNote: '恢复期间虚拟打印机将停止',\n\n    // GitHub Backup\n    githubBackup: 'GitHub 备份',\n    enabled: '已启用',\n    cloudLoginRequired: '需要登录 Bambu Cloud。请在 配置文件 → 云配置文件 中登录以启用 GitHub 备份。',\n    cloudLoginRequiredShort: '需要Cloud登录',\n    githubDescription: '自动将您的配置文件同步到私有 GitHub 仓库以进行备份和版本历史记录。',\n    repositoryUrl: '仓库 URL',\n    personalAccessToken: '个人访问令牌',\n    tokenSaved: '（已保存）',\n    enterNewToken: '输入新令牌以更新',\n    tokenHint: '具有内容读写权限的细粒度令牌',\n    branch: '分支',\n    manualOnly: '仅手动',\n    hourly: '每小时',\n    daily: '每天',\n    weekly: '每周',\n    includeInBackup: '包含在备份中',\n    kProfiles: 'K 配置文件',\n    kProfilesDescription: '来自已连接打印机的压力推进校准',\n    noPrintersConnected: '没有打印机连接',\n    printersConnected: '{{connected}}/{{total}} 已连接',\n    cloudProfiles: '云配置文件',\n    cloudProfilesDescription: '来自 Bambu Cloud 的耗材、打印机和工艺预设',\n    appSettings: '应用设置',\n    appSettingsDescription: 'Bambuddy 配置（完整数据库）',\n    spoolInventory: '耗材库存',\n    spoolInventoryDescription: '耗材卷轴、使用记录和成本追踪',\n    printArchives: '打印档案',\n    printArchivesDescription: '打印历史元数据（不含 gcode/3MF 文件）',\n    lastBackupAt: '上次备份：',\n    noBackupsYet: '尚无备份',\n    next: '下次：',\n    startingBackup: '正在启动备份...',\n    test: '测试',\n    enableBackup: '启用备份',\n    testConnection: '测试连接',\n    enterRepoUrl: '请输入仓库 URL',\n    enterRepoAndToken: '请输入仓库 URL 和访问令牌',\n    repoRequired: '仓库 URL 为必填项',\n    tokenRequired: '访问令牌为必填项',\n    githubBackupEnabled: 'GitHub 备份已启用',\n    tokenUpdated: '令牌已更新',\n    settingsSaved: '设置已保存',\n    failedToSave: '保存失败：{{message}}',\n    backupCompleteFiles: '备份完成 - {{count}} 个文件已更新',\n    backupSkippedNoChanges: '备份已跳过 - 无更改',\n    backupFailed2: '备份失败：{{message}}',\n    clearedLogs: '已清除 {{count}} 条日志',\n    failedToClearLogs: '清除日志失败：{{message}}',\n\n    // History\n    history: '历史记录',\n    clear: '清除',\n    date: '日期',\n    status: '状态',\n    commit: '提交',\n\n    // Local Backup\n    localBackup: '本地备份',\n    localBackupDescription: '创建 Bambuddy 数据的完整备份，包括数据库、档案、上传和所有文件。',\n    downloadBackupLabel: '下载备份',\n    completeBackupZip: '完整备份：数据库 + 所有文件（ZIP）',\n    download: '下载',\n    preparingBackup: '正在准备备份...',\n    creatingArchive: '正在创建备份归档...对于大型归档可能需要一些时间。',\n    downloadingFile: '正在下载备份文件...',\n    backupDownloaded: '备份下载成功',\n    failedToCreateBackup: '创建备份失败：{{message}}',\n    restore: '恢复',\n    restoreReplacesAll: '恢复将替换所有数据。',\n    restoreReplacesAllDetail: '您当前的数据库和文件将被完全替换。恢复后需要重启。',\n    restoreConfirmTitle: '恢复备份',\n    restoreConfirmMessage: '您确定要从\"{{filename}}\"恢复吗？这将完全替换您当前的数据库和所有文件。恢复后需要重启应用程序。',\n    restoreConfirmButton: '恢复备份',\n    uploadingFile: '正在上传备份文件...',\n    backupRestoredRestart: '备份已恢复。请重启 Bambuddy。',\n    failedToRestore: '恢复备份失败。请检查文件格式。',\n    reloadNow: '立即重新加载',\n    creatingBackup: '正在创建备份',\n    restoringBackup: '正在恢复备份',\n    preparing: '准备中...',\n    processing: '处理中...',\n    doNotClosePage: '请不要关闭此页面或导航离开。对于大型备份，此操作可能需要几分钟。',\n\n    // RestoreModal\n    restoring: '恢复中...',\n    restoreComplete: '恢复完成',\n    restoreFailed2: '恢复失败',\n    importSettings: '从备份文件导入设置',\n    pleaseWaitRestoring: '请等待数据恢复中',\n    selectBackupFile: '点击选择备份文件（.json 或 .zip）',\n    duplicateHandling: '重复项处理方式：',\n    matchPrinters: '打印机',\n    matchPrintersBy: '按序列号匹配',\n    matchSmartPlugs: '智能插座',\n    matchSmartPlugsBy: '按 IP 地址匹配',\n    matchNotificationProviders: '通知提供者',\n    matchNotificationProvidersBy: '按名称匹配',\n    matchFilaments: '耗材',\n    matchFilamentsBy: '按名称 + 类型 + 品牌匹配',\n    matchArchives: '档案',\n    matchArchivesBy: '按内容哈希匹配（始终跳过）',\n    matchPendingUploads: '待上传',\n    matchPendingUploadsBy: '按文件名匹配',\n    matchSettingsTemplates: '设置和模板',\n    matchSettingsTemplatesBy: '始终覆盖',\n    replaceExisting: '替换现有数据',\n    keepExisting: '保留现有数据',\n    overwriteDescription: '用备份数据覆盖已存在的项目',\n    keepDescription: '仅恢复尚不存在的项目',\n    overwriteCaution: '注意：',\n    overwriteWarning: '覆盖将用备份数据替换您当前的配置。出于安全考虑，打印机访问代码永远不会被覆盖。',\n    cancel: '取消',\n    processingBackup: '正在处理备份文件...',\n    itemsRestored: '已恢复项目',\n    itemsSkipped: '已跳过项目',\n    restored: '已恢复',\n    skippedAlreadyExist: '已跳过（已存在）',\n    filesCategory: '文件（3MF、缩略图等）',\n    andMore: '...还有 {{count}} 项',\n    newApiKeysGenerated: '已生成新的 API 密钥',\n    keysShownOnce: '这些密钥仅显示一次。请立即复制！',\n    copy: '复制',\n    noDataFound: '在备份文件中未找到可恢复的数据。',\n    close: '关闭',\n\n    // Scheduled local backups (#884)\n    scheduledBackup: 'Scheduled Backups',\n    scheduledBackupDescription: 'Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.',\n    frequency: 'Frequency',\n    backupTime: 'Time',\n    retention: 'Retention',\n    retentionDescription: 'Number of backups to keep',\n    outputPath: 'Output Path',\n    outputPathPlaceholder: 'Default: {{path}}',\n    outputPathDescription: 'Leave empty for default location',\n    runNow: 'Run Now',\n    backupFiles: 'Backup Files',\n    noScheduledBackups: 'No backups yet',\n    deleteBackup: 'Delete',\n    deleteBackupConfirm: 'Delete this backup file?',\n    backupRunning: 'Backup in progress...',\n    scheduledBackupComplete: 'Backup completed successfully',\n    scheduledBackupFailed: 'Backup failed',\n    nextBackup: 'Next backup',\n    backupSize: 'Size',\n    utc: 'UTC',\n    defaultPathLabel: 'Default:',\n\n    // Category labels\n    categories: {\n      settings: '设置',\n      notification_providers: '通知提供者',\n      notification_templates: '通知模板',\n      smart_plugs: '智能插座',\n      printers: '打印机',\n      filaments: '耗材',\n      maintenance_types: '维护类型',\n      archives: '档案',\n      projects: '项目',\n      pending_uploads: '待上传',\n      external_links: '外部链接',\n      api_keys: 'API 密钥',\n    },\n  },\n\n  // Tags\n  tags: {\n    title: '标签',\n    addTag: '添加标签',\n    editTag: '编辑标签',\n    deleteTag: '删除标签',\n    tagName: '标签名称',\n    tagColor: '标签颜色',\n    noTags: '无标签',\n    deleteConfirm: '确定要删除此标签吗？',\n    manageTags: '管理标签',\n  },\n\n  // Upload modal (archives)\n  uploadModal: {\n    title: '上传 3MF 文件',\n    dragDrop: '将 .3mf 文件拖放到此处',\n    or: '或',\n    browseFiles: '浏览文件',\n    extractionInfo: '将从 3MF 文件元数据中自动提取打印机型号。',\n    uploaded: '已上传',\n    failed: '失败',\n    uploading: '上传中...',\n    upload: '上传',\n    uploadFailed: '上传失败',\n  },\n\n  // Edit archive modal\n  // Edit Archive Modal\n  editArchive: {\n    title: '编辑归档',\n    name: '名称',\n    namePlaceholder: '打印名称',\n    printer: '打印机',\n    noPrinter: '无打印机',\n    project: '项目',\n    noProject: '无项目',\n    itemsPrinted: '打印数量',\n    itemsPrintedHelp: '此打印任务中生产的物品数量',\n    notes: '备注',\n    notesPlaceholder: '添加关于此打印的备注...',\n    externalLink: '外部链接',\n    externalLinkPlaceholder: 'https://printables.com/model/...',\n    externalLinkHelp: '链接到 Printables、Thingiverse 或其他来源',\n    tags: '标签',\n    tagsPlaceholder: '添加标签...',\n    addMoreTags: '添加更多标签...',\n    matchingTags: '匹配\"{{query}}\"',\n    existingTags: '现有标签',\n    clickToAdd: '（点击添加）',\n    status: '状态',\n    failureReason: '失败原因',\n    selectReason: '选择原因...',\n    photos: '打印成品照片',\n    photosHelp: '点击 + 添加打印成品照片',\n    printResult: '打印成品',\n    saving: '保存中...',\n    // Failure reasons\n    failureReasons: {\n      adhesionFailure: '附着力失败',\n      spaghettiDetached: '拉丝 / 脱落',\n      layerShift: '层偏移',\n      cloggedNozzle: '喷嘴堵塞',\n      filamentRunout: '耗材用完',\n      warping: '翘曲',\n      stringing: '拉丝',\n      underExtrusion: '挤出不足',\n      powerFailure: '断电',\n      userCancelled: '用户取消',\n      other: '其他',\n    },\n    // Archive statuses\n    statuses: {\n      completed: '已完成',\n      failed: '失败',\n      aborted: '已取消',\n      printing: '打印中',\n    },\n  },\n\n  // K-Profiles\n  kProfiles: {\n    title: 'K 值配置',\n    noPrintersConfigured: '未配置打印机',\n    addPrinterInSettings: '在设置中添加打印机以管理 K 值配置',\n    noActivePrinters: '无活跃打印机',\n    enablePrinterConnection: '启用打印机连接以查看其 K 值配置',\n    loadingProfiles: '加载 K 值配置中...',\n    printerOffline: '打印机离线',\n    printerOfflineDesc: '所选打印机未连接。开启电源以查看 K 值配置。',\n    noMatchingProfiles: '无匹配的配置',\n    noMatchingProfilesDesc: '没有配置匹配您的搜索条件',\n    noKProfiles: '无 K 值配置',\n    noKProfilesDesc: '未找到 {{diameter}}mm 喷嘴的压力推进配置',\n    createFirstProfile: '创建第一个配置',\n    // Controls\n    printer: '打印机',\n    nozzle: '喷嘴',\n    refresh: '刷新',\n    addProfile: '添加配置',\n    export: '导出',\n    import: '导入',\n    select: '选择',\n    selectAll: '全选',\n    delete: '删除',\n    // Filters\n    searchPlaceholder: '按名称或耗材搜索...',\n    allExtruders: '所有挤出机',\n    leftOnly: '仅左侧',\n    rightOnly: '仅右侧',\n    allFlow: '所有流量',\n    hfOnly: '仅高流量',\n    sOnly: '仅标准',\n    sortName: '排序：名称',\n    sortKValue: '排序：K 值',\n    sortFilament: '排序：耗材',\n    // Dual extruder labels\n    leftExtruder: '左挤出机',\n    rightExtruder: '右挤出机',\n    // Modal\n    modal: {\n      addTitle: '添加 K 值配置',\n      editTitle: '编辑 K 值配置',\n      profileName: '配置名称',\n      profileNamePlaceholder: '我的 PLA 配置',\n      kValue: 'K 值',\n      kValuePlaceholder: '0.020',\n      kValueHelp: '典型范围：PLA 0.01 - 0.06，PETG 0.02 - 0.10',\n      filament: '耗材',\n      selectFilament: '选择耗材...',\n      noFilamentsHelp: '未找到耗材。请先在 Bambu Studio 中创建 K 值配置。',\n      flowType: '流量类型',\n      highFlow: '高流量',\n      standard: '标准',\n      nozzleSize: '喷嘴尺寸',\n      extruder: '挤出机',\n      extruders: '挤出机',\n      left: '左',\n      right: '右',\n      notes: '备注（本地存储）',\n      notesPlaceholder: '添加关于此配置的备注...',\n      notesHelp: '备注保存在 Bambuddy 中，不在打印机上',\n      syncing: '与打印机同步中...',\n      savingExtruder: '保存到挤出机 {{current}}/{{total}}...',\n      pleaseWait: '请稍候',\n    },\n    // Delete confirmation\n    deleteConfirm: {\n      title: '删除配置',\n      cannotUndo: '此操作无法撤销',\n      message: '确定要从打印机删除\"{{name}}\"吗？',\n    },\n    // Bulk delete\n    bulkDelete: {\n      title: '删除配置',\n      cannotUndo: '此操作无法撤销',\n      message: '确定要从打印机删除 {{count}} 个选中的配置吗？',\n    },\n    // Toast\n    toast: {\n      profileSaved: 'K 值配置已保存',\n      profilesSaved: 'K 值配置已保存到 {{count}} 个挤出机',\n      selectAtLeastOneExtruder: '请至少选择一个挤出机',\n      profileDeleted: 'K 值配置已删除',\n      profilesDeleted: '已删除 {{count}} 个配置',\n      exportedProfiles: '已导出 {{count}} 个配置',\n      importedProfiles: '已导入 {{count}} / {{total}} 个配置',\n      noProfilesToExport: '无可导出的配置',\n      invalidFileFormat: '无效的文件格式',\n      failedToParseImport: '解析导入文件失败',\n      failedToSaveBatch: '批量保存 K 值配置失败',\n      noteSaved: '备注已保存',\n      failedToSaveNote: '保存备注失败',\n    },\n    // Permissions\n    permission: {\n      noRead: '您没有刷新配置的权限',\n      noCreate: '您没有添加配置的权限',\n      noUpdate: '您没有更新 K 值配置的权限',\n      noDelete: '您没有删除 K 值配置的权限',\n      noExport: '您没有导出配置的权限',\n      noImport: '您没有导入配置的权限',\n    },\n  },\n\n  // Virtual Printer\n  virtualPrinter: {\n    title: '虚拟打印机',\n    running: '运行中',\n    stopped: '已停止',\n    description: {\n      default: '启用虚拟打印机，使其在 Bambu Studio 和 OrcaSlicer 中可见。发送到此打印机的文件将直接归档而不打印。',\n      proxy: '启用代理，将切片软件流量中继到真实打印机，允许在任何网络上远程打印。',\n    },\n    enable: {\n      title: '启用虚拟打印机',\n      visibleInSlicer: '在切片软件发现中显示为\"Bambuddy\"',\n      proxyingTo: '代理到 {{name}}',\n      notActive: '未激活',\n    },\n    model: {\n      title: '打印机型号',\n      description: '选择要模拟的打印机型号。',\n      restartWarning: '更改型号将重启虚拟打印机',\n    },\n    accessCode: {\n      title: '访问码',\n      isSet: '访问码已设置',\n      notSet: '未设置访问码 - 需要设置才能启用',\n      placeholder: '输入 8 位字符代码',\n      placeholderChange: '输入新代码以更改',\n      hint: '必须恰好 8 个字符。切片软件使用此代码进行认证。',\n      charCount: '({{count}}/8)',\n    },\n    targetPrinter: {\n      title: '目标打印机',\n      configured: '代理目标已配置',\n      notConfigured: '未选择目标打印机 - 代理模式需要设置',\n      placeholder: '选择打印机...',\n      hint: '选择要将切片软件流量代理到的打印机。打印机必须处于局域网模式。',\n      noPrinters: '未配置打印机。请先添加打印机以使用代理模式。',\n    },\n    remoteInterface: {\n      title: '网络接口覆盖',\n      configured: '接口覆盖已激活',\n      optional: '可选 - 当自动检测的 IP 不正确时使用（例如多网卡、Docker、VPN）',\n      placeholder: '自动检测（默认）...',\n      hint: '覆盖通过 SSDP 广播并在 TLS 证书中使用的 IP 地址。在 Bambuddy 有多个网络接口时很有用。',\n    },\n    mode: {\n      title: '模式',\n      archive: '归档',\n      archiveDesc: '立即归档文件',\n      review: '审核',\n      reviewDesc: '归档前审核',\n      queue: '队列',\n      queueDesc: '归档并添加到队列',\n      proxy: '代理',\n      proxyDesc: '中继到真实打印机',\n    },\n    autoDispatch: {\n      title: '自动派发',\n      description: '添加到队列时自动开始打印。关闭后，打印任务等待手动派发。',\n    },\n    setupRequired: {\n      title: '需要设置',\n      description: '虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。',\n      readGuide: '启用前请阅读设置指南',\n    },\n    howItWorks: {\n      title: '工作原理',\n      step1: '在同一局域网中，虚拟打印机会通过发现机制自动出现在您的切片软件（Bambu Studio / OrcaSlicer）中。从其他网络，通过 IP 地址和访问码手动添加。',\n      step2: '在归档、审核和队列模式下，使用切片软件中的\"发送\"按钮将 3MF 文件上传到 Bambuddy。切片软件会显示\"打印成功\"— 文件已存储，未打印。',\n      step3: '在代理模式下，虚拟打印机将所有流量中继到真实打印机 — 打印会立即开始，就像直接连接一样。',\n    },\n    status: {\n      title: '状态详情',\n      printerName: '打印机名称',\n      model: '型号',\n      serialNumber: '序列号',\n      mode: '模式',\n      pendingFiles: '待处理文件',\n      targetPrinter: '目标打印机',\n      ftpPort: 'FTP 端口',\n      mqttPort: 'MQTT 端口',\n      ftpConnections: 'FTP 连接',\n      mqttConnections: 'MQTT 连接',\n    },\n    toast: {\n      updated: '虚拟打印机设置已更新',\n      failedToUpdate: '更新设置失败',\n      accessCodeRequired: '请先设置访问码',\n      targetPrinterRequired: '请先选择目标打印机',\n      bindIpRequired: '请先设置绑定 IP',\n      accessCodeEmpty: '访问码不能为空',\n      accessCodeLength: '访问码必须恰好 8 个字符',\n      created: '虚拟打印机已创建',\n      failedToCreate: '创建虚拟打印机失败',\n      deleted: '虚拟打印机已删除',\n      failedToDelete: '删除虚拟打印机失败',\n    },\n    list: {\n      title: '虚拟打印机',\n      add: '添加',\n      addFirst: '添加虚拟打印机',\n      empty: '未配置虚拟打印机。添加一个以开始使用。',\n    },\n    bindIp: {\n      title: '绑定接口',\n      placeholder: '选择接口...',\n      hint: '此虚拟打印机绑定的网络接口。每台打印机必须唯一。',\n    },\n    proxy: {\n      accessCodeHint: '在代理模式下，在切片软件中使用目标打印机的访问码。连接会透明转发到真实打印机。',\n    },\n    addDialog: {\n      title: '添加虚拟打印机',\n      name: '名称',\n      hint: '创建后可以配置访问码、目标打印机和其他设置。',\n      create: '创建',\n    },\n    deleteConfirm: {\n      title: '删除虚拟打印机',\n      message: '确定要删除\"{{name}}\"吗？这将停止此打印机的所有服务。',\n    },\n  },\n\n  // Model Viewer\n  modelViewer: {\n    openInSlicer: '在切片软件中打开',\n    tabs: {\n      model: '3D 模型',\n      gcode: 'G-code 预览',\n    },\n    notAvailable: '不可用',\n    notSliced: '未切片',\n    plates: '板',\n    allPlates: '所有板',\n    plateNumber: '板 {{number}}',\n    plateCount: '{{count}} 个板',\n    plateCount_other: '{{count}} 个板',\n    objectCount: '{{count}} 个对象',\n    objectCount_other: '{{count}} 个对象',\n    filamentCount: '{{count}} 种耗材',\n    filamentCount_other: '{{count}} 种耗材',\n    eta: '预计 {{minutes}} 分钟',\n    noPreview: '此文件无可用预览',\n    pagination: {\n      pageOf: '第 {{current}} / {{total}} 页',\n      prev: '上一页',\n      next: '下一页',\n    },\n    errors: {\n      failedToLoad: '加载文件失败',\n      noMeshes: '3MF 文件中未找到网格',\n      unsupportedFormat: '不支持的文件格式',\n    },\n  },\n\n  // Maintenance type descriptions (built-in)\n  maintenanceDescriptions: {\n    lubricateCarbonRods: '在碳纤维杆上涂抹润滑剂以确保顺畅运动',\n    lubricateRails: '在线性导轨上涂抹润滑剂以确保顺畅运动',\n    cleanNozzle: '清洁热端和喷嘴以防止堵塞',\n    checkBelts: '检查皮带张力以确保打印精度',\n    cleanBuildPlate: '清洁构建板以获得更好的附着力',\n    checkExtruder: '检查挤出机齿轮磨损情况',\n    checkCooling: '确保冷却风扇正常工作',\n    generalInspection: '打印机综合检查',\n    cleanCarbonRods: '清洁碳纤维杆以减少摩擦',\n    lubricateSteelRods: '在钢杆上涂抹润滑剂以确保顺畅运动',\n    cleanSteelRods: '清洁钢杆以减少摩擦',\n    cleanLinearRails: '擦拭线性导轨以清除灰尘和碎屑',\n    checkPtfeTube: '检查 PTFE 管的磨损或损坏',\n    replaceHepaFilter: '更换 HEPA 过滤器以保证空气质量',\n    replaceCarbonFilter: '更换活性炭过滤器',\n    lubricateLeftNozzleRail: '润滑左喷嘴导轨（H2 系列）',\n  },\n\n  // Smart Plugs\n  smartPlugs: {\n    offline: '离线',\n    admin: '管理',\n    openPlugAdminPage: '打开插座管理页面',\n    deleteSmartPlug: '删除智能插座',\n    turnOnSmartPlug: '开启智能插座',\n    turnOffSmartPlug: '关闭智能插座',\n    turnOn: '开启',\n    turnOff: '关闭',\n    addSmartPlug: {\n      scanningNetwork: '扫描网络中...',\n      chooseEntity: '选择实体...',\n      connectionFailed: '连接失败',\n      searchEntities: '搜索实体...',\n      searchPowerSensors: '搜索功率传感器...',\n      searchEnergySensors: '搜索能量传感器...',\n      placeholders: {\n        plugName: '客厅插座',\n        mqttStateOnValue: 'ON、true、1',\n        mqttSameAsPower: '与功率主题相同，或不同',\n      },\n    },\n    // SmartPlugCard\n    linkedTo: '关联到：',\n    monitorOnly: '仅监控',\n    alerts: '警报',\n    scheduleOn: '开启 {{time}}',\n    scheduleOff: '关闭 {{time}}',\n    on: '开启',\n    off: '关闭',\n    power: '功率',\n    kwhToday: '今日kWh',\n    settings: '设置',\n    automationSettings: '自动化设置',\n    showInSwitchbar: '在开关栏显示',\n    quickAccessSidebar: '从侧边栏快速访问',\n    enabled: '已启用',\n    enableAutomation: '为此插座启用自动化',\n    autoOn: '自动开启',\n    autoOnDescription: '打印开始时开启',\n    autoOff: '自动关闭',\n    autoOffDescription: '打印完成时关闭（一次性）',\n    autoOffPersistent: '保持启用',\n    autoOffPersistentDescription: '在打印之间保持启用而非一次性',\n    turnOffDelayMode: '关闭延迟模式',\n    time: '时间',\n    temp: '温度',\n    delayMinutes: '延迟（分钟）',\n    tempThreshold: '温度阈值（°C）',\n    tempThresholdDescription: '当喷嘴冷却到此温度以下时关闭',\n    edit: '编辑',\n    deleteConfirm: '确定要删除\"{{name}}\"吗？此操作无法撤销。',\n    turnOnConfirm: '确定要开启\"{{name}}\"吗？',\n    turnOffConfirm: '确定要关闭\"{{name}}\"吗？这将切断连接设备的电源。',\n    failedToTurn: '无法{{action}}\"{{name}}\"',\n    unknown: '未知',\n    // AddSmartPlugModal\n    addTitle: '添加智能插座',\n    editTitle: '编辑智能插座',\n    stopScanning: '停止扫描',\n    discoverTasmota: '发现Tasmota设备',\n    foundDevices: '找到{{count}}个设备 - 点击选择：',\n    noDevicesFound: '未在您的网络中找到Tasmota设备',\n    haNotConfigured: 'Home Assistant未配置。请在以下位置设置',\n    haSettingsPath: '设置 → 网络 → Home Assistant',\n    selectEntity: '选择实体 *',\n    ipAddress: 'IP地址 *',\n    nameLabel: '名称 *',\n    username: '用户名',\n    password: '密码',\n    authHint: '如果您的Tasmota设备不需要认证，请留空',\n    linkToPrinter: '关联打印机',\n    noPrinter: '无打印机（仅手动控制）',\n    linkingDescription: '关联后可在打印开始/完成时自动开关',\n    powerAlerts: '功率警报',\n    alertAbove: '高于时警报（W）',\n    alertBelow: '低于时警报（W）',\n    alertDescription: '当电力消耗超过这些阈值时收到通知。留空以禁用该方向。',\n    dailySchedule: '每日计划',\n    turnOnAt: '开启时间',\n    turnOffAt: '关闭时间',\n    scheduleDescription: '每天在这些时间自动开关插座。留空以跳过该操作。',\n    showOnPrinterCard: '在打印机卡片上显示',\n    displayOnPrinterCard: '在打印机卡片上显示按钮',\n    connectedResult: '已连接！',\n    deviceLabel: '设备：{{name}} - ',\n    stateLabel: '状态：{{state}}',\n    test: '测试',\n    delete: '删除',\n    save: '保存',\n    add: '添加',\n    cancel: '取消',\n    failedToStartScan: '无法开始扫描',\n    nameRequired: '名称为必填项',\n    entityRequired: 'Home Assistant插座需要实体',\n    mqttTopicRequired: '必须为功率、能源或状态监控配置至少一个MQTT主题',\n    loadingEntities: '正在加载实体...',\n    loading: '加载中...',\n    failedToLoadEntities: '加载实体失败：{{error}}',\n    noEntitiesMatching: '未找到匹配\"{{search}}\"的实体',\n    noEntitiesAvailable: '无可用实体',\n    searchingEntities: '搜索所有实体（找到{{count}}个）',\n    showingEntities: '显示 switch、light、input_boolean（{{count}}个可用）',\n    energyMonitoringOptional: '能源监控（可选）',\n    energyMonitoringHint: '搜索并选择提供功率/能源数据的传感器。',\n    powerSensorW: '功率传感器（W）',\n    energyTodayKwh: '今日能源（kWh）',\n    totalEnergyKwh: '总能源（kWh）',\n    noMatchingSensors: '无匹配的传感器',\n    none: '无',\n    mqttNotConfigured: 'MQTT代理未配置。请在以下位置设置代理地址',\n    mqttSettingsPath: '设置 → 网络 → MQTT发布',\n    mqttNotConfiguredSuffix: '（您不需要启用发布，只需填写代理详细信息）。',\n    mqttMonitorOnlyDescription: 'MQTT插座通过MQTT订阅接收功率/能源数据。开关控制不可用 - 请使用您的MQTT代理或家庭自动化系统。',\n    powerMonitoring: '功率监控',\n    energyMonitoring: '能源监控',\n    stateMonitoring: '状态监控',\n    optional: '可选',\n    topic: '主题',\n    jsonPath: 'JSON路径',\n    multiplier: '乘数',\n    onValue: 'ON值',\n    mqttPowerHint: 'JSON路径从JSON负载中提取值（例如\"power_l1\"）。如果主题发布原始数值，请留空。\\n乘数：mW→W使用0.001，kW→W使用1000。',\n    mqttEnergyHint: 'JSON路径从JSON负载中提取值。原始值请留空。\\n乘数：Wh→kWh使用0.001，MWh→kWh使用1000。',\n    mqttStateHint: 'JSON路径从JSON负载中提取值。原始值请留空。\\nON值：表示\"ON\"的确切字符串。留空以自动检测（ON、true、1）。',\n    // REST smart plug\n    restControl: 'Control',\n    restOnUrl: 'Turn ON URL',\n    restOffUrl: 'Turn OFF URL',\n    restOnBody: 'ON Request Body',\n    restOffBody: 'OFF Request Body',\n    restMethod: 'HTTP Method',\n    restHeaders: 'Custom Headers (JSON)',\n    restStatusUrl: 'Status URL',\n    restStatusPath: 'State JSON Path',\n    restStatusOnValue: 'ON Value',\n    restPowerUrl: '功率URL',\n    restPowerPath: 'Power JSON Path',\n    restPowerMultiplier: '功率乘数',\n    restEnergyUrl: '能耗URL',\n    restEnergyPath: 'Energy JSON Path',\n    restEnergyMultiplier: '能耗乘数',\n    restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',\n    restHeadersHint: 'e.g. {\"Authorization\": \"Bearer your-token\"}',\n    restBodyHint: 'e.g. ON, {\"state\": \"on\"}',\n    restStatusHint: 'URL to poll for current state',\n    restPathHint: 'e.g. state or data.power.status',\n    restPowerUrlHint: '功率数据的独立URL（留空则使用状态URL）',\n    restEnergyUrlHint: '能耗数据的独立URL（留空则使用状态URL）',\n    restEnergyHint: '每个值可以使用独立的URL，或回退到状态URL。使用乘数进行单位转换（例如：0.001 将 Wh 转换为 kWh）。',\n    testConnection: 'Test Connection',\n    connectionSuccess: 'Connection successful',\n    noSwitchesInSwitchbar: '开关栏中没有开关',\n    enableSwitchbarHint: '在设置 > 智能插座中启用\"在开关栏显示\"',\n  },\n\n  // Notifications\n  notifications: {\n    // Provider types\n    providerTypes: {\n      callmebot: 'CallMeBot/WhatsApp',\n      ntfy: 'ntfy',\n      pushover: 'Pushover',\n      telegram: 'Telegram',\n      email: '电子邮件',\n      discord: 'Discord',\n      webhook: 'Webhook',\n      homeassistant: 'Home Assistant',\n    },\n    // Provider descriptions\n    providerDescriptions: {\n      email: 'SMTP 电子邮件通知',\n      telegram: '通过 Telegram 机器人发送通知',\n      discord: '通过 Webhook 发送到 Discord 频道',\n      ntfy: '免费、可自托管的推送通知',\n      pushover: '简单、可靠的推送通知',\n      callmebot: '通过 CallMeBot 免费发送 WhatsApp 通知',\n      webhook: '通用 HTTP POST 到任意 URL',\n      homeassistant: 'Home Assistant 仪表板中的持久通知',\n    },\n    // NotificationProviderCard\n    lastSuccess: '上次：{{date}}',\n    error: '错误',\n    printer: '打印机：',\n    allPrinters: '所有打印机',\n    sendTestNotification: '发送测试通知',\n    eventSettings: '事件设置',\n    enabled: '已启用',\n    sendFromProvider: '从此提供商发送通知',\n    // Event categories\n    printEvents: '打印事件',\n    printerStatus: '打印机状态',\n    amsAlarms: 'AMS 警报',\n    amsHtAlarms: 'AMS-HT 警报',\n    printQueue: '打印队列',\n    // Event tags (badges)\n    start: '开始',\n    plateCheck: '热床检测',\n    complete: '完成',\n    failed: '失败',\n    stopped: '已停止',\n    progress: '进度',\n    offline: '离线',\n    lowFilament: '耗材不足',\n    maintenance: '维护',\n    amsHumidity: 'AMS 湿度',\n    amsTemp: 'AMS 温度',\n    amsHtHumidity: 'AMS-HT 湿度',\n    amsHtTemp: 'AMS-HT 温度',\n    bedCooled: '热床已冷却',\n    firstLayer: '首层完成',\n    quiet: '免打扰',\n    digest: '摘要 {{time}}',\n    // Event labels (expanded settings)\n    printStarted: '打印已开始',\n    plateNotEmpty: '热床非空',\n    plateNotEmptyDescription: '打印前检测到物体',\n    printCompleted: '打印已完成',\n    bedCooledLabel: '热床已冷却',\n    bedCooledDescription: '打印后热床温度降至阈值以下',\n    firstLayerCompleteLabel: '首层打印完成',\n    firstLayerCompleteDescription: '首层完成时发送带照片的通知',\n    missingSpoolAssignmentLabel: '缺少料卷分配',\n    missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',\n    printFailed: '打印失败',\n    printStopped: '打印已停止',\n    progressMilestones: '进度里程碑',\n    progressMilestonesDescription: '在 25%、50%、75% 时通知',\n    printerOffline: '打印机离线',\n    printerError: '打印机错误',\n    lowFilamentLabel: '耗材不足',\n    maintenanceDue: '需要维护',\n    maintenanceDueDescription: '需要维护时通知',\n    amsHumidityHigh: 'AMS 湿度过高',\n    amsHumidityHighDescription: '普通 AMS 湿度超过阈值',\n    amsTemperatureHigh: 'AMS 温度过高',\n    amsTemperatureHighDescription: '普通 AMS 温度超过阈值',\n    amsHtHumidityHigh: 'AMS-HT 湿度过高',\n    amsHtHumidityHighDescription: 'AMS-HT 湿度超过阈值',\n    amsHtTemperatureHigh: 'AMS-HT 温度过高',\n    amsHtTemperatureHighDescription: 'AMS-HT 温度超过阈值',\n    // Queue events\n    jobAdded: '任务已添加',\n    jobAddedDescription: '任务已添加到队列',\n    jobAssigned: '任务已分配',\n    jobAssignedDescription: '基于模型的任务已分配给打印机',\n    jobStarted: '任务已开始',\n    jobStartedDescription: '队列任务已开始打印',\n    jobWaiting: '任务等待中',\n    jobWaitingDescription: '任务正在等待耗材或打印机',\n    jobSkipped: '任务已跳过',\n    jobSkippedDescription: '任务已跳过（上一个失败）',\n    jobFailed: '任务失败',\n    jobFailedDescription: '任务启动失败',\n    queueComplete: '队列已完成',\n    queueCompleteDescription: '所有队列任务已完成',\n    // Quiet hours\n    quietHours: '免打扰时段',\n    noNotificationsDuring: '在此时段内不发送通知',\n    editProviderToChangeQuietHours: '编辑提供商以更改免打扰时段',\n    // Daily digest\n    dailyDigest: '每日摘要',\n    batchNotifications: '将通知汇总为每日摘要',\n    sendAt: '发送于 {{time}}',\n    editProviderToChangeDigestTime: '编辑提供商以更改摘要时间',\n    // Actions\n    edit: '编辑',\n    deleteProvider: '删除通知提供商',\n    deleteConfirm: '确定要删除\"{{name}}\"吗？此操作无法撤销。',\n    delete: '删除',\n    // AddNotificationModal\n    addTitle: '添加通知提供商',\n    editTitle: '编辑通知提供商',\n    nameLabel: '名称 *',\n    namePlaceholder: '我的通知',\n    providerTypeLabel: '提供商类型 *',\n    configuration: '配置',\n    testConfiguration: '测试配置',\n    printerFilter: '打印机筛选',\n    onlyFromPrinter: '仅发送来自此打印机的事件通知',\n    quietHoursDnd: '免打扰时段',\n    quietStart: '开始',\n    quietEnd: '结束',\n    dailyDigestLabel: '每日摘要',\n    sendDigestAt: '发送摘要于',\n    digestCollected: '事件将被收集并在此时间作为单条摘要发送',\n    notificationEvents: '通知事件',\n    progressPercent: '（25%、50%、75%）',\n    bedCooledAfterPrint: '（打印完成后）',\n    cancel: '取消',\n    save: '保存',\n    add: '添加',\n    nameRequired: '名称为必填项',\n    fieldRequired: '{{field}}为必填项',\n    // Config field labels\n    phoneNumber: '电话号码',\n    apiKey: 'API 密钥',\n    serverUrl: '服务器 URL',\n    topic: '主题',\n    authToken: '认证令牌',\n    userKey: '用户密钥',\n    appToken: '应用令牌',\n    priority: '优先级',\n    botToken: '机器人令牌',\n    chatId: '聊天 ID',\n    smtpServer: 'SMTP 服务器',\n    smtpPort: 'SMTP 端口',\n    security: '安全',\n    authentication: '认证',\n    username: '用户名',\n    password: '密码',\n    fromEmail: '发件人邮箱',\n    toEmail: '收件人邮箱',\n    webhookUrl: 'Webhook URL',\n    payloadFormat: '负载格式',\n    authorization: '授权',\n    titleFieldName: '标题字段名',\n    messageFieldName: '消息字段名',\n    // NotificationTemplateEditor\n    editTemplate: '编辑模板：{{name}}',\n    titleLabel: '标题',\n    bodyLabel: '正文',\n    titlePlaceholder: '通知标题...',\n    bodyPlaceholder: '通知正文...',\n    availableVariables: '可用变量',\n    clickToInsert: '点击插入到正文光标位置',\n    livePreview: '实时预览',\n    hide: '隐藏',\n    show: '显示',\n    loadingPreview: '加载预览中...',\n    enterTemplateContent: '输入模板内容以查看预览',\n    titlePreview: '标题：',\n    bodyPreview: '正文：',\n    resetToDefault: '恢复默认',\n    titleRequired: '标题为必填项',\n    bodyRequired: '正文为必填项',\n    // NotificationLogViewer\n    notificationLog: '通知日志',\n    showFailedOnly: '仅显示失败',\n    last24Hours: '最近 24 小时',\n    last7Days: '最近 7 天',\n    last30Days: '最近 30 天',\n    last90Days: '最近 90 天',\n    justNow: '刚刚',\n    noFailedNotifications: '没有失败的通知',\n    noNotificationsLogged: '没有通知记录',\n    unknownProvider: '未知提供商',\n    logTitle: '标题',\n    logMessage: '消息',\n    logError: '错误',\n    logProvider: '提供商：{{type}}',\n    logTime: '时间：{{time}}',\n    refresh: '刷新',\n    clearOld: '清除旧记录',\n    statsSummary: '最近 {{days}} 天：',\n    statsNotifications: '条通知',\n    statsSent: '{{count}} 条已发送',\n    statsFailed: '{{count}} 条失败',\n    // Event type labels (for log viewer)\n    eventTypes: {\n      print_start: '打印已开始',\n      print_complete: '打印完成',\n      print_failed: '打印失败',\n      print_stopped: '打印已停止',\n      print_progress: '进度',\n      printer_offline: '打印机离线',\n      printer_error: '打印机错误',\n      filament_low: '耗材不足',\n      maintenance_due: '需要维护',\n      test: '测试',\n    },\n    userEmail: {\n      title: '通知',\n      emailNotifications: '邮件通知',\n      emailNotificationsDesc: '接收您自己打印任务的邮件通知。邮件将通过高级身份验证中配置的 SMTP 设置发送。',\n      sendingTo: '通知将发送至',\n      noEmailWarning: '您的账户没有邮件地址。请联系管理员添加。',\n      printJobNotifications: '打印任务通知',\n      printJobNotificationsDesc: '选择哪些事件会触发您提交的打印任务的邮件通知。',\n      printJobStarts: '打印任务开始',\n      printJobStartsDesc: '当您的打印任务开始时收到通知。',\n      printJobFinishes: '打印任务完成',\n      printJobFinishesDesc: '当您的打印任务成功完成时收到通知。',\n      printErrors: '打印错误',\n      printErrorsDesc: '当您的打印任务失败或遇到错误时收到通知。',\n      printJobStops: '打印任务停止',\n      printJobStopsDesc: '当您的打印任务被取消或停止时收到通知。',\n      saveSuccess: '通知偏好设置已保存。',\n      saveError: '保存通知偏好设置失败。',\n    },\n  },\n\n  // Rich Text Editor\n  richTextEditor: {\n    bold: '粗体',\n    italic: '斜体',\n    underline: '下划线',\n    bulletList: '无序列表',\n    numberedList: '有序列表',\n    alignLeft: '左对齐',\n    alignCenter: '居中对齐',\n    alignRight: '右对齐',\n    addLink: '添加链接',\n    removeLink: '移除链接',\n  },\n\n  // External Links\n  externalLinks: {\n    noLinksConfigured: '未配置外部链接',\n    deleteLink: '删除链接',\n    removeCustomIcon: '移除自定义图标',\n    openInNewTab: '在新标签页中打开',\n    placeholders: {\n      linkName: '我的链接',\n    },\n  },\n\n  // Keyboard Shortcuts Modal\n  keyboardShortcuts: {\n    title: '键盘快捷键',\n    navigation: '导航',\n    archivesSection: '归档',\n    kProfilesSection: 'K 值配置',\n    generalSection: '通用',\n    shortcuts: {\n      goToPrinters: '前往打印机',\n      goToArchives: '前往归档',\n      goToQueue: '前往队列',\n      goToStats: '前往统计',\n      goToProfiles: '前往云端配置',\n      goToSettings: '前往设置',\n      focusSearch: '聚焦搜索',\n      openUploadModal: '打开上传对话框',\n      clearSelection: '清除选择 / 取消焦点',\n      contextMenu: '卡片右键菜单',\n      refreshProfiles: '刷新配置',\n      newProfile: '新建配置',\n      exitSelectionMode: '退出选择模式',\n      showHelp: '显示此帮助',\n    },\n    footer: '按 Esc 或点击外部关闭',\n  },\n\n  // Notification Log\n  notificationLog: {\n    title: '通知日志',\n    events: {\n      printStarted: '打印开始',\n      printComplete: '打印完成',\n      printFailed: '打印失败',\n      printStopped: '打印停止',\n      progress: '进度',\n      printerOffline: '打印机离线',\n      printerError: '打印机错误',\n      lowFilament: '耗材不足',\n      maintenanceDue: '维护到期',\n      test: '测试',\n    },\n    timeAgo: {\n      justNow: '刚刚',\n      minutesAgo: '{{minutes}} 分钟前',\n      hoursAgo: '{{hours}} 小时前',\n    },\n  },\n\n  // Restore/Backup Modal\n  restoreBackup: {\n    title: '恢复备份',\n    restoring: '恢复中...',\n    restoreComplete: '恢复完成',\n    restoreFailed: '恢复失败',\n    importSettings: '从备份文件导入设置',\n    pleaseWait: '请稍候，正在恢复您的数据',\n    clickToSelect: '点击选择备份文件（.json 或 .zip）',\n    howDuplicateHandling: '重复处理方式：',\n    categories: {\n      printers: '打印机',\n      smartPlugs: '智能插座',\n      notificationProviders: '通知提供商',\n      filaments: '耗材',\n      archives: '归档',\n      pendingUploads: '待处理上传',\n      settingsTemplates: '设置和模板',\n    },\n    matchingInfo: {\n      printers: '按序列号匹配',\n      smartPlugs: '按 IP 地址匹配',\n      notificationProviders: '按名称匹配',\n      filaments: '按名称 + 类型 + 品牌匹配',\n      archives: '按内容哈希匹配',\n      pendingUploads: '按文件名匹配',\n      settingsTemplates: '始终覆盖',\n    },\n    replaceExisting: '替换现有数据',\n    keepExisting: '保留现有数据',\n    replaceDescription: '用备份数据覆盖已存在的项目',\n    keepDescription: '仅恢复不存在的项目',\n    caution: '注意：',\n    cautionText: '覆盖将用备份数据替换您当前的配置。出于安全考虑，打印机访问码永远不会被覆盖。',\n    itemsRestored: '已恢复项目',\n    itemsSkipped: '已跳过项目',\n    restored: '已恢复',\n    skipped: '已跳过（已存在）',\n    filesLabel: '文件（3MF、缩略图等）',\n    newApiKeysGenerated: '已生成新 API 密钥',\n    newApiKeysWarning: '这些密钥仅显示一次。请立即复制！',\n    processingBackup: '处理备份文件中...',\n    noDataFound: '备份文件中未找到可恢复的数据。',\n    failedToRestore: '恢复备份失败。请检查文件格式。',\n  },\n\n  // Backup Export Modal\n  backupExport: {\n    title: '导出备份',\n    selectData: '选择要包含的数据',\n    selectAll: '全选',\n    selectNone: '全不选',\n    categoryDescriptions: {\n      settings: '语言、主题、更新偏好',\n      notifications: 'ntfy、Pushover、Discord 等',\n      templates: '自定义消息模板',\n      smartPlugs: 'Tasmota 插座配置',\n      externalLinks: '侧边栏外部服务链接',\n      printers: '打印机信息（不含访问码）',\n      plateDetection: '空打印板参考图像',\n      filaments: '耗材类型和成本',\n      maintenance: '自定义维护计划',\n      archives: '所有打印数据 + 文件（3MF、缩略图、照片）',\n      projects: '项目、材料清单和附件',\n      pendingUploads: '虚拟打印机待审核的上传',\n      apiKeys: 'Webhook API 密钥（导入时生成新密钥）',\n    },\n    requiresPrinters: '需要选择打印机',\n    zipFileWarning: '将创建 ZIP 文件。',\n    zipFileDescription: '包括所有 3MF 文件、缩略图、延时摄影和照片。这可能需要一些时间并生成较大的文件。',\n    includeAccessCodes: '包含访问码',\n    includeAccessCodesDescription: '用于转移到另一台机器',\n    includeAccessCodesWarning: '访问码将以明文形式包含。请妥善保管此备份文件！',\n    categoriesSelected: '已选择 {{selectedCount}} 个类别',\n  },\n\n  // Pending Uploads Panel\n  pendingUploads: {\n    placeholders: {\n      notes: '添加关于此打印的备注...',\n    },\n    discardUpload: '丢弃上传',\n    archiveAllUploads: '归档所有上传',\n    discardAllUploads: '丢弃所有上传',\n    archive: '归档',\n    timeAgo: {\n      justNow: '刚刚',\n      minutesAgo: '{{minutes}} 分钟前',\n      hoursAgo: '{{hours}} 小时前',\n      daysAgo: '{{days}} 天前',\n    },\n  },\n\n  // API Browser\n  apiBrowser: {\n    placeholders: {\n      requestBody: 'JSON 请求体...',\n      searchEndpoints: '搜索端点...',\n    },\n  },\n\n  // Configure AMS Slot Modal\n  configureAmsSlot: {\n    title: '配置 AMS 槽位',\n    slotConfigured: '槽位已配置！',\n    configuringSlot: '正在配置槽位：',\n    slotLabel: '{{ams}} 槽位 {{slot}}',\n    searchPresets: '搜索预设...',\n    colorPlaceholder: '颜色名称或十六进制（例如：棕色、FF8800）',\n    clearCustomColor: '清除自定义颜色',\n    noCloudPresets: '无云端预设。登录拓竹云以同步。',\n    noPresetsAvailable: '无可用预设。登录拓竹云或导入本地配置。',\n    noMatchingPresets: '未找到匹配的预设。',\n    custom: '自定义',\n    builtin: '内置',\n    settingsSentToPrinter: '设置已发送到打印机',\n    filamentProfile: '耗材配置',\n    kProfileLabel: 'K 值配置（压力推进）',\n    filteringFor: '筛选：{{material}}',\n    noKProfile: '无 K 值配置（使用默认值 0.020）',\n    noMatchingKProfiles: '未找到匹配的 K 值配置。将使用默认 K=0.020。',\n    selectFilamentFirst: '请先选择耗材配置',\n    kFromCalibration: 'K={{value}}（来自打印机校准）',\n    customColorLabel: '自定义颜色（可选）',\n    presetColors: '{{name}} 颜色：',\n    showLessColors: '显示更少颜色',\n    showMoreColors: '显示更多颜色',\n    clear: '清除',\n    hexLabel: '十六进制：#{{hex}}',\n    resetting: '重置中...',\n    resetSlot: '重置槽位',\n    cancel: '取消',\n    configuring: '配置中...',\n    configureSlot: '配置槽位',\n  },\n\n  // GitHub Backup Settings\n  githubBackup: {\n    title: 'GitHub 备份',\n    history: '历史',\n    downloadBackup: '下载备份',\n    restoreBackup: '恢复备份',\n    noBackupsYet: '暂无备份',\n  },\n\n  // Email Settings\n  emailSettings: {\n    placeholders: {\n      fromName: 'BamBuddy',\n    },\n  },\n\n  // Tag Management Modal\n  tagManagement: {\n    searchTags: '搜索标签...',\n    renameTag: '重命名标签',\n    deleteTag: '删除标签',\n  },\n\n  // Notification Template Editor\n  notificationTemplates: {\n    placeholders: {\n      title: '通知标题...',\n      body: '通知正文...',\n    },\n  },\n\n  // Batch Tag Modal\n  batchTag: {\n    placeholders: {\n      newTag: '输入新标签...',\n    },\n  },\n\n  // Photo Gallery Modal\n  photoGallery: {\n    deletePhoto: '删除照片',\n  },\n\n  // Filament Hover Card\n  filamentHoverCard: {\n    copySpoolUuid: '复制耗材 UUID',\n  },\n\n  // K Profiles View\n  kProfilesView: {\n    hasNote: '有备注',\n    copyProfile: '复制配置',\n  },\n\n  // Layout/Navigation\n  layout: {\n    openMenu: '打开菜单',\n    noPermissionSystemInfo: '您没有查看系统信息的权限',\n  },\n\n  // Dashboard\n  dashboard: {\n    dragToReorder: '拖动以重新排列',\n    hideWidget: '隐藏小部件',\n  },\n\n  // Notification Provider Card\n  notificationProviderCard: {\n    deleteNotificationProvider: '删除通知提供商',\n  },\n\n  // File Manager Modal\n  fileManagerModal: {\n    closeFileManager: '关闭文件管理器',\n    sortFiles: '排序文件',\n    goToParentFolder: '返回上级文件夹',\n    threeView: '3D 视图',\n  },\n\n  // Embedded Camera Viewer\n  embeddedCameraViewer: {\n    refreshStream: '刷新流',\n    close: '关闭',\n    zoomOut: '缩小',\n    resetZoom: '重置缩放',\n    zoomIn: '放大',\n    dragToResize: '拖动调整大小',\n  },\n\n  // Timelapse Viewer\n  timelapseViewer: {\n    skipBack5s: '后退 5 秒',\n    skipForward5s: '前进 5 秒',\n  },\n\n  // Notification Providers\n  notificationProviders: {\n    descriptions: {\n      email: 'SMTP 邮件通知',\n      telegram: '通过 Telegram 机器人通知',\n      discord: '通过 Webhook 发送到 Discord 频道',\n      ntfy: '免费、可自托管的推送通知',\n      pushover: '简单、可靠的推送通知',\n      callmebot: '通过 CallMeBot 的免费 WhatsApp 通知',\n      webhook: '通用 HTTP POST 到任意 URL',\n    },\n  },\n\n  // Log Viewer\n  logViewer: {\n    searchPlaceholder: '搜索消息或日志名称...',\n    noLogEntries: '未找到日志条目',\n  },\n\n  // Switchbar Popover\n  switchbarPopover: {\n    noSwitchesInSwitchbar: '切换栏中没有开关',\n  },\n\n  // Project Page Modal\n  projectPageModal: {\n    placeholders: {\n      title: '标题',\n      designer: '设计师',\n      license: '许可证',\n      description: '输入描述...',\n      profileTitle: '配置标题',\n      profileDescription: '配置描述...',\n    },\n  },\n\n  // Spoolman Settings\n  spoolmanSettings: {},\n\n  // Time\n  time: {\n    unknown: '-',\n    waiting: '等待中',\n    justNow: '刚刚',\n    now: '现在',\n    minsAgo: '{{count}} 分钟前',\n    inMins: '{{count}} 分钟后',\n    hoursAgo: '{{count}} 小时前',\n    inHours: '{{count}} 小时后',\n    daysAgo: '{{count}} 天前',\n    inDays: '{{count}} 天后',\n  },\n\n  // SpoolBuddy Kiosk\n  spoolbuddy: {\n    nav: {\n      dashboard: '仪表板',\n      ams: 'AMS',\n      inventory: '库存',\n      writeTag: '写入',\n      settings: '设置',\n    },\n    status: {\n      nfcReady: 'NFC 就绪',\n      nfcOff: 'NFC 关闭',\n      offline: '离线',\n      online: '在线',\n      noPrinters: '无打印机',\n      deviceOffline: '设备离线',\n      waitingConnection: '等待设备连接...',\n      systemReady: '系统就绪',\n      status: '状态',\n    },\n    dashboard: {\n      readyToScan: '准备扫描',\n      idleMessage: '将耗材放在秤上以识别',\n      nfcHint: 'NFC 标签将自动读取',\n      device: '设备',\n      syncWeight: '同步重量',\n      weightSynced: '已同步！',\n      unknownTag: '未知标签',\n      newTag: '检测到新标签',\n      onScale: '在秤上',\n      linkSpool: '链接到耗材',\n      linkTagTitle: '将标签链接到耗材',\n      linkTag: '链接标签',\n      selectSpool: '选择要链接此标签的耗材：',\n      noUntagged: '未找到没有标签的耗材',\n      tagDetected: '检测到标签',\n      noTag: '无标签',\n      tagId: '标签',\n      grossWeight: '毛重',\n      spoolSize: '耗材盘尺寸',\n      close: '关闭',\n      currentSpool: '当前耗材',\n    },\n    modal: {\n      spoolDetected: '检测到耗材',\n      assignToAms: '分配到 AMS',\n      syncWeight: '同步重量',\n      weightSynced: '已同步！',\n      syncing: '同步中...',\n      newTagDetected: '检测到新标签',\n      addToInventory: '添加到库存',\n      assignToAmsTitle: '分配到 AMS',\n      selectSlot: '选择槽位',\n      assign: '分配',\n      assigning: '分配中...',\n      assignSuccess: '已分配！',\n      assignError: '分配耗材失败。请重试。',\n      noPrinterSelected: '选择打印机...',\n      noAmsDetected: '此打印机未检测到 AMS',\n      slot: '槽位',\n    },\n    weight: {\n      noReading: '无读数',\n      stable: '稳定',\n      measuring: '测量中...',\n      tare: '去皮',\n      calibrate: '校准',\n    },\n    spool: {\n      remaining: '剩余',\n      material: '材料',\n      brand: '品牌',\n      color: '颜色',\n      coreWeight: '空盘',\n      labelWeight: '标签',\n      scaleWeight: '秤重',\n      netWeight: '净重',\n      lastUsed: '上次使用',\n    },\n    ams: {\n      noData: '未检测到 AMS',\n      connectAms: '连接 AMS 以查看耗材槽位',\n      noPrinter: '未选择打印机',\n      selectPrinter: '从顶部栏选择打印机',\n      printerDisconnected: '打印机已断开',\n      humidity: '湿度',\n      level: '余量',\n      active: '活跃',\n      slot: '槽位',\n      empty: '空',\n    },\n    inventory: {\n      search: '搜索耗材...',\n      empty: '库存中没有耗材',\n      noResults: '没有匹配的耗材',\n      spools: '个耗材',\n      addSpool: '添加耗材',\n    },\n    settings: {\n      // Tabs\n      tabDevice: '设备',\n      tabDisplay: '显示',\n      tabScale: '秤',\n      tabUpdates: '更新',\n      // Device tab\n      nfcReader: 'NFC 读卡器',\n      type: '类型',\n      connection: '连接',\n      notConnected: '不适用',\n      deviceInfo: '设备信息',\n      hostname: '主机',\n      uptime: '运行时间',\n      systemConfig: '后端与认证',\n      backendUrl: 'Bambuddy 后端 URL',\n      apiToken: 'API 令牌',\n      apiTokenPlaceholder: '输入 API 令牌',\n      saveConfig: '保存配置',\n      systemQueued: '配置已加入队列。',\n      nfcDiagnostic: 'NFC 诊断',\n      scaleDiagnostic: '秤诊断',\n      readTagDiagnostic: '读取标签诊断',\n      testNfc: '测试读卡器',\n      testScale: '测试精度',\n      testReadTag: '读取标签',\n      systemFieldsRequired: '后端 URL 为必填项。',\n      // Display tab\n      brightness: '亮度',\n      saved: '已保存',\n      noBacklight: '未检测到 DSI 背光。亮度控制需要 DSI 显示屏。',\n      screenBlank: '屏幕熄灭超时',\n      screenBlankDesc: '不活动后屏幕关闭。触摸唤醒。',\n      displayNote: '亮度作为软件滤镜应用。',\n      // Scale tab\n      scaleCalibration: '秤校准',\n      currentWeight: '当前重量',\n      tareOffset: '去皮',\n      calFactor: '系数',\n      knownWeight: '已知重量',\n      calStep1: '移除秤上所有物品并按设置零点。',\n      calStep2: '将已知重量放在秤上。',\n      setZero: '设置零点',\n      calibrateNow: '校准',\n      calibrated: '已校准',\n      tareSet: '去皮命令已发送。等待设备响应...',\n      tareFailed: '发送去皮命令失败',\n      zeroSet: '零点已设置。将已知重量放在秤上。',\n      calibrationDone: '校准完成！',\n      calibrationFailed: '校准失败',\n      lastCalibrated: '上次校准',\n      stable: '稳定',\n      settling: '稳定中...',\n      firmware: '固件',\n      scale: '秤',\n      noDevice: '未找到 SpoolBuddy 设备',\n      // Updates tab\n      daemonVersion: '守护进程版本',\n      currentVersion: '当前',\n      versionPending: '等待守护进程...',\n      checking: '检查中...',\n      checkUpdates: '检查更新',\n      updateAvailable: '有可用更新',\n      updateInstructions: '通过 SSH 更新：运行 SpoolBuddy 安装脚本进行升级。',\n      upToDate: '已是最新',\n      includeBeta: '包含测试版本',\n    },\n    writeTag: {\n      tabExisting: '现有耗材',\n      tabNew: '新耗材',\n      tabReplace: '替换标签',\n      searchPlaceholder: '按材料、颜色、品牌搜索...',\n      noUntaggedSpools: '没有无标签的耗材',\n      noTaggedSpools: '没有有标签的耗材',\n      selectSpool: '选择一个耗材，然后将空白 NTAG 放在读卡器上',\n      placeTag: '将 NTAG 放在读卡器上',\n      tagReady: '检测到标签 — 准备写入',\n      writeTag: '写入标签',\n      replaceTag: '替换标签',\n      writing: '写入标签中...',\n      waiting: '等待 SpoolBuddy...',\n      writeSuccess: '标签写入成功！',\n      writeFailed: '写入失败',\n      queueFailed: '排队写入命令失败',\n      tryAgain: '重试',\n      cancel: '取消',\n      replaceWarning: '旧标签将被取消链接。新标签将替换它。',\n      deviceOffline: 'SpoolBuddy 离线',\n      material: '材料',\n      colorName: '颜色名称',\n      color: '颜色',\n      brand: '品牌',\n      weight: '重量 (g)',\n      createSpool: '创建耗材',\n      creating: '创建中...',\n      spoolCreated: '耗材已创建！准备写入。',\n      createFailed: '创建耗材失败',\n    },\n    quickMenu: {\n      printerPower: '打印机电源',\n      systemControls: '系统',\n      restartDaemon: '重启守护进程',\n      restartBrowser: '重启浏览器',\n      reboot: '重启',\n      shutdown: '关机',\n      swipeToClose: '向下滑动关闭',\n      confirmTitle: '确认',\n      confirmShutdown: '确定要关闭SpoolBuddy吗？您需要物理访问才能重新开启。',\n      confirmReboot: '确定要重启SpoolBuddy吗？',\n      confirmRestartDaemon: '重启SpoolBuddy守护进程？NFC和秤将暂时不可用。',\n      confirmRestartBrowser: '重启kiosk浏览器？屏幕将短暂变黑。',\n      confirm: '确认',\n      confirmPlugOn: '开启 {{name}}？',\n      confirmPlugOff: '关闭 {{name}}？',\n      turnOn: '开启',\n      turnOff: '关闭',\n    },\n  },\n\n  bugReport: {\n    title: '报告错误',\n    description: '描述',\n    descriptionPlaceholder: '出了什么问题？请描述问题...',\n    email: '邮箱（可选）',\n    emailPlaceholder: 'your@email.com',\n    emailPrivacy: '如果提供，您的邮箱将包含在GitHub Issue的折叠部分中，以便维护者后续跟进。',\n    screenshot: '截图',\n    uploadOrPaste: '上传、粘贴或拖拽图片',\n    dataCollectedSummary: '报告中包含哪些数据？',\n    dataIncluded: '包含：',\n    dataIncludedList: '应用版本、操作系统、架构、Python版本、数据库统计（仅计数）、打印机型号、喷嘴数量、固件版本、连接状态、集成状态（Spoolman、MQTT、HA）、非敏感设置、网络接口数量、Docker详情、依赖版本。',\n    dataNeverIncluded: '绝不包含：',\n    dataNeverIncludedList: '打印机名称、序列号、访问代码、密码、IP地址、邮箱地址、API密钥、令牌、Webhook URL、主机名或用户名。',\n    submit: '提交',\n    startLogging: '开始调试日志',\n    stepEnableLogging: '调试日志已启用',\n    stepReproduce: '请现在重现问题',\n    stepStopLogging: '停止并提交报告',\n    stopAndSubmit: '停止并提交',\n    maxDuration: '{{minutes}}分钟后自动停止',\n    stoppingLogs: '正在收集日志并提交...',\n    submitting: '正在提交错误报告...',\n    submitSuccess: '错误报告提交成功！',\n    submitFailed: '提交错误报告失败',\n    thankYou: '谢谢！',\n    submitted: '您的错误报告已提交。',\n    viewIssue: '查看Issue',\n    unexpectedError: '发生了意外错误',\n  },\n  failureDetection: {\n    title: 'AI 故障检测',\n    description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',\n    mlUrl: 'Obico ML API 地址',\n    mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。',\n    test: '测试',\n    testSuccess: 'ML API 可访问且正常。',\n    testFailed: '无法访问 ML API。',\n    sensitivity: '灵敏度',\n    sensitivityLow: '低(减少误报)',\n    sensitivityMedium: '中(平衡)',\n    sensitivityHigh: '高(更早检测,更多误报)',\n    sensitivityHint: '调整触发警告和故障的置信度阈值。',\n    action: '检测到故障时的操作',\n    actionNotify: '仅通知',\n    actionPause: '暂停打印',\n    actionPauseOff: '暂停并切断电源',\n    pollInterval: '检查间隔(秒)',\n    pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。',\n    externalUrlMissing: 'External URL is not set.',\n    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',\n    perPrinterTitle: '监控的打印机',\n    perPrinterHint: '选择检测服务要监视哪些打印机。',\n    monitorAll: '监控所有已连接的打印机',\n    statusTitle: '状态',\n    serviceRunning: '服务运行中',\n    thresholds: '低 / 高阈值',\n    activePrinters: '活动打印',\n    noActivePrints: '当前没有正在进行的打印。',\n    historyTitle: '最近检测',\n    noHistory: '暂无检测记录。',\n  },\n};\n"
  },
  {
    "path": "frontend/src/i18n/locales/zh-TW.ts",
    "content": "export default {\n  // Navigation\n  nav: {\n    printers: '印表機',\n    archives: '歸檔',\n    queue: '佇列',\n    stats: '統計',\n    profiles: '設定檔案',\n    maintenance: '維護',\n    projects: '專案',\n    inventory: '耗材',\n    files: '檔案管理器',\n    notifications: '通知',\n    settings: '設定',\n    system: '系統',\n    collapseSidebar: '收起側邊欄',\n    expandSidebar: '展開側邊欄',\n    update: '更新',\n    updateAvailable: '有可用更新：v{{version}}',\n    updateAvailableBanner: '版本 {{version}} 已發布！',\n    viewUpdate: '檢視更新',\n    viewOnGithub: '在 GitHub 上檢視',\n    keyboardShortcuts: '鍵盤快捷鍵 (?)',\n    switchToLight: '切換到淺色模式',\n    switchToDark: '切換到深色模式',\n    smartSwitches: '智慧開關',\n    logout: '登出',\n  },\n\n  // Common\n  common: {\n    save: '儲存',\n    saving: '儲存中...',\n    cancel: '取消',\n    delete: '刪除',\n    edit: '編輯',\n    add: '新增',\n    close: '關閉',\n    confirm: '確認',\n    loading: '載入中...',\n    error: '錯誤',\n    success: '成功',\n    warning: '警告',\n    enabled: '已啟用',\n    disabled: '已停用',\n    yes: '是',\n    no: '否',\n    on: '開',\n    off: '關',\n    all: '全部',\n    none: '無',\n    search: '搜尋',\n    filter: '篩選',\n    sort: '排序',\n    refresh: '重新整理',\n    download: '下載',\n    upload: '上傳',\n    uploading: '上傳中...',\n    uploadFailed: '上傳失敗',\n    actions: '操作',\n    status: '狀態',\n    name: '名稱',\n    description: '描述',\n    date: '日期',\n    time: '時間',\n    hours: '小時',\n    minutes: '分鐘',\n    seconds: '秒',\n    days: '天',\n    enable: '啟用',\n    disable: '停用',\n    permissions: '權限',\n    noPrinters: '未設定印表機',\n    noData: '尚無資料',\n    linkNotFound: '未找到連結',\n    required: '必填',\n    optional: '可選',\n    dismiss: '關閉',\n    apply: '套用',\n    reset: '重設',\n    export: '匯出',\n    import: '匯入',\n    clear: '清除',\n    selectAll: '全選',\n    deselectAll: '取消全選',\n    noChange: '— 不更改 —',\n    unchanged: '未更改',\n    unassigned: '未分配',\n    unknown: '未知',\n    unknownError: '未知錯誤',\n    today: '今天',\n    tomorrow: '明天',\n    asap: '儘快',\n    overdue: '已逾期',\n    now: '現在',\n    collapse: '收起',\n    expand: '展開',\n    viewArchive: '檢視歸檔',\n    viewInFileManager: '在檔案管理器中檢視',\n    addedBy: '由 {{username}} 新增',\n    prints: '次列印',\n    more: '還有 {{count}} 個',\n    ascending: '升序',\n    descending: '降序',\n    back: '返回',\n    copy: '複製',\n    copied: '已複製!',\n    printer: '印表機',\n    remove: '移除',\n    type: '類型',\n    print: '列印',\n    rename: '重新命名',\n    move: '移動',\n    create: '建立',\n    duplicate: '複製',\n    left: '左',\n    right: '右',\n  },\n\n  // Printers page\n  printers: {\n    title: '印表機',\n    addPrinter: '新增印表機',\n    editPrinter: '編輯印表機',\n    deletePrinter: '刪除印表機',\n    printerName: '印表機名稱',\n    serialNumber: '序列號',\n    ipAddress: 'IP 位址 / 主機名稱',\n    accessCode: '存取碼',\n    model: '型號',\n    nozzleCount: '噴嘴數量',\n    autoArchive: '自動歸檔',\n    status: {\n      available: '可用',\n      idle: '空閒',\n      printing: '列印中',\n      paused: '已暫停',\n      offline: '離線',\n      problem: '故障',\n      error: '錯誤',\n      finished: '已完成',\n      unknown: '未知',\n    },\n    temperatures: {\n      nozzle: '噴嘴',\n      bed: '熱床',\n      chamber: '腔室',\n    },\n    progress: '{{percent}}% 完成',\n    timeRemaining: '剩餘 {{time}}',\n    deleteConfirm: '確定要刪除\"{{name}}\"嗎？',\n    maintenanceOk: '維護正常',\n    maintenanceWarning: '{{count}} 個警告',\n    maintenanceWarning_plural: '{{count}} 個警告',\n    maintenanceDue: '{{count}} 個到期',\n    maintenanceDue_plural: '{{count}} 個到期',\n    // Sort options\n    sort: {\n      name: '名稱',\n      status: '狀態',\n      model: '型號',\n      location: '位置',\n      ascending: '升序排列',\n      descending: '降序排列',\n    },\n    // Card size\n    cardSize: {\n      small: '小卡片',\n      medium: '中卡片',\n      large: '大卡片',\n      extraLarge: '超大卡片',\n    },\n    // Controls\n    hideOffline: '隱藏離線',\n    nextAvailable: '下一個可用',\n    powerOn: '開機',\n    offlinePrintersWithPlugs: '帶智慧插座的離線印表機',\n    noPrintersConfigured: '尚未設定印表機',\n    search: '搜尋印表機...',\n    noSearchResults: '沒有印表機符合您的搜尋或篩選條件',\n    filter: {\n      allStatuses: '所有狀態',\n      allLocations: '所有位置',\n    },\n    // Printer card\n    readyToPrint: '準備列印',\n    external: '外部',\n    extL: '外接左',\n    extR: '外接右',\n    deleteArchives: '刪除列印歸檔',\n    noLabel: '無標籤',\n    printPreview: '列印預覽',\n    width: '寬度',\n    height: '高度',\n    noObjectsFound: '未找到物件',\n    objectsLoadedOnPrintStart: '物件在列印開始時載入',\n    willBeSkipped: '將被跳過',\n    name: '名稱',\n    serialCannotBeChanged: '序列號無法更改',\n    locationHelp: '用於分組印表機和篩選佇列任務',\n    // WiFi signal strength\n    wifiSignal: {\n      veryWeak: '非常弱',\n      weak: '弱',\n      fair: '一般',\n      good: '良好',\n      excellent: '優秀',\n    },\n    // Maintenance\n    maintenanceUpToDate: '所有維護均已完成 - 點選檢視',\n    // Chamber light\n    chamberLightOn: '開啟腔室燈',\n    chamberLightOff: '關閉腔室燈',\n    // Files\n    files: '檔案',\n    browseFiles: '瀏覽印表機檔案',\n    // Smart plug\n    autoOffAfterPrint: '列印後自動關機',\n    autoOffExecuted: '已執行自動關機 - 開啟印表機以重設',\n    // HMS errors\n    hmsErrors: 'HMS 錯誤',\n    viewHmsErrors: '檢視 {{count}} 個 HMS 錯誤',\n    // Actions\n    resume: '繼續',\n    pause: '暫停',\n    stop: '停止',\n    camera: '攝影機',\n    skipObject: '跳過物件',\n    reconnect: '重新連線',\n    forceRefresh: '強制重新整理',\n    forceRefreshSuccess: '已請求重新整理',\n    mqttDebug: 'MQTT 偵錯',\n    printerInformation: '印表機資訊',\n    copyToClipboard: '複製',\n    copied: '已複製！',\n    state: '狀態',\n    wifiSignalLabel: 'Wi-Fi 訊號',\n    developerMode: '開發者模式',\n    enabled: '已啟用',\n    disabled: '已停用',\n    addedOn: '新增日期',\n    sdCard: 'SD 卡',\n    inserted: '已插入',\n    notInserted: '未插入',\n    totalPrintHours: '列印時長',\n    activeNozzle: '目前：{{nozzle}} 噴嘴',\n    nozzleRack: '噴嘴架',\n    nozzleDocked: '已停靠',\n    nozzleMounted: '已安裝',\n    nozzleActive: '使用中',\n    nozzleIdle: '空閒',\n    nozzleDiameter: '直徑',\n    nozzleType: '類型',\n    nozzleStatus: '狀態',\n    nozzleFilament: '耗材',\n    nozzleWear: '磨損',\n    nozzleMaxTemp: '最高溫度',\n    nozzleSerial: '序列號',\n    nozzleHardenedSteel: '硬化鋼',\n    nozzleStainlessSteel: '不鏽鋼',\n    nozzleTungstenCarbide: '碳化鎢',\n    nozzleFlow: '流量',\n    nozzleHighFlow: '高流量',\n    nozzleStandardFlow: '標準',\n    // Firmware\n    firmwareUpdate: '韌體更新',\n    firmwareInstructions: '在印表機觸控式螢幕上，前往',\n    firmwareNav: '導航到',\n    settings: '設定',\n    firmware: '韌體',\n    // Discovery\n    discoverPrinters: '發現印表機',\n    searching: '搜尋中...',\n    manualEntry: '手動輸入',\n    addFromCloud: '從雲端新增',\n    // Toast messages\n    toast: {\n      printerDeleted: '印表機已刪除',\n      missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',\n      printerAdded: '印表機已新增',\n      printerUpdated: '印表機已更新',\n      failedToDelete: '刪除印表機失敗',\n      failedToAdd: '新增印表機失敗',\n      failedToUpdate: '更新印表機失敗',\n      commandSent: '命令已傳送',\n      failedToSendCommand: '傳送命令失敗',\n      turnedOn: '{{name}} 已開啟',\n      failedToPowerOn: '開啟 {{name}} 失敗',\n      scriptTriggered: '腳本已觸發',\n      printStopped: '列印已停止',\n      printPaused: '列印已暫停',\n      printResumed: '列印已繼續',\n      referenceDeleted: '參考已刪除',\n      detectionAreaSaved: '檢測區域已儲存',\n      failedToRunScript: '執行腳本失敗',\n      failedToStopPrint: '停止列印失敗',\n      failedToPausePrint: '暫停列印失敗',\n      failedToResumePrint: '繼續列印失敗',\n      failedToControlChamberLight: '控制腔室燈失敗',\n      failedToSetSpeed: '設定列印速度失敗',\n      failedToUpdateSetting: '更新設定失敗',\n      failedToSkipObjects: '跳過物件失敗',\n      failedToRereadRfid: '重新讀取 RFID 失敗',\n      failedToCheckPlate: '檢查列印板失敗',\n      failedToUpdateLabel: '更新標籤失敗',\n      failedToDeleteReference: '刪除參考失敗',\n      failedToSaveDetectionArea: '儲存檢測區域失敗',\n      plateCheckEnabled: '列印板檢查已啟用',\n      plateCheckDisabled: '列印板檢查已停用',\n      calibrationSaved: '校準已儲存！',\n      calibrationFailed: '校準失敗',\n      rfidRereadInitiated: '已發起 RFID 重新讀取',\n    },\n    // Connection status\n    connection: {\n      connected: '已連線',\n      offline: '離線',\n    },\n    plateStatus: {\n      markCleared: '將列印板標記為已清理',\n      cleared: '列印板已清理',\n      notCleared: '列印板未清理',\n      inUse: '列印板使用中',\n    },\n    // Queue info\n    queue: {\n      inQueue: '佇列中有 {{count}} 個列印任務',\n      inQueue_plural: '佇列中有 {{count}} 個列印任務',\n    },\n    // Controls section\n    controls: '控制',\n    // RFID\n    rfid: {\n      reread: '重新讀取 RFID',\n    },\n    bedJog: {\n      title: '移動熱床',\n      bed: '熱床',\n      step: '步長 (mm)',\n      up: '熱床上移',\n      down: '熱床下移',\n      disabledWhilePrinting: '列印中已停用',\n      notHomedTitle: '印表機未歸零',\n      notHomedMessage: '印表機自上次列印以來尚未歸零。請先執行自動歸零以確保安全定位（先停放噴頭，然後歸零 X、Y 和 Z），或者直接移動 — 軟限位將被繞過。',\n      homeZ: '自動歸零',\n      moveAnyway: '強制移動',\n      homingStarted: '印表機自動歸零中…',\n    },\n    // Permissions\n    permission: {\n      noAdd: '您沒有新增印表機的權限',\n      noEdit: '您沒有編輯印表機的權限',\n      noDelete: '您沒有刪除印表機的權限',\n      noControl: '您沒有控制印表機的權限',\n      noFiles: '您沒有存取印表機檔案的權限',\n      noAmsRfid: '您沒有重新讀取 AMS RFID 的權限',\n      noSmartPlugControl: '您沒有控制智慧插座的權限',\n      noCamera: '您沒有檢視攝影機的權限',\n    },\n    // Add/Edit modal\n    modal: {\n      addTitle: '新增印表機',\n      editTitle: '編輯印表機',\n      myPrinter: '我的印表機',\n      selectModel: '選擇型號...',\n      locationGroup: '位置 / 分組（可選）',\n      locationPlaceholder: '例如：工作室、辦公室、地下室',\n      autoArchiveLabel: '自動歸檔已完成的列印',\n      fromPrinterSettings: '來自印表機設定',\n      modelOptional: '型號（可選）',\n      saveChanges: '儲存更改',\n    },\n    // Skip objects\n    skipObjects: {\n      tooltip: '跳過物件',\n      onlyWhilePrinting: '跳過物件（僅在列印時）',\n      requiresMultiple: '跳過物件（需要2個以上物件）',\n      title: '跳過物件',\n      matchIdsInfo: '將 ID 與印表機螢幕上的 ID 進行對照',\n      printerShowsIds: '印表機螢幕上顯示列印板上物件的 ID',\n      skipSelected: '跳過所選',\n      skipping: '跳過中...',\n      noObjectsSelected: '未選擇物件',\n      selectObjectsToSkip: '選擇要從目前列印中跳過的物件',\n      skipped: '已跳過',\n      objectsSkipped: '物件已跳過',\n      activeCount: '{{count}} 個活躍',\n      waitForLayer: '等待第2層以上才能跳過物件（目前第 {{layer}} 層）',\n      skip: '跳過',\n      confirmTitle: '跳過物件？',\n      confirmMessage: '確定要跳過\"{{name}}\"嗎？此操作無法復原。',\n    },\n    // Confirm modals\n    confirm: {\n      deleteTitle: '刪除印表機',\n      deleteMessage: '確定要刪除\"{{name}}\"嗎？這將移除所有連線設定。',\n      deleteArchivesNote: '此印表機的所有列印歷史將被永久刪除。',\n      keepArchivesNote: '列印歷史將保留，但不再與此印表機連結。',\n      stopTitle: '停止列印',\n      stopMessage: '確定要停止\"{{name}}\"上的目前列印嗎？這將取消列印任務。',\n      stopButton: '停止列印',\n      pauseTitle: '暫停列印',\n      pauseMessage: '確定要暫停\"{{name}}\"上的目前列印嗎？',\n      pauseButton: '暫停列印',\n      resumeTitle: '繼續列印',\n      resumeMessage: '確定要繼續\"{{name}}\"上的列印嗎？',\n      resumeButton: '繼續列印',\n      powerOnTitle: '開啟印表機',\n      powerOnMessage: '確定要開啟\"{{name}}\"的電源嗎？',\n      powerOnButton: '開機',\n      powerOffTitle: '關閉印表機',\n      powerOffMessage: '確定要關閉\"{{name}}\"的電源嗎？',\n      powerOffWarning: '警告：\"{{name}}\"正在列印中！確定要關閉電源嗎？這將中斷列印並可能損壞印表機。',\n      powerOffButton: '關機',\n    },\n    // Bulk actions\n    bulk: {\n      select: '選擇',\n      selectAll: '全選',\n      selectByLocation: '按位置選擇',\n      selected: '已選擇{{count}}臺',\n      actions: {\n        stop: '停止',\n        pause: '暫停',\n        resume: '繼續',\n        clearPlate: '清除列印床',\n        clearHMS: '清除通知',\n      },\n      confirm: {\n        stopTitle: '停止{{count}}個列印任務',\n        stopMessage: '這將取消{{count}}臺印表機上的活動列印任務。此操作無法復原。',\n        stopButton: '全部停止',\n        pauseTitle: '暫停{{count}}個列印任務',\n        pauseMessage: '這將暫停{{count}}臺印表機上的活動列印任務。',\n        pauseButton: '全部暫停',\n        clearPlateTitle: '清除{{count}}個列印床',\n        clearPlateMessage: '這將清除{{count}}臺印表機的列印床，可能會觸發佇列中的任務。',\n        clearPlateButton: '全部清除',\n      },\n      success: '{{action}}已在{{count}}臺印表機上完成',\n      partial: '{{succeeded}}成功，{{failed}}失敗',\n      noneApplicable: '沒有選中的印表機處於適合此操作的狀態',\n      selectByState: '按狀態選擇',\n    },\n    // Discovery\n    discovery: {\n      title: '發現印表機',\n      searching: '搜尋中...',\n      scanning: '掃描中...',\n      scanProgress: '掃描中... {{scanned}}/{{total}}',\n      foundPrinters: '發現 {{count}} 臺印表機',\n      noPrintersFound: '未找到印表機',\n      noPrintersFoundSubnet: '在指定子網中未找到印表機。',\n      noPrintersFoundNetwork: '在網路上未找到印表機。',\n      allConfigured: '所有發現的印表機已設定完畢。',\n      alreadyAdded: '已新增',\n      select: '選擇',\n      manualEntry: '手動輸入',\n      addFromCloud: '從雲端新增',\n      subnetToScan: '要掃描的子網',\n      dockerNote: '偵測到 Docker 環境。請以 CIDR 格式輸入印表機所在子網。需要在 docker-compose.yml 中設定 network_mode: host。',\n      scanSubnet: '掃描子網查詢印表機',\n      discoverNetwork: '在網路上發現印表機',\n      scanningSubnet: '正在掃描子網查詢拓竹印表機...',\n      scanningNetwork: '正在掃描網路...',\n      serialRequired: '需要序列號',\n      unknown: '未知',\n      failedToStart: '啟動發現失敗',\n    },\n    // AMS Drying\n    drying: {\n      start: '開始乾燥',\n      stop: '停止乾燥',\n      temperature: '溫度',\n      duration: '時長',\n      hours: '小時',\n      timeRemaining: '剩餘 {{time}}',\n      active: '乾燥中',\n      notSupported: '不支援乾燥',\n      powerRequired: '連線AMS電源介面卡以啟用乾燥',\n      startingDrying: '正在啟動乾燥...',\n      stoppingDrying: '正在停止乾燥...',\n      rotateTray: '乾燥時旋轉料盤',\n    },\n    // Filaments section\n    filaments: '耗材',\n    // Camera\n    openCameraOverlay: '開啟攝影機疊加層',\n    openCameraWindow: '在新視窗中開啟攝影機',\n    // Firmware\n    firmwareUpdateAvailable: '韌體更新可用：{{current}} → {{latest}}',\n    firmwareUpToDate: '韌體 {{version}} — 已是最新',\n    firmwareUpdateButton: '更新',\n    // Plate detection\n    plateDetection: {\n      noPermission: '您沒有更新印表機的權限',\n      enabledClick: '列印板檢查已啟用 - 點選停用',\n      disabledClick: '列印板檢查已停用 - 點選啟用',\n      manageCalibration: '管理列印板檢測校準',\n      calibrationRequired: '需要校準',\n      calibrationInstructions: '請確保列印板<strong>完全空置</strong>，然後點選校準。',\n      calibrationDescription: '校準會拍攝空置列印板的參考影像。後續檢查將與此參考進行比較以檢測物體。',\n      calibrationTip: '<strong>提示：</strong>您最多可以為不同的列印板儲存5個校準。系統會在檢查時自動使用最佳匹配。',\n      plateEmpty: '列印板似乎是空的',\n      objectsDetected: '在列印板上偵測到物體',\n      confidence: '置信度',\n      difference: '差異',\n      analysisPreview: '分析預覽：',\n      analysisLegend: '綠色框 = 檢測區域，紅色覆蓋 = 與校準的差異',\n      savedReferences: '已儲存的參考 ({{count}}/{{max}})',\n      deleteReference: '刪除參考',\n      labelPlaceholder: '標籤...',\n      clickToEdit: '{{label}} - 點選編輯',\n      clickToAddLabel: '點選新增標籤',\n    },\n    // Speed\n    speed: {\n      title: '列印速度',\n      silent: '靜音 (50%)',\n      standard: '標準 (100%)',\n      sport: '運動 (124%)',\n      ludicrous: '瘋狂 (166%)',\n    },\n    airduct: {\n      title: '風道模式',\n      cooling: '製冷',\n      heating: '加熱',\n    },\n    noSdCard: '無SD',\n    door: {\n      open: '開',\n      closed: '關',\n    },\n    // Fans\n    fans: {\n      partCooling: '零件冷卻風扇',\n      auxiliary: '輔助風扇',\n      chamber: '腔室風扇',\n    },\n    // HMS errors\n    clickToViewHmsErrors: '點選檢視 HMS 錯誤',\n    estimatedCompletion: '預計完成時間',\n    plateNumber: '板 {{number}}',\n    slotOptions: '槽位選項',\n    // AMS hover popup\n    amsPopup: {\n      friendlyName: 'AMS 名稱',\n      friendlyNamePlaceholder: '例如 AMS 友好名稱',\n      serialNumber: '序列號',\n      firmwareVersion: '韌體',\n      save: '儲存',\n      clear: '清除',\n      noEditPermission: '您沒有重新命名 AMS 單元的權限',\n    },\n    // Firmware modal\n    firmwareModal: {\n      title: '韌體更新',\n      titleUpToDate: '韌體資訊',\n      currentVersion: '目前版本：',\n      latestVersion: '最新版本：',\n      releaseNotes: '發布說明',\n      checkingPrereqs: '正在檢查前提條件...',\n      sdCardReady: 'SD 卡已就緒。點選下方上傳韌體。',\n      uploadedSuccess: '韌體已上傳到 SD 卡！',\n      applyInstructions: '在印表機上套用更新：',\n      step1: '在印表機觸控式螢幕上，前往<strong>設定</strong>',\n      step2: '導航到<strong>韌體</strong>',\n      step3: '選擇<strong>從 SD 卡更新</strong>',\n      step4: '更新將需要 10-20 分鐘',\n      done: '完成',\n      starting: '啟動中...',\n      uploadFirmware: '上傳韌體',\n      uploadFailed: '上傳啟動失敗：{{error}}',\n      uploadedToast: '韌體已上傳！請在印表機螢幕上觸發更新。',\n      availableVersions: '可用版本',\n      usable: '可用',\n      unavailable: '不可用',\n      installed: '已安裝',\n      newerBadge: '較新',\n      olderBadge: '較舊',\n      currentBadge: '目前',\n    },\n    accessCodePlaceholder: '留空以保持目前值',\n    // ROI editor\n    roi: {\n      title: '檢測區域 (ROI)',\n      xStart: 'X 起點',\n      yStart: 'Y 起點',\n      width: '寬度',\n      height: '高度',\n      instruction: '調整檢測區域以聚焦到列印板。預覽中的綠色框顯示目前區域。',\n    },\n    developerModeWarning: '以下印表機未啟用開發者區域網路模式：{{names}}。某些功能可能無法使用。',\n    howToEnable: '如何啟用',\n    incompatibleFile: '此檔案是為 {{slicedFor}} 切片的，但該印表機是 {{printerModel}}',\n    dropNotPrintable: '只能列印 .gcode 和 .gcode.3mf 檔案',\n    dropToPrint: '拖放以列印',\n    cannotPrint: '印表機忙碌',\n  },\n\n  // Archives page\n  archives: {\n    title: '列印歸檔',\n    searchPlaceholder: '搜尋歸檔...',\n    filterByPrinter: '按印表機篩選',\n    filterByStatus: '按狀態篩選',\n    sortBy: '排序方式',\n    sortNewest: '最新優先',\n    sortOldest: '最舊優先',\n    sortName: '名稱',\n    sortDuration: '時長',\n    sortLargest: '最大優先',\n    sortSmallest: '最小優先',\n    sortSize: '大小',\n    noArchives: '未找到歸檔',\n    noArchivesSearch: '沒有匹配搜尋的歸檔',\n    originalPrintNotVisible: '原始列印不可見 - 請嘗試清除篩選條件',\n    noArchivesYet: '尚無歸檔',\n    prints: '條列印',\n    pagination: {\n      showing: '顯示',\n      to: '至',\n      of: '共',\n      show: '每頁',\n      page: '頁',\n      all: '全部',\n    },\n    loadingArchives: '載入歸檔中...',\n    releaseToUpload: '放開以上傳',\n    showAll: '顯示全部',\n    showFavoritesOnly: '僅顯示收藏',\n    gridView: '網格檢視',\n    listView: '列表檢視',\n    calendarView: '日曆檢視',\n    logView: '列印日誌',\n    manageTags: '管理標籤',\n    showFailedPrints: '顯示失敗的列印',\n    hideFailedPrints: '隱藏失敗的列印',\n    hideDuplicates: '隱藏重複項',\n    viewOriginalPrint: '點選檢視原始列印 (#{{id}})',\n    printTime: '列印時間',\n    filamentUsed: '耗材用量',\n    cost: '成本',\n    reprint: '重新列印',\n    preview: '預覽',\n    deleteArchive: '刪除歸檔',\n    deleteConfirm: '確定要刪除此歸檔嗎？',\n    favorite: '收藏',\n    unfavorite: '取消收藏',\n    viewDetails: '檢視詳情',\n    status: {\n      completed: '已完成',\n      failed: '失敗',\n      stopped: '已停止',\n    },\n    toast: {\n      source3mfAttached: '源 3MF 已附加：{{filename}}',\n      failedUploadSource3mf: '上傳源 3MF 失敗',\n      source3mfRemoved: '源 3MF 已移除',\n      failedRemoveSource3mf: '移除源 3MF 失敗',\n      f3dAttached: 'F3D 已附加：{{filename}}',\n      failedUploadF3d: '上傳 F3D 失敗',\n      f3dRemoved: 'F3D 已移除',\n      failedRemoveF3d: '移除 F3D 失敗',\n      timelapseAttached: '縮時攝影已附加：{{filename}}',\n      timelapseAlreadyAttached: '縮時攝影已附加',\n      noMatchingTimelapse: '未找到匹配的縮時攝影',\n      failedScanTimelapse: '掃描縮時攝影失敗',\n      failedAttachTimelapse: '附加縮時攝影失敗',\n      timelapseRemoved: '縮時攝影已移除',\n      failedRemoveTimelapse: '移除縮時攝影失敗',\n      timelapseUploaded: '縮時攝影已上傳：{{filename}}',\n      failedUploadTimelapse: '上傳縮時攝影失敗',\n      archiveDeleted: '歸檔已刪除',\n      failedDeleteArchive: '刪除歸檔失敗',\n      addedToFavorites: '已新增到收藏',\n      removedFromFavorites: '已從收藏中移除',\n      projectUpdated: '專案已更新',\n      failedUpdateProject: '更新項目失敗',\n      linkCopied: '連結已複製到剪貼簿',\n      failedCopyLink: '複製連結失敗',\n      photoDeleted: '照片已刪除',\n      failedDeletePhoto: '刪除照片失敗',\n      failedDeleteArchives: '刪除歸檔失敗',\n      failedUpdateFavorites: '更新收藏失敗',\n      exportDownloaded: '匯出已下載',\n      exportFailed: '匯出失敗',\n    },\n    menu: {\n      print: '列印',\n      schedule: '排程',\n      openInBambuStudio: '在切片軟體中開啟',\n      slice: '切片',\n      externalLink: '外部連結',\n      viewOnMakerWorld: '在 MakerWorld 上檢視',\n      preview3d: '3D 預覽',\n      viewTimelapse: '檢視縮時攝影',\n      scanForTimelapse: '掃描縮時攝影',\n      uploadTimelapse: '上傳縮時攝影',\n      removeTimelapse: '移除縮時攝影',\n      downloadSource3mf: '下載源 3MF',\n      uploadSource3mf: '上傳源 3MF',\n      replaceSource3mf: '替換源 3MF',\n      removeSource3mf: '移除源 3MF',\n      uploadF3d: '上傳 F3D',\n      replaceF3d: '替換 F3D',\n      downloadF3d: '下載 F3D',\n      removeF3d: '移除 F3D',\n      download: '下載',\n      copyDownloadLink: '複製下載連結',\n      qrCode: 'QR Code',\n      viewPhotos: '檢視照片',\n      viewPhotosCount: '檢視照片 ({{count}})',\n      projectPage: '專案頁面',\n      addToFavorites: '新增到收藏',\n      removeFromFavorites: '從收藏中移除',\n      edit: '編輯',\n      goToProject: '前往專案：{{name}}',\n      addToProject: '新增到專案',\n      removeFromProject: '從專案中移除',\n      loading: '載入中...',\n      noProjectsAvailable: '無可用專案',\n      select: '選擇',\n      deselect: '取消選擇',\n      delete: '刪除',\n    },\n    permission: {\n      noReprint: '您沒有重新列印此歸檔的權限',\n      noAddToQueue: '您沒有新增到佇列的權限',\n      noUpdateArchives: '您沒有更新歸檔的權限',\n      noUploadFiles: '您沒有上傳檔案的權限',\n      noDownload: '您沒有下載歸檔的權限',\n      noCopyLink: '您沒有複製下載連結的權限',\n      noDelete: '您沒有刪除此歸檔的權限',\n      noCreate: '您沒有建立歸檔的權限',\n    },\n    card: {\n      previousPlate: '上一個板',\n      nextPlate: '下一個板',\n      plateNumber: '板 {{index}}',\n      moreOptions: '右鍵檢視更多選項',\n      addToFavorites: '新增到收藏',\n      removeFromFavorites: '從收藏中移除',\n      cancelled: '已取消',\n      failed: '失敗',\n      duplicate: '重複',\n      duplicateTitle: '此模型之前已列印過',\n      openSource3mf: '在 Bambu Studio 中開啟源 3MF（右鍵檢視更多選項）',\n      downloadF3d: '下載 Fusion 360 設計檔案',\n      viewTimelapse: '檢視縮時攝影',\n      viewPhoto: '檢視 1 張照片',\n      viewPhotos: '檢視 {{count}} 張照片',\n      openFolder: '開啟資料夾：{{name}}',\n      slicedFile: '已切片檔案 - 可以列印',\n      sourceFile: '僅原始檔 - 無 AMS 對應可用',\n      gcode: 'GCODE',\n      source: '原始檔',\n      project: '專案：{{name}}',\n      estimated: '預計：{{time}}',\n      actual: '實際：{{time}}',\n      accuracy: '準確度：{{percent}}%',\n      filament: '{{weight}}g',\n      layer: '{{count}} 層',\n      layers: '{{count}} 層',\n      object: '{{count}} 個物件',\n      objects: '{{count}} 個物件',\n      slicedFor: '為 {{model}} 切片',\n      uploadedBy: '上傳者',\n      noPermissionReprint: '您沒有重新列印的權限',\n      noFileForReprint: '無可用的 3MF 檔案 — 列印紀錄時無法從印表機下載該檔案',\n      noPermissionEdit: '您沒有編輯歸檔的權限',\n      noPermissionDelete: '您沒有刪除歸檔的權限',\n      reprint: '重新列印',\n      schedulePrint: '排程列印',\n      schedule: '排程',\n      openInBambuStudio: '在切片軟體中開啟',\n      openInBambuStudioToSlice: '在切片軟體中開啟進行切片',\n      slice: '切片',\n      externalLink: '外部連結',\n      makerWorld: 'MakerWorld：{{designer}}',\n      viewProject: '檢視專案',\n      noExternalLink: '無外部連結',\n      preview3d: '3D 預覽',\n      download: '下載',\n      edit: '編輯',\n      delete: '刪除',\n    },\n    modal: {\n      deleteArchive: '刪除歸檔',\n      deleteConfirm: '確定要刪除\"{{name}}\"嗎？此操作無法復原。',\n      deleteButton: '刪除',\n      removeSource3mf: '移除源 3MF',\n      removeSource3mfConfirm: '確定要從\"{{name}}\"中移除源 3MF 檔案嗎？這將刪除原始切片專案檔案。',\n      removeButton: '移除',\n      removeF3d: '移除 F3D',\n      removeF3dConfirm: '確定要從\"{{name}}\"中移除 Fusion 360 設計檔案嗎？',\n      removeTimelapse: '移除縮時攝影',\n      removeTimelapseConfirm: '確定要從\"{{name}}\"中移除縮時攝影影片嗎？',\n      timelapse: '{{name}} - 縮時攝影',\n      selectTimelapse: '選擇縮時攝影',\n      selectTimelapseDesc: '未找到自動匹配。請選擇此列印的縮時攝影：',\n      deleteArchives: '刪除歸檔',\n      deleteArchivesConfirm: '確定要刪除 {{count}} 個歸檔嗎？此操作無法復原。',\n      deleteCount: '刪除 {{count}} 個',\n    },\n    page: {\n      title: '歸檔',\n      printsCount: '{{filtered}} / {{total}} 次列印',\n      dropFilesHere: '將 .3mf 檔案拖放到此處',\n      releaseToUpload: '放開以上傳',\n      only3mfSupported: '僅支援 .3mf 檔案',\n      close: '關閉',\n      selected: '已選擇 {{count}} 個',\n      selectAll: '全選',\n      tags: '標籤',\n      project: '專案',\n      favorite: '收藏',\n      delete: '刪除',\n      toggledFavorites: '已切換 {{count}} 個歸檔的收藏狀態',\n      failedUpdateFavorites: '更新收藏失敗',\n      archivesDeleted: '已刪除 {{count}} 個歸檔',\n      failedDeleteArchives: '刪除歸檔失敗',\n      photoDeleted: '照片已刪除',\n      failedDeletePhoto: '刪除照片失敗',\n    },\n    list: {\n      name: '名稱',\n      printer: '印表機',\n      date: '日期',\n      size: '大小',\n      actions: '操作',\n      hasTimelapse: '有縮時攝影',\n    },\n    log: {\n      date: '日期',\n      printName: '列印名稱',\n      printer: '印表機',\n      user: '使用者',\n      status: '狀態',\n      duration: '時長',\n      filament: '耗材',\n      allPrinters: '所有印表機',\n      allUsers: '所有使用者',\n      allStatuses: '所有狀態',\n      cancelled: '已取消',\n      skipped: '已跳過',\n      dateFrom: '從',\n      dateTo: '到',\n      noEntries: '未找到列印日誌條目',\n      showing: '顯示 {{count}} / {{total}} 條',\n      rowsPerPage: '行數',\n      page: '頁',\n      prev: '上一頁',\n      next: '下一頁',\n      clearLog: '清除日誌',\n      clearLogTitle: '清除列印日誌',\n      clearLogConfirm: '所有列印日誌條目將被永久刪除。歸檔和佇列項目不受影響。此操作無法復原。確定要繼續嗎？',\n      clearLogButton: '全部清除',\n      cleared: '已清除 {{count}} 條日誌',\n      clearFailed: '清除列印日誌失敗',\n    },\n  },\n\n  // Queue page\n  queue: {\n    title: '列印佇列',\n    subtitle: '排程和管理您的列印任務',\n    addToQueue: '新增到佇列',\n    // Print modal\n    print: '列印',\n    reprint: '重新列印',\n    schedulePrint: '排程列印',\n    editQueueItem: '編輯佇列項目',\n    printToPrinters: '列印到 {{count}} 臺印表機',\n    queueToPrinters: '佇列到 {{count}} 臺印表機',\n    queueSelectedPlates: '將 {{count}} 個熱床加入佇列',\n    selectAllPlates: '選擇全部 {{count}} 個熱床',\n    deselectAll: '取消全選',\n    printQueued: '已加入列印佇列',\n    itemsQueued: '{{count}} 個任務已加入佇列',\n    sending: '傳送中...',\n    sendingProgress: '傳送中 {{current}}/{{total}}...',\n    adding: '新增中...',\n    addingProgress: '新增中 {{current}}/{{total}}...',\n    savingProgress: '儲存中 {{current}}/{{total}}...',\n    clearQueue: '清空佇列',\n    clearHistory: '清除歷史',\n    emptyQueue: '佇列為空',\n    position: '位置',\n    scheduledTime: '排程時間',\n    moveUp: '上移',\n    moveDown: '下移',\n    startNow: '立即開始',\n    printingInProgress: '列印進行中...',\n    viewArchive: '檢視歸檔',\n    viewInFileManager: '在檔案管理器中檢視',\n    itemCount: '{{count}} 個項目',\n    itemCount_plural: '{{count}} 個項目',\n    dragToReorder: '拖曳以重新排序（僅限盡快）',\n    reorderHint: '位置僅影響\"儘快\"項目。排程項目按設定時間執行。',\n    sjf: {\n      label: 'SJF',\n      tooltip: '最短任務優先 — 排程器優先處理較短的列印任務',\n    },\n    addedBy: '由 {{name}} 新增',\n    nextInQueue: '佇列中的下一個',\n    clearPlateSuccess: '列印板已清理 — 準備進行下一個列印',\n    plateNumber: '板 {{index}}',\n    // Batch / quantity\n    quantity: '數量',\n    quantityHint: '建立 {{count}} 個佇列項目',\n    activeBatches: '活躍批次',\n    batchProgress: '已完成 {{completed}}/{{total}}',\n    cancelBatch: '取消剩餘',\n    batchCancelled: '已取消剩餘批次項目',\n    cancelBatchConfirmTitle: '取消批次',\n    cancelBatchConfirmMessage: '取消此批次中所有剩餘的待處理項目？',\n    batch: '批次',\n    // Sections\n    sections: {\n      currentlyPrinting: '正在列印',\n      queued: '佇列中',\n      history: '歷史',\n    },\n    // Status\n    status: {\n      pending: '等待中',\n      waiting: '等待中',\n      printing: '列印中',\n      paused: '已暫停',\n      completed: '已完成',\n      failed: '失敗',\n      skipped: '已跳過',\n      cancelled: '已取消',\n    },\n    // Summary cards\n    summary: {\n      printing: '列印中',\n      queued: '佇列中',\n      totalTime: '總佇列時間',\n      totalWeight: '總佇列重量',\n      history: '歷史',\n    },\n    // Filters\n    filter: {\n      allPrinters: '所有印表機',\n      unassigned: '未分配',\n      allStatus: '所有狀態',\n      allLocations: '所有位置',\n      any: '任意',\n    },\n    // Sort\n    sort: {\n      byPosition: '按位置排序',\n      byName: '按名稱排序',\n      byPrinter: '按印表機排序',\n      bySchedule: '按排程排序',\n      byDate: '按日期排序',\n      ascendingOldest: '升序（最舊優先）',\n      descendingNewest: '降序（最新優先）',\n    },\n    // Badges\n    badges: {\n      staged: '已暫存',\n      requiresPrevious: '需要前一個成功',\n      autoPowerOff: '自動關機',\n      gcodeInjection: 'G-code',\n    },\n    // Empty state\n    empty: {\n      title: '沒有排程的列印',\n      description: '從歸檔頁面使用右鍵選單中的\"排程\"選項來排程列印，或拖放檔案開始。',\n    },\n    // Time\n    time: {\n      asap: '儘快',\n      overdue: '已逾期',\n      now: '現在',\n      lessThanMinute: '不到一分鐘',\n      inMinutes: '{{count}} 分鐘後',\n      inHours: '{{count}} 小時後',\n    },\n    // Actions\n    actions: {\n      stopPrint: '停止列印',\n      startPrint: '開始列印',\n      requeue: '重新佇列',\n    },\n    // Bulk edit\n    bulkEdit: {\n      title: '編輯 {{count}} 個項目',\n      title_plural: '編輯 {{count}} 個項目',\n      description: '僅更改的設定將套用於所選項目。',\n      printer: '印表機',\n      noChange: '— 不更改 —',\n      queueOptions: '佇列選項',\n      staged: '暫存（手動開始）',\n      autoPowerOff: '列印後自動關機',\n      requirePrevious: '要求前一個成功',\n      printOptions: '列印選項',\n      bedLevelling: '熱床調平',\n      flowCalibration: '流量校準',\n      vibrationCalibration: '振動校準',\n      layerInspection: '首層檢查',\n      timelapse: '縮時攝影',\n      useAms: '使用 AMS',\n      applyChanges: '套用更改',\n      selectAll: '全選',\n      deselectAll: '取消全選',\n      selected: '已選擇 {{count}} 個',\n      editSelected: '編輯所選',\n      cancelSelected: '取消所選',\n    },\n    // Confirmations\n    confirm: {\n      cancelTitle: '取消排程列印',\n      cancelMessage: '確定要取消\"{{name}}\"嗎？',\n      stopTitle: '停止列印',\n      stopMessage: '確定要停止目前列印\"{{name}}\"嗎？這將取消印表機上的列印任務。',\n      removeTitle: '從歷史中移除',\n      removeMessage: '確定要從佇列歷史中移除\"{{name}}\"嗎？',\n      clearHistoryTitle: '清除歷史',\n      clearHistoryMessage: '確定要從歷史中移除所有 {{count}} 個項目嗎？',\n      cancelButton: '取消列印',\n      stopButton: '停止列印',\n      thisPrint: '此列印',\n      thisItem: '此項目',\n    },\n    // Toast messages\n    toast: {\n      cancelled: '佇列項目已取消',\n      cancelFailed: '取消項目失敗',\n      removed: '佇列項目已移除',\n      removeFailed: '移除項目失敗',\n      stopped: '列印已停止',\n      stopFailed: '停止列印失敗',\n      released: '列印已加入佇列',\n      startFailed: '開始列印失敗',\n      reorderFailed: '重新排序佇列失敗',\n      historyCleared: '已清除 {{count}} 條歷史紀錄',\n      clearHistoryFailed: '清除歷史失敗',\n      updateFailed: '更新項目失敗',\n      bulkCancelled: '已取消 {{count}} 個項目',\n      bulkCancelFailed: '批次取消項目失敗',\n    },\n    // Timeline view\n    timeline: {\n      listView: '列表',\n      timelineView: '時間線',\n      unassigned: '未分配',\n      noData: '當天沒有計畫的列印任務',\n      allDoneBy: '所有列印預計在 {{time}} 前完成',\n      staged: '暫存',\n      filterAll: '全部顯示',\n      filterPrinting: '列印中',\n      filterQueued: '佇列中',\n      time: {\n        anyMoment: '即將完成',\n        minutesLeft: '剩餘{{minutes}} 分鐘',\n        hoursLeft: '剩餘{{hours}}小時',\n        hoursMinutesLeft: '剩餘{{hours}}小時{{minutes}} 分鐘',\n      },\n      day: {\n        previous: '前一天',\n        next: '後一天',\n        today: '今天',\n      },\n    },\n    // Permissions\n    permissions: {\n      noStopPrint: '您沒有停止列印的權限',\n      noStartPrint: '您沒有開始列印的權限',\n      noEdit: '您沒有編輯此佇列項目的權限',\n      noCancel: '您沒有取消此佇列項目的權限',\n      noRequeue: '您沒有重新佇列的權限',\n      noRemove: '您沒有移除此佇列項目的權限',\n      noClearHistory: '您沒有清除所有歷史的權限',\n      noEditItems: '您沒有編輯佇列項目的權限',\n      noCancelItems: '您沒有取消佇列項目的權限',\n    },\n  },\n\n  backgroundDispatch: {\n    unknownFile: '未知檔案',\n    unknownPrinter: '未知印表機',\n    startingPrints: '正在開始列印',\n    progressSummary: '{{complete}}/{{total}} 完成 • 已分發：{{dispatched}} • 處理中：{{processing}}',\n    expandDetails: '展開分發詳情',\n    collapseDetails: '收起分發詳情',\n    dismissToast: '關閉分發通知',\n    cancelDispatchJob: '取消分發任務',\n    cancel: '取消',\n    cancelling: '取消中…',\n    status: {\n      dispatched: '已分發',\n      processing: '處理中',\n      completed: '已完成',\n      failed: '失敗',\n      cancelled: '已取消',\n    },\n    toast: {\n      cancellingUpload: '取消上傳中...',\n      cancelled: '分發已取消',\n      cancelFailed: '取消分發失敗',\n      completeWithFailures: '後台分發完成：{{completed}} 成功，{{failed}} 失敗',\n      completeSuccess: '後台分發完成：{{completed}} 成功',\n      printStartedRemaining: '{{completed}} 個列印已開始，{{remaining}} 個正在傳送...',\n    },\n  },\n\n  // Statistics page\n  stats: {\n    title: '儀表板',\n    subtitle: '拖曳小工具以重新排列。點選眼睛圖示隱藏。',\n    overview: '概覽',\n    totalPrints: '總列印次數',\n    successRate: '成功率',\n    totalPrintTime: '總列印時間',\n    printTime: '列印時間',\n    totalFilament: '總耗材用量',\n    filamentUsed: '耗材用量',\n    filamentCost: '耗材成本',\n    totalCost: '總成本',\n    energyUsed: '能耗',\n    energyCost: '能源成本',\n    energyWarmingUpTooltip: '能耗追蹤正在收集每小時快照。當所選範圍之前至少存在一個快照時，時間段合計將變得準確。早期數值可能偏低。',\n    averagePrintTime: '平均列印時間',\n    printsPerDay: '每日列印次數',\n    byPrinter: '按印表機',\n    printsByPrinter: '各印表機列印次數',\n    byMaterial: '按材料',\n    byMonth: '按月份',\n    last7Days: '最近 7 天',\n    last30Days: '最近 30 天',\n    last90Days: '最近 90 天',\n    allTime: '全部時間',\n    // Widgets\n    quickStats: '快速統計',\n    printActivity: '列印活動',\n    filamentTypes: '耗材類型',\n    filamentTrends: '耗材趨勢',\n    failureAnalysis: '失敗分析',\n    timeAccuracy: '時間準確度',\n    successful: '成功：',\n    failed: '失敗：',\n    perfectEstimate: '100% = 完美估計',\n    noTimeAccuracyData: '尚無時間準確度資料',\n    noFilamentData: '尚無耗材資料',\n    noPrinterData: '尚無印表機資料',\n    noPrintData: '尚無列印資料',\n    noPrintDataLast30Days: '最近 30 天無列印資料',\n    failureReasons: '失敗原因',\n    topFailureReasons: '主要失敗原因',\n    failedPrintsCount: '{{failed}} / {{total}} 次列印失敗',\n    lastWeekRate: '上週：{{rate}}%',\n    // Actions\n    resetLayout: '重設佈局',\n    recalculateCosts: '重新計算成本',\n    recalculateCostsHint: '使用目前耗材價格重新計算所有歸檔成本',\n    exportStats: '匯出統計',\n    exportAsCsv: '匯出為 CSV',\n    exportAsExcel: '匯出為 Excel',\n    hiddenCount: '{{count}} 個已隱藏',\n    // Toast\n    exportDownloaded: '匯出已下載',\n    exportFailed: '匯出失敗',\n    layoutReset: '佈局已重設',\n    recalculatedCosts: '已為 {{count}} 個歸檔重新計算成本',\n    recalculateFailed: '重新計算成本失敗',\n    // Loading\n    loadingStats: '載入統計資料中...',\n    // Permissions\n    noPermissionResetLayout: '您沒有重設佈局的權限',\n    noPermissionRecalculate: '您沒有重新計算成本的權限',\n    noPrintDataInRange: '所選範圍內無列印資料',\n    periodFilament: '期間耗材',\n    periodCost: '期間成本',\n    avgPerPrint: '每次列印平均',\n    usageOverTime: '隨時間的使用量',\n    filamentByWeight: '重量',\n    printDuration: '列印時長',\n    printerUtilization: '印表機利用率',\n    filamentSuccess: '按材料成功率',\n    printHabits: '列印習慣',\n    printTimeOfDay: '列印時段',\n    colorDistribution: '顏色分佈',\n    noColorData: '尚無顏色資料',\n    records: '紀錄',\n    longestPrint: '最長列印',\n    heaviestPrint: '最重列印',\n    mostExpensivePrint: '最貴列印',\n    busiestDay: '最忙碌的一天',\n    successStreak: '連續成功',\n    streakPrint: '連續列印',\n    streakPrints: '{{count}} 次連續列印',\n    printerStats: '印表機統計',\n    hours: '小時',\n    avgPrints: '平均列印',\n    noArchiveData: '尚無列印資料',\n    filamentByTime: '時間',\n    avgWeight: '平均重量',\n    avgTime: '平均時間',\n    filamentByPrints: '列印次數',\n    timeframe: {\n      today: '今天',\n      'this-week': '本週',\n      'this-month': '本月',\n      'last-7': '最近 7 天',\n      'last-30': '最近 30 天',\n      'last-90': '最近 90 天',\n      'this-year': '今年',\n      'all-time': '全部時間',\n      custom: '自訂範圍',\n      from: '從',\n      to: '到',\n    },\n    allUsers: '所有使用者',\n    noUser: '無使用者（系統）',\n    filterByUser: '按使用者篩選',\n  },\n\n  // Maintenance page\n  maintenance: {\n    title: '維護',\n    overview: '概覽',\n    allOk: '所有維護均已完成',\n    dueCount: '{{count}} 項到期',\n    dueCount_plural: '{{count}} 項到期',\n    warningCount: '{{count}} 個警告',\n    warningCount_plural: '{{count}} 個警告',\n    totalPrintTime: '總列印時間',\n    nextMaintenance: '下次維護',\n    nothingDue: '無到期項目',\n    tasks: '任務',\n    lastPerformed: '上次執行',\n    interval: '間隔',\n    hoursRemaining: '剩餘 {{hours}} 小時',\n    hoursOverdue: '逾期 {{hours}} 小時',\n    markDone: '標記為完成',\n    performMaintenance: '執行維護',\n    history: '歷史',\n    noHistory: '無維護歷史',\n    editPrintHours: '編輯列印時間',\n    currentHours: '目前小時數',\n    // Tabs\n    statusTab: '狀態',\n    settingsTab: '設定',\n    // Status\n    overdueCount: '{{count}} 個逾期',\n    dueSoonCount: '{{count}} 個即將到期',\n    dueSoon: '即將到期',\n    allGood: '一切正常',\n    overdueBy: '逾期 {{duration}}',\n    dueIn: '{{duration}} 後到期',\n    timeLeft: '剩餘 {{duration}}',\n    // Duration formats\n    day: '1 天',\n    days: '{{count}} 天',\n    week: '1 週',\n    weeks: '{{count}} 週',\n    month: '1 個月',\n    months: '{{count}} 個月',\n    year: '1 年',\n    // Settings\n    maintenanceTypes: '維護類型',\n    maintenanceTypesDescription: '系統類型和您的自訂維護任務',\n    addCustomType: '新增自訂類型',\n    restoreDefaults: '恢復預設任務',\n    intervalType: '間隔類型',\n    intervalValue: '間隔 ({{type}})',\n    icon: '圖示',\n    documentationLink: '說明文件連結（可選）',\n    assignToPrinters: '分配給印表機',\n    selectAtLeastOnePrinter: '至少選擇一臺印表機',\n    addType: '新增類型',\n    custom: '自訂',\n    printHours: '列印小時數',\n    calendarDays: '日曆天數',\n    exampleName: '例如：更換 HEPA 過濾器',\n    viewDocumentation: '檢視說明文件',\n    timeBasedInterval: '基於時間的間隔',\n    // Interval overrides\n    intervalOverrides: '間隔覆蓋',\n    intervalOverridesDescription: '為特定印表機自訂間隔',\n    // Printer assignment\n    assignedToPrinters: '已分配給印表機：',\n    noPrintersAssigned: '未分配印表機',\n    addPrinterShort: '新增：',\n    printersAssignedClick: '已分配 {{count}} 臺印表機 - 點選管理',\n    removeFromPrinter: '從此印表機移除',\n    // Types\n    types: {\n      lubricateCarbonRods: '潤滑碳纖維杆',\n      lubricateRails: '潤滑線性導軌',\n      cleanNozzle: '清潔噴嘴/熱端',\n      checkBelts: '檢查皮帶張力',\n      cleanBuildPlate: '清潔列印板',\n      checkExtruder: '檢查擠出機齒輪',\n      checkCooling: '檢查冷卻風扇',\n      generalInspection: '綜合檢查',\n      cleanCarbonRods: '清潔碳纖維杆',\n      lubricateSteelRods: '潤滑鋼杆',\n      cleanSteelRods: '清潔鋼杆',\n      cleanLinearRails: '清潔線性導軌',\n      checkPtfeTube: '檢查 PTFE 管',\n      replaceHepaFilter: '更換 HEPA 過濾器',\n      replaceCarbonFilter: '更換活性炭過濾器',\n      lubricateLeftNozzleRail: '潤滑左噴嘴導軌',\n    },\n    // Toast\n    maintenanceComplete: '維護已標記為完成',\n    typeUpdated: '維護類型已更新',\n    typeDeleted: '維護類型已刪除',\n    defaultsRestored: '已恢復 {{count}} 個預設任務',\n    printHoursUpdated: '列印小時數已更新',\n    printerAssigned: '印表機已分配',\n    printerRemoved: '印表機已移除',\n    // Confirmation\n    deleteTypeConfirm: '刪除\"{{name}}\"？',\n    deleteSystemTypeTitle: '刪除預設維護任務？',\n    deleteSystemTypeMessage: '確定要刪除預設維護任務\"{{name}}\"嗎？',\n    // Permissions\n    noPermissionUpdate: '您沒有更新維護項目的權限',\n    noPermissionPerform: '您沒有執行維護的權限',\n    noPermissionEditTypes: '您沒有編輯維護類型的權限',\n    noPermissionDeleteTypes: '您沒有刪除維護類型的權限',\n    noPermissionEditHours: '您沒有編輯列印時間的權限',\n    noPermissionRemovePrinter: '您沒有移除印表機分配的權限',\n    noPermissionAssignPrinter: '您沒有分配印表機的權限',\n    noPermissionEditIntervals: '您沒有編輯間隔的權限',\n    // Configure link\n    configureSettings: '設定維護類型和間隔',\n  },\n\n  // Settings page\n  settings: {\n    title: '設定',\n    general: '通用',\n    // Tab names\n    tabs: {\n      general: '通用',\n      smartPlugs: '智慧插座',\n      notifications: '通知',\n      queue: '工作流程',\n      filament: '耗材',\n      network: '網路',\n      apiKeys: 'API 金鑰',\n      virtualPrinter: '虛擬印表機',\n      spoolbuddy: 'SpoolBuddy',\n      failureDetection: '故障檢測',\n      users: '身份驗證',\n      backup: '備份',\n      emailAuth: '信箱認證',\n      ldap: 'LDAP',\n      twoFa: '雙因素認證',\n      oidc: 'SSO / OIDC',\n    },\n    spoolbuddy: {\n      infoTitle: 'SpoolBuddy 裝置',\n      infoBody: 'SpoolBuddy kiosk 透過心跳自動註冊。如果裝置不再使用，或守護程式當機遺留了過時的重複項，可在此取消註冊。',\n      duplicatesTitle: '已註冊 {{count}} 台裝置',\n      duplicatesBody: 'kiosk 介面只使用最先註冊的裝置。如果其中有因當機遺留的過時重複項，請取消註冊它——線上裝置會在下次心跳時重新註冊自己。',\n      empty: '尚未註冊任何 SpoolBuddy 裝置。',\n      online: '線上',\n      offline: '離線',\n      unregister: '取消註冊',\n      unregisterSuccess: '裝置已取消註冊',\n      unregisterError: '取消註冊裝置失敗',\n      confirmTitle: '取消註冊 SpoolBuddy 裝置？',\n      confirmBody: '將從資料庫中移除 \"{{hostname}}\" ({{deviceId}})。如果裝置線上，會在下次心跳時重新註冊自己。',\n      ipAddress: 'IP 位址',\n      firmware: '韌體',\n      lastSeen: '上次線上',\n      daemonUptime: '守護程式執行時間',\n      systemUptime: '系統執行時間',\n      never: '從未',\n      nfc: 'NFC',\n      scale: '磅秤',\n      cpuTemp: 'CPU 溫度',\n      memory: '記憶體',\n      disk: '磁碟',\n      // Device actions\n      update: '更新',\n      updateConfirmTitle: '更新 SpoolBuddy 守護程式？',\n      updateConfirmBody: '對 \"{{hostname}}\" 觸發軟體更新？更新完成後守護程式將重新啟動。',\n      restartBrowser: '重新啟動瀏覽器',\n      restartBrowserConfirmTitle: '重新啟動 kiosk 瀏覽器？',\n      restartBrowserConfirmBody: '在 \"{{hostname}}\" 上重新啟動 kiosk 瀏覽器？顯示將短暫黑屏。',\n      restartDaemon: '重新啟動守護程式',\n      restartDaemonConfirmTitle: '重新啟動 SpoolBuddy 守護程式？',\n      restartDaemonConfirmBody: '在 \"{{hostname}}\" 上重新啟動 SpoolBuddy 守護程式？裝置將離線幾秒鐘。',\n      reboot: '重新開機',\n      rebootConfirmTitle: '重新開機？',\n      rebootConfirmBody: '重新開機 \"{{hostname}}\"？裝置將離線約一分鐘。',\n      shutdown: '關機',\n      shutdownConfirmTitle: '關閉裝置？',\n      shutdownConfirmBody: '關閉 \"{{hostname}}\"？您需要實體存取才能重新開機。',\n      commandConfirm: '確認',\n      commandQueued: '命令已加入佇列',\n      commandError: '傳送命令失敗',\n    },\n    ldap: {\n      title: 'LDAP 認證',\n      enabledDesc: 'LDAP 認證已啟用',\n      disabledDesc: 'LDAP 認證已停用',\n      disabledHint: '在下方設定並儲存 LDAP 設定，然後啟用。',\n      enabled: 'LDAP 認證已啟用',\n      disabled: 'LDAP 認證已停用',\n      feature1: '使用者可以使用 LDAP 憑據登入',\n      feature2: '本機管理員帳戶作為後備保留',\n      feature3: '登入時 LDAP 群組對應到 BamBuddy 群組',\n      serverConfig: 'LDAP 伺服器設定',\n      serverUrl: '伺服器 URL',\n      serverUrlHint: '使用 ldap:// 進行標準連線或 ldaps:// 進行 SSL 連線',\n      security: '安全',\n      securityHint: 'StartTLS 將普通連線升級為 TLS。LDAPS 從一開始就使用 TLS。',\n      bindDn: '繫結 DN（服務帳戶）',\n      bindPassword: '繫結密碼',\n      searchBase: '搜尋基礎 DN',\n      userFilter: '使用者搜尋過濾器',\n      userFilterHint: '{username} 替換為登入使用者名稱。OpenLDAP 使用 (uid={username})。',\n      autoProvision: '自動建立使用者',\n      autoProvisionHint: '首次 LDAP 登入時自動建立 BamBuddy 帳戶',\n      defaultGroup: '預設群組',\n      defaultGroupNone: '— 無（無復原）—',\n      defaultGroupHint: '當 LDAP 使用者透過身份驗證但不在任何已對應的 LDAP 群組中時分配的備援群組。留空以使未對應的使用者沒有權限。',\n      groupMapping: '群組對應（JSON）',\n      groupMappingHint: '將 LDAP 群組 DN 對應到 BamBuddy 群組。可用群組：',\n      testConnection: '測試連線',\n      settingsSaved: 'LDAP 設定已儲存',\n      errors: {\n        serverRequired: 'LDAP 伺服器 URL 為必填項',\n        searchBaseRequired: '搜尋基礎 DN 為必填項',\n        enableAuthFirst: '請先啟用認證',\n        configureLdapFirst: '請先儲存 LDAP 設定',\n      },\n    },\n    // Email settings\n    email: {\n      smtpSettings: 'SMTP 設定',\n      smtpHost: 'SMTP 伺服器',\n      smtpPort: 'SMTP 連接埠',\n      security: '安全',\n      authentication: '認證',\n      username: '使用者名稱',\n      password: '密碼',\n      fromEmail: '寄件信箱',\n      fromName: '寄件人名稱',\n      testConnection: '測試 SMTP 連線',\n      testRecipient: '測試收件信箱',\n      sendTest: '傳送測試郵件',\n      sending: '傳送中...',\n      save: '儲存設定',\n      saving: '儲存中...',\n      advancedAuth: '進階認證',\n      advancedAuthEnabled: '進階認證已啟用',\n      advancedAuthEnabledDesc: '基於信箱的使用者管理功能已啟用。新使用者將透過郵件收到自動產生的密碼，使用者可以透過忘記密碼功能重設密碼。',\n      advancedAuthDisabled: '進階認證已停用',\n      advancedAuthDisabledDesc: '啟用進階認證以啟用基於信箱的使用者管理功能。',\n      enable: '啟用',\n      disable: '停用',\n      feature1: '密碼自動產生並透過郵件傳送給新使用者',\n      feature2: '使用者可以使用使用者名稱或信箱登入',\n      feature3: '忘記密碼功能可用',\n      feature4: '管理員可以透過郵件重設使用者密碼',\n      // Error messages\n      errors: {\n        requiredFields: '請填寫所有必填欄位',\n        usernameRequired: '啟用認證時需要使用者名稱',\n        enterTestEmail: '請輸入測試信箱地址',\n        smtpServerAndEmail: '測試前請填寫 SMTP 伺服器和寄件信箱',\n        usernamePasswordRequired: '啟用認證時需要使用者名稱和密碼',\n        configureSmtpFirst: '請先設定並測試 SMTP 設定',\n        enableAuthFirst: '請先啟用身份驗證才能使用基於電子郵件的功能。',\n      },\n      // Success messages\n      success: {\n        settingsSaved: 'SMTP 設定儲存成功',\n      },\n      // Security options\n      securityOptions: {\n        starttls: 'STARTTLS（連接埠 587）',\n        ssl: 'SSL/TLS（連接埠 465）',\n        none: '無（連接埠 25）',\n      },\n      // Authentication options\n      authOptions: {\n        enabled: '已啟用',\n        disabled: '已停用',\n      },\n    },\n    appearance: '外觀',\n    notifications: '通知',\n    smartPlugs: '智慧插座',\n    spoolman: 'Spoolman',\n    updates: '更新',\n    language: '語言',\n    languageDescription: '選擇您的首選語言',\n    theme: '主題',\n    themeLight: '淺色',\n    themeDark: '深色',\n    themeSystem: '跟隨系統',\n    defaultView: '預設檢視',\n    defaultViewDescription: '開啟應用程式時顯示的頁面',\n    checkForUpdates: '檢查更新',\n    autoUpdate: '自動更新',\n    currentVersion: '目前版本',\n    latestVersion: '最新版本',\n    upToDate: '已是最新版本',\n    updateAvailable: '有可用更新',\n    // Notifications\n    notificationLanguage: '通知語言',\n    notificationLanguageDescription: '推送通知的語言',\n    bedCooledThreshold: '熱床冷卻閾值',\n    bedCooledThresholdDescription: '列印後熱床被視為已冷卻的溫度',\n    userNotificationsEnabled: '使用者通知',\n    userNotificationsEnabledDescription: '啟用使用者通知選單和列印任務事件的郵件通知。需要進階身份驗證。',\n    userNotificationsDisabledHint: '請啟用進階身份驗證以使用使用者通知。',\n    notificationProviders: '通知提供者',\n    addProvider: '新增提供者',\n    editProvider: '編輯提供者',\n    providerType: '提供者類型',\n    testNotification: '測試通知',\n    testSuccess: '測試通知傳送成功',\n    testFailed: '傳送測試通知失敗',\n    quietHours: '免打擾時間',\n    quietHoursDescription: '在此時間段內不傳送通知',\n    quietHoursStart: '開始',\n    quietHoursEnd: '結束',\n    events: {\n      title: '通知事件',\n      printStart: '列印開始',\n      printComplete: '列印完成',\n      printFailed: '列印失敗',\n      printStopped: '列印停止',\n      printProgress: '進度里程碑',\n      printProgressDescription: '在 25%、50%、75% 時通知',\n      printerOffline: '印表機離線',\n      printerError: '印表機錯誤',\n      filamentLow: '耗材不足',\n      maintenanceDue: '維護到期',\n      maintenanceDueDescription: '需要維護時通知',\n    },\n    // Smart Plugs\n    smartPlug: {\n      title: '智慧插座',\n      add: '新增智慧插座',\n      edit: '編輯智慧插座',\n      name: '名稱',\n      ipAddress: 'IP 位址',\n      linkedPrinter: '連結印表機',\n      autoOn: '自動開啟',\n      autoOnDescription: '列印開始時開啟',\n      autoOff: '自動關閉',\n      autoOffDescription: '列印完成後關閉',\n      offDelay: '關閉延遲',\n      offDelayMinutes: '列印後分鐘數',\n      offDelayTemp: '當噴嘴溫度低於',\n      currentState: '目前狀態',\n      turnOn: '開啟',\n      turnOff: '關閉',\n    },\n    // Filament Tracking Mode\n    filamentTracking: '耗材追蹤',\n    filamentTrackingDesc: '選擇如何追蹤您的耗材。您可以使用內建庫存或連線外部 Spoolman 伺服器。',\n    filamentChecks: '耗材檢查',\n    disableFilamentWarnings: '停用耗材警告',\n    disableFilamentWarningsDesc: '在列印或加入佇列時不顯示耗材不足警告',\n    preferLowestFilament: '優先使用剩餘最少的耗材',\n    preferLowestFilamentDesc: '當多個料盤匹配時，使用剩餘耗材最少的那個',\n    trackingModeBuiltIn: '內建庫存',\n    trackingModeBuiltInDesc: '包含 RFID 自動匹配和用量追蹤',\n    trackingModeSpoolmanDesc: '外部耗材管理伺服器',\n    builtInFeatureRfid: '自動檢測 AMS 中的拓竹 RFID 耗材',\n    builtInFeatureUsage: '追蹤每次列印的耗材消耗',\n    builtInFeatureCatalog: '管理耗材、顏色和 K 值設定檔案',\n    builtInFeatureThirdParty: '第三方耗材可分配到庫存耗材',\n    amsSyncButton: '從 AMS 同步重量',\n    amsSyncTitle: '從 AMS 同步耗材重量',\n    amsSyncMessage: '這將使用已連線印表機的目前 AMS 剩餘百分比值覆蓋所有庫存耗材重量。用於從損壞的重量資料中恢復。印表機必須線上。',\n    amsSyncing: '同步中...',\n    amsSyncSuccess: '已同步 {{synced}} 個耗材，跳過 {{skipped}} 個',\n    amsSyncError: '從 AMS 同步重量失敗',\n    // Spoolman settings\n    spoolmanUrl: 'Spoolman URL',\n    spoolmanUrlHint: 'Spoolman 伺服器的 URL（例如 http://localhost:7912）',\n    spoolmanConnected: '已連線',\n    spoolmanDisconnected: '未連線',\n    status: '狀態',\n    connect: '連線',\n    disconnect: '斷開',\n    howSyncWorks: '同步工作原理',\n    syncInfoRfidOnly: '僅同步帶有 RFID 的官方拓竹耗材',\n    syncInfoAutoCreate: '首次同步時自動在 Spoolman 中建立新耗材',\n    syncInfoThirdPartySkipped: '非拓竹耗材（第三方、重新填充的）將被跳過',\n    linkingExistingSpools: '連結現有耗材',\n    linkingExistingSpoolsDesc: '要將現有的 Spoolman 耗材連結到您的 AMS，請將滑鼠懸停在 AMS 槽位上並點選\"連結到 Spoolman\"。',\n    syncMode: '同步模式',\n    syncModeAuto: '自動',\n    syncModeManual: '僅手動',\n    syncModeAutoDesc: '偵測到更改時自動同步 AMS 資料',\n    syncModeManualDesc: '僅在手動觸發時同步',\n    syncAmsData: '同步 AMS 資料',\n    syncAmsDataDesc: '手動將印表機 AMS 資料同步到 Spoolman',\n    allPrinters: '所有印表機',\n    // Default printer\n    noDefaultPrinter: '無預設（每次詢問）',\n    // Sidebar\n    sidebarOrder: '側邊欄順序',\n    // Camera\n    saveThumbnails: '儲存縮圖',\n    captureFinishPhoto: '拍攝完成照片',\n    noPrintersConfigured: '未設定印表機',\n    // Archive settings\n    archiveMode: {\n      always: '始終建立歸檔條目',\n      never: '從不建立歸檔條目',\n      ask: '每次詢問',\n    },\n    // Updates\n    checkForUpdatesLabel: '檢查更新',\n    checkPrinterFirmware: '檢查印表機韌體',\n    includeBetaUpdates: '包含測試版本',\n    includeBetaUpdatesDesc: '檢查更新時通知測試版和預發布版本',\n    // Queue\n    enableRetry: '啟用重試',\n    // Home Assistant\n    homeAssistantDescription: '透過 Home Assistant 控制智慧插座',\n    environmentManagedLabel: '（環境變數管理）',\n    autoEnabledViaEnv: '透過環境變數自動啟用',\n    urlFromEnvReadOnly: '值由 HA_URL 環境變數設定（只讀）',\n    tokenFromEnvReadOnly: '值由 HA_TOKEN 環境變數設定（只讀）',\n    // MQTT\n    mqttConnectedTo: '已連線到',\n    // Prometheus\n    prometheusDescription: '以 Prometheus 格式暴露印表機資料',\n    // Smart plugs empty state\n    noSmartPlugsTitle: '未設定智慧插座',\n    noSmartPlugsDescription: '新增基於 Tasmota 的智慧插座以追蹤能耗並自動化電源控制。',\n    // Notifications empty state\n    noProvidersTitle: '未設定提供者',\n    noProvidersDescription: '新增提供者以接收警報。',\n    noTemplatesAvailable: '無可用範本。重新啟動後端以載入預設範本。',\n    // API permissions\n    apiPermissionView: '檢視印表機狀態和佇列',\n    apiPermissionEdit: '新增和移除列印佇列中的項目',\n    // API keys\n    apiKeysEmptyTitle: '無 API 金鑰',\n    apiKeysEmptyDescription: '建立 API 金鑰以與外部服務整合。',\n    // Users\n    noUsersFound: '未找到使用者',\n    noGroupsFound: '未找到群組',\n    noGroupsAvailable: '無可用群組',\n    passwordsDoNotMatch: '密碼不符',\n    systemGroupWarning: '系統群組名稱不可更改',\n    // Auth disabled\n    authDisabledTitle: '身份驗證已停用',\n    authDisabledFeature1: '需要登入才能存取系統',\n    authDisabledFeature2: '建立多個使用者並基於群組的權限管理',\n    authDisabledFeature3: '使用 50+ 個細粒度權限控制存取',\n    // User deletion\n    userHasCreated: '此使用者已建立：',\n    userItemsQuestion: '您想如何處理這些項目？',\n    deleteUserConfirm: '確定要刪除此使用者嗎？',\n    actionCannotBeUndone: '此操作無法復原。',\n    // Smart plugs\n    addFirstSmartPlug: '新增您的第一個智慧插座',\n    // Notifications\n    providers: '提供者',\n    log: '日誌',\n    testAll: '全部測試',\n    testResults: '測試結果',\n    testPassedCount: '{{count}} 個透過',\n    testFailedCount: '{{count}} 個失敗',\n    messageTemplates: '訊息範本',\n    messageTemplatesDescription: '自訂每個事件的通知訊息。',\n    // API Keys section\n    apiKeys: 'API 金鑰',\n    apiKeysDescription: '建立 API 金鑰用於外部整合和 Webhook。',\n    createKey: '建立金鑰',\n    apiKeyCreated: 'API 金鑰建立成功',\n    apiKeyCopyWarning: '請立即複製此金鑰 - 它不會再次顯示！',\n    useInApiBrowser: '在 API 瀏覽器中使用',\n    createNewApiKey: '建立新 API 金鑰',\n    keyName: '金鑰名稱',\n    keyNamePlaceholder: '例如：Home Assistant、OctoPrint',\n    readStatus: '讀取狀態',\n    readStatusDescription: '檢視印表機狀態和佇列',\n    manageQueue: '管理佇列',\n    manageQueueDescription: '新增和移除列印佇列中的項目',\n    controlPrinter: '控制印表機',\n    controlPrinterDescription: '暫停、繼續和停止列印',\n    unnamedKey: '未命名金鑰',\n    lastUsed: '上次使用',\n    read: '讀取',\n    control: '控制',\n    createFirstKey: '建立您的第一個金鑰',\n    webhookEndpoints: 'Webhook 端點',\n    webhookApiKeyHint: '在 X-API-Key 請求頭中使用您的 API 金鑰。',\n    webhook: {\n      getAllStatus: '獲取所有印表機狀態',\n      getSpecificStatus: '獲取特定印表機狀態',\n      addToQueue: '新增到列印佇列',\n      pausePrint: '暫停列印',\n      resumePrint: '繼續列印',\n      stopPrint: '停止列印',\n    },\n    apiBrowser: 'API 瀏覽器',\n    apiBrowserDescription: '瀏覽和測試所有可用的 API 端點。',\n    apiKeyForTesting: '測試用 API 金鑰',\n    apiKeyPlaceholder: '在此貼上您的 API 金鑰以測試需要認證的端點...',\n    apiKeyHint: '此金鑰將作為 X-API-Key 請求頭隨請求傳送。',\n    deleteApiKeyTitle: '刪除 API 金鑰',\n    deleteApiKeyMessage: '確定要刪除此 API 金鑰嗎？使用此金鑰的所有整合將停止工作。',\n    deleteKey: '刪除金鑰',\n    // Filament tab\n    amsDisplayThresholds: 'AMS 顯示閾值',\n    amsThresholdsDescription: '設定 AMS 濕度和溫度指示器的顏色閾值。',\n    humidity: '濕度',\n    goodGreen: '良好（綠色）',\n    fairOrange: '一般（橙色）',\n    aboveFairBad: '超過一般閾值顯示為紅色（差）',\n    fairAlsoDryingThreshold: '此閾值也用於觸發自動乾燥',\n    temperature: '溫度',\n    goodBlue: '良好（藍色）',\n    aboveFairHot: '超過一般閾值顯示為紅色（熱）',\n    historyRetention: '歷史保留',\n    keepSensorHistory: '保留感測器歷史',\n    historyRetentionDescription: '較舊的濕度和溫度資料將被自動刪除',\n    defaultPrintOptions: '預設列印選項',\n    defaultPrintOptionsDescription: '設定新列印的預設選項值。可在列印對話方塊中逐次覆蓋。',\n    defaultBedLevelling: '熱床調平',\n    defaultBedLevellingDesc: '列印前自動調平熱床',\n    defaultFlowCali: '流量校準',\n    defaultFlowCaliDesc: '校準擠出流量',\n    defaultVibrationCali: '振動校準',\n    defaultVibrationCaliDesc: '減少振紋偽影',\n    defaultLayerInspect: '首層檢測',\n    defaultLayerInspectDesc: 'AI首層檢測',\n    defaultTimelapse: '縮時攝影',\n    defaultTimelapseDesc: '錄製縮時攝影影片',\n    staggeredStart: '錯開啟動',\n    staggeredStartDescription: '多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。',\n    plateClear: '熱床清空確認',\n    requirePlateClear: '需要熱床清空確認',\n    requirePlateClearDescription: '啟用後，排程器會在已完成列印的印表機上啟動佇列列印之前，等待每臺印表機的熱床清空確認。停用後，也會隱藏印表機卡片上的列印板狀態標記和「將列印板標記為已清理」按鈕。',\n    gcodeInjection: 'G-code注入',\n    gcodeInjectionDescription: '為Farmloop、SwapMod、AutoClear和Printflow 3D等自動列印系統設定自訂G-code，在列印開始和/或結束時注入。程式碼片段按印表機型號設定，在佇列項目上啟用\"注入G-code\"時套用。',\n    gcodeInjectionNoPrinters: '未找到印表機。新增印表機以設定G-code程式碼片段。',\n    gcodeStartLabel: '開始G-code',\n    gcodeEndLabel: '結束G-code',\n    gcodeStartPlaceholder: '在列印開始前插入的G-code...',\n    gcodeEndPlaceholder: '在列印結束後追加的G-code...',\n    staggerGroupSize: '群組大小',\n    staggerGroupSizeHelp: '每個群組要同時啟動的印表機數量',\n    staggerInterval: '間隔（分鐘）',\n    staggerIntervalHelp: '每個群組啟動之間的延遲',\n    queueDrying: '自動乾燥',\n    queueDryingDescription: '在佇列列印之間，印表機空閒時自動乾燥AMS耗材。使用上方的濕度閾值觸發乾燥。',\n    queueDryingEnabled: '啟用自動乾燥',\n    queueDryingEnabledDescription: '當印表機空閒且濕度超過閾值時，自動啟動AMS乾燥',\n    queueDryingBlock: '等待乾燥完成',\n    queueDryingBlockDescription: '阻止列印佇列直到乾燥完成。關閉時，列印優先於乾燥。',\n    ambientDryingEnabled: '環境乾燥',\n    ambientDryingEnabledDescription: '當空閒印表機的濕度超過閾值時自動乾燥耗材，無需佇列列印。',\n    dryingPresets: '乾燥預設',\n    dryingPresetsDescription: '每種耗材類型的溫度和時長。AMS 2 Pro使用較低溫度，AMS-HT支援較高溫度。',\n    dryingFilament: '耗材',\n    printModal: '列印對話方塊',\n    expandCustomMapping: '預設展開自訂對應',\n    expandCustomMappingDescription: '列印到多臺印表機時，預設展開顯示每臺印表機的 AMS 對應',\n    // User management\n    authentication: '身份驗證',\n    authEnabledDescription: '您的實例已透過使用者身份驗證保護',\n    authDisabledDescription: '啟用以要求登入並管理使用者存取',\n    authDisabledMessage: '啟用身份驗證以建立使用者帳戶、管理權限並保護您的 Bambuddy 實例。',\n    enableAuthentication: '啟用身份驗證',\n    currentUser: '目前使用者',\n    changePassword: '修改密碼',\n    admin: '管理員',\n    users: '使用者',\n    addUser: '新增使用者',\n    groups: '群組',\n    addGroup: '新增群組',\n    system: '系統',\n    noDescription: '無描述',\n    userCount: '{{count}} 個使用者',\n    permissionCount: '{{count}} 個權限',\n    createUser: '建立使用者',\n    username: '使用者名稱',\n    enterUsername: '輸入使用者名稱',\n    password: '密碼',\n    enterPassword: '輸入密碼（至少 6 個字元）',\n    confirmPassword: '確認密碼',\n    confirmPasswordPlaceholder: '確認密碼',\n    // Title tooltips\n    viewReleaseOnGitHub: '在 GitHub 上檢視發布',\n    turnAllPlugsOn: '開啟所有插座',\n    turnAllPlugsOff: '關閉所有插座',\n    // Modal: Clear logs\n    clearNotificationLogs: '清除通知日誌',\n    clearLogsMessage: '這將永久刪除所有 30 天前的通知日誌。此操作無法復原。',\n    clearLogs: '清除日誌',\n    // Modal: Reset UI\n    resetUiPreferences: '重設 UI 偏好',\n    resetUiPreferencesMessage: '這將重設所有 UI 偏好為預設值：側邊欄順序、主題、儀表板佈局、檢視模式和排序偏好。您的印表機、歸檔和伺服器設定不會受到影響。清除後頁面將重新載入。',\n    resetPreferences: '重設偏好',\n    // Modal: Delete group\n    deleteGroupTitle: '刪除群組',\n    deleteGroupMessage: '確定要刪除此群組嗎？此群組中的使用者將失去這些權限。',\n    deleteGroup: '刪除群組',\n    // Modal: Disable auth\n    disableAuthenticationTitle: '停用身份驗證',\n    disableAuthenticationMessage: '確定要停用身份驗證嗎？這將使您的 Bambuddy 實例無需登入即可存取。所有使用者將保留在資料庫中但身份驗證將被停用。',\n    disableAuthentication: '停用身份驗證',\n    // Additional settings\n    configureBambuddy: '設定 Bambuddy',\n    systemDefault: '系統預設',\n    archiveSettings: '歸檔設定',\n    newWindow: '新視窗',\n    embeddedOverlay: '嵌入式疊加層',\n    preferredSlicer: '首選切片軟體',\n    preferredSlicerDescription: '選擇要用於開啟檔案的切片軟體',\n    externalCameras: '外部攝影機',\n    costTracking: '成本追蹤',\n    printsOnly: '僅列印',\n    totalConsumption: '總消耗',\n    dataManagement: '資料管理',\n    storageUsage: '儲存使用情況',\n    storageUsageDescription: '按類別的資料使用情況明細',\n    storageUsageTotal: '總計',\n    storageUsageErrors: '錯誤',\n    storageUsageOtherBreakdown: '其他（包括靜態資源、腳本和設定檔案）',\n    storageUsageSystem: '系統',\n    storageUsageData: '資料',\n    storageUsageUnavailable: '儲存使用資訊不可用',\n    clearNotificationLogsDescription: '刪除 30 天前的通知日誌',\n    resetUiPreferencesDescription: '重設側邊欄順序、主題、檢視模式和佈局偏好。印表機、歸檔和設定不受影響。',\n    enableHomeAssistant: '啟用 Home Assistant',\n    enableMqtt: '啟用 MQTT',\n    useTls: '使用 TLS',\n    enableMetricsEndpoint: '啟用指標端點',\n    availableMetrics: '可用指標',\n    editUser: '編輯使用者',\n    deleteUserTitle: '刪除使用者',\n    groupName: '群組名稱',\n    // Placeholders\n    leaveEmptyForAnonymous: '留空為匿名',\n    leaveEmptyForNoAuth: '留空為無認證',\n    enterNewPassword: '輸入新密碼',\n    confirmNewPassword: '確認新密碼',\n    enterGroupName: '輸入群組名稱',\n    enterDescriptionOptional: '輸入描述（可選）',\n    enterCurrentPassword: '輸入目前密碼',\n    enterNewPasswordMin6: '輸入新密碼（至少 6 個字元）',\n    toast: {\n      keyCopied: '金鑰已複製到剪貼簿',\n      copyFailed: '複製金鑰失敗',\n      keyAddedToBrowser: '金鑰已新增到 API 瀏覽器',\n      clearLogsFailed: '清除日誌失敗',\n      uiPreferencesReset: 'UI 偏好已重設。重新整理中...',\n      authDisabled: '身份驗證已成功停用',\n      authDisableFailed: '停用身份驗證失敗',\n      apiKeyCreated: 'API 金鑰已建立',\n      apiKeyDeleted: 'API 金鑰已刪除',\n      userCreated: '使用者建立成功',\n      userUpdated: '使用者更新成功',\n      userDeleted: '使用者刪除成功',\n      groupCreated: '群組建立成功',\n      groupUpdated: '群組更新成功',\n      groupDeleted: '群組刪除成功',\n      fillRequiredFields: '請填寫所有必填欄位',\n      passwordsDoNotMatch: '密碼不符',\n      passwordTooShort: '密碼至少需要 6 個字元',\n      enterGroupName: '請輸入群組名稱',\n      settingsSaved: '設定已儲存',\n      cameraSettingsSaved: '攝影機設定已儲存',\n      enterCameraUrl: '請輸入攝影機 URL',\n      passwordChanged: '密碼修改成功',\n      connectionFailed: '連線失敗',\n      testFailed: '測試失敗',\n      cameraConnected: '攝影機已連線{{resolution}}',\n    },\n    testConnection: '測試連線',\n    catalog: {\n      spoolCatalog: '耗材目錄',\n      spoolCatalogDescription: '按品牌/類型的空耗材重量。用於新增耗材時的自動重量查詢。',\n      searchCatalog: '搜尋目錄...',\n      addNewEntry: '新增新條目',\n      namePlaceholder: '名稱（例如：Bambu Lab - 塑膠）',\n      weight: '重量',\n      type: '類型',\n      default: '預設',\n      custom: '自訂',\n      noMatch: '沒有條目匹配您的搜尋',\n      empty: '目錄中沒有條目',\n      deleteEntry: '刪除條目',\n      deleteConfirm: '確定要刪除\"{{name}}\"嗎？',\n      resetCatalog: '重設目錄',\n      resetConfirm: '重設目錄為預設值？這將移除所有自訂條目。',\n      loadFailed: '載入耗材目錄失敗',\n      nameWeightRequired: '名稱和重量為必填項',\n      entryAdded: '條目已新增',\n      addFailed: '新增條目失敗',\n      entryUpdated: '條目已更新',\n      updateFailed: '更新條目失敗',\n      entryDeleted: '條目已刪除',\n      deleteFailed: '刪除條目失敗',\n      resetSuccess: '目錄已重設為預設值',\n      resetFailed: '重設目錄失敗',\n      exported: '已匯出 {{count}} 條',\n      imported: '已匯入 {{added}} 條（跳過 {{skipped}} 條）',\n      importFailed: '匯入失敗：無效的 JSON 格式',\n      exportTooltip: '匯出目錄為 JSON',\n      importTooltip: '從 JSON 匯入目錄',\n      resetTooltip: '重設為預設值',\n      selectedCount: '已選擇 {{count}} 項',\n      deleteSelected: '刪除所選',\n      bulkDeleteConfirm: '確定要刪除 {{count}} 個條目嗎？',\n      bulkDeleted: '已刪除 {{count}} 個條目',\n      bulkDeleteFailed: '刪除條目失敗',\n    },\n    colorCatalog: {\n      title: '顏色目錄',\n      description: '按製造商/材料的耗材顏色。用於新增耗材時的自動顏色查詢。',\n      searchColors: '搜尋顏色...',\n      allManufacturers: '所有製造商',\n      addNewColor: '新增新顏色',\n      manufacturer: '製造商',\n      colorName: '顏色名稱',\n      hex: '十六進位',\n      materialOptional: '材料（可選）',\n      showing: '顯示 {{filtered}} / {{total}} 種顏色',\n      noMatch: '沒有顏色匹配您的搜尋',\n      empty: '目錄中沒有顏色',\n      deleteColor: '刪除顏色',\n      deleteConfirm: '確定要刪除\"{{name}}\"嗎？',\n      resetCatalog: '重設顏色目錄',\n      resetConfirm: '重設目錄為預設值？這將移除所有自訂顏色。',\n      sync: '同步',\n      starting: '啟動中...',\n      syncTooltip: '從 FilamentColors.xyz 同步（2000+ 種顏色，可能需要一分鐘）',\n      loadFailed: '載入顏色目錄失敗',\n      fieldsRequired: '製造商、顏色名稱和十六進位顏色為必填項',\n      colorAdded: '顏色已新增',\n      addFailed: '新增顏色失敗',\n      colorUpdated: '顏色已更新',\n      updateFailed: '更新顏色失敗',\n      colorDeleted: '顏色已刪除',\n      deleteFailed: '刪除顏色失敗',\n      resetSuccess: '顏色目錄已重設為預設值',\n      resetFailed: '重設目錄失敗',\n      syncUpToDate: '已是最新（檢查了 {{count}} 種顏色）',\n      syncComplete: '新增了 {{added}} 種新顏色（{{skipped}} 種已存在）',\n      syncError: '同步錯誤',\n      syncFailed: '從 FilamentColors.xyz 同步失敗',\n      exported: '已匯出 {{count}} 種顏色',\n      imported: '已匯入 {{added}} 種顏色（跳過 {{skipped}} 種）',\n      importFailed: '匯入失敗：無效的 JSON 格式',\n      selectedCount: '已選擇 {{count}} 項',\n      deleteSelected: '刪除所選',\n      bulkDeleteConfirm: '確定要刪除 {{count}} 種顏色嗎？',\n      bulkDeleted: '已刪除 {{count}} 種顏色',\n      bulkDeleteFailed: '刪除顏色失敗',\n    },\n    dateFormat: '日期格式',\n    dateFormatUs: '美式 (MM/DD/YYYY)',\n    dateFormatEu: '歐式 (DD/MM/YYYY)',\n    dateFormatIso: 'ISO (YYYY-MM-DD)',\n    timeFormat: '時間格式',\n    timeFormat12: '12小時制 (3:30 PM)',\n    timeFormat24: '24小時制 (15:30)',\n    defaultPrinter: '預設印表機',\n    defaultPrinterDescription: '為上傳、重印和其他操作預選此印表機。',\n    slicerBambuStudio: 'Bambu Studio',\n    slicerOrcaSlicer: 'OrcaSlicer',\n    sidebarOrderDescription: '拖曳側邊欄項目以重新排序。在此處重設為預設順序。',\n    setDefault: '設為預設',\n    sidebarOrderSetDefaultHint: '設為預設將目前選單順序套用於尚未自訂的使用者。',\n    sidebarDefaultSet: '已設定預設選單順序。',\n    sidebarDefaultCleared: '已清除預設選單順序。',\n    sidebarDefaultFailed: '設定預設選單順序失敗。',\n    reset: '重設',\n    darkMode: '深色模式',\n    lightMode: '淺色模式',\n    active: '(目前)',\n    background: '背景',\n    accent: '強調色',\n    style: '樣式',\n    bgNeutral: '中性',\n    bgWarm: '暖色',\n    bgCool: '冷色',\n    bgOled: 'OLED 純黑',\n    bgSlate: '石板藍',\n    bgForest: '森林綠',\n    accentGreen: '綠色',\n    accentTeal: '青色',\n    accentBlue: '藍色',\n    accentOrange: '橙色',\n    accentPurple: '紫色',\n    accentRed: '紅色',\n    styleClassic: '經典',\n    styleGlow: '發光',\n    styleVibrant: '鮮豔',\n    themeToggleHint: '使用側邊欄中的太陽/月亮圖示在深色和淺色模式之間切換。',\n    autoArchivePrints: '自動歸檔列印',\n    autoArchiveDescription: '列印完成時自動儲存3MF檔案',\n    saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',\n    captureFinishPhotoDescription: '列印完成時從印表機攝影機拍照',\n    ffmpegNotInstalled: '未安裝ffmpeg',\n    ffmpegRequired: '攝影機捕獲需要ffmpeg。透過 <brew>brew install ffmpeg</brew>（macOS）或 <apt>apt install ffmpeg</apt>（Linux）安裝。',\n    camera: '攝影機',\n    cameraViewMode: '攝影機檢視模式',\n    cameraOverlayDescription: '攝影機在主螢幕上以可調大小的覆蓋層開啟',\n    cameraWindowDescription: '攝影機在單獨的瀏覽器視窗中開啟',\n    externalCamerasDescription: '設定外部攝影機以替換內建印表機攝影機。支援MJPEG流、RTSP、HTTP快照和USB攝影機（V4L2）。啟用後，外部攝影機將用於即時檢視和完成照片。',\n    cameraPlaceholderUsb: '裝置路徑 (/dev/video0)',\n    cameraPlaceholderUrl: '攝影機URL (rtsp://... 或 http://...)',\n    cameraTypeMjpeg: 'MJPEG 流',\n    cameraTypeRtsp: 'RTSP 流',\n    cameraTypeSnapshot: 'HTTP 快照',\n    cameraTypeUsb: 'USB 攝影機 (V4L2)',\n    cameraRotation: '旋轉',\n    test: '測試',\n    connected: '已連線',\n    disconnected: '未連線',\n    currency: '貨幣',\n    defaultFilamentCost: '預設耗材成本（每公斤）',\n    electricityCost: '每千瓦時電費',\n    energyDisplayMode: '能源顯示模式',\n    energyModePrintDescription: '儀表板顯示列印期間使用的能源總和',\n    energyModeTotalDescription: '儀表板顯示智慧插座的累計能源',\n    fileManager: '檔案管理器',\n    createArchiveEntry: '列印時建立歸檔條目',\n    createArchiveEntryDescription: '從檔案管理器列印時，可選擇建立歸檔條目',\n    lowDiskSpaceWarning: '磁碟空間不足警告',\n    lowDiskSpaceDescription: '當可用磁碟空間低於此閾值時顯示警告',\n    printerFirmware: '印表機韌體',\n    checkFirmwareDescription: '檢查Bambu Lab的印表機韌體更新',\n    bambuddySoftware: 'Bambuddy 軟體',\n    autoCheckDescription: '啟動時自動檢查新版本',\n    checkNow: '立即檢查',\n    updateAvailableVersion: '可用更新：v{{version}}',\n    releaseNotes: '發布說明',\n    updateViaDocker: '透過 Docker Compose 更新：',\n    installUpdate: '安裝更新',\n    latestVersionRunning: '您正在執行最新版本',\n    failedToCheckUpdates: '檢查更新失敗：{{error}}',\n    backupRestore: '備份與恢復',\n    backupRestoreDescription: '匯出/匯入設定並設定GitHub 備份',\n    goToBackup: '前往備份',\n    externalUrl: '外部URL',\n    externalUrlDescription: 'Bambuddy可存取的外部URL。用於通知影像和外部整合。',\n    bambuddyUrl: 'Bambuddy URL',\n    externalUrlHint: '包含協定和連接埠（例如：http://192.168.1.100:8000）',\n    ftpRetry: 'FTP重試',\n    ftpRetryDescription: '當印表機Wi-Fi 不穩定時重試FTP操作。適用於3MF下載、列印上傳、縮時攝影下載和韌體更新。',\n    autoRetryDescription: '自動重試失敗的FTP操作',\n    retryAttempts: '重試次數',\n    retryDelay: '重試延遲',\n    connectionTimeout: '連線超時',\n    time_one: '{{count}} 次',\n    time_other: '{{count}} 次',\n    second_one: '{{count}} 秒',\n    second_other: '{{count}} 秒',\n    nSeconds: '{{count}} 秒',\n    increaseForWeakWifi: '對Wi-Fi 訊號弱的印表機增加此值',\n    homeAssistant: 'Home Assistant',\n    homeAssistantFullDescription: '連線到Home Assistant，透過HA REST API控制智慧插座。支援switch、light、input_boolean和script實體。',\n    homeAssistantUrl: 'Home Assistant URL',\n    longLivedAccessToken: '長期存取權杖',\n    haTokenHint: '在HA中建立權杖：個人資料 → 長期存取權杖 → 建立權杖',\n    connectionSuccessful: '連線成功',\n    connectionFailed: '連線失敗',\n    haConnectionSuccess: '已成功連線到Home Assistant。',\n    haConnectionFailed: '連線Home Assistant失敗。',\n    mqttPublishing: 'MQTT發布',\n    mqttDescription: '將BamBuddy事件發布到外部MQTT代理，用於與Node-RED、Home Assistant和其他自動化系統整合。',\n    mqttEnableDescription: '向外部MQTT代理發布事件',\n    brokerHostname: '代理主機名稱',\n    port: '連接埠',\n    usernameOptional: '使用者名稱（可選）',\n    passwordOptional: '密碼（可選）',\n    topicPrefix: '主題前綴',\n    topicPrefixHint: '主題格式：{{prefix}}/printers/<serial>/status 等',\n    prometheusMetrics: 'Prometheus 指標',\n    prometheusEndpointDescription: '在 <code>/api/v1/metrics</code> 公開印表機指標，用於Prometheus/Grafana監控。',\n    bearerTokenOptional: 'Bearer權杖（可選）',\n    bearerTokenHint: '設定後，請求必須包含 <code>Authorization: Bearer <token></code>',\n    metricsConnectionStatus: '連線狀態',\n    metricsPrinterState: '印表機狀態（空閒/列印中等）',\n    metricsPrintProgress: '列印進度 0-100%',\n    metricsBedTemp: '熱床溫度',\n    metricsNozzleTemp: '噴嘴溫度',\n    metricsPrintsTotal: '按結果分類的總列印數',\n    metricsMore: '...以及更多（層數、風扇、佇列、耗材用量）',\n    smartPlugsDescription: '連線智慧插座（Tasmota或Home Assistant）以自動化電源控制並追蹤印表機的能源使用情況。',\n    allOn: '全部開啟',\n    allOff: '全部關閉',\n    addSmartPlug: '新增智慧插座',\n    energySummary: '能源概要',\n    currentPower: '目前功率',\n    plugsOnline: '{{reachable}}/{{total}} 個插座線上',\n    today: '今天',\n    yesterday: '昨天',\n    total: '總計',\n    enablePlugsForSummary: '啟用插座以檢視能源概要',\n    addNotificationProvider: '新增',\n    systemBadge: '(系統)',\n    creating: '建立中...',\n    changing: '修改中...',\n    deleteUserAndItems: '刪除使用者及其所有項目',\n    deleteUserKeepItems: '刪除使用者，保留項目（將變為無主項目）',\n    ok: '確定',\n\n    // 2FA settings\n    twoFa: {\n      totpTitle: '身份驗證器 App (TOTP)',\n      totpDesc: '使用 Google Authenticator、Aegis 或 Authy 等 App。',\n      emailOtpTitle: '郵件 OTP',\n      emailOtpDesc: '登入時向 {{email}} 傳送一次性驗證碼。',\n      emailOtpNoEmail: '請先為帳戶新增信箱地址以啟用此方式。',\n      addEmailFirst: '您的帳戶沒有信箱地址，請聯絡管理員新增。',\n      setupTotp: '設定身份驗證器 App',\n      setupAuthApp: '設定身份驗證器 App',\n      setupInstructions: '使用身份驗證器 App 掃描QR Code，然後輸入驗證碼確認。',\n      manualEntry: '無法掃描？請手動輸入此金鑰：',\n      scannedContinue: '已掃描 — 繼續',\n      enterCodeToConfirm: '請輸入身份驗證器 App 中的6位驗證碼以確認設定。',\n      activate: '啟用',\n      disableTotp: '停用身份驗證器',\n      disableConfirmHint: '請輸入有效的 TOTP 碼或備用碼來停用身份驗證器。',\n      totpDisabled: '身份驗證器 App 已停用。',\n      emailOtpEnabled: '郵件 OTP 已啟用。',\n      emailOtpDisabled: '郵件 OTP 已停用。',\n      smtpRequired: '請先設定並測試SMTP設定。',\n      invalidCode: '無效驗證碼，請重試。',\n      enableEmailOtp: '啟用郵件 OTP',\n      disableEmailOtp: '停用郵件 OTP',\n      emailSetupEnterCode: '驗證碼已傳送至您的信箱地址。請在下方輸入以確認您擁有此信箱。',\n      verifyAndEnable: '驗證並啟用',\n      emailDisablePasswordHint: '請輸入您的帳戶密碼以確認停用郵件 OTP。',\n      passwordPlaceholder: '輸入您的密碼',\n      backupCodesTitle: '儲存備用碼',\n      backupCodesWarning: '請將這些碼儲存在安全的地方。每個碼只能使用一次，且不會再次顯示。',\n      backupCodesRemaining: '剩餘 {{count}} 個備用碼',\n      savedCodes: '已儲存',\n      regenBackup: '重新產生備用碼',\n      regenBackupHint: '輸入目前 TOTP 碼以產生 10 個新備用碼，所有現有備用碼將失效。',\n      newBackupCodes: '新備用碼',\n      linkedAccounts: '已連結的 SSO 帳戶',\n      linkedAccountsDesc: '以下外部身份提供者已與您的帳戶連結。',\n      oidcUnlinked: '帳戶已解除連結。',\n    },\n\n    // OIDC provider settings\n    oidc: {\n      title: 'SSO / OIDC 提供者',\n      desc: '設定 OpenID Connect 提供者以實現單點登入。',\n      addProvider: '新增提供者',\n      newProvider: '新提供者',\n      empty: '尚未設定 OIDC 提供者。',\n      created: '提供者已建立。',\n      updated: '提供者已更新。',\n      deleted: '提供者已刪除。',\n      deleteTitle: '刪除提供者',\n      deleteMessage: '刪除\"{{name}}\"？所有連結帳戶將斷開連線。',\n      form: {\n        name: '顯示名稱',\n        issuerUrl: '頒發者 URL',\n        clientId: '客戶端 ID',\n        clientSecret: '客戶端金鑰',\n        scopes: '作用域',\n        iconUrl: '圖示 URL（可選）',\n        enabled: '已啟用',\n        autoCreate: '自動建立使用者',\n        autoCreateDesc: '首次登入時自動建立本機帳戶。',\n        autoLink: '自動連結已有帳戶',\n        autoLinkDesc: '首次登入時透過信箱匹配現有本機帳戶並自動連結。',\n        secretHint: '留空以保留目前',\n        secretPlaceholder: '新金鑰',\n      },\n    },\n\n  },\n\n  // Notifications (for push notifications)\n  notification: {\n    printStarted: {\n      title: '列印已開始',\n      body: '{{printer}}：{{filename}} 已開始列印',\n    },\n    printCompleted: {\n      title: '列印已完成',\n      body: '{{printer}}：{{filename}} 已成功完成',\n    },\n    printFailed: {\n      title: '列印失敗',\n      body: '{{printer}}：{{filename}} 列印失敗',\n    },\n    printStopped: {\n      title: '列印已停止',\n      body: '{{printer}}：{{filename}} 已停止',\n    },\n    printProgress: {\n      title: '列印進度',\n      body: '{{printer}}：{{filename}} 已完成 {{percent}}%',\n    },\n    printerOffline: {\n      title: '印表機離線',\n      body: '{{printer}} 已離線',\n    },\n    printerError: {\n      title: '印表機錯誤',\n      body: '{{printer}}：{{error}}',\n    },\n    filamentLow: {\n      title: '耗材不足',\n      body: '{{printer}}：耗材即將用完',\n    },\n    maintenanceDue: {\n      title: '維護到期',\n      body: '{{printer}}：{{items}} 需要關注',\n    },\n  },\n\n  // Errors\n  errors: {\n    generic: '出了點問題',\n    networkError: '網路錯誤。請檢查您的連線。',\n    notFound: '未找到',\n    unauthorized: '未授權',\n    serverError: '伺服器錯誤',\n    validationError: '請檢查您的輸入',\n    printerConnectionFailed: '連線印表機失敗',\n    saveFailed: '儲存更改失敗',\n    deleteFailed: '刪除失敗',\n    loadFailed: '載入資料失敗',\n  },\n\n  // HMS Errors modal\n  hmsErrors: {\n    title: '錯誤 - {{name}}',\n    noErrors: '無錯誤',\n    viewOnWiki: '在拓竹 Wiki 上檢視',\n    clearInstructions: '在印表機上清除錯誤以在此處消除它們。',\n    clearErrors: '清除錯誤',\n    clearSuccess: 'HMS 錯誤已清除',\n    clearFailed: '清除 HMS 錯誤失敗',\n  },\n\n  // MQTT Debug modal\n  mqttDebug: {\n    title: 'MQTT 偵錯日誌',\n    searchPlaceholder: '搜尋主題或負載...',\n    noMessages: '尚未紀錄訊息',\n    startLoggingHint: '點選\"開始紀錄\"以開始捕獲 MQTT 訊息',\n    noMessagesMatch: '沒有訊息匹配您的篩選條件',\n    adjustFilterHint: '嘗試調整您的搜尋或篩選條件',\n    incoming: '傳入',\n    outgoing: '傳出',\n    loggingStopped: '紀錄已停止',\n    loggingActive: '紀錄中 - 訊息將自動重新整理',\n    startLogging: '開始紀錄',\n    stopLogging: '停止紀錄',\n    clearLog: '清除日誌',\n    topic: '主題',\n    timestamp: '時間戳',\n    direction: '方向',\n    all: '全部',\n  },\n\n  // Printer File Manager modal (printer internal storage)\n  printerFiles: {\n    title: '檔案管理器',\n    storageUsed: '已用：',\n    storageFree: '剩餘：',\n    filterPlaceholder: '篩選檔案...',\n    deleteButton: '刪除',\n    deleteFiles: '刪除 {{count}} 個檔案',\n    deleteFileConfirm: '刪除\"{{name}}\"？此操作無法復原。',\n    deleteFilesConfirm: '刪除 {{count}} 個選中的檔案？此操作無法復原。',\n    noFiles: '印表機上沒有檔案',\n    loadingFiles: '載入檔案中...',\n    failedToLoad: '載入檔案失敗',\n    toast: {\n      filesDeleted: '已刪除 {{count}} 個檔案',\n      deleteFailed: '刪除失敗：{{error}}',\n    },\n  },\n\n  // Confirmations\n  confirm: {\n    delete: '確定要刪除嗎？',\n    unsavedChanges: '您有未儲存的更改。確定要離開嗎？',\n    clearQueue: '確定要清空佇列嗎？',\n  },\n\n  // Login page\n  login: {\n    title: 'Bambuddy 登入',\n    subtitle: '登入您的帳戶',\n    username: '使用者名稱',\n    usernamePlaceholder: '輸入您的使用者名稱',\n    usernameOrEmail: '使用者名稱或信箱',\n    usernameOrEmailPlaceholder: '使用者名稱或 @ 信箱',\n    password: '密碼',\n    passwordPlaceholder: '輸入您的密碼',\n    signIn: '登入',\n    signingIn: '登入中...',\n    forgotPassword: '忘記密碼？',\n    loginSuccess: '登入成功',\n    loginFailed: '登入失敗',\n    enterCredentials: '請輸入使用者名稱和密碼',\n    enterEmail: '請輸入您的電子郵件地址',\n    oidcLoginFailed: 'OIDC 登入失敗',\n    oidcErrors: {\n      providerError: '身份提供者返回了一個錯誤',\n      missingParameters: 'OIDC 回呼缺少必要引數',\n      invalidState: 'OIDC 狀態無效或已被使用',\n      stateExpired: 'OIDC 登入會話已過期，請重試',\n      providerNotFound: '未找到 OIDC 提供者',\n      discoveryFailed: '無法獲取 OIDC 探索文件',\n      invalidDiscovery: 'OIDC 探索文件無效',\n      networkError: 'OIDC 權杖交換時出現網路錯誤',\n      badResponse: 'OIDC 權杖交換時收到意外回應',\n      noIdToken: 'OIDC 提供者未返回 ID 權杖',\n      validationFailed: 'OIDC 權杖驗證失敗',\n      nonceMismatch: 'OIDC nonce 不符，可能存在重放攻擊',\n      missingSubClaim: 'OIDC 權杖缺少 sub 宣告',\n      noLinkedAccount: '沒有與此 OIDC 身份連結的本機帳戶',\n      accountInactive: '您的帳戶已被停用',\n      userResolutionFailed: '無法解析您的帳戶',\n      internalError: 'OIDC 登入過程中發生內部錯誤',\n      tokenExchangeFailed: 'OIDC 權杖交換失敗',\n    },\n    forgotPasswordTitle: '忘記密碼',\n    forgotPasswordMessage: '如果您忘記了密碼，請聯絡系統管理員進行重設。',\n    forgotPasswordEmailMessage: '輸入您的信箱地址，我們將向您傳送新密碼。',\n    emailAddress: '信箱地址',\n    emailPlaceholder: 'your.email@example.com',\n    cancel: '取消',\n    sending: '傳送中...',\n    sendResetEmail: '傳送重設郵件',\n    howToReset: '如何重設密碼：',\n    resetStep1: '聯絡您的 Bambuddy 管理員',\n    resetStep2: '請他們在使用者管理中重設您的密碼',\n    resetStep3: '他們可以為您設定一個臨時密碼',\n    resetStep4: '使用新密碼登入並在設定中修改密碼',\n    gotIt: '知道了',\n    resetPassword: {\n      title: '設定新密碼',\n      subtitle: '請在下方輸入並確認您的新密碼。',\n      newPassword: '新密碼',\n      newPasswordPlaceholder: '至少 8 個字元',\n      confirmPassword: '確認密碼',\n      confirmPasswordPlaceholder: '重複輸入新密碼',\n      saving: '儲存中…',\n      submit: '設定新密碼',\n      backToLogin: '回到登入',\n      passwordsDoNotMatch: '密碼不符',\n      passwordTooShort: '密碼至少需要 8 個字元',\n      resetFailed: '密碼重設失敗。連結可能已過期。',\n    },\n    twoFA: {\n      title: '兩步驗證',\n      subtitle: '您的帳戶已啟用兩步驗證。請在下方輸入驗證碼。',\n      methodAuthenticator: '身份驗證器 App',\n      methodEmail: '信箱驗證碼',\n      methodBackup: '備用恢復碼',\n      instructionsTotp: '請開啟您的身份驗證器 App，輸入 Bambuddy 的 6 位驗證碼。',\n      instructionsEmail: '6 位驗證碼已傳送至您的信箱，有效期為 10 分鐘。',\n      instructionsEmailNotSent: '點選下方按鈕，透過郵件獲取驗證碼。',\n      instructionsBackup: '請輸入您的一個 8 位備用恢復碼。每個恢復碼只能使用一次。',\n      sendCodeButton: '傳送信箱驗證碼',\n      sendingCode: '傳送中...',\n      resendCode: '重新傳送驗證碼',\n      codeLabel: '驗證碼',\n      backupCodeLabel: '備用恢復碼',\n      codePlaceholder: '000000',\n      backupCodePlaceholder: 'XXXXXXXX',\n      verifyButton: '驗證',\n      verifyingButton: '驗證中...',\n      backToLogin: '← 回到登入頁面',\n      orContinueWith: '或透過以下方式登入',\n      signInWith: '使用 {{provider}} 登入',\n      enterCode: '請輸入驗證碼',\n      sendCodeFailed: '驗證碼傳送失敗',\n      invalidCode: '無效驗證碼，請重試。',\n    },\n\n  },\n\n  // Setup page\n  setup: {\n    title: 'Bambuddy 設定',\n    subtitle: '為您的 Bambuddy 實例設定身份驗證',\n    enableAuth: '啟用身份驗證',\n    adminAccount: '管理員帳戶',\n    adminAccountDesc: '如果管理員使用者已存在，將使用現有管理員帳戶啟用身份驗證。如需使用現有管理員，請將下方欄位留空，或輸入新憑據建立新管理員使用者。',\n    adminUsername: '管理員使用者名稱',\n    adminPassword: '管理員密碼',\n    optionalIfAdminExists: '（如管理員使用者已存在則為可選）',\n    adminUsernamePlaceholder: '輸入管理員使用者名稱（可選）',\n    adminPasswordPlaceholder: '輸入管理員密碼（可選）',\n    confirmPassword: '確認密碼',\n    confirmPasswordPlaceholder: '確認管理員密碼',\n    settingUp: '設定中...',\n    completeSetup: '完成設定',\n    toast: {\n      authEnabledAdminCreated: '身份驗證已啟用並建立了管理員使用者',\n      authEnabledExistingAdmins: '使用現有管理員使用者啟用了身份驗證',\n      setupCompleted: '設定完成',\n      enterBothCredentials: '請輸入管理員使用者名稱和密碼，或將兩者留空以使用現有管理員使用者',\n      passwordsDoNotMatch: '密碼不符',\n      passwordTooShort: '密碼至少需要 6 個字元',\n    },\n  },\n\n  // Password change\n  changePassword: {\n    title: '修改密碼',\n    currentPassword: '目前密碼',\n    currentPasswordPlaceholder: '輸入目前密碼',\n    newPassword: '新密碼',\n    newPasswordPlaceholder: '輸入新密碼（至少 6 個字元）',\n    confirmPassword: '確認新密碼',\n    confirmPasswordPlaceholder: '確認新密碼',\n    passwordsDoNotMatch: '密碼不符',\n    passwordTooShort: '密碼至少需要 6 個字元',\n    changing: '修改中...',\n    success: '密碼修改成功',\n    failed: '密碼修改失敗',\n  },\n\n  // Plate detection alert\n  plateAlert: {\n    title: '列印已暫停！',\n    message: '在列印板上偵測到物體。列印已自動暫停。請清理列印板並繼續列印。',\n    understand: '我知道了',\n  },\n\n  // Camera page\n  camera: {\n    title: '攝影機檢視',\n    invalidPrinterId: '無效的印表機 ID',\n    live: '即時',\n    snapshot: '快照',\n    restartStream: '重新啟動流',\n    refreshSnapshot: '重新整理快照',\n    fullscreen: '全螢幕',\n    exitFullscreen: '離開全螢幕',\n    connectingToCamera: '連線攝影機中...',\n    capturingSnapshot: '拍攝快照中...',\n    connectionLost: '連線已斷開',\n    connectionFailed: '攝影機連線失敗',\n    reconnecting: '{{countdown}} 秒後重新連線...（第 {{attempt}}/{{max}} 次嘗試）',\n    reconnectNow: '立即重新連線',\n    cameraUnavailable: '攝影機不可用',\n    cameraUnavailableDesc: '請確保印表機已通電並已連線。',\n    noCamera: '無可用攝影機',\n    retry: '重試',\n    cameraStream: '攝影機流',\n    zoomOut: '縮小',\n    zoomIn: '放大',\n    resetZoom: '重設縮放',\n    recording: '錄製中',\n    startRecording: '開始錄製',\n    stopRecording: '停止錄製',\n    chamberLight: '切換腔室燈',\n  },\n\n  // Groups management\n  groups: {\n    title: '群組管理',\n    subtitle: '管理存取控制的權限群組',\n    backToSettings: '返回設定',\n    createGroup: '建立群組',\n    noPermission: '您沒有存取此頁面的權限。',\n    system: '系統',\n    noDescription: '無描述',\n    usersCount: '{{count}} 個使用者',\n    permissionsCount: '{{count}} 個權限',\n    edit: '編輯',\n    delete: '刪除',\n    toast: {\n      created: '群組建立成功',\n      updated: '群組更新成功',\n      deleted: '群組刪除成功',\n      enterGroupName: '請輸入群組名稱',\n    },\n    modal: {\n      editGroup: '編輯群組',\n      createGroup: '建立群組',\n      cancel: '取消',\n      saving: '儲存中...',\n      creating: '建立中...',\n      saveChanges: '儲存更改',\n    },\n    form: {\n      groupName: '群組名稱',\n      groupNamePlaceholder: '輸入群組名稱',\n      systemGroupWarning: '系統群組名稱不可更改',\n      description: '描述',\n      descriptionPlaceholder: '輸入描述（可選）',\n      permissions: '權限（已選 {{count}} 個）',\n    },\n    deleteModal: {\n      title: '刪除群組',\n      message: '確定要刪除此群組嗎？此群組中的使用者將失去這些權限。',\n      confirm: '刪除群組',\n    },\n    editor: {\n      title: '編輯群組',\n      createTitle: '建立群組',\n      search: '搜尋權限...',\n      selectAll: '全選',\n      clearAll: '清除全部',\n      permissionsSelected: '已選 {{count}} 個',\n      noResults: '沒有權限匹配您的搜尋',\n    },\n  },\n\n  // Users management\n  users: {\n    title: '使用者管理',\n    subtitle: '管理使用者及其對 Bambuddy 實例的存取',\n    backToSettings: '返回設定',\n    createUser: '建立使用者',\n    noPermission: '您沒有存取此頁面的權限。',\n    admin: '管理員',\n    noGroups: '無群組',\n    active: '活躍',\n    inactive: '非活躍',\n    edit: '編輯',\n    delete: '刪除',\n    system: '系統',\n    noGroupsAvailable: '無可用群組',\n    table: {\n      username: '使用者名稱',\n      groups: '群組',\n      status: '狀態',\n      actions: '操作',\n    },\n    toast: {\n      created: '使用者建立成功',\n      updated: '使用者更新成功',\n      deleted: '使用者刪除成功',\n      fillRequired: '請填寫所有必填欄位',\n      passwordsDoNotMatch: '密碼不符',\n      passwordTooShort: '密碼至少需要 6 個字元',\n    },\n    modal: {\n      createUser: '建立使用者',\n      editUser: '編輯使用者',\n      cancel: '取消',\n      creating: '建立中...',\n      saving: '儲存中...',\n      saveChanges: '儲存更改',\n      advancedAuthSubtitle: '使用進階認證',\n    },\n    form: {\n      username: '使用者名稱',\n      usernamePlaceholder: '輸入使用者名稱',\n      email: '信箱',\n      emailPlaceholder: 'user@example.com',\n      password: '密碼',\n      passwordPlaceholder: '輸入密碼',\n      confirmPassword: '確認密碼',\n      confirmPasswordPlaceholder: '確認密碼',\n      newPasswordPlaceholder: '輸入新密碼',\n      confirmNewPasswordPlaceholder: '確認新密碼',\n      leaveBlankToKeep: '留空以保持目前值',\n      groups: '群組',\n      optional: '可選',\n      autoGeneratedPassword: '將自動產生安全密碼並透過郵件傳送給使用者。',\n      passwordManagedByAdvancedAuth: '密碼由進階認證管理。使用\"重設密碼\"透過郵件向使用者傳送新密碼。',\n      resetPassword: '重設密碼',\n      resettingPassword: '重設密碼中...',\n    },\n    deleteModal: {\n      title: '刪除使用者',\n      message: '確定要刪除此使用者嗎？此操作無法復原。',\n      confirm: '刪除使用者',\n    },\n  },\n\n  // Stream overlay\n  streamOverlay: {\n    title: '流疊加層',\n    invalidPrinterId: '無效的印表機 ID',\n    cameraStream: '攝影機流',\n    progress: '進度',\n    eta: '預計完成時間',\n    printerIdle: '印表機空閒',\n    printerOffline: '印表機離線',\n    status: {\n      printing: '列印中',\n      paused: '已暫停',\n      finished: '已完成',\n      failed: '失敗',\n      idle: '空閒',\n      unknown: '未知',\n    },\n  },\n\n  // Profiles\n  profiles: {\n    title: '設定檔案',\n    subtitle: '管理您的切片預設和壓力推進校準',\n    tabs: {\n      cloud: '雲端設定檔案',\n      local: '本機設定檔案',\n      kprofiles: 'K 值設定',\n    },\n    localProfiles: {\n      title: '本機設定檔案',\n      subtitle: '從 OrcaSlicer 匯入和管理切片預設',\n      import: '匯入設定檔案',\n      importDesc: '將 .bbscfg、.bbsflmt、.orca_filament、.zip 或 .json 檔案拖放到此處',\n      importing: '匯入中...',\n      search: '搜尋本機預設...',\n      noPresets: '尚無本機預設',\n      badge: '本機',\n      edit: '編輯',\n      delete: '刪除',\n      cancel: '取消',\n      deleteConfirmTitle: '刪除預設',\n      deleteConfirm: '確定要刪除此預設嗎？此操作無法復原。',\n      source: '來源',\n      inheritsFrom: '繼承自',\n      filamentType: '類型',\n      vendor: '廠商',\n      compatiblePrinters: '相容印表機',\n      nozzleTemp: '噴嘴溫度',\n      cost: '成本',\n      density: '密度',\n      pressureAdvance: '壓力推進',\n      filament: '耗材',\n      process: '工藝',\n      printer: '印表機',\n      toast: {\n        importSuccess: '已匯入 {{count}} 個預設',\n        importSkipped: '跳過 {{count}} 個預設（重複）',\n        importError: '匯入時出現 {{count}} 個錯誤',\n        deleted: '預設已刪除',\n        updated: '預設已更新',\n      },\n    },\n    connectedAs: '已連線為',\n    logout: '登出',\n    noLogoutPermission: '您沒有登出的權限',\n    failedToLoad: '載入設定檔案失敗',\n    retry: '重試',\n    time: {\n      justNow: '剛剛',\n      minsAgo: '{{count}} 分鐘前',\n      hoursAgo: '{{count}} 小時前',\n      daysAgo: '{{count}} 天前',\n    },\n    toast: {\n      loggedOut: '已登出',\n    },\n    login: {\n      title: '連線到拓竹雲',\n      subtitle: '跨裝置同步您的切片預設',\n      email: '信箱',\n      password: '密碼',\n      region: '地區',\n      regionGlobal: '全球',\n      regionChina: '中國',\n      verificationCode: '驗證碼',\n      totpCode: '驗證器驗證碼',\n      checkEmail: '檢查您的信箱 ({{email}}) 獲取 6 位驗證碼',\n      enterTotpHint: '輸入驗證器 App 中的 6 位驗證碼',\n      accessToken: '存取權杖',\n      accessTokenHint: '貼上您的拓竹存取權杖（來自 Bambu Studio）',\n      back: '返回',\n      loginButton: '登入',\n      verifyButton: '驗證',\n      setTokenButton: '設定權杖',\n      useToken: '改用存取權杖',\n      useEmail: '改用信箱登入',\n      toast: {\n        loggedIn: '登入成功',\n        codeSent: '驗證碼已傳送到您的信箱',\n        enterTotp: '輸入驗證器 App 中的程式碼',\n        tokenSet: '權杖設定成功',\n      },\n    },\n    presets: {\n      myPreset: '我的預設（可編輯）',\n      duplicate: '複製',\n      editable: '可編輯',\n      failedToLoadDetails: '載入預設詳情失敗',\n      deleteConfirm: '刪除此預設？',\n      deleteWarning: '這將從拓竹雲中永久刪除\"{{name}}\"。此操作無法復原。',\n      noDuplicatePermission: '您沒有複製預設的權限',\n      noEditPermission: '您沒有編輯預設的權限',\n      noDeletePermission: '您沒有刪除預設的權限',\n      types: {\n        filament: '耗材預設',\n        printer: '印表機預設',\n        process: '工藝預設',\n      },\n      toast: {\n        deleted: '預設已刪除',\n        created: '預設已建立',\n        updated: '預設已更新',\n        duplicated: '預設已複製',\n        fieldAdded: '欄位\"{{key}}\"已新增',\n        exported: '預設已匯出',\n      },\n      baseLabel: '基礎：{{name}}',\n      currentLabel: '目前：{{name}}',\n      newPreset: '新增預設',\n      editPreset: '編輯預設',\n      duplicatePreset: '複製預設',\n      createNewPreset: '建立新預設',\n      customizeSettings: '自訂新預設的設定',\n      compareWithBase: '與基礎預設比較',\n      compare: '比較',\n      // CreatePresetModal - Basic Info\n      basePreset: '基礎預設',\n      selectBasePreset: '選擇基礎預設...',\n      presetName: '預設名稱',\n      myCustomPreset: '我的自訂預設',\n      inheritsFrom: '繼承自',\n      dropJsonToImport: '拖放 JSON 以匯入',\n      // CreatePresetModal - Tabs\n      tabs: {\n        common: '常用',\n        allFields: '所有欄位',\n      },\n      // CreatePresetModal - All Fields Tab\n      availableFields: '可用欄位',\n      searchFieldsPlaceholder: '搜尋欄位...',\n      noMatchingFields: '沒有匹配的欄位',\n      allFieldsAdded: '所有欄位已新增',\n      addCustomField: '新增自訂欄位',\n      yourOverrides: '您的覆蓋值',\n      noOverridesYet: '尚無覆蓋值',\n      clickFieldsToAdd: '點選左側的欄位進行新增',\n      saveAsTemplate: '儲存為範本',\n      jsonTip: '提示：將 .json 檔案拖放到此對話方塊的任意位置以匯入設定',\n    },\n    cloudView: {\n      searchPlaceholder: '搜尋預設...',\n      templates: '範本',\n      refresh: '重新整理',\n      newPreset: '新增預設',\n      clearFilters: '清除篩選',\n      // Compare mode\n      compareMode: '比較模式',\n      selectAnotherPreset: '選擇另一個 {{type}} 預設',\n      clickTwoPresets: '點選兩個相同類型的預設進行比較',\n      selectFirst: '1. 選擇第一個',\n      selectSecond: '2. 選擇第二個',\n      compareNow: '立即比較',\n      // Status row\n      lastSynced: '上次同步：',\n      showingCount: '顯示 {{showing}} / {{total}} 個預設',\n      noPresetsFound: '未找到預設',\n      // Column headers\n      columns: {\n        filament: '耗材',\n        process: '工藝',\n        printer: '印表機',\n      },\n      noFilamentPresets: '無耗材預設',\n      noProcessPresets: '無工藝預設',\n      noPrinterPresets: '無印表機預設',\n      // Filters\n      filters: {\n        type: '類型',\n        owner: '所有者',\n        printer: '印表機',\n        nozzle: '噴嘴',\n        filament: '耗材',\n        layer: '層',\n        all: '全部',\n        myPresets: '我的預設',\n        builtIn: '內建',\n        process: '工藝',\n      },\n      // Permissions\n      noTemplatesPermission: '您沒有管理範本的權限',\n      noRefreshPermission: '您沒有重新整理設定檔案的權限',\n      noCreatePermission: '您沒有建立預設的權限',\n    },\n    templates: {\n      title: '快速範本',\n      noTemplates: '尚無範本',\n      createFirst: '從預設編輯器建立範本',\n      typeFilter: '類型：',\n      deleteTitle: '刪除範本',\n      deleteWarning: '此操作無法復原',\n      deleteConfirm: '確定要刪除\"{{name}}\"嗎？',\n      namePlaceholder: '範本名稱',\n      descriptionPlaceholder: '描述',\n      settingsJson: '設定 (JSON)',\n      fieldsCount: '{{count}} 個欄位',\n      shownInModals: '在對話方塊中顯示',\n      hiddenInModals: '在對話方塊中隱藏',\n      apply: '套用',\n      toast: {\n        deleted: '範本已刪除',\n        updated: '範本已更新',\n        created: '範本已建立',\n        applied: '範本已套用',\n      },\n    },\n  },\n\n  // Support/Debug\n  support: {\n    debugLoggingActive: '偵錯日誌紀錄已啟用',\n    manageLogs: '管理',\n    collectItem7: '印表機連線和韌體版本',\n    collectItem8: '整合狀態（Spoolman、MQTT、HA）',\n    collectItem9: '網路介面（僅子網）',\n    collectItem10: 'Python 套件版本',\n    collectItem11: '資料庫健康檢查',\n    collectItem12: 'Docker 環境詳情',\n  },\n\n  // File manager\n  fileManager: {\n    title: '檔案管理器',\n    subtitle: '組織和管理您的列印檔案',\n    uploadFiles: '上傳檔案',\n    newFolder: '新增資料夾',\n    folderName: '資料夾名稱',\n    folderNamePlaceholder: '例如：功能零件',\n    renameFile: '重新命名檔案',\n    renameFolder: '重新命名資料夾',\n    moveFiles: '移動 {{count}} 個檔案',\n    rootNoFolder: '根目錄（無資料夾）',\n    current: '目前',\n    linkFolder: '連結資料夾',\n    linkFolderDescription: '將\"{{name}}\"連結到專案或歸檔以便快速存取。',\n    project: '專案',\n    archive: '歸檔',\n    noProjectsFound: '未找到專案',\n    noArchivesFound: '未找到歸檔',\n    unlink: '取消連結',\n    link: '連結',\n    dragDropFiles: '將檔案拖放到此處',\n    dropFilesHere: '將檔案放在此處',\n    orClickToBrowse: '或點選瀏覽',\n    allFileTypesSupported: '支援所有檔案類型。ZIP 檔案將被解壓。',\n    zipFilesDetected: '偵測到 ZIP 檔案',\n    zipExtractOptions: 'ZIP 檔案將被解壓。選擇如何處理資料夾結構：',\n    preserveZipStructure: '保留 ZIP 中的資料夾結構',\n    createFolderFromZip: '從 ZIP 檔名建立資料夾',\n    stlThumbnailGeneration: 'STL 縮圖產生',\n    zipMayContainStl: 'ZIP 檔案可能包含 STL 檔案。可以在解壓時產生縮圖。',\n    thumbnailsCanBeGenerated: '可以為 STL 檔案產生縮圖。大型模型可能需要更長時間處理。',\n    generateThumbnailsForStl: '為 STL 檔案產生縮圖',\n    threemfDetected: '偵測到 3MF 檔案',\n    threemfExtractionInfo: '將自動從 3MF 檔案中提取印表機型號、材料、顏色和列印設定。',\n    willBeExtracted: '將被解壓',\n    filesExtracted: '已解壓 {{count}} 個檔案',\n    uploadComplete: '上傳完成：{{succeeded}} 個成功',\n    uploadFailed: '上傳失敗',\n    zipFilesFailed: '{{count}} 個檔案失敗',\n    uploading: '上傳中...',\n    changeLink: '更改連結...',\n    linkTo: '連結到...',\n    linkToProjectOrArchive: '連結到專案或歸檔',\n    addToQueue: '新增到佇列',\n    schedulePrint: '排程',\n    generateThumbnail: '產生縮圖',\n    generateThumbnails: '產生縮圖',\n    generateThumbnailsForMissing: '為缺少縮圖的 STL 檔案產生縮圖',\n    gridView: '網格檢視',\n    listView: '列表檢視',\n    lowDiskSpaceWarning: '磁碟空間不足警告',\n    lowDiskSpaceDetails: '僅剩 {{free}}（總共 {{total}}）。閾值設定為 {{threshold}} GB。',\n    files: '檔案',\n    folders: '資料夾',\n    size: '大小',\n    free: '剩餘',\n    allFiles: '所有檔案',\n    wrap: '換行',\n    enableTextWrapping: '啟用文字換行',\n    disableTextWrapping: '停用文字換行',\n    collapse: '折疊',\n    collapseFoldersByDefault: '預設折疊資料夾',\n    expandFoldersByDefault: '預設展開資料夾',\n    dragToResizeTooltip: '拖曳調整大小，雙擊重設',\n    searchFiles: '搜尋檔案...',\n    allTypes: '所有類型',\n    prints: '列印',\n    ascending: '升序',\n    descending: '降序',\n    resultsCount: '{{showing}} / {{total}} 個檔案',\n    selectAll: '全選',\n    deselectAll: '取消全選',\n    selected: '已選擇 {{count}} 個',\n    adding: '新增中...',\n    loadingFiles: '載入檔案中...',\n    folderIsEmpty: '資料夾為空',\n    noFilesYet: '尚無檔案',\n    folderEmptyDescription: '上傳檔案或將檔案移入此資料夾以開始使用。',\n    noFilesDescription: '上傳檔案以開始組織您的列印相關檔案。',\n    noMatchingFiles: '沒有匹配的檔案',\n    noMatchingFilesDescription: '沒有檔案匹配您目前的搜尋或篩選條件。',\n    clearFilters: '清除篩選',\n    printedCount: '已列印 {{count}} 次',\n    uploadedBy: '上傳者',\n    deleteFolder: '刪除資料夾',\n    deleteFile: '刪除檔案',\n    deleteFilesCount: '刪除 {{count}} 個檔案',\n    deleteFolderConfirm: '確定要刪除此資料夾嗎？其中的所有檔案也將被刪除。',\n    deleteFileConfirm: '確定要刪除此檔案嗎？',\n    deleteFilesConfirm: '確定要刪除 {{count}} 個選中的檔案嗎？此操作無法復原。',\n    deleting: '刪除中...',\n    noPermissionRenameFolder: '您沒有重新命名資料夾的權限',\n    noPermissionLinkFolder: '您沒有連結資料夾的權限',\n    noPermissionDeleteFolder: '您沒有刪除資料夾的權限',\n    noPermissionPrint: '您沒有列印的權限',\n    noPermissionAddToQueue: '您沒有新增到佇列的權限',\n    noPermissionDownload: '您沒有下載檔案的權限',\n    noPermissionRenameFile: '您沒有重新命名此檔案的權限',\n    noPermissionGenerateThumbnail: '您沒有產生縮圖的權限',\n    noPermissionDeleteFile: '您沒有刪除此檔案的權限',\n    noPermissionCreateFolder: '您沒有建立資料夾的權限',\n    noPermissionUpload: '您沒有上傳檔案的權限',\n    noPermissionMoveFiles: '您沒有移動檔案的權限',\n    noPermissionDeleteFiles: '您沒有刪除檔案的權限',\n    // External folder\n    linkExternal: '連結外部',\n    linkExternalFolder: '連結外部資料夾',\n    linkExternalFolderDescription: '將主機目錄（NAS、USB、網路共享）掛載到檔案管理器中。檔案不會被複制——直接從原始路徑存取。',\n    externalFolderNamePlaceholder: '例如：NAS列印檔案',\n    externalPath: '主機路徑',\n    externalPathHelp: 'Docker主機上目錄的絕對路徑。必須以繫結掛載方式掛載到容器中。',\n    readOnly: '只讀',\n    readOnlyHelp: '防止上傳和刪除',\n    showHiddenFiles: '顯示隱藏檔案（點檔案）',\n    externalFolder: '外部資料夾',\n    scanFolder: '掃描',\n    toast: {\n      folderCreated: '資料夾已建立',\n      folderDeleted: '資料夾已刪除',\n      fileDeleted: '檔案已刪除',\n      filesDeleted: '已刪除 {{count}} 個檔案',\n      filesMoved: '檔案已移動',\n      folderLinked: '資料夾已連結',\n      folderUnlinked: '資料夾已取消連結',\n      externalFolderLinked: '外部資料夾已連結並掃描',\n      folderScanned: '掃描完成：新增 {{added}} 個，移除 {{removed}} 個',\n      addedToQueue: '已將 {{count}} 個檔案新增到佇列',\n      addedToQueuePartial: '已新增 {{added}} 個檔案，{{failed}} 個失敗',\n      failedToAddToQueue: '新增檔案失敗：{{error}}',\n      fileRenamed: '檔案已重新命名',\n      folderRenamed: '資料夾已重新命名',\n      thumbnailsGenerated: '已產生 {{count}} 個縮圖',\n      thumbnailsGeneratedPartial: '已產生 {{succeeded}} 個縮圖，{{failed}} 個失敗',\n      noStlMissingThumbnails: '沒有缺少縮圖的 STL 檔案',\n      failedToGenerateThumbnails: '產生縮圖失敗：{{error}}',\n      thumbnailGenerated: '縮圖已產生',\n      failedToGenerateThumbnail: '產生縮圖失敗：{{error}}',\n    },\n  },\n\n  // Projects\n  projects: {\n    title: '專案',\n    subtitle: '組織和追蹤您的 3D 列印專案',\n    newProject: '新增專案',\n    editProject: '編輯專案',\n    deleteProject: '刪除專案',\n    projectName: '專案名稱',\n    description: '描述',\n    noProjects: '尚無專案',\n    noProjectsFiltered: '沒有{{status}}專案',\n    noProjectsFilteredHelp: '您沒有任何{{status}}專案。當專案狀態更改時，它們將出現在這裡。',\n    createFirst: '建立您的第一個項目以開始組織相關列印、追蹤進度和管理構建。',\n    createFirstButton: '建立您的第一個項目',\n    create: '建立',\n    files: '檔案',\n    prints: '列印',\n    plates: '板',\n    parts: '零件',\n    lastModified: '最後修改',\n    deleteConfirm: '確定要刪除此項目嗎？歸檔和佇列項目將被取消連結但不會被刪除。',\n    addFiles: '新增檔案',\n    removeFile: '移除檔案',\n    viewDetails: '檢視詳情',\n    // Modal fields\n    namePlaceholder: '例如：Voron 2.4 構建',\n    descriptionPlaceholder: '可選描述...',\n    color: '顏色',\n    targetPlates: '目標板數',\n    targetPlatesPlaceholder: '例如：25',\n    targetPlatesHelp: '列印任務數量',\n    targetParts: '目標零件數',\n    targetPartsPlaceholder: '例如：150',\n    targetPartsHelp: '所需零件總數',\n    tagsLabel: '標籤（逗號分隔）',\n    tagsPlaceholder: '例如：voron、功能件、禮物',\n    dueDate: '截止日期',\n    priority: '優先順序',\n    priorityLow: '低',\n    priorityNormal: '普通',\n    priorityHigh: '高',\n    priorityUrgent: '緊急',\n    // Status\n    statusActive: '進行中',\n    statusCompleted: '已完成',\n    statusArchived: '已歸檔',\n    done: '完成',\n    completed: '已完成',\n    failed: '失敗',\n    inQueue: '佇列中',\n    noPrintsYet: '尚無列印',\n    // Footer stats\n    printJobs: '列印任務（板）',\n    partsPrinted: '已列印零件',\n    failedParts: '失敗零件',\n    // Actions\n    import: '匯入',\n    export: '匯出',\n    importProject: '匯入專案',\n    exportAll: '匯出所有專案',\n    loading: '載入專案中...',\n    // Permissions\n    noEditPermission: '您沒有編輯專案的權限',\n    noDeletePermission: '您沒有刪除專案的權限',\n    noCreatePermission: '您沒有建立專案的權限',\n    noImportPermission: '您沒有匯入專案的權限',\n    noExportPermission: '您沒有匯出專案的權限',\n    // Toast\n    toast: {\n      created: '專案已建立',\n      updated: '專案已更新',\n      deleted: '專案已刪除',\n      imported: '專案已匯入',\n      multipleImported: '已匯入 {{count}} 個項目',\n      importFailed: '匯入失敗',\n      exported: '專案已匯出（僅中繼資料）',\n    },\n  },\n\n  // Project detail page\n  projectDetail: {\n    notFound: '未找到專案',\n    backToProjects: '返回專案',\n    export: '匯出',\n    exportProject: '匯出專案',\n    noExportPermission: '您沒有匯出專案的權限',\n    noEditPermission: '您沒有編輯專案的權限',\n    partOf: '屬於：',\n    priorityLabel: '優先順序：',\n    noPrints: '此項目尚無列印',\n    status: {\n      active: '進行中',\n      completed: '已完成',\n      archived: '已歸檔',\n    },\n    priority: {\n      low: '低',\n      normal: '普通',\n      high: '高',\n      urgent: '緊急',\n    },\n    dueDate: {\n      overdue: '已逾期',\n      today: '今天到期',\n      daysLeft: '還有 {{count}} 天',\n    },\n    progress: {\n      platesProgress: '板進度',\n      partsProgress: '零件進度',\n      printJobs: '列印任務',\n      parts: '零件',\n      percentComplete: '{{percent}}% 完成',\n      remaining: '剩餘 {{count}} 個',\n    },\n    stats: {\n      printJobs: '列印任務',\n      total: '總計',\n      failed: '{{count}} 個失敗',\n      partsPrinted: '已列印 {{count}} 個零件',\n      printTime: '列印時間',\n      filamentUsed: '耗材用量',\n    },\n    cost: {\n      title: '成本追蹤',\n      filamentCost: '耗材成本',\n      energy: '能源',\n      totalCost: '總成本',\n      total: '總計',\n      includesBom: '含物料清單',\n      budget: '預算',\n      remaining: '剩餘',\n    },\n    subProjects: {\n      title: '子專案 ({{count}})',\n    },\n    notes: {\n      title: '備註',\n      noEditPermission: '您沒有編輯備註的權限',\n      placeholder: '新增關於此項目的備註...',\n      empty: '尚無備註。點選編輯新增備註。',\n    },\n    files: {\n      title: '檔案',\n      linkFolders: '從檔案管理器連結資料夾',\n      forQuickAccess: '到此項目以便快速存取。',\n      fileCount: '{{count}} 個檔案',\n      empty: '未連結資料夾。前往檔案管理器將資料夾連結到此項目。',\n      noFiles: '此資料夾中沒有檔案。',\n      print: '立即列印',\n      addToQueue: '加入佇列',\n    },\n    bom: {\n      title: '材料清單',\n      acquired: '已獲取 {{completed}}/{{total}}',\n      showAll: '顯示全部',\n      hideDone: '隱藏已完成',\n      addPart: '新增零件',\n      noAddPermission: '您沒有新增零件的權限',\n      partNamePlaceholder: '零件名稱（例如：M3x8 螺絲）',\n      partName: '零件名稱',\n      qty: '數量',\n      price: '價格 ({{currency}})',\n      sourcingUrlPlaceholder: '採購連結（可選）',\n      remarksPlaceholder: '備註（可選）',\n      deletePart: '刪除零件',\n      deleteConfirm: '確定要刪除\"{{name}}\"嗎？',\n      noUpdatePermission: '您沒有更新零件的權限',\n      noEditPermission: '您沒有編輯零件的權限',\n      noDeletePermission: '您沒有刪除零件的權限',\n      totalCost: '總成本：',\n      empty: '材料清單中沒有零件。新增硬體、電子元件或其他元件以追蹤需要採購的物品。',\n    },\n    timeline: {\n      title: '活動時間線',\n      empty: '尚無活動。',\n    },\n    template: {\n      saveAsTemplate: '儲存為範本',\n      noCreatePermission: '您沒有建立範本的權限',\n    },\n    queue: {\n      title: '佇列',\n      viewAll: '檢視全部',\n      printing: '{{count}} 個列印中',\n      queued: '{{count}} 個佇列中',\n    },\n    prints: {\n      title: '列印 ({{count}})',\n    },\n    toast: {\n      projectUpdated: '專案已更新',\n      partAdded: '零件已新增',\n      partRemoved: '零件已移除',\n      exportFailed: '匯出失敗',\n      projectExported: '專案已匯出',\n      templateCreated: '範本已建立',\n    },\n  },\n\n  // System info\n  system: {\n    title: '系統資訊',\n    version: '版本',\n    uptime: '執行時間',\n    cpuUsage: 'CPU 使用率',\n    memoryUsage: '記憶體使用率',\n    diskUsage: '磁碟使用率',\n    networkInfo: '網路資訊',\n    logs: '日誌',\n    debugMode: '偵錯模式',\n    enableDebug: '啟用偵錯日誌',\n    disableDebug: '停用偵錯日誌',\n    downloadLogs: '下載日誌',\n    clearLogs: '清除日誌',\n    dockerInfo: 'Docker 資訊',\n    containerName: '容器名稱',\n    imageName: '映象名稱',\n    platform: '平臺',\n    architecture: '架構',\n  },\n\n  // Library (K Profiles)\n  library: {\n    title: '耗材庫',\n    addFilament: '新增耗材',\n    editFilament: '編輯耗材',\n    deleteFilament: '刪除耗材',\n    vendor: '廠商',\n    material: '材料',\n    color: '顏色',\n    kFactor: 'K 值',\n    temperature: '溫度',\n    noFilaments: '耗材庫中沒有耗材',\n    deleteConfirm: '確定要刪除此耗材嗎？',\n    importFromPrinter: '從印表機匯入',\n    exportToFile: '匯出到檔案',\n  },\n\n  // Spoolman\n  spoolman: {\n    title: 'Spoolman 整合',\n    enabled: 'Spoolman 已啟用',\n    url: 'Spoolman URL',\n    connected: '已連線',\n    disconnected: '未連線',\n    testConnection: '測試連線',\n    sync: '同步',\n    syncing: '同步中...',\n    lastSync: '上次同步',\n    linkToSpoolman: '連結到 Spoolman',\n    openInSpoolman: '在 Spoolman 中開啟',\n    unlinkSpool: '取消連結耗材',\n    unlinkConfirmTitle: '解開料盤？',\n    unlinkConfirmMessage: '這將斷開卷軸與 Spoolman 的連線。Spoolman 中的卷軸資料將保持不變。',\n    selectSpool: '選擇耗材',\n    noUnlinkedSpools: '無未連結的耗材',\n    linkSuccess: '耗材已成功連結到 Spoolman',\n    linkFailed: '連結耗材失敗',\n    unlinkSuccess: '已成功從 Spoolman 取消連結耗材',\n    unlinkFailed: '取消連結耗材失敗',\n    spoolId: '耗材 ID',\n    fillSourceLabel: '(Spoolman)',\n    weight: '重量',\n    remaining: '剩餘',\n    disableWeightSync: '停用 AMS 估計重量同步',\n    disableWeightSyncDesc: '不從 AMS 估計值更新剩餘容量。如果您更喜歡 Spoolman 的用量追蹤而非 AMS 百分比估計，請使用此選項。新耗材仍將使用 AMS 估計值作為初始重量。',\n    reportPartialUsage: '報告失敗列印的部分用量',\n    reportPartialUsageDesc: '當列印失敗或被取消時，根據層進度報告估計的耗材使用量。',\n  },\n\n  // Inventory\n  inventory: {\n    title: '耗材庫存',\n    addSpool: '新增耗材',\n    editSpool: '編輯耗材',\n    material: '材料',\n    selectMaterial: '選擇材料...',\n    subtype: '子類型',\n    brand: '品牌',\n    searchBrand: '搜尋品牌...',\n    useCustomBrand: '使用\"{{brand}}\"',\n    useCustomMaterial: '使用自訂材料：{{material}}',\n    colorName: '顏色名稱',\n    colorNamePlaceholder: '翡翠白、烈焰紅...',\n    color: '顏色',\n    hexColor: '十六進位顏色',\n    pickColor: '選擇自訂顏色',\n    labelWeight: '標籤重量',\n    coreWeight: '空盤重量',\n    searchSpoolWeight: '搜尋耗材重量...',\n    weightUsed: '已使用',\n    currentWeight: '剩餘重量',\n    measuredWeight: '稱量重量',\n    spoolName: '料盤',\n    costPerKg: '每公斤成本',\n    measuredWeightError: '稱量重量必須在 {{min}}g 到 {{max}}g 之間。',\n    slicerFilament: '切片耗材',\n    slicerFilamentName: '切片預設名稱',\n    slicerPreset: '切片預設',\n    searchPresets: '搜尋耗材預設...',\n    selectedPreset: '已選擇',\n    noPresetsFound: '未找到預設',\n    tempOverrides: '溫度覆蓋',\n    note: '備註',\n    notePlaceholder: '關於此耗材的任何備註...',\n    archive: '歸檔',\n    restore: '恢復',\n    noSpools: '尚無耗材。新增您的第一個耗材開始使用。',\n    noManualSpools: '沒有手動新增的耗材。請先向庫存中新增耗材。',\n    kProfiles: 'K 值設定',\n    addKProfile: '新增 K 值設定',\n    assignSpool: '分配耗材',\n    unassignSpool: '取消分配',\n    assignSuccess: '耗材已分配，AMS 槽位已設定',\n    assignFailed: '分配耗材失敗',\n    assignMismatchTitle: '材料不符',\n    assignMismatchMessage: '所選料盤材料 \"{{spoolMaterial}}\" 與 {{location}} 的料槽材料 \"{{trayMaterial}}\" 不符。仍要分配嗎？',\n    assignMismatchConfirm: '仍然分配',\n    assignPartialMismatchMessage: '料盤材料 \"{{spoolMaterial}}\" 與 {{location}} 的 \"{{trayMaterial}}\" 相近但不完全一致。是否繼續？',\n    assignProfileMismatchMessage: '料盤設定 \"{{spoolProfile}}\" 與 {{location}} 的料槽設定 \"{{trayProfile}}\" 不一致。是否繼續？',\n    selectSpool: '選擇要分配到此槽位的耗材',\n    assigned: '已分配',\n    assigning: '分配中...',\n    searchSpools: '搜尋耗材...',\n    showAllSpools: '顯示所有耗材',\n    allMaterials: '所有材料',\n    filterByBrand: '按品牌篩選...',\n    showArchived: '顯示已歸檔',\n    quickAdd: '快速新增（庫存）',\n    quantity: '數量',\n    stock: '庫存',\n    configured: '已設定',\n    spoolsCreated: '已建立 {{count}} 個耗材',\n    spoolCreated: '耗材已建立',\n    spoolUpdated: '耗材已更新',\n    spoolDeleted: '耗材已刪除',\n    spoolArchived: '耗材已歸檔',\n    spoolRestored: '耗材已恢復',\n    deleteConfirm: '確定要刪除此耗材嗎？此操作無法復原。',\n    archiveConfirm: '確定要歸檔此耗材嗎？',\n    advancedSettings: '進階設定',\n    // Tabs\n    filamentInfoTab: '耗材資訊',\n    paProfileTab: 'PA 設定',\n    filamentInfo: '耗材',\n    additional: '附加',\n    // Cloud\n    loadingPresets: '載入雲端預設中...',\n    cloudConnected: '雲端已連線',\n    cloudNotConnected: '雲端未連線（使用預設值）',\n    // Colors\n    recentColors: '最近',\n    searchColors: '搜尋顏色...',\n    searchResults: '搜尋結果',\n    allColors: '所有顏色',\n    commonColors: '常用顏色',\n    showLess: '顯示更少',\n    showAll: '顯示全部',\n    noColorsFound: '沒有顏色匹配您的搜尋',\n    noResults: '未找到匹配項',\n    // PA Profiles\n    selectMaterialFirst: '請先在耗材資訊分頁中選擇材料。',\n    noPrintersConfigured: '未設定印表機。新增印表機以使用 PA 設定。',\n    matchingFilter: '匹配',\n    anyBrand: '任何品牌',\n    anyVariant: '任何變體',\n    autoSelect: '自動選擇',\n    matches: '匹配',\n    match: '匹配',\n    noMatches: '無匹配',\n    connected: '已連線',\n    offline: '離線',\n    printerOffline: '印表機離線。連線後檢視校準設定。',\n    noKProfilesMatch: '沒有 K 值設定匹配所選耗材。',\n    leftNozzle: '左噴嘴',\n    rightNozzle: '右噴嘴',\n    profilesSelected: '個校準設定已選擇',\n    // Stats & enhanced table\n    totalInventory: '總庫存',\n    totalConsumed: '總消耗',\n    byMaterial: '按材料',\n    inPrinter: '在印表機中',\n    lowStock: '庫存不足',\n    sinceTracking: '自開始追蹤',\n    loadedInAms: '已裝載到 AMS/外接',\n    remaining: '剩餘',\n    weightCheck: '重量檢查',\n    lastWeighed: '上次稱量',\n    neverWeighed: '從未稱量',\n    search: '搜尋耗材...',\n    showing: '顯示',\n    to: '到',\n    of: '共',\n    show: '顯示',\n    spools: '個耗材',\n    spool: '個耗材',\n    page: '頁',\n    noSpoolsMatch: '未找到結果',\n    noSpoolsMatchDesc: '嘗試調整您的搜尋或篩選條件。',\n    active: '活躍',\n    archived: '已歸檔',\n    all: '全部',\n    used: '已使用',\n    new: '新的',\n    clearFilters: '清除篩選',\n    table: '表格',\n    cards: '卡片',\n    net: '淨重',\n    // Grouping\n    groupSimilar: '分組',\n    groupedSpools: '{{count}} 個相同耗材',\n    groupedRows: '行',\n    // Column config\n    columns: '列',\n    configureColumns: '設定列',\n    configureColumnsDesc: '拖曳以重新排序列或使用箭頭。使用眼睛圖示切換可見性。',\n    visible: '可見',\n    reset: '重設',\n    cancel: '取消',\n    applyChanges: '套用更改',\n    moveUp: '上移',\n    moveDown: '下移',\n    hideColumn: '隱藏列',\n    showColumn: '顯示列',\n    // Tag linking\n    linkToSpool: '連結到耗材',\n    tagLinked: '標籤已連結到耗材',\n    tagLinkFailed: '連結標籤失敗',\n    tagAlreadyLinked: '標籤已連結到其他耗材',\n    unknownTag: '偵測到未知 RFID 標籤',\n    // Usage history\n    usageHistory: '使用歷史',\n    noUsageHistory: '尚無使用紀錄',\n    printName: '列印名稱',\n    weightConsumed: '消耗重量',\n    clearHistory: '清除',\n    historyCleared: '使用歷史已清除',\n    fillSourceLabel: '(庫存)',\n    lowStockThresholdError: '閾值必須在 0.1 到 99.9 之間',\n  },\n\n  // Timelapse\n  timelapse: {\n    title: '縮時攝影',\n    create: '建立縮時攝影',\n    download: '下載',\n    delete: '刪除',\n    preview: '預覽',\n    frameRate: '幀率',\n    quality: '品質',\n    processing: '處理中...',\n    noTimelapses: '無可用縮時攝影',\n  },\n\n  // AMS\n  ams: {\n    title: 'AMS',\n    slot: '槽位',\n    empty: '空',\n    emptySlot: '空槽位',\n    unknown: '未知',\n    humidity: '濕度',\n    temperature: '溫度',\n    filamentType: '耗材類型',\n    filamentColor: '顏色',\n    remaining: '剩餘',\n    history: 'AMS 歷史',\n    noHistory: '無可用歷史',\n    configureSlot: '設定槽位',\n    externalSpool: '外接耗材',\n    profile: '設定',\n    kFactor: 'K 值',\n    fill: '填充',\n    configure: '設定',\n    used: '已使用',\n    remainingUnit: '剩餘',\n  },\n\n  // Print modal\n  printModal: {\n    title: '開始列印',\n    selectPrinter: '選擇印表機',\n    selectPlate: '選擇板',\n    filamentMapping: '耗材對應',\n    totalCost: '總成本：',\n    slotRemainingShort: ' - 剩餘 {{grams}}g',\n    printSettings: '列印設定',\n    bedLeveling: '熱床調平',\n    flowCalibration: '流量校準',\n    vibrationCalibration: '振動校準',\n    layerInspection: '首層檢查',\n    timelapse: '縮時攝影',\n    startPrint: '開始列印',\n    addToQueue: '新增到佇列',\n    cancel: '取消',\n    noPrintersAvailable: '無可用印表機',\n    printerBusy: '印表機忙碌',\n    printerOffline: '印表機離線',\n    sameTypeDifferentColor: '相同類型，不同顏色',\n    filamentTypeNotLoaded: '耗材類型未裝載',\n    openCalendar: '開啟日曆',\n    leftNozzle: '左',\n    rightNozzle: '右',\n    leftNozzleTooltip: '左噴嘴',\n    rightNozzleTooltip: '右噴嘴',\n    filamentOverride: '耗材覆蓋',\n    filamentOverrideHint: '可選覆蓋用於基於模型的耗材分配。排程器將使用您選擇的耗材而不是原始 3MF 值進行匹配。',\n    originalFilament: '原始',\n    overrideWith: '覆蓋為',\n    resetToOriginal: '恢復為原始',\n    insufficientFilamentTitle: '耗材不足',\n    insufficientFilamentMessage: '部分已分配料盤的剩餘耗材少於本次列印所需：',\n    insufficientFilamentLine: '{{printer}} - {{slot}}：需要 {{required}}g，剩餘 {{remaining}}g',\n    printAnyway: '仍然列印',\n    forceColorMatch: '強制顏色匹配',\n    staggerPrinterStarts: '錯開印表機啟動',\n    staggerGroupSize: '群組大小',\n    staggerInterval: '間隔（分鐘）',\n    staggerPreview: '{{printers}} 台印表機 → 分成 {{groups}} 組，每組 {{size}} 台，每 {{interval}} 分鐘啟動一組',\n    staggerLastGroup: '最後一組：{{count}}',\n    staggerTotal: '總計：{{minutes}} 分鐘',\n    staggerToPrinters: '分批傳送到 {{count}} 臺印表機',\n    gcodeInjection: '注入自動列印G-code',\n  },\n\n  // Backup\n  backup: {\n    title: '備份與恢復',\n    createBackup: '建立備份',\n    restoreBackup: '恢復備份',\n    restoreDescription: '從備份檔案替換所有資料',\n    downloadBackup: '下載備份',\n    uploadBackup: '上傳備份',\n    lastBackup: '上次備份',\n    autoBackup: '自動備份',\n    backupNow: '立即備份',\n    restoreWarning: '警告：恢復備份將覆蓋所有目前資料。',\n    includeArchives: '包含歸檔',\n    includeSettings: '包含設定',\n    includeProfiles: '包含設定檔案',\n    backupSuccess: '備份建立成功',\n    restoreSuccess: '備份恢復成功',\n    backupFailed: '備份失敗',\n    restoreFailed: '恢復失敗',\n    restoreNote: '恢復期間虛擬印表機將停止',\n\n    // GitHub Backup\n    githubBackup: 'GitHub 備份',\n    enabled: '已啟用',\n    cloudLoginRequired: '需要登入 Bambu Cloud。請在 設定檔案 → 雲設定檔案 中登入以啟用 GitHub 備份。',\n    cloudLoginRequiredShort: '需要雲端登入',\n    githubDescription: '自動將您的設定檔案同步到私有 GitHub 倉庫以進行備份和版本歷史紀錄。',\n    repositoryUrl: '倉庫 URL',\n    personalAccessToken: '個人存取權杖',\n    tokenSaved: '（已儲存）',\n    enterNewToken: '輸入新權杖以更新',\n    tokenHint: '具有內容讀寫權限的細粒度權杖',\n    branch: '分支',\n    manualOnly: '僅手動',\n    hourly: '每小時',\n    daily: '每天',\n    weekly: '每週',\n    includeInBackup: '包含在備份中',\n    kProfiles: 'K 設定檔案',\n    kProfilesDescription: '來自已連線印表機的壓力推進校準',\n    noPrintersConnected: '沒有印表機連線',\n    printersConnected: '{{connected}}/{{total}} 已連線',\n    cloudProfiles: '雲設定檔案',\n    cloudProfilesDescription: '來自 Bambu Cloud 的耗材、印表機和工藝預設',\n    appSettings: '應用程式設定',\n    appSettingsDescription: 'Bambuddy 設定（完整資料庫）',\n    spoolInventory: '耗材庫存',\n    spoolInventoryDescription: '耗材卷軸、使用紀錄和成本追蹤',\n    printArchives: '列印檔案',\n    printArchivesDescription: '列印歷史中繼資料（不含 gcode/3MF 檔案）',\n    lastBackupAt: '上次備份：',\n    noBackupsYet: '尚無備份',\n    next: '下次：',\n    startingBackup: '正在啟動備份...',\n    test: '測試',\n    enableBackup: '啟用備份',\n    testConnection: '測試連線',\n    enterRepoUrl: '請輸入倉庫 URL',\n    enterRepoAndToken: '請輸入倉庫 URL 和存取權杖',\n    repoRequired: '倉庫 URL 為必填項',\n    tokenRequired: '存取權杖為必填項',\n    githubBackupEnabled: 'GitHub 備份已啟用',\n    tokenUpdated: '權杖已更新',\n    settingsSaved: '設定已儲存',\n    failedToSave: '儲存失敗：{{message}}',\n    backupCompleteFiles: '備份完成 - {{count}} 個檔案已更新',\n    backupSkippedNoChanges: '備份已跳過 - 無更改',\n    backupFailed2: '備份失敗：{{message}}',\n    clearedLogs: '已清除 {{count}} 條日誌',\n    failedToClearLogs: '清除日誌失敗：{{message}}',\n\n    // History\n    history: '歷史紀錄',\n    clear: '清除',\n    date: '日期',\n    status: '狀態',\n    commit: '提交',\n\n    // Local Backup\n    localBackup: '本機備份',\n    localBackupDescription: '建立 Bambuddy 資料的完整備份，包括資料庫、檔案、上傳和所有檔案。',\n    downloadBackupLabel: '下載備份',\n    completeBackupZip: '完整備份：資料庫 + 所有檔案（ZIP）',\n    download: '下載',\n    preparingBackup: '正在準備備份...',\n    creatingArchive: '正在建立備份歸檔...對於大型歸檔可能需要一些時間。',\n    downloadingFile: '正在下載備份檔案...',\n    backupDownloaded: '備份下載成功',\n    failedToCreateBackup: '建立備份失敗：{{message}}',\n    restore: '恢復',\n    restoreReplacesAll: '恢復將替換所有資料。',\n    restoreReplacesAllDetail: '您目前的資料庫和檔案將被完全替換。恢復後需要重新啟動。',\n    restoreConfirmTitle: '恢復備份',\n    restoreConfirmMessage: '您確定要從\"{{filename}}\"恢復嗎？這將完全替換您目前的資料庫和所有檔案。恢復後需要重新啟動應用程式。',\n    restoreConfirmButton: '恢復備份',\n    uploadingFile: '正在上傳備份檔案...',\n    backupRestoredRestart: '備份已恢復。請重新啟動 Bambuddy。',\n    failedToRestore: '恢復備份失敗。請檢查檔案格式。',\n    reloadNow: '立即重新載入',\n    creatingBackup: '正在建立備份',\n    restoringBackup: '正在恢復備份',\n    preparing: '準備中...',\n    processing: '處理中...',\n    doNotClosePage: '請不要關閉此頁面或離開頁面。對於大型備份，此操作可能需要幾分鐘。',\n\n    // RestoreModal\n    restoring: '恢復中...',\n    restoreComplete: '恢復完成',\n    restoreFailed2: '恢復失敗',\n    importSettings: '從備份檔案匯入設定',\n    pleaseWaitRestoring: '請等待資料恢復中',\n    selectBackupFile: '點選選擇備份檔案（.json 或 .zip）',\n    duplicateHandling: '重複項處理方式：',\n    matchPrinters: '印表機',\n    matchPrintersBy: '按序列號匹配',\n    matchSmartPlugs: '智慧插座',\n    matchSmartPlugsBy: '按 IP 位址匹配',\n    matchNotificationProviders: '通知提供者',\n    matchNotificationProvidersBy: '按名稱匹配',\n    matchFilaments: '耗材',\n    matchFilamentsBy: '按名稱 + 類型 + 品牌匹配',\n    matchArchives: '檔案',\n    matchArchivesBy: '按內容雜湊匹配（始終跳過）',\n    matchPendingUploads: '待上傳',\n    matchPendingUploadsBy: '按檔名匹配',\n    matchSettingsTemplates: '設定和範本',\n    matchSettingsTemplatesBy: '始終覆蓋',\n    replaceExisting: '替換現有資料',\n    keepExisting: '保留現有資料',\n    overwriteDescription: '用備份資料覆蓋已存在的項目',\n    keepDescription: '僅恢復尚不存在的項目',\n    overwriteCaution: '注意：',\n    overwriteWarning: '覆蓋將用備份資料替換您目前的設定。出於安全考慮，印表機存取碼永遠不會被覆蓋。',\n    cancel: '取消',\n    processingBackup: '正在處理備份檔案...',\n    itemsRestored: '已恢復項目',\n    itemsSkipped: '已跳過項目',\n    restored: '已恢復',\n    skippedAlreadyExist: '已跳過（已存在）',\n    filesCategory: '檔案（3MF、縮圖等）',\n    andMore: '...還有 {{count}} 項',\n    newApiKeysGenerated: '已產生新的 API 金鑰',\n    keysShownOnce: '這些金鑰僅顯示一次。請立即複製！',\n    copy: '複製',\n    noDataFound: '在備份檔案中未找到可恢復的資料。',\n    close: '關閉',\n\n    // Scheduled local backups (#884)\n    scheduledBackup: '排程備份',\n    scheduledBackupDescription: '依排程自動建立備份快照。輸出目錄可掛載到 NAS 或外部儲存。',\n    frequency: '頻率',\n    backupTime: '時間',\n    retention: '保留',\n    retentionDescription: '保留的備份數量',\n    outputPath: '輸出路徑',\n    outputPathPlaceholder: '預設：{{path}}',\n    outputPathDescription: '留空以使用預設位置',\n    runNow: '立即執行',\n    backupFiles: '備份檔案',\n    noScheduledBackups: '尚無備份',\n    deleteBackup: '刪除',\n    deleteBackupConfirm: '要刪除此備份檔案嗎？',\n    backupRunning: '備份進行中…',\n    scheduledBackupComplete: '備份已成功完成',\n    scheduledBackupFailed: '備份失敗',\n    nextBackup: '下次備份',\n    backupSize: '大小',\n    utc: 'UTC',\n    defaultPathLabel: '預設：',\n\n    // Category labels\n    categories: {\n      settings: '設定',\n      notification_providers: '通知提供者',\n      notification_templates: '通知範本',\n      smart_plugs: '智慧插座',\n      printers: '印表機',\n      filaments: '耗材',\n      maintenance_types: '維護類型',\n      archives: '檔案',\n      projects: '專案',\n      pending_uploads: '待上傳',\n      external_links: '外部連結',\n      api_keys: 'API 金鑰',\n    },\n  },\n\n  // Tags\n  tags: {\n    title: '標籤',\n    addTag: '新增標籤',\n    editTag: '編輯標籤',\n    deleteTag: '刪除標籤',\n    tagName: '標籤名稱',\n    tagColor: '標籤顏色',\n    noTags: '無標籤',\n    deleteConfirm: '確定要刪除此標籤嗎？',\n    manageTags: '管理標籤',\n  },\n\n  // Upload modal (archives)\n  uploadModal: {\n    title: '上傳 3MF 檔案',\n    dragDrop: '將 .3mf 檔案拖放到此處',\n    or: '或',\n    browseFiles: '瀏覽檔案',\n    extractionInfo: '將從 3MF 檔案中繼資料中自動提取印表機型號。',\n    uploaded: '已上傳',\n    failed: '失敗',\n    uploading: '上傳中...',\n    upload: '上傳',\n    uploadFailed: '上傳失敗',\n  },\n\n  // Edit archive modal\n  // Edit Archive Modal\n  editArchive: {\n    title: '編輯歸檔',\n    name: '名稱',\n    namePlaceholder: '列印名稱',\n    printer: '印表機',\n    noPrinter: '無印表機',\n    project: '專案',\n    noProject: '無專案',\n    itemsPrinted: '列印數量',\n    itemsPrintedHelp: '此列印任務中生產的物品數量',\n    notes: '備註',\n    notesPlaceholder: '新增關於此列印的備註...',\n    externalLink: '外部連結',\n    externalLinkPlaceholder: 'https://printables.com/model/...',\n    externalLinkHelp: '連結到 Printables、Thingiverse 或其他來源',\n    tags: '標籤',\n    tagsPlaceholder: '新增標籤...',\n    addMoreTags: '新增更多標籤...',\n    matchingTags: '匹配\"{{query}}\"',\n    existingTags: '現有標籤',\n    clickToAdd: '（點選新增）',\n    status: '狀態',\n    failureReason: '失敗原因',\n    selectReason: '選擇原因...',\n    photos: '列印成品照片',\n    photosHelp: '點選 + 新增列印成品照片',\n    printResult: '列印成品',\n    saving: '儲存中...',\n    // Failure reasons\n    failureReasons: {\n      adhesionFailure: '附著力失敗',\n      spaghettiDetached: '拉絲 / 脫落',\n      layerShift: '層偏移',\n      cloggedNozzle: '噴嘴堵塞',\n      filamentRunout: '耗材用完',\n      warping: '翹曲',\n      stringing: '拉絲',\n      underExtrusion: '擠出不足',\n      powerFailure: '斷電',\n      userCancelled: '使用者取消',\n      other: '其他',\n    },\n    // Archive statuses\n    statuses: {\n      completed: '已完成',\n      failed: '失敗',\n      aborted: '已取消',\n      printing: '列印中',\n    },\n  },\n\n  // K-Profiles\n  kProfiles: {\n    title: 'K 值設定',\n    noPrintersConfigured: '未設定印表機',\n    addPrinterInSettings: '在設定中新增印表機以管理 K 值設定',\n    noActivePrinters: '無活躍印表機',\n    enablePrinterConnection: '啟用印表機連線以檢視其 K 值設定',\n    loadingProfiles: '載入 K 值設定中...',\n    printerOffline: '印表機離線',\n    printerOfflineDesc: '所選印表機未連線。開啟電源以檢視 K 值設定。',\n    noMatchingProfiles: '無匹配的設定',\n    noMatchingProfilesDesc: '沒有設定匹配您的搜尋條件',\n    noKProfiles: '無 K 值設定',\n    noKProfilesDesc: '未找到 {{diameter}}mm 噴嘴的壓力推進設定',\n    createFirstProfile: '建立第一個設定',\n    // Controls\n    printer: '印表機',\n    nozzle: '噴嘴',\n    refresh: '重新整理',\n    addProfile: '新增設定',\n    export: '匯出',\n    import: '匯入',\n    select: '選擇',\n    selectAll: '全選',\n    delete: '刪除',\n    // Filters\n    searchPlaceholder: '按名稱或耗材搜尋...',\n    allExtruders: '所有擠出機',\n    leftOnly: '僅左側',\n    rightOnly: '僅右側',\n    allFlow: '所有流量',\n    hfOnly: '僅高流量',\n    sOnly: '僅標準',\n    sortName: '排序：名稱',\n    sortKValue: '排序：K 值',\n    sortFilament: '排序：耗材',\n    // Dual extruder labels\n    leftExtruder: '左擠出機',\n    rightExtruder: '右擠出機',\n    // Modal\n    modal: {\n      addTitle: '新增 K 值設定',\n      editTitle: '編輯 K 值設定',\n      profileName: '設定名稱',\n      profileNamePlaceholder: '我的 PLA 設定',\n      kValue: 'K 值',\n      kValuePlaceholder: '0.020',\n      kValueHelp: '典型範圍：PLA 0.01 - 0.06，PETG 0.02 - 0.10',\n      filament: '耗材',\n      selectFilament: '選擇耗材...',\n      noFilamentsHelp: '未找到耗材。請先在 Bambu Studio 中建立 K 值設定。',\n      flowType: '流量類型',\n      highFlow: '高流量',\n      standard: '標準',\n      nozzleSize: '噴嘴尺寸',\n      extruder: '擠出機',\n      extruders: '擠出機',\n      left: '左',\n      right: '右',\n      notes: '備註（本機儲存）',\n      notesPlaceholder: '新增關於此設定的備註...',\n      notesHelp: '備註儲存在 Bambuddy 中，不在印表機上',\n      syncing: '與印表機同步中...',\n      savingExtruder: '儲存到擠出機 {{current}}/{{total}}...',\n      pleaseWait: '請稍候',\n    },\n    // Delete confirmation\n    deleteConfirm: {\n      title: '刪除設定',\n      cannotUndo: '此操作無法復原',\n      message: '確定要從印表機刪除\"{{name}}\"嗎？',\n    },\n    // Bulk delete\n    bulkDelete: {\n      title: '刪除設定',\n      cannotUndo: '此操作無法復原',\n      message: '確定要從印表機刪除 {{count}} 個選中的設定嗎？',\n    },\n    // Toast\n    toast: {\n      profileSaved: 'K 值設定已儲存',\n      profilesSaved: 'K 值設定已儲存到 {{count}} 個擠出機',\n      selectAtLeastOneExtruder: '請至少選擇一個擠出機',\n      profileDeleted: 'K 值設定已刪除',\n      profilesDeleted: '已刪除 {{count}} 個設定',\n      exportedProfiles: '已匯出 {{count}} 個設定',\n      importedProfiles: '已匯入 {{count}} / {{total}} 個設定',\n      noProfilesToExport: '無可匯出的設定',\n      invalidFileFormat: '無效的檔案格式',\n      failedToParseImport: '解析匯入檔案失敗',\n      failedToSaveBatch: '批次儲存 K 值設定失敗',\n      noteSaved: '備註已儲存',\n      failedToSaveNote: '儲存備註失敗',\n    },\n    // Permissions\n    permission: {\n      noRead: '您沒有重新整理設定的權限',\n      noCreate: '您沒有新增設定的權限',\n      noUpdate: '您沒有更新 K 值設定的權限',\n      noDelete: '您沒有刪除 K 值設定的權限',\n      noExport: '您沒有匯出設定的權限',\n      noImport: '您沒有匯入設定的權限',\n    },\n  },\n\n  // Virtual Printer\n  virtualPrinter: {\n    title: '虛擬印表機',\n    running: '執行中',\n    stopped: '已停止',\n    description: {\n      default: '啟用虛擬印表機，使其在 Bambu Studio 和 OrcaSlicer 中可見。傳送到此印表機的檔案將直接歸檔而不列印。',\n      proxy: '啟用代理，將切片軟體流量中繼到真實印表機，允許在任何網路上遠端列印。',\n    },\n    enable: {\n      title: '啟用虛擬印表機',\n      visibleInSlicer: '在切片軟體發現中顯示為\"Bambuddy\"',\n      proxyingTo: '代理到 {{name}}',\n      notActive: '未啟用',\n    },\n    model: {\n      title: '印表機型號',\n      description: '選擇要模擬的印表機型號。',\n      restartWarning: '更改型號將重新啟動虛擬印表機',\n    },\n    accessCode: {\n      title: '存取碼',\n      isSet: '存取碼已設定',\n      notSet: '未設定存取碼 - 需要設定才能啟用',\n      placeholder: '輸入 8 位字元程式碼',\n      placeholderChange: '輸入新程式碼以更改',\n      hint: '必須恰好 8 個字元。切片軟體使用此程式碼進行認證。',\n      charCount: '({{count}}/8)',\n    },\n    targetPrinter: {\n      title: '目標印表機',\n      configured: '代理目標已設定',\n      notConfigured: '未選擇目標印表機 - 代理模式需要設定',\n      placeholder: '選擇印表機...',\n      hint: '選擇要將切片軟體流量代理到的印表機。印表機必須處於區域網路模式。',\n      noPrinters: '未設定印表機。請先新增印表機以使用代理模式。',\n    },\n    remoteInterface: {\n      title: '網路介面覆蓋',\n      configured: '介面覆蓋已啟用',\n      optional: '可選 - 當自動檢測的 IP 不正確時使用（例如多網路卡、Docker、VPN）',\n      placeholder: '自動檢測（預設）...',\n      hint: '覆蓋透過 SSDP 廣播並在 TLS 憑證中使用的 IP 位址。在 Bambuddy 有多個網路介面時很有用。',\n    },\n    mode: {\n      title: '模式',\n      archive: '歸檔',\n      archiveDesc: '立即歸檔檔案',\n      review: '審核',\n      reviewDesc: '歸檔前審核',\n      queue: '佇列',\n      queueDesc: '歸檔並新增到佇列',\n      proxy: '代理',\n      proxyDesc: '中繼到真實印表機',\n    },\n    autoDispatch: {\n      title: '自動派發',\n      description: '新增到佇列時自動開始列印。關閉後，列印任務等待手動派發。',\n    },\n    setupRequired: {\n      title: '需要設定',\n      description: '虛擬印表機功能需要額外的系統設定才能工作。包括埠轉發、防火牆規則和平臺特定設定。',\n      readGuide: '啟用前請閱讀設定指南',\n    },\n    howItWorks: {\n      title: '工作原理',\n      step1: '在同一區域網路中，虛擬印表機會透過發現機制自動出現在您的切片軟體（Bambu Studio / OrcaSlicer）中。從其他網路，透過 IP 位址和存取碼手動新增。',\n      step2: '在歸檔、審核和佇列模式下，使用切片軟體中的\"傳送\"按鈕將 3MF 檔案上傳到 Bambuddy。切片軟體會顯示\"列印成功\"— 檔案已儲存，未列印。',\n      step3: '在代理模式下，虛擬印表機將所有流量中繼到真實印表機 — 列印會立即開始，就像直接連線一樣。',\n    },\n    status: {\n      title: '狀態詳情',\n      printerName: '印表機名稱',\n      model: '型號',\n      serialNumber: '序列號',\n      mode: '模式',\n      pendingFiles: '待處理檔案',\n      targetPrinter: '目標印表機',\n      ftpPort: 'FTP 連接埠',\n      mqttPort: 'MQTT 連接埠',\n      ftpConnections: 'FTP 連線',\n      mqttConnections: 'MQTT 連線',\n    },\n    toast: {\n      updated: '虛擬印表機設定已更新',\n      failedToUpdate: '更新設定失敗',\n      accessCodeRequired: '請先設定存取碼',\n      targetPrinterRequired: '請先選擇目標印表機',\n      bindIpRequired: '請先設定繫結 IP',\n      accessCodeEmpty: '存取碼不能為空',\n      accessCodeLength: '存取碼必須恰好 8 個字元',\n      created: '虛擬印表機已建立',\n      failedToCreate: '建立虛擬印表機失敗',\n      deleted: '虛擬印表機已刪除',\n      failedToDelete: '刪除虛擬印表機失敗',\n    },\n    list: {\n      title: '虛擬印表機',\n      add: '新增',\n      addFirst: '新增虛擬印表機',\n      empty: '未設定虛擬印表機。新增一個以開始使用。',\n    },\n    bindIp: {\n      title: '繫結介面',\n      placeholder: '選擇介面...',\n      hint: '此虛擬印表機繫結的網路介面。每臺印表機必須唯一。',\n    },\n    proxy: {\n      accessCodeHint: '在代理模式下，在切片軟體中使用目標印表機的存取碼。連線會透明轉發到真實印表機。',\n    },\n    addDialog: {\n      title: '新增虛擬印表機',\n      name: '名稱',\n      hint: '建立後可以設定存取碼、目標印表機和其他設定。',\n      create: '建立',\n    },\n    deleteConfirm: {\n      title: '刪除虛擬印表機',\n      message: '確定要刪除\"{{name}}\"嗎？這將停止此印表機的所有服務。',\n    },\n  },\n\n  // Model Viewer\n  modelViewer: {\n    openInSlicer: '在切片軟體中開啟',\n    tabs: {\n      model: '3D 模型',\n      gcode: 'G-code 預覽',\n    },\n    notAvailable: '不可用',\n    notSliced: '未切片',\n    plates: '板',\n    allPlates: '所有板',\n    plateNumber: '板 {{number}}',\n    plateCount: '{{count}} 個板',\n    plateCount_other: '{{count}} 個板',\n    objectCount: '{{count}} 個物件',\n    objectCount_other: '{{count}} 個物件',\n    filamentCount: '{{count}} 種耗材',\n    filamentCount_other: '{{count}} 種耗材',\n    eta: '預計 {{minutes}} 分鐘',\n    noPreview: '此檔案無可用預覽',\n    pagination: {\n      pageOf: '第 {{current}} / {{total}} 頁',\n      prev: '上一頁',\n      next: '下一頁',\n    },\n    errors: {\n      failedToLoad: '載入檔案失敗',\n      noMeshes: '3MF 檔案中未找到網格',\n      unsupportedFormat: '不支援的檔案格式',\n    },\n  },\n\n  // Maintenance type descriptions (built-in)\n  maintenanceDescriptions: {\n    lubricateCarbonRods: '在碳纖維杆上塗抹潤滑劑以確保順暢運動',\n    lubricateRails: '在線性導軌上塗抹潤滑劑以確保順暢運動',\n    cleanNozzle: '清潔熱端和噴嘴以防止堵塞',\n    checkBelts: '檢查皮帶張力以確保列印精度',\n    cleanBuildPlate: '清潔列印板以獲得更好的附著力',\n    checkExtruder: '檢查擠出機齒輪磨損情況',\n    checkCooling: '確保冷卻風扇正常工作',\n    generalInspection: '印表機綜合檢查',\n    cleanCarbonRods: '清潔碳纖維杆以減少摩擦',\n    lubricateSteelRods: '在鋼杆上塗抹潤滑劑以確保順暢運動',\n    cleanSteelRods: '清潔鋼杆以減少摩擦',\n    cleanLinearRails: '擦拭線性導軌以清除灰塵和碎屑',\n    checkPtfeTube: '檢查 PTFE 管的磨損或損壞',\n    replaceHepaFilter: '更換 HEPA 過濾器以保證空氣品質',\n    replaceCarbonFilter: '更換活性炭過濾器',\n    lubricateLeftNozzleRail: '潤滑左噴嘴導軌（H2 系列）',\n  },\n\n  // Smart Plugs\n  smartPlugs: {\n    offline: '離線',\n    admin: '管理',\n    openPlugAdminPage: '開啟插座管理頁面',\n    deleteSmartPlug: '刪除智慧插座',\n    turnOnSmartPlug: '開啟智慧插座',\n    turnOffSmartPlug: '關閉智慧插座',\n    turnOn: '開啟',\n    turnOff: '關閉',\n    addSmartPlug: {\n      scanningNetwork: '掃描網路中...',\n      chooseEntity: '選擇實體...',\n      connectionFailed: '連線失敗',\n      searchEntities: '搜尋實體...',\n      searchPowerSensors: '搜尋功率感測器...',\n      searchEnergySensors: '搜尋能量感測器...',\n      placeholders: {\n        plugName: '客廳插座',\n        mqttStateOnValue: 'ON、true、1',\n        mqttSameAsPower: '與功率主題相同，或不同',\n      },\n    },\n    // SmartPlugCard\n    linkedTo: '連結到：',\n    monitorOnly: '僅監控',\n    alerts: '警報',\n    scheduleOn: '開啟 {{time}}',\n    scheduleOff: '關閉 {{time}}',\n    on: '開啟',\n    off: '關閉',\n    power: '功率',\n    kwhToday: '今日kWh',\n    settings: '設定',\n    automationSettings: '自動化設定',\n    showInSwitchbar: '在開關欄顯示',\n    quickAccessSidebar: '從側邊欄快速存取',\n    enabled: '已啟用',\n    enableAutomation: '為此插座啟用自動化',\n    autoOn: '自動開啟',\n    autoOnDescription: '列印開始時開啟',\n    autoOff: '自動關閉',\n    autoOffDescription: '列印完成時關閉（一次性）',\n    autoOffPersistent: '保持啟用',\n    autoOffPersistentDescription: '在列印之間保持啟用而非一次性',\n    turnOffDelayMode: '關閉延遲模式',\n    time: '時間',\n    temp: '溫度',\n    delayMinutes: '延遲（分鐘）',\n    tempThreshold: '溫度閾值（°C）',\n    tempThresholdDescription: '當噴嘴冷卻到此溫度以下時關閉',\n    edit: '編輯',\n    deleteConfirm: '確定要刪除\"{{name}}\"嗎？此操作無法復原。',\n    turnOnConfirm: '確定要開啟\"{{name}}\"嗎？',\n    turnOffConfirm: '確定要關閉\"{{name}}\"嗎？這將切斷連線裝置的電源。',\n    failedToTurn: '無法{{action}}\"{{name}}\"',\n    unknown: '未知',\n    // AddSmartPlugModal\n    addTitle: '新增智慧插座',\n    editTitle: '編輯智慧插座',\n    stopScanning: '停止掃描',\n    discoverTasmota: '發現Tasmota裝置',\n    foundDevices: '找到{{count}}個裝置 - 點選選擇：',\n    noDevicesFound: '未在您的網路中找到Tasmota裝置',\n    haNotConfigured: 'Home Assistant未設定。請在以下位置設定',\n    haSettingsPath: '設定 → 網路 → Home Assistant',\n    selectEntity: '選擇實體 *',\n    ipAddress: 'IP 位址 *',\n    nameLabel: '名稱 *',\n    username: '使用者名稱',\n    password: '密碼',\n    authHint: '如果您的Tasmota裝置不需要認證，請留空',\n    linkToPrinter: '連結印表機',\n    noPrinter: '無印表機（僅手動控制）',\n    linkingDescription: '連結後可在列印開始/完成時自動開關',\n    powerAlerts: '功率警報',\n    alertAbove: '高於時警報（W）',\n    alertBelow: '低於時警報（W）',\n    alertDescription: '當電力消耗超過這些閾值時收到通知。留空以停用該方向。',\n    dailySchedule: '每日計畫',\n    turnOnAt: '開啟時間',\n    turnOffAt: '關閉時間',\n    scheduleDescription: '每天在這些時間自動開關插座。留空以跳過該操作。',\n    showOnPrinterCard: '在印表機卡片上顯示',\n    displayOnPrinterCard: '在印表機卡片上顯示按鈕',\n    connectedResult: '已連線！',\n    deviceLabel: '裝置：{{name}} - ',\n    stateLabel: '狀態：{{state}}',\n    test: '測試',\n    delete: '刪除',\n    save: '儲存',\n    add: '新增',\n    cancel: '取消',\n    failedToStartScan: '無法開始掃描',\n    nameRequired: '名稱為必填項',\n    entityRequired: 'Home Assistant插座需要實體',\n    mqttTopicRequired: '必須為功率、能源或狀態監控設定至少一個MQTT主題',\n    loadingEntities: '正在載入實體...',\n    loading: '載入中...',\n    failedToLoadEntities: '載入實體失敗：{{error}}',\n    noEntitiesMatching: '未找到匹配\"{{search}}\"的實體',\n    noEntitiesAvailable: '無可用實體',\n    searchingEntities: '搜尋所有實體（找到{{count}}個）',\n    showingEntities: '顯示 switch、light、input_boolean（{{count}}個可用）',\n    energyMonitoringOptional: '能源監控（可選）',\n    energyMonitoringHint: '搜尋並選擇提供功率/能源資料的感測器。',\n    powerSensorW: '功率感測器（W）',\n    energyTodayKwh: '今日能源（kWh）',\n    totalEnergyKwh: '總能源（kWh）',\n    noMatchingSensors: '無匹配的感測器',\n    none: '無',\n    mqttNotConfigured: 'MQTT代理未設定。請在以下位置設定代理地址',\n    mqttSettingsPath: '設定 → 網路 → MQTT發布',\n    mqttNotConfiguredSuffix: '（您不需要啟用發布，只需填寫代理詳細資訊）。',\n    mqttMonitorOnlyDescription: 'MQTT插座透過MQTT訂閱接收功率/能源資料。開關控制不可用 - 請使用您的MQTT代理或家庭自動化系統。',\n    powerMonitoring: '功率監控',\n    energyMonitoring: '能源監控',\n    stateMonitoring: '狀態監控',\n    optional: '可選',\n    topic: '主題',\n    jsonPath: 'JSON路徑',\n    multiplier: '乘數',\n    onValue: 'ON值',\n    mqttPowerHint: 'JSON路徑從JSON負載中提取值（例如\"power_l1\"）。如果主題發布原始數值，請留空。\\n乘數：mW→W使用0.001，kW→W使用1000。',\n    mqttEnergyHint: 'JSON路徑從JSON負載中提取值。原始值請留空。\\n乘數：Wh→kWh使用0.001，MWh→kWh使用1000。',\n    mqttStateHint: 'JSON路徑從JSON負載中提取值。原始值請留空。\\nON值：表示\"ON\"的確切字串。留空以自動檢測（ON、true、1）。',\n    // REST smart plug\n    restControl: '控制',\n    restOnUrl: '開啟 URL',\n    restOffUrl: '關閉 URL',\n    restOnBody: '開啟請求內容',\n    restOffBody: '關閉請求內容',\n    restMethod: 'HTTP 方法',\n    restHeaders: '自訂標頭（JSON）',\n    restStatusUrl: '狀態 URL',\n    restStatusPath: '狀態 JSON 路徑',\n    restStatusOnValue: 'ON 值',\n    restPowerUrl: '功率URL',\n    restPowerPath: '功率 JSON 路徑',\n    restPowerMultiplier: '功率乘數',\n    restEnergyUrl: '能耗URL',\n    restEnergyPath: '能耗 JSON 路徑',\n    restEnergyMultiplier: '能耗乘數',\n    restUrlRequired: 'REST 插座至少需要一個 URL（ON 或 OFF）',\n    restHeadersHint: '例如：{\"Authorization\": \"Bearer your-token\"}',\n    restBodyHint: '例如：ON、{\"state\": \"on\"}',\n    restStatusHint: '用於輪詢目前狀態的 URL',\n    restPathHint: '例如：state 或 data.power.status',\n    restPowerUrlHint: '功率資料的獨立URL（留空則使用狀態URL）',\n    restEnergyUrlHint: '能耗資料的獨立URL（留空則使用狀態URL）',\n    restEnergyHint: '每個值可以使用獨立的URL，或回退到狀態 URL。使用乘數進行單位轉換（例如：0.001 將 Wh 轉換為 kWh）。',\n    testConnection: '測試連線',\n    connectionSuccess: '連線成功',\n    noSwitchesInSwitchbar: '開關欄中沒有開關',\n    enableSwitchbarHint: '在設定 > 智慧插座中啟用\"在開關欄顯示\"',\n  },\n\n  // Notifications\n  notifications: {\n    // Provider types\n    providerTypes: {\n      callmebot: 'CallMeBot/WhatsApp',\n      ntfy: 'ntfy',\n      pushover: 'Pushover',\n      telegram: 'Telegram',\n      email: '電子郵件',\n      discord: 'Discord',\n      webhook: 'Webhook',\n      homeassistant: 'Home Assistant',\n    },\n    // Provider descriptions\n    providerDescriptions: {\n      email: 'SMTP 電子郵件通知',\n      telegram: '透過 Telegram 機器人傳送通知',\n      discord: '透過 Webhook 傳送到 Discord 頻道',\n      ntfy: '免費、可自託管的推送通知',\n      pushover: '簡單、可靠的推送通知',\n      callmebot: '透過 CallMeBot 免費傳送 WhatsApp 通知',\n      webhook: '通用 HTTP POST 到任意 URL',\n      homeassistant: 'Home Assistant 儀表板中的持久通知',\n    },\n    // NotificationProviderCard\n    lastSuccess: '上次：{{date}}',\n    error: '錯誤',\n    printer: '印表機：',\n    allPrinters: '所有印表機',\n    sendTestNotification: '傳送測試通知',\n    eventSettings: '事件設定',\n    enabled: '已啟用',\n    sendFromProvider: '從此提供者傳送通知',\n    // Event categories\n    printEvents: '列印事件',\n    printerStatus: '印表機狀態',\n    amsAlarms: 'AMS 警報',\n    amsHtAlarms: 'AMS-HT 警報',\n    printQueue: '列印佇列',\n    // Event tags (badges)\n    start: '開始',\n    plateCheck: '熱床檢測',\n    complete: '完成',\n    failed: '失敗',\n    stopped: '已停止',\n    progress: '進度',\n    offline: '離線',\n    lowFilament: '耗材不足',\n    maintenance: '維護',\n    amsHumidity: 'AMS 濕度',\n    amsTemp: 'AMS 溫度',\n    amsHtHumidity: 'AMS-HT 濕度',\n    amsHtTemp: 'AMS-HT 溫度',\n    bedCooled: '熱床已冷卻',\n    firstLayer: '首層完成',\n    quiet: '免打擾',\n    digest: '摘要 {{time}}',\n    // Event labels (expanded settings)\n    printStarted: '列印已開始',\n    plateNotEmpty: '熱床非空',\n    plateNotEmptyDescription: '列印前偵測到物體',\n    printCompleted: '列印已完成',\n    bedCooledLabel: '熱床已冷卻',\n    bedCooledDescription: '列印後熱床溫度降至閾值以下',\n    firstLayerCompleteLabel: '首層列印完成',\n    firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',\n    missingSpoolAssignmentLabel: '缺少料卷分配',\n    missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知',\n    printFailed: '列印失敗',\n    printStopped: '列印已停止',\n    progressMilestones: '進度里程碑',\n    progressMilestonesDescription: '在 25%、50%、75% 時通知',\n    printerOffline: '印表機離線',\n    printerError: '印表機錯誤',\n    lowFilamentLabel: '耗材不足',\n    maintenanceDue: '需要維護',\n    maintenanceDueDescription: '需要維護時通知',\n    amsHumidityHigh: 'AMS 濕度過高',\n    amsHumidityHighDescription: '普通 AMS 濕度超過閾值',\n    amsTemperatureHigh: 'AMS 溫度過高',\n    amsTemperatureHighDescription: '普通 AMS 溫度超過閾值',\n    amsHtHumidityHigh: 'AMS-HT 濕度過高',\n    amsHtHumidityHighDescription: 'AMS-HT 濕度超過閾值',\n    amsHtTemperatureHigh: 'AMS-HT 溫度過高',\n    amsHtTemperatureHighDescription: 'AMS-HT 溫度超過閾值',\n    // Queue events\n    jobAdded: '任務已新增',\n    jobAddedDescription: '任務已新增到佇列',\n    jobAssigned: '任務已分配',\n    jobAssignedDescription: '基於模型的任務已分配給印表機',\n    jobStarted: '任務已開始',\n    jobStartedDescription: '佇列任務已開始列印',\n    jobWaiting: '任務等待中',\n    jobWaitingDescription: '任務正在等待耗材或印表機',\n    jobSkipped: '任務已跳過',\n    jobSkippedDescription: '任務已跳過（上一個失敗）',\n    jobFailed: '任務失敗',\n    jobFailedDescription: '任務啟動失敗',\n    queueComplete: '佇列已完成',\n    queueCompleteDescription: '所有佇列任務已完成',\n    // Quiet hours\n    quietHours: '免打擾時段',\n    noNotificationsDuring: '在此時段內不傳送通知',\n    editProviderToChangeQuietHours: '編輯提供者以更改免打擾時段',\n    // Daily digest\n    dailyDigest: '每日摘要',\n    batchNotifications: '將通知彙總為每日摘要',\n    sendAt: '傳送於 {{time}}',\n    editProviderToChangeDigestTime: '編輯提供者以更改摘要時間',\n    // Actions\n    edit: '編輯',\n    deleteProvider: '刪除通知提供者',\n    deleteConfirm: '確定要刪除\"{{name}}\"嗎？此操作無法復原。',\n    delete: '刪除',\n    // AddNotificationModal\n    addTitle: '新增通知提供者',\n    editTitle: '編輯通知提供者',\n    nameLabel: '名稱 *',\n    namePlaceholder: '我的通知',\n    providerTypeLabel: '提供者類型 *',\n    configuration: '設定',\n    testConfiguration: '測試設定',\n    printerFilter: '印表機篩選',\n    onlyFromPrinter: '僅傳送來自此印表機的事件通知',\n    quietHoursDnd: '免打擾時段',\n    quietStart: '開始',\n    quietEnd: '結束',\n    dailyDigestLabel: '每日摘要',\n    sendDigestAt: '傳送摘要於',\n    digestCollected: '事件將被收集並在此時間作為單條摘要傳送',\n    notificationEvents: '通知事件',\n    progressPercent: '（25%、50%、75%）',\n    bedCooledAfterPrint: '（列印完成後）',\n    cancel: '取消',\n    save: '儲存',\n    add: '新增',\n    nameRequired: '名稱為必填項',\n    fieldRequired: '{{field}}為必填項',\n    // Config field labels\n    phoneNumber: '電話號碼',\n    apiKey: 'API 金鑰',\n    serverUrl: '伺服器 URL',\n    topic: '主題',\n    authToken: '認證權杖',\n    userKey: '使用者金鑰',\n    appToken: '應用程式權杖',\n    priority: '優先順序',\n    botToken: '機器人權杖',\n    chatId: '聊天 ID',\n    smtpServer: 'SMTP 伺服器',\n    smtpPort: 'SMTP 連接埠',\n    security: '安全',\n    authentication: '認證',\n    username: '使用者名稱',\n    password: '密碼',\n    fromEmail: '寄件人信箱',\n    toEmail: '收件人信箱',\n    webhookUrl: 'Webhook URL',\n    payloadFormat: '負載格式',\n    authorization: '授權',\n    titleFieldName: '標題欄位名',\n    messageFieldName: '訊息欄位名',\n    // NotificationTemplateEditor\n    editTemplate: '編輯範本：{{name}}',\n    titleLabel: '標題',\n    bodyLabel: '正文',\n    titlePlaceholder: '通知標題...',\n    bodyPlaceholder: '通知正文...',\n    availableVariables: '可用變數',\n    clickToInsert: '點選插入到正文游標位置',\n    livePreview: '即時預覽',\n    hide: '隱藏',\n    show: '顯示',\n    loadingPreview: '載入預覽中...',\n    enterTemplateContent: '輸入範本內容以檢視預覽',\n    titlePreview: '標題：',\n    bodyPreview: '正文：',\n    resetToDefault: '恢復預設',\n    titleRequired: '標題為必填項',\n    bodyRequired: '正文為必填項',\n    // NotificationLogViewer\n    notificationLog: '通知日誌',\n    showFailedOnly: '僅顯示失敗',\n    last24Hours: '最近 24 小時',\n    last7Days: '最近 7 天',\n    last30Days: '最近 30 天',\n    last90Days: '最近 90 天',\n    justNow: '剛剛',\n    noFailedNotifications: '沒有失敗的通知',\n    noNotificationsLogged: '沒有通知紀錄',\n    unknownProvider: '未知提供者',\n    logTitle: '標題',\n    logMessage: '訊息',\n    logError: '錯誤',\n    logProvider: '提供者：{{type}}',\n    logTime: '時間：{{time}}',\n    refresh: '重新整理',\n    clearOld: '清除舊紀錄',\n    statsSummary: '最近 {{days}} 天：',\n    statsNotifications: '條通知',\n    statsSent: '{{count}} 條已傳送',\n    statsFailed: '{{count}} 條失敗',\n    // Event type labels (for log viewer)\n    eventTypes: {\n      print_start: '列印已開始',\n      print_complete: '列印完成',\n      print_failed: '列印失敗',\n      print_stopped: '列印已停止',\n      print_progress: '進度',\n      printer_offline: '印表機離線',\n      printer_error: '印表機錯誤',\n      filament_low: '耗材不足',\n      maintenance_due: '需要維護',\n      test: '測試',\n    },\n    userEmail: {\n      title: '通知',\n      emailNotifications: '郵件通知',\n      emailNotificationsDesc: '接收您自己列印任務的郵件通知。郵件將透過進階身份驗證中設定的 SMTP 設定傳送。',\n      sendingTo: '通知將傳送至',\n      noEmailWarning: '您的帳戶沒有郵件地址。請聯絡管理員新增。',\n      printJobNotifications: '列印任務通知',\n      printJobNotificationsDesc: '選擇哪些事件會觸發您提交的列印任務的郵件通知。',\n      printJobStarts: '列印任務開始',\n      printJobStartsDesc: '當您的列印任務開始時收到通知。',\n      printJobFinishes: '列印任務完成',\n      printJobFinishesDesc: '當您的列印任務成功完成時收到通知。',\n      printErrors: '列印錯誤',\n      printErrorsDesc: '當您的列印任務失敗或遇到錯誤時收到通知。',\n      printJobStops: '列印任務停止',\n      printJobStopsDesc: '當您的列印任務被取消或停止時收到通知。',\n      saveSuccess: '通知偏好設定已儲存。',\n      saveError: '儲存通知偏好設定失敗。',\n    },\n  },\n\n  // Rich Text Editor\n  richTextEditor: {\n    bold: '粗體',\n    italic: '斜體',\n    underline: '底線',\n    bulletList: '無序列表',\n    numberedList: '有序列表',\n    alignLeft: '左對齊',\n    alignCenter: '居中對齊',\n    alignRight: '右對齊',\n    addLink: '新增連結',\n    removeLink: '移除連結',\n  },\n\n  // External Links\n  externalLinks: {\n    noLinksConfigured: '未設定外部連結',\n    deleteLink: '刪除連結',\n    removeCustomIcon: '移除自訂圖示',\n    openInNewTab: '在新標籤頁中開啟',\n    placeholders: {\n      linkName: '我的連結',\n    },\n  },\n\n  // Keyboard Shortcuts Modal\n  keyboardShortcuts: {\n    title: '鍵盤快捷鍵',\n    navigation: '導航',\n    archivesSection: '歸檔',\n    kProfilesSection: 'K 值設定',\n    generalSection: '通用',\n    shortcuts: {\n      goToPrinters: '前往印表機',\n      goToArchives: '前往歸檔',\n      goToQueue: '前往佇列',\n      goToStats: '前往統計',\n      goToProfiles: '前往雲端設定',\n      goToSettings: '前往設定',\n      focusSearch: '聚焦搜尋',\n      openUploadModal: '開啟上傳對話方塊',\n      clearSelection: '清除選擇 / 取消焦點',\n      contextMenu: '卡片右鍵選單',\n      refreshProfiles: '重新整理設定',\n      newProfile: '新增設定',\n      exitSelectionMode: '結束選擇模式',\n      showHelp: '顯示此協助',\n    },\n    footer: '按 Esc 或點選外部關閉',\n  },\n\n  // Notification Log\n  notificationLog: {\n    title: '通知日誌',\n    events: {\n      printStarted: '列印開始',\n      printComplete: '列印完成',\n      printFailed: '列印失敗',\n      printStopped: '列印停止',\n      progress: '進度',\n      printerOffline: '印表機離線',\n      printerError: '印表機錯誤',\n      lowFilament: '耗材不足',\n      maintenanceDue: '維護到期',\n      test: '測試',\n    },\n    timeAgo: {\n      justNow: '剛剛',\n      minutesAgo: '{{minutes}} 分鐘前',\n      hoursAgo: '{{hours}} 小時前',\n    },\n  },\n\n  // Restore/Backup Modal\n  restoreBackup: {\n    title: '恢復備份',\n    restoring: '恢復中...',\n    restoreComplete: '恢復完成',\n    restoreFailed: '恢復失敗',\n    importSettings: '從備份檔案匯入設定',\n    pleaseWait: '請稍候，正在恢復您的資料',\n    clickToSelect: '點選選擇備份檔案（.json 或 .zip）',\n    howDuplicateHandling: '重複處理方式：',\n    categories: {\n      printers: '印表機',\n      smartPlugs: '智慧插座',\n      notificationProviders: '通知提供者',\n      filaments: '耗材',\n      archives: '歸檔',\n      pendingUploads: '待處理上傳',\n      settingsTemplates: '設定和範本',\n    },\n    matchingInfo: {\n      printers: '按序列號匹配',\n      smartPlugs: '按 IP 位址匹配',\n      notificationProviders: '按名稱匹配',\n      filaments: '按名稱 + 類型 + 品牌匹配',\n      archives: '按內容雜湊匹配',\n      pendingUploads: '按檔名匹配',\n      settingsTemplates: '始終覆蓋',\n    },\n    replaceExisting: '替換現有資料',\n    keepExisting: '保留現有資料',\n    replaceDescription: '用備份資料覆蓋已存在的項目',\n    keepDescription: '僅恢復不存在的項目',\n    caution: '注意：',\n    cautionText: '覆蓋將用備份資料替換您目前的設定。出於安全考慮，印表機存取碼永遠不會被覆蓋。',\n    itemsRestored: '已恢復項目',\n    itemsSkipped: '已跳過項目',\n    restored: '已恢復',\n    skipped: '已跳過（已存在）',\n    filesLabel: '檔案（3MF、縮圖等）',\n    newApiKeysGenerated: '已產生新 API 金鑰',\n    newApiKeysWarning: '這些金鑰僅顯示一次。請立即複製！',\n    processingBackup: '處理備份檔案中...',\n    noDataFound: '備份檔案中未找到可恢復的資料。',\n    failedToRestore: '恢復備份失敗。請檢查檔案格式。',\n  },\n\n  // Backup Export Modal\n  backupExport: {\n    title: '匯出備份',\n    selectData: '選擇要包含的資料',\n    selectAll: '全選',\n    selectNone: '全不選',\n    categoryDescriptions: {\n      settings: '語言、主題、更新偏好',\n      notifications: 'ntfy、Pushover、Discord 等',\n      templates: '自訂訊息範本',\n      smartPlugs: 'Tasmota 插座設定',\n      externalLinks: '側邊欄外部服務連結',\n      printers: '印表機資訊（不含存取碼）',\n      plateDetection: '空列印板參考影像',\n      filaments: '耗材類型和成本',\n      maintenance: '自訂維護計畫',\n      archives: '所有列印資料 + 檔案（3MF、縮圖、照片）',\n      projects: '專案、材料清單和附件',\n      pendingUploads: '虛擬印表機待審核的上傳',\n      apiKeys: 'Webhook API 金鑰（匯入時產生新金鑰）',\n    },\n    requiresPrinters: '需要選擇印表機',\n    zipFileWarning: '將建立 ZIP 檔案。',\n    zipFileDescription: '包括所有 3MF 檔案、縮圖、縮時攝影和照片。這可能需要一些時間並產生較大的檔案。',\n    includeAccessCodes: '包含存取碼',\n    includeAccessCodesDescription: '用於轉移到另一臺機器',\n    includeAccessCodesWarning: '存取碼將以明文形式包含。請妥善保管此備份檔案！',\n    categoriesSelected: '已選擇 {{selectedCount}} 個類別',\n  },\n\n  // Pending Uploads Panel\n  pendingUploads: {\n    placeholders: {\n      notes: '新增關於此列印的備註...',\n    },\n    discardUpload: '丟棄上傳',\n    archiveAllUploads: '歸檔所有上傳',\n    discardAllUploads: '丟棄所有上傳',\n    archive: '歸檔',\n    timeAgo: {\n      justNow: '剛剛',\n      minutesAgo: '{{minutes}} 分鐘前',\n      hoursAgo: '{{hours}} 小時前',\n      daysAgo: '{{days}} 天前',\n    },\n  },\n\n  // API Browser\n  apiBrowser: {\n    placeholders: {\n      requestBody: 'JSON 請求體...',\n      searchEndpoints: '搜尋端點...',\n    },\n  },\n\n  // Configure AMS Slot Modal\n  configureAmsSlot: {\n    title: '設定 AMS 槽位',\n    slotConfigured: '槽位已設定！',\n    configuringSlot: '正在設定槽位：',\n    slotLabel: '{{ams}} 槽位 {{slot}}',\n    searchPresets: '搜尋預設...',\n    colorPlaceholder: '顏色名稱或十六進位（例如：棕色、FF8800）',\n    clearCustomColor: '清除自訂顏色',\n    noCloudPresets: '無雲端預設。登入拓竹雲以同步。',\n    noPresetsAvailable: '無可用預設。登入拓竹雲或匯入本機設定。',\n    noMatchingPresets: '未找到匹配的預設。',\n    custom: '自訂',\n    builtin: '內建',\n    settingsSentToPrinter: '設定已傳送到印表機',\n    filamentProfile: '耗材設定',\n    kProfileLabel: 'K 值設定（壓力推進）',\n    filteringFor: '篩選：{{material}}',\n    noKProfile: '無 K 值設定（使用預設值 0.020）',\n    noMatchingKProfiles: '未找到匹配的 K 值設定。將使用預設 K=0.020。',\n    selectFilamentFirst: '請先選擇耗材設定',\n    kFromCalibration: 'K={{value}}（來自印表機校準）',\n    customColorLabel: '自訂顏色（可選）',\n    presetColors: '{{name}} 顏色：',\n    showLessColors: '顯示更少顏色',\n    showMoreColors: '顯示更多顏色',\n    clear: '清除',\n    hexLabel: '十六進位：#{{hex}}',\n    resetting: '重設中...',\n    resetSlot: '重設槽位',\n    cancel: '取消',\n    configuring: '設定中...',\n    configureSlot: '設定槽位',\n  },\n\n  // GitHub Backup Settings\n  githubBackup: {\n    title: 'GitHub 備份',\n    history: '歷史',\n    downloadBackup: '下載備份',\n    restoreBackup: '恢復備份',\n    noBackupsYet: '尚無備份',\n  },\n\n  // Email Settings\n  emailSettings: {\n    placeholders: {\n      fromName: 'BamBuddy',\n    },\n  },\n\n  // Tag Management Modal\n  tagManagement: {\n    searchTags: '搜尋標籤...',\n    renameTag: '重新命名標籤',\n    deleteTag: '刪除標籤',\n  },\n\n  // Notification Template Editor\n  notificationTemplates: {\n    placeholders: {\n      title: '通知標題...',\n      body: '通知正文...',\n    },\n  },\n\n  // Batch Tag Modal\n  batchTag: {\n    placeholders: {\n      newTag: '輸入新標籤...',\n    },\n  },\n\n  // Photo Gallery Modal\n  photoGallery: {\n    deletePhoto: '刪除照片',\n  },\n\n  // Filament Hover Card\n  filamentHoverCard: {\n    copySpoolUuid: '複製耗材 UUID',\n  },\n\n  // K Profiles View\n  kProfilesView: {\n    hasNote: '有備註',\n    copyProfile: '複製設定',\n  },\n\n  // Layout/Navigation\n  layout: {\n    openMenu: '開啟選單',\n    noPermissionSystemInfo: '您沒有檢視系統資訊的權限',\n  },\n\n  // Dashboard\n  dashboard: {\n    dragToReorder: '拖曳以重新排列',\n    hideWidget: '隱藏小工具',\n  },\n\n  // Notification Provider Card\n  notificationProviderCard: {\n    deleteNotificationProvider: '刪除通知提供者',\n  },\n\n  // File Manager Modal\n  fileManagerModal: {\n    closeFileManager: '關閉檔案管理器',\n    sortFiles: '排序檔案',\n    goToParentFolder: '返回上級資料夾',\n    threeView: '3D 檢視',\n  },\n\n  // Embedded Camera Viewer\n  embeddedCameraViewer: {\n    refreshStream: '重新整理流',\n    close: '關閉',\n    zoomOut: '縮小',\n    resetZoom: '重設縮放',\n    zoomIn: '放大',\n    dragToResize: '拖曳調整大小',\n  },\n\n  // Timelapse Viewer\n  timelapseViewer: {\n    skipBack5s: '後退 5 秒',\n    skipForward5s: '前進 5 秒',\n  },\n\n  // Notification Providers\n  notificationProviders: {\n    descriptions: {\n      email: 'SMTP 郵件通知',\n      telegram: '透過 Telegram 機器人通知',\n      discord: '透過 Webhook 傳送到 Discord 頻道',\n      ntfy: '免費、可自託管的推送通知',\n      pushover: '簡單、可靠的推送通知',\n      callmebot: '透過 CallMeBot 的免費 WhatsApp 通知',\n      webhook: '通用 HTTP POST 到任意 URL',\n    },\n  },\n\n  // Log Viewer\n  logViewer: {\n    searchPlaceholder: '搜尋訊息或日誌名稱...',\n    noLogEntries: '未找到日誌條目',\n  },\n\n  // Switchbar Popover\n  switchbarPopover: {\n    noSwitchesInSwitchbar: '切換欄中沒有開關',\n  },\n\n  // Project Page Modal\n  projectPageModal: {\n    placeholders: {\n      title: '標題',\n      designer: '設計師',\n      license: '許可證',\n      description: '輸入描述...',\n      profileTitle: '設定標題',\n      profileDescription: '設定描述...',\n    },\n  },\n\n  // Spoolman Settings\n  spoolmanSettings: {},\n\n  // Time\n  time: {\n    unknown: '-',\n    waiting: '等待中',\n    justNow: '剛剛',\n    now: '現在',\n    minsAgo: '{{count}} 分鐘前',\n    inMins: '{{count}} 分鐘後',\n    hoursAgo: '{{count}} 小時前',\n    inHours: '{{count}} 小時後',\n    daysAgo: '{{count}} 天前',\n    inDays: '{{count}} 天後',\n  },\n\n  // SpoolBuddy Kiosk\n  spoolbuddy: {\n    nav: {\n      dashboard: '儀表板',\n      ams: 'AMS',\n      inventory: '庫存',\n      writeTag: '寫入',\n      settings: '設定',\n    },\n    status: {\n      nfcReady: 'NFC 就緒',\n      nfcOff: 'NFC 關閉',\n      offline: '離線',\n      online: '線上',\n      noPrinters: '無印表機',\n      deviceOffline: '裝置離線',\n      waitingConnection: '等待裝置連線...',\n      systemReady: '系統就緒',\n      status: '狀態',\n    },\n    dashboard: {\n      readyToScan: '準備掃描',\n      idleMessage: '將耗材放在磅秤上以識別',\n      nfcHint: 'NFC 標籤將自動讀取',\n      device: '裝置',\n      syncWeight: '同步重量',\n      weightSynced: '已同步！',\n      unknownTag: '未知標籤',\n      newTag: '偵測到新標籤',\n      onScale: '在磅秤上',\n      linkSpool: '連結到耗材',\n      linkTagTitle: '將標籤連結到耗材',\n      linkTag: '連結標籤',\n      selectSpool: '選擇要連結此標籤的耗材：',\n      noUntagged: '未找到沒有標籤的耗材',\n      tagDetected: '偵測到標籤',\n      noTag: '無標籤',\n      tagId: '標籤',\n      grossWeight: '毛重',\n      spoolSize: '耗材盤尺寸',\n      close: '關閉',\n      currentSpool: '目前耗材',\n    },\n    modal: {\n      spoolDetected: '偵測到耗材',\n      assignToAms: '分配到 AMS',\n      syncWeight: '同步重量',\n      weightSynced: '已同步！',\n      syncing: '同步中...',\n      newTagDetected: '偵測到新標籤',\n      addToInventory: '新增到庫存',\n      assignToAmsTitle: '分配到 AMS',\n      selectSlot: '選擇槽位',\n      assign: '分配',\n      assigning: '分配中...',\n      assignSuccess: '已分配！',\n      assignError: '分配耗材失敗。請重試。',\n      noPrinterSelected: '選擇印表機...',\n      noAmsDetected: '此印表機未偵測到 AMS',\n      slot: '槽位',\n    },\n    weight: {\n      noReading: '無讀數',\n      stable: '穩定',\n      measuring: '測量中...',\n      tare: '去皮',\n      calibrate: '校準',\n    },\n    spool: {\n      remaining: '剩餘',\n      material: '材料',\n      brand: '品牌',\n      color: '顏色',\n      coreWeight: '軸心重',\n      labelWeight: '標籤重',\n      scaleWeight: '磅秤重',\n      netWeight: '淨重',\n      lastUsed: '上次使用',\n    },\n    ams: {\n      noData: '未偵測到 AMS',\n      connectAms: '連線 AMS 以檢視耗材槽位',\n      noPrinter: '未選擇印表機',\n      selectPrinter: '從頂部欄選擇印表機',\n      printerDisconnected: '印表機已斷開',\n      humidity: '濕度',\n      level: '餘量',\n      active: '活躍',\n      slot: '槽位',\n      empty: '空',\n    },\n    inventory: {\n      search: '搜尋耗材...',\n      empty: '庫存中沒有耗材',\n      noResults: '沒有匹配的耗材',\n      spools: '個耗材',\n      addSpool: '新增耗材',\n    },\n    settings: {\n      // Tabs\n      tabDevice: '裝置',\n      tabDisplay: '顯示',\n      tabScale: '磅秤',\n      tabUpdates: '更新',\n      // Device tab\n      nfcReader: 'NFC 讀卡器',\n      type: '類型',\n      connection: '連線',\n      notConnected: '不適用',\n      deviceInfo: '裝置資訊',\n      hostname: '主機',\n      uptime: '執行時間',\n      systemConfig: '後端與認證',\n      backendUrl: 'Bambuddy 後端 URL',\n      apiToken: 'API 權杖',\n      apiTokenPlaceholder: '輸入 API 權杖',\n      saveConfig: '儲存設定',\n      systemQueued: '設定已加入佇列。',\n      nfcDiagnostic: 'NFC 診斷',\n      scaleDiagnostic: '磅秤診斷',\n      readTagDiagnostic: '讀取標籤診斷',\n      testNfc: '測試讀卡器',\n      testScale: '測試精度',\n      testReadTag: '讀取標籤',\n      systemFieldsRequired: '後端 URL 為必填項。',\n      // Display tab\n      brightness: '亮度',\n      saved: '已儲存',\n      noBacklight: '未偵測到 DSI 背光。亮度控制需要 DSI 螢幕。',\n      screenBlank: '螢幕熄滅超時',\n      screenBlankDesc: '不活動後螢幕關閉。觸控喚醒。',\n      displayNote: '亮度作為軟體濾鏡套用。',\n      // Scale tab\n      scaleCalibration: '磅秤校準',\n      currentWeight: '目前重量',\n      tareOffset: '去皮',\n      calFactor: '係數',\n      knownWeight: '已知重量',\n      calStep1: '移除磅秤上所有物品並按設定零點。',\n      calStep2: '將已知重量放在磅秤上。',\n      setZero: '設定零點',\n      calibrateNow: '校準',\n      calibrated: '已校準',\n      tareSet: '去皮命令已傳送。等待裝置回應...',\n      tareFailed: '傳送去皮命令失敗',\n      zeroSet: '零點已設定。將已知重量放在磅秤上。',\n      calibrationDone: '校準完成！',\n      calibrationFailed: '校準失敗',\n      lastCalibrated: '上次校準',\n      stable: '穩定',\n      settling: '穩定中...',\n      firmware: '韌體',\n      scale: '磅秤',\n      noDevice: '未找到 SpoolBuddy 裝置',\n      // Updates tab\n      daemonVersion: '守護程式版本',\n      currentVersion: '目前',\n      versionPending: '等待守護程式...',\n      checking: '檢查中...',\n      checkUpdates: '檢查更新',\n      updateAvailable: '有可用更新',\n      updateInstructions: '透過 SSH 更新：執行 SpoolBuddy 安裝腳本進行升級。',\n      upToDate: '已是最新',\n      includeBeta: '包含測試版本',\n    },\n    writeTag: {\n      tabExisting: '現有耗材',\n      tabNew: '新耗材',\n      tabReplace: '替換標籤',\n      searchPlaceholder: '按材料、顏色、品牌搜尋...',\n      noUntaggedSpools: '沒有無標籤的耗材',\n      noTaggedSpools: '沒有有標籤的耗材',\n      selectSpool: '選擇一個耗材，然後將空白 NTAG 放在讀卡器上',\n      placeTag: '將 NTAG 放在讀卡器上',\n      tagReady: '偵測到標籤 — 準備寫入',\n      writeTag: '寫入標籤',\n      replaceTag: '替換標籤',\n      writing: '寫入標籤中...',\n      waiting: '等待 SpoolBuddy...',\n      writeSuccess: '標籤寫入成功！',\n      writeFailed: '寫入失敗',\n      queueFailed: '佇列寫入命令失敗',\n      tryAgain: '重試',\n      cancel: '取消',\n      replaceWarning: '舊標籤將被取消連結。新標籤將替換它。',\n      deviceOffline: 'SpoolBuddy 離線',\n      material: '材料',\n      colorName: '顏色名稱',\n      color: '顏色',\n      brand: '品牌',\n      weight: '重量 (g)',\n      createSpool: '建立耗材',\n      creating: '建立中...',\n      spoolCreated: '耗材已建立！準備寫入。',\n      createFailed: '建立耗材失敗',\n    },\n    quickMenu: {\n      printerPower: '印表機電源',\n      systemControls: '系統',\n      restartDaemon: '重新啟動守護程式',\n      restartBrowser: '重新啟動瀏覽器',\n      reboot: '重新開機',\n      shutdown: '關機',\n      swipeToClose: '向下滑動關閉',\n      confirmTitle: '確認',\n      confirmShutdown: '確定要關閉 SpoolBuddy 嗎？您需要實體存取才能重新開啟。',\n      confirmReboot: '確定要重新開機 SpoolBuddy 嗎？',\n      confirmRestartDaemon: '重新啟動 SpoolBuddy 守護程式？NFC 和磅秤將暫時不可用。',\n      confirmRestartBrowser: '重新啟動kiosk瀏覽器？螢幕將短暫變黑。',\n      confirm: '確認',\n      confirmPlugOn: '開啟 {{name}}？',\n      confirmPlugOff: '關閉 {{name}}？',\n      turnOn: '開啟',\n      turnOff: '關閉',\n    },\n  },\n\n  bugReport: {\n    title: '報告錯誤',\n    description: '描述',\n    descriptionPlaceholder: '出了什麼問題？請描述問題...',\n    email: '信箱（可選）',\n    emailPlaceholder: 'your@email.com',\n    emailPrivacy: '如果提供，您的信箱將包含在GitHub Issue的摺疊部分中，以便維護者後續跟進。',\n    screenshot: '截圖',\n    uploadOrPaste: '上傳、貼上或拖曳圖片',\n    dataCollectedSummary: '報告中包含哪些資料？',\n    dataIncluded: '包含：',\n    dataIncludedList: '應用程式版本、作業系統、架構、Python版本、資料庫統計（僅計數）、印表機型號、噴嘴數量、韌體版本、連線狀態、整合狀態（Spoolman、MQTT、HA）、非敏感設定、網路介面數量、Docker詳情、依賴版本。',\n    dataNeverIncluded: '絕不包含：',\n    dataNeverIncludedList: '印表機名稱、序列號、存取碼、密碼、IP 位址、信箱地址、API金鑰、權杖、Webhook URL、主機名稱或使用者名稱。',\n    submit: '提交',\n    startLogging: '開始偵錯日誌',\n    stepEnableLogging: '偵錯日誌已啟用',\n    stepReproduce: '請現在重現問題',\n    stepStopLogging: '停止並提交報告',\n    stopAndSubmit: '停止並提交',\n    maxDuration: '{{minutes}} 分鐘後自動停止',\n    stoppingLogs: '正在收集日誌並提交...',\n    submitting: '正在提交錯誤報告...',\n    submitSuccess: '錯誤報告提交成功！',\n    submitFailed: '提交錯誤報告失敗',\n    thankYou: '謝謝！',\n    submitted: '您的錯誤報告已提交。',\n    viewIssue: '檢視 Issue',\n    unexpectedError: '發生了意外錯誤',\n  },\n  failureDetection: {\n    title: 'AI 故障檢測',\n    description: '透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。',\n    mlUrl: 'Obico ML API 地址',\n    mlUrlHint: '您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。',\n    test: '測試',\n    testSuccess: 'ML API 可存取且正常。',\n    testFailed: '無法存取 ML API。',\n    sensitivity: '靈敏度',\n    sensitivityLow: '低(減少誤報)',\n    sensitivityMedium: '中(平衡)',\n    sensitivityHigh: '高(更早檢測,更多誤報)',\n    sensitivityHint: '調整觸發警告和故障的置信度閾值。',\n    action: '偵測到故障時的操作',\n    actionNotify: '僅通知',\n    actionPause: '暫停列印',\n    actionPauseOff: '暫停並切斷電源',\n    pollInterval: '檢查間隔(秒)',\n    pollIntervalHint: '列印過程中每臺印表機的檢查頻率。最小 5 秒,最大 120 秒。',\n    externalUrlMissing: '尚未設定外部 URL。',\n    externalUrlHint: 'ML API 透過 URL 擷取攝影機快照。請在一般設定中設定外部 URL，讓 ML API 容器可以連線到 Bambuddy。',\n    perPrinterTitle: '監控的印表機',\n    perPrinterHint: '選擇檢測服務要監視哪些印表機。',\n    monitorAll: '監控所有已連線的印表機',\n    statusTitle: '狀態',\n    serviceRunning: '服務執行中',\n    thresholds: '低 / 高閾值',\n    activePrinters: '活動列印',\n    noActivePrints: '目前沒有正在進行的列印。',\n    historyTitle: '最近檢測',\n    noHistory: '尚無檢測紀錄。',\n  },\n};\n"
  },
  {
    "path": "frontend/src/index.css",
    "content": "@import \"tailwindcss\";\n@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');\n\n/* Enable class-based dark mode for Tailwind v4 */\n@custom-variant dark (&:where(.dark, .dark *));\n\n@theme {\n  /* Accent colors - use CSS variables for theming */\n  --color-bambu-green: var(--accent);\n  --color-bambu-green-light: var(--accent-light);\n  --color-bambu-green-dark: var(--accent-dark);\n\n  /* Semantic status colors - fixed, don't change with accent */\n  --color-status-ok: var(--status-ok);\n  --color-status-error: var(--status-error);\n  --color-status-warning: var(--status-warning);\n\n  /* Theme-aware colors via CSS variables */\n  --color-bambu-dark: var(--bg-primary);\n  --color-bambu-dark-secondary: var(--bg-secondary);\n  --color-bambu-dark-tertiary: var(--bg-tertiary);\n  --color-bambu-gray: var(--text-muted);\n  --color-bambu-gray-light: var(--text-secondary);\n  --color-bambu-gray-dark: var(--text-tertiary);\n}\n\n/* ============================================\n   BASE DEFAULTS\n   ============================================ */\n\n:root {\n  /* Default accent color (green) */\n  --accent: #00ae42;\n  --accent-light: #00c64d;\n  --accent-dark: #009438;\n\n  /* Semantic status colors - these never change with accent theme */\n  --status-ok: #22c55e;      /* green-500 - always green for success/online/ok */\n  --status-error: #ef4444;   /* red-500 - always red for error/offline/failed */\n  --status-warning: #f59e0b; /* amber-500 - always amber for warnings */\n\n  /* Default light mode background (neutral) */\n  --bg-primary: #f5f5f5;\n  --bg-secondary: #ffffff;\n  --bg-tertiary: #e5e5e5;\n  --text-primary: #1a1a1a;\n  --text-secondary: #4a4a4a;\n  --text-muted: #6b6b6b;\n  --text-tertiary: #808080;\n  --border-color: #d4d4d4;\n\n  /* Default style (classic) */\n  --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);\n  --glow-color: transparent;\n\n  font-family: 'Inter', system-ui, sans-serif;\n  line-height: 1.5;\n  font-weight: 400;\n  font-synthesis: none;\n  text-rendering: optimizeLegibility;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n/* Dark mode base */\n.dark {\n  --bg-primary: #1a1a1a;\n  --bg-secondary: #2d2d2d;\n  --bg-tertiary: #3d3d3d;\n  --text-primary: #ffffff;\n  --text-secondary: #a0a0a0;\n  --text-muted: #808080;\n  --text-tertiary: #4a4a4a;\n  --border-color: #3d3d3d;\n  --card-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);\n}\n\n/* ============================================\n   LAYER 1: BACKGROUND PALETTES\n   ============================================ */\n\n/* Light mode backgrounds */\n.bg-neutral {\n  /* Default - already set in :root */\n}\n\n.bg-warm {\n  --bg-primary: #faf8f5;\n  --bg-secondary: #fffefa;\n  --bg-tertiary: #e8e4dd;\n  --text-primary: #2d2a26;\n  --text-secondary: #5c5750;\n  --text-muted: #7a756c;\n  --text-tertiary: #9a9590;\n  --border-color: #d8d4cc;\n}\n\n.bg-cool {\n  --bg-primary: #f0f4f8;\n  --bg-secondary: #ffffff;\n  --bg-tertiary: #dce4ec;\n  --text-primary: #1a2530;\n  --text-secondary: #4a5568;\n  --text-muted: #6b7a8a;\n  --text-tertiary: #8a9aaa;\n  --border-color: #c8d4e0;\n}\n\n/* Dark mode backgrounds */\n.dark.bg-neutral {\n  --bg-primary: #1a1a1a;\n  --bg-secondary: #2d2d2d;\n  --bg-tertiary: #3d3d3d;\n  --text-primary: #ffffff;\n  --text-secondary: #a0a0a0;\n  --text-muted: #808080;\n  --text-tertiary: #4a4a4a;\n  --border-color: #3d3d3d;\n}\n\n.dark.bg-warm {\n  --bg-primary: #1c1a18;\n  --bg-secondary: #2e2a26;\n  --bg-tertiary: #3e3a36;\n  --text-primary: #f5f0ea;\n  --text-secondary: #b0a898;\n  --text-muted: #8a8278;\n  --text-tertiary: #5a5248;\n  --border-color: #3e3a36;\n}\n\n.dark.bg-cool {\n  --bg-primary: #181c20;\n  --bg-secondary: #262c32;\n  --bg-tertiary: #363e46;\n  --text-primary: #f0f4f8;\n  --text-secondary: #98a8b8;\n  --text-muted: #788898;\n  --text-tertiary: #4a5a6a;\n  --border-color: #363e46;\n}\n\n.dark.bg-oled {\n  --bg-primary: #000000;\n  --bg-secondary: #141414;\n  --bg-tertiary: #1f1f1f;\n  --text-primary: #ffffff;\n  --text-secondary: #a0a0a0;\n  --text-muted: #707070;\n  --text-tertiary: #404040;\n  --border-color: #2a2a2a;\n}\n\n.dark.bg-slate {\n  --bg-primary: #0f172a;\n  --bg-secondary: #1e293b;\n  --bg-tertiary: #334155;\n  --text-primary: #f1f5f9;\n  --text-secondary: #94a3b8;\n  --text-muted: #64748b;\n  --text-tertiary: #475569;\n  --border-color: #334155;\n}\n\n.dark.bg-forest {\n  --bg-primary: #121a16;\n  --bg-secondary: #1c2a22;\n  --bg-tertiary: #2a3d30;\n  --text-primary: #e8f5ec;\n  --text-secondary: #8aa894;\n  --text-muted: #6a8874;\n  --text-tertiary: #4a6854;\n  --border-color: #2a3d30;\n}\n\n/* Printer card control buttons: stack only when they clip */\n.printer-control-buttons-container {\n  container-type: inline-size;\n}\n\n@container (max-width: 220px) {\n  .printer-control-buttons {\n    flex-direction: column;\n    align-items: stretch;\n  }\n\n  .printer-control-buttons > button {\n    width: 100%;\n  }\n}\n\n/* ============================================\n   LAYER 2: STYLE EFFECTS\n   ============================================ */\n\n/* Classic - default, clean minimal shadows */\n.style-classic {\n  /* Uses default shadows from :root and .dark */\n}\n\n/* Glow - accent-colored glow effects on cards */\n.style-glow {\n  --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 25px color-mix(in srgb, var(--accent) 12%, transparent);\n}\n\n.dark.style-glow {\n  --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 40px color-mix(in srgb, var(--accent) 15%, transparent);\n}\n\n/* Vibrant - dramatic deep shadows, more contrast */\n.style-vibrant {\n  --card-shadow: 0 8px 30px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1);\n}\n\n.dark.style-vibrant {\n  --card-shadow: 0 10px 40px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4);\n}\n\n/* ============================================\n   LAYER 3: ACCENT COLORS\n   ============================================ */\n\n.accent-green {\n  --accent: #00ae42;\n  --accent-light: #00c64d;\n  --accent-dark: #009438;\n}\n\n.accent-teal {\n  --accent: #14b8a6;\n  --accent-light: #2dd4bf;\n  --accent-dark: #0d9488;\n}\n\n.accent-blue {\n  --accent: #3b82f6;\n  --accent-light: #60a5fa;\n  --accent-dark: #2563eb;\n}\n\n.accent-orange {\n  --accent: #f97316;\n  --accent-light: #fb923c;\n  --accent-dark: #ea580c;\n}\n\n.accent-purple {\n  --accent: #8b5cf6;\n  --accent-light: #a78bfa;\n  --accent-dark: #7c3aed;\n}\n\n.accent-red {\n  --accent: #ef4444;\n  --accent-light: #f87171;\n  --accent-dark: #dc2626;\n}\n\nbody {\n  background-color: var(--bg-primary);\n  color: var(--text-primary);\n  margin: 0;\n  min-height: 100vh;\n  transition: background-color 0.2s ease, color 0.2s ease;\n}\n\n#root {\n  min-height: 100vh;\n}\n\n/* Override text-white to be theme-aware */\n.text-white {\n  color: var(--text-primary);\n}\n\n/* Smooth transitions for theme changes */\n.bg-bambu-dark,\n.bg-bambu-dark-secondary,\n.bg-bambu-dark-tertiary,\n.border-bambu-dark-tertiary {\n  transition: background-color 0.2s ease, border-color 0.2s ease;\n}\n\n/* Toast slide-in animation */\n@keyframes slide-in {\n  from {\n    transform: translateX(100%);\n    opacity: 0;\n  }\n  to {\n    transform: translateX(0);\n    opacity: 1;\n  }\n}\n\n.animate-slide-in {\n  animation: slide-in 0.2s ease-out;\n}\n\n/* Theme-aware icon inversion - only invert in dark mode */\n.icon-theme {\n  opacity: 0.5;\n}\n\n.dark .icon-theme {\n  filter: invert(1);\n  opacity: 0.4;\n}\n\n/* Green-colored icon for active status indicators */\n.icon-green {\n  filter: invert(48%) sepia(89%) saturate(459%) hue-rotate(93deg) brightness(95%) contrast(92%);\n  opacity: 1;\n}\n\n/* Orange/red-colored icon for heating indicators */\n.icon-heating {\n  filter: invert(50%) sepia(100%) saturate(1000%) hue-rotate(360deg) brightness(100%) contrast(100%);\n  opacity: 1;\n}\n\n/* Jogpad theme styling - darken background in dark mode */\n.jogpad-theme {\n  /* Light mode - normal */\n}\n\n.dark .jogpad-theme {\n  filter: brightness(0.35) contrast(1.2);\n}\n\n/* Empty AMS slot with diagonal stripes */\n.ams-empty-slot {\n  background: repeating-linear-gradient(\n    45deg,\n    #444,\n    #444 2px,\n    #222 2px,\n    #222 4px\n  );\n}\n\n.dark .ams-empty-slot {\n  background: repeating-linear-gradient(\n    45deg,\n    #555,\n    #555 2px,\n    #333 2px,\n    #333 4px\n  );\n}\n\n/* Touch manipulation to prevent zoom on double-tap */\n.touch-manipulation {\n  touch-action: manipulation;\n}\n\n/* Safe area insets for notched devices */\n.safe-area-bottom {\n  padding-bottom: env(safe-area-inset-bottom, 0);\n}\n\n.safe-area-top {\n  padding-top: env(safe-area-inset-top, 0);\n}\n\n/* Hide scrollbar but keep functionality */\n.scrollbar-hide {\n  -ms-overflow-style: none;\n  scrollbar-width: none;\n}\n\n.scrollbar-hide::-webkit-scrollbar {\n  display: none;\n}\n\n/* Mobile drawer animation */\n@keyframes slide-in-left {\n  from {\n    transform: translateX(-100%);\n  }\n  to {\n    transform: translateX(0);\n  }\n}\n\n.animate-slide-in-left {\n  animation: slide-in-left 0.3s ease-out;\n}\n\n/* SpoolBuddy idle scan animation: transform/opacity only for low-power devices */\n@keyframes spoolbuddy-optimized-ping {\n  0% {\n    transform: scale(0.8);\n    opacity: 0.8;\n  }\n  80%,\n  100% {\n    transform: scale(2.2);\n    opacity: 0;\n  }\n}\n\n.spoolbuddy-optimized-ping {\n  will-change: auto;\n  contain: layout;\n  animation: spoolbuddy-optimized-ping 3.5s cubic-bezier(0, 0, 0.2, 1) infinite;\n}\n\n.spoolbuddy-spool-glow {\n  transform: scale(2.0);\n  will-change: auto;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .spoolbuddy-optimized-ping {\n    animation: none;\n    opacity: 0.25;\n    transform: scale(1.2);\n  }\n\n  .spoolbuddy-spool-glow {\n    transition: none;\n  }\n}\n\n/* SpoolBuddy quick menu slide-down */\n@keyframes slide-down {\n  from {\n    transform: translateY(-100%);\n  }\n  to {\n    transform: translateY(0);\n  }\n}\n\n.animate-slide-down {\n  animation: slide-down 0.25s ease-out;\n}\n\n/* Card shadows - uses theme-specific shadow */\n.card-shadow {\n  box-shadow: var(--card-shadow);\n}\n\n/* ============================================\n   SPOOLBUDDY KIOSK MODAL CONSTRAINTS\n   Cap large modals (max-w-2xl) to viewport on the\n   small SpoolBuddy touchscreen. Excludes smaller\n   modals like the slot action picker (max-w-sm).\n   ============================================ */\n[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl {\n  height: 90vh;\n  max-height: 90vh;\n  display: flex;\n  flex-direction: column;\n}\n\n[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl > div:first-child {\n  flex-shrink: 0;\n}\n\n[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl > div:last-child {\n  flex-shrink: 0;\n}\n\n[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl > div:nth-child(2) {\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow-y: auto;\n}\n\n/* Remove inner spool grid max-height to avoid nested scrollbars */\n[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl .max-h-96 {\n  max-height: none;\n}\n\n/* Calendar selected-day list scrollbar theming */\n.calendar-scroll {\n  scrollbar-width: thin;\n  scrollbar-color: color-mix(in srgb, var(--text-muted) 60%, transparent) transparent;\n}\n\n.calendar-scroll::-webkit-scrollbar {\n  width: 8px;\n}\n\n.calendar-scroll::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.calendar-scroll::-webkit-scrollbar-thumb {\n  background-color: color-mix(in srgb, var(--text-muted) 60%, transparent);\n  border-radius: 999px;\n  border: 2px solid color-mix(in srgb, var(--bg-secondary) 70%, transparent);\n}\n\n.calendar-scroll::-webkit-scrollbar-thumb:hover {\n  background-color: color-mix(in srgb, var(--text-muted) 80%, transparent);\n}\n"
  },
  {
    "path": "frontend/src/lib/settingsSearch.ts",
    "content": "// Settings search registry.\n//\n// Each settings card/section registers itself at module-import time by calling\n// `registerSettingsSearch(...)` at module scope (NOT inside a component).\n// SettingsPage reads the accumulated registry to power its cross-tab search.\n//\n// Convention: co-locate the registration call with the component/section that\n// owns the `anchor` id. When you add a new settings card, add one call here\n// next to it — no central index to forget to update.\n\nexport type SettingsSearchTab =\n  | 'general'\n  | 'plugs'\n  | 'notifications'\n  | 'queue'\n  | 'filament'\n  | 'network'\n  | 'apikeys'\n  | 'virtual-printer'\n  | 'spoolbuddy'\n  | 'users'\n  | 'backup'\n  | 'failure-detection';\n\nexport type SettingsSearchSubTab = 'users' | 'email' | 'ldap' | 'oidc' | 'twofa';\n\nexport interface SettingsSearchEntry {\n  /** i18n key for the label. Resolved with t() at render time. */\n  labelKey: string;\n  /** Fallback label if the i18n key is missing. */\n  labelFallback?: string;\n  tab: SettingsSearchTab;\n  subTab?: SettingsSearchSubTab;\n  /** Space-separated extra search terms (lowercase). */\n  keywords: string;\n  /** DOM id attached to the target card — used for scrollIntoView. */\n  anchor: string;\n}\n\nconst entries = new Map<string, SettingsSearchEntry>();\n\nexport function registerSettingsSearch(entry: SettingsSearchEntry): void {\n  entries.set(entry.anchor, entry);\n}\n\nexport function getSettingsSearchEntries(): SettingsSearchEntry[] {\n  return Array.from(entries.values());\n}\n"
  },
  {
    "path": "frontend/src/main.tsx",
    "content": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.css'\nimport './i18n' // Initialize i18n\nimport App from './App.tsx'\n\ncreateRoot(document.getElementById('root')!).render(\n  <StrictMode>\n    <App />\n  </StrictMode>,\n)\n"
  },
  {
    "path": "frontend/src/pages/ArchivesPage.tsx",
    "content": "import { useState, useRef, useEffect, useCallback } from 'react';\nimport { Link } from 'react-router-dom';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Download,\n  Trash2,\n  Clock,\n  Package,\n  Coins,\n  Layers,\n  Search,\n  Filter,\n  Image,\n  Box,\n  Printer,\n  Upload,\n  ExternalLink,\n  CheckSquare,\n  Square,\n  X,\n  Globe,\n  Pencil,\n  LayoutGrid,\n  List,\n  CalendarDays,\n  ArrowUpDown,\n  Star,\n  Tag,\n  StickyNote,\n  FolderOpen,\n  Calendar,\n  AlertCircle,\n  Copy,\n  Film,\n  ScanSearch,\n  QrCode,\n  Camera,\n  FileText,\n  FileCode,\n  MoreVertical,\n  FileSpreadsheet,\n  GitCompare,\n  GitBranch,\n  Loader2,\n  FolderKanban,\n  ChevronLeft,\n  ChevronRight,\n  ChevronsLeft,\n  ChevronsRight,\n  Settings,\n  User,\n  Play,\n  ClipboardList,\n  Zap,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport { openInSlicer, type SlicerType } from '../utils/slicer';\nimport { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';\nimport { getCurrencySymbol } from '../utils/currency';\nimport { useIsMobile } from '../hooks/useIsMobile';\nimport type { Archive, ProjectListItem } from '../api/client';\nimport { Card, CardContent } from '../components/Card';\nimport { Button } from '../components/Button';\nimport { ModelViewerModal } from '../components/ModelViewerModal';\nimport { PrintModal } from '../components/PrintModal';\nimport { UploadModal } from '../components/UploadModal';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { EditArchiveModal } from '../components/EditArchiveModal';\nimport { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';\nimport { BatchTagModal } from '../components/BatchTagModal';\nimport { BatchProjectModal } from '../components/BatchProjectModal';\nimport { CalendarView } from '../components/CalendarView';\nimport { QRCodeModal } from '../components/QRCodeModal';\nimport { PhotoGalleryModal } from '../components/PhotoGalleryModal';\nimport { ProjectPageModal } from '../components/ProjectPageModal';\nimport { TimelapseViewer } from '../components/TimelapseViewer';\nimport { CompareArchivesModal } from '../components/CompareArchivesModal';\nimport { PendingUploadsPanel } from '../components/PendingUploadsPanel';\nimport { TagManagementModal } from '../components/TagManagementModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { formatFileSize } from '../utils/file';\n\ntype TFunction = (key: string, options?: Record<string, unknown>) => string;\n\n/**\n * Check if an archive represents a sliced/printable file.\n * Uses filename (.gcode, .gcode.3mf) as primary check, then falls back to\n * metadata — a .3mf with total_layers or print_time is sliced (contains gcode),\n * while a raw source .3mf (CAD export) has neither.\n */\nfunction isSlicedFile(archive: { filename?: string | null; total_layers?: number | null; print_time_seconds?: number | null }): boolean {\n  const filename = archive.filename;\n  if (filename) {\n    const lower = filename.toLowerCase();\n    if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return true;\n  }\n  // .3mf can be either sliced or source — check for gcode metadata\n  if (archive.total_layers || archive.print_time_seconds) return true;\n  return false;\n}\n\nfunction getArchiveFileType(filename: string | null | undefined): string | undefined {\n  if (!filename) return undefined;\n  const lower = filename.toLowerCase();\n  if (lower.endsWith('.3mf')) return '3mf';\n  if (lower.endsWith('.stl')) return 'stl';\n  if (lower.endsWith('.gcode') || lower.includes('.gcode.')) return 'gcode';\n  return lower.split('.').pop();\n}\n\n// formatDate imported from '../utils/date' - handles UTC conversion\n\n/**\n * Open an archive file in the slicer.\n * Fetches a short-lived download token, then builds a token-authenticated URL\n * that bypasses auth middleware (slicer protocol handlers can't send auth headers).\n */\nasync function openInSlicerWithToken(\n  archiveId: number,\n  filename: string,\n  resourceType: 'file' | 'source',\n  slicer: SlicerType,\n): Promise<void> {\n  try {\n    if (resourceType === 'source') {\n      const { token } = await api.createSourceSlicerToken(archiveId);\n      const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);\n      openInSlicer(`${window.location.origin}${path}`, slicer);\n    } else {\n      const { token } = await api.createArchiveSlicerToken(archiveId);\n      const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);\n      openInSlicer(`${window.location.origin}${path}`, slicer);\n    }\n  } catch {\n    // Fallback to direct URL (works when auth is disabled)\n    const path = resourceType === 'source'\n      ? api.getSource3mfForSlicer(archiveId, filename)\n      : api.getArchiveForSlicer(archiveId, filename);\n    openInSlicer(`${window.location.origin}${path}`, slicer);\n  }\n}\n\nfunction ArchiveCard({\n  archive,\n  printerName,\n  isSelected,\n  onSelect,\n  selectionMode,\n  projects,\n  isHighlighted,\n  timeFormat = 'system',\n  preferredSlicer = 'bambu_studio',\n  currency,\n  t,\n  onNavigateToArchive,\n}: {\n  archive: Archive;\n  printerName: string;\n  isSelected: boolean;\n  onSelect: (id: number) => void;\n  selectionMode: boolean;\n  projects: ProjectListItem[] | undefined;\n  isHighlighted?: boolean;\n  timeFormat?: TimeFormat;\n  preferredSlicer?: SlicerType;\n  currency: string;\n  t: TFunction;\n  onNavigateToArchive?: (archiveId: number) => void;\n}) {\n  // Debug: log when card is highlighted\n  if (isHighlighted) {\n    console.log('ArchiveCard isHighlighted=true for archive:', archive.id);\n  }\n\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission, canModify } = useAuth();\n  const isMobile = useIsMobile();\n  const [showViewer, setShowViewer] = useState(false);\n  const [showReprint, setShowReprint] = useState(false);\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [showEdit, setShowEdit] = useState(false);\n  const [showTimelapse, setShowTimelapse] = useState(false);\n  const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);\n  const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);\n  const [showQRCode, setShowQRCode] = useState(false);\n  const [showPhotos, setShowPhotos] = useState(false);\n  const [showProjectPage, setShowProjectPage] = useState(false);\n  const [showSchedule, setShowSchedule] = useState(false);\n  const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);\n  const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);\n  const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);\n  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);\n  const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);\n  const [showPlateNav, setShowPlateNav] = useState(false);\n  const source3mfInputRef = useRef<HTMLInputElement>(null);\n  const f3dInputRef = useRef<HTMLInputElement>(null);\n  const timelapseInputRef = useRef<HTMLInputElement>(null);\n\n  // Fetch plates data for multi-plate browsing (lazy - only when hovering)\n  const { data: platesData } = useQuery({\n    queryKey: ['archive-plates', archive.id],\n    queryFn: () => api.getArchivePlates(archive.id),\n    enabled: showPlateNav, // Only fetch when user hovers to see navigation\n    staleTime: 5 * 60 * 1000, // Cache for 5 minutes\n  });\n\n  // Use pre-computed duplicate sequence and original archive ID from list response\n  const duplicateSequence = archive.duplicate_sequence ?? 0;\n  const originalArchiveId = archive.original_archive_id ?? null;\n\n  const plates = platesData?.plates ?? [];\n  const isMultiPlate = platesData?.is_multi_plate ?? false;\n  const displayPlateIndex = currentPlateIndex ?? 0;\n\n  const timelapseDeleteMutation = useMutation({\n    mutationFn: () => api.deleteArchiveTimelapse(archive.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.timelapseRemoved'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error');\n    },\n  });\n\n  const timelapseUploadMutation = useMutation({\n    mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.timelapseUploaded', { filename: data.filename }));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error');\n    },\n  });\n\n  const source3mfUploadMutation = useMutation({\n    mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.source3mfAttached', { filename: data.filename }));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedUploadSource3mf'), 'error');\n    },\n  });\n\n  const source3mfDeleteMutation = useMutation({\n    mutationFn: () => api.deleteSource3mf(archive.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.source3mfRemoved'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedRemoveSource3mf'), 'error');\n    },\n  });\n\n  const f3dUploadMutation = useMutation({\n    mutationFn: (file: File) => api.uploadF3d(archive.id, file),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.f3dAttached', { filename: data.filename }));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedUploadF3d'), 'error');\n    },\n  });\n\n  const f3dDeleteMutation = useMutation({\n    mutationFn: () => api.deleteF3d(archive.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.f3dRemoved'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedRemoveF3d'), 'error');\n    },\n  });\n\n  const timelapseScanMutation = useMutation({\n    mutationFn: () => api.scanArchiveTimelapse(archive.id),\n    onSuccess: (data) => {\n      if (data.status === 'attached') {\n        queryClient.invalidateQueries({ queryKey: ['archives'] });\n        showToast(t('archives.toast.timelapseAttached', { filename: data.filename }));\n      } else if (data.status === 'exists') {\n        showToast(t('archives.toast.timelapseAlreadyAttached'));\n      } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) {\n        // Show selection dialog\n        setAvailableTimelapses(data.available_files);\n        setShowTimelapseSelect(true);\n      } else {\n        showToast(data.message || t('archives.toast.noMatchingTimelapse'), 'warning');\n      }\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedScanTimelapse'), 'error');\n    },\n  });\n\n  const timelapseSelectMutation = useMutation({\n    mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.timelapseAttached', { filename: data.filename }));\n      setShowTimelapseSelect(false);\n      setAvailableTimelapses([]);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedAttachTimelapse'), 'error');\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: () => api.deleteArchive(archive.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.archiveDeleted'));\n    },\n    onError: () => {\n      showToast(t('archives.toast.failedDeleteArchive'), 'error');\n    },\n  });\n\n  const favoriteMutation = useMutation({\n    mutationFn: () => api.toggleFavorite(archive.id),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(data.is_favorite ? t('archives.toast.addedToFavorites') : t('archives.toast.removedFromFavorites'));\n    },\n  });\n\n  // Query for linked folders\n  const { data: linkedFolders } = useQuery({\n    queryKey: ['archive-folders', archive.id],\n    queryFn: () => api.getLibraryFoldersByArchive(archive.id),\n  });\n\n  const assignProjectMutation = useMutation({\n    mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      queryClient.invalidateQueries({ queryKey: ['projects'] });\n      showToast(t('archives.toast.projectUpdated'));\n    },\n    onError: () => {\n      showToast(t('archives.toast.failedUpdateProject'), 'error');\n    },\n  });\n\n  const handleContextMenu = (e: React.MouseEvent) => {\n    e.preventDefault();\n    setContextMenu({ x: e.clientX, y: e.clientY });\n  };\n\n  const isGcodeFile = isSlicedFile(archive);\n\n  const contextMenuItems: ContextMenuItem[] = [\n    // For gcode files: show Print option\n    // For source files: show Slice as the primary action\n    ...(isGcodeFile ? [\n      {\n        label: t('archives.menu.print'),\n        icon: <Printer className=\"w-4 h-4\" />,\n        onClick: () => setShowReprint(true),\n        disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id),\n        title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,\n      },\n      {\n        label: t('archives.menu.schedule'),\n        icon: <Calendar className=\"w-4 h-4\" />,\n        onClick: () => setShowSchedule(true),\n        disabled: !archive.file_path || !hasPermission('queue:create'),\n        title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,\n      },\n      {\n        label: t('archives.menu.openInBambuStudio'),\n        icon: <ExternalLink className=\"w-4 h-4\" />,\n        onClick: () => {\n          const filename = archive.print_name || archive.filename || 'model';\n          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);\n        },\n        disabled: !archive.file_path,\n        title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,\n      },\n    ] : [\n      {\n        label: t('archives.menu.slice'),\n        icon: <ExternalLink className=\"w-4 h-4\" />,\n        onClick: () => {\n          const filename = archive.print_name || archive.filename || 'model';\n          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);\n        },\n      },\n    ]),\n    {\n      label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),\n      icon: <Globe className=\"w-4 h-4\" />,\n      onClick: () => {\n        const url = archive.external_url || archive.makerworld_url;\n        if (url) window.open(url, '_blank');\n      },\n      disabled: !archive.external_url && !archive.makerworld_url,\n    },\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: t('archives.menu.preview3d'),\n      icon: <Box className=\"w-4 h-4\" />,\n      onClick: () => setShowViewer(true),\n    },\n    {\n      label: t('archives.menu.viewTimelapse'),\n      icon: <Film className=\"w-4 h-4\" />,\n      onClick: () => setShowTimelapse(true),\n      disabled: !archive.timelapse_path,\n    },\n    {\n      label: t('archives.menu.scanForTimelapse'),\n      icon: <ScanSearch className=\"w-4 h-4\" />,\n      onClick: () => timelapseScanMutation.mutate(),\n      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    {\n      label: t('archives.menu.uploadTimelapse'),\n      icon: <Upload className=\"w-4 h-4\" />,\n      onClick: () => timelapseInputRef.current?.click(),\n      disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    ...(archive.timelapse_path ? [{\n      label: t('archives.menu.removeTimelapse'),\n      icon: <Trash2 className=\"w-4 h-4\" />,\n      onClick: () => setShowDeleteTimelapseConfirm(true),\n      danger: true,\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    }] : []),\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),\n      icon: <FileCode className=\"w-4 h-4\" />,\n      onClick: () => {\n        if (archive.source_3mf_path) {\n          api.downloadSource3mf(archive.id).catch((err) => {\n            console.error('Source 3MF download failed:', err);\n          });\n        } else {\n          source3mfInputRef.current?.click();\n        }\n      },\n      disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id),\n      title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUploadFiles') : undefined,\n    },\n    ...(archive.source_3mf_path ? [{\n      label: t('archives.menu.replaceSource3mf'),\n      icon: <Upload className=\"w-4 h-4\" />,\n      onClick: () => source3mfInputRef.current?.click(),\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    {\n      label: t('archives.menu.removeSource3mf'),\n      icon: <Trash2 className=\"w-4 h-4\" />,\n      onClick: () => setShowDeleteSource3mfConfirm(true),\n      danger: true,\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    }] : []),\n    {\n      label: archive.f3d_path ? t('archives.menu.replaceF3d') : t('archives.menu.uploadF3d'),\n      icon: <Box className=\"w-4 h-4\" />,\n      onClick: () => f3dInputRef.current?.click(),\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    ...(archive.f3d_path ? [{\n      label: t('archives.menu.downloadF3d'),\n      icon: <Download className=\"w-4 h-4\" />,\n      onClick: () => {\n        api.downloadF3d(archive.id).catch((err) => {\n          console.error('F3D download failed:', err);\n        });\n      },\n    },\n    {\n      label: t('archives.menu.removeF3d'),\n      icon: <Trash2 className=\"w-4 h-4\" />,\n      onClick: () => setShowDeleteF3dConfirm(true),\n      danger: true,\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    }] : []),\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: t('archives.menu.download'),\n      icon: <Download className=\"w-4 h-4\" />,\n      onClick: () => {\n        api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {\n          console.error('Archive download failed:', err);\n        });\n      },\n      disabled: !hasPermission('archives:read'),\n      title: !hasPermission('archives:read') ? t('archives.permission.noDownload') : undefined,\n    },\n    {\n      label: t('archives.menu.copyDownloadLink'),\n      icon: <Copy className=\"w-4 h-4\" />,\n      onClick: () => {\n        const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`;\n        navigator.clipboard.writeText(url).then(() => {\n          showToast(t('archives.toast.linkCopied'));\n        }).catch(() => {\n          showToast(t('archives.toast.failedCopyLink'), 'error');\n        });\n      },\n      disabled: !hasPermission('archives:read'),\n      title: !hasPermission('archives:read') ? t('archives.permission.noCopyLink') : undefined,\n    },\n    {\n      label: t('archives.menu.qrCode'),\n      icon: <QrCode className=\"w-4 h-4\" />,\n      onClick: () => setShowQRCode(true),\n    },\n    {\n      label: archive.photos?.length ? t('archives.menu.viewPhotosCount', { count: archive.photos.length }) : t('archives.menu.viewPhotos'),\n      icon: <Camera className=\"w-4 h-4\" />,\n      onClick: () => setShowPhotos(true),\n      disabled: !archive.photos?.length,\n    },\n    {\n      label: t('archives.menu.projectPage'),\n      icon: <FileText className=\"w-4 h-4\" />,\n      onClick: () => setShowProjectPage(true),\n    },\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'),\n      icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,\n      onClick: () => favoriteMutation.mutate(),\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    {\n      label: t('archives.menu.edit'),\n      icon: <Pencil className=\"w-4 h-4\" />,\n      onClick: () => setShowEdit(true),\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    ...(archive.project_id && archive.project_name ? [{\n      label: t('archives.menu.goToProject', { name: archive.project_name }),\n      icon: <FolderKanban className=\"w-4 h-4 text-bambu-green\" />,\n      onClick: () => window.location.href = '/projects',\n    }] : []),\n    {\n      label: t('archives.menu.addToProject'),\n      icon: <FolderKanban className=\"w-4 h-4\" />,\n      onClick: () => {},\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n      submenu: (() => {\n        const items: ContextMenuItem[] = [];\n\n        // Add \"Remove from Project\" if archive is in a project\n        if (archive.project_id) {\n          items.push({\n            label: t('archives.menu.removeFromProject'),\n            icon: <X className=\"w-4 h-4\" />,\n            onClick: () => assignProjectMutation.mutate(null),\n            disabled: !canModify('archives', 'update', archive.created_by_id),\n          });\n        }\n\n        // Add project options\n        if (!projects) {\n          items.push({\n            label: t('archives.menu.loading'),\n            icon: <Loader2 className=\"w-4 h-4 animate-spin\" />,\n            onClick: () => {},\n            disabled: true,\n          });\n        } else {\n          const activeProjects = projects.filter(p => p.status === 'active');\n          if (activeProjects.length === 0) {\n            items.push({\n              label: t('archives.menu.noProjectsAvailable'),\n              icon: <FolderKanban className=\"w-4 h-4 opacity-50\" />,\n              onClick: () => {},\n              disabled: true,\n            });\n          } else {\n            activeProjects.forEach(p => {\n              items.push({\n                label: p.name,\n                icon: <div className=\"w-3 h-3 rounded-full flex-shrink-0\" style={{ backgroundColor: p.color || '#888' }} />,\n                onClick: () => assignProjectMutation.mutate(p.id),\n                disabled: archive.project_id === p.id || !canModify('archives', 'update', archive.created_by_id),\n              });\n            });\n          }\n        }\n\n        return items;\n      })(),\n    },\n    {\n      label: isSelected ? t('archives.menu.deselect') : t('archives.menu.select'),\n      icon: isSelected ? <CheckSquare className=\"w-4 h-4\" /> : <Square className=\"w-4 h-4\" />,\n      onClick: () => onSelect(archive.id),\n    },\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: t('archives.menu.delete'),\n      icon: <Trash2 className=\"w-4 h-4\" />,\n      onClick: () => setShowDeleteConfirm(true),\n      danger: true,\n      disabled: !canModify('archives', 'delete', archive.created_by_id),\n      title: !canModify('archives', 'delete', archive.created_by_id) ? t('archives.permission.noDelete') : undefined,\n    },\n  ];\n\n  return (\n    <Card\n      data-archive-id={archive.id}\n      className={`relative flex flex-col group ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}\n      style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}\n      onContextMenu={handleContextMenu}\n      onClick={selectionMode ? () => onSelect(archive.id) : undefined}\n    >\n      {/* Selection checkbox */}\n      {selectionMode && (\n        <button\n          className=\"absolute top-2 left-2 z-10 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors\"\n          onClick={(e) => { e.stopPropagation(); onSelect(archive.id); }}\n        >\n          {isSelected ? (\n            <CheckSquare className=\"w-5 h-5 text-bambu-green\" />\n          ) : (\n            <Square className=\"w-5 h-5 text-white\" />\n          )}\n        </button>\n      )}\n\n      {/* Thumbnail with plate navigation */}\n      <div\n        className=\"aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl\"\n        onMouseEnter={() => setShowPlateNav(true)}\n        onMouseLeave={() => setShowPlateNav(false)}\n      >\n        {archive.thumbnail_path ? (\n          <img\n            src={\n              currentPlateIndex !== null && plates.length > 0\n                ? api.getArchivePlateThumbnail(archive.id, plates[displayPlateIndex]?.index ?? 0)\n                : api.getArchiveThumbnail(archive.id)\n            }\n            alt={archive.print_name || archive.filename}\n            className=\"w-full h-full object-cover\"\n          />\n        ) : (\n          <div className=\"w-full h-full flex items-center justify-center\">\n            <Image className=\"w-12 h-12 text-bambu-dark-tertiary\" />\n          </div>\n        )}\n        {/* Plate navigation - only show for multi-plate archives */}\n        {isMultiPlate && plates.length > 1 && (\n          <>\n            {/* Left arrow */}\n            <button\n              className={`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${\n                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'\n              }`}\n              onClick={(e) => {\n                e.stopPropagation();\n                setCurrentPlateIndex((prev) => {\n                  const current = prev ?? 0;\n                  return current > 0 ? current - 1 : plates.length - 1;\n                });\n              }}\n              title={t('archives.card.previousPlate')}\n            >\n              <ChevronLeft className=\"w-4 h-4 text-white\" />\n            </button>\n            {/* Right arrow */}\n            <button\n              className={`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${\n                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'\n              }`}\n              onClick={(e) => {\n                e.stopPropagation();\n                setCurrentPlateIndex((prev) => {\n                  const current = prev ?? 0;\n                  return current < plates.length - 1 ? current + 1 : 0;\n                });\n              }}\n              title={t('archives.card.nextPlate')}\n            >\n              <ChevronRight className=\"w-4 h-4 text-white\" />\n            </button>\n            {/* Dots indicator */}\n            <div\n              className={`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${\n                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'\n              }`}\n            >\n              {plates.map((plate, idx) => (\n                <button\n                  key={plate.index}\n                  className={`w-2 h-2 rounded-full transition-colors ${\n                    idx === displayPlateIndex ? 'bg-bambu-green' : 'bg-white/50 hover:bg-white/80'\n                  }`}\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setCurrentPlateIndex(idx);\n                  }}\n                  title={plate.name || t('archives.card.plateNumber', { index: plate.index })}\n                />\n              ))}\n            </div>\n          </>\n        )}\n        {/* Context menu button - visible on mobile, shows on hover for desktop */}\n        <button\n          className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${\n            isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'\n          } ${selectionMode ? 'left-10' : ''}`}\n          onClick={(e) => {\n            e.stopPropagation();\n            const rect = e.currentTarget.getBoundingClientRect();\n            setContextMenu({ x: rect.left, y: rect.bottom + 4 });\n          }}\n          title={t('archives.card.moreOptions')}\n        >\n          <MoreVertical className=\"w-5 h-5 text-white\" />\n        </button>\n        {/* Favorite star */}\n        <button\n          className={`absolute top-2 right-2 p-1 rounded transition-colors ${\n            canModify('archives', 'update', archive.created_by_id)\n              ? 'bg-black/50 hover:bg-black/70'\n              : 'bg-black/30 cursor-not-allowed'\n          }`}\n          onClick={(e) => {\n            e.stopPropagation();\n            if (canModify('archives', 'update', archive.created_by_id)) {\n              favoriteMutation.mutate();\n            }\n          }}\n          disabled={!canModify('archives', 'update', archive.created_by_id)}\n          title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : (archive.is_favorite ? t('archives.card.removeFromFavorites') : t('archives.card.addToFavorites'))}\n        >\n          <Star\n            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!canModify('archives', 'update', archive.created_by_id) ? 'opacity-50' : ''}`}\n          />\n        </button>\n        {(archive.status === 'failed' || archive.status === 'aborted') && (\n          <div className=\"absolute top-2 left-12 px-2 py-1 rounded text-xs bg-status-error/80 text-white\">\n            {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}\n          </div>\n        )}\n        {/* Duplicate badge */}\n        {archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && (\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              onNavigateToArchive?.(originalArchiveId);\n            }}\n            className=\"absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 hover:bg-purple-600/90 text-white flex items-center gap-1 transition-colors cursor-pointer\"\n            title={t('archives.viewOriginalPrint', { id: originalArchiveId })}\n          >\n            <Copy className=\"w-3 h-3\" />\n            #{duplicateSequence}\n          </button>\n        )}\n        {archive.duplicate_count > 0 && duplicateSequence === 0 && (\n          <span\n            className=\"absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1\"\n            title={`${archive.duplicate_count} reprint${archive.duplicate_count === 1 ? '' : 's'}`}\n          >\n            <GitBranch className=\"w-3 h-3\" />\n            +{archive.duplicate_count}\n          </span>\n        )}\n        {/* Source 3MF badge */}\n        {archive.source_3mf_path && (\n          <button\n            className=\"absolute bottom-2 left-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors\"\n            onClick={(e) => {\n              e.stopPropagation();\n              // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility\n              const sourceName = (archive.print_name || archive.filename || 'source').replace(/\\.gcode\\.3mf$/i, '') + '_source';\n              openInSlicerWithToken(archive.id, sourceName, 'source', preferredSlicer);\n            }}\n            title={t('archives.card.openSource3mf')}\n          >\n            <FileCode className=\"w-4 h-4 text-orange-400\" />\n          </button>\n        )}\n        {/* F3D badge */}\n        {archive.f3d_path && (\n          <button\n            className={`absolute bottom-2 ${archive.source_3mf_path ? 'left-12' : 'left-2'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}\n            onClick={(e) => {\n              e.stopPropagation();\n              // Download F3D file\n              api.downloadF3d(archive.id).catch((err) => {\n                console.error('F3D download failed:', err);\n              });\n            }}\n            title={t('archives.card.downloadF3d')}\n          >\n            <Box className=\"w-4 h-4 text-cyan-400\" />\n          </button>\n        )}\n        {/* 3D preview badge */}\n        <button\n          className=\"absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors\"\n          onClick={(e) => {\n            e.stopPropagation();\n            setShowViewer(true);\n          }}\n          title={t('archives.card.preview3d')}\n        >\n          <Layers className=\"w-4 h-4 text-white\" />\n        </button>\n        {/* Timelapse badge */}\n        {archive.timelapse_path && (\n          <button\n            className=\"absolute bottom-2 right-12 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors\"\n            onClick={(e) => {\n              e.stopPropagation();\n              setShowTimelapse(true);\n            }}\n            title={t('archives.card.viewTimelapse')}\n          >\n            <Film className=\"w-4 h-4 text-bambu-green\" />\n          </button>\n        )}\n        {/* Photos badge */}\n        {archive.photos && archive.photos.length > 0 && (\n          <button\n            className={`absolute bottom-2 ${archive.timelapse_path ? 'right-[5.5rem]' : 'right-12'} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`}\n            onClick={(e) => {\n              e.stopPropagation();\n              setShowPhotos(true);\n            }}\n            title={archive.photos.length === 1 ? t('archives.card.viewPhoto') : t('archives.card.viewPhotos', { count: archive.photos.length })}\n          >\n            <Camera className=\"w-4 h-4 text-blue-400\" />\n            {archive.photos.length > 1 && (\n              <span className=\"absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full text-[10px] text-white flex items-center justify-center\">\n                {archive.photos.length}\n              </span>\n            )}\n          </button>\n        )}\n        {/* Linked folder badge */}\n        {linkedFolders && linkedFolders.length > 0 && (\n          <Link\n            to={`/files?folder=${linkedFolders[0].id}`}\n            className=\"absolute bottom-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors\"\n            onClick={(e) => e.stopPropagation()}\n            title={t('archives.card.openFolder', { name: linkedFolders[0].name })}\n            style={{ left: archive.source_3mf_path ? (archive.f3d_path ? '5.5rem' : '3rem') : (archive.f3d_path ? '3rem' : '0.5rem') }}\n          >\n            <FolderOpen className=\"w-4 h-4 text-yellow-400\" />\n          </Link>\n        )}\n      </div>\n\n      <CardContent className=\"p-4 flex-1 flex flex-col\">\n        {/* Archive ID */}\n        <p className=\"text-[10px] text-bambu-gray/70 mb-1\">#{archive.id}</p>\n\n        {/* Title */}\n        <div className=\"flex items-center justify-between gap-2 mb-1\">\n          <h3 className=\"min-w-0 font-medium text-white truncate\">\n            {archive.print_name || archive.filename}\n          </h3>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"p-1 sm:p-1.5 shrink-0\"\n            onClick={() => setShowEdit(true)}\n            disabled={!canModify('archives', 'update', archive.created_by_id)}\n            title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.card.noPermissionEdit') : t('archives.card.edit')}\n          >\n            <Pencil className=\"w-3 h-3 sm:w-4 sm:h-4\" />\n          </Button>\n        </div>\n        <div className=\"flex items-center gap-2 mb-3 flex-wrap\">\n          <p className=\"text-xs text-bambu-gray\">{printerName}</p>\n          {/* File type badge */}\n          <span\n            className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${\n              isSlicedFile(archive)\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'bg-orange-500/20 text-orange-400'\n            }`}\n            title={\n              isSlicedFile(archive)\n                ? t('archives.card.slicedFile')\n                : t('archives.card.sourceFile')\n            }\n          >\n            {isSlicedFile(archive) ? t('archives.card.gcode') : t('archives.card.source')}\n          </span>\n          {/* File hash badge */}\n          {archive.content_hash && (\n            <span\n              className=\"text-[10px] px-1.5 py-0.5 rounded font-mono bg-bambu-dark-tertiary/50 text-bambu-gray-light opacity-0 transition-opacity duration-150 group-hover:opacity-100\"\n              title={`SHA256: ${archive.content_hash}`}\n            >\n              {archive.content_hash.slice(0, 8).toUpperCase()}\n            </span>\n          )}\n          {archive.project_name && (\n            <span\n              className=\"text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]\"\n              style={{\n                backgroundColor: `${projects?.find(p => p.id === archive.project_id)?.color || '#6b7280'}20`,\n                color: projects?.find(p => p.id === archive.project_id)?.color || '#6b7280'\n              }}\n              title={t('archives.card.project', { name: archive.project_name })}\n            >\n              {archive.project_name}\n            </span>\n          )}\n        </div>\n\n        {/* Stats */}\n        <div className=\"grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]\">\n          {(archive.print_time_seconds || archive.actual_time_seconds) && (\n            <div className=\"flex items-center gap-1.5 text-bambu-gray\" title={\n              archive.time_accuracy\n                ? `Estimated: ${formatDuration(archive.print_time_seconds || 0)}\\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`\n                : archive.actual_time_seconds\n                  ? `Actual: ${formatDuration(archive.actual_time_seconds)}`\n                  : `Estimated: ${formatDuration(archive.print_time_seconds || 0)}`\n            }>\n              <Clock className=\"w-3 h-3\" />\n              {formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)}\n              {archive.time_accuracy && (\n                <span className={`text-[10px] px-1 rounded ${\n                  archive.time_accuracy >= 95 && archive.time_accuracy <= 105\n                    ? 'bg-bambu-green/20 text-bambu-green'\n                    : archive.time_accuracy > 105\n                      ? 'bg-blue-500/20 text-blue-400'\n                      : 'bg-orange-500/20 text-orange-400'\n                }`}>\n                  {archive.time_accuracy > 100 ? '+' : ''}{(archive.time_accuracy - 100).toFixed(0)}%\n                </span>\n              )}\n            </div>\n          )}\n          {archive.filament_used_grams && (\n            <div className=\"flex items-center gap-1.5 text-bambu-gray\">\n              <Package className=\"w-3 h-3\" />\n              {archive.filament_used_grams.toFixed(1)}g\n            </div>\n          )}\n          {(archive.cost != null || archive.energy_cost != null) && (\n            <div className=\"flex items-center gap-3 text-bambu-gray\">\n              {archive.cost != null && (\n                <div className=\"flex items-center gap-1.5\">\n                  <Coins className=\"w-3 h-3\" />\n                  {currency}{archive.cost.toFixed(2)}\n                </div>\n              )}\n                {archive.energy_cost != null && (\n                  <div className=\"flex items-center gap-1.5\" title={`${t('stats.energyUsed')}: ${archive.energy_kwh?.toFixed(3) || 'N/A'} kWh`}>\n                    <Zap className=\"w-3 h-3\" />\n                    {currency}{archive.energy_cost.toFixed(2)}\n                  </div>\n                )}\n            </div>\n          )}\n          {(archive.layer_height || archive.total_layers) && (\n            <div className=\"flex items-center gap-1.5 text-bambu-gray\">\n              <Layers className=\"w-3 h-3\" />\n              {archive.total_layers && <span>{archive.total_layers === 1 ? t('archives.card.layer', { count: archive.total_layers }) : t('archives.card.layers', { count: archive.total_layers })}</span>}\n              {archive.total_layers && archive.layer_height && <span className=\"text-bambu-gray/50\">·</span>}\n              {archive.layer_height && <span>{archive.layer_height}mm</span>}\n            </div>\n          )}\n          {archive.object_count != null && archive.object_count > 0 && (\n            <div className=\"flex items-center gap-1.5 text-bambu-gray\" title={archive.object_count === 1 ? t('archives.card.object', { count: archive.object_count }) : t('archives.card.objects', { count: archive.object_count })}>\n              <Box className=\"w-3 h-3\" />\n              {archive.object_count === 1 ? t('archives.card.object', { count: archive.object_count }) : t('archives.card.objects', { count: archive.object_count })}\n            </div>\n          )}\n          {archive.sliced_for_model && (\n            <div className=\"flex items-center gap-1.5 text-bambu-gray\" title={t('archives.card.slicedFor', { model: archive.sliced_for_model })}>\n              <Printer className=\"w-3 h-3\" />\n              {archive.sliced_for_model}\n            </div>\n          )}\n          {archive.filament_type && (\n            <div className=\"flex items-center gap-1.5 col-span-2\">\n              <span className=\"text-bambu-gray text-xs\">{archive.filament_type}</span>\n              {archive.filament_color && (\n                <div className=\"flex items-center gap-0.5 flex-wrap\">\n                  {archive.filament_color.split(',').map((color, i) => (\n                    <div\n                      key={i}\n                      className=\"w-3 h-3 rounded-full border border-black/20\"\n                      style={{ backgroundColor: color }}\n                      title={color}\n                    />\n                  ))}\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n\n        {/* Tags & Notes */}\n        {(archive.tags || archive.notes) && (\n          <div className=\"flex flex-wrap items-center gap-1.5 mb-3\">\n            {archive.notes && (\n              <div\n                className=\"flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs\"\n                title={archive.notes}\n              >\n                <StickyNote className=\"w-3 h-3\" />\n              </div>\n            )}\n            {archive.tags?.split(',').map((tag, i) => (\n              <span\n                key={i}\n                className=\"px-1.5 py-0.5 bg-bambu-dark-tertiary text-bambu-gray-light rounded text-xs\"\n              >\n                {tag.trim()}\n              </span>\n            ))}\n          </div>\n        )}\n\n        {/* Spacer to push content to bottom */}\n        <div className=\"flex-1\" />\n\n        {/* Date, Size & Creator */}\n        <div className=\"flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3\">\n          <span>{formatDateTime(archive.created_at, timeFormat)}</span>\n          <div className=\"flex items-center gap-2\">\n            {archive.created_by_username && (\n              <span className=\"flex items-center gap-1\" title={t('archives.card.uploadedBy', { name: archive.created_by_username })}>\n                <User className=\"w-3 h-3\" />\n                {archive.created_by_username}\n              </span>\n            )}\n            <span>{formatFileSize(archive.file_size)}</span>\n          </div>\n        </div>\n\n        {/* Actions */}\n        <div className=\"flex gap-1 mt-3\">\n          {isSlicedFile(archive) ? (\n            // Sliced file - can print directly\n            <>\n              <Button\n                variant=\"primary\"\n                size=\"sm\"\n                className=\"flex-1 min-w-0 overflow-hidden\"\n                onClick={() => setShowReprint(true)}\n                disabled={!archive.file_path || !canModify('archives', 'reprint', archive.created_by_id)}\n                title={!archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : undefined}\n              >\n                <Printer className=\"w-3 h-3 flex-shrink-0\" />\n                <span className=\"hidden sm:inline truncate\">{t('archives.card.reprint')}</span>\n              </Button>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                className=\"flex-1 min-w-0 overflow-hidden\"\n                onClick={() => setShowSchedule(true)}\n                disabled={!archive.file_path || !hasPermission('queue:create')}\n                title={!archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : t('archives.card.schedulePrint')}\n              >\n                <Calendar className=\"w-3 h-3 flex-shrink-0\" />\n                <span className=\"hidden sm:inline truncate\">{t('archives.card.schedule')}</span>\n              </Button>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                className=\"min-w-0 p-1 sm:p-1.5\"\n                onClick={() => {\n                  const filename = archive.print_name || archive.filename || 'model';\n                  openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);\n                }}\n                title={t('archives.card.openInBambuStudio')}\n              >\n                <ExternalLink className=\"w-3 h-3 sm:w-4 sm:h-4\" />\n              </Button>\n            </>\n          ) : (\n            // Source file only - must open in slicer first\n            <Button\n              variant=\"primary\"\n              size=\"sm\"\n              className=\"flex-1 min-w-0 overflow-hidden\"\n              onClick={() => {\n                const filename = archive.print_name || archive.filename || 'model';\n                openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);\n              }}\n              title={t('archives.card.openInBambuStudioToSlice')}\n            >\n              <ExternalLink className=\"w-3 h-3 flex-shrink-0\" />\n              <span className=\"hidden sm:inline truncate\">{t('archives.card.slice')}</span>\n            </Button>\n          )}\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            className=\"min-w-0 p-1 sm:p-1.5\"\n            onClick={() => {\n              const url = archive.external_url || archive.makerworld_url;\n              if (url) window.open(url, '_blank');\n            }}\n            disabled={!archive.external_url && !archive.makerworld_url}\n            title={\n              archive.external_url\n                ? t('archives.card.externalLink')\n                : archive.makerworld_url\n                  ? t('archives.card.makerWorld', { designer: archive.designer || t('archives.card.viewProject') })\n                  : t('archives.card.noExternalLink')\n            }\n          >\n            <Globe className={`w-3 h-3 sm:w-4 sm:h-4 ${!archive.external_url && !archive.makerworld_url ? 'opacity-20' : ''}`} />\n          </Button>\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            className=\"min-w-0 p-1 sm:p-1.5\"\n            onClick={() => {\n              api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {\n                console.error('Archive download failed:', err);\n              });\n            }}\n            title={t('archives.card.download')}\n          >\n            <Download className=\"w-3 h-3 sm:w-4 sm:h-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"min-w-0 p-1 sm:p-1.5\"\n            onClick={() => setShowDeleteConfirm(true)}\n            disabled={!canModify('archives', 'delete', archive.created_by_id)}\n            title={!canModify('archives', 'delete', archive.created_by_id) ? t('archives.card.noPermissionDelete') : t('archives.card.delete')}\n          >\n            <Trash2 className=\"w-3 h-3 sm:w-4 sm:h-4 text-red-400\" />\n          </Button>\n        </div>\n      </CardContent>\n\n      {/* Edit Modal */}\n      {showEdit && (\n        <EditArchiveModal\n          archive={archive}\n          onClose={() => setShowEdit(false)}\n        />\n      )}\n\n      {/* 3D Viewer Modal */}\n      {showViewer && (\n        <ModelViewerModal\n          archiveId={archive.id}\n          title={archive.print_name || archive.filename}\n          fileType={getArchiveFileType(archive.filename)}\n          onClose={() => setShowViewer(false)}\n        />\n      )}\n\n      {/* Reprint Modal */}\n      {showReprint && (\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          onClose={() => setShowReprint(false)}\n        />\n      )}\n\n      {/* Delete Confirmation */}\n      {showDeleteConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.deleteArchive')}\n          message={t('archives.modal.deleteConfirm', { name: archive.print_name || archive.filename })}\n          confirmText={t('archives.modal.deleteButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            deleteMutation.mutate();\n            setShowDeleteConfirm(false);\n          }}\n          onCancel={() => setShowDeleteConfirm(false)}\n        />\n      )}\n\n      {/* Delete Source 3MF Confirmation */}\n      {showDeleteSource3mfConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.removeSource3mf')}\n          message={t('archives.modal.removeSource3mfConfirm', { name: archive.print_name || archive.filename })}\n          confirmText={t('archives.modal.removeButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            source3mfDeleteMutation.mutate();\n            setShowDeleteSource3mfConfirm(false);\n          }}\n          onCancel={() => setShowDeleteSource3mfConfirm(false)}\n        />\n      )}\n\n      {/* Delete F3D Confirmation */}\n      {showDeleteF3dConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.removeF3d')}\n          message={t('archives.modal.removeF3dConfirm', { name: archive.print_name || archive.filename })}\n          confirmText={t('archives.modal.removeButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            f3dDeleteMutation.mutate();\n            setShowDeleteF3dConfirm(false);\n          }}\n          onCancel={() => setShowDeleteF3dConfirm(false)}\n        />\n      )}\n\n      {/* Delete Timelapse Confirmation */}\n      {showDeleteTimelapseConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.removeTimelapse')}\n          message={t('archives.modal.removeTimelapseConfirm', { name: archive.print_name || archive.filename })}\n          confirmText={t('archives.modal.removeButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            timelapseDeleteMutation.mutate();\n            setShowDeleteTimelapseConfirm(false);\n          }}\n          onCancel={() => setShowDeleteTimelapseConfirm(false)}\n        />\n      )}\n\n      {/* Context Menu */}\n      {contextMenu && (\n        <ContextMenu\n          x={contextMenu.x}\n          y={contextMenu.y}\n          items={contextMenuItems}\n          onClose={() => setContextMenu(null)}\n        />\n      )}\n\n      {/* Timelapse Viewer Modal */}\n      {showTimelapse && archive.timelapse_path && (\n        <TimelapseViewer\n          src={api.getArchiveTimelapse(archive.id)}\n          title={t('archives.modal.timelapse', { name: archive.print_name || archive.filename })}\n          downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}\n          archiveId={archive.id}\n          onClose={() => setShowTimelapse(false)}\n          onEdit={() => {\n            queryClient.invalidateQueries({ queryKey: ['archives'] });\n            setShowTimelapse(false);  // Close viewer to reload fresh video\n          }}\n        />\n      )}\n\n      {/* Timelapse Selection Modal */}\n      {showTimelapseSelect && availableTimelapses.length > 0 && (\n        <div className=\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\">\n          <div className=\"bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col\">\n            <div className=\"flex items-center justify-between p-4 border-b border-gray-700\">\n              <div>\n                <h3 className=\"text-lg font-semibold text-white\">{t('archives.modal.selectTimelapse')}</h3>\n                <p className=\"text-sm text-gray-400 mt-1\">\n                  {t('archives.modal.selectTimelapseDesc')}\n                </p>\n              </div>\n              <button\n                onClick={() => {\n                  setShowTimelapseSelect(false);\n                  setAvailableTimelapses([]);\n                }}\n                className=\"text-gray-400 hover:text-white p-1\"\n              >\n                <X className=\"w-5 h-5\" />\n              </button>\n            </div>\n            <div className=\"overflow-y-auto flex-1 p-2\">\n              {availableTimelapses.map((file) => (\n                <button\n                  key={file.name}\n                  onClick={() => timelapseSelectMutation.mutate(file.name)}\n                  disabled={timelapseSelectMutation.isPending}\n                  className=\"w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-3 disabled:opacity-50\"\n                >\n                  <Film className=\"w-8 h-8 text-bambu-green flex-shrink-0\" />\n                  <div className=\"flex-1 min-w-0\">\n                    <p className=\"text-white font-medium truncate\">{file.name}</p>\n                    <p className=\"text-sm text-gray-400\">\n                      {formatFileSize(file.size)}\n                      {file.mtime && ` • ${formatDateTime(file.mtime, timeFormat)}`}\n                    </p>\n                  </div>\n                </button>\n              ))}\n            </div>\n            <div className=\"p-4 border-t border-gray-700\">\n              <Button\n                variant=\"secondary\"\n                onClick={() => {\n                  setShowTimelapseSelect(false);\n                  setAvailableTimelapses([]);\n                }}\n                className=\"w-full\"\n              >\n                Cancel\n              </Button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* QR Code Modal */}\n      {showQRCode && (\n        <QRCodeModal\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          onClose={() => setShowQRCode(false)}\n        />\n      )}\n\n      {/* Photo Gallery Modal */}\n      {showPhotos && archive.photos && archive.photos.length > 0 && (\n        <PhotoGalleryModal\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          photos={archive.photos}\n          onClose={() => setShowPhotos(false)}\n          onDelete={async (filename) => {\n            try {\n              await api.deleteArchivePhoto(archive.id, filename);\n              queryClient.invalidateQueries({ queryKey: ['archives'] });\n              showToast(t('archives.toast.photoDeleted'));\n            } catch {\n              showToast(t('archives.toast.failedDeletePhoto'), 'error');\n            }\n          }}\n        />\n      )}\n\n      {/* Project Page Modal */}\n      {showProjectPage && (\n        <ProjectPageModal\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          onClose={() => setShowProjectPage(false)}\n        />\n      )}\n\n      {showSchedule && (\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          onClose={() => setShowSchedule(false)}\n        />\n      )}\n\n      {/* Hidden file input for source 3MF upload */}\n      <input\n        ref={source3mfInputRef}\n        type=\"file\"\n        accept=\".3mf\"\n        className=\"hidden\"\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          if (file) {\n            source3mfUploadMutation.mutate(file);\n          }\n          e.target.value = '';\n        }}\n      />\n      {/* Hidden file input for F3D upload */}\n      <input\n        ref={f3dInputRef}\n        type=\"file\"\n        accept=\".f3d\"\n        className=\"hidden\"\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          if (file) {\n            f3dUploadMutation.mutate(file);\n          }\n          e.target.value = '';\n        }}\n      />\n      {/* Hidden file input for timelapse upload */}\n      <input\n        ref={timelapseInputRef}\n        type=\"file\"\n        accept=\".mp4,.avi,.mkv\"\n        className=\"hidden\"\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          if (file) {\n            timelapseUploadMutation.mutate(file);\n          }\n          e.target.value = '';\n        }}\n      />\n    </Card>\n  );\n}\n\nfunction ArchiveListRow({\n  archive,\n  printerName,\n  isSelected,\n  onSelect,\n  selectionMode,\n  projects,\n  isHighlighted,\n  preferredSlicer = 'bambu_studio',\n  t,\n  onNavigateToArchive,\n}: {\n  archive: Archive;\n  printerName: string;\n  isSelected: boolean;\n  onSelect: (id: number) => void;\n  selectionMode: boolean;\n  projects: ProjectListItem[] | undefined;\n  isHighlighted?: boolean;\n  preferredSlicer?: SlicerType;\n  t: TFunction;\n  onNavigateToArchive?: (archiveId: number) => void;\n}) {\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission, canModify } = useAuth();\n  const [showEdit, setShowEdit] = useState(false);\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [showReprint, setShowReprint] = useState(false);\n  const [showSchedule, setShowSchedule] = useState(false);\n  const [showViewer, setShowViewer] = useState(false);\n  const [showTimelapse, setShowTimelapse] = useState(false);\n  const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);\n  const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);\n  const [showQRCode, setShowQRCode] = useState(false);\n  const [showPhotos, setShowPhotos] = useState(false);\n  const [showProjectPage, setShowProjectPage] = useState(false);\n  const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);\n  const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);\n  const [showDeleteTimelapseConfirm, setShowDeleteTimelapseConfirm] = useState(false);\n  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);\n  const source3mfInputRef = useRef<HTMLInputElement>(null);\n  const f3dInputRef = useRef<HTMLInputElement>(null);\n  const timelapseInputRef = useRef<HTMLInputElement>(null);\n\n  // Use pre-computed duplicate sequence and original archive ID from list response\n  const duplicateSequence = archive.duplicate_sequence ?? 0;\n  const originalArchiveId = archive.original_archive_id ?? null;\n\n  const timelapseDeleteMutation = useMutation({\n    mutationFn: () => api.deleteArchiveTimelapse(archive.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.timelapseRemoved'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedRemoveTimelapse'), 'error');\n    },\n  });\n\n  const timelapseUploadMutation = useMutation({\n    mutationFn: (file: File) => api.uploadArchiveTimelapse(archive.id, file),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.timelapseUploaded', { filename: data.filename }));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedUploadTimelapse'), 'error');\n    },\n  });\n\n  const source3mfUploadMutation = useMutation({\n    mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.source3mfAttached', { filename: data.filename }));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedUploadSource3mf'), 'error');\n    },\n  });\n\n  const source3mfDeleteMutation = useMutation({\n    mutationFn: () => api.deleteSource3mf(archive.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.source3mfRemoved'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedRemoveSource3mf'), 'error');\n    },\n  });\n\n  const f3dUploadMutation = useMutation({\n    mutationFn: (file: File) => api.uploadF3d(archive.id, file),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.f3dAttached', { filename: data.filename }));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedUploadF3d'), 'error');\n    },\n  });\n\n  const f3dDeleteMutation = useMutation({\n    mutationFn: () => api.deleteF3d(archive.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.f3dRemoved'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedRemoveF3d'), 'error');\n    },\n  });\n\n  const timelapseScanMutation = useMutation({\n    mutationFn: () => api.scanArchiveTimelapse(archive.id),\n    onSuccess: (data) => {\n      if (data.status === 'attached') {\n        queryClient.invalidateQueries({ queryKey: ['archives'] });\n        showToast(t('archives.toast.timelapseAttached', { filename: data.filename }));\n      } else if (data.status === 'exists') {\n        showToast(t('archives.toast.timelapseAlreadyAttached'));\n      } else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) {\n        setAvailableTimelapses(data.available_files);\n        setShowTimelapseSelect(true);\n      } else {\n        showToast(data.message || t('archives.toast.noMatchingTimelapse'), 'warning');\n      }\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedScanTimelapse'), 'error');\n    },\n  });\n\n  const timelapseSelectMutation = useMutation({\n    mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.timelapseAttached', { filename: data.filename }));\n      setShowTimelapseSelect(false);\n      setAvailableTimelapses([]);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('archives.toast.failedAttachTimelapse'), 'error');\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: () => api.deleteArchive(archive.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(t('archives.toast.archiveDeleted'));\n    },\n    onError: () => {\n      showToast(t('archives.toast.failedDeleteArchive'), 'error');\n    },\n  });\n\n  const favoriteMutation = useMutation({\n    mutationFn: () => api.toggleFavorite(archive.id),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      showToast(data.is_favorite ? t('archives.toast.addedToFavorites') : t('archives.toast.removedFromFavorites'));\n    },\n  });\n\n  // Query for linked folders\n  const { data: linkedFolders } = useQuery({\n    queryKey: ['archive-folders', archive.id],\n    queryFn: () => api.getLibraryFoldersByArchive(archive.id),\n  });\n\n  const assignProjectMutation = useMutation({\n    mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      queryClient.invalidateQueries({ queryKey: ['projects'] });\n      showToast(t('archives.toast.projectUpdated'));\n    },\n    onError: () => {\n      showToast(t('archives.toast.failedUpdateProject'), 'error');\n    },\n  });\n\n  const handleContextMenu = (e: React.MouseEvent) => {\n    e.preventDefault();\n    setContextMenu({ x: e.clientX, y: e.clientY });\n  };\n\n  const isGcodeFile = isSlicedFile(archive);\n\n  const contextMenuItems: ContextMenuItem[] = [\n    ...(isGcodeFile ? [\n      {\n        label: t('archives.menu.print'),\n        icon: <Printer className=\"w-4 h-4\" />,\n        onClick: () => setShowReprint(true),\n        disabled: !archive.file_path || !canModify('archives', 'reprint', archive.created_by_id),\n        title: !archive.file_path ? t('archives.card.noFileForReprint') : !canModify('archives', 'reprint', archive.created_by_id) ? t('archives.permission.noReprint') : undefined,\n      },\n      {\n        label: t('archives.menu.schedule'),\n        icon: <Calendar className=\"w-4 h-4\" />,\n        onClick: () => setShowSchedule(true),\n        disabled: !archive.file_path || !hasPermission('queue:create'),\n        title: !archive.file_path ? t('archives.card.noFileForReprint') : !hasPermission('queue:create') ? t('archives.permission.noAddToQueue') : undefined,\n      },\n      {\n        label: t('archives.menu.openInBambuStudio'),\n        icon: <ExternalLink className=\"w-4 h-4\" />,\n        onClick: () => {\n          const filename = archive.print_name || archive.filename || 'model';\n          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);\n        },\n        disabled: !archive.file_path,\n        title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,\n      },\n    ] : [\n      {\n        label: t('archives.menu.slice'),\n        icon: <ExternalLink className=\"w-4 h-4\" />,\n        onClick: () => {\n          const filename = archive.print_name || archive.filename || 'model';\n          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);\n        },\n      },\n    ]),\n    {\n      label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),\n      icon: <Globe className=\"w-4 h-4\" />,\n      onClick: () => {\n        const url = archive.external_url || archive.makerworld_url;\n        if (url) window.open(url, '_blank');\n      },\n      disabled: !archive.external_url && !archive.makerworld_url,\n    },\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: t('archives.menu.preview3d'),\n      icon: <Box className=\"w-4 h-4\" />,\n      onClick: () => setShowViewer(true),\n    },\n    {\n      label: t('archives.menu.viewTimelapse'),\n      icon: <Film className=\"w-4 h-4\" />,\n      onClick: () => setShowTimelapse(true),\n      disabled: !archive.timelapse_path,\n    },\n    {\n      label: t('archives.menu.scanForTimelapse'),\n      icon: <ScanSearch className=\"w-4 h-4\" />,\n      onClick: () => timelapseScanMutation.mutate(),\n      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    {\n      label: t('archives.menu.uploadTimelapse'),\n      icon: <Upload className=\"w-4 h-4\" />,\n      onClick: () => timelapseInputRef.current?.click(),\n      disabled: !!archive.timelapse_path || !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    ...(archive.timelapse_path ? [{\n      label: t('archives.menu.removeTimelapse'),\n      icon: <Trash2 className=\"w-4 h-4\" />,\n      onClick: () => setShowDeleteTimelapseConfirm(true),\n      danger: true,\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    }] : []),\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: archive.source_3mf_path ? t('archives.menu.downloadSource3mf') : t('archives.menu.uploadSource3mf'),\n      icon: <FileCode className=\"w-4 h-4\" />,\n      onClick: () => {\n        if (archive.source_3mf_path) {\n          api.downloadSource3mf(archive.id).catch((err) => {\n            console.error('Source 3MF download failed:', err);\n          });\n        } else {\n          source3mfInputRef.current?.click();\n        }\n      },\n      disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id),\n      title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUploadFiles') : undefined,\n    },\n    ...(archive.source_3mf_path ? [{\n      label: t('archives.menu.replaceSource3mf'),\n      icon: <Upload className=\"w-4 h-4\" />,\n      onClick: () => source3mfInputRef.current?.click(),\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    {\n      label: t('archives.menu.removeSource3mf'),\n      icon: <Trash2 className=\"w-4 h-4\" />,\n      onClick: () => setShowDeleteSource3mfConfirm(true),\n      danger: true,\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    }] : []),\n    {\n      label: archive.f3d_path ? t('archives.menu.replaceF3d') : t('archives.menu.uploadF3d'),\n      icon: <Box className=\"w-4 h-4\" />,\n      onClick: () => f3dInputRef.current?.click(),\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    ...(archive.f3d_path ? [{\n      label: t('archives.menu.downloadF3d'),\n      icon: <Download className=\"w-4 h-4\" />,\n      onClick: () => {\n        api.downloadF3d(archive.id).catch((err) => {\n          console.error('F3D download failed:', err);\n        });\n      },\n    },\n    {\n      label: t('archives.menu.removeF3d'),\n      icon: <Trash2 className=\"w-4 h-4\" />,\n      onClick: () => setShowDeleteF3dConfirm(true),\n      danger: true,\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    }] : []),\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: t('archives.menu.download'),\n      icon: <Download className=\"w-4 h-4\" />,\n      onClick: () => {\n        api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {\n          console.error('Archive download failed:', err);\n        });\n      },\n      disabled: !hasPermission('archives:read'),\n      title: !hasPermission('archives:read') ? t('archives.permission.noDownload') : undefined,\n    },\n    {\n      label: t('archives.menu.copyDownloadLink'),\n      icon: <Copy className=\"w-4 h-4\" />,\n      onClick: () => {\n        const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`;\n        navigator.clipboard.writeText(url).then(() => {\n          showToast(t('archives.toast.linkCopied'));\n        }).catch(() => {\n          showToast(t('archives.toast.failedCopyLink'), 'error');\n        });\n      },\n      disabled: !hasPermission('archives:read'),\n      title: !hasPermission('archives:read') ? t('archives.permission.noCopyLink') : undefined,\n    },\n    {\n      label: t('archives.menu.qrCode'),\n      icon: <QrCode className=\"w-4 h-4\" />,\n      onClick: () => setShowQRCode(true),\n    },\n    {\n      label: archive.photos?.length ? t('archives.menu.viewPhotosCount', { count: archive.photos.length }) : t('archives.menu.viewPhotos'),\n      icon: <Camera className=\"w-4 h-4\" />,\n      onClick: () => setShowPhotos(true),\n      disabled: !archive.photos?.length,\n    },\n    {\n      label: t('archives.menu.projectPage'),\n      icon: <FileText className=\"w-4 h-4\" />,\n      onClick: () => setShowProjectPage(true),\n    },\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'),\n      icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,\n      onClick: () => favoriteMutation.mutate(),\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    {\n      label: t('archives.menu.edit'),\n      icon: <Pencil className=\"w-4 h-4\" />,\n      onClick: () => setShowEdit(true),\n      disabled: !canModify('archives', 'update', archive.created_by_id),\n      title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,\n    },\n    ...(archive.project_id && archive.project_name ? [{\n      label: t('archives.menu.goToProject', { name: archive.project_name }),\n      icon: <FolderKanban className=\"w-4 h-4 text-bambu-green\" />,\n      onClick: () => window.location.href = '/projects',\n    }] : []),\n    {\n      label: t('archives.menu.addToProject'),\n      icon: <FolderKanban className=\"w-4 h-4\" />,\n      onClick: () => {},\n      submenu: (() => {\n        const items: ContextMenuItem[] = [];\n        if (archive.project_id) {\n          items.push({\n            label: t('archives.menu.removeFromProject'),\n            icon: <X className=\"w-4 h-4\" />,\n            onClick: () => assignProjectMutation.mutate(null),\n          });\n        }\n        if (!projects) {\n          items.push({\n            label: t('archives.menu.loading'),\n            icon: <Loader2 className=\"w-4 h-4 animate-spin\" />,\n            onClick: () => {},\n            disabled: true,\n          });\n        } else {\n          const activeProjects = projects.filter(p => p.status === 'active');\n          if (activeProjects.length === 0) {\n            items.push({\n              label: t('archives.menu.noProjectsAvailable'),\n              icon: <FolderKanban className=\"w-4 h-4 opacity-50\" />,\n              onClick: () => {},\n              disabled: true,\n            });\n          } else {\n            activeProjects.forEach(p => {\n              items.push({\n                label: p.name,\n                icon: <div className=\"w-3 h-3 rounded-full flex-shrink-0\" style={{ backgroundColor: p.color || '#888' }} />,\n                onClick: () => assignProjectMutation.mutate(p.id),\n                disabled: archive.project_id === p.id,\n              });\n            });\n          }\n        }\n        return items;\n      })(),\n    },\n    {\n      label: isSelected ? t('archives.menu.deselect') : t('archives.menu.select'),\n      icon: isSelected ? <CheckSquare className=\"w-4 h-4\" /> : <Square className=\"w-4 h-4\" />,\n      onClick: () => onSelect(archive.id),\n    },\n    { label: '', divider: true, onClick: () => {} },\n    {\n      label: t('archives.menu.delete'),\n      icon: <Trash2 className=\"w-4 h-4\" />,\n      onClick: () => setShowDeleteConfirm(true),\n      danger: true,\n      disabled: !canModify('archives', 'delete', archive.created_by_id),\n      title: !canModify('archives', 'delete', archive.created_by_id) ? t('archives.permission.noDelete') : undefined,\n    },\n  ];\n\n  return (\n    <>\n      <div\n        data-archive-id={archive.id}\n        className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${\n          isSelected ? 'bg-bambu-green/10' : ''\n        }`}\n        style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '-4px' } : undefined}\n        onContextMenu={handleContextMenu}\n      >\n        <div className=\"col-span-1 flex items-center gap-2\">\n          {selectionMode && (\n            <button onClick={() => onSelect(archive.id)}>\n              {isSelected ? (\n                <CheckSquare className=\"w-4 h-4 text-bambu-green\" />\n              ) : (\n                <Square className=\"w-4 h-4 text-bambu-gray\" />\n              )}\n            </button>\n          )}\n          {archive.thumbnail_path ? (\n            <img\n              src={api.getArchiveThumbnail(archive.id)}\n              alt=\"\"\n              className=\"w-10 h-10 object-cover rounded\"\n            />\n          ) : (\n            <div className=\"w-10 h-10 bg-bambu-dark rounded flex items-center justify-center\">\n              <Image className=\"w-5 h-5 text-bambu-dark-tertiary\" />\n            </div>\n          )}\n        </div>\n        <div className=\"col-span-4\">\n          <div className=\"flex items-center gap-2\">\n            <p className=\"text-white text-sm truncate\">{archive.print_name || archive.filename}</p>\n            {(archive.status === 'failed' || archive.status === 'aborted') && (\n              <span className=\"px-1.5 py-0.5 rounded text-[10px] leading-tight bg-status-error/80 text-white flex-shrink-0\">\n                {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}\n              </span>\n            )}\n            {archive.duplicate_count > 0 && duplicateSequence > 0 && originalArchiveId && (\n              <button\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onNavigateToArchive?.(originalArchiveId);\n                }}\n                className=\"px-1.5 py-0.5 rounded text-[10px] leading-tight bg-purple-500/80 hover:bg-purple-600/90 text-white flex-shrink-0 transition-colors flex items-center gap-1\"\n                title={t('archives.viewOriginalPrint', { id: originalArchiveId })}\n              >\n                <Copy className=\"w-3 h-3\" />\n                #{duplicateSequence}\n              </button>\n            )}\n            {archive.duplicate_count > 0 && duplicateSequence === 0 && (\n              <span\n                className=\"px-1.5 py-0.5 rounded text-[10px] leading-tight bg-purple-500/80 text-white flex-shrink-0 flex items-center gap-1\"\n                title={`${archive.duplicate_count} reprint${archive.duplicate_count === 1 ? '' : 's'}`}\n              >\n                <GitBranch className=\"w-3 h-3\" />\n                +{archive.duplicate_count}\n              </span>\n            )}\n            {archive.timelapse_path && (\n              <span title={t('archives.list.hasTimelapse')}>\n                <Film className=\"w-3.5 h-3.5 text-bambu-green flex-shrink-0\" />\n              </span>\n            )}\n            {linkedFolders && linkedFolders.length > 0 && (\n              <Link\n                to={`/files?folder=${linkedFolders[0].id}`}\n                className=\"flex-shrink-0\"\n                title={t('archives.card.openFolder', { name: linkedFolders[0].name })}\n                onClick={(e) => e.stopPropagation()}\n              >\n                <FolderOpen className=\"w-3.5 h-3.5 text-yellow-400\" />\n              </Link>\n            )}\n          </div>\n          {(archive.filament_type || archive.sliced_for_model) && (\n            <div className=\"flex items-center gap-1.5 mt-0.5\">\n              {archive.sliced_for_model && (\n                <span className=\"text-xs text-bambu-gray flex items-center gap-1\" title={t('archives.card.slicedFor', { model: archive.sliced_for_model })}>\n                  <Printer className=\"w-2.5 h-2.5\" />\n                  {archive.sliced_for_model}\n                </span>\n              )}\n              {archive.sliced_for_model && archive.filament_type && (\n                <span className=\"text-bambu-gray/50\">·</span>\n              )}\n              {archive.filament_type && (\n                <span className=\"text-xs text-bambu-gray\">{archive.filament_type}</span>\n              )}\n              {archive.filament_color && (\n                <div className=\"flex items-center gap-0.5 flex-wrap\">\n                  {archive.filament_color.split(',').map((color, i) => (\n                    <div\n                      key={i}\n                      className=\"w-2.5 h-2.5 rounded-full border border-black/20\"\n                      style={{ backgroundColor: color }}\n                      title={color}\n                    />\n                  ))}\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n        <div className=\"col-span-2 text-sm text-bambu-gray truncate\">\n          {printerName}\n        </div>\n        <div className=\"col-span-2 text-sm text-bambu-gray\">\n          <div>{formatDateOnly(archive.created_at)}</div>\n          {archive.created_by_username && (\n            <div className=\"flex items-center gap-1 text-xs opacity-75\" title={t('archives.card.uploadedBy', { name: archive.created_by_username })}>\n              <User className=\"w-3 h-3\" />\n              {archive.created_by_username}\n            </div>\n          )}\n        </div>\n        <div className=\"col-span-1 text-sm text-bambu-gray\">\n          {formatFileSize(archive.file_size)}\n        </div>\n        <div className=\"col-span-2 flex justify-end gap-1\">\n          {isSlicedFile(archive) && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => setShowReprint(true)}\n              disabled={!canModify('archives', 'reprint', archive.created_by_id)}\n              title={!canModify('archives', 'reprint', archive.created_by_id) ? t('archives.card.noPermissionReprint') : t('archives.card.reprint')}\n              className=\"text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10\"\n            >\n              <Play className=\"w-4 h-4\" />\n            </Button>\n          )}\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => {\n              const filename = archive.print_name || archive.filename || 'model';\n              openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);\n            }}\n            title={t('archives.card.openInBambuStudio')}\n          >\n            <ExternalLink className=\"w-4 h-4\" />\n          </Button>\n          {(archive.external_url || archive.makerworld_url) && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => window.open((archive.external_url || archive.makerworld_url)!, '_blank')}\n              title={archive.external_url ? t('archives.card.externalLink') : 'MakerWorld'}\n            >\n              <Globe className=\"w-4 h-4\" />\n            </Button>\n          )}\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => {\n              api.downloadArchive(archive.id, `${archive.print_name || archive.filename}.3mf`).catch((err) => {\n                console.error('Archive download failed:', err);\n              });\n            }}\n            title={t('archives.card.download')}\n          >\n            <Download className=\"w-4 h-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => setShowEdit(true)}\n            disabled={!canModify('archives', 'update', archive.created_by_id)}\n            title={!canModify('archives', 'update', archive.created_by_id) ? t('archives.card.noPermissionEdit') : t('archives.card.edit')}\n          >\n            <Pencil className=\"w-4 h-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => setShowDeleteConfirm(true)}\n            disabled={!canModify('archives', 'delete', archive.created_by_id)}\n            title={!canModify('archives', 'delete', archive.created_by_id) ? t('archives.card.noPermissionDelete') : t('archives.card.delete')}\n          >\n            <Trash2 className=\"w-4 h-4 text-red-400\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={(e) => {\n              const rect = e.currentTarget.getBoundingClientRect();\n              setContextMenu({ x: rect.left, y: rect.bottom + 4 });\n            }}\n            title={t('archives.card.moreOptions')}\n          >\n            <MoreVertical className=\"w-4 h-4\" />\n          </Button>\n        </div>\n      </div>\n\n      {/* Edit Modal */}\n      {showEdit && (\n        <EditArchiveModal\n          archive={archive}\n          onClose={() => setShowEdit(false)}\n        />\n      )}\n\n      {/* 3D Viewer Modal */}\n      {showViewer && (\n        <ModelViewerModal\n          archiveId={archive.id}\n          title={archive.print_name || archive.filename}\n          fileType={getArchiveFileType(archive.filename)}\n          onClose={() => setShowViewer(false)}\n        />\n      )}\n\n      {/* Reprint Modal */}\n      {showReprint && (\n        <PrintModal\n          mode=\"reprint\"\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          onClose={() => setShowReprint(false)}\n        />\n      )}\n\n      {/* Delete Confirmation */}\n      {showDeleteConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.deleteArchive')}\n          message={t('archives.modal.deleteConfirm', { name: archive.print_name || archive.filename })}\n          confirmText={t('archives.modal.deleteButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            deleteMutation.mutate();\n            setShowDeleteConfirm(false);\n          }}\n          onCancel={() => setShowDeleteConfirm(false)}\n        />\n      )}\n\n      {/* Delete Source 3MF Confirmation */}\n      {showDeleteSource3mfConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.removeSource3mf')}\n          message={t('archives.modal.removeSource3mfConfirm', { name: archive.print_name || archive.filename })}\n          confirmText={t('archives.modal.removeButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            source3mfDeleteMutation.mutate();\n            setShowDeleteSource3mfConfirm(false);\n          }}\n          onCancel={() => setShowDeleteSource3mfConfirm(false)}\n        />\n      )}\n\n      {/* Delete F3D Confirmation */}\n      {showDeleteF3dConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.removeF3d')}\n          message={t('archives.modal.removeF3dConfirm', { name: archive.print_name || archive.filename })}\n          confirmText={t('archives.modal.removeButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            f3dDeleteMutation.mutate();\n            setShowDeleteF3dConfirm(false);\n          }}\n          onCancel={() => setShowDeleteF3dConfirm(false)}\n        />\n      )}\n\n      {/* Delete Timelapse Confirmation */}\n      {showDeleteTimelapseConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.removeTimelapse')}\n          message={t('archives.modal.removeTimelapseConfirm', { name: archive.print_name || archive.filename })}\n          confirmText={t('archives.modal.removeButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            timelapseDeleteMutation.mutate();\n            setShowDeleteTimelapseConfirm(false);\n          }}\n          onCancel={() => setShowDeleteTimelapseConfirm(false)}\n        />\n      )}\n\n      {/* Context Menu */}\n      {contextMenu && (\n        <ContextMenu\n          x={contextMenu.x}\n          y={contextMenu.y}\n          items={contextMenuItems}\n          onClose={() => setContextMenu(null)}\n        />\n      )}\n\n      {/* Timelapse Viewer Modal */}\n      {showTimelapse && archive.timelapse_path && (\n        <TimelapseViewer\n          src={api.getArchiveTimelapse(archive.id)}\n          title={t('archives.modal.timelapse', { name: archive.print_name || archive.filename })}\n          downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}\n          archiveId={archive.id}\n          onClose={() => setShowTimelapse(false)}\n          onEdit={() => {\n            queryClient.invalidateQueries({ queryKey: ['archives'] });\n            setShowTimelapse(false);\n          }}\n        />\n      )}\n\n      {/* Timelapse Selection Modal */}\n      {showTimelapseSelect && availableTimelapses.length > 0 && (\n        <div className=\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\">\n          <div className=\"bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col\">\n            <div className=\"flex items-center justify-between p-4 border-b border-gray-700\">\n              <div>\n                <h3 className=\"text-lg font-semibold text-white\">{t('archives.modal.selectTimelapse')}</h3>\n                <p className=\"text-sm text-gray-400 mt-1\">\n                  {t('archives.modal.selectTimelapseDesc')}\n                </p>\n              </div>\n              <button\n                onClick={() => {\n                  setShowTimelapseSelect(false);\n                  setAvailableTimelapses([]);\n                }}\n                className=\"text-gray-400 hover:text-white p-1\"\n              >\n                <X className=\"w-5 h-5\" />\n              </button>\n            </div>\n            <div className=\"overflow-y-auto flex-1 p-2\">\n              {availableTimelapses.map((file) => (\n                <button\n                  key={file.name}\n                  onClick={() => timelapseSelectMutation.mutate(file.name)}\n                  disabled={timelapseSelectMutation.isPending}\n                  className=\"w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors mb-1\"\n                >\n                  <div className=\"font-medium text-white\">{file.name}</div>\n                  <div className=\"text-sm text-gray-400 flex gap-3\">\n                    <span>{formatFileSize(file.size)}</span>\n                    {file.mtime && (\n                      <span>{formatDateOnly(file.mtime)}</span>\n                    )}\n                  </div>\n                </button>\n              ))}\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* QR Code Modal */}\n      {showQRCode && (\n        <QRCodeModal\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          onClose={() => setShowQRCode(false)}\n        />\n      )}\n\n      {/* Photo Gallery Modal */}\n      {showPhotos && archive.photos && (\n        <PhotoGalleryModal\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          photos={archive.photos}\n          onClose={() => setShowPhotos(false)}\n          onDelete={async (filename) => {\n            try {\n              await api.deleteArchivePhoto(archive.id, filename);\n              queryClient.invalidateQueries({ queryKey: ['archives'] });\n              showToast(t('archives.toast.photoDeleted'));\n            } catch {\n              showToast(t('archives.toast.failedDeletePhoto'), 'error');\n            }\n          }}\n        />\n      )}\n\n      {/* Project Page Modal */}\n      {showProjectPage && (\n        <ProjectPageModal\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          onClose={() => setShowProjectPage(false)}\n        />\n      )}\n\n      {/* Schedule Modal */}\n      {showSchedule && (\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={archive.id}\n          archiveName={archive.print_name || archive.filename}\n          onClose={() => setShowSchedule(false)}\n        />\n      )}\n\n      {/* Hidden file input for source 3MF upload */}\n      <input\n        ref={source3mfInputRef}\n        type=\"file\"\n        accept=\".3mf\"\n        className=\"hidden\"\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          if (file) {\n            source3mfUploadMutation.mutate(file);\n          }\n          e.target.value = '';\n        }}\n      />\n      {/* Hidden file input for F3D upload */}\n      <input\n        ref={f3dInputRef}\n        type=\"file\"\n        accept=\".f3d\"\n        className=\"hidden\"\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          if (file) {\n            f3dUploadMutation.mutate(file);\n          }\n          e.target.value = '';\n        }}\n      />\n      {/* Hidden file input for timelapse upload */}\n      <input\n        ref={timelapseInputRef}\n        type=\"file\"\n        accept=\".mp4,.avi,.mkv\"\n        className=\"hidden\"\n        onChange={(e) => {\n          const file = e.target.files?.[0];\n          if (file) {\n            timelapseUploadMutation.mutate(file);\n          }\n          e.target.value = '';\n        }}\n      />\n    </>\n  );\n}\n\ntype SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';\ntype ViewMode = 'grid' | 'list' | 'calendar' | 'log';\ntype Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';\n\nconst collections: { id: Collection; label: string; icon: React.ReactNode }[] = [\n  { id: 'all', label: 'All Archives', icon: <FolderOpen className=\"w-4 h-4\" /> },\n  { id: 'recent', label: 'Last 24 Hours', icon: <Clock className=\"w-4 h-4\" /> },\n  { id: 'this-week', label: 'This Week', icon: <Calendar className=\"w-4 h-4\" /> },\n  { id: 'this-month', label: 'This Month', icon: <Calendar className=\"w-4 h-4\" /> },\n  { id: 'favorites', label: 'Favorites', icon: <Star className=\"w-4 h-4\" /> },\n  { id: 'failed', label: 'Failed Prints', icon: <AlertCircle className=\"w-4 h-4\" /> },\n  { id: 'duplicates', label: 'Duplicates', icon: <Copy className=\"w-4 h-4\" /> },\n];\n\nexport function ArchivesPage() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission, hasAnyPermission } = useAuth();\n  const searchInputRef = useRef<HTMLInputElement>(null);\n  const [search, setSearch] = useState('');\n  const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {\n    const saved = localStorage.getItem('archiveFilterPrinter');\n    return saved ? Number(saved) : null;\n  });\n  const [filterMaterial, setFilterMaterial] = useState<string | null>(() =>\n    localStorage.getItem('archiveFilterMaterial')\n  );\n  const [filterColors, setFilterColors] = useState<Set<string>>(() => {\n    const saved = localStorage.getItem('archiveFilterColors');\n    return saved ? new Set(JSON.parse(saved)) : new Set();\n  });\n  const [colorFilterMode, setColorFilterMode] = useState<'or' | 'and'>(() =>\n    (localStorage.getItem('archiveColorFilterMode') as 'or' | 'and') || 'or'\n  );\n  const [filterFavorites, setFilterFavorites] = useState(() =>\n    localStorage.getItem('archiveFilterFavorites') === 'true'\n  );\n  const [hideFailed, setHideFailed] = useState(() =>\n    localStorage.getItem('archiveHideFailed') === 'true'\n  );\n  const [hideDuplicates, setHideDuplicates] = useState(() =>\n    localStorage.getItem('archiveHideDuplicates') === 'true'\n  );\n  const [filterTag, setFilterTag] = useState<string | null>(() =>\n    localStorage.getItem('archiveFilterTag')\n  );\n  const [filterFileType, setFilterFileType] = useState<'all' | 'gcode' | 'source'>(() =>\n    (localStorage.getItem('archiveFilterFileType') as 'all' | 'gcode' | 'source') || 'all'\n  );\n  const [showUpload, setShowUpload] = useState(false);\n  const [uploadFiles, setUploadFiles] = useState<File[]>([]);\n  const [isDraggingOver, setIsDraggingOver] = useState(false);\n  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());\n  const [isSelectionMode, setIsSelectionMode] = useState(false);\n  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);\n  const [showBatchTag, setShowBatchTag] = useState(false);\n  const [showBatchProject, setShowBatchProject] = useState(false);\n  const [viewMode, setViewMode] = useState<ViewMode>(() =>\n    (localStorage.getItem('archiveViewMode') as ViewMode) || 'grid'\n  );\n  const [sortBy, setSortBy] = useState<SortOption>(() =>\n    (localStorage.getItem('archiveSortBy') as SortOption) || 'date-desc'\n  );\n  const [collection, setCollection] = useState<Collection>(() =>\n    (localStorage.getItem('archiveCollection') as Collection) || 'all'\n  );\n  // Pagination state\n  const [pageIndex, setPageIndex] = useState(0);\n  const [pageSize, setPageSize] = useState<number>(() => {\n    try {\n      const stored = localStorage.getItem('archivePageSize');\n      return stored ? Number(stored) : 50;\n    } catch { return 50; }\n  });\n\n  const [showExportMenu, setShowExportMenu] = useState(false);\n  const [isExporting, setIsExporting] = useState(false);\n  const [showCompareModal, setShowCompareModal] = useState(false);\n  const [showTagManagement, setShowTagManagement] = useState(false);\n  const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);\n  const [pendingNavigationArchiveId, setPendingNavigationArchiveId] = useState<number | null>(null);\n\n  // Log view state\n  const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>\n    localStorage.getItem('logFilterUser') || null\n  );\n  const [logFilterStatus, setLogFilterStatus] = useState<string | null>(() =>\n    localStorage.getItem('logFilterStatus')\n  );\n  const [logFilterDateFrom, setLogFilterDateFrom] = useState(() =>\n    localStorage.getItem('logFilterDateFrom') || ''\n  );\n  const [logFilterDateTo, setLogFilterDateTo] = useState(() =>\n    localStorage.getItem('logFilterDateTo') || ''\n  );\n  const [logOffset, setLogOffset] = useState(() => {\n    const saved = localStorage.getItem('logOffset');\n    return saved ? Number(saved) : 0;\n  });\n  const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);\n  const [logPageSize, setLogPageSize] = useState(() => {\n    const saved = localStorage.getItem('logPageSize');\n    return saved ? Number(saved) : 25;\n  });\n\n  const handleNavigateToArchive = useCallback((archiveId: number) => {\n    setPendingNavigationArchiveId(archiveId);\n    setHighlightedArchiveId(archiveId);\n  }, []);\n\n  // Clear highlight after 5 seconds and scroll to highlighted element\n  useEffect(() => {\n    if (highlightedArchiveId) {\n      // Scroll to highlighted element after a short delay (to let the view render)\n      const scrollTimer = setTimeout(() => {\n        const element = document.querySelector(`[data-archive-id=\"${highlightedArchiveId}\"]`);\n        if (element) {\n          element.scrollIntoView({ behavior: 'smooth', block: 'center' });\n        } else if (pendingNavigationArchiveId === highlightedArchiveId) {\n          showToast(t('archives.originalPrintNotVisible'), 'warning');\n        }\n        if (pendingNavigationArchiveId === highlightedArchiveId) {\n          setPendingNavigationArchiveId(null);\n        }\n      }, 100);\n\n      // Clear highlight after 5 seconds\n      const clearTimer = setTimeout(() => setHighlightedArchiveId(null), 5000);\n      return () => {\n        clearTimeout(scrollTimer);\n        clearTimeout(clearTimer);\n      };\n    }\n  }, [highlightedArchiveId, pendingNavigationArchiveId, showToast, t]);\n\n  const { data: archives, isLoading } = useQuery({\n    queryKey: ['archives', filterPrinter],\n    queryFn: () => api.getArchives(filterPrinter || undefined),\n  });\n\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const { data: projects } = useQuery({\n    queryKey: ['projects'],\n    queryFn: () => api.getProjects(),\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const { data: users } = useQuery({\n    queryKey: ['users'],\n    queryFn: api.getUsers,\n    enabled: viewMode === 'log',\n  });\n\n  const { data: printLogData, isLoading: isLogLoading } = useQuery({\n    queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize],\n    queryFn: () => api.getPrintLog({\n      search: search || undefined,\n      printerId: filterPrinter || undefined,\n      username: logFilterUser || undefined,\n      status: logFilterStatus || undefined,\n      dateFrom: logFilterDateFrom || undefined,\n      dateTo: logFilterDateTo || undefined,\n      limit: logPageSize,\n      offset: logOffset,\n    }),\n    enabled: viewMode === 'log',\n  });\n\n  const timeFormat: TimeFormat = settings?.time_format || 'system';\n  const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';\n  const currency = getCurrencySymbol(settings?.currency || 'USD');\n\n  const bulkDeleteMutation = useMutation({\n    mutationFn: async (ids: number[]) => {\n      await Promise.all(ids.map((id) => api.deleteArchive(id)));\n      return ids.length;\n    },\n    onSuccess: (count) => {\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      setSelectedIds(new Set());\n      showToast(`${count} archive${count !== 1 ? 's' : ''} deleted`);\n    },\n    onError: () => {\n      showToast(t('archives.toast.failedDeleteArchives'), 'error');\n    },\n  });\n\n  const clearLogMutation = useMutation({\n    mutationFn: () => api.clearPrintLog(),\n    onSuccess: (data) => {\n      queryClient.invalidateQueries({ queryKey: ['print-log'] });\n      setLogOffset(0);\n      showToast(t('archives.log.cleared', { count: data.deleted }));\n    },\n    onError: () => {\n      showToast(t('archives.log.clearFailed'), 'error');\n    },\n  });\n\n  // Persist all filters to localStorage\n  useEffect(() => {\n    if (filterPrinter !== null) {\n      localStorage.setItem('archiveFilterPrinter', filterPrinter.toString());\n    } else {\n      localStorage.removeItem('archiveFilterPrinter');\n    }\n  }, [filterPrinter]);\n\n  useEffect(() => {\n    if (filterMaterial) {\n      localStorage.setItem('archiveFilterMaterial', filterMaterial);\n    } else {\n      localStorage.removeItem('archiveFilterMaterial');\n    }\n  }, [filterMaterial]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveFilterColors', JSON.stringify([...filterColors]));\n  }, [filterColors]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveColorFilterMode', colorFilterMode);\n  }, [colorFilterMode]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveFilterFavorites', filterFavorites.toString());\n  }, [filterFavorites]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveHideFailed', hideFailed.toString());\n  }, [hideFailed]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveHideDuplicates', hideDuplicates.toString());\n  }, [hideDuplicates]);\n\n  useEffect(() => {\n    if (filterTag) {\n      localStorage.setItem('archiveFilterTag', filterTag);\n    } else {\n      localStorage.removeItem('archiveFilterTag');\n    }\n  }, [filterTag]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveFilterFileType', filterFileType);\n  }, [filterFileType]);\n\n  // Reset page when filters/search/sort/collection change\n  useEffect(() => {\n    setPageIndex(0);\n  }, [search, filterPrinter, filterMaterial, filterColors, colorFilterMode, filterFavorites, hideFailed, hideDuplicates, filterTag, filterFileType, sortBy, collection]);\n\n  useEffect(() => {\n    try { localStorage.setItem('archivePageSize', String(pageSize)); } catch { /* ignore */ }\n  }, [pageSize]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveViewMode', viewMode);\n  }, [viewMode]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveSortBy', sortBy);\n  }, [sortBy]);\n\n  useEffect(() => {\n    localStorage.setItem('archiveCollection', collection);\n  }, [collection]);\n\n  // Persist log view filters\n  useEffect(() => {\n    if (logFilterUser) {\n      localStorage.setItem('logFilterUser', logFilterUser);\n    } else {\n      localStorage.removeItem('logFilterUser');\n    }\n  }, [logFilterUser]);\n\n  useEffect(() => {\n    if (logFilterStatus) {\n      localStorage.setItem('logFilterStatus', logFilterStatus);\n    } else {\n      localStorage.removeItem('logFilterStatus');\n    }\n  }, [logFilterStatus]);\n\n  useEffect(() => {\n    if (logFilterDateFrom) {\n      localStorage.setItem('logFilterDateFrom', logFilterDateFrom);\n    } else {\n      localStorage.removeItem('logFilterDateFrom');\n    }\n  }, [logFilterDateFrom]);\n\n  useEffect(() => {\n    if (logFilterDateTo) {\n      localStorage.setItem('logFilterDateTo', logFilterDateTo);\n    } else {\n      localStorage.removeItem('logFilterDateTo');\n    }\n  }, [logFilterDateTo]);\n\n  useEffect(() => {\n    localStorage.setItem('logOffset', logOffset.toString());\n  }, [logOffset]);\n\n  useEffect(() => {\n    localStorage.setItem('logPageSize', logPageSize.toString());\n  }, [logPageSize]);\n\n  const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);\n\n  // Extract unique materials and colors from archives\n  const uniqueMaterials = [...new Set(\n    archives?.flatMap(a => a.filament_type?.split(', ') || []).filter(Boolean) || []\n  )].sort();\n\n  const uniqueColors = [...new Set(\n    archives?.flatMap(a => a.filament_color?.split(',') || []).filter(Boolean) || []\n  )];\n\n  const uniqueTags = [...new Set(\n    archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []\n  )].sort();\n\n  const filteredArchives = archives\n    ?.filter((a) => {\n      // Collection filter\n      const now = new Date();\n      const archiveDate = parseUTCDate(a.created_at) || new Date(0);\n      let matchesCollection = true;\n\n      switch (collection) {\n        case 'recent':\n          matchesCollection = (now.getTime() - archiveDate.getTime()) < 24 * 60 * 60 * 1000;\n          break;\n        case 'this-week':\n          matchesCollection = (now.getTime() - archiveDate.getTime()) < 7 * 24 * 60 * 60 * 1000;\n          break;\n        case 'this-month':\n          matchesCollection = archiveDate.getMonth() === now.getMonth() && archiveDate.getFullYear() === now.getFullYear();\n          break;\n        case 'favorites':\n          matchesCollection = a.is_favorite === true;\n          break;\n        case 'failed':\n          matchesCollection = a.status === 'failed' || a.status === 'aborted';\n          break;\n        case 'duplicates':\n          matchesCollection = a.duplicate_count > 0;\n          break;\n      }\n\n      // Search filter\n      const matchesSearch = (a.print_name || a.filename).toLowerCase().includes(search.toLowerCase());\n\n      // Material filter\n      const matchesMaterial = !filterMaterial ||\n        (a.filament_type?.split(', ').includes(filterMaterial));\n\n      // Color filter (AND: must have all selected colors, OR: must have any selected color)\n      const archiveColors = a.filament_color?.split(',') || [];\n      const matchesColor = filterColors.size === 0 ||\n        (colorFilterMode === 'or'\n          ? archiveColors.some(c => filterColors.has(c))\n          : [...filterColors].every(c => archiveColors.includes(c)));\n\n      // Favorites filter (only apply if not using favorites collection)\n      const matchesFavorites = collection === 'favorites' || !filterFavorites || a.is_favorite;\n\n      // Hide failed filter (don't apply when viewing failed collection)\n      const matchesHideFailed = collection === 'failed' || !hideFailed || (a.status !== 'failed' && a.status !== 'aborted');\n\n      // Hide duplicates filter (don't apply when viewing duplicates collection)\n      const matchesHideDuplicates =\n        collection === 'duplicates' || !hideDuplicates || a.duplicate_count === 0 || a.duplicate_sequence === 0;\n\n      // Tag filter\n      const archiveTags = a.tags?.split(',').map(t => t.trim()) || [];\n      const matchesTag = !filterTag || archiveTags.includes(filterTag);\n\n      // File type filter (gcode = sliced, source = project file only)\n      const isGcodeFile = isSlicedFile(a);\n      const matchesFileType = filterFileType === 'all' ||\n        (filterFileType === 'gcode' && isGcodeFile) ||\n        (filterFileType === 'source' && !isGcodeFile);\n\n      return matchesCollection && matchesSearch && matchesMaterial && matchesColor && matchesFavorites && matchesHideFailed && matchesHideDuplicates && matchesTag && matchesFileType;\n    })\n    .sort((a, b) => {\n      switch (sortBy) {\n        case 'date-desc':\n          return (parseUTCDate(b.created_at)?.getTime() || 0) - (parseUTCDate(a.created_at)?.getTime() || 0);\n        case 'date-asc':\n          return (parseUTCDate(a.created_at)?.getTime() || 0) - (parseUTCDate(b.created_at)?.getTime() || 0);\n        case 'name-asc':\n          return (a.print_name || a.filename).localeCompare(b.print_name || b.filename);\n        case 'name-desc':\n          return (b.print_name || b.filename).localeCompare(a.print_name || a.filename);\n        case 'size-desc':\n          return b.file_size - a.file_size;\n        case 'size-asc':\n          return a.file_size - b.file_size;\n        default:\n          return 0;\n      }\n    });\n\n  // Pagination\n  const totalFiltered = filteredArchives?.length || 0;\n  const showAll = pageSize === -1;\n  const effectivePageSize = showAll ? totalFiltered || 1 : pageSize;\n  const totalPages = Math.max(1, Math.ceil(totalFiltered / effectivePageSize));\n  const paginatedArchives = showAll\n    ? filteredArchives\n    : filteredArchives?.slice(pageIndex * effectivePageSize, (pageIndex + 1) * effectivePageSize);\n\n  // Jump to the page containing the highlighted archive\n  useEffect(() => {\n    if (highlightedArchiveId && filteredArchives && !showAll) {\n      const idx = filteredArchives.findIndex(a => a.id === highlightedArchiveId);\n      if (idx >= 0) {\n        const targetPage = Math.floor(idx / effectivePageSize);\n        if (targetPage !== pageIndex) setPageIndex(targetPage);\n      }\n    }\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [highlightedArchiveId]);\n\n  const selectionMode = isSelectionMode || selectedIds.size > 0;\n\n  const toggleSelect = (id: number) => {\n    setSelectedIds((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) {\n        next.delete(id);\n      } else {\n        next.add(id);\n      }\n      return next;\n    });\n  };\n\n  const selectAll = () => {\n    if (filteredArchives) {\n      setSelectedIds(new Set(filteredArchives.map((a) => a.id)));\n    }\n  };\n\n  const clearSelection = () => {\n    setSelectedIds(new Set());\n    setIsSelectionMode(false);\n  };\n\n  const toggleColor = (color: string) => {\n    setFilterColors((prev) => {\n      const next = new Set(prev);\n      if (next.has(color)) {\n        next.delete(color);\n      } else {\n        next.add(color);\n      }\n      return next;\n    });\n  };\n\n  const clearColorFilter = () => {\n    setFilterColors(new Set());\n  };\n\n  const clearTopFilters = () => {\n    setSearch('');\n    setFilterPrinter(null);\n    setFilterMaterial(null);\n    setFilterFavorites(false);\n    setHideFailed(false);\n    setHideDuplicates(false);\n    setFilterTag(null);\n    setFilterFileType('all');\n  };\n\n  const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || hideDuplicates || filterTag || filterFileType !== 'all';\n\n  // Drag & drop handlers for page-wide upload\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    if (e.dataTransfer.types.includes('Files')) {\n      setIsDraggingOver(true);\n    }\n  }, []);\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    // Only hide if leaving the page (not entering a child)\n    if (e.currentTarget === e.target) {\n      setIsDraggingOver(false);\n    }\n  }, []);\n\n  const handleDrop = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDraggingOver(false);\n\n    const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.3mf'));\n    if (droppedFiles.length > 0) {\n      setUploadFiles(droppedFiles);\n      setShowUpload(true);\n    } else if (e.dataTransfer.files.length > 0) {\n      showToast(t('archives.page.only3mfSupported'), 'warning');\n    }\n  }, [showToast, t]);\n\n  // Keyboard shortcuts\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    const target = e.target as HTMLElement;\n    // Ignore if typing in an input/textarea\n    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {\n      if (e.key === 'Escape') {\n        target.blur();\n      }\n      return;\n    }\n\n    switch (e.key) {\n      case '/':\n        e.preventDefault();\n        searchInputRef.current?.focus();\n        break;\n      case 'u':\n      case 'U':\n        if (!e.metaKey && !e.ctrlKey) {\n          e.preventDefault();\n          setShowUpload(true);\n        }\n        break;\n      case 'Escape':\n        if (selectionMode) {\n          clearSelection();\n        }\n        break;\n    }\n  }, [selectionMode]);\n\n  useEffect(() => {\n    document.addEventListener('keydown', handleKeyDown);\n    return () => document.removeEventListener('keydown', handleKeyDown);\n  }, [handleKeyDown]);\n\n  return (\n    <div\n      className=\"p-4 md:p-8 relative\"\n      onDragOver={handleDragOver}\n      onDragLeave={handleDragLeave}\n      onDrop={handleDrop}\n    >\n      {/* Drag & Drop Overlay */}\n      {isDraggingOver && (\n        <div className=\"fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none\">\n          <div className=\"border-4 border-dashed border-bambu-green rounded-xl p-12 text-center\">\n            <Upload className=\"w-16 h-16 mx-auto mb-4 text-bambu-green\" />\n            <p className=\"text-2xl font-semibold text-white mb-2\">Drop .3mf files here</p>\n            <p className=\"text-bambu-gray\">{t('archives.releaseToUpload')}</p>\n          </div>\n        </div>\n      )}\n\n      {/* Selection Toolbar */}\n      {selectionMode && (\n        <div className=\"fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-4\">\n          <Button variant=\"secondary\" size=\"sm\" onClick={clearSelection}>\n            <X className=\"w-4 h-4\" />\n            Close\n          </Button>\n          <div className=\"w-px h-6 bg-bambu-dark-tertiary\" />\n          <span className=\"text-white font-medium\">\n            {selectedIds.size} selected\n          </span>\n          <div className=\"w-px h-6 bg-bambu-dark-tertiary\" />\n          <Button variant=\"secondary\" size=\"sm\" onClick={selectAll}>\n            Select All\n          </Button>\n          <div className=\"w-px h-6 bg-bambu-dark-tertiary\" />\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={() => setShowBatchTag(true)}\n            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}\n            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? t('archives.permission.noUpdateArchives') : undefined}\n          >\n            <Tag className=\"w-4 h-4\" />\n            Tags\n          </Button>\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={() => setShowBatchProject(true)}\n            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}\n            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? t('archives.permission.noUpdateArchives') : undefined}\n          >\n            <FolderKanban className=\"w-4 h-4\" />\n            Project\n          </Button>\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}\n            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? t('archives.permission.noUpdateArchives') : undefined}\n            onClick={() => {\n              const ids = Array.from(selectedIds);\n              Promise.all(ids.map(id => api.toggleFavorite(id)))\n                .then(() => {\n                  queryClient.invalidateQueries({ queryKey: ['archives'] });\n                  showToast(`Toggled favorites for ${ids.length} archive${ids.length !== 1 ? 's' : ''}`);\n                })\n                .catch(() => {\n                  showToast(t('archives.toast.failedUpdateFavorites'), 'error');\n                });\n            }}\n          >\n            <Star className=\"w-4 h-4\" />\n            Favorite\n          </Button>\n          <Button\n            size=\"sm\"\n            className=\"bg-red-500 hover:bg-red-600\"\n            onClick={() => setShowBulkDeleteConfirm(true)}\n            disabled={!hasAnyPermission('archives:delete_own', 'archives:delete_all')}\n            title={!hasAnyPermission('archives:delete_own', 'archives:delete_all') ? t('archives.permission.noDelete') : undefined}\n          >\n            <Trash2 className=\"w-4 h-4\" />\n            Delete\n          </Button>\n        </div>\n      )}\n\n      <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8\">\n        <div>\n          <div className=\"flex items-center gap-3\">\n            <h1 className=\"text-2xl font-bold text-white\">Archives</h1>\n            <select\n              className=\"px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray-light text-sm focus:border-bambu-green focus:outline-none\"\n              value={collection}\n              onChange={(e) => setCollection(e.target.value as Collection)}\n            >\n              {collections.map((c) => (\n                <option key={c.id} value={c.id}>\n                  {c.label}\n                </option>\n              ))}\n            </select>\n          </div>\n          <p className=\"text-bambu-gray\">\n            {filteredArchives?.length || 0} of {archives?.length || 0} prints\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2 sm:gap-3 flex-wrap\">\n          {/* Export dropdown */}\n          <div className=\"relative\">\n            <Button\n              variant=\"secondary\"\n              onClick={() => setShowExportMenu(!showExportMenu)}\n              disabled={isExporting}\n            >\n              {isExporting ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <FileSpreadsheet className=\"w-4 h-4\" />\n              )}\n              Export\n            </Button>\n            {showExportMenu && (\n              <div className=\"absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20\">\n                <button\n                  className=\"w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-t-lg\"\n                  onClick={async () => {\n                    setShowExportMenu(false);\n                    setIsExporting(true);\n                    try {\n                      const { blob, filename } = await api.exportArchives({\n                        format: 'csv',\n                        printerId: filterPrinter || undefined,\n                        status: collection === 'failed' ? 'failed' : undefined,\n                        search: search || undefined,\n                      });\n                      const url = URL.createObjectURL(blob);\n                      const a = document.createElement('a');\n                      a.href = url;\n                      a.download = filename;\n                      a.click();\n                      URL.revokeObjectURL(url);\n                      showToast(t('archives.toast.exportDownloaded'));\n                    } catch {\n                      showToast(t('archives.toast.exportFailed'), 'error');\n                    } finally {\n                      setIsExporting(false);\n                    }\n                  }}\n                >\n                  <FileText className=\"w-4 h-4\" />\n                  Export as CSV\n                </button>\n                <button\n                  className=\"w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg\"\n                  onClick={async () => {\n                    setShowExportMenu(false);\n                    setIsExporting(true);\n                    try {\n                      const { blob, filename } = await api.exportArchives({\n                        format: 'xlsx',\n                        printerId: filterPrinter || undefined,\n                        status: collection === 'failed' ? 'failed' : undefined,\n                        search: search || undefined,\n                      });\n                      const url = URL.createObjectURL(blob);\n                      const a = document.createElement('a');\n                      a.href = url;\n                      a.download = filename;\n                      a.click();\n                      URL.revokeObjectURL(url);\n                      showToast(t('archives.toast.exportDownloaded'));\n                    } catch {\n                      showToast(t('archives.toast.exportFailed'), 'error');\n                    } finally {\n                      setIsExporting(false);\n                    }\n                  }}\n                >\n                  <FileSpreadsheet className=\"w-4 h-4\" />\n                  Export as Excel\n                </button>\n              </div>\n            )}\n          </div>\n          {/* Compare button (only when 2-5 items selected) */}\n          {selectedIds.size >= 2 && selectedIds.size <= 5 && (\n            <Button\n              variant=\"secondary\"\n              onClick={() => setShowCompareModal(true)}\n            >\n              <GitCompare className=\"w-4 h-4\" />\n              Compare ({selectedIds.size})\n            </Button>\n          )}\n          {!selectionMode && (\n            <Button variant=\"secondary\" onClick={() => setIsSelectionMode(true)}>\n              <CheckSquare className=\"w-4 h-4\" />\n              Select\n            </Button>\n          )}\n          <Button\n            onClick={() => setShowUpload(true)}\n            disabled={!hasPermission('archives:create')}\n            title={!hasPermission('archives:create') ? t('archives.permission.noCreate') : undefined}\n          >\n            <Upload className=\"w-4 h-4\" />\n            Upload 3MF\n          </Button>\n        </div>\n      </div>\n\n      {/* View mode toggle — always visible */}\n      <div className=\"flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0 w-fit mb-4\">\n        <button\n          className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}\n          onClick={() => setViewMode('grid')}\n          title={t('archives.gridView')}\n        >\n          <LayoutGrid className=\"w-4 h-4\" />\n        </button>\n        <button\n          className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}\n          onClick={() => setViewMode('list')}\n          title={t('archives.listView')}\n        >\n          <List className=\"w-4 h-4\" />\n        </button>\n        <button\n          className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}\n          onClick={() => setViewMode('calendar')}\n          title={t('archives.calendarView')}\n        >\n          <CalendarDays className=\"w-4 h-4\" />\n        </button>\n        <button\n          className={`p-2 ${viewMode === 'log' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}\n          onClick={() => setViewMode('log')}\n          title={t('archives.logView')}\n        >\n          <ClipboardList className=\"w-4 h-4\" />\n        </button>\n      </div>\n\n      {/* Filters (hidden in log view which has its own filters) */}\n      {viewMode !== 'log' && <Card className=\"mb-6\">\n        <CardContent className=\"py-4\">\n          <div className=\"flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap\">\n            {/* Search - full width on mobile */}\n            <div className=\"w-full md:flex-1 relative md:min-w-[200px]\">\n              <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n              <input\n                ref={searchInputRef}\n                type=\"text\"\n                placeholder={t('archives.searchPlaceholder')}\n                className=\"w-full pl-10 pr-4 py-3 md:py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                value={search}\n                onChange={(e) => setSearch(e.target.value)}\n              />\n            </div>\n            {/* Filters - horizontal scroll on mobile */}\n            <div className=\"flex gap-2 md:gap-4 overflow-x-auto pb-1 md:pb-0 -mx-4 px-4 md:mx-0 md:px-0 md:flex-wrap scrollbar-hide\">\n            <div className=\"flex items-center gap-2 flex-shrink-0\">\n              <Filter className=\"w-4 h-4 text-bambu-gray hidden md:block\" />\n              <select\n                className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                value={filterPrinter || ''}\n                onChange={(e) =>\n                  setFilterPrinter(e.target.value ? Number(e.target.value) : null)\n                }\n              >\n                <option value=\"\">All Printers</option>\n                {printers?.map((p) => (\n                  <option key={p.id} value={p.id}>\n                    {p.name}\n                  </option>\n                ))}\n              </select>\n            </div>\n            <div className=\"flex items-center gap-2 flex-shrink-0\">\n              <Package className=\"w-4 h-4 text-bambu-gray hidden md:block\" />\n              <select\n                className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                value={filterMaterial || ''}\n                onChange={(e) =>\n                  setFilterMaterial(e.target.value || null)\n                }\n              >\n                <option value=\"\">All Materials</option>\n                {uniqueMaterials.map((m) => (\n                  <option key={m} value={m}>\n                    {m}\n                  </option>\n                ))}\n              </select>\n            </div>\n            <div className=\"flex items-center gap-2 flex-shrink-0\">\n              <FileCode className=\"w-4 h-4 text-bambu-gray hidden md:block\" />\n              <select\n                className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                value={filterFileType}\n                onChange={(e) => setFilterFileType(e.target.value as 'all' | 'gcode' | 'source')}\n              >\n                <option value=\"all\">All Files</option>\n                <option value=\"gcode\">Sliced (GCODE)</option>\n                <option value=\"source\">Source Only</option>\n              </select>\n            </div>\n            <button\n              onClick={() => setFilterFavorites(!filterFavorites)}\n              className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${\n                filterFavorites\n                  ? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'\n                  : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n              }`}\n              title={filterFavorites ? t('archives.showAll') : t('archives.showFavoritesOnly')}\n            >\n              <Star className={`w-4 h-4 ${filterFavorites ? 'fill-yellow-400' : ''}`} />\n              <span className=\"text-sm hidden md:inline\">Favorites</span>\n            </button>\n            <button\n              onClick={() => setHideFailed(!hideFailed)}\n              className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${\n                hideFailed\n                  ? 'bg-red-500/20 border-red-500 text-red-400'\n                  : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n              }`}\n              title={hideFailed ? t('archives.showFailedPrints') : t('archives.hideFailedPrints')}\n            >\n              <AlertCircle className={`w-4 h-4 ${hideFailed ? '' : ''}`} />\n              <span className=\"text-sm hidden md:inline\">Hide Failed</span>\n            </button>\n            <button\n              onClick={() => setHideDuplicates(!hideDuplicates)}\n              className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${\n                hideDuplicates\n                  ? 'bg-purple-500/20 border-purple-500 text-purple-400'\n                  : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n              }`}\n              title={t('archives.hideDuplicates')}\n            >\n              <Copy className=\"w-4 h-4\" />\n              <span className=\"text-sm hidden md:inline\">{t('archives.hideDuplicates')}</span>\n            </button>\n            {uniqueTags.length > 0 && (\n              <div className=\"flex items-center gap-2 flex-shrink-0\">\n                <Tag className=\"w-4 h-4 text-bambu-gray hidden md:block\" />\n                <select\n                  className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                  value={filterTag || ''}\n                  onChange={(e) => setFilterTag(e.target.value || null)}\n                >\n                  <option value=\"\">All Tags</option>\n                  {uniqueTags.map((t) => (\n                    <option key={t} value={t}>\n                      {t}\n                    </option>\n                  ))}\n                </select>\n                <button\n                  onClick={() => setShowTagManagement(true)}\n                  className=\"p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors\"\n                  title={t('archives.manageTags')}\n                >\n                  <Settings className=\"w-4 h-4\" />\n                </button>\n              </div>\n            )}\n            <div className=\"flex items-center gap-2 flex-shrink-0\">\n              <ArrowUpDown className=\"w-4 h-4 text-bambu-gray hidden md:block\" />\n              <select\n                className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                value={sortBy}\n                onChange={(e) => setSortBy(e.target.value as SortOption)}\n              >\n                <option value=\"date-desc\">{t('archives.sortNewest')}</option>\n                <option value=\"date-asc\">{t('archives.sortOldest')}</option>\n                <option value=\"name-asc\">{t('archives.sortName')} A-Z</option>\n                <option value=\"name-desc\">{t('archives.sortName')} Z-A</option>\n                <option value=\"size-desc\">{t('archives.sortLargest')}</option>\n                <option value=\"size-asc\">{t('archives.sortSmallest')}</option>\n              </select>\n            </div>\n            </div>\n            {hasTopFilters && (\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={clearTopFilters}\n                className=\"text-bambu-gray hover:text-white\"\n              >\n                <X className=\"w-4 h-4\" />\n                Reset\n              </Button>\n            )}\n          </div>\n          {/* Color Filter */}\n          {uniqueColors.length > 0 && (\n            <div className=\"flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary\">\n              <span className=\"text-xs text-bambu-gray\">Colors:</span>\n              {filterColors.size > 1 && (\n                <button\n                  onClick={() => setColorFilterMode(m => m === 'or' ? 'and' : 'or')}\n                  className={`px-2 py-0.5 text-xs rounded transition-colors ${\n                    colorFilterMode === 'and'\n                      ? 'bg-bambu-green text-white'\n                      : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white'\n                  }`}\n                  title={colorFilterMode === 'or' ? 'Match ANY selected color' : 'Match ALL selected colors'}\n                >\n                  {colorFilterMode.toUpperCase()}\n                </button>\n              )}\n              <div className=\"flex items-center gap-1.5 flex-wrap\">\n                {uniqueColors.map((color) => (\n                  <button\n                    key={color}\n                    onClick={() => toggleColor(color)}\n                    className={`w-6 h-6 rounded-full border-2 transition-all ${\n                      filterColors.has(color)\n                        ? 'border-bambu-green scale-110'\n                        : 'border-white/20 hover:border-white/40'\n                    }`}\n                    style={{ backgroundColor: color }}\n                    title={color}\n                  />\n                ))}\n              </div>\n              {filterColors.size > 0 && (\n                <button\n                  onClick={clearColorFilter}\n                  className=\"text-xs text-bambu-gray hover:text-white flex items-center gap-1\"\n                >\n                  <X className=\"w-3 h-3\" />\n                  Clear\n                </button>\n              )}\n            </div>\n          )}\n        </CardContent>\n      </Card>}\n\n      {/* Pending Uploads Panel (visible when in queue mode with pending files) */}\n      <PendingUploadsPanel />\n\n      {/* Archives */}\n      {isLoading ? (\n        <div className=\"text-center py-12 text-bambu-gray\">{t('archives.loadingArchives')}</div>\n      ) : filteredArchives?.length === 0 ? (\n        <Card>\n          <CardContent className=\"text-center py-12\">\n            <p className=\"text-bambu-gray\">\n              {search ? t('archives.noArchivesSearch') : t('archives.noArchivesYet')}\n            </p>\n            <p className=\"text-sm text-bambu-gray mt-2\">\n              Archives are created automatically when prints complete\n            </p>\n          </CardContent>\n        </Card>\n      ) : viewMode === 'calendar' ? (\n        <Card className=\"p-6\">\n          <CalendarView\n            archives={filteredArchives || []}\n            onArchiveClick={(archive) => {\n              // Switch to grid view and highlight the archive\n              setSearch(''); // Clear search to show all archives\n              setViewMode('grid');\n              setHighlightedArchiveId(archive.id);\n            }}\n            highlightedArchiveId={highlightedArchiveId}\n          />\n        </Card>\n      ) : viewMode === 'grid' ? (\n        <>\n          <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6\">\n            {paginatedArchives?.map((archive) => (\n              <ArchiveCard\n                key={archive.id}\n                archive={archive}\n                printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}\n                isSelected={selectedIds.has(archive.id)}\n                onSelect={toggleSelect}\n                selectionMode={selectionMode}\n                projects={projects}\n                isHighlighted={archive.id === highlightedArchiveId}\n                timeFormat={timeFormat}\n                preferredSlicer={preferredSlicer}\n                currency={currency}\n                t={t}\n                onNavigateToArchive={handleNavigateToArchive}\n              />\n            ))}\n          </div>\n          <ArchivePaginationBar\n            pageIndex={pageIndex}\n            pageSize={pageSize}\n            totalRows={totalFiltered}\n            totalPages={totalPages}\n            onPageChange={setPageIndex}\n            onPageSizeChange={(size) => { setPageSize(size); setPageIndex(0); }}\n            t={t}\n          />\n        </>\n      ) : viewMode === 'list' ? (\n        <>\n          <Card>\n            <div className=\"divide-y divide-bambu-dark-tertiary\">\n              {/* List Header */}\n              <div className=\"grid grid-cols-12 gap-4 px-4 py-3 text-xs text-bambu-gray font-medium\">\n                <div className=\"col-span-1\"></div>\n                <div className=\"col-span-4\">Name</div>\n                <div className=\"col-span-2\">Printer</div>\n                <div className=\"col-span-2\">Date</div>\n                <div className=\"col-span-1\">Size</div>\n                <div className=\"col-span-2 text-right\">Actions</div>\n              </div>\n              {/* List Items */}\n              {paginatedArchives?.map((archive) => (\n                <ArchiveListRow\n                  key={archive.id}\n                  archive={archive}\n                  printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}\n                  isSelected={selectedIds.has(archive.id)}\n                  onSelect={toggleSelect}\n                  selectionMode={selectionMode}\n                  projects={projects}\n                  isHighlighted={archive.id === highlightedArchiveId}\n                  preferredSlicer={preferredSlicer}\n                  t={t}\n                  onNavigateToArchive={handleNavigateToArchive}\n                />\n              ))}\n            </div>\n          </Card>\n          <ArchivePaginationBar\n            pageIndex={pageIndex}\n            pageSize={pageSize}\n            totalRows={totalFiltered}\n            totalPages={totalPages}\n            onPageChange={setPageIndex}\n            onPageSizeChange={(size) => { setPageSize(size); setPageIndex(0); }}\n            t={t}\n          />\n        </>\n      ) : viewMode === 'log' ? (\n        <div className=\"space-y-4\">\n          {/* Log filters */}\n          <Card>\n            <CardContent className=\"py-3\">\n              <div className=\"flex flex-col md:flex-row gap-3 md:items-center md:flex-wrap\">\n                {/* Search */}\n                <div className=\"flex-1 relative md:min-w-[200px]\">\n                  <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n                  <input\n                    type=\"text\"\n                    placeholder={t('archives.searchPlaceholder')}\n                    className=\"w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\"\n                    value={search}\n                    onChange={(e) => { setSearch(e.target.value); setLogOffset(0); }}\n                  />\n                </div>\n                {/* Printer filter */}\n                <select\n                  className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\"\n                  value={filterPrinter || ''}\n                  onChange={(e) => { setFilterPrinter(e.target.value ? Number(e.target.value) : null); setLogOffset(0); }}\n                >\n                  <option value=\"\">{t('archives.log.allPrinters')}</option>\n                  {printers?.map((p) => (\n                    <option key={p.id} value={p.id}>{p.name}</option>\n                  ))}\n                </select>\n                {/* User filter */}\n                <select\n                  className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\"\n                  value={logFilterUser || ''}\n                  onChange={(e) => { setLogFilterUser(e.target.value || null); setLogOffset(0); }}\n                >\n                  <option value=\"\">{t('archives.log.allUsers')}</option>\n                  {users?.map((u) => (\n                    <option key={u.id} value={u.username}>{u.username}</option>\n                  ))}\n                </select>\n                {/* Status filter */}\n                <select\n                  className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\"\n                  value={logFilterStatus || ''}\n                  onChange={(e) => { setLogFilterStatus(e.target.value || null); setLogOffset(0); }}\n                >\n                  <option value=\"\">{t('archives.log.allStatuses')}</option>\n                  <option value=\"completed\">{t('archives.status.completed')}</option>\n                  <option value=\"failed\">{t('archives.status.failed')}</option>\n                  <option value=\"stopped\">{t('archives.status.stopped')}</option>\n                  <option value=\"cancelled\">{t('archives.log.cancelled')}</option>\n                  <option value=\"skipped\">{t('archives.log.skipped')}</option>\n                </select>\n                {/* Date range */}\n                <div className=\"flex items-center gap-2\">\n                  <label className=\"text-sm text-bambu-gray\">{t('archives.log.dateFrom')}</label>\n                  <input\n                    type=\"date\"\n                    className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\"\n                    value={logFilterDateFrom}\n                    onChange={(e) => { setLogFilterDateFrom(e.target.value); setLogOffset(0); }}\n                  />\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  <label className=\"text-sm text-bambu-gray\">{t('archives.log.dateTo')}</label>\n                  <input\n                    type=\"date\"\n                    className=\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\"\n                    value={logFilterDateTo}\n                    onChange={(e) => { setLogFilterDateTo(e.target.value); setLogOffset(0); }}\n                  />\n                </div>\n                {/* Clear log button */}\n                <div className=\"ml-auto\">\n                  <Button\n                    variant=\"danger\"\n                    size=\"sm\"\n                    onClick={() => setShowClearLogConfirm(true)}\n                    disabled={!hasPermission('archives:delete_all') || clearLogMutation.isPending}\n                  >\n                    <Trash2 className=\"w-4 h-4\" />\n                    {t('archives.log.clearLog')}\n                  </Button>\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n\n          {/* Log table */}\n          <Card>\n            {isLogLoading ? (\n              <div className=\"flex items-center justify-center py-12\">\n                <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n              </div>\n            ) : !printLogData?.items.length ? (\n              <div className=\"text-center py-12 text-bambu-gray\">\n                {t('archives.log.noEntries')}\n              </div>\n            ) : (\n              <>\n                <div className=\"overflow-x-auto\">\n                  <table className=\"w-full text-sm\">\n                    <thead>\n                      <tr className=\"border-b border-bambu-dark-tertiary text-bambu-gray text-left\">\n                        <th className=\"px-4 py-3 font-medium\">{t('archives.log.date')}</th>\n                        <th className=\"px-4 py-3 font-medium\">{t('archives.log.printName')}</th>\n                        <th className=\"px-4 py-3 font-medium\">{t('archives.log.printer')}</th>\n                        <th className=\"px-4 py-3 font-medium\">{t('archives.log.user')}</th>\n                        <th className=\"px-4 py-3 font-medium\">{t('archives.log.status')}</th>\n                        <th className=\"px-4 py-3 font-medium\">{t('archives.log.duration')}</th>\n                        <th className=\"px-4 py-3 font-medium\">{t('archives.log.filament')}</th>\n                      </tr>\n                    </thead>\n                    <tbody className=\"divide-y divide-bambu-dark-tertiary\">\n                      {printLogData.items.map((entry) => (\n                        <tr key={entry.id} className=\"hover:bg-bambu-dark-secondary/50\">\n                          <td className=\"px-4 py-3 text-white whitespace-nowrap\">\n                            {formatDateTime(entry.started_at || entry.created_at, timeFormat)}\n                          </td>\n                          <td className=\"px-4 py-3\">\n                            <div className=\"flex items-center gap-2\">\n                              {entry.thumbnail_path && (\n                                <img\n                                  src={api.getPrintLogThumbnail(entry.id)}\n                                  alt=\"\"\n                                  className=\"w-8 h-8 rounded object-cover flex-shrink-0\"\n                                  onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}\n                                />\n                              )}\n                              <span className=\"text-white truncate max-w-[200px]\">\n                                {entry.print_name || '—'}\n                              </span>\n                            </div>\n                          </td>\n                          <td className=\"px-4 py-3 text-bambu-gray-light\">{entry.printer_name || '—'}</td>\n                          <td className=\"px-4 py-3 text-bambu-gray-light\">{entry.created_by_username || '—'}</td>\n                          <td className=\"px-4 py-3\">\n                            <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${\n                              entry.status === 'completed' ? 'bg-green-500/20 text-green-400' :\n                              entry.status === 'failed' ? 'bg-red-500/20 text-red-400' :\n                              entry.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :\n                              entry.status === 'cancelled' ? 'bg-orange-500/20 text-orange-400' :\n                              entry.status === 'skipped' ? 'bg-blue-500/20 text-blue-400' :\n                              'bg-gray-500/20 text-gray-400'\n                            }`}>\n                              {entry.status}\n                            </span>\n                          </td>\n                          <td className=\"px-4 py-3 text-bambu-gray-light whitespace-nowrap\">\n                            {entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}\n                          </td>\n                          <td className=\"px-4 py-3\">\n                            <div className=\"flex items-center gap-1.5\">\n                              {entry.filament_color && (\n                                <span\n                                  className=\"w-3 h-3 rounded-full border border-black/20 flex-shrink-0\"\n                                  style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}\n                                />\n                              )}\n                              <span className=\"text-bambu-gray-light text-xs\">\n                                {entry.filament_type || '—'}\n                              </span>\n                            </div>\n                          </td>\n                        </tr>\n                      ))}\n                    </tbody>\n                  </table>\n                </div>\n                {/* Pagination */}\n                <div className=\"flex items-center justify-between px-4 py-3 border-t border-bambu-dark-tertiary flex-wrap gap-2\">\n                  <div className=\"flex items-center gap-3\">\n                    <span className=\"text-sm text-bambu-gray\">\n                      {t('archives.log.showing', { count: Math.min(logOffset + logPageSize, printLogData.total), total: printLogData.total })}\n                    </span>\n                    <div className=\"flex items-center gap-1.5\">\n                      <label className=\"text-xs text-bambu-gray\">{t('archives.log.rowsPerPage')}</label>\n                      <select\n                        className=\"px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none\"\n                        value={logPageSize}\n                        onChange={(e) => { setLogPageSize(Number(e.target.value)); setLogOffset(0); }}\n                      >\n                        <option value={10}>10</option>\n                        <option value={25}>25</option>\n                        <option value={50}>50</option>\n                        <option value={100}>100</option>\n                      </select>\n                    </div>\n                  </div>\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-sm text-bambu-gray\">\n                      {t('archives.log.page')} {Math.floor(logOffset / logPageSize) + 1} / {Math.max(1, Math.ceil(printLogData.total / logPageSize))}\n                    </span>\n                    <Button variant=\"secondary\" size=\"sm\" onClick={() => setLogOffset(Math.max(0, logOffset - logPageSize))} disabled={logOffset === 0}>\n                      <ChevronLeft className=\"w-4 h-4\" />\n                    </Button>\n                    <Button variant=\"secondary\" size=\"sm\" onClick={() => setLogOffset(logOffset + logPageSize)} disabled={logOffset + logPageSize >= printLogData.total}>\n                      <ChevronRight className=\"w-4 h-4\" />\n                    </Button>\n                  </div>\n                </div>\n              </>\n            )}\n          </Card>\n\n        </div>\n      ) : null}\n\n      {/* Upload Modal */}\n      {showUpload && (\n        <UploadModal\n          onClose={() => {\n            setShowUpload(false);\n            setUploadFiles([]);\n          }}\n          initialFiles={uploadFiles}\n        />\n      )}\n\n      {/* Bulk Delete Confirmation */}\n      {showBulkDeleteConfirm && (\n        <ConfirmModal\n          title={t('archives.modal.deleteArchives')}\n          message={t('archives.modal.deleteArchivesConfirm', { count: selectedIds.size })}\n          confirmText={t('archives.modal.deleteCount', { count: selectedIds.size })}\n          variant=\"danger\"\n          onConfirm={() => {\n            bulkDeleteMutation.mutate(Array.from(selectedIds));\n            setShowBulkDeleteConfirm(false);\n          }}\n          onCancel={() => setShowBulkDeleteConfirm(false)}\n        />\n      )}\n\n      {/* Batch Tag Modal */}\n      {showBatchTag && (\n        <BatchTagModal\n          selectedIds={Array.from(selectedIds)}\n          existingTags={uniqueTags}\n          onClose={() => setShowBatchTag(false)}\n        />\n      )}\n\n      {/* Batch Project Modal */}\n      {showBatchProject && (\n        <BatchProjectModal\n          selectedIds={Array.from(selectedIds)}\n          onClose={() => setShowBatchProject(false)}\n        />\n      )}\n\n      {/* Compare Archives Modal */}\n      {showCompareModal && selectedIds.size >= 2 && selectedIds.size <= 5 && (\n        <CompareArchivesModal\n          archiveIds={Array.from(selectedIds)}\n          onClose={() => {\n            setShowCompareModal(false);\n            setSelectedIds(new Set());\n            setIsSelectionMode(false);\n          }}\n        />\n      )}\n\n      {/* Tag Management Modal */}\n      {showTagManagement && (\n        <TagManagementModal onClose={() => setShowTagManagement(false)} />\n      )}\n\n      {/* Clear Log Confirmation */}\n      {showClearLogConfirm && (\n        <ConfirmModal\n          title={t('archives.log.clearLogTitle')}\n          message={t('archives.log.clearLogConfirm')}\n          confirmText={t('archives.log.clearLogButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            clearLogMutation.mutate();\n            setShowClearLogConfirm(false);\n          }}\n          onCancel={() => setShowClearLogConfirm(false)}\n        />\n      )}\n    </div>\n  );\n}\n\n/* Pagination bar for archives grid/list views */\nfunction ArchivePaginationBar({\n  pageIndex, pageSize, totalRows, totalPages, onPageChange, onPageSizeChange, t,\n}: {\n  pageIndex: number;\n  pageSize: number;\n  totalRows: number;\n  totalPages: number;\n  onPageChange: (page: number) => void;\n  onPageSizeChange: (size: number) => void;\n  t: (key: string) => string;\n}) {\n  const isShowAll = pageSize === -1;\n  if (totalPages <= 1 && !isShowAll) return null;\n  const effectiveSize = isShowAll ? totalRows || 1 : pageSize;\n  return (\n    <div className=\"flex items-center justify-between pt-2 text-sm\">\n      <span className=\"text-bambu-gray\">\n        {isShowAll\n          ? `${totalRows} ${t('archives.prints')}`\n          : <>{t('archives.pagination.showing')} {pageIndex * effectiveSize + 1} {t('archives.pagination.to')}{' '}\n              {Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '}\n              {t('archives.pagination.of')} {totalRows} {t('archives.prints')}</>\n        }\n      </span>\n      <div className=\"flex items-center gap-2\">\n        <span className=\"text-bambu-gray\">{t('archives.pagination.show')}</span>\n        <select\n          value={pageSize}\n          onChange={(e) => onPageSizeChange(Number(e.target.value))}\n          className=\"px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green\"\n        >\n          {[25, 50, 100, 200].map((n) => (\n            <option key={n} value={n}>{n}</option>\n          ))}\n          <option value={-1}>{t('archives.pagination.all')}</option>\n        </select>\n        {!isShowAll && (\n          <>\n            <button\n              onClick={() => onPageChange(0)}\n              disabled={pageIndex === 0}\n              className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n            >\n              <ChevronsLeft className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={() => onPageChange(Math.max(0, pageIndex - 1))}\n              disabled={pageIndex === 0}\n              className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n            >\n              <ChevronLeft className=\"w-4 h-4\" />\n            </button>\n            <span className=\"text-bambu-gray px-2 whitespace-nowrap\">\n              {t('archives.pagination.page')} {pageIndex + 1} {t('archives.pagination.of')} {totalPages}\n            </span>\n            <button\n              onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}\n              disabled={pageIndex >= totalPages - 1}\n              className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n            >\n              <ChevronRight className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={() => onPageChange(totalPages - 1)}\n              disabled={pageIndex >= totalPages - 1}\n              className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n            >\n              <ChevronsRight className=\"w-4 h-4\" />\n            </button>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/CameraPage.tsx",
    "content": "import { useState, useEffect, useRef, useCallback } from 'react';\nimport { useParams } from 'react-router-dom';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';\nimport { api, getAuthToken, getStreamToken, withStreamToken } from '../api/client';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { useStreamTokenSync } from '../hooks/useCameraStreamToken';\nimport { ChamberLight } from '../components/icons/ChamberLight';\nimport { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';\n\nconst MAX_RECONNECT_ATTEMPTS = 5;\nconst INITIAL_RECONNECT_DELAY = 2000; // 2 seconds\nconst MAX_RECONNECT_DELAY = 30000; // 30 seconds\nconst STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds\n\nexport function CameraPage() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission, authEnabled, user } = useAuth();\n  const { printerId } = useParams<{ printerId: string }>();\n  const id = parseInt(printerId || '0', 10);\n\n  // Subscribe to the stream-token query so this page re-renders once the token\n  // arrives. useStreamTokenSync (mounted in App) already owns the fetch; this\n  // useQuery call dedupes via the shared key and just reads the cached value.\n  useStreamTokenSync();\n  const { data: streamTokenData } = useQuery({\n    queryKey: ['camera-stream-token', user?.id ?? null],\n    queryFn: () => api.getCameraStreamToken(),\n    enabled: authEnabled ? !!user : true,\n    staleTime: 50 * 60 * 1000,\n  });\n  const streamTokenValue = streamTokenData?.token ?? getStreamToken();\n\n  const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream');\n  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);\n  const [streamError, setStreamError] = useState(false);\n  const [streamLoading, setStreamLoading] = useState(true);\n  const [imageKey, setImageKey] = useState(Date.now());\n  const [transitioning, setTransitioning] = useState(false);\n  const [isFullscreen, setIsFullscreen] = useState(false);\n  const [reconnectAttempts, setReconnectAttempts] = useState(0);\n  const [isReconnecting, setIsReconnecting] = useState(false);\n  const [reconnectCountdown, setReconnectCountdown] = useState(0);\n  const [zoomLevel, setZoomLevel] = useState(1);\n  const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });\n  const [isPanning, setIsPanning] = useState(false);\n  const [panStart, setPanStart] = useState({ x: 0, y: 0 });\n  const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);\n  const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);\n  const imgRef = useRef<HTMLImageElement>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);\n  const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);\n  const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n  // Fetch printer info for the title\n  const { data: printer } = useQuery({\n    queryKey: ['printer', id],\n    queryFn: () => api.getPrinter(id),\n    enabled: id > 0,\n  });\n\n  // Fetch printer status for light toggle and skip objects\n  const { data: status } = useQuery({\n    queryKey: ['printerStatus', id],\n    queryFn: () => api.getPrinterStatus(id),\n    refetchInterval: 30000,\n    enabled: id > 0,\n  });\n\n  // Chamber light mutation with optimistic update\n  const chamberLightMutation = useMutation({\n    mutationFn: (on: boolean) => api.setChamberLight(id, on),\n    onMutate: async (on) => {\n      await queryClient.cancelQueries({ queryKey: ['printerStatus', id] });\n      const previousStatus = queryClient.getQueryData(['printerStatus', id]);\n      queryClient.setQueryData(['printerStatus', id], (old: typeof status) => ({\n        ...old,\n        chamber_light: on,\n      }));\n      return { previousStatus };\n    },\n    onSuccess: (_, on) => {\n      showToast(`Chamber light ${on ? 'on' : 'off'}`);\n    },\n    onError: (error: Error, _, context) => {\n      if (context?.previousStatus) {\n        queryClient.setQueryData(['printerStatus', id], context.previousStatus);\n      }\n      showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');\n    },\n  });\n\n  const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2;\n\n  // Update document title\n  useEffect(() => {\n    if (printer) {\n      document.title = `${printer.name} - Camera`;\n    }\n    return () => {\n      document.title = 'Bambuddy';\n    };\n  }, [printer]);\n\n  // Cleanup on unmount - stop the camera stream\n  // Track if we've already sent the stop signal to avoid duplicate calls\n  const stopSentRef = useRef(false);\n\n  useEffect(() => {\n    const stopUrl = `/api/v1/printers/${id}/camera/stop`;\n    stopSentRef.current = false;\n\n    const sendStopOnce = () => {\n      if (id > 0 && !stopSentRef.current) {\n        stopSentRef.current = true;\n        const headers: Record<string, string> = {};\n        const token = getAuthToken();\n        if (token) headers['Authorization'] = `Bearer ${token}`;\n        fetch(stopUrl, { method: 'POST', keepalive: true, headers }).catch(() => {});\n      }\n    };\n\n    // Handle page unload/close with keepalive fetch (more reliable than sendBeacon, supports auth)\n    const handleBeforeUnload = () => {\n      sendStopOnce();\n    };\n\n    window.addEventListener('beforeunload', handleBeforeUnload);\n\n    // Store ref value for cleanup - ref may change by cleanup time\n    const imgElement = imgRef.current;\n\n    return () => {\n      window.removeEventListener('beforeunload', handleBeforeUnload);\n\n      // Clear the image source first to stop the stream\n      if (imgElement) {\n        imgElement.src = '';\n      }\n      // Send stop signal only once\n      sendStopOnce();\n    };\n  }, [id]);\n\n  // Auto-hide loading after timeout\n  useEffect(() => {\n    if (streamLoading && !transitioning) {\n      const timeout = streamMode === 'stream' ? 3000 : 20000;\n      const timer = setTimeout(() => {\n        setStreamLoading(false);\n      }, timeout);\n      return () => clearTimeout(timer);\n    }\n  }, [streamMode, streamLoading, imageKey, transitioning]);\n\n  // Fullscreen change listener - refresh stream after fullscreen transition\n  useEffect(() => {\n    const handleFullscreenChange = () => {\n      const nowFullscreen = !!document.fullscreenElement;\n      setIsFullscreen(nowFullscreen);\n      // Reset zoom on fullscreen transition\n      setZoomLevel(1);\n      setPanOffset({ x: 0, y: 0 });\n\n      // Refresh stream after fullscreen transition to prevent stall\n      if (streamMode === 'stream' && !transitioning) {\n        // Clear image src first, then set new key after delay\n        if (imgRef.current) {\n          imgRef.current.src = '';\n        }\n        setTimeout(() => {\n          setStreamLoading(true);\n          setImageKey(Date.now());\n        }, 200);\n      }\n    };\n    document.addEventListener('fullscreenchange', handleFullscreenChange);\n    return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);\n  }, [streamMode, transitioning]);\n\n  // Save window size and position when user resizes or moves\n  // Works for both popup windows and standalone camera pages\n  useEffect(() => {\n    let saveTimeout: NodeJS.Timeout;\n    const saveWindowState = () => {\n      // Debounce to avoid saving during drag\n      clearTimeout(saveTimeout);\n      saveTimeout = setTimeout(() => {\n        localStorage.setItem('cameraWindowState', JSON.stringify({\n          width: window.outerWidth,\n          height: window.outerHeight,\n          left: window.screenX,\n          top: window.screenY,\n        }));\n      }, 500);\n    };\n\n    window.addEventListener('resize', saveWindowState);\n\n    return () => {\n      clearTimeout(saveTimeout);\n      window.removeEventListener('resize', saveWindowState);\n    };\n  }, []);\n\n  // Clean up reconnect timers on unmount\n  useEffect(() => {\n    return () => {\n      if (reconnectTimerRef.current) {\n        clearTimeout(reconnectTimerRef.current);\n      }\n      if (countdownIntervalRef.current) {\n        clearInterval(countdownIntervalRef.current);\n      }\n      if (stallCheckIntervalRef.current) {\n        clearInterval(stallCheckIntervalRef.current);\n      }\n    };\n  }, []);\n\n  // Auto-reconnect logic\n  const attemptReconnect = useCallback(() => {\n    if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n      setIsReconnecting(false);\n      setStreamError(true);\n      return;\n    }\n\n    // Calculate delay with exponential backoff\n    const delay = Math.min(\n      INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),\n      MAX_RECONNECT_DELAY\n    );\n\n    setIsReconnecting(true);\n    setReconnectCountdown(Math.ceil(delay / 1000));\n\n    // Countdown timer\n    countdownIntervalRef.current = setInterval(() => {\n      setReconnectCountdown((prev) => {\n        if (prev <= 1) {\n          if (countdownIntervalRef.current) {\n            clearInterval(countdownIntervalRef.current);\n          }\n          return 0;\n        }\n        return prev - 1;\n      });\n    }, 1000);\n\n    // Reconnect after delay\n    reconnectTimerRef.current = setTimeout(() => {\n      setReconnectAttempts((prev) => prev + 1);\n      setIsReconnecting(false);\n      setStreamLoading(true);\n      setStreamError(false);\n      if (imgRef.current) {\n        imgRef.current.src = '';\n      }\n      setImageKey(Date.now());\n    }, delay);\n  }, [reconnectAttempts]);\n\n  // Stall detection - periodically check if stream is still receiving frames\n  useEffect(() => {\n    // Only skip stall check during initial load, reconnecting, or transitioning\n    // Continue checking even during streamError to detect recovery\n    if (streamMode !== 'stream' || streamLoading || isReconnecting || transitioning) {\n      if (stallCheckIntervalRef.current) {\n        clearInterval(stallCheckIntervalRef.current);\n        stallCheckIntervalRef.current = null;\n      }\n      return;\n    }\n\n    // Start stall detection after stream has loaded\n    stallCheckIntervalRef.current = setInterval(async () => {\n      try {\n        const status = await api.getCameraStatus(id);\n        // Trigger reconnect if:\n        // 1. Backend reports stall (no frames for 10+ seconds)\n        // 2. OR stream is not active anymore (process died)\n        if (status.stalled || (!status.active && !streamError)) {\n          console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);\n          if (stallCheckIntervalRef.current) {\n            clearInterval(stallCheckIntervalRef.current);\n            stallCheckIntervalRef.current = null;\n          }\n          setStreamLoading(false);\n          attemptReconnect();\n        }\n      } catch {\n        // Ignore fetch errors - server might be temporarily unavailable\n      }\n    }, STALL_CHECK_INTERVAL);\n\n    return () => {\n      if (stallCheckIntervalRef.current) {\n        clearInterval(stallCheckIntervalRef.current);\n        stallCheckIntervalRef.current = null;\n      }\n    };\n  }, [streamMode, streamLoading, streamError, isReconnecting, transitioning, id, attemptReconnect]);\n\n  const handleStreamError = () => {\n    setStreamLoading(false);\n\n    // Only auto-reconnect for live stream mode\n    if (streamMode === 'stream' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {\n      attemptReconnect();\n    } else {\n      setStreamError(true);\n    }\n  };\n\n  const handleStreamLoad = () => {\n    setStreamLoading(false);\n    setStreamError(false);\n    // Reset reconnect attempts on successful connection\n    setReconnectAttempts(0);\n    setIsReconnecting(false);\n    if (reconnectTimerRef.current) {\n      clearTimeout(reconnectTimerRef.current);\n    }\n    if (countdownIntervalRef.current) {\n      clearInterval(countdownIntervalRef.current);\n    }\n\n    // Auto-resize window to fit video content (only if no saved preference)\n    if (imgRef.current && !localStorage.getItem('cameraWindowState')) {\n      const img = imgRef.current;\n      const videoWidth = img.naturalWidth;\n      const videoHeight = img.naturalHeight;\n\n      if (videoWidth > 0 && videoHeight > 0) {\n        // Add space for header bar (~45px) and some padding\n        const headerHeight = 45;\n        const padding = 16;\n\n        // Calculate window size (outer size includes chrome)\n        const chromeWidth = window.outerWidth - window.innerWidth;\n        const chromeHeight = window.outerHeight - window.innerHeight;\n\n        const targetWidth = videoWidth + padding + chromeWidth;\n        const targetHeight = videoHeight + headerHeight + padding + chromeHeight;\n\n        try {\n          window.resizeTo(targetWidth, targetHeight);\n        } catch {\n          // resizeTo may not be allowed in all contexts\n        }\n      }\n    }\n  };\n\n  const stopStream = () => {\n    if (id > 0) {\n      const headers: Record<string, string> = {};\n      const token = getAuthToken();\n      if (token) headers['Authorization'] = `Bearer ${token}`;\n      fetch(`/api/v1/printers/${id}/camera/stop`, { method: 'POST', headers }).catch(() => {});\n    }\n  };\n\n  const switchToMode = (newMode: 'stream' | 'snapshot') => {\n    if (streamMode === newMode || transitioning) return;\n    setTransitioning(true);\n    setStreamLoading(true);\n    setStreamError(false);\n    // Reset reconnect state on mode switch\n    setReconnectAttempts(0);\n    setIsReconnecting(false);\n    // Reset zoom on mode switch\n    setZoomLevel(1);\n    setPanOffset({ x: 0, y: 0 });\n    if (reconnectTimerRef.current) {\n      clearTimeout(reconnectTimerRef.current);\n    }\n    if (countdownIntervalRef.current) {\n      clearInterval(countdownIntervalRef.current);\n    }\n\n    if (imgRef.current) {\n      imgRef.current.src = '';\n    }\n\n    // Stop any active streams when switching modes\n    if (streamMode === 'stream') {\n      stopStream();\n    }\n\n    setTimeout(() => {\n      setStreamMode(newMode);\n      setImageKey(Date.now());\n      setTransitioning(false);\n    }, 100);\n  };\n\n  const refresh = () => {\n    if (transitioning) return;\n    setTransitioning(true);\n    setStreamLoading(true);\n    setStreamError(false);\n    // Reset reconnect state on manual refresh\n    setReconnectAttempts(0);\n    setIsReconnecting(false);\n    if (reconnectTimerRef.current) {\n      clearTimeout(reconnectTimerRef.current);\n    }\n    if (countdownIntervalRef.current) {\n      clearInterval(countdownIntervalRef.current);\n    }\n\n    if (imgRef.current) {\n      imgRef.current.src = '';\n    }\n\n    // Stop any active streams before refresh\n    if (streamMode === 'stream') {\n      stopStream();\n    }\n\n    setTimeout(() => {\n      setImageKey(Date.now());\n      setTransitioning(false);\n    }, 100);\n  };\n\n  const toggleFullscreen = () => {\n    if (!containerRef.current) return;\n    if (document.fullscreenElement) {\n      document.exitFullscreen();\n    } else {\n      containerRef.current.requestFullscreen();\n    }\n  };\n\n  const handleZoomIn = () => {\n    setZoomLevel(prev => Math.min(prev + 0.5, 4));\n  };\n\n  const handleZoomOut = () => {\n    setZoomLevel(prev => {\n      const newZoom = Math.max(prev - 0.5, 1);\n      if (newZoom === 1) setPanOffset({ x: 0, y: 0 });\n      return newZoom;\n    });\n  };\n\n  const handleWheel = (e: React.WheelEvent) => {\n    e.preventDefault();\n    if (e.deltaY < 0) {\n      handleZoomIn();\n    } else {\n      handleZoomOut();\n    }\n  };\n\n  const handleImageMouseDown = (e: React.MouseEvent) => {\n    if (zoomLevel > 1) {\n      e.preventDefault();\n      setIsPanning(true);\n      setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });\n    }\n  };\n\n  // Calculate max pan based on container size and zoom level\n  const getMaxPan = useCallback(() => {\n    if (!containerRef.current) {\n      return { x: 300, y: 200 };\n    }\n    const container = containerRef.current.getBoundingClientRect();\n    // Allow panning up to half the zoomed overflow in each direction\n    const maxX = (container.width * (zoomLevel - 1)) / 2;\n    const maxY = (container.height * (zoomLevel - 1)) / 2;\n    return { x: Math.max(50, maxX), y: Math.max(50, maxY) };\n  }, [zoomLevel]);\n\n  const handleImageMouseMove = (e: React.MouseEvent) => {\n    if (isPanning && zoomLevel > 1) {\n      const newX = e.clientX - panStart.x;\n      const newY = e.clientY - panStart.y;\n      const maxPan = getMaxPan();\n      setPanOffset({\n        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),\n        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),\n      });\n    }\n  };\n\n  const handleImageMouseUp = () => {\n    setIsPanning(false);\n  };\n\n  // Touch event handlers for mobile\n  const getTouchDistance = (touches: React.TouchList) => {\n    if (touches.length < 2) return 0;\n    const dx = touches[0].clientX - touches[1].clientX;\n    const dy = touches[0].clientY - touches[1].clientY;\n    return Math.sqrt(dx * dx + dy * dy);\n  };\n\n  const getTouchCenter = (touches: React.TouchList) => {\n    if (touches.length < 2) {\n      return { x: touches[0].clientX, y: touches[0].clientY };\n    }\n    return {\n      x: (touches[0].clientX + touches[1].clientX) / 2,\n      y: (touches[0].clientY + touches[1].clientY) / 2,\n    };\n  };\n\n  const handleTouchStart = (e: React.TouchEvent) => {\n    if (e.touches.length === 2) {\n      // Pinch gesture start\n      e.preventDefault();\n      setLastTouchDistance(getTouchDistance(e.touches));\n      setLastTouchCenter(getTouchCenter(e.touches));\n    } else if (e.touches.length === 1 && zoomLevel > 1) {\n      // Single touch pan start\n      e.preventDefault();\n      setIsPanning(true);\n      setPanStart({\n        x: e.touches[0].clientX - panOffset.x,\n        y: e.touches[0].clientY - panOffset.y,\n      });\n    }\n  };\n\n  const handleTouchMove = (e: React.TouchEvent) => {\n    if (e.touches.length === 2 && lastTouchDistance !== null) {\n      // Pinch gesture\n      e.preventDefault();\n      const newDistance = getTouchDistance(e.touches);\n      const scale = newDistance / lastTouchDistance;\n\n      setZoomLevel(prev => {\n        const newZoom = Math.max(1, Math.min(4, prev * scale));\n        if (newZoom === 1) {\n          setPanOffset({ x: 0, y: 0 });\n        }\n        return newZoom;\n      });\n\n      setLastTouchDistance(newDistance);\n\n      // Also handle pan during pinch\n      const newCenter = getTouchCenter(e.touches);\n      if (lastTouchCenter) {\n        const maxPan = getMaxPan();\n        setPanOffset(prev => ({\n          x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),\n          y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),\n        }));\n      }\n      setLastTouchCenter(newCenter);\n    } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {\n      // Single touch pan\n      e.preventDefault();\n      const newX = e.touches[0].clientX - panStart.x;\n      const newY = e.touches[0].clientY - panStart.y;\n      const maxPan = getMaxPan();\n      setPanOffset({\n        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),\n        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),\n      });\n    }\n  };\n\n  const handleTouchEnd = (e: React.TouchEvent) => {\n    if (e.touches.length < 2) {\n      setLastTouchDistance(null);\n      setLastTouchCenter(null);\n    }\n    if (e.touches.length === 0) {\n      setIsPanning(false);\n    }\n  };\n\n  const resetZoom = () => {\n    setZoomLevel(1);\n    setPanOffset({ x: 0, y: 0 });\n  };\n\n  // When auth is enabled, wait for the stream token before rendering the <img>\n  // src — otherwise the first request fires without ?token= and the backend\n  // rejects it with \"Valid camera stream token required\" (see #979). We append\n  // the token directly from the reactive query value instead of relying on the\n  // module-level cache in withStreamToken(), because that cache is updated in a\n  // useEffect that runs after render.\n  const waitingForStreamToken = authEnabled && !streamTokenValue;\n  const appendToken = (url: string) =>\n    streamTokenValue ? `${url}&token=${encodeURIComponent(streamTokenValue)}` : withStreamToken(url);\n  const currentUrl = transitioning || waitingForStreamToken\n    ? ''\n    : streamMode === 'stream'\n      ? appendToken(`/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`)\n      : appendToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);\n\n  const isDisabled = streamLoading || transitioning || isReconnecting;\n\n  if (!id) {\n    return (\n      <div className=\"min-h-screen bg-black flex items-center justify-center\">\n        <p className=\"text-white\">{t('camera.invalidPrinterId')}</p>\n      </div>\n    );\n  }\n\n  return (\n    <div ref={containerRef} className=\"min-h-screen bg-black flex flex-col\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary\">\n        <h1 className=\"text-sm font-medium text-white flex items-center gap-2\">\n          <Camera className=\"w-4 h-4\" />\n          {printer?.name || `Printer ${id}`}\n        </h1>\n        <div className=\"flex items-center gap-2\">\n          {/* Mode toggle */}\n          <div className=\"flex bg-bambu-dark rounded p-0.5\">\n            <button\n              onClick={() => switchToMode('stream')}\n              disabled={isDisabled}\n              className={`px-3 py-1 text-xs rounded transition-colors ${\n                streamMode === 'stream'\n                  ? 'bg-bambu-green text-white'\n                  : 'text-bambu-gray hover:text-white disabled:opacity-50'\n              }`}\n            >\n              {t('camera.live')}\n            </button>\n            <button\n              onClick={() => switchToMode('snapshot')}\n              disabled={isDisabled}\n              className={`px-3 py-1 text-xs rounded transition-colors ${\n                streamMode === 'snapshot'\n                  ? 'bg-bambu-green text-white'\n                  : 'text-bambu-gray hover:text-white disabled:opacity-50'\n              }`}\n            >\n              {t('camera.snapshot')}\n            </button>\n          </div>\n          <button\n            onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}\n            disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}\n            className={`p-1.5 rounded disabled:opacity-50 ${status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30' : 'hover:bg-bambu-dark-tertiary'}`}\n            title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('camera.chamberLight')}\n          >\n            <ChamberLight on={status?.chamber_light ?? false} className=\"w-4 h-4\" />\n          </button>\n          <button\n            onClick={() => setShowSkipObjectsModal(true)}\n            disabled={!isPrintingWithObjects || !hasPermission('printers:control')}\n            className={`p-1.5 rounded disabled:opacity-50 ${isPrintingWithObjects && hasPermission('printers:control') ? 'hover:bg-bambu-dark-tertiary' : ''}`}\n            title={\n              !hasPermission('printers:control')\n                ? t('printers.permission.noControl')\n                : !isPrintingWithObjects\n                  ? t('printers.skipObjects.onlyWhilePrinting')\n                  : t('printers.skipObjects.tooltip')\n            }\n          >\n            <SkipObjectsIcon className=\"w-4 h-4 text-bambu-gray\" />\n          </button>\n          <button\n            onClick={refresh}\n            disabled={isDisabled}\n            className=\"p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50\"\n            title={streamMode === 'stream' ? t('camera.restartStream') : t('camera.refreshSnapshot')}\n          >\n            <RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />\n          </button>\n          <button\n            onClick={toggleFullscreen}\n            className=\"p-1.5 hover:bg-bambu-dark-tertiary rounded\"\n            title={isFullscreen ? t('camera.exitFullscreen') : t('camera.fullscreen')}\n          >\n            {isFullscreen ? (\n              <Minimize className=\"w-4 h-4 text-bambu-gray\" />\n            ) : (\n              <Maximize className=\"w-4 h-4 text-bambu-gray\" />\n            )}\n          </button>\n        </div>\n      </div>\n\n      {/* Video area */}\n      <div\n        className=\"flex-1 flex items-center justify-center p-2 overflow-hidden\"\n        onWheel={handleWheel}\n        onMouseMove={handleImageMouseMove}\n        onMouseUp={handleImageMouseUp}\n        onMouseLeave={handleImageMouseUp}\n        onTouchStart={handleTouchStart}\n        onTouchMove={handleTouchMove}\n        onTouchEnd={handleTouchEnd}\n        style={{ touchAction: 'none' }}\n      >\n        <div className=\"relative w-full h-full flex items-center justify-center\">\n          {(streamLoading || transitioning) && !isReconnecting && (\n            <div className=\"absolute inset-0 flex items-center justify-center bg-black/50 z-10\">\n              <div className=\"text-center\">\n                <RefreshCw className=\"w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2\" />\n                <p className=\"text-sm text-bambu-gray\">\n                  {streamMode === 'stream' ? t('camera.connectingToCamera') : t('camera.capturingSnapshot')}\n                </p>\n              </div>\n            </div>\n          )}\n          {isReconnecting && (\n            <div className=\"absolute inset-0 flex items-center justify-center bg-black/80 z-10\">\n              <div className=\"text-center p-4\">\n                <WifiOff className=\"w-10 h-10 text-orange-400 mx-auto mb-3\" />\n                <p className=\"text-white mb-2\">{t('camera.connectionLost')}</p>\n                <p className=\"text-sm text-bambu-gray mb-3\">\n                  {t('camera.reconnecting', { countdown: reconnectCountdown, attempt: Math.min(reconnectAttempts + 1, MAX_RECONNECT_ATTEMPTS), max: MAX_RECONNECT_ATTEMPTS })}\n                </p>\n                <button\n                  onClick={refresh}\n                  className=\"px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors\"\n                >\n                  {t('camera.reconnectNow')}\n                </button>\n              </div>\n            </div>\n          )}\n          {streamError && !isReconnecting && (\n            <div className=\"absolute inset-0 flex items-center justify-center bg-black z-10\">\n              <div className=\"text-center p-4\">\n                <AlertTriangle className=\"w-12 h-12 text-orange-400 mx-auto mb-3\" />\n                <p className=\"text-white mb-2\">{t('camera.cameraUnavailable')}</p>\n                <p className=\"text-xs text-bambu-gray mb-4 max-w-md\">\n                  {t('camera.cameraUnavailableDesc')}\n                </p>\n                <button\n                  onClick={refresh}\n                  className=\"px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors\"\n                >\n                  {t('camera.retry')}\n                </button>\n              </div>\n            </div>\n          )}\n          <img\n            ref={imgRef}\n            key={imageKey}\n            src={currentUrl}\n            alt={t('camera.cameraStream')}\n            className=\"max-w-full max-h-full object-contain select-none\"\n            style={{\n              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px) rotate(${printer?.camera_rotation || 0}deg)`,\n              ...(printer?.camera_rotation === 90 || printer?.camera_rotation === 270 ? { maxWidth: '100vh', maxHeight: '100vw' } : {}),\n              cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',\n            }}\n            onError={currentUrl ? handleStreamError : undefined}\n            onLoad={currentUrl ? handleStreamLoad : undefined}\n            onMouseDown={handleImageMouseDown}\n            draggable={false}\n          />\n\n          {/* Zoom controls */}\n          <div className=\"absolute bottom-4 left-4 flex items-center gap-1.5 bg-black/60 rounded-lg px-2 py-1.5\">\n            <button\n              onClick={handleZoomOut}\n              disabled={zoomLevel <= 1}\n              className=\"p-1.5 hover:bg-white/10 rounded disabled:opacity-30\"\n              title={t('camera.zoomOut')}\n            >\n              <ZoomOut className=\"w-4 h-4 text-white\" />\n            </button>\n            <button\n              onClick={resetZoom}\n              className=\"px-2 py-1 text-sm text-white hover:bg-white/10 rounded min-w-[48px]\"\n              title={t('camera.resetZoom')}\n            >\n              {Math.round(zoomLevel * 100)}%\n            </button>\n            <button\n              onClick={handleZoomIn}\n              disabled={zoomLevel >= 4}\n              className=\"p-1.5 hover:bg-white/10 rounded disabled:opacity-30\"\n              title={t('camera.zoomIn')}\n            >\n              <ZoomIn className=\"w-4 h-4 text-white\" />\n            </button>\n          </div>\n        </div>\n      </div>\n\n      {/* Skip Objects Modal */}\n      <SkipObjectsModal\n        printerId={id}\n        isOpen={showSkipObjectsModal}\n        onClose={() => setShowSkipObjectsModal(false)}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/ExternalLinkPage.tsx",
    "content": "import { useParams } from 'react-router-dom';\nimport { useQuery } from '@tanstack/react-query';\nimport { Loader2, AlertTriangle } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport { useTheme } from '../contexts/ThemeContext';\n\nexport function ExternalLinkPage() {\n  const { t } = useTranslation();\n  const { id } = useParams<{ id: string }>();\n  const { mode } = useTheme();\n\n  const { data: link, isLoading, error } = useQuery({\n    queryKey: ['external-link', id],\n    queryFn: () => api.getExternalLink(Number(id)),\n    enabled: !!id,\n  });\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center h-full\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  if (error || !link) {\n    return (\n      <div className=\"flex flex-col items-center justify-center h-full gap-4 text-bambu-gray\">\n        <AlertTriangle className=\"w-12 h-12\" />\n        <p>{t('common.linkNotFound')}</p>\n      </div>\n    );\n  }\n\n  return (\n    <iframe\n      src={link.url}\n      className=\"h-full w-full border-0\"\n      style={{ colorScheme: mode }}\n      title={link.name}\n      sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"\n    />\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/FileManagerPage.tsx",
    "content": "import { useState, useRef, useCallback, useMemo, useEffect } from 'react';\nimport { useSearchParams } from 'react-router-dom';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  FolderOpen,\n  Loader2,\n  Plus,\n  Upload,\n  Trash2,\n  Download,\n  MoreVertical,\n  ChevronRight,\n  FolderPlus,\n  FileBox,\n  Clock,\n  HardDrive,\n  File,\n  MoveRight,\n  CheckSquare,\n  Square,\n  LayoutGrid,\n  List,\n  Search,\n  SortAsc,\n  SortDesc,\n  AlertTriangle,\n  Filter,\n  X,\n  Link2,\n  Unlink,\n  Archive as ArchiveIcon,\n  Briefcase,\n  Printer,\n  Pencil,\n  Play,\n  Image,\n  User,\n  Box,\n  RefreshCw,\n  Lock,\n  FolderSymlink,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport type {\n  LibraryFolderTree,\n  LibraryFileListItem,\n  LibraryFolderCreate,\n  LibraryFolderUpdate,\n  ExternalFolderCreate,\n  AppSettings,\n  Archive,\n  Permission,\n} from '../api/client';\nimport { Button } from '../components/Button';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { PrintModal } from '../components/PrintModal';\nimport { ModelViewerModal } from '../components/ModelViewerModal';\nimport { FileUploadModal } from '../components/FileUploadModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { useIsMobile } from '../hooks/useIsMobile';\nimport { useAuth } from '../contexts/AuthContext';\nimport { formatDuration, parseUTCDate } from '../utils/date';\nimport { formatFileSize } from '../utils/file';\n\ntype SortField = 'name' | 'date' | 'size' | 'type' | 'prints';\ntype SortDirection = 'asc' | 'desc';\ntype TFunction = (key: string, options?: Record<string, unknown>) => string;\n\n// New Folder Modal\ninterface NewFolderModalProps {\n  parentId: number | null;\n  onClose: () => void;\n  onSave: (data: LibraryFolderCreate) => void;\n  isLoading: boolean;\n  t: TFunction;\n}\n\nfunction NewFolderModal({ parentId, onClose, onSave, isLoading, t }: NewFolderModalProps) {\n  const [name, setName] = useState('');\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    onSave({ name: name.trim(), parent_id: parentId });\n  };\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary\">\n        <div className=\"p-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white\">{t('fileManager.newFolder')}</h2>\n        </div>\n        <form onSubmit={handleSubmit} className=\"p-4 space-y-4\">\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('fileManager.folderName')}\n            </label>\n            <input\n              type=\"text\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n              placeholder={t('fileManager.folderNamePlaceholder')}\n              autoFocus\n              required\n            />\n          </div>\n          <div className=\"flex justify-end gap-2 pt-2\">\n            <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n              {t('common.cancel')}\n            </Button>\n            <Button type=\"submit\" disabled={!name.trim() || isLoading}>\n              {isLoading ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : t('common.create')}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n\n// External Folder Modal\ninterface ExternalFolderModalProps {\n  onClose: () => void;\n  onSave: (data: ExternalFolderCreate) => void;\n  isLoading: boolean;\n  t: TFunction;\n}\n\nfunction ExternalFolderModal({ onClose, onSave, isLoading, t }: ExternalFolderModalProps) {\n  const [name, setName] = useState('');\n  const [path, setPath] = useState('');\n  const [readonly, setReadonly] = useState(true);\n  const [showHidden, setShowHidden] = useState(false);\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    onSave({\n      name: name.trim(),\n      external_path: path.trim(),\n      readonly,\n      show_hidden: showHidden,\n    });\n  };\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary\">\n        <div className=\"p-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n            <FolderSymlink className=\"w-5 h-5 text-bambu-green\" />\n            {t('fileManager.linkExternalFolder')}\n          </h2>\n          <p className=\"text-sm text-bambu-gray mt-1\">{t('fileManager.linkExternalFolderDescription')}</p>\n        </div>\n        <form onSubmit={handleSubmit} className=\"p-4 space-y-4\">\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('fileManager.folderName')}\n            </label>\n            <input\n              type=\"text\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n              placeholder={t('fileManager.externalFolderNamePlaceholder')}\n              autoFocus\n              required\n            />\n          </div>\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('fileManager.externalPath')}\n            </label>\n            <input\n              type=\"text\"\n              value={path}\n              onChange={(e) => setPath(e.target.value)}\n              className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green font-mono text-sm\"\n              placeholder=\"/mnt/nas/3d-prints\"\n              required\n            />\n            <p className=\"text-xs text-bambu-gray mt-1\">{t('fileManager.externalPathHelp')}</p>\n          </div>\n          <div className=\"space-y-2\">\n            <label className=\"flex items-center gap-2 cursor-pointer\">\n              <input\n                type=\"checkbox\"\n                checked={readonly}\n                onChange={(e) => setReadonly(e.target.checked)}\n                className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n              />\n              <span className=\"text-sm text-white\">{t('fileManager.readOnly')}</span>\n              <span className=\"text-xs text-bambu-gray\">({t('fileManager.readOnlyHelp')})</span>\n            </label>\n            <label className=\"flex items-center gap-2 cursor-pointer\">\n              <input\n                type=\"checkbox\"\n                checked={showHidden}\n                onChange={(e) => setShowHidden(e.target.checked)}\n                className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n              />\n              <span className=\"text-sm text-white\">{t('fileManager.showHiddenFiles')}</span>\n            </label>\n          </div>\n          <div className=\"flex justify-end gap-2 pt-2\">\n            <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n              {t('common.cancel')}\n            </Button>\n            <Button type=\"submit\" disabled={!name.trim() || !path.trim() || isLoading}>\n              {isLoading ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : t('fileManager.linkFolder')}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n\n// Rename Modal\ninterface RenameModalProps {\n  type: 'file' | 'folder';\n  currentName: string;\n  onClose: () => void;\n  onSave: (newName: string) => void;\n  isLoading: boolean;\n  t: TFunction;\n}\n\nfunction RenameModal({ type, currentName, onClose, onSave, isLoading, t }: RenameModalProps) {\n  // For files, separate the extension so users can only edit the base name\n  // Handle compound extensions like .gcode.3mf\n  const fileExtension = type === 'file' ? (currentName.match(/(\\.gcode\\.3mf|\\.3mf|\\.gcode)$/i)?.[1] ?? '') : '';\n  const baseName = type === 'file' && fileExtension ? currentName.slice(0, -fileExtension.length) : currentName;\n  const [name, setName] = useState(baseName);\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    const fullName = type === 'file' ? name.trim() + fileExtension : name.trim();\n    if (name.trim() && fullName !== currentName) {\n      onSave(fullName);\n    }\n  };\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary\">\n        <div className=\"p-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white\">{type === 'file' ? t('fileManager.renameFile') : t('fileManager.renameFolder')}</h2>\n        </div>\n        <form onSubmit={handleSubmit} className=\"p-4 space-y-4\">\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('common.name')}\n            </label>\n            <div className=\"flex items-center bg-bambu-dark border border-bambu-dark-tertiary rounded focus-within:border-bambu-green\">\n              <input\n                type=\"text\"\n                value={name}\n                onChange={(e) => setName(e.target.value)}\n                className=\"flex-1 bg-transparent px-3 py-2 text-white placeholder-bambu-gray focus:outline-none min-w-0\"\n                autoFocus\n                required\n              />\n              {fileExtension && (\n                <span className=\"pr-3 text-bambu-gray text-sm select-none whitespace-nowrap\">{fileExtension}</span>\n              )}\n            </div>\n          </div>\n          <div className=\"flex justify-end gap-2 pt-2\">\n            <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n              {t('common.cancel')}\n            </Button>\n            <Button type=\"submit\" disabled={!name.trim() || name.trim() === baseName || isLoading}>\n              {isLoading ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : t('common.rename')}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n\n// Move Files Modal\ninterface MoveFilesModalProps {\n  folders: LibraryFolderTree[];\n  selectedFiles: number[];\n  currentFolderId: number | null;\n  onClose: () => void;\n  onMove: (folderId: number | null) => void;\n  isLoading: boolean;\n  t: TFunction;\n}\n\nfunction MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading, t }: MoveFilesModalProps) {\n  const [targetFolder, setTargetFolder] = useState<number | null>(null);\n\n  const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number | null; name: string; depth: number }[] => {\n    const result: { id: number | null; name: string; depth: number }[] = [];\n    for (const item of items) {\n      result.push({ id: item.id, name: item.name, depth });\n      if (item.children.length > 0) {\n        result.push(...flattenFolders(item.children, depth + 1));\n      }\n    }\n    return result;\n  };\n\n  const flatFolders = [{ id: null, name: t('fileManager.rootNoFolder'), depth: 0 }, ...flattenFolders(folders)];\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary\">\n        <div className=\"p-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white\">{t('fileManager.moveFiles', { count: selectedFiles.length })}</h2>\n        </div>\n        <div className=\"p-4 space-y-4\">\n          <div className=\"max-h-64 overflow-y-auto space-y-1\">\n            {flatFolders.map((folder) => (\n              <button\n                key={folder.id ?? 'root'}\n                onClick={() => setTargetFolder(folder.id)}\n                disabled={folder.id === currentFolderId}\n                className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${\n                  targetFolder === folder.id\n                    ? 'bg-bambu-green/20 text-bambu-green'\n                    : folder.id === currentFolderId\n                    ? 'opacity-50 cursor-not-allowed text-bambu-gray'\n                    : 'hover:bg-bambu-dark text-white'\n                }`}\n                style={{ paddingLeft: `${12 + folder.depth * 16}px` }}\n              >\n                <FolderOpen className=\"w-4 h-4\" />\n                {folder.name}\n                {folder.id === currentFolderId && <span className=\"text-xs text-bambu-gray ml-auto\">({t('fileManager.current')})</span>}\n              </button>\n            ))}\n          </div>\n          <div className=\"flex justify-end gap-2 pt-2\">\n            <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n              {t('common.cancel')}\n            </Button>\n            <Button onClick={() => onMove(targetFolder)} disabled={isLoading}>\n              {isLoading ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : t('common.move')}\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// Link Folder Modal\ninterface LinkFolderModalProps {\n  folder: LibraryFolderTree;\n  onClose: () => void;\n  onLink: (update: LibraryFolderUpdate) => void;\n  isLoading: boolean;\n  t: TFunction;\n}\n\nfunction LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderModalProps) {\n  const [linkType, setLinkType] = useState<'project' | 'archive'>('project');\n  const [selectedId, setSelectedId] = useState<number | null>(\n    folder.project_id || folder.archive_id || null\n  );\n\n  // Initialize linkType based on existing link\n  useState(() => {\n    if (folder.archive_id) setLinkType('archive');\n  });\n\n  const { data: projects } = useQuery({\n    queryKey: ['projects'],\n    queryFn: () => api.getProjects(),\n  });\n\n  const { data: archives } = useQuery({\n    queryKey: ['archives-for-link'],\n    queryFn: () => api.getArchives(undefined, undefined, 100),\n  });\n\n  const handleSave = () => {\n    if (linkType === 'project') {\n      onLink({\n        project_id: selectedId,\n        archive_id: 0, // Unlink archive\n      });\n    } else {\n      onLink({\n        project_id: 0, // Unlink project\n        archive_id: selectedId,\n      });\n    }\n  };\n\n  const handleUnlink = () => {\n    onLink({\n      project_id: 0,\n      archive_id: 0,\n    });\n  };\n\n  const isLinked = folder.project_id || folder.archive_id;\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary\">\n        <div className=\"p-4 border-b border-bambu-dark-tertiary flex items-center justify-between\">\n          <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n            <Link2 className=\"w-5 h-5 text-bambu-green\" />\n            {t('fileManager.linkFolder')}\n          </h2>\n          <button onClick={onClose} className=\"p-1 hover:bg-bambu-dark rounded\">\n            <X className=\"w-5 h-5 text-bambu-gray\" />\n          </button>\n        </div>\n\n        <div className=\"p-4 space-y-4\">\n          <p className=\"text-sm text-bambu-gray\">\n            {t('fileManager.linkFolderDescription', { name: folder.name })}\n          </p>\n\n          {/* Link type selector */}\n          <div className=\"flex gap-2\">\n            <button\n              onClick={() => { setLinkType('project'); setSelectedId(null); }}\n              className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${\n                linkType === 'project'\n                  ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'\n                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n              }`}\n            >\n              <Briefcase className=\"w-4 h-4\" />\n              {t('fileManager.project')}\n            </button>\n            <button\n              onClick={() => { setLinkType('archive'); setSelectedId(null); }}\n              className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${\n                linkType === 'archive'\n                  ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'\n                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'\n              }`}\n            >\n              <ArchiveIcon className=\"w-4 h-4\" />\n              {t('fileManager.archive')}\n            </button>\n          </div>\n\n          {/* Selection list */}\n          <div className=\"max-h-64 overflow-y-auto space-y-1 bg-bambu-dark rounded-lg p-2\">\n            {linkType === 'project' ? (\n              projects && projects.length > 0 ? (\n                projects.map((project) => (\n                  <button\n                    key={project.id}\n                    onClick={() => setSelectedId(project.id)}\n                    className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${\n                      selectedId === project.id\n                        ? 'bg-bambu-green/20 text-bambu-green'\n                        : 'hover:bg-bambu-dark-tertiary text-white'\n                    }`}\n                  >\n                    <div\n                      className=\"w-3 h-3 rounded-full flex-shrink-0\"\n                      style={{ backgroundColor: project.color || '#00ae42' }}\n                    />\n                    <span className=\"truncate\">{project.name}</span>\n                  </button>\n                ))\n              ) : (\n                <p className=\"text-sm text-bambu-gray text-center py-4\">{t('fileManager.noProjectsFound')}</p>\n              )\n            ) : (\n              archives && archives.length > 0 ? (\n                archives.map((archive: Archive) => (\n                  <button\n                    key={archive.id}\n                    onClick={() => setSelectedId(archive.id)}\n                    className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${\n                      selectedId === archive.id\n                        ? 'bg-bambu-green/20 text-bambu-green'\n                        : 'hover:bg-bambu-dark-tertiary text-white'\n                    }`}\n                  >\n                    <FileBox className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n                    <span className=\"truncate\">{archive.print_name || archive.filename}</span>\n                  </button>\n                ))\n              ) : (\n                <p className=\"text-sm text-bambu-gray text-center py-4\">{t('fileManager.noArchivesFound')}</p>\n              )\n            )}\n          </div>\n        </div>\n\n        <div className=\"p-4 border-t border-bambu-dark-tertiary flex justify-between\">\n          {isLinked && (\n            <Button variant=\"danger\" onClick={handleUnlink} disabled={isLoading}>\n              <Unlink className=\"w-4 h-4 mr-2\" />\n              {t('fileManager.unlink')}\n            </Button>\n          )}\n          <div className={`flex gap-2 ${!isLinked ? 'ml-auto' : ''}`}>\n            <Button variant=\"secondary\" onClick={onClose}>\n              {t('common.cancel')}\n            </Button>\n            <Button onClick={handleSave} disabled={!selectedId || isLoading}>\n              {isLoading ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : t('fileManager.link')}\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// Folder Tree Item\ninterface FolderTreeItemProps {\n  folder: LibraryFolderTree;\n  selectedFolderId: number | null;\n  onSelect: (id: number | null) => void;\n  onDelete: (id: number) => void;\n  onLink: (folder: LibraryFolderTree) => void;\n  onRename: (folder: LibraryFolderTree) => void;\n  depth?: number;\n  wrapNames?: boolean;\n  defaultExpanded?: boolean;\n  hasPermission: (permission: Permission) => boolean;\n  t: TFunction;\n}\n\nfunction FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, hasPermission, t }: FolderTreeItemProps) {\n  const [expanded, setExpanded] = useState(defaultExpanded);\n  const [showActions, setShowActions] = useState(false);\n  const hasChildren = folder.children.length > 0;\n  const isLinked = folder.project_id || folder.archive_id;\n  const isExternal = folder.is_external;\n\n  return (\n    <div>\n      <div\n        className={`group flex items-center gap-1 px-2 py-1.5 rounded cursor-pointer transition-colors ${\n          selectedFolderId === folder.id\n            ? 'bg-bambu-green/20 text-bambu-green'\n            : 'hover:bg-bambu-dark text-white'\n        }`}\n        style={{ paddingLeft: `${8 + depth * 12}px` }}\n        onClick={() => onSelect(folder.id)}\n      >\n        {hasChildren ? (\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              setExpanded(!expanded);\n            }}\n            className=\"p-0.5 hover:bg-bambu-dark-tertiary rounded\"\n          >\n            <ChevronRight className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />\n          </button>\n        ) : (\n          <div className=\"w-4.5\" />\n        )}\n        {isExternal ? (\n          <FolderSymlink className=\"w-4 h-4 text-purple-400 flex-shrink-0\" />\n        ) : (\n          <FolderOpen className=\"w-4 h-4 text-bambu-green flex-shrink-0\" />\n        )}\n        <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>\n        {/* Link indicator - clickable to change link */}\n        {isLinked && (\n          <button\n            onClick={(e) => { e.stopPropagation(); onLink(folder); }}\n            className=\"flex-shrink-0 flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors\"\n            title={`${folder.project_name ? `Project: ${folder.project_name}` : `Archive: ${folder.archive_name}`} (click to change)`}\n          >\n            <Link2 className=\"w-3 h-3\" />\n            {folder.project_name ? (\n              <Briefcase className=\"w-3 h-3\" />\n            ) : (\n              <ArchiveIcon className=\"w-3 h-3\" />\n            )}\n          </button>\n        )}\n        {/* Read-only indicator for external folders */}\n        {isExternal && folder.external_readonly && (\n          <span title={t('fileManager.readOnly')}>\n            <Lock className=\"w-3 h-3 text-amber-400 flex-shrink-0\" />\n          </span>\n        )}\n        {folder.file_count > 0 && (\n          <span className=\"flex-shrink-0 text-xs text-bambu-gray\">{folder.file_count}</span>\n        )}\n        {/* Quick link button - always visible for unlinked folders */}\n        {!isLinked && !isExternal && (\n          <button\n            onClick={(e) => { e.stopPropagation(); onLink(folder); }}\n            className=\"flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary\"\n            title={t('fileManager.linkToProjectOrArchive')}\n          >\n            <Link2 className=\"w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green\" />\n          </button>\n        )}\n        <div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>\n          <div className=\"relative\">\n            <button\n              onClick={() => setShowActions(!showActions)}\n              className=\"p-1 rounded hover:bg-bambu-dark-tertiary\"\n            >\n              <MoreVertical className=\"w-3.5 h-3.5 text-bambu-gray\" />\n            </button>\n            {showActions && (\n              <>\n                <div className=\"fixed inset-0 z-10\" onClick={() => setShowActions(false)} />\n                <div className=\"absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]\">\n                <button\n                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                    hasPermission('library:update_all') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                  }`}\n                  onClick={() => { if (hasPermission('library:update_all')) { onRename(folder); setShowActions(false); } }}\n                  disabled={!hasPermission('library:update_all')}\n                  title={!hasPermission('library:update_all') ? t('fileManager.noPermissionRenameFolder') : undefined}\n                >\n                  <Pencil className=\"w-3.5 h-3.5\" />\n                  {t('common.rename')}\n                </button>\n                <button\n                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                    hasPermission('library:update_all') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                  }`}\n                  onClick={() => { if (hasPermission('library:update_all')) { onLink(folder); setShowActions(false); } }}\n                  disabled={!hasPermission('library:update_all')}\n                  title={!hasPermission('library:update_all') ? t('fileManager.noPermissionLinkFolder') : undefined}\n                >\n                  <Link2 className=\"w-3.5 h-3.5\" />\n                  {isLinked ? t('fileManager.changeLink') : t('fileManager.linkTo')}\n                </button>\n                <button\n                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                    hasPermission('library:delete_all') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                  }`}\n                  onClick={() => { if (hasPermission('library:delete_all')) { onDelete(folder.id); setShowActions(false); } }}\n                  disabled={!hasPermission('library:delete_all')}\n                  title={!hasPermission('library:delete_all') ? t('fileManager.noPermissionDeleteFolder') : undefined}\n                >\n                  <Trash2 className=\"w-3.5 h-3.5\" />\n                  {t('common.delete')}\n                </button>\n              </div>\n              </>\n            )}\n          </div>\n        </div>\n      </div>\n      {hasChildren && expanded && (\n        <div>\n          {folder.children.map((child) => (\n            <FolderTreeItem\n              key={child.id}\n              folder={child}\n              selectedFolderId={selectedFolderId}\n              onSelect={onSelect}\n              onDelete={onDelete}\n              onLink={onLink}\n              onRename={onRename}\n              depth={depth + 1}\n              wrapNames={wrapNames}\n              defaultExpanded={defaultExpanded}\n              hasPermission={hasPermission}\n              t={t}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\n// Helper to check if a file is sliced (printable)\nfunction isSlicedFilename(filename: string): boolean {\n  const lower = filename.toLowerCase();\n  return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');\n}\n\n// File Card\ninterface FileCardProps {\n  file: LibraryFileListItem;\n  isSelected: boolean;\n  isMobile: boolean;\n  onSelect: (id: number) => void;\n  onDelete: (id: number) => void;\n  onDownload: (id: number) => void;\n  onAddToQueue?: (id: number) => void;\n  onPrint?: (file: LibraryFileListItem) => void;\n  onPreview3d?: (file: LibraryFileListItem) => void;\n  onRename?: (file: LibraryFileListItem) => void;\n  onGenerateThumbnail?: (file: LibraryFileListItem) => void;\n  thumbnailVersion?: number;\n  hasPermission: (permission: Permission) => boolean;\n  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;\n  authEnabled: boolean;\n  t: TFunction;\n}\n\nfunction FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {\n  const [showActions, setShowActions] = useState(false);\n\n  return (\n    <div\n      className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${\n        isSelected\n          ? 'border-bambu-green ring-1 ring-bambu-green'\n          : 'border-bambu-dark-tertiary hover:border-bambu-green/50'\n      }`}\n      onClick={() => onSelect(file.id)}\n    >\n      {/* Thumbnail */}\n      <div className=\"aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden\">\n        {file.thumbnail_path ? (\n          <img\n            src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersion}`) : ''}`}\n            alt={file.filename}\n            className=\"w-full h-full object-cover\"\n          />\n        ) : (\n          <FileBox className=\"w-12 h-12 text-bambu-gray/30\" />\n        )}\n        {/* File type badge */}\n        <div className={`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${\n          file.file_type === '3mf' ? 'bg-bambu-green/90 text-white'\n          : file.file_type === 'gcode' ? 'bg-blue-500/90 text-white'\n          : file.file_type === 'stl' ? 'bg-purple-500/90 text-white'\n          : 'bg-bambu-gray/90 text-white'\n        }`}>\n          {file.file_type.toUpperCase()}\n        </div>\n      </div>\n\n      {/* Info */}\n      <div className=\"p-3\">\n        <h3 className=\"text-sm font-medium text-white truncate\" title={file.print_name || file.filename}>\n          {file.print_name || file.filename}\n        </h3>\n        <div className=\"flex items-center gap-3 mt-1 text-xs text-bambu-gray\">\n          <span>{formatFileSize(file.file_size)}</span>\n          {file.print_time_seconds && (\n            <span className=\"flex items-center gap-1\">\n              <Clock className=\"w-3 h-3\" />\n              {formatDuration(file.print_time_seconds)}\n            </span>\n          )}\n        </div>\n        {file.sliced_for_model && (\n          <div className=\"mt-1 text-xs text-bambu-gray flex items-center gap-1\">\n            <Printer className=\"w-3 h-3\" />\n            {file.sliced_for_model}\n          </div>\n        )}\n        {file.print_count > 0 && (\n          <div className=\"mt-1 text-xs text-bambu-green\">\n            {t('fileManager.printedCount', { count: file.print_count })}\n          </div>\n        )}\n        {authEnabled && file.created_by_username && (\n          <div className=\"mt-1 text-xs text-bambu-gray flex items-center gap-1\">\n            <User className=\"w-3 h-3\" />\n            {file.created_by_username}\n          </div>\n        )}\n      </div>\n\n      {/* Actions - always visible on mobile, hover on desktop */}\n      <div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>\n        <button\n          onClick={() => setShowActions(!showActions)}\n          className=\"p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary\"\n        >\n          <MoreVertical className=\"w-4 h-4 text-bambu-gray\" />\n        </button>\n        {showActions && (\n          <>\n            <div className=\"fixed inset-0 z-10\" onClick={() => setShowActions(false)} />\n            <div className=\"absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]\">\n              {onPrint && isSlicedFilename(file.filename) && (\n                <button\n                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                    hasPermission('printers:control') ? 'text-bambu-green hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                  }`}\n                  onClick={() => { if (hasPermission('printers:control')) { onPrint(file); setShowActions(false); } }}\n                  disabled={!hasPermission('printers:control')}\n                  title={!hasPermission('printers:control') ? t('fileManager.noPermissionPrint') : undefined}\n                >\n                  <Printer className=\"w-3.5 h-3.5\" />\n                  {t('common.print')}\n                </button>\n              )}\n              {onAddToQueue && isSlicedFilename(file.filename) && (\n                <button\n                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                    hasPermission('queue:create') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                  }`}\n                  onClick={() => { if (hasPermission('queue:create')) { onAddToQueue(file.id); setShowActions(false); } }}\n                  disabled={!hasPermission('queue:create')}\n                  title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}\n                >\n                  <Clock className=\"w-3.5 h-3.5\" />\n                  {t('fileManager.schedulePrint')}\n                </button>\n              )}\n              {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (\n                <button\n                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                    hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                  }`}\n                  onClick={() => { if (hasPermission('library:read')) { onPreview3d(file); setShowActions(false); } }}\n                  disabled={!hasPermission('library:read')}\n                  title={!hasPermission('library:read') ? 'You do not have permission to preview files' : undefined}\n                >\n                  <Box className=\"w-3.5 h-3.5\" />\n                  3D Preview\n                </button>\n              )}\n              <button\n                className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                  hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                }`}\n                onClick={() => { if (hasPermission('library:read')) { onDownload(file.id); setShowActions(false); } }}\n                disabled={!hasPermission('library:read')}\n                title={!hasPermission('library:read') ? t('fileManager.noPermissionDownload') : undefined}\n              >\n                <Download className=\"w-3.5 h-3.5\" />\n                {t('common.download')}\n              </button>\n              {onRename && (\n                <button\n                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                    canModify('library', 'update', file.created_by_id) ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                  }`}\n                  onClick={() => { if (canModify('library', 'update', file.created_by_id)) { onRename(file); setShowActions(false); } }}\n                  disabled={!canModify('library', 'update', file.created_by_id)}\n                  title={!canModify('library', 'update', file.created_by_id) ? t('fileManager.noPermissionRenameFile') : undefined}\n                >\n                  <Pencil className=\"w-3.5 h-3.5\" />\n                  {t('common.rename')}\n                </button>\n              )}\n              {onGenerateThumbnail && file.file_type === 'stl' && (\n                <button\n                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                    canModify('library', 'update', file.created_by_id) ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                  }`}\n                  onClick={() => { if (canModify('library', 'update', file.created_by_id)) { onGenerateThumbnail(file); setShowActions(false); } }}\n                  disabled={!canModify('library', 'update', file.created_by_id)}\n                  title={!canModify('library', 'update', file.created_by_id) ? t('fileManager.noPermissionGenerateThumbnail') : undefined}\n                >\n                  <Image className=\"w-3.5 h-3.5\" />\n                  {t('fileManager.generateThumbnail')}\n                </button>\n              )}\n              <button\n                className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${\n                  canModify('library', 'delete', file.created_by_id) ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                }`}\n                onClick={() => { if (canModify('library', 'delete', file.created_by_id)) { onDelete(file.id); setShowActions(false); } }}\n                disabled={!canModify('library', 'delete', file.created_by_id)}\n                title={!canModify('library', 'delete', file.created_by_id) ? t('fileManager.noPermissionDeleteFile') : undefined}\n              >\n                <Trash2 className=\"w-3.5 h-3.5\" />\n                {t('common.delete')}\n              </button>\n            </div>\n          </>\n        )}\n      </div>\n\n      {/* Selection checkbox - always visible on mobile, hover on desktop */}\n      <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${\n        isSelected\n          ? 'bg-bambu-green border-bambu-green'\n          : `border-white/30 bg-black/30 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`\n      }`}>\n        {isSelected && <div className=\"w-2 h-2 bg-white rounded-sm\" />}\n      </div>\n    </div>\n  );\n}\n\nexport function FileManagerPage() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth();\n  const [searchParams] = useSearchParams();\n\n  // Read folder ID from URL query parameter\n  const folderIdFromUrl = searchParams.get('folder');\n  const initialFolderId = folderIdFromUrl ? parseInt(folderIdFromUrl, 10) : null;\n\n  // State\n  const [selectedFolderId, setSelectedFolderId] = useState<number | null>(initialFolderId);\n  const [selectedFiles, setSelectedFiles] = useState<number[]>([]);\n  const [showNewFolderModal, setShowNewFolderModal] = useState(false);\n  const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);\n  const [showMoveModal, setShowMoveModal] = useState(false);\n  const [showUploadModal, setShowUploadModal] = useState(false);\n  const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);\n  const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);\n  const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);\n  const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);\n  const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);\n  const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);\n  const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});\n  const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);\n  const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {\n    return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';\n  });\n  const [wrapFolderNames, setWrapFolderNames] = useState(() => {\n    return localStorage.getItem('library-wrap-folders') === 'true';\n  });\n  const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => {\n    return localStorage.getItem('library-collapse-folders') === 'true';\n  });\n\n  // Resizable sidebar state\n  const [sidebarWidth, setSidebarWidth] = useState(() => {\n    const saved = localStorage.getItem('library-sidebar-width');\n    return saved ? parseInt(saved, 10) : 256; // Default w-64 = 256px\n  });\n  const [isResizing, setIsResizing] = useState(false);\n  const sidebarRef = useRef<HTMLDivElement>(null);\n\n  // Handle sidebar resize\n  useEffect(() => {\n    if (!isResizing) return;\n\n    // Prevent text selection during resize\n    document.body.style.userSelect = 'none';\n    document.body.style.cursor = 'col-resize';\n\n    const handleMouseMove = (e: MouseEvent) => {\n      if (!sidebarRef.current) return;\n      const containerRect = sidebarRef.current.parentElement?.getBoundingClientRect();\n      if (!containerRect) return;\n      // Calculate new width based on mouse position relative to container\n      const newWidth = e.clientX - containerRect.left;\n      // Clamp between 200px and 500px\n      const clampedWidth = Math.min(500, Math.max(200, newWidth));\n      setSidebarWidth(clampedWidth);\n    };\n\n    const handleMouseUp = () => {\n      setIsResizing(false);\n      document.body.style.userSelect = '';\n      document.body.style.cursor = '';\n      // Save to localStorage\n      localStorage.setItem('library-sidebar-width', String(sidebarWidth));\n    };\n\n    document.addEventListener('mousemove', handleMouseMove);\n    document.addEventListener('mouseup', handleMouseUp);\n\n    return () => {\n      document.removeEventListener('mousemove', handleMouseMove);\n      document.removeEventListener('mouseup', handleMouseUp);\n      document.body.style.userSelect = '';\n      document.body.style.cursor = '';\n    };\n  }, [isResizing, sidebarWidth]);\n\n  // Filter and sort state (persist sort preferences to localStorage)\n  const [searchQuery, setSearchQuery] = useState('');\n  const [filterType, setFilterType] = useState<string>('all');\n  const [filterUsername, setFilterUsername] = useState('');\n  const [sortField, setSortField] = useState<SortField>(() => {\n    const saved = localStorage.getItem('library-sort-field');\n    return (saved as SortField) || 'name';\n  });\n  const [sortDirection, setSortDirection] = useState<SortDirection>(() => {\n    const saved = localStorage.getItem('library-sort-direction');\n    return (saved as SortDirection) || 'asc';\n  });\n\n  // Mobile detection for touch-friendly UI\n  const isMobile = useIsMobile();\n\n  // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)\n  useEffect(() => {\n    const folderParam = searchParams.get('folder');\n    if (folderParam) {\n      const newFolderId = parseInt(folderParam, 10);\n      setSelectedFolderId(newFolderId);\n    }\n  }, [searchParams]);\n\n  // Queries\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: () => api.getSettings() as Promise<AppSettings>,\n  });\n  const { data: folders, isLoading: foldersLoading } = useQuery({\n    queryKey: ['library-folders'],\n    queryFn: () => api.getLibraryFolders(),\n  });\n\n  const { data: files, isLoading: filesLoading } = useQuery({\n    queryKey: ['library-files', selectedFolderId],\n    queryFn: () => api.getLibraryFiles(selectedFolderId, selectedFolderId === null),\n  });\n\n  const { data: stats } = useQuery({\n    queryKey: ['library-stats'],\n    queryFn: () => api.getLibraryStats(),\n  });\n\n  // Get users for the username filter autocomplete\n  const { data: users } = useQuery({\n    queryKey: ['users'],\n    queryFn: () => api.getUsers(),\n  });\n\n  // Get unique file types for filter dropdown\n  const fileTypes = useMemo(() => {\n    if (!files) return [];\n    const types = new Set(files.map((f) => f.file_type));\n    return Array.from(types).sort();\n  }, [files]);\n\n  // Filter and sort files\n  const filteredAndSortedFiles = useMemo(() => {\n    if (!files) return [];\n\n    let result = [...files];\n\n    // Apply search filter\n    if (searchQuery.trim()) {\n      const query = searchQuery.toLowerCase();\n      result = result.filter(\n        (f) =>\n          f.filename.toLowerCase().includes(query) ||\n          (f.print_name && f.print_name.toLowerCase().includes(query))\n      );\n    }\n\n    // Apply type filter\n    if (filterType !== 'all') {\n      result = result.filter((f) => f.file_type === filterType);\n    }\n\n    // Apply username filter\n    if (filterUsername.trim()) {\n      const query = filterUsername.toLowerCase();\n      result = result.filter(\n        (f) => f.created_by_username && f.created_by_username.toLowerCase().includes(query)\n      );\n    }\n\n    // Apply sorting\n    result.sort((a, b) => {\n      let comparison = 0;\n      switch (sortField) {\n        case 'name':\n          comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename);\n          break;\n        case 'date':\n          comparison = (parseUTCDate(a.created_at)?.getTime() ?? 0) - (parseUTCDate(b.created_at)?.getTime() ?? 0);\n          break;\n        case 'size':\n          comparison = a.file_size - b.file_size;\n          break;\n        case 'type':\n          comparison = a.file_type.localeCompare(b.file_type);\n          break;\n        case 'prints':\n          comparison = a.print_count - b.print_count;\n          break;\n      }\n      return sortDirection === 'asc' ? comparison : -comparison;\n    });\n\n    return result;\n  }, [files, searchQuery, filterType, filterUsername, sortField, sortDirection]);\n\n  // Check if disk space is low\n  const isDiskSpaceLow = useMemo(() => {\n    if (!stats || !settings) return false;\n    const thresholdBytes = (settings.library_disk_warning_gb || 5) * 1024 * 1024 * 1024;\n    return stats.disk_free_bytes < thresholdBytes;\n  }, [stats, settings]);\n\n  // Mutations\n  const createFolderMutation = useMutation({\n    mutationFn: (data: LibraryFolderCreate) => api.createLibraryFolder(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      setShowNewFolderModal(false);\n      showToast(t('fileManager.toast.folderCreated'), 'success');\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const createExternalFolderMutation = useMutation({\n    mutationFn: async (data: ExternalFolderCreate) => {\n      const folder = await api.createExternalFolder(data);\n      // Auto-scan after creation\n      await api.scanExternalFolder(folder.id);\n      return folder;\n    },\n    onSuccess: (folder) => {\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      queryClient.invalidateQueries({ queryKey: ['library-stats'] });\n      setShowExternalFolderModal(false);\n      setSelectedFolderId(folder.id);\n      showToast(t('fileManager.toast.externalFolderLinked'), 'success');\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const scanExternalFolderMutation = useMutation({\n    mutationFn: (folderId: number) => api.scanExternalFolder(folderId),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      queryClient.invalidateQueries({ queryKey: ['library-stats'] });\n      showToast(t('fileManager.toast.folderScanned', { added: result.added, removed: result.removed }), 'success');\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const deleteFolderMutation = useMutation({\n    mutationFn: (id: number) => api.deleteLibraryFolder(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      queryClient.invalidateQueries({ queryKey: ['library-stats'] });\n      if (selectedFolderId === deleteConfirm?.id) {\n        setSelectedFolderId(null);\n      }\n      setDeleteConfirm(null);\n      showToast(t('fileManager.toast.folderDeleted'), 'success');\n    },\n    onError: (error: Error) => {\n      setDeleteConfirm(null);\n      showToast(error.message, 'error');\n    },\n  });\n\n  const deleteFileMutation = useMutation({\n    mutationFn: (id: number) => api.deleteLibraryFile(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      queryClient.invalidateQueries({ queryKey: ['library-stats'] });\n      setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id));\n      setDeleteConfirm(null);\n      showToast(t('fileManager.toast.fileDeleted'), 'success');\n    },\n    onError: (error: Error) => {\n      setDeleteConfirm(null);\n      showToast(error.message, 'error');\n    },\n  });\n\n  const bulkDeleteMutation = useMutation({\n    mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []),\n    onSuccess: (_, fileIds) => {\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      queryClient.invalidateQueries({ queryKey: ['library-stats'] });\n      showToast(t('fileManager.toast.filesDeleted', { count: fileIds.length }), 'success');\n      setSelectedFiles([]);\n      setDeleteConfirm(null);\n    },\n    onError: (error: Error) => {\n      setDeleteConfirm(null);\n      showToast(error.message, 'error');\n    },\n  });\n\n  const moveFilesMutation = useMutation({\n    mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) =>\n      api.moveLibraryFiles(fileIds, folderId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      setSelectedFiles([]);\n      setShowMoveModal(false);\n      showToast(t('fileManager.toast.filesMoved'), 'success');\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const updateFolderMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data: LibraryFolderUpdate }) =>\n      api.updateLibraryFolder(id, data),\n    onSuccess: (_, variables) => {\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      // Invalidate project/archive folder queries so other pages see the update\n      queryClient.invalidateQueries({ queryKey: ['project-folders'] });\n      queryClient.invalidateQueries({ queryKey: ['archive-folders'] });\n      setLinkFolder(null);\n      const isUnlink = variables.data.project_id === 0 && variables.data.archive_id === 0;\n      showToast(isUnlink ? t('fileManager.toast.folderUnlinked') : t('fileManager.toast.folderLinked'), 'success');\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const renameFileMutation = useMutation({\n    mutationFn: ({ id, filename }: { id: number; filename: string }) =>\n      api.updateLibraryFile(id, { filename }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      setRenameItem(null);\n      showToast(t('fileManager.toast.fileRenamed'), 'success');\n    },\n    onError: (error: Error) => {\n      setRenameItem(null);\n      showToast(error.message, 'error');\n    },\n  });\n\n  const renameFolderMutation = useMutation({\n    mutationFn: ({ id, name }: { id: number; name: string }) =>\n      api.updateLibraryFolder(id, { name }),\n    onSuccess: () => {\n      // Invalidate both folders and files - files may display folder info\n      queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      setRenameItem(null);\n      showToast(t('fileManager.toast.folderRenamed'), 'success');\n    },\n    onError: (error: Error) => {\n      setRenameItem(null);\n      showToast(error.message, 'error');\n    },\n  });\n\n  const batchThumbnailMutation = useMutation({\n    mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      // Update thumbnail versions for cache busting\n      if (result.succeeded > 0) {\n        const now = Date.now();\n        const newVersions: Record<number, number> = {};\n        result.results.forEach((r) => {\n          if (r.success) {\n            newVersions[r.file_id] = now;\n          }\n        });\n        setThumbnailVersions((prev) => ({ ...prev, ...newVersions }));\n      }\n      if (result.succeeded > 0 && result.failed === 0) {\n        showToast(t('fileManager.toast.thumbnailsGenerated', { count: result.succeeded }), 'success');\n      } else if (result.succeeded > 0 && result.failed > 0) {\n        showToast(t('fileManager.toast.thumbnailsGeneratedPartial', { succeeded: result.succeeded, failed: result.failed }), 'success');\n      } else if (result.processed === 0) {\n        showToast(t('fileManager.toast.noStlMissingThumbnails'), 'info');\n      } else {\n        showToast(t('fileManager.toast.failedToGenerateThumbnails', { error: result.results[0]?.error || 'Unknown error' }), 'error');\n      }\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const singleThumbnailMutation = useMutation({\n    mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: ['library-files'] });\n      // Update thumbnail version for cache busting\n      if (result.succeeded > 0) {\n        const fileId = result.results[0]?.file_id;\n        if (fileId) {\n          setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() }));\n        }\n        showToast(t('fileManager.toast.thumbnailGenerated'), 'success');\n      } else {\n        showToast(t('fileManager.toast.failedToGenerateThumbnail', { error: result.results[0]?.error || 'Unknown error' }), 'error');\n      }\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  // Helper to check if a file is sliced (printable)\n  const isSlicedFile = useCallback((filename: string) => {\n    const lower = filename.toLowerCase();\n    return lower.endsWith('.gcode') || lower.includes('.gcode.');\n  }, []);\n\n  // Get sliced files from selection\n  const selectedSlicedFiles = useMemo(() => {\n    if (!files) return [];\n    return files.filter(f => selectedFiles.includes(f.id) && isSlicedFile(f.filename));\n  }, [files, selectedFiles, isSlicedFile]);\n\n  // Handlers\n  const handleFileSelect = useCallback((id: number) => {\n    // Always toggle selection (multi-select by default)\n    setSelectedFiles((prev) => {\n      return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];\n    });\n  }, []);\n\n  const handleSelectAll = useCallback(() => {\n    if (filteredAndSortedFiles.length > 0) {\n      setSelectedFiles(filteredAndSortedFiles.map((f) => f.id));\n    }\n  }, [filteredAndSortedFiles]);\n\n  const handleDeselectAll = useCallback(() => {\n    setSelectedFiles([]);\n  }, []);\n\n  const handleUploadComplete = () => {\n    queryClient.invalidateQueries({ queryKey: ['library-files'] });\n    queryClient.invalidateQueries({ queryKey: ['library-folders'] });\n    queryClient.invalidateQueries({ queryKey: ['library-stats'] });\n  };\n\n  const handleDownload = (id: number) => {\n    api.downloadLibraryFile(id).catch((err) => {\n      console.error('Library file download failed:', err);\n    });\n  };\n\n  const handleDeleteConfirm = () => {\n    if (!deleteConfirm) return;\n    if (deleteConfirm.type === 'file') {\n      deleteFileMutation.mutate(deleteConfirm.id);\n    } else if (deleteConfirm.type === 'folder') {\n      deleteFolderMutation.mutate(deleteConfirm.id);\n    } else if (deleteConfirm.type === 'bulk') {\n      bulkDeleteMutation.mutate(selectedFiles);\n    }\n  };\n\n  const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending;\n\n  const handleViewModeChange = (mode: 'grid' | 'list') => {\n    setViewMode(mode);\n    localStorage.setItem('library-view-mode', mode);\n  };\n\n  const isLoading = foldersLoading || filesLoading;\n\n  // Find the selected folder in the tree to check external status\n  const selectedFolder = useMemo(() => {\n    if (!selectedFolderId || !folders) return null;\n    const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => {\n      for (const item of items) {\n        if (item.id === selectedFolderId) return item;\n        const found = findFolder(item.children);\n        if (found) return found;\n      }\n      return null;\n    };\n    return findFolder(folders);\n  }, [selectedFolderId, folders]);\n\n  return (\n    <div className=\"p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col\">\n      {/* Header */}\n      <div className=\"flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6\">\n        <div>\n          <h1 className=\"text-2xl font-bold text-white flex items-center gap-3\">\n            <div className=\"p-2.5 bg-bambu-green/10 rounded-xl\">\n              <FolderOpen className=\"w-6 h-6 text-bambu-green\" />\n            </div>\n            {t('fileManager.title')}\n          </h1>\n          <p className=\"text-sm text-bambu-gray mt-2 ml-14\">\n            {t('fileManager.subtitle')}\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          {/* View mode toggle */}\n          <div className=\"flex items-center bg-bambu-dark rounded-lg p-1\">\n            <button\n              onClick={() => handleViewModeChange('grid')}\n              className={`p-1.5 rounded transition-colors ${\n                viewMode === 'grid' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'\n              }`}\n              title={t('fileManager.gridView')}\n            >\n              <LayoutGrid className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={() => handleViewModeChange('list')}\n              className={`p-1.5 rounded transition-colors ${\n                viewMode === 'list' ? 'bg-bambu-dark-secondary text-white' : 'text-bambu-gray hover:text-white'\n              }`}\n              title={t('fileManager.listView')}\n            >\n              <List className=\"w-4 h-4\" />\n            </button>\n          </div>\n          <Button\n            variant=\"secondary\"\n            onClick={() => batchThumbnailMutation.mutate()}\n            disabled={batchThumbnailMutation.isPending || !hasAnyPermission('library:update_own', 'library:update_all')}\n            title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.noPermissionGenerateThumbnail') : t('fileManager.generateThumbnailsForMissing')}\n          >\n            {batchThumbnailMutation.isPending ? (\n              <Loader2 className=\"w-4 h-4 mr-2 animate-spin\" />\n            ) : (\n              <Image className=\"w-4 h-4 mr-2\" />\n            )}\n            {t('fileManager.generateThumbnails')}\n          </Button>\n          <Button\n            variant=\"secondary\"\n            onClick={() => setShowExternalFolderModal(true)}\n            disabled={!hasPermission('library:upload')}\n            title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : t('fileManager.linkExternalFolder')}\n          >\n            <FolderSymlink className=\"w-4 h-4 mr-2\" />\n            {t('fileManager.linkExternal')}\n          </Button>\n          <Button\n            variant=\"secondary\"\n            onClick={() => setShowNewFolderModal(true)}\n            disabled={!hasPermission('library:upload')}\n            title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : undefined}\n          >\n            <FolderPlus className=\"w-4 h-4 mr-2\" />\n            {t('fileManager.newFolder')}\n          </Button>\n          <Button\n            onClick={() => setShowUploadModal(true)}\n            disabled={!hasPermission('library:upload')}\n            title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : undefined}\n          >\n            <Upload className=\"w-4 h-4 mr-2\" />\n            {t('common.upload')}\n          </Button>\n        </div>\n      </div>\n\n      {/* Disk space warning */}\n      {isDiskSpaceLow && stats && settings && (\n        <div className=\"flex items-center gap-3 mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg\">\n          <AlertTriangle className=\"w-5 h-5 text-amber-500 flex-shrink-0\" />\n          <div className=\"flex-1\">\n            <p className=\"text-sm text-amber-500 font-medium\">{t('fileManager.lowDiskSpaceWarning')}</p>\n            <p className=\"text-xs text-amber-500/80\">\n              {t('fileManager.lowDiskSpaceDetails', { free: formatFileSize(stats.disk_free_bytes), total: formatFileSize(stats.disk_total_bytes), threshold: settings.library_disk_warning_gb })}\n            </p>\n          </div>\n        </div>\n      )}\n\n      {/* Stats bar */}\n      {stats && (\n        <div className=\"flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-2 text-sm\">\n            <File className=\"w-4 h-4 text-bambu-green\" />\n            <span className=\"text-bambu-gray\">{t('fileManager.files')}:</span>\n            <span className=\"text-white font-medium\">{stats.total_files}</span>\n          </div>\n          <div className=\"flex items-center gap-2 text-sm\">\n            <FolderOpen className=\"w-4 h-4 text-blue-400\" />\n            <span className=\"text-bambu-gray\">{t('fileManager.folders')}:</span>\n            <span className=\"text-white font-medium\">{stats.total_folders}</span>\n          </div>\n          <div className=\"flex items-center gap-2 text-sm\">\n            <HardDrive className=\"w-4 h-4 text-amber-400\" />\n            <span className=\"text-bambu-gray\">{t('fileManager.size')}:</span>\n            <span className=\"text-white font-medium\">{formatFileSize(stats.total_size_bytes)}</span>\n          </div>\n          <div className=\"flex items-center gap-2 text-sm sm:ml-auto\">\n            <span className=\"text-bambu-gray\">{t('fileManager.free')}:</span>\n            <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>\n              {formatFileSize(stats.disk_free_bytes)}\n            </span>\n          </div>\n        </div>\n      )}\n\n      {/* Main content */}\n      <div className=\"flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0\">\n        {/* Mobile folder selector */}\n        <div className=\"lg:hidden\">\n          <select\n            value={selectedFolderId ?? ''}\n            onChange={(e) => setSelectedFolderId(e.target.value ? parseInt(e.target.value, 10) : null)}\n            className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green\"\n          >\n            <option value=\"\">📁 {t('fileManager.allFiles')}</option>\n            {folders && (() => {\n              // Flatten folder tree for mobile selector\n              const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {\n                const result: { id: number; name: string; fileCount: number; depth: number }[] = [];\n                for (const item of items) {\n                  result.push({ id: item.id, name: item.name, fileCount: item.file_count, depth });\n                  if (item.children.length > 0) {\n                    result.push(...flattenFolders(item.children, depth + 1));\n                  }\n                }\n                return result;\n              };\n              return flattenFolders(folders).map((folder) => (\n                <option key={folder.id} value={folder.id}>\n                  {'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}\n                </option>\n              ));\n            })()}\n          </select>\n        </div>\n\n        {/* Folder sidebar - resizable, hidden on mobile */}\n        <div\n          ref={sidebarRef}\n          className=\"hidden lg:flex flex-shrink-0 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative\"\n          style={{ width: `${sidebarWidth}px` }}\n        >\n          {/* Resize handle - drag to resize, double-click to reset */}\n          <div\n            className={`absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize z-10 group/resize flex items-center justify-center transition-colors ${\n              isResizing ? 'bg-bambu-green' : 'hover:bg-bambu-green/50'\n            }`}\n            onMouseDown={(e) => {\n              e.preventDefault();\n              setIsResizing(true);\n            }}\n            onDoubleClick={() => {\n              setSidebarWidth(256); // Reset to default w-64\n              localStorage.setItem('library-sidebar-width', '256');\n            }}\n            title={t('fileManager.dragToResizeTooltip')}\n          >\n            {/* Grip dots */}\n            <div className={`flex flex-col gap-1 opacity-0 group-hover/resize:opacity-100 transition-opacity ${isResizing ? 'opacity-100' : ''}`}>\n              <div className=\"w-0.5 h-0.5 rounded-full bg-white/70\" />\n              <div className=\"w-0.5 h-0.5 rounded-full bg-white/70\" />\n              <div className=\"w-0.5 h-0.5 rounded-full bg-white/70\" />\n            </div>\n          </div>\n          <div className=\"p-3 border-b border-bambu-dark-tertiary flex items-center justify-between\">\n            <h2 className=\"text-sm font-medium text-white\">{t('fileManager.folders')}</h2>\n            <div className=\"flex items-center gap-1\">\n              <button\n                onClick={() => {\n                  const newValue = !collapseFoldersByDefault;\n                  setCollapseFoldersByDefault(newValue);\n                  localStorage.setItem('library-collapse-folders', String(newValue));\n                }}\n                className={`text-xs px-1.5 py-0.5 rounded transition-colors ${\n                  collapseFoldersByDefault\n                    ? 'bg-bambu-green/20 text-bambu-green'\n                    : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'\n                }`}\n                title={collapseFoldersByDefault ? t('fileManager.expandFoldersByDefault') : t('fileManager.collapseFoldersByDefault')}\n              >\n                {t('fileManager.collapse')}\n              </button>\n              <button\n                onClick={() => {\n                  const newValue = !wrapFolderNames;\n                  setWrapFolderNames(newValue);\n                  localStorage.setItem('library-wrap-folders', String(newValue));\n                }}\n                className={`text-xs px-1.5 py-0.5 rounded transition-colors ${\n                  wrapFolderNames\n                    ? 'bg-bambu-green/20 text-bambu-green'\n                    : 'text-bambu-gray hover:text-white hover:bg-bambu-dark'\n                }`}\n                title={wrapFolderNames ? t('fileManager.disableTextWrapping') : t('fileManager.enableTextWrapping')}\n              >\n                {t('fileManager.wrap')}\n              </button>\n            </div>\n          </div>\n          <div className=\"flex-1 overflow-y-auto p-2\">\n            {/* All Files (root) */}\n            <div\n              className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${\n                selectedFolderId === null\n                  ? 'bg-bambu-green/20 text-bambu-green'\n                  : 'hover:bg-bambu-dark text-white'\n              }`}\n              onClick={() => setSelectedFolderId(null)}\n            >\n              <FileBox className=\"w-4 h-4\" />\n              <span className=\"text-sm\">{t('fileManager.allFiles')}</span>\n            </div>\n\n            {/* Folder tree — re-key on the collapse toggle so flipping it\n                remounts every FolderTreeItem, which re-reads defaultExpanded\n                and makes the preference take effect immediately. */}\n            {folders?.map((folder) => (\n              <FolderTreeItem\n                key={`${folder.id}-${collapseFoldersByDefault ? 'c' : 'e'}`}\n                folder={folder}\n                selectedFolderId={selectedFolderId}\n                onSelect={setSelectedFolderId}\n                onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}\n                onLink={setLinkFolder}\n                onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}\n                wrapNames={wrapFolderNames}\n                defaultExpanded={!collapseFoldersByDefault}\n                hasPermission={hasPermission}\n                t={t}\n              />\n            ))}\n          </div>\n        </div>\n\n        {/* Files area */}\n        <div className=\"flex-1 flex flex-col min-w-0 min-h-0\">\n          {/* External folder info bar */}\n          {selectedFolder?.is_external && (\n            <div className=\"flex items-center gap-3 mb-4 p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg\">\n              <FolderSymlink className=\"w-5 h-5 text-purple-400 flex-shrink-0\" />\n              <div className=\"flex-1 min-w-0\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"text-sm font-medium text-purple-300\">{t('fileManager.externalFolder')}</span>\n                  {selectedFolder.external_readonly && (\n                    <span className=\"text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 flex items-center gap-1\">\n                      <Lock className=\"w-3 h-3\" />\n                      {t('fileManager.readOnly')}\n                    </span>\n                  )}\n                </div>\n                <p className=\"text-xs text-bambu-gray truncate font-mono\" title={selectedFolder.external_path || ''}>\n                  {selectedFolder.external_path}\n                </p>\n              </div>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={() => selectedFolderId && scanExternalFolderMutation.mutate(selectedFolderId)}\n                disabled={scanExternalFolderMutation.isPending}\n                title={t('fileManager.scanFolder')}\n              >\n                {scanExternalFolderMutation.isPending ? (\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                ) : (\n                  <RefreshCw className=\"w-4 h-4\" />\n                )}\n                <span className=\"ml-1.5\">{t('fileManager.scanFolder')}</span>\n              </Button>\n            </div>\n          )}\n          {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}\n          {files && files.length > 0 && (\n            <div className=\"flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static\">\n              {/* Search */}\n              <div className=\"relative w-full sm:w-auto sm:flex-1 sm:max-w-xs\">\n                <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n                <input\n                  type=\"text\"\n                  placeholder={t('fileManager.searchFiles')}\n                  value={searchQuery}\n                  onChange={(e) => setSearchQuery(e.target.value)}\n                  className=\"w-full pl-9 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                />\n              </div>\n\n              {/* Type filter */}\n              <div className=\"flex items-center gap-2\">\n                <Filter className=\"w-4 h-4 text-bambu-gray hidden sm:block\" />\n                <select\n                  value={filterType}\n                  onChange={(e) => setFilterType(e.target.value)}\n                  className=\"bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green\"\n                >\n                  <option value=\"all\">{t('fileManager.allTypes')}</option>\n                  {fileTypes.map((type) => (\n                    <option key={type} value={type}>\n                      {type.toUpperCase()}\n                    </option>\n                  ))}\n                </select>\n              </div>\n\n              {/* Username filter with autocomplete - only show when auth is enabled */}\n              {authEnabled && (\n                <div className=\"relative\">\n                  <input\n                    type=\"text\"\n                    placeholder={t('fileManager.filterByUser', { defaultValue: 'Filter by user' })}\n                    value={filterUsername}\n                    onChange={(e) => setFilterUsername(e.target.value)}\n                    list=\"usernames-list\"\n                    className={`w-32 sm:w-40 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green ${filterUsername ? 'pr-7' : ''}`}\n                    style={filterUsername ? { WebkitAppearance: 'none', MozAppearance: 'textfield' } : undefined}\n                  />\n                  {filterUsername && (\n                    <button\n                      onClick={() => setFilterUsername('')}\n                      className=\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white z-10\"\n                    >\n                      <X className=\"w-3 h-3\" />\n                    </button>\n                  )}\n                  <datalist id=\"usernames-list\">\n                    {users?.map((user) => (\n                      <option key={user.id} value={user.username} />\n                    ))}\n                  </datalist>\n                </div>\n              )}\n\n              {/* Sort */}\n              <div className=\"flex items-center gap-2\">\n                <select\n                  value={sortField}\n                  onChange={(e) => {\n                    const newField = e.target.value as SortField;\n                    setSortField(newField);\n                    localStorage.setItem('library-sort-field', newField);\n                  }}\n                  className=\"bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green\"\n                >\n                  <option value=\"name\">{t('common.name')}</option>\n                  <option value=\"date\">{t('common.date')}</option>\n                  <option value=\"size\">{t('fileManager.size')}</option>\n                  <option value=\"type\">{t('common.type')}</option>\n                  <option value=\"prints\">{t('fileManager.prints')}</option>\n                </select>\n                <button\n                  onClick={() => setSortDirection((d) => {\n                    const newDir = d === 'asc' ? 'desc' : 'asc';\n                    localStorage.setItem('library-sort-direction', newDir);\n                    return newDir;\n                  })}\n                  className=\"p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors\"\n                  title={sortDirection === 'asc' ? t('fileManager.ascending') : t('fileManager.descending')}\n                >\n                  {sortDirection === 'asc' ? (\n                    <SortAsc className=\"w-4 h-4 text-white\" />\n                  ) : (\n                    <SortDesc className=\"w-4 h-4 text-white\" />\n                  )}\n                </button>\n              </div>\n\n              {/* Results count */}\n              {(searchQuery || filterType !== 'all' || filterUsername) && (\n                <span className=\"text-sm text-bambu-gray hidden sm:inline\">\n                  {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })}\n                </span>\n              )}\n            </div>\n          )}\n\n          {/* Selection toolbar - sticky on mobile below search bar */}\n          {filteredAndSortedFiles.length > 0 && (\n            <div className=\"flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static\">\n              {/* Select all / Deselect all */}\n              {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={handleDeselectAll}\n                >\n                  <Square className=\"w-4 h-4 sm:mr-1\" />\n                  <span className=\"hidden sm:inline\">{t('fileManager.deselectAll')}</span>\n                </Button>\n              ) : (\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={handleSelectAll}\n                >\n                  <CheckSquare className=\"w-4 h-4 sm:mr-1\" />\n                  <span className=\"hidden sm:inline\">{t('fileManager.selectAll')}</span>\n                </Button>\n              )}\n\n              {selectedFiles.length > 0 && (\n                <>\n                  <span className=\"text-sm text-bambu-gray ml-2\">\n                    {t('fileManager.selected', { count: selectedFiles.length })}\n                  </span>\n                  <div className=\"hidden sm:block flex-1\" />\n                  <div className=\"w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0\">\n                    {selectedSlicedFiles.length === 1 && (\n                      <Button\n                        variant=\"primary\"\n                        size=\"sm\"\n                        onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}\n                        disabled={!hasPermission('printers:control')}\n                        title={!hasPermission('printers:control') ? t('fileManager.noPermissionPrint') : undefined}\n                      >\n                        <Play className=\"w-4 h-4 sm:mr-1\" />\n                        <span className=\"hidden sm:inline\">{t('common.print')}</span>\n                      </Button>\n                    )}\n                    {selectedSlicedFiles.length === 1 && (\n                      <Button\n                        variant=\"secondary\"\n                        size=\"sm\"\n                        // Note: Schedule dialog (PrintModal) is designed for single file at a time\n                        // but supports scheduling to multiple printers. This provides more control\n                        // over scheduling options compared to the previous bulk queue mutation.\n                        onClick={() => setScheduleFile(selectedSlicedFiles[0])}\n                        disabled={!hasPermission('queue:create')}\n                        title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}\n                      >\n                        <Clock className=\"w-4 h-4 sm:mr-1\" />\n                        <span className=\"hidden sm:inline\">{t('fileManager.schedulePrint')}</span>\n                      </Button>\n                    )}\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={() => setShowMoveModal(true)}\n                      disabled={!hasAnyPermission('library:update_own', 'library:update_all')}\n                      title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.noPermissionMoveFiles') : undefined}\n                    >\n                      <MoveRight className=\"w-4 h-4 sm:mr-1\" />\n                      <span className=\"hidden sm:inline\">{t('common.move')}</span>\n                    </Button>\n                    <Button\n                      variant=\"danger\"\n                      size=\"sm\"\n                      onClick={() => {\n                        if (selectedFiles.length === 1) {\n                          setDeleteConfirm({ type: 'file', id: selectedFiles[0] });\n                        } else {\n                          setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });\n                        }\n                      }}\n                      disabled={!hasAnyPermission('library:delete_own', 'library:delete_all')}\n                      title={!hasAnyPermission('library:delete_own', 'library:delete_all') ? t('fileManager.noPermissionDeleteFiles') : undefined}\n                    >\n                      <Trash2 className=\"w-4 h-4 sm:mr-1\" />\n                      <span className=\"hidden sm:inline\">{t('common.delete')}</span>\n                    </Button>\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={handleDeselectAll}\n                    >\n                      <X className=\"w-4 h-4 sm:mr-1\" />\n                      <span className=\"hidden sm:inline\">{t('common.clear')}</span>\n                    </Button>\n                  </div>\n                </>\n              )}\n            </div>\n          )}\n\n          {/* File grid/list */}\n          {isLoading ? (\n            <div className=\"flex-1 flex items-center justify-center\">\n              <div className=\"flex flex-col items-center gap-3\">\n                <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green\" />\n                <p className=\"text-sm text-bambu-gray\">{t('fileManager.loadingFiles')}</p>\n              </div>\n            </div>\n          ) : files?.length === 0 ? (\n            <div className=\"flex-1 flex flex-col items-center justify-center\">\n              <div className=\"p-4 bg-bambu-dark rounded-2xl mb-4\">\n                <FileBox className=\"w-12 h-12 text-bambu-gray/50\" />\n              </div>\n              <h3 className=\"text-lg font-medium text-white mb-2\">\n                {selectedFolderId !== null ? t('fileManager.folderIsEmpty') : t('fileManager.noFilesYet')}\n              </h3>\n              <p className=\"text-bambu-gray text-center max-w-md mb-6\">\n                {selectedFolderId !== null\n                  ? t('fileManager.folderEmptyDescription')\n                  : t('fileManager.noFilesDescription')}\n              </p>\n              <Button\n                onClick={() => setShowUploadModal(true)}\n                disabled={!hasPermission('library:upload')}\n                title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : undefined}\n              >\n                <Plus className=\"w-4 h-4 mr-2\" />\n                {t('fileManager.uploadFiles')}\n              </Button>\n            </div>\n          ) : filteredAndSortedFiles.length === 0 ? (\n            <div className=\"flex-1 flex flex-col items-center justify-center\">\n              <div className=\"p-4 bg-bambu-dark rounded-2xl mb-4\">\n                <Search className=\"w-12 h-12 text-bambu-gray/50\" />\n              </div>\n              <h3 className=\"text-lg font-medium text-white mb-2\">{t('fileManager.noMatchingFiles')}</h3>\n              <p className=\"text-bambu-gray text-center max-w-md mb-6\">\n                {t('fileManager.noMatchingFilesDescription')}\n              </p>\n              <Button variant=\"secondary\" onClick={() => { setSearchQuery(''); setFilterType('all'); }}>\n                {t('fileManager.clearFilters')}\n              </Button>\n            </div>\n          ) : viewMode === 'grid' ? (\n            <div className=\"flex-1 lg:overflow-y-auto\">\n              <div className=\"grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4\">\n                {filteredAndSortedFiles.map((file) => (\n                  <FileCard\n                    key={file.id}\n                    file={file}\n                    isSelected={selectedFiles.includes(file.id)}\n                    isMobile={isMobile}\n                    t={t}\n                    onSelect={handleFileSelect}\n                    onDelete={(id) => setDeleteConfirm({ type: 'file', id })}\n                    onDownload={handleDownload}\n                    onAddToQueue={(id) => {\n                      const file = files?.find(f => f.id === id);\n                      if (file) setScheduleFile(file);\n                    }}\n                    onPrint={setPrintFile}\n                    onPreview3d={setViewerFile}\n                    onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}\n                    onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}\n                    thumbnailVersion={thumbnailVersions[file.id]}\n                    hasPermission={hasPermission}\n                    canModify={canModify}\n                    authEnabled={authEnabled}\n                  />\n                ))}\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex-1 lg:overflow-y-auto\">\n              <div className=\"bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden\">\n                {/* List header - hidden on mobile, show simplified on small screens */}\n                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_80px]' : 'grid-cols-[auto_1fr_100px_100px_100px_80px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>\n                  <div className=\"w-6\" />\n                  <div>{t('common.name')}</div>\n                  {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}\n                  <div>{t('common.type')}</div>\n                  <div>{t('fileManager.size')}</div>\n                  <div>{t('fileManager.prints')}</div>\n                  <div />\n                </div>\n                {/* List rows */}\n                {filteredAndSortedFiles.map((file) => (\n                  <div\n                    key={file.id}\n                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_80px]' : 'grid-cols-[auto_1fr_100px_100px_100px_80px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${\n                      selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''\n                    }`}\n                    onClick={() => handleFileSelect(file.id)}\n                  >\n                    {/* Checkbox */}\n                    <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${\n                      selectedFiles.includes(file.id)\n                        ? 'bg-bambu-green border-bambu-green'\n                        : 'border-bambu-gray/50'\n                    }`}>\n                      {selectedFiles.includes(file.id) && <div className=\"w-2 h-2 bg-white rounded-sm\" />}\n                    </div>\n                    {/* Name with thumbnail */}\n                    <div className=\"flex items-center gap-3 min-w-0\">\n                      <div className=\"relative group/thumb\">\n                        <div className=\"w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden\">\n                          {file.thumbnail_path ? (\n                            <img\n                              src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersions[file.id]}`) : ''}`}\n                              alt=\"\"\n                              className=\"w-full h-full object-cover\"\n                            />\n                          ) : (\n                            <div className=\"w-full h-full flex items-center justify-center\">\n                              <FileBox className=\"w-5 h-5 text-bambu-gray/50\" />\n                            </div>\n                          )}\n                        </div>\n                        {/* Hover preview */}\n                        {file.thumbnail_path && (\n                          <div className=\"absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block\">\n                            <div className=\"w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden\">\n                              <img\n                                src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? ((api.getLibraryFileThumbnailUrl(file.id).includes('?') ? '&' : '?') + `v=${thumbnailVersions[file.id]}`) : ''}`}\n                                alt={file.filename}\n                                className=\"w-full h-full object-contain\"\n                              />\n                            </div>\n                          </div>\n                        )}\n                      </div>\n                      <div className=\"min-w-0\">\n                        <div className=\"text-sm text-white truncate\">{file.print_name || file.filename}</div>\n                      </div>\n                    </div>\n                    {/* Uploaded By - only show when auth is enabled */}\n                    {authEnabled && (\n                      <div className=\"text-sm text-bambu-gray flex items-center gap-1\">\n                        {file.created_by_username ? (\n                          <>\n                            <User className=\"w-3 h-3\" />\n                            <span className=\"truncate\">{file.created_by_username}</span>\n                          </>\n                        ) : (\n                          '-'\n                        )}\n                      </div>\n                    )}\n                    {/* Type */}\n                    <div>\n                      <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${\n                        file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'\n                        : file.file_type === 'gcode' ? 'bg-blue-500/20 text-blue-400'\n                        : file.file_type === 'stl' ? 'bg-purple-500/20 text-purple-400'\n                        : 'bg-bambu-gray/20 text-bambu-gray'\n                      }`}>\n                        {file.file_type.toUpperCase()}\n                      </span>\n                    </div>\n                    {/* Size */}\n                    <div className=\"text-sm text-bambu-gray\">{formatFileSize(file.file_size)}</div>\n                    {/* Prints */}\n                    <div className=\"text-sm text-bambu-gray\">{file.print_count > 0 ? `${file.print_count}x` : '-'}</div>\n                    {/* Actions */}\n                    <div className=\"flex items-center gap-1\" onClick={(e) => e.stopPropagation()}>\n                      {isSlicedFilename(file.filename) && (\n                        <>\n                          <button\n                            onClick={() => hasPermission('printers:control') && setPrintFile(file)}\n                            className={`p-1.5 rounded transition-colors ${\n                              hasPermission('printers:control')\n                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'\n                                : 'text-bambu-gray/50 cursor-not-allowed'\n                            }`}\n                            title={hasPermission('printers:control') ? t('common.print') : t('fileManager.noPermissionPrint')}\n                            disabled={!hasPermission('printers:control')}\n                          >\n                            <Printer className=\"w-4 h-4\" />\n                          </button>\n                          <button\n                            onClick={() => {\n                              if (hasPermission('queue:create')) {\n                                setScheduleFile(file);\n                              }\n                            }}\n                            className={`p-1.5 rounded transition-colors ${\n                              hasPermission('queue:create')\n                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'\n                                : 'text-bambu-gray/50 cursor-not-allowed'\n                            }`}\n                            title={hasPermission('queue:create') ? t('fileManager.schedulePrint') : t('fileManager.noPermissionAddToQueue')}\n                            disabled={!hasPermission('queue:create')}\n                          >\n                            <Clock className=\"w-4 h-4\" />\n                          </button>\n                        </>\n                      )}\n                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl') && (\n                        <button\n                          onClick={() => hasPermission('library:read') && setViewerFile(file)}\n                          className={`p-1.5 rounded transition-colors ${\n                            hasPermission('library:read')\n                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'\n                              : 'text-bambu-gray/50 cursor-not-allowed'\n                          }`}\n                          title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}\n                          disabled={!hasPermission('library:read')}\n                        >\n                          <Box className=\"w-4 h-4\" />\n                        </button>\n                      )}\n                      <button\n                        onClick={() => hasPermission('library:read') && handleDownload(file.id)}\n                        className={`p-1.5 rounded transition-colors ${\n                          hasPermission('library:read')\n                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'\n                            : 'text-bambu-gray/50 cursor-not-allowed'\n                        }`}\n                        title={hasPermission('library:read') ? t('common.download') : t('fileManager.noPermissionDownload')}\n                        disabled={!hasPermission('library:read')}\n                      >\n                        <Download className=\"w-4 h-4\" />\n                      </button>\n                      <button\n                        onClick={() => canModify('library', 'update', file.created_by_id) && setRenameItem({ type: 'file', id: file.id, name: file.filename })}\n                        className={`p-1.5 rounded transition-colors ${\n                          canModify('library', 'update', file.created_by_id)\n                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'\n                            : 'text-bambu-gray/50 cursor-not-allowed'\n                        }`}\n                        title={canModify('library', 'update', file.created_by_id) ? t('common.rename') : t('fileManager.noPermissionRenameFile')}\n                        disabled={!canModify('library', 'update', file.created_by_id)}\n                      >\n                        <Pencil className=\"w-4 h-4\" />\n                      </button>\n                      {file.file_type === 'stl' && (\n                        <button\n                          onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}\n                          className={`p-1.5 rounded transition-colors ${\n                            canModify('library', 'update', file.created_by_id)\n                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'\n                              : 'text-bambu-gray/50 cursor-not-allowed'\n                          }`}\n                          title={canModify('library', 'update', file.created_by_id) ? t('fileManager.generateThumbnail') : t('fileManager.noPermissionGenerateThumbnail')}\n                          disabled={singleThumbnailMutation.isPending || !canModify('library', 'update', file.created_by_id)}\n                        >\n                          <Image className=\"w-4 h-4\" />\n                        </button>\n                      )}\n                      <button\n                        onClick={() => canModify('library', 'delete', file.created_by_id) && setDeleteConfirm({ type: 'file', id: file.id })}\n                        className={`p-1.5 rounded transition-colors ${\n                          canModify('library', 'delete', file.created_by_id)\n                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-400'\n                            : 'text-bambu-gray/50 cursor-not-allowed'\n                        }`}\n                        title={canModify('library', 'delete', file.created_by_id) ? t('common.delete') : t('fileManager.noPermissionDeleteFile')}\n                        disabled={!canModify('library', 'delete', file.created_by_id)}\n                      >\n                        <Trash2 className=\"w-4 h-4\" />\n                      </button>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Modals */}\n      {showNewFolderModal && (\n        <NewFolderModal\n          parentId={selectedFolderId}\n          onClose={() => setShowNewFolderModal(false)}\n          onSave={(data) => createFolderMutation.mutate(data)}\n          isLoading={createFolderMutation.isPending}\n          t={t}\n        />\n      )}\n\n      {showExternalFolderModal && (\n        <ExternalFolderModal\n          onClose={() => setShowExternalFolderModal(false)}\n          onSave={(data) => createExternalFolderMutation.mutate(data)}\n          isLoading={createExternalFolderMutation.isPending}\n          t={t}\n        />\n      )}\n\n      {showMoveModal && folders && (\n        <MoveFilesModal\n          folders={folders}\n          selectedFiles={selectedFiles}\n          currentFolderId={selectedFolderId}\n          onClose={() => setShowMoveModal(false)}\n          onMove={(folderId) => moveFilesMutation.mutate({ fileIds: selectedFiles, folderId })}\n          isLoading={moveFilesMutation.isPending}\n          t={t}\n        />\n      )}\n\n      {showUploadModal && (\n        <FileUploadModal\n          folderId={selectedFolderId}\n          onClose={() => setShowUploadModal(false)}\n          onUploadComplete={handleUploadComplete}\n        />\n      )}\n\n      {linkFolder && (\n        <LinkFolderModal\n          folder={linkFolder}\n          onClose={() => setLinkFolder(null)}\n          onLink={(data) => updateFolderMutation.mutate({ id: linkFolder.id, data })}\n          isLoading={updateFolderMutation.isPending}\n          t={t}\n        />\n      )}\n\n      {deleteConfirm && (\n        <ConfirmModal\n          title={\n            deleteConfirm.type === 'folder'\n              ? t('fileManager.deleteFolder')\n              : deleteConfirm.type === 'bulk'\n              ? t('fileManager.deleteFilesCount', { count: deleteConfirm.count })\n              : t('fileManager.deleteFile')\n          }\n          message={\n            deleteConfirm.type === 'folder'\n              ? t('fileManager.deleteFolderConfirm')\n              : deleteConfirm.type === 'bulk'\n              ? t('fileManager.deleteFilesConfirm', { count: deleteConfirm.count })\n              : t('fileManager.deleteFileConfirm')\n          }\n          confirmText={t('common.delete')}\n          variant=\"danger\"\n          isLoading={isDeleting}\n          loadingText={t('fileManager.deleting')}\n          onConfirm={handleDeleteConfirm}\n          onCancel={() => setDeleteConfirm(null)}\n        />\n      )}\n\n      {printFile && (\n        <PrintModal\n          mode=\"reprint\"\n          libraryFileId={printFile.id}\n          archiveName={printFile.print_name || printFile.filename}\n          onClose={() => setPrintFile(null)}\n          onSuccess={() => {\n            setPrintFile(null);\n            queryClient.invalidateQueries({ queryKey: ['library-files'] });\n            queryClient.invalidateQueries({ queryKey: ['archives'] });\n          }}\n        />\n      )}\n\n      {printMultiFile && (\n        <PrintModal\n          mode=\"reprint\"\n          libraryFileId={printMultiFile.id}\n          archiveName={printMultiFile.print_name || printMultiFile.filename}\n          onClose={() => setPrintMultiFile(null)}\n          onSuccess={() => {\n            setPrintMultiFile(null);\n            setSelectedFiles([]);\n            queryClient.invalidateQueries({ queryKey: ['library-files'] });\n            queryClient.invalidateQueries({ queryKey: ['archives'] });\n          }}\n        />\n      )}\n\n      {scheduleFile && (\n        <PrintModal\n          mode=\"add-to-queue\"\n          libraryFileId={scheduleFile.id}\n          archiveName={scheduleFile.print_name || scheduleFile.filename}\n          onClose={() => setScheduleFile(null)}\n          onSuccess={() => {\n            setScheduleFile(null);\n            setSelectedFiles([]);\n            queryClient.invalidateQueries({ queryKey: ['library-files'] });\n            queryClient.invalidateQueries({ queryKey: ['queue'] });\n            queryClient.invalidateQueries({ queryKey: ['archives'] });\n          }}\n        />\n      )}\n\n      {viewerFile && (\n        <ModelViewerModal\n          libraryFileId={viewerFile.id}\n          title={viewerFile.print_name || viewerFile.filename}\n          fileType={viewerFile.file_type}\n          onClose={() => setViewerFile(null)}\n        />\n      )}\n\n      {renameItem && (\n        <RenameModal\n          type={renameItem.type}\n          currentName={renameItem.name}\n          onClose={() => setRenameItem(null)}\n          onSave={(newName) => {\n            if (renameItem.type === 'file') {\n              renameFileMutation.mutate({ id: renameItem.id, filename: newName });\n            } else {\n              renameFolderMutation.mutate({ id: renameItem.id, name: newName });\n            }\n          }}\n          isLoading={renameFileMutation.isPending || renameFolderMutation.isPending}\n          t={t}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/GroupEditPage.tsx",
    "content": "import { useState, useMemo } from 'react';\nimport { useParams, useNavigate } from 'react-router-dom';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { ArrowLeft, Save, Loader2, Search, Check, Minus, Shield, AlertTriangle } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { Permission, PermissionCategory } from '../api/client';\nimport { Button } from '../components/Button';\nimport { Card } from '../components/Card';\nimport { useToast } from '../contexts/ToastContext';\n\nexport function GroupEditPage() {\n  const { id } = useParams<{ id: string }>();\n  const navigate = useNavigate();\n  const queryClient = useQueryClient();\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const isEditing = Boolean(id);\n\n  const [name, setName] = useState('');\n  const [description, setDescription] = useState('');\n  const [permissions, setPermissions] = useState<Permission[]>([]);\n  const [search, setSearch] = useState('');\n  const [initialized, setInitialized] = useState(false);\n\n  const { data: groupData, isLoading: groupLoading } = useQuery({\n    queryKey: ['group', id],\n    queryFn: () => api.getGroup(Number(id)),\n    enabled: isEditing,\n  });\n\n  const { data: permissionsData, isLoading: permissionsLoading } = useQuery({\n    queryKey: ['permissions'],\n    queryFn: () => api.getPermissions(),\n  });\n\n  // Initialize form from fetched group data (once)\n  if (isEditing && groupData && !initialized) {\n    setName(groupData.name);\n    setDescription(groupData.description || '');\n    setPermissions(groupData.permissions);\n    setInitialized(true);\n  }\n\n  const createMutation = useMutation({\n    mutationFn: (data: { name: string; description?: string; permissions: Permission[] }) =>\n      api.createGroup(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['groups'] });\n      showToast(t('groups.toast.created'));\n      navigate('/settings?tab=users');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: (data: { name?: string; description?: string; permissions: Permission[] }) =>\n      api.updateGroup(Number(id), data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['groups'] });\n      showToast(t('groups.toast.updated'));\n      navigate('/settings?tab=users');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const isSaving = createMutation.isPending || updateMutation.isPending;\n\n  const handleSave = () => {\n    if (!name.trim()) {\n      showToast(t('groups.toast.enterGroupName'), 'error');\n      return;\n    }\n    if (isEditing) {\n      updateMutation.mutate({\n        name: name !== groupData?.name ? name : undefined,\n        description,\n        permissions,\n      });\n    } else {\n      createMutation.mutate({\n        name,\n        description: description || undefined,\n        permissions,\n      });\n    }\n  };\n\n  const togglePermission = (perm: Permission) => {\n    setPermissions((prev) =>\n      prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]\n    );\n  };\n\n  const toggleCategoryPermissions = (category: PermissionCategory, checked: boolean) => {\n    const categoryPerms = category.permissions.map((p) => p.value);\n    setPermissions((prev) => {\n      const otherPerms = prev.filter((p) => !categoryPerms.includes(p));\n      return checked ? [...otherPerms, ...categoryPerms] : otherPerms;\n    });\n  };\n\n  const isCategoryFullySelected = (category: PermissionCategory) =>\n    category.permissions.every((p) => permissions.includes(p.value));\n\n  const isCategoryPartiallySelected = (category: PermissionCategory) => {\n    const count = category.permissions.filter((p) => permissions.includes(p.value)).length;\n    return count > 0 && count < category.permissions.length;\n  };\n\n  const selectAll = () => {\n    if (permissionsData) {\n      setPermissions(permissionsData.all_permissions);\n    }\n  };\n\n  const clearAll = () => {\n    setPermissions([]);\n  };\n\n  const searchLower = search.toLowerCase();\n\n  const filteredCategories = useMemo(() => {\n    if (!permissionsData) return [];\n    if (!searchLower) return permissionsData.categories;\n    return permissionsData.categories\n      .map((cat) => ({\n        ...cat,\n        permissions: cat.permissions.filter((p) =>\n          p.label.toLowerCase().includes(searchLower)\n        ),\n      }))\n      .filter((cat) => cat.permissions.length > 0);\n  }, [permissionsData, searchLower]);\n\n  const totalPermissions = permissionsData?.all_permissions.length ?? 0;\n\n  if (groupLoading || permissionsLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-16\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-6 max-w-5xl mx-auto\">\n      {/* Header */}\n      <div className=\"flex items-center gap-3\">\n        <button\n          onClick={() => navigate('/settings?tab=users')}\n          className=\"p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors\"\n        >\n          <ArrowLeft className=\"w-5 h-5\" />\n        </button>\n        <h1 className=\"text-xl font-bold text-white\">\n          {isEditing ? t('groups.editor.title') : t('groups.editor.createTitle')}\n        </h1>\n      </div>\n\n      {/* System group warning */}\n      {isEditing && groupData?.is_system && (\n        <div className=\"flex items-center gap-3 px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-sm\">\n          <AlertTriangle className=\"w-4 h-4 shrink-0\" />\n          {t('groups.form.systemGroupWarning')}\n        </div>\n      )}\n\n      {/* Name + Description */}\n      <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n        <div>\n          <label className=\"block text-sm font-medium text-white mb-2\">{t('groups.form.groupName')}</label>\n          <input\n            type=\"text\"\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            disabled={isEditing && groupData?.is_system}\n            className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50\"\n            placeholder={t('groups.form.groupNamePlaceholder')}\n          />\n        </div>\n        <div>\n          <label className=\"block text-sm font-medium text-white mb-2\">{t('groups.form.description')}</label>\n          <input\n            type=\"text\"\n            value={description}\n            onChange={(e) => setDescription(e.target.value)}\n            className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n            placeholder={t('groups.form.descriptionPlaceholder')}\n          />\n        </div>\n      </div>\n\n      {/* Toolbar */}\n      <div className=\"flex items-center justify-between flex-wrap gap-3\">\n        <div className=\"flex items-center gap-3\">\n          <span className=\"text-sm text-bambu-gray\">\n            {t('groups.editor.permissionsSelected', { count: permissions.length })} / {totalPermissions}\n          </span>\n          <Button size=\"sm\" variant=\"ghost\" onClick={selectAll}>\n            {t('groups.editor.selectAll')}\n          </Button>\n          <Button size=\"sm\" variant=\"ghost\" onClick={clearAll}>\n            {t('groups.editor.clearAll')}\n          </Button>\n        </div>\n        <div className=\"relative\">\n          <Search className=\"w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray\" />\n          <input\n            type=\"text\"\n            value={search}\n            onChange={(e) => setSearch(e.target.value)}\n            placeholder={t('groups.editor.search')}\n            className=\"pl-9 pr-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors w-64\"\n          />\n        </div>\n      </div>\n\n      {/* Permission grid */}\n      {filteredCategories.length === 0 ? (\n        <div className=\"text-center py-12 text-bambu-gray\">\n          {t('groups.editor.noResults')}\n        </div>\n      ) : (\n        <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n          {filteredCategories.map((category) => {\n            // Use the full (unfiltered) category for selection logic\n            const fullCategory = permissionsData!.categories.find((c) => c.name === category.name)!;\n            const selectedCount = fullCategory.permissions.filter((p) => permissions.includes(p.value)).length;\n            const totalCount = fullCategory.permissions.length;\n            const fullySelected = isCategoryFullySelected(fullCategory);\n            const partiallySelected = isCategoryPartiallySelected(fullCategory);\n\n            return (\n              <Card key={category.name}>\n                <div className=\"sticky top-0 z-10 flex items-center justify-between px-4 py-3 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary rounded-t-xl\">\n                  <div className=\"flex items-center gap-3\">\n                    <button\n                      type=\"button\"\n                      onClick={() => toggleCategoryPermissions(fullCategory, !fullySelected)}\n                      className={`w-5 h-5 rounded border flex items-center justify-center transition-colors shrink-0 ${\n                        fullySelected\n                          ? 'bg-bambu-green border-bambu-green'\n                          : partiallySelected\n                          ? 'bg-bambu-green/50 border-bambu-green'\n                          : 'border-bambu-gray hover:border-white'\n                      }`}\n                    >\n                      {fullySelected && <Check className=\"w-3 h-3 text-white\" />}\n                      {partiallySelected && !fullySelected && <Minus className=\"w-3 h-3 text-white\" />}\n                    </button>\n                    <Shield className=\"w-4 h-4 text-bambu-gray shrink-0\" />\n                    <span className=\"text-white font-medium text-sm\">{category.name}</span>\n                  </div>\n                  <span className=\"text-xs text-bambu-gray tabular-nums\">\n                    {selectedCount}/{totalCount}\n                  </span>\n                </div>\n                <div className=\"p-3 space-y-1\">\n                  {category.permissions.map((perm) => (\n                    <label\n                      key={perm.value}\n                      className=\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer\"\n                    >\n                      <input\n                        type=\"checkbox\"\n                        checked={permissions.includes(perm.value)}\n                        onChange={() => togglePermission(perm.value)}\n                        className=\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary\"\n                      />\n                      <span className=\"text-sm text-bambu-gray\">{perm.label}</span>\n                    </label>\n                  ))}\n                </div>\n              </Card>\n            );\n          })}\n        </div>\n      )}\n\n      {/* Spacer for fixed bottom bar */}\n      <div className=\"h-16\" />\n\n      {/* Fixed bottom bar */}\n      <div className=\"fixed bottom-0 left-0 right-0 z-20 px-6 py-3 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-center justify-center gap-3\">\n        <Button variant=\"secondary\" onClick={() => navigate('/settings?tab=users')}>\n          {t('common.cancel')}\n        </Button>\n        <Button onClick={handleSave} disabled={isSaving || !name.trim()}>\n          {isSaving ? (\n            <>\n              <Loader2 className=\"w-4 h-4 animate-spin\" />\n              {t('common.saving')}\n            </>\n          ) : (\n            <>\n              <Save className=\"w-4 h-4\" />\n              {t('common.save')}\n            </>\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/InventoryPage.tsx",
    "content": "import { useState, useMemo, useEffect, type ReactNode } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,\n  Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,\n  TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,\n  ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw,\n} from 'lucide-react';\nimport { api, spoolbuddyApi } from '../api/client';\nimport type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';\nimport { Button } from '../components/Button';\nimport { SpoolFormModal } from '../components/SpoolFormModal';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { resolveSpoolColorName } from '../utils/colors';\nimport { getCurrencySymbol } from '../utils/currency';\nimport { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';\nimport { formatSlotLabel } from '../utils/amsHelpers';\n\ntype ArchiveFilter = 'active' | 'archived';\ntype UsageFilter = 'all' | 'used' | 'new' | 'lowstock';\ntype ViewMode = 'table' | 'cards';\ntype SortDirection = 'asc' | 'desc';\ntype SortState = { column: string; direction: SortDirection } | null;\n\ntype DisplayItem =\n  | { type: 'single'; spool: InventorySpool }\n  | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };\n\nfunction spoolGroupKey(s: InventorySpool): string {\n  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;\n}\n\n// Column definitions for the inventory table\nconst COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';\n\nconst DEFAULT_COLUMNS: ColumnConfig[] = [\n  { id: 'id', label: '#', visible: true },\n  { id: 'added_time', label: 'Added', visible: true },\n  { id: 'encode_time', label: 'Encoded', visible: false },\n  { id: 'last_used_time', label: 'Last Used', visible: false },\n  { id: 'rgba', label: 'Color', visible: true },\n  { id: 'material', label: 'Material', visible: true },\n  { id: 'subtype', label: 'Subtype', visible: true },\n  { id: 'color_name', label: 'Color Name', visible: false },\n  { id: 'brand', label: 'Brand', visible: true },\n  { id: 'slicer_filament', label: 'Slicer Filament', visible: false },\n  { id: 'location', label: 'Location', visible: true },\n  { id: 'label_weight', label: 'Label', visible: true },\n  { id: 'net', label: 'Net', visible: true },\n  { id: 'gross', label: 'Gross', visible: false },\n  { id: 'added_full', label: 'Full', visible: false },\n  { id: 'used', label: 'Used', visible: false },\n  { id: 'printed_total', label: 'Printed Total', visible: false },\n  { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },\n  { id: 'note', label: 'Note', visible: false },\n  { id: 'pa_k', label: 'PA(K)', visible: true },\n  { id: 'tag_id', label: 'Tag ID', visible: false },\n  { id: 'data_origin', label: 'Data Origin', visible: false },\n  { id: 'tag_type', label: 'Linked Tag Type', visible: false },\n  { id: 'stock', label: 'Stock', visible: false },\n  { id: 'remaining', label: 'Remaining', visible: true },\n  { id: 'spool_name', label: 'Spool', visible: false },\n  { id: 'cost_per_kg', label: 'Cost/kg', visible: false },\n  { id: 'weight_check', label: 'Weight Check', visible: false },\n];\n\nfunction loadColumnConfig(): ColumnConfig[] {\n  try {\n    const stored = localStorage.getItem(COLUMN_CONFIG_KEY);\n    if (stored) {\n      const parsed = JSON.parse(stored) as ColumnConfig[];\n      const defaultIds = new Set(DEFAULT_COLUMNS.map((c) => c.id));\n      const storedIds = new Set(parsed.map((c) => c.id));\n      // Keep stored columns that still exist in defaults\n      const validStored = parsed.filter((c) => defaultIds.has(c.id));\n      // Add any new default columns not in stored config\n      const newColumns = DEFAULT_COLUMNS.filter((c) => !storedIds.has(c.id));\n      return [...validStored, ...newColumns];\n    }\n  } catch {\n    // Ignore errors\n  }\n  return DEFAULT_COLUMNS.map((c) => ({ ...c }));\n}\n\nfunction saveColumnConfig(config: ColumnConfig[]) {\n  try {\n    localStorage.setItem(COLUMN_CONFIG_KEY, JSON.stringify(config));\n  } catch {\n    // Ignore errors\n  }\n}\n\nfunction formatWeight(g: number, useKg = false): string {\n  if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;\n  return `${Math.round(g)}g`;\n}\n\n// Material color mapping for pills\nconst MATERIAL_COLORS: Record<string, string> = {\n  PLA: 'bg-green-500/20 text-green-400',\n  ABS: 'bg-red-500/20 text-red-400',\n  PETG: 'bg-blue-500/20 text-blue-400',\n  TPU: 'bg-purple-500/20 text-purple-400',\n  ASA: 'bg-orange-500/20 text-orange-400',\n  PA: 'bg-yellow-500/20 text-yellow-400',\n  PC: 'bg-cyan-500/20 text-cyan-400',\n  PET: 'bg-sky-500/20 text-sky-400',\n};\n\ntype TFn = (key: string, opts?: Record<string, unknown>) => string;\n\nfunction formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {\n  if (!dateStr) return '-';\n  const date = parseUTCDate(dateStr);\n  if (!date) return '-';\n  return formatDateInput(date, dateFormat);\n}\n\ntype CellCtx = {\n  spool: InventorySpool;\n  remaining: number;\n  pct: number;\n  assignmentMap: Record<number, SpoolAssignment>;\n  catalogMap: Record<number, SpoolCatalogEntry>;\n  currencySymbol: string;\n  dateFormat: DateFormat;\n  t: TFn;\n  onSyncWeight?: (spool: InventorySpool) => void;\n};\n\n// Column header labels (25 columns — matching SpoolBuddy exactly)\nconst columnHeaders: Record<string, (t: TFn) => string> = {\n  id: () => '#',\n  added_time: () => 'Added',\n  encode_time: () => 'Encoded',\n  last_used_time: () => 'Last Used',\n  rgba: (t) => t('inventory.color'),\n  material: (t) => t('inventory.material'),\n  subtype: (t) => t('inventory.subtype'),\n  color_name: (t) => t('inventory.colorName'),\n  brand: (t) => t('inventory.brand'),\n  slicer_filament: (t) => t('inventory.slicerFilament'),\n  location: () => 'Location',\n  label_weight: (t) => t('inventory.labelWeight'),\n  net: (t) => t('inventory.net'),\n  gross: () => 'Gross',\n  added_full: () => 'Full',\n  used: (t) => t('inventory.weightUsed'),\n  printed_total: () => 'Printed Total',\n  printed_since_weight: () => 'Printed Since Weight',\n  note: (t) => t('inventory.note'),\n  pa_k: () => 'PA(K)',\n  tag_id: () => 'Tag ID',\n  data_origin: () => 'Data Origin',\n  tag_type: () => 'Linked Tag Type',\n  stock: (t) => t('inventory.stock'),\n  remaining: (t) => t('inventory.remaining'),\n  spool_name: (t) => t('inventory.spoolName'),\n  cost_per_kg: (t) => t('inventory.costPerKg'),\n  weight_check: (t) => t('inventory.weightCheck'),\n};\n\n// Column cell renderers (25 columns — matching SpoolBuddy exactly)\nconst columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {\n  id: ({ spool }) => (\n    <span className=\"text-sm font-medium text-white\">{spool.id}</span>\n  ),\n  added_time: ({ spool, dateFormat }) => (\n    <span className=\"text-sm text-bambu-gray\">{formatInventoryDate(spool.created_at, dateFormat)}</span>\n  ),\n  encode_time: ({ spool, dateFormat }) => (\n    <span className=\"text-sm text-bambu-gray\">{formatInventoryDate(spool.encode_time, dateFormat)}</span>\n  ),\n  last_used_time: ({ spool, dateFormat }) => (\n    <span className=\"text-sm text-bambu-gray\">{spool.last_used ? formatInventoryDate(spool.last_used, dateFormat) : 'Never'}</span>\n  ),\n  rgba: ({ spool }) => (\n    <div className=\"flex items-center justify-center\">\n      <span\n        className=\"w-5 h-5 rounded-full border border-black/20 flex-shrink-0\"\n        style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}\n        title={spool.rgba ? `#${spool.rgba.substring(0, 6)}` : undefined}\n      />\n    </div>\n  ),\n  material: ({ spool }) => (\n    <span className=\"text-sm text-white\">{spool.material}</span>\n  ),\n  subtype: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray\">{spool.subtype || '-'}</span>\n  ),\n  color_name: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray\">{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}</span>\n  ),\n  brand: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray\">{spool.brand || '-'}</span>\n  ),\n  slicer_filament: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray\" title={spool.slicer_filament || undefined}>\n      {spool.slicer_filament_name || spool.slicer_filament || '-'}\n    </span>\n  ),\n  location: ({ spool, assignmentMap }) => {\n    const assignment = assignmentMap[spool.id];\n    if (!assignment) return <span className=\"text-sm text-bambu-gray\">-</span>;\n    const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;\n    const isExternal = assignment.ams_id === 254 || assignment.ams_id === 255;\n    const isHt = !isExternal && assignment.ams_id >= 128;\n    const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);\n    return (\n      <span className=\"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400\">\n        {printerLabel} {slotLabel}{assignment.ams_label ? ` (${assignment.ams_label})` : ''}\n      </span>\n    );\n  },\n  label_weight: ({ spool }) => (\n    <span className=\"text-sm text-white\">{formatWeight(spool.label_weight)}</span>\n  ),\n  net: ({ remaining }) => (\n    <span className=\"text-sm text-white\">{formatWeight(remaining)}</span>\n  ),\n  gross: ({ spool, remaining }) => (\n    <span className=\"text-sm text-bambu-gray\">{formatWeight(remaining + spool.core_weight)}</span>\n  ),\n  added_full: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray\">{spool.added_full == null ? '-' : spool.added_full ? 'Yes' : 'No'}</span>\n  ),\n  used: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray\">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>\n  ),\n  printed_total: () => (\n    <span className=\"text-sm text-bambu-gray/50\">-</span>\n  ),\n  printed_since_weight: () => (\n    <span className=\"text-sm text-bambu-gray/50\">-</span>\n  ),\n  note: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray max-w-[150px] truncate block\" title={spool.note || undefined}>{spool.note || '-'}</span>\n  ),\n  pa_k: ({ spool }) => {\n    const count = spool.k_profiles?.length ?? 0;\n    if (count === 0) return <span className=\"text-sm text-bambu-gray\">-</span>;\n    return (\n      <span className=\"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green\">\n        K\n      </span>\n    );\n  },\n  tag_id: ({ spool }) => {\n    const tag = spool.tag_uid || spool.tray_uuid;\n    if (!tag) return <span className=\"text-sm text-bambu-gray/50\">-</span>;\n    return (\n      <span className=\"text-sm text-bambu-gray font-mono\" title={tag}>\n        {tag.length > 12 ? `${tag.slice(0, 6)}...${tag.slice(-4)}` : tag}\n      </span>\n    );\n  },\n  data_origin: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray\">{spool.data_origin || '-'}</span>\n  ),\n  tag_type: ({ spool }) => (\n    <span className=\"text-sm text-bambu-gray\">{spool.tag_type || '-'}</span>\n  ),\n  stock: ({ spool, t }) => {\n    if (!spool.slicer_filament) {\n      return (\n        <span className=\"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-500/20 text-amber-400\">\n          {t('inventory.stock')}\n        </span>\n      );\n    }\n    return <span className=\"text-sm text-bambu-gray\">-</span>;\n  },\n  remaining: ({ remaining, pct }) => (\n    <div className=\"flex items-center gap-2\">\n      <div className=\"flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden\">\n        <div\n          className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}\n          style={{ width: `${Math.min(pct, 100)}%` }}\n        />\n      </div>\n      <span className=\"text-xs text-bambu-gray min-w-[40px] text-right\">{Math.round(remaining)}g</span>\n    </div>\n  ),\n  spool_name: ({ spool, catalogMap }) => {\n    const entry = spool.core_weight_catalog_id != null ? catalogMap[spool.core_weight_catalog_id] : undefined;\n    return <span className=\"text-sm text-bambu-gray\">{entry?.name || '-'}</span>;\n  },\n  cost_per_kg: ({ spool, currencySymbol }) => (\n    <span className=\"text-sm text-bambu-gray\">\n      {spool.cost_per_kg != null ? `${currencySymbol}${spool.cost_per_kg.toFixed(2)}` : '-'}\n    </span>\n  ),\n  weight_check: ({ spool, onSyncWeight }) => {\n    const scaleWeight = spool.last_scale_weight;\n    if (scaleWeight == null) return <span className=\"text-sm text-bambu-gray/50\" title=\"No scale measurement\">-</span>;\n\n    const coreWeight = spool.core_weight || 0;\n    const calculatedWeight = Math.max(0, spool.label_weight - spool.weight_used) + coreWeight;\n\n    // Edge case: scale < core_weight means spool is empty or not on scale — treat as match\n    let difference: number;\n    let isMatch: boolean;\n    if (scaleWeight < coreWeight) {\n      difference = scaleWeight - coreWeight;\n      isMatch = true;\n    } else {\n      difference = scaleWeight - calculatedWeight;\n      isMatch = Math.abs(difference) <= 50;\n    }\n\n    const diffStr = difference > 0 ? `+${Math.round(difference)}` : `${Math.round(difference)}`;\n    const tooltip = isMatch\n      ? `Scale: ${Math.round(scaleWeight)}g\\nCalculated: ${Math.round(calculatedWeight)}g\\nDifference: ${diffStr}g (within tolerance)`\n      : `Scale: ${Math.round(scaleWeight)}g\\nCalculated: ${Math.round(calculatedWeight)}g\\nDifference: ${diffStr}g (mismatch!)`;\n\n    return (\n      <div\n        className={`flex items-center gap-1 text-sm font-medium ${isMatch ? 'text-green-400' : 'text-yellow-400'}`}\n        title={tooltip}\n      >\n        <span>{Math.round(scaleWeight)}g</span>\n        {isMatch ? (\n          <Check className=\"w-3 h-3\" />\n        ) : (\n          <>\n            <AlertTriangle className=\"w-3 h-3\" />\n            {onSyncWeight && (\n              <button\n                type=\"button\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                  e.preventDefault();\n                  onSyncWeight(spool);\n                }}\n                className=\"p-1 hover:bg-bambu-green/20 rounded transition-colors text-bambu-green\"\n                title=\"Sync: trust scale weight and reset tracking\"\n              >\n                <RefreshCw className=\"w-3.5 h-3.5\" />\n              </button>\n            )}\n          </>\n        )}\n      </div>\n    );\n  },\n};\n\n// Sort value extractors — return a comparable value for each sortable column\nconst columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Record<number, SpoolAssignment>) => string | number> = {\n  id: (s) => s.id,\n  added_time: (s) => s.created_at || '',\n  encode_time: (s) => s.encode_time || '',\n  last_used_time: (s) => s.last_used || '',\n  material: (s) => (s.material || '').toLowerCase(),\n  subtype: (s) => (s.subtype || '').toLowerCase(),\n  color_name: (s) => (s.color_name || '').toLowerCase(),\n  brand: (s) => (s.brand || '').toLowerCase(),\n  slicer_filament: (s) => (s.slicer_filament_name || s.slicer_filament || '').toLowerCase(),\n  location: (s, am) => {\n    const a = am[s.id];\n    if (!a) return '';\n    const isExt = a.ams_id === 254 || a.ams_id === 255;\n    const isHt = !isExt && a.ams_id >= 128;\n    const label = a.ams_label ? ` (${a.ams_label})` : '';\n    return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`;\n  },\n  label_weight: (s) => s.label_weight,\n  net: (s) => Math.max(0, s.label_weight - s.weight_used),\n  gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight,\n  used: (s) => s.weight_used,\n  remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,\n  note: (s) => (s.note || '').toLowerCase(),\n  data_origin: (s) => (s.data_origin || '').toLowerCase(),\n  tag_type: (s) => (s.tag_type || '').toLowerCase(),\n  stock: (s) => s.slicer_filament ? 1 : 0,\n  spool_name: (s) => s.core_weight_catalog_id ?? 0,\n  cost_per_kg: (s) => s.cost_per_kg ?? 0,\n  weight_check: (s) => {\n    if (s.last_scale_weight == null) return -1;\n    const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight;\n    return Math.abs(s.last_scale_weight - expectedGross);\n  },\n};\n\nconst SORT_STATE_KEY = 'bambuddy-inventory-sort';\n\nfunction loadSortState(): SortState {\n  try {\n    const stored = localStorage.getItem(SORT_STATE_KEY);\n    if (stored) return JSON.parse(stored);\n  } catch { /* ignore */ }\n  return null;\n}\n\nfunction saveSortState(state: SortState) {\n  try {\n    if (state) {\n      localStorage.setItem(SORT_STATE_KEY, JSON.stringify(state));\n    } else {\n      localStorage.removeItem(SORT_STATE_KEY);\n    }\n  } catch { /* ignore */ }\n}\n\n// Wrapper: when Spoolman is enabled, embed its UI; otherwise show internal inventory\nexport default function InventoryPageRouter() {\n  const { data: spoolmanSettings } = useQuery({\n    queryKey: ['spoolman-settings'],\n    queryFn: api.getSpoolmanSettings,\n    staleTime: 5 * 60 * 1000,\n  });\n\n  if (spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url) {\n    return (\n      <iframe\n        src={`${spoolmanSettings.spoolman_url.replace(/\\/+$/, '')}/spool`}\n        className=\"h-full w-full border-0\"\n        title=\"Spoolman\"\n        sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"\n      />\n    );\n  }\n\n  return <InventoryPage />;\n}\n\nfunction InventoryPage() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);\n  const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);\n\n  // Filter state\n  const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');\n  const [usageFilter, setUsageFilter] = useState<UsageFilter>('all');\n  const [materialFilter, setMaterialFilter] = useState('');\n  const [brandFilter, setBrandFilter] = useState('');\n  const [spoolFilter, setSpoolFilter] = useState('');\n  const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');\n  const [search, setSearch] = useState('');\n  const [viewMode, setViewMode] = useState<ViewMode>('table');\n  const [sortState, setSortState] = useState<SortState>(loadSortState);\n  const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);\n  const [showColumnModal, setShowColumnModal] = useState(false);\n  const [groupSimilar, setGroupSimilar] = useState(() => {\n    try {\n      return localStorage.getItem('bambuddy-inventory-group') === 'true';\n    } catch { return false; }\n  });\n  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());\n\n  // Pagination state (pageSize persisted to localStorage)\n  const [pageIndex, setPageIndex] = useState(0);\n  const [pageSize, setPageSize] = useState(() => {\n    try {\n      const stored = localStorage.getItem('bambuddy-inventory-pageSize');\n      if (stored) {\n        const n = Number(stored);\n        if ([15, 30, 50, 100, -1].includes(n)) return n;\n      }\n    } catch { /* ignore */ }\n    return 15;\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const dateFormat: DateFormat = settings?.date_format || 'system';\n\n  const { data: spools, isLoading } = useQuery({\n    queryKey: ['inventory-spools'],\n    queryFn: () => api.getSpools(true), // Always fetch all, filter client-side\n    refetchInterval: 30000,\n  });\n\n  const { data: assignments } = useQuery({\n    queryKey: ['spool-assignments'],\n    queryFn: () => api.getAssignments(),\n    refetchInterval: 30000,\n  });\n\n  const { data: catalogEntries } = useQuery({\n    queryKey: ['spool-catalog'],\n    queryFn: () => api.getSpoolCatalog(),\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (id: number) => api.deleteSpool(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      showToast(t('inventory.spoolDeleted'), 'success');\n    },\n  });\n\n  const archiveMutation = useMutation({\n    mutationFn: (id: number) => api.archiveSpool(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      showToast(t('inventory.spoolArchived'), 'success');\n    },\n  });\n\n  const restoreMutation = useMutation({\n    mutationFn: (id: number) => api.restoreSpool(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      showToast(t('inventory.spoolRestored'), 'success');\n    },\n  });\n\n  const handleSyncWeight = async (spool: InventorySpool) => {\n    if (spool.last_scale_weight == null) return;\n    try {\n      await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight);\n      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });\n      const spoolName = [spool.brand, spool.material, spool.color_name].filter(Boolean).join(' ');\n      showToast(`Synced \"${spoolName}\" to scale weight`, 'success');\n    } catch {\n      showToast('Failed to sync weight', 'error');\n    }\n  };\n\n  // Low stock threshold from backend settings\n  const lowStockThreshold = settings?.low_stock_threshold ?? 20;\n  const [showThresholdInput, setShowThresholdInput] = useState(false);\n  const [thresholdInput, setThresholdInput] = useState(lowStockThreshold.toString());\n\n  // Sync thresholdInput when lowStockThreshold changes and input is not shown\n  useEffect(() => {\n    if (!showThresholdInput) {\n      setThresholdInput(lowStockThreshold.toString());\n    }\n  }, [lowStockThreshold, showThresholdInput]);\n\n  const updateThresholdMutation = useMutation({\n    mutationFn: (threshold: number) => api.updateSettings({ low_stock_threshold: threshold }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['settings'] });\n      showToast(t('common.saved'), 'success');\n      setShowThresholdInput(false);\n    },\n    onError: () => {\n      showToast(t('inventory.lowStockThresholdError'), 'error');\n    },\n  });\n\n  // Stats calculation (active spools only)\n  const stats = useMemo(() => {\n    if (!spools) return null;\n    let totalWeight = 0;\n    let totalConsumed = 0;\n    let lowStock = 0;\n    let activeCount = 0;\n    const byMaterial: Record<string, { count: number; weight: number }> = {};\n    for (const s of spools) {\n      if (s.archived_at) continue;\n      activeCount++;\n      const remaining = Math.max(0, s.label_weight - s.weight_used);\n      totalWeight += remaining;\n      totalConsumed += s.weight_used;\n      const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;\n      if (pct < lowStockThreshold) lowStock++;\n      const mat = s.material || 'Unknown';\n      if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };\n      byMaterial[mat].count++;\n      byMaterial[mat].weight += remaining;\n    }\n    return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount };\n  }, [spools, lowStockThreshold]);\n\n  const inPrinterCount = assignments?.length ?? 0;\n\n  const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');\n\n  // Map spool_id -> assignment for location column\n  const assignmentMap = useMemo(() => {\n    const map: Record<number, SpoolAssignment> = {};\n    for (const a of assignments || []) {\n      map[a.spool_id] = a;\n    }\n    return map;\n  }, [assignments]);\n\n  // Map catalog_id -> catalog entry for spool name column\n  const catalogMap = useMemo(() => {\n    const map: Record<number, SpoolCatalogEntry> = {};\n    for (const e of catalogEntries || []) {\n      map[e.id] = e;\n    }\n    return map;\n  }, [catalogEntries]);\n\n  // Top materials by weight for stat card pills\n  const topMaterials = useMemo(() => {\n    if (!stats) return [];\n    return Object.entries(stats.byMaterial)\n      .sort((a, b) => b[1].weight - a[1].weight)\n      .slice(0, 4);\n  }, [stats]);\n\n  // Filtering pipeline\n  const filteredSpools = useMemo(() => {\n    let filtered = spools || [];\n\n    // Archive filter\n    if (archiveFilter === 'active') {\n      filtered = filtered.filter((s) => !s.archived_at);\n    } else {\n      filtered = filtered.filter((s) => !!s.archived_at);\n    }\n\n    // Usage filter\n    if (usageFilter === 'used') {\n      filtered = filtered.filter((s) => s.weight_used > 0);\n    } else if (usageFilter === 'new') {\n      filtered = filtered.filter((s) => s.weight_used === 0);\n    } else if (usageFilter === 'lowstock') {\n      filtered = filtered.filter((s) => {\n        const remaining = Math.max(0, s.label_weight - s.weight_used);\n        const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;\n        return pct < lowStockThreshold;\n      });\n    }\n\n    // Material dropdown\n    if (materialFilter) {\n      filtered = filtered.filter((s) => s.material === materialFilter);\n    }\n\n    // Brand dropdown\n    if (brandFilter) {\n      filtered = filtered.filter((s) => s.brand === brandFilter);\n    }\n\n    // Spool name dropdown\n    if (spoolFilter) {\n      const catalogId = Number(spoolFilter);\n      filtered = filtered.filter((s) => s.core_weight_catalog_id === catalogId);\n    }\n\n    // Stock filter\n    if (stockFilter === 'stock') {\n      filtered = filtered.filter((s) => !s.slicer_filament);\n    } else if (stockFilter === 'configured') {\n      filtered = filtered.filter((s) => !!s.slicer_filament);\n    }\n\n    // Global search\n    if (search) {\n      const q = search.toLowerCase();\n      filtered = filtered.filter((s) =>\n        s.brand?.toLowerCase().includes(q) ||\n        s.material.toLowerCase().includes(q) ||\n        s.color_name?.toLowerCase().includes(q) ||\n        s.subtype?.toLowerCase().includes(q) ||\n        s.note?.toLowerCase().includes(q) ||\n        s.slicer_filament_name?.toLowerCase().includes(q)\n      );\n    }\n\n    return filtered;\n  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, spoolFilter, stockFilter, search, lowStockThreshold]);\n\n  // Reset page on filter changes\n  const resetPage = () => setPageIndex(0);\n\n  // Unique values for filter dropdowns\n  const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort();\n  const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];\n  const uniqueSpoolCatalogIds = [...new Set(spools?.map((s) => s.core_weight_catalog_id).filter((id): id is number => id != null) || [])].sort((a, b) => {\n    const nameA = (catalogMap[a]?.name || '').toLowerCase();\n    const nameB = (catalogMap[b]?.name || '').toLowerCase();\n    return nameA.localeCompare(nameB);\n  });\n\n  // Check if any filters are non-default\n  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!spoolFilter || stockFilter !== 'all' || !!search;\n\n  const handleColumnConfigSave = (config: ColumnConfig[]) => {\n    setColumnConfig(config);\n    saveColumnConfig(config);\n  };\n\n  // Visible column IDs in order\n  const visibleColumns = useMemo(\n    () => columnConfig.filter((c) => c.visible).map((c) => c.id),\n    [columnConfig]\n  );\n\n  const handleSort = (colId: string) => {\n    if (!columnSortValues[colId]) return; // Not sortable\n    setSortState((prev) => {\n      let next: SortState;\n      if (prev?.column === colId) {\n        // Toggle direction, or clear on third click\n        next = prev.direction === 'asc' ? { column: colId, direction: 'desc' } : null;\n      } else {\n        next = { column: colId, direction: 'asc' };\n      }\n      saveSortState(next);\n      return next;\n    });\n    resetPage();\n  };\n\n  // Sort filtered spools\n  const sortedSpools = useMemo(() => {\n    if (!sortState) return filteredSpools;\n    const extractor = columnSortValues[sortState.column];\n    if (!extractor) return filteredSpools;\n    const sorted = [...filteredSpools].sort((a, b) => {\n      const va = extractor(a, assignmentMap);\n      const vb = extractor(b, assignmentMap);\n      if (va < vb) return sortState.direction === 'asc' ? -1 : 1;\n      if (va > vb) return sortState.direction === 'asc' ? 1 : -1;\n      return 0;\n    });\n    return sorted;\n  }, [filteredSpools, sortState, assignmentMap]);\n\n  // Group similar spools when toggle is active\n  const displayItems = useMemo((): DisplayItem[] => {\n    if (!groupSimilar) return sortedSpools.map((s) => ({ type: 'single' as const, spool: s }));\n\n    const groups = new Map<string, InventorySpool[]>();\n\n    for (const spool of sortedSpools) {\n      // Only group unused & unassigned spools\n      if (spool.weight_used > 0 || assignmentMap[spool.id]) {\n        // Will be added as singles in the walk below\n      } else {\n        const key = spoolGroupKey(spool);\n        const arr = groups.get(key);\n        if (arr) arr.push(spool);\n        else groups.set(key, [spool]);\n      }\n    }\n\n    const items: DisplayItem[] = [];\n    const processedKeys = new Set<string>();\n\n    // Walk sortedSpools order so groups appear at the position of their first member\n    for (const spool of sortedSpools) {\n      if (spool.weight_used > 0 || assignmentMap[spool.id]) {\n        items.push({ type: 'single', spool });\n        continue;\n      }\n      const key = spoolGroupKey(spool);\n      if (processedKeys.has(key)) continue;\n      processedKeys.add(key);\n      const members = groups.get(key)!;\n      if (members.length === 1) {\n        items.push({ type: 'single', spool: members[0] });\n      } else {\n        items.push({ type: 'group', key, spools: members, representative: members[0] });\n      }\n    }\n    return items;\n  }, [sortedSpools, groupSimilar, assignmentMap]);\n\n  // Pagination (after sorting) — pageSize -1 means \"All\"\n  const showAll = pageSize === -1;\n  const totalDisplayItems = displayItems.length;\n  const effectivePageSize = showAll ? totalDisplayItems || 1 : pageSize;\n  const totalPages = Math.max(1, Math.ceil(totalDisplayItems / effectivePageSize));\n  const safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1);\n  const pagedItems = showAll\n    ? displayItems\n    : displayItems.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);\n  const toggleGroupSimilar = () => {\n    const next = !groupSimilar;\n    setGroupSimilar(next);\n    setExpandedGroups(new Set());\n    resetPage();\n    try { localStorage.setItem('bambuddy-inventory-group', String(next)); } catch { /* ignore */ }\n  };\n\n  const toggleGroupExpand = (key: string) => {\n    setExpandedGroups((prev) => {\n      const next = new Set(prev);\n      if (next.has(key)) next.delete(key);\n      else next.add(key);\n      return next;\n    });\n  };\n\n  const handlePageSizeChange = (size: number) => {\n    setPageSize(size);\n    setPageIndex(0);\n    try { localStorage.setItem('bambuddy-inventory-pageSize', String(size)); } catch { /* ignore */ }\n  };\n\n  const clearAllFilters = () => {\n    setArchiveFilter('active');\n    setUsageFilter('all');\n    setMaterialFilter('');\n    setBrandFilter('');\n    setSpoolFilter('');\n    setStockFilter('all');\n    setSearch('');\n    resetPage();\n  };\n\n  return (\n    <div className=\"p-4 md:p-6 space-y-6\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <div className=\"flex items-center gap-3\">\n            <Package className=\"w-6 h-6 text-bambu-green\" />\n            <h1 className=\"text-2xl font-bold text-white\">{t('inventory.title')}</h1>\n          </div>\n          <p className=\"text-sm text-bambu-gray mt-1 ml-9\">{t('inventory.noSpools').split('.')[0] ? '' : ''}</p>\n        </div>\n        <Button onClick={() => setFormModal({ spool: null })}>\n          <Plus className=\"w-4 h-4\" />\n          {t('inventory.addSpool')}\n        </Button>\n      </div>\n\n      {/* Stats Bar */}\n      {stats && !isLoading && (\n        <div className=\"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3\">\n          {/* Total Inventory */}\n          <div className=\"bg-bambu-dark-secondary rounded-lg p-4\">\n            <div className=\"flex items-center gap-2 mb-1\">\n              <Package className=\"w-4 h-4 text-bambu-green\" />\n              <span className=\"text-xs text-bambu-gray font-medium uppercase tracking-wide\">{t('inventory.totalInventory')}</span>\n            </div>\n            <div className=\"text-xl font-bold text-white\">{formatWeight(stats.totalWeight, true)}</div>\n            <div className=\"text-xs text-bambu-gray mt-1\">{stats.totalSpools} {stats.totalSpools !== 1 ? t('inventory.spools') : t('inventory.spool')}</div>\n          </div>\n\n          {/* Total Consumed */}\n          <div className=\"bg-bambu-dark-secondary rounded-lg p-4\">\n            <div className=\"flex items-center gap-2 mb-1\">\n              <TrendingDown className=\"w-4 h-4 text-blue-400\" />\n              <span className=\"text-xs text-bambu-gray font-medium uppercase tracking-wide\">{t('inventory.totalConsumed')}</span>\n            </div>\n            <div className=\"text-xl font-bold text-white\">{formatWeight(stats.totalConsumed, true)}</div>\n            <div className=\"text-xs text-bambu-gray mt-1\">{t('inventory.sinceTracking')}</div>\n          </div>\n\n          {/* By Material */}\n          <div className=\"bg-bambu-dark-secondary rounded-lg p-4\">\n            <div className=\"flex items-center gap-2 mb-1\">\n              <Layers className=\"w-4 h-4 text-green-400\" />\n              <span className=\"text-xs text-bambu-gray font-medium uppercase tracking-wide\">{t('inventory.byMaterial')}</span>\n            </div>\n            <div className=\"flex flex-wrap gap-1.5 mt-1\">\n              {topMaterials.map(([mat, data]) => (\n                <span\n                  key={mat}\n                  className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${MATERIAL_COLORS[mat] || 'bg-bambu-dark-tertiary text-bambu-gray'}`}\n                >\n                  {mat} <span className=\"opacity-70\">{formatWeight(data.weight, true)}</span>\n                </span>\n              ))}\n            </div>\n          </div>\n\n          {/* In Printer */}\n          <div className=\"bg-bambu-dark-secondary rounded-lg p-4\">\n            <div className=\"flex items-center gap-2 mb-1\">\n              <Printer className=\"w-4 h-4 text-purple-400\" />\n              <span className=\"text-xs text-bambu-gray font-medium uppercase tracking-wide\">{t('inventory.inPrinter')}</span>\n            </div>\n            <div className=\"text-xl font-bold text-white\">{inPrinterCount}</div>\n            <div className=\"text-xs text-bambu-gray mt-1\">{t('inventory.loadedInAms')}</div>\n          </div>\n\n          {/* Low Stock */}\n          <div className=\"bg-bambu-dark-secondary rounded-lg p-4\">\n            <div className=\"flex items-center gap-2 mb-1\">\n              <AlertTriangle className=\"w-4 h-4 text-yellow-400\" />\n              <span className=\"text-xs text-bambu-gray font-medium uppercase tracking-wide\">{t('inventory.lowStock')}</span>\n            </div>\n            <div className={`text-xl font-bold ${stats.lowStock > 0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}</div>\n            <div className=\"text-xs text-bambu-gray mt-1 flex items-center gap-2\">\n              {showThresholdInput ? (\n                <form\n                  onSubmit={e => {\n                    e.preventDefault();\n                    const val = parseFloat(thresholdInput);\n                    if (!isNaN(val) && val >= 0.1 && val <= 99.9) {\n                      updateThresholdMutation.mutate(val);\n                    } else {\n                      showToast(t('inventory.lowStockThresholdError'), 'error');\n                    }\n                  }}\n                  className=\"flex items-center gap-2\"\n                >\n                  <span className=\"text-xs text-bambu-gray\">{'<'}</span>\n                  <input\n                    type=\"text\"\n                    inputMode=\"decimal\"\n                    pattern=\"^\\d{0,2}(\\.\\d?)?$\"\n                    maxLength={4}\n                    value={thresholdInput}\n                    onChange={e => {\n                      // Only allow up to 2 digits before decimal and 1 after\n                      const val = e.target.value.replace(/[^\\d.]/g, '');\n                      if (/^\\d{0,2}(\\.\\d?)?$/.test(val)) {\n                        setThresholdInput(val);\n                      }\n                    }}\n                    className=\"px-1.5 py-1 rounded border border-bambu-dark-tertiary text-xs text-white bg-bambu-dark-secondary focus:outline-none focus:border-bambu-green w-14 text-center\"\n                    onWheel={e => e.currentTarget.blur()}\n                    disabled={updateThresholdMutation.isPending}\n                  />\n\n                  <span className=\"text-xs text-bambu-gray\">%</span>\n                  <Button type=\"submit\" size=\"sm\" disabled={updateThresholdMutation.isPending}>{t('common.save')}</Button>\n                  <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => setShowThresholdInput(false)} disabled={updateThresholdMutation.isPending}>{t('common.cancel')}</Button>\n                </form>\n              ) : (\n                <>\n                  <span className=\"text-bambu-gray\">{'< '}{lowStockThreshold}%</span>\n                  <button\n                    className=\"p-1.5 text-bambu-gray hover:text-white rounded transition-colors\"\n                    title={t('common.edit')}\n                    onClick={() => {\n                      setThresholdInput(lowStockThreshold.toString());\n                      setShowThresholdInput(true);\n                    }}\n                  >\n                    <Edit2 className=\"w-4 h-4\" />\n                  </button>\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Toolbar: Search + View toggle */}\n      <div className=\"flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between\">\n        <div className=\"relative flex-1 max-w-md\">\n          <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50\" />\n          <input\n            type=\"text\"\n            value={search}\n            onChange={(e) => { setSearch(e.target.value); resetPage(); }}\n            placeholder={t('inventory.search')}\n            className=\"w-full pl-10 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n          />\n          {search && (\n            <button\n              onClick={() => { setSearch(''); resetPage(); }}\n              className=\"absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\"\n            >\n              <X className=\"w-4 h-4\" />\n            </button>\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          {/* Columns button (table view only) */}\n          {viewMode === 'table' && (\n            <button\n              onClick={() => setShowColumnModal(true)}\n              className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors\"\n              title={t('inventory.configureColumns')}\n            >\n              <Columns className=\"w-4 h-4\" />\n              <span className=\"hidden sm:inline\">{t('inventory.columns')}</span>\n            </button>\n          )}\n          {/* Group similar toggle */}\n          <button\n            onClick={toggleGroupSimilar}\n            className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border rounded-lg transition-colors ${\n              groupSimilar\n                ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'\n                : 'text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'\n            }`}\n            title={t('inventory.groupSimilar')}\n          >\n            <Group className=\"w-4 h-4\" />\n            <span className=\"hidden sm:inline\">{t('inventory.groupSimilar')}</span>\n          </button>\n          {/* Table / Cards toggle */}\n          <div className=\"flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden\">\n            <button\n              onClick={() => setViewMode('table')}\n              className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${\n                viewMode === 'table'\n                  ? 'bg-bambu-green text-white'\n                  : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n              }`}\n            >\n              <TableProperties className=\"w-4 h-4\" />\n              <span className=\"hidden sm:inline\">{t('inventory.table')}</span>\n            </button>\n            <button\n              onClick={() => setViewMode('cards')}\n              className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${\n                viewMode === 'cards'\n                  ? 'bg-bambu-green text-white'\n                  : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n              }`}\n            >\n              <LayoutGrid className=\"w-4 h-4\" />\n              <span className=\"hidden sm:inline\">{t('inventory.cards')}</span>\n            </button>\n          </div>\n        </div>\n      </div>\n\n      {/* Filter chips row */}\n      <div className=\"flex flex-wrap items-center gap-2\">\n        {/* Active / Archived chips */}\n        <div className=\"flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden\">\n          <button\n            onClick={() => { setArchiveFilter('active'); resetPage(); }}\n            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${\n              archiveFilter === 'active'\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            <Package className=\"w-3.5 h-3.5\" />\n            {t('inventory.active')}\n          </button>\n          <button\n            onClick={() => { setArchiveFilter('archived'); resetPage(); }}\n            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${\n              archiveFilter === 'archived'\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            <Archive className=\"w-3.5 h-3.5\" />\n            {t('inventory.archived')}\n          </button>\n        </div>\n\n        <div className=\"w-px h-5 bg-bambu-dark-tertiary\" />\n\n        {/* All / Used / New chips */}\n        <div className=\"flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden\">\n          <button\n            onClick={() => { setUsageFilter('all'); resetPage(); }}\n            className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n              usageFilter === 'all'\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            {t('inventory.all')}\n          </button>\n          <button\n            onClick={() => { setUsageFilter('used'); resetPage(); }}\n            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${\n              usageFilter === 'used'\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            <Clock className=\"w-3.5 h-3.5\" />\n            {t('inventory.used')}\n          </button>\n          <button\n            onClick={() => { setUsageFilter('new'); resetPage(); }}\n            className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n              usageFilter === 'new'\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            {t('inventory.new')}\n          </button>\n          <button\n            onClick={() => { setUsageFilter('lowstock'); resetPage(); }}\n            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${\n              usageFilter === 'lowstock'\n                ? 'bg-yellow-500/20 text-yellow-400'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            <AlertTriangle className=\"w-3.5 h-3.5\" />\n            {t('inventory.lowStock')}\n          </button>\n        </div>\n\n        {/* Stock filter chips */}\n        <div className=\"flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden\">\n          <button\n            onClick={() => { setStockFilter('all'); resetPage(); }}\n            className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n              stockFilter === 'all'\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            {t('inventory.all')}\n          </button>\n          <button\n            onClick={() => { setStockFilter('stock'); resetPage(); }}\n            className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n              stockFilter === 'stock'\n                ? 'bg-amber-500/20 text-amber-400'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            {t('inventory.stock')}\n          </button>\n          <button\n            onClick={() => { setStockFilter('configured'); resetPage(); }}\n            className={`px-3 py-1.5 text-xs font-medium transition-colors ${\n              stockFilter === 'configured'\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            {t('inventory.configured')}\n          </button>\n        </div>\n\n        <div className=\"w-px h-5 bg-bambu-dark-tertiary\" />\n\n        {/* Material dropdown chip */}\n        <select\n          value={materialFilter}\n          onChange={(e) => { setMaterialFilter(e.target.value); resetPage(); }}\n          className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${\n            materialFilter\n              ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'\n              : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'\n          }`}\n        >\n          <option value=\"\">{t('inventory.material')}</option>\n          {uniqueMaterials.map((m) => (\n            <option key={m} value={m}>{m}</option>\n          ))}\n        </select>\n\n        {/* Brand dropdown chip */}\n        <select\n          value={brandFilter}\n          onChange={(e) => { setBrandFilter(e.target.value); resetPage(); }}\n          className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${\n            brandFilter\n              ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'\n              : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'\n          }`}\n        >\n          <option value=\"\">{t('inventory.brand')}</option>\n          {uniqueBrands.map((b) => (\n            <option key={b} value={b}>{b}</option>\n          ))}\n        </select>\n\n        {/* Spool name dropdown chip */}\n        {uniqueSpoolCatalogIds.length > 0 && (\n          <select\n            value={spoolFilter}\n            onChange={(e) => { setSpoolFilter(e.target.value); resetPage(); }}\n            className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${\n              spoolFilter\n                ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'\n                : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            <option value=\"\">{t('inventory.spoolName')}</option>\n            {uniqueSpoolCatalogIds.map((id) => (\n              <option key={id} value={id}>{catalogMap[id]?.name || `#${id}`}</option>\n            ))}\n          </select>\n        )}\n\n        {/* Clear filters */}\n        {hasActiveFilters && (\n          <>\n            <div className=\"w-px h-5 bg-bambu-dark-tertiary\" />\n            <button\n              onClick={clearAllFilters}\n              className=\"flex items-center gap-1 text-xs text-bambu-gray hover:text-bambu-green transition-colors\"\n            >\n              <X className=\"w-3.5 h-3.5\" />\n              {t('inventory.clearFilters')}\n            </button>\n          </>\n        )}\n\n        {/* Results count */}\n        <span className=\"ml-auto text-xs text-bambu-gray\">\n          {sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}\n          {groupSimilar && totalDisplayItems < sortedSpools.length && ` (${totalDisplayItems} ${t('inventory.groupedRows')})`}\n        </span>\n      </div>\n\n      {/* Content */}\n      {isLoading ? (\n        <div className=\"flex justify-center py-16\">\n          <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n        </div>\n      ) : viewMode === 'cards' ? (\n        /* Cards view */\n        pagedItems.length > 0 ? (\n          <>\n            <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">\n              {pagedItems.map((item) => {\n                if (item.type === 'group') {\n                  const { key, spools: groupSpools, representative: rep } = item;\n                  const colorStyle = rep.rgba ? `#${rep.rgba.substring(0, 6)}` : '#808080';\n                  const isExpanded = expandedGroups.has(key);\n                  return (\n                    <div key={`group-${key}`} className=\"col-span-full\">\n                      {/* Group header card */}\n                      <div\n                        className=\"bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer\"\n                        onClick={() => toggleGroupExpand(key)}\n                      >\n                        <div className=\"h-10 flex items-center px-4 gap-3\" style={{ backgroundColor: colorStyle }}>\n                          <span className=\"bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium\">\n                            {resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}\n                          </span>\n                        </div>\n                        <div className=\"px-4 py-3 flex items-center justify-between\">\n                          <div className=\"flex items-center gap-3\">\n                            <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />\n                            <div>\n                              <h3 className=\"font-semibold text-white\">{rep.material}{rep.subtype ? ` ${rep.subtype}` : ''}</h3>\n                              <p className=\"text-sm text-bambu-gray\">{rep.brand || '-'}</p>\n                            </div>\n                          </div>\n                          <div className=\"flex items-center gap-2\">\n                            <span className=\"text-sm text-bambu-gray\">{formatWeight(rep.label_weight)}</span>\n                            <span className=\"text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full\">\n                              {t('inventory.groupedSpools', { count: groupSpools.length })}\n                            </span>\n                          </div>\n                        </div>\n                      </div>\n                      {/* Expanded individual spools */}\n                      {isExpanded && (\n                        <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-2 ml-4\">\n                          {groupSpools.map((spool) => {\n                            const remaining = Math.max(0, spool.label_weight - spool.weight_used);\n                            const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;\n                            const spoolColor = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';\n                            return (\n                              <SpoolCard\n                                key={spool.id}\n                                spool={spool}\n                                remaining={remaining}\n                                pct={pct}\n                                colorStyle={spoolColor}\n                                onClick={() => setFormModal({ spool })}\n                                t={t}\n                              />\n                            );\n                          })}\n                        </div>\n                      )}\n                    </div>\n                  );\n                }\n                const spool = item.spool;\n                const remaining = Math.max(0, spool.label_weight - spool.weight_used);\n                const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;\n                const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';\n                return (\n                  <SpoolCard\n                    key={spool.id}\n                    spool={spool}\n                    remaining={remaining}\n                    pct={pct}\n                    colorStyle={colorStyle}\n                    onClick={() => setFormModal({ spool })}\n                    t={t}\n                  />\n                );\n              })}\n            </div>\n            {/* Pagination for cards */}\n            <PaginationBar\n              pageIndex={safePageIndex}\n              pageSize={pageSize}\n              totalRows={totalDisplayItems}\n              totalPages={totalPages}\n              onPageChange={setPageIndex}\n              onPageSizeChange={handlePageSizeChange}\n              t={t}\n            />\n          </>\n        ) : (\n          <EmptyFilterState\n            hasFilters={hasActiveFilters}\n            onAddSpool={() => setFormModal({ spool: null })}\n            t={t}\n          />\n        )\n      ) : (\n        /* Table view */\n        pagedItems.length > 0 ? (\n          <div className=\"bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary\">\n            <div className=\"overflow-x-auto\">\n              <table className=\"w-full\">\n                <thead>\n                  <tr className=\"border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30\">\n                    {visibleColumns.map((colId) => {\n                      const sortable = !!columnSortValues[colId];\n                      const isActive = sortState?.column === colId;\n                      return (\n                        <th\n                          key={colId}\n                          className={`text-left py-3 px-4 text-xs font-medium uppercase tracking-wide select-none ${colId === 'remaining' ? 'min-w-[150px]' : ''} ${\n                            sortable ? 'cursor-pointer hover:text-bambu-green transition-colors' : ''\n                          } ${isActive ? 'text-bambu-green' : 'text-bambu-gray'}`}\n                          onClick={sortable ? () => handleSort(colId) : undefined}\n                        >\n                          <span className=\"inline-flex items-center gap-1\">\n                            {columnHeaders[colId]?.(t) ?? colId}\n                            {sortable && (\n                              isActive\n                                ? sortState.direction === 'asc'\n                                  ? <ArrowUp className=\"w-3 h-3\" />\n                                  : <ArrowDown className=\"w-3 h-3\" />\n                                : <ArrowUpDown className=\"w-3 h-3 opacity-30\" />\n                            )}\n                          </span>\n                        </th>\n                      );\n                    })}\n                    <th className=\"text-right py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide\">{t('common.actions')}</th>\n                  </tr>\n                </thead>\n                <tbody>\n                  {pagedItems.map((item) => {\n                    if (item.type === 'group') {\n                      const { key, spools: groupSpools, representative: rep } = item;\n                      const isExpanded = expandedGroups.has(key);\n                      const remaining = Math.max(0, rep.label_weight - rep.weight_used);\n                      const pct = rep.label_weight > 0 ? (remaining / rep.label_weight) * 100 : 0;\n                      return (\n                        <SpoolTableGroup\n                          key={`group-${key}`}\n                          spools={groupSpools}\n                          representative={rep}\n                          remaining={remaining}\n                          pct={pct}\n                          isExpanded={isExpanded}\n                          onToggle={() => toggleGroupExpand(key)}\n                          onEdit={(s) => setFormModal({ spool: s })}\n                          onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}\n                          onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}\n                          visibleColumns={visibleColumns}\n                          assignmentMap={assignmentMap}\n                          catalogMap={catalogMap}\n                          currencySymbol={currencySymbol}\n                          dateFormat={dateFormat}\n                          t={t}\n                          onSyncWeight={handleSyncWeight}\n                        />\n                      );\n                    }\n                    const spool = item.spool;\n                    const remaining = Math.max(0, spool.label_weight - spool.weight_used);\n                    const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;\n                    return (\n                      <SpoolTableRow\n                        key={spool.id}\n                        spool={spool}\n                        remaining={remaining}\n                        pct={pct}\n                        onEdit={() => setFormModal({ spool })}\n                        onRestore={() => restoreMutation.mutate(spool.id)}\n                        onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}\n                        onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}\n                        visibleColumns={visibleColumns}\n                        assignmentMap={assignmentMap}\n                        catalogMap={catalogMap}\n                        currencySymbol={currencySymbol}\n                        dateFormat={dateFormat}\n                        t={t}\n                        onSyncWeight={handleSyncWeight}\n                      />\n                    );\n                  })}\n                </tbody>\n              </table>\n            </div>\n\n            {/* Pagination inside card footer */}\n            <div className=\"flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm\">\n              <span className=\"text-bambu-gray\">\n                {showAll\n                  ? `${totalDisplayItems} ${totalDisplayItems !== 1 ? t('inventory.spools') : t('inventory.spool')}`\n                  : <>{t('inventory.showing')} {safePageIndex * effectivePageSize + 1} {t('inventory.to')}{' '}\n                    {Math.min((safePageIndex + 1) * effectivePageSize, totalDisplayItems)}{' '}\n                    {t('inventory.of')} {totalDisplayItems} {t('inventory.spools')}</>\n                }\n              </span>\n\n              <div className=\"flex items-center gap-2\">\n                <span className=\"text-bambu-gray\">{t('inventory.show')}</span>\n                <select\n                  value={pageSize}\n                  onChange={(e) => handlePageSizeChange(Number(e.target.value))}\n                  className=\"px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green\"\n                >\n                  {[15, 30, 50, 100].map((n) => (\n                    <option key={n} value={n}>{n}</option>\n                  ))}\n                  <option value={-1}>{t('inventory.all')}</option>\n                </select>\n\n                {!showAll && (\n                  <>\n                    <button\n                      onClick={() => setPageIndex(0)}\n                      disabled={safePageIndex === 0}\n                      className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n                      title=\"First page\"\n                    >\n                      <ChevronsLeft className=\"w-4 h-4\" />\n                    </button>\n                    <button\n                      onClick={() => setPageIndex((p) => Math.max(0, p - 1))}\n                      disabled={safePageIndex === 0}\n                      className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n                    >\n                      <ChevronLeft className=\"w-4 h-4\" />\n                    </button>\n                    <span className=\"text-bambu-gray px-2 whitespace-nowrap\">\n                      {t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages}\n                    </span>\n                    <button\n                      onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}\n                      disabled={safePageIndex >= totalPages - 1}\n                      className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n                    >\n                      <ChevronRight className=\"w-4 h-4\" />\n                    </button>\n                    <button\n                      onClick={() => setPageIndex(totalPages - 1)}\n                      disabled={safePageIndex >= totalPages - 1}\n                      className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n                      title=\"Last page\"\n                    >\n                      <ChevronsRight className=\"w-4 h-4\" />\n                    </button>\n                  </>\n                )}\n              </div>\n            </div>\n          </div>\n        ) : (\n          <EmptyFilterState\n            hasFilters={hasActiveFilters}\n            onAddSpool={() => setFormModal({ spool: null })}\n            t={t}\n          />\n        )\n      )}\n\n      {/* Spool Form Modal */}\n      {formModal !== null && (\n        <SpoolFormModal\n          isOpen={true}\n          onClose={() => setFormModal(null)}\n          spool={formModal.spool}\n          currencySymbol={currencySymbol}\n        />\n      )}\n\n      {/* Confirm Modal (delete / archive) */}\n      {confirmAction && (\n        <ConfirmModal\n          title={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}\n          message={confirmAction.type === 'delete' ? t('inventory.deleteConfirm') : t('inventory.archiveConfirm')}\n          confirmText={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}\n          variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}\n          onConfirm={() => {\n            if (confirmAction.type === 'delete') {\n              deleteMutation.mutate(confirmAction.spoolId);\n            } else {\n              archiveMutation.mutate(confirmAction.spoolId);\n            }\n            setConfirmAction(null);\n          }}\n          onCancel={() => setConfirmAction(null)}\n        />\n      )}\n\n      {/* Column Config Modal */}\n      <ColumnConfigModal\n        isOpen={showColumnModal}\n        onClose={() => setShowColumnModal(false)}\n        columns={columnConfig}\n        defaultColumns={DEFAULT_COLUMNS}\n        onSave={handleColumnConfigSave}\n      />\n    </div>\n  );\n}\n\n/* Pagination bar (reused for cards view) */\nfunction PaginationBar({\n  pageIndex, pageSize, totalRows, totalPages, onPageChange, onPageSizeChange, t,\n}: {\n  pageIndex: number;\n  pageSize: number;\n  totalRows: number;\n  totalPages: number;\n  onPageChange: (page: number) => void;\n  onPageSizeChange: (size: number) => void;\n  t: (key: string) => string;\n}) {\n  const isShowAll = pageSize === -1;\n  if (totalPages <= 1 && !isShowAll) return null;\n  const effectiveSize = isShowAll ? totalRows || 1 : pageSize;\n  return (\n    <div className=\"flex items-center justify-between pt-2 text-sm\">\n      <span className=\"text-bambu-gray\">\n        {isShowAll\n          ? `${totalRows} ${totalRows !== 1 ? t('inventory.spools') : t('inventory.spool')}`\n          : <>{t('inventory.showing')} {pageIndex * effectiveSize + 1} {t('inventory.to')}{' '}\n              {Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '}\n              {t('inventory.of')} {totalRows} {t('inventory.spools')}</>\n        }\n      </span>\n      <div className=\"flex items-center gap-2\">\n        <span className=\"text-bambu-gray\">{t('inventory.show')}</span>\n        <select\n          value={pageSize}\n          onChange={(e) => onPageSizeChange(Number(e.target.value))}\n          className=\"px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green\"\n        >\n          {[15, 30, 50, 100].map((n) => (\n            <option key={n} value={n}>{n}</option>\n          ))}\n          <option value={-1}>{t('inventory.all')}</option>\n        </select>\n        {!isShowAll && (\n          <>\n            <button\n              onClick={() => onPageChange(0)}\n              disabled={pageIndex === 0}\n              className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n            >\n              <ChevronsLeft className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={() => onPageChange(Math.max(0, pageIndex - 1))}\n              disabled={pageIndex === 0}\n              className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n            >\n              <ChevronLeft className=\"w-4 h-4\" />\n            </button>\n            <span className=\"text-bambu-gray px-2 whitespace-nowrap\">\n              {t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages}\n            </span>\n            <button\n              onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}\n              disabled={pageIndex >= totalPages - 1}\n              className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n            >\n              <ChevronRight className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={() => onPageChange(totalPages - 1)}\n              disabled={pageIndex >= totalPages - 1}\n              className=\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\"\n            >\n              <ChevronsRight className=\"w-4 h-4\" />\n            </button>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/* Spool card for cards view */\nfunction SpoolCard({\n  spool, remaining, pct, colorStyle, onClick, t,\n}: {\n  spool: InventorySpool;\n  remaining: number;\n  pct: number;\n  colorStyle: string;\n  onClick: () => void;\n  t: (key: string, opts?: Record<string, unknown>) => string;\n}) {\n  return (\n    <div\n      className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}\n      onClick={onClick}\n    >\n      <div className=\"h-14 flex items-center justify-center\" style={{ backgroundColor: colorStyle }}>\n        <span className=\"bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium\">\n          {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}\n        </span>\n      </div>\n      <div className=\"p-4 space-y-3\">\n        <div className=\"flex items-start justify-between gap-2\">\n          <div>\n            <h3 className=\"font-semibold text-white\">\n              {spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}\n            </h3>\n            <p className=\"text-sm text-bambu-gray\">{spool.brand || '-'}</p>\n          </div>\n          <span className=\"text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded\">\n            #{spool.id}\n          </span>\n        </div>\n        <div>\n          <div className=\"flex justify-between text-xs text-bambu-gray mb-1\">\n            <span>{t('inventory.remaining')}</span>\n            <span>{Math.round(pct)}%</span>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <div className=\"flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden\">\n              <div\n                className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}\n                style={{ width: `${Math.min(pct, 100)}%` }}\n              />\n            </div>\n            <span className=\"text-xs text-bambu-gray min-w-[40px] text-right\">\n              {Math.round(remaining)}g\n            </span>\n          </div>\n        </div>\n        <div className=\"grid grid-cols-2 gap-2 text-xs\">\n          <div>\n            <span className=\"text-bambu-gray/60\">{t('inventory.labelWeight')}: </span>\n            <span className=\"text-bambu-gray\">{formatWeight(spool.label_weight)}</span>\n          </div>\n          <div>\n            <span className=\"text-bambu-gray/60\">{t('inventory.weightUsed')}: </span>\n            <span className=\"text-bambu-gray\">\n              {spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}\n            </span>\n          </div>\n        </div>\n        {spool.note && (\n          <div\n            className=\"text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate\"\n            title={spool.note}\n          >\n            {spool.note}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/* Single spool row for table view */\nfunction SpoolTableRow({\n  spool, remaining, pct, onEdit, onRestore, onArchive, onDelete,\n  visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,\n}: {\n  spool: InventorySpool;\n  remaining: number;\n  pct: number;\n  onEdit: () => void;\n  onRestore: () => void;\n  onArchive: () => void;\n  onDelete: () => void;\n  visibleColumns: string[];\n  assignmentMap: Record<number, SpoolAssignment>;\n  catalogMap: Record<number, SpoolCatalogEntry>;\n  currencySymbol: string;\n  dateFormat: DateFormat;\n  t: TFn;\n  onSyncWeight?: (spool: InventorySpool) => void;\n}) {\n  return (\n    <tr\n      className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${\n        spool.archived_at ? 'opacity-50' : ''\n      }`}\n      onClick={onEdit}\n    >\n      {visibleColumns.map((colId) => (\n        <td key={colId} className=\"py-3 px-4\">\n          {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}\n        </td>\n      ))}\n      <td className=\"py-3 px-4\">\n        <div className=\"flex items-center justify-end gap-1\" onClick={(e) => e.stopPropagation()}>\n          <button onClick={onEdit} className=\"p-1.5 text-bambu-gray hover:text-white rounded transition-colors\" title={t('common.edit')}>\n            <Edit2 className=\"w-4 h-4\" />\n          </button>\n          {spool.archived_at ? (\n            <button onClick={onRestore} className=\"p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors\" title={t('inventory.restore')}>\n              <RotateCcw className=\"w-4 h-4\" />\n            </button>\n          ) : (\n            <button onClick={onArchive} className=\"p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors\" title={t('inventory.archive')}>\n              <Archive className=\"w-4 h-4\" />\n            </button>\n          )}\n          <button onClick={onDelete} className=\"p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors\" title={t('common.delete')}>\n            <Trash2 className=\"w-4 h-4\" />\n          </button>\n        </div>\n      </td>\n    </tr>\n  );\n}\n\n/* Grouped spool rows for table view */\nfunction SpoolTableGroup({\n  spools, representative, remaining, pct, isExpanded, onToggle,\n  onEdit, onArchive, onDelete,\n  visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,\n}: {\n  spools: InventorySpool[];\n  representative: InventorySpool;\n  remaining: number;\n  pct: number;\n  isExpanded: boolean;\n  onToggle: () => void;\n  onEdit: (spool: InventorySpool) => void;\n  onArchive: (id: number) => void;\n  onDelete: (id: number) => void;\n  visibleColumns: string[];\n  assignmentMap: Record<number, SpoolAssignment>;\n  catalogMap: Record<number, SpoolCatalogEntry>;\n  currencySymbol: string;\n  dateFormat: DateFormat;\n  t: TFn;\n  onSyncWeight?: (spool: InventorySpool) => void;\n}) {\n  return (\n    <>\n      {/* Group header row */}\n      <tr\n        className=\"border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5\"\n        onClick={onToggle}\n      >\n        {visibleColumns.map((colId, idx) => (\n          <td key={colId} className=\"py-3 px-4\">\n            {idx === 0 ? (\n              <div className=\"flex items-center gap-2\">\n                <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />\n                {columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}\n              </div>\n            ) : colId === 'id' ? (\n              <span className=\"text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full\">\n                {t('inventory.groupedSpools', { count: spools.length })}\n              </span>\n            ) : (\n              columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })\n            )}\n          </td>\n        ))}\n        <td className=\"py-3 px-4\">\n          <span className=\"text-xs text-bambu-gray\">\n            {spools.map((s) => `#${s.id}`).join(', ')}\n          </span>\n        </td>\n      </tr>\n      {/* Expanded individual rows */}\n      {isExpanded && spools.map((spool) => {\n        const r = Math.max(0, spool.label_weight - spool.weight_used);\n        const p = spool.label_weight > 0 ? (r / spool.label_weight) * 100 : 0;\n        return (\n          <SpoolTableRow\n            key={spool.id}\n            spool={spool}\n            remaining={r}\n            pct={p}\n            onEdit={() => onEdit(spool)}\n            onRestore={() => {}}\n            onArchive={() => onArchive(spool.id)}\n            onDelete={() => onDelete(spool.id)}\n            visibleColumns={visibleColumns}\n            assignmentMap={assignmentMap}\n            catalogMap={catalogMap}\n            currencySymbol={currencySymbol}\n            dateFormat={dateFormat}\n            t={t}\n            onSyncWeight={onSyncWeight}\n          />\n        );\n      })}\n    </>\n  );\n}\n\n/* Empty state matching SpoolBuddy's design */\nfunction EmptyFilterState({\n  hasFilters,\n  onAddSpool,\n  t,\n}: {\n  hasFilters: boolean;\n  onAddSpool: () => void;\n  t: (key: string) => string;\n}) {\n  return (\n    <div className=\"flex flex-col items-center justify-center py-16 px-4\">\n      <div className=\"relative mb-6\">\n        <div className=\"absolute inset-0 -m-4 bg-bambu-green/5 rounded-full blur-2xl\" />\n        <div className=\"relative flex items-center justify-center w-24 h-24 rounded-2xl bg-gradient-to-br from-bambu-dark-secondary to-bambu-dark-tertiary border border-bambu-dark-tertiary shadow-lg\">\n          <div className=\"absolute -top-1 -right-1 w-3 h-3 rounded-full bg-bambu-green/30\" />\n          <div className=\"absolute -bottom-2 -left-2 w-2 h-2 rounded-full bg-bambu-green/20\" />\n          {hasFilters ? (\n            <Search className=\"w-10 h-10 text-bambu-gray/40\" strokeWidth={1.5} />\n          ) : (\n            <div className=\"relative\">\n              <div className=\"w-14 h-14 rounded-full border-4 border-bambu-gray/20 flex items-center justify-center\">\n                <div className=\"w-6 h-6 rounded-full bg-bambu-gray/10 border-2 border-bambu-gray/20\" />\n              </div>\n              <div className=\"absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-bambu-green flex items-center justify-center shadow-md\">\n                <span className=\"text-white text-lg font-bold leading-none\">+</span>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n      <h3 className=\"text-lg font-semibold text-white mb-2 text-center\">\n        {hasFilters ? t('inventory.noSpoolsMatch') : t('inventory.noSpools').split('.')[0]}\n      </h3>\n      <p className=\"text-sm text-bambu-gray text-center max-w-sm mb-6\">\n        {hasFilters\n          ? t('inventory.noSpoolsMatchDesc')\n          : t('inventory.noSpools')\n        }\n      </p>\n      {!hasFilters && (\n        <Button onClick={onAddSpool}>\n          <Package className=\"w-4 h-4\" />\n          {t('inventory.addSpool')}\n        </Button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/LoginPage.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport { useNavigate, useSearchParams } from 'react-router-dom';\nimport { useMutation, useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { useAuth } from '../contexts/AuthContext';\nimport { useToast } from '../contexts/ToastContext';\nimport { useTheme } from '../contexts/ThemeContext';\nimport { X, Mail, Shield, Smartphone, Key } from 'lucide-react';\nimport { api, type LoginResponse } from '../api/client';\nimport { Card, CardHeader, CardContent } from '../components/Card';\nimport { Button } from '../components/Button';\n\ntype LoginStep = 'credentials' | '2fa' | 'reset-password';\n\nexport function LoginPage() {\n  const navigate = useNavigate();\n  const [searchParams] = useSearchParams();\n  const { t } = useTranslation();\n  const { login, loginWithToken } = useAuth();\n  const { showToast } = useToast();\n  const { mode } = useTheme();\n\n  // Credentials step state\n  const [username, setUsername] = useState('');\n  const [password, setPassword] = useState('');\n  const [showForgotPassword, setShowForgotPassword] = useState(false);\n  const [forgotEmail, setForgotEmail] = useState('');\n\n  // 2FA step state\n  const [step, setStep] = useState<LoginStep>('credentials');\n  const [preAuthToken, setPreAuthToken] = useState('');\n  const [twoFAMethods, setTwoFAMethods] = useState<string[]>([]);\n  const [twoFAMethod, setTwoFAMethod] = useState<'totp' | 'email' | 'backup'>('totp');\n  const [twoFACode, setTwoFACode] = useState('');\n  const [emailOTPSent, setEmailOTPSent] = useState(false);\n  const twoFAInputRef = useRef<HTMLInputElement>(null);\n\n  // H-6: Password reset step state\n  const [resetToken, setResetToken] = useState('');\n  const [newPassword, setNewPassword] = useState('');\n  const [confirmPassword, setConfirmPassword] = useState('');\n\n  // Check if advanced auth is enabled\n  const { data: advancedAuthStatus } = useQuery({\n    queryKey: ['advancedAuthStatus'],\n    queryFn: () => api.getAdvancedAuthStatus(),\n  });\n\n  // Fetch enabled OIDC providers for login buttons\n  const { data: oidcProviders } = useQuery({\n    queryKey: ['oidcProviders'],\n    queryFn: () => api.getOIDCProviders(),\n  });\n\n  // M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.\n  // Fragments are never sent to the server so the token never appears in access-logs\n  // or Referer headers — mirrors the H-4 treatment of the OIDC token.\n  useEffect(() => {\n    const hash = window.location.hash;\n    const token = hash.startsWith('#reset_token=') ? hash.slice('#reset_token='.length) : null;\n    if (token) {\n      setResetToken(token);\n      setStep('reset-password');\n      // Clear the fragment from the URL so it can't be bookmarked or re-triggered.\n      navigate('/login', { replace: true });\n    }\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // Handle OIDC callback: if #oidc_token=... is present in the fragment, exchange it.\n  // H-4: Read from the URL fragment (#) — fragments are never sent to the server\n  // so the exchange token stays out of access logs and Referer headers.\n  useEffect(() => {\n    const hash = window.location.hash;\n    const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;\n    const oidcError = searchParams.get('oidc_error');\n\n    if (oidcError) {\n      // L-3: Whitelist known OIDC error codes so provider-controlled text is never\n      // shown verbatim. Any unknown code falls back to a generic message.\n      const KNOWN_OIDC_ERRORS: Record<string, string> = {\n        oidc_provider_error: t('login.oidcErrors.providerError'),\n        missing_parameters: t('login.oidcErrors.missingParameters'),\n        invalid_state: t('login.oidcErrors.invalidState'),\n        state_expired: t('login.oidcErrors.stateExpired'),\n        provider_not_found: t('login.oidcErrors.providerNotFound'),\n        discovery_failed: t('login.oidcErrors.discoveryFailed'),\n        invalid_discovery_document: t('login.oidcErrors.invalidDiscovery'),\n        token_exchange_network_error: t('login.oidcErrors.networkError'),\n        token_exchange_bad_response: t('login.oidcErrors.badResponse'),\n        no_id_token: t('login.oidcErrors.noIdToken'),\n        token_validation_failed: t('login.oidcErrors.validationFailed'),\n        nonce_mismatch: t('login.oidcErrors.nonceMismatch'),\n        missing_sub_claim: t('login.oidcErrors.missingSubClaim'),\n        no_linked_account: t('login.oidcErrors.noLinkedAccount'),\n        account_inactive: t('login.oidcErrors.accountInactive'),\n        user_resolution_failed: t('login.oidcErrors.userResolutionFailed'),\n        internal_error: t('login.oidcErrors.internalError'),\n      };\n      // Dynamic codes like \"token_exchange_<provider_code>\" → generic message\n      const errorMsg = KNOWN_OIDC_ERRORS[oidcError]\n        ?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed'));\n      showToast(errorMsg, 'error');\n      // Remove query params from URL cleanly\n      navigate('/login', { replace: true });\n      return;\n    }\n\n    if (oidcToken) {\n      api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {\n        if (resp.requires_2fa && resp.pre_auth_token) {\n          // OIDC user has 2FA enabled — redirect to 2FA step\n          setPreAuthToken(resp.pre_auth_token);\n          const methods = resp.two_fa_methods ?? [];\n          setTwoFAMethods(methods);\n          if (methods.includes('totp')) setTwoFAMethod('totp');\n          else if (methods.includes('email')) setTwoFAMethod('email');\n          else setTwoFAMethod('backup');\n          setStep('2fa');\n          // Remove oidc_token from URL so page refresh doesn't re-trigger exchange\n          navigate('/login', { replace: true });\n        } else if (resp.access_token && resp.user) {\n          loginWithToken(resp.access_token, resp.user);\n          showToast(t('login.loginSuccess'));\n          navigate('/', { replace: true });\n        }\n      }).catch((err: Error) => {\n        showToast(err.message || t('login.oidcLoginFailed'), 'error');\n        navigate('/login', { replace: true });\n      });\n    }\n  }, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // --- Step 1: Credentials login ---\n  const loginMutation = useMutation({\n    mutationFn: () => login(username, password),\n    onSuccess: (resp: LoginResponse) => {\n      if (resp.requires_2fa && resp.pre_auth_token) {\n        // 2FA required — switch to verification step\n        setPreAuthToken(resp.pre_auth_token);\n        const methods = resp.two_fa_methods ?? [];\n        setTwoFAMethods(methods);\n        // Pick a sensible default method\n        if (methods.includes('totp')) setTwoFAMethod('totp');\n        else if (methods.includes('email')) setTwoFAMethod('email');\n        else setTwoFAMethod('backup');\n        setStep('2fa');\n      } else if (resp.access_token && resp.user) {\n        showToast(t('login.loginSuccess'));\n        navigate('/');\n      }\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('login.loginFailed'), 'error');\n    },\n  });\n\n  const forgotPasswordMutation = useMutation({\n    mutationFn: (email: string) => api.forgotPassword({ email }),\n    onSuccess: (data) => {\n      showToast(data.message, 'success');\n      setShowForgotPassword(false);\n      setForgotEmail('');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // H-6: Mutation to set a new password using the reset token from the email link\n  const resetPasswordMutation = useMutation({\n    mutationFn: () => api.forgotPasswordConfirm(resetToken, newPassword),\n    onSuccess: (data) => {\n      showToast(data.message, 'success');\n      setStep('credentials');\n      setResetToken('');\n      setNewPassword('');\n      setConfirmPassword('');\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('login.resetPassword.resetFailed'), 'error');\n    },\n  });\n\n  // --- Step 2: 2FA verification ---\n  const sendEmailOTPMutation = useMutation({\n    mutationFn: () => api.sendEmailOTP(preAuthToken),\n    onSuccess: (data: { message: string; pre_auth_token?: string }) => {\n      setEmailOTPSent(true);\n      // Backend issues a fresh pre-auth token after consuming the original one\n      if (data.pre_auth_token) setPreAuthToken(data.pre_auth_token);\n      showToast(data.message, 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('login.twoFA.sendCodeFailed'), 'error');\n    },\n  });\n\n  const verify2FAMutation = useMutation({\n    mutationFn: () =>\n      api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }),\n    onSuccess: (resp: LoginResponse) => {\n      if (resp.access_token && resp.user) {\n        loginWithToken(resp.access_token, resp.user);\n        showToast(t('login.loginSuccess'));\n        navigate('/');\n      }\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('login.twoFA.invalidCode'), 'error');\n      setTwoFACode('');\n    },\n  });\n\n  // OIDC login\n  const oidcLoginMutation = useMutation({\n    mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),\n    onSuccess: (data) => {\n      window.location.href = data.auth_url;\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('login.oidcLoginFailed'), 'error');\n    },\n  });\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!username || !password) {\n      showToast(t('login.enterCredentials'), 'error');\n      return;\n    }\n    loginMutation.mutate();\n  };\n\n  const handle2FASubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!twoFACode.trim()) {\n      showToast(t('login.twoFA.enterCode'), 'error');\n      return;\n    }\n    verify2FAMutation.mutate();\n  };\n\n  const handleForgotPassword = (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!forgotEmail) {\n      showToast(t('login.enterEmail'), 'error');\n      return;\n    }\n    forgotPasswordMutation.mutate(forgotEmail);\n  };\n\n  const handleMethodChange = (method: 'totp' | 'email' | 'backup') => {\n    setTwoFAMethod(method);\n    setTwoFACode('');\n    setEmailOTPSent(false);\n    // Re-focus the code input after method switch (autoFocus only fires on mount)\n    setTimeout(() => twoFAInputRef.current?.focus(), 0);\n  };\n\n  // ---- Render: password-reset step (H-6) ----\n  if (step === 'reset-password') {\n    const handleResetSubmit = (e: React.FormEvent) => {\n      e.preventDefault();\n      if (newPassword !== confirmPassword) {\n        showToast(t('login.resetPassword.passwordsDoNotMatch'), 'error');\n        return;\n      }\n      if (newPassword.length < 8) {\n        showToast(t('login.resetPassword.passwordTooShort'), 'error');\n        return;\n      }\n      resetPasswordMutation.mutate();\n    };\n\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-bambu-dark p-4\">\n        <div className=\"max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg\">\n          <div className=\"text-center\">\n            <div className=\"flex items-center justify-center mb-4\">\n              <div className=\"w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center\">\n                <Key className=\"w-7 h-7 text-bambu-green\" />\n              </div>\n            </div>\n            <h2 className=\"text-2xl font-bold text-white\">{t('login.resetPassword.title')}</h2>\n            <p className=\"mt-2 text-sm text-bambu-gray\">{t('login.resetPassword.subtitle')}</p>\n          </div>\n\n          <form onSubmit={handleResetSubmit} className=\"space-y-4\">\n            <div>\n              <label htmlFor=\"new-password\" className=\"block text-sm font-medium text-white mb-2\">\n                {t('login.resetPassword.newPassword')}\n              </label>\n              <input\n                id=\"new-password\"\n                type=\"password\"\n                required\n                value={newPassword}\n                onChange={(e) => setNewPassword(e.target.value)}\n                className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                placeholder={t('login.resetPassword.newPasswordPlaceholder')}\n                autoFocus\n                autoComplete=\"new-password\"\n                minLength={8}\n              />\n            </div>\n\n            <div>\n              <label htmlFor=\"confirm-password\" className=\"block text-sm font-medium text-white mb-2\">\n                {t('login.resetPassword.confirmPassword')}\n              </label>\n              <input\n                id=\"confirm-password\"\n                type=\"password\"\n                required\n                value={confirmPassword}\n                onChange={(e) => setConfirmPassword(e.target.value)}\n                className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                placeholder={t('login.resetPassword.confirmPasswordPlaceholder')}\n                autoComplete=\"new-password\"\n              />\n            </div>\n\n            <button\n              type=\"submit\"\n              disabled={resetPasswordMutation.isPending || !newPassword || !confirmPassword}\n              className=\"w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n              {resetPasswordMutation.isPending ? t('login.resetPassword.saving') : t('login.resetPassword.submit')}\n            </button>\n          </form>\n\n          <div className=\"text-center\">\n            <button\n              type=\"button\"\n              onClick={() => {\n                setStep('credentials');\n                setResetToken('');\n                setNewPassword('');\n                setConfirmPassword('');\n              }}\n              className=\"text-sm text-bambu-gray hover:text-bambu-green transition-colors\"\n            >\n              {t('login.resetPassword.backToLogin')}\n            </button>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  // ---- Render: 2FA step ----\n  if (step === '2fa') {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-bambu-dark p-4\">\n        <div className=\"max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg\">\n          <div className=\"text-center\">\n            <div className=\"flex items-center justify-center mb-4\">\n              <div className=\"w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center\">\n                <Shield className=\"w-7 h-7 text-bambu-green\" />\n              </div>\n            </div>\n            <h2 className=\"text-2xl font-bold text-white\">{t('login.twoFA.title')}</h2>\n            <p className=\"mt-2 text-sm text-bambu-gray\">{t('login.twoFA.subtitle')}</p>\n          </div>\n\n          {/* Method selector — only show if multiple methods available */}\n          {twoFAMethods.length > 1 && (\n            <div className=\"flex gap-2\">\n              {twoFAMethods.includes('totp') && (\n                <button\n                  type=\"button\"\n                  onClick={() => handleMethodChange('totp')}\n                  className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${\n                    twoFAMethod === 'totp'\n                      ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'\n                      : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'\n                  }`}\n                >\n                  <Smartphone className=\"w-4 h-4\" />\n                  {t('login.twoFA.methodAuthenticator')}\n                </button>\n              )}\n              {twoFAMethods.includes('email') && (\n                <button\n                  type=\"button\"\n                  onClick={() => handleMethodChange('email')}\n                  className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${\n                    twoFAMethod === 'email'\n                      ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'\n                      : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'\n                  }`}\n                >\n                  <Mail className=\"w-4 h-4\" />\n                  {t('login.twoFA.methodEmail')}\n                </button>\n              )}\n              {twoFAMethods.includes('backup') && (\n                <button\n                  type=\"button\"\n                  onClick={() => handleMethodChange('backup')}\n                  className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${\n                    twoFAMethod === 'backup'\n                      ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'\n                      : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'\n                  }`}\n                >\n                  <Key className=\"w-4 h-4\" />\n                  {t('login.twoFA.methodBackup')}\n                </button>\n              )}\n            </div>\n          )}\n\n          <form onSubmit={handle2FASubmit} className=\"space-y-4\">\n            {/* Method-specific instructions */}\n            {twoFAMethod === 'totp' && (\n              <p className=\"text-sm text-bambu-gray\">{t('login.twoFA.instructionsTotp')}</p>\n            )}\n            {twoFAMethod === 'email' && (\n              <div className=\"space-y-3\">\n                <p className=\"text-sm text-bambu-gray\">\n                  {emailOTPSent\n                    ? t('login.twoFA.instructionsEmail')\n                    : t('login.twoFA.instructionsEmailNotSent')}\n                </p>\n                {!emailOTPSent && (\n                  <Button\n                    type=\"button\"\n                    variant=\"secondary\"\n                    className=\"w-full\"\n                    onClick={() => sendEmailOTPMutation.mutate()}\n                    disabled={sendEmailOTPMutation.isPending}\n                  >\n                    {sendEmailOTPMutation.isPending\n                      ? t('login.twoFA.sendingCode')\n                      : t('login.twoFA.sendCodeButton')}\n                  </Button>\n                )}\n                {emailOTPSent && (\n                  <button\n                    type=\"button\"\n                    onClick={() => { setEmailOTPSent(false); sendEmailOTPMutation.mutate(); }}\n                    className=\"text-xs text-bambu-gray hover:text-bambu-green transition-colors\"\n                  >\n                    {t('login.twoFA.resendCode')}\n                  </button>\n                )}\n              </div>\n            )}\n            {twoFAMethod === 'backup' && (\n              <p className=\"text-sm text-bambu-gray\">{t('login.twoFA.instructionsBackup')}</p>\n            )}\n\n            <div>\n              <label htmlFor=\"twofa-code\" className=\"block text-sm font-medium text-white mb-2\">\n                {twoFAMethod === 'backup'\n                  ? t('login.twoFA.backupCodeLabel')\n                  : t('login.twoFA.codeLabel')}\n              </label>\n              <input\n                ref={twoFAInputRef}\n                id=\"twofa-code\"\n                type=\"text\"\n                inputMode={twoFAMethod === 'backup' ? 'text' : 'numeric'}\n                autoComplete=\"one-time-code\"\n                value={twoFACode}\n                onChange={(e) => setTwoFACode(e.target.value.trim())}\n                disabled={twoFAMethod === 'email' && !emailOTPSent}\n                className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-center tracking-widest text-xl font-mono focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-40\"\n                placeholder={twoFAMethod === 'backup'\n                  ? t('login.twoFA.backupCodePlaceholder')\n                  : t('login.twoFA.codePlaceholder')}\n                maxLength={twoFAMethod === 'backup' ? 8 : 6}\n                autoFocus\n              />\n            </div>\n\n            <button\n              type=\"submit\"\n              disabled={\n                verify2FAMutation.isPending ||\n                !twoFACode.trim() ||\n                (twoFAMethod === 'email' && !emailOTPSent)\n              }\n              className=\"w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed\"\n            >\n              {verify2FAMutation.isPending\n                ? t('login.twoFA.verifyingButton')\n                : t('login.twoFA.verifyButton')}\n            </button>\n          </form>\n\n          <div className=\"text-center\">\n            <button\n              type=\"button\"\n              onClick={() => {\n                setStep('credentials');\n                setPreAuthToken('');\n                setTwoFACode('');\n                setEmailOTPSent(false);\n              }}\n              className=\"text-sm text-bambu-gray hover:text-bambu-green transition-colors\"\n            >\n              {t('login.twoFA.backToLogin')}\n            </button>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  // ---- Render: credentials step ----\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-bambu-dark p-4\">\n      <div className=\"max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg\">\n        <div className=\"text-center\">\n          <div className=\"flex items-center justify-center mb-6\">\n            <img\n              src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}\n              alt=\"Bambuddy\"\n              className=\"h-16\"\n            />\n          </div>\n          <h2 className=\"text-3xl font-bold text-white\">\n            {t('login.title')}\n          </h2>\n          <p className=\"mt-2 text-sm text-bambu-gray\">\n            {t('login.subtitle')}\n          </p>\n        </div>\n\n        <form className=\"mt-8 space-y-6\" onSubmit={handleSubmit}>\n          <div className=\"space-y-4\">\n            <div>\n              <label htmlFor=\"username\" className=\"block text-sm font-medium text-white mb-2\">\n                {advancedAuthStatus?.advanced_auth_enabled\n                  ? t('login.usernameOrEmail')\n                  : t('login.username')}\n              </label>\n              <input\n                id=\"username\"\n                type=\"text\"\n                required\n                value={username}\n                onChange={(e) => setUsername(e.target.value)}\n                className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                placeholder={advancedAuthStatus?.advanced_auth_enabled\n                  ? t('login.usernameOrEmailPlaceholder')\n                  : t('login.usernamePlaceholder')}\n                autoComplete=\"username\"\n              />\n            </div>\n\n            <div>\n              <label htmlFor=\"password\" className=\"block text-sm font-medium text-white mb-2\">\n                {t('login.password') || 'Password'}\n              </label>\n              <input\n                id=\"password\"\n                type=\"password\"\n                required\n                value={password}\n                onChange={(e) => setPassword(e.target.value)}\n                className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                placeholder={t('login.passwordPlaceholder')}\n                autoComplete=\"current-password\"\n              />\n            </div>\n          </div>\n\n          <div>\n            <button\n              type=\"submit\"\n              disabled={loginMutation.isPending}\n              className=\"w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green\"\n            >\n              {loginMutation.isPending ? t('login.signingIn') : t('login.signIn')}\n            </button>\n          </div>\n\n          <div className=\"text-center\">\n            <button\n              type=\"button\"\n              onClick={() => setShowForgotPassword(true)}\n              className=\"text-sm text-bambu-gray hover:text-bambu-green transition-colors\"\n            >\n              {t('login.forgotPassword')}\n            </button>\n          </div>\n        </form>\n\n        {/* OIDC provider buttons */}\n        {oidcProviders && oidcProviders.length > 0 && (\n          <div className=\"space-y-3\">\n            <div className=\"relative\">\n              <div className=\"absolute inset-0 flex items-center\">\n                <div className=\"w-full border-t border-bambu-dark-tertiary\" />\n              </div>\n              <div className=\"relative flex justify-center text-sm\">\n                <span className=\"px-2 bg-bambu-dark-secondary text-bambu-gray\">{t('login.twoFA.orContinueWith')}</span>\n              </div>\n            </div>\n\n            <div className=\"space-y-2\">\n              {oidcProviders.map((provider) => (\n                <button\n                  key={provider.id}\n                  type=\"button\"\n                  onClick={() => oidcLoginMutation.mutate(provider.id)}\n                  disabled={oidcLoginMutation.isPending}\n                  className=\"w-full flex items-center justify-center gap-3 py-3 px-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green/50 rounded-lg text-white font-medium transition-colors disabled:opacity-50\"\n                >\n                  {provider.icon_url ? (\n                    <img src={provider.icon_url} alt=\"\" className=\"w-5 h-5 object-contain\" />\n                  ) : (\n                    <Shield className=\"w-5 h-5 text-bambu-green\" />\n                  )}\n                  {t('login.twoFA.signInWith', { provider: provider.name })}\n                </button>\n              ))}\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Forgot Password Modal */}\n      {showForgotPassword && (\n        <div\n          className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n          onClick={() => setShowForgotPassword(false)}\n        >\n          <Card\n            className=\"w-full max-w-md\"\n            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n          >\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <Mail className=\"w-5 h-5 text-bambu-green\" />\n                  <h2 className=\"text-lg font-semibold text-white\">{t('login.forgotPasswordTitle')}</h2>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => {\n                    setShowForgotPassword(false);\n                    setForgotEmail('');\n                  }}\n                >\n                  <X className=\"w-5 h-5\" />\n                </Button>\n              </div>\n            </CardHeader>\n            <CardContent>\n              {advancedAuthStatus?.advanced_auth_enabled ? (\n                <form onSubmit={handleForgotPassword} className=\"space-y-4\">\n                  <p className=\"text-bambu-gray text-sm\">\n                    {t('login.forgotPasswordEmailMessage')}\n                  </p>\n\n                  <div>\n                    <label htmlFor=\"forgot-email\" className=\"block text-sm font-medium text-white mb-2\">\n                      {t('login.emailAddress')}\n                    </label>\n                    <input\n                      id=\"forgot-email\"\n                      type=\"email\"\n                      required\n                      value={forgotEmail}\n                      onChange={(e) => setForgotEmail(e.target.value)}\n                      className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                      placeholder={t('login.emailPlaceholder')}\n                    />\n                  </div>\n\n                  <div className=\"flex gap-2\">\n                    <Button\n                      type=\"button\"\n                      variant=\"secondary\"\n                      className=\"flex-1\"\n                      onClick={() => {\n                        setShowForgotPassword(false);\n                        setForgotEmail('');\n                      }}\n                    >\n                      {t('login.cancel')}\n                    </Button>\n                    <Button\n                      type=\"submit\"\n                      className=\"flex-1\"\n                      disabled={forgotPasswordMutation.isPending}\n                    >\n                      {forgotPasswordMutation.isPending\n                        ? t('login.sending')\n                        : t('login.sendResetEmail')}\n                    </Button>\n                  </div>\n                </form>\n              ) : (\n                <div className=\"space-y-4\">\n                  <p className=\"text-bambu-gray\">\n                    {t('login.forgotPasswordMessage')}\n                  </p>\n\n                  <div className=\"bg-bambu-dark rounded-lg p-4 space-y-2\">\n                    <p className=\"text-sm text-white font-medium\">{t('login.howToReset')}</p>\n                    <ol className=\"text-sm text-bambu-gray space-y-1 list-decimal list-inside\">\n                      <li>{t('login.resetStep1')}</li>\n                      <li>{t('login.resetStep2')}</li>\n                      <li>{t('login.resetStep3')}</li>\n                      <li>{t('login.resetStep4')}</li>\n                    </ol>\n                  </div>\n\n                  <Button\n                    variant=\"secondary\"\n                    className=\"w-full\"\n                    onClick={() => setShowForgotPassword(false)}\n                  >\n                    {t('login.gotIt')}\n                  </Button>\n                </div>\n              )}\n            </CardContent>\n          </Card>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/MaintenancePage.tsx",
    "content": "import { useState, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  Wrench,\n  Loader2,\n  Check,\n  AlertTriangle,\n  Clock,\n  Plus,\n  Trash2,\n  ChevronDown,\n  ChevronUp,\n  Droplet,\n  Flame,\n  Ruler,\n  Sparkles,\n  Square,\n  Cable,\n  Edit3,\n  RotateCcw,\n  Calendar,\n  Timer,\n  Cog,\n  Fan,\n  Zap,\n  Wind,\n  Thermometer,\n  Layers,\n  Box,\n  Target,\n  RefreshCw,\n  Settings,\n  Filter,\n  CircleDot,\n  Printer,\n  ExternalLink,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType, Permission } from '../api/client';\nimport { getMaintenanceWikiUrl } from '../utils/maintenanceWikiUrls';\nimport { Card, CardContent } from '../components/Card';\nimport { Button } from '../components/Button';\nimport { Toggle } from '../components/Toggle';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\n\n// Icon mapping for maintenance types\nconst iconMap: Record<string, React.ComponentType<{ className?: string }>> = {\n  Droplet,\n  Flame,\n  Ruler,\n  Sparkles,\n  Square,\n  Cable,\n  Wrench,\n  Calendar,\n  Timer,\n  Cog,\n  Fan,\n  Zap,\n  Wind,\n  Thermometer,\n  Layers,\n  Box,\n  Target,\n  RefreshCw,\n  Settings,\n  Filter,\n  CircleDot,\n};\n\nfunction getIcon(iconName: string | null) {\n  if (!iconName) return Wrench;\n  return iconMap[iconName] || Wrench;\n}\n\ntype TFunction = (key: string, options?: Record<string, unknown>) => string;\n\nfunction formatDuration(value: number, type: 'hours' | 'days', t?: TFunction): string {\n  if (type === 'days') {\n    if (value < 1) return t ? t('common.today') : 'Today';\n    if (value === 1) return t ? t('maintenance.day') : '1 day';\n    if (value < 7) {\n      const days = Math.round(value);\n      return t ? t('maintenance.days', { count: days }) : `${days} days`;\n    }\n    // Show weeks for anything under 6 months for better precision\n    if (value < 180) {\n      const weeks = Math.round(value / 7);\n      if (weeks === 1) return t ? t('maintenance.week') : '1 week';\n      return t ? t('maintenance.weeks', { count: weeks }) : `${weeks} weeks`;\n    }\n    // 6+ months show as months\n    const months = Math.round(value / 30);\n    if (months === 1) return t ? t('maintenance.month') : '1 month';\n    return t ? t('maintenance.months', { count: months }) : `${months} months`;\n  } else {\n    // Print hours - convert to readable units\n    if (value < 1) return `${Math.round(value * 60)}m`;\n    if (value < 24) return `${value < 10 ? value.toFixed(1) : Math.round(value)}h`;\n    // 24+ hours: show as days of print time\n    const days = value / 24;\n    if (days < 7) return `${days < 2 ? days.toFixed(1) : Math.round(days)}d`;\n    // 7+ days: show as weeks of print time\n    const weeks = days / 7;\n    if (weeks < 12) return `${weeks < 2 ? weeks.toFixed(1) : Math.round(weeks)}w`;\n    // 12+ weeks: show as months of print time\n    return `${Math.round(weeks / 4)}mo`;\n  }\n}\n\nfunction formatIntervalLabel(value: number, type: 'hours' | 'days', t?: TFunction): string {\n  if (type === 'days') {\n    if (value === 1) return t ? t('maintenance.day') : '1 day';\n    if (value === 7) return t ? t('maintenance.week') : '1 week';\n    if (value === 14) return t ? t('maintenance.weeks', { count: 2 }) : '2 weeks';\n    if (value === 30) return t ? t('maintenance.month') : '1 month';\n    if (value === 60) return t ? t('maintenance.months', { count: 2 }) : '2 months';\n    if (value === 90) return t ? t('maintenance.months', { count: 3 }) : '3 months';\n    if (value === 180) return t ? t('maintenance.months', { count: 6 }) : '6 months';\n    if (value === 365) return t ? t('maintenance.year') : '1 year';\n    return t ? t('maintenance.days', { count: value }) : `${value} days`;\n  }\n  return `${value}h`;\n}\n\n\n// Maintenance item card - cleaner, more visual design\nfunction MaintenanceCard({\n  item,\n  onPerform,\n  onToggle,\n  hasPermission,\n  t,\n}: {\n  item: MaintenanceStatus;\n  onPerform: (id: number) => void;\n  onToggle: (id: number, enabled: boolean) => void;\n  hasPermission: (permission: Permission) => boolean;\n  t: TFunction;\n}) {\n  const Icon = getIcon(item.maintenance_type_icon);\n  const intervalType = item.interval_type || 'hours';\n\n  // Calculate progress based on interval type\n  const getProgress = () => {\n    if (intervalType === 'days') {\n      const daysSince = item.days_since_maintenance ?? 0;\n      return Math.max(0, Math.min(100, (daysSince / item.interval_hours) * 100));\n    }\n    return Math.max(0, Math.min(100,\n      ((item.interval_hours - item.hours_until_due) / item.interval_hours) * 100\n    ));\n  };\n\n  const progressPercent = getProgress();\n\n  const getStatusColor = () => {\n    if (!item.enabled) return 'text-bambu-gray';\n    if (item.is_due) return 'text-red-400';\n    if (item.is_warning) return 'text-amber-400';\n    return 'text-bambu-green';\n  };\n\n  const getProgressColor = () => {\n    if (!item.enabled) return 'bg-bambu-gray/30';\n    if (item.is_due) return 'bg-red-500';\n    if (item.is_warning) return 'bg-amber-500';\n    return 'bg-bambu-green';\n  };\n\n  const getBgColor = () => {\n    if (!item.enabled) return 'bg-bambu-dark-secondary/50';\n    if (item.is_due) return 'bg-red-500/5 border-red-500/20';\n    if (item.is_warning) return 'bg-amber-500/5 border-amber-500/20';\n    return 'bg-bambu-dark-secondary border-bambu-dark-tertiary';\n  };\n\n  const getStatusText = () => {\n    if (!item.enabled) return t('common.disabled');\n\n    if (intervalType === 'days') {\n      const daysUntil = item.days_until_due ?? 0;\n      if (item.is_due) return t('maintenance.overdueBy', { duration: formatDuration(Math.abs(daysUntil), 'days', t) });\n      if (item.is_warning) return t('maintenance.dueIn', { duration: formatDuration(daysUntil, 'days', t) });\n      return t('maintenance.timeLeft', { duration: formatDuration(daysUntil, 'days', t) });\n    } else {\n      if (item.is_due) return t('maintenance.overdueBy', { duration: formatDuration(Math.abs(item.hours_until_due), 'hours', t) });\n      if (item.is_warning) return t('maintenance.dueIn', { duration: formatDuration(item.hours_until_due, 'hours', t) });\n      return t('maintenance.timeLeft', { duration: formatDuration(item.hours_until_due, 'hours', t) });\n    }\n  };\n\n  return (\n    <div className={`rounded-xl border p-4 transition-all ${getBgColor()}`}>\n      <div className=\"flex items-start gap-3 max-[550px]:flex-wrap\">\n        {/* Icon with status indicator */}\n        <div className={`relative p-2.5 rounded-lg shrink-0 ${\n          item.is_due ? 'bg-red-500/20' :\n          item.is_warning ? 'bg-amber-500/20' :\n          item.enabled ? 'bg-bambu-dark' : 'bg-bambu-dark/50'\n        }`}>\n          <Icon className={`w-5 h-5 ${getStatusColor()}`} />\n          {item.enabled && (item.is_due || item.is_warning) && (\n            <span className={`absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full ${\n              item.is_due ? 'bg-red-500' : 'bg-amber-500'\n            } animate-pulse`} />\n          )}\n        </div>\n\n        {/* Content */}\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center gap-2\">\n            <h3 className={`font-medium truncate ${item.enabled ? 'text-white' : 'text-bambu-gray'}`}>\n              {item.maintenance_type_name}\n            </h3>\n            {intervalType === 'days' && (\n              <span title={t('maintenance.timeBasedInterval')}>\n                <Calendar className=\"w-3.5 h-3.5 text-bambu-gray shrink-0\" />\n              </span>\n            )}\n            {/* Wiki link - next to name */}\n            {(() => {\n              // Use custom wiki_url from type if available, otherwise use computed URL\n              const wikiUrl = item.maintenance_type_wiki_url || getMaintenanceWikiUrl(item.maintenance_type_name, item.printer_model);\n              return wikiUrl ? (\n                <a\n                  href={wikiUrl}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-bambu-gray hover:text-bambu-green transition-colors shrink-0\"\n                  title={t('maintenance.viewDocumentation')}\n                  onClick={(e) => e.stopPropagation()}\n                >\n                  <ExternalLink className=\"w-3.5 h-3.5\" />\n                </a>\n              ) : null;\n            })()}\n          </div>\n\n          {/* Progress bar */}\n          <div className=\"mt-2 mb-1.5\">\n            <div className=\"w-full h-1.5 bg-bambu-dark rounded-full overflow-hidden\">\n              <div\n                className={`h-full rounded-full transition-all duration-500 ${getProgressColor()}`}\n                style={{ width: `${progressPercent}%` }}\n              />\n            </div>\n          </div>\n\n          {/* Status text */}\n          <div className={`text-xs flex items-center gap-1 ${getStatusColor()}`}>\n            {item.is_due && <AlertTriangle className=\"w-3 h-3\" />}\n            {item.is_warning && !item.is_due && <Clock className=\"w-3 h-3\" />}\n            {!item.is_due && !item.is_warning && item.enabled && <Check className=\"w-3 h-3\" />}\n            {getStatusText()}\n          </div>\n        </div>\n\n        {/* Actions */}\n        <div className=\"flex items-center gap-2 shrink-0 max-[550px]:w-full max-[550px]:justify-end max-[550px]:mt-1\">\n          <span title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionUpdate') : undefined}>\n            <Toggle\n              checked={item.enabled}\n              onChange={(checked) => onToggle(item.id, checked)}\n              disabled={!hasPermission('maintenance:update')}\n            />\n          </span>\n          <Button\n            size=\"sm\"\n            variant={item.is_due ? 'primary' : 'secondary'}\n            onClick={() => onPerform(item.id)}\n            disabled={!item.enabled || !hasPermission('maintenance:update')}\n            title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionPerform') : undefined}\n            className=\"!px-3\"\n          >\n            <RotateCcw className=\"w-3.5 h-3.5\" />\n            {t('common.reset')}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// Printer section with improved visual hierarchy\nfunction PrinterSection({\n  overview,\n  onPerform,\n  onToggle,\n  onSetHours,\n  hasPermission,\n  t,\n}: {\n  overview: PrinterMaintenanceOverview;\n  onPerform: (id: number) => void;\n  onToggle: (id: number, enabled: boolean) => void;\n  onSetHours: (printerId: number, hours: number) => void;\n  hasPermission: (permission: Permission) => boolean;\n  t: TFunction;\n}) {\n  const [expanded, setExpanded] = useState(false);\n  const [editingHours, setEditingHours] = useState(false);\n  const [hoursInput, setHoursInput] = useState(overview.total_print_hours.toFixed(1));\n\n  const sortedItems = [...overview.maintenance_items].sort((a, b) => {\n    // Sort by urgency first, then by type\n    if (a.is_due && !b.is_due) return -1;\n    if (!a.is_due && b.is_due) return 1;\n    if (a.is_warning && !b.is_warning) return -1;\n    if (!a.is_warning && b.is_warning) return 1;\n    return a.maintenance_type_id - b.maintenance_type_id;\n  });\n\n  const nextTask = sortedItems.find(item => item.enabled && (item.is_due || item.is_warning));\n\n  const handleSaveHours = () => {\n    const hours = parseFloat(hoursInput);\n    if (!isNaN(hours) && hours >= 0) {\n      onSetHours(overview.printer_id, hours);\n      setEditingHours(false);\n    }\n  };\n\n  return (\n    <Card className=\"overflow-hidden\">\n      {/* Header */}\n      <div className=\"p-5\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-4\">\n            <h2 className=\"text-xl font-semibold text-white\">{overview.printer_name}</h2>\n            <div className=\"flex items-center gap-2\">\n              {overview.due_count > 0 && (\n                <span className=\"px-2.5 py-1 bg-red-500/20 text-red-400 text-xs font-medium rounded-full flex items-center gap-1.5\">\n                  <AlertTriangle className=\"w-3 h-3\" />\n                  {t('maintenance.overdueCount', { count: overview.due_count })}\n                </span>\n              )}\n              {overview.warning_count > 0 && (\n                <span className=\"px-2.5 py-1 bg-amber-500/20 text-amber-400 text-xs font-medium rounded-full flex items-center gap-1.5\">\n                  <Clock className=\"w-3 h-3\" />\n                  {t('maintenance.dueSoonCount', { count: overview.warning_count })}\n                </span>\n              )}\n              {overview.due_count === 0 && overview.warning_count === 0 && (\n                <span className=\"px-2.5 py-1 bg-bambu-green/20 text-bambu-green text-xs font-medium rounded-full flex items-center gap-1.5\">\n                  <Check className=\"w-3 h-3\" />\n                  {t('maintenance.allGood')}\n                </span>\n              )}\n            </div>\n          </div>\n          <button\n            onClick={() => setExpanded(!expanded)}\n            className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark rounded-lg transition-colors\"\n          >\n            {expanded ? <ChevronUp className=\"w-4 h-4\" /> : <ChevronDown className=\"w-4 h-4\" />}\n            {expanded ? t('common.collapse') : t('common.expand')}\n          </button>\n        </div>\n\n        {/* Quick stats row */}\n        <div className=\"flex items-center gap-6 mt-4\">\n          {/* Print Hours */}\n          <div className=\"flex items-center gap-3\">\n            <div className=\"p-2 bg-bambu-dark/50 rounded-lg\">\n              <Timer className=\"w-4 h-4 text-bambu-gray\" />\n            </div>\n            {editingHours ? (\n              <div className=\"flex items-center gap-2\">\n                <input\n                  type=\"number\"\n                  value={hoursInput}\n                  onChange={(e) => setHoursInput(e.target.value)}\n                  onKeyDown={(e) => {\n                    if (e.key === 'Enter') handleSaveHours();\n                    if (e.key === 'Escape') setEditingHours(false);\n                  }}\n                  className=\"w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm\"\n                  min=\"0\"\n                  step=\"1\"\n                  autoFocus\n                />\n                <span className=\"text-xs text-bambu-gray\">{t('common.hours')}</span>\n                <Button size=\"sm\" onClick={handleSaveHours}>{t('common.save')}</Button>\n                <Button size=\"sm\" variant=\"secondary\" onClick={() => setEditingHours(false)}>{t('common.cancel')}</Button>\n              </div>\n            ) : (\n              <button\n                onClick={() => {\n                  if (!hasPermission('maintenance:update')) return;\n                  setHoursInput(Math.round(overview.total_print_hours).toString());\n                  setEditingHours(true);\n                }}\n                className={`group ${!hasPermission('maintenance:update') ? 'cursor-not-allowed opacity-60' : ''}`}\n                title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditHours') : undefined}\n              >\n                <div className={`text-sm font-medium text-white ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''} transition-colors flex items-center gap-1`}>\n                  {Math.round(overview.total_print_hours)} {t('common.hours')}\n                  <Edit3 className={`w-3 h-3 text-bambu-gray ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''}`} />\n                </div>\n                <div className=\"text-xs text-bambu-gray\">{t('maintenance.totalPrintTime')}</div>\n              </button>\n            )}\n          </div>\n\n          {/* Divider */}\n          <div className=\"w-px h-10 bg-bambu-dark-tertiary\" />\n\n          {/* Next Maintenance */}\n          {nextTask && (\n            <div className=\"flex items-center gap-3\">\n              <div className={`p-2 rounded-lg ${\n                nextTask.is_due ? 'bg-red-500/20' : 'bg-amber-500/20'\n              }`}>\n                {(() => {\n                  const Icon = getIcon(nextTask.maintenance_type_icon);\n                  return <Icon className={`w-4 h-4 ${nextTask.is_due ? 'text-red-400' : 'text-amber-400'}`} />;\n                })()}\n              </div>\n              <div>\n                <div className={`text-sm font-medium ${nextTask.is_due ? 'text-red-400' : 'text-amber-400'}`}>\n                  {nextTask.maintenance_type_name}\n                </div>\n                <div className={`text-xs ${nextTask.is_due ? 'text-red-400/70' : 'text-amber-400/70'}`}>\n                  {nextTask.is_due ? t('common.overdue') : t('maintenance.dueSoon')}\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Maintenance items */}\n      {expanded && (\n        <CardContent className=\"pt-0 border-t border-bambu-dark-tertiary\">\n          <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-3 pt-4\">\n            {sortedItems.map((item) => (\n              <MaintenanceCard\n                key={item.id}\n                item={item}\n                onPerform={onPerform}\n                onToggle={onToggle}\n                hasPermission={hasPermission}\n                t={t}\n              />\n            ))}\n          </div>\n        </CardContent>\n      )}\n    </Card>\n  );\n}\n\n// Settings section - maintenance types configuration\nfunction SettingsSection({\n  overview,\n  types,\n  onUpdateInterval,\n  onAddType,\n  onUpdateType,\n  onDeleteType,\n  onRestoreDefaults,\n  isRestoringDefaults,\n  onAssignType,\n  onRemoveItem,\n  hasPermission,\n  t,\n}: {\n  overview: PrinterMaintenanceOverview[] | undefined;\n  types: MaintenanceType[];\n  onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void;\n  onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string; wiki_url?: string | null }, printerIds: number[]) => void;\n  onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string; wiki_url?: string | null }) => void;\n  onDeleteType: (id: number) => void;\n  onRestoreDefaults: () => void;\n  isRestoringDefaults: boolean;\n  onAssignType: (printerId: number, typeId: number) => void;\n  onRemoveItem: (itemId: number) => void;\n  hasPermission: (permission: Permission) => boolean;\n  t: TFunction;\n}) {\n  const [editingInterval, setEditingInterval] = useState<number | null>(null);\n  const [intervalInput, setIntervalInput] = useState('');\n  const [intervalTypeInput, setIntervalTypeInput] = useState<'hours' | 'days'>('hours');\n  const [showAddType, setShowAddType] = useState(false);\n  const [newTypeName, setNewTypeName] = useState('');\n  const [newTypeInterval, setNewTypeInterval] = useState('100');\n  const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');\n  const [newTypeIcon, setNewTypeIcon] = useState('Wrench');\n  const [newTypeWikiUrl, setNewTypeWikiUrl] = useState('');\n  const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());\n  const [expandedType, setExpandedType] = useState<number | null>(null);\n  const [pendingSystemDelete, setPendingSystemDelete] = useState<MaintenanceType | null>(null);\n\n  // Get unique printers from overview\n  const printers = useMemo(() => {\n    if (!overview) return [];\n    return overview.map(o => ({ id: o.printer_id, name: o.printer_name }));\n  }, [overview]);\n\n  // Get which printers have a specific maintenance type assigned\n  const getAssignedPrinters = (typeId: number) => {\n    if (!overview) return [];\n    return overview\n      .filter(p => p.maintenance_items.some(item => item.maintenance_type_id === typeId))\n      .map(p => ({\n        printerId: p.printer_id,\n        printerName: p.printer_name,\n        itemId: p.maintenance_items.find(item => item.maintenance_type_id === typeId)?.id,\n      }));\n  };\n\n  // Get printers that DON'T have a specific type assigned\n  const getUnassignedPrinters = (typeId: number) => {\n    if (!overview) return [];\n    const assignedIds = new Set(getAssignedPrinters(typeId).map(p => p.printerId));\n    return printers.filter(p => !assignedIds.has(p.id));\n  };\n\n  // Edit type state\n  const [editingType, setEditingType] = useState<MaintenanceType | null>(null);\n  const [editTypeName, setEditTypeName] = useState('');\n  const [editTypeInterval, setEditTypeInterval] = useState('');\n  const [editTypeIntervalType, setEditTypeIntervalType] = useState<'hours' | 'days'>('hours');\n  const [editTypeIcon, setEditTypeIcon] = useState('Wrench');\n  const [editTypeWikiUrl, setEditTypeWikiUrl] = useState('');\n\n  const startEditType = (type: MaintenanceType) => {\n    setEditingType(type);\n    setEditTypeName(type.name);\n    setEditTypeInterval(type.default_interval_hours.toString());\n    setEditTypeIntervalType(type.interval_type || 'hours');\n    setEditTypeIcon(type.icon || 'Wrench');\n    setEditTypeWikiUrl(type.wiki_url || '');\n  };\n\n  const handleSaveEditType = () => {\n    if (editingType && editTypeName.trim() && parseFloat(editTypeInterval) > 0) {\n      onUpdateType(editingType.id, {\n        name: editTypeName.trim(),\n        default_interval_hours: parseFloat(editTypeInterval),\n        interval_type: editTypeIntervalType,\n        icon: editTypeIcon,\n        wiki_url: editTypeWikiUrl.trim() || null,\n      });\n      setEditingType(null);\n    }\n  };\n\n  const handleSaveInterval = (itemId: number, defaultInterval: number, defaultIntervalType: 'hours' | 'days') => {\n    const newInterval = parseFloat(intervalInput);\n    if (!isNaN(newInterval) && newInterval > 0) {\n      const customInterval = Math.abs(newInterval - defaultInterval) < 0.01 ? null : newInterval;\n      const customIntervalType = intervalTypeInput !== defaultIntervalType ? intervalTypeInput : null;\n      onUpdateInterval(itemId, {\n        custom_interval_hours: customInterval,\n        custom_interval_type: customIntervalType\n      });\n    }\n    setEditingInterval(null);\n  };\n\n  const handleAddType = (e: React.FormEvent) => {\n    e.preventDefault();\n    if (newTypeName.trim() && parseFloat(newTypeInterval) > 0 && selectedPrinters.size > 0) {\n      onAddType({\n        name: newTypeName.trim(),\n        default_interval_hours: parseFloat(newTypeInterval),\n        interval_type: newTypeIntervalType,\n        icon: newTypeIcon,\n        wiki_url: newTypeWikiUrl.trim() || null,\n      }, Array.from(selectedPrinters));\n      setNewTypeName('');\n      setNewTypeInterval('100');\n      setNewTypeIntervalType('hours');\n      setNewTypeWikiUrl('');\n      setSelectedPrinters(new Set());\n      setShowAddType(false);\n    }\n  };\n\n  const togglePrinterSelection = (printerId: number) => {\n    setSelectedPrinters(prev => {\n      const next = new Set(prev);\n      if (next.has(printerId)) {\n        next.delete(printerId);\n      } else {\n        next.add(printerId);\n      }\n      return next;\n    });\n  };\n\n  const printerItems = overview?.map(p => ({\n    printerId: p.printer_id,\n    printerName: p.printer_name,\n    items: p.maintenance_items.sort((a, b) => a.maintenance_type_id - b.maintenance_type_id),\n  })).sort((a, b) => a.printerName.localeCompare(b.printerName)) || [];\n\n  const systemTypes = types.filter(t => t.is_system);\n  const customTypes = types.filter(t => !t.is_system);\n\n  return (\n    <div className=\"space-y-8\">\n      {/* Maintenance Types */}\n      <div>\n        <div className=\"flex items-center justify-between mb-4\">\n          <div>\n            <h2 className=\"text-lg font-semibold text-white\">{t('maintenance.maintenanceTypes')}</h2>\n            <p className=\"text-sm text-bambu-gray mt-1\">{t('maintenance.maintenanceTypesDescription')}</p>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              variant=\"secondary\"\n              onClick={onRestoreDefaults}\n              disabled={!hasPermission('maintenance:delete') || isRestoringDefaults}\n              title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}\n            >\n              {t('maintenance.restoreDefaults')}\n            </Button>\n            <Button\n              onClick={() => setShowAddType(!showAddType)}\n              disabled={!hasPermission('maintenance:create')}\n              title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionEditTypes') : undefined}\n            >\n              <Plus className=\"w-4 h-4\" />\n              {t('maintenance.addCustomType')}\n            </Button>\n          </div>\n        </div>\n\n        {/* Add custom type form */}\n        {showAddType && (\n          <Card className=\"mb-6\">\n            <CardContent className=\"py-4\">\n              <form onSubmit={handleAddType}>\n                <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4\">\n                  <div className=\"lg:col-span-2\">\n                    <label className=\"block text-xs text-bambu-gray mb-1.5\">{t('common.name')}</label>\n                    <input\n                      type=\"text\"\n                      value={newTypeName}\n                      onChange={(e) => setNewTypeName(e.target.value)}\n                      className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                      placeholder={t('maintenance.exampleName')}\n                      autoFocus\n                    />\n                  </div>\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1.5\">{t('maintenance.intervalType')}</label>\n                    <select\n                      value={newTypeIntervalType}\n                      onChange={(e) => {\n                        setNewTypeIntervalType(e.target.value as 'hours' | 'days');\n                        // Set sensible default based on type\n                        if (e.target.value === 'days') {\n                          setNewTypeInterval('30');\n                        } else {\n                          setNewTypeInterval('100');\n                        }\n                      }}\n                      className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                    >\n                      <option value=\"hours\">{t('maintenance.printHours')}</option>\n                      <option value=\"days\">{t('maintenance.calendarDays')}</option>\n                    </select>\n                  </div>\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1.5\">\n                      {t('maintenance.intervalValue', { type: newTypeIntervalType === 'days' ? t('maintenance.calendarDays').toLowerCase() : t('common.hours') })}\n                    </label>\n                    <input\n                      type=\"number\"\n                      value={newTypeInterval}\n                      onChange={(e) => setNewTypeInterval(e.target.value)}\n                      className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                      min=\"1\"\n                    />\n                  </div>\n                </div>\n                <div className=\"mt-4 flex items-end justify-between\">\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1.5\">{t('maintenance.icon')}</label>\n                    <div className=\"flex gap-1\">\n                      {Object.keys(iconMap).map((iconName) => {\n                        const IconComp = iconMap[iconName];\n                        return (\n                          <button\n                            key={iconName}\n                            type=\"button\"\n                            onClick={() => setNewTypeIcon(iconName)}\n                            className={`p-2 rounded-lg transition-colors ${\n                              newTypeIcon === iconName\n                                ? 'bg-bambu-green text-white'\n                                : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'\n                            }`}\n                          >\n                            <IconComp className=\"w-4 h-4\" />\n                          </button>\n                        );\n                      })}\n                    </div>\n                  </div>\n                </div>\n                {/* Wiki URL */}\n                <div className=\"mt-4\">\n                  <label className=\"block text-xs text-bambu-gray mb-1.5\">{t('maintenance.documentationLink')}</label>\n                  <input\n                    type=\"url\"\n                    value={newTypeWikiUrl}\n                    onChange={(e) => setNewTypeWikiUrl(e.target.value)}\n                    className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                    placeholder=\"https://wiki.bambulab.com/...\"\n                  />\n                </div>\n                {/* Printer selection */}\n                <div className=\"mt-4\">\n                  <label className=\"block text-xs text-bambu-gray mb-1.5\">{t('maintenance.assignToPrinters')}</label>\n                  <div className=\"flex flex-wrap gap-2\">\n                    {printers.map(p => (\n                      <button\n                        key={p.id}\n                        type=\"button\"\n                        onClick={() => togglePrinterSelection(p.id)}\n                        className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${\n                          selectedPrinters.has(p.id)\n                            ? 'bg-bambu-green text-white'\n                            : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'\n                        }`}\n                      >\n                        {p.name}\n                      </button>\n                    ))}\n                  </div>\n                  {selectedPrinters.size === 0 && (\n                    <p className=\"text-xs text-orange-400 mt-1\">{t('maintenance.selectAtLeastOnePrinter')}</p>\n                  )}\n                </div>\n                <div className=\"mt-4 flex justify-end gap-2\">\n                  <Button type=\"button\" variant=\"secondary\" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>\n                    {t('common.cancel')}\n                  </Button>\n                  <Button type=\"submit\" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>\n                    {t('maintenance.addType')}\n                  </Button>\n                </div>\n              </form>\n            </CardContent>\n          </Card>\n        )}\n\n        {/* Types grid */}\n        <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3\">\n          {/* System types */}\n          {systemTypes.map((type) => {\n            const Icon = getIcon(type.icon);\n            const intervalType = type.interval_type || 'hours';\n            return (\n              <div key={type.id} className=\"bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-dark-tertiary\">\n                <div className=\"flex items-center gap-3\">\n                  <div className=\"p-2.5 bg-bambu-dark rounded-lg\">\n                    <Icon className=\"w-5 h-5 text-bambu-gray\" />\n                  </div>\n                  <div className=\"flex-1 min-w-0\">\n                    <div className=\"text-sm font-medium text-white truncate\">{type.name}</div>\n                    <div className=\"text-xs text-bambu-gray mt-0.5 flex items-center gap-1\">\n                      {intervalType === 'days' ? <Calendar className=\"w-3 h-3\" /> : <Timer className=\"w-3 h-3\" />}\n                      {formatIntervalLabel(type.default_interval_hours, intervalType, t)}\n                    </div>\n                  </div>\n                  <button\n                    onClick={() => {\n                      if (!hasPermission('maintenance:delete')) return;\n                      setPendingSystemDelete(type);\n                    }}\n                    disabled={!hasPermission('maintenance:delete')}\n                    title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}\n                    className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}\n                  >\n                    <Trash2 className=\"w-4 h-4\" />\n                  </button>\n                </div>\n              </div>\n            );\n          })}\n          {/* Custom types */}\n          {customTypes.map((type) => {\n            const Icon = getIcon(type.icon);\n            const intervalType = type.interval_type || 'hours';\n            const isEditing = editingType?.id === type.id;\n\n            if (isEditing) {\n              return (\n                <div key={type.id} className=\"bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green\">\n                  <div className=\"space-y-3\">\n                    <input\n                      type=\"text\"\n                      value={editTypeName}\n                      onChange={(e) => setEditTypeName(e.target.value)}\n                      className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                      placeholder={t('common.name')}\n                      autoFocus\n                    />\n                    <div className=\"flex gap-2\">\n                      <select\n                        value={editTypeIntervalType}\n                        onChange={(e) => setEditTypeIntervalType(e.target.value as 'hours' | 'days')}\n                        className=\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                      >\n                        <option value=\"hours\">{t('maintenance.printHours')}</option>\n                        <option value=\"days\">{t('maintenance.calendarDays')}</option>\n                      </select>\n                      <input\n                        type=\"number\"\n                        value={editTypeInterval}\n                        onChange={(e) => setEditTypeInterval(e.target.value)}\n                        className=\"w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                        min=\"1\"\n                      />\n                    </div>\n                    <div className=\"flex flex-wrap gap-1\">\n                      {Object.keys(iconMap).map((iconName) => {\n                        const IconComp = iconMap[iconName];\n                        return (\n                          <button\n                            key={iconName}\n                            type=\"button\"\n                            onClick={() => setEditTypeIcon(iconName)}\n                            className={`p-1.5 rounded transition-colors ${\n                              editTypeIcon === iconName\n                                ? 'bg-bambu-green text-white'\n                                : 'bg-bambu-dark text-bambu-gray hover:text-white'\n                            }`}\n                          >\n                            <IconComp className=\"w-3.5 h-3.5\" />\n                          </button>\n                        );\n                      })}\n                    </div>\n                    <input\n                      type=\"url\"\n                      value={editTypeWikiUrl}\n                      onChange={(e) => setEditTypeWikiUrl(e.target.value)}\n                      className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                      placeholder={t('maintenance.documentationLink')}\n                    />\n                    <div className=\"flex gap-2\">\n                      <Button size=\"sm\" onClick={handleSaveEditType} disabled={!editTypeName.trim()}>\n                        {t('common.save')}\n                      </Button>\n                      <Button size=\"sm\" variant=\"secondary\" onClick={() => setEditingType(null)}>\n                        {t('common.cancel')}\n                      </Button>\n                    </div>\n                  </div>\n                </div>\n              );\n            }\n\n            const assignedPrinters = getAssignedPrinters(type.id);\n            const unassignedPrinters = getUnassignedPrinters(type.id);\n            const isExpanded = expandedType === type.id;\n\n            return (\n              <div key={type.id} className=\"bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green/30\">\n                <div className=\"flex items-center gap-3\">\n                  <div className=\"p-2.5 bg-bambu-green/20 rounded-lg\">\n                    <Icon className=\"w-5 h-5 text-bambu-green\" />\n                  </div>\n                  <div className=\"flex-1 min-w-0\">\n                    <div className=\"flex items-center gap-2\">\n                      <span className=\"text-sm font-medium text-white truncate\">{type.name}</span>\n                      <span className=\"px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded\">\n                        {t('maintenance.custom')}\n                      </span>\n                    </div>\n                    <div className=\"text-xs text-bambu-gray mt-0.5 flex items-center gap-1\">\n                      {intervalType === 'days' ? <Calendar className=\"w-3 h-3\" /> : <Timer className=\"w-3 h-3\" />}\n                      {formatIntervalLabel(type.default_interval_hours, intervalType, t)}\n                    </div>\n                  </div>\n                  <button\n                    onClick={() => setExpandedType(isExpanded ? null : type.id)}\n                    className={`px-2 py-1 rounded-lg border transition-colors flex items-center gap-1 ${\n                      assignedPrinters.length > 0\n                        ? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'\n                        : 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'\n                    }`}\n                    title={t('maintenance.printersAssignedClick', { count: assignedPrinters.length })}\n                  >\n                    <Printer className=\"w-3 h-3\" />\n                    <span className=\"text-xs font-medium\">{assignedPrinters.length}</span>\n                    <ChevronDown className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />\n                  </button>\n                  <button\n                    onClick={() => startEditType(type)}\n                    disabled={!hasPermission('maintenance:update')}\n                    title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditTypes') : undefined}\n                    className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors ${!hasPermission('maintenance:update') ? 'opacity-50 cursor-not-allowed' : ''}`}\n                  >\n                    <Edit3 className=\"w-4 h-4\" />\n                  </button>\n                  <button\n                    onClick={() => {\n                      if (confirm(t('maintenance.deleteTypeConfirm', { name: type.name }))) {\n                        onDeleteType(type.id);\n                      }\n                    }}\n                    disabled={!hasPermission('maintenance:delete')}\n                    title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}\n                    className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}\n                  >\n                    <Trash2 className=\"w-4 h-4\" />\n                  </button>\n                </div>\n\n                {/* Printer assignment management */}\n                {isExpanded && (\n                  <div className=\"mt-3 pt-3 border-t border-bambu-dark-tertiary\">\n                    <p className=\"text-xs text-bambu-gray mb-2\">{t('maintenance.assignedToPrinters')}</p>\n                    {assignedPrinters.length === 0 ? (\n                      <p className=\"text-xs text-orange-400\">{t('maintenance.noPrintersAssigned')}</p>\n                    ) : (\n                      <div className=\"flex flex-wrap gap-1 mb-2\">\n                        {assignedPrinters.map(p => (\n                          <span\n                            key={p.printerId}\n                            className=\"inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark rounded text-xs text-white\"\n                          >\n                            {p.printerName}\n                            <button\n                              onClick={() => p.itemId && onRemoveItem(p.itemId)}\n                              disabled={!hasPermission('maintenance:delete')}\n                              title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionRemovePrinter') : t('maintenance.removeFromPrinter')}\n                              className={`ml-1 ${hasPermission('maintenance:delete') ? 'hover:text-red-400' : 'opacity-50 cursor-not-allowed'}`}\n                            >\n                              ×\n                            </button>\n                          </span>\n                        ))}\n                      </div>\n                    )}\n                    {unassignedPrinters.length > 0 && (\n                      <div className=\"flex flex-wrap gap-1\">\n                        <span className=\"text-xs text-bambu-gray mr-1\">{t('maintenance.addPrinterShort')}</span>\n                        {unassignedPrinters.map(p => (\n                          <button\n                            key={p.id}\n                            onClick={() => onAssignType(p.id, type.id)}\n                            disabled={!hasPermission('maintenance:create')}\n                            title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionAssignPrinter') : undefined}\n                            className={`px-2 py-1 bg-bambu-dark rounded text-xs transition-colors ${hasPermission('maintenance:create') ? 'hover:bg-bambu-green/20 text-bambu-gray hover:text-bambu-green' : 'opacity-50 cursor-not-allowed text-bambu-gray'}`}\n                          >\n                            + {p.name}\n                          </button>\n                        ))}\n                      </div>\n                    )}\n                  </div>\n                )}\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      {/* Per-printer interval overrides */}\n      {printerItems.length > 0 && (\n        <div>\n          <div className=\"mb-4\">\n            <h2 className=\"text-lg font-semibold text-white\">{t('maintenance.intervalOverrides')}</h2>\n            <p className=\"text-sm text-bambu-gray mt-1\">{t('maintenance.intervalOverridesDescription')}</p>\n          </div>\n          <div className=\"space-y-4\">\n            {printerItems.map((printer) => (\n              <Card key={printer.printerId}>\n                <CardContent className=\"py-4\">\n                  <h3 className=\"text-sm font-medium text-white mb-3\">{printer.printerName}</h3>\n                  <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2\">\n                    {printer.items.map((item) => {\n                      const Icon = getIcon(item.maintenance_type_icon);\n                      const typeInfo = types.find(t => t.id === item.maintenance_type_id);\n                      const defaultInterval = typeInfo?.default_interval_hours || item.interval_hours;\n                      const defaultIntervalType = typeInfo?.interval_type || 'hours';\n                      const intervalType = item.interval_type || 'hours';\n                      const isEditing = editingInterval === item.id;\n\n                      return (\n                        <div key={item.id} className=\"flex items-center gap-2 p-2.5 bg-bambu-dark rounded-lg\">\n                          <Icon className=\"w-4 h-4 text-bambu-gray shrink-0\" />\n                          <span className=\"text-xs text-bambu-gray flex-1 truncate\">{item.maintenance_type_name}</span>\n\n                          {isEditing ? (\n                            <div className=\"flex items-center gap-1\">\n                              {intervalTypeInput === 'days' ? (\n                                <Calendar className=\"w-3.5 h-3.5 text-bambu-gray shrink-0\" />\n                              ) : (\n                                <Timer className=\"w-3.5 h-3.5 text-bambu-gray shrink-0\" />\n                              )}\n                              <select\n                                value={intervalTypeInput}\n                                onChange={(e) => setIntervalTypeInput(e.target.value as 'hours' | 'days')}\n                                className=\"px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs\"\n                              >\n                                <option value=\"hours\">{t('maintenance.printHours')}</option>\n                                <option value=\"days\">{t('maintenance.calendarDays')}</option>\n                              </select>\n                              <input\n                                type=\"number\"\n                                value={intervalInput}\n                                onChange={(e) => setIntervalInput(e.target.value)}\n                                onKeyDown={(e) => {\n                                  if (e.key === 'Enter') handleSaveInterval(item.id, defaultInterval, defaultIntervalType);\n                                  if (e.key === 'Escape') setEditingInterval(null);\n                                }}\n                                className=\"w-16 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs\"\n                                min=\"1\"\n                              />\n                              <Button size=\"sm\" onClick={() => handleSaveInterval(item.id, defaultInterval, defaultIntervalType)}>OK</Button>\n                            </div>\n                          ) : (\n                            <button\n                              onClick={() => {\n                                if (!hasPermission('maintenance:update')) return;\n                                setEditingInterval(item.id);\n                                setIntervalInput(item.interval_hours.toString());\n                                setIntervalTypeInput(intervalType);\n                              }}\n                              disabled={!hasPermission('maintenance:update')}\n                              title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditIntervals') : undefined}\n                              className={`px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs font-medium text-white transition-colors flex items-center gap-1 ${hasPermission('maintenance:update') ? 'hover:bg-bambu-dark-secondary hover:border-bambu-green' : 'opacity-50 cursor-not-allowed'}`}\n                            >\n                              {intervalType === 'days' ? <Calendar className=\"w-3 h-3\" /> : <Timer className=\"w-3 h-3\" />}\n                              {formatIntervalLabel(item.interval_hours, intervalType, t)}\n                              <Edit3 className=\"w-3 h-3 text-bambu-gray\" />\n                            </button>\n                          )}\n                        </div>\n                      );\n                    })}\n                  </div>\n                </CardContent>\n              </Card>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {printerItems.length === 0 && (\n        <Card>\n          <CardContent className=\"text-center py-12\">\n            <Clock className=\"w-12 h-12 mx-auto mb-4 text-bambu-gray/30\" />\n            <p className=\"text-bambu-gray\">{t('common.noPrinters')}</p>\n            <p className=\"text-sm text-bambu-gray/70 mt-1\">\n              {t('maintenance.intervalOverridesDescription')}\n            </p>\n          </CardContent>\n        </Card>\n      )}\n\n      {pendingSystemDelete && (\n        <ConfirmModal\n          title={t('maintenance.deleteSystemTypeTitle')}\n          message={t('maintenance.deleteSystemTypeMessage', { name: pendingSystemDelete.name })}\n          confirmText={t('common.delete')}\n          cancelText={t('common.cancel')}\n          variant=\"danger\"\n          cancelVariant=\"primary\"\n          cardClassName=\"bg-red-950/70 border border-red-800/70\"\n          onConfirm={() => {\n            onDeleteType(pendingSystemDelete.id);\n            setPendingSystemDelete(null);\n          }}\n          onCancel={() => setPendingSystemDelete(null)}\n        />\n      )}\n    </div>\n  );\n}\n\ntype TabType = 'status' | 'settings';\n\nexport function MaintenancePage() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  const [activeTab, setActiveTab] = useState<TabType>('status');\n\n  const { data: overview, isLoading } = useQuery({\n    queryKey: ['maintenanceOverview'],\n    queryFn: api.getMaintenanceOverview,\n  });\n\n  const { data: types } = useQuery({\n    queryKey: ['maintenanceTypes'],\n    queryFn: api.getMaintenanceTypes,\n  });\n\n  const performMutation = useMutation({\n    mutationFn: ({ id, notes }: { id: number; notes?: string }) =>\n      api.performMaintenance(id, notes),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n      queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });\n      showToast(t('maintenance.maintenanceComplete'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null; enabled?: boolean } }) =>\n      api.updateMaintenanceItem(id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // addTypeMutation removed - we now handle type creation with printer assignment\n  // directly in onAddType callback\n\n  const updateTypeMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon: string }> }) =>\n      api.updateMaintenanceType(id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n      showToast(t('maintenance.typeUpdated'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const deleteTypeMutation = useMutation({\n    mutationFn: api.deleteMaintenanceType,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n      showToast(t('maintenance.typeDeleted'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const restoreDefaultsMutation = useMutation({\n    mutationFn: api.restoreDefaultMaintenanceTypes,\n    onSuccess: (data: { restored: number }) => {\n      queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n      showToast(t('maintenance.defaultsRestored', { count: data.restored }));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const setHoursMutation = useMutation({\n    mutationFn: ({ printerId, hours }: { printerId: number; hours: number }) =>\n      api.setPrinterHours(printerId, hours),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n      queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });\n      showToast(t('maintenance.printHoursUpdated'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const assignTypeMutation = useMutation({\n    mutationFn: ({ printerId, typeId }: { printerId: number; typeId: number }) =>\n      api.assignMaintenanceType(printerId, typeId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n      showToast(t('maintenance.printerAssigned'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const removeItemMutation = useMutation({\n    mutationFn: api.removeMaintenanceItem,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n      showToast(t('maintenance.printerRemoved'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const handlePerform = (id: number) => {\n    performMutation.mutate({ id });\n  };\n\n  const handleToggle = (id: number, enabled: boolean) => {\n    updateMutation.mutate({ id, data: { enabled } });\n  };\n\n  const handleSetHours = (printerId: number, hours: number) => {\n    setHoursMutation.mutate({ printerId, hours });\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"p-4 md:p-8 flex justify-center\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  const totalDue = overview?.reduce((sum, p) => sum + p.due_count, 0) || 0;\n  const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;\n\n  return (\n    <div className=\"p-4 md:p-8\">\n      {/* Header */}\n      <div className=\"mb-6\">\n        <h1 className=\"text-2xl font-bold text-white\">{t('maintenance.title')}</h1>\n        <p className=\"text-bambu-gray text-sm mt-1\">\n          {activeTab === 'status' ? (\n            <>\n              {totalDue > 0 && <span className=\"text-red-400\">{t('maintenance.dueCount', { count: totalDue })}</span>}\n              {totalDue > 0 && totalWarning > 0 && ' · '}\n              {totalWarning > 0 && <span className=\"text-amber-400\">{t('maintenance.warningCount', { count: totalWarning })}</span>}\n              {totalDue === 0 && totalWarning === 0 && <span className=\"text-bambu-green\">{t('maintenance.allOk')}</span>}\n            </>\n          ) : (\n            t('maintenance.configureSettings')\n          )}\n        </p>\n      </div>\n\n      {/* Tabs */}\n      <div className=\"flex gap-1 mb-6 border-b border-bambu-dark-tertiary\">\n        <button\n          onClick={() => setActiveTab('status')}\n          className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${\n            activeTab === 'status'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray border-transparent hover:text-white'\n          }`}\n        >\n          {t('maintenance.statusTab')}\n        </button>\n        <button\n          onClick={() => setActiveTab('settings')}\n          className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${\n            activeTab === 'settings'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray border-transparent hover:text-white'\n          }`}\n        >\n          {t('maintenance.settingsTab')}\n        </button>\n      </div>\n\n      {/* Tab content */}\n      {activeTab === 'status' ? (\n        <div className=\"space-y-6\">\n          {overview && overview.length > 0 ? (\n            [...overview].sort((a, b) => {\n              // Sort printers with issues first\n              const aScore = a.due_count * 10 + a.warning_count;\n              const bScore = b.due_count * 10 + b.warning_count;\n              if (aScore !== bScore) return bScore - aScore;\n              return a.printer_name.localeCompare(b.printer_name);\n            }).map((printerOverview) => (\n              <PrinterSection\n                key={printerOverview.printer_id}\n                overview={printerOverview}\n                onPerform={handlePerform}\n                onToggle={handleToggle}\n                onSetHours={handleSetHours}\n                hasPermission={hasPermission}\n                t={t}\n              />\n            ))\n          ) : (\n            <Card>\n              <CardContent className=\"text-center py-16\">\n                <Wrench className=\"w-16 h-16 mx-auto mb-4 text-bambu-gray/30\" />\n                <p className=\"text-lg font-medium text-white mb-2\">{t('common.noPrinters')}</p>\n                <p className=\"text-bambu-gray\">{t('maintenance.configureSettings')}</p>\n              </CardContent>\n            </Card>\n          )}\n        </div>\n      ) : (\n        <SettingsSection\n          overview={overview}\n          types={types || []}\n          onUpdateInterval={(id, data) =>\n            updateMutation.mutate({ id, data })\n          }\n          onAddType={async (data, printerIds) => {\n            // Create the type first, then assign to selected printers\n            const newType = await api.createMaintenanceType(data);\n            // Assign to each selected printer\n            for (const printerId of printerIds) {\n              await api.assignMaintenanceType(printerId, newType.id);\n            }\n            queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });\n            queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n            showToast(t('maintenance.typeUpdated'));\n          }}\n          onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}\n          onDeleteType={(id) => deleteTypeMutation.mutate(id)}\n          onRestoreDefaults={() => restoreDefaultsMutation.mutate()}\n          isRestoringDefaults={restoreDefaultsMutation.isPending}\n          onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}\n          onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}\n          hasPermission={hasPermission}\n          t={t}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/NotificationsPage.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useNavigate } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport { Bell, CheckCircle2, Loader2, Mail, Save } from 'lucide-react';\nimport { api } from '../api/client';\nimport { useAuth } from '../contexts/AuthContext';\nimport { useToast } from '../contexts/ToastContext';\nimport { Button } from '../components/Button';\nimport { Card, CardContent, CardHeader } from '../components/Card';\n\nexport function NotificationsPage() {\n  const { t } = useTranslation();\n  const { user } = useAuth();\n  const { showToast } = useToast();\n  const queryClient = useQueryClient();\n  const navigate = useNavigate();\n\n  const [notifyPrintStart, setNotifyPrintStart] = useState(true);\n  const [notifyPrintComplete, setNotifyPrintComplete] = useState(true);\n  const [notifyPrintFailed, setNotifyPrintFailed] = useState(true);\n  const [notifyPrintStopped, setNotifyPrintStopped] = useState(true);\n  const [isDirty, setIsDirty] = useState(false);\n\n  // Check advanced auth status - redirect if disabled\n  const { data: advancedAuthStatus, isLoading: isAdvancedAuthLoading } = useQuery({\n    queryKey: ['advancedAuthStatus'],\n    queryFn: api.getAdvancedAuthStatus,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n\n  const { data: settings, isLoading: isSettingsLoading } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n    staleTime: 5 * 60 * 1000,\n  });\n  \n  // Fetch current preferences\n  const { data: preferences, isLoading } = useQuery({\n    queryKey: ['user-email-preferences'],\n    queryFn: () => api.getUserEmailPreferences(),\n  });\n\n  // Redirect to settings if Advanced Auth is disabled\n  useEffect(() => {\n    if ((advancedAuthStatus && !advancedAuthStatus.advanced_auth_enabled) || (settings && !settings.user_notifications_enabled)) {\n      navigate('/settings', { replace: true });\n    }\n  }, [advancedAuthStatus, settings, navigate]);\n\n  // Populate form when preferences load\n  useEffect(() => {\n    if (preferences) {\n      setNotifyPrintStart(preferences.notify_print_start);\n      setNotifyPrintComplete(preferences.notify_print_complete);\n      setNotifyPrintFailed(preferences.notify_print_failed);\n      setNotifyPrintStopped(preferences.notify_print_stopped);\n      setIsDirty(false);\n    }\n  }, [preferences]);\n\n  // Save preferences\n  const saveMutation = useMutation({\n    mutationFn: () =>\n      api.updateUserEmailPreferences({\n        notify_print_start: notifyPrintStart,\n        notify_print_complete: notifyPrintComplete,\n        notify_print_failed: notifyPrintFailed,\n        notify_print_stopped: notifyPrintStopped,\n      }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['user-email-preferences'] });\n      setIsDirty(false);\n      showToast(t('notifications.userEmail.saveSuccess'), 'success');\n    },\n    onError: (err: Error) => {\n      showToast(err.message || t('notifications.userEmail.saveError'), 'error');\n    },\n  });\n\n  const handleToggle = (\n    setter: React.Dispatch<React.SetStateAction<boolean>>,\n    value: boolean\n  ) => {\n    setter(!value);\n    setIsDirty(true);\n  };\n\n  if (isLoading || isAdvancedAuthLoading || isSettingsLoading) {\n    return (\n      <div className=\"flex items-center justify-center h-64\">\n        <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"p-4 md:p-6 max-w-2xl mx-auto\">\n      <div className=\"flex items-center gap-3 mb-6\">\n        <Bell className=\"w-7 h-7 text-bambu-green\" />\n        <h1 className=\"text-2xl font-bold text-white\">{t('notifications.userEmail.title')}</h1>\n      </div>\n\n      {/* Info card */}\n      <Card className=\"mb-6 border-blue-500/30 bg-blue-500/5\">\n        <CardContent className=\"py-4\">\n          <div className=\"flex items-start gap-3\">\n            <div className=\"w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0\">\n              <Mail className=\"w-5 h-5 text-blue-400\" />\n            </div>\n            <div>\n              <h3 className=\"text-white font-medium\">{t('notifications.userEmail.emailNotifications')}</h3>\n              <p className=\"text-sm text-bambu-gray mt-1\">\n                {t('notifications.userEmail.emailNotificationsDesc')}\n              </p>\n              {user?.email ? (\n                <p className=\"text-sm text-blue-400 mt-2\">\n                  {t('notifications.userEmail.sendingTo')}: <strong>{user.email}</strong>\n                </p>\n              ) : (\n                <p className=\"text-sm text-yellow-400 mt-2\">\n                  {t('notifications.userEmail.noEmailWarning')}\n                </p>\n              )}\n            </div>\n          </div>\n        </CardContent>\n      </Card>\n\n      {/* Preferences card */}\n      <Card className=\"mb-6\">\n        <CardHeader>\n          <h2 className=\"text-lg font-semibold text-white\">{t('notifications.userEmail.printJobNotifications')}</h2>\n          <p className=\"text-sm text-bambu-gray mt-1\">{t('notifications.userEmail.printJobNotificationsDesc')}</p>\n        </CardHeader>\n        <CardContent className=\"space-y-4\">\n          {/* Print Job Starts */}\n          <div className=\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\">\n            <div className=\"flex items-center gap-3\">\n              <div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintStart ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>\n                <CheckCircle2 className={`w-5 h-5 ${notifyPrintStart ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n              </div>\n              <div>\n                <p className=\"text-white font-medium\">{t('notifications.userEmail.printJobStarts')}</p>\n                <p className=\"text-sm text-bambu-gray\">{t('notifications.userEmail.printJobStartsDesc')}</p>\n              </div>\n            </div>\n            <button\n              onClick={() => handleToggle(setNotifyPrintStart, notifyPrintStart)}\n              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${\n                notifyPrintStart ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n              }`}\n              role=\"switch\"\n              aria-checked={notifyPrintStart}\n            >\n              <span\n                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${\n                  notifyPrintStart ? 'translate-x-6' : 'translate-x-1'\n                }`}\n              />\n            </button>\n          </div>\n\n          {/* Print Job Finishes */}\n          <div className=\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\">\n            <div className=\"flex items-center gap-3\">\n              <div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintComplete ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>\n                <CheckCircle2 className={`w-5 h-5 ${notifyPrintComplete ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n              </div>\n              <div>\n                <p className=\"text-white font-medium\">{t('notifications.userEmail.printJobFinishes')}</p>\n                <p className=\"text-sm text-bambu-gray\">{t('notifications.userEmail.printJobFinishesDesc')}</p>\n              </div>\n            </div>\n            <button\n              onClick={() => handleToggle(setNotifyPrintComplete, notifyPrintComplete)}\n              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${\n                notifyPrintComplete ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n              }`}\n              role=\"switch\"\n              aria-checked={notifyPrintComplete}\n            >\n              <span\n                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${\n                  notifyPrintComplete ? 'translate-x-6' : 'translate-x-1'\n                }`}\n              />\n            </button>\n          </div>\n\n          {/* Print Errors */}\n          <div className=\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\">\n            <div className=\"flex items-center gap-3\">\n              <div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintFailed ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>\n                <CheckCircle2 className={`w-5 h-5 ${notifyPrintFailed ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n              </div>\n              <div>\n                <p className=\"text-white font-medium\">{t('notifications.userEmail.printErrors')}</p>\n                <p className=\"text-sm text-bambu-gray\">{t('notifications.userEmail.printErrorsDesc')}</p>\n              </div>\n            </div>\n            <button\n              onClick={() => handleToggle(setNotifyPrintFailed, notifyPrintFailed)}\n              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${\n                notifyPrintFailed ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n              }`}\n              role=\"switch\"\n              aria-checked={notifyPrintFailed}\n            >\n              <span\n                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${\n                  notifyPrintFailed ? 'translate-x-6' : 'translate-x-1'\n                }`}\n              />\n            </button>\n          </div>\n\n          {/* Print Job Stops */}\n          <div className=\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\">\n            <div className=\"flex items-center gap-3\">\n              <div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintStopped ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>\n                <CheckCircle2 className={`w-5 h-5 ${notifyPrintStopped ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n              </div>\n              <div>\n                <p className=\"text-white font-medium\">{t('notifications.userEmail.printJobStops')}</p>\n                <p className=\"text-sm text-bambu-gray\">{t('notifications.userEmail.printJobStopsDesc')}</p>\n              </div>\n            </div>\n            <button\n              onClick={() => handleToggle(setNotifyPrintStopped, notifyPrintStopped)}\n              className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${\n                notifyPrintStopped ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n              }`}\n              role=\"switch\"\n              aria-checked={notifyPrintStopped}\n            >\n              <span\n                className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${\n                  notifyPrintStopped ? 'translate-x-6' : 'translate-x-1'\n                }`}\n              />\n            </button>\n          </div>\n        </CardContent>\n      </Card>\n\n      {/* Save button */}\n      <div className=\"flex justify-end\">\n        <Button\n          onClick={() => saveMutation.mutate()}\n          disabled={!isDirty || saveMutation.isPending || !user?.email}\n        >\n          {saveMutation.isPending ? (\n            <>\n              <Loader2 className=\"w-4 h-4 animate-spin\" />\n              {t('common.saving')}\n            </>\n          ) : (\n            <>\n              <Save className=\"w-4 h-4\" />\n              {t('common.save')}\n            </>\n          )}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/PrintersPage.tsx",
    "content": "import { useState, useEffect, useMemo, useRef, useCallback } from 'react';\nimport { compareFwVersions } from '../utils/firmwareVersion';\nimport { formatPrintName } from '../utils/printName';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { useTheme } from '../contexts/ThemeContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport {\n  Plus,\n  Link,\n  Unlink,\n  Signal,\n  Clock,\n  MoreVertical,\n  Trash2,\n  RefreshCw,\n  RotateCw,\n  Box,\n  HardDrive,\n  AlertTriangle,\n  AlertCircle,\n  Terminal,\n  Power,\n  PowerOff,\n  Zap,\n  Wrench,\n  ChevronDown,\n  Pencil,\n  ArrowUp,\n  ArrowDown,\n  Layers,\n  Video,\n  Search,\n  Loader2,\n  Square,\n  Pause,\n  Play,\n  X,\n  Fan,\n  Wind,\n  AirVent,\n  Download,\n  ScanSearch,\n  CheckCircle,\n  CheckSquare,\n  XCircle,\n  User,\n  Home,\n  Printer as PrinterIcon,\n  Info,\n  Cable,\n  Flame,\n  Snowflake,\n  Gauge,\n  DoorOpen,\n  DoorClosed,\n  MoveVertical,\n} from 'lucide-react';\n\nimport { useNavigate } from 'react-router-dom';\nimport { api, discoveryApi, firmwareApi, withStreamToken } from '../api/client';\nimport { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';\nimport type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError } from '../api/client';\nimport { Card, CardContent } from '../components/Card';\nimport { Button } from '../components/Button';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinterToolbar';\nimport { FileManagerModal } from '../components/FileManagerModal';\nimport { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';\nimport { MQTTDebugModal } from '../components/MQTTDebugModal';\nimport { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';\nimport { PrinterQueueWidget } from '../components/PrinterQueueWidget';\nimport { AMSHistoryModal } from '../components/AMSHistoryModal';\nimport { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';\nimport { LinkSpoolModal } from '../components/LinkSpoolModal';\nimport { AssignSpoolModal } from '../components/AssignSpoolModal';\nimport { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { ChamberLight } from '../components/icons/ChamberLight';\nimport { PlateClearedIcon } from '../components/icons/PlateClearedIcon';\nimport { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';\nimport { FileUploadModal } from '../components/FileUploadModal';\nimport { PrintModal } from '../components/PrintModal';\nimport { PrinterInfoModal } from '../components/PrinterInfoModal';\nimport { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../utils/amsHelpers';\nimport { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';\nimport { FilamentSlotCircle } from '../components/FilamentSlotCircle';\nimport { Collapsible } from '../components/Collapsible';\nimport { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';\n\n// Color names resolve via getColorName() which reads the backend color_catalog\n// (loaded once by ColorCatalogProvider). No hardcoded tables here — see #857.\n\n// Format K value with 3 decimal places, default to 0.020 if null\nfunction formatKValue(k: number | null | undefined): string {\n  const value = k ?? 0.020;\n  return value.toFixed(3);\n}\n\n// Nozzle side indicators (Bambu Lab style - square badge with L/R)\nfunction NozzleBadge({ side }: { side: 'L' | 'R' }) {\n  const { mode } = useTheme();\n  // Light mode: #e7f5e9 (light green), Dark mode: #1a4d2e (dark green)\n  const bgColor = mode === 'dark' ? '#1a4d2e' : '#e7f5e9';\n  return (\n    <span\n      className=\"inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded\"\n      style={{ backgroundColor: bgColor, color: '#00ae42' }}\n    >\n      {side}\n    </span>\n  );\n}\n\n// Expand nozzle type codes to material names\n// Handles full text (\"hardened_steel\"), 2-char codes (\"HS\"/\"HH\"), and 4-char codes (\"HS01\")\n// Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide\nfunction nozzleTypeName(type: string, t: (key: string) => string): string {\n  if (!type) return '';\n  // Full text names (from main nozzle info)\n  if (type.includes('hardened')) return t('printers.nozzleHardenedSteel');\n  if (type.includes('stainless')) return t('printers.nozzleStainlessSteel');\n  if (type.includes('tungsten')) return t('printers.nozzleTungstenCarbide');\n  // 4-char codes (e.g. \"HS01\"): last 2 digits = material\n  if (type.length >= 4) {\n    const material = type.slice(2, 4);\n    if (material === '00') return t('printers.nozzleStainlessSteel');\n    if (material === '01') return t('printers.nozzleHardenedSteel');\n    if (material === '05') return t('printers.nozzleTungstenCarbide');\n  }\n  // 2-digit numeric codes\n  if (type === '00') return t('printers.nozzleStainlessSteel');\n  if (type === '01') return t('printers.nozzleHardenedSteel');\n  if (type === '05') return t('printers.nozzleTungstenCarbide');\n  // 2-char alpha codes: H prefix = hardened steel\n  if (type.startsWith('H')) return t('printers.nozzleHardenedSteel');\n  return type;\n}\n\n// Parse flow type from nozzle type code\n// HH = high flow, HS = standard/normal\nfunction nozzleFlowName(type: string, t: (key: string) => string): string {\n  if (!type) return '';\n  if (type.startsWith('HH')) return t('printers.nozzleHighFlow');\n  if (type.startsWith('HS')) return t('printers.nozzleStandardFlow');\n  return '';\n}\n\n// Per-slot hover card for nozzle rack\n// activeStatus: when true, show \"Active\" instead of \"Mounted\"/\"Docked\" (for hotend nozzles)\nfunction NozzleSlotHoverCard({ slot, index, activeStatus, filamentName, children }: {\n  slot: import('../api/client').NozzleRackSlot;\n  index: number;\n  activeStatus?: boolean;\n  filamentName?: string;\n  children: React.ReactNode;\n}) {\n  const { t } = useTranslation();\n  const [isVisible, setIsVisible] = useState(false);\n  const [position, setPosition] = useState<'top' | 'bottom'>('top');\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const cardRef = useRef<HTMLDivElement>(null);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;\n  const isMounted = slot.stat === 1;\n\n  useEffect(() => {\n    if (isVisible && triggerRef.current && cardRef.current) {\n      const triggerRect = triggerRef.current.getBoundingClientRect();\n      const cardHeight = cardRef.current.offsetHeight;\n      const headerHeight = 56;\n      const spaceAbove = triggerRect.top - headerHeight;\n      const spaceBelow = window.innerHeight - triggerRect.bottom;\n      if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {\n        setPosition('bottom');\n      } else {\n        setPosition('top');\n      }\n    }\n  }, [isVisible]);\n\n  const handleMouseEnter = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);\n  };\n\n  const handleMouseLeave = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);\n  };\n\n  useEffect(() => {\n    return () => {\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    };\n  }, []);\n\n  const filamentCss = parseFilamentColor(slot.filament_color);\n  const typeFull = nozzleTypeName(slot.nozzle_type, t);\n  const flowFull = nozzleFlowName(slot.nozzle_type, t);\n\n  return (\n    <div\n      ref={triggerRef}\n      className=\"relative\"\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n    >\n      {children}\n\n      {isVisible && (\n        <div\n          ref={cardRef}\n          className={`\n            absolute left-1/2 -translate-x-1/2 z-50\n            ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}\n            animate-in fade-in-0 zoom-in-95 duration-150\n          `}\n          style={{ maxWidth: 'calc(100vw - 24px)' }}\n        >\n          <div className=\"w-44 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm\">\n            {isEmpty ? (\n              <div className=\"px-3 py-2 text-xs text-bambu-gray text-center whitespace-nowrap\">\n                Slot {index + 1} — Empty\n              </div>\n            ) : (\n              <div className=\"p-2.5 space-y-1.5\">\n                {/* Diameter */}\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{t('printers.nozzleDiameter')}</span>\n                  <span className=\"text-xs text-white font-semibold\">{slot.nozzle_diameter} mm</span>\n                </div>\n\n                {/* Type */}\n                {typeFull && (\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{t('printers.nozzleType')}</span>\n                    <span className=\"text-xs text-white font-semibold truncate max-w-[100px]\">{typeFull}</span>\n                  </div>\n                )}\n\n                {/* Flow (hide if empty) */}\n                {flowFull && (\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{t('printers.nozzleFlow')}</span>\n                    <span className=\"text-xs text-white font-semibold\">{flowFull}</span>\n                  </div>\n                )}\n\n                {/* Status badge */}\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{t('printers.nozzleStatus')}</span>\n                  <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${\n                    activeStatus || isMounted\n                      ? 'bg-green-900/50 text-green-400'\n                      : 'bg-bambu-dark-tertiary text-bambu-gray'\n                  }`}>\n                    {activeStatus ? t('printers.nozzleActive') : isMounted ? t('printers.nozzleMounted') : t('printers.nozzleDocked')}\n                  </span>\n                </div>\n\n                {/* Wear (hide if null) */}\n                {slot.wear != null && (\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{t('printers.nozzleWear')}</span>\n                    <span className=\"text-xs text-white font-semibold\">{slot.wear}%</span>\n                  </div>\n                )}\n\n                {/* Max Temp (hide if 0) */}\n                {slot.max_temp > 0 && (\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{t('printers.nozzleMaxTemp')}</span>\n                    <span className=\"text-xs text-white font-semibold\">{slot.max_temp}°C</span>\n                  </div>\n                )}\n\n                {/* Serial (hide if empty) */}\n                {slot.serial_number && (\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{t('printers.nozzleSerial')}</span>\n                    <span className=\"text-[10px] text-white font-mono truncate max-w-[80px]\">{slot.serial_number}</span>\n                  </div>\n                )}\n\n                {/* Filament: material type + color swatch (hide if no color) */}\n                {(filamentCss || slot.filament_type) && (\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{t('printers.nozzleFilament')}</span>\n                    <div className=\"flex items-center gap-1\">\n                      {filamentCss && (\n                        <div className=\"w-3 h-3 rounded-sm border border-white/20\" style={{ backgroundColor: filamentCss }} />\n                      )}\n                      <span className=\"text-[10px] text-white font-semibold truncate max-w-[100px]\">{filamentName || slot.filament_type || slot.filament_id || ''}</span>\n                    </div>\n                  </div>\n                )}\n              </div>\n            )}\n          </div>\n\n          {/* Arrow pointer */}\n          <div\n            className={`\n              absolute left-1/2 -translate-x-1/2 w-0 h-0\n              border-l-[6px] border-l-transparent\n              border-r-[6px] border-r-transparent\n              ${position === 'top'\n                ? 'top-full border-t-[6px] border-t-bambu-dark-tertiary'\n                : 'bottom-full border-b-[6px] border-b-bambu-dark-tertiary'}\n            `}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n\n// Dual-nozzle hover card showing L and R nozzle details side by side\nfunction DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, filamentInfo, children }: {\n  leftSlot?: import('../api/client').NozzleRackSlot;\n  rightSlot?: import('../api/client').NozzleRackSlot;\n  activeNozzle: 'L' | 'R';\n  filamentInfo?: Record<string, { name: string; k: number | null }>;\n  children: React.ReactNode;\n}) {\n  const { t } = useTranslation();\n  const [isVisible, setIsVisible] = useState(false);\n  const [position, setPosition] = useState<'top' | 'bottom'>('top');\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const cardRef = useRef<HTMLDivElement>(null);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => {\n    if (isVisible && triggerRef.current && cardRef.current) {\n      const triggerRect = triggerRef.current.getBoundingClientRect();\n      const cardHeight = cardRef.current.offsetHeight;\n      const headerHeight = 56;\n      const spaceAbove = triggerRect.top - headerHeight;\n      const spaceBelow = window.innerHeight - triggerRect.bottom;\n      if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {\n        setPosition('bottom');\n      } else {\n        setPosition('top');\n      }\n    }\n  }, [isVisible]);\n\n  const handleMouseEnter = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);\n  };\n\n  const handleMouseLeave = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);\n  };\n\n  useEffect(() => {\n    return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };\n  }, []);\n\n  if (!leftSlot && !rightSlot) return <>{children}</>;\n\n  const renderColumn = (slot: import('../api/client').NozzleRackSlot, side: 'L' | 'R') => {\n    const isActive = activeNozzle === side;\n    const typeFull = nozzleTypeName(slot.nozzle_type, t);\n    const flowFull = nozzleFlowName(slot.nozzle_type, t);\n    const filamentCss = parseFilamentColor(slot.filament_color);\n    const filamentName = slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined;\n    return (\n      <div className=\"flex-1 space-y-1.5\">\n        <div className={`text-[10px] font-bold pb-1 border-b border-bambu-dark-tertiary/50 ${isActive ? 'text-amber-400' : 'text-bambu-gray'}`}>\n          {side === 'L' ? t('common.left') : t('common.right')}\n        </div>\n        {slot.nozzle_diameter && (\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-[10px] text-bambu-gray\">{t('printers.nozzleDiameter')}</span>\n            <span className=\"text-xs text-white font-semibold\">{slot.nozzle_diameter} mm</span>\n          </div>\n        )}\n        {typeFull && (\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-[10px] text-bambu-gray\">{t('printers.nozzleType')}</span>\n            <span className=\"text-[10px] text-white font-semibold\">{typeFull}</span>\n          </div>\n        )}\n        {flowFull && (\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-[10px] text-bambu-gray\">{t('printers.nozzleFlow')}</span>\n            <span className=\"text-[10px] text-white font-semibold\">{flowFull}</span>\n          </div>\n        )}\n        <div className=\"flex items-center justify-between\">\n          <span className=\"text-[10px] text-bambu-gray\">{t('printers.nozzleStatus')}</span>\n          <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${\n            isActive\n              ? 'bg-green-900/50 text-green-400'\n              : 'bg-bambu-dark-tertiary text-bambu-gray'\n          }`}>\n            {isActive ? t('printers.nozzleActive') : t('printers.nozzleIdle')}\n          </span>\n        </div>\n        {slot.wear != null && (\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-[10px] text-bambu-gray\">{t('printers.nozzleWear')}</span>\n            <span className=\"text-xs text-white font-semibold\">{slot.wear}%</span>\n          </div>\n        )}\n        {/* Serial and max temp only available on the right (removable) nozzle */}\n        {side === 'R' && slot.max_temp > 0 && (\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-[10px] text-bambu-gray\">{t('printers.nozzleMaxTemp')}</span>\n            <span className=\"text-xs text-white font-semibold\">{slot.max_temp}°C</span>\n          </div>\n        )}\n        {side === 'R' && slot.serial_number && (\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-[10px] text-bambu-gray\">{t('printers.nozzleSerial')}</span>\n            <span className=\"text-[10px] text-white font-mono\">{slot.serial_number}</span>\n          </div>\n        )}\n        {(filamentCss || slot.filament_type || slot.filament_id) && (\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-[10px] text-bambu-gray\">{t('printers.nozzleFilament')}</span>\n            <div className=\"flex items-center gap-1\">\n              {filamentCss && (\n                <div className=\"w-3 h-3 rounded-sm border border-white/20\" style={{ backgroundColor: filamentCss }} />\n              )}\n              <span className=\"text-[10px] text-white font-semibold truncate max-w-[100px]\">\n                {filamentName || slot.filament_type || slot.filament_id || ''}\n              </span>\n            </div>\n          </div>\n        )}\n      </div>\n    );\n  };\n\n  return (\n    <div\n      ref={triggerRef}\n      className=\"relative flex-1\"\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n    >\n      {children}\n\n      {isVisible && (\n        <div\n          ref={cardRef}\n          className={`\n            absolute left-1/2 -translate-x-1/2 z-50\n            ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}\n            animate-in fade-in-0 zoom-in-95 duration-150\n          `}\n          style={{ maxWidth: 'calc(100vw - 24px)' }}\n        >\n          <div className=\"w-96 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm\">\n            <div className=\"p-2.5 flex gap-3\">\n              {leftSlot && renderColumn(leftSlot, 'L')}\n              {leftSlot && rightSlot && <div className=\"w-px bg-bambu-dark-tertiary/50\" />}\n              {rightSlot && renderColumn(rightSlot, 'R')}\n            </div>\n          </div>\n\n          {/* Arrow pointer */}\n          <div\n            className={`\n              absolute left-1/2 -translate-x-1/2 w-0 h-0\n              border-l-[6px] border-l-transparent\n              border-r-[6px] border-r-transparent\n              ${position === 'top'\n                ? 'top-full border-t-[6px] border-t-bambu-dark-tertiary'\n                : 'bottom-full border-b-[6px] border-b-bambu-dark-tertiary'}\n            `}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n\n// H2C Nozzle Rack Card — compact single row showing 6-position tool-changer dock\nfunction NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client').NozzleRackSlot[]; filamentInfo?: Record<string, { name: string; k: number | null }> }) {\n  const { t } = useTranslation();\n  // Rack nozzles only (IDs >= 2) — excludes L/R hotend nozzles (IDs 0, 1).\n  // H2C rack slot IDs are fixed at 16..21. When a nozzle is picked up into the\n  // hotend the firmware omits that rack ID entirely, so we must map by the fixed\n  // base — computing it from min(present IDs) shifts everything left when slot 16\n  // is the one currently mounted (#943).\n  const rackNozzles = slots.filter(s => s.id >= 2);\n  const RACK_SIZE = 6;\n  const RACK_BASE_ID = 16;\n  const rackSlots: (import('../api/client').NozzleRackSlot)[] = Array.from(\n    { length: RACK_SIZE },\n    (_, i) => rackNozzles.find(s => s.id === RACK_BASE_ID + i) ?? {\n      id: -(i + 1), nozzle_type: '', nozzle_diameter: '', wear: null, stat: null,\n      max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '',\n    },\n  );\n\n  return (\n    <div className=\"text-center px-2.5 py-1.5 bg-bambu-dark rounded-lg flex-[2_1_190px] flex flex-col justify-center\">\n      <p className=\"text-[9px] text-bambu-gray mb-1\">{t('printers.nozzleRack')}</p>\n      <div className=\"flex gap-[3px] justify-center\">\n        {rackSlots.map((slot, i) => {\n          const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;\n          const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;\n          const lightBg = filamentBg ? isLightColor(slot.filament_color) : false;\n\n          return (\n            <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>\n              <div\n                className={`w-7 h-7 rounded flex items-center justify-center cursor-default transition-colors border-b-2 ${\n                  isEmpty\n                    ? 'bg-bambu-dark-tertiary/20 border-bambu-dark-tertiary/20'\n                    : 'bg-bambu-dark-tertiary/40 border-bambu-dark-tertiary/40'\n                }`}\n                style={filamentBg ? { backgroundColor: filamentBg } : undefined}\n              >\n                <span className={`text-[10px] font-semibold ${isEmpty ? 'text-bambu-gray/30' : lightBg ? 'text-black/80' : 'text-white'}`}\n                      style={filamentBg && !lightBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}\n                >\n                  {isEmpty ? '—' : (slot.nozzle_diameter || '?')}\n                </span>\n              </div>\n            </NozzleSlotHoverCard>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n// Water drop SVG - empty outline (Bambu Lab style from bambu-humidity)\nfunction WaterDropEmpty({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 36 54\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M17.8131 0.00538C18.4463 -0.15091 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00538ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z\" fill=\"#C3C2C1\"/>\n    </svg>\n  );\n}\n\n// Water drop SVG - half filled with blue water (Bambu Lab style from bambu-humidity)\nfunction WaterDropHalf({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 35 53\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M17.3165 0.0038C17.932 -0.14959 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.0038ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z\" fill=\"#C3C2C1\"/>\n      <path d=\"M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z\" fill=\"#1F8FEB\"/>\n    </svg>\n  );\n}\n\n// Water drop SVG - fully filled with blue water (Bambu Lab style from bambu-humidity)\nfunction WaterDropFull({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 36 54\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z\" fill=\"#1F8FEB\"/>\n      <path d=\"M17.7948 0.00538C18.4273 -0.15091 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0134 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00538ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z\" fill=\"#C3C2C1\"/>\n    </svg>\n  );\n}\n\n// Thermometer SVG - empty outline\nfunction ThermometerEmpty({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 12 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\" stroke=\"#C3C2C1\" strokeWidth=\"1\" fill=\"none\"/>\n      <circle cx=\"6\" cy=\"15\" r=\"2.5\" stroke=\"#C3C2C1\" strokeWidth=\"1\" fill=\"none\"/>\n    </svg>\n  );\n}\n\n// Thermometer SVG - half filled (gold - same as humidity fair)\nfunction ThermometerHalf({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 12 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <rect x=\"4.5\" y=\"8\" width=\"3\" height=\"4.5\" fill=\"#d4a017\" rx=\"0.5\"/>\n      <circle cx=\"6\" cy=\"15\" r=\"2\" fill=\"#d4a017\"/>\n      <path d=\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\" stroke=\"#C3C2C1\" strokeWidth=\"1\" fill=\"none\"/>\n    </svg>\n  );\n}\n\n// Thermometer SVG - fully filled (red - same as humidity bad)\nfunction ThermometerFull({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 12 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <rect x=\"4.5\" y=\"3\" width=\"3\" height=\"9.5\" fill=\"#c62828\" rx=\"0.5\"/>\n      <circle cx=\"6\" cy=\"15\" r=\"2\" fill=\"#c62828\"/>\n      <path d=\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\" stroke=\"#C3C2C1\" strokeWidth=\"1\" fill=\"none\"/>\n    </svg>\n  );\n}\n\n// Heater thermometer icon - filled when heating, outline when off\ninterface HeaterThermometerProps {\n  className?: string;\n  color: string;  // The color class (e.g., \"text-orange-400\")\n  isHeating: boolean;\n}\n\nfunction HeaterThermometer({ className, color, isHeating }: HeaterThermometerProps) {\n  // Extract the actual color from Tailwind class for SVG fill\n  const colorMap: Record<string, string> = {\n    'text-orange-400': '#fb923c',\n    'text-blue-400': '#60a5fa',\n    'text-green-400': '#4ade80',\n  };\n  const fillColor = colorMap[color] || '#888';\n\n  // Glow style when heating\n  const glowStyle = isHeating ? {\n    filter: `drop-shadow(0 0 4px ${fillColor}) drop-shadow(0 0 8px ${fillColor})`,\n  } : {};\n\n  if (isHeating) {\n    // Filled thermometer with glow - heater is ON\n    return (\n      <svg className={className} style={glowStyle} viewBox=\"0 0 12 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect x=\"4.5\" y=\"3\" width=\"3\" height=\"9.5\" fill={fillColor} rx=\"0.5\"/>\n        <circle cx=\"6\" cy=\"15\" r=\"2\" fill={fillColor}/>\n        <path d=\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\" stroke={fillColor} strokeWidth=\"1\" fill=\"none\"/>\n      </svg>\n    );\n  }\n\n  // Empty thermometer - heater is OFF\n  return (\n    <svg className={className} viewBox=\"0 0 12 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path d=\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\" stroke={fillColor} strokeWidth=\"1\" fill=\"none\"/>\n      <circle cx=\"6\" cy=\"15\" r=\"2.5\" stroke={fillColor} strokeWidth=\"1\" fill=\"none\"/>\n    </svg>\n  );\n}\n\n// Humidity indicator with water drop that fills based on level (Bambu Lab style)\n// Reference: https://github.com/theicedmango/bambu-humidity\ninterface HumidityIndicatorProps {\n  humidity: number | string;\n  goodThreshold?: number;  // <= this is green\n  fairThreshold?: number;  // <= this is orange, > is red\n  onClick?: () => void;\n  compact?: boolean;  // Smaller version for grid layout\n}\n\nfunction HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick, compact }: HumidityIndicatorProps) {\n  const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;\n  const good = typeof goodThreshold === 'number' ? goodThreshold : 40;\n  const fair = typeof fairThreshold === 'number' ? fairThreshold : 60;\n\n  // Status thresholds (configurable via settings)\n  // Good: ≤goodThreshold (green #22a352), Fair: ≤fairThreshold (gold #d4a017), Bad: >fairThreshold (red #c62828)\n  let textColor: string;\n  let statusText: string;\n\n  if (isNaN(humidityValue)) {\n    textColor = '#C3C2C1';\n    statusText = 'Unknown';\n  } else if (humidityValue <= good) {\n    textColor = '#22a352'; // Green - Good\n    statusText = 'Good';\n  } else if (humidityValue <= fair) {\n    textColor = '#d4a017'; // Gold - Fair\n    statusText = 'Fair';\n  } else {\n    textColor = '#c62828'; // Red - Bad\n    statusText = 'Bad';\n  }\n\n  // Fill level based on status: Good=Empty (dry), Fair=Half, Bad=Full (wet)\n  let DropComponent: React.FC<{ className?: string }>;\n  if (isNaN(humidityValue)) {\n    DropComponent = WaterDropEmpty;\n  } else if (humidityValue <= good) {\n    DropComponent = WaterDropEmpty; // Good - empty drop (dry)\n  } else if (humidityValue <= fair) {\n    DropComponent = WaterDropHalf; // Fair - half filled\n  } else {\n    DropComponent = WaterDropFull; // Bad - full (too humid)\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={`flex items-center gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}\n      title={`Humidity: ${humidityValue}% - ${statusText}${onClick ? ' (click for history)' : ''}`}\n    >\n      <DropComponent className={compact ? \"w-2.5 h-3\" : \"w-3 h-4\"} />\n      <span className={`font-medium tabular-nums ${compact ? 'text-[10px]' : 'text-xs'}`} style={{ color: textColor }}>{humidityValue}%</span>\n    </button>\n  );\n}\n\n// Temperature indicator with dynamic icon and coloring\ninterface TemperatureIndicatorProps {\n  temp: number;\n  goodThreshold?: number;  // <= this is blue\n  fairThreshold?: number;  // <= this is orange, > is red\n  onClick?: () => void;\n  compact?: boolean;  // Smaller version for grid layout\n}\n\nfunction TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick, compact }: TemperatureIndicatorProps) {\n  // Ensure thresholds are numbers\n  const good = typeof goodThreshold === 'number' ? goodThreshold : 28;\n  const fair = typeof fairThreshold === 'number' ? fairThreshold : 35;\n\n  let textColor: string;\n  let statusText: string;\n  let ThermoComponent: React.FC<{ className?: string }>;\n\n  if (temp <= good) {\n    textColor = '#22a352'; // Green - good (same as humidity)\n    statusText = 'Good';\n    ThermoComponent = ThermometerEmpty;\n  } else if (temp <= fair) {\n    textColor = '#d4a017'; // Gold - fair (same as humidity)\n    statusText = 'Fair';\n    ThermoComponent = ThermometerHalf;\n  } else {\n    textColor = '#c62828'; // Red - bad (same as humidity)\n    statusText = 'Bad';\n    ThermoComponent = ThermometerFull;\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={`flex items-center gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}\n      title={`Temperature: ${temp}°C - ${statusText}${onClick ? ' (click for history)' : ''}`}\n    >\n      <ThermoComponent className={compact ? \"w-2.5 h-3\" : \"w-3 h-4\"} />\n      <span className={`tabular-nums text-right ${compact ? 'text-[10px] w-8' : 'w-12'}`} style={{ color: textColor }}>{temp}°C</span>\n    </button>\n  );\n}\n\n// Get AMS label: AMS-A/B/C/D for regular AMS, HT-A/B for AMS-HT (single spool)\n// Always use tray count as the source of truth (1 tray = AMS-HT, 4 trays = regular AMS)\n// AMS-HT uses IDs 128+ while regular AMS uses 0-3\nfunction getAmsLabel(amsId: number | string, trayCount: number): string {\n  // Ensure amsId is a number (backend might send string)\n  const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;\n  const safeId = isNaN(id) ? 0 : id;\n  const isHt = trayCount === 1;\n  // AMS-HT uses IDs starting at 128, regular AMS uses 0-3\n  const normalizedId = safeId >= 128 ? safeId - 128 : safeId;\n  const letter = String.fromCharCode(65 + normalizedId); // 0=A, 1=B, 2=C, 3=D\n  return isHt ? `HT-${letter}` : `AMS-${letter}`;\n}\n\n\n/**\n * Check if a tray contains a Bambu Lab spool (RFID-tagged).\n * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx,\n * which is a filament profile/preset ID that third-party spools also get when\n * the user selects a generic Bambu preset (e.g. \"GFA00\" for Generic PLA).\n */\nfunction isBambuLabSpool(tray: {\n  tray_uuid?: string | null;\n  tag_uid?: string | null;\n} | null | undefined): boolean {\n  if (!tray) return false;\n\n  // Check tray_uuid (32 hex chars, non-zero)\n  if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') {\n    return true;\n  }\n\n  // Check tag_uid (16 hex chars, non-zero)\n  if (tray.tag_uid && tray.tag_uid !== '0000000000000000') {\n    return true;\n  }\n\n  return false;\n}\n\n\nfunction CoverImage({ url, printName }: { url: string | null; printName?: string }) {\n  const { t } = useTranslation();\n  const [loaded, setLoaded] = useState(false);\n  const [error, setError] = useState(false);\n  const [showOverlay, setShowOverlay] = useState(false);\n\n  // Cache-bust the image URL when the print name changes so the browser\n  // fetches the new cover instead of serving the stale cached image.\n  const cacheBustedUrl = useMemo(() => {\n    if (!url) return null;\n    const sep = url.includes('?') ? '&' : '?';\n    return withStreamToken(`${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`);\n  }, [url, printName]);\n\n  // Reset loaded/error state when the image URL changes\n  useEffect(() => {\n    setLoaded(false);\n    setError(false);\n  }, [cacheBustedUrl]);\n\n  return (\n    <>\n      <div\n        className={`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${cacheBustedUrl && loaded ? 'cursor-pointer' : ''}`}\n        onClick={() => cacheBustedUrl && loaded && setShowOverlay(true)}\n      >\n        {cacheBustedUrl && !error ? (\n          <>\n            <img\n              src={cacheBustedUrl}\n              alt={t('printers.printPreview')}\n              className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}\n              onLoad={() => setLoaded(true)}\n              onError={() => setError(true)}\n            />\n            {!loaded && <Box className=\"w-8 h-8 text-bambu-gray\" />}\n          </>\n        ) : (\n          <Box className=\"w-8 h-8 text-bambu-gray\" />\n        )}\n      </div>\n\n      {/* Cover Image Overlay */}\n      {showOverlay && cacheBustedUrl && (\n        <div\n          className=\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8\"\n          onClick={() => setShowOverlay(false)}\n        >\n          <div className=\"relative max-w-2xl max-h-full\">\n            <img\n              src={cacheBustedUrl}\n              alt={t('printers.printPreview')}\n              className=\"max-w-full max-h-[80vh] rounded-lg shadow-2xl\"\n            />\n            {printName && (\n              <p className=\"text-white text-center mt-4 text-lg\">{printName}</p>\n            )}\n          </div>\n        </div>\n      )}\n    </>\n  );\n}\n\ninterface PrinterMaintenanceInfo {\n  due_count: number;\n  warning_count: number;\n  total_print_hours: number;\n}\n\n// Status summary bar component - uses queryClient to read cached statuses\nfunction StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n\n  // Subscribe to query cache changes to re-render when status updates\n  // Throttled to prevent rapid re-renders from causing tab crashes\n  const [cacheTick, setCacheTick] = useState(0);\n  useEffect(() => {\n    let pending = false;\n    const unsubscribe = queryClient.getQueryCache().subscribe(() => {\n      if (!pending) {\n        pending = true;\n        requestAnimationFrame(() => {\n          setCacheTick(t => t + 1);\n          pending = false;\n        });\n      }\n    });\n    return () => unsubscribe();\n  }, [queryClient]);\n\n  const { counts, nextFinish } = useMemo(() => {\n    let printing = 0;\n    let paused = 0;\n    let finished = 0;\n    let idle = 0;\n    let offline = 0;\n    let loading = 0;\n    let error = 0;\n    let nextPrinterName: string | null = null;\n    let nextRemainingMin: number | null = null;\n    let nextProgress: number = 0;\n\n    printers?.forEach((printer) => {\n      const status = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null; progress: number | null; hms_errors?: HMSError[] }>(['printerStatus', printer.id]);\n      if (status === undefined) {\n        // Status not yet loaded - don't count as offline yet\n        loading++;\n      } else if (!status.connected) {\n        offline++;\n      } else {\n        // Count printers with HMS errors\n        if (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0) {\n          error++;\n        }\n        switch (status.state) {\n          case 'RUNNING':\n            printing++;\n            if (status.remaining_time != null && status.remaining_time > 0) {\n              if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) {\n                nextRemainingMin = status.remaining_time;\n                nextPrinterName = printer.name;\n                nextProgress = status.progress || 0;\n              }\n            }\n            break;\n          case 'PAUSE':\n            paused++;\n            break;\n          case 'FINISH':\n            finished++;\n            break;\n          case 'FAILED':\n            error++;\n            break;\n          default:\n            idle++;\n            break;\n        }\n      }\n    });\n\n    return {\n      counts: { printing, paused, finished, idle, offline, loading, error, total: (printers?.length || 0) },\n      nextFinish: nextPrinterName && nextRemainingMin ? { name: nextPrinterName, remainingMin: nextRemainingMin, progress: nextProgress } : null,\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [printers, queryClient, cacheTick]);\n\n  if (!printers?.length) return null;\n\n  const badges: { count: number; dot: string; label: string }[] = [\n    { count: counts.printing, dot: 'bg-bambu-green animate-pulse', label: t('printers.status.printing').toLowerCase() },\n    { count: counts.paused, dot: 'bg-status-warning', label: t('printers.status.paused', 'paused').toLowerCase() },\n    { count: counts.finished, dot: 'bg-blue-400', label: t('printers.status.finished', 'finished').toLowerCase() },\n    { count: counts.idle, dot: counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500', label: t('printers.status.available').toLowerCase() },\n    { count: counts.error, dot: 'bg-status-error', label: t('printers.status.problem').toLowerCase() },\n    { count: counts.offline, dot: 'bg-gray-400', label: t('printers.status.offline').toLowerCase() },\n  ];\n\n  return (\n    <div className=\"flex flex-wrap items-center gap-4 gap-y-2 text-sm\">\n      {badges.map(({ count, dot, label }) => count > 0 && (\n        <div key={label} className=\"flex items-center gap-1.5\">\n          <div className={`w-2 h-2 rounded-full ${dot}`} />\n          <span className=\"text-bambu-gray\">\n            <span className=\"text-white font-medium\">{count}</span> {label}\n          </span>\n        </div>\n      ))}\n      {nextFinish && (\n        <>\n          <div className=\"w-px h-4 bg-bambu-dark-tertiary\" />\n          <div className=\"flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2\">\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-bambu-green font-medium\">{t('printers.nextAvailable')}:</span>\n              <span className=\"text-white font-medium\">{nextFinish.name}</span>\n            </div>\n            <div className=\"flex items-center gap-2 w-full sm:w-auto\">\n              <div className=\"w-full sm:w-16 bg-bambu-dark-tertiary rounded-full h-1.5\">\n                <div\n                  className=\"bg-bambu-green h-1.5 rounded-full transition-all\"\n                  style={{ width: `${nextFinish.progress}%` }}\n                />\n              </div>\n              <span className=\"text-white font-medium\">{Math.round(nextFinish.progress)}%</span>\n              <span className=\"text-bambu-gray\">({formatDuration(nextFinish.remainingMin * 60)})</span>\n            </div>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\ntype SortOption = 'name' | 'status' | 'model' | 'location';\ntype ViewMode = 'expanded' | 'compact';\n\nconst STATUS_GROUP_ORDER: string[] = ['error', 'printing', 'paused', 'finished', 'idle', 'offline'];\n\nconst STATUS_GROUP_META: Record<string, { labelKey: string; dot: string }> = {\n  error:    { labelKey: 'printers.status.problem',   dot: 'bg-status-error' },\n  printing: { labelKey: 'printers.status.printing',  dot: 'bg-bambu-green animate-pulse' },\n  paused:   { labelKey: 'printers.status.paused',    dot: 'bg-status-warning' },\n  finished: { labelKey: 'printers.status.finished',  dot: 'bg-blue-400' },\n  idle:     { labelKey: 'printers.status.idle',       dot: 'bg-bambu-green' },\n  offline:  { labelKey: 'printers.status.offline',   dot: 'bg-gray-400' },\n};\n\n/** Classify a printer into one of the UI status buckets. */\nfunction classifyPrinterStatus(\n  status: { connected: boolean; state: string | null; hms_errors?: HMSError[] } | undefined,\n): PrinterState {\n  if (!status?.connected) return 'offline';\n  const hmsErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : [];\n  if (hmsErrors.length > 0) return 'error';\n  switch (status.state) {\n    case 'RUNNING': return 'printing';\n    case 'PAUSE':   return 'paused';\n    case 'FINISH':  return 'finished';\n    case 'FAILED':  return 'error';\n    default:        return 'idle';\n  }\n}\n\n/**\n * Get human-readable status display text for a printer.\n * Uses stg_cur_name for detailed calibration/preparation stages,\n * otherwise formats the gcode_state nicely.\n */\nfunction getStatusDisplay(state: string | null | undefined, stg_cur_name: string | null | undefined): string {\n  // If we have a specific stage name (calibration, heating, etc.), use it\n  if (stg_cur_name) {\n    return stg_cur_name;\n  }\n\n  // Format the gcode_state nicely\n  switch (state) {\n    case 'RUNNING':\n      return 'Printing';\n    case 'PAUSE':\n      return 'Paused';\n    case 'FINISH':\n      return 'Finished';\n    case 'FAILED':\n      return 'Failed';\n    case 'IDLE':\n      return 'Idle';\n    default:\n      return state ? state.charAt(0) + state.slice(1).toLowerCase() : 'Idle';\n  }\n}\n\n// Map SSDP model codes to display names\nfunction mapModelCode(ssdpModel: string | null): string {\n  if (!ssdpModel) return '';\n  const modelMap: Record<string, string> = {\n    // H2 Series\n    'O1D': 'H2D',\n    'O1E': 'H2D Pro',\n    'O2D': 'H2D Pro',\n    'O1C': 'H2C',\n    'O1C2': 'H2C',\n    'O1S': 'H2S',\n    // X1 Series\n    'BL-P001': 'X1C',\n    'BL-P002': 'X1',\n    'BL-P003': 'X1E',\n    // X2 Series\n    'N6': 'X2D',\n    // P Series\n    'C11': 'P1S',\n    'C12': 'P1P',\n    'C13': 'P2S',\n    // A1 Series\n    'N2S': 'A1',\n    'N1': 'A1 Mini',\n    // Direct matches\n    'X1C': 'X1C',\n    'X1': 'X1',\n    'X1E': 'X1E',\n    'X2D': 'X2D',\n    'P1S': 'P1S',\n    'P1P': 'P1P',\n    'P2S': 'P2S',\n    'A1': 'A1',\n    'A1 Mini': 'A1 Mini',\n    'H2D': 'H2D',\n    'H2D Pro': 'H2D Pro',\n    'H2C': 'H2C',\n    'H2S': 'H2S',\n  };\n  return modelMap[ssdpModel] || ssdpModel;\n}\n\n// ─── AMS Name Hover Card ──────────────────────────────────────────────────────\n// Wraps the AMS label (e.g. \"AMS-A\") and shows a popup with:\n//  • User-defined friendly name (editable, protected by printers:update)\n//  • AMS serial number\n//  • AMS firmware version\nexport function AmsNameHoverCard({\n  ams,\n  printerId,\n  label,\n  amsLabels,\n  canEdit,\n  onSaved,\n  children,\n}: {\n  ams: import('../api/client').AMSUnit;\n  printerId: number;\n  label: string;           // auto-generated label, e.g. \"AMS-A\"\n  amsLabels?: Record<number, string>;\n  canEdit: boolean;\n  onSaved: () => void;\n  children: React.ReactNode;\n}) {\n  const { t } = useTranslation();\n  const [isVisible, setIsVisible] = useState(false);\n  const [position, setPosition] = useState<'top' | 'bottom'>('top');\n  const [editValue, setEditValue] = useState('');\n  const [isSaving, setIsSaving] = useState(false);\n  const [saveError, setSaveError] = useState<string | null>(null);\n  const [isInputFocused, setIsInputFocused] = useState(false);\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const cardRef = useRef<HTMLDivElement>(null);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => {\n    if (isVisible) {\n      setEditValue(amsLabels?.[ams.id] ?? '');\n      setSaveError(null);\n      requestAnimationFrame(() => {\n        if (triggerRef.current && cardRef.current) {\n          const rect = triggerRef.current.getBoundingClientRect();\n          const spaceAbove = rect.top - 56;\n          const spaceBelow = window.innerHeight - rect.bottom;\n          setPosition(spaceAbove < cardRef.current.offsetHeight + 12 && spaceBelow > spaceAbove ? 'bottom' : 'top');\n        }\n      });\n    }\n  }, [isVisible, amsLabels, ams.id]);\n\n  const handleMouseEnter = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);\n  };\n  const handleMouseLeave = () => {\n    if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    if (!isInputFocused) {\n      timeoutRef.current = setTimeout(() => setIsVisible(false), 200);\n    }\n  };\n  useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);\n\n  const handleSave = async () => {\n    if (!canEdit) return;\n    setIsSaving(true);\n    setSaveError(null);\n    try {\n      const trimmed = editValue.trim();\n      if (trimmed) {\n        await api.saveAmsLabel(printerId, ams.id, trimmed, ams.serial_number);\n      } else {\n        await api.deleteAmsLabel(printerId, ams.id, ams.serial_number);\n      }\n      onSaved();\n      setIsVisible(false);\n    } catch (err) {\n      setSaveError(err instanceof Error ? err.message : String(err));\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const handleClear = async () => {\n    if (!canEdit) return;\n    setIsSaving(true);\n    setSaveError(null);\n    try {\n      await api.deleteAmsLabel(printerId, ams.id, ams.serial_number);\n      onSaved();\n      setIsVisible(false);\n    } catch (err) {\n      setSaveError(err instanceof Error ? err.message : String(err));\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  return (\n    <div\n      ref={triggerRef}\n      className=\"relative inline-block\"\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n    >\n      {children}\n\n      {isVisible && (\n        <div\n          ref={cardRef}\n          className={`\n            absolute left-0 z-50\n            ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}\n            animate-in fade-in-0 zoom-in-95 duration-150\n          `}\n          style={{ maxWidth: 'calc(100vw - 24px)' }}\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n        >\n          <div className=\"w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm p-2.5 space-y-2\">\n            {/* AMS auto-label */}\n            <div className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">{label}</div>\n\n            {/* Serial number */}\n            <div className=\"flex items-center justify-between gap-2\">\n              <span className=\"text-[10px] tracking-wide text-bambu-gray font-medium shrink-0\">\n                {t('printers.amsPopup.serialNumber')}\n              </span>\n              <span className=\"text-[10px] text-white font-mono truncate\">{ams.serial_number || '—'}</span>\n            </div>\n\n            {/* Firmware version */}\n            <div className=\"flex items-center justify-between gap-2\">\n              <span className=\"text-[10px] tracking-wide text-bambu-gray font-medium shrink-0\">\n                {t('printers.amsPopup.firmwareVersion')}\n              </span>\n              <span className=\"text-[10px] text-white font-mono truncate\">{ams.sw_ver || '—'}</span>\n            </div>\n\n            {/* Divider */}\n            <div className=\"h-px bg-bambu-dark-tertiary/50\" />\n\n            {/* Friendly name editor */}\n            <div className=\"space-y-1\">\n              <span className=\"text-[10px] text-bambu-gray font-medium block\">\n                {t('printers.amsPopup.friendlyName')}\n              </span>\n              <input\n                type=\"text\"\n                value={editValue}\n                onChange={(e) => canEdit && setEditValue(e.target.value)}\n                onKeyDown={(e) => e.key === 'Enter' && handleSave()}\n                onFocus={() => setIsInputFocused(true)}\n                onBlur={() => {\n                  setIsInputFocused(false);\n                  if (timeoutRef.current) clearTimeout(timeoutRef.current);\n                    timeoutRef.current = setTimeout(() => setIsVisible(false), 200);\n                }}\n                placeholder={canEdit ? t('printers.amsPopup.friendlyNamePlaceholder') : (amsLabels?.[ams.id] || '—')}\n                disabled={!canEdit}\n                title={!canEdit ? t('printers.amsPopup.noEditPermission') : undefined}\n                className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-xs text-white placeholder-bambu-gray/60 focus:outline-none focus:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed\"\n                maxLength={100}\n              />\n              {canEdit && (\n                <div className=\"space-y-1\">\n                  {saveError && (\n                    <p className=\"text-[10px] text-red-400 break-words\">{saveError}</p>\n                  )}\n                  <div className=\"flex gap-1 justify-end\">\n                    <button\n                      onClick={handleSave}\n                      disabled={isSaving}\n                      className=\"px-2 py-0.5 text-[10px] bg-bambu-green text-white rounded hover:bg-bambu-green/80 disabled:opacity-50\"\n                    >\n                      {t('printers.amsPopup.save')}\n                    </button>\n                    {amsLabels?.[ams.id] && (\n                      <button\n                        onClick={handleClear}\n                        disabled={isSaving}\n                        className=\"px-2 py-0.5 text-[10px] bg-bambu-dark-tertiary text-bambu-gray rounded hover:bg-bambu-dark-tertiary/70 disabled:opacity-50\"\n                      >\n                        {t('printers.amsPopup.clear')}\n                      </button>\n                    )}\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\n\n// AMS drying presets from BambuStudio filament profiles (idle mode temps)\n// Format: { n3f temp, n3s temp, n3f hours, n3s hours }\nconst DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {\n  'PLA':   { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },\n  'PETG':  { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },\n  'TPU':   { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },\n  'ABS':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n  'ASA':   { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n  'PA':    { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },\n  'PC':    { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n  'PVA':   { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },\n};\n\nfunction PrinterCard({\n  printer,\n  hideIfDisconnected,\n  maintenanceInfo,\n  viewMode = 'expanded',\n  cardSize = 2,\n  amsThresholds,\n  spoolmanEnabled = false,\n  linkedSpools,\n  spoolmanUrl,\n  spoolmanSyncMode,\n  onGetAssignment,\n  onUnassignSpool,\n  timeFormat = 'system',\n  cameraViewMode = 'window',\n  onOpenEmbeddedCamera,\n  checkPrinterFirmware = true,\n  dryingPresets = DRYING_PRESETS,\n  requirePlateClear = false,\n  selectionMode = false,\n  isSelected = false,\n  onToggleSelect,\n}: {\n  printer: Printer;\n  hideIfDisconnected?: boolean;\n  maintenanceInfo?: PrinterMaintenanceInfo;\n  viewMode?: ViewMode;\n  cardSize?: number;\n  amsThresholds?: {\n    humidityGood: number;\n    humidityFair: number;\n    tempGood: number;\n    tempFair: number;\n  };\n  spoolmanEnabled?: boolean;\n  hasUnlinkedSpools?: boolean;\n  linkedSpools?: Record<string, LinkedSpoolInfo>;\n  spoolmanUrl?: string | null;\n  spoolmanSyncMode?: string | null;\n  spoolAssignments?: SpoolAssignment[];\n  onGetAssignment?: (printerId: number, amsId: number, trayId: number) => SpoolAssignment | undefined;\n  onUnassignSpool?: (printerId: number, amsId: number, trayId: number) => void;\n  timeFormat?: 'system' | '12h' | '24h';\n  cameraViewMode?: 'window' | 'embedded';\n  onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;\n  checkPrinterFirmware?: boolean;\n  dryingPresets?: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }>;\n  requirePlateClear?: boolean;\n  selectionMode?: boolean;\n  isSelected?: boolean;\n  onToggleSelect?: (id: number) => void;\n}) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const navigate = useNavigate();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  const [showMenu, setShowMenu] = useState(false);\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n  const [deleteArchives, setDeleteArchives] = useState(true);\n  const [showEditModal, setShowEditModal] = useState(false);\n  const [showFileManager, setShowFileManager] = useState(false);\n  const [showMQTTDebug, setShowMQTTDebug] = useState(false);\n  const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);\n  const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);\n  const [showHMSModal, setShowHMSModal] = useState(false);\n  const [showStopConfirm, setShowStopConfirm] = useState(false);\n  const [showPauseConfirm, setShowPauseConfirm] = useState(false);\n  const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);\n  const [showAirductMenu, setShowAirductMenu] = useState<number | null>(null);\n  const [showBedJogMenu, setShowBedJogMenu] = useState<number | null>(null);\n  const [bedJogStep, setBedJogStep] = useState<number>(10);\n  const [showNotHomedModal, setShowNotHomedModal] = useState<null | { distance: number }>(null);\n  const [showResumeConfirm, setShowResumeConfirm] = useState(false);\n  const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);\n  const [showUploadForPrint, setShowUploadForPrint] = useState(false);\n  const [showPrinterInfo, setShowPrinterInfo] = useState(false);\n  const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []);\n  const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null);\n  // AMS drying popover state: which AMS unit has the popover open\n  const [dryingPopoverAmsId, setDryingPopoverAmsId] = useState<number | null>(null);\n  const [dryingPopoverModuleType, setDryingPopoverModuleType] = useState<string>('n3f');\n  const [dryingFilament, setDryingFilament] = useState('PLA');\n  const [dryingTemp, setDryingTemp] = useState(50);\n  const [dryingDuration, setDryingDuration] = useState(4);\n  const [dryingRotateTray, setDryingRotateTray] = useState(false);\n  const [dryingPopoverPos, setDryingPopoverPos] = useState<{ top: number; left: number } | null>(null);\n  const [isDraggingFile, setIsDraggingFile] = useState(false);\n  const [isDropUploading, setIsDropUploading] = useState(false);\n  const dragCounterRef = useRef(0);\n  const [amsHistoryModal, setAmsHistoryModal] = useState<{\n    amsId: number;\n    amsLabel: string;\n    mode: 'humidity' | 'temperature';\n  } | null>(null);\n  const [linkSpoolModal, setLinkSpoolModal] = useState<{\n    tagUid: string;\n    trayUuid: string;\n    printerId: number;\n    amsId: number;\n    trayId: number;\n  } | null>(null);\n  const [assignSpoolModal, setAssignSpoolModal] = useState<{\n    printerId: number;\n    amsId: number;\n    trayId: number;\n    trayInfo: { type: string; color: string; location: string; material?: string; profile?: string };\n  } | null>(null);\n  const [configureSlotModal, setConfigureSlotModal] = useState<{\n    amsId: number;\n    trayId: number;\n    trayCount: number;\n    trayType?: string;\n    trayColor?: string;\n    traySubBrands?: string;\n    trayInfoIdx?: string;\n    extruderId?: number;\n    caliIdx?: number | null;\n    savedPresetId?: string;\n  } | null>(null);\n  const [showFirmwareModal, setShowFirmwareModal] = useState(false);\n  const [plateCheckResult, setPlateCheckResult] = useState<{\n    is_empty: boolean;\n    confidence: number;\n    difference_percent: number;\n    message: string;\n    debug_image_url?: string;\n    needs_calibration: boolean;\n    light_warning?: boolean;\n    reference_count?: number;\n    max_references?: number;\n    roi?: { x: number; y: number; w: number; h: number };\n  } | null>(null);\n  const [isCheckingPlate, setIsCheckingPlate] = useState(false);\n  const [isCalibrating, setIsCalibrating] = useState(false);\n  const [editingRoi, setEditingRoi] = useState<{ x: number; y: number; w: number; h: number } | null>(null);\n  const [isSavingRoi, setIsSavingRoi] = useState(false);\n  const [plateCheckLightWasOff, setPlateCheckLightWasOff] = useState(false);\n\n  const { data: status } = useQuery({\n    queryKey: ['printerStatus', printer.id],\n    queryFn: () => api.getPrinterStatus(printer.id),\n    refetchInterval: 30000, // Fallback polling, WebSocket handles real-time\n  });\n\n  // Check for firmware updates (cached for 5 minutes, can be disabled in settings)\n  const { data: firmwareInfo } = useQuery({\n    queryKey: ['firmwareUpdate', printer.id],\n    queryFn: () => firmwareApi.checkPrinterUpdate(printer.id),\n    staleTime: 5 * 60 * 1000,\n    refetchInterval: 5 * 60 * 1000,\n    enabled: checkPrinterFirmware && hasPermission('firmware:read'),\n  });\n\n  // Collect unique tray_info_idx values for cloud filament info lookup\n  const trayInfoIds = useMemo(() => {\n    const ids = new Set<string>();\n    if (status?.ams) {\n      for (const ams of status.ams) {\n        for (const tray of ams.tray || []) {\n          if (tray.tray_info_idx) {\n            ids.add(tray.tray_info_idx);\n          }\n        }\n      }\n    }\n    for (const vt of status?.vt_tray ?? []) {\n      if (vt.tray_info_idx) ids.add(vt.tray_info_idx);\n    }\n    if (status?.nozzle_rack) {\n      for (const slot of status.nozzle_rack) {\n        if (slot.filament_id) {\n          ids.add(slot.filament_id);\n        }\n      }\n    }\n    return Array.from(ids);\n  }, [status?.ams, status?.vt_tray, status?.nozzle_rack]);\n\n  // Collect loaded filament types for queue widget filtering\n  const loadedFilamentTypes = useMemo(() => {\n    const types = new Set<string>();\n    if (status?.ams) {\n      for (const ams of status.ams) {\n        for (const tray of ams.tray || []) {\n          if (tray.tray_type) types.add(tray.tray_type.toUpperCase());\n        }\n      }\n    }\n    for (const vt of status?.vt_tray ?? []) {\n      if (vt.tray_type) types.add(vt.tray_type.toUpperCase());\n    }\n    return types;\n  }, [status?.ams, status?.vt_tray]);\n\n  // Collect loaded filament type+color pairs for queue widget override matching\n  // Format: \"TYPE:rrggbb\" (e.g., \"PETG:ffffff\") — mirrors backend _count_override_color_matches()\n  const loadedFilaments = useMemo(() => {\n    const filaments = new Set<string>();\n    if (status?.ams) {\n      for (const ams of status.ams) {\n        for (const tray of ams.tray || []) {\n          if (tray.tray_type && tray.tray_color) {\n            const color = tray.tray_color.replace('#', '').toLowerCase().slice(0, 6);\n            filaments.add(`${tray.tray_type.toUpperCase()}:${color}`);\n          }\n        }\n      }\n    }\n    for (const vt of status?.vt_tray ?? []) {\n      if (vt.tray_type && vt.tray_color) {\n        const color = vt.tray_color.replace('#', '').toLowerCase().slice(0, 6);\n        filaments.add(`${vt.tray_type.toUpperCase()}:${color}`);\n      }\n    }\n    return filaments;\n  }, [status?.ams, status?.vt_tray]);\n\n  // Fetch cloud filament info for tooltips (name includes color, also has K value)\n  const { data: filamentInfo } = useQuery({\n    queryKey: ['filamentInfo', trayInfoIds],\n    queryFn: () => api.getFilamentInfo(trayInfoIds),\n    enabled: trayInfoIds.length > 0,\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n\n  // Fetch slot preset mappings (stores preset name for user-configured slots)\n  const { data: slotPresets } = useQuery({\n    queryKey: ['slotPresets', printer.id],\n    queryFn: () => api.getSlotPresets(printer.id),\n    staleTime: 2 * 60 * 1000, // 2 minutes\n  });\n\n  // Fetch plate list for the archive linked to the active print (#881 follow-up).\n  // Only queried when there's a running print backed by an archive; shared\n  // React Query cache with the Queue / Archives pages keeps it cheap.\n  const activeArchiveId =\n    (status?.state === 'RUNNING' || status?.state === 'PAUSE') ? status?.current_archive_id ?? null : null;\n  const { data: activeArchivePlates } = useQuery({\n    queryKey: ['archive-plates', activeArchiveId],\n    queryFn: () => api.getArchivePlates(activeArchiveId!),\n    enabled: activeArchiveId != null,\n    staleTime: 5 * 60 * 1000,\n  });\n  const activePlateLabel = (() => {\n    if (!activeArchivePlates?.is_multi_plate || status?.current_plate_id == null) return null;\n    const plate = activeArchivePlates.plates.find(p => p.index === status.current_plate_id);\n    return plate?.name || t('printers.plateNumber', 'Plate {{number}}', { number: status.current_plate_id });\n  })();\n\n  // Fetch user-defined AMS friendly names from the database\n  const { data: amsLabels, refetch: refetchAmsLabels } = useQuery({\n    queryKey: ['amsLabels', printer.id],\n    queryFn: () => api.getAmsLabels(printer.id),\n    staleTime: 5 * 60 * 1000, // 5 minutes\n  });\n\n  // Cache WiFi signal to prevent it disappearing on updates\n  const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);\n  useEffect(() => {\n    if (status?.wifi_signal != null) {\n      setCachedWifiSignal(status.wifi_signal);\n    }\n  }, [status?.wifi_signal]);\n  const wifiSignal = status?.wifi_signal ?? cachedWifiSignal;\n\n  // Cache connected state to prevent flicker when status briefly becomes undefined\n  const cachedConnected = useRef<boolean | undefined>(undefined);\n  useEffect(() => {\n    if (status?.connected !== undefined) {\n      cachedConnected.current = status.connected;\n    }\n  }, [status?.connected]);\n  const isConnected = status?.connected ?? cachedConnected.current;\n\n  // Cache ams_extruder_map to prevent L/R indicators bouncing on updates\n  const cachedAmsExtruderMap = useRef<Record<string, number>>({});\n  useEffect(() => {\n    if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {\n      cachedAmsExtruderMap.current = status.ams_extruder_map;\n    }\n  }, [status?.ams_extruder_map]);\n  const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)\n    ? status.ams_extruder_map\n    : cachedAmsExtruderMap.current;\n\n  // Cache AMS data to prevent it disappearing on idle/offline printers\n  const cachedAmsData = useRef<AMSUnit[]>([]);\n  useEffect(() => {\n    if (status?.ams && status.ams.length > 0) {\n      cachedAmsData.current = status.ams;\n    }\n  }, [status?.ams]);\n  const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;\n\n  // Cache tray_now to prevent flickering when undefined values come in\n  // Valid tray IDs: 0-253 for AMS, 254 for external spool\n  // tray_now=255 means \"no tray loaded\" (Bambu protocol sentinel) — never active\n  const cachedTrayNow = useRef<number | undefined>(undefined);\n  const currentTrayNow = status?.tray_now;\n  // Update cache: 255 means \"no tray\" so clear cache; valid values get cached\n  if (currentTrayNow !== undefined && currentTrayNow !== 255) {\n    cachedTrayNow.current = currentTrayNow;\n  } else if (currentTrayNow === 255) {\n    cachedTrayNow.current = undefined;\n  }\n  const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255)\n    ? currentTrayNow\n    : cachedTrayNow.current;\n\n  // Fetch smart plug for this printer\n  const { data: smartPlug } = useQuery({\n    queryKey: ['smartPlugByPrinter', printer.id],\n    queryFn: () => api.getSmartPlugByPrinter(printer.id),\n  });\n\n  // Fetch script plugs for this printer (for multi-device control)\n  const { data: scriptPlugs } = useQuery({\n    queryKey: ['scriptPlugsByPrinter', printer.id],\n    queryFn: () => api.getScriptPlugsByPrinter(printer.id),\n  });\n\n  // Fetch smart plug status if plug exists (faster refresh for energy monitoring)\n  const { data: plugStatus } = useQuery({\n    queryKey: ['smartPlugStatus', smartPlug?.id],\n    queryFn: () => smartPlug ? api.getSmartPlugStatus(smartPlug.id) : null,\n    enabled: !!smartPlug,\n    refetchInterval: 10000, // 10 seconds for real-time power display\n  });\n\n  // Fetch queue count for this printer\n  const { data: queueItems } = useQuery({\n    queryKey: ['queue', printer.id, 'pending'],\n    queryFn: () => api.getQueue(printer.id, 'pending'),\n  });\n  // Filter queue items by filament compatibility (same logic as PrinterQueueWidget)\n  // so the badge only shows on printers that can actually run the queued jobs.\n  // An empty Set means no filaments are loaded — jobs requiring specific types are incompatible.\n  const queueCount = useMemo(() => {\n    if (!queueItems?.length) return 0;\n    return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments).length;\n  }, [queueItems, loadedFilamentTypes, loadedFilaments]);\n\n  // Fetch currently printing queue item to show who started it (Issue #206)\n  const { data: printingQueueItems } = useQuery({\n    queryKey: ['queue', printer.id, 'printing'],\n    queryFn: () => api.getQueue(printer.id, 'printing'),\n    enabled: status?.state === 'RUNNING',\n  });\n\n  // Fetch reprint user info (for prints started via Reprint, not queue - Issue #206)\n  const { data: reprintUser } = useQuery({\n    queryKey: ['currentPrintUser', printer.id],\n    queryFn: () => api.getCurrentPrintUser(printer.id),\n    enabled: status?.state === 'RUNNING',\n  });\n\n  // Combine both sources: queue item user takes precedence, then reprint user\n  const currentPrintUser = printingQueueItems?.[0]?.created_by_username || reprintUser?.username;\n\n  // Fetch last completed print for this printer\n  const { data: lastPrints } = useQuery({\n    queryKey: ['archives', printer.id, 'last'],\n    queryFn: () => api.getArchives(printer.id, 1, 0),\n    enabled: status?.connected && status?.state !== 'RUNNING',\n  });\n  const lastPrint = lastPrints?.[0];\n  const isPrintingOrPaused = status?.state === 'RUNNING' || status?.state === 'PAUSE';\n  const needsPlateClear = requirePlateClear && status?.awaiting_plate_clear === true;\n  const showClearPlateButton = status?.connected && needsPlateClear && !isPrintingOrPaused;\n  const plateStatus = (() => {\n    if (!requirePlateClear || !status?.connected) return null;\n    if (isPrintingOrPaused) {\n      return {\n        label: t('printers.plateStatus.inUse'),\n        className: 'bg-blue-500/20 text-blue-400',\n      };\n    }\n    if (status.awaiting_plate_clear) {\n      return {\n        label: t('printers.plateStatus.notCleared'),\n        className: 'bg-yellow-500/20 text-yellow-400',\n      };\n    }\n    return {\n      label: t('printers.plateStatus.cleared'),\n      className: 'bg-status-ok/20 text-status-ok',\n    };\n  })();\n  const plateStatusPill = plateStatus ? (\n    <span className={`inline-flex flex-shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${plateStatus.className}`}>\n      {plateStatus.label}\n    </span>\n  ) : null;\n\n  // Determine if this card should be hidden (use cached connected state to prevent flicker)\n  const shouldHide = hideIfDisconnected && isConnected === false;\n\n  const deleteMutation = useMutation({\n    mutationFn: (options: { deleteArchives: boolean }) =>\n      api.deletePrinter(printer.id, options.deleteArchives),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['printers'] });\n      queryClient.invalidateQueries({ queryKey: ['archives'] });\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToDelete'), 'error'),\n  });\n\n  const connectMutation = useMutation({\n    mutationFn: () => api.connectPrinter(printer.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n    },\n  });\n\n  const forceRefreshMutation = useMutation({\n    mutationFn: () => api.refreshPrinterStatus(printer.id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n      showToast(t('printers.forceRefreshSuccess'), 'success');\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),\n  });\n\n  const unlinkSpoolMutation = useMutation({\n    mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),\n    onSuccess: (result) => {\n      showToast(t('spoolman.unlinkSuccess') || result?.message, 'success');\n      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });\n      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('spoolman.unlinkFailed'), 'error');\n    },\n  });\n\n  // AMS drying mutations\n  const startDryingMutation = useMutation({\n    mutationFn: ({ amsId, temp, duration, filament, rotateTray }: { amsId: number; temp: number; duration: number; filament: string; rotateTray: boolean }) =>\n      api.startDrying(printer.id, amsId, temp, duration, filament, rotateTray),\n    onSuccess: () => {\n      setDryingPopoverAmsId(null);\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),\n  });\n\n  const stopDryingMutation = useMutation({\n    mutationFn: (amsId: number) => api.stopDrying(printer.id, amsId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),\n  });\n\n  // Smart plug control mutations\n  const powerControlMutation = useMutation({\n    mutationFn: (action: 'on' | 'off') =>\n      smartPlug ? api.controlSmartPlug(smartPlug.id, action) : Promise.reject('No plug'),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smartPlugStatus', smartPlug?.id] });\n    },\n  });\n\n  const toggleAutoOffMutation = useMutation({\n    mutationFn: (enabled: boolean) =>\n      smartPlug ? api.updateSmartPlug(smartPlug.id, { auto_off: enabled }) : Promise.reject('No plug'),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', printer.id] });\n      // Also invalidate the smart-plugs list to keep Settings page in sync\n      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });\n    },\n  });\n\n  // Run HA entity mutation — scripts use 'on' (trigger), switches use 'toggle'\n  const runScriptMutation = useMutation({\n    mutationFn: ({ id, action }: { id: number; action: 'on' | 'toggle' }) => api.controlSmartPlug(id, action),\n    onSuccess: () => {\n      showToast(t('printers.toast.scriptTriggered'));\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToRunScript'), 'error'),\n  });\n\n  // Print control mutations\n  const stopPrintMutation = useMutation({\n    mutationFn: () => api.stopPrint(printer.id),\n    onSuccess: () => {\n      showToast(t('printers.toast.printStopped'));\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToStopPrint'), 'error'),\n  });\n\n  const pausePrintMutation = useMutation({\n    mutationFn: () => api.pausePrint(printer.id),\n    onSuccess: () => {\n      showToast(t('printers.toast.printPaused'));\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToPausePrint'), 'error'),\n  });\n\n  const resumePrintMutation = useMutation({\n    mutationFn: () => api.resumePrint(printer.id),\n    onSuccess: () => {\n      showToast(t('printers.toast.printResumed'));\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToResumePrint'), 'error'),\n  });\n\n  const clearPlateMutation = useMutation({\n    mutationFn: () => api.clearPlate(printer.id),\n    onSuccess: () => {\n      showToast(t('queue.clearPlateSuccess'));\n      queryClient.setQueryData(['printerStatus', printer.id], (old: PrinterStatus | undefined) =>\n        old ? { ...old, awaiting_plate_clear: false } : old\n      );\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n      queryClient.invalidateQueries({ queryKey: ['queue', printer.id] });\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),\n  });\n\n  // Chamber light mutation with optimistic update\n  const chamberLightMutation = useMutation({\n    mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),\n    onMutate: async (on) => {\n      // Cancel any outgoing refetches\n      await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });\n      // Snapshot the previous value\n      const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]);\n      // Optimistically update\n      queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({\n        ...old,\n        chamber_light: on,\n      }));\n      return { previousStatus };\n    },\n    onSuccess: (_, on) => {\n      showToast(`Chamber light ${on ? 'on' : 'off'}`);\n    },\n    onError: (error: Error, _, context) => {\n      // Rollback on error\n      if (context?.previousStatus) {\n        queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus);\n      }\n      showToast(error.message || t('printers.toast.failedToControlChamberLight'), 'error');\n    },\n  });\n\n  // Print speed mutation with optimistic update\n  const printSpeedMutation = useMutation({\n    mutationFn: (mode: number) => api.setPrintSpeed(printer.id, mode),\n    onMutate: async (mode) => {\n      await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });\n      const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]);\n      queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({\n        ...old,\n        speed_level: mode,\n      }));\n      return { previousStatus };\n    },\n    onError: (error: Error, _, context) => {\n      if (context?.previousStatus) {\n        queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus);\n      }\n      showToast(error.message || t('printers.toast.failedToSetSpeed'), 'error');\n    },\n  });\n\n  const airductMutation = useMutation({\n    mutationFn: (mode: 'cooling' | 'heating') => api.setAirductMode(printer.id, mode),\n    onMutate: async (mode) => {\n      await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });\n      const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]);\n      queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({\n        ...old,\n        airduct_mode: mode === 'cooling' ? 0 : 1,\n      }));\n      return { previousStatus };\n    },\n    onError: (error: Error, _, context) => {\n      if (context?.previousStatus) {\n        queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus);\n      }\n      showToast(error.message || t('printers.toast.failedToSendCommand'), 'error');\n    },\n  });\n\n  const bedJogMutation = useMutation({\n    mutationFn: ({ distance, force }: { distance: number; force?: boolean }) =>\n      api.bedJog(printer.id, distance, force ?? false),\n    onError: (error: Error) =>\n      showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),\n  });\n\n  const homeAxesMutation = useMutation({\n    mutationFn: (axes: 'z' | 'xy' | 'all') => api.homeAxes(printer.id, axes),\n    onSuccess: () => {\n      // Flip the session-scoped \"warned\" flag so the next bed-jog click doesn't re-prompt\n      // the not-homed modal. The flag is the same one \"Move anyway\" sets; after a successful\n      // auto-home request the printer is (or will shortly be) in a known-homed state, so\n      // prompting again in the same session is noise — #1052 follow-up.\n      try { sessionStorage.setItem(`bambuddy.bedJog.warned.${printer.id}`, '1'); } catch { /* ignore */ }\n      showToast(t('printers.bedJog.homingStarted'));\n    },\n    onError: (error: Error) =>\n      showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),\n  });\n\n  // Plate detection setting mutation\n  const plateDetectionMutation = useMutation({\n    mutationFn: (enabled: boolean) => api.updatePrinter(printer.id, { plate_detection_enabled: enabled }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['printers'] });\n      showToast(plateDetectionMutation.variables ? t('printers.toast.plateCheckEnabled') : t('printers.toast.plateCheckDisabled'));\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUpdateSetting'), 'error'),\n  });\n\n  // Query for printable objects (for skip functionality)\n  // Fetch when printing with 2+ objects OR when modal is open\n  const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2;\n  const { data: objectsData } = useQuery({\n    queryKey: ['printableObjects', printer.id],\n    queryFn: () => api.getPrintableObjects(printer.id),\n    enabled: showSkipObjectsModal || isPrintingWithObjects,\n    refetchInterval: showSkipObjectsModal ? 5000 : (isPrintingWithObjects ? 30000 : false), // 5s when modal open, 30s otherwise\n  });\n\n  // State for tracking which AMS slot is being refreshed\n  const [refreshingSlot, setRefreshingSlot] = useState<{ amsId: number; slotId: number } | null>(null);\n  // Track if we've seen the printer enter \"busy\" state (ams_status_main !== 0)\n  const seenBusyStateRef = useRef<boolean>(false);\n  // Fallback timeout ref\n  const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  // Minimum display time passed\n  const minTimePassedRef = useRef<boolean>(false);\n\n  // AMS slot refresh mutation\n  const refreshAmsSlotMutation = useMutation({\n    mutationFn: ({ amsId, slotId }: { amsId: number; slotId: number }) =>\n      api.refreshAmsSlot(printer.id, amsId, slotId),\n    onMutate: ({ amsId, slotId }) => {\n      // Clear any existing timeout\n      if (refreshTimeoutRef.current) {\n        clearTimeout(refreshTimeoutRef.current);\n      }\n      // Reset state\n      seenBusyStateRef.current = false;\n      minTimePassedRef.current = false;\n      setRefreshingSlot({ amsId, slotId });\n      // Minimum display time (2 seconds)\n      setTimeout(() => {\n        minTimePassedRef.current = true;\n      }, 2000);\n      // Fallback timeout (30 seconds max)\n      refreshTimeoutRef.current = setTimeout(() => {\n        setRefreshingSlot(null);\n      }, 30000);\n    },\n    onSuccess: (data) => {\n      showToast(data.message || t('printers.toast.rfidRereadInitiated'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('printers.toast.failedToRereadRfid'), 'error');\n      if (refreshTimeoutRef.current) {\n        clearTimeout(refreshTimeoutRef.current);\n      }\n      setRefreshingSlot(null);\n    },\n  });\n\n  // Plate references state\n  const [plateReferences, setPlateReferences] = useState<{\n    references: Array<{ index: number; label: string; timestamp: string; has_image: boolean; thumbnail_url: string }>;\n    max_references: number;\n  } | null>(null);\n  const [editingRefLabel, setEditingRefLabel] = useState<{ index: number; label: string } | null>(null);\n\n  // Fetch plate references\n  const fetchPlateReferences = async () => {\n    try {\n      const data = await api.getPlateReferences(printer.id);\n      setPlateReferences(data);\n    } catch {\n      // Ignore errors - references will show as empty\n    }\n  };\n\n  // Toggle plate detection enabled/disabled\n  const handleTogglePlateDetection = () => {\n    plateDetectionMutation.mutate(!printer.plate_detection_enabled);\n  };\n\n  // Open plate detection management modal (for calibration/references)\n  const handleOpenPlateManagement = async () => {\n    setIsCheckingPlate(true);\n    setPlateCheckResult(null);\n\n    // Auto-turn on light if it's off\n    const lightWasOff = status?.chamber_light === false;\n    setPlateCheckLightWasOff(lightWasOff);\n    if (lightWasOff) {\n      await api.setChamberLight(printer.id, true);\n      // Wait for light to physically turn on and camera to adjust exposure\n      // (MQTT command is async, light takes ~1s to turn on, camera needs time to adjust)\n      await new Promise(resolve => setTimeout(resolve, 2500));\n    }\n\n    try {\n      const result = await api.checkPlateEmpty(printer.id, { includeDebugImage: true });\n      setPlateCheckResult(result);\n      fetchPlateReferences();\n    } catch (error) {\n      showToast(error instanceof Error ? error.message : t('printers.toast.failedToCheckPlate'), 'error');\n      // Restore light if check failed\n      if (lightWasOff) {\n        await api.setChamberLight(printer.id, false);\n        setPlateCheckLightWasOff(false);\n      }\n    } finally {\n      setIsCheckingPlate(false);\n    }\n  };\n\n  // Close plate check modal and restore light state\n  const closePlateCheckModal = useCallback(async () => {\n    setPlateCheckResult(null);\n    // Restore light to original state if we turned it on\n    if (plateCheckLightWasOff) {\n      await api.setChamberLight(printer.id, false);\n      setPlateCheckLightWasOff(false);\n    }\n  }, [plateCheckLightWasOff, printer.id]);\n\n  // Calibrate plate detection handler\n  const handleCalibratePlate = async (label?: string) => {\n    setIsCalibrating(true);\n    try {\n      const result = await api.calibratePlateDetection(printer.id, { label });\n      if (result.success) {\n        showToast(result.message || t('printers.toast.calibrationSaved'), 'success');\n        // Refresh references and re-check\n        fetchPlateReferences();\n        const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true });\n        setPlateCheckResult(checkResult);\n      } else {\n        showToast(result.message || t('printers.toast.calibrationFailed'), 'error');\n      }\n    } catch (error) {\n      showToast(error instanceof Error ? error.message : t('printers.toast.calibrationFailed'), 'error');\n    } finally {\n      setIsCalibrating(false);\n    }\n  };\n\n  // Update reference label\n  const handleUpdateRefLabel = async (index: number, label: string) => {\n    try {\n      await api.updatePlateReferenceLabel(printer.id, index, label);\n      setEditingRefLabel(null);\n      fetchPlateReferences();\n    } catch (error) {\n      showToast(error instanceof Error ? error.message : t('printers.toast.failedToUpdateLabel'), 'error');\n    }\n  };\n\n  // Delete reference\n  const handleDeleteRef = async (index: number) => {\n    try {\n      await api.deletePlateReference(printer.id, index);\n      showToast(t('printers.toast.referenceDeleted'), 'success');\n      fetchPlateReferences();\n      // Re-check to update counts\n      const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true });\n      setPlateCheckResult(checkResult);\n    } catch (error) {\n      showToast(error instanceof Error ? error.message : t('printers.toast.failedToDeleteReference'), 'error');\n    }\n  };\n\n  // Save ROI settings\n  const handleSaveRoi = async () => {\n    if (!editingRoi) return;\n    setIsSavingRoi(true);\n    try {\n      await api.updatePrinter(printer.id, { plate_detection_roi: editingRoi });\n      showToast(t('printers.toast.detectionAreaSaved'), 'success');\n      setEditingRoi(null);\n      // Re-check to see new ROI in action\n      const checkResult = await api.checkPlateEmpty(printer.id, { includeDebugImage: true });\n      setPlateCheckResult(checkResult);\n    } catch (error) {\n      showToast(error instanceof Error ? error.message : t('printers.toast.failedToSaveDetectionArea'), 'error');\n    } finally {\n      setIsSavingRoi(false);\n    }\n  };\n\n  // Close plate check modal on Escape key\n  useEffect(() => {\n    const handleEscape = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && plateCheckResult) {\n        closePlateCheckModal();\n      }\n    };\n    window.addEventListener('keydown', handleEscape);\n    return () => window.removeEventListener('keydown', handleEscape);\n  }, [plateCheckResult, closePlateCheckModal]);\n\n  // Watch ams_status_main to detect when RFID read completes\n  // ams_status_main: 0=idle, 2=rfid_identifying\n  const deferredClearRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => {\n    if (!refreshingSlot) return;\n\n    const amsStatus = status?.ams_status_main ?? 0;\n\n    // Track when we see non-idle state (printer is working)\n    if (amsStatus !== 0) {\n      seenBusyStateRef.current = true;\n      // Cancel any deferred clear since we're back to busy\n      if (deferredClearRef.current) {\n        clearTimeout(deferredClearRef.current);\n        deferredClearRef.current = null;\n      }\n    }\n\n    // When we've seen busy and now idle, clear (with min time check)\n    if (seenBusyStateRef.current && amsStatus === 0) {\n      if (minTimePassedRef.current) {\n        // Min time passed - clear now\n        if (refreshTimeoutRef.current) {\n          clearTimeout(refreshTimeoutRef.current);\n        }\n        setRefreshingSlot(null);\n      } else {\n        // Schedule clear after min time (2 seconds from start)\n        if (!deferredClearRef.current) {\n          deferredClearRef.current = setTimeout(() => {\n            if (refreshTimeoutRef.current) {\n              clearTimeout(refreshTimeoutRef.current);\n            }\n            setRefreshingSlot(null);\n          }, 2000);\n        }\n      }\n    }\n\n    return () => {\n      if (deferredClearRef.current) {\n        clearTimeout(deferredClearRef.current);\n      }\n    };\n  }, [status?.ams_status_main, refreshingSlot]);\n\n  // State for AMS slot menu\n  const [amsSlotMenu, setAmsSlotMenu] = useState<{ amsId: number; slotId: number } | null>(null);\n\n  if (shouldHide) {\n    return null;\n  }\n\n  // Size-based styling helpers\n  const getImageSize = () => {\n    switch (cardSize) {\n      case 1: return 'w-10 h-10';\n      case 2: return 'w-14 h-14';\n      case 3: return 'w-16 h-16';\n      case 4: return 'w-20 h-20';\n      default: return 'w-14 h-14';\n    }\n  };\n  const getTitleSize = () => {\n    switch (cardSize) {\n      case 1: return 'text-base truncate';\n      case 2: return 'text-lg';\n      case 3: return 'text-xl';\n      case 4: return 'text-2xl';\n      default: return 'text-lg';\n    }\n  };\n  const getSpacing = () => {\n    switch (cardSize) {\n      case 1: return 'mb-2';\n      case 2: return 'mb-4';\n      case 3: return 'mb-5';\n      case 4: return 'mb-6';\n      default: return 'mb-4';\n    }\n  };\n\n  const canDrop = isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && hasPermission('printers:control');\n\n  const handleCardDragEnter = (e: React.DragEvent) => {\n    e.preventDefault();\n    dragCounterRef.current++;\n    if (dragCounterRef.current === 1) setIsDraggingFile(true);\n  };\n\n  const handleCardDragOver = (e: React.DragEvent) => {\n    e.preventDefault();\n    e.dataTransfer.dropEffect = canDrop ? 'copy' : 'none';\n  };\n\n  const handleCardDragLeave = (e: React.DragEvent) => {\n    e.preventDefault();\n    dragCounterRef.current--;\n    if (dragCounterRef.current === 0) setIsDraggingFile(false);\n  };\n\n  const handleCardDrop = async (e: React.DragEvent) => {\n    e.preventDefault();\n    dragCounterRef.current = 0;\n    setIsDraggingFile(false);\n\n    if (!canDrop) return;\n\n    const droppedFiles = Array.from(e.dataTransfer.files);\n    const file = droppedFiles[0];\n    if (!file) return;\n\n    // Only accept sliced/printable files (.gcode, .gcode.3mf, etc.)\n    const lower = file.name.toLowerCase();\n    if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) {\n      showToast(t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed'), 'error');\n      return;\n    }\n\n    setIsDropUploading(true);\n    try {\n      const result = await api.uploadLibraryFile(file, null);\n\n      // Check printer compatibility if sliced_for_model is available in metadata\n      const slicedFor = (result.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;\n      const printerModel = mapModelCode(printer.model);\n      if (slicedFor && printerModel && slicedFor.toLowerCase() !== printerModel.toLowerCase()) {\n        await api.deleteLibraryFile(result.id).catch(() => {});\n        showToast(\n          t('printers.incompatibleFile', 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}', { slicedFor, printerModel }),\n          'error'\n        );\n        return;\n      }\n\n      setPrintAfterUpload({ id: result.id, filename: result.filename });\n    } catch {\n      showToast(t('common.uploadFailed', 'Upload failed'), 'error');\n    } finally {\n      setIsDropUploading(false);\n    }\n  };\n\n  return (\n    <Card\n      className={`relative ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}\n      onDragEnter={handleCardDragEnter}\n      onDragOver={handleCardDragOver}\n      onDragLeave={handleCardDragLeave}\n      onDrop={handleCardDrop}\n    >\n      {/* Selection mode click overlay — captures all clicks, preventing nested interactions */}\n      {selectionMode && (\n        <div\n          className=\"absolute inset-0 z-20 flex items-start p-2\"\n          onClick={(e) => { e.stopPropagation(); onToggleSelect?.(printer.id); }}\n        >\n          {isSelected ? (\n            <CheckSquare className=\"w-5 h-5 text-bambu-green\" />\n          ) : (\n            <Square className=\"w-5 h-5 text-bambu-gray\" />\n          )}\n        </div>\n      )}\n      {/* Drop zone overlay */}\n      {(isDraggingFile || isDropUploading) && (\n        <div\n          className={`absolute inset-0 z-10 rounded-xl border-2 border-dashed flex items-center justify-center transition-colors ${\n            isDropUploading\n              ? 'bg-bambu-green/10 border-bambu-green/50'\n              : canDrop\n                ? 'bg-bambu-green/10 border-bambu-green'\n                : 'bg-red-500/10 border-red-500/50'\n          }`}\n        >\n          <div className=\"text-center\">\n            {isDropUploading ? (\n              <>\n                <Loader2 className=\"w-8 h-8 mx-auto mb-2 text-bambu-green animate-spin\" />\n                <p className=\"text-sm font-medium text-bambu-green\">{t('common.uploading', 'Uploading...')}</p>\n              </>\n            ) : canDrop ? (\n              <>\n                <PrinterIcon className=\"w-8 h-8 mx-auto mb-2 text-bambu-green\" />\n                <p className=\"text-sm font-medium text-bambu-green\">{t('printers.dropToPrint', 'Drop to print')}</p>\n              </>\n            ) : (\n              <>\n                <X className=\"w-8 h-8 mx-auto mb-2 text-red-400\" />\n                <p className=\"text-sm font-medium text-red-400\">{t('printers.cannotPrint', 'Printer busy')}</p>\n              </>\n            )}\n          </div>\n        </div>\n      )}\n      <CardContent className={cardSize >= 3 ? 'p-5' : ''}>\n        {/* Header */}\n        <div className={getSpacing()}>\n          {/* Top row: Image, Name, Menu */}\n          <div className=\"flex items-start justify-between gap-2\">\n            <div className=\"flex items-center gap-3 min-w-0 flex-1\">\n              {/* Printer Model Image */}\n              <img\n                src={getPrinterImage(printer.model)}\n                alt={printer.model || t('common.printer')}\n                className={`object-contain rounded-lg bg-bambu-dark flex-shrink-0 ${getImageSize()}`}\n              />\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"flex items-center gap-2\">\n                  <h3 className={`font-semibold text-white ${getTitleSize()}`}>{printer.name}</h3>\n                  {/* Connection indicator dot for compact mode */}\n                  {viewMode === 'compact' && (() => {\n                    const hmsErrors = status?.connected && status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : [];\n                    const hasSevere = hmsErrors.some(e => e.severity <= 2);\n                    const hasWarning = hmsErrors.length > 0;\n                    const pipColor = !status?.connected\n                      ? 'bg-status-error'\n                      : hasSevere\n                        ? 'bg-status-error'\n                        : hasWarning\n                          ? 'bg-status-warning'\n                          : 'bg-status-ok';\n                    const pipTitle = !status?.connected\n                      ? t('printers.connection.offline')\n                      : hasWarning\n                        ? `${hmsErrors.length} HMS ${hmsErrors.length === 1 ? 'error' : 'errors'}`\n                        : t('printers.connection.connected');\n                    return (\n                      <div\n                        className={`w-2 h-2 rounded-full flex-shrink-0 ${pipColor}`}\n                        title={pipTitle}\n                      />\n                    );\n                  })()}\n                </div>\n                <p className=\"text-sm text-bambu-gray\">\n                  {printer.model || 'Unknown Model'}\n                  {/* Nozzle Info - only in expanded */}\n                  {viewMode === 'expanded' && status?.nozzles && status.nozzles[0]?.nozzle_diameter && (\n                    <span className=\"ml-1.5 text-bambu-gray\" title={status.nozzles[0].nozzle_type || 'Nozzle'}>\n                      • {status.nozzles[0].nozzle_diameter}mm\n                    </span>\n                  )}\n                  {viewMode === 'expanded' && maintenanceInfo && maintenanceInfo.total_print_hours > 0 && (\n                    <span className=\"ml-2 text-bambu-gray\">\n                      <Clock className=\"w-3 h-3 inline-block mr-1\" />\n                      {Math.round(maintenanceInfo.total_print_hours)}h\n                    </span>\n                  )}\n                </p>\n              </div>\n            </div>\n            {/* Menu button */}\n            <div className=\"relative flex-shrink-0\">\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => setShowMenu(!showMenu)}\n              >\n                <MoreVertical className=\"w-4 h-4\" />\n              </Button>\n              {showMenu && (\n                <div className=\"absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20\">\n                  <button\n                    className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${\n                      hasPermission('printers:update')\n                        ? 'hover:bg-bambu-dark-tertiary'\n                        : 'opacity-50 cursor-not-allowed'\n                    }`}\n                    onClick={() => {\n                      if (!hasPermission('printers:update')) return;\n                      setShowEditModal(true);\n                      setShowMenu(false);\n                    }}\n                    title={!hasPermission('printers:update') ? t('printers.permission.noEdit') : undefined}\n                  >\n                    <Pencil className=\"w-4 h-4\" />\n                    {t('common.edit')}\n                  </button>\n                  <button\n                    className=\"w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2\"\n                    onClick={() => {\n                      setShowPrinterInfo(true);\n                      setShowMenu(false);\n                    }}\n                  >\n                    <Info className=\"w-4 h-4\" />\n                    {t('printers.printerInformation')}\n                  </button>\n                  <button\n                    className=\"w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2\"\n                    onClick={() => {\n                      connectMutation.mutate();\n                      setShowMenu(false);\n                    }}\n                  >\n                    <RefreshCw className=\"w-4 h-4\" />\n                    {t('printers.reconnect')}\n                  </button>\n                  <button\n                    className=\"w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2 disabled:opacity-50\"\n                    disabled={forceRefreshMutation.isPending}\n                    onClick={() => {\n                      forceRefreshMutation.mutate();\n                      setShowMenu(false);\n                    }}\n                  >\n                    <RotateCw className={`w-4 h-4 ${forceRefreshMutation.isPending ? 'animate-spin' : ''}`} />\n                    {t('printers.forceRefresh')}\n                  </button>\n                  <button\n                    className=\"w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2\"\n                    onClick={() => {\n                      setShowMQTTDebug(true);\n                      setShowMenu(false);\n                    }}\n                  >\n                    <Terminal className=\"w-4 h-4\" />\n                    {t('printers.mqttDebug')}\n                  </button>\n                  <button\n                    className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${\n                      hasPermission('printers:delete')\n                        ? 'text-red-400 hover:bg-bambu-dark-tertiary'\n                        : 'text-red-400/50 cursor-not-allowed'\n                    }`}\n                    onClick={() => {\n                      if (!hasPermission('printers:delete')) return;\n                      setShowDeleteConfirm(true);\n                      setShowMenu(false);\n                    }}\n                    title={!hasPermission('printers:delete') ? t('printers.permission.noDelete') : undefined}\n                  >\n                    <Trash2 className=\"w-4 h-4\" />\n                    {t('common.delete')}\n                  </button>\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Badges row - only in expanded mode */}\n          {viewMode === 'expanded' && (\n            <div className=\"flex flex-wrap items-center gap-2 mt-2\">\n              {/* Connection status badge */}\n              <span\n                className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${\n                  status?.connected\n                    ? 'bg-status-ok/20 text-status-ok'\n                    : 'bg-status-error/20 text-status-error'\n                }`}\n              >\n                {status?.connected ? (\n                  <Link className=\"w-3 h-3\" />\n                ) : (\n                  <Unlink className=\"w-3 h-3\" />\n                )}\n                {status?.connected ? t('printers.connection.connected') : t('printers.connection.offline')}\n              </span>\n              {/* Network connection indicator */}\n              {status?.connected && status?.wired_network && (\n                <span\n                  className=\"flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-status-ok/20 text-status-ok\"\n                  title={t('printers.connection.ethernet', 'Ethernet')}\n                >\n                  <Cable className=\"w-3 h-3\" />\n                  {t('printers.connection.ethernet', 'Ethernet')}\n                </span>\n              )}\n              {/* WiFi signal indicator */}\n              {status?.connected && !status?.wired_network && wifiSignal != null && (\n                <span\n                  className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs ${\n                    wifiSignal >= -50\n                      ? 'bg-status-ok/20 text-status-ok'\n                      : wifiSignal >= -60\n                      ? 'bg-status-ok/20 text-status-ok'\n                      : wifiSignal >= -70\n                      ? 'bg-status-warning/20 text-status-warning'\n                      : wifiSignal >= -80\n                      ? 'bg-orange-500/20 text-orange-600'\n                      : 'bg-status-error/20 text-status-error'\n                  }`}\n                  title={`WiFi: ${wifiSignal} dBm - ${t(getWifiStrength(wifiSignal).labelKey)}`}\n                >\n                  <Signal className=\"w-3 h-3\" />\n                  {wifiSignal}dBm\n                </span>\n              )}\n              {/* HMS Status Indicator */}\n              {status?.connected && (() => {\n                const knownErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : [];\n                return (\n                  <button\n                    onClick={() => setShowHMSModal(true)}\n                    className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${\n                      knownErrors.length > 0\n                        ? knownErrors.some(e => e.severity <= 2)\n                          ? 'bg-status-error/20 text-status-error'\n                          : 'bg-status-warning/20 text-status-warning'\n                        : 'bg-status-ok/20 text-status-ok'\n                    }`}\n                    title={t('printers.clickToViewHmsErrors')}\n                  >\n                    <AlertTriangle className=\"w-3 h-3\" />\n                    {knownErrors.length > 0 ? knownErrors.length : 'OK'}\n                  </button>\n                );\n              })()}\n              {/* Maintenance Status Indicator */}\n              {maintenanceInfo && (\n                <button\n                  onClick={() => navigate('/maintenance')}\n                  className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${\n                    maintenanceInfo.due_count > 0\n                      ? 'bg-status-error/20 text-status-error'\n                      : maintenanceInfo.warning_count > 0\n                      ? 'bg-status-warning/20 text-status-warning'\n                      : 'bg-status-ok/20 text-status-ok'\n                  }`}\n                  title={\n                    maintenanceInfo.due_count > 0 || maintenanceInfo.warning_count > 0\n                      ? `${maintenanceInfo.due_count > 0 ? `${maintenanceInfo.due_count} maintenance due` : ''}${maintenanceInfo.due_count > 0 && maintenanceInfo.warning_count > 0 ? ', ' : ''}${maintenanceInfo.warning_count > 0 ? `${maintenanceInfo.warning_count} due soon` : ''} - Click to view`\n                      : t('printers.maintenanceUpToDate')\n                  }\n                >\n                  <Wrench className=\"w-3 h-3\" />\n                  {maintenanceInfo.due_count > 0 || maintenanceInfo.warning_count > 0\n                    ? maintenanceInfo.due_count + maintenanceInfo.warning_count\n                    : 'OK'}\n                </button>\n              )}\n              {/* Queue Count Badge */}\n              {queueCount > 0 && (\n                <button\n                  onClick={() => navigate('/queue')}\n                  className=\"flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-indigo-500/20 text-indigo-400 hover:opacity-80 transition-opacity\"\n                  title={t('printers.queue.inQueue', { count: queueCount })}\n                >\n                  <Layers className=\"w-3 h-3\" />\n                  {queueCount}\n                </button>\n              )}\n              {/* Firmware Version Badge */}\n              {checkPrinterFirmware && firmwareInfo?.current_version && firmwareInfo?.latest_version ? (\n                <button\n                  onClick={() => setShowFirmwareModal(true)}\n                  className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs hover:opacity-80 transition-opacity ${\n                    firmwareInfo.update_available\n                      ? 'bg-orange-500/20 text-orange-400'\n                      : 'bg-status-ok/20 text-status-ok'\n                  }`}\n                  title={\n                    firmwareInfo.update_available\n                      ? t('printers.firmwareUpdateAvailable', { current: firmwareInfo.current_version, latest: firmwareInfo.latest_version })\n                      : t('printers.firmwareUpToDate', { version: firmwareInfo.current_version })\n                  }\n                >\n                  {firmwareInfo.update_available ? <Download className=\"w-3 h-3\" /> : <CheckCircle className=\"w-3 h-3\" />}\n                  {firmwareInfo.current_version}\n                </button>\n              ) : status?.firmware_version ? (\n                <span className=\"flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-bambu-dark-tertiary/50 text-bambu-gray\">\n                  {status.firmware_version}\n                </span>\n              ) : null}\n\n              {/* Enclosure Door Badge (X1/X2D/P1S/P2S/H2*) */}\n              {status?.connected && ['X1C', 'X1', 'X1E', 'X2D', 'P1S', 'P1P', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '') && (\n                <span\n                  className={`flex items-center px-2 py-1 rounded-full text-xs ${\n                    status.door_open\n                      ? 'bg-yellow-500/20 text-yellow-400'\n                      : 'bg-status-ok/20 text-status-ok'\n                  }`}\n                  title={status.door_open ? t('printers.door.open') : t('printers.door.closed')}\n                >\n                  {status.door_open ? <DoorOpen className=\"w-3 h-3\" /> : <DoorClosed className=\"w-3 h-3\" />}\n                </span>\n              )}\n            </div>\n          )}\n        </div>\n\n        {/* Delete Confirmation */}\n        {showDeleteConfirm && (\n          <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\">\n            <Card className=\"w-full max-w-md mx-4\">\n              <CardContent>\n                <div className=\"flex items-start gap-3 mb-4\">\n                  <div className=\"p-2 rounded-full bg-red-500/20\">\n                    <AlertTriangle className=\"w-5 h-5 text-red-400\" />\n                  </div>\n                  <div>\n                    <h3 className=\"text-lg font-semibold text-white\">{t('printers.confirm.deleteTitle')}</h3>\n                    <p className=\"text-sm text-bambu-gray mt-1\">\n                      {t('printers.confirm.deleteMessage', { name: printer.name })}\n                    </p>\n                  </div>\n                </div>\n\n                <div className=\"bg-bambu-dark rounded-lg p-3 mb-4\">\n                  <label className=\"flex items-start gap-3 cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={deleteArchives}\n                      onChange={(e) => setDeleteArchives(e.target.checked)}\n                      className=\"mt-0.5 w-4 h-4 rounded border-bambu-gray bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0\"\n                    />\n                    <div>\n                      <span className=\"text-sm text-white\">{t('printers.deleteArchives')}</span>\n                      <p className=\"text-xs text-bambu-gray mt-0.5\">\n                        {deleteArchives\n                          ? t('printers.confirm.deleteArchivesNote')\n                          : t('printers.confirm.keepArchivesNote')}\n                      </p>\n                    </div>\n                  </label>\n                </div>\n\n                <div className=\"flex justify-end gap-2\">\n                  <Button\n                    variant=\"secondary\"\n                    onClick={() => {\n                      setShowDeleteConfirm(false);\n                      setDeleteArchives(true);\n                    }}\n                  >\n                    {t('common.cancel')}\n                  </Button>\n                  <Button\n                    variant=\"danger\"\n                    onClick={() => {\n                      deleteMutation.mutate({ deleteArchives });\n                      setShowDeleteConfirm(false);\n                      setDeleteArchives(true);\n                    }}\n                  >\n                    Delete\n                  </Button>\n                </div>\n              </CardContent>\n            </Card>\n          </div>\n        )}\n\n        {/* Status */}\n        {status?.connected && (\n          <>\n            {/* Compact: Simple status bar */}\n            {viewMode === 'compact' ? (\n              <div className=\"mt-2\">\n                {(status.state === 'RUNNING' || status.state === 'PAUSE') ? (\n                  <div className=\"flex items-center gap-2\">\n                    <div className=\"flex-1 bg-bambu-dark-tertiary rounded-full h-1.5\">\n                      <div\n                        className={`${status.state === 'PAUSE' ? 'bg-status-warning' : 'bg-bambu-green'} h-1.5 rounded-full transition-all`}\n                        style={{ width: `${status.progress || 0}%` }}\n                      />\n                    </div>\n                    <div className=\"flex flex-shrink-0 items-center gap-1.5\">\n                      <span className=\"text-xs text-white\">{Math.round(status.progress || 0)}%</span>\n                      {plateStatusPill}\n                    </div>\n                  </div>\n                ) : (\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <div className=\"min-w-0 flex-1 flex items-center gap-1.5\">\n                      <p className=\"min-w-0 truncate text-xs text-bambu-gray\">{getStatusDisplay(status.state, status.stg_cur_name)}</p>\n                      {plateStatusPill}\n                    </div>\n                    {showClearPlateButton && (\n                      <button\n                        type=\"button\"\n                        onClick={() => clearPlateMutation.mutate()}\n                        disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}\n                        aria-label={t('printers.plateStatus.markCleared')}\n                        className=\"inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-yellow-500/20 border border-yellow-400/40 text-yellow-400 hover:bg-yellow-500/30 transition-colors disabled:opacity-50\"\n                        title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}\n                      >\n                        {clearPlateMutation.isPending ? (\n                          <Loader2 className=\"w-3 h-3 animate-spin\" />\n                        ) : (\n                          <PlateClearedIcon className=\"w-3 h-3\" />\n                        )}\n                      </button>\n                    )}\n                  </div>\n                )}\n              </div>\n            ) : (\n              /* Expanded: Full status section */\n              <>\n                {/* Current Print or Idle Placeholder */}\n                <div className=\"mb-4 p-3 bg-bambu-dark rounded-lg relative\">\n                  {/* Skip Objects button - top right corner, always visible */}\n                  <button\n                    onClick={() => setShowSkipObjectsModal(true)}\n                    disabled={!(status.state === 'RUNNING' || status.state === 'PAUSE') || (status.printable_objects_count ?? 0) < 2 || !hasPermission('printers:control')}\n                    className={`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${\n                      (status.state === 'RUNNING' || status.state === 'PAUSE') && (status.printable_objects_count ?? 0) >= 2 && hasPermission('printers:control')\n                        ? 'text-bambu-gray hover:text-white hover:bg-white/10'\n                        : 'text-bambu-gray/30 cursor-not-allowed'\n                    }`}\n                    title={\n                      !hasPermission('printers:control')\n                        ? t('printers.permission.noControl')\n                        : !(status.state === 'RUNNING' || status.state === 'PAUSE')\n                          ? t('printers.skipObjects.onlyWhilePrinting')\n                          : (status.printable_objects_count ?? 0) >= 2\n                            ? t('printers.skipObjects.tooltip')\n                            : t('printers.skipObjects.requiresMultiple')\n                    }\n                  >\n                    <SkipObjectsIcon className=\"w-4 h-4\" />\n                    {/* Badge showing skipped count */}\n                    {objectsData && objectsData.skipped_count > 0 && (\n                      <span className=\"absolute -top-1 -right-1 min-w-[16px] h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-red-500 text-white rounded-full\">\n                        {objectsData.skipped_count}\n                      </span>\n                    )}\n                  </button>\n                  <div className=\"flex gap-3\">\n                    {/* Cover Image */}\n                    <CoverImage\n                      url={(status.state === 'RUNNING' || status.state === 'PAUSE') ? status.cover_url : null}\n                      printName={(status.state === 'RUNNING' || status.state === 'PAUSE') ? (formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel) || undefined) : undefined}\n                    />\n                    {/* Print Info */}\n                    <div className=\"flex-1 min-w-0\">\n                      {status.current_print && (status.state === 'RUNNING' || status.state === 'PAUSE') ? (\n                        <>\n                          <div className=\"mb-1 flex items-center gap-2\">\n                            <p className=\"text-sm text-bambu-gray\">{getStatusDisplay(status.state, status.stg_cur_name)}</p>\n                            {plateStatusPill}\n                          </div>\n                          <p className=\"text-white text-sm mb-2 truncate\">\n                            {formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t, activePlateLabel)}\n                          </p>\n                          <div className=\"flex items-center justify-between text-sm\">\n                            <div className=\"flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3\">\n                              <div\n                                className={`${status.state === 'PAUSE' ? 'bg-status-warning' : 'bg-bambu-green'} h-2 rounded-full transition-all`}\n                                style={{ width: `${status.progress || 0}%` }}\n                              />\n                            </div>\n                            <span className=\"text-white\">{Math.round(status.progress || 0)}%</span>\n                          </div>\n                          <div className=\"flex items-center gap-3 mt-2 text-xs text-bambu-gray\">\n                            {status.remaining_time != null && status.remaining_time > 0 && (\n                              <>\n                                <span className=\"flex items-center gap-1\">\n                                  <Clock className=\"w-3 h-3\" />\n                                  {formatDuration(status.remaining_time * 60)}\n                                </span>\n                                <span className=\"text-bambu-green font-medium\" title={t('printers.estimatedCompletion')}>\n                                  ETA {formatETA(status.remaining_time, timeFormat, t)}\n                                </span>\n                              </>\n                            )}\n                            {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (\n                              <span className=\"flex items-center gap-1\">\n                                <Layers className=\"w-3 h-3\" />\n                                {status.layer_num}/{status.total_layers}\n                              </span>\n                            )}\n                            {currentPrintUser && (\n                              <span className=\"flex items-center gap-1\" title={`Started by ${currentPrintUser}`}>\n                                <User className=\"w-3 h-3\" />\n                                {currentPrintUser}\n                              </span>\n                            )}\n                          </div>\n                        </>\n                      ) : (\n                        <>\n                          <p className=\"text-sm text-bambu-gray mb-1\">{t('printers.sort.status')}</p>\n                          <div className=\"mb-2 flex items-center gap-2\">\n                            <p className=\"text-white text-sm\">\n                              {getStatusDisplay(status.state, status.stg_cur_name)}\n                            </p>\n                            {plateStatusPill}\n                          </div>\n                          <div className=\"flex items-center justify-between text-sm\">\n                            <div className=\"flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3\">\n                              <div className=\"bg-bambu-dark-tertiary h-2 rounded-full\" />\n                            </div>\n                            <span className=\"text-bambu-gray\">—</span>\n                          </div>\n                          {lastPrint ? (\n                            <p className=\"text-xs text-bambu-gray mt-2 truncate\" title={lastPrint.print_name || lastPrint.filename}>\n                              Last: {lastPrint.print_name || lastPrint.filename}\n                              {lastPrint.completed_at && (\n                                <span className=\"ml-1 text-bambu-gray/60\">\n                                  • {formatDateOnly(lastPrint.completed_at, { month: 'short', day: 'numeric' })}\n                                </span>\n                              )}\n                            </p>\n                          ) : (\n                            <p className=\"text-xs text-bambu-gray mt-2\">{t('printers.readyToPrint')}</p>\n                          )}\n                        </>\n                      )}\n                    </div>\n                  </div>\n                </div>\n\n                {/* Queue Widget - always visible when there are pending items */}\n                <PrinterQueueWidget printerId={printer.id} printerModel={printer.model} loadedFilamentTypes={loadedFilamentTypes} loadedFilaments={loadedFilaments} />\n              </>\n            )}\n\n            {/* Temperatures */}\n            {status.temperatures && viewMode === 'expanded' && (() => {\n              // Use actual heater states from MQTT stream\n              const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;\n              const bedHeating = status.temperatures.bed_heating || false;\n              const chamberHeating = status.temperatures.chamber_heating || false;\n              const isDualNozzle = printer.nozzle_count === 2 || status.temperatures.nozzle_2 !== undefined;\n              // active_extruder: 0=right, 1=left\n              const activeNozzle = status.active_extruder === 1 ? 'L' : 'R';\n              // Extended nozzle data from nozzle_rack (H2 series: wear, serial, max_temp, etc.)\n              // nozzle_rack id 0 = extruder 0 = RIGHT, id 1 = extruder 1 = LEFT\n              const leftNozzleSlot = status.nozzle_rack?.find(s => s.id === 1);\n              const rightNozzleSlot = status.nozzle_rack?.find(s => s.id === 0);\n              // Single-nozzle models (H2D, H2C): use the primary nozzle (id 0)\n              const singleNozzleSlot = rightNozzleSlot || leftNozzleSlot;\n\n              return (\n                <div className=\"flex items-stretch gap-1.5 flex-wrap\">\n                  {/* Nozzle temp - combined for dual nozzle */}\n                  <div className=\"text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center\">\n                    <HeaterThermometer className=\"w-3.5 h-3.5 mb-0.5\" color=\"text-orange-400\" isHeating={nozzleHeating} />\n                    {status.temperatures.nozzle_2 !== undefined ? (\n                      <>\n                        <p className=\"text-[9px] text-bambu-gray\">L / R</p>\n                        <p className=\"text-[11px] text-white\">\n                          {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°\n                        </p>\n                      </>\n                    ) : singleNozzleSlot ? (\n                      <NozzleSlotHoverCard slot={singleNozzleSlot} index={0} activeStatus filamentName={singleNozzleSlot.filament_id ? filamentInfo?.[singleNozzleSlot.filament_id]?.name : undefined}>\n                        <div className=\"cursor-default\">\n                          <p className=\"text-[9px] text-bambu-gray\">{t('printers.temperatures.nozzle')}</p>\n                          <p className=\"text-[11px] text-white\">\n                            {Math.round(status.temperatures.nozzle || 0)}°C\n                          </p>\n                        </div>\n                      </NozzleSlotHoverCard>\n                    ) : (\n                      <>\n                        <p className=\"text-[9px] text-bambu-gray\">{t('printers.temperatures.nozzle')}</p>\n                        <p className=\"text-[11px] text-white\">\n                          {Math.round(status.temperatures.nozzle || 0)}°C\n                        </p>\n                      </>\n                    )}\n                  </div>\n                  <div className=\"text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center\">\n                    <HeaterThermometer className=\"w-3.5 h-3.5 mb-0.5\" color=\"text-blue-400\" isHeating={bedHeating} />\n                    <p className=\"text-[9px] text-bambu-gray\">{t('printers.temperatures.bed')}</p>\n                    <p className=\"text-[11px] text-white\">\n                      {Math.round(status.temperatures.bed || 0)}°C\n                    </p>\n                  </div>\n                  {status.temperatures.chamber !== undefined && (\n                    <div className=\"text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center\">\n                      <HeaterThermometer className=\"w-3.5 h-3.5 mb-0.5\" color=\"text-green-400\" isHeating={chamberHeating} />\n                      <p className=\"text-[9px] text-bambu-gray\">{t('printers.temperatures.chamber')}</p>\n                      <p className=\"text-[11px] text-white\">\n                        {Math.round(status.temperatures.chamber || 0)}°C\n                      </p>\n                    </div>\n                  )}\n                  {/* Active nozzle indicator for dual-nozzle printers */}\n                  {isDualNozzle && (\n                    <DualNozzleHoverCard\n                      leftSlot={leftNozzleSlot}\n                      rightSlot={rightNozzleSlot}\n                      activeNozzle={activeNozzle}\n                      filamentInfo={filamentInfo}\n                    >\n                      <div className=\"text-center px-3 py-1.5 bg-bambu-dark rounded-lg h-full flex flex-col justify-center items-center cursor-default\" title={t('printers.activeNozzle', { nozzle: activeNozzle === 'L' ? t('common.left') : t('common.right') })}>\n                        <div className=\"flex items-center gap-2 mb-1\">\n                          <span className={`text-[11px] font-bold ${activeNozzle === 'L' ? 'text-amber-400' : 'text-gray-500'}`}>\n                            L{leftNozzleSlot?.nozzle_diameter ? ` ${leftNozzleSlot.nozzle_diameter}` : ''}\n                          </span>\n                          <span className=\"text-[9px] text-bambu-gray/40\">·</span>\n                          <span className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>\n                            R{rightNozzleSlot?.nozzle_diameter ? ` ${rightNozzleSlot.nozzle_diameter}` : ''}\n                          </span>\n                        </div>\n                        <p className=\"text-[9px] text-bambu-gray\">{t('printers.temperatures.nozzle')}</p>\n                      </div>\n                    </DualNozzleHoverCard>\n                  )}\n                  {/* H2C nozzle rack (tool-changer dock) — only show when rack nozzles exist (IDs >= 2) */}\n                  {status.nozzle_rack && status.nozzle_rack.some(s => s.id >= 2) && (\n                    <NozzleRackCard slots={status.nozzle_rack} filamentInfo={filamentInfo} />\n                  )}\n                </div>\n              );\n            })()}\n\n            {viewMode === 'expanded' && showClearPlateButton && (\n              <button\n                type=\"button\"\n                onClick={() => clearPlateMutation.mutate()}\n                disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}\n                className=\"mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-500/20 border border-yellow-400/40 text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50\"\n                title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}\n              >\n                {clearPlateMutation.isPending ? (\n                  <Loader2 className=\"w-3 h-3 animate-spin\" />\n                ) : (\n                  <PlateClearedIcon className=\"w-4 h-4\" />\n                )}\n                {t('printers.plateStatus.markCleared')}\n              </button>\n            )}\n\n            {/* Controls - Fans + Print Buttons */}\n            {viewMode === 'expanded' && (() => {\n              // Determine print state for control buttons\n              const isRunning = status.state === 'RUNNING';\n              const isPaused = status.state === 'PAUSE';\n              const isPrinting = isRunning || isPaused;\n              const isControlBusy = stopPrintMutation.isPending || pausePrintMutation.isPending || resumePrintMutation.isPending;\n\n              // Fan data\n              const partFan = status.cooling_fan_speed;\n              const auxFan = status.big_fan1_speed;\n              const chamberFan = status.big_fan2_speed;\n\n              return (\n                <div className=\"mt-3\">\n                  {/* Section Header */}\n                  <div className=\"flex items-center gap-2 mb-2\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">\n                      {t('printers.controls')}\n                    </span>\n                    <div className=\"flex-1 h-px bg-bambu-dark-tertiary/30\" />\n                  </div>\n\n                  <div className=\"flex flex-wrap items-start justify-between gap-x-2 gap-y-2\">\n                    {/* Left: Fan Status - always visible, dynamic coloring */}\n                    <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1.5 min-w-0\">\n                      {/* Part Cooling Fan */}\n                      <div\n                        className={`flex items-center gap-1 px-1.5 py-1 rounded ${partFan && partFan > 0 ? 'bg-cyan-500/10' : 'bg-bambu-dark'}`}\n                        title={t('printers.fans.partCooling')}\n                      >\n                        <Fan className={`w-3.5 h-3.5 ${partFan && partFan > 0 ? 'text-cyan-400' : 'text-bambu-gray/50'}`} />\n                        <span className={`text-[10px] ${partFan && partFan > 0 ? 'text-cyan-400' : 'text-bambu-gray/50'}`}>\n                          {partFan ?? 0}%\n                        </span>\n                      </div>\n\n                      {/* Auxiliary Fan */}\n                      <div\n                        className={`flex items-center gap-1 px-1.5 py-1 rounded ${auxFan && auxFan > 0 ? 'bg-blue-500/10' : 'bg-bambu-dark'}`}\n                        title={t('printers.fans.auxiliary')}\n                      >\n                        <Wind className={`w-3.5 h-3.5 ${auxFan && auxFan > 0 ? 'text-blue-400' : 'text-bambu-gray/50'}`} />\n                        <span className={`text-[10px] ${auxFan && auxFan > 0 ? 'text-blue-400' : 'text-bambu-gray/50'}`}>\n                          {auxFan ?? 0}%\n                        </span>\n                      </div>\n\n                      {/* Chamber Fan */}\n                      <div\n                        className={`flex items-center gap-1 px-1.5 py-1 rounded ${chamberFan && chamberFan > 0 ? 'bg-green-500/10' : 'bg-bambu-dark'}`}\n                        title={t('printers.fans.chamber')}\n                      >\n                        <AirVent className={`w-3.5 h-3.5 ${chamberFan && chamberFan > 0 ? 'text-green-400' : 'text-bambu-gray/50'}`} />\n                        <span className={`text-[10px] ${chamberFan && chamberFan > 0 ? 'text-green-400' : 'text-bambu-gray/50'}`}>\n                          {chamberFan ?? 0}%\n                        </span>\n                      </div>\n\n                      {/* Separator */}\n                      <div className=\"w-px h-5 bg-bambu-gray/30\" />\n\n                      {/* Airduct Mode (P2S / X2D / H2*) */}\n                      {(['P2S', 'X2D', 'H2D', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => {\n                        const isHeating = status.airduct_mode === 1;\n                        const Icon = isHeating ? Flame : Snowflake;\n                        const color = isHeating ? 'text-orange-400' : 'text-sky-400';\n                        const bg = isHeating ? 'bg-orange-500/10 hover:bg-orange-500/20' : 'bg-sky-500/10 hover:bg-sky-500/20';\n                        return (\n                          <div className=\"relative\">\n                            <button\n                              onClick={() => setShowAirductMenu(showAirductMenu === printer.id ? null : printer.id)}\n                              disabled={!hasPermission('printers:control')}\n                              className={`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${bg} disabled:opacity-50 disabled:cursor-not-allowed`}\n                              title={t('printers.airduct.title')}\n                            >\n                              <Icon className={`w-3.5 h-3.5 ${color}`} />\n                              <span className={`text-[10px] ${color}`}>\n                                {isHeating ? t('printers.airduct.heating') : t('printers.airduct.cooling')}\n                              </span>\n                            </button>\n                            {showAirductMenu === printer.id && (\n                              <>\n                                <div className=\"fixed inset-0 z-40\" onClick={() => setShowAirductMenu(null)} />\n                                <div className=\"absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg py-1 min-w-[130px]\">\n                                  {([\n                                    { mode: 'cooling', label: t('printers.airduct.cooling'), modeId: 0 },\n                                    { mode: 'heating', label: t('printers.airduct.heating'), modeId: 1 },\n                                  ] as const).map(({ mode, label, modeId }) => (\n                                    <button\n                                      key={mode}\n                                      onClick={() => {\n                                        airductMutation.mutate(mode);\n                                        setShowAirductMenu(null);\n                                      }}\n                                      className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2 ${\n                                        status.airduct_mode === modeId\n                                          ? 'text-bambu-green bg-bambu-green/10'\n                                          : 'text-white hover:bg-bambu-dark-tertiary'\n                                      }`}\n                                    >\n                                      {mode === 'heating' ? <Flame className=\"w-3 h-3\" /> : <Snowflake className=\"w-3 h-3\" />}\n                                      {label}\n                                    </button>\n                                  ))}\n                                </div>\n                              </>\n                            )}\n                          </div>\n                        );\n                      })()}\n\n                      {/* Print Speed */}\n                      {(() => {\n                        const speedLabels: Record<number, string> = { 1: '50%', 2: '100%', 3: '124%', 4: '166%' };\n                        const speedPct = speedLabels[status.speed_level] || '100%';\n                        return (\n                          <div className=\"relative\">\n                            <button\n                              onClick={() => setShowSpeedMenu(showSpeedMenu === printer.id ? null : printer.id)}\n                              disabled={!isPrinting || !hasPermission('printers:control')}\n                              className={`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${\n                                isPrinting\n                                  ? 'bg-amber-500/10 hover:bg-amber-500/20'\n                                  : 'bg-bambu-dark cursor-not-allowed'\n                              }`}\n                              title={isPrinting ? t('printers.speed.title') : undefined}\n                            >\n                              <Gauge className={`w-3.5 h-3.5 ${\n                                isPrinting ? 'text-amber-400' : 'text-bambu-gray/50'\n                              }`} />\n                              <span className={`text-[10px] ${\n                                isPrinting ? 'text-amber-400' : 'text-bambu-gray/50'\n                              }`}>\n                                {speedPct}\n                              </span>\n                            </button>\n                            {showSpeedMenu === printer.id && (\n                              <>\n                                <div className=\"fixed inset-0 z-40\" onClick={() => setShowSpeedMenu(null)} />\n                                <div className=\"absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg py-1 min-w-[130px]\">\n                                  {([\n                                    { mode: 1, label: t('printers.speed.silent') },\n                                    { mode: 2, label: t('printers.speed.standard') },\n                                    { mode: 3, label: t('printers.speed.sport') },\n                                    { mode: 4, label: t('printers.speed.ludicrous') },\n                                  ] as const).map(({ mode, label }) => (\n                                    <button\n                                      key={mode}\n                                      onClick={() => {\n                                        printSpeedMutation.mutate(mode);\n                                        setShowSpeedMenu(null);\n                                      }}\n                                      className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${\n                                        status.speed_level === mode\n                                          ? 'text-bambu-green bg-bambu-green/10'\n                                          : 'text-white hover:bg-bambu-dark-tertiary'\n                                      }`}\n                                    >\n                                      {label}\n                                    </button>\n                                  ))}\n                                </div>\n                              </>\n                            )}\n                          </div>\n                        );\n                      })()}\n\n                      {/* Separator */}\n                      <div className=\"w-px h-5 bg-bambu-gray/30\" />\n\n                      {/* Bed Jog (Z-axis) — compact badge, popover holds the actual controls */}\n                      {(() => {\n                        const canControl = hasPermission('printers:control');\n                        const disabled = isPrinting || !canControl;\n                        const bambuIsPlateBelow = true; // positive Z moves plate away from nozzle\n                        const requestJog = (direction: 1 | -1) => {\n                          const signed = direction * bedJogStep * (bambuIsPlateBelow ? 1 : -1);\n                          const warnedKey = `bambuddy.bedJog.warned.${printer.id}`;\n                          const warned = (() => {\n                            try { return sessionStorage.getItem(warnedKey) === '1'; }\n                            catch { return false; }\n                          })();\n                          setShowBedJogMenu(null);\n                          if (warned) {\n                            bedJogMutation.mutate({ distance: signed, force: true });\n                          } else {\n                            setShowNotHomedModal({ distance: signed });\n                          }\n                        };\n                        return (\n                          <div className=\"relative\">\n                            <button\n                              onClick={() => setShowBedJogMenu(showBedJogMenu === printer.id ? null : printer.id)}\n                              disabled={disabled}\n                              className={`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${\n                                disabled\n                                  ? 'bg-bambu-dark cursor-not-allowed'\n                                  : 'bg-indigo-500/10 hover:bg-indigo-500/20'\n                              }`}\n                              title={!canControl ? t('printers.permission.noControl') : isPrinting ? t('printers.bedJog.disabledWhilePrinting') : t('printers.bedJog.title')}\n                            >\n                              <MoveVertical className={`w-3.5 h-3.5 ${disabled ? 'text-bambu-gray/50' : 'text-indigo-400'}`} />\n                              <span className={`text-[10px] ${disabled ? 'text-bambu-gray/50' : 'text-indigo-400'}`}>\n                                {t('printers.bedJog.bed')}\n                              </span>\n                              <span className={`text-[10px] tabular-nums opacity-70 ${disabled ? 'text-bambu-gray/50' : 'text-indigo-400'}`}>\n                                {bedJogStep}mm\n                              </span>\n                            </button>\n                            {showBedJogMenu === printer.id && (\n                              <>\n                                <div className=\"fixed inset-0 z-40\" onClick={() => setShowBedJogMenu(null)} />\n                                <div className=\"absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg p-2 min-w-[140px]\">\n                                  <div className=\"flex items-center justify-between gap-1 mb-2\">\n                                    <button\n                                      onClick={() => requestJog(-1)}\n                                      className=\"flex-1 flex items-center justify-center py-1.5 rounded bg-indigo-500/15 hover:bg-indigo-500/30 text-indigo-300\"\n                                      aria-label={t('printers.bedJog.up')}\n                                    >\n                                      <ArrowUp className=\"w-4 h-4\" />\n                                    </button>\n                                    <button\n                                      onClick={() => requestJog(1)}\n                                      className=\"flex-1 flex items-center justify-center py-1.5 rounded bg-indigo-500/15 hover:bg-indigo-500/30 text-indigo-300\"\n                                      aria-label={t('printers.bedJog.down')}\n                                    >\n                                      <ArrowDown className=\"w-4 h-4\" />\n                                    </button>\n                                  </div>\n                                  <div className=\"text-[9px] uppercase tracking-wider text-bambu-gray/70 px-1 mb-1\">\n                                    {t('printers.bedJog.step')}\n                                  </div>\n                                  <div className=\"flex gap-1\">\n                                    {[1, 10, 50].map((step) => (\n                                      <button\n                                        key={step}\n                                        onClick={() => setBedJogStep(step)}\n                                        className={`flex-1 px-1 py-1 rounded text-[10px] transition-colors ${\n                                          bedJogStep === step\n                                            ? 'bg-bambu-green/20 text-bambu-green'\n                                            : 'bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary'\n                                        }`}\n                                      >\n                                        {step}\n                                      </button>\n                                    ))}\n                                  </div>\n                                </div>\n                              </>\n                            )}\n                          </div>\n                        );\n                      })()}\n\n                    </div>\n\n                    {/* Right: Print Control Buttons */}\n                    <div className=\"flex items-center gap-2 flex-shrink-0 max-[550px]:self-start\">\n                      {/* Stop button */}\n                      <button\n                        onClick={() => setShowStopConfirm(true)}\n                        disabled={!isPrinting || isControlBusy || !hasPermission('printers:control')}\n                        className={`\n                          flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium\n                          transition-colors\n                          ${isPrinting && hasPermission('printers:control')\n                            ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'\n                            : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'\n                          }\n                        `}\n                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('printers.stop')}\n                      >\n                        <Square className=\"w-3 h-3\" />\n                        {t('printers.stop')}\n                      </button>\n\n                      {/* Pause/Resume button */}\n                      <button\n                        onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}\n                        disabled={!isPrinting || isControlBusy || !hasPermission('printers:control')}\n                        className={`\n                          flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium\n                          transition-colors\n                          ${isPrinting && hasPermission('printers:control')\n                            ? isPaused\n                              ? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'\n                              : 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'\n                            : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'\n                          }\n                        `}\n                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : (isPaused ? t('printers.resume') : t('printers.pause'))}\n                      >\n                        {isPaused ? <Play className=\"w-3 h-3\" /> : <Pause className=\"w-3 h-3\" />}\n                        {isPaused ? t('printers.resume') : t('printers.pause')}\n                      </button>\n                    </div>\n                  </div>\n                </div>\n              );\n            })()}\n\n            {/* AMS Units - 2-Column Grid Layout */}\n            {(amsData?.length > 0 || status.vt_tray.length > 0) && viewMode === 'expanded' && (() => {\n              // Separate regular AMS (4-tray) from HT AMS (1-tray)\n              const regularAms = amsData.filter(ams => ams.tray.length > 1);\n              const htAms = amsData.filter(ams => ams.tray.length === 1);\n              const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;\n\n              return (\n                <div className=\"mt-3\">\n                  {/* Section Header */}\n                  <div className=\"flex items-center gap-2 mb-2\">\n                    <span className=\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\">\n                      {t('printers.filaments')}\n                    </span>\n                    <div className=\"flex-1 h-px bg-bambu-dark-tertiary/30\" />\n                  </div>\n\n                  {/* AMS Content */}\n                  <div className=\"space-y-3\">\n                    {/* Row 1-2: Regular AMS (4-tray) in 2-column grid */}\n                    {regularAms.length > 0 && (\n                      <div className=\"grid grid-cols-2 gap-3\">\n                        {regularAms.map((ams) => {\n                        const mappedExtruderId = amsExtruderMap[String(ams.id)];\n                        const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;\n                        const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;\n                        const isLeftNozzle = extruderId === 1;\n                        const isRightNozzle = extruderId === 0;\n\n                        return (\n                          <div key={ams.id} className=\"p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30\">\n                            {/* Header: Label + Stats (no icon) */}\n                            <div className=\"flex items-center justify-between mb-2\">\n                              <div className=\"flex items-center gap-1.5\">\n                                {/* AMS name — hover to see serial, firmware, and edit friendly name */}\n                                <AmsNameHoverCard\n                                  ams={ams}\n                                  printerId={printer.id}\n                                  label={getAmsLabel(ams.id, ams.tray.length)}\n                                  amsLabels={amsLabels}\n                                  canEdit={hasPermission('printers:update')}\n                                  onSaved={refetchAmsLabels}\n                                >\n                                  <span className=\"text-[10px] text-white font-medium cursor-default select-none\">\n                                    {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}\n                                  </span>\n                                </AmsNameHoverCard>\n                                {isDualNozzle && (isLeftNozzle || isRightNozzle) && (\n                                  <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />\n                                )}\n                              </div>\n                              {(ams.humidity != null || ams.temp != null) && (\n                                <div className=\"flex items-center gap-1.5 max-[550px]:flex-col max-[550px]:items-start\">\n                                  {ams.humidity != null && (\n                                    <HumidityIndicator\n                                      humidity={ams.humidity}\n                                      goodThreshold={amsThresholds?.humidityGood}\n                                      fairThreshold={amsThresholds?.humidityFair}\n                                      onClick={() => setAmsHistoryModal({\n                                        amsId: ams.id,\n                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),\n                                        mode: 'humidity',\n                                      })}\n                                      compact\n                                    />\n                                  )}\n                                  {ams.temp != null && (\n                                    <TemperatureIndicator\n                                      temp={ams.temp}\n                                      goodThreshold={amsThresholds?.tempGood}\n                                      fairThreshold={amsThresholds?.tempFair}\n                                      onClick={() => setAmsHistoryModal({\n                                        amsId: ams.id,\n                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),\n                                        mode: 'temperature',\n                                      })}\n                                      compact\n                                    />\n                                  )}\n                                  {/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s) */}\n                                  {status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (\n                                    <button\n                                      disabled={!!(ams.dry_sf_reason?.length && ams.dry_time === 0)}\n                                      onClick={(e) => {\n                                        if (ams.dry_time > 0) {\n                                          stopDryingMutation.mutate(ams.id);\n                                        } else if (dryingPopoverAmsId === ams.id) {\n                                          setDryingPopoverAmsId(null);\n                                        } else {\n                                          const firstTray = ams.tray.find(t => t.tray_type);\n                                          const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();\n                                          const preset = dryingPresets[filType] || dryingPresets['PLA'];\n                                          const moduleType = ams.module_type as 'n3f' | 'n3s';\n                                          setDryingFilament(filType);\n                                          setDryingTemp(preset[moduleType] || preset.n3f);\n                                          setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);\n                                          setDryingRotateTray(false);\n                                          setDryingPopoverModuleType(ams.module_type);\n                                          setDryingPopoverAmsId(ams.id);\n                                          const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();\n                                          setDryingPopoverPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) });\n                                        }\n                                      }}\n                                      className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${\n                                        ams.dry_time > 0\n                                          ? 'bg-amber-500/20 text-amber-400'\n                                          : ams.dry_sf_reason?.length\n                                            ? 'bg-bambu-dark-tertiary/30 text-bambu-gray/50 cursor-not-allowed'\n                                            : 'bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'\n                                      }`}\n                                      title={ams.dry_time > 0 ? t('printers.drying.stop') : ams.dry_sf_reason?.length ? t('printers.drying.powerRequired') : t('printers.drying.start')}\n                                    >\n                                      <Flame className=\"w-3 h-3\" />\n                                    </button>\n                                  )}\n                                </div>\n                              )}\n                            </div>\n                            {/* Drying status bar */}\n                            {ams.dry_time > 0 && (\n                              <div className=\"flex items-center gap-2 px-2 py-1 mb-1 bg-amber-500/10 border border-amber-500/20 rounded text-[9px]\">\n                                <Flame className=\"w-3 h-3 text-amber-400 shrink-0\" />\n                                <span className=\"text-amber-400 font-medium\">{t('printers.drying.active')}</span>\n                                <span className=\"text-amber-300/70\">\n                                  {t('printers.drying.timeRemaining', {\n                                    time: ams.dry_time >= 60\n                                      ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`\n                                      : `${ams.dry_time}m`\n                                  })}\n                                </span>\n                                <button\n                                  onClick={() => stopDryingMutation.mutate(ams.id)}\n                                  disabled={stopDryingMutation.isPending}\n                                  className=\"ml-auto text-amber-400 hover:text-amber-300 transition-colors disabled:opacity-50\"\n                                  title={t('printers.drying.stop')}\n                                >\n                                  <X className=\"w-3 h-3\" />\n                                </button>\n                              </div>\n                            )}\n                            {/* Slots grid: 4 columns - always render 4 slots */}\n                            <div className=\"grid grid-cols-4 gap-1.5\">\n                              {[0, 1, 2, 3].map((slotIdx) => {\n                                // Find tray data for this slot (may be undefined if data incomplete)\n                                // Use array index if available, as tray.id may not always be set\n                                const tray = ams.tray[slotIdx] || ams.tray.find(t => t.id === slotIdx);\n                                const hasFillLevel = tray?.tray_type && tray.remain >= 0;\n                                const isEmpty = !tray?.tray_type;\n                                // Check if this is the currently loaded tray\n                                // Global tray ID = ams.id * 4 + slot index (for standard AMS)\n                                const globalTrayId = ams.id * 4 + slotIdx;\n                                const isActive = effectiveTrayNow === globalTrayId;\n                                // Get cloud preset info if available\n                                const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;\n                                // Get saved slot preset mapping (for user-configured slots)\n                                const slotPreset = slotPresets?.[globalTrayId];\n\n                                // Fill level fallback chain: Spoolman → Inventory → AMS remain\n                                const trayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx))?.toUpperCase();\n                                const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;\n                                const spoolmanFill = getSpoolmanFillLevel(linkedSpool);\n                                const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx);\n                                const inventoryFill = (() => {\n                                  const sp = inventoryAssignment?.spool;\n                                  if (sp && sp.label_weight > 0 && sp.weight_used != null) {\n                                    return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);\n                                  }\n                                  return null;\n                                })();\n                                // If inventory says 0% but AMS reports positive remain, prefer AMS\n                                // (inventory weight_used may be stale or over-counted — #676)\n                                const resolvedInventoryFill = (inventoryFill === 0 && hasFillLevel && tray.remain > 0)\n                                  ? null : inventoryFill;\n                                const effectiveFill = spoolmanFill ?? resolvedInventoryFill ?? (hasFillLevel ? tray.remain : null);\n                                const fillSource = spoolmanFill !== null ? 'spoolman' as const\n                                  : resolvedInventoryFill !== null ? 'inventory' as const\n                                  : hasFillLevel ? 'ams' as const\n                                  : undefined;\n\n                                // Build filament data for hover card\n                                const filamentData = tray?.tray_type ? {\n                                  vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',\n                                  profile: slotPreset?.preset_name || cloudInfo?.name || inventoryAssignment?.spool?.slicer_filament_name || tray.tray_sub_brands || tray.tray_type,\n                                  colorName: getColorName(tray.tray_color || ''),\n                                  colorHex: tray.tray_color || null,\n                                  kFactor: formatKValue(tray.k),\n                                  fillLevel: effectiveFill,\n                                  trayUuid: tray.tray_uuid || null,\n                                  tagUid: tray.tag_uid || null,\n                                  fillSource,\n                                } : null;\n\n                                // Check if this specific slot is being refreshed\n                                const isRefreshing = refreshingSlot?.amsId === ams.id &&\n                                  refreshingSlot?.slotId === slotIdx;\n\n                                // Slot visual content (goes inside hover card)\n                                const slotVisual = (\n                                  <div\n                                    className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}\n                                  >\n                                    {/* Filament color circle with 1-based slot number centered inside */}\n                                    <FilamentSlotCircle\n                                      trayColor={tray?.tray_color}\n                                      trayType={tray?.tray_type}\n                                      isEmpty={isEmpty}\n                                      slotNumber={slotIdx + 1}\n                                    />\n                                    <div className=\"text-[9px] text-white font-bold truncate\">\n                                      {tray?.tray_type || '—'}\n                                    </div>\n                                    {/* Fill bar */}\n                                    <div className=\"mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden\">\n                                      {effectiveFill !== null && effectiveFill >= 0 && !isEmpty && tray && (\n                                        <div\n                                          className=\"h-full rounded-full transition-all\"\n                                          style={{\n                                            width: `${effectiveFill}%`,\n                                            backgroundColor: getFillBarColor(effectiveFill),\n                                          }}\n                                        />\n                                      )}\n                                    </div>\n                                  </div>\n                                );\n\n                                // Wrapper with menu button, dropdown, and loading overlay (outside hover card)\n                                return (\n                                  <div key={slotIdx} className=\"relative group\">\n                                    {/* Loading overlay during RFID re-read */}\n                                    {isRefreshing && (\n                                      <div className=\"absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20\">\n                                        <RefreshCw className=\"w-4 h-4 text-bambu-green animate-spin\" />\n                                      </div>\n                                    )}\n                                    {/* Menu button - appears on hover, hidden when printer busy */}\n                                    {status?.state !== 'RUNNING' && (\n                                      <button\n                                        onClick={(e) => {\n                                          e.stopPropagation();\n                                          setAmsSlotMenu(\n                                            amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx\n                                              ? null\n                                              : { amsId: ams.id, slotId: slotIdx }\n                                          );\n                                        }}\n                                        className=\"absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary\"\n                                        title={t('printers.slotOptions')}\n                                      >\n                                        <MoreVertical className=\"w-2.5 h-2.5 text-bambu-gray\" />\n                                      </button>\n                                    )}\n                                    {/* Dropdown menu */}\n                                    {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx && (\n                                      <div className=\"absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]\">\n                                        <button\n                                          className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${\n                                            hasPermission('printers:ams_rfid')\n                                              ? 'text-white hover:bg-bambu-dark-tertiary'\n                                              : 'text-bambu-gray/50 cursor-not-allowed'\n                                          }`}\n                                          onClick={(e) => {\n                                            e.stopPropagation();\n                                            if (!hasPermission('printers:ams_rfid')) return;\n                                            refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });\n                                            setAmsSlotMenu(null);\n                                          }}\n                                          disabled={isRefreshing || !hasPermission('printers:ams_rfid')}\n                                          title={!hasPermission('printers:ams_rfid') ? t('printers.permission.noAmsRfid') : undefined}\n                                        >\n                                          <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />\n                                          {t('printers.rfid.reread')}\n                                        </button>\n                                      </div>\n                                    )}\n                                    {/* Hover card wraps only the visual content */}\n                                    {filamentData ? (\n                                      <FilamentHoverCard\n                                        data={filamentData}\n                                        spoolman={{\n                                          enabled: spoolmanEnabled,\n                                          linkedSpoolId: trayTag\n                                            ? linkedSpools?.[trayTag]?.id\n                                            : undefined,\n                                          spoolmanUrl,\n                                          syncMode: spoolmanSyncMode,\n                                          onLinkSpool: spoolmanEnabled ? () => {\n                                            const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx)).toUpperCase();\n                                            setLinkSpoolModal({\n                                              tagUid: filamentData.tagUid || linkTag,\n                                              trayUuid: filamentData.trayUuid || '',\n                                              printerId: printer.id,\n                                              amsId: ams.id,\n                                              trayId: slotIdx,\n                                            });\n                                          } : undefined,\n                                          onUnlinkSpool: linkedSpool?.id ? () => unlinkSpoolMutation.mutate(linkedSpool.id) : undefined,\n                                        }}\n                                        inventory={spoolmanEnabled ? undefined : (() => {\n                                          const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx);\n                                          return {\n                                            assignedSpool: assignment?.spool ? {\n                                              id: assignment.spool.id,\n                                              material: assignment.spool.material,\n                                              brand: assignment.spool.brand,\n                                              color_name: assignment.spool.color_name,\n                                              remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),\n                                            } : null,\n                                            onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({\n                                              printerId: printer.id,\n                                              amsId: ams.id,\n                                              trayId: slotIdx,\n                                              trayInfo: {\n                                                type: tray?.tray_type || filamentData.profile,\n                                                material: tray?.tray_type ?? undefined,\n                                                profile: filamentData.profile,\n                                                color: filamentData.colorHex || '',\n                                                location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,\n                                              },\n                                            }) : undefined,\n                                            onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,\n                                          };\n                                        })()}\n                                        configureSlot={{\n                                          enabled: hasPermission('printers:control'),\n                                          onConfigure: () => setConfigureSlotModal({\n                                            amsId: ams.id,\n                                            trayId: slotIdx,\n                                            trayCount: ams.tray.length,\n                                            trayType: tray?.tray_type || undefined,\n                                            trayColor: tray?.tray_color || undefined,\n                                            traySubBrands: tray?.tray_sub_brands || undefined,\n                                            trayInfoIdx: tray?.tray_info_idx || undefined,\n                                            extruderId: mappedExtruderId,\n                                            caliIdx: tray?.cali_idx,\n                                            savedPresetId: slotPreset?.preset_id,\n                                          }),\n                                        }}\n                                      >\n                                        {slotVisual}\n                                      </FilamentHoverCard>\n                                    ) : (\n                                      <EmptySlotHoverCard\n                                        configureSlot={{\n                                          enabled: hasPermission('printers:control'),\n                                          onConfigure: () => setConfigureSlotModal({\n                                            amsId: ams.id,\n                                            trayId: slotIdx,\n                                            trayCount: ams.tray.length,\n                                            extruderId: mappedExtruderId,\n                                          }),\n                                        }}\n                                        inventory={spoolmanEnabled ? undefined : {\n                                          onAssignSpool: () => setAssignSpoolModal({\n                                            printerId: printer.id,\n                                            amsId: ams.id,\n                                            trayId: slotIdx,\n                                            trayInfo: {\n                                              type: '',\n                                              color: '',\n                                              location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,\n                                            },\n                                          }),\n                                        }}\n                                      >\n                                        {slotVisual}\n                                      </EmptySlotHoverCard>\n                                    )}\n                                  </div>\n                                );\n                              })}\n                            </div>\n                          </div>\n                        );\n                      })}\n                    </div>\n                  )}\n\n                    {/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */}\n                    {(htAms.length > 0 || status.vt_tray.length > 0) && (\n                      <div className=\"grid grid-cols-4 gap-3\">\n                      {/* HT AMS units - name/badge top, slot left, stats right */}\n                      {htAms.map((ams) => {\n                        const mappedExtruderId = amsExtruderMap[String(ams.id)];\n                        const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;\n                        const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;\n                        const isLeftNozzle = extruderId === 1;\n                        const isRightNozzle = extruderId === 0;\n                        const tray = ams.tray[0];\n                        const hasFillLevel = tray?.tray_type && tray.remain >= 0;\n                        const isEmpty = !tray?.tray_type;\n                        // Check if this is the currently loaded tray\n                        const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);\n                        const isActive = effectiveTrayNow === globalTrayId;\n                        // Get cloud preset info if available\n                        const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;\n                        // Get saved slot preset mapping (for user-configured slots)\n                        const slotPreset = slotPresets?.[globalTrayId];\n                        const htSlotId = tray?.id ?? 0;\n\n                        // Fill level fallback chain: Spoolman → Inventory → AMS remain\n                        const htTrayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId))?.toUpperCase();\n                        const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;\n                        const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);\n                        const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htSlotId);\n                        const htInventoryFill = (() => {\n                          const sp = htInventoryAssignment?.spool;\n                          if (sp && sp.label_weight > 0 && sp.weight_used != null) {\n                            return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);\n                          }\n                          return null;\n                        })();\n                        // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)\n                        const htResolvedInventoryFill = (htInventoryFill === 0 && hasFillLevel && tray.remain > 0)\n                          ? null : htInventoryFill;\n                        const htEffectiveFill = htSpoolmanFill ?? htResolvedInventoryFill ?? (hasFillLevel ? tray.remain : null);\n                        const htFillSource = htSpoolmanFill !== null ? 'spoolman' as const\n                          : htResolvedInventoryFill !== null ? 'inventory' as const\n                          : hasFillLevel ? 'ams' as const\n                          : undefined;\n\n                        // Build filament data for hover card\n                        const filamentData = tray?.tray_type ? {\n                          vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',\n                          profile: slotPreset?.preset_name || cloudInfo?.name || htInventoryAssignment?.spool?.slicer_filament_name || tray.tray_sub_brands || tray.tray_type,\n                          colorName: getColorName(tray.tray_color || ''),\n                          colorHex: tray.tray_color || null,\n                          kFactor: formatKValue(tray.k),\n                          fillLevel: htEffectiveFill,\n                          trayUuid: tray.tray_uuid || null,\n                          tagUid: tray.tag_uid || null,\n                          fillSource: htFillSource,\n                        } : null;\n\n                        // Check if this specific slot is being refreshed\n                        const isHtRefreshing = refreshingSlot?.amsId === ams.id &&\n                          refreshingSlot?.slotId === htSlotId;\n\n                        // Slot visual content (goes inside hover card)\n                        const slotVisual = (\n                          <div\n                            className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}\n                          >\n                            {/* Filament color circle with 1-based slot number centered inside */}\n                            <FilamentSlotCircle\n                              trayColor={tray?.tray_color}\n                              trayType={tray?.tray_type}\n                              isEmpty={isEmpty}\n                              slotNumber={1}\n                            />\n                            <div className=\"text-[9px] text-white font-bold truncate\">\n                              {tray?.tray_type || '—'}\n                            </div>\n                            {/* Fill bar */}\n                            <div className=\"mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden\">\n                              {htEffectiveFill !== null && htEffectiveFill >= 0 && !isEmpty && (\n                                <div\n                                  className=\"h-full rounded-full transition-all\"\n                                  style={{\n                                    width: `${htEffectiveFill}%`,\n                                    backgroundColor: getFillBarColor(htEffectiveFill),\n                                  }}\n                                />\n                              )}\n                            </div>\n                          </div>\n                        );\n\n                        return (\n                          <div key={ams.id} className=\"p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30\">\n                            {/* Row 1: Label + Nozzle + Drying */}\n                            <div className=\"flex items-center gap-1 mb-2\">\n                              {/* AMS name — hover to see serial, firmware, and edit friendly name */}\n                              <AmsNameHoverCard\n                                ams={ams}\n                                printerId={printer.id}\n                                label={getAmsLabel(ams.id, ams.tray.length)}\n                                amsLabels={amsLabels}\n                                canEdit={hasPermission('printers:update')}\n                                onSaved={refetchAmsLabels}\n                              >\n                                <span className=\"text-[10px] text-white font-medium cursor-default select-none\">\n                                  {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}\n                                </span>\n                              </AmsNameHoverCard>\n                              {isDualNozzle && (isLeftNozzle || isRightNozzle) && (\n                                <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />\n                              )}\n                              {/* Drying button for HT AMS */}\n                              {status.supports_drying && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (\n                                <div className=\"relative ml-auto\">\n                                  <button\n                                    onClick={(e) => {\n                                      if (ams.dry_time > 0) {\n                                        stopDryingMutation.mutate(ams.id);\n                                      } else if (dryingPopoverAmsId === ams.id) {\n                                        setDryingPopoverAmsId(null);\n                                      } else {\n                                        const firstTray = ams.tray.find(t => t.tray_type);\n                                        const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();\n                                        const preset = dryingPresets[filType] || dryingPresets['PLA'];\n                                        const moduleType = ams.module_type as 'n3f' | 'n3s';\n                                        setDryingFilament(filType);\n                                        setDryingTemp(preset[moduleType] || preset.n3f);\n                                        setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);\n                                        setDryingRotateTray(false);\n                                        setDryingPopoverModuleType(ams.module_type);\n                                        setDryingPopoverAmsId(ams.id);\n                                        const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();\n                                        setDryingPopoverPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - 240) });\n                                      }\n                                    }}\n                                    className={`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${\n                                      ams.dry_time > 0\n                                        ? 'bg-amber-500/20 text-amber-400'\n                                        : 'bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'\n                                    }`}\n                                    title={ams.dry_time > 0 ? t('printers.drying.stop') : t('printers.drying.start')}\n                                  >\n                                    <Flame className=\"w-3 h-3\" />\n                                  </button>\n                                </div>\n                              )}\n                            </div>\n                            {/* HT AMS drying status bar */}\n                            {ams.dry_time > 0 && (\n                              <div className=\"flex items-center gap-1.5 px-2 py-1 mb-1 bg-amber-500/10 border border-amber-500/20 rounded text-[9px] whitespace-nowrap overflow-hidden\">\n                                <Flame className=\"w-3 h-3 text-amber-400 shrink-0\" />\n                                <span className=\"text-amber-300/70 text-[8px] truncate\">\n                                  {ams.dry_time >= 60\n                                    ? `${Math.floor(ams.dry_time / 60)}h ${ams.dry_time % 60}m`\n                                    : `${ams.dry_time}m`}\n                                </span>\n                                <button\n                                  onClick={() => stopDryingMutation.mutate(ams.id)}\n                                  disabled={stopDryingMutation.isPending}\n                                  className=\"ml-auto text-amber-400 hover:text-amber-300 transition-colors disabled:opacity-50 shrink-0\"\n                                  title={t('printers.drying.stop')}\n                                >\n                                  <X className=\"w-3 h-3\" />\n                                </button>\n                              </div>\n                            )}\n                            {/* Row 2: Slot (left) + Stats (right stacked) */}\n                            <div className=\"flex gap-1.5 max-[550px]:flex-col max-[550px]:items-start\">\n                              {/* Slot wrapper with menu button, dropdown, and loading overlay */}\n                              <div className=\"relative group flex-1 max-[550px]:w-full\">\n                                {/* Loading overlay during RFID re-read */}\n                                {isHtRefreshing && (\n                                  <div className=\"absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20\">\n                                    <RefreshCw className=\"w-4 h-4 text-bambu-green animate-spin\" />\n                                  </div>\n                                )}\n                                {/* Menu button - appears on hover, hidden when printer busy */}\n                                {status?.state !== 'RUNNING' && (\n                                  <button\n                                    onClick={(e) => {\n                                      e.stopPropagation();\n                                      setAmsSlotMenu(\n                                        amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId\n                                          ? null\n                                          : { amsId: ams.id, slotId: htSlotId }\n                                      );\n                                    }}\n                                    className=\"absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary\"\n                                    title={t('printers.slotOptions')}\n                                  >\n                                    <MoreVertical className=\"w-2.5 h-2.5 text-bambu-gray\" />\n                                  </button>\n                                )}\n                                {/* Dropdown menu */}\n                                {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId && (\n                                  <div className=\"absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]\">\n                                    <button\n                                      className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${\n                                        hasPermission('printers:ams_rfid')\n                                          ? 'text-white hover:bg-bambu-dark-tertiary'\n                                          : 'text-bambu-gray/50 cursor-not-allowed'\n                                      }`}\n                                      onClick={(e) => {\n                                        e.stopPropagation();\n                                        if (!hasPermission('printers:ams_rfid')) return;\n                                        refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });\n                                        setAmsSlotMenu(null);\n                                      }}\n                                      disabled={isHtRefreshing || !hasPermission('printers:ams_rfid')}\n                                      title={!hasPermission('printers:ams_rfid') ? t('printers.permission.noAmsRfid') : undefined}\n                                    >\n                                      <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />\n                                      {t('printers.rfid.reread')}\n                                    </button>\n                                  </div>\n                                )}\n                                {/* Hover card wraps only the visual content */}\n                                {filamentData ? (\n                                  <FilamentHoverCard\n                                    data={filamentData}\n                                    spoolman={{\n                                      enabled: spoolmanEnabled,\n                                      linkedSpoolId: htTrayTag\n                                        ? linkedSpools?.[htTrayTag]?.id\n                                        : undefined,\n                                      spoolmanUrl,\n                                      syncMode: spoolmanSyncMode,\n                                      onLinkSpool: spoolmanEnabled ? () => {\n                                        const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId)).toUpperCase();\n                                        setLinkSpoolModal({\n                                          tagUid: filamentData.tagUid || linkTag,\n                                          trayUuid: filamentData.trayUuid || '',\n                                          printerId: printer.id,\n                                          amsId: ams.id,\n                                          trayId: htSlotId,\n                                        });\n                                      } : undefined,\n                                      onUnlinkSpool: htLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(htLinkedSpool.id) : undefined,\n                                    }}\n                                    inventory={spoolmanEnabled ? undefined : (() => {\n                                      const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId);\n                                      return {\n                                        assignedSpool: assignment?.spool ? {\n                                          id: assignment.spool.id,\n                                          material: assignment.spool.material,\n                                          brand: assignment.spool.brand,\n                                          color_name: assignment.spool.color_name,\n                                          remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),\n                                        } : null,\n                                        onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({\n                                          printerId: printer.id,\n                                          amsId: ams.id,\n                                          trayId: htSlotId,\n                                          trayInfo: {\n                                            type: tray?.tray_type || filamentData.profile,\n                                            material: tray?.tray_type ?? undefined,\n                                            profile: filamentData.profile,\n                                            color: filamentData.colorHex || '',\n                                            location: getAmsLabel(ams.id, ams.tray.length),\n                                          },\n                                        }) : undefined,\n                                        onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,\n                                      };\n                                    })()}\n                                    configureSlot={{\n                                      enabled: hasPermission('printers:control'),\n                                      onConfigure: () => setConfigureSlotModal({\n                                        amsId: ams.id,\n                                        trayId: htSlotId,\n                                        trayCount: ams.tray.length,\n                                        trayType: tray?.tray_type || undefined,\n                                        trayColor: tray?.tray_color || undefined,\n                                        traySubBrands: tray?.tray_sub_brands || undefined,\n                                        trayInfoIdx: tray?.tray_info_idx || undefined,\n                                        extruderId: mappedExtruderId,\n                                        caliIdx: tray?.cali_idx,\n                                        savedPresetId: slotPreset?.preset_id,\n                                      }),\n                                    }}\n                                  >\n                                    {slotVisual}\n                                  </FilamentHoverCard>\n                                ) : (\n                                  <EmptySlotHoverCard\n                                    configureSlot={{\n                                      enabled: hasPermission('printers:control'),\n                                      onConfigure: () => setConfigureSlotModal({\n                                        amsId: ams.id,\n                                        trayId: htSlotId,\n                                        trayCount: ams.tray.length,\n                                        extruderId: mappedExtruderId,\n                                      }),\n                                    }}\n                                    inventory={spoolmanEnabled ? undefined : {\n                                      onAssignSpool: () => setAssignSpoolModal({\n                                        printerId: printer.id,\n                                        amsId: ams.id,\n                                        trayId: htSlotId,\n                                        trayInfo: {\n                                          type: '',\n                                          color: '',\n                                          location: getAmsLabel(ams.id, ams.tray.length),\n                                        },\n                                      }),\n                                    }}\n                                  >\n                                    {slotVisual}\n                                  </EmptySlotHoverCard>\n                                )}\n                              </div>\n                              {/* Stats stacked vertically: Temp on top, Humidity below */}\n                              {(ams.humidity != null || ams.temp != null) && (\n                                <div className=\"flex flex-col justify-center gap-1 shrink-0 max-[550px]:w-full\">\n                                  {ams.temp != null && (\n                                    <TemperatureIndicator\n                                      temp={ams.temp}\n                                      goodThreshold={amsThresholds?.tempGood}\n                                      fairThreshold={amsThresholds?.tempFair}\n                                      onClick={() => setAmsHistoryModal({\n                                        amsId: ams.id,\n                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),\n                                        mode: 'temperature',\n                                      })}\n                                      compact\n                                    />\n                                  )}\n                                  {ams.humidity != null && (\n                                    <HumidityIndicator\n                                      humidity={ams.humidity}\n                                      goodThreshold={amsThresholds?.humidityGood}\n                                      fairThreshold={amsThresholds?.humidityFair}\n                                      onClick={() => setAmsHistoryModal({\n                                        amsId: ams.id,\n                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),\n                                        mode: 'humidity',\n                                      })}\n                                      compact\n                                    />\n                                  )}\n                                </div>\n                              )}\n                            </div>\n                          </div>\n                        );\n                      })}\n                      {/* External spool(s) - grouped in one card like regular AMS */}\n                      {status.vt_tray.length > 0 && (\n                        <div className={`p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30 ${status.vt_tray.length === 1 ? 'max-w-[50%]' : ''}`}>\n                          <div className=\"flex items-center gap-1 mb-2\">\n                            <span className=\"text-[10px] text-white font-medium\">{t('printers.external')}</span>\n                          </div>\n                          <div className={`grid ${status.vt_tray.length > 1 ? 'grid-cols-2' : 'grid-cols-1'} gap-1.5`}>\n                            {[...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)).map((extTray) => {\n                              const extTrayId = extTray.id ?? 254;\n                              // On dual-nozzle (H2C/H2D), tray_now=254 means \"external spool\"\n                              // generically — use active_extruder to determine L vs R:\n                              // extruder 1=left → Ext-L (id=254), extruder 0=right → Ext-R (id=255)\n                              const isExtActive = isDualNozzle && effectiveTrayNow === 254\n                                ? (extTrayId === 254 && status.active_extruder === 1) ||\n                                  (extTrayId === 255 && status.active_extruder === 0)\n                                : effectiveTrayNow === extTrayId;\n                              const slotTrayId = extTrayId - 254; // 0 or 1\n                              const extLabel = isDualNozzle\n                                ? (extTrayId === 254 ? t('printers.extL') : t('printers.extR'))\n                                : '';\n                              const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;\n                              const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];\n\n                              const extTrayTag = (extTray.tray_uuid || extTray.tag_uid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId))?.toUpperCase();\n                              const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;\n                              const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);\n                              const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId);\n                              const extInventoryFill = (() => {\n                                const sp = extInventoryAssignment?.spool;\n                                if (sp && sp.label_weight > 0 && sp.weight_used != null) {\n                                  return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);\n                                }\n                                return null;\n                              })();\n                              const extHasFillLevel = extTray.tray_type && extTray.remain >= 0;\n                              // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)\n                              const extResolvedInventoryFill = (extInventoryFill === 0 && extHasFillLevel && extTray.remain > 0)\n                                ? null : extInventoryFill;\n                              const extEffectiveFill = extSpoolmanFill ?? extResolvedInventoryFill ?? (extHasFillLevel ? extTray.remain : null);\n                              const extFillSource = extSpoolmanFill !== null ? 'spoolman' as const\n                                : extResolvedInventoryFill !== null ? 'inventory' as const\n                                : extHasFillLevel ? 'ams' as const\n                                : undefined;\n\n                              const extFilamentData = {\n                                vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',\n                                profile: extSlotPreset?.preset_name || extCloudInfo?.name || extInventoryAssignment?.spool?.slicer_filament_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',\n                                colorName: getColorName(extTray.tray_color || ''),\n                                colorHex: extTray.tray_color || null,\n                                kFactor: formatKValue(extTray.k),\n                                fillLevel: extEffectiveFill,\n                                trayUuid: extTray.tray_uuid || null,\n                                tagUid: extTray.tag_uid || null,\n                                fillSource: extFillSource,\n                              };\n\n                              const isEmpty = !extTray.tray_type;\n                              const extSlotContent = (\n                                <div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>\n                                  {/* Filament color circle with 1-based slot number centered inside */}\n                                  <FilamentSlotCircle\n                                    trayColor={extTray.tray_color}\n                                    trayType={extTray.tray_type}\n                                    isEmpty={isEmpty}\n                                    slotNumber={slotTrayId + 1}\n                                  />\n                                  <div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>\n                                    {extTray.tray_type || '—'}\n                                  </div>\n                                  <div className=\"mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden\">\n                                    {extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty && (\n                                      <div\n                                        className=\"h-full rounded-full transition-all\"\n                                        style={{\n                                          width: `${extEffectiveFill}%`,\n                                          backgroundColor: getFillBarColor(extEffectiveFill),\n                                        }}\n                                      />\n                                    )}\n                                  </div>\n                                  {extLabel && <div className=\"text-[7px] text-white/40 mt-0.5 truncate\">{extLabel}</div>}\n                                </div>\n                              );\n\n                              return (\n                                <div key={extTrayId} className=\"relative group\">\n                                  {!isEmpty ? (\n                                    <FilamentHoverCard\n                                      data={extFilamentData}\n                                      spoolman={{\n                                        enabled: spoolmanEnabled,\n                                        linkedSpoolId: extTrayTag\n                                          ? linkedSpools?.[extTrayTag]?.id\n                                          : undefined,\n                                        spoolmanUrl,\n                                        syncMode: spoolmanSyncMode,\n                                        onLinkSpool: spoolmanEnabled ? () => {\n                                          const linkTag = (extFilamentData.trayUuid || extFilamentData.tagUid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId)).toUpperCase();\n                                          setLinkSpoolModal({\n                                            tagUid: extFilamentData.tagUid || linkTag,\n                                            trayUuid: extFilamentData.trayUuid || '',\n                                            printerId: printer.id,\n                                            amsId: 255,\n                                            trayId: slotTrayId,\n                                          });\n                                        } : undefined,\n                                        onUnlinkSpool: extLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(extLinkedSpool.id) : undefined,\n                                      }}\n                                      inventory={spoolmanEnabled ? undefined : (() => {\n                                        const assignment = onGetAssignment?.(printer.id, 255, slotTrayId);\n                                        return {\n                                          assignedSpool: assignment?.spool ? {\n                                            id: assignment.spool.id,\n                                            material: assignment.spool.material,\n                                            brand: assignment.spool.brand,\n                                            color_name: assignment.spool.color_name,\n                                            remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),\n                                          } : null,\n                                          onAssignSpool: () => setAssignSpoolModal({\n                                            printerId: printer.id,\n                                            amsId: 255,\n                                            trayId: slotTrayId,\n                                            trayInfo: {\n                                              type: extTray.tray_type || extFilamentData.profile,\n                                              material: extTray.tray_type ?? undefined,\n                                              profile: extFilamentData.profile,\n                                              color: extFilamentData.colorHex || '',\n                                              location: extLabel || t('printers.external'),\n                                            },\n                                          }),\n                                          onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, slotTrayId) : undefined,\n                                        };\n                                      })()}\n                                      configureSlot={{\n                                        enabled: hasPermission('printers:control'),\n                                        onConfigure: () => setConfigureSlotModal({\n                                          amsId: 255,\n                                          trayId: slotTrayId,\n                                          trayCount: 1,\n                                          trayType: extTray.tray_type || undefined,\n                                          trayColor: extTray.tray_color || undefined,\n                                          traySubBrands: extTray.tray_sub_brands || undefined,\n                                          trayInfoIdx: extTray.tray_info_idx || undefined,\n                                          extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,\n                                          caliIdx: extTray.cali_idx,\n                                          savedPresetId: extSlotPreset?.preset_id,\n                                        }),\n                                      }}\n                                    >\n                                      {extSlotContent}\n                                    </FilamentHoverCard>\n                                  ) : (\n                                    <EmptySlotHoverCard\n                                      configureSlot={{\n                                        enabled: hasPermission('printers:control'),\n                                        onConfigure: () => setConfigureSlotModal({\n                                          amsId: 255,\n                                          trayId: slotTrayId,\n                                          trayCount: 1,\n                                          extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,\n                                        }),\n                                      }}\n                                      inventory={spoolmanEnabled ? undefined : {\n                                        onAssignSpool: () => setAssignSpoolModal({\n                                          printerId: printer.id,\n                                          amsId: 255,\n                                          trayId: slotTrayId,\n                                          trayInfo: {\n                                            type: '',\n                                            color: '',\n                                            location: extLabel || t('printers.external'),\n                                          },\n                                        }),\n                                      }}\n                                    >\n                                      {extSlotContent}\n                                    </EmptySlotHoverCard>\n                                  )}\n                                </div>\n                              );\n                            })}\n                          </div>\n                        </div>\n                      )}\n                      </div>\n                    )}\n                  </div>\n                </div>\n              );\n            })()}\n          </>\n        )}\n\n        {/* Smart Plug Controls - hidden in compact mode */}\n        {smartPlug && viewMode === 'expanded' && (\n          <div className=\"mt-4 pt-4 border-t border-bambu-dark-tertiary\">\n            <div className=\"flex items-center gap-3\">\n              {/* Plug name and status */}\n              <div className=\"flex items-center gap-2 min-w-0\">\n                <Zap className=\"w-4 h-4 text-bambu-gray flex-shrink-0\" />\n                <span className=\"text-sm text-white truncate\">{smartPlug.name}</span>\n                {plugStatus && (\n                  <span\n                    className={`text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${\n                      plugStatus.state === 'ON'\n                        ? 'bg-bambu-green/20 text-bambu-green'\n                        : plugStatus.state === 'OFF'\n                        ? 'bg-red-500/20 text-red-400'\n                        : 'bg-bambu-gray/20 text-bambu-gray'\n                    }`}\n                  >\n                    {plugStatus.state || '?'}\n                    {plugStatus.state === 'ON' && plugStatus.energy?.power != null && (\n                      <span className=\"text-yellow-400 ml-1.5\">· {plugStatus.energy.power}W</span>\n                    )}\n                  </span>\n                )}\n              </div>\n\n              {/* Spacer */}\n              <div className=\"flex-1\" />\n\n              {/* Power buttons */}\n              <div className=\"flex items-center gap-1\">\n                <button\n                  onClick={() => setShowPowerOnConfirm(true)}\n                  disabled={powerControlMutation.isPending || plugStatus?.state === 'ON' || !hasPermission('smart_plugs:control')}\n                  className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${\n                    !hasPermission('smart_plugs:control')\n                      ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'\n                      : plugStatus?.state === 'ON'\n                        ? 'bg-bambu-green text-white'\n                        : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'\n                  }`}\n                  title={!hasPermission('smart_plugs:control') ? t('printers.permission.noSmartPlugControl') : undefined}\n                >\n                  <Power className=\"w-3 h-3\" />\n                  On\n                </button>\n                <button\n                  onClick={() => setShowPowerOffConfirm(true)}\n                  disabled={powerControlMutation.isPending || plugStatus?.state === 'OFF' || !hasPermission('smart_plugs:control')}\n                  className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${\n                    !hasPermission('smart_plugs:control')\n                      ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'\n                      : plugStatus?.state === 'OFF'\n                        ? 'bg-red-500/30 text-red-400'\n                        : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'\n                  }`}\n                  title={!hasPermission('smart_plugs:control') ? t('printers.permission.noSmartPlugControl') : undefined}\n                >\n                  <PowerOff className=\"w-3 h-3\" />\n                  Off\n                </button>\n              </div>\n\n              {/* Auto-off toggle */}\n              <div className=\"flex items-center gap-2 flex-shrink-0\">\n                <span className={`text-xs hidden sm:inline ${smartPlug.auto_off_executed ? 'text-bambu-green' : 'text-bambu-gray'}`}>\n                  {smartPlug.auto_off_executed ? 'Auto-off done' : 'Auto-off'}\n                </span>\n                <button\n                  onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}\n                  disabled={toggleAutoOffMutation.isPending || smartPlug.auto_off_executed || !hasPermission('smart_plugs:control')}\n                  title={!hasPermission('smart_plugs:control') ? t('printers.permission.noSmartPlugControl') : (smartPlug.auto_off_executed ? t('printers.autoOffExecuted') : t('printers.autoOffAfterPrint'))}\n                  className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${\n                    !hasPermission('smart_plugs:control')\n                      ? 'bg-bambu-dark-tertiary/50 cursor-not-allowed'\n                      : smartPlug.auto_off_executed\n                        ? 'bg-bambu-green/50 cursor-not-allowed'\n                        : smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n                  }`}\n                >\n                  <span\n                    className={`absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full transition-transform ${\n                      smartPlug.auto_off || smartPlug.auto_off_executed ? 'translate-x-4' : 'translate-x-0'\n                    }`}\n                  />\n                </button>\n              </div>\n            </div>\n\n            {/* HA entity buttons row */}\n            {scriptPlugs && scriptPlugs.length > 0 && (\n              <div className=\"flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50\">\n                <Home className=\"w-3.5 h-3.5 text-blue-400 flex-shrink-0\" />\n                <span className=\"text-xs text-bambu-gray\">HA:</span>\n                <div className=\"flex flex-wrap gap-1\">\n                  {scriptPlugs.map(script => {\n                    const isScript = script.ha_entity_id?.startsWith('script.');\n                    return (\n                      <button\n                        key={script.id}\n                        onClick={() => runScriptMutation.mutate({ id: script.id, action: isScript ? 'on' : 'toggle' })}\n                        disabled={runScriptMutation.isPending}\n                        title={`${isScript ? 'Run' : 'Toggle'} ${script.ha_entity_id}`}\n                        className=\"px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1\"\n                      >\n                        <Play className=\"w-2.5 h-2.5\" />\n                        {script.name}\n                      </button>\n                    );\n                  })}\n                </div>\n              </div>\n            )}\n          </div>\n        )}\n\n        {/* Connection Info & Actions - hidden in compact mode */}\n        {viewMode === 'expanded' && (\n          <div className=\"mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-end gap-2 flex-wrap\">\n              {/* Chamber Light */}\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}\n                disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}\n                title={!hasPermission('printers:control') ? t('printers.permission.noControl') : (status?.chamber_light ? t('printers.chamberLightOff') : t('printers.chamberLightOn'))}\n                className={status?.chamber_light ? '!border-yellow-500 !text-yellow-400 hover:!bg-yellow-500/20' : ''}\n              >\n                <ChamberLight on={status?.chamber_light ?? false} className={`w-4 h-4 ${status?.chamber_light ? 'text-yellow-400' : ''}`} />\n              </Button>\n              {/* Camera Button */}\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={() => {\n                  if (cameraViewMode === 'embedded' && onOpenEmbeddedCamera) {\n                    onOpenEmbeddedCamera(printer.id, printer.name);\n                  } else {\n                    // Use saved window state or defaults\n                    const saved = localStorage.getItem('cameraWindowState');\n                    const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };\n                    const features = [\n                      `width=${state.width}`,\n                      `height=${state.height}`,\n                      state.left !== undefined ? `left=${state.left}` : '',\n                      state.top !== undefined ? `top=${state.top}` : '',\n                      // No `noopener`: same-origin popup needs opener so the browser\n                      // copies sessionStorage (auth token) into the new window.\n                      'menubar=no,toolbar=no,location=no,status=no',\n                    ].filter(Boolean).join(',');\n                    window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);\n                  }\n                }}\n                disabled={!status?.connected || !hasPermission('camera:view')}\n                title={!hasPermission('camera:view') ? t('printers.permission.noCamera') : (cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow'))}\n              >\n                <Video className=\"w-4 h-4\" />\n              </Button>\n              {/* Split button: main part toggles detection, chevron opens modal */}\n              <div className={`inline-flex rounded-md ${printer.plate_detection_enabled ? 'ring-1 ring-green-500' : ''}`}>\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={handleTogglePlateDetection}\n                  disabled={!status?.connected || plateDetectionMutation.isPending || !hasPermission('printers:update')}\n                  title={!hasPermission('printers:update') ? t('printers.plateDetection.noPermission') : (printer.plate_detection_enabled ? t('printers.plateDetection.enabledClick') : t('printers.plateDetection.disabledClick'))}\n                  className={`!rounded-r-none !border-r-0 ${printer.plate_detection_enabled ? \"!border-green-500 !text-green-400 hover:!bg-green-500/20\" : \"\"}`}\n                >\n                  {plateDetectionMutation.isPending ? (\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  ) : (\n                    <ScanSearch className=\"w-4 h-4\" />\n                  )}\n                </Button>\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={handleOpenPlateManagement}\n                  disabled={!status?.connected || isCheckingPlate || !hasPermission('printers:update')}\n                  title={!hasPermission('printers:update') ? t('printers.plateDetection.noPermission') : t('printers.plateDetection.manageCalibration')}\n                  className={`!rounded-l-none !px-1.5 ${printer.plate_detection_enabled ? \"!border-green-500 !text-green-400 hover:!bg-green-500/20\" : \"\"}`}\n                >\n                  {isCheckingPlate ? (\n                    <Loader2 className=\"w-3 h-3 animate-spin\" />\n                  ) : (\n                    <ChevronDown className=\"w-3 h-3\" />\n                  )}\n                </Button>\n              </div>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={() => setShowFileManager(true)}\n                disabled={!isConnected || !hasPermission('printers:files')}\n                title={!hasPermission('printers:files') ? t('printers.permission.noFiles') : t('printers.browseFiles')}\n              >\n                <HardDrive className=\"w-4 h-4\" />\n                {t('printers.files')}\n              </Button>\n              {isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && (\n                <Button\n                  size=\"sm\"\n                  onClick={() => setShowUploadForPrint(true)}\n                  disabled={!hasPermission('printers:control')}\n                  title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('common.print')}\n                  className=\"!bg-bambu-green hover:!bg-bambu-green/80 !text-white\"\n                >\n                  <PrinterIcon className=\"w-4 h-4\" />\n                  {t('common.print')}\n                </Button>\n              )}\n          </div>\n        )}\n      </CardContent>\n\n      {/* File Manager Modal */}\n      {showFileManager && (\n        <FileManagerModal\n          printerId={printer.id}\n          printerName={printer.name}\n          onClose={() => setShowFileManager(false)}\n        />\n      )}\n\n      {/* Upload for Print Modal */}\n      {showUploadForPrint && (\n        <FileUploadModal\n          folderId={null}\n          onClose={() => setShowUploadForPrint(false)}\n          onUploadComplete={() => {}}\n          autoUpload\n          accept=\".gcode,.3mf\"\n          validateFile={(file) => {\n            const lower = file.name.toLowerCase();\n            if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) {\n              return t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed');\n            }\n          }}\n          onFileUploaded={(uploadedFile) => {\n            // Check printer compatibility if sliced_for_model is available in metadata\n            const slicedFor = (uploadedFile.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;\n            const printerModel = mapModelCode(printer.model);\n            if (slicedFor && printerModel && slicedFor.toLowerCase() !== printerModel.toLowerCase()) {\n              api.deleteLibraryFile(uploadedFile.id).catch(() => {});\n              return t('printers.incompatibleFile', 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}', { slicedFor, printerModel });\n            }\n            setPrintAfterUpload({ id: uploadedFile.id, filename: uploadedFile.filename });\n          }}\n        />\n      )}\n\n      {/* Print Modal (after upload) */}\n      {printAfterUpload && (\n        <PrintModal\n          mode=\"reprint\"\n          libraryFileId={printAfterUpload.id}\n          archiveName={printAfterUpload.filename}\n          initialSelectedPrinterIds={[printer.id]}\n          onClose={() => setPrintAfterUpload(null)}\n          onSuccess={() => setPrintAfterUpload(null)}\n          cleanupLibraryAfterDispatch\n        />\n      )}\n\n      {/* MQTT Debug Modal */}\n      {showMQTTDebug && (\n        <MQTTDebugModal\n          printerId={printer.id}\n          printerName={printer.name}\n          onClose={() => setShowMQTTDebug(false)}\n        />\n      )}\n\n      {showPrinterInfo && (\n        <PrinterInfoModal\n          printer={printer}\n          status={status}\n          totalPrintHours={maintenanceInfo?.total_print_hours}\n          onClose={closePrinterInfo}\n        />\n      )}\n\n      {/* Plate Check Result Modal */}\n      {plateCheckResult && (\n        <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\" onClick={() => closePlateCheckModal()}>\n          <div className=\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-w-lg w-full\" onClick={e => e.stopPropagation()}>\n            <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n              <div className=\"flex items-center gap-2\">\n                {plateCheckResult.needs_calibration ? (\n                  <ScanSearch className=\"w-5 h-5 text-blue-500\" />\n                ) : plateCheckResult.is_empty ? (\n                  <CheckCircle className=\"w-5 h-5 text-green-500\" />\n                ) : (\n                  <XCircle className=\"w-5 h-5 text-yellow-500\" />\n                )}\n                <h2 className=\"text-lg font-semibold text-white\">\n                  Build Plate Check\n                </h2>\n                {plateCheckResult.reference_count !== undefined && plateCheckResult.max_references && (\n                  <span className=\"text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded\">\n                    {plateCheckResult.reference_count}/{plateCheckResult.max_references} refs\n                  </span>\n                )}\n              </div>\n              <button\n                onClick={() => closePlateCheckModal()}\n                className=\"p-1 text-bambu-gray hover:text-white rounded transition-colors\"\n              >\n                <X className=\"w-5 h-5\" />\n              </button>\n            </div>\n            <div className=\"p-4 space-y-4\">\n              {plateCheckResult.needs_calibration ? (\n                <>\n                  <div className=\"p-3 rounded-lg bg-blue-500/20 border border-blue-500/50\">\n                    <p className=\"font-medium text-blue-400\">\n                      {t('printers.plateDetection.calibrationRequired')}\n                    </p>\n                    <p className=\"text-sm text-bambu-gray mt-1\" dangerouslySetInnerHTML={{ __html: t('printers.plateDetection.calibrationInstructions') }} />\n                  </div>\n                  <div className=\"text-sm text-bambu-gray space-y-2\">\n                    <p>{t('printers.plateDetection.calibrationDescription')}</p>\n                    <p dangerouslySetInnerHTML={{ __html: t('printers.plateDetection.calibrationTip') }} />\n                  </div>\n                </>\n              ) : (\n                <>\n                  <div className={`p-3 rounded-lg ${plateCheckResult.is_empty ? 'bg-green-500/20 border border-green-500/50' : 'bg-yellow-500/20 border border-yellow-500/50'}`}>\n                    <p className={`font-medium ${plateCheckResult.is_empty ? 'text-green-400' : 'text-yellow-400'}`}>\n                      {plateCheckResult.is_empty ? t('printers.plateDetection.plateEmpty') : t('printers.plateDetection.objectsDetected')}\n                    </p>\n                    <p className=\"text-sm text-bambu-gray mt-1\">\n                      {t('printers.plateDetection.confidence')}: {Math.round(plateCheckResult.confidence * 100)}% | {t('printers.plateDetection.difference')}: {plateCheckResult.difference_percent.toFixed(1)}%\n                    </p>\n                  </div>\n                  {plateCheckResult.debug_image_url && (\n                    <div>\n                      <p className=\"text-sm text-bambu-gray mb-2\">{t('printers.plateDetection.analysisPreview')}</p>\n                      <img\n                        src={plateCheckResult.debug_image_url}\n                        alt={t('printers.plateDetection.analysisPreview')}\n                        className=\"w-full rounded-lg border border-bambu-dark-tertiary\"\n                      />\n                      <p className=\"text-xs text-bambu-gray mt-2\">\n                        {t('printers.plateDetection.analysisLegend')}\n                      </p>\n                    </div>\n                  )}\n                  <p className=\"text-xs text-bambu-gray\">\n                    {plateCheckResult.message}\n                  </p>\n                </>\n              )}\n\n              {/* Saved References Grid */}\n              {plateReferences && plateReferences.references.length > 0 && (\n                <div className=\"mt-4 pt-4 border-t border-bambu-dark-tertiary\">\n                  <p className=\"text-sm font-medium text-white mb-2\">\n                    {t('printers.plateDetection.savedReferences', { count: plateReferences.references.length, max: plateReferences.max_references })}\n                  </p>\n                  <div className=\"grid grid-cols-5 gap-2\">\n                    {plateReferences.references.map((ref) => (\n                      <div key={ref.index} className=\"relative group\">\n                        <img\n                          src={api.getPlateReferenceThumbnailUrl(printer.id, ref.index)}\n                          alt={ref.label || `Reference ${ref.index + 1}`}\n                          className=\"w-full aspect-video object-cover rounded border border-bambu-dark-tertiary\"\n                        />\n                        {/* Delete button */}\n                        <button\n                          onClick={() => handleDeleteRef(ref.index)}\n                          className=\"absolute top-1 right-1 p-0.5 bg-red-500/80 rounded opacity-0 group-hover:opacity-100 transition-opacity\"\n                          title={t('printers.plateDetection.deleteReference')}\n                        >\n                          <X className=\"w-3 h-3 text-white\" />\n                        </button>\n                        {/* Label */}\n                        {editingRefLabel?.index === ref.index ? (\n                          <input\n                            type=\"text\"\n                            value={editingRefLabel.label}\n                            onChange={(e) => setEditingRefLabel({ ...editingRefLabel, label: e.target.value })}\n                            onBlur={() => handleUpdateRefLabel(ref.index, editingRefLabel.label)}\n                            onKeyDown={(e) => {\n                              if (e.key === 'Enter') handleUpdateRefLabel(ref.index, editingRefLabel.label);\n                              if (e.key === 'Escape') setEditingRefLabel(null);\n                            }}\n                            className=\"w-full mt-1 px-1 py-0.5 text-xs bg-bambu-dark-tertiary border border-bambu-green rounded text-white\"\n                            autoFocus\n                            placeholder={t('printers.plateDetection.labelPlaceholder')}\n                          />\n                        ) : (\n                          <p\n                            className=\"text-xs text-bambu-gray mt-1 truncate cursor-pointer hover:text-white\"\n                            onClick={() => setEditingRefLabel({ index: ref.index, label: ref.label })}\n                            title={ref.label ? t('printers.plateDetection.clickToEdit', { label: ref.label }) : t('printers.plateDetection.clickToAddLabel')}\n                          >\n                            {ref.label || <span className=\"italic opacity-50\">{t('printers.noLabel')}</span>}\n                          </p>\n                        )}\n                        {/* Timestamp */}\n                        <p className=\"text-[10px] text-bambu-gray/60\">\n                          {ref.timestamp ? parseUTCDate(ref.timestamp)?.toLocaleDateString() ?? '' : ''}\n                        </p>\n                      </div>\n                    ))}\n                  </div>\n                </div>\n              )}\n\n              {/* ROI Editor */}\n              {!plateCheckResult.needs_calibration && (\n                <div className=\"mt-4 pt-4 border-t border-bambu-dark-tertiary\">\n                  <div className=\"flex items-center justify-between mb-2\">\n                    <p className=\"text-sm font-medium text-white\">{t('printers.roi.title')}</p>\n                    {!editingRoi ? (\n                      <Button\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        onClick={() => setEditingRoi(plateCheckResult.roi || { x: 0.15, y: 0.35, w: 0.70, h: 0.55 })}\n                      >\n                        <Pencil className=\"w-3 h-3 mr-1\" />\n                        {t('common.edit')}\n                      </Button>\n                    ) : (\n                      <div className=\"flex gap-1\">\n                        <Button\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() => setEditingRoi(null)}\n                          disabled={isSavingRoi}\n                        >\n                          {t('common.cancel')}\n                        </Button>\n                        <Button\n                          size=\"sm\"\n                          onClick={handleSaveRoi}\n                          disabled={isSavingRoi}\n                        >\n                          {isSavingRoi ? <Loader2 className=\"w-3 h-3 animate-spin\" /> : t('common.save')}\n                        </Button>\n                      </div>\n                    )}\n                  </div>\n                  {editingRoi ? (\n                    <div className=\"space-y-3 bg-bambu-dark-tertiary/50 p-3 rounded-lg\">\n                      <div className=\"grid grid-cols-2 gap-3\">\n                        <div>\n                          <label className=\"text-xs text-bambu-gray\">{t('printers.roi.xStart')}</label>\n                          <input\n                            type=\"range\"\n                            min=\"0\"\n                            max=\"0.9\"\n                            step=\"0.01\"\n                            value={editingRoi.x}\n                            onChange={(e) => setEditingRoi({ ...editingRoi, x: parseFloat(e.target.value) })}\n                            className=\"w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500\"\n                          />\n                          <span className=\"text-xs text-bambu-gray\">{Math.round(editingRoi.x * 100)}%</span>\n                        </div>\n                        <div>\n                          <label className=\"text-xs text-bambu-gray\">{t('printers.roi.yStart')}</label>\n                          <input\n                            type=\"range\"\n                            min=\"0\"\n                            max=\"0.9\"\n                            step=\"0.01\"\n                            value={editingRoi.y}\n                            onChange={(e) => setEditingRoi({ ...editingRoi, y: parseFloat(e.target.value) })}\n                            className=\"w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500\"\n                          />\n                          <span className=\"text-xs text-bambu-gray\">{Math.round(editingRoi.y * 100)}%</span>\n                        </div>\n                        <div>\n                          <label className=\"text-xs text-bambu-gray\">{t('printers.width')}</label>\n                          <input\n                            type=\"range\"\n                            min=\"0.1\"\n                            max=\"1\"\n                            step=\"0.01\"\n                            value={editingRoi.w}\n                            onChange={(e) => setEditingRoi({ ...editingRoi, w: parseFloat(e.target.value) })}\n                            className=\"w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500\"\n                          />\n                          <span className=\"text-xs text-bambu-gray\">{Math.round(editingRoi.w * 100)}%</span>\n                        </div>\n                        <div>\n                          <label className=\"text-xs text-bambu-gray\">{t('printers.height')}</label>\n                          <input\n                            type=\"range\"\n                            min=\"0.1\"\n                            max=\"1\"\n                            step=\"0.01\"\n                            value={editingRoi.h}\n                            onChange={(e) => setEditingRoi({ ...editingRoi, h: parseFloat(e.target.value) })}\n                            className=\"w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500\"\n                          />\n                          <span className=\"text-xs text-bambu-gray\">{Math.round(editingRoi.h * 100)}%</span>\n                        </div>\n                      </div>\n                      <p className=\"text-xs text-bambu-gray\">\n                        {t('printers.roi.instruction')}\n                      </p>\n                    </div>\n                  ) : (\n                    <p className=\"text-xs text-bambu-gray\">\n                      Current: X={Math.round((plateCheckResult.roi?.x || 0.15) * 100)}%, Y={Math.round((plateCheckResult.roi?.y || 0.35) * 100)}%,\n                      W={Math.round((plateCheckResult.roi?.w || 0.70) * 100)}%, H={Math.round((plateCheckResult.roi?.h || 0.55) * 100)}%\n                    </p>\n                  )}\n                </div>\n              )}\n            </div>\n            <div className=\"flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary\">\n              {plateCheckResult.needs_calibration ? (\n                <>\n                  <Button variant=\"ghost\" onClick={() => closePlateCheckModal()}>\n                    {t('common.cancel')}\n                  </Button>\n                  <Button\n                    onClick={() => handleCalibratePlate()}\n                    disabled={isCalibrating}\n                  >\n                    {isCalibrating ? (\n                      <>\n                        <Loader2 className=\"w-4 h-4 mr-2 animate-spin\" />\n                        Calibrating...\n                      </>\n                    ) : (\n                      'Calibrate Empty Plate'\n                    )}\n                  </Button>\n                </>\n              ) : (\n                <>\n                  <Button variant=\"ghost\" onClick={() => handleCalibratePlate()} disabled={isCalibrating}>\n                    {isCalibrating ? 'Adding...' : `Add Reference (${plateReferences?.references.length || 0}/${plateReferences?.max_references || 5})`}\n                  </Button>\n                  <Button onClick={() => closePlateCheckModal()}>\n                    Close\n                  </Button>\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Power On Confirmation */}\n      {showPowerOnConfirm && smartPlug && (\n        <ConfirmModal\n          title={t('printers.confirm.powerOnTitle')}\n          message={t('printers.confirm.powerOnMessage', { name: printer.name })}\n          confirmText={t('printers.confirm.powerOnButton')}\n          variant=\"default\"\n          onConfirm={() => {\n            powerControlMutation.mutate('on');\n            setShowPowerOnConfirm(false);\n          }}\n          onCancel={() => setShowPowerOnConfirm(false)}\n        />\n      )}\n\n      {/* Power Off Confirmation */}\n      {showPowerOffConfirm && smartPlug && (\n        <ConfirmModal\n          title={t('printers.confirm.powerOffTitle')}\n          message={\n            status?.state === 'RUNNING'\n              ? t('printers.confirm.powerOffWarning', { name: printer.name })\n              : t('printers.confirm.powerOffMessage', { name: printer.name })\n          }\n          confirmText={t('printers.confirm.powerOffButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            powerControlMutation.mutate('off');\n            setShowPowerOffConfirm(false);\n          }}\n          onCancel={() => setShowPowerOffConfirm(false)}\n        />\n      )}\n\n      {/* Stop Print Confirmation */}\n      {showStopConfirm && (\n        <ConfirmModal\n          title={t('printers.confirm.stopTitle')}\n          message={t('printers.confirm.stopMessage', { name: printer.name })}\n          confirmText={t('printers.confirm.stopButton')}\n          variant=\"danger\"\n          onConfirm={() => {\n            stopPrintMutation.mutate();\n            setShowStopConfirm(false);\n          }}\n          onCancel={() => setShowStopConfirm(false)}\n        />\n      )}\n\n      {/* Pause Print Confirmation */}\n      {showPauseConfirm && (\n        <ConfirmModal\n          title={t('printers.confirm.pauseTitle')}\n          message={t('printers.confirm.pauseMessage', { name: printer.name })}\n          confirmText={t('printers.confirm.pauseButton')}\n          variant=\"default\"\n          onConfirm={() => {\n            pausePrintMutation.mutate();\n            setShowPauseConfirm(false);\n          }}\n          onCancel={() => setShowPauseConfirm(false)}\n        />\n      )}\n\n      {/* Resume Print Confirmation */}\n      {showResumeConfirm && (\n        <ConfirmModal\n          title={t('printers.confirm.resumeTitle')}\n          message={t('printers.confirm.resumeMessage', { name: printer.name })}\n          confirmText={t('printers.confirm.resumeButton')}\n          variant=\"default\"\n          onConfirm={() => {\n            resumePrintMutation.mutate();\n            setShowResumeConfirm(false);\n          }}\n          onCancel={() => setShowResumeConfirm(false)}\n        />\n      )}\n\n      {/* Bed Jog — not-homed warning (Studio-style) */}\n      {showNotHomedModal && (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4\">\n          <div className=\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl w-full max-w-sm p-5\">\n            <div className=\"flex items-start gap-3 mb-4\">\n              <AlertTriangle className=\"w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5\" />\n              <div>\n                <h3 className=\"text-sm font-semibold text-white mb-1\">\n                  {t('printers.bedJog.notHomedTitle')}\n                </h3>\n                <p className=\"text-xs text-bambu-gray leading-relaxed\">\n                  {t('printers.bedJog.notHomedMessage')}\n                </p>\n              </div>\n            </div>\n            <div className=\"flex flex-col gap-2\">\n              <button\n                onClick={() => {\n                  homeAxesMutation.mutate('all');\n                  setShowNotHomedModal(null);\n                }}\n                className=\"w-full px-3 py-2 rounded-lg text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors\"\n              >\n                {t('printers.bedJog.homeZ')}\n              </button>\n              <button\n                onClick={() => {\n                  const d = showNotHomedModal.distance;\n                  try { sessionStorage.setItem(`bambuddy.bedJog.warned.${printer.id}`, '1'); } catch { /* ignore */ }\n                  bedJogMutation.mutate({ distance: d, force: true });\n                  setShowNotHomedModal(null);\n                }}\n                className=\"w-full px-3 py-2 rounded-lg text-xs font-medium bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition-colors\"\n              >\n                {t('printers.bedJog.moveAnyway')}\n              </button>\n              <button\n                onClick={() => setShowNotHomedModal(null)}\n                className=\"w-full px-3 py-2 rounded-lg text-xs font-medium bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary transition-colors\"\n              >\n                {t('common.cancel')}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Skip Objects Modal */}\n      <SkipObjectsModal\n        printerId={printer.id}\n        isOpen={showSkipObjectsModal}\n        onClose={() => setShowSkipObjectsModal(false)}\n      />\n\n      {/* HMS Error Modal */}\n      {showHMSModal && (\n        <HMSErrorModal\n          printerName={printer.name}\n          errors={status?.hms_errors || []}\n          onClose={() => setShowHMSModal(false)}\n          printerId={printer.id}\n          hasPermission={hasPermission}\n        />\n      )}\n\n      {/* AMS History Modal */}\n      {amsHistoryModal && (\n        <AMSHistoryModal\n          isOpen={!!amsHistoryModal}\n          onClose={() => setAmsHistoryModal(null)}\n          printerId={printer.id}\n          printerName={printer.name}\n          amsId={amsHistoryModal.amsId}\n          amsLabel={amsHistoryModal.amsLabel}\n          initialMode={amsHistoryModal.mode}\n          thresholds={amsThresholds}\n        />\n      )}\n\n      {/* Link Spool Modal */}\n      {linkSpoolModal && (\n        <LinkSpoolModal\n          isOpen={!!linkSpoolModal}\n          onClose={() => setLinkSpoolModal(null)}\n          tagUid={linkSpoolModal.tagUid}\n          trayUuid={linkSpoolModal.trayUuid}\n          printerId={linkSpoolModal.printerId}\n          amsId={linkSpoolModal.amsId}\n          trayId={linkSpoolModal.trayId}\n        />\n      )}\n\n      {/* Assign Spool Modal */}\n      {assignSpoolModal && (\n        <AssignSpoolModal\n          isOpen={!!assignSpoolModal}\n          onClose={() => setAssignSpoolModal(null)}\n          printerId={assignSpoolModal.printerId}\n          amsId={assignSpoolModal.amsId}\n          trayId={assignSpoolModal.trayId}\n          trayInfo={assignSpoolModal.trayInfo}\n        />\n      )}\n\n      {/* Configure AMS Slot Modal */}\n      {configureSlotModal && (\n        <ConfigureAmsSlotModal\n          isOpen={!!configureSlotModal}\n          onClose={() => setConfigureSlotModal(null)}\n          printerId={printer.id}\n          slotInfo={configureSlotModal}\n          printerModel={mapModelCode(printer.model) || undefined}\n          onSuccess={() => {\n            // Refresh slot presets to show updated profile name\n            queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });\n            // Printer status will update automatically via WebSocket when AMS data changes\n            queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n          }}\n        />\n      )}\n\n      {/* Edit Printer Modal */}\n      {showEditModal && (\n        <EditPrinterModal\n          printer={printer}\n          onClose={() => setShowEditModal(false)}\n        />\n      )}\n\n      {/* Firmware Update Modal */}\n      {showFirmwareModal && firmwareInfo && (\n        <FirmwareUpdateModal\n          printer={printer}\n          firmwareInfo={firmwareInfo}\n          onClose={() => setShowFirmwareModal(false)}\n        />\n      )}\n\n      {/* AMS Slot Menu Backdrop - closes menu when clicking outside */}\n      {amsSlotMenu && (\n        <div\n          className=\"fixed inset-0 z-40\"\n          onClick={() => setAmsSlotMenu(null)}\n        />\n      )}\n\n      {/* AMS Drying Popover — fixed position to avoid overflow/z-index issues */}\n      {dryingPopoverAmsId !== null && dryingPopoverPos && (() => {\n        const maxTemp = dryingPopoverModuleType === 'n3s' ? 85 : 65;\n        const sliderMin = 35;\n        const sliderMax = maxTemp + 10;\n        return (\n          <>\n            {/* Backdrop */}\n            <div className=\"fixed inset-0 z-[100]\" onClick={() => setDryingPopoverAmsId(null)} />\n            {/* Popover */}\n            <div\n              className=\"fixed z-[101] w-[240px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl overflow-hidden\"\n              style={{ top: dryingPopoverPos.top, left: dryingPopoverPos.left }}\n              onClick={e => e.stopPropagation()}\n            >\n              {/* Header */}\n              <div className=\"flex items-center gap-2 px-3 py-2.5 border-b border-bambu-dark-tertiary\">\n                <Flame className=\"w-3.5 h-3.5 text-amber-400\" />\n                <span className=\"text-xs text-white font-medium\">{t('printers.drying.start')}</span>\n              </div>\n              {/* Body */}\n              <div className=\"px-3 py-2.5 space-y-2.5\">\n                {/* Filament type select */}\n                <div>\n                  <label className=\"text-[10px] text-bambu-gray mb-1 block\">{t('printers.filaments')}</label>\n                  <select\n                    value={dryingFilament}\n                    onChange={e => {\n                      const fil = e.target.value;\n                      setDryingFilament(fil);\n                      const preset = dryingPresets[fil];\n                      if (preset) {\n                        const key = dryingPopoverModuleType === 'n3s' ? 'n3s' : 'n3f';\n                        setDryingTemp(preset[key]);\n                        setDryingDuration(dryingPopoverModuleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);\n                      }\n                    }}\n                    className=\"w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs focus:outline-none focus:border-amber-500/50\"\n                  >\n                    {Object.keys(dryingPresets).map(fil => (\n                      <option key={fil} value={fil}>{fil}</option>\n                    ))}\n                  </select>\n                </div>\n                {/* Temperature */}\n                <div>\n                  <div className=\"flex items-center justify-between mb-1\">\n                    <label className=\"text-[10px] text-bambu-gray\">{t('printers.drying.temperature')}</label>\n                    <div className=\"flex items-center gap-1\">\n                      <input\n                        type=\"number\"\n                        min={45}\n                        max={maxTemp}\n                        value={dryingTemp}\n                        onChange={e => setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value) || 45)))}\n                        className=\"w-12 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                      />\n                      <span className=\"text-[10px] text-bambu-gray\">°C</span>\n                    </div>\n                  </div>\n                  <input\n                    type=\"range\"\n                    min={sliderMin}\n                    max={sliderMax}\n                    value={dryingTemp}\n                    onChange={e => setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value))))}\n                    className=\"w-full h-1 accent-amber-500 cursor-pointer\"\n                  />\n                  <div className=\"flex justify-between text-[9px] text-bambu-gray/50 mt-0.5\">\n                    <span>45°C</span>\n                    <span>{maxTemp}°C</span>\n                  </div>\n                </div>\n                {/* Duration */}\n                <div>\n                  <div className=\"flex items-center justify-between mb-1\">\n                    <label className=\"text-[10px] text-bambu-gray\">{t('printers.drying.duration')}</label>\n                    <div className=\"flex items-center gap-1\">\n                      <input\n                        type=\"number\"\n                        min={1}\n                        max={24}\n                        value={dryingDuration}\n                        onChange={e => setDryingDuration(Math.min(24, Math.max(1, Number(e.target.value) || 1)))}\n                        className=\"w-10 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                      />\n                      <span className=\"text-[10px] text-bambu-gray\">{t('printers.drying.hours')}</span>\n                    </div>\n                  </div>\n                  <input\n                    type=\"range\"\n                    min={1}\n                    max={24}\n                    value={dryingDuration}\n                    onChange={e => setDryingDuration(Number(e.target.value))}\n                    className=\"w-full h-1 accent-amber-500 cursor-pointer\"\n                  />\n                  <div className=\"flex justify-between text-[9px] text-bambu-gray/50 mt-0.5\">\n                    <span>1h</span>\n                    <span>24h</span>\n                  </div>\n                </div>\n                {/* Rotate tray */}\n                <label className=\"flex items-center gap-2 cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={dryingRotateTray}\n                    onChange={e => setDryingRotateTray(e.target.checked)}\n                    className=\"w-3.5 h-3.5 accent-amber-500 rounded cursor-pointer\"\n                  />\n                  <span className=\"text-[11px] text-bambu-gray\">{t('printers.drying.rotateTray')}</span>\n                </label>\n              </div>\n              {/* Footer */}\n              <div className=\"px-3 pb-3\">\n                <button\n                  onClick={() => {\n                    if (dryingPopoverAmsId !== null) {\n                      startDryingMutation.mutate({ amsId: dryingPopoverAmsId, temp: dryingTemp, duration: dryingDuration, filament: dryingFilament, rotateTray: dryingRotateTray });\n                    }\n                  }}\n                  disabled={startDryingMutation.isPending}\n                  className=\"w-full py-1.5 bg-amber-500 hover:bg-amber-400 text-white text-xs font-medium rounded-lg transition-colors disabled:opacity-50\"\n                >\n                  {startDryingMutation.isPending ? t('printers.drying.startingDrying') : t('printers.drying.start')}\n                </button>\n              </div>\n            </div>\n          </>\n        );\n      })()}\n    </Card>\n  );\n}\n\nfunction AddPrinterModal({\n  onClose,\n  onAdd,\n  existingSerials,\n}: {\n  onClose: () => void;\n  onAdd: (data: PrinterCreate) => void;\n  existingSerials: string[];\n}) {\n  const { t } = useTranslation();\n  const [form, setForm] = useState<PrinterCreate>({\n    name: '',\n    serial_number: '',\n    ip_address: '',\n    access_code: '',\n    model: '',\n    location: '',\n    auto_archive: true,\n  });\n\n  // Discovery state\n  const [discovering, setDiscovering] = useState(false);\n  const [discovered, setDiscovered] = useState<DiscoveredPrinter[]>([]);\n  const [discoveryError, setDiscoveryError] = useState('');\n  const [hasScanned, setHasScanned] = useState(false);\n  const [isDocker, setIsDocker] = useState(false);\n  const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);\n  const [subnet, setSubnet] = useState('');\n  const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });\n\n  // Fetch discovery info on mount\n  useEffect(() => {\n    discoveryApi.getInfo().then(info => {\n      setIsDocker(info.is_docker);\n      if (info.subnets.length > 0) {\n        setDetectedSubnets(info.subnets);\n        setSubnet(info.subnets[0]);\n      }\n    }).catch(() => {\n      // Ignore errors, assume not Docker\n    });\n  }, []);\n\n  // Filter out already-added printers\n  const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial));\n\n  const startDiscovery = async () => {\n    setDiscoveryError('');\n    setDiscovered([]);\n    setDiscovering(true);\n    setHasScanned(false);\n    setScanProgress({ scanned: 0, total: 0 });\n\n    try {\n      if (isDocker) {\n        // Use subnet scanning for Docker\n        await discoveryApi.startSubnetScan(subnet);\n\n        // Poll for scan status and results\n        const pollInterval = setInterval(async () => {\n          try {\n            const status = await discoveryApi.getScanStatus();\n            setScanProgress({ scanned: status.scanned, total: status.total });\n\n            const printers = await discoveryApi.getDiscoveredPrinters();\n            setDiscovered(printers);\n\n            if (!status.running) {\n              clearInterval(pollInterval);\n              setDiscovering(false);\n              setHasScanned(true);\n            }\n          } catch (e) {\n            console.error('Failed to get scan status:', e);\n          }\n        }, 500);\n      } else {\n        // Use SSDP discovery for native installs\n        await discoveryApi.startDiscovery(10);\n\n        // Poll for discovered printers every second\n        const pollInterval = setInterval(async () => {\n          try {\n            const printers = await discoveryApi.getDiscoveredPrinters();\n            setDiscovered(printers);\n          } catch (e) {\n            console.error('Failed to get discovered printers:', e);\n          }\n        }, 1000);\n\n        // Stop after 10 seconds\n        setTimeout(async () => {\n          clearInterval(pollInterval);\n          try {\n            await discoveryApi.stopDiscovery();\n          } catch {\n            // Ignore stop errors\n          }\n          setDiscovering(false);\n          setHasScanned(true);\n          // Final fetch\n          try {\n            const printers = await discoveryApi.getDiscoveredPrinters();\n            setDiscovered(printers);\n          } catch (e) {\n            console.error('Failed to get final discovered printers:', e);\n          }\n        }, 10000);\n      }\n    } catch (e) {\n      console.error('Failed to start discovery:', e);\n      setDiscoveryError(e instanceof Error ? e.message : t('printers.discovery.failedToStart'));\n      setDiscovering(false);\n      setHasScanned(true);\n    }\n  };\n\n  // Reuse module-level mapModelCode\n\n  const selectPrinter = (printer: DiscoveredPrinter) => {\n    // Don't pre-fill serial if it's a placeholder (unknown-*) - user needs to enter actual serial\n    const serialNumber = printer.serial.startsWith('unknown-') ? '' : printer.serial;\n    setForm({\n      ...form,\n      name: printer.name || '',\n      serial_number: serialNumber,\n      ip_address: printer.ip_address,\n      model: mapModelCode(printer.model),\n    });\n    // Clear discovery results after selection\n    setDiscovered([]);\n  };\n\n  // Cleanup discovery on unmount\n  useEffect(() => {\n    return () => {\n      discoveryApi.stopDiscovery().catch(() => {});\n      discoveryApi.stopSubnetScan().catch(() => {});\n    };\n  }, []);\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-start sm:items-center justify-center z-50 p-4 overflow-y-auto\"\n      onClick={onClose}\n    >\n      <Card className=\"w-full max-w-md my-auto max-h-[calc(100vh-2rem)] overflow-y-auto\" onClick={(e: React.MouseEvent) => e.stopPropagation()}>\n        <CardContent>\n          <h2 className=\"text-xl font-semibold mb-4\">{t('printers.addPrinter')}</h2>\n\n          {/* Discovery Section */}\n          <div className=\"mb-4 pb-4 border-b border-bambu-dark-tertiary\">\n            {isDocker && (\n              <div className=\"mb-3\">\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('printers.discovery.subnetToScan')}\n                </label>\n                {detectedSubnets.length > 0 ? (\n                  <select\n                    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 text-sm\"\n                    value={subnet}\n                    onChange={(e) => setSubnet(e.target.value)}\n                    disabled={discovering}\n                  >\n                    {detectedSubnets.map(s => (\n                      <option key={s} value={s}>{s}</option>\n                    ))}\n                  </select>\n                ) : (\n                  <input\n                    type=\"text\"\n                    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 text-sm\"\n                    value={subnet}\n                    onChange={(e) => setSubnet(e.target.value)}\n                    placeholder=\"192.168.1.0/24\"\n                    disabled={discovering}\n                  />\n                )}\n                <p className=\"mt-1 text-xs text-bambu-gray\">\n                  {t('printers.discovery.dockerNote')}\n                </p>\n              </div>\n            )}\n\n            <Button\n              type=\"button\"\n              variant=\"secondary\"\n              onClick={startDiscovery}\n              disabled={discovering}\n              className=\"w-full\"\n            >\n              {discovering ? (\n                <>\n                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  {isDocker && scanProgress.total > 0\n                    ? t('printers.discovery.scanProgress', { scanned: scanProgress.scanned, total: scanProgress.total })\n                    : t('printers.discovery.scanning')}\n                </>\n              ) : (\n                <>\n                  <Search className=\"w-4 h-4\" />\n                  {isDocker ? t('printers.discovery.scanSubnet') : t('printers.discovery.discoverNetwork')}\n                </>\n              )}\n            </Button>\n\n            {discoveryError && (\n              <div className=\"mt-2 text-sm text-red-400\">{discoveryError}</div>\n            )}\n\n            {newPrinters.length > 0 && (\n              <div className=\"mt-3 space-y-2 max-h-40 overflow-y-auto\">\n                {newPrinters.map((printer) => (\n                  <div\n                    key={printer.serial}\n                    className=\"flex items-center justify-between p-2 bg-bambu-dark rounded-lg hover:bg-bambu-dark-secondary cursor-pointer transition-colors\"\n                    onClick={() => selectPrinter(printer)}\n                  >\n                    <div className=\"min-w-0 flex-1\">\n                      <p className=\"font-medium text-white text-sm truncate\">\n                        {printer.name || printer.serial}\n                      </p>\n                      <p className=\"text-xs text-bambu-gray truncate\">\n                        {mapModelCode(printer.model) || t('printers.discovery.unknown')} • {printer.ip_address}\n                        {printer.serial.startsWith('unknown-') && (\n                          <span className=\"text-yellow-500\"> • {t('printers.discovery.serialRequired')}</span>\n                        )}\n                      </p>\n                    </div>\n                    <ChevronDown className=\"w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2\" />\n                  </div>\n                ))}\n              </div>\n            )}\n\n            {discovering && (\n              <p className=\"mt-2 text-sm text-bambu-gray text-center\">\n                {isDocker ? t('printers.discovery.scanningSubnet') : t('printers.discovery.scanningNetwork')}\n              </p>\n            )}\n\n            {hasScanned && !discovering && discovered.length === 0 && (\n              <p className=\"mt-2 text-sm text-bambu-gray text-center\">\n                {isDocker ? t('printers.discovery.noPrintersFoundSubnet') : t('printers.discovery.noPrintersFoundNetwork')}\n              </p>\n            )}\n\n            {hasScanned && !discovering && discovered.length > 0 && newPrinters.length === 0 && (\n              <p className=\"mt-2 text-sm text-bambu-gray text-center\">\n                {t('printers.discovery.allConfigured')}\n              </p>\n            )}\n          </div>\n          <form\n            onSubmit={(e) => {\n              e.preventDefault();\n              onAdd(form);\n            }}\n            className=\"space-y-4\"\n          >\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.name')}</label>\n              <input\n                type=\"text\"\n                required\n                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\"\n                value={form.name}\n                onChange={(e) => setForm({ ...form, name: e.target.value })}\n                placeholder={t('printers.modal.myPrinter')}\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.ipAddress')}</label>\n              <input\n                type=\"text\"\n                required\n                pattern=\"(\\d{1,3}(\\.\\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*)\"\n                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\"\n                value={form.ip_address}\n                onChange={(e) => setForm({ ...form, ip_address: e.target.value })}\n                placeholder=\"192.168.1.100 or printer.local\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.serialNumber')}</label>\n              <input\n                type=\"text\"\n                required\n                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\"\n                value={form.serial_number}\n                onChange={(e) => setForm({ ...form, serial_number: e.target.value })}\n                placeholder=\"01P00A000000000\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.accessCode')}</label>\n              <input\n                type=\"password\"\n                required\n                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\"\n                value={form.access_code}\n                onChange={(e) => setForm({ ...form, access_code: e.target.value })}\n                placeholder={t('printers.modal.fromPrinterSettings')}\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.modal.modelOptional')}</label>\n              <select\n                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\"\n                value={form.model || ''}\n                onChange={(e) => setForm({ ...form, model: e.target.value })}\n              >\n                <option value=\"\">{t('printers.modal.selectModel')}</option>\n                <optgroup label=\"H2 Series\">\n                  <option value=\"H2C\">H2C</option>\n                  <option value=\"H2D\">H2D</option>\n                  <option value=\"H2D Pro\">H2D Pro</option>\n                  <option value=\"H2S\">H2S</option>\n                </optgroup>\n                <optgroup label=\"X2 Series\">\n                  <option value=\"X2D\">X2D</option>\n                </optgroup>\n                <optgroup label=\"X1 Series\">\n                  <option value=\"X1E\">X1E</option>\n                  <option value=\"X1C\">X1 Carbon</option>\n                  <option value=\"X1\">X1</option>\n                </optgroup>\n                <optgroup label=\"P Series\">\n                  <option value=\"P2S\">P2S</option>\n                  <option value=\"P1S\">P1S</option>\n                  <option value=\"P1P\">P1P</option>\n                </optgroup>\n                <optgroup label=\"A1 Series\">\n                  <option value=\"A1\">A1</option>\n                  <option value=\"A1 Mini\">A1 Mini</option>\n                </optgroup>\n              </select>\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.modal.locationGroup')}</label>\n              <input\n                type=\"text\"\n                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\"\n                value={form.location || ''}\n                onChange={(e) => setForm({ ...form, location: e.target.value })}\n                placeholder={t('printers.modal.locationPlaceholder')}\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('printers.locationHelp')}</p>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <input\n                type=\"checkbox\"\n                id=\"auto_archive\"\n                checked={form.auto_archive}\n                onChange={(e) => setForm({ ...form, auto_archive: e.target.checked })}\n                className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n              />\n              <label htmlFor=\"auto_archive\" className=\"text-sm text-bambu-gray\">\n                {t('printers.modal.autoArchiveLabel')}\n              </label>\n            </div>\n            <div className=\"flex gap-3 pt-4\">\n              <Button type=\"button\" variant=\"secondary\" onClick={onClose} className=\"flex-1\">\n                {t('common.cancel')}\n              </Button>\n              <Button type=\"submit\" className=\"flex-1\">\n                {t('printers.addPrinter')}\n              </Button>\n            </div>\n          </form>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\nfunction FirmwareUpdateModal({\n  printer,\n  firmwareInfo,\n  onClose,\n}: {\n  printer: Printer;\n  firmwareInfo: FirmwareUpdateInfo;\n  onClose: () => void;\n}) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  const canUpdate = hasPermission('firmware:update');\n  const [uploadStatus, setUploadStatus] = useState<FirmwareUploadStatus | null>(null);\n  const [isUploading, setIsUploading] = useState(false);\n  const [pollInterval, setPollInterval] = useState<NodeJS.Timeout | null>(null);\n  const [selectedVersion, setSelectedVersion] = useState<string | null>(\n    firmwareInfo.update_available ? firmwareInfo.latest_version : null,\n  );\n\n  // Prepare check query — runs when a version is selected and user can update\n  const { data: prepareInfo, isLoading: isPreparing } = useQuery({\n    queryKey: ['firmwarePrepare', printer.id, selectedVersion],\n    queryFn: () => firmwareApi.prepareUpload(printer.id, selectedVersion ?? undefined),\n    staleTime: 30000,\n    enabled: !!selectedVersion && canUpdate && !isUploading,\n  });\n\n  // Start upload mutation\n  const uploadMutation = useMutation({\n    mutationFn: () => firmwareApi.startUpload(printer.id, selectedVersion ?? undefined),\n    onSuccess: () => {\n      setIsUploading(true);\n      // Start polling for status\n      const interval = setInterval(async () => {\n        try {\n          const status = await firmwareApi.getUploadStatus(printer.id);\n          setUploadStatus(status);\n          if (status.status === 'complete' || status.status === 'error') {\n            clearInterval(interval);\n            setPollInterval(null);\n            setIsUploading(false);\n            if (status.status === 'complete') {\n              showToast(t('printers.firmwareModal.uploadedToast'), 'success');\n              queryClient.invalidateQueries({ queryKey: ['firmwareUpdate', printer.id] });\n            }\n          }\n        } catch {\n          // Ignore errors during polling\n        }\n      }, 2000);\n      setPollInterval(interval);\n    },\n    onError: (error: Error) => {\n      showToast(t('printers.firmwareModal.uploadFailed', { error: error.message }), 'error');\n      setIsUploading(false);\n    },\n  });\n\n  // Cleanup on unmount\n  useEffect(() => {\n    return () => {\n      if (pollInterval) clearInterval(pollInterval);\n    };\n  }, [pollInterval]);\n\n  const handleStartUpload = () => {\n    setUploadStatus(null);\n    uploadMutation.mutate();\n  };\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\">\n      <Card className=\"w-full max-w-md mx-4\">\n        <CardContent>\n          <div className=\"flex items-start gap-3 mb-4\">\n            <div className={`p-2 rounded-full ${firmwareInfo.update_available ? 'bg-orange-500/20' : 'bg-status-ok/20'}`}>\n              {firmwareInfo.update_available\n                ? <Download className=\"w-5 h-5 text-orange-400\" />\n                : <CheckCircle className=\"w-5 h-5 text-status-ok\" />}\n            </div>\n            <div className=\"flex-1\">\n              <h3 className=\"text-lg font-semibold text-white\">\n                {firmwareInfo.update_available ? t('printers.firmwareModal.title') : t('printers.firmwareModal.titleUpToDate')}\n              </h3>\n              <p className=\"text-sm text-bambu-gray mt-1\">\n                {printer.name}\n              </p>\n            </div>\n          </div>\n\n          {/* Version Info */}\n          {(() => {\n            const selectedEntry = selectedVersion\n              ? firmwareInfo.available_versions?.find((v) => v.version === selectedVersion)\n              : null;\n            const displayVersion = selectedVersion ?? firmwareInfo.latest_version;\n            const displayNotes = selectedEntry?.release_notes ?? firmwareInfo.release_notes;\n            const showSecondLine = !!displayVersion && displayVersion !== firmwareInfo.current_version;\n            return (\n              <div className=\"bg-bambu-dark rounded-lg p-3 mb-4\">\n                <div className=\"flex justify-between items-center text-sm\">\n                  <span className=\"text-bambu-gray\">{t('printers.firmwareModal.currentVersion')}</span>\n                  <span className={`font-mono ${showSecondLine ? 'text-white' : 'text-status-ok'}`}>\n                    {firmwareInfo.current_version || t('common.unknown')}\n                  </span>\n                </div>\n                {showSecondLine && (\n                  <div className=\"flex justify-between items-center text-sm mt-1\">\n                    <span className=\"text-bambu-gray\">{t('printers.firmwareModal.latestVersion')}</span>\n                    <span className=\"text-orange-400 font-mono\">{displayVersion}</span>\n                  </div>\n                )}\n                {displayNotes && (\n                  <details className=\"mt-3 text-sm\" open={!showSecondLine} key={displayVersion ?? 'none'}>\n                    <summary className={`cursor-pointer hover:underline ${showSecondLine ? 'text-orange-400' : 'text-status-ok'}`}>\n                      {t('printers.firmwareModal.releaseNotes')}\n                    </summary>\n                    <div className=\"mt-2 text-bambu-gray text-xs max-h-40 overflow-y-auto whitespace-pre-wrap\">\n                      {displayNotes}\n                    </div>\n                  </details>\n                )}\n              </div>\n            );\n          })()}\n\n          {/* Available versions list */}\n          {firmwareInfo.available_versions && firmwareInfo.available_versions.length > 0 && !isUploading && uploadStatus?.status !== 'complete' && (\n            <div className=\"mb-4\">\n              <div className=\"text-xs text-bambu-gray mb-2\">{t('printers.firmwareModal.availableVersions')}</div>\n              <div className=\"max-h-56 overflow-y-auto border border-bambu-dark-tertiary rounded-lg divide-y divide-bambu-dark-tertiary\">\n                {firmwareInfo.available_versions.map((v) => {\n                  const isCurrent = firmwareInfo.current_version === v.version;\n                  const isSelected = selectedVersion === v.version;\n                  const cmp = firmwareInfo.current_version\n                    ? compareFwVersions(v.version, firmwareInfo.current_version)\n                    : 0;\n                  const relLabel = isCurrent\n                    ? t('printers.firmwareModal.currentBadge')\n                    : cmp > 0\n                      ? t('printers.firmwareModal.newerBadge')\n                      : t('printers.firmwareModal.olderBadge');\n                  const relClass = isCurrent\n                    ? 'text-bambu-gray'\n                    : cmp > 0\n                      ? 'text-orange-400'\n                      : 'text-blue-400';\n                  return (\n                    <button\n                      key={v.version}\n                      type=\"button\"\n                      disabled={!v.file_available || !canUpdate || isCurrent}\n                      onClick={() => setSelectedVersion(v.version)}\n                      className={`w-full text-left px-3 py-2 text-sm flex items-center justify-between gap-2 transition-colors ${\n                        isSelected ? 'bg-orange-500/10' : 'hover:bg-bambu-dark'\n                      } ${!v.file_available || !canUpdate || isCurrent ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}\n                    >\n                      <div className=\"flex items-center gap-2 min-w-0\">\n                        <span className=\"font-mono text-white\">{v.version}</span>\n                        <span className={`text-xs ${relClass}`}>{relLabel}</span>\n                      </div>\n                      <span className={`text-xs px-2 py-0.5 rounded-full ${\n                        isCurrent\n                          ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'\n                          : v.file_available\n                            ? 'bg-bambu-green/15 text-bambu-green border border-bambu-green/30'\n                            : 'bg-bambu-gray/10 text-bambu-gray border border-bambu-gray/30'\n                      }`}>\n                        {isCurrent\n                          ? t('printers.firmwareModal.installed')\n                          : v.file_available\n                          ? t('printers.firmwareModal.usable')\n                          : t('printers.firmwareModal.unavailable')}\n                      </span>\n                    </button>\n                  );\n                })}\n              </div>\n            </div>\n          )}\n\n          {/* Status / Progress (only when a version is selected) */}\n          {!selectedVersion ? null : isPreparing ? (\n            <div className=\"flex items-center gap-2 text-bambu-gray text-sm mb-4\">\n              <Loader2 className=\"w-4 h-4 animate-spin\" />\n              {t('printers.firmwareModal.checkingPrereqs')}\n            </div>\n          ) : prepareInfo && !isUploading && !uploadStatus ? (\n            <div className=\"mb-4\">\n              {prepareInfo.can_proceed ? (\n                <div className=\"flex items-center gap-2 text-bambu-green text-sm\">\n                  <Box className=\"w-4 h-4\" />\n                  {t('printers.firmwareModal.sdCardReady')}\n                </div>\n              ) : (\n                <div className=\"space-y-1\">\n                  {prepareInfo.errors.map((error, i) => (\n                    <div key={i} className=\"flex items-center gap-2 text-red-400 text-sm\">\n                      <AlertCircle className=\"w-4 h-4 flex-shrink-0\" />\n                      {error}\n                    </div>\n                  ))}\n                </div>\n              )}\n            </div>\n          ) : null}\n\n          {/* Upload Progress */}\n          {(isUploading || uploadStatus) && uploadStatus && (\n            <div className=\"mb-4\">\n              <div className=\"flex items-center justify-between text-sm mb-1\">\n                <span className=\"text-bambu-gray capitalize\">{uploadStatus.status}</span>\n                <span className=\"text-white\">{uploadStatus.progress}%</span>\n              </div>\n              <div className=\"w-full bg-bambu-dark-tertiary rounded-full h-2\">\n                <div\n                  className={`h-2 rounded-full transition-all ${\n                    uploadStatus.status === 'error' ? 'bg-status-error' :\n                    uploadStatus.status === 'complete' ? 'bg-status-ok' : 'bg-orange-500'\n                  } ${uploadStatus.status === 'uploading' ? 'animate-pulse' : ''}`}\n                  style={{ width: `${uploadStatus.progress}%` }}\n                />\n              </div>\n              <p className=\"text-xs text-bambu-gray mt-1\">{uploadStatus.message}</p>\n              {uploadStatus.error && (\n                <p className=\"text-xs text-red-400 mt-1\">{uploadStatus.error}</p>\n              )}\n            </div>\n          )}\n\n          {/* Success Message */}\n          {uploadStatus?.status === 'complete' && (\n            <div className=\"bg-bambu-green/10 border border-bambu-green/30 rounded-lg p-3 mb-4\">\n              <p className=\"text-sm text-bambu-green font-medium mb-2\">\n                {t('printers.firmwareModal.uploadedSuccess')}\n              </p>\n              <p className=\"text-xs text-bambu-gray\">\n                {t('printers.firmwareModal.applyInstructions')}\n              </p>\n              <ol className=\"text-xs text-bambu-gray mt-1 list-decimal list-inside space-y-1\">\n                <li dangerouslySetInnerHTML={{ __html: t('printers.firmwareModal.step1') }} />\n                <li dangerouslySetInnerHTML={{ __html: t('printers.firmwareModal.step2') }} />\n                <li dangerouslySetInnerHTML={{ __html: t('printers.firmwareModal.step3') }} />\n                <li>{t('printers.firmwareModal.step4')}</li>\n              </ol>\n            </div>\n          )}\n\n          {/* Buttons */}\n          <div className=\"flex gap-2 justify-end\">\n            <Button variant=\"secondary\" onClick={onClose}>\n              {uploadStatus?.status === 'complete' ? t('printers.firmwareModal.done') : t('common.cancel')}\n            </Button>\n            {prepareInfo?.can_proceed && !isUploading && uploadStatus?.status !== 'complete' && canUpdate && (\n              <Button\n                onClick={handleStartUpload}\n                disabled={uploadMutation.isPending}\n              >\n                {uploadMutation.isPending ? (\n                  <>\n                    <Loader2 className=\"w-4 h-4 animate-spin mr-2\" />\n                    {t('printers.firmwareModal.starting')}\n                  </>\n                ) : (\n                  <>\n                    <Download className=\"w-4 h-4 mr-2\" />\n                    {t('printers.firmwareModal.uploadFirmware')}\n                  </>\n                )}\n              </Button>\n            )}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\nfunction EditPrinterModal({\n  printer,\n  onClose,\n}: {\n  printer: Printer;\n  onClose: () => void;\n}) {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const [form, setForm] = useState({\n    name: printer.name,\n    ip_address: printer.ip_address,\n    access_code: '',\n    model: printer.model || '',\n    location: printer.location || '',\n    auto_archive: printer.auto_archive,\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: (data: Partial<PrinterCreate>) => api.updatePrinter(printer.id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['printers'] });\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });\n      onClose();\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUpdate'), 'error'),\n  });\n\n  // Close on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    const data: Partial<PrinterCreate> = {\n      name: form.name,\n      ip_address: form.ip_address,\n      model: form.model || undefined,\n      location: form.location || undefined,\n      auto_archive: form.auto_archive,\n    };\n    // Only include access_code if it was changed\n    if (form.access_code) {\n      data.access_code = form.access_code;\n    }\n    updateMutation.mutate(data);\n  };\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-start sm:items-center justify-center z-50 p-4 overflow-y-auto\"\n      onClick={onClose}\n    >\n      <Card className=\"w-full max-w-md my-auto max-h-[calc(100vh-2rem)] overflow-y-auto\" onClick={(e: React.MouseEvent) => e.stopPropagation()}>\n        <CardContent>\n          <h2 className=\"text-xl font-semibold mb-4\">{t('printers.editPrinter')}</h2>\n          <form onSubmit={handleSubmit} className=\"space-y-4\">\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.name')}</label>\n              <input\n                type=\"text\"\n                required\n                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\"\n                value={form.name}\n                onChange={(e) => setForm({ ...form, name: e.target.value })}\n                placeholder={t('printers.modal.myPrinter')}\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.ipAddress')}</label>\n              <input\n                type=\"text\"\n                required\n                pattern=\"(\\d{1,3}(\\.\\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?)*)\"\n                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\"\n                value={form.ip_address}\n                onChange={(e) => setForm({ ...form, ip_address: e.target.value })}\n                placeholder=\"192.168.1.100 or printer.local\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.serialNumber')}</label>\n              <input\n                type=\"text\"\n                disabled\n                className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed\"\n                value={printer.serial_number}\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('printers.serialCannotBeChanged')}</p>\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.accessCode')}</label>\n              <input\n                type=\"password\"\n                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\"\n                value={form.access_code}\n                onChange={(e) => setForm({ ...form, access_code: e.target.value })}\n                placeholder={t('printers.accessCodePlaceholder')}\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">{t('printers.model')}</label>\n              <select\n                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\"\n                value={form.model}\n                onChange={(e) => setForm({ ...form, model: e.target.value })}\n              >\n                <option value=\"\">{t('printers.modal.selectModel')}</option>\n                <optgroup label=\"H2 Series\">\n                  <option value=\"H2C\">H2C</option>\n                  <option value=\"H2D\">H2D</option>\n                  <option value=\"H2D Pro\">H2D Pro</option>\n                  <option value=\"H2S\">H2S</option>\n                </optgroup>\n                <optgroup label=\"X2 Series\">\n                  <option value=\"X2D\">X2D</option>\n                </optgroup>\n                <optgroup label=\"X1 Series\">\n                  <option value=\"X1E\">X1E</option>\n                  <option value=\"X1C\">X1 Carbon</option>\n                  <option value=\"X1\">X1</option>\n                </optgroup>\n                <optgroup label=\"P Series\">\n                  <option value=\"P2S\">P2S</option>\n                  <option value=\"P1S\">P1S</option>\n                  <option value=\"P1P\">P1P</option>\n                </optgroup>\n                <optgroup label=\"A1 Series\">\n                  <option value=\"A1\">A1</option>\n                  <option value=\"A1 Mini\">A1 Mini</option>\n                </optgroup>\n              </select>\n            </div>\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">Location / Group</label>\n              <input\n                type=\"text\"\n                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\"\n                value={form.location}\n                onChange={(e) => setForm({ ...form, location: e.target.value })}\n                placeholder={t('printers.modal.locationPlaceholder')}\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('printers.locationHelp')}</p>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <input\n                type=\"checkbox\"\n                id=\"edit_auto_archive\"\n                checked={form.auto_archive}\n                onChange={(e) => setForm({ ...form, auto_archive: e.target.checked })}\n                className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n              />\n              <label htmlFor=\"edit_auto_archive\" className=\"text-sm text-bambu-gray\">\n                {t('printers.modal.autoArchiveLabel')}\n              </label>\n            </div>\n            <div className=\"flex gap-3 pt-4\">\n              <Button type=\"button\" variant=\"secondary\" onClick={onClose} className=\"flex-1\">\n                {t('common.cancel')}\n              </Button>\n              <Button type=\"submit\" className=\"flex-1\" disabled={updateMutation.isPending}>\n                {updateMutation.isPending ? t('common.saving') : t('printers.modal.saveChanges')}\n              </Button>\n            </div>\n          </form>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\n// Component to check if a printer is offline (for power dropdown)\nfunction usePrinterOfflineStatus(printerId: number) {\n  const { data: status } = useQuery({\n    queryKey: ['printerStatus', printerId],\n    queryFn: () => api.getPrinterStatus(printerId),\n    refetchInterval: 30000,\n  });\n  return !status?.connected;\n}\n\n// Power dropdown item for an offline printer\nfunction PowerDropdownItem({\n  printer,\n  plug,\n  onPowerOn,\n  isPowering,\n}: {\n  printer: Printer;\n  plug: { id: number; name: string };\n  onPowerOn: (plugId: number) => void;\n  isPowering: boolean;\n}) {\n  const isOffline = usePrinterOfflineStatus(printer.id);\n\n  // Fetch plug status\n  const { data: plugStatus } = useQuery({\n    queryKey: ['smartPlugStatus', plug.id],\n    queryFn: () => api.getSmartPlugStatus(plug.id),\n    refetchInterval: 10000,\n  });\n\n  // Only show if printer is offline\n  if (!isOffline) {\n    return null;\n  }\n\n  return (\n    <div className=\"flex items-center justify-between px-3 py-2 hover:bg-gray-100 dark:hover:bg-bambu-dark-tertiary\">\n      <div className=\"flex items-center gap-2 min-w-0\">\n        <span className=\"text-sm text-gray-900 dark:text-white truncate\">{printer.name}</span>\n        {plugStatus && (\n          <span\n            className={`text-xs px-1.5 py-0.5 rounded ${\n              plugStatus.state === 'ON'\n                ? 'bg-bambu-green/20 text-bambu-green'\n                : 'bg-red-500/20 text-red-400'\n            }`}\n          >\n            {plugStatus.state || '?'}\n          </span>\n        )}\n      </div>\n      <button\n        onClick={() => onPowerOn(plug.id)}\n        disabled={isPowering || plugStatus?.state === 'ON'}\n        className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${\n          plugStatus?.state === 'ON'\n            ? 'bg-bambu-green/20 text-bambu-green cursor-default'\n            : 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green hover:text-white'\n        }`}\n      >\n        <Power className=\"w-3 h-3\" />\n        {isPowering ? '...' : 'On'}\n      </button>\n    </div>\n  );\n}\n\nexport function PrintersPage() {\n  const { t } = useTranslation();\n  const [showAddModal, setShowAddModal] = useState(false);\n  const [hideDisconnected, setHideDisconnected] = useState(() => {\n    return localStorage.getItem('hideDisconnectedPrinters') === 'true';\n  });\n  const [showPowerDropdown, setShowPowerDropdown] = useState(false);\n  const [poweringOn, setPoweringOn] = useState<number | null>(null);\n  const [sortBy, setSortBy] = useState<SortOption>(() => {\n    return (localStorage.getItem('printerSortBy') as SortOption) || 'name';\n  });\n  const [sortAsc, setSortAsc] = useState<boolean>(() => {\n    return localStorage.getItem('printerSortAsc') !== 'false';\n  });\n  // Card size: 1=small, 2=medium, 3=large, 4=xl\n  const [cardSize, setCardSize] = useState<number>(() => {\n    const saved = localStorage.getItem('printerCardSize');\n    return saved ? parseInt(saved, 10) : 2; // Default to medium\n  });\n  // Derive viewMode from cardSize: S=compact, M/L/XL=expanded\n  const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';\n  const [search, setSearch] = useState('');\n  const [statusFilter, setStatusFilter] = useState<string>('all');\n  const [locationFilter, setLocationFilter] = useState<string>('all');\n  const [statusCacheVersion, setStatusCacheVersion] = useState(0);\n  const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(() => {\n    try {\n      const saved = localStorage.getItem('printerCollapsedSections');\n      return saved ? JSON.parse(saved) : {};\n    } catch { return {}; }\n  });\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  // Embedded camera viewer state - supports multiple simultaneous viewers\n  // Persisted to localStorage so cameras reopen after navigation\n  const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {\n    // Initialize from localStorage if camera_view_mode is embedded\n    const saved = localStorage.getItem('openEmbeddedCameras');\n    if (saved) {\n      try {\n        const cameras = JSON.parse(saved) as Array<{ id: number; name: string }>;\n        return new Map(cameras.map(c => [c.id, c]));\n      } catch {\n        return new Map();\n      }\n    }\n    return new Map();\n  });\n\n  // Persist open cameras to localStorage when they change\n  useEffect(() => {\n    const cameras = Array.from(embeddedCameraPrinters.values());\n    if (cameras.length > 0) {\n      localStorage.setItem('openEmbeddedCameras', JSON.stringify(cameras));\n    } else {\n      localStorage.removeItem('openEmbeddedCameras');\n    }\n  }, [embeddedCameraPrinters]);\n\n  const { data: printers, isLoading } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  // Fetch app settings for AMS thresholds\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  // Compute drying presets: user-configured (from settings) merged over built-in defaults\n  const effectiveDryingPresets = useMemo(() => {\n    if (settings?.drying_presets) {\n      try {\n        const userPresets = JSON.parse(settings.drying_presets);\n        if (typeof userPresets === 'object' && userPresets !== null && Object.keys(userPresets).length > 0) {\n          return { ...DRYING_PRESETS, ...userPresets };\n        }\n      } catch { /* ignore parse errors, use defaults */ }\n    }\n    return DRYING_PRESETS;\n  }, [settings?.drying_presets]);\n\n  // Close embedded cameras if mode changes to 'window'\n  useEffect(() => {\n    if (settings?.camera_view_mode === 'window' && embeddedCameraPrinters.size > 0) {\n      setEmbeddedCameraPrinters(new Map());\n    }\n  }, [settings?.camera_view_mode, embeddedCameraPrinters.size]);\n\n  // Fetch all smart plugs to know which printers have them\n  const { data: smartPlugs } = useQuery({\n    queryKey: ['smart-plugs'],\n    queryFn: api.getSmartPlugs,\n  });\n\n  // Fetch maintenance overview for all printers to show badges\n  const { data: maintenanceOverview } = useQuery({\n    queryKey: ['maintenanceOverview'],\n    queryFn: api.getMaintenanceOverview,\n    staleTime: 60 * 1000, // 1 minute\n  });\n\n  // Fetch Spoolman status to enable link spool feature\n  const { data: spoolmanStatus } = useQuery({\n    queryKey: ['spoolman-status'],\n    queryFn: api.getSpoolmanStatus,\n    staleTime: 60 * 1000, // 1 minute\n  });\n  const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected;\n\n  // Fetch Spoolman settings to get sync mode\n  const { data: spoolmanSettings } = useQuery({\n    queryKey: ['spoolman-settings'],\n    queryFn: api.getSpoolmanSettings,\n    enabled: !!spoolmanEnabled,\n    staleTime: 60 * 1000, // 1 minute\n  });\n  const spoolmanSyncMode = spoolmanSettings?.spoolman_sync_mode;\n\n  // Fetch unlinked spools to know if link button should be enabled\n  const { data: unlinkedSpools } = useQuery({\n    queryKey: ['unlinked-spools'],\n    queryFn: api.getUnlinkedSpools,\n    enabled: !!spoolmanEnabled,\n    staleTime: 30 * 1000, // 30 seconds\n  });\n  const hasUnlinkedSpools = unlinkedSpools && unlinkedSpools.length > 0;\n\n  // Fetch linked spools map (tag -> spool_id) to know which spools are already in Spoolman\n  const { data: linkedSpoolsData } = useQuery({\n    queryKey: ['linked-spools'],\n    queryFn: api.getLinkedSpools,\n    enabled: !!spoolmanEnabled,\n    staleTime: 30 * 1000, // 30 seconds\n  });\n  const linkedSpools = linkedSpoolsData?.linked;\n\n  // Fetch spool assignments for inventory feature\n  const { data: spoolAssignments } = useQuery({\n    queryKey: ['spool-assignments'],\n    queryFn: () => api.getAssignments(),\n    enabled: hasPermission('inventory:view_assignments'),\n    staleTime: 30 * 1000,\n  });\n\n  const unassignMutation = useMutation({\n    mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>\n      api.unassignSpool(printerId, amsId, trayId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });\n    },\n  });\n\n  // Helper to find assignment for a specific slot\n  const getAssignment = (printerId: number, amsId: number | string, trayId: number | string): SpoolAssignment | undefined => {\n    return spoolAssignments?.find(\n      (a) => a.printer_id === printerId && a.ams_id === Number(amsId) && a.tray_id === Number(trayId)\n    );\n  };\n\n  // Create a map of printer_id -> maintenance info for quick lookup\n  const maintenanceByPrinter = maintenanceOverview?.reduce(\n    (acc, overview) => {\n      acc[overview.printer_id] = {\n        due_count: overview.due_count,\n        warning_count: overview.warning_count,\n        total_print_hours: overview.total_print_hours,\n      };\n      return acc;\n    },\n    {} as Record<number, PrinterMaintenanceInfo>\n  ) || {};\n\n  // Create a map of printer_id -> smart plug\n  const smartPlugByPrinter = smartPlugs?.reduce(\n    (acc, plug) => {\n      if (plug.printer_id) {\n        acc[plug.printer_id] = plug;\n      }\n      return acc;\n    },\n    {} as Record<number, typeof smartPlugs[0]>\n  ) || {};\n\n  const addMutation = useMutation({\n    mutationFn: api.createPrinter,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['printers'] });\n      queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });\n      setShowAddModal(false);\n    },\n    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToAdd'), 'error'),\n  });\n\n  const powerOnMutation = useMutation({\n    mutationFn: (plugId: number) => api.controlSmartPlug(plugId, 'on'),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });\n      setPoweringOn(null);\n    },\n    onError: () => {\n      setPoweringOn(null);\n    },\n  });\n\n  // Bulk selection state\n  const [selectedPrinterIds, setSelectedPrinterIds] = useState<Set<number>>(new Set());\n  const [isSelectionMode, setIsSelectionMode] = useState(false);\n  const [bulkConfirmAction, setBulkConfirmAction] = useState<'stop' | 'pause' | 'clearPlate' | null>(null);\n  const [bulkActionPending, setBulkActionPending] = useState(false);\n  const selectionMode = isSelectionMode || selectedPrinterIds.size > 0;\n\n  const toggleSelect = useCallback((id: number) => {\n    setSelectedPrinterIds(prev => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n  }, []);\n\n  const clearSelection = useCallback(() => {\n    setSelectedPrinterIds(new Set());\n    setIsSelectionMode(false);\n  }, []);\n\n  // Escape key exits selection mode\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape' && selectionMode) {\n        clearSelection();\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [selectionMode, clearSelection]);\n\n  const executeBulkAction = useCallback(async (action: 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS') => {\n    setBulkActionPending(true);\n    const ids = Array.from(selectedPrinterIds);\n\n    // Filter to only applicable printers based on cached state\n    const applicableIds = ids.filter(id => {\n      const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', id]);\n      if (!status?.connected) return false;\n      switch (action) {\n        case 'stop': return status.state === 'RUNNING' || status.state === 'PAUSE';\n        case 'pause': return status.state === 'RUNNING';\n        case 'resume': return status.state === 'PAUSE';\n        case 'clearPlate': return !!(status as { awaiting_plate_clear?: boolean }).awaiting_plate_clear;\n        case 'clearHMS': return status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0;\n        default: return false;\n      }\n    });\n\n    if (applicableIds.length === 0) {\n      showToast(t('printers.bulk.noneApplicable'), 'error');\n      setBulkActionPending(false);\n      setBulkConfirmAction(null);\n      return;\n    }\n\n    const apiCall = {\n      stop: api.stopPrint,\n      pause: api.pausePrint,\n      resume: api.resumePrint,\n      clearPlate: api.clearPlate,\n      clearHMS: api.clearHMSErrors,\n    }[action];\n\n    const results = await Promise.allSettled(\n      applicableIds.map(id => apiCall(id))\n    );\n\n    const succeeded = results.filter(r => r.status === 'fulfilled').length;\n    const failed = results.filter(r => r.status === 'rejected').length;\n\n    if (failed === 0) {\n      showToast(t('printers.bulk.success', { action: t(`printers.bulk.actions.${action}`), count: succeeded }));\n    } else {\n      showToast(t('printers.bulk.partial', { succeeded, failed }), 'error');\n    }\n\n    // Invalidate status queries for affected printers\n    applicableIds.forEach(id => {\n      queryClient.invalidateQueries({ queryKey: ['printerStatus', id] });\n    });\n\n    setBulkActionPending(false);\n    setBulkConfirmAction(null);\n  }, [selectedPrinterIds, queryClient, showToast, t]);\n\n  const handleBulkAction = useCallback((action: 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS') => {\n    // Actions that need confirmation\n    if (action === 'stop' || action === 'pause' || action === 'clearPlate') {\n      setBulkConfirmAction(action);\n    } else {\n      executeBulkAction(action);\n    }\n  }, [executeBulkAction]);\n\n  const toggleHideDisconnected = () => {\n    const newValue = !hideDisconnected;\n    setHideDisconnected(newValue);\n    localStorage.setItem('hideDisconnectedPrinters', String(newValue));\n  };\n\n  const handleSortChange = (newSort: SortOption) => {\n    setSortBy(newSort);\n    localStorage.setItem('printerSortBy', newSort);\n  };\n\n  const toggleSortDirection = () => {\n    const newAsc = !sortAsc;\n    setSortAsc(newAsc);\n    localStorage.setItem('printerSortAsc', String(newAsc));\n  };\n\n  // Grid classes based on card size (1=small, 2=medium, 3=large, 4=xl)\n  const getGridClasses = () => {\n    switch (cardSize) {\n      case 1: return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'; // S: many small cards\n      case 2: return 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'; // M: medium cards\n      case 3: return 'grid-cols-1 lg:grid-cols-2'; // L: large cards, 2 columns max\n      case 4: return 'grid-cols-1'; // XL: single column, full width\n      default: return 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3';\n    }\n  };\n\n  const cardSizeLabels = ['S', 'M', 'L', 'XL'];\n\n  // Increment version counter whenever a printer status cache entry is updated so\n  // filteredPrinters re-computes reactively on WebSocket-driven status changes.\n  useEffect(() => {\n    const unsubscribe = queryClient.getQueryCache().subscribe((event) => {\n      if (\n        event.type === 'updated' &&\n        Array.isArray(event.query.queryKey) &&\n        event.query.queryKey[0] === 'printerStatus'\n      ) {\n        setStatusCacheVersion(v => v + 1);\n      }\n    });\n    return unsubscribe;\n  }, [queryClient]);\n\n  // Filter printers by search term, status, and location\n  const filteredPrinters = useMemo(() => {\n    if (!printers) return [];\n    let result = printers;\n\n    // Text search\n    if (search.trim()) {\n      const q = search.trim().toLowerCase();\n      result = result.filter(p =>\n        p.name.toLowerCase().includes(q) ||\n        (p.model || '').toLowerCase().includes(q) ||\n        (p.location || '').toLowerCase().includes(q) ||\n        (p.serial_number || '').toLowerCase().includes(q)\n      );\n    }\n\n    // Location filter\n    if (locationFilter !== 'all') {\n      result = result.filter(p => (p.location || '') === locationFilter);\n    }\n\n    // Status filter\n    if (statusFilter !== 'all') {\n      result = result.filter(p => {\n        const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]);\n        if (!status?.connected) return statusFilter === 'offline';\n        const hmsErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : [];\n        switch (statusFilter) {\n          case 'printing': return status.state === 'RUNNING';\n          case 'paused':   return status.state === 'PAUSE';\n          case 'finished': return status.state === 'FINISH';\n          case 'error':    return status.state === 'FAILED' || hmsErrors.length > 0;\n          case 'idle':     return status.state !== 'RUNNING' && status.state !== 'PAUSE' && status.state !== 'FINISH' && status.state !== 'FAILED' && hmsErrors.length === 0;\n          case 'offline':  return false; // Connected printers are never offline\n          default:         return true;\n        }\n      });\n    }\n\n    return result;\n  // eslint-disable-next-line react-hooks/exhaustive-deps -- statusCacheVersion is intentional: it forces recompute when WebSocket updates printer status cache\n  }, [printers, search, statusFilter, locationFilter, queryClient, statusCacheVersion]);\n\n  // Derive unique locations for the location filter dropdown\n  const availableLocations = useMemo(() => {\n    if (!printers) return [];\n    return [...new Set(printers.map(p => p.location || '').filter(Boolean))].sort();\n  }, [printers]);\n\n  // Sort printers based on selected option\n  const sortedPrinters = useMemo(() => {\n    const sorted = [...filteredPrinters];\n\n    switch (sortBy) {\n      case 'name':\n        sorted.sort((a, b) => a.name.localeCompare(b.name));\n        break;\n      case 'model':\n        sorted.sort((a, b) => (a.model || '').localeCompare(b.model || ''));\n        break;\n      case 'location':\n        // Sort by location, with ungrouped printers last\n        sorted.sort((a, b) => {\n          const locA = a.location || '';\n          const locB = b.location || '';\n          if (!locA && locB) return 1;\n          if (locA && !locB) return -1;\n          return locA.localeCompare(locB) || a.name.localeCompare(b.name);\n        });\n        break;\n      case 'status':\n        // Sort by status: HMS errors > printing > idle > offline\n        sorted.sort((a, b) => {\n          const statusA = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', a.id]);\n          const statusB = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', b.id]);\n\n          const getPriority = (s: typeof statusA) => {\n            if (!s?.connected) return 3; // offline\n            const hmsErrors = s.hms_errors ? filterKnownHMSErrors(s.hms_errors) : [];\n            if (hmsErrors.length > 0) return 0; // HMS errors - top priority\n            if (s.state === 'RUNNING') return 1; // printing\n            return 2; // idle\n          };\n\n          return getPriority(statusA) - getPriority(statusB);\n        });\n        break;\n    }\n\n    // Apply ascending/descending\n    if (!sortAsc) {\n      sorted.reverse();\n    }\n\n    return sorted;\n  }, [filteredPrinters, sortBy, sortAsc, queryClient]);\n\n  const selectAll = useCallback(() => {\n    setSelectedPrinterIds(new Set(sortedPrinters.map(p => p.id)));\n    setIsSelectionMode(true);\n  }, [sortedPrinters]);\n\n  const selectByState = useCallback((state: PrinterState) => {\n    setSelectedPrinterIds(prev => {\n      const next = new Set(prev);\n      sortedPrinters.forEach(p => {\n        const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]);\n        if (classifyPrinterStatus(status) === state) next.add(p.id);\n      });\n      return next;\n    });\n    setIsSelectionMode(true);\n  }, [sortedPrinters, queryClient]);\n\n  const selectByLocation = useCallback((location: string) => {\n    setSelectedPrinterIds(prev => {\n      const next = new Set(prev);\n      sortedPrinters.filter(p => (p.location || '') === location).forEach(p => next.add(p.id));\n      return next;\n    });\n    setIsSelectionMode(true);\n  }, [sortedPrinters]);\n\n  const selectByModel = useCallback((model: string) => {\n    setSelectedPrinterIds(prev => {\n      const next = new Set(prev);\n      sortedPrinters.filter(p => (p.model || 'Unknown') === model).forEach(p => next.add(p.id));\n      return next;\n    });\n    setIsSelectionMode(true);\n  }, [sortedPrinters]);\n\n  const toggleSectionCollapse = useCallback((key: string) => {\n    setCollapsedSections(prev => {\n      const next = { ...prev, [key]: !prev[key] };\n      try { localStorage.setItem('printerCollapsedSections', JSON.stringify(next)); } catch { /* quota exceeded / private mode */ }\n      return next;\n    });\n  }, []);\n\n  // Group printers when sorted by location, status, or model\n  const groupedPrinters = useMemo(() => {\n    if (sortBy === 'name') return null;\n\n    const groups: Record<string, typeof sortedPrinters> = {};\n\n    if (sortBy === 'location') {\n      sortedPrinters.forEach(printer => {\n        const location = printer.location || 'Ungrouped';\n        if (!groups[location]) groups[location] = [];\n        groups[location].push(printer);\n      });\n    } else if (sortBy === 'model') {\n      sortedPrinters.forEach(printer => {\n        const model = printer.model || 'Unknown';\n        if (!groups[model]) groups[model] = [];\n        groups[model].push(printer);\n      });\n    } else if (sortBy === 'status') {\n      sortedPrinters.forEach(printer => {\n        const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', printer.id]);\n        const group = classifyPrinterStatus(status);\n        if (!groups[group]) groups[group] = [];\n        groups[group].push(printer);\n      });\n    }\n\n    return groups;\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- classifyPrinterStatus & filterKnownHMSErrors are stable module-level functions, not reactive deps; statusCacheVersion forces recompute on WebSocket status updates\n  }, [sortBy, sortedPrinters, queryClient, statusCacheVersion]);\n\n  return (\n    <div className=\"p-4 md:p-8\">\n      <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6\">\n        <div>\n          <h1 className=\"text-2xl font-bold text-white\">{t('printers.title')}</h1>\n          <StatusSummaryBar printers={printers} />\n          {/* Only show search bar when printers exist */}\n          {printers && printers.length > 0 && (\n            <div className=\"relative w-full sm:max-w-sm mt-3\">\n              <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50\" />\n              <input\n                type=\"search\"\n                name=\"printer-search\"\n                autoComplete=\"off\"\n                data-1p-ignore\n                data-lpignore=\"true\"\n                value={search}\n                onChange={(e) => setSearch(e.target.value)}\n                placeholder={t('printers.search')}\n                aria-label={t('printers.search')}\n                className=\"w-full pl-10 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"\n              />\n              {search && (\n                <button\n                  type=\"button\"\n                  aria-label={t('common.clear')}\n                  onClick={() => setSearch('')}\n                  className=\"absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\"\n                >\n                  <X className=\"w-4 h-4\" />\n                </button>\n              )}\n            </div>\n          )}\n        </div>\n        <div className=\"flex items-center gap-2 sm:gap-3 flex-wrap\">\n          {/* Sort dropdown */}\n          <div className=\"flex items-center gap-1\">\n            <select\n              value={sortBy}\n              onChange={(e) => handleSortChange(e.target.value as SortOption)}\n              className=\"text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none\"\n            >\n              <option value=\"name\">{t('printers.sort.name')}</option>\n              <option value=\"status\">{t('printers.sort.status')}</option>\n              <option value=\"model\">{t('printers.sort.model')}</option>\n              <option value=\"location\">{t('printers.sort.location')}</option>\n            </select>\n            <button\n              onClick={toggleSortDirection}\n              className=\"p-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors\"\n              title={sortAsc ? t('printers.sort.descending') : t('printers.sort.ascending')}\n            >\n              {sortAsc ? (\n                <ArrowUp className=\"w-4 h-4 text-bambu-gray\" />\n              ) : (\n                <ArrowDown className=\"w-4 h-4 text-bambu-gray\" />\n              )}\n            </button>\n          </div>\n\n          {/* Status filter */}\n          {printers && printers.length > 0 && (\n            <select\n              value={statusFilter}\n              onChange={(e) => setStatusFilter(e.target.value)}\n              className=\"text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none\"\n            >\n              <option value=\"all\">{t('printers.filter.allStatuses')}</option>\n              <option value=\"printing\">{t('printers.status.printing')}</option>\n              <option value=\"paused\">{t('printers.status.paused')}</option>\n              <option value=\"idle\">{t('printers.status.idle')}</option>\n              <option value=\"finished\">{t('printers.status.finished')}</option>\n              <option value=\"error\">{t('printers.status.error')}</option>\n              <option value=\"offline\">{t('printers.status.offline')}</option>\n            </select>\n          )}\n\n          {/* Location filter — only shown when at least one printer has a location */}\n          {printers && printers.length > 0 && availableLocations.length > 0 && (\n            <select\n              value={locationFilter}\n              onChange={(e) => setLocationFilter(e.target.value)}\n              className=\"text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none\"\n            >\n              <option value=\"all\">{t('printers.filter.allLocations')}</option>\n              {availableLocations.map(loc => (\n                <option key={loc} value={loc}>{loc}</option>\n              ))}\n            </select>\n          )}\n\n          {/* Card size selector */}\n          <div className=\"flex items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n            {cardSizeLabels.map((label, index) => {\n              const size = index + 1;\n              const isSelected = cardSize === size;\n              return (\n                <button\n                  key={label}\n                  onClick={() => {\n                    setCardSize(size);\n                    localStorage.setItem('printerCardSize', String(size));\n                  }}\n                  className={`px-2 py-1.5 text-xs font-medium transition-colors ${\n                    index === 0 ? 'rounded-l-lg' : ''\n                  } ${\n                    index === cardSizeLabels.length - 1 ? 'rounded-r-lg' : ''\n                  } ${\n                    isSelected\n                      ? 'bg-bambu-green text-white'\n                      : 'text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white'\n                  }`}\n                  title={label === 'S' ? t('printers.cardSize.small') : label === 'M' ? t('printers.cardSize.medium') : label === 'L' ? t('printers.cardSize.large') : t('printers.cardSize.extraLarge')}\n                >\n                  {label}\n                </button>\n              );\n            })}\n          </div>\n\n          {/* Bulk select toggle */}\n          <button\n            onClick={() => {\n              if (selectionMode) clearSelection();\n              else setIsSelectionMode(true);\n            }}\n            className={`p-1.5 rounded-lg transition-colors ${\n              selectionMode\n                ? 'bg-bambu-green text-white'\n                : 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'\n            }`}\n            title={t('printers.bulk.select')}\n            disabled={!hasPermission('printers:control')}\n          >\n            <CheckSquare className=\"w-4 h-4\" />\n          </button>\n\n          <div className=\"w-px h-6 bg-bambu-dark-tertiary\" />\n\n          <label className=\"flex items-center gap-2 text-sm text-bambu-gray cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={hideDisconnected}\n              onChange={toggleHideDisconnected}\n              className=\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"\n            />\n            {t('printers.hideOffline')}\n          </label>\n          {/* Power dropdown for offline printers with smart plugs */}\n          {hideDisconnected && Object.keys(smartPlugByPrinter).length > 0 && (\n            <div className=\"relative\">\n              <button\n                onClick={() => setShowPowerDropdown(!showPowerDropdown)}\n                className=\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white dark:bg-bambu-dark-secondary border border-gray-200 dark:border-bambu-dark-tertiary rounded-lg text-gray-600 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white hover:border-bambu-green transition-colors\"\n              >\n                <Power className=\"w-4 h-4\" />\n                {t('printers.powerOn')}\n                <ChevronDown className={`w-3 h-3 transition-transform ${showPowerDropdown ? 'rotate-180' : ''}`} />\n              </button>\n              {showPowerDropdown && (\n                <>\n                  {/* Backdrop to close dropdown */}\n                  <div\n                    className=\"fixed inset-0 z-10\"\n                    onClick={() => setShowPowerDropdown(false)}\n                  />\n                  <div className=\"absolute right-0 mt-2 w-56 bg-white dark:bg-bambu-dark-secondary border border-gray-200 dark:border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1\">\n                    <div className=\"px-3 py-2 text-xs text-gray-500 dark:text-bambu-gray border-b border-gray-200 dark:border-bambu-dark-tertiary\">\n                      {t('printers.offlinePrintersWithPlugs')}\n                    </div>\n                    {printers?.filter(p => smartPlugByPrinter[p.id]).map(printer => (\n                      <PowerDropdownItem\n                        key={printer.id}\n                        printer={printer}\n                        plug={smartPlugByPrinter[printer.id]}\n                        onPowerOn={(plugId) => {\n                          setPoweringOn(plugId);\n                          powerOnMutation.mutate(plugId);\n                        }}\n                        isPowering={poweringOn === smartPlugByPrinter[printer.id]?.id}\n                      />\n                    ))}\n                    {printers?.filter(p => smartPlugByPrinter[p.id]).length === 0 && (\n                      <div className=\"px-3 py-2 text-sm text-bambu-gray\">\n                        No printers with smart plugs\n                      </div>\n                    )}\n                  </div>\n                </>\n              )}\n            </div>\n          )}\n          <Button\n            onClick={() => setShowAddModal(true)}\n            disabled={!hasPermission('printers:create')}\n            title={!hasPermission('printers:create') ? t('printers.permission.noAdd') : undefined}\n          >\n            <Plus className=\"w-4 h-4\" />\n            {t('printers.addPrinter')}\n          </Button>\n        </div>\n      </div>\n\n      {isLoading ? (\n        <div className=\"text-center py-12 text-bambu-gray\">{t('common.loading')}</div>\n      ) : printers?.length === 0 ? (\n        <Card>\n          <CardContent className=\"text-center py-12\">\n            <p className=\"text-bambu-gray mb-4\">{t('printers.noPrintersConfigured')}</p>\n            <Button\n              onClick={() => setShowAddModal(true)}\n              disabled={!hasPermission('printers:create')}\n              title={!hasPermission('printers:create') ? t('printers.permission.noAdd') : undefined}\n            >\n              <Plus className=\"w-4 h-4\" />\n              {t('printers.addPrinter')}\n            </Button>\n          </CardContent>\n        </Card>\n      ) : sortedPrinters.length === 0 && (search.trim() || statusFilter !== 'all' || locationFilter !== 'all') ? (\n        <Card>\n          <CardContent className=\"text-center py-12\">\n            <p className=\"text-bambu-gray\">{t('printers.noSearchResults')}</p>\n          </CardContent>\n        </Card>\n      ) : groupedPrinters ? (\n        /* Grouped view (location, status, or model) */\n        <div className=\"space-y-6\">\n          {(() => {\n            const keys = sortBy === 'status'\n              ? STATUS_GROUP_ORDER.filter(k => groupedPrinters[k]?.length > 0)\n              : Object.keys(groupedPrinters);\n            // For status grouping, asc/desc flips the fixed priority order\n            // (asc = error→offline, desc = offline→error). This matches the\n            // sort-toggle behaviour for other groupings.\n            return (sortAsc ? keys : [...keys].reverse());\n          })().map((groupKey) => {\n            const groupPrinters = groupedPrinters[groupKey];\n            const collapseKey = `${sortBy}:${groupKey}`;\n            const isOpen = !collapsedSections[collapseKey];\n\n            const dot = sortBy === 'status'\n              ? STATUS_GROUP_META[groupKey]?.dot || 'bg-bambu-green'\n              : 'bg-bambu-green';\n            const label = sortBy === 'status'\n              ? t(STATUS_GROUP_META[groupKey]?.labelKey || groupKey)\n              : groupKey;\n\n            return (\n              <Collapsible\n                key={groupKey}\n                open={isOpen}\n                onToggle={() => toggleSectionCollapse(collapseKey)}\n                summaryClassName=\"py-1\"\n                summary={\n                  <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                    <span className={`w-2 h-2 rounded-full ${dot}`} />\n                    {label}\n                    <span className=\"text-sm font-normal text-bambu-gray\">({groupPrinters.length})</span>\n                    {selectionMode && (\n                      <button\n                        onClick={(e) => {\n                          e.stopPropagation();\n                          if (sortBy === 'location') selectByLocation(groupKey === 'Ungrouped' ? '' : groupKey);\n                          else if (sortBy === 'status') selectByState(groupKey as PrinterState);\n                          else if (sortBy === 'model') selectByModel(groupKey);\n                        }}\n                        className=\"text-xs text-bambu-green hover:text-bambu-green-light transition-colors ml-1\"\n                      >\n                        {t('printers.bulk.selectAll')}\n                      </button>\n                    )}\n                  </h2>\n                }\n              >\n                <div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>\n                  {groupPrinters.map((printer) => (\n                    <PrinterCard\n                      key={printer.id}\n                      printer={printer}\n                      hideIfDisconnected={hideDisconnected}\n                      maintenanceInfo={maintenanceByPrinter[printer.id]}\n                      viewMode={viewMode}\n                      cardSize={cardSize}\n                      amsThresholds={settings ? {\n                        humidityGood: Number(settings.ams_humidity_good) || 40,\n                        humidityFair: Number(settings.ams_humidity_fair) || 60,\n                        tempGood: Number(settings.ams_temp_good) || 28,\n                        tempFair: Number(settings.ams_temp_fair) || 35,\n                      } : undefined}\n                      spoolmanEnabled={spoolmanEnabled}\n                      hasUnlinkedSpools={hasUnlinkedSpools}\n                      linkedSpools={linkedSpools}\n                      spoolmanUrl={spoolmanStatus?.url}\n                      spoolmanSyncMode={spoolmanSyncMode}\n                      onGetAssignment={getAssignment}\n                      onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}\n                      timeFormat={settings?.time_format || 'system'}\n                      cameraViewMode={settings?.camera_view_mode || 'window'}\n                      onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}\n                      checkPrinterFirmware={settings?.check_printer_firmware !== false}\n                      dryingPresets={effectiveDryingPresets}\n                      requirePlateClear={settings?.require_plate_clear === true}\n                      selectionMode={selectionMode}\n                      isSelected={selectedPrinterIds.has(printer.id)}\n                      onToggleSelect={toggleSelect}\n                    />\n                  ))}\n                </div>\n              </Collapsible>\n            );\n          })}\n        </div>\n      ) : (\n        /* Regular grid view */\n        <div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>\n          {sortedPrinters.map((printer) => (\n            <PrinterCard\n              key={printer.id}\n              printer={printer}\n              hideIfDisconnected={hideDisconnected}\n              maintenanceInfo={maintenanceByPrinter[printer.id]}\n              viewMode={viewMode}\n              cardSize={cardSize}\n              spoolmanEnabled={spoolmanEnabled}\n              hasUnlinkedSpools={hasUnlinkedSpools}\n              linkedSpools={linkedSpools}\n              spoolmanUrl={spoolmanStatus?.url}\n              spoolmanSyncMode={spoolmanSyncMode}\n              onGetAssignment={getAssignment}\n              onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}\n              amsThresholds={settings ? {\n                humidityGood: Number(settings.ams_humidity_good) || 40,\n                humidityFair: Number(settings.ams_humidity_fair) || 60,\n                tempGood: Number(settings.ams_temp_good) || 28,\n                tempFair: Number(settings.ams_temp_fair) || 35,\n              } : undefined}\n              timeFormat={settings?.time_format || 'system'}\n              cameraViewMode={settings?.camera_view_mode || 'window'}\n              onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}\n              checkPrinterFirmware={settings?.check_printer_firmware !== false}\n              dryingPresets={effectiveDryingPresets}\n              requirePlateClear={settings?.require_plate_clear === true}\n              selectionMode={selectionMode}\n              isSelected={selectedPrinterIds.has(printer.id)}\n              onToggleSelect={toggleSelect}\n            />\n          ))}\n        </div>\n      )}\n\n      {showAddModal && (\n        <AddPrinterModal\n          onClose={() => setShowAddModal(false)}\n          onAdd={(data) => addMutation.mutate(data)}\n          existingSerials={printers?.map(p => p.serial_number) || []}\n        />\n      )}\n\n      {/* Bulk selection toolbar */}\n      {selectionMode && printers && (\n        <BulkPrinterToolbar\n          selectedIds={selectedPrinterIds}\n          printers={printers}\n          onClose={clearSelection}\n          onSelectAll={selectAll}\n          onSelectByLocation={selectByLocation}\n          onSelectByState={selectByState}\n          onAction={handleBulkAction}\n          actionPending={bulkActionPending}\n        />\n      )}\n\n      {/* Bulk action confirmation modals */}\n      {bulkConfirmAction === 'stop' && (\n        <ConfirmModal\n          title={t('printers.bulk.confirm.stopTitle', { count: selectedPrinterIds.size })}\n          message={t('printers.bulk.confirm.stopMessage', { count: selectedPrinterIds.size })}\n          confirmText={t('printers.bulk.confirm.stopButton')}\n          variant=\"danger\"\n          isLoading={bulkActionPending}\n          onConfirm={() => executeBulkAction('stop')}\n          onCancel={() => setBulkConfirmAction(null)}\n        />\n      )}\n      {bulkConfirmAction === 'pause' && (\n        <ConfirmModal\n          title={t('printers.bulk.confirm.pauseTitle', { count: selectedPrinterIds.size })}\n          message={t('printers.bulk.confirm.pauseMessage', { count: selectedPrinterIds.size })}\n          confirmText={t('printers.bulk.confirm.pauseButton')}\n          isLoading={bulkActionPending}\n          onConfirm={() => executeBulkAction('pause')}\n          onCancel={() => setBulkConfirmAction(null)}\n        />\n      )}\n      {bulkConfirmAction === 'clearPlate' && (\n        <ConfirmModal\n          title={t('printers.bulk.confirm.clearPlateTitle', { count: selectedPrinterIds.size })}\n          message={t('printers.bulk.confirm.clearPlateMessage', { count: selectedPrinterIds.size })}\n          confirmText={t('printers.bulk.confirm.clearPlateButton')}\n          isLoading={bulkActionPending}\n          onConfirm={() => executeBulkAction('clearPlate')}\n          onCancel={() => setBulkConfirmAction(null)}\n        />\n      )}\n\n      {/* Embedded Camera Viewers - multiple viewers can be open simultaneously */}\n      {Array.from(embeddedCameraPrinters.values()).map((camera, index) => (\n        <EmbeddedCameraViewer\n          key={camera.id}\n          printerId={camera.id}\n          printerName={camera.name}\n          viewerIndex={index}\n          onClose={() => setEmbeddedCameraPrinters(prev => {\n            const next = new Map(prev);\n            next.delete(camera.id);\n            return next;\n          })}\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/ProfilesPage.tsx",
    "content": "import { useState, useEffect, useMemo } from 'react';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Cloud,\n  LogIn,\n  LogOut,\n  Loader2,\n  Settings2,\n  Printer as PrinterIcon,\n  Droplet,\n  X,\n  Key,\n  RefreshCw,\n  Gauge,\n  Pencil,\n  Trash2,\n  Save,\n  AlertTriangle,\n  Search,\n  Plus,\n  Copy,\n  Clock,\n  Layers,\n  Filter,\n  ChevronDown,\n  ArrowUp,\n  Upload,\n  Download,\n  Sparkles,\n  Check,\n  AlertCircle,\n  Code,\n  Sliders,\n  List,\n  Eye,\n  EyeOff,\n  GitCompare,\n  ArrowRight,\n  Equal,\n  Minus as MinusIcon,\n  Plus as PlusIcon,\n  HardDrive,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport { formatRelativeTime } from '../utils/date';\nimport type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';\nimport { Card, CardContent } from '../components/Card';\nimport { Button } from '../components/Button';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { KProfilesView } from '../components/KProfilesView';\nimport { LocalProfilesView } from '../components/LocalProfilesView';\n\ntype TFunction = (key: string, options?: Record<string, unknown>) => string;\ntype ProfileTab = 'cloud' | 'local' | 'kprofiles';\ntype LoginStep = 'email' | 'code' | 'token';\ntype PresetType = 'all' | 'filament' | 'printer' | 'process';\n\n// Extract metadata from preset name or inherits field\nfunction extractMetadata(name: string, inherits?: string): {\n  printer: string | null;\n  nozzle: string | null;\n  layerHeight: string | null;\n  filamentType: string | null;\n} {\n  const searchIn = `${name} ${inherits || ''}`;\n\n  // Extract printer (e.g., \"X1C\", \"P1S\", \"A1\", \"H2D\")\n  const printerMatch = searchIn.match(/@?\\s*(?:BBL\\s+)?(?:Bambu\\s+Lab\\s+)?([XPAH][1-9][A-Z]?(?:\\s*(?:Carbon|mini))?|H2D)/i);\n  const printer = printerMatch ? printerMatch[1].trim() : null;\n\n  // Extract nozzle size (e.g., \"0.4 nozzle\", \"0.6mm\")\n  const nozzleMatch = searchIn.match(/(\\d+\\.?\\d*)\\s*(?:mm\\s*)?nozzle|nozzle\\s*(\\d+\\.?\\d*)/i);\n  const nozzle = nozzleMatch ? (nozzleMatch[1] || nozzleMatch[2]) + 'mm' : null;\n\n  // Extract layer height (e.g., \"0.20mm\", \"0.08mm Extra Fine\")\n  const layerMatch = searchIn.match(/(\\d+\\.?\\d*)mm\\s*(?:Standard|Fine|Extra Fine|Draft|Quality)?/i);\n  const layerHeight = layerMatch ? layerMatch[1] + 'mm' : null;\n\n  // Extract filament type (e.g., \"PLA\", \"PETG\", \"ABS\", \"TPU\")\n  const filamentMatch = searchIn.match(/\\b(PLA|PETG|ABS|ASA|TPU|PC|PA|PVA|HIPS|PP|PET(?:-?CF)?|PA(?:-?CF)?|PLA(?:-?CF)?)\\b/i);\n  const filamentType = filamentMatch ? filamentMatch[1].toUpperCase() : null;\n\n  return { printer, nozzle, layerHeight, filamentType };\n}\n\n// Check if preset is user-created (editable)\nfunction isUserPreset(settingId: string): boolean {\n  return /^(P[FPM]US|PF\\d|PP\\d)/.test(settingId);\n}\n\n// ============================================================================\n// LOGIN FORM\n// ============================================================================\n\nfunction LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {\n  const { showToast } = useToast();\n  const [step, setStep] = useState<LoginStep>('email');\n  const [email, setEmail] = useState('');\n  const [password, setPassword] = useState('');\n  const [code, setCode] = useState('');\n  const [token, setToken] = useState('');\n  const [region, setRegion] = useState('global');\n  const [verificationType, setVerificationType] = useState<'email' | 'totp' | null>(null);\n  const [tfaKey, setTfaKey] = useState<string | null>(null);\n\n  const loginMutation = useMutation({\n    mutationFn: () => api.cloudLogin(email, password, region),\n    onSuccess: (result) => {\n      if (result.success) {\n        showToast(t('profiles.login.toast.loggedIn'));\n        onSuccess();\n      } else if (result.needs_verification) {\n        setVerificationType(result.verification_type || 'email');\n        setTfaKey(result.tfa_key || null);\n        if (result.verification_type === 'totp') {\n          showToast(t('profiles.login.toast.enterTotp'));\n        } else {\n          showToast(t('profiles.login.toast.codeSent'));\n        }\n        setStep('code');\n      } else {\n        showToast(result.message, 'error');\n      }\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const verifyMutation = useMutation({\n    mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined, region),\n    onSuccess: (result) => {\n      if (result.success) {\n        showToast(t('profiles.login.toast.loggedIn'));\n        onSuccess();\n      } else {\n        showToast(result.message, 'error');\n      }\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const tokenMutation = useMutation({\n    mutationFn: () => api.cloudSetToken(token, region),\n    onSuccess: () => {\n      showToast(t('profiles.login.toast.tokenSet'));\n      onSuccess();\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    if (step === 'email') loginMutation.mutate();\n    else if (step === 'code') verifyMutation.mutate();\n    else if (step === 'token') tokenMutation.mutate();\n  };\n\n  const isPending = loginMutation.isPending || verifyMutation.isPending || tokenMutation.isPending;\n\n  return (\n    <Card className=\"max-w-md mx-auto\">\n      <CardContent>\n        <div className=\"text-center mb-6\">\n          <div className=\"inline-flex items-center justify-center w-12 h-12 rounded-xl bg-bambu-green/20 mb-3\">\n            <Cloud className=\"w-6 h-6 text-bambu-green\" />\n          </div>\n          <h2 className=\"text-xl font-semibold text-white\">{t('profiles.login.title')}</h2>\n          <p className=\"text-sm text-bambu-gray mt-1\">{t('profiles.login.subtitle')}</p>\n        </div>\n\n        <form onSubmit={handleSubmit} className=\"space-y-4\">\n          {step === 'email' && (\n            <>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('profiles.login.email')}</label>\n                <input\n                  type=\"email\"\n                  value={email}\n                  onChange={(e) => setEmail(e.target.value)}\n                  className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\"\n                  placeholder=\"your@email.com\"\n                  required\n                />\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('profiles.login.password')}</label>\n                <input\n                  type=\"password\"\n                  value={password}\n                  onChange={(e) => setPassword(e.target.value)}\n                  className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\"\n                  placeholder=\"••••••••\"\n                  required\n                />\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('profiles.login.region')}</label>\n                <select\n                  value={region}\n                  onChange={(e) => setRegion(e.target.value)}\n                  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\"\n                >\n                  <option value=\"global\">{t('profiles.login.regionGlobal')}</option>\n                  <option value=\"china\">{t('profiles.login.regionChina')}</option>\n                </select>\n              </div>\n            </>\n          )}\n\n          {step === 'code' && (\n            <div>\n              <label className=\"block text-sm text-bambu-gray mb-1\">\n                {verificationType === 'totp' ? t('profiles.login.totpCode') : t('profiles.login.verificationCode')}\n              </label>\n              <p className=\"text-xs text-bambu-gray mb-2\">\n                {verificationType === 'totp'\n                  ? t('profiles.login.enterTotpHint')\n                  : t('profiles.login.checkEmail', { email })}\n              </p>\n              <input\n                type=\"text\"\n                value={code}\n                onChange={(e) => setCode(e.target.value.replace(/\\D/g, '').slice(0, 6))}\n                className=\"w-full px-3 py-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-center text-2xl tracking-widest font-mono focus:border-bambu-green focus:outline-none\"\n                placeholder=\"000000\"\n                maxLength={6}\n                required\n              />\n            </div>\n          )}\n\n          {step === 'token' && (\n            <>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('profiles.login.accessToken')}</label>\n                <p className=\"text-xs text-bambu-gray mb-2\">{t('profiles.login.accessTokenHint')}</p>\n                <textarea\n                  value={token}\n                  onChange={(e) => setToken(e.target.value)}\n                  className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none resize-none\"\n                  placeholder=\"eyJ...\"\n                  rows={4}\n                  required\n                />\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('profiles.login.region')}</label>\n                <select\n                  value={region}\n                  onChange={(e) => setRegion(e.target.value)}\n                  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\"\n                >\n                  <option value=\"global\">{t('profiles.login.regionGlobal')}</option>\n                  <option value=\"china\">{t('profiles.login.regionChina')}</option>\n                </select>\n              </div>\n            </>\n          )}\n\n          <div className=\"flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center\">\n            {step === 'code' && (\n              <Button type=\"button\" variant=\"secondary\" onClick={() => setStep('email')} className=\"flex-1\">\n                {t('profiles.login.back')}\n              </Button>\n            )}\n            <Button type=\"submit\" disabled={isPending} className=\"flex-1\">\n              {isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <LogIn className=\"w-4 h-4\" />}\n              {step === 'email' ? t('profiles.login.loginButton') : step === 'code' ? t('profiles.login.verifyButton') : t('profiles.login.setTokenButton')}\n            </Button>\n          </div>\n\n          {step === 'email' && (\n            <div className=\"pt-4 border-t border-bambu-dark-tertiary\">\n              <button\n                type=\"button\"\n                onClick={() => setStep('token')}\n                className=\"text-sm text-bambu-gray hover:text-white flex items-center gap-2 transition-colors\"\n              >\n                <Key className=\"w-4 h-4\" />\n                {t('profiles.login.useToken')}\n              </button>\n            </div>\n          )}\n\n          {step === 'token' && (\n            <div className=\"pt-4 border-t border-bambu-dark-tertiary\">\n              <button\n                type=\"button\"\n                onClick={() => setStep('email')}\n                className=\"text-sm text-bambu-gray hover:text-white flex items-center gap-2 transition-colors\"\n              >\n                <LogIn className=\"w-4 h-4\" />\n                {t('profiles.login.useEmail')}\n              </button>\n            </div>\n          )}\n        </form>\n      </CardContent>\n    </Card>\n  );\n}\n\n// ============================================================================\n// FILTER DROPDOWN\n// ============================================================================\n\nfunction FilterDropdown({\n  label,\n  value,\n  options,\n  onChange,\n}: {\n  label: string;\n  value: string;\n  options: { value: string; label: string; count?: number }[];\n  onChange: (value: string) => void;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const selectedOption = options.find(o => o.value === value);\n\n  return (\n    <div className=\"relative\">\n      <button\n        onClick={() => setIsOpen(!isOpen)}\n        className=\"flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white hover:border-bambu-gray-dark transition-colors\"\n      >\n        <span className=\"text-bambu-gray\">{label}:</span>\n        <span>{selectedOption?.label || 'All'}</span>\n        <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isOpen ? 'rotate-180' : ''}`} />\n      </button>\n\n      {isOpen && (\n        <>\n          <div className=\"fixed inset-0 z-10\" onClick={() => setIsOpen(false)} />\n          <div className=\"absolute top-full left-0 mt-1 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 py-1 max-h-60 overflow-y-auto\">\n            {options.map((option) => (\n              <button\n                key={option.value}\n                onClick={() => { onChange(option.value); setIsOpen(false); }}\n                className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-bambu-dark-tertiary transition-colors ${\n                  value === option.value ? 'text-bambu-green' : 'text-white'\n                }`}\n              >\n                <span>{option.label}</span>\n                {option.count !== undefined && (\n                  <span className=\"text-bambu-gray text-xs\">{option.count}</span>\n                )}\n              </button>\n            ))}\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\n// ============================================================================\n// SCROLL TO TOP BUTTON\n// ============================================================================\n\nfunction ScrollToTop() {\n  const [isVisible, setIsVisible] = useState(false);\n\n  useEffect(() => {\n    const toggleVisibility = () => {\n      setIsVisible(window.scrollY > 300);\n    };\n\n    window.addEventListener('scroll', toggleVisibility);\n    return () => window.removeEventListener('scroll', toggleVisibility);\n  }, []);\n\n  const scrollToTop = () => {\n    window.scrollTo({ top: 0, behavior: 'smooth' });\n  };\n\n  if (!isVisible) return null;\n\n  return (\n    <button\n      onClick={scrollToTop}\n      className=\"fixed bottom-6 right-6 p-3 bg-bambu-green hover:bg-bambu-green-light text-white rounded-full shadow-lg shadow-bambu-green/25 transition-all z-40\"\n      aria-label=\"Scroll to top\"\n    >\n      <ArrowUp className=\"w-5 h-5\" />\n    </button>\n  );\n}\n\n// ============================================================================\n// PRESET LIST ITEM (compact row style like K-Profiles)\n// ============================================================================\n\nfunction PresetListItem({\n  setting,\n  onClick,\n  onDuplicate,\n  compareMode,\n  isCompareSelected,\n  compareIndex,\n  compareDisabled,\n  t,\n}: {\n  setting: SlicerSetting;\n  onClick: () => void;\n  onDuplicate: () => void;\n  compareMode?: boolean;\n  isCompareSelected?: boolean;\n  compareIndex?: number;\n  compareDisabled?: boolean;\n  t: TFunction;\n}) {\n  const metadata = extractMetadata(setting.name);\n  const isEditable = isUserPreset(setting.setting_id);\n\n  return (\n    <div className=\"flex items-center gap-2 group\">\n      <button\n        onClick={onClick}\n        disabled={compareDisabled}\n        className={`flex-1 text-left px-3 py-2 rounded transition-colors ${\n          isCompareSelected\n            ? 'bg-blue-500/20 border border-blue-500/50'\n            : compareDisabled\n              ? 'bg-bambu-dark/50 opacity-40 cursor-not-allowed'\n              : 'bg-bambu-dark hover:bg-bambu-dark-tertiary'\n        } ${compareMode && !compareDisabled ? 'cursor-pointer' : ''}`}\n      >\n        <div className=\"flex items-center gap-2\">\n          {isCompareSelected && compareIndex !== undefined && (\n            <span className=\"flex-shrink-0 w-5 h-5 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium\">\n              {compareIndex + 1}\n            </span>\n          )}\n          {!isCompareSelected && isEditable && (\n            <span className=\"flex-shrink-0 w-1.5 h-1.5 rounded-full bg-bambu-green\" title={t('profiles.presets.myPreset')} />\n          )}\n          <span className=\"text-white text-sm truncate flex-1\" title={setting.name}>\n            {setting.name}\n          </span>\n          {/* Show relevant metadata tag */}\n          {metadata.filamentType && setting.type === 'filament' && (\n            <span className=\"text-xs text-bambu-gray whitespace-nowrap\">\n              {metadata.filamentType}\n            </span>\n          )}\n          {metadata.layerHeight && setting.type === 'process' && (\n            <span className=\"text-xs text-bambu-gray whitespace-nowrap\">\n              {metadata.layerHeight}\n            </span>\n          )}\n          {metadata.printer && (\n            <span className=\"text-xs text-bambu-gray whitespace-nowrap\">\n              {metadata.printer}\n            </span>\n          )}\n        </div>\n      </button>\n      <button\n        onClick={(e) => { e.stopPropagation(); onDuplicate(); }}\n        className=\"opacity-0 group-hover:opacity-100 text-bambu-gray hover:text-white transition-all p-1\"\n        title={t('profiles.presets.duplicate')}\n      >\n        <Copy className=\"w-4 h-4\" />\n      </button>\n    </div>\n  );\n}\n\n// ============================================================================\n// PRESET DETAIL MODAL\n// ============================================================================\n\n// Format JSON for display, converting escaped newlines to real newlines in string values\nfunction formatJsonForDisplay(obj: unknown, indent = 0): string {\n  const spaces = '  '.repeat(indent);\n\n  if (obj === null) return 'null';\n  if (obj === undefined) return 'undefined';\n\n  if (typeof obj === 'string') {\n    // Convert escaped newlines to actual newlines for readability\n    if (obj.includes('\\\\n') || obj.includes('\\n')) {\n      const formatted = obj\n        .replace(/\\\\n/g, '\\n')\n        .replace(/\\\\\"/g, '\"')\n        .replace(/\\\\t/g, '\\t');\n      // For multi-line strings, show them nicely indented\n      const lines = formatted.split('\\n');\n      if (lines.length > 1) {\n        return '\"\"\"\\n' + lines.map(l => spaces + '  ' + l).join('\\n') + '\\n' + spaces + '\"\"\"';\n      }\n    }\n    return JSON.stringify(obj);\n  }\n\n  if (typeof obj === 'number' || typeof obj === 'boolean') {\n    return String(obj);\n  }\n\n  if (Array.isArray(obj)) {\n    if (obj.length === 0) return '[]';\n    const items = obj.map(item => spaces + '  ' + formatJsonForDisplay(item, indent + 1));\n    return '[\\n' + items.join(',\\n') + '\\n' + spaces + ']';\n  }\n\n  if (typeof obj === 'object') {\n    const entries = Object.entries(obj);\n    if (entries.length === 0) return '{}';\n    const items = entries.map(([key, val]) =>\n      spaces + '  ' + JSON.stringify(key) + ': ' + formatJsonForDisplay(val, indent + 1)\n    );\n    return '{\\n' + items.join(',\\n') + '\\n' + spaces + '}';\n  }\n\n  return String(obj);\n}\n\nfunction PresetDetailModal({\n  setting,\n  onClose,\n  onDeleted,\n  onDuplicate,\n  onEdit,\n  hasPermission,\n  t,\n}: {\n  setting: SlicerSetting;\n  onClose: () => void;\n  onDeleted: () => void;\n  onDuplicate: () => void;\n  onEdit: () => void;\n  hasPermission: (permission: Permission) => boolean;\n  t: TFunction;\n}) {\n  const { showToast } = useToast();\n  const queryClient = useQueryClient();\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n\n  const { data: detail, isLoading } = useQuery<SlicerSettingDetail>({\n    queryKey: ['cloudSettingDetail', setting.setting_id],\n    queryFn: () => api.getCloudSettingDetail(setting.setting_id),\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: () => api.deleteCloudSetting(setting.setting_id),\n    onSuccess: () => {\n      showToast(t('profiles.presets.toast.deleted'));\n      queryClient.invalidateQueries({ queryKey: ['cloudSettings'] });\n      onDeleted();\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const isEditable = isUserPreset(setting.setting_id);\n  const metadata = extractMetadata(setting.name, detail?.setting?.inherits as string);\n\n  return (\n    <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\">\n      <Card className=\"w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden\">\n        <CardContent className=\"p-0 flex flex-col min-h-0 flex-1\">\n          {/* Header */}\n          <div className=\"flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <div className=\"flex-1 min-w-0\">\n              <div className=\"flex items-center gap-2\">\n                <h2 className=\"text-xl font-semibold text-white truncate\">{setting.name}</h2>\n                {isEditable && (\n                  <span className=\"px-2 py-0.5 text-xs font-medium bg-bambu-green/20 text-bambu-green rounded-full\">\n                    {t('profiles.presets.editable')}\n                  </span>\n                )}\n              </div>\n              <div className=\"flex items-center gap-2 mt-1 text-sm text-bambu-gray\">\n                <span className=\"capitalize\">{t(`profiles.presets.types.${setting.type}`)}</span>\n                {metadata.printer && <><span>•</span><span>{metadata.printer}</span></>}\n              </div>\n            </div>\n            <button onClick={onClose} className=\"p-2 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors\">\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Content */}\n          <div className=\"flex-1 min-h-0 overflow-y-auto p-4\">\n            {isLoading ? (\n              <div className=\"flex items-center justify-center py-16\">\n                <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n              </div>\n            ) : detail ? (\n              <pre className=\"text-xs text-bambu-gray font-mono whitespace-pre-wrap break-all bg-bambu-dark p-4 rounded-lg border border-bambu-dark-tertiary overflow-x-auto max-w-full\">\n                {formatJsonForDisplay(detail)}\n              </pre>\n            ) : (\n              <div className=\"text-center py-16 text-bambu-gray\">{t('profiles.presets.failedToLoadDetails')}</div>\n            )}\n          </div>\n\n          {/* Footer */}\n          {showDeleteConfirm ? (\n            <div className=\"flex-shrink-0 p-4 border-t border-bambu-dark-tertiary bg-red-500/5\">\n              <div className=\"flex items-center gap-2 mb-3 text-red-400\">\n                <AlertTriangle className=\"w-5 h-5\" />\n                <span className=\"font-medium\">{t('profiles.presets.deleteConfirm')}</span>\n              </div>\n              <p className=\"text-sm text-bambu-gray mb-4\">\n                {t('profiles.presets.deleteWarning', { name: setting.name })}\n              </p>\n              <div className=\"flex gap-2\">\n                <Button variant=\"secondary\" onClick={() => setShowDeleteConfirm(false)} disabled={deleteMutation.isPending} className=\"flex-1\">\n                  {t('common.cancel')}\n                </Button>\n                <Button variant=\"danger\" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} className=\"flex-1\">\n                  {deleteMutation.isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Trash2 className=\"w-4 h-4\" />}\n                  {t('common.delete')}\n                </Button>\n              </div>\n            </div>\n          ) : (\n            <div className=\"flex-shrink-0 p-4 border-t border-bambu-dark-tertiary\">\n              <div className=\"flex gap-2\">\n                <Button variant=\"secondary\" onClick={onClose} className=\"flex-1\">{t('common.close')}</Button>\n                <Button\n                  variant=\"secondary\"\n                  onClick={onDuplicate}\n                  disabled={!hasPermission('cloud:auth')}\n                  title={!hasPermission('cloud:auth') ? t('profiles.presets.noDuplicatePermission') : undefined}\n                >\n                  <Copy className=\"w-4 h-4\" />\n                  {t('profiles.presets.duplicate')}\n                </Button>\n                {isEditable && (\n                  <>\n                    <Button\n                      variant=\"secondary\"\n                      onClick={onEdit}\n                      disabled={isLoading || !detail || !hasPermission('cloud:auth')}\n                      title={!hasPermission('cloud:auth') ? t('profiles.presets.noEditPermission') : undefined}\n                    >\n                      <Pencil className=\"w-4 h-4\" />\n                      {t('common.edit')}\n                    </Button>\n                    <Button\n                      variant=\"danger\"\n                      onClick={() => setShowDeleteConfirm(true)}\n                      disabled={!hasPermission('cloud:auth')}\n                      title={!hasPermission('cloud:auth') ? t('profiles.presets.noDeletePermission') : undefined}\n                    >\n                      <Trash2 className=\"w-4 h-4\" />\n                    </Button>\n                  </>\n                )}\n              </div>\n            </div>\n          )}\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\n// ============================================================================\n// TEMPLATES\n// ============================================================================\n\ntype EditorTab = 'common' | 'fields' | 'json';\n\ninterface CustomTemplate {\n  id: string;\n  name: string;\n  description: string;\n  type: 'filament' | 'print' | 'printer';\n  settings: Record<string, unknown>;\n  showInModal?: boolean; // If true, show in add/edit preset modals\n}\n\n// Load custom templates from localStorage\nfunction loadCustomTemplates(): CustomTemplate[] {\n  try {\n    const stored = localStorage.getItem('bambusy_preset_templates');\n    return stored ? JSON.parse(stored) : [];\n  } catch {\n    return [];\n  }\n}\n\n// Save custom templates to localStorage\nfunction saveCustomTemplates(templates: CustomTemplate[]) {\n  localStorage.setItem('bambusy_preset_templates', JSON.stringify(templates));\n}\n\n// ============================================================================\n// TEMPLATES MODAL (manage templates from main page)\n// ============================================================================\n\nfunction TemplatesModal({\n  onClose,\n  onApply,\n  t,\n}: {\n  onClose: () => void;\n  onApply: (template: CustomTemplate) => void;\n  t: TFunction;\n}) {\n  const { showToast } = useToast();\n  const [templates, setTemplates] = useState<CustomTemplate[]>(loadCustomTemplates);\n  const [filterType, setFilterType] = useState<'all' | 'filament' | 'print' | 'printer'>('all');\n  const [editingId, setEditingId] = useState<string | null>(null);\n  const [editName, setEditName] = useState('');\n  const [editDesc, setEditDesc] = useState('');\n  const [editSettings, setEditSettings] = useState('{}');\n  const [editSettingsError, setEditSettingsError] = useState<string | null>(null);\n  const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);\n\n  const filteredTemplates = filterType === 'all'\n    ? templates\n    : templates.filter(tpl => tpl.type === filterType);\n\n  const saveTemplates = (updated: CustomTemplate[]) => {\n    setTemplates(updated);\n    saveCustomTemplates(updated);\n  };\n\n  const handleDelete = (id: string) => {\n    const updated = templates.filter(tpl => tpl.id !== id);\n    saveTemplates(updated);\n    setDeleteConfirmId(null);\n    showToast(t('profiles.templates.toast.deleted'));\n  };\n\n  const handleEdit = (template: CustomTemplate) => {\n    setEditingId(template.id);\n    setEditName(template.name);\n    setEditDesc(template.description);\n    setEditSettings(JSON.stringify(template.settings, null, 2));\n    setEditSettingsError(null);\n  };\n\n  const handleSaveEdit = () => {\n    if (!editingId || !editName.trim()) return;\n    try {\n      const settings = JSON.parse(editSettings);\n      const updated = templates.map(tpl =>\n        tpl.id === editingId\n          ? { ...tpl, name: editName.trim(), description: editDesc.trim(), settings }\n          : tpl\n      );\n      saveTemplates(updated);\n      setEditingId(null);\n      showToast(t('profiles.templates.toast.updated'));\n    } catch (e) {\n      setEditSettingsError((e as Error).message);\n    }\n  };\n\n  const handleCancelEdit = () => {\n    setEditingId(null);\n    setEditName('');\n    setEditDesc('');\n    setEditSettings('{}');\n    setEditSettingsError(null);\n  };\n\n  const toggleShowInModal = (id: string) => {\n    const updated = templates.map(tpl =>\n      tpl.id === id ? { ...tpl, showInModal: !tpl.showInModal } : tpl\n    );\n    saveTemplates(updated);\n  };\n\n  const typeLabels = {\n    filament: { label: t('profiles.presets.types.filament'), icon: Droplet, color: 'text-amber-400' },\n    print: { label: t('profiles.presets.types.process'), icon: Settings2, color: 'text-blue-400' },\n    printer: { label: t('profiles.presets.types.printer'), icon: PrinterIcon, color: 'text-purple-400' },\n  };\n\n  const templateToDelete = deleteConfirmId ? templates.find(tpl => tpl.id === deleteConfirmId) : null;\n\n  // Handle Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        if (deleteConfirmId) {\n          setDeleteConfirmId(null);\n        } else if (editingId) {\n          handleCancelEdit();\n        } else {\n          onClose();\n        }\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [deleteConfirmId, editingId, onClose]);\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      {/* Delete confirmation modal */}\n      {templateToDelete && (\n        <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-60\">\n          <Card className=\"w-full max-w-md\">\n            <CardContent className=\"p-6\">\n              <div className=\"flex items-center gap-3 mb-4\">\n                <div className=\"p-2 bg-red-500/20 rounded-lg\">\n                  <AlertTriangle className=\"w-6 h-6 text-red-400\" />\n                </div>\n                <div>\n                  <h3 className=\"text-lg font-semibold text-white\">{t('profiles.templates.deleteTitle')}</h3>\n                  <p className=\"text-sm text-bambu-gray\">{t('profiles.templates.deleteWarning')}</p>\n                </div>\n              </div>\n              <p className=\"text-white mb-6\">\n                {t('profiles.templates.deleteConfirm', { name: templateToDelete.name })}\n              </p>\n              <div className=\"flex gap-2\">\n                <Button variant=\"secondary\" onClick={() => setDeleteConfirmId(null)} className=\"flex-1\">\n                  {t('common.cancel')}\n                </Button>\n                <Button onClick={() => handleDelete(deleteConfirmId!)} className=\"flex-1 bg-red-500 hover:bg-red-600\">\n                  <Trash2 className=\"w-4 h-4\" />\n                  {t('common.delete')}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n\n      <Card className=\"w-full max-w-2xl max-h-[80vh] flex flex-col\">\n        <CardContent className=\"p-0 flex flex-col h-full\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n              <Sparkles className=\"w-5 h-5 text-amber-400\" />\n              {t('profiles.templates.title')}\n            </h2>\n            <button onClick={onClose} className=\"text-bambu-gray hover:text-white\">\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Filter row */}\n          <div className=\"flex items-center gap-2 p-4 border-b border-bambu-dark-tertiary\">\n            <span className=\"text-sm text-bambu-gray\">{t('profiles.templates.typeFilter')}</span>\n            {(['all', 'filament', 'print', 'printer'] as const).map((type) => (\n              <button\n                key={type}\n                onClick={() => setFilterType(type)}\n                className={`px-3 py-1 text-sm rounded-lg transition-colors ${\n                  filterType === type\n                    ? 'bg-bambu-green text-white'\n                    : 'bg-bambu-dark text-bambu-gray hover:text-white'\n                }`}\n              >\n                {type === 'all' ? t('common.all') : typeLabels[type].label}\n              </button>\n            ))}\n          </div>\n\n          {/* Templates list */}\n          <div className=\"flex-1 overflow-y-auto p-4\">\n            {filteredTemplates.length === 0 ? (\n              <div className=\"text-center py-12 text-bambu-gray\">\n                <Sparkles className=\"w-12 h-12 mx-auto mb-4 opacity-30\" />\n                <p>{t('profiles.templates.noTemplates')}</p>\n                <p className=\"text-sm mt-1\">{t('profiles.templates.createFirst')}</p>\n              </div>\n            ) : (\n              <div className=\"space-y-2\">\n                {filteredTemplates.map((template) => {\n                  const typeInfo = typeLabels[template.type];\n                  const TypeIcon = typeInfo.icon;\n\n                  if (editingId === template.id) {\n                    return (\n                      <div\n                        key={template.id}\n                        className=\"p-4 bg-bambu-dark rounded-lg border border-bambu-green\"\n                      >\n                        <div className=\"grid grid-cols-2 gap-3 mb-3\">\n                          <input\n                            type=\"text\"\n                            value={editName}\n                            onChange={(e) => setEditName(e.target.value)}\n                            placeholder={t('profiles.templates.namePlaceholder')}\n                            className=\"px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                            autoFocus\n                          />\n                          <input\n                            type=\"text\"\n                            value={editDesc}\n                            onChange={(e) => setEditDesc(e.target.value)}\n                            placeholder={t('profiles.templates.descriptionPlaceholder')}\n                            className=\"px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                          />\n                        </div>\n                        <div className=\"mb-3\">\n                          <label className=\"text-xs text-bambu-gray mb-1 block\">{t('profiles.templates.settingsJson')}</label>\n                          <textarea\n                            value={editSettings}\n                            onChange={(e) => {\n                              setEditSettings(e.target.value);\n                              setEditSettingsError(null);\n                            }}\n                            rows={6}\n                            className={`w-full px-3 py-2 bg-bambu-dark-secondary border rounded text-white text-sm font-mono focus:outline-none ${\n                              editSettingsError ? 'border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'\n                            }`}\n                          />\n                          {editSettingsError && (\n                            <p className=\"text-xs text-red-400 mt-1\">{editSettingsError}</p>\n                          )}\n                        </div>\n                        <div className=\"flex gap-2\">\n                          <Button size=\"sm\" onClick={handleSaveEdit} disabled={!editName.trim()}>\n                            <Save className=\"w-4 h-4\" />\n                            Save\n                          </Button>\n                          <Button size=\"sm\" variant=\"secondary\" onClick={handleCancelEdit}>\n                            Cancel\n                          </Button>\n                        </div>\n                      </div>\n                    );\n                  }\n\n                  return (\n                    <div\n                      key={template.id}\n                      className=\"flex items-center gap-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary hover:border-bambu-gray-dark transition-colors\"\n                    >\n                      <TypeIcon className={`w-5 h-5 ${typeInfo.color} flex-shrink-0`} />\n                      <div className=\"flex-1 min-w-0\">\n                        <p className=\"text-sm font-medium text-white\">{template.name}</p>\n                        <p className=\"text-xs text-bambu-gray truncate\">{template.description}</p>\n                      </div>\n                      <span className=\"text-xs text-bambu-gray-dark px-2 py-1 bg-bambu-dark-secondary rounded\">\n                        {t('profiles.templates.fieldsCount', { count: Object.keys(template.settings).length })}\n                      </span>\n                      <button\n                        onClick={() => toggleShowInModal(template.id)}\n                        className={`p-1 transition-colors ${\n                          template.showInModal\n                            ? 'text-bambu-green hover:text-bambu-green/70'\n                            : 'text-bambu-gray hover:text-white'\n                        }`}\n                        title={template.showInModal ? t('profiles.templates.shownInModals') : t('profiles.templates.hiddenInModals')}\n                      >\n                        {template.showInModal ? <Eye className=\"w-4 h-4\" /> : <EyeOff className=\"w-4 h-4\" />}\n                      </button>\n                      <button\n                        onClick={() => onApply(template)}\n                        className=\"px-3 py-1 text-xs bg-bambu-green/20 text-bambu-green rounded hover:bg-bambu-green/30 transition-colors\"\n                      >\n                        {t('profiles.templates.apply')}\n                      </button>\n                      <button\n                        onClick={() => handleEdit(template)}\n                        className=\"p-1 text-bambu-gray hover:text-white\"\n                        title={t('common.edit')}\n                      >\n                        <Pencil className=\"w-4 h-4\" />\n                      </button>\n                      <button\n                        onClick={() => setDeleteConfirmId(template.id)}\n                        className=\"p-1 text-bambu-gray hover:text-red-400\"\n                        title={t('common.delete')}\n                      >\n                        <Trash2 className=\"w-4 h-4\" />\n                      </button>\n                    </div>\n                  );\n                })}\n              </div>\n            )}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\n// ============================================================================\n// DIFF MODAL - Compare two presets or preset vs base\n// ============================================================================\n\ntype DiffEntry = {\n  key: string;\n  left: unknown;\n  right: unknown;\n  status: 'added' | 'removed' | 'changed' | 'same';\n};\n\nfunction DiffModal({\n  onClose,\n  leftPreset,\n  rightPreset,\n  leftLabel,\n  rightLabel,\n  t,\n}: {\n  onClose: () => void;\n  leftPreset: Record<string, unknown>;\n  rightPreset: Record<string, unknown>;\n  leftLabel: string;\n  rightLabel: string;\n  t: TFunction;\n}) {\n  const [filterMode, setFilterMode] = useState<'changes' | 'all'>('changes');\n  const [searchQuery, setSearchQuery] = useState('');\n\n  // Calculate diff\n  const diffEntries = useMemo(() => {\n    const allKeys = new Set([...Object.keys(leftPreset), ...Object.keys(rightPreset)]);\n    const entries: DiffEntry[] = [];\n\n    for (const key of allKeys) {\n      // Skip internal fields\n      if (key === 'inherits' || key === 'version') continue;\n\n      const leftVal = leftPreset[key];\n      const rightVal = rightPreset[key];\n      const leftExists = key in leftPreset;\n      const rightExists = key in rightPreset;\n\n      let status: DiffEntry['status'];\n      if (!leftExists && rightExists) {\n        status = 'added';\n      } else if (leftExists && !rightExists) {\n        status = 'removed';\n      } else if (JSON.stringify(leftVal) !== JSON.stringify(rightVal)) {\n        status = 'changed';\n      } else {\n        status = 'same';\n      }\n\n      entries.push({ key, left: leftVal, right: rightVal, status });\n    }\n\n    return entries.sort((a, b) => {\n      // Sort by status (changed first, then added, removed, same)\n      const statusOrder = { changed: 0, added: 1, removed: 2, same: 3 };\n      if (statusOrder[a.status] !== statusOrder[b.status]) {\n        return statusOrder[a.status] - statusOrder[b.status];\n      }\n      return a.key.localeCompare(b.key);\n    });\n  }, [leftPreset, rightPreset]);\n\n  // Filter entries\n  const filteredEntries = useMemo(() => {\n    let entries = [...diffEntries];\n    if (filterMode === 'changes') {\n      entries = entries.filter(e => e.status !== 'same');\n    }\n    if (searchQuery) {\n      const q = searchQuery.toLowerCase();\n      entries = entries.filter(e =>\n        e.key.toLowerCase().includes(q) ||\n        String(e.left).toLowerCase().includes(q) ||\n        String(e.right).toLowerCase().includes(q)\n      );\n    }\n    return entries;\n  }, [diffEntries, filterMode, searchQuery]);\n\n  // Stats\n  const stats = useMemo(() => {\n    return {\n      added: diffEntries.filter(e => e.status === 'added').length,\n      removed: diffEntries.filter(e => e.status === 'removed').length,\n      changed: diffEntries.filter(e => e.status === 'changed').length,\n      same: diffEntries.filter(e => e.status === 'same').length,\n    };\n  }, [diffEntries]);\n\n  const formatValue = (val: unknown): string => {\n    if (val === undefined) return '—';\n    if (val === null) return 'null';\n    if (Array.isArray(val)) {\n      // Show arrays more cleanly\n      if (val.length === 0) return '[]';\n      if (val.length === 1) return String(val[0]);\n      return val.join(', ');\n    }\n    if (typeof val === 'object') return JSON.stringify(val);\n    // Handle strings - truncate long ones and clean up escaped chars\n    const str = String(val);\n    // Check if it looks like G-code or multi-line content\n    if (str.includes('\\\\n') || str.length > 100) {\n      // Count lines and show summary\n      const lines = str.split('\\\\n').length;\n      if (lines > 1) {\n        return `[${lines} lines of G-code/script]`;\n      }\n      if (str.length > 100) {\n        return str.substring(0, 100) + '…';\n      }\n    }\n    return str;\n  };\n\n  // Handle Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') onClose();\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [onClose]);\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <Card className=\"w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden\">\n        <CardContent className=\"p-0 flex flex-col min-h-0 flex-1\">\n          {/* Header */}\n          <div className=\"flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n              <GitCompare className=\"w-5 h-5 text-blue-400\" />\n              {t('profiles.diff.title')}\n            </h2>\n            <button onClick={onClose} className=\"text-bambu-gray hover:text-white\">\n              <X className=\"w-5 h-5\" />\n            </button>\n          </div>\n\n          {/* Preset labels */}\n          <div className=\"flex-shrink-0 grid grid-cols-2 gap-4 p-4 border-b border-bambu-dark-tertiary bg-bambu-dark\">\n            <div className=\"text-center\">\n              <span className=\"text-sm text-bambu-gray\">{t('profiles.diff.left')}</span>\n              <p className=\"text-white font-medium truncate\">{leftLabel}</p>\n            </div>\n            <div className=\"text-center\">\n              <span className=\"text-sm text-bambu-gray\">{t('profiles.diff.right')}</span>\n              <p className=\"text-white font-medium truncate\">{rightLabel}</p>\n            </div>\n          </div>\n\n          {/* Stats and filters */}\n          <div className=\"flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <div className=\"flex items-center gap-4 text-sm\">\n              <span className=\"flex items-center gap-1 text-green-400\">\n                <PlusIcon className=\"w-3.5 h-3.5\" />\n                {stats.added} {t('profiles.diff.added')}\n              </span>\n              <span className=\"flex items-center gap-1 text-red-400\">\n                <MinusIcon className=\"w-3.5 h-3.5\" />\n                {stats.removed} {t('profiles.diff.removed')}\n              </span>\n              <span className=\"flex items-center gap-1 text-amber-400\">\n                <ArrowRight className=\"w-3.5 h-3.5\" />\n                {stats.changed} {t('profiles.diff.changed')}\n              </span>\n              <span className=\"flex items-center gap-1 text-bambu-gray\">\n                <Equal className=\"w-3.5 h-3.5\" />\n                {stats.same} {t('profiles.diff.same')}\n              </span>\n            </div>\n            <div className=\"flex items-center gap-3\">\n              <div className=\"relative\">\n                <Search className=\"absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n                <input\n                  type=\"text\"\n                  value={searchQuery}\n                  onChange={(e) => setSearchQuery(e.target.value)}\n                  placeholder={t('profiles.diff.searchFields')}\n                  className=\"pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none w-48\"\n                />\n              </div>\n              {stats.same > 0 && (\n                <div className=\"flex rounded overflow-hidden border border-bambu-dark-tertiary\">\n                  <button\n                    onClick={() => setFilterMode('changes')}\n                    className={`px-3 py-1.5 text-sm transition-colors ${\n                      filterMode === 'changes'\n                        ? 'bg-bambu-green text-white'\n                        : 'bg-bambu-dark text-bambu-gray hover:text-white'\n                    }`}\n                  >\n                    {t('profiles.diff.changes')}\n                  </button>\n                  <button\n                    onClick={() => setFilterMode('all')}\n                    className={`px-3 py-1.5 text-sm transition-colors ${\n                      filterMode === 'all'\n                        ? 'bg-bambu-green text-white'\n                        : 'bg-bambu-dark text-bambu-gray hover:text-white'\n                    }`}\n                  >\n                    {t('common.all')}\n                  </button>\n                </div>\n              )}\n            </div>\n          </div>\n\n          {/* Diff table */}\n          <div className=\"flex-1 min-h-0 overflow-y-auto\">\n            {filteredEntries.length === 0 ? (\n              <div className=\"text-center py-12 text-bambu-gray\">\n                <Equal className=\"w-12 h-12 mx-auto mb-4 opacity-30\" />\n                <p>{filterMode === 'changes' ? t('profiles.diff.noDifferences') : t('profiles.diff.noFieldsMatch')}</p>\n              </div>\n            ) : (\n              <table className=\"w-full\">\n                <thead className=\"sticky top-0 bg-bambu-dark-secondary\">\n                  <tr className=\"text-sm text-bambu-gray border-b border-bambu-dark-tertiary\">\n                    <th className=\"text-left p-3 w-1/3\">{t('profiles.diff.field')}</th>\n                    <th className=\"text-left p-3 w-1/3\">{leftLabel}</th>\n                    <th className=\"text-left p-3 w-1/3\">{rightLabel}</th>\n                  </tr>\n                </thead>\n                <tbody>\n                  {filteredEntries.map((entry) => {\n                    const bgClass = {\n                      added: 'bg-green-500/10',\n                      removed: 'bg-red-500/10',\n                      changed: 'bg-amber-500/10',\n                      same: '',\n                    }[entry.status];\n\n                    const statusIcon = {\n                      added: <PlusIcon className=\"w-3.5 h-3.5 text-green-400\" />,\n                      removed: <MinusIcon className=\"w-3.5 h-3.5 text-red-400\" />,\n                      changed: <ArrowRight className=\"w-3.5 h-3.5 text-amber-400\" />,\n                      same: <Equal className=\"w-3.5 h-3.5 text-bambu-gray-dark\" />,\n                    }[entry.status];\n\n                    return (\n                      <tr key={entry.key} className={`border-b border-bambu-dark-tertiary ${bgClass}`}>\n                        <td className=\"p-3\">\n                          <div className=\"flex items-center gap-2\">\n                            {statusIcon}\n                            <span className=\"text-sm text-white font-mono\">{entry.key}</span>\n                          </div>\n                        </td>\n                        <td className=\"p-3\">\n                          <span className={`text-sm font-mono break-all ${\n                            entry.status === 'removed' ? 'text-red-300' :\n                            entry.status === 'changed' ? 'text-white' : 'text-bambu-gray'\n                          }`}>\n                            {formatValue(entry.left)}\n                          </span>\n                        </td>\n                        <td className=\"p-3\">\n                          <span className={`text-sm font-mono break-all ${\n                            entry.status === 'added' ? 'text-green-300' :\n                            entry.status === 'changed' ? 'text-white' : 'text-bambu-gray'\n                          }`}>\n                            {formatValue(entry.right)}\n                          </span>\n                        </td>\n                      </tr>\n                    );\n                  })}\n                </tbody>\n              </table>\n            )}\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\n// ============================================================================\n// CREATE PRESET MODAL\n// ============================================================================\n\nfunction CreatePresetModal({\n  onClose,\n  initialData,\n  allPresets,\n  t,\n}: {\n  onClose: () => void;\n  initialData?: { type: string; name: string; base_id: string; setting: Record<string, unknown>; setting_id?: string };\n  allPresets: SlicerSettingsResponse;\n  t: TFunction;\n}) {\n  const { showToast } = useToast();\n  const queryClient = useQueryClient();\n\n  // Editing mode if initialData has setting_id\n  const isEditMode = !!initialData?.setting_id;\n\n  const [activeTab, setActiveTab] = useState<EditorTab>('common');\n  const [presetType, setPresetType] = useState<'filament' | 'print' | 'printer'>(\n    (initialData?.type as 'filament' | 'print' | 'printer') || 'filament'\n  );\n  const [name, setName] = useState(\n    initialData?.name\n      ? (isEditMode ? initialData.name : `${initialData.name} (Copy)`)\n      : ''\n  );\n  const [baseId, setBaseId] = useState(initialData?.base_id || '');\n  const [baseName, setBaseName] = useState('');\n  const [settingsObj, setSettingsObj] = useState<Record<string, unknown>>(\n    initialData?.setting || { inherits: '' }\n  );\n  const [jsonText, setJsonText] = useState(JSON.stringify(initialData?.setting || { inherits: '' }, null, 2));\n  const [jsonError, setJsonError] = useState<string | null>(null);\n  const [fieldSearch, setFieldSearch] = useState('');\n  const [isDragging, setIsDragging] = useState(false);\n  const [customFieldKey, setCustomFieldKey] = useState('');\n  const [showCustomFieldInput, setShowCustomFieldInput] = useState(false);\n  const [customTemplates, setCustomTemplates] = useState<CustomTemplate[]>(loadCustomTemplates);\n  const [showSaveTemplate, setShowSaveTemplate] = useState(false);\n  const [newTemplateName, setNewTemplateName] = useState('');\n  const [newTemplateDesc, setNewTemplateDesc] = useState('');\n  const [newTemplateShowInModal, setNewTemplateShowInModal] = useState(true);\n  const [appliedTemplateName, setAppliedTemplateName] = useState<string | null>(null);\n  const [showDiffModal, setShowDiffModal] = useState(false);\n\n  // Fetch ALL preset details for the current type to discover all available fields\n  const presetsOfType = useMemo(() => {\n    const typeMap: Record<string, SlicerSetting[]> = {\n      filament: allPresets.filament,\n      print: allPresets.process,\n      printer: allPresets.printer,\n    };\n    return typeMap[presetType] || [];\n  }, [allPresets, presetType]);\n\n  // Only fetch details for USER presets (not Bambu's built-in ones which return 500)\n  const userPresetsOfType = useMemo(() => {\n    return presetsOfType.filter(p => isUserPreset(p.setting_id));\n  }, [presetsOfType]);\n\n  // Fetch field definitions from API (cached, only loaded once per type)\n  const { data: fieldDefinitions } = useQuery({\n    queryKey: ['cloudFields', presetType],\n    queryFn: () => api.getCloudFields(presetType === 'print' ? 'process' : presetType),\n    staleTime: 1000 * 60 * 60, // Cache for 1 hour\n  });\n\n  // Fetch details for user presets of this type (for field discovery)\n  const { data: allPresetDetails } = useQuery({\n    queryKey: ['allPresetDetails', presetType, userPresetsOfType.map(p => p.setting_id).join(',')],\n    queryFn: async () => {\n      // Fetch all preset details in parallel (limit concurrency to avoid overwhelming API)\n      const results: Record<string, SlicerSettingDetail> = {};\n      const batchSize = 5;\n      for (let i = 0; i < userPresetsOfType.length; i += batchSize) {\n        const batch = userPresetsOfType.slice(i, i + batchSize);\n        const batchResults = await Promise.all(\n          batch.map(async (preset) => {\n            try {\n              const detail = await api.getCloudSettingDetail(preset.setting_id);\n              return { id: preset.setting_id, detail };\n            } catch {\n              return null;\n            }\n          })\n        );\n        batchResults.forEach(r => {\n          if (r) results[r.id] = r.detail;\n        });\n      }\n      return results;\n    },\n    enabled: userPresetsOfType.length > 0,\n    staleTime: 1000 * 60 * 10, // Cache for 10 minutes\n  });\n\n  // Fetch base preset details (works for both user presets and built-in presets with new API version)\n  const { data: basePresetDetail, isLoading: isLoadingBasePreset } = useQuery<SlicerSettingDetail>({\n    queryKey: ['cloudSettingDetail', baseId],\n    queryFn: () => api.getCloudSettingDetail(baseId),\n    enabled: !!baseId,\n  });\n\n  // Sync JSON text with settings object\n  useEffect(() => {\n    if (activeTab !== 'json') {\n      setJsonText(JSON.stringify(settingsObj, null, 2));\n    }\n  }, [settingsObj, activeTab]);\n\n  // Get presets filtered by selected type - only built-in presets allowed as base\n  // (Bambu Cloud only allows custom presets to inherit from built-in presets)\n  const availableBasePresets = useMemo(() => {\n    const typeMap: Record<string, SlicerSetting[]> = {\n      filament: allPresets.filament,\n      print: allPresets.process,\n      printer: allPresets.printer,\n    };\n    return (typeMap[presetType] || [])\n      .filter(p => !isUserPreset(p.setting_id)) // Only built-in presets\n      .sort((a, b) => a.name.localeCompare(b.name));\n  }, [allPresets, presetType]);\n\n  // Set inherits field when base preset changes (don't pre-fill all values - they show as placeholders)\n  // In edit mode, don't reset settingsObj - keep the saved values\n  useEffect(() => {\n    if (!baseId) return;\n\n    const preset = availableBasePresets.find(p => p.setting_id === baseId);\n    if (preset) {\n      setBaseName(preset.name);\n      // Don't reset settings in edit mode - keep saved values\n      if (!isEditMode) {\n        setSettingsObj({ inherits: preset.name });\n        setJsonText(JSON.stringify({ inherits: preset.name }, null, 2));\n      }\n    }\n  }, [baseId, availableBasePresets, isEditMode]);\n\n  // Build dynamic fields list: merge API definitions with discovered fields from user presets\n  const dynamicFields = useMemo(() => {\n    // Use API field definitions if available\n    const knownFields: FieldDefinition[] = fieldDefinitions?.fields || [];\n    const knownKeySet = new Set(knownFields.map(f => f.key));\n\n    // Collect all unique field keys from ALL user presets of this type\n    const discoveredKeys = new Set<string>();\n    const excludeKeys = new Set(['inherits', 'updated_time', 'compatible_printers', 'compatible_prints']);\n\n    // From all preset details\n    if (allPresetDetails) {\n      Object.values(allPresetDetails).forEach(detail => {\n        if (detail?.setting) {\n          Object.keys(detail.setting).forEach(key => {\n            if (!knownKeySet.has(key) && !excludeKeys.has(key)) {\n              discoveredKeys.add(key);\n            }\n          });\n        }\n      });\n    }\n\n    // From current settings (in case user added custom fields)\n    Object.keys(settingsObj).forEach(key => {\n      if (!knownKeySet.has(key) && !excludeKeys.has(key)) {\n        discoveredKeys.add(key);\n      }\n    });\n\n    // Create field definitions for discovered keys (generic text inputs)\n    const discoveredFields: FieldDefinition[] = Array.from(discoveredKeys)\n      .sort()\n      .map(key => ({\n        key,\n        label: key.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase()),\n        type: 'text' as const,\n        category: 'discovered',\n        description: t('profiles.presets.discoveredFromPresets'),\n      }));\n\n    return [...knownFields, ...discoveredFields];\n  }, [fieldDefinitions, allPresetDetails, settingsObj, t]);\n\n  // Filter fields for search\n  const filteredFields = dynamicFields.filter(f =>\n    f.label.toLowerCase().includes(fieldSearch.toLowerCase()) ||\n    f.key.toLowerCase().includes(fieldSearch.toLowerCase())\n  );\n\n  // Add a custom field\n  const addCustomField = () => {\n    if (customFieldKey.trim()) {\n      const key = customFieldKey.trim().toLowerCase().replace(/\\s+/g, '_');\n      updateField(key, '');\n      setCustomFieldKey('');\n      setShowCustomFieldInput(false);\n      showToast(t('profiles.presets.toast.fieldAdded', { key }));\n    }\n  };\n\n  // Update a single field\n  const updateField = (key: string, value: unknown) => {\n    setSettingsObj(prev => {\n      const newObj = { ...prev };\n      if (value === '' || value === undefined) {\n        delete newObj[key];\n      } else {\n        newObj[key] = value;\n      }\n      return newObj;\n    });\n  };\n\n  // Apply a template\n  const applyTemplate = (template: { name: string; settings: Record<string, unknown> }) => {\n    setSettingsObj(prev => ({ ...prev, ...template.settings }));\n    setAppliedTemplateName(template.name);\n    showToast(t('profiles.templates.toast.applied'));\n  };\n\n  // Save current settings as a template\n  const saveAsTemplate = () => {\n    if (!newTemplateName.trim()) return;\n    const overrides = { ...settingsObj };\n    delete overrides.inherits;\n    if (Object.keys(overrides).length === 0) {\n      showToast(t('profiles.presets.noOverridesToSave'), 'error');\n      return;\n    }\n    const newTemplate: CustomTemplate = {\n      id: Date.now().toString(),\n      name: newTemplateName.trim(),\n      description: newTemplateDesc.trim() || t('profiles.presets.customTemplate'),\n      type: presetType,\n      settings: overrides,\n      showInModal: newTemplateShowInModal,\n    };\n    const updated = [...customTemplates, newTemplate];\n    setCustomTemplates(updated);\n    saveCustomTemplates(updated);\n    setShowSaveTemplate(false);\n    setNewTemplateName('');\n    setNewTemplateDesc('');\n    setNewTemplateShowInModal(true);\n    showToast(t('profiles.templates.toast.created'));\n  };\n\n  // Get templates for current type (only those marked to show in modals)\n  const templatesForType = useMemo(() => {\n    return customTemplates.filter(t => t.type === presetType && t.showInModal);\n  }, [presetType, customTemplates]);\n\n  // Handle JSON edit\n  const handleJsonChange = (text: string) => {\n    setJsonText(text);\n    try {\n      const parsed = JSON.parse(text);\n      setSettingsObj(parsed);\n      setJsonError(null);\n    } catch (e) {\n      setJsonError((e as Error).message);\n    }\n  };\n\n  // Handle file drop\n  const handleFileDrop = (e: React.DragEvent) => {\n    e.preventDefault();\n    setIsDragging(false);\n    const file = e.dataTransfer.files[0];\n    if (file && file.name.endsWith('.json')) {\n      const reader = new FileReader();\n      reader.onload = (event) => {\n        try {\n          const content = event.target?.result as string;\n          const parsed = JSON.parse(content);\n          // Handle both full preset format and settings-only format\n          const settings = parsed.setting || parsed;\n          setSettingsObj(prev => ({ ...prev, ...settings }));\n          setJsonText(JSON.stringify({ ...settingsObj, ...settings }, null, 2));\n          showToast(t('profiles.presets.fileImported'));\n        } catch {\n          showToast(t('profiles.presets.invalidJsonFile'), 'error');\n        }\n      };\n      reader.readAsText(file);\n    }\n  };\n\n  const createMutation = useMutation({\n    mutationFn: () => {\n      const finalSettings = { ...settingsObj };\n      const settingsIdKey = presetType === 'filament' ? 'filament_settings_id'\n        : presetType === 'print' ? 'print_settings_id' : 'printer_settings_id';\n      finalSettings[settingsIdKey] = `\"${name}\"`;\n\n      const data: SlicerSettingCreate = { type: presetType, name, base_id: baseId, setting: finalSettings };\n      return api.createCloudSetting(data);\n    },\n    onSuccess: async () => {\n      showToast(t('profiles.presets.toast.created'));\n      // Force immediate refetch of the settings list\n      await queryClient.refetchQueries({ queryKey: ['cloudSettings'] });\n      onClose();\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: () => {\n      if (!initialData?.setting_id) throw new Error(t('profiles.presets.noSettingId'));\n      return api.updateCloudSetting(initialData.setting_id, { name, setting: settingsObj });\n    },\n    onSuccess: async () => {\n      showToast(t('profiles.presets.toast.updated'));\n      // Clear all detail caches to ensure fresh data\n      queryClient.removeQueries({ queryKey: ['cloudSettingDetail'] });\n      // Force immediate refetch of the settings list\n      await queryClient.refetchQueries({ queryKey: ['cloudSettings'] });\n      onClose();\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const saveMutation = isEditMode ? updateMutation : createMutation;\n\n  // Check if base preset inherits from another preset (for user presets that only store overrides)\n  const inheritedPresetName = basePresetDetail?.setting?.inherits as string | undefined;\n  const inheritedPreset = inheritedPresetName\n    ? availableBasePresets.find(p => p.name === inheritedPresetName)\n    : undefined;\n\n  // Fetch the inherited preset's full values (if applicable)\n  const { data: inheritedPresetDetail } = useQuery<SlicerSettingDetail>({\n    queryKey: ['cloudSettingDetail', inheritedPreset?.setting_id],\n    queryFn: () => api.getCloudSettingDetail(inheritedPreset!.setting_id),\n    enabled: !!inheritedPreset?.setting_id,\n  });\n\n  // Get base preset values - merge inherited values with overrides\n  const basePresetValues = useMemo(() => {\n    // Start with inherited preset's values (full base)\n    const inheritedValues = inheritedPresetDetail?.setting as Record<string, unknown> || {};\n\n    // Get the selected preset's values (could be overrides only)\n    const selectedValues = basePresetDetail?.setting as Record<string, unknown> || {};\n\n    // Fallback to allPresetDetails if no dedicated query result\n    const fallbackValues = baseId && allPresetDetails?.[baseId]?.setting\n      ? allPresetDetails[baseId].setting as Record<string, unknown>\n      : {};\n\n    // Merge: inherited base values + selected preset overrides\n    // Selected values take precedence\n    return {\n      ...inheritedValues,\n      ...selectedValues,\n      ...fallbackValues,\n    };\n  }, [baseId, basePresetDetail, inheritedPresetDetail, allPresetDetails]);\n\n  // Format a value for display (handles arrays, objects, etc.)\n  const formatValue = (val: unknown): string => {\n    if (val === undefined || val === null) return '';\n    if (Array.isArray(val)) {\n      // For arrays, join with comma or take first value if all same\n      const unique = [...new Set(val.map(v => String(v)))];\n      return unique.length === 1 ? unique[0] : val.join(', ');\n    }\n    return String(val);\n  };\n\n  // Render a field input\n  const renderFieldInput = (field: FieldDefinition) => {\n    const value = settingsObj[field.key] as string | number | boolean | undefined;\n    const baseValue = basePresetValues[field.key];\n    const formattedBaseValue = formatValue(baseValue);\n    // Always show base value as placeholder when available\n    const placeholder = isLoadingBasePreset\n      ? t('common.loading')\n      : (formattedBaseValue || '');\n    const baseClass = \"w-full px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\";\n\n    if (field.type === 'boolean') {\n      const isOn = value === '1' || (value === undefined && baseValue === '1');\n      return (\n        <button\n          type=\"button\"\n          onClick={() => updateField(field.key, value === '1' ? '0' : '1')}\n          className={`w-8 h-5 rounded-full transition-colors ${isOn ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'}`}\n        >\n          <div className={`w-4 h-4 rounded-full bg-white shadow transition-transform ${isOn ? 'translate-x-3.5' : 'translate-x-0.5'}`} />\n        </button>\n      );\n    }\n\n    if (field.type === 'select') {\n      return (\n        <select\n          value={(value as string) || ''}\n          onChange={(e) => updateField(field.key, e.target.value)}\n          className={baseClass}\n        >\n          <option value=\"\">{placeholder}</option>\n          {field.options?.map(opt => (\n            <option key={opt.value} value={opt.value}>{opt.label}</option>\n          ))}\n        </select>\n      );\n    }\n\n    return (\n      <div className=\"flex items-center gap-2\">\n        <input\n          type={field.type === 'number' ? 'number' : 'text'}\n          value={value !== undefined ? String(value) : ''}\n          onChange={(e) => updateField(field.key, e.target.value)}\n          step={field.step}\n          min={field.min}\n          max={field.max}\n          placeholder={placeholder}\n          className={baseClass}\n        />\n        {field.unit && <span className=\"text-xs text-bambu-gray whitespace-nowrap\">{field.unit}</span>}\n      </div>\n    );\n  };\n\n  // Get base preset settings for diff comparison\n  const basePresetSettings = useMemo(() => {\n    if (!basePresetDetail?.setting) return {};\n    return basePresetDetail.setting as Record<string, unknown>;\n  }, [basePresetDetail]);\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\"\n      onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}\n      onDragLeave={() => setIsDragging(false)}\n      onDrop={handleFileDrop}\n    >\n      {/* Diff Modal */}\n      {showDiffModal && baseId && (\n        <DiffModal\n          onClose={() => setShowDiffModal(false)}\n          leftPreset={basePresetSettings}\n          rightPreset={settingsObj}\n          leftLabel={t('profiles.presets.baseLabel', { name: baseName || baseId })}\n          rightLabel={t('profiles.presets.currentLabel', { name: name || t('profiles.presets.newPreset') })}\n          t={t}\n        />\n      )}\n\n      <Card className=\"w-full max-w-6xl max-h-[90vh] flex flex-col overflow-y-auto\">\n        <CardContent className=\"p-0 flex flex-col h-full\">\n          {/* Header */}\n          <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n            <div>\n              <h2 className=\"text-xl font-semibold text-white\">\n                {isEditMode ? t('profiles.presets.editPreset') : (initialData ? t('profiles.presets.duplicatePreset') : t('profiles.presets.createNewPreset'))}\n              </h2>\n              <p className=\"text-sm text-bambu-gray mt-1\">\n                {t('profiles.presets.customizeSettings')}\n              </p>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              {baseId && (\n                <button\n                  onClick={() => setShowDiffModal(true)}\n                  className=\"flex items-center gap-2 px-3 py-2 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors\"\n                  title={t('profiles.presets.compareWithBase')}\n                >\n                  <GitCompare className=\"w-4 h-4\" />\n                  {t('profiles.presets.compare')}\n                </button>\n              )}\n              <button onClick={onClose} className=\"p-2 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors\">\n                <X className=\"w-5 h-5\" />\n              </button>\n            </div>\n          </div>\n\n          {/* Drag overlay */}\n          {isDragging && (\n            <div className=\"absolute inset-0 bg-bambu-green/10 border-2 border-dashed border-bambu-green rounded-lg flex items-center justify-center z-10\">\n              <div className=\"text-center\">\n                <Upload className=\"w-12 h-12 text-bambu-green mx-auto mb-2\" />\n                <p className=\"text-bambu-green font-medium\">{t('profiles.presets.dropJsonToImport')}</p>\n              </div>\n            </div>\n          )}\n\n          {/* Basic Info */}\n          <div className=\"p-4 border-b border-bambu-dark-tertiary space-y-3\">\n            <div className=\"grid grid-cols-3 gap-4 max-[640px]:grid-cols-1\">\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('common.type')}</label>\n                <select\n                  value={presetType}\n                  onChange={(e) => { setPresetType(e.target.value as 'filament' | 'print' | 'printer'); setBaseId(''); }}\n                  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\"\n                >\n                  <option value=\"filament\">{t('profiles.presets.types.filament')}</option>\n                  <option value=\"print\">{t('profiles.presets.types.process')}</option>\n                  <option value=\"printer\">{t('profiles.presets.types.printer')}</option>\n                </select>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('profiles.presets.basePreset')}</label>\n                <select\n                  value={baseId}\n                  onChange={(e) => setBaseId(e.target.value)}\n                  className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"\n                >\n                  <option value=\"\">{t('profiles.presets.selectBasePreset')}</option>\n                  {availableBasePresets.map((preset) => (\n                    <option key={preset.setting_id} value={preset.setting_id}>{preset.name}</option>\n                  ))}\n                </select>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('profiles.presets.presetName')}</label>\n                <input\n                  type=\"text\"\n                  value={name}\n                  onChange={(e) => setName(e.target.value)}\n                  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\"\n                  placeholder={t('profiles.presets.myCustomPreset')}\n                />\n              </div>\n            </div>\n            {baseName && (\n              <div className=\"text-xs text-bambu-gray\">\n                <p className=\"flex items-center gap-1\">\n                  <Check className=\"w-3 h-3 text-bambu-green\" />\n                  {t('profiles.presets.inheritsFrom')} <span className=\"text-white\">{baseName}</span>\n                  {isLoadingBasePreset && (\n                    <Loader2 className=\"w-3 h-3 animate-spin ml-1\" />\n                  )}\n                </p>\n              </div>\n            )}\n          </div>\n\n          {/* Tabs */}\n          <div className=\"flex border-b border-bambu-dark-tertiary max-[640px]:flex-wrap max-[640px]:items-center\">\n            <button\n              onClick={() => setActiveTab('common')}\n              className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${\n                activeTab === 'common' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'\n              }`}\n            >\n              <Sliders className=\"w-4 h-4\" />\n              {t('profiles.presets.tabs.common')}\n            </button>\n            <button\n              onClick={() => setActiveTab('fields')}\n              className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${\n                activeTab === 'fields' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'\n              }`}\n            >\n              <List className=\"w-4 h-4\" />\n              {t('profiles.presets.tabs.allFields')}\n            </button>\n            <button\n              onClick={() => setActiveTab('json')}\n              className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${\n                activeTab === 'json' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'\n              }`}\n            >\n              <Code className=\"w-4 h-4\" />\n              JSON\n              {jsonError && <AlertCircle className=\"w-3 h-3 text-red-400\" />}\n            </button>\n            <div className=\"flex-1 max-[640px]:hidden\" />\n            <button\n              onClick={() => {\n                const exportData = {\n                  name,\n                  type: presetType,\n                  base_id: baseId,\n                  setting: settingsObj,\n                };\n                const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });\n                const url = URL.createObjectURL(blob);\n                const a = document.createElement('a');\n                a.href = url;\n                a.download = `${name || 'preset'}.json`;\n                document.body.appendChild(a);\n                a.click();\n                document.body.removeChild(a);\n                URL.revokeObjectURL(url);\n                showToast(t('profiles.presets.toast.exported'));\n              }}\n              className=\"flex items-center gap-2 px-4 py-3 text-sm text-bambu-gray hover:text-white transition-colors\"\n              title={t('profiles.presets.exportToJson')}\n            >\n              <Download className=\"w-4 h-4\" />\n              {t('common.download')}\n            </button>\n            <button\n              onClick={() => document.getElementById('file-import')?.click()}\n              className=\"flex items-center gap-2 px-4 py-3 text-sm text-bambu-gray hover:text-white transition-colors\"\n              title={t('profiles.presets.importFromJson')}\n            >\n              <Upload className=\"w-4 h-4\" />\n              {t('common.upload')}\n            </button>\n            <input\n              id=\"file-import\"\n              type=\"file\"\n              accept=\".json\"\n              className=\"hidden\"\n              onChange={(e) => {\n                const file = e.target.files?.[0];\n                if (file) {\n                  const reader = new FileReader();\n                  reader.onload = (event) => {\n                    try {\n                      const parsed = JSON.parse(event.target?.result as string);\n                      const settings = parsed.setting || parsed;\n                      setSettingsObj(prev => ({ ...prev, ...settings }));\n                      showToast(t('profiles.presets.fileImported'));\n                    } catch {\n                      showToast(t('profiles.presets.invalidJson'), 'error');\n                    }\n                  };\n                  reader.readAsText(file);\n                }\n              }}\n            />\n          </div>\n\n          {/* Tab Content */}\n          <div className=\"flex-1 p-4\">\n            {activeTab === 'common' && (\n              <div className=\"space-y-6\">\n                {/* Templates */}\n                <div>\n                  <div className=\"flex items-center justify-between mb-3\">\n                    <h3 className=\"text-sm font-medium text-white flex items-center gap-2\">\n                      <Sparkles className=\"w-4 h-4 text-amber-400\" />\n                      {t('profiles.templates.title')}\n                    </h3>\n                    {Object.keys(settingsObj).filter(k => k !== 'inherits').length > 0 && (\n                      <button\n                        onClick={() => setShowSaveTemplate(!showSaveTemplate)}\n                        className=\"text-xs text-bambu-gray hover:text-white flex items-center gap-1 transition-colors\"\n                      >\n                        <Save className=\"w-3 h-3\" />\n                        {t('profiles.presets.saveAsTemplate')}\n                      </button>\n                    )}\n                  </div>\n\n                  {showSaveTemplate && (\n                    <div className=\"mb-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                      <div className=\"grid grid-cols-2 gap-2 mb-2\">\n                        <input\n                          type=\"text\"\n                          value={newTemplateName}\n                          onChange={(e) => setNewTemplateName(e.target.value)}\n                          placeholder={t('profiles.templates.namePlaceholder')}\n                          className=\"px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                          autoFocus\n                        />\n                        <input\n                          type=\"text\"\n                          value={newTemplateDesc}\n                          onChange={(e) => setNewTemplateDesc(e.target.value)}\n                          placeholder={t('profiles.templates.descriptionPlaceholder')}\n                          className=\"px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                        />\n                      </div>\n                      <div className=\"flex items-center justify-between\">\n                        <div className=\"flex gap-2\">\n                          <Button size=\"sm\" onClick={saveAsTemplate} disabled={!newTemplateName.trim()}>\n                            <Save className=\"w-3 h-3\" />\n                            {t('common.save')}\n                          </Button>\n                          <Button size=\"sm\" variant=\"secondary\" onClick={() => setShowSaveTemplate(false)}>\n                            {t('common.cancel')}\n                          </Button>\n                        </div>\n                        <button\n                          onClick={() => setNewTemplateShowInModal(!newTemplateShowInModal)}\n                          className={`flex items-center gap-1.5 text-xs transition-colors ${\n                            newTemplateShowInModal ? 'text-bambu-green' : 'text-bambu-gray hover:text-white'\n                          }`}\n                        >\n                          {newTemplateShowInModal ? <Eye className=\"w-3.5 h-3.5\" /> : <EyeOff className=\"w-3.5 h-3.5\" />}\n                          {newTemplateShowInModal ? t('profiles.templates.shownInModals') : t('profiles.templates.hiddenInModals')}\n                        </button>\n                      </div>\n                    </div>\n                  )}\n\n                  {/* Applied template indicator */}\n                  {appliedTemplateName && (\n                    <div className=\"mb-3 px-3 py-2 bg-bambu-green/10 border border-bambu-green/30 rounded-lg flex items-center gap-2\">\n                      <Check className=\"w-4 h-4 text-bambu-green\" />\n                      <span className=\"text-sm text-bambu-green\">{t('profiles.presets.templateApplied')} <span className=\"font-medium\">{appliedTemplateName}</span></span>\n                      <button\n                        onClick={() => setAppliedTemplateName(null)}\n                        className=\"ml-auto text-bambu-green/70 hover:text-bambu-green\"\n                      >\n                        <X className=\"w-4 h-4\" />\n                      </button>\n                    </div>\n                  )}\n\n                  <div className=\"grid grid-cols-3 gap-2\">\n                    {templatesForType.map((template) => (\n                      <button\n                        key={template.id}\n                        onClick={() => applyTemplate(template)}\n                        className=\"p-3 text-left bg-bambu-dark border border-bambu-dark-tertiary rounded-lg hover:border-bambu-gray-dark transition-colors\"\n                      >\n                        <p className=\"text-sm font-medium text-white\">{template.name}</p>\n                        <p className=\"text-xs text-bambu-gray mt-1\">{template.description}</p>\n                      </button>\n                    ))}\n                    {templatesForType.length === 0 && (\n                      <p className=\"col-span-3 text-center text-bambu-gray text-sm py-4\">\n                        {t('profiles.presets.noTemplatesSelected')}\n                      </p>\n                    )}\n                  </div>\n\n                  {/* Note about template management */}\n                  <p className=\"text-xs text-bambu-gray-dark mt-2 text-center\">\n                    {t('profiles.presets.manageTemplatesHint')}\n                  </p>\n                </div>\n\n                {/* Common Fields */}\n                <div>\n                  <h3 className=\"text-sm font-medium text-white mb-3\">{t('profiles.presets.commonSettings')}</h3>\n                  <div className=\"grid grid-cols-2 gap-x-6 gap-y-3\">\n                    {dynamicFields.slice(0, 10).map(field => (\n                      <div key={field.key} className=\"flex items-center justify-between gap-4 max-[640px]:flex-col max-[640px]:items-start\">\n                        <label className=\"text-sm text-bambu-gray flex-shrink-0\">{field.label}</label>\n                        <div className=\"w-48 max-[640px]:w-full\">{renderFieldInput(field)}</div>\n                      </div>\n                    ))}\n                  </div>\n                </div>\n\n                {/* Current overrides */}\n                {Object.keys(settingsObj).length > 1 && (\n                  <div>\n                    <h3 className=\"text-sm font-medium text-white mb-3\">{t('profiles.presets.currentOverrides')}</h3>\n                    <div className=\"flex flex-wrap gap-2\">\n                      {Object.entries(settingsObj)\n                        .filter(([k]) => k !== 'inherits')\n                        .map(([key, value]) => (\n                          <span key={key} className=\"inline-flex items-center gap-1 px-2 py-1 bg-bambu-green/10 text-bambu-green text-xs rounded\">\n                            {key}: {String(value).slice(0, 20)}\n                            <button onClick={() => updateField(key, undefined)} className=\"hover:text-white\">\n                              <X className=\"w-3 h-3\" />\n                            </button>\n                          </span>\n                        ))}\n                    </div>\n                  </div>\n                )}\n              </div>\n            )}\n\n            {activeTab === 'fields' && (\n              <div className=\"grid grid-cols-2 gap-6\" style={{ height: '400px' }}>\n                {/* Left: Available Fields */}\n                <div className=\"flex flex-col h-full overflow-hidden\">\n                  <div className=\"flex items-center justify-between mb-3 flex-shrink-0\">\n                    <h3 className=\"text-sm font-medium text-white\">{t('profiles.presets.availableFields')}</h3>\n                    <span className=\"text-xs text-bambu-gray\">\n                      {allPresetDetails\n                        ? t('profiles.templates.fieldsCount', { count: dynamicFields.length })\n                        : t('common.loading')}\n                    </span>\n                  </div>\n\n                  <div className=\"relative mb-3 flex-shrink-0\">\n                    <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n                    <input\n                      type=\"text\"\n                      value={fieldSearch}\n                      onChange={(e) => setFieldSearch(e.target.value)}\n                      placeholder={t('profiles.presets.searchFieldsPlaceholder')}\n                      className=\"w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\"\n                    />\n                  </div>\n\n                  <div className=\"flex-1 overflow-y-auto space-y-1 pr-2 min-h-0\">\n                    {filteredFields\n                      .filter(f => !(f.key in settingsObj))\n                      .map(field => {\n                        const baseVal = basePresetValues[field.key];\n                        const formattedVal = formatValue(baseVal);\n                        return (\n                          <div\n                            key={field.key}\n                            onClick={() => {\n                              // Add field directly (don't use updateField which deletes on empty)\n                              setSettingsObj(prev => ({ ...prev, [field.key]: formattedVal || '' }));\n                            }}\n                            className=\"flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors cursor-pointer group\"\n                          >\n                            <div className=\"min-w-0 flex-1\">\n                              <p className=\"text-sm text-white truncate\">{field.label}</p>\n                              <p className=\"text-xs text-bambu-gray-dark truncate\">{field.key}</p>\n                            </div>\n                            <div className=\"flex items-center gap-2 flex-shrink-0\">\n                              {formattedVal && (\n                                <span className=\"text-xs text-bambu-gray bg-bambu-dark px-2 py-0.5 rounded max-w-32 truncate\" title={formattedVal}>\n                                  {formattedVal.slice(0, 20)}{formattedVal.length > 20 ? '...' : ''}\n                                </span>\n                              )}\n                              <div className=\"w-6 h-6 flex items-center justify-center rounded bg-bambu-dark-tertiary group-hover:bg-bambu-green/20 transition-colors\">\n                                <Plus className=\"w-4 h-4 text-bambu-gray group-hover:text-bambu-green transition-colors\" />\n                              </div>\n                            </div>\n                          </div>\n                        );\n                      })}\n\n                    {filteredFields.filter(f => !(f.key in settingsObj)).length === 0 && (\n                      <p className=\"text-center text-bambu-gray py-4 text-sm\">\n                        {fieldSearch ? t('profiles.presets.noMatchingFields') : t('profiles.presets.allFieldsAdded')}\n                      </p>\n                    )}\n                  </div>\n\n                  {/* Custom field input */}\n                  <div className=\"pt-3 mt-3 border-t border-bambu-dark-tertiary flex-shrink-0\">\n                    {showCustomFieldInput ? (\n                      <div className=\"flex gap-2\">\n                        <input\n                          type=\"text\"\n                          value={customFieldKey}\n                          onChange={(e) => setCustomFieldKey(e.target.value)}\n                          onKeyDown={(e) => e.key === 'Enter' && addCustomField()}\n                          placeholder=\"custom_field_name\"\n                          className=\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\"\n                          autoFocus\n                        />\n                        <Button size=\"sm\" onClick={addCustomField} disabled={!customFieldKey.trim()}>\n                          <Plus className=\"w-4 h-4\" />\n                        </Button>\n                        <Button size=\"sm\" variant=\"secondary\" onClick={() => { setShowCustomFieldInput(false); setCustomFieldKey(''); }}>\n                          <X className=\"w-4 h-4\" />\n                        </Button>\n                      </div>\n                    ) : (\n                      <button\n                        onClick={() => setShowCustomFieldInput(true)}\n                        className=\"w-full flex items-center justify-center gap-2 p-2 text-sm text-bambu-gray hover:text-white border border-dashed border-bambu-dark-tertiary hover:border-bambu-gray-dark rounded-lg transition-colors\"\n                      >\n                        <Plus className=\"w-4 h-4\" />\n                        {t('profiles.presets.addCustomField')}\n                      </button>\n                    )}\n                  </div>\n                </div>\n\n                {/* Right: Added Fields */}\n                <div className=\"flex flex-col h-full overflow-hidden\">\n                  <div className=\"flex items-center justify-between mb-3 flex-shrink-0\">\n                    <h3 className=\"text-sm font-medium text-white\">{t('profiles.presets.yourOverrides')}</h3>\n                    <span className=\"text-xs text-bambu-gray\">\n                      {t('profiles.templates.fieldsCount', { count: Object.keys(settingsObj).filter(k => k !== 'inherits').length })}\n                    </span>\n                  </div>\n\n                  <div className=\"flex-1 overflow-y-auto space-y-2 pr-2 min-h-0\">\n                    {Object.entries(settingsObj)\n                      .filter(([key]) => key !== 'inherits')\n                      .map(([key, value]) => {\n                        const fieldDef = dynamicFields.find(f => f.key === key);\n                        return (\n                          <div key={key} className=\"p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n                            <div className=\"flex items-center justify-between mb-2\">\n                              <div>\n                                <p className=\"text-sm font-medium text-white\">{fieldDef?.label || key}</p>\n                                <p className=\"text-xs text-bambu-gray-dark\">{key}</p>\n                              </div>\n                              <button\n                                onClick={() => updateField(key, undefined)}\n                                className=\"p-1 text-bambu-gray hover:text-red-400 transition-colors\"\n                              >\n                                <X className=\"w-4 h-4\" />\n                              </button>\n                            </div>\n                            {fieldDef ? (\n                              renderFieldInput(fieldDef)\n                            ) : (\n                              <input\n                                type=\"text\"\n                                value={String(value)}\n                                onChange={(e) => updateField(key, e.target.value)}\n                                className=\"w-full px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                              />\n                            )}\n                          </div>\n                        );\n                      })}\n\n                    {Object.keys(settingsObj).filter(k => k !== 'inherits').length === 0 && (\n                      <div className=\"text-center py-8 text-bambu-gray\">\n                        <Sliders className=\"w-8 h-8 mx-auto mb-2 opacity-50\" />\n                        <p className=\"text-sm\">{t('profiles.presets.noOverridesYet')}</p>\n                        <p className=\"text-xs mt-1\">{t('profiles.presets.clickFieldsToAdd')}</p>\n                      </div>\n                    )}\n                  </div>\n\n                  {/* Save as template button */}\n                  {Object.keys(settingsObj).filter(k => k !== 'inherits').length > 0 && (\n                    <div className=\"pt-3 mt-3 border-t border-bambu-dark-tertiary flex-shrink-0\">\n                      <button\n                        onClick={() => { setShowSaveTemplate(true); setActiveTab('common'); }}\n                        className=\"w-full flex items-center justify-center gap-2 p-2 text-sm text-bambu-gray hover:text-white border border-dashed border-bambu-dark-tertiary hover:border-bambu-gray-dark rounded-lg transition-colors\"\n                      >\n                        <Save className=\"w-4 h-4\" />\n                        {t('profiles.presets.saveAsTemplate')}\n                      </button>\n                    </div>\n                  )}\n                </div>\n              </div>\n            )}\n\n            {activeTab === 'json' && (\n              <div className=\"space-y-2\">\n                {jsonError && (\n                  <div className=\"flex items-center gap-2 text-red-400 text-sm\">\n                    <AlertCircle className=\"w-4 h-4\" />\n                    {jsonError}\n                  </div>\n                )}\n                <textarea\n                  value={jsonText}\n                  onChange={(e) => handleJsonChange(e.target.value)}\n                  className={`w-full h-80 px-3 py-2 bg-bambu-dark border rounded-lg text-white text-xs font-mono focus:outline-none resize-none ${\n                    jsonError ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'\n                  }`}\n                  spellCheck={false}\n                />\n                <p className=\"text-xs text-bambu-gray\">\n                  {t('profiles.presets.jsonTip')}\n                </p>\n              </div>\n            )}\n          </div>\n\n          {/* Footer */}\n          <div className=\"p-4 border-t border-bambu-dark-tertiary flex gap-2\">\n            <Button variant=\"secondary\" onClick={onClose} className=\"flex-1\">{t('common.cancel')}</Button>\n            <Button\n              onClick={() => saveMutation.mutate()}\n              disabled={saveMutation.isPending || !name.trim() || (!isEditMode && !baseId) || !!jsonError}\n              className=\"flex-1\"\n            >\n              {saveMutation.isPending ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : (isEditMode ? <Save className=\"w-4 h-4\" /> : <Plus className=\"w-4 h-4\" />)}\n              {isEditMode ? t('common.save') : (initialData ? t('common.duplicate') : t('common.create'))}\n            </Button>\n          </div>\n        </CardContent>\n      </Card>\n    </div>\n  );\n}\n\n// ============================================================================\n// CLOUD PROFILES VIEW\n// ============================================================================\n\nfunction CloudProfilesView({\n  settings,\n  lastSyncTime,\n  onRefresh,\n  isRefreshing,\n  printers,\n  hasPermission,\n  t,\n}: {\n  settings: SlicerSettingsResponse;\n  lastSyncTime?: Date;\n  onRefresh: () => void;\n  isRefreshing: boolean;\n  printers: Printer[];\n  hasPermission: (permission: Permission) => boolean;\n  t: TFunction;\n}) {\n  const [searchQuery, setSearchQuery] = useState('');\n  const [filterType, setFilterType] = useState<PresetType>('all');\n  const [filterOwner, setFilterOwner] = useState<'all' | 'custom' | 'builtin'>('all');\n  const [filterPrinter, setFilterPrinter] = useState('all');\n  const [filterNozzle, setFilterNozzle] = useState('all');\n  const [filterFilament, setFilterFilament] = useState('all');\n  const [filterLayerHeight, setFilterLayerHeight] = useState('all');\n  const [selectedSetting, setSelectedSetting] = useState<SlicerSetting | null>(null);\n  const [showCreateModal, setShowCreateModal] = useState(false);\n  const [showTemplatesModal, setShowTemplatesModal] = useState(false);\n  const [duplicateData, setDuplicateData] = useState<{ type: string; name: string; base_id: string; setting: Record<string, unknown> } | null>(null);\n  const [editData, setEditData] = useState<{ type: string; name: string; base_id: string; setting: Record<string, unknown>; setting_id: string } | null>(null);\n  const [templateData, setTemplateData] = useState<{ type: string; setting: Record<string, unknown> } | null>(null);\n  // Compare mode state\n  const [compareMode, setCompareMode] = useState(false);\n  const [compareSelection, setCompareSelection] = useState<[SlicerSetting | null, SlicerSetting | null]>([null, null]);\n  const [showCompareModal, setShowCompareModal] = useState(false);\n  const [comparePresets, setComparePresets] = useState<[Record<string, unknown>, Record<string, unknown>] | null>(null);\n\n  const queryClient = useQueryClient();\n\n  // Combine all presets with metadata\n  const allPresetsWithMeta = useMemo(() => {\n    const combined = [\n      ...settings.filament.map(s => ({ ...s, type: 'filament' as const })),\n      ...settings.printer.map(s => ({ ...s, type: 'printer' as const })),\n      ...settings.process.map(s => ({ ...s, type: 'process' as const })),\n    ];\n    return combined.map(s => ({ ...s, meta: extractMetadata(s.name) }));\n  }, [settings]);\n\n  // Extract unique filter values (use configured printers from API)\n  const filterOptions = useMemo(() => {\n    const nozzles = new Set<string>();\n    const filaments = new Set<string>();\n    const layerHeights = new Set<string>();\n\n    allPresetsWithMeta.forEach(p => {\n      if (p.meta.nozzle) nozzles.add(p.meta.nozzle);\n      if (p.meta.filamentType) filaments.add(p.meta.filamentType);\n      if (p.meta.layerHeight) layerHeights.add(p.meta.layerHeight);\n    });\n\n    return {\n      printers: printers.map(p => ({ id: p.id.toString(), name: p.name })),\n      nozzles: Array.from(nozzles).sort((a, b) => parseFloat(a) - parseFloat(b)),\n      filaments: Array.from(filaments).sort(),\n      layerHeights: Array.from(layerHeights).sort((a, b) => parseFloat(a) - parseFloat(b)),\n    };\n  }, [allPresetsWithMeta, printers]);\n\n  // Get selected printer's model for filtering\n  const selectedPrinterModel = useMemo(() => {\n    if (filterPrinter === 'all') return null;\n    const printer = printers.find(p => p.id.toString() === filterPrinter);\n    return printer?.model || null;\n  }, [filterPrinter, printers]);\n\n  // Apply filters\n  const filteredPresets = useMemo(() => {\n    return allPresetsWithMeta\n      .filter(s => filterType === 'all' || s.type === filterType)\n      .filter(s => {\n        if (filterOwner === 'all') return true;\n        const isCustom = isUserPreset(s.setting_id);\n        return filterOwner === 'custom' ? isCustom : !isCustom;\n      })\n      .filter(s => {\n        if (filterPrinter === 'all' || !selectedPrinterModel) return true;\n        // Match preset's printer model to configured printer's model\n        const presetPrinter = s.meta.printer?.toLowerCase() || '';\n        const configuredModel = selectedPrinterModel.toLowerCase();\n        return presetPrinter.includes(configuredModel) || configuredModel.includes(presetPrinter);\n      })\n      .filter(s => filterNozzle === 'all' || s.meta.nozzle === filterNozzle)\n      .filter(s => filterFilament === 'all' || s.meta.filamentType === filterFilament)\n      .filter(s => filterLayerHeight === 'all' || s.meta.layerHeight === filterLayerHeight)\n      .filter(s => searchQuery === '' || s.name.toLowerCase().includes(searchQuery.toLowerCase()))\n      .sort((a, b) => a.name.localeCompare(b.name));\n  }, [allPresetsWithMeta, filterType, filterOwner, filterPrinter, selectedPrinterModel, filterNozzle, filterFilament, filterLayerHeight, searchQuery]);\n\n  // Handle click on preset in compare mode\n  const handlePresetClick = (preset: SlicerSetting) => {\n    if (compareMode) {\n      // In compare mode, toggle selection\n      const isFirst = compareSelection[0]?.setting_id === preset.setting_id;\n      const isSecond = compareSelection[1]?.setting_id === preset.setting_id;\n\n      if (isFirst) {\n        // Deselect first\n        setCompareSelection([compareSelection[1], null]);\n      } else if (isSecond) {\n        // Deselect second\n        setCompareSelection([compareSelection[0], null]);\n      } else if (!compareSelection[0]) {\n        // Select as first\n        setCompareSelection([preset, null]);\n      } else if (!compareSelection[1]) {\n        // Check type match - only allow same type\n        if (compareSelection[0].type !== preset.type) {\n          return; // Don't allow selecting different types\n        }\n        // Select as second\n        setCompareSelection([compareSelection[0], preset]);\n      } else {\n        // Both selected, replace second (must match first's type)\n        if (compareSelection[0].type !== preset.type) {\n          return;\n        }\n        setCompareSelection([compareSelection[0], preset]);\n      }\n    } else {\n      // Normal mode, open detail\n      setSelectedSetting(preset);\n    }\n  };\n\n  // Check if preset is selected for comparison\n  const getCompareIndex = (preset: SlicerSetting): number | undefined => {\n    if (compareSelection[0]?.setting_id === preset.setting_id) return 0;\n    if (compareSelection[1]?.setting_id === preset.setting_id) return 1;\n    return undefined;\n  };\n\n  const handleDuplicate = async (setting: SlicerSetting) => {\n    try {\n      // Always fetch fresh data (bypass cache)\n      const detail = await api.getCloudSettingDetail(setting.setting_id);\n\n      const apiType = setting.type === 'process' ? 'print' : setting.type;\n      setDuplicateData({\n        type: apiType,\n        name: setting.name,\n        base_id: detail.base_id || 'GFSA00',\n        setting: detail.setting || {},\n      });\n      setSelectedSetting(null);\n    } catch (error) {\n      console.error('Failed to fetch preset details for duplication:', error);\n    }\n  };\n\n  const handleEdit = async (setting: SlicerSetting) => {\n    try {\n      // Clear any cached data first\n      queryClient.removeQueries({ queryKey: ['cloudSettingDetail', setting.setting_id] });\n\n      // Always fetch fresh data (bypass cache)\n      const detail = await api.getCloudSettingDetail(setting.setting_id);\n\n      const apiType = setting.type === 'process' ? 'print' : setting.type;\n      setEditData({\n        type: apiType,\n        name: setting.name,\n        base_id: detail.base_id || 'GFSA00',\n        setting: detail.setting || {},\n        setting_id: setting.setting_id,\n      });\n      setSelectedSetting(null);\n    } catch (error) {\n      console.error('Failed to fetch preset details for editing:', error);\n    }\n  };\n\n  const clearFilters = () => {\n    setFilterType('all');\n    setFilterOwner('all');\n    setFilterPrinter('all');\n    setFilterNozzle('all');\n    setFilterFilament('all');\n    setFilterLayerHeight('all');\n    setSearchQuery('');\n  };\n\n  const hasActiveFilters = filterType !== 'all' || filterOwner !== 'all' || filterPrinter !== 'all' || filterNozzle !== 'all' ||\n    filterFilament !== 'all' || filterLayerHeight !== 'all' || searchQuery !== '';\n\n  const totalCount = settings.filament.length + settings.printer.length + settings.process.length;\n\n  return (\n    <>\n      {/* Search and Filters */}\n      <div className=\"space-y-4 mb-6\">\n        {/* Search row */}\n        <div className=\"flex flex-col sm:flex-row gap-3\">\n          <div className=\"relative flex-1\">\n            <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\" />\n            <input\n              type=\"text\"\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n              placeholder={t('profiles.cloudView.searchPlaceholder')}\n              className=\"w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\"\n            />\n          </div>\n\n          <div className=\"flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center\">\n            <Button\n              variant={compareMode ? 'primary' : 'secondary'}\n              onClick={() => {\n                if (compareMode) {\n                  setCompareMode(false);\n                  setCompareSelection([null, null]);\n                } else {\n                  setCompareMode(true);\n                }\n              }}\n            >\n              <GitCompare className=\"w-4 h-4\" />\n              {compareMode ? t('common.cancel') : t('profiles.presets.compare')}\n            </Button>\n            <Button\n              variant=\"secondary\"\n              onClick={() => setShowTemplatesModal(true)}\n              disabled={!hasPermission('cloud:auth')}\n              title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noTemplatesPermission') : undefined}\n            >\n              <Sparkles className=\"w-4 h-4\" />\n              {t('profiles.cloudView.templates')}\n            </Button>\n            <Button\n              variant=\"secondary\"\n              onClick={onRefresh}\n              disabled={isRefreshing || !hasPermission('cloud:auth')}\n              title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noRefreshPermission') : undefined}\n            >\n              <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />\n              {t('profiles.cloudView.refresh')}\n            </Button>\n            <Button\n              onClick={() => setShowCreateModal(true)}\n              disabled={!hasPermission('cloud:auth')}\n              title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noCreatePermission') : undefined}\n            >\n              <Plus className=\"w-4 h-4\" />\n              {t('profiles.cloudView.newPreset')}\n            </Button>\n          </div>\n        </div>\n\n        {/* Filter row */}\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <Filter className=\"w-4 h-4 text-bambu-gray\" />\n\n          <FilterDropdown\n            label={t('profiles.cloudView.filters.type')}\n            value={filterType}\n            options={[\n              { value: 'all', label: t('profiles.cloudView.filters.all'), count: totalCount },\n              { value: 'filament', label: t('profiles.cloudView.filters.filament'), count: settings.filament.length },\n              { value: 'printer', label: t('profiles.cloudView.filters.printer'), count: settings.printer.length },\n              { value: 'process', label: t('profiles.cloudView.filters.process'), count: settings.process.length },\n            ]}\n            onChange={(v) => setFilterType(v as PresetType)}\n          />\n\n          <FilterDropdown\n            label={t('profiles.cloudView.filters.owner')}\n            value={filterOwner}\n            options={[\n              { value: 'all', label: t('profiles.cloudView.filters.all') },\n              { value: 'custom', label: t('profiles.cloudView.filters.myPresets') },\n              { value: 'builtin', label: t('profiles.cloudView.filters.builtIn') },\n            ]}\n            onChange={(v) => setFilterOwner(v as 'all' | 'custom' | 'builtin')}\n          />\n\n          {filterOptions.printers.length > 0 && (\n            <FilterDropdown\n              label={t('profiles.cloudView.filters.printer')}\n              value={filterPrinter}\n              options={[\n                { value: 'all', label: t('profiles.cloudView.filters.all') },\n                ...filterOptions.printers.map(p => ({ value: p.id, label: p.name })),\n              ]}\n              onChange={setFilterPrinter}\n            />\n          )}\n\n          {filterOptions.nozzles.length > 0 && (\n            <FilterDropdown\n              label={t('profiles.cloudView.filters.nozzle')}\n              value={filterNozzle}\n              options={[\n                { value: 'all', label: t('profiles.cloudView.filters.all') },\n                ...filterOptions.nozzles.map(n => ({ value: n, label: n })),\n              ]}\n              onChange={setFilterNozzle}\n            />\n          )}\n\n          {filterOptions.filaments.length > 0 && (filterType === 'all' || filterType === 'filament') && (\n            <FilterDropdown\n              label={t('profiles.cloudView.filters.filament')}\n              value={filterFilament}\n              options={[\n                { value: 'all', label: t('profiles.cloudView.filters.all') },\n                ...filterOptions.filaments.map(f => ({ value: f, label: f })),\n              ]}\n              onChange={setFilterFilament}\n            />\n          )}\n\n          {filterOptions.layerHeights.length > 0 && (filterType === 'all' || filterType === 'process') && (\n            <FilterDropdown\n              label={t('profiles.cloudView.filters.layer')}\n              value={filterLayerHeight}\n              options={[\n                { value: 'all', label: t('profiles.cloudView.filters.all') },\n                ...filterOptions.layerHeights.map(l => ({ value: l, label: l })),\n              ]}\n              onChange={setFilterLayerHeight}\n            />\n          )}\n\n          {hasActiveFilters && (\n            <button\n              onClick={clearFilters}\n              className=\"px-3 py-2 text-sm text-bambu-gray hover:text-white transition-colors\"\n            >\n              {t('profiles.cloudView.clearFilters')}\n            </button>\n          )}\n        </div>\n      </div>\n\n      {/* Compare mode bar */}\n      {compareMode && (\n        <div className=\"mb-4 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-4\">\n              <GitCompare className=\"w-5 h-5 text-blue-400\" />\n              <span className=\"text-white font-medium\">{t('profiles.cloudView.compareMode')}</span>\n              <span className=\"text-bambu-gray\">\n                {compareSelection[0]\n                  ? t('profiles.cloudView.selectAnotherPreset', { type: compareSelection[0].type })\n                  : t('profiles.cloudView.clickTwoPresets')}\n              </span>\n            </div>\n            <div className=\"flex items-center gap-3\">\n              <div className=\"flex items-center gap-2\">\n                <span className={`px-2 py-1 text-sm rounded truncate max-w-[200px] ${compareSelection[0] ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-bambu-dark text-bambu-gray'}`}>\n                  {compareSelection[0] ? compareSelection[0].name : t('profiles.cloudView.selectFirst')}\n                </span>\n                <ArrowRight className=\"w-4 h-4 text-bambu-gray\" />\n                <span className={`px-2 py-1 text-sm rounded truncate max-w-[200px] ${compareSelection[1] ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-bambu-dark text-bambu-gray'}`}>\n                  {compareSelection[1] ? compareSelection[1].name : t('profiles.cloudView.selectSecond')}\n                </span>\n              </div>\n              {compareSelection[0] && compareSelection[1] && (\n                <Button\n                  size=\"sm\"\n                  onClick={async () => {\n                    try {\n                      const [left, right] = await Promise.all([\n                        api.getCloudSettingDetail(compareSelection[0]!.setting_id),\n                        api.getCloudSettingDetail(compareSelection[1]!.setting_id),\n                      ]);\n                      setComparePresets([\n                        (left.setting || {}) as Record<string, unknown>,\n                        (right.setting || {}) as Record<string, unknown>,\n                      ]);\n                      setShowCompareModal(true);\n                    } catch {\n                      // Handle error silently\n                    }\n                  }}\n                >\n                  <GitCompare className=\"w-4 h-4\" />\n                  {t('profiles.cloudView.compareNow')}\n                </Button>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Status row: sync time, count, and legend */}\n      <div className=\"flex flex-wrap items-center gap-4 mb-4 text-sm text-bambu-gray\">\n        {lastSyncTime && (\n          <div className=\"flex items-center gap-1\">\n            <Clock className=\"w-3 h-3\" />\n            {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}\n          </div>\n        )}\n        <span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>\n        <div className=\"flex items-center gap-1\">\n          <span className=\"w-1.5 h-1.5 rounded-full bg-bambu-green\" />\n          <span>= {t('profiles.presets.myPreset')}</span>\n        </div>\n      </div>\n\n      {/* 3-Column Presets List */}\n      {filteredPresets.length === 0 ? (\n        <div className=\"text-center py-16\">\n          <Layers className=\"w-12 h-12 text-bambu-gray-dark mx-auto mb-4\" />\n          <p className=\"text-bambu-gray\">{t('profiles.cloudView.noPresetsFound')}</p>\n          {hasActiveFilters && (\n            <button onClick={clearFilters} className=\"mt-2 text-sm text-bambu-green hover:text-bambu-green-light\">\n              {t('profiles.cloudView.clearFilters')}\n            </button>\n          )}\n        </div>\n      ) : (\n        <div className=\"grid grid-cols-1 md:grid-cols-3 gap-6\">\n          {/* Filament Column */}\n          <div>\n            <div className=\"flex items-center gap-2 mb-3 px-1\">\n              <Droplet className=\"w-4 h-4 text-amber-400\" />\n              <h3 className=\"text-sm font-medium text-bambu-gray\">{t('profiles.cloudView.columns.filament')}</h3>\n              <span className=\"text-xs text-bambu-gray-dark\">\n                ({filteredPresets.filter(p => p.type === 'filament').length})\n              </span>\n            </div>\n            <div className=\"space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1\">\n              {filteredPresets\n                .filter(p => p.type === 'filament')\n                .map((preset) => (\n                  <PresetListItem\n                    key={preset.setting_id}\n                    setting={preset}\n                    onClick={() => handlePresetClick(preset)}\n                    onDuplicate={() => handleDuplicate(preset)}\n                    compareMode={compareMode}\n                    isCompareSelected={getCompareIndex(preset) !== undefined}\n                    compareIndex={getCompareIndex(preset)}\n                    compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}\n                    t={t}\n                  />\n                ))}\n              {filteredPresets.filter(p => p.type === 'filament').length === 0 && (\n                <p className=\"text-xs text-bambu-gray-dark px-3 py-2\">{t('profiles.cloudView.noFilamentPresets')}</p>\n              )}\n            </div>\n          </div>\n\n          {/* Process Column */}\n          <div>\n            <div className=\"flex items-center gap-2 mb-3 px-1\">\n              <Settings2 className=\"w-4 h-4 text-blue-400\" />\n              <h3 className=\"text-sm font-medium text-bambu-gray\">{t('profiles.cloudView.columns.process')}</h3>\n              <span className=\"text-xs text-bambu-gray-dark\">\n                ({filteredPresets.filter(p => p.type === 'process').length})\n              </span>\n            </div>\n            <div className=\"space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1\">\n              {filteredPresets\n                .filter(p => p.type === 'process')\n                .map((preset) => (\n                  <PresetListItem\n                    key={preset.setting_id}\n                    setting={preset}\n                    onClick={() => handlePresetClick(preset)}\n                    onDuplicate={() => handleDuplicate(preset)}\n                    compareMode={compareMode}\n                    isCompareSelected={getCompareIndex(preset) !== undefined}\n                    compareIndex={getCompareIndex(preset)}\n                    compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}\n                    t={t}\n                  />\n                ))}\n              {filteredPresets.filter(p => p.type === 'process').length === 0 && (\n                <p className=\"text-xs text-bambu-gray-dark px-3 py-2\">{t('profiles.cloudView.noProcessPresets')}</p>\n              )}\n            </div>\n          </div>\n\n          {/* Printer Column */}\n          <div>\n            <div className=\"flex items-center gap-2 mb-3 px-1\">\n              <PrinterIcon className=\"w-4 h-4 text-purple-400\" />\n              <h3 className=\"text-sm font-medium text-bambu-gray\">{t('profiles.cloudView.columns.printer')}</h3>\n              <span className=\"text-xs text-bambu-gray-dark\">\n                ({filteredPresets.filter(p => p.type === 'printer').length})\n              </span>\n            </div>\n            <div className=\"space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1\">\n              {filteredPresets\n                .filter(p => p.type === 'printer')\n                .map((preset) => (\n                  <PresetListItem\n                    key={preset.setting_id}\n                    setting={preset}\n                    onClick={() => handlePresetClick(preset)}\n                    onDuplicate={() => handleDuplicate(preset)}\n                    compareMode={compareMode}\n                    isCompareSelected={getCompareIndex(preset) !== undefined}\n                    compareIndex={getCompareIndex(preset)}\n                    compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}\n                    t={t}\n                  />\n                ))}\n              {filteredPresets.filter(p => p.type === 'printer').length === 0 && (\n                <p className=\"text-xs text-bambu-gray-dark px-3 py-2\">{t('profiles.cloudView.noPrinterPresets')}</p>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Modals */}\n      {selectedSetting && (\n        <PresetDetailModal\n          setting={selectedSetting}\n          onClose={() => setSelectedSetting(null)}\n          onDeleted={() => setSelectedSetting(null)}\n          onDuplicate={() => handleDuplicate(selectedSetting)}\n          onEdit={() => handleEdit(selectedSetting)}\n          hasPermission={hasPermission}\n          t={t}\n        />\n      )}\n\n      {(showCreateModal || duplicateData || editData || templateData) && (\n        <CreatePresetModal\n          onClose={() => { setShowCreateModal(false); setDuplicateData(null); setEditData(null); setTemplateData(null); }}\n          initialData={editData || duplicateData || (templateData ? { type: templateData.type, name: '', base_id: '', setting: templateData.setting } : undefined)}\n          allPresets={settings}\n          t={t}\n        />\n      )}\n\n      {showTemplatesModal && (\n        <TemplatesModal\n          onClose={() => setShowTemplatesModal(false)}\n          onApply={(template) => {\n            setTemplateData({ type: template.type, setting: template.settings });\n            setShowTemplatesModal(false);\n          }}\n          t={t}\n        />\n      )}\n\n      {showCompareModal && comparePresets && compareSelection[0] && compareSelection[1] && (\n        <DiffModal\n          onClose={() => {\n            setShowCompareModal(false);\n            setComparePresets(null);\n          }}\n          leftPreset={comparePresets[0]}\n          rightPreset={comparePresets[1]}\n          leftLabel={compareSelection[0].name}\n          rightLabel={compareSelection[1].name}\n          t={t}\n        />\n      )}\n    </>\n  );\n}\n\n// ============================================================================\n// MAIN PAGE\n// ============================================================================\n\nexport function ProfilesPage() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');\n  const [lastSyncTime, setLastSyncTime] = useState<Date>();\n\n  const { data: status, isLoading: statusLoading } = useQuery({\n    queryKey: ['cloudStatus'],\n    queryFn: api.getCloudStatus,\n  });\n\n  const { data: printers = [] } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const { data: settings, isLoading: settingsLoading, refetch: refetchSettings, dataUpdatedAt } = useQuery({\n    queryKey: ['cloudSettings'],\n    queryFn: () => api.getCloudSettings(),\n    enabled: !!status?.is_authenticated,\n    retry: false,\n    staleTime: 1000 * 60 * 5,\n  });\n\n  useEffect(() => {\n    if (dataUpdatedAt) {\n      setLastSyncTime(new Date(dataUpdatedAt));\n    }\n  }, [dataUpdatedAt]);\n\n  const logoutMutation = useMutation({\n    mutationFn: api.cloudLogout,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });\n      queryClient.removeQueries({ queryKey: ['cloudSettings'] });\n      showToast(t('profiles.toast.loggedOut'));\n    },\n  });\n\n  const handleLoginSuccess = () => {\n    queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });\n  };\n\n  if (statusLoading) {\n    return (\n      <div className=\"p-4 md:p-8 flex items-center justify-center min-h-[400px]\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"p-6 lg:p-8\">\n      {/* Page Header */}\n      <div className=\"mb-6\">\n        <h1 className=\"text-2xl font-bold text-white\">{t('profiles.title')}</h1>\n        <p className=\"text-bambu-gray\">{t('profiles.subtitle')}</p>\n      </div>\n\n      {/* Tab Navigation */}\n      <div className=\"flex border-b border-bambu-dark-tertiary mb-6\">\n        <button\n          onClick={() => setActiveTab('cloud')}\n          className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${\n            activeTab === 'cloud'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-white border-transparent'\n          }`}\n        >\n          <Cloud className=\"w-4 h-4\" />\n          {t('profiles.tabs.cloud')}\n        </button>\n        <button\n          onClick={() => setActiveTab('local')}\n          className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${\n            activeTab === 'local'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-white border-transparent'\n          }`}\n        >\n          <HardDrive className=\"w-4 h-4\" />\n          {t('profiles.tabs.local')}\n        </button>\n        <button\n          onClick={() => setActiveTab('kprofiles')}\n          className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${\n            activeTab === 'kprofiles'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-white border-transparent'\n          }`}\n        >\n          <Gauge className=\"w-4 h-4\" />\n          {t('profiles.tabs.kprofiles')}\n        </button>\n      </div>\n\n      {/* Cloud Profiles Tab */}\n      {activeTab === 'cloud' && (\n        <>\n          {/* Connection Status Bar */}\n          {status?.is_authenticated && (\n            <div className=\"flex items-center justify-between p-3 mb-6 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\">\n              <div className=\"flex items-center gap-3\">\n                <div className=\"w-2 h-2 rounded-full bg-bambu-green animate-pulse\" />\n                <span className=\"text-sm text-bambu-gray\">\n                  {t('profiles.connectedAs')} <span className=\"text-white\">{status.email}</span>\n                  {status.region && (\n                    <span className=\"ml-2 px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray\">\n                      {status.region === 'china'\n                        ? t('profiles.login.regionChina')\n                        : t('profiles.login.regionGlobal')}\n                    </span>\n                  )}\n                </span>\n              </div>\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={() => logoutMutation.mutate()}\n                disabled={logoutMutation.isPending || !hasPermission('cloud:auth')}\n                title={!hasPermission('cloud:auth') ? t('profiles.noLogoutPermission') : undefined}\n              >\n                <LogOut className=\"w-4 h-4\" />\n                {t('profiles.logout')}\n              </Button>\n            </div>\n          )}\n\n          {!status?.is_authenticated ? (\n            <LoginForm onSuccess={handleLoginSuccess} t={t} />\n          ) : settingsLoading ? (\n            <div className=\"flex items-center justify-center py-16\">\n              <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n            </div>\n          ) : settings ? (\n            <CloudProfilesView\n              settings={settings}\n              lastSyncTime={lastSyncTime}\n              onRefresh={() => refetchSettings()}\n              isRefreshing={settingsLoading}\n              printers={printers}\n              hasPermission={hasPermission}\n              t={t}\n            />\n          ) : (\n            <div className=\"text-center py-16\">\n              <p className=\"text-bambu-gray mb-4\">{t('profiles.failedToLoad')}</p>\n              <Button onClick={() => refetchSettings()}>{t('profiles.retry')}</Button>\n            </div>\n          )}\n        </>\n      )}\n\n      {/* Local Profiles Tab */}\n      {activeTab === 'local' && <LocalProfilesView />}\n\n      {/* K-Profiles Tab */}\n      {activeTab === 'kprofiles' && <KProfilesView />}\n\n      {/* Scroll to Top Button */}\n      <ScrollToTop />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/ProjectDetailPage.tsx",
    "content": "import { useState, useMemo } from 'react';\nimport DOMPurify from 'dompurify';\nimport { useParams, useNavigate, Link } from 'react-router-dom';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  ArrowLeft,\n  Edit3,\n  Loader2,\n  Package,\n  Clock,\n  CheckCircle,\n  XCircle,\n  ListTodo,\n  Printer,\n  ChevronRight,\n  FileText,\n  Tag,\n  Calendar,\n  AlertTriangle,\n  Save,\n  X,\n  Trash2,\n  Plus,\n  History,\n  FolderTree,\n  Copy,\n  Layers,\n  ExternalLink,\n  ShoppingCart,\n  FolderOpen,\n  Download,\n  Pencil,\n  Play,\n  CalendarPlus,\n  FileBox,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport { parseUTCDate, formatDateOnly, formatDateTime, formatDurationFromHours, type TimeFormat } from '../utils/date';\nimport type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate, LibraryFileListItem } from '../api/client';\nimport { Card, CardContent } from '../components/Card';\nimport { Button } from '../components/Button';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { RichTextEditor } from '../components/RichTextEditor';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { PrintModal } from '../components/PrintModal';\n\n// Project edit modal (reused from ProjectsPage)\nimport { ProjectModal } from './ProjectsPage';\nimport { getCurrencySymbol } from '../utils/currency';\n\n// Returns true for sliced (printable) files: .gcode and .gcode.3mf\nfunction isSlicedFilename(filename: string): boolean {\n  const lower = filename.toLowerCase();\n  return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');\n}\n\nfunction formatFilament(grams: number): string {\n  if (grams >= 1000) {\n    return `${(grams / 1000).toFixed(2)}kg`;\n  }\n  return `${Math.round(grams)}g`;\n}\n\ntype TFunction = (key: string, options?: Record<string, unknown>) => string;\n\nfunction StatusBadge({ status, t }: { status: string; t: TFunction }) {\n  const colors = {\n    active: 'bg-bambu-green/20 text-bambu-green',\n    completed: 'bg-blue-500/20 text-blue-400',\n    archived: 'bg-bambu-gray/20 text-bambu-gray',\n  };\n  const color = colors[status as keyof typeof colors] || colors.active;\n\n  const labels: Record<string, string> = {\n    active: t('projectDetail.status.active'),\n    completed: t('projectDetail.status.completed'),\n    archived: t('projectDetail.status.archived'),\n  };\n\n  return (\n    <span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>\n      {labels[status] || status.charAt(0).toUpperCase() + status.slice(1)}\n    </span>\n  );\n}\n\nfunction StatCard({\n  icon: Icon,\n  label,\n  value,\n  subValue,\n  hint,\n  color = 'text-bambu-gray',\n}: {\n  icon: React.ElementType;\n  label: string;\n  value: string | number;\n  subValue?: string;\n  hint?: string;\n  color?: string;\n}) {\n  return (\n    <Card>\n      <CardContent className=\"p-4\">\n        <div className=\"flex items-center gap-3\" title={hint}>\n          <div className={`p-2 rounded-lg bg-bambu-dark ${color}`}>\n            <Icon className=\"w-5 h-5\" />\n          </div>\n          <div>\n            <p className=\"text-sm text-bambu-gray\">{label}</p>\n            <p className=\"text-xl font-semibold text-white\">{value}</p>\n            {subValue && <p className=\"text-xs text-bambu-gray/70\">{subValue}</p>}\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\nfunction ArchiveGrid({ archives, t }: { archives: Archive[]; t: TFunction }) {\n  if (archives.length === 0) {\n    return (\n      <div className=\"text-center py-8 text-bambu-gray\">\n        <Package className=\"w-12 h-12 mx-auto mb-2 opacity-50\" />\n        <p>{t('projectDetail.noPrints')}</p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3\">\n      {archives.map((archive) => (\n        <Link\n          key={archive.id}\n          to={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}\n          className=\"group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors\"\n        >\n          {archive.thumbnail_path ? (\n            <img\n              src={api.getArchiveThumbnail(archive.id)}\n              alt={archive.print_name || 'Print'}\n              className=\"w-full h-full object-cover\"\n            />\n          ) : (\n            <div className=\"w-full h-full flex items-center justify-center text-bambu-gray\">\n              <Package className=\"w-8 h-8\" />\n            </div>\n          )}\n\n          {/* Status overlay */}\n          {archive.status === 'failed' && (\n            <div className=\"absolute inset-0 bg-red-500/30 flex items-center justify-center\">\n              <XCircle className=\"w-8 h-8 text-white\" />\n            </div>\n          )}\n          {archive.status === 'completed' && (\n            <div className=\"absolute top-1 right-1\">\n              <CheckCircle className=\"w-4 h-4 text-bambu-green\" />\n            </div>\n          )}\n\n          {/* Name overlay on hover */}\n          <div className=\"absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity\">\n            <p className=\"text-xs text-white truncate\">{archive.print_name || 'Unknown'}</p>\n          </div>\n        </Link>\n      ))}\n    </div>\n  );\n}\n\nfunction PriorityBadge({ priority, t }: { priority: string; t: TFunction }) {\n  const config = {\n    low: { color: 'bg-gray-500/20 text-gray-400', label: t('projectDetail.priority.low') },\n    normal: { color: 'bg-blue-500/20 text-blue-400', label: t('projectDetail.priority.normal') },\n    high: { color: 'bg-orange-500/20 text-orange-400', label: t('projectDetail.priority.high') },\n    urgent: { color: 'bg-red-500/20 text-red-400', label: t('projectDetail.priority.urgent') },\n  };\n  const { color, label } = config[priority as keyof typeof config] || config.normal;\n\n  return (\n    <span className={`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 ${color}`}>\n      {priority === 'urgent' && <AlertTriangle className=\"w-3 h-3\" />}\n      {label}\n    </span>\n  );\n}\n\nfunction getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null {\n  if (!dateString) return null;\n  const dueDate = parseUTCDate(dateString);\n  if (!dueDate) return null;\n  const now = new Date();\n  const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));\n\n  if (diffDays < 0) return { color: 'text-red-400', label: t('projectDetail.dueDate.overdue') };\n  if (diffDays === 0) return { color: 'text-orange-400', label: t('projectDetail.dueDate.today') };\n  if (diffDays <= 3) return { color: 'text-yellow-400', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };\n  return { color: 'text-bambu-gray', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };\n}\n\nexport function ProjectDetailPage() {\n  const { id } = useParams<{ id: string }>();\n  const navigate = useNavigate();\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  const [showEditModal, setShowEditModal] = useState(false);\n  const [editingNotes, setEditingNotes] = useState(false);\n  const [notesContent, setNotesContent] = useState('');\n  const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);\n  const [scheduleFile, setScheduleFile] = useState<LibraryFileListItem | null>(null);\n\n  const projectId = parseInt(id || '0', 10);\n\n  const { data: project, isLoading: projectLoading, error: projectError } = useQuery({\n    queryKey: ['project', projectId],\n    queryFn: () => api.getProject(projectId),\n    enabled: projectId > 0,\n  });\n\n  const { data: archives, isLoading: archivesLoading } = useQuery({\n    queryKey: ['project-archives', projectId],\n    queryFn: () => api.getProjectArchives(projectId),\n    enabled: projectId > 0,\n  });\n\n  const { data: bomItems, isLoading: bomLoading } = useQuery({\n    queryKey: ['project-bom', projectId],\n    queryFn: () => api.getProjectBOM(projectId),\n    enabled: projectId > 0,\n  });\n\n  const { data: timeline, isLoading: timelineLoading } = useQuery({\n    queryKey: ['project-timeline', projectId],\n    queryFn: () => api.getProjectTimeline(projectId, 20),\n    enabled: projectId > 0,\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const { data: linkedFolders } = useQuery({\n    queryKey: ['project-folders', projectId],\n    queryFn: () => api.getLibraryFoldersByProject(projectId),\n    enabled: projectId > 0,\n  });\n\n  // Single bulk query — replaces the previous N+1 useQueries pattern\n  const { data: allProjectFiles, isLoading: projectFilesLoading } = useQuery({\n    queryKey: ['project-files', projectId],\n    queryFn: () => api.getLibraryFiles(null, false, projectId),\n    enabled: projectId > 0,\n  });\n\n  // Group files by folder_id for the section-based render\n  const filesByFolder = useMemo(() => {\n    const map = new Map<number, LibraryFileListItem[]>();\n    for (const file of allProjectFiles ?? []) {\n      if (file.folder_id != null) {\n        const arr = map.get(file.folder_id) ?? [];\n        arr.push(file);\n        map.set(file.folder_id, arr);\n      }\n    }\n    return map;\n  }, [allProjectFiles]);\n\n  const currency = getCurrencySymbol(settings?.currency || 'USD');\n  const timeFormat: TimeFormat = settings?.time_format || 'system';\n\n  const updateMutation = useMutation({\n    mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['project', projectId] });\n      queryClient.invalidateQueries({ queryKey: ['projects'] });\n      setShowEditModal(false);\n      setEditingNotes(false);\n      showToast(t('projectDetail.toast.projectUpdated'), 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const handleStartEditNotes = () => {\n    setNotesContent(project?.notes || '');\n    setEditingNotes(true);\n  };\n\n  const handleSaveNotes = () => {\n    updateMutation.mutate({ notes: notesContent });\n  };\n\n  const handleCancelNotes = () => {\n    setEditingNotes(false);\n    setNotesContent('');\n  };\n\n  // BOM handlers\n  const [newBomName, setNewBomName] = useState('');\n  const [newBomQty, setNewBomQty] = useState(1);\n  const [newBomPrice, setNewBomPrice] = useState('');\n  const [newBomUrl, setNewBomUrl] = useState('');\n  const [newBomRemarks, setNewBomRemarks] = useState('');\n  const [showBomForm, setShowBomForm] = useState(false);\n  const [hideBomCompleted, setHideBomCompleted] = useState(false);\n  const [editingBomItem, setEditingBomItem] = useState<BOMItem | null>(null);\n  const [editBomName, setEditBomName] = useState('');\n  const [editBomQty, setEditBomQty] = useState(1);\n  const [editBomPrice, setEditBomPrice] = useState('');\n  const [editBomUrl, setEditBomUrl] = useState('');\n  const [editBomRemarks, setEditBomRemarks] = useState('');\n\n  // Confirm modal state\n  const [confirmModal, setConfirmModal] = useState<{\n    isOpen: boolean;\n    title: string;\n    message: string;\n    onConfirm: () => void;\n  }>({ isOpen: false, title: '', message: '', onConfirm: () => {} });\n\n  const createBomMutation = useMutation({\n    mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });\n      queryClient.invalidateQueries({ queryKey: ['project', projectId] });\n      setNewBomName('');\n      setNewBomQty(1);\n      setNewBomPrice('');\n      setNewBomUrl('');\n      setNewBomRemarks('');\n      setShowBomForm(false);\n      showToast(t('projectDetail.toast.partAdded'), 'success');\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const updateBomMutation = useMutation({\n    mutationFn: ({ itemId, data }: { itemId: number; data: BOMItemUpdate }) =>\n      api.updateBOMItem(projectId, itemId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });\n      queryClient.invalidateQueries({ queryKey: ['project', projectId] });\n      setEditingBomItem(null);\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const deleteBomMutation = useMutation({\n    mutationFn: (itemId: number) => api.deleteBOMItem(projectId, itemId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });\n      queryClient.invalidateQueries({ queryKey: ['project', projectId] });\n      showToast(t('projectDetail.toast.partRemoved'), 'success');\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const handleAddBomItem = (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!newBomName.trim()) return;\n    createBomMutation.mutate({\n      name: newBomName.trim(),\n      quantity_needed: newBomQty,\n      unit_price: newBomPrice ? parseFloat(newBomPrice) : undefined,\n      sourcing_url: newBomUrl.trim() || undefined,\n      remarks: newBomRemarks.trim() || undefined,\n    });\n  };\n\n  const handleToggleAcquired = (item: BOMItem) => {\n    const newQty = item.is_complete ? 0 : item.quantity_needed;\n    updateBomMutation.mutate({\n      itemId: item.id,\n      data: { quantity_acquired: newQty },\n    });\n  };\n\n  const handleDeleteBomItem = (itemId: number, itemName: string) => {\n    setConfirmModal({\n      isOpen: true,\n      title: t('projectDetail.bom.deletePart'),\n      message: t('projectDetail.bom.deleteConfirm', { name: itemName }),\n      onConfirm: () => {\n        setConfirmModal(prev => ({ ...prev, isOpen: false }));\n        deleteBomMutation.mutate(itemId);\n      },\n    });\n  };\n\n  const handleEditBomItem = (item: BOMItem) => {\n    setEditingBomItem(item);\n    setEditBomName(item.name);\n    setEditBomQty(item.quantity_needed);\n    setEditBomPrice(item.unit_price?.toString() || '');\n    setEditBomUrl(item.sourcing_url || '');\n    setEditBomRemarks(item.remarks || '');\n  };\n\n  const handleSaveBomEdit = (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!editingBomItem || !editBomName.trim()) return;\n    updateBomMutation.mutate({\n      itemId: editingBomItem.id,\n      data: {\n        name: editBomName.trim(),\n        quantity_needed: editBomQty,\n        unit_price: editBomPrice ? parseFloat(editBomPrice) : undefined,\n        sourcing_url: editBomUrl.trim() || undefined,\n        remarks: editBomRemarks.trim() || undefined,\n      },\n    });\n  };\n\n  const handleCancelBomEdit = () => {\n    setEditingBomItem(null);\n  };\n\n  const handleExportProject = async () => {\n    try {\n      const { blob, filename } = await api.exportProjectZip(Number(projectId));\n      const url = URL.createObjectURL(blob);\n      const a = document.createElement('a');\n      a.href = url;\n      a.download = filename || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;\n      a.click();\n      URL.revokeObjectURL(url);\n      showToast(t('projectDetail.toast.projectExported'), 'success');\n    } catch (error) {\n      showToast((error as Error).message, 'error');\n    }\n  };\n\n  // Template handlers\n  const createTemplateMutation = useMutation({\n    mutationFn: () => api.createTemplateFromProject(projectId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['projects'] });\n      showToast(t('projectDetail.toast.templateCreated'), 'success');\n    },\n    onError: (error: Error) => showToast(error.message, 'error'),\n  });\n\n  const formatTimelineDate = (timestamp: string) => {\n    return formatDateTime(timestamp, timeFormat, {\n      month: 'short',\n      day: 'numeric',\n      hour: '2-digit',\n      minute: '2-digit',\n    });\n  };\n\n  if (projectLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-24\">\n        <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green\" />\n      </div>\n    );\n  }\n\n  if (projectError || !project) {\n    return (\n      <div className=\"text-center py-24\">\n        <p className=\"text-bambu-gray\">\n          {projectError ? `${t('common.error')}: ${(projectError as Error).message}` : t('projectDetail.notFound')}\n        </p>\n        <Button variant=\"secondary\" className=\"mt-4\" onClick={() => navigate('/projects')}>\n          {t('projectDetail.backToProjects')}\n        </Button>\n      </div>\n    );\n  }\n\n  const stats = project.stats;\n  // Plates progress: total_archives / target_count\n  const platesProgressPercent = stats?.progress_percent ?? 0;\n  // Parts progress: completed_prints / target_parts_count\n  const partsProgressPercent = stats?.parts_progress_percent ?? 0;\n\n  return (\n    <div className=\"p-4 md:p-8 space-y-8\">\n      {/* Breadcrumb */}\n      <div className=\"flex items-center gap-2 text-sm text-bambu-gray\">\n        <Link to=\"/projects\" className=\"hover:text-white transition-colors\">\n          {t('nav.projects')}\n        </Link>\n        <ChevronRight className=\"w-4 h-4\" />\n        <span className=\"text-white\">{project.name}</span>\n      </div>\n\n      {/* Header */}\n      <div className=\"flex items-start justify-between\">\n        <div className=\"flex items-center gap-4\">\n          <button\n            onClick={() => navigate('/projects')}\n            className=\"p-2 rounded-lg bg-bambu-card hover:bg-bambu-dark-tertiary transition-colors\"\n          >\n            <ArrowLeft className=\"w-5 h-5 text-bambu-gray\" />\n          </button>\n          <div className=\"flex items-center gap-3\">\n            <div\n              className=\"w-4 h-4 rounded-full shrink-0\"\n              style={{ backgroundColor: project.color || '#6b7280' }}\n            />\n            <div>\n              <h1 className=\"text-2xl font-bold text-white\">{project.name}</h1>\n              {project.description && (\n                <p className=\"text-bambu-gray mt-1\">{project.description}</p>\n              )}\n            </div>\n          </div>\n          <StatusBadge status={project.status} t={t} />\n        </div>\n        <div className=\"flex gap-2\">\n          <Button\n            variant=\"secondary\"\n            onClick={handleExportProject}\n            disabled={!hasPermission('projects:read')}\n            title={!hasPermission('projects:read') ? t('projectDetail.noExportPermission') : t('projectDetail.exportProject')}\n          >\n            <Download className=\"w-4 h-4 mr-2\" />\n            {t('projectDetail.export')}\n          </Button>\n          <Button\n            onClick={() => setShowEditModal(true)}\n            disabled={!hasPermission('projects:update')}\n            title={!hasPermission('projects:update') ? t('projectDetail.noEditPermission') : undefined}\n          >\n            <Edit3 className=\"w-4 h-4 mr-2\" />\n            {t('common.edit')}\n          </Button>\n        </div>\n      </div>\n\n      {/* Progress bars (if targets set) */}\n      {(project.target_count || project.target_parts_count) && (\n        <Card>\n          <CardContent className=\"p-4 space-y-4\">\n            {/* Plates progress */}\n            {project.target_count && (\n              <div>\n                <div className=\"flex items-center justify-between mb-2\">\n                  <span className=\"text-sm text-bambu-gray\">{t('projectDetail.progress.platesProgress')}</span>\n                  <span className=\"text-sm font-medium text-white\">\n                    {stats?.total_archives || 0} / {project.target_count} {t('projectDetail.progress.printJobs')}\n                  </span>\n                </div>\n                <div className=\"h-3 bg-bambu-dark rounded-full overflow-hidden\">\n                  <div\n                    className=\"h-full transition-all duration-500\"\n                    style={{\n                      width: `${Math.min(platesProgressPercent, 100)}%`,\n                      backgroundColor: platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',\n                    }}\n                  />\n                </div>\n                <div className=\"flex justify-between mt-1\">\n                  <span className=\"text-xs text-bambu-gray/70\">\n                    {t('projectDetail.progress.percentComplete', { percent: platesProgressPercent.toFixed(0) })}\n                  </span>\n                  {stats?.remaining_prints != null && stats.remaining_prints > 0 && (\n                    <span className=\"text-xs text-bambu-gray/70\">\n                      {t('projectDetail.progress.remaining', { count: stats.remaining_prints })}\n                    </span>\n                  )}\n                </div>\n              </div>\n            )}\n            {/* Parts progress */}\n            {project.target_parts_count && (\n              <div>\n                <div className=\"flex items-center justify-between mb-2\">\n                  <span className=\"text-sm text-bambu-gray\">{t('projectDetail.progress.partsProgress')}</span>\n                  <span className=\"text-sm font-medium text-white\">\n                    {stats?.completed_prints || 0} / {project.target_parts_count} {t('projectDetail.progress.parts')}\n                  </span>\n                </div>\n                <div className=\"h-3 bg-bambu-dark rounded-full overflow-hidden\">\n                  <div\n                    className=\"h-full transition-all duration-500\"\n                    style={{\n                      width: `${Math.min(partsProgressPercent, 100)}%`,\n                      backgroundColor: partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',\n                    }}\n                  />\n                </div>\n                <div className=\"flex justify-between mt-1\">\n                  <span className=\"text-xs text-bambu-gray/70\">\n                    {t('projectDetail.progress.percentComplete', { percent: partsProgressPercent.toFixed(0) })}\n                  </span>\n                  {stats?.remaining_parts != null && stats.remaining_parts > 0 && (\n                    <span className=\"text-xs text-bambu-gray/70\">\n                      {t('projectDetail.progress.remaining', { count: stats.remaining_parts })}\n                    </span>\n                  )}\n                </div>\n              </div>\n            )}\n          </CardContent>\n        </Card>\n      )}\n\n      {/* Stats grid */}\n      {stats && (\n        <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n          <Card>\n            <CardContent className=\"p-4\">\n              <div className=\"flex items-center gap-3\">\n                <div className=\"p-2 rounded-lg bg-bambu-dark text-bambu-green\">\n                  <Package className=\"w-5 h-5\" />\n                </div>\n                <div>\n                  <p className=\"text-sm text-bambu-gray\">{t('projectDetail.stats.printJobs')}</p>\n                  <p className=\"text-xl font-semibold text-white\">{stats.total_archives} <span className=\"text-sm font-normal text-bambu-gray\">{t('projectDetail.stats.total')}</span></p>\n                  {stats.failed_prints > 0 && (\n                    <p className=\"text-sm text-status-error\">{t('projectDetail.stats.failed', { count: stats.failed_prints })}</p>\n                  )}\n                  <p className=\"text-sm text-bambu-gray\">{t('projectDetail.stats.partsPrinted', { count: stats.completed_prints })}</p>\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n          <StatCard\n            icon={Clock}\n            label={t('projectDetail.stats.printTime')}\n            value={formatDurationFromHours(stats.total_print_time_hours)}\n            color=\"text-yellow-400\"\n          />\n          <StatCard\n            icon={Printer}\n            label={t('projectDetail.stats.filamentUsed')}\n            value={formatFilament(stats.total_filament_grams)}\n            color=\"text-purple-400\"\n          />\n        </div>\n      )}\n\n      {/* Cost tracking */}\n      {stats && (() => {\n        const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;\n        return (stats.estimated_cost > 0 || totalCost > 0 || project.budget !== null);\n      })() && (\n        <Card>\n          <CardContent className=\"p-4\">\n            <h2 className=\"text-lg font-semibold text-white mb-3\">\n              {t('projectDetail.cost.title')}\n            </h2>\n            <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n              <div>\n                <p className=\"text-xs text-bambu-gray uppercase\">{t('projectDetail.cost.filamentCost')}</p>\n                <p className=\"text-lg font-semibold text-white\">\n                  {currency}{stats.estimated_cost.toFixed(2)}\n                </p>\n              </div>\n              {stats.total_energy_kwh > 0 && (\n                <div>\n                  <p className=\"text-xs text-bambu-gray uppercase\">{t('projectDetail.cost.energy')}</p>\n                  <p className=\"text-lg font-semibold text-white\">\n                    {stats.total_energy_kwh.toFixed(3)} kWh\n                    {stats.total_energy_cost > 0 && (\n                      <span className=\"text-sm text-bambu-gray ml-1\">\n                        ({currency}{stats.total_energy_cost.toFixed(2)})\n                      </span>\n                    )}\n                  </p>\n                </div>\n              )}\n              {(() => {\n                const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;\n                if (totalCost <= 0) return null;\n                return (\n                  <div>\n                    <p className=\"text-xs text-bambu-gray uppercase\">{t('projectDetail.cost.totalCost')}</p>\n                    <p className=\"text-lg font-semibold text-bambu-green\">\n                      {currency}{totalCost.toFixed(2)}\n                    </p>\n                    {stats.bom_cost > 0 && (\n                      <p className=\"text-xs text-bambu-gray/70\">{t('projectDetail.cost.includesBom')}</p>\n                    )}\n                  </div>\n                );\n              })()}\n              {project.budget !== null && (() => {\n                const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;\n                const remaining = project.budget - totalCost;\n                return (\n                  <div>\n                    <p className=\"text-xs text-bambu-gray uppercase\">{t('projectDetail.cost.budget')}</p>\n                    <p className=\"text-sm text-bambu-gray\">\n                      {t('projectDetail.cost.total')}: <span className=\"text-white font-semibold\">{currency}{project.budget.toFixed(2)}</span>\n                    </p>\n                    <p className={`text-sm ${remaining >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>\n                      {t('projectDetail.cost.remaining')}: <span className=\"font-semibold\">{currency}{remaining.toFixed(2)}</span>\n                    </p>\n                  </div>\n                );\n              })()}\n            </div>\n          </CardContent>\n        </Card>\n      )}\n\n      {/* Sub-projects */}\n      {project.children && project.children.length > 0 && (\n        <Card>\n          <CardContent className=\"p-4\">\n            <h2 className=\"text-lg font-semibold text-white flex items-center gap-2 mb-3\">\n              <FolderTree className=\"w-5 h-5\" />\n              {t('projectDetail.subProjects.title', { count: project.children.length })}\n            </h2>\n            <div className=\"space-y-2\">\n              {project.children.map((child) => (\n                <Link\n                  key={child.id}\n                  to={`/projects/${child.id}`}\n                  className=\"flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors\"\n                >\n                  <div className=\"flex items-center gap-3\">\n                    <div\n                      className=\"w-3 h-3 rounded-full\"\n                      style={{ backgroundColor: child.color || '#6b7280' }}\n                    />\n                    <span className=\"text-white\">{child.name}</span>\n                    <span className={`text-xs px-2 py-0.5 rounded ${\n                      child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :\n                      child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :\n                      'bg-blue-500/20 text-blue-400'\n                    }`}>\n                      {child.status}\n                    </span>\n                  </div>\n                  {child.progress_percent !== null && (\n                    <span className=\"text-sm text-bambu-gray\">\n                      {child.progress_percent.toFixed(0)}%\n                    </span>\n                  )}\n                </Link>\n              ))}\n            </div>\n          </CardContent>\n        </Card>\n      )}\n\n      {/* Parent project link */}\n      {project.parent_id && project.parent_name && (\n        <div className=\"flex items-center gap-2 text-sm\">\n          <Layers className=\"w-4 h-4 text-bambu-gray\" />\n          <span className=\"text-bambu-gray\">{t('projectDetail.partOf')}</span>\n          <Link\n            to={`/projects/${project.parent_id}`}\n            className=\"text-bambu-green hover:underline\"\n          >\n            {project.parent_name}\n          </Link>\n        </div>\n      )}\n\n      {/* Meta info row - Tags, Due Date, Priority */}\n      {(project.tags || project.due_date || project.priority !== 'normal') && (\n        <div className=\"flex flex-wrap items-center gap-4\">\n          {/* Priority */}\n          {project.priority && project.priority !== 'normal' && (\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-xs text-bambu-gray uppercase\">{t('projectDetail.priorityLabel')}</span>\n              <PriorityBadge priority={project.priority} t={t} />\n            </div>\n          )}\n\n          {/* Due Date */}\n          {project.due_date && (\n            <div className=\"flex items-center gap-2\">\n              <Calendar className=\"w-4 h-4 text-bambu-gray\" />\n              <span className=\"text-sm text-white\">{formatDateOnly(project.due_date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>\n              {getDueDateStatus(project.due_date, t) && (\n                <span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>\n                  ({getDueDateStatus(project.due_date, t)!.label})\n                </span>\n              )}\n            </div>\n          )}\n\n          {/* Tags */}\n          {project.tags && (\n            <div className=\"flex items-center gap-2\">\n              <Tag className=\"w-4 h-4 text-bambu-gray\" />\n              <div className=\"flex flex-wrap gap-1\">\n                {project.tags.split(',').map((tag, index) => (\n                  <span\n                    key={index}\n                    className=\"px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded\"\n                  >\n                    {tag.trim()}\n                  </span>\n                ))}\n              </div>\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Notes section */}\n      <Card>\n        <CardContent className=\"p-4\">\n          <div className=\"flex items-center justify-between mb-3\">\n            <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n              <FileText className=\"w-5 h-5\" />\n              {t('projectDetail.notes.title')}\n            </h2>\n            {!editingNotes ? (\n              <Button\n                variant=\"secondary\"\n                size=\"sm\"\n                onClick={handleStartEditNotes}\n                disabled={!hasPermission('projects:update')}\n                title={!hasPermission('projects:update') ? t('projectDetail.notes.noEditPermission') : undefined}\n              >\n                <Edit3 className=\"w-4 h-4 mr-1\" />\n                {t('common.edit')}\n              </Button>\n            ) : (\n              <div className=\"flex gap-2\">\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={handleCancelNotes}\n                  disabled={updateMutation.isPending}\n                >\n                  <X className=\"w-4 h-4 mr-1\" />\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  size=\"sm\"\n                  onClick={handleSaveNotes}\n                  disabled={updateMutation.isPending}\n                >\n                  {updateMutation.isPending ? (\n                    <Loader2 className=\"w-4 h-4 animate-spin mr-1\" />\n                  ) : (\n                    <Save className=\"w-4 h-4 mr-1\" />\n                  )}\n                  {t('common.save')}\n                </Button>\n              </div>\n            )}\n          </div>\n\n          {editingNotes ? (\n            <RichTextEditor\n              content={notesContent}\n              onChange={setNotesContent}\n              placeholder={t('projectDetail.notes.placeholder')}\n            />\n          ) : project.notes ? (\n            <div\n              className=\"prose prose-invert prose-sm max-w-none\"\n              dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(project.notes) }}\n            />\n          ) : (\n            <p className=\"text-bambu-gray/70 text-sm italic\">\n              {t('projectDetail.notes.empty')}\n            </p>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Files section - linked folders from File Manager with printable files */}\n      <Card>\n        <CardContent className=\"p-4\">\n          <div className=\"flex items-center justify-between mb-3\">\n            <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n              <FolderOpen className=\"w-5 h-5\" />\n              {t('projectDetail.files.title')}\n            </h2>\n          </div>\n\n          <p className=\"text-xs text-bambu-gray mb-3\">\n            <Link to=\"/files\" className=\"text-bambu-green hover:underline\">\n              {t('projectDetail.files.linkFolders')}\n            </Link>\n            {' '}{t('projectDetail.files.forQuickAccess')}\n          </p>\n\n          {linkedFolders && linkedFolders.length > 0 ? (\n            <div className=\"space-y-4\">\n              {linkedFolders.map((folder) => {\n                const files = filesByFolder.get(folder.id) ?? [];\n                const isLoading = projectFilesLoading;\n\n                return (\n                  <div key={folder.id}>\n                    {/* Folder header — links to File Manager */}\n                    <Link\n                      to={`/files?folder=${folder.id}`}\n                      className=\"flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors mb-2\"\n                    >\n                      <div className=\"flex items-center gap-3 min-w-0\">\n                        <FolderOpen className=\"w-5 h-5 text-bambu-green shrink-0\" />\n                        <div className=\"min-w-0\">\n                          <p className=\"text-sm text-white truncate\">{folder.name}</p>\n                          <p className=\"text-xs text-bambu-gray\">\n                            {t('projectDetail.files.fileCount', { count: folder.file_count })}\n                          </p>\n                        </div>\n                      </div>\n                      <ChevronRight className=\"w-4 h-4 text-bambu-gray shrink-0\" />\n                    </Link>\n\n                    {/* File list within the folder */}\n                    {isLoading ? (\n                      <div className=\"flex items-center gap-2 px-3 py-2 text-bambu-gray text-sm\">\n                        <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      </div>\n                    ) : files.length === 0 ? (\n                      <p className=\"text-bambu-gray/60 text-xs italic px-3\">\n                        {t('projectDetail.files.noFiles')}\n                      </p>\n                    ) : (\n                      <div className=\"space-y-1 pl-3\">\n                        {files.map((file) => {\n                          const printable = isSlicedFilename(file.filename);\n                          return (\n                            <div\n                              key={file.id}\n                              className=\"flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors\"\n                            >\n                              {/* Thumbnail */}\n                              <div className=\"w-10 h-10 shrink-0 rounded bg-bambu-dark overflow-hidden flex items-center justify-center\">\n                                {file.thumbnail_path ? (\n                                  <img\n                                    src={api.getLibraryFileThumbnailUrl(file.id)}\n                                    alt={file.print_name || file.filename}\n                                    className=\"w-full h-full object-cover\"\n                                  />\n                                ) : (\n                                  <FileBox className=\"w-5 h-5 text-bambu-gray/40\" />\n                                )}\n                              </div>\n\n                              {/* Name + type badge */}\n                              <div className=\"flex-1 min-w-0\">\n                                <p className=\"text-sm text-white truncate\" title={file.print_name || file.filename}>\n                                  {file.print_name || file.filename}\n                                </p>\n                                <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${\n                                  file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'\n                                  : file.file_type === 'gcode' ? 'bg-blue-500/20 text-blue-400'\n                                  : 'bg-bambu-gray/20 text-bambu-gray'\n                                }`}>\n                                  {file.file_type.toUpperCase()}\n                                </span>\n                              </div>\n\n                              {/* Print actions for sliced files */}\n                              {printable && (\n                                <div className=\"flex items-center gap-1 shrink-0\">\n                                  <button\n                                    onClick={() => setPrintFile(file)}\n                                    title={t('projectDetail.files.print')}\n                                    className=\"p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors\"\n                                  >\n                                    <Play className=\"w-4 h-4\" />\n                                  </button>\n                                  <button\n                                    onClick={() => setScheduleFile(file)}\n                                    title={t('projectDetail.files.addToQueue')}\n                                    className=\"p-1.5 rounded hover:bg-blue-500/20 text-blue-400 transition-colors\"\n                                  >\n                                    <CalendarPlus className=\"w-4 h-4\" />\n                                  </button>\n                                </div>\n                              )}\n                            </div>\n                          );\n                        })}\n                      </div>\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n          ) : (\n            <p className=\"text-bambu-gray/70 text-sm italic\">\n              {t('projectDetail.files.empty')}\n            </p>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* BOM Section - Parts to source/purchase */}\n      <Card>\n        <CardContent className=\"p-4\">\n          <div className=\"flex items-center justify-between mb-4\">\n            <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n              <ShoppingCart className=\"w-5 h-5\" />\n              {t('projectDetail.bom.title')}\n              {stats && stats.bom_total_items > 0 && (\n                <span className=\"text-sm font-normal text-bambu-gray\">\n                  ({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })})\n                </span>\n              )}\n            </h2>\n            <div className=\"flex items-center gap-2\">\n              {bomItems && bomItems.some(item => item.is_complete) && (\n                <button\n                  onClick={() => setHideBomCompleted(!hideBomCompleted)}\n                  className={`text-xs px-2 py-1 rounded transition-colors ${\n                    hideBomCompleted\n                      ? 'bg-bambu-green/20 text-bambu-green'\n                      : 'bg-bambu-dark text-bambu-gray hover:text-white'\n                  }`}\n                >\n                  {hideBomCompleted ? t('projectDetail.bom.showAll') : t('projectDetail.bom.hideDone')}\n                </button>\n              )}\n              {!showBomForm && (\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={() => setShowBomForm(true)}\n                  disabled={!hasPermission('projects:update')}\n                  title={!hasPermission('projects:update') ? t('projectDetail.bom.noAddPermission') : undefined}\n                >\n                  <Plus className=\"w-4 h-4 mr-1\" />\n                  {t('projectDetail.bom.addPart')}\n                </Button>\n              )}\n            </div>\n          </div>\n\n          {/* Add BOM item form */}\n          {showBomForm && (\n            <form onSubmit={handleAddBomItem} className=\"bg-bambu-dark rounded-lg p-4 mb-4 space-y-3\">\n              <div className=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n                <input\n                  type=\"text\"\n                  value={newBomName}\n                  onChange={(e) => setNewBomName(e.target.value)}\n                  className=\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                  placeholder={t('projectDetail.bom.partNamePlaceholder')}\n                  autoFocus\n                />\n                <div className=\"flex gap-2\">\n                  <input\n                    type=\"number\"\n                    value={newBomQty}\n                    onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}\n                    className=\"w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green\"\n                    min=\"1\"\n                    placeholder={t('projectDetail.bom.qty')}\n                  />\n                  <input\n                    type=\"number\"\n                    step=\"0.01\"\n                    value={newBomPrice}\n                    onChange={(e) => setNewBomPrice(e.target.value)}\n                    className=\"flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                    placeholder={t('projectDetail.bom.price', { currency })}\n                  />\n                </div>\n              </div>\n              <input\n                type=\"url\"\n                value={newBomUrl}\n                onChange={(e) => setNewBomUrl(e.target.value)}\n                className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}\n              />\n              <input\n                type=\"text\"\n                value={newBomRemarks}\n                onChange={(e) => setNewBomRemarks(e.target.value)}\n                className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                placeholder={t('projectDetail.bom.remarksPlaceholder')}\n              />\n              <div className=\"flex justify-end gap-2\">\n                <Button type=\"button\" variant=\"secondary\" size=\"sm\" onClick={() => setShowBomForm(false)}>\n                  {t('common.cancel')}\n                </Button>\n                <Button type=\"submit\" size=\"sm\" disabled={!newBomName.trim() || createBomMutation.isPending}>\n                  {createBomMutation.isPending ? (\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                  ) : (\n                    t('projectDetail.bom.addPart')\n                  )}\n                </Button>\n              </div>\n            </form>\n          )}\n\n          {bomLoading ? (\n            <div className=\"flex items-center justify-center py-4\">\n              <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n            </div>\n          ) : bomItems && bomItems.length > 0 ? (\n            <div className=\"space-y-2\">\n              {bomItems\n                .filter(item => !hideBomCompleted || !item.is_complete)\n                .map((item) => (\n                <div\n                  key={item.id}\n                  className={`p-3 rounded-lg transition-colors ${\n                    item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'\n                  }`}\n                >\n                  {editingBomItem?.id === item.id ? (\n                    // Edit form for this BOM item\n                    <form onSubmit={handleSaveBomEdit} className=\"space-y-3\">\n                      <div className=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n                        <input\n                          type=\"text\"\n                          value={editBomName}\n                          onChange={(e) => setEditBomName(e.target.value)}\n                          className=\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                          placeholder={t('projectDetail.bom.partName')}\n                          autoFocus\n                        />\n                        <div className=\"flex gap-2\">\n                          <input\n                            type=\"number\"\n                            value={editBomQty}\n                            onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}\n                            className=\"w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green\"\n                            min=\"1\"\n                            placeholder={t('projectDetail.bom.qty')}\n                          />\n                          <input\n                            type=\"number\"\n                            step=\"0.01\"\n                            value={editBomPrice}\n                            onChange={(e) => setEditBomPrice(e.target.value)}\n                            className=\"flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                            placeholder={t('projectDetail.bom.price', { currency })}\n                          />\n                        </div>\n                      </div>\n                      <input\n                        type=\"url\"\n                        value={editBomUrl}\n                        onChange={(e) => setEditBomUrl(e.target.value)}\n                        className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                        placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}\n                      />\n                      <input\n                        type=\"text\"\n                        value={editBomRemarks}\n                        onChange={(e) => setEditBomRemarks(e.target.value)}\n                        className=\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                        placeholder={t('projectDetail.bom.remarksPlaceholder')}\n                      />\n                      <div className=\"flex justify-end gap-2\">\n                        <Button type=\"button\" variant=\"secondary\" size=\"sm\" onClick={handleCancelBomEdit}>\n                          {t('common.cancel')}\n                        </Button>\n                        <Button type=\"submit\" size=\"sm\" disabled={!editBomName.trim() || updateBomMutation.isPending}>\n                          {updateBomMutation.isPending ? (\n                            <Loader2 className=\"w-4 h-4 animate-spin\" />\n                          ) : (\n                            t('common.save')\n                          )}\n                        </Button>\n                      </div>\n                    </form>\n                  ) : (\n                    // Display mode\n                    <div className=\"flex items-start gap-3\">\n                      <button\n                        onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}\n                        disabled={updateBomMutation.isPending || !hasPermission('projects:update')}\n                        title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined}\n                        className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${\n                          item.is_complete\n                            ? 'bg-status-ok border-status-ok text-white'\n                            : hasPermission('projects:update')\n                              ? 'border-bambu-gray hover:border-bambu-green'\n                              : 'border-bambu-gray/50 cursor-not-allowed'\n                        }`}\n                      >\n                        {item.is_complete && <CheckCircle className=\"w-3 h-3\" />}\n                      </button>\n                      <div className=\"flex-1 min-w-0\">\n                        <div className=\"flex items-center justify-between gap-2\">\n                          <div className=\"flex items-center gap-2 min-w-0\">\n                            <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>\n                              {item.name}\n                              <span className=\"text-bambu-gray font-normal ml-2\">\n                                x{item.quantity_needed}\n                              </span>\n                            </p>\n                            {item.unit_price !== null && (\n                              <span className=\"text-xs text-bambu-green whitespace-nowrap\">\n                                {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}\n                              </span>\n                            )}\n                          </div>\n                          <div className=\"flex items-center gap-1\">\n                            <button\n                              onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}\n                              disabled={!hasPermission('projects:update')}\n                              className={`p-1 rounded transition-colors shrink-0 ${\n                                hasPermission('projects:update')\n                                  ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'\n                                  : 'text-bambu-gray/50 cursor-not-allowed'\n                              }`}\n                              title={!hasPermission('projects:update') ? t('projectDetail.bom.noEditPermission') : t('common.edit')}\n                            >\n                              <Pencil className=\"w-4 h-4\" />\n                            </button>\n                            <button\n                              onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}\n                              disabled={!hasPermission('projects:update')}\n                              className={`p-1 rounded transition-colors shrink-0 ${\n                                hasPermission('projects:update')\n                                  ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'\n                                  : 'text-bambu-gray/50 cursor-not-allowed'\n                              }`}\n                              title={!hasPermission('projects:update') ? t('projectDetail.bom.noDeletePermission') : t('common.delete')}\n                            >\n                              <Trash2 className=\"w-4 h-4\" />\n                            </button>\n                          </div>\n                        </div>\n                        {/* Sourcing URL */}\n                        {item.sourcing_url && (\n                          <a\n                            href={item.sourcing_url}\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors\"\n                            onClick={(e) => e.stopPropagation()}\n                          >\n                            <ExternalLink className=\"w-3 h-3 shrink-0\" />\n                            <span className=\"truncate\">\n                              {(() => {\n                                try {\n                                  return new URL(item.sourcing_url).hostname.replace('www.', '');\n                                } catch {\n                                  return item.sourcing_url;\n                                }\n                              })()}\n                            </span>\n                          </a>\n                        )}\n                        {/* Remarks */}\n                        {item.remarks && (\n                          <p className=\"mt-1 text-xs text-bambu-gray/80 italic\">\n                            {item.remarks}\n                          </p>\n                        )}\n                      </div>\n                    </div>\n                  )}\n                </div>\n              ))}\n              {/* BOM Total */}\n              {stats && stats.bom_cost > 0 && (\n                <div className=\"pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm\">\n                  <span className=\"text-bambu-gray\">{t('projectDetail.bom.totalCost')}</span>\n                  <span className=\"text-white font-medium\">\n                    {currency}{stats.bom_cost.toFixed(2)}\n                  </span>\n                </div>\n              )}\n            </div>\n          ) : (\n            <p className=\"text-bambu-gray/70 text-sm italic\">\n              {t('projectDetail.bom.empty')}\n            </p>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Timeline Section */}\n      <Card>\n        <CardContent className=\"p-4\">\n          <div className=\"flex items-center justify-between mb-3\">\n            <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n              <History className=\"w-5 h-5\" />\n              {t('projectDetail.timeline.title')}\n            </h2>\n          </div>\n\n          {timelineLoading ? (\n            <div className=\"flex items-center justify-center py-4\">\n              <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n            </div>\n          ) : timeline && timeline.length > 0 ? (\n            <div className=\"space-y-3\">\n              {timeline.slice(0, 10).map((event, index) => (\n                <div key={index} className=\"flex gap-3\">\n                  <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${\n                    event.event_type === 'print_completed' ? 'bg-status-ok/20 text-status-ok' :\n                    event.event_type === 'print_failed' ? 'bg-status-error/20 text-status-error' :\n                    event.event_type === 'print_started' ? 'bg-yellow-500/20 text-yellow-400' :\n                    'bg-bambu-dark-tertiary text-bambu-gray'\n                  }`}>\n                    {event.event_type === 'print_completed' && <CheckCircle className=\"w-4 h-4\" />}\n                    {event.event_type === 'print_failed' && <XCircle className=\"w-4 h-4\" />}\n                    {event.event_type === 'print_started' && <Printer className=\"w-4 h-4\" />}\n                    {event.event_type === 'queued' && <ListTodo className=\"w-4 h-4\" />}\n                    {event.event_type === 'project_created' && <Plus className=\"w-4 h-4\" />}\n                  </div>\n                  <div className=\"flex-1 min-w-0\">\n                    <p className=\"text-sm text-white\">{event.title}</p>\n                    {event.description && (\n                      <p className=\"text-xs text-bambu-gray truncate\">{event.description}</p>\n                    )}\n                    <p className=\"text-xs text-bambu-gray/70\">{formatTimelineDate(event.timestamp)}</p>\n                  </div>\n                </div>\n              ))}\n            </div>\n          ) : (\n            <p className=\"text-bambu-gray/70 text-sm italic\">\n              {t('projectDetail.timeline.empty')}\n            </p>\n          )}\n        </CardContent>\n      </Card>\n\n      {/* Template action */}\n      {!project.is_template && (\n        <div className=\"flex justify-end\">\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={() => createTemplateMutation.mutate()}\n            disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}\n            title={!hasPermission('projects:create') ? t('projectDetail.template.noCreatePermission') : undefined}\n          >\n            {createTemplateMutation.isPending ? (\n              <Loader2 className=\"w-4 h-4 animate-spin mr-2\" />\n            ) : (\n              <Copy className=\"w-4 h-4 mr-2\" />\n            )}\n            {t('projectDetail.template.saveAsTemplate')}\n          </Button>\n        </div>\n      )}\n\n      {/* Queue section */}\n      {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (\n        <Card>\n          <CardContent className=\"p-4\">\n            <div className=\"flex items-center justify-between mb-3\">\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                <ListTodo className=\"w-5 h-5\" />\n                {t('projectDetail.queue.title')}\n              </h2>\n              <Link\n                to={`/queue?project=${projectId}`}\n                className=\"text-sm text-bambu-green hover:underline\"\n              >\n                {t('projectDetail.queue.viewAll')}\n              </Link>\n            </div>\n            <div className=\"flex items-center gap-4 text-sm\">\n              {stats.in_progress_prints > 0 && (\n                <span className=\"text-yellow-400\">\n                  {t('projectDetail.queue.printing', { count: stats.in_progress_prints })}\n                </span>\n              )}\n              {stats.queued_prints > 0 && (\n                <span className=\"text-bambu-gray\">\n                  {t('projectDetail.queue.queued', { count: stats.queued_prints })}\n                </span>\n              )}\n            </div>\n          </CardContent>\n        </Card>\n      )}\n\n      {/* Archives section */}\n      <div>\n        <div className=\"flex items-center justify-between mb-4\">\n          <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n            <Package className=\"w-5 h-5\" />\n            {t('projectDetail.prints.title', { count: archives?.length || 0 })}\n          </h2>\n        </div>\n        {archivesLoading ? (\n          <div className=\"flex items-center justify-center py-8\">\n            <Loader2 className=\"w-6 h-6 animate-spin text-bambu-green\" />\n          </div>\n        ) : (\n          <ArchiveGrid archives={archives || []} t={t} />\n        )}\n      </div>\n\n      {/* Edit Modal */}\n      {showEditModal && (\n        <ProjectModal\n          t={t}\n          currencySymbol={currency}\n          project={{\n            ...project,\n            archive_count: stats?.total_archives || 0,\n            total_items: stats?.total_items || 0,\n            completed_count: stats?.completed_prints || 0,\n            failed_count: stats?.failed_prints || 0,\n            queue_count: stats?.queued_prints || 0,\n            progress_percent: stats?.progress_percent || null,\n            archives: [],\n          }}\n          onClose={() => setShowEditModal(false)}\n          onSave={(data) => updateMutation.mutate(data as ProjectUpdate)}\n          isLoading={updateMutation.isPending}\n        />\n      )}\n\n      {/* Confirm Modal */}\n      {confirmModal.isOpen && (\n        <ConfirmModal\n          title={confirmModal.title}\n          message={confirmModal.message}\n          confirmText={t('common.delete')}\n          variant=\"danger\"\n          onConfirm={confirmModal.onConfirm}\n          onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}\n        />\n      )}\n\n      {/* Print directly from project — reprint mode */}\n      {printFile && (\n        <PrintModal\n          mode=\"reprint\"\n          libraryFileId={printFile.id}\n          archiveName={printFile.print_name || printFile.filename}\n          projectId={projectId}\n          onClose={() => setPrintFile(null)}\n          onSuccess={() => {\n            setPrintFile(null);\n            queryClient.invalidateQueries({ queryKey: ['archives'] });\n          }}\n        />\n      )}\n\n      {/* Add to queue from project */}\n      {scheduleFile && (\n        <PrintModal\n          mode=\"add-to-queue\"\n          libraryFileId={scheduleFile.id}\n          archiveName={scheduleFile.print_name || scheduleFile.filename}\n          projectId={projectId}\n          onClose={() => setScheduleFile(null)}\n          onSuccess={() => {\n            setScheduleFile(null);\n            queryClient.invalidateQueries({ queryKey: ['queue'] });\n          }}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/ProjectsPage.tsx",
    "content": "import { useState, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate } from 'react-router-dom';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n  FolderKanban,\n  Loader2,\n  Plus,\n  Trash2,\n  Edit3,\n  Archive,\n  ListTodo,\n  Package,\n  Layers,\n  Clock,\n  CheckCircle2,\n  AlertTriangle,\n  ChevronRight,\n  MoreVertical,\n  Download,\n  Upload,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';\nimport { Button } from '../components/Button';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { getCurrencySymbol } from '../utils/currency';\n\nconst PROJECT_COLORS = [\n  '#ef4444', // red\n  '#f97316', // orange\n  '#eab308', // yellow\n  '#22c55e', // green\n  '#06b6d4', // cyan\n  '#3b82f6', // blue\n  '#8b5cf6', // violet\n  '#ec4899', // pink\n  '#6b7280', // gray\n];\n\ntype TFunction = (key: string, options?: Record<string, unknown>) => string;\n\ninterface ProjectModalProps {\n  project?: ProjectListItem;\n  onClose: () => void;\n  onSave: (data: ProjectCreate | ProjectUpdate) => void;\n  isLoading: boolean;\n  currencySymbol: string;\n  t: TFunction;\n}\n\nexport function ProjectModal({ project, onClose, onSave, isLoading, currencySymbol, t }: ProjectModalProps) {\n  const [name, setName] = useState(project?.name || '');\n  const [description, setDescription] = useState(project?.description || '');\n  const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);\n  const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');\n  const [targetPartsCount, setTargetPartsCount] = useState(project?.target_parts_count?.toString() || '');\n  const [status, setStatus] = useState(project?.status || 'active');\n  const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');\n  const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');\n  const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');\n  const [budget, setBudget] = useState(project?.budget?.toString() || '');\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    onSave({\n      name: name.trim(),\n      description: description.trim() || undefined,\n      color,\n      target_count: targetCount ? parseInt(targetCount, 10) : undefined,\n      target_parts_count: targetPartsCount ? parseInt(targetPartsCount, 10) : undefined,\n      tags: tags.trim() || undefined,\n      due_date: dueDate || undefined,\n      priority,\n      budget: budget.trim() ? parseFloat(budget) : null,\n      ...(project && { status }),\n    });\n  };\n\n  return (\n    <div className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\">\n      <div className=\"bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary\">\n        <div className=\"p-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white\">\n            {project ? t('projects.editProject') : t('projects.newProject')}\n          </h2>\n        </div>\n\n        <form onSubmit={handleSubmit} className=\"p-4 space-y-4\">\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('common.name')}\n            </label>\n            <input\n              type=\"text\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n              placeholder={t('projects.namePlaceholder')}\n              required\n            />\n          </div>\n\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('common.description')}\n            </label>\n            <textarea\n              value={description}\n              onChange={(e) => setDescription(e.target.value)}\n              className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none\"\n              placeholder={t('projects.descriptionPlaceholder')}\n              rows={2}\n            />\n          </div>\n\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('projects.color')}\n            </label>\n            <div className=\"flex gap-2 flex-wrap\">\n              {PROJECT_COLORS.map((c) => (\n                <button\n                  key={c}\n                  type=\"button\"\n                  onClick={() => setColor(c)}\n                  className={`w-8 h-8 rounded-full transition-transform ${\n                    color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : ''\n                  }`}\n                  style={{ backgroundColor: c }}\n                />\n              ))}\n            </div>\n          </div>\n\n          {/* Target Counts - Plates and Parts side by side */}\n          <div className=\"grid grid-cols-2 gap-4\">\n            <div>\n              <label className=\"block text-sm font-medium text-white mb-1\">\n                {t('projects.targetPlates')}\n              </label>\n              <input\n                type=\"number\"\n                value={targetCount}\n                onChange={(e) => setTargetCount(e.target.value)}\n                className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                placeholder={t('projects.targetPlatesPlaceholder')}\n                min=\"1\"\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('projects.targetPlatesHelp')}</p>\n            </div>\n            <div>\n              <label className=\"block text-sm font-medium text-white mb-1\">\n                {t('projects.targetParts')}\n              </label>\n              <input\n                type=\"number\"\n                value={targetPartsCount}\n                onChange={(e) => setTargetPartsCount(e.target.value)}\n                className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                placeholder={t('projects.targetPartsPlaceholder')}\n                min=\"1\"\n              />\n              <p className=\"text-xs text-bambu-gray mt-1\">{t('projects.targetPartsHelp')}</p>\n            </div>\n          </div>\n\n          {/* Tags */}\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('projects.tagsLabel')}\n            </label>\n            <input\n              type=\"text\"\n              value={tags}\n              onChange={(e) => setTags(e.target.value)}\n              className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n              placeholder={t('projects.tagsPlaceholder')}\n            />\n          </div>\n\n          {/* Due Date and Priority in a row */}\n          <div className=\"grid grid-cols-2 gap-4\">\n            <div>\n              <label className=\"block text-sm font-medium text-white mb-1\">\n                {t('projects.dueDate')}\n              </label>\n              <input\n                type=\"date\"\n                value={dueDate}\n                onChange={(e) => setDueDate(e.target.value)}\n                className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green\"\n              />\n            </div>\n            <div>\n              <label className=\"block text-sm font-medium text-white mb-1\">\n                {t('projects.priority')}\n              </label>\n              <select\n                value={priority}\n                onChange={(e) => setPriority(e.target.value)}\n                className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green\"\n              >\n                <option value=\"low\">{t('projects.priorityLow')}</option>\n                <option value=\"normal\">{t('projects.priorityNormal')}</option>\n                <option value=\"high\">{t('projects.priorityHigh')}</option>\n                <option value=\"urgent\">{t('projects.priorityUrgent')}</option>\n              </select>\n            </div>\n          </div>\n\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-1\">\n              {t('projectDetail.cost.budget')}\n            </label>\n            <div className=\"relative\">\n              <span className=\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray pointer-events-none\">\n                {currencySymbol}\n              </span>\n              <input\n                type=\"number\"\n                step=\"0.01\"\n                min=\"0\"\n                value={budget}\n                onChange={(e) => setBudget(e.target.value)}\n                className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded pl-8 pr-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"\n                placeholder=\"0.00\"\n              />\n            </div>\n          </div>\n\n          {project && (\n            <div>\n              <label className=\"block text-sm font-medium text-white mb-1\">\n                {t('common.status')}\n              </label>\n              <select\n                value={status}\n                onChange={(e) => setStatus(e.target.value)}\n                className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green\"\n              >\n                <option value=\"active\">{t('projects.statusActive')}</option>\n                <option value=\"completed\">{t('projects.statusCompleted')}</option>\n                <option value=\"archived\">{t('projects.statusArchived')}</option>\n              </select>\n            </div>\n          )}\n\n          <div className=\"flex justify-end gap-2 pt-2\">\n            <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n              {t('common.cancel')}\n            </Button>\n            <Button type=\"submit\" disabled={!name.trim() || isLoading}>\n              {isLoading ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : project ? (\n                t('common.save')\n              ) : (\n                t('projects.create')\n              )}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n\ninterface ProjectCardProps {\n  project: ProjectListItem;\n  onClick: () => void;\n  onEdit: () => void;\n  onDelete: () => void;\n  hasPermission: (permission: Permission) => boolean;\n  t: TFunction;\n}\n\nfunction ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {\n  // Plates progress: archive_count / target_count\n  const platesProgressPercent = project.target_count\n    ? Math.round((project.archive_count / project.target_count) * 100)\n    : 0;\n  // Parts progress: completed_count / target_parts_count\n  const partsProgressPercent = project.target_parts_count\n    ? Math.round((project.completed_count / project.target_parts_count) * 100)\n    : 0;\n  const isCompleted = project.status === 'completed';\n  const isArchived = project.status === 'archived';\n  const [showActions, setShowActions] = useState(false);\n\n  // Status icon and color\n  const getStatusConfig = () => {\n    if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };\n    if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };\n    if (project.queue_count > 0) return { icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' };\n    return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };\n  };\n  const statusConfig = getStatusConfig();\n\n  return (\n    <div\n      className=\"group relative bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary hover:border-bambu-green/50 hover:shadow-lg hover:shadow-bambu-green/5 transition-all duration-300 cursor-pointer overflow-hidden\"\n      onClick={onClick}\n    >\n      {/* Color accent bar with glow */}\n      <div\n        className=\"absolute top-0 left-0 w-1.5 h-full\"\n        style={{\n          backgroundColor: project.color || '#6b7280',\n          boxShadow: `0 0 12px ${project.color || '#6b7280'}40`\n        }}\n      />\n\n      <div className=\"p-5 pl-6\">\n        {/* Header */}\n        <div className=\"flex items-start justify-between mb-4\">\n          <div className=\"flex items-center gap-3 min-w-0 flex-1\">\n            <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>\n              <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />\n            </div>\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"flex items-center gap-2 flex-wrap\">\n                <h3 className=\"font-semibold text-white truncate\">{project.name}</h3>\n                {project.target_parts_count ? (\n                  <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${\n                    partsProgressPercent >= 100\n                      ? 'bg-bambu-green/20 text-bambu-green'\n                      : 'bg-bambu-dark text-bambu-gray'\n                  }`}>\n                    {project.completed_count}/{project.target_parts_count} {t('projects.parts')}\n                  </span>\n                ) : project.target_count ? (\n                  <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${\n                    platesProgressPercent >= 100\n                      ? 'bg-bambu-green/20 text-bambu-green'\n                      : 'bg-bambu-dark text-bambu-gray'\n                  }`}>\n                    {project.archive_count}/{project.target_count} {t('projects.plates')}\n                  </span>\n                ) : project.completed_count > 0 ? (\n                  <span className=\"text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray\">\n                    {project.completed_count} {t('projects.parts')}\n                  </span>\n                ) : null}\n                {isCompleted && (\n                  <span className=\"text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap\">\n                    {t('projects.done')}\n                  </span>\n                )}\n                {isArchived && (\n                  <span className=\"text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap\">\n                    {t('projects.statusArchived')}\n                  </span>\n                )}\n              </div>\n              {project.description && (\n                <p className=\"text-sm text-bambu-gray/70 mt-1 line-clamp-1\">\n                  {project.description}\n                </p>\n              )}\n              {/* Filament materials/colors */}\n              {project.archives && project.archives.length > 0 && (() => {\n                // Flatten comma-separated materials and deduplicate\n                const allMaterials = project.archives\n                  .map(a => a.filament_type)\n                  .filter(Boolean)\n                  .flatMap(m => (m as string).split(',').map(s => s.trim()))\n                  .filter(Boolean);\n                const materials = [...new Set(allMaterials)];\n                // Flatten comma-separated colors and deduplicate\n                const allColors = project.archives\n                  .map(a => a.filament_color)\n                  .filter(Boolean)\n                  .flatMap(c => (c as string).split(',').map(s => s.trim()))\n                  .filter(c => c.startsWith('#') || /^[0-9A-Fa-f]{6}$/.test(c));\n                const colors = [...new Set(allColors)];\n                if (materials.length === 0 && colors.length === 0) return null;\n                return (\n                  <div className=\"flex items-center gap-2 mt-1.5\">\n                    {/* Material types as text badges */}\n                    {materials.slice(0, 3).map((mat) => (\n                      <span key={mat} className=\"text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded\">\n                        {mat}\n                      </span>\n                    ))}\n                    {/* Colors as swatches */}\n                    {colors.length > 0 && (\n                      <div className=\"flex items-center gap-0.5\">\n                        {colors.slice(0, 5).map((col) => (\n                          <div\n                            key={col}\n                            className=\"w-3 h-3 rounded-full border border-black/20\"\n                            style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}\n                            title={col}\n                          />\n                        ))}\n                        {colors.length > 5 && (\n                          <span className=\"text-[10px] text-bambu-gray ml-0.5\">+{colors.length - 5}</span>\n                        )}\n                      </div>\n                    )}\n                  </div>\n                );\n              })()}\n            </div>\n          </div>\n\n          {/* Actions menu */}\n          <div className=\"relative\" onClick={(e) => e.stopPropagation()}>\n            <button\n              className=\"p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100\"\n              onClick={() => setShowActions(!showActions)}\n            >\n              <MoreVertical className=\"w-4 h-4\" />\n            </button>\n            {showActions && (\n              <>\n                <div className=\"fixed inset-0 z-10\" onClick={() => setShowActions(false)} />\n                <div className=\"absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]\">\n                  <button\n                    className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${\n                      hasPermission('projects:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                    }`}\n                    onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}\n                    disabled={!hasPermission('projects:update')}\n                    title={!hasPermission('projects:update') ? t('projects.noEditPermission') : undefined}\n                  >\n                    <Edit3 className=\"w-4 h-4\" />\n                    {t('common.edit')}\n                  </button>\n                  <button\n                    className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${\n                      hasPermission('projects:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'\n                    }`}\n                    onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}\n                    disabled={!hasPermission('projects:delete')}\n                    title={!hasPermission('projects:delete') ? t('projects.noDeletePermission') : undefined}\n                  >\n                    <Trash2 className=\"w-4 h-4\" />\n                    {t('common.delete')}\n                  </button>\n                </div>\n              </>\n            )}\n          </div>\n        </div>\n\n        {/* Progress section - show for all projects */}\n        <div className=\"mb-4\">\n          {(project.target_count || project.target_parts_count) ? (\n            <div className=\"space-y-3\">\n              {/* Plates progress */}\n              {project.target_count && (\n                <div>\n                  <div className=\"flex items-center justify-between text-xs mb-1\">\n                    <span className=\"text-bambu-gray\">{t('projects.plates')}</span>\n                    <span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>\n                      {project.archive_count} / {project.target_count}\n                    </span>\n                  </div>\n                  <div className=\"h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm\">\n                    <div\n                      className=\"h-full transition-all duration-500 ease-out rounded-full relative\"\n                      style={{\n                        width: `${Math.min(platesProgressPercent, 100)}%`,\n                        background: platesProgressPercent >= 100\n                          ? 'linear-gradient(90deg, #22c55e, #4ade80)'\n                          : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,\n                        boxShadow: `0 0 8px ${platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`\n                      }}\n                    />\n                  </div>\n                </div>\n              )}\n              {/* Parts progress */}\n              {project.target_parts_count && (\n                <div>\n                  <div className=\"flex items-center justify-between text-xs mb-1\">\n                    <span className=\"text-bambu-gray\">{t('projects.parts')}</span>\n                    <span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>\n                      {project.completed_count} / {project.target_parts_count}\n                    </span>\n                  </div>\n                  <div className=\"h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm\">\n                    <div\n                      className=\"h-full transition-all duration-500 ease-out rounded-full relative\"\n                      style={{\n                        width: `${Math.min(partsProgressPercent, 100)}%`,\n                        background: partsProgressPercent >= 100\n                          ? 'linear-gradient(90deg, #22c55e, #4ade80)'\n                          : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,\n                        boxShadow: `0 0 8px ${partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`\n                      }}\n                    />\n                  </div>\n                </div>\n              )}\n              {/* Failed count */}\n              {project.failed_count > 0 && (\n                <div className=\"text-xs text-red-400\">\n                  {project.failed_count} {t('projects.failed')}\n                </div>\n              )}\n            </div>\n          ) : project.completed_count > 0 || project.failed_count > 0 ? (\n            <div className=\"flex items-center gap-4 text-xs\">\n              {project.completed_count > 0 && (\n                <div className=\"flex items-center gap-1.5 text-bambu-gray\">\n                  <Archive className=\"w-3.5 h-3.5\" />\n                  <span>{project.completed_count} {t('projects.completed')}</span>\n                </div>\n              )}\n              {project.failed_count > 0 && (\n                <div className=\"flex items-center gap-1.5 text-red-400\">\n                  <AlertTriangle className=\"w-3.5 h-3.5\" />\n                  <span>{project.failed_count} {t('projects.failed')}</span>\n                </div>\n              )}\n              {project.queue_count > 0 && (\n                <div className=\"flex items-center gap-1.5 text-blue-400\">\n                  <Clock className=\"w-3.5 h-3.5\" />\n                  <span>{project.queue_count} {t('projects.inQueue')}</span>\n                </div>\n              )}\n            </div>\n          ) : (\n            <div className=\"text-xs text-bambu-gray/60 italic\">\n              {t('projects.noPrintsYet')}\n            </div>\n          )}\n        </div>\n\n        {/* Archive thumbnails - compact 4-column grid */}\n        {project.archives && project.archives.length > 0 && (\n          <div className=\"mb-4\">\n            <div className=\"grid grid-cols-4 gap-1.5\">\n              {project.archives.slice(0, 4).map((archive) => (\n                <div\n                  key={archive.id}\n                  className=\"relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary\"\n                  title={archive.print_name || 'Unknown'}\n                >\n                  {archive.thumbnail_path ? (\n                    <img\n                      src={api.getArchiveThumbnail(archive.id)}\n                      alt={archive.print_name || ''}\n                      className=\"w-full h-full object-cover\"\n                    />\n                  ) : (\n                    <div className=\"w-full h-full flex items-center justify-center text-bambu-gray/50\">\n                      <Package className=\"w-6 h-6\" />\n                    </div>\n                  )}\n                  {archive.status === 'failed' && (\n                    <div className=\"absolute inset-0 bg-red-500/40 flex items-center justify-center\">\n                      <AlertTriangle className=\"w-4 h-4 text-white\" />\n                    </div>\n                  )}\n                </div>\n              ))}\n            </div>\n            {project.archive_count > 4 && (\n              <p className=\"text-xs text-bambu-gray mt-1.5 text-center\">\n                {t('common.more', { count: project.archive_count - 4 })}\n              </p>\n            )}\n          </div>\n        )}\n\n        {/* Stats footer */}\n        <div className=\"flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary\">\n          <div className=\"flex items-center gap-4 text-xs text-bambu-gray\">\n            <div className=\"flex items-center gap-1.5\" title={t('projects.printJobs')}>\n              <Layers className=\"w-3.5 h-3.5 text-blue-400\" />\n              <span>{project.archive_count} {t('projects.plates')}</span>\n            </div>\n            <div className=\"flex items-center gap-1.5\" title={t('projects.partsPrinted')}>\n              <Package className=\"w-3.5 h-3.5 text-bambu-green\" />\n              <span>{project.completed_count} {t('projects.parts')}</span>\n            </div>\n            {project.failed_count > 0 && (\n              <div className=\"flex items-center gap-1.5 text-red-400\" title={t('projects.failedParts')}>\n                <AlertTriangle className=\"w-3.5 h-3.5\" />\n                <span>{project.failed_count}</span>\n              </div>\n            )}\n            {project.queue_count > 0 && (\n              <div className=\"flex items-center gap-1.5 text-yellow-400\" title={t('projects.inQueue')}>\n                <ListTodo className=\"w-3.5 h-3.5\" />\n                <span>{project.queue_count}</span>\n              </div>\n            )}\n          </div>\n          <ChevronRight className=\"w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function ProjectsPage() {\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission } = useAuth();\n  const [showModal, setShowModal] = useState(false);\n  const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();\n  const [statusFilter, setStatusFilter] = useState<string>('active');\n  const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');\n\n  const { data: projects, isLoading } = useQuery({\n    queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],\n    queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),\n  });\n\n  const createMutation = useMutation({\n    mutationFn: (data: ProjectCreate) => api.createProject(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['projects'] });\n      setShowModal(false);\n      showToast(t('projects.toast.created'), 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>\n      api.updateProject(id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['projects'] });\n      setShowModal(false);\n      setEditingProject(undefined);\n      showToast(t('projects.toast.updated'), 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (id: number) => api.deleteProject(id),\n    onSuccess: () => {\n      setDeleteConfirm(null);\n      showToast(t('projects.toast.deleted'), 'success');\n      // Reload to refresh the list (React Query cache invalidation not working reliably)\n      setTimeout(() => window.location.reload(), 100);\n    },\n    onError: (error: Error) => {\n      setDeleteConfirm(null);\n      showToast(error.message, 'error');\n    },\n  });\n\n  const importMutation = useMutation({\n    mutationFn: (data: ProjectImport) => api.importProject(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['projects'] });\n      showToast(t('projects.toast.imported'), 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const fileInputRef = useRef<HTMLInputElement>(null);\n\n  const handleExportAll = async () => {\n    try {\n      // Export all projects as JSON (metadata only, no files)\n      const allProjects = await api.getProjects();\n      const exports = await Promise.all(\n        allProjects.map(async (p) => {\n          const exported = await api.exportProjectJson(p.id);\n          return exported;\n        })\n      );\n      const blob = new Blob([JSON.stringify(exports, null, 2)], { type: 'application/json' });\n      const url = URL.createObjectURL(blob);\n      const a = document.createElement('a');\n      a.href = url;\n      a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;\n      a.click();\n      URL.revokeObjectURL(url);\n      showToast(t('projects.toast.exported'), 'success');\n    } catch (error) {\n      showToast((error as Error).message, 'error');\n    }\n  };\n\n  const handleImportClick = () => {\n    fileInputRef.current?.click();\n  };\n\n  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (!file) return;\n\n    try {\n      const filename = file.name.toLowerCase();\n\n      if (filename.endsWith('.zip')) {\n        // ZIP file: upload via file endpoint\n        await api.importProjectFile(file);\n        queryClient.invalidateQueries({ queryKey: ['projects'] });\n        showToast(t('projects.toast.imported'), 'success');\n      } else {\n        // JSON file: parse and handle bulk or single import\n        const text = await file.text();\n        const data = JSON.parse(text);\n\n        // Handle both single project and array of projects\n        const projectsToImport = Array.isArray(data) ? data : [data];\n\n        for (const project of projectsToImport) {\n          await importMutation.mutateAsync(project);\n        }\n\n        if (projectsToImport.length > 1) {\n          showToast(t('projects.toast.multipleImported', { count: projectsToImport.length }), 'success');\n        }\n      }\n    } catch (error) {\n      showToast(`${t('projects.toast.importFailed')}: ${(error as Error).message}`, 'error');\n    }\n\n    // Reset file input\n    e.target.value = '';\n  };\n\n  const handleSave = (data: ProjectCreate | ProjectUpdate) => {\n    if (editingProject) {\n      updateMutation.mutate({ id: editingProject.id, data });\n    } else {\n      createMutation.mutate(data as ProjectCreate);\n    }\n  };\n\n  const handleEdit = (project: ProjectListItem) => {\n    setEditingProject(project);\n    setShowModal(true);\n  };\n\n  const handleClick = (project: ProjectListItem) => {\n    // Navigate to project detail page\n    navigate(`/projects/${project.id}`);\n  };\n\n  const handleDeleteClick = (id: number) => {\n    setDeleteConfirm(id);\n  };\n\n  const handleDeleteConfirm = () => {\n    if (deleteConfirm !== null) {\n      deleteMutation.mutate(deleteConfirm);\n    }\n  };\n\n  // Count projects by status for filter badges\n  const projectCounts = projects?.reduce((acc, p) => {\n    acc[p.status] = (acc[p.status] || 0) + 1;\n    acc.all = (acc.all || 0) + 1;\n    return acc;\n  }, {} as Record<string, number>) || {};\n\n  return (\n    <div className=\"p-4 md:p-8 space-y-8\">\n      {/* Hidden file input for import */}\n      <input\n        ref={fileInputRef}\n        type=\"file\"\n        accept=\".json,.zip\"\n        onChange={handleFileChange}\n        className=\"hidden\"\n      />\n\n      {/* Header */}\n      <div className=\"flex flex-col sm:flex-row sm:items-center justify-between gap-4\">\n        <div>\n          <h1 className=\"text-2xl font-bold text-white flex items-center gap-3\">\n            <div className=\"p-2.5 bg-bambu-green/10 rounded-xl\">\n              <FolderKanban className=\"w-6 h-6 text-bambu-green\" />\n            </div>\n            {t('projects.title')}\n          </h1>\n          <p className=\"text-sm text-bambu-gray mt-2 ml-14\">\n            {t('projects.subtitle')}\n          </p>\n        </div>\n        <div className=\"flex gap-2\">\n          <Button\n            variant=\"secondary\"\n            onClick={handleImportClick}\n            disabled={!hasPermission('projects:create')}\n            title={!hasPermission('projects:create') ? t('projects.noImportPermission') : t('projects.importProject')}\n          >\n            <Upload className=\"w-4 h-4 mr-2\" />\n            {t('projects.import')}\n          </Button>\n          <Button\n            variant=\"secondary\"\n            onClick={handleExportAll}\n            disabled={!hasPermission('projects:read')}\n            title={!hasPermission('projects:read') ? t('projects.noExportPermission') : t('projects.exportAll')}\n          >\n            <Download className=\"w-4 h-4 mr-2\" />\n            {t('projects.export')}\n          </Button>\n          <Button\n            onClick={() => setShowModal(true)}\n            className=\"sm:w-auto w-full\"\n            disabled={!hasPermission('projects:create')}\n            title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}\n          >\n            <Plus className=\"w-4 h-4 mr-2\" />\n            {t('projects.newProject')}\n          </Button>\n        </div>\n      </div>\n\n      {/* Filter tabs */}\n      <div className=\"flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit\">\n        {[\n          { key: 'active', label: t('projects.statusActive'), icon: Clock },\n          { key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },\n          { key: 'archived', label: t('projects.statusArchived'), icon: Archive },\n          { key: 'all', label: t('common.all'), icon: FolderKanban },\n        ].map(({ key, label, icon: Icon }) => (\n          <button\n            key={key}\n            onClick={() => setStatusFilter(key)}\n            className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${\n              statusFilter === key\n                ? 'bg-bambu-card text-white shadow-sm'\n                : 'text-bambu-gray hover:text-white'\n            }`}\n          >\n            <Icon className=\"w-4 h-4\" />\n            <span>{label}</span>\n            {projectCounts[key] > 0 && (\n              <span className={`text-xs px-1.5 py-0.5 rounded-full ${\n                statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'\n              }`}>\n                {projectCounts[key]}\n              </span>\n            )}\n          </button>\n        ))}\n      </div>\n\n      {/* Content */}\n      {isLoading ? (\n        <div className=\"flex items-center justify-center py-20\">\n          <div className=\"flex flex-col items-center gap-3\">\n            <Loader2 className=\"w-8 h-8 animate-spin text-bambu-green\" />\n            <p className=\"text-sm text-bambu-gray\">{t('projects.loading')}</p>\n          </div>\n        </div>\n      ) : projects?.length === 0 ? (\n        <div className=\"flex flex-col items-center justify-center py-20 px-4\">\n          <div className=\"p-4 bg-bambu-dark rounded-2xl mb-4\">\n            <FolderKanban className=\"w-12 h-12 text-bambu-gray/50\" />\n          </div>\n          <h3 className=\"text-lg font-medium text-white mb-2\">\n            {statusFilter === 'all' ? t('projects.noProjects') : t('projects.noProjectsFiltered', { status: statusFilter })}\n          </h3>\n          <p className=\"text-bambu-gray text-center max-w-md mb-6\">\n            {statusFilter === 'all'\n              ? t('projects.createFirst')\n              : t('projects.noProjectsFilteredHelp', { status: statusFilter })\n            }\n          </p>\n          {statusFilter === 'all' && (\n            <Button\n              onClick={() => setShowModal(true)}\n              disabled={!hasPermission('projects:create')}\n              title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}\n            >\n              <Plus className=\"w-4 h-4 mr-2\" />\n              {t('projects.createFirstButton')}\n            </Button>\n          )}\n        </div>\n      ) : (\n        <div className=\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8\">\n          {projects?.map((project) => (\n            <ProjectCard\n              key={project.id}\n              project={project}\n              onClick={() => handleClick(project)}\n              onEdit={() => handleEdit(project)}\n              onDelete={() => handleDeleteClick(project.id)}\n              hasPermission={hasPermission}\n              t={t}\n            />\n          ))}\n        </div>\n      )}\n\n      {/* Delete Confirmation Modal */}\n      {deleteConfirm !== null && (\n        <ConfirmModal\n          title={t('projects.deleteProject')}\n          message={t('projects.deleteConfirm')}\n          confirmText={t('projects.deleteProject')}\n          variant=\"danger\"\n          onConfirm={handleDeleteConfirm}\n          onCancel={() => setDeleteConfirm(null)}\n        />\n      )}\n\n      {/* Modal */}\n      {showModal && (\n        <ProjectModal\n          project={editingProject}\n          onClose={() => {\n            setShowModal(false);\n            setEditingProject(undefined);\n          }}\n          onSave={handleSave}\n          isLoading={createMutation.isPending || updateMutation.isPending}\n          currencySymbol={currencySymbol}\n          t={t}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/QueuePage.tsx",
    "content": "import { useState, useMemo, useEffect, useCallback } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Link } from 'react-router-dom';\nimport {\n  DndContext,\n  closestCenter,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n} from '@dnd-kit/core';\nimport type { DragEndEvent } from '@dnd-kit/core';\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  useSortable,\n  verticalListSortingStrategy,\n} from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport {\n  Clock,\n  Trash2,\n  Play,\n  X,\n  CheckCircle,\n  XCircle,\n  AlertCircle,\n  Calendar,\n  Printer,\n  GripVertical,\n  SkipForward,\n  ExternalLink,\n  Power,\n  StopCircle,\n  Pencil,\n  RefreshCw,\n  Timer,\n  ListOrdered,\n  Layers,\n  ArrowUp,\n  ArrowDown,\n  Hand,\n  Check,\n  CheckSquare,\n  Square,\n  User,\n  Pause,\n  Weight,\n  ChevronDown,\n  ChevronRight,\n  List,\n  GanttChart,\n  Code,\n  Snail,\n} from 'lucide-react';\nimport { api } from '../api/client';\nimport { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';\nimport type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';\nimport { Card } from '../components/Card';\nimport { Button } from '../components/Button';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { PrintModal } from '../components/PrintModal';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { QueueStatsBar } from '../components/QueueStatsBar';\nimport { CompactHistoryRow } from '../components/CompactHistoryRow';\nimport { QueueTimelineView } from '../components/QueueTimelineView';\n\nfunction formatWeight(g: number, useKg = false): string {\n  if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;\n  return `${Math.round(g)}g`;\n}\n\nfunction StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {\n  // Special case: pending with waiting_reason shows as \"Waiting\"\n  if (status === 'pending' && waitingReason) {\n    return (\n      <span className=\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20\">\n        <Clock className=\"w-3.5 h-3.5\" />\n        {t('queue.status.waiting')}\n      </span>\n    );\n  }\n\n  // Special case: printing but printer is paused\n  if (status === 'printing' && printerState === 'PAUSE') {\n    return (\n      <span className=\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-400 bg-yellow-400/10 border-yellow-400/20\">\n        <Pause className=\"w-3.5 h-3.5\" />\n        {t('queue.status.paused')}\n      </span>\n    );\n  }\n\n  const config = {\n    pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },\n    printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },\n    completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },\n    failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },\n    skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: t('queue.status.skipped') },\n    cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },\n  };\n\n  const { icon: Icon, color, label } = config[status];\n\n  return (\n    <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>\n      <Icon className=\"w-3.5 h-3.5\" />\n      {label}\n    </span>\n  );\n}\n\n// Bulk edit modal for multiple queue items\nfunction BulkEditModal({\n  selectedCount,\n  printers,\n  onSave,\n  onClose,\n  isSaving,\n  canControlPrinter,\n  t,\n}: {\n  selectedCount: number;\n  printers: { id: number; name: string }[];\n  onSave: (data: Partial<PrintQueueBulkUpdate>) => void;\n  onClose: () => void;\n  isSaving: boolean;\n  canControlPrinter: boolean;\n  t: (key: string, options?: Record<string, unknown>) => string;\n}) {\n  const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');\n  const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');\n  const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');\n  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');\n  const [bedLevelling, setBedLevelling] = useState<boolean | 'unchanged'>('unchanged');\n  const [flowCali, setFlowCali] = useState<boolean | 'unchanged'>('unchanged');\n  const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');\n  const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');\n  const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');\n  const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');\n\n  const handleSave = () => {\n    const data: Partial<PrintQueueBulkUpdate> = {};\n    if (printerId !== 'unchanged') data.printer_id = printerId;\n    if (manualStart !== 'unchanged') data.manual_start = manualStart;\n    if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter;\n    if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess;\n    if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling;\n    if (flowCali !== 'unchanged') data.flow_cali = flowCali;\n    if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali;\n    if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;\n    if (timelapse !== 'unchanged') data.timelapse = timelapse;\n    if (useAms !== 'unchanged') data.use_ams = useAms;\n    onSave(data);\n  };\n\n  const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||\n    requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||\n    vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged';\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm\">\n      <div className=\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto\">\n        <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n          <h2 className=\"text-lg font-semibold text-white\">\n            {t('queue.bulkEdit.title', { count: selectedCount })}\n          </h2>\n          <button onClick={onClose} className=\"p-1 hover:bg-bambu-dark rounded\">\n            <X className=\"w-5 h-5 text-bambu-gray\" />\n          </button>\n        </div>\n\n        <div className=\"p-4 space-y-4\">\n          <p className=\"text-sm text-bambu-gray\">\n            {t('queue.bulkEdit.description')}\n          </p>\n\n          {/* Printer Assignment */}\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-2\">{t('queue.bulkEdit.printer')}</label>\n            <select\n              value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}\n              onChange={(e) => {\n                const val = e.target.value;\n                if (val === 'unchanged') setPrinterId('unchanged');\n                else if (val === 'null') setPrinterId(null);\n                else setPrinterId(Number(val));\n              }}\n              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\"\n            >\n              <option value=\"unchanged\">{t('queue.bulkEdit.noChange')}</option>\n              <option value=\"null\">{t('queue.filter.unassigned')}</option>\n              {printers.map(p => (\n                <option key={p.id} value={p.id}>{p.name}</option>\n              ))}\n            </select>\n          </div>\n\n          {/* Queue Options */}\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-2\">{t('queue.bulkEdit.queueOptions')}</label>\n            <div className=\"space-y-2\">\n              <TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />\n              <TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} disabled={!canControlPrinter} t={t} />\n              <TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />\n            </div>\n          </div>\n\n          {/* Print Options */}\n          <div>\n            <label className=\"block text-sm font-medium text-white mb-2\">{t('queue.bulkEdit.printOptions')}</label>\n            <div className=\"space-y-2\">\n              <TriStateToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />\n              <TriStateToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />\n              <TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />\n              <TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />\n              <TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />\n              <TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary\">\n          <Button variant=\"secondary\" onClick={onClose}>{t('common.cancel')}</Button>\n          <Button\n            onClick={handleSave}\n            disabled={!hasChanges || isSaving}\n          >\n            {isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// Tri-state toggle for bulk edit (unchanged / on / off)\nfunction TriStateToggle({\n  label,\n  value,\n  onChange,\n  disabled,\n  t,\n}: {\n  label: string;\n  value: boolean | 'unchanged';\n  onChange: (val: boolean | 'unchanged') => void;\n  disabled?: boolean;\n  t: (key: string) => string;\n}) {\n  return (\n    <div className={`flex items-center justify-between py-1 ${disabled ? 'opacity-50' : ''}`}>\n      <span className=\"text-sm text-bambu-gray\">{label}</span>\n      <div className=\"flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5\">\n        <button\n          onClick={() => onChange('unchanged')}\n          disabled={disabled}\n          className={`px-2 py-1 text-xs rounded ${value === 'unchanged' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}\n        >\n          —\n        </button>\n        <button\n          onClick={() => onChange(false)}\n          disabled={disabled}\n          className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-500/20 text-red-400' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}\n        >\n          {t('common.off')}\n        </button>\n        <button\n          onClick={() => onChange(true)}\n          disabled={disabled}\n          className={`px-2 py-1 text-xs rounded ${value === true ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'} disabled:cursor-not-allowed`}\n        >\n          {t('common.on')}\n        </button>\n      </div>\n    </div>\n  );\n}\n\n// Sortable queue item for drag and drop\nfunction SortableQueueItem({\n  item,\n  position,\n  onEdit,\n  onCancel,\n  onRemove,\n  onStop,\n  onRequeue,\n  onStart,\n  timeFormat = 'system',\n  isSelected = false,\n  onToggleSelect,\n  hasPermission,\n  canModify,\n  printerState,\n  t,\n}: {\n  item: PrintQueueItem;\n  position?: number;\n  onEdit: () => void;\n  onCancel: () => void;\n  onRemove: () => void;\n  onStop: () => void;\n  onRequeue: () => void;\n  onStart: () => void;\n  timeFormat?: TimeFormat;\n  isSelected?: boolean;\n  onToggleSelect?: () => void;\n  hasPermission: (permission: Permission) => boolean;\n  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;\n  printerState?: string | null;\n  t: (key: string, options?: Record<string, unknown>) => string;\n}) {\n  // Fetch printer status every 30 seconds while printing to monitor progress\n  const { data: status } = useQuery({\n    queryKey: ['printerStatus', item.printer_id],\n    queryFn: () => api.getPrinterStatus(item.printer_id!),\n    refetchInterval: 30000,\n    enabled: item.printer_id != null && printerState === 'printing',\n  });\n\n  // Determine if we're printing a library file\n  const isLibraryFile = !!item.library_file_id && !item.archive_id;\n  // Fetch archive plate details\n  const { data: archivePlatesData } = useQuery({\n    queryKey: ['archive-plates', item.archive_id],\n    queryFn: () => api.getArchivePlates(item.archive_id!),\n    enabled: !!item.archive_id && !isLibraryFile,\n  });\n\n  // Fetch library file plate details\n  const { data: libraryPlatesData } = useQuery({\n    queryKey: ['library-file-plates', item.library_file_id],\n    queryFn: () => api.getLibraryFilePlates(item.library_file_id!),\n    enabled: isLibraryFile && !!item.library_file_id,\n  });\n\n  // Combine plates data from either source\n  const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;\n  const plates = platesData?.plates ?? [];\n\n  const canReorder = hasPermission('queue:reorder');\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n  };\n\n  const isPrinting = item.status === 'printing';\n  const isPending = item.status === 'pending';\n  const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);\n\n  const isMobileSelectable = isPending && onToggleSelect;\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      className={`\n        group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200\n        border-l-[3px] ${\n          isPrinting ? 'border-l-blue-500' :\n          isPending ? 'border-l-yellow-500' :\n          item.status === 'completed' ? 'border-l-emerald-500' :\n          item.status === 'failed' ? 'border-l-red-500' :\n          'border-l-gray-500'\n        }\n        ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}\n        ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}\n        ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}\n        ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}\n        ${isMobileSelectable ? 'sm:cursor-default' : ''}\n      `}\n      onClick={isMobileSelectable ? () => {\n        if (window.innerWidth < 640) onToggleSelect();\n      } : undefined}\n    >\n      {/* Mobile selected left accent bar */}\n      {isMobileSelectable && isSelected && (\n        <div className=\"sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green\" />\n      )}\n\n      <div className=\"flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4\">\n        {/* Mobile selection indicator — left accent bar only, no tick */}\n\n        {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}\n        {isPending && onToggleSelect && (\n          <button\n            onClick={(e) => {\n              e.stopPropagation();\n              onToggleSelect();\n            }}\n            className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${\n              isSelected\n                ? 'bg-bambu-green border-bambu-green text-white'\n                : 'border-white/30 bg-black/30 hover:border-bambu-green/50'\n            }`}\n          >\n            {isSelected && <Check className=\"w-4 h-4\" />}\n          </button>\n        )}\n\n        {/* Drag handle or position number - hidden on mobile */}\n        {isPending ? (\n          <div\n            {...attributes}\n            {...listeners}\n            className=\"hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0\"\n          >\n            <GripVertical className=\"w-4 h-4 text-bambu-gray\" />\n          </div>\n        ) : position !== undefined ? (\n          <div className=\"hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium shrink-0\">\n            #{position}\n          </div>\n        ) : (\n          <div className=\"hidden sm:block w-8 shrink-0\" />\n        )}\n\n        {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}\n        <div className=\"w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden\">\n          {item.archive_thumbnail ? (\n            <img\n              src={\n                item.plate_id != null\n                  ? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)\n                  : api.getArchiveThumbnail(item.archive_id!)\n              }\n              alt=\"\"\n              className=\"w-full h-full object-cover\"\n            />\n          ) : item.library_file_thumbnail ? (\n            <img\n              src={\n                item.plate_id != null\n                  ? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)\n                  : api.getLibraryFileThumbnailUrl(item.library_file_id!)\n              }\n              alt=\"\"\n              className=\"w-full h-full object-cover\"\n            />\n          ) : (\n            <div className=\"w-full h-full flex items-center justify-center text-bambu-gray\">\n              <Layers className=\"w-5 h-5 sm:w-6 sm:h-6\" />\n            </div>\n          )}\n        </div>\n\n        {/* Info */}\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-center gap-2 mb-1\">\n            <p className=\"text-sm sm:text-base text-white font-medium truncate\">\n              {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}\n              {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}\n            </p>\n            {item.archive_id ? (\n              <Link\n                to={`/archives?highlight=${item.archive_id}`}\n                className=\"text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0\"\n                title={t('queue.viewArchive')}\n              >\n                <ExternalLink className=\"w-3.5 h-3.5\" />\n              </Link>\n            ) : item.library_file_id ? (\n              <Link\n                to={`/library?highlight=${item.library_file_id}`}\n                className=\"text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0\"\n                title={t('queue.viewInFileManager')}\n              >\n                <ExternalLink className=\"w-3.5 h-3.5\" />\n              </Link>\n            ) : null}\n            {item.batch_name && (\n              <span className=\"flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-purple-500/20 text-purple-300 rounded border border-purple-500/30\">\n                {item.batch_name}\n              </span>\n            )}\n          </div>\n\n          <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray\">\n            <span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model && !item.printer_id ? 'text-blue-400' : ''}`}>\n              <Printer className=\"w-3 h-3 sm:w-3.5 sm:h-3.5\" />\n              <span className=\"truncate max-w-[120px] sm:max-w-none\">\n              {item.target_model && !item.printer_id\n                ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`\n                : item.printer_id === null\n                  ? t('queue.filter.unassigned')\n                  : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}\n              </span>\n            </span>\n            {item.print_time_seconds && (\n              <span className=\"flex items-center gap-1 sm:gap-1.5\">\n                <Timer className=\"w-3 h-3 sm:w-3.5 sm:h-3.5\" />\n                {formatDuration(item.print_time_seconds)}\n              </span>\n            )}\n            {item.filament_used_grams && (\n              <span className=\"flex items-center gap-1 sm:gap-1.5\">\n                <Weight className=\"w-3 h-3 sm:w-3.5 sm:h-3.5\" />\n                {formatWeight(item.filament_used_grams)}\n              </span>\n            )}\n            {item.created_by_username && (\n              <span className=\"hidden sm:flex items-center gap-1.5\" title={t('queue.addedBy', { name: item.created_by_username })}>\n                <User className=\"w-3.5 h-3.5\" />\n                {item.created_by_username}\n              </span>\n            )}\n            {isPending && !item.manual_start && (\n              <span className=\"flex items-center gap-1.5\">\n                <Clock className=\"w-3.5 h-3.5\" />\n                {item.scheduled_time\n                  ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000\n                      ? t?.('queue.time.overdue') ?? 'Overdue'\n                      : formatRelativeTime(item.scheduled_time, timeFormat, t))\n                  : t?.('queue.time.asap') ?? 'ASAP'}\n              </span>\n            )}\n          </div>\n\n          {/* Options badges */}\n          <div className=\"flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2\">\n            {item.manual_start && (\n              <span className=\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1\">\n                <Hand className=\"w-2.5 h-2.5 sm:w-3 sm:h-3\" />\n                {t('queue.badges.staged')}\n              </span>\n            )}\n            {item.require_previous_success && (\n              <span className=\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20\">\n                {t('queue.badges.requiresPrevious')}\n              </span>\n            )}\n            {item.auto_off_after && (\n              <span className=\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1\">\n                <Power className=\"w-2.5 h-2.5 sm:w-3 sm:h-3\" />\n                {t('queue.badges.autoPowerOff')}\n              </span>\n            )}\n            {item.gcode_injection && (\n              <span className=\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-emerald-500/10 text-emerald-400 rounded-full border border-emerald-500/20 flex items-center gap-1\">\n                <Code className=\"w-2.5 h-2.5 sm:w-3 sm:h-3\" />\n                {t('queue.badges.gcodeInjection')}\n              </span>\n            )}\n          </div>\n\n          {/* Progress bar for printing items - TODO: integrate with WebSocket */}\n          {isPrinting && status && (() => {\n            // Gate progress/remaining/layer on printer actually running this print.\n            // Between dispatch and RUNNING transition (H2D/P1 MQTT lag), status.progress\n            // is stale from the previous print — showing 100% then snapping back to 0%\n            // once the new print starts. Only trust these fields when state is active.\n            const isActive = status.state === 'RUNNING' || status.state === 'PAUSE';\n            const progress = isActive ? (status.progress || 0) : 0;\n            const remaining = isActive ? status.remaining_time : null;\n            const layerNum = isActive ? status.layer_num : null;\n            const totalLayers = isActive ? status.total_layers : null;\n            return (\n              <div className=\"mt-2 sm:mt-3\">\n                <div className=\"flex items-center justify-between text-xs sm:text-sm\">\n                  <div className=\"flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3\">\n                    <div\n                      className=\"bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all\"\n                      style={{ width: `${progress}%` }}\n                    />\n                  </div>\n                  <span className=\"text-white\">{Math.round(progress)}%</span>\n                </div>\n                <div className=\"flex flex-wrap items-center gap-2 sm:gap-3 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-bambu-gray\">\n                  {remaining != null && remaining > 0 && (\n                    <>\n                      <span className=\"flex items-center gap-1\">\n                        <Clock className=\"w-3 h-3\" />\n                        {formatDuration(remaining * 60)}\n                      </span>\n                      <span className=\"text-bambu-green font-medium\" title={t('printers.estimatedCompletion')}>\n                        ETA {formatETA(remaining, timeFormat, t)}\n                      </span>\n                    </>\n                  )}\n                  {layerNum != null && totalLayers != null && totalLayers > 0 && (\n                    <span className=\"flex items-center gap-1\">\n                      <Layers className=\"w-3 h-3\" />\n                      {layerNum}/{totalLayers}\n                    </span>\n                  )}\n                </div>\n              </div>\n            );\n          })()}\n\n          {/* Waiting reason for model-based assignments */}\n          {item.waiting_reason && item.status === 'pending' && (\n            <p className=\"text-[10px] sm:text-xs text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1\">\n              <AlertCircle className=\"w-3 h-3 mt-0.5 flex-shrink-0\" />\n              <span>{item.waiting_reason}</span>\n            </p>\n          )}\n\n          {/* Error message */}\n          {item.error_message && (\n            <p className=\"text-[10px] sm:text-xs text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1\">\n              <AlertCircle className=\"w-3 h-3\" />\n              {item.error_message}\n            </p>\n          )}\n        </div>\n\n        {/* Status badge + Actions */}\n        <div className=\"flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0\" onClick={(e) => e.stopPropagation()}>\n          <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />\n\n          <div className=\"flex items-center gap-0.5 sm:gap-1\">\n            {isPrinting && (\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={onStop}\n                disabled={!hasPermission('printers:control')}\n                title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}\n                className=\"text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2\"\n              >\n                <StopCircle className=\"w-4 h-4\" />\n              </Button>\n            )}\n            {isPending && (\n              <>\n                {item.manual_start && (\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={onStart}\n                    disabled={!hasPermission('printers:control')}\n                    title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}\n                    className=\"text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2\"\n                  >\n                    <Play className=\"w-4 h-4\" />\n                  </Button>\n                )}\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={onEdit}\n                  disabled={!canModify('queue', 'update', item.created_by_id)}\n                  title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}\n                  className=\"p-1.5 sm:p-2\"\n                >\n                  <Pencil className=\"w-4 h-4\" />\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={onCancel}\n                  disabled={!canModify('queue', 'delete', item.created_by_id)}\n                  title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}\n                  className=\"text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2\"\n                >\n                  <X className=\"w-4 h-4\" />\n                </Button>\n              </>\n            )}\n            {isHistory && (\n              <>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={onRequeue}\n                  disabled={!hasPermission('queue:create')}\n                  title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}\n                  className=\"text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2\"\n                >\n                  <RefreshCw className=\"w-4 h-4\" />\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={onRemove}\n                  disabled={!canModify('queue', 'delete', item.created_by_id)}\n                  title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}\n                  className=\"p-1.5 sm:p-2\"\n                >\n                  <Trash2 className=\"w-4 h-4\" />\n                </Button>\n              </>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function QueuePage() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n  const { hasPermission, hasAnyPermission, canModify } = useAuth();\n  const [filterPrinter, setFilterPrinter] = useState<number | null>(null);\n  const [filterStatus, setFilterStatus] = useState<string>('');\n  const [filterLocation, setFilterLocation] = useState<string>('');\n  const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);\n  const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);\n  const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);\n  const [confirmAction, setConfirmAction] = useState<{\n    type: 'cancel' | 'remove' | 'stop';\n    item: PrintQueueItem;\n  } | null>(null);\n  const [selectedItems, setSelectedItems] = useState<number[]>([]);\n  const [showBulkEditModal, setShowBulkEditModal] = useState(false);\n  const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {\n    const saved = localStorage.getItem('queue.historySortBy');\n    return (saved as 'date' | 'name' | 'printer') || 'date';\n  });\n  const [historySortAsc, setHistorySortAsc] = useState(() => {\n    const saved = localStorage.getItem('queue.historySortAsc');\n    return saved !== null ? saved === 'true' : false;\n  });\n  const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {\n    const saved = localStorage.getItem('queue.pendingSortBy');\n    return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';\n  });\n  const [pendingSortAsc, setPendingSortAsc] = useState(() => {\n    const saved = localStorage.getItem('queue.pendingSortAsc');\n    return saved !== null ? saved === 'true' : true;\n  });\n  const [historyCollapsed, setHistoryCollapsed] = useState(() => {\n    return localStorage.getItem('queue.historyCollapsed') !== 'false';\n  });\n  const [viewMode, setViewMode] = useState<'list' | 'timeline'>(() => {\n    return (localStorage.getItem('queue.viewMode') as 'list' | 'timeline') || 'list';\n  });\n\n  // Persist sort settings to localStorage\n  useEffect(() => {\n    localStorage.setItem('queue.historySortBy', historySortBy);\n  }, [historySortBy]);\n\n  useEffect(() => {\n    localStorage.setItem('queue.historySortAsc', String(historySortAsc));\n  }, [historySortAsc]);\n\n  useEffect(() => {\n    localStorage.setItem('queue.pendingSortBy', pendingSortBy);\n  }, [pendingSortBy]);\n\n  useEffect(() => {\n    localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));\n  }, [pendingSortAsc]);\n\n  useEffect(() => {\n    localStorage.setItem('queue.historyCollapsed', String(historyCollapsed));\n  }, [historyCollapsed]);\n\n  useEffect(() => {\n    localStorage.setItem('queue.viewMode', viewMode);\n  }, [viewMode]);\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),\n    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })\n  );\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const timeFormat: TimeFormat = settings?.time_format || 'system';\n\n  const { data: queue, isLoading } = useQuery({\n    queryKey: ['queue', filterPrinter, filterStatus],\n    queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),\n    refetchInterval: 5000,\n  });\n\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: () => api.getPrinters(),\n  });\n\n  const sjfMutation = useMutation({\n    mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['settings'] });\n    },\n  });\n\n  const cancelMutation = useMutation({\n    mutationFn: (id: number) => api.cancelQueueItem(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      showToast(t('queue.toast.cancelled'));\n    },\n    onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),\n  });\n\n  const removeMutation = useMutation({\n    mutationFn: (id: number) => api.removeFromQueue(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      showToast(t('queue.toast.removed'));\n    },\n    onError: () => showToast(t('queue.toast.removeFailed'), 'error'),\n  });\n\n  const stopMutation = useMutation({\n    mutationFn: (id: number) => api.stopQueueItem(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      showToast(t('queue.toast.stopped'));\n    },\n    onError: () => showToast(t('queue.toast.stopFailed'), 'error'),\n  });\n\n  const startMutation = useMutation({\n    mutationFn: (id: number) => api.startQueueItem(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      showToast(t('queue.toast.released'));\n    },\n    onError: () => showToast(t('queue.toast.startFailed'), 'error'),\n  });\n\n  const reorderMutation = useMutation({\n    mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n    },\n    onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),\n  });\n\n  const clearHistoryMutation = useMutation({\n    mutationFn: async () => {\n      const historyItems = queue?.filter(i =>\n        ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)\n      ) || [];\n      for (const item of historyItems) {\n        await api.removeFromQueue(item.id);\n      }\n      return historyItems.length;\n    },\n    onSuccess: (count) => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      showToast(t('queue.toast.historyCleared', { count }));\n    },\n    onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),\n  });\n\n  const bulkUpdateMutation = useMutation({\n    mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      setSelectedItems([]);\n      setShowBulkEditModal(false);\n      showToast(result.message);\n    },\n    onError: () => showToast(t('queue.toast.updateFailed'), 'error'),\n  });\n\n  const bulkCancelMutation = useMutation({\n    mutationFn: async (ids: number[]) => {\n      for (const id of ids) {\n        await api.cancelQueueItem(id);\n      }\n      return ids.length;\n    },\n    onSuccess: (count) => {\n      queryClient.invalidateQueries({ queryKey: ['queue'] });\n      setSelectedItems([]);\n      showToast(t('queue.toast.bulkCancelled', { count }));\n    },\n    onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),\n  });\n\n  const handleToggleSelect = (id: number) => {\n    setSelectedItems(prev =>\n      prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]\n    );\n  };\n\n  // Get unique locations from printers for the filter dropdown\n  const uniqueLocations = useMemo(() => {\n    const locations = new Set<string>();\n    printers?.forEach(p => {\n      if (p.location) locations.add(p.location);\n    });\n    // Also include locations from queue items (for model-based assignments)\n    queue?.forEach(item => {\n      if (item.target_location) locations.add(item.target_location);\n    });\n    return Array.from(locations).sort();\n  }, [printers, queue]);\n\n  // Helper to check if a queue item matches the location filter\n  const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {\n    if (!filterLocation) return true;\n    // For model-based assignments, check target_location\n    if (item.target_location) return item.target_location === filterLocation;\n    // For printer-based assignments, check the printer's location\n    if (item.printer_id) {\n      const printer = printers?.find(p => p.id === item.printer_id);\n      return printer?.location === filterLocation;\n    }\n    return false;\n  }, [filterLocation, printers]);\n\n  const pendingItems = useMemo(() => {\n    let items = queue?.filter(i => i.status === 'pending') || [];\n\n    // Apply location filter\n    if (filterLocation) {\n      items = items.filter(matchesLocationFilter);\n    }\n\n    // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)\n    const getScheduledTime = (item: PrintQueueItem): number => {\n      if (!item.scheduled_time) return 0;\n      const time = parseUTCDate(item.scheduled_time)?.getTime() ?? 0;\n      // Placeholder dates (> 6 months out) are treated as ASAP\n      const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);\n      return time > sixMonthsFromNow ? 0 : time;\n    };\n\n    // When SJF is enabled, override sort to match scheduler order\n    if (settings?.queue_shortest_first) {\n      return [...items].sort((a, b) => {\n        // Group by printer first (nulls = model-based, grouped by target_model)\n        const aPrinter = a.printer_id ?? -(a.target_model?.charCodeAt(0) ?? 0);\n        const bPrinter = b.printer_id ?? -(b.target_model?.charCodeAt(0) ?? 0);\n        if (aPrinter !== bPrinter) return aPrinter - bPrinter;\n        // Within same printer/model: jumped items first (starvation guard)\n        const aJumped = a.been_jumped ? 1 : 0;\n        const bJumped = b.been_jumped ? 1 : 0;\n        if (aJumped !== bJumped) return bJumped - aJumped;\n        // Shortest print time next (nulls last)\n        const aTime = a.print_time_seconds ?? Infinity;\n        const bTime = b.print_time_seconds ?? Infinity;\n        if (aTime !== bTime) return aTime - bTime;\n        // Position as tiebreaker\n        return a.position - b.position;\n      });\n    }\n\n    return [...items].sort((a, b) => {\n      let cmp: number;\n      if (pendingSortBy === 'name') {\n        const aName = a.archive_name || a.library_file_name || '';\n        const bName = b.archive_name || b.library_file_name || '';\n        cmp = aName.localeCompare(bName);\n      } else if (pendingSortBy === 'printer') {\n        cmp = (a.printer_name || '').localeCompare(b.printer_name || '');\n      } else if (pendingSortBy === 'time') {\n        // Sort by scheduled start time (when print will begin)\n        cmp = getScheduledTime(a) - getScheduledTime(b);\n      } else {\n        cmp = a.position - b.position;\n      }\n      return pendingSortAsc ? cmp : -cmp;\n    });\n  }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation, settings?.queue_shortest_first]);\n\n  const handleSelectAll = () => {\n    const allPendingIds = pendingItems.map(i => i.id);\n    if (selectedItems.length === allPendingIds.length) {\n      setSelectedItems([]);\n    } else {\n      setSelectedItems(allPendingIds);\n    }\n  };\n\n  const activeItems = useMemo(() => {\n    let items = queue?.filter(i => i.status === 'printing') || [];\n    if (filterLocation) {\n      items = items.filter(matchesLocationFilter);\n    }\n    return items;\n  }, [queue, filterLocation, matchesLocationFilter]);\n\n  // Get unique printer IDs from active items to fetch their statuses\n  const activePrinterIds = useMemo(() => {\n    const ids = new Set<number>();\n    activeItems.forEach(item => {\n      if (item.printer_id) ids.add(item.printer_id);\n    });\n    return Array.from(ids);\n  }, [activeItems]);\n\n  // Fetch printer statuses for printers with active jobs\n  const printerStatusQueries = useQueries({\n    queries: activePrinterIds.map(printerId => ({\n      queryKey: ['printerStatus', printerId],\n      queryFn: () => api.getPrinterStatus(printerId),\n      refetchInterval: 5000,\n    })),\n  });\n\n  // Build a map of printer_id -> state for quick lookup\n  const printerStateMap = useMemo(() => {\n    const map: Record<number, string | null> = {};\n    activePrinterIds.forEach((printerId, index) => {\n      const result = printerStatusQueries[index];\n      if (result?.data?.state) {\n        map[printerId] = result.data.state;\n      }\n    });\n    return map;\n  }, [activePrinterIds, printerStatusQueries]);\n\n  // Build a map of printer_id -> full status for timeline view\n  const printerStatusMap = useMemo(() => {\n    const map: Record<number, { progress?: number; remaining_time?: number; state?: string }> = {};\n    activePrinterIds.forEach((printerId, index) => {\n      const result = printerStatusQueries[index];\n      if (result?.data) {\n        map[printerId] = {\n          progress: result.data.progress ?? undefined,\n          remaining_time: result.data.remaining_time ?? undefined,\n          state: result.data.state ?? undefined,\n        };\n      }\n    });\n    return map;\n  }, [activePrinterIds, printerStatusQueries]);\n\n  const historyItems = useMemo(() => {\n    let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];\n    if (filterLocation) {\n      items = items.filter(matchesLocationFilter);\n    }\n    return [...items].sort((a, b) => {\n      let cmp: number;\n      if (historySortBy === 'name') {\n        const aName = a.archive_name || a.library_file_name || '';\n        const bName = b.archive_name || b.library_file_name || '';\n        cmp = aName.localeCompare(bName);\n      } else if (historySortBy === 'printer') {\n        cmp = (a.printer_name || '').localeCompare(b.printer_name || '');\n      } else {\n        // Default: by date - most recent first (desc) is the natural order\n        cmp = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0);\n      }\n      return historySortAsc ? -cmp : cmp;\n    });\n  }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);\n\n  // Calculate total queue time\n  const totalQueueTime = useMemo(() => {\n    return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);\n  }, [pendingItems]);\n\n  // Calculate total material weight\n  const totalWeight = useMemo(() => {\n    return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0);\n  }, [pendingItems]);\n\n  const handleDragEnd = (event: DragEndEvent) => {\n    const { active, over } = event;\n    if (!over || active.id === over.id) return;\n\n    const oldIndex = pendingItems.findIndex(i => i.id === active.id);\n    const newIndex = pendingItems.findIndex(i => i.id === over.id);\n\n    if (oldIndex !== -1 && newIndex !== -1) {\n      const reordered = arrayMove(pendingItems, oldIndex, newIndex);\n      const updates = reordered.map((item, index) => ({\n        id: item.id,\n        position: index + 1,\n      }));\n      reorderMutation.mutate(updates);\n    }\n  };\n\n  return (\n    <div className=\"p-4 md:p-8\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between mb-8\">\n        <div>\n          <h1 className=\"text-2xl font-bold text-white flex items-center gap-3\">\n            <ListOrdered className=\"w-7 h-7 text-bambu-green\" />\n            {t('queue.title')}\n          </h1>\n          <p className=\"text-bambu-gray mt-1\">{t('queue.subtitle')}</p>\n        </div>\n      </div>\n\n      {/* Summary Stats */}\n      <QueueStatsBar\n        activeCount={activeItems.length}\n        pendingCount={pendingItems.length}\n        totalTime={totalQueueTime}\n        totalWeight={totalWeight}\n        historyCount={historyItems.length}\n        t={t}\n      />\n\n      {/* Filters */}\n      <div className=\"flex flex-wrap items-center gap-2 sm:gap-4 mb-6\">\n        <select\n          className=\"px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none\"\n          value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}\n          onChange={(e) => {\n            const val = e.target.value;\n            if (val === 'unassigned') setFilterPrinter(-1);\n            else if (val === '') setFilterPrinter(null);\n            else setFilterPrinter(Number(val));\n          }}\n        >\n          <option value=\"\">{t('queue.filter.allPrinters')}</option>\n          <option value=\"unassigned\">{t('queue.filter.unassigned')}</option>\n          {printers?.map((p) => (\n            <option key={p.id} value={p.id}>{p.name}</option>\n          ))}\n        </select>\n\n        <select\n          className=\"px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none\"\n          value={filterStatus}\n          onChange={(e) => setFilterStatus(e.target.value)}\n        >\n          <option value=\"\">{t('queue.filter.allStatus')}</option>\n          <option value=\"pending\">{t('queue.status.pending')}</option>\n          <option value=\"printing\">{t('queue.status.printing')}</option>\n          <option value=\"completed\">{t('queue.status.completed')}</option>\n          <option value=\"failed\">{t('queue.status.failed')}</option>\n          <option value=\"skipped\">{t('queue.status.skipped')}</option>\n          <option value=\"cancelled\">{t('queue.status.cancelled')}</option>\n        </select>\n\n        {uniqueLocations.length > 0 && (\n          <select\n            className=\"px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none\"\n            value={filterLocation}\n            onChange={(e) => setFilterLocation(e.target.value)}\n          >\n            <option value=\"\">{t('queue.filter.allLocations')}</option>\n            {uniqueLocations.map((loc) => (\n              <option key={loc} value={loc}>{loc}</option>\n            ))}\n          </select>\n        )}\n\n        <div className=\"hidden sm:block flex-1\" />\n\n        {historyItems.length > 0 && (\n          <Button\n            className=\"w-full sm:w-auto\"\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={() => setShowClearHistoryConfirm(true)}\n            disabled={!hasPermission('queue:delete_all')}\n            title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}\n          >\n            <Trash2 className=\"w-4 h-4\" />\n            {t('queue.clearHistory')}\n          </Button>\n        )}\n      </div>\n\n      {/* View Mode Toggle + SJF */}\n      <div className=\"flex items-center gap-3 mb-6\">\n        <div className=\"hidden sm:flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden\">\n          <button\n            className={`p-2 transition-colors ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}\n            onClick={() => setViewMode('list')}\n            title={t('queue.timeline.listView')}\n          >\n            <List className=\"w-4 h-4\" />\n          </button>\n          <button\n            className={`p-2 transition-colors ${viewMode === 'timeline' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}\n            onClick={() => setViewMode('timeline')}\n            title={t('queue.timeline.timelineView')}\n          >\n            <GanttChart className=\"w-4 h-4\" />\n          </button>\n        </div>\n        <button\n          onClick={() => {\n            const newValue = !(settings?.queue_shortest_first ?? false);\n            sjfMutation.mutate(newValue);\n          }}\n          className={`flex items-center gap-1 px-2 py-1.5 text-xs rounded-lg border transition-colors ${\n            settings?.queue_shortest_first\n              ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'\n              : 'bg-bambu-dark-secondary border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'\n          }`}\n          title={t('queue.sjf.tooltip', 'Shortest Job First — scheduler prioritizes shorter prints')}\n        >\n          <Snail className=\"w-4 h-4\" />\n          <span className=\"hidden sm:inline\">{t('queue.sjf.label', 'SJF')}</span>\n          <span className={`w-1.5 h-1.5 rounded-full ${settings?.queue_shortest_first ? 'bg-bambu-green' : 'bg-bambu-gray'}`} />\n        </button>\n      </div>\n\n      {isLoading ? (\n        <div className=\"text-center py-12 text-bambu-gray\">{t('common.loading')}</div>\n      ) : queue?.length === 0 ? (\n        <Card className=\"p-12 text-center border-dashed\">\n          <Calendar className=\"w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50\" />\n          <h3 className=\"text-xl font-medium text-white mb-2\">{t('queue.empty.title')}</h3>\n          <p className=\"text-bambu-gray max-w-md mx-auto\">\n            {t('queue.empty.description')}\n          </p>\n        </Card>\n      ) : viewMode === 'timeline' ? (\n        <QueueTimelineView\n          queueItems={queue || []}\n          printerStatuses={printerStatusMap}\n          onItemClick={(item) => {\n            if (['completed', 'failed', 'skipped', 'cancelled'].includes(item.status)) {\n              setRequeueItem(item);\n            } else if (item.status === 'pending') {\n              setEditItem(item);\n            } else if (item.status === 'printing') {\n              setConfirmAction({ type: 'stop', item });\n            }\n          }}\n          t={t}\n        />\n      ) : (\n        <div className=\"space-y-6 sm:space-y-8\">\n          {/* Active Prints */}\n          {activeItems.length > 0 && (\n            <div>\n              <h2 className=\"text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2\">\n                <div className=\"w-2 h-2 rounded-full bg-blue-400 animate-pulse\" />\n                {t('queue.sections.currentlyPrinting')}\n              </h2>\n              <div className=\"space-y-2 sm:space-y-3\">\n                {activeItems.map((item) => (\n                  <SortableQueueItem\n                    key={item.id}\n                    item={item}\n                    onEdit={() => {}}\n                    onCancel={() => {}}\n                    onRemove={() => {}}\n                    onStop={() => setConfirmAction({ type: 'stop', item })}\n                    onRequeue={() => {}}\n                    onStart={() => {}}\n                    timeFormat={timeFormat}\n                    hasPermission={hasPermission}\n                    canModify={canModify}\n                    printerState={item.printer_id ? printerStateMap[item.printer_id] : null}\n                    t={t}\n                  />\n                ))}\n              </div>\n            </div>\n          )}\n\n          {/* Pending Queue */}\n          {pendingItems.length > 0 && (\n            <div>\n              <div className=\"flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4\">\n                <h2 className=\"text-base sm:text-lg font-semibold text-white flex items-center gap-2\">\n                  <Clock className=\"w-4 h-4 sm:w-5 sm:h-5 text-yellow-400\" />\n                  {t('queue.sections.queued')}\n                  <span className=\"text-xs sm:text-sm font-normal text-bambu-gray\">\n                    ({t('queue.itemCount', { count: pendingItems.length })})\n                  </span>\n                  <span className=\"hidden sm:inline text-xs text-bambu-gray ml-2\" title={t('queue.reorderHint')}>\n                    {t('queue.dragToReorder')}\n                  </span>\n                </h2>\n                <div className=\"flex items-center gap-2\">\n                  <select\n                    className=\"px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                    value={pendingSortBy}\n                    onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}\n                  >\n                    <option value=\"position\">{t('queue.sort.byPosition')}</option>\n                    <option value=\"name\">{t('queue.sort.byName')}</option>\n                    <option value=\"printer\">{t('queue.sort.byPrinter')}</option>\n                    <option value=\"time\">{t('queue.sort.bySchedule')}</option>\n                  </select>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => setPendingSortAsc(!pendingSortAsc)}\n                    title={pendingSortAsc ? t('common.ascending') : t('common.descending')}\n                    className=\"px-2\"\n                  >\n                    {pendingSortAsc ? <ArrowUp className=\"w-4 h-4\" /> : <ArrowDown className=\"w-4 h-4\" />}\n                  </Button>\n                </div>\n              </div>\n\n              {/* Bulk action toolbar */}\n              <div className=\"flex flex-wrap items-center gap-2 sm:gap-3 mb-3 sm:mb-4 p-2 sm:p-3 bg-bambu-dark rounded-lg\">\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={handleSelectAll}\n                  className=\"flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm\"\n                >\n                  {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (\n                    <CheckSquare className=\"w-4 h-4 text-bambu-green\" />\n                  ) : (\n                    <Square className=\"w-4 h-4\" />\n                  )}\n                  {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}\n                </Button>\n                {selectedItems.length > 0 && (\n                  <>\n                    <span className=\"text-xs sm:text-sm text-bambu-gray\">\n                      {t('queue.bulkEdit.selected', { count: selectedItems.length })}\n                    </span>\n                    <div className=\"hidden sm:block h-4 w-px bg-bambu-dark-tertiary\" />\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => setShowBulkEditModal(true)}\n                      className=\"flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light\"\n                      disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}\n                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : t('queue.bulkEdit.editSelected')}\n                    >\n                      <Pencil className=\"w-3.5 h-3.5 sm:w-4 sm:h-4\" />\n                      <span className=\"hidden sm:inline\">{t('queue.bulkEdit.editSelected')}</span>\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => bulkCancelMutation.mutate(selectedItems)}\n                      className=\"flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-400 hover:text-red-300\"\n                      disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}\n                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : t('queue.bulkEdit.cancelSelected')}\n                    >\n                      <X className=\"w-3.5 h-3.5 sm:w-4 sm:h-4\" />\n                      <span className=\"hidden sm:inline\">{t('queue.bulkEdit.cancelSelected')}</span>\n                    </Button>\n                  </>\n                )}\n              </div>\n\n              <DndContext\n                sensors={sensors}\n                collisionDetection={closestCenter}\n                onDragEnd={handleDragEnd}\n              >\n                <SortableContext\n                  items={pendingItems.map(i => i.id)}\n                  strategy={verticalListSortingStrategy}\n                >\n                  <div className=\"space-y-2 sm:space-y-3\">\n                    {pendingItems.map((item, index) => (\n                      <SortableQueueItem\n                        key={item.id}\n                        item={item}\n                        position={index + 1}\n                        onEdit={() => setEditItem(item)}\n                        onCancel={() => setConfirmAction({ type: 'cancel', item })}\n                        onRemove={() => {}}\n                        onStop={() => {}}\n                        onRequeue={() => {}}\n                        onStart={() => startMutation.mutate(item.id)}\n                        timeFormat={timeFormat}\n                        isSelected={selectedItems.includes(item.id)}\n                        onToggleSelect={() => handleToggleSelect(item.id)}\n                        hasPermission={hasPermission}\n                        canModify={canModify}\n                        t={t}\n                      />\n                    ))}\n                  </div>\n                </SortableContext>\n              </DndContext>\n            </div>\n          )}\n\n          {/* History */}\n          {historyItems.length > 0 && (\n            <div>\n              <div className=\"flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4\">\n                <button\n                  onClick={() => setHistoryCollapsed(!historyCollapsed)}\n                  className=\"text-base sm:text-lg font-semibold text-white flex items-center gap-2 hover:text-bambu-green transition-colors\"\n                >\n                  {historyCollapsed ? <ChevronRight className=\"w-4 h-4 sm:w-5 sm:h-5\" /> : <ChevronDown className=\"w-4 h-4 sm:w-5 sm:h-5\" />}\n                  {t('queue.sections.history')}\n                  <span className=\"text-xs sm:text-sm font-normal text-bambu-gray\">\n                    ({t('queue.itemCount', { count: historyItems.length })})\n                  </span>\n                </button>\n                {!historyCollapsed && (\n                  <div className=\"flex items-center gap-2\">\n                    <select\n                      className=\"px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                      value={historySortBy}\n                      onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}\n                    >\n                      <option value=\"date\">{t('queue.sort.byDate')}</option>\n                      <option value=\"name\">{t('queue.sort.byName')}</option>\n                      <option value=\"printer\">{t('queue.sort.byPrinter')}</option>\n                    </select>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => setHistorySortAsc(!historySortAsc)}\n                      title={historySortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}\n                      className=\"px-2\"\n                    >\n                      {historySortAsc ? <ArrowUp className=\"w-4 h-4\" /> : <ArrowDown className=\"w-4 h-4\" />}\n                    </Button>\n                  </div>\n                )}\n              </div>\n              {!historyCollapsed && (\n                <div className=\"space-y-1.5 sm:space-y-2\">\n                  {historyItems.slice(0, 50).map((item) => (\n                    <CompactHistoryRow\n                      key={item.id}\n                      item={item}\n                      onRemove={() => setConfirmAction({ type: 'remove', item })}\n                      onRequeue={() => setRequeueItem(item)}\n                      timeFormat={timeFormat}\n                      hasPermission={hasPermission}\n                      canModify={canModify}\n                      t={t}\n                    />\n                  ))}\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Edit Modal */}\n      {editItem && (\n        <PrintModal\n          mode=\"edit-queue-item\"\n          archiveId={editItem.archive_id ?? undefined}\n          libraryFileId={editItem.library_file_id ?? undefined}\n          archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}\n          queueItem={editItem}\n          onClose={() => setEditItem(null)}\n        />\n      )}\n\n      {/* Re-queue Modal */}\n      {requeueItem && (\n        <PrintModal\n          mode=\"add-to-queue\"\n          archiveId={requeueItem.archive_id ?? undefined}\n          libraryFileId={requeueItem.library_file_id ?? undefined}\n          archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}\n          onClose={() => setRequeueItem(null)}\n        />\n      )}\n\n      {/* Confirm Action Modal */}\n      {confirmAction && (\n        <ConfirmModal\n          title={\n            confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :\n            confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :\n            t('queue.confirm.removeTitle')\n          }\n          message={\n            confirmAction.type === 'cancel'\n              ? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })\n              : confirmAction.type === 'stop'\n              ? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })\n              : t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })\n          }\n          confirmText={\n            confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :\n            confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :\n            t('common.remove')\n          }\n          variant=\"danger\"\n          onConfirm={() => {\n            if (confirmAction.type === 'cancel') {\n              cancelMutation.mutate(confirmAction.item.id);\n            } else if (confirmAction.type === 'stop') {\n              stopMutation.mutate(confirmAction.item.id);\n            } else {\n              removeMutation.mutate(confirmAction.item.id);\n            }\n            setConfirmAction(null);\n          }}\n          onCancel={() => setConfirmAction(null)}\n        />\n      )}\n\n      {/* Clear History Confirm Modal */}\n      {showClearHistoryConfirm && (\n        <ConfirmModal\n          title={t('queue.confirm.clearHistoryTitle')}\n          message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}\n          confirmText={t('queue.clearHistory')}\n          variant=\"danger\"\n          onConfirm={() => {\n            clearHistoryMutation.mutate();\n            setShowClearHistoryConfirm(false);\n          }}\n          onCancel={() => setShowClearHistoryConfirm(false)}\n        />\n      )}\n\n      {/* Bulk Edit Modal */}\n      {showBulkEditModal && (\n        <BulkEditModal\n          selectedCount={selectedItems.length}\n          printers={printers?.map(p => ({ id: p.id, name: p.name })) || []}\n          onSave={(data) => {\n            if (Object.keys(data).length > 0) {\n              bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data });\n            }\n          }}\n          onClose={() => setShowBulkEditModal(false)}\n          isSaving={bulkUpdateMutation.isPending}\n          canControlPrinter={hasPermission('printers:control')}\n          t={t}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/SettingsPage.tsx",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { useNavigate, useSearchParams } from 'react-router-dom';\nimport { api } from '../api/client';\nimport { useAuth } from '../contexts/AuthContext';\nimport { formatDateOnly } from '../utils/date';\nimport { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';\nimport type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';\nimport { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';\nimport { Collapsible } from '../components/Collapsible';\nimport { Button } from '../components/Button';\nimport { SmartPlugCard } from '../components/SmartPlugCard';\nimport { AddSmartPlugModal } from '../components/AddSmartPlugModal';\nimport { NotificationProviderCard } from '../components/NotificationProviderCard';\nimport { AddNotificationModal } from '../components/AddNotificationModal';\nimport { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';\nimport { NotificationLogViewer } from '../components/NotificationLogViewer';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';\nimport { SpoolmanSettings } from '../components/SpoolmanSettings';\nimport { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';\nimport { ColorCatalogSettings } from '../components/ColorCatalogSettings';\nimport { ExternalLinksSettings } from '../components/ExternalLinksSettings';\nimport { VirtualPrinterList } from '../components/VirtualPrinterList';\nimport { SpoolBuddySettings } from '../components/SpoolBuddySettings';\nimport { GitHubBackupSettings } from '../components/GitHubBackupSettings';\nimport { FailureDetectionSettings } from '../components/FailureDetectionSettings';\nimport { EmailSettings } from '../components/EmailSettings';\nimport { LDAPSettings } from '../components/LDAPSettings';\nimport { TwoFactorSettings } from '../components/TwoFactorSettings';\nimport { OIDCProviderSettings } from '../components/OIDCProviderSettings';\nimport { APIBrowser } from '../components/APIBrowser';\nimport { Toggle } from '../components/Toggle';\nimport { virtualPrinterApi, spoolbuddyApi } from '../api/client';\nimport { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';\nimport { availableLanguages } from '../i18n';\nimport { useToast } from '../contexts/ToastContext';\nimport { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';\nimport { useState, useEffect, useRef, useCallback } from 'react';\nimport { Palette } from 'lucide-react';\nimport { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';\n\nconst validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;\ntype TabType = typeof validTabs[number];\ntype UsersSubTab = 'users' | 'email' | 'ldap' | 'twofa' | 'oidc';\n\n// Cross-tab search registrations for cards rendered inline in this file.\n// Adding a new settings card? Register it here (or, if the card lives in its\n// own component file, call registerSettingsSearch at that file's module scope).\nregisterSettingsSearch({ labelKey: 'settings.general', tab: 'general', keywords: 'language date time format printer model printers cards', anchor: 'card-general' });\nregisterSettingsSearch({ labelKey: 'settings.appearance', tab: 'general', keywords: 'theme dark light mode colors', anchor: 'card-appearance' });\nregisterSettingsSearch({ labelKey: 'settings.archiveSettings', tab: 'general', keywords: 'archive auto save thumbnails captures', anchor: 'card-archive' });\nregisterSettingsSearch({ labelKey: 'settings.camera', tab: 'general', keywords: 'camera external video stream', anchor: 'card-camera' });\nregisterSettingsSearch({ labelKey: 'settings.costTracking', tab: 'general', keywords: 'currency filament cost energy kwh price', anchor: 'card-cost' });\nregisterSettingsSearch({ labelKey: 'settings.fileManager', tab: 'general', keywords: 'file manager archive mode disk warning storage', anchor: 'card-filemanager' });\nregisterSettingsSearch({ labelKey: 'settings.updates', tab: 'general', keywords: 'updates version firmware beta check', anchor: 'card-updates' });\nregisterSettingsSearch({ labelKey: 'settings.dataManagement', tab: 'general', keywords: 'data reset clear logs notifications preferences', anchor: 'card-data' });\nregisterSettingsSearch({ labelKey: 'settings.smartPlugs', tab: 'plugs', keywords: 'smart plug energy power automation tapo kasa tplink shelly', anchor: 'card-plugs' });\nregisterSettingsSearch({ labelKey: 'settings.providers', tab: 'notifications', keywords: 'telegram discord email notification providers webhook', anchor: 'card-providers' });\nregisterSettingsSearch({ labelKey: 'settings.messageTemplates', tab: 'notifications', keywords: 'message templates notification text edit', anchor: 'card-templates' });\nregisterSettingsSearch({ labelKey: 'settings.defaultPrintOptions', labelFallback: 'Default Print Options', tab: 'queue', keywords: 'print bed leveling flow calibration vibration first layer timelapse', anchor: 'card-print-options' });\nregisterSettingsSearch({ labelKey: 'settings.staggeredStart', labelFallback: 'Staggered Start', tab: 'queue', keywords: 'staggered batch delay start queue group', anchor: 'card-staggered' });\nregisterSettingsSearch({ labelKey: 'settings.plateClear', labelFallback: 'Plate-Clear Confirmation', tab: 'queue', keywords: 'plate clear confirm auto queue', anchor: 'card-plate' });\nregisterSettingsSearch({ labelKey: 'settings.gcodeInjection', labelFallback: 'G-code Injection', tab: 'queue', keywords: 'gcode injection start end autoprint farmloop swapmod autoclear printflow', anchor: 'card-gcode' });\nregisterSettingsSearch({ labelKey: 'settings.queueDrying', tab: 'queue', keywords: 'drying presets temperature time humidity ams', anchor: 'card-drying' });\nregisterSettingsSearch({ labelKey: 'settings.filamentChecks', tab: 'filament', keywords: 'filament check warning runout remaining', anchor: 'card-filamentchecks' });\nregisterSettingsSearch({ labelKey: 'settings.printModal', tab: 'filament', keywords: 'print modal custom mapping', anchor: 'card-printmodal' });\nregisterSettingsSearch({ labelKey: 'settings.amsDisplayThresholds', tab: 'filament', keywords: 'ams humidity temperature threshold history retention', anchor: 'card-amsthresholds' });\nregisterSettingsSearch({ labelKey: 'settings.externalUrl', tab: 'network', keywords: 'external url reverse proxy public notification link', anchor: 'card-externalurl' });\nregisterSettingsSearch({ labelKey: 'settings.ftpRetry', tab: 'network', keywords: 'ftp retry upload retries backoff', anchor: 'card-ftpretry' });\nregisterSettingsSearch({ labelKey: 'settings.homeAssistant', tab: 'network', keywords: 'home assistant ha hass mqtt integration', anchor: 'card-ha' });\nregisterSettingsSearch({ labelKey: 'settings.mqttPublishing', tab: 'network', keywords: 'mqtt publish broker topic', anchor: 'card-mqtt' });\nregisterSettingsSearch({ labelKey: 'settings.prometheusMetrics', tab: 'network', keywords: 'prometheus metrics grafana monitoring bearer token', anchor: 'card-prometheus' });\nregisterSettingsSearch({ labelKey: 'settings.createNewApiKey', tab: 'apikeys', keywords: 'api key create permission scope', anchor: 'card-createapi' });\nregisterSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' });\nregisterSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' });\nregisterSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' });\nregisterSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' });\nregisterSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' });\nregisterSettingsSearch({ labelKey: 'settings.users', tab: 'users', subTab: 'users', keywords: 'users accounts list', anchor: 'card-users' });\nregisterSettingsSearch({ labelKey: 'settings.groups', tab: 'users', subTab: 'users', keywords: 'groups roles permissions administrators operators viewers', anchor: 'card-groups' });\nregisterSettingsSearch({ labelKey: 'settings.email.smtpSettings', labelFallback: 'SMTP Configuration', tab: 'users', subTab: 'email', keywords: 'smtp email send server port password auth starttls ssl', anchor: 'card-smtp' });\nregisterSettingsSearch({ labelKey: 'settings.ldap.title', labelFallback: 'LDAP Authentication', tab: 'users', subTab: 'ldap', keywords: 'ldap active directory ad authentication bind dn search base group mapping', anchor: 'card-ldap' });\nregisterSettingsSearch({ labelKey: 'settings.tabs.backup', tab: 'backup', keywords: 'backup github restore download cloud sync profiles archives', anchor: 'card-backup' });\n// Sidebar Links (external links settings is rendered in the General tab)\nregisterSettingsSearch({ labelKey: 'externalLinks.title', labelFallback: 'Sidebar Links', tab: 'general', keywords: 'sidebar links external custom navigation url add', anchor: 'card-sidebar-links' });\n// Filament tab — integrations\nregisterSettingsSearch({ labelKey: 'settings.filamentTracking', tab: 'filament', keywords: 'spoolman filament tracking inventory sync remote integration', anchor: 'card-spoolman' });\nregisterSettingsSearch({ labelKey: 'settings.catalog.spoolCatalog', labelFallback: 'Spool Catalog', tab: 'filament', keywords: 'spool catalog entries brand material reset import export', anchor: 'card-spool-catalog' });\nregisterSettingsSearch({ labelKey: 'settings.colorCatalog.title', labelFallback: 'Color Catalog', tab: 'filament', keywords: 'color catalog hex swatch palette sync reset', anchor: 'card-color-catalog' });\n// Failure detection sub-cards\nregisterSettingsSearch({ labelKey: 'settings.tabs.failureDetection', labelFallback: 'Failure Detection', tab: 'failure-detection', keywords: 'failure detection ai ml obico spaghetti detect monitoring', anchor: 'card-fd-ml' });\nregisterSettingsSearch({ labelKey: 'failureDetection.perPrinterTitle', labelFallback: 'Per-Printer Settings', tab: 'failure-detection', keywords: 'failure detection per printer enable per-printer sensitivity', anchor: 'card-fd-perprinter' });\nregisterSettingsSearch({ labelKey: 'failureDetection.statusTitle', labelFallback: 'Detection Status', tab: 'failure-detection', keywords: 'failure detection status running connection', anchor: 'card-fd-status' });\nregisterSettingsSearch({ labelKey: 'failureDetection.historyTitle', labelFallback: 'Detection History', tab: 'failure-detection', keywords: 'failure detection history log events', anchor: 'card-fd-history' });\n// Email auth sub-cards (subTab=email)\nregisterSettingsSearch({ labelKey: 'settings.email.advancedAuth', labelFallback: 'Advanced Email Authentication', tab: 'users', subTab: 'email', keywords: 'email authentication advanced password reset self-service forgot', anchor: 'card-email-advanced-auth' });\nregisterSettingsSearch({ labelKey: 'settings.email.testConnection', labelFallback: 'Test SMTP Connection', tab: 'users', subTab: 'email', keywords: 'email smtp test connection send check', anchor: 'card-email-test' });\n// Two-Factor sub-cards (subTab=twofa)\nregisterSettingsSearch({ labelKey: 'settings.twoFa.totpTitle', labelFallback: 'Authenticator App (TOTP)', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa totp authenticator app google authy otp', anchor: 'card-2fa-totp' });\nregisterSettingsSearch({ labelKey: 'settings.twoFa.emailOtpTitle', labelFallback: 'Email One-Time Codes', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa email otp one time code', anchor: 'card-2fa-emailotp' });\nregisterSettingsSearch({ labelKey: 'settings.twoFa.linkedAccounts', labelFallback: 'Linked Accounts', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa linked accounts sso oidc provider google github', anchor: 'card-2fa-linked' });\n// OIDC / SSO (subTab=oidc)\nregisterSettingsSearch({ labelKey: 'settings.oidc.title', labelFallback: 'Single Sign-On (OIDC)', tab: 'users', subTab: 'oidc', keywords: 'sso oidc openid single sign-on pocketid authentik keycloak google okta azure provider', anchor: 'card-oidc' });\n// LDAP server config card (complements existing card-ldap)\nregisterSettingsSearch({ labelKey: 'settings.ldap.serverConfig', labelFallback: 'LDAP Server Configuration', tab: 'users', subTab: 'ldap', keywords: 'ldap server url bind dn user search base group filter tls', anchor: 'card-ldap-server' });\n// Backup sub-cards\nregisterSettingsSearch({ labelKey: 'backup.githubBackup', labelFallback: 'GitHub Backup', tab: 'backup', keywords: 'github backup cloud remote sync profiles token', anchor: 'card-backup-github' });\nregisterSettingsSearch({ labelKey: 'backup.history', labelFallback: 'Backup History', tab: 'backup', keywords: 'backup history log runs github commits', anchor: 'card-backup-history' });\nregisterSettingsSearch({ labelKey: 'backup.localBackup', labelFallback: 'Local Backup', tab: 'backup', keywords: 'local backup download zip manual export', anchor: 'card-backup-local' });\nregisterSettingsSearch({ labelKey: 'backup.scheduledBackup', labelFallback: 'Scheduled Backups', tab: 'backup', keywords: 'scheduled backup automatic hourly daily weekly retention local path', anchor: 'card-backup-scheduled' });\n\nconst STORAGE_CATEGORY_COLORS: Record<string, string> = {\n  database: 'bg-blue-600',\n  library_files: 'bg-green-500',\n  library_thumbnails: 'bg-teal-500',\n  library_other: 'bg-emerald-700',\n  archive_timelapses: 'bg-red-500',\n  archive_thumbnails: 'bg-amber-500',\n  archive_files: 'bg-sky-500',\n  virtual_printer_uploads: 'bg-purple-500',\n  virtual_printer_upload_cache: 'bg-fuchsia-500',\n  virtual_printer_certs: 'bg-violet-500',\n  virtual_printer_other: 'bg-purple-700',\n  downloads: 'bg-cyan-500',\n  plate_calibration: 'bg-lime-500',\n  logs: 'bg-orange-500',\n  other_data: 'bg-yellow-500',\n};\n\nconst STORAGE_FALLBACK_COLORS = [\n  'bg-blue-500',\n  'bg-green-500',\n  'bg-yellow-500',\n  'bg-red-500',\n  'bg-orange-500',\n  'bg-teal-500',\n  'bg-cyan-500',\n  'bg-purple-500',\n];\n\nconst getStorageColor = (key: string, index: number) =>\n  STORAGE_CATEGORY_COLORS[key] || STORAGE_FALLBACK_COLORS[index % STORAGE_FALLBACK_COLORS.length];\n\nexport function SettingsPage() {\n  const queryClient = useQueryClient();\n  const navigate = useNavigate();\n  const [searchParams, setSearchParams] = useSearchParams();\n  const { t, i18n } = useTranslation();\n  const { showToast } = useToast();\n  const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = useAuth();\n  const {\n    mode,\n    darkStyle, darkBackground, darkAccent,\n    lightStyle, lightBackground, lightAccent,\n    setDarkStyle, setDarkBackground, setDarkAccent,\n    setLightStyle, setLightBackground, setLightAccent,\n  } = useTheme();\n  const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);\n  const [showPlugModal, setShowPlugModal] = useState(false);\n  const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);\n  const [showNotificationModal, setShowNotificationModal] = useState(false);\n  const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);\n  const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);\n  const [templateFilter, setTemplateFilter] = useState('');\n  const [settingsSearch, setSettingsSearch] = useState('');\n  const [showLogViewer, setShowLogViewer] = useState(false);\n  const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());\n\n  // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)\n  const tabParam = searchParams.get('tab');\n  const isLegacyEmailTab = tabParam === 'email';\n  const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');\n  const [activeTab, setActiveTab] = useState<TabType>(initialTab);\n  const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');\n\n  // Update URL when tab changes\n  const handleTabChange = (tab: TabType) => {\n    setActiveTab(tab);\n    if (tab === 'users') {\n      setUsersSubTab('users');\n    }\n    if (tab === 'general') {\n      searchParams.delete('tab');\n    } else {\n      searchParams.set('tab', tab);\n    }\n    setSearchParams(searchParams, { replace: true });\n  };\n  const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);\n  const [newAPIKeyName, setNewAPIKeyName] = useState('');\n  const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({\n    can_queue: true,\n    can_control_printer: false,\n    can_read_status: true,\n  });\n  const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);\n  const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);\n  const [testApiKey, setTestApiKey] = useState('');\n\n  // Confirm modal states\n  const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);\n  const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);\n  const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);\n  const [showReleaseNotes, setShowReleaseNotes] = useState(false);\n  const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);\n  const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);\n  const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });\n  const [changePasswordLoading, setChangePasswordLoading] = useState(false);\n  const [storageUsageRefreshing, setStorageUsageRefreshing] = useState(false);\n\n  // User management state\n  const [showCreateUserModal, setShowCreateUserModal] = useState(false);\n  const [showEditUserModal, setShowEditUserModal] = useState(false);\n  const [editingUserId, setEditingUserId] = useState<number | null>(null);\n  const [deleteUserId, setDeleteUserId] = useState<number | null>(null);\n  const [deleteUserItemCounts, setDeleteUserItemCounts] = useState<{ archives: number; queue_items: number; library_files: number } | null>(null);\n  const [deleteUserLoading, setDeleteUserLoading] = useState(false);\n  const [userFormData, setUserFormData] = useState<{\n    username: string;\n    password?: string;\n    email?: string;\n    confirmPassword: string;\n    role: string;\n    group_ids: number[];\n  }>({\n    username: '',\n    password: '',\n    email: '',\n    confirmPassword: '',\n    role: 'user',\n    group_ids: [],\n  });\n\n  // Group management state\n  const [deleteGroupId, setDeleteGroupId] = useState<number | null>(null);\n\n  // Home Assistant test connection state\n  const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);\n  const [haTestLoading, setHaTestLoading] = useState(false);\n\n  // External camera test state\n  const [extCameraTestResults, setExtCameraTestResults] = useState<Record<number, { success: boolean; error?: string; resolution?: string } | null>>({});\n  const [extCameraTestLoading, setExtCameraTestLoading] = useState<Record<number, boolean>>({});\n\n  const handleDefaultViewChange = (path: string) => {\n    setDefaultViewState(path);\n    setDefaultView(path);\n    showToast(t('settings.toast.settingsSaved'), 'success');\n  };\n\n  const handleResetSidebarOrder = () => {\n    localStorage.removeItem('sidebarOrder');\n    window.location.reload();\n  };\n\n  const isDefaultSidebarEnabled = !!localSettings?.default_sidebar_order;\n\n  const handleToggleDefaultSidebarOrder = async (enabled: boolean) => {\n    try {\n      if (enabled) {\n        let orderArr: string[];\n        const stored = localStorage.getItem('sidebarOrder');\n        try {\n          orderArr = stored ? JSON.parse(stored) : defaultNavItems.map(i => i.id);\n        } catch {\n          orderArr = defaultNavItems.map(i => i.id);\n        }\n        if (!Array.isArray(orderArr) || orderArr.length === 0) {\n          orderArr = defaultNavItems.map(i => i.id);\n        }\n        const payload = JSON.stringify({ order: orderArr });\n        await api.updateSettings({ default_sidebar_order: payload });\n        setLocalSettings(prev => prev ? { ...prev, default_sidebar_order: payload } : prev);\n        showToast(t('settings.sidebarDefaultSet'), 'success');\n      } else {\n        await api.updateSettings({ default_sidebar_order: '' });\n        setLocalSettings(prev => prev ? { ...prev, default_sidebar_order: '' } : prev);\n        showToast(t('settings.sidebarDefaultCleared'), 'success');\n      }\n      queryClient.invalidateQueries({ queryKey: ['settings'] });\n      queryClient.invalidateQueries({ queryKey: ['default-sidebar-order'] });\n    } catch {\n      showToast(t('settings.sidebarDefaultFailed'), 'error');\n    }\n  };\n\n  const { data: settings, isLoading } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const {\n    data: storageUsage,\n    isLoading: storageUsageLoading,\n    isFetching: storageUsageFetching,\n  } = useQuery<StorageUsageResponse>({\n    queryKey: ['storage-usage'],\n    queryFn: () => api.getStorageUsage(),\n    enabled: activeTab === 'general',\n    staleTime: Infinity,\n    refetchInterval: false,\n    refetchOnWindowFocus: false,\n    refetchOnReconnect: false,\n  });\n\n  const handleStorageUsageRefresh = async () => {\n    setStorageUsageRefreshing(true);\n    try {\n      const data = await api.getStorageUsage({ refresh: true });\n      queryClient.setQueryData(['storage-usage'], data);\n    } catch (error) {\n      const message = error instanceof Error ? error.message : 'Failed to refresh storage usage';\n      showToast(message, 'error');\n    } finally {\n      setStorageUsageRefreshing(false);\n    }\n  };\n\n  const { data: smartPlugs, isLoading: plugsLoading } = useQuery({\n    queryKey: ['smart-plugs'],\n    queryFn: api.getSmartPlugs,\n  });\n\n  // Fetch energy data for all smart plugs when on the plugs tab\n  const { data: plugEnergySummary, isLoading: energyLoading } = useQuery({\n    queryKey: ['smart-plugs-energy', smartPlugs?.map(p => p.id)],\n    queryFn: async () => {\n      if (!smartPlugs || smartPlugs.length === 0) return null;\n      const statuses = await Promise.all(\n        smartPlugs.filter(p => p.enabled).map(async (plug) => {\n          try {\n            const status = await api.getSmartPlugStatus(plug.id);\n            return { plug, status };\n          } catch {\n            return { plug, status: null as SmartPlugStatus | null };\n          }\n        })\n      );\n\n      // Aggregate energy data\n      let totalPower = 0;\n      let totalToday = 0;\n      let totalYesterday = 0;\n      let totalLifetime = 0;\n      let reachableCount = 0;\n\n      for (const { plug, status } of statuses) {\n        // For MQTT plugs, consider reachable if we have power data\n        const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null);\n        const isReachable = (status?.reachable || hasMqttData) && status?.energy;\n\n        if (isReachable) {\n          reachableCount++;\n          if (status.energy?.power != null) totalPower += status.energy.power;\n          if (status.energy?.today != null) totalToday += status.energy.today;\n          if (status.energy?.yesterday != null) totalYesterday += status.energy.yesterday;\n          if (status.energy?.total != null) totalLifetime += status.energy.total;\n        }\n      }\n\n      return {\n        totalPower,\n        totalToday,\n        totalYesterday,\n        totalLifetime,\n        reachableCount,\n        totalPlugs: smartPlugs.filter(p => p.enabled).length,\n      };\n    },\n    enabled: activeTab === 'plugs' && !!smartPlugs && smartPlugs.length > 0,\n    refetchInterval: activeTab === 'plugs' ? 10000 : false, // Refresh every 10s when on plugs tab\n  });\n\n  const { data: notificationProviders, isLoading: providersLoading } = useQuery({\n    queryKey: ['notification-providers'],\n    queryFn: api.getNotificationProviders,\n  });\n\n  const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({\n    queryKey: ['api-keys'],\n    queryFn: api.getAPIKeys,\n  });\n\n  const createAPIKeyMutation = useMutation({\n    mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean }) =>\n      api.createAPIKey(data),\n    onSuccess: (data) => {\n      setCreatedAPIKey(data.key || null);\n      setShowCreateAPIKey(false);\n      setNewAPIKeyName('');\n      queryClient.invalidateQueries({ queryKey: ['api-keys'] });\n      showToast(t('settings.toast.apiKeyCreated'));\n    },\n    onError: (error: Error) => {\n      showToast(`Failed to create API key: ${error.message}`, 'error');\n    },\n  });\n\n  const deleteAPIKeyMutation = useMutation({\n    mutationFn: (id: number) => api.deleteAPIKey(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['api-keys'] });\n      showToast(t('settings.toast.apiKeyDeleted'));\n    },\n    onError: (error: Error) => {\n      showToast(`Failed to delete API key: ${error.message}`, 'error');\n    },\n  });\n\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({\n    queryKey: ['notification-templates'],\n    queryFn: api.getNotificationTemplates,\n  });\n\n  // Virtual printer status for tab indicator\n  const { data: virtualPrinterSettings } = useQuery({\n    queryKey: ['virtual-printer-settings'],\n    queryFn: virtualPrinterApi.getSettings,\n    refetchInterval: 10000,\n  });\n  const virtualPrinterRunning = virtualPrinterSettings?.status?.running ?? false;\n\n  // SpoolBuddy devices for tab indicator\n  const { data: spoolbuddyDevices } = useQuery({\n    queryKey: ['spoolbuddy-devices'],\n    queryFn: () => spoolbuddyApi.getDevices(),\n    refetchInterval: 15000,\n  });\n  const spoolbuddyDeviceCount = spoolbuddyDevices?.length ?? 0;\n  const spoolbuddyAnyOnline = spoolbuddyDevices?.some((d) => d.online) ?? false;\n\n  // Obico failure-detection service status for tab indicator\n  const { data: obicoStatus } = useQuery({\n    queryKey: ['obico-status'],\n    queryFn: api.getObicoStatus,\n    refetchInterval: 15000,\n  });\n  const obicoActive = !!(obicoStatus?.is_running && obicoStatus?.enabled);\n\n  const { data: ffmpegStatus } = useQuery({\n    queryKey: ['ffmpeg-status'],\n    queryFn: api.checkFfmpeg,\n  });\n\n  const { data: versionInfo } = useQuery({\n    queryKey: ['version'],\n    queryFn: api.getVersion,\n  });\n\n  const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({\n    queryKey: ['updateCheck'],\n    queryFn: api.checkForUpdates,\n    enabled: settings?.check_updates !== false,\n    staleTime: 5 * 60 * 1000,\n  });\n\n  const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({\n    queryKey: ['updateStatus'],\n    queryFn: api.getUpdateStatus,\n    refetchInterval: (query) => {\n      const status = query.state.data as UpdateStatus | undefined;\n      // Poll while update is in progress\n      if (status?.status === 'downloading' || status?.status === 'installing') {\n        return 1000;\n      }\n      return false;\n    },\n  });\n\n  // MQTT status for Network tab\n  const { data: mqttStatus } = useQuery({\n    queryKey: ['mqtt-status'],\n    queryFn: api.getMQTTStatus,\n    refetchInterval: activeTab === 'network' ? 5000 : false, // Poll every 5s when on Network tab\n  });\n\n  // GitHub backup status for Backup tab indicator\n  const { data: githubBackupStatus } = useQuery<GitHubBackupStatus>({\n    queryKey: ['github-backup-status'],\n    queryFn: api.getGitHubBackupStatus,\n  });\n\n  // Cloud auth status for Backup tab indicator\n  const { data: cloudAuthStatus } = useQuery<CloudAuthStatus>({\n    queryKey: ['cloud-status'],\n    queryFn: api.getCloudStatus,\n  });\n\n  // Advanced auth status for user creation\n  const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false } } = useQuery({\n    queryKey: ['advancedAuthStatus'],\n    queryFn: () => api.getAdvancedAuthStatus(),\n  });\n\n  const { data: ldapStatus } = useQuery({\n    queryKey: ['ldapStatus'],\n    queryFn: () => api.getLDAPStatus(),\n  });\n\n  // Tab-indicator queries: green bullet when 2FA is enabled for the current\n  // user, or when at least one OIDC provider is configured and enabled.\n  const { data: twoFAStatus } = useQuery({\n    queryKey: ['twoFAStatus'],\n    queryFn: () => api.get2FAStatus(),\n  });\n  const { data: oidcProvidersAll = [] } = useQuery({\n    queryKey: ['oidcProvidersAll'],\n    queryFn: () => api.getOIDCProvidersAll(),\n    enabled: isAdmin,\n  });\n\n  // User management queries and mutations\n  const { data: usersData = [], isLoading: usersLoading } = useQuery({\n    queryKey: ['users'],\n    queryFn: () => api.getUsers(),\n    enabled: authEnabled && hasPermission('users:read'),\n  });\n\n  const { data: groupsData = [], isLoading: groupsLoading } = useQuery({\n    queryKey: ['groups'],\n    queryFn: () => api.getGroups(),\n    enabled: authEnabled && hasPermission('groups:read'),\n  });\n\n  const createUserMutation = useMutation({\n    mutationFn: (data: UserCreate) => api.createUser(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['users'] });\n      queryClient.invalidateQueries({ queryKey: ['groups'] });\n      setShowCreateUserModal(false);\n      setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n      showToast(t('settings.toast.userCreated'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const updateUserMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['users'] });\n      queryClient.invalidateQueries({ queryKey: ['groups'] });\n      setShowEditUserModal(false);\n      setEditingUserId(null);\n      setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n      showToast(t('settings.toast.userUpdated'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const deleteUserMutation = useMutation({\n    mutationFn: ({ id, deleteItems }: { id: number; deleteItems: boolean }) => api.deleteUser(id, deleteItems),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['users'] });\n      showToast(t('settings.toast.userDeleted'));\n      setDeleteUserId(null);\n      setDeleteUserItemCounts(null);\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const resetPasswordMutation = useMutation({\n    mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }),\n    onSuccess: (response) => {\n      showToast(response.message, 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // Function to initiate user deletion with item count check\n  const handleDeleteUserClick = async (userId: number) => {\n    setDeleteUserId(userId);\n    setDeleteUserLoading(true);\n    try {\n      const counts = await api.getUserItemsCount(userId);\n      setDeleteUserItemCounts(counts);\n    } catch {\n      // If we can't get counts, just proceed without showing item options\n      setDeleteUserItemCounts({ archives: 0, queue_items: 0, library_files: 0 });\n    } finally {\n      setDeleteUserLoading(false);\n    }\n  };\n\n  const deleteGroupMutation = useMutation({\n    mutationFn: (id: number) => api.deleteGroup(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['groups'] });\n      showToast(t('settings.toast.groupDeleted'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // User management handlers\n  const handleCreateUser = () => {\n    // Use the status from the query hook\n    const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;\n\n    if (!userFormData.username) {\n      showToast(t('settings.toast.fillRequiredFields'), 'error');\n      return;\n    }\n\n    // Email is required when advanced auth is enabled\n    if (advancedAuthEnabled && !userFormData.email) {\n      showToast('Email is required when advanced authentication is enabled', 'error');\n      return;\n    }\n\n    // Password validation only when advanced auth is disabled\n    if (!advancedAuthEnabled) {\n      if (!userFormData.password) {\n        showToast(t('settings.toast.fillRequiredFields'), 'error');\n        return;\n      }\n      if (userFormData.password !== userFormData.confirmPassword) {\n        showToast(t('settings.toast.passwordsDoNotMatch'), 'error');\n        return;\n      }\n      if (userFormData.password.length < 6) {\n        showToast(t('settings.toast.passwordTooShort'), 'error');\n        return;\n      }\n    }\n\n    createUserMutation.mutate({\n      username: userFormData.username,\n      password: advancedAuthEnabled ? undefined : userFormData.password,\n      email: userFormData.email || undefined,\n      role: userFormData.role,\n      group_ids: userFormData.group_ids.length > 0 ? userFormData.group_ids : undefined,\n    });\n  };\n\n  const handleUpdateUser = (id: number) => {\n    if (userFormData.password) {\n      if (userFormData.password !== userFormData.confirmPassword) {\n        showToast(t('settings.toast.passwordsDoNotMatch'), 'error');\n        return;\n      }\n      if (userFormData.password.length < 6) {\n        showToast(t('settings.toast.passwordTooShort'), 'error');\n        return;\n      }\n    }\n    const updateData: UserUpdate = {\n      username: userFormData.username || undefined,\n      password: userFormData.password || undefined,\n      email: userFormData.email || undefined,\n      role: userFormData.role,\n      group_ids: userFormData.group_ids,\n    };\n    if (!updateData.password) {\n      delete updateData.password;\n    }\n    updateUserMutation.mutate({ id, data: updateData });\n  };\n\n  const startEditUser = (userToEdit: UserResponse) => {\n    setEditingUserId(userToEdit.id);\n    setUserFormData({\n      username: userToEdit.username,\n      password: '',\n      email: userToEdit.email || '',\n      confirmPassword: '',\n      role: userToEdit.role,\n      group_ids: userToEdit.groups?.map(g => g.id) || [],\n    });\n    setShowEditUserModal(true);\n  };\n\n  const toggleUserGroup = (groupId: number) => {\n    setUserFormData(prev => ({\n      ...prev,\n      group_ids: prev.group_ids.includes(groupId)\n        ? prev.group_ids.filter(id => id !== groupId)\n        : [...prev.group_ids, groupId],\n    }));\n  };\n\n  const applyUpdateMutation = useMutation({\n    mutationFn: api.applyUpdate,\n    onSuccess: (data) => {\n      if (data.is_docker) {\n        showToast(data.message, 'error');\n      } else {\n        refetchUpdateStatus();\n      }\n    },\n  });\n\n  // Test all notification providers\n  const [testAllResult, setTestAllResult] = useState<{\n    tested: number;\n    success: number;\n    failed: number;\n    results: Array<{\n      provider_id: number;\n      provider_name: string;\n      provider_type: string;\n      success: boolean;\n      message: string;\n    }>;\n  } | null>(null);\n\n  const testAllMutation = useMutation({\n    mutationFn: api.testAllNotificationProviders,\n    onSuccess: (data) => {\n      setTestAllResult(data);\n      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });\n      if (data.failed === 0) {\n        showToast(`All ${data.tested} providers tested successfully!`, 'success');\n      } else {\n        showToast(`${data.success}/${data.tested} providers succeeded`, data.failed > 0 ? 'error' : 'success');\n      }\n    },\n    onError: (error: Error) => {\n      showToast(`Failed to test providers: ${error.message}`, 'error');\n    },\n  });\n\n  // Bulk action for smart plugs\n  const bulkPlugActionMutation = useMutation({\n    mutationFn: async (action: 'on' | 'off') => {\n      if (!smartPlugs) return { success: 0, failed: 0 };\n      const enabledPlugs = smartPlugs.filter(p => p.enabled);\n      const results = await Promise.all(\n        enabledPlugs.map(async (plug) => {\n          try {\n            await api.controlSmartPlug(plug.id, action);\n            return { success: true };\n          } catch {\n            return { success: false };\n          }\n        })\n      );\n      return {\n        success: results.filter(r => r.success).length,\n        failed: results.filter(r => !r.success).length,\n      };\n    },\n    onSuccess: (data, action) => {\n      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });\n      queryClient.invalidateQueries({ queryKey: ['smart-plugs-energy'] });\n      if (data.failed === 0) {\n        showToast(`All ${data.success} plugs turned ${action}`, 'success');\n      } else {\n        showToast(`${data.success} plugs turned ${action}, ${data.failed} failed`, 'error');\n      }\n    },\n    onError: (error: Error) => {\n      showToast(`Failed: ${error.message}`, 'error');\n    },\n  });\n\n  // Ref for debounce timeout\n  const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const pendingGcodeSnippetsRef = useRef<string | null>(null);\n  const isSavingRef = useRef(false);\n  const isInitialLoadRef = useRef(true);\n\n  // Sync local state when settings load\n  useEffect(() => {\n    if (settings && !localSettings) {\n      // Auto-detect external_url from browser if not set\n      const settingsWithExternalUrl = {\n        ...settings,\n        external_url: settings.external_url || window.location.origin,\n      };\n      setLocalSettings(settingsWithExternalUrl);\n      // Mark initial load complete after a short delay\n      setTimeout(() => {\n        isInitialLoadRef.current = false;\n      }, 100);\n    }\n  }, [settings, localSettings]);\n\n  const updateMutation = useMutation({\n    mutationFn: api.updateSettings,\n    onSuccess: (data) => {\n      queryClient.setQueryData(['settings'], data);\n      // Don't call setLocalSettings(data) here — it would overwrite in-progress\n      // user input (e.g. typing a hostname) with the stale saved snapshot,\n      // causing the text field to reset mid-typing. Instead, let the useEffect\n      // re-compare the updated `settings` with current `localSettings` and\n      // debounce-save any remaining differences.\n      queryClient.invalidateQueries({ queryKey: ['archiveStats'] });\n      showToast(t('settings.toast.settingsSaved'), 'success');\n    },\n    onError: (error: Error) => {\n      showToast(`Failed to save: ${error.message}`, 'error');\n    },\n    onSettled: () => {\n      // Reset saving flag when mutation completes (success or error)\n      isSavingRef.current = false;\n    },\n  });\n\n  const updatePrinterMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> }) =>\n      api.updatePrinter(id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['printers'] });\n      showToast(t('settings.toast.cameraSettingsSaved'), 'success');\n    },\n    onError: (error: Error) => {\n      showToast(`Failed to update printer: ${error.message}`, 'error');\n    },\n  });\n\n  // Debounced auto-save when localSettings change\n  useEffect(() => {\n    // Skip if initial load or no settings\n    if (isInitialLoadRef.current || !localSettings || !settings) {\n      return;\n    }\n\n    // Check if there are actual changes\n    const hasChanges =\n      settings.auto_archive !== localSettings.auto_archive ||\n      settings.save_thumbnails !== localSettings.save_thumbnails ||\n      settings.capture_finish_photo !== localSettings.capture_finish_photo ||\n      settings.default_filament_cost !== localSettings.default_filament_cost ||\n      settings.currency !== localSettings.currency ||\n      settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||\n      settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||\n      settings.check_updates !== localSettings.check_updates ||\n      (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||\n      (settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||\n      settings.notification_language !== localSettings.notification_language ||\n      (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||\n      settings.ams_humidity_good !== localSettings.ams_humidity_good ||\n      settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||\n      settings.ams_temp_good !== localSettings.ams_temp_good ||\n      settings.ams_temp_fair !== localSettings.ams_temp_fair ||\n      settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||\n      settings.disable_filament_warnings !== localSettings.disable_filament_warnings ||\n      settings.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||\n      (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||\n      (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||\n      (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||\n      (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||\n      settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||\n      settings.date_format !== localSettings.date_format ||\n      settings.time_format !== localSettings.time_format ||\n      settings.default_printer_id !== localSettings.default_printer_id ||\n      settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||\n      settings.ftp_retry_count !== localSettings.ftp_retry_count ||\n      settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||\n      settings.ftp_timeout !== localSettings.ftp_timeout ||\n      settings.mqtt_enabled !== localSettings.mqtt_enabled ||\n      settings.mqtt_broker !== localSettings.mqtt_broker ||\n      settings.mqtt_port !== localSettings.mqtt_port ||\n      settings.mqtt_username !== localSettings.mqtt_username ||\n      settings.mqtt_password !== localSettings.mqtt_password ||\n      settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||\n      settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||\n      settings.external_url !== localSettings.external_url ||\n      settings.ha_enabled !== localSettings.ha_enabled ||\n      settings.ha_url !== localSettings.ha_url ||\n      settings.ha_token !== localSettings.ha_token ||\n      (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||\n      Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||\n      (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||\n      (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||\n      settings.prometheus_enabled !== localSettings.prometheus_enabled ||\n      settings.prometheus_token !== localSettings.prometheus_token ||\n      (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||\n      (settings.default_bed_levelling ?? true) !== (localSettings.default_bed_levelling ?? true) ||\n      (settings.default_flow_cali ?? false) !== (localSettings.default_flow_cali ?? false) ||\n      (settings.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||\n      (settings.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||\n      (settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||\n      (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||\n      (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||\n      (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false);\n\n    if (!hasChanges) {\n      return;\n    }\n\n    // Don't queue more saves while one is in progress\n    if (isSavingRef.current) {\n      return;\n    }\n\n    // Clear existing timeout\n    if (saveTimeoutRef.current) {\n      clearTimeout(saveTimeoutRef.current);\n    }\n\n    // Set new debounced save (500ms delay)\n    saveTimeoutRef.current = setTimeout(() => {\n      // Skip if a save is already in progress\n      if (isSavingRef.current) {\n        return;\n      }\n      isSavingRef.current = true;\n      // Only send the fields we manage on this page (exclude virtual_printer_* which are managed separately)\n      const settingsToSave: AppSettingsUpdate = {\n        auto_archive: localSettings.auto_archive,\n        save_thumbnails: localSettings.save_thumbnails,\n        capture_finish_photo: localSettings.capture_finish_photo,\n        default_filament_cost: localSettings.default_filament_cost,\n        currency: localSettings.currency,\n        energy_cost_per_kwh: localSettings.energy_cost_per_kwh,\n        energy_tracking_mode: localSettings.energy_tracking_mode,\n        check_updates: localSettings.check_updates,\n        check_printer_firmware: localSettings.check_printer_firmware,\n        include_beta_updates: localSettings.include_beta_updates,\n        notification_language: localSettings.notification_language,\n        bed_cooled_threshold: localSettings.bed_cooled_threshold,\n        ams_humidity_good: localSettings.ams_humidity_good,\n        ams_humidity_fair: localSettings.ams_humidity_fair,\n        ams_temp_good: localSettings.ams_temp_good,\n        ams_temp_fair: localSettings.ams_temp_fair,\n        ams_history_retention_days: localSettings.ams_history_retention_days,\n        disable_filament_warnings: localSettings.disable_filament_warnings,\n        prefer_lowest_filament: localSettings.prefer_lowest_filament,\n        queue_drying_enabled: localSettings.queue_drying_enabled,\n        queue_drying_block: localSettings.queue_drying_block,\n        ambient_drying_enabled: localSettings.ambient_drying_enabled,\n        drying_presets: localSettings.drying_presets,\n        per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,\n        date_format: localSettings.date_format,\n        time_format: localSettings.time_format,\n        default_printer_id: localSettings.default_printer_id,\n        ftp_retry_enabled: localSettings.ftp_retry_enabled,\n        ftp_retry_count: localSettings.ftp_retry_count,\n        ftp_retry_delay: localSettings.ftp_retry_delay,\n        ftp_timeout: localSettings.ftp_timeout,\n        mqtt_enabled: localSettings.mqtt_enabled,\n        mqtt_broker: localSettings.mqtt_broker,\n        mqtt_port: localSettings.mqtt_port,\n        mqtt_username: localSettings.mqtt_username,\n        mqtt_password: localSettings.mqtt_password,\n        mqtt_topic_prefix: localSettings.mqtt_topic_prefix,\n        mqtt_use_tls: localSettings.mqtt_use_tls,\n        external_url: localSettings.external_url,\n        ha_enabled: localSettings.ha_enabled,\n        ha_url: localSettings.ha_url,\n        ha_token: localSettings.ha_token,\n        library_archive_mode: localSettings.library_archive_mode,\n        library_disk_warning_gb: localSettings.library_disk_warning_gb,\n        camera_view_mode: localSettings.camera_view_mode,\n        preferred_slicer: localSettings.preferred_slicer,\n        prometheus_enabled: localSettings.prometheus_enabled,\n        prometheus_token: localSettings.prometheus_token,\n        user_notifications_enabled: localSettings.user_notifications_enabled,\n        default_bed_levelling: localSettings.default_bed_levelling,\n        default_flow_cali: localSettings.default_flow_cali,\n        default_vibration_cali: localSettings.default_vibration_cali,\n        default_layer_inspect: localSettings.default_layer_inspect,\n        default_timelapse: localSettings.default_timelapse,\n        stagger_group_size: localSettings.stagger_group_size,\n        stagger_interval_minutes: localSettings.stagger_interval_minutes,\n        require_plate_clear: localSettings.require_plate_clear,\n      };\n      updateMutation.mutate(settingsToSave);\n    }, 500);\n\n    // Cleanup on unmount or when localSettings changes again\n    return () => {\n      if (saveTimeoutRef.current) {\n        clearTimeout(saveTimeoutRef.current);\n      }\n    };\n  }, [localSettings, settings, updateMutation]);\n\n  const updateSetting = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {\n    setLocalSettings(prev => prev ? { ...prev, [key]: value } : null);\n  }, []);\n\n  const handleTestExternalCamera = async (printerId: number, url: string, cameraType: string) => {\n    if (!url) {\n      showToast(t('settings.toast.enterCameraUrl'), 'error');\n      return;\n    }\n    setExtCameraTestLoading(prev => ({ ...prev, [printerId]: true }));\n    setExtCameraTestResults(prev => ({ ...prev, [printerId]: null }));\n    try {\n      const result = await api.testExternalCamera(printerId, url, cameraType);\n      setExtCameraTestResults(prev => ({ ...prev, [printerId]: result }));\n      if (result.success) {\n        showToast(t('settings.toast.cameraConnected', { resolution: result.resolution || '' }), 'success');\n      } else {\n        showToast(result.error || t('settings.toast.connectionFailed'), 'error');\n      }\n    } catch (error) {\n      const message = error instanceof Error ? error.message : t('settings.toast.testFailed');\n      setExtCameraTestResults(prev => ({ ...prev, [printerId]: { success: false, error: message } }));\n      showToast(message, 'error');\n    } finally {\n      setExtCameraTestLoading(prev => ({ ...prev, [printerId]: false }));\n    }\n  };\n\n  // Local state for camera URL inputs (to avoid saving on every keystroke)\n  const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});\n  const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});\n  const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());\n\n  // Initialize local camera URLs from printer data\n  useEffect(() => {\n    if (printers) {\n      const urls: Record<number, string> = {};\n      printers.forEach(p => {\n        if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {\n          urls[p.id] = p.external_camera_url;\n          initializedPrinterUrlsRef.current.add(p.id);\n        }\n      });\n      if (Object.keys(urls).length > 0) {\n        setLocalCameraUrls(prev => ({ ...prev, ...urls }));\n      }\n    }\n  }, [printers]);\n\n  const handleCameraUrlChange = (printerId: number, url: string) => {\n    // Update local state immediately for responsive UI\n    setLocalCameraUrls(prev => ({ ...prev, [printerId]: url }));\n\n    // Clear existing timeout for this printer\n    if (cameraUrlSaveTimeoutRef.current[printerId]) {\n      clearTimeout(cameraUrlSaveTimeoutRef.current[printerId]);\n    }\n\n    // Debounce the save (800ms delay)\n    cameraUrlSaveTimeoutRef.current[printerId] = setTimeout(() => {\n      updatePrinterMutation.mutate({\n        id: printerId,\n        data: { external_camera_url: url || null }\n      });\n    }, 800);\n  };\n\n  const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number }) => {\n    const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {};\n    if (updates.type !== undefined) data.external_camera_type = updates.type || null;\n    if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;\n    if (updates.rotation !== undefined) data.camera_rotation = updates.rotation;\n    updatePrinterMutation.mutate({ id: printerId, data });\n  };\n\n  if (isLoading || !localSettings) {\n    return (\n      <div className=\"p-4 md:p-8 flex justify-center\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  // Cross-tab search is powered by the module-level registry in lib/settingsSearch.\n  // Resolve i18n labels here so language changes take effect without re-registering.\n  const searchIndex = getSettingsSearchEntries().map(e => ({\n    ...e,\n    label: t(e.labelKey, e.labelFallback ?? e.labelKey),\n  }));\n\n  const searchQuery = settingsSearch.trim().toLowerCase();\n  const searchResults = searchQuery\n    ? searchIndex.filter(\n        e =>\n          e.label.toLowerCase().includes(searchQuery) ||\n          e.keywords.toLowerCase().includes(searchQuery)\n      ).slice(0, 8)\n    : [];\n\n  const jumpToSetting = (entry: typeof searchIndex[number]) => {\n    handleTabChange(entry.tab as TabType);\n    if (entry.subTab) {\n      setUsersSubTab(entry.subTab as UsersSubTab);\n    }\n    setSettingsSearch('');\n    // Scroll to the card after the tab has rendered\n    setTimeout(() => {\n      const el = document.getElementById(entry.anchor);\n      if (el) {\n        el.scrollIntoView({ behavior: 'smooth', block: 'start' });\n        el.classList.add('ring-2', 'ring-bambu-green');\n        setTimeout(() => el.classList.remove('ring-2', 'ring-bambu-green'), 1500);\n      }\n    }, 50);\n  };\n\n  return (\n    <CardDensityProvider density=\"dense\">\n    <div className=\"p-4 md:p-6\">\n      <div className=\"mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3\">\n        <div className=\"flex items-baseline gap-3\">\n          <h1 className=\"text-2xl font-bold text-white\">{t('settings.title')}</h1>\n          <p className=\"text-sm text-bambu-gray hidden md:block\">{t('settings.configureBambuddy')}</p>\n        </div>\n        {/* Cross-tab search */}\n        <div className=\"relative sm:w-72\">\n          <Search className=\"w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" />\n          <input\n            type=\"text\"\n            value={settingsSearch}\n            onChange={(e) => setSettingsSearch(e.target.value)}\n            placeholder={t('settings.searchPlaceholder', 'Search settings…')}\n            className=\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n          />\n          {settingsSearch && (\n            <button\n              onClick={() => setSettingsSearch('')}\n              className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-bambu-gray hover:text-white\"\n              aria-label=\"Clear\"\n            >\n              <X className=\"w-3.5 h-3.5\" />\n            </button>\n          )}\n          {searchResults.length > 0 && (\n            <div className=\"absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-30 overflow-hidden\">\n              {searchResults.map((entry) => (\n                <button\n                  key={entry.anchor}\n                  onClick={() => jumpToSetting(entry)}\n                  className=\"w-full px-3 py-2 text-left hover:bg-bambu-dark-tertiary transition-colors border-b border-bambu-dark-tertiary last:border-b-0\"\n                >\n                  <p className=\"text-sm text-white\">{entry.label}</p>\n                  <p className=\"text-xs text-bambu-gray\">\n                    {t(`settings.tabs.${entry.tab === 'virtual-printer' ? 'virtualPrinter' : entry.tab === 'failure-detection' ? 'failureDetection' : entry.tab}`)}\n                    {entry.subTab ? ` › ${t(`settings.tabs.${entry.subTab}`, entry.subTab)}` : ''}\n                  </p>\n                </button>\n              ))}\n            </div>\n          )}\n          {searchQuery && searchResults.length === 0 && (\n            <div className=\"absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-30 p-3\">\n              <p className=\"text-xs text-bambu-gray italic\">{t('settings.noSearchResults', 'No matching settings.')}</p>\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Tab Navigation + content: horizontal tabs on mobile, vertical rail on lg+ */}\n      <div className=\"flex flex-col lg:flex-row gap-4 lg:gap-6\">\n      <nav className=\"flex flex-wrap gap-1 border-b border-bambu-dark-tertiary lg:flex-col lg:flex-nowrap lg:gap-0 lg:border-b-0 lg:border-r lg:w-48 lg:flex-shrink-0 lg:self-start lg:sticky lg:top-4\">\n        <button\n          onClick={() => handleTabChange('general')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'general'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <SettingsIcon className=\"w-4 h-4\" />\n          {t('settings.tabs.general')}\n        </button>\n        <button\n          onClick={() => handleTabChange('plugs')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'plugs'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Plug className=\"w-4 h-4\" />\n          {t('settings.tabs.smartPlugs')}\n          {smartPlugs && smartPlugs.length > 0 && (\n            <span className=\"text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full\">\n              {smartPlugs.length}\n            </span>\n          )}\n        </button>\n        <button\n          onClick={() => handleTabChange('notifications')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'notifications'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Bell className=\"w-4 h-4\" />\n          {t('settings.tabs.notifications')}\n          {notificationProviders && notificationProviders.length > 0 && (\n            <span className=\"text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full\">\n              {notificationProviders.length}\n            </span>\n          )}\n        </button>\n        <button\n          onClick={() => handleTabChange('queue')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'queue'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <ListOrdered className=\"w-4 h-4\" />\n          {t('settings.tabs.queue', 'Workflow')}\n        </button>\n        <button\n          onClick={() => handleTabChange('filament')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'filament'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Cylinder className=\"w-4 h-4\" />\n          {t('settings.tabs.filament')}\n        </button>\n        <button\n          onClick={() => handleTabChange('network')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'network'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Wifi className=\"w-4 h-4\" />\n          {t('settings.tabs.network')}\n          <span className={`w-2 h-2 rounded-full ${mqttStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />\n        </button>\n        <button\n          onClick={() => handleTabChange('apikeys')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'apikeys'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Key className=\"w-4 h-4\" />\n          {t('settings.tabs.apiKeys')}\n          {apiKeys && apiKeys.length > 0 && (\n            <span className=\"text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full\">\n              {apiKeys.length}\n            </span>\n          )}\n        </button>\n        <button\n          onClick={() => handleTabChange('virtual-printer')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'virtual-printer'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Printer className=\"w-4 h-4\" />\n          {t('settings.tabs.virtualPrinter')}\n          <span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />\n        </button>\n        <button\n          onClick={() => handleTabChange('spoolbuddy')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'spoolbuddy'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Scale className=\"w-4 h-4\" />\n          {t('settings.tabs.spoolbuddy')}\n          {spoolbuddyDeviceCount > 0 && (\n            <span className=\"text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full\">\n              {spoolbuddyDeviceCount}\n            </span>\n          )}\n          <span className={`w-2 h-2 rounded-full ${spoolbuddyAnyOnline ? 'bg-green-400' : 'bg-gray-500'}`} />\n        </button>\n        <button\n          onClick={() => handleTabChange('failure-detection')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'failure-detection'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <ScanEye className=\"w-4 h-4\" />\n          {t('settings.tabs.failureDetection')}\n          <span className={`w-2 h-2 rounded-full ${obicoActive ? 'bg-green-400' : 'bg-gray-500'}`} />\n        </button>\n        <button\n          onClick={() => handleTabChange('users')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'users'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Users className=\"w-4 h-4\" />\n          {t('settings.tabs.users')}\n          {authEnabled && (\n            <span className=\"w-2 h-2 rounded-full bg-green-400\" />\n          )}\n        </button>\n        <button\n          onClick={() => handleTabChange('backup')}\n          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n            activeTab === 'backup'\n              ? 'text-bambu-green border-bambu-green'\n              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n          }`}\n        >\n          <Database className=\"w-4 h-4\" />\n          {t('settings.tabs.backup')}\n          <span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />\n        </button>\n      </nav>\n      <div className=\"flex-1 min-w-0\">\n      {activeTab === 'general' && (\n      <div className=\"flex flex-col lg:flex-row gap-4 lg:gap-6\">\n        {/* Left Column - General Settings */}\n        <div className=\"space-y-3 flex-1 lg:max-w-xl\">\n          <Card id=\"card-general\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white\">{t('settings.general')}</h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  <Globe className=\"w-4 h-4 inline mr-1\" />\n                  {t('settings.language')}\n                </label>\n                <div className=\"relative\">\n                  <select\n                    value={i18n.language}\n                    onChange={(e) => { i18n.changeLanguage(e.target.value); api.updateSettings({ language: e.target.value }); showToast(t('settings.toast.settingsSaved'), 'success'); }}\n                    className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                  >\n                    {availableLanguages.map((lang) => (\n                      <option key={lang.code} value={lang.code}>\n                        {lang.nativeName} ({lang.name})\n                      </option>\n                    ))}\n                  </select>\n                  <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                </div>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.languageDescription')}\n                </p>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.defaultView')}\n                </label>\n                <div className=\"relative\">\n                  <select\n                    value={defaultView}\n                    onChange={(e) => handleDefaultViewChange(e.target.value)}\n                    className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                  >\n                    {defaultNavItems.map((item) => (\n                      <option key={item.id} value={item.to}>\n                        {t(item.labelKey)}\n                      </option>\n                    ))}\n                  </select>\n                  <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                </div>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.defaultViewDescription')}\n                </p>\n              </div>\n              <div className=\"grid grid-cols-2 gap-3\">\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">\n                    {t('settings.dateFormat')}\n                  </label>\n                  <div className=\"relative\">\n                    <select\n                      value={localSettings.date_format || 'system'}\n                      onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}\n                      className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                    >\n                      <option value=\"system\">{t('settings.systemDefault')}</option>\n                      <option value=\"us\">{t('settings.dateFormatUs')}</option>\n                      <option value=\"eu\">{t('settings.dateFormatEu')}</option>\n                      <option value=\"iso\">{t('settings.dateFormatIso')}</option>\n                    </select>\n                    <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                  </div>\n                </div>\n                <div>\n                  <label className=\"block text-sm text-bambu-gray mb-1\">\n                    {t('settings.timeFormat')}\n                  </label>\n                  <div className=\"relative\">\n                    <select\n                      value={localSettings.time_format || 'system'}\n                      onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}\n                      className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                    >\n                      <option value=\"system\">{t('settings.systemDefault')}</option>\n                      <option value=\"12h\">{t('settings.timeFormat12')}</option>\n                      <option value=\"24h\">{t('settings.timeFormat24')}</option>\n                    </select>\n                    <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                  </div>\n                </div>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.defaultPrinter')}\n                </label>\n                <div className=\"relative\">\n                  <select\n                    value={localSettings.default_printer_id ?? ''}\n                    onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}\n                    className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                  >\n                    <option value=\"\">{t('settings.noDefaultPrinter')}</option>\n                    {printers?.map((printer) => (\n                      <option key={printer.id} value={printer.id}>\n                        {printer.name}\n                      </option>\n                    ))}\n                  </select>\n                  <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                </div>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.defaultPrinterDescription')}\n                </p>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.preferredSlicer')}\n                </label>\n                <div className=\"relative\">\n                  <select\n                    value={localSettings.preferred_slicer ?? 'bambu_studio'}\n                    onChange={(e) => updateSetting('preferred_slicer', e.target.value as 'bambu_studio' | 'orcaslicer')}\n                    className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                  >\n                    <option value=\"bambu_studio\">{t('settings.slicerBambuStudio')}</option>\n                    <option value=\"orcaslicer\">{t('settings.slicerOrcaSlicer')}</option>\n                  </select>\n                  <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                </div>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.preferredSlicerDescription')}\n                </p>\n              </div>\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.sidebarOrder')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.sidebarOrderDescription')}\n                    {authEnabled && hasPermission('settings:update') && ` ${t('settings.sidebarOrderSetDefaultHint')}`}\n                  </p>\n                </div>\n                <div className=\"flex items-center gap-2 shrink-0\">\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    onClick={handleResetSidebarOrder}\n                  >\n                    <RotateCcw className=\"w-4 h-4\" />\n                    {t('settings.reset')}\n                  </Button>\n                  {authEnabled && hasPermission('settings:update') && (\n                    <div className=\"flex items-center gap-2\">\n                      <span className=\"text-sm text-bambu-gray whitespace-nowrap\">{t('settings.setDefault')}</span>\n                      <Toggle\n                        checked={isDefaultSidebarEnabled}\n                        onChange={handleToggleDefaultSidebarOrder}\n                        disabled={isLoading}\n                      />\n                    </div>\n                  )}\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n\n          <Card id=\"card-appearance\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                <Palette className=\"w-5 h-5\" />\n                {t('settings.appearance')}\n              </h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              {/* Dark Mode Settings */}\n              <div className={`space-y-3 p-4 rounded-lg border ${mode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>\n                <h3 className=\"text-sm font-medium text-white flex items-center gap-2\">\n                  {t('settings.darkMode')}\n                  {mode === 'dark' && <span className=\"text-xs text-bambu-green\">{t('settings.active')}</span>}\n                </h3>\n                <div className=\"grid grid-cols-3 gap-3\">\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1\">{t('settings.background')}</label>\n                    <select\n                      value={darkBackground}\n                      onChange={(e) => { setDarkBackground(e.target.value as DarkBackground); showToast(t('settings.toast.settingsSaved'), 'success'); }}\n                      className=\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                    >\n                      <option value=\"neutral\">{t('settings.bgNeutral')}</option>\n                      <option value=\"warm\">{t('settings.bgWarm')}</option>\n                      <option value=\"cool\">{t('settings.bgCool')}</option>\n                      <option value=\"oled\">{t('settings.bgOled')}</option>\n                      <option value=\"slate\">{t('settings.bgSlate')}</option>\n                      <option value=\"forest\">{t('settings.bgForest')}</option>\n                    </select>\n                  </div>\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1\">{t('settings.accent')}</label>\n                    <select\n                      value={darkAccent}\n                      onChange={(e) => { setDarkAccent(e.target.value as ThemeAccent); showToast(t('settings.toast.settingsSaved'), 'success'); }}\n                      className=\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                    >\n                      <option value=\"green\">{t('settings.accentGreen')}</option>\n                      <option value=\"teal\">{t('settings.accentTeal')}</option>\n                      <option value=\"blue\">{t('settings.accentBlue')}</option>\n                      <option value=\"orange\">{t('settings.accentOrange')}</option>\n                      <option value=\"purple\">{t('settings.accentPurple')}</option>\n                      <option value=\"red\">{t('settings.accentRed')}</option>\n                    </select>\n                  </div>\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1\">{t('settings.style')}</label>\n                    <select\n                      value={darkStyle}\n                      onChange={(e) => { setDarkStyle(e.target.value as ThemeStyle); showToast(t('settings.toast.settingsSaved'), 'success'); }}\n                      className=\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                    >\n                      <option value=\"classic\">{t('settings.styleClassic')}</option>\n                      <option value=\"glow\">{t('settings.styleGlow')}</option>\n                      <option value=\"vibrant\">{t('settings.styleVibrant')}</option>\n                    </select>\n                  </div>\n                </div>\n              </div>\n\n              {/* Light Mode Settings */}\n              <div className={`space-y-3 p-4 rounded-lg border ${mode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>\n                <h3 className=\"text-sm font-medium text-white flex items-center gap-2\">\n                  {t('settings.lightMode')}\n                  {mode === 'light' && <span className=\"text-xs text-bambu-green\">{t('settings.active')}</span>}\n                </h3>\n                <div className=\"grid grid-cols-3 gap-3\">\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1\">{t('settings.background')}</label>\n                    <select\n                      value={lightBackground}\n                      onChange={(e) => { setLightBackground(e.target.value as LightBackground); showToast(t('settings.toast.settingsSaved'), 'success'); }}\n                      className=\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                    >\n                      <option value=\"neutral\">{t('settings.bgNeutral')}</option>\n                      <option value=\"warm\">{t('settings.bgWarm')}</option>\n                      <option value=\"cool\">{t('settings.bgCool')}</option>\n                    </select>\n                  </div>\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1\">{t('settings.accent')}</label>\n                    <select\n                      value={lightAccent}\n                      onChange={(e) => { setLightAccent(e.target.value as ThemeAccent); showToast(t('settings.toast.settingsSaved'), 'success'); }}\n                      className=\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                    >\n                      <option value=\"green\">{t('settings.accentGreen')}</option>\n                      <option value=\"teal\">{t('settings.accentTeal')}</option>\n                      <option value=\"blue\">{t('settings.accentBlue')}</option>\n                      <option value=\"orange\">{t('settings.accentOrange')}</option>\n                      <option value=\"purple\">{t('settings.accentPurple')}</option>\n                      <option value=\"red\">{t('settings.accentRed')}</option>\n                    </select>\n                  </div>\n                  <div>\n                    <label className=\"block text-xs text-bambu-gray mb-1\">{t('settings.style')}</label>\n                    <select\n                      value={lightStyle}\n                      onChange={(e) => { setLightStyle(e.target.value as ThemeStyle); showToast(t('settings.toast.settingsSaved'), 'success'); }}\n                      className=\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                    >\n                      <option value=\"classic\">{t('settings.styleClassic')}</option>\n                      <option value=\"glow\">{t('settings.styleGlow')}</option>\n                      <option value=\"vibrant\">{t('settings.styleVibrant')}</option>\n                    </select>\n                  </div>\n                </div>\n              </div>\n\n              <p className=\"text-xs text-bambu-gray\">\n                {t('settings.themeToggleHint')}\n              </p>\n            </CardContent>\n          </Card>\n\n          <Card id=\"card-archive\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white\">{t('settings.archiveSettings')}</h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.autoArchivePrints')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.autoArchiveDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.auto_archive}\n                    onChange={(e) => updateSetting('auto_archive', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.saveThumbnails')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.saveThumbnailsDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.save_thumbnails}\n                    onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.captureFinishPhoto')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.captureFinishPhotoDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.capture_finish_photo}\n                    onChange={(e) => updateSetting('capture_finish_photo', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (\n                <div className=\"flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg\">\n                  <AlertTriangle className=\"w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5\" />\n                  <div className=\"text-sm\">\n                    <p className=\"text-yellow-500 font-medium\">{t('settings.ffmpegNotInstalled')}</p>\n                    <p className=\"text-bambu-gray mt-1\">\n                      {t('settings.ffmpegRequired')}\n                    </p>\n                  </div>\n                </div>\n              )}\n            </CardContent>\n          </Card>\n\n        </div>\n\n        {/* Second Column - Camera, Cost, AMS & Spoolman */}\n        <div className=\"space-y-3 flex-1 lg:max-w-md\">\n          {/* Camera Settings */}\n          <Card id=\"card-camera\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                <Video className=\"w-5 h-5 text-bambu-green\" />\n                {t('settings.camera')}\n              </h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.cameraViewMode')}\n                </label>\n                <select\n                  value={localSettings.camera_view_mode ?? 'window'}\n                  onChange={(e) => updateSetting('camera_view_mode', e.target.value as 'window' | 'embedded')}\n                  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\"\n                >\n                  <option value=\"window\">{t('settings.newWindow')}</option>\n                  <option value=\"embedded\">{t('settings.embeddedOverlay')}</option>\n                </select>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {localSettings.camera_view_mode === 'embedded'\n                    ? t('settings.cameraOverlayDescription')\n                    : t('settings.cameraWindowDescription')}\n                </p>\n              </div>\n\n              {/* External Cameras Section */}\n              <div className=\"border-t border-bambu-dark-tertiary pt-4 mt-4\">\n                <h3 className=\"text-sm font-medium text-white mb-2\">{t('settings.externalCameras')}</h3>\n                <p className=\"text-xs text-bambu-gray mb-3\">\n                  {t('settings.externalCamerasDescription')}\n                </p>\n\n                {printers && printers.length > 0 ? (\n                  <div className=\"space-y-3\">\n                    {printers.map(printer => (\n                      <div key={printer.id} className=\"p-3 bg-bambu-dark rounded-lg\">\n                        <div className=\"flex items-center justify-between mb-2\">\n                          <span className=\"text-white font-medium text-sm\">{printer.name}</span>\n                          <label className=\"relative inline-flex items-center cursor-pointer\">\n                            <input\n                              type=\"checkbox\"\n                              checked={printer.external_camera_enabled}\n                              onChange={(e) => handleUpdatePrinterCamera(printer.id, { enabled: e.target.checked })}\n                              className=\"sr-only peer\"\n                            />\n                            <div className=\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"></div>\n                          </label>\n                        </div>\n\n                        {printer.external_camera_enabled && (\n                          <div className=\"space-y-2 mt-2\">\n                            <input\n                              type=\"text\"\n                              placeholder={printer.external_camera_type === 'usb' ? t('settings.cameraPlaceholderUsb') : t('settings.cameraPlaceholderUrl')}\n                              value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}\n                              onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}\n                              className=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                            />\n                            <div className=\"flex gap-2\">\n                              <select\n                                value={printer.external_camera_type || 'mjpeg'}\n                                onChange={(e) => handleUpdatePrinterCamera(printer.id, { type: e.target.value })}\n                                className=\"flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"\n                              >\n                                <option value=\"mjpeg\">{t('settings.cameraTypeMjpeg')}</option>\n                                <option value=\"rtsp\">{t('settings.cameraTypeRtsp')}</option>\n                                <option value=\"snapshot\">{t('settings.cameraTypeSnapshot')}</option>\n                                <option value=\"usb\">{t('settings.cameraTypeUsb')}</option>\n                              </select>\n                              <Button\n                                size=\"sm\"\n                                variant=\"secondary\"\n                                onClick={() => handleTestExternalCamera(printer.id, localCameraUrls[printer.id] ?? printer.external_camera_url ?? '', printer.external_camera_type || 'mjpeg')}\n                                disabled={extCameraTestLoading[printer.id] || !(localCameraUrls[printer.id] ?? printer.external_camera_url)}\n                              >\n                                {extCameraTestLoading[printer.id] ? (\n                                  <Loader2 className=\"w-4 h-4 animate-spin\" />\n                                ) : (\n                                  t('settings.test')\n                                )}\n                              </Button>\n                            </div>\n                            {extCameraTestResults[printer.id] && (\n                              <div className={`text-xs flex items-center gap-1 ${extCameraTestResults[printer.id]?.success ? 'text-green-500' : 'text-red-500'}`}>\n                                {extCameraTestResults[printer.id]?.success ? (\n                                  <>\n                                    <CheckCircle className=\"w-3 h-3\" />\n                                    {t('settings.connected')}{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`}\n                                  </>\n                                ) : (\n                                  <>\n                                    <XCircle className=\"w-3 h-3\" />\n                                    {extCameraTestResults[printer.id]?.error || t('settings.toast.connectionFailed')}\n                                  </>\n                                )}\n                              </div>\n                            )}\n                            <div className=\"flex items-center gap-2\">\n                              <label className=\"text-xs text-bambu-gray\">{t('settings.cameraRotation')}</label>\n                              <select\n                                value={printer.camera_rotation || 0}\n                                onChange={(e) => handleUpdatePrinterCamera(printer.id, { rotation: parseInt(e.target.value) })}\n                                className=\"px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none\"\n                              >\n                                <option value={0}>0°</option>\n                                <option value={90}>90°</option>\n                                <option value={180}>180°</option>\n                                <option value={270}>270°</option>\n                              </select>\n                            </div>\n                          </div>\n                        )}\n                      </div>\n                    ))}\n                  </div>\n                ) : (\n                  <p className=\"text-xs text-bambu-gray italic\">{t('settings.noPrintersConfigured')}</p>\n                )}\n              </div>\n            </CardContent>\n          </Card>\n\n\n          <Card id=\"card-cost\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white\">{t('settings.costTracking')}</h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">{t('settings.currency')}</label>\n                <select\n                  value={localSettings.currency}\n                  onChange={(e) => updateSetting('currency', e.target.value)}\n                  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\"\n                >\n                  {SUPPORTED_CURRENCIES.map((c) => (\n                    <option key={c.code} value={c.code}>{c.label}</option>\n                  ))}\n                </select>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.defaultFilamentCost')}\n                </label>\n                <div className=\"relative\">\n                  <span className=\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none\">\n                    {getCurrencySymbol(localSettings.currency)}\n                  </span>\n                  <input\n                    type=\"number\"\n                    step=\"0.01\"\n                    min=\"0\"\n                    value={localSettings.default_filament_cost}\n                    onChange={(e) =>\n                      updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)\n                    }\n                    style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}\n                    className=\"w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.electricityCost')}\n                </label>\n                <div className=\"relative\">\n                  <span className=\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none\">\n                    {getCurrencySymbol(localSettings.currency)}\n                  </span>\n                  <input\n                    type=\"number\"\n                    step=\"0.001\"\n                    min=\"0\"\n                    value={localSettings.energy_cost_per_kwh}\n                    onChange={(e) =>\n                      updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)\n                    }\n                    style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}\n                    className=\"w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                  />\n                </div>\n              </div>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.energyDisplayMode')}\n                </label>\n                <select\n                  value={localSettings.energy_tracking_mode || 'total'}\n                  onChange={(e) => updateSetting('energy_tracking_mode', e.target.value as 'print' | 'total')}\n                  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\"\n                >\n                  <option value=\"print\">{t('settings.printsOnly')}</option>\n                  <option value=\"total\">{t('settings.totalConsumption')}</option>\n                </select>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {localSettings.energy_tracking_mode === 'print'\n                    ? t('settings.energyModePrintDescription')\n                    : t('settings.energyModeTotalDescription')}\n                </p>\n              </div>\n            </CardContent>\n          </Card>\n\n          {/* File Manager Settings */}\n          <Card id=\"card-filemanager\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                <FileText className=\"w-5 h-5 text-bambu-green\" />\n                {t('settings.fileManager')}\n              </h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              {/* Archive Mode */}\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.createArchiveEntry')}\n                </label>\n                <select\n                  value={localSettings.library_archive_mode ?? 'ask'}\n                  onChange={(e) => updateSetting('library_archive_mode', e.target.value as 'always' | 'never' | 'ask')}\n                  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\"\n                >\n                  <option value=\"always\">{t('settings.archiveMode.always')}</option>\n                  <option value=\"never\">{t('settings.archiveMode.never')}</option>\n                  <option value=\"ask\">{t('settings.archiveMode.ask')}</option>\n                </select>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.createArchiveEntryDescription')}\n                </p>\n              </div>\n\n              {/* Disk Space Warning Threshold */}\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.lowDiskSpaceWarning')}\n                </label>\n                <div className=\"flex items-center gap-2\">\n                  <input\n                    type=\"number\"\n                    min=\"0.5\"\n                    max=\"100\"\n                    step=\"0.5\"\n                    value={localSettings.library_disk_warning_gb ?? 5}\n                    onChange={(e) => updateSetting('library_disk_warning_gb', parseFloat(e.target.value) || 5)}\n                    className=\"w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                  />\n                  <span className=\"text-bambu-gray\">GB</span>\n                </div>\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.lowDiskSpaceDescription')}\n                </p>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n\n        {/* Third Column - Sidebar Links & Updates */}\n        <div className=\"space-y-3 flex-1 lg:max-w-sm\">\n          {/* Sidebar Links */}\n          <ExternalLinksSettings />\n\n          <Card id=\"card-updates\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white\">{t('settings.updates')}</h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-xs font-medium text-bambu-gray uppercase tracking-wider\">{t('settings.printerFirmware')}</p>\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.checkPrinterFirmware')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.checkFirmwareDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.check_printer_firmware ?? true}\n                    onChange={(e) => updateSetting('check_printer_firmware', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              <div className=\"border-t border-bambu-dark-tertiary pt-4\">\n                <p className=\"text-xs font-medium text-bambu-gray uppercase tracking-wider mb-4\">{t('settings.bambuddySoftware')}</p>\n              </div>\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.checkForUpdatesLabel')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.autoCheckDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.check_updates}\n                    onChange={(e) => updateSetting('check_updates', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              <div className={`flex items-center justify-between ${!localSettings.check_updates ? 'opacity-50' : ''}`}>\n                <div>\n                  <p className=\"text-white\">{t('settings.includeBetaUpdates')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.includeBetaUpdatesDesc')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.include_beta_updates ?? false}\n                    onChange={(e) => updateSetting('include_beta_updates', e.target.checked)}\n                    disabled={!localSettings.check_updates}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              <div>\n                <div className=\"flex items-center justify-between mb-2\">\n                  <div>\n                    <p className=\"text-white\">{t('settings.currentVersion')}</p>\n                    <p className=\"text-sm text-bambu-gray\">v{versionInfo?.version || '...'}</p>\n                  </div>\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    onClick={() => refetchUpdateCheck()}\n                    disabled={isCheckingUpdate}\n                  >\n                    {isCheckingUpdate ? (\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    ) : (\n                      <RefreshCw className=\"w-4 h-4\" />\n                    )}\n                    {t('settings.checkNow')}\n                  </Button>\n                </div>\n\n                {updateCheck?.update_available ? (\n                  <div className=\"mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg\">\n                    <div className=\"flex items-start justify-between\">\n                      <div>\n                        <p className=\"text-bambu-green font-medium\">\n                          Update available: v{updateCheck.latest_version}\n                        </p>\n                        {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (\n                          <p className=\"text-sm text-bambu-gray mt-1\">{updateCheck.release_name}</p>\n                        )}\n                      </div>\n                      <div className=\"flex items-center gap-2\">\n                        {updateCheck.release_notes && (\n                          <button\n                            onClick={() => setShowReleaseNotes(true)}\n                            className=\"text-bambu-gray hover:text-white transition-colors text-sm underline\"\n                          >\n                            {t('settings.releaseNotes')}\n                          </button>\n                        )}\n                        {updateCheck.release_url && (\n                          <a\n                            href={updateCheck.release_url}\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            className=\"text-bambu-gray hover:text-white transition-colors\"\n                            title={t('settings.viewReleaseOnGitHub')}\n                          >\n                            <ExternalLink className=\"w-4 h-4\" />\n                          </a>\n                        )}\n                      </div>\n                    </div>\n\n                    {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (\n                      <div className=\"mt-3\">\n                        <div className=\"flex items-center gap-2 text-sm text-bambu-gray\">\n                          <Loader2 className=\"w-4 h-4 animate-spin\" />\n                          <span>{updateStatus.message}</span>\n                        </div>\n                        <div className=\"mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2\">\n                          <div\n                            className=\"bg-bambu-green h-2 rounded-full transition-all duration-300\"\n                            style={{ width: `${updateStatus.progress}%` }}\n                          />\n                        </div>\n                      </div>\n                    ) : updateStatus?.status === 'complete' ? (\n                      <div className=\"mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green\">\n                        {updateStatus.message}\n                      </div>\n                    ) : updateStatus?.status === 'error' ? (\n                      <div className=\"mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400\">\n                        {updateStatus.error || updateStatus.message}\n                      </div>\n                    ) : updateCheck?.is_docker ? (\n                      <div className=\"mt-3 p-3 bg-bambu-dark-tertiary rounded-lg\">\n                        <p className=\"text-sm text-bambu-gray mb-2\">\n                          {t('settings.updateViaDocker')}\n                        </p>\n                        <code className=\"block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono\">\n                          docker compose pull && docker compose up -d\n                        </code>\n                      </div>\n                    ) : (\n                      <Button\n                        className=\"mt-3\"\n                        onClick={() => applyUpdateMutation.mutate()}\n                        disabled={applyUpdateMutation.isPending}\n                      >\n                        {applyUpdateMutation.isPending ? (\n                          <Loader2 className=\"w-4 h-4 animate-spin\" />\n                        ) : (\n                          <Download className=\"w-4 h-4\" />\n                        )}\n                        {t('settings.installUpdate')}\n                      </Button>\n                    )}\n                  </div>\n                ) : updateCheck?.error ? (\n                  <div className=\"mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-400\">\n                    {t('settings.failedToCheckUpdates', { error: updateCheck.error })}\n                  </div>\n                ) : updateCheck && !updateCheck.update_available ? (\n                  <p className=\"mt-2 text-sm text-bambu-gray\">\n                    {t('settings.latestVersionRunning')}\n                  </p>\n                ) : null}\n              </div>\n            </CardContent>\n          </Card>\n\n          {/* Data Management */}\n          <Card id=\"card-data\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white\">{t('settings.dataManagement')}</h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.clearNotificationLogs')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.clearNotificationLogsDescription')}\n                  </p>\n                </div>\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={() => setShowClearLogsConfirm(true)}\n                >\n                  <Trash2 className=\"w-4 h-4\" />\n                  {t('common.clear')}\n                </Button>\n              </div>\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.resetUiPreferences')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.resetUiPreferencesDescription')}\n                  </p>\n                </div>\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={() => setShowClearStorageConfirm(true)}\n                >\n                  <Trash2 className=\"w-4 h-4\" />\n                  {t('settings.reset')}\n                </Button>\n              </div>\n              <div className=\"pt-4 border-t border-bambu-dark-tertiary\">\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-white\">{t('settings.storageUsage', 'Storage Usage')}</p>\n                    <p className=\"text-sm text-bambu-gray\">\n                      {t('settings.storageUsageDescription', 'Breakdown of data usage by category')}\n                    </p>\n                  </div>\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    onClick={handleStorageUsageRefresh}\n                    disabled={storageUsageFetching || storageUsageRefreshing}\n                  >\n                    <RefreshCw\n                      className={`w-4 h-4 ${storageUsageFetching || storageUsageRefreshing ? 'animate-spin' : ''}`}\n                    />\n                    {t('common.refresh', 'Refresh')}\n                  </Button>\n                </div>\n                <div className=\"mt-3\">\n                  {storageUsageLoading ? (\n                    <div className=\"flex items-center gap-2 text-sm text-bambu-gray\">\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      {t('common.loading', 'Loading')}\n                    </div>\n                  ) : storageUsage ? (\n                    <>\n                      <div className=\"w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex\">\n                        {storageUsage.categories\n                          .filter((category) => category.bytes > 0)\n                          .map((category, index) => (\n                            <div\n                              key={category.key}\n                              className={`${getStorageColor(category.key, index)} h-full`}\n                              style={{ width: `${category.percent_of_total}%` }}\n                              title={`${category.label}: ${category.formatted}`}\n                            />\n                          ))}\n                      </div>\n                      <div className=\"mt-3 flex flex-wrap gap-3\">\n                        {storageUsage.categories\n                          .filter((category) => category.bytes > 0)\n                          .map((category, index) => (\n                            <div key={category.key} className=\"flex items-center gap-2 text-xs\">\n                              <span\n                                className={`w-3 h-3 rounded-full ${getStorageColor(category.key, index)}`}\n                              />\n                              <span className=\"text-bambu-gray\">{category.label}</span>\n                              <span className=\"text-white\">{category.formatted}</span>\n                              <span className=\"text-bambu-gray\">({category.percent_of_total.toFixed(1)}%)</span>\n                            </div>\n                          ))}\n                      </div>\n                      <div className=\"mt-2 text-xs text-bambu-gray\">\n                        {t('settings.storageUsageTotal', 'Total')}: <span className=\"text-white\">{storageUsage.total_formatted}</span>\n                        {storageUsage.scan_errors > 0 && (\n                          <span className=\"ml-2 text-amber-400\">\n                            {t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors}\n                          </span>\n                        )}\n                      </div>\n                      {storageUsage.other_breakdown?.length > 0 && (\n                        <div className=\"mt-4\">\n                          <p className=\"text-xs text-bambu-gray mb-2\">\n                            {t('settings.storageUsageOtherBreakdown', 'Other breakdown')}\n                          </p>\n                          <div className=\"space-y-2\">\n                            {storageUsage.other_breakdown.map((item) => (\n                              <div key={`${item.bucket}-${item.kind}`} className=\"flex items-center justify-between text-xs\">\n                                <div className=\"flex items-center gap-2\">\n                                  <span className=\"text-white\">{item.label}</span>\n                                  <span\n                                    className={`px-2 py-0.5 rounded-full border ${\n                                      item.kind === 'system'\n                                        ? 'border-slate-500 text-slate-300'\n                                        : 'border-bambu-green text-bambu-green'\n                                    }`}\n                                  >\n                                    {item.kind === 'system'\n                                      ? t('settings.storageUsageSystem', 'System')\n                                      : t('settings.storageUsageData', 'Data')}\n                                  </span>\n                                </div>\n                                <div className=\"flex items-center gap-2 text-bambu-gray\">\n                                  <span className=\"text-white\">{item.formatted}</span>\n                                  <span>({item.percent_of_total.toFixed(1)}%)</span>\n                                </div>\n                              </div>\n                            ))}\n                          </div>\n                        </div>\n                      )}\n                    </>\n                  ) : (\n                    <p className=\"text-sm text-bambu-gray\">\n                      {t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}\n                    </p>\n                  )}\n                </div>\n              </div>\n              <div className=\"flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary\">\n                <div>\n                  <p className=\"text-white\">{t('settings.backupRestore')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.backupRestoreDescription')}\n                  </p>\n                </div>\n                <Button\n                  variant=\"secondary\"\n                  size=\"sm\"\n                  onClick={() => handleTabChange('backup')}\n                >\n                  <Database className=\"w-4 h-4\" />\n                  {t('settings.goToBackup')}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      </div>\n      )}\n\n      {/* Network Tab */}\n      {activeTab === 'network' && localSettings && (\n      <div className=\"flex flex-col lg:flex-row gap-6\">\n        {/* Left Column - External URL & FTP Retry */}\n        <div className=\"flex-1 lg:max-w-xl space-y-3\">\n          {/* External URL */}\n          <Card id=\"card-externalurl\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                <Globe className=\"w-5 h-5 text-blue-400\" />\n                {t('settings.externalUrl')}\n              </h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-sm text-bambu-gray\">\n                {t('settings.externalUrlDescription')}\n              </p>\n              <div>\n                <label className=\"block text-sm text-bambu-gray mb-1\">\n                  {t('settings.bambuddyUrl')}\n                </label>\n                <input\n                  type=\"text\"\n                  value={localSettings.external_url ?? ''}\n                  onChange={(e) => updateSetting('external_url', e.target.value)}\n                  placeholder=\"http://192.168.1.100:8000\"\n                  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\"\n                />\n                <p className=\"text-xs text-bambu-gray mt-1\">\n                  {t('settings.externalUrlHint')}\n                </p>\n              </div>\n            </CardContent>\n          </Card>\n\n          <Card id=\"card-ftpretry\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                <RefreshCw className=\"w-5 h-5 text-blue-400\" />\n                {t('settings.ftpRetry')}\n              </h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-sm text-bambu-gray\">\n                {t('settings.ftpRetryDescription')}\n              </p>\n\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.enableRetry')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.autoRetryDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.ftp_retry_enabled ?? true}\n                    onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n\n              {localSettings.ftp_retry_enabled && (\n                <div className=\"space-y-3 pt-2 border-t border-bambu-dark-tertiary\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.retryAttempts')}\n                    </label>\n                    <div className=\"relative w-44\">\n                      <select\n                        value={localSettings.ftp_retry_count ?? 3}\n                        onChange={(e) => updateSetting('ftp_retry_count', parseInt(e.target.value))}\n                        className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                      >\n                        {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => (\n                          <option key={n} value={n}>{t('settings.time', { count: n })}</option>\n                        ))}\n                      </select>\n                      <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                    </div>\n                  </div>\n\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.retryDelay')}\n                    </label>\n                    <div className=\"relative w-44\">\n                      <select\n                        value={localSettings.ftp_retry_delay ?? 2}\n                        onChange={(e) => updateSetting('ftp_retry_delay', parseInt(e.target.value))}\n                        className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                      >\n                        {[1, 2, 3, 5, 10, 15, 20, 30].map(n => (\n                          <option key={n} value={n}>{t('settings.second', { count: n })}</option>\n                        ))}\n                      </select>\n                      <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                    </div>\n                  </div>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.connectionTimeout')}\n                    </label>\n                    <div className=\"relative w-44\">\n                      <select\n                        value={localSettings.ftp_timeout ?? 30}\n                        onChange={(e) => updateSetting('ftp_timeout', parseInt(e.target.value))}\n                        className=\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\"\n                      >\n                        {[10, 15, 20, 30, 45, 60, 90, 120, 180, 300].map(n => (\n                          <option key={n} value={n}>{t('settings.nSeconds', { count: n })}</option>\n                        ))}\n                      </select>\n                      <ChevronDown className=\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\" />\n                    </div>\n                    <p className=\"text-xs text-bambu-gray mt-1\">\n                      {t('settings.increaseForWeakWifi')}\n                    </p>\n                  </div>\n                </div>\n              )}\n            </CardContent>\n          </Card>\n\n        </div>\n\n        {/* Right Column - Home Assistant & MQTT Publishing */}\n        <div className=\"flex-1 lg:max-w-xl space-y-3\">\n          {/* Home Assistant Integration */}\n          <Card id=\"card-ha\">\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                  <Home className=\"w-5 h-5 text-bambu-green\" />\n                  {t('settings.homeAssistant')}\n                </h2>\n                {localSettings.ha_enabled && haTestResult && (\n                  <div className=\"flex items-center gap-2\">\n                    <span className={`w-2.5 h-2.5 rounded-full ${haTestResult.success ? 'bg-green-400' : 'bg-red-400'}`} />\n                    <span className={`text-sm ${haTestResult.success ? 'text-green-400' : 'text-red-400'}`}>\n                      {haTestResult.success ? t('settings.connected') : t('settings.disconnected')}\n                    </span>\n                  </div>\n                )}\n              </div>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-sm text-bambu-gray\">\n                {t('settings.homeAssistantFullDescription')}\n              </p>\n\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex-1\">\n                  <p className=\"text-white\">{t('settings.enableHomeAssistant')}</p>\n                  <p className=\"text-xs text-bambu-gray\">{t('settings.homeAssistantDescription')}</p>\n                  {localSettings.ha_env_managed && (\n                    <div className=\"flex items-center gap-1 mt-1\">\n                      <Lock className=\"w-3 h-3 text-bambu-green\" />\n                      <span className=\"text-xs text-bambu-green\">\n                        {t('settings.autoEnabledViaEnv')}\n                      </span>\n                    </div>\n                  )}\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.ha_enabled ?? false}\n                    onChange={(e) => updateSetting('ha_enabled', e.target.checked)}\n                    disabled={localSettings.ha_env_managed}\n                    className=\"sr-only peer\"\n                  />\n                  <div className={`w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green ${\n                    localSettings.ha_env_managed ? 'opacity-60 cursor-not-allowed' : ''\n                  }`}></div>\n                </label>\n              </div>\n\n              {localSettings.ha_enabled && (\n                <>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.homeAssistantUrl')}\n                      {localSettings.ha_url_from_env && (\n                        <span className=\"ml-2 text-xs text-bambu-green\">\n                          {t('settings.environmentManagedLabel')}\n                        </span>\n                      )}\n                    </label>\n                    <div className=\"relative\">\n                      <input\n                        type=\"text\"\n                        value={localSettings.ha_url ?? ''}\n                        onChange={(e) => updateSetting('ha_url', e.target.value)}\n                        placeholder=\"http://192.168.1.100:8123\"\n                        disabled={localSettings.ha_url_from_env}\n                        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 ${\n                          localSettings.ha_url_from_env ? 'opacity-60 cursor-not-allowed' : ''\n                        }`}\n                      />\n                      {localSettings.ha_url_from_env && (\n                        <Lock className=\"absolute right-3 top-2.5 w-4 h-4 text-bambu-gray\" />\n                      )}\n                    </div>\n                    {localSettings.ha_url_from_env && (\n                      <p className=\"text-xs text-bambu-gray mt-1\">\n                        {t('settings.urlFromEnvReadOnly')}\n                      </p>\n                    )}\n                  </div>\n\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.longLivedAccessToken')}\n                      {localSettings.ha_token_from_env && (\n                        <span className=\"ml-2 text-xs text-bambu-green\">\n                          {t('settings.environmentManagedLabel')}\n                        </span>\n                      )}\n                    </label>\n                    <div className=\"relative\">\n                      <input\n                        type=\"password\"\n                        value={localSettings.ha_token ?? ''}\n                        onChange={(e) => updateSetting('ha_token', e.target.value)}\n                        placeholder=\"eyJ0eXAiOiJKV1QiLC...\"\n                        disabled={localSettings.ha_token_from_env}\n                        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 ${\n                          localSettings.ha_token_from_env ? 'opacity-60 cursor-not-allowed' : ''\n                        }`}\n                      />\n                      {localSettings.ha_token_from_env && (\n                        <Lock className=\"absolute right-3 top-2.5 w-4 h-4 text-bambu-gray\" />\n                      )}\n                    </div>\n                    {localSettings.ha_token_from_env ? (\n                      <p className=\"text-xs text-bambu-gray mt-1\">\n                        {t('settings.tokenFromEnvReadOnly')}\n                      </p>\n                    ) : (\n                      <p className=\"text-xs text-bambu-gray mt-1\">\n                        {t('settings.haTokenHint')}\n                      </p>\n                    )}\n                  </div>\n\n                  {localSettings.ha_url && localSettings.ha_token && (\n                    <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n                      <Button\n                        variant=\"secondary\"\n                        size=\"sm\"\n                        disabled={haTestLoading}\n                        onClick={async () => {\n                          setHaTestLoading(true);\n                          setHaTestResult(null);\n                          try {\n                            const result = await api.testHAConnection(localSettings.ha_url!, localSettings.ha_token!);\n                            setHaTestResult(result);\n                          } catch (e) {\n                            setHaTestResult({ success: false, message: null, error: e instanceof Error ? e.message : t('common.unknownError') });\n                          } finally {\n                            setHaTestLoading(false);\n                          }\n                        }}\n                      >\n                        {haTestLoading ? <Loader2 className=\"w-4 h-4 animate-spin\" /> : <Wifi className=\"w-4 h-4\" />}\n                        {t('settings.testConnection')}\n                      </Button>\n                    </div>\n                  )}\n                </>\n              )}\n            </CardContent>\n          </Card>\n\n          {/* MQTT Publishing */}\n          <Card id=\"card-mqtt\">\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                  <Wifi className=\"w-5 h-5 text-blue-400\" />\n                  {t('settings.mqttPublishing')}\n                </h2>\n                {mqttStatus?.enabled && (\n                  <div className=\"flex items-center gap-2\">\n                    <span className={`w-2.5 h-2.5 rounded-full ${mqttStatus.connected ? 'bg-green-400' : 'bg-red-400'}`} />\n                    <span className={`text-sm ${mqttStatus.connected ? 'text-green-400' : 'text-red-400'}`}>\n                      {mqttStatus.connected ? t('settings.connected') : t('settings.disconnected')}\n                    </span>\n                  </div>\n                )}\n              </div>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-sm text-bambu-gray\">\n                {t('settings.mqttDescription')}\n              </p>\n\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.enableMqtt')}</p>\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.mqttEnableDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.mqtt_enabled ?? false}\n                    onChange={(e) => updateSetting('mqtt_enabled', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n\n              {localSettings.mqtt_enabled && (\n                <div className=\"space-y-3 pt-2 border-t border-bambu-dark-tertiary\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.brokerHostname')}\n                    </label>\n                    <input\n                      type=\"text\"\n                      value={localSettings.mqtt_broker ?? ''}\n                      onChange={(e) => updateSetting('mqtt_broker', e.target.value)}\n                      placeholder=\"mqtt.example.com or 192.168.1.100\"\n                      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\"\n                    />\n                  </div>\n\n                  <div className=\"flex items-end gap-4\">\n                    <div className=\"flex-1\">\n                      <label className=\"block text-sm text-bambu-gray mb-1\">\n                        {t('settings.port')}\n                      </label>\n                      <input\n                        type=\"number\"\n                        min=\"1\"\n                        max=\"65535\"\n                        value={localSettings.mqtt_port ?? 1883}\n                        onChange={(e) => updateSetting('mqtt_port', Math.min(65535, Math.max(1, parseInt(e.target.value) || 1883)))}\n                        className=\"w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                      />\n                    </div>\n                    <div className=\"flex items-center gap-3 pb-2\">\n                      <label className=\"relative inline-flex items-center cursor-pointer\">\n                        <input\n                          type=\"checkbox\"\n                          checked={localSettings.mqtt_use_tls ?? false}\n                          onChange={(e) => {\n                            const useTls = e.target.checked;\n                            updateSetting('mqtt_use_tls', useTls);\n                            // Auto-populate port based on TLS selection\n                            const currentPort = localSettings.mqtt_port ?? 1883;\n                            if (useTls && currentPort === 1883) {\n                              updateSetting('mqtt_port', 8883);\n                            } else if (!useTls && currentPort === 8883) {\n                              updateSetting('mqtt_port', 1883);\n                            }\n                          }}\n                          className=\"sr-only peer\"\n                        />\n                        <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                      </label>\n                      <span className=\"text-white text-sm\">{t('settings.useTls')}</span>\n                    </div>\n                  </div>\n\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.usernameOptional')}\n                    </label>\n                    <input\n                      type=\"text\"\n                      value={localSettings.mqtt_username ?? ''}\n                      onChange={(e) => updateSetting('mqtt_username', e.target.value)}\n                      placeholder={t('settings.leaveEmptyForAnonymous')}\n                      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\"\n                    />\n                  </div>\n\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.passwordOptional')}\n                    </label>\n                    <input\n                      type=\"password\"\n                      value={localSettings.mqtt_password ?? ''}\n                      onChange={(e) => updateSetting('mqtt_password', e.target.value)}\n                      placeholder={t('settings.leaveEmptyForAnonymous')}\n                      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\"\n                    />\n                  </div>\n\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.topicPrefix')}\n                    </label>\n                    <input\n                      type=\"text\"\n                      value={localSettings.mqtt_topic_prefix ?? 'bambuddy'}\n                      onChange={(e) => updateSetting('mqtt_topic_prefix', e.target.value)}\n                      placeholder=\"bambuddy\"\n                      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\"\n                    />\n                    <p className=\"text-xs text-bambu-gray mt-1\">\n                      {t('settings.topicPrefixHint', { prefix: localSettings.mqtt_topic_prefix || 'bambuddy' })}\n                    </p>\n                  </div>\n\n                  {/* Connection Info */}\n                  {mqttStatus && (\n                    <div className=\"pt-3 mt-3 border-t border-bambu-dark-tertiary\">\n                      <div className=\"flex items-center gap-2 text-sm\">\n                        <span className={`w-2 h-2 rounded-full ${mqttStatus.connected ? 'bg-green-400' : 'bg-red-400'}`} />\n                        <span className=\"text-bambu-gray\">\n                          {mqttStatus.connected ? (\n                            <>{t('settings.mqttConnectedTo')} <span className=\"text-white\">{mqttStatus.broker}:{mqttStatus.port}</span></>\n                          ) : (\n                            t('settings.spoolmanDisconnected')\n                          )}\n                        </span>\n                      </div>\n                    </div>\n                  )}\n                </div>\n              )}\n            </CardContent>\n          </Card>\n        </div>\n\n        {/* Third Column - Prometheus Metrics */}\n        <div className=\"flex-1 lg:max-w-md space-y-3\">\n          <Card id=\"card-prometheus\">\n            <CardHeader>\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                <TrendingUp className=\"w-5 h-5 text-orange-400\" />\n                {t('settings.prometheusMetrics')}\n              </h2>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-sm text-bambu-gray\">\n                {t('settings.prometheusEndpointDescription')}\n              </p>\n\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <p className=\"text-white\">{t('settings.enableMetricsEndpoint')}</p>\n                  <p className=\"text-xs text-bambu-gray\">{t('settings.prometheusDescription')}</p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.prometheus_enabled ?? false}\n                    onChange={(e) => updateSetting('prometheus_enabled', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n\n              {localSettings.prometheus_enabled && (\n                <div className=\"space-y-3 pt-2 border-t border-bambu-dark-tertiary\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.bearerTokenOptional')}\n                    </label>\n                    <input\n                      type=\"password\"\n                      value={localSettings.prometheus_token ?? ''}\n                      onChange={(e) => updateSetting('prometheus_token', e.target.value)}\n                      placeholder={t('settings.leaveEmptyForNoAuth')}\n                      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\"\n                    />\n                    <p className=\"text-xs text-bambu-gray mt-1\">\n                      {t('settings.bearerTokenHint')}\n                    </p>\n                  </div>\n\n                  <div className=\"pt-2 border-t border-bambu-dark-tertiary\">\n                    <p className=\"text-sm text-white mb-2\">{t('settings.availableMetrics')}</p>\n                    <div className=\"text-xs text-bambu-gray space-y-1\">\n                      <p><code className=\"text-orange-400\">bambuddy_printer_connected</code> - {t('settings.metricsConnectionStatus')}</p>\n                      <p><code className=\"text-orange-400\">bambuddy_printer_state</code> - {t('settings.metricsPrinterState')}</p>\n                      <p><code className=\"text-orange-400\">bambuddy_print_progress</code> - {t('settings.metricsPrintProgress')}</p>\n                      <p><code className=\"text-orange-400\">bambuddy_bed_temp_celsius</code> - {t('settings.metricsBedTemp')}</p>\n                      <p><code className=\"text-orange-400\">bambuddy_nozzle_temp_celsius</code> - {t('settings.metricsNozzleTemp')}</p>\n                      <p><code className=\"text-orange-400\">bambuddy_prints_total</code> - {t('settings.metricsPrintsTotal')}</p>\n                      <p className=\"text-bambu-gray/70 italic\">{t('settings.metricsMore')}</p>\n                    </div>\n                  </div>\n                </div>\n              )}\n            </CardContent>\n          </Card>\n        </div>\n      </div>\n      )}\n\n      {/* Home Assistant Test Connection Modal */}\n      {haTestResult && (\n        <div className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\">\n          <div className=\"bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full mx-4\">\n            <div className=\"flex items-center gap-3 mb-4\">\n              {haTestResult.success ? (\n                <CheckCircle className=\"w-8 h-8 text-green-400\" />\n              ) : (\n                <XCircle className=\"w-8 h-8 text-red-400\" />\n              )}\n              <h3 className=\"text-lg font-medium text-white\">\n                {haTestResult.success ? t('settings.connectionSuccessful') : t('settings.connectionFailed')}\n              </h3>\n            </div>\n            <p className=\"text-bambu-gray mb-6\">\n              {haTestResult.success\n                ? haTestResult.message || t('settings.haConnectionSuccess')\n                : haTestResult.error || t('settings.haConnectionFailed')}\n            </p>\n            <div className=\"flex justify-end\">\n              <Button\n                variant=\"primary\"\n                onClick={() => setHaTestResult(null)}\n              >\n                {t('settings.ok')}\n              </Button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Smart Plugs Tab */}\n      {activeTab === 'plugs' && (\n        <div id=\"card-plugs\">\n          <div className=\"flex items-start justify-between mb-6\">\n            <div>\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\">\n                <Plug className=\"w-5 h-5 text-bambu-green\" />\n                {t('settings.smartPlugs')}\n              </h2>\n              <p className=\"text-sm text-bambu-gray mt-1\">\n                {t('settings.smartPlugsDescription')}\n              </p>\n            </div>\n            <div className=\"flex items-center gap-2 pt-1 shrink-0\">\n              {smartPlugs && smartPlugs.filter(p => p.enabled).length > 1 && (\n                <>\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    className=\"whitespace-nowrap\"\n                    onClick={() => setShowBulkPlugConfirm('on')}\n                    disabled={bulkPlugActionMutation.isPending}\n                    title={t('settings.turnAllPlugsOn')}\n                  >\n                    {bulkPlugActionMutation.isPending ? (\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    ) : (\n                      <Power className=\"w-4 h-4 text-bambu-green\" />\n                    )}\n                    {t('settings.allOn')}\n                  </Button>\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    className=\"whitespace-nowrap\"\n                    onClick={() => setShowBulkPlugConfirm('off')}\n                    disabled={bulkPlugActionMutation.isPending}\n                    title={t('settings.turnAllPlugsOff')}\n                  >\n                    {bulkPlugActionMutation.isPending ? (\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    ) : (\n                      <PowerOff className=\"w-4 h-4 text-red-400\" />\n                    )}\n                    {t('settings.allOff')}\n                  </Button>\n                </>\n              )}\n              <Button\n                className=\"whitespace-nowrap\"\n                onClick={() => {\n                  setEditingPlug(null);\n                  setShowPlugModal(true);\n                }}\n              >\n                <Plus className=\"w-4 h-4\" />\n                {t('settings.addSmartPlug')}\n              </Button>\n            </div>\n          </div>\n\n          {/* Energy Summary Card */}\n          {smartPlugs && smartPlugs.length > 0 && (\n            <Card className=\"mb-6\">\n              <CardHeader>\n                <h3 className=\"text-base font-semibold text-white flex items-center gap-2\">\n                  <Zap className=\"w-4 h-4 text-yellow-400\" />\n                  {t('settings.energySummary')}\n                  {energyLoading && (\n                    <Loader2 className=\"w-4 h-4 animate-spin text-bambu-gray ml-2\" />\n                  )}\n                </h3>\n              </CardHeader>\n              <CardContent>\n                {plugEnergySummary ? (\n                  <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n                    {/* Current Power */}\n                    <div className=\"bg-bambu-dark rounded-lg p-3\">\n                      <div className=\"flex items-center gap-2 text-bambu-gray text-xs mb-1\">\n                        <Zap className=\"w-3 h-3\" />\n                        {t('settings.currentPower')}\n                      </div>\n                      <div className=\"text-xl font-bold text-white\">\n                        {plugEnergySummary.totalPower.toFixed(1)}\n                        <span className=\"text-sm font-normal text-bambu-gray ml-1\">W</span>\n                      </div>\n                      <div className=\"text-xs text-bambu-gray mt-1\">\n                        {t('settings.plugsOnline', { reachable: plugEnergySummary.reachableCount, total: plugEnergySummary.totalPlugs })}\n                      </div>\n                    </div>\n\n                    {/* Today */}\n                    <div className=\"bg-bambu-dark rounded-lg p-3\">\n                      <div className=\"flex items-center gap-2 text-bambu-gray text-xs mb-1\">\n                        <Calendar className=\"w-3 h-3\" />\n                        {t('settings.today')}\n                      </div>\n                      <div className=\"text-xl font-bold text-white\">\n                        {plugEnergySummary.totalToday.toFixed(3)}\n                        <span className=\"text-sm font-normal text-bambu-gray ml-1\">kWh</span>\n                      </div>\n                      {(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (\n                        <div className=\"text-xs text-bambu-gray mt-1\">\n                          ~{(plugEnergySummary.totalToday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}\n                        </div>\n                      )}\n                    </div>\n\n                    {/* Yesterday */}\n                    <div className=\"bg-bambu-dark rounded-lg p-3\">\n                      <div className=\"flex items-center gap-2 text-bambu-gray text-xs mb-1\">\n                        <TrendingUp className=\"w-3 h-3\" />\n                        {t('settings.yesterday')}\n                      </div>\n                      <div className=\"text-xl font-bold text-white\">\n                        {plugEnergySummary.totalYesterday.toFixed(3)}\n                        <span className=\"text-sm font-normal text-bambu-gray ml-1\">kWh</span>\n                      </div>\n                      {(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (\n                        <div className=\"text-xs text-bambu-gray mt-1\">\n                          ~{(plugEnergySummary.totalYesterday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}\n                        </div>\n                      )}\n                    </div>\n\n                    {/* Total Lifetime */}\n                    <div className=\"bg-bambu-dark rounded-lg p-3\">\n                      <div className=\"flex items-center gap-2 text-bambu-gray text-xs mb-1\">\n                        <DollarSign className=\"w-3 h-3\" />\n                        {t('settings.total')}\n                      </div>\n                      <div className=\"text-xl font-bold text-white\">\n                        {plugEnergySummary.totalLifetime.toFixed(1)}\n                        <span className=\"text-sm font-normal text-bambu-gray ml-1\">kWh</span>\n                      </div>\n                      {(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (\n                        <div className=\"text-xs text-bambu-gray mt-1\">\n                          ~{(plugEnergySummary.totalLifetime * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}\n                        </div>\n                      )}\n                    </div>\n                  </div>\n                ) : !energyLoading ? (\n                  <p className=\"text-sm text-bambu-gray\">\n                    {t('settings.enablePlugsForSummary')}\n                  </p>\n                ) : null}\n              </CardContent>\n            </Card>\n          )}\n\n          {plugsLoading ? (\n            <div className=\"flex justify-center py-12\">\n              <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n            </div>\n          ) : smartPlugs && smartPlugs.length > 0 ? (\n            <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">\n              {smartPlugs.map((plug) => (\n                <SmartPlugCard\n                  key={plug.id}\n                  plug={plug}\n                  onEdit={(p) => {\n                    setEditingPlug(p);\n                    setShowPlugModal(true);\n                  }}\n                />\n              ))}\n            </div>\n          ) : (\n            <Card>\n              <CardContent className=\"py-12\">\n                <div className=\"text-center text-bambu-gray\">\n                  <Plug className=\"w-16 h-16 mx-auto mb-4 opacity-30\" />\n                  <p className=\"text-lg font-medium text-white mb-2\">{t('settings.noSmartPlugsTitle')}</p>\n                  <p className=\"text-sm mb-4\">{t('settings.noSmartPlugsDescription')}</p>\n                  <Button\n                    onClick={() => {\n                      setEditingPlug(null);\n                      setShowPlugModal(true);\n                    }}\n                  >\n                    <Plus className=\"w-4 h-4\" />\n                    {t('settings.addFirstSmartPlug')}\n                  </Button>\n                </div>\n              </CardContent>\n            </Card>\n          )}\n        </div>\n      )}\n\n      {/* Notifications Tab */}\n      {activeTab === 'notifications' && (\n        <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n          {/* Left Column: Providers */}\n          <div>\n            <div className=\"flex items-center justify-between mb-4\">\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\" id=\"card-providers\">\n                <Bell className=\"w-5 h-5 text-bambu-green\" />\n                {t('settings.providers')}\n              </h2>\n              <div className=\"flex items-center gap-2\">\n                <Button\n                  size=\"sm\"\n                  variant=\"secondary\"\n                  onClick={() => setShowLogViewer(true)}\n                >\n                  <History className=\"w-4 h-4\" />\n                  {t('settings.log')}\n                </Button>\n                {notificationProviders && notificationProviders.length > 0 && (\n                  <Button\n                    size=\"sm\"\n                    variant=\"secondary\"\n                    onClick={() => {\n                      setTestAllResult(null);\n                      testAllMutation.mutate();\n                    }}\n                    disabled={testAllMutation.isPending}\n                  >\n                    {testAllMutation.isPending ? (\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    ) : (\n                      <Send className=\"w-4 h-4\" />\n                    )}\n                    {t('settings.testAll')}\n                  </Button>\n                )}\n                <Button\n                  size=\"sm\"\n                  onClick={() => {\n                    setEditingProvider(null);\n                    setShowNotificationModal(true);\n                  }}\n                >\n                  <Plus className=\"w-4 h-4\" />\n                  {t('settings.addNotificationProvider')}\n                </Button>\n              </div>\n            </div>\n\n            {/* Notification Language Setting */}\n            <Card className=\"mb-4\">\n              <CardContent className=\"py-3\">\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-white text-sm font-medium\">{t('settings.notificationLanguage')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('settings.notificationLanguageDescription')}</p>\n                  </div>\n                  <select\n                    value={localSettings.notification_language || 'en'}\n                    onChange={(e) => updateSetting('notification_language', e.target.value)}\n                    className=\"px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green\"\n                  >\n                    {availableLanguages.map((lang) => (\n                      <option key={lang.code} value={lang.code}>\n                        {lang.nativeName}\n                      </option>\n                    ))}\n                  </select>\n                </div>\n              </CardContent>\n            </Card>\n\n            {/* Bed Cooled Threshold Setting */}\n            <Card className=\"mb-4\">\n              <CardContent className=\"py-3\">\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-white text-sm font-medium\">{t('settings.bedCooledThreshold')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('settings.bedCooledThresholdDescription')}</p>\n                  </div>\n                  <div className=\"flex items-center gap-1\">\n                    <input\n                      type=\"number\"\n                      min={20}\n                      max={80}\n                      step={1}\n                      value={localSettings.bed_cooled_threshold ?? 35}\n                      onChange={(e) => updateSetting('bed_cooled_threshold', Number(e.target.value))}\n                      className=\"w-16 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-bambu-green\"\n                    />\n                    <span className=\"text-sm text-bambu-gray\">°C</span>\n                  </div>\n                </div>\n              </CardContent>\n            </Card>\n\n            {/* User Notifications Toggle */}\n            <Card className=\"mb-4\">\n              <CardContent className=\"py-3\">\n                <div className={`flex items-center justify-between ${!advancedAuthStatus?.advanced_auth_enabled ? 'opacity-50' : ''}`}>\n                  <div>\n                    <p className=\"text-white text-sm font-medium\">{t('settings.userNotificationsEnabled')}</p>\n                    <p className=\"text-xs text-bambu-gray\">\n                      {!advancedAuthStatus?.advanced_auth_enabled\n                        ? t('settings.userNotificationsDisabledHint')\n                        : t('settings.userNotificationsEnabledDescription')}\n                    </p>\n                  </div>\n                  <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      className=\"sr-only peer\"\n                      checked={localSettings.user_notifications_enabled ?? true}\n                      disabled={!advancedAuthStatus?.advanced_auth_enabled}\n                      onChange={(e) => updateSetting('user_notifications_enabled', e.target.checked)}\n                    />\n                    <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green peer-disabled:cursor-not-allowed\"></div>\n                  </label>\n                </div>\n              </CardContent>\n            </Card>\n\n            {/* Test All Results */}\n            {testAllResult && (\n              <Card className=\"mb-4\">\n                <CardContent className=\"py-3\">\n                  <div className=\"flex items-center justify-between mb-2\">\n                    <span className=\"text-sm font-medium text-white\">{t('settings.testResults')}</span>\n                    <button\n                      onClick={() => setTestAllResult(null)}\n                      className=\"text-bambu-gray hover:text-white text-xs\"\n                    >\n                      {t('common.dismiss')}\n                    </button>\n                  </div>\n                  <div className=\"flex items-center gap-4 text-sm mb-2\">\n                    <span className=\"flex items-center gap-1 text-bambu-green\">\n                      <CheckCircle className=\"w-4 h-4\" />\n                      {t('settings.testPassedCount', { count: testAllResult.success })}\n                    </span>\n                    {testAllResult.failed > 0 && (\n                      <span className=\"flex items-center gap-1 text-red-400\">\n                        <XCircle className=\"w-4 h-4\" />\n                        {t('settings.testFailedCount', { count: testAllResult.failed })}\n                      </span>\n                    )}\n                  </div>\n                  {testAllResult.results.filter(r => !r.success).length > 0 && (\n                    <div className=\"space-y-1 mt-2 pt-2 border-t border-bambu-dark-tertiary\">\n                      {testAllResult.results.filter(r => !r.success).map((result) => (\n                        <div key={result.provider_id} className=\"text-xs text-red-400\">\n                          <span className=\"font-medium\">{result.provider_name}:</span> {result.message}\n                        </div>\n                      ))}\n                    </div>\n                  )}\n                </CardContent>\n              </Card>\n            )}\n\n            {providersLoading ? (\n              <div className=\"flex justify-center py-12\">\n                <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n              </div>\n            ) : notificationProviders && notificationProviders.length > 0 ? (\n              <div className=\"space-y-3\">\n                {notificationProviders.map((provider) => (\n                  <NotificationProviderCard\n                    key={provider.id}\n                    provider={provider}\n                    onEdit={(p) => {\n                      setEditingProvider(p);\n                      setShowNotificationModal(true);\n                    }}\n                  />\n                ))}\n              </div>\n            ) : (\n              <Card>\n                <CardContent className=\"py-8\">\n                  <div className=\"text-center text-bambu-gray\">\n                    <Bell className=\"w-12 h-12 mx-auto mb-3 opacity-30\" />\n                    <p className=\"text-sm font-medium text-white mb-2\">{t('settings.noProvidersTitle')}</p>\n                    <p className=\"text-xs mb-3\">{t('settings.noProvidersDescription')}</p>\n                    <Button\n                      size=\"sm\"\n                      onClick={() => {\n                        setEditingProvider(null);\n                        setShowNotificationModal(true);\n                      }}\n                    >\n                      <Plus className=\"w-4 h-4\" />\n                      {t('settings.addProvider')}\n                    </Button>\n                  </div>\n                </CardContent>\n              </Card>\n            )}\n          </div>\n\n          {/* Right Column: Templates */}\n          <div>\n            <h2 className=\"text-lg font-semibold text-white flex items-center gap-2 mb-2\" id=\"card-templates\">\n              <FileText className=\"w-5 h-5 text-bambu-green\" />\n              {t('settings.messageTemplates')}\n            </h2>\n            <p className=\"text-sm text-bambu-gray mb-3\">\n              {t('settings.messageTemplatesDescription')}\n            </p>\n\n            {/* Filter input */}\n            <div className=\"relative mb-3\">\n              <Search className=\"w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\" />\n              <input\n                type=\"text\"\n                value={templateFilter}\n                onChange={(e) => setTemplateFilter(e.target.value)}\n                placeholder={t('settings.filterTemplates', 'Filter templates…')}\n                className=\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n              />\n              {templateFilter && (\n                <button\n                  onClick={() => setTemplateFilter('')}\n                  className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-bambu-gray hover:text-white\"\n                  aria-label=\"Clear filter\"\n                >\n                  <X className=\"w-3.5 h-3.5\" />\n                </button>\n              )}\n            </div>\n\n            {templatesLoading ? (\n              <div className=\"flex justify-center py-8\">\n                <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n              </div>\n            ) : notificationTemplates && notificationTemplates.length > 0 ? (\n              (() => {\n                const filter = templateFilter.trim().toLowerCase();\n                const filtered = [...notificationTemplates]\n                  .sort((a, b) => a.name.localeCompare(b.name))\n                  .filter(tpl =>\n                    !filter ||\n                    tpl.name.toLowerCase().includes(filter) ||\n                    (tpl.title_template || '').toLowerCase().includes(filter)\n                  );\n                if (filtered.length === 0) {\n                  return (\n                    <p className=\"text-sm text-bambu-gray italic text-center py-6\">\n                      {t('settings.noTemplatesMatch', 'No templates match your filter.')}\n                    </p>\n                  );\n                }\n                return (\n              <div className=\"space-y-2\">\n                {filtered.map((template) => (\n                  <Card\n                    key={template.id}\n                    className=\"cursor-pointer hover:border-bambu-green/50 transition-colors\"\n                    onClick={() => setEditingTemplate(template)}\n                  >\n                    <CardContent className=\"py-2.5 px-3\">\n                      <div className=\"flex items-center justify-between\">\n                        <div className=\"min-w-0 flex-1\">\n                          <p className=\"text-white font-medium text-sm truncate\">{template.name}</p>\n                          <p className=\"text-bambu-gray text-xs truncate mt-0.5\">\n                            {template.title_template}\n                          </p>\n                        </div>\n                        <button\n                          className=\"p-1.5 hover:bg-bambu-dark-tertiary rounded transition-colors shrink-0 ml-2\"\n                          onClick={(e) => {\n                            e.stopPropagation();\n                            setEditingTemplate(template);\n                          }}\n                        >\n                          <Edit2 className=\"w-4 h-4 text-bambu-gray\" />\n                        </button>\n                      </div>\n                    </CardContent>\n                  </Card>\n                ))}\n              </div>\n                );\n              })()\n            ) : (\n              <Card>\n                <CardContent className=\"py-8\">\n                  <div className=\"text-center text-bambu-gray\">\n                    <FileText className=\"w-12 h-12 mx-auto mb-3 opacity-30\" />\n                    <p className=\"text-sm\">{t('settings.noTemplatesAvailable')}</p>\n                  </div>\n                </CardContent>\n              </Card>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* API Keys Tab */}\n      {activeTab === 'apikeys' && (\n        <div className=\"grid grid-cols-1 xl:grid-cols-2 gap-4\">\n          {/* Left Column - API Keys Management */}\n          <div>\n            <div className=\"flex items-start justify-between gap-4 mb-6\">\n              <div className=\"flex-1\">\n                <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\" id=\"card-createapi\">\n                  <Key className=\"w-5 h-5 text-bambu-green\" />\n                  {t('settings.apiKeys')}\n                </h2>\n                <p className=\"text-sm text-bambu-gray mt-1\">\n                  {t('settings.apiKeysDescription')}\n                </p>\n              </div>\n              <Button size=\"sm\" onClick={() => setShowCreateAPIKey(true)} className=\"flex-shrink-0\">\n                <Plus className=\"w-4 h-4\" />\n                {t('settings.createKey')}\n              </Button>\n            </div>\n\n            {/* Created Key Display */}\n            {createdAPIKey && (\n              <Card className=\"mb-6 border-bambu-green\">\n                <CardContent className=\"py-4\">\n                  <div className=\"flex items-start gap-3\">\n                    <CheckCircle className=\"w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5\" />\n                    <div className=\"flex-1\">\n                      <p className=\"text-white font-medium mb-1\">{t('settings.apiKeyCreated')}</p>\n                      <p className=\"text-sm text-bambu-gray mb-2\">\n                        {t('settings.apiKeyCopyWarning')}\n                      </p>\n                      <div className=\"flex items-center gap-2 bg-bambu-dark rounded-lg p-2\">\n                        <code className=\"flex-1 text-sm text-bambu-green font-mono break-all\">\n                          {createdAPIKey}\n                        </code>\n                        <Button\n                          variant=\"secondary\"\n                          size=\"sm\"\n                          onClick={async () => {\n                            try {\n                              if (navigator.clipboard && navigator.clipboard.writeText) {\n                                await navigator.clipboard.writeText(createdAPIKey);\n                              } else {\n                                const textArea = document.createElement('textarea');\n                                textArea.value = createdAPIKey;\n                                textArea.style.position = 'fixed';\n                                textArea.style.left = '-999999px';\n                                document.body.appendChild(textArea);\n                                textArea.select();\n                                document.execCommand('copy');\n                                document.body.removeChild(textArea);\n                              }\n                              showToast(t('settings.toast.keyCopied'));\n                            } catch {\n                              showToast(t('settings.toast.copyFailed'), 'error');\n                            }\n                          }}\n                        >\n                          <Copy className=\"w-4 h-4\" />\n                        </Button>\n                      </div>\n                      <div className=\"flex gap-2 mt-3\">\n                        <Button\n                          variant=\"secondary\"\n                          size=\"sm\"\n                          onClick={() => {\n                            setTestApiKey(createdAPIKey);\n                            showToast(t('settings.toast.keyAddedToBrowser'));\n                          }}\n                        >\n                          {t('settings.useInApiBrowser')}\n                        </Button>\n                        <Button\n                          variant=\"secondary\"\n                          size=\"sm\"\n                          onClick={() => setCreatedAPIKey(null)}\n                        >\n                          {t('common.dismiss')}\n                        </Button>\n                      </div>\n                    </div>\n                  </div>\n                </CardContent>\n              </Card>\n            )}\n\n            {/* Create Key Form */}\n            {showCreateAPIKey && (\n              <Card className=\"mb-6\">\n                <CardHeader>\n                  <h3 className=\"text-base font-semibold text-white\">{t('settings.createNewApiKey')}</h3>\n                </CardHeader>\n                <CardContent className=\"space-y-3\">\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">{t('settings.keyName')}</label>\n                    <input\n                      type=\"text\"\n                      value={newAPIKeyName}\n                      onChange={(e) => setNewAPIKeyName(e.target.value)}\n                      placeholder={t('settings.keyNamePlaceholder')}\n                      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\"\n                    />\n                  </div>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-2\">{t('common.permissions')}</label>\n                    <div className=\"space-y-2\">\n                      <label className=\"flex items-center gap-3 cursor-pointer\">\n                        <input\n                          type=\"checkbox\"\n                          checked={newAPIKeyPermissions.can_read_status}\n                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_read_status: e.target.checked }))}\n                          className=\"w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green\"\n                        />\n                        <div>\n                          <span className=\"text-white\">{t('settings.readStatus')}</span>\n                          <p className=\"text-xs text-bambu-gray\">{t('settings.readStatusDescription')}</p>\n                        </div>\n                      </label>\n                      <label className=\"flex items-center gap-3 cursor-pointer\">\n                        <input\n                          type=\"checkbox\"\n                          checked={newAPIKeyPermissions.can_queue}\n                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_queue: e.target.checked }))}\n                          className=\"w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green\"\n                        />\n                        <div>\n                          <span className=\"text-white\">{t('settings.manageQueue')}</span>\n                          <p className=\"text-xs text-bambu-gray\">{t('settings.manageQueueDescription')}</p>\n                        </div>\n                      </label>\n                      <label className=\"flex items-center gap-3 cursor-pointer\">\n                        <input\n                          type=\"checkbox\"\n                          checked={newAPIKeyPermissions.can_control_printer}\n                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_control_printer: e.target.checked }))}\n                          className=\"w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green\"\n                        />\n                        <div>\n                          <span className=\"text-white\">{t('settings.controlPrinter')}</span>\n                          <p className=\"text-xs text-bambu-gray\">{t('settings.controlPrinterDescription')}</p>\n                        </div>\n                      </label>\n                    </div>\n                  </div>\n                  <div className=\"flex items-center gap-2 pt-2\">\n                    <Button\n                      onClick={() => createAPIKeyMutation.mutate({\n                        name: newAPIKeyName || t('settings.unnamedKey'),\n                        ...newAPIKeyPermissions,\n                      })}\n                      disabled={createAPIKeyMutation.isPending}\n                    >\n                      {createAPIKeyMutation.isPending ? (\n                        <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      ) : (\n                        <Plus className=\"w-4 h-4\" />\n                      )}\n                      {t('settings.createKey')}\n                    </Button>\n                    <Button variant=\"secondary\" onClick={() => setShowCreateAPIKey(false)}>\n                      {t('common.cancel')}\n                    </Button>\n                  </div>\n                </CardContent>\n              </Card>\n            )}\n\n            {/* Existing Keys List */}\n            {apiKeysLoading ? (\n              <div className=\"flex justify-center py-12\">\n                <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n              </div>\n            ) : apiKeys && apiKeys.length > 0 ? (\n              <div className=\"space-y-3\">\n                {apiKeys.map((key) => (\n                  <Card key={key.id}>\n                    <CardContent className=\"py-3\">\n                      <div className=\"flex items-center justify-between\">\n                        <div className=\"flex items-center gap-3\">\n                          <Key className={`w-5 h-5 ${key.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />\n                          <div>\n                            <p className=\"text-white font-medium\">{key.name}</p>\n                            <p className=\"text-xs text-bambu-gray\">\n                              {key.key_prefix}••••••••\n                              {key.last_used && ` · ${t('settings.lastUsed')}: ${formatDateOnly(key.last_used)}`}\n                            </p>\n                          </div>\n                        </div>\n                        <div className=\"flex items-center gap-2\">\n                          <div className=\"flex gap-1 text-xs\">\n                            {key.can_read_status && (\n                              <span className=\"px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded\">{t('settings.read')}</span>\n                            )}\n                            {key.can_queue && (\n                              <span className=\"px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded\">{t('queue.title')}</span>\n                            )}\n                            {key.can_control_printer && (\n                              <span className=\"px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded\">{t('settings.control')}</span>\n                            )}\n                          </div>\n                          <Button\n                            variant=\"secondary\"\n                            size=\"sm\"\n                            onClick={() => setShowDeleteAPIKeyConfirm(key.id)}\n                          >\n                            <Trash2 className=\"w-4 h-4 text-red-400\" />\n                          </Button>\n                        </div>\n                      </div>\n                    </CardContent>\n                  </Card>\n                ))}\n              </div>\n            ) : (\n              <Card>\n                <CardContent className=\"py-12\">\n                  <div className=\"text-center text-bambu-gray\">\n                    <Key className=\"w-16 h-16 mx-auto mb-4 opacity-30\" />\n                    <p className=\"text-lg font-medium text-white mb-2\">{t('settings.apiKeysEmptyTitle')}</p>\n                    <p className=\"text-sm mb-4\">{t('settings.apiKeysEmptyDescription')}</p>\n                    <Button onClick={() => setShowCreateAPIKey(true)}>\n                      <Plus className=\"w-4 h-4\" />\n                      {t('settings.createFirstKey')}\n                    </Button>\n                  </div>\n                </CardContent>\n              </Card>\n            )}\n\n            {/* Webhook Documentation */}\n            <Card className=\"mt-6\">\n              <CardHeader>\n                <h3 className=\"text-base font-semibold text-white\" id=\"card-webhooks\">{t('settings.webhookEndpoints')}</h3>\n              </CardHeader>\n              <CardContent className=\"space-y-3 text-sm\">\n                <p className=\"text-bambu-gray\">\n                  {t('settings.webhookApiKeyHint')}\n                </p>\n                <div className=\"space-y-2 font-mono text-xs\">\n                  <div className=\"p-2 bg-bambu-dark rounded\">\n                    <span className=\"text-blue-400\">GET</span>{' '}\n                    <span className=\"text-white\">/api/v1/webhook/status</span>\n                    <span className=\"text-bambu-gray\"> - {t('settings.webhook.getAllStatus')}</span>\n                  </div>\n                  <div className=\"p-2 bg-bambu-dark rounded\">\n                    <span className=\"text-blue-400\">GET</span>{' '}\n                    <span className=\"text-white\">/api/v1/webhook/status/:id</span>\n                    <span className=\"text-bambu-gray\"> - {t('settings.webhook.getSpecificStatus')}</span>\n                  </div>\n                  <div className=\"p-2 bg-bambu-dark rounded\">\n                    <span className=\"text-green-400\">POST</span>{' '}\n                    <span className=\"text-white\">/api/v1/webhook/queue</span>\n                    <span className=\"text-bambu-gray\"> - {t('settings.webhook.addToQueue')}</span>\n                  </div>\n                  <div className=\"p-2 bg-bambu-dark rounded\">\n                    <span className=\"text-orange-400\">POST</span>{' '}\n                    <span className=\"text-white\">/api/v1/webhook/printer/:id/pause</span>\n                    <span className=\"text-bambu-gray\"> - {t('settings.webhook.pausePrint')}</span>\n                  </div>\n                  <div className=\"p-2 bg-bambu-dark rounded\">\n                    <span className=\"text-orange-400\">POST</span>{' '}\n                    <span className=\"text-white\">/api/v1/webhook/printer/:id/resume</span>\n                    <span className=\"text-bambu-gray\"> - {t('settings.webhook.resumePrint')}</span>\n                  </div>\n                  <div className=\"p-2 bg-bambu-dark rounded\">\n                    <span className=\"text-red-400\">POST</span>{' '}\n                    <span className=\"text-white\">/api/v1/webhook/printer/:id/stop</span>\n                    <span className=\"text-bambu-gray\"> - {t('settings.webhook.stopPrint')}</span>\n                  </div>\n                </div>\n              </CardContent>\n            </Card>\n          </div>\n\n          {/* Right Column - API Browser */}\n          <div>\n            <div className=\"mb-6\">\n              <h2 className=\"text-lg font-semibold text-white flex items-center gap-2\" id=\"card-apibrowser\">\n                <Globe className=\"w-5 h-5 text-bambu-green\" />\n                {t('settings.apiBrowser')}\n              </h2>\n              <p className=\"text-sm text-bambu-gray mt-1\">\n                {t('settings.apiBrowserDescription')}\n              </p>\n            </div>\n\n            {/* API Key Input for Testing */}\n            <Card className=\"mb-4\">\n              <CardContent className=\"py-3\">\n                <label className=\"block text-sm text-bambu-gray mb-2\">{t('settings.apiKeyForTesting')}</label>\n                <input\n                  type=\"text\"\n                  value={testApiKey}\n                  onChange={(e) => setTestApiKey(e.target.value)}\n                  placeholder={t('settings.apiKeyPlaceholder')}\n                  className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm focus:border-bambu-green focus:outline-none\"\n                />\n                <p className=\"text-xs text-bambu-gray mt-2\">\n                  {t('settings.apiKeyHint')}\n                </p>\n              </CardContent>\n            </Card>\n\n            <APIBrowser apiKey={testApiKey} />\n          </div>\n        </div>\n      )}\n\n      {/* Virtual Printer Tab */}\n      {activeTab === 'virtual-printer' && (\n        <div id=\"card-vp\">\n          <VirtualPrinterList />\n        </div>\n      )}\n\n      {/* SpoolBuddy Tab */}\n      {activeTab === 'spoolbuddy' && (\n        <div id=\"card-spoolbuddy\">\n          <SpoolBuddySettings />\n        </div>\n      )}\n\n      {/* Filament Tab */}\n      {/* Queue Tab */}\n      {activeTab === 'queue' && localSettings && (\n        <div className=\"flex flex-col lg:flex-row gap-4 lg:gap-6\">\n          {/* Left Column */}\n          <div className=\"lg:w-1/2 space-y-3\">\n          {/* Default Print Options */}\n          <Card id=\"card-print-options\">\n            <CardHeader>\n              <h3 className=\"text-base font-semibold text-white flex items-center gap-2\">\n                <ListOrdered className=\"w-4 h-4 text-bambu-green\" />\n                {t('settings.defaultPrintOptions', 'Default Print Options')}\n              </h3>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-xs text-bambu-gray\">\n                {t('settings.defaultPrintOptionsDescription', 'Set default values for print options when starting new prints. These can be overridden per print in the print dialog.')}\n              </p>\n              {[\n                { key: 'default_bed_levelling' as const, label: t('settings.defaultBedLevelling', 'Bed Levelling'), desc: t('settings.defaultBedLevellingDesc', 'Auto-level bed before print'), fallback: true },\n                { key: 'default_flow_cali' as const, label: t('settings.defaultFlowCali', 'Flow Calibration'), desc: t('settings.defaultFlowCaliDesc', 'Calibrate extrusion flow'), fallback: false },\n                { key: 'default_vibration_cali' as const, label: t('settings.defaultVibrationCali', 'Vibration Calibration'), desc: t('settings.defaultVibrationCaliDesc', 'Reduce ringing artifacts'), fallback: true },\n                { key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false },\n                { key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false },\n              ].map(({ key, label, desc, fallback }) => (\n                <div key={key} className=\"flex items-center justify-between\">\n                  <div className=\"flex-1 mr-4\">\n                    <p className=\"text-sm text-white\">{label}</p>\n                    <p className=\"text-xs text-bambu-gray mt-0.5\">{desc}</p>\n                  </div>\n                  <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={localSettings[key] ?? fallback}\n                      onChange={(e) => updateSetting(key, e.target.checked)}\n                      className=\"sr-only peer\"\n                    />\n                    <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                  </label>\n                </div>\n              ))}\n            </CardContent>\n          </Card>\n\n          {/* Staggered Batch Start */}\n          <Card id=\"card-staggered\">\n            <CardHeader>\n              <h3 className=\"text-base font-semibold text-white flex items-center gap-2\">\n                <Layers className=\"w-4 h-4 text-bambu-green\" />\n                {t('settings.staggeredStart', 'Staggered Start')}\n              </h3>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-xs text-bambu-gray\">\n                {t('settings.staggeredStartDescription', 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.')}\n              </p>\n              <div className=\"flex gap-4\">\n                <div className=\"flex-1\">\n                  <label className=\"block text-xs text-bambu-gray mb-1\">\n                    {t('settings.staggerGroupSize', 'Group size')}\n                  </label>\n                  <input\n                    type=\"number\"\n                    min={1}\n                    max={50}\n                    value={localSettings.stagger_group_size ?? 2}\n                    onChange={(e) => updateSetting('stagger_group_size', Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))}\n                    className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n                  />\n                  <p className=\"text-xs text-bambu-gray mt-1\">\n                    {t('settings.staggerGroupSizeHelp', 'Printers to start simultaneously per group')}\n                  </p>\n                </div>\n                <div className=\"flex-1\">\n                  <label className=\"block text-xs text-bambu-gray mb-1\">\n                    {t('settings.staggerInterval', 'Interval (minutes)')}\n                  </label>\n                  <input\n                    type=\"number\"\n                    min={1}\n                    max={60}\n                    value={localSettings.stagger_interval_minutes ?? 5}\n                    onChange={(e) => updateSetting('stagger_interval_minutes', Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))}\n                    className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"\n                  />\n                  <p className=\"text-xs text-bambu-gray mt-1\">\n                    {t('settings.staggerIntervalHelp', 'Delay between each group starting')}\n                  </p>\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n\n          {/* Plate-Clear Confirmation */}\n          <Card id=\"card-plate\">\n            <CardHeader>\n              <h3 className=\"text-base font-semibold text-white flex items-center gap-2\">\n                <Shield className=\"w-4 h-4 text-bambu-green\" />\n                {t('settings.plateClear', 'Plate-Clear Confirmation')}\n              </h3>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex-1 mr-4\">\n                  <p className=\"text-sm text-white\">\n                    {t('settings.requirePlateClear', 'Require plate-clear confirmation')}\n                  </p>\n                  <p className=\"text-xs text-bambu-gray mt-1\">\n                    {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the \"Mark plate as cleared\" button on printer cards.')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.require_plate_clear ?? false}\n                    onChange={(e) => updateSetting('require_plate_clear', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n            </CardContent>\n          </Card>\n\n          {/* G-code Injection (#422) */}\n          <Card id=\"card-gcode\">\n            <CardHeader>\n              <h3 className=\"text-base font-semibold text-white flex items-center gap-2\">\n                <Code className=\"w-4 h-4 text-bambu-green\" />\n                {t('settings.gcodeInjection', 'G-code Injection')}\n              </h3>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-xs text-bambu-gray\">\n                {t('settings.gcodeInjectionDescription', 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when \"Inject G-code\" is enabled on a queue item.')}\n              </p>\n              {(() => {\n                const gcodeSnippets: Record<string, { start_gcode: string; end_gcode: string }> = (() => {\n                  try {\n                    return localSettings.gcode_snippets ? JSON.parse(localSettings.gcode_snippets) : {};\n                  } catch {\n                    return {};\n                  }\n                })();\n                const printerModels = [...new Set((printers || []).filter((p) => p.model).map((p) => p.model as string))].sort();\n\n                const updateSnippet = (model: string, field: 'start_gcode' | 'end_gcode', value: string) => {\n                  const updated = { ...gcodeSnippets };\n                  if (!updated[model]) {\n                    updated[model] = { start_gcode: '', end_gcode: '' };\n                  }\n                  updated[model][field] = value;\n                  // Remove model entry if both fields are empty\n                  if (!updated[model].start_gcode && !updated[model].end_gcode) {\n                    delete updated[model];\n                  }\n                  const newValue = Object.keys(updated).length > 0 ? JSON.stringify(updated) : '';\n                  // Update local state for immediate UI feedback, save on blur\n                  setLocalSettings(prev => prev ? { ...prev, gcode_snippets: newValue } : null);\n                  pendingGcodeSnippetsRef.current = newValue;\n                };\n\n                const saveGcodeSnippets = () => {\n                  if (pendingGcodeSnippetsRef.current !== null) {\n                    updateMutation.mutate({ gcode_snippets: pendingGcodeSnippetsRef.current });\n                    pendingGcodeSnippetsRef.current = null;\n                  }\n                };\n\n                if (printerModels.length === 0) {\n                  return (\n                    <p className=\"text-sm text-bambu-gray italic\">\n                      {t('settings.gcodeInjectionNoPrinters', 'No printers found. Add printers to configure G-code snippets.')}\n                    </p>\n                  );\n                }\n\n                return printerModels.map((model) => {\n                  const snippet = gcodeSnippets[model] || { start_gcode: '', end_gcode: '' };\n                  const hasContent = !!(snippet.start_gcode || snippet.end_gcode);\n                  return (\n                    <Collapsible\n                      key={model}\n                      defaultOpen={hasContent}\n                      className=\"border border-bambu-dark-tertiary rounded-lg px-3 py-2\"\n                      summary={\n                        <div className=\"flex items-center gap-2\">\n                          <h4 className=\"text-sm font-medium text-white\">{model}</h4>\n                          {hasContent && (\n                            <span className=\"text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green\">\n                              {t('settings.gcodeConfigured', 'Configured')}\n                            </span>\n                          )}\n                        </div>\n                      }\n                    >\n                      <div className=\"space-y-2\">\n                        <div>\n                          <label className=\"block text-xs text-bambu-gray mb-1\">\n                            {t('settings.gcodeStartLabel', 'Start G-code')}\n                          </label>\n                          <textarea\n                            value={snippet.start_gcode}\n                            onChange={(e) => updateSnippet(model, 'start_gcode', e.target.value)}\n                            onBlur={saveGcodeSnippets}\n                            placeholder={t('settings.gcodeStartPlaceholder', 'G-code prepended before the print starts...')}\n                            rows={3}\n                            className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:outline-none focus:border-bambu-green resize-y\"\n                          />\n                        </div>\n                        <div>\n                          <label className=\"block text-xs text-bambu-gray mb-1\">\n                            {t('settings.gcodeEndLabel', 'End G-code')}\n                          </label>\n                          <textarea\n                            value={snippet.end_gcode}\n                            onChange={(e) => updateSnippet(model, 'end_gcode', e.target.value)}\n                            onBlur={saveGcodeSnippets}\n                            placeholder={t('settings.gcodeEndPlaceholder', 'G-code appended after the print ends...')}\n                            rows={3}\n                            className=\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:outline-none focus:border-bambu-green resize-y\"\n                          />\n                        </div>\n                      </div>\n                    </Collapsible>\n                  );\n                });\n              })()}\n            </CardContent>\n          </Card>\n\n          </div>\n          {/* Right Column */}\n          <div className=\"lg:w-1/2 space-y-3\">\n          {/* Auto-Drying */}\n          <Card>\n            <CardHeader>\n              <h3 className=\"text-base font-semibold text-white flex items-center gap-2\" id=\"card-drying\">\n                <Flame className=\"w-4 h-4 text-amber-400\" />\n                {t('settings.queueDrying')}\n              </h3>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              <p className=\"text-xs text-bambu-gray\">\n                {t('settings.queueDryingDescription')}\n              </p>\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <label className=\"block text-sm text-white\">\n                    {t('settings.queueDryingEnabled')}\n                  </label>\n                  <p className=\"text-xs text-bambu-gray mt-0.5\">\n                    {t('settings.queueDryingEnabledDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.queue_drying_enabled ?? false}\n                    onChange={(e) => updateSetting('queue_drying_enabled', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              {localSettings.queue_drying_enabled && (\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <label className=\"block text-sm text-white\">\n                      {t('settings.queueDryingBlock')}\n                    </label>\n                    <p className=\"text-xs text-bambu-gray mt-0.5\">\n                      {t('settings.queueDryingBlockDescription')}\n                    </p>\n                  </div>\n                  <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={localSettings.queue_drying_block ?? false}\n                      onChange={(e) => updateSetting('queue_drying_block', e.target.checked)}\n                      className=\"sr-only peer\"\n                    />\n                    <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500\"></div>\n                  </label>\n                </div>\n              )}\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <label className=\"block text-sm text-white\">\n                    {t('settings.ambientDryingEnabled')}\n                  </label>\n                  <p className=\"text-xs text-bambu-gray mt-0.5\">\n                    {t('settings.ambientDryingEnabledDescription')}\n                  </p>\n                </div>\n                <label className=\"relative inline-flex items-center cursor-pointer\">\n                  <input\n                    type=\"checkbox\"\n                    checked={localSettings.ambient_drying_enabled ?? false}\n                    onChange={(e) => updateSetting('ambient_drying_enabled', e.target.checked)}\n                    className=\"sr-only peer\"\n                  />\n                  <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                </label>\n              </div>\n              {/* Drying Presets Table */}\n              <div className=\"space-y-2\">\n                <p className=\"text-sm text-white font-medium\">{t('settings.dryingPresets')}</p>\n                <p className=\"text-xs text-bambu-gray\">{t('settings.dryingPresetsDescription')}</p>\n                <div className=\"overflow-x-auto\">\n                  <table className=\"w-full text-xs\">\n                    <thead>\n                      <tr className=\"text-bambu-gray border-b border-bambu-dark-tertiary\">\n                        <th className=\"text-left py-1.5\">{t('settings.dryingFilament')}</th>\n                        <th className=\"text-center py-1.5\" colSpan={2}>AMS 2 Pro</th>\n                        <th className=\"text-center py-1.5\" colSpan={2}>AMS-HT</th>\n                      </tr>\n                    </thead>\n                    <tbody>\n                      {(() => {\n                        const defaults: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {\n                          PLA: { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },\n                          PETG: { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },\n                          TPU: { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },\n                          ABS: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n                          ASA: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n                          PA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },\n                          PC: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },\n                          PVA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },\n                        };\n                        let presets = { ...defaults };\n                        try {\n                          if (localSettings.drying_presets) {\n                            const parsed = JSON.parse(localSettings.drying_presets);\n                            if (typeof parsed === 'object' && parsed !== null) {\n                              presets = { ...defaults, ...parsed };\n                            }\n                          }\n                        } catch { /* use defaults */ }\n\n                        const updatePreset = (fil: string, key: string, value: number) => {\n                          const updated = { ...presets, [fil]: { ...presets[fil], [key]: value } };\n                          updateSetting('drying_presets', JSON.stringify(updated));\n                        };\n\n                        return Object.entries(presets).map(([fil, preset]) => (\n                          <tr key={fil} className=\"border-b border-bambu-dark-tertiary/50\">\n                            <td className=\"py-1.5 pr-2 text-white font-medium\">{fil}</td>\n                            <td className=\"py-1 px-1\">\n                              <div className=\"flex items-center justify-end gap-1\">\n                                <input type=\"number\" min={30} max={65} value={preset.n3f}\n                                  onChange={e => updatePreset(fil, 'n3f', Math.max(1, parseInt(e.target.value) || 0))}\n                                  className=\"w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                                />\n                                <span className=\"text-bambu-gray\">°C</span>\n                              </div>\n                            </td>\n                            <td className=\"py-1 px-1\">\n                              <div className=\"flex items-center gap-1\">\n                                <input type=\"number\" min={1} max={24} value={preset.n3f_hours}\n                                  onChange={e => updatePreset(fil, 'n3f_hours', Math.max(1, parseInt(e.target.value) || 0))}\n                                  className=\"w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                                />\n                                <span className=\"text-bambu-gray\">h</span>\n                              </div>\n                            </td>\n                            <td className=\"py-1 px-1\">\n                              <div className=\"flex items-center justify-end gap-1\">\n                                <input type=\"number\" min={30} max={85} value={preset.n3s}\n                                  onChange={e => updatePreset(fil, 'n3s', Math.max(1, parseInt(e.target.value) || 0))}\n                                  className=\"w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                                />\n                                <span className=\"text-bambu-gray\">°C</span>\n                              </div>\n                            </td>\n                            <td className=\"py-1 px-1\">\n                              <div className=\"flex items-center gap-1\">\n                                <input type=\"number\" min={1} max={24} value={preset.n3s_hours}\n                                  onChange={e => updatePreset(fil, 'n3s_hours', Math.max(1, parseInt(e.target.value) || 0))}\n                                  className=\"w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"\n                                />\n                                <span className=\"text-bambu-gray\">h</span>\n                              </div>\n                            </td>\n                          </tr>\n                        ));\n                      })()}\n                    </tbody>\n                  </table>\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n          </div>\n        </div>\n      )}\n\n      {activeTab === 'filament' && localSettings && (\n        <>\n        <div className=\"flex flex-col lg:flex-row gap-4 lg:gap-6\">\n          {/* Left Column (1/3) - Mode Selector + AMS Thresholds */}\n          <div className=\"lg:w-1/3 space-y-3\">\n            <SpoolmanSettings />\n\n            <Card id=\"card-filamentchecks\">\n              <CardHeader>\n                <h2 className=\"text-lg font-semibold text-white\">{t('settings.filamentChecks')}</h2>\n              </CardHeader>\n              <CardContent className=\"space-y-3\">\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-white\">{t('settings.disableFilamentWarnings')}</p>\n                    <p className=\"text-sm text-bambu-gray\">\n                      {t('settings.disableFilamentWarningsDesc')}\n                    </p>\n                  </div>\n                  <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={localSettings.disable_filament_warnings}\n                      onChange={(e) => updateSetting('disable_filament_warnings', e.target.checked)}\n                      className=\"sr-only peer\"\n                    />\n                    <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                  </label>\n                </div>\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-white\">{t('settings.preferLowestFilament')}</p>\n                    <p className=\"text-sm text-bambu-gray\">\n                      {t('settings.preferLowestFilamentDesc')}\n                    </p>\n                  </div>\n                  <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={localSettings.prefer_lowest_filament}\n                      onChange={(e) => updateSetting('prefer_lowest_filament', e.target.checked)}\n                      className=\"sr-only peer\"\n                    />\n                    <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                  </label>\n                </div>\n              </CardContent>\n            </Card>\n\n            {/* Per-Printer Mapping Default */}\n            <Card id=\"card-printmodal\">\n              <CardHeader>\n                <h2 className=\"text-lg font-semibold text-white\">{t('settings.printModal')}</h2>\n              </CardHeader>\n              <CardContent className=\"space-y-3\">\n                <div className=\"flex items-center justify-between\">\n                  <div>\n                    <p className=\"text-white\">{t('settings.expandCustomMapping')}</p>\n                    <p className=\"text-sm text-bambu-gray\">\n                      {t('settings.expandCustomMappingDescription')}\n                    </p>\n                  </div>\n                  <label className=\"relative inline-flex items-center cursor-pointer\">\n                    <input\n                      type=\"checkbox\"\n                      checked={localSettings.per_printer_mapping_expanded ?? false}\n                      onChange={(e) => updateSetting('per_printer_mapping_expanded', e.target.checked)}\n                      className=\"sr-only peer\"\n                    />\n                    <div className=\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"></div>\n                  </label>\n                </div>\n              </CardContent>\n            </Card>\n\n            <Card id=\"card-amsthresholds\">\n              <CardHeader>\n                <h2 className=\"text-lg font-semibold text-white\">{t('settings.amsDisplayThresholds')}</h2>\n              </CardHeader>\n              <CardContent className=\"space-y-3\">\n                <p className=\"text-sm text-bambu-gray\">\n                  {t('settings.amsThresholdsDescription')}\n                </p>\n\n                {/* Humidity Thresholds */}\n                <div className=\"space-y-3\">\n                  <div className=\"flex items-center gap-2 text-white\">\n                    <Droplets className=\"w-4 h-4 text-blue-400\" />\n                    <span className=\"font-medium\">{t('settings.humidity')}</span>\n                  </div>\n                  <div className=\"grid grid-cols-2 gap-3\">\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">\n                        {t('settings.goodGreen')} ≤\n                      </label>\n                      <div className=\"flex items-center gap-2\">\n                        <input\n                          type=\"number\"\n                          min=\"0\"\n                          max=\"100\"\n                          value={localSettings.ams_humidity_good ?? 40}\n                          onChange={(e) => updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)}\n                          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\"\n                        />\n                        <span className=\"text-bambu-gray\">%</span>\n                      </div>\n                    </div>\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">\n                        {t('settings.fairOrange')} ≤\n                      </label>\n                      <div className=\"flex items-center gap-2\">\n                        <input\n                          type=\"number\"\n                          min=\"0\"\n                          max=\"100\"\n                          value={localSettings.ams_humidity_fair ?? 60}\n                          onChange={(e) => updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)}\n                          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\"\n                        />\n                        <span className=\"text-bambu-gray\">%</span>\n                      </div>\n                    </div>\n                  </div>\n                  <p className=\"text-xs text-bambu-gray\">\n                    {t('settings.aboveFairBad')}\n                  </p>\n                  <p className=\"text-xs text-amber-400/70\">\n                    {t('settings.fairAlsoDryingThreshold')}\n                  </p>\n                </div>\n\n                {/* Temperature Thresholds */}\n                <div className=\"space-y-3 pt-2 border-t border-bambu-dark-tertiary\">\n                  <div className=\"flex items-center gap-2 text-white\">\n                    <Thermometer className=\"w-4 h-4 text-orange-400\" />\n                    <span className=\"font-medium\">{t('settings.temperature')}</span>\n                  </div>\n                  <div className=\"grid grid-cols-2 gap-3\">\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">\n                        {t('settings.goodBlue')} ≤\n                      </label>\n                      <div className=\"flex items-center gap-2\">\n                        <input\n                          type=\"number\"\n                          step=\"0.5\"\n                          min=\"0\"\n                          max=\"60\"\n                          value={localSettings.ams_temp_good ?? 28}\n                          onChange={(e) => updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)}\n                          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\"\n                        />\n                        <span className=\"text-bambu-gray\">°C</span>\n                      </div>\n                    </div>\n                    <div>\n                      <label className=\"block text-sm text-bambu-gray mb-1\">\n                        {t('settings.fairOrange')} ≤\n                      </label>\n                      <div className=\"flex items-center gap-2\">\n                        <input\n                          type=\"number\"\n                          step=\"0.5\"\n                          min=\"0\"\n                          max=\"60\"\n                          value={localSettings.ams_temp_fair ?? 35}\n                          onChange={(e) => updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)}\n                          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\"\n                        />\n                        <span className=\"text-bambu-gray\">°C</span>\n                      </div>\n                    </div>\n                  </div>\n                  <p className=\"text-xs text-bambu-gray\">\n                    {t('settings.aboveFairHot')}\n                  </p>\n                </div>\n\n                {/* History Retention */}\n                <div className=\"space-y-3 pt-4 border-t border-bambu-dark-tertiary\">\n                  <div className=\"flex items-center gap-2 text-white\">\n                    <Database className=\"w-4 h-4 text-purple-400\" />\n                    <span className=\"font-medium\">{t('settings.historyRetention')}</span>\n                  </div>\n                  <div>\n                    <label className=\"block text-sm text-bambu-gray mb-1\">\n                      {t('settings.keepSensorHistory')}\n                    </label>\n                    <div className=\"flex items-center gap-2\">\n                      <input\n                        type=\"number\"\n                        min=\"1\"\n                        max=\"365\"\n                        value={localSettings.ams_history_retention_days ?? 30}\n                        onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}\n                        className=\"w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"\n                      />\n                      <span className=\"text-bambu-gray\">{t('common.days')}</span>\n                    </div>\n                  </div>\n                  <p className=\"text-xs text-bambu-gray\">\n                    {t('settings.historyRetentionDescription')}\n                  </p>\n                </div>\n\n              </CardContent>\n            </Card>\n          </div>\n\n          {/* Right Column (2/3) - Spool Catalog + Color Catalog */}\n          <div className=\"lg:w-2/3 space-y-3\">\n            <SpoolCatalogSettings />\n            <ColorCatalogSettings />\n          </div>\n        </div>\n        </>\n      )}\n\n      {/* Delete API Key Confirmation */}\n      {showDeleteAPIKeyConfirm !== null && (\n        <ConfirmModal\n          title={t('settings.deleteApiKeyTitle')}\n          message={t('settings.deleteApiKeyMessage')}\n          confirmText={t('settings.deleteKey')}\n          variant=\"danger\"\n          onConfirm={() => {\n            deleteAPIKeyMutation.mutate(showDeleteAPIKeyConfirm);\n            setShowDeleteAPIKeyConfirm(null);\n          }}\n          onCancel={() => setShowDeleteAPIKeyConfirm(null)}\n        />\n      )}\n\n      {/* Smart Plug Modal */}\n      {showPlugModal && (\n        <AddSmartPlugModal\n          plug={editingPlug}\n          onClose={() => {\n            setShowPlugModal(false);\n            setEditingPlug(null);\n          }}\n        />\n      )}\n\n      {/* Notification Modal */}\n      {showNotificationModal && (\n        <AddNotificationModal\n          provider={editingProvider}\n          onClose={() => {\n            setShowNotificationModal(false);\n            setEditingProvider(null);\n          }}\n        />\n      )}\n\n      {/* Template Editor Modal */}\n      {editingTemplate && (\n        <NotificationTemplateEditor\n          template={editingTemplate}\n          onClose={() => setEditingTemplate(null)}\n        />\n      )}\n\n      {/* Notification Log Viewer */}\n      {showLogViewer && (\n        <NotificationLogViewer\n          onClose={() => setShowLogViewer(false)}\n        />\n      )}\n\n      {/* Confirm Modal: Clear Notification Logs */}\n      {showClearLogsConfirm && (\n        <ConfirmModal\n          title={t('settings.clearNotificationLogs')}\n          message={t('settings.clearLogsMessage')}\n          confirmText={t('settings.clearLogs')}\n          variant=\"warning\"\n          onConfirm={async () => {\n            setShowClearLogsConfirm(false);\n            try {\n              const result = await api.clearNotificationLogs(30);\n              showToast(result.message, 'success');\n            } catch {\n              showToast(t('settings.toast.clearLogsFailed'), 'error');\n            }\n          }}\n          onCancel={() => setShowClearLogsConfirm(false)}\n        />\n      )}\n\n      {/* Confirm Modal: Clear Local Storage */}\n      {showClearStorageConfirm && (\n        <ConfirmModal\n          title={t('settings.resetUiPreferences')}\n          message={t('settings.resetUiPreferencesMessage')}\n          confirmText={t('settings.resetPreferences')}\n          variant=\"default\"\n          onConfirm={() => {\n            setShowClearStorageConfirm(false);\n            localStorage.clear();\n            showToast(t('settings.toast.uiPreferencesReset'), 'success');\n            setTimeout(() => window.location.reload(), 1000);\n          }}\n          onCancel={() => setShowClearStorageConfirm(false)}\n        />\n      )}\n\n      {/* Confirm Modal: Bulk Plug Action */}\n      {showBulkPlugConfirm && (\n        <ConfirmModal\n          title={`Turn All Plugs ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}\n          message={`This will turn ${showBulkPlugConfirm === 'on' ? 'ON' : 'OFF'} all ${smartPlugs?.filter(p => p.enabled).length || 0} enabled smart plugs. ${showBulkPlugConfirm === 'off' ? 'Any running printers may be affected!' : ''}`}\n          confirmText={`Turn All ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}\n          variant={showBulkPlugConfirm === 'off' ? 'danger' : 'warning'}\n          onConfirm={() => {\n            const action = showBulkPlugConfirm;\n            setShowBulkPlugConfirm(null);\n            bulkPlugActionMutation.mutate(action);\n          }}\n          onCancel={() => setShowBulkPlugConfirm(null)}\n        />\n      )}\n\n      {/* Release Notes Modal */}\n      {showReleaseNotes && updateCheck?.release_notes && (\n        <div\n          className=\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\"\n          onClick={() => setShowReleaseNotes(false)}\n        >\n          <Card className=\"w-full max-w-2xl max-h-[80vh] flex flex-col\" onClick={(e: React.MouseEvent) => e.stopPropagation()}>\n            <CardHeader className=\"flex flex-row items-center justify-between shrink-0\">\n              <div>\n                <h2 className=\"text-lg font-semibold text-white\">\n                  Release Notes - v{updateCheck.latest_version}\n                </h2>\n                {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (\n                  <p className=\"text-sm text-bambu-gray\">{updateCheck.release_name}</p>\n                )}\n              </div>\n              <button\n                onClick={() => setShowReleaseNotes(false)}\n                className=\"p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\"\n              >\n                <X className=\"w-5 h-5\" />\n              </button>\n            </CardHeader>\n            <CardContent className=\"overflow-y-auto flex-1\">\n              <pre className=\"text-sm text-bambu-gray whitespace-pre-wrap font-sans\">\n                {updateCheck.release_notes}\n              </pre>\n            </CardContent>\n            <div className=\"p-4 border-t border-bambu-dark-tertiary shrink-0 flex gap-2\">\n              {updateCheck.release_url && (\n                <a\n                  href={updateCheck.release_url}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"flex-1\"\n                >\n                  <Button variant=\"secondary\" className=\"w-full\">\n                    <ExternalLink className=\"w-4 h-4\" />\n                    View on GitHub\n                  </Button>\n                </a>\n              )}\n              <Button\n                onClick={() => setShowReleaseNotes(false)}\n                className=\"flex-1\"\n              >\n                Close\n              </Button>\n            </div>\n          </Card>\n        </div>\n      )}\n\n      {/* Users Tab */}\n      {activeTab === 'users' && (\n        <div className=\"space-y-3\">\n          {/* Sub-tab Navigation */}\n          <div className=\"flex gap-1 border-b border-bambu-dark-tertiary\">\n            <button\n              onClick={() => setUsersSubTab('users')}\n              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n                usersSubTab === 'users'\n                  ? 'text-bambu-green border-bambu-green'\n                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n              }`}\n            >\n              <Users className=\"w-4 h-4\" />\n              {t('settings.tabs.users')}\n            </button>\n            <button\n              onClick={() => setUsersSubTab('email')}\n              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n                usersSubTab === 'email'\n                  ? 'text-bambu-green border-bambu-green'\n                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n              }`}\n            >\n              <Mail className=\"w-4 h-4\" />\n              {t('settings.tabs.emailAuth') || 'Email Authentication'}\n              {advancedAuthStatus?.advanced_auth_enabled && (\n                <span className=\"w-2 h-2 rounded-full bg-green-400\" />\n              )}\n            </button>\n            <button\n              onClick={() => setUsersSubTab('ldap')}\n              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${\n                usersSubTab === 'ldap'\n                  ? 'text-bambu-green border-bambu-green'\n                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n              }`}\n            >\n              <Shield className=\"w-4 h-4\" />\n              {t('settings.tabs.ldap') || 'LDAP'}\n              {ldapStatus?.ldap_enabled && (\n                <span className=\"w-2 h-2 rounded-full bg-green-400\" />\n              )}\n            </button>\n            <button\n              onClick={() => setUsersSubTab('twofa')}\n              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${\n                usersSubTab === 'twofa'\n                  ? 'text-bambu-green border-bambu-green'\n                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n              }`}\n            >\n              <Shield className=\"w-4 h-4\" />\n              {t('settings.tabs.twoFa')}\n              <span\n                className={`w-2 h-2 rounded-full ${\n                  twoFAStatus?.totp_enabled || twoFAStatus?.email_otp_enabled\n                    ? 'bg-green-400'\n                    : 'bg-bambu-gray/40'\n                }`}\n              />\n            </button>\n            {isAdmin && (\n              <button\n                onClick={() => setUsersSubTab('oidc')}\n                className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${\n                  usersSubTab === 'oidc'\n                    ? 'text-bambu-green border-bambu-green'\n                    : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'\n                }`}\n              >\n                <Globe className=\"w-4 h-4\" />\n                {t('settings.tabs.oidc')}\n                <span\n                  className={`w-2 h-2 rounded-full ${\n                    oidcProvidersAll.some((p) => p.is_enabled)\n                      ? 'bg-green-400'\n                      : 'bg-bambu-gray/40'\n                  }`}\n                />\n              </button>\n            )}\n          </div>\n\n          {/* Users Sub-tab */}\n          {usersSubTab === 'users' && (\n          <>\n          {/* Auth Toggle Header */}\n          <Card>\n            <CardContent className=\"py-4\">\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-3\">\n                  <div className={`w-10 h-10 rounded-full flex items-center justify-center ${authEnabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>\n                    {authEnabled ? (\n                      <Lock className=\"w-5 h-5 text-green-400\" />\n                    ) : (\n                      <Unlock className=\"w-5 h-5 text-gray-400\" />\n                    )}\n                  </div>\n                  <div>\n                    <h3 className=\"text-white font-medium\">{t('settings.authentication')}</h3>\n                    <p className=\"text-sm text-bambu-gray\">\n                      {authEnabled\n                        ? t('settings.authEnabledDescription')\n                        : t('settings.authDisabledDescription')}\n                    </p>\n                  </div>\n                </div>\n                {!authEnabled ? (\n                  <Button onClick={() => navigate('/setup')}>\n                    <Lock className=\"w-4 h-4\" />\n                    {t('common.enable')}\n                  </Button>\n                ) : user?.is_admin && (\n                  <Button variant=\"secondary\" onClick={() => setShowDisableAuthConfirm(true)}>\n                    <Unlock className=\"w-4 h-4\" />\n                    {t('common.disable')}\n                  </Button>\n                )}\n              </div>\n            </CardContent>\n          </Card>\n\n          {/* Advanced Authentication Notice Box */}\n          {advancedAuthStatus?.advanced_auth_enabled && (\n            <Card className=\"border-blue-500/30 bg-blue-500/5\">\n              <CardContent className=\"py-4\">\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0\">\n                    <Mail className=\"w-5 h-5 text-blue-400\" />\n                  </div>\n                  <div>\n                    <h3 className=\"text-white font-medium\">{t('settings.email.advancedAuthEnabled')}</h3>\n                    <p className=\"text-sm text-bambu-gray mt-1\">\n                      {t('settings.email.advancedAuthEnabledDesc')}\n                    </p>\n                  </div>\n                </div>\n              </CardContent>\n            </Card>\n          )}\n\n          {authEnabled && (\n            <div className=\"grid grid-cols-1 xl:grid-cols-2 gap-6\">\n              {/* Left Column: Current User + User List */}\n              <div className=\"space-y-3\">\n                {/* Current User Card */}\n                {user && (\n                  <Card>\n                    <CardHeader>\n                      <div className=\"flex items-center justify-between\">\n                        <h3 className=\"text-lg font-semibold text-white flex items-center gap-2\" id=\"card-currentuser\">\n                          <Users className=\"w-5 h-5 text-bambu-green\" />\n                          {t('settings.currentUser')}\n                        </h3>\n                        {user.auth_source !== 'ldap' && (\n                        <Button size=\"sm\" variant=\"ghost\" onClick={() => setShowChangePasswordModal(true)}>\n                          <Key className=\"w-4 h-4\" />\n                          {t('settings.changePassword')}\n                        </Button>\n                        )}\n                      </div>\n                    </CardHeader>\n                    <CardContent>\n                      <div className=\"flex items-center justify-between\">\n                        <div>\n                          <p className=\"text-white font-medium text-lg\">{user.username}</p>\n                          <div className=\"flex flex-wrap gap-1 mt-2\">\n                            {user.is_admin && (\n                              <span className=\"px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300\">\n                                {t('settings.admin')}\n                              </span>\n                            )}\n                            {user.groups?.map(group => (\n                              <span\n                                key={group.id}\n                                className={`px-2 py-0.5 rounded-full text-xs font-medium ${\n                                  group.name === 'Administrators'\n                                    ? 'bg-purple-500/20 text-purple-300'\n                                    : group.name === 'Operators'\n                                    ? 'bg-blue-500/20 text-blue-300'\n                                    : group.name === 'Viewers'\n                                    ? 'bg-green-500/20 text-green-300'\n                                    : 'bg-gray-500/20 text-gray-300'\n                                }`}\n                              >\n                                {group.name}\n                              </span>\n                            ))}\n                          </div>\n                        </div>\n                      </div>\n                    </CardContent>\n                  </Card>\n                )}\n\n                {/* User List */}\n                <Card>\n                  <CardHeader>\n                    <div className=\"flex items-center justify-between\">\n                      <h3 className=\"text-lg font-semibold text-white flex items-center gap-2\" id=\"card-users\">\n                        <Users className=\"w-5 h-5 text-bambu-green\" />\n                        {t('settings.users')}\n                      </h3>\n                      {hasPermission('users:create') && (\n                        <Button\n                          size=\"sm\"\n                          onClick={() => {\n                            setShowCreateUserModal(true);\n                            setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n                          }}\n                        >\n                          <Plus className=\"w-4 h-4\" />\n                          {t('settings.addUser')}\n                        </Button>\n                      )}\n                    </div>\n                  </CardHeader>\n                  <CardContent>\n                    {usersLoading ? (\n                      <div className=\"flex items-center justify-center py-8\">\n                        <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n                      </div>\n                    ) : usersData.length === 0 ? (\n                      <p className=\"text-center text-bambu-gray py-8\">{t('settings.noUsersFound')}</p>\n                    ) : (\n                      <div className=\"divide-y divide-bambu-dark-tertiary\">\n                        {usersData.map((userItem) => (\n                          <div key={userItem.id} className=\"py-3 flex items-center justify-between\">\n                            <div className=\"flex-1 min-w-0\">\n                              <p className=\"text-white font-medium truncate\">{userItem.username}</p>\n                              <div className=\"flex flex-wrap gap-1 mt-1\">\n                                {userItem.auth_source === 'ldap' && (\n                                  <span className=\"px-2 py-0.5 rounded-full text-xs font-medium bg-cyan-500/20 text-cyan-300\">\n                                    LDAP\n                                  </span>\n                                )}\n                                {userItem.is_admin && (\n                                  <span className=\"px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300\">\n                                    {t('settings.admin')}\n                                  </span>\n                                )}\n                                {userItem.groups?.map(group => (\n                                  <span\n                                    key={group.id}\n                                    className={`px-2 py-0.5 rounded-full text-xs font-medium ${\n                                      group.name === 'Administrators'\n                                        ? 'bg-purple-500/20 text-purple-300'\n                                        : group.name === 'Operators'\n                                        ? 'bg-blue-500/20 text-blue-300'\n                                        : group.name === 'Viewers'\n                                        ? 'bg-green-500/20 text-green-300'\n                                        : 'bg-gray-500/20 text-gray-300'\n                                    }`}\n                                  >\n                                    {group.name}\n                                  </span>\n                                ))}\n                              </div>\n                            </div>\n                            <div className=\"flex items-center gap-1 ml-4\">\n                              {hasPermission('users:update') && (\n                                <Button size=\"sm\" variant=\"ghost\" onClick={() => startEditUser(userItem)}>\n                                  <Edit2 className=\"w-4 h-4\" />\n                                </Button>\n                              )}\n                              {hasPermission('users:delete') && userItem.id !== user?.id && (\n                                <Button size=\"sm\" variant=\"ghost\" onClick={() => handleDeleteUserClick(userItem.id)}>\n                                  <Trash2 className=\"w-4 h-4\" />\n                                </Button>\n                              )}\n                            </div>\n                          </div>\n                        ))}\n                      </div>\n                    )}\n                  </CardContent>\n                </Card>\n              </div>\n\n              {/* Right Column: Groups */}\n              <div>\n                <Card>\n                  <CardHeader>\n                    <div className=\"flex items-center justify-between\">\n                      <h3 className=\"text-lg font-semibold text-white flex items-center gap-2\" id=\"card-groups\">\n                        <Shield className=\"w-5 h-5 text-bambu-green\" />\n                        {t('settings.groups')}\n                      </h3>\n                      {hasPermission('groups:create') && (\n                        <Button\n                          size=\"sm\"\n                          onClick={() => navigate('/groups/new')}\n                        >\n                          <Plus className=\"w-4 h-4\" />\n                          {t('settings.addGroup')}\n                        </Button>\n                      )}\n                    </div>\n                  </CardHeader>\n                  <CardContent>\n                    {groupsLoading ? (\n                      <div className=\"flex items-center justify-center py-8\">\n                        <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n                      </div>\n                    ) : groupsData.length === 0 ? (\n                      <p className=\"text-center text-bambu-gray py-8\">{t('settings.noGroupsFound')}</p>\n                    ) : (\n                      <div className=\"divide-y divide-bambu-dark-tertiary\">\n                        {groupsData.map((group) => (\n                          <div key={group.id} className=\"py-3\">\n                            <div className=\"flex items-center justify-between\">\n                              <div className=\"flex items-center gap-2\">\n                                <Shield\n                                  className={`w-4 h-4 ${\n                                    group.name === 'Administrators'\n                                      ? 'text-purple-400'\n                                      : group.name === 'Operators'\n                                      ? 'text-blue-400'\n                                      : group.name === 'Viewers'\n                                      ? 'text-green-400'\n                                      : 'text-bambu-gray'\n                                  }`}\n                                />\n                                <span className=\"text-white font-medium\">{group.name}</span>\n                                {group.is_system && (\n                                  <span className=\"px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400\">\n                                    {t('settings.system')}\n                                  </span>\n                                )}\n                              </div>\n                              <div className=\"flex items-center gap-1\">\n                                {hasPermission('groups:update') && (\n                                  <Button size=\"sm\" variant=\"ghost\" onClick={() => navigate(`/groups/${group.id}/edit`)}>\n                                    <Edit2 className=\"w-4 h-4\" />\n                                  </Button>\n                                )}\n                                {hasPermission('groups:delete') && !group.is_system && (\n                                  <Button size=\"sm\" variant=\"ghost\" onClick={() => setDeleteGroupId(group.id)}>\n                                    <Trash2 className=\"w-4 h-4\" />\n                                  </Button>\n                                )}\n                              </div>\n                            </div>\n                            <p className=\"text-sm text-bambu-gray mt-1 ml-6\">\n                              {group.description || t('settings.noDescription')}\n                            </p>\n                            <div className=\"flex items-center gap-4 mt-2 ml-6 text-xs text-bambu-gray\">\n                              <span>{t('settings.userCount', { count: group.user_count })}</span>\n                              <span>{t('settings.permissionCount', { count: group.permissions.length })}</span>\n                            </div>\n                          </div>\n                        ))}\n                      </div>\n                    )}\n                  </CardContent>\n                </Card>\n              </div>\n            </div>\n          )}\n\n          {/* Auth Disabled Info */}\n          {!authEnabled && (\n            <Card>\n              <CardContent className=\"py-6\">\n                <div className=\"text-center\">\n                  <Unlock className=\"w-12 h-12 text-bambu-gray mx-auto mb-4\" />\n                  <h3 className=\"text-lg font-medium text-white mb-2\">{t('settings.authDisabledTitle')}</h3>\n                  <p className=\"text-sm text-bambu-gray mb-4 max-w-md mx-auto\">\n                    {t('settings.authDisabledMessage')}\n                  </p>\n                  <ul className=\"space-y-2 text-sm text-bambu-gray mb-6 text-left max-w-xs mx-auto\">\n                    <li className=\"flex items-start gap-2\">\n                      <CheckCircle className=\"w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0\" />\n                      <span>{t('settings.authDisabledFeature1')}</span>\n                    </li>\n                    <li className=\"flex items-start gap-2\">\n                      <CheckCircle className=\"w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0\" />\n                      <span>{t('settings.authDisabledFeature2')}</span>\n                    </li>\n                    <li className=\"flex items-start gap-2\">\n                      <CheckCircle className=\"w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0\" />\n                      <span>{t('settings.authDisabledFeature3')}</span>\n                    </li>\n                  </ul>\n                  <Button onClick={() => navigate('/setup')}>\n                    <Lock className=\"w-4 h-4\" />\n                    {t('settings.enableAuthentication')}\n                  </Button>\n                </div>\n              </CardContent>\n            </Card>\n          )}\n          </>\n          )}\n\n          {/* Email Auth Sub-tab */}\n          {usersSubTab === 'email' && (\n            <div className=\"max-w-5xl\" id=\"card-smtp\">\n              <EmailSettings />\n            </div>\n          )}\n\n          {usersSubTab === 'ldap' && (\n            <div className=\"max-w-5xl\" id=\"card-ldap\">\n              <LDAPSettings />\n            </div>\n          )}\n\n          {usersSubTab === 'twofa' && (\n            <div className=\"max-w-2xl\">\n              <TwoFactorSettings />\n            </div>\n          )}\n\n          {usersSubTab === 'oidc' && isAdmin && (\n            <div className=\"max-w-3xl\">\n              <OIDCProviderSettings />\n            </div>\n          )}\n        </div>\n      )}\n\n      {/* Create User Modal */}\n      {showCreateUserModal && !advancedAuthStatus?.advanced_auth_enabled && (\n        <div\n          className=\"fixed inset-0 bg-black flex items-center justify-center z-50 p-4\"\n          onClick={() => {\n            setShowCreateUserModal(false);\n            setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n          }}\n        >\n          <Card\n            className=\"w-full max-w-md\"\n            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n          >\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <Users className=\"w-5 h-5 text-bambu-green\" />\n                  <h2 className=\"text-lg font-semibold text-white\">{t('settings.createUser')}</h2>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => {\n                    setShowCreateUserModal(false);\n                    setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n                  }}\n                >\n                  <X className=\"w-5 h-5\" />\n                </Button>\n              </div>\n            </CardHeader>\n            <CardContent>\n              <div className=\"space-y-3\">\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">{t('settings.username')}</label>\n                  <input\n                    type=\"text\"\n                    value={userFormData.username}\n                    onChange={(e) => setUserFormData({ ...userFormData, username: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('settings.enterUsername')}\n                    autoComplete=\"username\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">{t('settings.password')}</label>\n                  <input\n                    type=\"password\"\n                    value={userFormData.password}\n                    onChange={(e) => setUserFormData({ ...userFormData, password: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('settings.enterPassword')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">{t('settings.confirmPassword')}</label>\n                  <input\n                    type=\"password\"\n                    value={userFormData.confirmPassword}\n                    onChange={(e) => setUserFormData({ ...userFormData, confirmPassword: e.target.value })}\n                    className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${\n                      userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword\n                        ? 'border-red-500'\n                        : 'border-bambu-dark-tertiary'\n                    }`}\n                    placeholder={t('settings.confirmPasswordPlaceholder')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                  {userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword && (\n                    <p className=\"text-red-400 text-xs mt-1\">{t('settings.passwordsDoNotMatch')}</p>\n                  )}\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">{t('settings.groups')}</label>\n                  <div className=\"space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\">\n                    {groupsData.map(group => (\n                      <label\n                        key={group.id}\n                        className=\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer\"\n                      >\n                        <input\n                          type=\"checkbox\"\n                          checked={userFormData.group_ids.includes(group.id)}\n                          onChange={() => toggleUserGroup(group.id)}\n                          className=\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark\"\n                        />\n                        <span className=\"text-sm text-white\">{group.name}</span>\n                        {group.is_system && (\n                          <span className=\"text-xs text-yellow-400\">{t('settings.systemBadge')}</span>\n                        )}\n                      </label>\n                    ))}\n                    {groupsData.length === 0 && (\n                      <p className=\"text-sm text-bambu-gray\">{t('settings.noGroupsAvailable')}</p>\n                    )}\n                  </div>\n                </div>\n              </div>\n              <div className=\"mt-6 flex justify-end gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => {\n                    setShowCreateUserModal(false);\n                    setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n                  }}\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  onClick={handleCreateUser}\n                  disabled={createUserMutation.isPending || !userFormData.username || !userFormData.password || userFormData.password !== userFormData.confirmPassword || userFormData.password.length < 6}\n                >\n                  {createUserMutation.isPending ? (\n                    <>\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      {t('settings.creating')}\n                    </>\n                  ) : (\n                    <>\n                      <Plus className=\"w-4 h-4\" />\n                      {t('settings.createUser')}\n                    </>\n                  )}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n\n      {/* Create User Modal - Advanced Authentication */}\n      {showCreateUserModal && advancedAuthStatus?.advanced_auth_enabled && (\n        <CreateUserAdvancedAuthModal\n          formData={userFormData}\n          setFormData={setUserFormData}\n          groups={groupsData}\n          onClose={() => {\n            setShowCreateUserModal(false);\n            setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n          }}\n          onCreate={handleCreateUser}\n          isCreating={createUserMutation.isPending}\n          isCreateButtonDisabled={createUserMutation.isPending || !userFormData.username || !userFormData.email}\n        />\n      )}\n\n      {/* Edit User Modal */}\n      {showEditUserModal && editingUserId !== null && (\n        <div\n          className=\"fixed inset-0 bg-black flex items-center justify-center z-50 p-4\"\n          onClick={() => {\n            setShowEditUserModal(false);\n            setEditingUserId(null);\n            setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n          }}\n        >\n          <Card\n            className=\"w-full max-w-md\"\n            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n          >\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <Edit2 className=\"w-5 h-5 text-bambu-green\" />\n                  <h2 className=\"text-lg font-semibold text-white\">{t('settings.editUser')}</h2>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => {\n                    setShowEditUserModal(false);\n                    setEditingUserId(null);\n                    setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n                  }}\n                >\n                  <X className=\"w-5 h-5\" />\n                </Button>\n              </div>\n            </CardHeader>\n            <CardContent>\n              <div className=\"space-y-3\">\n                {/* Username Field */}\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('settings.username')} {advancedAuthStatus?.advanced_auth_enabled && <span className=\"text-red-400\">*</span>}\n                  </label>\n                  <input\n                    type=\"text\"\n                    value={userFormData.username}\n                    onChange={(e) => setUserFormData({ ...userFormData, username: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('settings.enterUsername')}\n                    autoComplete=\"username\"\n                  />\n                </div>\n\n                {/* Email Field */}\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.email') || 'Email'} {advancedAuthStatus?.advanced_auth_enabled ? <span className=\"text-red-400\">*</span> : <span className=\"text-bambu-gray font-normal\">({t('users.form.optional') || 'optional'})</span>}\n                  </label>\n                  <input\n                    type=\"email\"\n                    value={userFormData.email}\n                    onChange={(e) => setUserFormData({ ...userFormData, email: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}\n                    required={advancedAuthStatus?.advanced_auth_enabled}\n                  />\n                </div>\n\n                {/* Password Fields - only show when Advanced Auth is disabled */}\n                {!advancedAuthStatus?.advanced_auth_enabled && (\n                  <>\n                    <div>\n                      <label className=\"block text-sm font-medium text-white mb-2\">\n                        {t('users.form.password') || 'Password'} <span className=\"text-bambu-gray font-normal\">({t('users.form.leaveBlankToKeep') || 'leave blank to keep current'})</span>\n                      </label>\n                      <input\n                        type=\"password\"\n                        value={userFormData.password}\n                        onChange={(e) => setUserFormData({ ...userFormData, password: e.target.value, confirmPassword: '' })}\n                        className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                        placeholder={t('settings.enterNewPassword')}\n                        autoComplete=\"new-password\"\n                        minLength={6}\n                      />\n                    </div>\n                    {userFormData.password && (\n                      <div>\n                        <label className=\"block text-sm font-medium text-white mb-2\">{t('settings.confirmPassword')}</label>\n                        <input\n                          type=\"password\"\n                          value={userFormData.confirmPassword}\n                          onChange={(e) => setUserFormData({ ...userFormData, confirmPassword: e.target.value })}\n                          className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${\n                            userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword\n                              ? 'border-red-500'\n                              : 'border-bambu-dark-tertiary'\n                          }`}\n                          placeholder={t('settings.confirmNewPassword')}\n                          autoComplete=\"new-password\"\n                          minLength={6}\n                        />\n                        {userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword && (\n                          <p className=\"text-red-400 text-xs mt-1\">{t('settings.passwordsDoNotMatch')}</p>\n                        )}\n                      </div>\n                    )}\n                  </>\n                )}\n\n                {/* Info box about auto-generated password when Advanced Auth is enabled */}\n                {advancedAuthStatus?.advanced_auth_enabled && (\n                  <div className=\"bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3 space-y-3\">\n                    <p className=\"text-sm text-bambu-gray\">\n                      {t('users.form.passwordManagedByAdvancedAuth') || 'Password is managed by Advanced Authentication. Use \"Reset Password\" to send a new password to the user via email.'}\n                    </p>\n                    <Button\n                      variant=\"secondary\"\n                      size=\"sm\"\n                      onClick={() => editingUserId && resetPasswordMutation.mutate(editingUserId)}\n                      disabled={resetPasswordMutation.isPending || !userFormData.email}\n                      className=\"w-full\"\n                    >\n                      {resetPasswordMutation.isPending ? (\n                        <>\n                          <Loader2 className=\"w-4 h-4 animate-spin\" />\n                          {t('users.form.resettingPassword') || 'Resetting Password...'}\n                        </>\n                      ) : (\n                        <>\n                          <RotateCcw className=\"w-4 h-4\" />\n                          {t('users.form.resetPassword') || 'Reset Password'}\n                        </>\n                      )}\n                    </Button>\n                  </div>\n                )}\n\n                {/* Groups Field */}\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">{t('users.form.groups') || 'Groups'}</label>\n                  <div className=\"space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\">\n                    {groupsData.map(group => (\n                      <label\n                        key={group.id}\n                        className=\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer\"\n                      >\n                        <input\n                          type=\"checkbox\"\n                          checked={userFormData.group_ids.includes(group.id)}\n                          onChange={() => toggleUserGroup(group.id)}\n                          className=\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark\"\n                        />\n                        <span className=\"text-sm text-white\">{group.name}</span>\n                        {group.is_system && (\n                          <span className=\"text-xs text-yellow-400\">({t('users.system') || 'System'})</span>\n                        )}\n                      </label>\n                    ))}\n                  </div>\n                </div>\n              </div>\n              <div className=\"mt-6 flex justify-end gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => {\n                    setShowEditUserModal(false);\n                    setEditingUserId(null);\n                    setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n                  }}\n                >\n                  {t('users.modal.cancel') || 'Cancel'}\n                </Button>\n                <Button\n                  onClick={() => handleUpdateUser(editingUserId)}\n                  disabled={\n                    updateUserMutation.isPending ||\n                    !userFormData.username ||\n                    (advancedAuthStatus?.advanced_auth_enabled && !userFormData.email) ||\n                    Boolean(!advancedAuthStatus?.advanced_auth_enabled && userFormData.password && (userFormData.password !== userFormData.confirmPassword || userFormData.password.length < 6))\n                  }\n                >\n                  {updateUserMutation.isPending ? (\n                    <>\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      {t('users.modal.saving') || 'Saving...'}\n                    </>\n                  ) : (\n                    <>\n                      <Save className=\"w-4 h-4\" />\n                      {t('users.modal.saveChanges') || 'Save Changes'}\n                    </>\n                  )}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n\n      {/* Delete User Confirmation Modal */}\n      {deleteUserId !== null && (\n        <div\n          className=\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\"\n          onClick={() => {\n            setDeleteUserId(null);\n            setDeleteUserItemCounts(null);\n          }}\n        >\n          <Card\n            className=\"w-full max-w-md\"\n            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n          >\n            <CardHeader>\n              <div className=\"flex items-center gap-2 text-red-400\">\n                <Trash2 className=\"w-5 h-5\" />\n                <h3 className=\"text-lg font-semibold\">{t('settings.deleteUserTitle')}</h3>\n              </div>\n            </CardHeader>\n            <CardContent className=\"space-y-3\">\n              {deleteUserLoading ? (\n                <div className=\"flex items-center justify-center py-4\">\n                  <div className=\"animate-spin rounded-full h-6 w-6 border-2 border-bambu-green border-t-transparent\" />\n                </div>\n              ) : deleteUserItemCounts && (deleteUserItemCounts.archives + deleteUserItemCounts.queue_items + deleteUserItemCounts.library_files > 0) ? (\n                <>\n                  <p className=\"text-white\">{t('settings.userHasCreated')}</p>\n                  <ul className=\"list-disc list-inside text-bambu-gray space-y-1\">\n                    {deleteUserItemCounts.archives > 0 && (\n                      <li>{deleteUserItemCounts.archives} archive{deleteUserItemCounts.archives !== 1 ? 's' : ''}</li>\n                    )}\n                    {deleteUserItemCounts.queue_items > 0 && (\n                      <li>{deleteUserItemCounts.queue_items} queue item{deleteUserItemCounts.queue_items !== 1 ? 's' : ''}</li>\n                    )}\n                    {deleteUserItemCounts.library_files > 0 && (\n                      <li>{deleteUserItemCounts.library_files} library file{deleteUserItemCounts.library_files !== 1 ? 's' : ''}</li>\n                    )}\n                  </ul>\n                  <p className=\"text-bambu-gray text-sm\">{t('settings.userItemsQuestion')}</p>\n                  <div className=\"flex flex-col gap-2\">\n                    <Button\n                      variant=\"danger\"\n                      onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: true })}\n                      disabled={deleteUserMutation.isPending}\n                      className=\"justify-center\"\n                    >\n                      {t('settings.deleteUserAndItems')}\n                    </Button>\n                    <Button\n                      variant=\"secondary\"\n                      onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: false })}\n                      disabled={deleteUserMutation.isPending}\n                      className=\"justify-center\"\n                    >\n                      {t('settings.deleteUserKeepItems')}\n                    </Button>\n                    <Button\n                      variant=\"ghost\"\n                      onClick={() => {\n                        setDeleteUserId(null);\n                        setDeleteUserItemCounts(null);\n                      }}\n                      disabled={deleteUserMutation.isPending}\n                      className=\"justify-center\"\n                    >\n                      {t('common.cancel')}\n                    </Button>\n                  </div>\n                </>\n              ) : (\n                <>\n                  <p className=\"text-white\">{t('settings.deleteUserConfirm')}</p>\n                  <p className=\"text-bambu-gray text-sm\">{t('settings.actionCannotBeUndone')}</p>\n                  <div className=\"flex gap-2 justify-end\">\n                    <Button\n                      variant=\"ghost\"\n                      onClick={() => {\n                        setDeleteUserId(null);\n                        setDeleteUserItemCounts(null);\n                      }}\n                      disabled={deleteUserMutation.isPending}\n                    >\n                      {t('common.cancel')}\n                    </Button>\n                    <Button\n                      variant=\"danger\"\n                      onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: false })}\n                      disabled={deleteUserMutation.isPending}\n                    >\n                      {t('settings.deleteUserTitle')}\n                    </Button>\n                  </div>\n                </>\n              )}\n            </CardContent>\n          </Card>\n        </div>\n      )}\n\n      {/* Delete Group Confirmation Modal */}\n      {deleteGroupId !== null && (\n        <ConfirmModal\n          title={t('settings.deleteGroupTitle')}\n          message={t('settings.deleteGroupMessage')}\n          confirmText={t('settings.deleteGroup')}\n          variant=\"danger\"\n          onConfirm={() => {\n            deleteGroupMutation.mutate(deleteGroupId);\n            setDeleteGroupId(null);\n          }}\n          onCancel={() => setDeleteGroupId(null)}\n        />\n      )}\n\n      {/* Backup Tab */}\n      {activeTab === 'failure-detection' && (\n        <div id=\"card-failure-detection\">\n          <FailureDetectionSettings />\n        </div>\n      )}\n\n      {activeTab === 'backup' && (\n        <div id=\"card-backup\">\n          <GitHubBackupSettings />\n        </div>\n      )}\n\n      {/* Disable Authentication Confirmation Modal */}\n      {showDisableAuthConfirm && (\n        <ConfirmModal\n          title={t('settings.disableAuthenticationTitle')}\n          message={t('settings.disableAuthenticationMessage')}\n          confirmText={t('settings.disableAuthentication')}\n          variant=\"danger\"\n          onConfirm={async () => {\n            try {\n              await api.disableAuth();\n              showToast(t('settings.toast.authDisabled'), 'success');\n              await refreshAuth();\n              setShowDisableAuthConfirm(false);\n              // Refresh the page to ensure all protected routes are accessible\n              window.location.href = '/';\n            } catch (error: unknown) {\n              const message = error instanceof Error ? error.message : t('settings.toast.authDisableFailed');\n              showToast(message, 'error');\n            }\n          }}\n          onCancel={() => setShowDisableAuthConfirm(false)}\n        />\n      )}\n\n      {/* Change Password Modal */}\n      {showChangePasswordModal && (\n        <div\n          className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n          onClick={() => {\n            setShowChangePasswordModal(false);\n            setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });\n          }}\n        >\n          <Card\n            className=\"w-full max-w-md\"\n            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n          >\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <Key className=\"w-5 h-5 text-bambu-green\" />\n                  <h2 className=\"text-lg font-semibold text-white\">{t('settings.changePassword')}</h2>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => {\n                    setShowChangePasswordModal(false);\n                    setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });\n                  }}\n                >\n                  <X className=\"w-5 h-5\" />\n                </Button>\n              </div>\n            </CardHeader>\n            <CardContent>\n              <div className=\"space-y-3\">\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    Current Password\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={changePasswordData.currentPassword}\n                    onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('settings.enterCurrentPassword')}\n                    autoComplete=\"current-password\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    New Password\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={changePasswordData.newPassword}\n                    onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('settings.enterNewPasswordMin6')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    Confirm New Password\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={changePasswordData.confirmPassword}\n                    onChange={(e) => setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })}\n                    className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${\n                      changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword\n                        ? 'border-red-500'\n                        : 'border-bambu-dark-tertiary'\n                    }`}\n                    placeholder={t('settings.confirmNewPassword')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                  {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (\n                    <p className=\"text-red-400 text-xs mt-1\">{t('settings.passwordsDoNotMatch')}</p>\n                  )}\n                </div>\n              </div>\n              <div className=\"mt-6 flex justify-end gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => {\n                    setShowChangePasswordModal(false);\n                    setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });\n                  }}\n                >\n                  {t('common.cancel')}\n                </Button>\n                <Button\n                  onClick={async () => {\n                    if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {\n                      showToast(t('settings.toast.passwordsDoNotMatch'), 'error');\n                      return;\n                    }\n                    if (changePasswordData.newPassword.length < 6) {\n                      showToast(t('settings.toast.passwordTooShort'), 'error');\n                      return;\n                    }\n                    setChangePasswordLoading(true);\n                    try {\n                      await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);\n                      showToast(t('settings.toast.passwordChanged'), 'success');\n                      setShowChangePasswordModal(false);\n                      setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });\n                    } catch (error: unknown) {\n                      const message = error instanceof Error ? error.message : 'Failed to change password';\n                      showToast(message, 'error');\n                    } finally {\n                      setChangePasswordLoading(false);\n                    }\n                  }}\n                  disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}\n                >\n                  {changePasswordLoading ? (\n                    <>\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      {t('settings.changing')}\n                    </>\n                  ) : (\n                    <>\n                      <Key className=\"w-4 h-4\" />\n                      {t('settings.changePassword')}\n                    </>\n                  )}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n      </div>\n      </div>\n    </div>\n    </CardDensityProvider>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/SetupPage.tsx",
    "content": "import { useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useMutation } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { api } from '../api/client';\nimport { useToast } from '../contexts/ToastContext';\nimport { useTheme } from '../contexts/ThemeContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { Info } from 'lucide-react';\n\nexport function SetupPage() {\n  const navigate = useNavigate();\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const { mode } = useTheme();\n  const { refreshAuth } = useAuth();\n  const [authEnabled, setAuthEnabled] = useState(false);\n  const [adminUsername, setAdminUsername] = useState('');\n  const [adminPassword, setAdminPassword] = useState('');\n  const [confirmPassword, setConfirmPassword] = useState('');\n\n  const setupMutation = useMutation({\n    mutationFn: () =>\n      api.setupAuth({\n        auth_enabled: authEnabled,\n        admin_username: authEnabled ? adminUsername : undefined,\n        admin_password: authEnabled ? adminPassword : undefined,\n      }),\n    onSuccess: async (data) => {\n      // Refresh auth status after setup\n      await refreshAuth();\n\n      if (data.auth_enabled) {\n        if (data.admin_created) {\n          showToast(t('setup.toast.authEnabledAdminCreated'));\n          navigate('/login');\n        } else {\n          showToast(t('setup.toast.authEnabledExistingAdmins'));\n          navigate('/login');\n        }\n      } else {\n        showToast(t('setup.toast.setupCompleted'));\n        navigate('/');\n      }\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (authEnabled) {\n      // Only validate if credentials are provided\n      // If no credentials provided, backend will use existing admin users if they exist\n      if (adminUsername || adminPassword) {\n        if (!adminUsername || !adminPassword) {\n          showToast(t('setup.toast.enterBothCredentials'), 'error');\n          return;\n        }\n        if (adminPassword !== confirmPassword) {\n          showToast(t('setup.toast.passwordsDoNotMatch'), 'error');\n          return;\n        }\n        if (adminPassword.length < 6) {\n          showToast(t('setup.toast.passwordTooShort'), 'error');\n          return;\n        }\n      }\n    }\n\n    setupMutation.mutate();\n  };\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-bambu-dark p-4\">\n      <div className=\"max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg\">\n        <div className=\"text-center\">\n          <div className=\"flex items-center justify-center mb-6\">\n            <img\n              src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}\n              alt=\"Bambuddy\"\n              className=\"h-16\"\n            />\n          </div>\n          <h2 className=\"text-3xl font-bold text-white\">\n            {t('setup.title')}\n          </h2>\n          <p className=\"mt-2 text-sm text-bambu-gray\">\n            {t('setup.subtitle')}\n          </p>\n        </div>\n\n        <form className=\"mt-8 space-y-6\" onSubmit={handleSubmit}>\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center p-4 bg-bambu-dark-secondary/50 rounded-lg border border-bambu-dark-tertiary\">\n              <input\n                id=\"auth-enabled\"\n                type=\"checkbox\"\n                checked={authEnabled}\n                onChange={(e) => setAuthEnabled(e.target.checked)}\n                className=\"h-4 w-4 text-bambu-green focus:ring-bambu-green border-bambu-dark-tertiary rounded bg-bambu-dark-secondary\"\n              />\n              <label htmlFor=\"auth-enabled\" className=\"ml-3 block text-sm font-medium text-white\">\n                {t('setup.enableAuth')}\n              </label>\n            </div>\n\n            {authEnabled && (\n              <div className=\"space-y-4 mt-4\">\n                <div className=\"p-3 bg-bambu-dark-secondary/50 border border-bambu-dark-tertiary rounded-lg\">\n                  <div className=\"flex items-start gap-2\">\n                    <Info className=\"w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0\" />\n                    <div className=\"text-sm text-bambu-gray\">\n                      <p className=\"text-white font-medium mb-1\">{t('setup.adminAccount')}</p>\n                      <p>\n                        {t('setup.adminAccountDesc')}\n                      </p>\n                    </div>\n                  </div>\n                </div>\n\n                <div>\n                  <label htmlFor=\"admin-username\" className=\"block text-sm font-medium text-white mb-2\">\n                    {t('setup.adminUsername')} <span className=\"text-bambu-gray text-xs\">{t('setup.optionalIfAdminExists')}</span>\n                  </label>\n                  <input\n                    id=\"admin-username\"\n                    type=\"text\"\n                    value={adminUsername}\n                    onChange={(e) => setAdminUsername(e.target.value)}\n                    className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('setup.adminUsernamePlaceholder')}\n                    autoComplete=\"username\"\n                  />\n                </div>\n\n                <div>\n                  <label htmlFor=\"admin-password\" className=\"block text-sm font-medium text-white mb-2\">\n                    {t('setup.adminPassword')} <span className=\"text-bambu-gray text-xs\">{t('setup.optionalIfAdminExists')}</span>\n                  </label>\n                  <input\n                    id=\"admin-password\"\n                    type=\"password\"\n                    value={adminPassword}\n                    onChange={(e) => setAdminPassword(e.target.value)}\n                    className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('setup.adminPasswordPlaceholder')}\n                    minLength={6}\n                    autoComplete=\"new-password\"\n                  />\n                </div>\n\n                {adminPassword && (\n                  <div>\n                    <label htmlFor=\"confirm-password\" className=\"block text-sm font-medium text-white mb-2\">\n                      {t('setup.confirmPassword')}\n                    </label>\n                    <input\n                      id=\"confirm-password\"\n                      type=\"password\"\n                      value={confirmPassword}\n                      onChange={(e) => setConfirmPassword(e.target.value)}\n                      className=\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                      placeholder={t('setup.confirmPasswordPlaceholder')}\n                      minLength={6}\n                      autoComplete=\"new-password\"\n                    />\n                  </div>\n                )}\n              </div>\n            )}\n          </div>\n\n          <div>\n            <button\n              type=\"submit\"\n              disabled={setupMutation.isPending}\n              className=\"w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green\"\n            >\n              {setupMutation.isPending ? t('setup.settingUp') : t('setup.completeSetup')}\n            </button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/StatsPage.tsx",
    "content": "import { useQuery } from '@tanstack/react-query';\nimport { useState, useEffect, useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Package,\n  Clock,\n  CheckCircle,\n  XCircle,\n  DollarSign,\n  Target,\n  Zap,\n  AlertTriangle,\n  TrendingDown,\n  FileSpreadsheet,\n  FileText,\n  Loader2,\n  Eye,\n  RotateCcw,\n  Calculator,\n  Calendar,\n  ChevronDown,\n  Users,\n} from 'lucide-react';\nimport {\n  BarChart,\n  Bar,\n  XAxis,\n  YAxis,\n  CartesianGrid,\n  Tooltip,\n  ResponsiveContainer,\n} from 'recharts';\nimport { Button } from '../components/Button';\nimport { useToast } from '../contexts/ToastContext';\nimport { useAuth } from '../contexts/AuthContext';\nimport { api, type ArchiveSlim } from '../api/client';\nimport { PrintCalendar } from '../components/PrintCalendar';\nimport { FilamentTrends } from '../components/FilamentTrends';\nimport { Dashboard, type DashboardWidget } from '../components/Dashboard';\nimport { getCurrencySymbol } from '../utils/currency';\nimport { formatWeight } from '../utils/weight';\nimport { parseUTCDate, formatDuration } from '../utils/date';\nimport { MetricToggle, type Metric } from '../components/MetricToggle';\n\n// Timeframe types and helpers\ntype TimeframePreset = 'today' | 'this-week' | 'this-month' | 'last-7' | 'last-30' | 'last-90' | 'this-year' | 'all-time' | 'custom';\n\ninterface TimeframeState {\n  preset: TimeframePreset;\n  dateFrom: string | undefined; // YYYY-MM-DD\n  dateTo: string | undefined;   // YYYY-MM-DD\n}\n\nfunction computeDateRange(preset: TimeframePreset): { dateFrom?: string; dateTo?: string } {\n  const now = new Date();\n  const y = now.getUTCFullYear(), m = now.getUTCMonth(), d = now.getUTCDate();\n  const fmt = (dt: Date) => dt.toISOString().split('T')[0];\n  const todayStr = fmt(now);\n\n  switch (preset) {\n    case 'today':\n      return { dateFrom: todayStr, dateTo: todayStr };\n    case 'this-week': {\n      const day = now.getUTCDay();\n      const start = new Date(Date.UTC(y, m, d - (day === 0 ? 6 : day - 1)));\n      return { dateFrom: fmt(start), dateTo: todayStr };\n    }\n    case 'this-month':\n      return { dateFrom: fmt(new Date(Date.UTC(y, m, 1))), dateTo: todayStr };\n    case 'last-7':\n      return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 6))), dateTo: todayStr };\n    case 'last-30':\n      return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 29))), dateTo: todayStr };\n    case 'last-90':\n      return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 89))), dateTo: todayStr };\n    case 'this-year':\n      return { dateFrom: fmt(new Date(Date.UTC(y, 0, 1))), dateTo: todayStr };\n    case 'all-time':\n      return { dateFrom: undefined, dateTo: undefined };\n    case 'custom':\n      return {};\n  }\n}\n\nconst TIMEFRAME_PRESETS: TimeframePreset[] = [\n  'today', 'this-week', 'this-month',\n  'last-7', 'last-30', 'last-90',\n  'this-year', 'all-time',\n];\n\n// Constants\nconst DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];\n\nconst HOUR_LABELS = [\n  '12am', '1am', '2am', '3am', '4am', '5am',\n  '6am', '7am', '8am', '9am', '10am', '11am',\n  '12pm', '1pm', '2pm', '3pm', '4pm', '5pm',\n  '6pm', '7pm', '8pm', '9pm', '10pm', '11pm',\n];\n\nconst DURATION_BUCKETS = [\n  { key: '<30m', max: 1800 },\n  { key: '30m-1h', max: 3600 },\n  { key: '1-2h', max: 7200 },\n  { key: '2-4h', max: 14400 },\n  { key: '4-8h', max: 28800 },\n  { key: '8-12h', max: 43200 },\n  { key: '12-24h', max: 86400 },\n  { key: '24h+', max: Infinity },\n];\n\nconst RECHARTS_TOOLTIP_STYLE = {\n  backgroundColor: '#2d2d2d',\n  border: '1px solid #3d3d3d',\n  borderRadius: '8px',\n};\n\n// Widget Components\nfunction QuickStatsWidget({\n  stats,\n  currency,\n}: {\n  stats: {\n    total_prints: number;\n    successful_prints: number;\n    failed_prints: number;\n    total_print_time_hours: number;\n    total_filament_grams: number;\n    total_cost: number;\n    total_energy_kwh: number;\n    total_energy_cost: number;\n    energy_data_warming_up?: boolean;\n  } | undefined;\n  currency: string;\n}) {\n  const { t } = useTranslation();\n\n  const warmingUp = stats?.energy_data_warming_up === true;\n  const warmingUpTooltip = warmingUp ? t('stats.energyWarmingUpTooltip') : undefined;\n\n  const items = [\n    { icon: Package, color: 'text-bambu-green', label: t('stats.totalPrints'), value: `${stats?.total_prints || 0}` },\n    { icon: Clock, color: 'text-blue-400', label: t('stats.printTime'), value: `${stats?.total_print_time_hours?.toFixed(1) ?? '0'}h` },\n    { icon: Package, color: 'text-orange-400', label: t('stats.filamentUsed'), value: formatWeight(stats?.total_filament_grams || 0) },\n    { icon: DollarSign, color: 'text-green-400', label: t('stats.filamentCost'), value: `${currency} ${stats?.total_cost?.toFixed(2) ?? '0.00'}` },\n    {\n      icon: Zap,\n      color: 'text-yellow-400',\n      label: t('stats.energyUsed'),\n      value: `${stats?.total_energy_kwh?.toFixed(3) ?? '0.000'} kWh`,\n      warning: warmingUp,\n      tooltip: warmingUpTooltip,\n    },\n    {\n      icon: DollarSign,\n      color: 'text-yellow-500',\n      label: t('stats.energyCost'),\n      value: `${currency} ${stats?.total_energy_cost?.toFixed(2) ?? '0.00'}`,\n      warning: warmingUp,\n      tooltip: warmingUpTooltip,\n    },\n  ];\n\n  return (\n    <div className=\"grid grid-cols-2 sm:grid-cols-3 gap-4\">\n      {items.map((item) => (\n        <div key={item.label} className=\"flex items-start gap-3\" title={item.tooltip}>\n          <div className={`p-2 rounded-lg bg-bambu-dark ${item.color}`}>\n            <item.icon className=\"w-5 h-5\" />\n          </div>\n          <div>\n            <p className=\"text-xs text-bambu-gray flex items-center gap-1\">\n              {item.label}\n              {item.warning && <AlertTriangle className=\"w-3 h-3 text-yellow-400\" aria-label={item.tooltip} />}\n            </p>\n            <p className=\"text-xl font-bold text-white\">{item.value}</p>\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction SuccessRateWidget({\n  stats,\n  printerMap,\n  size = 1,\n}: {\n  stats: {\n    total_prints: number;\n    successful_prints: number;\n    failed_prints: number;\n    prints_by_printer: Record<string, number>;\n  } | undefined;\n  printerMap: Map<string, string>;\n  size?: 1 | 2 | 4;\n}) {\n  const { t } = useTranslation();\n  const completedAndFailed = (stats?.successful_prints || 0) + (stats?.failed_prints || 0);\n  const successRate = completedAndFailed\n    ? Math.round((stats!.successful_prints / completedAndFailed) * 100)\n    : 0;\n\n  // Scale gauge size based on widget size\n  const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;\n  const radius = gaugeSize / 2 - 8;\n  const circumference = radius * 2 * Math.PI;\n\n  return (\n    <div className=\"flex items-center gap-6\">\n      <div className=\"relative flex-shrink-0\" style={{ width: gaugeSize, height: gaugeSize }}>\n        <svg className=\"w-full h-full -rotate-90\">\n          <circle\n            cx={gaugeSize / 2}\n            cy={gaugeSize / 2}\n            r={radius}\n            fill=\"none\"\n            stroke=\"#3d3d3d\"\n            strokeWidth=\"10\"\n          />\n          <circle\n            cx={gaugeSize / 2}\n            cy={gaugeSize / 2}\n            r={radius}\n            fill=\"none\"\n            stroke=\"#00ae42\"\n            strokeWidth=\"10\"\n            strokeLinecap=\"round\"\n            strokeDasharray={`${(successRate / 100) * circumference} ${circumference}`}\n          />\n        </svg>\n        <div className=\"absolute inset-0 flex items-center justify-center\">\n          <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{successRate}%</span>\n        </div>\n      </div>\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"space-y-2\">\n          <div className=\"flex items-center gap-2\">\n            <CheckCircle className=\"w-4 h-4 text-status-ok flex-shrink-0\" />\n            <span className=\"text-sm text-bambu-gray\">{t('stats.successful')}</span>\n            <span className=\"text-sm text-white font-medium\">{stats?.successful_prints || 0}</span>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <XCircle className=\"w-4 h-4 text-status-error flex-shrink-0\" />\n            <span className=\"text-sm text-bambu-gray\">{t('stats.failed')}</span>\n            <span className=\"text-sm text-white font-medium\">{stats?.failed_prints || 0}</span>\n          </div>\n        </div>\n        {/* Show per-printer breakdown when expanded */}\n        {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (\n          <div className=\"mt-4 pt-4 border-t border-bambu-dark-tertiary\">\n            <p className=\"text-xs text-bambu-gray font-medium mb-2\">{t('stats.printsByPrinter')}</p>\n            <div className={`grid gap-x-6 gap-y-1 ${size === 4 ? 'grid-cols-3' : 'grid-cols-2'}`} style={{ width: 'fit-content' }}>\n              {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (\n                <div key={printerId} className=\"flex items-center gap-3 text-sm\">\n                  <span className=\"text-bambu-gray truncate max-w-[120px]\">\n                    {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}\n                  </span>\n                  <span className=\"text-white font-medium\">{count}</span>\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction TimeAccuracyWidget({\n  stats,\n  printerMap,\n  size = 1,\n}: {\n  stats: {\n    average_time_accuracy: number | null;\n    time_accuracy_by_printer: Record<string, number> | null;\n  } | undefined;\n  printerMap: Map<string, string>;\n  size?: 1 | 2 | 4;\n}) {\n  const { t } = useTranslation();\n  const accuracy = stats?.average_time_accuracy;\n\n  if (accuracy === null || accuracy === undefined) {\n    return (\n      <div className=\"flex items-center justify-center h-full\">\n        <p className=\"text-bambu-gray text-center py-4\">{t('stats.noTimeAccuracyData')}</p>\n      </div>\n    );\n  }\n\n  // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge)\n  const displayValue = Math.min(150, Math.max(50, accuracy));\n  const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100\n\n  // Color based on accuracy\n  const getColor = (acc: number) => {\n    if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5%\n    if (acc > 105) return '#3b82f6'; // Blue - faster than expected\n    return '#f97316'; // Orange - slower than expected\n  };\n\n  const color = getColor(accuracy);\n  const deviation = accuracy - 100;\n\n  // Scale gauge size based on widget size\n  const gaugeSize = size === 1 ? 112 : size === 2 ? 128 : 144;\n  const radius = gaugeSize / 2 - 8;\n  const circumference = radius * 2 * Math.PI;\n\n  // Show more printers when expanded\n  const maxPrinters = size === 1 ? 3 : size === 2 ? 6 : 999;\n  const printerEntries = stats?.time_accuracy_by_printer\n    ? Object.entries(stats.time_accuracy_by_printer).slice(0, maxPrinters)\n    : [];\n\n  return (\n    <div className=\"flex items-center gap-6\">\n      <div className=\"relative flex-shrink-0\" style={{ width: gaugeSize, height: gaugeSize }}>\n        <svg className=\"w-full h-full -rotate-90\">\n          <circle\n            cx={gaugeSize / 2}\n            cy={gaugeSize / 2}\n            r={radius}\n            fill=\"none\"\n            stroke=\"#3d3d3d\"\n            strokeWidth=\"10\"\n          />\n          <circle\n            cx={gaugeSize / 2}\n            cy={gaugeSize / 2}\n            r={radius}\n            fill=\"none\"\n            stroke={color}\n            strokeWidth=\"10\"\n            strokeLinecap=\"round\"\n            strokeDasharray={`${(normalizedForGauge / 100) * circumference} ${circumference}`}\n          />\n        </svg>\n        <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n          <span className={`font-bold text-white ${size >= 2 ? 'text-2xl' : 'text-xl'}`}>{accuracy.toFixed(0)}%</span>\n          <span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>\n            {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%\n          </span>\n        </div>\n      </div>\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2 text-xs text-bambu-gray\">\n          <Target className=\"w-3 h-3 flex-shrink-0\" />\n          <span>{t('stats.perfectEstimate')}</span>\n        </div>\n        {printerEntries.length > 0 && (\n          <div className={`mt-2 ${size === 4 ? 'grid grid-cols-3 gap-x-6 gap-y-1' : size === 2 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`} style={{ width: 'fit-content' }}>\n            {printerEntries.map(([printerId, acc]) => (\n              <div key={printerId} className=\"flex items-center gap-2 text-xs\">\n                <span className=\"text-bambu-gray truncate max-w-[100px]\">\n                  {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}\n                </span>\n                <span className={`font-medium ${\n                  acc >= 95 && acc <= 105 ? 'text-status-ok' :\n                  acc > 105 ? 'text-blue-400' : 'text-status-warning'\n                }`}>\n                  {acc.toFixed(0)}%\n                </span>\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction HourlyHeatmap({ printDates, dateFrom, dateTo }: { printDates: string[]; dateFrom: string; dateTo: string }) {\n  const { days, hourlyCounts, maxCount } = useMemo(() => {\n    const start = new Date(dateFrom + 'T00:00:00');\n    const end = new Date(dateTo + 'T00:00:00');\n    const days: { key: string; label: string }[] = [];\n    const fmtLocal = (d: Date) =>\n      `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;\n    const current = new Date(start);\n    while (current <= end) {\n      days.push({\n        key: fmtLocal(current),\n        label: current.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }),\n      });\n      current.setDate(current.getDate() + 1);\n    }\n\n    // Count prints per (day, hour)\n    const counts: Record<string, number> = {};\n    let max = 0;\n    printDates.forEach(d => {\n      const date = parseUTCDate(d);\n      if (!date) return;\n      const dayKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;\n      const k = `${dayKey}-${date.getHours()}`;\n      counts[k] = (counts[k] || 0) + 1;\n      if (counts[k] > max) max = counts[k];\n    });\n\n    return { days, hourlyCounts: counts, maxCount: Math.max(1, max) };\n  }, [printDates, dateFrom, dateTo]);\n\n  const getColor = (count: number) => {\n    if (count === 0) return 'bg-bambu-dark';\n    const intensity = count / maxCount;\n    if (intensity <= 0.25) return 'bg-bambu-green/30';\n    if (intensity <= 0.5) return 'bg-bambu-green/50';\n    if (intensity <= 0.75) return 'bg-bambu-green/75';\n    return 'bg-bambu-green';\n  };\n\n  const cellSize = 20;\n  const gap = 2;\n\n  const dayLabelWidth = 80;\n\n  return (\n    <div className=\"w-full overflow-x-auto\">\n      <div className=\"inline-flex flex-col\" style={{ gap }}>\n        {/* Hour labels row */}\n        <div className=\"flex\" style={{ gap, marginLeft: dayLabelWidth + 4 }}>\n          {HOUR_LABELS.map((label, i) => (\n            <div\n              key={i}\n              className=\"text-bambu-gray text-[10px] text-center\"\n              style={{ width: cellSize, visibility: i % 2 === 0 ? 'visible' : 'hidden' }}\n            >\n              {label}\n            </div>\n          ))}\n        </div>\n\n        {/* Day rows */}\n        {days.map(day => (\n          <div key={day.key} className=\"flex items-center\" style={{ gap }}>\n            <div\n              className=\"text-bambu-gray text-[10px] flex-shrink-0 truncate\"\n              style={{ width: dayLabelWidth }}\n            >\n              {day.label}\n            </div>\n            {Array.from({ length: 24 }, (_, hour) => {\n              const count = hourlyCounts[`${day.key}-${hour}`] || 0;\n              return (\n                <div\n                  key={hour}\n                  className={`rounded-sm ${getColor(count)}`}\n                  style={{ width: cellSize, height: cellSize }}\n                  title={`${day.label} ${HOUR_LABELS[hour]}: ${count} print${count !== 1 ? 's' : ''}`}\n                />\n              );\n            })}\n          </div>\n        ))}\n      </div>\n\n      {/* Legend */}\n      <div className=\"flex items-center gap-2 mt-3 text-bambu-gray text-xs\">\n        <span>Less</span>\n        <div className=\"flex\" style={{ gap }}>\n          <div className=\"rounded-sm bg-bambu-dark\" style={{ width: cellSize, height: cellSize }} />\n          <div className=\"rounded-sm bg-bambu-green/30\" style={{ width: cellSize, height: cellSize }} />\n          <div className=\"rounded-sm bg-bambu-green/50\" style={{ width: cellSize, height: cellSize }} />\n          <div className=\"rounded-sm bg-bambu-green/75\" style={{ width: cellSize, height: cellSize }} />\n          <div className=\"rounded-sm bg-bambu-green\" style={{ width: cellSize, height: cellSize }} />\n        </div>\n        <span>More</span>\n      </div>\n    </div>\n  );\n}\n\nfunction PrintActivityWidget({\n  printDates,\n  size = 2,\n  dateFrom,\n  dateTo,\n}: {\n  printDates: string[];\n  size?: 1 | 2 | 4;\n  dateFrom?: string;\n  dateTo?: string;\n}) {\n  const spanDays = useMemo(() => {\n    if (dateFrom && dateTo) {\n      return Math.max((new Date(dateTo).getTime() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;\n    }\n    if (dateFrom) {\n      return Math.max((Date.now() - new Date(dateFrom).getTime()) / 86400000, 0) + 1;\n    }\n    return Infinity;\n  }, [dateFrom, dateTo]);\n\n  if (spanDays <= 7 && dateFrom && dateTo) {\n    return <HourlyHeatmap printDates={printDates} dateFrom={dateFrom} dateTo={dateTo} />;\n  }\n\n  // Calculate months from the timeframe span, fall back to size-based default for all-time\n  const sizeDefault = size === 1 ? 3 : size === 2 ? 6 : 12;\n  const months = spanDays === Infinity\n    ? sizeDefault\n    : Math.max(1, Math.ceil(spanDays / 30));\n  return <PrintCalendar printDates={printDates} months={months} />;\n}\n\nfunction PrinterStatsWidget({\n  stats,\n  archives,\n  printerMap,\n}: {\n  stats: { prints_by_printer: Record<string, number> } | undefined;\n  archives: ArchiveSlim[];\n  printerMap: Map<string, string>;\n}) {\n  const { t } = useTranslation();\n  const [printerMetric, setPrinterMetric] = useState<Metric>('weight');\n  const [habitsMetric, setHabitsMetric] = useState<Metric>('weight');\n\n  // Per-printer data\n  const printerData = useMemo(() => {\n    const map = new Map<string, { prints: number; weight: number; time: number }>();\n    if (stats?.prints_by_printer) {\n      Object.entries(stats.prints_by_printer).forEach(([id, count]) => {\n        const entry = map.get(id) || { prints: 0, weight: 0, time: 0 };\n        entry.prints = count;\n        map.set(id, entry);\n      });\n    }\n    archives.forEach(a => {\n      if (!a.printer_id) return;\n      const id = String(a.printer_id);\n      const entry = map.get(id) || { prints: 0, weight: 0, time: 0 };\n      entry.weight += a.filament_used_grams || 0;\n      entry.time += a.actual_time_seconds || a.print_time_seconds || 0;\n      if (!stats?.prints_by_printer) entry.prints++;\n      map.set(id, entry);\n    });\n    return Array.from(map.entries())\n      .map(([id, v]) => ({\n        name: printerMap.get(id) || `${t('common.printer')} ${id}`,\n        value: printerMetric === 'prints' ? v.prints :\n               printerMetric === 'weight' ? Math.round(v.weight) :\n               Math.round((v.time / 3600) * 10) / 10,\n      }))\n      .sort((a, b) => b.value - a.value);\n  }, [stats, archives, printerMap, printerMetric, t]);\n\n  // Hourly distribution (time of day)\n  const hourlyData = useMemo(() => {\n    const hours = Array.from({ length: 24 }, (_, i) => ({\n      hour: i,\n      label: HOUR_LABELS[i],\n      total: 0,\n      failures: 0,\n    }));\n\n    archives.forEach(a => {\n      if (!a.started_at) return;\n      const date = parseUTCDate(a.started_at);\n      if (!date) return;\n      const h = date.getHours();\n      hours[h].total++;\n      if (a.status === 'failed') {\n        hours[h].failures++;\n      }\n    });\n\n    return hours;\n  }, [archives]);\n\n  // Duration distribution\n  const durationData = useMemo(() => {\n    const counts = DURATION_BUCKETS.map(b => ({ name: b.key, count: 0 }));\n    archives.forEach(a => {\n      const seconds = a.actual_time_seconds || a.print_time_seconds;\n      if (!seconds || seconds <= 0) return;\n      for (let i = 0; i < DURATION_BUCKETS.length; i++) {\n        if (seconds <= DURATION_BUCKETS[i].max) {\n          counts[i].count++;\n          break;\n        }\n      }\n    });\n    return counts;\n  }, [archives]);\n\n  // Habits (avg per day-of-week)\n  const habitsData = useMemo(() => {\n    const dayValues = [0, 0, 0, 0, 0, 0, 0];\n    const weeksSet = new Set<string>();\n    archives.forEach(a => {\n      const date = parseUTCDate(a.created_at) || new Date(a.created_at);\n      let day = date.getDay() - 1;\n      if (day < 0) day = 6;\n      if (habitsMetric === 'prints') dayValues[day]++;\n      else if (habitsMetric === 'weight') dayValues[day] += a.filament_used_grams || 0;\n      else dayValues[day] += (a.actual_time_seconds || a.print_time_seconds || 0) / 3600;\n      const weekStart = new Date(date);\n      weekStart.setDate(date.getDate() - ((date.getDay() + 6) % 7));\n      weeksSet.add(`${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`);\n    });\n    const numWeeks = Math.max(weeksSet.size, 1);\n    return DAY_LABELS.map((name, i) => ({\n      name,\n      avg: Math.round((dayValues[i] / numWeeks) * 10) / 10,\n    }));\n  }, [archives, habitsMetric]);\n\n  const metricStyle = (m: Metric) => ({\n    unit: m === 'weight' ? 'g' : m === 'time' ? 'h' : '',\n    color: m === 'weight' ? '#00ae42' : m === 'time' ? '#3b82f6' : '#f59e0b',\n  });\n  const ps = metricStyle(printerMetric);\n  const pLabel = printerMetric === 'weight' ? t('stats.filamentByWeight') : printerMetric === 'time' ? t('stats.hours') : t('common.prints');\n  const hs = metricStyle(habitsMetric);\n  const hLabel = habitsMetric === 'weight' ? t('stats.avgWeight') : habitsMetric === 'time' ? t('stats.avgTime') : t('stats.avgPrints');\n\n  return (\n    <div className=\"space-y-4\">\n      {/* By Printer */}\n      <div className=\"bg-bambu-dark rounded-lg p-4\">\n        <div className=\"flex items-center justify-between mb-3\">\n          <h4 className=\"text-sm font-medium text-bambu-gray\">{t('stats.printsByPrinter')}</h4>\n          <MetricToggle value={printerMetric} onChange={setPrinterMetric} />\n        </div>\n        {printerData.length > 0 ? (\n          <ResponsiveContainer width=\"100%\" height={Math.max(140, printerData.length * 40)}>\n            <BarChart data={printerData} layout=\"vertical\" margin={{ left: 10 }}>\n              <CartesianGrid strokeDasharray=\"3 3\" stroke=\"#3d3d3d\" />\n              <XAxis type=\"number\" stroke=\"#9ca3af\" tick={{ fontSize: 11 }} unit={ps.unit} />\n              <YAxis type=\"category\" dataKey=\"name\" stroke=\"#9ca3af\" tick={{ fontSize: 11 }} width={100} />\n              <Tooltip\n                contentStyle={RECHARTS_TOOLTIP_STYLE}\n                formatter={(v: number | undefined) => [\n                  printerMetric === 'weight' ? formatWeight(Number(v ?? 0)) : `${v ?? 0}${ps.unit}`,\n                  pLabel,\n                ]}\n              />\n              <Bar dataKey=\"value\" fill={ps.color} radius={[0, 4, 4, 0]} />\n            </BarChart>\n          </ResponsiveContainer>\n        ) : (\n          <p className=\"text-bambu-gray text-center py-4\">{t('stats.noPrinterData')}</p>\n        )}\n      </div>\n\n      <div className=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">\n        {/* Print Duration */}\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <h4 className=\"text-sm font-medium text-bambu-gray mb-3\">{t('stats.printDuration')}</h4>\n          {archives.length > 0 ? (\n            <ResponsiveContainer width=\"100%\" height={160}>\n              <BarChart data={durationData}>\n                <CartesianGrid strokeDasharray=\"3 3\" stroke=\"#3d3d3d\" />\n                <XAxis dataKey=\"name\" stroke=\"#9ca3af\" tick={{ fontSize: 11 }} />\n                <YAxis stroke=\"#9ca3af\" tick={{ fontSize: 11 }} allowDecimals={false} />\n                <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} />\n                <Bar dataKey=\"count\" name={t('common.prints')} fill=\"#00ae42\" radius={[4, 4, 0, 0]} />\n              </BarChart>\n            </ResponsiveContainer>\n          ) : (\n            <p className=\"text-bambu-gray text-center py-4\">{t('stats.noArchiveData')}</p>\n          )}\n        </div>\n\n        {/* Print Habits */}\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <div className=\"flex items-center justify-between mb-3\">\n            <h4 className=\"text-sm font-medium text-bambu-gray\">{t('stats.printHabits')}</h4>\n            <MetricToggle value={habitsMetric} onChange={setHabitsMetric} />\n          </div>\n          {archives.length > 0 ? (\n            <ResponsiveContainer width=\"100%\" height={160}>\n              <BarChart data={habitsData}>\n                <CartesianGrid strokeDasharray=\"3 3\" stroke=\"#3d3d3d\" />\n                <XAxis dataKey=\"name\" stroke=\"#9ca3af\" tick={{ fontSize: 11 }} />\n                <YAxis stroke=\"#9ca3af\" tick={{ fontSize: 11 }} unit={hs.unit} />\n                <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} formatter={(v: number | undefined) => [`${v ?? 0}${hs.unit}`, hLabel]} />\n                <Bar dataKey=\"avg\" fill={hs.color} radius={[4, 4, 0, 0]} />\n              </BarChart>\n            </ResponsiveContainer>\n          ) : (\n            <p className=\"text-bambu-gray text-center py-4\">{t('stats.noArchiveData')}</p>\n          )}\n        </div>\n\n        {/* Print Time of Day */}\n        <div className=\"bg-bambu-dark rounded-lg p-4\">\n          <h4 className=\"text-sm font-medium text-bambu-gray mb-3\">{t('stats.printTimeOfDay')}</h4>\n          {archives.length > 0 ? (\n            <ResponsiveContainer width=\"100%\" height={160}>\n              <BarChart data={hourlyData}>\n                <CartesianGrid strokeDasharray=\"3 3\" stroke=\"#3d3d3d\" />\n                <XAxis dataKey=\"label\" stroke=\"#9ca3af\" tick={{ fontSize: 10 }} interval={5} />\n                <YAxis stroke=\"#9ca3af\" tick={{ fontSize: 11 }} allowDecimals={false} />\n                <Tooltip contentStyle={RECHARTS_TOOLTIP_STYLE} />\n                <Bar dataKey=\"total\" name={t('stats.totalPrints')} fill=\"#00ae42\" radius={[2, 2, 0, 0]} />\n                <Bar dataKey=\"failures\" name={t('stats.failed')} fill=\"#ef4444\" radius={[2, 2, 0, 0]} />\n              </BarChart>\n            </ResponsiveContainer>\n          ) : (\n            <p className=\"text-bambu-gray text-center py-4\">{t('stats.noArchiveData')}</p>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction FilamentTrendsWidget({\n  archives,\n  currency,\n  dateFrom,\n  dateTo,\n}: {\n  archives: Parameters<typeof FilamentTrends>[0]['archives'];\n  currency: string;\n  dateFrom?: string;\n  dateTo?: string;\n}) {\n  const { t } = useTranslation();\n  if (!archives || archives.length === 0) {\n    return <p className=\"text-bambu-gray text-center py-4\">{t('stats.noPrintData')}</p>;\n  }\n  return <FilamentTrends archives={archives} currency={currency} dateFrom={dateFrom} dateTo={dateTo} />;\n}\n\nfunction FailureAnalysisWidget({ size = 1, dateFrom, dateTo, createdById }: {\n  size?: 1 | 2 | 4;\n  dateFrom?: string;\n  dateTo?: string;\n  createdById?: number;\n}) {\n  const { t } = useTranslation();\n  const hasDateRange = !!(dateFrom || dateTo);\n  const { data: analysis, isLoading } = useQuery({\n    queryKey: ['failureAnalysis', dateFrom, dateTo, createdById ?? 'all'],\n    queryFn: () => api.getFailureAnalysis({\n      ...(hasDateRange ? { dateFrom, dateTo } : { days: 30 }),\n      createdById,\n    }),\n  });\n\n  if (isLoading) {\n    return (\n      <div className=\"flex justify-center py-4\">\n        <Loader2 className=\"w-6 h-6 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  if (!analysis || analysis.total_prints === 0) {\n    return <p className=\"text-bambu-gray text-center py-4\">{hasDateRange ? t('stats.noPrintDataInRange') : t('stats.noPrintDataLast30Days')}</p>;\n  }\n\n  // Show more reasons when expanded\n  const maxReasons = size === 1 ? 5 : size === 2 ? 8 : 999;\n  const allReasons = Object.entries(analysis.failures_by_reason).sort(([, a], [, b]) => b - a);\n  const topReasons = allReasons.slice(0, maxReasons);\n  const hasMore = allReasons.length > maxReasons;\n\n  return (\n    <div className={`${size >= 2 ? 'flex gap-8' : 'space-y-4'}`}>\n      {/* Summary */}\n      <div className={size >= 2 ? 'flex-shrink-0' : ''}>\n        <div className=\"flex items-center gap-4\">\n          <div className=\"flex items-center gap-2\">\n            <AlertTriangle className={`w-5 h-5 ${analysis.failure_rate > 20 ? 'text-status-error' : analysis.failure_rate > 10 ? 'text-status-warning' : 'text-status-ok'}`} />\n            <span className={`font-bold text-white ${size >= 2 ? 'text-3xl' : 'text-2xl'}`}>{analysis.failure_rate.toFixed(1)}%</span>\n          </div>\n        </div>\n        <div className=\"text-sm text-bambu-gray mt-1\">\n          {t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}\n        </div>\n        {/* Trend indicator */}\n        {analysis.trend && analysis.trend.length >= 2 && (\n          <div className={`${size >= 2 ? 'mt-4' : 'mt-2 pt-2 border-t border-bambu-dark-tertiary'}`}>\n            <div className=\"flex items-center gap-2 text-sm\">\n              <TrendingDown className={`w-4 h-4 ${\n                analysis.trend[analysis.trend.length - 1].failure_rate < analysis.trend[analysis.trend.length - 2].failure_rate\n                  ? 'text-status-ok'\n                  : 'text-status-error'\n              }`} />\n              <span className=\"text-bambu-gray\">\n                {t('stats.lastWeekRate', { rate: analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1) })}\n              </span>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Failure Reasons */}\n      {topReasons.length > 0 && (\n        <div className={`flex-1 ${size >= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>\n          <p className=\"text-xs text-bambu-gray font-medium mb-2\">\n            {size >= 2 ? t('stats.failureReasons') : t('stats.topFailureReasons')}\n          </p>\n          <div className={`${size === 4 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`}>\n            {topReasons.map(([reason, count]) => (\n              <div key={reason} className=\"flex items-center justify-between text-sm\">\n                <span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>\n                  {reason || t('common.unknown')}\n                </span>\n                <span className=\"text-bambu-gray ml-2\">{count}</span>\n              </div>\n            ))}\n          </div>\n          {hasMore && (\n            <p className=\"text-xs text-bambu-gray mt-2\">\n              {t('common.more', { count: allReasons.length - maxReasons })}\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; currency: string }) {\n  const { t } = useTranslation();\n\n  const records = useMemo(() => {\n    const result: Array<{\n      icon: typeof Clock;\n      iconColor: string;\n      label: string;\n      value: string;\n      detail: string | null;\n    }> = [];\n\n    if (archives.length === 0) return result;\n\n    // Find the archive with the highest value for a given field\n    const findMax = (getter: (a: ArchiveSlim) => number | null | undefined): { archive: ArchiveSlim | null; value: number } => {\n      let best: ArchiveSlim | null = null;\n      let bestVal = 0;\n      archives.forEach(a => {\n        const v = getter(a);\n        if (v && v > bestVal) { bestVal = v; best = a; }\n      });\n      return { archive: best, value: bestVal };\n    };\n\n    const longest = findMax(a => a.actual_time_seconds);\n    if (longest.archive) {\n      result.push({\n        icon: Clock, iconColor: 'text-blue-400', label: t('stats.longestPrint'),\n        value: formatDuration(longest.value),\n        detail: longest.archive.print_name || null,\n      });\n    }\n\n    const heaviest = findMax(a => a.filament_used_grams);\n    if (heaviest.archive) {\n      result.push({\n        icon: Package, iconColor: 'text-orange-400', label: t('stats.heaviestPrint'),\n        value: formatWeight(heaviest.value),\n        detail: heaviest.archive.print_name || null,\n      });\n    }\n\n    const costliest = findMax(a => a.cost);\n    if (costliest.archive) {\n      result.push({\n        icon: DollarSign, iconColor: 'text-green-400', label: t('stats.mostExpensivePrint'),\n        value: `${currency}${costliest.value.toFixed(2)}`,\n        detail: costliest.archive.print_name || null,\n      });\n    }\n\n    // Busiest day\n    const dayCounts = new Map<string, number>();\n    archives.forEach(a => {\n      const date = parseUTCDate(a.created_at) || new Date(a.created_at);\n      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;\n      dayCounts.set(key, (dayCounts.get(key) || 0) + 1);\n    });\n    let busiestDay = '';\n    let busiestCount = 0;\n    dayCounts.forEach((count, day) => {\n      if (count > busiestCount) {\n        busiestCount = count;\n        busiestDay = day;\n      }\n    });\n    if (busiestCount > 1) {\n      result.push({\n        icon: Calendar,\n        iconColor: 'text-purple-400',\n        label: t('stats.busiestDay'),\n        value: `${busiestCount} ${t('common.prints')}`,\n        detail: (() => { const [y, m, d] = busiestDay.split('-').map(Number); return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); })(),\n      });\n    }\n\n    // Success streak\n    const sorted = [...archives]\n      .filter(a => a.status === 'completed' || a.status === 'failed')\n      .sort((a, b) => new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime());\n    let streak = 0;\n    for (const a of sorted) {\n      if (a.status === 'completed') streak++;\n      else break;\n    }\n    if (streak > 0) {\n      result.push({\n        icon: Zap,\n        iconColor: 'text-yellow-400',\n        label: t('stats.successStreak'),\n        value: `${streak}`,\n        detail: streak === 1 ? t('stats.streakPrint') : t('stats.streakPrints', { count: streak }),\n      });\n    }\n\n    return result;\n  }, [archives, currency, t]);\n\n  if (records.length === 0) {\n    return <p className=\"text-bambu-gray text-center py-4\">{t('stats.noArchiveData')}</p>;\n  }\n\n  return (\n    <div className=\"space-y-3\">\n      {records.map((record, i) => (\n        <div key={i} className=\"flex items-center gap-3\">\n          <div className={`p-1.5 rounded-lg bg-bambu-dark ${record.iconColor}`}>\n            <record.icon className=\"w-4 h-4\" />\n          </div>\n          <div className=\"flex-1 min-w-0\">\n            <p className=\"text-xs text-bambu-gray\">{record.label}</p>\n            <div className=\"flex items-baseline gap-2\">\n              <span className=\"text-sm font-bold text-white\">{record.value}</span>\n              {record.detail && (\n                <span className=\"text-xs text-bambu-gray truncate\">{record.detail}</span>\n              )}\n            </div>\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n\nexport function StatsPage() {\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n  const { hasPermission, authEnabled } = useAuth();\n  const [isExporting, setIsExporting] = useState(false);\n  const [showExportMenu, setShowExportMenu] = useState(false);\n  const [dashboardKey, setDashboardKey] = useState(0);\n  const [hiddenCount, setHiddenCount] = useState(0);\n  const [isRecalculating, setIsRecalculating] = useState(false);\n  const [selectedUserId, setSelectedUserId] = useState<number | null>(null);\n  const [showUserPicker, setShowUserPicker] = useState(false);\n  const canFilterByUser = authEnabled && hasPermission('stats:filter_by_user');\n  const [timeframe, setTimeframe] = useState<TimeframeState>(() => {\n    try {\n      const saved = localStorage.getItem('bambusy-stats-timeframe');\n      if (saved) {\n        const parsed = JSON.parse(saved);\n        if (parsed.preset) return parsed;\n      }\n    } catch { /* ignore */ }\n    return { preset: 'all-time', dateFrom: undefined, dateTo: undefined };\n  });\n  const [showTimeframePicker, setShowTimeframePicker] = useState(false);\n\n  // Persist timeframe selection\n  useEffect(() => {\n    localStorage.setItem('bambusy-stats-timeframe', JSON.stringify(timeframe));\n  }, [timeframe]);\n\n  const effectiveDateRange = useMemo(() => {\n    if (timeframe.preset === 'custom') {\n      return { dateFrom: timeframe.dateFrom, dateTo: timeframe.dateTo };\n    }\n    return computeDateRange(timeframe.preset);\n  }, [timeframe]);\n\n  // Read hidden count from localStorage\n  useEffect(() => {\n    const updateHiddenCount = () => {\n      try {\n        const saved = localStorage.getItem('bambusy-dashboard-layout-v2');\n        if (saved) {\n          const layout = JSON.parse(saved);\n          setHiddenCount(layout.hidden?.length || 0);\n        }\n      } catch {\n        setHiddenCount(0);\n      }\n    };\n    updateHiddenCount();\n    // Listen for storage changes\n    window.addEventListener('storage', updateHiddenCount);\n    // Also poll for changes (since storage event doesn't fire for same-tab changes)\n    const interval = setInterval(updateHiddenCount, 2000);\n    return () => {\n      window.removeEventListener('storage', updateHiddenCount);\n      clearInterval(interval);\n    };\n  }, [dashboardKey]);\n\n  // Only pass createdById when a user is actually selected (not \"All Users\")\n  const createdByIdParam = selectedUserId !== null ? selectedUserId : undefined;\n\n  const { data: stats, isLoading, isFetching: isStatsFetching, refetch: refetchStats } = useQuery({\n    queryKey: ['archiveStats', effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam ?? 'all'],\n    queryFn: () => api.getArchiveStats({\n      dateFrom: effectiveDateRange.dateFrom,\n      dateTo: effectiveDateRange.dateTo,\n      createdById: createdByIdParam,\n    }),\n  });\n\n  const { data: printers } = useQuery({\n    queryKey: ['printers'],\n    queryFn: api.getPrinters,\n  });\n\n  const { data: archives, isFetching: isArchivesFetching, refetch: refetchArchives } = useQuery({\n    queryKey: ['archivesSlim', effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam ?? 'all'],\n    queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam),\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const { data: users } = useQuery({\n    queryKey: ['users'],\n    queryFn: api.getUsers,\n    enabled: canFilterByUser,\n  });\n\n  const selectedUserLabel = useMemo(() => {\n    if (selectedUserId === null) return t('stats.allUsers', 'All Users');\n    if (selectedUserId === -1) return t('stats.noUser', 'No User (System)');\n    return users?.find(u => u.id === selectedUserId)?.username ?? '?';\n  }, [selectedUserId, users, t]);\n\n  const handleExport = async (format: 'csv' | 'xlsx') => {\n    setShowExportMenu(false);\n    setIsExporting(true);\n    try {\n      const { blob, filename } = await api.exportStats({ format, days: 90, createdById: createdByIdParam });\n      const url = URL.createObjectURL(blob);\n      const a = document.createElement('a');\n      a.href = url;\n      a.download = filename;\n      a.click();\n      URL.revokeObjectURL(url);\n      showToast(t('stats.exportDownloaded'));\n    } catch {\n      showToast(t('stats.exportFailed'), 'error');\n    } finally {\n      setIsExporting(false);\n    }\n  };\n\n  const handleRecalculateCosts = async () => {\n    setIsRecalculating(true);\n    try {\n      const result = await api.recalculateCosts();\n      await Promise.all([refetchStats(), refetchArchives()]);\n      showToast(t('stats.recalculatedCosts', { count: result.updated }));\n    } catch {\n      showToast(t('stats.recalculateFailed'), 'error');\n    } finally {\n      setIsRecalculating(false);\n    }\n  };\n\n  const isRefetching = (isStatsFetching || isArchivesFetching) && !isLoading;\n\n  const currency = getCurrencySymbol(settings?.currency || 'USD');\n  const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);\n  const printDates = useMemo(() => archives?.map((a) => a.created_at) || [], [archives]);\n\n  if (isLoading) {\n    return (\n      <div className=\"p-4 md:p-8\">\n        <div className=\"text-center py-12 text-bambu-gray\">{t('stats.loadingStats')}</div>\n      </div>\n    );\n  }\n\n  // Define dashboard widgets\n  // Sizes: 1 = quarter (1/4), 2 = half (1/2), 4 = full width\n  // Widgets can use render functions to receive the current size for responsive content\n  const widgets: DashboardWidget[] = [\n    {\n      id: 'quick-stats',\n      title: t('stats.quickStats'),\n      component: <QuickStatsWidget stats={stats} currency={currency} />,\n      defaultSize: 2,\n    },\n    {\n      id: 'success-rate',\n      title: t('stats.successRate'),\n      component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,\n      defaultSize: 1,\n    },\n    {\n      id: 'time-accuracy',\n      title: t('stats.timeAccuracy'),\n      component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,\n      defaultSize: 1,\n    },\n    {\n      id: 'failure-analysis',\n      title: t('stats.failureAnalysis'),\n      component: (size) => <FailureAnalysisWidget size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} createdById={createdByIdParam} />,\n      defaultSize: 1,\n    },\n    {\n      id: 'print-activity',\n      title: t('stats.printActivity'),\n      component: (size) => <PrintActivityWidget printDates={printDates} size={size} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,\n      defaultSize: 2,\n    },\n    {\n      id: 'records',\n      title: t('stats.records'),\n      component: <RecordsWidget archives={archives || []} currency={currency} />,\n      defaultSize: 1,\n    },\n    {\n      id: 'printer-stats',\n      title: t('stats.printerStats'),\n      component: <PrinterStatsWidget stats={stats} archives={archives || []} printerMap={printerMap} />,\n      defaultSize: 4,\n    },\n    {\n      id: 'filament-trends',\n      title: t('stats.filamentTrends'),\n      component: <FilamentTrendsWidget archives={archives || []} currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,\n      defaultSize: 4,\n    },\n  ];\n\n  return (\n    <div className=\"p-4 md:p-8\">\n      <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6\">\n        <div>\n          <div className=\"flex items-center gap-2\">\n            <h1 className=\"text-2xl font-bold text-white\">{t('stats.title')}</h1>\n            {isRefetching && <Loader2 className=\"w-5 h-5 text-bambu-green animate-spin\" />}\n          </div>\n          <p className=\"text-bambu-gray\">{t('stats.subtitle')}</p>\n        </div>\n        <div className=\"flex items-center gap-2 flex-wrap\">\n          {/* Hidden widgets button - toggles panel in Dashboard */}\n          {hiddenCount > 0 && (\n            <Button\n              variant=\"secondary\"\n              onClick={() => {\n                // Toggle the hidden panel in Dashboard by triggering a custom event\n                window.dispatchEvent(new CustomEvent('toggle-hidden-panel'));\n              }}\n            >\n              <Eye className=\"w-4 h-4\" />\n              {t('stats.hiddenCount', { count: hiddenCount })}\n            </Button>\n          )}\n          {/* Reset Layout */}\n          <Button\n            variant=\"secondary\"\n            onClick={() => {\n              localStorage.removeItem('bambusy-dashboard-layout-v2');\n              setDashboardKey(prev => prev + 1);\n              showToast(t('stats.layoutReset'));\n            }}\n            disabled={!hasPermission('settings:update')}\n            title={!hasPermission('settings:update') ? t('stats.noPermissionResetLayout') : undefined}\n          >\n            <RotateCcw className=\"w-4 h-4\" />\n            {t('stats.resetLayout')}\n          </Button>\n          {/* Recalculate Costs */}\n          <Button\n            variant=\"secondary\"\n            onClick={handleRecalculateCosts}\n            disabled={isRecalculating || !hasPermission('archives:update_all')}\n            title={!hasPermission('archives:update_all') ? t('stats.noPermissionRecalculate') : t('stats.recalculateCostsHint')}\n          >\n            {isRecalculating ? (\n              <Loader2 className=\"w-4 h-4 animate-spin\" />\n            ) : (\n              <Calculator className=\"w-4 h-4\" />\n            )}\n            {t('stats.recalculateCosts')}\n          </Button>\n          {/* Export dropdown */}\n          <div className=\"relative\">\n            <Button\n              variant=\"secondary\"\n              onClick={() => setShowExportMenu(!showExportMenu)}\n              disabled={isExporting}\n            >\n              {isExporting ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : (\n                <FileSpreadsheet className=\"w-4 h-4\" />\n              )}\n              {t('stats.exportStats')}\n            </Button>\n            {showExportMenu && (\n              <div className=\"absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20\">\n                <button\n                  className=\"w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-t-lg\"\n                  onClick={() => handleExport('csv')}\n                >\n                  <FileText className=\"w-4 h-4\" />\n                  {t('stats.exportAsCsv')}\n                </button>\n                <button\n                  className=\"w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg\"\n                  onClick={() => handleExport('xlsx')}\n                >\n                  <FileSpreadsheet className=\"w-4 h-4\" />\n                  {t('stats.exportAsExcel')}\n                </button>\n              </div>\n            )}\n          </div>\n          {/* User Filter */}\n          {canFilterByUser && users && users.length > 0 && (\n            <div className=\"relative\">\n              <Button\n                variant=\"secondary\"\n                onClick={() => setShowUserPicker(!showUserPicker)}\n              >\n                <Users className=\"w-4 h-4\" />\n                {selectedUserLabel}\n                <ChevronDown className=\"w-3 h-3\" />\n              </Button>\n              {showUserPicker && (\n                <>\n                  <div\n                    className=\"fixed inset-0 z-10\"\n                    onClick={() => setShowUserPicker(false)}\n                  />\n                  <div className=\"absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 p-2 max-h-64 overflow-y-auto\">\n                    <button\n                      className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${\n                        selectedUserId === null\n                          ? 'bg-bambu-green text-white'\n                          : 'text-white hover:bg-bambu-dark-tertiary'\n                      }`}\n                      onClick={() => { setSelectedUserId(null); setShowUserPicker(false); }}\n                    >\n                      {t('stats.allUsers', 'All Users')}\n                    </button>\n                    <button\n                      className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${\n                        selectedUserId === -1\n                          ? 'bg-bambu-green text-white'\n                          : 'text-white hover:bg-bambu-dark-tertiary'\n                      }`}\n                      onClick={() => { setSelectedUserId(-1); setShowUserPicker(false); }}\n                    >\n                      {t('stats.noUser', 'No User (System)')}\n                    </button>\n                    <div className=\"border-t border-bambu-dark-tertiary my-1\" />\n                    {users.map(u => (\n                      <button\n                        key={u.id}\n                        className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${\n                          selectedUserId === u.id\n                            ? 'bg-bambu-green text-white'\n                            : 'text-white hover:bg-bambu-dark-tertiary'\n                        }`}\n                        onClick={() => { setSelectedUserId(u.id); setShowUserPicker(false); }}\n                      >\n                        {u.username}\n                      </button>\n                    ))}\n                  </div>\n                </>\n              )}\n            </div>\n          )}\n          {/* Timeframe Selector */}\n          <div className=\"relative\">\n            <Button\n              variant=\"secondary\"\n              onClick={() => setShowTimeframePicker(!showTimeframePicker)}\n            >\n              <Calendar className=\"w-4 h-4\" />\n              {t(`stats.timeframe.${timeframe.preset}`)}\n              <ChevronDown className=\"w-3 h-3\" />\n            </Button>\n\n            {showTimeframePicker && (\n              <>\n                <div\n                  className=\"fixed inset-0 z-10\"\n                  onClick={() => setShowTimeframePicker(false)}\n                />\n                <div className=\"absolute right-0 top-full mt-1 w-64 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 p-2\">\n                  {TIMEFRAME_PRESETS.map((preset) => (\n                    <button\n                      key={preset}\n                      className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${\n                        timeframe.preset === preset\n                          ? 'bg-bambu-green text-white'\n                          : 'text-white hover:bg-bambu-dark-tertiary'\n                      }`}\n                      onClick={() => {\n                        setTimeframe({ preset, dateFrom: undefined, dateTo: undefined });\n                        setShowTimeframePicker(false);\n                      }}\n                    >\n                      {t(`stats.timeframe.${preset}`)}\n                    </button>\n                  ))}\n\n                  <div className=\"border-t border-bambu-dark-tertiary my-2\" />\n\n                  <button\n                    className={`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${\n                      timeframe.preset === 'custom'\n                        ? 'bg-bambu-green text-white'\n                        : 'text-white hover:bg-bambu-dark-tertiary'\n                    }`}\n                    onClick={() => setTimeframe(prev => ({ ...prev, preset: 'custom' }))}\n                  >\n                    {t('stats.timeframe.custom')}\n                  </button>\n\n                  {timeframe.preset === 'custom' && (\n                    <div className=\"mt-2 px-1 pb-1 space-y-2\">\n                      <div>\n                        <label className=\"text-xs text-bambu-gray block mb-1\">{t('stats.timeframe.from')}</label>\n                        <input\n                          type=\"date\"\n                          value={timeframe.dateFrom || ''}\n                          max={timeframe.dateTo || new Date().toISOString().split('T')[0]}\n                          onChange={(e) => setTimeframe(prev => ({ ...prev, dateFrom: e.target.value || undefined }))}\n                          className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]\"\n                        />\n                      </div>\n                      <div>\n                        <label className=\"text-xs text-bambu-gray block mb-1\">{t('stats.timeframe.to')}</label>\n                        <input\n                          type=\"date\"\n                          value={timeframe.dateTo || ''}\n                          min={timeframe.dateFrom}\n                          max={new Date().toISOString().split('T')[0]}\n                          onChange={(e) => setTimeframe(prev => ({ ...prev, dateTo: e.target.value || undefined }))}\n                          className=\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]\"\n                        />\n                      </div>\n                      <Button\n                        variant=\"primary\"\n                        onClick={() => setShowTimeframePicker(false)}\n                        className=\"w-full\"\n                      >\n                        {t('common.apply')}\n                      </Button>\n                    </div>\n                  )}\n                </div>\n              </>\n            )}\n          </div>\n        </div>\n      </div>\n\n      <Dashboard\n        key={dashboardKey}\n        widgets={widgets}\n        storageKey=\"bambusy-dashboard-layout-v2\"\n        stackBelow={640}\n        hideControls\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/StreamOverlayPage.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react';\nimport { useParams, useSearchParams } from 'react-router-dom';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Layers, Clock, Timer, Printer } from 'lucide-react';\nimport { api, withStreamToken } from '../api/client';\nimport type { PrinterStatus } from '../api/client';\nimport { formatDuration, formatETA, type TimeFormat } from '../utils/date';\n\ntype TFunction = (key: string, options?: Record<string, unknown>) => string;\n\ntype OverlaySize = 'small' | 'medium' | 'large';\n\ninterface OverlayConfig {\n  size: OverlaySize;\n  fps: number;\n  showCamera: boolean;\n  showProgress: boolean;\n  showLayers: boolean;\n  showEta: boolean;\n  showFilename: boolean;\n  showStatus: boolean;\n  showPrinter: boolean;\n}\n\nfunction formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {\n  if (!name) return '';\n  if (!gcodeFile) return name;\n  const match = gcodeFile.match(/plate_(\\d+)\\.gcode/);\n  if (match && parseInt(match[1], 10) > 1) {\n    return `${name} — ${t('printers.plateNumber', 'Plate {{number}}', { number: match[1] })}`;\n  }\n  return name;\n}\n\nfunction parseConfig(params: URLSearchParams): OverlayConfig {\n  const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];\n\n  // Parse FPS (default 15, max 30, min 1)\n  const fpsParam = parseInt(params.get('fps') || '15', 10);\n  const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);\n\n  // Parse camera toggle (default true, set camera=false to hide)\n  const cameraParam = params.get('camera');\n  const showCamera = cameraParam !== 'false' && cameraParam !== '0';\n\n  return {\n    size: (params.get('size') as OverlaySize) || 'medium',\n    fps,\n    showCamera,\n    showProgress: show.includes('progress'),\n    showLayers: show.includes('layers'),\n    showEta: show.includes('eta'),\n    showFilename: show.includes('filename'),\n    showStatus: show.includes('status'),\n    showPrinter: show.includes('printer'),\n  };\n}\n\nfunction getStatusText(status: PrinterStatus, t: TFunction): string {\n  if (status.stg_cur_name) return status.stg_cur_name;\n\n  switch (status.state) {\n    case 'RUNNING': return t('streamOverlay.status.printing');\n    case 'PAUSE': return t('streamOverlay.status.paused');\n    case 'FINISH': return t('streamOverlay.status.finished');\n    case 'FAILED': return t('streamOverlay.status.failed');\n    case 'IDLE': return t('streamOverlay.status.idle');\n    default: return status.state || t('streamOverlay.status.unknown');\n  }\n}\n\nfunction getSizeClasses(size: OverlaySize) {\n  switch (size) {\n    case 'small':\n      return {\n        container: 'p-3',\n        text: 'text-sm',\n        textLarge: 'text-lg',\n        progressHeight: 'h-2',\n        icon: 'w-3 h-3',\n        gap: 'gap-2',\n        logoHeight: 'h-12',\n      };\n    case 'large':\n      return {\n        container: 'p-6',\n        text: 'text-xl',\n        textLarge: 'text-3xl',\n        progressHeight: 'h-4',\n        icon: 'w-6 h-6',\n        gap: 'gap-4',\n        logoHeight: 'h-24',\n      };\n    case 'medium':\n    default:\n      return {\n        container: 'p-4',\n        text: 'text-base',\n        textLarge: 'text-xl',\n        progressHeight: 'h-3',\n        icon: 'w-4 h-4',\n        gap: 'gap-3',\n        logoHeight: 'h-16',\n      };\n  }\n}\n\nexport function StreamOverlayPage() {\n  const { printerId } = useParams<{ printerId: string }>();\n  const [searchParams] = useSearchParams();\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const id = parseInt(printerId || '0', 10);\n  const [imageKey, setImageKey] = useState(Date.now());\n\n  const config = useMemo(() => parseConfig(searchParams), [searchParams]);\n  const sizes = getSizeClasses(config.size);\n\n  // Fetch printer info\n  const { data: printer } = useQuery({\n    queryKey: ['printer', id],\n    queryFn: () => api.getPrinter(id),\n    enabled: id > 0,\n  });\n\n  // Fetch printer status with polling\n  const { data: status } = useQuery({\n    queryKey: ['printerStatus', id],\n    queryFn: () => api.getPrinterStatus(id),\n    enabled: id > 0,\n    refetchInterval: 2000,\n  });\n\n  // Fetch settings info\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const timeFormat: TimeFormat = settings?.time_format || 'system';\n\n  // WebSocket for real-time updates\n  useEffect(() => {\n    if (!id) return;\n\n    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;\n    const ws = new WebSocket(wsUrl);\n\n    ws.onmessage = (event) => {\n      try {\n        const data = JSON.parse(event.data);\n        if (data.type === 'printer_status' && data.printer_id === id) {\n          queryClient.setQueryData(['printerStatus', id], data.status);\n        }\n      } catch {\n        // Ignore parse errors\n      }\n    };\n\n    ws.onerror = () => {\n      // WebSocket error - polling will continue as fallback\n    };\n\n    return () => {\n      ws.close();\n    };\n  }, [id, queryClient]);\n\n  // Update document title\n  useEffect(() => {\n    document.title = printer ? `${printer.name} - ${t('streamOverlay.title')}` : t('streamOverlay.title');\n    return () => {\n      document.title = 'Bambuddy';\n    };\n  }, [printer, t]);\n\n  // Refresh stream on error\n  const handleStreamError = () => {\n    setTimeout(() => {\n      setImageKey(Date.now());\n    }, 3000);\n  };\n\n  if (!id) {\n    return (\n      <div className=\"min-h-screen bg-black flex items-center justify-center\">\n        <p className=\"text-white\">{t('streamOverlay.invalidPrinterId')}</p>\n      </div>\n    );\n  }\n\n  if (!status) {\n    return (\n      <div className=\"min-h-screen bg-black flex items-center justify-center\">\n        <p className=\"text-gray-400\">{t('common.loading')}</p>\n      </div>\n    );\n  }\n\n  const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';\n  const progress = status.progress || 0;\n  const streamUrl = withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`);\n\n  return (\n    <div className=\"min-h-screen bg-black relative overflow-hidden\">\n      {/* Camera feed - fullscreen background (optional) */}\n      {config.showCamera && (\n        <img\n          key={imageKey}\n          src={streamUrl}\n          alt={t('streamOverlay.cameraStream')}\n          className=\"absolute inset-0 w-full h-full object-contain\"\n          style={printer?.camera_rotation ? { transform: `rotate(${printer.camera_rotation}deg)` } : undefined}\n          onError={handleStreamError}\n        />\n      )}\n\n      {/* Bambuddy logo - top right */}\n      <a\n        href=\"https://github.com/maziggy/bambuddy\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"absolute top-4 right-4 z-10\"\n      >\n        <img\n          src=\"/img/bambuddy_logo_dark_transparent.png\"\n          alt=\"Bambuddy\"\n          className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}\n        />\n      </a>\n\n      {/* Status overlay - bottom */}\n      <div className=\"absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent\">\n        <div className={`${sizes.container}`}>\n          {/* Printer name */}\n          {config.showPrinter && printer && (\n            <div className={`flex items-center ${sizes.gap} mb-2`}>\n              <Printer className={`${sizes.icon} text-white/70`} />\n              <span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>\n            </div>\n          )}\n\n          {/* Filename */}\n          {config.showFilename && status.current_print && (\n            <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>\n              {formatPrintName(status.current_print.replace(/\\.gcode\\.3mf$|\\.3mf$|\\.gcode$/i, ''), status.gcode_file, t)}\n            </div>\n          )}\n\n          {/* Status text */}\n          {config.showStatus && (\n            <div className={`${sizes.text} text-white/70 mb-2`}>\n              {getStatusText(status, t)}\n            </div>\n          )}\n\n          {/* Progress bar */}\n          {config.showProgress && isPrinting && (\n            <div className=\"mb-3\">\n              <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>\n                <span className=\"text-white/70\">{t('streamOverlay.progress')}</span>\n                <span className=\"text-white font-bold\">{Math.round(progress)}%</span>\n              </div>\n              <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>\n                <div\n                  className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}\n                  style={{ width: `${progress}%` }}\n                />\n              </div>\n            </div>\n          )}\n\n          {/* Stats row */}\n          {isPrinting && (config.showLayers || config.showEta) && (\n            <div className={`flex items-center ${sizes.gap} flex-wrap`}>\n              {/* Layers */}\n              {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (\n                <div className={`flex items-center ${sizes.gap} text-white/70`}>\n                  <Layers className={sizes.icon} />\n                  <span className={sizes.text}>\n                    <span className=\"text-white\">{status.layer_num}</span>\n                    <span className=\"mx-1\">/</span>\n                    <span>{status.total_layers}</span>\n                  </span>\n                </div>\n              )}\n\n              {/* Remaining time */}\n              {config.showEta && status.remaining_time != null && status.remaining_time > 0 && (\n                <>\n                  <div className={`flex items-center ${sizes.gap} text-white/70`}>\n                    <Timer className={sizes.icon} />\n                    <span className={`${sizes.text} text-white`}>\n                      {formatDuration(status.remaining_time * 60)}\n                    </span>\n                  </div>\n\n                  <div className={`flex items-center ${sizes.gap} text-white/70`}>\n                    <Clock className={sizes.icon} />\n                    <span className={`${sizes.text} text-white`}>\n                      {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}\n                    </span>\n                  </div>\n                </>\n              )}\n            </div>\n          )}\n\n          {/* Idle state */}\n          {!isPrinting && (\n            <div className={`${sizes.text} text-white/70 py-2`}>\n              {status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/SystemInfoPage.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport {\n  Server,\n  Database,\n  HardDrive,\n  Cpu,\n  MemoryStick,\n  Printer,\n  Archive,\n  Clock,\n  CheckCircle2,\n  XCircle,\n  Loader2,\n  RefreshCw,\n  Plug,\n  FolderKanban,\n  Palette,\n  Bug,\n  Download,\n  Headphones,\n  FolderOpen,\n} from 'lucide-react';\nimport { api, supportApi } from '../api/client';\nimport { Card } from '../components/Card';\nimport { LogViewer } from '../components/LogViewer';\nimport { formatDateTime, type TimeFormat } from '../utils/date';\n\nfunction formatBytes(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;\n}\n\nfunction StatCard({\n  icon: Icon,\n  label,\n  value,\n  subValue,\n  color = 'text-bambu-green',\n}: {\n  icon: React.ElementType;\n  label: string;\n  value: string | number;\n  subValue?: string;\n  color?: string;\n}) {\n  return (\n    <div className=\"flex items-start gap-3 p-4 bg-bambu-dark rounded-lg\">\n      <div className={`p-2 rounded-lg bg-bambu-dark-tertiary ${color}`}>\n        <Icon className=\"w-5 h-5\" />\n      </div>\n      <div className=\"flex-1 min-w-0\">\n        <p className=\"text-sm text-bambu-gray\">{label}</p>\n        <p className=\"text-lg font-semibold text-white truncate\">{value}</p>\n        {subValue && <p className=\"text-xs text-bambu-gray mt-0.5\">{subValue}</p>}\n      </div>\n    </div>\n  );\n}\n\nfunction ProgressBar({ percent, color = 'bg-bambu-green' }: { percent: number; color?: string }) {\n  return (\n    <div className=\"w-full h-2 bg-bambu-dark rounded-full overflow-hidden\">\n      <div\n        className={`h-full ${color} transition-all duration-300`}\n        style={{ width: `${Math.min(100, percent)}%` }}\n      />\n    </div>\n  );\n}\n\nfunction Section({\n  title,\n  icon: Icon,\n  children,\n}: {\n  title: string;\n  icon: React.ElementType;\n  children: React.ReactNode;\n}) {\n  return (\n    <Card className=\"p-6\">\n      <div className=\"flex items-center gap-2 mb-4\">\n        <Icon className=\"w-5 h-5 text-bambu-green\" />\n        <h2 className=\"text-lg font-semibold text-white\">{title}</h2>\n      </div>\n      {children}\n    </Card>\n  );\n}\n\nexport function SystemInfoPage() {\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const [bundleError, setBundleError] = useState<string | null>(null);\n  const [bundleDownloading, setBundleDownloading] = useState(false);\n  const [debugToggling, setDebugToggling] = useState(false);\n\n  const { data: systemInfo, isLoading, refetch, isFetching } = useQuery({\n    queryKey: ['systemInfo'],\n    queryFn: api.getSystemInfo,\n    refetchInterval: 30000, // Auto-refresh every 30 seconds\n  });\n\n  const { data: debugLoggingState } = useQuery({\n    queryKey: ['debugLogging'],\n    queryFn: supportApi.getDebugLoggingState,\n    staleTime: 10 * 1000, // 10 seconds\n    refetchInterval: 10 * 1000,\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const { data: libraryStats } = useQuery({\n    queryKey: ['library-stats'],\n    queryFn: api.getLibraryStats,\n  });\n\n  const timeFormat: TimeFormat = settings?.time_format || 'system';\n\n  const handleToggleDebugLogging = async () => {\n    setDebugToggling(true);\n    try {\n      const newState = await supportApi.setDebugLogging(!debugLoggingState?.enabled);\n      // Immediately update the cache with the new state (includes fresh enabled_at timestamp)\n      queryClient.setQueryData(['debugLogging'], newState);\n    } catch (err) {\n      console.error('Failed to toggle debug logging:', err);\n    } finally {\n      setDebugToggling(false);\n    }\n  };\n\n  const handleDownloadBundle = async () => {\n    setBundleError(null);\n    setBundleDownloading(true);\n    try {\n      await supportApi.downloadSupportBundle();\n    } catch (err) {\n      setBundleError(err instanceof Error ? err.message : 'Failed to download support bundle');\n    } finally {\n      setBundleDownloading(false);\n    }\n  };\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center h-64\">\n        <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n      </div>\n    );\n  }\n\n  if (!systemInfo) {\n    return (\n      <div className=\"p-6 text-center text-bambu-gray\">\n        {t('system.failedToLoad', 'Failed to load system information')}\n      </div>\n    );\n  }\n\n  const diskColor =\n    systemInfo.storage.disk_percent_used > 90\n      ? 'bg-red-500'\n      : systemInfo.storage.disk_percent_used > 75\n      ? 'bg-yellow-500'\n      : 'bg-bambu-green';\n\n  const memoryColor =\n    systemInfo.memory.percent_used > 90\n      ? 'bg-red-500'\n      : systemInfo.memory.percent_used > 75\n      ? 'bg-yellow-500'\n      : 'bg-bambu-green';\n\n  return (\n    <div className=\"p-6 space-y-6\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h1 className=\"text-2xl font-bold text-white\">{t('system.title', 'System Information')}</h1>\n          <p className=\"text-bambu-gray mt-1\">\n            {t('system.subtitle', 'Monitor system resources and database statistics')}\n          </p>\n        </div>\n        <button\n          onClick={() => refetch()}\n          disabled={isFetching}\n          className=\"flex items-center gap-2 px-4 py-2 bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50\"\n        >\n          <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />\n          {t('common.refresh', 'Refresh')}\n        </button>\n      </div>\n\n      {/* Application Info */}\n      <Section title={t('system.application', 'Application')} icon={Server}>\n        <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n          <StatCard\n            icon={Server}\n            label={t('system.version', 'Version')}\n            value={`v${systemInfo.app.version}`}\n          />\n          <StatCard\n            icon={Clock}\n            label={t('system.uptime', 'System Uptime')}\n            value={systemInfo.system.uptime_formatted}\n          />\n          <StatCard\n            icon={Server}\n            label={t('system.hostname', 'Hostname')}\n            value={systemInfo.system.hostname}\n          />\n        </div>\n      </Section>\n\n      {/* Support & Troubleshooting */}\n      <Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>\n        <div className=\"space-y-4\">\n          <p className=\"text-sm text-bambu-gray\">\n            {t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}\n          </p>\n\n          {/* Debug Logging Toggle */}\n          <div className=\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\">\n            <div className=\"flex items-center gap-3\">\n              <div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>\n                <Bug className=\"w-5 h-5\" />\n              </div>\n              <div>\n                <p className=\"font-medium text-white\">{t('support.debugLogging', 'Debug Logging')}</p>\n                <p className=\"text-sm text-bambu-gray\">\n                  {debugLoggingState?.enabled\n                    ? t('support.debugLoggingEnabled', 'Capturing detailed logs')\n                    : t('support.debugLoggingDisabled', 'Normal logging level')}\n                  {debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (\n                    <span className=\"text-amber-400 ml-2\">\n                      ({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)\n                    </span>\n                  )}\n                </p>\n              </div>\n            </div>\n            <button\n              onClick={handleToggleDebugLogging}\n              disabled={debugToggling}\n              className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${\n                debugLoggingState?.enabled\n                  ? 'bg-amber-500/20 text-amber-400 hover:bg-amber-500/30'\n                  : 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'\n              } disabled:opacity-50`}\n            >\n              {debugToggling && <Loader2 className=\"w-4 h-4 animate-spin\" />}\n              {debugLoggingState?.enabled\n                ? t('support.disableDebug', 'Disable')\n                : t('support.enableDebug', 'Enable')}\n            </button>\n          </div>\n\n          {/* Support Bundle Download */}\n          <div className=\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\">\n            <div className=\"flex items-center gap-3\">\n              <div className=\"p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green\">\n                <Download className=\"w-5 h-5\" />\n              </div>\n              <div>\n                <p className=\"font-medium text-white\">{t('support.supportBundle', 'Support Bundle')}</p>\n                <p className=\"text-sm text-bambu-gray\">\n                  {t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}\n                </p>\n              </div>\n            </div>\n            <button\n              onClick={handleDownloadBundle}\n              disabled={bundleDownloading || !debugLoggingState?.enabled}\n              className=\"px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed\"\n              title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}\n            >\n              {bundleDownloading && <Loader2 className=\"w-4 h-4 animate-spin\" />}\n              {t('common.download', 'Download')}\n            </button>\n          </div>\n\n          {/* Error message */}\n          {bundleError && (\n            <div className=\"p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm\">\n              {bundleError}\n            </div>\n          )}\n\n          {/* Instructions */}\n          {!debugLoggingState?.enabled && (\n            <div className=\"p-4 bg-bambu-dark-tertiary/50 rounded-lg\">\n              <p className=\"text-sm text-bambu-gray\">\n                <span className=\"text-amber-400 font-medium\">{t('support.instructions', 'To report an issue:')}</span>\n                <br />\n                1. {t('support.step1', 'Enable debug logging')}\n                <br />\n                2. {t('support.step2', 'Reproduce the issue')}\n                <br />\n                3. {t('support.step3', 'Download the support bundle')}\n                <br />\n                4. {t('support.step4', 'Attach the ZIP file to your issue report')}\n              </p>\n            </div>\n          )}\n\n          {/* Privacy Info */}\n          <div className=\"p-4 bg-bambu-dark rounded-lg space-y-3\">\n            <p className=\"text-sm font-medium text-white\">{t('support.privacyTitle', 'What\\'s in the support bundle?')}</p>\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4 text-sm\">\n              <div>\n                <p className=\"text-bambu-green font-medium mb-1\">{t('support.collected', 'Collected:')}</p>\n                <ul className=\"text-bambu-gray space-y-0.5\">\n                  <li>• {t('support.collectItem1', 'App version and debug mode')}</li>\n                  <li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>\n                  <li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>\n                  <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>\n                  <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>\n                  <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>\n                  <li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>\n                  <li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>\n                  <li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>\n                  <li>• {t('support.collectItem10', 'Python package versions')}</li>\n                  <li>• {t('support.collectItem11', 'Database health checks')}</li>\n                  <li>• {t('support.collectItem12', 'Docker environment details')}</li>\n                </ul>\n              </div>\n              <div>\n                <p className=\"text-red-400 font-medium mb-1\">{t('support.notCollected', 'NOT collected:')}</p>\n                <ul className=\"text-bambu-gray space-y-0.5\">\n                  <li>• {t('support.notItem1', 'Printer names and serial numbers')}</li>\n                  <li>• {t('support.notItem2', 'Access codes and passwords')}</li>\n                  <li>• {t('support.notItem3', 'Email addresses')}</li>\n                  <li>• {t('support.notItem4', 'API keys and tokens')}</li>\n                  <li>• {t('support.notItem5', 'Webhook URLs')}</li>\n                  <li>• {t('support.notItem6', 'Your hostname or username')}</li>\n                  <li>• {t('support.notItem7', 'IP addresses')}</li>\n                </ul>\n              </div>\n            </div>\n            <p className=\"text-xs text-bambu-gray/70\">\n              {t('support.privacyNote', 'Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], serial numbers with [SERIAL], and IP addresses with [IP].')}\n            </p>\n          </div>\n\n          {/* Log Viewer */}\n          <LogViewer />\n        </div>\n      </Section>\n\n      {/* Database Stats */}\n      <Section title={t('system.database', 'Database')} icon={Database}>\n        <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4 mb-4\">\n          <StatCard\n            icon={Database}\n            label={t('system.dbEngine', 'Database Engine')}\n            value={systemInfo.database.engine || 'SQLite'}\n          />\n          <StatCard\n            icon={Database}\n            label={t('system.dbVersion', 'Version')}\n            value={systemInfo.database.version || 'unknown'}\n          />\n        </div>\n        <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-4\">\n          <StatCard\n            icon={Archive}\n            label={t('system.totalArchives', 'Total Archives')}\n            value={systemInfo.database.archives}\n          />\n          <StatCard\n            icon={CheckCircle2}\n            label={t('system.completed', 'Completed')}\n            value={systemInfo.database.archives_completed}\n            color=\"text-green-500\"\n          />\n          <StatCard\n            icon={XCircle}\n            label={t('system.failed', 'Failed')}\n            value={systemInfo.database.archives_failed}\n            color=\"text-red-500\"\n          />\n          <StatCard\n            icon={Loader2}\n            label={t('system.printing', 'Printing')}\n            value={systemInfo.database.archives_printing}\n            color=\"text-yellow-500\"\n          />\n        </div>\n        <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n          <StatCard\n            icon={Printer}\n            label={t('system.printers', 'Printers')}\n            value={systemInfo.database.printers}\n          />\n          <StatCard\n            icon={Palette}\n            label={t('system.filaments', 'Filaments')}\n            value={systemInfo.database.filaments}\n          />\n          <StatCard\n            icon={FolderKanban}\n            label={t('system.projects', 'Projects')}\n            value={systemInfo.database.projects}\n          />\n          <StatCard\n            icon={Plug}\n            label={t('system.smartPlugs', 'Smart Plugs')}\n            value={systemInfo.database.smart_plugs}\n          />\n        </div>\n        <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4 mt-4\">\n          <StatCard\n            icon={Clock}\n            label={t('system.totalPrintTime', 'Total Print Time')}\n            value={systemInfo.database.total_print_time_formatted}\n          />\n          <StatCard\n            icon={Archive}\n            label={t('system.totalFilament', 'Total Filament Used')}\n            value={`${systemInfo.database.total_filament_kg} kg`}\n            subValue={`${systemInfo.database.total_filament_grams.toLocaleString()} g`}\n          />\n        </div>\n      </Section>\n\n      {/* Connected Printers */}\n      <Section title={t('system.connectedPrinters', 'Connected Printers')} icon={Printer}>\n        <div className=\"flex items-center gap-4 mb-4\">\n          <div className=\"text-3xl font-bold text-bambu-green\">\n            {systemInfo.printers.connected}\n          </div>\n          <div className=\"text-bambu-gray\">\n            {t('system.ofTotal', 'of {{total}} printers connected', {\n              total: systemInfo.printers.total,\n            })}\n          </div>\n        </div>\n        {systemInfo.printers.connected_list.length > 0 ? (\n          <div className=\"space-y-2\">\n            {systemInfo.printers.connected_list.map((printer) => (\n              <div\n                key={printer.id}\n                className=\"flex items-center justify-between p-3 bg-bambu-dark rounded-lg\"\n              >\n                <div className=\"flex items-center gap-3\">\n                  <div className=\"w-2 h-2 rounded-full bg-bambu-green\" />\n                  <span className=\"font-medium text-white\">{printer.name}</span>\n                </div>\n                <div className=\"flex items-center gap-4 text-sm text-bambu-gray\">\n                  <span>{printer.model}</span>\n                  <span\n                    className={`px-2 py-0.5 rounded ${\n                      printer.state === 'RUNNING'\n                        ? 'bg-bambu-green/20 text-bambu-green'\n                        : printer.state === 'IDLE'\n                        ? 'bg-blue-500/20 text-blue-400'\n                        : 'bg-bambu-dark-tertiary'\n                    }`}\n                  >\n                    {printer.state}\n                  </span>\n                </div>\n              </div>\n            ))}\n          </div>\n        ) : (\n          <p className=\"text-bambu-gray\">{t('system.noPrintersConnected', 'No printers connected')}</p>\n        )}\n      </Section>\n\n      {/* Storage */}\n      <Section title={t('system.storage', 'Storage')} icon={HardDrive}>\n        <div className=\"space-y-4\">\n          <div>\n            <div className=\"flex justify-between text-sm mb-1\">\n              <span className=\"text-bambu-gray\">{t('system.diskUsage', 'Disk Usage')}</span>\n              <span className=\"text-white\">\n                {systemInfo.storage.disk_used_formatted} / {systemInfo.storage.disk_total_formatted}\n              </span>\n            </div>\n            <ProgressBar percent={systemInfo.storage.disk_percent_used} color={diskColor} />\n            <p className=\"text-xs text-bambu-gray mt-1\">\n              {systemInfo.storage.disk_free_formatted} {t('system.free', 'free')} (\n              {(100 - systemInfo.storage.disk_percent_used).toFixed(1)}%)\n            </p>\n          </div>\n          <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n            <StatCard\n              icon={Archive}\n              label={t('system.archiveStorage', 'Archive Storage')}\n              value={systemInfo.storage.archive_size_formatted}\n            />\n            <StatCard\n              icon={Database}\n              label={t('system.databaseSize', 'Database Size')}\n              value={systemInfo.storage.database_size_formatted}\n            />\n            {libraryStats && (\n              <StatCard\n                icon={FolderOpen}\n                label={t('system.fileManagerStorage', 'File Manager')}\n                value={formatBytes(libraryStats.total_size_bytes)}\n                subValue={`${libraryStats.total_files} files, ${libraryStats.total_folders} folders`}\n              />\n            )}\n          </div>\n        </div>\n      </Section>\n\n      {/* System Resources */}\n      <div className=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n        {/* Memory */}\n        <Section title={t('system.memory', 'Memory')} icon={MemoryStick}>\n          <div className=\"space-y-4\">\n            <div>\n              <div className=\"flex justify-between text-sm mb-1\">\n                <span className=\"text-bambu-gray\">{t('system.memoryUsage', 'Memory Usage')}</span>\n                <span className=\"text-white\">\n                  {systemInfo.memory.used_formatted} / {systemInfo.memory.total_formatted}\n                </span>\n              </div>\n              <ProgressBar percent={systemInfo.memory.percent_used} color={memoryColor} />\n              <p className=\"text-xs text-bambu-gray mt-1\">\n                {systemInfo.memory.available_formatted} {t('system.available', 'available')}\n              </p>\n            </div>\n          </div>\n        </Section>\n\n        {/* CPU */}\n        <Section title={t('system.cpu', 'CPU')} icon={Cpu}>\n          <div className=\"space-y-4\">\n            <div className=\"grid grid-cols-2 gap-4\">\n              <StatCard\n                icon={Cpu}\n                label={t('system.cores', 'Cores')}\n                value={systemInfo.cpu.count}\n                subValue={`${systemInfo.cpu.count_logical} logical`}\n              />\n              <StatCard\n                icon={Cpu}\n                label={t('system.usage', 'Usage')}\n                value={`${systemInfo.cpu.percent}%`}\n              />\n            </div>\n          </div>\n        </Section>\n      </div>\n\n      {/* System Details */}\n      <Section title={t('system.systemDetails', 'System Details')} icon={Server}>\n        <div className=\"grid grid-cols-2 md:grid-cols-4 gap-4\">\n          <StatCard\n            icon={Server}\n            label={t('system.os', 'Operating System')}\n            value={systemInfo.system.platform}\n            subValue={systemInfo.system.platform_release}\n          />\n          <StatCard\n            icon={Cpu}\n            label={t('system.architecture', 'Architecture')}\n            value={systemInfo.system.architecture}\n          />\n          <StatCard\n            icon={Server}\n            label={t('system.python', 'Python')}\n            value={systemInfo.system.python_version}\n          />\n          <StatCard\n            icon={Clock}\n            label={t('system.bootTime', 'Boot Time')}\n            value={formatDateTime(systemInfo.system.boot_time, timeFormat)}\n          />\n        </div>\n      </Section>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/UsersPage.tsx",
    "content": "import { useState, useEffect, useMemo } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft, RotateCcw } from 'lucide-react';\nimport { api } from '../api/client';\nimport type { UserCreate, UserUpdate, UserResponse } from '../api/client';\nimport { useAuth } from '../contexts/AuthContext';\nimport { useToast } from '../contexts/ToastContext';\nimport { Button } from '../components/Button';\nimport { Card, CardContent, CardHeader } from '../components/Card';\nimport { ConfirmModal } from '../components/ConfirmModal';\nimport { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';\n\ninterface FormData extends UserCreate {\n  group_ids: number[];\n  confirmPassword: string;\n  email?: string;\n}\n\nexport function UsersPage() {\n  const navigate = useNavigate();\n  const { t } = useTranslation();\n  const { user: currentUser, hasPermission } = useAuth();\n  const { showToast } = useToast();\n  const queryClient = useQueryClient();\n  const [showCreateModal, setShowCreateModal] = useState(false);\n  const [showEditModal, setShowEditModal] = useState(false);\n  const [editingUserId, setEditingUserId] = useState<number | null>(null);\n  const [deleteUserId, setDeleteUserId] = useState<number | null>(null);\n  const [formData, setFormData] = useState<FormData>({\n    username: '',\n    password: '',\n    email: '',\n    confirmPassword: '',\n    role: 'user',\n    group_ids: [],\n  });\n\n  // Check if advanced auth is enabled\n  const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false } } = useQuery({\n    queryKey: ['advancedAuthStatus'],\n    queryFn: () => api.getAdvancedAuthStatus(),\n  });\n\n  // Close modal on Escape key\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === 'Escape') {\n        if (showCreateModal) {\n          setShowCreateModal(false);\n          setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n        }\n        if (showEditModal) {\n          setShowEditModal(false);\n          setEditingUserId(null);\n          setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n        }\n      }\n    };\n    window.addEventListener('keydown', handleKeyDown);\n    return () => window.removeEventListener('keydown', handleKeyDown);\n  }, [showCreateModal, showEditModal]);\n\n  const { data: users = [], isLoading } = useQuery({\n    queryKey: ['users'],\n    queryFn: () => api.getUsers(),\n    enabled: hasPermission('users:read'),\n  });\n\n  const { data: groups = [] } = useQuery({\n    queryKey: ['groups'],\n    queryFn: () => api.getGroups(),\n    enabled: hasPermission('groups:read'),\n  });\n\n  const createMutation = useMutation({\n    mutationFn: (data: UserCreate) => api.createUser(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['users'] });\n      queryClient.invalidateQueries({ queryKey: ['groups'] });\n      setShowCreateModal(false);\n      setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n      showToast(t('users.toast.created'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['users'] });\n      queryClient.invalidateQueries({ queryKey: ['groups'] });\n      setShowEditModal(false);\n      setEditingUserId(null);\n      setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n      showToast(t('users.toast.updated'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (id: number) => api.deleteUser(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['users'] });\n      showToast(t('users.toast.deleted'));\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  const resetPasswordMutation = useMutation({\n    mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }),\n    onSuccess: (data) => {\n      showToast(data.message, 'success');\n    },\n    onError: (error: Error) => {\n      showToast(error.message, 'error');\n    },\n  });\n\n  // Validation for create user button\n  const isCreateButtonDisabled = useMemo(() => {\n    if (createMutation.isPending || !formData.username) {\n      return true;\n    }\n    if (advancedAuthStatus?.advanced_auth_enabled) {\n      // When advanced auth is enabled, require email (password is auto-generated)\n      return !formData.email;\n    }\n    // When advanced auth is disabled, require valid password\n    return !formData.password || formData.password !== formData.confirmPassword || formData.password.length < 6;\n  }, [\n    createMutation.isPending,\n    formData.username,\n    formData.email,\n    formData.password,\n    formData.confirmPassword,\n    advancedAuthStatus?.advanced_auth_enabled\n  ]);\n\n  const handleCreate = () => {\n    // Use the status from the query hook\n    const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;\n\n    if (!formData.username) {\n      const errorMsg = t('users.toast.fillRequired');\n      showToast(errorMsg, 'error');\n      if (advancedAuthEnabled) {\n        console.error('[Advanced Auth] Create user failed: Username is required');\n      }\n      return;\n    }\n\n    // Email is required when advanced auth is enabled\n    if (advancedAuthEnabled && !formData.email) {\n      const errorMsg = 'Email is required when advanced authentication is enabled';\n      showToast(errorMsg, 'error');\n      console.error('[Advanced Auth] Create user failed: Email is required when advanced authentication is enabled');\n      return;\n    }\n\n    // Password validation only when advanced auth is disabled\n    if (!advancedAuthEnabled) {\n      if (!formData.password) {\n        showToast(t('users.toast.fillRequired'), 'error');\n        return;\n      }\n      if (formData.password !== formData.confirmPassword) {\n        showToast(t('users.toast.passwordsDoNotMatch'), 'error');\n        return;\n      }\n      if (formData.password.length < 6) {\n        showToast(t('users.toast.passwordTooShort'), 'error');\n        return;\n      }\n    }\n\n    createMutation.mutate({\n      username: formData.username,\n      password: advancedAuthEnabled ? undefined : formData.password,\n      email: formData.email || undefined,\n      role: formData.role,\n      group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,\n    });\n  };\n\n  const handleUpdate = (id: number) => {\n    // Validate password confirmation if a new password is being set\n    if (formData.password) {\n      if (formData.password !== formData.confirmPassword) {\n        showToast(t('users.toast.passwordsDoNotMatch'), 'error');\n        return;\n      }\n      if (formData.password.length < 6) {\n        showToast(t('users.toast.passwordTooShort'), 'error');\n        return;\n      }\n    }\n    const updateData: UserUpdate = {\n      username: formData.username || undefined,\n      password: formData.password || undefined,\n      email: formData.email || undefined,\n      role: formData.role,\n      group_ids: formData.group_ids,\n    };\n    // Remove password if empty\n    if (!updateData.password) {\n      delete updateData.password;\n    }\n    // Remove email if empty\n    if (!updateData.email) {\n      delete updateData.email;\n    }\n    updateMutation.mutate({ id, data: updateData });\n  };\n\n  const handleDelete = (id: number) => {\n    setDeleteUserId(id);\n  };\n\n  const startEdit = (user: UserResponse) => {\n    setEditingUserId(user.id);\n    setFormData({\n      username: user.username,\n      password: '',\n      email: user.email || '',\n      confirmPassword: '',\n      role: user.role,\n      group_ids: user.groups?.map(g => g.id) || [],\n    });\n    setShowEditModal(true);\n  };\n\n  const closeEditModal = () => {\n    setShowEditModal(false);\n    setEditingUserId(null);\n    setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n  };\n\n  const toggleGroup = (groupId: number) => {\n    setFormData(prev => ({\n      ...prev,\n      group_ids: prev.group_ids.includes(groupId)\n        ? prev.group_ids.filter(id => id !== groupId)\n        : [...prev.group_ids, groupId],\n    }));\n  };\n\n  if (!hasPermission('users:read')) {\n    return (\n      <div className=\"p-6\">\n        <Card>\n          <CardContent className=\"py-6\">\n            <div className=\"flex items-center gap-3 text-red-400\">\n              <Shield className=\"w-5 h-5\" />\n              <p className=\"text-white\">{t('users.noPermission')}</p>\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"p-6\">\n      <div className=\"flex justify-between items-center mb-6\">\n        <div className=\"flex items-center gap-4\">\n          <button\n            onClick={() => navigate('/settings?tab=users')}\n            className=\"p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors\"\n            title={t('users.backToSettings')}\n          >\n            <ArrowLeft className=\"w-5 h-5\" />\n          </button>\n          <div>\n            <h1 className=\"text-2xl font-bold text-white flex items-center gap-2\">\n              <UsersIcon className=\"w-6 h-6 text-bambu-green\" />\n              {t('users.title')}\n            </h1>\n            <p className=\"text-sm text-bambu-gray mt-1\">\n              {t('users.subtitle')}\n            </p>\n          </div>\n        </div>\n        <Button\n          onClick={() => {\n            setShowCreateModal(true);\n            setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n          }}\n        >\n          <Plus className=\"w-4 h-4\" />\n          {t('users.createUser')}\n        </Button>\n      </div>\n\n      {isLoading ? (\n        <div className=\"flex items-center justify-center py-12\">\n          <Loader2 className=\"w-8 h-8 text-bambu-green animate-spin\" />\n        </div>\n      ) : (\n        <Card>\n          <div className=\"overflow-x-auto\">\n            <table className=\"min-w-full divide-y divide-bambu-dark-tertiary\">\n              <thead>\n                <tr>\n                  <th className=\"px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider\">\n                    {t('users.table.username')}\n                  </th>\n                  <th className=\"px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider\">\n                    {t('users.table.groups')}\n                  </th>\n                  <th className=\"px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider\">\n                    {t('users.table.status')}\n                  </th>\n                  <th className=\"px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider\">\n                    {t('users.table.actions')}\n                  </th>\n                </tr>\n              </thead>\n              <tbody className=\"divide-y divide-bambu-dark-tertiary\">\n                {users.map((user) => (\n                  <tr key={user.id} className=\"hover:bg-bambu-dark-tertiary/50 transition-colors\">\n                    <td className=\"px-6 py-4 whitespace-nowrap text-sm font-medium text-white\">\n                      {user.username}\n                    </td>\n                    <td className=\"px-6 py-4 text-sm\">\n                      <div className=\"flex flex-wrap gap-1\">\n                        {user.is_admin && (\n                          <span className=\"px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300\">\n                            {t('users.admin')}\n                          </span>\n                        )}\n                        {user.groups?.map(group => (\n                          <span\n                            key={group.id}\n                            className={`px-2 py-0.5 rounded-full text-xs font-medium ${\n                              group.name === 'Administrators'\n                                ? 'bg-purple-500/20 text-purple-300'\n                                : group.name === 'Operators'\n                                ? 'bg-blue-500/20 text-blue-300'\n                                : group.name === 'Viewers'\n                                ? 'bg-green-500/20 text-green-300'\n                                : 'bg-gray-500/20 text-gray-300'\n                            }`}\n                          >\n                            {group.name}\n                          </span>\n                        ))}\n                        {(!user.groups || user.groups.length === 0) && !user.is_admin && (\n                          <span className=\"text-bambu-gray\">{t('users.noGroups')}</span>\n                        )}\n                      </div>\n                    </td>\n                    <td className=\"px-6 py-4 whitespace-nowrap text-sm\">\n                      <span className={`px-3 py-1 rounded-full text-xs font-medium ${\n                        user.is_active\n                          ? 'bg-bambu-green/20 text-bambu-green'\n                          : 'bg-red-500/20 text-red-400'\n                      }`}>\n                        {user.is_active ? t('users.active') : t('users.inactive')}\n                      </span>\n                    </td>\n                    <td className=\"px-6 py-4 whitespace-nowrap text-sm font-medium\">\n                      <div className=\"flex items-center gap-2\">\n                        <Button\n                          size=\"sm\"\n                          variant=\"ghost\"\n                          onClick={() => startEdit(user)}\n                        >\n                          <Edit2 className=\"w-4 h-4\" />\n                          {t('users.edit')}\n                        </Button>\n                        {user.id !== currentUser?.id && (\n                          <Button\n                            size=\"sm\"\n                            variant=\"ghost\"\n                            onClick={() => handleDelete(user.id)}\n                          >\n                            <Trash2 className=\"w-4 h-4\" />\n                            {t('users.delete')}\n                          </Button>\n                        )}\n                        {advancedAuthStatus?.advanced_auth_enabled && user.email && user.id !== currentUser?.id && (\n                          <Button\n                            size=\"sm\"\n                            variant=\"ghost\"\n                            onClick={() => resetPasswordMutation.mutate(user.id)}\n                            disabled={resetPasswordMutation.isPending}\n                          >\n                            <RotateCcw className=\"w-4 h-4\" />\n                            {t('users.form.resetPassword') || 'Reset Password'}\n                          </Button>\n                        )}\n                      </div>\n                    </td>\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>\n        </Card>\n      )}\n\n      {/* Create User Modal */}\n      {showCreateModal && !advancedAuthStatus?.advanced_auth_enabled && (\n        <div\n          className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n          onClick={() => {\n            setShowCreateModal(false);\n            setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n          }}\n        >\n          <Card\n            className=\"w-full max-w-md\"\n            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n          >\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <UsersIcon className=\"w-5 h-5 text-bambu-green\" />\n                  <h2 className=\"text-lg font-semibold text-white\">{t('users.modal.createUser')}</h2>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => {\n                    setShowCreateModal(false);\n                    setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n                  }}\n                >\n                  <X className=\"w-5 h-5\" />\n                </Button>\n              </div>\n            </CardHeader>\n            <CardContent>\n              <div className=\"space-y-4\">\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.username')}\n                  </label>\n                  <input\n                    type=\"text\"\n                    value={formData.username}\n                    onChange={(e) => setFormData({ ...formData, username: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('users.form.usernamePlaceholder')}\n                    autoComplete=\"username\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.password')}\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={formData.password}\n                    onChange={(e) => setFormData({ ...formData, password: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('users.form.passwordPlaceholder')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.confirmPassword')}\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={formData.confirmPassword}\n                    onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}\n                    className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${\n                      formData.confirmPassword && formData.password !== formData.confirmPassword\n                        ? 'border-red-500'\n                        : 'border-bambu-dark-tertiary'\n                    }`}\n                    placeholder={t('users.form.confirmPasswordPlaceholder')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                  {formData.confirmPassword && formData.password !== formData.confirmPassword && (\n                    <p className=\"text-red-400 text-xs mt-1\">{t('users.toast.passwordsDoNotMatch')}</p>\n                  )}\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.groups')}\n                  </label>\n                  <div className=\"space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\">\n                    {groups.map(group => (\n                      <label\n                        key={group.id}\n                        className=\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer\"\n                      >\n                        <input\n                          type=\"checkbox\"\n                          checked={formData.group_ids.includes(group.id)}\n                          onChange={() => toggleGroup(group.id)}\n                          className=\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark\"\n                        />\n                        <span className=\"text-sm text-white\">{group.name}</span>\n                        {group.is_system && (\n                          <span className=\"text-xs text-yellow-400\">({t('users.system')})</span>\n                        )}\n                      </label>\n                    ))}\n                    {groups.length === 0 && (\n                      <p className=\"text-sm text-bambu-gray\">{t('users.noGroupsAvailable')}</p>\n                    )}\n                  </div>\n                </div>\n              </div>\n              <div className=\"mt-6 flex justify-end gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={() => {\n                    setShowCreateModal(false);\n                    setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n                  }}\n                >\n                  {t('users.modal.cancel')}\n                </Button>\n                <Button\n                  onClick={handleCreate}\n                  disabled={isCreateButtonDisabled}\n                >\n                  {createMutation.isPending ? (\n                    <>\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      {t('users.modal.creating')}\n                    </>\n                  ) : (\n                    <>\n                      <Plus className=\"w-4 h-4\" />\n                      {t('users.modal.createUser')}\n                    </>\n                  )}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n\n      {/* Create User Modal - Advanced Authentication */}\n      {showCreateModal && advancedAuthStatus?.advanced_auth_enabled && (\n        <CreateUserAdvancedAuthModal\n          formData={formData}\n          setFormData={setFormData}\n          groups={groups}\n          onClose={() => {\n            setShowCreateModal(false);\n            setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });\n          }}\n          onCreate={handleCreate}\n          isCreating={createMutation.isPending}\n          isCreateButtonDisabled={isCreateButtonDisabled}\n        />\n      )}\n\n      {/* Edit User Modal */}\n      {showEditModal && editingUserId !== null && (\n        <div\n          className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\"\n          onClick={closeEditModal}\n        >\n          <Card\n            className=\"w-full max-w-md\"\n            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n          >\n            <CardHeader>\n              <div className=\"flex items-center justify-between\">\n                <div className=\"flex items-center gap-2\">\n                  <Edit2 className=\"w-5 h-5 text-bambu-green\" />\n                  <h2 className=\"text-lg font-semibold text-white\">{t('users.modal.editUser')}</h2>\n                </div>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={closeEditModal}\n                >\n                  <X className=\"w-5 h-5\" />\n                </Button>\n              </div>\n            </CardHeader>\n            <CardContent>\n              <div className=\"space-y-4\">\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.username')}\n                  </label>\n                  <input\n                    type=\"text\"\n                    value={formData.username}\n                    onChange={(e) => setFormData({ ...formData, username: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('users.form.usernamePlaceholder')}\n                    autoComplete=\"username\"\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.email') || 'Email'} <span className=\"text-bambu-gray font-normal\">({t('users.form.optional') || 'optional'})</span>\n                  </label>\n                  <input\n                    type=\"email\"\n                    value={formData.email}\n                    onChange={(e) => setFormData({ ...formData, email: e.target.value })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}\n                  />\n                </div>\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.password')} <span className=\"text-bambu-gray font-normal\">({t('users.form.leaveBlankToKeep')})</span>\n                  </label>\n                  <input\n                    type=\"password\"\n                    value={formData.password}\n                    onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}\n                    className=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"\n                    placeholder={t('users.form.newPasswordPlaceholder')}\n                    autoComplete=\"new-password\"\n                    minLength={6}\n                  />\n                </div>\n                {formData.password && (\n                  <div>\n                    <label className=\"block text-sm font-medium text-white mb-2\">\n                      {t('users.form.confirmPassword')}\n                    </label>\n                    <input\n                      type=\"password\"\n                      value={formData.confirmPassword}\n                      onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}\n                      className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${\n                        formData.confirmPassword && formData.password !== formData.confirmPassword\n                          ? 'border-red-500'\n                          : 'border-bambu-dark-tertiary'\n                      }`}\n                      placeholder={t('users.form.confirmNewPasswordPlaceholder')}\n                      autoComplete=\"new-password\"\n                      minLength={6}\n                    />\n                    {formData.confirmPassword && formData.password !== formData.confirmPassword && (\n                      <p className=\"text-red-400 text-xs mt-1\">{t('users.toast.passwordsDoNotMatch')}</p>\n                    )}\n                  </div>\n                )}\n                <div>\n                  <label className=\"block text-sm font-medium text-white mb-2\">\n                    {t('users.form.groups')}\n                  </label>\n                  <div className=\"space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\">\n                    {groups.map(group => (\n                      <label\n                        key={group.id}\n                        className=\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer\"\n                      >\n                        <input\n                          type=\"checkbox\"\n                          checked={formData.group_ids.includes(group.id)}\n                          onChange={() => toggleGroup(group.id)}\n                          className=\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark\"\n                        />\n                        <span className=\"text-sm text-white\">{group.name}</span>\n                        {group.is_system && (\n                          <span className=\"text-xs text-yellow-400\">({t('users.system')})</span>\n                        )}\n                      </label>\n                    ))}\n                    {groups.length === 0 && (\n                      <p className=\"text-sm text-bambu-gray\">{t('users.noGroupsAvailable')}</p>\n                    )}\n                  </div>\n                </div>\n              </div>\n              <div className=\"mt-6 flex justify-end gap-3\">\n                <Button\n                  variant=\"secondary\"\n                  onClick={closeEditModal}\n                >\n                  {t('users.modal.cancel')}\n                </Button>\n                <Button\n                  onClick={() => handleUpdate(editingUserId)}\n                  disabled={updateMutation.isPending || !formData.username || !!(formData.password && (formData.password !== formData.confirmPassword || formData.password.length < 6))}\n                >\n                  {updateMutation.isPending ? (\n                    <>\n                      <Loader2 className=\"w-4 h-4 animate-spin\" />\n                      {t('users.modal.saving')}\n                    </>\n                  ) : (\n                    <>\n                      <Save className=\"w-4 h-4\" />\n                      {t('users.modal.saveChanges')}\n                    </>\n                  )}\n                </Button>\n              </div>\n            </CardContent>\n          </Card>\n        </div>\n      )}\n\n      {/* Delete Confirmation Modal */}\n      {deleteUserId !== null && (\n        <ConfirmModal\n          title={t('users.deleteModal.title')}\n          message={t('users.deleteModal.message')}\n          confirmText={t('users.deleteModal.confirm')}\n          variant=\"danger\"\n          onConfirm={() => {\n            deleteMutation.mutate(deleteUserId);\n            setDeleteUserId(null);\n          }}\n          onCancel={() => setDeleteUserId(null)}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx",
    "content": "import { useState, useEffect, useMemo, useCallback, useRef } from 'react';\nimport { useOutletContext } from 'react-router-dom';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';\nimport type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';\nimport { api } from '../../api/client';\nimport type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';\nimport { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel } from '../../utils/amsHelpers';\nimport { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';\nimport type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';\nimport { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';\nimport { AssignSpoolModal } from '../../components/AssignSpoolModal';\nimport { LinkSpoolModal } from '../../components/LinkSpoolModal';\nimport { useToast } from '../../contexts/ToastContext';\n\nfunction getAmsName(amsId: number): string {\n  if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;\n  if (amsId >= 128 && amsId <= 135) return `AMS HT ${String.fromCharCode(65 + amsId - 128)}`;\n  return `AMS ${amsId}`;\n}\n\nfunction mapModelCode(ssdpModel: string | null): string {\n  if (!ssdpModel) return '';\n  const modelMap: Record<string, string> = {\n    'O1D': 'H2D', 'O1E': 'H2D Pro', 'O2D': 'H2D Pro', 'O1C': 'H2C', 'O1C2': 'H2C', 'O1S': 'H2S',\n    'BL-P001': 'X1C', 'BL-P002': 'X1', 'BL-P003': 'X1E',\n    'N6': 'X2D',\n    'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S',\n    'N2S': 'A1', 'N1': 'A1 Mini',\n    'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'X2D': 'X2D', 'P1S': 'P1S', 'P1P': 'P1P', 'P2S': 'P2S',\n    'A1': 'A1', 'A1 Mini': 'A1 Mini', 'H2D': 'H2D', 'H2D Pro': 'H2D Pro', 'H2C': 'H2C', 'H2S': 'H2S',\n  };\n  return modelMap[ssdpModel] || ssdpModel;\n}\n\nfunction isTrayEmpty(tray: AMSTray): boolean {\n  return !tray.tray_type || tray.tray_type === '';\n}\n\nfunction trayColorToCSS(color: string | null): string {\n  if (!color) return '#808080';\n  return `#${color.slice(0, 6)}`;\n}\n\nexport function SpoolBuddyAmsPage() {\n  const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();\n  const { t } = useTranslation();\n  const queryClient = useQueryClient();\n  const { showToast } = useToast();\n\n  const { data: status } = useQuery<PrinterStatus>({\n    queryKey: ['printerStatus', selectedPrinterId],\n    queryFn: () => api.getPrinterStatus(selectedPrinterId!),\n    enabled: selectedPrinterId !== null,\n    staleTime: 30 * 1000,\n  });\n\n  const { data: printer } = useQuery({\n    queryKey: ['printer', selectedPrinterId],\n    queryFn: () => api.getPrinter(selectedPrinterId!),\n    enabled: selectedPrinterId !== null,\n    staleTime: 60 * 1000,\n  });\n\n  const { data: slotPresets } = useQuery({\n    queryKey: ['slotPresets', selectedPrinterId],\n    queryFn: () => api.getSlotPresets(selectedPrinterId!),\n    enabled: selectedPrinterId !== null,\n    staleTime: 2 * 60 * 1000,\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: () => api.getSettings(),\n    staleTime: 5 * 60 * 1000,\n  });\n\n  // Fetch Spoolman status to enable fill-level chain\n  const { data: spoolmanStatus } = useQuery({\n    queryKey: ['spoolman-status'],\n    queryFn: api.getSpoolmanStatus,\n    staleTime: 60 * 1000,\n  });\n  const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected;\n\n  // Fetch linked spools map (tag -> spool info) for Spoolman fill levels\n  const { data: linkedSpoolsData } = useQuery({\n    queryKey: ['linked-spools'],\n    queryFn: api.getLinkedSpools,\n    enabled: !!spoolmanEnabled,\n    staleTime: 30 * 1000,\n  });\n  const linkedSpools = linkedSpoolsData?.linked;\n\n  const { data: assignments } = useQuery({\n    queryKey: ['spool-assignments', selectedPrinterId],\n    queryFn: () => api.getAssignments(selectedPrinterId!),\n    enabled: selectedPrinterId !== null,\n    staleTime: 30 * 1000,\n  });\n\n  // Build fill-level override map from inventory assignments\n  // Key: \"amsId-trayId\", Value: fill percentage (0-100)\n  const fillOverrides = useMemo(() => {\n    const map: Record<string, number> = {};\n    if (!assignments) return map;\n    for (const a of assignments) {\n      const sp = a.spool;\n      if (sp && sp.label_weight > 0 && sp.weight_used != null) {\n        const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);\n        map[`${a.ams_id}-${a.tray_id}`] = fill;\n      }\n    }\n    return map;\n  }, [assignments]);\n\n  // Look up Spoolman fill level for a given tray\n  const printerSerial = printer?.serial_number ?? '';\n  const getSpoolmanFillForSlot = useCallback((amsId: number, trayId: number, tray: AMSTray | null): number | null => {\n    if (!linkedSpools || !printerSerial) return null;\n    const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();\n    const linkedSpool = tag ? linkedSpools[tag] : undefined;\n    return getSpoolmanFillLevel(linkedSpool);\n  }, [linkedSpools, printerSerial]);\n\n  const isConnected = status?.connected ?? false;\n\n  // Cache AMS data per printer to prevent it disappearing on idle/offline printers\n  const cachedAmsData = useRef<Record<number, PrinterStatus['ams']>>({});\n  useEffect(() => {\n    if (selectedPrinterId && status?.ams && status.ams.length > 0) {\n      cachedAmsData.current[selectedPrinterId] = status.ams;\n    }\n  }, [status?.ams, selectedPrinterId]);\n  const amsUnits = useMemo(() => {\n    const live = status?.ams;\n    if (live && live.length > 0) return live;\n    return (selectedPrinterId ? cachedAmsData.current[selectedPrinterId] : null) ?? [];\n  }, [status?.ams, selectedPrinterId]);\n  const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);\n  const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);\n\n  // Build Spoolman fill-level override map for regular AMS cards\n  const spoolmanFillOverrides = useMemo(() => {\n    const map: Record<string, number> = {};\n    if (!linkedSpools || !printerSerial) return map;\n    for (const unit of regularAms) {\n      for (let i = 0; i < (unit.tray?.length ?? 0); i++) {\n        const tray = unit.tray![i];\n        const fill = getSpoolmanFillForSlot(unit.id, i, isTrayEmpty(tray) ? null : tray);\n        if (fill !== null) map[`${unit.id}-${i}`] = fill;\n      }\n    }\n    return map;\n  }, [linkedSpools, printerSerial, regularAms, getSpoolmanFillForSlot]);\n\n  // Cache tray_now to prevent flickering when undefined values come in\n  // Valid tray IDs: 0-253 for AMS, 254 for external spool\n  // tray_now=255 means \"no tray loaded\" (Bambu protocol sentinel) — never active\n  const cachedTrayNow = useRef<number | undefined>(undefined);\n  const currentTrayNow = status?.tray_now;\n  if (currentTrayNow !== undefined && currentTrayNow !== 255) {\n    cachedTrayNow.current = currentTrayNow;\n  } else if (currentTrayNow === 255) {\n    cachedTrayNow.current = undefined;\n  }\n  const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255)\n    ? currentTrayNow\n    : cachedTrayNow.current;\n  const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;\n  const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);\n\n  const amsThresholds: AmsThresholds | undefined = settings ? {\n    humidityGood: Number(settings.ams_humidity_good) || 40,\n    humidityFair: Number(settings.ams_humidity_fair) || 60,\n    tempGood: Number(settings.ams_temp_good) || 28,\n    tempFair: Number(settings.ams_temp_fair) || 35,\n  } : undefined;\n\n  // Cache ams_extruder_map to prevent L/R indicators bouncing on updates\n  const cachedAmsExtruderMap = useRef<Record<string, number>>({});\n  useEffect(() => {\n    if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {\n      cachedAmsExtruderMap.current = status.ams_extruder_map;\n    }\n  }, [status?.ams_extruder_map]);\n  const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)\n    ? status.ams_extruder_map\n    : cachedAmsExtruderMap.current;\n\n  const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {\n    if (!isDualNozzle) return null;\n    const mappedExtruderId = amsExtruderMap[String(amsId)];\n    const normalizedId = amsId >= 128 ? amsId - 128 : amsId;\n    const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;\n    // extruder 0 = right, 1 = left\n    return extruderId === 1 ? 'L' : 'R';\n  }, [isDualNozzle, amsExtruderMap]);\n\n  const [configureSlotModal, setConfigureSlotModal] = useState<{\n    amsId: number;\n    trayId: number;\n    trayCount: number;\n    trayType?: string;\n    trayColor?: string;\n    traySubBrands?: string;\n    trayInfoIdx?: string;\n    extruderId?: number;\n    caliIdx?: number | null;\n    savedPresetId?: string;\n  } | null>(null);\n\n  // Slot action picker: shown before opening configure or assign modal\n  const [slotActionPicker, setSlotActionPicker] = useState<{\n    amsId: number;\n    trayId: number;\n    trayCount: number;\n    tray: AMSTray | null;\n    trayType?: string;\n    trayColor?: string;\n    traySubBrands?: string;\n    trayInfoIdx?: string;\n    extruderId?: number;\n    caliIdx?: number | null;\n    savedPresetId?: string;\n    location: string;\n  } | null>(null);\n\n  // Assign spool modal state (inventory)\n  const [assignSpoolModal, setAssignSpoolModal] = useState<{\n    printerId: number;\n    amsId: number;\n    trayId: number;\n    trayInfo: { type: string; material?: string; profile?: string; color: string; location: string };\n  } | null>(null);\n\n  // Link spool modal state (Spoolman)\n  const [linkSpoolModal, setLinkSpoolModal] = useState<{\n    tagUid: string;\n    trayUuid: string;\n    printerId: number;\n    amsId: number;\n    trayId: number;\n  } | null>(null);\n\n  const getAssignment = useCallback((amsId: number, trayId: number): SpoolAssignment | undefined => {\n    return assignments?.find(a => a.ams_id === Number(amsId) && a.tray_id === Number(trayId));\n  }, [assignments]);\n\n  const getLinkedSpool = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {\n    if (!linkedSpools || !printerSerial) return undefined;\n    const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();\n    return tag ? linkedSpools[tag] : undefined;\n  }, [linkedSpools, printerSerial]);\n\n  const unassignMutation = useMutation({\n    mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>\n      api.unassignSpool(printerId, amsId, trayId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });\n      showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');\n      setSlotActionPicker(null);\n    },\n  });\n\n  const unlinkSpoolMutation = useMutation({\n    mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),\n    onSuccess: (result) => {\n      showToast(t('spoolman.unlinkSuccess') || result?.message, 'success');\n      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });\n      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });\n      setSlotActionPicker(null);\n    },\n    onError: (error: Error) => {\n      showToast(error.message || t('spoolman.unlinkFailed'), 'error');\n    },\n  });\n\n  const getActiveSlotForAms = useCallback((amsId: number): number | null => {\n    if (effectiveTrayNow === undefined) return null;\n    if (amsId <= 3) {\n      const activeAmsId = Math.floor(effectiveTrayNow / 4);\n      if (activeAmsId === amsId) return effectiveTrayNow % 4;\n    }\n    if (amsId >= 128 && amsId <= 135) {\n      // AMS-HT: global tray ID equals the AMS unit ID itself (128, 129, ...)\n      if (effectiveTrayNow === getGlobalTrayId(amsId, 0, false)) return 0;\n    }\n    return null;\n  }, [effectiveTrayNow]);\n\n  const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {\n    const globalTrayId = getGlobalTrayId(amsId, trayId, false);\n    const slotPreset = slotPresets?.[globalTrayId];\n    const mappedExtruderId = amsExtruderMap[String(amsId)];\n    const normalizedId = amsId >= 128 ? amsId - 128 : amsId;\n    const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;\n    const slotData = {\n      amsId,\n      trayId,\n      trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,\n      tray,\n      trayType: tray?.tray_type || undefined,\n      trayColor: tray?.tray_color || undefined,\n      traySubBrands: tray?.tray_sub_brands || undefined,\n      trayInfoIdx: tray?.tray_info_idx || undefined,\n      extruderId: isDualNozzle ? extruderId : undefined,\n      caliIdx: tray?.cali_idx,\n      savedPresetId: slotPreset?.preset_id,\n      location: `${getAmsName(amsId)} Slot ${trayId + 1}`,\n    };\n\n    setSlotActionPicker(slotData);\n  }, [slotPresets, amsExtruderMap, isDualNozzle]);\n\n  const handleExtSlotClick = useCallback((extTray: AMSTray) => {\n    const extTrayId = extTray.id ?? 254;\n    const slotTrayId = extTrayId - 254;\n    const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];\n    const slotData = {\n      amsId: 255,\n      trayId: slotTrayId,\n      trayCount: 1,\n      tray: isTrayEmpty(extTray) ? null : extTray,\n      trayType: extTray.tray_type || undefined,\n      trayColor: extTray.tray_color || undefined,\n      traySubBrands: extTray.tray_sub_brands || undefined,\n      trayInfoIdx: extTray.tray_info_idx || undefined,\n      extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,\n      caliIdx: extTray.cali_idx,\n      savedPresetId: extSlotPreset?.preset_id,\n      location: isDualNozzle\n        ? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')\n        : 'External',\n    };\n\n    setSlotActionPicker(slotData);\n  }, [slotPresets, isDualNozzle]);\n\n  const openConfigureFromPicker = useCallback(() => {\n    if (!slotActionPicker) return;\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    const { tray, location, ...configData } = slotActionPicker;\n    setSlotActionPicker(null);\n    setConfigureSlotModal(configData);\n  }, [slotActionPicker]);\n\n  const openAssignFromPicker = useCallback(() => {\n    if (!slotActionPicker || !selectedPrinterId) return;\n    const { amsId, trayId, trayType, trayColor, location } = slotActionPicker;\n    setSlotActionPicker(null);\n    setAssignSpoolModal({\n      printerId: selectedPrinterId,\n      amsId,\n      trayId,\n      trayInfo: {\n        type: trayType || '',\n        material: trayType,\n        color: trayColor?.slice(0, 6) || '',\n        location,\n      },\n    });\n  }, [slotActionPicker, selectedPrinterId]);\n\n  const openLinkFromPicker = useCallback(() => {\n    if (!slotActionPicker || !selectedPrinterId) return;\n    const { amsId, trayId, tray } = slotActionPicker;\n    const linkTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase() || '';\n    setSlotActionPicker(null);\n    setLinkSpoolModal({\n      tagUid: tray?.tag_uid || linkTag,\n      trayUuid: tray?.tray_uuid || '',\n      printerId: selectedPrinterId,\n      amsId,\n      trayId,\n    });\n  }, [slotActionPicker, selectedPrinterId, printerSerial]);\n\n  const handleUnassignFromPicker = useCallback(() => {\n    if (!slotActionPicker || !selectedPrinterId) return;\n    const { amsId, trayId } = slotActionPicker;\n    unassignMutation.mutate({ printerId: selectedPrinterId, amsId, trayId });\n  }, [slotActionPicker, selectedPrinterId, unassignMutation]);\n\n  // Set alert for low filament in status bar\n  useEffect(() => {\n    if (!isConnected && selectedPrinterId) {\n      setAlert({ type: 'warning', message: t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected') });\n      return;\n    }\n    for (const unit of amsUnits) {\n      const trays = unit.tray || [];\n      for (let i = 0; i < trays.length; i++) {\n        const tray = trays[i];\n        if (tray.remain !== null && tray.remain >= 0 && tray.remain < 15 && tray.tray_type) {\n          const isExternal = unit.id === 254 || unit.id === 255;\n          const isHt = !isExternal && unit.id >= 128;\n          const slot = formatSlotLabel(unit.id, i, isHt, isExternal);\n          setAlert({\n            type: 'warning',\n            message: `Low Filament: ${tray.tray_type} (${slot}) - ${tray.remain}% remaining`,\n          });\n          return;\n        }\n      }\n    }\n    setAlert(null);\n  }, [amsUnits, isConnected, selectedPrinterId, setAlert, t]);\n\n  // Build list of single-slot items (AMS-HT + External) for compact rendering\n  const singleSlots = useMemo(() => {\n    const items: {\n      key: string; label: string; tray: AMSTray; isEmpty: boolean; isActive: boolean;\n      temp?: number | null; humidity?: number | null; nozzleSide?: 'L' | 'R' | null;\n      effectiveFill: number | null;\n      onClick: () => void;\n    }[] = [];\n\n    for (const unit of htAms) {\n      const tray = unit.tray?.[0] || {\n        id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,\n        tray_id_name: null, tray_info_idx: null, remain: -1, k: null,\n        cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,\n      };\n      // Fill level fallback chain: Spoolman → Inventory → AMS remain\n      const spoolmanFill = getSpoolmanFillForSlot(unit.id, 0, isTrayEmpty(tray) ? null : tray);\n      const invFill = fillOverrides[`${unit.id}-0`] ?? null;\n      const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;\n      // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)\n      const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;\n      items.push({\n        key: `ht-${unit.id}`,\n        label: getAmsName(unit.id),\n        tray,\n        isEmpty: isTrayEmpty(tray),\n        isActive: getActiveSlotForAms(unit.id) === 0,\n        temp: unit.temp,\n        humidity: unit.humidity,\n        nozzleSide: getNozzleSide(unit.id),\n        effectiveFill: spoolmanFill ?? resolvedInvFill ?? amsFill,\n        onClick: () => handleAmsSlotClick(unit.id, 0, isTrayEmpty(tray) ? null : tray),\n      });\n    }\n\n    for (const extTray of vtTrays) {\n      const extTrayId = extTray.id ?? 254;\n      // On dual-nozzle (H2C/H2D), tray_now=254 means \"external spool\"\n      // generically — use active_extruder to determine L vs R:\n      // extruder 1=left → Ext-L (id=254), extruder 0=right → Ext-R (id=255)\n      const isExtActive = isDualNozzle && effectiveTrayNow === 254\n        ? (extTrayId === 254 && status?.active_extruder === 1) ||\n          (extTrayId === 255 && status?.active_extruder === 0)\n        : effectiveTrayNow === extTrayId;\n      const extSlotTrayId = extTrayId - 254;\n      // Fill level fallback chain: Spoolman → Inventory → AMS remain\n      const extSpoolmanFill = getSpoolmanFillForSlot(255, extSlotTrayId, isTrayEmpty(extTray) ? null : extTray);\n      const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;\n      const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;\n      // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)\n      const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;\n      items.push({\n        key: `ext-${extTrayId}`,\n        label: isDualNozzle\n          ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))\n          : t('printers.ext', 'Ext'),\n        tray: extTray,\n        isEmpty: isTrayEmpty(extTray),\n        isActive: isExtActive,\n        nozzleSide: null,\n        effectiveFill: extSpoolmanFill ?? extResolvedInvFill ?? extAmsFill,\n        onClick: () => handleExtSlotClick(extTray),\n      });\n    }\n\n    return items;\n  }, [htAms, vtTrays, isDualNozzle, effectiveTrayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides, getSpoolmanFillForSlot]);\n\n  return (\n    <div className=\"h-full flex flex-col p-4\">\n      <div className=\"flex-1 min-h-0\">\n        {!selectedPrinterId ? (\n          <div className=\"flex-1 flex items-center justify-center h-full\">\n            <div className=\"text-center text-white/50\">\n              <p className=\"text-lg mb-2\">{t('spoolbuddy.ams.noPrinter', 'No printer selected')}</p>\n              <p className=\"text-sm\">{t('spoolbuddy.ams.selectPrinter', 'Select a printer from the top bar')}</p>\n            </div>\n          </div>\n        ) : !isConnected ? (\n          <div className=\"flex-1 flex items-center justify-center h-full\">\n            <div className=\"text-center text-white/50\">\n              <p className=\"text-lg mb-2\">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>\n            </div>\n          </div>\n        ) : amsUnits.length === 0 && vtTrays.length === 0 ? (\n          <div className=\"flex-1 flex items-center justify-center h-full\">\n            <div className=\"text-center text-white/50\">\n              <Layers className=\"w-12 h-12 mx-auto mb-3 opacity-50\" />\n              <p className=\"text-lg mb-2\">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>\n              <p className=\"text-sm\">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex flex-col gap-3 h-full\">\n            {/* Regular AMS cards — 4-slot, 2-col grid */}\n            <div className=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n              {regularAms.map((unit) => (\n                <AmsUnitCard\n                  key={unit.id}\n                  unit={unit}\n                  activeSlot={getActiveSlotForAms(unit.id)}\n                  onConfigureSlot={handleAmsSlotClick}\n                  isDualNozzle={isDualNozzle}\n                  nozzleSide={getNozzleSide(unit.id)}\n                  thresholds={amsThresholds}\n                  fillOverrides={fillOverrides}\n                  spoolmanFillOverrides={spoolmanFillOverrides}\n                />\n              ))}\n            </div>\n\n            {/* Third row: single-slot cards (AMS-HT + External) — half-width to align with AMS cards */}\n            {singleSlots.length > 0 && (\n              <div className=\"grid grid-cols-2 md:grid-cols-4 gap-3\">\n                {singleSlots.map(({ key, label, tray, isEmpty, isActive, temp, humidity, nozzleSide, effectiveFill, onClick }) => {\n                  const color = trayColorToCSS(tray.tray_color);\n                  return (\n                    <div\n                      key={key}\n                      className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-3 ${isActive ? 'ring-2 ring-bambu-green' : ''}`}\n                      onClick={onClick}\n                    >\n                      {/* Spool */}\n                      <div className=\"relative w-10 h-10 flex-shrink-0\">\n                        {isEmpty ? (\n                          <div className=\"w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center\">\n                            <div className=\"w-1.5 h-1.5 rounded-full bg-gray-600\" />\n                          </div>\n                        ) : (\n                          <svg viewBox=\"0 0 56 56\" className=\"w-full h-full\">\n                            <circle cx=\"28\" cy=\"28\" r=\"26\" fill={color} />\n                            <circle cx=\"28\" cy=\"28\" r=\"20\" fill={color} style={{ filter: 'brightness(0.85)' }} />\n                            <ellipse cx=\"20\" cy=\"20\" rx=\"6\" ry=\"4\" fill=\"white\" opacity=\"0.3\" />\n                            <circle cx=\"28\" cy=\"28\" r=\"8\" fill=\"#2d2d2d\" />\n                            <circle cx=\"28\" cy=\"28\" r=\"5\" fill=\"#1a1a1a\" />\n                          </svg>\n                        )}\n                        {isActive && (\n                          <div className=\"absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-bambu-green rounded-full\" />\n                        )}\n                      </div>\n                      {/* Info */}\n                      <div className=\"min-w-0\">\n                        <div className=\"flex items-center gap-1\">\n                          <span className=\"text-xs text-white/50 font-medium truncate\">{label}</span>\n                          {nozzleSide && <NozzleBadge side={nozzleSide} />}\n                        </div>\n                        <div className=\"text-sm text-white/80 truncate\">\n                          {isEmpty ? 'Empty' : tray.tray_type || '?'}\n                        </div>\n                        {(temp != null || humidity != null) && (\n                          <div className=\"flex items-center gap-1.5\">\n                            {temp != null && (\n                              <TemperatureIndicator\n                                temp={temp}\n                                goodThreshold={amsThresholds?.tempGood}\n                                fairThreshold={amsThresholds?.tempFair}\n                              />\n                            )}\n                            {humidity != null && (\n                              <HumidityIndicator\n                                humidity={humidity}\n                                goodThreshold={amsThresholds?.humidityGood}\n                                fairThreshold={amsThresholds?.humidityFair}\n                              />\n                            )}\n                          </div>\n                        )}\n                      </div>\n                      {/* Fill bar */}\n                      {!isEmpty && effectiveFill != null && effectiveFill >= 0 && (\n                        <div className=\"w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden flex-shrink-0 flex flex-col-reverse\">\n                          <div\n                            className=\"w-full rounded-full\"\n                            style={{\n                              height: `${effectiveFill}%`,\n                              backgroundColor: getFillBarColor(effectiveFill),\n                            }}\n                          />\n                        </div>\n                      )}\n                    </div>\n                  );\n                })}\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n\n      {configureSlotModal && selectedPrinterId && (\n        <ConfigureAmsSlotModal\n          isOpen={!!configureSlotModal}\n          onClose={() => setConfigureSlotModal(null)}\n          printerId={selectedPrinterId}\n          slotInfo={configureSlotModal}\n          printerModel={mapModelCode(printer?.model ?? null) || undefined}\n          fullScreen\n          onSuccess={() => {\n            queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] });\n            queryClient.invalidateQueries({ queryKey: ['printerStatus', selectedPrinterId] });\n          }}\n        />\n      )}\n\n      {/* Slot action picker */}\n      {slotActionPicker && selectedPrinterId && (() => {\n        const assignment = getAssignment(slotActionPicker.amsId, slotActionPicker.trayId);\n        const linked = getLinkedSpool(slotActionPicker.amsId, slotActionPicker.trayId, slotActionPicker.tray);\n        return (\n          <div className=\"fixed inset-0 z-50 flex items-center justify-center\">\n            <div\n              className=\"absolute inset-0 bg-black/60 backdrop-blur-sm\"\n              onClick={() => setSlotActionPicker(null)}\n            />\n            <div className=\"relative w-full max-w-sm mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl\">\n              <div className=\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\">\n                <div className=\"flex items-center gap-2\">\n                  {slotActionPicker.trayColor && (\n                    <span\n                      className=\"w-4 h-4 rounded-full border border-black/20\"\n                      style={{ backgroundColor: `#${slotActionPicker.trayColor.slice(0, 6)}` }}\n                    />\n                  )}\n                  <h2 className=\"text-lg font-semibold text-white\">{slotActionPicker.location}</h2>\n                  {slotActionPicker.traySubBrands && (\n                    <span className=\"text-sm text-bambu-gray\">({slotActionPicker.traySubBrands})</span>\n                  )}\n                </div>\n                <button\n                  onClick={() => setSlotActionPicker(null)}\n                  className=\"p-1 text-bambu-gray hover:text-white rounded transition-colors\"\n                >\n                  <X className=\"w-5 h-5\" />\n                </button>\n              </div>\n              <div className=\"p-4 space-y-2\">\n                {/* Currently assigned/linked spool info */}\n                {!spoolmanEnabled && assignment?.spool && (\n                  <div className=\"p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3\">\n                    <p className=\"text-xs text-bambu-gray mb-1\">{t('inventory.assignedSpool', 'Assigned spool')}</p>\n                    <div className=\"flex items-center gap-2\">\n                      {assignment.spool.rgba && (\n                        <span\n                          className=\"w-3 h-3 rounded-full border border-black/20 flex-shrink-0\"\n                          style={{ backgroundColor: `#${assignment.spool.rgba.substring(0, 6)}` }}\n                        />\n                      )}\n                      <span className=\"text-sm text-white\">\n                        {assignment.spool.brand ? `${assignment.spool.brand} ` : ''}{assignment.spool.material}\n                        {assignment.spool.color_name ? ` - ${assignment.spool.color_name}` : ''}\n                      </span>\n                    </div>\n                  </div>\n                )}\n                {spoolmanEnabled && linked && (\n                  <div className=\"p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3\">\n                    <p className=\"text-xs text-bambu-gray mb-1\">{t('spoolman.linkedSpool', 'Linked spool')}</p>\n                    <div className=\"flex items-center gap-2\">\n                      <span className=\"text-sm text-white\">\n                        Spoolman #{linked.id}\n                        {linked.remaining_weight != null ? ` (${Math.round(linked.remaining_weight)}g)` : ''}\n                      </span>\n                    </div>\n                  </div>\n                )}\n\n                <button\n                  onClick={openConfigureFromPicker}\n                  className=\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-blue transition-colors text-left\"\n                >\n                  <Settings2 className=\"w-5 h-5 text-bambu-blue flex-shrink-0\" />\n                  <div>\n                    <p className=\"text-white font-medium\">{t('configureAmsSlot.title')}</p>\n                    <p className=\"text-xs text-bambu-gray\">{t('spoolbuddy.ams.configureDesc', 'Set filament preset, K-profile, and color')}</p>\n                  </div>\n                </button>\n\n                {/* Inventory: Assign or Unassign */}\n                {!spoolmanEnabled && (assignment ? (\n                  <button\n                    onClick={handleUnassignFromPicker}\n                    disabled={unassignMutation.isPending}\n                    className=\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left\"\n                  >\n                    <Unlink className=\"w-5 h-5 text-amber-400 flex-shrink-0\" />\n                    <div>\n                      <p className=\"text-amber-400 font-medium\">{t('inventory.unassignSpool', 'Unassign')}</p>\n                      <p className=\"text-xs text-bambu-gray\">{t('spoolbuddy.ams.unassignDesc', 'Remove inventory spool from this slot')}</p>\n                    </div>\n                  </button>\n                ) : (\n                  <button\n                    onClick={openAssignFromPicker}\n                    className=\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left\"\n                  >\n                    <Package className=\"w-5 h-5 text-bambu-green flex-shrink-0\" />\n                    <div>\n                      <p className=\"text-white font-medium\">{t('inventory.assignSpool')}</p>\n                      <p className=\"text-xs text-bambu-gray\">{t('spoolbuddy.ams.assignDesc', 'Track a spool from your inventory')}</p>\n                    </div>\n                  </button>\n                ))}\n\n                {/* Spoolman: Link or Unlink */}\n                {spoolmanEnabled && (linked?.id ? (\n                  <button\n                    onClick={() => unlinkSpoolMutation.mutate(linked.id)}\n                    disabled={unlinkSpoolMutation.isPending}\n                    className=\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left\"\n                  >\n                    <Unlink className=\"w-5 h-5 text-amber-400 flex-shrink-0\" />\n                    <div>\n                      <p className=\"text-amber-400 font-medium\">{t('spoolman.unlinkSpool')}</p>\n                      <p className=\"text-xs text-bambu-gray\">{t('spoolbuddy.ams.unlinkDesc', 'Remove Spoolman link from this slot')}</p>\n                    </div>\n                  </button>\n                ) : (\n                  <button\n                    onClick={openLinkFromPicker}\n                    className=\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left\"\n                  >\n                    <Link2 className=\"w-5 h-5 text-bambu-green flex-shrink-0\" />\n                    <div>\n                      <p className=\"text-white font-medium\">{t('spoolman.linkToSpoolman')}</p>\n                      <p className=\"text-xs text-bambu-gray\">{t('spoolbuddy.ams.linkDesc', 'Link a Spoolman spool to this slot')}</p>\n                    </div>\n                  </button>\n                ))}\n              </div>\n            </div>\n          </div>\n        );\n      })()}\n\n      {/* Assign spool modal (inventory) */}\n      {assignSpoolModal && (\n        <AssignSpoolModal\n          isOpen={!!assignSpoolModal}\n          onClose={() => {\n            setAssignSpoolModal(null);\n            queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });\n          }}\n          printerId={assignSpoolModal.printerId}\n          amsId={assignSpoolModal.amsId}\n          trayId={assignSpoolModal.trayId}\n          trayInfo={assignSpoolModal.trayInfo}\n        />\n      )}\n\n      {/* Link spool modal (Spoolman) */}\n      {linkSpoolModal && (\n        <LinkSpoolModal\n          isOpen={!!linkSpoolModal}\n          onClose={() => setLinkSpoolModal(null)}\n          tagUid={linkSpoolModal.tagUid}\n          trayUuid={linkSpoolModal.trayUuid}\n          printerId={linkSpoolModal.printerId}\n          amsId={linkSpoolModal.amsId}\n          trayId={linkSpoolModal.trayId}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/spoolbuddy/SpoolBuddyCalibrationPage.tsx",
    "content": "import { useState } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { useNavigate, useOutletContext } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';\nimport { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';\n\nfunction ScaleCalibration({ device, weight, weightStable, rawAdc }: {\n  device: SpoolBuddyDevice;\n  weight: number | null;\n  weightStable: boolean;\n  rawAdc: number | null;\n}) {\n  const { t } = useTranslation();\n  const [calibrating, setCalibrating] = useState(false);\n  const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');\n  const [knownWeight, setKnownWeight] = useState('500');\n  const [tareRawAdc, setTareRawAdc] = useState<number | null>(null);\n  const [taring, setTaring] = useState(false);\n\n  const numpadPress = (key: string) => {\n    if (key === 'backspace') {\n      setKnownWeight((v) => v.slice(0, -1) || '');\n    } else if (key === '.' && !knownWeight.includes('.')) {\n      setKnownWeight((v) => v + '.');\n    } else if (key >= '0' && key <= '9') {\n      setKnownWeight((v) => (v === '0' ? key : v + key));\n    }\n  };\n\n  const handleTare = async () => {\n    setTaring(true);\n    try {\n      await spoolbuddyApi.tare(device.device_id);\n    } catch (e) {\n      console.error('Failed to tare:', e);\n    } finally {\n      setTaring(false);\n    }\n  };\n\n  const startCalibration = () => {\n    setCalStep('tare');\n  };\n\n  const handleCalStep = async () => {\n    if (calStep === 'tare') {\n      setCalibrating(true);\n      try {\n        // Capture raw ADC before taring — this is our zero reference\n        setTareRawAdc(rawAdc);\n        await spoolbuddyApi.tare(device.device_id);\n        setCalStep('weight');\n      } catch (e) {\n        console.error('Failed to tare:', e);\n      } finally {\n        setCalibrating(false);\n      }\n    } else if (calStep === 'weight') {\n      const weightNum = parseFloat(knownWeight);\n      if (rawAdc === null || !weightNum || weightNum <= 0) return;\n      setCalibrating(true);\n      try {\n        await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined);\n        setCalStep('idle');\n      } catch (e) {\n        console.error('Failed to calibrate:', e);\n      } finally {\n        setCalibrating(false);\n      }\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Current weight */}\n      <div className=\"bg-zinc-800 rounded-lg p-4\">\n        <div className=\"flex items-center justify-between\">\n          <span className=\"text-sm text-zinc-400\">{t('spoolbuddy.settings.currentWeight', 'Current weight')}</span>\n          <div className=\"flex items-center gap-2\">\n            <div className={`w-2 h-2 rounded-full ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />\n            <span className=\"text-lg font-mono text-zinc-200\">\n              {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}\n            </span>\n          </div>\n        </div>\n\n        {/* Tare offset + calibration factor */}\n        <div className=\"grid grid-cols-2 gap-4 mt-3 text-xs\">\n          <div className=\"flex justify-between\">\n            <span className=\"text-zinc-500\">{t('spoolbuddy.settings.tareOffset', 'Tare offset')}</span>\n            <span className=\"text-zinc-400 font-mono\">{device.tare_offset}</span>\n          </div>\n          <div className=\"flex justify-between\">\n            <span className=\"text-zinc-500\">{t('spoolbuddy.settings.calFactor', 'Cal. factor')}</span>\n            <span className=\"text-zinc-400 font-mono\">{device.calibration_factor.toFixed(2)}</span>\n          </div>\n        </div>\n      </div>\n\n      {/* Calibration flow */}\n      {calStep === 'idle' ? (\n        <div className=\"flex gap-2\">\n          <button\n            onClick={handleTare}\n            disabled={taring}\n            className=\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px]\"\n          >\n            {taring ? '...' : t('spoolbuddy.weight.tare', 'Tare')}\n          </button>\n          <button\n            onClick={startCalibration}\n            className=\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.weight.calibrate', 'Calibrate')}\n          </button>\n        </div>\n      ) : (\n        <div className=\"bg-zinc-800 border border-zinc-700 rounded-lg p-3 space-y-2\">\n          <div className=\"text-sm font-medium text-zinc-200\">\n            {calStep === 'tare'\n              ? t('spoolbuddy.settings.calStep1', 'Step 1: Remove all items from the scale')\n              : t('spoolbuddy.settings.calStep2', 'Step 2: Place known weight on scale')}\n          </div>\n\n          {calStep === 'weight' && (\n            <div className=\"space-y-1.5\">\n              <div className=\"flex items-center gap-2\">\n                <span className=\"text-xs text-zinc-400\">{t('spoolbuddy.settings.knownWeight', 'Known weight (g)')}</span>\n                <div className=\"flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-right text-base font-mono text-zinc-100\">\n                  {knownWeight || '0'}<span className=\"text-zinc-500 ml-1\">g</span>\n                </div>\n              </div>\n              <div className=\"grid grid-cols-4 gap-1\">\n                {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (\n                  <button\n                    key={key}\n                    onClick={() => numpadPress(key)}\n                    className={`py-2 rounded text-sm font-medium transition-colors min-h-[36px] ${\n                      key === 'backspace'\n                        ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'\n                        : 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'\n                    }`}\n                  >\n                    {key === 'backspace' ? '\\u232B' : key}\n                  </button>\n                ))}\n              </div>\n            </div>\n          )}\n\n          <div className=\"flex gap-2\">\n            <button\n              onClick={() => setCalStep('idle')}\n              className=\"flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[40px]\"\n            >\n              {t('common.cancel', 'Cancel')}\n            </button>\n            <button\n              onClick={handleCalStep}\n              disabled={calibrating}\n              className=\"flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[40px]\"\n            >\n              {calibrating ? '...' : calStep === 'tare' ? t('spoolbuddy.settings.setZero', 'Set Zero') : t('spoolbuddy.settings.calibrateNow', 'Calibrate')}\n            </button>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport function SpoolBuddyCalibrationPage() {\n  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n\n  const { data: devices = [] } = useQuery({\n    queryKey: ['spoolbuddy-devices'],\n    queryFn: () => spoolbuddyApi.getDevices(),\n    refetchInterval: 10000,\n  });\n\n  const device = sbState.deviceId\n    ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]\n    : devices[0];\n\n  return (\n    <div className=\"h-full flex flex-col p-4\">\n      <div className=\"flex items-center gap-3 mb-4\">\n        <button\n          onClick={() => navigate('/spoolbuddy/settings')}\n          className=\"p-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 transition-colors\"\n        >\n          <svg className=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 19l-7-7 7-7\" />\n          </svg>\n        </button>\n        <h1 className=\"text-xl font-semibold text-zinc-100\">\n          {t('spoolbuddy.settings.scaleCalibration', 'Scale Calibration')}\n        </h1>\n      </div>\n\n      <div className=\"flex-1 min-h-0 overflow-y-auto\">\n        {!device ? (\n          <div className=\"flex items-center justify-center h-32\">\n            <div className=\"text-center text-zinc-500\">\n              <p className=\"text-sm\">{t('spoolbuddy.settings.noDevice', 'No SpoolBuddy device found')}</p>\n            </div>\n          </div>\n        ) : (\n          <ScaleCalibration\n            device={device}\n            weight={sbState.weight}\n            weightStable={sbState.weightStable}\n            rawAdc={sbState.rawAdc}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx",
    "content": "import { useState, useEffect, useMemo, useRef } from 'react';\nimport { useOutletContext } from 'react-router-dom';\nimport { useQuery, useQueries } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';\nimport { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';\nimport { useToast } from '../../contexts/ToastContext';\nimport { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';\nimport { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';\nimport { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';\nimport { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';\n\nfunction normalizeHexTag(value: string | null | undefined): string {\n  if (!value) return '';\n  return value.replace(/[^0-9a-f]/gi, '').toUpperCase();\n}\n\nfunction tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean {\n  const aNorm = normalizeHexTag(a);\n  const bNorm = normalizeHexTag(b);\n  if (!aNorm || !bNorm) return false;\n  if (aNorm === bNorm) return true;\n  // Some readers report shortened UID forms.\n  return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm);\n}\n\n// Color palette for the cycling spool animation\nconst SPOOL_COLORS = [\n  '#00AE42', '#FF6B35', '#3B82F6', '#EF4444', '#A855F7',\n  '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',\n];\n\n// --- Idle state with slow color-cycling spool ---\nfunction IdleSpool() {\n  const { t } = useTranslation();\n  const [colorIndex, setColorIndex] = useState(0);\n\n  useEffect(() => {\n    const interval = setInterval(() => {\n      setColorIndex((prev) => (prev + 1) % SPOOL_COLORS.length);\n    }, 5000);\n    return () => clearInterval(interval);\n  }, []);\n\n  const currentColor = SPOOL_COLORS[colorIndex];\n\n  return (\n    <div className=\"flex flex-col items-center text-center\">\n      {/* Animated spool with optimized NFC waves */}\n      <div className=\"relative mb-6 flex items-center justify-center\" style={{ width: 160, height: 160 }}>\n        {/* NFC wave rings: transform + opacity only for Pi-friendly rendering */}\n        <div className=\"absolute inset-0 flex items-center justify-center pointer-events-none\">\n          {[0, 1].map((i) => (\n            <div\n              key={i}\n              className=\"absolute rounded-full border spoolbuddy-optimized-ping\"\n              style={{\n                width: 80,\n                height: 80,\n                borderColor: `${currentColor}4D`,\n                transition: 'border-color 140ms linear',\n                animationDelay: `${i * 0.8}s`,\n              }}\n            />\n          ))}\n        </div>\n\n        {/* Spool icon with lightweight radial glow */}\n        <div className=\"relative overflow-hidden rounded-full\">\n          <div\n            className=\"absolute inset-0 rounded-full opacity-30 spoolbuddy-spool-glow\"\n            style={{\n              background: `radial-gradient(circle, ${currentColor} 0%, transparent 70%)`,\n            }}\n          />\n          <div className=\"relative\" style={{ transition: 'opacity 140ms linear' }}>\n            <SpoolIcon color={currentColor} isEmpty={false} size={100} />\n          </div>\n        </div>\n      </div>\n\n      {/* Text content */}\n      <div className=\"space-y-2\">\n        <p className=\"text-xl font-medium text-zinc-300\">\n          {t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}\n        </p>\n        <p className=\"text-sm text-zinc-500\">\n          {t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}\n        </p>\n      </div>\n\n      {/* Subtle hint */}\n      <div className=\"mt-6 flex items-center gap-2 text-sm text-zinc-600\">\n        <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n        </svg>\n        <span>{t('spoolbuddy.dashboard.nfcHint', 'NFC tag will be read automatically')}</span>\n      </div>\n    </div>\n  );\n}\n\n// --- Offline state ---\nfunction DeviceOfflineState() {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"flex flex-col items-center text-center\">\n      {/* Offline icon */}\n      <div className=\"relative mb-6 flex items-center justify-center\" style={{ width: 160, height: 160 }}>\n        <div className=\"w-24 h-24 rounded-full bg-zinc-800 flex items-center justify-center\">\n          <svg className=\"w-12 h-12 text-zinc-600\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"1.5\" d=\"M18.364 5.636a9 9 0 010 12.728m0 0l-12.728-12.728m12.728 12.728L5.636 5.636m12.728 0a9 9 0 00-12.728 0m0 12.728a9 9 0 010-12.728\" />\n          </svg>\n        </div>\n      </div>\n\n      <div className=\"space-y-2\">\n        <p className=\"text-lg font-medium text-zinc-500\">\n          {t('spoolbuddy.status.deviceOffline', 'Device Offline')}\n        </p>\n        <p className=\"text-sm text-zinc-600\">\n          {t('spoolbuddy.status.connectDisplay', 'Connect the SpoolBuddy display to scan spools')}\n        </p>\n      </div>\n\n      <div className=\"mt-6 flex items-center gap-2 text-xs text-zinc-600\">\n        <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"2\" d=\"M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0\" />\n        </svg>\n        <span>{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}</span>\n      </div>\n    </div>\n  );\n}\n\n// --- Main Dashboard ---\nexport function SpoolBuddyDashboard() {\n  const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();\n  const { t } = useTranslation();\n  const { showToast } = useToast();\n\n  // Fetch spools for stats, tag lookup, and untagged list\n  const { data: spools = [], refetch: refetchSpools } = useQuery({\n    queryKey: ['inventory-spools'],\n    queryFn: () => api.getSpools(false),\n  });\n\n  // Fetch printers and their statuses for the status badges\n  const { data: printers = [] } = useQuery({\n    queryKey: ['printers'],\n    queryFn: () => api.getPrinters(),\n  });\n\n  const statusQueries = useQueries({\n    queries: printers.map((printer: Printer) => ({\n      queryKey: ['printerStatus', printer.id],\n      queryFn: () => api.getPrinterStatus(printer.id),\n      refetchInterval: 10000,\n      select: (data: PrinterStatus) => ({ connected: data?.connected }),\n    })),\n  });\n\n  // Current Spool card state - persists until user closes or new tag detected\n  const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);\n  const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);\n  const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);\n  const [showLinkModal, setShowLinkModal] = useState(false);\n  const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);\n  const [showQuickAddModal, setShowQuickAddModal] = useState(false);\n  const [quickAddBusy, setQuickAddBusy] = useState(false);\n\n  // Track current tag from state\n  const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;\n  const currentWeight = sbState.weight;\n  const weightStable = sbState.weightStable;\n\n  // Stabilized scale display: only update when change exceeds threshold to prevent bouncing\n  const stableDisplayWeight = useRef<number | null>(null);\n  const WEIGHT_THRESHOLD = 3; // grams - ignore changes smaller than this\n  if (currentWeight === null) {\n    stableDisplayWeight.current = null;\n  } else if (stableDisplayWeight.current === null || Math.abs(currentWeight - stableDisplayWeight.current) >= WEIGHT_THRESHOLD || weightStable) {\n    stableDisplayWeight.current = currentWeight;\n  }\n  const scaleDisplayValue = stableDisplayWeight.current;\n\n  // Find spool by tag_id in the loaded spools list\n  const displayedSpool = useMemo(() => {\n    if (sbState.matchedSpool?.id) {\n      const byId = spools.find((s) => s.id === sbState.matchedSpool?.id);\n      if (byId) return byId;\n    }\n    if (!displayedTagId) return null;\n    return spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId)) ?? null;\n  }, [displayedTagId, sbState.matchedSpool, spools]);\n\n  // Untagged spools for the Link feature\n  const untaggedSpools = useMemo(() => {\n    return spools.filter((s) => !s.tag_uid && !s.archived_at);\n  }, [spools]);\n\n  // Handle tag detection - show card when tag detected, keep until user closes or new tag\n  useEffect(() => {\n    if (currentTagId) {\n      const isHidden = hiddenTagId === currentTagId;\n      const isDifferentTag = displayedTagId !== null && displayedTagId !== currentTagId;\n\n      if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) {\n        setDisplayedTagId(currentTagId);\n        setDisplayedWeight(null);\n        setHiddenTagId(null);\n      }\n\n      // Update weight when stable and card is visible\n      if (!isHidden && currentWeight !== null && weightStable) {\n        setDisplayedWeight(Math.round(Math.max(0, currentWeight)));\n      }\n    } else {\n      // Tag removed - clear hidden state so same tag can show when re-placed\n      if (hiddenTagId) {\n        setDisplayedTagId(null);\n        setHiddenTagId(null);\n        setDisplayedWeight(null);\n      }\n    }\n  }, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);\n\n  // Auto-sync weight once when known spool first detected\n\n  const handleCloseSpoolCard = () => {\n    setHiddenTagId(displayedTagId);\n  };\n\n  const handleLinkTagToSpool = async (spool: InventorySpool) => {\n    if (!displayedTagId) return;\n    try {\n      await api.linkTagToSpool(spool.id, {\n        tag_uid: displayedTagId,\n        tag_type: 'generic',\n        data_origin: 'nfc_link',\n      });\n      setShowLinkModal(false);\n      refetchSpools();\n    } catch (e) {\n      console.error('Failed to link tag:', e);\n    }\n  };\n\n  const handleQuickAddToInventory = async () => {\n    if (!displayedTagId) return;\n    setQuickAddBusy(true);\n    try {\n      const weight = liveWeight ?? displayedWeight;\n      await api.createSpool({\n        material: 'PLA',\n        subtype: null,\n        color_name: null,\n        rgba: null,\n        brand: null,\n        label_weight: 1000,\n        core_weight: 250,\n        core_weight_catalog_id: null,\n        weight_used: 0,\n        slicer_filament: null,\n        slicer_filament_name: null,\n        nozzle_temp_min: null,\n        nozzle_temp_max: null,\n        note: null,\n        added_full: null,\n        last_used: null,\n        encode_time: null,\n        tag_uid: displayedTagId,\n        tray_uuid: null,\n        data_origin: 'spoolbuddy',\n        tag_type: 'generic',\n        cost_per_kg: null,\n        last_scale_weight: weight !== null ? Math.round(weight) : null,\n        last_weighed_at: weight !== null ? new Date().toISOString() : null,\n      });\n    } catch (e) {\n      const msg = e instanceof Error ? e.message : String(e);\n      console.error('Failed to quick-add spool:', msg);\n      showToast(msg || t('spoolbuddy.errors.quickAddFailed', 'Failed to add spool'), 'error');\n    } finally {\n      setShowQuickAddModal(false);\n      setQuickAddBusy(false);\n      refetchSpools();\n    }\n  };\n\n  // For unknown tags, use live weight or stored displayed weight\n  const useScaleWeight = currentWeight !== null &&\n    (currentTagId === displayedTagId || (currentTagId === null && displayedTagId !== null));\n  const liveWeight = useScaleWeight ? currentWeight : null;\n\n  // Stats\n  const totalSpools = spools.length;\n  const materials = new Set(spools.map((s) => s.material)).size;\n  const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size;\n\n  return (\n    <div className=\"h-full flex flex-col p-4\">\n      {/* Compact stats bar */}\n      <div className=\"flex items-center gap-6 px-4 py-1.5 bg-zinc-800/50 rounded-xl border border-zinc-700/50 mb-3 shrink-0\">\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-xl font-bold text-zinc-100\">{totalSpools}</span>\n          <span className=\"text-sm text-zinc-500\">{t('spoolbuddy.inventory.spools', 'Spools')}</span>\n        </div>\n        <div className=\"w-px h-5 bg-zinc-700\" />\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-xl font-bold text-zinc-100\">{materials}</span>\n          <span className=\"text-sm text-zinc-500\">{t('spoolbuddy.spool.material', 'Materials')}</span>\n        </div>\n        <div className=\"w-px h-5 bg-zinc-700\" />\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-xl font-bold text-zinc-100\">{brands}</span>\n          <span className=\"text-sm text-zinc-500\">{t('spoolbuddy.spool.brand', 'Brands')}</span>\n        </div>\n      </div>\n\n      {/* Main content: Device (left) + Current Spool (right) */}\n      <div className=\"flex-1 flex gap-4 min-h-0\">\n        {/* Left column */}\n        <div className=\"w-5/12 flex flex-col min-h-0\">\n          {/* Device card */}\n          <div className=\"border border-dashed border-zinc-700/50 rounded-xl p-4\">\n            <h2 className=\"text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-3\">\n              {t('spoolbuddy.dashboard.device', 'Device')}\n            </h2>\n\n            <div className=\"space-y-2.5\">\n              {/* Connection status */}\n              <div className=\"flex items-center gap-3\">\n                <div className={`w-2.5 h-2.5 rounded-full ${sbState.deviceOnline ? 'bg-green-500' : 'bg-red-500'}`} />\n                <span className=\"text-base text-zinc-400\">\n                  {sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Disconnected')}\n                </span>\n              </div>\n\n              {/* Scale weight */}\n              <div className=\"flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg\">\n                <div className=\"flex items-center gap-2\">\n                  <svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"2\" d=\"M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3\" />\n                  </svg>\n                  <span className=\"text-sm text-zinc-500\">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>\n                </div>\n                <span className=\"text-lg font-mono font-semibold text-zinc-100\">\n                  {scaleDisplayValue !== null ? `${Math.abs(scaleDisplayValue) <= 20 ? 0 : Math.round(Math.max(0, scaleDisplayValue))}g` : '\\u2014'}\n                </span>\n              </div>\n\n              {/* NFC status */}\n              <div className=\"flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg\">\n                <div className=\"flex items-center gap-2\">\n                  <svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"2\" d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z\" />\n                  </svg>\n                  <span className=\"text-sm text-zinc-500\">NFC</span>\n                </div>\n                <span className={`text-sm font-medium ${currentTagId ? 'text-green-500' : 'text-zinc-500'}`}>\n                  {currentTagId ? t('spoolbuddy.dashboard.tagDetected', 'Tag detected') : t('spoolbuddy.dashboard.noTag', 'No tag')}\n                </span>\n              </div>\n            </div>\n          </div>\n\n          {/* Printer status badges */}\n          {printers.length > 0 && (\n            <div className=\"mt-3 border border-dashed border-zinc-700/50 rounded-xl p-4\">\n              <h2 className=\"text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-2.5\">\n                {t('spoolbuddy.dashboard.printers', 'Printers')}\n              </h2>\n              <div className=\"flex flex-wrap gap-2 overflow-hidden\">\n                {printers.map((printer: Printer, i: number) => {\n                  const isOnline = statusQueries[i]?.data?.connected ?? false;\n                  return (\n                    <div\n                      key={printer.id}\n                      className=\"flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 rounded-lg\"\n                      title={`${printer.name} — ${isOnline ? 'Online' : 'Offline'}`}\n                    >\n                      <div className={`w-2 h-2 rounded-full shrink-0 ${isOnline ? 'bg-green-500' : 'bg-zinc-600'}`} />\n                      <span className=\"text-xs text-zinc-400 truncate max-w-[100px]\">{printer.name}</span>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          )}\n        </div>\n\n        {/* Right column: Current Spool */}\n        <div className=\"w-7/12 min-h-0\">\n          <div className=\"border border-dashed border-zinc-700/50 rounded-xl p-6 h-full flex flex-col\">\n            <h2 className=\"text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-4 shrink-0\">\n              {t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}\n            </h2>\n            <div className=\"flex-1 flex items-center justify-center min-h-0\">\n              {!sbState.deviceOnline ? (\n                <DeviceOfflineState />\n              ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (\n                <SpoolInfoCard\n                  spool={(() => {\n                    const s = displayedSpool ?? sbState.matchedSpool!;\n                    return {\n                      id: s.id,\n                      tag_uid: displayedTagId,\n                      material: s.material,\n                      subtype: s.subtype,\n                      color_name: s.color_name,\n                      rgba: s.rgba,\n                      brand: s.brand,\n                      label_weight: s.label_weight,\n                      core_weight: s.core_weight,\n                      weight_used: s.weight_used,\n                    };\n                  })()}\n                  scaleWeight={liveWeight ?? displayedWeight}\n                  onSyncWeight={() => refetchSpools()}\n                  onAssignToAms={() => setShowAssignAmsModal(true)}\n                  onClose={handleCloseSpoolCard}\n                />\n              ) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (\n                <UnknownTagCard\n                  tagUid={displayedTagId}\n                  scaleWeight={liveWeight ?? displayedWeight}\n                  onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}\n                  onAddToInventory={() => setShowQuickAddModal(true)}\n                  onClose={handleCloseSpoolCard}\n                />\n              ) : (\n                <IdleSpool />\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Assign to AMS Modal */}\n      {displayedSpool && displayedTagId && (\n        <AssignToAmsModal\n          isOpen={showAssignAmsModal}\n          onClose={() => setShowAssignAmsModal(false)}\n          spool={displayedSpool}\n          printerId={selectedPrinterId}\n        />\n      )}\n\n      {/* Link Tag to Spool Modal */}\n      {displayedTagId && (\n        <LinkSpoolModal\n          isOpen={showLinkModal}\n          onClose={() => setShowLinkModal(false)}\n          tagId={displayedTagId}\n          untaggedSpools={untaggedSpools}\n          onLink={handleLinkTagToSpool}\n        />\n      )}\n\n      {/* Quick-add to Inventory Modal */}\n      {showQuickAddModal && displayedTagId && (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60\">\n          <div className=\"bg-zinc-800 rounded-2xl p-6 mx-4 max-w-sm w-full border border-zinc-700\">\n            <h3 className=\"text-lg font-semibold text-zinc-100 mb-3\">\n              {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}\n            </h3>\n\n            {/* Hint */}\n            <div className=\"flex gap-2.5 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg mb-4\">\n              <svg className=\"w-5 h-5 text-amber-500 shrink-0 mt-0.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth=\"2\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n              </svg>\n              <p className=\"text-sm text-amber-200/80\">\n                {t('spoolbuddy.modal.quickAddHint', 'For best results, add the spool in the Bambuddy web interface first (with material, color, brand), then use \"Link to Spool\" here to assign the NFC tag.')}\n              </p>\n            </div>\n\n            <p className=\"text-sm text-zinc-400 mb-1\">\n              {t('spoolbuddy.modal.quickAddDesc', 'This will create a basic PLA spool entry with this NFC tag. You can edit the details later in Bambuddy.')}\n            </p>\n            <p className=\"text-xs text-zinc-500 font-mono mb-5\">{displayedTagId}</p>\n\n            <div className=\"flex gap-3\">\n              <button\n                onClick={() => setShowQuickAddModal(false)}\n                className=\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\"\n              >\n                {t('common.cancel', 'Cancel')}\n              </button>\n              <button\n                onClick={handleQuickAddToInventory}\n                disabled={quickAddBusy}\n                className=\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors min-h-[44px]\"\n              >\n                {quickAddBusy ? t('common.saving', 'Saving...') : t('spoolbuddy.modal.addAnyway', 'Add Anyway')}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx",
    "content": "import { useState, useMemo } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport { useOutletContext } from 'react-router-dom';\nimport { Search, X, Package } from 'lucide-react';\nimport { api } from '../../api/client';\nimport type { InventorySpool, SpoolAssignment } from '../../api/client';\nimport { resolveSpoolColorName } from '../../utils/colors';\nimport { formatSlotLabel } from '../../utils/amsHelpers';\nimport { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';\nimport { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';\nimport type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';\n\ntype FilterMode = 'all' | 'in_ams' | string; // string = material name\n\nfunction spoolColor(spool: InventorySpool): string {\n  if (spool.rgba) return `#${spool.rgba.substring(0, 6)}`;\n  return '#808080';\n}\n\nfunction spoolRemaining(spool: InventorySpool): number {\n  return Math.max(0, spool.label_weight - spool.weight_used);\n}\n\nfunction spoolPct(spool: InventorySpool): number {\n  if (spool.label_weight <= 0) return 0;\n  return Math.max(0, Math.min(100, ((spool.label_weight - spool.weight_used) / spool.label_weight) * 100));\n}\n\nfunction spoolDisplayName(spool: InventorySpool): string {\n  const parts = [spool.material];\n  if (spool.subtype) parts.push(spool.subtype);\n  return parts.join(' ');\n}\n\nfunction assignmentLabel(a: SpoolAssignment): string {\n  const isExternal = a.ams_id === 254 || a.ams_id === 255;\n  const isHt = !isExternal && a.ams_id >= 128;\n  return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal);\n}\n\n/* Spool circle — same style as AMS page tray slots */\nfunction SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 56 56\">\n      <circle cx=\"28\" cy=\"28\" r=\"26\" fill={color} />\n      <circle cx=\"28\" cy=\"28\" r=\"20\" fill={color} style={{ filter: 'brightness(0.85)' }} />\n      <ellipse cx=\"20\" cy=\"20\" rx=\"6\" ry=\"4\" fill=\"white\" opacity=\"0.3\" />\n      <circle cx=\"28\" cy=\"28\" r=\"8\" fill=\"#2d2d2d\" />\n      <circle cx=\"28\" cy=\"28\" r=\"5\" fill=\"#1a1a1a\" />\n    </svg>\n  );\n}\n\nexport function SpoolBuddyInventoryPage() {\n  const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();\n  const { t } = useTranslation();\n  const [searchQuery, setSearchQuery] = useState('');\n  const [filterMode, setFilterMode] = useState<FilterMode>('all');\n  const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);\n  const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);\n\n  const { data: spoolmanSettings } = useQuery({\n    queryKey: ['spoolman-settings'],\n    queryFn: api.getSpoolmanSettings,\n    staleTime: 5 * 60 * 1000,\n  });\n\n  const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({\n    queryKey: ['inventory-spools'],\n    queryFn: () => api.getSpools(false),\n    refetchInterval: 30000,\n  });\n\n  const { data: assignments = [] } = useQuery({\n    queryKey: ['spool-assignments'],\n    queryFn: () => api.getAssignments(),\n    refetchInterval: 30000,\n  });\n\n  // Build assignment lookup: spool_id → assignment\n  const assignmentMap = useMemo(() => {\n    const map: Record<number, SpoolAssignment> = {};\n    assignments.forEach(a => { map[a.spool_id] = a; });\n    return map;\n  }, [assignments]);\n\n  const activeSpools = useMemo(() => spools.filter(s => !s.archived_at), [spools]);\n\n  // Spools that have an AMS assignment\n  const assignedSpoolIds = useMemo(() => new Set(assignments.map(a => a.spool_id)), [assignments]);\n  const inAmsCount = useMemo(() => activeSpools.filter(s => assignedSpoolIds.has(s.id)).length, [activeSpools, assignedSpoolIds]);\n\n  // Unique materials for filter pills\n  const materials = useMemo(() => {\n    const set = new Set<string>();\n    activeSpools.forEach(s => set.add(s.material));\n    return Array.from(set).sort();\n  }, [activeSpools]);\n\n  // Filter and sort\n  const filteredSpools = useMemo(() => {\n    let list = activeSpools;\n\n    if (filterMode === 'in_ams') {\n      list = list.filter(s => assignedSpoolIds.has(s.id));\n    } else if (filterMode !== 'all') {\n      list = list.filter(s => s.material === filterMode);\n    }\n\n    if (searchQuery.trim()) {\n      const q = searchQuery.toLowerCase().trim();\n      list = list.filter(s =>\n        s.material.toLowerCase().includes(q) ||\n        (s.subtype && s.subtype.toLowerCase().includes(q)) ||\n        (s.brand && s.brand.toLowerCase().includes(q)) ||\n        (s.color_name && s.color_name.toLowerCase().includes(q)) ||\n        (s.note && s.note.toLowerCase().includes(q))\n      );\n    }\n\n    // Sort: assigned spools first (by slot label), then by most recently updated\n    return [...list].sort((a, b) => {\n      const aAssigned = assignedSpoolIds.has(a.id) ? 0 : 1;\n      const bAssigned = assignedSpoolIds.has(b.id) ? 0 : 1;\n      if (aAssigned !== bAssigned) return aAssigned - bAssigned;\n      return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();\n    });\n  }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);\n\n  // Spoolman iframe mode\n  const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;\n  if (spoolmanEnabled) {\n    return (\n      <div className=\"h-full flex flex-col\">\n        <iframe\n          src={`${spoolmanSettings.spoolman_url.replace(/\\/+$/, '')}/spool`}\n          className=\"flex-1 w-full border-0\"\n          title=\"Spoolman\"\n          sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"h-full flex flex-col\">\n      {/* Search + filter pills */}\n      <div className=\"px-3 pt-3 pb-2 space-y-2.5\">\n        {/* Search */}\n        <div className=\"relative\">\n          <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40\" />\n          <input\n            type=\"text\"\n            value={searchQuery}\n            onChange={e => setSearchQuery(e.target.value)}\n            placeholder={t('spoolbuddy.inventory.searchPlaceholder', 'Search spools...')}\n            className=\"w-full pl-9 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-bambu-green\"\n          />\n          {searchQuery && (\n            <button\n              onClick={() => setSearchQuery('')}\n              className=\"absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60\"\n            >\n              <X className=\"w-4 h-4\" />\n            </button>\n          )}\n        </div>\n\n        {/* Filter pills — inline scrollable row */}\n        <div className=\"flex gap-1.5 overflow-x-auto no-scrollbar\">\n          <FilterPill\n            active={filterMode === 'all'}\n            onClick={() => setFilterMode('all')}\n            label={`${t('spoolbuddy.inventory.all', 'All')} (${activeSpools.length})`}\n            green\n          />\n          {inAmsCount > 0 && (\n            <FilterPill\n              active={filterMode === 'in_ams'}\n              onClick={() => setFilterMode('in_ams')}\n              label={`${t('spoolbuddy.inventory.inAms', 'In AMS')} (${inAmsCount})`}\n            />\n          )}\n          {materials.map(mat => (\n            <FilterPill\n              key={mat}\n              active={filterMode === mat}\n              onClick={() => setFilterMode(filterMode === mat ? 'all' : mat)}\n              label={mat}\n            />\n          ))}\n        </div>\n      </div>\n\n      {/* Spool grid */}\n      <div className=\"flex-1 overflow-y-auto px-3 pb-3\">\n        {isLoading ? (\n          <div className=\"flex items-center justify-center py-16\">\n            <div className=\"w-8 h-8 border-2 border-bambu-green border-t-transparent rounded-full animate-spin\" />\n          </div>\n        ) : filteredSpools.length === 0 ? (\n          <div className=\"flex flex-col items-center justify-center py-16 text-white/30\">\n            <Package className=\"w-12 h-12 mb-3\" />\n            <p className=\"text-sm\">\n              {searchQuery || filterMode !== 'all'\n                ? t('spoolbuddy.inventory.noResults', 'No spools match your filters')\n                : t('spoolbuddy.inventory.empty', 'No spools in inventory')}\n            </p>\n          </div>\n        ) : (\n          <div className=\"grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-2\">\n            {filteredSpools.map(spool => (\n              <CatalogCard\n                key={spool.id}\n                spool={spool}\n                assignment={assignmentMap[spool.id]}\n                onClick={() => setSelectedSpoolId(spool.id)}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n\n      {/* Detail modal — look up spool from live query data so it stays current */}\n      {selectedSpoolId != null && (() => {\n        const liveSpool = spools.find(s => s.id === selectedSpoolId);\n        if (!liveSpool) return null;\n        const handleCloseDetail = () => {\n          setSelectedSpoolId(null);\n          setShowAssignAmsModal(false);\n        };\n        return (\n          <>\n            <SpoolDetailModal\n              spool={liveSpool}\n              assignment={assignmentMap[liveSpool.id]}\n              sbState={sbState}\n              onSyncWeight={() => {\n                void refetchSpools();\n              }}\n              onAssignToAms={() => setShowAssignAmsModal(true)}\n              onClose={handleCloseDetail}\n            />\n            <AssignToAmsModal\n              isOpen={showAssignAmsModal}\n              onClose={() => setShowAssignAmsModal(false)}\n              spool={liveSpool}\n              printerId={selectedPrinterId}\n            />\n          </>\n        );\n      })()}\n    </div>\n  );\n}\n\n/* Filter pill button */\nfunction FilterPill({ active, onClick, label, green }: {\n  active: boolean;\n  onClick: () => void;\n  label: string;\n  green?: boolean;\n}) {\n  return (\n    <button\n      onClick={onClick}\n      className={`px-4 py-1.5 rounded-full text-sm font-medium border whitespace-nowrap shrink-0 transition-colors ${\n        active\n          ? green\n            ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/50'\n            : 'bg-white/10 text-white border-white/20'\n          : 'bg-transparent text-white/40 border-bambu-dark-tertiary hover:text-white/60'\n      }`}\n    >\n      {label}\n    </button>\n  );\n}\n\n/* Catalog-style spool card matching the mockup */\nfunction CatalogCard({ spool, assignment, onClick }: {\n  spool: InventorySpool;\n  assignment?: SpoolAssignment;\n  onClick: () => void;\n}) {\n  const color = spoolColor(spool);\n  const pct = spoolPct(spool);\n  const remaining = spoolRemaining(spool);\n  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);\n\n  return (\n    <button\n      onClick={onClick}\n      className=\"bg-bambu-dark-secondary rounded-xl p-3 flex flex-col items-center text-center gap-1.5 border border-transparent hover:border-bambu-green/50 transition-colors\"\n    >\n      {/* Spool icon */}\n      <SpoolCircle color={color} size={56} />\n\n      {/* Material + Subtype */}\n      <p className=\"text-xs font-semibold text-white leading-tight truncate w-full\">\n        {spoolDisplayName(spool)}\n      </p>\n\n      {/* Color dot + name */}\n      <div className=\"flex items-center gap-1 min-w-0 max-w-full\">\n        <span\n          className=\"w-2.5 h-2.5 rounded-full shrink-0 border border-white/10\"\n          style={{ backgroundColor: color }}\n        />\n        <span className=\"text-[11px] text-white/50 truncate\">\n          {colorName || '-'}\n        </span>\n      </div>\n\n      {/* Fill bar + weight */}\n      <div className=\"w-full space-y-0.5\">\n        <div className=\"h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden\">\n          <div\n            className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}\n            style={{ width: `${Math.min(pct, 100)}%` }}\n          />\n        </div>\n        <p className=\"text-[11px] text-white/40\">\n          {Math.round(remaining)}g ({Math.round(pct)}%)\n        </p>\n      </div>\n\n      {/* AMS location badge */}\n      {assignment && (\n        <span className=\"px-2 py-0.5 rounded text-[10px] font-bold bg-bambu-green/20 text-bambu-green\">\n          {assignmentLabel(assignment)}\n        </span>\n      )}\n    </button>\n  );\n}\n\n/* Detail bottom sheet */\nfunction SpoolDetailModal({ spool, assignment, sbState, onSyncWeight, onAssignToAms, onClose }: {\n  spool: InventorySpool;\n  assignment?: SpoolAssignment;\n  sbState: SpoolBuddyOutletContext['sbState'];\n  onSyncWeight: () => void;\n  onAssignToAms: () => void;\n  onClose: () => void;\n}) {\n  const useLiveScaleWeight = sbState.deviceOnline && sbState.weight !== null;\n  const modalScaleWeight = useLiveScaleWeight\n    ? Math.round(sbState.weight as number)\n    : null;\n  const persistedGrossWeight = spool.last_scale_weight != null ? Math.round(spool.last_scale_weight) : null;\n\n  return (\n    <div className=\"fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4\" onClick={onClose}>\n      <div\n        className=\"w-full max-w-md bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-2xl p-4 overflow-y-auto max-h-[90vh]\"\n        onClick={e => e.stopPropagation()}\n      >\n        <div className=\"space-y-3\">\n          {assignment && (\n            <div className=\"flex items-center justify-center gap-2\">\n              <span className=\"px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green\">\n                {assignmentLabel(assignment)}\n              </span>\n              {assignment.printer_name && (\n                <span className=\"text-xs text-zinc-400\">{assignment.printer_name}</span>\n              )}\n            </div>\n          )}\n\n          <div className=\"flex justify-center\">\n            <InventorySpoolInfoCard\n              spool={spool}\n              liveScaleWeight={modalScaleWeight}\n              persistedGrossWeight={persistedGrossWeight}\n              onSyncWeight={onSyncWeight}\n              onAssignToAms={onAssignToAms}\n              onClose={onClose}\n              className=\"max-w-md\"\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx",
    "content": "import { useState, useCallback, useRef, useEffect } from 'react';\nimport { useQuery } from '@tanstack/react-query';\nimport { useOutletContext } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';\nimport { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';\nimport { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';\nimport { FileText, Wand2, Zap } from 'lucide-react';\n\n\nfunction formatUptime(seconds: number): string {\n  if (seconds < 60) return `${seconds}s`;\n  if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;\n  const h = Math.floor(seconds / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  return `${h}h ${m}m`;\n}\n\nfunction formatDateTime(iso: string | null): string {\n  if (!iso) return '-';\n  try {\n    const d = new Date(iso);\n    return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +\n      d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });\n  } catch {\n    return '-';\n  }\n}\n\nconst BLANK_OPTIONS = [\n  { label: 'Off', value: 0 },\n  { label: '1m', value: 60 },\n  { label: '2m', value: 120 },\n  { label: '5m', value: 300 },\n  { label: '10m', value: 600 },\n  { label: '30m', value: 1800 },\n];\n\n// --- Device Tab ---\n\nfunction DeviceTab({ device }: { device: SpoolBuddyDevice }) {\n  const { t } = useTranslation();\n  const [diagnosticOpen, setDiagnosticOpen] = useState<'nfc' | 'scale' | 'read_tag' | null>(null);\n  const [backendUrl, setBackendUrl] = useState('');\n  const [apiToken, setApiToken] = useState('');\n  const [systemBusy, setSystemBusy] = useState(false);\n  const [systemMsg, setSystemMsg] = useState<{ type: 'ok' | 'error'; text: string } | null>(null);\n\n  useEffect(() => {\n    if (!backendUrl && device.backend_url) {\n      setBackendUrl(device.backend_url);\n    }\n  }, [device.backend_url, backendUrl]);\n\n  const saveConfig = async () => {\n    if (!backendUrl.trim()) {\n      setSystemMsg({ type: 'error', text: t('spoolbuddy.settings.systemFieldsRequired', 'Backend URL is required.') });\n      return;\n    }\n\n    setSystemBusy(true);\n    setSystemMsg(null);\n    try {\n      await spoolbuddyApi.updateSystemConfig(\n        device.device_id,\n        backendUrl.trim(),\n        apiToken.trim() || undefined\n      );\n      setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemQueued', 'Config queued.') });\n    } catch (e) {\n      setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') });\n    } finally {\n      setSystemBusy(false);\n    }\n  };\n\n  return (\n    <div className=\"space-y-2\">\n      {/* NFC Reader + Device Info side by side */}\n      <div className=\"grid grid-cols-2 gap-2\">\n        {/* NFC Reader */}\n        <div className=\"bg-zinc-800 rounded-lg p-3\">\n          <h3 className=\"text-sm font-semibold text-zinc-300 mb-2\">\n            {t('spoolbuddy.settings.nfcReader', 'NFC Reader')}\n          </h3>\n          <div className=\"space-y-1.5 text-xs\">\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">{t('spoolbuddy.settings.type', 'Type')}</span>\n              <span className=\"text-zinc-300 font-mono\">\n                {device.nfc_reader_type || 'N/A'}\n              </span>\n            </div>\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">{t('spoolbuddy.settings.connection', 'Connection')}</span>\n              <span className=\"text-zinc-300 font-mono\">\n                {device.nfc_connection || 'N/A'}\n              </span>\n            </div>\n            <div className=\"flex justify-between items-center\">\n              <span className=\"text-zinc-500\">{t('spoolbuddy.status.status', 'Status')}</span>\n              <div className=\"flex items-center gap-1.5\">\n                <div className={`w-2 h-2 rounded-full ${\n                  device.nfc_ok ? 'bg-green-500' : device.nfc_reader_type ? 'bg-red-500' : 'bg-zinc-600'\n                }`} />\n                <span className={\n                  device.nfc_ok ? 'text-green-400' : device.nfc_reader_type ? 'text-red-400' : 'text-zinc-500'\n                }>\n                  {device.nfc_ok\n                    ? t('spoolbuddy.status.nfcReady', 'Ready')\n                    : device.nfc_reader_type\n                      ? t('common.error', 'Error')\n                      : t('spoolbuddy.settings.notConnected', 'N/A')}\n                </span>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {/* Device Info */}\n        <div className=\"bg-zinc-800 rounded-lg p-3\">\n          <h3 className=\"text-sm font-semibold text-zinc-300 mb-2\">\n            {t('spoolbuddy.settings.deviceInfo', 'Device Info')}\n          </h3>\n          <div className=\"space-y-1.5 text-xs\">\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">{t('spoolbuddy.settings.hostname', 'Host')}</span>\n              <span className=\"text-zinc-300 truncate ml-2\">{device.hostname}</span>\n            </div>\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">IP</span>\n              <span className=\"text-zinc-300\">{device.ip_address}</span>\n            </div>\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">{t('spoolbuddy.settings.uptime', 'Uptime')}</span>\n              <span className=\"text-zinc-300\">{formatUptime(device.uptime_s)}</span>\n            </div>\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">ID</span>\n              <span className=\"text-zinc-400 font-mono truncate ml-2\">{device.device_id}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Backend/Auth + Diagnostics side by side */}\n      <div className=\"grid grid-cols-2 gap-2\">\n        {/* Backend/Auth Config */}\n        <div className=\"bg-zinc-800 rounded-lg p-3 space-y-2\">\n          <h3 className=\"text-sm font-semibold text-zinc-300\">\n            {t('spoolbuddy.settings.systemConfig', 'Backend & Auth')}\n          </h3>\n          <input\n            value={backendUrl}\n            onChange={(e) => setBackendUrl(e.target.value)}\n            placeholder=\"http://192.168.1.100:5000\"\n            className=\"w-full px-2 py-1.5 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-xs\"\n          />\n          <div className=\"flex gap-2\">\n            <input\n              type=\"password\"\n              value={apiToken}\n              onChange={(e) => setApiToken(e.target.value)}\n              placeholder={t('spoolbuddy.settings.apiTokenPlaceholder', 'API token')}\n              className=\"flex-1 px-2 py-1.5 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-xs\"\n            />\n            <button\n              onClick={saveConfig}\n              disabled={systemBusy}\n              className=\"px-3 py-1.5 rounded bg-green-700 hover:bg-green-600 disabled:bg-zinc-700 text-xs font-medium text-zinc-100\"\n            >\n              {t('spoolbuddy.settings.saveConfig', 'Save')}\n            </button>\n          </div>\n          {systemMsg && (\n            <div className={`text-xs ${systemMsg.type === 'ok' ? 'text-green-400' : 'text-red-400'}`}>\n              {systemMsg.text}\n            </div>\n          )}\n        </div>\n\n        {/* Diagnostic Buttons */}\n        <div className=\"bg-zinc-800 rounded-lg p-3 flex flex-col gap-2\">\n          <button\n            onClick={() => setDiagnosticOpen('nfc')}\n            className=\"flex-1 bg-blue-700 hover:bg-blue-600 transition-colors rounded-lg p-2 flex items-center gap-2\"\n          >\n            <Wand2 className=\"w-4 h-4 text-blue-300 shrink-0\" />\n            <span className=\"text-xs font-medium text-blue-100\">\n              {t('spoolbuddy.settings.nfcDiagnostic', 'NFC Diagnostic')}\n            </span>\n          </button>\n          <button\n            onClick={() => setDiagnosticOpen('scale')}\n            className=\"flex-1 bg-yellow-700 hover:bg-yellow-600 transition-colors rounded-lg p-2 flex items-center gap-2\"\n          >\n            <Zap className=\"w-4 h-4 text-yellow-300 shrink-0\" />\n            <span className=\"text-xs font-medium text-yellow-100\">\n              {t('spoolbuddy.settings.scaleDiagnostic', 'Scale Diagnostic')}\n            </span>\n          </button>\n          <button\n            onClick={() => setDiagnosticOpen('read_tag')}\n            className=\"flex-1 bg-emerald-700 hover:bg-emerald-600 transition-colors rounded-lg p-2 flex items-center gap-2\"\n          >\n            <FileText className=\"w-4 h-4 text-emerald-300 shrink-0\" />\n            <span className=\"text-xs font-medium text-emerald-100\">\n              {t('spoolbuddy.settings.readTagDiagnostic', 'Read Tag')}\n            </span>\n          </button>\n        </div>\n      </div>\n\n      {/* Diagnostic Modal */}\n      {diagnosticOpen && device && (\n        <DiagnosticModal\n          type={diagnosticOpen}\n          deviceId={device.device_id}\n          onClose={() => setDiagnosticOpen(null)}\n        />\n      )}\n    </div>\n  );\n}\n\n// --- Display Tab ---\n\nfunction DisplayTab({ device, onBrightnessChange }: {\n  device: SpoolBuddyDevice;\n  onBrightnessChange: (value: number) => void;\n}) {\n  const { t } = useTranslation();\n  const [brightness, setBrightness] = useState(device.display_brightness);\n  const [blankTimeout, setBlankTimeout] = useState(device.display_blank_timeout);\n  const [saved, setSaved] = useState(false);\n  const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const savedTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  // Sync local state when device data updates from server\n  useEffect(() => {\n    setBrightness(device.display_brightness);\n    setBlankTimeout(device.display_blank_timeout);\n  }, [device.display_brightness, device.display_blank_timeout]);\n\n  const showSaved = useCallback(() => {\n    setSaved(true);\n    if (savedTimerRef.current) clearTimeout(savedTimerRef.current);\n    savedTimerRef.current = setTimeout(() => setSaved(false), 1500);\n  }, []);\n\n  const sendDisplayUpdate = useCallback((b: number, bt: number) => {\n    if (debounceRef.current) clearTimeout(debounceRef.current);\n    debounceRef.current = setTimeout(() => {\n      spoolbuddyApi.updateDisplay(device.device_id, b, bt)\n        .then(() => showSaved())\n        .catch((e) => console.error('Failed to update display:', e));\n    }, 300);\n  }, [device.device_id, showSaved]);\n\n  const handleBrightnessChange = (value: number) => {\n    setBrightness(value);\n    onBrightnessChange(value);  // Instant layout update\n    sendDisplayUpdate(value, blankTimeout);\n  };\n\n  const handleBlankTimeoutChange = (value: number) => {\n    setBlankTimeout(value);\n    sendDisplayUpdate(brightness, value);\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Brightness */}\n      <div className=\"bg-zinc-800 rounded-lg p-4\">\n        <div className=\"flex items-center justify-between mb-3\">\n          <h3 className=\"text-sm font-semibold text-zinc-300\">\n            {t('spoolbuddy.settings.brightness', 'Brightness')}\n          </h3>\n          {saved && (\n            <span className=\"text-xs text-green-400 flex items-center gap-1 animate-pulse\">\n              <svg className=\"w-3 h-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={3}>\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n              </svg>\n              {t('spoolbuddy.settings.saved', 'Saved')}\n            </span>\n          )}\n        </div>\n        <div className=\"flex items-center gap-3\">\n          <svg className=\"w-4 h-4 text-zinc-500 flex-shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z\" />\n          </svg>\n          <input\n            type=\"range\"\n            min={0}\n            max={100}\n            value={brightness}\n            onChange={(e) => handleBrightnessChange(parseInt(e.target.value))}\n            className=\"flex-1 h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-green-500\"\n          />\n          <span className=\"text-sm font-mono text-zinc-400 w-10 text-right\">{brightness}%</span>\n        </div>\n        {!device.has_backlight && (\n          <p className=\"text-xs text-zinc-600 mt-2\">\n            {t('spoolbuddy.settings.noBacklight', 'No DSI backlight detected. Brightness control requires a DSI display.')}\n          </p>\n        )}\n      </div>\n\n      {/* Screen blank timeout */}\n      <div className=\"bg-zinc-800 rounded-lg p-4\">\n        <h3 className=\"text-sm font-semibold text-zinc-300 mb-1\">\n          {t('spoolbuddy.settings.screenBlank', 'Screen Blank Timeout')}\n        </h3>\n        <p className=\"text-xs text-zinc-500 mb-3\">\n          {t('spoolbuddy.settings.screenBlankDesc', 'Screen turns off after inactivity. Touch to wake.')}\n        </p>\n        <div className=\"grid grid-cols-3 gap-2\">\n          {BLANK_OPTIONS.map((opt) => (\n            <button\n              key={opt.value}\n              onClick={() => handleBlankTimeoutChange(opt.value)}\n              className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors min-h-[40px] ${\n                blankTimeout === opt.value\n                  ? 'bg-green-600 text-white'\n                  : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'\n              }`}\n            >\n              {opt.label}\n            </button>\n          ))}\n        </div>\n      </div>\n\n      <p className=\"text-xs text-zinc-600 text-center\">\n        {t('spoolbuddy.settings.displayNote', 'Brightness is applied as a software filter.')}\n      </p>\n    </div>\n  );\n}\n\n// --- Scale Tab ---\n\nfunction StepIndicator({ step, labels }: { step: 'tare' | 'weight'; labels: { tare: string; weight: string } }) {\n  return (\n    <div className=\"flex flex-col items-center w-16 shrink-0 pt-1\">\n      {/* Step 1 circle */}\n      <div className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${\n        step === 'tare'\n          ? 'bg-green-600 text-white'\n          : 'bg-green-600/20 text-green-400'\n      }`}>\n        {step === 'weight' ? (\n          <svg className=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={3}>\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n          </svg>\n        ) : '1'}\n      </div>\n      <span className={`text-[10px] mt-0.5 ${step === 'tare' ? 'text-green-400 font-medium' : 'text-green-400/60'}`}>\n        {labels.tare}\n      </span>\n\n      {/* Connector line */}\n      <div className={`w-px h-5 my-1 ${step === 'weight' ? 'bg-green-600/40' : 'bg-zinc-700'}`} />\n\n      {/* Step 2 circle */}\n      <div className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${\n        step === 'weight'\n          ? 'bg-green-600 text-white'\n          : 'bg-zinc-700 text-zinc-500'\n      }`}>\n        2\n      </div>\n      <span className={`text-[10px] mt-0.5 ${step === 'weight' ? 'text-green-400 font-medium' : 'text-zinc-600'}`}>\n        {labels.weight}\n      </span>\n    </div>\n  );\n}\n\nfunction ScaleTab({ device, weight, weightStable, rawAdc }: {\n  device: SpoolBuddyDevice;\n  weight: number | null;\n  weightStable: boolean;\n  rawAdc: number | null;\n}) {\n  const { t } = useTranslation();\n  const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');\n  const [knownWeight, setKnownWeight] = useState('500');\n  const [tareRawAdc, setTareRawAdc] = useState<number | null>(null);\n  const [busy, setBusy] = useState(false);\n  const [status, setStatus] = useState<{ type: 'ok' | 'error'; msg: string } | null>(null);\n\n  const numpadPress = (key: string) => {\n    if (key === 'backspace') {\n      setKnownWeight((v) => v.slice(0, -1) || '');\n    } else if (key === '.' && !knownWeight.includes('.')) {\n      setKnownWeight((v) => v + '.');\n    } else if (key >= '0' && key <= '9') {\n      setKnownWeight((v) => (v === '0' ? key : v + key));\n    }\n  };\n\n  const handleTare = async () => {\n    setBusy(true);\n    setStatus(null);\n    try {\n      await spoolbuddyApi.tare(device.device_id);\n      setStatus({ type: 'ok', msg: t('spoolbuddy.settings.tareSet', 'Tare command sent. Waiting for device...') });\n    } catch {\n      setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') });\n    } finally {\n      setBusy(false);\n    }\n  };\n\n  const handleCalStep = async () => {\n    if (calStep === 'tare') {\n      setBusy(true);\n      setStatus(null);\n      try {\n        setTareRawAdc(rawAdc);\n        await spoolbuddyApi.tare(device.device_id);\n        setStatus({ type: 'ok', msg: t('spoolbuddy.settings.zeroSet', 'Zero point set. Place known weight on scale.') });\n        setCalStep('weight');\n      } catch {\n        setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') });\n      } finally {\n        setBusy(false);\n      }\n    } else if (calStep === 'weight') {\n      const weightNum = parseFloat(knownWeight);\n      if (rawAdc === null || !weightNum || weightNum <= 0) return;\n      setBusy(true);\n      setStatus(null);\n      try {\n        await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined);\n        setStatus({ type: 'ok', msg: t('spoolbuddy.settings.calibrationDone', 'Calibration complete!') });\n        setCalStep('idle');\n      } catch {\n        setStatus({ type: 'error', msg: t('spoolbuddy.settings.calibrationFailed', 'Calibration failed') });\n      } finally {\n        setBusy(false);\n      }\n    }\n  };\n\n  // --- Idle state: weight card + buttons ---\n  if (calStep === 'idle') {\n    return (\n      <div className=\"flex flex-col h-full\">\n        {/* Weight + info card */}\n        <div className=\"bg-zinc-800 rounded-lg p-3 mb-3\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <div className={`w-2 h-2 rounded-full ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />\n              <span className=\"text-lg font-mono text-zinc-200\">\n                {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}\n              </span>\n            </div>\n            <div className=\"text-xs text-zinc-500 text-right\">\n              <span>{t('spoolbuddy.settings.tareOffset', 'Tare')}: {device.tare_offset}</span>\n              <span className=\"mx-1.5\">&middot;</span>\n              <span>{t('spoolbuddy.settings.calFactor', 'Factor')}: {device.calibration_factor.toFixed(2)}</span>\n            </div>\n          </div>\n          {device.last_calibrated_at && (\n            <div className=\"text-xs text-zinc-600 mt-1\">\n              {t('spoolbuddy.settings.lastCalibrated', 'Last calibrated')}: {formatDateTime(device.last_calibrated_at)}\n            </div>\n          )}\n        </div>\n\n        {/* Status message */}\n        {status && (\n          <div className={`rounded-lg px-3 py-2 mb-3 text-sm ${\n            status.type === 'ok' ? 'bg-green-900/30 text-green-300 border border-green-800' : 'bg-red-900/30 text-red-300 border border-red-800'\n          }`}>\n            {status.msg}\n          </div>\n        )}\n\n        {/* Action buttons */}\n        <div className=\"flex gap-2\">\n          <button\n            onClick={handleTare}\n            disabled={busy}\n            className=\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2\"\n          >\n            {busy && (\n              <svg className=\"w-4 h-4 animate-spin\" viewBox=\"0 0 24 24\" fill=\"none\">\n                <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n                <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\" />\n              </svg>\n            )}\n            {t('spoolbuddy.weight.tare', 'Tare')}\n          </button>\n          <button\n            onClick={() => { setCalStep('tare'); setStatus(null); }}\n            className=\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\"\n          >\n            {t('spoolbuddy.weight.calibrate', 'Calibrate')}\n          </button>\n        </div>\n      </div>\n    );\n  }\n\n  // --- Calibration wizard: step indicator left + content right ---\n  return (\n    <div className=\"flex gap-3\">\n      {/* Left: step indicator */}\n      <StepIndicator step={calStep} labels={{ tare: t('spoolbuddy.weight.tare', 'Tare'), weight: t('spoolbuddy.settings.knownWeight', 'Known weight') }} />\n\n      {/* Right: content */}\n      <div className=\"flex-1 min-w-0\">\n        {/* Live weight bar */}\n        <div className=\"flex items-center gap-2 bg-zinc-800 rounded-lg px-3 py-1.5 mb-1.5\">\n          <div className={`w-2 h-2 rounded-full shrink-0 ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />\n          <span className=\"text-sm font-mono text-zinc-200\">\n            {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}\n          </span>\n          <span className={`text-xs ml-auto ${weightStable ? 'text-green-400' : 'text-amber-400'}`}>\n            {weightStable ? t('spoolbuddy.settings.stable', 'Stable') : t('spoolbuddy.settings.settling', 'Settling...')}\n          </span>\n        </div>\n\n        {/* Status message */}\n        {status && (\n          <div className={`rounded-lg px-3 py-1.5 mb-1.5 text-xs ${\n            status.type === 'ok' ? 'bg-green-900/30 text-green-300 border border-green-800' : 'bg-red-900/30 text-red-300 border border-red-800'\n          }`}>\n            {status.msg}\n          </div>\n        )}\n\n        {/* Step content */}\n        {calStep === 'tare' ? (\n          <p className=\"text-sm text-zinc-300 mb-3\">\n            {t('spoolbuddy.settings.calStep1', 'Remove all items from the scale and press Set Zero.')}\n          </p>\n        ) : (\n          <>\n            <div className=\"flex items-center gap-2 mb-1.5\">\n              <span className=\"text-xs text-zinc-400 shrink-0\">{t('spoolbuddy.settings.knownWeight', 'Known weight')}</span>\n              <div className=\"flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1 text-right text-lg font-mono text-zinc-100\">\n                {knownWeight || '0'}<span className=\"text-zinc-500 ml-1\">g</span>\n              </div>\n            </div>\n            <div className=\"grid grid-cols-4 gap-1 mb-1.5\">\n              {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (\n                <button\n                  key={key}\n                  onClick={() => numpadPress(key)}\n                  className={`rounded text-lg font-medium transition-colors h-[48px] active:scale-95 ${\n                    key === 'backspace'\n                      ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'\n                      : 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'\n                  }`}\n                >\n                  {key === 'backspace' ? '\\u232B' : key}\n                </button>\n              ))}\n            </div>\n          </>\n        )}\n\n        {/* Action buttons */}\n        <div className=\"flex gap-2\">\n          <button\n            onClick={() => { setCalStep('idle'); setStatus(null); }}\n            className=\"flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors h-[40px]\"\n          >\n            {t('common.cancel', 'Cancel')}\n          </button>\n          <button\n            onClick={handleCalStep}\n            disabled={busy}\n            className=\"flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors h-[40px] flex items-center justify-center gap-2\"\n          >\n            {busy && (\n              <svg className=\"w-4 h-4 animate-spin\" viewBox=\"0 0 24 24\" fill=\"none\">\n                <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n                <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\" />\n              </svg>\n            )}\n            {calStep === 'tare' ? t('spoolbuddy.settings.setZero', 'Set Zero') : t('spoolbuddy.settings.calibrateNow', 'Calibrate')}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// --- Updates Tab ---\n\nfunction UpdatesTab({ device }: { device: SpoolBuddyDevice }) {\n  const { t } = useTranslation();\n  const [busy, setBusy] = useState<'checking' | 'applying' | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [sshExpanded, setSSHExpanded] = useState(false);\n  const [copied, setCopied] = useState(false);\n\n  const isUpdating = device.update_status === 'pending' || device.update_status === 'updating';\n\n  // When applying succeeds and device picks up the update, keep showing busy\n  useEffect(() => {\n    if (isUpdating && busy === 'applying') {\n      setBusy(null); // device has picked it up, isUpdating takes over the UI\n    }\n  }, [isUpdating, busy]);\n\n  // Reload the page when daemon comes back online after an update\n  useEffect(() => {\n    const handleOnline = () => {\n      if (isUpdating) {\n        // Daemon re-registered — reload to get fresh version + state\n        setTimeout(() => window.location.reload(), 1000);\n      }\n    };\n    window.addEventListener('spoolbuddy-online', handleOnline);\n    return () => window.removeEventListener('spoolbuddy-online', handleOnline);\n  }, [isUpdating]);\n\n  const { data: updateResult, refetch } = useQuery({\n    queryKey: ['spoolbuddy-update-check', device.device_id],\n    queryFn: () => spoolbuddyApi.checkDaemonUpdate(device.device_id),\n    staleTime: 0,\n  });\n\n  const { data: sshKeyData } = useQuery({\n    queryKey: ['spoolbuddy-ssh-key'],\n    queryFn: () => spoolbuddyApi.getSSHPublicKey(),\n    enabled: sshExpanded,\n    staleTime: Infinity,\n  });\n\n  const checkForUpdates = async () => {\n    setBusy('checking');\n    setError(null);\n    try {\n      await refetch();\n    } finally {\n      setBusy(null);\n    }\n  };\n\n  const applyUpdate = async () => {\n    setBusy('applying');\n    setError(null);\n    try {\n      await spoolbuddyApi.triggerUpdate(device.device_id);\n      // Don't clear busy — keep showing spinner until isUpdating takes over or timeout\n    } catch (e) {\n      setError(e instanceof Error ? e.message : 'Failed to trigger update');\n      setBusy(null);\n    }\n  };\n\n  const showSpinner = busy != null || isUpdating;\n\n  const copyKey = () => {\n    if (sshKeyData?.public_key) {\n      navigator.clipboard.writeText(sshKeyData.public_key);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    }\n  };\n\n  const displayVersion = device.firmware_version\n    || (updateResult?.current_version && updateResult.current_version !== '0.0.0' ? updateResult.current_version : null);\n\n  return (\n    <div className=\"space-y-3\">\n      {/* Version + Update status + Check — single card */}\n      <div className=\"bg-zinc-800 rounded-lg p-3 space-y-3\">\n        {/* Version row */}\n        <div className=\"flex justify-between items-center text-sm\">\n          <span className=\"text-zinc-500\">{t('spoolbuddy.settings.currentVersion', 'Current Version')}</span>\n          <span className=\"text-zinc-200 font-mono\">\n            {displayVersion || (\n              <span className=\"text-zinc-500 italic text-xs\">{t('spoolbuddy.settings.versionPending', 'Waiting for daemon...')}</span>\n            )}\n          </span>\n        </div>\n\n        {/* Status / progress row */}\n        {showSpinner ? (\n          <div className=\"flex items-center gap-2\">\n            <svg className=\"w-4 h-4 animate-spin text-green-400 flex-shrink-0\" viewBox=\"0 0 24 24\" fill=\"none\">\n              <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n              <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\" />\n            </svg>\n            <span className=\"text-green-300 text-xs\">\n              {busy === 'checking' ? t('spoolbuddy.settings.checking', 'Checking for updates...')\n                : device.update_message || t('spoolbuddy.settings.updateWaiting', 'Updating...')}\n            </span>\n          </div>\n        ) : device.update_status === 'error' ? (\n          <p className=\"text-xs text-red-300\">{device.update_message || t('spoolbuddy.settings.updateFailed', 'Update failed')}</p>\n        ) : error ? (\n          <p className=\"text-xs text-red-300\">{error}</p>\n        ) : updateResult?.update_available ? (\n          <p className=\"text-xs text-green-300\">\n            {t('spoolbuddy.settings.updateAvailable', 'Update available')}: {displayVersion} → {updateResult.latest_version}\n          </p>\n        ) : null}\n\n        {/* Action buttons */}\n        {!showSpinner && (\n          updateResult?.update_available ? (\n            <button\n              onClick={applyUpdate}\n              disabled={!device.online}\n              className=\"w-full px-3 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors\"\n            >\n              {!device.online\n                ? t('spoolbuddy.settings.deviceOffline', 'Device Offline')\n                : t('spoolbuddy.settings.applyUpdate', 'Apply Update')}\n            </button>\n          ) : (\n            <div className=\"flex gap-2\">\n              <button\n                onClick={checkForUpdates}\n                className=\"flex-1 px-3 py-2 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors\"\n              >\n                {t('spoolbuddy.settings.checkUpdates', 'Check for Updates')}\n              </button>\n              <button\n                onClick={applyUpdate}\n                disabled={!device.online}\n                className=\"px-3 py-2 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-zinc-200 disabled:opacity-40 transition-colors\"\n              >\n                {t('spoolbuddy.settings.forceUpdate', 'Force Update')}\n              </button>\n            </div>\n          )\n        )}\n      </div>\n\n      {/* SSH Setup — collapsible */}\n      <div className=\"bg-zinc-800 rounded-lg p-3\">\n        <button\n          onClick={() => setSSHExpanded(!sshExpanded)}\n          className=\"w-full flex justify-between items-center text-xs\"\n        >\n          <span className=\"font-medium text-zinc-400\">\n            {t('spoolbuddy.settings.sshSetup', 'SSH Setup')}\n          </span>\n          <svg\n            className={`w-3 h-3 text-zinc-500 transition-transform ${sshExpanded ? 'rotate-180' : ''}`}\n            fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}\n          >\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19 9l-7 7-7-7\" />\n          </svg>\n        </button>\n\n        {sshExpanded && (\n          <div className=\"mt-2 space-y-2\">\n            <p className=\"text-xs text-zinc-500\">\n              {t('spoolbuddy.settings.sshDescription', 'SSH key is deployed automatically. For manual setup, add this key to ~/.ssh/authorized_keys on the device.')}\n            </p>\n            {sshKeyData?.public_key ? (\n              <div className=\"relative\">\n                <pre className=\"bg-zinc-900 rounded p-2 text-[10px] text-zinc-400 font-mono break-all whitespace-pre-wrap\">\n                  {sshKeyData.public_key}\n                </pre>\n                <button\n                  onClick={copyKey}\n                  className=\"absolute top-1 right-1 px-1.5 py-0.5 rounded text-[10px] bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors\"\n                >\n                  {copied ? t('common.copied', 'Copied!') : t('common.copy', 'Copy')}\n                </button>\n              </div>\n            ) : (\n              <span className=\"text-[10px] text-zinc-500 italic\">\n                {t('spoolbuddy.settings.sshKeyLoading', 'Loading...')}\n              </span>\n            )}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\n// --- System Tab ---\n\nfunction UsageBar({ percent, color }: { percent: number; color: string }) {\n  return (\n    <div className=\"w-full h-2 bg-zinc-700 rounded-full overflow-hidden\">\n      <div\n        className={`h-full rounded-full transition-all ${color}`}\n        style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}\n      />\n    </div>\n  );\n}\n\nfunction formatSystemUptime(seconds: number): string {\n  const d = Math.floor(seconds / 86400);\n  const h = Math.floor((seconds % 86400) / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  if (d > 0) return `${d}d ${h}h ${m}m`;\n  if (h > 0) return `${h}h ${m}m`;\n  return `${m}m`;\n}\n\nfunction SystemTab({ device }: { device: SpoolBuddyDevice }) {\n  const { t } = useTranslation();\n  const stats = device.system_stats;\n\n  if (!stats) {\n    return (\n      <div className=\"flex items-center justify-center h-32\">\n        <p className=\"text-sm text-zinc-500\">\n          {t('spoolbuddy.settings.systemStatsWaiting', 'Waiting for system stats...')}\n        </p>\n      </div>\n    );\n  }\n\n  const mem = stats.memory;\n  const disk = stats.disk;\n  const tempColor = (stats.cpu_temp_c ?? 0) >= 80 ? 'text-red-400' : (stats.cpu_temp_c ?? 0) >= 65 ? 'text-amber-400' : 'text-green-400';\n  const memColor = (mem?.percent ?? 0) >= 90 ? 'bg-red-500' : (mem?.percent ?? 0) >= 70 ? 'bg-amber-500' : 'bg-green-500';\n  const diskColor = (disk?.percent ?? 0) >= 90 ? 'bg-red-500' : (disk?.percent ?? 0) >= 70 ? 'bg-amber-500' : 'bg-green-500';\n\n  return (\n    <div className=\"space-y-2\">\n      {/* CPU + Memory side by side */}\n      <div className=\"grid grid-cols-2 gap-2\">\n        <div className=\"bg-zinc-800 rounded-lg p-3\">\n          <h3 className=\"text-sm font-semibold text-zinc-300 mb-2\">CPU</h3>\n          <div className=\"space-y-1.5 text-xs\">\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">{t('spoolbuddy.settings.cores', 'Cores')}</span>\n              <span className=\"text-zinc-300 font-mono\">{stats.cpu_count ?? '-'}</span>\n            </div>\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">{t('spoolbuddy.settings.loadAvg', 'Load Avg')}</span>\n              <span className=\"text-zinc-300 font-mono\">\n                {stats.load_avg ? stats.load_avg.join(' / ') : '-'}\n              </span>\n            </div>\n            <div className=\"flex justify-between\">\n              <span className=\"text-zinc-500\">{t('spoolbuddy.settings.temp', 'Temp')}</span>\n              <span className={`font-mono font-medium ${tempColor}`}>\n                {stats.cpu_temp_c != null ? `${stats.cpu_temp_c}\\u00B0C` : '-'}\n              </span>\n            </div>\n          </div>\n        </div>\n\n        {/* Memory */}\n        <div className=\"bg-zinc-800 rounded-lg p-3\">\n          <h3 className=\"text-sm font-semibold text-zinc-300 mb-2\">\n            {t('spoolbuddy.settings.memory', 'Memory')}\n          </h3>\n          {mem ? (\n            <div className=\"space-y-1.5\">\n              <UsageBar percent={mem.percent ?? 0} color={memColor} />\n              <div className=\"space-y-1 text-xs\">\n                <div className=\"flex justify-between\">\n                  <span className=\"text-zinc-500\">{t('spoolbuddy.settings.used', 'Used')}</span>\n                  <span className=\"text-zinc-300 font-mono\">{mem.used_mb} / {mem.total_mb} MB</span>\n                </div>\n                <div className=\"flex justify-between\">\n                  <span className=\"text-zinc-500\">{t('spoolbuddy.settings.available', 'Free')}</span>\n                  <span className=\"text-zinc-300 font-mono\">{mem.available_mb} MB</span>\n                </div>\n              </div>\n            </div>\n          ) : (\n            <span className=\"text-xs text-zinc-500\">-</span>\n          )}\n        </div>\n      </div>\n\n      {/* Disk — compact single row */}\n      <div className=\"bg-zinc-800 rounded-lg px-3 py-2\">\n        <div className=\"flex items-center gap-3\">\n          <h3 className=\"text-sm font-semibold text-zinc-300 shrink-0\">\n            {t('spoolbuddy.settings.disk', 'Disk')}\n          </h3>\n          {disk ? (\n            <>\n              <div className=\"flex-1\"><UsageBar percent={disk.percent ?? 0} color={diskColor} /></div>\n              <span className=\"text-xs text-zinc-300 font-mono shrink-0\">{disk.used_gb} / {disk.total_gb} GB</span>\n            </>\n          ) : (\n            <span className=\"text-xs text-zinc-500\">-</span>\n          )}\n        </div>\n      </div>\n\n      {/* OS + Runtime side by side */}\n      <div className=\"grid grid-cols-2 gap-2\">\n        <div className=\"bg-zinc-800 rounded-lg p-3\">\n          <h3 className=\"text-sm font-semibold text-zinc-300 mb-1.5\">\n            {t('spoolbuddy.settings.osInfo', 'OS')}\n          </h3>\n          <div className=\"space-y-1 text-xs\">\n            {stats.os?.os && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-zinc-500\">{t('spoolbuddy.settings.distro', 'Distro')}</span>\n                <span className=\"text-zinc-300 truncate ml-2\">{stats.os.os}</span>\n              </div>\n            )}\n            {stats.os?.kernel && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-zinc-500\">{t('spoolbuddy.settings.kernel', 'Kernel')}</span>\n                <span className=\"text-zinc-300 font-mono truncate ml-2\">{stats.os.kernel}</span>\n              </div>\n            )}\n            {stats.os?.arch && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-zinc-500\">{t('spoolbuddy.settings.arch', 'Arch')}</span>\n                <span className=\"text-zinc-300 font-mono\">{stats.os.arch}</span>\n              </div>\n            )}\n          </div>\n        </div>\n        <div className=\"bg-zinc-800 rounded-lg p-3\">\n          <h3 className=\"text-sm font-semibold text-zinc-300 mb-1.5\">\n            {t('spoolbuddy.settings.runtime', 'Runtime')}\n          </h3>\n          <div className=\"space-y-1 text-xs\">\n            {stats.os?.python && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-zinc-500\">Python</span>\n                <span className=\"text-zinc-300 font-mono\">{stats.os.python}</span>\n              </div>\n            )}\n            {stats.system_uptime_s != null && (\n              <div className=\"flex justify-between\">\n                <span className=\"text-zinc-500\">{t('spoolbuddy.settings.systemUptime', 'Uptime')}</span>\n                <span className=\"text-zinc-300\">{formatSystemUptime(stats.system_uptime_s)}</span>\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n// --- Main Settings Page ---\n\ntype SettingsTab = 'device' | 'display' | 'scale' | 'updates' | 'system';\n\nexport function SpoolBuddySettingsPage() {\n  const { sbState, setDisplayBrightness } = useOutletContext<SpoolBuddyOutletContext>();\n  const { t } = useTranslation();\n  const [activeTab, setActiveTab] = useState<SettingsTab>('device');\n\n  const { data: devices = [] } = useQuery({\n    queryKey: ['spoolbuddy-devices'],\n    queryFn: () => spoolbuddyApi.getDevices(),\n    refetchInterval: 10000,\n  });\n\n  // Use first device (most common setup) or find one matching current state\n  const device = sbState.deviceId\n    ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]\n    : devices[0];\n\n\n  const tabs: { id: SettingsTab; label: string }[] = [\n    { id: 'device', label: t('spoolbuddy.settings.tabDevice', 'Device') },\n    { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },\n    { id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') },\n    { id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') },\n    { id: 'system', label: t('spoolbuddy.settings.tabSystem', 'System') },\n  ];\n\n  return (\n    <div className=\"h-full flex flex-col p-4\">\n      <h1 className=\"text-xl font-semibold text-zinc-100 mb-3\">\n        {t('spoolbuddy.nav.settings', 'Settings')}\n      </h1>\n\n      {/* Tab bar */}\n      <div className=\"flex gap-1 bg-zinc-800/50 rounded-lg p-1 mb-4\">\n        {tabs.map((tab) => (\n          <button\n            key={tab.id}\n            onClick={() => setActiveTab(tab.id)}\n            className={`flex-1 px-2 py-2 rounded-md text-sm font-medium transition-colors min-h-[36px] ${\n              activeTab === tab.id\n                ? 'bg-zinc-700 text-zinc-100'\n                : 'text-zinc-500 hover:text-zinc-300'\n            }`}\n          >\n            {tab.label}\n          </button>\n        ))}\n      </div>\n\n      {/* Content */}\n      <div className=\"flex-1 min-h-0 overflow-y-auto\">\n        {!device ? (\n          <div className=\"flex items-center justify-center h-32\">\n            <div className=\"text-center text-zinc-500\">\n              <p className=\"text-sm\">{t('spoolbuddy.settings.noDevice', 'No SpoolBuddy device found')}</p>\n            </div>\n          </div>\n        ) : (\n          <>\n            {activeTab === 'device' && <DeviceTab device={device} />}\n            {activeTab === 'display' && (\n              <DisplayTab\n                device={device}\n                onBrightnessChange={setDisplayBrightness}\n              />\n            )}\n            {activeTab === 'scale' && (\n              <ScaleTab\n                device={device}\n                weight={sbState.weight}\n                weightStable={sbState.weightStable}\n                rawAdc={sbState.rawAdc}\n              />\n            )}\n            {activeTab === 'updates' && <UpdatesTab device={device} />}\n            {activeTab === 'system' && <SystemTab device={device} />}\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx",
    "content": "import { useState, useEffect, useCallback, useMemo } from 'react';\nimport { useOutletContext } from 'react-router-dom';\nimport { useQuery } from '@tanstack/react-query';\nimport { useTranslation } from 'react-i18next';\nimport type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';\nimport {\n  api,\n  spoolbuddyApi,\n  type InventorySpool,\n  type LocalPreset,\n  type SlicerSetting,\n  type SpoolCatalogEntry,\n} from '../../api/client';\nimport { getCurrencySymbol } from '../../utils/currency';\nimport { FilamentSection } from '../../components/spool-form/FilamentSection';\nimport { ColorSection } from '../../components/spool-form/ColorSection';\nimport { AdditionalSection } from '../../components/spool-form/AdditionalSection';\nimport { PAProfileSection } from '../../components/spool-form/PAProfileSection';\nimport type { ColorPreset, PrinterWithCalibrations, SpoolFormData } from '../../components/spool-form/types';\nimport { defaultFormData, validateForm } from '../../components/spool-form/types';\nimport {\n  buildFilamentOptions,\n  extractBrandsFromPresets,\n  findPresetOption,\n  loadRecentColors,\n  parsePresetName,\n  saveRecentColor,\n} from '../../components/spool-form/utils';\nimport { MATERIALS } from '../../components/spool-form/constants';\n\ntype Tab = 'existing' | 'new' | 'replace';\ntype WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';\nconst SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];\n\nexport function SpoolBuddyWriteTagPage() {\n  const { t } = useTranslation();\n  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();\n\n  const [activeTab, setActiveTab] = useState<Tab>('existing');\n  const [selectedSpool, setSelectedSpool] = useState<InventorySpool | null>(null);\n  const [searchQuery, setSearchQuery] = useState('');\n  const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');\n  const [writeMessage, setWriteMessage] = useState('');\n  const [untagging, setUntagging] = useState(false);\n  const [tagOnReader, setTagOnReader] = useState(false);\n  const [tagUid, setTagUid] = useState<string | null>(null);\n\n\n  const { data: spools = [], refetch: refetchSpools } = useQuery({\n    queryKey: ['inventory-spools'],\n    queryFn: () => api.getSpools(false),\n    refetchInterval: 10000,\n  });\n\n  const { data: devices = [] } = useQuery({\n    queryKey: ['spoolbuddy-devices'],\n    queryFn: () => spoolbuddyApi.getDevices(),\n    refetchInterval: 5000,\n  });\n\n  const { data: settings } = useQuery({\n    queryKey: ['settings'],\n    queryFn: api.getSettings,\n  });\n\n  const device = devices[0];\n  const deviceOnline = sbState.deviceOnline;\n  const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');\n\n  // Filter spools based on tab\n  const filteredSpools = useMemo(() => {\n    let list: InventorySpool[];\n    if (activeTab === 'existing') {\n      list = spools.filter(s => !s.tag_uid && !s.archived_at);\n    } else if (activeTab === 'replace') {\n      list = spools.filter(s => (s.tag_uid || s.tray_uuid) && !s.archived_at);\n    } else {\n      return [];\n    }\n\n    if (searchQuery) {\n      const q = searchQuery.toLowerCase();\n      list = list.filter(s =>\n        (s.material?.toLowerCase().includes(q)) ||\n        (s.color_name?.toLowerCase().includes(q)) ||\n        (s.brand?.toLowerCase().includes(q)) ||\n        (s.subtype?.toLowerCase().includes(q))\n      );\n    }\n\n    return list;\n  }, [spools, activeTab, searchQuery]);\n\n  // Listen for tag events\n  const handleUnknownTag = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    const sak = detail.sak ?? detail.data?.sak;\n    if (sak === 0x00) {\n      setTagOnReader(true);\n      setTagUid(detail.tag_uid ?? detail.data?.tag_uid ?? null);\n    }\n  }, []);\n\n  const handleTagMatched = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    // Tag is on the reader — could be used for replace flow\n    setTagOnReader(true);\n    setTagUid(detail.tag_uid ?? detail.data?.tag_uid ?? null);\n  }, []);\n\n  const handleTagRemoved = useCallback(() => {\n    setTagOnReader(false);\n    setTagUid(null);\n  }, []);\n\n  const handleTagWritten = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    if (detail.spool_id === selectedSpool?.id || detail.data?.spool_id === selectedSpool?.id) {\n      setWriteStatus('success');\n      setWriteMessage(t('spoolbuddy.writeTag.writeSuccess', 'Tag written successfully!'));\n      refetchSpools();\n      setTimeout(() => {\n        setWriteStatus('idle');\n        setSelectedSpool(null);\n        setWriteMessage('');\n      }, 5000);\n    }\n  }, [selectedSpool, t, refetchSpools]);\n\n  const handleWriteFailed = useCallback((e: Event) => {\n    const detail = (e as CustomEvent).detail;\n    if (detail.spool_id === selectedSpool?.id || detail.data?.spool_id === selectedSpool?.id) {\n      setWriteStatus('error');\n      setWriteMessage(detail.message ?? detail.data?.message ?? t('spoolbuddy.writeTag.writeFailed', 'Write failed'));\n    }\n  }, [selectedSpool, t]);\n\n  useEffect(() => {\n    window.addEventListener('spoolbuddy-unknown-tag', handleUnknownTag);\n    window.addEventListener('spoolbuddy-tag-matched', handleTagMatched);\n    window.addEventListener('spoolbuddy-tag-removed', handleTagRemoved);\n    window.addEventListener('spoolbuddy-tag-written', handleTagWritten);\n    window.addEventListener('spoolbuddy-tag-write-failed', handleWriteFailed);\n    return () => {\n      window.removeEventListener('spoolbuddy-unknown-tag', handleUnknownTag);\n      window.removeEventListener('spoolbuddy-tag-matched', handleTagMatched);\n      window.removeEventListener('spoolbuddy-tag-removed', handleTagRemoved);\n      window.removeEventListener('spoolbuddy-tag-written', handleTagWritten);\n      window.removeEventListener('spoolbuddy-tag-write-failed', handleWriteFailed);\n    };\n  }, [handleUnknownTag, handleTagMatched, handleTagRemoved, handleTagWritten, handleWriteFailed]);\n\n  // Clear selection when switching tabs\n  useEffect(() => {\n    setSelectedSpool(null);\n    setWriteStatus('idle');\n    setWriteMessage('');\n    setSearchQuery('');\n  }, [activeTab]);\n\n  const handleWriteTag = async () => {\n    if (!selectedSpool || !device) return;\n    setWriteStatus('writing');\n    setWriteMessage(t('spoolbuddy.writeTag.waiting', 'Waiting for SpoolBuddy...'));\n    try {\n      await spoolbuddyApi.writeTag(device.device_id, selectedSpool.id);\n    } catch {\n      setWriteStatus('error');\n      setWriteMessage(t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'));\n    }\n  };\n\n  const handleCancelWrite = async () => {\n    if (!device) return;\n    try {\n      await spoolbuddyApi.cancelWrite(device.device_id);\n    } catch { /* ignore */ }\n    setWriteStatus('idle');\n    setWriteMessage('');\n  };\n\n  const handleUntagSpool = async () => {\n    if (!selectedSpool || !isReplaceTagged(selectedSpool)) return;\n    setUntagging(true);\n    setWriteStatus('idle');\n    setWriteMessage('');\n    try {\n      await api.linkTagToSpool(selectedSpool.id, {\n        tag_uid: '',\n        tray_uuid: '',\n        data_origin: 'manual',\n      });\n      await refetchSpools();\n      setSelectedSpool(null);\n      setWriteStatus('success');\n      setWriteMessage(t('spoolbuddy.writeTag.untagSuccess', 'Tag removed from spool'));\n      setTimeout(() => {\n        setWriteStatus('idle');\n        setWriteMessage('');\n      }, 2500);\n    } catch {\n      setWriteStatus('error');\n      setWriteMessage(t('spoolbuddy.writeTag.untagFailed', 'Failed to remove tag from spool'));\n    } finally {\n      setUntagging(false);\n    }\n  };\n\n  const handleSpoolCreated = useCallback((createdSpool: InventorySpool) => {\n    setSelectedSpool(createdSpool);\n    setWriteStatus('idle');\n    setWriteMessage('');\n    void refetchSpools();\n  }, [refetchSpools]);\n\n  const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success';\n\n  return (\n    <div className=\"flex flex-col h-full\">\n      {/* Tab bar */}\n      <div className=\"flex border-b border-bambu-dark-tertiary shrink-0\">\n        {([\n          { key: 'existing' as Tab, label: t('spoolbuddy.writeTag.tabExisting', 'Existing Spool') },\n          { key: 'new' as Tab, label: t('spoolbuddy.writeTag.tabNew', 'New Spool') },\n          { key: 'replace' as Tab, label: t('spoolbuddy.writeTag.tabReplace', 'Replace Tag') },\n        ]).map(tab => (\n          <button\n            key={tab.key}\n            onClick={() => setActiveTab(tab.key)}\n            className={`flex-1 py-3 text-sm font-medium transition-colors ${\n              activeTab === tab.key\n                ? 'text-bambu-green border-b-2 border-bambu-green bg-bambu-dark'\n                : 'text-zinc-400 hover:text-zinc-200 hover:bg-bambu-dark-tertiary'\n            }`}\n          >\n            {tab.label}\n          </button>\n        ))}\n      </div>\n\n      {/* Main content: two columns */}\n      <div className=\"flex flex-1 overflow-hidden\">\n        {/* Left panel — spool list or form */}\n        <div className=\"flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary\">\n          {activeTab === 'new' ? (\n            <NewSpoolTouchForm\n              currencySymbol={currencySymbol}\n              onCreated={handleSpoolCreated}\n              selectedSpool={selectedSpool}\n              t={t}\n            />\n          ) : (\n            <>\n              {/* Search */}\n              <div className=\"p-3 shrink-0\">\n                <input\n                  type=\"text\"\n                  value={searchQuery}\n                  onChange={(e) => setSearchQuery(e.target.value)}\n                  placeholder={t('spoolbuddy.writeTag.searchPlaceholder', 'Search by material, color, brand...')}\n                  className=\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green\"\n                />\n              </div>\n\n              {/* Spool list */}\n              <div className=\"flex-1 overflow-y-auto px-3 pb-3 space-y-2\">\n                {filteredSpools.length === 0 ? (\n                  <div className=\"text-center text-zinc-500 py-8 text-sm\">\n                    {activeTab === 'existing'\n                      ? t('spoolbuddy.writeTag.noUntaggedSpools', 'No spools without tags')\n                      : t('spoolbuddy.writeTag.noTaggedSpools', 'No spools with tags')}\n                  </div>\n                ) : (\n                  filteredSpools.map(spool => (\n                    <SpoolListItem\n                      key={spool.id}\n                      spool={spool}\n                      selected={selectedSpool?.id === spool.id}\n                      showTag={activeTab === 'replace'}\n                      onClick={() => {\n                        setSelectedSpool(spool);\n                        setWriteStatus('idle');\n                        setWriteMessage('');\n                      }}\n                    />\n                  ))\n                )}\n              </div>\n            </>\n          )}\n        </div>\n\n        {/* Right panel — NFC status + write action */}\n        <div className=\"w-[340px] flex flex-col items-center justify-center p-6 shrink-0\">\n          <NfcStatusPanel\n            writeStatus={writeStatus}\n            writeMessage={writeMessage}\n            selectedSpool={selectedSpool}\n            tagOnReader={tagOnReader}\n            tagUid={tagUid}\n            deviceOnline={deviceOnline}\n            canWrite={!!canWrite}\n            isReplace={activeTab === 'replace'}\n            canUntag={activeTab === 'replace' && !!selectedSpool && isReplaceTagged(selectedSpool)}\n            untagging={untagging}\n            onWrite={handleWriteTag}\n            onUntag={handleUntagSpool}\n            onCancel={handleCancelWrite}\n            onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}\n            t={t}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction isReplaceTagged(spool: InventorySpool): boolean {\n  return !!(spool.tag_uid || spool.tray_uuid);\n}\n\n// --- Spool list item ---\nfunction SpoolListItem({ spool, selected, showTag, onClick }: {\n  spool: InventorySpool;\n  selected: boolean;\n  showTag: boolean;\n  onClick: () => void;\n}) {\n  const color = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#666';\n  const remaining = Math.max(0, spool.label_weight - spool.weight_used);\n  const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;\n\n  return (\n    <button\n      onClick={onClick}\n      className={`w-full flex items-center gap-3 p-3 rounded-lg text-left transition-colors ${\n        selected\n          ? 'bg-bambu-green/15 border border-bambu-green/50'\n          : 'bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-transparent'\n      }`}\n    >\n      {/* Color dot */}\n      <div\n        className=\"w-8 h-8 rounded-full shrink-0 border border-white/10\"\n        style={{ backgroundColor: color }}\n      />\n\n      {/* Info */}\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-sm font-medium text-white truncate\">\n            {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-2 text-xs text-zinc-400\">\n          {spool.color_name && <span>{spool.color_name}</span>}\n          <span>{remaining}g / {spool.label_weight}g ({pct}%)</span>\n        </div>\n        {showTag && spool.tag_uid && (\n          <div className=\"text-xs text-zinc-500 mt-0.5 font-mono\">{spool.tag_uid}</div>\n        )}\n      </div>\n\n      {/* Check mark when selected */}\n      {selected && (\n        <svg className=\"w-5 h-5 text-bambu-green shrink-0\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n          <path fillRule=\"evenodd\" d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\" clipRule=\"evenodd\" />\n        </svg>\n      )}\n    </button>\n  );\n}\n\ntype NewSpoolSubTab = 'filament' | 'pa-profile';\ntype NewSpoolViewMode = 'simple' | 'full';\n\n// --- New spool touch form (mirrors Add Spool fields/options in kiosk-friendly layout) ---\nfunction NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {\n  currencySymbol: string;\n  onCreated: (spool: InventorySpool) => void;\n  selectedSpool: InventorySpool | null;\n  t: (key: string, fallback: string) => string;\n}) {\n  const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');\n  const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');\n  const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);\n  const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});\n  const [quickAdd, setQuickAdd] = useState(false);\n  const [quantity, setQuantity] = useState(1);\n  const [creating, setCreating] = useState(false);\n  const [createError, setCreateError] = useState<string | null>(null);\n\n  const [cloudAuthenticated, setCloudAuthenticated] = useState(false);\n  const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);\n  const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);\n  const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);\n  const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);\n  const [colorCatalog, setColorCatalog] = useState<\n    { manufacturer: string; color_name: string; hex_color: string; material: string | null }[]\n  >([]);\n  const [presetInputValue, setPresetInputValue] = useState('');\n  const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);\n\n  const [printersWithCalibrations, setPrintersWithCalibrations] = useState<PrinterWithCalibrations[]>([]);\n  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());\n  const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());\n\n  useEffect(() => {\n    setRecentColors(loadRecentColors());\n  }, []);\n\n  useEffect(() => {\n    const fetchData = async () => {\n      // Only load full data when in full view mode\n      if (viewMode !== 'full') {\n        return;\n      }\n\n      setLoadingCloudPresets(true);\n      try {\n        const status = await api.getCloudStatus();\n        setCloudAuthenticated(status.is_authenticated);\n        if (status.is_authenticated) {\n          const presets = await api.getFilamentPresets();\n          setCloudPresets(presets);\n        }\n      } catch {\n        setCloudAuthenticated(false);\n      } finally {\n        setLoadingCloudPresets(false);\n      }\n\n      api.getSpoolCatalog().then(setSpoolCatalog).catch(() => undefined);\n      api.getColorCatalog().then(setColorCatalog).catch(() => undefined);\n      api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(() => undefined);\n\n      try {\n        const printers = await api.getPrinters();\n        const statuses = await Promise.all(printers.map(p => api.getPrinterStatus(p.id).catch(() => null)));\n        const results: PrinterWithCalibrations[] = [];\n        for (let i = 0; i < printers.length; i++) {\n          const printer = printers[i];\n          const status = statuses[i];\n          const connected = status?.connected ?? false;\n          let calibrations: PrinterWithCalibrations['calibrations'] = [];\n          if (connected) {\n            try {\n              const kRes = await api.getKProfiles(printer.id);\n              calibrations = kRes.profiles.map(p => ({\n                cali_idx: p.slot_id,\n                filament_id: p.filament_id,\n                setting_id: p.setting_id || '',\n                name: p.name,\n                k_value: parseFloat(p.k_value) || 0,\n                n_coef: parseFloat(p.n_coef) || 0,\n                extruder_id: p.extruder_id,\n                nozzle_diameter: p.nozzle_diameter,\n              }));\n            } catch {\n              // ignore per-printer unsupported profile endpoints\n            }\n          }\n          results.push({ printer: { ...printer, connected }, calibrations });\n        }\n        setPrintersWithCalibrations(results);\n      } catch {\n        // ignore calibration loading errors on kiosk form\n      }\n    };\n\n    fetchData();\n  }, [viewMode]);\n\n  useEffect(() => {\n    if (printersWithCalibrations.length > 0) {\n      setExpandedPrinters(new Set(printersWithCalibrations.map(p => String(p.printer.id))));\n    }\n  }, [printersWithCalibrations]);\n\n  const filamentOptions = useMemo(\n    () => buildFilamentOptions(cloudPresets, new Set(), localPresets),\n    [cloudPresets, localPresets],\n  );\n\n  const selectedPresetOption = useMemo(\n    () => findPresetOption(formData.slicer_filament, filamentOptions),\n    [formData.slicer_filament, filamentOptions],\n  );\n\n  const baseAvailableBrands = useMemo(() => {\n    const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);\n    const catalogBrands = colorCatalog\n      .map(entry => entry.manufacturer?.trim())\n      .filter((brand): brand is string => !!brand);\n    return Array.from(new Set<string>([...presetBrands, ...catalogBrands])).sort((a, b) => a.localeCompare(b));\n  }, [cloudPresets, localPresets, colorCatalog]);\n\n  const baseAvailableMaterials = useMemo(() => {\n    const catalogMaterials = colorCatalog\n      .map(entry => entry.material?.trim())\n      .filter((material): material is string => !!material);\n    return Array.from(new Set<string>([...MATERIALS, ...catalogMaterials])).sort((a, b) => a.localeCompare(b));\n  }, [colorCatalog]);\n\n  const brandMaterialPairs = useMemo(() => {\n    const pairs: Array<{ brand: string; material: string }> = [];\n    for (const entry of colorCatalog) {\n      const brand = entry.manufacturer?.trim();\n      const material = entry.material?.trim();\n      if (brand && material) pairs.push({ brand, material });\n    }\n    for (const preset of cloudPresets) {\n      const parsed = parsePresetName(preset.name);\n      if (parsed.brand && parsed.material) pairs.push({ brand: parsed.brand, material: parsed.material });\n    }\n    for (const preset of localPresets) {\n      const parsed = parsePresetName(preset.name);\n      const brand = preset.filament_vendor?.trim() || parsed.brand;\n      const material = parsed.material;\n      if (brand && material) pairs.push({ brand, material });\n    }\n    return pairs;\n  }, [cloudPresets, colorCatalog, localPresets]);\n\n  const brandToMaterials = useMemo(() => {\n    const map = new Map<string, Set<string>>();\n    for (const pair of brandMaterialPairs) {\n      const brandKey = pair.brand.toLowerCase();\n      const materialKey = pair.material.toLowerCase();\n      if (!map.has(brandKey)) map.set(brandKey, new Set());\n      map.get(brandKey)!.add(materialKey);\n    }\n    return map;\n  }, [brandMaterialPairs]);\n\n  const materialToBrands = useMemo(() => {\n    const map = new Map<string, Set<string>>();\n    for (const pair of brandMaterialPairs) {\n      const brandKey = pair.brand.toLowerCase();\n      const materialKey = pair.material.toLowerCase();\n      if (!map.has(materialKey)) map.set(materialKey, new Set());\n      map.get(materialKey)!.add(brandKey);\n    }\n    return map;\n  }, [brandMaterialPairs]);\n\n  const availableBrands = useMemo(() => {\n    if (!formData.material) return baseAvailableBrands;\n    const materialKey = formData.material.toLowerCase();\n    const brandKeys = materialToBrands.get(materialKey);\n    if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;\n    return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));\n  }, [baseAvailableBrands, formData.material, materialToBrands]);\n\n  const availableMaterials = useMemo(() => {\n    if (!formData.brand) return baseAvailableMaterials;\n    const brandKey = formData.brand.toLowerCase();\n    const materialKeys = brandToMaterials.get(brandKey);\n    if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;\n    return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));\n  }, [baseAvailableMaterials, formData.brand, brandToMaterials]);\n\n  const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {\n    setFormData(prev => ({ ...prev, [key]: value }));\n    if (errors[key]) {\n      setErrors(prev => ({ ...prev, [key]: undefined }));\n    }\n  };\n\n  const handleColorUsed = (color: ColorPreset) => {\n    setRecentColors(prev => saveRecentColor(color, prev));\n  };\n\n  const saveKProfiles = async (spoolId: number) => {\n    if (selectedProfiles.size === 0) {\n      try {\n        await api.saveSpoolKProfiles(spoolId, []);\n      } catch {\n        // ignore\n      }\n      return;\n    }\n\n    const profiles = [];\n    for (const key of selectedProfiles) {\n      const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');\n      const printerId = parseInt(printerIdStr);\n      const caliIdx = parseInt(caliIdxStr);\n      const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);\n\n      const pc = printersWithCalibrations.find(p => p.printer.id === printerId);\n      if (pc) {\n        const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);\n        if (cal) {\n          profiles.push({\n            printer_id: printerId,\n            extruder,\n            nozzle_diameter: cal.nozzle_diameter || '0.4',\n            k_value: cal.k_value,\n            name: cal.name || null,\n            cali_idx: cal.cali_idx,\n            setting_id: cal.setting_id || null,\n          });\n        }\n      }\n    }\n\n    if (profiles.length > 0) {\n      await api.saveSpoolKProfiles(spoolId, profiles);\n    }\n  };\n\n  const handleCreate = async () => {\n    setCreateError(null);\n    const validation = validateForm(formData, viewMode === 'simple' ? true : quickAdd);\n    if (!validation.isValid) {\n      setErrors(validation.errors);\n      setActiveSubTab('filament');\n      return;\n    }\n\n    const presetName = selectedPresetOption?.displayName || presetInputValue || null;\n    const payload = {\n      material: formData.material,\n      subtype: formData.subtype || null,\n      brand: formData.brand || null,\n      color_name: formData.color_name || null,\n      rgba: formData.rgba || null,\n      label_weight: formData.label_weight,\n      core_weight: formData.core_weight,\n      core_weight_catalog_id: formData.core_weight_catalog_id,\n      weight_used: formData.weight_used,\n      slicer_filament: formData.slicer_filament || null,\n      slicer_filament_name: presetName,\n      nozzle_temp_min: null,\n      nozzle_temp_max: null,\n      note: formData.note || null,\n      cost_per_kg: formData.cost_per_kg,\n      added_full: null,\n      last_used: null,\n      encode_time: null,\n      tag_uid: null,\n      tray_uuid: null,\n      data_origin: null,\n      tag_type: null,\n      last_scale_weight: null,\n      last_weighed_at: null,\n    };\n\n    setCreating(true);\n    try {\n      if (quantity > 1) {\n        const created = await api.bulkCreateSpools(payload, quantity);\n        for (const spool of created) {\n          await saveKProfiles(spool.id);\n        }\n        if (created.length > 0) onCreated(created[0]);\n      } else {\n        const created = await api.createSpool(payload);\n        await saveKProfiles(created.id);\n        onCreated(created);\n      }\n    } catch {\n      setCreateError(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));\n    } finally {\n      setCreating(false);\n    }\n  };\n\n  const simpleColorHex = `#${(formData.rgba || '808080FF').slice(0, 6)}`;\n\n  return (\n    <div className=\"p-3 space-y-3 overflow-y-auto h-full\">\n      <div className=\"flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary\">\n        <span className=\"text-sm text-zinc-200\">{t('spoolbuddy.writeTag.viewMode', 'View')}</span>\n        <div className=\"flex rounded-lg overflow-hidden border border-bambu-dark-tertiary\">\n          <button\n            type=\"button\"\n            onClick={() => setViewMode('simple')}\n            className={`px-3 py-1.5 text-xs font-medium ${\n              viewMode === 'simple' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'\n            }`}\n          >\n            {t('spoolbuddy.writeTag.simpleView', 'Simple')}\n          </button>\n          <button\n            type=\"button\"\n            onClick={() => setViewMode('full')}\n            className={`px-3 py-1.5 text-xs font-medium ${\n              viewMode === 'full' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'\n            }`}\n          >\n            {t('spoolbuddy.writeTag.fullView', 'Full')}\n          </button>\n        </div>\n      </div>\n\n      {viewMode === 'simple' ? (\n        selectedSpool ? (\n          <div className=\"flex flex-col items-center justify-center h-full p-6 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\">\n            <div\n              className=\"w-12 h-12 rounded-full mb-4 border border-white/10\"\n              style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}\n            />\n            <p className=\"text-white font-medium\">\n              {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}\n            </p>\n            {selectedSpool.color_name && <p className=\"text-zinc-400 text-sm\">{selectedSpool.color_name}</p>}\n            <p className=\"text-zinc-500 text-xs mt-1\">{selectedSpool.label_weight}g</p>\n            <p className=\"text-bambu-green text-sm mt-4\">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>\n          </div>\n        ) : (\n          <div className=\"p-4 space-y-4 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\">\n            <div>\n              <label className=\"block text-xs text-zinc-400 mb-1\">{t('spoolbuddy.writeTag.material', 'Material')}</label>\n              <select\n                value={formData.material}\n                onChange={(e) => updateField('material', e.target.value)}\n                className=\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green\"\n              >\n                {SIMPLE_COMMON_MATERIALS.map((m) => (\n                  <option key={m} value={m}>{m}</option>\n                ))}\n              </select>\n            </div>\n\n            <div className=\"flex gap-3\">\n              <div className=\"flex-1\">\n                <label className=\"block text-xs text-zinc-400 mb-1\">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>\n                <input\n                  type=\"text\"\n                  value={formData.color_name}\n                  onChange={(e) => updateField('color_name', e.target.value)}\n                  placeholder=\"Jade White\"\n                  className=\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green\"\n                />\n              </div>\n              <div>\n                <label className=\"block text-xs text-zinc-400 mb-1\">{t('spoolbuddy.writeTag.color', 'Color')}</label>\n                <input\n                  type=\"color\"\n                  value={simpleColorHex}\n                  onChange={(e) => updateField('rgba', e.target.value.replace('#', '').toUpperCase() + 'FF')}\n                  className=\"w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer\"\n                />\n              </div>\n            </div>\n\n            <div>\n              <label className=\"block text-xs text-zinc-400 mb-1\">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>\n              <input\n                type=\"text\"\n                value={formData.brand}\n                onChange={(e) => updateField('brand', e.target.value)}\n                placeholder=\"Polymaker\"\n                className=\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green\"\n              />\n            </div>\n\n            <div>\n              <label className=\"block text-xs text-zinc-400 mb-1\">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>\n              <input\n                type=\"number\"\n                value={formData.label_weight}\n                onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}\n                min={0}\n                max={10000}\n                className=\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green\"\n              />\n            </div>\n\n            <button\n              onClick={handleCreate}\n              disabled={creating || !formData.material}\n              className=\"w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors\"\n            >\n              {creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}\n            </button>\n          </div>\n        )\n      ) : (\n        <>\n      <div className=\"flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary\">\n        <span className=\"text-sm text-zinc-200\">{t('inventory.quickAdd', 'Quick Add')}</span>\n        <button\n          type=\"button\"\n          onClick={() => setQuickAdd((prev) => !prev)}\n          className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${\n            quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'\n          }`}\n        >\n          <span className={`inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform ${quickAdd ? 'translate-x-6' : 'translate-x-1'}`} />\n        </button>\n      </div>\n\n      <div className=\"flex border border-bambu-dark-tertiary rounded-lg overflow-hidden\">\n        <button\n          onClick={() => setActiveSubTab('filament')}\n          className={`flex-1 py-2.5 text-sm font-medium ${\n            activeSubTab === 'filament' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'\n          }`}\n        >\n          {t('inventory.filamentInfoTab', 'Filament')}\n        </button>\n        {!quickAdd && (\n          <button\n            onClick={() => setActiveSubTab('pa-profile')}\n            className={`flex-1 py-2.5 text-sm font-medium ${\n              activeSubTab === 'pa-profile' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'\n            }`}\n          >\n            {t('inventory.paProfileTab', 'PA Profile')}\n          </button>\n        )}\n      </div>\n\n      <div className=\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-3\">\n        {activeSubTab === 'filament' ? (\n          <div className=\"space-y-4\">\n            <FilamentSection\n              formData={formData}\n              updateField={updateField}\n              cloudAuthenticated={cloudAuthenticated}\n              loadingCloudPresets={loadingCloudPresets}\n              presetInputValue={presetInputValue}\n              setPresetInputValue={setPresetInputValue}\n              selectedPresetOption={selectedPresetOption}\n              filamentOptions={filamentOptions}\n              availableBrands={availableBrands}\n              availableMaterials={availableMaterials}\n              quickAdd={quickAdd}\n              quantity={quantity}\n              onQuantityChange={setQuantity}\n              errors={errors}\n            />\n\n            <ColorSection\n              formData={formData}\n              updateField={updateField}\n              recentColors={recentColors}\n              onColorUsed={handleColorUsed}\n              catalogColors={colorCatalog}\n            />\n\n            <AdditionalSection\n              formData={formData}\n              updateField={updateField}\n              spoolCatalog={spoolCatalog}\n              currencySymbol={currencySymbol}\n            />\n          </div>\n        ) : (\n          <PAProfileSection\n            formData={formData}\n            updateField={updateField}\n            printersWithCalibrations={printersWithCalibrations}\n            selectedProfiles={selectedProfiles}\n            setSelectedProfiles={setSelectedProfiles}\n            expandedPrinters={expandedPrinters}\n            setExpandedPrinters={setExpandedPrinters}\n          />\n        )}\n      </div>\n        </>\n      )}\n\n      {createError && (\n        <div className=\"text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded-lg px-3 py-2\">\n          {createError}\n        </div>\n      )}\n\n      {viewMode === 'full' && (\n        <button\n          onClick={handleCreate}\n          disabled={creating}\n          className=\"w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors\"\n        >\n          {creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}\n        </button>\n      )}\n\n      {viewMode === 'full' && selectedSpool && (\n        <div className=\"flex flex-col items-center justify-center p-4 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\">\n          <div\n            className=\"w-12 h-12 rounded-full mb-4 border border-white/10\"\n            style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}\n          />\n          <p className=\"text-white font-medium\">\n            {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}\n          </p>\n          {selectedSpool.color_name && <p className=\"text-zinc-400 text-sm\">{selectedSpool.color_name}</p>}\n          <p className=\"text-zinc-500 text-xs mt-1\">{selectedSpool.label_weight}g</p>\n          <p className=\"text-bambu-green text-sm mt-4\">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>\n        </div>\n      )}\n    </div>\n  );\n}\n\n// --- NFC status panel ---\nfunction NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, canUntag, untagging, onWrite, onUntag, onCancel, onRetry, t }: {\n  writeStatus: WriteStatus;\n  writeMessage: string;\n  selectedSpool: InventorySpool | null;\n  tagOnReader: boolean;\n  tagUid: string | null;\n  deviceOnline: boolean;\n  canWrite: boolean;\n  isReplace: boolean;\n  canUntag: boolean;\n  untagging: boolean;\n  onWrite: () => void;\n  onUntag: () => void;\n  onCancel: () => void;\n  onRetry: () => void;\n  t: (key: string, fallback: string) => string;\n}) {\n  // Success state\n  if (writeStatus === 'success') {\n    return (\n      <div className=\"flex flex-col items-center text-center space-y-4\">\n        <div className=\"w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center\">\n          <svg className=\"w-8 h-8 text-green-400\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\n          </svg>\n        </div>\n        <p className=\"text-green-400 font-medium\">{writeMessage}</p>\n        {selectedSpool && (\n          <p className=\"text-zinc-400 text-sm\">\n            {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}\n            {selectedSpool.color_name ? ` - ${selectedSpool.color_name}` : ''}\n          </p>\n        )}\n      </div>\n    );\n  }\n\n  // Error state\n  if (writeStatus === 'error') {\n    return (\n      <div className=\"flex flex-col items-center text-center space-y-4\">\n        <div className=\"w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center\">\n          <svg className=\"w-8 h-8 text-red-400\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n          </svg>\n        </div>\n        <p className=\"text-red-400 font-medium\">{writeMessage}</p>\n        <button\n          onClick={onRetry}\n          className=\"px-4 py-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-white text-sm rounded transition-colors\"\n        >\n          {t('spoolbuddy.writeTag.tryAgain', 'Try Again')}\n        </button>\n      </div>\n    );\n  }\n\n  // Writing state\n  if (writeStatus === 'writing') {\n    return (\n      <div className=\"flex flex-col items-center text-center space-y-4\">\n        <div className=\"relative w-16 h-16\">\n          <div className=\"absolute inset-0 rounded-full border-2 border-bambu-green/30 animate-ping\" />\n          <div className=\"absolute inset-2 rounded-full border-2 border-bambu-green/50 animate-pulse\" />\n          <div className=\"absolute inset-0 flex items-center justify-center\">\n            <NfcIcon className=\"w-8 h-8 text-bambu-green\" />\n          </div>\n        </div>\n        <p className=\"text-bambu-green font-medium\">{t('spoolbuddy.writeTag.writing', 'Writing tag...')}</p>\n        <p className=\"text-zinc-500 text-xs\">{writeMessage}</p>\n        <button\n          onClick={onCancel}\n          className=\"px-4 py-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-400 text-sm rounded transition-colors\"\n        >\n          {t('spoolbuddy.writeTag.cancel', 'Cancel')}\n        </button>\n      </div>\n    );\n  }\n\n  // Device offline\n  if (!deviceOnline) {\n    return (\n      <div className=\"flex flex-col items-center text-center space-y-3\">\n        <NfcIcon className=\"w-12 h-12 text-zinc-600\" />\n        <p className=\"text-zinc-500 text-sm\">{t('spoolbuddy.writeTag.deviceOffline', 'SpoolBuddy is offline')}</p>\n      </div>\n    );\n  }\n\n  // No spool selected\n  if (!selectedSpool) {\n    return (\n      <div className=\"flex flex-col items-center text-center space-y-3\">\n        <NfcIcon className=\"w-12 h-12 text-zinc-600\" />\n        <p className=\"text-zinc-400 text-sm\">{t('spoolbuddy.writeTag.selectSpool', 'Select a spool, then place a blank NTAG on the reader')}</p>\n      </div>\n    );\n  }\n\n  // Spool selected — show summary + write button\n  const spoolColor = selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666';\n\n  return (\n    <div className=\"flex flex-col items-center text-center space-y-4 w-full\">\n      {/* NFC indicator */}\n      <div className=\"relative w-16 h-16\">\n        {tagOnReader ? (\n          <>\n            <div className=\"absolute inset-0 rounded-full bg-bambu-green/10\" />\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n              <NfcIcon className=\"w-8 h-8 text-bambu-green\" />\n            </div>\n          </>\n        ) : (\n          <>\n            <div className=\"absolute inset-0 rounded-full border-2 border-zinc-600 animate-pulse\" />\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n              <NfcIcon className=\"w-8 h-8 text-zinc-500\" />\n            </div>\n          </>\n        )}\n      </div>\n\n      {tagOnReader ? (\n        <div className=\"space-y-1\">\n          <p className=\"text-bambu-green text-sm font-medium\">{t('spoolbuddy.writeTag.tagReady', 'Tag detected — ready to write')}</p>\n          {tagUid && <p className=\"text-zinc-500 text-xs font-mono\">{tagUid}</p>}\n        </div>\n      ) : (\n        <p className=\"text-zinc-400 text-sm\">{t('spoolbuddy.writeTag.placeTag', 'Place an NTAG on the reader')}</p>\n      )}\n\n      {/* Selected spool summary */}\n      <div className=\"w-full bg-bambu-dark-secondary rounded-lg p-3 space-y-2\">\n        <div className=\"flex items-center gap-3\">\n          <div className=\"w-8 h-8 rounded-full border border-white/10 shrink-0\" style={{ backgroundColor: spoolColor }} />\n          <div className=\"text-left min-w-0\">\n            <p className=\"text-white text-sm font-medium truncate\">\n              {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}\n            </p>\n            {selectedSpool.color_name && <p className=\"text-zinc-400 text-xs\">{selectedSpool.color_name}</p>}\n          </div>\n        </div>\n        <div className=\"text-xs text-zinc-500\">{selectedSpool.label_weight}g</div>\n      </div>\n\n      {/* Replace warning */}\n      {isReplace && selectedSpool.tag_uid && (\n        <p className=\"text-yellow-500/80 text-xs\">\n          {t('spoolbuddy.writeTag.replaceWarning', 'Old tag will be unlinked. New tag will replace it.')}\n        </p>\n      )}\n\n      {/* Write button */}\n      <button\n        onClick={onWrite}\n        disabled={!canWrite}\n        className=\"w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-40 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors text-sm\"\n      >\n        {isReplace\n          ? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')\n          : t('spoolbuddy.writeTag.writeTag', 'Write Tag')}\n      </button>\n\n      {isReplace && canUntag && (\n        <button\n          onClick={onUntag}\n          disabled={untagging}\n          className=\"w-full py-2.5 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary disabled:opacity-40 disabled:cursor-not-allowed text-zinc-200 rounded-lg transition-colors text-sm\"\n        >\n          {untagging\n            ? t('spoolbuddy.writeTag.untagging', 'Removing tag...')\n            : t('spoolbuddy.writeTag.untagSpool', 'Untag Selected Spool')}\n        </button>\n      )}\n\n    </div>\n  );\n}\n\n// --- NFC icon ---\nfunction NfcIcon({ className }: { className?: string }) {\n  return (\n    <svg className={className} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5}>\n      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0\" />\n      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/types/plates.ts",
    "content": "export interface PlateFilament {\n  slot_id: number;\n  type: string;\n  color: string;\n  used_grams: number;\n  used_meters: number;\n}\n\nexport interface PlateMetadata {\n  index: number;\n  name: string | null;\n  objects: string[];\n  object_count?: number;\n  has_thumbnail: boolean;\n  thumbnail_url: string | null;\n  print_time_seconds: number | null;\n  filament_used_grams: number | null;\n  filaments: PlateFilament[];\n}\n\nexport interface ArchivePlatesResponse {\n  archive_id: number;\n  filename: string;\n  plates: PlateMetadata[];\n  is_multi_plate: boolean;\n}\n\nexport interface LibraryFilePlatesResponse {\n  file_id: number;\n  filename: string;\n  plates: PlateMetadata[];\n  is_multi_plate: boolean;\n}\n\nexport interface ViewerPlateSelectionState {\n  selected_plate_id: number | null;\n}\n\nexport interface PlateAssignment {\n  object_id: string;\n  plate_id: number | null;\n}\n"
  },
  {
    "path": "frontend/src/utils/amsHelpers.ts",
    "content": "/**\n * AMS (Automatic Material System) helper utilities for Bambu Lab printers.\n * These functions handle color normalization, slot labeling, and tray ID calculations\n * for AMS, AMS-HT, and external spool configurations.\n */\nimport { parseUTCDate } from './date';\n\n/**\n * Normalize color format from various sources.\n * API returns \"RRGGBBAA\" (8-char), 3MF uses \"#RRGGBB\" (7-char with hash).\n * This normalizes to \"#RRGGBB\" format.\n */\nexport function normalizeColor(color: string | null | undefined): string {\n  if (!color) return '#808080';\n  // Remove alpha channel if present (8-char hex to 6-char)\n  const hex = color.replace('#', '').substring(0, 6);\n  return `#${hex}`;\n}\n\n/**\n * Normalize color for comparison (case-insensitive, strip hash and alpha).\n */\nexport function normalizeColorForCompare(color: string | undefined): string {\n  if (!color) return '';\n  return color.replace('#', '').toLowerCase().substring(0, 6);\n}\n\n/**\n * Filament type equivalence groups.\n * Types within the same group are interchangeable on the printer side\n * (e.g., Bambu Lab firmware treats PA-CF and PA12-CF as compatible).\n */\nconst FILAMENT_TYPE_GROUPS: string[][] = [\n  ['PA-CF', 'PA12-CF', 'PAHT-CF'],\n];\n\nconst _equivalenceMap: Record<string, string> = {};\nfor (const group of FILAMENT_TYPE_GROUPS) {\n  const canonical = group[0];\n  for (const t of group) {\n    _equivalenceMap[t.toUpperCase()] = canonical.toUpperCase();\n  }\n}\n\n/**\n * Get the canonical filament type for equivalence matching.\n * Types in the same group (e.g., PA-CF / PA12-CF / PAHT-CF) return the same canonical type.\n */\nexport function canonicalFilamentType(type: string | undefined): string {\n  if (!type) return '';\n  const upper = type.toUpperCase();\n  return _equivalenceMap[upper] ?? upper;\n}\n\n/**\n * Check if two filament types are compatible (same type or same equivalence group).\n */\nexport function filamentTypesCompatible(a: string | undefined, b: string | undefined): boolean {\n  return canonicalFilamentType(a) === canonicalFilamentType(b);\n}\n\n/**\n * Check if two colors are visually similar within a threshold.\n * Uses RGB component comparison with configurable tolerance.\n * @param color1 - First hex color\n * @param color2 - Second hex color\n * @param threshold - Maximum difference per RGB component (default: 40)\n */\nexport function colorsAreSimilar(\n  color1: string | undefined,\n  color2: string | undefined,\n  threshold = 40\n): boolean {\n  const hex1 = normalizeColorForCompare(color1);\n  const hex2 = normalizeColorForCompare(color2);\n  if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;\n\n  const r1 = parseInt(hex1.substring(0, 2), 16);\n  const g1 = parseInt(hex1.substring(2, 4), 16);\n  const b1 = parseInt(hex1.substring(4, 6), 16);\n  const r2 = parseInt(hex2.substring(0, 2), 16);\n  const g2 = parseInt(hex2.substring(2, 4), 16);\n  const b2 = parseInt(hex2.substring(4, 6), 16);\n\n  return (\n    Math.abs(r1 - r2) <= threshold &&\n    Math.abs(g1 - g2) <= threshold &&\n    Math.abs(b1 - b2) <= threshold\n  );\n}\n\n/**\n * Format slot label for display in the UI.\n * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)\n * @param trayId - Tray/slot ID within the AMS unit (0-3)\n * @param isHt - Whether this is an AMS-HT unit (single tray)\n * @param isExternal - Whether this is the external spool holder\n */\nexport function formatSlotLabel(\n  amsId: number,\n  trayId: number,\n  isHt: boolean,\n  isExternal: boolean\n): string {\n  if (isExternal) return 'Ext';\n  // Convert AMS ID to letter (A, B, C, D)\n  // AMS-HT uses IDs starting at 128\n  const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));\n  if (isHt) return `HT-${letter}`;\n  return `${letter}${trayId + 1}`;\n}\n\n/**\n * Calculate global tray ID for MQTT command.\n * Used in the ams_mapping array sent to the printer.\n * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)\n * @param trayId - Tray/slot ID within the AMS unit\n * @param isExternal - Whether this is the external spool holder\n * @returns Global tray ID (0-15 for AMS, 128+ for AMS-HT, 254 for external)\n */\nexport function getGlobalTrayId(\n  amsId: number,\n  trayId: number,\n  isExternal: boolean\n): number {\n  if (isExternal) return 254 + trayId;\n  // AMS-HT units have IDs starting at 128 with a single tray — use ID directly\n  if (amsId >= 128) return amsId;\n  return amsId * 4 + trayId;\n}\n\n/**\n * Get fill bar color based on spool fill level.\n * Matches PrintersPage thresholds and Bambu Lab brand green.\n */\nexport function getFillBarColor(fillLevel: number): string {\n  if (fillLevel > 50) return '#00ae42'; // Green - good\n  if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)\n  return '#ef4444'; // Red - critical (< 15%)\n}\n\n/**\n * Calculate fill level from Spoolman weight data.\n * Used as the first source in the Spoolman → Inventory → AMS fill chain.\n */\nexport function getSpoolmanFillLevel(\n  linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | undefined\n): number | null {\n  if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight\n      || linkedSpool.filament_weight <= 0) return null;\n  return Math.min(100, Math.round(\n    (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100\n  ));\n}\n\nfunction toFixedHex(value: number, width: number): string {\n  const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;\n  return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);\n}\n\n// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)\nfunction hashSerialToHex32(serial: string): string {\n  const input = (serial || '').trim().toUpperCase();\n  let hash = 0x811c9dc5;\n  for (let i = 0; i < input.length; i++) {\n    hash ^= input.charCodeAt(i);\n    hash = Math.imul(hash, 0x01000193);\n  }\n  return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');\n}\n\n/**\n * Generate a stable fallback spool tag for slots without RFID identifiers.\n * Returns a 16-char hex string derived from the printer serial + slot position.\n */\nexport function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {\n  return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;\n}\n\n/**\n * Get minimum datetime for scheduling (now + 1 minute).\n * Returns ISO string format for datetime-local input.\n */\nexport function getMinDateTime(): string {\n  const now = new Date();\n  now.setMinutes(now.getMinutes() + 1);\n  return now.toISOString().slice(0, 16);\n}\n\n/**\n * Check if a scheduled time is a placeholder far-future date.\n * Placeholder dates (more than 6 months out) are treated as ASAP.\n */\nexport function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {\n  if (!scheduledTime) return false;\n  const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;\n  return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;\n}\n\n/**\n * Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.\n * Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.\n */\nexport function autoMatchFilament(\n  req: { type?: string; color?: string; nozzle_id?: number | null },\n  loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number; remain?: number }[],\n  usedTrayIds: Set<number>,\n  preferLowest?: boolean,\n): typeof loadedFilaments[number] | undefined {\n  let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);\n\n  if (preferLowest) {\n    nozzleFilaments = [...nozzleFilaments].sort((a, b) => {\n      const ra = (a.remain ?? -1) >= 0 ? (a.remain ?? -1) : 101;\n      const rb = (b.remain ?? -1) >= 0 ? (b.remain ?? -1) : 101;\n      return ra - rb;\n    });\n  }\n\n  const exactMatch = nozzleFilaments.find(\n    (f) =>\n      !usedTrayIds.has(f.globalTrayId) &&\n      filamentTypesCompatible(f.type, req.type) &&\n      normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)\n  );\n  const similarMatch = exactMatch\n    ? undefined\n    : nozzleFilaments.find(\n        (f) =>\n          !usedTrayIds.has(f.globalTrayId) &&\n          filamentTypesCompatible(f.type, req.type) &&\n          colorsAreSimilar(f.color, req.color)\n      );\n  const typeOnlyMatch =\n    exactMatch || similarMatch\n      ? undefined\n      : nozzleFilaments.find(\n          (f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type)\n        );\n  return exactMatch ?? similarMatch ?? typeOnlyMatch;\n}\n\n/**\n * Filter loaded filaments to those valid for a given nozzle requirement.\n * For single-nozzle printers (nozzle_id is null/undefined), returns all filaments.\n */\nexport function filterFilamentsByNozzle<T extends { extruderId?: number }>(\n  loadedFilaments: T[],\n  nozzleId: number | undefined | null,\n): T[] {\n  return loadedFilaments.filter(\n    (f) => nozzleId == null || f.extruderId === nozzleId\n  );\n}\n"
  },
  {
    "path": "frontend/src/utils/colors.ts",
    "content": "// Runtime color-name catalog, populated once at app startup by ColorCatalogProvider\n// from /api/inventory/colors/map. The backend color_catalog table is the single\n// source of truth — no hardcoded hex→name tables live on the frontend anymore.\n//\n// Keyed by lowercase 6-char hex (no leading '#'). Lookups before the provider has\n// fetched the catalog fall through to hexToColorName (HSL-based bucketing). A\n// subscribe/getSnapshot pair lets React components re-render via\n// useSyncExternalStore when the catalog loads, so pages that mount before the\n// fetch resolves (InventoryPage, PrintersPage) update to the catalog name once it\n// arrives instead of staying stuck on the HSL fallback.\n\nlet runtimeColorCatalog: Record<string, string> = {};\nlet catalogVersion = 0;\nconst catalogListeners = new Set<() => void>();\n\nexport function setColorCatalog(map: Record<string, string>): void {\n  // Normalize keys to lowercase 6-char hex (no '#'), defensively. Backend already\n  // does this, but the frontend contract is explicit so callers from tests or\n  // future integrations can't accidentally break lookups.\n  const normalized: Record<string, string> = {};\n  for (const [key, value] of Object.entries(map)) {\n    if (!key || !value) continue;\n    const hex = key.replace('#', '').toLowerCase().slice(0, 6);\n    if (hex.length === 6) normalized[hex] = value;\n  }\n  runtimeColorCatalog = normalized;\n  catalogVersion += 1;\n  // Snapshot listeners to avoid mutation-during-iteration if a listener unsubscribes.\n  for (const listener of Array.from(catalogListeners)) {\n    listener();\n  }\n}\n\nexport function subscribeColorCatalog(listener: () => void): () => void {\n  catalogListeners.add(listener);\n  return () => {\n    catalogListeners.delete(listener);\n  };\n}\n\nexport function getColorCatalogVersion(): number {\n  return catalogVersion;\n}\n\n/** Test-only hook: reset the catalog to empty so unit tests can exercise fallbacks. */\nexport function __resetColorCatalogForTests(): void {\n  runtimeColorCatalog = {};\n  catalogVersion = 0;\n  catalogListeners.clear();\n}\n\n/**\n * Convert hex color to basic color name using HSL analysis.\n * Used as fallback when hex is not in the runtime catalog.\n */\nexport function hexToColorName(hex: string | null | undefined): string {\n  if (!hex || hex.length < 6) return 'Unknown';\n  const cleanHex = hex.replace('#', '');\n  const r = parseInt(cleanHex.substring(0, 2), 16);\n  const g = parseInt(cleanHex.substring(2, 4), 16);\n  const b = parseInt(cleanHex.substring(4, 6), 16);\n\n  const max = Math.max(r, g, b) / 255;\n  const min = Math.min(r, g, b) / 255;\n  const l = (max + min) / 2;\n\n  let h = 0;\n  let s = 0;\n\n  if (max !== min) {\n    const d = max - min;\n    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n    const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;\n    if (max === rNorm) h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;\n    else if (max === gNorm) h = ((bNorm - rNorm) / d + 2) / 6;\n    else h = ((rNorm - gNorm) / d + 4) / 6;\n  }\n  h = h * 360;\n\n  if (l < 0.15) return 'Black';\n  if (l > 0.85) return 'White';\n  if (s < 0.15) {\n    if (l < 0.4) return 'Dark Gray';\n    if (l > 0.6) return 'Light Gray';\n    return 'Gray';\n  }\n  // Brown is orange/yellow hue with lower lightness\n  if (h >= 15 && h < 45 && l < 0.45) return 'Brown';\n  if (h >= 45 && h < 70 && l < 0.40) return 'Brown';\n  if (h < 15 || h >= 345) return 'Red';\n  if (h < 45) return 'Orange';\n  if (h < 70) return 'Yellow';\n  if (h < 150) return 'Green';\n  if (h < 200) return 'Cyan';\n  if (h < 260) return 'Blue';\n  if (h < 290) return 'Purple';\n  return 'Pink';\n}\n\n/**\n * Get color name from hex color.\n * Looks up the runtime color catalog (backend-sourced), then falls back to HSL.\n */\nexport function getColorName(hexColor: string): string {\n  if (!hexColor) return hexToColorName(hexColor);\n  const hex = hexColor.replace('#', '').toLowerCase().substring(0, 6);\n  const mapped = runtimeColorCatalog[hex];\n  if (mapped) return mapped;\n  return hexToColorName(hexColor);\n}\n\n/**\n * Resolve a spool's display color name.\n * Tries: stored color_name (if it's a readable name) → runtime catalog via rgba → null.\n * Detects Bambu internal codes (e.g. \"A06-D0\") and ignores them in favor of hex lookup\n * because the same code is not globally unique across material families (#857).\n */\nexport function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {\n  // If color_name looks like a readable name (no pattern like \"X00-Y0\"), use it directly\n  if (colorName && !/^[A-Z]\\d+-[A-Z]\\d+$/.test(colorName)) {\n    return colorName;\n  }\n  // Try hex color lookup from rgba via the runtime catalog\n  if (rgba && rgba.length >= 6) {\n    const hex = rgba.substring(0, 6).toLowerCase();\n    const mapped = runtimeColorCatalog[hex];\n    if (mapped) return mapped;\n  }\n  // Return null (displayed as \"-\") — better than showing a code\n  return null;\n}\n\n/**\n * Parse an RGBA hex string (e.g., \"FF0000FF\") to a CSS rgba() color.\n * Returns null for empty, all-zero, or fully transparent colors.\n */\nexport function parseFilamentColor(rgba: string): string | null {\n  if (!rgba || rgba === '00000000' || rgba.length < 6) return null;\n  const r = rgba.slice(0, 2);\n  const g = rgba.slice(2, 4);\n  const b = rgba.slice(4, 6);\n  const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1;\n  if (a === 0) return null;\n  return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;\n}\n\n/**\n * Check if a hex color is light (for choosing text contrast).\n * Uses luminance formula: 0.299*R + 0.587*G + 0.114*B.\n */\nexport function isLightColor(hex: string | null): boolean {\n  if (!hex || hex.length < 6) return false;\n  const cleanHex = hex.replace('#', '');\n  const r = parseInt(cleanHex.slice(0, 2), 16);\n  const g = parseInt(cleanHex.slice(2, 4), 16);\n  const b = parseInt(cleanHex.slice(4, 6), 16);\n  return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;\n}\n"
  },
  {
    "path": "frontend/src/utils/currency.ts",
    "content": "const CURRENCY_SYMBOLS: Record<string, string> = {\n  USD: '$',\n  EUR: '€',\n  GBP: '£',\n  CHF: 'Fr.',\n  JPY: '¥',\n  CNY: '¥',\n  CAD: '$',\n  AUD: '$',\n  INR: '₹',\n  HKD: 'HK$',\n  KRW: '₩',\n  SEK: 'kr',\n  NOK: 'kr',\n  DKK: 'kr',\n  PLN: 'zł',\n  BRL: 'R$',\n  TWD: 'NT$',\n  SGD: 'S$',\n  NZD: 'NZ$',\n  MXN: 'MX$',\n  MYR: 'RM',\n  CZK: 'Kč',\n  THB: '฿',\n  ZAR: 'R',\n  TRY: '₺',\n  RUB: '₽',\n  HUF: 'Ft',\n  ILS: '₪',\n  UAH: '₴',\n};\n\nexport function getCurrencySymbol(currencyCode: string): string {\n  return CURRENCY_SYMBOLS[currencyCode.toUpperCase()] || currencyCode;\n}\n\nexport const SUPPORTED_CURRENCIES = Object.entries(CURRENCY_SYMBOLS).map(([code, symbol]) => ({\n  code,\n  label: `${code} (${symbol})`,\n}));\n"
  },
  {
    "path": "frontend/src/utils/date.ts",
    "content": "/**\n * Date utilities for handling UTC timestamps from the backend.\n *\n * The backend stores all timestamps in UTC without timezone indicators.\n * These utilities ensure dates are properly interpreted as UTC and\n * displayed in the user's local timezone.\n */\n\nexport type TimeFormat = 'system' | '12h' | '24h';\nexport type DateFormat = 'system' | 'us' | 'eu' | 'iso';\n\n/**\n * Get the date input placeholder based on format setting.\n */\nexport function getDatePlaceholder(dateFormat: DateFormat = 'system'): string {\n  const resolved = dateFormat === 'system' ? detectSystemDateFormat() : dateFormat;\n  switch (resolved) {\n    case 'us': return 'MM/DD/YYYY';\n    case 'eu': return 'DD/MM/YYYY';\n    case 'iso': return 'YYYY-MM-DD';\n    default: return resolved satisfies never;\n  }\n}\n\n/**\n * Get the time input placeholder based on format setting.\n */\nexport function getTimePlaceholder(timeFormat: TimeFormat = 'system'): string {\n  switch (timeFormat) {\n    case '12h':\n      return 'HH:MM AM/PM';\n    case '24h':\n      return 'HH:MM';\n    case 'system':\n    default: {\n      // Try to detect system format\n      const testDate = new Date(2000, 0, 1, 14, 30);\n      const formatted = testDate.toLocaleTimeString();\n      if (formatted.includes('PM') || formatted.includes('AM')) return 'HH:MM AM/PM';\n      return 'HH:MM';\n    }\n  }\n}\n\n/**\n * Format a Date object to a date string based on format setting.\n */\nexport function formatDateInput(date: Date, dateFormat: DateFormat = 'system'): string {\n  const day = String(date.getDate()).padStart(2, '0');\n  const month = String(date.getMonth() + 1).padStart(2, '0');\n  const year = date.getFullYear();\n\n  switch (dateFormat) {\n    case 'us':\n      return `${month}/${day}/${year}`;\n    case 'eu':\n      return `${day}/${month}/${year}`;\n    case 'iso':\n      return `${year}-${month}-${day}`;\n    case 'system':\n    default:\n      return date.toLocaleDateString();\n  }\n}\n\n/**\n * Format a Date object to a time string based on format setting.\n */\nexport function formatTimeInput(date: Date, timeFormat: TimeFormat = 'system'): string {\n  const hours24 = date.getHours();\n  const minutes = String(date.getMinutes()).padStart(2, '0');\n\n  switch (timeFormat) {\n    case '12h': {\n      const hours12 = hours24 % 12 || 12;\n      const ampm = hours24 < 12 ? 'AM' : 'PM';\n      return `${hours12}:${minutes} ${ampm}`;\n    }\n    case '24h':\n      return `${String(hours24).padStart(2, '0')}:${minutes}`;\n    case 'system':\n    default:\n      return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n  }\n}\n\n/**\n * Split a date string by common separators (/, ., -).\n */\nfunction splitDateParts(value: string): string[] | null {\n  for (const sep of ['/', '.', '-']) {\n    const parts = value.split(sep);\n    if (parts.length === 3) return parts;\n  }\n  return null;\n}\n\nfunction detectSystemDateFormat(): 'us' | 'eu' | 'iso' {\n  const formatted = new Date(2000, 11, 31).toLocaleDateString();\n  if (formatted.startsWith('12')) return 'us';\n  if (formatted.startsWith('31')) return 'eu';\n  return 'iso';\n}\n\n/**\n * Parse a date string based on format setting.\n * Returns null if parsing fails.\n * Supports common separators: / . -\n */\nexport function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null {\n  if (!value) return null;\n\n  const parts = splitDateParts(value);\n  if (!parts) return null;\n\n  const resolved = dateFormat === 'system' ? detectSystemDateFormat() : dateFormat;\n  let day: number, month: number, year: number;\n\n  switch (resolved) {\n    case 'us':\n      month = parseInt(parts[0], 10);\n      day = parseInt(parts[1], 10);\n      year = parseInt(parts[2], 10);\n      break;\n    case 'eu':\n      day = parseInt(parts[0], 10);\n      month = parseInt(parts[1], 10);\n      year = parseInt(parts[2], 10);\n      break;\n    case 'iso':\n      year = parseInt(parts[0], 10);\n      month = parseInt(parts[1], 10);\n      day = parseInt(parts[2], 10);\n      break;\n  }\n\n  if (isNaN(day) || isNaN(month) || isNaN(year)) return null;\n  if (month < 1 || month > 12) return null;\n  if (day < 1 || day > 31) return null;\n  if (year < 1900 || year > 2100) return null;\n\n  return new Date(year, month - 1, day);\n}\n\n/**\n * Parse a time string. Handles both 12h (with AM/PM) and 24h formats.\n * Returns { hours, minutes } or null if parsing fails.\n */\nexport function parseTimeInput(value: string): { hours: number; minutes: number } | null {\n  if (!value) return null;\n\n  const trimmed = value.trim();\n\n  const match = trimmed.match(/^(\\d{1,2}):(\\d{2})\\s*(AM|PM)?$/i);\n  if (!match) return null;\n\n  let hours = parseInt(match[1], 10);\n  const minutes = parseInt(match[2], 10);\n  const ampm = match[3]?.toUpperCase();\n\n  if (ampm === 'PM' && hours < 12) hours += 12;\n  if (ampm === 'AM' && hours === 12) hours = 0;\n\n  if (hours < 0 || hours > 23) return null;\n  if (minutes < 0 || minutes > 59) return null;\n\n  return { hours, minutes };\n}\n\n/**\n * Convert a Date object to datetime-local input value (ISO format).\n */\nexport function toDateTimeLocalValue(date: Date): string {\n  const year = date.getFullYear();\n  const month = String(date.getMonth() + 1).padStart(2, '0');\n  const day = String(date.getDate()).padStart(2, '0');\n  const hours = String(date.getHours()).padStart(2, '0');\n  const minutes = String(date.getMinutes()).padStart(2, '0');\n  return `${year}-${month}-${day}T${hours}:${minutes}`;\n}\n\n/**\n * Apply time format setting to Intl.DateTimeFormatOptions.\n * This modifies the options object in place and returns it.\n */\nexport function applyTimeFormat(\n  options: Intl.DateTimeFormatOptions,\n  timeFormat: TimeFormat = 'system'\n): Intl.DateTimeFormatOptions {\n  if (timeFormat === '12h') {\n    options.hour12 = true;\n  } else if (timeFormat === '24h') {\n    options.hour12 = false;\n  }\n  // 'system' leaves hour12 undefined, letting the browser decide\n  return options;\n}\n\n/**\n * Parse a date string from the backend as UTC.\n * Handles ISO 8601 strings with or without timezone indicators.\n *\n * @param dateStr - Date string from backend (e.g., \"2026-01-09T12:03:36.288768\")\n * @returns Date object in local timezone\n */\nexport function parseUTCDate(dateStr: string | null | undefined): Date | null {\n  if (!dateStr) return null;\n\n  // If the string already has a timezone indicator, parse as-is\n  if (dateStr.endsWith('Z') || /[+-]\\d{2}:\\d{2}$/.test(dateStr)) {\n    return new Date(dateStr);\n  }\n\n  // Otherwise, append 'Z' to interpret as UTC\n  return new Date(dateStr + 'Z');\n}\n\n/**\n * Format a UTC date string to a localized date/time string.\n *\n * @param dateStr - Date string from backend\n * @param options - Intl.DateTimeFormat options (defaults to showing date and time)\n * @returns Formatted date string in user's locale and timezone\n */\nexport function formatDate(\n  dateStr: string | null | undefined,\n  options?: Intl.DateTimeFormatOptions\n): string {\n  const date = parseUTCDate(dateStr);\n  if (!date) return '';\n\n  const defaultOptions: Intl.DateTimeFormatOptions = {\n    year: 'numeric',\n    month: 'short',\n    day: 'numeric',\n    hour: '2-digit',\n    minute: '2-digit',\n  };\n\n  return date.toLocaleString(undefined, options ?? defaultOptions);\n}\n\n/**\n * Format a UTC date string to a localized date-only string.\n *\n * @param dateStr - Date string from backend\n * @param options - Intl.DateTimeFormat options\n * @returns Formatted date string in user's locale and timezone\n */\nexport function formatDateOnly(\n  dateStr: string | null | undefined,\n  options?: Intl.DateTimeFormatOptions\n): string {\n  const date = parseUTCDate(dateStr);\n  if (!date) return '';\n\n  const defaultOptions: Intl.DateTimeFormatOptions = {\n    year: 'numeric',\n    month: 'short',\n    day: 'numeric',\n  };\n\n  return date.toLocaleDateString(undefined, options ?? defaultOptions);\n}\n\n/**\n * Format a UTC date string to a localized date/time string with time format support.\n *\n * @param dateStr - Date string from backend\n * @param timeFormat - Time format setting ('system', '12h', '24h')\n * @param options - Intl.DateTimeFormat options (defaults to showing date and time)\n * @returns Formatted date string in user's locale and timezone\n */\nexport function formatDateTime(\n  dateStr: string | null | undefined,\n  timeFormat: TimeFormat = 'system',\n  options?: Intl.DateTimeFormatOptions\n): string {\n  const date = parseUTCDate(dateStr);\n  if (!date) return '';\n\n  const defaultOptions: Intl.DateTimeFormatOptions = {\n    year: 'numeric',\n    month: 'short',\n    day: 'numeric',\n    hour: '2-digit',\n    minute: '2-digit',\n  };\n\n  const finalOptions = applyTimeFormat(options ?? defaultOptions, timeFormat);\n  return date.toLocaleString(undefined, finalOptions);\n}\n\n/**\n * Format a Date object to a localized time string with time format support.\n *\n * @param date - Date object\n * @param timeFormat - Time format setting ('system', '12h', '24h')\n * @param options - Additional Intl.DateTimeFormat options\n * @returns Formatted time string\n */\nexport function formatTimeOnly(\n  date: Date,\n  timeFormat: TimeFormat = 'system',\n  options?: Intl.DateTimeFormatOptions\n): string {\n  const defaultOptions: Intl.DateTimeFormatOptions = {\n    hour: '2-digit',\n    minute: '2-digit',\n  };\n\n  const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);\n  return date.toLocaleTimeString([], finalOptions);\n}\n\n/**\n * Calculate and format an ETA based on remaining minutes from now.\n *\n * @param remainingMinutes - Minutes until completion\n * @param timeFormat - Time format setting ('system', '12h', '24h')\n * @param t - Optional i18n translation function\n * @returns Formatted ETA string (e.g., \"3:45 PM\", \"Tomorrow 9:30 AM\", \"Wed 2:00 PM\")\n */\nexport function formatETA(\n  remainingMinutes: number,\n  timeFormat: TimeFormat = 'system',\n  t?: (key: string) => string\n): string {\n  const now = new Date();\n  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);\n\n  const today = new Date(now);\n  today.setHours(0, 0, 0, 0);\n  const etaDay = new Date(eta);\n  etaDay.setHours(0, 0, 0, 0);\n\n  const timeOptions = applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat);\n  const timeStr = eta.toLocaleTimeString([], timeOptions);\n  const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000);\n\n  if (dayDiff === 0) return timeStr;\n  if (dayDiff === 1) return `${t?.('common.tomorrow') ?? 'Tomorrow'} ${timeStr}`;\n  return `${eta.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`;\n}\n\n/**\n * Format a duration in seconds to a human-readable string, with null handling.\n *\n * @param seconds - Duration in seconds, or null/undefined\n * @returns Formatted string (e.g., \"2h 30m\", \"45m\") or \"--\" if no value\n */\nexport function formatDuration(seconds: number | null | undefined): string {\n  if (seconds == null || seconds < 0) return '--';\n\n  const hours = Math.floor(seconds / 3600);\n  const minutes = Math.floor((seconds % 3600) / 60);\n\n  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;\n}\n\ntype TranslateFunction = (key: string, options?: Record<string, unknown>) => string;\n\n/**\n * Format a date string as a human-readable relative time expression.\n *\n * @param dateStr - UTC date string, or null\n * @param timeFormat - Time format preference ('12h', '24h', or 'system')\n * @param t - Optional translation function for i18n support\n * @returns Relative string (e.g., \"5m ago\", \"in 2h\", \"3d ago\") or formatted date if older than 7 days\n */\nexport function formatRelativeTime(\n  dateStr: string | null,\n  timeFormat: TimeFormat = 'system',\n  t?: TranslateFunction\n): string {\n  if (!dateStr) return t?.('time.unknown') ?? '-';\n\n  const date = parseUTCDate(dateStr);\n  if (!date) return t?.('time.unknown') ?? '-';\n\n  const now = new Date();\n  const diffMs = date.getTime() - now.getTime();\n  const isPast = diffMs < 0;\n  const absDiffMs = Math.abs(diffMs);\n\n  const minutes = Math.floor(absDiffMs / 60000);\n  const hours = Math.floor(absDiffMs / 3600000);\n  const days = Math.floor(absDiffMs / 86400000);\n\n  // Less than 1 minute\n  if (minutes < 1) {\n    return isPast\n      ? t?.('time.justNow') ?? 'Just now'\n      : t?.('time.now') ?? 'Now';\n  }\n\n  // Less than 1 hour\n  if (hours < 1) {\n    return isPast\n      ? t?.('time.minsAgo', { count: minutes }) ?? `${minutes}m ago`\n      : t?.('time.inMins', { count: minutes }) ?? `in ${minutes}m`;\n  }\n\n  // Less than 1 day\n  if (days < 1) {\n    return isPast\n      ? t?.('time.hoursAgo', { count: hours }) ?? `${hours}h ago`\n      : t?.('time.inHours', { count: hours }) ?? `in ${hours}h`;\n  }\n\n  // Less than 7 days\n  if (days < 7) {\n    return isPast\n      ? t?.('time.daysAgo', { count: days }) ?? `${days}d ago`\n      : t?.('time.inDays', { count: days }) ?? `in ${days}d`;\n  }\n\n  // Older than 7 days\n  return formatDateTime(dateStr, timeFormat);\n}\n\n/**\n * Format seconds as MM:SS for media/video player display.\n *\n * @param seconds - Total seconds\n * @returns Formatted string (e.g., \"2:05\", \"0:30\")\n */\nexport function formatMediaTime(seconds: number): string {\n  const mins = Math.floor(seconds / 60);\n  const secs = Math.floor(seconds % 60);\n  return `${mins}:${secs.toString().padStart(2, '0')}`;\n}\n\n/**\n * Format a duration given in hours to a human-readable string.\n *\n * @param hours - Duration in hours (e.g., 2.5)\n * @returns Formatted string (e.g., \"2h 30m\", \"45m\", \"3h\")\n */\nexport function formatDurationFromHours(hours: number): string {\n  if (hours < 1) return `${Math.round(hours * 60)}m`;\n  const h = Math.floor(hours);\n  const m = Math.round((hours - h) * 60);\n  return m > 0 ? `${h}h ${m}m` : `${h}h`;\n}\n"
  },
  {
    "path": "frontend/src/utils/file.ts",
    "content": "/**\n * Formats a byte count into a human-readable string (e.g. `1.5 MB`).\n *\n * @param bytes - The number of bytes to format.\n * @returns A formatted string with the appropriate unit (B, KB, MB, GB, or TB).\n */\nexport function formatFileSize(bytes: number): string {\n  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';\n\n  const units = ['B', 'KB', 'MB', 'GB', 'TB'];\n  const k = 1024;\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n  const size = bytes / Math.pow(k, i);\n\n  // No decimals for bytes, 1 decimal for larger units\n  return i === 0\n    ? `${size} ${units[i]}`\n    : `${size.toFixed(1)} ${units[i]}`;\n}\n"
  },
  {
    "path": "frontend/src/utils/firmwareVersion.ts",
    "content": "/**\n * Compare two Bambu Lab firmware version strings (format: \"XX.XX.XX.XX\").\n *\n * Returns a negative number if `a` < `b`, zero if equal, positive if `a` > `b`.\n * Missing trailing segments are treated as 0.\n */\nexport function compareFwVersions(a: string, b: string): number {\n  const pa = a.split('.').map((n) => parseInt(n, 10) || 0);\n  const pb = b.split('.').map((n) => parseInt(n, 10) || 0);\n  while (pa.length < 4) pa.push(0);\n  while (pb.length < 4) pb.push(0);\n  for (let i = 0; i < 4; i++) {\n    if (pa[i] !== pb[i]) return pa[i] - pb[i];\n  }\n  return 0;\n}\n"
  },
  {
    "path": "frontend/src/utils/maintenanceWikiUrls.ts",
    "content": "/**\n * Resolve a Bambu Lab wiki URL for a maintenance task based on the printer model.\n *\n * Model families:\n *   - X1, P1         → carbon rods\n *   - P2S, X2D       → hardened steel rods (X2D shares P2S's gantry — #988)\n *   - A1, A1 Mini    → linear rails (Y axis)\n *   - H2D, H2C, H2S  → linear rails (X-axis lubrication)\n *\n * Returns null when no wiki page applies (e.g. \"Clean Carbon Rods\" on an H2D),\n * which the caller renders as a task with no clickable help link.\n */\nexport function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): string | null {\n  const model = (printerModel || '').toUpperCase().replace(/[- ]/g, '');\n\n  const isX1 = model.includes('X1');\n  const isP1 = model.includes('P1');\n  const isA1Mini = model.includes('A1MINI');\n  const isA1 = model.includes('A1') && !isA1Mini;\n  const isH2D = model.includes('H2D');\n  const isH2C = model.includes('H2C');\n  const isH2S = model.includes('H2S');\n  const isH2 = isH2D || isH2C || isH2S;\n  const isP2S = model.includes('P2S');\n  const isX2D = model.includes('X2D');\n  // X2D shares the hardened steel rod hardware and belt layout with P2S,\n  // so its maintenance routes use the P2S wiki pages until dedicated\n  // X2D pages are published by Bambu Lab.\n  const isSteelRod = isP2S || isX2D;\n\n  switch (typeName) {\n    case 'Lubricate Steel Rods':\n      if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis';\n      return null;\n\n    case 'Clean Steel Rods':\n      if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis';\n      return null;\n\n    case 'Lubricate Linear Rails':\n      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';\n      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';\n      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';\n      return null;\n\n    case 'Clean Nozzle/Hotend':\n      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';\n      if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/troubleshooting/nozzle-clog';\n      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/nozzl-cold-pull-maintenance-and-cleaning';\n      if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/cold-pull-maintenance-hotend';\n      return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';\n\n    case 'Check Belt Tension':\n      if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';\n      if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance';\n      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/belt_tension';\n      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/belt_tension';\n      if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/belt-tension';\n      if (isH2C) return 'https://wiki.bambulab.com/en/h2c/maintenance/belt-tension';\n      if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/belt-tension';\n      if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension';\n      return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';\n\n    case 'Clean Carbon Rods':\n      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';\n      return null;\n\n    case 'Clean Linear Rails':\n      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';\n      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';\n      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';\n      return null;\n\n    case 'Clean Build Plate':\n      return 'https://wiki.bambulab.com/en/filament-acc/acc/pei-plate-clean-guide';\n\n    case 'Check PTFE Tube':\n      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube';\n      if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/ptfe-tube';\n      if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer';\n      if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/replace-ptfe-tube-on-h2s-printer';\n      if (isH2C) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer'; // H2C uses H2D guide\n      if (isSteelRod) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube'; // P2S/X2D use similar PTFE\n      return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube';\n\n    case 'Replace HEPA Filter':\n    case 'HEPA Filter':\n    case 'Replace Carbon Filter':\n    case 'Carbon Filter':\n      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-smoke-purifier-air-filte';\n      return 'https://wiki.bambulab.com/en/x1/maintenance/replace-carbon-filter';\n\n    case 'Lubricate Left Nozzle Rail':\n    case 'Left Nozzle Rail':\n      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';\n      return null;\n\n    default:\n      return null;\n  }\n}\n"
  },
  {
    "path": "frontend/src/utils/printName.ts",
    "content": "/**\n * Append a plate label to the print name. When `plateLabel` is provided (resolved\n * by the caller from the linked archive's plate list — see #881 follow-up), it\n * is used verbatim, including the explicit \"Plate 1\" case on multi-plate 3MFs.\n * Falls back to parsing `plate_N.gcode` from the MQTT gcode_file path, and in\n * that fallback we only show N > 1 because we can't tell from the path alone\n * whether the 3MF is multi-plate.\n */\nexport function formatPrintName(\n  name: string | null,\n  gcodeFile: string | null | undefined,\n  t: (key: string, fallback: string, opts?: Record<string, unknown>) => string,\n  plateLabel?: string | null,\n): string {\n  if (!name) return '';\n  if (plateLabel) return `${name} — ${plateLabel}`;\n  if (!gcodeFile) return name;\n  const match = gcodeFile.match(/plate_(\\d+)\\.gcode/);\n  if (match && parseInt(match[1], 10) > 1) {\n    return `${name} — ${t('printers.plateNumber', 'Plate {{number}}', { number: match[1] })}`;\n  }\n  return name;\n}\n"
  },
  {
    "path": "frontend/src/utils/printer.ts",
    "content": "export function getPrinterImage(model: string | null | undefined): string {\n  if (!model) return '/img/printers/default.png';\n  const m = model.toLowerCase().replace(/\\s+/g, '');\n  if (m.includes('x1e')) return '/img/printers/x1e.png';\n  if (m.includes('x1c') || m.includes('x1carbon')) return '/img/printers/x1c.png';\n  if (m.includes('x1')) return '/img/printers/x1c.png';\n  if (m.includes('x2d') || m === 'n6') return '/img/printers/x2d.png';\n  if (m.includes('h2dpro') || m.includes('h2d-pro')) return '/img/printers/h2dpro.png';\n  if (m.includes('h2d')) return '/img/printers/h2d.png';\n  if (m.includes('h2c')) return '/img/printers/h2c.png';\n  if (m.includes('h2s')) return '/img/printers/h2d.png';\n  if (m.includes('p2s')) return '/img/printers/p1s.png';\n  if (m.includes('p1s')) return '/img/printers/p1s.png';\n  if (m.includes('p1p')) return '/img/printers/p1p.png';\n  if (m.includes('a1mini')) return '/img/printers/a1mini.png';\n  if (m.includes('a1')) return '/img/printers/a1.png';\n  return '/img/printers/default.png';\n}\n\nexport function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } {\n  if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 };\n  if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 };\n  if (rssi >= -70) return { labelKey: 'printers.wifiSignal.fair', color: 'text-yellow-400', bars: 2 };\n  if (rssi >= -80) return { labelKey: 'printers.wifiSignal.weak', color: 'text-orange-400', bars: 1 };\n  return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };\n}\n\nimport type { PrintQueueItem } from '../api/client';\n\n/**\n * Filters queue items based on printer compatibility (filament types and colors).\n * Mirrors backend _find_idle_printer_for_model() logic.\n * @param items - Array of queue items to filter\n * @param loadedFilamentTypes - Set of loaded filament types (e.g., \"PLA\", \"PETG\")\n * @param loadedFilaments - Set of loaded filament type+color pairs (e.g., \"PLA:ffffff\", \"PETG:ff0000\")\n * @returns Array of compatible queue items\n */\nexport function filterCompatibleQueueItems(\n  items: PrintQueueItem[],\n  loadedFilamentTypes?: Set<string>,\n  loadedFilaments?: Set<string>\n): PrintQueueItem[] {\n  return items.filter(item => {\n    // Type check: all required filament types must be loaded\n    if (item.required_filament_types && item.required_filament_types.length > 0 && loadedFilamentTypes !== undefined) {\n      if (!item.required_filament_types.every((t: string) => loadedFilamentTypes.has(t.toUpperCase()))) {\n        return false;\n      }\n    }\n\n    // Color check: evaluate force_color_match per slot\n    // Only apply when loadedFilaments is provided (not undefined).\n    // An empty Set means no filaments are loaded — force-matched slots cannot match.\n    if (item.filament_overrides && item.filament_overrides.length > 0 && loadedFilaments !== undefined) {\n      const forceOverrides = item.filament_overrides.filter(o => o.force_color_match === true);\n      const prefOverrides = item.filament_overrides.filter(o => o.force_color_match !== true);\n\n      // All force-matched slots must have exact type+color on this printer\n      if (forceOverrides.length > 0) {\n        const allForceMatch = forceOverrides.every(o => {\n          const oType = (o.type || '').toUpperCase();\n          const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);\n          return loadedFilaments.has(`${oType}:${oColor}`);\n        });\n        if (!allForceMatch) return false;\n      }\n\n      // Preference-only overrides: at least one color must match (existing behaviour)\n      if (prefOverrides.length > 0 && forceOverrides.length === 0) {\n        const hasColorMatch = prefOverrides.some(o => {\n          const oType = (o.type || '').toUpperCase();\n          const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);\n          return loadedFilaments.has(`${oType}:${oColor}`);\n        });\n        if (!hasColorMatch) return false;\n      }\n    }\n\n    return true;\n  });\n}\n"
  },
  {
    "path": "frontend/src/utils/slicer.ts",
    "content": "/**\n * Utility for opening files in slicer applications\n *\n * Protocol handler URL formats (from BambuStudio/OrcaSlicer source code):\n *\n * Bambu Studio has TWO separate URL handlers:\n *   1. post_init() [Windows/Linux CLI args]: bambustudio://open?file=<URL>\n *      - Checks: starts_with(\"bambustudio://open\")\n *      - Calls url_decode(), then split_str(url, \"file=\")\n *   2. MacOpenURL() [macOS Apple Events]: bambustudioopen://<encoded-URL>\n *      - Checks: starts_with(\"bambustudioopen://\")\n *      - Strips prefix, then url_decode()\n *\n * OrcaSlicer Downloader accepts both formats via regex:\n *   - (orcaslicer|bambustudio|...)://open?file=<URL>\n *   - bambustudioopen://<URL>\n *\n * Key insight: Using ?file= query format, the browser's URL parser preserves\n * http:// in the query string without any encoding. Only the macOS-specific\n * bambustudioopen:// format needs encodeURIComponent (BS calls url_decode).\n */\n\nexport type SlicerType = 'bambu_studio' | 'orcaslicer';\n\ntype Platform = 'windows' | 'macos' | 'linux' | 'unknown';\n\n/**\n * Detect the user's operating system\n */\nexport function detectPlatform(): Platform {\n  const userAgent = navigator.userAgent.toLowerCase();\n  const platform = navigator.platform?.toLowerCase() || '';\n\n  if (userAgent.includes('win') || platform.includes('win')) {\n    return 'windows';\n  }\n  if (userAgent.includes('mac') || platform.includes('mac')) {\n    return 'macos';\n  }\n  if (userAgent.includes('linux') || platform.includes('linux')) {\n    return 'linux';\n  }\n  return 'unknown';\n}\n\n/**\n * Open a URL in the specified slicer application.\n * @param downloadUrl - The URL to the file to open\n * @param slicer - Which slicer to use (defaults to bambu_studio)\n */\nexport function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_studio'): void {\n  let url: string;\n\n  if (slicer === 'orcaslicer') {\n    // OrcaSlicer: ?file= query format — http:// preserved in query string\n    url = `orcaslicer://open?file=${downloadUrl}`;\n  } else {\n    const platform = detectPlatform();\n    if (platform === 'macos') {\n      // macOS only: bambustudioopen scheme via MacOpenURL() callback.\n      // Must encode because bare http:// in authority gets mangled by browser.\n      // BS calls url_decode() after stripping \"bambustudioopen://\" prefix.\n      url = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;\n    } else {\n      // Windows/Linux: bambustudio://open?file= via post_init() CLI args.\n      // The ?file= query format preserves http:// without encoding.\n      // IMPORTANT: On Linux, BS only handles \"bambustudio://open\" prefix —\n      // it does NOT process \"bambustudioopen://\" (that's macOS-only).\n      url = `bambustudio://open?file=${downloadUrl}`;\n    }\n  }\n\n  // Use a temporary <a> element to trigger the protocol handler.\n  // This avoids navigating away from the page (unlike window.location.href).\n  const link = document.createElement('a');\n  link.href = url;\n  link.style.display = 'none';\n  document.body.appendChild(link);\n  link.click();\n  document.body.removeChild(link);\n}\n\n/**\n * Build a full download URL for a file\n * @param path - The API path (e.g., from api.getArchiveForSlicer())\n */\nexport function buildDownloadUrl(path: string): string {\n  return `${window.location.origin}${path}`;\n}\n\n/**\n * Convenience function to open an archive in the slicer\n * @param path - The API path to the archive\n * @param slicer - Which slicer to use (defaults to bambu_studio)\n */\nexport function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_studio'): void {\n  const downloadUrl = buildDownloadUrl(path);\n  openInSlicer(downloadUrl, slicer);\n}\n"
  },
  {
    "path": "frontend/src/utils/weight.ts",
    "content": "export function formatWeight(grams: number): string {\n  if (grams >= 1_000_000) {\n    const tonnes = grams / 1_000_000;\n    return `${tonnes % 1 === 0 ? tonnes.toFixed(0) : tonnes.toFixed(1)}t`;\n  }\n  if (grams >= 1000) {\n    const kg = grams / 1000;\n    return `${kg % 1 === 0 ? kg.toFixed(0) : kg.toFixed(1)}kg`;\n  }\n  return `${Math.round(grams)}g`;\n}\n"
  },
  {
    "path": "frontend/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  darkMode: 'class',\n  content: [\n    \"./index.html\",\n    \"./src/**/*.{js,ts,jsx,tsx}\",\n  ],\n  theme: {\n    extend: {\n      colors: {\n        // Bambu Lab color palette\n        bambu: {\n          green: '#00ae42',\n          'green-light': '#00c64d',\n          'green-dark': '#009438',\n          dark: '#1a1a1a',\n          'dark-secondary': '#2d2d2d',\n          'dark-tertiary': '#3d3d3d',\n          card: '#2d2d2d', // Same as dark-secondary for card backgrounds\n          gray: '#808080',\n          'gray-light': '#a0a0a0',\n          'gray-dark': '#4a4a4a',\n        }\n      },\n      fontFamily: {\n        sans: ['Inter', 'system-ui', 'sans-serif'],\n      },\n      keyframes: {\n        fadeIn: {\n          '0%': { opacity: '0' },\n          '100%': { opacity: '1' },\n        },\n        slideUp: {\n          '0%': { opacity: '0', transform: 'translateY(16px) scale(0.98)' },\n          '100%': { opacity: '1', transform: 'translateY(0) scale(1)' },\n        },\n      },\n      animation: {\n        'fade-in': 'fadeIn 0.15s ease-out',\n        'slide-up': 'slideUp 0.2s ease-out',\n      },\n    },\n  },\n  plugins: [],\n}\n"
  },
  {
    "path": "frontend/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"vite/client\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"src/__tests__\"]\n}\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "frontend/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "frontend/vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport path from 'path'\n\n// Backend port for dev server proxy (default: 8000)\nconst backendPort = process.env.BACKEND_PORT || '8000'\nconst backendUrl = `http://localhost:${backendPort}`\n\nexport default defineConfig({\n  plugins: [react()],\n  build: {\n    outDir: '../static',\n    emptyOutDir: true,\n    chunkSizeWarningLimit: 3000,\n  },\n  server: {\n    host: '0.0.0.0',\n    proxy: {\n      '/api/v1/ws': {\n        target: backendUrl,\n        ws: true,\n        changeOrigin: true,\n      },\n      '/api': {\n        target: backendUrl,\n        changeOrigin: true,\n      },\n    },\n  },\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, './src'),\n    },\n  },\n})\n"
  },
  {
    "path": "frontend/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\nimport react from '@vitejs/plugin-react';\nimport path from 'path';\n\nexport default defineConfig({\n  plugins: [react()],\n  test: {\n    globals: true,\n    environment: 'jsdom',\n    environmentOptions: {\n      jsdom: {\n        url: 'http://localhost:3000',\n      },\n    },\n    setupFiles: ['./src/__tests__/setup.ts'],\n    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],\n    exclude: ['node_modules', 'dist'],\n    coverage: {\n      provider: 'v8',\n      reporter: ['text', 'json', 'html'],\n      include: ['src/**/*.{ts,tsx}'],\n      exclude: [\n        'src/**/*.test.{ts,tsx}',\n        'src/**/*.spec.{ts,tsx}',\n        'src/__tests__/**',\n        'src/main.tsx',\n        'src/vite-env.d.ts',\n      ],\n    },\n  },\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, './src'),\n    },\n  },\n});\n"
  },
  {
    "path": "install/README.md",
    "content": "# BamBuddy Installation Scripts\n\nInteractive installation scripts for BamBuddy with support for both native and Docker deployments.\n\n## Quick Start\n\n### Docker Installation (Recommended)\n\n**Linux/macOS:**\n```bash\ncurl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh\n```\n\n### Native Installation\n\n**Linux/macOS:**\n```bash\ncurl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh -o install.sh && chmod +x install.sh && ./install.sh\n```\n\n---\n\n## Scripts Overview\n\n| Script | Platform | Method |\n|--------|----------|--------|\n| `install.sh` | Linux, macOS | Native (Python venv) |\n| `docker-install.sh` | Linux, macOS | Docker |\n| `update.sh` | Linux (systemd) | Native update helper |\n\n---\n\n## Native Installation Scripts\n\n### `install.sh` (Linux/macOS)\n\nInstalls BamBuddy with Python virtual environment and optional systemd/launchd service.\n\n**Supported Systems:**\n- Debian/Ubuntu (apt)\n- RHEL/Fedora/CentOS (dnf/yum)\n- Arch Linux (pacman)\n- openSUSE (zypper)\n- macOS (Homebrew)\n\n**Options:**\n```\n--path PATH        Installation directory (default: /opt/bambuddy)\n--port PORT        Port to listen on (default: 8000)\n--tz TIMEZONE      Timezone (default: system timezone)\n--data-dir PATH    Data directory (default: INSTALL_PATH/data)\n--log-dir PATH     Log directory (default: INSTALL_PATH/logs)\n--debug            Enable debug mode\n--log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)\n--no-service       Skip systemd/launchd service setup\n--yes, -y          Non-interactive mode, accept defaults\n```\n\n**Examples:**\n```bash\n# Interactive installation\n./install.sh\n\n# Unattended with custom settings\n./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes\n\n# Minimal unattended\n./install.sh -y\n\n# Skip service setup\n./install.sh --no-service -y\n```\n\n---\n\n## Docker Installation Scripts\n\n### `docker-install.sh` (Linux/macOS)\n\nInstalls BamBuddy using Docker containers.\n\n**Options:**\n```\n--path PATH        Installation directory (default: ~/bambuddy)\n--port PORT        Port to expose (default: 8000)\n--tz TIMEZONE      Timezone (default: system timezone)\n--build            Build from source instead of using pre-built image\n--yes, -y          Non-interactive mode, accept defaults\n```\n\n**Examples:**\n```bash\n# Interactive installation\n./docker-install.sh\n\n# Unattended with custom settings\n./docker-install.sh --path /srv/bambuddy --port 3000 --tz Europe/Berlin --yes\n\n# Build from source\n./docker-install.sh --build --yes\n```\n\n---\n\n## Configuration Options\n\nAll scripts support these configuration options:\n\n| Option | Description | Default |\n|--------|-------------|---------|\n| Install Path | Where BamBuddy is installed | `/opt/bambuddy` (Linux/Docker) |\n| Port | HTTP port for web interface | `8000` |\n| Timezone | Server timezone | System timezone or `UTC` |\n| Data Directory | Database and archives | `INSTALL_PATH/data` |\n| Log Directory | Application logs | `INSTALL_PATH/logs` |\n| Debug Mode | Enable verbose logging | `false` |\n| Log Level | INFO, WARNING, ERROR, DEBUG | `INFO` |\n\n---\n\n## Post-Installation\n\n### Accessing BamBuddy\n\nAfter installation, open your browser to:\n```\nhttp://localhost:8000\n```\n\nOr use the port you specified during installation.\n\n### Service Management\n\n**Linux (systemd):**\n```bash\nsudo systemctl status bambuddy    # Check status\nsudo systemctl start bambuddy     # Start\nsudo systemctl stop bambuddy      # Stop\nsudo systemctl restart bambuddy   # Restart\nsudo journalctl -u bambuddy -f    # View logs\n```\n\n**macOS (launchd):**\n```bash\nlaunchctl list | grep bambuddy                              # Check status\nlaunchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist    # Start\nlaunchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist  # Stop\n```\n\n**Docker:**\n```bash\ndocker compose ps           # Check status\ndocker compose up -d        # Start\ndocker compose down         # Stop\ndocker compose restart      # Restart\ndocker compose logs -f      # View logs\n```\n\n### Updating\n\n**Native installation:**\n```bash\ncurl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/update.sh -o update.sh\nchmod +x update.sh\nsudo ./update.sh\n```\n\nThe updater performs:\n- Root permission check (fails fast before any work)\n- Optional built-in backup API call (`/api/v1/settings/backup`) before update\n- Keeps only the newest 5 local backup ZIP files\n- Local-change warning + confirmation before `git reset --hard`\n- If remote has no new commits, updater exits early without stopping the service\n- Service stop/start with code rollback + service restart attempt if update fails\n\nUseful environment overrides:\n```bash\n# Typical native install defaults\nINSTALL_DIR=/opt/bambuddy SERVICE_NAME=bambuddy sudo ./update.sh\n\n# Require backup to succeed (abort update if backup fails)\nBACKUP_MODE=require sudo ./update.sh\n\n# Skip backup API call\nBACKUP_MODE=skip sudo ./update.sh\n\n# Auth-enabled instances: provide API key for backup endpoint\nBAMBUDDY_API_KEY=bb_xxx BACKUP_MODE=require sudo ./update.sh\n```\n\n**Docker (pre-built image):**\n```bash\ncd ~/bambuddy\ndocker compose pull\ndocker compose up -d\n```\n\n**Docker (from source):**\n```bash\ncd ~/bambuddy\ngit pull\ndocker compose up -d --build\n```\n\n---\n\n## Troubleshooting\n\n### Permission Denied (Linux)\nRun with `sudo` or ensure your user has appropriate permissions:\n```bash\nsudo ./install.sh\n```\n\n### Docker: Printer Discovery Not Working\nDocker Desktop for macOS doesn't support host networking. Add printers manually by IP address in the BamBuddy web interface.\n\n### Service Won't Start\nCheck logs for errors:\n```bash\n# Linux\nsudo journalctl -u bambuddy -n 50\n\n# Docker\ndocker compose logs bambuddy\n```\n\n### Port Already in Use\nChoose a different port during installation or stop the conflicting service:\n```bash\n# Find what's using port 8000\nsudo lsof -i :8000  # Linux/macOS\n```\n\n---\n\n## Requirements\n\n### Native Installation\n- Python 3.10+ (automatically installed if missing)\n- Node.js 18+ (automatically installed if missing)\n- Git (automatically installed if missing)\n- ~500MB disk space\n\n### Docker Installation\n- Docker Engine 20+ or Docker Desktop\n- ~1GB disk space (includes image)\n\n---\n\n## Support\n\n- **Documentation:** https://wiki.bambuddy.cool\n- **Discord:** https://discord.gg/aFS3ZfScHM\n- **Issues:** https://github.com/maziggy/bambuddy/issues\n"
  },
  {
    "path": "install/docker-install.sh",
    "content": "#!/usr/bin/env bash\n#\n# BamBuddy Docker Installation Script\n# Supports: Linux (all distros), macOS\n#\n# Usage:\n#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh\n#   Unattended:   ./docker-install.sh --path /opt/bambuddy --port 8000 --yes\n#\n# Options:\n#   --path PATH        Installation directory (default: /opt/bambuddy)\n#   --port PORT        Port to expose (default: 8000)\n#   --tz TIMEZONE      Timezone (default: system timezone or UTC)\n#   --build            Build from source instead of using pre-built image\n#   --yes, -y          Non-interactive mode, accept defaults\n#   --redirect-990     (Deprecated, no longer needed — FTP binds to port 990 directly)\n#   --help, -h         Show this help message\n#\n\nset -e\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nCYAN='\\033[0;36m'\nNC='\\033[0m' # No Color\nBOLD='\\033[1m'\n\n# Default values\nDEFAULT_INSTALL_PATH=\"/opt/bambuddy\"\nDEFAULT_PORT=\"8000\"\n\n# Script variables\nINSTALL_PATH=\"\"\nPORT=\"\"\nTIMEZONE=\"\"\nBUILD_FROM_SOURCE=\"false\"\nNON_INTERACTIVE=\"false\"\nOS_TYPE=\"\"\nDOCKER_CMD=\"\"\nREDIRECT_990=\"false\"\n\n# -----------------------------------------------------------------------------\n# Helper Functions\n# -----------------------------------------------------------------------------\n\nprint_banner() {\n    echo -e \"${CYAN}\"\n    echo \"╔════════════════════════════════════════════════════════╗\"\n    echo \"║                                                        ║\"\n    echo \"║   ____                  _               _     _        ║\"\n    echo \"║  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  ║\"\n    echo \"║  |  _ \\\\ / _\\` | '_ \\` _ \\\\| '_ \\\\| | | |/ _\\` |/ _\\` | | | | ║\"\n    echo \"║  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║\"\n    echo \"║  |____/ \\\\__,_|_| |_| |_|_.__/ \\\\__,_|\\\\__,_|\\\\__,_|\\\\__, | ║\"\n    echo \"║                                                 |___/  ║\"\n    echo \"║                                                        ║\"\n    echo \"║            Docker Installation Script                  ║\"\n    echo \"║                                                        ║\"\n    echo \"╚════════════════════════════════════════════════════════╝\"\n    echo -e \"${NC}\"\n}\n\nlog_info() {\n    echo -e \"${BLUE}[INFO]${NC} $1\"\n}\n\nlog_success() {\n    echo -e \"${GREEN}[OK]${NC} $1\"\n}\n\nlog_warn() {\n    echo -e \"${YELLOW}[WARN]${NC} $1\"\n}\n\nlog_error() {\n    echo -e \"${RED}[ERROR]${NC} $1\"\n}\n\nprompt() {\n    local prompt_text=\"$1\"\n    local default_value=\"$2\"\n    local var_name=\"$3\"\n\n    if [[ \"$NON_INTERACTIVE\" == \"true\" ]]; then\n        eval \"$var_name=\\\"$default_value\\\"\"\n        return\n    fi\n\n    if [[ -n \"$default_value\" ]]; then\n        echo -en \"${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: \"\n    else\n        echo -en \"${BOLD}$prompt_text${NC}: \"\n    fi\n\n    read -r input\n    if [[ -z \"$input\" ]]; then\n        eval \"$var_name=\\\"$default_value\\\"\"\n    else\n        eval \"$var_name=\\\"$input\\\"\"\n    fi\n}\n\nprompt_yes_no() {\n    local prompt_text=\"$1\"\n    local default=\"$2\"  # y or n\n\n    if [[ \"$NON_INTERACTIVE\" == \"true\" ]]; then\n        [[ \"$default\" == \"y\" ]] && return 0 || return 1\n    fi\n\n    local yn_hint=\"[y/n]\"\n    [[ \"$default\" == \"y\" ]] && yn_hint=\"[Y/n]\"\n    [[ \"$default\" == \"n\" ]] && yn_hint=\"[y/N]\"\n\n    while true; do\n        echo -en \"${BOLD}$prompt_text${NC} $yn_hint: \"\n        read -r yn\n        [[ -z \"$yn\" ]] && yn=\"$default\"\n        case \"$yn\" in\n            [Yy]* ) return 0;;\n            [Nn]* ) return 1;;\n            * ) echo \"Please answer yes or no.\";;\n        esac\n    done\n}\n\nshow_help() {\n    echo \"BamBuddy Docker Installation Script\"\n    echo \"\"\n    echo \"Usage: $0 [OPTIONS]\"\n    echo \"\"\n    echo \"Options:\"\n    echo \"  --path PATH        Installation directory (default: /opt/bambuddy)\"\n    echo \"  --port PORT        Port to expose (default: 8000)\"\n    echo \"  --tz TIMEZONE      Timezone (default: system timezone or UTC)\"\n    echo \"  --build            Build from source instead of using pre-built image\"\n    echo \"  --yes, -y          Non-interactive mode, accept defaults\"\n    echo \"  --redirect-990     (Deprecated, no longer needed)\"\n    echo \"  --help, -h         Show this help message\"\n    echo \"\"\n    echo \"Examples:\"\n    echo \"  Interactive installation:\"\n    echo \"    ./docker-install.sh\"\n    echo \"\"\n    echo \"  Unattended installation with custom settings:\"\n    echo \"    ./docker-install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes\"\n    echo \"\"\n    echo \"  Build from source:\"\n    echo \"    ./docker-install.sh --build --yes\"\n    exit 0\n}\n\n# -----------------------------------------------------------------------------\n# System Detection\n# -----------------------------------------------------------------------------\n\ndetect_os() {\n    if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n        OS_TYPE=\"macos\"\n        return\n    fi\n\n    if [[ -f /etc/os-release ]]; then\n        OS_TYPE=\"linux\"\n    else\n        log_error \"Cannot detect operating system\"\n        exit 1\n    fi\n}\n\ndetect_docker() {\n    # Check for docker compose (v2) or docker-compose (v1)\n    if docker compose version &>/dev/null 2>&1; then\n        DOCKER_CMD=\"docker compose\"\n        log_success \"Found Docker Compose v2\"\n        return 0\n    elif docker-compose --version &>/dev/null 2>&1; then\n        DOCKER_CMD=\"docker-compose\"\n        log_success \"Found Docker Compose v1\"\n        return 0\n    fi\n    return 1\n}\n\ndetect_timezone() {\n    if [[ -n \"$TIMEZONE\" ]]; then\n        return 0\n    fi\n\n    # Try to get system timezone (with error handling for set -e)\n    TIMEZONE=\"\"\n    if [[ -f /etc/timezone ]]; then\n        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true\n    fi\n\n    if [[ -z \"$TIMEZONE\" ]] && [[ -L /etc/localtime ]]; then\n        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true\n    fi\n\n    if [[ -z \"$TIMEZONE\" ]] && command -v timedatectl &>/dev/null; then\n        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true\n    fi\n\n    # Default to UTC if not found (use if/then to avoid set -e issue with &&)\n    if [[ -z \"$TIMEZONE\" ]]; then\n        TIMEZONE=\"UTC\"\n    fi\n    return 0\n}\n\n# -----------------------------------------------------------------------------\n# Installation Functions\n# -----------------------------------------------------------------------------\n\ninstall_docker() {\n    log_info \"Docker not found, installing...\"\n\n    case \"$OS_TYPE\" in\n        linux)\n            # Use Docker's convenience script\n            curl -fsSL https://get.docker.com | sh\n\n            # Add current user to docker group\n            if [[ -n \"$SUDO_USER\" ]]; then\n                sudo usermod -aG docker \"$SUDO_USER\"\n                log_warn \"Added $SUDO_USER to docker group. You may need to log out and back in.\"\n            else\n                sudo usermod -aG docker \"$USER\"\n                log_warn \"Added $USER to docker group. You may need to log out and back in.\"\n            fi\n\n            # Start Docker service\n            sudo systemctl enable docker\n            sudo systemctl start docker\n            ;;\n        macos)\n            log_error \"Docker Desktop not found.\"\n            log_error \"Please install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop\"\n            exit 1\n            ;;\n    esac\n\n    log_success \"Docker installed\"\n}\n\ncreate_install_dir() {\n    log_info \"Creating installation directory...\"\n\n    mkdir -p \"$INSTALL_PATH\"\n    cd \"$INSTALL_PATH\"\n\n    log_success \"Directory created: $INSTALL_PATH\"\n}\n\ndownload_compose_file() {\n    log_info \"Downloading docker-compose.yml...\"\n\n    if [[ \"$BUILD_FROM_SOURCE\" == \"true\" ]]; then\n        # Clone the full repo for building\n        if [[ -d \".git\" ]]; then\n            log_info \"Existing repository found, updating...\"\n            git fetch origin\n            git reset --hard origin/main\n        else\n            git clone https://github.com/maziggy/bambuddy.git .\n        fi\n    else\n        # Just download the compose file\n        curl -fsSL -o docker-compose.yml \\\n            https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml\n    fi\n\n    log_success \"docker-compose.yml ready\"\n}\n\ncreate_env_file() {\n    log_info \"Creating environment configuration...\"\n\n    cat > .env << EOF\n# BamBuddy Docker Configuration\n# Generated by docker-install.sh on $(date)\n\n# Port BamBuddy runs on\nPORT=$PORT\n\n# Timezone\nTZ=$TIMEZONE\nEOF\n\n    log_success \"Environment file created\"\n}\n\ncustomize_compose() {\n    # Detect if we need to disable host networking (macOS/Windows in Docker Desktop)\n    if [[ \"$OS_TYPE\" == \"macos\" ]]; then\n        log_warn \"Docker Desktop detected. Host networking is not supported.\"\n        log_info \"Modifying docker-compose.yml for port mapping...\"\n\n        # Create a modified compose file for macOS\n        if [[ -f docker-compose.yml ]]; then\n            # Comment out network_mode: host and uncomment ports section\n            sed -i.bak \\\n                -e 's/^[[:space:]]*network_mode: host/#    network_mode: host/' \\\n                -e 's/^[[:space:]]*#ports:/    ports:/' \\\n                -e 's/^[[:space:]]*#[[:space:]]*- \"\\${PORT:-8000}:8000\"/      - \"\\${PORT:-8000}:8000\"/' \\\n                docker-compose.yml\n\n            log_warn \"Printer discovery may not work. Add printers manually by IP address.\"\n        fi\n    fi\n}\n\nstart_container() {\n    log_info \"Starting BamBuddy...\"\n\n    if [[ \"$BUILD_FROM_SOURCE\" == \"true\" ]]; then\n        $DOCKER_CMD up -d --build\n    else\n        $DOCKER_CMD up -d\n    fi\n\n    # Wait for container to start\n    log_info \"Waiting for container to start...\"\n    local max_attempts=15\n    local attempt=0\n\n    while [[ $attempt -lt $max_attempts ]]; do\n        # Check if container is running (Up)\n        if $DOCKER_CMD ps | grep -q \"Up\"; then\n            log_success \"BamBuddy container is running\"\n            return 0\n        fi\n\n        # Check if container failed\n        if $DOCKER_CMD ps -a | grep -q \"Exited\"; then\n            log_error \"Container failed to start\"\n            log_info \"Check logs with: $DOCKER_CMD logs bambuddy\"\n            return 1\n        fi\n\n        sleep 2\n        ((attempt++))\n    done\n\n    log_warn \"Container may still be starting. Check with: $DOCKER_CMD ps\"\n}\n\n# -----------------------------------------------------------------------------\n# Main Installation Flow\n# -----------------------------------------------------------------------------\n\nparse_args() {\n    while [[ $# -gt 0 ]]; do\n        case \"$1\" in\n            --path)\n                INSTALL_PATH=\"$2\"\n                shift 2\n                ;;\n            --port)\n                PORT=\"$2\"\n                shift 2\n                ;;\n            --tz)\n                TIMEZONE=\"$2\"\n                shift 2\n                ;;\n            --build)\n                BUILD_FROM_SOURCE=\"true\"\n                shift\n                ;;\n            --yes|-y)\n                NON_INTERACTIVE=\"true\"\n                shift\n                ;;\n            --redirect-990)\n                REDIRECT_990=\"true\"\n                shift\n                ;;\n            --help|-h)\n                show_help\n                ;;\n            *)\n                log_error \"Unknown option: $1\"\n                show_help\n                ;;\n        esac\n    done\n}\n\ncheck_sudo() {\n    if ! command -v sudo &>/dev/null; then\n        log_error \"sudo is required for iptables redirect but is not installed. Skipping iptables redirect.\"\n        return 1\n    fi\n    if ! command -v iptables &>/dev/null; then\n        log_error \"iptables is required for iptables redirect but is not installed. Skipping iptables redirect.\"\n        return 1\n    fi\n    return 0\n}\n\nconfigure_iptables_redirect() {\n    # Deprecated: FTP now binds directly to port 990 (requires CAP_NET_BIND_SERVICE).\n    # The iptables 990→9990 redirect is no longer needed and caused issues with\n    # multi-VP setups (REDIRECT rewrites dest IP to the interface's primary address).\n    if [[ \"$REDIRECT_990\" == \"true\" ]]; then\n        log_warn \"The --redirect-990 flag is deprecated. FTP now binds directly to port 990.\"\n        log_warn \"No iptables redirect is needed. Skipping.\"\n    fi\n}\n\ngather_config() {\n    echo \"\"\n    echo -e \"${BOLD}Installation Configuration${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo \"\"\n\n    # Installation path\n    [[ -z \"$INSTALL_PATH\" ]] && prompt \"Installation directory\" \"$DEFAULT_INSTALL_PATH\" INSTALL_PATH\n\n    # Port\n    [[ -z \"$PORT\" ]] && prompt \"Port to expose\" \"$DEFAULT_PORT\" PORT\n\n    # Timezone\n    detect_timezone\n    prompt \"Timezone\" \"$TIMEZONE\" TIMEZONE\n\n    # Build from source?\n    if [[ \"$BUILD_FROM_SOURCE\" != \"true\" ]] && [[ \"$NON_INTERACTIVE\" != \"true\" ]]; then\n        if prompt_yes_no \"Build from source? (No = use pre-built image)\" \"n\"; then\n            BUILD_FROM_SOURCE=\"true\"\n        fi\n    fi\n\n    # Confirm\n    echo \"\"\n    echo -e \"${BOLD}Installation Summary${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo -e \"  Install path:  ${GREEN}$INSTALL_PATH${NC}\"\n    echo -e \"  Port:          ${GREEN}$PORT${NC}\"\n    echo -e \"  Timezone:      ${GREEN}$TIMEZONE${NC}\"\n    echo -e \"  Build source:  ${GREEN}$BUILD_FROM_SOURCE${NC}\"\n    echo -e \"  Redirect 990:  ${GREEN}$REDIRECT_990${NC}\"\n    echo \"\"\n\n    if ! prompt_yes_no \"Proceed with installation?\" \"y\"; then\n        echo \"Installation cancelled.\"\n        exit 0\n    fi\n}\n\nmain() {\n    parse_args \"$@\"\n    print_banner\n\n    # Check if running via pipe (curl | bash) - interactive mode won't work\n    if [[ ! -t 0 ]] && [[ \"$NON_INTERACTIVE\" != \"true\" ]]; then\n        log_error \"Interactive mode requires a terminal.\"\n        log_info \"When using 'curl | bash', you must use non-interactive mode:\"\n        echo \"\"\n        echo \"    curl -fsSL URL | bash -s -- --yes\"\n        echo \"\"\n        log_info \"Or download and run directly:\"\n        echo \"\"\n        echo \"    curl -fsSL URL -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh\"\n        echo \"\"\n        exit 1\n    fi\n\n    # Detect system\n    log_info \"Detecting system...\"\n    detect_os\n    log_success \"Detected: $OS_TYPE\"\n\n    # Check for Docker\n    if ! command -v docker &>/dev/null; then\n        install_docker\n    fi\n\n    if ! detect_docker; then\n        log_error \"Docker Compose not found. Please install Docker Compose.\"\n        exit 1\n    fi\n\n    # Check if Docker daemon is running\n    if ! docker info &>/dev/null; then\n        log_error \"Docker daemon is not running. Please start Docker and try again.\"\n        exit 1\n    fi\n\n    # Gather configuration\n    gather_config\n\n    # Install steps\n    echo \"\"\n    echo -e \"${BOLD}Starting Installation${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo \"\"\n\n    create_install_dir\n    download_compose_file\n    create_env_file\n    customize_compose\n    start_container\n    configure_iptables_redirect\n\n    # Done!\n    echo \"\"\n    echo -e \"${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}\"\n    echo -e \"${GREEN}║                                                              ║${NC}\"\n    echo -e \"${GREEN}║              Installation Complete!                          ║${NC}\"\n    echo -e \"${GREEN}║                                                              ║${NC}\"\n    echo -e \"${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}\"\n    echo \"\"\n    local ip_addr\n    ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr=\"<your-ip>\"\n    echo -e \"  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}\"\n    echo -e \"                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)\"\n    echo \"\"\n    echo -e \"  ${BOLD}Manage container:${NC}\"\n    echo -e \"    Status:  cd $INSTALL_PATH && $DOCKER_CMD ps\"\n    echo -e \"    Logs:    cd $INSTALL_PATH && $DOCKER_CMD logs -f bambuddy\"\n    echo -e \"    Stop:    cd $INSTALL_PATH && $DOCKER_CMD down\"\n    echo -e \"    Start:   cd $INSTALL_PATH && $DOCKER_CMD up -d\"\n    echo -e \"    Restart: cd $INSTALL_PATH && $DOCKER_CMD restart\"\n    echo \"\"\n    echo -e \"  ${BOLD}Update BamBuddy:${NC}\"\n    if [[ \"$BUILD_FROM_SOURCE\" == \"true\" ]]; then\n        echo -e \"    cd $INSTALL_PATH && git pull && $DOCKER_CMD up -d --build\"\n    else\n        echo -e \"    cd $INSTALL_PATH && $DOCKER_CMD pull && $DOCKER_CMD up -d\"\n    fi\n    echo \"\"\n    echo -e \"  ${BOLD}Data location:${NC}  Docker volumes (bambuddy_data, bambuddy_logs)\"\n    echo \"\"\n    echo -e \"  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}\"\n    echo \"\"\n\n    # Warn about iptables persistence\n    if [[ \"$REDIRECT_990\" == \"true\" ]] && [[ \"$OS_TYPE\" == \"linux\" ]]; then\n        echo -e \"  ${YELLOW}Note:${NC} iptables redirect rules do NOT survive reboot.\"\n        echo -e \"        Install 'iptables-persistent' if persistence is required.\"\n        echo \"\"\n    fi\n\n    if [[ \"$OS_TYPE\" == \"macos\" ]]; then\n        echo -e \"  ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop.\"\n        echo -e \"        Add printers manually using their IP address.\"\n        echo \"\"\n    fi\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "install/install.sh",
    "content": "#!/usr/bin/env bash\n#\n# BamBuddy Native Installation Script\n# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS\n#\n# Usage:\n#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh -o install.sh && chmod +x install.sh && ./install.sh\n#   Unattended:   ./install.sh --path /opt/bambuddy --port 8000 --yes\n#\n# Options:\n#   --path PATH        Installation directory (default: /opt/bambuddy)\n#   --port PORT        Port to listen on (default: 8000)\n#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)\n#   --tz TIMEZONE      Timezone (default: system timezone or UTC)\n#   --data-dir PATH    Data directory (default: INSTALL_PATH/data)\n#   --log-dir PATH     Log directory (default: INSTALL_PATH/logs)\n#   --debug            Enable debug mode\n#   --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)\n#   --branch BRANCH    Git branch to install (default: main)\n#   --no-service       Skip systemd service setup (Linux only)\n#   --set-system-tz    Set system timezone to match (for unattended installs)\n#   --yes, -y          Non-interactive mode, accept defaults\n#   --help, -h         Show this help message\n#\n\nset -e\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nCYAN='\\033[0;36m'\nNC='\\033[0m' # No Color\nBOLD='\\033[1m'\n\n# Default values\nDEFAULT_INSTALL_PATH=\"/opt/bambuddy\"\nDEFAULT_PORT=\"8000\"\nDEFAULT_BIND_ADDRESS=\"0.0.0.0\"\nDEFAULT_LOG_LEVEL=\"INFO\"\nDEFAULT_DEBUG=\"false\"\n\n# Script variables\nINSTALL_PATH=\"\"\nPORT=\"\"\nBIND_ADDRESS=\"\"\nTIMEZONE=\"\"\nDATA_DIR=\"\"\nLOG_DIR=\"\"\nDEBUG_MODE=\"\"\nLOG_LEVEL=\"\"\nSKIP_SERVICE=\"false\"\nSET_SYSTEM_TZ=\"\"\nNON_INTERACTIVE=\"false\"\nOS_TYPE=\"\"\nPKG_MANAGER=\"\"\nPYTHON_CMD=\"\"\nBRANCH=\"\"\nSERVICE_USER=\"bambuddy\"\n\n# -----------------------------------------------------------------------------\n# Helper Functions\n# -----------------------------------------------------------------------------\n\nprint_banner() {\n    echo -e \"${CYAN}\"\n    echo \"╔════════════════════════════════════════════════════════╗\"\n    echo \"║                                                        ║\"\n    echo \"║   ____                  _               _     _        ║\"\n    echo \"║  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  ║\"\n    echo \"║  |  _ \\\\ / _\\` | '_ \\` _ \\\\| '_ \\\\| | | |/ _\\` |/ _\\` | | | | ║\"\n    echo \"║  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║\"\n    echo \"║  |____/ \\\\__,_|_| |_| |_|_.__/ \\\\__,_|\\\\__,_|\\\\__,_|\\\\__, | ║\"\n    echo \"║                                                 |___/  ║\"\n    echo \"║                                                        ║\"\n    echo \"║            Native Installation Script                  ║\"\n    echo \"║                                                        ║\"\n    echo \"╚════════════════════════════════════════════════════════╝\"\n    echo -e \"${NC}\"\n}\n\nlog_info() {\n    echo -e \"${BLUE}[INFO]${NC} $1\"\n}\n\nlog_success() {\n    echo -e \"${GREEN}[OK]${NC} $1\"\n}\n\nlog_warn() {\n    echo -e \"${YELLOW}[WARN]${NC} $1\"\n}\n\nlog_error() {\n    echo -e \"${RED}[ERROR]${NC} $1\"\n}\n\nprompt() {\n    local prompt_text=\"$1\"\n    local default_value=\"$2\"\n    local var_name=\"$3\"\n\n    if [[ \"$NON_INTERACTIVE\" == \"true\" ]]; then\n        eval \"$var_name=\\\"$default_value\\\"\"\n        return\n    fi\n\n    if [[ -n \"$default_value\" ]]; then\n        echo -en \"${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: \"\n    else\n        echo -en \"${BOLD}$prompt_text${NC}: \"\n    fi\n\n    read -r input\n    if [[ -z \"$input\" ]]; then\n        eval \"$var_name=\\\"$default_value\\\"\"\n    else\n        eval \"$var_name=\\\"$input\\\"\"\n    fi\n}\n\nprompt_yes_no() {\n    local prompt_text=\"$1\"\n    local default=\"$2\"  # y or n\n\n    if [[ \"$NON_INTERACTIVE\" == \"true\" ]]; then\n        [[ \"$default\" == \"y\" ]] && return 0 || return 1\n    fi\n\n    local yn_hint=\"[y/n]\"\n    [[ \"$default\" == \"y\" ]] && yn_hint=\"[Y/n]\"\n    [[ \"$default\" == \"n\" ]] && yn_hint=\"[y/N]\"\n\n    while true; do\n        echo -en \"${BOLD}$prompt_text${NC} $yn_hint: \"\n        read -r yn\n        [[ -z \"$yn\" ]] && yn=\"$default\"\n        case \"$yn\" in\n            [Yy]* ) return 0;;\n            [Nn]* ) return 1;;\n            * ) echo \"Please answer yes or no.\";;\n        esac\n    done\n}\n\nshow_help() {\n    echo \"BamBuddy Native Installation Script\"\n    echo \"\"\n    echo \"Usage: $0 [OPTIONS]\"\n    echo \"\"\n    echo \"Options:\"\n    echo \"  --path PATH        Installation directory (default: /opt/bambuddy)\"\n    echo \"  --port PORT        Port to listen on (default: 8000)\"\n    echo \"  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)\"\n    echo \"  --tz TIMEZONE      Timezone (default: system timezone or UTC)\"\n    echo \"  --data-dir PATH    Data directory (default: INSTALL_PATH/data)\"\n    echo \"  --log-dir PATH     Log directory (default: INSTALL_PATH/logs)\"\n    echo \"  --debug            Enable debug mode\"\n    echo \"  --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)\"\n    echo \"  --branch BRANCH    Git branch to install (default: main)\"\n    echo \"  --no-service       Skip systemd service setup (Linux only)\"\n    echo \"  --set-system-tz    Set system timezone to match (for unattended installs)\"\n    echo \"  --yes, -y          Non-interactive mode, accept defaults\"\n    echo \"  --help, -h         Show this help message\"\n    echo \"\"\n    echo \"Examples:\"\n    echo \"  Interactive installation:\"\n    echo \"    ./install.sh\"\n    echo \"\"\n    echo \"  Unattended installation with custom settings:\"\n    echo \"    ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes\"\n    echo \"\"\n    echo \"  Minimal unattended installation:\"\n    echo \"    ./install.sh -y\"\n    exit 0\n}\n\n# -----------------------------------------------------------------------------\n# System Detection\n# -----------------------------------------------------------------------------\n\ndetect_os() {\n    if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n        OS_TYPE=\"macos\"\n        PKG_MANAGER=\"brew\"\n        return\n    fi\n\n    if [[ -f /etc/os-release ]]; then\n        . /etc/os-release\n        case \"$ID\" in\n            ubuntu|debian|raspbian|linuxmint|pop)\n                OS_TYPE=\"debian\"\n                PKG_MANAGER=\"apt\"\n                ;;\n            fedora|rhel|centos|rocky|almalinux|ol)\n                OS_TYPE=\"rhel\"\n                if command -v dnf &>/dev/null; then\n                    PKG_MANAGER=\"dnf\"\n                else\n                    PKG_MANAGER=\"yum\"\n                fi\n                ;;\n            arch|manjaro|endeavouros)\n                OS_TYPE=\"arch\"\n                PKG_MANAGER=\"pacman\"\n                ;;\n            opensuse*|sles)\n                OS_TYPE=\"suse\"\n                PKG_MANAGER=\"zypper\"\n                ;;\n            *)\n                log_error \"Unsupported Linux distribution: $ID\"\n                exit 1\n                ;;\n        esac\n    else\n        log_error \"Cannot detect operating system\"\n        exit 1\n    fi\n}\n\ndetect_python() {\n    # Try python3 first, then python\n    if command -v python3 &>/dev/null; then\n        PYTHON_CMD=\"python3\"\n    elif command -v python &>/dev/null; then\n        local version\n        version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)\n        if [[ \"$version\" -ge 3 ]]; then\n            PYTHON_CMD=\"python\"\n        fi\n    fi\n\n    if [[ -z \"$PYTHON_CMD\" ]]; then\n        return 1\n    fi\n\n    # Check version >= 3.10\n    local version\n    version=$($PYTHON_CMD -c 'import sys; print(f\"{sys.version_info.major}.{sys.version_info.minor}\")')\n    local major minor\n    major=$(echo \"$version\" | cut -d'.' -f1)\n    minor=$(echo \"$version\" | cut -d'.' -f2)\n\n    if [[ \"$major\" -lt 3 ]] || { [[ \"$major\" -eq 3 ]] && [[ \"$minor\" -lt 10 ]]; }; then\n        log_warn \"Python $version found, but 3.10 or newer is required\"\n        return 1\n    fi\n\n    log_success \"Found Python $version\"\n    return 0\n}\n\ndetect_timezone() {\n    if [[ -n \"$TIMEZONE\" ]]; then\n        return 0\n    fi\n\n    # Try to get system timezone (with error handling for set -e)\n    TIMEZONE=\"\"\n    if [[ -f /etc/timezone ]]; then\n        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true\n    fi\n\n    if [[ -z \"$TIMEZONE\" ]] && [[ -L /etc/localtime ]]; then\n        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true\n    fi\n\n    if [[ -z \"$TIMEZONE\" ]] && command -v timedatectl &>/dev/null; then\n        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true\n    fi\n\n    # Default to UTC if not found (use if/then to avoid set -e issue with &&)\n    if [[ -z \"$TIMEZONE\" ]]; then\n        TIMEZONE=\"UTC\"\n    fi\n    return 0\n}\n\n# -----------------------------------------------------------------------------\n# Package Installation\n# -----------------------------------------------------------------------------\n\ninstall_dependencies() {\n    log_info \"Installing system dependencies...\"\n\n    case \"$PKG_MANAGER\" in\n        apt)\n            sudo apt-get update\n            sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg\n            ;;\n        dnf|yum)\n            sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg\n            ;;\n        pacman)\n            sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg\n            ;;\n        zypper)\n            sudo zypper install -y python3 python3-pip git curl ffmpeg\n            ;;\n        brew)\n            # Check if Homebrew is installed\n            if ! command -v brew &>/dev/null; then\n                log_error \"Homebrew not found. Please install it first: https://brew.sh\"\n                exit 1\n            fi\n            brew install python git curl ffmpeg\n            ;;\n    esac\n\n    log_success \"System dependencies installed\"\n}\n\n# -----------------------------------------------------------------------------\n# Installation Steps\n# -----------------------------------------------------------------------------\n\ncreate_user() {\n    if [[ \"$OS_TYPE\" == \"macos\" ]]; then\n        return  # Skip user creation on macOS\n    fi\n\n    if id \"$SERVICE_USER\" &>/dev/null; then\n        log_info \"User '$SERVICE_USER' already exists\"\n        return\n    fi\n\n    log_info \"Creating service user '$SERVICE_USER'...\"\n    sudo useradd --system --shell /usr/sbin/nologin --home-dir \"$INSTALL_PATH\" \"$SERVICE_USER\"\n    log_success \"Service user created\"\n}\n\ndownload_bambuddy() {\n    log_info \"Downloading BamBuddy...\"\n\n    # Validate branch exists on remote before proceeding\n    if ! git ls-remote --exit-code --heads https://github.com/maziggy/bambuddy.git \"$BRANCH\" &>/dev/null; then\n        log_error \"Branch '$BRANCH' not found in the BamBuddy repository.\"\n        log_info \"Available branches:\"\n        git ls-remote --heads https://github.com/maziggy/bambuddy.git | sed 's|.*refs/heads/|  - |'\n        exit 1\n    fi\n\n    if [[ -d \"$INSTALL_PATH/.git\" ]]; then\n        log_info \"Existing installation found, updating...\"\n        # Add safe.directory to avoid \"dubious ownership\" error when running as root\n        git config --global --add safe.directory \"$INSTALL_PATH\" 2>/dev/null || true\n        cd \"$INSTALL_PATH\"\n        git fetch origin\n        git checkout \"$BRANCH\" 2>/dev/null || git checkout -b \"$BRANCH\" \"origin/$BRANCH\"\n        git reset --hard \"origin/$BRANCH\"\n        # Ensure correct ownership after update\n        sudo chown -R \"$SERVICE_USER:$SERVICE_USER\" \"$INSTALL_PATH\" 2>/dev/null || true\n    else\n        # Clone as root so we have write access regardless of the installing user,\n        # then hand ownership to the service user. Previously we chown'd the empty\n        # dir to the service user before the clone, which left the install-running\n        # user (not root, not bambuddy) unable to write .git into it.\n        sudo mkdir -p \"$INSTALL_PATH\"\n        sudo git clone --branch \"$BRANCH\" https://github.com/maziggy/bambuddy.git \"$INSTALL_PATH\"\n        sudo chown -R \"$SERVICE_USER:$SERVICE_USER\" \"$INSTALL_PATH\" 2>/dev/null || true\n    fi\n\n    log_success \"BamBuddy downloaded to $INSTALL_PATH (branch: $BRANCH)\"\n}\n\nsetup_virtualenv() {\n    log_info \"Setting up Python virtual environment...\"\n\n    cd \"$INSTALL_PATH\"\n\n    if [[ \"$OS_TYPE\" == \"macos\" ]]; then\n        $PYTHON_CMD -m venv venv\n        \"$INSTALL_PATH/venv/bin/pip\" install --upgrade pip\n        \"$INSTALL_PATH/venv/bin/pip\" install -r requirements.txt\n    else\n        # Venv is owned by the service user, so pip must also run as that user —\n        # otherwise `pip install --upgrade pip` fails trying to rewrite its own\n        # binary inside the venv it doesn't own.\n        sudo -u \"$SERVICE_USER\" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv\n        sudo -u \"$SERVICE_USER\" \"$INSTALL_PATH/venv/bin/pip\" install --upgrade pip\n        sudo -u \"$SERVICE_USER\" \"$INSTALL_PATH/venv/bin/pip\" install -r requirements.txt\n    fi\n\n    log_success \"Virtual environment configured\"\n}\n\ncheck_node_version() {\n    # Returns 0 if Node.js 20+ is available, 1 otherwise\n    if ! command -v node &>/dev/null; then\n        return 1\n    fi\n\n    local version\n    version=$(node --version 2>/dev/null | sed 's/^v//')\n    local major\n    major=$(echo \"$version\" | cut -d'.' -f1)\n\n    if [[ \"$major\" -ge 20 ]]; then\n        log_success \"Found Node.js v$version\"\n        return 0\n    else\n        log_warn \"Found Node.js v$version (need 20+)\"\n        return 1\n    fi\n}\n\ninstall_nodejs() {\n    log_info \"Installing Node.js 22...\"\n    case \"$PKG_MANAGER\" in\n        apt)\n            # Remove old nodejs if present\n            sudo apt-get remove -y nodejs npm 2>/dev/null || true\n            curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -\n            sudo apt-get install -y nodejs\n            ;;\n        dnf|yum)\n            sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true\n            curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -\n            sudo $PKG_MANAGER install -y nodejs\n            ;;\n        pacman)\n            sudo pacman -S --noconfirm nodejs npm\n            ;;\n        zypper)\n            sudo zypper install -y nodejs22\n            ;;\n        brew)\n            brew install node@22\n            brew link --overwrite node@22\n            ;;\n        *)\n            log_error \"Please install Node.js 20+ manually: https://nodejs.org/\"\n            exit 1\n            ;;\n    esac\n    # Refresh PATH\n    hash -r 2>/dev/null || true\n}\n\nbuild_frontend() {\n    log_info \"Building frontend...\"\n\n    cd \"$INSTALL_PATH/frontend\"\n\n    # Check for Node.js 20+\n    if ! check_node_version; then\n        install_nodejs\n        # Verify installation\n        if ! check_node_version; then\n            log_error \"Failed to install Node.js 20+. Please install manually.\"\n            exit 1\n        fi\n    fi\n\n    # Frontend tree is owned by the service user, so npm must run as that user —\n    # otherwise creating node_modules/ and writing build output fails. macOS\n    # keeps the current-user flow since it has no service user.\n    if [[ \"$OS_TYPE\" == \"macos\" ]]; then\n        npm ci\n        npm run build\n    else\n        sudo -H -u \"$SERVICE_USER\" npm ci\n        sudo -H -u \"$SERVICE_USER\" npm run build\n    fi\n\n    log_success \"Frontend built\"\n}\n\ncreate_directories() {\n    log_info \"Creating data directories...\"\n\n    sudo mkdir -p \"$DATA_DIR\" \"$LOG_DIR\"\n\n    if [[ \"$OS_TYPE\" != \"macos\" ]]; then\n        sudo chown -R \"$SERVICE_USER:$SERVICE_USER\" \"$DATA_DIR\" \"$LOG_DIR\"\n    fi\n\n    log_success \"Directories created\"\n}\n\ncreate_env_file() {\n    log_info \"Creating environment configuration...\"\n\n    local env_file=\"$INSTALL_PATH/.env\"\n\n    # Note: Only include settings recognized by the app's pydantic Settings class\n    # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service\n    cat > /tmp/bambuddy.env << EOF\n# BamBuddy Configuration\n# Generated by install.sh on $(date)\n\n# Debug mode (true = verbose logging)\nDEBUG=$DEBUG_MODE\n\n# Log level (only used when DEBUG=false)\n# Options: DEBUG, INFO, WARNING, ERROR\nLOG_LEVEL=$LOG_LEVEL\n\n# Enable file logging\nLOG_TO_FILE=true\nEOF\n\n    sudo mv /tmp/bambuddy.env \"$env_file\"\n    if [[ \"$OS_TYPE\" != \"macos\" ]]; then\n        sudo chown \"$SERVICE_USER:$SERVICE_USER\" \"$env_file\"\n    fi\n    sudo chmod 600 \"$env_file\"\n\n    log_success \"Environment file created at $env_file\"\n}\n\ncreate_systemd_service() {\n    if [[ \"$OS_TYPE\" == \"macos\" ]] || [[ \"$SKIP_SERVICE\" == \"true\" ]]; then\n        return\n    fi\n\n    log_info \"Creating systemd service...\"\n\n    cat > /tmp/bambuddy.service << EOF\n[Unit]\nDescription=BamBuddy - Bambu Lab Print Management\nDocumentation=https://github.com/maziggy/bambuddy\nAfter=network.target\n\n[Service]\nType=simple\nUser=$SERVICE_USER\nGroup=$SERVICE_USER\nWorkingDirectory=$INSTALL_PATH\n\n# App settings from .env file\nEnvironmentFile=$INSTALL_PATH/.env\n\n# Service settings (not in .env to avoid pydantic validation errors)\nEnvironment=\"DATA_DIR=$DATA_DIR\"\nEnvironment=\"LOG_DIR=$LOG_DIR\"\nEnvironment=\"TZ=$TIMEZONE\"\n\nExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT\nRestart=on-failure\nRestartSec=5\nStandardOutput=journal\nStandardError=journal\n\n# Allow binding to privileged ports (322, 990, 2024-2026) for Virtual Printer proxy mode\nAmbientCapabilities=CAP_NET_BIND_SERVICE\n\n# Security hardening\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH\n\n[Install]\nWantedBy=multi-user.target\nEOF\n\n    sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service\n    sudo systemctl daemon-reload\n\n    log_success \"Systemd service created\"\n\n    if prompt_yes_no \"Enable BamBuddy to start on boot?\" \"y\"; then\n        sudo systemctl enable bambuddy\n        log_success \"Service enabled\"\n    fi\n\n    if prompt_yes_no \"Start BamBuddy now?\" \"y\"; then\n        sudo systemctl start bambuddy\n        sleep 2\n        if sudo systemctl is-active --quiet bambuddy; then\n            log_success \"BamBuddy is running\"\n        else\n            log_warn \"Service may have failed to start. Check: sudo journalctl -u bambuddy -f\"\n        fi\n    fi\n}\n\ncreate_launchd_service() {\n    if [[ \"$OS_TYPE\" != \"macos\" ]] || [[ \"$SKIP_SERVICE\" == \"true\" ]]; then\n        return\n    fi\n\n    log_info \"Creating launchd service...\"\n\n    local plist_path=\"$HOME/Library/LaunchAgents/com.bambuddy.app.plist\"\n\n    cat > \"$plist_path\" << EOF\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>Label</key>\n    <string>com.bambuddy.app</string>\n    <key>ProgramArguments</key>\n    <array>\n        <string>$INSTALL_PATH/venv/bin/uvicorn</string>\n        <string>backend.app.main:app</string>\n        <string>--host</string>\n        <string>$BIND_ADDRESS</string>\n        <string>--port</string>\n        <string>$PORT</string>\n    </array>\n    <key>WorkingDirectory</key>\n    <string>$INSTALL_PATH</string>\n    <key>EnvironmentVariables</key>\n    <dict>\n        <key>DEBUG</key>\n        <string>$DEBUG_MODE</string>\n        <key>LOG_LEVEL</key>\n        <string>$LOG_LEVEL</string>\n        <key>DATA_DIR</key>\n        <string>$DATA_DIR</string>\n        <key>LOG_DIR</key>\n        <string>$LOG_DIR</string>\n        <key>TZ</key>\n        <string>$TIMEZONE</string>\n    </dict>\n    <key>RunAtLoad</key>\n    <true/>\n    <key>KeepAlive</key>\n    <true/>\n    <key>StandardOutPath</key>\n    <string>$LOG_DIR/bambuddy.log</string>\n    <key>StandardErrorPath</key>\n    <string>$LOG_DIR/bambuddy.error.log</string>\n</dict>\n</plist>\nEOF\n\n    log_success \"Launchd plist created at $plist_path\"\n\n    if prompt_yes_no \"Load BamBuddy service now?\" \"y\"; then\n        launchctl load \"$plist_path\"\n        sleep 2\n        if launchctl list | grep -q \"com.bambuddy.app\"; then\n            log_success \"BamBuddy is running\"\n        else\n            log_warn \"Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log\"\n        fi\n    fi\n}\n\n# -----------------------------------------------------------------------------\n# Main Installation Flow\n# -----------------------------------------------------------------------------\n\nparse_args() {\n    while [[ $# -gt 0 ]]; do\n        case \"$1\" in\n            --path)\n                INSTALL_PATH=\"$2\"\n                shift 2\n                ;;\n            --port)\n                PORT=\"$2\"\n                shift 2\n                ;;\n            --bind)\n                BIND_ADDRESS=\"$2\"\n                shift 2\n                ;;\n            --tz)\n                TIMEZONE=\"$2\"\n                shift 2\n                ;;\n            --data-dir)\n                DATA_DIR=\"$2\"\n                shift 2\n                ;;\n            --log-dir)\n                LOG_DIR=\"$2\"\n                shift 2\n                ;;\n            --debug)\n                DEBUG_MODE=\"true\"\n                shift\n                ;;\n            --log-level)\n                LOG_LEVEL=\"$2\"\n                shift 2\n                ;;\n            --branch)\n                BRANCH=\"$2\"\n                shift 2\n                ;;\n            --no-service)\n                SKIP_SERVICE=\"true\"\n                shift\n                ;;\n            --set-system-tz)\n                SET_SYSTEM_TZ=\"true\"\n                shift\n                ;;\n            --yes|-y)\n                NON_INTERACTIVE=\"true\"\n                shift\n                ;;\n            --help|-h)\n                show_help\n                ;;\n            *)\n                log_error \"Unknown option: $1\"\n                show_help\n                ;;\n        esac\n    done\n}\n\ngather_config() {\n    echo \"\"\n    echo -e \"${BOLD}Installation Configuration${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo \"\"\n\n    # Installation path\n    [[ -z \"$INSTALL_PATH\" ]] && prompt \"Installation directory\" \"$DEFAULT_INSTALL_PATH\" INSTALL_PATH\n\n    # Branch\n    [[ -z \"$BRANCH\" ]] && prompt \"Git branch\" \"main\" BRANCH\n\n    # Port\n    [[ -z \"$PORT\" ]] && prompt \"Port to listen on\" \"$DEFAULT_PORT\" PORT\n\n    # Bind address\n    if [[ -z \"$BIND_ADDRESS\" ]]; then\n        echo \"\"\n        echo \"Network access:\"\n        echo \"  0.0.0.0   - Accessible from other devices on your network (recommended)\"\n        echo \"  127.0.0.1 - Only accessible from this machine\"\n        prompt \"Bind address\" \"$DEFAULT_BIND_ADDRESS\" BIND_ADDRESS\n    fi\n\n    # Timezone\n    detect_timezone\n    prompt \"Timezone\" \"$TIMEZONE\" TIMEZONE\n\n    # Offer to set system timezone if different from current (skip if already set via --set-system-tz)\n    if [[ -z \"$SET_SYSTEM_TZ\" ]]; then\n        local current_tz\n        current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true\n        if [[ -n \"$TIMEZONE\" ]] && [[ \"$TIMEZONE\" != \"$current_tz\" ]]; then\n            # Default to \"n\" so unattended installs don't change system TZ unless --set-system-tz is used\n            if prompt_yes_no \"Set system timezone to $TIMEZONE?\" \"n\"; then\n                SET_SYSTEM_TZ=\"true\"\n            else\n                SET_SYSTEM_TZ=\"false\"\n            fi\n        else\n            SET_SYSTEM_TZ=\"false\"\n        fi\n    fi\n\n    # Data directory\n    [[ -z \"$DATA_DIR\" ]] && DATA_DIR=\"$INSTALL_PATH/data\"\n    prompt \"Data directory\" \"$DATA_DIR\" DATA_DIR\n\n    # Log directory\n    [[ -z \"$LOG_DIR\" ]] && LOG_DIR=\"$INSTALL_PATH/logs\"\n    prompt \"Log directory\" \"$LOG_DIR\" LOG_DIR\n\n    # Debug mode\n    if [[ -z \"$DEBUG_MODE\" ]]; then\n        if prompt_yes_no \"Enable debug mode?\" \"n\"; then\n            DEBUG_MODE=\"true\"\n        else\n            DEBUG_MODE=\"false\"\n        fi\n    fi\n\n    # Log level\n    if [[ -z \"$LOG_LEVEL\" ]]; then\n        echo \"\"\n        echo \"Log levels: DEBUG, INFO, WARNING, ERROR\"\n        prompt \"Log level\" \"$DEFAULT_LOG_LEVEL\" LOG_LEVEL\n    fi\n\n    # Confirm\n    echo \"\"\n    echo -e \"${BOLD}Installation Summary${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo -e \"  Install path:  ${GREEN}$INSTALL_PATH${NC}\"\n    if [[ \"$BRANCH\" != \"main\" ]]; then\n        echo -e \"  Branch:        ${YELLOW}$BRANCH${NC} (beta)\"\n    else\n        echo -e \"  Branch:        ${GREEN}$BRANCH${NC}\"\n    fi\n    echo -e \"  Port:          ${GREEN}$PORT${NC}\"\n    echo -e \"  Bind address:  ${GREEN}$BIND_ADDRESS${NC}\"\n    echo -e \"  Timezone:      ${GREEN}$TIMEZONE${NC}\"\n    echo -e \"  Data dir:      ${GREEN}$DATA_DIR${NC}\"\n    echo -e \"  Log dir:       ${GREEN}$LOG_DIR${NC}\"\n    echo -e \"  Debug mode:    ${GREEN}$DEBUG_MODE${NC}\"\n    echo -e \"  Log level:     ${GREEN}$LOG_LEVEL${NC}\"\n    echo \"\"\n\n    if ! prompt_yes_no \"Proceed with installation?\" \"y\"; then\n        echo \"Installation cancelled.\"\n        exit 0\n    fi\n}\n\nmain() {\n    parse_args \"$@\"\n    print_banner\n\n    # Check if running via pipe (curl | bash) - interactive mode won't work\n    if [[ ! -t 0 ]] && [[ \"$NON_INTERACTIVE\" != \"true\" ]]; then\n        log_error \"Interactive mode requires a terminal.\"\n        log_info \"When using 'curl | bash', you must use non-interactive mode:\"\n        echo \"\"\n        echo \"    curl -fsSL URL | bash -s -- --yes\"\n        echo \"\"\n        log_info \"Or download and run directly:\"\n        echo \"\"\n        echo \"    curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh\"\n        echo \"\"\n        exit 1\n    fi\n\n    # Check for root (we need sudo for some operations)\n    if [[ \"$EUID\" -eq 0 ]] && [[ \"$OS_TYPE\" != \"macos\" ]]; then\n        log_warn \"Running as root. Consider using a regular user with sudo privileges.\"\n    fi\n\n    # Detect system\n    log_info \"Detecting system...\"\n    detect_os\n    log_success \"Detected: $OS_TYPE (package manager: $PKG_MANAGER)\"\n\n    # Check/install Python\n    if ! detect_python; then\n        log_info \"Python 3.10+ not found, will install...\"\n    fi\n\n    # Gather configuration\n    gather_config\n\n    # Install steps\n    echo \"\"\n    echo -e \"${BOLD}Starting Installation${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo \"\"\n\n    install_dependencies\n    detect_python || { log_error \"Failed to install Python\"; exit 1; }\n\n    # Set system timezone if requested\n    if [[ \"$SET_SYSTEM_TZ\" == \"true\" ]]; then\n        log_info \"Setting system timezone to $TIMEZONE...\"\n        if [[ \"$OS_TYPE\" == \"macos\" ]]; then\n            sudo systemsetup -settimezone \"$TIMEZONE\" 2>/dev/null || true\n        else\n            sudo timedatectl set-timezone \"$TIMEZONE\" 2>/dev/null || true\n        fi\n        log_success \"System timezone set to $TIMEZONE\"\n    fi\n\n    if [[ \"$OS_TYPE\" != \"macos\" ]]; then\n        create_user\n    else\n        SERVICE_USER=\"$USER\"\n    fi\n\n    download_bambuddy\n    setup_virtualenv\n    build_frontend\n    create_directories\n    create_env_file\n\n    if [[ \"$OS_TYPE\" == \"macos\" ]]; then\n        create_launchd_service\n    else\n        create_systemd_service\n    fi\n\n    # Done!\n    echo \"\"\n    echo -e \"${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}\"\n    echo -e \"${GREEN}║                                                              ║${NC}\"\n    echo -e \"${GREEN}║              Installation Complete!                          ║${NC}\"\n    echo -e \"${GREEN}║                                                              ║${NC}\"\n    echo -e \"${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}\"\n    echo \"\"\n    # Show appropriate URL based on bind address\n    if [[ \"$BIND_ADDRESS\" == \"0.0.0.0\" ]]; then\n        local ip_addr\n        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr=\"<your-ip>\"\n        echo -e \"  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}\"\n        echo -e \"                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)\"\n    else\n        echo -e \"  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}\"\n    fi\n    echo \"\"\n    if [[ \"$OS_TYPE\" == \"macos\" ]]; then\n        echo -e \"  ${BOLD}Manage service:${NC}\"\n        echo -e \"    Start:   launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist\"\n        echo -e \"    Stop:    launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist\"\n        echo -e \"    Logs:    tail -f $LOG_DIR/bambuddy.log\"\n    else\n        echo -e \"  ${BOLD}Manage service:${NC}\"\n        echo -e \"    Status:  sudo systemctl status bambuddy\"\n        echo -e \"    Start:   sudo systemctl start bambuddy\"\n        echo -e \"    Stop:    sudo systemctl stop bambuddy\"\n        echo -e \"    Logs:    sudo journalctl -u bambuddy -f\"\n    fi\n    echo \"\"\n    echo -e \"  ${BOLD}Update BamBuddy:${NC}\"\n    echo -e \"    cd $INSTALL_PATH && git pull && source venv/bin/activate\"\n    echo -e \"    pip install -r requirements.txt && cd frontend && npm ci && npm run build\"\n    if [[ \"$OS_TYPE\" != \"macos\" ]]; then\n        echo -e \"    sudo systemctl restart bambuddy\"\n    fi\n    echo \"\"\n    echo -e \"  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}\"\n    echo \"\"\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "install/update.sh",
    "content": "#!/usr/bin/env bash\nset -Eeuo pipefail\n\nINSTALL_DIR=\"${INSTALL_DIR:-/opt/bambuddy}\"\nSERVICE_NAME=\"${SERVICE_NAME:-bambuddy}\"\nBRANCH=\"${BRANCH:-}\"\nVENV_PIP=\"${VENV_PIP:-$INSTALL_DIR/venv/bin/pip}\"\nFRONTEND_DIR=\"${FRONTEND_DIR:-$INSTALL_DIR/frontend}\"\nBACKUP_DIR=\"${BACKUP_DIR:-$INSTALL_DIR/backups}\"\nBAMBUDDY_API_URL=\"${BAMBUDDY_API_URL:-http://127.0.0.1:8000/api/v1}\"\nBAMBUDDY_API_KEY=\"${BAMBUDDY_API_KEY:-}\"\nBACKUP_MODE=\"${BACKUP_MODE:-auto}\" # auto|require|skip\nBACKUP_KEEP_COUNT=5\nFORCE=\"${FORCE:-0}\"\n\nSERVICE_STOPPED=0\nCODE_UPDATED=0\nold_commit=\"\"\n\nlog() {\n  printf '[bambuddy-update] %s\\n' \"$*\"\n}\n\nwarn() {\n  printf '[bambuddy-update] WARNING: %s\\n' \"$*\" >&2\n}\n\ndie() {\n  printf '[bambuddy-update] ERROR: %s\\n' \"$*\" >&2\n  exit 1\n}\n\nrequire_cmd() {\n  command -v \"$1\" >/dev/null 2>&1 || die \"Missing required command: $1\"\n}\n\ncleanup_old_backups() {\n  local -a backup_files\n  local max_count=\"$1\"\n\n  [ \"$max_count\" -gt 0 ] || return 0\n\n  mapfile -t backup_files < <(ls -1t \"$BACKUP_DIR\"/bambuddy-backup-*.zip 2>/dev/null || true)\n  if [ \"${#backup_files[@]}\" -le \"$max_count\" ]; then\n    return 0\n  fi\n\n  for old_file in \"${backup_files[@]:$max_count}\"; do\n    rm -f \"$old_file\"\n  done\n\n  log \"Pruned old backups, kept newest $max_count file(s)\"\n}\n\non_error() {\n  local exit_code=\"$1\"\n\n  if [ \"$SERVICE_STOPPED\" -eq 1 ]; then\n    if [ \"$CODE_UPDATED\" -eq 1 ] && [ -n \"$old_commit\" ]; then\n      warn \"Update failed after code change, attempting rollback to $old_commit\"\n      git reset --hard \"$old_commit\" || warn \"Rollback reset failed\"\n    fi\n\n    warn \"Update failed, attempting to restart service: $SERVICE_NAME\"\n    systemctl start \"$SERVICE_NAME\" || true\n  fi\n\n  exit \"$exit_code\"\n}\ntrap 'on_error $?' ERR\n\ncreate_backup() {\n  local ts backup_file\n  local -a auth_args=()\n\n  if [ \"$BACKUP_MODE\" = \"skip\" ]; then\n    log \"Skipping backup (BACKUP_MODE=skip)\"\n    return 0\n  fi\n\n  if ! systemctl is-active --quiet \"$SERVICE_NAME\"; then\n    if [ \"$BACKUP_MODE\" = \"require\" ]; then\n      die \"Service is not running; cannot call built-in backup API.\"\n    fi\n    warn \"Service is not running; skipping built-in backup API call.\"\n    return 0\n  fi\n\n  mkdir -p \"$BACKUP_DIR\"\n  ts=\"$(date +%Y%m%d-%H%M%S)\"\n  backup_file=\"$BACKUP_DIR/bambuddy-backup-$ts.zip\"\n\n  [ -n \"$BAMBUDDY_API_KEY\" ] && auth_args=(-H \"X-API-Key: $BAMBUDDY_API_KEY\")\n\n  log \"Creating built-in backup via API: $backup_file\"\n  if curl --silent --show-error --fail --location \\\n    --connect-timeout 5 --max-time 900 \\\n    \"${auth_args[@]}\" \\\n    \"$BAMBUDDY_API_URL/settings/backup\" \\\n    --output \"$backup_file\"; then\n    log \"Backup created successfully\"\n    cleanup_old_backups \"$BACKUP_KEEP_COUNT\"\n    return 0\n  fi\n\n  rm -f \"$backup_file\"\n  if [ \"$BACKUP_MODE\" = \"require\" ]; then\n    die \"Built-in backup API call failed (BACKUP_MODE=require).\"\n  fi\n  warn \"Built-in backup API call failed. Continuing because BACKUP_MODE=auto.\"\n}\n\n[ \"${EUID:-$(id -u)}\" -eq 0 ] || die \"Run as root (or with sudo).\"\n\ncase \"$BACKUP_MODE\" in\n  auto|require|skip) ;;\n  *) die \"Invalid BACKUP_MODE '$BACKUP_MODE' (expected: auto, require, skip).\" ;;\nesac\n\nrequire_cmd git\nrequire_cmd systemctl\nrequire_cmd curl\n\n[ -d \"$INSTALL_DIR\" ] || die \"Install directory not found: $INSTALL_DIR\"\ncd \"$INSTALL_DIR\"\nif [ ! -d .git ]; then\n  cat >&2 <<EOF\n[bambuddy-update] ERROR: No .git directory found in $INSTALL_DIR.\n\nThis update script requires a git-based install. If you installed by\ndownloading a ZIP or tarball from GitHub, reinstall from scratch:\n\n  1. Back up your data:\n       sudo systemctl stop $SERVICE_NAME\n       sudo tar czf ~/bambuddy-backup.tgz -C $INSTALL_DIR \\\\\n         data bambuddy.db bambuddy.db-shm bambuddy.db-wal \\\\\n         virtual_printer archive projects icons .env 2>/dev/null || true\n\n  2. Remove the old install and reinstall via install.sh:\n       sudo rm -rf $INSTALL_DIR\n       curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh \\\\\n         -o /tmp/install.sh && sudo bash /tmp/install.sh --path $INSTALL_DIR\n\n  3. Restore your data:\n       sudo systemctl stop $SERVICE_NAME\n       sudo tar xzf ~/bambuddy-backup.tgz -C $INSTALL_DIR\n       sudo systemctl start $SERVICE_NAME\nEOF\n  exit 1\nfi\n\nif [ -z \"$BRANCH\" ]; then\n  BRANCH=\"$(git rev-parse --abbrev-ref HEAD)\"\n  [ \"$BRANCH\" = \"HEAD\" ] && BRANCH=\"main\"\nfi\n\nload_state=\"$(systemctl show \"$SERVICE_NAME\" --property=LoadState --value 2>/dev/null || true)\"\nif [ -z \"$load_state\" ] || [ \"$load_state\" = \"not-found\" ]; then\n  die \"Service not found: ${SERVICE_NAME}.service\"\nfi\n\nold_commit=\"$(git rev-parse --short HEAD || true)\"\n\nlog \"Fetching latest code from origin/$BRANCH\"\ngit fetch --prune origin\n\nremote_commit=\"$(git rev-parse --short \"origin/$BRANCH\" || true)\"\nlog \"Current commit: ${old_commit:-unknown}\"\nlog \"Remote commit: ${remote_commit:-unknown}\"\n\nif git diff --quiet HEAD \"origin/$BRANCH\"; then\n  log \"You are already running the latest version of Bambuddy.\"\n  read -r -p \"Do you want to run the update process anyway? [y/N]: \" run_anyway\n  case \"${run_anyway:-}\" in\n    y|Y|yes|YES) ;;\n    *) exit 0 ;;\n  esac\nelse\n  read -r -p \"An update for Bambuddy is available. Install now? [y/N]: \" install_now\n  case \"${install_now:-}\" in\n    y|Y|yes|YES) ;;\n    *) exit 0 ;;\n  esac\nfi\n\nif [ -n \"$(git status --porcelain)\" ]; then\n  if [ \"$FORCE\" != \"1\" ]; then\n    read -r -p \"Local edits were detected in your installation. Updating now will overwrite those edits. Continue? [y/N]: \" answer\n    case \"${answer:-}\" in\n      y|Y|yes|YES) ;;\n      *) die \"Update cancelled by user.\" ;;\n    esac\n  else\n    warn \"Proceeding without prompt because FORCE=1.\"\n  fi\nfi\n\ncreate_backup\n\nlog \"Stopping service: $SERVICE_NAME\"\nsystemctl stop \"$SERVICE_NAME\"\nSERVICE_STOPPED=1\n\nlog \"Updating code to origin/$BRANCH\"\ngit reset --hard \"origin/$BRANCH\"\nCODE_UPDATED=1\n\nif [ -x \"$VENV_PIP\" ] && [ -f requirements.txt ]; then\n  log \"Updating Python dependencies\"\n  \"$VENV_PIP\" install -r requirements.txt\nelse\n  warn \"Skipping Python dependency update (venv pip or requirements.txt missing).\"\nfi\n\nif [ -f \"$FRONTEND_DIR/package.json\" ]; then\n  if command -v npm >/dev/null 2>&1; then\n    log \"Building frontend\"\n    (\n      cd \"$FRONTEND_DIR\"\n      npm ci\n      npm run build\n    )\n  else\n    warn \"Skipping frontend build (npm not installed).\"\n  fi\nelse\n  warn \"Skipping frontend build (frontend/package.json not found).\"\nfi\n\nlog \"Starting service: $SERVICE_NAME\"\nsystemctl start \"$SERVICE_NAME\"\nSERVICE_STOPPED=0\nsystemctl --no-pager --lines=8 status \"$SERVICE_NAME\"\n\nnew_commit=\"$(git rev-parse --short HEAD || true)\"\nlog \"Update complete: ${old_commit:-unknown} -> ${new_commit:-unknown}\"\n"
  },
  {
    "path": "install/update_macos.sh",
    "content": "#!/usr/bin/env bash\nset -Eeuo pipefail\n\nINSTALL_DIR=\"${INSTALL_DIR:-/opt/bambuddy}\"\nSERVICE_NAME=\"${SERVICE_NAME:-com.bambuddy.app}\"\nPLIST_PATH=\"${PLIST_PATH:-$HOME/Library/LaunchAgents/com.bambuddy.app.plist}\"\nBRANCH=\"${BRANCH:-}\"\nVENV_PIP=\"${VENV_PIP:-$INSTALL_DIR/venv/bin/pip}\"\nFRONTEND_DIR=\"${FRONTEND_DIR:-$INSTALL_DIR/frontend}\"\nBACKUP_DIR=\"${BACKUP_DIR:-$INSTALL_DIR/backups}\"\nBAMBUDDY_API_URL=\"${BAMBUDDY_API_URL:-http://127.0.0.1:8000/api/v1}\"\nBAMBUDDY_API_KEY=\"${BAMBUDDY_API_KEY:-}\"\nBACKUP_MODE=\"${BACKUP_MODE:-auto}\" # auto|require|skip\nBACKUP_KEEP_COUNT=5\nFORCE=\"${FORCE:-0}\"\n\nSERVICE_STOPPED=0\nCODE_UPDATED=0\nold_commit=\"\"\n\nlog() {\n  printf '[bambuddy-update] %s\\n' \"$*\"\n}\n\nwarn() {\n  printf '[bambuddy-update] WARNING: %s\\n' \"$*\" >&2\n}\n\ndie() {\n  printf '[bambuddy-update] ERROR: %s\\n' \"$*\" >&2\n  exit 1\n}\n\nrequire_cmd() {\n  command -v \"$1\" >/dev/null 2>&1 || die \"Missing required command: $1\"\n}\n\ncleanup_old_backups() {\n  local -a backup_files\n  local max_count=\"$1\"\n\n  [ \"$max_count\" -gt 0 ] || return 0\n\n  mapfile -t backup_files < <(ls -1t \"$BACKUP_DIR\"/bambuddy-backup-*.zip 2>/dev/null || true)\n  if [ \"${#backup_files[@]}\" -le \"$max_count\" ]; then\n    return 0\n  fi\n\n  for old_file in \"${backup_files[@]:$max_count}\"; do\n    rm -f \"$old_file\"\n  done\n\n  log \"Pruned old backups, kept newest $max_count file(s)\"\n}\n\nis_service_active() {\n  launchctl list | grep -q \"$SERVICE_NAME\"\n}\n\non_error() {\n  local exit_code=\"$1\"\n\n  if [ \"$SERVICE_STOPPED\" -eq 1 ]; then\n    if [ \"$CODE_UPDATED\" -eq 1 ] && [ -n \"$old_commit\" ]; then\n      warn \"Update failed after code change, attempting rollback to $old_commit\"\n      git reset --hard \"$old_commit\" || warn \"Rollback reset failed\"\n    fi\n\n    warn \"Update failed, attempting to restart service: $SERVICE_NAME\"\n    launchctl load \"$PLIST_PATH\" || true\n  fi\n\n  exit \"$exit_code\"\n}\ntrap 'on_error $?' ERR\n\ncreate_backup() {\n  local ts backup_file\n  local -a auth_args=()\n\n  if [ \"$BACKUP_MODE\" = \"skip\" ]; then\n    log \"Skipping backup (BACKUP_MODE=skip)\"\n    return 0\n  fi\n\n  if ! is_service_active; then\n    if [ \"$BACKUP_MODE\" = \"require\" ]; then\n      die \"Service is not running; cannot call built-in backup API.\"\n    fi\n    warn \"Service is not running; skipping built-in backup API call.\"\n    return 0\n  fi\n\n  mkdir -p \"$BACKUP_DIR\"\n  ts=\"$(date +%Y%m%d-%H%M%S)\"\n  backup_file=\"$BACKUP_DIR/bambuddy-backup-$ts.zip\"\n\n  [ -n \"$BAMBUDDY_API_KEY\" ] && auth_args=(-H \"X-API-Key: $BAMBUDDY_API_KEY\")\n\n  log \"Creating built-in backup via API: $backup_file\"\n  if curl --silent --show-error --fail --location \\\n    --connect-timeout 5 --max-time 900 \\\n    ${auth_args:+${auth_args[@]}} \\\n    \"$BAMBUDDY_API_URL/settings/backup\" \\\n    --output \"$backup_file\"; then\n    log \"Backup created successfully\"\n    cleanup_old_backups \"$BACKUP_KEEP_COUNT\"\n    return 0\n  fi\n\n  rm -f \"$backup_file\"\n  if [ \"$BACKUP_MODE\" = \"require\" ]; then\n    die \"Built-in backup API call failed (BACKUP_MODE=require).\"\n  fi\n  warn \"Built-in backup API call failed. Continuing because BACKUP_MODE=auto.\"\n}\n\n# NOTE: kept root check as-is (you can remove if desired)\n#[ \"${EUID:-$(id -u)}\" -eq 0 ] || die \"Run as root (or with sudo).\"\n\ncase \"$BACKUP_MODE\" in\n  auto|require|skip) ;;\n  *) die \"Invalid BACKUP_MODE '$BACKUP_MODE' (expected: auto, require, skip).\" ;;\nesac\n\nrequire_cmd git\nrequire_cmd launchctl\nrequire_cmd curl\n\n[ -d \"$INSTALL_DIR\" ] || die \"Install directory not found: $INSTALL_DIR\"\n[ -f \"$PLIST_PATH\" ] || die \"Service plist not found: $PLIST_PATH\"\n\ncd \"$INSTALL_DIR\"\n[ -d .git ] || die \"No git repository found in: $INSTALL_DIR\"\n\nif [ -z \"$BRANCH\" ]; then\n  BRANCH=\"$(git rev-parse --abbrev-ref HEAD)\"\n  [ \"$BRANCH\" = \"HEAD\" ] && BRANCH=\"main\"\nfi\n\n# replaced systemctl show check\nif ! launchctl list | grep -q \"$SERVICE_NAME\" && [ ! -f \"$PLIST_PATH\" ]; then\n  die \"Service not found: $SERVICE_NAME\"\nfi\n\nold_commit=\"$(git rev-parse --short HEAD || true)\"\n\nlog \"Fetching latest code from origin/$BRANCH\"\ngit fetch --prune origin\n\nremote_commit=\"$(git rev-parse --short \"origin/$BRANCH\" || true)\"\nlog \"Current commit: ${old_commit:-unknown}\"\nlog \"Remote commit: ${remote_commit:-unknown}\"\n\nif git diff --quiet HEAD \"origin/$BRANCH\"; then\n  log \"You are already running the latest version of Bambuddy.\"\n  read -r -p \"Do you want to run the update process anyway? [y/N]: \" run_anyway\n  case \"${run_anyway:-}\" in\n    y|Y|yes|YES) ;;\n    *) exit 0 ;;\n  esac\nelse\n  read -r -p \"An update for Bambuddy is available. Install now? [y/N]: \" install_now\n  case \"${install_now:-}\" in\n    y|Y|yes|YES) ;;\n    *) exit 0 ;;\n  esac\nfi\n\nif [ -n \"$(git status --porcelain)\" ]; then\n  if [ \"$FORCE\" != \"1\" ]; then\n    read -r -p \"Local edits were detected in your installation. Updating now will overwrite those edits. Continue? [y/N]: \" answer\n    case \"${answer:-}\" in\n      y|Y|yes|YES) ;;\n      *) die \"Update cancelled by user.\" ;;\n    esac\n  else\n    warn \"Proceeding without prompt because FORCE=1.\"\n  fi\nfi\n\ncreate_backup\n\nlog \"Stopping service: $SERVICE_NAME\"\nlaunchctl unload \"$PLIST_PATH\"\nSERVICE_STOPPED=1\n\nlog \"Updating code to origin/$BRANCH\"\ngit reset --hard \"origin/$BRANCH\"\nCODE_UPDATED=1\n\nif [ -x \"$VENV_PIP\" ] && [ -f requirements.txt ]; then\n  log \"Updating Python dependencies\"\n  \"$VENV_PIP\" install -r requirements.txt\nelse\n  warn \"Skipping Python dependency update (venv pip or requirements.txt missing).\"\nfi\n\nif [ -f \"$FRONTEND_DIR/package.json\" ]; then\n  if command -v npm >/dev/null 2>&1; then\n    log \"Building frontend\"\n    (\n      cd \"$FRONTEND_DIR\"\n      npm ci\n      npm run build\n    )\n  else\n    warn \"Skipping frontend build (npm not installed).\"\n  fi\nelse\n  warn \"Skipping frontend build (frontend/package.json not found).\"\nfi\n\nlog \"Starting service: $SERVICE_NAME\"\nlaunchctl load \"$PLIST_PATH\"\nSERVICE_STOPPED=0\nlaunchctl list | grep \"$SERVICE_NAME\" || true\n\nnew_commit=\"$(git rev-parse --short HEAD || true)\"\nlog \"Update complete: ${old_commit:-unknown} -> ${new_commit:-unknown}\"\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"bambuddy\"\nversion = \"0.1.5\"\ndescription = \"Archive and manage Bambu Lab 3MF files\"\nrequires-python = \">=3.10\"\n\n[tool.ruff]\ntarget-version = \"py310\"\nline-length = 120\nexclude = [\n    \".git\",\n    \".venv\",\n    \"venv\",\n    \"__pycache__\",\n    \"static\",\n    \"frontend\",\n    \"*.pyc\",\n]\n\n[tool.ruff.lint]\nselect = [\n    \"E\",      # pycodestyle errors\n    \"W\",      # pycodestyle warnings\n    \"F\",      # Pyflakes\n    \"I\",      # isort\n    \"B\",      # flake8-bugbear\n    \"C4\",     # flake8-comprehensions\n    \"UP\",     # pyupgrade\n    \"ARG\",    # flake8-unused-arguments\n    \"SIM\",    # flake8-simplify\n]\nignore = [\n    \"E501\",   # line too long (handled by formatter)\n    \"B008\",   # do not perform function calls in argument defaults (FastAPI Depends)\n    \"B904\",   # raise from (too noisy)\n    \"ARG001\", # unused function argument (common in FastAPI)\n    \"ARG002\", # unused method argument\n    \"SIM108\", # ternary operator (readability preference)\n    \"SIM102\", # nested if (readability preference)\n    \"SIM105\", # contextlib.suppress (readability preference)\n    \"UP017\",  # datetime.UTC alias (Python 3.11+ only, we support 3.10)\n]\n\n# Allow autofix for all enabled rules\nfixable = [\"ALL\"]\nunfixable = []\n\n[tool.ruff.lint.per-file-ignores]\n# Tests can have unused imports and assertions\n\"**/tests/**\" = [\"F401\", \"F811\", \"ARG\"]\n# Init files often have unused imports for re-export\n\"**/__init__.py\" = [\"F401\"]\n# main.py needs early logging setup before other imports\n\"backend/app/main.py\" = [\"E402\"]\n# MQTT client has some unused variables for debugging\n\"backend/app/services/bambu_mqtt.py\" = [\"F841\"]\n# compat module intentionally uses version checks for Python 3.10 support\n\"backend/app/core/compat.py\" = [\"UP036\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"backend\"]\nforce-single-line = false\ncombine-as-imports = true\n\n[tool.ruff.format]\nquote-style = \"double\"\nindent-style = \"space\"\nskip-magic-trailing-comma = false\nline-ending = \"auto\"\n\n[tool.pytest.ini_options]\ntestpaths = [\"backend/tests\"]\npython_files = [\"test_*.py\"]\npython_functions = [\"test_*\"]\nasyncio_mode = \"auto\"\nfilterwarnings = [\n    \"ignore::DeprecationWarning\",\n]\nmarkers = [\n    \"docker: marks tests that run in Docker integration environment\",\n]\n\n[dependency-groups]\ndev = [\n    \"cryptography>=46.0.7\",\n    \"pyjwt>=2.12.1\",\n]\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "# Development and testing dependencies\npytest>=8.0.0\npytest-asyncio>=0.23.0\npytest-cov>=4.1.0\npytest-xdist>=3.5.0\npytest-timeout>=2.4.0\nhttpx>=0.27.0\nruff>=0.8.0\n\n# Required by pyftpdlib TLS_FTPHandler for mock FTP server tests\npyOpenSSL>=26.0.0\n\n# Security scanning\nbandit[sarif]>=1.7.0\npip-audit>=2.7.0\n"
  },
  {
    "path": "requirements.txt",
    "content": "# Web Framework\nfastapi>=0.109.0\nuvicorn[standard]>=0.27.0\n\n# Database\nsqlalchemy>=2.0.0\naiosqlite>=0.19.0\nasyncpg>=0.29.0\ngreenlet>=3.0.0\n\n# Pydantic\npydantic>=2.0.0\npydantic-settings>=2.0.0\n# Transitive of pydantic-settings, floor-pinned to patch CVE-2026-28684 (dotenv 1.2.1)\npython-dotenv>=1.2.2\n\n# Bambu Lab Printer Communication\npaho-mqtt>=2.0.0\naioftp>=0.22.0\n\n# Virtual Printer (emulates Bambu printer for slicer uploads)\npyftpdlib>=2.0.0\ncryptography>=46.0.7\n\n# SpoolBuddy remote SSH updates (pure-Python SSH client; avoids the\n# OpenSSH `ssh` binary which calls getpwuid() and fails in Docker when\n# the container UID isn't in /etc/passwd)\nasyncssh>=2.18.0\n\n# 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)\ndefusedxml>=0.7.0  # Safe XML parsing (prevents XXE attacks)\n\n# Excel Export\nopenpyxl>=3.1.0\n\n# Notifications\npywebpush>=2.0.0\n\n# Utilities\npython-multipart>=0.0.26\naiofiles>=23.0.0\n\n# QR Code generation\nqrcode[pil]>=7.4.0\n\n# STL Thumbnail Generation\ntrimesh>=4.0.0\nmatplotlib>=3.8.0\nfast-simplification>=0.1.0\n\n# System monitoring\npsutil>=6.0.0\n\n# Authentication\nPyJWT>=2.12.0\npasslib[bcrypt]>=1.7.4\nldap3>=2.9.0\npyotp>=2.9.0\n\n# HTTP client (used for OIDC token exchange)\nhttpx>=0.26.0\n\n# Plate Detection (optional - enables build plate empty detection)\nopencv-python-headless>=4.8.0\nnumpy>=1.24.0\n\n# Development\npytest>=9.0.3\npytest-asyncio>=0.23.0\nhttpx>=0.26.0\nruff>=0.2.0\n\npillow>=12.2.0\n"
  },
  {
    "path": "scripts/debug_preset.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Debug script to investigate Bambu Cloud preset API responses.\"\"\"\n\nimport asyncio\nimport json\nimport sys\nfrom pathlib import Path\n\n# Add backend to path\nsys.path.insert(0, str(Path(__file__).parent.parent))\n\nimport httpx\nfrom sqlalchemy import create_engine, text\n\nfrom backend.app.core.config import settings\n\n# Test preset IDs (from the warning logs)\nTEST_IDS = [\"GFG02\", \"GFL05\", \"GFA00\", \"GFA02\", \"GFA06\"]\n\n\ndef get_token_from_db() -> str | None:\n    \"\"\"Get the stored token from the database.\"\"\"\n    db_path = settings.base_dir / \"bambuddy.db\"\n    engine = create_engine(f\"sqlite:///{db_path}\")\n\n    with engine.connect() as conn:\n        result = conn.execute(text(\"SELECT value FROM settings WHERE key = 'bambu_cloud_token'\"))\n        row = result.fetchone()\n\n        if row and row[0]:\n            return row[0]\n    return None\n\n\nasync def test_preset(setting_id: str, token: str, base_url: str = \"https://api.bambulab.com\"):\n    \"\"\"Test fetching a single preset and show full response.\"\"\"\n    url = f\"{base_url}/v1/iot-service/api/slicer/setting/{setting_id}\"\n    headers = {\n        \"Authorization\": f\"Bearer {token}\",\n        \"Content-Type\": \"application/json\",\n    }\n\n    print(f\"\\n{'=' * 60}\")\n    print(f\"Testing preset: {setting_id}\")\n    print(f\"URL: {url}\")\n    print(f\"{'=' * 60}\")\n\n    async with httpx.AsyncClient() as client:\n        response = await client.get(url, headers=headers)\n\n        print(f\"Status: {response.status_code}\")\n        print(\"\\nResponse body:\")\n        try:\n            data = response.json()\n            print(json.dumps(data, indent=2))\n        except Exception:\n            print(response.text)\n\n    return response.status_code\n\n\nasync def main():\n    # Get token from DB\n    token = get_token_from_db()\n\n    if not token:\n        print(\"Could not find token in database.\")\n        print(\"Make sure you're logged into Bambu Cloud in Bambuddy.\")\n        sys.exit(1)\n\n    print(f\"Found token in database (length: {len(token)})\")\n\n    # Allow testing specific preset IDs from command line\n    test_ids = sys.argv[1:] if len(sys.argv) > 1 else TEST_IDS\n\n    # Test each preset\n    for preset_id in test_ids:\n        await test_preset(preset_id, token)\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "scripts/import_spoolman.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Import spools from Spoolman into Bambuddy inventory.\n\nUsage:\n    python scripts/import_spoolman.py --spoolman-url http://localhost:7912 --bambuddy-url http://localhost:8000\n    python scripts/import_spoolman.py --spoolman-url http://localhost:7912 --bambuddy-url http://localhost:8000 --api-key YOUR_KEY\n    python scripts/import_spoolman.py --spoolman-url http://localhost:7912 --bambuddy-url http://localhost:8000 --dry-run\n\"\"\"\n\nimport argparse\nimport sys\n\nimport requests\n\n\ndef fetch_spoolman_spools(spoolman_url: str) -> list[dict]:\n    \"\"\"Fetch all spools from Spoolman API.\"\"\"\n    url = f\"{spoolman_url.rstrip('/')}/api/v1/spool\"\n    resp = requests.get(url, timeout=30)\n    resp.raise_for_status()\n    return resp.json()\n\n\ndef map_spool(sm_spool: dict) -> dict:\n    \"\"\"Map a Spoolman spool to a Bambuddy SpoolCreate payload.\"\"\"\n    filament = sm_spool.get(\"filament\") or {}\n    vendor = filament.get(\"vendor\") or {}\n\n    material = filament.get(\"material\") or \"PLA\"\n    color_hex = filament.get(\"color_hex\") or \"\"\n    # Spoolman color_hex is 6-char (#RRGGBB or RRGGBB), Bambuddy rgba is 8-char RRGGBBAA\n    rgba = None\n    if color_hex:\n        color_hex = color_hex.lstrip(\"#\")\n        if len(color_hex) == 6:\n            rgba = f\"{color_hex}FF\"\n        elif len(color_hex) == 8:\n            rgba = color_hex\n\n    label_weight = int(filament.get(\"weight\") or 1000)\n    used_weight = float(sm_spool.get(\"used_weight\") or 0)\n\n    # Filament name from Spoolman (e.g. \"eSun PLA+ Black\")\n    filament_name = filament.get(\"name\") or \"\"\n    # Vendor name (e.g. \"eSun\", \"Bambu Lab\")\n    brand = vendor.get(\"name\")\n\n    # Color name - prefer filament color name if present\n    color_name = filament.get(\"color_hex_name\") or None\n\n    # Cost: Spoolman stores price per spool, we need cost per kg\n    cost_per_kg = None\n    spool_price = sm_spool.get(\"price\") or filament.get(\"price\")\n    if spool_price and label_weight > 0:\n        cost_per_kg = round(float(spool_price) / (label_weight / 1000), 2)\n\n    # Temperature range from filament settings\n    nozzle_temp_min = filament.get(\"settings\", {}).get(\"nozzle_temperature_min\") if filament.get(\"settings\") else None\n    nozzle_temp_max = filament.get(\"settings\", {}).get(\"nozzle_temperature_max\") if filament.get(\"settings\") else None\n\n    # Extra fields\n    extra = sm_spool.get(\"extra\") or {}\n    tag_uid = extra.get(\"tag\") or None\n\n    # Build note with Spoolman reference\n    note_parts = []\n    if sm_spool.get(\"comment\"):\n        note_parts.append(sm_spool[\"comment\"])\n    if sm_spool.get(\"lot_nr\"):\n        note_parts.append(f\"Lot: {sm_spool['lot_nr']}\")\n    note_parts.append(f\"Imported from Spoolman (ID: {sm_spool['id']})\")\n    note = \" | \".join(note_parts)\n\n    payload = {\n        \"material\": material,\n        \"color_name\": color_name,\n        \"rgba\": rgba.upper() if rgba else None,\n        \"brand\": brand,\n        \"label_weight\": label_weight,\n        \"weight_used\": used_weight,\n        \"note\": note,\n        \"cost_per_kg\": cost_per_kg,\n        \"tag_uid\": tag_uid,\n        \"data_origin\": \"spoolman\",\n    }\n\n    if filament_name:\n        payload[\"subtype\"] = filament_name\n\n    if nozzle_temp_min is not None:\n        payload[\"nozzle_temp_min\"] = int(nozzle_temp_min)\n    if nozzle_temp_max is not None:\n        payload[\"nozzle_temp_max\"] = int(nozzle_temp_max)\n\n    return payload\n\n\ndef create_bambuddy_spool(bambuddy_url: str, spool_data: dict, api_key: str | None = None) -> dict:\n    \"\"\"Create a spool in Bambuddy inventory.\"\"\"\n    url = f\"{bambuddy_url.rstrip('/')}/api/v1/inventory/spools\"\n    headers = {}\n    if api_key:\n        headers[\"X-API-Key\"] = api_key\n    resp = requests.post(url, json=spool_data, headers=headers, timeout=30)\n    resp.raise_for_status()\n    return resp.json()\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\"Import spools from Spoolman into Bambuddy inventory\")\n    parser.add_argument(\"--spoolman-url\", required=True, help=\"Spoolman URL (e.g. http://localhost:7912)\")\n    parser.add_argument(\"--bambuddy-url\", required=True, help=\"Bambuddy URL (e.g. http://localhost:8000)\")\n    parser.add_argument(\"--api-key\", help=\"Bambuddy API key (required if auth is enabled)\")\n    parser.add_argument(\"--dry-run\", action=\"store_true\", help=\"Print mapped spools without importing\")\n    parser.add_argument(\"--archived\", action=\"store_true\", help=\"Include archived Spoolman spools\")\n    args = parser.parse_args()\n\n    print(f\"Fetching spools from {args.spoolman_url}...\")\n    try:\n        sm_spools = fetch_spoolman_spools(args.spoolman_url)\n    except requests.RequestException as e:\n        print(f\"Error fetching from Spoolman: {e}\", file=sys.stderr)\n        sys.exit(1)\n\n    if not args.archived:\n        sm_spools = [s for s in sm_spools if not s.get(\"archived\")]\n\n    print(f\"Found {len(sm_spools)} spools in Spoolman\")\n\n    if not sm_spools:\n        print(\"Nothing to import.\")\n        return\n\n    created = 0\n    failed = 0\n\n    for sm_spool in sm_spools:\n        filament = sm_spool.get(\"filament\") or {}\n        vendor = (filament.get(\"vendor\") or {}).get(\"name\", \"?\")\n        name = filament.get(\"name\") or filament.get(\"material\") or \"Unknown\"\n        label = f\"#{sm_spool['id']} {vendor} {name}\"\n\n        payload = map_spool(sm_spool)\n\n        if args.dry_run:\n            print(f\"  [DRY RUN] {label}\")\n            for k, v in payload.items():\n                if v is not None:\n                    print(f\"    {k}: {v}\")\n            print()\n            continue\n\n        try:\n            result = create_bambuddy_spool(args.bambuddy_url, payload, args.api_key)\n            print(f\"  Imported {label} -> Bambuddy spool #{result['id']}\")\n            created += 1\n        except requests.RequestException as e:\n            print(f\"  FAILED {label}: {e}\", file=sys.stderr)\n            failed += 1\n\n    if not args.dry_run:\n        print(f\"\\nDone: {created} imported, {failed} failed\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/mqtt_sniffer.py",
    "content": "#!/usr/bin/env python3\n\"\"\"MQTT Sniffer for Bambu Lab printers.\n\nConnects to a printer and logs all MQTT messages to capture the exact\ncommand format used by OrcaSlicer or Bambu Studio.\n\nUsage:\n    python mqtt_sniffer.py <printer_ip> <serial_number> <access_code>\n\nExample:\n    python mqtt_sniffer.py 192.168.1.100 0948BB540200427 12345678\n\"\"\"\n\nimport json\nimport ssl\nimport sys\nfrom datetime import datetime\n\nimport paho.mqtt.client as mqtt\n\n\ndef on_connect(client, userdata, flags, rc):\n    \"\"\"Called when connected to the MQTT broker.\"\"\"\n    if rc == 0:\n        print(f\"[{datetime.now().strftime('%H:%M:%S')}] Connected to printer!\")\n        serial = userdata[\"serial\"]\n        # Subscribe to all topics for this printer\n        topic_report = f\"device/{serial}/report\"\n        client.subscribe(topic_report)\n        print(f\"[{datetime.now().strftime('%H:%M:%S')}] Subscribed to: {topic_report}\")\n        print(\"-\" * 80)\n        print(\"Listening for MQTT messages... Press Ctrl+C to stop.\")\n        print(\"Now use OrcaSlicer to add a K-profile and the command will be logged here.\")\n        print(\"-\" * 80)\n    else:\n        print(f\"[{datetime.now().strftime('%H:%M:%S')}] Connection failed with code: {rc}\")\n\n\ndef on_message(client, userdata, msg):\n    \"\"\"Called when a message is received.\"\"\"\n    try:\n        payload = json.loads(msg.payload.decode(\"utf-8\"))\n\n        # Check if this is an extrusion_cali related message\n        is_cali_msg = False\n        command = None\n\n        if \"print\" in payload:\n            command = payload[\"print\"].get(\"command\", \"\")\n            if \"extrusion_cali\" in command:\n                is_cali_msg = True\n\n        # Always log calibration messages with full detail\n        if is_cali_msg:\n            print(f\"\\n{'=' * 80}\")\n            print(f\"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***\")\n            print(f\"Topic: {msg.topic}\")\n            print(\"Full payload:\")\n            print(json.dumps(payload, indent=2))\n            print(f\"{'=' * 80}\\n\")\n        else:\n            # For other messages, just show a brief summary\n            if \"print\" in payload:\n                cmd = payload[\"print\"].get(\"command\", \"unknown\")\n                # Skip noisy status messages\n                if cmd not in [\"push_status\"]:\n                    print(f\"[{datetime.now().strftime('%H:%M:%S')}] Command: {cmd}\")\n\n    except json.JSONDecodeError:\n        print(f\"[{datetime.now().strftime('%H:%M:%S')}] Non-JSON message on {msg.topic}\")\n    except Exception as e:\n        print(f\"[{datetime.now().strftime('%H:%M:%S')}] Error processing message: {e}\")\n\n\ndef on_disconnect(client, userdata, rc):\n    \"\"\"Called when disconnected from the MQTT broker.\"\"\"\n    print(f\"[{datetime.now().strftime('%H:%M:%S')}] Disconnected with code: {rc}\")\n\n\ndef main():\n    if len(sys.argv) != 4:\n        print(\"Usage: python mqtt_sniffer.py <printer_ip> <serial_number> <access_code>\")\n        print(\"\\nExample:\")\n        print(\"  python mqtt_sniffer.py 192.168.1.100 0948BB540200427 12345678\")\n        sys.exit(1)\n\n    printer_ip = sys.argv[1]\n    serial_number = sys.argv[2]\n    access_code = sys.argv[3]\n\n    print(f\"Connecting to printer at {printer_ip}...\")\n    print(f\"Serial: {serial_number}\")\n\n    # Create MQTT client\n    client = mqtt.Client(userdata={\"serial\": serial_number})\n    client.username_pw_set(\"bblp\", access_code)\n\n    # Configure TLS\n    ssl_context = ssl.create_default_context()\n    ssl_context.check_hostname = False\n    ssl_context.verify_mode = ssl.CERT_NONE\n    client.tls_set_context(ssl_context)\n\n    # Set callbacks\n    client.on_connect = on_connect\n    client.on_message = on_message\n    client.on_disconnect = on_disconnect\n\n    try:\n        client.connect(printer_ip, 8883, 60)\n        client.loop_forever()\n    except KeyboardInterrupt:\n        print(\"\\n\\nStopping sniffer...\")\n        client.disconnect()\n    except Exception as e:\n        print(f\"Error: {e}\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/update_archive_date.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Update the created_at date for a specific archive.\"\"\"\n\nimport argparse\nimport sys\nfrom datetime import datetime\nfrom pathlib import Path\n\n# Add backend to path\nsys.path.insert(0, str(Path(__file__).parent.parent))\n\nfrom sqlalchemy import create_engine, text\n\nfrom backend.app.core.config import settings\n\n\ndef update_archive_date(archive_id: int, new_date: datetime) -> bool:\n    \"\"\"Update created_at for an archive.\"\"\"\n    db_path = settings.base_dir / \"bambuddy.db\"\n    engine = create_engine(f\"sqlite:///{db_path}\")\n\n    with engine.connect() as conn:\n        # Check if archive exists\n        result = conn.execute(\n            text(\"SELECT id, filename, created_at FROM print_archives WHERE id = :id\"),\n            {\"id\": archive_id},\n        )\n        row = result.fetchone()\n\n        if not row:\n            print(f\"Archive ID {archive_id} not found!\")\n            return False\n\n        print(f\"Archive: {row[1]}\")\n        print(f\"Current date: {row[2]}\")\n        print(f\"New date: {new_date}\")\n\n        # Update\n        conn.execute(\n            text(\"UPDATE print_archives SET created_at = :date WHERE id = :id\"),\n            {\"id\": archive_id, \"date\": new_date},\n        )\n        conn.commit()\n        print(\"✓ Updated successfully!\")\n        return True\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\"Update archive created_at date\")\n    parser.add_argument(\"archive_id\", type=int, help=\"Archive ID to update\")\n    parser.add_argument(\n        \"date\",\n        type=str,\n        help=\"New date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)\",\n    )\n\n    args = parser.parse_args()\n\n    # Parse date\n    try:\n        if \" \" in args.date:\n            new_date = datetime.strptime(args.date, \"%Y-%m-%d %H:%M:%S\")\n        else:\n            new_date = datetime.strptime(args.date, \"%Y-%m-%d\")\n    except ValueError:\n        print(\"Invalid date format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM:SS\")\n        sys.exit(1)\n\n    update_archive_date(args.archive_id, new_date)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/update_archive_quantities.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Update archive quantities from 3MF files.\n\nThis script updates the quantity field on existing archives by parsing\ntheir 3MF files to count the number of printable objects.\n\nRun this once after upgrading to add proper parts tracking to your projects.\n\nUsage:\n    # From the bambuddy directory:\n    python scripts/update_archive_quantities.py\n\n    # Or with docker:\n    docker exec -it bambuddy python scripts/update_archive_quantities.py\n\n    # Dry run (show what would be updated without changing anything):\n    python scripts/update_archive_quantities.py --dry-run\n\"\"\"\n\nimport argparse\nimport asyncio\nimport sys\nimport zipfile\nfrom pathlib import Path\nfrom xml.etree import ElementTree as ET\n\n# Add parent directory to path for imports\nsys.path.insert(0, str(Path(__file__).parent.parent))\n\nfrom sqlalchemy import select\n\nfrom backend.app.core.config import settings\nfrom backend.app.core.database import async_session\nfrom backend.app.models.archive import PrintArchive\n\n\ndef extract_object_count_from_3mf(file_path: Path) -> int | None:\n    \"\"\"Extract the number of printable objects from a 3MF file.\n\n    Returns the count of non-skipped objects, or None if parsing fails.\n    \"\"\"\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zf:\n            if \"Metadata/slice_info.config\" not in zf.namelist():\n                return None\n\n            content = zf.read(\"Metadata/slice_info.config\").decode()\n            root = ET.fromstring(content)\n\n            # Find the plate (use first plate)\n            plate = root.find(\".//plate\")\n            if plate is None:\n                return None\n\n            # Count non-skipped objects\n            count = 0\n            for obj in plate.findall(\"object\"):\n                skipped = obj.get(\"skipped\", \"false\")\n                if skipped.lower() != \"true\":\n                    count += 1\n\n            return count if count > 0 else None\n\n    except Exception as e:\n        print(f\"  Error parsing {file_path.name}: {e}\")\n        return None\n\n\nasync def update_archive_quantities(dry_run: bool = False):\n    \"\"\"Update quantity field on archives based on 3MF object count.\"\"\"\n\n    print(\"=\" * 60)\n    print(\"Archive Quantity Updater\")\n    print(\"=\" * 60)\n    print()\n\n    if dry_run:\n        print(\"DRY RUN MODE - No changes will be made\")\n        print()\n\n    async with async_session() as db:\n        # Get all archives with quantity=1 (the default)\n        result = await db.execute(select(PrintArchive).where(PrintArchive.quantity == 1))\n        archives = result.scalars().all()\n\n        print(f\"Found {len(archives)} archives with quantity=1\")\n        print()\n\n        updated = 0\n        skipped = 0\n        errors = 0\n\n        for archive in archives:\n            # Skip if no file path\n            if not archive.file_path:\n                skipped += 1\n                continue\n\n            file_path = settings.base_dir / archive.file_path\n\n            # Skip if file doesn't exist\n            if not file_path.exists():\n                print(f\"  [{archive.id}] File not found: {archive.file_path}\")\n                skipped += 1\n                continue\n\n            # Extract object count\n            object_count = extract_object_count_from_3mf(file_path)\n\n            if object_count is None:\n                skipped += 1\n                continue\n\n            if object_count == 1:\n                # No change needed\n                skipped += 1\n                continue\n\n            # Update the archive\n            print(f\"  [{archive.id}] {archive.print_name}: 1 -> {object_count} parts\")\n\n            if not dry_run:\n                archive.quantity = object_count\n                updated += 1\n            else:\n                updated += 1\n\n        if not dry_run:\n            await db.commit()\n\n        print()\n        print(\"-\" * 60)\n        print(f\"Updated: {updated}\")\n        print(f\"Skipped: {skipped} (no change needed or file not found)\")\n        print(f\"Errors:  {errors}\")\n        print()\n\n        if dry_run and updated > 0:\n            print(\"Run without --dry-run to apply these changes.\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\"Update archive quantities from 3MF files\")\n    parser.add_argument(\n        \"--dry-run\",\n        action=\"store_true\",\n        help=\"Show what would be updated without making changes\",\n    )\n    args = parser.parse_args()\n\n    asyncio.run(update_archive_quantities(dry_run=args.dry_run))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "spoolbuddy/README.md",
    "content": "# SpoolBuddy Hardware Setup\n\n## PN5180 NFC Reader (SPI)\n\n### Wiring\n\n| PN5180 Pin | Raspberry Pi Pin | GPIO | Wire Color |\n|------------|------------------|------|------------|\n| 3V3        | Pin 1            | —    | Red        |\n| 5V         | Pin 2            | —    | Red        |\n| GND        | Pin 20           | —    | Black      |\n| SCK        | Pin 23           | GPIO11 | Yellow   |\n| MISO       | Pin 21           | GPIO9  | Blue     |\n| MOSI       | Pin 19           | GPIO10 | Green    |\n| NSS (CS)   | Pin 16           | GPIO23 | Orange   |\n| BUSY       | Pin 22           | GPIO25 | White    |\n| RST        | Pin 18           | GPIO24 | Brown    |\n\n> **Power:** The PN5180 board has two power pins. 3V3 powers the IC itself,\n> 5V powers the antenna booster and extends read range. Both should be connected.\n> Do NOT connect 5V to the 3V3 pin — it will destroy the reader.\n\n> **NSS:** We use GPIO23 for manual chip-select instead of the default SPI CE0\n> (GPIO8) because the kernel SPI driver's automatic CS timing does not meet the\n> PN5180's requirements (5µs setup, 100µs hold). Manual CS via GPIO23 with\n> `spidev.no_cs = True` resolves this.\n\n### Setup Steps\n\n#### 1. Enable SPI and I2C\n\nAfter a fresh Raspberry Pi OS install, SPI and I2C are disabled by default.\n\n```bash\nsudo raspi-config\n# Navigate to: Interface Options -> SPI -> Enable\n# Navigate to: Interface Options -> I2C -> Enable\nsudo reboot\n```\n\nVerify after reboot:\n\n```bash\nls /dev/spidev0.*\n# Should show: /dev/spidev0.0  /dev/spidev0.1\n\nls /dev/i2c-*\n# Should include: /dev/i2c-1\n```\n\n#### 2. Configure `/boot/firmware/config.txt`\n\nAdd the following lines under the `[all]` section:\n\n```\n# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3)\ndtparam=i2c_arm=on\n\n# SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180)\ndtoverlay=spi0-0cs\n```\n\n- `i2c_arm=on` enables I2C bus 1 (GPIO2/GPIO3). The NAU7802 is wired to bus 1.\n  manual CS on GPIO23 because the driver's CS timing doesn't meet the PN5180's\n\nThen reboot:\n\n```bash\nsudo reboot\n```\n\nVerify after reboot:\n\n```bash\nls /dev/i2c-1\n# Should exist\n\nsudo i2cdetect -y 1\n# Should show 0x2A (NAU7802)\n```\n\n#### 3. Install system packages\n\n```bash\nsudo apt install python3-spidev python3-libgpiod gpiod libgpiod3 i2c-tools\n```\n\n- `python3-spidev` / `libgpiod3` — system libraries for SPI and GPIO access\n- `gpiod` — command-line GPIO tools (useful for debugging)\n\n```bash\npip install spidev gpiod smbus2\n```\n\n- `spidev` — Python SPI bindings (PN5180 NFC reader)\n- `gpiod` — Python GPIO bindings via libgpiod (works on both RPi 4 and RPi 5)\n\nWago connectors or breadboard jumpers are unreliable for SPI — the PN5180\nis very sensitive to signal integrity issues (loose connections cause RF\nfield flickering, phantom errors, and intermittent communication failures).\n**Solder all wires directly** for reliable operation.\n\n#### 6. Verify hardware communication\n\nRun the diagnostic script to confirm the PN5180 is responding:\n\n```bash\nsudo python3 spoolbuddy/pn5180_diag.py\n```\n\nExpected output includes product version (e.g. `v4.0`), firmware version,\nregister dump, and \"Diagnostics complete\" at the end.\n\n#### 7. Test tag reading\n\n```bash\nsudo python3 spoolbuddy/read_tag.py\n```\n\nPlace a tag on the reader. Supported tag types:\n\n| Tag Type            | SAK    | Use Case                     |\n|---------------------|--------|------------------------------|\n| MIFARE Classic 1K   | `0x08` | Bambu Lab filament tags      |\n| MIFARE Classic 4K   | `0x18` | Bambu Lab filament tags      |\n| NTAG (213/215/216)  | `0x00` / `0x04` | SpoolEase / OpenPrintTag     |\n\n### Troubleshooting\n\n| Symptom | Cause | Fix |\n|---------|-------|-----|\n| All zeros from SPI reads | SPI not enabled | Run `raspi-config` and enable SPI, then reboot |\n| `GENERAL_ERROR` on SEND_DATA | Automatic CS timing too fast | Use manual CS on GPIO23 with `spi0-0cs` overlay |\n| `BUSY timeout` | Wiring issue or RST not connected | Check RST and BUSY pin connections |\n| RF field flickering on/off | Loose power wires | Solder all connections |\n| `No tag found` but tag is present | Wrong protocol or missing `setTransceiveMode()` | Ensure ISO 14443A config (`0x00, 0x80`) and `setTransceiveMode()` before every `SEND_DATA` |\n| Auth failed for block N | Wrong key derivation | Verify HKDF uses context `\"RFID-A\\0\"` (7 bytes including null terminator) |\n| `EBUSY` when requesting GPIO8 | Kernel SPI driver owns CE0 | Use GPIO23 for NSS instead |\n\n### Technical Notes\n\n- SPI speed: **500 kHz** (higher speeds cause communication errors)\n- SPI mode: **0** (CPOL=0, CPHA=0)\n\n\n### Wiring\n\n| NAU7802 Pin | Raspberry Pi Pin | GPIO   | Wire Color |\n|-------------|------------------|--------|------------|\n| VCC         | Pin 1            | —      | Red        |\n| SDA         | Pin 3            | GPIO 2 | Yellow     |\n| SCL         | Pin 5            | GPIO 3 | White      |\n| GND         | Pin 30           | —      | Black      |\n\n> **I2C Bus:** Uses I2C bus 1 (GPIO2/GPIO3), enabled via `dtparam=i2c_arm=on`\n> in config.txt.\n\n### Verify\n\n```bash\nsudo i2cdetect -y 1\n# Should show 0x2A\n\nsudo python3 spoolbuddy/scale_diag.py\n```\n\nThe diagnostic reads 10 samples at 10 SPS and shows raw ADC values, average,\nand spread. Typical idle readings are around ~500k with a spread under 20k.\n"
  },
  {
    "path": "spoolbuddy/daemon/__init__.py",
    "content": "import re\nfrom pathlib import Path\n\n\ndef _read_app_version() -> str:\n    \"\"\"Read APP_VERSION from backend config — single source of truth.\"\"\"\n    config_path = Path(__file__).resolve().parent.parent.parent / \"backend\" / \"app\" / \"core\" / \"config.py\"\n    try:\n        text = config_path.read_text()\n        match = re.search(r'^APP_VERSION\\s*=\\s*[\"\\'](.+?)[\"\\']', text, re.MULTILINE)\n        if match:\n            return match.group(1)\n    except OSError:\n        pass\n    return \"0.0.0\"\n\n\n__version__ = _read_app_version()\n"
  },
  {
    "path": "spoolbuddy/daemon/api_client.py",
    "content": "\"\"\"HTTP client for communicating with Bambuddy backend.\"\"\"\n\nimport asyncio\nimport logging\nfrom collections import deque\n\nimport httpx\n\nlogger = logging.getLogger(__name__)\n\nMAX_BUFFER_SIZE = 100\n\n\nclass APIClient:\n    def __init__(self, backend_url: str, api_key: str):\n        self._base = backend_url.rstrip(\"/\") + \"/api/v1/spoolbuddy\"\n        self._headers = {\"X-API-Key\": api_key} if api_key else {}\n        self._client = httpx.AsyncClient(timeout=10.0, headers=self._headers)\n        self._backoff = 1.0\n        self._max_backoff = 30.0\n        self._buffer: deque[dict] = deque(maxlen=MAX_BUFFER_SIZE)\n        self._connected = False\n\n    async def close(self):\n        await self._client.aclose()\n\n    async def _post(self, path: str, data: dict) -> dict | None:\n        try:\n            resp = await self._client.post(f\"{self._base}{path}\", json=data)\n            resp.raise_for_status()\n            self._backoff = 1.0\n            self._connected = True\n            return resp.json()\n        except Exception as e:\n            if self._connected:\n                logger.warning(\"Backend connection lost: %s\", e)\n                self._connected = False\n            self._buffer.append({\"path\": path, \"data\": data})\n            return None\n\n    async def _get(self, path: str) -> dict | None:\n        try:\n            resp = await self._client.get(f\"{self._base}{path}\")\n            resp.raise_for_status()\n            return resp.json()\n        except Exception as e:\n            logger.warning(\"GET %s failed: %s\", path, e)\n            return None\n\n    async def _flush_buffer(self):\n        while self._buffer:\n            item = self._buffer[0]\n            try:\n                resp = await self._client.post(f\"{self._base}{item['path']}\", json=item[\"data\"])\n                resp.raise_for_status()\n                self._buffer.popleft()\n            except Exception:\n                break\n\n    async def register_device(\n        self,\n        device_id: str,\n        hostname: str,\n        ip_address: str,\n        firmware_version: str | None = None,\n        has_nfc: bool = True,\n        has_scale: bool = True,\n        tare_offset: int = 0,\n        calibration_factor: float = 1.0,\n        nfc_reader_type: str | None = None,\n        nfc_connection: str | None = None,\n        backend_url: str | None = None,\n        has_backlight: bool = False,\n    ) -> dict | None:\n        while True:\n            result = await self._post(\n                \"/devices/register\",\n                {\n                    \"device_id\": device_id,\n                    \"hostname\": hostname,\n                    \"ip_address\": ip_address,\n                    \"firmware_version\": firmware_version,\n                    \"has_nfc\": has_nfc,\n                    \"has_scale\": has_scale,\n                    \"tare_offset\": tare_offset,\n                    \"calibration_factor\": calibration_factor,\n                    \"nfc_reader_type\": nfc_reader_type,\n                    \"nfc_connection\": nfc_connection,\n                    \"backend_url\": backend_url,\n                    \"has_backlight\": has_backlight,\n                },\n            )\n            if result is not None:\n                logger.info(\"Registered with backend as %s\", device_id)\n                return result\n            logger.warning(\"Registration failed, retrying in %.0fs...\", self._backoff)\n            await asyncio.sleep(self._backoff)\n            self._backoff = min(self._backoff * 2, self._max_backoff)\n\n    async def heartbeat(\n        self,\n        device_id: str,\n        nfc_ok: bool,\n        scale_ok: bool,\n        uptime_s: int,\n        ip_address: str | None = None,\n        firmware_version: str | None = None,\n        nfc_reader_type: str | None = None,\n        nfc_connection: str | None = None,\n        backend_url: str | None = None,\n        system_stats: dict | None = None,\n    ) -> dict | None:\n        payload: dict = {\n            \"nfc_ok\": nfc_ok,\n            \"scale_ok\": scale_ok,\n            \"uptime_s\": uptime_s,\n            \"ip_address\": ip_address,\n            \"firmware_version\": firmware_version,\n            \"nfc_reader_type\": nfc_reader_type,\n            \"nfc_connection\": nfc_connection,\n            \"backend_url\": backend_url,\n        }\n        if system_stats is not None:\n            payload[\"system_stats\"] = system_stats\n        result = await self._post(\n            f\"/devices/{device_id}/heartbeat\",\n            payload,\n        )\n        if result and self._buffer:\n            await self._flush_buffer()\n        return result\n\n    async def tag_scanned(\n        self,\n        device_id: str,\n        tag_uid: str,\n        tray_uuid: str | None = None,\n        sak: int | None = None,\n        tag_type: str | None = None,\n    ) -> dict | None:\n        return await self._post(\n            \"/nfc/tag-scanned\",\n            {\n                \"device_id\": device_id,\n                \"tag_uid\": tag_uid,\n                \"tray_uuid\": tray_uuid,\n                \"sak\": sak,\n                \"tag_type\": tag_type,\n            },\n        )\n\n    async def tag_removed(self, device_id: str, tag_uid: str) -> dict | None:\n        return await self._post(\n            \"/nfc/tag-removed\",\n            {\n                \"device_id\": device_id,\n                \"tag_uid\": tag_uid,\n            },\n        )\n\n    async def update_tare(self, device_id: str, tare_offset: int) -> dict | None:\n        return await self._post(\n            f\"/devices/{device_id}/calibration/set-tare\",\n            {\"tare_offset\": tare_offset},\n        )\n\n    async def scale_reading(\n        self, device_id: str, weight_grams: float, stable: bool, raw_adc: int | None = None\n    ) -> dict | None:\n        return await self._post(\n            \"/scale/reading\",\n            {\n                \"device_id\": device_id,\n                \"weight_grams\": weight_grams,\n                \"stable\": stable,\n                \"raw_adc\": raw_adc,\n            },\n        )\n\n    async def write_tag_result(\n        self, device_id: str, spool_id: int, tag_uid: str, success: bool, message: str | None = None\n    ) -> dict | None:\n        return await self._post(\n            \"/nfc/write-result\",\n            {\n                \"device_id\": device_id,\n                \"spool_id\": spool_id,\n                \"tag_uid\": tag_uid,\n                \"success\": success,\n                \"message\": message,\n            },\n        )\n\n    async def report_update_status(self, device_id: str, status: str, message: str = \"\") -> dict | None:\n        return await self._post(\n            f\"/devices/{device_id}/update-status\",\n            {\"status\": status, \"message\": message},\n        )\n\n    async def diagnostic_result(\n        self,\n        device_id: str,\n        diagnostic: str,\n        success: bool,\n        output: str,\n        exit_code: int,\n    ) -> dict | None:\n        return await self._post(\n            f\"/diagnostics/{device_id}/result\",\n            {\n                \"diagnostic\": diagnostic,\n                \"success\": success,\n                \"output\": output,\n                \"exit_code\": exit_code,\n            },\n        )\n\n    async def system_command_result(\n        self,\n        device_id: str,\n        command: str,\n        success: bool,\n        message: str | None = None,\n    ) -> dict | None:\n        return await self._post(\n            f\"/devices/{device_id}/system/command-result\",\n            {\n                \"command\": command,\n                \"success\": success,\n                \"message\": message,\n            },\n        )\n"
  },
  {
    "path": "spoolbuddy/daemon/config.py",
    "content": "\"\"\"Configuration loader for SpoolBuddy daemon.\n\nAll configuration is via environment variables. The systemd service file\nor a shell wrapper sets these before launching the daemon.\n\nRequired:\n    SPOOLBUDDY_BACKEND_URL  — Bambuddy server URL (e.g. http://192.168.1.100:5000)\n    SPOOLBUDDY_API_KEY      — API key created in Bambuddy Settings → API Keys\n\nOptional:\n    SPOOLBUDDY_DEVICE_ID    — Unique device identifier (default: derived from MAC)\n    SPOOLBUDDY_HOSTNAME     — Display name (default: system hostname)\n\"\"\"\n\nimport os\nimport socket\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\n\n@dataclass\nclass Config:\n    backend_url: str = \"\"\n    api_key: str = \"\"\n    device_id: str = \"\"\n    hostname: str = \"\"\n\n    nfc_poll_interval: float = 0.3\n    scale_read_interval: float = 0.1\n    scale_report_interval: float = 1.0\n    heartbeat_interval: float = 10.0\n    stability_threshold: float = 2.0\n    stability_window: float = 1.0\n\n    tare_offset: int = 0\n    calibration_factor: float = 1.0\n\n    @classmethod\n    def load(cls) -> \"Config\":\n        cfg = cls()\n\n        cfg.backend_url = os.environ.get(\"SPOOLBUDDY_BACKEND_URL\", \"\")\n        cfg.api_key = os.environ.get(\"SPOOLBUDDY_API_KEY\", \"\")\n        cfg.device_id = os.environ.get(\"SPOOLBUDDY_DEVICE_ID\", \"\")\n        cfg.hostname = os.environ.get(\"SPOOLBUDDY_HOSTNAME\", \"\")\n\n        if not cfg.backend_url:\n            raise RuntimeError(\"SPOOLBUDDY_BACKEND_URL is required (e.g. http://192.168.1.100:5000)\")\n        if not cfg.api_key:\n            raise RuntimeError(\"SPOOLBUDDY_API_KEY is required (create one in Bambuddy Settings → API Keys)\")\n\n        # Default device_id from MAC address\n        if not cfg.device_id:\n            cfg.device_id = _get_mac_id()\n\n        # Default hostname from system\n        if not cfg.hostname:\n            cfg.hostname = socket.gethostname()\n\n        return cfg\n\n\ndef _get_mac_id() -> str:\n    \"\"\"Generate a stable device ID from the primary network interface MAC address.\n\n    Interfaces are sorted by name so the same interface is always picked\n    regardless of filesystem iteration order (eth0 before wlan0, etc.).\n    \"\"\"\n    try:\n        ifaces = sorted(Path(\"/sys/class/net\").iterdir(), key=lambda p: p.name)\n        for iface in ifaces:\n            if iface.name == \"lo\":\n                continue\n            addr_file = iface / \"address\"\n            if addr_file.exists():\n                mac = addr_file.read_text().strip().replace(\":\", \"\")\n                if mac and mac != \"000000000000\":\n                    return f\"sb-{mac}\"\n    except Exception:\n        pass\n    import uuid\n\n    return f\"sb-{uuid.uuid4().hex[:12]}\"\n"
  },
  {
    "path": "spoolbuddy/daemon/display_control.py",
    "content": "\"\"\"Display brightness and screen blanking control for SpoolBuddy kiosk.\n\nBrightness: DSI backlights are controlled via sysfs /sys/class/backlight/*/brightness.\n            HDMI brightness is handled by the frontend via CSS filter.\nBlanking:   swayidle is the sole authority on screen blanking (idle timeout →\n            wlopm --off, touch → wlopm --on).  The daemon wakes the display by\n            writing to a FIFO that the idle watchdog monitors — the watchdog\n            runs inside the Wayland session and calls wlopm --on on behalf of\n            the daemon.\n\"\"\"\n\nimport logging\nimport os\nimport stat\nimport time\nfrom pathlib import Path\n\nlogger = logging.getLogger(__name__)\n\nBACKLIGHT_BASE = Path(\"/sys/class/backlight\")\nWAKE_FIFO = Path(\"/tmp/spoolbuddy-wake\")\n\n\nclass DisplayControl:\n    def __init__(self):\n        self._backlight_path = self._find_backlight()\n        self._max_brightness = self._read_max_brightness()\n        self._blank_timeout = 0  # seconds, 0 = disabled\n        self._last_activity = time.monotonic()\n        self._blanked = False\n\n        if self._backlight_path:\n            logger.info(\"Backlight found: %s (max=%d)\", self._backlight_path, self._max_brightness)\n        else:\n            logger.info(\"No DSI backlight found, brightness control via frontend CSS\")\n\n    def _find_backlight(self) -> Path | None:\n        if not BACKLIGHT_BASE.exists():\n            return None\n        for entry in BACKLIGHT_BASE.iterdir():\n            brightness_file = entry / \"brightness\"\n            if brightness_file.exists():\n                return entry\n        return None\n\n    def _read_max_brightness(self) -> int:\n        if not self._backlight_path:\n            return 100\n        try:\n            return int((self._backlight_path / \"max_brightness\").read_text().strip())\n        except Exception:\n            return 255\n\n    @property\n    def has_backlight(self) -> bool:\n        return self._backlight_path is not None\n\n    def set_brightness(self, pct: int):\n        \"\"\"Set backlight brightness (0-100%). No-op if no backlight.\"\"\"\n        if not self._backlight_path:\n            return\n        pct = max(0, min(100, pct))\n        value = round(self._max_brightness * pct / 100)\n        try:\n            (self._backlight_path / \"brightness\").write_text(str(value))\n            logger.debug(\"Brightness set to %d%% (%d/%d)\", pct, value, self._max_brightness)\n        except PermissionError:\n            logger.warning(\n                \"Permission denied writing to %s/brightness. Ensure spoolbuddy user is in the 'video' group.\",\n                self._backlight_path,\n            )\n        except Exception as e:\n            logger.warning(\"Failed to set brightness: %s\", e)\n\n    def set_blank_timeout(self, seconds: int):\n        \"\"\"Set screen blank timeout in seconds. 0 = disabled.\"\"\"\n        self._blank_timeout = max(0, seconds)\n\n    def wake(self):\n        \"\"\"Wake screen on activity (NFC tag, scale weight change).\n\n        Writes to /tmp/spoolbuddy-wake FIFO which the idle watchdog\n        (spoolbuddy-idle.sh) monitors inside the Wayland session.  The\n        watchdog calls wlopm --on on our behalf.  No-op if the FIFO\n        doesn't exist (kiosk not running or blanking disabled without FIFO).\n        \"\"\"\n        self._last_activity = time.monotonic()\n        self._blanked = False\n        self._signal_wake()\n\n    def tick(self):\n        \"\"\"Called periodically from heartbeat loop. Tracks idle state internally.\"\"\"\n        if self._blank_timeout <= 0:\n            self._blanked = False\n            return\n        idle = time.monotonic() - self._last_activity\n        if not self._blanked and idle >= self._blank_timeout:\n            self._blanked = True\n            logger.debug(\"Screen idle timeout reached (swayidle manages blanking)\")\n\n    def _signal_wake(self) -> None:\n        \"\"\"Write to the wake FIFO to request display power-on.\"\"\"\n        if not WAKE_FIFO.exists():\n            return\n        try:\n            # Verify it's actually a FIFO, not a regular file\n            if not stat.S_ISFIFO(WAKE_FIFO.stat().st_mode):\n                return\n            # Open non-blocking so we don't hang if no reader is attached\n            fd = os.open(str(WAKE_FIFO), os.O_WRONLY | os.O_NONBLOCK)\n            try:\n                os.write(fd, b\"wake\\n\")\n                logger.info(\"Wake signal sent via FIFO\")\n            finally:\n                os.close(fd)\n        except OSError as e:\n            # ENXIO = no reader on the FIFO (idle script not running) — expected\n            if e.errno != 6:  # ENXIO\n                logger.debug(\"Wake FIFO write failed: %s\", e)\n"
  },
  {
    "path": "spoolbuddy/daemon/main.py",
    "content": "#!/usr/bin/env python3\n\"\"\"SpoolBuddy daemon — reads NFC tags and scale, pushes events to Bambuddy backend.\"\"\"\n\nimport asyncio\nimport logging\nimport os\nimport socket\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nfrom . import __version__, system_stats\nfrom .api_client import APIClient\nfrom .config import Config\nfrom .display_control import DisplayControl\nfrom .nfc_reader import NFCReader, NFCState\nfrom .scale_reader import ScaleReader\n\nlogging.basicConfig(\n    level=logging.INFO,\n    format=\"%(asctime)s [%(name)s] %(levelname)s: %(message)s\",\n    datefmt=\"%H:%M:%S\",\n)\nlogger = logging.getLogger(\"spoolbuddy\")\nlogging.getLogger(\"daemon.pn5180\").setLevel(logging.DEBUG)\n\n\ndef _spoolbuddy_env_path() -> Path:\n    # installer writes this at <install>/spoolbuddy/.env; allow override for custom setups/tests\n    override = os.environ.get(\"SPOOLBUDDY_ENV_FILE\", \"\").strip()\n    if override:\n        return Path(override)\n    return Path(__file__).resolve().parent.parent / \".env\"\n\n\ndef _set_env_value(path: Path, key: str, value: str):\n    lines: list[str] = []\n    if path.exists():\n        lines = path.read_text(encoding=\"utf-8\").splitlines()\n\n    updated = False\n    new_lines: list[str] = []\n    for line in lines:\n        if line.startswith(f\"{key}=\"):\n            new_lines.append(f\"{key}={value}\")\n            updated = True\n        else:\n            new_lines.append(line)\n\n    if not updated:\n        new_lines.append(f\"{key}={value}\")\n\n    path.parent.mkdir(parents=True, exist_ok=True)\n    path.write_text(\"\\n\".join(new_lines) + \"\\n\", encoding=\"utf-8\")\n\n\ndef _get_ip() -> str:\n    try:\n        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n        s.connect((\"8.8.8.8\", 80))\n        ip = s.getsockname()[0]\n        s.close()\n        return ip\n    except Exception:\n        return \"unknown\"\n\n\ndef _deploy_ssh_key(public_key: str) -> None:\n    \"\"\"Write Bambuddy's SSH public key to authorized_keys if not already present.\"\"\"\n    home = Path.home()\n    ssh_dir = home / \".ssh\"\n    auth_keys = ssh_dir / \"authorized_keys\"\n\n    try:\n        ssh_dir.mkdir(mode=0o700, exist_ok=True)\n\n        # Check if key already deployed\n        if auth_keys.exists():\n            existing = auth_keys.read_text()\n            if public_key.strip() in existing:\n                return\n\n        # Append key\n        with auth_keys.open(\"a\") as f:\n            f.write(public_key.strip() + \"\\n\")\n        auth_keys.chmod(0o600)\n        logger.info(\"SSH public key deployed to %s\", auth_keys)\n    except Exception as e:\n        logger.warning(\"Failed to deploy SSH key: %s\", e)\n\n\nasync def nfc_poll_loop(config: Config, api: APIClient, shared: dict):\n    \"\"\"Continuous NFC polling loop — runs in asyncio with blocking reads offloaded.\"\"\"\n    display: DisplayControl = shared[\"display\"]\n\n    try:\n        while True:\n            if shared.get(\"nfc_scan_paused\", False):\n                await asyncio.sleep(config.nfc_poll_interval)\n                continue\n\n            nfc: NFCReader | None = shared.get(\"nfc\")\n            if not nfc or not nfc.ok:\n                await asyncio.sleep(config.nfc_poll_interval)\n                continue\n\n            event_type, event_data = await asyncio.to_thread(nfc.poll)\n\n            if event_type == \"tag_detected\":\n                display.wake()\n                await api.tag_scanned(\n                    device_id=config.device_id,\n                    tag_uid=event_data[\"tag_uid\"],\n                    tray_uuid=event_data.get(\"tray_uuid\"),\n                    sak=event_data.get(\"sak\"),\n                    tag_type=event_data.get(\"tag_type\"),\n                )\n            elif event_type == \"tag_removed\":\n                await api.tag_removed(\n                    device_id=config.device_id,\n                    tag_uid=event_data[\"tag_uid\"],\n                )\n\n            # Check for pending write command\n            pending = shared.get(\"pending_write\")\n            if pending and nfc.state == NFCState.TAG_PRESENT:\n                if nfc.current_sak in (0x00, 0x04):\n                    logger.info(\"Executing pending tag write for spool %d\", pending[\"spool_id\"])\n                    success, msg = await asyncio.to_thread(nfc.write_ntag, pending[\"ndef_data\"])\n                    await api.write_tag_result(\n                        device_id=config.device_id,\n                        spool_id=pending[\"spool_id\"],\n                        tag_uid=nfc.current_uid or \"\",\n                        success=success,\n                        message=msg,\n                    )\n                    shared.pop(\"pending_write\", None)\n                else:\n                    # Fail fast when a non-NTAG is presented during write mode.\n                    # Without this, UI can appear stuck on \"waiting for SpoolBuddy\".\n                    sak = nfc.current_sak\n                    await api.write_tag_result(\n                        device_id=config.device_id,\n                        spool_id=pending[\"spool_id\"],\n                        tag_uid=nfc.current_uid or \"\",\n                        success=False,\n                        message=f\"Incompatible tag type (SAK=0x{sak:02X}). Place an NTAG tag to write.\",\n                    )\n                    logger.warning(\n                        \"Write aborted for spool %d: incompatible tag type SAK=0x%02X\",\n                        pending[\"spool_id\"],\n                        sak,\n                    )\n                    shared.pop(\"pending_write\", None)\n\n            await asyncio.sleep(config.nfc_poll_interval)\n    finally:\n        nfc: NFCReader | None = shared.get(\"nfc\")\n        if nfc:\n            nfc.close()\n\n\nasync def scale_poll_loop(config: Config, api: APIClient, shared: dict):\n    \"\"\"Continuous scale reading loop — reads at 100ms, reports at 1s intervals.\"\"\"\n    scale: ScaleReader = shared[\"scale\"]\n    display: DisplayControl = shared[\"display\"]\n    if not scale.ok:\n        logger.warning(\"Scale not available, skipping scale polling\")\n        return\n\n    last_report = 0.0\n    last_reported_grams: float | None = None\n    last_wake_grams: float | None = None\n    REPORT_THRESHOLD = 2.0  # Only report if weight changed by more than this (grams)\n    WAKE_THRESHOLD = 50.0  # Only wake display on large changes (spool placed/removed, not sensor bounce)\n    try:\n        while True:\n            result = await asyncio.to_thread(scale.read)\n\n            if result is not None:\n                grams, stable, raw_adc = result\n                now = time.monotonic()\n\n                if now - last_report >= config.scale_report_interval:\n                    # Only send when weight changed meaningfully\n                    weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD\n\n                    if weight_changed:\n                        # Wake display only on large weight changes (spool placed/removed)\n                        # to avoid sensor bounce keeping the screen on forever.\n                        wake_changed = last_wake_grams is None or abs(grams - last_wake_grams) >= WAKE_THRESHOLD\n                        if wake_changed:\n                            display.wake()\n                            last_wake_grams = grams\n                        await api.scale_reading(\n                            device_id=config.device_id,\n                            weight_grams=grams,\n                            stable=stable,\n                            raw_adc=raw_adc,\n                        )\n                        last_reported_grams = grams\n                    last_report = now\n\n            await asyncio.sleep(config.scale_read_interval)\n    finally:\n        scale.close()\n\n\nasync def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):\n    \"\"\"Periodic heartbeat to keep device registered and pick up commands.\"\"\"\n    display: DisplayControl = shared[\"display\"]\n    ip = _get_ip()\n\n    while True:\n        await asyncio.sleep(config.heartbeat_interval)\n\n        nfc = shared.get(\"nfc\")\n        scale = shared.get(\"scale\")\n        uptime = int(time.monotonic() - start_time)\n        stats = await asyncio.to_thread(system_stats.collect)\n        result = await api.heartbeat(\n            device_id=config.device_id,\n            nfc_ok=nfc.ok if nfc else False,\n            scale_ok=scale.ok if scale else False,\n            uptime_s=uptime,\n            ip_address=ip,\n            firmware_version=__version__,\n            nfc_reader_type=nfc.reader_type if nfc else None,\n            nfc_connection=nfc.connection if nfc else None,\n            backend_url=config.backend_url,\n            system_stats=stats,\n        )\n\n        if result:\n            cmd = result.get(\"pending_command\")\n            if cmd == \"tare\":\n                scale = shared.get(\"scale\")\n                if scale and scale.ok:\n                    new_offset = await asyncio.to_thread(scale.tare)\n                    logger.info(\"Tare executed: offset=%d\", new_offset)\n                    await api.update_tare(config.device_id, new_offset)\n                    config.tare_offset = new_offset\n                else:\n                    logger.warning(\"Tare command received but scale not available\")\n                # Skip calibration sync — this heartbeat response predates the tare\n                continue\n            elif cmd == \"apply_system_config\":\n                payload = result.get(\"pending_system_payload\") or {}\n                backend_url = str(payload.get(\"backend_url\", \"\")).strip()\n                api_key_value = payload.get(\"api_key\")\n                api_key = str(api_key_value).strip() if api_key_value is not None else \"\"\n\n                if not backend_url:\n                    await api.system_command_result(\n                        config.device_id,\n                        \"apply_system_config\",\n                        False,\n                        \"Missing backend_url payload\",\n                    )\n                    continue\n\n                try:\n                    env_path = _spoolbuddy_env_path()\n                    await asyncio.to_thread(_set_env_value, env_path, \"SPOOLBUDDY_BACKEND_URL\", backend_url)\n                    if api_key:\n                        await asyncio.to_thread(_set_env_value, env_path, \"SPOOLBUDDY_API_KEY\", api_key)\n\n                    await api.system_command_result(\n                        config.device_id,\n                        \"apply_system_config\",\n                        True,\n                        f\"Updated {env_path}\",\n                    )\n\n                    logger.info(\"Applied system config update\")\n                except Exception as e:\n                    logger.exception(\"Failed to apply system config\")\n                    await api.system_command_result(\n                        config.device_id,\n                        \"apply_system_config\",\n                        False,\n                        str(e),\n                    )\n                continue\n            elif cmd in (\"run_nfc_diag\", \"run_scale_diag\", \"run_read_tag_diag\"):\n                if cmd == \"run_scale_diag\":\n                    diagnostic = \"scale\"\n                    script_name = \"scale_diag.py\"\n                elif cmd == \"run_read_tag_diag\":\n                    diagnostic = \"read_tag\"\n                    script_name = \"read_tag.py\"\n                else:\n                    diagnostic = \"nfc\"\n                    script_name = \"pn5180_diag.py\"\n                script_path = Path(__file__).resolve().parent.parent / \"scripts\" / script_name\n\n                if diagnostic in (\"nfc\", \"read_tag\"):\n                    logger.info(\"Pausing NFC continuous scan for diagnostic\")\n                    shared[\"nfc_scan_paused\"] = True\n                    nfc_for_diag = shared.get(\"nfc\")\n                    if nfc_for_diag:\n                        await asyncio.to_thread(nfc_for_diag.close)\n                        shared[\"nfc\"] = None\n\n                logger.info(\"Running %s diagnostic via %s\", diagnostic, script_path)\n                try:\n                    proc = await asyncio.to_thread(\n                        subprocess.run,\n                        [sys.executable, str(script_path)],\n                        capture_output=True,\n                        text=True,\n                        timeout=45,\n                    )\n                    output = (proc.stdout or \"\") + ((\"\\n\" + proc.stderr) if proc.stderr else \"\")\n                    await api.diagnostic_result(\n                        config.device_id,\n                        diagnostic,\n                        proc.returncode == 0,\n                        output,\n                        proc.returncode,\n                    )\n                except subprocess.TimeoutExpired:\n                    await api.diagnostic_result(\n                        config.device_id,\n                        diagnostic,\n                        False,\n                        \"Diagnostic timed out after 45 seconds\",\n                        -1,\n                    )\n                except Exception as e:\n                    await api.diagnostic_result(\n                        config.device_id,\n                        diagnostic,\n                        False,\n                        f\"Diagnostic execution failed: {e}\",\n                        -1,\n                    )\n                finally:\n                    if diagnostic in (\"nfc\", \"read_tag\"):\n                        logger.info(\"Reinitializing NFC continuous scan after diagnostic\")\n                        shared[\"nfc\"] = NFCReader()\n                        shared[\"nfc_scan_paused\"] = False\n                continue\n            elif cmd == \"write_tag\":\n                write_payload = result.get(\"pending_write_payload\")\n                if write_payload:\n                    shared[\"pending_write\"] = {\n                        \"spool_id\": write_payload[\"spool_id\"],\n                        \"ndef_data\": bytes.fromhex(write_payload[\"ndef_data_hex\"]),\n                    }\n                    logger.info(\"Write tag command received for spool %d\", write_payload[\"spool_id\"])\n            elif cmd in (\"reboot\", \"shutdown\", \"restart_daemon\", \"restart_browser\"):\n                logger.info(\"System command received: %s\", cmd)\n                try:\n                    await api.system_command_result(config.device_id, cmd, True, f\"Executing {cmd}\")\n                except Exception:\n                    pass  # Best effort — we're about to restart/shutdown anyway\n                if cmd == \"reboot\":\n                    await asyncio.to_thread(subprocess.run, [\"sudo\", \"reboot\"], check=False)\n                elif cmd == \"shutdown\":\n                    await asyncio.to_thread(subprocess.run, [\"sudo\", \"shutdown\", \"-h\", \"now\"], check=False)\n                elif cmd == \"restart_daemon\":\n                    await asyncio.to_thread(\n                        subprocess.run, [\"sudo\", \"systemctl\", \"restart\", \"spoolbuddy.service\"], check=False\n                    )\n                elif cmd == \"restart_browser\":\n                    await asyncio.to_thread(\n                        subprocess.run, [\"sudo\", \"systemctl\", \"restart\", \"getty@tty1.service\"], check=False\n                    )\n                continue\n\n            tare = result.get(\"tare_offset\", config.tare_offset)\n            cal = result.get(\"calibration_factor\", config.calibration_factor)\n            if tare != config.tare_offset or cal != config.calibration_factor:\n                config.tare_offset = tare\n                config.calibration_factor = cal\n                scale = shared.get(\"scale\")\n                if scale:\n                    scale.update_calibration(tare, cal)\n                logger.info(\"Calibration updated from backend: tare=%d, factor=%.6f\", tare, cal)\n\n            # Apply display settings from backend\n            brightness = result.get(\"display_brightness\")\n            blank_timeout = result.get(\"display_blank_timeout\")\n            if brightness is not None:\n                display.set_brightness(brightness)\n            if blank_timeout is not None:\n                display.set_blank_timeout(blank_timeout)\n\n        display.tick()\n\n\nasync def main():\n    config = Config.load()\n    logger.info(\n        \"SpoolBuddy daemon v%s starting (device=%s, backend=%s)\", __version__, config.device_id, config.backend_url\n    )\n\n    api = APIClient(config.backend_url, config.api_key)\n    ip = _get_ip()\n    start_time = time.monotonic()\n\n    # Initialize hardware before registration so we can report capabilities\n    nfc = NFCReader()\n    scale = ScaleReader(\n        tare_offset=config.tare_offset,\n        calibration_factor=config.calibration_factor,\n    )\n    display = DisplayControl()\n\n    # Register with backend (retries until success)\n    reg = await api.register_device(\n        device_id=config.device_id,\n        hostname=config.hostname,\n        ip_address=ip,\n        firmware_version=__version__,\n        has_nfc=True,\n        has_scale=True,\n        tare_offset=config.tare_offset,\n        calibration_factor=config.calibration_factor,\n        nfc_reader_type=nfc.reader_type,\n        nfc_connection=nfc.connection,\n        backend_url=config.backend_url,\n        has_backlight=display.has_backlight,\n    )\n\n    # Use server-side calibration if available\n    if reg:\n        config.tare_offset = reg.get(\"tare_offset\", config.tare_offset)\n        config.calibration_factor = reg.get(\"calibration_factor\", config.calibration_factor)\n        scale.update_calibration(config.tare_offset, config.calibration_factor)\n\n        # Auto-deploy Bambuddy's SSH public key for remote updates\n        ssh_key = reg.get(\"ssh_public_key\")\n        if ssh_key:\n            _deploy_ssh_key(ssh_key)\n\n    logger.info(\"Device registered, starting poll loops\")\n\n    shared: dict = {\"nfc\": nfc, \"scale\": scale, \"display\": display, \"nfc_scan_paused\": False}\n    try:\n        await asyncio.gather(\n            nfc_poll_loop(config, api, shared),\n            scale_poll_loop(config, api, shared),\n            heartbeat_loop(config, api, start_time, shared),\n        )\n    except KeyboardInterrupt:\n        logger.info(\"Shutting down\")\n    finally:\n        await api.close()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "spoolbuddy/daemon/nau7802.py",
    "content": "\"\"\"NAU7802 24-bit ADC driver for load cell / scale applications.\n\nI2C address: 0x2A\nBus: /dev/i2c-1 (GPIO2/GPIO3 on RPi)\n\"\"\"\n\nimport logging\nimport os\nimport struct\nimport time\n\nimport smbus2\n\nlogger = logging.getLogger(__name__)\n\n\ndef _env_int(name: str, default: int) -> int:\n    value = os.environ.get(name)\n    if value is None or value == \"\":\n        return default\n    try:\n        return int(value)\n    except ValueError:\n        return default\n\n\nI2C_BUS = _env_int(\"SPOOLBUDDY_I2C_BUS\", 1)\nNAU7802_ADDR = 0x2A\n\n# Register addresses\nREG_PU_CTRL = 0x00\nREG_CTRL1 = 0x01\nREG_CTRL2 = 0x02\nREG_ADCO_B2 = 0x12  # ADC output MSB\nREG_ADCO_B1 = 0x13\nREG_ADCO_B0 = 0x14  # ADC output LSB\nREG_ADC = 0x15\nREG_PGA = 0x1B\nREG_PWR_CTRL = 0x1C\nREG_REVISION = 0x1F\n\n# PU_CTRL bits\nPU_RR = 0x01  # Register reset\nPU_PUD = 0x02  # Power up digital\nPU_PUA = 0x04  # Power up analog\nPU_PUR = 0x08  # Power up ready (read-only)\nPU_CS = 0x10  # Cycle start\nPU_CR = 0x20  # Cycle ready (read-only)\nPU_OSCS = 0x40  # Oscillator select\nPU_AVDDS = 0x80  # AVDD source select\n\n\nclass NAU7802:\n    def __init__(self, bus: int = I2C_BUS, addr: int = NAU7802_ADDR):\n        self._bus_num = bus\n        self._bus = smbus2.SMBus(bus)\n        self._addr = addr\n\n    # CTRL2 bits for AFE calibration\n    _CTRL2_CALS = 1 << 2\n    _CTRL2_CAL_ERROR = 1 << 3\n\n    def close(self):\n        self._bus.close()\n\n    def read_reg(self, reg: int) -> int:\n        return self._bus.read_byte_data(self._addr, reg)\n\n    def write_reg(self, reg: int, val: int):\n        self._bus.write_byte_data(self._addr, reg, val & 0xFF)\n\n    def _update_bits(self, reg: int, mask: int, value: int):\n        cur = self.read_reg(reg)\n        self.write_reg(reg, (cur & ~mask) | (value & mask))\n\n    def _set_bit(self, reg: int, bit: int, enabled: bool):\n        mask = 1 << bit\n        self._update_bits(reg, mask, mask if enabled else 0)\n\n    def _set_field(self, reg: int, shift: int, width: int, value: int):\n        mask = ((1 << width) - 1) << shift\n        self._update_bits(reg, mask, value << shift)\n\n    def init(self):\n        \"\"\"Initialize NAU7802 per datasheet power-on sequencing (Section 8.1).\n\n        Datasheet steps:\n          1. RR=1 (reset all registers)\n          2. RR=0, PUD=1 (enter normal operation; PUD auto-starts AD conversion)\n          3. Wait ~200µs for PUR=1\n          4. Configure (LDO, gain, rate, etc.)\n          5. Tuning (ADC chopper, PGA caps)\n          6. (Optional) calibration and flush transients\n        \"\"\"\n\n        # Step 1: Reset (set RR=1, then RR=0)\n        self._set_bit(REG_PU_CTRL, 0, True)  # RR=1\n        time.sleep(0.010)\n        self._set_bit(REG_PU_CTRL, 0, False)  # RR=0 exits reset\n        # Datasheet says \"about 200 microseconds\" before PUR is set\n        time.sleep(0.001)\n\n        # Step 2: Power up digital (PUD=1 auto-starts AD conversion)\n        self._set_bit(REG_PU_CTRL, 1, True)  # PUD=1\n        # Step 2b: Power up analog (PUA=1)\n        self._set_bit(REG_PU_CTRL, 2, True)  # PUA=1\n        time.sleep(0.600)  # Wait for LDO and analog section to stabilize\n\n        # Step 3: Wait for power-up ready (PUR bit 3)\n\n        for _ in range(100):\n            status = self.read_reg(REG_PU_CTRL)\n            if status & PU_PUR:\n                logger.debug(\"  Power-up ready\")\n                break\n            time.sleep(0.001)\n        else:\n            raise TimeoutError(\"NAU7802 power-up timeout (PUR bit not set)\")\n\n        # Check revision register low nibble (datasheet expects 0xF).\n\n        revision = self.read_reg(REG_REVISION)\n        logger.debug(f\"  Revision: 0x{revision:02X}\")\n        if (revision & 0x0F) != 0x0F:\n            raise RuntimeError(f\"Unexpected NAU7802 revision: 0x{revision:02X} (expected 0x_F)\")\n\n        # Step 4: Configure device\n        # Internal LDO enable (AVDDS=1, bit 7) and set voltage to 3.0V\n\n        self._set_bit(REG_PU_CTRL, 7, True)  # AVDDS=1\n        self._set_field(REG_CTRL1, shift=3, width=3, value=0b101)  # VLDO=3.0V\n        logger.debug(\"  LDO: 3.0V (internal)\")\n\n        # Set gain to 128x (CTRL1 bits 2:0 = 0b111)\n\n        self._set_field(REG_CTRL1, shift=0, width=3, value=0b111)\n        logger.debug(\"  Gain: 128x\")\n\n        # Set sample rate to 10 SPS (CTRL2 bits 6:4 = 0b000)\n        # Note: At 10 SPS, each sample takes ~100ms; first 4 samples = ~400ms to settle\n\n        self._set_field(REG_CTRL2, shift=4, width=3, value=0b000)\n        logger.debug(\"  Sample rate: 10 SPS\")\n\n        # Step 5: Tuning per application notes\n        # Disable ADC chopper clock (ADC bits 5:4 = 0b11)\n        self._set_field(REG_ADC, shift=4, width=2, value=0b11)\n        # Enable low-ESR caps on PGA (PGA bit 6 = 0 for improved accuracy)\n        self._set_bit(REG_PGA, 6, False)\n\n        # Step 6: Trigger fresh AD conversion and wait for first result\n        # CS bit transition 0→1 starts fresh conversion; takes ~4-sample time for result\n\n        self._set_bit(REG_PU_CTRL, 4, True)  # CS=1\n        logger.debug(\"  Conversion started\")\n\n        # Flush startup transients before calibration\n        # At 10 SPS, initial 4 samples may contain settling artifacts\n\n        self.flush_readings(count=4, timeout_s=1.5)\n\n        # Run AFE calibration (internal mode), then flush result\n        self.calibrate_afe(timeout_ms=1000, mode=0)\n        self.flush_readings(count=2, timeout_s=1.0)\n        logger.debug(\"  Initialization complete\")\n\n    def begin_calibrate_afe(self, mode: int = 0) -> None:\n        \"\"\"Start asynchronous AFE calibration.\n\n        mode values match NAU7802 CALMOD: 0=internal, 1=offset, 2=gain.\n        \"\"\"\n        ctrl2 = self.read_reg(REG_CTRL2)\n        ctrl2 &= 0xFC  # clear CALMOD bits[1:0]\n        ctrl2 |= mode & 0x03\n        self.write_reg(REG_CTRL2, ctrl2)\n\n        # Set CALS (bit 2) to start calibration.\n        self.write_reg(REG_CTRL2, self.read_reg(REG_CTRL2) | self._CTRL2_CALS)\n\n    def wait_for_calibrate_afe(self, timeout_ms: int = 1000) -> bool:\n        deadline = time.monotonic() + (timeout_ms / 1000.0) if timeout_ms > 0 else None\n\n        while True:\n            ctrl2 = self.read_reg(REG_CTRL2)\n            if (ctrl2 & self._CTRL2_CALS) == 0:\n                return (ctrl2 & self._CTRL2_CAL_ERROR) == 0\n\n            if deadline is not None and time.monotonic() >= deadline:\n                return False\n            time.sleep(0.001)\n\n    def calibrate_afe(self, timeout_ms: int = 1000, mode: int = 0) -> None:\n        \"\"\"Run AFE calibration per datasheet CTRL2[2] CALS bit sequence.\n\n        Datasheet says:\n          - Write 1 to CALS to start (mode in CALMOD bits [1:0])\n          - CALS=1 during calibration, 0 when complete\n          - Check CAL_ERR bit after completion\n        \"\"\"\n        self.begin_calibrate_afe(mode=mode)\n        if not self.wait_for_calibrate_afe(timeout_ms=timeout_ms):\n            raise RuntimeError(f\"NAU7802 AFE calibration timed out after {timeout_ms}ms\")\n        # Check CAL_ERR bit to ensure no error during calibration\n        ctrl2 = self.read_reg(REG_CTRL2)\n        if ctrl2 & self._CTRL2_CAL_ERROR:\n            raise RuntimeError(\"NAU7802 AFE calibration completed with CAL_ERR set\")\n        logger.debug(\"  AFE calibration: OK\")\n\n    def wait_data_ready(self, timeout_s: float = 1.0) -> bool:\n        deadline = time.monotonic() + timeout_s\n        while time.monotonic() < deadline:\n            if self.data_ready():\n                return True\n            time.sleep(0.001)\n        return False\n\n    def flush_readings(self, count: int = 4, timeout_s: float = 1.0) -> None:\n        flushed = 0\n        while flushed < count:\n            if not self.wait_data_ready(timeout_s=timeout_s):\n                raise TimeoutError(\"Timeout while flushing startup scale readings\")\n            _ = self.read_raw()\n            flushed += 1\n\n    def data_ready(self) -> bool:\n        return bool(self.read_reg(REG_PU_CTRL) & PU_CR)\n\n    def read_raw(self) -> int:\n        \"\"\"Read 24-bit signed ADC value.\"\"\"\n        b2 = self.read_reg(REG_ADCO_B2)\n        b1 = self.read_reg(REG_ADCO_B1)\n        b0 = self.read_reg(REG_ADCO_B0)\n        raw = (b2 << 16) | (b1 << 8) | b0\n        # Sign extend 24-bit to 32-bit\n        if raw & 0x800000:\n            raw |= 0xFF000000\n            raw = struct.unpack(\"i\", struct.pack(\"I\", raw))[0]\n        return raw\n"
  },
  {
    "path": "spoolbuddy/daemon/nfc_reader.py",
    "content": "\"\"\"NFC reader wrapper with state machine for tag presence detection.\"\"\"\n\nimport logging\nimport time\nfrom enum import Enum, auto\n\nlogger = logging.getLogger(__name__)\n\nMISS_THRESHOLD = 3  # Consecutive misses before declaring tag removed\nERROR_RECOVERY_THRESHOLD = 10  # Consecutive errors before attempting RF reset\n\n\nclass NFCState(Enum):\n    IDLE = auto()\n    TAG_PRESENT = auto()\n\n\nclass NFCReader:\n    def __init__(self):\n        self._nfc = None\n        self._state = NFCState.IDLE\n        self._current_uid: str | None = None\n        self._current_sak: int | None = None\n        self._miss_count = 0\n        self._ok = False\n        self._error_count = 0\n        self._poll_count = 0\n        self._last_status_log = 0.0\n\n        try:\n            from .pn5180 import PN5180\n\n            self._nfc = PN5180()\n            self._init_rf()\n            self._ok = True\n            logger.info(\"NFC reader initialized\")\n        except Exception as e:\n            logger.warning(\"NFC not available: %s\", e)\n\n    def _init_rf(self):\n        \"\"\"Full RF initialization sequence.\"\"\"\n        self._nfc.reset()\n        self._nfc.load_rf_config(0x00, 0x80)\n        time.sleep(0.010)\n        self._nfc.rf_on()\n        time.sleep(0.030)\n        self._nfc.set_transceive_mode()\n\n    def _full_reset(self):\n        \"\"\"Full hardware reset + RF init to recover from stuck state.\"\"\"\n        try:\n            self._init_rf()\n            self._error_count = 0\n            logger.info(\"NFC reader recovered after full reset\")\n            return True\n        except Exception as e:\n            logger.warning(\"NFC full reset failed: %s\", e)\n            return False\n\n    @property\n    def reader_type(self) -> str:\n        \"\"\"Return NFC reader hardware type.\"\"\"\n        return \"PN5180\" if self._nfc is not None else \"Unknown\"\n\n    @property\n    def connection(self) -> str:\n        \"\"\"Return NFC reader connection type.\"\"\"\n        return \"SPI\" if self._nfc is not None else \"None\"\n\n    @property\n    def ok(self) -> bool:\n        return self._ok\n\n    @property\n    def state(self) -> NFCState:\n        return self._state\n\n    @property\n    def current_uid(self) -> str | None:\n        return self._current_uid\n\n    def close(self):\n        try:\n            self._nfc.rf_off()\n            self._nfc.close()\n        except Exception:\n            pass\n\n    @property\n    def current_sak(self) -> int | None:\n        return self._current_sak\n\n    def write_ntag(self, data: bytes) -> tuple[bool, str]:\n        \"\"\"Write raw NDEF bytes to currently present NTAG tag.\n\n        Requires tag in TAG_PRESENT state with SAK=0x00.\n        Returns (success, message).\n        \"\"\"\n        if self._state != NFCState.TAG_PRESENT:\n            return False, \"No tag present\"\n        if self._current_sak not in (0x00, 0x04):\n            return False, f\"Not an NTAG (SAK=0x{self._current_sak:02X})\"\n        if not self._nfc:\n            return False, \"NFC reader not available\"\n\n        try:\n            # Reactivate card before writing\n            result = self._nfc.reactivate_card()\n            if result is None:\n                return False, \"Failed to reactivate card for write\"\n\n            uid_bytes, sak = result\n            if uid_bytes.hex().upper() != self._current_uid:\n                return False, \"Tag UID changed during write\"\n\n            # Write starting at page 4\n            success = self._nfc.ntag_write_pages(start_page=4, data=data)\n            if success:\n                logger.info(\"NTAG write successful: %d bytes to tag %s\", len(data), self._current_uid)\n                return True, \"Write successful\"\n            else:\n                return False, \"Write or verification failed\"\n        except Exception as e:\n            logger.error(\"NTAG write error: %s\", e)\n            return False, f\"Write error: {e}\"\n\n    def poll(self) -> tuple[str, dict | None]:\n        \"\"\"Poll for tag. Returns (event_type, event_data).\n\n        event_type: \"none\", \"tag_detected\", \"tag_removed\"\n        \"\"\"\n        self._poll_count += 1\n\n        # Periodic status log (every 60s)\n        now = time.monotonic()\n        if now - self._last_status_log >= 60.0:\n            logger.info(\n                \"NFC status: state=%s, polls=%d, errors=%d, ok=%s\",\n                self._state.name,\n                self._poll_count,\n                self._error_count,\n                self._ok,\n            )\n            self._last_status_log = now\n\n        if self._state == NFCState.IDLE:\n            # Full hardware reset before every idle poll. Each activate_type_a()\n            # call that returns None corrupts the PN5180 state — subsequent calls\n            # silently fail even when a tag is present. Only a full RST pin toggle\n            # recovers the reader. ~240ms overhead per poll, giving ~1.8 Hz poll\n            # rate which is fine for a spool tag reader.\n            try:\n                self._init_rf()\n            except Exception as e:\n                logger.warning(\"NFC pre-poll reset failed: %s\", e)\n        else:\n            # Tag present: light RF cycle to reset card from ACTIVE back to IDLE\n            # state after previous SELECT, so it responds to the next WUPA/REQA.\n            try:\n                self._nfc.rf_off()\n                time.sleep(0.003)\n                self._nfc.rf_on()\n                time.sleep(0.010)\n            except Exception:\n                pass  # Will be caught by activate_type_a() error handling below\n\n        try:\n            result = self._nfc.activate_type_a()\n        except Exception as e:\n            self._error_count += 1\n            self._ok = False\n\n            if self._error_count == 1:\n                logger.warning(\"NFC poll error: %s\", e)\n            elif self._error_count == ERROR_RECOVERY_THRESHOLD:\n                logger.warning(\n                    \"NFC reader stuck (%d consecutive errors), attempting recovery...\",\n                    self._error_count,\n                )\n                if self._full_reset():\n                    return \"none\", None\n                # Reset failed — will keep trying on next threshold\n                self._error_count = 0\n            elif self._error_count % ERROR_RECOVERY_THRESHOLD == 0:\n                logger.warning(\"NFC recovery attempt #%d\", self._error_count // ERROR_RECOVERY_THRESHOLD)\n                self._full_reset()\n\n            return \"none\", None\n\n        # Successful poll — clear error streak\n        if self._error_count > 0:\n            logger.info(\"NFC reader recovered after %d errors\", self._error_count)\n        self._error_count = 0\n        self._ok = True\n\n        if result is not None:\n            uid_bytes, sak = result\n            uid_hex = uid_bytes.hex().upper()\n            self._miss_count = 0\n\n            if self._state == NFCState.IDLE:\n                self._state = NFCState.TAG_PRESENT\n                self._current_uid = uid_hex\n                self._current_sak = sak\n\n                # Try reading Bambu tag data\n                tray_uuid = None\n                tag_type = \"mifare_classic\" if sak in (0x08, 0x18) else \"ntag\" if sak in (0x00, 0x04) else \"unknown\"\n\n                if sak in (0x08, 0x18):\n                    blocks = self._nfc.read_bambu_tag(uid_bytes)\n                    if blocks:\n                        tray_uuid = _extract_tray_uuid(blocks)\n\n                logger.info(\"Tag detected: %s (SAK=0x%02X, type=%s)\", uid_hex, sak, tag_type)\n                return \"tag_detected\", {\n                    \"tag_uid\": uid_hex,\n                    \"sak\": sak,\n                    \"tag_type\": tag_type,\n                    \"tray_uuid\": tray_uuid,\n                }\n\n            # Tag still present — no event\n            return \"none\", None\n\n        # No tag found\n        if self._state == NFCState.TAG_PRESENT:\n            self._miss_count += 1\n            if self._miss_count >= MISS_THRESHOLD:\n                old_uid = self._current_uid\n                self._state = NFCState.IDLE\n                self._current_uid = None\n                self._current_sak = None\n                self._miss_count = 0\n                logger.info(\"Tag removed: %s\", old_uid)\n                return \"tag_removed\", {\"tag_uid\": old_uid}\n\n        return \"none\", None\n\n\ndef _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:\n    \"\"\"Extract tray_uuid from Bambu MIFARE Classic data blocks.\"\"\"\n    # Block 4-5 contain the tray UUID as 32 ASCII hex chars across 32 bytes.\n    if 4 in blocks and 5 in blocks:\n        raw = blocks[4] + blocks[5]\n        try:\n            # Preferred path: decode full ASCII payload, keep only hex chars.\n            ascii_candidate = raw.decode(\"ascii\", errors=\"ignore\")\n            hex_chars = \"\".join(ch for ch in ascii_candidate if ch in \"0123456789abcdefABCDEF\")\n            if len(hex_chars) >= 32:\n                uuid_str = hex_chars[:32].upper()\n                if uuid_str != \"0\" * 32:\n                    return uuid_str\n        except Exception:\n            pass\n\n        try:\n            # Fallback for partially decoded payloads: use first 16 raw bytes as hex.\n            # This preserves compatibility with older decoding behavior.\n            uuid_str = raw[:16].hex().upper()\n            if uuid_str and uuid_str != \"0\" * 32:\n                return uuid_str\n        except Exception:\n            pass\n    return None\n"
  },
  {
    "path": "spoolbuddy/daemon/pn5180.py",
    "content": "\"\"\"PN5180 NFC frontend driver — ported from working Pico firmware (pico-nfc-bridge.ino).\n\nKey learnings from pico-nfc-bridge.ino:\n- Must call setTransceiveMode() before every SEND_DATA\n- waitBusy() must wait for HIGH then LOW (not just LOW)\n- Bambu tags are MIFARE Classic 1K (ISO 14443A), not ISO 15693\n- SPI at 500kHz, 5us CS setup, 100us post-CS delay\n- MFC_AUTHENTICATE (0x0C) is a PN5180 host command — Crypto1 handled in hardware\n- HKDF-SHA256 derives per-sector keys from master key + UID\n\"\"\"\n\nimport hashlib\nimport hmac\nimport logging\nimport os\nimport time\n\nimport gpiod\nimport spidev\n\nlogger = logging.getLogger(__name__)\n\n\ndef _env_int(name: str, default: int) -> int:\n    value = os.environ.get(name)\n    if value is None or value == \"\":\n        return default\n    try:\n        return int(value)\n    except ValueError:\n        return default\n\n\nBUSY_PIN = _env_int(\"SPOOLBUDDY_NFC_BUSY_PIN\", 25)\nRST_PIN = _env_int(\"SPOOLBUDDY_NFC_RST_PIN\", 24)\nNSS_PIN = _env_int(\"SPOOLBUDDY_NFC_NSS_PIN\", 23)  # Manual CS by default\nSPI_BUS = _env_int(\"SPOOLBUDDY_NFC_SPI_BUS\", 0)\nSPI_DEVICE = _env_int(\"SPOOLBUDDY_NFC_SPI_DEVICE\", 0)\nSPI_SPEED_HZ = _env_int(\"SPOOLBUDDY_NFC_SPI_SPEED_HZ\", 500_000)\n\n# Bambu Lab MIFARE Classic key derivation constants (from pico-nfc-bridge.ino)\nBAMBU_MASTER_KEY = bytes(\n    [\n        0x9A,\n        0x75,\n        0x9C,\n        0xF2,\n        0xC4,\n        0xF7,\n        0xCA,\n        0xFF,\n        0x22,\n        0x2C,\n        0xB9,\n        0x76,\n        0x9B,\n        0x41,\n        0xBC,\n        0x96,\n    ]\n)\nBAMBU_CONTEXT = b\"RFID-A\\x00\"  # 7 bytes including null terminator\n\n# Blocks to read for Bambu tag data\nBAMBU_BLOCKS = [1, 2, 4, 5]\n\n\ndef hkdf_derive_keys(uid: bytes) -> bytes:\n    \"\"\"Derive 96 bytes of MIFARE key material (16 sectors * 6 bytes each).\n\n    Uses HKDF-SHA256 with the Bambu master key as salt and the tag UID as IKM.\n    \"\"\"\n    # HKDF-Extract: PRK = HMAC-SHA256(salt=master_key, IKM=uid)\n    prk = hmac.new(BAMBU_MASTER_KEY, uid, hashlib.sha256).digest()\n\n    # HKDF-Expand: generate 96 bytes using context \"RFID-A\\0\"\n    okm = b\"\"\n    t = b\"\"\n    counter = 1\n    while len(okm) < 96:\n        t = hmac.new(prk, t + BAMBU_CONTEXT + bytes([counter]), hashlib.sha256).digest()\n        okm += t\n        counter += 1\n    return okm[:96]\n\n\ndef get_sector_key(keys: bytes, block: int) -> bytes:\n    \"\"\"Get the 6-byte key for the sector containing the given block.\"\"\"\n    sector = block // 4\n    return keys[sector * 6 : sector * 6 + 6]\n\n\ndef _find_gpio_chip():\n    for path in [\"/dev/gpiochip4\", \"/dev/gpiochip0\"]:\n        try:\n            chip = gpiod.Chip(path)\n            if \"pinctrl\" in chip.get_info().label:\n                return chip\n            chip.close()\n        except (FileNotFoundError, PermissionError, OSError):\n            continue\n    raise RuntimeError(\"No GPIO chip\")\n\n\nclass PN5180:\n    def __init__(self):\n        self._chip = _find_gpio_chip()\n        self._lines = self._chip.request_lines(\n            consumer=\"pn5180\",\n            config={\n                BUSY_PIN: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT),\n                RST_PIN: gpiod.LineSettings(\n                    direction=gpiod.line.Direction.OUTPUT, output_value=gpiod.line.Value.ACTIVE\n                ),\n                NSS_PIN: gpiod.LineSettings(\n                    direction=gpiod.line.Direction.OUTPUT, output_value=gpiod.line.Value.ACTIVE\n                ),\n            },\n        )\n        self._spi = spidev.SpiDev()\n        self._spi.open(SPI_BUS, SPI_DEVICE)\n        self._spi.max_speed_hz = SPI_SPEED_HZ\n        self._spi.mode = 0b00\n        self._spi.no_cs = True\n\n    def close(self):\n        self._spi.close()\n        self._lines.release()\n        self._chip.close()\n\n    def _cs_low(self):\n        self._lines.set_value(NSS_PIN, gpiod.line.Value.INACTIVE)\n        time.sleep(0.000005)  # 5us setup\n\n    def _cs_high(self):\n        self._lines.set_value(NSS_PIN, gpiod.line.Value.ACTIVE)\n        time.sleep(0.000100)  # 100us post-CS delay\n\n    def _wait_busy(self, timeout_s=1.0):\n        \"\"\"Wait for BUSY to go HIGH (processing) then LOW (done) — matches Pico firmware.\"\"\"\n        deadline = time.monotonic() + min(timeout_s, 0.010)\n        # Wait for BUSY HIGH (PN5180 started processing)\n        while self._lines.get_value(BUSY_PIN) != gpiod.line.Value.ACTIVE:\n            if time.monotonic() > deadline:\n                break  # Timeout waiting for HIGH — command may have processed already\n            time.sleep(0.00001)\n        # Wait for BUSY LOW (PN5180 done)\n        deadline = time.monotonic() + timeout_s\n        while self._lines.get_value(BUSY_PIN) == gpiod.line.Value.ACTIVE:\n            if time.monotonic() > deadline:\n                raise TimeoutError(\"BUSY timeout\")\n            time.sleep(0.0001)\n\n    def _cmd(self, data):\n        self._cs_low()\n        self._spi.xfer2(list(data))\n        self._cs_high()\n        self._wait_busy()\n\n    def _read_response(self, n):\n        self._cs_low()\n        result = self._spi.xfer2([0xFF] * n)\n        self._cs_high()\n        return result\n\n    # -- Register ops --\n\n    def write_reg(self, reg, val):\n        self._cmd([0x00, reg, val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])\n\n    def write_reg_or(self, reg, mask):\n        self._cmd([0x01, reg, mask & 0xFF, (mask >> 8) & 0xFF, (mask >> 16) & 0xFF, (mask >> 24) & 0xFF])\n\n    def write_reg_and(self, reg, mask):\n        self._cmd([0x02, reg, mask & 0xFF, (mask >> 8) & 0xFF, (mask >> 16) & 0xFF, (mask >> 24) & 0xFF])\n\n    def read_reg(self, reg):\n        self._cmd([0x04, reg])\n        time.sleep(0.000100)  # Extra 100us before read\n        return int.from_bytes(self._read_response(4), \"little\")\n\n    def read_eeprom(self, addr, length):\n        self._cmd([0x07, addr, length])\n        time.sleep(0.000100)\n        return bytes(self._read_response(length))\n\n    # -- Commands --\n\n    def reset(self):\n        self._lines.set_value(RST_PIN, gpiod.line.Value.INACTIVE)\n        time.sleep(0.050)\n        self._lines.set_value(RST_PIN, gpiod.line.Value.ACTIVE)\n        time.sleep(0.100)\n        self._wait_busy(2.0)\n        time.sleep(0.050)\n\n    def load_rf_config(self, tx, rx):\n        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs first\n        time.sleep(0.000100)\n        self._cmd([0x11, tx, rx])\n        time.sleep(0.010)\n\n    def rf_on(self):\n        self._cmd([0x16, 0x00])\n        time.sleep(0.010)\n\n    def rf_off(self):\n        self._cmd([0x17, 0x00])\n        time.sleep(0.005)\n\n    def set_pin(self, pin: int, value: bool) -> None:\n        \"\"\"Set the state of a control pin (NSS or RST). Value: True=ACTIVE, False=INACTIVE.\"\"\"\n        if pin not in (NSS_PIN, RST_PIN):\n            raise ValueError(\"Only NSS_PIN and RST_PIN can be set via set_pin().\")\n        self._lines.set_value(pin, gpiod.line.Value.ACTIVE if value else gpiod.line.Value.INACTIVE)\n\n    def get_pin(self, pin: int) -> bool:\n        \"\"\"Get the state of a control pin (NSS or RST). Returns True if ACTIVE, False if INACTIVE.\"\"\"\n        if pin not in (NSS_PIN, RST_PIN):\n            raise ValueError(\"Only NSS_PIN and RST_PIN can be read via get_pin().\")\n        return self._lines.get_value(pin) == gpiod.line.Value.ACTIVE\n\n    def set_transceive_mode(self):\n        \"\"\"Set SYSTEM_CONFIG command bits to TRANSCEIVE (0x03) — CRITICAL!\"\"\"\n        sys_cfg = self.read_reg(0x00)\n        sys_cfg = (sys_cfg & 0xFFFFFFF8) | 0x03\n        self.write_reg(0x00, sys_cfg)\n\n    def send_data(self, data, valid_bits=0x00):\n        self._cs_low()\n        self._spi.xfer2([0x09, valid_bits] + list(data))\n        self._cs_high()\n        time.sleep(0.000100)\n        self._wait_busy()\n\n    def read_data(self, length):\n        self._cmd([0x0A, 0x00])\n        return bytes(self._read_response(length))\n\n    # -- ISO 14443A --\n\n    def activate_type_a(self):\n        \"\"\"Full Type A activation: WUPA -> Anticollision -> SELECT. Returns (uid, sak) or None.\"\"\"\n        # Crypto off, CRC off\n        self.write_reg_and(0x00, 0xFFFFFFBF)\n        self.write_reg_and(0x12, 0xFFFFFFFE)\n        self.write_reg_and(0x19, 0xFFFFFFFE)\n        self.write_reg(0x03, 0xFFFFFFFF)\n\n        # Reset to IDLE then TRANSCEIVE\n        sys_cfg = self.read_reg(0x00)\n        self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE\n        time.sleep(0.001)\n        self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE\n        time.sleep(0.002)\n\n        # WUPA (7-bit)\n        self.send_data([0x52], valid_bits=0x07)\n        time.sleep(0.005)\n\n        rx_status = self.read_reg(0x13)\n        rx_len = rx_status & 0x1FF\n        if rx_len < 2 or rx_len == 511:\n            # Try REQA\n            self.write_reg(0x03, 0xFFFFFFFF)\n            time.sleep(0.002)\n            self.set_transceive_mode()\n            time.sleep(0.002)\n            self.send_data([0x26], valid_bits=0x07)\n            time.sleep(0.005)\n            rx_status = self.read_reg(0x13)\n            rx_len = rx_status & 0x1FF\n            if rx_len < 2 or rx_len == 511:\n                return None\n\n        atqa = self.read_data(2)\n        if atqa[0] == 0xFF or atqa[0] == 0x00:\n            return None\n\n        # Anti-collision Level 1\n        self.write_reg(0x03, 0xFFFFFFFF)\n        self.set_transceive_mode()\n        time.sleep(0.002)\n\n        self.send_data([0x93, 0x20])\n        time.sleep(0.010)\n\n        rx_status = self.read_reg(0x13)\n        rx_len = rx_status & 0x1FF\n        if rx_len < 5 or rx_len > 64:\n            return None\n\n        uid_buf = self.read_data(5)\n        uid = uid_buf[:4]\n        bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]\n        if bcc != uid_buf[4]:\n            return None\n\n        # SELECT\n        self.write_reg(0x03, 0xFFFFFFFF)\n        self.set_transceive_mode()\n        time.sleep(0.002)\n\n        # Enable CRC for SELECT\n        self.write_reg_or(0x19, 0x01)\n        self.write_reg_or(0x12, 0x01)\n\n        self.send_data([0x93, 0x70, uid[0], uid[1], uid[2], uid[3], bcc])\n        time.sleep(0.010)\n\n        rx_status = self.read_reg(0x13)\n        rx_len = rx_status & 0x1FF\n        if rx_len < 1:\n            return None\n\n        sak_buf = self.read_data(min(rx_len, 3))\n        sak = sak_buf[0]\n\n        return bytes(uid), sak\n\n    # -- MIFARE Classic --\n\n    def mfc_authenticate(self, block: int, key: bytes, uid: bytes) -> bool:\n        \"\"\"MIFARE Classic authentication via PN5180 MFC_AUTHENTICATE (0x0C).\n\n        The PN5180 handles Crypto1 internally. After success, bit 6 of\n        SYSTEM_CONFIG is set (MFC_CRYPTO1_ON) and all subsequent RF\n        communication is encrypted/decrypted by the hardware.\n\n        Args:\n            block: Block number to authenticate\n            key: 6-byte MIFARE Key A\n            uid: 4-byte tag UID\n        Returns:\n            True if authentication succeeded\n        \"\"\"\n        # Wait for BUSY LOW before starting\n        deadline = time.monotonic() + 0.100\n        while self._lines.get_value(BUSY_PIN) == gpiod.line.Value.ACTIVE:\n            if time.monotonic() > deadline:\n                return False\n            time.sleep(0.001)\n\n        # MFC_AUTHENTICATE: [0x0C][key 6B][keyType][blockNo][uid 4B] = 13 bytes\n        cmd = [0x0C] + list(key) + [0x60, block] + list(uid[:4])\n        self._cs_low()\n        self._spi.xfer2(cmd)\n        self._cs_high()\n\n        # Wait for BUSY HIGH then LOW (auth can take up to 1s)\n        self._wait_busy(timeout_s=1.0)\n\n        # Read 1-byte response: 0x00 = success\n        self._cs_low()\n        response = self._spi.xfer2([0xFF])\n        self._cs_high()\n\n        return response[0] == 0x00\n\n    def mfc_read_block(self, block: int) -> bytes | None:\n        \"\"\"Read a 16-byte MIFARE Classic block (must be authenticated first).\n\n        Returns 16 bytes of block data, or None on failure.\n        \"\"\"\n        # Clear IRQs\n        self.write_reg(0x03, 0xFFFFFFFF)\n\n        # Set transceive mode (Crypto1 stays active from MFC_AUTHENTICATE)\n        self.set_transceive_mode()\n        time.sleep(0.001)\n\n        # Enable TX and RX CRC for encrypted read\n        self.write_reg_or(0x19, 0x01)\n        self.write_reg_or(0x12, 0x01)\n\n        # Send MIFARE READ command: 0x30 + block number\n        self.send_data([0x30, block])\n        time.sleep(0.010)\n\n        # Check RX status\n        rx_status = self.read_reg(0x13)\n        rx_len = rx_status & 0x1FF\n        if rx_len != 16:\n            return None\n\n        return self.read_data(16)\n\n    def ntag_read_pages(self, start_page: int, num_pages: int) -> bytes | None:\n        \"\"\"Read NTAG pages (4 bytes each). No authentication required.\n\n        Uses NTAG READ command (0x30) which returns 4 pages (16 bytes) at a time.\n        \"\"\"\n        # One-time setup: Crypto1 off, TX CRC on, RX CRC off, IDLE→TRANSCEIVE\n        self.write_reg_and(0x00, 0xFFFFFFBF)  # Crypto1 off\n        self.write_reg_or(0x19, 0x01)  # TX CRC on\n        self.write_reg_and(0x12, 0xFFFFFFFE)  # RX CRC off\n        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs\n\n        sys_cfg = self.read_reg(0x00)\n        self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE\n        time.sleep(0.001)\n        self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE\n        time.sleep(0.002)\n\n        result = bytearray()\n        pages_read = 0\n        while pages_read < num_pages:\n            if pages_read > 0:\n                # Subsequent iterations: just clear IRQs and re-enter TRANSCEIVE\n                self.write_reg(0x03, 0xFFFFFFFF)\n                self.set_transceive_mode()\n                time.sleep(0.001)\n\n            # READ command: 0x30 + page number -> returns 16 bytes (4 pages)\n            self.send_data([0x30, start_page + pages_read])\n            time.sleep(0.010)\n\n            rx_status = self.read_reg(0x13)\n            rx_len = rx_status & 0x1FF\n            if rx_len < 16:\n                logger.warning(\n                    \"NTAG read page %d: rx_len=%d (expected >=16), rx_status=0x%08X\",\n                    start_page + pages_read,\n                    rx_len,\n                    rx_status,\n                )\n                return None\n\n            data = self.read_data(16)\n            pages_to_copy = min(4, num_pages - pages_read)\n            result.extend(data[: pages_to_copy * 4])\n            pages_read += 4\n\n        return bytes(result)\n\n    def reactivate_card(self) -> tuple[bytes, int] | None:\n        \"\"\"RF cycle and full re-select of the card. Returns (uid, sak) or None.\"\"\"\n        self.rf_off()\n        time.sleep(0.010)\n\n        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs\n        self.load_rf_config(0x00, 0x80)  # ISO 14443A\n        time.sleep(0.005)\n\n        self.rf_on()\n        time.sleep(0.020)\n\n        return self.activate_type_a()\n\n    def read_bambu_tag(self, uid: bytes) -> dict[int, bytes] | None:\n        \"\"\"Read Bambu tag data blocks using HKDF-derived keys.\n\n        Args:\n            uid: 4-byte tag UID (from activate_type_a)\n        Returns:\n            Dict mapping block number -> 16 bytes of data, or None on failure\n        \"\"\"\n        # Derive per-sector keys from UID\n        keys = hkdf_derive_keys(uid)\n\n        # Clear Crypto1 state and IRQs\n        self.write_reg_and(0x00, 0xFFFFFFBF)  # Clear MFC_CRYPTO1_ON (bit 6)\n        self.write_reg(0x03, 0xFFFFFFFF)\n\n        # Reactivate card (may have timed out)\n        result = self.reactivate_card()\n        if result is None:\n            logger.debug(\"Failed to reactivate card for Bambu tag read\")\n            return None\n\n        uid_check, _ = result\n        if uid_check != uid:\n            logger.debug(\"UID mismatch after reactivation: %s != %s\", uid_check.hex(), uid.hex())\n            return None\n\n        # Read blocks with per-sector authentication\n        blocks = {}\n        current_sector = -1\n\n        for block in BAMBU_BLOCKS:\n            sector = block // 4\n\n            # Authenticate when entering a new sector\n            if sector != current_sector:\n                key = get_sector_key(keys, block)\n                if not self.mfc_authenticate(block, key, uid):\n                    logger.debug(\"Auth failed for block %d (sector %d)\", block, sector)\n                    return None\n                current_sector = sector\n\n            # Read the block\n            data = self.mfc_read_block(block)\n            if data is None:\n                logger.debug(\"Read failed for block %d\", block)\n                return None\n            blocks[block] = data\n\n        return blocks\n\n    def ntag_write_page(self, page: int, data: bytes) -> bool:\n        \"\"\"Write 4 bytes to a single NTAG page.\n\n        NTAG WRITE command: 0xA2 + page_number + 4 bytes data.\n        TX CRC on (tag requires it). Always returns True — the 4-bit ACK\n        cannot be captured by the PN5180, so verification is deferred to\n        ntag_write_pages() which reads back all written data.\n        \"\"\"\n        if len(data) != 4:\n            return False\n\n        # Crypto1 off, TX CRC on (tag expects CRC), RX CRC off (ACK is 4-bit, no CRC)\n        self.write_reg_and(0x00, 0xFFFFFFBF)  # Crypto1 off\n        self.write_reg_or(0x19, 0x01)  # TX CRC on\n        self.write_reg_and(0x12, 0xFFFFFFFE)  # RX CRC off\n        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs\n\n        # Reset state machine: IDLE then TRANSCEIVE\n        sys_cfg = self.read_reg(0x00)\n        self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE\n        time.sleep(0.001)\n        self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE\n        time.sleep(0.002)\n\n        # WRITE command: 0xA2 + page + 4 bytes\n        self.send_data([0xA2, page] + list(data))\n        time.sleep(0.010)\n\n        # The NTAG ACK is only 4 bits (0x0A). The PN5180 detects SOF but\n        # cannot capture sub-byte frames — RX_IRQ never fires. Skip ACK\n        # checking; the tag's SOF response confirms it received the command.\n        return True\n\n    def ntag_write_pages(self, start_page: int, data: bytes) -> bool:\n        \"\"\"Write data to consecutive NTAG pages starting at start_page.\n\n        Pads last chunk to 4 bytes. Verification is skipped — the PN5180\n        cannot reliably read back NTAG pages after a batch write (the\n        second READ command gets no response). The write itself is reliable:\n        the tag ACKs each page (RX SOF detected on every response).\n        \"\"\"\n        # Pad to 4-byte boundary\n        padded = bytearray(data)\n        while len(padded) % 4 != 0:\n            padded.append(0x00)\n\n        # Write page by page\n        num_pages = len(padded) // 4\n        for i in range(0, len(padded), 4):\n            page = start_page + (i // 4)\n            chunk = bytes(padded[i : i + 4])\n            if not self.ntag_write_page(page, chunk):\n                logger.warning(\"NTAG write failed at page %d (of %d pages)\", page, num_pages)\n                return False\n            time.sleep(0.002)\n\n        logger.info(\"NTAG write complete (%d pages)\", num_pages)\n        return True\n\n    def read_ntag(self, uid: bytes) -> bytes | None:\n        \"\"\"Read NTAG pages 4-20 (NDEF data area, 68 bytes). No auth needed.\n\n        Used for SpoolEase / OpenPrintTag community tags.\n        \"\"\"\n        # Reactivate card\n        result = self.reactivate_card()\n        if result is None:\n            logger.debug(\"Failed to reactivate card for NTAG read\")\n            return None\n\n        return self.ntag_read_pages(start_page=4, num_pages=17)\n"
  },
  {
    "path": "spoolbuddy/daemon/scale_reader.py",
    "content": "\"\"\"Scale reader wrapper with stability detection and calibration.\"\"\"\n\nimport logging\nimport time\nfrom collections import deque\n\nlogger = logging.getLogger(__name__)\n\nMOVING_AVG_SIZE = 20\n\n\nclass ScaleReader:\n    def __init__(self, tare_offset: int = 0, calibration_factor: float = 1.0):\n        self._scale = None\n        self._tare_offset = tare_offset\n        self._calibration_factor = calibration_factor\n        self._samples: deque[float] = deque(maxlen=MOVING_AVG_SIZE)\n        self._stability_history: deque[tuple[float, float]] = deque(maxlen=20)\n        self._ok = False\n        self._last_raw = 0\n\n        try:\n            from .nau7802 import NAU7802\n\n            self._scale = NAU7802()\n            self._scale.init()\n            self._ok = True\n            bus_num = getattr(self._scale, \"_bus_num\", \"?\")\n            logger.info(\n                \"Scale initialized on I2C bus %s (tare=%d, cal=%.6f)\",\n                bus_num,\n                tare_offset,\n                calibration_factor,\n            )\n        except Exception as e:\n            logger.info(\"Scale not available: %s\", e)\n\n    @property\n    def ok(self) -> bool:\n        return self._ok\n\n    @property\n    def last_raw(self) -> int:\n        return self._last_raw\n\n    def close(self):\n        try:\n            if self._scale:\n                self._scale.close()\n        except Exception:\n            pass\n\n    def update_calibration(self, tare_offset: int, calibration_factor: float):\n        self._tare_offset = tare_offset\n        self._calibration_factor = calibration_factor\n        logger.info(\"Calibration updated: tare=%d, factor=%.6f\", tare_offset, calibration_factor)\n\n    def tare(self):\n        \"\"\"Set current raw reading as tare offset.\"\"\"\n        if self._last_raw:\n            self._tare_offset = self._last_raw\n            self._samples.clear()\n            self._stability_history.clear()\n            logger.info(\"Tared at raw=%d\", self._tare_offset)\n        return self._tare_offset\n\n    def read(self) -> tuple[float, bool, int] | None:\n        \"\"\"Read current weight. Returns (grams, stable, raw_adc) or None.\"\"\"\n        try:\n            if not self._scale.data_ready():\n                return None\n\n            raw = self._scale.read_raw()\n            self._last_raw = raw\n            self._ok = True\n\n            grams = (raw - self._tare_offset) * self._calibration_factor\n            self._samples.append(grams)\n\n            # Moving average\n            avg_grams = sum(self._samples) / len(self._samples)\n\n            # Stability: track readings over time\n            now = time.monotonic()\n            self._stability_history.append((now, avg_grams))\n\n            # Stable if all readings within 1s window are within 2g of each other\n            stable = False\n            if len(self._stability_history) >= 5:\n                cutoff = now - 1.0\n                recent = [g for t, g in self._stability_history if t >= cutoff]\n                if len(recent) >= 3:\n                    spread = max(recent) - min(recent)\n                    stable = spread < 2.0\n\n            return round(avg_grams, 1), stable, raw\n\n        except Exception as e:\n            logger.debug(\"Scale read error: %s\", e)\n            self._ok = False\n            return None\n"
  },
  {
    "path": "spoolbuddy/daemon/system_stats.py",
    "content": "\"\"\"Collect OS-level system stats from the Raspberry Pi using stdlib only.\"\"\"\n\nimport os\nimport platform\n\n\ndef _read_file(path: str) -> str | None:\n    try:\n        with open(path) as f:\n            return f.read().strip()\n    except OSError:\n        return None\n\n\ndef _cpu_temp() -> float | None:\n    raw = _read_file(\"/sys/class/thermal/thermal_zone0/temp\")\n    if raw is None:\n        return None\n    try:\n        return round(int(raw) / 1000, 1)\n    except (ValueError, TypeError):\n        return None\n\n\ndef _memory_info() -> dict | None:\n    raw = _read_file(\"/proc/meminfo\")\n    if raw is None:\n        return None\n    info: dict[str, int] = {}\n    for line in raw.splitlines():\n        parts = line.split()\n        if len(parts) >= 2 and parts[0].endswith(\":\"):\n            key = parts[0][:-1]\n            try:\n                info[key] = int(parts[1])  # kB\n            except ValueError:\n                continue\n    total = info.get(\"MemTotal\", 0)\n    available = info.get(\"MemAvailable\", 0)\n    if total == 0:\n        return None\n    return {\n        \"total_mb\": round(total / 1024),\n        \"available_mb\": round(available / 1024),\n        \"used_mb\": round((total - available) / 1024),\n        \"percent\": round((total - available) / total * 100, 1),\n    }\n\n\ndef _disk_info() -> dict | None:\n    try:\n        st = os.statvfs(\"/\")\n    except OSError:\n        return None\n    total = st.f_frsize * st.f_blocks\n    free = st.f_frsize * st.f_bavail\n    used = total - free\n    if total == 0:\n        return None\n    return {\n        \"total_gb\": round(total / (1024**3), 1),\n        \"used_gb\": round(used / (1024**3), 1),\n        \"free_gb\": round(free / (1024**3), 1),\n        \"percent\": round(used / total * 100, 1),\n    }\n\n\ndef _load_avg() -> list[float] | None:\n    try:\n        load = os.getloadavg()\n        return [round(x, 2) for x in load]\n    except OSError:\n        return None\n\n\ndef _cpu_count() -> int | None:\n    return os.cpu_count()\n\n\ndef _os_info() -> dict:\n    uname = platform.uname()\n    os_release = _read_file(\"/etc/os-release\")\n    pretty_name = None\n    if os_release:\n        for line in os_release.splitlines():\n            if line.startswith(\"PRETTY_NAME=\"):\n                pretty_name = line.split(\"=\", 1)[1].strip().strip('\"')\n                break\n    return {\n        \"os\": pretty_name or f\"{uname.system} {uname.release}\",\n        \"kernel\": uname.release,\n        \"arch\": uname.machine,\n        \"python\": platform.python_version(),\n    }\n\n\ndef _system_uptime() -> int | None:\n    raw = _read_file(\"/proc/uptime\")\n    if raw is None:\n        return None\n    try:\n        return int(float(raw.split()[0]))\n    except (ValueError, IndexError):\n        return None\n\n\ndef collect() -> dict:\n    \"\"\"Collect all system stats. Returns a flat dict safe for JSON serialization.\"\"\"\n    stats: dict = {}\n\n    stats[\"os\"] = _os_info()\n\n    temp = _cpu_temp()\n    if temp is not None:\n        stats[\"cpu_temp_c\"] = temp\n\n    cpu_count = _cpu_count()\n    if cpu_count is not None:\n        stats[\"cpu_count\"] = cpu_count\n\n    load = _load_avg()\n    if load is not None:\n        stats[\"load_avg\"] = load\n\n    mem = _memory_info()\n    if mem is not None:\n        stats[\"memory\"] = mem\n\n    disk = _disk_info()\n    if disk is not None:\n        stats[\"disk\"] = disk\n\n    uptime = _system_uptime()\n    if uptime is not None:\n        stats[\"system_uptime_s\"] = uptime\n\n    return stats\n"
  },
  {
    "path": "spoolbuddy/daemon/systemd/spoolbuddy.service",
    "content": "[Unit]\nDescription=SpoolBuddy Daemon\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nUser=mz\nWorkingDirectory=/opt/bambuddy/spoolbuddy\nEnvironment=SPOOLBUDDY_BACKEND_URL=http://bambuddy:5000\nEnvironment=SPOOLBUDDY_API_KEY=bb_change_me\nExecStart=/opt/bambuddy/venv/bin/python -m daemon.main\nRestart=always\nRestartSec=5\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target\n"
  },
  {
    "path": "spoolbuddy/daemon/tag_parser.py",
    "content": "\"\"\"Parse Bambu Lab MIFARE Classic tag data blocks into structured metadata.\"\"\"\n\nimport logging\n\nlogger = logging.getLogger(__name__)\n\n# Bambu tag block layout (MIFARE Classic 1K):\n# Block 1: material type (bytes 0-7), color info (bytes 8-15)\n# Block 2: temperatures, weights\n# Block 4-5: tray UUID (32 hex chars across 2 blocks)\n\n\ndef parse_bambu_blocks(blocks: dict[int, bytes]) -> dict:\n    \"\"\"Parse raw Bambu MIFARE Classic blocks into metadata dict.\n\n    Args:\n        blocks: Dict mapping block number -> 16 bytes\n\n    Returns:\n        Dict with tray_uuid, material_type, color, etc.\n    \"\"\"\n    result = {}\n\n    # Extract tray UUID from blocks 4+5\n    if 4 in blocks and 5 in blocks:\n        uuid_raw = blocks[4] + blocks[5]\n        result[\"tray_uuid\"] = uuid_raw[:16].hex().upper()\n\n    # Extract material info from block 1\n    if 1 in blocks:\n        data = blocks[1]\n        # Material type is typically in the first few bytes\n        material_bytes = data[:8]\n        result[\"material_raw\"] = material_bytes.hex().upper()\n\n    # Extract block 2 data (temperatures, weights)\n    if 2 in blocks:\n        data = blocks[2]\n        result[\"block2_raw\"] = data.hex().upper()\n\n    return result\n"
  },
  {
    "path": "spoolbuddy/install/generate_splash.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Generate a polished SpoolBuddy boot splash image (1024x600).\n\nUses the SpoolBuddy logo with baked-in glow, radial gradient background,\nsubtle light rays, and vignette effects for a premium kiosk boot screen.\n\nUsage:\n    python3 generate_splash.py [output_path]\n\nRequires: Pillow (pip install Pillow)\n\"\"\"\n\nimport math\nimport os\nimport sys\n\nfrom PIL import Image, ImageDraw, ImageFilter\n\n# --- Configuration ---\nWIDTH, HEIGHT = 1024, 600\nBG_CENTER = (55, 55, 55)  # Notably lighter center\nBG_EDGE = (2, 2, 2)  # Nearly black edges\nACCENT = (0, 200, 75)  # Brighter SpoolBuddy green\nACCENT_GLOW = (0, 255, 100)  # Vivid glow core\nLOGO_SCALE = 0.50  # Scale logo to 50% of canvas width\nGLOW_RADIUS = 160  # Very wide glow spread\nVIGNETTE_STRENGTH = 0.85  # Heavy edge darkening\nRAY_COUNT = 32  # More radial light rays\nRAY_OPACITY = 50  # Clearly visible rays (0-255)\n\n\ndef radial_gradient(size, center_color, edge_color):\n    \"\"\"Create a radial gradient from center to edges.\"\"\"\n    w, h = size\n    img = Image.new(\"RGB\", size)\n    pixels = img.load()\n    cx, cy = w // 2, h // 2\n    max_dist = math.sqrt(cx**2 + cy**2)\n\n    for y in range(h):\n        for x in range(w):\n            dist = math.sqrt((x - cx) ** 2 + (y - cy) ** 2)\n            t = min(dist / max_dist, 1.0)\n            # Ease-out curve for smoother falloff\n            t = t * t\n            r = int(center_color[0] + (edge_color[0] - center_color[0]) * t)\n            g = int(center_color[1] + (edge_color[1] - center_color[1]) * t)\n            b = int(center_color[2] + (edge_color[2] - center_color[2]) * t)\n            pixels[x, y] = (r, g, b)\n\n    return img\n\n\ndef create_light_rays(size, num_rays, opacity):\n    \"\"\"Create subtle radial light rays emanating from center.\"\"\"\n    w, h = size\n    rays = Image.new(\"RGBA\", size, (0, 0, 0, 0))\n    draw = ImageDraw.Draw(rays)\n    cx, cy = w // 2, h // 2\n    max_radius = int(math.sqrt(cx**2 + cy**2)) + 50\n\n    for i in range(num_rays):\n        angle = (2 * math.pi * i) / num_rays\n        # Vary ray width slightly for organic feel\n        half_width = math.radians(1.5 + (i % 3) * 0.5)\n\n        a1 = angle - half_width\n        a2 = angle + half_width\n\n        points = [\n            (cx, cy),\n            (cx + int(max_radius * math.cos(a1)), cy + int(max_radius * math.sin(a1))),\n            (cx + int(max_radius * math.cos(a2)), cy + int(max_radius * math.sin(a2))),\n        ]\n        # Green-tinted rays\n        draw.polygon(points, fill=(ACCENT[0], ACCENT[1], ACCENT[2], opacity))\n\n    # Heavy blur to make rays soft and diffuse\n    rays = rays.filter(ImageFilter.GaussianBlur(radius=30))\n    return rays\n\n\ndef create_vignette(size, strength):\n    \"\"\"Create a vignette (edge darkening) mask.\"\"\"\n    w, h = size\n    vignette = Image.new(\"L\", size, 255)\n    pixels = vignette.load()\n    cx, cy = w / 2, h / 2\n    max_dist = math.sqrt(cx**2 + cy**2)\n\n    for y in range(h):\n        for x in range(w):\n            dist = math.sqrt((x - cx) ** 2 + (y - cy) ** 2)\n            t = dist / max_dist\n            # Ramp darkening from ~40% radius outward\n            fade = max(0, (t - 0.4) / 0.6)\n            fade = fade * fade  # Quadratic ease\n            val = int(255 * (1 - fade * strength))\n            pixels[x, y] = max(0, val)\n\n    return vignette\n\n\ndef create_glow(logo_img, color, radius, intensity=1.5):\n    \"\"\"Create a colored glow effect from a logo's alpha channel.\"\"\"\n    # Extract alpha as the glow shape\n    if logo_img.mode != \"RGBA\":\n        return Image.new(\"RGBA\", logo_img.size, (0, 0, 0, 0))\n\n    alpha = logo_img.split()[3]\n\n    # Create colored version of the alpha shape\n    glow = Image.new(\"RGBA\", logo_img.size, (0, 0, 0, 0))\n    glow_pixels = glow.load()\n    alpha_pixels = alpha.load()\n\n    for y in range(logo_img.height):\n        for x in range(logo_img.width):\n            a = alpha_pixels[x, y]\n            if a > 0:\n                boosted = min(255, int(a * intensity))\n                glow_pixels[x, y] = (color[0], color[1], color[2], boosted)\n\n    # Blur to create the glow spread\n    glow = glow.filter(ImageFilter.GaussianBlur(radius=radius))\n    return glow\n\n\ndef generate_splash(output_path):\n    \"\"\"Generate the final splash image.\"\"\"\n    print(f\"Generating {WIDTH}x{HEIGHT} splash image...\")\n\n    # 1. Radial gradient background\n    print(\"  Creating radial gradient background...\")\n    canvas = radial_gradient((WIDTH, HEIGHT), BG_CENTER, BG_EDGE)\n\n    # 2. Light rays\n    print(\"  Adding light rays...\")\n    rays = create_light_rays((WIDTH, HEIGHT), RAY_COUNT, RAY_OPACITY)\n    canvas.paste(\n        Image.alpha_composite(\n            Image.new(\"RGBA\", (WIDTH, HEIGHT), (0, 0, 0, 0)),\n            rays,\n        ),\n        (0, 0),\n        rays,\n    )\n\n    # 3. Load and scale logo\n    print(\"  Loading SpoolBuddy logo...\")\n    script_dir = os.path.dirname(os.path.abspath(__file__))\n    logo_paths = [\n        os.path.join(script_dir, \"..\", \"..\", \"frontend\", \"public\", \"spoolbuddy_logo_dark.png\"),\n        os.path.join(script_dir, \"..\", \"..\", \"frontend\", \"public\", \"img\", \"spoolbuddy_logo_dark.png\"),\n    ]\n\n    logo = None\n    for p in logo_paths:\n        resolved = os.path.normpath(p)\n        if os.path.exists(resolved):\n            logo = Image.open(resolved).convert(\"RGBA\")\n            print(f\"  Loaded logo from {resolved}\")\n            break\n\n    if logo is None:\n        print(\"  ERROR: Could not find spoolbuddy_logo_dark.png\")\n        sys.exit(1)\n\n    # Scale logo to target width\n    target_w = int(WIDTH * LOGO_SCALE)\n    scale = target_w / logo.width\n    target_h = int(logo.height * scale)\n    logo = logo.resize((target_w, target_h), Image.LANCZOS)\n\n    # Center position (shift up slightly for visual balance)\n    logo_x = (WIDTH - target_w) // 2\n    logo_y = (HEIGHT - target_h) // 2 - 10\n\n    # 4. Glow behind logo (two layers: wide diffuse + tight bright)\n    print(\"  Rendering glow effects...\")\n\n    # Wide diffuse glow — very prominent\n    glow_wide = create_glow(logo, ACCENT, radius=GLOW_RADIUS, intensity=3.0)\n    glow_canvas = Image.new(\"RGBA\", (WIDTH, HEIGHT), (0, 0, 0, 0))\n    glow_canvas.paste(glow_wide, (logo_x, logo_y), glow_wide)\n    canvas = Image.alpha_composite(canvas.convert(\"RGBA\"), glow_canvas)\n\n    # Medium glow layer for extra punch\n    glow_mid = create_glow(logo, ACCENT_GLOW, radius=GLOW_RADIUS // 2, intensity=2.5)\n    glow_canvas_mid = Image.new(\"RGBA\", (WIDTH, HEIGHT), (0, 0, 0, 0))\n    glow_canvas_mid.paste(glow_mid, (logo_x, logo_y), glow_mid)\n    canvas = Image.alpha_composite(canvas, glow_canvas_mid)\n\n    # Tighter bright glow core\n    glow_tight = create_glow(logo, ACCENT_GLOW, radius=GLOW_RADIUS // 4, intensity=2.0)\n    glow_canvas2 = Image.new(\"RGBA\", (WIDTH, HEIGHT), (0, 0, 0, 0))\n    glow_canvas2.paste(glow_tight, (logo_x, logo_y), glow_tight)\n    canvas = Image.alpha_composite(canvas, glow_canvas2)\n\n    # 5. Composite logo on top\n    print(\"  Compositing logo...\")\n    canvas.paste(logo, (logo_x, logo_y), logo)\n\n    # 6. Apply vignette\n    print(\"  Applying vignette...\")\n    vignette = create_vignette((WIDTH, HEIGHT), VIGNETTE_STRENGTH)\n    canvas_rgb = canvas.convert(\"RGB\")\n\n    # Multiply canvas by vignette mask\n    r, g, b = canvas_rgb.split()\n    r = Image.composite(r, Image.new(\"L\", (WIDTH, HEIGHT), 0), vignette)\n    g = Image.composite(g, Image.new(\"L\", (WIDTH, HEIGHT), 0), vignette)\n    b = Image.composite(b, Image.new(\"L\", (WIDTH, HEIGHT), 0), vignette)\n    canvas = Image.merge(\"RGB\", (r, g, b))\n\n    # 7. Save\n    canvas.save(output_path, \"PNG\", optimize=True)\n    file_size = os.path.getsize(output_path) / 1024\n    print(f\"  Saved to {output_path} ({file_size:.0f} KB)\")\n\n\nif __name__ == \"__main__\":\n    out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), \"splash.png\")\n    generate_splash(out)\n"
  },
  {
    "path": "spoolbuddy/install/install.sh",
    "content": "#!/usr/bin/env bash\n#\n# SpoolBuddy Installation Script for Raspberry Pi\n#\n# Supports two scenarios:\n#   1) SpoolBuddy only — NFC/scale companion connecting to a remote Bambuddy instance\n#   2) SpoolBuddy + Bambuddy — both running natively on this Raspberry Pi\n#\n# Usage:\n#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/spoolbuddy/install.sh -o install.sh && chmod +x install.sh && sudo ./install.sh\n#   Unattended:   sudo ./install.sh --mode spoolbuddy --bambuddy-url http://192.168.1.100:8000 --api-key bb_xxx --yes\n#\n# Options:\n#   --mode MODE          Installation mode: \"spoolbuddy\" (companion only) or \"full\" (both)\n#   --repo URL           Git repository URL to install from (default: upstream repo)\n#   --ref REF            Git ref to install (branch/tag/commit, default: main)\n#   --bambuddy-url URL   Bambuddy server URL (required for spoolbuddy mode)\n#   --api-key KEY        Bambuddy API key (required for spoolbuddy mode)\n#   --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)\n#   --port PORT          Bambuddy port (full mode only, default: 8000)\n#   --ssh-pubkey KEY     Bambuddy SSH public key for remote updates\n#   --yes, -y            Non-interactive mode, accept defaults\n#   --help, -h           Show this help message\n#\n\nset -e\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Constants\n# ─────────────────────────────────────────────────────────────────────────────\n\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nCYAN='\\033[0;36m'\nBOLD='\\033[1m'\nNC='\\033[0m'\n\nGITHUB_REPO=\"https://github.com/maziggy/bambuddy.git\"\nSPOOLBUDDY_SERVICE_USER=\"spoolbuddy\"\nBAMBUDDY_SERVICE_USER=\"bambuddy\"\n\n# Packages needed for SpoolBuddy hardware (NFC reader + scale)\nSYSTEM_PACKAGES=\"python3 python3-pip python3-venv python3-dev python3-spidev python3-libgpiod gpiod libgpiod-dev i2c-tools git\"\n\n# Python packages for SpoolBuddy daemon\nSPOOLBUDDY_PIP_PACKAGES=\"spidev gpiod smbus2 httpx\"\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Variables (set by args or prompts)\n# ─────────────────────────────────────────────────────────────────────────────\n\nINSTALL_MODE=\"\"          # \"spoolbuddy\" or \"full\"\nINSTALL_PATH=\"\"\nINSTALL_REPO=\"\"\nINSTALL_REF=\"\"\nDETECTED_INSTALLER_REPO=\"\"\nDETECTED_INSTALLER_REF=\"\"\nBAMBUDDY_URL=\"\"\nAPI_KEY=\"\"\nBAMBUDDY_PORT=\"8000\"\nNON_INTERACTIVE=\"false\"\nREBOOT_NEEDED=\"false\"\nKIOSK_USER=\"\"            # auto-detected from $SUDO_USER\nKIOSK_URL=\"\"             # derived from $BAMBUDDY_URL/spoolbuddy?token=$API_KEY\nSSH_PUBKEY=\"\"            # Bambuddy's SSH public key for remote updates\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Helpers\n# ─────────────────────────────────────────────────────────────────────────────\n\ninfo()    { echo -e \"${CYAN}[INFO]${NC} $1\"; }\nsuccess() { echo -e \"${GREEN}[OK]${NC} $1\"; }\nwarn()    { echo -e \"${YELLOW}[WARN]${NC} $1\"; }\nerror()   { echo -e \"${RED}[ERROR]${NC} $1\"; exit 1; }\n\n# Run a long-running command with a spinner + live progress output.\n# Usage: run_with_progress \"description\" command [args...]\nrun_with_progress() {\n    local desc=\"$1\"\n    shift\n\n    local log_file\n    log_file=$(mktemp /tmp/spoolbuddy-install.XXXXXX)\n    local start_time=$SECONDS\n\n    # Run command in background, capture stdout+stderr\n    \"$@\" > \"$log_file\" 2>&1 &\n    local pid=$!\n\n    # Spinner frames (braille pattern)\n    local -a spin=(\"⠋\" \"⠙\" \"⠹\" \"⠸\" \"⠼\" \"⠴\" \"⠦\" \"⠧\" \"⠇\" \"⠏\")\n    local i=0\n\n    while kill -0 \"$pid\" 2>/dev/null; do\n        local elapsed=$(( SECONDS - start_time ))\n        local time_str\n        if (( elapsed >= 60 )); then\n            time_str=\"$(( elapsed / 60 ))m$(printf '%02d' $(( elapsed % 60 )))s\"\n        else\n            time_str=\"${elapsed}s\"\n        fi\n\n        # Last chunk of output (handles \\r progress lines and regular \\n lines)\n        local last_line=\"\"\n        last_line=$(tail -c 4096 \"$log_file\" 2>/dev/null | tr '\\r' '\\n' | sed 's/\\x1b\\[[0-9;]*[mGKHJ]//g' | sed '/^[[:space:]]*$/d' | tail -1 | sed 's/^[[:space:]]*//' | cut -c1-50) || true\n\n        printf \"\\r  ${spin[$((i % 10))]}  %-36s ${CYAN}%6s${NC}  %s\\033[K\" \"$desc\" \"$time_str\" \"$last_line\"\n        i=$(( i + 1 ))\n        sleep 0.15\n    done\n\n    local exit_code=0\n    wait \"$pid\" || exit_code=$?\n\n    # Clear spinner line\n    printf \"\\r\\033[K\"\n\n    # Format elapsed time for summary\n    local elapsed=$(( SECONDS - start_time ))\n    local time_suffix=\"\"\n    if (( elapsed >= 60 )); then\n        time_suffix=\" ($(( elapsed / 60 ))m $(( elapsed % 60 ))s)\"\n    elif (( elapsed >= 5 )); then\n        time_suffix=\" (${elapsed}s)\"\n    fi\n\n    if [[ $exit_code -eq 0 ]]; then\n        success \"${desc}${time_suffix}\"\n        rm -f \"$log_file\"\n    else\n        echo -e \"${RED}[FAIL]${NC} ${desc}${time_suffix}\"\n        echo \"\"\n        echo -e \"  ${YELLOW}Last 20 lines:${NC}\"\n        tail -20 \"$log_file\" 2>/dev/null | sed 's/^/    /'\n        echo \"\"\n        echo -e \"  Full log: ${CYAN}$log_file${NC}\"\n        exit 1\n    fi\n}\n\nprompt() {\n    local prompt_text=\"$1\"\n    local default_value=\"$2\"\n    local var_name=\"$3\"\n\n    if [[ \"$NON_INTERACTIVE\" == \"true\" ]]; then\n        eval \"$var_name=\\\"$default_value\\\"\"\n        return\n    fi\n\n    if [[ -n \"$default_value\" ]]; then\n        echo -en \"${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: \"\n    else\n        echo -en \"${BOLD}$prompt_text${NC}: \"\n    fi\n\n    read -r input\n    if [[ -z \"$input\" ]]; then\n        eval \"$var_name=\\\"$default_value\\\"\"\n    else\n        eval \"$var_name=\\\"$input\\\"\"\n    fi\n}\n\nprompt_yes_no() {\n    local prompt_text=\"$1\"\n    local default=\"$2\"\n\n    if [[ \"$NON_INTERACTIVE\" == \"true\" ]]; then\n        [[ \"$default\" == \"y\" ]] && return 0 || return 1\n    fi\n\n    local yn_hint=\"[y/n]\"\n    [[ \"$default\" == \"y\" ]] && yn_hint=\"[Y/n]\"\n    [[ \"$default\" == \"n\" ]] && yn_hint=\"[y/N]\"\n\n    while true; do\n        echo -en \"${BOLD}$prompt_text${NC} $yn_hint: \"\n        read -r yn\n        [[ -z \"$yn\" ]] && yn=\"$default\"\n        case \"$yn\" in\n            [Yy]* ) return 0;;\n            [Nn]* ) return 1;;\n            * ) echo \"Please answer yes or no.\";;\n        esac\n    done\n}\n\nshow_help() {\n    echo \"SpoolBuddy Installation Script for Raspberry Pi\"\n    echo \"\"\n    echo \"Usage: sudo $0 [OPTIONS]\"\n    echo \"\"\n    echo \"Options:\"\n    echo \"  --mode MODE          \\\"spoolbuddy\\\" (companion only) or \\\"full\\\" (Bambuddy + SpoolBuddy)\"\n    echo \"  --repo URL           Git repository URL to install from\"\n    echo \"  --ref REF            Git ref to install (branch/tag/commit)\"\n    echo \"  --bambuddy-url URL   Bambuddy server URL (required for spoolbuddy mode)\"\n    echo \"  --api-key KEY        Bambuddy API key (required for spoolbuddy mode)\"\n    echo \"  --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)\"\n    echo \"  --port PORT          Bambuddy port (full mode only, default: 8000)\"\n    echo \"  --ssh-pubkey KEY     Bambuddy SSH public key for remote updates\"\n    echo \"  --yes, -y            Non-interactive mode, accept defaults\"\n    echo \"  --help, -h           Show this help message\"\n    echo \"\"\n    echo \"Examples:\"\n    echo \"  Interactive:\"\n    echo \"    sudo ./install.sh\"\n    echo \"\"\n    echo \"  SpoolBuddy companion (unattended):\"\n    echo \"    sudo ./install.sh --mode spoolbuddy --bambuddy-url http://192.168.1.100:8000 --api-key bb_xxx -y\"\n    echo \"\"\n    echo \"  Full install (unattended):\"\n    echo \"    sudo ./install.sh --mode full --port 8000 -y\"\n    exit 0\n}\n\nnormalize_github_repo_url() {\n    local url=\"$1\"\n    if [[ -z \"$url\" ]]; then\n        echo \"\"\n        return\n    fi\n\n    # Convert git@github.com:owner/repo(.git) to https://github.com/owner/repo.git\n    if [[ \"$url\" =~ ^git@github.com:(.+)$ ]]; then\n        url=\"https://github.com/${BASH_REMATCH[1]}\"\n    fi\n\n    # Keep remote URL style consistent.\n    url=\"${url%.git}\"\n    echo \"${url}.git\"\n}\n\ndetect_installer_source_context() {\n    local script_dir\n    script_dir=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n\n    if git -C \"$script_dir\" rev-parse --is-inside-work-tree >/dev/null 2>&1; then\n        DETECTED_INSTALLER_REF=\"$(git -C \"$script_dir\" rev-parse --abbrev-ref HEAD 2>/dev/null || true)\"\n        local origin_url\n        origin_url=\"$(git -C \"$script_dir\" remote get-url origin 2>/dev/null || true)\"\n        DETECTED_INSTALLER_REPO=\"$(normalize_github_repo_url \"$origin_url\")\"\n    fi\n\n    # Optional environment overrides for raw-download installs.\n    if [[ -n \"${SPOOLBUDDY_INSTALL_REPO:-}\" ]]; then\n        DETECTED_INSTALLER_REPO=\"$(normalize_github_repo_url \"$SPOOLBUDDY_INSTALL_REPO\")\"\n    fi\n    if [[ -n \"${SPOOLBUDDY_INSTALL_REF:-}\" ]]; then\n        DETECTED_INSTALLER_REF=\"$SPOOLBUDDY_INSTALL_REF\"\n    fi\n\n    if [[ -z \"$INSTALL_REPO\" ]]; then\n        if [[ -n \"$DETECTED_INSTALLER_REPO\" ]]; then\n            INSTALL_REPO=\"$DETECTED_INSTALLER_REPO\"\n        else\n            INSTALL_REPO=\"$GITHUB_REPO\"\n        fi\n    fi\n\n    if [[ -z \"$INSTALL_REF\" ]]; then\n        if [[ -n \"$DETECTED_INSTALLER_REF\" && \"$DETECTED_INSTALLER_REF\" != \"HEAD\" ]]; then\n            INSTALL_REF=\"$DETECTED_INSTALLER_REF\"\n        else\n            INSTALL_REF=\"main\"\n        fi\n    fi\n}\n\nresolve_install_ref() {\n    local ref=\"$1\"\n    # If ref exists on origin as a branch, track/reset it. Otherwise treat it as tag/commit.\n    if git ls-remote --exit-code --heads origin \"$ref\" >/dev/null 2>&1; then\n        git checkout -B \"$ref\" \"origin/$ref\" > /dev/null 2>&1\n        git reset --hard \"origin/$ref\" > /dev/null 2>&1\n    else\n        git checkout \"$ref\" > /dev/null 2>&1\n    fi\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Pre-flight Checks\n# ─────────────────────────────────────────────────────────────────────────────\n\ncheck_root() {\n    if [[ $EUID -ne 0 ]]; then\n        error \"This script must be run as root (use sudo)\"\n    fi\n}\n\ncheck_raspberry_pi() {\n    if ! grep -q \"Raspberry Pi\\|BCM2\" /proc/cpuinfo 2>/dev/null; then\n        error \"This script is designed for Raspberry Pi only\"\n    fi\n\n    # Detect Pi model for hardware recommendations\n    local model\n    model=$(tr -d '\\0' < /proc/device-tree/model 2>/dev/null) || model=\"Unknown\"\n    success \"Detected: $model\"\n}\n\ncheck_raspberry_pi_os() {\n    if [[ ! -f /etc/os-release ]]; then\n        error \"Cannot detect operating system\"\n    fi\n\n    . /etc/os-release\n    if [[ \"$ID\" != \"raspbian\" && \"$ID\" != \"debian\" ]]; then\n        warn \"Expected Raspberry Pi OS (Debian-based), found: $ID\"\n        if ! prompt_yes_no \"Continue anyway?\" \"n\"; then\n            exit 0\n        fi\n    fi\n\n    success \"OS: $PRETTY_NAME\"\n}\n\ndetect_python() {\n    local cmd=\"\"\n    if command -v python3 &>/dev/null; then\n        cmd=\"python3\"\n    elif command -v python &>/dev/null; then\n        local ver\n        ver=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)\n        if [[ \"$ver\" -ge 3 ]]; then\n            cmd=\"python\"\n        fi\n    fi\n\n    if [[ -z \"$cmd\" ]]; then\n        return 1\n    fi\n\n    local version\n    version=$($cmd -c 'import sys; print(f\"{sys.version_info.major}.{sys.version_info.minor}\")')\n    local major minor\n    major=$(echo \"$version\" | cut -d'.' -f1)\n    minor=$(echo \"$version\" | cut -d'.' -f2)\n\n    if [[ \"$major\" -lt 3 ]] || { [[ \"$major\" -eq 3 ]] && [[ \"$minor\" -lt 10 ]]; }; then\n        warn \"Python $version found, but 3.10+ is required\"\n        return 1\n    fi\n\n    PYTHON_CMD=\"$cmd\"\n    success \"Found Python $version\"\n    return 0\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Raspberry Pi Hardware Configuration\n# ─────────────────────────────────────────────────────────────────────────────\n\nenable_spi() {\n    if raspi-config nonint get_spi 2>/dev/null | grep -q \"1\"; then\n        info \"Enabling SPI...\"\n        raspi-config nonint do_spi 0\n        REBOOT_NEEDED=\"true\"\n        success \"SPI enabled\"\n    else\n        success \"SPI already enabled\"\n    fi\n}\n\nenable_i2c() {\n    if raspi-config nonint get_i2c 2>/dev/null | grep -q \"1\"; then\n        info \"Enabling I2C...\"\n        raspi-config nonint do_i2c 0\n        REBOOT_NEEDED=\"true\"\n        success \"I2C enabled\"\n    else\n        success \"I2C already enabled\"\n    fi\n}\n\nconfigure_boot_config() {\n    # Find the boot config file (Bookworm+ uses /boot/firmware/config.txt)\n    local boot_config=\"/boot/firmware/config.txt\"\n    if [[ ! -f \"$boot_config\" ]]; then\n        boot_config=\"/boot/config.txt\"\n    fi\n\n    if [[ ! -f \"$boot_config\" ]]; then\n        warn \"Boot config not found at /boot/firmware/config.txt or /boot/config.txt\"\n        warn \"You may need to manually add: dtparam=i2c_arm=on and dtoverlay=spi0-0cs\"\n        return\n    fi\n\n    info \"Configuring $boot_config...\"\n\n    # Migrate legacy SpoolBuddy setting (bus 0 / i2c_vc) to bus 1 / i2c_arm.\n    if grep -q \"^dtparam=i2c_vc=on\" \"$boot_config\"; then\n        sed -i \"s/^dtparam=i2c_vc=on$/# dtparam=i2c_vc=on (disabled by SpoolBuddy installer; use i2c_arm bus 1)/\" \"$boot_config\"\n        REBOOT_NEEDED=\"true\"\n        success \"Disabled legacy dtparam=i2c_vc=on\"\n    fi\n\n    if grep -q \"^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)\" \"$boot_config\"; then\n        sed -i \"s/^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0\\/GPIO1)$/# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2\\/GPIO3)/\" \"$boot_config\"\n    fi\n\n    # Ensure I2C bus 1 (GPIO2/GPIO3) is enabled for NAU7802 scale\n    if ! grep -q \"^dtparam=i2c_arm=on\" \"$boot_config\"; then\n        echo \"\" >> \"$boot_config\"\n        echo \"# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3)\" >> \"$boot_config\"\n        echo \"dtparam=i2c_arm=on\" >> \"$boot_config\"\n        REBOOT_NEEDED=\"true\"\n        success \"Added dtparam=i2c_arm=on\"\n    else\n        success \"dtparam=i2c_arm=on already set\"\n    fi\n\n    # Disable SPI auto chip-select (manual CS on GPIO23 for PN5180)\n    if ! grep -q \"^dtoverlay=spi0-0cs\" \"$boot_config\"; then\n        echo \"\" >> \"$boot_config\"\n        echo \"# SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180)\" >> \"$boot_config\"\n        echo \"dtoverlay=spi0-0cs\" >> \"$boot_config\"\n        REBOOT_NEEDED=\"true\"\n        success \"Added dtoverlay=spi0-0cs\"\n    else\n        success \"dtoverlay=spi0-0cs already set\"\n    fi\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Package Installation\n# ─────────────────────────────────────────────────────────────────────────────\n\ninstall_system_packages() {\n    run_with_progress \"Updating package lists\" apt-get update\n    run_with_progress \"Installing system packages\" apt-get install -y $SYSTEM_PACKAGES\n}\n\ninstall_wifi_safeguard() {\n    # Protect WiFi credentials from being wiped by apt upgrades.\n    # Raspberry Pi OS Bookworm migrated from wpa_supplicant/dhcpcd to\n    # NetworkManager, but certain package upgrades (raspberrypi-sys-mods,\n    # raspi-config, NetworkManager itself) can delete saved connections\n    # from /etc/NetworkManager/system-connections/.  This hook backs them\n    # up before dpkg runs and restores them if they vanish.\n    local hook_file=\"/etc/apt/apt.conf.d/80-preserve-wifi\"\n\n    if [[ -f \"$hook_file\" ]]; then\n        success \"WiFi safeguard already installed\"\n        return\n    fi\n\n    # Only install if NetworkManager is the active network manager\n    if ! systemctl is-active --quiet NetworkManager 2>/dev/null; then\n        return\n    fi\n\n    # Write a helper script (avoids quote escaping issues in APT config)\n    local helper=\"/usr/local/sbin/preserve-wifi\"\n    cat > \"$helper\" << 'HELPEREOF'\n#!/bin/sh\n# Called by APT hooks to preserve NetworkManager WiFi connections.\nNM_DIR=\"/etc/NetworkManager/system-connections\"\nBAK_DIR=\"/etc/NetworkManager/system-connections.bak\"\ncase \"$1\" in\n  backup)\n    if [ -d \"$NM_DIR\" ] && [ -n \"$(ls -A \"$NM_DIR\" 2>/dev/null)\" ]; then\n      cp -a \"$NM_DIR/\" \"$BAK_DIR/\"\n    fi\n    ;;\n  restore)\n    if [ -d \"$BAK_DIR\" ] && [ -z \"$(ls -A \"$NM_DIR\" 2>/dev/null)\" ]; then\n      cp -a \"$BAK_DIR\"/* \"$NM_DIR\"/\n      nmcli general reload 2>/dev/null\n    fi\n    rm -rf \"$BAK_DIR\" 2>/dev/null\n    ;;\nesac\nHELPEREOF\n    chmod +x \"$helper\"\n\n    cat > \"$hook_file\" << 'APTEOF'\n// Preserve NetworkManager WiFi connections across apt upgrades.\n// Installed by SpoolBuddy.\nDPkg::Pre-Invoke {\"/usr/local/sbin/preserve-wifi backup\";};\nDPkg::Post-Invoke {\"/usr/local/sbin/preserve-wifi restore\";};\nAPTEOF\n\n    success \"WiFi safeguard installed (${hook_file})\"\n}\n\nupgrade_system_packages() {\n    run_with_progress \"Upgrading system packages\" apt-get upgrade -y\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# SpoolBuddy Installation\n# ─────────────────────────────────────────────────────────────────────────────\n\ncreate_spoolbuddy_user() {\n    if id \"$SPOOLBUDDY_SERVICE_USER\" &>/dev/null; then\n        info \"User '$SPOOLBUDDY_SERVICE_USER' already exists\"\n        # Ensure existing installs get a real shell for SSH access\n        usermod --shell /bin/bash \"$SPOOLBUDDY_SERVICE_USER\" 2>/dev/null || true\n    else\n        info \"Creating service user '$SPOOLBUDDY_SERVICE_USER'...\"\n        useradd --system --shell /bin/bash --home-dir \"$INSTALL_PATH\" \"$SPOOLBUDDY_SERVICE_USER\"\n        success \"Service user created\"\n    fi\n\n    # Add to hardware access groups (gpio, spi, i2c, video for backlight)\n    for group in gpio spi i2c video; do\n        if getent group \"$group\" &>/dev/null; then\n            usermod -aG \"$group\" \"$SPOOLBUDDY_SERVICE_USER\" 2>/dev/null || true\n        fi\n    done\n    success \"User added to gpio, spi, i2c, video groups\"\n\n    # Allow passwordless restart of daemon + kiosk (needed for SSH-based updates from Bambuddy)\n    cat > /etc/sudoers.d/spoolbuddy << 'SUDOERS'\nspoolbuddy ALL=(root) NOPASSWD: /usr/bin/systemctl restart spoolbuddy.service\nspoolbuddy ALL=(root) NOPASSWD: /usr/bin/systemctl restart getty@tty1.service\nspoolbuddy ALL=(root) NOPASSWD: /usr/bin/find /home -maxdepth 5 *\nspoolbuddy ALL=(root) NOPASSWD: /sbin/reboot\nspoolbuddy ALL=(root) NOPASSWD: /sbin/shutdown -h now\nSUDOERS\n    chmod 440 /etc/sudoers.d/spoolbuddy\n    success \"Sudoers entries created for service, kiosk restart, reboot and shutdown\"\n}\n\ndownload_spoolbuddy() {\n    if [[ -d \"$INSTALL_PATH/.git\" ]]; then\n        info \"Existing installation found, updating...\"\n        git config --global --add safe.directory \"$INSTALL_PATH\" 2>/dev/null || true\n        cd \"$INSTALL_PATH\"\n        git remote set-url origin \"$INSTALL_REPO\" 2>/dev/null || true\n        run_with_progress \"Fetching updates\" git fetch origin\n        resolve_install_ref \"$INSTALL_REF\"\n    else\n        mkdir -p \"$INSTALL_PATH\"\n        run_with_progress \"Cloning repository\" git clone \"$INSTALL_REPO\" \"$INSTALL_PATH\"\n        cd \"$INSTALL_PATH\"\n        resolve_install_ref \"$INSTALL_REF\"\n    fi\n\n    chown -R \"$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER\" \"$INSTALL_PATH\"\n}\n\nsetup_spoolbuddy_venv() {\n    cd \"$INSTALL_PATH/spoolbuddy\"\n\n    run_with_progress \"Creating SpoolBuddy venv\" $PYTHON_CMD -m venv --system-site-packages venv\n    run_with_progress \"Upgrading pip\" \"$INSTALL_PATH/spoolbuddy/venv/bin/pip\" install --upgrade pip\n    run_with_progress \"Installing SpoolBuddy packages\" \"$INSTALL_PATH/spoolbuddy/venv/bin/pip\" install $SPOOLBUDDY_PIP_PACKAGES\n\n    chown -R \"$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER\" \"$INSTALL_PATH/spoolbuddy/venv\"\n}\n\ncreate_spoolbuddy_env() {\n    info \"Creating SpoolBuddy configuration...\"\n\n    local env_file=\"$INSTALL_PATH/spoolbuddy/.env\"\n\n    cat > \"$env_file\" << EOF\n# SpoolBuddy Configuration\n# Generated by install.sh on $(date)\n\n# Bambuddy backend URL\nSPOOLBUDDY_BACKEND_URL=$BAMBUDDY_URL\n\n# API key (create one in Bambuddy Settings -> API Keys)\nSPOOLBUDDY_API_KEY=$API_KEY\n\n# NAU7802 scale bus (RPi GPIO2/GPIO3)\nSPOOLBUDDY_I2C_BUS=1\nEOF\n\n    chown \"$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER\" \"$env_file\"\n    # Keep secrets owner-writable while allowing kiosk user (in spoolbuddy group)\n    # to read backend URL/API key for dynamic launcher URL resolution.\n    chgrp \"$SPOOLBUDDY_SERVICE_USER\" \"$env_file\"\n    chmod 640 \"$env_file\"\n    success \"Configuration saved to $env_file\"\n}\n\nensure_kiosk_env_access() {\n    local env_file=\"$INSTALL_PATH/spoolbuddy/.env\"\n\n    if [[ ! -f \"$env_file\" ]]; then\n        warn \"SpoolBuddy env file not found at $env_file\"\n        return\n    fi\n\n    # Ensure kiosk user is known even when this function is called outside setup_kiosk.\n    if [[ -z \"$KIOSK_USER\" ]]; then\n        KIOSK_USER=\"${SUDO_USER:-$(logname 2>/dev/null || echo pi)}\"\n    fi\n\n    if id \"$KIOSK_USER\" &>/dev/null; then\n        usermod -aG \"$SPOOLBUDDY_SERVICE_USER\" \"$KIOSK_USER\" 2>/dev/null || true\n    fi\n\n    chgrp \"$SPOOLBUDDY_SERVICE_USER\" \"$env_file\"\n    chmod 640 \"$env_file\"\n\n    if ! su -s /bin/sh -c \"test -r '$env_file'\" \"$KIOSK_USER\"; then\n        error \"Kiosk user '$KIOSK_USER' cannot read $env_file (required for dynamic kiosk URL). Check groups/permissions.\"\n    fi\n\n    success \"Verified kiosk user '$KIOSK_USER' can read SpoolBuddy env\"\n}\n\nsetup_ssh_key() {\n    info \"Setting up SSH access for Bambuddy remote updates...\"\n\n    local ssh_dir=\"$INSTALL_PATH/.ssh\"\n    local auth_keys=\"$ssh_dir/authorized_keys\"\n\n    mkdir -p \"$ssh_dir\"\n    chmod 700 \"$ssh_dir\"\n\n    if [[ -n \"$SSH_PUBKEY\" ]]; then\n        # Manual key provided via --ssh-pubkey flag\n        if [[ -f \"$auth_keys\" ]] && grep -qF \"$SSH_PUBKEY\" \"$auth_keys\" 2>/dev/null; then\n            info \"SSH key already present in authorized_keys\"\n        else\n            echo \"$SSH_PUBKEY\" >> \"$auth_keys\"\n            success \"SSH public key added\"\n        fi\n    else\n        # No manual key — the daemon will auto-deploy it on first registration\n        info \"SSH key will be deployed automatically when the daemon connects to Bambuddy\"\n        touch \"$auth_keys\"\n    fi\n\n    chmod 600 \"$auth_keys\"\n    chown -R \"$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER\" \"$ssh_dir\"\n}\n\ncreate_spoolbuddy_service() {\n    info \"Creating SpoolBuddy systemd service...\"\n\n    local after_line=\"After=network-online.target\"\n    if [[ \"$INSTALL_MODE\" == \"full\" ]]; then\n        after_line=\"After=network-online.target bambuddy.service\"\n    fi\n\n    cat > /etc/systemd/system/spoolbuddy.service << EOF\n[Unit]\nDescription=SpoolBuddy - NFC Spool Management Daemon\nDocumentation=https://github.com/maziggy/bambuddy\n$after_line\nWants=network-online.target\n\n[Service]\nType=simple\nUser=$SPOOLBUDDY_SERVICE_USER\nWorkingDirectory=$INSTALL_PATH/spoolbuddy\nEnvironmentFile=$INSTALL_PATH/spoolbuddy/.env\nExecStart=$INSTALL_PATH/spoolbuddy/venv/bin/python -m daemon.main\nRestart=always\nRestartSec=5\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target\nEOF\n\n    systemctl daemon-reload\n    systemctl enable spoolbuddy.service\n    success \"SpoolBuddy service created and enabled\"\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Bambuddy Installation (full mode only)\n# ─────────────────────────────────────────────────────────────────────────────\n\ncreate_bambuddy_user() {\n    if id \"$BAMBUDDY_SERVICE_USER\" &>/dev/null; then\n        info \"User '$BAMBUDDY_SERVICE_USER' already exists\"\n        return\n    fi\n\n    info \"Creating service user '$BAMBUDDY_SERVICE_USER'...\"\n    useradd --system --shell /usr/sbin/nologin --home-dir \"$INSTALL_PATH\" \"$BAMBUDDY_SERVICE_USER\"\n    success \"Service user created\"\n}\n\nsetup_bambuddy_venv() {\n    cd \"$INSTALL_PATH\"\n\n    run_with_progress \"Creating Bambuddy venv\" $PYTHON_CMD -m venv venv\n    run_with_progress \"Upgrading pip\" \"$INSTALL_PATH/venv/bin/pip\" install --upgrade pip\n    run_with_progress \"Installing Bambuddy dependencies\" \"$INSTALL_PATH/venv/bin/pip\" install -r requirements.txt\n\n    chown -R \"$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER\" \"$INSTALL_PATH/venv\"\n}\n\ninstall_nodejs() {\n    if command -v node &>/dev/null; then\n        local version\n        version=$(node --version 2>/dev/null | sed 's/^v//')\n        local major\n        major=$(echo \"$version\" | cut -d'.' -f1)\n        if [[ \"$major\" -ge 20 ]]; then\n            success \"Found Node.js v$version\"\n            return\n        fi\n    fi\n\n    apt-get remove -y nodejs npm > /dev/null 2>&1 || true\n    run_with_progress \"Setting up Node.js repository\" bash -c \"curl -fsSL https://deb.nodesource.com/setup_22.x | bash -\"\n    run_with_progress \"Installing Node.js\" apt-get install -y nodejs\n    hash -r 2>/dev/null || true\n    success \"Node.js installed: $(node --version)\"\n}\n\nbuild_frontend() {\n    cd \"$INSTALL_PATH/frontend\"\n\n    run_with_progress \"Installing frontend dependencies\" npm ci\n    run_with_progress \"Building frontend\" npm run build\n}\n\ncreate_bambuddy_env() {\n    info \"Creating Bambuddy configuration...\"\n\n    local env_file=\"$INSTALL_PATH/.env\"\n\n    cat > \"$env_file\" << EOF\n# Bambuddy Configuration\n# Generated by install.sh on $(date)\n\nDEBUG=false\nLOG_LEVEL=INFO\nLOG_TO_FILE=true\nEOF\n\n    chown \"$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER\" \"$env_file\"\n    chmod 600 \"$env_file\"\n    success \"Configuration saved to $env_file\"\n}\n\ncreate_bambuddy_directories() {\n    mkdir -p \"$INSTALL_PATH/data\" \"$INSTALL_PATH/logs\"\n    chown -R \"$BAMBUDDY_SERVICE_USER:$BAMBUDDY_SERVICE_USER\" \"$INSTALL_PATH/data\" \"$INSTALL_PATH/logs\"\n    success \"Data directories created\"\n}\n\ncreate_bambuddy_service() {\n    info \"Creating Bambuddy systemd service...\"\n\n    cat > /etc/systemd/system/bambuddy.service << EOF\n[Unit]\nDescription=Bambuddy - Bambu Lab Print Management\nDocumentation=https://github.com/maziggy/bambuddy\nAfter=network.target\n\n[Service]\nType=simple\nUser=$BAMBUDDY_SERVICE_USER\nGroup=$BAMBUDDY_SERVICE_USER\nWorkingDirectory=$INSTALL_PATH\nEnvironmentFile=$INSTALL_PATH/.env\nEnvironment=\"DATA_DIR=$INSTALL_PATH/data\"\nEnvironment=\"LOG_DIR=$INSTALL_PATH/logs\"\nExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port $BAMBUDDY_PORT\nRestart=on-failure\nRestartSec=5\nStandardOutput=journal\nStandardError=journal\n\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nReadWritePaths=$INSTALL_PATH/data $INSTALL_PATH/logs $INSTALL_PATH\n\n[Install]\nWantedBy=multi-user.target\nEOF\n\n    systemctl daemon-reload\n    systemctl enable bambuddy.service\n    success \"Bambuddy service created and enabled\"\n}\n\nbootstrap_spoolbuddy_kiosk_key() {\n    # Provision an API key for the local SpoolBuddy kiosk and write it into\n    # spoolbuddy/.env. Runs against the Bambuddy DB directly (via the CLI),\n    # so the bambuddy service does not need to be running yet.\n    info \"Provisioning SpoolBuddy kiosk API key...\"\n\n    local env_file=\"$INSTALL_PATH/spoolbuddy/.env\"\n    if [[ ! -f \"$env_file\" ]]; then\n        warn \"SpoolBuddy env file not found at $env_file — skipping kiosk key bootstrap\"\n        return\n    fi\n\n    # CWD must be $INSTALL_PATH so `python -m backend.app.cli` finds the backend\n    # package on sys.path (matches the systemd unit's WorkingDirectory).\n    local kiosk_key\n    if ! kiosk_key=\"$(cd \"$INSTALL_PATH\" && sudo -u \"$BAMBUDDY_SERVICE_USER\" \\\n            env DATA_DIR=\"$INSTALL_PATH/data\" LOG_DIR=\"$INSTALL_PATH/logs\" \\\n            \"$INSTALL_PATH/venv/bin/python\" -m backend.app.cli kiosk-bootstrap --force)\"; then\n        error \"Failed to bootstrap SpoolBuddy kiosk API key\"\n    fi\n\n    if [[ -z \"$kiosk_key\" || \"$kiosk_key\" != bb_* ]]; then\n        error \"CLI returned an invalid API key (got: ${kiosk_key:0:8}...)\"\n    fi\n\n    if ! grep -q '^SPOOLBUDDY_API_KEY=' \"$env_file\"; then\n        error \"Sentinel 'SPOOLBUDDY_API_KEY=' line missing in $env_file\"\n    fi\n\n    # Escape for sed replacement (the key is base64url-safe, no slashes, but be defensive)\n    local escaped_key\n    escaped_key=$(printf '%s\\n' \"$kiosk_key\" | sed -e 's/[\\/&]/\\\\&/g')\n    sed -i \"s/^SPOOLBUDDY_API_KEY=.*/SPOOLBUDDY_API_KEY=${escaped_key}/\" \"$env_file\"\n\n    success \"SpoolBuddy kiosk API key provisioned\"\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# System Strip-Down (dedicated appliance — remove unnecessary services/packages)\n# ─────────────────────────────────────────────────────────────────────────────\n\nstrip_services() {\n    info \"Disabling unnecessary services...\"\n\n    local services=(\n        bluetooth.service\n        lightdm.service\n        cloud-init-local.service\n        cloud-init.service\n        cloud-init-network.service\n        cloud-config.service\n        cloud-final.service\n        cloud-init-hotplugd.socket\n        avahi-daemon.service\n        avahi-daemon.socket\n        ModemManager.service\n        udisks2.service\n        apparmor.service\n        man-db.timer\n        e2scrub_all.timer\n        e2scrub_reap.service\n        # Audio stack (no speakers on a spool reader)\n        pipewire.service\n        pipewire.socket\n        pipewire-pulse.service\n        pipewire-pulse.socket\n        wireplumber.service\n        # Printing\n        cups.service\n        cups.socket\n        cups-browsed.service\n        # Desktop services\n        accounts-daemon.service\n        upower.service\n        polkit.service\n        # Flatpak portals (not using Flatpak)\n        xdg-desktop-portal.service\n        xdg-desktop-portal-gtk.service\n        xdg-document-portal.service\n        xdg-permission-store.service\n        # NFS/RPC (unnecessary + security surface)\n        rpcbind.service\n        rpcbind.socket\n        # Bluetooth media proxy\n        mpris-proxy.service\n    )\n\n    local disabled=0\n    for svc in \"${services[@]}\"; do\n        if systemctl is-enabled \"$svc\" &>/dev/null; then\n            systemctl disable \"$svc\" 2>/dev/null || true\n            systemctl mask \"$svc\" 2>/dev/null || true\n            (( ++disabled ))\n        fi\n    done\n\n    if (( disabled > 0 )); then\n        success \"Disabled $disabled unnecessary services\"\n    else\n        success \"No unnecessary services to disable\"\n    fi\n\n    # Mask user-level services globally via /etc/systemd/user/ overrides.\n    # The su-based approach doesn't reliably reach the user's systemd instance\n    # when run from sudo, so we create global masks that apply before login.\n    local user_services=(\n        pipewire.service\n        pipewire.socket\n        pipewire-pulse.service\n        pipewire-pulse.socket\n        wireplumber.service\n        xdg-desktop-portal.service\n        xdg-desktop-portal-gtk.service\n        xdg-document-portal.service\n        xdg-permission-store.service\n        mpris-proxy.service\n    )\n    mkdir -p /etc/systemd/user\n    local user_masked=0\n    for svc in \"${user_services[@]}\"; do\n        if [[ ! -L \"/etc/systemd/user/$svc\" ]] || [[ \"$(readlink /etc/systemd/user/$svc)\" != \"/dev/null\" ]]; then\n            ln -sf /dev/null \"/etc/systemd/user/$svc\"\n            (( ++user_masked ))\n        fi\n    done\n    if (( user_masked > 0 )); then\n        success \"Masked $user_masked unnecessary user services globally\"\n    fi\n}\n\nstrip_packages() {\n    info \"Removing unnecessary packages...\"\n\n    local packages=(\n        mkvtoolnix\n        firmware-atheros\n        firmware-mediatek\n        cloud-init\n        rpi-cloud-init-mods\n        rpi-connect-lite\n        avahi-daemon\n        modemmanager\n        udisks2\n        pipewire\n        pipewire-pulse\n        wireplumber\n        cups\n        cups-browsed\n        cups-common\n        cups-client\n        rpcbind\n    )\n\n    local to_remove=()\n    for pkg in \"${packages[@]}\"; do\n        if dpkg -l \"$pkg\" &>/dev/null 2>&1; then\n            to_remove+=(\"$pkg\")\n        fi\n    done\n\n    if (( ${#to_remove[@]} > 0 )); then\n        run_with_progress \"Removing ${#to_remove[@]} packages\" apt-get remove --purge -y \"${to_remove[@]}\"\n        run_with_progress \"Cleaning up dependencies\" apt-get autoremove --purge -y\n    else\n        success \"No unnecessary packages to remove\"\n    fi\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Kiosk Setup (labwc + Chromium + Plymouth splash)\n# ─────────────────────────────────────────────────────────────────────────────\n\nsetup_kiosk() {\n    info \"Setting up touchscreen kiosk...\"\n\n    # Detect kiosk user (the human user who ran sudo)\n    KIOSK_USER=\"${SUDO_USER:-$(logname 2>/dev/null || echo pi)}\"\n    KIOSK_URL=\"${BAMBUDDY_URL}/spoolbuddy?token=${API_KEY}\"\n    local KIOSK_HOME\n    KIOSK_HOME=$(eval echo \"~$KIOSK_USER\")\n\n    info \"Kiosk user: $KIOSK_USER (home: $KIOSK_HOME)\"\n    info \"Kiosk URL:  $KIOSK_URL\"\n\n    # Allow kiosk user to read SpoolBuddy env so launcher can resolve backend URL\n    # and API key dynamically instead of using stale install-time fallback values.\n    local spoolbuddy_env=\"$INSTALL_PATH/spoolbuddy/.env\"\n    if [[ -f \"$spoolbuddy_env\" ]]; then\n        usermod -aG \"$SPOOLBUDDY_SERVICE_USER\" \"$KIOSK_USER\" 2>/dev/null || true\n        chgrp \"$SPOOLBUDDY_SERVICE_USER\" \"$spoolbuddy_env\" 2>/dev/null || true\n        chmod 640 \"$spoolbuddy_env\" 2>/dev/null || true\n    fi\n\n    # ── Install kiosk packages ────────────────────────────────────────────\n    # Temporarily block initramfs rebuilds during package install — we rebuild\n    # once at the end after the Plymouth theme is configured, saving ~4 runs\n    # (one per installed kernel per hook trigger).\n    if [[ -x /usr/sbin/update-initramfs ]]; then\n        dpkg-divert --local --rename --add /usr/sbin/update-initramfs >/dev/null 2>&1 || true\n        ln -sf /bin/true /usr/sbin/update-initramfs\n    fi\n    run_with_progress \"Installing kiosk packages\" apt-get install -y labwc chromium plymouth wlr-randr swayidle wlopm jq curl\n    # Restore real update-initramfs\n    if dpkg-divert --list /usr/sbin/update-initramfs 2>/dev/null | grep -q local; then\n        rm -f /usr/sbin/update-initramfs\n        dpkg-divert --local --rename --remove /usr/sbin/update-initramfs >/dev/null 2>&1 || true\n    fi\n\n    # ── config.txt tweaks ─────────────────────────────────────────────────\n    local boot_config=\"/boot/firmware/config.txt\"\n    if [[ ! -f \"$boot_config\" ]]; then\n        boot_config=\"/boot/config.txt\"\n    fi\n\n    if [[ -f \"$boot_config\" ]]; then\n        info \"Configuring $boot_config for kiosk...\"\n\n        # Disable audio (change existing on→off)\n        sed -i 's/^dtparam=audio=on/dtparam=audio=off/' \"$boot_config\"\n\n        # Disable camera auto-detect (change existing 1→0)\n        sed -i 's/^camera_auto_detect=1/camera_auto_detect=0/' \"$boot_config\"\n\n        # Append if missing: gpu_mem=32\n        if ! grep -q \"^gpu_mem=\" \"$boot_config\"; then\n            echo \"\" >> \"$boot_config\"\n            echo \"# Kiosk: Minimal GPU firmware memory (KMS uses CMA from system RAM)\" >> \"$boot_config\"\n            echo \"gpu_mem=32\" >> \"$boot_config\"\n        fi\n\n        # Append if missing: dtoverlay=disable-bt\n        if ! grep -q \"^dtoverlay=disable-bt\" \"$boot_config\"; then\n            echo \"\" >> \"$boot_config\"\n            echo \"# Kiosk: Disable Bluetooth hardware\" >> \"$boot_config\"\n            echo \"dtoverlay=disable-bt\" >> \"$boot_config\"\n        fi\n\n        # Append if missing: disable_splash=1\n        if ! grep -q \"^disable_splash=\" \"$boot_config\"; then\n            echo \"\" >> \"$boot_config\"\n            echo \"# Kiosk: Disable Raspberry Pi firmware splash, use custom splash.png\" >> \"$boot_config\"\n            echo \"disable_splash=1\" >> \"$boot_config\"\n        fi\n\n        success \"Boot config updated\"\n    fi\n\n    # ── cmdline.txt tweaks ────────────────────────────────────────────────\n    local cmdline=\"/boot/firmware/cmdline.txt\"\n    if [[ ! -f \"$cmdline\" ]]; then\n        cmdline=\"/boot/cmdline.txt\"\n    fi\n\n    if [[ -f \"$cmdline\" ]]; then\n        info \"Configuring $cmdline for kiosk...\"\n\n        # Remove serial console (Plymouth needs tty-only console)\n        sed -i 's/console=serial0,[0-9]* //' \"$cmdline\"\n\n        # Disable console blanking (kernel default is 600s, can blank during boot transition)\n        grep -q \"consoleblank=\" \"$cmdline\" || sed -i 's/$/ consoleblank=0/' \"$cmdline\"\n\n        # Add splash quiet loglevel=3 logo.nologo if missing\n        grep -q \"splash\" \"$cmdline\" || sed -i 's/$/ splash quiet loglevel=3 logo.nologo/' \"$cmdline\"\n\n        # Add video mode if missing\n        grep -q \"video=HDMI-A-1\" \"$cmdline\" || sed -i 's/$/ video=HDMI-A-1:1024x600@60/' \"$cmdline\"\n\n        success \"Kernel cmdline updated\"\n    fi\n\n    # ── Plymouth splash theme ─────────────────────────────────────────────\n    info \"Installing Plymouth boot splash...\"\n    local theme_dir=\"/usr/share/plymouth/themes/spoolbuddy\"\n    mkdir -p \"$theme_dir\"\n\n    # Copy bundled splash image from the install directory\n    local script_dir\n    script_dir=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n    if [[ -f \"$script_dir/splash.png\" ]]; then\n        cp \"$script_dir/splash.png\" \"$theme_dir/splash.png\"\n    elif [[ -f \"$INSTALL_PATH/spoolbuddy/install/splash.png\" ]]; then\n        cp \"$INSTALL_PATH/spoolbuddy/install/splash.png\" \"$theme_dir/splash.png\"\n    else\n        warn \"splash.png not found — Plymouth splash will not display an image\"\n    fi\n\n    # Write .plymouth theme file\n    cat > \"$theme_dir/spoolbuddy.plymouth\" << 'EOF'\n[Plymouth Theme]\nName=SpoolBuddy\nDescription=SpoolBuddy boot splash\nModuleName=script\n\n[script]\nImageDir=/usr/share/plymouth/themes/spoolbuddy\nScriptFile=/usr/share/plymouth/themes/spoolbuddy/spoolbuddy.script\nEOF\n\n    # Write .script theme file\n    cat > \"$theme_dir/spoolbuddy.script\" << 'EOF'\nwallpaper_image = Image(\"splash.png\");\nscreen_width = Window.GetWidth();\nscreen_height = Window.GetHeight();\nresized_wallpaper_image = wallpaper_image.Scale(screen_width, screen_height);\nwallpaper_sprite = Sprite(resized_wallpaper_image);\nwallpaper_sprite.SetZ(-100);\nEOF\n\n    plymouth-set-default-theme spoolbuddy\n    run_with_progress \"Updating initramfs\" update-initramfs -u\n    success \"Plymouth splash installed\"\n\n    # ── Auto-login on tty1 ────────────────────────────────────────────────\n    info \"Configuring auto-login for $KIOSK_USER...\"\n    mkdir -p /etc/systemd/system/getty@tty1.service.d\n\n    cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << EOF\n[Unit]\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nExecStart=\nExecStart=-/sbin/agetty --autologin $KIOSK_USER --noclear %I \\$TERM\nEOF\n\n    success \"Auto-login configured\"\n\n    # ── labwc rc.xml (no decorations, no keybinds) ────────────────────────\n    info \"Configuring labwc window manager...\"\n    local labwc_dir=\"$KIOSK_HOME/.config/labwc\"\n    mkdir -p \"$labwc_dir\"\n\n    cat > \"$labwc_dir/rc.xml\" << 'EOF'\n<?xml version=\"1.0\"?>\n<labwc_config>\n\n  <!-- Disable screen blanking — kiosk must stay on -->\n  <core>\n    <screenBlankTimeout>0</screenBlankTimeout>\n  </core>\n\n  <theme>\n    <name></name>\n    <cornerRadius>0</cornerRadius>\n  </theme>\n\n  <!-- Disable all keybindings - kiosk lockdown -->\n  <keyboard>\n  </keyboard>\n\n  <!-- Disable right-click menu -->\n  <mouse>\n    <default />\n  </mouse>\n\n  <!-- Remove window decorations, maximize Chromium, prevent unfullscreen -->\n  <windowRules>\n    <windowRule identifier=\"*\">\n      <serverDecoration>no</serverDecoration>\n    </windowRule>\n    <windowRule identifier=\"chromium\">\n      <skipTaskbar>yes</skipTaskbar>\n      <fixedPosition>yes</fixedPosition>\n    </windowRule>\n  </windowRules>\n\n</labwc_config>\nEOF\n\n        # ── Override Debian/RPi Chromium defaults for kiosk performance ──────\n        cat > /etc/chromium.d/spoolbuddy-kiosk << 'CHROMIUM_EOF'\n# SpoolBuddy kiosk: add kiosk-specific flags on top of Pi defaults.\n# Preserves Pi GPU settings (gpu-rasterization, ANGLE/GLES) for stability.\nCHROMIUM_FLAGS=\"$CHROMIUM_FLAGS --disable-smooth-scrolling\"\nCHROMIUM_FLAGS=\"$CHROMIUM_FLAGS --disable-extensions\"\nCHROMIUM_FLAGS=\"$CHROMIUM_FLAGS --disable-background-timer-throttling\"\nCHROMIUM_FLAGS=\"$CHROMIUM_FLAGS --disable-renderer-backgrounding\"\nCHROMIUM_FLAGS=\"$CHROMIUM_FLAGS --disable-crash-reporter\"\nCHROMIUM_EOF\n        success \"Chromium kiosk performance flags installed\"\n\n        # ── kiosk launcher (dynamic URL from spoolbuddy/.env) ─────────────────\n        local kiosk_launcher=\"/usr/local/bin/spoolbuddy-kiosk-launch\"\n        cat > \"$kiosk_launcher\" << EOF\n#!/usr/bin/env bash\nset -euo pipefail\n\nENV_FILE=\"$INSTALL_PATH/spoolbuddy/.env\"\nFALLBACK_URL=\"$KIOSK_URL\"\n\nbackend_url=\"\"\napi_key=\"\"\n\nif [[ -r \"\\$ENV_FILE\" ]]; then\n    backend_url=\"\\$(sed -n 's/^SPOOLBUDDY_BACKEND_URL=//p' \"\\$ENV_FILE\" | tail -n1 | tr -d '\\r')\"\n    api_key=\"\\$(sed -n 's/^SPOOLBUDDY_API_KEY=//p' \"\\$ENV_FILE\" | tail -n1 | tr -d '\\r')\"\n    backend_url=\"\\${backend_url%\\\"}\"\n    backend_url=\"\\${backend_url#\\\"}\"\n    api_key=\"\\${api_key%\\\"}\"\n    api_key=\"\\${api_key#\\\"}\"\nelif [[ -f \"\\$ENV_FILE\" ]]; then\n    echo \"spoolbuddy-kiosk-launch: ERROR: \\$ENV_FILE exists but is not readable\" >&2\n    echo \"spoolbuddy-kiosk-launch: Fix permissions (group-readable by kiosk user) and restart kiosk\" >&2\n    exit 1\nfi\n\nif [[ -n \"\\$backend_url\" && -n \"\\$api_key\" ]]; then\n    backend_url=\"\\${backend_url%/}\"\n    kiosk_url=\"\\${backend_url}/spoolbuddy?token=\\${api_key}\"\nelse\n    kiosk_url=\"\\$FALLBACK_URL\"\nfi\n\n# Wait for the Bambuddy backend to be reachable before launching Chromium.\n# Without this the browser opens before uvicorn has bound to the port on a\n# cold boot and the user sees an ERR_CONNECTION_REFUSED splash until they\n# manually reload. Probe /health (no auth, no body) with a short timeout.\nprobe_url=\"\\${backend_url:-http://localhost}/health\"\nfor _i in \\$(seq 1 60); do\n    if curl -sf --max-time 2 \"\\$probe_url\" >/dev/null 2>&1; then\n        break\n    fi\n    sleep 1\ndone\n\nexec chromium --kiosk --no-first-run --disable-infobars \\\n    --disable-session-crashed-bubble --disable-features=TranslateUI \\\n    --noerrdialogs --disable-component-update \\\n    --overscroll-history-navigation=0 \\\n    --ozone-platform=wayland \\\n    --disable-crash-reporter --disable-breakpad \\\n    \"\\$kiosk_url\"\nEOF\n\n        chmod 755 \"$kiosk_launcher\"\n\n        # Tiny self-check: ensure sed command substitutions were not expanded\n        # while generating the launcher script.\n        if ! grep -Fq 'backend_url=\"$(sed -n' \"$kiosk_launcher\" || ! grep -Fq 'api_key=\"$(sed -n' \"$kiosk_launcher\"; then\n            error \"Kiosk launcher generation failed: dynamic env parsing commands were expanded unexpectedly\"\n        fi\n\n        # ── labwc autostart ───────────────────────────────────────────────────\n        cat > \"$labwc_dir/autostart\" << EOF\n# Force 1024x600 (panel doesn't advertise this natively)\nwlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &\n\n# Idle watchdog: powers off HDMI via wlopm after the configured inactivity\n# timeout (SpoolBuddy Settings → Display → Screen blank timeout). Reads the\n# current value from the backend on startup; UI changes take effect on the\n# next reboot / kiosk restart.\n$INSTALL_PATH/spoolbuddy/install/spoolbuddy-idle.sh &\n\n# Launch Chromium via helper that resolves URL from spoolbuddy/.env\n$kiosk_launcher &\nEOF\n\n    chown -R \"$KIOSK_USER:$KIOSK_USER\" \"$labwc_dir\"\n\n    # ── .bash_profile (source .bashrc, exec labwc on tty1) ────────────────\n    cat > \"$KIOSK_HOME/.bash_profile\" << 'EOF'\n# Source .bashrc if it exists\nif [ -f ~/.bashrc ]; then\n  . ~/.bashrc\nfi\n\n# Auto-start kiosk on tty1\nif [ \"$(tty)\" = \"/dev/tty1\" ]; then\n  exec labwc\nfi\nEOF\n\n    chown \"$KIOSK_USER:$KIOSK_USER\" \"$KIOSK_HOME/.bash_profile\"\n\n    REBOOT_NEEDED=\"true\"\n    success \"Kiosk setup complete\"\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# User Prompts\n# ─────────────────────────────────────────────────────────────────────────────\n\nparse_args() {\n    while [[ $# -gt 0 ]]; do\n        case \"$1\" in\n            --mode)\n                INSTALL_MODE=\"$2\"\n                shift 2\n                ;;\n            --repo)\n                INSTALL_REPO=\"$(normalize_github_repo_url \"$2\")\"\n                shift 2\n                ;;\n            --ref)\n                INSTALL_REF=\"$2\"\n                shift 2\n                ;;\n            --bambuddy-url)\n                BAMBUDDY_URL=\"$2\"\n                shift 2\n                ;;\n            --api-key)\n                API_KEY=\"$2\"\n                shift 2\n                ;;\n            --path)\n                INSTALL_PATH=\"$2\"\n                shift 2\n                ;;\n            --port)\n                BAMBUDDY_PORT=\"$2\"\n                shift 2\n                ;;\n            --ssh-pubkey)\n                SSH_PUBKEY=\"$2\"\n                shift 2\n                ;;\n            --yes|-y)\n                NON_INTERACTIVE=\"true\"\n                shift\n                ;;\n            --help|-h)\n                show_help\n                ;;\n            *)\n                error \"Unknown option: $1 (use --help for usage)\"\n                ;;\n        esac\n    done\n}\n\nask_install_mode() {\n    if [[ -n \"$INSTALL_MODE\" ]]; then\n        return\n    fi\n\n    echo \"\"\n    echo -e \"${BOLD}How would you like to set up SpoolBuddy?${NC}\"\n    echo \"\"\n    echo -e \"  ${CYAN}1)${NC} SpoolBuddy only\"\n    echo \"     NFC reader + scale on this RPi, Bambuddy runs on another device\"\n    echo \"\"\n    echo -e \"  ${CYAN}2)${NC} SpoolBuddy + Bambuddy\"\n    echo \"     Both running natively on this Raspberry Pi\"\n    echo \"\"\n\n    while true; do\n        echo -en \"${BOLD}Choose${NC} [${CYAN}1${NC}/${CYAN}2${NC}]: \"\n        read -r choice\n        case \"$choice\" in\n            1) INSTALL_MODE=\"spoolbuddy\"; return;;\n            2) INSTALL_MODE=\"full\"; return;;\n            *) echo \"Please enter 1 or 2.\";;\n        esac\n    done\n}\n\ngather_config() {\n    echo \"\"\n    echo -e \"${BOLD}Configuration${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo \"\"\n\n    # Set default install path based on mode\n    if [[ -z \"$INSTALL_PATH\" ]]; then\n        if [[ \"$INSTALL_MODE\" == \"full\" ]]; then\n            INSTALL_PATH=\"/opt/bambuddy\"\n        else\n            INSTALL_PATH=\"/opt/bambuddy\"\n        fi\n    fi\n    prompt \"Installation directory\" \"$INSTALL_PATH\" INSTALL_PATH\n\n    if [[ -z \"$INSTALL_REPO\" ]]; then\n        INSTALL_REPO=\"$GITHUB_REPO\"\n    fi\n    prompt \"Git repository URL\" \"$INSTALL_REPO\" INSTALL_REPO\n    INSTALL_REPO=\"$(normalize_github_repo_url \"$INSTALL_REPO\")\"\n\n    if [[ -z \"$INSTALL_REF\" ]]; then\n        INSTALL_REF=\"main\"\n    fi\n\n    if [[ \"$NON_INTERACTIVE\" != \"true\" && -n \"$DETECTED_INSTALLER_REF\" && \"$DETECTED_INSTALLER_REF\" != \"HEAD\" ]]; then\n        echo \"\"\n        echo -e \"${BOLD}Install Source Ref${NC}\"\n        echo \"1) main\"\n        echo \"2) $DETECTED_INSTALLER_REF (detected from installer context)\"\n        echo \"3) custom\"\n        while true; do\n            echo -en \"${BOLD}Choose${NC} [1/2/3]: \"\n            read -r ref_choice\n            case \"$ref_choice\" in\n                \"\"|1)\n                    INSTALL_REF=\"main\"\n                    break\n                    ;;\n                2)\n                    INSTALL_REF=\"$DETECTED_INSTALLER_REF\"\n                    break\n                    ;;\n                3)\n                    prompt \"Git ref (branch/tag/commit)\" \"$INSTALL_REF\" INSTALL_REF\n                    break\n                    ;;\n                *)\n                    echo \"Please enter 1, 2, or 3.\"\n                    ;;\n            esac\n        done\n    else\n        prompt \"Git ref (branch/tag/commit)\" \"$INSTALL_REF\" INSTALL_REF\n    fi\n\n    if [[ \"$INSTALL_MODE\" == \"spoolbuddy\" ]]; then\n        # Need remote Bambuddy URL and API key\n        echo \"\"\n        info \"SpoolBuddy needs to connect to your Bambuddy server.\"\n        info \"You can find/create an API key in Bambuddy under Settings -> API Keys.\"\n        echo \"\"\n\n        while [[ -z \"$BAMBUDDY_URL\" ]]; do\n            prompt \"Bambuddy server URL (e.g. http://192.168.1.100:8000)\" \"\" BAMBUDDY_URL\n            if [[ -z \"$BAMBUDDY_URL\" ]]; then\n                warn \"Bambuddy URL is required\"\n            fi\n        done\n\n        while [[ -z \"$API_KEY\" ]]; do\n            prompt \"Bambuddy API key\" \"\" API_KEY\n            if [[ -z \"$API_KEY\" ]]; then\n                warn \"API key is required\"\n            fi\n        done\n    else\n        # Full mode — Bambuddy runs locally\n        prompt \"Bambuddy port\" \"$BAMBUDDY_PORT\" BAMBUDDY_PORT\n        BAMBUDDY_URL=\"http://localhost:$BAMBUDDY_PORT\"\n\n        echo \"\"\n        info \"After installation, create an API key in Bambuddy (Settings -> API Keys)\"\n        info \"and update it in: $INSTALL_PATH/spoolbuddy/.env\"\n        API_KEY=\"CHANGE_ME_AFTER_SETUP\"\n    fi\n\n    # Summary\n    echo \"\"\n    echo -e \"${BOLD}Installation Summary${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo -e \"  Mode:           ${GREEN}$([ \"$INSTALL_MODE\" == \"full\" ] && echo \"Bambuddy + SpoolBuddy\" || echo \"SpoolBuddy only\")${NC}\"\n    echo -e \"  Install path:   ${GREEN}$INSTALL_PATH${NC}\"\n    echo -e \"  Git repo:       ${GREEN}$INSTALL_REPO${NC}\"\n    echo -e \"  Git ref:        ${GREEN}$INSTALL_REF${NC}\"\n    if [[ \"$INSTALL_MODE\" == \"full\" ]]; then\n        echo -e \"  Bambuddy port:  ${GREEN}$BAMBUDDY_PORT${NC}\"\n        echo -e \"  Bambuddy URL:   ${GREEN}$BAMBUDDY_URL${NC}\"\n    else\n        echo -e \"  Bambuddy URL:   ${GREEN}$BAMBUDDY_URL${NC}\"\n    fi\n    echo \"\"\n\n    if ! prompt_yes_no \"Proceed with installation?\" \"y\"; then\n        echo \"Installation cancelled.\"\n        exit 0\n    fi\n}\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main\n# ─────────────────────────────────────────────────────────────────────────────\n\nmain() {\n    parse_args \"$@\"\n    detect_installer_source_context\n\n    echo \"\"\n    echo -e \"${CYAN}╔══════════════════════════════════════════════════════════╗${NC}\"\n    echo -e \"${CYAN}║                                                          ║${NC}\"\n    echo -e \"${CYAN}║   ____                    _ ____            _     _       ║${NC}\"\n    echo -e \"${CYAN}║  / ___| _ __   ___   ___ | | __ ) _   _  __| | __| |_   _ ║${NC}\"\n    echo -e \"${CYAN}║  \\\\___ \\\\| '_ \\\\ / _ \\\\ / _ \\\\| |  _ \\\\| | | |/ _\\` |/ _\\` | | | |║${NC}\"\n    echo -e \"${CYAN}║   ___) | |_) | (_) | (_) | | |_) | |_| | (_| | (_| | |_| |║${NC}\"\n    echo -e \"${CYAN}║  |____/| .__/ \\\\___/ \\\\___/|_|____/ \\\\__,_|\\\\__,_|\\\\__,_|\\\\__, |║${NC}\"\n    echo -e \"${CYAN}║        |_|                                          |___/ ║${NC}\"\n    echo -e \"${CYAN}║                                                          ║${NC}\"\n    echo -e \"${CYAN}║          NFC Spool Management for Bambuddy               ║${NC}\"\n    echo -e \"${CYAN}║                                                          ║${NC}\"\n    echo -e \"${CYAN}╚══════════════════════════════════════════════════════════╝${NC}\"\n    echo \"\"\n\n    # Check if running via pipe without -y\n    if [[ ! -t 0 ]] && [[ \"$NON_INTERACTIVE\" != \"true\" ]]; then\n        error \"Interactive mode requires a terminal. Use -y for unattended install, or download and run directly.\"\n    fi\n\n    # Pre-flight checks\n    check_root\n    check_raspberry_pi\n    check_raspberry_pi_os\n\n    if ! detect_python; then\n        info \"Python 3.10+ not found, will install...\"\n    fi\n\n    # Gather user preferences\n    ask_install_mode\n    gather_config\n\n    # Validate mode\n    if [[ \"$INSTALL_MODE\" != \"spoolbuddy\" && \"$INSTALL_MODE\" != \"full\" ]]; then\n        error \"Invalid mode: $INSTALL_MODE (must be 'spoolbuddy' or 'full')\"\n    fi\n\n    echo \"\"\n    echo -e \"${BOLD}Starting Installation${NC}\"\n    echo -e \"${CYAN}─────────────────────────────────────────${NC}\"\n    echo \"\"\n\n    # ── Step 1: Raspberry Pi hardware config ──────────────────────────────\n    info \"Configuring Raspberry Pi hardware...\"\n    enable_spi\n    enable_i2c\n    configure_boot_config\n    echo \"\"\n\n    # ── Step 2: System packages ───────────────────────────────────────────\n    install_system_packages\n    install_wifi_safeguard\n    upgrade_system_packages\n    detect_python || error \"Failed to install Python 3.10+\"\n    echo \"\"\n\n    # ── Step 2b: Strip unnecessary services & packages ────────────────────\n    strip_services\n    strip_packages\n    echo \"\"\n\n    # ── Step 3: Download source code ──────────────────────────────────────\n    create_spoolbuddy_user\n    download_spoolbuddy\n    echo \"\"\n\n    # ── Step 3b: Kiosk setup (labwc + Chromium + squeekboard + Plymouth) ──\n    setup_kiosk\n    echo \"\"\n\n    # ── Step 4: SpoolBuddy setup ──────────────────────────────────────────\n    info \"Setting up SpoolBuddy...\"\n    setup_spoolbuddy_venv\n    create_spoolbuddy_env\n    # Kiosk env access: only needed if actual kiosk hardware is available\n    if [[ -f /boot/firmware/config.txt ]] || [[ -f /boot/config.txt ]]; then\n        ensure_kiosk_env_access\n    fi\n    setup_ssh_key\n    create_spoolbuddy_service\n    echo \"\"\n\n    # ── Step 5: Bambuddy setup (full mode only) ───────────────────────────\n    if [[ \"$INSTALL_MODE\" == \"full\" ]]; then\n        info \"Setting up Bambuddy...\"\n        create_bambuddy_user\n        setup_bambuddy_venv\n        install_nodejs\n        build_frontend\n        create_bambuddy_directories\n        create_bambuddy_env\n        create_bambuddy_service\n        bootstrap_spoolbuddy_kiosk_key\n        echo \"\"\n    fi\n\n    # ── Done ──────────────────────────────────────────────────────────────\n    echo \"\"\n    echo -e \"${GREEN}╔══════════════════════════════════════════════════════════╗${NC}\"\n    echo -e \"${GREEN}║                                                          ║${NC}\"\n    echo -e \"${GREEN}║              Installation Complete!                      ║${NC}\"\n    echo -e \"${GREEN}║                                                          ║${NC}\"\n    echo -e \"${GREEN}╚══════════════════════════════════════════════════════════╝${NC}\"\n    echo \"\"\n\n    local ip_addr\n    ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr=\"<your-ip>\"\n\n    if [[ \"$INSTALL_MODE\" == \"full\" ]]; then\n        echo -e \"  ${BOLD}Bambuddy:${NC}         ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC}\"\n    else\n        echo -e \"  ${BOLD}SpoolBuddy:${NC}       Connecting to ${CYAN}$BAMBUDDY_URL${NC}\"\n    fi\n    echo -e \"  ${BOLD}Kiosk URL:${NC}        ${CYAN}$KIOSK_URL${NC}\"\n    echo -e \"  ${BOLD}Kiosk user:${NC}       ${CYAN}$KIOSK_USER${NC}\"\n    echo \"\"\n\n    if [[ \"$INSTALL_MODE\" == \"full\" ]]; then\n        echo -e \"  ${BOLD}Next steps:${NC}\"\n        echo -e \"    1. Reboot (required for kiosk, Plymouth splash, and hardware changes)\"\n        echo -e \"    2. The touchscreen kiosk will start automatically after reboot\"\n        echo -e \"    3. On another device, open ${CYAN}http://$ip_addr:$BAMBUDDY_PORT${NC} to complete first-run admin setup\"\n    fi\n\n    echo \"\"\n    echo -e \"  ${BOLD}Manage services:${NC}\"\n    echo -e \"    SpoolBuddy status:   ${CYAN}sudo systemctl status spoolbuddy${NC}\"\n    echo -e \"    SpoolBuddy logs:     ${CYAN}sudo journalctl -u spoolbuddy -f${NC}\"\n    if [[ \"$INSTALL_MODE\" == \"full\" ]]; then\n        echo -e \"    Bambuddy status:     ${CYAN}sudo systemctl status bambuddy${NC}\"\n        echo -e \"    Bambuddy logs:       ${CYAN}sudo journalctl -u bambuddy -f${NC}\"\n    fi\n\n    echo \"\"\n    echo -e \"  ${BOLD}Configuration:${NC}    ${CYAN}$INSTALL_PATH/spoolbuddy/.env${NC}\"\n    echo -e \"  ${BOLD}Hardware wiring:${NC}  ${CYAN}$INSTALL_PATH/spoolbuddy/README.md${NC}\"\n    echo -e \"  ${BOLD}Diagnostics:${NC}      ${CYAN}sudo $INSTALL_PATH/spoolbuddy/venv/bin/python $INSTALL_PATH/spoolbuddy/pn5180_diag.py${NC}\"\n    echo \"\"\n\n    echo -e \"  ${YELLOW}A reboot is required to apply all changes (kiosk, Plymouth splash, hardware).${NC}\"\n    echo \"\"\n    if prompt_yes_no \"Reboot now?\" \"y\"; then\n        reboot\n    else\n        echo -e \"  Run ${CYAN}sudo reboot${NC} when ready.\"\n    fi\n\n    echo \"\"\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "spoolbuddy/install/spoolbuddy-idle.sh",
    "content": "#!/bin/bash\n# SpoolBuddy kiosk display idle watchdog.\n#\n# Powers the HDMI output off via wlopm after the configured inactivity\n# timeout, driven by swayidle inside the labwc Wayland session. The timeout\n# value is fetched once from the Bambuddy backend on startup so it matches\n# whatever the user picked in SpoolBuddy Settings → Display. Changes made\n# in the UI take effect on the next reboot / kiosk restart.\n#\n# Runs in labwc's autostart file as the kiosk user — needs access to\n# WAYLAND_DISPLAY, which it inherits from the parent labwc process.\n\nset -u\n\nLOG_FILE=\"${SPOOLBUDDY_IDLE_LOG:-$HOME/.cache/spoolbuddy-idle.log}\"\nmkdir -p \"$(dirname \"$LOG_FILE\")\" 2>/dev/null || true\nexec >>\"$LOG_FILE\" 2>&1\necho \"=== $(date -Is) spoolbuddy-idle starting (pid=$$) ===\"\necho \"WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-<unset>}\"\necho \"XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-<unset>}\"\necho \"PATH=$PATH\"\n\nDEFAULT_TIMEOUT=300\nENV_FILE=\"${SPOOLBUDDY_ENV_FILE:-/opt/bambuddy/spoolbuddy/.env}\"\nOUTPUT=\"${SPOOLBUDDY_DISPLAY_OUTPUT:-HDMI-A-1}\"\n\n# Wait for labwc to actually bring up its Wayland socket. Autostart fires\n# before labwc finishes exporting WAYLAND_DISPLAY on some systems, which\n# makes swayidle exit immediately.\nif [ -z \"${WAYLAND_DISPLAY:-}\" ] && [ -n \"${XDG_RUNTIME_DIR:-}\" ]; then\n    for _ in $(seq 1 20); do\n        sock=$(ls -1 \"$XDG_RUNTIME_DIR\"/wayland-* 2>/dev/null | grep -v '\\.lock$' | head -n1 || true)\n        if [ -n \"$sock\" ]; then\n            WAYLAND_DISPLAY=\"$(basename \"$sock\")\"\n            export WAYLAND_DISPLAY\n            echo \"auto-detected WAYLAND_DISPLAY=$WAYLAND_DISPLAY\"\n            break\n        fi\n        sleep 0.5\n    done\nfi\nif [ -z \"${XDG_RUNTIME_DIR:-}\" ]; then\n    XDG_RUNTIME_DIR=\"/run/user/$(id -u)\"\n    export XDG_RUNTIME_DIR\n    echo \"defaulted XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR\"\nfi\n\nif [ -r \"$ENV_FILE\" ]; then\n    set -a\n    # shellcheck disable=SC1090\n    . \"$ENV_FILE\"\n    set +a\nfi\n\nBACKEND_URL=\"${SPOOLBUDDY_BACKEND_URL:-}\"\nAPI_KEY=\"${SPOOLBUDDY_API_KEY:-}\"\nDEVICE_ID=\"${SPOOLBUDDY_DEVICE_ID:-}\"\n\n# Derive device_id from the first non-loopback NIC MAC address, the same\n# algorithm daemon/config.py uses so installs without an explicit\n# SPOOLBUDDY_DEVICE_ID still match.\nif [ -z \"$DEVICE_ID\" ]; then\n    for iface in $(ls -1 /sys/class/net/ 2>/dev/null | sort); do\n        [ \"$iface\" = \"lo\" ] && continue\n        addr_file=\"/sys/class/net/$iface/address\"\n        [ -r \"$addr_file\" ] || continue\n        mac=$(tr -d ':' < \"$addr_file\" 2>/dev/null)\n        if [ -n \"$mac\" ] && [ \"$mac\" != \"000000000000\" ]; then\n            DEVICE_ID=\"sb-$mac\"\n            break\n        fi\n    done\nfi\n\nTIMEOUT=\"$DEFAULT_TIMEOUT\"\nif [ -n \"$BACKEND_URL\" ] && [ -n \"$API_KEY\" ] && [ -n \"$DEVICE_ID\" ]; then\n    response=$(curl -fsS --max-time 10 \\\n        -H \"Authorization: Bearer $API_KEY\" \\\n        \"$BACKEND_URL/api/v1/spoolbuddy/devices/$DEVICE_ID/display\" 2>/dev/null || true)\n    if [ -n \"$response\" ]; then\n        fetched=$(printf '%s' \"$response\" | jq -r '.blank_timeout // empty' 2>/dev/null || true)\n        if [ -n \"$fetched\" ] && [ \"$fetched\" -eq \"$fetched\" ] 2>/dev/null; then\n            TIMEOUT=\"$fetched\"\n        fi\n    fi\nfi\n\n# FIFO for the SpoolBuddy daemon to request display wake from outside the\n# Wayland session (NFC tag scan, scale weight change).  The daemon writes\n# \"wake\\n\" to this pipe; the monitor loop below calls wlopm --on.\nWAKE_FIFO=\"/tmp/spoolbuddy-wake\"\nrm -f \"$WAKE_FIFO\"\nmkfifo -m 622 \"$WAKE_FIFO\"\necho \"wake FIFO created at $WAKE_FIFO\"\n\nif [ \"$TIMEOUT\" -le 0 ]; then\n    # Blanking explicitly disabled — just monitor the wake FIFO so NFC/scale\n    # wake still works even without swayidle.\n    echo \"timeout<=0, monitoring wake FIFO only (no swayidle)\"\n    while read -r _ < \"$WAKE_FIFO\"; do\n        wlopm --on \"$OUTPUT\" 2>/dev/null || true\n    done\n    exit 0\nfi\n\necho \"starting swayidle with timeout=$TIMEOUT output=$OUTPUT\"\nswayidle -w \\\n    timeout \"$TIMEOUT\" \"wlopm --off $OUTPUT\" \\\n    resume \"wlopm --on $OUTPUT\" &\nSWAYIDLE_PID=$!\n\n# Monitor wake FIFO — when the daemon writes to it, turn the display on\n# and schedule a re-blank after TIMEOUT seconds (swayidle doesn't know about\n# FIFO wakes so it won't re-blank on its own).\nREBLANK_PID=\"\"\nwhile read -r _ < \"$WAKE_FIFO\"; do\n    wlopm --on \"$OUTPUT\" 2>/dev/null || true\n    # Cancel any pending re-blank timer, then start a new one\n    [ -n \"$REBLANK_PID\" ] && kill \"$REBLANK_PID\" 2>/dev/null || true\n    (sleep \"$TIMEOUT\" && wlopm --off \"$OUTPUT\" 2>/dev/null) &\n    REBLANK_PID=$!\ndone &\nFIFO_PID=$!\n\n# If either process exits, clean up and exit.\nwait -n \"$SWAYIDLE_PID\" \"$FIFO_PID\" 2>/dev/null\necho \"child exited, cleaning up\"\nkill \"$SWAYIDLE_PID\" \"$FIFO_PID\" 2>/dev/null || true\nrm -f \"$WAKE_FIFO\"\n"
  },
  {
    "path": "spoolbuddy/scripts/pn5180_diag.py",
    "content": "#!/usr/bin/env python3\n\"\"\"PN5180 NFC reader diagnostic script.\n\nConnects to a PN5180 over SPI on a Raspberry Pi and reads\nhardware status, version info, and register state.\n\nWiring (from spoolbuddy/README.md):\n    PN5180 VCC  -> Pi Pin 1  (3.3V)\n    PN5180 GND  -> Pi Pin 20 (GND)\n    PN5180 SCK  -> Pi Pin 23 (GPIO11)\n    PN5180 MISO -> Pi Pin 21 (GPIO9)\n    PN5180 MOSI -> Pi Pin 19 (GPIO10)\n    PN5180 NSS  -> Pi Pin 16 (GPIO23, manual CS)\n    PN5180 BUSY -> Pi Pin 22 (GPIO25)\n    PN5180 RST  -> Pi Pin 18 (GPIO24)\n\"\"\"\n\nimport os\nimport sys\nimport time\n\nimport gpiod\n\nsys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), \"..\", \"daemon\")))\n\n\nfrom pn5180 import (  # noqa: E402\n    NSS_PIN as DRIVER_NSS_PIN,\n    PN5180,\n    RST_PIN as DRIVER_RST_PIN,\n    SPI_BUS as DRIVER_SPI_BUS,\n    SPI_DEVICE as DRIVER_SPI_DEVICE,\n)\n\nREG_SYSTEM_CONFIG = 0x00\nREG_IRQ_ENABLE = 0x01\nREG_IRQ_STATUS = 0x02\nREG_IRQ_CLEAR = 0x03\nREG_TRANSCEIVE_CONTROL = 0x04\nREG_TIMER1_RELOAD = 0x0C\nREG_TIMER1_CONFIG = 0x0F\nREG_RX_WAIT_CONFIG = 0x11\nREG_CRC_RX_CONFIG = 0x12\nREG_RX_STATUS = 0x13\nREG_CRC_TX_CONFIG = 0x19\nREG_RF_STATUS = 0x1D\nREG_SYSTEM_STATUS = 0x24\nREG_SIGPRO_CONFIG = 0x1A  # Signal Processing Configuration\nREG_TEMP_CONTROL = 0x25\n\n# ---------------------------------------------------------------------------\n# EEPROM addresses\n# ---------------------------------------------------------------------------\nEEPROM_DIE_IDENTIFIER = 0x00  # 16 bytes\nEEPROM_PRODUCT_VERSION = 0x10  # 2 bytes\nEEPROM_FIRMWARE_VERSION = 0x12  # 2 bytes\nEEPROM_EEPROM_VERSION = 0x14  # 2 bytes\nEEPROM_IRQ_PIN_CONFIG = 0x1A  # 1 byte\n\n\ndef _check_spi_device_access() -> str:\n    \"\"\"Check that the configured spidev exists and can be opened.\"\"\"\n    spi_path = f\"/dev/spidev{DRIVER_SPI_BUS}.{DRIVER_SPI_DEVICE}\"\n    if not os.path.exists(spi_path):\n        raise FileNotFoundError(f\"SPI device not found: {spi_path}\")\n\n    fd = os.open(spi_path, os.O_RDWR)\n    os.close(fd)\n    return spi_path\n\n\ndef _self_test_control_pins(nfc: PN5180):\n    \"\"\"Toggle NSS and RST pins and print observed line state.\n    Uses public set_pin/get_pin methods to avoid direct access to driver internals.\n    \"\"\"\n    for pin_name, pin_num in ((\"NSS\", DRIVER_NSS_PIN), (\"RST\", DRIVER_RST_PIN)):\n        nfc.set_pin(pin_num, True)\n        time.sleep(0.005)\n        active_state = nfc.get_pin(pin_num)\n\n        nfc.set_pin(pin_num, False)\n        time.sleep(0.005)\n        inactive_state = nfc.get_pin(pin_num)\n\n        # Restore idle-high level used by this driver.\n        nfc.set_pin(pin_num, True)\n\n        print(\n            f\"    {pin_name} pin {pin_num}: \"\n            f\"ACTIVE->{'ACTIVE' if active_state else 'INACTIVE'}, \"\n            f\"INACTIVE->{'ACTIVE' if inactive_state else 'INACTIVE'}\"\n        )\n\n\ndef run_diagnostics():\n    print(\"=\" * 60)\n    print(\"PN5180 NFC Reader Diagnostics\")\n    print(\"=\" * 60)\n\n    nfc = None\n    try:\n        print(\"\\n[1] SPI device check...\")\n        spi_path = _check_spi_device_access()\n        print(f\"    SPI device OK: {spi_path}\")\n\n        nfc = PN5180()\n\n        print(\"\\n[2] Control pin self-test (NSS/RST)...\")\n        _self_test_control_pins(nfc)\n\n        # Reset\n        print(\"\\n[3] Hardware reset...\")\n        nfc.reset()\n        print(\"    Reset OK\")\n\n        # Version info\n        print(\"\\n[4] Version info (EEPROM)\")\n        product = nfc.read_eeprom(EEPROM_PRODUCT_VERSION, 2)\n        firmware = nfc.read_eeprom(EEPROM_FIRMWARE_VERSION, 2)\n        eeprom = nfc.read_eeprom(EEPROM_EEPROM_VERSION, 2)\n        die_id = nfc.read_eeprom(EEPROM_DIE_IDENTIFIER, 16)\n\n        print(f\"    Product version  : {product[1]}.{product[0]}\")\n        print(f\"    Firmware version : {firmware[1]}.{firmware[0]}\")\n        print(f\"    EEPROM version   : {eeprom[1]}.{eeprom[0]}\")\n        print(f\"    Die identifier   : {die_id.hex()}\")\n\n        # Register dump\n        print(\"\\n[5] Register dump\")\n        # Use register names from the script (not in pn5180.py)\n        REGISTER_NAMES_DUMP = {\n            0x00: \"SYSTEM_CONFIG\",\n            0x01: \"IRQ_ENABLE\",\n            0x02: \"IRQ_STATUS\",\n            0x03: \"IRQ_CLEAR\",\n            0x04: \"TRANSCEIVE_CONTROL\",\n            0x0C: \"TIMER1_RELOAD\",\n            0x0F: \"TIMER1_CONFIG\",\n            0x11: \"RX_WAIT_CONFIG\",\n            0x12: \"CRC_RX_CONFIG\",\n            0x13: \"RX_STATUS\",\n            0x19: \"CRC_TX_CONFIG\",\n            0x1A: \"SIGPRO_CONFIG\",\n            0x1D: \"RF_STATUS\",\n            0x24: \"SYSTEM_STATUS\",\n            0x25: \"TEMP_CONTROL\",\n        }\n        for addr, name in sorted(REGISTER_NAMES_DUMP.items()):\n            val = nfc.read_reg(addr)\n            print(f\"    0x{addr:02X} {name:<24s} = 0x{val:08X}\")\n\n        # SIGPRO_CONFIG ISO/IEC14443 mode check\n        sigpro_val = nfc.read_reg(REG_SIGPRO_CONFIG)\n        sigpro_mode = (sigpro_val >> 0) & 0b111\n        baudrate_map = {\n            0b100: \"106 kBd (ISO/IEC14443 type A/B)\",\n            0b101: \"212 kBd (FeliCa 212 kBd)\",\n            0b110: \"424 kBd (FeliCa 424 kBd)\",\n            0b111: \"848 kBd\",\n        }\n        baudrate_str = baudrate_map.get(sigpro_mode, \"Unknown or reserved\")\n        print(f\"\\n[5b] SIGPRO_CONFIG (0x1A) bits 2:0 = 0b{sigpro_mode:03b} ({baudrate_str})\")\n\n        # IRQ status breakdown\n        irq = nfc.read_reg(REG_IRQ_STATUS)\n        print(f\"\\n[6] IRQ status flags (0x{irq:08X})\")\n        irq_flags = [\n            (0, \"RX_IRQ\"),\n            (1, \"TX_IRQ\"),\n            (2, \"IDLE_IRQ\"),\n            (3, \"MODE_DETECTED_IRQ\"),\n            (4, \"CARD_ACTIVATED_IRQ\"),\n            (5, \"STATE_CHANGE_IRQ\"),\n            (6, \"RFOFF_DET_IRQ\"),\n            (7, \"RFON_DET_IRQ\"),\n            (8, \"TX_RFOFF_IRQ\"),\n            (9, \"TX_RFON_IRQ\"),\n            (10, \"RF_ACTIVE_ERROR_IRQ\"),\n            (14, \"LPCD_IRQ\"),\n        ]\n        for bit, name in irq_flags:\n            state = \"SET\" if irq & (1 << bit) else \"---\"\n            print(f\"    bit {bit:2d}: {name:<28s} [{state}]\")\n\n        # RF status\n        rf = nfc.read_reg(REG_RF_STATUS)\n        print(f\"\\n[7] RF status (0x{rf:08X})\")\n        tx_rf_on = bool(rf & (1 << 0))\n        rx_en = bool(rf & (1 << 1))\n        print(f\"    TX RF active : {tx_rf_on}\")\n        print(f\"    RX enabled   : {rx_en}\")\n\n        # System status\n        sys_stat = nfc.read_reg(REG_SYSTEM_STATUS)\n        print(f\"\\n[8] System status (0x{sys_stat:08X})\")\n\n        # System status bit breakdown\n        sys_stat_bits = [\n            (9, \"LDO_TVDD_OK\"),\n            (8, \"PARAMETER_ERROR\"),\n            (7, \"SYNTAX_ERROR\"),\n            (6, \"SEMANTIC_ERROR\"),\n            (5, \"STBY_PREVENT_RFLD\"),\n            (4, \"BOOT_TEMP\"),\n            (3, \"BOOT_SOFT_RESET\"),\n            (2, \"BOOT_WUC\"),\n            (1, \"BOOT_RFLD\"),\n            (0, \"BOOT_POR\"),\n        ]\n        for bit, symbol in sys_stat_bits:\n            state = \"SET\" if sys_stat & (1 << bit) else \"---\"\n            print(f\"    bit {bit:2d}: {symbol:<18s} [{state}]\")\n\n        # Temperature\n        temp_ctrl = nfc.read_reg(REG_TEMP_CONTROL)\n        print(f\"\\n[9] Temp control register (0x{temp_ctrl:08X})\")\n\n        # TEMP_DELTA bits 1:0\n        temp_delta = (temp_ctrl >> 0) & 0b11\n        temp_delta_map = {\n            0b00: \"85°C\",\n            0b01: \"115°C\",\n            0b10: \"125°C\",\n            0b11: \"135°C\",\n        }\n        temp_delta_str = temp_delta_map.get(temp_delta, \"Unknown\")\n        print(f\"    bits 1:0 TEMP_DELTA = 0b{temp_delta:02b} ({temp_delta_str})\")\n\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Diagnostics complete - PN5180 is responding over SPI.\")\n        print(\"=\" * 60)\n\n    except TimeoutError as e:\n        print(f\"\\nERROR: {e}\")\n        print(\"Check wiring and ensure SPI is enabled (dtparam=spi=on in /boot/firmware/config.txt)\")\n        sys.exit(1)\n    except Exception as e:\n        print(f\"\\nERROR: {e}\")\n        sys.exit(1)\n    finally:\n        if nfc is not None:\n            nfc.close()\n\n\nif __name__ == \"__main__\":\n    run_diagnostics()\n"
  },
  {
    "path": "spoolbuddy/scripts/read_tag.py",
    "content": "#!/usr/bin/env python3\n\"\"\"PN5180 NFC tag reader — ported from working Pico firmware (pico-nfc-bridge.ino).\n\nKey learnings from pico-nfc-bridge.ino:\n- Must call setTransceiveMode() before every SEND_DATA\n- waitBusy() must wait for HIGH then LOW (not just LOW)\n- Bambu tags are MIFARE Classic 1K (ISO 14443A), not ISO 15693\n- SPI at 500kHz, 5µs CS setup, 100µs post-CS delay\n- MFC_AUTHENTICATE (0x0C) is a PN5180 host command — Crypto1 handled in hardware\n- HKDF-SHA256 derives per-sector keys from master key + UID\n\"\"\"\n\nimport hashlib\nimport hmac\nimport os\nimport sys\nimport time\n\nimport gpiod\nimport spidev\n\n\ndef _env_int(name: str, default: int) -> int:\n    value = os.environ.get(name)\n    if value is None or value == \"\":\n        return default\n    try:\n        return int(value)\n    except ValueError:\n        return default\n\n\nBUSY_PIN = _env_int(\"SPOOLBUDDY_NFC_BUSY_PIN\", 25)\nRST_PIN = _env_int(\"SPOOLBUDDY_NFC_RST_PIN\", 24)\nNSS_PIN = _env_int(\"SPOOLBUDDY_NFC_NSS_PIN\", 23)  # Manual CS by default\nSPI_BUS = _env_int(\"SPOOLBUDDY_NFC_SPI_BUS\", 0)\nSPI_DEVICE = _env_int(\"SPOOLBUDDY_NFC_SPI_DEVICE\", 0)\nSPI_SPEED_HZ = _env_int(\"SPOOLBUDDY_NFC_SPI_SPEED_HZ\", 500_000)\n\n# Bambu Lab MIFARE Classic key derivation constants (from pico-nfc-bridge.ino)\nBAMBU_MASTER_KEY = bytes(\n    [\n        0x9A,\n        0x75,\n        0x9C,\n        0xF2,\n        0xC4,\n        0xF7,\n        0xCA,\n        0xFF,\n        0x22,\n        0x2C,\n        0xB9,\n        0x76,\n        0x9B,\n        0x41,\n        0xBC,\n        0x96,\n    ]\n)\nBAMBU_CONTEXT = b\"RFID-A\\x00\"  # 7 bytes including null terminator\n\n# Blocks to read for Bambu tag data\nBAMBU_BLOCKS = [1, 2, 4, 5]\n\n\ndef hkdf_derive_keys(uid: bytes) -> bytes:\n    \"\"\"Derive 96 bytes of MIFARE key material (16 sectors * 6 bytes each).\n\n    Uses HKDF-SHA256 with the Bambu master key as salt and the tag UID as IKM.\n    \"\"\"\n    # HKDF-Extract: PRK = HMAC-SHA256(salt=master_key, IKM=uid)\n    prk = hmac.new(BAMBU_MASTER_KEY, uid, hashlib.sha256).digest()\n\n    # HKDF-Expand: generate 96 bytes using context \"RFID-A\\0\"\n    okm = b\"\"\n    t = b\"\"\n    counter = 1\n    while len(okm) < 96:\n        t = hmac.new(prk, t + BAMBU_CONTEXT + bytes([counter]), hashlib.sha256).digest()\n        okm += t\n        counter += 1\n    return okm[:96]\n\n\ndef get_sector_key(keys: bytes, block: int) -> bytes:\n    \"\"\"Get the 6-byte key for the sector containing the given block.\"\"\"\n    sector = block // 4\n    return keys[sector * 6 : sector * 6 + 6]\n\n\ndef _find_gpio_chip():\n    for path in [\"/dev/gpiochip4\", \"/dev/gpiochip0\"]:\n        try:\n            chip = gpiod.Chip(path)\n            if \"pinctrl\" in chip.get_info().label:\n                return chip\n            chip.close()\n        except (FileNotFoundError, PermissionError, OSError):\n            continue\n    raise RuntimeError(\"No GPIO chip\")\n\n\nclass PN5180:\n    def __init__(self):\n        self._chip = _find_gpio_chip()\n        self._lines = self._chip.request_lines(\n            consumer=\"pn5180\",\n            config={\n                BUSY_PIN: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT),\n                RST_PIN: gpiod.LineSettings(\n                    direction=gpiod.line.Direction.OUTPUT, output_value=gpiod.line.Value.ACTIVE\n                ),\n                NSS_PIN: gpiod.LineSettings(\n                    direction=gpiod.line.Direction.OUTPUT, output_value=gpiod.line.Value.ACTIVE\n                ),\n            },\n        )\n        self._spi = spidev.SpiDev()\n        self._spi.open(SPI_BUS, SPI_DEVICE)\n        self._spi.max_speed_hz = SPI_SPEED_HZ\n        self._spi.mode = 0b00\n        self._spi.no_cs = True\n\n    def close(self):\n        self._spi.close()\n        self._lines.release()\n        self._chip.close()\n\n    def _cs_low(self):\n        self._lines.set_value(NSS_PIN, gpiod.line.Value.INACTIVE)\n        time.sleep(0.000005)  # 5µs setup\n\n    def _cs_high(self):\n        self._lines.set_value(NSS_PIN, gpiod.line.Value.ACTIVE)\n        time.sleep(0.000100)  # 100µs post-CS delay\n\n    def _wait_busy(self, timeout_s=1.0):\n        \"\"\"Wait for BUSY to go HIGH (processing) then LOW (done) — matches Pico firmware.\"\"\"\n        deadline = time.monotonic() + min(timeout_s, 0.010)\n        # Wait for BUSY HIGH (PN5180 started processing)\n        while self._lines.get_value(BUSY_PIN) != gpiod.line.Value.ACTIVE:\n            if time.monotonic() > deadline:\n                break  # Timeout waiting for HIGH — command may have processed already\n            time.sleep(0.00001)\n        # Wait for BUSY LOW (PN5180 done)\n        deadline = time.monotonic() + timeout_s\n        while self._lines.get_value(BUSY_PIN) == gpiod.line.Value.ACTIVE:\n            if time.monotonic() > deadline:\n                raise TimeoutError(\"BUSY timeout\")\n            time.sleep(0.0001)\n\n    def _cmd(self, data):\n        self._cs_low()\n        self._spi.xfer2(list(data))\n        self._cs_high()\n        self._wait_busy()\n\n    def _read_response(self, n):\n        self._cs_low()\n        result = self._spi.xfer2([0xFF] * n)\n        self._cs_high()\n        return result\n\n    # -- Register ops --\n\n    def write_reg(self, reg, val):\n        self._cmd([0x00, reg, val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF])\n\n    def write_reg_or(self, reg, mask):\n        self._cmd([0x01, reg, mask & 0xFF, (mask >> 8) & 0xFF, (mask >> 16) & 0xFF, (mask >> 24) & 0xFF])\n\n    def write_reg_and(self, reg, mask):\n        self._cmd([0x02, reg, mask & 0xFF, (mask >> 8) & 0xFF, (mask >> 16) & 0xFF, (mask >> 24) & 0xFF])\n\n    def read_reg(self, reg):\n        self._cmd([0x04, reg])\n        time.sleep(0.000100)  # Extra 100µs before read\n        return int.from_bytes(self._read_response(4), \"little\")\n\n    def read_eeprom(self, addr, length):\n        self._cmd([0x07, addr, length])\n        time.sleep(0.000100)\n        return bytes(self._read_response(length))\n\n    # -- Commands --\n\n    def reset(self):\n        self._lines.set_value(RST_PIN, gpiod.line.Value.INACTIVE)\n        time.sleep(0.050)\n        self._lines.set_value(RST_PIN, gpiod.line.Value.ACTIVE)\n        time.sleep(0.100)\n        self._wait_busy(2.0)\n        time.sleep(0.050)\n\n    def load_rf_config(self, tx, rx):\n        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs first\n        time.sleep(0.000100)\n        self._cmd([0x11, tx, rx])\n        time.sleep(0.010)\n\n    def rf_on(self):\n        self._cmd([0x16, 0x00])\n        time.sleep(0.010)\n\n    def rf_off(self):\n        self._cmd([0x17, 0x00])\n        time.sleep(0.005)\n\n    def set_transceive_mode(self):\n        \"\"\"Set SYSTEM_CONFIG command bits to TRANSCEIVE (0x03) — CRITICAL!\"\"\"\n        sys_cfg = self.read_reg(0x00)\n        sys_cfg = (sys_cfg & 0xFFFFFFF8) | 0x03\n        self.write_reg(0x00, sys_cfg)\n\n    def send_data(self, data, valid_bits=0x00):\n        self._cs_low()\n        self._spi.xfer2([0x09, valid_bits] + list(data))\n        self._cs_high()\n        time.sleep(0.000100)\n        self._wait_busy()\n\n    def read_data(self, length):\n        self._cmd([0x0A, 0x00])\n        return bytes(self._read_response(length))\n\n    # -- ISO 14443A --\n\n    def activate_type_a(self):\n        \"\"\"Full Type A activation: WUPA -> Anticollision -> SELECT. Returns (uid, sak) or None.\"\"\"\n        # Crypto off, CRC off\n        self.write_reg_and(0x00, 0xFFFFFFBF)\n        self.write_reg_and(0x12, 0xFFFFFFFE)\n        self.write_reg_and(0x19, 0xFFFFFFFE)\n        self.write_reg(0x03, 0xFFFFFFFF)\n\n        # Reset to IDLE then TRANSCEIVE\n        sys_cfg = self.read_reg(0x00)\n        self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE\n        time.sleep(0.001)\n        self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE\n        time.sleep(0.002)\n\n        # WUPA (7-bit)\n        self.send_data([0x52], valid_bits=0x07)\n        time.sleep(0.005)\n\n        rx_status = self.read_reg(0x13)\n        rx_len = rx_status & 0x1FF\n        if rx_len < 2 or rx_len == 511:\n            # Try REQA\n            self.write_reg(0x03, 0xFFFFFFFF)\n            time.sleep(0.002)\n            self.set_transceive_mode()\n            time.sleep(0.002)\n            self.send_data([0x26], valid_bits=0x07)\n            time.sleep(0.005)\n            rx_status = self.read_reg(0x13)\n            rx_len = rx_status & 0x1FF\n            if rx_len < 2 or rx_len == 511:\n                return None\n\n        atqa = self.read_data(2)\n        if atqa[0] == 0xFF or atqa[0] == 0x00:\n            return None\n\n        # Anti-collision Level 1\n        self.write_reg(0x03, 0xFFFFFFFF)\n        self.set_transceive_mode()\n        time.sleep(0.002)\n\n        self.send_data([0x93, 0x20])\n        time.sleep(0.010)\n\n        rx_status = self.read_reg(0x13)\n        rx_len = rx_status & 0x1FF\n        if rx_len < 5 or rx_len > 64:\n            return None\n\n        uid_buf = self.read_data(5)\n        uid = uid_buf[:4]\n        bcc = uid[0] ^ uid[1] ^ uid[2] ^ uid[3]\n        if bcc != uid_buf[4]:\n            return None\n\n        # SELECT\n        self.write_reg(0x03, 0xFFFFFFFF)\n        self.set_transceive_mode()\n        time.sleep(0.002)\n\n        # Enable CRC for SELECT\n        self.write_reg_or(0x19, 0x01)\n        self.write_reg_or(0x12, 0x01)\n\n        self.send_data([0x93, 0x70, uid[0], uid[1], uid[2], uid[3], bcc])\n        time.sleep(0.010)\n\n        rx_status = self.read_reg(0x13)\n        rx_len = rx_status & 0x1FF\n        if rx_len < 1:\n            return None\n\n        sak_buf = self.read_data(min(rx_len, 3))\n        sak = sak_buf[0]\n\n        return bytes(uid), sak\n\n    # -- MIFARE Classic --\n\n    def mfc_authenticate(self, block: int, key: bytes, uid: bytes) -> bool:\n        \"\"\"MIFARE Classic authentication via PN5180 MFC_AUTHENTICATE (0x0C).\n\n        The PN5180 handles Crypto1 internally. After success, bit 6 of\n        SYSTEM_CONFIG is set (MFC_CRYPTO1_ON) and all subsequent RF\n        communication is encrypted/decrypted by the hardware.\n\n        Args:\n            block: Block number to authenticate\n            key: 6-byte MIFARE Key A\n            uid: 4-byte tag UID\n        Returns:\n            True if authentication succeeded\n        \"\"\"\n        # Wait for BUSY LOW before starting\n        deadline = time.monotonic() + 0.100\n        while self._lines.get_value(BUSY_PIN) == gpiod.line.Value.ACTIVE:\n            if time.monotonic() > deadline:\n                return False\n            time.sleep(0.001)\n\n        # MFC_AUTHENTICATE: [0x0C][key 6B][keyType][blockNo][uid 4B] = 13 bytes\n        cmd = [0x0C] + list(key) + [0x60, block] + list(uid[:4])\n        self._cs_low()\n        self._spi.xfer2(cmd)\n        self._cs_high()\n\n        # Wait for BUSY HIGH then LOW (auth can take up to 1s)\n        self._wait_busy(timeout_s=1.0)\n\n        # Read 1-byte response: 0x00 = success\n        self._cs_low()\n        response = self._spi.xfer2([0xFF])\n        self._cs_high()\n\n        return response[0] == 0x00\n\n    def mfc_read_block(self, block: int) -> bytes | None:\n        \"\"\"Read a 16-byte MIFARE Classic block (must be authenticated first).\n\n        Returns 16 bytes of block data, or None on failure.\n        \"\"\"\n        # Clear IRQs\n        self.write_reg(0x03, 0xFFFFFFFF)\n\n        # Set transceive mode (Crypto1 stays active from MFC_AUTHENTICATE)\n        self.set_transceive_mode()\n        time.sleep(0.001)\n\n        # Enable TX and RX CRC for encrypted read\n        self.write_reg_or(0x19, 0x01)\n        self.write_reg_or(0x12, 0x01)\n\n        # Send MIFARE READ command: 0x30 + block number\n        self.send_data([0x30, block])\n        time.sleep(0.010)\n\n        # Check RX status\n        rx_status = self.read_reg(0x13)\n        rx_len = rx_status & 0x1FF\n        if rx_len != 16:\n            return None\n\n        return self.read_data(16)\n\n    def _ntag_reactivate(self) -> bool:\n        \"\"\"Full hardware reset + RF activation for NTAG re-selection between reads.\n\n        The PN5180 enters an unrecoverable state after an NTAG READ command —\n        simple RF off/on cycles cannot re-select the tag. A full GPIO hardware\n        reset is required to clear the internal transceiver state.\n        \"\"\"\n        self.reset()\n        self.load_rf_config(0x00, 0x80)  # ISO 14443A\n        time.sleep(0.010)\n        self.rf_on()\n        time.sleep(0.030)\n        self.set_transceive_mode()\n\n        return self.activate_type_a() is not None\n\n    def ntag_read_pages(self, start_page: int, num_pages: int) -> bytes | None:\n        \"\"\"Read NTAG pages (4 bytes each). No authentication required.\n\n        Uses NTAG READ command (0x30) which returns 4 pages (16 bytes) at a time.\n        The PN5180 cannot issue consecutive NTAG READs in one session — the card\n        stops responding after the first READ. We do a full RF cycle and re-select\n        with extended timing between each 4-page batch.\n        \"\"\"\n        result = bytearray()\n        pages_read = 0\n        while pages_read < num_pages:\n            if pages_read > 0:\n                if not self._ntag_reactivate():\n                    print(f\"    Failed to reactivate card before page {start_page + pages_read}\")\n                    return None\n\n            # Setup: Crypto1 off, TX CRC on, RX CRC off, IDLE→TRANSCEIVE\n            self.write_reg_and(0x00, 0xFFFFFFBF)\n            self.write_reg_or(0x19, 0x01)\n            self.write_reg_and(0x12, 0xFFFFFFFE)\n            self.write_reg(0x03, 0xFFFFFFFF)\n\n            sys_cfg = self.read_reg(0x00)\n            self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE\n            time.sleep(0.001)\n            self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE\n            time.sleep(0.002)\n\n            # READ command: 0x30 + page → returns 16 bytes (4 pages)\n            self.send_data([0x30, start_page + pages_read])\n            time.sleep(0.010)\n\n            rx_status = self.read_reg(0x13)\n            rx_len = rx_status & 0x1FF\n            if rx_len < 16:\n                # Tag may have fewer pages than requested (e.g. MIFARE Ultralight\n                # has only 16 pages). Return what we have so far.\n                if result:\n                    return bytes(result)\n                print(f\"    NTAG read page {start_page + pages_read}: rx_len={rx_len} (expected >=16)\")\n                return None\n\n            data = self.read_data(16)\n            pages_to_copy = min(4, num_pages - pages_read)\n            result.extend(data[: pages_to_copy * 4])\n            pages_read += 4\n\n        return bytes(result)\n\n    def reactivate_card(self) -> tuple[bytes, int] | None:\n        \"\"\"RF cycle and full re-select of the card. Returns (uid, sak) or None.\"\"\"\n        self.rf_off()\n        time.sleep(0.010)\n\n        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs\n        self.load_rf_config(0x00, 0x80)  # ISO 14443A\n        time.sleep(0.005)\n\n        self.rf_on()\n        time.sleep(0.020)\n\n        return self.activate_type_a()\n\n    def read_bambu_tag(self, uid: bytes) -> dict[int, bytes] | None:\n        \"\"\"Read Bambu tag data blocks using HKDF-derived keys.\n\n        Args:\n            uid: 4-byte tag UID (from activate_type_a)\n        Returns:\n            Dict mapping block number -> 16 bytes of data, or None on failure\n        \"\"\"\n        # Derive per-sector keys from UID\n        keys = hkdf_derive_keys(uid)\n\n        # Clear Crypto1 state and IRQs\n        self.write_reg_and(0x00, 0xFFFFFFBF)  # Clear MFC_CRYPTO1_ON (bit 6)\n        self.write_reg(0x03, 0xFFFFFFFF)\n\n        # Reactivate card (may have timed out)\n        result = self.reactivate_card()\n        if result is None:\n            print(\"    Failed to reactivate card\")\n            return None\n\n        uid_check, _ = result\n        if uid_check != uid:\n            print(f\"    UID mismatch after reactivation: {uid_check.hex()} != {uid.hex()}\")\n            return None\n\n        # Read blocks with per-sector authentication\n        blocks = {}\n        current_sector = -1\n\n        for block in BAMBU_BLOCKS:\n            sector = block // 4\n\n            # Authenticate when entering a new sector\n            if sector != current_sector:\n                key = get_sector_key(keys, block)\n                if not self.mfc_authenticate(block, key, uid):\n                    print(f\"    Auth failed for block {block} (sector {sector})\")\n                    return None\n                current_sector = sector\n\n            # Read the block\n            data = self.mfc_read_block(block)\n            if data is None:\n                print(f\"    Read failed for block {block}\")\n                return None\n            blocks[block] = data\n\n        return blocks\n\n    def ntag_write_page(self, page: int, data: bytes) -> bool:\n        \"\"\"Write 4 bytes to a single NTAG page.\n\n        NTAG WRITE command: 0xA2 + page_number + 4 bytes data.\n        TX CRC on (tag requires it). Always returns True — the 4-bit ACK\n        cannot be captured by the PN5180, so verification is deferred to\n        ntag_write_pages() which reads back all written data.\n        \"\"\"\n        if len(data) != 4:\n            return False\n\n        # Crypto1 off, TX CRC on (tag expects CRC), RX CRC off (ACK is 4-bit, no CRC)\n        self.write_reg_and(0x00, 0xFFFFFFBF)  # Crypto1 off\n        self.write_reg_or(0x19, 0x01)  # TX CRC on\n        self.write_reg_and(0x12, 0xFFFFFFFE)  # RX CRC off\n        self.write_reg(0x03, 0xFFFFFFFF)  # Clear IRQs\n\n        # Reset state machine: IDLE then TRANSCEIVE\n        sys_cfg = self.read_reg(0x00)\n        self.write_reg(0x00, sys_cfg & 0xFFFFFFF8)  # IDLE\n        time.sleep(0.001)\n        self.write_reg(0x00, (sys_cfg & 0xFFFFFFF8) | 0x03)  # TRANSCEIVE\n        time.sleep(0.002)\n\n        # WRITE command: 0xA2 + page + 4 bytes\n        self.send_data([0xA2, page] + list(data))\n        time.sleep(0.005)\n\n        # PN5180 cannot reliably capture the 4-bit ACK, so always return True\n        return True\n\n    def ntag_write_pages(self, start_page: int, data: bytes) -> bool:\n        \"\"\"Write data to consecutive NTAG pages starting at start_page.\n\n        Pads last chunk to 4 bytes. Verification is skipped — the PN5180\n        cannot reliably read back NTAG pages after a batch write (the\n        second READ command gets no response). The write itself is reliable:\n        the tag ACKs each page (RX SOF detected on every response).\n        \"\"\"\n        # Pad to 4-byte boundary\n        padded = bytearray(data)\n        while len(padded) % 4 != 0:\n            padded.append(0x00)\n\n        # Write page by page\n        num_pages = len(padded) // 4\n        for i in range(0, len(padded), 4):\n            page = start_page + (i // 4)\n            chunk = bytes(padded[i : i + 4])\n            if not self.ntag_write_page(page, chunk):\n                print(f\"    NTAG write failed at page {page} (of {num_pages} pages)\")\n                return False\n            time.sleep(0.002)\n\n        print(f\"    NTAG write complete ({num_pages} pages)\")\n        return True\n\n    def read_ntag(self, uid: bytes) -> bytes | None:\n        \"\"\"Read NTAG pages 4-20 (NDEF data area, 68 bytes). No auth needed.\n\n        Used for SpoolEase / OpenPrintTag community tags.\n        \"\"\"\n        # Reactivate card\n        result = self.reactivate_card()\n        if result is None:\n            print(\"    Failed to reactivate card\")\n            return None\n\n        return self.ntag_read_pages(start_page=4, num_pages=17)\n\n\ndef _print_hex_dump(data: bytes, label: str, bytes_per_line: int = 16):\n    \"\"\"Print a hex dump with ASCII sidebar.\"\"\"\n    for i in range(0, len(data), bytes_per_line):\n        chunk = data[i : i + bytes_per_line]\n        hex_str = \" \".join(f\"{b:02X}\" for b in chunk)\n        ascii_str = \"\".join(chr(b) if 32 <= b < 127 else \".\" for b in chunk)\n        print(f\"    {label}{i:3d}: {hex_str:<{bytes_per_line * 3}}|{ascii_str}|\")\n\n\ndef main():\n    print(\"=\" * 60)\n    print(\"PN5180 NFC Tag Reader\")\n    print(\"  Supports: Bambu (MIFARE Classic) + NTAG (SpoolEase/OpenPrintTag)\")\n    print(\"=\" * 60)\n\n    try:\n        nfc = PN5180()\n    except (OSError, RuntimeError, PermissionError) as e:\n        print(f\"\\nERROR: Failed to initialize NFC reader: {e}\")\n\n        # Check if it's a resource conflict\n        error_str = str(e).lower()\n        is_resource_conflict = any(x in error_str for x in [\"busy\", \"resource\", \"already in use\", \"permission denied\"])\n\n        if is_resource_conflict:\n            print(\"\\nGPIO/SPI RESOURCE IN USE: Another process is using the NFC reader.\")\n            print(\"This typically means the SpoolBuddy daemon is already reading tags.\")\n            print(\"\\nTo run this diagnostic, stop the daemon first:\")\n            print(\"  sudo systemctl stop bambuddy\")\n            print(\"  # Run diagnostic\")\n            print(\"  .../read_tag.py\")\n            print(\"  # Restart daemon when done:\")\n            print(\"  sudo systemctl start bambuddy\")\n        else:\n            print(\"\\nCheck:\")\n            print(\"  - Correct GPIO chip is available (/dev/gpiochip0 or /dev/gpiochip4)\")\n            print(f\"  - SPI device is available (SPI_BUS={SPI_BUS}, SPI_DEVICE={SPI_DEVICE})\")\n            print(\"  - GPIO and SPI permissions are correct\")\n            # Only print full traceback for unexpected errors\n            import traceback\n\n            traceback.print_exc()\n\n        sys.exit(1)\n\n    try:\n        nfc.reset()\n        ver = nfc.read_eeprom(0x10, 2)\n        print(f\"[1] Reset OK — product v{ver[1]}.{ver[0]}\")\n\n        nfc.load_rf_config(0x00, 0x80)  # ISO 14443A\n        time.sleep(0.010)\n        nfc.rf_on()\n        time.sleep(0.030)\n        nfc.set_transceive_mode()\n\n        rf = nfc.read_reg(0x1D)\n        print(f\"[2] RF ON  (RF_STATUS=0x{rf:08X}, TX_RF={'ON' if rf & 1 else 'OFF'})\")\n\n        print(\"[3] Scanning for tag...\")\n        result = nfc.activate_type_a()\n\n        if result is None:\n            print(\"    No tag found.\")\n            sys.exit(1)\n\n        uid, sak = result\n        tag_types = {\n            0x00: \"NTAG\",\n            0x04: \"NTAG (MIFARE Ultralight)\",\n            0x08: \"MIFARE Classic 1K\",\n            0x18: \"MIFARE Classic 4K\",\n        }\n        print(f\"    UID : {uid.hex().upper()}\")\n        print(f\"    SAK : 0x{sak:02X} ({tag_types.get(sak, 'Unknown')})\")\n\n        if sak in (0x08, 0x18):\n            # MIFARE Classic 1K or 4K — Bambu Lab tag\n            print(\"[4] Reading Bambu tag data (MIFARE Classic)...\")\n            blocks = nfc.read_bambu_tag(uid)\n\n            if blocks is None:\n                print(\"    Failed to read tag data.\")\n                nfc.rf_off()\n                sys.exit(1)\n\n            print(\"[5] Tag data:\")\n            for block_num in BAMBU_BLOCKS:\n                data = blocks[block_num]\n                hex_str = \" \".join(f\"{b:02X}\" for b in data)\n                ascii_str = \"\".join(chr(b) if 32 <= b < 127 else \".\" for b in data)\n                print(f\"    Block {block_num:2d}: {hex_str}  |{ascii_str}|\")\n\n            raw = b\"\"\n            for block_num in BAMBU_BLOCKS:\n                raw += blocks[block_num]\n            print(f\"\\n    Raw payload ({len(raw)} bytes): {raw.hex().upper()}\")\n\n        elif sak in (0x00, 0x04):\n            # NTAG / MIFARE Ultralight family — SpoolEase / OpenPrintTag\n            print(\"[4] Reading NTAG data (pages 4-20)...\")\n            ntag_data = nfc.read_ntag(uid)\n\n            if ntag_data is None:\n                print(\"    Failed to read NTAG data.\")\n                nfc.rf_off()\n                sys.exit(1)\n\n            print(f\"[5] NTAG data ({len(ntag_data)} bytes):\")\n            _print_hex_dump(ntag_data, \"page \")\n\n        else:\n            print(f\"    Unsupported tag type (SAK=0x{sak:02X})\")\n            nfc.rf_off()\n            sys.exit(1)\n\n        nfc.rf_off()\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Tag read complete!\")\n        print(\"=\" * 60)\n\n    except Exception as e:\n        print(f\"\\nERROR: {e}\")\n        import traceback\n\n        traceback.print_exc()\n        sys.exit(1)\n    finally:\n        nfc.close()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "spoolbuddy/scripts/scale_diag.py",
    "content": "#!/usr/bin/env python3\n\"\"\"NAU7802 Scale Diagnostic.\n\nI2C address: 0x2A\nBus: /dev/i2c-1 (GPIO2/GPIO3 on RPi)\n\"\"\"\n\nimport os\nimport sys\nimport time\n\nimport smbus2\n\nsys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), \"..\", \"daemon\")))\n\nfrom nau7802 import NAU7802\n\n\ndef _env_int(name: str, default: int) -> int:\n    value = os.environ.get(name)\n    if value is None or value == \"\":\n        return default\n    try:\n        return int(value)\n    except ValueError:\n        return default\n\n\nI2C_BUS = _env_int(\"SPOOLBUDDY_I2C_BUS\", 1)\nNAU7802_ADDR = 0x2A\n\n# Register addresses\nREG_PU_CTRL = 0x00\nREG_CTRL1 = 0x01\nREG_CTRL2 = 0x02\nREG_ADCO_B2 = 0x12  # ADC output MSB\nREG_ADCO_B1 = 0x13\nREG_ADCO_B0 = 0x14  # ADC output LSB\nREG_ADC = 0x15\nREG_PGA = 0x1B\nREG_PWR_CTRL = 0x1C\nREG_REVISION = 0x1F\n\n# PU_CTRL bits\nPU_RR = 0x01  # Register reset\nPU_PUD = 0x02  # Power up digital\nPU_PUA = 0x04  # Power up analog\nPU_PUR = 0x08  # Power up ready (read-only)\nPU_CS = 0x10  # Cycle start\nPU_CR = 0x20  # Cycle ready (read-only)\nPU_OSCS = 0x40  # Oscillator select\nPU_AVDDS = 0x80  # AVDD source select\n\n\ndef main():\n    print(\"=\" * 60)\n    print(\"NAU7802 Scale Diagnostic\")\n    print(\"=\" * 60)\n\n    print(f\"Configured bus: {I2C_BUS}, address: 0x{NAU7802_ADDR:02X}\")\n\n    # Probe both common I2C buses and show where devices are actually visible.\n    found_by_bus: dict[int, list[int]] = {}\n    for bus_num in (0, 1):\n        found_by_bus[bus_num] = []\n        try:\n            with smbus2.SMBus(bus_num) as probe_bus:\n                for addr in range(0x03, 0x78):\n                    try:\n                        probe_bus.read_byte(addr)\n                        found_by_bus[bus_num].append(addr)\n                    except OSError:\n                        continue\n        except FileNotFoundError:\n            continue\n        except PermissionError:\n            continue\n\n    for bus_num, addrs in found_by_bus.items():\n        if addrs:\n            pretty = \" \".join(f\"0x{a:02X}\" for a in addrs)\n            print(f\"Bus {bus_num} devices: {pretty}\")\n        else:\n            print(f\"Bus {bus_num} devices: (none)\")\n\n    if NAU7802_ADDR not in found_by_bus.get(I2C_BUS, []):\n        for alt in (1, 0):\n            if alt != I2C_BUS and NAU7802_ADDR in found_by_bus.get(alt, []):\n                print(f\"\\nHint: NAU7802 (0x{NAU7802_ADDR:02X}) appears on bus {alt}, not configured bus {I2C_BUS}.\")\n                print(f\"Try: SPOOLBUDDY_I2C_BUS={alt} .../scale_diag.py\")\n                break\n\n    scale = NAU7802()\n    try:\n        print(\"[1] Initializing...\")\n\n        scale.init()\n\n        # Print key interpreted config values\n        revision = scale.read_reg(REG_REVISION)\n        revision_id = revision & 0x0F\n        print(f\"    Revision ID: {revision_id}\")\n\n        # PU_CTRL bit 7: AVDD source select\n        pu_ctrl = scale.read_reg(REG_PU_CTRL)\n        avdds = (pu_ctrl >> 7) & 0x1\n        avdds_str = \"Internal LDO\" if avdds == 1 else \"AVDD pin input\"\n        print(f\"    AVDD source: {avdds_str}\")\n        ctrl1 = scale.read_reg(REG_CTRL1)\n        vldo = (ctrl1 >> 3) & 0b111\n        vldo_map = {\n            0b111: \"2.4V\",\n            0b110: \"2.7V\",\n            0b101: \"3.0V\",\n            0b100: \"3.3V\",\n            0b011: \"3.6V\",\n            0b010: \"3.9V\",\n            0b001: \"4.2V\",\n            0b000: \"4.5V\",\n        }\n        vldo_str = vldo_map.get(vldo, f\"Unknown ({vldo})\")\n        gain = ctrl1 & 0b111\n        gain_map = {\n            0b000: \"1x\",\n            0b001: \"2x\",\n            0b010: \"4x\",\n            0b011: \"8x\",\n            0b100: \"16x\",\n            0b101: \"32x\",\n            0b110: \"64x\",\n            0b111: \"128x\",\n        }\n        gain_str = gain_map.get(gain, f\"Unknown ({gain})\")\n        print(f\"    LDO setting (VLDO): {vldo_str}\")\n        print(f\"    Gain setting: {gain_str}\")\n        ctrl2 = scale.read_reg(REG_CTRL2)\n        sps = (ctrl2 >> 4) & 0b111\n        sps_map = {\n            0b000: \"10 SPS\",\n            0b001: \"20 SPS\",\n            0b010: \"40 SPS\",\n            0b011: \"80 SPS\",\n            0b100: \"320 SPS\",\n        }\n        sps_str = sps_map.get(sps, f\"Unknown ({sps})\")\n        print(f\"    Sample rate: {sps_str}\")\n        adc = scale.read_reg(REG_ADC)\n        chopper = (adc >> 4) & 0b11\n        chopper_str = {0b00: \"Enabled\", 0b01: \"Enabled\", 0b10: \"Enabled\", 0b11: \"Disabled\"}.get(\n            chopper, f\"Unknown ({chopper})\"\n        )\n        print(f\"    ADC chopper: {chopper_str}\")\n        pga = scale.read_reg(REG_PGA)\n        low_esr = (pga >> 6) & 0x1\n        low_esr_str = \"Enabled\" if low_esr == 0 else \"Disabled\"\n        print(f\"    PGA low-ESR caps: {low_esr_str}\")\n\n        print(\"[2] Waiting for first reading...\")\n        for _ in range(200):\n            if scale.data_ready():\n                break\n            time.sleep(0.010)\n        else:\n            print(\"    Timeout waiting for data ready\")\n            sys.exit(1)\n\n        print(\"    Reading 10 samples (10 SPS = ~1 second)...\")\n        readings = []\n        for i in range(10):\n            # Wait for data ready\n            for _ in range(200):\n                if scale.data_ready():\n                    break\n                time.sleep(0.010)\n            raw = scale.read_raw()\n            readings.append(raw)\n            print(f\"    Sample {i + 1:2d}: {raw:>10d}\")\n\n        avg = sum(readings) / len(readings)\n        spread = max(readings) - min(readings)\n        print(f\"\\n    Average: {avg:>10.0f}\")\n        print(f\"    Min:     {min(readings):>10d}\")\n        print(f\"    Max:     {max(readings):>10d}\")\n        print(f\"    Spread:  {spread:>10d}\")\n\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Diagnostic complete!\")\n        print(\"=\" * 60)\n\n    except Exception as e:\n        print(f\"\\nERROR: {e}\")\n        is_known_error = False\n\n        if isinstance(e, OSError):\n            if e.errno == 16:  # Device or resource busy\n                is_known_error = True\n                print(\"\\nI2C DEVICE BUSY (Errno 16): Another process is using the I2C bus.\")\n                print(\"This typically means the SpoolBuddy daemon is already reading the scale.\")\n                print(\"\\nTo run this diagnostic, stop the daemon first:\")\n                print(\"  sudo systemctl stop bambuddy\")\n                print(\"  # Run diagnostic\")\n                print(\"  .../scale_diag.py\")\n                print(\"  # Restart daemon when done:\")\n                print(\"  sudo systemctl start bambuddy\")\n            elif e.errno == 121:\n                is_known_error = True\n                print(\"\\nI2C NACK (Errno 121): the device did not acknowledge reads at 0x2A.\")\n                print(\"Check:\")\n                print(\"  - NAU7802 SDA/SCL are on the configured bus pins\")\n                print(\"  - 3.3V and GND are correct and stable\")\n                print(\"  - Sensor address is really 0x2A\")\n                print(\"  - No loose wire or swapped SDA/SCL\")\n            else:\n                print(f\"\\nI2C Error (Errno {e.errno}): {e}\")\n\n        # Only print full traceback for unexpected errors\n        if not is_known_error:\n            import traceback\n\n            traceback.print_exc()\n\n        sys.exit(1)\n    finally:\n        scale.close()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "spoolbuddy/tests/__init__.py",
    "content": ""
  },
  {
    "path": "spoolbuddy/tests/test_api_client.py",
    "content": "\"\"\"Tests for daemon.api_client — APIClient HTTP communication.\"\"\"\n\nimport asyncio\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport httpx\nimport pytest\nfrom daemon.api_client import MAX_BUFFER_SIZE, APIClient\n\n\n@pytest.fixture\ndef api():\n    return APIClient(\"http://localhost:5000\", \"test-key\")\n\n\nclass TestAPIClientInit:\n    def test_base_url_construction(self, api):\n        assert api._base == \"http://localhost:5000/api/v1/spoolbuddy\"\n\n    def test_base_url_strips_trailing_slash(self):\n        client = APIClient(\"http://localhost:5000/\", \"key\")\n        assert client._base == \"http://localhost:5000/api/v1/spoolbuddy\"\n\n    def test_api_key_in_headers(self):\n        client = APIClient(\"http://localhost:5000\", \"my-key\")\n        assert client._headers == {\"X-API-Key\": \"my-key\"}\n\n    def test_no_api_key_empty_headers(self):\n        client = APIClient(\"http://localhost:5000\", \"\")\n        assert client._headers == {}\n\n\nclass TestPost:\n    @pytest.mark.asyncio\n    async def test_post_success(self, api):\n        mock_resp = MagicMock()\n        mock_resp.status_code = 200\n        mock_resp.json.return_value = {\"ok\": True}\n        mock_resp.raise_for_status = MagicMock()\n\n        api._client.post = AsyncMock(return_value=mock_resp)\n\n        result = await api._post(\"/test\", {\"key\": \"value\"})\n\n        assert result == {\"ok\": True}\n        assert api._connected is True\n        assert api._backoff == 1.0\n        api._client.post.assert_awaited_once()\n\n    @pytest.mark.asyncio\n    async def test_post_failure_buffers_request(self, api):\n        api._client.post = AsyncMock(side_effect=httpx.ConnectError(\"refused\"))\n\n        result = await api._post(\"/test\", {\"data\": 1})\n\n        assert result is None\n        assert len(api._buffer) == 1\n        assert api._buffer[0] == {\"path\": \"/test\", \"data\": {\"data\": 1}}\n\n    @pytest.mark.asyncio\n    async def test_post_failure_logs_connection_lost_once(self, api):\n        api._connected = True\n        api._client.post = AsyncMock(side_effect=httpx.ConnectError(\"refused\"))\n\n        await api._post(\"/a\", {})\n        assert api._connected is False\n\n        # Second failure should not log \"connection lost\" again\n        await api._post(\"/b\", {})\n        assert len(api._buffer) == 2\n\n    @pytest.mark.asyncio\n    async def test_post_success_resets_backoff(self, api):\n        api._backoff = 16.0\n        mock_resp = MagicMock()\n        mock_resp.json.return_value = {}\n        mock_resp.raise_for_status = MagicMock()\n        api._client.post = AsyncMock(return_value=mock_resp)\n\n        await api._post(\"/test\", {})\n\n        assert api._backoff == 1.0\n\n    @pytest.mark.asyncio\n    async def test_buffer_max_size(self, api):\n        api._client.post = AsyncMock(side_effect=httpx.ConnectError(\"refused\"))\n\n        for i in range(MAX_BUFFER_SIZE + 20):\n            await api._post(\"/test\", {\"i\": i})\n\n        assert len(api._buffer) == MAX_BUFFER_SIZE\n        # Oldest entries should have been dropped (deque maxlen behavior)\n        assert api._buffer[0][\"data\"][\"i\"] == 20\n\n\nclass TestHeartbeat:\n    @pytest.mark.asyncio\n    async def test_heartbeat_posts_to_correct_path(self, api):\n        mock_resp = MagicMock()\n        mock_resp.json.return_value = {\"pending_command\": None}\n        mock_resp.raise_for_status = MagicMock()\n        api._client.post = AsyncMock(return_value=mock_resp)\n\n        result = await api.heartbeat(\n            device_id=\"dev-1\",\n            nfc_ok=True,\n            scale_ok=False,\n            uptime_s=120,\n            ip_address=\"192.168.1.50\",\n            firmware_version=\"0.2.2b1\",\n        )\n\n        assert result == {\"pending_command\": None}\n        call_args = api._client.post.call_args\n        assert \"/devices/dev-1/heartbeat\" in call_args[0][0]\n\n    @pytest.mark.asyncio\n    async def test_heartbeat_flushes_buffer_on_success(self, api):\n        # Pre-populate buffer\n        api._buffer.append({\"path\": \"/old\", \"data\": {\"x\": 1}})\n\n        mock_resp = MagicMock()\n        mock_resp.json.return_value = {\"ok\": True}\n        mock_resp.raise_for_status = MagicMock()\n        api._client.post = AsyncMock(return_value=mock_resp)\n\n        await api.heartbeat(device_id=\"d\", nfc_ok=True, scale_ok=True, uptime_s=0)\n\n        # Buffer should be flushed (post called for heartbeat + 1 buffered item)\n        assert len(api._buffer) == 0\n\n    @pytest.mark.asyncio\n    async def test_heartbeat_returns_none_on_failure(self, api):\n        api._client.post = AsyncMock(side_effect=httpx.ConnectError(\"fail\"))\n\n        result = await api.heartbeat(device_id=\"d\", nfc_ok=True, scale_ok=True, uptime_s=0)\n\n        assert result is None\n\n\nclass TestRegisterDevice:\n    @pytest.mark.asyncio\n    async def test_register_retries_until_success(self, api):\n        mock_resp = MagicMock()\n        mock_resp.json.return_value = {\"device_id\": \"dev-1\"}\n        mock_resp.raise_for_status = MagicMock()\n\n        # Fail twice, then succeed\n        call_count = 0\n\n        async def mock_post(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count <= 2:\n                raise httpx.ConnectError(\"refused\")\n            return mock_resp\n\n        api._client.post = mock_post\n        # Speed up retries\n        api._backoff = 0.01\n        api._max_backoff = 0.02\n\n        result = await api.register_device(\n            device_id=\"dev-1\",\n            hostname=\"test\",\n            ip_address=\"1.2.3.4\",\n        )\n\n        assert result == {\"device_id\": \"dev-1\"}\n        assert call_count == 3\n\n    @pytest.mark.asyncio\n    async def test_register_sends_all_fields(self, api):\n        mock_resp = MagicMock()\n        mock_resp.json.return_value = {\"ok\": True}\n        mock_resp.raise_for_status = MagicMock()\n        api._client.post = AsyncMock(return_value=mock_resp)\n\n        await api.register_device(\n            device_id=\"dev-1\",\n            hostname=\"myhost\",\n            ip_address=\"10.0.0.1\",\n            firmware_version=\"0.2.2b1\",\n            has_nfc=True,\n            has_scale=False,\n            tare_offset=100,\n            calibration_factor=1.05,\n            nfc_reader_type=\"PN532\",\n            nfc_connection=\"SPI\",\n            has_backlight=True,\n        )\n\n        call_args = api._client.post.call_args\n        payload = call_args[1][\"json\"]\n        assert payload[\"device_id\"] == \"dev-1\"\n        assert payload[\"has_backlight\"] is True\n        assert payload[\"calibration_factor\"] == 1.05\n\n\nclass TestReportUpdateStatus:\n    @pytest.mark.asyncio\n    async def test_report_update_status(self, api):\n        mock_resp = MagicMock()\n        mock_resp.json.return_value = {\"ok\": True}\n        mock_resp.raise_for_status = MagicMock()\n        api._client.post = AsyncMock(return_value=mock_resp)\n\n        result = await api.report_update_status(\"dev-1\", \"updating\", \"Fetching...\")\n\n        assert result == {\"ok\": True}\n        call_args = api._client.post.call_args\n        assert \"/devices/dev-1/update-status\" in call_args[0][0]\n        payload = call_args[1][\"json\"]\n        assert payload[\"status\"] == \"updating\"\n        assert payload[\"message\"] == \"Fetching...\"\n\n    @pytest.mark.asyncio\n    async def test_report_update_status_failure_returns_none(self, api):\n        api._client.post = AsyncMock(side_effect=httpx.ConnectError(\"fail\"))\n\n        result = await api.report_update_status(\"dev-1\", \"error\", \"oops\")\n\n        assert result is None\n"
  },
  {
    "path": "spoolbuddy/tests/test_config.py",
    "content": "\"\"\"Tests for daemon.config — Config.load() and _get_mac_id().\"\"\"\n\nimport pytest\nfrom daemon.config import Config, _get_mac_id\n\n\nclass TestConfigLoad:\n    \"\"\"Config.load() reads env vars and validates required fields.\"\"\"\n\n    def test_load_with_all_env_vars(self, monkeypatch):\n        monkeypatch.setenv(\"SPOOLBUDDY_BACKEND_URL\", \"http://10.0.0.1:5000\")\n        monkeypatch.setenv(\"SPOOLBUDDY_API_KEY\", \"test-key-123\")\n        monkeypatch.setenv(\"SPOOLBUDDY_DEVICE_ID\", \"my-device\")\n        monkeypatch.setenv(\"SPOOLBUDDY_HOSTNAME\", \"my-host\")\n\n        cfg = Config.load()\n\n        assert cfg.backend_url == \"http://10.0.0.1:5000\"\n        assert cfg.api_key == \"test-key-123\"\n        assert cfg.device_id == \"my-device\"\n        assert cfg.hostname == \"my-host\"\n\n    def test_load_missing_backend_url_raises(self, monkeypatch):\n        monkeypatch.delenv(\"SPOOLBUDDY_BACKEND_URL\", raising=False)\n        monkeypatch.setenv(\"SPOOLBUDDY_API_KEY\", \"key\")\n\n        with pytest.raises(RuntimeError, match=\"SPOOLBUDDY_BACKEND_URL is required\"):\n            Config.load()\n\n    def test_load_missing_api_key_raises(self, monkeypatch):\n        monkeypatch.setenv(\"SPOOLBUDDY_BACKEND_URL\", \"http://localhost:5000\")\n        monkeypatch.delenv(\"SPOOLBUDDY_API_KEY\", raising=False)\n\n        with pytest.raises(RuntimeError, match=\"SPOOLBUDDY_API_KEY is required\"):\n            Config.load()\n\n    def test_load_both_missing_raises_backend_url_first(self, monkeypatch):\n        monkeypatch.delenv(\"SPOOLBUDDY_BACKEND_URL\", raising=False)\n        monkeypatch.delenv(\"SPOOLBUDDY_API_KEY\", raising=False)\n\n        with pytest.raises(RuntimeError, match=\"SPOOLBUDDY_BACKEND_URL\"):\n            Config.load()\n\n    def test_load_defaults_device_id_from_mac(self, monkeypatch, tmp_path):\n        monkeypatch.setenv(\"SPOOLBUDDY_BACKEND_URL\", \"http://localhost:5000\")\n        monkeypatch.setenv(\"SPOOLBUDDY_API_KEY\", \"key\")\n        monkeypatch.delenv(\"SPOOLBUDDY_DEVICE_ID\", raising=False)\n        monkeypatch.delenv(\"SPOOLBUDDY_HOSTNAME\", raising=False)\n\n        # Mock /sys/class/net with a fake interface\n        net_dir = tmp_path / \"sys\" / \"class\" / \"net\"\n        eth0 = net_dir / \"eth0\"\n        eth0.mkdir(parents=True)\n        (eth0 / \"address\").write_text(\"aa:bb:cc:dd:ee:ff\\n\")\n\n        import daemon.config as config_mod\n\n        monkeypatch.setattr(config_mod, \"_get_mac_id\", lambda: \"sb-aabbccddeeff\")\n\n        cfg = Config.load()\n\n        assert cfg.device_id == \"sb-aabbccddeeff\"\n\n    def test_load_defaults_hostname_from_socket(self, monkeypatch):\n        monkeypatch.setenv(\"SPOOLBUDDY_BACKEND_URL\", \"http://localhost:5000\")\n        monkeypatch.setenv(\"SPOOLBUDDY_API_KEY\", \"key\")\n        monkeypatch.setenv(\"SPOOLBUDDY_DEVICE_ID\", \"dev-1\")\n        monkeypatch.delenv(\"SPOOLBUDDY_HOSTNAME\", raising=False)\n\n        cfg = Config.load()\n\n        # Should fall back to socket.gethostname()\n        import socket\n\n        assert cfg.hostname == socket.gethostname()\n\n    def test_load_default_intervals(self, monkeypatch):\n        monkeypatch.setenv(\"SPOOLBUDDY_BACKEND_URL\", \"http://localhost:5000\")\n        monkeypatch.setenv(\"SPOOLBUDDY_API_KEY\", \"key\")\n        monkeypatch.setenv(\"SPOOLBUDDY_DEVICE_ID\", \"dev-1\")\n\n        cfg = Config.load()\n\n        assert cfg.nfc_poll_interval == 0.3\n        assert cfg.scale_read_interval == 0.1\n        assert cfg.scale_report_interval == 1.0\n        assert cfg.heartbeat_interval == 10.0\n        assert cfg.tare_offset == 0\n        assert cfg.calibration_factor == 1.0\n\n\nclass TestGetMacId:\n    \"\"\"_get_mac_id() reads MAC from /sys/class/net.\"\"\"\n\n    def test_reads_first_non_lo_interface(self, monkeypatch, tmp_path):\n        net_dir = tmp_path / \"sys\" / \"class\" / \"net\"\n\n        lo = net_dir / \"lo\"\n        lo.mkdir(parents=True)\n        (lo / \"address\").write_text(\"00:00:00:00:00:00\\n\")\n\n        eth0 = net_dir / \"eth0\"\n        eth0.mkdir(parents=True)\n        (eth0 / \"address\").write_text(\"de:ad:be:ef:00:01\\n\")\n\n        from pathlib import Path\n\n        import daemon.config as config_mod\n\n        monkeypatch.setattr(\n            config_mod, \"Path\", lambda p: tmp_path / \"sys\" / \"class\" / \"net\" if p == \"/sys/class/net\" else Path(p)\n        )\n\n        _get_mac_id()\n\n    def test_skips_loopback(self, monkeypatch, tmp_path):\n        \"\"\"lo interface is skipped even if it has a MAC — result is uuid fallback.\"\"\"\n        # When only lo exists and /sys/class/net points to our tmp dir,\n        # _get_mac_id should skip lo and fall back to uuid.\n        # We test the real function by patching Path at the module level.\n        from pathlib import Path\n\n        import daemon.config as config_mod\n\n        net_dir = tmp_path / \"net\"\n        lo = net_dir / \"lo\"\n        lo.mkdir(parents=True)\n        (lo / \"address\").write_text(\"00:00:00:00:00:00\\n\")\n\n        real_path = Path\n\n        def fake_path(p):\n            if p == \"/sys/class/net\":\n                return real_path(net_dir)\n            return real_path(p)\n\n        monkeypatch.setattr(config_mod, \"Path\", fake_path)\n\n        result = _get_mac_id()\n        assert result.startswith(\"sb-\")\n        assert len(result) == 15  # \"sb-\" + 12 hex uuid chars\n\n    def test_skips_all_zero_mac(self, monkeypatch, tmp_path):\n        \"\"\"Interfaces with all-zero MAC are skipped.\"\"\"\n        net_dir = tmp_path / \"net\"\n        eth0 = net_dir / \"eth0\"\n        eth0.mkdir(parents=True)\n        (eth0 / \"address\").write_text(\"00:00:00:00:00:00\\n\")\n\n    def test_fallback_to_uuid_when_no_interfaces(self, monkeypatch):\n        \"\"\"When /sys/class/net doesn't exist, falls back to uuid.\"\"\"\n        from pathlib import Path\n\n        import daemon.config as config_mod\n\n        # Make Path(\"/sys/class/net\") point to nonexistent dir\n        real_path = Path\n\n        def fake_path(p):\n            if p == \"/sys/class/net\":\n                return real_path(\"/nonexistent/path/that/does/not/exist\")\n            return real_path(p)\n\n        monkeypatch.setattr(config_mod, \"Path\", fake_path)\n\n        result = _get_mac_id()\n\n        assert result.startswith(\"sb-\")\n        assert len(result) == 15  # \"sb-\" + 12 hex chars\n"
  },
  {
    "path": "spoolbuddy/tests/test_display_control.py",
    "content": "\"\"\"Tests for daemon.display_control — DisplayControl brightness and blanking.\"\"\"\n\nimport time\n\nimport pytest\n\n\nclass TestDisplayControlNoBacklight:\n    \"\"\"DisplayControl behavior when no backlight is present.\"\"\"\n\n    def test_no_backlight_detected(self, monkeypatch, tmp_path):\n        # Point BACKLIGHT_BASE to an empty directory (no backlight entries)\n        import daemon.display_control as dc_mod\n\n        empty_dir = tmp_path / \"backlight\"\n        empty_dir.mkdir()\n        monkeypatch.setattr(dc_mod, \"BACKLIGHT_BASE\", empty_dir)\n\n        dc = dc_mod.DisplayControl()\n\n        assert dc.has_backlight is False\n\n    def test_no_backlight_dir_missing(self, monkeypatch, tmp_path):\n        import daemon.display_control as dc_mod\n\n        monkeypatch.setattr(dc_mod, \"BACKLIGHT_BASE\", tmp_path / \"nonexistent\")\n\n        dc = dc_mod.DisplayControl()\n\n        assert dc.has_backlight is False\n\n    def test_set_brightness_noop_without_backlight(self, monkeypatch, tmp_path):\n        import daemon.display_control as dc_mod\n\n        empty_dir = tmp_path / \"backlight\"\n        empty_dir.mkdir()\n        monkeypatch.setattr(dc_mod, \"BACKLIGHT_BASE\", empty_dir)\n\n        dc = dc_mod.DisplayControl()\n\n        # Should not raise\n        dc.set_brightness(50)\n        dc.set_brightness(0)\n        dc.set_brightness(100)\n\n\nclass TestDisplayControlWithBacklight:\n    \"\"\"DisplayControl behavior with a mock sysfs backlight.\"\"\"\n\n    @pytest.fixture\n    def display(self, monkeypatch, tmp_path):\n        import daemon.display_control as dc_mod\n\n        bl_dir = tmp_path / \"backlight\" / \"rpi_backlight\"\n        bl_dir.mkdir(parents=True)\n        (bl_dir / \"brightness\").write_text(\"200\")\n        (bl_dir / \"max_brightness\").write_text(\"255\")\n\n        monkeypatch.setattr(dc_mod, \"BACKLIGHT_BASE\", tmp_path / \"backlight\")\n\n        return dc_mod.DisplayControl(), bl_dir\n\n    def test_has_backlight_true(self, display):\n        dc, _ = display\n        assert dc.has_backlight is True\n\n    def test_set_brightness_100(self, display):\n        dc, bl_dir = display\n        dc.set_brightness(100)\n        assert (bl_dir / \"brightness\").read_text() == \"255\"\n\n    def test_set_brightness_0(self, display):\n        dc, bl_dir = display\n        dc.set_brightness(0)\n        assert (bl_dir / \"brightness\").read_text() == \"0\"\n\n    def test_set_brightness_50(self, display):\n        dc, bl_dir = display\n        dc.set_brightness(50)\n        value = int((bl_dir / \"brightness\").read_text())\n        # 50% of 255 = 127 or 128 depending on rounding\n        assert value == round(255 * 50 / 100)\n\n    def test_set_brightness_clamped_above_100(self, display):\n        dc, bl_dir = display\n        dc.set_brightness(200)\n        assert (bl_dir / \"brightness\").read_text() == \"255\"\n\n    def test_set_brightness_clamped_below_0(self, display):\n        dc, bl_dir = display\n        dc.set_brightness(-50)\n        assert (bl_dir / \"brightness\").read_text() == \"0\"\n\n    def test_max_brightness_fallback_on_missing_file(self, monkeypatch, tmp_path):\n        \"\"\"If max_brightness file doesn't exist, defaults to 255.\"\"\"\n        import daemon.display_control as dc_mod\n\n        bl_dir = tmp_path / \"backlight\" / \"rpi_backlight\"\n        bl_dir.mkdir(parents=True)\n        (bl_dir / \"brightness\").write_text(\"100\")\n        # No max_brightness file\n\n        monkeypatch.setattr(dc_mod, \"BACKLIGHT_BASE\", tmp_path / \"backlight\")\n\n        dc = dc_mod.DisplayControl()\n        assert dc._max_brightness == 255\n\n\nclass TestDisplayControlBlanking:\n    \"\"\"Blanking logic: timeout, wake, tick.\"\"\"\n\n    @pytest.fixture\n    def display(self, monkeypatch, tmp_path):\n        import daemon.display_control as dc_mod\n\n        empty_dir = tmp_path / \"backlight\"\n        empty_dir.mkdir()\n        monkeypatch.setattr(dc_mod, \"BACKLIGHT_BASE\", empty_dir)\n\n        return dc_mod.DisplayControl()\n\n    def test_blank_timeout_default_disabled(self, display):\n        assert display._blank_timeout == 0\n\n    def test_set_blank_timeout(self, display):\n        display.set_blank_timeout(30)\n        assert display._blank_timeout == 30\n\n    def test_set_blank_timeout_negative_clamped(self, display):\n        display.set_blank_timeout(-10)\n        assert display._blank_timeout == 0\n\n    def test_tick_does_not_blank_when_disabled(self, display):\n        display.set_blank_timeout(0)\n        display.tick()\n        assert display._blanked is False\n\n    def test_tick_blanks_after_timeout(self, display, monkeypatch):\n        display.set_blank_timeout(5)\n        # Simulate idle for 10 seconds by backdating last_activity\n        display._last_activity = time.monotonic() - 10\n        display.tick()\n        assert display._blanked is True\n\n    def test_tick_does_not_blank_before_timeout(self, display):\n        display.set_blank_timeout(60)\n        display.wake()  # Reset activity\n        display.tick()\n        assert display._blanked is False\n\n    def test_wake_unblanks(self, display):\n        display.set_blank_timeout(5)\n        display._last_activity = time.monotonic() - 10\n        display.tick()\n        assert display._blanked is True\n\n        display.wake()\n        assert display._blanked is False\n\n    def test_tick_unblanks_when_timeout_disabled_while_blanked(self, display):\n        \"\"\"If timeout is disabled while screen is blanked, tick should unblank.\"\"\"\n        display.set_blank_timeout(5)\n        display._last_activity = time.monotonic() - 10\n        display.tick()\n        assert display._blanked is True\n\n        display.set_blank_timeout(0)\n        display.tick()\n        assert display._blanked is False\n\n    def test_wake_resets_activity_timer(self, display):\n        display.set_blank_timeout(5)\n        old_time = display._last_activity\n        time.sleep(0.01)\n        display.wake()\n        assert display._last_activity > old_time\n"
  },
  {
    "path": "spoolbuddy/tests/test_main.py",
    "content": "\"\"\"Tests for daemon.main — _perform_update() and heartbeat_loop command dispatch.\"\"\"\n\nimport asyncio\nimport sys\nimport time\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom daemon.config import Config\nfrom daemon.main import _perform_update, heartbeat_loop\n\n\ndef _make_config(**overrides):\n    cfg = Config(\n        backend_url=\"http://localhost:5000\",\n        api_key=\"test-key\",\n        device_id=\"dev-1\",\n        hostname=\"test-host\",\n        heartbeat_interval=0.01,  # fast for tests\n    )\n    for k, v in overrides.items():\n        setattr(cfg, k, v)\n    return cfg\n\n\ndef _make_api():\n    api = AsyncMock()\n    api.report_update_status = AsyncMock(return_value={\"ok\": True})\n    api.heartbeat = AsyncMock(return_value=None)\n    api.update_tare = AsyncMock(return_value={\"ok\": True})\n    return api\n\n\ndef _mock_process(returncode=0, stdout=b\"\", stderr=b\"\"):\n    proc = AsyncMock()\n    proc.communicate = AsyncMock(return_value=(stdout, stderr))\n    proc.returncode = returncode\n    return proc\n\n\nclass TestPerformUpdate:\n    @pytest.mark.asyncio\n    async def test_successful_update(self):\n        config = _make_config()\n        api = _make_api()\n\n        proc_ok = _mock_process(returncode=0)\n\n        with (\n            patch(\"daemon.main.asyncio.create_subprocess_exec\", return_value=proc_ok),\n            patch(\"daemon.main.shutil.which\", return_value=\"/usr/bin/git\"),\n            patch(\"daemon.main.Path\") as mock_path_cls,\n            pytest.raises(SystemExit) as exc_info,\n        ):\n            # Make venv pip not exist so it uses sys.executable path\n            mock_path_inst = MagicMock()\n            mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst\n            mock_path_inst.__truediv__ = MagicMock(\n                side_effect=lambda x: MagicMock(\n                    exists=MagicMock(return_value=False),\n                    __truediv__=MagicMock(return_value=MagicMock(exists=MagicMock(return_value=False))),\n                    __str__=MagicMock(return_value=\"/fake/repo\"),\n                )\n            )\n            mock_path_inst.__str__ = MagicMock(return_value=\"/fake/repo\")\n\n            await _perform_update(config, api)\n\n        assert exc_info.value.code == 0\n\n        # Should have reported status multiple times\n        assert api.report_update_status.await_count >= 3\n        # Last call should be \"complete\"\n        last_call = api.report_update_status.call_args_list[-1]\n        assert last_call[0][1] == \"complete\"\n\n    @pytest.mark.asyncio\n    async def test_git_fetch_failure(self):\n        config = _make_config()\n        api = _make_api()\n\n        proc_fail = _mock_process(returncode=1, stderr=b\"fatal: cannot fetch\")\n\n        with (\n            patch(\"daemon.main.asyncio.create_subprocess_exec\", return_value=proc_fail),\n            patch(\"daemon.main.shutil.which\", return_value=\"/usr/bin/git\"),\n            patch(\"daemon.main.Path\") as mock_path_cls,\n        ):\n            mock_path_inst = MagicMock()\n            mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst\n            mock_path_inst.__str__ = MagicMock(return_value=\"/fake/repo\")\n\n            await _perform_update(config, api)\n\n        # Should report error status\n        error_calls = [c for c in api.report_update_status.call_args_list if c[0][1] == \"error\"]\n        assert len(error_calls) == 1\n        assert \"git fetch failed\" in error_calls[0][0][2]\n\n    @pytest.mark.asyncio\n    async def test_git_reset_failure(self):\n        config = _make_config()\n        api = _make_api()\n\n        call_count = 0\n\n        async def mock_exec(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                # git fetch succeeds\n                return _mock_process(returncode=0)\n            else:\n                # git reset fails\n                return _mock_process(returncode=1, stderr=b\"reset error\")\n\n        with (\n            patch(\"daemon.main.asyncio.create_subprocess_exec\", side_effect=mock_exec),\n            patch(\"daemon.main.shutil.which\", return_value=\"/usr/bin/git\"),\n            patch(\"daemon.main.Path\") as mock_path_cls,\n        ):\n            mock_path_inst = MagicMock()\n            mock_path_cls.return_value.resolve.return_value.parent.parent.parent = mock_path_inst\n            mock_path_inst.__str__ = MagicMock(return_value=\"/fake/repo\")\n\n            await _perform_update(config, api)\n\n        error_calls = [c for c in api.report_update_status.call_args_list if c[0][1] == \"error\"]\n        assert len(error_calls) == 1\n        assert \"git reset failed\" in error_calls[0][0][2]\n\n\nclass TestHeartbeatLoopCommands:\n    \"\"\"Test command dispatch in heartbeat_loop.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_update_command_triggers_perform_update(self):\n        config = _make_config()\n        api = _make_api()\n\n        # First heartbeat returns update command, second returns None to break\n        call_count = 0\n\n        async def mock_heartbeat(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return {\"pending_command\": \"update\"}\n            return None\n\n        api.heartbeat = mock_heartbeat\n\n        display = MagicMock()\n        display.set_brightness = MagicMock()\n        display.set_blank_timeout = MagicMock()\n        display.tick = MagicMock()\n\n        shared = {\"nfc\": None, \"scale\": None, \"display\": display}\n\n        with patch(\"daemon.main._perform_update\", new_callable=AsyncMock) as mock_update:\n            # Run for 2 iterations then cancel\n            task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))\n            await asyncio.sleep(0.1)\n            task.cancel()\n            try:\n                await task\n            except asyncio.CancelledError:\n                pass\n\n            mock_update.assert_awaited_once_with(config, api)\n\n    @pytest.mark.asyncio\n    async def test_update_command_reports_error_on_exception(self):\n        config = _make_config()\n        api = _make_api()\n\n        call_count = 0\n\n        async def mock_heartbeat(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return {\"pending_command\": \"update\"}\n            return None\n\n        api.heartbeat = mock_heartbeat\n\n        display = MagicMock()\n        display.tick = MagicMock()\n        shared = {\"nfc\": None, \"scale\": None, \"display\": display}\n\n        with patch(\"daemon.main._perform_update\", new_callable=AsyncMock, side_effect=RuntimeError(\"boom\")):\n            task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))\n            await asyncio.sleep(0.1)\n            task.cancel()\n            try:\n                await task\n            except asyncio.CancelledError:\n                pass\n\n            api.report_update_status.assert_awaited()\n            error_call = api.report_update_status.call_args\n            assert error_call[0][1] == \"error\"\n\n    @pytest.mark.asyncio\n    async def test_tare_command_executes_scale_tare(self):\n        config = _make_config()\n        api = _make_api()\n\n        call_count = 0\n\n        async def mock_heartbeat(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return {\"pending_command\": \"tare\"}\n            return None\n\n        api.heartbeat = mock_heartbeat\n\n        scale = MagicMock()\n        scale.ok = True\n        scale.tare = MagicMock(return_value=512)\n\n        display = MagicMock()\n        display.tick = MagicMock()\n        shared = {\"nfc\": None, \"scale\": scale, \"display\": display}\n\n        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))\n        await asyncio.sleep(0.1)\n        task.cancel()\n        try:\n            await task\n        except asyncio.CancelledError:\n            pass\n\n        scale.tare.assert_called_once()\n        api.update_tare.assert_awaited_once_with(\"dev-1\", 512)\n        assert config.tare_offset == 512\n\n    @pytest.mark.asyncio\n    async def test_tare_command_no_scale_logs_warning(self):\n        config = _make_config()\n        api = _make_api()\n\n        call_count = 0\n\n        async def mock_heartbeat(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return {\"pending_command\": \"tare\"}\n            return None\n\n        api.heartbeat = mock_heartbeat\n\n        display = MagicMock()\n        display.tick = MagicMock()\n        shared = {\"nfc\": None, \"scale\": None, \"display\": display}\n\n        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))\n        await asyncio.sleep(0.1)\n        task.cancel()\n        try:\n            await task\n        except asyncio.CancelledError:\n            pass\n\n        # Should not crash; update_tare should NOT be called\n        api.update_tare.assert_not_awaited()\n\n    @pytest.mark.asyncio\n    async def test_write_tag_command_sets_pending_write(self):\n        config = _make_config()\n        api = _make_api()\n\n        call_count = 0\n\n        async def mock_heartbeat(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return {\n                    \"pending_command\": \"write_tag\",\n                    \"pending_write_payload\": {\n                        \"spool_id\": 42,\n                        \"ndef_data_hex\": \"DEADBEEF\",\n                    },\n                }\n            return None\n\n        api.heartbeat = mock_heartbeat\n\n        display = MagicMock()\n        display.tick = MagicMock()\n        display.set_brightness = MagicMock()\n        display.set_blank_timeout = MagicMock()\n        shared = {\"nfc\": None, \"scale\": None, \"display\": display}\n\n        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))\n        await asyncio.sleep(0.1)\n        task.cancel()\n        try:\n            await task\n        except asyncio.CancelledError:\n            pass\n\n        assert \"pending_write\" in shared\n        assert shared[\"pending_write\"][\"spool_id\"] == 42\n        assert shared[\"pending_write\"][\"ndef_data\"] == bytes.fromhex(\"DEADBEEF\")\n\n    @pytest.mark.asyncio\n    async def test_display_settings_applied_from_heartbeat(self):\n        config = _make_config()\n        api = _make_api()\n\n        call_count = 0\n\n        async def mock_heartbeat(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return {\n                    \"display_brightness\": 75,\n                    \"display_blank_timeout\": 300,\n                }\n            return None\n\n        api.heartbeat = mock_heartbeat\n\n        display = MagicMock()\n        display.tick = MagicMock()\n        shared = {\"nfc\": None, \"scale\": None, \"display\": display}\n\n        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))\n        await asyncio.sleep(0.1)\n        task.cancel()\n        try:\n            await task\n        except asyncio.CancelledError:\n            pass\n\n        display.set_brightness.assert_called_with(75)\n        display.set_blank_timeout.assert_called_with(300)\n\n    @pytest.mark.asyncio\n    async def test_calibration_sync_from_heartbeat(self):\n        config = _make_config(tare_offset=0, calibration_factor=1.0)\n        api = _make_api()\n\n        call_count = 0\n\n        async def mock_heartbeat(*args, **kwargs):\n            nonlocal call_count\n            call_count += 1\n            if call_count == 1:\n                return {\n                    \"tare_offset\": 200,\n                    \"calibration_factor\": 1.05,\n                }\n            return None\n\n        api.heartbeat = mock_heartbeat\n\n        scale = MagicMock()\n        scale.ok = True\n        display = MagicMock()\n        display.tick = MagicMock()\n        shared = {\"nfc\": None, \"scale\": scale, \"display\": display}\n\n        task = asyncio.create_task(heartbeat_loop(config, api, time.monotonic(), shared))\n        await asyncio.sleep(0.1)\n        task.cancel()\n        try:\n            await task\n        except asyncio.CancelledError:\n            pass\n\n        assert config.tare_offset == 200\n        assert config.calibration_factor == 1.05\n        scale.update_calibration.assert_called_with(200, 1.05)\n"
  },
  {
    "path": "spoolbuddy/tests/test_tag_parser.py",
    "content": "\"\"\"Tests for daemon.tag_parser — parse_bambu_blocks().\"\"\"\n\nfrom daemon.tag_parser import parse_bambu_blocks\n\n\nclass TestParseBambuBlocks:\n    \"\"\"parse_bambu_blocks() extracts metadata from MIFARE Classic blocks.\"\"\"\n\n    def test_empty_dict_returns_empty(self):\n        result = parse_bambu_blocks({})\n        assert result == {}\n\n    def test_tray_uuid_from_blocks_4_and_5(self):\n        # 16 bytes per block, UUID is first 16 bytes of block4+block5\n        block4 = bytes(range(16))  # 00010203...0f\n        block5 = bytes(range(16, 32))  # 10111213...1f\n        blocks = {4: block4, 5: block5}\n\n        result = parse_bambu_blocks(blocks)\n\n        # UUID = first 16 bytes of (block4 + block5) = block4 itself\n        expected_uuid = block4.hex().upper()\n        assert result[\"tray_uuid\"] == expected_uuid\n\n    def test_tray_uuid_missing_block_4(self):\n        blocks = {5: b\"\\x00\" * 16}\n        result = parse_bambu_blocks(blocks)\n        assert \"tray_uuid\" not in result\n\n    def test_tray_uuid_missing_block_5(self):\n        blocks = {4: b\"\\x00\" * 16}\n        result = parse_bambu_blocks(blocks)\n        assert \"tray_uuid\" not in result\n\n    def test_material_raw_from_block_1(self):\n        block1 = b\"\\x50\\x4c\\x41\\x00\\x00\\x00\\x00\\x00\" + b\"\\xff\" * 8\n        blocks = {1: block1}\n\n        result = parse_bambu_blocks(blocks)\n\n        assert result[\"material_raw\"] == block1[:8].hex().upper()\n\n    def test_block2_raw_from_block_2(self):\n        block2 = bytes([0xAA, 0xBB] + [0x00] * 14)\n        blocks = {2: block2}\n\n        result = parse_bambu_blocks(blocks)\n\n        assert result[\"block2_raw\"] == block2.hex().upper()\n\n    def test_all_blocks_present(self):\n        block1 = b\"\\x01\" * 16\n        block2 = b\"\\x02\" * 16\n        block4 = b\"\\x04\" * 16\n        block5 = b\"\\x05\" * 16\n        blocks = {1: block1, 2: block2, 4: block4, 5: block5}\n\n        result = parse_bambu_blocks(blocks)\n\n        assert \"tray_uuid\" in result\n        assert \"material_raw\" in result\n        assert \"block2_raw\" in result\n\n    def test_extra_blocks_ignored(self):\n        \"\"\"Blocks not in {1, 2, 4, 5} don't affect output.\"\"\"\n        blocks = {0: b\"\\x00\" * 16, 3: b\"\\x03\" * 16, 6: b\"\\x06\" * 16}\n        result = parse_bambu_blocks(blocks)\n        assert result == {}\n\n    def test_tray_uuid_hex_uppercase(self):\n        block4 = b\"\\xab\\xcd\\xef\\x12\\x34\\x56\\x78\\x9a\\xbc\\xde\\xf0\\x11\\x22\\x33\\x44\\x55\"\n        block5 = b\"\\x00\" * 16\n        blocks = {4: block4, 5: block5}\n\n        result = parse_bambu_blocks(blocks)\n\n        # Verify uppercase hex\n        assert result[\"tray_uuid\"] == result[\"tray_uuid\"].upper()\n        assert \"abcdef\" not in result[\"tray_uuid\"]  # no lowercase\n"
  },
  {
    "path": "static/assets/index-BoxU3Y8Y.css",
    "content": "@import\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\";@layer properties,theme,base,components,utilities;@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-lime-500:oklch(76.8% .233 130.85);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-900:oklch(37.9% .146 265.522);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-violet-500:oklch(60.6% .25 292.717);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-fuchsia-500:oklch(66.7% .295 322.15);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-500:oklch(55.4% .046 257.417);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--drop-shadow-md:0 3px 3px #0000001f;--drop-shadow-lg:0 4px 4px #00000026;--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0,0,.2,1)infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--blur-sm:8px;--blur-2xl:40px;--aspect-video:16/9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bambu-green:var(--accent);--color-bambu-green-light:var(--accent-light);--color-bambu-green-dark:var(--accent-dark);--color-status-ok:var(--status-ok);--color-status-error:var(--status-error);--color-status-warning:var(--status-warning);--color-bambu-dark:var(--bg-primary);--color-bambu-dark-secondary:var(--bg-secondary);--color-bambu-dark-tertiary:var(--bg-tertiary);--color-bambu-gray:var(--text-muted);--color-bambu-gray-light:var(--text-secondary);--color-bambu-gray-dark:var(--text-tertiary)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.inset-2{inset:calc(var(--spacing)*2)}.inset-x-0{inset-inline:calc(var(--spacing)*0)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.-top-0\\.5{top:calc(var(--spacing)*-.5)}.-top-1{top:calc(var(--spacing)*-1)}.-top-1\\.5{top:calc(var(--spacing)*-1.5)}.top-0{top:calc(var(--spacing)*0)}.top-0\\.5{top:calc(var(--spacing)*.5)}.top-1{top:calc(var(--spacing)*1)}.top-1\\.5{top:calc(var(--spacing)*1.5)}.top-1\\/2{top:50%}.top-2{top:calc(var(--spacing)*2)}.top-2\\.5{top:calc(var(--spacing)*2.5)}.top-3{top:calc(var(--spacing)*3)}.top-4{top:calc(var(--spacing)*4)}.top-8{top:calc(var(--spacing)*8)}.top-\\[2px\\]{top:2px}.top-\\[3px\\]{top:3px}.top-\\[52px\\]{top:52px}.top-full{top:100%}.-right-0\\.5{right:calc(var(--spacing)*-.5)}.-right-1{right:calc(var(--spacing)*-1)}.-right-1\\.5{right:calc(var(--spacing)*-1.5)}.-right-2{right:calc(var(--spacing)*-2)}.right-0{right:calc(var(--spacing)*0)}.right-1{right:calc(var(--spacing)*1)}.right-1\\.5{right:calc(var(--spacing)*1.5)}.right-2{right:calc(var(--spacing)*2)}.right-3{right:calc(var(--spacing)*3)}.right-4{right:calc(var(--spacing)*4)}.right-6{right:calc(var(--spacing)*6)}.right-12{right:calc(var(--spacing)*12)}.right-20{right:calc(var(--spacing)*20)}.right-\\[5\\.5rem\\]{right:5.5rem}.right-full{right:100%}.-bottom-0\\.5{bottom:calc(var(--spacing)*-.5)}.-bottom-1{bottom:calc(var(--spacing)*-1)}.-bottom-2{bottom:calc(var(--spacing)*-2)}.-bottom-7{bottom:calc(var(--spacing)*-7)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-1{bottom:calc(var(--spacing)*1)}.bottom-2{bottom:calc(var(--spacing)*2)}.bottom-3{bottom:calc(var(--spacing)*3)}.bottom-4{bottom:calc(var(--spacing)*4)}.bottom-6{bottom:calc(var(--spacing)*6)}.bottom-8{bottom:calc(var(--spacing)*8)}.bottom-20{bottom:calc(var(--spacing)*20)}.bottom-full{bottom:100%}.-left-2{left:calc(var(--spacing)*-2)}.left-0{left:calc(var(--spacing)*0)}.left-0\\.5{left:calc(var(--spacing)*.5)}.left-1{left:calc(var(--spacing)*1)}.left-1\\/2{left:50%}.left-2{left:calc(var(--spacing)*2)}.left-2\\.5{left:calc(var(--spacing)*2.5)}.left-3{left:calc(var(--spacing)*3)}.left-4{left:calc(var(--spacing)*4)}.left-10{left:calc(var(--spacing)*10)}.left-12{left:calc(var(--spacing)*12)}.left-\\[2px\\]{left:2px}.left-\\[3px\\]{left:3px}.left-full{left:100%}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-60,.z-\\[60\\]{z-index:60}.z-\\[100\\]{z-index:100}.z-\\[101\\]{z-index:101}.z-\\[9998\\]{z-index:9998}.z-\\[9999\\]{z-index:9999}.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-full{grid-column:1/-1}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.-m-4{margin:calc(var(--spacing)*-4)}.-mx-4{margin-inline:calc(var(--spacing)*-4)}.mx-1{margin-inline:calc(var(--spacing)*1)}.mx-1\\.5{margin-inline:calc(var(--spacing)*1.5)}.mx-2{margin-inline:calc(var(--spacing)*2)}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-5{margin-inline:calc(var(--spacing)*5)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-2{margin-block:calc(var(--spacing)*2)}.my-8{margin-block:calc(var(--spacing)*8)}.my-auto{margin-block:auto}.-mt-0\\.5{margin-top:calc(var(--spacing)*-.5)}.-mt-2{margin-top:calc(var(--spacing)*-2)}.mt-0\\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-1\\.5{margin-top:calc(var(--spacing)*1.5)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-14{margin-top:calc(var(--spacing)*14)}.mt-auto{margin-top:auto}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-1\\.5{margin-right:calc(var(--spacing)*1.5)}.mr-2{margin-right:calc(var(--spacing)*2)}.mr-3{margin-right:calc(var(--spacing)*3)}.mr-4{margin-right:calc(var(--spacing)*4)}.mr-auto{margin-right:auto}.-mb-px{margin-bottom:-1px}.mb-0{margin-bottom:calc(var(--spacing)*0)}.mb-0\\.5{margin-bottom:calc(var(--spacing)*.5)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-1\\.5{margin-bottom:calc(var(--spacing)*1.5)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-2\\.5{margin-bottom:calc(var(--spacing)*2.5)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-5{margin-bottom:calc(var(--spacing)*5)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.-ml-1{margin-left:calc(var(--spacing)*-1)}.-ml-2{margin-left:calc(var(--spacing)*-2)}.ml-0\\.5{margin-left:calc(var(--spacing)*.5)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-1\\.5{margin-left:calc(var(--spacing)*1.5)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-6{margin-left:calc(var(--spacing)*6)}.ml-7{margin-left:calc(var(--spacing)*7)}.ml-9{margin-left:calc(var(--spacing)*9)}.ml-14{margin-left:calc(var(--spacing)*14)}.ml-16{margin-left:calc(var(--spacing)*16)}.ml-64{margin-left:calc(var(--spacing)*64)}.ml-auto{margin-left:auto}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.h-0{height:calc(var(--spacing)*0)}.h-0\\.5{height:calc(var(--spacing)*.5)}.h-1{height:calc(var(--spacing)*1)}.h-1\\.5{height:calc(var(--spacing)*1.5)}.h-2{height:calc(var(--spacing)*2)}.h-2\\.5{height:calc(var(--spacing)*2.5)}.h-3{height:calc(var(--spacing)*3)}.h-3\\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-4\\.5{height:calc(var(--spacing)*4.5)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-16{height:calc(var(--spacing)*16)}.h-20{height:calc(var(--spacing)*20)}.h-24{height:calc(var(--spacing)*24)}.h-32{height:calc(var(--spacing)*32)}.h-48{height:calc(var(--spacing)*48)}.h-64{height:calc(var(--spacing)*64)}.h-80{height:calc(var(--spacing)*80)}.h-\\[18px\\]{height:18px}.h-\\[38px\\]{height:38px}.h-\\[40px\\]{height:40px}.h-\\[48px\\]{height:48px}.h-\\[80vh\\]{height:80vh}.h-\\[160px\\]{height:160px}.h-\\[300px\\]{height:300px}.h-\\[calc\\(100\\%-40px\\)\\]{height:calc(100% - 40px)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-40{max-height:calc(var(--spacing)*40)}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-56{max-height:calc(var(--spacing)*56)}.max-h-60{max-height:calc(var(--spacing)*60)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-80{max-height:calc(var(--spacing)*80)}.max-h-96{max-height:calc(var(--spacing)*96)}.max-h-\\[3\\.75rem\\]{max-height:3.75rem}.max-h-\\[50vh\\]{max-height:50vh}.max-h-\\[60vh\\]{max-height:60vh}.max-h-\\[80vh\\]{max-height:80vh}.max-h-\\[85vh\\]{max-height:85vh}.max-h-\\[90vh\\]{max-height:90vh}.max-h-\\[300px\\]{max-height:300px}.max-h-\\[400px\\]{max-height:400px}.max-h-\\[500px\\]{max-height:500px}.max-h-\\[600px\\]{max-height:600px}.max-h-\\[calc\\(90vh-80px\\)\\]{max-height:calc(90vh - 80px)}.max-h-\\[calc\\(100vh-2rem\\)\\]{max-height:calc(100vh - 2rem)}.max-h-\\[calc\\(100vh-320px\\)\\]{max-height:calc(100vh - 320px)}.max-h-full{max-height:100%}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-\\[36px\\]{min-height:36px}.min-h-\\[40px\\]{min-height:40px}.min-h-\\[44px\\]{min-height:44px}.min-h-\\[48px\\]{min-height:48px}.min-h-\\[80px\\]{min-height:80px}.min-h-\\[120px\\]{min-height:120px}.min-h-\\[300px\\]{min-height:300px}.min-h-\\[400px\\]{min-height:400px}.min-h-\\[calc\\(100vh-64px\\)\\]{min-height:calc(100vh - 64px)}.min-h-screen{min-height:100vh}.w-0{width:calc(var(--spacing)*0)}.w-0\\.5{width:calc(var(--spacing)*.5)}.w-1{width:calc(var(--spacing)*1)}.w-1\\.5{width:calc(var(--spacing)*1.5)}.w-1\\/2{width:50%}.w-1\\/3{width:33.3333%}.w-2{width:calc(var(--spacing)*2)}.w-2\\.5{width:calc(var(--spacing)*2.5)}.w-3{width:calc(var(--spacing)*3)}.w-3\\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-4\\.5{width:calc(var(--spacing)*4.5)}.w-5{width:calc(var(--spacing)*5)}.w-5\\/12{width:41.6667%}.w-6{width:calc(var(--spacing)*6)}.w-7{width:calc(var(--spacing)*7)}.w-7\\/12{width:58.3333%}.w-8{width:calc(var(--spacing)*8)}.w-9{width:calc(var(--spacing)*9)}.w-10{width:calc(var(--spacing)*10)}.w-11{width:calc(var(--spacing)*11)}.w-12{width:calc(var(--spacing)*12)}.w-14{width:calc(var(--spacing)*14)}.w-16{width:calc(var(--spacing)*16)}.w-20{width:calc(var(--spacing)*20)}.w-24{width:calc(var(--spacing)*24)}.w-32{width:calc(var(--spacing)*32)}.w-36{width:calc(var(--spacing)*36)}.w-40{width:calc(var(--spacing)*40)}.w-44{width:calc(var(--spacing)*44)}.w-48{width:calc(var(--spacing)*48)}.w-52{width:calc(var(--spacing)*52)}.w-56{width:calc(var(--spacing)*56)}.w-64{width:calc(var(--spacing)*64)}.w-72{width:calc(var(--spacing)*72)}.w-96{width:calc(var(--spacing)*96)}.w-\\[240px\\]{width:240px}.w-\\[340px\\]{width:340px}.w-\\[420px\\]{width:420px}.w-\\[560px\\]{width:560px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.w-screen{width:100vw}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-32{max-width:calc(var(--spacing)*32)}.max-w-\\[40\\%\\]{max-width:40%}.max-w-\\[50\\%\\]{max-width:50%}.max-w-\\[80px\\]{max-width:80px}.max-w-\\[90vw\\]{max-width:90vw}.max-w-\\[100px\\]{max-width:100px}.max-w-\\[120px\\]{max-width:120px}.max-w-\\[150px\\]{max-width:150px}.max-w-\\[160px\\]{max-width:160px}.max-w-\\[200px\\]{max-width:200px}.max-w-\\[220px\\]{max-width:220px}.max-w-\\[280px\\]{max-width:280px}.max-w-\\[600px\\]{max-width:600px}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-48{min-width:calc(var(--spacing)*48)}.min-w-\\[16px\\]{min-width:16px}.min-w-\\[18px\\]{min-width:18px}.min-w-\\[32px\\]{min-width:32px}.min-w-\\[40px\\]{min-width:40px}.min-w-\\[48px\\]{min-width:48px}.min-w-\\[50px\\]{min-width:50px}.min-w-\\[80px\\]{min-width:80px}.min-w-\\[100px\\]{min-width:100px}.min-w-\\[120px\\]{min-width:120px}.min-w-\\[130px\\]{min-width:130px}.min-w-\\[140px\\]{min-width:140px}.min-w-\\[150px\\]{min-width:150px}.min-w-\\[160px\\]{min-width:160px}.min-w-\\[180px\\]{min-width:180px}.min-w-\\[200px\\]{min-width:200px}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-\\[2_1_190px\\]{flex:2 190px}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-x-1\\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0\\.5{--tw-translate-x:calc(var(--spacing)*.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-1{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-3\\.5{--tw-translate-x:calc(var(--spacing)*3.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-5{--tw-translate-x:calc(var(--spacing)*5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-6{--tw-translate-x:calc(var(--spacing)*6);translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-105{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-\\[1\\.02\\]{scale:1.02}.-rotate-90{rotate:-90deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-ew-resize{cursor:ew-resize}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-row-resize{cursor:row-resize}.cursor-se-resize{cursor:se-resize}.touch-manipulation{touch-action:manipulation}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.\\[appearance\\:textfield\\]{appearance:textfield}.appearance-none{appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-\\[auto_1fr_100px_100px_100px_80px\\]{grid-template-columns:auto 1fr 100px 100px 100px 80px}.grid-cols-\\[auto_1fr_120px_100px_100px_100px_80px\\]{grid-template-columns:auto 1fr 120px 100px 100px 100px 80px}.grid-cols-\\[repeat\\(auto-fill\\,minmax\\(130px\\,1fr\\)\\)\\]{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing)*0)}.gap-0\\.5{gap:calc(var(--spacing)*.5)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-2\\.5{gap:calc(var(--spacing)*2.5)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-5{gap:calc(var(--spacing)*5)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}.gap-\\[3px\\]{gap:3px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*0)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*0)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-0\\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing)*2)}.gap-x-3{column-gap:calc(var(--spacing)*3)}.gap-x-4{column-gap:calc(var(--spacing)*4)}.gap-x-6{column-gap:calc(var(--spacing)*6)}.gap-y-1{row-gap:calc(var(--spacing)*1)}.gap-y-1\\.5{row-gap:calc(var(--spacing)*1.5)}.gap-y-2{row-gap:calc(var(--spacing)*2)}.gap-y-3{row-gap:calc(var(--spacing)*3)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-bambu-dark-tertiary>:not(:last-child)),:where(.divide-bambu-dark-tertiary\\/30>:not(:last-child)){border-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){:where(.divide-bambu-dark-tertiary\\/30>:not(:last-child)){border-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)30%,transparent)}}:where(.divide-bambu-gray\\/20>:not(:last-child)){border-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){:where(.divide-bambu-gray\\/20>:not(:last-child)){border-color:color-mix(in oklab,var(--color-bambu-gray)20%,transparent)}}.self-stretch{align-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.\\!rounded-l-none{border-top-left-radius:0!important;border-bottom-left-radius:0!important}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-l-lg{border-top-left-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.rounded-tl{border-top-left-radius:.25rem}.\\!rounded-r-none{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-b-2xl{border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-t-\\[5px\\]{border-top-style:var(--tw-border-style);border-top-width:5px}.border-t-\\[6px\\]{border-top-style:var(--tw-border-style);border-top-width:6px}.\\!border-r-0{border-right-style:var(--tw-border-style)!important;border-right-width:0!important}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-\\[5px\\]{border-right-style:var(--tw-border-style);border-right-width:5px}.border-r-\\[6px\\]{border-right-style:var(--tw-border-style);border-right-width:6px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-b-\\[6px\\]{border-bottom-style:var(--tw-border-style);border-bottom-width:6px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-l-\\[3px\\]{border-left-style:var(--tw-border-style);border-left-width:3px}.border-l-\\[5px\\]{border-left-style:var(--tw-border-style);border-left-width:5px}.border-l-\\[6px\\]{border-left-style:var(--tw-border-style);border-left-width:6px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\\!border-green-500{border-color:var(--color-green-500)!important}.\\!border-yellow-500{border-color:var(--color-yellow-500)!important}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\\/20{border-color:#f99c0033}@supports (color:color-mix(in lab,red,red)){.border-amber-500\\/20{border-color:color-mix(in oklab,var(--color-amber-500)20%,transparent)}}.border-amber-500\\/30{border-color:#f99c004d}@supports (color:color-mix(in lab,red,red)){.border-amber-500\\/30{border-color:color-mix(in oklab,var(--color-amber-500)30%,transparent)}}.border-amber-700{border-color:var(--color-amber-700)}.border-bambu-dark-secondary{border-color:var(--color-bambu-dark-secondary)}.border-bambu-dark-tertiary,.border-bambu-dark-tertiary\\/20{border-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.border-bambu-dark-tertiary\\/20{border-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)20%,transparent)}}.border-bambu-dark-tertiary\\/30{border-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.border-bambu-dark-tertiary\\/30{border-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)30%,transparent)}}.border-bambu-dark-tertiary\\/40{border-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.border-bambu-dark-tertiary\\/40{border-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)40%,transparent)}}.border-bambu-dark-tertiary\\/50{border-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.border-bambu-dark-tertiary\\/50{border-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)50%,transparent)}}.border-bambu-gray,.border-bambu-gray\\/20{border-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.border-bambu-gray\\/20{border-color:color-mix(in oklab,var(--color-bambu-gray)20%,transparent)}}.border-bambu-gray\\/30{border-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.border-bambu-gray\\/30{border-color:color-mix(in oklab,var(--color-bambu-gray)30%,transparent)}}.border-bambu-gray\\/50{border-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.border-bambu-gray\\/50{border-color:color-mix(in oklab,var(--color-bambu-gray)50%,transparent)}}.border-bambu-green,.border-bambu-green\\/20{border-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.border-bambu-green\\/20{border-color:color-mix(in oklab,var(--color-bambu-green)20%,transparent)}}.border-bambu-green\\/30{border-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.border-bambu-green\\/30{border-color:color-mix(in oklab,var(--color-bambu-green)30%,transparent)}}.border-bambu-green\\/40{border-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.border-bambu-green\\/40{border-color:color-mix(in oklab,var(--color-bambu-green)40%,transparent)}}.border-bambu-green\\/50{border-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.border-bambu-green\\/50{border-color:color-mix(in oklab,var(--color-bambu-green)50%,transparent)}}.border-black\\/20{border-color:#0003}@supports (color:color-mix(in lab,red,red)){.border-black\\/20{border-color:color-mix(in oklab,var(--color-black)20%,transparent)}}.border-blue-400\\/20{border-color:#54a2ff33}@supports (color:color-mix(in lab,red,red)){.border-blue-400\\/20{border-color:color-mix(in oklab,var(--color-blue-400)20%,transparent)}}.border-blue-400\\/50{border-color:#54a2ff80}@supports (color:color-mix(in lab,red,red)){.border-blue-400\\/50{border-color:color-mix(in oklab,var(--color-blue-400)50%,transparent)}}.border-blue-500{border-color:var(--color-blue-500)}.border-blue-500\\/20{border-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.border-blue-500\\/20{border-color:color-mix(in oklab,var(--color-blue-500)20%,transparent)}}.border-blue-500\\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.border-blue-500\\/30{border-color:color-mix(in oklab,var(--color-blue-500)30%,transparent)}}.border-blue-500\\/40{border-color:#3080ff66}@supports (color:color-mix(in lab,red,red)){.border-blue-500\\/40{border-color:color-mix(in oklab,var(--color-blue-500)40%,transparent)}}.border-blue-500\\/50{border-color:#3080ff80}@supports (color:color-mix(in lab,red,red)){.border-blue-500\\/50{border-color:color-mix(in oklab,var(--color-blue-500)50%,transparent)}}.border-emerald-500\\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab,red,red)){.border-emerald-500\\/20{border-color:color-mix(in oklab,var(--color-emerald-500)20%,transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400\\/20{border-color:#99a1af33}@supports (color:color-mix(in lab,red,red)){.border-gray-400\\/20{border-color:color-mix(in oklab,var(--color-gray-400)20%,transparent)}}.border-gray-500{border-color:var(--color-gray-500)}.border-gray-500\\/40{border-color:#6a728266}@supports (color:color-mix(in lab,red,red)){.border-gray-500\\/40{border-color:color-mix(in oklab,var(--color-gray-500)40%,transparent)}}.border-gray-700{border-color:var(--color-gray-700)}.border-gray-800{border-color:var(--color-gray-800)}.border-green-300{border-color:var(--color-green-300)}.border-green-400{border-color:var(--color-green-400)}.border-green-500{border-color:var(--color-green-500)}.border-green-500\\/30{border-color:#00c7584d}@supports (color:color-mix(in lab,red,red)){.border-green-500\\/30{border-color:color-mix(in oklab,var(--color-green-500)30%,transparent)}}.border-green-500\\/40{border-color:#00c75866}@supports (color:color-mix(in lab,red,red)){.border-green-500\\/40{border-color:color-mix(in oklab,var(--color-green-500)40%,transparent)}}.border-green-500\\/50{border-color:#00c75880}@supports (color:color-mix(in lab,red,red)){.border-green-500\\/50{border-color:color-mix(in oklab,var(--color-green-500)50%,transparent)}}.border-green-800{border-color:var(--color-green-800)}.border-orange-400\\/20{border-color:#ff8b1a33}@supports (color:color-mix(in lab,red,red)){.border-orange-400\\/20{border-color:color-mix(in oklab,var(--color-orange-400)20%,transparent)}}.border-orange-400\\/50{border-color:#ff8b1a80}@supports (color:color-mix(in lab,red,red)){.border-orange-400\\/50{border-color:color-mix(in oklab,var(--color-orange-400)50%,transparent)}}.border-orange-500\\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\\/20{border-color:color-mix(in oklab,var(--color-orange-500)20%,transparent)}}.border-orange-500\\/30{border-color:#fe6e004d}@supports (color:color-mix(in lab,red,red)){.border-orange-500\\/30{border-color:color-mix(in oklab,var(--color-orange-500)30%,transparent)}}.border-purple-400\\/20{border-color:#c07eff33}@supports (color:color-mix(in lab,red,red)){.border-purple-400\\/20{border-color:color-mix(in oklab,var(--color-purple-400)20%,transparent)}}.border-purple-500{border-color:var(--color-purple-500)}.border-purple-500\\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab,red,red)){.border-purple-500\\/20{border-color:color-mix(in oklab,var(--color-purple-500)20%,transparent)}}.border-purple-500\\/30{border-color:#ac4bff4d}@supports (color:color-mix(in lab,red,red)){.border-purple-500\\/30{border-color:color-mix(in oklab,var(--color-purple-500)30%,transparent)}}.border-red-300{border-color:var(--color-red-300)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\\/20{border-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.border-red-500\\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.border-red-500\\/30{border-color:color-mix(in oklab,var(--color-red-500)30%,transparent)}}.border-red-500\\/40{border-color:#fb2c3666}@supports (color:color-mix(in lab,red,red)){.border-red-500\\/40{border-color:color-mix(in oklab,var(--color-red-500)40%,transparent)}}.border-red-500\\/50{border-color:#fb2c3680}@supports (color:color-mix(in lab,red,red)){.border-red-500\\/50{border-color:color-mix(in oklab,var(--color-red-500)50%,transparent)}}.border-red-800{border-color:var(--color-red-800)}.border-red-800\\/70{border-color:#9f0712b3}@supports (color:color-mix(in lab,red,red)){.border-red-800\\/70{border-color:color-mix(in oklab,var(--color-red-800)70%,transparent)}}.border-red-900\\/40{border-color:#82181a66}@supports (color:color-mix(in lab,red,red)){.border-red-900\\/40{border-color:color-mix(in oklab,var(--color-red-900)40%,transparent)}}.border-slate-500{border-color:var(--color-slate-500)}.border-status-error\\/20{border-color:var(--color-status-error)}@supports (color:color-mix(in lab,red,red)){.border-status-error\\/20{border-color:color-mix(in oklab,var(--color-status-error)20%,transparent)}}.border-status-ok,.border-status-ok\\/20{border-color:var(--color-status-ok)}@supports (color:color-mix(in lab,red,red)){.border-status-ok\\/20{border-color:color-mix(in oklab,var(--color-status-ok)20%,transparent)}}.border-status-warning\\/20{border-color:var(--color-status-warning)}@supports (color:color-mix(in lab,red,red)){.border-status-warning\\/20{border-color:color-mix(in oklab,var(--color-status-warning)20%,transparent)}}.border-transparent{border-color:#0000}.border-white{border-color:var(--color-white)}.border-white\\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.border-white\\/10{border-color:color-mix(in oklab,var(--color-white)10%,transparent)}}.border-white\\/20{border-color:#fff3}@supports (color:color-mix(in lab,red,red)){.border-white\\/20{border-color:color-mix(in oklab,var(--color-white)20%,transparent)}}.border-white\\/30{border-color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.border-white\\/30{border-color:color-mix(in oklab,var(--color-white)30%,transparent)}}.border-yellow-400\\/20{border-color:#fac80033}@supports (color:color-mix(in lab,red,red)){.border-yellow-400\\/20{border-color:color-mix(in oklab,var(--color-yellow-400)20%,transparent)}}.border-yellow-400\\/40{border-color:#fac80066}@supports (color:color-mix(in lab,red,red)){.border-yellow-400\\/40{border-color:color-mix(in oklab,var(--color-yellow-400)40%,transparent)}}.border-yellow-400\\/50{border-color:#fac80080}@supports (color:color-mix(in lab,red,red)){.border-yellow-400\\/50{border-color:color-mix(in oklab,var(--color-yellow-400)50%,transparent)}}.border-yellow-500{border-color:var(--color-yellow-500)}.border-yellow-500\\/20{border-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\\/20{border-color:color-mix(in oklab,var(--color-yellow-500)20%,transparent)}}.border-yellow-500\\/30{border-color:#edb2004d}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\\/30{border-color:color-mix(in oklab,var(--color-yellow-500)30%,transparent)}}.border-yellow-500\\/50{border-color:#edb20080}@supports (color:color-mix(in lab,red,red)){.border-yellow-500\\/50{border-color:color-mix(in oklab,var(--color-yellow-500)50%,transparent)}}.border-zinc-500{border-color:var(--color-zinc-500)}.border-zinc-600{border-color:var(--color-zinc-600)}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\\/50{border-color:#3f3f4680}@supports (color:color-mix(in lab,red,red)){.border-zinc-700\\/50{border-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.border-t-bambu-dark-tertiary{border-top-color:var(--color-bambu-dark-tertiary)}.border-t-transparent{border-top-color:#0000}.border-r-transparent{border-right-color:#0000}.border-b-bambu-dark-tertiary{border-bottom-color:var(--color-bambu-dark-tertiary)}.border-l-blue-500{border-left-color:var(--color-blue-500)}.border-l-emerald-500{border-left-color:var(--color-emerald-500)}.border-l-gray-500{border-left-color:var(--color-gray-500)}.border-l-red-500{border-left-color:var(--color-red-500)}.border-l-transparent{border-left-color:#0000}.border-l-yellow-500{border-left-color:var(--color-yellow-500)}.\\!bg-bambu-green{background-color:var(--color-bambu-green)!important}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\\/5{background-color:#f99c000d}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\\/5{background-color:color-mix(in oklab,var(--color-amber-500)5%,transparent)}}.bg-amber-500\\/10{background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\\/10{background-color:color-mix(in oklab,var(--color-amber-500)10%,transparent)}}.bg-amber-500\\/20{background-color:#f99c0033}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\\/20{background-color:color-mix(in oklab,var(--color-amber-500)20%,transparent)}}.bg-amber-600{background-color:var(--color-amber-600)}.bg-amber-600\\/20{background-color:#dd740033}@supports (color:color-mix(in lab,red,red)){.bg-amber-600\\/20{background-color:color-mix(in oklab,var(--color-amber-600)20%,transparent)}}.bg-amber-900\\/30{background-color:#7b33064d}@supports (color:color-mix(in lab,red,red)){.bg-amber-900\\/30{background-color:color-mix(in oklab,var(--color-amber-900)30%,transparent)}}.bg-bambu-dark{background-color:var(--color-bambu-dark)}.bg-bambu-dark-secondary,.bg-bambu-dark-secondary\\/30{background-color:var(--color-bambu-dark-secondary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-secondary\\/30{background-color:color-mix(in oklab,var(--color-bambu-dark-secondary)30%,transparent)}}.bg-bambu-dark-secondary\\/50{background-color:var(--color-bambu-dark-secondary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-secondary\\/50{background-color:color-mix(in oklab,var(--color-bambu-dark-secondary)50%,transparent)}}.bg-bambu-dark-secondary\\/60{background-color:var(--color-bambu-dark-secondary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-secondary\\/60{background-color:color-mix(in oklab,var(--color-bambu-dark-secondary)60%,transparent)}}.bg-bambu-dark-secondary\\/90{background-color:var(--color-bambu-dark-secondary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-secondary\\/90{background-color:color-mix(in oklab,var(--color-bambu-dark-secondary)90%,transparent)}}.bg-bambu-dark-secondary\\/95{background-color:var(--color-bambu-dark-secondary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-secondary\\/95{background-color:color-mix(in oklab,var(--color-bambu-dark-secondary)95%,transparent)}}.bg-bambu-dark-tertiary,.bg-bambu-dark-tertiary\\/20{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-tertiary\\/20{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)20%,transparent)}}.bg-bambu-dark-tertiary\\/30{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-tertiary\\/30{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)30%,transparent)}}.bg-bambu-dark-tertiary\\/40{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-tertiary\\/40{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)40%,transparent)}}.bg-bambu-dark-tertiary\\/50{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-tertiary\\/50{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)50%,transparent)}}.bg-bambu-dark-tertiary\\/70{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-tertiary\\/70{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)70%,transparent)}}.bg-bambu-dark-tertiary\\/80{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark-tertiary\\/80{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)80%,transparent)}}.bg-bambu-dark\\/20{background-color:var(--color-bambu-dark)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark\\/20{background-color:color-mix(in oklab,var(--color-bambu-dark)20%,transparent)}}.bg-bambu-dark\\/30{background-color:var(--color-bambu-dark)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark\\/30{background-color:color-mix(in oklab,var(--color-bambu-dark)30%,transparent)}}.bg-bambu-dark\\/50{background-color:var(--color-bambu-dark)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark\\/50{background-color:color-mix(in oklab,var(--color-bambu-dark)50%,transparent)}}.bg-bambu-dark\\/80{background-color:var(--color-bambu-dark)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark\\/80{background-color:color-mix(in oklab,var(--color-bambu-dark)80%,transparent)}}.bg-bambu-dark\\/90{background-color:var(--color-bambu-dark)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-dark\\/90{background-color:color-mix(in oklab,var(--color-bambu-dark)90%,transparent)}}.bg-bambu-gray,.bg-bambu-gray\\/10{background-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-gray\\/10{background-color:color-mix(in oklab,var(--color-bambu-gray)10%,transparent)}}.bg-bambu-gray\\/20{background-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-gray\\/20{background-color:color-mix(in oklab,var(--color-bambu-gray)20%,transparent)}}.bg-bambu-gray\\/30{background-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-gray\\/30{background-color:color-mix(in oklab,var(--color-bambu-gray)30%,transparent)}}.bg-bambu-gray\\/40{background-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-gray\\/40{background-color:color-mix(in oklab,var(--color-bambu-gray)40%,transparent)}}.bg-bambu-gray\\/50{background-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-gray\\/50{background-color:color-mix(in oklab,var(--color-bambu-gray)50%,transparent)}}.bg-bambu-gray\\/60{background-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-gray\\/60{background-color:color-mix(in oklab,var(--color-bambu-gray)60%,transparent)}}.bg-bambu-gray\\/90{background-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-gray\\/90{background-color:color-mix(in oklab,var(--color-bambu-gray)90%,transparent)}}.bg-bambu-green,.bg-bambu-green\\/5{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/5{background-color:color-mix(in oklab,var(--color-bambu-green)5%,transparent)}}.bg-bambu-green\\/10{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/10{background-color:color-mix(in oklab,var(--color-bambu-green)10%,transparent)}}.bg-bambu-green\\/15{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/15{background-color:color-mix(in oklab,var(--color-bambu-green)15%,transparent)}}.bg-bambu-green\\/20{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/20{background-color:color-mix(in oklab,var(--color-bambu-green)20%,transparent)}}.bg-bambu-green\\/30{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/30{background-color:color-mix(in oklab,var(--color-bambu-green)30%,transparent)}}.bg-bambu-green\\/40{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/40{background-color:color-mix(in oklab,var(--color-bambu-green)40%,transparent)}}.bg-bambu-green\\/50{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/50{background-color:color-mix(in oklab,var(--color-bambu-green)50%,transparent)}}.bg-bambu-green\\/75{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/75{background-color:color-mix(in oklab,var(--color-bambu-green)75%,transparent)}}.bg-bambu-green\\/90{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.bg-bambu-green\\/90{background-color:color-mix(in oklab,var(--color-bambu-green)90%,transparent)}}.bg-black{background-color:var(--color-black)}.bg-black\\/15{background-color:#00000026}@supports (color:color-mix(in lab,red,red)){.bg-black\\/15{background-color:color-mix(in oklab,var(--color-black)15%,transparent)}}.bg-black\\/30{background-color:#0000004d}@supports (color:color-mix(in lab,red,red)){.bg-black\\/30{background-color:color-mix(in oklab,var(--color-black)30%,transparent)}}.bg-black\\/40{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.bg-black\\/40{background-color:color-mix(in oklab,var(--color-black)40%,transparent)}}.bg-black\\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-black\\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-black\\/70{background-color:#000000b3}@supports (color:color-mix(in lab,red,red)){.bg-black\\/70{background-color:color-mix(in oklab,var(--color-black)70%,transparent)}}.bg-black\\/80{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.bg-black\\/80{background-color:color-mix(in oklab,var(--color-black)80%,transparent)}}.bg-black\\/90{background-color:#000000e6}@supports (color:color-mix(in lab,red,red)){.bg-black\\/90{background-color:color-mix(in oklab,var(--color-black)90%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-400\\/10{background-color:#54a2ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-400\\/10{background-color:color-mix(in oklab,var(--color-blue-400)10%,transparent)}}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\\/5{background-color:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.bg-blue-500\\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\\/10{background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.bg-blue-500\\/15{background-color:#3080ff26}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\\/15{background-color:color-mix(in oklab,var(--color-blue-500)15%,transparent)}}.bg-blue-500\\/20{background-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\\/20{background-color:color-mix(in oklab,var(--color-blue-500)20%,transparent)}}.bg-blue-500\\/30{background-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\\/30{background-color:color-mix(in oklab,var(--color-blue-500)30%,transparent)}}.bg-blue-500\\/90{background-color:#3080ffe6}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\\/90{background-color:color-mix(in oklab,var(--color-blue-500)90%,transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-600\\/20{background-color:#155dfc33}@supports (color:color-mix(in lab,red,red)){.bg-blue-600\\/20{background-color:color-mix(in oklab,var(--color-blue-600)20%,transparent)}}.bg-blue-700{background-color:var(--color-blue-700)}.bg-cyan-500{background-color:var(--color-cyan-500)}.bg-cyan-500\\/10{background-color:#00b7d71a}@supports (color:color-mix(in lab,red,red)){.bg-cyan-500\\/10{background-color:color-mix(in oklab,var(--color-cyan-500)10%,transparent)}}.bg-cyan-500\\/20{background-color:#00b7d733}@supports (color:color-mix(in lab,red,red)){.bg-cyan-500\\/20{background-color:color-mix(in oklab,var(--color-cyan-500)20%,transparent)}}.bg-cyan-600\\/20{background-color:#0092b533}@supports (color:color-mix(in lab,red,red)){.bg-cyan-600\\/20{background-color:color-mix(in oklab,var(--color-cyan-600)20%,transparent)}}.bg-emerald-500\\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\\/10{background-color:color-mix(in oklab,var(--color-emerald-500)10%,transparent)}}.bg-emerald-500\\/20{background-color:#00bb7f33}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\\/20{background-color:color-mix(in oklab,var(--color-emerald-500)20%,transparent)}}.bg-emerald-600\\/20{background-color:#00976733}@supports (color:color-mix(in lab,red,red)){.bg-emerald-600\\/20{background-color:color-mix(in oklab,var(--color-emerald-600)20%,transparent)}}.bg-emerald-700{background-color:var(--color-emerald-700)}.bg-fuchsia-500{background-color:var(--color-fuchsia-500)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-400\\/10{background-color:#99a1af1a}@supports (color:color-mix(in lab,red,red)){.bg-gray-400\\/10{background-color:color-mix(in oklab,var(--color-gray-400)10%,transparent)}}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-500\\/15{background-color:#6a728226}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\\/15{background-color:color-mix(in oklab,var(--color-gray-500)15%,transparent)}}.bg-gray-500\\/20{background-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\\/20{background-color:color-mix(in oklab,var(--color-gray-500)20%,transparent)}}.bg-gray-600{background-color:var(--color-gray-600)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-400{background-color:var(--color-green-400)}.bg-green-400\\/10{background-color:#05df721a}@supports (color:color-mix(in lab,red,red)){.bg-green-400\\/10{background-color:color-mix(in oklab,var(--color-green-400)10%,transparent)}}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\\/10{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.bg-green-500\\/15{background-color:#00c75826}@supports (color:color-mix(in lab,red,red)){.bg-green-500\\/15{background-color:color-mix(in oklab,var(--color-green-500)15%,transparent)}}.bg-green-500\\/20{background-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.bg-green-500\\/20{background-color:color-mix(in oklab,var(--color-green-500)20%,transparent)}}.bg-green-600{background-color:var(--color-green-600)}.bg-green-600\\/20{background-color:#00a54433}@supports (color:color-mix(in lab,red,red)){.bg-green-600\\/20{background-color:color-mix(in oklab,var(--color-green-600)20%,transparent)}}.bg-green-600\\/40{background-color:#00a54466}@supports (color:color-mix(in lab,red,red)){.bg-green-600\\/40{background-color:color-mix(in oklab,var(--color-green-600)40%,transparent)}}.bg-green-700{background-color:var(--color-green-700)}.bg-green-900\\/30{background-color:#0d542b4d}@supports (color:color-mix(in lab,red,red)){.bg-green-900\\/30{background-color:color-mix(in oklab,var(--color-green-900)30%,transparent)}}.bg-green-900\\/50{background-color:#0d542b80}@supports (color:color-mix(in lab,red,red)){.bg-green-900\\/50{background-color:color-mix(in oklab,var(--color-green-900)50%,transparent)}}.bg-indigo-500\\/10{background-color:#625fff1a}@supports (color:color-mix(in lab,red,red)){.bg-indigo-500\\/10{background-color:color-mix(in oklab,var(--color-indigo-500)10%,transparent)}}.bg-indigo-500\\/15{background-color:#625fff26}@supports (color:color-mix(in lab,red,red)){.bg-indigo-500\\/15{background-color:color-mix(in oklab,var(--color-indigo-500)15%,transparent)}}.bg-indigo-500\\/20{background-color:#625fff33}@supports (color:color-mix(in lab,red,red)){.bg-indigo-500\\/20{background-color:color-mix(in oklab,var(--color-indigo-500)20%,transparent)}}.bg-lime-500{background-color:var(--color-lime-500)}.bg-orange-400\\/10{background-color:#ff8b1a1a}@supports (color:color-mix(in lab,red,red)){.bg-orange-400\\/10{background-color:color-mix(in oklab,var(--color-orange-400)10%,transparent)}}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\\/10{background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.bg-orange-500\\/20{background-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\\/20{background-color:color-mix(in oklab,var(--color-orange-500)20%,transparent)}}.bg-orange-600{background-color:var(--color-orange-600)}.bg-orange-600\\/20{background-color:#f0510033}@supports (color:color-mix(in lab,red,red)){.bg-orange-600\\/20{background-color:color-mix(in oklab,var(--color-orange-600)20%,transparent)}}.bg-purple-400\\/10{background-color:#c07eff1a}@supports (color:color-mix(in lab,red,red)){.bg-purple-400\\/10{background-color:color-mix(in oklab,var(--color-purple-400)10%,transparent)}}.bg-purple-500{background-color:var(--color-purple-500)}.bg-purple-500\\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\\/10{background-color:color-mix(in oklab,var(--color-purple-500)10%,transparent)}}.bg-purple-500\\/20{background-color:#ac4bff33}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\\/20{background-color:color-mix(in oklab,var(--color-purple-500)20%,transparent)}}.bg-purple-500\\/80{background-color:#ac4bffcc}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\\/80{background-color:color-mix(in oklab,var(--color-purple-500)80%,transparent)}}.bg-purple-500\\/90{background-color:#ac4bffe6}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\\/90{background-color:color-mix(in oklab,var(--color-purple-500)90%,transparent)}}.bg-purple-700{background-color:var(--color-purple-700)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-400{background-color:var(--color-red-400)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\\/5{background-color:#fb2c360d}@supports (color:color-mix(in lab,red,red)){.bg-red-500\\/5{background-color:color-mix(in oklab,var(--color-red-500)5%,transparent)}}.bg-red-500\\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\\/10{background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.bg-red-500\\/15{background-color:#fb2c3626}@supports (color:color-mix(in lab,red,red)){.bg-red-500\\/15{background-color:color-mix(in oklab,var(--color-red-500)15%,transparent)}}.bg-red-500\\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.bg-red-500\\/20{background-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.bg-red-500\\/30{background-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.bg-red-500\\/30{background-color:color-mix(in oklab,var(--color-red-500)30%,transparent)}}.bg-red-500\\/40{background-color:#fb2c3666}@supports (color:color-mix(in lab,red,red)){.bg-red-500\\/40{background-color:color-mix(in oklab,var(--color-red-500)40%,transparent)}}.bg-red-500\\/80{background-color:#fb2c36cc}@supports (color:color-mix(in lab,red,red)){.bg-red-500\\/80{background-color:color-mix(in oklab,var(--color-red-500)80%,transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-red-900\\/20{background-color:#82181a33}@supports (color:color-mix(in lab,red,red)){.bg-red-900\\/20{background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}}.bg-red-900\\/30{background-color:#82181a4d}@supports (color:color-mix(in lab,red,red)){.bg-red-900\\/30{background-color:color-mix(in oklab,var(--color-red-900)30%,transparent)}}.bg-red-950\\/70{background-color:#460809b3}@supports (color:color-mix(in lab,red,red)){.bg-red-950\\/70{background-color:color-mix(in oklab,var(--color-red-950)70%,transparent)}}.bg-rose-500\\/20{background-color:#ff235733}@supports (color:color-mix(in lab,red,red)){.bg-rose-500\\/20{background-color:color-mix(in oklab,var(--color-rose-500)20%,transparent)}}.bg-rose-600\\/20{background-color:#e7004433}@supports (color:color-mix(in lab,red,red)){.bg-rose-600\\/20{background-color:color-mix(in oklab,var(--color-rose-600)20%,transparent)}}.bg-sky-500{background-color:var(--color-sky-500)}.bg-sky-500\\/10{background-color:#00a5ef1a}@supports (color:color-mix(in lab,red,red)){.bg-sky-500\\/10{background-color:color-mix(in oklab,var(--color-sky-500)10%,transparent)}}.bg-sky-500\\/20{background-color:#00a5ef33}@supports (color:color-mix(in lab,red,red)){.bg-sky-500\\/20{background-color:color-mix(in oklab,var(--color-sky-500)20%,transparent)}}.bg-status-error,.bg-status-error\\/10{background-color:var(--color-status-error)}@supports (color:color-mix(in lab,red,red)){.bg-status-error\\/10{background-color:color-mix(in oklab,var(--color-status-error)10%,transparent)}}.bg-status-error\\/20{background-color:var(--color-status-error)}@supports (color:color-mix(in lab,red,red)){.bg-status-error\\/20{background-color:color-mix(in oklab,var(--color-status-error)20%,transparent)}}.bg-status-error\\/80{background-color:var(--color-status-error)}@supports (color:color-mix(in lab,red,red)){.bg-status-error\\/80{background-color:color-mix(in oklab,var(--color-status-error)80%,transparent)}}.bg-status-ok,.bg-status-ok\\/10{background-color:var(--color-status-ok)}@supports (color:color-mix(in lab,red,red)){.bg-status-ok\\/10{background-color:color-mix(in oklab,var(--color-status-ok)10%,transparent)}}.bg-status-ok\\/20{background-color:var(--color-status-ok)}@supports (color:color-mix(in lab,red,red)){.bg-status-ok\\/20{background-color:color-mix(in oklab,var(--color-status-ok)20%,transparent)}}.bg-status-warning,.bg-status-warning\\/10{background-color:var(--color-status-warning)}@supports (color:color-mix(in lab,red,red)){.bg-status-warning\\/10{background-color:color-mix(in oklab,var(--color-status-warning)10%,transparent)}}.bg-status-warning\\/20{background-color:var(--color-status-warning)}@supports (color:color-mix(in lab,red,red)){.bg-status-warning\\/20{background-color:color-mix(in oklab,var(--color-status-warning)20%,transparent)}}.bg-teal-500{background-color:var(--color-teal-500)}.bg-teal-500\\/20{background-color:#00baa733}@supports (color:color-mix(in lab,red,red)){.bg-teal-500\\/20{background-color:color-mix(in oklab,var(--color-teal-500)20%,transparent)}}.bg-transparent{background-color:#0000}.bg-violet-500{background-color:var(--color-violet-500)}.bg-white{background-color:var(--color-white)}.bg-white\\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.bg-white\\/10{background-color:color-mix(in oklab,var(--color-white)10%,transparent)}}.bg-white\\/20{background-color:#fff3}@supports (color:color-mix(in lab,red,red)){.bg-white\\/20{background-color:color-mix(in oklab,var(--color-white)20%,transparent)}}.bg-white\\/50{background-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.bg-white\\/50{background-color:color-mix(in oklab,var(--color-white)50%,transparent)}}.bg-white\\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.bg-white\\/70{background-color:color-mix(in oklab,var(--color-white)70%,transparent)}}.bg-white\\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.bg-white\\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-400\\/10{background-color:#fac8001a}@supports (color:color-mix(in lab,red,red)){.bg-yellow-400\\/10{background-color:color-mix(in oklab,var(--color-yellow-400)10%,transparent)}}.bg-yellow-400\\/20{background-color:#fac80033}@supports (color:color-mix(in lab,red,red)){.bg-yellow-400\\/20{background-color:color-mix(in oklab,var(--color-yellow-400)20%,transparent)}}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-yellow-500\\/5{background-color:#edb2000d}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\\/5{background-color:color-mix(in oklab,var(--color-yellow-500)5%,transparent)}}.bg-yellow-500\\/10{background-color:#edb2001a}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\\/10{background-color:color-mix(in oklab,var(--color-yellow-500)10%,transparent)}}.bg-yellow-500\\/20{background-color:#edb20033}@supports (color:color-mix(in lab,red,red)){.bg-yellow-500\\/20{background-color:color-mix(in oklab,var(--color-yellow-500)20%,transparent)}}.bg-yellow-700{background-color:var(--color-yellow-700)}.bg-zinc-600{background-color:var(--color-zinc-600)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-800\\/50{background-color:#27272a80}@supports (color:color-mix(in lab,red,red)){.bg-zinc-800\\/50{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.bg-zinc-800\\/60{background-color:#27272a99}@supports (color:color-mix(in lab,red,red)){.bg-zinc-800\\/60{background-color:color-mix(in oklab,var(--color-zinc-800)60%,transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-900\\/50{background-color:#18181b80}@supports (color:color-mix(in lab,red,red)){.bg-zinc-900\\/50{background-color:color-mix(in oklab,var(--color-zinc-900)50%,transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-t{--tw-gradient-position:to top in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-bambu-dark-secondary{--tw-gradient-from:var(--color-bambu-dark-secondary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-black\\/80{--tw-gradient-from:#000c}@supports (color:color-mix(in lab,red,red)){.from-black\\/80{--tw-gradient-from:color-mix(in oklab,var(--color-black)80%,transparent)}}.from-black\\/80{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-blue-500\\/5{--tw-gradient-from:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.from-blue-500\\/5{--tw-gradient-from:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.from-blue-500\\/5{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-white\\/10{--tw-gradient-from:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.from-white\\/10{--tw-gradient-from:color-mix(in oklab,var(--color-white)10%,transparent)}}.from-white\\/10{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.via-black\\/60{--tw-gradient-via:#0009}@supports (color:color-mix(in lab,red,red)){.via-black\\/60{--tw-gradient-via:color-mix(in oklab,var(--color-black)60%,transparent)}}.via-black\\/60{--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-bambu-dark-secondary{--tw-gradient-to:var(--color-bambu-dark-secondary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-bambu-dark-tertiary{--tw-gradient-to:var(--color-bambu-dark-tertiary);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.bg-cover{background-size:cover}.bg-center{background-position:50%}.fill-yellow-400{fill:var(--color-yellow-400)}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-left{object-position:left}.p-0{padding:calc(var(--spacing)*0)}.p-0\\.5{padding:calc(var(--spacing)*.5)}.p-1{padding:calc(var(--spacing)*1)}.p-1\\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-2\\.5{padding:calc(var(--spacing)*2.5)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-12{padding:calc(var(--spacing)*12)}.\\!px-1\\.5{padding-inline:calc(var(--spacing)*1.5)!important}.\\!px-3{padding-inline:calc(var(--spacing)*3)!important}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-6{padding-inline:calc(var(--spacing)*6)}.py-0\\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-12{padding-block:calc(var(--spacing)*12)}.py-16{padding-block:calc(var(--spacing)*16)}.py-20{padding-block:calc(var(--spacing)*20)}.py-24{padding-block:calc(var(--spacing)*24)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pr-1{padding-right:calc(var(--spacing)*1)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-3{padding-right:calc(var(--spacing)*3)}.pr-4{padding-right:calc(var(--spacing)*4)}.pr-6{padding-right:calc(var(--spacing)*6)}.pr-7{padding-right:calc(var(--spacing)*7)}.pr-8{padding-right:calc(var(--spacing)*8)}.pr-10{padding-right:calc(var(--spacing)*10)}.pb-1{padding-bottom:calc(var(--spacing)*1)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-3{padding-left:calc(var(--spacing)*3)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-6{padding-left:calc(var(--spacing)*6)}.pl-7{padding-left:calc(var(--spacing)*7)}.pl-8{padding-left:calc(var(--spacing)*8)}.pl-9{padding-left:calc(var(--spacing)*9)}.pl-10{padding-left:calc(var(--spacing)*10)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\\[6px\\]{font-size:6px}.text-\\[7px\\]{font-size:7px}.text-\\[8px\\]{font-size:8px}.text-\\[9px\\]{font-size:9px}.text-\\[10px\\]{font-size:10px}.text-\\[11px\\]{font-size:11px}.leading-5{--tw-leading:calc(var(--spacing)*5);line-height:calc(var(--spacing)*5)}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.\\!text-green-400{color:var(--color-green-400)!important}.\\!text-white{color:var(--color-white)!important}.\\!text-yellow-400{color:var(--color-yellow-400)!important}.text-amber-200{color:var(--color-amber-200)}.text-amber-200\\/80{color:#fee685cc}@supports (color:color-mix(in lab,red,red)){.text-amber-200\\/80{color:color-mix(in oklab,var(--color-amber-200)80%,transparent)}}.text-amber-300{color:var(--color-amber-300)}.text-amber-300\\/60{color:#ffd23699}@supports (color:color-mix(in lab,red,red)){.text-amber-300\\/60{color:color-mix(in oklab,var(--color-amber-300)60%,transparent)}}.text-amber-300\\/70{color:#ffd236b3}@supports (color:color-mix(in lab,red,red)){.text-amber-300\\/70{color:color-mix(in oklab,var(--color-amber-300)70%,transparent)}}.text-amber-300\\/80{color:#ffd236cc}@supports (color:color-mix(in lab,red,red)){.text-amber-300\\/80{color:color-mix(in oklab,var(--color-amber-300)80%,transparent)}}.text-amber-400{color:var(--color-amber-400)}.text-amber-400\\/70{color:#fcbb00b3}@supports (color:color-mix(in lab,red,red)){.text-amber-400\\/70{color:color-mix(in oklab,var(--color-amber-400)70%,transparent)}}.text-amber-500{color:var(--color-amber-500)}.text-amber-500\\/80{color:#f99c00cc}@supports (color:color-mix(in lab,red,red)){.text-amber-500\\/80{color:color-mix(in oklab,var(--color-amber-500)80%,transparent)}}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-bambu-dark-tertiary{color:var(--color-bambu-dark-tertiary)}.text-bambu-gray{color:var(--color-bambu-gray)}.text-bambu-gray-dark{color:var(--color-bambu-gray-dark)}.text-bambu-gray-light{color:var(--color-bambu-gray-light)}.text-bambu-gray\\/30{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.text-bambu-gray\\/30{color:color-mix(in oklab,var(--color-bambu-gray)30%,transparent)}}.text-bambu-gray\\/40{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.text-bambu-gray\\/40{color:color-mix(in oklab,var(--color-bambu-gray)40%,transparent)}}.text-bambu-gray\\/50{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.text-bambu-gray\\/50{color:color-mix(in oklab,var(--color-bambu-gray)50%,transparent)}}.text-bambu-gray\\/60{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.text-bambu-gray\\/60{color:color-mix(in oklab,var(--color-bambu-gray)60%,transparent)}}.text-bambu-gray\\/70{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.text-bambu-gray\\/70{color:color-mix(in oklab,var(--color-bambu-gray)70%,transparent)}}.text-bambu-gray\\/80{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.text-bambu-gray\\/80{color:color-mix(in oklab,var(--color-bambu-gray)80%,transparent)}}.text-bambu-green,.text-bambu-green\\/70{color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.text-bambu-green\\/70{color:color-mix(in oklab,var(--color-bambu-green)70%,transparent)}}.text-black{color:var(--color-black)}.text-black\\/80{color:#000c}@supports (color:color-mix(in lab,red,red)){.text-black\\/80{color:color-mix(in oklab,var(--color-black)80%,transparent)}}.text-blue-100{color:var(--color-blue-100)}.text-blue-300{color:var(--color-blue-300)}.text-blue-300\\/70{color:#90c5ffb3}@supports (color:color-mix(in lab,red,red)){.text-blue-300\\/70{color:color-mix(in oklab,var(--color-blue-300)70%,transparent)}}.text-blue-300\\/80{color:#90c5ffcc}@supports (color:color-mix(in lab,red,red)){.text-blue-300\\/80{color:color-mix(in oklab,var(--color-blue-300)80%,transparent)}}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-500\\/70{color:#3080ffb3}@supports (color:color-mix(in lab,red,red)){.text-blue-500\\/70{color:color-mix(in oklab,var(--color-blue-500)70%,transparent)}}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-cyan-300{color:var(--color-cyan-300)}.text-cyan-400{color:var(--color-cyan-400)}.text-emerald-100{color:var(--color-emerald-100)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-200{color:var(--color-green-200)}.text-green-300{color:var(--color-green-300)}.text-green-400{color:var(--color-green-400)}.text-green-400\\/60{color:#05df7299}@supports (color:color-mix(in lab,red,red)){.text-green-400\\/60{color:color-mix(in oklab,var(--color-green-400)60%,transparent)}}.text-green-500{color:var(--color-green-500)}.text-green-500\\/60{color:#00c75899}@supports (color:color-mix(in lab,red,red)){.text-green-500\\/60{color:color-mix(in oklab,var(--color-green-500)60%,transparent)}}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-indigo-300{color:var(--color-indigo-300)}.text-indigo-400{color:var(--color-indigo-400)}.text-orange-200{color:var(--color-orange-200)}.text-orange-300{color:var(--color-orange-300)}.text-orange-400{color:var(--color-orange-400)}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-purple-300{color:var(--color-purple-300)}.text-purple-300\\/70{color:#d9b3ffb3}@supports (color:color-mix(in lab,red,red)){.text-purple-300\\/70{color:color-mix(in oklab,var(--color-purple-300)70%,transparent)}}.text-purple-400{color:var(--color-purple-400)}.text-purple-400\\/80{color:#c07effcc}@supports (color:color-mix(in lab,red,red)){.text-purple-400\\/80{color:color-mix(in oklab,var(--color-purple-400)80%,transparent)}}.text-red-200{color:var(--color-red-200)}.text-red-300{color:var(--color-red-300)}.text-red-300\\/80{color:#ffa3a3cc}@supports (color:color-mix(in lab,red,red)){.text-red-300\\/80{color:color-mix(in oklab,var(--color-red-300)80%,transparent)}}.text-red-400{color:var(--color-red-400)}.text-red-400\\/50{color:#ff656880}@supports (color:color-mix(in lab,red,red)){.text-red-400\\/50{color:color-mix(in oklab,var(--color-red-400)50%,transparent)}}.text-red-400\\/60{color:#ff656899}@supports (color:color-mix(in lab,red,red)){.text-red-400\\/60{color:color-mix(in oklab,var(--color-red-400)60%,transparent)}}.text-red-400\\/70{color:#ff6568b3}@supports (color:color-mix(in lab,red,red)){.text-red-400\\/70{color:color-mix(in oklab,var(--color-red-400)70%,transparent)}}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-rose-300{color:var(--color-rose-300)}.text-rose-400{color:var(--color-rose-400)}.text-sky-400{color:var(--color-sky-400)}.text-slate-300{color:var(--color-slate-300)}.text-status-error{color:var(--color-status-error)}.text-status-ok{color:var(--color-status-ok)}.text-status-warning{color:var(--color-status-warning)}.text-teal-400{color:var(--color-teal-400)}.text-white{color:var(--color-white)}.text-white\\/30{color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.text-white\\/30{color:color-mix(in oklab,var(--color-white)30%,transparent)}}.text-white\\/40{color:#fff6}@supports (color:color-mix(in lab,red,red)){.text-white\\/40{color:color-mix(in oklab,var(--color-white)40%,transparent)}}.text-white\\/50{color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.text-white\\/50{color:color-mix(in oklab,var(--color-white)50%,transparent)}}.text-white\\/60{color:#fff9}@supports (color:color-mix(in lab,red,red)){.text-white\\/60{color:color-mix(in oklab,var(--color-white)60%,transparent)}}.text-white\\/70{color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.text-white\\/70{color:color-mix(in oklab,var(--color-white)70%,transparent)}}.text-white\\/80{color:#fffc}@supports (color:color-mix(in lab,red,red)){.text-white\\/80{color:color-mix(in oklab,var(--color-white)80%,transparent)}}.text-white\\/90{color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.text-white\\/90{color:color-mix(in oklab,var(--color-white)90%,transparent)}}.text-yellow-100{color:var(--color-yellow-100)}.text-yellow-200{color:var(--color-yellow-200)}.text-yellow-200\\/70{color:#fff085b3}@supports (color:color-mix(in lab,red,red)){.text-yellow-200\\/70{color:color-mix(in oklab,var(--color-yellow-200)70%,transparent)}}.text-yellow-300{color:var(--color-yellow-300)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-500\\/70{color:#edb200b3}@supports (color:color-mix(in lab,red,red)){.text-yellow-500\\/70{color:color-mix(in oklab,var(--color-yellow-500)70%,transparent)}}.text-yellow-500\\/80{color:#edb200cc}@supports (color:color-mix(in lab,red,red)){.text-yellow-500\\/80{color:color-mix(in oklab,var(--color-yellow-500)80%,transparent)}}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.placeholder-bambu-gray::placeholder{color:var(--color-bambu-gray)}.placeholder-bambu-gray-dark::placeholder{color:var(--color-bambu-gray-dark)}.placeholder-bambu-gray\\/40::placeholder{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.placeholder-bambu-gray\\/40::placeholder{color:color-mix(in oklab,var(--color-bambu-gray)40%,transparent)}}.placeholder-bambu-gray\\/50::placeholder{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.placeholder-bambu-gray\\/50::placeholder{color:color-mix(in oklab,var(--color-bambu-gray)50%,transparent)}}.placeholder-bambu-gray\\/60::placeholder{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.placeholder-bambu-gray\\/60::placeholder{color:color-mix(in oklab,var(--color-bambu-gray)60%,transparent)}}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.placeholder-white\\/30::placeholder{color:#ffffff4d}@supports (color:color-mix(in lab,red,red)){.placeholder-white\\/30::placeholder{color:color-mix(in oklab,var(--color-white)30%,transparent)}}.placeholder-zinc-500::placeholder{color:var(--color-zinc-500)}.accent-amber-500{accent-color:var(--color-amber-500)}.accent-bambu-green{accent-color:var(--color-bambu-green)}.accent-green-500{accent-color:var(--color-green-500)}.\\[color-scheme\\:dark\\]{color-scheme:dark}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\\[0_0_6px_rgba\\(34\\,197\\,94\\,0\\.5\\)\\]{--tw-shadow:0 0 6px var(--tw-shadow-color,#22c55e80);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow\\/depth{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-bambu-green\\/20{--tw-shadow-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.shadow-bambu-green\\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-bambu-green)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-bambu-green\\/25{--tw-shadow-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.shadow-bambu-green\\/25{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-bambu-green)25%,transparent)var(--tw-shadow-alpha),transparent)}}.ring-bambu-green,.ring-bambu-green\\/30{--tw-ring-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.ring-bambu-green\\/30{--tw-ring-color:color-mix(in oklab,var(--color-bambu-green)30%,transparent)}}.ring-blue-400\\/50{--tw-ring-color:#54a2ff80}@supports (color:color-mix(in lab,red,red)){.ring-blue-400\\/50{--tw-ring-color:color-mix(in oklab,var(--color-blue-400)50%,transparent)}}.ring-green-500{--tw-ring-color:var(--color-green-500)}.ring-white{--tw-ring-color:var(--color-white)}.ring-offset-1{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-bambu-dark{--tw-ring-offset-color:var(--color-bambu-dark)}.ring-offset-bambu-dark-secondary{--tw-ring-offset-color:var(--color-bambu-dark-secondary)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.blur-2xl{--tw-blur:blur(var(--blur-2xl));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.\\!filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)!important}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}@media(hover:hover){.group-hover\\:bg-bambu-green\\/20:is(:where(.group):hover *){background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.group-hover\\:bg-bambu-green\\/20:is(:where(.group):hover *){background-color:color-mix(in oklab,var(--color-bambu-green)20%,transparent)}}.group-hover\\:text-bambu-gray:is(:where(.group):hover *){color:var(--color-bambu-gray)}.group-hover\\:text-bambu-green:is(:where(.group):hover *){color:var(--color-bambu-green)}.group-hover\\:opacity-50:is(:where(.group):hover *){opacity:.5}.group-hover\\:opacity-100:is(:where(.group):hover *),.group-hover\\/resize\\:opacity-100:is(:where(.group\\/resize):hover *){opacity:1}.group-hover\\/thumb\\:block:is(:where(.group\\/thumb):hover *){display:block}}.peer-checked\\:bg-amber-500:is(:where(.peer):checked~*){background-color:var(--color-amber-500)}.peer-checked\\:bg-bambu-green:is(:where(.peer):checked~*){background-color:var(--color-bambu-green)}.peer-focus\\:outline-none:is(:where(.peer):focus~*){--tw-outline-style:none;outline-style:none}.peer-disabled\\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.placeholder\\:text-bambu-gray::placeholder,.placeholder\\:text-bambu-gray\\/50::placeholder{color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.placeholder\\:text-bambu-gray\\/50::placeholder{color:color-mix(in oklab,var(--color-bambu-gray)50%,transparent)}}.before\\:absolute:before{content:var(--tw-content);position:absolute}.before\\:top-0:before{content:var(--tw-content);top:calc(var(--spacing)*0)}.before\\:right-0:before{content:var(--tw-content);right:calc(var(--spacing)*0)}.before\\:left-0:before{content:var(--tw-content);left:calc(var(--spacing)*0)}.before\\:h-0\\.5:before{content:var(--tw-content);height:calc(var(--spacing)*.5)}.before\\:bg-bambu-green:before{content:var(--tw-content);background-color:var(--color-bambu-green)}.after\\:absolute:after{content:var(--tw-content);position:absolute}.after\\:top-\\[2px\\]:after{content:var(--tw-content);top:2px}.after\\:left-\\[2px\\]:after{content:var(--tw-content);left:2px}.after\\:h-4:after{content:var(--tw-content);height:calc(var(--spacing)*4)}.after\\:h-5:after{content:var(--tw-content);height:calc(var(--spacing)*5)}.after\\:w-4:after{content:var(--tw-content);width:calc(var(--spacing)*4)}.after\\:w-5:after{content:var(--tw-content);width:calc(var(--spacing)*5)}.after\\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\\:border-gray-300:after{content:var(--tw-content);border-color:var(--color-gray-300)}.after\\:bg-white:after{content:var(--tw-content);background-color:var(--color-white)}.after\\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\\:content-\\[\\'\\'\\]:after{--tw-content:\"\";content:var(--tw-content)}.peer-checked\\:after\\:translate-x-full:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.peer-checked\\:after\\:border-white:is(:where(.peer):checked~*):after{content:var(--tw-content);border-color:var(--color-white)}.last\\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.focus-within\\:border-bambu-green:focus-within{border-color:var(--color-bambu-green)}@media(hover:hover){.hover\\:z-20:hover{z-index:20}.hover\\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x)var(--tw-scale-y)}.hover\\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x)var(--tw-scale-y)}.hover\\:border-amber-500:hover{border-color:var(--color-amber-500)}.hover\\:border-bambu-dark-tertiary\\/80:hover{border-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.hover\\:border-bambu-dark-tertiary\\/80:hover{border-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)80%,transparent)}}.hover\\:border-bambu-gray:hover{border-color:var(--color-bambu-gray)}.hover\\:border-bambu-gray-dark:hover{border-color:var(--color-bambu-gray-dark)}.hover\\:border-bambu-gray\\/50:hover{border-color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.hover\\:border-bambu-gray\\/50:hover{border-color:color-mix(in oklab,var(--color-bambu-gray)50%,transparent)}}.hover\\:border-bambu-green:hover,.hover\\:border-bambu-green\\/40:hover{border-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:border-bambu-green\\/40:hover{border-color:color-mix(in oklab,var(--color-bambu-green)40%,transparent)}}.hover\\:border-bambu-green\\/50:hover{border-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:border-bambu-green\\/50:hover{border-color:color-mix(in oklab,var(--color-bambu-green)50%,transparent)}}.hover\\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\\:border-green-500\\/50:hover{border-color:#00c75880}@supports (color:color-mix(in lab,red,red)){.hover\\:border-green-500\\/50:hover{border-color:color-mix(in oklab,var(--color-green-500)50%,transparent)}}.hover\\:border-white:hover{border-color:var(--color-white)}.hover\\:border-white\\/40:hover{border-color:#fff6}@supports (color:color-mix(in lab,red,red)){.hover\\:border-white\\/40:hover{border-color:color-mix(in oklab,var(--color-white)40%,transparent)}}.hover\\:\\!bg-bambu-green\\/80:hover{background-color:var(--color-bambu-green)!important}@supports (color:color-mix(in lab,red,red)){.hover\\:\\!bg-bambu-green\\/80:hover{background-color:color-mix(in oklab,var(--color-bambu-green)80%,transparent)!important}}.hover\\:\\!bg-green-500\\/20:hover{background-color:#00c75833!important}@supports (color:color-mix(in lab,red,red)){.hover\\:\\!bg-green-500\\/20:hover{background-color:color-mix(in oklab,var(--color-green-500)20%,transparent)!important}}.hover\\:\\!bg-yellow-500\\/20:hover{background-color:#edb20033!important}@supports (color:color-mix(in lab,red,red)){.hover\\:\\!bg-yellow-500\\/20:hover{background-color:color-mix(in oklab,var(--color-yellow-500)20%,transparent)!important}}.hover\\:bg-amber-400:hover{background-color:var(--color-amber-400)}.hover\\:bg-amber-500\\/20:hover{background-color:#f99c0033}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-amber-500\\/20:hover{background-color:color-mix(in oklab,var(--color-amber-500)20%,transparent)}}.hover\\:bg-amber-500\\/30:hover{background-color:#f99c004d}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-amber-500\\/30:hover{background-color:color-mix(in oklab,var(--color-amber-500)30%,transparent)}}.hover\\:bg-amber-700:hover{background-color:var(--color-amber-700)}.hover\\:bg-amber-900\\/50:hover{background-color:#7b330680}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-amber-900\\/50:hover{background-color:color-mix(in oklab,var(--color-amber-900)50%,transparent)}}.hover\\:bg-bambu-dark:hover{background-color:var(--color-bambu-dark)}.hover\\:bg-bambu-dark-secondary:hover,.hover\\:bg-bambu-dark-secondary\\/50:hover{background-color:var(--color-bambu-dark-secondary)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-dark-secondary\\/50:hover{background-color:color-mix(in oklab,var(--color-bambu-dark-secondary)50%,transparent)}}.hover\\:bg-bambu-dark-secondary\\/80:hover{background-color:var(--color-bambu-dark-secondary)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-dark-secondary\\/80:hover{background-color:color-mix(in oklab,var(--color-bambu-dark-secondary)80%,transparent)}}.hover\\:bg-bambu-dark-tertiary:hover,.hover\\:bg-bambu-dark-tertiary\\/30:hover{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-dark-tertiary\\/30:hover{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)30%,transparent)}}.hover\\:bg-bambu-dark-tertiary\\/50:hover{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-dark-tertiary\\/50:hover{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)50%,transparent)}}.hover\\:bg-bambu-dark-tertiary\\/70:hover{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-dark-tertiary\\/70:hover{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)70%,transparent)}}.hover\\:bg-bambu-dark-tertiary\\/80:hover{background-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-dark-tertiary\\/80:hover{background-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)80%,transparent)}}.hover\\:bg-bambu-dark\\/50:hover{background-color:var(--color-bambu-dark)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-dark\\/50:hover{background-color:color-mix(in oklab,var(--color-bambu-dark)50%,transparent)}}.hover\\:bg-bambu-dark\\/80:hover{background-color:var(--color-bambu-dark)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-dark\\/80:hover{background-color:color-mix(in oklab,var(--color-bambu-dark)80%,transparent)}}.hover\\:bg-bambu-gray-dark:hover{background-color:var(--color-bambu-gray-dark)}.hover\\:bg-bambu-green:hover{background-color:var(--color-bambu-green)}.hover\\:bg-bambu-green-dark:hover{background-color:var(--color-bambu-green-dark)}.hover\\:bg-bambu-green-light:hover{background-color:var(--color-bambu-green-light)}.hover\\:bg-bambu-green\\/10:hover{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-green\\/10:hover{background-color:color-mix(in oklab,var(--color-bambu-green)10%,transparent)}}.hover\\:bg-bambu-green\\/20:hover{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-green\\/20:hover{background-color:color-mix(in oklab,var(--color-bambu-green)20%,transparent)}}.hover\\:bg-bambu-green\\/30:hover{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-green\\/30:hover{background-color:color-mix(in oklab,var(--color-bambu-green)30%,transparent)}}.hover\\:bg-bambu-green\\/50:hover{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-green\\/50:hover{background-color:color-mix(in oklab,var(--color-bambu-green)50%,transparent)}}.hover\\:bg-bambu-green\\/80:hover{background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-bambu-green\\/80:hover{background-color:color-mix(in oklab,var(--color-bambu-green)80%,transparent)}}.hover\\:bg-black\\/40:hover{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-black\\/40:hover{background-color:color-mix(in oklab,var(--color-black)40%,transparent)}}.hover\\:bg-black\\/70:hover{background-color:#000000b3}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-black\\/70:hover{background-color:color-mix(in oklab,var(--color-black)70%,transparent)}}.hover\\:bg-black\\/80:hover{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-black\\/80:hover{background-color:color-mix(in oklab,var(--color-black)80%,transparent)}}.hover\\:bg-blue-500\\/20:hover{background-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-blue-500\\/20:hover{background-color:color-mix(in oklab,var(--color-blue-500)20%,transparent)}}.hover\\:bg-blue-500\\/30:hover{background-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-blue-500\\/30:hover{background-color:color-mix(in oklab,var(--color-blue-500)30%,transparent)}}.hover\\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\\:bg-emerald-600:hover{background-color:var(--color-emerald-600)}.hover\\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\\:bg-gray-700:hover{background-color:var(--color-gray-700)}.hover\\:bg-green-500\\/20:hover{background-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-green-500\\/20:hover{background-color:color-mix(in oklab,var(--color-green-500)20%,transparent)}}.hover\\:bg-green-600:hover{background-color:var(--color-green-600)}.hover\\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\\:bg-indigo-500\\/20:hover{background-color:#625fff33}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-indigo-500\\/20:hover{background-color:color-mix(in oklab,var(--color-indigo-500)20%,transparent)}}.hover\\:bg-indigo-500\\/30:hover{background-color:#625fff4d}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-indigo-500\\/30:hover{background-color:color-mix(in oklab,var(--color-indigo-500)30%,transparent)}}.hover\\:bg-orange-400\\/20:hover{background-color:#ff8b1a33}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-orange-400\\/20:hover{background-color:color-mix(in oklab,var(--color-orange-400)20%,transparent)}}.hover\\:bg-orange-500\\/20:hover{background-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-orange-500\\/20:hover{background-color:color-mix(in oklab,var(--color-orange-500)20%,transparent)}}.hover\\:bg-purple-600\\/90:hover{background-color:#9810fae6}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-purple-600\\/90:hover{background-color:color-mix(in oklab,var(--color-purple-600)90%,transparent)}}.hover\\:bg-red-200:hover{background-color:var(--color-red-200)}.hover\\:bg-red-400\\/10:hover{background-color:#ff65681a}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-red-400\\/10:hover{background-color:color-mix(in oklab,var(--color-red-400)10%,transparent)}}.hover\\:bg-red-500\\/10:hover{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-red-500\\/10:hover{background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.hover\\:bg-red-500\\/20:hover{background-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-red-500\\/20:hover{background-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.hover\\:bg-red-500\\/30:hover{background-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-red-500\\/30:hover{background-color:color-mix(in oklab,var(--color-red-500)30%,transparent)}}.hover\\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\\:bg-red-900\\/50:hover{background-color:#82181a80}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-red-900\\/50:hover{background-color:color-mix(in oklab,var(--color-red-900)50%,transparent)}}.hover\\:bg-sky-500\\/20:hover{background-color:#00a5ef33}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-sky-500\\/20:hover{background-color:color-mix(in oklab,var(--color-sky-500)20%,transparent)}}.hover\\:bg-white\\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-white\\/5:hover{background-color:color-mix(in oklab,var(--color-white)5%,transparent)}}.hover\\:bg-white\\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-white\\/10:hover{background-color:color-mix(in oklab,var(--color-white)10%,transparent)}}.hover\\:bg-white\\/80:hover{background-color:#fffc}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-white\\/80:hover{background-color:color-mix(in oklab,var(--color-white)80%,transparent)}}.hover\\:bg-yellow-500\\/30:hover{background-color:#edb2004d}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-yellow-500\\/30:hover{background-color:color-mix(in oklab,var(--color-yellow-500)30%,transparent)}}.hover\\:bg-yellow-600:hover{background-color:var(--color-yellow-600)}.hover\\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\\:bg-zinc-700\\/50:hover{background-color:#3f3f4680}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-zinc-700\\/50:hover{background-color:color-mix(in oklab,var(--color-zinc-700)50%,transparent)}}.hover\\:bg-zinc-700\\/60:hover{background-color:#3f3f4699}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-zinc-700\\/60:hover{background-color:color-mix(in oklab,var(--color-zinc-700)60%,transparent)}}.hover\\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\\:text-amber-300:hover{color:var(--color-amber-300)}.hover\\:text-amber-800:hover{color:var(--color-amber-800)}.hover\\:text-bambu-gray:hover{color:var(--color-bambu-gray)}.hover\\:text-bambu-green:hover{color:var(--color-bambu-green)}.hover\\:text-bambu-green-light:hover{color:var(--color-bambu-green-light)}.hover\\:text-bambu-green\\/70:hover{color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:text-bambu-green\\/70:hover{color:color-mix(in oklab,var(--color-bambu-green)70%,transparent)}}.hover\\:text-bambu-green\\/80:hover{color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:text-bambu-green\\/80:hover{color:color-mix(in oklab,var(--color-bambu-green)80%,transparent)}}.hover\\:text-blue-300:hover{color:var(--color-blue-300)}.hover\\:text-blue-600:hover{color:var(--color-blue-600)}.hover\\:text-gray-600:hover{color:var(--color-gray-600)}.hover\\:text-gray-900:hover{color:var(--color-gray-900)}.hover\\:text-orange-300:hover{color:var(--color-orange-300)}.hover\\:text-red-200:hover{color:var(--color-red-200)}.hover\\:text-red-300:hover{color:var(--color-red-300)}.hover\\:text-red-400:hover{color:var(--color-red-400)}.hover\\:text-white:hover{color:var(--color-white)}.hover\\:text-white\\/60:hover{color:#fff9}@supports (color:color-mix(in lab,red,red)){.hover\\:text-white\\/60:hover{color:color-mix(in oklab,var(--color-white)60%,transparent)}}.hover\\:text-white\\/70:hover{color:#ffffffb3}@supports (color:color-mix(in lab,red,red)){.hover\\:text-white\\/70:hover{color:color-mix(in oklab,var(--color-white)70%,transparent)}}.hover\\:text-white\\/80:hover{color:#fffc}@supports (color:color-mix(in lab,red,red)){.hover\\:text-white\\/80:hover{color:color-mix(in oklab,var(--color-white)80%,transparent)}}.hover\\:text-yellow-400:hover{color:var(--color-yellow-400)}.hover\\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\\:text-zinc-300:hover{color:var(--color-zinc-300)}.hover\\:underline:hover{text-decoration-line:underline}.hover\\:opacity-70:hover{opacity:.7}.hover\\:opacity-80:hover{opacity:.8}.hover\\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a),0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\\:shadow-bambu-green\\/5:hover{--tw-shadow-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:shadow-bambu-green\\/5:hover{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-bambu-green)5%,transparent)var(--tw-shadow-alpha),transparent)}}.hover\\:shadow-bambu-green\\/30:hover{--tw-shadow-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.hover\\:shadow-bambu-green\\/30:hover{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-bambu-green)30%,transparent)var(--tw-shadow-alpha),transparent)}}}.focus\\:border-amber-500\\/50:focus{border-color:#f99c0080}@supports (color:color-mix(in lab,red,red)){.focus\\:border-amber-500\\/50:focus{border-color:color-mix(in oklab,var(--color-amber-500)50%,transparent)}}.focus\\:border-bambu-green:focus{border-color:var(--color-bambu-green)}.focus\\:border-red-500:focus{border-color:var(--color-red-500)}.focus\\:border-transparent:focus{border-color:#0000}.focus\\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\\:ring-bambu-gray:focus{--tw-ring-color:var(--color-bambu-gray)}.focus\\:ring-bambu-green:focus,.focus\\:ring-bambu-green\\/50:focus{--tw-ring-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.focus\\:ring-bambu-green\\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-bambu-green)50%,transparent)}}.focus\\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\\:ring-offset-0:focus{--tw-ring-offset-width:0px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\\:ring-offset-bambu-dark:focus{--tw-ring-offset-color:var(--color-bambu-dark)}.focus\\:ring-offset-bambu-dark-secondary:focus{--tw-ring-offset-color:var(--color-bambu-dark-secondary)}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.active\\:cursor-grabbing:active{cursor:grabbing}.disabled\\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\\:bg-gray-600:disabled{background-color:var(--color-gray-600)}.disabled\\:bg-zinc-700:disabled{background-color:var(--color-zinc-700)}.disabled\\:opacity-30:disabled{opacity:.3}.disabled\\:opacity-40:disabled{opacity:.4}.disabled\\:opacity-50:disabled{opacity:.5}@media(hover:hover){.disabled\\:hover\\:bg-bambu-green:disabled:hover{background-color:var(--color-bambu-green)}}@media not all and (min-width:640px){.max-\\[640px\\]\\:hidden{display:none}.max-\\[640px\\]\\:w-full{width:100%}.max-\\[640px\\]\\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.max-\\[640px\\]\\:flex-col{flex-direction:column}.max-\\[640px\\]\\:flex-wrap{flex-wrap:wrap}.max-\\[640px\\]\\:items-center{align-items:center}.max-\\[640px\\]\\:items-start{align-items:flex-start}}@media not all and (min-width:550px){.max-\\[550px\\]\\:order-1{order:1}.max-\\[550px\\]\\:order-2{order:2}.max-\\[550px\\]\\:order-3{order:3}.max-\\[550px\\]\\:order-4{order:4}.max-\\[550px\\]\\:mt-1{margin-top:calc(var(--spacing)*1)}.max-\\[550px\\]\\:w-full{width:100%}.max-\\[550px\\]\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.max-\\[550px\\]\\:flex-col{flex-direction:column}.max-\\[550px\\]\\:flex-wrap{flex-wrap:wrap}.max-\\[550px\\]\\:items-center{align-items:center}.max-\\[550px\\]\\:items-start{align-items:flex-start}.max-\\[550px\\]\\:justify-end{justify-content:flex-end}.max-\\[550px\\]\\:gap-3{gap:calc(var(--spacing)*3)}.max-\\[550px\\]\\:self-start{align-self:flex-start}}@media(min-width:40rem){.sm\\:mt-0{margin-top:calc(var(--spacing)*0)}.sm\\:mt-2{margin-top:calc(var(--spacing)*2)}.sm\\:mt-3{margin-top:calc(var(--spacing)*3)}.sm\\:mr-1{margin-right:calc(var(--spacing)*1)}.sm\\:mb-4{margin-bottom:calc(var(--spacing)*4)}.sm\\:ml-\\[68px\\]{margin-left:68px}.sm\\:ml-auto{margin-left:auto}.sm\\:block{display:block}.sm\\:flex{display:flex}.sm\\:grid{display:grid}.sm\\:hidden{display:none}.sm\\:inline{display:inline}.sm\\:h-2{height:calc(var(--spacing)*2)}.sm\\:h-3{height:calc(var(--spacing)*3)}.sm\\:h-3\\.5{height:calc(var(--spacing)*3.5)}.sm\\:h-4{height:calc(var(--spacing)*4)}.sm\\:h-5{height:calc(var(--spacing)*5)}.sm\\:h-6{height:calc(var(--spacing)*6)}.sm\\:h-14{height:calc(var(--spacing)*14)}.sm\\:w-3{width:calc(var(--spacing)*3)}.sm\\:w-3\\.5{width:calc(var(--spacing)*3.5)}.sm\\:w-4{width:calc(var(--spacing)*4)}.sm\\:w-5{width:calc(var(--spacing)*5)}.sm\\:w-6{width:calc(var(--spacing)*6)}.sm\\:w-14{width:calc(var(--spacing)*14)}.sm\\:w-16{width:calc(var(--spacing)*16)}.sm\\:w-40{width:calc(var(--spacing)*40)}.sm\\:w-72{width:calc(var(--spacing)*72)}.sm\\:w-auto{width:auto}.sm\\:max-w-none{max-width:none}.sm\\:max-w-sm{max-width:var(--container-sm)}.sm\\:max-w-xs{max-width:var(--container-xs)}.sm\\:flex-1{flex:1}.sm\\:flex-none{flex:none}.sm\\:cursor-default{cursor:default}.sm\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\\:flex-row{flex-direction:row}.sm\\:items-center{align-items:center}.sm\\:justify-between{justify-content:space-between}.sm\\:gap-1{gap:calc(var(--spacing)*1)}.sm\\:gap-1\\.5{gap:calc(var(--spacing)*1.5)}.sm\\:gap-2{gap:calc(var(--spacing)*2)}.sm\\:gap-3{gap:calc(var(--spacing)*3)}.sm\\:gap-4{gap:calc(var(--spacing)*4)}.sm\\:gap-5{gap:calc(var(--spacing)*5)}.sm\\:gap-6{gap:calc(var(--spacing)*6)}:where(.sm\\:space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.sm\\:space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.sm\\:space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}.sm\\:border-bambu-dark-tertiary{border-color:var(--color-bambu-dark-tertiary)}.sm\\:p-1\\.5{padding:calc(var(--spacing)*1.5)}.sm\\:p-2{padding:calc(var(--spacing)*2)}.sm\\:p-3{padding:calc(var(--spacing)*3)}.sm\\:p-4{padding:calc(var(--spacing)*4)}.sm\\:px-2{padding-inline:calc(var(--spacing)*2)}.sm\\:px-3{padding-inline:calc(var(--spacing)*3)}.sm\\:px-4{padding-inline:calc(var(--spacing)*4)}.sm\\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}}@media(min-width:48rem){.md\\:top-\\[2px\\]{top:2px}.md\\:left-\\[2px\\]{left:2px}.md\\:col-span-2{grid-column:span 2/span 2}.md\\:mx-0{margin-inline:calc(var(--spacing)*0)}.md\\:block{display:block}.md\\:inline{display:inline}.md\\:h-4{height:calc(var(--spacing)*4)}.md\\:h-5{height:calc(var(--spacing)*5)}.md\\:min-h-0{min-height:calc(var(--spacing)*0)}.md\\:w-4{width:calc(var(--spacing)*4)}.md\\:w-9{width:calc(var(--spacing)*9)}.md\\:min-w-\\[200px\\]{min-width:200px}.md\\:flex-1{flex:1}.md\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\\:flex-row{flex-direction:row}.md\\:flex-wrap{flex-wrap:wrap}.md\\:items-center{align-items:center}.md\\:gap-4{gap:calc(var(--spacing)*4)}.md\\:p-6{padding:calc(var(--spacing)*6)}.md\\:p-8{padding:calc(var(--spacing)*8)}.md\\:px-0{padding-inline:calc(var(--spacing)*0)}.md\\:py-2{padding-block:calc(var(--spacing)*2)}.md\\:pb-0{padding-bottom:calc(var(--spacing)*0)}}@media(min-width:64rem){.lg\\:static{position:static}.lg\\:sticky{position:sticky}.lg\\:top-4{top:calc(var(--spacing)*4)}.lg\\:col-span-2{grid-column:span 2/span 2}.lg\\:col-span-3{grid-column:span 3/span 3}.lg\\:mb-0{margin-bottom:calc(var(--spacing)*0)}.lg\\:-ml-px{margin-left:-1px}.lg\\:flex{display:flex}.lg\\:hidden{display:none}.lg\\:h-\\[calc\\(100vh-64px\\)\\]{height:calc(100vh - 64px)}.lg\\:w-1\\/2{width:50%}.lg\\:w-1\\/3{width:33.3333%}.lg\\:w-2\\/3{width:66.6667%}.lg\\:w-48{width:calc(var(--spacing)*48)}.lg\\:w-80{width:calc(var(--spacing)*80)}.lg\\:w-\\[480px\\]{width:480px}.lg\\:max-w-md{max-width:var(--container-md)}.lg\\:max-w-sm{max-width:var(--container-sm)}.lg\\:max-w-xl{max-width:var(--container-xl)}.lg\\:flex-shrink-0{flex-shrink:0}.lg\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\\:flex-col{flex-direction:column}.lg\\:flex-row{flex-direction:row}.lg\\:flex-nowrap{flex-wrap:nowrap}.lg\\:justify-start{justify-content:flex-start}.lg\\:gap-0{gap:calc(var(--spacing)*0)}.lg\\:gap-6{gap:calc(var(--spacing)*6)}.lg\\:gap-8{gap:calc(var(--spacing)*8)}.lg\\:self-start{align-self:flex-start}.lg\\:overflow-y-auto{overflow-y:auto}.lg\\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.lg\\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.lg\\:border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.lg\\:p-8{padding:calc(var(--spacing)*8)}}@media(min-width:80rem){.xl\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.xl\\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}@media(min-width:96rem){.\\32xl\\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}.dark\\:border-amber-700:where(.dark,.dark *){border-color:var(--color-amber-700)}.dark\\:border-amber-800:where(.dark,.dark *){border-color:var(--color-amber-800)}.dark\\:border-bambu-dark-tertiary:where(.dark,.dark *),.dark\\:border-bambu-dark-tertiary\\/50:where(.dark,.dark *){border-color:var(--color-bambu-dark-tertiary)}@supports (color:color-mix(in lab,red,red)){.dark\\:border-bambu-dark-tertiary\\/50:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-bambu-dark-tertiary)50%,transparent)}}.dark\\:border-bambu-green\\/40:where(.dark,.dark *){border-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.dark\\:border-bambu-green\\/40:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-bambu-green)40%,transparent)}}.dark\\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\\:border-red-500\\/30:where(.dark,.dark *){border-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.dark\\:border-red-500\\/30:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-red-500)30%,transparent)}}.dark\\:border-red-500\\/40:where(.dark,.dark *){border-color:#fb2c3666}@supports (color:color-mix(in lab,red,red)){.dark\\:border-red-500\\/40:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-red-500)40%,transparent)}}.dark\\:bg-amber-500\\/10:where(.dark,.dark *){background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-amber-500\\/10:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-amber-500)10%,transparent)}}.dark\\:bg-amber-900\\/20:where(.dark,.dark *){background-color:#7b330633}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-amber-900\\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-amber-900)20%,transparent)}}.dark\\:bg-bambu-dark:where(.dark,.dark *){background-color:var(--color-bambu-dark)}.dark\\:bg-bambu-dark-secondary:where(.dark,.dark *){background-color:var(--color-bambu-dark-secondary)}.dark\\:bg-bambu-green\\/20:where(.dark,.dark *){background-color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-bambu-green\\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-bambu-green)20%,transparent)}}.dark\\:bg-black\\/80:where(.dark,.dark *){background-color:#000c}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-black\\/80:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-black)80%,transparent)}}.dark\\:bg-blue-500\\/10:where(.dark,.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-blue-500\\/10:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.dark\\:bg-blue-500\\/20:where(.dark,.dark *){background-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-blue-500\\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-blue-500)20%,transparent)}}.dark\\:bg-blue-900\\/20:where(.dark,.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-blue-900\\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-blue-900)20%,transparent)}}.dark\\:bg-gray-700:where(.dark,.dark *){background-color:var(--color-gray-700)}.dark\\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.dark\\:bg-red-500\\/10:where(.dark,.dark *){background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-red-500\\/10:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.dark\\:bg-red-500\\/20:where(.dark,.dark *){background-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.dark\\:bg-red-500\\/20:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.dark\\:text-amber-200:where(.dark,.dark *){color:var(--color-amber-200)}.dark\\:text-amber-300:where(.dark,.dark *){color:var(--color-amber-300)}.dark\\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\\:text-bambu-gray:where(.dark,.dark *),.dark\\:text-bambu-gray\\/30:where(.dark,.dark *){color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.dark\\:text-bambu-gray\\/30:where(.dark,.dark *){color:color-mix(in oklab,var(--color-bambu-gray)30%,transparent)}}.dark\\:text-bambu-gray\\/50:where(.dark,.dark *){color:var(--color-bambu-gray)}@supports (color:color-mix(in lab,red,red)){.dark\\:text-bambu-gray\\/50:where(.dark,.dark *){color:color-mix(in oklab,var(--color-bambu-gray)50%,transparent)}}.dark\\:text-bambu-green:where(.dark,.dark *),.dark\\:text-bambu-green\\/60:where(.dark,.dark *){color:var(--color-bambu-green)}@supports (color:color-mix(in lab,red,red)){.dark\\:text-bambu-green\\/60:where(.dark,.dark *){color:color-mix(in oklab,var(--color-bambu-green)60%,transparent)}}.dark\\:text-blue-200:where(.dark,.dark *){color:var(--color-blue-200)}.dark\\:text-blue-200\\/80:where(.dark,.dark *){color:#bedbffcc}@supports (color:color-mix(in lab,red,red)){.dark\\:text-blue-200\\/80:where(.dark,.dark *){color:color-mix(in oklab,var(--color-blue-200)80%,transparent)}}.dark\\:text-blue-300:where(.dark,.dark *){color:var(--color-blue-300)}.dark\\:text-blue-300\\/60:where(.dark,.dark *){color:#90c5ff99}@supports (color:color-mix(in lab,red,red)){.dark\\:text-blue-300\\/60:where(.dark,.dark *){color:color-mix(in oklab,var(--color-blue-300)60%,transparent)}}.dark\\:text-blue-400:where(.dark,.dark *){color:var(--color-blue-400)}.dark\\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\\:text-gray-500:where(.dark,.dark *){color:var(--color-gray-500)}.dark\\:text-gray-600:where(.dark,.dark *){color:var(--color-gray-600)}.dark\\:text-green-400:where(.dark,.dark *){color:var(--color-green-400)}.dark\\:text-orange-200:where(.dark,.dark *){color:var(--color-orange-200)}.dark\\:text-orange-400:where(.dark,.dark *){color:var(--color-orange-400)}.dark\\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.dark\\:text-red-400\\/70:where(.dark,.dark *){color:#ff6568b3}@supports (color:color-mix(in lab,red,red)){.dark\\:text-red-400\\/70:where(.dark,.dark *){color:color-mix(in oklab,var(--color-red-400)70%,transparent)}}.dark\\:text-white:where(.dark,.dark *){color:var(--color-white)}.dark\\:text-yellow-200:where(.dark,.dark *){color:var(--color-yellow-200)}.dark\\:text-yellow-200\\/70:where(.dark,.dark *){color:#fff085b3}@supports (color:color-mix(in lab,red,red)){.dark\\:text-yellow-200\\/70:where(.dark,.dark *){color:color-mix(in oklab,var(--color-yellow-200)70%,transparent)}}@media(hover:hover){.dark\\:hover\\:border-gray-500:where(.dark,.dark *):hover{border-color:var(--color-gray-500)}.dark\\:hover\\:bg-bambu-dark-tertiary:where(.dark,.dark *):hover{background-color:var(--color-bambu-dark-tertiary)}.dark\\:hover\\:bg-bambu-dark\\/50:where(.dark,.dark *):hover{background-color:var(--color-bambu-dark)}@supports (color:color-mix(in lab,red,red)){.dark\\:hover\\:bg-bambu-dark\\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-bambu-dark)50%,transparent)}}.dark\\:hover\\:bg-gray-600:where(.dark,.dark *):hover{background-color:var(--color-gray-600)}.dark\\:hover\\:bg-red-500\\/30:where(.dark,.dark *):hover{background-color:#fb2c364d}@supports (color:color-mix(in lab,red,red)){.dark\\:hover\\:bg-red-500\\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-red-500)30%,transparent)}}.dark\\:hover\\:text-amber-200:where(.dark,.dark *):hover{color:var(--color-amber-200)}.dark\\:hover\\:text-gray-300:where(.dark,.dark *):hover{color:var(--color-gray-300)}.dark\\:hover\\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}}.\\[\\&\\:\\:-webkit-inner-spin-button\\]\\:appearance-none::-webkit-inner-spin-button{appearance:none}.\\[\\&\\:\\:-webkit-outer-spin-button\\]\\:appearance-none::-webkit-outer-spin-button{appearance:none}.\\[\\&\\:\\:-webkit-slider-thumb\\]\\:h-3::-webkit-slider-thumb{height:calc(var(--spacing)*3)}.\\[\\&\\:\\:-webkit-slider-thumb\\]\\:w-3::-webkit-slider-thumb{width:calc(var(--spacing)*3)}.\\[\\&\\:\\:-webkit-slider-thumb\\]\\:cursor-pointer::-webkit-slider-thumb{cursor:pointer}.\\[\\&\\:\\:-webkit-slider-thumb\\]\\:appearance-none::-webkit-slider-thumb{appearance:none}.\\[\\&\\:\\:-webkit-slider-thumb\\]\\:rounded-full::-webkit-slider-thumb{border-radius:3.40282e38px}.\\[\\&\\:\\:-webkit-slider-thumb\\]\\:bg-bambu-green::-webkit-slider-thumb{background-color:var(--color-bambu-green)}}:root{--accent:#00ae42;--accent-light:#00c64d;--accent-dark:#009438;--status-ok:#22c55e;--status-error:#ef4444;--status-warning:#f59e0b;--bg-primary:#f5f5f5;--bg-secondary:#fff;--bg-tertiary:#e5e5e5;--text-primary:#1a1a1a;--text-secondary:#4a4a4a;--text-muted:#6b6b6b;--text-tertiary:gray;--border-color:#d4d4d4;--card-shadow:0 2px 8px #00000014;--glow-color:transparent;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,system-ui,sans-serif;font-weight:400;line-height:1.5}.dark{--bg-primary:#1a1a1a;--bg-secondary:#2d2d2d;--bg-tertiary:#3d3d3d;--text-primary:#fff;--text-secondary:#a0a0a0;--text-muted:gray;--text-tertiary:#4a4a4a;--border-color:#3d3d3d;--card-shadow:0 4px 16px #0006}.bg-warm{--bg-primary:#faf8f5;--bg-secondary:#fffefa;--bg-tertiary:#e8e4dd;--text-primary:#2d2a26;--text-secondary:#5c5750;--text-muted:#7a756c;--text-tertiary:#9a9590;--border-color:#d8d4cc}.bg-cool{--bg-primary:#f0f4f8;--bg-secondary:#fff;--bg-tertiary:#dce4ec;--text-primary:#1a2530;--text-secondary:#4a5568;--text-muted:#6b7a8a;--text-tertiary:#8a9aaa;--border-color:#c8d4e0}.dark.bg-neutral{--bg-primary:#1a1a1a;--bg-secondary:#2d2d2d;--bg-tertiary:#3d3d3d;--text-primary:#fff;--text-secondary:#a0a0a0;--text-muted:gray;--text-tertiary:#4a4a4a;--border-color:#3d3d3d}.dark.bg-warm{--bg-primary:#1c1a18;--bg-secondary:#2e2a26;--bg-tertiary:#3e3a36;--text-primary:#f5f0ea;--text-secondary:#b0a898;--text-muted:#8a8278;--text-tertiary:#5a5248;--border-color:#3e3a36}.dark.bg-cool{--bg-primary:#181c20;--bg-secondary:#262c32;--bg-tertiary:#363e46;--text-primary:#f0f4f8;--text-secondary:#98a8b8;--text-muted:#788898;--text-tertiary:#4a5a6a;--border-color:#363e46}.dark.bg-oled{--bg-primary:#000;--bg-secondary:#141414;--bg-tertiary:#1f1f1f;--text-primary:#fff;--text-secondary:#a0a0a0;--text-muted:#707070;--text-tertiary:#404040;--border-color:#2a2a2a}.dark.bg-slate{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#94a3b8;--text-muted:#64748b;--text-tertiary:#475569;--border-color:#334155}.dark.bg-forest{--bg-primary:#121a16;--bg-secondary:#1c2a22;--bg-tertiary:#2a3d30;--text-primary:#e8f5ec;--text-secondary:#8aa894;--text-muted:#6a8874;--text-tertiary:#4a6854;--border-color:#2a3d30}.printer-control-buttons-container{container-type:inline-size}@container (max-width:220px){.printer-control-buttons{flex-direction:column;align-items:stretch}.printer-control-buttons>button{width:100%}}.style-glow{--card-shadow:0 2px 8px #00000014,0 0 25px var(--accent)}@supports (color:color-mix(in lab,red,red)){.style-glow{--card-shadow:0 2px 8px #00000014,0 0 25px color-mix(in srgb,var(--accent)12%,transparent)}}.dark.style-glow{--card-shadow:0 4px 20px #00000080,0 0 40px var(--accent)}@supports (color:color-mix(in lab,red,red)){.dark.style-glow{--card-shadow:0 4px 20px #00000080,0 0 40px color-mix(in srgb,var(--accent)15%,transparent)}}.style-vibrant{--card-shadow:0 8px 30px #00000026,0 2px 8px #0000001a}.dark.style-vibrant{--card-shadow:0 10px 40px #0009,0 4px 12px #0006}.accent-green{--accent:#00ae42;--accent-light:#00c64d;--accent-dark:#009438}.accent-teal{--accent:#14b8a6;--accent-light:#2dd4bf;--accent-dark:#0d9488}.accent-blue{--accent:#3b82f6;--accent-light:#60a5fa;--accent-dark:#2563eb}.accent-orange{--accent:#f97316;--accent-light:#fb923c;--accent-dark:#ea580c}.accent-purple{--accent:#8b5cf6;--accent-light:#a78bfa;--accent-dark:#7c3aed}.accent-red{--accent:#ef4444;--accent-light:#f87171;--accent-dark:#dc2626}body{background-color:var(--bg-primary);color:var(--text-primary);min-height:100vh;margin:0;transition:background-color .2s,color .2s}#root{min-height:100vh}.text-white{color:var(--text-primary)}.bg-bambu-dark,.bg-bambu-dark-secondary,.bg-bambu-dark-tertiary,.border-bambu-dark-tertiary{transition:background-color .2s,border-color .2s}@keyframes slide-in{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}.animate-slide-in{animation:.2s ease-out slide-in}.icon-theme{opacity:.5}.dark .icon-theme{filter:invert();opacity:.4}.icon-green{filter:invert(48%)sepia(89%)saturate(459%)hue-rotate(93deg)brightness(95%)contrast(92%);opacity:1}.icon-heating{filter:invert(50%)sepia()saturate(1000%)hue-rotate(360deg)brightness()contrast();opacity:1}.dark .jogpad-theme{filter:brightness(.35)contrast(1.2)}.ams-empty-slot{background:repeating-linear-gradient(45deg,#444,#444 2px,#222 2px 4px)}.dark .ams-empty-slot{background:repeating-linear-gradient(45deg,#555,#555 2px,#333 2px 4px)}.touch-manipulation{touch-action:manipulation}.safe-area-bottom{padding-bottom:env(safe-area-inset-bottom,0)}.safe-area-top{padding-top:env(safe-area-inset-top,0)}.scrollbar-hide{-ms-overflow-style:none;scrollbar-width:none}.scrollbar-hide::-webkit-scrollbar{display:none}@keyframes slide-in-left{0%{transform:translate(-100%)}to{transform:translate(0)}}.animate-slide-in-left{animation:.3s ease-out slide-in-left}@keyframes spoolbuddy-optimized-ping{0%{opacity:.8;transform:scale(.8)}80%,to{opacity:0;transform:scale(2.2)}}.spoolbuddy-optimized-ping{will-change:auto;contain:layout;animation:3.5s cubic-bezier(0,0,.2,1) infinite spoolbuddy-optimized-ping}.spoolbuddy-spool-glow{will-change:auto;transform:scale(2)}@media(prefers-reduced-motion:reduce){.spoolbuddy-optimized-ping{opacity:.25;animation:none;transform:scale(1.2)}.spoolbuddy-spool-glow{transition:none}}@keyframes slide-down{0%{transform:translateY(-100%)}to{transform:translateY(0)}}.animate-slide-down{animation:.25s ease-out slide-down}.card-shadow{box-shadow:var(--card-shadow)}[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl{flex-direction:column;height:90vh;max-height:90vh;display:flex}[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl>div:first-child,[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl>div:last-child{flex-shrink:0}[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl>div:nth-child(2){flex:auto;min-height:0;overflow-y:auto}[data-spoolbuddy-kiosk] .fixed .relative.max-w-2xl .max-h-96{max-height:none}.calendar-scroll{scrollbar-width:thin;scrollbar-color:var(--text-muted)transparent}@supports (color:color-mix(in lab,red,red)){.calendar-scroll{scrollbar-color:color-mix(in srgb,var(--text-muted)60%,transparent)transparent}}.calendar-scroll::-webkit-scrollbar{width:8px}.calendar-scroll::-webkit-scrollbar-track{background:0 0}.calendar-scroll::-webkit-scrollbar-thumb{background-color:var(--text-muted)}@supports (color:color-mix(in lab,red,red)){.calendar-scroll::-webkit-scrollbar-thumb{background-color:color-mix(in srgb,var(--text-muted)60%,transparent)}}.calendar-scroll::-webkit-scrollbar-thumb{border:2px solid var(--bg-secondary);border-radius:999px}@supports (color:color-mix(in lab,red,red)){.calendar-scroll::-webkit-scrollbar-thumb{border:2px solid color-mix(in srgb,var(--bg-secondary)70%,transparent)}}.calendar-scroll::-webkit-scrollbar-thumb:hover{background-color:var(--text-muted)}@supports (color:color-mix(in lab,red,red)){.calendar-scroll::-webkit-scrollbar-thumb:hover{background-color:color-mix(in srgb,var(--text-muted)80%,transparent)}}@property --tw-translate-x{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-y{syntax:\"*\";inherits:false;initial-value:0}@property --tw-translate-z{syntax:\"*\";inherits:false;initial-value:0}@property --tw-scale-x{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-y{syntax:\"*\";inherits:false;initial-value:1}@property --tw-scale-z{syntax:\"*\";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:\"*\";inherits:false}@property --tw-rotate-y{syntax:\"*\";inherits:false}@property --tw-rotate-z{syntax:\"*\";inherits:false}@property --tw-skew-x{syntax:\"*\";inherits:false}@property --tw-skew-y{syntax:\"*\";inherits:false}@property --tw-space-y-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:\"*\";inherits:false}@property --tw-gradient-from{syntax:\"<color>\";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:\"<color>\";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:\"<color>\";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:\"*\";inherits:false}@property --tw-gradient-via-stops{syntax:\"*\";inherits:false}@property --tw-gradient-from-position{syntax:\"<length-percentage>\";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:\"<length-percentage>\";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:\"<length-percentage>\";inherits:false;initial-value:100%}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-tracking{syntax:\"*\";inherits:false}@property --tw-ordinal{syntax:\"*\";inherits:false}@property --tw-slashed-zero{syntax:\"*\";inherits:false}@property --tw-numeric-figure{syntax:\"*\";inherits:false}@property --tw-numeric-spacing{syntax:\"*\";inherits:false}@property --tw-numeric-fraction{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"<length>\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-blur{syntax:\"*\";inherits:false}@property --tw-brightness{syntax:\"*\";inherits:false}@property --tw-contrast{syntax:\"*\";inherits:false}@property --tw-grayscale{syntax:\"*\";inherits:false}@property --tw-hue-rotate{syntax:\"*\";inherits:false}@property --tw-invert{syntax:\"*\";inherits:false}@property --tw-opacity{syntax:\"*\";inherits:false}@property --tw-saturate{syntax:\"*\";inherits:false}@property --tw-sepia{syntax:\"*\";inherits:false}@property --tw-drop-shadow{syntax:\"*\";inherits:false}@property --tw-drop-shadow-color{syntax:\"*\";inherits:false}@property --tw-drop-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:\"*\";inherits:false}@property --tw-backdrop-blur{syntax:\"*\";inherits:false}@property --tw-backdrop-brightness{syntax:\"*\";inherits:false}@property --tw-backdrop-contrast{syntax:\"*\";inherits:false}@property --tw-backdrop-grayscale{syntax:\"*\";inherits:false}@property --tw-backdrop-hue-rotate{syntax:\"*\";inherits:false}@property --tw-backdrop-invert{syntax:\"*\";inherits:false}@property --tw-backdrop-opacity{syntax:\"*\";inherits:false}@property --tw-backdrop-saturate{syntax:\"*\";inherits:false}@property --tw-backdrop-sepia{syntax:\"*\";inherits:false}@property --tw-duration{syntax:\"*\";inherits:false}@property --tw-ease{syntax:\"*\";inherits:false}@property --tw-content{syntax:\"*\";inherits:false;initial-value:\"\"}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:\"\"}}}.hg-theme-default{background-color:#ececec;border-radius:5px;box-sizing:border-box;font-family:HelveticaNeue-Light,Helvetica Neue Light,Helvetica Neue,Helvetica,Arial,Lucida Grande,sans-serif;overflow:hidden;padding:5px;touch-action:manipulation;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%}.hg-theme-default .hg-button span,.hg-theme-default .hg-button span svg{pointer-events:none}.hg-theme-default button.hg-button{border-width:0;font-size:inherit}.hg-theme-default .hg-button{display:inline-block;flex-grow:1}.hg-theme-default .hg-row{display:flex}.hg-theme-default .hg-row:not(:last-child){margin-bottom:5px}.hg-theme-default .hg-row .hg-button-container,.hg-theme-default .hg-row .hg-button:not(:last-child){margin-right:5px}.hg-theme-default .hg-row>div:last-child{margin-right:0}.hg-theme-default .hg-row .hg-button-container{display:flex}.hg-theme-default .hg-button{align-items:center;background:#fff;border-bottom:1px solid #b5b5b5;border-radius:5px;box-shadow:0 0 3px -1px #0000004d;box-sizing:border-box;cursor:pointer;display:flex;height:40px;justify-content:center;padding:5px;-webkit-tap-highlight-color:rgba(0,0,0,0)}.hg-theme-default .hg-button.hg-standardBtn{width:20px}.hg-theme-default .hg-button.hg-activeButton{background:#efefef}.hg-theme-default.hg-layout-numeric .hg-button{align-items:center;display:flex;height:60px;justify-content:center;width:33.3%}.hg-theme-default .hg-button.hg-button-numpadadd,.hg-theme-default .hg-button.hg-button-numpadenter{height:85px}.hg-theme-default .hg-button.hg-button-numpad0{width:105px}.hg-theme-default .hg-button.hg-button-com{max-width:85px}.hg-theme-default .hg-button.hg-standardBtn.hg-button-at{max-width:45px}.hg-theme-default .hg-button.hg-selectedButton{background:#05194687;color:#fff}.hg-theme-default .hg-button.hg-standardBtn[data-skbtn=\".com\"]{max-width:82px}.hg-theme-default .hg-button.hg-standardBtn[data-skbtn=\"@\"]{max-width:60px}.hg-candidate-box{background:#ececec;border-bottom:2px solid #b5b5b5;border-radius:5px;display:inline-flex;margin-top:-10px;position:absolute;transform:translateY(-100%);-webkit-user-select:none;-moz-user-select:none;user-select:none}ul.hg-candidate-box-list{display:flex;flex:1;list-style:none;margin:0;padding:0}li.hg-candidate-box-list-item{align-items:center;display:flex;height:40px;justify-content:center;width:40px}li.hg-candidate-box-list-item:hover{background:#00000008;cursor:pointer}li.hg-candidate-box-list-item:active{background:#0000001a}.hg-candidate-box-prev:before{content:\"◄\"}.hg-candidate-box-next:before{content:\"►\"}.hg-candidate-box-next,.hg-candidate-box-prev{align-items:center;color:#969696;cursor:pointer;display:flex;padding:0 10px}.hg-candidate-box-next{border-bottom-right-radius:5px;border-top-right-radius:5px}.hg-candidate-box-prev{border-bottom-left-radius:5px;border-top-left-radius:5px}.hg-candidate-box-btn-active{color:#444}.simple-keyboard.vkb-theme{background:#1a1a1a;border-top:1px solid #333;padding:8px 4px;font-family:inherit}.simple-keyboard.vkb-theme .hg-row{display:flex!important;flex-direction:row!important;flex-wrap:nowrap!important;gap:4px;margin-bottom:4px}.simple-keyboard.vkb-theme .hg-row:last-child{margin-bottom:0}.simple-keyboard.vkb-theme .hg-button{display:inline-flex!important;align-items:center;justify-content:center;flex-grow:1;flex-shrink:1;flex-basis:auto;background:#2d2d2d;color:#e0e0e0;border:none;border-radius:6px;height:44px;font-size:16px;font-weight:500;box-shadow:0 1px 2px #0000004d;transition:background .1s;cursor:pointer;padding:0 2px;min-width:0}.simple-keyboard.vkb-theme .hg-button:active{background:#00ae42;color:#fff}.simple-keyboard.vkb-theme .hg-button-bksp,.simple-keyboard.vkb-theme .hg-button-shift,.simple-keyboard.vkb-theme .hg-button-lock{background:#3a3a3a;color:#aaa;flex-grow:1.5}.simple-keyboard.vkb-theme .hg-button-close{background:#3a3a3a;color:#aaa;flex-grow:2;font-weight:600}.simple-keyboard.vkb-theme .hg-button-close:active{background:#555}.simple-keyboard.vkb-theme .hg-button-space{flex-grow:7}.simple-keyboard.vkb-theme .hg-activeButton{background:#00ae42;color:#fff}\n"
  },
  {
    "path": "static/assets/index-NbcE7Ots.js",
    "content": "function Fde(t,e){for(var n=0;n<e.length;n++){const r=e[n];if(typeof r!=\"string\"&&!Array.isArray(r)){for(const i in r)if(i!==\"default\"&&!(i in t)){const s=Object.getOwnPropertyDescriptor(r,i);s&&Object.defineProperty(t,i,s.get?s:{enumerable:!0,get:()=>r[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 s of i)if(s.type===\"childList\")for(const o of s.addedNodes)o.tagName===\"LINK\"&&o.rel===\"modulepreload\"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin===\"use-credentials\"?s.credentials=\"include\":i.crossOrigin===\"anonymous\"?s.credentials=\"omit\":s.credentials=\"same-origin\",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var W_=typeof globalThis<\"u\"?globalThis:typeof window<\"u\"?window:typeof global<\"u\"?global:typeof self<\"u\"?self:{};function bc(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,\"default\")?t.default:t}var fM={exports:{}},Tv={};var T8;function Rde(){if(T8)return Tv;T8=1;var t=Symbol.for(\"react.transitional.element\"),e=Symbol.for(\"react.fragment\");function n(r,i,s){var o=null;if(s!==void 0&&(o=\"\"+s),i.key!==void 0&&(o=\"\"+i.key),\"key\"in i){s={};for(var l in i)l!==\"key\"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return Tv.Fragment=e,Tv.jsx=n,Tv.jsxs=n,Tv}var A8;function Lde(){return A8||(A8=1,fM.exports=Rde()),fM.exports}var a=Lde(),gM={exports:{}},Wn={};var j8;function Ode(){if(j8)return Wn;j8=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\"),s=Symbol.for(\"react.consumer\"),o=Symbol.for(\"react.context\"),l=Symbol.for(\"react.forward_ref\"),c=Symbol.for(\"react.suspense\"),d=Symbol.for(\"react.memo\"),u=Symbol.for(\"react.lazy\"),m=Symbol.for(\"react.activity\"),p=Symbol.iterator;function f(W){return W===null||typeof W!=\"object\"?null:(W=p&&W[p]||W[\"@@iterator\"],typeof W==\"function\"?W:null)}var y={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,b={};function g(W,ne,me){this.props=W,this.context=ne,this.refs=b,this.updater=me||y}g.prototype.isReactComponent={},g.prototype.setState=function(W,ne){if(typeof W!=\"object\"&&typeof W!=\"function\"&&W!=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,W,ne,\"setState\")},g.prototype.forceUpdate=function(W){this.updater.enqueueForceUpdate(this,W,\"forceUpdate\")};function _(){}_.prototype=g.prototype;function C(W,ne,me){this.props=W,this.context=ne,this.refs=b,this.updater=me||y}var P=C.prototype=new _;P.constructor=C,v(P,g.prototype),P.isPureReactComponent=!0;var N=Array.isArray;function A(){}var T={H:null,A:null,T:null,S:null},F=Object.prototype.hasOwnProperty;function k(W,ne,me){var be=me.ref;return{$$typeof:t,type:W,key:ne,ref:be!==void 0?be:null,props:me}}function D(W,ne){return k(W.type,ne,W.props)}function H(W){return typeof W==\"object\"&&W!==null&&W.$$typeof===t}function z(W){var ne={\"=\":\"=0\",\":\":\"=2\"};return\"$\"+W.replace(/[=:]/g,function(me){return ne[me]})}var Q=/\\/+/g;function L(W,ne){return typeof W==\"object\"&&W!==null&&W.key!=null?z(\"\"+W.key):ne.toString(36)}function te(W){switch(W.status){case\"fulfilled\":return W.value;case\"rejected\":throw W.reason;default:switch(typeof W.status==\"string\"?W.then(A,A):(W.status=\"pending\",W.then(function(ne){W.status===\"pending\"&&(W.status=\"fulfilled\",W.value=ne)},function(ne){W.status===\"pending\"&&(W.status=\"rejected\",W.reason=ne)})),W.status){case\"fulfilled\":return W.value;case\"rejected\":throw W.reason}}throw W}function ie(W,ne,me,be,Ce){var q=typeof W;(q===\"undefined\"||q===\"boolean\")&&(W=null);var Y=!1;if(W===null)Y=!0;else switch(q){case\"bigint\":case\"string\":case\"number\":Y=!0;break;case\"object\":switch(W.$$typeof){case t:case e:Y=!0;break;case u:return Y=W._init,ie(Y(W._payload),ne,me,be,Ce)}}if(Y)return Ce=Ce(W),Y=be===\"\"?\".\"+L(W,0):be,N(Ce)?(me=\"\",Y!=null&&(me=Y.replace(Q,\"$&/\")+\"/\"),ie(Ce,ne,me,\"\",function(O){return O})):Ce!=null&&(H(Ce)&&(Ce=D(Ce,me+(Ce.key==null||W&&W.key===Ce.key?\"\":(\"\"+Ce.key).replace(Q,\"$&/\")+\"/\")+Y)),ne.push(Ce)),1;Y=0;var E=be===\"\"?\".\":be+\":\";if(N(W))for(var j=0;j<W.length;j++)be=W[j],q=E+L(be,j),Y+=ie(be,ne,me,q,Ce);else if(j=f(W),typeof j==\"function\")for(W=j.call(W),j=0;!(be=W.next()).done;)be=be.value,q=E+L(be,j++),Y+=ie(be,ne,me,q,Ce);else if(q===\"object\"){if(typeof W.then==\"function\")return ie(te(W),ne,me,be,Ce);throw ne=String(W),Error(\"Objects are not valid as a React child (found: \"+(ne===\"[object Object]\"?\"object with keys {\"+Object.keys(W).join(\", \")+\"}\":ne)+\"). If you meant to render a collection of children, use an array instead.\")}return Y}function J(W,ne,me){if(W==null)return W;var be=[],Ce=0;return ie(W,be,\"\",\"\",function(q){return ne.call(me,q,Ce++)}),be}function oe(W){if(W._status===-1){var ne=W._result;ne=ne(),ne.then(function(me){(W._status===0||W._status===-1)&&(W._status=1,W._result=me)},function(me){(W._status===0||W._status===-1)&&(W._status=2,W._result=me)}),W._status===-1&&(W._status=0,W._result=ne)}if(W._status===1)return W._result.default;throw W._result}var fe=typeof reportError==\"function\"?reportError:function(W){if(typeof window==\"object\"&&typeof window.ErrorEvent==\"function\"){var ne=new window.ErrorEvent(\"error\",{bubbles:!0,cancelable:!0,message:typeof W==\"object\"&&W!==null&&typeof W.message==\"string\"?String(W.message):String(W),error:W});if(!window.dispatchEvent(ne))return}else if(typeof process==\"object\"&&typeof process.emit==\"function\"){process.emit(\"uncaughtException\",W);return}console.error(W)},re={map:J,forEach:function(W,ne,me){J(W,function(){ne.apply(this,arguments)},me)},count:function(W){var ne=0;return J(W,function(){ne++}),ne},toArray:function(W){return J(W,function(ne){return ne})||[]},only:function(W){if(!H(W))throw Error(\"React.Children.only expected to receive a single React element child.\");return W}};return Wn.Activity=m,Wn.Children=re,Wn.Component=g,Wn.Fragment=n,Wn.Profiler=i,Wn.PureComponent=C,Wn.StrictMode=r,Wn.Suspense=c,Wn.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=T,Wn.__COMPILER_RUNTIME={__proto__:null,c:function(W){return T.H.useMemoCache(W)}},Wn.cache=function(W){return function(){return W.apply(null,arguments)}},Wn.cacheSignal=function(){return null},Wn.cloneElement=function(W,ne,me){if(W==null)throw Error(\"The argument must be a React element, but you passed \"+W+\".\");var be=v({},W.props),Ce=W.key;if(ne!=null)for(q in ne.key!==void 0&&(Ce=\"\"+ne.key),ne)!F.call(ne,q)||q===\"key\"||q===\"__self\"||q===\"__source\"||q===\"ref\"&&ne.ref===void 0||(be[q]=ne[q]);var q=arguments.length-2;if(q===1)be.children=me;else if(1<q){for(var Y=Array(q),E=0;E<q;E++)Y[E]=arguments[E+2];be.children=Y}return k(W.type,Ce,be)},Wn.createContext=function(W){return W={$$typeof:o,_currentValue:W,_currentValue2:W,_threadCount:0,Provider:null,Consumer:null},W.Provider=W,W.Consumer={$$typeof:s,_context:W},W},Wn.createElement=function(W,ne,me){var be,Ce={},q=null;if(ne!=null)for(be in ne.key!==void 0&&(q=\"\"+ne.key),ne)F.call(ne,be)&&be!==\"key\"&&be!==\"__self\"&&be!==\"__source\"&&(Ce[be]=ne[be]);var Y=arguments.length-2;if(Y===1)Ce.children=me;else if(1<Y){for(var E=Array(Y),j=0;j<Y;j++)E[j]=arguments[j+2];Ce.children=E}if(W&&W.defaultProps)for(be in Y=W.defaultProps,Y)Ce[be]===void 0&&(Ce[be]=Y[be]);return k(W,q,Ce)},Wn.createRef=function(){return{current:null}},Wn.forwardRef=function(W){return{$$typeof:l,render:W}},Wn.isValidElement=H,Wn.lazy=function(W){return{$$typeof:u,_payload:{_status:-1,_result:W},_init:oe}},Wn.memo=function(W,ne){return{$$typeof:d,type:W,compare:ne===void 0?null:ne}},Wn.startTransition=function(W){var ne=T.T,me={};T.T=me;try{var be=W(),Ce=T.S;Ce!==null&&Ce(me,be),typeof be==\"object\"&&be!==null&&typeof be.then==\"function\"&&be.then(A,fe)}catch(q){fe(q)}finally{ne!==null&&me.types!==null&&(ne.types=me.types),T.T=ne}},Wn.unstable_useCacheRefresh=function(){return T.H.useCacheRefresh()},Wn.use=function(W){return T.H.use(W)},Wn.useActionState=function(W,ne,me){return T.H.useActionState(W,ne,me)},Wn.useCallback=function(W,ne){return T.H.useCallback(W,ne)},Wn.useContext=function(W){return T.H.useContext(W)},Wn.useDebugValue=function(){},Wn.useDeferredValue=function(W,ne){return T.H.useDeferredValue(W,ne)},Wn.useEffect=function(W,ne){return T.H.useEffect(W,ne)},Wn.useEffectEvent=function(W){return T.H.useEffectEvent(W)},Wn.useId=function(){return T.H.useId()},Wn.useImperativeHandle=function(W,ne,me){return T.H.useImperativeHandle(W,ne,me)},Wn.useInsertionEffect=function(W,ne){return T.H.useInsertionEffect(W,ne)},Wn.useLayoutEffect=function(W,ne){return T.H.useLayoutEffect(W,ne)},Wn.useMemo=function(W,ne){return T.H.useMemo(W,ne)},Wn.useOptimistic=function(W,ne){return T.H.useOptimistic(W,ne)},Wn.useReducer=function(W,ne,me){return T.H.useReducer(W,ne,me)},Wn.useRef=function(W){return T.H.useRef(W)},Wn.useState=function(W){return T.H.useState(W)},Wn.useSyncExternalStore=function(W,ne,me){return T.H.useSyncExternalStore(W,ne,me)},Wn.useTransition=function(){return T.H.useTransition()},Wn.version=\"19.2.4\",Wn}var M8;function Rg(){return M8||(M8=1,gM.exports=Ode()),gM.exports}var w=Rg();const ia=bc(w),Ide=Fde({__proto__:null,default:ia},[w]);var bM={exports:{}},Av={},xM={exports:{}},yM={};var E8;function zde(){return E8||(E8=1,(function(t){function e(ie,J){var oe=ie.length;ie.push(J);e:for(;0<oe;){var fe=oe-1>>>1,re=ie[fe];if(0<i(re,J))ie[fe]=J,ie[oe]=re,oe=fe;else break e}}function n(ie){return ie.length===0?null:ie[0]}function r(ie){if(ie.length===0)return null;var J=ie[0],oe=ie.pop();if(oe!==J){ie[0]=oe;e:for(var fe=0,re=ie.length,W=re>>>1;fe<W;){var ne=2*(fe+1)-1,me=ie[ne],be=ne+1,Ce=ie[be];if(0>i(me,oe))be<re&&0>i(Ce,me)?(ie[fe]=Ce,ie[be]=oe,fe=be):(ie[fe]=me,ie[ne]=oe,fe=ne);else if(be<re&&0>i(Ce,oe))ie[fe]=Ce,ie[be]=oe,fe=be;else break e}}return J}function i(ie,J){var oe=ie.sortIndex-J.sortIndex;return oe!==0?oe:ie.id-J.id}if(t.unstable_now=void 0,typeof performance==\"object\"&&typeof performance.now==\"function\"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],d=[],u=1,m=null,p=3,f=!1,y=!1,v=!1,b=!1,g=typeof setTimeout==\"function\"?setTimeout:null,_=typeof clearTimeout==\"function\"?clearTimeout:null,C=typeof setImmediate<\"u\"?setImmediate:null;function P(ie){for(var J=n(d);J!==null;){if(J.callback===null)r(d);else if(J.startTime<=ie)r(d),J.sortIndex=J.expirationTime,e(c,J);else break;J=n(d)}}function N(ie){if(v=!1,P(ie),!y)if(n(c)!==null)y=!0,A||(A=!0,z());else{var J=n(d);J!==null&&te(N,J.startTime-ie)}}var A=!1,T=-1,F=5,k=-1;function D(){return b?!0:!(t.unstable_now()-k<F)}function H(){if(b=!1,A){var ie=t.unstable_now();k=ie;var J=!0;try{e:{y=!1,v&&(v=!1,_(T),T=-1),f=!0;var oe=p;try{t:{for(P(ie),m=n(c);m!==null&&!(m.expirationTime>ie&&D());){var fe=m.callback;if(typeof fe==\"function\"){m.callback=null,p=m.priorityLevel;var re=fe(m.expirationTime<=ie);if(ie=t.unstable_now(),typeof re==\"function\"){m.callback=re,P(ie),J=!0;break t}m===n(c)&&r(c),P(ie)}else r(c);m=n(c)}if(m!==null)J=!0;else{var W=n(d);W!==null&&te(N,W.startTime-ie),J=!1}}break e}finally{m=null,p=oe,f=!1}J=void 0}}finally{J?z():A=!1}}}var z;if(typeof C==\"function\")z=function(){C(H)};else if(typeof MessageChannel<\"u\"){var Q=new MessageChannel,L=Q.port2;Q.port1.onmessage=H,z=function(){L.postMessage(null)}}else z=function(){g(H,0)};function te(ie,J){T=g(function(){ie(t.unstable_now())},J)}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(ie){ie.callback=null},t.unstable_forceFrameRate=function(ie){0>ie||125<ie?console.error(\"forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported\"):F=0<ie?Math.floor(1e3/ie):5},t.unstable_getCurrentPriorityLevel=function(){return p},t.unstable_next=function(ie){switch(p){case 1:case 2:case 3:var J=3;break;default:J=p}var oe=p;p=J;try{return ie()}finally{p=oe}},t.unstable_requestPaint=function(){b=!0},t.unstable_runWithPriority=function(ie,J){switch(ie){case 1:case 2:case 3:case 4:case 5:break;default:ie=3}var oe=p;p=ie;try{return J()}finally{p=oe}},t.unstable_scheduleCallback=function(ie,J,oe){var fe=t.unstable_now();switch(typeof oe==\"object\"&&oe!==null?(oe=oe.delay,oe=typeof oe==\"number\"&&0<oe?fe+oe:fe):oe=fe,ie){case 1:var re=-1;break;case 2:re=250;break;case 5:re=1073741823;break;case 4:re=1e4;break;default:re=5e3}return re=oe+re,ie={id:u++,callback:J,priorityLevel:ie,startTime:oe,expirationTime:re,sortIndex:-1},oe>fe?(ie.sortIndex=oe,e(d,ie),n(c)===null&&ie===n(d)&&(v?(_(T),T=-1):v=!0,te(N,oe-fe))):(ie.sortIndex=re,e(c,ie),y||f||(y=!0,A||(A=!0,z()))),ie},t.unstable_shouldYield=D,t.unstable_wrapCallback=function(ie){var J=p;return function(){var oe=p;p=J;try{return ie.apply(this,arguments)}finally{p=oe}}}})(yM)),yM}var D8;function Ude(){return D8||(D8=1,xM.exports=zde()),xM.exports}var vM={exports:{}},js={};var F8;function Bde(){if(F8)return js;F8=1;var t=Rg();function e(c){var d=\"https://react.dev/errors/\"+c;if(1<arguments.length){d+=\"?args[]=\"+encodeURIComponent(arguments[1]);for(var u=2;u<arguments.length;u++)d+=\"&args[]=\"+encodeURIComponent(arguments[u])}return\"Minified React error #\"+c+\"; visit \"+d+\" for the full message or use the non-minified dev environment for full errors and additional helpful warnings.\"}function n(){}var r={d:{f:n,r:function(){throw Error(e(522))},D:n,C:n,L:n,m:n,X:n,S:n,M:n},p:0,findDOMNode:null},i=Symbol.for(\"react.portal\");function s(c,d,u){var m=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:i,key:m==null?null:\"\"+m,children:c,containerInfo:d,implementation:u}}var o=t.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function l(c,d){if(c===\"font\")return\"\";if(typeof d==\"string\")return d===\"use-credentials\"?d:\"\"}return js.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=r,js.createPortal=function(c,d){var u=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!d||d.nodeType!==1&&d.nodeType!==9&&d.nodeType!==11)throw Error(e(299));return s(c,d,null,u)},js.flushSync=function(c){var d=o.T,u=r.p;try{if(o.T=null,r.p=2,c)return c()}finally{o.T=d,r.p=u,r.d.f()}},js.preconnect=function(c,d){typeof c==\"string\"&&(d?(d=d.crossOrigin,d=typeof d==\"string\"?d===\"use-credentials\"?d:\"\":void 0):d=null,r.d.C(c,d))},js.prefetchDNS=function(c){typeof c==\"string\"&&r.d.D(c)},js.preinit=function(c,d){if(typeof c==\"string\"&&d&&typeof d.as==\"string\"){var u=d.as,m=l(u,d.crossOrigin),p=typeof d.integrity==\"string\"?d.integrity:void 0,f=typeof d.fetchPriority==\"string\"?d.fetchPriority:void 0;u===\"style\"?r.d.S(c,typeof d.precedence==\"string\"?d.precedence:void 0,{crossOrigin:m,integrity:p,fetchPriority:f}):u===\"script\"&&r.d.X(c,{crossOrigin:m,integrity:p,fetchPriority:f,nonce:typeof d.nonce==\"string\"?d.nonce:void 0})}},js.preinitModule=function(c,d){if(typeof c==\"string\")if(typeof d==\"object\"&&d!==null){if(d.as==null||d.as===\"script\"){var u=l(d.as,d.crossOrigin);r.d.M(c,{crossOrigin:u,integrity:typeof d.integrity==\"string\"?d.integrity:void 0,nonce:typeof d.nonce==\"string\"?d.nonce:void 0})}}else d==null&&r.d.M(c)},js.preload=function(c,d){if(typeof c==\"string\"&&typeof d==\"object\"&&d!==null&&typeof d.as==\"string\"){var u=d.as,m=l(u,d.crossOrigin);r.d.L(c,u,{crossOrigin:m,integrity:typeof d.integrity==\"string\"?d.integrity:void 0,nonce:typeof d.nonce==\"string\"?d.nonce:void 0,type:typeof d.type==\"string\"?d.type:void 0,fetchPriority:typeof d.fetchPriority==\"string\"?d.fetchPriority:void 0,referrerPolicy:typeof d.referrerPolicy==\"string\"?d.referrerPolicy:void 0,imageSrcSet:typeof d.imageSrcSet==\"string\"?d.imageSrcSet:void 0,imageSizes:typeof d.imageSizes==\"string\"?d.imageSizes:void 0,media:typeof d.media==\"string\"?d.media:void 0})}},js.preloadModule=function(c,d){if(typeof c==\"string\")if(d){var u=l(d.as,d.crossOrigin);r.d.m(c,{as:typeof d.as==\"string\"&&d.as!==\"script\"?d.as:void 0,crossOrigin:u,integrity:typeof d.integrity==\"string\"?d.integrity:void 0})}else r.d.m(c)},js.requestFormReset=function(c){r.d.r(c)},js.unstable_batchedUpdates=function(c,d){return c(d)},js.useFormState=function(c,d,u){return o.H.useFormState(c,d,u)},js.useFormStatus=function(){return o.H.useHostTransitionStatus()},js.version=\"19.2.4\",js}var R8;function UY(){if(R8)return vM.exports;R8=1;function t(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>\"u\"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=\"function\"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),vM.exports=Bde(),vM.exports}var L8;function Hde(){if(L8)return Av;L8=1;var t=Ude(),e=Rg(),n=UY();function r(h){var x=\"https://react.dev/errors/\"+h;if(1<arguments.length){x+=\"?args[]=\"+encodeURIComponent(arguments[1]);for(var S=2;S<arguments.length;S++)x+=\"&args[]=\"+encodeURIComponent(arguments[S])}return\"Minified React error #\"+h+\"; visit \"+x+\" for the full message or use the non-minified dev environment for full errors and additional helpful warnings.\"}function i(h){return!(!h||h.nodeType!==1&&h.nodeType!==9&&h.nodeType!==11)}function s(h){var x=h,S=h;if(h.alternate)for(;x.return;)x=x.return;else{h=x;do x=h,(x.flags&4098)!==0&&(S=x.return),h=x.return;while(h)}return x.tag===3?S:null}function o(h){if(h.tag===13){var x=h.memoizedState;if(x===null&&(h=h.alternate,h!==null&&(x=h.memoizedState)),x!==null)return x.dehydrated}return null}function l(h){if(h.tag===31){var x=h.memoizedState;if(x===null&&(h=h.alternate,h!==null&&(x=h.memoizedState)),x!==null)return x.dehydrated}return null}function c(h){if(s(h)!==h)throw Error(r(188))}function d(h){var x=h.alternate;if(!x){if(x=s(h),x===null)throw Error(r(188));return x!==h?null:h}for(var S=h,M=x;;){var $=S.return;if($===null)break;var Z=$.alternate;if(Z===null){if(M=$.return,M!==null){S=M;continue}break}if($.child===Z.child){for(Z=$.child;Z;){if(Z===S)return c($),h;if(Z===M)return c($),x;Z=Z.sibling}throw Error(r(188))}if(S.return!==M.return)S=$,M=Z;else{for(var Ne=!1,He=$.child;He;){if(He===S){Ne=!0,S=$,M=Z;break}if(He===M){Ne=!0,M=$,S=Z;break}He=He.sibling}if(!Ne){for(He=Z.child;He;){if(He===S){Ne=!0,S=Z,M=$;break}if(He===M){Ne=!0,M=Z,S=$;break}He=He.sibling}if(!Ne)throw Error(r(189))}}if(S.alternate!==M)throw Error(r(190))}if(S.tag!==3)throw Error(r(188));return S.stateNode.current===S?h:x}function u(h){var x=h.tag;if(x===5||x===26||x===27||x===6)return h;for(h=h.child;h!==null;){if(x=u(h),x!==null)return x;h=h.sibling}return null}var m=Object.assign,p=Symbol.for(\"react.element\"),f=Symbol.for(\"react.transitional.element\"),y=Symbol.for(\"react.portal\"),v=Symbol.for(\"react.fragment\"),b=Symbol.for(\"react.strict_mode\"),g=Symbol.for(\"react.profiler\"),_=Symbol.for(\"react.consumer\"),C=Symbol.for(\"react.context\"),P=Symbol.for(\"react.forward_ref\"),N=Symbol.for(\"react.suspense\"),A=Symbol.for(\"react.suspense_list\"),T=Symbol.for(\"react.memo\"),F=Symbol.for(\"react.lazy\"),k=Symbol.for(\"react.activity\"),D=Symbol.for(\"react.memo_cache_sentinel\"),H=Symbol.iterator;function z(h){return h===null||typeof h!=\"object\"?null:(h=H&&h[H]||h[\"@@iterator\"],typeof h==\"function\"?h:null)}var Q=Symbol.for(\"react.client.reference\");function L(h){if(h==null)return null;if(typeof h==\"function\")return h.$$typeof===Q?null:h.displayName||h.name||null;if(typeof h==\"string\")return h;switch(h){case v:return\"Fragment\";case g:return\"Profiler\";case b:return\"StrictMode\";case N:return\"Suspense\";case A:return\"SuspenseList\";case k:return\"Activity\"}if(typeof h==\"object\")switch(h.$$typeof){case y:return\"Portal\";case C:return h.displayName||\"Context\";case _:return(h._context.displayName||\"Context\")+\".Consumer\";case P:var x=h.render;return h=h.displayName,h||(h=x.displayName||x.name||\"\",h=h!==\"\"?\"ForwardRef(\"+h+\")\":\"ForwardRef\"),h;case T:return x=h.displayName||null,x!==null?x:L(h.type)||\"Memo\";case F:x=h._payload,h=h._init;try{return L(h(x))}catch{}}return null}var te=Array.isArray,ie=e.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,J=n.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,oe={pending:!1,data:null,method:null,action:null},fe=[],re=-1;function W(h){return{current:h}}function ne(h){0>re||(h.current=fe[re],fe[re]=null,re--)}function me(h,x){re++,fe[re]=h.current,h.current=x}var be=W(null),Ce=W(null),q=W(null),Y=W(null);function E(h,x){switch(me(q,x),me(Ce,h),me(be,null),x.nodeType){case 9:case 11:h=(h=x.documentElement)&&(h=h.namespaceURI)?QB(h):0;break;default:if(h=x.tagName,x=x.namespaceURI)x=QB(x),h=ZB(x,h);else switch(h){case\"svg\":h=1;break;case\"math\":h=2;break;default:h=0}}ne(be),me(be,h)}function j(){ne(be),ne(Ce),ne(q)}function O(h){h.memoizedState!==null&&me(Y,h);var x=be.current,S=ZB(x,h.type);x!==S&&(me(Ce,h),me(be,S))}function K(h){Ce.current===h&&(ne(be),ne(Ce)),Y.current===h&&(ne(Y),kv._currentValue=oe)}var U,de;function I(h){if(U===void 0)try{throw Error()}catch(S){var x=S.stack.trim().match(/\\n( *(at )?)/);U=x&&x[1]||\"\",de=-1<S.stack.indexOf(`\n    at`)?\" (<anonymous>)\":-1<S.stack.indexOf(\"@\")?\"@unknown:0:0\":\"\"}return`\n`+U+h+de}var G=!1;function X(h,x){if(!h||G)return\"\";G=!0;var S=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var M={DetermineComponentFrameRoot:function(){try{if(x){var zt=function(){throw Error()};if(Object.defineProperty(zt.prototype,\"props\",{set:function(){throw Error()}}),typeof Reflect==\"object\"&&Reflect.construct){try{Reflect.construct(zt,[])}catch(Dt){var Nt=Dt}Reflect.construct(h,[],zt)}else{try{zt.call()}catch(Dt){Nt=Dt}h.call(zt.prototype)}}else{try{throw Error()}catch(Dt){Nt=Dt}(zt=h())&&typeof zt.catch==\"function\"&&zt.catch(function(){})}}catch(Dt){if(Dt&&Nt&&typeof Dt.stack==\"string\")return[Dt.stack,Nt.stack]}return[null,null]}};M.DetermineComponentFrameRoot.displayName=\"DetermineComponentFrameRoot\";var $=Object.getOwnPropertyDescriptor(M.DetermineComponentFrameRoot,\"name\");$&&$.configurable&&Object.defineProperty(M.DetermineComponentFrameRoot,\"name\",{value:\"DetermineComponentFrameRoot\"});var Z=M.DetermineComponentFrameRoot(),Ne=Z[0],He=Z[1];if(Ne&&He){var rt=Ne.split(`\n`),kt=He.split(`\n`);for($=M=0;M<rt.length&&!rt[M].includes(\"DetermineComponentFrameRoot\");)M++;for(;$<kt.length&&!kt[$].includes(\"DetermineComponentFrameRoot\");)$++;if(M===rt.length||$===kt.length)for(M=rt.length-1,$=kt.length-1;1<=M&&0<=$&&rt[M]!==kt[$];)$--;for(;1<=M&&0<=$;M--,$--)if(rt[M]!==kt[$]){if(M!==1||$!==1)do if(M--,$--,0>$||rt[M]!==kt[$]){var Ot=`\n`+rt[M].replace(\" at new \",\" at \");return h.displayName&&Ot.includes(\"<anonymous>\")&&(Ot=Ot.replace(\"<anonymous>\",h.displayName)),Ot}while(1<=M&&0<=$);break}}}finally{G=!1,Error.prepareStackTrace=S}return(S=h?h.displayName||h.name:\"\")?I(S):\"\"}function V(h,x){switch(h.tag){case 26:case 27:case 5:return I(h.type);case 16:return I(\"Lazy\");case 13:return h.child!==x&&x!==null?I(\"Suspense Fallback\"):I(\"Suspense\");case 19:return I(\"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 I(\"Activity\");default:return\"\"}}function ee(h){try{var x=\"\",S=null;do x+=V(h,S),S=h,h=h.return;while(h);return x}catch(M){return`\nError generating stack: `+M.message+`\n`+M.stack}}var se=Object.prototype.hasOwnProperty,ge=t.unstable_scheduleCallback,he=t.unstable_cancelCallback,le=t.unstable_shouldYield,B=t.unstable_requestPaint,R=t.unstable_now,ae=t.unstable_getCurrentPriorityLevel,_e=t.unstable_ImmediatePriority,Se=t.unstable_UserBlockingPriority,ve=t.unstable_NormalPriority,Te=t.unstable_LowPriority,ye=t.unstable_IdlePriority,je=t.log,Le=t.unstable_setDisableYieldValue,Me=null,Oe=null;function Re(h){if(typeof je==\"function\"&&Le(h),Oe&&typeof Oe.setStrictMode==\"function\")try{Oe.setStrictMode(Me,h)}catch{}}var $e=Math.clz32?Math.clz32:pe,Ye=Math.log,tt=Math.LN2;function pe(h){return h>>>=0,h===0?32:31-(Ye(h)/tt|0)|0}var Fe=256,we=262144,Ve=4194304;function Ae(h){var x=h&42;if(x!==0)return x;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 ce(h,x,S){var M=h.pendingLanes;if(M===0)return 0;var $=0,Z=h.suspendedLanes,Ne=h.pingedLanes;h=h.warmLanes;var He=M&134217727;return He!==0?(M=He&~Z,M!==0?$=Ae(M):(Ne&=He,Ne!==0?$=Ae(Ne):S||(S=He&~h,S!==0&&($=Ae(S))))):(He=M&~Z,He!==0?$=Ae(He):Ne!==0?$=Ae(Ne):S||(S=M&~h,S!==0&&($=Ae(S)))),$===0?0:x!==0&&x!==$&&(x&Z)===0&&(Z=$&-$,S=x&-x,Z>=S||Z===32&&(S&4194048)!==0)?x:$}function xe(h,x){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&x)===0}function Be(h,x){switch(h){case 1:case 2:case 4:case 8:case 64:return x+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 x+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 Qe(){var h=Ve;return Ve<<=1,(Ve&62914560)===0&&(Ve=4194304),h}function ht(h){for(var x=[],S=0;31>S;S++)x.push(h);return x}function xt(h,x){h.pendingLanes|=x,x!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function gt(h,x,S,M,$,Z){var Ne=h.pendingLanes;h.pendingLanes=S,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=S,h.entangledLanes&=S,h.errorRecoveryDisabledLanes&=S,h.shellSuspendCounter=0;var He=h.entanglements,rt=h.expirationTimes,kt=h.hiddenUpdates;for(S=Ne&~S;0<S;){var Ot=31-$e(S),zt=1<<Ot;He[Ot]=0,rt[Ot]=-1;var Nt=kt[Ot];if(Nt!==null)for(kt[Ot]=null,Ot=0;Ot<Nt.length;Ot++){var Dt=Nt[Ot];Dt!==null&&(Dt.lane&=-536870913)}S&=~zt}M!==0&&Ut(h,M,0),Z!==0&&$===0&&h.tag!==0&&(h.suspendedLanes|=Z&~(Ne&~x))}function Ut(h,x,S){h.pendingLanes|=x,h.suspendedLanes&=~x;var M=31-$e(x);h.entangledLanes|=x,h.entanglements[M]=h.entanglements[M]|1073741824|S&261930}function Wt(h,x){var S=h.entangledLanes|=x;for(h=h.entanglements;S;){var M=31-$e(S),$=1<<M;$&x|h[M]&x&&(h[M]|=x),S&=~$}}function Zt(h,x){var S=x&-x;return S=(S&42)!==0?1:Kt(S),(S&(h.suspendedLanes|x))!==0?0:S}function Kt(h){switch(h){case 2:h=1;break;case 8:h=4;break;case 32:h=16;break;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:case 4194304:case 8388608:case 16777216:case 33554432:h=128;break;case 268435456:h=134217728;break;default:h=0}return h}function Xt(h){return h&=-h,2<h?8<h?(h&134217727)!==0?32:268435456:8:2}function ln(){var h=J.p;return h!==0?h:(h=window.event,h===void 0?32:w8(h.type))}function vn(h,x){var S=J.p;try{return J.p=h,x()}finally{J.p=S}}var Ke=Math.random().toString(36).slice(2),at=\"__reactFiber$\"+Ke,Lt=\"__reactProps$\"+Ke,Et=\"__reactContainer$\"+Ke,At=\"__reactEvents$\"+Ke,Ie=\"__reactListeners$\"+Ke,mt=\"__reactHandles$\"+Ke,pt=\"__reactResources$\"+Ke,nt=\"__reactMarker$\"+Ke;function ze(h){delete h[at],delete h[Lt],delete h[At],delete h[Ie],delete h[mt]}function ot(h){var x=h[at];if(x)return x;for(var S=h.parentNode;S;){if(x=S[Et]||S[at]){if(S=x.alternate,x.child!==null||S!==null&&S.child!==null)for(h=i8(h);h!==null;){if(S=h[at])return S;h=i8(h)}return x}h=S,S=h.parentNode}return null}function Pe(h){if(h=h[at]||h[Et]){var x=h.tag;if(x===5||x===6||x===13||x===31||x===26||x===27||x===3)return h}return null}function Ge(h){var x=h.tag;if(x===5||x===26||x===27||x===6)return h.stateNode;throw Error(r(33))}function Ze(h){var x=h[pt];return x||(x=h[pt]={hoistableStyles:new Map,hoistableScripts:new Map}),x}function Je(h){h[nt]=!0}var We=new Set,Ue={};function et(h,x){jt(h,x),jt(h+\"Capture\",x)}function jt(h,x){for(Ue[h]=x,h=0;h<x.length;h++)We.add(x[h])}var yt=RegExp(\"^[:A-Z_a-z\\\\u00C0-\\\\u00D6\\\\u00D8-\\\\u00F6\\\\u00F8-\\\\u02FF\\\\u0370-\\\\u037D\\\\u037F-\\\\u1FFF\\\\u200C-\\\\u200D\\\\u2070-\\\\u218F\\\\u2C00-\\\\u2FEF\\\\u3001-\\\\uD7FF\\\\uF900-\\\\uFDCF\\\\uFDF0-\\\\uFFFD][:A-Z_a-z\\\\u00C0-\\\\u00D6\\\\u00D8-\\\\u00F6\\\\u00F8-\\\\u02FF\\\\u0370-\\\\u037D\\\\u037F-\\\\u1FFF\\\\u200C-\\\\u200D\\\\u2070-\\\\u218F\\\\u2C00-\\\\u2FEF\\\\u3001-\\\\uD7FF\\\\uF900-\\\\uFDCF\\\\uFDF0-\\\\uFFFD\\\\-.0-9\\\\u00B7\\\\u0300-\\\\u036F\\\\u203F-\\\\u2040]*$\"),qe={},St={};function Pt(h){return se.call(St,h)?!0:se.call(qe,h)?!1:yt.test(h)?St[h]=!0:(qe[h]=!0,!1)}function qt(h,x,S){if(Pt(x))if(S===null)h.removeAttribute(x);else{switch(typeof S){case\"undefined\":case\"function\":case\"symbol\":h.removeAttribute(x);return;case\"boolean\":var M=x.toLowerCase().slice(0,5);if(M!==\"data-\"&&M!==\"aria-\"){h.removeAttribute(x);return}}h.setAttribute(x,\"\"+S)}}function on(h,x,S){if(S===null)h.removeAttribute(x);else{switch(typeof S){case\"undefined\":case\"function\":case\"symbol\":case\"boolean\":h.removeAttribute(x);return}h.setAttribute(x,\"\"+S)}}function dn(h,x,S,M){if(M===null)h.removeAttribute(S);else{switch(typeof M){case\"undefined\":case\"function\":case\"symbol\":case\"boolean\":h.removeAttribute(S);return}h.setAttributeNS(x,S,\"\"+M)}}function Nn(h){switch(typeof h){case\"bigint\":case\"boolean\":case\"number\":case\"string\":case\"undefined\":return h;case\"object\":return h;default:return\"\"}}function bn(h){var x=h.type;return(h=h.nodeName)&&h.toLowerCase()===\"input\"&&(x===\"checkbox\"||x===\"radio\")}function un(h,x,S){var M=Object.getOwnPropertyDescriptor(h.constructor.prototype,x);if(!h.hasOwnProperty(x)&&typeof M<\"u\"&&typeof M.get==\"function\"&&typeof M.set==\"function\"){var $=M.get,Z=M.set;return Object.defineProperty(h,x,{configurable:!0,get:function(){return $.call(this)},set:function(Ne){S=\"\"+Ne,Z.call(this,Ne)}}),Object.defineProperty(h,x,{enumerable:M.enumerable}),{getValue:function(){return S},setValue:function(Ne){S=\"\"+Ne},stopTracking:function(){h._valueTracker=null,delete h[x]}}}}function wn(h){if(!h._valueTracker){var x=bn(h)?\"checked\":\"value\";h._valueTracker=un(h,x,\"\"+h[x])}}function pn(h){if(!h)return!1;var x=h._valueTracker;if(!x)return!0;var S=x.getValue(),M=\"\";return h&&(M=bn(h)?h.checked?\"true\":\"false\":h.value),h=M,h!==S?(x.setValue(h),!0):!1}function gr(h){if(h=h||(typeof document<\"u\"?document:void 0),typeof h>\"u\")return null;try{return h.activeElement||h.body}catch{return h.body}}var Nr=/[\\n\"\\\\]/g;function Fn(h){return h.replace(Nr,function(x){return\"\\\\\"+x.charCodeAt(0).toString(16)+\" \"})}function Ba(h,x,S,M,$,Z,Ne,He){h.name=\"\",Ne!=null&&typeof Ne!=\"function\"&&typeof Ne!=\"symbol\"&&typeof Ne!=\"boolean\"?h.type=Ne:h.removeAttribute(\"type\"),x!=null?Ne===\"number\"?(x===0&&h.value===\"\"||h.value!=x)&&(h.value=\"\"+Nn(x)):h.value!==\"\"+Nn(x)&&(h.value=\"\"+Nn(x)):Ne!==\"submit\"&&Ne!==\"reset\"||h.removeAttribute(\"value\"),x!=null?ma(h,Ne,Nn(x)):S!=null?ma(h,Ne,Nn(S)):M!=null&&h.removeAttribute(\"value\"),$==null&&Z!=null&&(h.defaultChecked=!!Z),$!=null&&(h.checked=$&&typeof $!=\"function\"&&typeof $!=\"symbol\"),He!=null&&typeof He!=\"function\"&&typeof He!=\"symbol\"&&typeof He!=\"boolean\"?h.name=\"\"+Nn(He):h.removeAttribute(\"name\")}function In(h,x,S,M,$,Z,Ne,He){if(Z!=null&&typeof Z!=\"function\"&&typeof Z!=\"symbol\"&&typeof Z!=\"boolean\"&&(h.type=Z),x!=null||S!=null){if(!(Z!==\"submit\"&&Z!==\"reset\"||x!=null)){wn(h);return}S=S!=null?\"\"+Nn(S):\"\",x=x!=null?\"\"+Nn(x):S,He||x===h.value||(h.value=x),h.defaultValue=x}M=M??$,M=typeof M!=\"function\"&&typeof M!=\"symbol\"&&!!M,h.checked=He?h.checked:!!M,h.defaultChecked=!!M,Ne!=null&&typeof Ne!=\"function\"&&typeof Ne!=\"symbol\"&&typeof Ne!=\"boolean\"&&(h.name=Ne),wn(h)}function ma(h,x,S){x===\"number\"&&gr(h.ownerDocument)===h||h.defaultValue===\"\"+S||(h.defaultValue=\"\"+S)}function ra(h,x,S,M){if(h=h.options,x){x={};for(var $=0;$<S.length;$++)x[\"$\"+S[$]]=!0;for(S=0;S<h.length;S++)$=x.hasOwnProperty(\"$\"+h[S].value),h[S].selected!==$&&(h[S].selected=$),$&&M&&(h[S].defaultSelected=!0)}else{for(S=\"\"+Nn(S),x=null,$=0;$<h.length;$++){if(h[$].value===S){h[$].selected=!0,M&&(h[$].defaultSelected=!0);return}x!==null||h[$].disabled||(x=h[$])}x!==null&&(x.selected=!0)}}function Fr(h,x,S){if(x!=null&&(x=\"\"+Nn(x),x!==h.value&&(h.value=x),S==null)){h.defaultValue!==x&&(h.defaultValue=x);return}h.defaultValue=S!=null?\"\"+Nn(S):\"\"}function Br(h,x,S,M){if(x==null){if(M!=null){if(S!=null)throw Error(r(92));if(te(M)){if(1<M.length)throw Error(r(93));M=M[0]}S=M}S==null&&(S=\"\"),x=S}S=Nn(x),h.defaultValue=S,M=h.textContent,M===S&&M!==\"\"&&M!==null&&(h.value=M),wn(h)}function cr(h,x){if(x){var S=h.firstChild;if(S&&S===h.lastChild&&S.nodeType===3){S.nodeValue=x;return}}h.textContent=x}var Ci=new Set(\"animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth MozAnimationIterationCount MozBoxFlex MozBoxFlexGroup MozLineClamp msAnimationIterationCount msFlex msZoom msFlexGrow msFlexNegative msFlexOrder msFlexPositive msFlexShrink msGridColumn msGridColumnSpan msGridRow msGridRowSpan WebkitAnimationIterationCount WebkitBoxFlex WebKitBoxFlexGroup WebkitBoxOrdinalGroup WebkitColumnCount WebkitColumns WebkitFlex WebkitFlexGrow WebkitFlexPositive WebkitFlexShrink WebkitLineClamp\".split(\" \"));function Ui(h,x,S){var M=x.indexOf(\"--\")===0;S==null||typeof S==\"boolean\"||S===\"\"?M?h.setProperty(x,\"\"):x===\"float\"?h.cssFloat=\"\":h[x]=\"\":M?h.setProperty(x,S):typeof S!=\"number\"||S===0||Ci.has(x)?x===\"float\"?h.cssFloat=S:h[x]=(\"\"+S).trim():h[x]=S+\"px\"}function vc(h,x,S){if(x!=null&&typeof x!=\"object\")throw Error(r(62));if(h=h.style,S!=null){for(var M in S)!S.hasOwnProperty(M)||x!=null&&x.hasOwnProperty(M)||(M.indexOf(\"--\")===0?h.setProperty(M,\"\"):M===\"float\"?h.cssFloat=\"\":h[M]=\"\");for(var $ in x)M=x[$],x.hasOwnProperty($)&&S[$]!==M&&Ui(h,$,M)}else for(var Z in x)x.hasOwnProperty(Z)&&Ui(h,Z,x[Z])}function _l(h){if(h.indexOf(\"-\")===-1)return!1;switch(h){case\"annotation-xml\":case\"color-profile\":case\"font-face\":case\"font-face-src\":case\"font-face-uri\":case\"font-face-format\":case\"font-face-name\":case\"missing-glyph\":return!1;default:return!0}}var vt=new Map([[\"acceptCharset\",\"accept-charset\"],[\"htmlFor\",\"for\"],[\"httpEquiv\",\"http-equiv\"],[\"crossOrigin\",\"crossorigin\"],[\"accentHeight\",\"accent-height\"],[\"alignmentBaseline\",\"alignment-baseline\"],[\"arabicForm\",\"arabic-form\"],[\"baselineShift\",\"baseline-shift\"],[\"capHeight\",\"cap-height\"],[\"clipPath\",\"clip-path\"],[\"clipRule\",\"clip-rule\"],[\"colorInterpolation\",\"color-interpolation\"],[\"colorInterpolationFilters\",\"color-interpolation-filters\"],[\"colorProfile\",\"color-profile\"],[\"colorRendering\",\"color-rendering\"],[\"dominantBaseline\",\"dominant-baseline\"],[\"enableBackground\",\"enable-background\"],[\"fillOpacity\",\"fill-opacity\"],[\"fillRule\",\"fill-rule\"],[\"floodColor\",\"flood-color\"],[\"floodOpacity\",\"flood-opacity\"],[\"fontFamily\",\"font-family\"],[\"fontSize\",\"font-size\"],[\"fontSizeAdjust\",\"font-size-adjust\"],[\"fontStretch\",\"font-stretch\"],[\"fontStyle\",\"font-style\"],[\"fontVariant\",\"font-variant\"],[\"fontWeight\",\"font-weight\"],[\"glyphName\",\"glyph-name\"],[\"glyphOrientationHorizontal\",\"glyph-orientation-horizontal\"],[\"glyphOrientationVertical\",\"glyph-orientation-vertical\"],[\"horizAdvX\",\"horiz-adv-x\"],[\"horizOriginX\",\"horiz-origin-x\"],[\"imageRendering\",\"image-rendering\"],[\"letterSpacing\",\"letter-spacing\"],[\"lightingColor\",\"lighting-color\"],[\"markerEnd\",\"marker-end\"],[\"markerMid\",\"marker-mid\"],[\"markerStart\",\"marker-start\"],[\"overlinePosition\",\"overline-position\"],[\"overlineThickness\",\"overline-thickness\"],[\"paintOrder\",\"paint-order\"],[\"panose-1\",\"panose-1\"],[\"pointerEvents\",\"pointer-events\"],[\"renderingIntent\",\"rendering-intent\"],[\"shapeRendering\",\"shape-rendering\"],[\"stopColor\",\"stop-color\"],[\"stopOpacity\",\"stop-opacity\"],[\"strikethroughPosition\",\"strikethrough-position\"],[\"strikethroughThickness\",\"strikethrough-thickness\"],[\"strokeDasharray\",\"stroke-dasharray\"],[\"strokeDashoffset\",\"stroke-dashoffset\"],[\"strokeLinecap\",\"stroke-linecap\"],[\"strokeLinejoin\",\"stroke-linejoin\"],[\"strokeMiterlimit\",\"stroke-miterlimit\"],[\"strokeOpacity\",\"stroke-opacity\"],[\"strokeWidth\",\"stroke-width\"],[\"textAnchor\",\"text-anchor\"],[\"textDecoration\",\"text-decoration\"],[\"textRendering\",\"text-rendering\"],[\"transformOrigin\",\"transform-origin\"],[\"underlinePosition\",\"underline-position\"],[\"underlineThickness\",\"underline-thickness\"],[\"unicodeBidi\",\"unicode-bidi\"],[\"unicodeRange\",\"unicode-range\"],[\"unitsPerEm\",\"units-per-em\"],[\"vAlphabetic\",\"v-alphabetic\"],[\"vHanging\",\"v-hanging\"],[\"vIdeographic\",\"v-ideographic\"],[\"vMathematical\",\"v-mathematical\"],[\"vectorEffect\",\"vector-effect\"],[\"vertAdvY\",\"vert-adv-y\"],[\"vertOriginX\",\"vert-origin-x\"],[\"vertOriginY\",\"vert-origin-y\"],[\"wordSpacing\",\"word-spacing\"],[\"writingMode\",\"writing-mode\"],[\"xmlnsXlink\",\"xmlns:xlink\"],[\"xHeight\",\"x-height\"]]),Kn=/^[\\u0000-\\u001F ]*j[\\r\\n\\t]*a[\\r\\n\\t]*v[\\r\\n\\t]*a[\\r\\n\\t]*s[\\r\\n\\t]*c[\\r\\n\\t]*r[\\r\\n\\t]*i[\\r\\n\\t]*p[\\r\\n\\t]*t[\\r\\n\\t]*:/i;function dr(h){return Kn.test(\"\"+h)?\"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')\":h}function $t(){}var Ks=null;function rs(h){return h=h.target||h.srcElement||window,h.correspondingUseElement&&(h=h.correspondingUseElement),h.nodeType===3?h.parentNode:h}var wc=null,Sc=null;function Ha(h){var x=Pe(h);if(x&&(h=x.stateNode)){var S=h[Lt]||null;e:switch(h=x.stateNode,x.type){case\"input\":if(Ba(h,S.value,S.defaultValue,S.defaultValue,S.checked,S.defaultChecked,S.type,S.name),x=S.name,S.type===\"radio\"&&x!=null){for(S=h;S.parentNode;)S=S.parentNode;for(S=S.querySelectorAll('input[name=\"'+Fn(\"\"+x)+'\"][type=\"radio\"]'),x=0;x<S.length;x++){var M=S[x];if(M!==h&&M.form===h.form){var $=M[Lt]||null;if(!$)throw Error(r(90));Ba(M,$.value,$.defaultValue,$.defaultValue,$.checked,$.defaultChecked,$.type,$.name)}}for(x=0;x<S.length;x++)M=S[x],M.form===h.form&&pn(M)}break e;case\"textarea\":Fr(h,S.value,S.defaultValue);break e;case\"select\":x=S.value,x!=null&&ra(h,!!S.multiple,x,!1)}}}var id=!1;function Ip(h,x,S){if(id)return h(x,S);id=!0;try{var M=h(x);return M}finally{if(id=!1,(wc!==null||Sc!==null)&&(N_(),wc&&(x=wc,h=Sc,Sc=wc=null,Ha(x),h)))for(x=0;x<h.length;x++)Ha(h[x])}}function tu(h,x){var S=h.stateNode;if(S===null)return null;var M=S[Lt]||null;if(M===null)return null;S=M[x];e:switch(x){case\"onClick\":case\"onClickCapture\":case\"onDoubleClick\":case\"onDoubleClickCapture\":case\"onMouseDown\":case\"onMouseDownCapture\":case\"onMouseMove\":case\"onMouseMoveCapture\":case\"onMouseUp\":case\"onMouseUpCapture\":case\"onMouseEnter\":(M=!M.disabled)||(h=h.type,M=!(h===\"button\"||h===\"input\"||h===\"select\"||h===\"textarea\")),h=!M;break e;default:h=!1}if(h)return null;if(S&&typeof S!=\"function\")throw Error(r(231,x,typeof S));return S}var yo=!(typeof window>\"u\"||typeof window.document>\"u\"||typeof window.document.createElement>\"u\"),nu=!1;if(yo)try{var Ps={};Object.defineProperty(Ps,\"passive\",{get:function(){nu=!0}}),window.addEventListener(\"test\",Ps,Ps),window.removeEventListener(\"test\",Ps,Ps)}catch{nu=!1}var vo=null,Ts=null,Wo=null;function wo(){if(Wo)return Wo;var h,x=Ts,S=x.length,M,$=\"value\"in vo?vo.value:vo.textContent,Z=$.length;for(h=0;h<S&&x[h]===$[h];h++);var Ne=S-h;for(M=1;M<=Ne&&x[S-M]===$[Z-M];M++);return Wo=$.slice(h,1<M?1-M:void 0)}function _c(h){var x=h.keyCode;return\"charCode\"in h?(h=h.charCode,h===0&&x===13&&(h=13)):h=x,h===10&&(h=13),32<=h||h===13?h:0}function ru(){return!0}function zp(){return!1}function di(h){function x(S,M,$,Z,Ne){this._reactName=S,this._targetInst=$,this.type=M,this.nativeEvent=Z,this.target=Ne,this.currentTarget=null;for(var He in h)h.hasOwnProperty(He)&&(S=h[He],this[He]=S?S(Z):Z[He]);return this.isDefaultPrevented=(Z.defaultPrevented!=null?Z.defaultPrevented:Z.returnValue===!1)?ru:zp,this.isPropagationStopped=zp,this}return m(x.prototype,{preventDefault:function(){this.defaultPrevented=!0;var S=this.nativeEvent;S&&(S.preventDefault?S.preventDefault():typeof S.returnValue!=\"unknown\"&&(S.returnValue=!1),this.isDefaultPrevented=ru)},stopPropagation:function(){var S=this.nativeEvent;S&&(S.stopPropagation?S.stopPropagation():typeof S.cancelBubble!=\"unknown\"&&(S.cancelBubble=!0),this.isPropagationStopped=ru)},persist:function(){},isPersistent:ru}),x}var As={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(h){return h.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},au=di(As),sd=m({},As,{view:0,detail:0}),Up=di(sd),Ko,iu,So,Xo=m({},sd,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Cc,button:0,buttons:0,relatedTarget:function(h){return h.relatedTarget===void 0?h.fromElement===h.srcElement?h.toElement:h.fromElement:h.relatedTarget},movementX:function(h){return\"movementX\"in h?h.movementX:(h!==So&&(So&&h.type===\"mousemove\"?(Ko=h.screenX-So.screenX,iu=h.screenY-So.screenY):iu=Ko=0,So=h),Ko)},movementY:function(h){return\"movementY\"in h?h.movementY:iu}}),kl=di(Xo),Yo=m({},Xo,{dataTransfer:0}),Nl=di(Yo),Lm=m({},sd,{relatedTarget:0}),as=di(Lm),Om=m({},As,{animationName:0,elapsedTime:0,pseudoElement:0}),xn=di(Om),kc=m({},As,{clipboardData:function(h){return\"clipboardData\"in h?h.clipboardData:window.clipboardData}}),Bp=di(kc),od=m({},As,{data:0}),Nc=di(od),su={Esc:\"Escape\",Spacebar:\" \",Left:\"ArrowLeft\",Up:\"ArrowUp\",Right:\"ArrowRight\",Down:\"ArrowDown\",Del:\"Delete\",Win:\"OS\",Menu:\"ContextMenu\",Apps:\"ContextMenu\",Scroll:\"ScrollLock\",MozPrintableKey:\"Unidentified\"},qy={8:\"Backspace\",9:\"Tab\",12:\"Clear\",13:\"Enter\",16:\"Shift\",17:\"Control\",18:\"Alt\",19:\"Pause\",20:\"CapsLock\",27:\"Escape\",32:\" \",33:\"PageUp\",34:\"PageDown\",35:\"End\",36:\"Home\",37:\"ArrowLeft\",38:\"ArrowUp\",39:\"ArrowRight\",40:\"ArrowDown\",45:\"Insert\",46:\"Delete\",112:\"F1\",113:\"F2\",114:\"F3\",115:\"F4\",116:\"F5\",117:\"F6\",118:\"F7\",119:\"F8\",120:\"F9\",121:\"F10\",122:\"F11\",123:\"F12\",144:\"NumLock\",145:\"ScrollLock\",224:\"Meta\"},Hp={Alt:\"altKey\",Control:\"ctrlKey\",Meta:\"metaKey\",Shift:\"shiftKey\"};function ld(h){var x=this.nativeEvent;return x.getModifierState?x.getModifierState(h):(h=Hp[h])?!!x[h]:!1}function Cc(){return ld}var Im=m({},sd,{key:function(h){if(h.key){var x=su[h.key]||h.key;if(x!==\"Unidentified\")return x}return h.type===\"keypress\"?(h=_c(h),h===13?\"Enter\":String.fromCharCode(h)):h.type===\"keydown\"||h.type===\"keyup\"?qy[h.keyCode]||\"Unidentified\":\"\"},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,repeat:0,locale:0,getModifierState:Cc,charCode:function(h){return h.type===\"keypress\"?_c(h):0},keyCode:function(h){return h.type===\"keydown\"||h.type===\"keyup\"?h.keyCode:0},which:function(h){return h.type===\"keypress\"?_c(h):h.type===\"keydown\"||h.type===\"keyup\"?h.keyCode:0}}),$y=di(Im),ke=m({},Xo,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),Rt=di(ke),Sn=m({},sd,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,ctrlKey:0,shiftKey:0,getModifierState:Cc}),_n=di(Sn),Bn=m({},As,{propertyName:0,elapsedTime:0,pseudoElement:0}),xa=di(Bn),ui=m({},Xo,{deltaX:function(h){return\"deltaX\"in h?h.deltaX:\"wheelDeltaX\"in h?-h.wheelDeltaX:0},deltaY:function(h){return\"deltaY\"in h?h.deltaY:\"wheelDeltaY\"in h?-h.wheelDeltaY:\"wheelDelta\"in h?-h.wheelDelta:0},deltaZ:0,deltaMode:0}),Cr=di(ui),zm=m({},As,{newState:0,oldState:0}),qp=di(zm),_A=[9,13,27,32],Vy=yo&&\"CompositionEvent\"in window,lt=null;yo&&\"documentMode\"in document&&(lt=document.documentMode);var Bt=yo&&\"TextEvent\"in window&&!lt,Jt=yo&&(!Vy||lt&&8<lt&&11>=lt),ct=\" \",Un=!1;function Tr(h,x){switch(h){case\"keyup\":return _A.indexOf(x.keyCode)!==-1;case\"keydown\":return x.keyCode!==229;case\"keypress\":case\"mousedown\":case\"focusout\":return!0;default:return!1}}function ar(h){return h=h.detail,typeof h==\"object\"&&\"data\"in h?h.data:null}var Rr=!1;function Da(h,x){switch(h){case\"compositionend\":return ar(x);case\"keypress\":return x.which!==32?null:(Un=!0,ct);case\"textInput\":return h=x.data,h===ct&&Un?null:h;default:return null}}function rn(h,x){if(Rr)return h===\"compositionend\"||!Vy&&Tr(h,x)?(h=wo(),Wo=Ts=vo=null,Rr=!1,h):null;switch(h){case\"paste\":return null;case\"keypress\":if(!(x.ctrlKey||x.altKey||x.metaKey)||x.ctrlKey&&x.altKey){if(x.char&&1<x.char.length)return x.char;if(x.which)return String.fromCharCode(x.which)}return null;case\"compositionend\":return Jt&&x.locale!==\"ko\"?null:x.data;default:return null}}var En={color:!0,date:!0,datetime:!0,\"datetime-local\":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function Na(h){var x=h&&h.nodeName&&h.nodeName.toLowerCase();return x===\"input\"?!!En[h.type]:x===\"textarea\"}function ai(h,x,S,M){wc?Sc?Sc.push(M):Sc=[M]:wc=M,x=E_(x,\"onChange\"),0<x.length&&(S=new au(\"onChange\",\"change\",null,S,M),h.push({event:S,listeners:x}))}var mi=null,Xs=null;function Cl(h){VB(h,0)}function ha(h){var x=Ge(h);if(pn(x))return h}function Bi(h,x){if(h===\"change\")return x}var Pl=!1;if(yo){var Hi;if(yo){var Ys=\"oninput\"in document;if(!Ys){var Pc=document.createElement(\"div\");Pc.setAttribute(\"oninput\",\"return;\"),Ys=typeof Pc.oninput==\"function\"}Hi=Ys}else Hi=!1;Pl=Hi&&(!document.documentMode||9<document.documentMode)}function pa(){mi&&(mi.detachEvent(\"onpropertychange\",_o),Xs=mi=null)}function _o(h){if(h.propertyName===\"value\"&&ha(Xs)){var x=[];ai(x,Xs,h,rs(h)),Ip(Cl,x)}}function Gy(h,x,S){h===\"focusin\"?(pa(),mi=x,Xs=S,mi.attachEvent(\"onpropertychange\",_o)):h===\"focusout\"&&pa()}function ya(h){if(h===\"selectionchange\"||h===\"keyup\"||h===\"keydown\")return ha(Xs)}function ou(h,x){if(h===\"click\")return ha(x)}function $p(h,x){if(h===\"input\"||h===\"change\")return ha(x)}function zn(h,x){return h===x&&(h!==0||1/h===1/x)||h!==h&&x!==x}var ko=typeof Object.is==\"function\"?Object.is:zn;function Um(h,x){if(ko(h,x))return!0;if(typeof h!=\"object\"||h===null||typeof x!=\"object\"||x===null)return!1;var S=Object.keys(h),M=Object.keys(x);if(S.length!==M.length)return!1;for(M=0;M<S.length;M++){var $=S[M];if(!se.call(x,$)||!ko(h[$],x[$]))return!1}return!0}function Vp(h){for(;h&&h.firstChild;)h=h.firstChild;return h}function Wy(h,x){var S=Vp(h);h=0;for(var M;S;){if(S.nodeType===3){if(M=h+S.textContent.length,h<=x&&M>=x)return{node:S,offset:x-h};h=M}e:{for(;S;){if(S.nextSibling){S=S.nextSibling;break e}S=S.parentNode}S=void 0}S=Vp(S)}}function Ky(h,x){return h&&x?h===x?!0:h&&h.nodeType===3?!1:x&&x.nodeType===3?Ky(h,x.parentNode):\"contains\"in h?h.contains(x):h.compareDocumentPosition?!!(h.compareDocumentPosition(x)&16):!1:!1}function $S(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var x=gr(h.document);x instanceof h.HTMLIFrameElement;){try{var S=typeof x.contentWindow.location.href==\"string\"}catch{S=!1}if(S)h=x.contentWindow;else break;x=gr(h.document)}return x}function kA(h){var x=h&&h.nodeName&&h.nodeName.toLowerCase();return x&&(x===\"input\"&&(h.type===\"text\"||h.type===\"search\"||h.type===\"tel\"||h.type===\"url\"||h.type===\"password\")||x===\"textarea\"||h.contentEditable===\"true\")}var hce=yo&&\"documentMode\"in document&&11>=document.documentMode,Gg=null,NA=null,Xy=null,CA=!1;function y5(h,x,S){var M=S.window===S?S.document:S.nodeType===9?S:S.ownerDocument;CA||Gg==null||Gg!==gr(M)||(M=Gg,\"selectionStart\"in M&&kA(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}),Xy&&Um(Xy,M)||(Xy=M,M=E_(NA,\"onSelect\"),0<M.length&&(x=new au(\"onSelect\",\"select\",null,x,S),h.push({event:x,listeners:M}),x.target=Gg)))}function Gp(h,x){var S={};return S[h.toLowerCase()]=x.toLowerCase(),S[\"Webkit\"+h]=\"webkit\"+x,S[\"Moz\"+h]=\"moz\"+x,S}var Wg={animationend:Gp(\"Animation\",\"AnimationEnd\"),animationiteration:Gp(\"Animation\",\"AnimationIteration\"),animationstart:Gp(\"Animation\",\"AnimationStart\"),transitionrun:Gp(\"Transition\",\"TransitionRun\"),transitionstart:Gp(\"Transition\",\"TransitionStart\"),transitioncancel:Gp(\"Transition\",\"TransitionCancel\"),transitionend:Gp(\"Transition\",\"TransitionEnd\")},PA={},v5={};yo&&(v5=document.createElement(\"div\").style,\"AnimationEvent\"in window||(delete Wg.animationend.animation,delete Wg.animationiteration.animation,delete Wg.animationstart.animation),\"TransitionEvent\"in window||delete Wg.transitionend.transition);function Wp(h){if(PA[h])return PA[h];if(!Wg[h])return h;var x=Wg[h],S;for(S in x)if(x.hasOwnProperty(S)&&S in v5)return PA[h]=x[S];return h}var w5=Wp(\"animationend\"),S5=Wp(\"animationiteration\"),_5=Wp(\"animationstart\"),pce=Wp(\"transitionrun\"),fce=Wp(\"transitionstart\"),gce=Wp(\"transitioncancel\"),k5=Wp(\"transitionend\"),N5=new Map,TA=\"abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel\".split(\" \");TA.push(\"scrollEnd\");function Tc(h,x){N5.set(h,x),et(x,[h])}var VS=typeof reportError==\"function\"?reportError:function(h){if(typeof window==\"object\"&&typeof window.ErrorEvent==\"function\"){var x=new window.ErrorEvent(\"error\",{bubbles:!0,cancelable:!0,message:typeof h==\"object\"&&h!==null&&typeof h.message==\"string\"?String(h.message):String(h),error:h});if(!window.dispatchEvent(x))return}else if(typeof process==\"object\"&&typeof process.emit==\"function\"){process.emit(\"uncaughtException\",h);return}console.error(h)},Tl=[],Kg=0,AA=0;function GS(){for(var h=Kg,x=AA=Kg=0;x<h;){var S=Tl[x];Tl[x++]=null;var M=Tl[x];Tl[x++]=null;var $=Tl[x];Tl[x++]=null;var Z=Tl[x];if(Tl[x++]=null,M!==null&&$!==null){var Ne=M.pending;Ne===null?$.next=$:($.next=Ne.next,Ne.next=$),M.pending=$}Z!==0&&C5(S,$,Z)}}function WS(h,x,S,M){Tl[Kg++]=h,Tl[Kg++]=x,Tl[Kg++]=S,Tl[Kg++]=M,AA|=M,h.lanes|=M,h=h.alternate,h!==null&&(h.lanes|=M)}function jA(h,x,S,M){return WS(h,x,S,M),KS(h)}function Kp(h,x){return WS(h,null,null,x),KS(h)}function C5(h,x,S){h.lanes|=S;var M=h.alternate;M!==null&&(M.lanes|=S);for(var $=!1,Z=h.return;Z!==null;)Z.childLanes|=S,M=Z.alternate,M!==null&&(M.childLanes|=S),Z.tag===22&&(h=Z.stateNode,h===null||h._visibility&1||($=!0)),h=Z,Z=Z.return;return h.tag===3?(Z=h.stateNode,$&&x!==null&&($=31-$e(S),h=Z.hiddenUpdates,M=h[$],M===null?h[$]=[x]:M.push(x),x.lane=S|536870912),Z):null}function KS(h){if(50<bv)throw bv=0,zj=null,Error(r(185));for(var x=h.return;x!==null;)h=x,x=h.return;return h.tag===3?h.stateNode:null}var Xg={};function bce(h,x,S,M){this.tag=h,this.key=S,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.refCleanup=this.ref=null,this.pendingProps=x,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=M,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Qo(h,x,S,M){return new bce(h,x,S,M)}function MA(h){return h=h.prototype,!(!h||!h.isReactComponent)}function lu(h,x){var S=h.alternate;return S===null?(S=Qo(h.tag,x,h.key,h.mode),S.elementType=h.elementType,S.type=h.type,S.stateNode=h.stateNode,S.alternate=h,h.alternate=S):(S.pendingProps=x,S.type=h.type,S.flags=0,S.subtreeFlags=0,S.deletions=null),S.flags=h.flags&65011712,S.childLanes=h.childLanes,S.lanes=h.lanes,S.child=h.child,S.memoizedProps=h.memoizedProps,S.memoizedState=h.memoizedState,S.updateQueue=h.updateQueue,x=h.dependencies,S.dependencies=x===null?null:{lanes:x.lanes,firstContext:x.firstContext},S.sibling=h.sibling,S.index=h.index,S.ref=h.ref,S.refCleanup=h.refCleanup,S}function P5(h,x){h.flags&=65011714;var S=h.alternate;return S===null?(h.childLanes=0,h.lanes=x,h.child=null,h.subtreeFlags=0,h.memoizedProps=null,h.memoizedState=null,h.updateQueue=null,h.dependencies=null,h.stateNode=null):(h.childLanes=S.childLanes,h.lanes=S.lanes,h.child=S.child,h.subtreeFlags=0,h.deletions=null,h.memoizedProps=S.memoizedProps,h.memoizedState=S.memoizedState,h.updateQueue=S.updateQueue,h.type=S.type,x=S.dependencies,h.dependencies=x===null?null:{lanes:x.lanes,firstContext:x.firstContext}),h}function XS(h,x,S,M,$,Z){var Ne=0;if(M=h,typeof h==\"function\")MA(h)&&(Ne=1);else if(typeof h==\"string\")Ne=Sde(h,S,be.current)?26:h===\"html\"||h===\"head\"||h===\"body\"?27:5;else e:switch(h){case k:return h=Qo(31,S,x,$),h.elementType=k,h.lanes=Z,h;case v:return Xp(S.children,$,Z,x);case b:Ne=8,$|=24;break;case g:return h=Qo(12,S,x,$|2),h.elementType=g,h.lanes=Z,h;case N:return h=Qo(13,S,x,$),h.elementType=N,h.lanes=Z,h;case A:return h=Qo(19,S,x,$),h.elementType=A,h.lanes=Z,h;default:if(typeof h==\"object\"&&h!==null)switch(h.$$typeof){case C:Ne=10;break e;case _:Ne=9;break e;case P:Ne=11;break e;case T:Ne=14;break e;case F:Ne=16,M=null;break e}Ne=29,S=Error(r(130,h===null?\"null\":typeof h,\"\")),M=null}return x=Qo(Ne,S,x,$),x.elementType=h,x.type=M,x.lanes=Z,x}function Xp(h,x,S,M){return h=Qo(7,h,M,x),h.lanes=S,h}function EA(h,x,S){return h=Qo(6,h,null,x),h.lanes=S,h}function T5(h){var x=Qo(18,null,null,0);return x.stateNode=h,x}function DA(h,x,S){return x=Qo(4,h.children!==null?h.children:[],h.key,x),x.lanes=S,x.stateNode={containerInfo:h.containerInfo,pendingChildren:null,implementation:h.implementation},x}var A5=new WeakMap;function Al(h,x){if(typeof h==\"object\"&&h!==null){var S=A5.get(h);return S!==void 0?S:(x={value:h,source:x,stack:ee(x)},A5.set(h,x),x)}return{value:h,source:x,stack:ee(x)}}var Yg=[],Qg=0,YS=null,Yy=0,jl=[],Ml=0,Bm=null,cd=1,dd=\"\";function cu(h,x){Yg[Qg++]=Yy,Yg[Qg++]=YS,YS=h,Yy=x}function j5(h,x,S){jl[Ml++]=cd,jl[Ml++]=dd,jl[Ml++]=Bm,Bm=h;var M=cd;h=dd;var $=32-$e(M)-1;M&=~(1<<$),S+=1;var Z=32-$e(x)+$;if(30<Z){var Ne=$-$%5;Z=(M&(1<<Ne)-1).toString(32),M>>=Ne,$-=Ne,cd=1<<32-$e(x)+$|S<<$|M,dd=Z+h}else cd=1<<Z|S<<$|M,dd=h}function FA(h){h.return!==null&&(cu(h,1),j5(h,1,0))}function RA(h){for(;h===YS;)YS=Yg[--Qg],Yg[Qg]=null,Yy=Yg[--Qg],Yg[Qg]=null;for(;h===Bm;)Bm=jl[--Ml],jl[Ml]=null,dd=jl[--Ml],jl[Ml]=null,cd=jl[--Ml],jl[Ml]=null}function M5(h,x){jl[Ml++]=cd,jl[Ml++]=dd,jl[Ml++]=Bm,cd=x.id,dd=x.overflow,Bm=h}var is=null,va=null,vr=!1,Hm=null,El=!1,LA=Error(r(519));function qm(h){var x=Error(r(418,1<arguments.length&&arguments[1]!==void 0&&arguments[1]?\"text\":\"HTML\",\"\"));throw Qy(Al(x,h)),LA}function E5(h){var x=h.stateNode,S=h.type,M=h.memoizedProps;switch(x[at]=h,x[Lt]=M,S){case\"dialog\":pr(\"cancel\",x),pr(\"close\",x);break;case\"iframe\":case\"object\":case\"embed\":pr(\"load\",x);break;case\"video\":case\"audio\":for(S=0;S<yv.length;S++)pr(yv[S],x);break;case\"source\":pr(\"error\",x);break;case\"img\":case\"image\":case\"link\":pr(\"error\",x),pr(\"load\",x);break;case\"details\":pr(\"toggle\",x);break;case\"input\":pr(\"invalid\",x),In(x,M.value,M.defaultValue,M.checked,M.defaultChecked,M.type,M.name,!0);break;case\"select\":pr(\"invalid\",x);break;case\"textarea\":pr(\"invalid\",x),Br(x,M.value,M.defaultValue,M.children)}S=M.children,typeof S!=\"string\"&&typeof S!=\"number\"&&typeof S!=\"bigint\"||x.textContent===\"\"+S||M.suppressHydrationWarning===!0||XB(x.textContent,S)?(M.popover!=null&&(pr(\"beforetoggle\",x),pr(\"toggle\",x)),M.onScroll!=null&&pr(\"scroll\",x),M.onScrollEnd!=null&&pr(\"scrollend\",x),M.onClick!=null&&(x.onclick=$t),x=!0):x=!1,x||qm(h,!0)}function D5(h){for(is=h.return;is;)switch(is.tag){case 5:case 31:case 13:El=!1;return;case 27:case 3:El=!0;return;default:is=is.return}}function Zg(h){if(h!==is)return!1;if(!vr)return D5(h),vr=!0,!1;var x=h.tag,S;if((S=x!==3&&x!==27)&&((S=x===5)&&(S=h.type,S=!(S!==\"form\"&&S!==\"button\")||eM(h.type,h.memoizedProps)),S=!S),S&&va&&qm(h),D5(h),x===13){if(h=h.memoizedState,h=h!==null?h.dehydrated:null,!h)throw Error(r(317));va=a8(h)}else if(x===31){if(h=h.memoizedState,h=h!==null?h.dehydrated:null,!h)throw Error(r(317));va=a8(h)}else x===27?(x=va,rh(h.type)?(h=iM,iM=null,va=h):va=x):va=is?Fl(h.stateNode.nextSibling):null;return!0}function Yp(){va=is=null,vr=!1}function OA(){var h=Hm;return h!==null&&(To===null?To=h:To.push.apply(To,h),Hm=null),h}function Qy(h){Hm===null?Hm=[h]:Hm.push(h)}var IA=W(null),Qp=null,du=null;function $m(h,x,S){me(IA,x._currentValue),x._currentValue=S}function uu(h){h._currentValue=IA.current,ne(IA)}function zA(h,x,S){for(;h!==null;){var M=h.alternate;if((h.childLanes&x)!==x?(h.childLanes|=x,M!==null&&(M.childLanes|=x)):M!==null&&(M.childLanes&x)!==x&&(M.childLanes|=x),h===S)break;h=h.return}}function UA(h,x,S,M){var $=h.child;for($!==null&&($.return=h);$!==null;){var Z=$.dependencies;if(Z!==null){var Ne=$.child;Z=Z.firstContext;e:for(;Z!==null;){var He=Z;Z=$;for(var rt=0;rt<x.length;rt++)if(He.context===x[rt]){Z.lanes|=S,He=Z.alternate,He!==null&&(He.lanes|=S),zA(Z.return,S,h),M||(Ne=null);break e}Z=He.next}}else if($.tag===18){if(Ne=$.return,Ne===null)throw Error(r(341));Ne.lanes|=S,Z=Ne.alternate,Z!==null&&(Z.lanes|=S),zA(Ne,S,h),Ne=null}else Ne=$.child;if(Ne!==null)Ne.return=$;else for(Ne=$;Ne!==null;){if(Ne===h){Ne=null;break}if($=Ne.sibling,$!==null){$.return=Ne.return,Ne=$;break}Ne=Ne.return}$=Ne}}function Jg(h,x,S,M){h=null;for(var $=x,Z=!1;$!==null;){if(!Z){if(($.flags&524288)!==0)Z=!0;else if(($.flags&262144)!==0)break}if($.tag===10){var Ne=$.alternate;if(Ne===null)throw Error(r(387));if(Ne=Ne.memoizedProps,Ne!==null){var He=$.type;ko($.pendingProps.value,Ne.value)||(h!==null?h.push(He):h=[He])}}else if($===Y.current){if(Ne=$.alternate,Ne===null)throw Error(r(387));Ne.memoizedState.memoizedState!==$.memoizedState.memoizedState&&(h!==null?h.push(kv):h=[kv])}$=$.return}h!==null&&UA(x,h,S,M),x.flags|=262144}function QS(h){for(h=h.firstContext;h!==null;){if(!ko(h.context._currentValue,h.memoizedValue))return!0;h=h.next}return!1}function Zp(h){Qp=h,du=null,h=h.dependencies,h!==null&&(h.firstContext=null)}function ss(h){return F5(Qp,h)}function ZS(h,x){return Qp===null&&Zp(h),F5(h,x)}function F5(h,x){var S=x._currentValue;if(x={context:x,memoizedValue:S,next:null},du===null){if(h===null)throw Error(r(308));du=x,h.dependencies={lanes:0,firstContext:x},h.flags|=524288}else du=du.next=x;return S}var xce=typeof AbortController<\"u\"?AbortController:function(){var h=[],x=this.signal={aborted:!1,addEventListener:function(S,M){h.push(M)}};this.abort=function(){x.aborted=!0,h.forEach(function(S){return S()})}},yce=t.unstable_scheduleCallback,vce=t.unstable_NormalPriority,hi={$$typeof:C,Consumer:null,Provider:null,_currentValue:null,_currentValue2:null,_threadCount:0};function BA(){return{controller:new xce,data:new Map,refCount:0}}function Zy(h){h.refCount--,h.refCount===0&&yce(vce,function(){h.controller.abort()})}var Jy=null,HA=0,eb=0,tb=null;function wce(h,x){if(Jy===null){var S=Jy=[];HA=0,eb=Vj(),tb={status:\"pending\",value:void 0,then:function(M){S.push(M)}}}return HA++,x.then(R5,R5),x}function R5(){if(--HA===0&&Jy!==null){tb!==null&&(tb.status=\"fulfilled\");var h=Jy;Jy=null,eb=0,tb=null;for(var x=0;x<h.length;x++)(0,h[x])()}}function Sce(h,x){var S=[],M={status:\"pending\",value:null,reason:null,then:function($){S.push($)}};return h.then(function(){M.status=\"fulfilled\",M.value=x;for(var $=0;$<S.length;$++)(0,S[$])(x)},function($){for(M.status=\"rejected\",M.reason=$,$=0;$<S.length;$++)(0,S[$])(void 0)}),M}var L5=ie.S;ie.S=function(h,x){yB=R(),typeof x==\"object\"&&x!==null&&typeof x.then==\"function\"&&wce(h,x),L5!==null&&L5(h,x)};var Jp=W(null);function qA(){var h=Jp.current;return h!==null?h:aa.pooledCache}function JS(h,x){x===null?me(Jp,Jp.current):me(Jp,x.pool)}function O5(){var h=qA();return h===null?null:{parent:hi._currentValue,pool:h}}var nb=Error(r(460)),$A=Error(r(474)),e_=Error(r(542)),t_={then:function(){}};function I5(h){return h=h.status,h===\"fulfilled\"||h===\"rejected\"}function z5(h,x,S){switch(S=h[S],S===void 0?h.push(x):S!==x&&(x.then($t,$t),x=S),x.status){case\"fulfilled\":return x.value;case\"rejected\":throw h=x.reason,B5(h),h;default:if(typeof x.status==\"string\")x.then($t,$t);else{if(h=aa,h!==null&&100<h.shellSuspendCounter)throw Error(r(482));h=x,h.status=\"pending\",h.then(function(M){if(x.status===\"pending\"){var $=x;$.status=\"fulfilled\",$.value=M}},function(M){if(x.status===\"pending\"){var $=x;$.status=\"rejected\",$.reason=M}})}switch(x.status){case\"fulfilled\":return x.value;case\"rejected\":throw h=x.reason,B5(h),h}throw tf=x,nb}}function ef(h){try{var x=h._init;return x(h._payload)}catch(S){throw S!==null&&typeof S==\"object\"&&typeof S.then==\"function\"?(tf=S,nb):S}}var tf=null;function U5(){if(tf===null)throw Error(r(459));var h=tf;return tf=null,h}function B5(h){if(h===nb||h===e_)throw Error(r(483))}var rb=null,ev=0;function n_(h){var x=ev;return ev+=1,rb===null&&(rb=[]),z5(rb,h,x)}function tv(h,x){x=x.props.ref,h.ref=x!==void 0?x:null}function r_(h,x){throw x.$$typeof===p?Error(r(525)):(h=Object.prototype.toString.call(x),Error(r(31,h===\"[object Object]\"?\"object with keys {\"+Object.keys(x).join(\", \")+\"}\":h)))}function H5(h){function x(bt,dt){if(h){var _t=bt.deletions;_t===null?(bt.deletions=[dt],bt.flags|=16):_t.push(dt)}}function S(bt,dt){if(!h)return null;for(;dt!==null;)x(bt,dt),dt=dt.sibling;return null}function M(bt){for(var dt=new Map;bt!==null;)bt.key!==null?dt.set(bt.key,bt):dt.set(bt.index,bt),bt=bt.sibling;return dt}function $(bt,dt){return bt=lu(bt,dt),bt.index=0,bt.sibling=null,bt}function Z(bt,dt,_t){return bt.index=_t,h?(_t=bt.alternate,_t!==null?(_t=_t.index,_t<dt?(bt.flags|=67108866,dt):_t):(bt.flags|=67108866,dt)):(bt.flags|=1048576,dt)}function Ne(bt){return h&&bt.alternate===null&&(bt.flags|=67108866),bt}function He(bt,dt,_t,It){return dt===null||dt.tag!==6?(dt=EA(_t,bt.mode,It),dt.return=bt,dt):(dt=$(dt,_t),dt.return=bt,dt)}function rt(bt,dt,_t,It){var Tn=_t.type;return Tn===v?Ot(bt,dt,_t.props.children,It,_t.key):dt!==null&&(dt.elementType===Tn||typeof Tn==\"object\"&&Tn!==null&&Tn.$$typeof===F&&ef(Tn)===dt.type)?(dt=$(dt,_t.props),tv(dt,_t),dt.return=bt,dt):(dt=XS(_t.type,_t.key,_t.props,null,bt.mode,It),tv(dt,_t),dt.return=bt,dt)}function kt(bt,dt,_t,It){return dt===null||dt.tag!==4||dt.stateNode.containerInfo!==_t.containerInfo||dt.stateNode.implementation!==_t.implementation?(dt=DA(_t,bt.mode,It),dt.return=bt,dt):(dt=$(dt,_t.children||[]),dt.return=bt,dt)}function Ot(bt,dt,_t,It,Tn){return dt===null||dt.tag!==7?(dt=Xp(_t,bt.mode,It,Tn),dt.return=bt,dt):(dt=$(dt,_t),dt.return=bt,dt)}function zt(bt,dt,_t){if(typeof dt==\"string\"&&dt!==\"\"||typeof dt==\"number\"||typeof dt==\"bigint\")return dt=EA(\"\"+dt,bt.mode,_t),dt.return=bt,dt;if(typeof dt==\"object\"&&dt!==null){switch(dt.$$typeof){case f:return _t=XS(dt.type,dt.key,dt.props,null,bt.mode,_t),tv(_t,dt),_t.return=bt,_t;case y:return dt=DA(dt,bt.mode,_t),dt.return=bt,dt;case F:return dt=ef(dt),zt(bt,dt,_t)}if(te(dt)||z(dt))return dt=Xp(dt,bt.mode,_t,null),dt.return=bt,dt;if(typeof dt.then==\"function\")return zt(bt,n_(dt),_t);if(dt.$$typeof===C)return zt(bt,ZS(bt,dt),_t);r_(bt,dt)}return null}function Nt(bt,dt,_t,It){var Tn=dt!==null?dt.key:null;if(typeof _t==\"string\"&&_t!==\"\"||typeof _t==\"number\"||typeof _t==\"bigint\")return Tn!==null?null:He(bt,dt,\"\"+_t,It);if(typeof _t==\"object\"&&_t!==null){switch(_t.$$typeof){case f:return _t.key===Tn?rt(bt,dt,_t,It):null;case y:return _t.key===Tn?kt(bt,dt,_t,It):null;case F:return _t=ef(_t),Nt(bt,dt,_t,It)}if(te(_t)||z(_t))return Tn!==null?null:Ot(bt,dt,_t,It,null);if(typeof _t.then==\"function\")return Nt(bt,dt,n_(_t),It);if(_t.$$typeof===C)return Nt(bt,dt,ZS(bt,_t),It);r_(bt,_t)}return null}function Dt(bt,dt,_t,It,Tn){if(typeof It==\"string\"&&It!==\"\"||typeof It==\"number\"||typeof It==\"bigint\")return bt=bt.get(_t)||null,He(dt,bt,\"\"+It,Tn);if(typeof It==\"object\"&&It!==null){switch(It.$$typeof){case f:return bt=bt.get(It.key===null?_t:It.key)||null,rt(dt,bt,It,Tn);case y:return bt=bt.get(It.key===null?_t:It.key)||null,kt(dt,bt,It,Tn);case F:return It=ef(It),Dt(bt,dt,_t,It,Tn)}if(te(It)||z(It))return bt=bt.get(_t)||null,Ot(dt,bt,It,Tn,null);if(typeof It.then==\"function\")return Dt(bt,dt,_t,n_(It),Tn);if(It.$$typeof===C)return Dt(bt,dt,_t,ZS(dt,It),Tn);r_(dt,It)}return null}function mn(bt,dt,_t,It){for(var Tn=null,Ar=null,fn=dt,Qn=dt=0,xr=null;fn!==null&&Qn<_t.length;Qn++){fn.index>Qn?(xr=fn,fn=null):xr=fn.sibling;var jr=Nt(bt,fn,_t[Qn],It);if(jr===null){fn===null&&(fn=xr);break}h&&fn&&jr.alternate===null&&x(bt,fn),dt=Z(jr,dt,Qn),Ar===null?Tn=jr:Ar.sibling=jr,Ar=jr,fn=xr}if(Qn===_t.length)return S(bt,fn),vr&&cu(bt,Qn),Tn;if(fn===null){for(;Qn<_t.length;Qn++)fn=zt(bt,_t[Qn],It),fn!==null&&(dt=Z(fn,dt,Qn),Ar===null?Tn=fn:Ar.sibling=fn,Ar=fn);return vr&&cu(bt,Qn),Tn}for(fn=M(fn);Qn<_t.length;Qn++)xr=Dt(fn,bt,Qn,_t[Qn],It),xr!==null&&(h&&xr.alternate!==null&&fn.delete(xr.key===null?Qn:xr.key),dt=Z(xr,dt,Qn),Ar===null?Tn=xr:Ar.sibling=xr,Ar=xr);return h&&fn.forEach(function(lh){return x(bt,lh)}),vr&&cu(bt,Qn),Tn}function Rn(bt,dt,_t,It){if(_t==null)throw Error(r(151));for(var Tn=null,Ar=null,fn=dt,Qn=dt=0,xr=null,jr=_t.next();fn!==null&&!jr.done;Qn++,jr=_t.next()){fn.index>Qn?(xr=fn,fn=null):xr=fn.sibling;var lh=Nt(bt,fn,jr.value,It);if(lh===null){fn===null&&(fn=xr);break}h&&fn&&lh.alternate===null&&x(bt,fn),dt=Z(lh,dt,Qn),Ar===null?Tn=lh:Ar.sibling=lh,Ar=lh,fn=xr}if(jr.done)return S(bt,fn),vr&&cu(bt,Qn),Tn;if(fn===null){for(;!jr.done;Qn++,jr=_t.next())jr=zt(bt,jr.value,It),jr!==null&&(dt=Z(jr,dt,Qn),Ar===null?Tn=jr:Ar.sibling=jr,Ar=jr);return vr&&cu(bt,Qn),Tn}for(fn=M(fn);!jr.done;Qn++,jr=_t.next())jr=Dt(fn,bt,Qn,jr.value,It),jr!==null&&(h&&jr.alternate!==null&&fn.delete(jr.key===null?Qn:jr.key),dt=Z(jr,dt,Qn),Ar===null?Tn=jr:Ar.sibling=jr,Ar=jr);return h&&fn.forEach(function(Dde){return x(bt,Dde)}),vr&&cu(bt,Qn),Tn}function Xr(bt,dt,_t,It){if(typeof _t==\"object\"&&_t!==null&&_t.type===v&&_t.key===null&&(_t=_t.props.children),typeof _t==\"object\"&&_t!==null){switch(_t.$$typeof){case f:e:{for(var Tn=_t.key;dt!==null;){if(dt.key===Tn){if(Tn=_t.type,Tn===v){if(dt.tag===7){S(bt,dt.sibling),It=$(dt,_t.props.children),It.return=bt,bt=It;break e}}else if(dt.elementType===Tn||typeof Tn==\"object\"&&Tn!==null&&Tn.$$typeof===F&&ef(Tn)===dt.type){S(bt,dt.sibling),It=$(dt,_t.props),tv(It,_t),It.return=bt,bt=It;break e}S(bt,dt);break}else x(bt,dt);dt=dt.sibling}_t.type===v?(It=Xp(_t.props.children,bt.mode,It,_t.key),It.return=bt,bt=It):(It=XS(_t.type,_t.key,_t.props,null,bt.mode,It),tv(It,_t),It.return=bt,bt=It)}return Ne(bt);case y:e:{for(Tn=_t.key;dt!==null;){if(dt.key===Tn)if(dt.tag===4&&dt.stateNode.containerInfo===_t.containerInfo&&dt.stateNode.implementation===_t.implementation){S(bt,dt.sibling),It=$(dt,_t.children||[]),It.return=bt,bt=It;break e}else{S(bt,dt);break}else x(bt,dt);dt=dt.sibling}It=DA(_t,bt.mode,It),It.return=bt,bt=It}return Ne(bt);case F:return _t=ef(_t),Xr(bt,dt,_t,It)}if(te(_t))return mn(bt,dt,_t,It);if(z(_t)){if(Tn=z(_t),typeof Tn!=\"function\")throw Error(r(150));return _t=Tn.call(_t),Rn(bt,dt,_t,It)}if(typeof _t.then==\"function\")return Xr(bt,dt,n_(_t),It);if(_t.$$typeof===C)return Xr(bt,dt,ZS(bt,_t),It);r_(bt,_t)}return typeof _t==\"string\"&&_t!==\"\"||typeof _t==\"number\"||typeof _t==\"bigint\"?(_t=\"\"+_t,dt!==null&&dt.tag===6?(S(bt,dt.sibling),It=$(dt,_t),It.return=bt,bt=It):(S(bt,dt),It=EA(_t,bt.mode,It),It.return=bt,bt=It),Ne(bt)):S(bt,dt)}return function(bt,dt,_t,It){try{ev=0;var Tn=Xr(bt,dt,_t,It);return rb=null,Tn}catch(fn){if(fn===nb||fn===e_)throw fn;var Ar=Qo(29,fn,null,bt.mode);return Ar.lanes=It,Ar.return=bt,Ar}}}var nf=H5(!0),q5=H5(!1),Vm=!1;function VA(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function GA(h,x){h=h.updateQueue,x.updateQueue===h&&(x.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function Gm(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function Wm(h,x,S){var M=h.updateQueue;if(M===null)return null;if(M=M.shared,(Lr&2)!==0){var $=M.pending;return $===null?x.next=x:(x.next=$.next,$.next=x),M.pending=x,x=KS(h),C5(h,null,S),x}return WS(h,M,x,S),KS(h)}function nv(h,x,S){if(x=x.updateQueue,x!==null&&(x=x.shared,(S&4194048)!==0)){var M=x.lanes;M&=h.pendingLanes,S|=M,x.lanes=S,Wt(h,S)}}function WA(h,x){var S=h.updateQueue,M=h.alternate;if(M!==null&&(M=M.updateQueue,S===M)){var $=null,Z=null;if(S=S.firstBaseUpdate,S!==null){do{var Ne={lane:S.lane,tag:S.tag,payload:S.payload,callback:null,next:null};Z===null?$=Z=Ne:Z=Z.next=Ne,S=S.next}while(S!==null);Z===null?$=Z=x:Z=Z.next=x}else $=Z=x;S={baseState:M.baseState,firstBaseUpdate:$,lastBaseUpdate:Z,shared:M.shared,callbacks:M.callbacks},h.updateQueue=S;return}h=S.lastBaseUpdate,h===null?S.firstBaseUpdate=x:h.next=x,S.lastBaseUpdate=x}var KA=!1;function rv(){if(KA){var h=tb;if(h!==null)throw h}}function av(h,x,S,M){KA=!1;var $=h.updateQueue;Vm=!1;var Z=$.firstBaseUpdate,Ne=$.lastBaseUpdate,He=$.shared.pending;if(He!==null){$.shared.pending=null;var rt=He,kt=rt.next;rt.next=null,Ne===null?Z=kt:Ne.next=kt,Ne=rt;var Ot=h.alternate;Ot!==null&&(Ot=Ot.updateQueue,He=Ot.lastBaseUpdate,He!==Ne&&(He===null?Ot.firstBaseUpdate=kt:He.next=kt,Ot.lastBaseUpdate=rt))}if(Z!==null){var zt=$.baseState;Ne=0,Ot=kt=rt=null,He=Z;do{var Nt=He.lane&-536870913,Dt=Nt!==He.lane;if(Dt?(br&Nt)===Nt:(M&Nt)===Nt){Nt!==0&&Nt===eb&&(KA=!0),Ot!==null&&(Ot=Ot.next={lane:0,tag:He.tag,payload:He.payload,callback:null,next:null});e:{var mn=h,Rn=He;Nt=x;var Xr=S;switch(Rn.tag){case 1:if(mn=Rn.payload,typeof mn==\"function\"){zt=mn.call(Xr,zt,Nt);break e}zt=mn;break e;case 3:mn.flags=mn.flags&-65537|128;case 0:if(mn=Rn.payload,Nt=typeof mn==\"function\"?mn.call(Xr,zt,Nt):mn,Nt==null)break e;zt=m({},zt,Nt);break e;case 2:Vm=!0}}Nt=He.callback,Nt!==null&&(h.flags|=64,Dt&&(h.flags|=8192),Dt=$.callbacks,Dt===null?$.callbacks=[Nt]:Dt.push(Nt))}else Dt={lane:Nt,tag:He.tag,payload:He.payload,callback:He.callback,next:null},Ot===null?(kt=Ot=Dt,rt=zt):Ot=Ot.next=Dt,Ne|=Nt;if(He=He.next,He===null){if(He=$.shared.pending,He===null)break;Dt=He,He=Dt.next,Dt.next=null,$.lastBaseUpdate=Dt,$.shared.pending=null}}while(!0);Ot===null&&(rt=zt),$.baseState=rt,$.firstBaseUpdate=kt,$.lastBaseUpdate=Ot,Z===null&&($.shared.lanes=0),Zm|=Ne,h.lanes=Ne,h.memoizedState=zt}}function $5(h,x){if(typeof h!=\"function\")throw Error(r(191,h));h.call(x)}function V5(h,x){var S=h.callbacks;if(S!==null)for(h.callbacks=null,h=0;h<S.length;h++)$5(S[h],x)}var ab=W(null),a_=W(0);function G5(h,x){h=vu,me(a_,h),me(ab,x),vu=h|x.baseLanes}function XA(){me(a_,vu),me(ab,ab.current)}function YA(){vu=a_.current,ne(ab),ne(a_)}var Zo=W(null),Dl=null;function Km(h){var x=h.alternate;me(ii,ii.current&1),me(Zo,h),Dl===null&&(x===null||ab.current!==null||x.memoizedState!==null)&&(Dl=h)}function QA(h){me(ii,ii.current),me(Zo,h),Dl===null&&(Dl=h)}function W5(h){h.tag===22?(me(ii,ii.current),me(Zo,h),Dl===null&&(Dl=h)):Xm()}function Xm(){me(ii,ii.current),me(Zo,Zo.current)}function Jo(h){ne(Zo),Dl===h&&(Dl=null),ne(ii)}var ii=W(0);function i_(h){for(var x=h;x!==null;){if(x.tag===13){var S=x.memoizedState;if(S!==null&&(S=S.dehydrated,S===null||rM(S)||aM(S)))return x}else if(x.tag===19&&(x.memoizedProps.revealOrder===\"forwards\"||x.memoizedProps.revealOrder===\"backwards\"||x.memoizedProps.revealOrder===\"unstable_legacy-backwards\"||x.memoizedProps.revealOrder===\"together\")){if((x.flags&128)!==0)return x}else if(x.child!==null){x.child.return=x,x=x.child;continue}if(x===h)break;for(;x.sibling===null;){if(x.return===null||x.return===h)return null;x=x.return}x.sibling.return=x.return,x=x.sibling}return null}var mu=0,Xn=null,Wr=null,pi=null,s_=!1,ib=!1,rf=!1,o_=0,iv=0,sb=null,_ce=0;function qa(){throw Error(r(321))}function ZA(h,x){if(x===null)return!1;for(var S=0;S<x.length&&S<h.length;S++)if(!ko(h[S],x[S]))return!1;return!0}function JA(h,x,S,M,$,Z){return mu=Z,Xn=x,x.memoizedState=null,x.updateQueue=null,x.lanes=0,ie.H=h===null||h.memoizedState===null?jU:pj,rf=!1,Z=S(M,$),rf=!1,ib&&(Z=X5(x,S,M,$)),K5(h),Z}function K5(h){ie.H=lv;var x=Wr!==null&&Wr.next!==null;if(mu=0,pi=Wr=Xn=null,s_=!1,iv=0,sb=null,x)throw Error(r(300));h===null||fi||(h=h.dependencies,h!==null&&QS(h)&&(fi=!0))}function X5(h,x,S,M){Xn=h;var $=0;do{if(ib&&(sb=null),iv=0,ib=!1,25<=$)throw Error(r(301));if($+=1,pi=Wr=null,h.updateQueue!=null){var Z=h.updateQueue;Z.lastEffect=null,Z.events=null,Z.stores=null,Z.memoCache!=null&&(Z.memoCache.index=0)}ie.H=MU,Z=x(S,M)}while(ib);return Z}function kce(){var h=ie.H,x=h.useState()[0];return x=typeof x.then==\"function\"?sv(x):x,h=h.useState()[0],(Wr!==null?Wr.memoizedState:null)!==h&&(Xn.flags|=1024),x}function ej(){var h=o_!==0;return o_=0,h}function tj(h,x,S){x.updateQueue=h.updateQueue,x.flags&=-2053,h.lanes&=~S}function nj(h){if(s_){for(h=h.memoizedState;h!==null;){var x=h.queue;x!==null&&(x.pending=null),h=h.next}s_=!1}mu=0,pi=Wr=Xn=null,ib=!1,iv=o_=0,sb=null}function Qs(){var h={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return pi===null?Xn.memoizedState=pi=h:pi=pi.next=h,pi}function si(){if(Wr===null){var h=Xn.alternate;h=h!==null?h.memoizedState:null}else h=Wr.next;var x=pi===null?Xn.memoizedState:pi.next;if(x!==null)pi=x,Wr=h;else{if(h===null)throw Xn.alternate===null?Error(r(467)):Error(r(310));Wr=h,h={memoizedState:Wr.memoizedState,baseState:Wr.baseState,baseQueue:Wr.baseQueue,queue:Wr.queue,next:null},pi===null?Xn.memoizedState=pi=h:pi=pi.next=h}return pi}function l_(){return{lastEffect:null,events:null,stores:null,memoCache:null}}function sv(h){var x=iv;return iv+=1,sb===null&&(sb=[]),h=z5(sb,h,x),x=Xn,(pi===null?x.memoizedState:pi.next)===null&&(x=x.alternate,ie.H=x===null||x.memoizedState===null?jU:pj),h}function c_(h){if(h!==null&&typeof h==\"object\"){if(typeof h.then==\"function\")return sv(h);if(h.$$typeof===C)return ss(h)}throw Error(r(438,String(h)))}function rj(h){var x=null,S=Xn.updateQueue;if(S!==null&&(x=S.memoCache),x==null){var M=Xn.alternate;M!==null&&(M=M.updateQueue,M!==null&&(M=M.memoCache,M!=null&&(x={data:M.data.map(function($){return $.slice()}),index:0})))}if(x==null&&(x={data:[],index:0}),S===null&&(S=l_(),Xn.updateQueue=S),S.memoCache=x,S=x.data[x.index],S===void 0)for(S=x.data[x.index]=Array(h),M=0;M<h;M++)S[M]=D;return x.index++,S}function hu(h,x){return typeof x==\"function\"?x(h):x}function d_(h){var x=si();return aj(x,Wr,h)}function aj(h,x,S){var M=h.queue;if(M===null)throw Error(r(311));M.lastRenderedReducer=S;var $=h.baseQueue,Z=M.pending;if(Z!==null){if($!==null){var Ne=$.next;$.next=Z.next,Z.next=Ne}x.baseQueue=$=Z,M.pending=null}if(Z=h.baseState,$===null)h.memoizedState=Z;else{x=$.next;var He=Ne=null,rt=null,kt=x,Ot=!1;do{var zt=kt.lane&-536870913;if(zt!==kt.lane?(br&zt)===zt:(mu&zt)===zt){var Nt=kt.revertLane;if(Nt===0)rt!==null&&(rt=rt.next={lane:0,revertLane:0,gesture:null,action:kt.action,hasEagerState:kt.hasEagerState,eagerState:kt.eagerState,next:null}),zt===eb&&(Ot=!0);else if((mu&Nt)===Nt){kt=kt.next,Nt===eb&&(Ot=!0);continue}else zt={lane:0,revertLane:kt.revertLane,gesture:null,action:kt.action,hasEagerState:kt.hasEagerState,eagerState:kt.eagerState,next:null},rt===null?(He=rt=zt,Ne=Z):rt=rt.next=zt,Xn.lanes|=Nt,Zm|=Nt;zt=kt.action,rf&&S(Z,zt),Z=kt.hasEagerState?kt.eagerState:S(Z,zt)}else Nt={lane:zt,revertLane:kt.revertLane,gesture:kt.gesture,action:kt.action,hasEagerState:kt.hasEagerState,eagerState:kt.eagerState,next:null},rt===null?(He=rt=Nt,Ne=Z):rt=rt.next=Nt,Xn.lanes|=zt,Zm|=zt;kt=kt.next}while(kt!==null&&kt!==x);if(rt===null?Ne=Z:rt.next=He,!ko(Z,h.memoizedState)&&(fi=!0,Ot&&(S=tb,S!==null)))throw S;h.memoizedState=Z,h.baseState=Ne,h.baseQueue=rt,M.lastRenderedState=Z}return $===null&&(M.lanes=0),[h.memoizedState,M.dispatch]}function ij(h){var x=si(),S=x.queue;if(S===null)throw Error(r(311));S.lastRenderedReducer=h;var M=S.dispatch,$=S.pending,Z=x.memoizedState;if($!==null){S.pending=null;var Ne=$=$.next;do Z=h(Z,Ne.action),Ne=Ne.next;while(Ne!==$);ko(Z,x.memoizedState)||(fi=!0),x.memoizedState=Z,x.baseQueue===null&&(x.baseState=Z),S.lastRenderedState=Z}return[Z,M]}function Y5(h,x,S){var M=Xn,$=si(),Z=vr;if(Z){if(S===void 0)throw Error(r(407));S=S()}else S=x();var Ne=!ko((Wr||$).memoizedState,S);if(Ne&&($.memoizedState=S,fi=!0),$=$.queue,lj(J5.bind(null,M,$,h),[h]),$.getSnapshot!==x||Ne||pi!==null&&pi.memoizedState.tag&1){if(M.flags|=2048,ob(9,{destroy:void 0},Z5.bind(null,M,$,S,x),null),aa===null)throw Error(r(349));Z||(mu&127)!==0||Q5(M,x,S)}return S}function Q5(h,x,S){h.flags|=16384,h={getSnapshot:x,value:S},x=Xn.updateQueue,x===null?(x=l_(),Xn.updateQueue=x,x.stores=[h]):(S=x.stores,S===null?x.stores=[h]:S.push(h))}function Z5(h,x,S,M){x.value=S,x.getSnapshot=M,eU(x)&&tU(h)}function J5(h,x,S){return S(function(){eU(x)&&tU(h)})}function eU(h){var x=h.getSnapshot;h=h.value;try{var S=x();return!ko(h,S)}catch{return!0}}function tU(h){var x=Kp(h,2);x!==null&&Ao(x,h,2)}function sj(h){var x=Qs();if(typeof h==\"function\"){var S=h;if(h=S(),rf){Re(!0);try{S()}finally{Re(!1)}}}return x.memoizedState=x.baseState=h,x.queue={pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:h},x}function nU(h,x,S,M){return h.baseState=S,aj(h,Wr,typeof M==\"function\"?M:hu)}function Nce(h,x,S,M,$){if(h_(h))throw Error(r(485));if(h=x.action,h!==null){var Z={payload:$,action:h,next:null,isTransition:!0,status:\"pending\",value:null,reason:null,listeners:[],then:function(Ne){Z.listeners.push(Ne)}};ie.T!==null?S(!0):Z.isTransition=!1,M(Z),S=x.pending,S===null?(Z.next=x.pending=Z,rU(x,Z)):(Z.next=S.next,x.pending=S.next=Z)}}function rU(h,x){var S=x.action,M=x.payload,$=h.state;if(x.isTransition){var Z=ie.T,Ne={};ie.T=Ne;try{var He=S($,M),rt=ie.S;rt!==null&&rt(Ne,He),aU(h,x,He)}catch(kt){oj(h,x,kt)}finally{Z!==null&&Ne.types!==null&&(Z.types=Ne.types),ie.T=Z}}else try{Z=S($,M),aU(h,x,Z)}catch(kt){oj(h,x,kt)}}function aU(h,x,S){S!==null&&typeof S==\"object\"&&typeof S.then==\"function\"?S.then(function(M){iU(h,x,M)},function(M){return oj(h,x,M)}):iU(h,x,S)}function iU(h,x,S){x.status=\"fulfilled\",x.value=S,sU(x),h.state=S,x=h.pending,x!==null&&(S=x.next,S===x?h.pending=null:(S=S.next,x.next=S,rU(h,S)))}function oj(h,x,S){var M=h.pending;if(h.pending=null,M!==null){M=M.next;do x.status=\"rejected\",x.reason=S,sU(x),x=x.next;while(x!==M)}h.action=null}function sU(h){h=h.listeners;for(var x=0;x<h.length;x++)(0,h[x])()}function oU(h,x){return x}function lU(h,x){if(vr){var S=aa.formState;if(S!==null){e:{var M=Xn;if(vr){if(va){t:{for(var $=va,Z=El;$.nodeType!==8;){if(!Z){$=null;break t}if($=Fl($.nextSibling),$===null){$=null;break t}}Z=$.data,$=Z===\"F!\"||Z===\"F\"?$:null}if($){va=Fl($.nextSibling),M=$.data===\"F!\";break e}}qm(M)}M=!1}M&&(x=S[0])}}return S=Qs(),S.memoizedState=S.baseState=x,M={pending:null,lanes:0,dispatch:null,lastRenderedReducer:oU,lastRenderedState:x},S.queue=M,S=PU.bind(null,Xn,M),M.dispatch=S,M=sj(!1),Z=hj.bind(null,Xn,!1,M.queue),M=Qs(),$={state:x,dispatch:null,action:h,pending:null},M.queue=$,S=Nce.bind(null,Xn,$,Z,S),$.dispatch=S,M.memoizedState=h,[x,S,!1]}function cU(h){var x=si();return dU(x,Wr,h)}function dU(h,x,S){if(x=aj(h,x,oU)[0],h=d_(hu)[0],typeof x==\"object\"&&x!==null&&typeof x.then==\"function\")try{var M=sv(x)}catch(Ne){throw Ne===nb?e_:Ne}else M=x;x=si();var $=x.queue,Z=$.dispatch;return S!==x.memoizedState&&(Xn.flags|=2048,ob(9,{destroy:void 0},Cce.bind(null,$,S),null)),[M,Z,h]}function Cce(h,x){h.action=x}function uU(h){var x=si(),S=Wr;if(S!==null)return dU(x,S,h);si(),x=x.memoizedState,S=si();var M=S.queue.dispatch;return S.memoizedState=h,[x,M,!1]}function ob(h,x,S,M){return h={tag:h,create:S,deps:M,inst:x,next:null},x=Xn.updateQueue,x===null&&(x=l_(),Xn.updateQueue=x),S=x.lastEffect,S===null?x.lastEffect=h.next=h:(M=S.next,S.next=h,h.next=M,x.lastEffect=h),h}function mU(){return si().memoizedState}function u_(h,x,S,M){var $=Qs();Xn.flags|=h,$.memoizedState=ob(1|x,{destroy:void 0},S,M===void 0?null:M)}function m_(h,x,S,M){var $=si();M=M===void 0?null:M;var Z=$.memoizedState.inst;Wr!==null&&M!==null&&ZA(M,Wr.memoizedState.deps)?$.memoizedState=ob(x,Z,S,M):(Xn.flags|=h,$.memoizedState=ob(1|x,Z,S,M))}function hU(h,x){u_(8390656,8,h,x)}function lj(h,x){m_(2048,8,h,x)}function Pce(h){Xn.flags|=4;var x=Xn.updateQueue;if(x===null)x=l_(),Xn.updateQueue=x,x.events=[h];else{var S=x.events;S===null?x.events=[h]:S.push(h)}}function pU(h){var x=si().memoizedState;return Pce({ref:x,nextImpl:h}),function(){if((Lr&2)!==0)throw Error(r(440));return x.impl.apply(void 0,arguments)}}function fU(h,x){return m_(4,2,h,x)}function gU(h,x){return m_(4,4,h,x)}function bU(h,x){if(typeof x==\"function\"){h=h();var S=x(h);return function(){typeof S==\"function\"?S():x(null)}}if(x!=null)return h=h(),x.current=h,function(){x.current=null}}function xU(h,x,S){S=S!=null?S.concat([h]):null,m_(4,4,bU.bind(null,x,h),S)}function cj(){}function yU(h,x){var S=si();x=x===void 0?null:x;var M=S.memoizedState;return x!==null&&ZA(x,M[1])?M[0]:(S.memoizedState=[h,x],h)}function vU(h,x){var S=si();x=x===void 0?null:x;var M=S.memoizedState;if(x!==null&&ZA(x,M[1]))return M[0];if(M=h(),rf){Re(!0);try{h()}finally{Re(!1)}}return S.memoizedState=[M,x],M}function dj(h,x,S){return S===void 0||(mu&1073741824)!==0&&(br&261930)===0?h.memoizedState=x:(h.memoizedState=S,h=wB(),Xn.lanes|=h,Zm|=h,S)}function wU(h,x,S,M){return ko(S,x)?S:ab.current!==null?(h=dj(h,S,M),ko(h,x)||(fi=!0),h):(mu&42)===0||(mu&1073741824)!==0&&(br&261930)===0?(fi=!0,h.memoizedState=S):(h=wB(),Xn.lanes|=h,Zm|=h,x)}function SU(h,x,S,M,$){var Z=J.p;J.p=Z!==0&&8>Z?Z:8;var Ne=ie.T,He={};ie.T=He,hj(h,!1,x,S);try{var rt=$(),kt=ie.S;if(kt!==null&&kt(He,rt),rt!==null&&typeof rt==\"object\"&&typeof rt.then==\"function\"){var Ot=Sce(rt,M);ov(h,x,Ot,nl(h))}else ov(h,x,M,nl(h))}catch(zt){ov(h,x,{then:function(){},status:\"rejected\",reason:zt},nl())}finally{J.p=Z,Ne!==null&&He.types!==null&&(Ne.types=He.types),ie.T=Ne}}function Tce(){}function uj(h,x,S,M){if(h.tag!==5)throw Error(r(476));var $=_U(h).queue;SU(h,$,x,oe,S===null?Tce:function(){return kU(h),S(M)})}function _U(h){var x=h.memoizedState;if(x!==null)return x;x={memoizedState:oe,baseState:oe,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:oe},next:null};var S={};return x.next={memoizedState:S,baseState:S,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:S},next:null},h.memoizedState=x,h=h.alternate,h!==null&&(h.memoizedState=x),x}function kU(h){var x=_U(h);x.next===null&&(x=h.alternate.memoizedState),ov(h,x.next.queue,{},nl())}function mj(){return ss(kv)}function NU(){return si().memoizedState}function CU(){return si().memoizedState}function Ace(h){for(var x=h.return;x!==null;){switch(x.tag){case 24:case 3:var S=nl();h=Gm(S);var M=Wm(x,h,S);M!==null&&(Ao(M,x,S),nv(M,x,S)),x={cache:BA()},h.payload=x;return}x=x.return}}function jce(h,x,S){var M=nl();S={lane:M,revertLane:0,gesture:null,action:S,hasEagerState:!1,eagerState:null,next:null},h_(h)?TU(x,S):(S=jA(h,x,S,M),S!==null&&(Ao(S,h,M),AU(S,x,M)))}function PU(h,x,S){var M=nl();ov(h,x,S,M)}function ov(h,x,S,M){var $={lane:M,revertLane:0,gesture:null,action:S,hasEagerState:!1,eagerState:null,next:null};if(h_(h))TU(x,$);else{var Z=h.alternate;if(h.lanes===0&&(Z===null||Z.lanes===0)&&(Z=x.lastRenderedReducer,Z!==null))try{var Ne=x.lastRenderedState,He=Z(Ne,S);if($.hasEagerState=!0,$.eagerState=He,ko(He,Ne))return WS(h,x,$,0),aa===null&&GS(),!1}catch{}if(S=jA(h,x,$,M),S!==null)return Ao(S,h,M),AU(S,x,M),!0}return!1}function hj(h,x,S,M){if(M={lane:2,revertLane:Vj(),gesture:null,action:M,hasEagerState:!1,eagerState:null,next:null},h_(h)){if(x)throw Error(r(479))}else x=jA(h,S,M,2),x!==null&&Ao(x,h,2)}function h_(h){var x=h.alternate;return h===Xn||x!==null&&x===Xn}function TU(h,x){ib=s_=!0;var S=h.pending;S===null?x.next=x:(x.next=S.next,S.next=x),h.pending=x}function AU(h,x,S){if((S&4194048)!==0){var M=x.lanes;M&=h.pendingLanes,S|=M,x.lanes=S,Wt(h,S)}}var lv={readContext:ss,use:c_,useCallback:qa,useContext:qa,useEffect:qa,useImperativeHandle:qa,useLayoutEffect:qa,useInsertionEffect:qa,useMemo:qa,useReducer:qa,useRef:qa,useState:qa,useDebugValue:qa,useDeferredValue:qa,useTransition:qa,useSyncExternalStore:qa,useId:qa,useHostTransitionStatus:qa,useFormState:qa,useActionState:qa,useOptimistic:qa,useMemoCache:qa,useCacheRefresh:qa};lv.useEffectEvent=qa;var jU={readContext:ss,use:c_,useCallback:function(h,x){return Qs().memoizedState=[h,x===void 0?null:x],h},useContext:ss,useEffect:hU,useImperativeHandle:function(h,x,S){S=S!=null?S.concat([h]):null,u_(4194308,4,bU.bind(null,x,h),S)},useLayoutEffect:function(h,x){return u_(4194308,4,h,x)},useInsertionEffect:function(h,x){u_(4,2,h,x)},useMemo:function(h,x){var S=Qs();x=x===void 0?null:x;var M=h();if(rf){Re(!0);try{h()}finally{Re(!1)}}return S.memoizedState=[M,x],M},useReducer:function(h,x,S){var M=Qs();if(S!==void 0){var $=S(x);if(rf){Re(!0);try{S(x)}finally{Re(!1)}}}else $=x;return M.memoizedState=M.baseState=$,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:$},M.queue=h,h=h.dispatch=jce.bind(null,Xn,h),[M.memoizedState,h]},useRef:function(h){var x=Qs();return h={current:h},x.memoizedState=h},useState:function(h){h=sj(h);var x=h.queue,S=PU.bind(null,Xn,x);return x.dispatch=S,[h.memoizedState,S]},useDebugValue:cj,useDeferredValue:function(h,x){var S=Qs();return dj(S,h,x)},useTransition:function(){var h=sj(!1);return h=SU.bind(null,Xn,h.queue,!0,!1),Qs().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,x,S){var M=Xn,$=Qs();if(vr){if(S===void 0)throw Error(r(407));S=S()}else{if(S=x(),aa===null)throw Error(r(349));(br&127)!==0||Q5(M,x,S)}$.memoizedState=S;var Z={value:S,getSnapshot:x};return $.queue=Z,hU(J5.bind(null,M,Z,h),[h]),M.flags|=2048,ob(9,{destroy:void 0},Z5.bind(null,M,Z,S,x),null),S},useId:function(){var h=Qs(),x=aa.identifierPrefix;if(vr){var S=dd,M=cd;S=(M&~(1<<32-$e(M)-1)).toString(32)+S,x=\"_\"+x+\"R_\"+S,S=o_++,0<S&&(x+=\"H\"+S.toString(32)),x+=\"_\"}else S=_ce++,x=\"_\"+x+\"r_\"+S.toString(32)+\"_\";return h.memoizedState=x},useHostTransitionStatus:mj,useFormState:lU,useActionState:lU,useOptimistic:function(h){var x=Qs();x.memoizedState=x.baseState=h;var S={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return x.queue=S,x=hj.bind(null,Xn,!0,S),S.dispatch=x,[h,x]},useMemoCache:rj,useCacheRefresh:function(){return Qs().memoizedState=Ace.bind(null,Xn)},useEffectEvent:function(h){var x=Qs(),S={impl:h};return x.memoizedState=S,function(){if((Lr&2)!==0)throw Error(r(440));return S.impl.apply(void 0,arguments)}}},pj={readContext:ss,use:c_,useCallback:yU,useContext:ss,useEffect:lj,useImperativeHandle:xU,useInsertionEffect:fU,useLayoutEffect:gU,useMemo:vU,useReducer:d_,useRef:mU,useState:function(){return d_(hu)},useDebugValue:cj,useDeferredValue:function(h,x){var S=si();return wU(S,Wr.memoizedState,h,x)},useTransition:function(){var h=d_(hu)[0],x=si().memoizedState;return[typeof h==\"boolean\"?h:sv(h),x]},useSyncExternalStore:Y5,useId:NU,useHostTransitionStatus:mj,useFormState:cU,useActionState:cU,useOptimistic:function(h,x){var S=si();return nU(S,Wr,h,x)},useMemoCache:rj,useCacheRefresh:CU};pj.useEffectEvent=pU;var MU={readContext:ss,use:c_,useCallback:yU,useContext:ss,useEffect:lj,useImperativeHandle:xU,useInsertionEffect:fU,useLayoutEffect:gU,useMemo:vU,useReducer:ij,useRef:mU,useState:function(){return ij(hu)},useDebugValue:cj,useDeferredValue:function(h,x){var S=si();return Wr===null?dj(S,h,x):wU(S,Wr.memoizedState,h,x)},useTransition:function(){var h=ij(hu)[0],x=si().memoizedState;return[typeof h==\"boolean\"?h:sv(h),x]},useSyncExternalStore:Y5,useId:NU,useHostTransitionStatus:mj,useFormState:uU,useActionState:uU,useOptimistic:function(h,x){var S=si();return Wr!==null?nU(S,Wr,h,x):(S.baseState=h,[h,S.queue.dispatch])},useMemoCache:rj,useCacheRefresh:CU};MU.useEffectEvent=pU;function fj(h,x,S,M){x=h.memoizedState,S=S(M,x),S=S==null?x:m({},x,S),h.memoizedState=S,h.lanes===0&&(h.updateQueue.baseState=S)}var gj={enqueueSetState:function(h,x,S){h=h._reactInternals;var M=nl(),$=Gm(M);$.payload=x,S!=null&&($.callback=S),x=Wm(h,$,M),x!==null&&(Ao(x,h,M),nv(x,h,M))},enqueueReplaceState:function(h,x,S){h=h._reactInternals;var M=nl(),$=Gm(M);$.tag=1,$.payload=x,S!=null&&($.callback=S),x=Wm(h,$,M),x!==null&&(Ao(x,h,M),nv(x,h,M))},enqueueForceUpdate:function(h,x){h=h._reactInternals;var S=nl(),M=Gm(S);M.tag=2,x!=null&&(M.callback=x),x=Wm(h,M,S),x!==null&&(Ao(x,h,S),nv(x,h,S))}};function EU(h,x,S,M,$,Z,Ne){return h=h.stateNode,typeof h.shouldComponentUpdate==\"function\"?h.shouldComponentUpdate(M,Z,Ne):x.prototype&&x.prototype.isPureReactComponent?!Um(S,M)||!Um($,Z):!0}function DU(h,x,S,M){h=x.state,typeof x.componentWillReceiveProps==\"function\"&&x.componentWillReceiveProps(S,M),typeof x.UNSAFE_componentWillReceiveProps==\"function\"&&x.UNSAFE_componentWillReceiveProps(S,M),x.state!==h&&gj.enqueueReplaceState(x,x.state,null)}function af(h,x){var S=x;if(\"ref\"in x){S={};for(var M in x)M!==\"ref\"&&(S[M]=x[M])}if(h=h.defaultProps){S===x&&(S=m({},S));for(var $ in h)S[$]===void 0&&(S[$]=h[$])}return S}function FU(h){VS(h)}function RU(h){console.error(h)}function LU(h){VS(h)}function p_(h,x){try{var S=h.onUncaughtError;S(x.value,{componentStack:x.stack})}catch(M){setTimeout(function(){throw M})}}function OU(h,x,S){try{var M=h.onCaughtError;M(S.value,{componentStack:S.stack,errorBoundary:x.tag===1?x.stateNode:null})}catch($){setTimeout(function(){throw $})}}function bj(h,x,S){return S=Gm(S),S.tag=3,S.payload={element:null},S.callback=function(){p_(h,x)},S}function IU(h){return h=Gm(h),h.tag=3,h}function zU(h,x,S,M){var $=S.type.getDerivedStateFromError;if(typeof $==\"function\"){var Z=M.value;h.payload=function(){return $(Z)},h.callback=function(){OU(x,S,M)}}var Ne=S.stateNode;Ne!==null&&typeof Ne.componentDidCatch==\"function\"&&(h.callback=function(){OU(x,S,M),typeof $!=\"function\"&&(Jm===null?Jm=new Set([this]):Jm.add(this));var He=M.stack;this.componentDidCatch(M.value,{componentStack:He!==null?He:\"\"})})}function Mce(h,x,S,M,$){if(S.flags|=32768,M!==null&&typeof M==\"object\"&&typeof M.then==\"function\"){if(x=S.alternate,x!==null&&Jg(x,S,$,!0),S=Zo.current,S!==null){switch(S.tag){case 31:case 13:return Dl===null?C_():S.alternate===null&&$a===0&&($a=3),S.flags&=-257,S.flags|=65536,S.lanes=$,M===t_?S.flags|=16384:(x=S.updateQueue,x===null?S.updateQueue=new Set([M]):x.add(M),Hj(h,M,$)),!1;case 22:return S.flags|=65536,M===t_?S.flags|=16384:(x=S.updateQueue,x===null?(x={transitions:null,markerInstances:null,retryQueue:new Set([M])},S.updateQueue=x):(S=x.retryQueue,S===null?x.retryQueue=new Set([M]):S.add(M)),Hj(h,M,$)),!1}throw Error(r(435,S.tag))}return Hj(h,M,$),C_(),!1}if(vr)return x=Zo.current,x!==null?((x.flags&65536)===0&&(x.flags|=256),x.flags|=65536,x.lanes=$,M!==LA&&(h=Error(r(422),{cause:M}),Qy(Al(h,S)))):(M!==LA&&(x=Error(r(423),{cause:M}),Qy(Al(x,S))),h=h.current.alternate,h.flags|=65536,$&=-$,h.lanes|=$,M=Al(M,S),$=bj(h.stateNode,M,$),WA(h,$),$a!==4&&($a=2)),!1;var Z=Error(r(520),{cause:M});if(Z=Al(Z,S),gv===null?gv=[Z]:gv.push(Z),$a!==4&&($a=2),x===null)return!0;M=Al(M,S),S=x;do{switch(S.tag){case 3:return S.flags|=65536,h=$&-$,S.lanes|=h,h=bj(S.stateNode,M,h),WA(S,h),!1;case 1:if(x=S.type,Z=S.stateNode,(S.flags&128)===0&&(typeof x.getDerivedStateFromError==\"function\"||Z!==null&&typeof Z.componentDidCatch==\"function\"&&(Jm===null||!Jm.has(Z))))return S.flags|=65536,$&=-$,S.lanes|=$,$=IU($),zU($,h,S,M),WA(S,$),!1}S=S.return}while(S!==null);return!1}var xj=Error(r(461)),fi=!1;function ls(h,x,S,M){x.child=h===null?q5(x,null,S,M):nf(x,h.child,S,M)}function UU(h,x,S,M,$){S=S.render;var Z=x.ref;if(\"ref\"in M){var Ne={};for(var He in M)He!==\"ref\"&&(Ne[He]=M[He])}else Ne=M;return Zp(x),M=JA(h,x,S,Ne,Z,$),He=ej(),h!==null&&!fi?(tj(h,x,$),pu(h,x,$)):(vr&&He&&FA(x),x.flags|=1,ls(h,x,M,$),x.child)}function BU(h,x,S,M,$){if(h===null){var Z=S.type;return typeof Z==\"function\"&&!MA(Z)&&Z.defaultProps===void 0&&S.compare===null?(x.tag=15,x.type=Z,HU(h,x,Z,M,$)):(h=XS(S.type,null,M,x,x.mode,$),h.ref=x.ref,h.return=x,x.child=h)}if(Z=h.child,!Cj(h,$)){var Ne=Z.memoizedProps;if(S=S.compare,S=S!==null?S:Um,S(Ne,M)&&h.ref===x.ref)return pu(h,x,$)}return x.flags|=1,h=lu(Z,M),h.ref=x.ref,h.return=x,x.child=h}function HU(h,x,S,M,$){if(h!==null){var Z=h.memoizedProps;if(Um(Z,M)&&h.ref===x.ref)if(fi=!1,x.pendingProps=M=Z,Cj(h,$))(h.flags&131072)!==0&&(fi=!0);else return x.lanes=h.lanes,pu(h,x,$)}return yj(h,x,S,M,$)}function qU(h,x,S,M){var $=M.children,Z=h!==null?h.memoizedState:null;if(h===null&&x.stateNode===null&&(x.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),M.mode===\"hidden\"){if((x.flags&128)!==0){if(Z=Z!==null?Z.baseLanes|S:S,h!==null){for(M=x.child=h.child,$=0;M!==null;)$=$|M.lanes|M.childLanes,M=M.sibling;M=$&~Z}else M=0,x.child=null;return $U(h,x,Z,S,M)}if((S&536870912)!==0)x.memoizedState={baseLanes:0,cachePool:null},h!==null&&JS(x,Z!==null?Z.cachePool:null),Z!==null?G5(x,Z):XA(),W5(x);else return M=x.lanes=536870912,$U(h,x,Z!==null?Z.baseLanes|S:S,S,M)}else Z!==null?(JS(x,Z.cachePool),G5(x,Z),Xm(),x.memoizedState=null):(h!==null&&JS(x,null),XA(),Xm());return ls(h,x,$,S),x.child}function cv(h,x){return h!==null&&h.tag===22||x.stateNode!==null||(x.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),x.sibling}function $U(h,x,S,M,$){var Z=qA();return Z=Z===null?null:{parent:hi._currentValue,pool:Z},x.memoizedState={baseLanes:S,cachePool:Z},h!==null&&JS(x,null),XA(),W5(x),h!==null&&Jg(h,x,M,!0),x.childLanes=$,null}function f_(h,x){return x=b_({mode:x.mode,children:x.children},h.mode),x.ref=h.ref,h.child=x,x.return=h,x}function VU(h,x,S){return nf(x,h.child,null,S),h=f_(x,x.pendingProps),h.flags|=2,Jo(x),x.memoizedState=null,h}function Ece(h,x,S){var M=x.pendingProps,$=(x.flags&128)!==0;if(x.flags&=-129,h===null){if(vr){if(M.mode===\"hidden\")return h=f_(x,M),x.lanes=536870912,cv(null,h);if(QA(x),(h=va)?(h=r8(h,El),h=h!==null&&h.data===\"&\"?h:null,h!==null&&(x.memoizedState={dehydrated:h,treeContext:Bm!==null?{id:cd,overflow:dd}:null,retryLane:536870912,hydrationErrors:null},S=T5(h),S.return=x,x.child=S,is=x,va=null)):h=null,h===null)throw qm(x);return x.lanes=536870912,null}return f_(x,M)}var Z=h.memoizedState;if(Z!==null){var Ne=Z.dehydrated;if(QA(x),$)if(x.flags&256)x.flags&=-257,x=VU(h,x,S);else if(x.memoizedState!==null)x.child=h.child,x.flags|=128,x=null;else throw Error(r(558));else if(fi||Jg(h,x,S,!1),$=(S&h.childLanes)!==0,fi||$){if(M=aa,M!==null&&(Ne=Zt(M,S),Ne!==0&&Ne!==Z.retryLane))throw Z.retryLane=Ne,Kp(h,Ne),Ao(M,h,Ne),xj;C_(),x=VU(h,x,S)}else h=Z.treeContext,va=Fl(Ne.nextSibling),is=x,vr=!0,Hm=null,El=!1,h!==null&&M5(x,h),x=f_(x,M),x.flags|=4096;return x}return h=lu(h.child,{mode:M.mode,children:M.children}),h.ref=x.ref,x.child=h,h.return=x,h}function g_(h,x){var S=x.ref;if(S===null)h!==null&&h.ref!==null&&(x.flags|=4194816);else{if(typeof S!=\"function\"&&typeof S!=\"object\")throw Error(r(284));(h===null||h.ref!==S)&&(x.flags|=4194816)}}function yj(h,x,S,M,$){return Zp(x),S=JA(h,x,S,M,void 0,$),M=ej(),h!==null&&!fi?(tj(h,x,$),pu(h,x,$)):(vr&&M&&FA(x),x.flags|=1,ls(h,x,S,$),x.child)}function GU(h,x,S,M,$,Z){return Zp(x),x.updateQueue=null,S=X5(x,M,S,$),K5(h),M=ej(),h!==null&&!fi?(tj(h,x,Z),pu(h,x,Z)):(vr&&M&&FA(x),x.flags|=1,ls(h,x,S,Z),x.child)}function WU(h,x,S,M,$){if(Zp(x),x.stateNode===null){var Z=Xg,Ne=S.contextType;typeof Ne==\"object\"&&Ne!==null&&(Z=ss(Ne)),Z=new S(M,Z),x.memoizedState=Z.state!==null&&Z.state!==void 0?Z.state:null,Z.updater=gj,x.stateNode=Z,Z._reactInternals=x,Z=x.stateNode,Z.props=M,Z.state=x.memoizedState,Z.refs={},VA(x),Ne=S.contextType,Z.context=typeof Ne==\"object\"&&Ne!==null?ss(Ne):Xg,Z.state=x.memoizedState,Ne=S.getDerivedStateFromProps,typeof Ne==\"function\"&&(fj(x,S,Ne,M),Z.state=x.memoizedState),typeof S.getDerivedStateFromProps==\"function\"||typeof Z.getSnapshotBeforeUpdate==\"function\"||typeof Z.UNSAFE_componentWillMount!=\"function\"&&typeof Z.componentWillMount!=\"function\"||(Ne=Z.state,typeof Z.componentWillMount==\"function\"&&Z.componentWillMount(),typeof Z.UNSAFE_componentWillMount==\"function\"&&Z.UNSAFE_componentWillMount(),Ne!==Z.state&&gj.enqueueReplaceState(Z,Z.state,null),av(x,M,Z,$),rv(),Z.state=x.memoizedState),typeof Z.componentDidMount==\"function\"&&(x.flags|=4194308),M=!0}else if(h===null){Z=x.stateNode;var He=x.memoizedProps,rt=af(S,He);Z.props=rt;var kt=Z.context,Ot=S.contextType;Ne=Xg,typeof Ot==\"object\"&&Ot!==null&&(Ne=ss(Ot));var zt=S.getDerivedStateFromProps;Ot=typeof zt==\"function\"||typeof Z.getSnapshotBeforeUpdate==\"function\",He=x.pendingProps!==He,Ot||typeof Z.UNSAFE_componentWillReceiveProps!=\"function\"&&typeof Z.componentWillReceiveProps!=\"function\"||(He||kt!==Ne)&&DU(x,Z,M,Ne),Vm=!1;var Nt=x.memoizedState;Z.state=Nt,av(x,M,Z,$),rv(),kt=x.memoizedState,He||Nt!==kt||Vm?(typeof zt==\"function\"&&(fj(x,S,zt,M),kt=x.memoizedState),(rt=Vm||EU(x,S,rt,M,Nt,kt,Ne))?(Ot||typeof Z.UNSAFE_componentWillMount!=\"function\"&&typeof Z.componentWillMount!=\"function\"||(typeof Z.componentWillMount==\"function\"&&Z.componentWillMount(),typeof Z.UNSAFE_componentWillMount==\"function\"&&Z.UNSAFE_componentWillMount()),typeof Z.componentDidMount==\"function\"&&(x.flags|=4194308)):(typeof Z.componentDidMount==\"function\"&&(x.flags|=4194308),x.memoizedProps=M,x.memoizedState=kt),Z.props=M,Z.state=kt,Z.context=Ne,M=rt):(typeof Z.componentDidMount==\"function\"&&(x.flags|=4194308),M=!1)}else{Z=x.stateNode,GA(h,x),Ne=x.memoizedProps,Ot=af(S,Ne),Z.props=Ot,zt=x.pendingProps,Nt=Z.context,kt=S.contextType,rt=Xg,typeof kt==\"object\"&&kt!==null&&(rt=ss(kt)),He=S.getDerivedStateFromProps,(kt=typeof He==\"function\"||typeof Z.getSnapshotBeforeUpdate==\"function\")||typeof Z.UNSAFE_componentWillReceiveProps!=\"function\"&&typeof Z.componentWillReceiveProps!=\"function\"||(Ne!==zt||Nt!==rt)&&DU(x,Z,M,rt),Vm=!1,Nt=x.memoizedState,Z.state=Nt,av(x,M,Z,$),rv();var Dt=x.memoizedState;Ne!==zt||Nt!==Dt||Vm||h!==null&&h.dependencies!==null&&QS(h.dependencies)?(typeof He==\"function\"&&(fj(x,S,He,M),Dt=x.memoizedState),(Ot=Vm||EU(x,S,Ot,M,Nt,Dt,rt)||h!==null&&h.dependencies!==null&&QS(h.dependencies))?(kt||typeof Z.UNSAFE_componentWillUpdate!=\"function\"&&typeof Z.componentWillUpdate!=\"function\"||(typeof Z.componentWillUpdate==\"function\"&&Z.componentWillUpdate(M,Dt,rt),typeof Z.UNSAFE_componentWillUpdate==\"function\"&&Z.UNSAFE_componentWillUpdate(M,Dt,rt)),typeof Z.componentDidUpdate==\"function\"&&(x.flags|=4),typeof Z.getSnapshotBeforeUpdate==\"function\"&&(x.flags|=1024)):(typeof Z.componentDidUpdate!=\"function\"||Ne===h.memoizedProps&&Nt===h.memoizedState||(x.flags|=4),typeof Z.getSnapshotBeforeUpdate!=\"function\"||Ne===h.memoizedProps&&Nt===h.memoizedState||(x.flags|=1024),x.memoizedProps=M,x.memoizedState=Dt),Z.props=M,Z.state=Dt,Z.context=rt,M=Ot):(typeof Z.componentDidUpdate!=\"function\"||Ne===h.memoizedProps&&Nt===h.memoizedState||(x.flags|=4),typeof Z.getSnapshotBeforeUpdate!=\"function\"||Ne===h.memoizedProps&&Nt===h.memoizedState||(x.flags|=1024),M=!1)}return Z=M,g_(h,x),M=(x.flags&128)!==0,Z||M?(Z=x.stateNode,S=M&&typeof S.getDerivedStateFromError!=\"function\"?null:Z.render(),x.flags|=1,h!==null&&M?(x.child=nf(x,h.child,null,$),x.child=nf(x,null,S,$)):ls(h,x,S,$),x.memoizedState=Z.state,h=x.child):h=pu(h,x,$),h}function KU(h,x,S,M){return Yp(),x.flags|=256,ls(h,x,S,M),x.child}var vj={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function wj(h){return{baseLanes:h,cachePool:O5()}}function Sj(h,x,S){return h=h!==null?h.childLanes&~S:0,x&&(h|=tl),h}function XU(h,x,S){var M=x.pendingProps,$=!1,Z=(x.flags&128)!==0,Ne;if((Ne=Z)||(Ne=h!==null&&h.memoizedState===null?!1:(ii.current&2)!==0),Ne&&($=!0,x.flags&=-129),Ne=(x.flags&32)!==0,x.flags&=-33,h===null){if(vr){if($?Km(x):Xm(),(h=va)?(h=r8(h,El),h=h!==null&&h.data!==\"&\"?h:null,h!==null&&(x.memoizedState={dehydrated:h,treeContext:Bm!==null?{id:cd,overflow:dd}:null,retryLane:536870912,hydrationErrors:null},S=T5(h),S.return=x,x.child=S,is=x,va=null)):h=null,h===null)throw qm(x);return aM(h)?x.lanes=32:x.lanes=536870912,null}var He=M.children;return M=M.fallback,$?(Xm(),$=x.mode,He=b_({mode:\"hidden\",children:He},$),M=Xp(M,$,S,null),He.return=x,M.return=x,He.sibling=M,x.child=He,M=x.child,M.memoizedState=wj(S),M.childLanes=Sj(h,Ne,S),x.memoizedState=vj,cv(null,M)):(Km(x),_j(x,He))}var rt=h.memoizedState;if(rt!==null&&(He=rt.dehydrated,He!==null)){if(Z)x.flags&256?(Km(x),x.flags&=-257,x=kj(h,x,S)):x.memoizedState!==null?(Xm(),x.child=h.child,x.flags|=128,x=null):(Xm(),He=M.fallback,$=x.mode,M=b_({mode:\"visible\",children:M.children},$),He=Xp(He,$,S,null),He.flags|=2,M.return=x,He.return=x,M.sibling=He,x.child=M,nf(x,h.child,null,S),M=x.child,M.memoizedState=wj(S),M.childLanes=Sj(h,Ne,S),x.memoizedState=vj,x=cv(null,M));else if(Km(x),aM(He)){if(Ne=He.nextSibling&&He.nextSibling.dataset,Ne)var kt=Ne.dgst;Ne=kt,M=Error(r(419)),M.stack=\"\",M.digest=Ne,Qy({value:M,source:null,stack:null}),x=kj(h,x,S)}else if(fi||Jg(h,x,S,!1),Ne=(S&h.childLanes)!==0,fi||Ne){if(Ne=aa,Ne!==null&&(M=Zt(Ne,S),M!==0&&M!==rt.retryLane))throw rt.retryLane=M,Kp(h,M),Ao(Ne,h,M),xj;rM(He)||C_(),x=kj(h,x,S)}else rM(He)?(x.flags|=192,x.child=h.child,x=null):(h=rt.treeContext,va=Fl(He.nextSibling),is=x,vr=!0,Hm=null,El=!1,h!==null&&M5(x,h),x=_j(x,M.children),x.flags|=4096);return x}return $?(Xm(),He=M.fallback,$=x.mode,rt=h.child,kt=rt.sibling,M=lu(rt,{mode:\"hidden\",children:M.children}),M.subtreeFlags=rt.subtreeFlags&65011712,kt!==null?He=lu(kt,He):(He=Xp(He,$,S,null),He.flags|=2),He.return=x,M.return=x,M.sibling=He,x.child=M,cv(null,M),M=x.child,He=h.child.memoizedState,He===null?He=wj(S):($=He.cachePool,$!==null?(rt=hi._currentValue,$=$.parent!==rt?{parent:rt,pool:rt}:$):$=O5(),He={baseLanes:He.baseLanes|S,cachePool:$}),M.memoizedState=He,M.childLanes=Sj(h,Ne,S),x.memoizedState=vj,cv(h.child,M)):(Km(x),S=h.child,h=S.sibling,S=lu(S,{mode:\"visible\",children:M.children}),S.return=x,S.sibling=null,h!==null&&(Ne=x.deletions,Ne===null?(x.deletions=[h],x.flags|=16):Ne.push(h)),x.child=S,x.memoizedState=null,S)}function _j(h,x){return x=b_({mode:\"visible\",children:x},h.mode),x.return=h,h.child=x}function b_(h,x){return h=Qo(22,h,null,x),h.lanes=0,h}function kj(h,x,S){return nf(x,h.child,null,S),h=_j(x,x.pendingProps.children),h.flags|=2,x.memoizedState=null,h}function YU(h,x,S){h.lanes|=x;var M=h.alternate;M!==null&&(M.lanes|=x),zA(h.return,x,S)}function Nj(h,x,S,M,$,Z){var Ne=h.memoizedState;Ne===null?h.memoizedState={isBackwards:x,rendering:null,renderingStartTime:0,last:M,tail:S,tailMode:$,treeForkCount:Z}:(Ne.isBackwards=x,Ne.rendering=null,Ne.renderingStartTime=0,Ne.last=M,Ne.tail=S,Ne.tailMode=$,Ne.treeForkCount=Z)}function QU(h,x,S){var M=x.pendingProps,$=M.revealOrder,Z=M.tail;M=M.children;var Ne=ii.current,He=(Ne&2)!==0;if(He?(Ne=Ne&1|2,x.flags|=128):Ne&=1,me(ii,Ne),ls(h,x,M,S),M=vr?Yy:0,!He&&h!==null&&(h.flags&128)!==0)e:for(h=x.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&YU(h,S,x);else if(h.tag===19)YU(h,S,x);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===x)break e;for(;h.sibling===null;){if(h.return===null||h.return===x)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch($){case\"forwards\":for(S=x.child,$=null;S!==null;)h=S.alternate,h!==null&&i_(h)===null&&($=S),S=S.sibling;S=$,S===null?($=x.child,x.child=null):($=S.sibling,S.sibling=null),Nj(x,!1,$,S,Z,M);break;case\"backwards\":case\"unstable_legacy-backwards\":for(S=null,$=x.child,x.child=null;$!==null;){if(h=$.alternate,h!==null&&i_(h)===null){x.child=$;break}h=$.sibling,$.sibling=S,S=$,$=h}Nj(x,!0,S,null,Z,M);break;case\"together\":Nj(x,!1,null,null,void 0,M);break;default:x.memoizedState=null}return x.child}function pu(h,x,S){if(h!==null&&(x.dependencies=h.dependencies),Zm|=x.lanes,(S&x.childLanes)===0)if(h!==null){if(Jg(h,x,S,!1),(S&x.childLanes)===0)return null}else return null;if(h!==null&&x.child!==h.child)throw Error(r(153));if(x.child!==null){for(h=x.child,S=lu(h,h.pendingProps),x.child=S,S.return=x;h.sibling!==null;)h=h.sibling,S=S.sibling=lu(h,h.pendingProps),S.return=x;S.sibling=null}return x.child}function Cj(h,x){return(h.lanes&x)!==0?!0:(h=h.dependencies,!!(h!==null&&QS(h)))}function Dce(h,x,S){switch(x.tag){case 3:E(x,x.stateNode.containerInfo),$m(x,hi,h.memoizedState.cache),Yp();break;case 27:case 5:O(x);break;case 4:E(x,x.stateNode.containerInfo);break;case 10:$m(x,x.type,x.memoizedProps.value);break;case 31:if(x.memoizedState!==null)return x.flags|=128,QA(x),null;break;case 13:var M=x.memoizedState;if(M!==null)return M.dehydrated!==null?(Km(x),x.flags|=128,null):(S&x.child.childLanes)!==0?XU(h,x,S):(Km(x),h=pu(h,x,S),h!==null?h.sibling:null);Km(x);break;case 19:var $=(h.flags&128)!==0;if(M=(S&x.childLanes)!==0,M||(Jg(h,x,S,!1),M=(S&x.childLanes)!==0),$){if(M)return QU(h,x,S);x.flags|=128}if($=x.memoizedState,$!==null&&($.rendering=null,$.tail=null,$.lastEffect=null),me(ii,ii.current),M)break;return null;case 22:return x.lanes=0,qU(h,x,S,x.pendingProps);case 24:$m(x,hi,h.memoizedState.cache)}return pu(h,x,S)}function ZU(h,x,S){if(h!==null)if(h.memoizedProps!==x.pendingProps)fi=!0;else{if(!Cj(h,S)&&(x.flags&128)===0)return fi=!1,Dce(h,x,S);fi=(h.flags&131072)!==0}else fi=!1,vr&&(x.flags&1048576)!==0&&j5(x,Yy,x.index);switch(x.lanes=0,x.tag){case 16:e:{var M=x.pendingProps;if(h=ef(x.elementType),x.type=h,typeof h==\"function\")MA(h)?(M=af(h,M),x.tag=1,x=WU(null,x,h,M,S)):(x.tag=0,x=yj(null,x,h,M,S));else{if(h!=null){var $=h.$$typeof;if($===P){x.tag=11,x=UU(null,x,h,M,S);break e}else if($===T){x.tag=14,x=BU(null,x,h,M,S);break e}}throw x=L(h)||h,Error(r(306,x,\"\"))}}return x;case 0:return yj(h,x,x.type,x.pendingProps,S);case 1:return M=x.type,$=af(M,x.pendingProps),WU(h,x,M,$,S);case 3:e:{if(E(x,x.stateNode.containerInfo),h===null)throw Error(r(387));M=x.pendingProps;var Z=x.memoizedState;$=Z.element,GA(h,x),av(x,M,null,S);var Ne=x.memoizedState;if(M=Ne.cache,$m(x,hi,M),M!==Z.cache&&UA(x,[hi],S,!0),rv(),M=Ne.element,Z.isDehydrated)if(Z={element:M,isDehydrated:!1,cache:Ne.cache},x.updateQueue.baseState=Z,x.memoizedState=Z,x.flags&256){x=KU(h,x,M,S);break e}else if(M!==$){$=Al(Error(r(424)),x),Qy($),x=KU(h,x,M,S);break e}else for(h=x.stateNode.containerInfo,h.nodeType===9?h=h.body:h=h.nodeName===\"HTML\"?h.ownerDocument.body:h,va=Fl(h.firstChild),is=x,vr=!0,Hm=null,El=!0,S=q5(x,null,M,S),x.child=S;S;)S.flags=S.flags&-3|4096,S=S.sibling;else{if(Yp(),M===$){x=pu(h,x,S);break e}ls(h,x,M,S)}x=x.child}return x;case 26:return g_(h,x),h===null?(S=c8(x.type,null,x.pendingProps,null))?x.memoizedState=S:vr||(S=x.type,h=x.pendingProps,M=D_(q.current).createElement(S),M[at]=x,M[Lt]=h,cs(M,S,h),Je(M),x.stateNode=M):x.memoizedState=c8(x.type,h.memoizedProps,x.pendingProps,h.memoizedState),null;case 27:return O(x),h===null&&vr&&(M=x.stateNode=s8(x.type,x.pendingProps,q.current),is=x,El=!0,$=va,rh(x.type)?(iM=$,va=Fl(M.firstChild)):va=$),ls(h,x,x.pendingProps.children,S),g_(h,x),h===null&&(x.flags|=4194304),x.child;case 5:return h===null&&vr&&(($=M=va)&&(M=cde(M,x.type,x.pendingProps,El),M!==null?(x.stateNode=M,is=x,va=Fl(M.firstChild),El=!1,$=!0):$=!1),$||qm(x)),O(x),$=x.type,Z=x.pendingProps,Ne=h!==null?h.memoizedProps:null,M=Z.children,eM($,Z)?M=null:Ne!==null&&eM($,Ne)&&(x.flags|=32),x.memoizedState!==null&&($=JA(h,x,kce,null,null,S),kv._currentValue=$),g_(h,x),ls(h,x,M,S),x.child;case 6:return h===null&&vr&&((h=S=va)&&(S=dde(S,x.pendingProps,El),S!==null?(x.stateNode=S,is=x,va=null,h=!0):h=!1),h||qm(x)),null;case 13:return XU(h,x,S);case 4:return E(x,x.stateNode.containerInfo),M=x.pendingProps,h===null?x.child=nf(x,null,M,S):ls(h,x,M,S),x.child;case 11:return UU(h,x,x.type,x.pendingProps,S);case 7:return ls(h,x,x.pendingProps,S),x.child;case 8:return ls(h,x,x.pendingProps.children,S),x.child;case 12:return ls(h,x,x.pendingProps.children,S),x.child;case 10:return M=x.pendingProps,$m(x,x.type,M.value),ls(h,x,M.children,S),x.child;case 9:return $=x.type._context,M=x.pendingProps.children,Zp(x),$=ss($),M=M($),x.flags|=1,ls(h,x,M,S),x.child;case 14:return BU(h,x,x.type,x.pendingProps,S);case 15:return HU(h,x,x.type,x.pendingProps,S);case 19:return QU(h,x,S);case 31:return Ece(h,x,S);case 22:return qU(h,x,S,x.pendingProps);case 24:return Zp(x),M=ss(hi),h===null?($=qA(),$===null&&($=aa,Z=BA(),$.pooledCache=Z,Z.refCount++,Z!==null&&($.pooledCacheLanes|=S),$=Z),x.memoizedState={parent:M,cache:$},VA(x),$m(x,hi,$)):((h.lanes&S)!==0&&(GA(h,x),av(x,null,null,S),rv()),$=h.memoizedState,Z=x.memoizedState,$.parent!==M?($={parent:M,cache:M},x.memoizedState=$,x.lanes===0&&(x.memoizedState=x.updateQueue.baseState=$),$m(x,hi,M)):(M=Z.cache,$m(x,hi,M),M!==$.cache&&UA(x,[hi],S,!0))),ls(h,x,x.pendingProps.children,S),x.child;case 29:throw x.pendingProps}throw Error(r(156,x.tag))}function fu(h){h.flags|=4}function Pj(h,x,S,M,$){if((x=(h.mode&32)!==0)&&(x=!1),x){if(h.flags|=16777216,($&335544128)===$)if(h.stateNode.complete)h.flags|=8192;else if(NB())h.flags|=8192;else throw tf=t_,$A}else h.flags&=-16777217}function JU(h,x){if(x.type!==\"stylesheet\"||(x.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!p8(x))if(NB())h.flags|=8192;else throw tf=t_,$A}function x_(h,x){x!==null&&(h.flags|=4),h.flags&16384&&(x=h.tag!==22?Qe():536870912,h.lanes|=x,ub|=x)}function dv(h,x){if(!vr)switch(h.tailMode){case\"hidden\":x=h.tail;for(var S=null;x!==null;)x.alternate!==null&&(S=x),x=x.sibling;S===null?h.tail=null:S.sibling=null;break;case\"collapsed\":S=h.tail;for(var M=null;S!==null;)S.alternate!==null&&(M=S),S=S.sibling;M===null?x||h.tail===null?h.tail=null:h.tail.sibling=null:M.sibling=null}}function wa(h){var x=h.alternate!==null&&h.alternate.child===h.child,S=0,M=0;if(x)for(var $=h.child;$!==null;)S|=$.lanes|$.childLanes,M|=$.subtreeFlags&65011712,M|=$.flags&65011712,$.return=h,$=$.sibling;else for($=h.child;$!==null;)S|=$.lanes|$.childLanes,M|=$.subtreeFlags,M|=$.flags,$.return=h,$=$.sibling;return h.subtreeFlags|=M,h.childLanes=S,x}function Fce(h,x,S){var M=x.pendingProps;switch(RA(x),x.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return wa(x),null;case 1:return wa(x),null;case 3:return S=x.stateNode,M=null,h!==null&&(M=h.memoizedState.cache),x.memoizedState.cache!==M&&(x.flags|=2048),uu(hi),j(),S.pendingContext&&(S.context=S.pendingContext,S.pendingContext=null),(h===null||h.child===null)&&(Zg(x)?fu(x):h===null||h.memoizedState.isDehydrated&&(x.flags&256)===0||(x.flags|=1024,OA())),wa(x),null;case 26:var $=x.type,Z=x.memoizedState;return h===null?(fu(x),Z!==null?(wa(x),JU(x,Z)):(wa(x),Pj(x,$,null,M,S))):Z?Z!==h.memoizedState?(fu(x),wa(x),JU(x,Z)):(wa(x),x.flags&=-16777217):(h=h.memoizedProps,h!==M&&fu(x),wa(x),Pj(x,$,h,M,S)),null;case 27:if(K(x),S=q.current,$=x.type,h!==null&&x.stateNode!=null)h.memoizedProps!==M&&fu(x);else{if(!M){if(x.stateNode===null)throw Error(r(166));return wa(x),null}h=be.current,Zg(x)?E5(x):(h=s8($,M,S),x.stateNode=h,fu(x))}return wa(x),null;case 5:if(K(x),$=x.type,h!==null&&x.stateNode!=null)h.memoizedProps!==M&&fu(x);else{if(!M){if(x.stateNode===null)throw Error(r(166));return wa(x),null}if(Z=be.current,Zg(x))E5(x);else{var Ne=D_(q.current);switch(Z){case 1:Z=Ne.createElementNS(\"http://www.w3.org/2000/svg\",$);break;case 2:Z=Ne.createElementNS(\"http://www.w3.org/1998/Math/MathML\",$);break;default:switch($){case\"svg\":Z=Ne.createElementNS(\"http://www.w3.org/2000/svg\",$);break;case\"math\":Z=Ne.createElementNS(\"http://www.w3.org/1998/Math/MathML\",$);break;case\"script\":Z=Ne.createElement(\"div\"),Z.innerHTML=\"<script><\\/script>\",Z=Z.removeChild(Z.firstChild);break;case\"select\":Z=typeof M.is==\"string\"?Ne.createElement(\"select\",{is:M.is}):Ne.createElement(\"select\"),M.multiple?Z.multiple=!0:M.size&&(Z.size=M.size);break;default:Z=typeof M.is==\"string\"?Ne.createElement($,{is:M.is}):Ne.createElement($)}}Z[at]=x,Z[Lt]=M;e:for(Ne=x.child;Ne!==null;){if(Ne.tag===5||Ne.tag===6)Z.appendChild(Ne.stateNode);else if(Ne.tag!==4&&Ne.tag!==27&&Ne.child!==null){Ne.child.return=Ne,Ne=Ne.child;continue}if(Ne===x)break e;for(;Ne.sibling===null;){if(Ne.return===null||Ne.return===x)break e;Ne=Ne.return}Ne.sibling.return=Ne.return,Ne=Ne.sibling}x.stateNode=Z;e:switch(cs(Z,$,M),$){case\"button\":case\"input\":case\"select\":case\"textarea\":M=!!M.autoFocus;break e;case\"img\":M=!0;break e;default:M=!1}M&&fu(x)}}return wa(x),Pj(x,x.type,h===null?null:h.memoizedProps,x.pendingProps,S),null;case 6:if(h&&x.stateNode!=null)h.memoizedProps!==M&&fu(x);else{if(typeof M!=\"string\"&&x.stateNode===null)throw Error(r(166));if(h=q.current,Zg(x)){if(h=x.stateNode,S=x.memoizedProps,M=null,$=is,$!==null)switch($.tag){case 27:case 5:M=$.memoizedProps}h[at]=x,h=!!(h.nodeValue===S||M!==null&&M.suppressHydrationWarning===!0||XB(h.nodeValue,S)),h||qm(x,!0)}else h=D_(h).createTextNode(M),h[at]=x,x.stateNode=h}return wa(x),null;case 31:if(S=x.memoizedState,h===null||h.memoizedState!==null){if(M=Zg(x),S!==null){if(h===null){if(!M)throw Error(r(318));if(h=x.memoizedState,h=h!==null?h.dehydrated:null,!h)throw Error(r(557));h[at]=x}else Yp(),(x.flags&128)===0&&(x.memoizedState=null),x.flags|=4;wa(x),h=!1}else S=OA(),h!==null&&h.memoizedState!==null&&(h.memoizedState.hydrationErrors=S),h=!0;if(!h)return x.flags&256?(Jo(x),x):(Jo(x),null);if((x.flags&128)!==0)throw Error(r(558))}return wa(x),null;case 13:if(M=x.memoizedState,h===null||h.memoizedState!==null&&h.memoizedState.dehydrated!==null){if($=Zg(x),M!==null&&M.dehydrated!==null){if(h===null){if(!$)throw Error(r(318));if($=x.memoizedState,$=$!==null?$.dehydrated:null,!$)throw Error(r(317));$[at]=x}else Yp(),(x.flags&128)===0&&(x.memoizedState=null),x.flags|=4;wa(x),$=!1}else $=OA(),h!==null&&h.memoizedState!==null&&(h.memoizedState.hydrationErrors=$),$=!0;if(!$)return x.flags&256?(Jo(x),x):(Jo(x),null)}return Jo(x),(x.flags&128)!==0?(x.lanes=S,x):(S=M!==null,h=h!==null&&h.memoizedState!==null,S&&(M=x.child,$=null,M.alternate!==null&&M.alternate.memoizedState!==null&&M.alternate.memoizedState.cachePool!==null&&($=M.alternate.memoizedState.cachePool.pool),Z=null,M.memoizedState!==null&&M.memoizedState.cachePool!==null&&(Z=M.memoizedState.cachePool.pool),Z!==$&&(M.flags|=2048)),S!==h&&S&&(x.child.flags|=8192),x_(x,x.updateQueue),wa(x),null);case 4:return j(),h===null&&Xj(x.stateNode.containerInfo),wa(x),null;case 10:return uu(x.type),wa(x),null;case 19:if(ne(ii),M=x.memoizedState,M===null)return wa(x),null;if($=(x.flags&128)!==0,Z=M.rendering,Z===null)if($)dv(M,!1);else{if($a!==0||h!==null&&(h.flags&128)!==0)for(h=x.child;h!==null;){if(Z=i_(h),Z!==null){for(x.flags|=128,dv(M,!1),h=Z.updateQueue,x.updateQueue=h,x_(x,h),x.subtreeFlags=0,h=S,S=x.child;S!==null;)P5(S,h),S=S.sibling;return me(ii,ii.current&1|2),vr&&cu(x,M.treeForkCount),x.child}h=h.sibling}M.tail!==null&&R()>__&&(x.flags|=128,$=!0,dv(M,!1),x.lanes=4194304)}else{if(!$)if(h=i_(Z),h!==null){if(x.flags|=128,$=!0,h=h.updateQueue,x.updateQueue=h,x_(x,h),dv(M,!0),M.tail===null&&M.tailMode===\"hidden\"&&!Z.alternate&&!vr)return wa(x),null}else 2*R()-M.renderingStartTime>__&&S!==536870912&&(x.flags|=128,$=!0,dv(M,!1),x.lanes=4194304);M.isBackwards?(Z.sibling=x.child,x.child=Z):(h=M.last,h!==null?h.sibling=Z:x.child=Z,M.last=Z)}return M.tail!==null?(h=M.tail,M.rendering=h,M.tail=h.sibling,M.renderingStartTime=R(),h.sibling=null,S=ii.current,me(ii,$?S&1|2:S&1),vr&&cu(x,M.treeForkCount),h):(wa(x),null);case 22:case 23:return Jo(x),YA(),M=x.memoizedState!==null,h!==null?h.memoizedState!==null!==M&&(x.flags|=8192):M&&(x.flags|=8192),M?(S&536870912)!==0&&(x.flags&128)===0&&(wa(x),x.subtreeFlags&6&&(x.flags|=8192)):wa(x),S=x.updateQueue,S!==null&&x_(x,S.retryQueue),S=null,h!==null&&h.memoizedState!==null&&h.memoizedState.cachePool!==null&&(S=h.memoizedState.cachePool.pool),M=null,x.memoizedState!==null&&x.memoizedState.cachePool!==null&&(M=x.memoizedState.cachePool.pool),M!==S&&(x.flags|=2048),h!==null&&ne(Jp),null;case 24:return S=null,h!==null&&(S=h.memoizedState.cache),x.memoizedState.cache!==S&&(x.flags|=2048),uu(hi),wa(x),null;case 25:return null;case 30:return null}throw Error(r(156,x.tag))}function Rce(h,x){switch(RA(x),x.tag){case 1:return h=x.flags,h&65536?(x.flags=h&-65537|128,x):null;case 3:return uu(hi),j(),h=x.flags,(h&65536)!==0&&(h&128)===0?(x.flags=h&-65537|128,x):null;case 26:case 27:case 5:return K(x),null;case 31:if(x.memoizedState!==null){if(Jo(x),x.alternate===null)throw Error(r(340));Yp()}return h=x.flags,h&65536?(x.flags=h&-65537|128,x):null;case 13:if(Jo(x),h=x.memoizedState,h!==null&&h.dehydrated!==null){if(x.alternate===null)throw Error(r(340));Yp()}return h=x.flags,h&65536?(x.flags=h&-65537|128,x):null;case 19:return ne(ii),null;case 4:return j(),null;case 10:return uu(x.type),null;case 22:case 23:return Jo(x),YA(),h!==null&&ne(Jp),h=x.flags,h&65536?(x.flags=h&-65537|128,x):null;case 24:return uu(hi),null;case 25:return null;default:return null}}function eB(h,x){switch(RA(x),x.tag){case 3:uu(hi),j();break;case 26:case 27:case 5:K(x);break;case 4:j();break;case 31:x.memoizedState!==null&&Jo(x);break;case 13:Jo(x);break;case 19:ne(ii);break;case 10:uu(x.type);break;case 22:case 23:Jo(x),YA(),h!==null&&ne(Jp);break;case 24:uu(hi)}}function uv(h,x){try{var S=x.updateQueue,M=S!==null?S.lastEffect:null;if(M!==null){var $=M.next;S=$;do{if((S.tag&h)===h){M=void 0;var Z=S.create,Ne=S.inst;M=Z(),Ne.destroy=M}S=S.next}while(S!==$)}}catch(He){qr(x,x.return,He)}}function Ym(h,x,S){try{var M=x.updateQueue,$=M!==null?M.lastEffect:null;if($!==null){var Z=$.next;M=Z;do{if((M.tag&h)===h){var Ne=M.inst,He=Ne.destroy;if(He!==void 0){Ne.destroy=void 0,$=x;var rt=S,kt=He;try{kt()}catch(Ot){qr($,rt,Ot)}}}M=M.next}while(M!==Z)}}catch(Ot){qr(x,x.return,Ot)}}function tB(h){var x=h.updateQueue;if(x!==null){var S=h.stateNode;try{V5(x,S)}catch(M){qr(h,h.return,M)}}}function nB(h,x,S){S.props=af(h.type,h.memoizedProps),S.state=h.memoizedState;try{S.componentWillUnmount()}catch(M){qr(h,x,M)}}function mv(h,x){try{var S=h.ref;if(S!==null){switch(h.tag){case 26:case 27:case 5:var M=h.stateNode;break;case 30:M=h.stateNode;break;default:M=h.stateNode}typeof S==\"function\"?h.refCleanup=S(M):S.current=M}}catch($){qr(h,x,$)}}function ud(h,x){var S=h.ref,M=h.refCleanup;if(S!==null)if(typeof M==\"function\")try{M()}catch($){qr(h,x,$)}finally{h.refCleanup=null,h=h.alternate,h!=null&&(h.refCleanup=null)}else if(typeof S==\"function\")try{S(null)}catch($){qr(h,x,$)}else S.current=null}function rB(h){var x=h.type,S=h.memoizedProps,M=h.stateNode;try{e:switch(x){case\"button\":case\"input\":case\"select\":case\"textarea\":S.autoFocus&&M.focus();break e;case\"img\":S.src?M.src=S.src:S.srcSet&&(M.srcset=S.srcSet)}}catch($){qr(h,h.return,$)}}function Tj(h,x,S){try{var M=h.stateNode;rde(M,h.type,S,x),M[Lt]=x}catch($){qr(h,h.return,$)}}function aB(h){return h.tag===5||h.tag===3||h.tag===26||h.tag===27&&rh(h.type)||h.tag===4}function Aj(h){e:for(;;){for(;h.sibling===null;){if(h.return===null||aB(h.return))return null;h=h.return}for(h.sibling.return=h.return,h=h.sibling;h.tag!==5&&h.tag!==6&&h.tag!==18;){if(h.tag===27&&rh(h.type)||h.flags&2||h.child===null||h.tag===4)continue e;h.child.return=h,h=h.child}if(!(h.flags&2))return h.stateNode}}function jj(h,x,S){var M=h.tag;if(M===5||M===6)h=h.stateNode,x?(S.nodeType===9?S.body:S.nodeName===\"HTML\"?S.ownerDocument.body:S).insertBefore(h,x):(x=S.nodeType===9?S.body:S.nodeName===\"HTML\"?S.ownerDocument.body:S,x.appendChild(h),S=S._reactRootContainer,S!=null||x.onclick!==null||(x.onclick=$t));else if(M!==4&&(M===27&&rh(h.type)&&(S=h.stateNode,x=null),h=h.child,h!==null))for(jj(h,x,S),h=h.sibling;h!==null;)jj(h,x,S),h=h.sibling}function y_(h,x,S){var M=h.tag;if(M===5||M===6)h=h.stateNode,x?S.insertBefore(h,x):S.appendChild(h);else if(M!==4&&(M===27&&rh(h.type)&&(S=h.stateNode),h=h.child,h!==null))for(y_(h,x,S),h=h.sibling;h!==null;)y_(h,x,S),h=h.sibling}function iB(h){var x=h.stateNode,S=h.memoizedProps;try{for(var M=h.type,$=x.attributes;$.length;)x.removeAttributeNode($[0]);cs(x,M,S),x[at]=h,x[Lt]=S}catch(Z){qr(h,h.return,Z)}}var gu=!1,gi=!1,Mj=!1,sB=typeof WeakSet==\"function\"?WeakSet:Set,qi=null;function Lce(h,x){if(h=h.containerInfo,Zj=U_,h=$S(h),kA(h)){if(\"selectionStart\"in h)var S={start:h.selectionStart,end:h.selectionEnd};else e:{S=(S=h.ownerDocument)&&S.defaultView||window;var M=S.getSelection&&S.getSelection();if(M&&M.rangeCount!==0){S=M.anchorNode;var $=M.anchorOffset,Z=M.focusNode;M=M.focusOffset;try{S.nodeType,Z.nodeType}catch{S=null;break e}var Ne=0,He=-1,rt=-1,kt=0,Ot=0,zt=h,Nt=null;t:for(;;){for(var Dt;zt!==S||$!==0&&zt.nodeType!==3||(He=Ne+$),zt!==Z||M!==0&&zt.nodeType!==3||(rt=Ne+M),zt.nodeType===3&&(Ne+=zt.nodeValue.length),(Dt=zt.firstChild)!==null;)Nt=zt,zt=Dt;for(;;){if(zt===h)break t;if(Nt===S&&++kt===$&&(He=Ne),Nt===Z&&++Ot===M&&(rt=Ne),(Dt=zt.nextSibling)!==null)break;zt=Nt,Nt=zt.parentNode}zt=Dt}S=He===-1||rt===-1?null:{start:He,end:rt}}else S=null}S=S||{start:0,end:0}}else S=null;for(Jj={focusedElem:h,selectionRange:S},U_=!1,qi=x;qi!==null;)if(x=qi,h=x.child,(x.subtreeFlags&1028)!==0&&h!==null)h.return=x,qi=h;else for(;qi!==null;){switch(x=qi,Z=x.alternate,h=x.flags,x.tag){case 0:if((h&4)!==0&&(h=x.updateQueue,h=h!==null?h.events:null,h!==null))for(S=0;S<h.length;S++)$=h[S],$.ref.impl=$.nextImpl;break;case 11:case 15:break;case 1:if((h&1024)!==0&&Z!==null){h=void 0,S=x,$=Z.memoizedProps,Z=Z.memoizedState,M=S.stateNode;try{var mn=af(S.type,$);h=M.getSnapshotBeforeUpdate(mn,Z),M.__reactInternalSnapshotBeforeUpdate=h}catch(Rn){qr(S,S.return,Rn)}}break;case 3:if((h&1024)!==0){if(h=x.stateNode.containerInfo,S=h.nodeType,S===9)nM(h);else if(S===1)switch(h.nodeName){case\"HEAD\":case\"HTML\":case\"BODY\":nM(h);break;default:h.textContent=\"\"}}break;case 5:case 26:case 27:case 6:case 4:case 17:break;default:if((h&1024)!==0)throw Error(r(163))}if(h=x.sibling,h!==null){h.return=x.return,qi=h;break}qi=x.return}}function oB(h,x,S){var M=S.flags;switch(S.tag){case 0:case 11:case 15:xu(h,S),M&4&&uv(5,S);break;case 1:if(xu(h,S),M&4)if(h=S.stateNode,x===null)try{h.componentDidMount()}catch(Ne){qr(S,S.return,Ne)}else{var $=af(S.type,x.memoizedProps);x=x.memoizedState;try{h.componentDidUpdate($,x,h.__reactInternalSnapshotBeforeUpdate)}catch(Ne){qr(S,S.return,Ne)}}M&64&&tB(S),M&512&&mv(S,S.return);break;case 3:if(xu(h,S),M&64&&(h=S.updateQueue,h!==null)){if(x=null,S.child!==null)switch(S.child.tag){case 27:case 5:x=S.child.stateNode;break;case 1:x=S.child.stateNode}try{V5(h,x)}catch(Ne){qr(S,S.return,Ne)}}break;case 27:x===null&&M&4&&iB(S);case 26:case 5:xu(h,S),x===null&&M&4&&rB(S),M&512&&mv(S,S.return);break;case 12:xu(h,S);break;case 31:xu(h,S),M&4&&dB(h,S);break;case 13:xu(h,S),M&4&&uB(h,S),M&64&&(h=S.memoizedState,h!==null&&(h=h.dehydrated,h!==null&&(S=Vce.bind(null,S),ude(h,S))));break;case 22:if(M=S.memoizedState!==null||gu,!M){x=x!==null&&x.memoizedState!==null||gi,$=gu;var Z=gi;gu=M,(gi=x)&&!Z?yu(h,S,(S.subtreeFlags&8772)!==0):xu(h,S),gu=$,gi=Z}break;case 30:break;default:xu(h,S)}}function lB(h){var x=h.alternate;x!==null&&(h.alternate=null,lB(x)),h.child=null,h.deletions=null,h.sibling=null,h.tag===5&&(x=h.stateNode,x!==null&&ze(x)),h.stateNode=null,h.return=null,h.dependencies=null,h.memoizedProps=null,h.memoizedState=null,h.pendingProps=null,h.stateNode=null,h.updateQueue=null}var Ca=null,No=!1;function bu(h,x,S){for(S=S.child;S!==null;)cB(h,x,S),S=S.sibling}function cB(h,x,S){if(Oe&&typeof Oe.onCommitFiberUnmount==\"function\")try{Oe.onCommitFiberUnmount(Me,S)}catch{}switch(S.tag){case 26:gi||ud(S,x),bu(h,x,S),S.memoizedState?S.memoizedState.count--:S.stateNode&&(S=S.stateNode,S.parentNode.removeChild(S));break;case 27:gi||ud(S,x);var M=Ca,$=No;rh(S.type)&&(Ca=S.stateNode,No=!1),bu(h,x,S),wv(S.stateNode),Ca=M,No=$;break;case 5:gi||ud(S,x);case 6:if(M=Ca,$=No,Ca=null,bu(h,x,S),Ca=M,No=$,Ca!==null)if(No)try{(Ca.nodeType===9?Ca.body:Ca.nodeName===\"HTML\"?Ca.ownerDocument.body:Ca).removeChild(S.stateNode)}catch(Z){qr(S,x,Z)}else try{Ca.removeChild(S.stateNode)}catch(Z){qr(S,x,Z)}break;case 18:Ca!==null&&(No?(h=Ca,t8(h.nodeType===9?h.body:h.nodeName===\"HTML\"?h.ownerDocument.body:h,S.stateNode),yb(h)):t8(Ca,S.stateNode));break;case 4:M=Ca,$=No,Ca=S.stateNode.containerInfo,No=!0,bu(h,x,S),Ca=M,No=$;break;case 0:case 11:case 14:case 15:Ym(2,S,x),gi||Ym(4,S,x),bu(h,x,S);break;case 1:gi||(ud(S,x),M=S.stateNode,typeof M.componentWillUnmount==\"function\"&&nB(S,x,M)),bu(h,x,S);break;case 21:bu(h,x,S);break;case 22:gi=(M=gi)||S.memoizedState!==null,bu(h,x,S),gi=M;break;default:bu(h,x,S)}}function dB(h,x){if(x.memoizedState===null&&(h=x.alternate,h!==null&&(h=h.memoizedState,h!==null))){h=h.dehydrated;try{yb(h)}catch(S){qr(x,x.return,S)}}}function uB(h,x){if(x.memoizedState===null&&(h=x.alternate,h!==null&&(h=h.memoizedState,h!==null&&(h=h.dehydrated,h!==null))))try{yb(h)}catch(S){qr(x,x.return,S)}}function Oce(h){switch(h.tag){case 31:case 13:case 19:var x=h.stateNode;return x===null&&(x=h.stateNode=new sB),x;case 22:return h=h.stateNode,x=h._retryCache,x===null&&(x=h._retryCache=new sB),x;default:throw Error(r(435,h.tag))}}function v_(h,x){var S=Oce(h);x.forEach(function(M){if(!S.has(M)){S.add(M);var $=Gce.bind(null,h,M);M.then($,$)}})}function Co(h,x){var S=x.deletions;if(S!==null)for(var M=0;M<S.length;M++){var $=S[M],Z=h,Ne=x,He=Ne;e:for(;He!==null;){switch(He.tag){case 27:if(rh(He.type)){Ca=He.stateNode,No=!1;break e}break;case 5:Ca=He.stateNode,No=!1;break e;case 3:case 4:Ca=He.stateNode.containerInfo,No=!0;break e}He=He.return}if(Ca===null)throw Error(r(160));cB(Z,Ne,$),Ca=null,No=!1,Z=$.alternate,Z!==null&&(Z.return=null),$.return=null}if(x.subtreeFlags&13886)for(x=x.child;x!==null;)mB(x,h),x=x.sibling}var Ac=null;function mB(h,x){var S=h.alternate,M=h.flags;switch(h.tag){case 0:case 11:case 14:case 15:Co(x,h),Po(h),M&4&&(Ym(3,h,h.return),uv(3,h),Ym(5,h,h.return));break;case 1:Co(x,h),Po(h),M&512&&(gi||S===null||ud(S,S.return)),M&64&&gu&&(h=h.updateQueue,h!==null&&(M=h.callbacks,M!==null&&(S=h.shared.hiddenCallbacks,h.shared.hiddenCallbacks=S===null?M:S.concat(M))));break;case 26:var $=Ac;if(Co(x,h),Po(h),M&512&&(gi||S===null||ud(S,S.return)),M&4){var Z=S!==null?S.memoizedState:null;if(M=h.memoizedState,S===null)if(M===null)if(h.stateNode===null){e:{M=h.type,S=h.memoizedProps,$=$.ownerDocument||$;t:switch(M){case\"title\":Z=$.getElementsByTagName(\"title\")[0],(!Z||Z[nt]||Z[at]||Z.namespaceURI===\"http://www.w3.org/2000/svg\"||Z.hasAttribute(\"itemprop\"))&&(Z=$.createElement(M),$.head.insertBefore(Z,$.querySelector(\"head > title\"))),cs(Z,M,S),Z[at]=h,Je(Z),M=Z;break e;case\"link\":var Ne=m8(\"link\",\"href\",$).get(M+(S.href||\"\"));if(Ne){for(var He=0;He<Ne.length;He++)if(Z=Ne[He],Z.getAttribute(\"href\")===(S.href==null||S.href===\"\"?null:S.href)&&Z.getAttribute(\"rel\")===(S.rel==null?null:S.rel)&&Z.getAttribute(\"title\")===(S.title==null?null:S.title)&&Z.getAttribute(\"crossorigin\")===(S.crossOrigin==null?null:S.crossOrigin)){Ne.splice(He,1);break t}}Z=$.createElement(M),cs(Z,M,S),$.head.appendChild(Z);break;case\"meta\":if(Ne=m8(\"meta\",\"content\",$).get(M+(S.content||\"\"))){for(He=0;He<Ne.length;He++)if(Z=Ne[He],Z.getAttribute(\"content\")===(S.content==null?null:\"\"+S.content)&&Z.getAttribute(\"name\")===(S.name==null?null:S.name)&&Z.getAttribute(\"property\")===(S.property==null?null:S.property)&&Z.getAttribute(\"http-equiv\")===(S.httpEquiv==null?null:S.httpEquiv)&&Z.getAttribute(\"charset\")===(S.charSet==null?null:S.charSet)){Ne.splice(He,1);break t}}Z=$.createElement(M),cs(Z,M,S),$.head.appendChild(Z);break;default:throw Error(r(468,M))}Z[at]=h,Je(Z),M=Z}h.stateNode=M}else h8($,h.type,h.stateNode);else h.stateNode=u8($,M,h.memoizedProps);else Z!==M?(Z===null?S.stateNode!==null&&(S=S.stateNode,S.parentNode.removeChild(S)):Z.count--,M===null?h8($,h.type,h.stateNode):u8($,M,h.memoizedProps)):M===null&&h.stateNode!==null&&Tj(h,h.memoizedProps,S.memoizedProps)}break;case 27:Co(x,h),Po(h),M&512&&(gi||S===null||ud(S,S.return)),S!==null&&M&4&&Tj(h,h.memoizedProps,S.memoizedProps);break;case 5:if(Co(x,h),Po(h),M&512&&(gi||S===null||ud(S,S.return)),h.flags&32){$=h.stateNode;try{cr($,\"\")}catch(mn){qr(h,h.return,mn)}}M&4&&h.stateNode!=null&&($=h.memoizedProps,Tj(h,$,S!==null?S.memoizedProps:$)),M&1024&&(Mj=!0);break;case 6:if(Co(x,h),Po(h),M&4){if(h.stateNode===null)throw Error(r(162));M=h.memoizedProps,S=h.stateNode;try{S.nodeValue=M}catch(mn){qr(h,h.return,mn)}}break;case 3:if(L_=null,$=Ac,Ac=F_(x.containerInfo),Co(x,h),Ac=$,Po(h),M&4&&S!==null&&S.memoizedState.isDehydrated)try{yb(x.containerInfo)}catch(mn){qr(h,h.return,mn)}Mj&&(Mj=!1,hB(h));break;case 4:M=Ac,Ac=F_(h.stateNode.containerInfo),Co(x,h),Po(h),Ac=M;break;case 12:Co(x,h),Po(h);break;case 31:Co(x,h),Po(h),M&4&&(M=h.updateQueue,M!==null&&(h.updateQueue=null,v_(h,M)));break;case 13:Co(x,h),Po(h),h.child.flags&8192&&h.memoizedState!==null!=(S!==null&&S.memoizedState!==null)&&(S_=R()),M&4&&(M=h.updateQueue,M!==null&&(h.updateQueue=null,v_(h,M)));break;case 22:$=h.memoizedState!==null;var rt=S!==null&&S.memoizedState!==null,kt=gu,Ot=gi;if(gu=kt||$,gi=Ot||rt,Co(x,h),gi=Ot,gu=kt,Po(h),M&8192)e:for(x=h.stateNode,x._visibility=$?x._visibility&-2:x._visibility|1,$&&(S===null||rt||gu||gi||sf(h)),S=null,x=h;;){if(x.tag===5||x.tag===26){if(S===null){rt=S=x;try{if(Z=rt.stateNode,$)Ne=Z.style,typeof Ne.setProperty==\"function\"?Ne.setProperty(\"display\",\"none\",\"important\"):Ne.display=\"none\";else{He=rt.stateNode;var zt=rt.memoizedProps.style,Nt=zt!=null&&zt.hasOwnProperty(\"display\")?zt.display:null;He.style.display=Nt==null||typeof Nt==\"boolean\"?\"\":(\"\"+Nt).trim()}}catch(mn){qr(rt,rt.return,mn)}}}else if(x.tag===6){if(S===null){rt=x;try{rt.stateNode.nodeValue=$?\"\":rt.memoizedProps}catch(mn){qr(rt,rt.return,mn)}}}else if(x.tag===18){if(S===null){rt=x;try{var Dt=rt.stateNode;$?n8(Dt,!0):n8(rt.stateNode,!1)}catch(mn){qr(rt,rt.return,mn)}}}else if((x.tag!==22&&x.tag!==23||x.memoizedState===null||x===h)&&x.child!==null){x.child.return=x,x=x.child;continue}if(x===h)break e;for(;x.sibling===null;){if(x.return===null||x.return===h)break e;S===x&&(S=null),x=x.return}S===x&&(S=null),x.sibling.return=x.return,x=x.sibling}M&4&&(M=h.updateQueue,M!==null&&(S=M.retryQueue,S!==null&&(M.retryQueue=null,v_(h,S))));break;case 19:Co(x,h),Po(h),M&4&&(M=h.updateQueue,M!==null&&(h.updateQueue=null,v_(h,M)));break;case 30:break;case 21:break;default:Co(x,h),Po(h)}}function Po(h){var x=h.flags;if(x&2){try{for(var S,M=h.return;M!==null;){if(aB(M)){S=M;break}M=M.return}if(S==null)throw Error(r(160));switch(S.tag){case 27:var $=S.stateNode,Z=Aj(h);y_(h,Z,$);break;case 5:var Ne=S.stateNode;S.flags&32&&(cr(Ne,\"\"),S.flags&=-33);var He=Aj(h);y_(h,He,Ne);break;case 3:case 4:var rt=S.stateNode.containerInfo,kt=Aj(h);jj(h,kt,rt);break;default:throw Error(r(161))}}catch(Ot){qr(h,h.return,Ot)}h.flags&=-3}x&4096&&(h.flags&=-4097)}function hB(h){if(h.subtreeFlags&1024)for(h=h.child;h!==null;){var x=h;hB(x),x.tag===5&&x.flags&1024&&x.stateNode.reset(),h=h.sibling}}function xu(h,x){if(x.subtreeFlags&8772)for(x=x.child;x!==null;)oB(h,x.alternate,x),x=x.sibling}function sf(h){for(h=h.child;h!==null;){var x=h;switch(x.tag){case 0:case 11:case 14:case 15:Ym(4,x,x.return),sf(x);break;case 1:ud(x,x.return);var S=x.stateNode;typeof S.componentWillUnmount==\"function\"&&nB(x,x.return,S),sf(x);break;case 27:wv(x.stateNode);case 26:case 5:ud(x,x.return),sf(x);break;case 22:x.memoizedState===null&&sf(x);break;case 30:sf(x);break;default:sf(x)}h=h.sibling}}function yu(h,x,S){for(S=S&&(x.subtreeFlags&8772)!==0,x=x.child;x!==null;){var M=x.alternate,$=h,Z=x,Ne=Z.flags;switch(Z.tag){case 0:case 11:case 15:yu($,Z,S),uv(4,Z);break;case 1:if(yu($,Z,S),M=Z,$=M.stateNode,typeof $.componentDidMount==\"function\")try{$.componentDidMount()}catch(kt){qr(M,M.return,kt)}if(M=Z,$=M.updateQueue,$!==null){var He=M.stateNode;try{var rt=$.shared.hiddenCallbacks;if(rt!==null)for($.shared.hiddenCallbacks=null,$=0;$<rt.length;$++)$5(rt[$],He)}catch(kt){qr(M,M.return,kt)}}S&&Ne&64&&tB(Z),mv(Z,Z.return);break;case 27:iB(Z);case 26:case 5:yu($,Z,S),S&&M===null&&Ne&4&&rB(Z),mv(Z,Z.return);break;case 12:yu($,Z,S);break;case 31:yu($,Z,S),S&&Ne&4&&dB($,Z);break;case 13:yu($,Z,S),S&&Ne&4&&uB($,Z);break;case 22:Z.memoizedState===null&&yu($,Z,S),mv(Z,Z.return);break;case 30:break;default:yu($,Z,S)}x=x.sibling}}function Ej(h,x){var S=null;h!==null&&h.memoizedState!==null&&h.memoizedState.cachePool!==null&&(S=h.memoizedState.cachePool.pool),h=null,x.memoizedState!==null&&x.memoizedState.cachePool!==null&&(h=x.memoizedState.cachePool.pool),h!==S&&(h!=null&&h.refCount++,S!=null&&Zy(S))}function Dj(h,x){h=null,x.alternate!==null&&(h=x.alternate.memoizedState.cache),x=x.memoizedState.cache,x!==h&&(x.refCount++,h!=null&&Zy(h))}function jc(h,x,S,M){if(x.subtreeFlags&10256)for(x=x.child;x!==null;)pB(h,x,S,M),x=x.sibling}function pB(h,x,S,M){var $=x.flags;switch(x.tag){case 0:case 11:case 15:jc(h,x,S,M),$&2048&&uv(9,x);break;case 1:jc(h,x,S,M);break;case 3:jc(h,x,S,M),$&2048&&(h=null,x.alternate!==null&&(h=x.alternate.memoizedState.cache),x=x.memoizedState.cache,x!==h&&(x.refCount++,h!=null&&Zy(h)));break;case 12:if($&2048){jc(h,x,S,M),h=x.stateNode;try{var Z=x.memoizedProps,Ne=Z.id,He=Z.onPostCommit;typeof He==\"function\"&&He(Ne,x.alternate===null?\"mount\":\"update\",h.passiveEffectDuration,-0)}catch(rt){qr(x,x.return,rt)}}else jc(h,x,S,M);break;case 31:jc(h,x,S,M);break;case 13:jc(h,x,S,M);break;case 23:break;case 22:Z=x.stateNode,Ne=x.alternate,x.memoizedState!==null?Z._visibility&2?jc(h,x,S,M):hv(h,x):Z._visibility&2?jc(h,x,S,M):(Z._visibility|=2,lb(h,x,S,M,(x.subtreeFlags&10256)!==0||!1)),$&2048&&Ej(Ne,x);break;case 24:jc(h,x,S,M),$&2048&&Dj(x.alternate,x);break;default:jc(h,x,S,M)}}function lb(h,x,S,M,$){for($=$&&((x.subtreeFlags&10256)!==0||!1),x=x.child;x!==null;){var Z=h,Ne=x,He=S,rt=M,kt=Ne.flags;switch(Ne.tag){case 0:case 11:case 15:lb(Z,Ne,He,rt,$),uv(8,Ne);break;case 23:break;case 22:var Ot=Ne.stateNode;Ne.memoizedState!==null?Ot._visibility&2?lb(Z,Ne,He,rt,$):hv(Z,Ne):(Ot._visibility|=2,lb(Z,Ne,He,rt,$)),$&&kt&2048&&Ej(Ne.alternate,Ne);break;case 24:lb(Z,Ne,He,rt,$),$&&kt&2048&&Dj(Ne.alternate,Ne);break;default:lb(Z,Ne,He,rt,$)}x=x.sibling}}function hv(h,x){if(x.subtreeFlags&10256)for(x=x.child;x!==null;){var S=h,M=x,$=M.flags;switch(M.tag){case 22:hv(S,M),$&2048&&Ej(M.alternate,M);break;case 24:hv(S,M),$&2048&&Dj(M.alternate,M);break;default:hv(S,M)}x=x.sibling}}var pv=8192;function cb(h,x,S){if(h.subtreeFlags&pv)for(h=h.child;h!==null;)fB(h,x,S),h=h.sibling}function fB(h,x,S){switch(h.tag){case 26:cb(h,x,S),h.flags&pv&&h.memoizedState!==null&&_de(S,Ac,h.memoizedState,h.memoizedProps);break;case 5:cb(h,x,S);break;case 3:case 4:var M=Ac;Ac=F_(h.stateNode.containerInfo),cb(h,x,S),Ac=M;break;case 22:h.memoizedState===null&&(M=h.alternate,M!==null&&M.memoizedState!==null?(M=pv,pv=16777216,cb(h,x,S),pv=M):cb(h,x,S));break;default:cb(h,x,S)}}function gB(h){var x=h.alternate;if(x!==null&&(h=x.child,h!==null)){x.child=null;do x=h.sibling,h.sibling=null,h=x;while(h!==null)}}function fv(h){var x=h.deletions;if((h.flags&16)!==0){if(x!==null)for(var S=0;S<x.length;S++){var M=x[S];qi=M,xB(M,h)}gB(h)}if(h.subtreeFlags&10256)for(h=h.child;h!==null;)bB(h),h=h.sibling}function bB(h){switch(h.tag){case 0:case 11:case 15:fv(h),h.flags&2048&&Ym(9,h,h.return);break;case 3:fv(h);break;case 12:fv(h);break;case 22:var x=h.stateNode;h.memoizedState!==null&&x._visibility&2&&(h.return===null||h.return.tag!==13)?(x._visibility&=-3,w_(h)):fv(h);break;default:fv(h)}}function w_(h){var x=h.deletions;if((h.flags&16)!==0){if(x!==null)for(var S=0;S<x.length;S++){var M=x[S];qi=M,xB(M,h)}gB(h)}for(h=h.child;h!==null;){switch(x=h,x.tag){case 0:case 11:case 15:Ym(8,x,x.return),w_(x);break;case 22:S=x.stateNode,S._visibility&2&&(S._visibility&=-3,w_(x));break;default:w_(x)}h=h.sibling}}function xB(h,x){for(;qi!==null;){var S=qi;switch(S.tag){case 0:case 11:case 15:Ym(8,S,x);break;case 23:case 22:if(S.memoizedState!==null&&S.memoizedState.cachePool!==null){var M=S.memoizedState.cachePool.pool;M!=null&&M.refCount++}break;case 24:Zy(S.memoizedState.cache)}if(M=S.child,M!==null)M.return=S,qi=M;else e:for(S=h;qi!==null;){M=qi;var $=M.sibling,Z=M.return;if(lB(M),M===S){qi=null;break e}if($!==null){$.return=Z,qi=$;break e}qi=Z}}}var Ice={getCacheForType:function(h){var x=ss(hi),S=x.data.get(h);return S===void 0&&(S=h(),x.data.set(h,S)),S},cacheSignal:function(){return ss(hi).controller.signal}},zce=typeof WeakMap==\"function\"?WeakMap:Map,Lr=0,aa=null,hr=null,br=0,Hr=0,el=null,Qm=!1,db=!1,Fj=!1,vu=0,$a=0,Zm=0,of=0,Rj=0,tl=0,ub=0,gv=null,To=null,Lj=!1,S_=0,yB=0,__=1/0,k_=null,Jm=null,Pi=0,eh=null,mb=null,wu=0,Oj=0,Ij=null,vB=null,bv=0,zj=null;function nl(){return(Lr&2)!==0&&br!==0?br&-br:ie.T!==null?Vj():ln()}function wB(){if(tl===0)if((br&536870912)===0||vr){var h=we;we<<=1,(we&3932160)===0&&(we=262144),tl=h}else tl=536870912;return h=Zo.current,h!==null&&(h.flags|=32),tl}function Ao(h,x,S){(h===aa&&(Hr===2||Hr===9)||h.cancelPendingCommit!==null)&&(hb(h,0),th(h,br,tl,!1)),xt(h,S),((Lr&2)===0||h!==aa)&&(h===aa&&((Lr&2)===0&&(of|=S),$a===4&&th(h,br,tl,!1)),md(h))}function SB(h,x,S){if((Lr&6)!==0)throw Error(r(327));var M=!S&&(x&127)===0&&(x&h.expiredLanes)===0||xe(h,x),$=M?Hce(h,x):Bj(h,x,!0),Z=M;do{if($===0){db&&!M&&th(h,x,0,!1);break}else{if(S=h.current.alternate,Z&&!Uce(S)){$=Bj(h,x,!1),Z=!1;continue}if($===2){if(Z=x,h.errorRecoveryDisabledLanes&Z)var Ne=0;else Ne=h.pendingLanes&-536870913,Ne=Ne!==0?Ne:Ne&536870912?536870912:0;if(Ne!==0){x=Ne;e:{var He=h;$=gv;var rt=He.current.memoizedState.isDehydrated;if(rt&&(hb(He,Ne).flags|=256),Ne=Bj(He,Ne,!1),Ne!==2){if(Fj&&!rt){He.errorRecoveryDisabledLanes|=Z,of|=Z,$=4;break e}Z=To,To=$,Z!==null&&(To===null?To=Z:To.push.apply(To,Z))}$=Ne}if(Z=!1,$!==2)continue}}if($===1){hb(h,0),th(h,x,0,!0);break}e:{switch(M=h,Z=$,Z){case 0:case 1:throw Error(r(345));case 4:if((x&4194048)!==x)break;case 6:th(M,x,tl,!Qm);break e;case 2:To=null;break;case 3:case 5:break;default:throw Error(r(329))}if((x&62914560)===x&&($=S_+300-R(),10<$)){if(th(M,x,tl,!Qm),ce(M,0,!0)!==0)break e;wu=x,M.timeoutHandle=JB(_B.bind(null,M,S,To,k_,Lj,x,tl,of,ub,Qm,Z,\"Throttled\",-0,0),$);break e}_B(M,S,To,k_,Lj,x,tl,of,ub,Qm,Z,null,-0,0)}}break}while(!0);md(h)}function _B(h,x,S,M,$,Z,Ne,He,rt,kt,Ot,zt,Nt,Dt){if(h.timeoutHandle=-1,zt=x.subtreeFlags,zt&8192||(zt&16785408)===16785408){zt={stylesheets:null,count:0,imgCount:0,imgBytes:0,suspenseyImages:[],waitingForImages:!0,waitingForViewTransition:!1,unsuspend:$t},fB(x,Z,zt);var mn=(Z&62914560)===Z?S_-R():(Z&4194048)===Z?yB-R():0;if(mn=kde(zt,mn),mn!==null){wu=Z,h.cancelPendingCommit=mn(MB.bind(null,h,x,Z,S,M,$,Ne,He,rt,Ot,zt,null,Nt,Dt)),th(h,Z,Ne,!kt);return}}MB(h,x,Z,S,M,$,Ne,He,rt)}function Uce(h){for(var x=h;;){var S=x.tag;if((S===0||S===11||S===15)&&x.flags&16384&&(S=x.updateQueue,S!==null&&(S=S.stores,S!==null)))for(var M=0;M<S.length;M++){var $=S[M],Z=$.getSnapshot;$=$.value;try{if(!ko(Z(),$))return!1}catch{return!1}}if(S=x.child,x.subtreeFlags&16384&&S!==null)S.return=x,x=S;else{if(x===h)break;for(;x.sibling===null;){if(x.return===null||x.return===h)return!0;x=x.return}x.sibling.return=x.return,x=x.sibling}}return!0}function th(h,x,S,M){x&=~Rj,x&=~of,h.suspendedLanes|=x,h.pingedLanes&=~x,M&&(h.warmLanes|=x),M=h.expirationTimes;for(var $=x;0<$;){var Z=31-$e($),Ne=1<<Z;M[Z]=-1,$&=~Ne}S!==0&&Ut(h,S,x)}function N_(){return(Lr&6)===0?(xv(0),!1):!0}function Uj(){if(hr!==null){if(Hr===0)var h=hr.return;else h=hr,du=Qp=null,nj(h),rb=null,ev=0,h=hr;for(;h!==null;)eB(h.alternate,h),h=h.return;hr=null}}function hb(h,x){var S=h.timeoutHandle;S!==-1&&(h.timeoutHandle=-1,sde(S)),S=h.cancelPendingCommit,S!==null&&(h.cancelPendingCommit=null,S()),wu=0,Uj(),aa=h,hr=S=lu(h.current,null),br=x,Hr=0,el=null,Qm=!1,db=xe(h,x),Fj=!1,ub=tl=Rj=of=Zm=$a=0,To=gv=null,Lj=!1,(x&8)!==0&&(x|=x&32);var M=h.entangledLanes;if(M!==0)for(h=h.entanglements,M&=x;0<M;){var $=31-$e(M),Z=1<<$;x|=h[$],M&=~Z}return vu=x,GS(),S}function kB(h,x){Xn=null,ie.H=lv,x===nb||x===e_?(x=U5(),Hr=3):x===$A?(x=U5(),Hr=4):Hr=x===xj?8:x!==null&&typeof x==\"object\"&&typeof x.then==\"function\"?6:1,el=x,hr===null&&($a=1,p_(h,Al(x,h.current)))}function NB(){var h=Zo.current;return h===null?!0:(br&4194048)===br?Dl===null:(br&62914560)===br||(br&536870912)!==0?h===Dl:!1}function CB(){var h=ie.H;return ie.H=lv,h===null?lv:h}function PB(){var h=ie.A;return ie.A=Ice,h}function C_(){$a=4,Qm||(br&4194048)!==br&&Zo.current!==null||(db=!0),(Zm&134217727)===0&&(of&134217727)===0||aa===null||th(aa,br,tl,!1)}function Bj(h,x,S){var M=Lr;Lr|=2;var $=CB(),Z=PB();(aa!==h||br!==x)&&(k_=null,hb(h,x)),x=!1;var Ne=$a;e:do try{if(Hr!==0&&hr!==null){var He=hr,rt=el;switch(Hr){case 8:Uj(),Ne=6;break e;case 3:case 2:case 9:case 6:Zo.current===null&&(x=!0);var kt=Hr;if(Hr=0,el=null,pb(h,He,rt,kt),S&&db){Ne=0;break e}break;default:kt=Hr,Hr=0,el=null,pb(h,He,rt,kt)}}Bce(),Ne=$a;break}catch(Ot){kB(h,Ot)}while(!0);return x&&h.shellSuspendCounter++,du=Qp=null,Lr=M,ie.H=$,ie.A=Z,hr===null&&(aa=null,br=0,GS()),Ne}function Bce(){for(;hr!==null;)TB(hr)}function Hce(h,x){var S=Lr;Lr|=2;var M=CB(),$=PB();aa!==h||br!==x?(k_=null,__=R()+500,hb(h,x)):db=xe(h,x);e:do try{if(Hr!==0&&hr!==null){x=hr;var Z=el;t:switch(Hr){case 1:Hr=0,el=null,pb(h,x,Z,1);break;case 2:case 9:if(I5(Z)){Hr=0,el=null,AB(x);break}x=function(){Hr!==2&&Hr!==9||aa!==h||(Hr=7),md(h)},Z.then(x,x);break e;case 3:Hr=7;break e;case 4:Hr=5;break e;case 7:I5(Z)?(Hr=0,el=null,AB(x)):(Hr=0,el=null,pb(h,x,Z,7));break;case 5:var Ne=null;switch(hr.tag){case 26:Ne=hr.memoizedState;case 5:case 27:var He=hr;if(Ne?p8(Ne):He.stateNode.complete){Hr=0,el=null;var rt=He.sibling;if(rt!==null)hr=rt;else{var kt=He.return;kt!==null?(hr=kt,P_(kt)):hr=null}break t}}Hr=0,el=null,pb(h,x,Z,5);break;case 6:Hr=0,el=null,pb(h,x,Z,6);break;case 8:Uj(),$a=6;break e;default:throw Error(r(462))}}qce();break}catch(Ot){kB(h,Ot)}while(!0);return du=Qp=null,ie.H=M,ie.A=$,Lr=S,hr!==null?0:(aa=null,br=0,GS(),$a)}function qce(){for(;hr!==null&&!le();)TB(hr)}function TB(h){var x=ZU(h.alternate,h,vu);h.memoizedProps=h.pendingProps,x===null?P_(h):hr=x}function AB(h){var x=h,S=x.alternate;switch(x.tag){case 15:case 0:x=GU(S,x,x.pendingProps,x.type,void 0,br);break;case 11:x=GU(S,x,x.pendingProps,x.type.render,x.ref,br);break;case 5:nj(x);default:eB(S,x),x=hr=P5(x,vu),x=ZU(S,x,vu)}h.memoizedProps=h.pendingProps,x===null?P_(h):hr=x}function pb(h,x,S,M){du=Qp=null,nj(x),rb=null,ev=0;var $=x.return;try{if(Mce(h,$,x,S,br)){$a=1,p_(h,Al(S,h.current)),hr=null;return}}catch(Z){if($!==null)throw hr=$,Z;$a=1,p_(h,Al(S,h.current)),hr=null;return}x.flags&32768?(vr||M===1?h=!0:db||(br&536870912)!==0?h=!1:(Qm=h=!0,(M===2||M===9||M===3||M===6)&&(M=Zo.current,M!==null&&M.tag===13&&(M.flags|=16384))),jB(x,h)):P_(x)}function P_(h){var x=h;do{if((x.flags&32768)!==0){jB(x,Qm);return}h=x.return;var S=Fce(x.alternate,x,vu);if(S!==null){hr=S;return}if(x=x.sibling,x!==null){hr=x;return}hr=x=h}while(x!==null);$a===0&&($a=5)}function jB(h,x){do{var S=Rce(h.alternate,h);if(S!==null){S.flags&=32767,hr=S;return}if(S=h.return,S!==null&&(S.flags|=32768,S.subtreeFlags=0,S.deletions=null),!x&&(h=h.sibling,h!==null)){hr=h;return}hr=h=S}while(h!==null);$a=6,hr=null}function MB(h,x,S,M,$,Z,Ne,He,rt){h.cancelPendingCommit=null;do T_();while(Pi!==0);if((Lr&6)!==0)throw Error(r(327));if(x!==null){if(x===h.current)throw Error(r(177));if(Z=x.lanes|x.childLanes,Z|=AA,gt(h,S,Z,Ne,He,rt),h===aa&&(hr=aa=null,br=0),mb=x,eh=h,wu=S,Oj=Z,Ij=$,vB=M,(x.subtreeFlags&10256)!==0||(x.flags&10256)!==0?(h.callbackNode=null,h.callbackPriority=0,Wce(ve,function(){return LB(),null})):(h.callbackNode=null,h.callbackPriority=0),M=(x.flags&13878)!==0,(x.subtreeFlags&13878)!==0||M){M=ie.T,ie.T=null,$=J.p,J.p=2,Ne=Lr,Lr|=4;try{Lce(h,x,S)}finally{Lr=Ne,J.p=$,ie.T=M}}Pi=1,EB(),DB(),FB()}}function EB(){if(Pi===1){Pi=0;var h=eh,x=mb,S=(x.flags&13878)!==0;if((x.subtreeFlags&13878)!==0||S){S=ie.T,ie.T=null;var M=J.p;J.p=2;var $=Lr;Lr|=4;try{mB(x,h);var Z=Jj,Ne=$S(h.containerInfo),He=Z.focusedElem,rt=Z.selectionRange;if(Ne!==He&&He&&He.ownerDocument&&Ky(He.ownerDocument.documentElement,He)){if(rt!==null&&kA(He)){var kt=rt.start,Ot=rt.end;if(Ot===void 0&&(Ot=kt),\"selectionStart\"in He)He.selectionStart=kt,He.selectionEnd=Math.min(Ot,He.value.length);else{var zt=He.ownerDocument||document,Nt=zt&&zt.defaultView||window;if(Nt.getSelection){var Dt=Nt.getSelection(),mn=He.textContent.length,Rn=Math.min(rt.start,mn),Xr=rt.end===void 0?Rn:Math.min(rt.end,mn);!Dt.extend&&Rn>Xr&&(Ne=Xr,Xr=Rn,Rn=Ne);var bt=Wy(He,Rn),dt=Wy(He,Xr);if(bt&&dt&&(Dt.rangeCount!==1||Dt.anchorNode!==bt.node||Dt.anchorOffset!==bt.offset||Dt.focusNode!==dt.node||Dt.focusOffset!==dt.offset)){var _t=zt.createRange();_t.setStart(bt.node,bt.offset),Dt.removeAllRanges(),Rn>Xr?(Dt.addRange(_t),Dt.extend(dt.node,dt.offset)):(_t.setEnd(dt.node,dt.offset),Dt.addRange(_t))}}}}for(zt=[],Dt=He;Dt=Dt.parentNode;)Dt.nodeType===1&&zt.push({element:Dt,left:Dt.scrollLeft,top:Dt.scrollTop});for(typeof He.focus==\"function\"&&He.focus(),He=0;He<zt.length;He++){var It=zt[He];It.element.scrollLeft=It.left,It.element.scrollTop=It.top}}U_=!!Zj,Jj=Zj=null}finally{Lr=$,J.p=M,ie.T=S}}h.current=x,Pi=2}}function DB(){if(Pi===2){Pi=0;var h=eh,x=mb,S=(x.flags&8772)!==0;if((x.subtreeFlags&8772)!==0||S){S=ie.T,ie.T=null;var M=J.p;J.p=2;var $=Lr;Lr|=4;try{oB(h,x.alternate,x)}finally{Lr=$,J.p=M,ie.T=S}}Pi=3}}function FB(){if(Pi===4||Pi===3){Pi=0,B();var h=eh,x=mb,S=wu,M=vB;(x.subtreeFlags&10256)!==0||(x.flags&10256)!==0?Pi=5:(Pi=0,mb=eh=null,RB(h,h.pendingLanes));var $=h.pendingLanes;if($===0&&(Jm=null),Xt(S),x=x.stateNode,Oe&&typeof Oe.onCommitFiberRoot==\"function\")try{Oe.onCommitFiberRoot(Me,x,void 0,(x.current.flags&128)===128)}catch{}if(M!==null){x=ie.T,$=J.p,J.p=2,ie.T=null;try{for(var Z=h.onRecoverableError,Ne=0;Ne<M.length;Ne++){var He=M[Ne];Z(He.value,{componentStack:He.stack})}}finally{ie.T=x,J.p=$}}(wu&3)!==0&&T_(),md(h),$=h.pendingLanes,(S&261930)!==0&&($&42)!==0?h===zj?bv++:(bv=0,zj=h):bv=0,xv(0)}}function RB(h,x){(h.pooledCacheLanes&=x)===0&&(x=h.pooledCache,x!=null&&(h.pooledCache=null,Zy(x)))}function T_(){return EB(),DB(),FB(),LB()}function LB(){if(Pi!==5)return!1;var h=eh,x=Oj;Oj=0;var S=Xt(wu),M=ie.T,$=J.p;try{J.p=32>S?32:S,ie.T=null,S=Ij,Ij=null;var Z=eh,Ne=wu;if(Pi=0,mb=eh=null,wu=0,(Lr&6)!==0)throw Error(r(331));var He=Lr;if(Lr|=4,bB(Z.current),pB(Z,Z.current,Ne,S),Lr=He,xv(0,!1),Oe&&typeof Oe.onPostCommitFiberRoot==\"function\")try{Oe.onPostCommitFiberRoot(Me,Z)}catch{}return!0}finally{J.p=$,ie.T=M,RB(h,x)}}function OB(h,x,S){x=Al(S,x),x=bj(h.stateNode,x,2),h=Wm(h,x,2),h!==null&&(xt(h,2),md(h))}function qr(h,x,S){if(h.tag===3)OB(h,h,S);else for(;x!==null;){if(x.tag===3){OB(x,h,S);break}else if(x.tag===1){var M=x.stateNode;if(typeof x.type.getDerivedStateFromError==\"function\"||typeof M.componentDidCatch==\"function\"&&(Jm===null||!Jm.has(M))){h=Al(S,h),S=IU(2),M=Wm(x,S,2),M!==null&&(zU(S,M,x,h),xt(M,2),md(M));break}}x=x.return}}function Hj(h,x,S){var M=h.pingCache;if(M===null){M=h.pingCache=new zce;var $=new Set;M.set(x,$)}else $=M.get(x),$===void 0&&($=new Set,M.set(x,$));$.has(S)||(Fj=!0,$.add(S),h=$ce.bind(null,h,x,S),x.then(h,h))}function $ce(h,x,S){var M=h.pingCache;M!==null&&M.delete(x),h.pingedLanes|=h.suspendedLanes&S,h.warmLanes&=~S,aa===h&&(br&S)===S&&($a===4||$a===3&&(br&62914560)===br&&300>R()-S_?(Lr&2)===0&&hb(h,0):Rj|=S,ub===br&&(ub=0)),md(h)}function IB(h,x){x===0&&(x=Qe()),h=Kp(h,x),h!==null&&(xt(h,x),md(h))}function Vce(h){var x=h.memoizedState,S=0;x!==null&&(S=x.retryLane),IB(h,S)}function Gce(h,x){var S=0;switch(h.tag){case 31:case 13:var M=h.stateNode,$=h.memoizedState;$!==null&&(S=$.retryLane);break;case 19:M=h.stateNode;break;case 22:M=h.stateNode._retryCache;break;default:throw Error(r(314))}M!==null&&M.delete(x),IB(h,S)}function Wce(h,x){return ge(h,x)}var A_=null,fb=null,qj=!1,j_=!1,$j=!1,nh=0;function md(h){h!==fb&&h.next===null&&(fb===null?A_=fb=h:fb=fb.next=h),j_=!0,qj||(qj=!0,Xce())}function xv(h,x){if(!$j&&j_){$j=!0;do for(var S=!1,M=A_;M!==null;){if(h!==0){var $=M.pendingLanes;if($===0)var Z=0;else{var Ne=M.suspendedLanes,He=M.pingedLanes;Z=(1<<31-$e(42|h)+1)-1,Z&=$&~(Ne&~He),Z=Z&201326741?Z&201326741|1:Z?Z|2:0}Z!==0&&(S=!0,HB(M,Z))}else Z=br,Z=ce(M,M===aa?Z:0,M.cancelPendingCommit!==null||M.timeoutHandle!==-1),(Z&3)===0||xe(M,Z)||(S=!0,HB(M,Z));M=M.next}while(S);$j=!1}}function Kce(){zB()}function zB(){j_=qj=!1;var h=0;nh!==0&&ide()&&(h=nh);for(var x=R(),S=null,M=A_;M!==null;){var $=M.next,Z=UB(M,x);Z===0?(M.next=null,S===null?A_=$:S.next=$,$===null&&(fb=S)):(S=M,(h!==0||(Z&3)!==0)&&(j_=!0)),M=$}Pi!==0&&Pi!==5||xv(h),nh!==0&&(nh=0)}function UB(h,x){for(var S=h.suspendedLanes,M=h.pingedLanes,$=h.expirationTimes,Z=h.pendingLanes&-62914561;0<Z;){var Ne=31-$e(Z),He=1<<Ne,rt=$[Ne];rt===-1?((He&S)===0||(He&M)!==0)&&($[Ne]=Be(He,x)):rt<=x&&(h.expiredLanes|=He),Z&=~He}if(x=aa,S=br,S=ce(h,h===x?S:0,h.cancelPendingCommit!==null||h.timeoutHandle!==-1),M=h.callbackNode,S===0||h===x&&(Hr===2||Hr===9)||h.cancelPendingCommit!==null)return M!==null&&M!==null&&he(M),h.callbackNode=null,h.callbackPriority=0;if((S&3)===0||xe(h,S)){if(x=S&-S,x===h.callbackPriority)return x;switch(M!==null&&he(M),Xt(S)){case 2:case 8:S=Se;break;case 32:S=ve;break;case 268435456:S=ye;break;default:S=ve}return M=BB.bind(null,h),S=ge(S,M),h.callbackPriority=x,h.callbackNode=S,x}return M!==null&&M!==null&&he(M),h.callbackPriority=2,h.callbackNode=null,2}function BB(h,x){if(Pi!==0&&Pi!==5)return h.callbackNode=null,h.callbackPriority=0,null;var S=h.callbackNode;if(T_()&&h.callbackNode!==S)return null;var M=br;return M=ce(h,h===aa?M:0,h.cancelPendingCommit!==null||h.timeoutHandle!==-1),M===0?null:(SB(h,M,x),UB(h,R()),h.callbackNode!=null&&h.callbackNode===S?BB.bind(null,h):null)}function HB(h,x){if(T_())return null;SB(h,x,!0)}function Xce(){ode(function(){(Lr&6)!==0?ge(_e,Kce):zB()})}function Vj(){if(nh===0){var h=eb;h===0&&(h=Fe,Fe<<=1,(Fe&261888)===0&&(Fe=256)),nh=h}return nh}function qB(h){return h==null||typeof h==\"symbol\"||typeof h==\"boolean\"?null:typeof h==\"function\"?h:dr(\"\"+h)}function $B(h,x){var S=x.ownerDocument.createElement(\"input\");return S.name=x.name,S.value=x.value,h.id&&S.setAttribute(\"form\",h.id),x.parentNode.insertBefore(S,x),h=new FormData(h),S.parentNode.removeChild(S),h}function Yce(h,x,S,M,$){if(x===\"submit\"&&S&&S.stateNode===$){var Z=qB(($[Lt]||null).action),Ne=M.submitter;Ne&&(x=(x=Ne[Lt]||null)?qB(x.formAction):Ne.getAttribute(\"formAction\"),x!==null&&(Z=x,Ne=null));var He=new au(\"action\",\"action\",null,M,$);h.push({event:He,listeners:[{instance:null,listener:function(){if(M.defaultPrevented){if(nh!==0){var rt=Ne?$B($,Ne):new FormData($);uj(S,{pending:!0,data:rt,method:$.method,action:Z},null,rt)}}else typeof Z==\"function\"&&(He.preventDefault(),rt=Ne?$B($,Ne):new FormData($),uj(S,{pending:!0,data:rt,method:$.method,action:Z},Z,rt))},currentTarget:$}]})}}for(var Gj=0;Gj<TA.length;Gj++){var Wj=TA[Gj],Qce=Wj.toLowerCase(),Zce=Wj[0].toUpperCase()+Wj.slice(1);Tc(Qce,\"on\"+Zce)}Tc(w5,\"onAnimationEnd\"),Tc(S5,\"onAnimationIteration\"),Tc(_5,\"onAnimationStart\"),Tc(\"dblclick\",\"onDoubleClick\"),Tc(\"focusin\",\"onFocus\"),Tc(\"focusout\",\"onBlur\"),Tc(pce,\"onTransitionRun\"),Tc(fce,\"onTransitionStart\"),Tc(gce,\"onTransitionCancel\"),Tc(k5,\"onTransitionEnd\"),jt(\"onMouseEnter\",[\"mouseout\",\"mouseover\"]),jt(\"onMouseLeave\",[\"mouseout\",\"mouseover\"]),jt(\"onPointerEnter\",[\"pointerout\",\"pointerover\"]),jt(\"onPointerLeave\",[\"pointerout\",\"pointerover\"]),et(\"onChange\",\"change click focusin focusout input keydown keyup selectionchange\".split(\" \")),et(\"onSelect\",\"focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange\".split(\" \")),et(\"onBeforeInput\",[\"compositionend\",\"keypress\",\"textInput\",\"paste\"]),et(\"onCompositionEnd\",\"compositionend focusout keydown keypress keyup mousedown\".split(\" \")),et(\"onCompositionStart\",\"compositionstart focusout keydown keypress keyup mousedown\".split(\" \")),et(\"onCompositionUpdate\",\"compositionupdate focusout keydown keypress keyup mousedown\".split(\" \"));var yv=\"abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting\".split(\" \"),Jce=new Set(\"beforetoggle cancel close invalid load scroll scrollend toggle\".split(\" \").concat(yv));function VB(h,x){x=(x&4)!==0;for(var S=0;S<h.length;S++){var M=h[S],$=M.event;M=M.listeners;e:{var Z=void 0;if(x)for(var Ne=M.length-1;0<=Ne;Ne--){var He=M[Ne],rt=He.instance,kt=He.currentTarget;if(He=He.listener,rt!==Z&&$.isPropagationStopped())break e;Z=He,$.currentTarget=kt;try{Z($)}catch(Ot){VS(Ot)}$.currentTarget=null,Z=rt}else for(Ne=0;Ne<M.length;Ne++){if(He=M[Ne],rt=He.instance,kt=He.currentTarget,He=He.listener,rt!==Z&&$.isPropagationStopped())break e;Z=He,$.currentTarget=kt;try{Z($)}catch(Ot){VS(Ot)}$.currentTarget=null,Z=rt}}}}function pr(h,x){var S=x[At];S===void 0&&(S=x[At]=new Set);var M=h+\"__bubble\";S.has(M)||(GB(x,h,2,!1),S.add(M))}function Kj(h,x,S){var M=0;x&&(M|=4),GB(S,h,M,x)}var M_=\"_reactListening\"+Math.random().toString(36).slice(2);function Xj(h){if(!h[M_]){h[M_]=!0,We.forEach(function(S){S!==\"selectionchange\"&&(Jce.has(S)||Kj(S,!1,h),Kj(S,!0,h))});var x=h.nodeType===9?h:h.ownerDocument;x===null||x[M_]||(x[M_]=!0,Kj(\"selectionchange\",!1,x))}}function GB(h,x,S,M){switch(w8(x)){case 2:var $=Pde;break;case 8:$=Tde;break;default:$=dM}S=$.bind(null,x,S,h),$=void 0,!nu||x!==\"touchstart\"&&x!==\"touchmove\"&&x!==\"wheel\"||($=!0),M?$!==void 0?h.addEventListener(x,S,{capture:!0,passive:$}):h.addEventListener(x,S,!0):$!==void 0?h.addEventListener(x,S,{passive:$}):h.addEventListener(x,S,!1)}function Yj(h,x,S,M,$){var Z=M;if((x&1)===0&&(x&2)===0&&M!==null)e:for(;;){if(M===null)return;var Ne=M.tag;if(Ne===3||Ne===4){var He=M.stateNode.containerInfo;if(He===$)break;if(Ne===4)for(Ne=M.return;Ne!==null;){var rt=Ne.tag;if((rt===3||rt===4)&&Ne.stateNode.containerInfo===$)return;Ne=Ne.return}for(;He!==null;){if(Ne=ot(He),Ne===null)return;if(rt=Ne.tag,rt===5||rt===6||rt===26||rt===27){M=Z=Ne;continue e}He=He.parentNode}}M=M.return}Ip(function(){var kt=Z,Ot=rs(S),zt=[];e:{var Nt=N5.get(h);if(Nt!==void 0){var Dt=au,mn=h;switch(h){case\"keypress\":if(_c(S)===0)break e;case\"keydown\":case\"keyup\":Dt=$y;break;case\"focusin\":mn=\"focus\",Dt=as;break;case\"focusout\":mn=\"blur\",Dt=as;break;case\"beforeblur\":case\"afterblur\":Dt=as;break;case\"click\":if(S.button===2)break e;case\"auxclick\":case\"dblclick\":case\"mousedown\":case\"mousemove\":case\"mouseup\":case\"mouseout\":case\"mouseover\":case\"contextmenu\":Dt=kl;break;case\"drag\":case\"dragend\":case\"dragenter\":case\"dragexit\":case\"dragleave\":case\"dragover\":case\"dragstart\":case\"drop\":Dt=Nl;break;case\"touchcancel\":case\"touchend\":case\"touchmove\":case\"touchstart\":Dt=_n;break;case w5:case S5:case _5:Dt=xn;break;case k5:Dt=xa;break;case\"scroll\":case\"scrollend\":Dt=Up;break;case\"wheel\":Dt=Cr;break;case\"copy\":case\"cut\":case\"paste\":Dt=Bp;break;case\"gotpointercapture\":case\"lostpointercapture\":case\"pointercancel\":case\"pointerdown\":case\"pointermove\":case\"pointerout\":case\"pointerover\":case\"pointerup\":Dt=Rt;break;case\"toggle\":case\"beforetoggle\":Dt=qp}var Rn=(x&4)!==0,Xr=!Rn&&(h===\"scroll\"||h===\"scrollend\"),bt=Rn?Nt!==null?Nt+\"Capture\":null:Nt;Rn=[];for(var dt=kt,_t;dt!==null;){var It=dt;if(_t=It.stateNode,It=It.tag,It!==5&&It!==26&&It!==27||_t===null||bt===null||(It=tu(dt,bt),It!=null&&Rn.push(vv(dt,It,_t))),Xr)break;dt=dt.return}0<Rn.length&&(Nt=new Dt(Nt,mn,null,S,Ot),zt.push({event:Nt,listeners:Rn}))}}if((x&7)===0){e:{if(Nt=h===\"mouseover\"||h===\"pointerover\",Dt=h===\"mouseout\"||h===\"pointerout\",Nt&&S!==Ks&&(mn=S.relatedTarget||S.fromElement)&&(ot(mn)||mn[Et]))break e;if((Dt||Nt)&&(Nt=Ot.window===Ot?Ot:(Nt=Ot.ownerDocument)?Nt.defaultView||Nt.parentWindow:window,Dt?(mn=S.relatedTarget||S.toElement,Dt=kt,mn=mn?ot(mn):null,mn!==null&&(Xr=s(mn),Rn=mn.tag,mn!==Xr||Rn!==5&&Rn!==27&&Rn!==6)&&(mn=null)):(Dt=null,mn=kt),Dt!==mn)){if(Rn=kl,It=\"onMouseLeave\",bt=\"onMouseEnter\",dt=\"mouse\",(h===\"pointerout\"||h===\"pointerover\")&&(Rn=Rt,It=\"onPointerLeave\",bt=\"onPointerEnter\",dt=\"pointer\"),Xr=Dt==null?Nt:Ge(Dt),_t=mn==null?Nt:Ge(mn),Nt=new Rn(It,dt+\"leave\",Dt,S,Ot),Nt.target=Xr,Nt.relatedTarget=_t,It=null,ot(Ot)===kt&&(Rn=new Rn(bt,dt+\"enter\",mn,S,Ot),Rn.target=_t,Rn.relatedTarget=Xr,It=Rn),Xr=It,Dt&&mn)t:{for(Rn=ede,bt=Dt,dt=mn,_t=0,It=bt;It;It=Rn(It))_t++;It=0;for(var Tn=dt;Tn;Tn=Rn(Tn))It++;for(;0<_t-It;)bt=Rn(bt),_t--;for(;0<It-_t;)dt=Rn(dt),It--;for(;_t--;){if(bt===dt||dt!==null&&bt===dt.alternate){Rn=bt;break t}bt=Rn(bt),dt=Rn(dt)}Rn=null}else Rn=null;Dt!==null&&WB(zt,Nt,Dt,Rn,!1),mn!==null&&Xr!==null&&WB(zt,Xr,mn,Rn,!0)}}e:{if(Nt=kt?Ge(kt):window,Dt=Nt.nodeName&&Nt.nodeName.toLowerCase(),Dt===\"select\"||Dt===\"input\"&&Nt.type===\"file\")var Ar=Bi;else if(Na(Nt))if(Pl)Ar=$p;else{Ar=ya;var fn=Gy}else Dt=Nt.nodeName,!Dt||Dt.toLowerCase()!==\"input\"||Nt.type!==\"checkbox\"&&Nt.type!==\"radio\"?kt&&_l(kt.elementType)&&(Ar=Bi):Ar=ou;if(Ar&&(Ar=Ar(h,kt))){ai(zt,Ar,S,Ot);break e}fn&&fn(h,Nt,kt),h===\"focusout\"&&kt&&Nt.type===\"number\"&&kt.memoizedProps.value!=null&&ma(Nt,\"number\",Nt.value)}switch(fn=kt?Ge(kt):window,h){case\"focusin\":(Na(fn)||fn.contentEditable===\"true\")&&(Gg=fn,NA=kt,Xy=null);break;case\"focusout\":Xy=NA=Gg=null;break;case\"mousedown\":CA=!0;break;case\"contextmenu\":case\"mouseup\":case\"dragend\":CA=!1,y5(zt,S,Ot);break;case\"selectionchange\":if(hce)break;case\"keydown\":case\"keyup\":y5(zt,S,Ot)}var Qn;if(Vy)e:{switch(h){case\"compositionstart\":var xr=\"onCompositionStart\";break e;case\"compositionend\":xr=\"onCompositionEnd\";break e;case\"compositionupdate\":xr=\"onCompositionUpdate\";break e}xr=void 0}else Rr?Tr(h,S)&&(xr=\"onCompositionEnd\"):h===\"keydown\"&&S.keyCode===229&&(xr=\"onCompositionStart\");xr&&(Jt&&S.locale!==\"ko\"&&(Rr||xr!==\"onCompositionStart\"?xr===\"onCompositionEnd\"&&Rr&&(Qn=wo()):(vo=Ot,Ts=\"value\"in vo?vo.value:vo.textContent,Rr=!0)),fn=E_(kt,xr),0<fn.length&&(xr=new Nc(xr,h,null,S,Ot),zt.push({event:xr,listeners:fn}),Qn?xr.data=Qn:(Qn=ar(S),Qn!==null&&(xr.data=Qn)))),(Qn=Bt?Da(h,S):rn(h,S))&&(xr=E_(kt,\"onBeforeInput\"),0<xr.length&&(fn=new Nc(\"onBeforeInput\",\"beforeinput\",null,S,Ot),zt.push({event:fn,listeners:xr}),fn.data=Qn)),Yce(zt,h,kt,S,Ot)}VB(zt,x)})}function vv(h,x,S){return{instance:h,listener:x,currentTarget:S}}function E_(h,x){for(var S=x+\"Capture\",M=[];h!==null;){var $=h,Z=$.stateNode;if($=$.tag,$!==5&&$!==26&&$!==27||Z===null||($=tu(h,S),$!=null&&M.unshift(vv(h,$,Z)),$=tu(h,x),$!=null&&M.push(vv(h,$,Z))),h.tag===3)return M;h=h.return}return[]}function ede(h){if(h===null)return null;do h=h.return;while(h&&h.tag!==5&&h.tag!==27);return h||null}function WB(h,x,S,M,$){for(var Z=x._reactName,Ne=[];S!==null&&S!==M;){var He=S,rt=He.alternate,kt=He.stateNode;if(He=He.tag,rt!==null&&rt===M)break;He!==5&&He!==26&&He!==27||kt===null||(rt=kt,$?(kt=tu(S,Z),kt!=null&&Ne.unshift(vv(S,kt,rt))):$||(kt=tu(S,Z),kt!=null&&Ne.push(vv(S,kt,rt)))),S=S.return}Ne.length!==0&&h.push({event:x,listeners:Ne})}var tde=/\\r\\n?/g,nde=/\\u0000|\\uFFFD/g;function KB(h){return(typeof h==\"string\"?h:\"\"+h).replace(tde,`\n`).replace(nde,\"\")}function XB(h,x){return x=KB(x),KB(h)===x}function Kr(h,x,S,M,$,Z){switch(S){case\"children\":typeof M==\"string\"?x===\"body\"||x===\"textarea\"&&M===\"\"||cr(h,M):(typeof M==\"number\"||typeof M==\"bigint\")&&x!==\"body\"&&cr(h,\"\"+M);break;case\"className\":on(h,\"class\",M);break;case\"tabIndex\":on(h,\"tabindex\",M);break;case\"dir\":case\"role\":case\"viewBox\":case\"width\":case\"height\":on(h,S,M);break;case\"style\":vc(h,M,Z);break;case\"data\":if(x!==\"object\"){on(h,\"data\",M);break}case\"src\":case\"href\":if(M===\"\"&&(x!==\"a\"||S!==\"href\")){h.removeAttribute(S);break}if(M==null||typeof M==\"function\"||typeof M==\"symbol\"||typeof M==\"boolean\"){h.removeAttribute(S);break}M=dr(\"\"+M),h.setAttribute(S,M);break;case\"action\":case\"formAction\":if(typeof M==\"function\"){h.setAttribute(S,\"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')\");break}else typeof Z==\"function\"&&(S===\"formAction\"?(x!==\"input\"&&Kr(h,x,\"name\",$.name,$,null),Kr(h,x,\"formEncType\",$.formEncType,$,null),Kr(h,x,\"formMethod\",$.formMethod,$,null),Kr(h,x,\"formTarget\",$.formTarget,$,null)):(Kr(h,x,\"encType\",$.encType,$,null),Kr(h,x,\"method\",$.method,$,null),Kr(h,x,\"target\",$.target,$,null)));if(M==null||typeof M==\"symbol\"||typeof M==\"boolean\"){h.removeAttribute(S);break}M=dr(\"\"+M),h.setAttribute(S,M);break;case\"onClick\":M!=null&&(h.onclick=$t);break;case\"onScroll\":M!=null&&pr(\"scroll\",h);break;case\"onScrollEnd\":M!=null&&pr(\"scrollend\",h);break;case\"dangerouslySetInnerHTML\":if(M!=null){if(typeof M!=\"object\"||!(\"__html\"in M))throw Error(r(61));if(S=M.__html,S!=null){if($.children!=null)throw Error(r(60));h.innerHTML=S}}break;case\"multiple\":h.multiple=M&&typeof M!=\"function\"&&typeof M!=\"symbol\";break;case\"muted\":h.muted=M&&typeof M!=\"function\"&&typeof M!=\"symbol\";break;case\"suppressContentEditableWarning\":case\"suppressHydrationWarning\":case\"defaultValue\":case\"defaultChecked\":case\"innerHTML\":case\"ref\":break;case\"autoFocus\":break;case\"xlinkHref\":if(M==null||typeof M==\"function\"||typeof M==\"boolean\"||typeof M==\"symbol\"){h.removeAttribute(\"xlink:href\");break}S=dr(\"\"+M),h.setAttributeNS(\"http://www.w3.org/1999/xlink\",\"xlink:href\",S);break;case\"contentEditable\":case\"spellCheck\":case\"draggable\":case\"value\":case\"autoReverse\":case\"externalResourcesRequired\":case\"focusable\":case\"preserveAlpha\":M!=null&&typeof M!=\"function\"&&typeof M!=\"symbol\"?h.setAttribute(S,\"\"+M):h.removeAttribute(S);break;case\"inert\":case\"allowFullScreen\":case\"async\":case\"autoPlay\":case\"controls\":case\"default\":case\"defer\":case\"disabled\":case\"disablePictureInPicture\":case\"disableRemotePlayback\":case\"formNoValidate\":case\"hidden\":case\"loop\":case\"noModule\":case\"noValidate\":case\"open\":case\"playsInline\":case\"readOnly\":case\"required\":case\"reversed\":case\"scoped\":case\"seamless\":case\"itemScope\":M&&typeof M!=\"function\"&&typeof M!=\"symbol\"?h.setAttribute(S,\"\"):h.removeAttribute(S);break;case\"capture\":case\"download\":M===!0?h.setAttribute(S,\"\"):M!==!1&&M!=null&&typeof M!=\"function\"&&typeof M!=\"symbol\"?h.setAttribute(S,M):h.removeAttribute(S);break;case\"cols\":case\"rows\":case\"size\":case\"span\":M!=null&&typeof M!=\"function\"&&typeof M!=\"symbol\"&&!isNaN(M)&&1<=M?h.setAttribute(S,M):h.removeAttribute(S);break;case\"rowSpan\":case\"start\":M==null||typeof M==\"function\"||typeof M==\"symbol\"||isNaN(M)?h.removeAttribute(S):h.setAttribute(S,M);break;case\"popover\":pr(\"beforetoggle\",h),pr(\"toggle\",h),qt(h,\"popover\",M);break;case\"xlinkActuate\":dn(h,\"http://www.w3.org/1999/xlink\",\"xlink:actuate\",M);break;case\"xlinkArcrole\":dn(h,\"http://www.w3.org/1999/xlink\",\"xlink:arcrole\",M);break;case\"xlinkRole\":dn(h,\"http://www.w3.org/1999/xlink\",\"xlink:role\",M);break;case\"xlinkShow\":dn(h,\"http://www.w3.org/1999/xlink\",\"xlink:show\",M);break;case\"xlinkTitle\":dn(h,\"http://www.w3.org/1999/xlink\",\"xlink:title\",M);break;case\"xlinkType\":dn(h,\"http://www.w3.org/1999/xlink\",\"xlink:type\",M);break;case\"xmlBase\":dn(h,\"http://www.w3.org/XML/1998/namespace\",\"xml:base\",M);break;case\"xmlLang\":dn(h,\"http://www.w3.org/XML/1998/namespace\",\"xml:lang\",M);break;case\"xmlSpace\":dn(h,\"http://www.w3.org/XML/1998/namespace\",\"xml:space\",M);break;case\"is\":qt(h,\"is\",M);break;case\"innerText\":case\"textContent\":break;default:(!(2<S.length)||S[0]!==\"o\"&&S[0]!==\"O\"||S[1]!==\"n\"&&S[1]!==\"N\")&&(S=vt.get(S)||S,qt(h,S,M))}}function Qj(h,x,S,M,$,Z){switch(S){case\"style\":vc(h,M,Z);break;case\"dangerouslySetInnerHTML\":if(M!=null){if(typeof M!=\"object\"||!(\"__html\"in M))throw Error(r(61));if(S=M.__html,S!=null){if($.children!=null)throw Error(r(60));h.innerHTML=S}}break;case\"children\":typeof M==\"string\"?cr(h,M):(typeof M==\"number\"||typeof M==\"bigint\")&&cr(h,\"\"+M);break;case\"onScroll\":M!=null&&pr(\"scroll\",h);break;case\"onScrollEnd\":M!=null&&pr(\"scrollend\",h);break;case\"onClick\":M!=null&&(h.onclick=$t);break;case\"suppressContentEditableWarning\":case\"suppressHydrationWarning\":case\"innerHTML\":case\"ref\":break;case\"innerText\":case\"textContent\":break;default:if(!Ue.hasOwnProperty(S))e:{if(S[0]===\"o\"&&S[1]===\"n\"&&($=S.endsWith(\"Capture\"),x=S.slice(2,$?S.length-7:void 0),Z=h[Lt]||null,Z=Z!=null?Z[S]:null,typeof Z==\"function\"&&h.removeEventListener(x,Z,$),typeof M==\"function\")){typeof Z!=\"function\"&&Z!==null&&(S in h?h[S]=null:h.hasAttribute(S)&&h.removeAttribute(S)),h.addEventListener(x,M,$);break e}S in h?h[S]=M:M===!0?h.setAttribute(S,\"\"):qt(h,S,M)}}}function cs(h,x,S){switch(x){case\"div\":case\"span\":case\"svg\":case\"path\":case\"a\":case\"g\":case\"p\":case\"li\":break;case\"img\":pr(\"error\",h),pr(\"load\",h);var M=!1,$=!1,Z;for(Z in S)if(S.hasOwnProperty(Z)){var Ne=S[Z];if(Ne!=null)switch(Z){case\"src\":M=!0;break;case\"srcSet\":$=!0;break;case\"children\":case\"dangerouslySetInnerHTML\":throw Error(r(137,x));default:Kr(h,x,Z,Ne,S,null)}}$&&Kr(h,x,\"srcSet\",S.srcSet,S,null),M&&Kr(h,x,\"src\",S.src,S,null);return;case\"input\":pr(\"invalid\",h);var He=Z=Ne=$=null,rt=null,kt=null;for(M in S)if(S.hasOwnProperty(M)){var Ot=S[M];if(Ot!=null)switch(M){case\"name\":$=Ot;break;case\"type\":Ne=Ot;break;case\"checked\":rt=Ot;break;case\"defaultChecked\":kt=Ot;break;case\"value\":Z=Ot;break;case\"defaultValue\":He=Ot;break;case\"children\":case\"dangerouslySetInnerHTML\":if(Ot!=null)throw Error(r(137,x));break;default:Kr(h,x,M,Ot,S,null)}}In(h,Z,He,rt,kt,Ne,$,!1);return;case\"select\":pr(\"invalid\",h),M=Ne=Z=null;for($ in S)if(S.hasOwnProperty($)&&(He=S[$],He!=null))switch($){case\"value\":Z=He;break;case\"defaultValue\":Ne=He;break;case\"multiple\":M=He;default:Kr(h,x,$,He,S,null)}x=Z,S=Ne,h.multiple=!!M,x!=null?ra(h,!!M,x,!1):S!=null&&ra(h,!!M,S,!0);return;case\"textarea\":pr(\"invalid\",h),Z=$=M=null;for(Ne in S)if(S.hasOwnProperty(Ne)&&(He=S[Ne],He!=null))switch(Ne){case\"value\":M=He;break;case\"defaultValue\":$=He;break;case\"children\":Z=He;break;case\"dangerouslySetInnerHTML\":if(He!=null)throw Error(r(91));break;default:Kr(h,x,Ne,He,S,null)}Br(h,M,$,Z);return;case\"option\":for(rt in S)S.hasOwnProperty(rt)&&(M=S[rt],M!=null)&&(rt===\"selected\"?h.selected=M&&typeof M!=\"function\"&&typeof M!=\"symbol\":Kr(h,x,rt,M,S,null));return;case\"dialog\":pr(\"beforetoggle\",h),pr(\"toggle\",h),pr(\"cancel\",h),pr(\"close\",h);break;case\"iframe\":case\"object\":pr(\"load\",h);break;case\"video\":case\"audio\":for(M=0;M<yv.length;M++)pr(yv[M],h);break;case\"image\":pr(\"error\",h),pr(\"load\",h);break;case\"details\":pr(\"toggle\",h);break;case\"embed\":case\"source\":case\"link\":pr(\"error\",h),pr(\"load\",h);case\"area\":case\"base\":case\"br\":case\"col\":case\"hr\":case\"keygen\":case\"meta\":case\"param\":case\"track\":case\"wbr\":case\"menuitem\":for(kt in S)if(S.hasOwnProperty(kt)&&(M=S[kt],M!=null))switch(kt){case\"children\":case\"dangerouslySetInnerHTML\":throw Error(r(137,x));default:Kr(h,x,kt,M,S,null)}return;default:if(_l(x)){for(Ot in S)S.hasOwnProperty(Ot)&&(M=S[Ot],M!==void 0&&Qj(h,x,Ot,M,S,void 0));return}}for(He in S)S.hasOwnProperty(He)&&(M=S[He],M!=null&&Kr(h,x,He,M,S,null))}function rde(h,x,S,M){switch(x){case\"div\":case\"span\":case\"svg\":case\"path\":case\"a\":case\"g\":case\"p\":case\"li\":break;case\"input\":var $=null,Z=null,Ne=null,He=null,rt=null,kt=null,Ot=null;for(Dt in S){var zt=S[Dt];if(S.hasOwnProperty(Dt)&&zt!=null)switch(Dt){case\"checked\":break;case\"value\":break;case\"defaultValue\":rt=zt;default:M.hasOwnProperty(Dt)||Kr(h,x,Dt,null,M,zt)}}for(var Nt in M){var Dt=M[Nt];if(zt=S[Nt],M.hasOwnProperty(Nt)&&(Dt!=null||zt!=null))switch(Nt){case\"type\":Z=Dt;break;case\"name\":$=Dt;break;case\"checked\":kt=Dt;break;case\"defaultChecked\":Ot=Dt;break;case\"value\":Ne=Dt;break;case\"defaultValue\":He=Dt;break;case\"children\":case\"dangerouslySetInnerHTML\":if(Dt!=null)throw Error(r(137,x));break;default:Dt!==zt&&Kr(h,x,Nt,Dt,M,zt)}}Ba(h,Ne,He,rt,kt,Ot,Z,$);return;case\"select\":Dt=Ne=He=Nt=null;for(Z in S)if(rt=S[Z],S.hasOwnProperty(Z)&&rt!=null)switch(Z){case\"value\":break;case\"multiple\":Dt=rt;default:M.hasOwnProperty(Z)||Kr(h,x,Z,null,M,rt)}for($ in M)if(Z=M[$],rt=S[$],M.hasOwnProperty($)&&(Z!=null||rt!=null))switch($){case\"value\":Nt=Z;break;case\"defaultValue\":He=Z;break;case\"multiple\":Ne=Z;default:Z!==rt&&Kr(h,x,$,Z,M,rt)}x=He,S=Ne,M=Dt,Nt!=null?ra(h,!!S,Nt,!1):!!M!=!!S&&(x!=null?ra(h,!!S,x,!0):ra(h,!!S,S?[]:\"\",!1));return;case\"textarea\":Dt=Nt=null;for(He in S)if($=S[He],S.hasOwnProperty(He)&&$!=null&&!M.hasOwnProperty(He))switch(He){case\"value\":break;case\"children\":break;default:Kr(h,x,He,null,M,$)}for(Ne in M)if($=M[Ne],Z=S[Ne],M.hasOwnProperty(Ne)&&($!=null||Z!=null))switch(Ne){case\"value\":Nt=$;break;case\"defaultValue\":Dt=$;break;case\"children\":break;case\"dangerouslySetInnerHTML\":if($!=null)throw Error(r(91));break;default:$!==Z&&Kr(h,x,Ne,$,M,Z)}Fr(h,Nt,Dt);return;case\"option\":for(var mn in S)Nt=S[mn],S.hasOwnProperty(mn)&&Nt!=null&&!M.hasOwnProperty(mn)&&(mn===\"selected\"?h.selected=!1:Kr(h,x,mn,null,M,Nt));for(rt in M)Nt=M[rt],Dt=S[rt],M.hasOwnProperty(rt)&&Nt!==Dt&&(Nt!=null||Dt!=null)&&(rt===\"selected\"?h.selected=Nt&&typeof Nt!=\"function\"&&typeof Nt!=\"symbol\":Kr(h,x,rt,Nt,M,Dt));return;case\"img\":case\"link\":case\"area\":case\"base\":case\"br\":case\"col\":case\"embed\":case\"hr\":case\"keygen\":case\"meta\":case\"param\":case\"source\":case\"track\":case\"wbr\":case\"menuitem\":for(var Rn in S)Nt=S[Rn],S.hasOwnProperty(Rn)&&Nt!=null&&!M.hasOwnProperty(Rn)&&Kr(h,x,Rn,null,M,Nt);for(kt in M)if(Nt=M[kt],Dt=S[kt],M.hasOwnProperty(kt)&&Nt!==Dt&&(Nt!=null||Dt!=null))switch(kt){case\"children\":case\"dangerouslySetInnerHTML\":if(Nt!=null)throw Error(r(137,x));break;default:Kr(h,x,kt,Nt,M,Dt)}return;default:if(_l(x)){for(var Xr in S)Nt=S[Xr],S.hasOwnProperty(Xr)&&Nt!==void 0&&!M.hasOwnProperty(Xr)&&Qj(h,x,Xr,void 0,M,Nt);for(Ot in M)Nt=M[Ot],Dt=S[Ot],!M.hasOwnProperty(Ot)||Nt===Dt||Nt===void 0&&Dt===void 0||Qj(h,x,Ot,Nt,M,Dt);return}}for(var bt in S)Nt=S[bt],S.hasOwnProperty(bt)&&Nt!=null&&!M.hasOwnProperty(bt)&&Kr(h,x,bt,null,M,Nt);for(zt in M)Nt=M[zt],Dt=S[zt],!M.hasOwnProperty(zt)||Nt===Dt||Nt==null&&Dt==null||Kr(h,x,zt,Nt,M,Dt)}function YB(h){switch(h){case\"css\":case\"script\":case\"font\":case\"img\":case\"image\":case\"input\":case\"link\":return!0;default:return!1}}function ade(){if(typeof performance.getEntriesByType==\"function\"){for(var h=0,x=0,S=performance.getEntriesByType(\"resource\"),M=0;M<S.length;M++){var $=S[M],Z=$.transferSize,Ne=$.initiatorType,He=$.duration;if(Z&&He&&YB(Ne)){for(Ne=0,He=$.responseEnd,M+=1;M<S.length;M++){var rt=S[M],kt=rt.startTime;if(kt>He)break;var Ot=rt.transferSize,zt=rt.initiatorType;Ot&&YB(zt)&&(rt=rt.responseEnd,Ne+=Ot*(rt<He?1:(He-kt)/(rt-kt)))}if(--M,x+=8*(Z+Ne)/($.duration/1e3),h++,10<h)break}}if(0<h)return x/h/1e6}return navigator.connection&&(h=navigator.connection.downlink,typeof h==\"number\")?h:5}var Zj=null,Jj=null;function D_(h){return h.nodeType===9?h:h.ownerDocument}function QB(h){switch(h){case\"http://www.w3.org/2000/svg\":return 1;case\"http://www.w3.org/1998/Math/MathML\":return 2;default:return 0}}function ZB(h,x){if(h===0)switch(x){case\"svg\":return 1;case\"math\":return 2;default:return 0}return h===1&&x===\"foreignObject\"?0:h}function eM(h,x){return h===\"textarea\"||h===\"noscript\"||typeof x.children==\"string\"||typeof x.children==\"number\"||typeof x.children==\"bigint\"||typeof x.dangerouslySetInnerHTML==\"object\"&&x.dangerouslySetInnerHTML!==null&&x.dangerouslySetInnerHTML.__html!=null}var tM=null;function ide(){var h=window.event;return h&&h.type===\"popstate\"?h===tM?!1:(tM=h,!0):(tM=null,!1)}var JB=typeof setTimeout==\"function\"?setTimeout:void 0,sde=typeof clearTimeout==\"function\"?clearTimeout:void 0,e8=typeof Promise==\"function\"?Promise:void 0,ode=typeof queueMicrotask==\"function\"?queueMicrotask:typeof e8<\"u\"?function(h){return e8.resolve(null).then(h).catch(lde)}:JB;function lde(h){setTimeout(function(){throw h})}function rh(h){return h===\"head\"}function t8(h,x){var S=x,M=0;do{var $=S.nextSibling;if(h.removeChild(S),$&&$.nodeType===8)if(S=$.data,S===\"/$\"||S===\"/&\"){if(M===0){h.removeChild($),yb(x);return}M--}else if(S===\"$\"||S===\"$?\"||S===\"$~\"||S===\"$!\"||S===\"&\")M++;else if(S===\"html\")wv(h.ownerDocument.documentElement);else if(S===\"head\"){S=h.ownerDocument.head,wv(S);for(var Z=S.firstChild;Z;){var Ne=Z.nextSibling,He=Z.nodeName;Z[nt]||He===\"SCRIPT\"||He===\"STYLE\"||He===\"LINK\"&&Z.rel.toLowerCase()===\"stylesheet\"||S.removeChild(Z),Z=Ne}}else S===\"body\"&&wv(h.ownerDocument.body);S=$}while(S);yb(x)}function n8(h,x){var S=h;h=0;do{var M=S.nextSibling;if(S.nodeType===1?x?(S._stashedDisplay=S.style.display,S.style.display=\"none\"):(S.style.display=S._stashedDisplay||\"\",S.getAttribute(\"style\")===\"\"&&S.removeAttribute(\"style\")):S.nodeType===3&&(x?(S._stashedText=S.nodeValue,S.nodeValue=\"\"):S.nodeValue=S._stashedText||\"\"),M&&M.nodeType===8)if(S=M.data,S===\"/$\"){if(h===0)break;h--}else S!==\"$\"&&S!==\"$?\"&&S!==\"$~\"&&S!==\"$!\"||h++;S=M}while(S)}function nM(h){var x=h.firstChild;for(x&&x.nodeType===10&&(x=x.nextSibling);x;){var S=x;switch(x=x.nextSibling,S.nodeName){case\"HTML\":case\"HEAD\":case\"BODY\":nM(S),ze(S);continue;case\"SCRIPT\":case\"STYLE\":continue;case\"LINK\":if(S.rel.toLowerCase()===\"stylesheet\")continue}h.removeChild(S)}}function cde(h,x,S,M){for(;h.nodeType===1;){var $=S;if(h.nodeName.toLowerCase()!==x.toLowerCase()){if(!M&&(h.nodeName!==\"INPUT\"||h.type!==\"hidden\"))break}else if(M){if(!h[nt])switch(x){case\"meta\":if(!h.hasAttribute(\"itemprop\"))break;return h;case\"link\":if(Z=h.getAttribute(\"rel\"),Z===\"stylesheet\"&&h.hasAttribute(\"data-precedence\"))break;if(Z!==$.rel||h.getAttribute(\"href\")!==($.href==null||$.href===\"\"?null:$.href)||h.getAttribute(\"crossorigin\")!==($.crossOrigin==null?null:$.crossOrigin)||h.getAttribute(\"title\")!==($.title==null?null:$.title))break;return h;case\"style\":if(h.hasAttribute(\"data-precedence\"))break;return h;case\"script\":if(Z=h.getAttribute(\"src\"),(Z!==($.src==null?null:$.src)||h.getAttribute(\"type\")!==($.type==null?null:$.type)||h.getAttribute(\"crossorigin\")!==($.crossOrigin==null?null:$.crossOrigin))&&Z&&h.hasAttribute(\"async\")&&!h.hasAttribute(\"itemprop\"))break;return h;default:return h}}else if(x===\"input\"&&h.type===\"hidden\"){var Z=$.name==null?null:\"\"+$.name;if($.type===\"hidden\"&&h.getAttribute(\"name\")===Z)return h}else return h;if(h=Fl(h.nextSibling),h===null)break}return null}function dde(h,x,S){if(x===\"\")return null;for(;h.nodeType!==3;)if((h.nodeType!==1||h.nodeName!==\"INPUT\"||h.type!==\"hidden\")&&!S||(h=Fl(h.nextSibling),h===null))return null;return h}function r8(h,x){for(;h.nodeType!==8;)if((h.nodeType!==1||h.nodeName!==\"INPUT\"||h.type!==\"hidden\")&&!x||(h=Fl(h.nextSibling),h===null))return null;return h}function rM(h){return h.data===\"$?\"||h.data===\"$~\"}function aM(h){return h.data===\"$!\"||h.data===\"$?\"&&h.ownerDocument.readyState!==\"loading\"}function ude(h,x){var S=h.ownerDocument;if(h.data===\"$~\")h._reactRetry=x;else if(h.data!==\"$?\"||S.readyState!==\"loading\")x();else{var M=function(){x(),S.removeEventListener(\"DOMContentLoaded\",M)};S.addEventListener(\"DOMContentLoaded\",M),h._reactRetry=M}}function Fl(h){for(;h!=null;h=h.nextSibling){var x=h.nodeType;if(x===1||x===3)break;if(x===8){if(x=h.data,x===\"$\"||x===\"$!\"||x===\"$?\"||x===\"$~\"||x===\"&\"||x===\"F!\"||x===\"F\")break;if(x===\"/$\"||x===\"/&\")return null}}return h}var iM=null;function a8(h){h=h.nextSibling;for(var x=0;h;){if(h.nodeType===8){var S=h.data;if(S===\"/$\"||S===\"/&\"){if(x===0)return Fl(h.nextSibling);x--}else S!==\"$\"&&S!==\"$!\"&&S!==\"$?\"&&S!==\"$~\"&&S!==\"&\"||x++}h=h.nextSibling}return null}function i8(h){h=h.previousSibling;for(var x=0;h;){if(h.nodeType===8){var S=h.data;if(S===\"$\"||S===\"$!\"||S===\"$?\"||S===\"$~\"||S===\"&\"){if(x===0)return h;x--}else S!==\"/$\"&&S!==\"/&\"||x++}h=h.previousSibling}return null}function s8(h,x,S){switch(x=D_(S),h){case\"html\":if(h=x.documentElement,!h)throw Error(r(452));return h;case\"head\":if(h=x.head,!h)throw Error(r(453));return h;case\"body\":if(h=x.body,!h)throw Error(r(454));return h;default:throw Error(r(451))}}function wv(h){for(var x=h.attributes;x.length;)h.removeAttributeNode(x[0]);ze(h)}var Rl=new Map,o8=new Set;function F_(h){return typeof h.getRootNode==\"function\"?h.getRootNode():h.nodeType===9?h:h.ownerDocument}var Su=J.d;J.d={f:mde,r:hde,D:pde,C:fde,L:gde,m:bde,X:yde,S:xde,M:vde};function mde(){var h=Su.f(),x=N_();return h||x}function hde(h){var x=Pe(h);x!==null&&x.tag===5&&x.type===\"form\"?kU(x):Su.r(h)}var gb=typeof document>\"u\"?null:document;function l8(h,x,S){var M=gb;if(M&&typeof x==\"string\"&&x){var $=Fn(x);$='link[rel=\"'+h+'\"][href=\"'+$+'\"]',typeof S==\"string\"&&($+='[crossorigin=\"'+S+'\"]'),o8.has($)||(o8.add($),h={rel:h,crossOrigin:S,href:x},M.querySelector($)===null&&(x=M.createElement(\"link\"),cs(x,\"link\",h),Je(x),M.head.appendChild(x)))}}function pde(h){Su.D(h),l8(\"dns-prefetch\",h,null)}function fde(h,x){Su.C(h,x),l8(\"preconnect\",h,x)}function gde(h,x,S){Su.L(h,x,S);var M=gb;if(M&&h&&x){var $='link[rel=\"preload\"][as=\"'+Fn(x)+'\"]';x===\"image\"&&S&&S.imageSrcSet?($+='[imagesrcset=\"'+Fn(S.imageSrcSet)+'\"]',typeof S.imageSizes==\"string\"&&($+='[imagesizes=\"'+Fn(S.imageSizes)+'\"]')):$+='[href=\"'+Fn(h)+'\"]';var Z=$;switch(x){case\"style\":Z=bb(h);break;case\"script\":Z=xb(h)}Rl.has(Z)||(h=m({rel:\"preload\",href:x===\"image\"&&S&&S.imageSrcSet?void 0:h,as:x},S),Rl.set(Z,h),M.querySelector($)!==null||x===\"style\"&&M.querySelector(Sv(Z))||x===\"script\"&&M.querySelector(_v(Z))||(x=M.createElement(\"link\"),cs(x,\"link\",h),Je(x),M.head.appendChild(x)))}}function bde(h,x){Su.m(h,x);var S=gb;if(S&&h){var M=x&&typeof x.as==\"string\"?x.as:\"script\",$='link[rel=\"modulepreload\"][as=\"'+Fn(M)+'\"][href=\"'+Fn(h)+'\"]',Z=$;switch(M){case\"audioworklet\":case\"paintworklet\":case\"serviceworker\":case\"sharedworker\":case\"worker\":case\"script\":Z=xb(h)}if(!Rl.has(Z)&&(h=m({rel:\"modulepreload\",href:h},x),Rl.set(Z,h),S.querySelector($)===null)){switch(M){case\"audioworklet\":case\"paintworklet\":case\"serviceworker\":case\"sharedworker\":case\"worker\":case\"script\":if(S.querySelector(_v(Z)))return}M=S.createElement(\"link\"),cs(M,\"link\",h),Je(M),S.head.appendChild(M)}}}function xde(h,x,S){Su.S(h,x,S);var M=gb;if(M&&h){var $=Ze(M).hoistableStyles,Z=bb(h);x=x||\"default\";var Ne=$.get(Z);if(!Ne){var He={loading:0,preload:null};if(Ne=M.querySelector(Sv(Z)))He.loading=5;else{h=m({rel:\"stylesheet\",href:h,\"data-precedence\":x},S),(S=Rl.get(Z))&&sM(h,S);var rt=Ne=M.createElement(\"link\");Je(rt),cs(rt,\"link\",h),rt._p=new Promise(function(kt,Ot){rt.onload=kt,rt.onerror=Ot}),rt.addEventListener(\"load\",function(){He.loading|=1}),rt.addEventListener(\"error\",function(){He.loading|=2}),He.loading|=4,R_(Ne,x,M)}Ne={type:\"stylesheet\",instance:Ne,count:1,state:He},$.set(Z,Ne)}}}function yde(h,x){Su.X(h,x);var S=gb;if(S&&h){var M=Ze(S).hoistableScripts,$=xb(h),Z=M.get($);Z||(Z=S.querySelector(_v($)),Z||(h=m({src:h,async:!0},x),(x=Rl.get($))&&oM(h,x),Z=S.createElement(\"script\"),Je(Z),cs(Z,\"link\",h),S.head.appendChild(Z)),Z={type:\"script\",instance:Z,count:1,state:null},M.set($,Z))}}function vde(h,x){Su.M(h,x);var S=gb;if(S&&h){var M=Ze(S).hoistableScripts,$=xb(h),Z=M.get($);Z||(Z=S.querySelector(_v($)),Z||(h=m({src:h,async:!0,type:\"module\"},x),(x=Rl.get($))&&oM(h,x),Z=S.createElement(\"script\"),Je(Z),cs(Z,\"link\",h),S.head.appendChild(Z)),Z={type:\"script\",instance:Z,count:1,state:null},M.set($,Z))}}function c8(h,x,S,M){var $=($=q.current)?F_($):null;if(!$)throw Error(r(446));switch(h){case\"meta\":case\"title\":return null;case\"style\":return typeof S.precedence==\"string\"&&typeof S.href==\"string\"?(x=bb(S.href),S=Ze($).hoistableStyles,M=S.get(x),M||(M={type:\"style\",instance:null,count:0,state:null},S.set(x,M)),M):{type:\"void\",instance:null,count:0,state:null};case\"link\":if(S.rel===\"stylesheet\"&&typeof S.href==\"string\"&&typeof S.precedence==\"string\"){h=bb(S.href);var Z=Ze($).hoistableStyles,Ne=Z.get(h);if(Ne||($=$.ownerDocument||$,Ne={type:\"stylesheet\",instance:null,count:0,state:{loading:0,preload:null}},Z.set(h,Ne),(Z=$.querySelector(Sv(h)))&&!Z._p&&(Ne.instance=Z,Ne.state.loading=5),Rl.has(h)||(S={rel:\"preload\",as:\"style\",href:S.href,crossOrigin:S.crossOrigin,integrity:S.integrity,media:S.media,hrefLang:S.hrefLang,referrerPolicy:S.referrerPolicy},Rl.set(h,S),Z||wde($,h,S,Ne.state))),x&&M===null)throw Error(r(528,\"\"));return Ne}if(x&&M!==null)throw Error(r(529,\"\"));return null;case\"script\":return x=S.async,S=S.src,typeof S==\"string\"&&x&&typeof x!=\"function\"&&typeof x!=\"symbol\"?(x=xb(S),S=Ze($).hoistableScripts,M=S.get(x),M||(M={type:\"script\",instance:null,count:0,state:null},S.set(x,M)),M):{type:\"void\",instance:null,count:0,state:null};default:throw Error(r(444,h))}}function bb(h){return'href=\"'+Fn(h)+'\"'}function Sv(h){return'link[rel=\"stylesheet\"]['+h+\"]\"}function d8(h){return m({},h,{\"data-precedence\":h.precedence,precedence:null})}function wde(h,x,S,M){h.querySelector('link[rel=\"preload\"][as=\"style\"]['+x+\"]\")?M.loading=1:(x=h.createElement(\"link\"),M.preload=x,x.addEventListener(\"load\",function(){return M.loading|=1}),x.addEventListener(\"error\",function(){return M.loading|=2}),cs(x,\"link\",S),Je(x),h.head.appendChild(x))}function xb(h){return'[src=\"'+Fn(h)+'\"]'}function _v(h){return\"script[async]\"+h}function u8(h,x,S){if(x.count++,x.instance===null)switch(x.type){case\"style\":var M=h.querySelector('style[data-href~=\"'+Fn(S.href)+'\"]');if(M)return x.instance=M,Je(M),M;var $=m({},S,{\"data-href\":S.href,\"data-precedence\":S.precedence,href:null,precedence:null});return M=(h.ownerDocument||h).createElement(\"style\"),Je(M),cs(M,\"style\",$),R_(M,S.precedence,h),x.instance=M;case\"stylesheet\":$=bb(S.href);var Z=h.querySelector(Sv($));if(Z)return x.state.loading|=4,x.instance=Z,Je(Z),Z;M=d8(S),($=Rl.get($))&&sM(M,$),Z=(h.ownerDocument||h).createElement(\"link\"),Je(Z);var Ne=Z;return Ne._p=new Promise(function(He,rt){Ne.onload=He,Ne.onerror=rt}),cs(Z,\"link\",M),x.state.loading|=4,R_(Z,S.precedence,h),x.instance=Z;case\"script\":return Z=xb(S.src),($=h.querySelector(_v(Z)))?(x.instance=$,Je($),$):(M=S,($=Rl.get(Z))&&(M=m({},S),oM(M,$)),h=h.ownerDocument||h,$=h.createElement(\"script\"),Je($),cs($,\"link\",M),h.head.appendChild($),x.instance=$);case\"void\":return null;default:throw Error(r(443,x.type))}else x.type===\"stylesheet\"&&(x.state.loading&4)===0&&(M=x.instance,x.state.loading|=4,R_(M,S.precedence,h));return x.instance}function R_(h,x,S){for(var M=S.querySelectorAll('link[rel=\"stylesheet\"][data-precedence],style[data-precedence]'),$=M.length?M[M.length-1]:null,Z=$,Ne=0;Ne<M.length;Ne++){var He=M[Ne];if(He.dataset.precedence===x)Z=He;else if(Z!==$)break}Z?Z.parentNode.insertBefore(h,Z.nextSibling):(x=S.nodeType===9?S.head:S,x.insertBefore(h,x.firstChild))}function sM(h,x){h.crossOrigin==null&&(h.crossOrigin=x.crossOrigin),h.referrerPolicy==null&&(h.referrerPolicy=x.referrerPolicy),h.title==null&&(h.title=x.title)}function oM(h,x){h.crossOrigin==null&&(h.crossOrigin=x.crossOrigin),h.referrerPolicy==null&&(h.referrerPolicy=x.referrerPolicy),h.integrity==null&&(h.integrity=x.integrity)}var L_=null;function m8(h,x,S){if(L_===null){var M=new Map,$=L_=new Map;$.set(S,M)}else $=L_,M=$.get(S),M||(M=new Map,$.set(S,M));if(M.has(h))return M;for(M.set(h,null),S=S.getElementsByTagName(h),$=0;$<S.length;$++){var Z=S[$];if(!(Z[nt]||Z[at]||h===\"link\"&&Z.getAttribute(\"rel\")===\"stylesheet\")&&Z.namespaceURI!==\"http://www.w3.org/2000/svg\"){var Ne=Z.getAttribute(x)||\"\";Ne=h+Ne;var He=M.get(Ne);He?He.push(Z):M.set(Ne,[Z])}}return M}function h8(h,x,S){h=h.ownerDocument||h,h.head.insertBefore(S,x===\"title\"?h.querySelector(\"head > title\"):null)}function Sde(h,x,S){if(S===1||x.itemProp!=null)return!1;switch(h){case\"meta\":case\"title\":return!0;case\"style\":if(typeof x.precedence!=\"string\"||typeof x.href!=\"string\"||x.href===\"\")break;return!0;case\"link\":if(typeof x.rel!=\"string\"||typeof x.href!=\"string\"||x.href===\"\"||x.onLoad||x.onError)break;return x.rel===\"stylesheet\"?(h=x.disabled,typeof x.precedence==\"string\"&&h==null):!0;case\"script\":if(x.async&&typeof x.async!=\"function\"&&typeof x.async!=\"symbol\"&&!x.onLoad&&!x.onError&&x.src&&typeof x.src==\"string\")return!0}return!1}function p8(h){return!(h.type===\"stylesheet\"&&(h.state.loading&3)===0)}function _de(h,x,S,M){if(S.type===\"stylesheet\"&&(typeof M.media!=\"string\"||matchMedia(M.media).matches!==!1)&&(S.state.loading&4)===0){if(S.instance===null){var $=bb(M.href),Z=x.querySelector(Sv($));if(Z){x=Z._p,x!==null&&typeof x==\"object\"&&typeof x.then==\"function\"&&(h.count++,h=O_.bind(h),x.then(h,h)),S.state.loading|=4,S.instance=Z,Je(Z);return}Z=x.ownerDocument||x,M=d8(M),($=Rl.get($))&&sM(M,$),Z=Z.createElement(\"link\"),Je(Z);var Ne=Z;Ne._p=new Promise(function(He,rt){Ne.onload=He,Ne.onerror=rt}),cs(Z,\"link\",M),S.instance=Z}h.stylesheets===null&&(h.stylesheets=new Map),h.stylesheets.set(S,x),(x=S.state.preload)&&(S.state.loading&3)===0&&(h.count++,S=O_.bind(h),x.addEventListener(\"load\",S),x.addEventListener(\"error\",S))}}var lM=0;function kde(h,x){return h.stylesheets&&h.count===0&&z_(h,h.stylesheets),0<h.count||0<h.imgCount?function(S){var M=setTimeout(function(){if(h.stylesheets&&z_(h,h.stylesheets),h.unsuspend){var Z=h.unsuspend;h.unsuspend=null,Z()}},6e4+x);0<h.imgBytes&&lM===0&&(lM=62500*ade());var $=setTimeout(function(){if(h.waitingForImages=!1,h.count===0&&(h.stylesheets&&z_(h,h.stylesheets),h.unsuspend)){var Z=h.unsuspend;h.unsuspend=null,Z()}},(h.imgBytes>lM?50:800)+x);return h.unsuspend=S,function(){h.unsuspend=null,clearTimeout(M),clearTimeout($)}}:null}function O_(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)z_(this,this.stylesheets);else if(this.unsuspend){var h=this.unsuspend;this.unsuspend=null,h()}}}var I_=null;function z_(h,x){h.stylesheets=null,h.unsuspend!==null&&(h.count++,I_=new Map,x.forEach(Nde,h),I_=null,O_.call(h))}function Nde(h,x){if(!(x.state.loading&4)){var S=I_.get(h);if(S)var M=S.get(null);else{S=new Map,I_.set(h,S);for(var $=h.querySelectorAll(\"link[data-precedence],style[data-precedence]\"),Z=0;Z<$.length;Z++){var Ne=$[Z];(Ne.nodeName===\"LINK\"||Ne.getAttribute(\"media\")!==\"not all\")&&(S.set(Ne.dataset.precedence,Ne),M=Ne)}M&&S.set(null,M)}$=x.instance,Ne=$.getAttribute(\"data-precedence\"),Z=S.get(Ne)||M,Z===M&&S.set(null,$),S.set(Ne,$),this.count++,M=O_.bind(this),$.addEventListener(\"load\",M),$.addEventListener(\"error\",M),Z?Z.parentNode.insertBefore($,Z.nextSibling):(h=h.nodeType===9?h.head:h,h.insertBefore($,h.firstChild)),x.state.loading|=4}}var kv={$$typeof:C,Provider:null,Consumer:null,_currentValue:oe,_currentValue2:oe,_threadCount:0};function Cde(h,x,S,M,$,Z,Ne,He,rt){this.tag=1,this.containerInfo=h,this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.next=this.pendingContext=this.context=this.cancelPendingCommit=null,this.callbackPriority=0,this.expirationTimes=ht(-1),this.entangledLanes=this.shellSuspendCounter=this.errorRecoveryDisabledLanes=this.expiredLanes=this.warmLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ht(0),this.hiddenUpdates=ht(null),this.identifierPrefix=M,this.onUncaughtError=$,this.onCaughtError=Z,this.onRecoverableError=Ne,this.pooledCache=null,this.pooledCacheLanes=0,this.formState=rt,this.incompleteTransitions=new Map}function f8(h,x,S,M,$,Z,Ne,He,rt,kt,Ot,zt){return h=new Cde(h,x,S,Ne,rt,kt,Ot,zt,He),x=1,Z===!0&&(x|=24),Z=Qo(3,null,null,x),h.current=Z,Z.stateNode=h,x=BA(),x.refCount++,h.pooledCache=x,x.refCount++,Z.memoizedState={element:M,isDehydrated:S,cache:x},VA(Z),h}function g8(h){return h?(h=Xg,h):Xg}function b8(h,x,S,M,$,Z){$=g8($),M.context===null?M.context=$:M.pendingContext=$,M=Gm(x),M.payload={element:S},Z=Z===void 0?null:Z,Z!==null&&(M.callback=Z),S=Wm(h,M,x),S!==null&&(Ao(S,h,x),nv(S,h,x))}function x8(h,x){if(h=h.memoizedState,h!==null&&h.dehydrated!==null){var S=h.retryLane;h.retryLane=S!==0&&S<x?S:x}}function cM(h,x){x8(h,x),(h=h.alternate)&&x8(h,x)}function y8(h){if(h.tag===13||h.tag===31){var x=Kp(h,67108864);x!==null&&Ao(x,h,67108864),cM(h,67108864)}}function v8(h){if(h.tag===13||h.tag===31){var x=nl();x=Kt(x);var S=Kp(h,x);S!==null&&Ao(S,h,x),cM(h,x)}}var U_=!0;function Pde(h,x,S,M){var $=ie.T;ie.T=null;var Z=J.p;try{J.p=2,dM(h,x,S,M)}finally{J.p=Z,ie.T=$}}function Tde(h,x,S,M){var $=ie.T;ie.T=null;var Z=J.p;try{J.p=8,dM(h,x,S,M)}finally{J.p=Z,ie.T=$}}function dM(h,x,S,M){if(U_){var $=uM(M);if($===null)Yj(h,x,M,B_,S),S8(h,M);else if(jde($,h,x,S,M))M.stopPropagation();else if(S8(h,M),x&4&&-1<Ade.indexOf(h)){for(;$!==null;){var Z=Pe($);if(Z!==null)switch(Z.tag){case 3:if(Z=Z.stateNode,Z.current.memoizedState.isDehydrated){var Ne=Ae(Z.pendingLanes);if(Ne!==0){var He=Z;for(He.pendingLanes|=2,He.entangledLanes|=2;Ne;){var rt=1<<31-$e(Ne);He.entanglements[1]|=rt,Ne&=~rt}md(Z),(Lr&6)===0&&(__=R()+500,xv(0))}}break;case 31:case 13:He=Kp(Z,2),He!==null&&Ao(He,Z,2),N_(),cM(Z,2)}if(Z=uM(M),Z===null&&Yj(h,x,M,B_,S),Z===$)break;$=Z}$!==null&&M.stopPropagation()}else Yj(h,x,M,null,S)}}function uM(h){return h=rs(h),mM(h)}var B_=null;function mM(h){if(B_=null,h=ot(h),h!==null){var x=s(h);if(x===null)h=null;else{var S=x.tag;if(S===13){if(h=o(x),h!==null)return h;h=null}else if(S===31){if(h=l(x),h!==null)return h;h=null}else if(S===3){if(x.stateNode.current.memoizedState.isDehydrated)return x.tag===3?x.stateNode.containerInfo:null;h=null}else x!==h&&(h=null)}}return B_=h,null}function w8(h){switch(h){case\"beforetoggle\":case\"cancel\":case\"click\":case\"close\":case\"contextmenu\":case\"copy\":case\"cut\":case\"auxclick\":case\"dblclick\":case\"dragend\":case\"dragstart\":case\"drop\":case\"focusin\":case\"focusout\":case\"input\":case\"invalid\":case\"keydown\":case\"keypress\":case\"keyup\":case\"mousedown\":case\"mouseup\":case\"paste\":case\"pause\":case\"play\":case\"pointercancel\":case\"pointerdown\":case\"pointerup\":case\"ratechange\":case\"reset\":case\"resize\":case\"seeked\":case\"submit\":case\"toggle\":case\"touchcancel\":case\"touchend\":case\"touchstart\":case\"volumechange\":case\"change\":case\"selectionchange\":case\"textInput\":case\"compositionstart\":case\"compositionend\":case\"compositionupdate\":case\"beforeblur\":case\"afterblur\":case\"beforeinput\":case\"blur\":case\"fullscreenchange\":case\"focus\":case\"hashchange\":case\"popstate\":case\"select\":case\"selectstart\":return 2;case\"drag\":case\"dragenter\":case\"dragexit\":case\"dragleave\":case\"dragover\":case\"mousemove\":case\"mouseout\":case\"mouseover\":case\"pointermove\":case\"pointerout\":case\"pointerover\":case\"scroll\":case\"touchmove\":case\"wheel\":case\"mouseenter\":case\"mouseleave\":case\"pointerenter\":case\"pointerleave\":return 8;case\"message\":switch(ae()){case _e:return 2;case Se:return 8;case ve:case Te:return 32;case ye:return 268435456;default:return 32}default:return 32}}var hM=!1,ah=null,ih=null,sh=null,Nv=new Map,Cv=new Map,oh=[],Ade=\"mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset\".split(\" \");function S8(h,x){switch(h){case\"focusin\":case\"focusout\":ah=null;break;case\"dragenter\":case\"dragleave\":ih=null;break;case\"mouseover\":case\"mouseout\":sh=null;break;case\"pointerover\":case\"pointerout\":Nv.delete(x.pointerId);break;case\"gotpointercapture\":case\"lostpointercapture\":Cv.delete(x.pointerId)}}function Pv(h,x,S,M,$,Z){return h===null||h.nativeEvent!==Z?(h={blockedOn:x,domEventName:S,eventSystemFlags:M,nativeEvent:Z,targetContainers:[$]},x!==null&&(x=Pe(x),x!==null&&y8(x)),h):(h.eventSystemFlags|=M,x=h.targetContainers,$!==null&&x.indexOf($)===-1&&x.push($),h)}function jde(h,x,S,M,$){switch(x){case\"focusin\":return ah=Pv(ah,h,x,S,M,$),!0;case\"dragenter\":return ih=Pv(ih,h,x,S,M,$),!0;case\"mouseover\":return sh=Pv(sh,h,x,S,M,$),!0;case\"pointerover\":var Z=$.pointerId;return Nv.set(Z,Pv(Nv.get(Z)||null,h,x,S,M,$)),!0;case\"gotpointercapture\":return Z=$.pointerId,Cv.set(Z,Pv(Cv.get(Z)||null,h,x,S,M,$)),!0}return!1}function _8(h){var x=ot(h.target);if(x!==null){var S=s(x);if(S!==null){if(x=S.tag,x===13){if(x=o(S),x!==null){h.blockedOn=x,vn(h.priority,function(){v8(S)});return}}else if(x===31){if(x=l(S),x!==null){h.blockedOn=x,vn(h.priority,function(){v8(S)});return}}else if(x===3&&S.stateNode.current.memoizedState.isDehydrated){h.blockedOn=S.tag===3?S.stateNode.containerInfo:null;return}}}h.blockedOn=null}function H_(h){if(h.blockedOn!==null)return!1;for(var x=h.targetContainers;0<x.length;){var S=uM(h.nativeEvent);if(S===null){S=h.nativeEvent;var M=new S.constructor(S.type,S);Ks=M,S.target.dispatchEvent(M),Ks=null}else return x=Pe(S),x!==null&&y8(x),h.blockedOn=S,!1;x.shift()}return!0}function k8(h,x,S){H_(h)&&S.delete(x)}function Mde(){hM=!1,ah!==null&&H_(ah)&&(ah=null),ih!==null&&H_(ih)&&(ih=null),sh!==null&&H_(sh)&&(sh=null),Nv.forEach(k8),Cv.forEach(k8)}function q_(h,x){h.blockedOn===x&&(h.blockedOn=null,hM||(hM=!0,t.unstable_scheduleCallback(t.unstable_NormalPriority,Mde)))}var $_=null;function N8(h){$_!==h&&($_=h,t.unstable_scheduleCallback(t.unstable_NormalPriority,function(){$_===h&&($_=null);for(var x=0;x<h.length;x+=3){var S=h[x],M=h[x+1],$=h[x+2];if(typeof M!=\"function\"){if(mM(M||S)===null)continue;break}var Z=Pe(S);Z!==null&&(h.splice(x,3),x-=3,uj(Z,{pending:!0,data:$,method:S.method,action:M},M,$))}}))}function yb(h){function x(rt){return q_(rt,h)}ah!==null&&q_(ah,h),ih!==null&&q_(ih,h),sh!==null&&q_(sh,h),Nv.forEach(x),Cv.forEach(x);for(var S=0;S<oh.length;S++){var M=oh[S];M.blockedOn===h&&(M.blockedOn=null)}for(;0<oh.length&&(S=oh[0],S.blockedOn===null);)_8(S),S.blockedOn===null&&oh.shift();if(S=(h.ownerDocument||h).$$reactFormReplay,S!=null)for(M=0;M<S.length;M+=3){var $=S[M],Z=S[M+1],Ne=$[Lt]||null;if(typeof Z==\"function\")Ne||N8(S);else if(Ne){var He=null;if(Z&&Z.hasAttribute(\"formAction\")){if($=Z,Ne=Z[Lt]||null)He=Ne.formAction;else if(mM($)!==null)continue}else He=Ne.action;typeof He==\"function\"?S[M+1]=He:(S.splice(M,3),M-=3),N8(S)}}}function C8(){function h(Z){Z.canIntercept&&Z.info===\"react-transition\"&&Z.intercept({handler:function(){return new Promise(function(Ne){return $=Ne})},focusReset:\"manual\",scroll:\"manual\"})}function x(){$!==null&&($(),$=null),M||setTimeout(S,20)}function S(){if(!M&&!navigation.transition){var Z=navigation.currentEntry;Z&&Z.url!=null&&navigation.navigate(Z.url,{state:Z.getState(),info:\"react-transition\",history:\"replace\"})}}if(typeof navigation==\"object\"){var M=!1,$=null;return navigation.addEventListener(\"navigate\",h),navigation.addEventListener(\"navigatesuccess\",x),navigation.addEventListener(\"navigateerror\",x),setTimeout(S,100),function(){M=!0,navigation.removeEventListener(\"navigate\",h),navigation.removeEventListener(\"navigatesuccess\",x),navigation.removeEventListener(\"navigateerror\",x),$!==null&&($(),$=null)}}}function pM(h){this._internalRoot=h}V_.prototype.render=pM.prototype.render=function(h){var x=this._internalRoot;if(x===null)throw Error(r(409));var S=x.current,M=nl();b8(S,M,h,x,null,null)},V_.prototype.unmount=pM.prototype.unmount=function(){var h=this._internalRoot;if(h!==null){this._internalRoot=null;var x=h.containerInfo;b8(h.current,2,null,h,null,null),N_(),x[Et]=null}};function V_(h){this._internalRoot=h}V_.prototype.unstable_scheduleHydration=function(h){if(h){var x=ln();h={blockedOn:null,target:h,priority:x};for(var S=0;S<oh.length&&x!==0&&x<oh[S].priority;S++);oh.splice(S,0,h),S===0&&_8(h)}};var P8=e.version;if(P8!==\"19.2.4\")throw Error(r(527,P8,\"19.2.4\"));J.findDOMNode=function(h){var x=h._reactInternals;if(x===void 0)throw typeof h.render==\"function\"?Error(r(188)):(h=Object.keys(h).join(\",\"),Error(r(268,h)));return h=d(x),h=h!==null?u(h):null,h=h===null?null:h.stateNode,h};var Ede={bundleType:0,version:\"19.2.4\",rendererPackageName:\"react-dom\",currentDispatcherRef:ie,reconcilerVersion:\"19.2.4\"};if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<\"u\"){var G_=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!G_.isDisabled&&G_.supportsFiber)try{Me=G_.inject(Ede),Oe=G_}catch{}}return Av.createRoot=function(h,x){if(!i(h))throw Error(r(299));var S=!1,M=\"\",$=FU,Z=RU,Ne=LU;return x!=null&&(x.unstable_strictMode===!0&&(S=!0),x.identifierPrefix!==void 0&&(M=x.identifierPrefix),x.onUncaughtError!==void 0&&($=x.onUncaughtError),x.onCaughtError!==void 0&&(Z=x.onCaughtError),x.onRecoverableError!==void 0&&(Ne=x.onRecoverableError)),x=f8(h,1,!1,null,null,S,M,null,$,Z,Ne,C8),h[Et]=x.current,Xj(h),new pM(x)},Av.hydrateRoot=function(h,x,S){if(!i(h))throw Error(r(299));var M=!1,$=\"\",Z=FU,Ne=RU,He=LU,rt=null;return S!=null&&(S.unstable_strictMode===!0&&(M=!0),S.identifierPrefix!==void 0&&($=S.identifierPrefix),S.onUncaughtError!==void 0&&(Z=S.onUncaughtError),S.onCaughtError!==void 0&&(Ne=S.onCaughtError),S.onRecoverableError!==void 0&&(He=S.onRecoverableError),S.formState!==void 0&&(rt=S.formState)),x=f8(h,1,!0,x,S??null,M,$,rt,Z,Ne,He,C8),x.context=g8(null),S=x.current,M=nl(),M=Kt(M),$=Gm(M),$.callback=null,Wm(S,$,M),S=M,x.current.lanes=S,xt(x,S),md(x),h[Et]=x.current,Xj(h),new V_(x)},Av.version=\"19.2.4\",Av}var O8;function qde(){if(O8)return bM.exports;O8=1;function t(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>\"u\"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=\"function\"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),bM.exports=Hde(),bM.exports}var $de=qde();const Hn=t=>typeof t==\"string\",jv=()=>{let t,e;const n=new Promise((r,i)=>{t=r,e=i});return n.resolve=t,n.reject=e,n},I8=t=>t==null?\"\":\"\"+t,Vde=(t,e,n)=>{t.forEach(r=>{e[r]&&(n[r]=e[r])})},Gde=/###/g,z8=t=>t&&t.indexOf(\"###\")>-1?t.replace(Gde,\".\"):t,U8=t=>!t||Hn(t),P0=(t,e,n)=>{const r=Hn(e)?e.split(\".\"):e;let i=0;for(;i<r.length-1;){if(U8(t))return{};const s=z8(r[i]);!t[s]&&n&&(t[s]=new n),Object.prototype.hasOwnProperty.call(t,s)?t=t[s]:t={},++i}return U8(t)?{}:{obj:t,k:z8(r[i])}},B8=(t,e,n)=>{const{obj:r,k:i}=P0(t,e,Object);if(r!==void 0||e.length===1){r[i]=n;return}let s=e[e.length-1],o=e.slice(0,e.length-1),l=P0(t,o,Object);for(;l.obj===void 0&&o.length;)s=`${o[o.length-1]}.${s}`,o=o.slice(0,o.length-1),l=P0(t,o,Object),l?.obj&&typeof l.obj[`${l.k}.${s}`]<\"u\"&&(l.obj=void 0);l.obj[`${l.k}.${s}`]=n},Wde=(t,e,n,r)=>{const{obj:i,k:s}=P0(t,e,Object);i[s]=i[s]||[],i[s].push(n)},mN=(t,e)=>{const{obj:n,k:r}=P0(t,e);if(n&&Object.prototype.hasOwnProperty.call(n,r))return n[r]},Kde=(t,e,n)=>{const r=mN(t,n);return r!==void 0?r:mN(e,n)},BY=(t,e,n)=>{for(const r in e)r!==\"__proto__\"&&r!==\"constructor\"&&(r in t?Hn(t[r])||t[r]instanceof String||Hn(e[r])||e[r]instanceof String?n&&(t[r]=e[r]):BY(t[r],e[r],n):t[r]=e[r]);return t},vb=t=>t.replace(/[\\-\\[\\]\\/\\{\\}\\(\\)\\*\\+\\?\\.\\\\\\^\\$\\|]/g,\"\\\\$&\");var Xde={\"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",'\"':\"&quot;\",\"'\":\"&#39;\",\"/\":\"&#x2F;\"};const Yde=t=>Hn(t)?t.replace(/[&<>\"'\\/]/g,e=>Xde[e]):t;class Qde{constructor(e){this.capacity=e,this.regExpMap=new Map,this.regExpQueue=[]}getRegExp(e){const n=this.regExpMap.get(e);if(n!==void 0)return n;const r=new RegExp(e);return this.regExpQueue.length===this.capacity&&this.regExpMap.delete(this.regExpQueue.shift()),this.regExpMap.set(e,r),this.regExpQueue.push(e),r}}const Zde=[\" \",\",\",\"?\",\"!\",\";\"],Jde=new Qde(20),eue=(t,e,n)=>{e=e||\"\",n=n||\"\";const r=Zde.filter(o=>e.indexOf(o)<0&&n.indexOf(o)<0);if(r.length===0)return!0;const i=Jde.getRegExp(`(${r.map(o=>o===\"?\"?\"\\\\?\":o).join(\"|\")})`);let s=!i.test(t);if(!s){const o=t.indexOf(n);o>0&&!i.test(t.substring(0,o))&&(s=!0)}return s},H3=(t,e,n=\".\")=>{if(!t)return;if(t[e])return Object.prototype.hasOwnProperty.call(t,e)?t[e]:void 0;const r=e.split(n);let i=t;for(let s=0;s<r.length;){if(!i||typeof i!=\"object\")return;let o,l=\"\";for(let c=s;c<r.length;++c)if(c!==s&&(l+=n),l+=r[c],o=i[l],o!==void 0){if([\"string\",\"number\",\"boolean\"].indexOf(typeof o)>-1&&c<r.length-1)continue;s+=c-s+1;break}i=o}return i},ew=t=>t?.replace(\"_\",\"-\"),tue={type:\"logger\",log(t){this.output(\"log\",t)},warn(t){this.output(\"warn\",t)},error(t){this.output(\"error\",t)},output(t,e){console?.[t]?.apply?.(console,e)}};class hN{constructor(e,n={}){this.init(e,n)}init(e,n={}){this.prefix=n.prefix||\"i18next:\",this.logger=e||tue,this.options=n,this.debug=n.debug}log(...e){return this.forward(e,\"log\",\"\",!0)}warn(...e){return this.forward(e,\"warn\",\"\",!0)}error(...e){return this.forward(e,\"error\",\"\")}deprecate(...e){return this.forward(e,\"warn\",\"WARNING DEPRECATED: \",!0)}forward(e,n,r,i){return i&&!this.debug?null:(Hn(e[0])&&(e[0]=`${r}${this.prefix} ${e[0]}`),this.logger[n](e))}create(e){return new hN(this.logger,{prefix:`${this.prefix}:${e}:`,...this.options})}clone(e){return e=e||this.options,e.prefix=e.prefix||this.prefix,new hN(this.logger,e)}}var Ad=new hN;let IP=class{constructor(){this.observers={}}on(e,n){return e.split(\" \").forEach(r=>{this.observers[r]||(this.observers[r]=new Map);const i=this.observers[r].get(n)||0;this.observers[r].set(n,i+1)}),this}off(e,n){if(this.observers[e]){if(!n){delete this.observers[e];return}this.observers[e].delete(n)}}emit(e,...n){this.observers[e]&&Array.from(this.observers[e].entries()).forEach(([i,s])=>{for(let o=0;o<s;o++)i(...n)}),this.observers[\"*\"]&&Array.from(this.observers[\"*\"].entries()).forEach(([i,s])=>{for(let o=0;o<s;o++)i.apply(i,[e,...n])})}};class H8 extends IP{constructor(e,n={ns:[\"translation\"],defaultNS:\"translation\"}){super(),this.data=e||{},this.options=n,this.options.keySeparator===void 0&&(this.options.keySeparator=\".\"),this.options.ignoreJSONStructure===void 0&&(this.options.ignoreJSONStructure=!0)}addNamespaces(e){this.options.ns.indexOf(e)<0&&this.options.ns.push(e)}removeNamespaces(e){const n=this.options.ns.indexOf(e);n>-1&&this.options.ns.splice(n,1)}getResource(e,n,r,i={}){const s=i.keySeparator!==void 0?i.keySeparator:this.options.keySeparator,o=i.ignoreJSONStructure!==void 0?i.ignoreJSONStructure:this.options.ignoreJSONStructure;let l;e.indexOf(\".\")>-1?l=e.split(\".\"):(l=[e,n],r&&(Array.isArray(r)?l.push(...r):Hn(r)&&s?l.push(...r.split(s)):l.push(r)));const c=mN(this.data,l);return!c&&!n&&!r&&e.indexOf(\".\")>-1&&(e=l[0],n=l[1],r=l.slice(2).join(\".\")),c||!o||!Hn(r)?c:H3(this.data?.[e]?.[n],r,s)}addResource(e,n,r,i,s={silent:!1}){const o=s.keySeparator!==void 0?s.keySeparator:this.options.keySeparator;let l=[e,n];r&&(l=l.concat(o?r.split(o):r)),e.indexOf(\".\")>-1&&(l=e.split(\".\"),i=n,n=l[1]),this.addNamespaces(n),B8(this.data,l,i),s.silent||this.emit(\"added\",e,n,r,i)}addResources(e,n,r,i={silent:!1}){for(const s in r)(Hn(r[s])||Array.isArray(r[s]))&&this.addResource(e,n,s,r[s],{silent:!0});i.silent||this.emit(\"added\",e,n,r)}addResourceBundle(e,n,r,i,s,o={silent:!1,skipCopy:!1}){let l=[e,n];e.indexOf(\".\")>-1&&(l=e.split(\".\"),i=r,r=n,n=l[1]),this.addNamespaces(n);let c=mN(this.data,l)||{};o.skipCopy||(r=JSON.parse(JSON.stringify(r))),i?BY(c,r,s):c={...c,...r},B8(this.data,l,c),o.silent||this.emit(\"added\",e,n,r)}removeResourceBundle(e,n){this.hasResourceBundle(e,n)&&delete this.data[e][n],this.removeNamespaces(n),this.emit(\"removed\",e,n)}hasResourceBundle(e,n){return this.getResource(e,n)!==void 0}getResourceBundle(e,n){return n||(n=this.options.defaultNS),this.getResource(e,n)}getDataByLanguage(e){return this.data[e]}hasLanguageSomeTranslations(e){const n=this.getDataByLanguage(e);return!!(n&&Object.keys(n)||[]).find(i=>n[i]&&Object.keys(n[i]).length>0)}toJSON(){return this.data}}var HY={processors:{},addPostProcessor(t){this.processors[t.name]=t},handle(t,e,n,r,i){return t.forEach(s=>{e=this.processors[s]?.process(e,n,r,i)??e}),e}};const qY=Symbol(\"i18next/PATH_KEY\");function nue(){const t=[],e=Object.create(null);let n;return e.get=(r,i)=>(n?.revoke?.(),i===qY?t:(t.push(i),n=Proxy.revocable(r,e),n.proxy)),Proxy.revocable(Object.create(null),e).proxy}function q3(t,e){const{[qY]:n}=t(nue());return n.join(e?.keySeparator??\".\")}const q8={},wM=t=>!Hn(t)&&typeof t!=\"boolean\"&&typeof t!=\"number\";class pN extends IP{constructor(e,n={}){super(),Vde([\"resourceStore\",\"languageUtils\",\"pluralResolver\",\"interpolator\",\"backendConnector\",\"i18nFormat\",\"utils\"],e,this),this.options=n,this.options.keySeparator===void 0&&(this.options.keySeparator=\".\"),this.logger=Ad.create(\"translator\")}changeLanguage(e){e&&(this.language=e)}exists(e,n={interpolation:{}}){const r={...n};if(e==null)return!1;const i=this.resolve(e,r);if(i?.res===void 0)return!1;const s=wM(i.res);return!(r.returnObjects===!1&&s)}extractFromKey(e,n){let r=n.nsSeparator!==void 0?n.nsSeparator:this.options.nsSeparator;r===void 0&&(r=\":\");const i=n.keySeparator!==void 0?n.keySeparator:this.options.keySeparator;let s=n.ns||this.options.defaultNS||[];const o=r&&e.indexOf(r)>-1,l=!this.options.userDefinedKeySeparator&&!n.keySeparator&&!this.options.userDefinedNsSeparator&&!n.nsSeparator&&!eue(e,r,i);if(o&&!l){const c=e.match(this.interpolator.nestingRegexp);if(c&&c.length>0)return{key:e,namespaces:Hn(s)?[s]:s};const d=e.split(r);(r!==i||r===i&&this.options.ns.indexOf(d[0])>-1)&&(s=d.shift()),e=d.join(i)}return{key:e,namespaces:Hn(s)?[s]:s}}translate(e,n,r){let i=typeof n==\"object\"?{...n}:n;if(typeof i!=\"object\"&&this.options.overloadTranslationOptionHandler&&(i=this.options.overloadTranslationOptionHandler(arguments)),typeof i==\"object\"&&(i={...i}),i||(i={}),e==null)return\"\";typeof e==\"function\"&&(e=q3(e,{...this.options,...i})),Array.isArray(e)||(e=[String(e)]);const s=i.returnDetails!==void 0?i.returnDetails:this.options.returnDetails,o=i.keySeparator!==void 0?i.keySeparator:this.options.keySeparator,{key:l,namespaces:c}=this.extractFromKey(e[e.length-1],i),d=c[c.length-1];let u=i.nsSeparator!==void 0?i.nsSeparator:this.options.nsSeparator;u===void 0&&(u=\":\");const m=i.lng||this.language,p=i.appendNamespaceToCIMode||this.options.appendNamespaceToCIMode;if(m?.toLowerCase()===\"cimode\")return p?s?{res:`${d}${u}${l}`,usedKey:l,exactUsedKey:l,usedLng:m,usedNS:d,usedParams:this.getUsedParamsDetails(i)}:`${d}${u}${l}`:s?{res:l,usedKey:l,exactUsedKey:l,usedLng:m,usedNS:d,usedParams:this.getUsedParamsDetails(i)}:l;const f=this.resolve(e,i);let y=f?.res;const v=f?.usedKey||l,b=f?.exactUsedKey||l,g=[\"[object Number]\",\"[object Function]\",\"[object RegExp]\"],_=i.joinArrays!==void 0?i.joinArrays:this.options.joinArrays,C=!this.i18nFormat||this.i18nFormat.handleAsObject,P=i.count!==void 0&&!Hn(i.count),N=pN.hasDefaultValue(i),A=P?this.pluralResolver.getSuffix(m,i.count,i):\"\",T=i.ordinal&&P?this.pluralResolver.getSuffix(m,i.count,{ordinal:!1}):\"\",F=P&&!i.ordinal&&i.count===0,k=F&&i[`defaultValue${this.options.pluralSeparator}zero`]||i[`defaultValue${A}`]||i[`defaultValue${T}`]||i.defaultValue;let D=y;C&&!y&&N&&(D=k);const H=wM(D),z=Object.prototype.toString.apply(D);if(C&&D&&H&&g.indexOf(z)<0&&!(Hn(_)&&Array.isArray(D))){if(!i.returnObjects&&!this.options.returnObjects){this.options.returnedObjectHandler||this.logger.warn(\"accessing an object - but returnObjects options is not enabled!\");const Q=this.options.returnedObjectHandler?this.options.returnedObjectHandler(v,D,{...i,ns:c}):`key '${l} (${this.language})' returned an object instead of string.`;return s?(f.res=Q,f.usedParams=this.getUsedParamsDetails(i),f):Q}if(o){const Q=Array.isArray(D),L=Q?[]:{},te=Q?b:v;for(const ie in D)if(Object.prototype.hasOwnProperty.call(D,ie)){const J=`${te}${o}${ie}`;N&&!y?L[ie]=this.translate(J,{...i,defaultValue:wM(k)?k[ie]:void 0,joinArrays:!1,ns:c}):L[ie]=this.translate(J,{...i,joinArrays:!1,ns:c}),L[ie]===J&&(L[ie]=D[ie])}y=L}}else if(C&&Hn(_)&&Array.isArray(y))y=y.join(_),y&&(y=this.extendTranslation(y,e,i,r));else{let Q=!1,L=!1;!this.isValidLookup(y)&&N&&(Q=!0,y=k),this.isValidLookup(y)||(L=!0,y=l);const ie=(i.missingKeyNoValueFallbackToKey||this.options.missingKeyNoValueFallbackToKey)&&L?void 0:y,J=N&&k!==y&&this.options.updateMissing;if(L||Q||J){if(this.logger.log(J?\"updateKey\":\"missingKey\",m,d,l,J?k:y),o){const W=this.resolve(l,{...i,keySeparator:!1});W&&W.res&&this.logger.warn(\"Seems the loaded translations were in flat JSON format instead of nested. Either set keySeparator: false on init or make sure your translations are published in nested format.\")}let oe=[];const fe=this.languageUtils.getFallbackCodes(this.options.fallbackLng,i.lng||this.language);if(this.options.saveMissingTo===\"fallback\"&&fe&&fe[0])for(let W=0;W<fe.length;W++)oe.push(fe[W]);else this.options.saveMissingTo===\"all\"?oe=this.languageUtils.toResolveHierarchy(i.lng||this.language):oe.push(i.lng||this.language);const re=(W,ne,me)=>{const be=N&&me!==y?me:ie;this.options.missingKeyHandler?this.options.missingKeyHandler(W,d,ne,be,J,i):this.backendConnector?.saveMissing&&this.backendConnector.saveMissing(W,d,ne,be,J,i),this.emit(\"missingKey\",W,d,ne,y)};this.options.saveMissing&&(this.options.saveMissingPlurals&&P?oe.forEach(W=>{const ne=this.pluralResolver.getSuffixes(W,i);F&&i[`defaultValue${this.options.pluralSeparator}zero`]&&ne.indexOf(`${this.options.pluralSeparator}zero`)<0&&ne.push(`${this.options.pluralSeparator}zero`),ne.forEach(me=>{re([W],l+me,i[`defaultValue${me}`]||k)})}):re(oe,l,k))}y=this.extendTranslation(y,e,i,f,r),L&&y===l&&this.options.appendNamespaceToMissingKey&&(y=`${d}${u}${l}`),(L||Q)&&this.options.parseMissingKeyHandler&&(y=this.options.parseMissingKeyHandler(this.options.appendNamespaceToMissingKey?`${d}${u}${l}`:l,Q?y:void 0,i))}return s?(f.res=y,f.usedParams=this.getUsedParamsDetails(i),f):y}extendTranslation(e,n,r,i,s){if(this.i18nFormat?.parse)e=this.i18nFormat.parse(e,{...this.options.interpolation.defaultVariables,...r},r.lng||this.language||i.usedLng,i.usedNS,i.usedKey,{resolved:i});else if(!r.skipInterpolation){r.interpolation&&this.interpolator.init({...r,interpolation:{...this.options.interpolation,...r.interpolation}});const c=Hn(e)&&(r?.interpolation?.skipOnVariables!==void 0?r.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables);let d;if(c){const m=e.match(this.interpolator.nestingRegexp);d=m&&m.length}let u=r.replace&&!Hn(r.replace)?r.replace:r;if(this.options.interpolation.defaultVariables&&(u={...this.options.interpolation.defaultVariables,...u}),e=this.interpolator.interpolate(e,u,r.lng||this.language||i.usedLng,r),c){const m=e.match(this.interpolator.nestingRegexp),p=m&&m.length;d<p&&(r.nest=!1)}!r.lng&&i&&i.res&&(r.lng=this.language||i.usedLng),r.nest!==!1&&(e=this.interpolator.nest(e,(...m)=>s?.[0]===m[0]&&!r.context?(this.logger.warn(`It seems you are nesting recursively key: ${m[0]} in key: ${n[0]}`),null):this.translate(...m,n),r)),r.interpolation&&this.interpolator.reset()}const o=r.postProcess||this.options.postProcess,l=Hn(o)?[o]:o;return e!=null&&l?.length&&r.applyPostProcessor!==!1&&(e=HY.handle(l,e,n,this.options&&this.options.postProcessPassResolved?{i18nResolved:{...i,usedParams:this.getUsedParamsDetails(r)},...r}:r,this)),e}resolve(e,n={}){let r,i,s,o,l;return Hn(e)&&(e=[e]),e.forEach(c=>{if(this.isValidLookup(r))return;const d=this.extractFromKey(c,n),u=d.key;i=u;let m=d.namespaces;this.options.fallbackNS&&(m=m.concat(this.options.fallbackNS));const p=n.count!==void 0&&!Hn(n.count),f=p&&!n.ordinal&&n.count===0,y=n.context!==void 0&&(Hn(n.context)||typeof n.context==\"number\")&&n.context!==\"\",v=n.lngs?n.lngs:this.languageUtils.toResolveHierarchy(n.lng||this.language,n.fallbackLng);m.forEach(b=>{this.isValidLookup(r)||(l=b,!q8[`${v[0]}-${b}`]&&this.utils?.hasLoadedNamespace&&!this.utils?.hasLoadedNamespace(l)&&(q8[`${v[0]}-${b}`]=!0,this.logger.warn(`key \"${i}\" for languages \"${v.join(\", \")}\" won't get resolved as namespace \"${l}\" was not yet loaded`,\"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!\")),v.forEach(g=>{if(this.isValidLookup(r))return;o=g;const _=[u];if(this.i18nFormat?.addLookupKeys)this.i18nFormat.addLookupKeys(_,u,g,b,n);else{let P;p&&(P=this.pluralResolver.getSuffix(g,n.count,n));const N=`${this.options.pluralSeparator}zero`,A=`${this.options.pluralSeparator}ordinal${this.options.pluralSeparator}`;if(p&&(n.ordinal&&P.indexOf(A)===0&&_.push(u+P.replace(A,this.options.pluralSeparator)),_.push(u+P),f&&_.push(u+N)),y){const T=`${u}${this.options.contextSeparator||\"_\"}${n.context}`;_.push(T),p&&(n.ordinal&&P.indexOf(A)===0&&_.push(T+P.replace(A,this.options.pluralSeparator)),_.push(T+P),f&&_.push(T+N))}}let C;for(;C=_.pop();)this.isValidLookup(r)||(s=C,r=this.getResource(g,b,C,n))}))})}),{res:r,usedKey:i,exactUsedKey:s,usedLng:o,usedNS:l}}isValidLookup(e){return e!==void 0&&!(!this.options.returnNull&&e===null)&&!(!this.options.returnEmptyString&&e===\"\")}getResource(e,n,r,i={}){return this.i18nFormat?.getResource?this.i18nFormat.getResource(e,n,r,i):this.resourceStore.getResource(e,n,r,i)}getUsedParamsDetails(e={}){const n=[\"defaultValue\",\"ordinal\",\"context\",\"replace\",\"lng\",\"lngs\",\"fallbackLng\",\"ns\",\"keySeparator\",\"nsSeparator\",\"returnObjects\",\"returnDetails\",\"joinArrays\",\"postProcess\",\"interpolation\"],r=e.replace&&!Hn(e.replace);let i=r?e.replace:e;if(r&&typeof e.count<\"u\"&&(i.count=e.count),this.options.interpolation.defaultVariables&&(i={...this.options.interpolation.defaultVariables,...i}),!r){i={...i};for(const s of n)delete i[s]}return i}static hasDefaultValue(e){const n=\"defaultValue\";for(const r in e)if(Object.prototype.hasOwnProperty.call(e,r)&&n===r.substring(0,n.length)&&e[r]!==void 0)return!0;return!1}}class $8{constructor(e){this.options=e,this.supportedLngs=this.options.supportedLngs||!1,this.logger=Ad.create(\"languageUtils\")}getScriptPartFromCode(e){if(e=ew(e),!e||e.indexOf(\"-\")<0)return null;const n=e.split(\"-\");return n.length===2||(n.pop(),n[n.length-1].toLowerCase()===\"x\")?null:this.formatLanguageCode(n.join(\"-\"))}getLanguagePartFromCode(e){if(e=ew(e),!e||e.indexOf(\"-\")<0)return e;const n=e.split(\"-\");return this.formatLanguageCode(n[0])}formatLanguageCode(e){if(Hn(e)&&e.indexOf(\"-\")>-1){let n;try{n=Intl.getCanonicalLocales(e)[0]}catch{}return n&&this.options.lowerCaseLng&&(n=n.toLowerCase()),n||(this.options.lowerCaseLng?e.toLowerCase():e)}return this.options.cleanCode||this.options.lowerCaseLng?e.toLowerCase():e}isSupportedCode(e){return(this.options.load===\"languageOnly\"||this.options.nonExplicitSupportedLngs)&&(e=this.getLanguagePartFromCode(e)),!this.supportedLngs||!this.supportedLngs.length||this.supportedLngs.indexOf(e)>-1}getBestMatchFromCodes(e){if(!e)return null;let n;return e.forEach(r=>{if(n)return;const i=this.formatLanguageCode(r);(!this.options.supportedLngs||this.isSupportedCode(i))&&(n=i)}),!n&&this.options.supportedLngs&&e.forEach(r=>{if(n)return;const i=this.getScriptPartFromCode(r);if(this.isSupportedCode(i))return n=i;const s=this.getLanguagePartFromCode(r);if(this.isSupportedCode(s))return n=s;n=this.options.supportedLngs.find(o=>{if(o===s)return o;if(!(o.indexOf(\"-\")<0&&s.indexOf(\"-\")<0)&&(o.indexOf(\"-\")>0&&s.indexOf(\"-\")<0&&o.substring(0,o.indexOf(\"-\"))===s||o.indexOf(s)===0&&s.length>1))return o})}),n||(n=this.getFallbackCodes(this.options.fallbackLng)[0]),n}getFallbackCodes(e,n){if(!e)return[];if(typeof e==\"function\"&&(e=e(n)),Hn(e)&&(e=[e]),Array.isArray(e))return e;if(!n)return e.default||[];let r=e[n];return r||(r=e[this.getScriptPartFromCode(n)]),r||(r=e[this.formatLanguageCode(n)]),r||(r=e[this.getLanguagePartFromCode(n)]),r||(r=e.default),r||[]}toResolveHierarchy(e,n){const r=this.getFallbackCodes((n===!1?[]:n)||this.options.fallbackLng||[],e),i=[],s=o=>{o&&(this.isSupportedCode(o)?i.push(o):this.logger.warn(`rejecting language code not found in supportedLngs: ${o}`))};return Hn(e)&&(e.indexOf(\"-\")>-1||e.indexOf(\"_\")>-1)?(this.options.load!==\"languageOnly\"&&s(this.formatLanguageCode(e)),this.options.load!==\"languageOnly\"&&this.options.load!==\"currentOnly\"&&s(this.getScriptPartFromCode(e)),this.options.load!==\"currentOnly\"&&s(this.getLanguagePartFromCode(e))):Hn(e)&&s(this.formatLanguageCode(e)),r.forEach(o=>{i.indexOf(o)<0&&s(this.formatLanguageCode(o))}),i}}const V8={zero:0,one:1,two:2,few:3,many:4,other:5},G8={select:t=>t===1?\"one\":\"other\",resolvedOptions:()=>({pluralCategories:[\"one\",\"other\"]})};class rue{constructor(e,n={}){this.languageUtils=e,this.options=n,this.logger=Ad.create(\"pluralResolver\"),this.pluralRulesCache={}}addRule(e,n){this.rules[e]=n}clearCache(){this.pluralRulesCache={}}getRule(e,n={}){const r=ew(e===\"dev\"?\"en\":e),i=n.ordinal?\"ordinal\":\"cardinal\",s=JSON.stringify({cleanedCode:r,type:i});if(s in this.pluralRulesCache)return this.pluralRulesCache[s];let o;try{o=new Intl.PluralRules(r,{type:i})}catch{if(!Intl)return this.logger.error(\"No Intl support, please use an Intl polyfill!\"),G8;if(!e.match(/-|_/))return G8;const c=this.languageUtils.getLanguagePartFromCode(e);o=this.getRule(c,n)}return this.pluralRulesCache[s]=o,o}needsPlural(e,n={}){let r=this.getRule(e,n);return r||(r=this.getRule(\"dev\",n)),r?.resolvedOptions().pluralCategories.length>1}getPluralFormsOfKey(e,n,r={}){return this.getSuffixes(e,r).map(i=>`${n}${i}`)}getSuffixes(e,n={}){let r=this.getRule(e,n);return r||(r=this.getRule(\"dev\",n)),r?r.resolvedOptions().pluralCategories.sort((i,s)=>V8[i]-V8[s]).map(i=>`${this.options.prepend}${n.ordinal?`ordinal${this.options.prepend}`:\"\"}${i}`):[]}getSuffix(e,n,r={}){const i=this.getRule(e,r);return i?`${this.options.prepend}${r.ordinal?`ordinal${this.options.prepend}`:\"\"}${i.select(n)}`:(this.logger.warn(`no plural rule found for: ${e}`),this.getSuffix(\"dev\",n,r))}}const W8=(t,e,n,r=\".\",i=!0)=>{let s=Kde(t,e,n);return!s&&i&&Hn(n)&&(s=H3(t,n,r),s===void 0&&(s=H3(e,n,r))),s},SM=t=>t.replace(/\\$/g,\"$$$$\");class aue{constructor(e={}){this.logger=Ad.create(\"interpolator\"),this.options=e,this.format=e?.interpolation?.format||(n=>n),this.init(e)}init(e={}){e.interpolation||(e.interpolation={escapeValue:!0});const{escape:n,escapeValue:r,useRawValueToEscape:i,prefix:s,prefixEscaped:o,suffix:l,suffixEscaped:c,formatSeparator:d,unescapeSuffix:u,unescapePrefix:m,nestingPrefix:p,nestingPrefixEscaped:f,nestingSuffix:y,nestingSuffixEscaped:v,nestingOptionsSeparator:b,maxReplaces:g,alwaysFormat:_}=e.interpolation;this.escape=n!==void 0?n:Yde,this.escapeValue=r!==void 0?r:!0,this.useRawValueToEscape=i!==void 0?i:!1,this.prefix=s?vb(s):o||\"{{\",this.suffix=l?vb(l):c||\"}}\",this.formatSeparator=d||\",\",this.unescapePrefix=u?\"\":m||\"-\",this.unescapeSuffix=this.unescapePrefix?\"\":u||\"\",this.nestingPrefix=p?vb(p):f||vb(\"$t(\"),this.nestingSuffix=y?vb(y):v||vb(\")\"),this.nestingOptionsSeparator=b||\",\",this.maxReplaces=g||1e3,this.alwaysFormat=_!==void 0?_:!1,this.resetRegExp()}reset(){this.options&&this.init(this.options)}resetRegExp(){const e=(n,r)=>n?.source===r?(n.lastIndex=0,n):new RegExp(r,\"g\");this.regexp=e(this.regexp,`${this.prefix}(.+?)${this.suffix}`),this.regexpUnescape=e(this.regexpUnescape,`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`),this.nestingRegexp=e(this.nestingRegexp,`${this.nestingPrefix}((?:[^()\"']+|\"[^\"]*\"|'[^']*'|\\\\((?:[^()]|\"[^\"]*\"|'[^']*')*\\\\))*?)${this.nestingSuffix}`)}interpolate(e,n,r,i){let s,o,l;const c=this.options&&this.options.interpolation&&this.options.interpolation.defaultVariables||{},d=f=>{if(f.indexOf(this.formatSeparator)<0){const g=W8(n,c,f,this.options.keySeparator,this.options.ignoreJSONStructure);return this.alwaysFormat?this.format(g,void 0,r,{...i,...n,interpolationkey:f}):g}const y=f.split(this.formatSeparator),v=y.shift().trim(),b=y.join(this.formatSeparator).trim();return this.format(W8(n,c,v,this.options.keySeparator,this.options.ignoreJSONStructure),b,r,{...i,...n,interpolationkey:v})};this.resetRegExp();const u=i?.missingInterpolationHandler||this.options.missingInterpolationHandler,m=i?.interpolation?.skipOnVariables!==void 0?i.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables;return[{regex:this.regexpUnescape,safeValue:f=>SM(f)},{regex:this.regexp,safeValue:f=>this.escapeValue?SM(this.escape(f)):SM(f)}].forEach(f=>{for(l=0;s=f.regex.exec(e);){const y=s[1].trim();if(o=d(y),o===void 0)if(typeof u==\"function\"){const b=u(e,s,i);o=Hn(b)?b:\"\"}else if(i&&Object.prototype.hasOwnProperty.call(i,y))o=\"\";else if(m){o=s[0];continue}else this.logger.warn(`missed to pass in variable ${y} for interpolating ${e}`),o=\"\";else!Hn(o)&&!this.useRawValueToEscape&&(o=I8(o));const v=f.safeValue(o);if(e=e.replace(s[0],v),m?(f.regex.lastIndex+=o.length,f.regex.lastIndex-=s[0].length):f.regex.lastIndex=0,l++,l>=this.maxReplaces)break}}),e}nest(e,n,r={}){let i,s,o;const l=(c,d)=>{const u=this.nestingOptionsSeparator;if(c.indexOf(u)<0)return c;const m=c.split(new RegExp(`${u}[ ]*{`));let p=`{${m[1]}`;c=m[0],p=this.interpolate(p,o);const f=p.match(/'/g),y=p.match(/\"/g);((f?.length??0)%2===0&&!y||y.length%2!==0)&&(p=p.replace(/'/g,'\"'));try{o=JSON.parse(p),d&&(o={...d,...o})}catch(v){return this.logger.warn(`failed parsing options string in nesting for key ${c}`,v),`${c}${u}${p}`}return o.defaultValue&&o.defaultValue.indexOf(this.prefix)>-1&&delete o.defaultValue,c};for(;i=this.nestingRegexp.exec(e);){let c=[];o={...r},o=o.replace&&!Hn(o.replace)?o.replace:o,o.applyPostProcessor=!1,delete o.defaultValue;const d=/{.*}/.test(i[1])?i[1].lastIndexOf(\"}\")+1:i[1].indexOf(this.formatSeparator);if(d!==-1&&(c=i[1].slice(d).split(this.formatSeparator).map(u=>u.trim()).filter(Boolean),i[1]=i[1].slice(0,d)),s=n(l.call(this,i[1].trim(),o),o),s&&i[0]===e&&!Hn(s))return s;Hn(s)||(s=I8(s)),s||(this.logger.warn(`missed to resolve ${i[1]} for nesting ${e}`),s=\"\"),c.length&&(s=c.reduce((u,m)=>this.format(u,m,r.lng,{...r,interpolationkey:i[1].trim()}),s.trim())),e=e.replace(i[0],s),this.regexp.lastIndex=0}return e}}const iue=t=>{let e=t.toLowerCase().trim();const n={};if(t.indexOf(\"(\")>-1){const r=t.split(\"(\");e=r[0].toLowerCase().trim();const i=r[1].substring(0,r[1].length-1);e===\"currency\"&&i.indexOf(\":\")<0?n.currency||(n.currency=i.trim()):e===\"relativetime\"&&i.indexOf(\":\")<0?n.range||(n.range=i.trim()):i.split(\";\").forEach(o=>{if(o){const[l,...c]=o.split(\":\"),d=c.join(\":\").trim().replace(/^'+|'+$/g,\"\"),u=l.trim();n[u]||(n[u]=d),d===\"false\"&&(n[u]=!1),d===\"true\"&&(n[u]=!0),isNaN(d)||(n[u]=parseInt(d,10))}})}return{formatName:e,formatOptions:n}},K8=t=>{const e={};return(n,r,i)=>{let s=i;i&&i.interpolationkey&&i.formatParams&&i.formatParams[i.interpolationkey]&&i[i.interpolationkey]&&(s={...s,[i.interpolationkey]:void 0});const o=r+JSON.stringify(s);let l=e[o];return l||(l=t(ew(r),i),e[o]=l),l(n)}},sue=t=>(e,n,r)=>t(ew(n),r)(e);class oue{constructor(e={}){this.logger=Ad.create(\"formatter\"),this.options=e,this.init(e)}init(e,n={interpolation:{}}){this.formatSeparator=n.interpolation.formatSeparator||\",\";const r=n.cacheInBuiltFormats?K8:sue;this.formats={number:r((i,s)=>{const o=new Intl.NumberFormat(i,{...s});return l=>o.format(l)}),currency:r((i,s)=>{const o=new Intl.NumberFormat(i,{...s,style:\"currency\"});return l=>o.format(l)}),datetime:r((i,s)=>{const o=new Intl.DateTimeFormat(i,{...s});return l=>o.format(l)}),relativetime:r((i,s)=>{const o=new Intl.RelativeTimeFormat(i,{...s});return l=>o.format(l,s.range||\"day\")}),list:r((i,s)=>{const o=new Intl.ListFormat(i,{...s});return l=>o.format(l)})}}add(e,n){this.formats[e.toLowerCase().trim()]=n}addCached(e,n){this.formats[e.toLowerCase().trim()]=K8(n)}format(e,n,r,i={}){const s=n.split(this.formatSeparator);if(s.length>1&&s[0].indexOf(\"(\")>1&&s[0].indexOf(\")\")<0&&s.find(l=>l.indexOf(\")\")>-1)){const l=s.findIndex(c=>c.indexOf(\")\")>-1);s[0]=[s[0],...s.splice(1,l)].join(this.formatSeparator)}return s.reduce((l,c)=>{const{formatName:d,formatOptions:u}=iue(c);if(this.formats[d]){let m=l;try{const p=i?.formatParams?.[i.interpolationkey]||{},f=p.locale||p.lng||i.locale||i.lng||r;m=this.formats[d](l,f,{...u,...i,...p})}catch(p){this.logger.warn(p)}return m}else this.logger.warn(`there was no format function for ${d}`);return l},e)}}const lue=(t,e)=>{t.pending[e]!==void 0&&(delete t.pending[e],t.pendingCount--)};class cue extends IP{constructor(e,n,r,i={}){super(),this.backend=e,this.store=n,this.services=r,this.languageUtils=r.languageUtils,this.options=i,this.logger=Ad.create(\"backendConnector\"),this.waitingReads=[],this.maxParallelReads=i.maxParallelReads||10,this.readingCalls=0,this.maxRetries=i.maxRetries>=0?i.maxRetries:5,this.retryTimeout=i.retryTimeout>=1?i.retryTimeout:350,this.state={},this.queue=[],this.backend?.init?.(r,i.backend,i)}queueLoad(e,n,r,i){const s={},o={},l={},c={};return e.forEach(d=>{let u=!0;n.forEach(m=>{const p=`${d}|${m}`;!r.reload&&this.store.hasResourceBundle(d,m)?this.state[p]=2:this.state[p]<0||(this.state[p]===1?o[p]===void 0&&(o[p]=!0):(this.state[p]=1,u=!1,o[p]===void 0&&(o[p]=!0),s[p]===void 0&&(s[p]=!0),c[m]===void 0&&(c[m]=!0)))}),u||(l[d]=!0)}),(Object.keys(s).length||Object.keys(o).length)&&this.queue.push({pending:o,pendingCount:Object.keys(o).length,loaded:{},errors:[],callback:i}),{toLoad:Object.keys(s),pending:Object.keys(o),toLoadLanguages:Object.keys(l),toLoadNamespaces:Object.keys(c)}}loaded(e,n,r){const i=e.split(\"|\"),s=i[0],o=i[1];n&&this.emit(\"failedLoading\",s,o,n),!n&&r&&this.store.addResourceBundle(s,o,r,void 0,void 0,{skipCopy:!0}),this.state[e]=n?-1:2,n&&r&&(this.state[e]=0);const l={};this.queue.forEach(c=>{Wde(c.loaded,[s],o),lue(c,e),n&&c.errors.push(n),c.pendingCount===0&&!c.done&&(Object.keys(c.loaded).forEach(d=>{l[d]||(l[d]={});const u=c.loaded[d];u.length&&u.forEach(m=>{l[d][m]===void 0&&(l[d][m]=!0)})}),c.done=!0,c.errors.length?c.callback(c.errors):c.callback())}),this.emit(\"loaded\",l),this.queue=this.queue.filter(c=>!c.done)}read(e,n,r,i=0,s=this.retryTimeout,o){if(!e.length)return o(null,{});if(this.readingCalls>=this.maxParallelReads){this.waitingReads.push({lng:e,ns:n,fcName:r,tried:i,wait:s,callback:o});return}this.readingCalls++;const l=(d,u)=>{if(this.readingCalls--,this.waitingReads.length>0){const m=this.waitingReads.shift();this.read(m.lng,m.ns,m.fcName,m.tried,m.wait,m.callback)}if(d&&u&&i<this.maxRetries){setTimeout(()=>{this.read.call(this,e,n,r,i+1,s*2,o)},s);return}o(d,u)},c=this.backend[r].bind(this.backend);if(c.length===2){try{const d=c(e,n);d&&typeof d.then==\"function\"?d.then(u=>l(null,u)).catch(l):l(null,d)}catch(d){l(d)}return}return c(e,n,l)}prepareLoading(e,n,r={},i){if(!this.backend)return this.logger.warn(\"No backend was added via i18next.use. Will not load resources.\"),i&&i();Hn(e)&&(e=this.languageUtils.toResolveHierarchy(e)),Hn(n)&&(n=[n]);const s=this.queueLoad(e,n,r,i);if(!s.toLoad.length)return s.pending.length||i(),null;s.toLoad.forEach(o=>{this.loadOne(o)})}load(e,n,r){this.prepareLoading(e,n,{},r)}reload(e,n,r){this.prepareLoading(e,n,{reload:!0},r)}loadOne(e,n=\"\"){const r=e.split(\"|\"),i=r[0],s=r[1];this.read(i,s,\"read\",void 0,void 0,(o,l)=>{o&&this.logger.warn(`${n}loading namespace ${s} for language ${i} failed`,o),!o&&l&&this.logger.log(`${n}loaded namespace ${s} for language ${i}`,l),this.loaded(e,o,l)})}saveMissing(e,n,r,i,s,o={},l=()=>{}){if(this.services?.utils?.hasLoadedNamespace&&!this.services?.utils?.hasLoadedNamespace(n)){this.logger.warn(`did not save key \"${r}\" as the namespace \"${n}\" was not yet loaded`,\"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!\");return}if(!(r==null||r===\"\")){if(this.backend?.create){const c={...o,isUpdate:s},d=this.backend.create.bind(this.backend);if(d.length<6)try{let u;d.length===5?u=d(e,n,r,i,c):u=d(e,n,r,i),u&&typeof u.then==\"function\"?u.then(m=>l(null,m)).catch(l):l(null,u)}catch(u){l(u)}else d(e,n,r,i,l,c)}!e||!e[0]||this.store.addResource(e[0],n,r,i)}}}const X8=()=>({debug:!1,initAsync:!0,ns:[\"translation\"],defaultNS:[\"translation\"],fallbackLng:[\"dev\"],fallbackNS:!1,supportedLngs:!1,nonExplicitSupportedLngs:!1,load:\"all\",preload:!1,simplifyPluralSuffix:!0,keySeparator:\".\",nsSeparator:\":\",pluralSeparator:\"_\",contextSeparator:\"_\",partialBundledLanguages:!1,saveMissing:!1,updateMissing:!1,saveMissingTo:\"fallback\",saveMissingPlurals:!0,missingKeyHandler:!1,missingInterpolationHandler:!1,postProcess:!1,postProcessPassResolved:!1,returnNull:!1,returnEmptyString:!0,returnObjects:!1,joinArrays:!1,returnedObjectHandler:!1,parseMissingKeyHandler:!1,appendNamespaceToMissingKey:!1,appendNamespaceToCIMode:!1,overloadTranslationOptionHandler:t=>{let e={};if(typeof t[1]==\"object\"&&(e=t[1]),Hn(t[1])&&(e.defaultValue=t[1]),Hn(t[2])&&(e.tDescription=t[2]),typeof t[2]==\"object\"||typeof t[3]==\"object\"){const n=t[3]||t[2];Object.keys(n).forEach(r=>{e[r]=n[r]})}return e},interpolation:{escapeValue:!0,format:t=>t,prefix:\"{{\",suffix:\"}}\",formatSeparator:\",\",unescapePrefix:\"-\",nestingPrefix:\"$t(\",nestingSuffix:\")\",nestingOptionsSeparator:\",\",maxReplaces:1e3,skipOnVariables:!0},cacheInBuiltFormats:!0}),Y8=t=>(Hn(t.ns)&&(t.ns=[t.ns]),Hn(t.fallbackLng)&&(t.fallbackLng=[t.fallbackLng]),Hn(t.fallbackNS)&&(t.fallbackNS=[t.fallbackNS]),t.supportedLngs?.indexOf?.(\"cimode\")<0&&(t.supportedLngs=t.supportedLngs.concat([\"cimode\"])),typeof t.initImmediate==\"boolean\"&&(t.initAsync=t.initImmediate),t),K_=()=>{},due=t=>{Object.getOwnPropertyNames(Object.getPrototypeOf(t)).forEach(n=>{typeof t[n]==\"function\"&&(t[n]=t[n].bind(t))})};class T0 extends IP{constructor(e={},n){if(super(),this.options=Y8(e),this.services={},this.logger=Ad,this.modules={external:[]},due(this),n&&!this.isInitialized&&!e.isClone){if(!this.options.initAsync)return this.init(e,n),this;setTimeout(()=>{this.init(e,n)},0)}}init(e={},n){this.isInitializing=!0,typeof e==\"function\"&&(n=e,e={}),e.defaultNS==null&&e.ns&&(Hn(e.ns)?e.defaultNS=e.ns:e.ns.indexOf(\"translation\")<0&&(e.defaultNS=e.ns[0]));const r=X8();this.options={...r,...this.options,...Y8(e)},this.options.interpolation={...r.interpolation,...this.options.interpolation},e.keySeparator!==void 0&&(this.options.userDefinedKeySeparator=e.keySeparator),e.nsSeparator!==void 0&&(this.options.userDefinedNsSeparator=e.nsSeparator);const i=d=>d?typeof d==\"function\"?new d:d:null;if(!this.options.isClone){this.modules.logger?Ad.init(i(this.modules.logger),this.options):Ad.init(null,this.options);let d;this.modules.formatter?d=this.modules.formatter:d=oue;const u=new $8(this.options);this.store=new H8(this.options.resources,this.options);const m=this.services;m.logger=Ad,m.resourceStore=this.store,m.languageUtils=u,m.pluralResolver=new rue(u,{prepend:this.options.pluralSeparator,simplifyPluralSuffix:this.options.simplifyPluralSuffix}),this.options.interpolation.format&&this.options.interpolation.format!==r.interpolation.format&&this.logger.deprecate(\"init: you are still using the legacy format function, please use the new approach: https://www.i18next.com/translation-function/formatting\"),d&&(!this.options.interpolation.format||this.options.interpolation.format===r.interpolation.format)&&(m.formatter=i(d),m.formatter.init&&m.formatter.init(m,this.options),this.options.interpolation.format=m.formatter.format.bind(m.formatter)),m.interpolator=new aue(this.options),m.utils={hasLoadedNamespace:this.hasLoadedNamespace.bind(this)},m.backendConnector=new cue(i(this.modules.backend),m.resourceStore,m,this.options),m.backendConnector.on(\"*\",(f,...y)=>{this.emit(f,...y)}),this.modules.languageDetector&&(m.languageDetector=i(this.modules.languageDetector),m.languageDetector.init&&m.languageDetector.init(m,this.options.detection,this.options)),this.modules.i18nFormat&&(m.i18nFormat=i(this.modules.i18nFormat),m.i18nFormat.init&&m.i18nFormat.init(this)),this.translator=new pN(this.services,this.options),this.translator.on(\"*\",(f,...y)=>{this.emit(f,...y)}),this.modules.external.forEach(f=>{f.init&&f.init(this)})}if(this.format=this.options.interpolation.format,n||(n=K_),this.options.fallbackLng&&!this.services.languageDetector&&!this.options.lng){const d=this.services.languageUtils.getFallbackCodes(this.options.fallbackLng);d.length>0&&d[0]!==\"dev\"&&(this.options.lng=d[0])}!this.services.languageDetector&&!this.options.lng&&this.logger.warn(\"init: no languageDetector is used and no lng is defined\"),[\"getResource\",\"hasResourceBundle\",\"getResourceBundle\",\"getDataByLanguage\"].forEach(d=>{this[d]=(...u)=>this.store[d](...u)}),[\"addResource\",\"addResources\",\"addResourceBundle\",\"removeResourceBundle\"].forEach(d=>{this[d]=(...u)=>(this.store[d](...u),this)});const l=jv(),c=()=>{const d=(u,m)=>{this.isInitializing=!1,this.isInitialized&&!this.initializedStoreOnce&&this.logger.warn(\"init: i18next is already initialized. You should call init just once!\"),this.isInitialized=!0,this.options.isClone||this.logger.log(\"initialized\",this.options),this.emit(\"initialized\",this.options),l.resolve(m),n(u,m)};if(this.languages&&!this.isInitialized)return d(null,this.t.bind(this));this.changeLanguage(this.options.lng,d)};return this.options.resources||!this.options.initAsync?c():setTimeout(c,0),l}loadResources(e,n=K_){let r=n;const i=Hn(e)?e:this.language;if(typeof e==\"function\"&&(r=e),!this.options.resources||this.options.partialBundledLanguages){if(i?.toLowerCase()===\"cimode\"&&(!this.options.preload||this.options.preload.length===0))return r();const s=[],o=l=>{if(!l||l===\"cimode\")return;this.services.languageUtils.toResolveHierarchy(l).forEach(d=>{d!==\"cimode\"&&s.indexOf(d)<0&&s.push(d)})};i?o(i):this.services.languageUtils.getFallbackCodes(this.options.fallbackLng).forEach(c=>o(c)),this.options.preload?.forEach?.(l=>o(l)),this.services.backendConnector.load(s,this.options.ns,l=>{!l&&!this.resolvedLanguage&&this.language&&this.setResolvedLanguage(this.language),r(l)})}else r(null)}reloadResources(e,n,r){const i=jv();return typeof e==\"function\"&&(r=e,e=void 0),typeof n==\"function\"&&(r=n,n=void 0),e||(e=this.languages),n||(n=this.options.ns),r||(r=K_),this.services.backendConnector.reload(e,n,s=>{i.resolve(),r(s)}),i}use(e){if(!e)throw new Error(\"You are passing an undefined module! Please check the object you are passing to i18next.use()\");if(!e.type)throw new Error(\"You are passing a wrong module! Please check the object you are passing to i18next.use()\");return e.type===\"backend\"&&(this.modules.backend=e),(e.type===\"logger\"||e.log&&e.warn&&e.error)&&(this.modules.logger=e),e.type===\"languageDetector\"&&(this.modules.languageDetector=e),e.type===\"i18nFormat\"&&(this.modules.i18nFormat=e),e.type===\"postProcessor\"&&HY.addPostProcessor(e),e.type===\"formatter\"&&(this.modules.formatter=e),e.type===\"3rdParty\"&&this.modules.external.push(e),this}setResolvedLanguage(e){if(!(!e||!this.languages)&&!([\"cimode\",\"dev\"].indexOf(e)>-1)){for(let n=0;n<this.languages.length;n++){const r=this.languages[n];if(!([\"cimode\",\"dev\"].indexOf(r)>-1)&&this.store.hasLanguageSomeTranslations(r)){this.resolvedLanguage=r;break}}!this.resolvedLanguage&&this.languages.indexOf(e)<0&&this.store.hasLanguageSomeTranslations(e)&&(this.resolvedLanguage=e,this.languages.unshift(e))}}changeLanguage(e,n){this.isLanguageChangingTo=e;const r=jv();this.emit(\"languageChanging\",e);const i=l=>{this.language=l,this.languages=this.services.languageUtils.toResolveHierarchy(l),this.resolvedLanguage=void 0,this.setResolvedLanguage(l)},s=(l,c)=>{c?this.isLanguageChangingTo===e&&(i(c),this.translator.changeLanguage(c),this.isLanguageChangingTo=void 0,this.emit(\"languageChanged\",c),this.logger.log(\"languageChanged\",c)):this.isLanguageChangingTo=void 0,r.resolve((...d)=>this.t(...d)),n&&n(l,(...d)=>this.t(...d))},o=l=>{!e&&!l&&this.services.languageDetector&&(l=[]);const c=Hn(l)?l:l&&l[0],d=this.store.hasLanguageSomeTranslations(c)?c:this.services.languageUtils.getBestMatchFromCodes(Hn(l)?[l]:l);d&&(this.language||i(d),this.translator.language||this.translator.changeLanguage(d),this.services.languageDetector?.cacheUserLanguage?.(d)),this.loadResources(d,u=>{s(u,d)})};return!e&&this.services.languageDetector&&!this.services.languageDetector.async?o(this.services.languageDetector.detect()):!e&&this.services.languageDetector&&this.services.languageDetector.async?this.services.languageDetector.detect.length===0?this.services.languageDetector.detect().then(o):this.services.languageDetector.detect(o):o(e),r}getFixedT(e,n,r){const i=(s,o,...l)=>{let c;typeof o!=\"object\"?c=this.options.overloadTranslationOptionHandler([s,o].concat(l)):c={...o},c.lng=c.lng||i.lng,c.lngs=c.lngs||i.lngs,c.ns=c.ns||i.ns,c.keyPrefix!==\"\"&&(c.keyPrefix=c.keyPrefix||r||i.keyPrefix);const d=this.options.keySeparator||\".\";let u;return c.keyPrefix&&Array.isArray(s)?u=s.map(m=>(typeof m==\"function\"&&(m=q3(m,{...this.options,...o})),`${c.keyPrefix}${d}${m}`)):(typeof s==\"function\"&&(s=q3(s,{...this.options,...o})),u=c.keyPrefix?`${c.keyPrefix}${d}${s}`:s),this.t(u,c)};return Hn(e)?i.lng=e:i.lngs=e,i.ns=n,i.keyPrefix=r,i}t(...e){return this.translator?.translate(...e)}exists(...e){return this.translator?.exists(...e)}setDefaultNamespace(e){this.options.defaultNS=e}hasLoadedNamespace(e,n={}){if(!this.isInitialized)return this.logger.warn(\"hasLoadedNamespace: i18next was not initialized\",this.languages),!1;if(!this.languages||!this.languages.length)return this.logger.warn(\"hasLoadedNamespace: i18n.languages were undefined or empty\",this.languages),!1;const r=n.lng||this.resolvedLanguage||this.languages[0],i=this.options?this.options.fallbackLng:!1,s=this.languages[this.languages.length-1];if(r.toLowerCase()===\"cimode\")return!0;const o=(l,c)=>{const d=this.services.backendConnector.state[`${l}|${c}`];return d===-1||d===0||d===2};if(n.precheck){const l=n.precheck(this,o);if(l!==void 0)return l}return!!(this.hasResourceBundle(r,e)||!this.services.backendConnector.backend||this.options.resources&&!this.options.partialBundledLanguages||o(r,e)&&(!i||o(s,e)))}loadNamespaces(e,n){const r=jv();return this.options.ns?(Hn(e)&&(e=[e]),e.forEach(i=>{this.options.ns.indexOf(i)<0&&this.options.ns.push(i)}),this.loadResources(i=>{r.resolve(),n&&n(i)}),r):(n&&n(),Promise.resolve())}loadLanguages(e,n){const r=jv();Hn(e)&&(e=[e]);const i=this.options.preload||[],s=e.filter(o=>i.indexOf(o)<0&&this.services.languageUtils.isSupportedCode(o));return s.length?(this.options.preload=i.concat(s),this.loadResources(o=>{r.resolve(),n&&n(o)}),r):(n&&n(),Promise.resolve())}dir(e){if(e||(e=this.resolvedLanguage||(this.languages?.length>0?this.languages[0]:this.language)),!e)return\"rtl\";try{const i=new Intl.Locale(e);if(i&&i.getTextInfo){const s=i.getTextInfo();if(s&&s.direction)return s.direction}}catch{}const n=[\"ar\",\"shu\",\"sqr\",\"ssh\",\"xaa\",\"yhd\",\"yud\",\"aao\",\"abh\",\"abv\",\"acm\",\"acq\",\"acw\",\"acx\",\"acy\",\"adf\",\"ads\",\"aeb\",\"aec\",\"afb\",\"ajp\",\"apc\",\"apd\",\"arb\",\"arq\",\"ars\",\"ary\",\"arz\",\"auz\",\"avl\",\"ayh\",\"ayl\",\"ayn\",\"ayp\",\"bbz\",\"pga\",\"he\",\"iw\",\"ps\",\"pbt\",\"pbu\",\"pst\",\"prp\",\"prd\",\"ug\",\"ur\",\"ydd\",\"yds\",\"yih\",\"ji\",\"yi\",\"hbo\",\"men\",\"xmn\",\"fa\",\"jpr\",\"peo\",\"pes\",\"prs\",\"dv\",\"sam\",\"ckb\"],r=this.services?.languageUtils||new $8(X8());return e.toLowerCase().indexOf(\"-latn\")>1?\"ltr\":n.indexOf(r.getLanguagePartFromCode(e))>-1||e.toLowerCase().indexOf(\"-arab\")>1?\"rtl\":\"ltr\"}static createInstance(e={},n){const r=new T0(e,n);return r.createInstance=T0.createInstance,r}cloneInstance(e={},n=K_){const r=e.forkResourceStore;r&&delete e.forkResourceStore;const i={...this.options,...e,isClone:!0},s=new T0(i);if((e.debug!==void 0||e.prefix!==void 0)&&(s.logger=s.logger.clone(e)),[\"store\",\"services\",\"language\"].forEach(l=>{s[l]=this[l]}),s.services={...this.services},s.services.utils={hasLoadedNamespace:s.hasLoadedNamespace.bind(s)},r){const l=Object.keys(this.store.data).reduce((c,d)=>(c[d]={...this.store.data[d]},c[d]=Object.keys(c[d]).reduce((u,m)=>(u[m]={...c[d][m]},u),c[d]),c),{});s.store=new H8(l,i),s.services.resourceStore=s.store}return s.translator=new pN(s.services,i),s.translator.on(\"*\",(l,...c)=>{s.emit(l,...c)}),s.init(i,n),s.translator.options=i,s.translator.backendConnector.services.utils={hasLoadedNamespace:s.hasLoadedNamespace.bind(s)},s}toJSON(){return{options:this.options,store:this.store,language:this.language,languages:this.languages,resolvedLanguage:this.resolvedLanguage}}}const xo=T0.createInstance();xo.createInstance;xo.dir;xo.init;xo.loadResources;xo.reloadResources;xo.use;xo.changeLanguage;xo.getFixedT;xo.t;xo.exists;xo.setDefaultNamespace;xo.hasLoadedNamespace;xo.loadNamespaces;xo.loadLanguages;const uue=(t,e,n,r)=>{const i=[n,{code:e,...r||{}}];if(t?.services?.logger?.forward)return t.services.logger.forward(i,\"warn\",\"react-i18next::\",!0);sg(i[0])&&(i[0]=`react-i18next:: ${i[0]}`),t?.services?.logger?.warn?t.services.logger.warn(...i):console?.warn&&console.warn(...i)},Q8={},$Y=(t,e,n,r)=>{sg(n)&&Q8[n]||(sg(n)&&(Q8[n]=new Date),uue(t,e,n,r))},VY=(t,e)=>()=>{if(t.isInitialized)e();else{const n=()=>{setTimeout(()=>{t.off(\"initialized\",n)},0),e()};t.on(\"initialized\",n)}},$3=(t,e,n)=>{t.loadNamespaces(e,VY(t,n))},Z8=(t,e,n,r)=>{if(sg(n)&&(n=[n]),t.options.preload&&t.options.preload.indexOf(e)>-1)return $3(t,n,r);n.forEach(i=>{t.options.ns.indexOf(i)<0&&t.options.ns.push(i)}),t.loadLanguages(e,VY(t,r))},mue=(t,e,n={})=>!e.languages||!e.languages.length?($Y(e,\"NO_LANGUAGES\",\"i18n.languages were undefined or empty\",{languages:e.languages}),!0):e.hasLoadedNamespace(t,{lng:n.lng,precheck:(r,i)=>{if(n.bindI18n&&n.bindI18n.indexOf(\"languageChanging\")>-1&&r.services.backendConnector.backend&&r.isLanguageChangingTo&&!i(r.isLanguageChangingTo,t))return!1}}),sg=t=>typeof t==\"string\",hue=t=>typeof t==\"object\"&&t!==null,pue=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,fue={\"&amp;\":\"&\",\"&#38;\":\"&\",\"&lt;\":\"<\",\"&#60;\":\"<\",\"&gt;\":\">\",\"&#62;\":\">\",\"&apos;\":\"'\",\"&#39;\":\"'\",\"&quot;\":'\"',\"&#34;\":'\"',\"&nbsp;\":\" \",\"&#160;\":\" \",\"&copy;\":\"©\",\"&#169;\":\"©\",\"&reg;\":\"®\",\"&#174;\":\"®\",\"&hellip;\":\"…\",\"&#8230;\":\"…\",\"&#x2F;\":\"/\",\"&#47;\":\"/\"},gue=t=>fue[t],bue=t=>t.replace(pue,gue);let V3={bindI18n:\"languageChanged\",bindI18nStore:\"\",transEmptyNodeValue:\"\",transSupportBasicHtmlNodes:!0,transWrapTextNodes:\"\",transKeepBasicHtmlNodesFor:[\"br\",\"strong\",\"i\",\"p\"],useSuspense:!0,unescape:bue,transDefaultProps:void 0};const xue=(t={})=>{V3={...V3,...t}},yue=()=>V3;let GY;const vue=t=>{GY=t},wue=()=>GY,Sue={type:\"3rdParty\",init(t){xue(t.options.react),vue(t)}},_ue=w.createContext();class kue{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach(n=>{this.usedNamespaces[n]||(this.usedNamespaces[n]=!0)})}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}var _M={exports:{}},kM={};var J8;function Nue(){if(J8)return kM;J8=1;var t=Rg();function e(m,p){return m===p&&(m!==0||1/m===1/p)||m!==m&&p!==p}var n=typeof Object.is==\"function\"?Object.is:e,r=t.useState,i=t.useEffect,s=t.useLayoutEffect,o=t.useDebugValue;function l(m,p){var f=p(),y=r({inst:{value:f,getSnapshot:p}}),v=y[0].inst,b=y[1];return s(function(){v.value=f,v.getSnapshot=p,c(v)&&b({inst:v})},[m,f,p]),i(function(){return c(v)&&b({inst:v}),m(function(){c(v)&&b({inst:v})})},[m]),o(f),f}function c(m){var p=m.getSnapshot;m=m.value;try{var f=p();return!n(m,f)}catch{return!0}}function d(m,p){return p()}var u=typeof window>\"u\"||typeof window.document>\"u\"||typeof window.document.createElement>\"u\"?d:l;return kM.useSyncExternalStore=t.useSyncExternalStore!==void 0?t.useSyncExternalStore:u,kM}var eH;function WY(){return eH||(eH=1,_M.exports=Nue()),_M.exports}var hO=WY();const Cue=(t,e)=>sg(e)?e:hue(e)&&sg(e.defaultValue)?e.defaultValue:Array.isArray(t)?t[t.length-1]:t,Pue={t:Cue,ready:!1},Tue=()=>()=>{},Ft=(t,e={})=>{const{i18n:n}=e,{i18n:r,defaultNS:i}=w.useContext(_ue)||{},s=n||r||wue();s&&!s.reportNamespaces&&(s.reportNamespaces=new kue),s||$Y(s,\"NO_I18NEXT_INSTANCE\",\"useTranslation: You will need to pass in an i18next instance by using initReactI18next\");const o=w.useMemo(()=>({...yue(),...s?.options?.react,...e}),[s,e]),{useSuspense:l,keyPrefix:c}=o,d=i||s?.options?.defaultNS,u=sg(d)?[d]:d||[\"translation\"],m=w.useMemo(()=>u,u);s?.reportNamespaces?.addUsedNamespaces?.(m);const p=w.useRef(0),f=w.useCallback(k=>{if(!s)return Tue;const{bindI18n:D,bindI18nStore:H}=o,z=()=>{p.current+=1,k()};return D&&s.on(D,z),H&&s.store.on(H,z),()=>{D&&D.split(\" \").forEach(Q=>s.off(Q,z)),H&&H.split(\" \").forEach(Q=>s.store.off(Q,z))}},[s,o]),y=w.useRef(),v=w.useCallback(()=>{if(!s)return Pue;const k=!!(s.isInitialized||s.initializedStoreOnce)&&m.every(te=>mue(te,s,o)),D=e.lng||s.language,H=p.current,z=y.current;if(z&&z.ready===k&&z.lng===D&&z.keyPrefix===c&&z.revision===H)return z;const L={t:s.getFixedT(D,o.nsMode===\"fallback\"?m:m[0],c),ready:k,lng:D,keyPrefix:c,revision:H};return y.current=L,L},[s,m,c,o,e.lng]),[b,g]=w.useState(0),{t:_,ready:C}=hO.useSyncExternalStore(f,v,v);w.useEffect(()=>{if(s&&!C&&!l){const k=()=>g(D=>D+1);e.lng?Z8(s,e.lng,m,k):$3(s,m,k)}},[s,e.lng,m,C,l,b]);const P=s||{},N=w.useRef(null),A=w.useRef(),T=k=>{const D=Object.getOwnPropertyDescriptors(k);D.__original&&delete D.__original;const H=Object.create(Object.getPrototypeOf(k),D);if(!Object.prototype.hasOwnProperty.call(H,\"__original\"))try{Object.defineProperty(H,\"__original\",{value:k,writable:!1,enumerable:!1,configurable:!1})}catch{}return H},F=w.useMemo(()=>{const k=P,D=k?.language;let H=k;k&&(N.current&&N.current.__original===k?A.current!==D?(H=T(k),N.current=H,A.current=D):H=N.current:(H=T(k),N.current=H,A.current=D));const z=[_,H,C];return z.t=_,z.i18n=H,z.ready=C,z},[_,P,C,P.resolvedLanguage,P.language,P.languages]);if(s&&l&&!C)throw new Promise(k=>{const D=()=>k();e.lng?Z8(s,e.lng,m,D):$3(s,m,D)});return F},{slice:Aue,forEach:jue}=[];function Mue(t){return jue.call(Aue.call(arguments,1),e=>{if(e)for(const n in e)t[n]===void 0&&(t[n]=e[n])}),t}function Eue(t){return typeof t!=\"string\"?!1:[/<\\s*script.*?>/i,/<\\s*\\/\\s*script\\s*>/i,/<\\s*img.*?on\\w+\\s*=/i,/<\\s*\\w+\\s*on\\w+\\s*=.*?>/i,/javascript\\s*:/i,/vbscript\\s*:/i,/expression\\s*\\(/i,/eval\\s*\\(/i,/alert\\s*\\(/i,/document\\.cookie/i,/document\\.write\\s*\\(/i,/window\\.location/i,/innerHTML/i].some(n=>n.test(t))}const tH=/^[\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+$/,Due=function(t,e){const r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{path:\"/\"},i=encodeURIComponent(e);let s=`${t}=${i}`;if(r.maxAge>0){const o=r.maxAge-0;if(Number.isNaN(o))throw new Error(\"maxAge should be a Number\");s+=`; Max-Age=${Math.floor(o)}`}if(r.domain){if(!tH.test(r.domain))throw new TypeError(\"option domain is invalid\");s+=`; Domain=${r.domain}`}if(r.path){if(!tH.test(r.path))throw new TypeError(\"option path is invalid\");s+=`; Path=${r.path}`}if(r.expires){if(typeof r.expires.toUTCString!=\"function\")throw new TypeError(\"option expires is invalid\");s+=`; Expires=${r.expires.toUTCString()}`}if(r.httpOnly&&(s+=\"; HttpOnly\"),r.secure&&(s+=\"; Secure\"),r.sameSite)switch(typeof r.sameSite==\"string\"?r.sameSite.toLowerCase():r.sameSite){case!0:s+=\"; SameSite=Strict\";break;case\"lax\":s+=\"; SameSite=Lax\";break;case\"strict\":s+=\"; SameSite=Strict\";break;case\"none\":s+=\"; SameSite=None\";break;default:throw new TypeError(\"option sameSite is invalid\")}return r.partitioned&&(s+=\"; Partitioned\"),s},nH={create(t,e,n,r){let i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:{path:\"/\",sameSite:\"strict\"};n&&(i.expires=new Date,i.expires.setTime(i.expires.getTime()+n*60*1e3)),r&&(i.domain=r),document.cookie=Due(t,e,i)},read(t){const e=`${t}=`,n=document.cookie.split(\";\");for(let r=0;r<n.length;r++){let i=n[r];for(;i.charAt(0)===\" \";)i=i.substring(1,i.length);if(i.indexOf(e)===0)return i.substring(e.length,i.length)}return null},remove(t,e){this.create(t,\"\",-1,e)}};var Fue={name:\"cookie\",lookup(t){let{lookupCookie:e}=t;if(e&&typeof document<\"u\")return nH.read(e)||void 0},cacheUserLanguage(t,e){let{lookupCookie:n,cookieMinutes:r,cookieDomain:i,cookieOptions:s}=e;n&&typeof document<\"u\"&&nH.create(n,t,r,i,s)}},Rue={name:\"querystring\",lookup(t){let{lookupQuerystring:e}=t,n;if(typeof window<\"u\"){let{search:r}=window.location;!window.location.search&&window.location.hash?.indexOf(\"?\")>-1&&(r=window.location.hash.substring(window.location.hash.indexOf(\"?\")));const s=r.substring(1).split(\"&\");for(let o=0;o<s.length;o++){const l=s[o].indexOf(\"=\");l>0&&s[o].substring(0,l)===e&&(n=s[o].substring(l+1))}}return n}},Lue={name:\"hash\",lookup(t){let{lookupHash:e,lookupFromHashIndex:n}=t,r;if(typeof window<\"u\"){const{hash:i}=window.location;if(i&&i.length>2){const s=i.substring(1);if(e){const o=s.split(\"&\");for(let l=0;l<o.length;l++){const c=o[l].indexOf(\"=\");c>0&&o[l].substring(0,c)===e&&(r=o[l].substring(c+1))}}if(r)return r;if(!r&&n>-1){const o=i.match(/\\/([a-zA-Z-]*)/g);return Array.isArray(o)?o[typeof n==\"number\"?n:0]?.replace(\"/\",\"\"):void 0}}}return r}};let wb=null;const rH=()=>{if(wb!==null)return wb;try{if(wb=typeof window<\"u\"&&window.localStorage!==null,!wb)return!1;const t=\"i18next.translate.boo\";window.localStorage.setItem(t,\"foo\"),window.localStorage.removeItem(t)}catch{wb=!1}return wb};var Oue={name:\"localStorage\",lookup(t){let{lookupLocalStorage:e}=t;if(e&&rH())return window.localStorage.getItem(e)||void 0},cacheUserLanguage(t,e){let{lookupLocalStorage:n}=e;n&&rH()&&window.localStorage.setItem(n,t)}};let Sb=null;const aH=()=>{if(Sb!==null)return Sb;try{if(Sb=typeof window<\"u\"&&window.sessionStorage!==null,!Sb)return!1;const t=\"i18next.translate.boo\";window.sessionStorage.setItem(t,\"foo\"),window.sessionStorage.removeItem(t)}catch{Sb=!1}return Sb};var Iue={name:\"sessionStorage\",lookup(t){let{lookupSessionStorage:e}=t;if(e&&aH())return window.sessionStorage.getItem(e)||void 0},cacheUserLanguage(t,e){let{lookupSessionStorage:n}=e;n&&aH()&&window.sessionStorage.setItem(n,t)}},zue={name:\"navigator\",lookup(t){const e=[];if(typeof navigator<\"u\"){const{languages:n,userLanguage:r,language:i}=navigator;if(n)for(let s=0;s<n.length;s++)e.push(n[s]);r&&e.push(r),i&&e.push(i)}return e.length>0?e:void 0}},Uue={name:\"htmlTag\",lookup(t){let{htmlTag:e}=t,n;const r=e||(typeof document<\"u\"?document.documentElement:null);return r&&typeof r.getAttribute==\"function\"&&(n=r.getAttribute(\"lang\")),n}},Bue={name:\"path\",lookup(t){let{lookupFromPathIndex:e}=t;if(typeof window>\"u\")return;const n=window.location.pathname.match(/\\/([a-zA-Z-]*)/g);return Array.isArray(n)?n[typeof e==\"number\"?e:0]?.replace(\"/\",\"\"):void 0}},Hue={name:\"subdomain\",lookup(t){let{lookupFromSubdomainIndex:e}=t;const n=typeof e==\"number\"?e+1:1,r=typeof window<\"u\"&&window.location?.hostname?.match(/^(\\w{2,5})\\.(([a-z0-9-]{1,63}\\.[a-z]{2,6})|localhost)/i);if(r)return r[n]}};let KY=!1;try{document.cookie,KY=!0}catch{}const XY=[\"querystring\",\"cookie\",\"localStorage\",\"sessionStorage\",\"navigator\",\"htmlTag\"];KY||XY.splice(1,1);const que=()=>({order:XY,lookupQuerystring:\"lng\",lookupCookie:\"i18next\",lookupLocalStorage:\"i18nextLng\",lookupSessionStorage:\"i18nextLng\",caches:[\"localStorage\"],excludeCacheFor:[\"cimode\"],convertDetectedLanguage:t=>t});class YY{constructor(e){let n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};this.type=\"languageDetector\",this.detectors={},this.init(e,n)}init(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{languageUtils:{}},n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};this.services=e,this.options=Mue(n,this.options||{},que()),typeof this.options.convertDetectedLanguage==\"string\"&&this.options.convertDetectedLanguage.indexOf(\"15897\")>-1&&(this.options.convertDetectedLanguage=i=>i.replace(\"-\",\"_\")),this.options.lookupFromUrlIndex&&(this.options.lookupFromPathIndex=this.options.lookupFromUrlIndex),this.i18nOptions=r,this.addDetector(Fue),this.addDetector(Rue),this.addDetector(Oue),this.addDetector(Iue),this.addDetector(zue),this.addDetector(Uue),this.addDetector(Bue),this.addDetector(Hue),this.addDetector(Lue)}addDetector(e){return this.detectors[e.name]=e,this}detect(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:this.options.order,n=[];return e.forEach(r=>{if(this.detectors[r]){let i=this.detectors[r].lookup(this.options);i&&typeof i==\"string\"&&(i=[i]),i&&(n=n.concat(i))}}),n=n.filter(r=>r!=null&&!Eue(r)).map(r=>this.options.convertDetectedLanguage(r)),this.services&&this.services.languageUtils&&this.services.languageUtils.getBestMatchFromCodes?n:n.length>0?n[0]:null}cacheUserLanguage(e){let n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:this.options.caches;n&&(this.options.excludeCacheFor&&this.options.excludeCacheFor.indexOf(e)>-1||n.forEach(r=>{this.detectors[r]&&this.detectors[r].cacheUserLanguage(e,this.options)}))}}YY.type=\"languageDetector\";const $ue={nav:{printers:\"Printers\",archives:\"Archives\",queue:\"Queue\",stats:\"Statistics\",profiles:\"Profiles\",maintenance:\"Maintenance\",projects:\"Projects\",inventory:\"Filament\",files:\"File Manager\",notifications:\"Notifications\",settings:\"Settings\",system:\"System\",collapseSidebar:\"Collapse sidebar\",expandSidebar:\"Expand sidebar\",update:\"Update\",updateAvailable:\"Update available: v{{version}}\",updateAvailableBanner:\"Version {{version}} is available!\",viewUpdate:\"View update\",viewOnGithub:\"View on GitHub\",keyboardShortcuts:\"Keyboard shortcuts (?)\",switchToLight:\"Switch to light mode\",switchToDark:\"Switch to dark mode\",smartSwitches:\"Smart Switches\",logout:\"Logout\"},common:{save:\"Save\",saving:\"Saving...\",cancel:\"Cancel\",delete:\"Delete\",edit:\"Edit\",add:\"Add\",close:\"Close\",confirm:\"Confirm\",loading:\"Loading...\",error:\"Error\",success:\"Success\",warning:\"Warning\",enabled:\"Enabled\",disabled:\"Disabled\",yes:\"Yes\",no:\"No\",on:\"On\",off:\"Off\",all:\"All\",none:\"None\",search:\"Search\",filter:\"Filter\",sort:\"Sort\",refresh:\"Refresh\",download:\"Download\",upload:\"Upload\",uploading:\"Uploading...\",uploadFailed:\"Upload failed\",actions:\"Actions\",status:\"Status\",name:\"Name\",description:\"Description\",date:\"Date\",time:\"Time\",hours:\"hours\",minutes:\"minutes\",seconds:\"seconds\",days:\"days\",enable:\"Enable\",disable:\"Disable\",permissions:\"Permissions\",noPrinters:\"No printers configured\",noData:\"No data available\",linkNotFound:\"Link not found\",required:\"Required\",optional:\"Optional\",dismiss:\"Dismiss\",apply:\"Apply\",reset:\"Reset\",export:\"Export\",import:\"Import\",clear:\"Clear\",selectAll:\"Select All\",deselectAll:\"Deselect All\",noChange:\"— No change —\",unchanged:\"Unchanged\",unassigned:\"Unassigned\",unknown:\"Unknown\",unknownError:\"Unknown error\",today:\"Today\",tomorrow:\"Tomorrow\",asap:\"ASAP\",overdue:\"Overdue\",now:\"Now\",collapse:\"Collapse\",expand:\"Expand\",viewArchive:\"View archive\",viewInFileManager:\"View in File Manager\",addedBy:\"Added by {{username}}\",prints:\"prints\",more:\"+{{count}} more\",ascending:\"Ascending\",descending:\"Descending\",back:\"Back\",copy:\"Copy\",copied:\"Copied!\",printer:\"Printer\",remove:\"Remove\",type:\"Type\",print:\"Print\",rename:\"Rename\",move:\"Move\",create:\"Create\",duplicate:\"Duplicate\",left:\"Left\",right:\"Right\"},printers:{title:\"Printers\",addPrinter:\"Add Printer\",editPrinter:\"Edit Printer\",deletePrinter:\"Delete Printer\",printerName:\"Printer Name\",serialNumber:\"Serial Number\",ipAddress:\"IP Address / Hostname\",accessCode:\"Access Code\",model:\"Model\",nozzleCount:\"Nozzle Count\",autoArchive:\"Auto Archive\",status:{available:\"Available\",idle:\"Idle\",printing:\"Printing\",paused:\"Paused\",offline:\"Offline\",problem:\"Problem\",error:\"Error\",finished:\"Finished\",unknown:\"Unknown\"},temperatures:{nozzle:\"Nozzle\",bed:\"Bed\",chamber:\"Chamber\"},progress:\"{{percent}}% complete\",timeRemaining:\"{{time}} remaining\",deleteConfirm:'Are you sure you want to delete \"{{name}}\"?',maintenanceOk:\"Maintenance OK\",maintenanceWarning:\"{{count}} warning\",maintenanceWarning_plural:\"{{count}} warnings\",maintenanceDue:\"{{count}} due\",maintenanceDue_plural:\"{{count}} due\",sort:{name:\"Name\",status:\"Status\",model:\"Model\",location:\"Location\",ascending:\"Sort ascending\",descending:\"Sort descending\"},cardSize:{small:\"Small cards\",medium:\"Medium cards\",large:\"Large cards\",extraLarge:\"Extra large cards\"},hideOffline:\"Hide offline\",nextAvailable:\"Next available\",powerOn:\"Power On\",offlinePrintersWithPlugs:\"Offline printers with smart plugs\",noPrintersConfigured:\"No printers configured yet\",search:\"Search printers...\",noSearchResults:\"No printers match your search or filters\",filter:{allStatuses:\"All statuses\",allLocations:\"All locations\"},readyToPrint:\"Ready to print\",external:\"External\",extL:\"Ext-L\",extR:\"Ext-R\",deleteArchives:\"Delete print archives\",noLabel:\"No label\",printPreview:\"Print preview\",width:\"Width\",height:\"Height\",noObjectsFound:\"No objects found\",objectsLoadedOnPrintStart:\"Objects are loaded when a print starts\",willBeSkipped:\"Will be skipped\",name:\"Name\",serialCannotBeChanged:\"Serial number cannot be changed\",locationHelp:\"Used to group printers and filter queue jobs\",wifiSignal:{veryWeak:\"Very weak\",weak:\"Weak\",fair:\"Fair\",good:\"Good\",excellent:\"Excellent\"},maintenanceUpToDate:\"All maintenance up to date - Click to view\",chamberLightOn:\"Turn on chamber light\",chamberLightOff:\"Turn off chamber light\",files:\"Files\",browseFiles:\"Browse printer files\",autoOffAfterPrint:\"Auto power-off after print\",autoOffExecuted:\"Auto-off was executed - turn printer on to reset\",hmsErrors:\"HMS Errors\",viewHmsErrors:\"View {{count}} HMS error(s)\",resume:\"Resume\",pause:\"Pause\",stop:\"Stop\",camera:\"Camera\",skipObject:\"Skip Object\",reconnect:\"Reconnect\",forceRefresh:\"Force Refresh\",forceRefreshSuccess:\"Refresh requested\",mqttDebug:\"MQTT Debug\",printerInformation:\"Printer Information\",copyToClipboard:\"Copy\",copied:\"Copied!\",state:\"State\",wifiSignalLabel:\"WiFi Signal\",developerMode:\"Developer Mode\",enabled:\"Enabled\",disabled:\"Disabled\",addedOn:\"Added\",sdCard:\"SD Card\",inserted:\"Inserted\",notInserted:\"Not inserted\",totalPrintHours:\"Print Hours\",activeNozzle:\"Active: {{nozzle}} nozzle\",nozzleRack:\"Nozzle Rack\",nozzleDocked:\"Docked\",nozzleMounted:\"Mounted\",nozzleActive:\"Active\",nozzleIdle:\"Idle\",nozzleDiameter:\"Diameter\",nozzleType:\"Type\",nozzleStatus:\"Status\",nozzleFilament:\"Filament\",nozzleWear:\"Wear\",nozzleMaxTemp:\"Max Temp\",nozzleSerial:\"Serial\",nozzleHardenedSteel:\"Hardened Steel\",nozzleStainlessSteel:\"Stainless Steel\",nozzleTungstenCarbide:\"Tungsten Carbide\",nozzleFlow:\"Flow\",nozzleHighFlow:\"High Flow\",nozzleStandardFlow:\"Standard\",firmwareUpdate:\"Firmware Update\",firmwareInstructions:\"On the printer's touchscreen, go to\",firmwareNav:\"Navigate to\",settings:\"Settings\",firmware:\"Firmware\",discoverPrinters:\"Discover Printers\",searching:\"Searching...\",manualEntry:\"Manual Entry\",addFromCloud:\"Add from Cloud\",toast:{printerDeleted:\"Printer deleted\",missingSpoolAssignment:\"Print started on {{printer}}. Missing spool assignment for: {{slots}}\",printerAdded:\"Printer added\",printerUpdated:\"Printer updated\",failedToDelete:\"Failed to delete printer\",failedToAdd:\"Failed to add printer\",failedToUpdate:\"Failed to update printer\",commandSent:\"Command sent\",failedToSendCommand:\"Failed to send command\",turnedOn:\"{{name}} turned on\",failedToPowerOn:\"Failed to power on {{name}}\",scriptTriggered:\"Script triggered\",printStopped:\"Print stopped\",printPaused:\"Print paused\",printResumed:\"Print resumed\",referenceDeleted:\"Reference deleted\",detectionAreaSaved:\"Detection area saved\",failedToRunScript:\"Failed to run script\",failedToStopPrint:\"Failed to stop print\",failedToPausePrint:\"Failed to pause print\",failedToResumePrint:\"Failed to resume print\",failedToControlChamberLight:\"Failed to control chamber light\",failedToSetSpeed:\"Failed to set print speed\",failedToUpdateSetting:\"Failed to update setting\",failedToSkipObjects:\"Failed to skip objects\",failedToRereadRfid:\"Failed to re-read RFID\",failedToCheckPlate:\"Failed to check plate\",failedToUpdateLabel:\"Failed to update label\",failedToDeleteReference:\"Failed to delete reference\",failedToSaveDetectionArea:\"Failed to save detection area\",plateCheckEnabled:\"Plate check enabled\",plateCheckDisabled:\"Plate check disabled\",calibrationSaved:\"Calibration saved!\",calibrationFailed:\"Calibration failed\",rfidRereadInitiated:\"RFID re-read initiated\"},connection:{connected:\"Connected\",offline:\"Offline\"},plateStatus:{markCleared:\"Mark plate as cleared\",cleared:\"Plate Clear\",notCleared:\"Plate not Clear\",inUse:\"Plate in Use\"},queue:{inQueue:\"{{count}} print in queue\",inQueue_plural:\"{{count}} prints in queue\"},controls:\"Controls\",rfid:{reread:\"Re-read RFID\"},bedJog:{title:\"Move build plate\",bed:\"Bed\",step:\"Step (mm)\",up:\"Move plate up\",down:\"Move plate down\",disabledWhilePrinting:\"Disabled while printing\",notHomedTitle:\"Printer is not homed\",notHomedMessage:\"The printer has not been homed since the last print. Run auto-home first for safe positioning (parks the toolhead, then homes X, Y, and Z), or move anyway — soft endstops will be bypassed.\",homeZ:\"Auto Home\",moveAnyway:\"Move anyway\",homingStarted:\"Auto-homing printer…\"},permission:{noAdd:\"You do not have permission to add printers\",noEdit:\"You do not have permission to edit printers\",noDelete:\"You do not have permission to delete printers\",noControl:\"You do not have permission to control printers\",noFiles:\"You do not have permission to access printer files\",noAmsRfid:\"You do not have permission to re-read AMS RFID\",noSmartPlugControl:\"You do not have permission to control smart plugs\",noCamera:\"You do not have permission to view cameras\"},modal:{addTitle:\"Add Printer\",editTitle:\"Edit Printer\",myPrinter:\"My Printer\",selectModel:\"Select model...\",locationGroup:\"Location / Group (optional)\",locationPlaceholder:\"e.g., Workshop, Office, Basement\",autoArchiveLabel:\"Auto-archive completed prints\",fromPrinterSettings:\"From printer settings\",modelOptional:\"Model (optional)\",saveChanges:\"Save Changes\"},skipObjects:{tooltip:\"Skip objects\",onlyWhilePrinting:\"Skip objects (only while printing)\",requiresMultiple:\"Skip objects (requires 2+ objects)\",title:\"Skip Objects\",matchIdsInfo:\"Match IDs with your printer display\",printerShowsIds:\"The printer screen shows object IDs on the build plate\",skipSelected:\"Skip Selected\",skipping:\"Skipping...\",noObjectsSelected:\"No objects selected\",selectObjectsToSkip:\"Select objects you want to skip from the current print\",skipped:\"skipped\",objectsSkipped:\"Objects skipped\",activeCount:\"{{count}} active\",waitForLayer:\"Wait for layer 2+ to skip objects (currently layer {{layer}})\",skip:\"Skip\",confirmTitle:\"Skip Object?\",confirmMessage:'Are you sure you want to skip \"{{name}}\"? This cannot be undone.'},confirm:{deleteTitle:\"Delete Printer\",deleteMessage:'Are you sure you want to delete \"{{name}}\"? This will remove all connection settings.',deleteArchivesNote:\"All print history for this printer will be permanently deleted.\",keepArchivesNote:\"Print history will be kept but no longer associated with this printer.\",stopTitle:\"Stop Print\",stopMessage:'Are you sure you want to stop the current print on \"{{name}}\"? This will cancel the print job.',stopButton:\"Stop Print\",pauseTitle:\"Pause Print\",pauseMessage:'Are you sure you want to pause the current print on \"{{name}}\"?',pauseButton:\"Pause Print\",resumeTitle:\"Resume Print\",resumeMessage:'Are you sure you want to resume the print on \"{{name}}\"?',resumeButton:\"Resume Print\",powerOnTitle:\"Power On Printer\",powerOnMessage:'Are you sure you want to turn ON the power for \"{{name}}\"?',powerOnButton:\"Power On\",powerOffTitle:\"Power Off Printer\",powerOffMessage:'Are you sure you want to turn OFF the power for \"{{name}}\"?',powerOffWarning:'WARNING: \"{{name}}\" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.',powerOffButton:\"Power Off\"},bulk:{select:\"Select\",selectAll:\"Select All\",selectByLocation:\"Select by Location\",selected:\"{{count}} selected\",actions:{stop:\"Stop\",pause:\"Pause\",resume:\"Resume\",clearPlate:\"Clear Bed\",clearHMS:\"Clear Notifications\"},confirm:{stopTitle:\"Stop {{count}} Prints\",stopMessage:\"This will cancel active prints on {{count}} printer(s). This action cannot be undone.\",stopButton:\"Stop All\",pauseTitle:\"Pause {{count}} Prints\",pauseMessage:\"This will pause active prints on {{count}} printer(s).\",pauseButton:\"Pause All\",clearPlateTitle:\"Clear {{count}} Print Beds\",clearPlateMessage:\"This will clear the print bed on {{count}} printer(s) and may trigger queued jobs.\",clearPlateButton:\"Clear All\"},success:\"{{action}} completed on {{count}} printer(s)\",partial:\"{{succeeded}} succeeded, {{failed}} failed\",noneApplicable:\"No selected printers are in the right state for this action\",selectByState:\"Select by State\"},discovery:{title:\"Discover Printers\",searching:\"Searching...\",scanning:\"Scanning...\",scanProgress:\"Scanning... {{scanned}}/{{total}}\",foundPrinters:\"Found {{count}} printer(s)\",noPrintersFound:\"No printers found\",noPrintersFoundSubnet:\"No printers found in the specified subnet.\",noPrintersFoundNetwork:\"No printers found on the network.\",allConfigured:\"All discovered printers are already configured.\",alreadyAdded:\"Already added\",select:\"Select\",manualEntry:\"Manual Entry\",addFromCloud:\"Add from Cloud\",subnetToScan:\"Subnet to scan\",dockerNote:\"Docker detected. Enter your printer's subnet in CIDR notation. Requires network_mode: host in docker-compose.yml.\",scanSubnet:\"Scan Subnet for Printers\",discoverNetwork:\"Discover Printers on Network\",scanningSubnet:\"Scanning subnet for Bambu printers...\",scanningNetwork:\"Scanning network...\",serialRequired:\"Serial required\",unknown:\"Unknown\",failedToStart:\"Failed to start discovery\"},drying:{start:\"Start Drying\",stop:\"Stop Drying\",temperature:\"Temperature\",duration:\"Duration\",hours:\"hours\",timeRemaining:\"{{time}} left\",active:\"Drying\",notSupported:\"Drying not supported\",powerRequired:\"Connect AMS power adapter to enable drying\",startingDrying:\"Starting drying...\",stoppingDrying:\"Stopping drying...\",rotateTray:\"Rotate spool during drying\"},filaments:\"Filaments\",openCameraOverlay:\"Open camera overlay\",openCameraWindow:\"Open camera in new window\",firmwareUpdateAvailable:\"Firmware update available: {{current}} → {{latest}}\",firmwareUpToDate:\"Firmware {{version}} — Up to date\",firmwareUpdateButton:\"Update\",plateDetection:{noPermission:\"You do not have permission to update printers\",enabledClick:\"Plate check enabled - Click to disable\",disabledClick:\"Plate check disabled - Click to enable\",manageCalibration:\"Manage plate detection calibration\",calibrationRequired:\"Calibration Required\",calibrationInstructions:\"Please ensure the build plate is <strong>completely empty</strong>, then click Calibrate.\",calibrationDescription:\"Calibration captures a reference image of the empty plate. Future checks will compare against this reference to detect objects.\",calibrationTip:\"<strong>Tip:</strong> You can store up to 5 calibrations for different plates. The system automatically uses the best match when checking.\",plateEmpty:\"Plate appears empty\",objectsDetected:\"Objects detected on plate\",confidence:\"Confidence\",difference:\"Difference\",analysisPreview:\"Analysis preview:\",analysisLegend:\"Green box = detection area, Red overlay = differences from calibration\",savedReferences:\"Saved References ({{count}}/{{max}})\",deleteReference:\"Delete reference\",labelPlaceholder:\"Label...\",clickToEdit:\"{{label}} - Click to edit\",clickToAddLabel:\"Click to add label\"},speed:{title:\"Print Speed\",silent:\"Silent (50%)\",standard:\"Standard (100%)\",sport:\"Sport (124%)\",ludicrous:\"Ludicrous (166%)\"},airduct:{title:\"Airduct Mode\",cooling:\"Cooling\",heating:\"Heating\"},noSdCard:\"No SD\",door:{open:\"Open\",closed:\"Closed\"},fans:{partCooling:\"Part Cooling Fan\",auxiliary:\"Auxiliary Fan\",chamber:\"Chamber Fan\"},clickToViewHmsErrors:\"Click to view HMS errors\",estimatedCompletion:\"Estimated completion time\",plateNumber:\"Plate {{number}}\",slotOptions:\"Slot options\",amsPopup:{friendlyName:\"AMS Name\",friendlyNamePlaceholder:\"e.g. AMS Friendly Name\",serialNumber:\"Serial Number\",firmwareVersion:\"Firmware\",save:\"Save\",clear:\"Clear\",noEditPermission:\"You do not have permission to rename AMS units\"},firmwareModal:{title:\"Firmware Update\",titleUpToDate:\"Firmware Info\",currentVersion:\"Current:\",latestVersion:\"Latest:\",releaseNotes:\"Release Notes\",checkingPrereqs:\"Checking prerequisites...\",sdCardReady:\"SD card ready. Click below to upload firmware.\",uploadedSuccess:\"Firmware uploaded to SD card!\",applyInstructions:\"To apply the update on your printer:\",step1:\"On the printer's touchscreen, go to <strong>Settings</strong>\",step2:\"Navigate to <strong>Firmware</strong>\",step3:\"Select <strong>Update from SD card</strong>\",step4:\"The update will take 10-20 minutes\",done:\"Done\",starting:\"Starting...\",uploadFirmware:\"Upload Firmware\",uploadFailed:\"Failed to start upload: {{error}}\",uploadedToast:\"Firmware uploaded! Trigger update from printer screen.\",availableVersions:\"Available versions\",usable:\"Usable\",unavailable:\"Unavailable\",installed:\"Installed\",newerBadge:\"newer\",olderBadge:\"older\",currentBadge:\"current\"},accessCodePlaceholder:\"Leave empty to keep current\",roi:{title:\"Detection Area (ROI)\",xStart:\"X Start\",yStart:\"Y Start\",width:\"Width\",height:\"Height\",instruction:\"Adjust the detection area to focus on the build plate. The green box in the preview shows the current area.\"},developerModeWarning:\"Developer LAN mode is not enabled on: {{names}}. Some features may not work.\",howToEnable:\"How to enable\",incompatibleFile:\"This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}\",dropNotPrintable:\"Only .gcode and .gcode.3mf files can be printed\",dropToPrint:\"Drop to print\",cannotPrint:\"Printer busy\"},archives:{title:\"Print Archives\",searchPlaceholder:\"Search archives...\",filterByPrinter:\"Filter by printer\",filterByStatus:\"Filter by status\",sortBy:\"Sort by\",sortNewest:\"Newest first\",sortOldest:\"Oldest first\",sortName:\"Name\",sortDuration:\"Duration\",sortLargest:\"Largest first\",sortSmallest:\"Smallest first\",sortSize:\"Size\",noArchives:\"No archives found\",noArchivesSearch:\"No archives match your search\",originalPrintNotVisible:\"Original print not visible - try clearing filters\",noArchivesYet:\"No archives yet\",prints:\"prints\",pagination:{showing:\"Showing\",to:\"to\",of:\"of\",show:\"Show\",page:\"Page\",all:\"All\"},loadingArchives:\"Loading archives...\",releaseToUpload:\"Release to upload\",showAll:\"Show all\",showFavoritesOnly:\"Show favorites only\",gridView:\"Grid view\",listView:\"List view\",calendarView:\"Calendar view\",logView:\"Print Log\",manageTags:\"Manage Tags\",showFailedPrints:\"Show failed prints\",hideFailedPrints:\"Hide failed prints\",hideDuplicates:\"Hide Duplicates\",viewOriginalPrint:\"Click to view original print (#{{id}})\",printTime:\"Print Time\",filamentUsed:\"Filament Used\",cost:\"Cost\",reprint:\"Reprint\",preview:\"Preview\",deleteArchive:\"Delete Archive\",deleteConfirm:\"Are you sure you want to delete this archive?\",favorite:\"Favorite\",unfavorite:\"Remove from favorites\",viewDetails:\"View Details\",status:{completed:\"Completed\",failed:\"Failed\",stopped:\"Stopped\"},toast:{source3mfAttached:\"Source 3MF attached: {{filename}}\",failedUploadSource3mf:\"Failed to upload source 3MF\",source3mfRemoved:\"Source 3MF removed\",failedRemoveSource3mf:\"Failed to remove source 3MF\",f3dAttached:\"F3D attached: {{filename}}\",failedUploadF3d:\"Failed to upload F3D\",f3dRemoved:\"F3D removed\",failedRemoveF3d:\"Failed to remove F3D\",timelapseAttached:\"Timelapse attached: {{filename}}\",timelapseAlreadyAttached:\"Timelapse already attached\",noMatchingTimelapse:\"No matching timelapse found\",failedScanTimelapse:\"Failed to scan for timelapse\",failedAttachTimelapse:\"Failed to attach timelapse\",timelapseRemoved:\"Timelapse removed\",failedRemoveTimelapse:\"Failed to remove timelapse\",timelapseUploaded:\"Timelapse uploaded: {{filename}}\",failedUploadTimelapse:\"Failed to upload timelapse\",archiveDeleted:\"Archive deleted\",failedDeleteArchive:\"Failed to delete archive\",addedToFavorites:\"Added to favorites\",removedFromFavorites:\"Removed from favorites\",projectUpdated:\"Project updated\",failedUpdateProject:\"Failed to update project\",linkCopied:\"Link copied to clipboard\",failedCopyLink:\"Failed to copy link\",photoDeleted:\"Photo deleted\",failedDeletePhoto:\"Failed to delete photo\",failedDeleteArchives:\"Failed to delete archives\",failedUpdateFavorites:\"Failed to update favorites\",exportDownloaded:\"Export downloaded\",exportFailed:\"Export failed\"},menu:{print:\"Print\",schedule:\"Schedule\",openInBambuStudio:\"Open in Slicer\",slice:\"Slice\",externalLink:\"External Link\",viewOnMakerWorld:\"View on MakerWorld\",preview3d:\"3D Preview\",viewTimelapse:\"View Timelapse\",scanForTimelapse:\"Scan for Timelapse\",uploadTimelapse:\"Upload Timelapse\",removeTimelapse:\"Remove Timelapse\",downloadSource3mf:\"Download Source 3MF\",uploadSource3mf:\"Upload Source 3MF\",replaceSource3mf:\"Replace Source 3MF\",removeSource3mf:\"Remove Source 3MF\",uploadF3d:\"Upload F3D\",replaceF3d:\"Replace F3D\",downloadF3d:\"Download F3D\",removeF3d:\"Remove F3D\",download:\"Download\",copyDownloadLink:\"Copy Download Link\",qrCode:\"QR Code\",viewPhotos:\"View Photos\",viewPhotosCount:\"View Photos ({{count}})\",projectPage:\"Project Page\",addToFavorites:\"Add to Favorites\",removeFromFavorites:\"Remove from Favorites\",edit:\"Edit\",goToProject:\"Go to Project: {{name}}\",addToProject:\"Add to Project\",removeFromProject:\"Remove from Project\",loading:\"Loading...\",noProjectsAvailable:\"No projects available\",select:\"Select\",deselect:\"Deselect\",delete:\"Delete\"},permission:{noReprint:\"You do not have permission to reprint this archive\",noAddToQueue:\"You do not have permission to add to queue\",noUpdateArchives:\"You do not have permission to update archives\",noUploadFiles:\"You do not have permission to upload files\",noDownload:\"You do not have permission to download archives\",noCopyLink:\"You do not have permission to copy download links\",noDelete:\"You do not have permission to delete this archive\",noCreate:\"You do not have permission to create archives\"},card:{previousPlate:\"Previous plate\",nextPlate:\"Next plate\",plateNumber:\"Plate {{index}}\",moreOptions:\"Right-click for more options\",addToFavorites:\"Add to favorites\",removeFromFavorites:\"Remove from favorites\",cancelled:\"cancelled\",failed:\"failed\",duplicate:\"duplicate\",duplicateTitle:\"This model has been printed before\",openSource3mf:\"Open source 3MF in Bambu Studio (right-click for more options)\",downloadF3d:\"Download Fusion 360 design file\",viewTimelapse:\"View timelapse\",viewPhoto:\"View 1 photo\",viewPhotos:\"View {{count}} photos\",openFolder:\"Open folder: {{name}}\",slicedFile:\"Sliced file - ready to print\",sourceFile:\"Source file only - no AMS mapping available\",gcode:\"GCODE\",source:\"SOURCE\",project:\"Project: {{name}}\",estimated:\"Estimated: {{time}}\",actual:\"Actual: {{time}}\",accuracy:\"Accuracy: {{percent}}%\",filament:\"{{weight}}g\",layer:\"{{count}} layer\",layers:\"{{count}} layers\",object:\"{{count}} object\",objects:\"{{count}} objects\",slicedFor:\"Sliced for {{model}}\",uploadedBy:\"Uploaded By\",noPermissionReprint:\"You do not have permission to reprint\",noFileForReprint:\"No 3MF file available — the file could not be downloaded from the printer when the print was recorded\",noPermissionEdit:\"You do not have permission to edit archives\",noPermissionDelete:\"You do not have permission to delete archives\",reprint:\"Reprint\",schedulePrint:\"Schedule Print\",schedule:\"Schedule\",openInBambuStudio:\"Open in Slicer\",openInBambuStudioToSlice:\"Open in Slicer to slice\",slice:\"Slice\",externalLink:\"External Link\",makerWorld:\"MakerWorld: {{designer}}\",viewProject:\"View project\",noExternalLink:\"No external link\",preview3d:\"3D Preview\",download:\"Download\",edit:\"Edit\",delete:\"Delete\"},modal:{deleteArchive:\"Delete Archive\",deleteConfirm:'Are you sure you want to delete \"{{name}}\"? This action cannot be undone.',deleteButton:\"Delete\",removeSource3mf:\"Remove Source 3MF\",removeSource3mfConfirm:'Are you sure you want to remove the source 3MF file from \"{{name}}\"? This will delete the original slicer project file.',removeButton:\"Remove\",removeF3d:\"Remove F3D\",removeF3dConfirm:'Are you sure you want to remove the Fusion 360 design file from \"{{name}}\"?',removeTimelapse:\"Remove Timelapse\",removeTimelapseConfirm:'Are you sure you want to remove the timelapse video from \"{{name}}\"?',timelapse:\"{{name}} - Timelapse\",selectTimelapse:\"Select Timelapse\",selectTimelapseDesc:\"No auto-match found. Select the timelapse for this print:\",deleteArchives:\"Delete Archives\",deleteArchivesConfirm:\"Are you sure you want to delete {{count}} archive(s)? This action cannot be undone.\",deleteCount:\"Delete {{count}}\"},page:{title:\"Archives\",printsCount:\"{{filtered}} of {{total}} prints\",dropFilesHere:\"Drop .3mf files here\",releaseToUpload:\"Release to upload\",only3mfSupported:\"Only .3mf files are supported\",close:\"Close\",selected:\"{{count}} selected\",selectAll:\"Select All\",tags:\"Tags\",project:\"Project\",favorite:\"Favorite\",delete:\"Delete\",toggledFavorites:\"Toggled favorites for {{count}} archive(s)\",failedUpdateFavorites:\"Failed to update favorites\",archivesDeleted:\"{{count}} archive(s) deleted\",failedDeleteArchives:\"Failed to delete archives\",photoDeleted:\"Photo deleted\",failedDeletePhoto:\"Failed to delete photo\"},list:{name:\"Name\",printer:\"Printer\",date:\"Date\",size:\"Size\",actions:\"Actions\",hasTimelapse:\"Has timelapse\"},log:{date:\"Date\",printName:\"Print Name\",printer:\"Printer\",user:\"User\",status:\"Status\",duration:\"Duration\",filament:\"Filament\",allPrinters:\"All Printers\",allUsers:\"All Users\",allStatuses:\"All Statuses\",cancelled:\"Cancelled\",skipped:\"Skipped\",dateFrom:\"From\",dateTo:\"To\",noEntries:\"No print log entries found\",showing:\"Showing {{count}} of {{total}} entries\",rowsPerPage:\"Rows\",page:\"Page\",prev:\"Prev\",next:\"Next\",clearLog:\"Clear Log\",clearLogTitle:\"Clear Print Log\",clearLogConfirm:\"All print log entries will be permanently deleted. Archives and queue items are not affected. This action cannot be undone. Are you sure?\",clearLogButton:\"Clear All\",cleared:\"{{count}} log entries cleared\",clearFailed:\"Failed to clear print log\"}},queue:{title:\"Print Queue\",subtitle:\"Schedule and manage your print jobs\",addToQueue:\"Add to Queue\",print:\"Print\",reprint:\"Re-print\",schedulePrint:\"Schedule Print\",editQueueItem:\"Edit Queue Item\",printToPrinters:\"Print to {{count}} Printers\",queueToPrinters:\"Queue to {{count}} Printers\",queueSelectedPlates:\"Queue {{count}} Plates\",selectAllPlates:\"Select All {{count}} Plates\",deselectAll:\"Deselect All\",printQueued:\"Print queued\",itemsQueued:\"{{count}} items queued\",sending:\"Sending...\",sendingProgress:\"Sending {{current}}/{{total}}...\",adding:\"Adding...\",addingProgress:\"Adding {{current}}/{{total}}...\",savingProgress:\"Saving {{current}}/{{total}}...\",clearQueue:\"Clear Queue\",clearHistory:\"Clear History\",emptyQueue:\"Queue is empty\",position:\"Position\",scheduledTime:\"Scheduled Time\",moveUp:\"Move Up\",moveDown:\"Move Down\",startNow:\"Start Now\",printingInProgress:\"Printing in progress...\",viewArchive:\"View archive\",viewInFileManager:\"View in File Manager\",itemCount:\"{{count}} item\",itemCount_plural:\"{{count}} items\",dragToReorder:\"Drag to reorder (ASAP only)\",reorderHint:\"Position only affects ASAP items. Scheduled items run at their set time.\",sjf:{label:\"SJF\",tooltip:\"Shortest Job First — scheduler prioritizes shorter prints\"},addedBy:\"Added by {{name}}\",nextInQueue:\"Next in queue\",clearPlateSuccess:\"Plate cleared — ready for next print\",plateNumber:\"Plate {{index}}\",quantity:\"Quantity\",quantityHint:\"Creates {{count}} queue items\",activeBatches:\"Active Batches\",batchProgress:\"{{completed}} of {{total}} completed\",cancelBatch:\"Cancel Remaining\",batchCancelled:\"Remaining batch items cancelled\",cancelBatchConfirmTitle:\"Cancel Batch\",cancelBatchConfirmMessage:\"Cancel all remaining pending items in this batch?\",batch:\"Batch\",sections:{currentlyPrinting:\"Currently Printing\",queued:\"Queued\",history:\"History\"},status:{pending:\"Pending\",waiting:\"Waiting\",printing:\"Printing\",paused:\"Paused\",completed:\"Completed\",failed:\"Failed\",skipped:\"Skipped\",cancelled:\"Cancelled\"},summary:{printing:\"Printing\",queued:\"Queued\",totalTime:\"Total Queue Time\",totalWeight:\"Total Queue Weight\",history:\"History\"},filter:{allPrinters:\"All Printers\",unassigned:\"Unassigned\",allStatus:\"All Status\",allLocations:\"All Locations\",any:\"Any\"},sort:{byPosition:\"Sort by Position\",byName:\"Sort by Name\",byPrinter:\"Sort by Printer\",bySchedule:\"Sort by Schedule\",byDate:\"Sort by Date\",ascendingOldest:\"Ascending (oldest first)\",descendingNewest:\"Descending (newest first)\"},badges:{staged:\"Staged\",requiresPrevious:\"Requires previous success\",autoPowerOff:\"Auto power off\",gcodeInjection:\"G-code\"},empty:{title:\"No prints scheduled\",description:'Schedule a print from the Archives page using the \"Schedule\" option in the context menu, or drag and drop files to get started.'},time:{asap:\"ASAP\",overdue:\"Overdue\",now:\"Now\",lessThanMinute:\"In less than a minute\",inMinutes:\"In {{count}} min\",inHours:\"In {{count}} hours\"},actions:{stopPrint:\"Stop Print\",startPrint:\"Start Print\",requeue:\"Re-queue\"},bulkEdit:{title:\"Edit {{count}} Item\",title_plural:\"Edit {{count}} Items\",description:\"Only changed settings will be applied to selected items.\",printer:\"Printer\",noChange:\"— No change —\",queueOptions:\"Queue Options\",staged:\"Staged (manual start)\",autoPowerOff:\"Auto power off after print\",requirePrevious:\"Require previous success\",printOptions:\"Print Options\",bedLevelling:\"Bed levelling\",flowCalibration:\"Flow calibration\",vibrationCalibration:\"Vibration calibration\",layerInspection:\"First layer inspection\",timelapse:\"Timelapse\",useAms:\"Use AMS\",applyChanges:\"Apply Changes\",selectAll:\"Select All\",deselectAll:\"Deselect All\",selected:\"{{count}} selected\",editSelected:\"Edit Selected\",cancelSelected:\"Cancel Selected\"},confirm:{cancelTitle:\"Cancel Scheduled Print\",cancelMessage:'Are you sure you want to cancel \"{{name}}\"?',stopTitle:\"Stop Print\",stopMessage:'Are you sure you want to stop the current print \"{{name}}\"? This will cancel the print job on the printer.',removeTitle:\"Remove from History\",removeMessage:'Are you sure you want to remove \"{{name}}\" from the queue history?',clearHistoryTitle:\"Clear History\",clearHistoryMessage:\"Are you sure you want to remove all {{count}} item(s) from the history?\",cancelButton:\"Cancel Print\",stopButton:\"Stop Print\",thisPrint:\"this print\",thisItem:\"this item\"},toast:{cancelled:\"Queue item cancelled\",cancelFailed:\"Failed to cancel item\",removed:\"Queue item removed\",removeFailed:\"Failed to remove item\",stopped:\"Print stopped\",stopFailed:\"Failed to stop print\",released:\"Print released to queue\",startFailed:\"Failed to start print\",reorderFailed:\"Failed to reorder queue\",historyCleared:\"Cleared {{count}} history item(s)\",clearHistoryFailed:\"Failed to clear history\",updateFailed:\"Failed to update items\",bulkCancelled:\"Cancelled {{count}} item(s)\",bulkCancelFailed:\"Failed to cancel items\"},timeline:{listView:\"List\",timelineView:\"Timeline\",unassigned:\"Unassigned\",noData:\"No scheduled prints for this day\",allDoneBy:\"All prints estimated done by {{time}}\",staged:\"Staged\",filterAll:\"Show All\",filterPrinting:\"Printing\",filterQueued:\"Queued\",time:{anyMoment:\"any moment\",minutesLeft:\"{{minutes}}m left\",hoursLeft:\"{{hours}}h left\",hoursMinutesLeft:\"{{hours}}h {{minutes}}m left\"},day:{previous:\"Previous day\",next:\"Next day\",today:\"Today\"}},permissions:{noStopPrint:\"You do not have permission to stop prints\",noStartPrint:\"You do not have permission to start prints\",noEdit:\"You do not have permission to edit this queue item\",noCancel:\"You do not have permission to cancel this queue item\",noRequeue:\"You do not have permission to re-queue items\",noRemove:\"You do not have permission to remove this queue item\",noClearHistory:\"You do not have permission to clear all history\",noEditItems:\"You do not have permission to edit queue items\",noCancelItems:\"You do not have permission to cancel queue items\"}},backgroundDispatch:{unknownFile:\"Unknown file\",unknownPrinter:\"Unknown printer\",startingPrints:\"Starting prints\",progressSummary:\"{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}\",expandDetails:\"Expand dispatch details\",collapseDetails:\"Collapse dispatch details\",dismissToast:\"Dismiss dispatch toast\",cancelDispatchJob:\"Cancel dispatch job\",cancel:\"Cancel\",cancelling:\"Cancelling…\",status:{dispatched:\"Dispatched\",processing:\"Processing\",completed:\"Completed\",failed:\"Failed\",cancelled:\"Cancelled\"},toast:{cancellingUpload:\"Cancelling upload...\",cancelled:\"Dispatch cancelled\",cancelFailed:\"Failed to cancel dispatch\",completeWithFailures:\"Background dispatch complete: {{completed}} succeeded, {{failed}} failed\",completeSuccess:\"Background dispatch complete: {{completed}} succeeded\",printStartedRemaining:\"{{completed}} print(s) started, {{remaining}} more sending...\"}},stats:{title:\"Dashboard\",subtitle:\"Drag widgets to rearrange. Click the eye icon to hide.\",overview:\"Overview\",totalPrints:\"Total Prints\",successRate:\"Success Rate\",totalPrintTime:\"Total Print Time\",printTime:\"Print Time\",totalFilament:\"Total Filament Used\",filamentUsed:\"Filament Used\",filamentCost:\"Filament Cost\",totalCost:\"Total Cost\",energyUsed:\"Energy Used\",energyCost:\"Energy Cost\",energyWarmingUpTooltip:\"Energy tracking is still collecting hourly snapshots. Date-range totals will become accurate once at least one snapshot exists before the selected range. Early values may undercount.\",averagePrintTime:\"Average Print Time\",printsPerDay:\"Prints per Day\",byPrinter:\"By Printer\",printsByPrinter:\"Prints by Printer\",byMaterial:\"By Material\",byMonth:\"By Month\",last7Days:\"Last 7 Days\",last30Days:\"Last 30 Days\",last90Days:\"Last 90 Days\",allTime:\"All Time\",quickStats:\"Quick Stats\",printActivity:\"Print Activity\",filamentTypes:\"Filament Types\",filamentTrends:\"Filament Trends\",failureAnalysis:\"Failure Analysis\",timeAccuracy:\"Time Accuracy\",successful:\"Successful:\",failed:\"Failed:\",perfectEstimate:\"100% = perfect estimate\",noTimeAccuracyData:\"No time accuracy data yet\",noFilamentData:\"No filament data available\",noPrinterData:\"No printer data available\",noPrintData:\"No print data available\",noPrintDataLast30Days:\"No print data in the last 30 days\",failureReasons:\"Failure Reasons\",topFailureReasons:\"Top Failure Reasons\",failedPrintsCount:\"{{failed}} / {{total}} prints failed\",lastWeekRate:\"Last week: {{rate}}%\",resetLayout:\"Reset Layout\",recalculateCosts:\"Recalculate Costs\",recalculateCostsHint:\"Recalculate all archive costs using current filament prices\",exportStats:\"Export Stats\",exportAsCsv:\"Export as CSV\",exportAsExcel:\"Export as Excel\",hiddenCount:\"{{count}} Hidden\",exportDownloaded:\"Export downloaded\",exportFailed:\"Export failed\",layoutReset:\"Layout reset\",recalculatedCosts:\"Recalculated costs for {{count}} archives\",recalculateFailed:\"Failed to recalculate costs\",loadingStats:\"Loading statistics...\",noPermissionResetLayout:\"You do not have permission to reset layout\",noPermissionRecalculate:\"You do not have permission to recalculate costs\",noPrintDataInRange:\"No print data in selected range\",periodFilament:\"Period Filament\",periodCost:\"Period Cost\",avgPerPrint:\"Avg per Print\",usageOverTime:\"Usage Over Time\",filamentByWeight:\"Weight\",printDuration:\"Print Duration\",printerUtilization:\"Printer Utilization\",filamentSuccess:\"Success by Material\",printHabits:\"Print Habits\",printTimeOfDay:\"Print Time of Day\",colorDistribution:\"Color Distribution\",noColorData:\"No color data available\",records:\"Records\",longestPrint:\"Longest Print\",heaviestPrint:\"Heaviest Print\",mostExpensivePrint:\"Most Expensive\",busiestDay:\"Busiest Day\",successStreak:\"Success Streak\",streakPrint:\"consecutive print\",streakPrints:\"{{count}} consecutive prints\",printerStats:\"Printer Stats\",hours:\"hours\",avgPrints:\"Avg. prints\",noArchiveData:\"No print data available\",filamentByTime:\"Time\",avgWeight:\"Avg. weight\",avgTime:\"Avg. time\",filamentByPrints:\"Prints\",timeframe:{today:\"Today\",\"this-week\":\"This Week\",\"this-month\":\"This Month\",\"last-7\":\"Last 7 Days\",\"last-30\":\"Last 30 Days\",\"last-90\":\"Last 90 Days\",\"this-year\":\"This Year\",\"all-time\":\"All Time\",custom:\"Custom Range\",from:\"From\",to:\"To\"},allUsers:\"All Users\",noUser:\"No User (System)\",filterByUser:\"Filter by User\"},maintenance:{title:\"Maintenance\",overview:\"Overview\",allOk:\"All maintenance up to date\",dueCount:\"{{count}} item due\",dueCount_plural:\"{{count}} items due\",warningCount:\"{{count}} warning\",warningCount_plural:\"{{count}} warnings\",totalPrintTime:\"Total Print Time\",nextMaintenance:\"Next Maintenance\",nothingDue:\"Nothing due\",tasks:\"Tasks\",lastPerformed:\"Last performed\",interval:\"Interval\",hoursRemaining:\"{{hours}}h remaining\",hoursOverdue:\"{{hours}}h overdue\",markDone:\"Mark as Done\",performMaintenance:\"Perform Maintenance\",history:\"History\",noHistory:\"No maintenance history\",editPrintHours:\"Edit Print Hours\",currentHours:\"Current Hours\",statusTab:\"Status\",settingsTab:\"Settings\",overdueCount:\"{{count}} overdue\",dueSoonCount:\"{{count}} due soon\",dueSoon:\"Due soon\",allGood:\"All good\",overdueBy:\"Overdue by {{duration}}\",dueIn:\"Due in {{duration}}\",timeLeft:\"{{duration}} left\",day:\"1 day\",days:\"{{count}} days\",week:\"1 week\",weeks:\"{{count}} weeks\",month:\"1 month\",months:\"{{count}} months\",year:\"1 year\",maintenanceTypes:\"Maintenance Types\",maintenanceTypesDescription:\"System types and your custom maintenance tasks\",addCustomType:\"Add Custom Type\",restoreDefaults:\"Restore Default Tasks\",intervalType:\"Interval Type\",intervalValue:\"Interval ({{type}})\",icon:\"Icon\",documentationLink:\"Documentation Link (optional)\",assignToPrinters:\"Assign to Printers\",selectAtLeastOnePrinter:\"Select at least one printer\",addType:\"Add Type\",custom:\"Custom\",printHours:\"Print Hours\",calendarDays:\"Calendar Days\",exampleName:\"e.g., Replace HEPA Filter\",viewDocumentation:\"View documentation\",timeBasedInterval:\"Time-based interval\",intervalOverrides:\"Interval Overrides\",intervalOverridesDescription:\"Customize intervals for specific printers\",assignedToPrinters:\"Assigned to printers:\",noPrintersAssigned:\"No printers assigned\",addPrinterShort:\"Add:\",printersAssignedClick:\"{{count}} printer(s) assigned - click to manage\",removeFromPrinter:\"Remove from this printer\",types:{lubricateCarbonRods:\"Lubricate Carbon Rods\",lubricateRails:\"Lubricate Linear Rails\",cleanNozzle:\"Clean Nozzle/Hotend\",checkBelts:\"Check Belt Tension\",cleanBuildPlate:\"Clean Build Plate\",checkExtruder:\"Check Extruder Gears\",checkCooling:\"Check Cooling Fans\",generalInspection:\"General Inspection\",cleanCarbonRods:\"Clean Carbon Rods\",lubricateSteelRods:\"Lubricate Steel Rods\",cleanSteelRods:\"Clean Steel Rods\",cleanLinearRails:\"Clean Linear Rails\",checkPtfeTube:\"Check PTFE Tube\",replaceHepaFilter:\"Replace HEPA Filter\",replaceCarbonFilter:\"Replace Carbon Filter\",lubricateLeftNozzleRail:\"Lubricate Left Nozzle Rail\"},maintenanceComplete:\"Maintenance marked as complete\",typeUpdated:\"Maintenance type updated\",typeDeleted:\"Maintenance type deleted\",defaultsRestored:\"Restored {{count}} default task(s)\",printHoursUpdated:\"Print hours updated\",printerAssigned:\"Printer assigned\",printerRemoved:\"Printer removed\",deleteTypeConfirm:'Delete \"{{name}}\"?',deleteSystemTypeTitle:\"Delete default maintenance task?\",deleteSystemTypeMessage:'Are you sure you want to delete the default maintenance task \"{{name}}\"?',noPermissionUpdate:\"You do not have permission to update maintenance items\",noPermissionPerform:\"You do not have permission to perform maintenance\",noPermissionEditTypes:\"You do not have permission to edit maintenance types\",noPermissionDeleteTypes:\"You do not have permission to delete maintenance types\",noPermissionEditHours:\"You do not have permission to edit print hours\",noPermissionRemovePrinter:\"You do not have permission to remove printer assignments\",noPermissionAssignPrinter:\"You do not have permission to assign printers\",noPermissionEditIntervals:\"You do not have permission to edit intervals\",configureSettings:\"Configure maintenance types and intervals\"},settings:{title:\"Settings\",general:\"General\",tabs:{general:\"General\",smartPlugs:\"Smart Plugs\",notifications:\"Notifications\",queue:\"Workflow\",filament:\"Filament\",network:\"Network\",apiKeys:\"API Keys\",virtualPrinter:\"Virtual Printer\",spoolbuddy:\"SpoolBuddy\",failureDetection:\"Failure Detection\",users:\"Authentication\",backup:\"Backup\",emailAuth:\"Email Authentication\",ldap:\"LDAP\",twoFa:\"Two-Factor Auth\",oidc:\"SSO / OIDC\"},spoolbuddy:{infoTitle:\"SpoolBuddy devices\",infoBody:\"SpoolBuddy kiosks register themselves automatically via heartbeat. Unregister a device here if it is no longer in use or if a stale duplicate was left behind by a daemon crash.\",duplicatesTitle:\"{{count}} devices registered\",duplicatesBody:\"Only the first registered device is used by the kiosk UI. If one of these is a stale duplicate from a crash, unregister it — an online device will re-register itself on its next heartbeat.\",empty:\"No SpoolBuddy devices registered yet.\",online:\"Online\",offline:\"Offline\",unregister:\"Unregister\",unregisterSuccess:\"Device unregistered\",unregisterError:\"Failed to unregister device\",confirmTitle:\"Unregister SpoolBuddy device?\",confirmBody:'This will remove \"{{hostname}}\" ({{deviceId}}) from the database. If the device is online, it will re-register itself on its next heartbeat.',ipAddress:\"IP address\",firmware:\"Firmware\",lastSeen:\"Last seen\",daemonUptime:\"Daemon uptime\",systemUptime:\"System uptime\",never:\"never\",nfc:\"NFC\",scale:\"Scale\",cpuTemp:\"CPU temp\",memory:\"Memory\",disk:\"Disk\",update:\"Update\",updateConfirmTitle:\"Update Spoolbuddy daemon?\",updateConfirmBody:'Trigger a software update on \"{{hostname}}\"? The daemon will restart once the update is applied.',restartBrowser:\"Restart Browser\",restartBrowserConfirmTitle:\"Restart kiosk browser?\",restartBrowserConfirmBody:'Restart the kiosk browser on \"{{hostname}}\"? The display will blank briefly.',restartDaemon:\"Restart Daemon\",restartDaemonConfirmTitle:\"Restart Spoolbuddy daemon?\",restartDaemonConfirmBody:'Restart the Spoolbuddy daemon on \"{{hostname}}\"? The device will go offline for a few seconds.',reboot:\"Reboot\",rebootConfirmTitle:\"Reboot device?\",rebootConfirmBody:'Reboot \"{{hostname}}\"? The device will be offline for around a minute.',shutdown:\"Shutdown\",shutdownConfirmTitle:\"Shutdown device?\",shutdownConfirmBody:'Shutdown \"{{hostname}}\"? You will need physical access to power it back on.',commandConfirm:\"Confirm\",commandQueued:\"Command queued\",commandError:\"Failed to send command\"},ldap:{title:\"LDAP Authentication\",enabledDesc:\"LDAP authentication is enabled\",disabledDesc:\"LDAP authentication is disabled\",disabledHint:\"Configure and save LDAP settings below, then enable.\",enabled:\"LDAP authentication enabled\",disabled:\"LDAP authentication disabled\",feature1:\"Users can login with LDAP credentials\",feature2:\"Local admin account remains as fallback\",feature3:\"LDAP groups are mapped to BamBuddy groups on login\",serverConfig:\"LDAP Server Configuration\",serverUrl:\"Server URL\",serverUrlHint:\"Use ldaps:// for SSL or ldap:// with StartTLS\",security:\"Security\",securityHint:\"StartTLS upgrades a plain connection to TLS. LDAPS uses TLS from the start.\",bindDn:\"Bind DN (Service Account)\",bindPassword:\"Bind Password\",searchBase:\"Search Base DN\",userFilter:\"User Search Filter\",userFilterHint:\"{username} is replaced with the login username. Use (uid={username}) for OpenLDAP.\",autoProvision:\"Auto-provision users\",autoProvisionHint:\"Automatically create a BamBuddy account on first LDAP login\",defaultGroup:\"Default group\",defaultGroupNone:\"— None (no fallback) —\",defaultGroupHint:\"Fallback group assigned when an LDAP user authenticates but is not listed in any mapped LDAP group. Leave empty to leave unmapped users without permissions.\",groupMapping:\"Group Mapping (JSON)\",groupMappingHint:\"Map LDAP group DNs to BamBuddy groups. Available groups: \",testConnection:\"Test Connection\",settingsSaved:\"LDAP settings saved\",errors:{serverRequired:\"LDAP server URL is required\",searchBaseRequired:\"Search base DN is required\",enableAuthFirst:\"Enable authentication first\",configureLdapFirst:\"Save LDAP settings first\"}},email:{smtpSettings:\"SMTP Configuration\",smtpHost:\"SMTP Server\",smtpPort:\"SMTP Port\",security:\"Security\",authentication:\"Authentication\",username:\"Username\",password:\"Password\",fromEmail:\"From Email\",fromName:\"From Name\",testConnection:\"Test SMTP Connection\",testRecipient:\"Test Recipient Email\",sendTest:\"Send Test Email\",sending:\"Sending...\",save:\"Save Settings\",saving:\"Saving...\",advancedAuth:\"Advanced Authentication\",advancedAuthEnabled:\"Advanced Authentication is enabled\",advancedAuthEnabledDesc:\"Email-based user management features are active. New users will receive auto-generated passwords via email, and users can reset their passwords through the forgot password feature.\",advancedAuthDisabled:\"Advanced Authentication is disabled\",advancedAuthDisabledDesc:\"Enable advanced authentication to activate email-based features for user management.\",enable:\"Enable\",disable:\"Disable\",feature1:\"Passwords are auto-generated and emailed to new users\",feature2:\"Users can login with username or email\",feature3:\"Forgot password feature is available\",feature4:\"Admins can reset user passwords via email\",errors:{requiredFields:\"Please fill in all required fields\",usernameRequired:\"Username is required when authentication is enabled\",enterTestEmail:\"Please enter a test email address\",smtpServerAndEmail:\"Please fill in SMTP Server and From Email before testing\",usernamePasswordRequired:\"Username and Password are required when authentication is enabled\",configureSmtpFirst:\"Please configure and test SMTP settings first\",enableAuthFirst:\"Please enable authentication first to use email-based features.\"},success:{settingsSaved:\"SMTP settings saved successfully\"},securityOptions:{starttls:\"STARTTLS (Port 587)\",ssl:\"SSL/TLS (Port 465)\",none:\"None (Port 25)\"},authOptions:{enabled:\"Enabled\",disabled:\"Disabled\"}},appearance:\"Appearance\",notifications:\"Notifications\",smartPlugs:\"Smart Plugs\",spoolman:\"Spoolman\",updates:\"Updates\",language:\"Language\",languageDescription:\"Select your preferred language\",theme:\"Theme\",themeLight:\"Light\",themeDark:\"Dark\",themeSystem:\"System\",defaultView:\"Default View\",defaultViewDescription:\"Page to show when opening the app\",checkForUpdates:\"Check for Updates\",autoUpdate:\"Auto Update\",currentVersion:\"Current Version\",latestVersion:\"Latest Version\",upToDate:\"You are up to date\",updateAvailable:\"Update available\",notificationLanguage:\"Notification Language\",notificationLanguageDescription:\"Language for push notifications\",bedCooledThreshold:\"Bed Cooled Threshold\",bedCooledThresholdDescription:\"Temperature below which the bed is considered cooled after a print\",userNotificationsEnabled:\"User Notifications\",userNotificationsEnabledDescription:\"Enable the user notifications menu and email notifications for print job events. Requires Advanced Authentication.\",userNotificationsDisabledHint:\"Enable Advanced Authentication to use user notifications.\",notificationProviders:\"Notification Providers\",addProvider:\"Add Provider\",editProvider:\"Edit Provider\",providerType:\"Provider Type\",testNotification:\"Test Notification\",testSuccess:\"Test notification sent successfully\",testFailed:\"Failed to send test notification\",quietHours:\"Quiet Hours\",quietHoursDescription:\"Do not disturb during these hours\",quietHoursStart:\"Start\",quietHoursEnd:\"End\",events:{title:\"Notification Events\",printStart:\"Print Started\",printComplete:\"Print Completed\",printFailed:\"Print Failed\",printStopped:\"Print Stopped\",printProgress:\"Progress Milestones\",printProgressDescription:\"Notify at 25%, 50%, 75%\",printerOffline:\"Printer Offline\",printerError:\"Printer Error\",filamentLow:\"Low Filament\",maintenanceDue:\"Maintenance Due\",maintenanceDueDescription:\"Notify when maintenance is needed\"},smartPlug:{title:\"Smart Plugs\",add:\"Add Smart Plug\",edit:\"Edit Smart Plug\",name:\"Name\",ipAddress:\"IP Address\",linkedPrinter:\"Linked Printer\",autoOn:\"Auto Power On\",autoOnDescription:\"Turn on when print starts\",autoOff:\"Auto Power Off\",autoOffDescription:\"Turn off after print completes\",offDelay:\"Off Delay\",offDelayMinutes:\"Minutes after print\",offDelayTemp:\"When nozzle below temperature\",currentState:\"Current State\",turnOn:\"Turn On\",turnOff:\"Turn Off\"},filamentTracking:\"Filament Tracking\",filamentTrackingDesc:\"Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.\",filamentChecks:\"Filament checks\",disableFilamentWarnings:\"Disable filament warnings\",disableFilamentWarningsDesc:\"Don't show warnings about insufficient filament when printing or queueing\",preferLowestFilament:\"Prefer lowest remaining filament\",preferLowestFilamentDesc:\"When multiple spools match, use the one with the least filament remaining\",trackingModeBuiltIn:\"Built-in Inventory\",trackingModeBuiltInDesc:\"RFID auto-matching and usage tracking included\",trackingModeSpoolmanDesc:\"External filament management server\",builtInFeatureRfid:\"Automatically detects Bambu Lab RFID spools in AMS\",builtInFeatureUsage:\"Tracks filament consumption per print\",builtInFeatureCatalog:\"Manage spools, colors, and K-factor profiles\",builtInFeatureThirdParty:\"Third-party spools can be assigned to inventory spools\",amsSyncButton:\"Sync Weights from AMS\",amsSyncTitle:\"Sync Spool Weights from AMS\",amsSyncMessage:\"This will overwrite all inventory spool weights with the current AMS remain% values from connected printers. Use this to recover from corrupted weight data. Printers must be online.\",amsSyncing:\"Syncing...\",amsSyncSuccess:\"{{synced}} spool(s) synced, {{skipped}} skipped\",amsSyncError:\"Failed to sync weights from AMS\",spoolmanUrl:\"Spoolman URL\",spoolmanUrlHint:\"URL of your Spoolman server (e.g., http://localhost:7912)\",spoolmanConnected:\"Connected\",spoolmanDisconnected:\"Disconnected\",status:\"Status\",connect:\"Connect\",disconnect:\"Disconnect\",howSyncWorks:\"How Sync Works\",syncInfoRfidOnly:\"Only official Bambu Lab spools with RFID are synced\",syncInfoAutoCreate:\"New spools are auto-created in Spoolman on first sync\",syncInfoThirdPartySkipped:\"Non-Bambu Lab spools (third-party, refilled) are skipped\",linkingExistingSpools:\"Linking Existing Spools\",linkingExistingSpoolsDesc:'To link existing Spoolman spools to your AMS, hover over an AMS slot and click \"Link to Spoolman\".',syncMode:\"Sync Mode\",syncModeAuto:\"Automatic\",syncModeManual:\"Manual Only\",syncModeAutoDesc:\"AMS data syncs automatically when changes are detected\",syncModeManualDesc:\"Only sync when manually triggered\",syncAmsData:\"Sync AMS Data\",syncAmsDataDesc:\"Manually sync printer AMS data to Spoolman\",allPrinters:\"All Printers\",noDefaultPrinter:\"No default (ask each time)\",sidebarOrder:\"Sidebar order\",saveThumbnails:\"Save thumbnails\",captureFinishPhoto:\"Capture finish photo\",noPrintersConfigured:\"No printers configured\",archiveMode:{always:\"Always create archive entry\",never:\"Never create archive entry\",ask:\"Ask each time\"},checkForUpdatesLabel:\"Check for updates\",checkPrinterFirmware:\"Check printer firmware\",includeBetaUpdates:\"Include beta versions\",includeBetaUpdatesDesc:\"Notify about beta and prerelease versions when checking for updates\",enableRetry:\"Enable retry\",homeAssistantDescription:\"Control smart plugs via Home Assistant\",environmentManagedLabel:\"(Environment Managed)\",autoEnabledViaEnv:\"Automatically enabled via environment variables\",urlFromEnvReadOnly:\"Value set by HA_URL environment variable (read-only)\",tokenFromEnvReadOnly:\"Value set by HA_TOKEN environment variable (read-only)\",mqttConnectedTo:\"Connected to\",prometheusDescription:\"Expose printer data in Prometheus format\",noSmartPlugsTitle:\"No smart plugs configured\",noSmartPlugsDescription:\"Add a Tasmota-based smart plug to track energy usage and automate power control.\",noProvidersTitle:\"No providers configured\",noProvidersDescription:\"Add a provider to receive alerts.\",noTemplatesAvailable:\"No templates available. Restart the backend to seed default templates.\",apiPermissionView:\"View printer status and queue\",apiPermissionEdit:\"Add and remove items from print queue\",apiKeysEmptyTitle:\"No API keys\",apiKeysEmptyDescription:\"Create an API key to integrate with external services.\",noUsersFound:\"No users found\",noGroupsFound:\"No groups found\",noGroupsAvailable:\"No groups available\",passwordsDoNotMatch:\"Passwords do not match\",systemGroupWarning:\"System group names cannot be changed\",authDisabledTitle:\"Authentication is Disabled\",authDisabledFeature1:\"Require login to access the system\",authDisabledFeature2:\"Create multiple users with group-based permissions\",authDisabledFeature3:\"Control access with 50+ granular permissions\",userHasCreated:\"This user has created:\",userItemsQuestion:\"What would you like to do with these items?\",deleteUserConfirm:\"Are you sure you want to delete this user?\",actionCannotBeUndone:\"This action cannot be undone.\",addFirstSmartPlug:\"Add Your First Smart Plug\",providers:\"Providers\",log:\"Log\",testAll:\"Test All\",testResults:\"Test Results\",testPassedCount:\"{{count}} passed\",testFailedCount:\"{{count}} failed\",messageTemplates:\"Message Templates\",messageTemplatesDescription:\"Customize notification messages for each event.\",apiKeys:\"API Keys\",apiKeysDescription:\"Create API keys for external integrations and webhooks.\",createKey:\"Create Key\",apiKeyCreated:\"API Key Created Successfully\",apiKeyCopyWarning:\"Copy this key now - it won't be shown again!\",useInApiBrowser:\"Use in API Browser\",createNewApiKey:\"Create New API Key\",keyName:\"Key Name\",keyNamePlaceholder:\"e.g., Home Assistant, OctoPrint\",readStatus:\"Read Status\",readStatusDescription:\"View printer status and queue\",manageQueue:\"Manage Queue\",manageQueueDescription:\"Add and remove items from print queue\",controlPrinter:\"Control Printer\",controlPrinterDescription:\"Pause, resume, and stop prints\",unnamedKey:\"Unnamed Key\",lastUsed:\"Last used\",read:\"Read\",control:\"Control\",createFirstKey:\"Create Your First Key\",webhookEndpoints:\"Webhook Endpoints\",webhookApiKeyHint:\"Use your API key in the X-API-Key header.\",webhook:{getAllStatus:\"Get all printer status\",getSpecificStatus:\"Get specific printer status\",addToQueue:\"Add to print queue\",pausePrint:\"Pause print\",resumePrint:\"Resume print\",stopPrint:\"Stop print\"},apiBrowser:\"API Browser\",apiBrowserDescription:\"Explore and test all available API endpoints.\",apiKeyForTesting:\"API Key for Testing\",apiKeyPlaceholder:\"Paste your API key here to test authenticated endpoints...\",apiKeyHint:\"This key will be sent as X-API-Key header with requests.\",deleteApiKeyTitle:\"Delete API Key\",deleteApiKeyMessage:\"Are you sure you want to delete this API key? Any integrations using this key will stop working.\",deleteKey:\"Delete Key\",amsDisplayThresholds:\"AMS Display Thresholds\",amsThresholdsDescription:\"Configure color thresholds for AMS humidity and temperature indicators.\",humidity:\"Humidity\",goodGreen:\"Good (green)\",fairOrange:\"Fair (orange)\",aboveFairBad:\"Above fair threshold shows as red (bad)\",fairAlsoDryingThreshold:\"This threshold is also used to trigger auto-drying when enabled\",temperature:\"Temperature\",goodBlue:\"Good (blue)\",aboveFairHot:\"Above fair threshold shows as red (hot)\",historyRetention:\"History Retention\",keepSensorHistory:\"Keep sensor history for\",historyRetentionDescription:\"Older humidity and temperature data will be automatically deleted\",defaultPrintOptions:\"Default Print Options\",defaultPrintOptionsDescription:\"Set default values for print options when starting new prints. These can be overridden per print in the print dialog.\",defaultBedLevelling:\"Bed Levelling\",defaultBedLevellingDesc:\"Auto-level bed before print\",defaultFlowCali:\"Flow Calibration\",defaultFlowCaliDesc:\"Calibrate extrusion flow\",defaultVibrationCali:\"Vibration Calibration\",defaultVibrationCaliDesc:\"Reduce ringing artifacts\",defaultLayerInspect:\"First Layer Inspection\",defaultLayerInspectDesc:\"AI inspection of first layer\",defaultTimelapse:\"Timelapse\",defaultTimelapseDesc:\"Record timelapse video\",staggeredStart:\"Staggered Start\",staggeredStartDescription:\"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.\",plateClear:\"Plate-Clear Confirmation\",requirePlateClear:\"Require plate-clear confirmation\",requirePlateClearDescription:'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the \"Mark plate as cleared\" button on printer cards.',gcodeInjection:\"G-code Injection\",gcodeInjectionDescription:'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when \"Inject G-code\" is enabled on a queue item.',gcodeInjectionNoPrinters:\"No printers found. Add printers to configure G-code snippets.\",gcodeStartLabel:\"Start G-code\",gcodeEndLabel:\"End G-code\",gcodeStartPlaceholder:\"G-code prepended before the print starts...\",gcodeEndPlaceholder:\"G-code appended after the print ends...\",staggerGroupSize:\"Group size\",staggerGroupSizeHelp:\"Printers to start simultaneously per group\",staggerInterval:\"Interval (minutes)\",staggerIntervalHelp:\"Delay between each group starting\",queueDrying:\"Queue Auto-Drying\",queueDryingDescription:\"Automatically dry AMS filament when printer is idle between queued prints. Uses humidity threshold above to trigger drying.\",queueDryingEnabled:\"Enable auto-drying\",queueDryingEnabledDescription:\"Start AMS drying automatically when printer is idle and humidity is above threshold\",queueDryingBlock:\"Wait for drying to complete\",queueDryingBlockDescription:\"Block the print queue until drying finishes. When off, prints take priority over drying.\",ambientDryingEnabled:\"Ambient drying\",ambientDryingEnabledDescription:\"Automatically dry filament on idle printers when humidity exceeds threshold, even without queued prints.\",dryingPresets:\"Drying Presets\",dryingPresetsDescription:\"Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.\",dryingFilament:\"Filament\",printModal:\"Print Modal\",expandCustomMapping:\"Expand custom mapping by default\",expandCustomMappingDescription:\"When printing to multiple printers, show per-printer AMS mapping expanded\",authentication:\"Authentication\",authEnabledDescription:\"Your instance is secured with user authentication\",authDisabledDescription:\"Enable to require login and manage user access\",authDisabledMessage:\"Enable authentication to create user accounts, manage permissions, and secure your Bambuddy instance.\",enableAuthentication:\"Enable Authentication\",currentUser:\"Current User\",changePassword:\"Change Password\",admin:\"Admin\",users:\"Users\",addUser:\"Add User\",groups:\"Groups\",addGroup:\"Add Group\",system:\"System\",noDescription:\"No description\",userCount:\"{{count}} users\",permissionCount:\"{{count}} permissions\",createUser:\"Create User\",username:\"Username\",enterUsername:\"Enter username\",password:\"Password\",enterPassword:\"Enter password (min 6 characters)\",confirmPassword:\"Confirm Password\",confirmPasswordPlaceholder:\"Confirm password\",viewReleaseOnGitHub:\"View release on GitHub\",turnAllPlugsOn:\"Turn all plugs on\",turnAllPlugsOff:\"Turn all plugs off\",clearNotificationLogs:\"Clear Notification Logs\",clearLogsMessage:\"This will permanently delete all notification logs older than 30 days. This action cannot be undone.\",clearLogs:\"Clear Logs\",resetUiPreferences:\"Reset UI Preferences\",resetUiPreferencesMessage:\"This will reset all UI preferences to defaults: sidebar order, theme, dashboard layout, view modes, and sorting preferences. Your printers, archives, and server settings will NOT be affected. The page will reload after clearing.\",resetPreferences:\"Reset Preferences\",deleteGroupTitle:\"Delete Group\",deleteGroupMessage:\"Are you sure you want to delete this group? Users in this group will lose these permissions.\",deleteGroup:\"Delete Group\",disableAuthenticationTitle:\"Disable Authentication\",disableAuthenticationMessage:\"Are you sure you want to disable authentication? This will make your Bambuddy instance accessible without login. All users will remain in the database but authentication will be disabled.\",disableAuthentication:\"Disable Authentication\",configureBambuddy:\"Configure Bambuddy\",systemDefault:\"System Default\",archiveSettings:\"Archive Settings\",newWindow:\"New Window\",embeddedOverlay:\"Embedded Overlay\",preferredSlicer:\"Preferred Slicer\",preferredSlicerDescription:\"Choose which slicer application to open files with\",externalCameras:\"External Cameras\",costTracking:\"Cost Tracking\",printsOnly:\"Prints Only\",totalConsumption:\"Total Consumption\",dataManagement:\"Data Management\",storageUsage:\"Storage Usage\",storageUsageDescription:\"Breakdown of data usage by category\",storageUsageTotal:\"Total\",storageUsageErrors:\"Errors\",storageUsageOtherBreakdown:\"Other (includes static assets, scripts, and configuration files)\",storageUsageSystem:\"System\",storageUsageData:\"Data\",storageUsageUnavailable:\"Storage usage information unavailable\",clearNotificationLogsDescription:\"Delete notification logs older than 30 days\",resetUiPreferencesDescription:\"Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.\",enableHomeAssistant:\"Enable Home Assistant\",enableMqtt:\"Enable MQTT\",useTls:\"Use TLS\",enableMetricsEndpoint:\"Enable Metrics Endpoint\",availableMetrics:\"Available Metrics\",editUser:\"Edit User\",deleteUserTitle:\"Delete User\",groupName:\"Group Name\",leaveEmptyForAnonymous:\"Leave empty for anonymous\",leaveEmptyForNoAuth:\"Leave empty for no authentication\",enterNewPassword:\"Enter new password\",confirmNewPassword:\"Confirm new password\",enterGroupName:\"Enter group name\",enterDescriptionOptional:\"Enter description (optional)\",enterCurrentPassword:\"Enter current password\",enterNewPasswordMin6:\"Enter new password (min 6 characters)\",toast:{keyCopied:\"Key copied to clipboard\",copyFailed:\"Failed to copy key\",keyAddedToBrowser:\"Key added to API Browser\",clearLogsFailed:\"Failed to clear logs\",uiPreferencesReset:\"UI preferences reset. Refreshing...\",authDisabled:\"Authentication disabled successfully\",authDisableFailed:\"Failed to disable authentication\",apiKeyCreated:\"API key created\",apiKeyDeleted:\"API key deleted\",userCreated:\"User created successfully\",userUpdated:\"User updated successfully\",userDeleted:\"User deleted successfully\",groupCreated:\"Group created successfully\",groupUpdated:\"Group updated successfully\",groupDeleted:\"Group deleted successfully\",fillRequiredFields:\"Please fill in all required fields\",passwordsDoNotMatch:\"Passwords do not match\",passwordTooShort:\"Password must be at least 6 characters\",enterGroupName:\"Please enter a group name\",settingsSaved:\"Settings saved\",cameraSettingsSaved:\"Camera settings saved\",enterCameraUrl:\"Please enter a camera URL\",passwordChanged:\"Password changed successfully\",connectionFailed:\"Connection failed\",testFailed:\"Test failed\",cameraConnected:\"Camera connected{{resolution}}\"},testConnection:\"Test Connection\",catalog:{spoolCatalog:\"Spool Catalog\",spoolCatalogDescription:\"Empty spool weights by brand/type. Used for automatic weight lookup when adding spools.\",searchCatalog:\"Search catalog...\",addNewEntry:\"Add New Entry\",namePlaceholder:\"Name (e.g., Bambu Lab - Plastic)\",weight:\"Weight\",type:\"Type\",default:\"Default\",custom:\"Custom\",noMatch:\"No entries match your search\",empty:\"No entries in catalog\",deleteEntry:\"Delete Entry\",deleteConfirm:'Are you sure you want to delete \"{{name}}\"?',resetCatalog:\"Reset Catalog\",resetConfirm:\"Reset catalog to defaults? This will remove all custom entries.\",loadFailed:\"Failed to load spool catalog\",nameWeightRequired:\"Name and weight are required\",entryAdded:\"Entry added\",addFailed:\"Failed to add entry\",entryUpdated:\"Entry updated\",updateFailed:\"Failed to update entry\",entryDeleted:\"Entry deleted\",deleteFailed:\"Failed to delete entry\",resetSuccess:\"Catalog reset to defaults\",resetFailed:\"Failed to reset catalog\",exported:\"Exported {{count}} entries\",imported:\"Imported {{added}} entries ({{skipped}} skipped)\",importFailed:\"Failed to import: invalid JSON format\",exportTooltip:\"Export catalog to JSON\",importTooltip:\"Import catalog from JSON\",resetTooltip:\"Reset to defaults\",selectedCount:\"{{count}} selected\",deleteSelected:\"Delete Selected\",bulkDeleteConfirm:\"Are you sure you want to delete {{count}} entries?\",bulkDeleted:\"Deleted {{count}} entries\",bulkDeleteFailed:\"Failed to delete entries\"},colorCatalog:{title:\"Color Catalog\",description:\"Filament colors by manufacturer/material. Used for automatic color lookup when adding spools.\",searchColors:\"Search colors...\",allManufacturers:\"All manufacturers\",addNewColor:\"Add New Color\",manufacturer:\"Manufacturer\",colorName:\"Color Name\",hex:\"Hex\",materialOptional:\"Material (optional)\",showing:\"Showing {{filtered}} of {{total}} colors\",noMatch:\"No colors match your search\",empty:\"No colors in catalog\",deleteColor:\"Delete Color\",deleteConfirm:'Are you sure you want to delete \"{{name}}\"?',resetCatalog:\"Reset Color Catalog\",resetConfirm:\"Reset catalog to defaults? This will remove all custom colors.\",sync:\"Sync\",starting:\"Starting...\",syncTooltip:\"Sync from FilamentColors.xyz (2000+ colors, may take a minute)\",loadFailed:\"Failed to load color catalog\",fieldsRequired:\"Manufacturer, color name, and hex color are required\",colorAdded:\"Color added\",addFailed:\"Failed to add color\",colorUpdated:\"Color updated\",updateFailed:\"Failed to update color\",colorDeleted:\"Color deleted\",deleteFailed:\"Failed to delete color\",resetSuccess:\"Color catalog reset to defaults\",resetFailed:\"Failed to reset catalog\",syncUpToDate:\"Already up to date ({{count}} colors checked)\",syncComplete:\"Added {{added}} new colors ({{skipped}} already existed)\",syncError:\"Sync error\",syncFailed:\"Failed to sync from FilamentColors.xyz\",exported:\"Exported {{count}} colors\",imported:\"Imported {{added}} colors ({{skipped}} skipped)\",importFailed:\"Failed to import: invalid JSON format\",selectedCount:\"{{count}} selected\",deleteSelected:\"Delete Selected\",bulkDeleteConfirm:\"Are you sure you want to delete {{count}} colors?\",bulkDeleted:\"Deleted {{count}} colors\",bulkDeleteFailed:\"Failed to delete colors\"},dateFormat:\"Date Format\",dateFormatUs:\"US (MM/DD/YYYY)\",dateFormatEu:\"EU (DD/MM/YYYY)\",dateFormatIso:\"ISO (YYYY-MM-DD)\",timeFormat:\"Time Format\",timeFormat12:\"12-hour (3:30 PM)\",timeFormat24:\"24-hour (15:30)\",defaultPrinter:\"Default Printer\",defaultPrinterDescription:\"Pre-select this printer for uploads, reprints, and other operations.\",slicerBambuStudio:\"Bambu Studio\",slicerOrcaSlicer:\"OrcaSlicer\",sidebarOrderDescription:\"Drag items in the sidebar to reorder. Reset to default order here.\",setDefault:\"Set Default\",sidebarOrderSetDefaultHint:\"Set default applies the current menu order to users who haven't customized theirs.\",sidebarDefaultSet:\"Default menu order has been set.\",sidebarDefaultCleared:\"Default menu order cleared.\",sidebarDefaultFailed:\"Failed to set default menu order.\",reset:\"Reset\",darkMode:\"Dark Mode\",lightMode:\"Light Mode\",active:\"(active)\",background:\"Background\",accent:\"Accent\",style:\"Style\",bgNeutral:\"Neutral\",bgWarm:\"Warm\",bgCool:\"Cool\",bgOled:\"OLED Black\",bgSlate:\"Slate Blue\",bgForest:\"Forest Green\",accentGreen:\"Green\",accentTeal:\"Teal\",accentBlue:\"Blue\",accentOrange:\"Orange\",accentPurple:\"Purple\",accentRed:\"Red\",styleClassic:\"Classic\",styleGlow:\"Glow\",styleVibrant:\"Vibrant\",themeToggleHint:\"Toggle between dark and light mode using the sun/moon icon in the sidebar.\",autoArchivePrints:\"Auto-archive prints\",autoArchiveDescription:\"Automatically save 3MF files when prints complete\",saveThumbnailsDescription:\"Extract and save preview images from 3MF files\",captureFinishPhotoDescription:\"Take a photo from printer camera when print completes\",ffmpegNotInstalled:\"ffmpeg not installed\",ffmpegRequired:\"Camera capture requires ffmpeg. Install it via <brew>brew install ffmpeg</brew> (macOS) or <apt>apt install ffmpeg</apt> (Linux).\",camera:\"Camera\",cameraViewMode:\"Camera View Mode\",cameraOverlayDescription:\"Camera opens in a resizable overlay on the main screen\",cameraWindowDescription:\"Camera opens in a separate browser window\",externalCamerasDescription:\"Configure external cameras to replace the built-in printer camera. Supports MJPEG streams, RTSP, HTTP snapshots, and USB cameras (V4L2). When enabled, the external camera is used for live view and finish photos.\",cameraPlaceholderUsb:\"Device path (/dev/video0)\",cameraPlaceholderUrl:\"Camera URL (rtsp://... or http://...)\",cameraTypeMjpeg:\"MJPEG Stream\",cameraTypeRtsp:\"RTSP Stream\",cameraTypeSnapshot:\"HTTP Snapshot\",cameraTypeUsb:\"USB Camera (V4L2)\",cameraRotation:\"Rotation\",test:\"Test\",connected:\"Connected\",disconnected:\"Disconnected\",currency:\"Currency\",defaultFilamentCost:\"Default filament cost (per kg)\",electricityCost:\"Electricity cost per kWh\",energyDisplayMode:\"Energy display mode\",energyModePrintDescription:\"Dashboard shows sum of energy used during prints\",energyModeTotalDescription:\"Dashboard shows lifetime energy from smart plugs\",fileManager:\"File Manager\",createArchiveEntry:\"Create Archive Entry When Printing\",createArchiveEntryDescription:\"When printing from File Manager, optionally create an archive entry\",lowDiskSpaceWarning:\"Low Disk Space Warning\",lowDiskSpaceDescription:\"Show warning when free disk space falls below this threshold\",printerFirmware:\"Printer Firmware\",checkFirmwareDescription:\"Check for printer firmware updates from Bambu Lab\",bambuddySoftware:\"Bambuddy Software\",autoCheckDescription:\"Automatically check for new versions on startup\",checkNow:\"Check now\",updateAvailableVersion:\"Update available: v{{version}}\",releaseNotes:\"Release Notes\",updateViaDocker:\"Update via Docker Compose:\",installUpdate:\"Install Update\",latestVersionRunning:\"You're running the latest version\",failedToCheckUpdates:\"Failed to check for updates: {{error}}\",backupRestore:\"Backup & Restore\",backupRestoreDescription:\"Export/import settings and configure GitHub backup\",goToBackup:\"Go to Backup\",externalUrl:\"External URL\",externalUrlDescription:\"The external URL where Bambuddy is accessible. Used for notification images and external integrations.\",bambuddyUrl:\"Bambuddy URL\",externalUrlHint:\"Include protocol and port (e.g., http://192.168.1.100:8000)\",ftpRetry:\"FTP Retry\",ftpRetryDescription:\"Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.\",autoRetryDescription:\"Automatically retry failed FTP operations\",retryAttempts:\"Retry attempts\",retryDelay:\"Retry delay\",connectionTimeout:\"Connection timeout\",time_one:\"{{count}} time\",time_other:\"{{count}} times\",second_one:\"{{count}} second\",second_other:\"{{count}} seconds\",nSeconds:\"{{count}} seconds\",increaseForWeakWifi:\"Increase for printers with weak WiFi\",homeAssistant:\"Home Assistant\",homeAssistantFullDescription:\"Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.\",homeAssistantUrl:\"Home Assistant URL\",longLivedAccessToken:\"Long-Lived Access Token\",haTokenHint:\"Create a token in HA: Profile → Long-Lived Access Tokens → Create Token\",connectionSuccessful:\"Connection Successful\",connectionFailed:\"Connection Failed\",haConnectionSuccess:\"Successfully connected to Home Assistant.\",haConnectionFailed:\"Failed to connect to Home Assistant.\",mqttPublishing:\"MQTT Publishing\",mqttDescription:\"Publish BamBuddy events to an external MQTT broker for integration with Node-RED, Home Assistant, and other automation systems.\",mqttEnableDescription:\"Publish events to external MQTT broker\",brokerHostname:\"Broker hostname\",port:\"Port\",usernameOptional:\"Username (optional)\",passwordOptional:\"Password (optional)\",topicPrefix:\"Topic prefix\",topicPrefixHint:\"Topics will be: {{prefix}}/printers/<serial>/status, etc.\",prometheusMetrics:\"Prometheus Metrics\",prometheusEndpointDescription:\"Expose printer metrics at <code>/api/v1/metrics</code> for Prometheus/Grafana monitoring.\",bearerTokenOptional:\"Bearer Token (optional)\",bearerTokenHint:\"If set, requests must include <code>Authorization: Bearer <token></code>\",metricsConnectionStatus:\"Connection status\",metricsPrinterState:\"Printer state (idle/printing/etc)\",metricsPrintProgress:\"Print progress 0-100%\",metricsBedTemp:\"Bed temperature\",metricsNozzleTemp:\"Nozzle temperature\",metricsPrintsTotal:\"Total prints by result\",metricsMore:\"...and more (layers, fans, queue, filament usage)\",smartPlugsDescription:\"Connect smart plugs (Tasmota or Home Assistant) to automate power control and track energy usage for your printers.\",allOn:\"All On\",allOff:\"All Off\",addSmartPlug:\"Add Smart Plug\",energySummary:\"Energy Summary\",currentPower:\"Current Power\",plugsOnline:\"{{reachable}}/{{total}} plugs online\",today:\"Today\",yesterday:\"Yesterday\",total:\"Total\",enablePlugsForSummary:\"Enable plugs to see energy summary\",addNotificationProvider:\"Add\",systemBadge:\"(System)\",creating:\"Creating...\",changing:\"Changing...\",deleteUserAndItems:\"Delete user AND their items\",deleteUserKeepItems:\"Delete user, keep items (become ownerless)\",ok:\"OK\",twoFa:{totpTitle:\"Authenticator App (TOTP)\",totpDesc:\"Use an authenticator app like Google Authenticator, Aegis or Authy.\",emailOtpTitle:\"Email OTP\",emailOtpDesc:\"Send a one-time code to {{email}} when you log in.\",emailOtpNoEmail:\"Add an email address to your account to enable this method.\",addEmailFirst:\"Your account has no email address. Ask an admin to add one before enabling Email OTP.\",setupTotp:\"Set up Authenticator App\",setupAuthApp:\"Set up Authenticator App\",setupInstructions:\"Scan the QR code below with your authenticator app, then confirm with a code.\",manualEntry:\"Can't scan? Enter this secret manually:\",scannedContinue:\"I've scanned the code — continue\",enterCodeToConfirm:\"Enter the 6-digit code from your authenticator app to confirm setup.\",activate:\"Activate\",disableTotp:\"Disable Authenticator\",disableConfirmHint:\"Enter a valid TOTP code or a backup code to disable the authenticator.\",totpDisabled:\"Authenticator app disabled.\",emailOtpEnabled:\"Email OTP enabled.\",emailOtpDisabled:\"Email OTP disabled.\",smtpRequired:\"Please configure and test SMTP settings first.\",invalidCode:\"Invalid code. Please try again.\",enableEmailOtp:\"Enable Email OTP\",disableEmailOtp:\"Disable Email OTP\",emailSetupEnterCode:\"A verification code has been sent to your email address. Enter it below to confirm you own this inbox.\",verifyAndEnable:\"Verify & Enable\",emailDisablePasswordHint:\"Enter your account password to confirm disabling email OTP.\",passwordPlaceholder:\"Enter your password\",backupCodesTitle:\"Save your backup codes\",backupCodesWarning:\"Save these codes somewhere safe. Each code can only be used once and they will not be shown again.\",backupCodesRemaining:\"{{count}} backup codes remaining\",savedCodes:\"I've saved my codes\",regenBackup:\"Regenerate Backup Codes\",regenBackupHint:\"Enter your current TOTP code to generate 10 new backup codes. All existing backup codes will be invalidated.\",newBackupCodes:\"New backup codes\",linkedAccounts:\"Linked SSO Accounts\",linkedAccountsDesc:\"These external identity providers are linked to your account.\",oidcUnlinked:\"Account unlinked.\"},oidc:{title:\"SSO / OIDC Providers\",desc:\"Configure OpenID Connect providers to allow single sign-on via external identity providers.\",addProvider:\"Add Provider\",newProvider:\"New Provider\",empty:\"No OIDC providers configured yet.\",created:\"Provider created.\",updated:\"Provider updated.\",deleted:\"Provider deleted.\",deleteTitle:\"Delete Provider\",deleteMessage:'Delete \"{{name}}\"? All linked user accounts will be disconnected.',form:{name:\"Display Name\",issuerUrl:\"Issuer URL\",clientId:\"Client ID\",clientSecret:\"Client Secret\",scopes:\"Scopes\",iconUrl:\"Icon URL (optional)\",enabled:\"Enabled\",autoCreate:\"Auto-create users\",autoCreateDesc:\"Automatically create a local account on first login.\",autoLink:\"Auto-link existing accounts\",autoLinkDesc:\"Link existing local accounts by matching email on first login.\",secretHint:\"leave blank to keep current\",secretPlaceholder:\"new secret\"}}},notification:{printStarted:{title:\"Print Started\",body:\"{{printer}}: {{filename}} has started printing\"},printCompleted:{title:\"Print Completed\",body:\"{{printer}}: {{filename}} completed successfully\"},printFailed:{title:\"Print Failed\",body:\"{{printer}}: {{filename}} has failed\"},printStopped:{title:\"Print Stopped\",body:\"{{printer}}: {{filename}} was stopped\"},printProgress:{title:\"Print Progress\",body:\"{{printer}}: {{filename}} is {{percent}}% complete\"},printerOffline:{title:\"Printer Offline\",body:\"{{printer}} is offline\"},printerError:{title:\"Printer Error\",body:\"{{printer}}: {{error}}\"},filamentLow:{title:\"Low Filament\",body:\"{{printer}}: Filament is running low\"},maintenanceDue:{title:\"Maintenance Due\",body:\"{{printer}}: {{items}} need attention\"}},errors:{generic:\"Something went wrong\",networkError:\"Network error. Please check your connection.\",notFound:\"Not found\",unauthorized:\"Unauthorized\",serverError:\"Server error\",validationError:\"Please check your input\",printerConnectionFailed:\"Failed to connect to printer\",saveFailed:\"Failed to save changes\",deleteFailed:\"Failed to delete\",loadFailed:\"Failed to load data\"},hmsErrors:{title:\"Errors - {{name}}\",noErrors:\"No errors\",viewOnWiki:\"View on Bambu Lab Wiki\",clearInstructions:\"Clear errors on the printer to dismiss them here.\",clearErrors:\"Clear Errors\",clearSuccess:\"HMS errors cleared\",clearFailed:\"Failed to clear HMS errors\"},mqttDebug:{title:\"MQTT Debug Log\",searchPlaceholder:\"Search topic or payload...\",noMessages:\"No messages logged yet\",startLoggingHint:'Click \"Start Logging\" to begin capturing MQTT messages',noMessagesMatch:\"No messages match your filter\",adjustFilterHint:\"Try adjusting your search or filter criteria\",incoming:\"Incoming\",outgoing:\"Outgoing\",loggingStopped:\"Logging stopped\",loggingActive:\"Logging active - messages will auto-refresh\",startLogging:\"Start Logging\",stopLogging:\"Stop Logging\",clearLog:\"Clear Log\",topic:\"Topic\",timestamp:\"Timestamp\",direction:\"Direction\",all:\"All\"},printerFiles:{title:\"File Manager\",storageUsed:\"Used:\",storageFree:\"Free:\",filterPlaceholder:\"Filter files...\",deleteButton:\"Delete\",deleteFiles:\"Delete {{count}} Files\",deleteFileConfirm:'Delete \"{{name}}\"? This cannot be undone.',deleteFilesConfirm:\"Delete {{count}} selected files? This cannot be undone.\",noFiles:\"No files on printer\",loadingFiles:\"Loading files...\",failedToLoad:\"Failed to load files\",toast:{filesDeleted:\"Deleted {{count}} file(s)\",deleteFailed:\"Delete failed: {{error}}\"}},confirm:{delete:\"Are you sure you want to delete this?\",unsavedChanges:\"You have unsaved changes. Are you sure you want to leave?\",clearQueue:\"Are you sure you want to clear the queue?\"},login:{title:\"Bambuddy Login\",subtitle:\"Sign in to your account\",username:\"Username\",usernamePlaceholder:\"Enter your username\",usernameOrEmail:\"Username or Email\",usernameOrEmailPlaceholder:\"Username or @ Email\",password:\"Password\",passwordPlaceholder:\"Enter your password\",signIn:\"Sign in\",signingIn:\"Logging in...\",forgotPassword:\"Forgot your password?\",loginSuccess:\"Logged in successfully\",loginFailed:\"Login failed\",enterCredentials:\"Please enter username and password\",enterEmail:\"Please enter your email address\",oidcLoginFailed:\"OIDC login failed\",oidcErrors:{providerError:\"The identity provider returned an error\",missingParameters:\"OIDC callback is missing required parameters\",invalidState:\"OIDC state is invalid or has already been used\",stateExpired:\"OIDC login session expired — please try again\",providerNotFound:\"OIDC provider not found\",discoveryFailed:\"Failed to fetch OIDC discovery document\",invalidDiscovery:\"OIDC discovery document is invalid\",networkError:\"Network error during OIDC token exchange\",badResponse:\"Unexpected response during OIDC token exchange\",noIdToken:\"OIDC provider did not return an ID token\",validationFailed:\"OIDC token validation failed\",nonceMismatch:\"OIDC nonce mismatch — possible replay attack\",missingSubClaim:\"OIDC token is missing the sub claim\",noLinkedAccount:\"No local account is linked to this OIDC identity\",accountInactive:\"Your account is inactive\",userResolutionFailed:\"Failed to resolve your account\",internalError:\"An internal error occurred during OIDC login\",tokenExchangeFailed:\"OIDC token exchange failed\"},forgotPasswordTitle:\"Forgot Password\",forgotPasswordMessage:\"If you've forgotten your password, please contact your system administrator to reset it.\",forgotPasswordEmailMessage:\"Enter your email address and we'll send you a new password.\",emailAddress:\"Email Address\",emailPlaceholder:\"your.email@example.com\",cancel:\"Cancel\",sending:\"Sending...\",sendResetEmail:\"Send Reset Email\",howToReset:\"How to reset your password:\",resetStep1:\"Contact your Bambuddy administrator\",resetStep2:\"Ask them to reset your password in User Management\",resetStep3:\"They can set a new temporary password for you\",resetStep4:\"Log in with the new password and change it in Settings\",gotIt:\"Got it\",resetPassword:{title:\"Set New Password\",subtitle:\"Enter and confirm your new password below.\",newPassword:\"New Password\",newPasswordPlaceholder:\"At least 8 characters\",confirmPassword:\"Confirm Password\",confirmPasswordPlaceholder:\"Repeat new password\",saving:\"Saving…\",submit:\"Set New Password\",backToLogin:\"Back to login\",passwordsDoNotMatch:\"Passwords do not match\",passwordTooShort:\"Password must be at least 8 characters\",resetFailed:\"Password reset failed. The link may have expired.\"},twoFA:{title:\"Two-Factor Authentication\",subtitle:\"Your account is protected with 2FA. Enter the verification code below.\",methodAuthenticator:\"Authenticator App\",methodEmail:\"Email Code\",methodBackup:\"Backup Code\",instructionsTotp:\"Open your authenticator app and enter the 6-digit code for Bambuddy.\",instructionsEmail:\"A 6-digit code has been sent to your email address. It expires in 10 minutes.\",instructionsEmailNotSent:\"Click the button below to receive a verification code via email.\",instructionsBackup:\"Enter one of your 8-character backup recovery codes. Each code can only be used once.\",sendCodeButton:\"Send Code via Email\",sendingCode:\"Sending...\",resendCode:\"Resend code\",codeLabel:\"Verification Code\",backupCodeLabel:\"Backup Code\",codePlaceholder:\"000000\",backupCodePlaceholder:\"XXXXXXXX\",verifyButton:\"Verify\",verifyingButton:\"Verifying...\",backToLogin:\"← Back to login\",orContinueWith:\"or continue with\",signInWith:\"Sign in with {{provider}}\",enterCode:\"Please enter the verification code\",sendCodeFailed:\"Failed to send verification code\",invalidCode:\"Invalid code. Please try again.\"}},setup:{title:\"Bambuddy Setup\",subtitle:\"Configure authentication for your Bambuddy instance\",enableAuth:\"Enable Authentication\",adminAccount:\"Admin Account\",adminAccountDesc:\"If admin users already exist, authentication will be enabled using the existing admin accounts. Leave the fields below empty to use existing admins, or enter new credentials to create a new admin user.\",adminUsername:\"Admin Username\",adminPassword:\"Admin Password\",optionalIfAdminExists:\"(optional if admin users exist)\",adminUsernamePlaceholder:\"Enter admin username (optional)\",adminPasswordPlaceholder:\"Enter admin password (optional)\",confirmPassword:\"Confirm Password\",confirmPasswordPlaceholder:\"Confirm admin password\",settingUp:\"Setting up...\",completeSetup:\"Complete Setup\",toast:{authEnabledAdminCreated:\"Authentication enabled and admin user created\",authEnabledExistingAdmins:\"Authentication enabled using existing admin users\",setupCompleted:\"Setup completed\",enterBothCredentials:\"Please enter both admin username and password, or leave both empty to use existing admin users\",passwordsDoNotMatch:\"Passwords do not match\",passwordTooShort:\"Password must be at least 6 characters\"}},changePassword:{title:\"Change Password\",currentPassword:\"Current Password\",currentPasswordPlaceholder:\"Enter current password\",newPassword:\"New Password\",newPasswordPlaceholder:\"Enter new password (min 6 characters)\",confirmPassword:\"Confirm New Password\",confirmPasswordPlaceholder:\"Confirm new password\",passwordsDoNotMatch:\"Passwords do not match\",passwordTooShort:\"Password must be at least 6 characters\",changing:\"Changing...\",success:\"Password changed successfully\",failed:\"Failed to change password\"},plateAlert:{title:\"Print Paused!\",message:\"Objects detected on build plate. The print has been automatically paused. Please clear the plate and resume the print.\",understand:\"I Understand\"},camera:{title:\"Camera View\",invalidPrinterId:\"Invalid printer ID\",live:\"Live\",snapshot:\"Snapshot\",restartStream:\"Restart stream\",refreshSnapshot:\"Refresh snapshot\",fullscreen:\"Fullscreen\",exitFullscreen:\"Exit fullscreen\",connectingToCamera:\"Connecting to camera...\",capturingSnapshot:\"Capturing snapshot...\",connectionLost:\"Connection lost\",connectionFailed:\"Camera connection failed\",reconnecting:\"Reconnecting in {{countdown}}s... (attempt {{attempt}}/{{max}})\",reconnectNow:\"Reconnect now\",cameraUnavailable:\"Camera unavailable\",cameraUnavailableDesc:\"Make sure the printer is powered on and connected.\",noCamera:\"No camera available\",retry:\"Retry\",cameraStream:\"Camera stream\",zoomOut:\"Zoom out\",zoomIn:\"Zoom in\",resetZoom:\"Reset zoom\",recording:\"Recording\",startRecording:\"Start Recording\",stopRecording:\"Stop Recording\",chamberLight:\"Toggle chamber light\"},groups:{title:\"Group Management\",subtitle:\"Manage permission groups for access control\",backToSettings:\"Back to Settings\",createGroup:\"Create Group\",noPermission:\"You do not have permission to access this page.\",system:\"System\",noDescription:\"No description\",usersCount:\"{{count}} users\",permissionsCount:\"{{count}} permissions\",edit:\"Edit\",delete:\"Delete\",toast:{created:\"Group created successfully\",updated:\"Group updated successfully\",deleted:\"Group deleted successfully\",enterGroupName:\"Please enter a group name\"},modal:{editGroup:\"Edit Group\",createGroup:\"Create Group\",cancel:\"Cancel\",saving:\"Saving...\",creating:\"Creating...\",saveChanges:\"Save Changes\"},form:{groupName:\"Group Name\",groupNamePlaceholder:\"Enter group name\",systemGroupWarning:\"System group names cannot be changed\",description:\"Description\",descriptionPlaceholder:\"Enter description (optional)\",permissions:\"Permissions ({{count}} selected)\"},deleteModal:{title:\"Delete Group\",message:\"Are you sure you want to delete this group? Users in this group will lose these permissions.\",confirm:\"Delete Group\"},editor:{title:\"Edit Group\",createTitle:\"Create Group\",search:\"Search permissions...\",selectAll:\"Select All\",clearAll:\"Clear All\",permissionsSelected:\"{{count}} selected\",noResults:\"No permissions match your search\"}},users:{title:\"User Management\",subtitle:\"Manage users and their access to your Bambuddy instance\",backToSettings:\"Back to Settings\",createUser:\"Create User\",noPermission:\"You do not have permission to access this page.\",admin:\"Admin\",noGroups:\"No groups\",active:\"Active\",inactive:\"Inactive\",edit:\"Edit\",delete:\"Delete\",system:\"System\",noGroupsAvailable:\"No groups available\",table:{username:\"Username\",groups:\"Groups\",status:\"Status\",actions:\"Actions\"},toast:{created:\"User created successfully\",updated:\"User updated successfully\",deleted:\"User deleted successfully\",fillRequired:\"Please fill in all required fields\",passwordsDoNotMatch:\"Passwords do not match\",passwordTooShort:\"Password must be at least 6 characters\"},modal:{createUser:\"Create User\",editUser:\"Edit User\",cancel:\"Cancel\",creating:\"Creating...\",saving:\"Saving...\",saveChanges:\"Save Changes\",advancedAuthSubtitle:\"with Advanced Authentication\"},form:{username:\"Username\",usernamePlaceholder:\"Enter username\",email:\"Email\",emailPlaceholder:\"user@example.com\",password:\"Password\",passwordPlaceholder:\"Enter password\",confirmPassword:\"Confirm Password\",confirmPasswordPlaceholder:\"Confirm password\",newPasswordPlaceholder:\"Enter new password\",confirmNewPasswordPlaceholder:\"Confirm new password\",leaveBlankToKeep:\"leave blank to keep current\",groups:\"Groups\",optional:\"optional\",autoGeneratedPassword:\"A secure password will be automatically generated and emailed to the user.\",passwordManagedByAdvancedAuth:'Password is managed by Advanced Authentication. Use \"Reset Password\" to send a new password to the user via email.',resetPassword:\"Reset Password\",resettingPassword:\"Resetting Password...\"},deleteModal:{title:\"Delete User\",message:\"Are you sure you want to delete this user? This action cannot be undone.\",confirm:\"Delete User\"}},streamOverlay:{title:\"Stream Overlay\",invalidPrinterId:\"Invalid printer ID\",cameraStream:\"Camera stream\",progress:\"Progress\",eta:\"ETA\",printerIdle:\"Printer is idle\",printerOffline:\"Printer offline\",status:{printing:\"Printing\",paused:\"Paused\",finished:\"Finished\",failed:\"Failed\",idle:\"Idle\",unknown:\"Unknown\"}},profiles:{title:\"Profiles\",subtitle:\"Manage your slicer presets and pressure advance calibrations\",tabs:{cloud:\"Cloud Profiles\",local:\"Local Profiles\",kprofiles:\"K-Profiles\"},localProfiles:{title:\"Local Profiles\",subtitle:\"Import and manage slicer presets from OrcaSlicer\",import:\"Import Profiles\",importDesc:\"Drop .bbscfg, .bbsflmt, .orca_filament, .zip, or .json files here\",importing:\"Importing...\",search:\"Search local presets...\",noPresets:\"No local presets yet\",badge:\"Local\",edit:\"Edit\",delete:\"Delete\",cancel:\"Cancel\",deleteConfirmTitle:\"Delete Preset\",deleteConfirm:\"Are you sure you want to delete this preset? This cannot be undone.\",source:\"Source\",inheritsFrom:\"Inherits\",filamentType:\"Type\",vendor:\"Vendor\",compatiblePrinters:\"Printers\",nozzleTemp:\"Nozzle Temp\",cost:\"Cost\",density:\"Density\",pressureAdvance:\"Pressure Advance\",filament:\"Filament\",process:\"Process\",printer:\"Printer\",toast:{importSuccess:\"{{count}} preset(s) imported\",importSkipped:\"{{count}} preset(s) skipped (duplicates)\",importError:\"{{count}} error(s) during import\",deleted:\"Preset deleted\",updated:\"Preset updated\"}},connectedAs:\"Connected as\",logout:\"Logout\",noLogoutPermission:\"You do not have permission to logout\",failedToLoad:\"Failed to load profiles\",retry:\"Retry\",time:{justNow:\"Just now\",minsAgo:\"{{count}}m ago\",hoursAgo:\"{{count}}h ago\",daysAgo:\"{{count}}d ago\"},toast:{loggedOut:\"Logged out\"},login:{title:\"Connect to Bambu Cloud\",subtitle:\"Sync your slicer presets across devices\",email:\"Email\",password:\"Password\",region:\"Region\",regionGlobal:\"Global\",regionChina:\"China\",verificationCode:\"Verification Code\",totpCode:\"Authenticator Code\",checkEmail:\"Check your email ({{email}}) for a 6-digit code\",enterTotpHint:\"Enter the 6-digit code from your authenticator app\",accessToken:\"Access Token\",accessTokenHint:\"Paste your Bambu Lab access token (from Bambu Studio)\",back:\"Back\",loginButton:\"Login\",verifyButton:\"Verify\",setTokenButton:\"Set Token\",useToken:\"Use access token instead\",useEmail:\"Login with email instead\",toast:{loggedIn:\"Logged in successfully\",codeSent:\"Verification code sent to your email\",enterTotp:\"Enter code from your authenticator app\",tokenSet:\"Token set successfully\"}},presets:{myPreset:\"My preset (editable)\",duplicate:\"Duplicate\",editable:\"Editable\",failedToLoadDetails:\"Failed to load preset details\",deleteConfirm:\"Delete this preset?\",deleteWarning:'This will permanently delete \"{{name}}\" from Bambu Cloud. This cannot be undone.',noDuplicatePermission:\"You do not have permission to duplicate presets\",noEditPermission:\"You do not have permission to edit presets\",noDeletePermission:\"You do not have permission to delete presets\",types:{filament:\"Filament preset\",printer:\"Printer preset\",process:\"Process preset\"},toast:{deleted:\"Preset deleted\",created:\"Preset created\",updated:\"Preset updated\",duplicated:\"Preset duplicated\",fieldAdded:'Field \"{{key}}\" added',exported:\"Preset exported\"},baseLabel:\"Base: {{name}}\",currentLabel:\"Current: {{name}}\",newPreset:\"New Preset\",editPreset:\"Edit Preset\",duplicatePreset:\"Duplicate Preset\",createNewPreset:\"Create New Preset\",customizeSettings:\"Customize settings for your new preset\",compareWithBase:\"Compare with base preset\",compare:\"Compare\",basePreset:\"Base Preset\",selectBasePreset:\"Select base preset...\",presetName:\"Preset Name\",myCustomPreset:\"My custom preset\",inheritsFrom:\"Inherits from\",dropJsonToImport:\"Drop JSON to import\",tabs:{common:\"Common\",allFields:\"All Fields\"},availableFields:\"Available Fields\",searchFieldsPlaceholder:\"Search fields...\",noMatchingFields:\"No matching fields\",allFieldsAdded:\"All fields added\",addCustomField:\"Add custom field\",yourOverrides:\"Your Overrides\",noOverridesYet:\"No overrides yet\",clickFieldsToAdd:\"Click fields on the left to add them\",saveAsTemplate:\"Save as template\",jsonTip:\"Tip: Drag & drop a .json file anywhere on this modal to import settings\"},cloudView:{searchPlaceholder:\"Search presets...\",templates:\"Templates\",refresh:\"Refresh\",newPreset:\"New Preset\",clearFilters:\"Clear filters\",compareMode:\"Compare Mode\",selectAnotherPreset:\"Select another {{type}} preset\",clickTwoPresets:\"Click two presets of the same type to compare\",selectFirst:\"1. Select first\",selectSecond:\"2. Select second\",compareNow:\"Compare Now\",lastSynced:\"Last synced:\",showingCount:\"Showing {{showing}} of {{total}} presets\",noPresetsFound:\"No presets found\",columns:{filament:\"Filament\",process:\"Process\",printer:\"Printer\"},noFilamentPresets:\"No filament presets\",noProcessPresets:\"No process presets\",noPrinterPresets:\"No printer presets\",filters:{type:\"Type\",owner:\"Owner\",printer:\"Printer\",nozzle:\"Nozzle\",filament:\"Filament\",layer:\"Layer\",all:\"All\",myPresets:\"My Presets\",builtIn:\"Built-in\",process:\"Process\"},noTemplatesPermission:\"You do not have permission to manage templates\",noRefreshPermission:\"You do not have permission to refresh profiles\",noCreatePermission:\"You do not have permission to create presets\"},templates:{title:\"Quick Templates\",noTemplates:\"No templates yet\",createFirst:\"Create templates from the preset editor\",typeFilter:\"Type:\",deleteTitle:\"Delete Template\",deleteWarning:\"This action cannot be undone\",deleteConfirm:'Are you sure you want to delete \"{{name}}\"?',namePlaceholder:\"Template name\",descriptionPlaceholder:\"Description\",settingsJson:\"Settings (JSON)\",fieldsCount:\"{{count}} fields\",shownInModals:\"Shown in modals\",hiddenInModals:\"Hidden in modals\",apply:\"Apply\",toast:{deleted:\"Template deleted\",updated:\"Template updated\",created:\"Template created\",applied:\"Template applied\"}}},support:{debugLoggingActive:\"Debug logging is active\",manageLogs:\"Manage\",collectItem7:\"Printer connectivity and firmware versions\",collectItem8:\"Integration status (Spoolman, MQTT, HA)\",collectItem9:\"Network interfaces (subnets only)\",collectItem10:\"Python package versions\",collectItem11:\"Database health checks\",collectItem12:\"Docker environment details\"},fileManager:{title:\"File Manager\",subtitle:\"Organize and manage your print files\",uploadFiles:\"Upload Files\",newFolder:\"New Folder\",folderName:\"Folder Name\",folderNamePlaceholder:\"e.g., Functional Parts\",renameFile:\"Rename File\",renameFolder:\"Rename Folder\",moveFiles:\"Move {{count}} File(s)\",rootNoFolder:\"Root (No Folder)\",current:\"current\",linkFolder:\"Link Folder\",linkFolderDescription:'Link \"{{name}}\" to a project or archive for quick access.',project:\"Project\",archive:\"Archive\",noProjectsFound:\"No projects found\",noArchivesFound:\"No archives found\",unlink:\"Unlink\",link:\"Link\",dragDropFiles:\"Drag & drop files here\",dropFilesHere:\"Drop files here\",orClickToBrowse:\"or click to browse\",allFileTypesSupported:\"All file types supported. ZIP files will be extracted.\",zipFilesDetected:\"ZIP files detected\",zipExtractOptions:\"ZIP files will be extracted. Choose how to handle folder structure:\",preserveZipStructure:\"Preserve folder structure from ZIP\",createFolderFromZip:\"Create folder from ZIP filename\",stlThumbnailGeneration:\"STL thumbnail generation\",zipMayContainStl:\"ZIP files may contain STL files. Thumbnails can be generated during extraction.\",thumbnailsCanBeGenerated:\"Thumbnails can be generated for STL files. Large models may take longer to process.\",generateThumbnailsForStl:\"Generate thumbnails for STL files\",threemfDetected:\"3MF files detected\",threemfExtractionInfo:\"Printer model, material, color, and print settings will be automatically extracted from 3MF files.\",willBeExtracted:\"Will be extracted\",filesExtracted:\"{{count}} files extracted\",uploadComplete:\"Upload complete: {{succeeded}} succeeded\",uploadFailed:\"Upload failed\",zipFilesFailed:\"{{count}} files failed\",uploading:\"Uploading...\",changeLink:\"Change Link...\",linkTo:\"Link to...\",linkToProjectOrArchive:\"Link to project or archive\",addToQueue:\"Add to Queue\",schedulePrint:\"Schedule\",generateThumbnail:\"Generate Thumbnail\",generateThumbnails:\"Generate Thumbnails\",generateThumbnailsForMissing:\"Generate thumbnails for STL files missing them\",gridView:\"Grid view\",listView:\"List view\",lowDiskSpaceWarning:\"Low disk space warning\",lowDiskSpaceDetails:\"Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.\",files:\"Files\",folders:\"Folders\",size:\"Size\",free:\"Free\",allFiles:\"All Files\",wrap:\"Wrap\",enableTextWrapping:\"Enable text wrapping\",disableTextWrapping:\"Disable text wrapping\",collapse:\"Collapse\",collapseFoldersByDefault:\"Collapse folders by default\",expandFoldersByDefault:\"Expand folders by default\",dragToResizeTooltip:\"Drag to resize, double-click to reset\",searchFiles:\"Search files...\",allTypes:\"All types\",prints:\"Prints\",ascending:\"Ascending\",descending:\"Descending\",resultsCount:\"{{showing}} of {{total}} files\",selectAll:\"Select All\",deselectAll:\"Deselect All\",selected:\"{{count}} selected\",adding:\"Adding...\",loadingFiles:\"Loading files...\",folderIsEmpty:\"Folder is empty\",noFilesYet:\"No files yet\",folderEmptyDescription:\"Upload files or move files into this folder to get started.\",noFilesDescription:\"Upload files to start organizing your print-related files.\",noMatchingFiles:\"No matching files\",noMatchingFilesDescription:\"No files match your current search or filter criteria.\",clearFilters:\"Clear filters\",printedCount:\"Printed {{count}}x\",uploadedBy:\"Uploaded By\",deleteFolder:\"Delete Folder\",deleteFile:\"Delete File\",deleteFilesCount:\"Delete {{count}} Files\",deleteFolderConfirm:\"Are you sure you want to delete this folder? All files inside will also be deleted.\",deleteFileConfirm:\"Are you sure you want to delete this file?\",deleteFilesConfirm:\"Are you sure you want to delete {{count}} selected files? This action cannot be undone.\",deleting:\"Deleting...\",noPermissionRenameFolder:\"You do not have permission to rename folders\",noPermissionLinkFolder:\"You do not have permission to link folders\",noPermissionDeleteFolder:\"You do not have permission to delete folders\",noPermissionPrint:\"You do not have permission to print\",noPermissionAddToQueue:\"You do not have permission to add to queue\",noPermissionDownload:\"You do not have permission to download files\",noPermissionRenameFile:\"You do not have permission to rename this file\",noPermissionGenerateThumbnail:\"You do not have permission to generate thumbnails\",noPermissionDeleteFile:\"You do not have permission to delete this file\",noPermissionCreateFolder:\"You do not have permission to create folders\",noPermissionUpload:\"You do not have permission to upload files\",noPermissionMoveFiles:\"You do not have permission to move files\",noPermissionDeleteFiles:\"You do not have permission to delete files\",linkExternal:\"Link External\",linkExternalFolder:\"Link External Folder\",linkExternalFolderDescription:\"Mount a host directory (NAS, USB, network share) into the File Manager. Files are not copied — they are accessed directly from the original path.\",externalFolderNamePlaceholder:\"e.g., NAS Prints\",externalPath:\"Host Path\",externalPathHelp:\"Absolute path to the directory on the Docker host. Must be bind-mounted into the container.\",readOnly:\"Read Only\",readOnlyHelp:\"prevents uploads and deletions\",showHiddenFiles:\"Show hidden files (dotfiles)\",externalFolder:\"External Folder\",scanFolder:\"Scan\",toast:{folderCreated:\"Folder created\",folderDeleted:\"Folder deleted\",fileDeleted:\"File deleted\",filesDeleted:\"Deleted {{count}} files\",filesMoved:\"Files moved\",folderLinked:\"Folder linked\",folderUnlinked:\"Folder unlinked\",externalFolderLinked:\"External folder linked and scanned\",folderScanned:\"Scan complete: {{added}} added, {{removed}} removed\",addedToQueue:\"Added {{count}} file(s) to queue\",addedToQueuePartial:\"Added {{added}} file(s), {{failed}} failed\",failedToAddToQueue:\"Failed to add files: {{error}}\",fileRenamed:\"File renamed\",folderRenamed:\"Folder renamed\",thumbnailsGenerated:\"Generated {{count}} thumbnail(s)\",thumbnailsGeneratedPartial:\"Generated {{succeeded}} thumbnail(s), {{failed}} failed\",noStlMissingThumbnails:\"No STL files missing thumbnails\",failedToGenerateThumbnails:\"Failed to generate thumbnails: {{error}}\",thumbnailGenerated:\"Thumbnail generated\",failedToGenerateThumbnail:\"Failed to generate thumbnail: {{error}}\"}},projects:{title:\"Projects\",subtitle:\"Organize and track your 3D printing projects\",newProject:\"New Project\",editProject:\"Edit Project\",deleteProject:\"Delete Project\",projectName:\"Project Name\",description:\"Description\",noProjects:\"No projects yet\",noProjectsFiltered:\"No {{status}} projects\",noProjectsFilteredHelp:\"You don't have any {{status}} projects. Projects will appear here when their status changes.\",createFirst:\"Create your first project to start organizing related prints, tracking progress, and managing your builds.\",createFirstButton:\"Create Your First Project\",create:\"Create\",files:\"Files\",prints:\"Prints\",plates:\"plates\",parts:\"parts\",lastModified:\"Last Modified\",deleteConfirm:\"Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted.\",addFiles:\"Add Files\",removeFile:\"Remove File\",viewDetails:\"View Details\",namePlaceholder:\"e.g., Voron 2.4 Build\",descriptionPlaceholder:\"Optional description...\",color:\"Color\",targetPlates:\"Target Plates\",targetPlatesPlaceholder:\"e.g., 25\",targetPlatesHelp:\"Number of print jobs\",targetParts:\"Target Parts\",targetPartsPlaceholder:\"e.g., 150\",targetPartsHelp:\"Total objects needed\",tagsLabel:\"Tags (comma-separated)\",tagsPlaceholder:\"e.g., voron, functional, gift\",dueDate:\"Due Date\",priority:\"Priority\",priorityLow:\"Low\",priorityNormal:\"Normal\",priorityHigh:\"High\",priorityUrgent:\"Urgent\",statusActive:\"Active\",statusCompleted:\"Completed\",statusArchived:\"Archived\",done:\"Done\",completed:\"completed\",failed:\"failed\",inQueue:\"in queue\",noPrintsYet:\"No prints yet\",printJobs:\"Print jobs (plates)\",partsPrinted:\"Parts printed\",failedParts:\"Failed parts\",import:\"Import\",export:\"Export\",importProject:\"Import project\",exportAll:\"Export all projects\",loading:\"Loading projects...\",noEditPermission:\"You do not have permission to edit projects\",noDeletePermission:\"You do not have permission to delete projects\",noCreatePermission:\"You do not have permission to create projects\",noImportPermission:\"You do not have permission to import projects\",noExportPermission:\"You do not have permission to export projects\",toast:{created:\"Project created\",updated:\"Project updated\",deleted:\"Project deleted\",imported:\"Project imported\",multipleImported:\"{{count}} projects imported\",importFailed:\"Import failed\",exported:\"Projects exported (metadata only)\"}},projectDetail:{notFound:\"Project not found\",backToProjects:\"Back to Projects\",export:\"Export\",exportProject:\"Export project\",noExportPermission:\"You do not have permission to export projects\",noEditPermission:\"You do not have permission to edit projects\",partOf:\"Part of:\",priorityLabel:\"Priority:\",noPrints:\"No prints in this project yet\",status:{active:\"Active\",completed:\"Completed\",archived:\"Archived\"},priority:{low:\"Low\",normal:\"Normal\",high:\"High\",urgent:\"Urgent\"},dueDate:{overdue:\"Overdue\",today:\"Due today\",daysLeft:\"{{count}} days left\"},progress:{platesProgress:\"Plates Progress\",partsProgress:\"Parts Progress\",printJobs:\"print jobs\",parts:\"parts\",percentComplete:\"{{percent}}% complete\",remaining:\"{{count}} remaining\"},stats:{printJobs:\"Print Jobs\",total:\"total\",failed:\"{{count}} failed\",partsPrinted:\"{{count}} parts printed\",printTime:\"Print Time\",filamentUsed:\"Filament Used\"},cost:{title:\"Cost Tracking\",filamentCost:\"Filament Cost\",energy:\"Energy\",totalCost:\"Total Cost\",total:\"Total\",includesBom:\"incl. BOM\",budget:\"Budget\",remaining:\"Remaining\"},subProjects:{title:\"Sub-projects ({{count}})\"},notes:{title:\"Notes\",noEditPermission:\"You do not have permission to edit notes\",placeholder:\"Add notes about this project...\",empty:\"No notes yet. Click Edit to add notes.\"},files:{title:\"Files\",linkFolders:\"Link folders from the File Manager\",forQuickAccess:\"to this project for quick access.\",fileCount:\"{{count}} file(s)\",empty:\"No folders linked. Go to File Manager and link a folder to this project.\",noFiles:\"No files in this folder.\",print:\"Print Now\",addToQueue:\"Add to Queue\"},bom:{title:\"Bill of Materials\",acquired:\"{{completed}}/{{total}} acquired\",showAll:\"Show all\",hideDone:\"Hide done\",addPart:\"Add Part\",noAddPermission:\"You do not have permission to add parts\",partNamePlaceholder:\"Part name (e.g., M3x8 screws)\",partName:\"Part name\",qty:\"Qty\",price:\"Price ({{currency}})\",sourcingUrlPlaceholder:\"Sourcing URL (optional)\",remarksPlaceholder:\"Remarks (optional)\",deletePart:\"Delete Part\",deleteConfirm:'Are you sure you want to delete \"{{name}}\"?',noUpdatePermission:\"You do not have permission to update parts\",noEditPermission:\"You do not have permission to edit parts\",noDeletePermission:\"You do not have permission to delete parts\",totalCost:\"Total cost:\",empty:\"No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.\"},timeline:{title:\"Activity Timeline\",empty:\"No activity yet.\"},template:{saveAsTemplate:\"Save as Template\",noCreatePermission:\"You do not have permission to create templates\"},queue:{title:\"Queue\",viewAll:\"View all\",printing:\"{{count}} printing\",queued:\"{{count}} queued\"},prints:{title:\"Prints ({{count}})\"},toast:{projectUpdated:\"Project updated\",partAdded:\"Part added\",partRemoved:\"Part removed\",exportFailed:\"Export failed\",projectExported:\"Project exported\",templateCreated:\"Template created\"}},system:{title:\"System Information\",version:\"Version\",uptime:\"Uptime\",cpuUsage:\"CPU Usage\",memoryUsage:\"Memory Usage\",diskUsage:\"Disk Usage\",networkInfo:\"Network Info\",logs:\"Logs\",debugMode:\"Debug Mode\",enableDebug:\"Enable Debug Logging\",disableDebug:\"Disable Debug Logging\",downloadLogs:\"Download Logs\",clearLogs:\"Clear Logs\",dockerInfo:\"Docker Info\",containerName:\"Container Name\",imageName:\"Image Name\",platform:\"Platform\",architecture:\"Architecture\"},library:{title:\"Filament Library\",addFilament:\"Add Filament\",editFilament:\"Edit Filament\",deleteFilament:\"Delete Filament\",vendor:\"Vendor\",material:\"Material\",color:\"Color\",kFactor:\"K Factor\",temperature:\"Temperature\",noFilaments:\"No filaments in library\",deleteConfirm:\"Are you sure you want to delete this filament?\",importFromPrinter:\"Import from Printer\",exportToFile:\"Export to File\"},spoolman:{title:\"Spoolman Integration\",enabled:\"Spoolman Enabled\",url:\"Spoolman URL\",connected:\"Connected\",disconnected:\"Not Connected\",testConnection:\"Test Connection\",sync:\"Sync\",syncing:\"Syncing...\",lastSync:\"Last Sync\",linkToSpoolman:\"Link to Spoolman\",openInSpoolman:\"Open in Spoolman\",unlinkSpool:\"Unlink Spool\",unlinkConfirmTitle:\"Unlink Spool?\",unlinkConfirmMessage:\"This will disconnect the spool from Spoolman. The spool data in Spoolman will remain unchanged.\",selectSpool:\"Select Spool\",noUnlinkedSpools:\"No unlinked spools available\",linkSuccess:\"Spool linked to Spoolman successfully\",linkFailed:\"Failed to link spool\",unlinkSuccess:\"Spool unlinked from Spoolman successfully\",unlinkFailed:\"Failed to unlink spool\",spoolId:\"Spool ID\",fillSourceLabel:\"(Spoolman)\",weight:\"Weight\",remaining:\"Remaining\",disableWeightSync:\"Disable AMS Estimated Weight Sync\",disableWeightSyncDesc:\"Don't update remaining capacity from AMS estimates. Use this if you prefer Spoolman's usage tracking over AMS percentage-based estimates. New spools will still use the AMS estimate as their initial weight.\",reportPartialUsage:\"Report Partial Usage for Failed Prints\",reportPartialUsageDesc:\"When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.\"},inventory:{title:\"Spool Inventory\",addSpool:\"Add Spool\",editSpool:\"Edit Spool\",material:\"Material\",selectMaterial:\"Select material...\",subtype:\"Subtype\",brand:\"Brand\",searchBrand:\"Search brand...\",useCustomBrand:'Use \"{{brand}}\"',useCustomMaterial:\"Use custom material: {{material}}\",colorName:\"Color Name\",colorNamePlaceholder:\"Jade White, Fire Red...\",color:\"Color\",hexColor:\"Hex Color\",pickColor:\"Pick custom color\",labelWeight:\"Label Weight\",coreWeight:\"Empty Spool Weight\",searchSpoolWeight:\"Search spool weight...\",weightUsed:\"Used\",currentWeight:\"Remaining Weight\",measuredWeight:\"Measured Weight\",spoolName:\"Spool\",costPerKg:\"Cost per kg\",measuredWeightError:\"Measured weight must be between {{min}}g and {{max}}g.\",slicerFilament:\"Slicer Filament\",slicerFilamentName:\"Slicer Preset Name\",slicerPreset:\"Slicer Preset\",searchPresets:\"Search filament presets...\",selectedPreset:\"Selected\",noPresetsFound:\"No presets found\",tempOverrides:\"Temperature Overrides\",note:\"Note\",notePlaceholder:\"Any additional notes about this spool...\",archive:\"Archive\",restore:\"Restore\",noSpools:\"No spools yet. Add your first spool to get started.\",noManualSpools:\"No manually added spools available. Add a spool to your inventory first.\",kProfiles:\"K-Profiles\",addKProfile:\"Add K-Profile\",assignSpool:\"Assign Spool\",unassignSpool:\"Unassign\",assignSuccess:\"Spool assigned and AMS slot configured\",assignFailed:\"Failed to assign spool\",selectSpool:\"Select a spool to assign to this slot\",assigned:\"Assigned\",assigning:\"Assigning...\",searchSpools:\"Search spools...\",showAllSpools:\"Show all spools\",allMaterials:\"All Materials\",filterByBrand:\"Filter by brand...\",showArchived:\"Show archived\",quickAdd:\"Quick Add (Stock)\",quantity:\"Quantity\",stock:\"Stock\",configured:\"Configured\",spoolsCreated:\"{{count}} spools created\",spoolCreated:\"Spool created\",spoolUpdated:\"Spool updated\",spoolDeleted:\"Spool deleted\",spoolArchived:\"Spool archived\",spoolRestored:\"Spool restored\",deleteConfirm:\"Are you sure you want to delete this spool? This cannot be undone.\",archiveConfirm:\"Are you sure you want to archive this spool?\",advancedSettings:\"Advanced Settings\",filamentInfoTab:\"Filament Info\",paProfileTab:\"PA Profile\",filamentInfo:\"Filament\",additional:\"Additional\",loadingPresets:\"Loading cloud presets...\",cloudConnected:\"Cloud connected\",cloudNotConnected:\"Cloud not connected (using defaults)\",recentColors:\"Recent\",searchColors:\"Search colors...\",searchResults:\"Search results\",allColors:\"All colors\",commonColors:\"Common colors\",showLess:\"Show less\",showAll:\"Show all\",noColorsFound:\"No colors match your search\",noResults:\"No matches found\",selectMaterialFirst:\"Please select a material first in the Filament Info tab.\",noPrintersConfigured:\"No printers configured. Add printers to use PA profiles.\",matchingFilter:\"Matching\",anyBrand:\"Any brand\",anyVariant:\"Any variant\",autoSelect:\"Auto-select\",matches:\"matches\",match:\"match\",noMatches:\"No matches\",connected:\"Connected\",offline:\"Offline\",printerOffline:\"Printer is offline. Connect to view calibration profiles.\",noKProfilesMatch:\"No K-profiles match the selected filament.\",leftNozzle:\"Left Nozzle\",rightNozzle:\"Right Nozzle\",profilesSelected:\"calibration profile(s) selected\",totalInventory:\"Total Inventory\",totalConsumed:\"Total Consumed\",byMaterial:\"By Material\",inPrinter:\"In Printer\",lowStock:\"Low Stock\",sinceTracking:\"Since tracking started\",loadedInAms:\"Loaded in AMS/Ext\",remaining:\"Remaining\",weightCheck:\"Weight Check\",lastWeighed:\"Last weighed\",neverWeighed:\"Never weighed\",search:\"Search spools...\",showing:\"Showing\",to:\"to\",of:\"of\",show:\"Show\",spools:\"spools\",spool:\"spool\",page:\"Page\",noSpoolsMatch:\"No results found\",noSpoolsMatchDesc:\"Try adjusting your search or filters to find what you're looking for.\",active:\"Active\",archived:\"Archived\",all:\"All\",used:\"Used\",new:\"New\",clearFilters:\"Clear filters\",table:\"Table\",cards:\"Cards\",net:\"Net\",groupSimilar:\"Group\",groupedSpools:\"{{count}} identical spools\",groupedRows:\"rows\",columns:\"Columns\",configureColumns:\"Configure Columns\",configureColumnsDesc:\"Drag to reorder columns or use arrows. Toggle visibility with the eye icon.\",visible:\"visible\",reset:\"Reset\",cancel:\"Cancel\",applyChanges:\"Apply Changes\",moveUp:\"Move up\",moveDown:\"Move down\",hideColumn:\"Hide column\",showColumn:\"Show column\",linkToSpool:\"Link to Spool\",tagLinked:\"Tag linked to spool\",tagLinkFailed:\"Failed to link tag\",tagAlreadyLinked:\"Tag already linked to another spool\",unknownTag:\"Unknown RFID tag detected\",usageHistory:\"Usage History\",noUsageHistory:\"No usage recorded yet\",printName:\"Print Name\",weightConsumed:\"Weight Consumed\",clearHistory:\"Clear\",historyCleared:\"Usage history cleared\",fillSourceLabel:\"(Inv)\",lowStockThresholdError:\"Threshold must be between 0.1 and 99.9\",assignMismatchTitle:\"Material mismatch\",assignMismatchMessage:'The selected spool material \"{{spoolMaterial}}\" does not match the tray material \"{{trayMaterial}}\" for {{location}}. Assign anyway?',assignMismatchConfirm:\"Assign Anyway\",assignPartialMismatchMessage:'The spool material \"{{spoolMaterial}}\" is similar to but not exactly matching \"{{trayMaterial}}\" in {{location}}. Do you want to proceed?',assignProfileMismatchMessage:'The spool profile \"{{spoolProfile}}\" does not match the tray profile \"{{trayProfile}}\" in {{location}}. Do you want to proceed?'},timelapse:{title:\"Timelapse\",create:\"Create Timelapse\",download:\"Download\",delete:\"Delete\",preview:\"Preview\",frameRate:\"Frame Rate\",quality:\"Quality\",processing:\"Processing...\",noTimelapses:\"No timelapses available\"},ams:{title:\"AMS\",slot:\"Slot\",empty:\"Empty\",emptySlot:\"Empty slot\",unknown:\"Unknown\",humidity:\"Humidity\",temperature:\"Temperature\",filamentType:\"Filament Type\",filamentColor:\"Color\",remaining:\"Remaining\",history:\"AMS History\",noHistory:\"No history available\",configureSlot:\"Configure Slot\",externalSpool:\"External Spool\",profile:\"Profile\",kFactor:\"K Factor\",fill:\"Fill\",configure:\"Configure\",used:\"used\",remainingUnit:\"remaining\"},printModal:{title:\"Start Print\",selectPrinter:\"Select Printer\",selectPlate:\"Select Plate\",filamentMapping:\"Filament Mapping\",totalCost:\"Total cost:\",slotRemainingShort:\" - {{grams}}g left\",printSettings:\"Print Settings\",bedLeveling:\"Bed Leveling\",flowCalibration:\"Flow Calibration\",vibrationCalibration:\"Vibration Calibration\",layerInspection:\"First Layer Inspection\",timelapse:\"Timelapse\",startPrint:\"Start Print\",addToQueue:\"Add to Queue\",cancel:\"Cancel\",noPrintersAvailable:\"No printers available\",printerBusy:\"Printer is busy\",printerOffline:\"Printer is offline\",sameTypeDifferentColor:\"Same type, different color\",filamentTypeNotLoaded:\"Filament type not loaded\",openCalendar:\"Open calendar\",leftNozzle:\"L\",rightNozzle:\"R\",leftNozzleTooltip:\"Left nozzle\",rightNozzleTooltip:\"Right nozzle\",filamentOverride:\"Filament Override\",filamentOverrideHint:\"Optionally override filaments for model-based assignment. The scheduler will match against your selected filaments instead of the original 3MF values.\",originalFilament:\"Original\",overrideWith:\"Override with\",resetToOriginal:\"Reset to original\",insufficientFilamentTitle:\"Not enough filament\",insufficientFilamentMessage:\"Some assigned spools have less filament remaining than this print needs:\",insufficientFilamentLine:\"{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g\",printAnyway:\"Print anyway\",forceColorMatch:\"Force color match\",staggerPrinterStarts:\"Stagger printer starts\",staggerGroupSize:\"Group size\",staggerInterval:\"Interval (min)\",staggerPreview:\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",staggerLastGroup:\"last group: {{count}}\",staggerTotal:\"total: {{minutes}} min\",staggerToPrinters:\"Stagger to {{count}} printers\",gcodeInjection:\"Inject auto-print G-code\"},backup:{title:\"Backup & Restore\",createBackup:\"Create Backup\",restoreBackup:\"Restore Backup\",restoreDescription:\"Replace all data from a backup file\",downloadBackup:\"Download Backup\",uploadBackup:\"Upload Backup\",lastBackup:\"Last Backup\",autoBackup:\"Auto Backup\",backupNow:\"Backup Now\",restoreWarning:\"Warning: Restoring a backup will overwrite all current data.\",includeArchives:\"Include Archives\",includeSettings:\"Include Settings\",includeProfiles:\"Include Profiles\",backupSuccess:\"Backup created successfully\",restoreSuccess:\"Backup restored successfully\",backupFailed:\"Backup failed\",restoreFailed:\"Restore failed\",restoreNote:\"Virtual Printer will be stopped during restore\",githubBackup:\"GitHub Backup\",enabled:\"Enabled\",cloudLoginRequired:\"Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.\",cloudLoginRequiredShort:\"Cloud login required\",githubDescription:\"Automatically sync your profiles to a private GitHub repository for backup and version history.\",repositoryUrl:\"Repository URL\",personalAccessToken:\"Personal Access Token\",tokenSaved:\"(saved)\",enterNewToken:\"Enter new token to update\",tokenHint:\"Fine-grained token with Contents read/write permission\",branch:\"Branch\",manualOnly:\"Manual only\",hourly:\"Hourly\",daily:\"Daily\",weekly:\"Weekly\",includeInBackup:\"Include in backup\",kProfiles:\"K-Profiles\",kProfilesDescription:\"Pressure advance calibration from connected printers\",noPrintersConnected:\"No printers connected\",printersConnected:\"{{connected}}/{{total}} connected\",cloudProfiles:\"Cloud Profiles\",cloudProfilesDescription:\"Filament, printer, and process presets from Bambu Cloud\",appSettings:\"App Settings\",appSettingsDescription:\"Bambuddy configuration (complete database)\",spoolInventory:\"Spool Inventory\",spoolInventoryDescription:\"Filament spools, usage history, and cost tracking\",printArchives:\"Print Archives\",printArchivesDescription:\"Print history metadata (no gcode/3MF files)\",lastBackupAt:\"Last backup:\",noBackupsYet:\"No backups yet\",next:\"Next:\",startingBackup:\"Starting backup...\",test:\"Test\",enableBackup:\"Enable Backup\",testConnection:\"Test Connection\",enterRepoUrl:\"Enter repository URL\",enterRepoAndToken:\"Enter repository URL and access token\",repoRequired:\"Repository URL is required\",tokenRequired:\"Access token is required\",githubBackupEnabled:\"GitHub backup enabled\",tokenUpdated:\"Token updated\",settingsSaved:\"Settings saved\",failedToSave:\"Failed to save: {{message}}\",backupCompleteFiles:\"Backup complete - {{count}} files updated\",backupSkippedNoChanges:\"Backup skipped - no changes\",backupFailed2:\"Backup failed: {{message}}\",clearedLogs:\"Cleared {{count}} logs\",failedToClearLogs:\"Failed to clear logs: {{message}}\",history:\"History\",clear:\"Clear\",date:\"Date\",status:\"Status\",commit:\"Commit\",localBackup:\"Local Backup\",localBackupDescription:\"Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.\",downloadBackupLabel:\"Download Backup\",completeBackupZip:\"Complete backup: database + all files (ZIP)\",download:\"Download\",preparingBackup:\"Preparing backup...\",creatingArchive:\"Creating backup archive... This may take a while for large archives.\",downloadingFile:\"Downloading backup file...\",backupDownloaded:\"Backup downloaded successfully\",failedToCreateBackup:\"Failed to create backup: {{message}}\",restore:\"Restore\",restoreReplacesAll:\"Restore replaces all data.\",restoreReplacesAllDetail:\"Your current database and files will be completely replaced. A restart is required after restore.\",restoreConfirmTitle:\"Restore Backup\",restoreConfirmMessage:'Are you sure you want to restore from \"{{filename}}\"? This will completely replace your current database and all files. The application will need to be restarted after restore.',restoreConfirmButton:\"Restore Backup\",uploadingFile:\"Uploading backup file...\",backupRestoredRestart:\"Backup restored. Please restart Bambuddy.\",failedToRestore:\"Failed to restore backup. Please check the file format.\",reloadNow:\"Reload Now\",creatingBackup:\"Creating Backup\",restoringBackup:\"Restoring Backup\",preparing:\"Preparing...\",processing:\"Processing...\",doNotClosePage:\"Please do not close this page or navigate away. This operation may take several minutes for large backups.\",restoring:\"Restoring...\",restoreComplete:\"Restore Complete\",restoreFailed2:\"Restore Failed\",importSettings:\"Import settings from a backup file\",pleaseWaitRestoring:\"Please wait while your data is being restored\",selectBackupFile:\"Click to select backup file (.json or .zip)\",duplicateHandling:\"How duplicate handling works:\",matchPrinters:\"Printers\",matchPrintersBy:\"matched by serial number\",matchSmartPlugs:\"Smart Plugs\",matchSmartPlugsBy:\"matched by IP address\",matchNotificationProviders:\"Notification Providers\",matchNotificationProvidersBy:\"matched by name\",matchFilaments:\"Filaments\",matchFilamentsBy:\"matched by name + type + brand\",matchArchives:\"Archives\",matchArchivesBy:\"matched by content hash (always skipped)\",matchPendingUploads:\"Pending Uploads\",matchPendingUploadsBy:\"matched by filename\",matchSettingsTemplates:\"Settings & Templates\",matchSettingsTemplatesBy:\"always overwritten\",replaceExisting:\"Replace existing data\",keepExisting:\"Keep existing data\",overwriteDescription:\"Overwrite items that already exist with backup data\",keepDescription:\"Only restore items that don't already exist\",overwriteCaution:\"Caution:\",overwriteWarning:\"Overwriting will replace your current configurations with data from the backup. Printer access codes are never overwritten for security.\",cancel:\"Cancel\",processingBackup:\"Processing backup file...\",itemsRestored:\"Items Restored\",itemsSkipped:\"Items Skipped\",restored:\"Restored\",skippedAlreadyExist:\"Skipped (already exist)\",filesCategory:\"Files (3MF, thumbnails, etc.)\",andMore:\"...and {{count}} more\",newApiKeysGenerated:\"New API Keys Generated\",keysShownOnce:\"These keys are only shown once. Copy them now!\",copy:\"Copy\",noDataFound:\"No data was found to restore in the backup file.\",close:\"Close\",scheduledBackup:\"Scheduled Backups\",scheduledBackupDescription:\"Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.\",frequency:\"Frequency\",backupTime:\"Time\",retention:\"Retention\",retentionDescription:\"Number of backups to keep\",outputPath:\"Output Path\",outputPathPlaceholder:\"Default: {{path}}\",outputPathDescription:\"Leave empty for default location\",runNow:\"Run Now\",backupFiles:\"Backup Files\",noScheduledBackups:\"No backups yet\",deleteBackup:\"Delete\",deleteBackupConfirm:\"Delete this backup file?\",backupRunning:\"Backup in progress...\",scheduledBackupComplete:\"Backup completed successfully\",scheduledBackupFailed:\"Backup failed\",nextBackup:\"Next backup\",backupSize:\"Size\",utc:\"UTC\",defaultPathLabel:\"Default:\",categories:{settings:\"Settings\",notification_providers:\"Notification Providers\",notification_templates:\"Notification Templates\",smart_plugs:\"Smart Plugs\",printers:\"Printers\",filaments:\"Filaments\",maintenance_types:\"Maintenance Types\",archives:\"Archives\",projects:\"Projects\",pending_uploads:\"Pending Uploads\",external_links:\"External Links\",api_keys:\"API Keys\"}},tags:{title:\"Tags\",addTag:\"Add Tag\",editTag:\"Edit Tag\",deleteTag:\"Delete Tag\",tagName:\"Tag Name\",tagColor:\"Tag Color\",noTags:\"No tags\",deleteConfirm:\"Are you sure you want to delete this tag?\",manageTags:\"Manage Tags\"},uploadModal:{title:\"Upload 3MF Files\",dragDrop:\"Drag & drop .3mf files here\",or:\"or\",browseFiles:\"Browse Files\",extractionInfo:\"The printer model will be automatically extracted from the 3MF file metadata.\",uploaded:\"uploaded\",failed:\"failed\",uploading:\"Uploading...\",upload:\"Upload\",uploadFailed:\"Upload failed\"},editArchive:{title:\"Edit Archive\",name:\"Name\",namePlaceholder:\"Print name\",printer:\"Printer\",noPrinter:\"No printer\",project:\"Project\",noProject:\"No project\",itemsPrinted:\"Items Printed\",itemsPrintedHelp:\"Number of items produced in this print job\",notes:\"Notes\",notesPlaceholder:\"Add notes about this print...\",externalLink:\"External Link\",externalLinkPlaceholder:\"https://printables.com/model/...\",externalLinkHelp:\"Link to Printables, Thingiverse, or other source\",tags:\"Tags\",tagsPlaceholder:\"Add tags...\",addMoreTags:\"Add more tags...\",matchingTags:'Matching \"{{query}}\"',existingTags:\"Existing tags\",clickToAdd:\"(click to add)\",status:\"Status\",failureReason:\"Failure Reason\",selectReason:\"Select reason...\",photos:\"Photos of Printed Result\",photosHelp:\"Click + to add photos of your printed result\",printResult:\"Print result\",saving:\"Saving...\",failureReasons:{adhesionFailure:\"Adhesion failure\",spaghettiDetached:\"Spaghetti / Detached\",layerShift:\"Layer shift\",cloggedNozzle:\"Clogged nozzle\",filamentRunout:\"Filament runout\",warping:\"Warping\",stringing:\"Stringing\",underExtrusion:\"Under-extrusion\",powerFailure:\"Power failure\",userCancelled:\"User cancelled\",other:\"Other\"},statuses:{completed:\"Completed\",failed:\"Failed\",aborted:\"Cancelled\",printing:\"Printing\"}},kProfiles:{title:\"K-Profiles\",noPrintersConfigured:\"No Printers Configured\",addPrinterInSettings:\"Add a printer in Settings to manage K-profiles\",noActivePrinters:\"No Active Printers\",enablePrinterConnection:\"Enable a printer connection to view its K-profiles\",loadingProfiles:\"Loading K-Profiles...\",printerOffline:\"Printer Offline\",printerOfflineDesc:\"The selected printer is not connected. Power it on to view K-profiles.\",noMatchingProfiles:\"No Matching Profiles\",noMatchingProfilesDesc:\"No profiles match your search criteria\",noKProfiles:\"No K-Profiles\",noKProfilesDesc:\"No pressure advance profiles found for {{diameter}}mm nozzle\",createFirstProfile:\"Create First Profile\",printer:\"Printer\",nozzle:\"Nozzle\",refresh:\"Refresh\",addProfile:\"Add Profile\",export:\"Export\",import:\"Import\",select:\"Select\",selectAll:\"Select All\",delete:\"Delete\",searchPlaceholder:\"Search by name or filament...\",allExtruders:\"All Extruders\",leftOnly:\"Left Only\",rightOnly:\"Right Only\",allFlow:\"All Flow\",hfOnly:\"HF Only\",sOnly:\"S Only\",sortName:\"Sort: Name\",sortKValue:\"Sort: K-Value\",sortFilament:\"Sort: Filament\",leftExtruder:\"Left Extruder\",rightExtruder:\"Right Extruder\",modal:{addTitle:\"Add K-Profile\",editTitle:\"Edit K-Profile\",profileName:\"Profile Name\",profileNamePlaceholder:\"My PLA Profile\",kValue:\"K-Value\",kValuePlaceholder:\"0.020\",kValueHelp:\"Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG\",filament:\"Filament\",selectFilament:\"Select filament...\",noFilamentsHelp:\"No filaments found. Create a K-profile in Bambu Studio first.\",flowType:\"Flow Type\",highFlow:\"High Flow\",standard:\"Standard\",nozzleSize:\"Nozzle Size\",extruder:\"Extruder\",extruders:\"Extruders\",left:\"Left\",right:\"Right\",notes:\"Notes (stored locally)\",notesPlaceholder:\"Add notes about this profile...\",notesHelp:\"Notes are saved in Bambuddy, not on the printer\",syncing:\"Syncing with printer...\",savingExtruder:\"Saving to extruder {{current}}/{{total}}...\",pleaseWait:\"Please wait\"},deleteConfirm:{title:\"Delete Profile\",cannotUndo:\"This cannot be undone\",message:'Are you sure you want to delete \"{{name}}\" from the printer?'},bulkDelete:{title:\"Delete Profiles\",cannotUndo:\"This cannot be undone\",message:\"Are you sure you want to delete {{count}} selected profiles from the printer?\"},toast:{profileSaved:\"K-profile saved\",profilesSaved:\"K-profile saved to {{count}} extruders\",selectAtLeastOneExtruder:\"Please select at least one extruder\",profileDeleted:\"K-profile deleted\",profilesDeleted:\"Deleted {{count}} profiles\",exportedProfiles:\"Exported {{count}} profiles\",importedProfiles:\"Imported {{count}} of {{total}} profiles\",noProfilesToExport:\"No profiles to export\",invalidFileFormat:\"Invalid file format\",failedToParseImport:\"Failed to parse import file\",failedToSaveBatch:\"Failed to save K-profiles\",noteSaved:\"Note saved\",failedToSaveNote:\"Failed to save note\"},permission:{noRead:\"You do not have permission to refresh profiles\",noCreate:\"You do not have permission to add profiles\",noUpdate:\"You do not have permission to update K-profiles\",noDelete:\"You do not have permission to delete K-profiles\",noExport:\"You do not have permission to export profiles\",noImport:\"You do not have permission to import profiles\"}},virtualPrinter:{title:\"Virtual Printer\",running:\"Running\",stopped:\"Stopped\",description:{default:\"Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer will be archived directly without printing.\",proxy:\"Enable a proxy that relays slicer traffic to a real printer, allowing remote printing over any network.\"},enable:{title:\"Enable Virtual Printer\",visibleInSlicer:'Visible as \"Bambuddy\" in slicer discovery',proxyingTo:\"Proxying to {{name}}\",notActive:\"Not active\"},model:{title:\"Printer Model\",description:\"Select which printer model to emulate.\",restartWarning:\"Changing the model will restart the virtual printer\"},accessCode:{title:\"Access Code\",isSet:\"Access code is set\",notSet:\"No access code set - required to enable\",placeholder:\"Enter 8-char code\",placeholderChange:\"Enter new code to change\",hint:\"Must be exactly 8 characters. Used by slicers to authenticate.\",charCount:\"({{count}}/8)\"},targetPrinter:{title:\"Target Printer\",configured:\"Proxy target configured\",notConfigured:\"No target printer selected - required for proxy mode\",placeholder:\"Select a printer...\",hint:\"Select the printer to proxy slicer traffic to. The printer must be in LAN mode.\",noPrinters:\"No printers configured. Add a printer first to use proxy mode.\"},remoteInterface:{title:\"Network Interface Override\",configured:\"Interface override active\",optional:\"Optional - use if auto-detected IP is wrong (e.g. multiple NICs, Docker, VPN)\",placeholder:\"Auto-detect (default)...\",hint:\"Override the IP address advertised via SSDP and used in the TLS certificate. Useful when Bambuddy has multiple network interfaces.\"},mode:{title:\"Mode\",archive:\"Archive\",archiveDesc:\"Archive files immediately\",review:\"Review\",reviewDesc:\"Review before archiving\",queue:\"Queue\",queueDesc:\"Archive and add to queue\",proxy:\"Proxy\",proxyDesc:\"Relay to real printer\"},autoDispatch:{title:\"Auto-dispatch\",description:\"Automatically start prints when added to queue. When off, prints wait for manual dispatch.\"},setupRequired:{title:\"Setup Required\",description:\"The virtual printer feature requires additional system configuration before it will work. This includes port forwarding, firewall rules, and platform-specific settings.\",readGuide:\"Read the setup guide before enabling\"},howItWorks:{title:\"How it works\",step1:\"On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.\",step2:'In Archive, Review, and Queue modes, use the \"Send\" button in your slicer to upload 3MF files to Bambuddy. The slicer will show \"Print success\" — the file is stored, not printed.',step3:\"In Proxy mode, the virtual printer relays all traffic to a real printer — prints start immediately as if connected directly.\"},status:{title:\"Status Details\",printerName:\"Printer Name\",model:\"Model\",serialNumber:\"Serial Number\",mode:\"Mode\",pendingFiles:\"Pending Files\",targetPrinter:\"Target Printer\",ftpPort:\"FTP Port\",mqttPort:\"MQTT Port\",ftpConnections:\"FTP Connections\",mqttConnections:\"MQTT Connections\"},toast:{updated:\"Virtual printer settings updated\",failedToUpdate:\"Failed to update settings\",accessCodeRequired:\"Please set an access code first\",targetPrinterRequired:\"Please select a target printer first\",bindIpRequired:\"Please set a bind IP first\",accessCodeEmpty:\"Access code cannot be empty\",accessCodeLength:\"Access code must be exactly 8 characters\",created:\"Virtual printer created\",failedToCreate:\"Failed to create virtual printer\",deleted:\"Virtual printer deleted\",failedToDelete:\"Failed to delete virtual printer\"},list:{title:\"Virtual Printers\",add:\"Add\",addFirst:\"Add Virtual Printer\",empty:\"No virtual printers configured. Add one to get started.\"},bindIp:{title:\"Bind Interface\",placeholder:\"Select interface...\",hint:\"Network interface for this virtual printer to bind to. Must be unique per printer.\"},proxy:{accessCodeHint:\"In proxy mode, use your target printer's access code in the slicer. The connection is forwarded transparently to the real printer.\"},addDialog:{title:\"Add Virtual Printer\",name:\"Name\",hint:\"You can configure access code, target printer, and other settings after creating.\",create:\"Create\"},deleteConfirm:{title:\"Delete Virtual Printer\",message:'Are you sure you want to delete \"{{name}}\"? This will stop all services for this printer.'}},modelViewer:{openInSlicer:\"Open in Slicer\",tabs:{model:\"3D Model\",gcode:\"G-code Preview\"},notAvailable:\"not available\",notSliced:\"not sliced\",plates:\"Plates\",allPlates:\"All Plates\",plateNumber:\"Plate {{number}}\",plateCount:\"{{count}} plate\",plateCount_other:\"{{count}} plates\",objectCount:\"{{count}} object\",objectCount_other:\"{{count}} objects\",filamentCount:\"{{count}} filament\",filamentCount_other:\"{{count}} filaments\",eta:\"ETA {{minutes}} min\",noPreview:\"No preview available for this file\",pagination:{pageOf:\"Page {{current}} of {{total}}\",prev:\"Prev\",next:\"Next\"},errors:{failedToLoad:\"Failed to load file\",noMeshes:\"No meshes found in 3MF file\",unsupportedFormat:\"Unsupported file format\"}},maintenanceDescriptions:{lubricateCarbonRods:\"Apply lubricant to carbon rods for smooth motion\",lubricateRails:\"Apply lubricant to linear rails for smooth motion\",cleanNozzle:\"Clean hotend and nozzle to prevent clogs\",checkBelts:\"Verify belt tension for accurate prints\",cleanBuildPlate:\"Clean build plate for better adhesion\",checkExtruder:\"Inspect extruder gears for wear\",checkCooling:\"Ensure cooling fans are working properly\",generalInspection:\"General printer inspection\",cleanCarbonRods:\"Clean carbon rods to reduce friction\",lubricateSteelRods:\"Apply lubricant to steel rods for smooth motion\",cleanSteelRods:\"Clean steel rods to reduce friction\",cleanLinearRails:\"Wipe linear rails to remove dust and debris\",checkPtfeTube:\"Inspect PTFE tube for wear or damage\",replaceHepaFilter:\"Replace HEPA filter for air quality\",replaceCarbonFilter:\"Replace activated carbon filter\",lubricateLeftNozzleRail:\"Lubricate left nozzle rail (H2 series)\"},smartPlugs:{offline:\"Offline\",admin:\"Admin\",openPlugAdminPage:\"Open plug admin page\",deleteSmartPlug:\"Delete Smart Plug\",turnOnSmartPlug:\"Turn On Smart Plug\",turnOffSmartPlug:\"Turn Off Smart Plug\",turnOn:\"Turn On\",turnOff:\"Turn Off\",addSmartPlug:{scanningNetwork:\"Scanning network...\",chooseEntity:\"Choose an entity...\",connectionFailed:\"Connection failed\",searchEntities:\"Search entities...\",searchPowerSensors:\"Search power sensors...\",searchEnergySensors:\"Search energy sensors...\",placeholders:{plugName:\"Living Room Plug\",mqttStateOnValue:\"ON, true, 1\",mqttSameAsPower:\"Same as power topic, or different\"}},linkedTo:\"Linked to:\",monitorOnly:\"Monitor Only\",alerts:\"Alerts\",scheduleOn:\"On {{time}}\",scheduleOff:\"Off {{time}}\",on:\"On\",off:\"Off\",power:\"Power\",kwhToday:\"kWh Today\",settings:\"Settings\",automationSettings:\"Automation Settings\",showInSwitchbar:\"Show in Switchbar\",quickAccessSidebar:\"Quick access from sidebar\",enabled:\"Enabled\",enableAutomation:\"Enable automation for this plug\",autoOn:\"Auto On\",autoOnDescription:\"Turn on when print starts\",autoOff:\"Auto Off\",autoOffDescription:\"Turn off when print completes (one-shot)\",autoOffPersistent:\"Keep Enabled\",autoOffPersistentDescription:\"Stay enabled between prints instead of one-shot\",turnOffDelayMode:\"Turn Off Delay Mode\",time:\"Time\",temp:\"Temp\",delayMinutes:\"Delay (minutes)\",tempThreshold:\"Temperature threshold (°C)\",tempThresholdDescription:\"Turns off when nozzle cools below this temperature\",edit:\"Edit\",deleteConfirm:'Are you sure you want to delete \"{{name}}\"? This cannot be undone.',turnOnConfirm:'Are you sure you want to turn on \"{{name}}\"?',turnOffConfirm:'Are you sure you want to turn off \"{{name}}\"? This will cut power to the connected device.',failedToTurn:'Failed to turn {{action}} \"{{name}}\"',unknown:\"Unknown\",addTitle:\"Add Smart Plug\",editTitle:\"Edit Smart Plug\",stopScanning:\"Stop Scanning\",discoverTasmota:\"Discover Tasmota Devices\",foundDevices:\"Found {{count}} device(s) - click to select:\",noDevicesFound:\"No Tasmota devices found on your network\",haNotConfigured:\"Home Assistant is not configured. Set it up in\",haSettingsPath:\"Settings → Network → Home Assistant\",selectEntity:\"Select Entity *\",ipAddress:\"IP Address *\",nameLabel:\"Name *\",username:\"Username\",password:\"Password\",authHint:\"Leave empty if your Tasmota device doesn't require authentication\",linkToPrinter:\"Link to Printer\",noPrinter:\"No printer (manual control only)\",linkingDescription:\"Linking enables automatic on/off when prints start/complete\",powerAlerts:\"Power Alerts\",alertAbove:\"Alert if above (W)\",alertBelow:\"Alert if below (W)\",alertDescription:\"Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.\",dailySchedule:\"Daily Schedule\",turnOnAt:\"Turn On at\",turnOffAt:\"Turn Off at\",scheduleDescription:\"Automatically turn the plug on/off at these times daily. Leave empty to skip that action.\",showOnPrinterCard:\"Show on Printer Card\",displayOnPrinterCard:\"Display button on printer card\",connectedResult:\"Connected!\",deviceLabel:\"Device: {{name}} - \",stateLabel:\"State: {{state}}\",test:\"Test\",delete:\"Delete\",save:\"Save\",add:\"Add\",cancel:\"Cancel\",failedToStartScan:\"Failed to start scan\",nameRequired:\"Name is required\",entityRequired:\"Entity is required for Home Assistant plugs\",mqttTopicRequired:\"At least one MQTT topic must be configured for power, energy, or state monitoring\",loadingEntities:\"Loading entities...\",loading:\"Loading...\",failedToLoadEntities:\"Failed to load entities: {{error}}\",noEntitiesMatching:'No entities found matching \"{{search}}\"',noEntitiesAvailable:\"No entities available\",searchingEntities:\"Searching all entities ({{count}} found)\",showingEntities:\"Showing switch, light, input_boolean ({{count}} available)\",energyMonitoringOptional:\"Energy Monitoring (Optional)\",energyMonitoringHint:\"Search and select sensors that provide power/energy data.\",powerSensorW:\"Power Sensor (W)\",energyTodayKwh:\"Energy Today (kWh)\",totalEnergyKwh:\"Total Energy (kWh)\",noMatchingSensors:\"No matching sensors\",none:\"None\",mqttNotConfigured:\"MQTT broker not configured. Set broker address in\",mqttSettingsPath:\"Settings → Network → MQTT Publishing\",mqttNotConfiguredSuffix:\"(you don't need to enable publishing, just fill in the broker details).\",mqttMonitorOnlyDescription:\"MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system.\",powerMonitoring:\"Power Monitoring\",energyMonitoring:\"Energy Monitoring\",stateMonitoring:\"State Monitoring\",optional:\"optional\",topic:\"Topic\",jsonPath:\"JSON Path\",multiplier:\"Multiplier\",onValue:\"ON Value\",mqttPowerHint:`JSON path extracts value from JSON payload (e.g., \"power_l1\"). Leave empty if topic publishes raw numeric values.\nUse multiplier 0.001 for mW→W, 1000 for kW→W.`,mqttEnergyHint:`JSON path extracts value from JSON payload. Leave empty for raw values.\nUse multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.`,mqttStateHint:`JSON path extracts value from JSON payload. Leave empty for raw values.\nON value: the exact string that means \"ON\". Leave empty for auto-detect (ON, true, 1).`,restControl:\"Control\",restOnUrl:\"Turn ON URL\",restOffUrl:\"Turn OFF URL\",restOnBody:\"ON Request Body\",restOffBody:\"OFF Request Body\",restMethod:\"HTTP Method\",restHeaders:\"Custom Headers (JSON)\",restStatusUrl:\"Status URL\",restStatusPath:\"State JSON Path\",restStatusOnValue:\"ON Value\",restPowerUrl:\"Power URL\",restPowerPath:\"Power JSON Path\",restPowerMultiplier:\"Power Multiplier\",restEnergyUrl:\"Energy URL\",restEnergyPath:\"Energy JSON Path\",restEnergyMultiplier:\"Energy Multiplier\",restUrlRequired:\"At least one URL (ON or OFF) is required for REST plugs\",restHeadersHint:'e.g. {\"Authorization\": \"Bearer your-token\"}',restBodyHint:'e.g. ON, {\"state\": \"on\"}',restStatusHint:\"URL to poll for current state\",restPathHint:\"e.g. state or data.power.status\",restPowerUrlHint:\"Separate URL for power data (uses Status URL if empty)\",restEnergyUrlHint:\"Separate URL for energy data (uses Status URL if empty)\",restEnergyHint:\"Each value can use its own URL or fall back to the Status URL. Use multipliers for unit conversion (e.g. 0.001 to convert Wh to kWh).\",testConnection:\"Test Connection\",connectionSuccess:\"Connection successful\",noSwitchesInSwitchbar:\"No switches in switchbar\",enableSwitchbarHint:'Enable \"Show in Switchbar\" in Settings > Smart Plugs'},notifications:{providerTypes:{callmebot:\"CallMeBot/WhatsApp\",ntfy:\"ntfy\",pushover:\"Pushover\",telegram:\"Telegram\",email:\"Email\",discord:\"Discord\",webhook:\"Webhook\",homeassistant:\"Home Assistant\"},providerDescriptions:{email:\"SMTP email notifications\",telegram:\"Notifications via Telegram bot\",discord:\"Send to Discord channel via webhook\",ntfy:\"Free, self-hostable push notifications\",pushover:\"Simple, reliable push notifications\",callmebot:\"Free WhatsApp notifications via CallMeBot\",webhook:\"Generic HTTP POST to any URL\",homeassistant:\"Persistent notifications in Home Assistant dashboard\"},lastSuccess:\"Last: {{date}}\",error:\"Error\",printer:\"Printer:\",allPrinters:\"All printers\",sendTestNotification:\"Send Test Notification\",eventSettings:\"Event Settings\",enabled:\"Enabled\",sendFromProvider:\"Send notifications from this provider\",printEvents:\"Print Events\",printerStatus:\"Printer Status\",amsAlarms:\"AMS Alarms\",amsHtAlarms:\"AMS-HT Alarms\",printQueue:\"Print Queue\",start:\"Start\",plateCheck:\"Plate Check\",complete:\"Complete\",failed:\"Failed\",stopped:\"Stopped\",progress:\"Progress\",offline:\"Offline\",lowFilament:\"Low Filament\",maintenance:\"Maintenance\",amsHumidity:\"AMS Humidity\",amsTemp:\"AMS Temp\",amsHtHumidity:\"AMS-HT Humidity\",amsHtTemp:\"AMS-HT Temp\",bedCooled:\"Bed Cooled\",firstLayer:\"First Layer\",quiet:\"Quiet\",digest:\"Digest {{time}}\",printStarted:\"Print Started\",plateNotEmpty:\"Plate Not Empty\",plateNotEmptyDescription:\"Objects detected before print\",printCompleted:\"Print Completed\",bedCooledLabel:\"Bed Cooled\",bedCooledDescription:\"Bed cooled below threshold after print\",firstLayerCompleteLabel:\"First Layer Complete\",firstLayerCompleteDescription:\"Notify with snapshot when first layer finishes\",missingSpoolAssignmentLabel:\"Missing Spool Assignment\",missingSpoolAssignmentDescription:\"Notify when print starts and required trays have no assigned spool\",printFailed:\"Print Failed\",printStopped:\"Print Stopped\",progressMilestones:\"Progress Milestones\",progressMilestonesDescription:\"Notify at 25%, 50%, 75%\",printerOffline:\"Printer Offline\",printerError:\"Printer Error\",lowFilamentLabel:\"Low Filament\",maintenanceDue:\"Maintenance Due\",maintenanceDueDescription:\"Notify when maintenance is needed\",amsHumidityHigh:\"AMS Humidity High\",amsHumidityHighDescription:\"Regular AMS humidity exceeds threshold\",amsTemperatureHigh:\"AMS Temperature High\",amsTemperatureHighDescription:\"Regular AMS temperature exceeds threshold\",amsHtHumidityHigh:\"AMS-HT Humidity High\",amsHtHumidityHighDescription:\"AMS-HT humidity exceeds threshold\",amsHtTemperatureHigh:\"AMS-HT Temperature High\",amsHtTemperatureHighDescription:\"AMS-HT temperature exceeds threshold\",jobAdded:\"Job Added\",jobAddedDescription:\"Job added to queue\",jobAssigned:\"Job Assigned\",jobAssignedDescription:\"Model-based job assigned to printer\",jobStarted:\"Job Started\",jobStartedDescription:\"Queue job started printing\",jobWaiting:\"Job Waiting\",jobWaitingDescription:\"Job waiting for filament or printer\",jobSkipped:\"Job Skipped\",jobSkippedDescription:\"Job skipped (previous failed)\",jobFailed:\"Job Failed\",jobFailedDescription:\"Job failed to start\",queueComplete:\"Queue Complete\",queueCompleteDescription:\"All queue jobs finished\",quietHours:\"Quiet Hours\",noNotificationsDuring:\"No notifications during these hours\",editProviderToChangeQuietHours:\"Edit provider to change quiet hours\",dailyDigest:\"Daily Digest\",batchNotifications:\"Batch notifications into a single daily summary\",sendAt:\"Send at {{time}}\",editProviderToChangeDigestTime:\"Edit provider to change digest time\",edit:\"Edit\",deleteProvider:\"Delete Notification Provider\",deleteConfirm:'Are you sure you want to delete \"{{name}}\"? This cannot be undone.',delete:\"Delete\",addTitle:\"Add Notification Provider\",editTitle:\"Edit Notification Provider\",nameLabel:\"Name *\",namePlaceholder:\"My Notifications\",providerTypeLabel:\"Provider Type *\",configuration:\"Configuration\",testConfiguration:\"Test Configuration\",printerFilter:\"Printer Filter\",onlyFromPrinter:\"Only send notifications for events from this printer\",quietHoursDnd:\"Quiet Hours (Do Not Disturb)\",quietStart:\"Start\",quietEnd:\"End\",dailyDigestLabel:\"Daily Digest\",sendDigestAt:\"Send digest at\",digestCollected:\"Events will be collected and sent as a single summary at this time\",notificationEvents:\"Notification Events\",progressPercent:\"(25%, 50%, 75%)\",bedCooledAfterPrint:\"(after print completes)\",cancel:\"Cancel\",save:\"Save\",add:\"Add\",nameRequired:\"Name is required\",fieldRequired:\"{{field}} is required\",phoneNumber:\"Phone Number\",apiKey:\"API Key\",serverUrl:\"Server URL\",topic:\"Topic\",authToken:\"Auth Token\",userKey:\"User Key\",appToken:\"App Token\",priority:\"Priority\",botToken:\"Bot Token\",chatId:\"Chat ID\",smtpServer:\"SMTP Server\",smtpPort:\"SMTP Port\",security:\"Security\",authentication:\"Authentication\",username:\"Username\",password:\"Password\",fromEmail:\"From Email\",toEmail:\"To Email\",webhookUrl:\"Webhook URL\",payloadFormat:\"Payload Format\",authorization:\"Authorization\",titleFieldName:\"Title Field Name\",messageFieldName:\"Message Field Name\",editTemplate:\"Edit Template: {{name}}\",titleLabel:\"Title\",bodyLabel:\"Body\",titlePlaceholder:\"Notification title...\",bodyPlaceholder:\"Notification body...\",availableVariables:\"Available Variables\",clickToInsert:\"Click to insert at cursor position in body\",livePreview:\"Live Preview\",hide:\"Hide\",show:\"Show\",loadingPreview:\"Loading preview...\",enterTemplateContent:\"Enter template content to see preview\",titlePreview:\"Title:\",bodyPreview:\"Body:\",resetToDefault:\"Reset to Default\",titleRequired:\"Title is required\",bodyRequired:\"Body is required\",notificationLog:\"Notification Log\",showFailedOnly:\"Failed only\",last24Hours:\"Last 24 hours\",last7Days:\"Last 7 days\",last30Days:\"Last 30 days\",last90Days:\"Last 90 days\",justNow:\"Just now\",noFailedNotifications:\"No failed notifications\",noNotificationsLogged:\"No notifications logged\",unknownProvider:\"Unknown Provider\",logTitle:\"Title\",logMessage:\"Message\",logError:\"Error\",logProvider:\"Provider: {{type}}\",logTime:\"Time: {{time}}\",refresh:\"Refresh\",clearOld:\"Clear Old\",statsSummary:\"Last {{days}} days:\",statsNotifications:\"notifications\",statsSent:\"{{count}} sent\",statsFailed:\"{{count}} failed\",eventTypes:{print_start:\"Print Started\",print_complete:\"Print Complete\",print_failed:\"Print Failed\",print_stopped:\"Print Stopped\",print_progress:\"Progress\",printer_offline:\"Printer Offline\",printer_error:\"Printer Error\",filament_low:\"Low Filament\",maintenance_due:\"Maintenance Due\",test:\"Test\"},userEmail:{title:\"Notifications\",emailNotifications:\"Email Notifications\",emailNotificationsDesc:\"Receive email notifications for your own print jobs. Emails are sent using the system SMTP settings configured in Advanced Authentication.\",sendingTo:\"Notifications will be sent to\",noEmailWarning:\"Your account does not have an email address. Contact an administrator to add one.\",printJobNotifications:\"Print Job Notifications\",printJobNotificationsDesc:\"Choose which events trigger email notifications for print jobs you submit.\",printJobStarts:\"Print Job Starts\",printJobStartsDesc:\"Get notified when your print job begins.\",printJobFinishes:\"Print Job Finishes\",printJobFinishesDesc:\"Get notified when your print job completes successfully.\",printErrors:\"Print Errors\",printErrorsDesc:\"Get notified when your print job fails or encounters an error.\",printJobStops:\"Print Job Stops\",printJobStopsDesc:\"Get notified when your print job is cancelled or stopped.\",saveSuccess:\"Notification preferences saved.\",saveError:\"Failed to save notification preferences.\"}},richTextEditor:{bold:\"Bold\",italic:\"Italic\",underline:\"Underline\",bulletList:\"Bullet List\",numberedList:\"Numbered List\",alignLeft:\"Align Left\",alignCenter:\"Align Center\",alignRight:\"Align Right\",addLink:\"Add Link\",removeLink:\"Remove Link\"},externalLinks:{noLinksConfigured:\"No external links configured\",deleteLink:\"Delete Link\",removeCustomIcon:\"Remove custom icon\",openInNewTab:\"Open in new tab\",placeholders:{linkName:\"My Link\"}},keyboardShortcuts:{title:\"Keyboard Shortcuts\",navigation:\"Navigation\",archivesSection:\"Archives\",kProfilesSection:\"K-Profiles\",generalSection:\"General\",shortcuts:{goToPrinters:\"Go to Printers\",goToArchives:\"Go to Archives\",goToQueue:\"Go to Queue\",goToStats:\"Go to Statistics\",goToProfiles:\"Go to Cloud Profiles\",goToSettings:\"Go to Settings\",focusSearch:\"Focus search\",openUploadModal:\"Open upload modal\",clearSelection:\"Clear selection / blur input\",contextMenu:\"Context menu on cards\",refreshProfiles:\"Refresh profiles\",newProfile:\"New profile\",exitSelectionMode:\"Exit selection mode\",showHelp:\"Show this help\"},footer:\"Press Esc or click outside to close\"},notificationLog:{title:\"Notification Log\",events:{printStarted:\"Print Started\",printComplete:\"Print Complete\",printFailed:\"Print Failed\",printStopped:\"Print Stopped\",progress:\"Progress\",printerOffline:\"Printer Offline\",printerError:\"Printer Error\",lowFilament:\"Low Filament\",maintenanceDue:\"Maintenance Due\",test:\"Test\"},timeAgo:{justNow:\"Just now\",minutesAgo:\"{{minutes}}m ago\",hoursAgo:\"{{hours}}h ago\"}},restoreBackup:{title:\"Restore Backup\",restoring:\"Restoring...\",restoreComplete:\"Restore Complete\",restoreFailed:\"Restore Failed\",importSettings:\"Import settings from a backup file\",pleaseWait:\"Please wait while your data is being restored\",clickToSelect:\"Click to select backup file (.json or .zip)\",howDuplicateHandling:\"How duplicate handling works:\",categories:{printers:\"Printers\",smartPlugs:\"Smart Plugs\",notificationProviders:\"Notification Providers\",filaments:\"Filaments\",archives:\"Archives\",pendingUploads:\"Pending Uploads\",settingsTemplates:\"Settings & Templates\"},matchingInfo:{printers:\"matched by serial number\",smartPlugs:\"matched by IP address\",notificationProviders:\"matched by name\",filaments:\"matched by name + type + brand\",archives:\"matched by content hash\",pendingUploads:\"matched by filename\",settingsTemplates:\"always overwritten\"},replaceExisting:\"Replace existing data\",keepExisting:\"Keep existing data\",replaceDescription:\"Overwrite items that already exist with backup data\",keepDescription:\"Only restore items that don't already exist\",caution:\"Caution:\",cautionText:\"Overwriting will replace your current configurations with backup data. Printer access codes are never overwritten for security.\",itemsRestored:\"Items Restored\",itemsSkipped:\"Items Skipped\",restored:\"Restored\",skipped:\"Skipped (already exist)\",filesLabel:\"Files (3MF, thumbnails, etc.)\",newApiKeysGenerated:\"New API Keys Generated\",newApiKeysWarning:\"These keys are only shown once. Copy them now!\",processingBackup:\"Processing backup file...\",noDataFound:\"No data was found to restore in the backup file.\",failedToRestore:\"Failed to restore backup. Please check the file format.\"},backupExport:{title:\"Export Backup\",selectData:\"Select data to include\",selectAll:\"Select All\",selectNone:\"Select None\",categoryDescriptions:{settings:\"Language, theme, update preferences\",notifications:\"ntfy, Pushover, Discord, etc.\",templates:\"Custom message templates\",smartPlugs:\"Tasmota plug configurations\",externalLinks:\"Sidebar links to external services\",printers:\"Printer info (access codes excluded)\",plateDetection:\"Empty plate reference images\",filaments:\"Filament types and costs\",maintenance:\"Custom maintenance schedules\",archives:\"All print data + files (3MF, thumbnails, photos)\",projects:\"Projects, BOM items, and attachments\",pendingUploads:\"Virtual printer uploads awaiting review\",apiKeys:\"Webhook API keys (new keys generated on import)\"},requiresPrinters:\"Requires Printers to be selected\",zipFileWarning:\"ZIP file will be created.\",zipFileDescription:\"Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.\",includeAccessCodes:\"Include Access Codes\",includeAccessCodesDescription:\"For transferring to another machine\",includeAccessCodesWarning:\"Access codes will be included in plain text. Keep this backup file secure!\",categoriesSelected:\"{{selectedCount}} categories selected\"},pendingUploads:{placeholders:{notes:\"Add notes about this print...\"},discardUpload:\"Discard Upload\",archiveAllUploads:\"Archive All Uploads\",discardAllUploads:\"Discard All Uploads\",archive:\"Archive\",timeAgo:{justNow:\"Just now\",minutesAgo:\"{{minutes}}m ago\",hoursAgo:\"{{hours}}h ago\",daysAgo:\"{{days}}d ago\"}},apiBrowser:{placeholders:{requestBody:\"JSON request body...\",searchEndpoints:\"Search endpoints...\"}},configureAmsSlot:{title:\"Configure AMS Slot\",slotConfigured:\"Slot Configured!\",configuringSlot:\"Configuring slot:\",slotLabel:\"{{ams}} Slot {{slot}}\",searchPresets:\"Search presets...\",colorPlaceholder:\"Color name or hex (e.g., brown, FF8800)\",clearCustomColor:\"Clear custom color\",noCloudPresets:\"No cloud presets. Login to Bambu Cloud to sync.\",noPresetsAvailable:\"No presets available. Login to Bambu Cloud or import local profiles.\",noMatchingPresets:\"No matching presets found.\",custom:\"Custom\",builtin:\"Built-in\",settingsSentToPrinter:\"Settings sent to printer\",filamentProfile:\"Filament Profile\",kProfileLabel:\"K Profile (Pressure Advance)\",filteringFor:\"Filtering for: {{material}}\",noKProfile:\"No K profile (use default 0.020)\",noMatchingKProfiles:\"No matching K profiles found. Default K=0.020 will be used.\",selectFilamentFirst:\"Select a filament profile first\",kFromCalibration:\"K={{value}} from printer calibration\",customColorLabel:\"Custom Color (optional)\",presetColors:\"{{name}} colors:\",showLessColors:\"Show less colors\",showMoreColors:\"Show more colors\",clear:\"Clear\",hexLabel:\"Hex: #{{hex}}\",resetting:\"Resetting...\",resetSlot:\"Reset Slot\",cancel:\"Cancel\",configuring:\"Configuring...\",configureSlot:\"Configure Slot\"},githubBackup:{title:\"GitHub Backup\",history:\"History\",downloadBackup:\"Download Backup\",restoreBackup:\"Restore Backup\",noBackupsYet:\"No backups yet\"},emailSettings:{placeholders:{fromName:\"BamBuddy\"}},tagManagement:{searchTags:\"Search tags...\",renameTag:\"Rename tag\",deleteTag:\"Delete tag\"},notificationTemplates:{placeholders:{title:\"Notification title...\",body:\"Notification body...\"}},batchTag:{placeholders:{newTag:\"Enter new tag...\"}},photoGallery:{deletePhoto:\"Delete Photo\"},filamentHoverCard:{copySpoolUuid:\"Copy spool UUID\"},kProfilesView:{hasNote:\"Has note\",copyProfile:\"Copy profile\"},layout:{openMenu:\"Open menu\",noPermissionSystemInfo:\"You do not have permission to view system information\"},dashboard:{dragToReorder:\"Drag to reorder\",hideWidget:\"Hide widget\"},notificationProviderCard:{deleteNotificationProvider:\"Delete Notification Provider\"},fileManagerModal:{closeFileManager:\"Close file manager\",sortFiles:\"Sort files\",goToParentFolder:\"Go to parent folder\",threeView:\"3D View\"},embeddedCameraViewer:{refreshStream:\"Refresh stream\",close:\"Close\",zoomOut:\"Zoom out\",resetZoom:\"Reset zoom\",zoomIn:\"Zoom in\",dragToResize:\"Drag to resize\"},timelapseViewer:{skipBack5s:\"Skip back 5s\",skipForward5s:\"Skip forward 5s\"},notificationProviders:{descriptions:{email:\"SMTP email notifications\",telegram:\"Notifications via Telegram bot\",discord:\"Send to Discord channel via webhook\",ntfy:\"Free, self-hostable push notifications\",pushover:\"Simple, reliable push notifications\",callmebot:\"Free WhatsApp notifications via CallMeBot\",webhook:\"Generic HTTP POST to any URL\"}},logViewer:{searchPlaceholder:\"Search message or logger name...\",noLogEntries:\"No log entries found\"},switchbarPopover:{noSwitchesInSwitchbar:\"No switches in switchbar\"},projectPageModal:{placeholders:{title:\"Title\",designer:\"Designer\",license:\"License\",description:\"Enter description...\",profileTitle:\"Profile Title\",profileDescription:\"Profile description...\"}},spoolmanSettings:{},time:{unknown:\"-\",waiting:\"Waiting\",justNow:\"Just now\",now:\"Now\",minsAgo:\"{{count}}m ago\",inMins:\"in {{count}}m\",hoursAgo:\"{{count}}h ago\",inHours:\"in {{count}}h\",daysAgo:\"{{count}}d ago\",inDays:\"in {{count}}d\"},spoolbuddy:{nav:{dashboard:\"Dashboard\",ams:\"AMS\",inventory:\"Inventory\",writeTag:\"Write\",settings:\"Settings\"},status:{nfcReady:\"NFC Ready\",nfcOff:\"NFC Off\",offline:\"Offline\",online:\"Online\",noPrinters:\"No printers\",deviceOffline:\"Device Offline\",waitingConnection:\"Waiting for device connection...\",systemReady:\"System Ready\",status:\"Status\"},dashboard:{readyToScan:\"Ready to scan\",idleMessage:\"Place a spool on the scale to identify it\",nfcHint:\"NFC tag will be read automatically\",device:\"Device\",syncWeight:\"Sync Weight\",weightSynced:\"Synced!\",unknownTag:\"Unknown Tag\",newTag:\"New Tag Detected\",onScale:\"on scale\",linkSpool:\"Link to Spool\",linkTagTitle:\"Link Tag to Spool\",linkTag:\"Link Tag\",selectSpool:\"Select a spool to link this tag to:\",noUntagged:\"No spools without tags found\",tagDetected:\"Tag detected\",noTag:\"No tag\",tagId:\"Tag\",grossWeight:\"Gross weight\",spoolSize:\"Spool size\",close:\"Close\",currentSpool:\"Current Spool\"},modal:{spoolDetected:\"Spool Detected\",assignToAms:\"Assign to AMS\",syncWeight:\"Sync Weight\",weightSynced:\"Synced!\",syncing:\"Syncing...\",newTagDetected:\"New Tag Detected\",addToInventory:\"Add to Inventory\",assignToAmsTitle:\"Assign to AMS\",selectSlot:\"Select a slot\",assign:\"Assign\",assigning:\"Assigning...\",assignSuccess:\"Assigned!\",assignError:\"Failed to assign spool. Please try again.\",noPrinterSelected:\"Select a printer...\",noAmsDetected:\"No AMS detected on this printer\",slot:\"Slot\"},weight:{noReading:\"No reading\",stable:\"Stable\",measuring:\"Measuring...\",tare:\"Tare\",calibrate:\"Calibrate\"},spool:{remaining:\"Remaining\",material:\"Material\",brand:\"Brand\",color:\"Color\",coreWeight:\"Core\",labelWeight:\"Label\",scaleWeight:\"Scale\",netWeight:\"Net\",lastUsed:\"Last used\"},ams:{noData:\"No AMS detected\",connectAms:\"Connect an AMS to see filament slots\",noPrinter:\"No printer selected\",selectPrinter:\"Select a printer from the top bar\",printerDisconnected:\"Printer disconnected\",humidity:\"Humidity\",level:\"Level\",active:\"Active\",slot:\"Slot\",empty:\"Empty\"},inventory:{search:\"Search spools...\",empty:\"No spools in inventory\",noResults:\"No matching spools\",spools:\"spools\",addSpool:\"Add Spool\"},settings:{tabDevice:\"Device\",tabDisplay:\"Display\",tabScale:\"Scale\",tabUpdates:\"Updates\",nfcReader:\"NFC Reader\",type:\"Type\",connection:\"Connection\",notConnected:\"N/A\",deviceInfo:\"Device Info\",hostname:\"Host\",uptime:\"Uptime\",systemConfig:\"Backend & Auth\",backendUrl:\"Bambuddy Backend URL\",apiToken:\"API Token\",apiTokenPlaceholder:\"Enter API token\",saveConfig:\"Save Config\",systemQueued:\"Config queued.\",nfcDiagnostic:\"NFC Diagnostic\",scaleDiagnostic:\"Scale Diagnostic\",readTagDiagnostic:\"Read Tag Diagnostic\",testNfc:\"Test reader\",testScale:\"Test accuracy\",testReadTag:\"Read tag\",systemFieldsRequired:\"Backend URL is required.\",brightness:\"Brightness\",saved:\"Saved\",noBacklight:\"No DSI backlight detected. Brightness control requires a DSI display.\",screenBlank:\"Screen Blank Timeout\",screenBlankDesc:\"Screen turns off after inactivity. Touch to wake.\",displayNote:\"Brightness is applied as a software filter.\",scaleCalibration:\"Scale Calibration\",currentWeight:\"Current weight\",tareOffset:\"Tare\",calFactor:\"Factor\",knownWeight:\"Known weight\",calStep1:\"Remove all items from the scale and press Set Zero.\",calStep2:\"Place known weight on scale.\",setZero:\"Set Zero\",calibrateNow:\"Calibrate\",calibrated:\"Calibrated\",tareSet:\"Tare command sent. Waiting for device...\",tareFailed:\"Failed to send tare command\",zeroSet:\"Zero point set. Place known weight on scale.\",calibrationDone:\"Calibration complete!\",calibrationFailed:\"Calibration failed\",lastCalibrated:\"Last calibrated\",stable:\"Stable\",settling:\"Settling...\",firmware:\"Firmware\",scale:\"Scale\",noDevice:\"No SpoolBuddy device found\",daemonVersion:\"Daemon Version\",currentVersion:\"Current\",versionPending:\"Waiting for daemon...\",checking:\"Checking...\",checkUpdates:\"Check for Updates\",updateAvailable:\"Update available\",updateInstructions:\"Update via SSH: run the SpoolBuddy install script to upgrade.\",upToDate:\"Up to date\",includeBeta:\"Include beta versions\"},writeTag:{tabExisting:\"Existing Spool\",tabNew:\"New Spool\",tabReplace:\"Replace Tag\",searchPlaceholder:\"Search by material, color, brand...\",noUntaggedSpools:\"No spools without tags\",noTaggedSpools:\"No spools with tags\",selectSpool:\"Select a spool, then place a blank NTAG on the reader\",placeTag:\"Place an NTAG on the reader\",tagReady:\"Tag detected — ready to write\",writeTag:\"Write Tag\",replaceTag:\"Replace Tag\",writing:\"Writing tag...\",waiting:\"Waiting for SpoolBuddy...\",writeSuccess:\"Tag written successfully!\",writeFailed:\"Write failed\",queueFailed:\"Failed to queue write command\",tryAgain:\"Try Again\",cancel:\"Cancel\",replaceWarning:\"Old tag will be unlinked. New tag will replace it.\",deviceOffline:\"SpoolBuddy is offline\",material:\"Material\",colorName:\"Color Name\",color:\"Color\",brand:\"Brand\",weight:\"Weight (g)\",createSpool:\"Create Spool\",creating:\"Creating...\",spoolCreated:\"Spool created! Ready to write.\",createFailed:\"Failed to create spool\"},quickMenu:{printerPower:\"Printer Power\",systemControls:\"System\",restartDaemon:\"Restart Daemon\",restartBrowser:\"Restart Browser\",reboot:\"Reboot\",shutdown:\"Shutdown\",swipeToClose:\"Swipe down to close\",confirmTitle:\"Confirm\",confirmShutdown:\"Are you sure you want to shut down the SpoolBuddy? You will need physical access to turn it back on.\",confirmReboot:\"Are you sure you want to reboot the SpoolBuddy?\",confirmRestartDaemon:\"Restart the SpoolBuddy daemon? NFC and scale will be temporarily unavailable.\",confirmRestartBrowser:\"Restart the kiosk browser? The display will briefly go blank.\",confirm:\"Confirm\",confirmPlugOn:\"Turn on {{name}}?\",confirmPlugOff:\"Turn off {{name}}?\",turnOn:\"Turn On\",turnOff:\"Turn Off\"}},bugReport:{title:\"Report a Bug\",description:\"Description\",descriptionPlaceholder:\"What went wrong? Please describe the issue...\",email:\"Email (optional)\",emailPlaceholder:\"your@email.com\",emailPrivacy:\"If provided, your email will be included in a collapsed section of the GitHub issue so the maintainer can follow up.\",screenshot:\"Screenshot\",uploadOrPaste:\"Upload, paste, or drag an image\",dataCollectedSummary:\"What data is included in the report?\",dataIncluded:\"Included:\",dataIncludedList:\"App version, OS, architecture, Python version, database stats (counts only), printer models, nozzle counts, firmware versions, connectivity status, integration status (Spoolman, MQTT, HA), non-sensitive settings, network interface count, Docker details, dependency versions.\",dataNeverIncluded:\"Never included:\",dataNeverIncludedList:\"Printer names, serial numbers, access codes, passwords, IP addresses, email addresses, API keys, tokens, webhook URLs, hostnames, or usernames.\",submit:\"Submit\",startLogging:\"Start Debug Logging\",stepEnableLogging:\"Debug logging enabled\",stepReproduce:\"Reproduce the issue now\",stepStopLogging:\"Stop & submit report\",stopAndSubmit:\"Stop & Submit\",maxDuration:\"Auto-stops after {{minutes}} min\",stoppingLogs:\"Collecting logs & submitting...\",submitting:\"Submitting bug report...\",submitSuccess:\"Bug report submitted successfully!\",submitFailed:\"Failed to submit bug report\",thankYou:\"Thank you!\",submitted:\"Your bug report has been submitted.\",viewIssue:\"View Issue\",unexpectedError:\"An unexpected error occurred\"},failureDetection:{title:\"AI Failure Detection\",description:\"Monitor prints with a self-hosted Obico ML API and act on detected failures automatically.\",mlUrl:\"Obico ML API URL\",mlUrlHint:\"Base URL of your self-hosted Obico ml_api container (e.g. http://192.168.1.10:3333).\",test:\"Test\",testSuccess:\"ML API reachable and healthy.\",testFailed:\"Could not reach the ML API.\",sensitivity:\"Sensitivity\",sensitivityLow:\"Low (fewer false positives)\",sensitivityMedium:\"Medium (balanced)\",sensitivityHigh:\"High (detect early, more false positives)\",sensitivityHint:\"Adjusts the confidence thresholds that trigger warnings and failures.\",action:\"Action on detected failure\",actionNotify:\"Notify only\",actionPause:\"Pause print\",actionPauseOff:\"Pause and cut power\",pollInterval:\"Poll interval (seconds)\",pollIntervalHint:\"How often to check each printer while it is printing. Minimum 5s, maximum 120s.\",externalUrlMissing:\"External URL is not set.\",externalUrlHint:\"The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.\",perPrinterTitle:\"Monitored Printers\",perPrinterHint:\"Choose which printers the detection service watches.\",monitorAll:\"Monitor all connected printers\",statusTitle:\"Status\",serviceRunning:\"Service running\",thresholds:\"Low / High thresholds\",activePrinters:\"Active prints\",noActivePrints:\"No prints currently running.\",historyTitle:\"Recent Detections\",noHistory:\"No detections yet.\"}},Vue={nav:{printers:\"Drucker\",archives:\"Archiv\",queue:\"Warteschlange\",stats:\"Statistiken\",profiles:\"Profile\",maintenance:\"Wartung\",projects:\"Projekte\",inventory:\"Filament\",files:\"Dateimanager\",notifications:\"Benachrichtigungen\",settings:\"Einstellungen\",system:\"System\",collapseSidebar:\"Seitenleiste einklappen\",expandSidebar:\"Seitenleiste ausklappen\",update:\"Update\",updateAvailable:\"Update verfügbar: v{{version}}\",updateAvailableBanner:\"Version {{version}} ist verfügbar!\",viewUpdate:\"Update anzeigen\",viewOnGithub:\"Auf GitHub ansehen\",keyboardShortcuts:\"Tastaturkürzel (?)\",switchToLight:\"Zum hellen Modus wechseln\",switchToDark:\"Zum dunklen Modus wechseln\",smartSwitches:\"Smart Switches\",logout:\"Abmelden\"},common:{save:\"Speichern\",saving:\"Speichern...\",cancel:\"Abbrechen\",delete:\"Löschen\",edit:\"Bearbeiten\",add:\"Hinzufügen\",close:\"Schließen\",confirm:\"Bestätigen\",loading:\"Lädt...\",error:\"Fehler\",success:\"Erfolg\",warning:\"Warnung\",enabled:\"Aktiviert\",disabled:\"Deaktiviert\",yes:\"Ja\",no:\"Nein\",on:\"An\",off:\"Aus\",all:\"Alle\",none:\"Keine\",search:\"Suchen\",filter:\"Filtern\",sort:\"Sortieren\",refresh:\"Aktualisieren\",download:\"Herunterladen\",upload:\"Hochladen\",uploading:\"Hochladen...\",uploadFailed:\"Hochladen fehlgeschlagen\",actions:\"Aktionen\",status:\"Status\",name:\"Name\",description:\"Beschreibung\",date:\"Datum\",time:\"Zeit\",hours:\"Stunden\",minutes:\"Minuten\",seconds:\"Sekunden\",days:\"Tage\",enable:\"Aktivieren\",disable:\"Deaktivieren\",permissions:\"Berechtigungen\",noPrinters:\"Keine Drucker konfiguriert\",noData:\"Keine Daten verfügbar\",linkNotFound:\"Link nicht gefunden\",required:\"Erforderlich\",optional:\"Optional\",dismiss:\"Schließen\",apply:\"Anwenden\",reset:\"Zurücksetzen\",export:\"Exportieren\",import:\"Importieren\",clear:\"Leeren\",selectAll:\"Alle auswählen\",deselectAll:\"Auswahl aufheben\",noChange:\"— Keine Änderung —\",unchanged:\"Unverändert\",unassigned:\"Nicht zugewiesen\",unknown:\"Unbekannt\",unknownError:\"Unbekannter Fehler\",today:\"Heute\",tomorrow:\"Morgen\",asap:\"Sofort\",overdue:\"Überfällig\",now:\"Jetzt\",collapse:\"Einklappen\",expand:\"Ausklappen\",viewArchive:\"Archiv anzeigen\",viewInFileManager:\"Im Dateimanager anzeigen\",addedBy:\"Hinzugefügt von {{username}}\",prints:\"Drucke\",more:\"+{{count}} weitere\",ascending:\"Aufsteigend\",descending:\"Absteigend\",back:\"Zurück\",copy:\"Kopieren\",copied:\"Kopiert!\",printer:\"Drucker\",remove:\"Entfernen\",type:\"Typ\",print:\"Drucken\",rename:\"Umbenennen\",move:\"Verschieben\",create:\"Erstellen\",duplicate:\"Duplizieren\",left:\"Links\",right:\"Rechts\"},printers:{title:\"Drucker\",addPrinter:\"Drucker hinzufügen\",editPrinter:\"Drucker bearbeiten\",deletePrinter:\"Drucker löschen\",printerName:\"Druckername\",serialNumber:\"Seriennummer\",ipAddress:\"IP-Adresse / Hostname\",accessCode:\"Zugangscode\",model:\"Modell\",nozzleCount:\"Düsenanzahl\",autoArchive:\"Automatische Archivierung\",status:{available:\"Verfügbar\",idle:\"Bereit\",printing:\"Druckt\",paused:\"Pausiert\",offline:\"Offline\",problem:\"Problem\",error:\"Fehler\",finished:\"Fertig\",unknown:\"Unbekannt\"},temperatures:{nozzle:\"Düse\",bed:\"Druckbett\",chamber:\"Kammer\"},progress:\"{{percent}}% abgeschlossen\",timeRemaining:\"Noch {{time}}\",deleteConfirm:'Möchten Sie \"{{name}}\" wirklich löschen?',maintenanceOk:\"Wartung OK\",maintenanceWarning:\"{{count}} Warnung\",maintenanceWarning_plural:\"{{count}} Warnungen\",maintenanceDue:\"{{count}} fällig\",maintenanceDue_plural:\"{{count}} fällig\",sort:{name:\"Name\",status:\"Status\",model:\"Modell\",location:\"Standort\",ascending:\"Aufsteigend sortieren\",descending:\"Absteigend sortieren\"},cardSize:{small:\"Kleine Karten\",medium:\"Mittlere Karten\",large:\"Große Karten\",extraLarge:\"Extra große Karten\"},hideOffline:\"Offline ausblenden\",nextAvailable:\"Nächster verfügbar\",powerOn:\"Einschalten\",offlinePrintersWithPlugs:\"Offline-Drucker mit Smart-Plugs\",noPrintersConfigured:\"Noch keine Drucker konfiguriert\",search:\"Drucker suchen...\",noSearchResults:\"Keine Drucker entsprechen deiner Suche oder deinen Filtern\",filter:{allStatuses:\"Alle Status\",allLocations:\"Alle Standorte\"},readyToPrint:\"Druckbereit\",external:\"Extern\",extL:\"Ext-L\",extR:\"Ext-R\",deleteArchives:\"Druckarchive löschen\",noLabel:\"Keine Bezeichnung\",printPreview:\"Druckvorschau\",width:\"Breite\",height:\"Höhe\",noObjectsFound:\"Keine Objekte gefunden\",objectsLoadedOnPrintStart:\"Objekte werden beim Druckstart geladen\",willBeSkipped:\"Wird übersprungen\",name:\"Name\",serialCannotBeChanged:\"Seriennummer kann nicht geändert werden\",locationHelp:\"Dient zur Gruppierung von Druckern und zum Filtern von Warteschlangenaufträgen\",wifiSignal:{veryWeak:\"Sehr schwach\",weak:\"Schwach\",fair:\"Ausreichend\",good:\"Gut\",excellent:\"Ausgezeichnet\"},maintenanceUpToDate:\"Alle Wartungen aktuell - Klicken zum Anzeigen\",chamberLightOn:\"Kammerbeleuchtung einschalten\",chamberLightOff:\"Kammerbeleuchtung ausschalten\",files:\"Dateien\",browseFiles:\"Druckerdateien durchsuchen\",autoOffAfterPrint:\"Automatisches Ausschalten nach Druck\",autoOffExecuted:\"Auto-off wurde ausgeführt - Drucker einschalten zum Zurücksetzen\",hmsErrors:\"HMS-Fehler\",viewHmsErrors:\"{{count}} HMS-Fehler anzeigen\",resume:\"Fortsetzen\",pause:\"Pausieren\",stop:\"Stoppen\",camera:\"Kamera\",skipObject:\"Objekt überspringen\",reconnect:\"Neu verbinden\",forceRefresh:\"Aktualisierung erzwingen\",forceRefreshSuccess:\"Aktualisierung angefordert\",mqttDebug:\"MQTT-Debug\",printerInformation:\"Druckerinformationen\",copyToClipboard:\"Kopieren\",copied:\"Kopiert!\",state:\"Zustand\",wifiSignalLabel:\"WLAN-Signal\",developerMode:\"Entwicklermodus\",enabled:\"Aktiviert\",disabled:\"Deaktiviert\",addedOn:\"Hinzugefügt\",sdCard:\"SD-Karte\",inserted:\"Eingelegt\",notInserted:\"Nicht eingelegt\",totalPrintHours:\"Druckstunden\",activeNozzle:\"Aktiv: {{nozzle}} Düse\",nozzleRack:\"Düsenhalter\",nozzleDocked:\"Angedockt\",nozzleMounted:\"Montiert\",nozzleActive:\"Aktiv\",nozzleIdle:\"Inaktiv\",nozzleDiameter:\"Durchmesser\",nozzleType:\"Typ\",nozzleStatus:\"Status\",nozzleFilament:\"Filament\",nozzleWear:\"Verschleiß\",nozzleMaxTemp:\"Max Temp\",nozzleSerial:\"Seriennr.\",nozzleHardenedSteel:\"Gehärteter Stahl\",nozzleStainlessSteel:\"Edelstahl\",nozzleTungstenCarbide:\"Wolframkarbid\",nozzleFlow:\"Durchfluss\",nozzleHighFlow:\"High Flow\",nozzleStandardFlow:\"Standard\",firmwareUpdate:\"Firmware-Update\",firmwareInstructions:\"Gehen Sie auf dem Touchscreen des Druckers zu\",firmwareNav:\"Navigieren Sie zu\",settings:\"Einstellungen\",firmware:\"Firmware\",discoverPrinters:\"Drucker entdecken\",searching:\"Suche...\",manualEntry:\"Manuelle Eingabe\",addFromCloud:\"Aus Cloud hinzufügen\",toast:{printerDeleted:\"Drucker gelöscht\",missingSpoolAssignment:\"Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}\",printerAdded:\"Drucker hinzugefügt\",printerUpdated:\"Drucker aktualisiert\",failedToDelete:\"Drucker konnte nicht gelöscht werden\",failedToAdd:\"Drucker konnte nicht hinzugefügt werden\",failedToUpdate:\"Drucker konnte nicht aktualisiert werden\",commandSent:\"Befehl gesendet\",failedToSendCommand:\"Befehl konnte nicht gesendet werden\",turnedOn:\"{{name}} eingeschaltet\",failedToPowerOn:\"{{name}} konnte nicht eingeschaltet werden\",scriptTriggered:\"Skript ausgelöst\",printStopped:\"Druck gestoppt\",printPaused:\"Druck pausiert\",printResumed:\"Druck fortgesetzt\",referenceDeleted:\"Referenz gelöscht\",detectionAreaSaved:\"Erkennungsbereich gespeichert\",failedToRunScript:\"Skript konnte nicht ausgeführt werden\",failedToStopPrint:\"Druck konnte nicht gestoppt werden\",failedToPausePrint:\"Druck konnte nicht pausiert werden\",failedToResumePrint:\"Druck konnte nicht fortgesetzt werden\",failedToControlChamberLight:\"Kammerbeleuchtung konnte nicht gesteuert werden\",failedToSetSpeed:\"Druckgeschwindigkeit konnte nicht eingestellt werden\",failedToUpdateSetting:\"Einstellung konnte nicht aktualisiert werden\",failedToSkipObjects:\"Objekte konnten nicht übersprungen werden\",failedToRereadRfid:\"RFID konnte nicht erneut gelesen werden\",failedToCheckPlate:\"Platte konnte nicht überprüft werden\",failedToUpdateLabel:\"Bezeichnung konnte nicht aktualisiert werden\",failedToDeleteReference:\"Referenz konnte nicht gelöscht werden\",failedToSaveDetectionArea:\"Erkennungsbereich konnte nicht gespeichert werden\",plateCheckEnabled:\"Plattenprüfung aktiviert\",plateCheckDisabled:\"Plattenprüfung deaktiviert\",calibrationSaved:\"Kalibrierung gespeichert!\",calibrationFailed:\"Kalibrierung fehlgeschlagen\",rfidRereadInitiated:\"RFID-Neueinlesen gestartet\"},connection:{connected:\"Verbunden\",offline:\"Offline\"},plateStatus:{markCleared:\"Platte als freigegeben markieren\",cleared:\"Platte freigegeben\",notCleared:\"Platte nicht freigegeben\",inUse:\"Platte in Benutzung\"},queue:{inQueue:\"{{count}} Druck in Warteschlange\",inQueue_plural:\"{{count}} Drucke in Warteschlange\"},controls:\"Steuerung\",rfid:{reread:\"RFID neu lesen\"},bedJog:{title:\"Druckbett bewegen\",bed:\"Bett\",step:\"Schritt (mm)\",up:\"Platte hoch\",down:\"Platte runter\",disabledWhilePrinting:\"Während des Drucks deaktiviert\",notHomedTitle:\"Drucker ist nicht referenziert\",notHomedMessage:\"Der Drucker wurde seit dem letzten Druck nicht referenziert. Führen Sie zuerst die automatische Referenzfahrt aus (parkt den Werkzeugkopf und referenziert dann X, Y und Z) oder bewegen Sie trotzdem — die Software-Endschalter werden dabei umgangen.\",homeZ:\"Automatische Referenzfahrt\",moveAnyway:\"Trotzdem bewegen\",homingStarted:\"Drucker wird automatisch referenziert…\"},permission:{noAdd:\"Sie haben keine Berechtigung, Drucker hinzuzufügen\",noEdit:\"Sie haben keine Berechtigung, Drucker zu bearbeiten\",noDelete:\"Sie haben keine Berechtigung, Drucker zu löschen\",noControl:\"Sie haben keine Berechtigung, Drucker zu steuern\",noFiles:\"Sie haben keine Berechtigung, auf Druckerdateien zuzugreifen\",noAmsRfid:\"Sie haben keine Berechtigung, AMS-RFID erneut zu lesen\",noSmartPlugControl:\"Sie haben keine Berechtigung, Smart Plugs zu steuern\",noCamera:\"Sie haben keine Berechtigung, Kameras anzuzeigen\"},modal:{addTitle:\"Drucker hinzufügen\",editTitle:\"Drucker bearbeiten\",myPrinter:\"Mein Drucker\",selectModel:\"Modell auswählen...\",locationGroup:\"Standort / Gruppe (optional)\",locationPlaceholder:\"z.B. Werkstatt, Büro, Keller\",autoArchiveLabel:\"Abgeschlossene Drucke automatisch archivieren\",fromPrinterSettings:\"Aus Druckereinstellungen\",modelOptional:\"Modell (optional)\",saveChanges:\"Änderungen speichern\"},skipObjects:{tooltip:\"Objekte überspringen\",onlyWhilePrinting:\"Objekte überspringen (nur während des Drucks)\",requiresMultiple:\"Objekte überspringen (erfordert 2+ Objekte)\",title:\"Objekte überspringen\",matchIdsInfo:\"IDs mit Drucker-Display abgleichen\",printerShowsIds:\"Der Druckerbildschirm zeigt Objekt-IDs auf der Bauplatte\",skipSelected:\"Ausgewählte überspringen\",skipping:\"Überspringe...\",noObjectsSelected:\"Keine Objekte ausgewählt\",selectObjectsToSkip:\"Wählen Sie Objekte aus, die Sie vom aktuellen Druck überspringen möchten\",skipped:\"übersprungen\",objectsSkipped:\"Objekte übersprungen\",activeCount:\"{{count}} aktiv\",waitForLayer:\"Warten Sie auf Schicht 2+ zum Überspringen von Objekten (aktuell Schicht {{layer}})\",skip:\"Überspringen\",confirmTitle:\"Objekt überspringen?\",confirmMessage:'Möchten Sie \"{{name}}\" wirklich überspringen? Dies kann nicht rückgängig gemacht werden.'},confirm:{deleteTitle:\"Drucker löschen\",deleteMessage:'Möchten Sie \"{{name}}\" wirklich löschen? Alle Verbindungseinstellungen werden entfernt.',deleteArchivesNote:\"Der gesamte Druckverlauf für diesen Drucker wird dauerhaft gelöscht.\",keepArchivesNote:\"Der Druckverlauf wird beibehalten, aber nicht mehr mit diesem Drucker verknüpft.\",stopTitle:\"Druck stoppen\",stopMessage:'Möchten Sie den aktuellen Druck auf \"{{name}}\" wirklich stoppen? Der Druckauftrag wird abgebrochen.',stopButton:\"Druck stoppen\",pauseTitle:\"Druck pausieren\",pauseMessage:'Möchten Sie den aktuellen Druck auf \"{{name}}\" wirklich pausieren?',pauseButton:\"Druck pausieren\",resumeTitle:\"Druck fortsetzen\",resumeMessage:'Möchten Sie den Druck auf \"{{name}}\" fortsetzen?',resumeButton:\"Druck fortsetzen\",powerOnTitle:\"Drucker einschalten\",powerOnMessage:'Möchten Sie die Stromversorgung für \"{{name}}\" wirklich EINSCHALTEN?',powerOnButton:\"Einschalten\",powerOffTitle:\"Drucker ausschalten\",powerOffMessage:'Möchten Sie die Stromversorgung für \"{{name}}\" wirklich AUSSCHALTEN?',powerOffWarning:'WARNUNG: \"{{name}}\" druckt gerade! Möchten Sie die Stromversorgung wirklich AUSSCHALTEN? Dies unterbricht den Druck und kann den Drucker beschädigen.',powerOffButton:\"Ausschalten\"},bulk:{select:\"Auswählen\",selectAll:\"Alle auswählen\",selectByLocation:\"Nach Standort auswählen\",selected:\"{{count}} ausgewählt\",actions:{stop:\"Stoppen\",pause:\"Pausieren\",resume:\"Fortsetzen\",clearPlate:\"Druckbett leeren\",clearHMS:\"Benachrichtigungen löschen\"},confirm:{stopTitle:\"{{count}} Drucke stoppen\",stopMessage:\"Dies wird aktive Drucke auf {{count}} Drucker(n) abbrechen. Diese Aktion kann nicht rückgängig gemacht werden.\",stopButton:\"Alle stoppen\",pauseTitle:\"{{count}} Drucke pausieren\",pauseMessage:\"Dies wird aktive Drucke auf {{count}} Drucker(n) pausieren.\",pauseButton:\"Alle pausieren\",clearPlateTitle:\"{{count}} Druckbetten leeren\",clearPlateMessage:\"Dies wird das Druckbett auf {{count}} Drucker(n) leeren und kann wartende Aufträge starten.\",clearPlateButton:\"Alle leeren\"},success:\"{{action}} auf {{count}} Drucker(n) abgeschlossen\",partial:\"{{succeeded}} erfolgreich, {{failed}} fehlgeschlagen\",noneApplicable:\"Keine ausgewählten Drucker sind im richtigen Zustand für diese Aktion\",selectByState:\"Nach Status auswählen\"},discovery:{title:\"Drucker entdecken\",searching:\"Suche...\",scanning:\"Scanne...\",scanProgress:\"Scanne... {{scanned}}/{{total}}\",foundPrinters:\"{{count}} Drucker gefunden\",noPrintersFound:\"Keine Drucker gefunden\",noPrintersFoundSubnet:\"Keine Drucker im angegebenen Subnetz gefunden.\",noPrintersFoundNetwork:\"Keine Drucker im Netzwerk gefunden.\",allConfigured:\"Alle erkannten Drucker sind bereits konfiguriert.\",alreadyAdded:\"Bereits hinzugefügt\",select:\"Auswählen\",manualEntry:\"Manuelle Eingabe\",addFromCloud:\"Aus Cloud hinzufügen\",subnetToScan:\"Zu scannendes Subnetz\",dockerNote:\"Docker erkannt. Geben Sie das Subnetz Ihres Druckers in CIDR-Notation ein. Erfordert network_mode: host in docker-compose.yml.\",scanSubnet:\"Subnetz nach Druckern scannen\",discoverNetwork:\"Drucker im Netzwerk suchen\",scanningSubnet:\"Subnetz wird nach Bambu-Druckern gescannt...\",scanningNetwork:\"Netzwerk wird gescannt...\",serialRequired:\"Seriennummer erforderlich\",unknown:\"Unbekannt\",failedToStart:\"Erkennung konnte nicht gestartet werden\"},drying:{start:\"Trocknung starten\",stop:\"Trocknung stoppen\",temperature:\"Temperatur\",duration:\"Dauer\",hours:\"Stunden\",timeRemaining:\"{{time}} verbleibend\",active:\"Trocknung\",notSupported:\"Trocknung nicht unterstützt\",powerRequired:\"AMS-Netzteil anschließen, um Trocknung zu aktivieren\",startingDrying:\"Trocknung wird gestartet...\",stoppingDrying:\"Trocknung wird gestoppt...\",rotateTray:\"Spule während der Trocknung drehen\"},filaments:\"Filamente\",openCameraOverlay:\"Kamera-Overlay öffnen\",openCameraWindow:\"Kamera in neuem Fenster öffnen\",firmwareUpdateAvailable:\"Firmware-Update verfügbar: {{current}} → {{latest}}\",firmwareUpToDate:\"Firmware {{version}} — Aktuell\",firmwareUpdateButton:\"Update\",plateDetection:{noPermission:\"Sie haben keine Berechtigung, Drucker zu aktualisieren\",enabledClick:\"Plattenprüfung aktiviert - Klicken zum Deaktivieren\",disabledClick:\"Plattenprüfung deaktiviert - Klicken zum Aktivieren\",manageCalibration:\"Platten-Erkennungskalibrierung verwalten\",calibrationRequired:\"Kalibrierung erforderlich\",calibrationInstructions:\"Bitte stellen Sie sicher, dass die Druckplatte <strong>vollständig leer</strong> ist, und klicken Sie dann auf Kalibrieren.\",calibrationDescription:\"Die Kalibrierung erfasst ein Referenzbild der leeren Platte. Zukünftige Prüfungen vergleichen mit dieser Referenz, um Objekte zu erkennen.\",calibrationTip:\"<strong>Tipp:</strong> Sie können bis zu 5 Kalibrierungen für verschiedene Platten speichern. Das System verwendet automatisch die beste Übereinstimmung bei der Prüfung.\",plateEmpty:\"Platte erscheint leer\",objectsDetected:\"Objekte auf Platte erkannt\",confidence:\"Konfidenz\",difference:\"Differenz\",analysisPreview:\"Analysevorschau:\",analysisLegend:\"Grüner Rahmen = Erkennungsbereich, Rote Überlagerung = Unterschiede zur Kalibrierung\",savedReferences:\"Gespeicherte Referenzen ({{count}}/{{max}})\",deleteReference:\"Referenz löschen\",labelPlaceholder:\"Bezeichnung...\",clickToEdit:\"{{label}} - Zum Bearbeiten klicken\",clickToAddLabel:\"Zum Hinzufügen einer Bezeichnung klicken\"},speed:{title:\"Druckgeschwindigkeit\",silent:\"Leise (50%)\",standard:\"Standard (100%)\",sport:\"Sport (124%)\",ludicrous:\"Ludicrous (166%)\"},airduct:{title:\"Luftkanal-Modus\",cooling:\"Kühlen\",heating:\"Heizen\"},noSdCard:\"Keine SD\",door:{open:\"Offen\",closed:\"Zu\"},fans:{partCooling:\"Bauteilkühlung\",auxiliary:\"Hilfsventilator\",chamber:\"Kammerventilator\"},clickToViewHmsErrors:\"Klicken, um HMS-Fehler anzuzeigen\",estimatedCompletion:\"Geschätzte Fertigstellungszeit\",plateNumber:\"Platte {{number}}\",slotOptions:\"Slot-Optionen\",amsPopup:{friendlyName:\"AMS-Name\",friendlyNamePlaceholder:\"z. B. AMS-Anzeigename\",serialNumber:\"Seriennummer\",firmwareVersion:\"Firmware\",save:\"Speichern\",clear:\"Löschen\",noEditPermission:\"Sie haben keine Berechtigung, AMS-Einheiten umzubenennen\"},firmwareModal:{title:\"Firmware-Update\",titleUpToDate:\"Firmware-Info\",currentVersion:\"Aktuell:\",latestVersion:\"Neueste:\",releaseNotes:\"Versionshinweise\",checkingPrereqs:\"Prüfe Voraussetzungen...\",sdCardReady:\"SD-Karte bereit. Klicken Sie unten, um die Firmware hochzuladen.\",uploadedSuccess:\"Firmware auf SD-Karte hochgeladen!\",applyInstructions:\"So wenden Sie das Update auf Ihrem Drucker an:\",step1:\"Gehen Sie auf dem Touchscreen des Druckers zu <strong>Einstellungen</strong>\",step2:\"Navigieren Sie zu <strong>Firmware</strong>\",step3:\"Wählen Sie <strong>Update von SD-Karte</strong>\",step4:\"Das Update dauert 10-20 Minuten\",done:\"Fertig\",starting:\"Starte...\",uploadFirmware:\"Firmware hochladen\",uploadFailed:\"Upload fehlgeschlagen: {{error}}\",uploadedToast:\"Firmware hochgeladen! Starten Sie das Update vom Druckerbildschirm.\",availableVersions:\"Verfügbare Versionen\",usable:\"Installierbar\",unavailable:\"Nicht verfügbar\",installed:\"Installiert\",newerBadge:\"neuer\",olderBadge:\"älter\",currentBadge:\"aktuell\"},accessCodePlaceholder:\"Leer lassen, um den aktuellen zu behalten\",roi:{title:\"Erkennungsbereich (ROI)\",xStart:\"X-Start\",yStart:\"Y-Start\",width:\"Breite\",height:\"Höhe\",instruction:\"Passen Sie den Erkennungsbereich an, um sich auf die Druckplatte zu konzentrieren. Der grüne Rahmen in der Vorschau zeigt den aktuellen Bereich.\"},developerModeWarning:\"Der Entwickler-LAN-Modus ist nicht aktiviert auf: {{names}}. Einige Funktionen funktionieren möglicherweise nicht.\",howToEnable:\"Aktivieren\",incompatibleFile:\"Diese Datei wurde für {{slicedFor}} geslicet, aber dieser Drucker ist ein {{printerModel}}\",dropNotPrintable:\"Nur .gcode- und .gcode.3mf-Dateien können gedruckt werden\",dropToPrint:\"Zum Drucken ablegen\",cannotPrint:\"Drucker beschäftigt\"},archives:{title:\"Druckarchiv\",searchPlaceholder:\"Archiv durchsuchen...\",filterByPrinter:\"Nach Drucker filtern\",filterByStatus:\"Nach Status filtern\",sortBy:\"Sortieren nach\",sortNewest:\"Neueste zuerst\",sortOldest:\"Älteste zuerst\",sortName:\"Name\",sortDuration:\"Dauer\",sortLargest:\"Größte zuerst\",sortSmallest:\"Kleinste zuerst\",sortSize:\"Größe\",noArchives:\"Keine Archive gefunden\",noArchivesSearch:\"Keine Archive entsprechen Ihrer Suche\",originalPrintNotVisible:\"Ursprünglicher Druck nicht sichtbar - versuchen Sie, die Filter zu löschen\",noArchivesYet:\"Noch keine Archive\",prints:\"Drucke\",pagination:{showing:\"Zeige\",to:\"bis\",of:\"von\",show:\"Zeige\",page:\"Seite\",all:\"Alle\"},loadingArchives:\"Lade Archive...\",releaseToUpload:\"Loslassen zum Hochladen\",showAll:\"Alle anzeigen\",showFavoritesOnly:\"Nur Favoriten anzeigen\",gridView:\"Rasteransicht\",listView:\"Listenansicht\",calendarView:\"Kalenderansicht\",logView:\"Druckprotokoll\",manageTags:\"Tags verwalten\",showFailedPrints:\"Fehlgeschlagene Drucke anzeigen\",hideFailedPrints:\"Fehlgeschlagene Drucke ausblenden\",hideDuplicates:\"Duplikate ausblenden\",viewOriginalPrint:\"Klicken, um den ursprünglichen Druck anzuzeigen (#{{id}})\",printTime:\"Druckzeit\",filamentUsed:\"Verbrauchtes Filament\",cost:\"Kosten\",reprint:\"Drucken\",preview:\"Vorschau\",deleteArchive:\"Archiv löschen\",deleteConfirm:\"Möchten Sie dieses Archiv wirklich löschen?\",favorite:\"Favorit\",unfavorite:\"Aus Favoriten entfernen\",viewDetails:\"Details anzeigen\",status:{completed:\"Abgeschlossen\",failed:\"Fehlgeschlagen\",stopped:\"Gestoppt\"},toast:{source3mfAttached:\"Quell-3MF angehängt: {{filename}}\",failedUploadSource3mf:\"Fehler beim Hochladen der Quell-3MF\",source3mfRemoved:\"Quell-3MF entfernt\",failedRemoveSource3mf:\"Fehler beim Entfernen der Quell-3MF\",f3dAttached:\"F3D angehängt: {{filename}}\",failedUploadF3d:\"Fehler beim Hochladen der F3D\",f3dRemoved:\"F3D entfernt\",failedRemoveF3d:\"Fehler beim Entfernen der F3D\",timelapseAttached:\"Zeitraffer angehängt: {{filename}}\",timelapseAlreadyAttached:\"Zeitraffer bereits angehängt\",noMatchingTimelapse:\"Kein passender Zeitraffer gefunden\",failedScanTimelapse:\"Fehler beim Suchen nach Zeitraffer\",failedAttachTimelapse:\"Fehler beim Anhängen des Zeitraffers\",timelapseRemoved:\"Zeitraffer entfernt\",failedRemoveTimelapse:\"Fehler beim Entfernen des Zeitraffers\",timelapseUploaded:\"Zeitraffer hochgeladen: {{filename}}\",failedUploadTimelapse:\"Fehler beim Hochladen des Zeitraffers\",archiveDeleted:\"Archiv gelöscht\",failedDeleteArchive:\"Fehler beim Löschen des Archivs\",addedToFavorites:\"Zu Favoriten hinzugefügt\",removedFromFavorites:\"Aus Favoriten entfernt\",projectUpdated:\"Projekt aktualisiert\",failedUpdateProject:\"Fehler beim Aktualisieren des Projekts\",linkCopied:\"Link in die Zwischenablage kopiert\",failedCopyLink:\"Fehler beim Kopieren des Links\",photoDeleted:\"Foto gelöscht\",failedDeletePhoto:\"Fehler beim Löschen des Fotos\",failedDeleteArchives:\"Fehler beim Löschen der Archive\",failedUpdateFavorites:\"Fehler beim Aktualisieren der Favoriten\",exportDownloaded:\"Export heruntergeladen\",exportFailed:\"Export fehlgeschlagen\"},menu:{print:\"Drucken\",schedule:\"Planen\",openInBambuStudio:\"Im Slicer öffnen\",slice:\"Slicen\",externalLink:\"Externer Link\",viewOnMakerWorld:\"Auf MakerWorld ansehen\",preview3d:\"3D-Vorschau\",viewTimelapse:\"Zeitraffer ansehen\",scanForTimelapse:\"Nach Zeitraffer suchen\",uploadTimelapse:\"Zeitraffer hochladen\",removeTimelapse:\"Zeitraffer entfernen\",downloadSource3mf:\"Quell-3MF herunterladen\",uploadSource3mf:\"Quell-3MF hochladen\",replaceSource3mf:\"Quell-3MF ersetzen\",removeSource3mf:\"Quell-3MF entfernen\",uploadF3d:\"F3D hochladen\",replaceF3d:\"F3D ersetzen\",downloadF3d:\"F3D herunterladen\",removeF3d:\"F3D entfernen\",download:\"Herunterladen\",copyDownloadLink:\"Download-Link kopieren\",qrCode:\"QR-Code\",viewPhotos:\"Fotos ansehen\",viewPhotosCount:\"Fotos ansehen ({{count}})\",projectPage:\"Projektseite\",addToFavorites:\"Zu Favoriten hinzufügen\",removeFromFavorites:\"Aus Favoriten entfernen\",edit:\"Bearbeiten\",goToProject:\"Zum Projekt: {{name}}\",addToProject:\"Zu Projekt hinzufügen\",removeFromProject:\"Aus Projekt entfernen\",loading:\"Laden...\",noProjectsAvailable:\"Keine Projekte verfügbar\",select:\"Auswählen\",deselect:\"Abwählen\",delete:\"Löschen\"},permission:{noReprint:\"Sie haben keine Berechtigung, dieses Archiv erneut zu drucken\",noAddToQueue:\"Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen\",noUpdateArchives:\"Sie haben keine Berechtigung, Archive zu aktualisieren\",noUploadFiles:\"Sie haben keine Berechtigung, Dateien hochzuladen\",noDownload:\"Sie haben keine Berechtigung, Archive herunterzuladen\",noCopyLink:\"Sie haben keine Berechtigung, Download-Links zu kopieren\",noDelete:\"Sie haben keine Berechtigung, dieses Archiv zu löschen\",noCreate:\"Sie haben keine Berechtigung, Archive zu erstellen\"},card:{previousPlate:\"Vorherige Platte\",nextPlate:\"Nächste Platte\",plateNumber:\"Platte {{index}}\",moreOptions:\"Rechtsklick für mehr Optionen\",addToFavorites:\"Zu Favoriten hinzufügen\",removeFromFavorites:\"Aus Favoriten entfernen\",cancelled:\"abgebrochen\",failed:\"fehlgeschlagen\",duplicate:\"Duplikat\",duplicateTitle:\"Dieses Modell wurde bereits zuvor gedruckt\",openSource3mf:\"Quell-3MF in Bambu Studio öffnen (Rechtsklick für mehr Optionen)\",downloadF3d:\"Fusion 360 Designdatei herunterladen\",viewTimelapse:\"Zeitraffer ansehen\",viewPhoto:\"1 Foto ansehen\",viewPhotos:\"{{count}} Fotos ansehen\",openFolder:\"Ordner öffnen: {{name}}\",slicedFile:\"Geslicte Datei - druckbereit\",sourceFile:\"Nur Quelldatei - keine AMS-Zuordnung verfügbar\",gcode:\"GCODE\",source:\"QUELLE\",project:\"Projekt: {{name}}\",estimated:\"Geschätzt: {{time}}\",actual:\"Tatsächlich: {{time}}\",accuracy:\"Genauigkeit: {{percent}}%\",filament:\"{{weight}}g\",layer:\"{{count}} Schicht\",layers:\"{{count}} Schichten\",object:\"{{count}} Objekt\",objects:\"{{count}} Objekte\",slicedFor:\"Geslict für {{model}}\",uploadedBy:\"Hochgeladen von\",noPermissionReprint:\"Sie haben keine Berechtigung, erneut zu drucken\",noFileForReprint:\"Keine 3MF-Datei verfügbar — die Datei konnte beim Aufzeichnen des Drucks nicht vom Drucker heruntergeladen werden\",noPermissionEdit:\"Sie haben keine Berechtigung, Archive zu bearbeiten\",noPermissionDelete:\"Sie haben keine Berechtigung, Archive zu löschen\",reprint:\"Drucken\",schedulePrint:\"Druck planen\",schedule:\"Planen\",openInBambuStudio:\"Im Slicer öffnen\",openInBambuStudioToSlice:\"Im Slicer öffnen zum Slicen\",slice:\"Slicen\",externalLink:\"Externer Link\",makerWorld:\"MakerWorld: {{designer}}\",viewProject:\"Projekt ansehen\",noExternalLink:\"Kein externer Link\",preview3d:\"3D-Vorschau\",download:\"Herunterladen\",edit:\"Bearbeiten\",delete:\"Löschen\"},modal:{deleteArchive:\"Archiv löschen\",deleteConfirm:'Möchten Sie \"{{name}}\" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',deleteButton:\"Löschen\",removeSource3mf:\"Quell-3MF entfernen\",removeSource3mfConfirm:'Möchten Sie die Quell-3MF-Datei wirklich von \"{{name}}\" entfernen? Die ursprüngliche Slicer-Projektdatei wird gelöscht.',removeButton:\"Entfernen\",removeF3d:\"F3D entfernen\",removeF3dConfirm:'Möchten Sie die Fusion 360 Designdatei wirklich von \"{{name}}\" entfernen?',removeTimelapse:\"Zeitraffer entfernen\",removeTimelapseConfirm:'Möchten Sie das Zeitraffervideo wirklich von \"{{name}}\" entfernen?',timelapse:\"{{name}} - Zeitraffer\",selectTimelapse:\"Zeitraffer auswählen\",selectTimelapseDesc:\"Keine automatische Übereinstimmung gefunden. Wählen Sie den Zeitraffer für diesen Druck:\",deleteArchives:\"Archive löschen\",deleteArchivesConfirm:\"Möchten Sie wirklich {{count}} Archiv(e) löschen? Diese Aktion kann nicht rückgängig gemacht werden.\",deleteCount:\"{{count}} löschen\"},page:{title:\"Archive\",printsCount:\"{{filtered}} von {{total}} Drucken\",dropFilesHere:\".3mf-Dateien hier ablegen\",releaseToUpload:\"Loslassen zum Hochladen\",only3mfSupported:\"Nur .3mf-Dateien werden unterstützt\",close:\"Schließen\",selected:\"{{count}} ausgewählt\",selectAll:\"Alle auswählen\",tags:\"Tags\",project:\"Projekt\",favorite:\"Favorit\",delete:\"Löschen\",toggledFavorites:\"Favoriten für {{count}} Archiv(e) umgeschaltet\",failedUpdateFavorites:\"Fehler beim Aktualisieren der Favoriten\",archivesDeleted:\"{{count}} Archiv(e) gelöscht\",failedDeleteArchives:\"Fehler beim Löschen der Archive\",photoDeleted:\"Foto gelöscht\",failedDeletePhoto:\"Fehler beim Löschen des Fotos\"},list:{name:\"Name\",printer:\"Drucker\",date:\"Datum\",size:\"Größe\",actions:\"Aktionen\",hasTimelapse:\"Hat Zeitraffer\"},log:{date:\"Datum\",printName:\"Druckname\",printer:\"Drucker\",user:\"Benutzer\",status:\"Status\",duration:\"Dauer\",filament:\"Filament\",allPrinters:\"Alle Drucker\",allUsers:\"Alle Benutzer\",allStatuses:\"Alle Status\",cancelled:\"Abgebrochen\",skipped:\"Übersprungen\",dateFrom:\"Von\",dateTo:\"Bis\",noEntries:\"Keine Druckprotokolleinträge gefunden\",showing:\"{{count}} von {{total}} Einträgen\",rowsPerPage:\"Zeilen\",page:\"Seite\",prev:\"Zurück\",next:\"Weiter\",clearLog:\"Protokoll löschen\",clearLogTitle:\"Druckprotokoll löschen\",clearLogConfirm:\"Alle Druckprotokolleinträge werden dauerhaft gelöscht. Archive und Warteschlangeneinträge sind nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher?\",clearLogButton:\"Alle löschen\",cleared:\"{{count}} Protokolleinträge gelöscht\",clearFailed:\"Druckprotokoll konnte nicht gelöscht werden\"}},queue:{title:\"Druckwarteschlange\",subtitle:\"Planen und verwalten Sie Ihre Druckaufträge\",addToQueue:\"Zur Warteschlange hinzufügen\",print:\"Drucken\",reprint:\"Erneut drucken\",schedulePrint:\"Druck planen\",editQueueItem:\"Warteschlangeneintrag bearbeiten\",printToPrinters:\"Auf {{count}} Druckern drucken\",queueToPrinters:\"Zu {{count}} Druckern hinzufügen\",queueSelectedPlates:\"{{count}} Platten in die Warteschlange\",selectAllPlates:\"Alle {{count}} Platten auswählen\",deselectAll:\"Alle abwählen\",printQueued:\"Druck in Warteschlange\",itemsQueued:\"{{count}} Einträge in Warteschlange\",sending:\"Wird gesendet...\",sendingProgress:\"Sende {{current}}/{{total}}...\",adding:\"Wird hinzugefügt...\",addingProgress:\"Füge hinzu {{current}}/{{total}}...\",savingProgress:\"Speichere {{current}}/{{total}}...\",clearQueue:\"Warteschlange leeren\",clearHistory:\"Verlauf löschen\",emptyQueue:\"Warteschlange ist leer\",position:\"Position\",scheduledTime:\"Geplante Zeit\",moveUp:\"Nach oben\",moveDown:\"Nach unten\",startNow:\"Jetzt starten\",printingInProgress:\"Druck läuft...\",viewArchive:\"Archiv anzeigen\",viewInFileManager:\"Im Dateimanager anzeigen\",itemCount:\"{{count}} Element\",itemCount_plural:\"{{count}} Elemente\",dragToReorder:\"Ziehen zum Neuordnen (nur Sofort)\",reorderHint:\"Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.\",sjf:{label:\"SJF\",tooltip:\"Kürzester Auftrag zuerst — Scheduler bevorzugt kürzere Drucke\"},addedBy:\"Hinzugefügt von {{name}}\",nextInQueue:\"Nächster in der Warteschlange\",clearPlateSuccess:\"Druckplatte freigegeben — bereit für nächsten Druck\",plateNumber:\"Platte {{index}}\",quantity:\"Menge\",quantityHint:\"Erstellt {{count}} Warteschlangeneinträge\",activeBatches:\"Aktive Stapel\",batchProgress:\"{{completed}} von {{total}} abgeschlossen\",cancelBatch:\"Verbleibende abbrechen\",batchCancelled:\"Verbleibende Stapeleinträge abgebrochen\",cancelBatchConfirmTitle:\"Stapel abbrechen\",cancelBatchConfirmMessage:\"Alle verbleibenden ausstehenden Einträge in diesem Stapel abbrechen?\",batch:\"Stapel\",sections:{currentlyPrinting:\"Aktuell druckend\",queued:\"In Warteschlange\",history:\"Verlauf\"},status:{pending:\"Ausstehend\",waiting:\"Wartend\",printing:\"Druckt\",paused:\"Pausiert\",completed:\"Abgeschlossen\",failed:\"Fehlgeschlagen\",skipped:\"Übersprungen\",cancelled:\"Abgebrochen\"},summary:{printing:\"Druckt\",queued:\"In Warteschlange\",totalTime:\"Gesamte Wartezeit\",totalWeight:\"Gesamtgewicht der Warteschlange\",history:\"Verlauf\"},filter:{allPrinters:\"Alle Drucker\",unassigned:\"Nicht zugewiesen\",allStatus:\"Alle Status\",allLocations:\"Alle Standorte\",any:\"Beliebig\"},sort:{byPosition:\"Nach Position sortieren\",byName:\"Nach Name sortieren\",byPrinter:\"Nach Drucker sortieren\",bySchedule:\"Nach Zeitplan sortieren\",byDate:\"Nach Datum sortieren\",ascendingOldest:\"Aufsteigend (älteste zuerst)\",descendingNewest:\"Absteigend (neueste zuerst)\"},badges:{staged:\"Bereitgestellt\",requiresPrevious:\"Erfordert vorherigen Erfolg\",autoPowerOff:\"Automatisch ausschalten\",gcodeInjection:\"G-code\"},empty:{title:\"Keine Drucke geplant\",description:'Planen Sie einen Druck von der Archivseite über die Option \"Planen\" im Kontextmenü oder ziehen Sie Dateien hierher.'},time:{asap:\"Sofort\",overdue:\"Überfällig\",now:\"Jetzt\",lessThanMinute:\"In weniger als einer Minute\",inMinutes:\"In {{count}} Min\",inHours:\"In {{count}} Stunden\"},actions:{stopPrint:\"Druck stoppen\",startPrint:\"Druck starten\",requeue:\"Erneut einreihen\"},bulkEdit:{title:\"{{count}} Element bearbeiten\",title_plural:\"{{count}} Elemente bearbeiten\",description:\"Nur geänderte Einstellungen werden auf ausgewählte Elemente angewendet.\",printer:\"Drucker\",noChange:\"— Keine Änderung —\",queueOptions:\"Warteschlangenoptionen\",staged:\"Bereitgestellt (manueller Start)\",autoPowerOff:\"Nach Druck automatisch ausschalten\",requirePrevious:\"Vorherigen Erfolg erfordern\",printOptions:\"Druckoptionen\",bedLevelling:\"Bett-Nivellierung\",flowCalibration:\"Fluss-Kalibrierung\",vibrationCalibration:\"Vibrations-Kalibrierung\",layerInspection:\"Erste-Schicht-Prüfung\",timelapse:\"Zeitraffer\",useAms:\"AMS verwenden\",applyChanges:\"Änderungen übernehmen\",selectAll:\"Alle auswählen\",deselectAll:\"Auswahl aufheben\",selected:\"{{count}} ausgewählt\",editSelected:\"Ausgewählte bearbeiten\",cancelSelected:\"Ausgewählte abbrechen\"},confirm:{cancelTitle:\"Geplanten Druck abbrechen\",cancelMessage:'Möchten Sie \"{{name}}\" wirklich abbrechen?',stopTitle:\"Druck stoppen\",stopMessage:'Möchten Sie den aktuellen Druck \"{{name}}\" wirklich stoppen? Der Druckauftrag wird am Drucker abgebrochen.',removeTitle:\"Aus Verlauf entfernen\",removeMessage:'Möchten Sie \"{{name}}\" wirklich aus dem Warteschlangenverlauf entfernen?',clearHistoryTitle:\"Verlauf löschen\",clearHistoryMessage:\"Möchten Sie alle {{count}} Element(e) aus dem Verlauf entfernen?\",cancelButton:\"Druck abbrechen\",stopButton:\"Druck stoppen\",thisPrint:\"diesen Druck\",thisItem:\"dieses Element\"},toast:{cancelled:\"Warteschlangenelement abgebrochen\",cancelFailed:\"Element konnte nicht abgebrochen werden\",removed:\"Warteschlangenelement entfernt\",removeFailed:\"Element konnte nicht entfernt werden\",stopped:\"Druck gestoppt\",stopFailed:\"Druck konnte nicht gestoppt werden\",released:\"Druck in Warteschlange freigegeben\",startFailed:\"Druck konnte nicht gestartet werden\",reorderFailed:\"Warteschlange konnte nicht neu geordnet werden\",historyCleared:\"{{count}} Verlaufselement(e) gelöscht\",clearHistoryFailed:\"Verlauf konnte nicht gelöscht werden\",updateFailed:\"Elemente konnten nicht aktualisiert werden\",bulkCancelled:\"{{count}} Element(e) abgebrochen\",bulkCancelFailed:\"Elemente konnten nicht abgebrochen werden\"},timeline:{listView:\"Liste\",timelineView:\"Zeitstrahl\",unassigned:\"Nicht zugewiesen\",noData:\"Keine geplanten Drucke für diesen Tag\",allDoneBy:\"Alle Drucke voraussichtlich fertig um {{time}}\",staged:\"Bereitgestellt\",filterAll:\"Alle anzeigen\",filterPrinting:\"Druckend\",filterQueued:\"Warteschlange\",time:{anyMoment:\"jeden Moment\",minutesLeft:\"{{minutes}}m übrig\",hoursLeft:\"{{hours}}h übrig\",hoursMinutesLeft:\"{{hours}}h {{minutes}}m übrig\"},day:{previous:\"Vorheriger Tag\",next:\"Nächster Tag\",today:\"Heute\"}},permissions:{noStopPrint:\"Sie haben keine Berechtigung, Drucke zu stoppen\",noStartPrint:\"Sie haben keine Berechtigung, Drucke zu starten\",noEdit:\"Sie haben keine Berechtigung, dieses Warteschlangenelement zu bearbeiten\",noCancel:\"Sie haben keine Berechtigung, dieses Warteschlangenelement abzubrechen\",noRequeue:\"Sie haben keine Berechtigung, Elemente erneut einzureihen\",noRemove:\"Sie haben keine Berechtigung, dieses Warteschlangenelement zu entfernen\",noClearHistory:\"Sie haben keine Berechtigung, den gesamten Verlauf zu löschen\",noEditItems:\"Sie haben keine Berechtigung, Warteschlangenelemente zu bearbeiten\",noCancelItems:\"Sie haben keine Berechtigung, Warteschlangenelemente abzubrechen\"}},backgroundDispatch:{unknownFile:\"Unbekannte Datei\",unknownPrinter:\"Unbekannter Drucker\",startingPrints:\"Starte Drucke\",progressSummary:\"{{complete}}/{{total}} abgeschlossen • Geplant: {{dispatched}} • In Bearbeitung: {{processing}}\",expandDetails:\"Dispatch-Details ausklappen\",collapseDetails:\"Dispatch-Details einklappen\",dismissToast:\"Dispatch-Hinweis schließen\",cancelDispatchJob:\"Dispatch-Job abbrechen\",cancel:\"Abbrechen\",cancelling:\"Wird abgebrochen…\",status:{dispatched:\"Geplant\",processing:\"In Bearbeitung\",completed:\"Abgeschlossen\",failed:\"Fehlgeschlagen\",cancelled:\"Abgebrochen\"},toast:{cancellingUpload:\"Upload wird abgebrochen...\",cancelled:\"Dispatch abgebrochen\",cancelFailed:\"Dispatch konnte nicht abgebrochen werden\",completeWithFailures:\"Background Dispatch abgeschlossen: {{completed}} erfolgreich, {{failed}} fehlgeschlagen\",completeSuccess:\"Background Dispatch abgeschlossen: {{completed}} erfolgreich\",printStartedRemaining:\"{{completed}} Druck(e) gestartet, {{remaining}} weitere werden gesendet...\"}},stats:{title:\"Dashboard\",subtitle:\"Widgets zum Neuanordnen ziehen. Auf das Augensymbol klicken zum Ausblenden.\",overview:\"Übersicht\",totalPrints:\"Gesamtdrucke\",successRate:\"Erfolgsrate\",totalPrintTime:\"Gesamtdruckzeit\",printTime:\"Druckzeit\",totalFilament:\"Gesamtverbrauch Filament\",filamentUsed:\"Filamentverbrauch\",filamentCost:\"Filamentkosten\",totalCost:\"Gesamtkosten\",energyUsed:\"Energieverbrauch\",energyCost:\"Energiekosten\",energyWarmingUpTooltip:\"Die Energieerfassung sammelt noch stündliche Snapshots. Zeitraumwerte werden genau, sobald vor dem gewählten Bereich mindestens ein Snapshot vorliegt. Frühe Werte können zu niedrig sein.\",averagePrintTime:\"Durchschnittliche Druckzeit\",printsPerDay:\"Drucke pro Tag\",byPrinter:\"Nach Drucker\",printsByPrinter:\"Drucke nach Drucker\",byMaterial:\"Nach Material\",byMonth:\"Nach Monat\",last7Days:\"Letzte 7 Tage\",last30Days:\"Letzte 30 Tage\",last90Days:\"Letzte 90 Tage\",allTime:\"Gesamt\",quickStats:\"Schnellstatistiken\",printActivity:\"Druckaktivität\",filamentTypes:\"Filamenttypen\",filamentTrends:\"Filamenttrends\",failureAnalysis:\"Fehleranalyse\",timeAccuracy:\"Zeitgenauigkeit\",successful:\"Erfolgreich:\",failed:\"Fehlgeschlagen:\",perfectEstimate:\"100% = perfekte Schätzung\",noTimeAccuracyData:\"Noch keine Zeitgenauigkeitsdaten\",noFilamentData:\"Keine Filamentdaten verfügbar\",noPrinterData:\"Keine Druckerdaten verfügbar\",noPrintData:\"Keine Druckdaten verfügbar\",noPrintDataLast30Days:\"Keine Druckdaten in den letzten 30 Tagen\",failureReasons:\"Fehlerursachen\",topFailureReasons:\"Häufigste Fehlerursachen\",failedPrintsCount:\"{{failed}} / {{total}} Drucke fehlgeschlagen\",lastWeekRate:\"Letzte Woche: {{rate}}%\",resetLayout:\"Layout zurücksetzen\",recalculateCosts:\"Kosten neu berechnen\",recalculateCostsHint:\"Alle Archivkosten mit aktuellen Filamentpreisen neu berechnen\",exportStats:\"Statistiken exportieren\",exportAsCsv:\"Als CSV exportieren\",exportAsExcel:\"Als Excel exportieren\",hiddenCount:\"{{count}} ausgeblendet\",exportDownloaded:\"Export heruntergeladen\",exportFailed:\"Export fehlgeschlagen\",layoutReset:\"Layout zurückgesetzt\",recalculatedCosts:\"Kosten für {{count}} Archive neu berechnet\",recalculateFailed:\"Kosten konnten nicht neu berechnet werden\",loadingStats:\"Statistiken werden geladen...\",noPermissionResetLayout:\"Sie haben keine Berechtigung, das Layout zurückzusetzen\",noPermissionRecalculate:\"Sie haben keine Berechtigung, Kosten neu zu berechnen\",noPrintDataInRange:\"Keine Druckdaten im ausgewählten Zeitraum\",periodFilament:\"Filamentverbrauch\",periodCost:\"Kosten\",avgPerPrint:\"Durchschnitt pro Druck\",usageOverTime:\"Verbrauch im Zeitverlauf\",filamentByWeight:\"Gewicht\",printDuration:\"Druckdauer\",printerUtilization:\"Druckerauslastung\",filamentSuccess:\"Erfolg nach Material\",printHabits:\"Druckgewohnheiten\",printTimeOfDay:\"Druck-Tageszeit\",colorDistribution:\"Farbverteilung\",noColorData:\"Keine Farbdaten verfügbar\",records:\"Rekorde\",longestPrint:\"Längster Druck\",heaviestPrint:\"Schwerster Druck\",mostExpensivePrint:\"Teuerster Druck\",busiestDay:\"Aktivster Tag\",successStreak:\"Erfolgsserie\",streakPrint:\"aufeinanderfolgender Druck\",streakPrints:\"{{count}} aufeinanderfolgende Drucke\",printerStats:\"Druckerstatistiken\",hours:\"Stunden\",avgPrints:\"Ø Drucke\",noArchiveData:\"Keine Druckdaten verfügbar\",filamentByTime:\"Zeitverlauf\",avgWeight:\"Ø Gewicht\",avgTime:\"Ø Zeit\",filamentByPrints:\"Drucke\",timeframe:{today:\"Heute\",\"this-week\":\"Diese Woche\",\"this-month\":\"Dieser Monat\",\"last-7\":\"Letzte 7 Tage\",\"last-30\":\"Letzte 30 Tage\",\"last-90\":\"Letzte 90 Tage\",\"this-year\":\"Dieses Jahr\",\"all-time\":\"Gesamt\",custom:\"Benutzerdefiniert\",from:\"Von\",to:\"Bis\"},allUsers:\"Alle Benutzer\",noUser:\"Kein Benutzer (System)\",filterByUser:\"Nach Benutzer filtern\"},maintenance:{title:\"Wartung\",overview:\"Übersicht\",allOk:\"Alle Wartungen aktuell\",dueCount:\"{{count}} Aufgabe fällig\",dueCount_plural:\"{{count}} Aufgaben fällig\",warningCount:\"{{count}} Warnung\",warningCount_plural:\"{{count}} Warnungen\",totalPrintTime:\"Gesamtdruckzeit\",nextMaintenance:\"Nächste Wartung\",nothingDue:\"Nichts fällig\",tasks:\"Aufgaben\",lastPerformed:\"Zuletzt durchgeführt\",interval:\"Intervall\",hoursRemaining:\"{{hours}}h verbleibend\",hoursOverdue:\"{{hours}}h überfällig\",markDone:\"Als erledigt markieren\",performMaintenance:\"Wartung durchführen\",history:\"Verlauf\",noHistory:\"Kein Wartungsverlauf\",editPrintHours:\"Druckstunden bearbeiten\",currentHours:\"Aktuelle Stunden\",statusTab:\"Status\",settingsTab:\"Einstellungen\",overdueCount:\"{{count}} überfällig\",dueSoonCount:\"{{count}} bald fällig\",dueSoon:\"Bald fällig\",allGood:\"Alles in Ordnung\",overdueBy:\"Überfällig um {{duration}}\",dueIn:\"Fällig in {{duration}}\",timeLeft:\"{{duration}} verbleibend\",day:\"1 Tag\",days:\"{{count}} Tage\",week:\"1 Woche\",weeks:\"{{count}} Wochen\",month:\"1 Monat\",months:\"{{count}} Monate\",year:\"1 Jahr\",maintenanceTypes:\"Wartungstypen\",maintenanceTypesDescription:\"Systemtypen und Ihre benutzerdefinierten Wartungsaufgaben\",addCustomType:\"Benutzerdefinierten Typ hinzufügen\",restoreDefaults:\"Standardaufgaben wiederherstellen\",intervalType:\"Intervalltyp\",intervalValue:\"Intervall ({{type}})\",icon:\"Symbol\",documentationLink:\"Dokumentationslink (optional)\",assignToPrinters:\"Druckern zuweisen\",selectAtLeastOnePrinter:\"Wählen Sie mindestens einen Drucker\",addType:\"Typ hinzufügen\",custom:\"Benutzerdefiniert\",printHours:\"Druckstunden\",calendarDays:\"Kalendertage\",exampleName:\"z.B. HEPA-Filter ersetzen\",viewDocumentation:\"Dokumentation anzeigen\",timeBasedInterval:\"Zeitbasiertes Intervall\",intervalOverrides:\"Intervall-Überschreibungen\",intervalOverridesDescription:\"Intervalle für bestimmte Drucker anpassen\",assignedToPrinters:\"Druckern zugewiesen:\",noPrintersAssigned:\"Keine Drucker zugewiesen\",addPrinterShort:\"Hinzufügen:\",printersAssignedClick:\"{{count}} Drucker zugewiesen - klicken zum Verwalten\",removeFromPrinter:\"Von diesem Drucker entfernen\",types:{lubricateCarbonRods:\"Karbonstäbe schmieren\",lubricateRails:\"Linearschienen schmieren\",cleanNozzle:\"Düse/Hotend reinigen\",checkBelts:\"Riemenspannung prüfen\",cleanBuildPlate:\"Druckbett reinigen\",checkExtruder:\"Extruderzahnräder prüfen\",checkCooling:\"Kühlungslüfter prüfen\",generalInspection:\"Allgemeine Inspektion\",cleanCarbonRods:\"Kohlenstoffstangen reinigen\",lubricateSteelRods:\"Stahlstangen schmieren\",cleanSteelRods:\"Stahlstangen reinigen\",cleanLinearRails:\"Linearschienen reinigen\",checkPtfeTube:\"PTFE-Schlauch prüfen\",replaceHepaFilter:\"HEPA-Filter ersetzen\",replaceCarbonFilter:\"Aktivkohlefilter ersetzen\",lubricateLeftNozzleRail:\"Linke Düsenschiene schmieren\"},maintenanceComplete:\"Wartung als abgeschlossen markiert\",typeUpdated:\"Wartungstyp aktualisiert\",typeDeleted:\"Wartungstyp gelöscht\",defaultsRestored:\"{{count}} Standardaufgabe(n) wiederhergestellt\",printHoursUpdated:\"Druckstunden aktualisiert\",printerAssigned:\"Drucker zugewiesen\",printerRemoved:\"Drucker entfernt\",deleteTypeConfirm:'\"{{name}}\" löschen?',deleteSystemTypeTitle:\"Standard-Wartungsaufgabe löschen?\",deleteSystemTypeMessage:'Möchten Sie die Standard-Wartungsaufgabe \"{{name}}\" wirklich löschen?',noPermissionUpdate:\"Sie haben keine Berechtigung, Wartungselemente zu aktualisieren\",noPermissionPerform:\"Sie haben keine Berechtigung, Wartungen durchzuführen\",noPermissionEditTypes:\"Sie haben keine Berechtigung, Wartungstypen zu bearbeiten\",noPermissionDeleteTypes:\"Sie haben keine Berechtigung, Wartungstypen zu löschen\",noPermissionEditHours:\"Sie haben keine Berechtigung, Druckstunden zu bearbeiten\",noPermissionRemovePrinter:\"Sie haben keine Berechtigung, Druckerzuweisungen zu entfernen\",noPermissionAssignPrinter:\"Sie haben keine Berechtigung, Drucker zuzuweisen\",noPermissionEditIntervals:\"Sie haben keine Berechtigung, Intervalle zu bearbeiten\",configureSettings:\"Wartungstypen und Intervalle konfigurieren\"},settings:{title:\"Einstellungen\",general:\"Allgemein\",tabs:{general:\"Allgemein\",smartPlugs:\"Smart Plugs\",notifications:\"Benachrichtigungen\",queue:\"Workflow\",filament:\"Filament\",network:\"Netzwerk\",apiKeys:\"API-Schlüssel\",virtualPrinter:\"Virtueller Drucker\",spoolbuddy:\"SpoolBuddy\",failureDetection:\"Fehlererkennung\",users:\"Authentifizierung\",backup:\"Sicherung\",emailAuth:\"E-Mail-Authentifizierung\",ldap:\"LDAP\",twoFa:\"Zwei-Faktor-Auth\",oidc:\"SSO / OIDC\"},spoolbuddy:{infoTitle:\"SpoolBuddy-Geräte\",infoBody:\"SpoolBuddy-Kioske registrieren sich automatisch per Heartbeat. Ein Gerät hier abmelden, wenn es nicht mehr verwendet wird oder wenn ein veralteter Eintrag nach einem Daemon-Absturz übrig geblieben ist.\",duplicatesTitle:\"{{count}} Geräte registriert\",duplicatesBody:\"Die Kiosk-Oberfläche verwendet nur das zuerst registrierte Gerät. Falls eines davon ein veralteter Doppeleintrag nach einem Absturz ist, kann es hier entfernt werden — ein laufendes Gerät registriert sich beim nächsten Heartbeat automatisch neu.\",empty:\"Noch keine SpoolBuddy-Geräte registriert.\",online:\"Online\",offline:\"Offline\",unregister:\"Abmelden\",unregisterSuccess:\"Gerät abgemeldet\",unregisterError:\"Gerät konnte nicht abgemeldet werden\",confirmTitle:\"SpoolBuddy-Gerät abmelden?\",confirmBody:'Dies entfernt „{{hostname}}\" ({{deviceId}}) aus der Datenbank. Ein laufendes Gerät registriert sich beim nächsten Heartbeat automatisch neu.',ipAddress:\"IP-Adresse\",firmware:\"Firmware\",lastSeen:\"Zuletzt gesehen\",daemonUptime:\"Daemon-Laufzeit\",systemUptime:\"System-Laufzeit\",never:\"nie\",nfc:\"NFC\",scale:\"Waage\",cpuTemp:\"CPU-Temp.\",memory:\"Speicher\",disk:\"Festplatte\",update:\"Aktualisieren\",updateConfirmTitle:\"Spoolbuddy-Daemon aktualisieren?\",updateConfirmBody:'Software-Update auf „{{hostname}}\" auslösen? Der Daemon startet nach dem Update neu.',restartBrowser:\"Browser neu starten\",restartBrowserConfirmTitle:\"Kiosk-Browser neu starten?\",restartBrowserConfirmBody:'Kiosk-Browser auf „{{hostname}}\" neu starten? Die Anzeige wird kurz schwarz.',restartDaemon:\"Daemon neu starten\",restartDaemonConfirmTitle:\"Spoolbuddy-Daemon neu starten?\",restartDaemonConfirmBody:'Spoolbuddy-Daemon auf „{{hostname}}\" neu starten? Das Gerät ist für einige Sekunden offline.',reboot:\"Neustart\",rebootConfirmTitle:\"Gerät neu starten?\",rebootConfirmBody:'„{{hostname}}\" neu starten? Das Gerät ist für etwa eine Minute offline.',shutdown:\"Herunterfahren\",shutdownConfirmTitle:\"Gerät herunterfahren?\",shutdownConfirmBody:'„{{hostname}}\" herunterfahren? Physischer Zugriff ist nötig, um es wieder einzuschalten.',commandConfirm:\"Bestätigen\",commandQueued:\"Befehl eingereiht\",commandError:\"Befehl konnte nicht gesendet werden\"},ldap:{title:\"LDAP-Authentifizierung\",enabledDesc:\"LDAP-Authentifizierung ist aktiviert\",disabledDesc:\"LDAP-Authentifizierung ist deaktiviert\",disabledHint:\"LDAP-Einstellungen unten konfigurieren und speichern, dann aktivieren.\",enabled:\"LDAP-Authentifizierung aktiviert\",disabled:\"LDAP-Authentifizierung deaktiviert\",feature1:\"Benutzer können sich mit LDAP-Anmeldedaten anmelden\",feature2:\"Lokales Admin-Konto bleibt als Fallback erhalten\",feature3:\"LDAP-Gruppen werden bei der Anmeldung BamBuddy-Gruppen zugeordnet\",serverConfig:\"LDAP-Server-Konfiguration\",serverUrl:\"Server-URL\",serverUrlHint:\"Verwenden Sie ldap:// für Standard oder ldaps:// für SSL-Verbindungen\",security:\"Sicherheit\",securityHint:\"StartTLS aktualisiert eine einfache Verbindung auf TLS. LDAPS verwendet TLS von Anfang an.\",bindDn:\"Bind-DN (Dienstkonto)\",bindPassword:\"Bind-Passwort\",searchBase:\"Such-Basis-DN\",userFilter:\"Benutzer-Suchfilter\",userFilterHint:\"{username} wird durch den Anmeldenamen ersetzt. Verwenden Sie (uid={username}) für OpenLDAP.\",autoProvision:\"Benutzer automatisch anlegen\",autoProvisionHint:\"Automatisch ein BamBuddy-Konto bei der ersten LDAP-Anmeldung erstellen\",defaultGroup:\"Standardgruppe\",defaultGroupNone:\"— Keine (kein Fallback) —\",defaultGroupHint:\"Fallback-Gruppe, die zugewiesen wird, wenn sich ein LDAP-Benutzer authentifiziert, aber in keiner zugeordneten LDAP-Gruppe enthalten ist. Leer lassen, um nicht zugeordnete Benutzer ohne Berechtigungen zu belassen.\",groupMapping:\"Gruppenzuordnung (JSON)\",groupMappingHint:\"LDAP-Gruppen-DNs BamBuddy-Gruppen zuordnen. Verfügbare Gruppen: \",testConnection:\"Verbindung testen\",settingsSaved:\"LDAP-Einstellungen gespeichert\",errors:{serverRequired:\"LDAP-Server-URL ist erforderlich\",searchBaseRequired:\"Such-Basis-DN ist erforderlich\",enableAuthFirst:\"Authentifizierung zuerst aktivieren\",configureLdapFirst:\"LDAP-Einstellungen zuerst speichern\"}},email:{smtpSettings:\"SMTP-Konfiguration\",smtpHost:\"SMTP-Server\",smtpPort:\"SMTP-Port\",security:\"Sicherheit\",authentication:\"Authentifizierung\",username:\"Benutzername\",password:\"Passwort\",fromEmail:\"Absender-E-Mail\",fromName:\"Absendername\",testConnection:\"SMTP-Verbindung testen\",testRecipient:\"Test-Empfänger-E-Mail\",sendTest:\"Test-E-Mail senden\",sending:\"Wird gesendet...\",save:\"Einstellungen speichern\",saving:\"Wird gespeichert...\",advancedAuth:\"Erweiterte Authentifizierung\",advancedAuthEnabled:\"Erweiterte Authentifizierung ist aktiviert\",advancedAuthEnabledDesc:\"E-Mail-basierte Benutzerverwaltungsfunktionen sind aktiv. Neue Benutzer erhalten automatisch generierte Passwörter per E-Mail und können ihr Passwort über die Passwort vergessen Funktion zurücksetzen.\",advancedAuthDisabled:\"Erweiterte Authentifizierung ist deaktiviert\",advancedAuthDisabledDesc:\"Aktivieren Sie die erweiterte Authentifizierung, um E-Mail-basierte Funktionen für die Benutzerverwaltung zu aktivieren.\",enable:\"Aktivieren\",disable:\"Deaktivieren\",feature1:\"Passwörter werden automatisch generiert und an neue Benutzer gesendet\",feature2:\"Benutzer können sich mit Benutzername oder E-Mail anmelden\",feature3:\"Passwort vergessen Funktion ist verfügbar\",feature4:\"Administratoren können Benutzerpasswörter per E-Mail zurücksetzen\",errors:{requiredFields:\"Bitte füllen Sie alle Pflichtfelder aus\",usernameRequired:\"Benutzername ist erforderlich, wenn Authentifizierung aktiviert ist\",enterTestEmail:\"Bitte geben Sie eine Test-E-Mail-Adresse ein\",smtpServerAndEmail:\"Bitte füllen Sie SMTP-Server und Absender-E-Mail aus, bevor Sie testen\",usernamePasswordRequired:\"Benutzername und Passwort sind erforderlich, wenn Authentifizierung aktiviert ist\",configureSmtpFirst:\"Bitte konfigurieren und testen Sie zuerst die SMTP-Einstellungen\",enableAuthFirst:\"Bitte aktivieren Sie zuerst die Authentifizierung, um E-Mail-basierte Funktionen nutzen zu können.\"},success:{settingsSaved:\"SMTP-Einstellungen erfolgreich gespeichert\"},securityOptions:{starttls:\"STARTTLS (Port 587)\",ssl:\"SSL/TLS (Port 465)\",none:\"Keine (Port 25)\"},authOptions:{enabled:\"Aktiviert\",disabled:\"Deaktiviert\"}},appearance:\"Erscheinungsbild\",notifications:\"Benachrichtigungen\",smartPlugs:\"Smart Plugs\",spoolman:\"Spoolman\",updates:\"Updates\",language:\"Sprache\",languageDescription:\"Wählen Sie Ihre bevorzugte Sprache\",theme:\"Design\",themeLight:\"Hell\",themeDark:\"Dunkel\",themeSystem:\"System\",defaultView:\"Standardansicht\",defaultViewDescription:\"Seite, die beim Öffnen der App angezeigt wird\",checkForUpdates:\"Nach Updates suchen\",autoUpdate:\"Automatische Updates\",currentVersion:\"Aktuelle Version\",latestVersion:\"Neueste Version\",upToDate:\"Sie sind auf dem neuesten Stand\",updateAvailable:\"Update verfügbar\",notificationLanguage:\"Benachrichtigungssprache\",notificationLanguageDescription:\"Sprache für Push-Benachrichtigungen\",bedCooledThreshold:\"Bett-Abkühlung Schwellenwert\",bedCooledThresholdDescription:\"Temperatur, unter der das Bett nach einem Druck als abgekühlt gilt\",userNotificationsEnabled:\"Benutzerbenachrichtigungen\",userNotificationsEnabledDescription:\"Aktiviert das Benutzerbenachrichtigungsmenü und E-Mail-Benachrichtigungen für Druckereignisse. Erfordert Erweiterte Authentifizierung.\",userNotificationsDisabledHint:\"Erweiterte Authentifizierung aktivieren, um Benutzerbenachrichtigungen zu verwenden.\",notificationProviders:\"Benachrichtigungsanbieter\",addProvider:\"Anbieter hinzufügen\",editProvider:\"Anbieter bearbeiten\",providerType:\"Anbietertyp\",testNotification:\"Testbenachrichtigung\",testSuccess:\"Testbenachrichtigung erfolgreich gesendet\",testFailed:\"Testbenachrichtigung konnte nicht gesendet werden\",quietHours:\"Ruhezeiten\",quietHoursDescription:\"Keine Störungen während dieser Zeiten\",quietHoursStart:\"Beginn\",quietHoursEnd:\"Ende\",events:{title:\"Benachrichtigungsereignisse\",printStart:\"Druck gestartet\",printComplete:\"Druck abgeschlossen\",printFailed:\"Druck fehlgeschlagen\",printStopped:\"Druck gestoppt\",printProgress:\"Fortschrittsmeldungen\",printProgressDescription:\"Bei 25%, 50%, 75% benachrichtigen\",printerOffline:\"Drucker offline\",printerError:\"Druckerfehler\",filamentLow:\"Filament niedrig\",maintenanceDue:\"Wartung fällig\",maintenanceDueDescription:\"Benachrichtigen, wenn Wartung erforderlich\"},smartPlug:{title:\"Smart Plugs\",add:\"Smart Plug hinzufügen\",edit:\"Smart Plug bearbeiten\",name:\"Name\",ipAddress:\"IP-Adresse\",linkedPrinter:\"Verknüpfter Drucker\",autoOn:\"Automatisch einschalten\",autoOnDescription:\"Einschalten beim Druckstart\",autoOff:\"Automatisch ausschalten\",autoOffDescription:\"Ausschalten nach Druckende\",offDelay:\"Ausschaltverzögerung\",offDelayMinutes:\"Minuten nach Druck\",offDelayTemp:\"Wenn Düse unter Temperatur\",currentState:\"Aktueller Status\",turnOn:\"Einschalten\",turnOff:\"Ausschalten\"},filamentTracking:\"Filament-Verfolgung\",filamentTrackingDesc:\"Wählen Sie, wie Sie Ihre Filamentspulen verfolgen möchten. Sie können das integrierte Inventar oder einen externen Spoolman-Server verwenden.\",filamentChecks:\"Filament-Prüfungen\",disableFilamentWarnings:\"Filament-Warnungen deaktivieren\",disableFilamentWarningsDesc:\"Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen\",preferLowestFilament:\"Niedrigsten Filamentrest bevorzugen\",preferLowestFilamentDesc:\"Bei mehreren passenden Spulen die mit dem geringsten Restfilament verwenden\",trackingModeBuiltIn:\"Integriertes Inventar\",trackingModeBuiltInDesc:\"RFID-Erkennung und Verbrauchserfassung inklusive\",trackingModeSpoolmanDesc:\"Externer Filament-Management-Server\",builtInFeatureRfid:\"Erkennt automatisch Bambu Lab RFID-Spulen im AMS\",builtInFeatureUsage:\"Erfasst den Filamentverbrauch pro Druck\",builtInFeatureCatalog:\"Spulen, Farben und K-Faktor-Profile verwalten\",builtInFeatureThirdParty:\"Drittanbieter-Spulen können Inventarspulen zugewiesen werden\",amsSyncButton:\"Gewichte vom AMS synchronisieren\",amsSyncTitle:\"Spulengewichte vom AMS synchronisieren\",amsSyncMessage:\"Alle Inventar-Spulengewichte werden mit den aktuellen AMS-Restwerten der verbundenen Drucker überschrieben. Verwenden Sie dies zur Wiederherstellung beschädigter Gewichtsdaten. Drucker müssen online sein.\",amsSyncing:\"Synchronisiere...\",amsSyncSuccess:\"{{synced}} Spule(n) synchronisiert, {{skipped}} übersprungen\",amsSyncError:\"Synchronisierung der Gewichte vom AMS fehlgeschlagen\",spoolmanUrl:\"Spoolman URL\",spoolmanUrlHint:\"URL Ihres Spoolman-Servers (z.B. http://localhost:7912)\",spoolmanConnected:\"Verbunden\",spoolmanDisconnected:\"Nicht verbunden\",status:\"Status\",connect:\"Verbinden\",disconnect:\"Trennen\",howSyncWorks:\"So funktioniert die Synchronisierung\",syncInfoRfidOnly:\"Nur offizielle Bambu Lab Spulen mit RFID werden synchronisiert\",syncInfoAutoCreate:\"Neue Spulen werden bei der ersten Synchronisierung automatisch in Spoolman erstellt\",syncInfoThirdPartySkipped:\"Nicht-Bambu-Lab-Spulen (Drittanbieter, nachgefüllt) werden übersprungen\",linkingExistingSpools:\"Vorhandene Spulen verknüpfen\",linkingExistingSpoolsDesc:'Um vorhandene Spoolman-Spulen mit Ihrem AMS zu verknüpfen, fahren Sie über einen AMS-Slot und klicken Sie auf \"Mit Spoolman verknüpfen\".',syncMode:\"Synchronisierungsmodus\",syncModeAuto:\"Automatisch\",syncModeManual:\"Nur manuell\",syncModeAutoDesc:\"AMS-Daten werden automatisch synchronisiert, wenn Änderungen erkannt werden\",syncModeManualDesc:\"Nur bei manueller Auslösung synchronisieren\",syncAmsData:\"AMS-Daten synchronisieren\",syncAmsDataDesc:\"AMS-Daten des Druckers manuell mit Spoolman synchronisieren\",allPrinters:\"Alle Drucker\",noDefaultPrinter:\"Kein Standard (jedes Mal fragen)\",sidebarOrder:\"Seitenleisten-Reihenfolge\",saveThumbnails:\"Vorschaubilder speichern\",captureFinishPhoto:\"Abschlussfoto aufnehmen\",noPrintersConfigured:\"Keine Drucker konfiguriert\",archiveMode:{always:\"Immer Archiveintrag erstellen\",never:\"Nie Archiveintrag erstellen\",ask:\"Jedes Mal fragen\"},checkForUpdatesLabel:\"Nach Updates suchen\",checkPrinterFirmware:\"Drucker-Firmware prüfen\",includeBetaUpdates:\"Beta-Versionen einschließen\",includeBetaUpdatesDesc:\"Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen\",enableRetry:\"Wiederholung aktivieren\",homeAssistantDescription:\"Smart Plugs über Home Assistant steuern\",environmentManagedLabel:\"(Umgebungsvariable)\",autoEnabledViaEnv:\"Automatisch über Umgebungsvariablen aktiviert\",urlFromEnvReadOnly:\"Wert wird über HA_URL Umgebungsvariable gesetzt (schreibgeschützt)\",tokenFromEnvReadOnly:\"Wert wird über HA_TOKEN Umgebungsvariable gesetzt (schreibgeschützt)\",mqttConnectedTo:\"Verbunden mit\",prometheusDescription:\"Druckerdaten im Prometheus-Format bereitstellen\",noSmartPlugsTitle:\"Keine Smart Plugs konfiguriert\",noSmartPlugsDescription:\"Fügen Sie einen Tasmota-basierten Smart Plug hinzu, um den Energieverbrauch zu verfolgen und die Stromsteuerung zu automatisieren.\",noProvidersTitle:\"Keine Anbieter konfiguriert\",noProvidersDescription:\"Fügen Sie einen Anbieter hinzu, um Benachrichtigungen zu erhalten.\",noTemplatesAvailable:\"Keine Vorlagen verfügbar. Starten Sie das Backend neu, um Standardvorlagen zu laden.\",apiPermissionView:\"Druckerstatus und Warteschlange anzeigen\",apiPermissionEdit:\"Elemente zur Druckwarteschlange hinzufügen und entfernen\",apiKeysEmptyTitle:\"Keine API-Schlüssel\",apiKeysEmptyDescription:\"Erstellen Sie einen API-Schlüssel zur Integration mit externen Diensten.\",noUsersFound:\"Keine Benutzer gefunden\",noGroupsFound:\"Keine Gruppen gefunden\",noGroupsAvailable:\"Keine Gruppen verfügbar\",passwordsDoNotMatch:\"Passwörter stimmen nicht überein\",systemGroupWarning:\"System-Gruppennamen können nicht geändert werden\",authDisabledTitle:\"Authentifizierung ist deaktiviert\",authDisabledFeature1:\"Anmeldung zum Zugriff auf das System erforderlich\",authDisabledFeature2:\"Mehrere Benutzer mit gruppenbasierten Berechtigungen erstellen\",authDisabledFeature3:\"Zugriff mit über 50 granularen Berechtigungen steuern\",userHasCreated:\"Dieser Benutzer hat erstellt:\",userItemsQuestion:\"Was möchten Sie mit diesen Elementen tun?\",deleteUserConfirm:\"Möchten Sie diesen Benutzer wirklich löschen?\",actionCannotBeUndone:\"Diese Aktion kann nicht rückgängig gemacht werden.\",addFirstSmartPlug:\"Ersten Smart Plug hinzufügen\",providers:\"Anbieter\",log:\"Protokoll\",testAll:\"Alle testen\",testResults:\"Testergebnisse\",testPassedCount:\"{{count}} bestanden\",testFailedCount:\"{{count}} fehlgeschlagen\",messageTemplates:\"Nachrichtenvorlagen\",messageTemplatesDescription:\"Passen Sie Benachrichtigungen für jedes Ereignis an.\",apiKeys:\"API-Schlüssel\",apiKeysDescription:\"Erstellen Sie API-Schlüssel für externe Integrationen und Webhooks.\",createKey:\"Schlüssel erstellen\",apiKeyCreated:\"API-Schlüssel erfolgreich erstellt\",apiKeyCopyWarning:\"Kopieren Sie diesen Schlüssel jetzt - er wird nicht mehr angezeigt!\",useInApiBrowser:\"Im API-Browser verwenden\",createNewApiKey:\"Neuen API-Schlüssel erstellen\",keyName:\"Schlüsselname\",keyNamePlaceholder:\"z.B. Home Assistant, OctoPrint\",readStatus:\"Status lesen\",readStatusDescription:\"Druckerstatus und Warteschlange anzeigen\",manageQueue:\"Warteschlange verwalten\",manageQueueDescription:\"Elemente zur Druckwarteschlange hinzufügen und entfernen\",controlPrinter:\"Drucker steuern\",controlPrinterDescription:\"Drucke pausieren, fortsetzen und stoppen\",unnamedKey:\"Unbenannter Schlüssel\",lastUsed:\"Zuletzt verwendet\",read:\"Lesen\",control:\"Steuern\",createFirstKey:\"Ersten Schlüssel erstellen\",webhookEndpoints:\"Webhook-Endpunkte\",webhookApiKeyHint:\"Verwenden Sie Ihren API-Schlüssel im X-API-Key-Header.\",webhook:{getAllStatus:\"Alle Druckerstatus abrufen\",getSpecificStatus:\"Spezifischen Druckerstatus abrufen\",addToQueue:\"Zur Druckwarteschlange hinzufügen\",pausePrint:\"Druck pausieren\",resumePrint:\"Druck fortsetzen\",stopPrint:\"Druck stoppen\"},apiBrowser:\"API-Browser\",apiBrowserDescription:\"Erkunden und testen Sie alle verfügbaren API-Endpunkte.\",apiKeyForTesting:\"API-Schlüssel zum Testen\",apiKeyPlaceholder:\"Fügen Sie hier Ihren API-Schlüssel ein, um authentifizierte Endpunkte zu testen...\",apiKeyHint:\"Dieser Schlüssel wird als X-API-Key-Header mit Anfragen gesendet.\",deleteApiKeyTitle:\"API-Schlüssel löschen\",deleteApiKeyMessage:\"Möchten Sie diesen API-Schlüssel wirklich löschen? Alle Integrationen, die diesen Schlüssel verwenden, funktionieren nicht mehr.\",deleteKey:\"Schlüssel löschen\",amsDisplayThresholds:\"AMS-Anzeigeschwellenwerte\",amsThresholdsDescription:\"Konfigurieren Sie Farbschwellenwerte für AMS-Feuchtigkeits- und Temperaturanzeigen.\",humidity:\"Luftfeuchtigkeit\",goodGreen:\"Gut (grün)\",fairOrange:\"Mittel (orange)\",aboveFairBad:\"Über dem mittleren Schwellenwert wird rot angezeigt (schlecht)\",fairAlsoDryingThreshold:\"Dieser Schwellenwert wird auch für die automatische Trocknung verwendet\",temperature:\"Temperatur\",goodBlue:\"Gut (blau)\",aboveFairHot:\"Über dem mittleren Schwellenwert wird rot angezeigt (heiß)\",historyRetention:\"Verlaufsaufbewahrung\",keepSensorHistory:\"Sensorverlauf behalten für\",historyRetentionDescription:\"Ältere Feuchtigkeits- und Temperaturdaten werden automatisch gelöscht\",defaultPrintOptions:\"Standard-Druckoptionen\",defaultPrintOptionsDescription:\"Standardwerte für Druckoptionen bei neuen Drucken festlegen. Diese können im Druckdialog pro Druck überschrieben werden.\",defaultBedLevelling:\"Bett-Nivellierung\",defaultBedLevellingDesc:\"Bett vor dem Druck automatisch nivellieren\",defaultFlowCali:\"Fluss-Kalibrierung\",defaultFlowCaliDesc:\"Extrusionsfluss kalibrieren\",defaultVibrationCali:\"Vibrationskalibrierung\",defaultVibrationCaliDesc:\"Ringing-Artefakte reduzieren\",defaultLayerInspect:\"Erste-Schicht-Inspektion\",defaultLayerInspectDesc:\"KI-Inspektion der ersten Schicht\",defaultTimelapse:\"Zeitraffer\",defaultTimelapseDesc:\"Zeitraffervideo aufnehmen\",staggeredStart:\"Staggered Start\",staggeredStartDescription:\"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.\",plateClear:\"Druckplatte-Bestätigung\",requirePlateClear:\"Druckplatte-Bestätigung erforderlich\",requirePlateClearDescription:'Wenn aktiviert, wartet der Scheduler auf eine Druckplatten-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Wenn dies deaktiviert ist, werden auch das Druckplatten-Status-Badge und die Schaltfläche \"Druckplatte als freigegeben markieren\" auf den Druckerkarten ausgeblendet.',gcodeInjection:\"G-code Injection\",gcodeInjectionDescription:'Konfigurieren Sie benutzerdefinierten G-code, der am Anfang und/oder Ende von Drucken für Auto-Print-Systeme wie Farmloop, SwapMod, AutoClear und Printflow 3D eingefügt wird. Snippets werden pro Druckermodell konfiguriert und angewendet, wenn \"G-code einfügen\" bei einem Warteschlangen-Element aktiviert ist.',gcodeInjectionNoPrinters:\"Keine Drucker gefunden. Fügen Sie Drucker hinzu, um G-code-Snippets zu konfigurieren.\",gcodeStartLabel:\"Start G-code\",gcodeEndLabel:\"End G-code\",gcodeStartPlaceholder:\"G-code, der vor dem Druckstart eingefügt wird...\",gcodeEndPlaceholder:\"G-code, der nach dem Druckende angefügt wird...\",staggerGroupSize:\"Group size\",staggerGroupSizeHelp:\"Printers to start simultaneously per group\",staggerInterval:\"Interval (minutes)\",staggerIntervalHelp:\"Delay between each group starting\",queueDrying:\"Automatische Trocknung\",queueDryingDescription:\"AMS-Filament automatisch trocknen, wenn der Drucker zwischen Warteschlangen-Drucken im Leerlauf ist. Verwendet den Feuchtigkeitsschwellenwert oben.\",queueDryingEnabled:\"Automatische Trocknung aktivieren\",queueDryingEnabledDescription:\"AMS-Trocknung automatisch starten, wenn der Drucker im Leerlauf ist und die Feuchtigkeit über dem Schwellenwert liegt\",queueDryingBlock:\"Auf Trocknung warten\",queueDryingBlockDescription:\"Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.\",ambientDryingEnabled:\"Umgebungstrocknung\",ambientDryingEnabledDescription:\"Filament auf inaktiven Druckern automatisch trocknen, wenn die Luftfeuchtigkeit den Schwellenwert überschreitet — auch ohne Warteschlange.\",dryingPresets:\"Trocknungsvoreinstellungen\",dryingPresetsDescription:\"Temperatur und Dauer pro Filamenttyp. AMS 2 Pro verwendet niedrigere Temperaturen, AMS-HT unterstützt höhere.\",dryingFilament:\"Filament\",printModal:\"Druckdialog\",expandCustomMapping:\"Benutzerdefinierte Zuordnung standardmäßig erweitern\",expandCustomMappingDescription:\"Bei Druck auf mehrere Drucker die AMS-Zuordnung pro Drucker erweitert anzeigen\",authentication:\"Authentifizierung\",authEnabledDescription:\"Ihre Instanz ist mit Benutzerauthentifizierung gesichert\",authDisabledDescription:\"Aktivieren Sie die Anmeldepflicht und verwalten Sie den Benutzerzugriff\",authDisabledMessage:\"Aktivieren Sie die Authentifizierung, um Benutzerkonten zu erstellen, Berechtigungen zu verwalten und Ihre Bambuddy-Instanz zu sichern.\",enableAuthentication:\"Authentifizierung aktivieren\",currentUser:\"Aktueller Benutzer\",changePassword:\"Passwort ändern\",admin:\"Admin\",users:\"Benutzer\",addUser:\"Benutzer hinzufügen\",groups:\"Gruppen\",addGroup:\"Gruppe hinzufügen\",system:\"System\",noDescription:\"Keine Beschreibung\",userCount:\"{{count}} Benutzer\",permissionCount:\"{{count}} Berechtigungen\",createUser:\"Benutzer erstellen\",username:\"Benutzername\",enterUsername:\"Benutzername eingeben\",password:\"Passwort\",enterPassword:\"Passwort eingeben (min. 6 Zeichen)\",confirmPassword:\"Passwort bestätigen\",confirmPasswordPlaceholder:\"Passwort bestätigen\",viewReleaseOnGitHub:\"Release auf GitHub anzeigen\",turnAllPlugsOn:\"Alle Stecker einschalten\",turnAllPlugsOff:\"Alle Stecker ausschalten\",clearNotificationLogs:\"Benachrichtigungsprotokolle löschen\",clearLogsMessage:\"Dadurch werden alle Benachrichtigungsprotokolle, die älter als 30 Tage sind, dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.\",clearLogs:\"Protokolle löschen\",resetUiPreferences:\"UI-Einstellungen zurücksetzen\",resetUiPreferencesMessage:\"Dadurch werden alle UI-Einstellungen auf Standardwerte zurückgesetzt: Seitenleisten-Reihenfolge, Theme, Dashboard-Layout, Ansichtsmodi und Sortiereinstellungen. Ihre Drucker, Archive und Servereinstellungen werden NICHT beeinträchtigt. Die Seite wird nach dem Löschen neu geladen.\",resetPreferences:\"Einstellungen zurücksetzen\",deleteGroupTitle:\"Gruppe löschen\",deleteGroupMessage:\"Möchten Sie diese Gruppe wirklich löschen? Benutzer in dieser Gruppe verlieren diese Berechtigungen.\",deleteGroup:\"Gruppe löschen\",disableAuthenticationTitle:\"Authentifizierung deaktivieren\",disableAuthenticationMessage:\"Möchten Sie die Authentifizierung wirklich deaktivieren? Dadurch wird Ihre Bambuddy-Instanz ohne Anmeldung zugänglich. Alle Benutzer bleiben in der Datenbank, aber die Authentifizierung wird deaktiviert.\",disableAuthentication:\"Authentifizierung deaktivieren\",configureBambuddy:\"Bambuddy konfigurieren\",systemDefault:\"Systemstandard\",archiveSettings:\"Archiv-Einstellungen\",newWindow:\"Neues Fenster\",embeddedOverlay:\"Eingebettetes Overlay\",preferredSlicer:\"Bevorzugter Slicer\",preferredSlicerDescription:\"Wähle die Slicer-Anwendung zum Öffnen von Dateien\",externalCameras:\"Externe Kameras\",costTracking:\"Kostenverfolgung\",printsOnly:\"Nur Drucke\",totalConsumption:\"Gesamtverbrauch\",dataManagement:\"Datenverwaltung\",storageUsage:\"Speichernutzung\",storageUsageDescription:\"Aufschlüsselung der Datennutzung nach Kategorie\",storageUsageTotal:\"Gesamt\",storageUsageErrors:\"Fehler\",storageUsageOtherBreakdown:\"Sonstiges (enthält statische Assets, Skripte und Konfigurationsdateien)\",storageUsageSystem:\"System\",storageUsageData:\"Daten\",storageUsageUnavailable:\"Speichernutzungsinformationen nicht verfügbar\",clearNotificationLogsDescription:\"Benachrichtigungsprotokolle älter als 30 Tage löschen\",resetUiPreferencesDescription:\"Seitenleisten-Reihenfolge, Theme, Ansichtsmodi und Layout-Einstellungen zurücksetzen. Drucker, Archive und Einstellungen werden nicht beeinflusst.\",enableHomeAssistant:\"Home Assistant aktivieren\",enableMqtt:\"MQTT aktivieren\",useTls:\"TLS verwenden\",enableMetricsEndpoint:\"Metrik-Endpunkt aktivieren\",availableMetrics:\"Verfügbare Metriken\",editUser:\"Benutzer bearbeiten\",deleteUserTitle:\"Benutzer löschen\",groupName:\"Gruppenname\",leaveEmptyForAnonymous:\"Leer lassen für anonym\",leaveEmptyForNoAuth:\"Leer lassen für keine Authentifizierung\",enterNewPassword:\"Neues Passwort eingeben\",confirmNewPassword:\"Neues Passwort bestätigen\",enterGroupName:\"Gruppenname eingeben\",enterDescriptionOptional:\"Beschreibung eingeben (optional)\",enterCurrentPassword:\"Aktuelles Passwort eingeben\",enterNewPasswordMin6:\"Neues Passwort eingeben (min. 6 Zeichen)\",toast:{keyCopied:\"Schlüssel in Zwischenablage kopiert\",copyFailed:\"Schlüssel konnte nicht kopiert werden\",keyAddedToBrowser:\"Schlüssel zum API-Browser hinzugefügt\",clearLogsFailed:\"Protokolle konnten nicht gelöscht werden\",uiPreferencesReset:\"UI-Einstellungen zurückgesetzt. Wird neu geladen...\",authDisabled:\"Authentifizierung erfolgreich deaktiviert\",authDisableFailed:\"Authentifizierung konnte nicht deaktiviert werden\",apiKeyCreated:\"API-Schlüssel erstellt\",apiKeyDeleted:\"API-Schlüssel gelöscht\",userCreated:\"Benutzer erfolgreich erstellt\",userUpdated:\"Benutzer erfolgreich aktualisiert\",userDeleted:\"Benutzer erfolgreich gelöscht\",groupCreated:\"Gruppe erfolgreich erstellt\",groupUpdated:\"Gruppe erfolgreich aktualisiert\",groupDeleted:\"Gruppe erfolgreich gelöscht\",fillRequiredFields:\"Bitte füllen Sie alle erforderlichen Felder aus\",passwordsDoNotMatch:\"Passwörter stimmen nicht überein\",passwordTooShort:\"Passwort muss mindestens 6 Zeichen lang sein\",enterGroupName:\"Bitte geben Sie einen Gruppennamen ein\",settingsSaved:\"Einstellungen gespeichert\",cameraSettingsSaved:\"Kamera-Einstellungen gespeichert\",enterCameraUrl:\"Bitte geben Sie eine Kamera-URL ein\",passwordChanged:\"Passwort erfolgreich geändert\",connectionFailed:\"Verbindung fehlgeschlagen\",testFailed:\"Test fehlgeschlagen\",cameraConnected:\"Kamera verbunden{{resolution}}\"},testConnection:\"Verbindung testen\",catalog:{spoolCatalog:\"Spulenkatalog\",spoolCatalogDescription:\"Leerspulengewichte nach Marke/Typ. Wird für die automatische Gewichtssuche beim Hinzufügen von Spulen verwendet.\",searchCatalog:\"Katalog durchsuchen...\",addNewEntry:\"Neuen Eintrag hinzufügen\",namePlaceholder:\"Name (z.B. Bambu Lab - Plastik)\",weight:\"Gewicht\",type:\"Typ\",default:\"Standard\",custom:\"Benutzerdefiniert\",noMatch:\"Keine Einträge entsprechen Ihrer Suche\",empty:\"Keine Einträge im Katalog\",deleteEntry:\"Eintrag löschen\",deleteConfirm:'Möchten Sie \"{{name}}\" wirklich löschen?',resetCatalog:\"Katalog zurücksetzen\",resetConfirm:\"Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Einträge werden entfernt.\",loadFailed:\"Spulenkatalog konnte nicht geladen werden\",nameWeightRequired:\"Name und Gewicht sind erforderlich\",entryAdded:\"Eintrag hinzugefügt\",addFailed:\"Eintrag konnte nicht hinzugefügt werden\",entryUpdated:\"Eintrag aktualisiert\",updateFailed:\"Eintrag konnte nicht aktualisiert werden\",entryDeleted:\"Eintrag gelöscht\",deleteFailed:\"Eintrag konnte nicht gelöscht werden\",resetSuccess:\"Katalog auf Standardwerte zurückgesetzt\",resetFailed:\"Katalog konnte nicht zurückgesetzt werden\",exported:\"{{count}} Einträge exportiert\",imported:\"{{added}} Einträge importiert ({{skipped}} übersprungen)\",importFailed:\"Import fehlgeschlagen: ungültiges JSON-Format\",exportTooltip:\"Katalog als JSON exportieren\",importTooltip:\"Katalog aus JSON importieren\",resetTooltip:\"Auf Standardwerte zurücksetzen\",selectedCount:\"{{count}} ausgewählt\",deleteSelected:\"Ausgewählte löschen\",bulkDeleteConfirm:\"Möchten Sie {{count}} Einträge wirklich löschen?\",bulkDeleted:\"{{count}} Einträge gelöscht\",bulkDeleteFailed:\"Fehler beim Löschen der Einträge\"},colorCatalog:{title:\"Farbkatalog\",description:\"Filamentfarben nach Hersteller/Material. Wird für die automatische Farbsuche beim Hinzufügen von Spulen verwendet.\",searchColors:\"Farben durchsuchen...\",allManufacturers:\"Alle Hersteller\",addNewColor:\"Neue Farbe hinzufügen\",manufacturer:\"Hersteller\",colorName:\"Farbname\",hex:\"Hex\",materialOptional:\"Material (optional)\",showing:\"{{filtered}} von {{total}} Farben angezeigt\",noMatch:\"Keine Farben entsprechen Ihrer Suche\",empty:\"Keine Farben im Katalog\",deleteColor:\"Farbe löschen\",deleteConfirm:'Möchten Sie \"{{name}}\" wirklich löschen?',resetCatalog:\"Farbkatalog zurücksetzen\",resetConfirm:\"Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Farben werden entfernt.\",sync:\"Sync\",starting:\"Starten...\",syncTooltip:\"Von FilamentColors.xyz synchronisieren (2000+ Farben)\",loadFailed:\"Farbkatalog konnte nicht geladen werden\",fieldsRequired:\"Hersteller, Farbname und Hex-Farbe sind erforderlich\",colorAdded:\"Farbe hinzugefügt\",addFailed:\"Farbe konnte nicht hinzugefügt werden\",colorUpdated:\"Farbe aktualisiert\",updateFailed:\"Farbe konnte nicht aktualisiert werden\",colorDeleted:\"Farbe gelöscht\",deleteFailed:\"Farbe konnte nicht gelöscht werden\",resetSuccess:\"Farbkatalog auf Standardwerte zurückgesetzt\",resetFailed:\"Katalog konnte nicht zurückgesetzt werden\",syncUpToDate:\"Bereits aktuell ({{count}} Farben geprüft)\",syncComplete:\"{{added}} neue Farben hinzugefügt ({{skipped}} bereits vorhanden)\",syncError:\"Sync-Fehler\",syncFailed:\"Synchronisierung von FilamentColors.xyz fehlgeschlagen\",exported:\"{{count}} Farben exportiert\",imported:\"{{added}} Farben importiert ({{skipped}} übersprungen)\",importFailed:\"Import fehlgeschlagen: ungültiges JSON-Format\",selectedCount:\"{{count}} ausgewählt\",deleteSelected:\"Ausgewählte löschen\",bulkDeleteConfirm:\"Möchten Sie {{count}} Farben wirklich löschen?\",bulkDeleted:\"{{count}} Farben gelöscht\",bulkDeleteFailed:\"Fehler beim Löschen der Farben\"},dateFormat:\"Datumsformat\",dateFormatUs:\"US (MM/TT/JJJJ)\",dateFormatEu:\"EU (TT/MM/JJJJ)\",dateFormatIso:\"ISO (JJJJ-MM-TT)\",timeFormat:\"Zeitformat\",timeFormat12:\"12-Stunden (3:30 PM)\",timeFormat24:\"24-Stunden (15:30)\",defaultPrinter:\"Standarddrucker\",defaultPrinterDescription:\"Diesen Drucker für Uploads, Nachdrucke und andere Vorgänge vorauswählen.\",slicerBambuStudio:\"Bambu Studio\",slicerOrcaSlicer:\"OrcaSlicer\",sidebarOrderDescription:\"Elemente in der Seitenleiste per Drag & Drop neu anordnen. Hier auf Standardreihenfolge zurücksetzen.\",setDefault:\"Standard setzen\",sidebarOrderSetDefaultHint:\"Standard setzen übernimmt die aktuelle Menüreihenfolge für Benutzer, die ihre noch nicht angepasst haben.\",sidebarDefaultSet:\"Standard-Menüreihenfolge wurde festgelegt.\",sidebarDefaultCleared:\"Standard-Menüreihenfolge gelöscht.\",sidebarDefaultFailed:\"Festlegen der Standard-Menüreihenfolge fehlgeschlagen.\",reset:\"Zurücksetzen\",darkMode:\"Dunkelmodus\",lightMode:\"Hellmodus\",active:\"(aktiv)\",background:\"Hintergrund\",accent:\"Akzent\",style:\"Stil\",bgNeutral:\"Neutral\",bgWarm:\"Warm\",bgCool:\"Kühl\",bgOled:\"OLED Schwarz\",bgSlate:\"Schieferblau\",bgForest:\"Waldgrün\",accentGreen:\"Grün\",accentTeal:\"Türkis\",accentBlue:\"Blau\",accentOrange:\"Orange\",accentPurple:\"Lila\",accentRed:\"Rot\",styleClassic:\"Klassisch\",styleGlow:\"Leuchtend\",styleVibrant:\"Lebendig\",themeToggleHint:\"Zwischen Hell- und Dunkelmodus mit dem Sonnen-/Mondsymbol in der Seitenleiste wechseln.\",autoArchivePrints:\"Drucke automatisch archivieren\",autoArchiveDescription:\"3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind\",saveThumbnailsDescription:\"Vorschaubilder aus 3MF-Dateien extrahieren und speichern\",captureFinishPhotoDescription:\"Foto von der Druckerkamera aufnehmen, wenn der Druck abgeschlossen ist\",ffmpegNotInstalled:\"ffmpeg nicht installiert\",ffmpegRequired:\"Kameraaufnahme benötigt ffmpeg. Installieren über <brew>brew install ffmpeg</brew> (macOS) oder <apt>apt install ffmpeg</apt> (Linux).\",camera:\"Kamera\",cameraViewMode:\"Kamera-Ansichtsmodus\",cameraOverlayDescription:\"Kamera öffnet sich als größenveränderbares Overlay auf dem Hauptbildschirm\",cameraWindowDescription:\"Kamera öffnet sich in einem separaten Browserfenster\",externalCamerasDescription:\"Externe Kameras konfigurieren, um die eingebaute Druckerkamera zu ersetzen. Unterstützt MJPEG-Streams, RTSP, HTTP-Snapshots und USB-Kameras (V4L2). Wenn aktiviert, wird die externe Kamera für Live-Ansicht und Abschlussfotos verwendet.\",cameraPlaceholderUsb:\"Gerätepfad (/dev/video0)\",cameraPlaceholderUrl:\"Kamera-URL (rtsp://... oder http://...)\",cameraTypeMjpeg:\"MJPEG-Stream\",cameraTypeRtsp:\"RTSP-Stream\",cameraTypeSnapshot:\"HTTP-Snapshot\",cameraTypeUsb:\"USB-Kamera (V4L2)\",cameraRotation:\"Drehung\",test:\"Testen\",connected:\"Verbunden\",disconnected:\"Getrennt\",currency:\"Währung\",defaultFilamentCost:\"Standard-Filamentkosten (pro kg)\",electricityCost:\"Stromkosten pro kWh\",energyDisplayMode:\"Energieanzeige-Modus\",energyModePrintDescription:\"Dashboard zeigt Summe der während Drucken verbrauchten Energie\",energyModeTotalDescription:\"Dashboard zeigt Gesamtenergie der Smart Plugs\",fileManager:\"Dateimanager\",createArchiveEntry:\"Archiveintrag beim Drucken erstellen\",createArchiveEntryDescription:\"Beim Drucken aus dem Dateimanager optional einen Archiveintrag erstellen\",lowDiskSpaceWarning:\"Warnung bei wenig Speicherplatz\",lowDiskSpaceDescription:\"Warnung anzeigen, wenn freier Speicherplatz unter diesen Schwellenwert fällt\",printerFirmware:\"Drucker-Firmware\",checkFirmwareDescription:\"Nach Firmware-Updates von Bambu Lab suchen\",bambuddySoftware:\"Bambuddy Software\",autoCheckDescription:\"Automatisch beim Start nach neuen Versionen suchen\",checkNow:\"Jetzt prüfen\",updateAvailableVersion:\"Update verfügbar: v{{version}}\",releaseNotes:\"Versionshinweise\",updateViaDocker:\"Update über Docker Compose:\",installUpdate:\"Update installieren\",latestVersionRunning:\"Sie verwenden die neueste Version\",failedToCheckUpdates:\"Update-Prüfung fehlgeschlagen: {{error}}\",backupRestore:\"Sicherung & Wiederherstellung\",backupRestoreDescription:\"Einstellungen exportieren/importieren und GitHub-Backup konfigurieren\",goToBackup:\"Zur Sicherung\",externalUrl:\"Externe URL\",externalUrlDescription:\"Die externe URL, unter der Bambuddy erreichbar ist. Wird für Benachrichtigungsbilder und externe Integrationen verwendet.\",bambuddyUrl:\"Bambuddy-URL\",externalUrlHint:\"Protokoll und Port angeben (z.B. http://192.168.1.100:8000)\",ftpRetry:\"FTP-Wiederholung\",ftpRetryDescription:\"FTP-Operationen bei unzuverlässigem Drucker-WLAN wiederholen. Gilt für 3MF-Downloads, Druck-Uploads, Zeitraffer-Downloads und Firmware-Updates.\",autoRetryDescription:\"Fehlgeschlagene FTP-Operationen automatisch wiederholen\",retryAttempts:\"Wiederholungsversuche\",retryDelay:\"Wiederholungsverzögerung\",connectionTimeout:\"Verbindungs-Timeout\",time_one:\"{{count}} Mal\",time_other:\"{{count}} Mal\",second_one:\"{{count}} Sekunde\",second_other:\"{{count}} Sekunden\",nSeconds:\"{{count}} Sekunden\",increaseForWeakWifi:\"Erhöhen für Drucker mit schwachem WLAN\",homeAssistant:\"Home Assistant\",homeAssistantFullDescription:\"Mit Home Assistant verbinden, um Smart Plugs über die HA REST-API zu steuern. Unterstützt Switch-, Light-, Input_Boolean- und Script-Entitäten.\",homeAssistantUrl:\"Home Assistant URL\",longLivedAccessToken:\"Langlebiges Zugriffstoken\",haTokenHint:\"Token in HA erstellen: Profil → Langlebige Zugriffstoken → Token erstellen\",connectionSuccessful:\"Verbindung erfolgreich\",connectionFailed:\"Verbindung fehlgeschlagen\",haConnectionSuccess:\"Erfolgreich mit Home Assistant verbunden.\",haConnectionFailed:\"Verbindung zu Home Assistant fehlgeschlagen.\",mqttPublishing:\"MQTT-Veröffentlichung\",mqttDescription:\"BamBuddy-Ereignisse an einen externen MQTT-Broker zur Integration mit Node-RED, Home Assistant und anderen Automatisierungssystemen veröffentlichen.\",mqttEnableDescription:\"Ereignisse an externen MQTT-Broker veröffentlichen\",brokerHostname:\"Broker-Hostname\",port:\"Port\",usernameOptional:\"Benutzername (optional)\",passwordOptional:\"Passwort (optional)\",topicPrefix:\"Topic-Präfix\",topicPrefixHint:\"Topics werden sein: {{prefix}}/printers/<serial>/status, etc.\",prometheusMetrics:\"Prometheus-Metriken\",prometheusEndpointDescription:\"Druckermetriken unter <code>/api/v1/metrics</code> für Prometheus/Grafana-Überwachung bereitstellen.\",bearerTokenOptional:\"Bearer-Token (optional)\",bearerTokenHint:\"Wenn gesetzt, müssen Anfragen <code>Authorization: Bearer <token></code> enthalten\",metricsConnectionStatus:\"Verbindungsstatus\",metricsPrinterState:\"Druckerstatus (idle/printing/etc)\",metricsPrintProgress:\"Druckfortschritt 0-100%\",metricsBedTemp:\"Betttemperatur\",metricsNozzleTemp:\"Düsentemperatur\",metricsPrintsTotal:\"Gesamtdrucke nach Ergebnis\",metricsMore:\"...und mehr (Schichten, Lüfter, Warteschlange, Filamentverbrauch)\",smartPlugsDescription:\"Smart Plugs (Tasmota oder Home Assistant) verbinden, um Stromsteuerung zu automatisieren und Energieverbrauch für Ihre Drucker zu verfolgen.\",allOn:\"Alle Ein\",allOff:\"Alle Aus\",addSmartPlug:\"Smart Plug hinzufügen\",energySummary:\"Energieübersicht\",currentPower:\"Aktuelle Leistung\",plugsOnline:\"{{reachable}}/{{total}} Plugs online\",today:\"Heute\",yesterday:\"Gestern\",total:\"Gesamt\",enablePlugsForSummary:\"Plugs aktivieren, um Energieübersicht zu sehen\",addNotificationProvider:\"Hinzufügen\",systemBadge:\"(System)\",creating:\"Erstellen...\",changing:\"Ändern...\",deleteUserAndItems:\"Benutzer UND dessen Elemente löschen\",deleteUserKeepItems:\"Benutzer löschen, Elemente behalten (werden herrenlos)\",ok:\"OK\",twoFa:{totpTitle:\"Authenticator-App (TOTP)\",totpDesc:\"Verwende eine Authenticator-App wie Google Authenticator, Aegis oder Authy.\",emailOtpTitle:\"E-Mail OTP\",emailOtpDesc:\"Sende einen Einmalcode an {{email}} beim Einloggen.\",emailOtpNoEmail:\"Füge eine E-Mail-Adresse zu deinem Konto hinzu, um diese Methode zu aktivieren.\",addEmailFirst:\"Dein Konto hat keine E-Mail-Adresse. Bitte einen Administrator, eine hinzuzufügen.\",setupTotp:\"Authenticator-App einrichten\",setupAuthApp:\"Authenticator-App einrichten\",setupInstructions:\"Scanne den QR-Code mit deiner Authenticator-App und bestätige mit einem Code.\",manualEntry:\"Kein Scanner? Gib dieses Secret manuell ein:\",scannedContinue:\"Code gescannt — weiter\",enterCodeToConfirm:\"Gib den 6-stelligen Code aus deiner Authenticator-App ein, um die Einrichtung zu bestätigen.\",activate:\"Aktivieren\",disableTotp:\"Authenticator deaktivieren\",disableConfirmHint:\"Gib einen gültigen TOTP-Code oder einen Backup-Code ein, um den Authenticator zu deaktivieren.\",totpDisabled:\"Authenticator-App deaktiviert.\",emailOtpEnabled:\"E-Mail OTP aktiviert.\",emailOtpDisabled:\"E-Mail OTP deaktiviert.\",smtpRequired:\"Bitte konfigurieren und testen Sie zuerst die SMTP-Einstellungen.\",invalidCode:\"Ungültiger Code. Bitte erneut versuchen.\",enableEmailOtp:\"E-Mail OTP aktivieren\",disableEmailOtp:\"E-Mail OTP deaktivieren\",emailSetupEnterCode:\"Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um zu bestätigen, dass Ihnen dieses Postfach gehört.\",verifyAndEnable:\"Verifizieren & Aktivieren\",emailDisablePasswordHint:\"Geben Sie Ihr Kontopasswort ein, um die Deaktivierung des E-Mail OTP zu bestätigen.\",passwordPlaceholder:\"Passwort eingeben\",backupCodesTitle:\"Backup-Codes sichern\",backupCodesWarning:\"Speichere diese Codes sicher. Jeder Code kann nur einmal verwendet werden und wird nicht erneut angezeigt.\",backupCodesRemaining:\"{{count}} Backup-Codes verbleibend\",savedCodes:\"Codes gespeichert\",regenBackup:\"Backup-Codes neu generieren\",regenBackupHint:\"Gib deinen aktuellen TOTP-Code ein, um 10 neue Backup-Codes zu generieren. Alle bestehenden Codes werden ungültig.\",newBackupCodes:\"Neue Backup-Codes\",linkedAccounts:\"Verknüpfte SSO-Konten\",linkedAccountsDesc:\"Diese externen Identitätsanbieter sind mit deinem Konto verknüpft.\",oidcUnlinked:\"Konto getrennt.\"},oidc:{title:\"SSO / OIDC-Anbieter\",desc:\"Konfiguriere OpenID Connect-Anbieter für Single Sign-On.\",addProvider:\"Anbieter hinzufügen\",newProvider:\"Neuer Anbieter\",empty:\"Noch keine OIDC-Anbieter konfiguriert.\",created:\"Anbieter erstellt.\",updated:\"Anbieter aktualisiert.\",deleted:\"Anbieter gelöscht.\",deleteTitle:\"Anbieter löschen\",deleteMessage:'\"{{name}}\" löschen? Alle verknüpften Benutzerkonten werden getrennt.',form:{name:\"Anzeigename\",issuerUrl:\"Aussteller-URL\",clientId:\"Client-ID\",clientSecret:\"Client-Secret\",scopes:\"Scopes\",iconUrl:\"Symbol-URL (optional)\",enabled:\"Aktiviert\",autoCreate:\"Benutzer automatisch anlegen\",autoCreateDesc:\"Erstellt beim ersten Login automatisch ein lokales Konto.\",autoLink:\"Bestehende Konten automatisch verknüpfen\",autoLinkDesc:\"Verknüpft beim ersten Login vorhandene lokale Konten anhand der E-Mail-Adresse.\",secretHint:\"leer lassen zum Beibehalten\",secretPlaceholder:\"neues Secret\"}}},notification:{printStarted:{title:\"Druck gestartet\",body:\"{{printer}}: {{filename}} wird gedruckt\"},printCompleted:{title:\"Druck abgeschlossen\",body:\"{{printer}}: {{filename}} erfolgreich abgeschlossen\"},printFailed:{title:\"Druck fehlgeschlagen\",body:\"{{printer}}: {{filename}} ist fehlgeschlagen\"},printStopped:{title:\"Druck gestoppt\",body:\"{{printer}}: {{filename}} wurde gestoppt\"},printProgress:{title:\"Druckfortschritt\",body:\"{{printer}}: {{filename}} ist zu {{percent}}% abgeschlossen\"},printerOffline:{title:\"Drucker offline\",body:\"{{printer}} ist offline\"},printerError:{title:\"Druckerfehler\",body:\"{{printer}}: {{error}}\"},filamentLow:{title:\"Filament niedrig\",body:\"{{printer}}: Filament geht zur Neige\"},maintenanceDue:{title:\"Wartung fällig\",body:\"{{printer}}: {{items}} benötigen Aufmerksamkeit\"}},errors:{generic:\"Etwas ist schiefgelaufen\",networkError:\"Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.\",notFound:\"Nicht gefunden\",unauthorized:\"Nicht autorisiert\",serverError:\"Serverfehler\",validationError:\"Bitte überprüfen Sie Ihre Eingabe\",printerConnectionFailed:\"Verbindung zum Drucker fehlgeschlagen\",saveFailed:\"Speichern fehlgeschlagen\",deleteFailed:\"Löschen fehlgeschlagen\",loadFailed:\"Laden der Daten fehlgeschlagen\"},hmsErrors:{title:\"Fehler - {{name}}\",noErrors:\"Keine Fehler\",viewOnWiki:\"Im Bambu Lab Wiki ansehen\",clearInstructions:\"Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.\",clearErrors:\"Fehler löschen\",clearSuccess:\"HMS-Fehler gelöscht\",clearFailed:\"HMS-Fehler konnten nicht gelöscht werden\"},mqttDebug:{title:\"MQTT-Debug-Protokoll\",searchPlaceholder:\"Topic oder Payload suchen...\",noMessages:\"Noch keine Nachrichten protokolliert\",startLoggingHint:'Klicken Sie auf \"Protokollierung starten\", um MQTT-Nachrichten aufzuzeichnen',noMessagesMatch:\"Keine Nachrichten entsprechen Ihrem Filter\",adjustFilterHint:\"Versuchen Sie, Ihre Such- oder Filterkriterien anzupassen\",incoming:\"Eingehend\",outgoing:\"Ausgehend\",loggingStopped:\"Protokollierung gestoppt\",loggingActive:\"Protokollierung aktiv - Nachrichten werden automatisch aktualisiert\",startLogging:\"Protokollierung starten\",stopLogging:\"Protokollierung stoppen\",clearLog:\"Protokoll löschen\",topic:\"Topic\",timestamp:\"Zeitstempel\",direction:\"Richtung\",all:\"Alle\"},printerFiles:{title:\"Dateimanager\",storageUsed:\"Belegt:\",storageFree:\"Frei:\",filterPlaceholder:\"Dateien filtern...\",deleteButton:\"Löschen\",deleteFiles:\"{{count}} Dateien löschen\",deleteFileConfirm:'\"{{name}}\" löschen? Dies kann nicht rückgängig gemacht werden.',deleteFilesConfirm:\"{{count}} ausgewählte Dateien löschen? Dies kann nicht rückgängig gemacht werden.\",noFiles:\"Keine Dateien auf dem Drucker\",loadingFiles:\"Dateien werden geladen...\",failedToLoad:\"Dateien konnten nicht geladen werden\",toast:{filesDeleted:\"{{count}} Datei(en) gelöscht\",deleteFailed:\"Löschen fehlgeschlagen: {{error}}\"}},confirm:{delete:\"Möchten Sie dies wirklich löschen?\",unsavedChanges:\"Sie haben ungespeicherte Änderungen. Möchten Sie wirklich verlassen?\",clearQueue:\"Möchten Sie die Warteschlange wirklich leeren?\"},login:{title:\"Bambuddy Anmeldung\",subtitle:\"Melden Sie sich bei Ihrem Konto an\",username:\"Benutzername\",usernamePlaceholder:\"Benutzername eingeben\",usernameOrEmail:\"Benutzername oder E-Mail\",usernameOrEmailPlaceholder:\"Benutzername oder @ E-Mail\",password:\"Passwort\",passwordPlaceholder:\"Passwort eingeben\",signIn:\"Anmelden\",signingIn:\"Anmeldung läuft...\",forgotPassword:\"Passwort vergessen?\",loginSuccess:\"Erfolgreich angemeldet\",loginFailed:\"Anmeldung fehlgeschlagen\",enterCredentials:\"Bitte Benutzername und Passwort eingeben\",enterEmail:\"Bitte geben Sie Ihre E-Mail-Adresse ein\",oidcLoginFailed:\"OIDC-Anmeldung fehlgeschlagen\",oidcErrors:{providerError:\"Der Identity-Provider hat einen Fehler zurückgegeben\",missingParameters:\"Dem OIDC-Callback fehlen erforderliche Parameter\",invalidState:\"OIDC-State ist ungültig oder wurde bereits verwendet\",stateExpired:\"OIDC-Sitzung abgelaufen — bitte erneut versuchen\",providerNotFound:\"OIDC-Provider nicht gefunden\",discoveryFailed:\"OIDC-Discovery-Dokument konnte nicht abgerufen werden\",invalidDiscovery:\"OIDC-Discovery-Dokument ist ungültig\",networkError:\"Netzwerkfehler beim OIDC-Token-Austausch\",badResponse:\"Unerwartete Antwort beim OIDC-Token-Austausch\",noIdToken:\"OIDC-Provider hat kein ID-Token zurückgegeben\",validationFailed:\"OIDC-Token-Validierung fehlgeschlagen\",nonceMismatch:\"OIDC-Nonce stimmt nicht überein — möglicher Replay-Angriff\",missingSubClaim:\"OIDC-Token enthält keinen Sub-Claim\",noLinkedAccount:\"Kein lokales Konto mit dieser OIDC-Identität verknüpft\",accountInactive:\"Ihr Konto ist inaktiv\",userResolutionFailed:\"Ihr Konto konnte nicht aufgelöst werden\",internalError:\"Interner Fehler beim OIDC-Login\",tokenExchangeFailed:\"OIDC-Token-Austausch fehlgeschlagen\"},forgotPasswordTitle:\"Passwort vergessen\",forgotPasswordMessage:\"Wenn Sie Ihr Passwort vergessen haben, wenden Sie sich bitte an Ihren Systemadministrator.\",forgotPasswordEmailMessage:\"Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen ein neues Passwort.\",emailAddress:\"E-Mail-Adresse\",emailPlaceholder:\"ihre.email@beispiel.de\",cancel:\"Abbrechen\",sending:\"Wird gesendet...\",sendResetEmail:\"Zurücksetzungs-E-Mail senden\",howToReset:\"So setzen Sie Ihr Passwort zurück:\",resetStep1:\"Kontaktieren Sie Ihren Bambuddy-Administrator\",resetStep2:\"Bitten Sie ihn, Ihr Passwort in der Benutzerverwaltung zurückzusetzen\",resetStep3:\"Er kann ein neues temporäres Passwort für Sie festlegen\",resetStep4:\"Melden Sie sich mit dem neuen Passwort an und ändern Sie es in den Einstellungen\",gotIt:\"Verstanden\",resetPassword:{title:\"Neues Passwort festlegen\",subtitle:\"Geben Sie unten Ihr neues Passwort ein und bestätigen Sie es.\",newPassword:\"Neues Passwort\",newPasswordPlaceholder:\"Mindestens 8 Zeichen\",confirmPassword:\"Passwort bestätigen\",confirmPasswordPlaceholder:\"Neues Passwort wiederholen\",saving:\"Wird gespeichert…\",submit:\"Neues Passwort festlegen\",backToLogin:\"Zurück zur Anmeldung\",passwordsDoNotMatch:\"Passwörter stimmen nicht überein\",passwordTooShort:\"Passwort muss mindestens 8 Zeichen lang sein\",resetFailed:\"Passwort zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.\"},twoFA:{title:\"Zwei-Faktor-Authentifizierung\",subtitle:\"Ihr Konto ist mit 2FA geschützt. Geben Sie unten den Bestätigungscode ein.\",methodAuthenticator:\"Authenticator-App\",methodEmail:\"E-Mail-Code\",methodBackup:\"Wiederherstellungscode\",instructionsTotp:\"Öffnen Sie Ihre Authenticator-App und geben Sie den 6-stelligen Code für Bambuddy ein.\",instructionsEmail:\"Ein 6-stelliger Code wurde an Ihre E-Mail-Adresse gesendet. Er ist 10 Minuten gültig.\",instructionsEmailNotSent:\"Klicken Sie unten, um einen Bestätigungscode per E-Mail zu erhalten.\",instructionsBackup:\"Geben Sie einen Ihrer 8-stelligen Wiederherstellungscodes ein. Jeder Code kann nur einmal verwendet werden.\",sendCodeButton:\"Code per E-Mail senden\",sendingCode:\"Wird gesendet...\",resendCode:\"Code erneut senden\",codeLabel:\"Bestätigungscode\",backupCodeLabel:\"Wiederherstellungscode\",codePlaceholder:\"000000\",backupCodePlaceholder:\"XXXXXXXX\",verifyButton:\"Bestätigen\",verifyingButton:\"Wird überprüft...\",backToLogin:\"← Zurück zur Anmeldung\",orContinueWith:\"oder anmelden mit\",signInWith:\"Anmelden mit {{provider}}\",enterCode:\"Bitte geben Sie den Bestätigungscode ein\",sendCodeFailed:\"Bestätigungscode konnte nicht gesendet werden\",invalidCode:\"Ungültiger Code. Bitte erneut versuchen.\"}},setup:{title:\"Bambuddy Einrichtung\",subtitle:\"Konfigurieren Sie die Authentifizierung für Ihre Bambuddy-Instanz\",enableAuth:\"Authentifizierung aktivieren\",adminAccount:\"Admin-Konto\",adminAccountDesc:\"Wenn bereits Admin-Benutzer existieren, wird die Authentifizierung mit den vorhandenen Admin-Konten aktiviert. Lassen Sie die Felder unten leer, um vorhandene Admins zu verwenden, oder geben Sie neue Anmeldedaten ein, um einen neuen Admin-Benutzer zu erstellen.\",adminUsername:\"Admin-Benutzername\",adminPassword:\"Admin-Passwort\",optionalIfAdminExists:\"(optional, wenn Admin-Benutzer existieren)\",adminUsernamePlaceholder:\"Admin-Benutzernamen eingeben (optional)\",adminPasswordPlaceholder:\"Admin-Passwort eingeben (optional)\",confirmPassword:\"Passwort bestätigen\",confirmPasswordPlaceholder:\"Admin-Passwort bestätigen\",settingUp:\"Einrichtung läuft...\",completeSetup:\"Einrichtung abschließen\",toast:{authEnabledAdminCreated:\"Authentifizierung aktiviert und Admin-Benutzer erstellt\",authEnabledExistingAdmins:\"Authentifizierung mit vorhandenen Admin-Benutzern aktiviert\",setupCompleted:\"Einrichtung abgeschlossen\",enterBothCredentials:\"Bitte geben Sie sowohl Admin-Benutzernamen als auch Passwort ein, oder lassen Sie beide leer, um vorhandene Admin-Benutzer zu verwenden\",passwordsDoNotMatch:\"Passwörter stimmen nicht überein\",passwordTooShort:\"Passwort muss mindestens 6 Zeichen lang sein\"}},changePassword:{title:\"Passwort ändern\",currentPassword:\"Aktuelles Passwort\",currentPasswordPlaceholder:\"Aktuelles Passwort eingeben\",newPassword:\"Neues Passwort\",newPasswordPlaceholder:\"Neues Passwort eingeben (min. 6 Zeichen)\",confirmPassword:\"Neues Passwort bestätigen\",confirmPasswordPlaceholder:\"Neues Passwort bestätigen\",passwordsDoNotMatch:\"Passwörter stimmen nicht überein\",passwordTooShort:\"Passwort muss mindestens 6 Zeichen lang sein\",changing:\"Wird geändert...\",success:\"Passwort erfolgreich geändert\",failed:\"Passwortänderung fehlgeschlagen\"},plateAlert:{title:\"Druck pausiert!\",message:\"Objekte auf dem Druckbett erkannt. Der Druck wurde automatisch pausiert. Bitte räumen Sie das Druckbett und setzen Sie den Druck fort.\",understand:\"Verstanden\"},camera:{title:\"Kameraansicht\",invalidPrinterId:\"Ungültige Drucker-ID\",live:\"Live\",snapshot:\"Schnappschuss\",restartStream:\"Stream neu starten\",refreshSnapshot:\"Schnappschuss aktualisieren\",fullscreen:\"Vollbild\",exitFullscreen:\"Vollbild beenden\",connectingToCamera:\"Verbinde mit Kamera...\",capturingSnapshot:\"Schnappschuss wird aufgenommen...\",connectionLost:\"Verbindung verloren\",connectionFailed:\"Kameraverbindung fehlgeschlagen\",reconnecting:\"Neuverbindung in {{countdown}}s... (Versuch {{attempt}}/{{max}})\",reconnectNow:\"Jetzt verbinden\",cameraUnavailable:\"Kamera nicht verfügbar\",cameraUnavailableDesc:\"Stellen Sie sicher, dass der Drucker eingeschaltet und verbunden ist.\",noCamera:\"Keine Kamera verfügbar\",retry:\"Erneut versuchen\",cameraStream:\"Kamera-Stream\",zoomOut:\"Verkleinern\",zoomIn:\"Vergrößern\",resetZoom:\"Zoom zurücksetzen\",recording:\"Aufnahme\",startRecording:\"Aufnahme starten\",stopRecording:\"Aufnahme stoppen\",chamberLight:\"Kammerbeleuchtung umschalten\"},groups:{title:\"Gruppenverwaltung\",subtitle:\"Berechtigungsgruppen für Zugriffskontrolle verwalten\",backToSettings:\"Zurück zu Einstellungen\",createGroup:\"Gruppe erstellen\",noPermission:\"Sie haben keine Berechtigung, auf diese Seite zuzugreifen.\",system:\"System\",noDescription:\"Keine Beschreibung\",usersCount:\"{{count}} Benutzer\",permissionsCount:\"{{count}} Berechtigungen\",edit:\"Bearbeiten\",delete:\"Löschen\",toast:{created:\"Gruppe erfolgreich erstellt\",updated:\"Gruppe erfolgreich aktualisiert\",deleted:\"Gruppe erfolgreich gelöscht\",enterGroupName:\"Bitte geben Sie einen Gruppennamen ein\"},modal:{editGroup:\"Gruppe bearbeiten\",createGroup:\"Gruppe erstellen\",cancel:\"Abbrechen\",saving:\"Speichern...\",creating:\"Erstellen...\",saveChanges:\"Änderungen speichern\"},form:{groupName:\"Gruppenname\",groupNamePlaceholder:\"Gruppennamen eingeben\",systemGroupWarning:\"Systemgruppennamen können nicht geändert werden\",description:\"Beschreibung\",descriptionPlaceholder:\"Beschreibung eingeben (optional)\",permissions:\"Berechtigungen ({{count}} ausgewählt)\"},deleteModal:{title:\"Gruppe löschen\",message:\"Sind Sie sicher, dass Sie diese Gruppe löschen möchten? Benutzer in dieser Gruppe verlieren diese Berechtigungen.\",confirm:\"Gruppe löschen\"},editor:{title:\"Gruppe bearbeiten\",createTitle:\"Gruppe erstellen\",search:\"Berechtigungen suchen...\",selectAll:\"Alle auswählen\",clearAll:\"Alle abwählen\",permissionsSelected:\"{{count}} ausgewählt\",noResults:\"Keine Berechtigungen entsprechen Ihrer Suche\"}},users:{title:\"Benutzerverwaltung\",subtitle:\"Benutzer und deren Zugriff auf Ihre Bambuddy-Instanz verwalten\",backToSettings:\"Zurück zu Einstellungen\",createUser:\"Benutzer erstellen\",noPermission:\"Sie haben keine Berechtigung, auf diese Seite zuzugreifen.\",admin:\"Admin\",noGroups:\"Keine Gruppen\",active:\"Aktiv\",inactive:\"Inaktiv\",edit:\"Bearbeiten\",delete:\"Löschen\",system:\"System\",noGroupsAvailable:\"Keine Gruppen verfügbar\",table:{username:\"Benutzername\",groups:\"Gruppen\",status:\"Status\",actions:\"Aktionen\"},toast:{created:\"Benutzer erfolgreich erstellt\",updated:\"Benutzer erfolgreich aktualisiert\",deleted:\"Benutzer erfolgreich gelöscht\",fillRequired:\"Bitte füllen Sie alle Pflichtfelder aus\",passwordsDoNotMatch:\"Passwörter stimmen nicht überein\",passwordTooShort:\"Passwort muss mindestens 6 Zeichen lang sein\"},modal:{createUser:\"Benutzer erstellen\",editUser:\"Benutzer bearbeiten\",cancel:\"Abbrechen\",creating:\"Erstellen...\",saving:\"Speichern...\",saveChanges:\"Änderungen speichern\",advancedAuthSubtitle:\"mit erweiterter Authentifizierung\"},form:{username:\"Benutzername\",usernamePlaceholder:\"Benutzernamen eingeben\",email:\"E-Mail\",emailPlaceholder:\"benutzer@beispiel.de\",password:\"Passwort\",passwordPlaceholder:\"Passwort eingeben\",confirmPassword:\"Passwort bestätigen\",confirmPasswordPlaceholder:\"Passwort bestätigen\",newPasswordPlaceholder:\"Neues Passwort eingeben\",confirmNewPasswordPlaceholder:\"Neues Passwort bestätigen\",leaveBlankToKeep:\"leer lassen, um das aktuelle zu behalten\",groups:\"Gruppen\",optional:\"optional\",autoGeneratedPassword:\"Ein sicheres Passwort wird automatisch generiert und per E-Mail an den Benutzer gesendet.\",passwordManagedByAdvancedAuth:'Das Passwort wird durch erweiterte Authentifizierung verwaltet. Verwenden Sie \"Passwort zurücksetzen\", um ein neues Passwort per E-Mail an den Benutzer zu senden.',resetPassword:\"Passwort zurücksetzen\",resettingPassword:\"Passwort wird zurückgesetzt...\"},deleteModal:{title:\"Benutzer löschen\",message:\"Sind Sie sicher, dass Sie diesen Benutzer löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.\",confirm:\"Benutzer löschen\"}},streamOverlay:{title:\"Stream-Overlay\",invalidPrinterId:\"Ungültige Drucker-ID\",cameraStream:\"Kamera-Stream\",progress:\"Fortschritt\",eta:\"ETA\",printerIdle:\"Drucker ist inaktiv\",printerOffline:\"Drucker offline\",status:{printing:\"Druckt\",paused:\"Pausiert\",finished:\"Fertig\",failed:\"Fehlgeschlagen\",idle:\"Inaktiv\",unknown:\"Unbekannt\"}},profiles:{title:\"Profile\",subtitle:\"Verwalten Sie Ihre Slicer-Voreinstellungen und Druckvorschub-Kalibrierungen\",tabs:{cloud:\"Cloud-Profile\",local:\"Lokale Profile\",kprofiles:\"K-Profile\"},localProfiles:{title:\"Lokale Profile\",subtitle:\"Slicer-Voreinstellungen aus OrcaSlicer importieren und verwalten\",import:\"Profile importieren\",importDesc:\".bbscfg-, .bbsflmt-, .orca_filament-, .zip- oder .json-Dateien hier ablegen\",importing:\"Importiere...\",search:\"Lokale Voreinstellungen durchsuchen...\",noPresets:\"Noch keine lokalen Voreinstellungen\",badge:\"Lokal\",edit:\"Bearbeiten\",delete:\"Löschen\",cancel:\"Abbrechen\",deleteConfirmTitle:\"Voreinstellung löschen\",deleteConfirm:\"Möchten Sie diese Voreinstellung wirklich löschen? Dies kann nicht rückgängig gemacht werden.\",source:\"Quelle\",inheritsFrom:\"Erbt von\",filamentType:\"Typ\",vendor:\"Hersteller\",compatiblePrinters:\"Drucker\",nozzleTemp:\"Düsentemperatur\",cost:\"Kosten\",density:\"Dichte\",pressureAdvance:\"Druckvorschub\",filament:\"Filament\",process:\"Prozess\",printer:\"Drucker\",toast:{importSuccess:\"{{count}} Voreinstellung(en) importiert\",importSkipped:\"{{count}} Voreinstellung(en) übersprungen (Duplikate)\",importError:\"{{count}} Fehler beim Import\",deleted:\"Voreinstellung gelöscht\",updated:\"Voreinstellung aktualisiert\"}},connectedAs:\"Verbunden als\",logout:\"Abmelden\",noLogoutPermission:\"Sie haben keine Berechtigung zum Abmelden\",failedToLoad:\"Profile konnten nicht geladen werden\",retry:\"Erneut versuchen\",time:{justNow:\"Gerade eben\",minsAgo:\"vor {{count}}m\",hoursAgo:\"vor {{count}}h\",daysAgo:\"vor {{count}}d\"},toast:{loggedOut:\"Abgemeldet\"},login:{title:\"Mit Bambu Cloud verbinden\",subtitle:\"Synchronisieren Sie Ihre Slicer-Voreinstellungen geräteübergreifend\",email:\"E-Mail\",password:\"Passwort\",region:\"Region\",regionGlobal:\"Global\",regionChina:\"China\",verificationCode:\"Bestätigungscode\",totpCode:\"Authenticator-Code\",checkEmail:\"Prüfen Sie Ihre E-Mail ({{email}}) für einen 6-stelligen Code\",enterTotpHint:\"Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein\",accessToken:\"Zugriffstoken\",accessTokenHint:\"Fügen Sie Ihr Bambu Lab Zugriffstoken ein (aus Bambu Studio)\",back:\"Zurück\",loginButton:\"Anmelden\",verifyButton:\"Bestätigen\",setTokenButton:\"Token setzen\",useToken:\"Stattdessen Zugriffstoken verwenden\",useEmail:\"Stattdessen mit E-Mail anmelden\",toast:{loggedIn:\"Erfolgreich angemeldet\",codeSent:\"Bestätigungscode an Ihre E-Mail gesendet\",enterTotp:\"Geben Sie den Code aus Ihrer Authenticator-App ein\",tokenSet:\"Token erfolgreich gesetzt\"}},presets:{myPreset:\"Mein Profil (bearbeitbar)\",duplicate:\"Duplizieren\",editable:\"Bearbeitbar\",failedToLoadDetails:\"Profil-Details konnten nicht geladen werden\",deleteConfirm:\"Dieses Profil löschen?\",deleteWarning:'\"{{name}}\" wird dauerhaft aus Bambu Cloud gelöscht. Dies kann nicht rückgängig gemacht werden.',noDuplicatePermission:\"Sie haben keine Berechtigung zum Duplizieren von Profilen\",noEditPermission:\"Sie haben keine Berechtigung zum Bearbeiten von Profilen\",noDeletePermission:\"Sie haben keine Berechtigung zum Löschen von Profilen\",types:{filament:\"Filament-Profil\",printer:\"Drucker-Profil\",process:\"Prozess-Profil\"},toast:{deleted:\"Profil gelöscht\",created:\"Profil erstellt\",updated:\"Profil aktualisiert\",duplicated:\"Profil dupliziert\",fieldAdded:'Feld \"{{key}}\" hinzugefügt',exported:\"Profil exportiert\"},baseLabel:\"Basis: {{name}}\",currentLabel:\"Aktuell: {{name}}\",newPreset:\"Neues Profil\",editPreset:\"Profil bearbeiten\",duplicatePreset:\"Profil duplizieren\",createNewPreset:\"Neues Profil erstellen\",customizeSettings:\"Passen Sie die Einstellungen für Ihr neues Profil an\",compareWithBase:\"Mit Basis-Profil vergleichen\",compare:\"Vergleichen\",basePreset:\"Basis-Profil\",selectBasePreset:\"Basis-Profil auswählen...\",presetName:\"Profilname\",myCustomPreset:\"Mein eigenes Profil\",inheritsFrom:\"Erbt von\",dropJsonToImport:\"JSON zum Importieren ablegen\",tabs:{common:\"Allgemein\",allFields:\"Alle Felder\"},availableFields:\"Verfügbare Felder\",searchFieldsPlaceholder:\"Felder suchen...\",noMatchingFields:\"Keine passenden Felder\",allFieldsAdded:\"Alle Felder hinzugefügt\",addCustomField:\"Eigenes Feld hinzufügen\",yourOverrides:\"Ihre Überschreibungen\",noOverridesYet:\"Noch keine Überschreibungen\",clickFieldsToAdd:\"Klicken Sie links auf Felder, um sie hinzuzufügen\",saveAsTemplate:\"Als Vorlage speichern\",jsonTip:\"Tipp: Ziehen Sie eine .json-Datei auf dieses Fenster, um Einstellungen zu importieren\"},cloudView:{searchPlaceholder:\"Profile suchen...\",templates:\"Vorlagen\",refresh:\"Aktualisieren\",newPreset:\"Neues Profil\",clearFilters:\"Filter zurücksetzen\",compareMode:\"Vergleichsmodus\",selectAnotherPreset:\"Wählen Sie ein weiteres {{type}}-Profil\",clickTwoPresets:\"Klicken Sie auf zwei Profile des gleichen Typs zum Vergleichen\",selectFirst:\"1. Erstes auswählen\",selectSecond:\"2. Zweites auswählen\",compareNow:\"Jetzt vergleichen\",lastSynced:\"Zuletzt synchronisiert:\",showingCount:\"{{showing}} von {{total}} Profilen\",noPresetsFound:\"Keine Profile gefunden\",columns:{filament:\"Filament\",process:\"Prozess\",printer:\"Drucker\"},noFilamentPresets:\"Keine Filament-Profile\",noProcessPresets:\"Keine Prozess-Profile\",noPrinterPresets:\"Keine Drucker-Profile\",filters:{type:\"Typ\",owner:\"Besitzer\",printer:\"Drucker\",nozzle:\"Düse\",filament:\"Filament\",layer:\"Schicht\",all:\"Alle\",myPresets:\"Meine Profile\",builtIn:\"Voreingestellt\",process:\"Prozess\"},noTemplatesPermission:\"Sie haben keine Berechtigung, Vorlagen zu verwalten\",noRefreshPermission:\"Sie haben keine Berechtigung, Profile zu aktualisieren\",noCreatePermission:\"Sie haben keine Berechtigung, Profile zu erstellen\"},templates:{title:\"Schnellvorlagen\",noTemplates:\"Noch keine Vorlagen\",createFirst:\"Erstellen Sie Vorlagen aus dem Preset-Editor\",typeFilter:\"Typ:\",deleteTitle:\"Vorlage löschen\",deleteWarning:\"Diese Aktion kann nicht rückgängig gemacht werden\",deleteConfirm:'Möchten Sie \"{{name}}\" wirklich löschen?',namePlaceholder:\"Vorlagenname\",descriptionPlaceholder:\"Beschreibung\",settingsJson:\"Einstellungen (JSON)\",fieldsCount:\"{{count}} Felder\",shownInModals:\"In Dialogen angezeigt\",hiddenInModals:\"In Dialogen ausgeblendet\",apply:\"Anwenden\",toast:{deleted:\"Vorlage gelöscht\",updated:\"Vorlage aktualisiert\",created:\"Vorlage erstellt\",applied:\"Vorlage angewendet\"}}},support:{debugLoggingActive:\"Debug-Protokollierung ist aktiv\",manageLogs:\"Verwalten\",collectItem7:\"Drucker-Verbindungsstatus und Firmware-Versionen\",collectItem8:\"Integrationsstatus (Spoolman, MQTT, HA)\",collectItem9:\"Netzwerkschnittstellen (nur Subnetze)\",collectItem10:\"Python-Paketversionen\",collectItem11:\"Datenbankzustandsprüfungen\",collectItem12:\"Docker-Umgebungsdetails\"},fileManager:{title:\"Dateimanager\",subtitle:\"Organisieren und verwalten Sie Ihre Druckdateien\",uploadFiles:\"Dateien hochladen\",newFolder:\"Neuer Ordner\",folderName:\"Ordnername\",folderNamePlaceholder:\"z.B. Funktionsteile\",renameFile:\"Datei umbenennen\",renameFolder:\"Ordner umbenennen\",moveFiles:\"{{count}} Datei(en) verschieben\",rootNoFolder:\"Stammverzeichnis (Kein Ordner)\",current:\"aktuell\",linkFolder:\"Ordner verknüpfen\",linkFolderDescription:'\"{{name}}\" mit einem Projekt oder Archiv verknüpfen für schnellen Zugriff.',project:\"Projekt\",archive:\"Archiv\",noProjectsFound:\"Keine Projekte gefunden\",noArchivesFound:\"Keine Archive gefunden\",unlink:\"Verknüpfung aufheben\",link:\"Verknüpfen\",dragDropFiles:\"Dateien hierher ziehen\",dropFilesHere:\"Dateien hier ablegen\",orClickToBrowse:\"oder klicken zum Durchsuchen\",allFileTypesSupported:\"Alle Dateitypen werden unterstützt. ZIP-Dateien werden extrahiert.\",zipFilesDetected:\"ZIP-Dateien erkannt\",zipExtractOptions:\"ZIP-Dateien werden extrahiert. Wählen Sie, wie die Ordnerstruktur behandelt werden soll:\",preserveZipStructure:\"Ordnerstruktur aus ZIP beibehalten\",createFolderFromZip:\"Ordner aus ZIP-Dateiname erstellen\",stlThumbnailGeneration:\"STL-Vorschaubildgenerierung\",zipMayContainStl:\"ZIP-Dateien können STL-Dateien enthalten. Vorschaubilder können während der Extraktion generiert werden.\",thumbnailsCanBeGenerated:\"Vorschaubilder können für STL-Dateien generiert werden. Große Modelle benötigen möglicherweise mehr Zeit.\",generateThumbnailsForStl:\"Vorschaubilder für STL-Dateien generieren\",threemfDetected:\"3MF-Dateien erkannt\",threemfExtractionInfo:\"Druckermodell, Material, Farbe und Druckeinstellungen werden automatisch aus 3MF-Dateien extrahiert.\",willBeExtracted:\"Wird extrahiert\",filesExtracted:\"{{count}} Dateien extrahiert\",uploadComplete:\"Upload abgeschlossen: {{succeeded}} erfolgreich\",uploadFailed:\"Hochladen fehlgeschlagen\",zipFilesFailed:\"{{count}} Dateien fehlgeschlagen\",uploading:\"Hochladen...\",changeLink:\"Verknüpfung ändern...\",linkTo:\"Verknüpfen mit...\",linkToProjectOrArchive:\"Mit Projekt oder Archiv verknüpfen\",addToQueue:\"Zur Warteschlange\",schedulePrint:\"Planen\",generateThumbnail:\"Vorschaubild generieren\",generateThumbnails:\"Vorschaubilder generieren\",generateThumbnailsForMissing:\"Vorschaubilder für STL-Dateien ohne Vorschau generieren\",gridView:\"Rasteransicht\",listView:\"Listenansicht\",lowDiskSpaceWarning:\"Warnung: Wenig Speicherplatz\",lowDiskSpaceDetails:\"Nur {{free}} frei von {{total}} gesamt. Schwellenwert ist auf {{threshold}} GB eingestellt.\",files:\"Dateien\",folders:\"Ordner\",size:\"Größe\",free:\"Frei\",allFiles:\"Alle Dateien\",wrap:\"Umbrechen\",enableTextWrapping:\"Textumbruch aktivieren\",disableTextWrapping:\"Textumbruch deaktivieren\",collapse:\"Einklappen\",collapseFoldersByDefault:\"Ordner standardmäßig einklappen\",expandFoldersByDefault:\"Ordner standardmäßig ausklappen\",dragToResizeTooltip:\"Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen\",searchFiles:\"Dateien suchen...\",allTypes:\"Alle Typen\",prints:\"Drucke\",ascending:\"Aufsteigend\",descending:\"Absteigend\",resultsCount:\"{{showing}} von {{total}} Dateien\",selectAll:\"Alle auswählen\",deselectAll:\"Auswahl aufheben\",selected:\"{{count}} ausgewählt\",adding:\"Hinzufügen...\",loadingFiles:\"Dateien werden geladen...\",folderIsEmpty:\"Ordner ist leer\",noFilesYet:\"Noch keine Dateien\",folderEmptyDescription:\"Laden Sie Dateien hoch oder verschieben Sie Dateien in diesen Ordner.\",noFilesDescription:\"Laden Sie Dateien hoch, um Ihre Druckdateien zu organisieren.\",noMatchingFiles:\"Keine passenden Dateien\",noMatchingFilesDescription:\"Keine Dateien entsprechen Ihren aktuellen Such- oder Filterkriterien.\",clearFilters:\"Filter zurücksetzen\",printedCount:\"{{count}}x gedruckt\",uploadedBy:\"Hochgeladen von\",deleteFolder:\"Ordner löschen\",deleteFile:\"Datei löschen\",deleteFilesCount:\"{{count}} Dateien löschen\",deleteFolderConfirm:\"Möchten Sie diesen Ordner wirklich löschen? Alle Dateien darin werden ebenfalls gelöscht.\",deleteFileConfirm:\"Möchten Sie diese Datei wirklich löschen?\",deleteFilesConfirm:\"Möchten Sie {{count}} ausgewählte Dateien wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.\",deleting:\"Wird gelöscht...\",noPermissionRenameFolder:\"Sie haben keine Berechtigung, Ordner umzubenennen\",noPermissionLinkFolder:\"Sie haben keine Berechtigung, Ordner zu verknüpfen\",noPermissionDeleteFolder:\"Sie haben keine Berechtigung, Ordner zu löschen\",noPermissionPrint:\"Sie haben keine Berechtigung zum Drucken\",noPermissionAddToQueue:\"Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen\",noPermissionDownload:\"Sie haben keine Berechtigung, Dateien herunterzuladen\",noPermissionRenameFile:\"Sie haben keine Berechtigung, diese Datei umzubenennen\",noPermissionGenerateThumbnail:\"Sie haben keine Berechtigung, Vorschaubilder zu generieren\",noPermissionDeleteFile:\"Sie haben keine Berechtigung, diese Datei zu löschen\",noPermissionCreateFolder:\"Sie haben keine Berechtigung, Ordner zu erstellen\",noPermissionUpload:\"Sie haben keine Berechtigung, Dateien hochzuladen\",noPermissionMoveFiles:\"Sie haben keine Berechtigung, Dateien zu verschieben\",noPermissionDeleteFiles:\"Sie haben keine Berechtigung, Dateien zu löschen\",linkExternal:\"Extern verknüpfen\",linkExternalFolder:\"Externen Ordner verknüpfen\",linkExternalFolderDescription:\"Ein Host-Verzeichnis (NAS, USB, Netzlaufwerk) in den Dateimanager einbinden. Dateien werden nicht kopiert — sie werden direkt vom Originalpfad gelesen.\",externalFolderNamePlaceholder:\"z.B. NAS-Drucke\",externalPath:\"Host-Pfad\",externalPathHelp:\"Absoluter Pfad zum Verzeichnis auf dem Docker-Host. Muss als Bind-Mount in den Container eingebunden sein.\",readOnly:\"Nur Lesen\",readOnlyHelp:\"verhindert Uploads und Löschungen\",showHiddenFiles:\"Versteckte Dateien anzeigen (Punkt-Dateien)\",externalFolder:\"Externer Ordner\",scanFolder:\"Scannen\",toast:{folderCreated:\"Ordner erstellt\",folderDeleted:\"Ordner gelöscht\",fileDeleted:\"Datei gelöscht\",filesDeleted:\"{{count}} Dateien gelöscht\",filesMoved:\"Dateien verschoben\",folderLinked:\"Ordner verknüpft\",folderUnlinked:\"Ordnerverknüpfung aufgehoben\",externalFolderLinked:\"Externer Ordner verknüpft und gescannt\",folderScanned:\"Scan abgeschlossen: {{added}} hinzugefügt, {{removed}} entfernt\",addedToQueue:\"{{count}} Datei(en) zur Warteschlange hinzugefügt\",addedToQueuePartial:\"{{added}} Datei(en) hinzugefügt, {{failed}} fehlgeschlagen\",failedToAddToQueue:\"Fehler beim Hinzufügen: {{error}}\",fileRenamed:\"Datei umbenannt\",folderRenamed:\"Ordner umbenannt\",thumbnailsGenerated:\"{{count}} Vorschaubild(er) generiert\",thumbnailsGeneratedPartial:\"{{succeeded}} Vorschaubild(er) generiert, {{failed}} fehlgeschlagen\",noStlMissingThumbnails:\"Keine STL-Dateien ohne Vorschaubild\",failedToGenerateThumbnails:\"Fehler beim Generieren der Vorschaubilder: {{error}}\",thumbnailGenerated:\"Vorschaubild generiert\",failedToGenerateThumbnail:\"Fehler beim Generieren des Vorschaubildes: {{error}}\"}},projects:{title:\"Projekte\",subtitle:\"Organisieren und verfolgen Sie Ihre 3D-Druckprojekte\",newProject:\"Neues Projekt\",editProject:\"Projekt bearbeiten\",deleteProject:\"Projekt löschen\",projectName:\"Projektname\",description:\"Beschreibung\",noProjects:\"Noch keine Projekte\",noProjectsFiltered:\"Keine {{status}} Projekte\",noProjectsFilteredHelp:\"Sie haben keine {{status}} Projekte. Projekte werden hier angezeigt, wenn sich ihr Status ändert.\",createFirst:\"Erstellen Sie Ihr erstes Projekt, um verwandte Drucke zu organisieren, den Fortschritt zu verfolgen und Ihre Builds zu verwalten.\",createFirstButton:\"Erstes Projekt erstellen\",create:\"Erstellen\",files:\"Dateien\",prints:\"Drucke\",plates:\"Platten\",parts:\"Teile\",lastModified:\"Zuletzt geändert\",deleteConfirm:\"Möchten Sie dieses Projekt wirklich löschen? Archive und Warteschlangenelemente werden getrennt, aber nicht gelöscht.\",addFiles:\"Dateien hinzufügen\",removeFile:\"Datei entfernen\",viewDetails:\"Details anzeigen\",namePlaceholder:\"z.B. Voron 2.4 Build\",descriptionPlaceholder:\"Optionale Beschreibung...\",color:\"Farbe\",targetPlates:\"Ziel-Platten\",targetPlatesPlaceholder:\"z.B. 25\",targetPlatesHelp:\"Anzahl der Druckaufträge\",targetParts:\"Ziel-Teile\",targetPartsPlaceholder:\"z.B. 150\",targetPartsHelp:\"Benötigte Objekte insgesamt\",tagsLabel:\"Tags (kommagetrennt)\",tagsPlaceholder:\"z.B. voron, funktional, geschenk\",dueDate:\"Fälligkeitsdatum\",priority:\"Priorität\",priorityLow:\"Niedrig\",priorityNormal:\"Normal\",priorityHigh:\"Hoch\",priorityUrgent:\"Dringend\",statusActive:\"Aktiv\",statusCompleted:\"Abgeschlossen\",statusArchived:\"Archiviert\",done:\"Fertig\",completed:\"abgeschlossen\",failed:\"fehlgeschlagen\",inQueue:\"in Warteschlange\",noPrintsYet:\"Noch keine Drucke\",printJobs:\"Druckaufträge (Platten)\",partsPrinted:\"Gedruckte Teile\",failedParts:\"Fehlgeschlagene Teile\",import:\"Importieren\",export:\"Exportieren\",importProject:\"Projekt importieren\",exportAll:\"Alle Projekte exportieren\",loading:\"Projekte werden geladen...\",noEditPermission:\"Sie haben keine Berechtigung, Projekte zu bearbeiten\",noDeletePermission:\"Sie haben keine Berechtigung, Projekte zu löschen\",noCreatePermission:\"Sie haben keine Berechtigung, Projekte zu erstellen\",noImportPermission:\"Sie haben keine Berechtigung, Projekte zu importieren\",noExportPermission:\"Sie haben keine Berechtigung, Projekte zu exportieren\",toast:{created:\"Projekt erstellt\",updated:\"Projekt aktualisiert\",deleted:\"Projekt gelöscht\",imported:\"Projekt importiert\",multipleImported:\"{{count}} Projekte importiert\",importFailed:\"Import fehlgeschlagen\",exported:\"Projekte exportiert (nur Metadaten)\"}},projectDetail:{notFound:\"Projekt nicht gefunden\",backToProjects:\"Zurück zu Projekten\",export:\"Exportieren\",exportProject:\"Projekt exportieren\",noExportPermission:\"Sie haben keine Berechtigung, Projekte zu exportieren\",noEditPermission:\"Sie haben keine Berechtigung, Projekte zu bearbeiten\",partOf:\"Teil von:\",priorityLabel:\"Priorität:\",noPrints:\"Noch keine Drucke in diesem Projekt\",status:{active:\"Aktiv\",completed:\"Abgeschlossen\",archived:\"Archiviert\"},priority:{low:\"Niedrig\",normal:\"Normal\",high:\"Hoch\",urgent:\"Dringend\"},dueDate:{overdue:\"Überfällig\",today:\"Heute fällig\",daysLeft:\"{{count}} Tage übrig\"},progress:{platesProgress:\"Platten-Fortschritt\",partsProgress:\"Teile-Fortschritt\",printJobs:\"Druckaufträge\",parts:\"Teile\",percentComplete:\"{{percent}}% abgeschlossen\",remaining:\"{{count}} verbleibend\"},stats:{printJobs:\"Druckaufträge\",total:\"gesamt\",failed:\"{{count}} fehlgeschlagen\",partsPrinted:\"{{count}} Teile gedruckt\",printTime:\"Druckzeit\",filamentUsed:\"Filament verbraucht\"},cost:{title:\"Kostenverfolgung\",filamentCost:\"Filamentkosten\",energy:\"Energie\",totalCost:\"Gesamtkosten\",total:\"Gesamt\",includesBom:\"inkl. Stückliste\",budget:\"Budget\",remaining:\"Verbleibend\"},subProjects:{title:\"Unterprojekte ({{count}})\"},notes:{title:\"Notizen\",noEditPermission:\"Sie haben keine Berechtigung, Notizen zu bearbeiten\",placeholder:\"Notizen zu diesem Projekt hinzufügen...\",empty:\"Noch keine Notizen. Klicken Sie auf Bearbeiten, um Notizen hinzuzufügen.\"},files:{title:\"Dateien\",linkFolders:\"Ordner aus dem Dateimanager verknüpfen\",forQuickAccess:\"für schnellen Zugriff auf dieses Projekt.\",fileCount:\"{{count}} Datei(en)\",empty:\"Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.\",noFiles:\"Keine Dateien in diesem Ordner.\",print:\"Jetzt drucken\",addToQueue:\"Zur Warteschlange\"},bom:{title:\"Stückliste\",acquired:\"{{completed}}/{{total}} beschafft\",showAll:\"Alle anzeigen\",hideDone:\"Erledigte ausblenden\",addPart:\"Teil hinzufügen\",noAddPermission:\"Sie haben keine Berechtigung, Teile hinzuzufügen\",partNamePlaceholder:\"Teilename (z.B. M3x8 Schrauben)\",partName:\"Teilename\",qty:\"Menge\",price:\"Preis ({{currency}})\",sourcingUrlPlaceholder:\"Bezugsquelle-URL (optional)\",remarksPlaceholder:\"Bemerkungen (optional)\",deletePart:\"Teil löschen\",deleteConfirm:'Möchten Sie \"{{name}}\" wirklich löschen?',noUpdatePermission:\"Sie haben keine Berechtigung, Teile zu aktualisieren\",noEditPermission:\"Sie haben keine Berechtigung, Teile zu bearbeiten\",noDeletePermission:\"Sie haben keine Berechtigung, Teile zu löschen\",totalCost:\"Gesamtkosten:\",empty:\"Keine Teile in der Stückliste. Fügen Sie Hardware, Elektronik oder andere Komponenten hinzu, um zu verfolgen, was beschafft werden muss.\"},timeline:{title:\"Aktivitätsverlauf\",empty:\"Noch keine Aktivität.\"},template:{saveAsTemplate:\"Als Vorlage speichern\",noCreatePermission:\"Sie haben keine Berechtigung, Vorlagen zu erstellen\"},queue:{title:\"Warteschlange\",viewAll:\"Alle anzeigen\",printing:\"{{count}} druckend\",queued:\"{{count}} in Warteschlange\"},prints:{title:\"Drucke ({{count}})\"},toast:{projectUpdated:\"Projekt aktualisiert\",partAdded:\"Teil hinzugefügt\",partRemoved:\"Teil entfernt\",exportFailed:\"Export fehlgeschlagen\",projectExported:\"Projekt exportiert\",templateCreated:\"Vorlage erstellt\"}},system:{title:\"Systeminformationen\",version:\"Version\",uptime:\"Laufzeit\",cpuUsage:\"CPU-Auslastung\",memoryUsage:\"Speicherauslastung\",diskUsage:\"Festplattenauslastung\",networkInfo:\"Netzwerkinformationen\",logs:\"Protokolle\",debugMode:\"Debug-Modus\",enableDebug:\"Debug-Protokollierung aktivieren\",disableDebug:\"Debug-Protokollierung deaktivieren\",downloadLogs:\"Protokolle herunterladen\",clearLogs:\"Protokolle löschen\",dockerInfo:\"Docker-Info\",containerName:\"Container-Name\",imageName:\"Image-Name\",platform:\"Plattform\",architecture:\"Architektur\"},library:{title:\"Filament-Bibliothek\",addFilament:\"Filament hinzufügen\",editFilament:\"Filament bearbeiten\",deleteFilament:\"Filament löschen\",vendor:\"Hersteller\",material:\"Material\",color:\"Farbe\",kFactor:\"K-Faktor\",temperature:\"Temperatur\",noFilaments:\"Keine Filamente in der Bibliothek\",deleteConfirm:\"Möchten Sie dieses Filament wirklich löschen?\",importFromPrinter:\"Vom Drucker importieren\",exportToFile:\"In Datei exportieren\"},spoolman:{title:\"Spoolman-Integration\",enabled:\"Spoolman aktiviert\",url:\"Spoolman URL\",connected:\"Verbunden\",disconnected:\"Nicht verbunden\",testConnection:\"Verbindung testen\",sync:\"Synchronisieren\",syncing:\"Synchronisiert...\",lastSync:\"Letzte Synchronisierung\",linkToSpoolman:\"Mit Spoolman verknüpfen\",openInSpoolman:\"In Spoolman öffnen\",unlinkSpool:\"Spule trennen\",unlinkConfirmTitle:\"Spule entkoppeln?\",unlinkConfirmMessage:\"Dadurch wird die Spule von Spoolman getrennt. Die Spulendaten in Spoolman bleiben unverändert.\",selectSpool:\"Spule auswählen\",noUnlinkedSpools:\"Keine nicht verknüpften Spulen verfügbar\",linkSuccess:\"Spule erfolgreich mit Spoolman verknüpft\",linkFailed:\"Verknüpfung mit Spoolman fehlgeschlagen\",unlinkSuccess:\"Spule erfolgreich von Spoolman getrennt\",unlinkFailed:\"Trennen der Spule von Spoolman fehlgeschlagen\",spoolId:\"Spulen-ID\",fillSourceLabel:\"(Spoolman)\",weight:\"Gewicht\",remaining:\"Verbleibend\",disableWeightSync:\"AMS-Gewichtsschätzung deaktivieren\",disableWeightSyncDesc:\"Verbleibende Kapazität nicht aus AMS-Schätzungen aktualisieren. Verwenden Sie dies, wenn Sie die Verbrauchserfassung von Spoolman gegenüber den prozentualen AMS-Schätzungen bevorzugen. Neue Spulen verwenden weiterhin die AMS-Schätzung als Anfangsgewicht.\",reportPartialUsage:\"Teilverbrauch bei fehlgeschlagenen Drucken melden\",reportPartialUsageDesc:\"Wenn ein Druck fehlschlägt oder abgebrochen wird, den geschätzten Filamentverbrauch bis zu diesem Zeitpunkt basierend auf dem Schichtfortschritt melden.\"},inventory:{title:\"Spulen-Inventar\",addSpool:\"Spule hinzufügen\",editSpool:\"Spule bearbeiten\",material:\"Material\",selectMaterial:\"Material auswählen...\",subtype:\"Untertyp\",brand:\"Marke\",searchBrand:\"Marke suchen...\",useCustomBrand:'\"{{brand}}\" verwenden',useCustomMaterial:\"Benutzerdefiniertes Material verwenden: {{material}}\",colorName:\"Farbname\",colorNamePlaceholder:\"Jade White, Fire Red...\",color:\"Farbe\",hexColor:\"Hex-Farbe\",pickColor:\"Benutzerdefinierte Farbe wählen\",labelWeight:\"Nenngewicht\",coreWeight:\"Leergewicht der Spule\",searchSpoolWeight:\"Spulengewicht suchen...\",weightUsed:\"Verbraucht\",currentWeight:\"Restgewicht\",measuredWeight:\"Gemessenes Gewicht\",spoolName:\"Spule\",costPerKg:\"Kosten pro kg\",measuredWeightError:\"Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.\",slicerFilament:\"Slicer-Filament\",slicerFilamentName:\"Slicer-Preset-Name\",slicerPreset:\"Slicer-Preset\",searchPresets:\"Filament-Presets suchen...\",selectedPreset:\"Ausgewählt\",noPresetsFound:\"Keine Presets gefunden\",tempOverrides:\"Temperatur-Überschreibungen\",note:\"Notiz\",notePlaceholder:\"Zusätzliche Notizen zu dieser Spule...\",archive:\"Archivieren\",restore:\"Wiederherstellen\",noSpools:\"Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.\",noManualSpools:\"Keine manuell hinzugefügten Spulen verfügbar. Fügen Sie zuerst eine Spule zum Inventar hinzu.\",kProfiles:\"K-Profile\",addKProfile:\"K-Profil hinzufügen\",assignSpool:\"Spule zuweisen\",unassignSpool:\"Zuweisung aufheben\",assignSuccess:\"Spule zugewiesen und AMS-Slot konfiguriert\",assignFailed:\"Spulenzuweisung fehlgeschlagen\",selectSpool:\"Wählen Sie eine Spule für diesen Slot\",assigned:\"Zugewiesen\",assigning:\"Wird zugewiesen...\",searchSpools:\"Spulen suchen...\",showAllSpools:\"Alle Spulen anzeigen\",allMaterials:\"Alle Materialien\",filterByBrand:\"Nach Marke filtern...\",showArchived:\"Archivierte anzeigen\",quickAdd:\"Schnellerfassung (Lager)\",quantity:\"Menge\",stock:\"Lager\",configured:\"Konfiguriert\",spoolsCreated:\"{{count}} Spulen erstellt\",spoolCreated:\"Spule erstellt\",spoolUpdated:\"Spule aktualisiert\",spoolDeleted:\"Spule gelöscht\",spoolArchived:\"Spule archiviert\",spoolRestored:\"Spule wiederhergestellt\",deleteConfirm:\"Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.\",archiveConfirm:\"Möchten Sie diese Spule wirklich archivieren?\",advancedSettings:\"Erweiterte Einstellungen\",filamentInfoTab:\"Filament-Info\",paProfileTab:\"PA-Profil\",filamentInfo:\"Filament\",additional:\"Zusätzlich\",loadingPresets:\"Cloud-Presets werden geladen...\",cloudConnected:\"Cloud verbunden\",cloudNotConnected:\"Cloud nicht verbunden (Standardwerte)\",recentColors:\"Zuletzt\",searchColors:\"Farben suchen...\",searchResults:\"Suchergebnisse\",allColors:\"Alle Farben\",commonColors:\"Häufige Farben\",showLess:\"Weniger\",showAll:\"Alle\",noColorsFound:\"Keine Farben gefunden\",noResults:\"Keine Ergebnisse\",selectMaterialFirst:\"Bitte zuerst ein Material im Filament-Info Tab auswählen.\",noPrintersConfigured:\"Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.\",matchingFilter:\"Filter\",anyBrand:\"Jede Marke\",anyVariant:\"Jede Variante\",autoSelect:\"Auto-Auswahl\",matches:\"Treffer\",match:\"Treffer\",noMatches:\"Keine Treffer\",connected:\"Verbunden\",offline:\"Offline\",printerOffline:\"Drucker ist offline. Verbinden Sie ihn, um Kalibrierungsprofile anzuzeigen.\",noKProfilesMatch:\"Keine K-Profile stimmen mit dem gewählten Filament überein.\",leftNozzle:\"Linke Düse\",rightNozzle:\"Rechte Düse\",profilesSelected:\"Kalibrierungsprofil(e) ausgewählt\",totalInventory:\"Gesamtbestand\",totalConsumed:\"Gesamtverbrauch\",byMaterial:\"Nach Material\",inPrinter:\"Im Drucker\",lowStock:\"Niedriger Bestand\",sinceTracking:\"Seit Beginn der Erfassung\",loadedInAms:\"Im AMS/Ext geladen\",remaining:\"Verbleibend\",weightCheck:\"Gewichtskontrolle\",lastWeighed:\"Zuletzt gewogen\",neverWeighed:\"Nie gewogen\",search:\"Spulen suchen...\",showing:\"Zeige\",to:\"bis\",of:\"von\",show:\"Zeige\",spools:\"Spulen\",spool:\"Spule\",page:\"Seite\",noSpoolsMatch:\"Keine Ergebnisse\",noSpoolsMatchDesc:\"Versuchen Sie, Ihre Suche oder Filter anzupassen.\",active:\"Aktiv\",archived:\"Archiviert\",all:\"Alle\",used:\"Verwendet\",new:\"Neu\",clearFilters:\"Filter löschen\",table:\"Tabelle\",cards:\"Karten\",net:\"Netto\",groupSimilar:\"Gruppieren\",groupedSpools:\"{{count}} identische Spulen\",groupedRows:\"Zeilen\",columns:\"Spalten\",configureColumns:\"Spalten konfigurieren\",configureColumnsDesc:\"Ziehen zum Neuordnen oder Pfeile verwenden. Sichtbarkeit mit dem Augensymbol umschalten.\",visible:\"sichtbar\",reset:\"Zurücksetzen\",cancel:\"Abbrechen\",applyChanges:\"Änderungen anwenden\",moveUp:\"Nach oben\",moveDown:\"Nach unten\",hideColumn:\"Spalte ausblenden\",showColumn:\"Spalte einblenden\",linkToSpool:\"Mit Spule verknüpfen\",tagLinked:\"Tag mit Spule verknüpft\",tagLinkFailed:\"Tag-Verknüpfung fehlgeschlagen\",tagAlreadyLinked:\"Tag bereits mit anderer Spule verknüpft\",unknownTag:\"Unbekannter RFID-Tag erkannt\",usageHistory:\"Verbrauchshistorie\",noUsageHistory:\"Noch kein Verbrauch erfasst\",printName:\"Druckname\",weightConsumed:\"Verbrauchtes Gewicht\",clearHistory:\"Löschen\",historyCleared:\"Verbrauchshistorie gelöscht\",fillSourceLabel:\"(Inv)\",lowStockThresholdError:\"Der Schwellenwert muss zwischen 0.1 und 99.9 liegen\",assignMismatchTitle:\"Material stimmt nicht überein\",assignMismatchMessage:'Das ausgewählte Spulenmaterial \"{{spoolMaterial}}\" stimmt nicht mit dem Tray-Material \"{{trayMaterial}}\" für {{location}} überein. Trotzdem zuweisen?',assignMismatchConfirm:\"Trotzdem zuweisen\",assignPartialMismatchMessage:'Das Spulenmaterial \"{{spoolMaterial}}\" ist ähnlich, stimmt aber nicht genau mit \"{{trayMaterial}}\" in {{location}} überein. Möchten Sie fortfahren?',assignProfileMismatchMessage:'Das Spulenprofil \"{{spoolProfile}}\" stimmt nicht mit dem Fachprofil \"{{trayProfile}}\" in {{location}} überein. Möchten Sie fortfahren?'},timelapse:{title:\"Zeitraffer\",create:\"Zeitraffer erstellen\",download:\"Herunterladen\",delete:\"Löschen\",preview:\"Vorschau\",frameRate:\"Bildrate\",quality:\"Qualität\",processing:\"Wird verarbeitet...\",noTimelapses:\"Keine Zeitraffer verfügbar\"},ams:{title:\"AMS\",slot:\"Slot\",empty:\"Leer\",emptySlot:\"Leerer Slot\",unknown:\"Unbekannt\",humidity:\"Luftfeuchtigkeit\",temperature:\"Temperatur\",filamentType:\"Filamenttyp\",filamentColor:\"Farbe\",remaining:\"Verbleibend\",history:\"AMS-Verlauf\",noHistory:\"Kein Verlauf verfügbar\",configureSlot:\"Slot konfigurieren\",externalSpool:\"Externe Spule\",profile:\"Profil\",kFactor:\"K-Faktor\",fill:\"Füllstand\",configure:\"Konfigurieren\",used:\"verwendet\",remainingUnit:\"verbleibend\"},printModal:{title:\"Druck starten\",selectPrinter:\"Drucker auswählen\",selectPlate:\"Platte auswählen\",filamentMapping:\"Filamentzuordnung\",totalCost:\"Gesamtkosten:\",slotRemainingShort:\" - {{grams}}g übrig\",printSettings:\"Druckeinstellungen\",bedLeveling:\"Bett-Nivellierung\",flowCalibration:\"Fluss-Kalibrierung\",vibrationCalibration:\"Vibrations-Kalibrierung\",layerInspection:\"Erste-Schicht-Prüfung\",timelapse:\"Zeitraffer\",startPrint:\"Druck starten\",addToQueue:\"Zur Warteschlange hinzufügen\",cancel:\"Abbrechen\",noPrintersAvailable:\"Keine Drucker verfügbar\",printerBusy:\"Drucker ist beschäftigt\",printerOffline:\"Drucker ist offline\",sameTypeDifferentColor:\"Gleicher Typ, andere Farbe\",filamentTypeNotLoaded:\"Filamenttyp nicht geladen\",openCalendar:\"Kalender öffnen\",leftNozzle:\"L\",rightNozzle:\"R\",leftNozzleTooltip:\"Linke Düse\",rightNozzleTooltip:\"Rechte Düse\",filamentOverride:\"Filament-Überschreibung\",filamentOverrideHint:\"Filamente für modellbasierte Zuweisung optional überschreiben. Der Planer wird gegen die ausgewählten Filamente statt der ursprünglichen 3MF-Werte abgleichen.\",originalFilament:\"Original\",overrideWith:\"Ersetzen mit\",resetToOriginal:\"Auf Original zurücksetzen\",insufficientFilamentTitle:\"Nicht genug Filament\",insufficientFilamentMessage:\"Einige zugewiesene Spulen haben weniger Filament als dieser Druck benötigt:\",insufficientFilamentLine:\"{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g\",printAnyway:\"Trotzdem drucken\",forceColorMatch:\"Farbe erzwingen\",staggerPrinterStarts:\"Stagger printer starts\",staggerGroupSize:\"Group size\",staggerInterval:\"Interval (min)\",staggerPreview:\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",staggerLastGroup:\"last group: {{count}}\",staggerTotal:\"total: {{minutes}} min\",staggerToPrinters:\"Gestaffelt an {{count}} Drucker senden\",gcodeInjection:\"Auto-Print G-code einfügen\"},backup:{title:\"Sichern & Wiederherstellen\",createBackup:\"Sicherung erstellen\",restoreBackup:\"Sicherung wiederherstellen\",restoreDescription:\"Alle Daten aus einer Sicherungsdatei ersetzen\",downloadBackup:\"Sicherung herunterladen\",uploadBackup:\"Sicherung hochladen\",lastBackup:\"Letzte Sicherung\",autoBackup:\"Automatische Sicherung\",backupNow:\"Jetzt sichern\",restoreWarning:\"Warnung: Das Wiederherstellen einer Sicherung überschreibt alle aktuellen Daten.\",includeArchives:\"Archive einschließen\",includeSettings:\"Einstellungen einschließen\",includeProfiles:\"Profile einschließen\",backupSuccess:\"Sicherung erfolgreich erstellt\",restoreSuccess:\"Sicherung erfolgreich wiederhergestellt\",backupFailed:\"Sicherung fehlgeschlagen\",restoreFailed:\"Wiederherstellung fehlgeschlagen\",restoreNote:\"Virtueller Drucker wird während der Wiederherstellung gestoppt\",githubBackup:\"GitHub Backup\",enabled:\"Aktiviert\",cloudLoginRequired:\"Bambu Cloud Login erforderlich. Melden Sie sich unter Profile → Cloud-Profile an, um GitHub-Backup zu aktivieren.\",cloudLoginRequiredShort:\"Cloud-Login erforderlich\",githubDescription:\"Synchronisieren Sie Ihre Profile automatisch mit einem privaten GitHub-Repository für Backup und Versionsverlauf.\",repositoryUrl:\"Repository-URL\",personalAccessToken:\"Persönlicher Zugriffstoken\",tokenSaved:\"(gespeichert)\",enterNewToken:\"Neuen Token eingeben zum Aktualisieren\",tokenHint:\"Feingranularer Token mit Lese-/Schreibberechtigung für Inhalte\",branch:\"Branch\",manualOnly:\"Nur manuell\",hourly:\"Stündlich\",daily:\"Täglich\",weekly:\"Wöchentlich\",includeInBackup:\"In Sicherung einschließen\",kProfiles:\"K-Profile\",kProfilesDescription:\"Druckvorschub-Kalibrierung von verbundenen Druckern\",noPrintersConnected:\"Keine Drucker verbunden\",printersConnected:\"{{connected}}/{{total}} verbunden\",cloudProfiles:\"Cloud-Profile\",cloudProfilesDescription:\"Filament-, Drucker- und Prozessprofile aus der Bambu Cloud\",appSettings:\"App-Einstellungen\",appSettingsDescription:\"Bambuddy-Konfiguration (komplette Datenbank)\",spoolInventory:\"Spulenbestand\",spoolInventoryDescription:\"Filamentspulen, Nutzungsverlauf und Kostenverfolgung\",printArchives:\"Druckarchive\",printArchivesDescription:\"Druckverlauf-Metadaten (keine GCode/3MF-Dateien)\",lastBackupAt:\"Letzte Sicherung:\",noBackupsYet:\"Noch keine Sicherungen\",next:\"Nächste:\",startingBackup:\"Sicherung wird gestartet...\",test:\"Test\",enableBackup:\"Sicherung aktivieren\",testConnection:\"Verbindung testen\",enterRepoUrl:\"Repository-URL eingeben\",enterRepoAndToken:\"Repository-URL und Zugriffstoken eingeben\",repoRequired:\"Repository-URL ist erforderlich\",tokenRequired:\"Zugriffstoken ist erforderlich\",githubBackupEnabled:\"GitHub-Backup aktiviert\",tokenUpdated:\"Token aktualisiert\",settingsSaved:\"Einstellungen gespeichert\",failedToSave:\"Speichern fehlgeschlagen: {{message}}\",backupCompleteFiles:\"Sicherung abgeschlossen - {{count}} Dateien aktualisiert\",backupSkippedNoChanges:\"Sicherung übersprungen - keine Änderungen\",backupFailed2:\"Sicherung fehlgeschlagen: {{message}}\",clearedLogs:\"{{count}} Protokolle gelöscht\",failedToClearLogs:\"Protokolle löschen fehlgeschlagen: {{message}}\",history:\"Verlauf\",clear:\"Löschen\",date:\"Datum\",status:\"Status\",commit:\"Commit\",localBackup:\"Lokale Sicherung\",localBackupDescription:\"Erstellen Sie eine vollständige Sicherung Ihrer Bambuddy-Daten einschließlich Datenbank, Archive, Uploads und aller Dateien.\",downloadBackupLabel:\"Sicherung herunterladen\",completeBackupZip:\"Vollständige Sicherung: Datenbank + alle Dateien (ZIP)\",download:\"Herunterladen\",preparingBackup:\"Sicherung wird vorbereitet...\",creatingArchive:\"Sicherungsarchiv wird erstellt... Dies kann bei großen Archiven eine Weile dauern.\",downloadingFile:\"Sicherungsdatei wird heruntergeladen...\",backupDownloaded:\"Sicherung erfolgreich heruntergeladen\",failedToCreateBackup:\"Sicherung erstellen fehlgeschlagen: {{message}}\",restore:\"Wiederherstellen\",restoreReplacesAll:\"Wiederherstellung ersetzt alle Daten.\",restoreReplacesAllDetail:\"Ihre aktuelle Datenbank und Dateien werden vollständig ersetzt. Nach der Wiederherstellung ist ein Neustart erforderlich.\",restoreConfirmTitle:\"Sicherung wiederherstellen\",restoreConfirmMessage:'Sind Sie sicher, dass Sie von \"{{filename}}\" wiederherstellen möchten? Dies ersetzt Ihre aktuelle Datenbank und alle Dateien vollständig. Die Anwendung muss nach der Wiederherstellung neu gestartet werden.',restoreConfirmButton:\"Sicherung wiederherstellen\",uploadingFile:\"Sicherungsdatei wird hochgeladen...\",backupRestoredRestart:\"Sicherung wiederhergestellt. Bitte starten Sie Bambuddy neu.\",failedToRestore:\"Sicherung wiederherstellen fehlgeschlagen. Bitte überprüfen Sie das Dateiformat.\",reloadNow:\"Jetzt neu laden\",creatingBackup:\"Sicherung erstellen\",restoringBackup:\"Sicherung wiederherstellen\",preparing:\"Vorbereiten...\",processing:\"Verarbeiten...\",doNotClosePage:\"Bitte schließen Sie diese Seite nicht und navigieren Sie nicht weg. Dieser Vorgang kann bei großen Sicherungen mehrere Minuten dauern.\",restoring:\"Wiederherstellen...\",restoreComplete:\"Wiederherstellung abgeschlossen\",restoreFailed2:\"Wiederherstellung fehlgeschlagen\",importSettings:\"Einstellungen aus einer Sicherungsdatei importieren\",pleaseWaitRestoring:\"Bitte warten Sie, während Ihre Daten wiederhergestellt werden\",selectBackupFile:\"Klicken Sie, um eine Sicherungsdatei auszuwählen (.json oder .zip)\",duplicateHandling:\"So funktioniert die Duplikatbehandlung:\",matchPrinters:\"Drucker\",matchPrintersBy:\"abgeglichen nach Seriennummer\",matchSmartPlugs:\"Smart Plugs\",matchSmartPlugsBy:\"abgeglichen nach IP-Adresse\",matchNotificationProviders:\"Benachrichtigungsanbieter\",matchNotificationProvidersBy:\"abgeglichen nach Name\",matchFilaments:\"Filamente\",matchFilamentsBy:\"abgeglichen nach Name + Typ + Marke\",matchArchives:\"Archive\",matchArchivesBy:\"abgeglichen nach Inhaltshash (immer übersprungen)\",matchPendingUploads:\"Ausstehende Uploads\",matchPendingUploadsBy:\"abgeglichen nach Dateiname\",matchSettingsTemplates:\"Einstellungen & Vorlagen\",matchSettingsTemplatesBy:\"immer überschrieben\",replaceExisting:\"Vorhandene Daten ersetzen\",keepExisting:\"Vorhandene Daten behalten\",overwriteDescription:\"Bereits vorhandene Elemente mit Sicherungsdaten überschreiben\",keepDescription:\"Nur Elemente wiederherstellen, die noch nicht vorhanden sind\",overwriteCaution:\"Achtung:\",overwriteWarning:\"Das Überschreiben ersetzt Ihre aktuellen Konfigurationen durch Daten aus der Sicherung. Drucker-Zugangscodes werden aus Sicherheitsgründen nie überschrieben.\",cancel:\"Abbrechen\",processingBackup:\"Sicherungsdatei wird verarbeitet...\",itemsRestored:\"Wiederhergestellt\",itemsSkipped:\"Übersprungen\",restored:\"Wiederhergestellt\",skippedAlreadyExist:\"Übersprungen (bereits vorhanden)\",filesCategory:\"Dateien (3MF, Thumbnails, etc.)\",andMore:\"...und {{count}} weitere\",newApiKeysGenerated:\"Neue API-Schlüssel generiert\",keysShownOnce:\"Diese Schlüssel werden nur einmal angezeigt. Kopieren Sie sie jetzt!\",copy:\"Kopieren\",noDataFound:\"In der Sicherungsdatei wurden keine Daten zur Wiederherstellung gefunden.\",close:\"Schließen\",scheduledBackup:\"Scheduled Backups\",scheduledBackupDescription:\"Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.\",frequency:\"Frequency\",backupTime:\"Time\",retention:\"Retention\",retentionDescription:\"Number of backups to keep\",outputPath:\"Output Path\",outputPathPlaceholder:\"Default: {{path}}\",outputPathDescription:\"Leave empty for default location\",runNow:\"Run Now\",backupFiles:\"Backup Files\",noScheduledBackups:\"No backups yet\",deleteBackup:\"Delete\",deleteBackupConfirm:\"Delete this backup file?\",backupRunning:\"Backup in progress...\",scheduledBackupComplete:\"Backup completed successfully\",scheduledBackupFailed:\"Backup failed\",nextBackup:\"Next backup\",backupSize:\"Size\",utc:\"UTC\",defaultPathLabel:\"Default:\",categories:{settings:\"Einstellungen\",notification_providers:\"Benachrichtigungsanbieter\",notification_templates:\"Benachrichtigungsvorlagen\",smart_plugs:\"Smart Plugs\",printers:\"Drucker\",filaments:\"Filamente\",maintenance_types:\"Wartungstypen\",archives:\"Archive\",projects:\"Projekte\",pending_uploads:\"Ausstehende Uploads\",external_links:\"Externe Links\",api_keys:\"API-Schlüssel\"}},tags:{title:\"Tags\",addTag:\"Tag hinzufügen\",editTag:\"Tag bearbeiten\",deleteTag:\"Tag löschen\",tagName:\"Tag-Name\",tagColor:\"Tag-Farbe\",noTags:\"Keine Tags\",deleteConfirm:\"Möchten Sie diesen Tag wirklich löschen?\",manageTags:\"Tags verwalten\"},uploadModal:{title:\"3MF-Dateien hochladen\",dragDrop:\"3MF-Dateien hierher ziehen\",or:\"oder\",browseFiles:\"Dateien durchsuchen\",extractionInfo:\"Das Druckermodell wird automatisch aus den 3MF-Datei-Metadaten extrahiert.\",uploaded:\"hochgeladen\",failed:\"fehlgeschlagen\",uploading:\"Wird hochgeladen...\",upload:\"Hochladen\",uploadFailed:\"Hochladen fehlgeschlagen\"},editArchive:{title:\"Archiv bearbeiten\",name:\"Name\",namePlaceholder:\"Druckname\",printer:\"Drucker\",noPrinter:\"Kein Drucker\",project:\"Projekt\",noProject:\"Kein Projekt\",itemsPrinted:\"Gedruckte Teile\",itemsPrintedHelp:\"Anzahl der in diesem Druckauftrag produzierten Teile\",notes:\"Notizen\",notesPlaceholder:\"Notizen zu diesem Druck hinzufügen...\",externalLink:\"Externer Link\",externalLinkPlaceholder:\"https://printables.com/model/...\",externalLinkHelp:\"Link zu Printables, Thingiverse oder anderer Quelle\",tags:\"Tags\",tagsPlaceholder:\"Tags hinzufügen...\",addMoreTags:\"Weitere Tags hinzufügen...\",matchingTags:'Übereinstimmend mit \"{{query}}\"',existingTags:\"Vorhandene Tags\",clickToAdd:\"(zum Hinzufügen klicken)\",status:\"Status\",failureReason:\"Fehlergrund\",selectReason:\"Grund auswählen...\",photos:\"Fotos des Druckergebnisses\",photosHelp:\"Klicken Sie auf + um Fotos Ihres Druckergebnisses hinzuzufügen\",printResult:\"Druckergebnis\",saving:\"Wird gespeichert...\",failureReasons:{adhesionFailure:\"Haftungsfehler\",spaghettiDetached:\"Spaghetti / Abgelöst\",layerShift:\"Schichtversatz\",cloggedNozzle:\"Verstopfte Düse\",filamentRunout:\"Filament aufgebraucht\",warping:\"Verformung\",stringing:\"Fadenziehen\",underExtrusion:\"Unterextrusion\",powerFailure:\"Stromausfall\",userCancelled:\"Vom Benutzer abgebrochen\",other:\"Sonstiges\"},statuses:{completed:\"Abgeschlossen\",failed:\"Fehlgeschlagen\",aborted:\"Abgebrochen\",printing:\"Druckt\"}},kProfiles:{title:\"K-Profile\",noPrintersConfigured:\"Keine Drucker konfiguriert\",addPrinterInSettings:\"Fügen Sie einen Drucker in den Einstellungen hinzu, um K-Profile zu verwalten\",noActivePrinters:\"Keine aktiven Drucker\",enablePrinterConnection:\"Aktivieren Sie eine Druckerverbindung, um K-Profile anzuzeigen\",loadingProfiles:\"Lade K-Profile...\",printerOffline:\"Drucker offline\",printerOfflineDesc:\"Der ausgewählte Drucker ist nicht verbunden. Schalten Sie ihn ein, um K-Profile anzuzeigen.\",noMatchingProfiles:\"Keine passenden Profile\",noMatchingProfilesDesc:\"Keine Profile entsprechen Ihren Suchkriterien\",noKProfiles:\"Keine K-Profile\",noKProfilesDesc:\"Keine Druckvorschub-Profile für {{diameter}}mm Düse gefunden\",createFirstProfile:\"Erstes Profil erstellen\",printer:\"Drucker\",nozzle:\"Düse\",refresh:\"Aktualisieren\",addProfile:\"Profil hinzufügen\",export:\"Exportieren\",import:\"Importieren\",select:\"Auswählen\",selectAll:\"Alle auswählen\",delete:\"Löschen\",searchPlaceholder:\"Nach Name oder Filament suchen...\",allExtruders:\"Alle Extruder\",leftOnly:\"Nur links\",rightOnly:\"Nur rechts\",allFlow:\"Alle Flusstypen\",hfOnly:\"Nur HF\",sOnly:\"Nur S\",sortName:\"Sortieren: Name\",sortKValue:\"Sortieren: K-Wert\",sortFilament:\"Sortieren: Filament\",leftExtruder:\"Linker Extruder\",rightExtruder:\"Rechter Extruder\",modal:{addTitle:\"K-Profil hinzufügen\",editTitle:\"K-Profil bearbeiten\",profileName:\"Profilname\",profileNamePlaceholder:\"Mein PLA-Profil\",kValue:\"K-Wert\",kValuePlaceholder:\"0,020\",kValueHelp:\"Typischer Bereich: 0,01 - 0,06 für PLA, 0,02 - 0,10 für PETG\",filament:\"Filament\",selectFilament:\"Filament auswählen...\",noFilamentsHelp:\"Keine Filamente gefunden. Erstellen Sie zuerst ein K-Profil in Bambu Studio.\",flowType:\"Flusstyp\",highFlow:\"High Flow\",standard:\"Standard\",nozzleSize:\"Düsengröße\",extruder:\"Extruder\",extruders:\"Extruder\",left:\"Links\",right:\"Rechts\",notes:\"Notizen (lokal gespeichert)\",notesPlaceholder:\"Notizen zu diesem Profil hinzufügen...\",notesHelp:\"Notizen werden in Bambuddy gespeichert, nicht auf dem Drucker\",syncing:\"Synchronisiert mit Drucker...\",savingExtruder:\"Speichern auf Extruder {{current}}/{{total}}...\",pleaseWait:\"Bitte warten\"},deleteConfirm:{title:\"Profil löschen\",cannotUndo:\"Dies kann nicht rückgängig gemacht werden\",message:'Möchten Sie \"{{name}}\" wirklich vom Drucker löschen?'},bulkDelete:{title:\"Profile löschen\",cannotUndo:\"Dies kann nicht rückgängig gemacht werden\",message:\"Möchten Sie wirklich {{count}} ausgewählte Profile vom Drucker löschen?\"},toast:{profileSaved:\"K-Profil gespeichert\",profilesSaved:\"K-Profil auf {{count}} Extrudern gespeichert\",selectAtLeastOneExtruder:\"Bitte wählen Sie mindestens einen Extruder aus\",profileDeleted:\"K-Profil gelöscht\",profilesDeleted:\"{{count}} Profile gelöscht\",exportedProfiles:\"{{count}} Profile exportiert\",importedProfiles:\"{{count}} von {{total}} Profilen importiert\",noProfilesToExport:\"Keine Profile zum Exportieren\",invalidFileFormat:\"Ungültiges Dateiformat\",failedToParseImport:\"Import-Datei konnte nicht gelesen werden\",failedToSaveBatch:\"K-Profile konnten nicht gespeichert werden\",noteSaved:\"Notiz gespeichert\",failedToSaveNote:\"Notiz konnte nicht gespeichert werden\"},permission:{noRead:\"Sie haben keine Berechtigung, Profile zu aktualisieren\",noCreate:\"Sie haben keine Berechtigung, Profile hinzuzufügen\",noUpdate:\"Sie haben keine Berechtigung, K-Profile zu aktualisieren\",noDelete:\"Sie haben keine Berechtigung, K-Profile zu löschen\",noExport:\"Sie haben keine Berechtigung, Profile zu exportieren\",noImport:\"Sie haben keine Berechtigung, Profile zu importieren\"}},virtualPrinter:{title:\"Virtueller Drucker\",running:\"Läuft\",stopped:\"Gestoppt\",description:{default:\"Aktiviere einen virtuellen Drucker, der in Bambu Studio und OrcaSlicer erscheint. Dateien, die an diesen Drucker gesendet werden, werden direkt archiviert ohne zu drucken.\",proxy:\"Aktiviere einen Proxy, der Slicer-Datenverkehr an einen echten Drucker weiterleitet, um Ferndruck über jedes Netzwerk zu ermöglichen.\"},enable:{title:\"Virtuellen Drucker aktivieren\",visibleInSlicer:'Sichtbar als \"Bambuddy\" in der Slicer-Erkennung',proxyingTo:\"Proxy zu {{name}}\",notActive:\"Nicht aktiv\"},model:{title:\"Druckermodell\",description:\"Wähle welches Druckermodell emuliert werden soll.\",restartWarning:\"Das Ändern des Modells startet den virtuellen Drucker neu\"},accessCode:{title:\"Zugangscode\",isSet:\"Zugangscode ist gesetzt\",notSet:\"Kein Zugangscode gesetzt - erforderlich zum Aktivieren\",placeholder:\"8-Zeichen-Code eingeben\",placeholderChange:\"Neuen Code eingeben zum Ändern\",hint:\"Muss genau 8 Zeichen lang sein. Wird von Slicern zur Authentifizierung verwendet.\",charCount:\"({{count}}/8)\"},targetPrinter:{title:\"Zieldrucker\",configured:\"Proxy-Ziel konfiguriert\",notConfigured:\"Kein Zieldrucker ausgewählt - erforderlich für Proxy-Modus\",placeholder:\"Drucker auswählen...\",hint:\"Wähle den Drucker aus, an den der Slicer-Datenverkehr weitergeleitet werden soll. Der Drucker muss im LAN-Modus sein.\",noPrinters:\"Keine Drucker konfiguriert. Füge zuerst einen Drucker hinzu, um den Proxy-Modus zu verwenden.\"},remoteInterface:{title:\"Netzwerkschnittstelle überschreiben\",configured:\"Schnittstellenüberschreibung aktiv\",optional:\"Optional - verwenden wenn die automatisch erkannte IP falsch ist (z.B. mehrere NICs, Docker, VPN)\",placeholder:\"Automatisch erkennen (Standard)...\",hint:\"Überschreibt die per SSDP beworbene und im TLS-Zertifikat verwendete IP-Adresse. Nützlich wenn Bambuddy mehrere Netzwerkschnittstellen hat.\"},mode:{title:\"Modus\",archive:\"Archivieren\",archiveDesc:\"Dateien sofort archivieren\",review:\"Überprüfen\",reviewDesc:\"Vor dem Archivieren überprüfen\",queue:\"Warteschlange\",queueDesc:\"Archivieren und zur Warteschlange hinzufügen\",proxy:\"Proxy\",proxyDesc:\"An echten Drucker weiterleiten\"},autoDispatch:{title:\"Automatisch starten\",description:\"Drucke automatisch starten, wenn sie zur Warteschlange hinzugefügt werden. Wenn deaktiviert, warten Drucke auf manuellen Start.\"},setupRequired:{title:\"Einrichtung erforderlich\",description:\"Die virtuelle Druckerfunktion erfordert zusätzliche Systemkonfiguration, bevor sie funktioniert. Dies beinhaltet Portweiterleitung, Firewall-Regeln und plattformspezifische Einstellungen.\",readGuide:\"Lese die Einrichtungsanleitung vor dem Aktivieren\"},howItWorks:{title:\"So funktioniert es\",step1:\"Im selben LAN erscheinen virtuelle Drucker automatisch in deinem Slicer (Bambu Studio / OrcaSlicer). Aus anderen Netzwerken füge sie manuell per IP-Adresse und Zugangscode hinzu.\",step2:'Im Archiv-, Überprüfungs- und Warteschlangen-Modus verwende die \"Senden\"-Funktion im Slicer, um 3MF-Dateien an Bambuddy zu senden. Der Slicer zeigt \"Druck erfolgreich\" — die Datei wird gespeichert, nicht gedruckt.',step3:\"Im Proxy-Modus leitet der virtuelle Drucker den gesamten Datenverkehr an einen echten Drucker weiter — Drucke starten sofort wie bei einer direkten Verbindung.\"},status:{title:\"Status-Details\",printerName:\"Druckername\",model:\"Modell\",serialNumber:\"Seriennummer\",mode:\"Modus\",pendingFiles:\"Ausstehende Dateien\",targetPrinter:\"Zieldrucker\",ftpPort:\"FTP-Port\",mqttPort:\"MQTT-Port\",ftpConnections:\"FTP-Verbindungen\",mqttConnections:\"MQTT-Verbindungen\"},toast:{updated:\"Virtuelle Druckereinstellungen aktualisiert\",failedToUpdate:\"Einstellungen konnten nicht aktualisiert werden\",accessCodeRequired:\"Bitte zuerst einen Zugangscode setzen\",targetPrinterRequired:\"Bitte zuerst einen Zieldrucker auswählen\",bindIpRequired:\"Bitte zuerst eine Bind-IP setzen\",accessCodeEmpty:\"Zugangscode darf nicht leer sein\",accessCodeLength:\"Zugangscode muss genau 8 Zeichen lang sein\",created:\"Virtueller Drucker erstellt\",failedToCreate:\"Virtueller Drucker konnte nicht erstellt werden\",deleted:\"Virtueller Drucker gelöscht\",failedToDelete:\"Virtueller Drucker konnte nicht gelöscht werden\"},list:{title:\"Virtuelle Drucker\",add:\"Hinzufügen\",addFirst:\"Virtuellen Drucker hinzufügen\",empty:\"Keine virtuellen Drucker konfiguriert. Fügen Sie einen hinzu, um zu beginnen.\"},bindIp:{title:\"Bind-Interface\",placeholder:\"Interface auswählen...\",hint:\"Netzwerkinterface, an das dieser virtuelle Drucker gebunden wird. Muss pro Drucker eindeutig sein.\"},proxy:{accessCodeHint:\"Im Proxy-Modus den Zugangscode des Zieldruckers im Slicer verwenden. Die Verbindung wird transparent zum echten Drucker weitergeleitet.\"},addDialog:{title:\"Virtuellen Drucker hinzufügen\",name:\"Name\",hint:\"Sie können Zugangscode, Zieldrucker und andere Einstellungen nach dem Erstellen konfigurieren.\",create:\"Erstellen\"},deleteConfirm:{title:\"Virtuellen Drucker löschen\",message:'Möchten Sie \"{{name}}\" wirklich löschen? Dies stoppt alle Dienste für diesen Drucker.'}},modelViewer:{openInSlicer:\"Im Slicer öffnen\",tabs:{model:\"3D-Modell\",gcode:\"G-Code Vorschau\"},notAvailable:\"nicht verfügbar\",notSliced:\"nicht geslicet\",plates:\"Platten\",allPlates:\"Alle Platten\",plateNumber:\"Platte {{number}}\",plateCount:\"{{count}} Platte\",plateCount_other:\"{{count}} Platten\",objectCount:\"{{count}} Objekt\",objectCount_other:\"{{count}} Objekte\",filamentCount:\"{{count}} Filament\",filamentCount_other:\"{{count}} Filamente\",eta:\"ETA {{minutes}} Min\",noPreview:\"Keine Vorschau für diese Datei verfügbar\",pagination:{pageOf:\"Seite {{current}} von {{total}}\",prev:\"Zurück\",next:\"Weiter\"},errors:{failedToLoad:\"Datei konnte nicht geladen werden\",noMeshes:\"Keine Meshes in 3MF-Datei gefunden\",unsupportedFormat:\"Nicht unterstütztes Dateiformat\"}},maintenanceDescriptions:{lubricateCarbonRods:\"Schmiermittel auf Karbonstäbe für sanfte Bewegung auftragen\",lubricateRails:\"Schmiermittel auf Linearschienen für sanfte Bewegung auftragen\",cleanNozzle:\"Hotend und Düse reinigen, um Verstopfungen zu verhindern\",checkBelts:\"Riemenspannung für präzise Drucke überprüfen\",cleanBuildPlate:\"Druckplatte für bessere Haftung reinigen\",checkExtruder:\"Extruderzahnräder auf Verschleiß prüfen\",checkCooling:\"Sicherstellen, dass Lüfter ordnungsgemäß funktionieren\",generalInspection:\"Allgemeine Druckerinspektion\",cleanCarbonRods:\"Karbonstäbe reinigen, um Reibung zu reduzieren\",lubricateSteelRods:\"Schmiermittel auf Stahlstangen für sanfte Bewegung auftragen\",cleanSteelRods:\"Stahlstangen reinigen, um Reibung zu reduzieren\",cleanLinearRails:\"Linearschienen abwischen, um Staub und Schmutz zu entfernen\",checkPtfeTube:\"PTFE-Schlauch auf Verschleiß oder Beschädigung prüfen\",replaceHepaFilter:\"HEPA-Filter für Luftqualität ersetzen\",replaceCarbonFilter:\"Aktivkohlefilter ersetzen\",lubricateLeftNozzleRail:\"Linke Düsenschiene schmieren (H2-Serie)\"},smartPlugs:{offline:\"Offline\",admin:\"Admin\",openPlugAdminPage:\"Plug-Admin-Seite öffnen\",deleteSmartPlug:\"Smart Plug löschen\",turnOnSmartPlug:\"Smart Plug einschalten\",turnOffSmartPlug:\"Smart Plug ausschalten\",turnOn:\"Einschalten\",turnOff:\"Ausschalten\",addSmartPlug:{scanningNetwork:\"Netzwerk wird durchsucht...\",chooseEntity:\"Entität auswählen...\",connectionFailed:\"Verbindung fehlgeschlagen\",searchEntities:\"Entitäten suchen...\",searchPowerSensors:\"Leistungssensoren suchen...\",searchEnergySensors:\"Energiesensoren suchen...\",placeholders:{plugName:\"Wohnzimmer Steckdose\",mqttStateOnValue:\"ON, true, 1\",mqttSameAsPower:\"Gleich wie Leistungs-Topic oder anders\"}},linkedTo:\"Verbunden mit:\",monitorOnly:\"Nur Überwachung\",alerts:\"Alarme\",scheduleOn:\"Ein {{time}}\",scheduleOff:\"Aus {{time}}\",on:\"Ein\",off:\"Aus\",power:\"Leistung\",kwhToday:\"kWh Heute\",settings:\"Einstellungen\",automationSettings:\"Automatisierungseinstellungen\",showInSwitchbar:\"In Schaltleiste anzeigen\",quickAccessSidebar:\"Schnellzugriff über Seitenleiste\",enabled:\"Aktiviert\",enableAutomation:\"Automatisierung für diesen Stecker aktivieren\",autoOn:\"Auto Ein\",autoOnDescription:\"Einschalten wenn Druck startet\",autoOff:\"Auto Aus\",autoOffDescription:\"Ausschalten wenn Druck abgeschlossen (einmalig)\",autoOffPersistent:\"Aktiviert lassen\",autoOffPersistentDescription:\"Zwischen Drucken aktiviert bleiben statt einmalig\",turnOffDelayMode:\"Ausschaltverzögerungsmodus\",time:\"Zeit\",temp:\"Temp\",delayMinutes:\"Verzögerung (Minuten)\",tempThreshold:\"Temperaturschwelle (°C)\",tempThresholdDescription:\"Schaltet aus wenn die Düse unter diese Temperatur abkühlt\",edit:\"Bearbeiten\",deleteConfirm:'Möchten Sie \"{{name}}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.',turnOnConfirm:'Möchten Sie \"{{name}}\" wirklich einschalten?',turnOffConfirm:'Möchten Sie \"{{name}}\" wirklich ausschalten? Dies unterbricht die Stromversorgung des angeschlossenen Geräts.',failedToTurn:'{{name}}\" konnte nicht {{action}} werden',unknown:\"Unbekannt\",addTitle:\"Smart Plug hinzufügen\",editTitle:\"Smart Plug bearbeiten\",stopScanning:\"Suche beenden\",discoverTasmota:\"Tasmota Geräte suchen\",foundDevices:\"{{count}} Gerät(e) gefunden - zum Auswählen klicken:\",noDevicesFound:\"Keine Tasmota Geräte in Ihrem Netzwerk gefunden\",haNotConfigured:\"Home Assistant ist nicht konfiguriert. Einrichtung unter\",haSettingsPath:\"Einstellungen → Netzwerk → Home Assistant\",selectEntity:\"Entität auswählen *\",ipAddress:\"IP-Adresse *\",nameLabel:\"Name *\",username:\"Benutzername\",password:\"Passwort\",authHint:\"Leer lassen, wenn Ihr Tasmota-Gerät keine Authentifizierung benötigt\",linkToPrinter:\"Mit Drucker verbinden\",noPrinter:\"Kein Drucker (nur manuelle Steuerung)\",linkingDescription:\"Verknüpfung ermöglicht automatisches Ein-/Ausschalten bei Druckstart/-ende\",powerAlerts:\"Leistungsalarme\",alertAbove:\"Alarm wenn über (W)\",alertBelow:\"Alarm wenn unter (W)\",alertDescription:\"Benachrichtigung wenn der Stromverbrauch diese Schwellenwerte überschreitet. Leer lassen um diese Richtung zu deaktivieren.\",dailySchedule:\"Tagesplan\",turnOnAt:\"Einschalten um\",turnOffAt:\"Ausschalten um\",scheduleDescription:\"Den Stecker automatisch täglich zu diesen Zeiten ein-/ausschalten. Leer lassen um diese Aktion zu überspringen.\",showOnPrinterCard:\"Auf Druckerkarte anzeigen\",displayOnPrinterCard:\"Schaltfläche auf Druckerkarte anzeigen\",connectedResult:\"Verbunden!\",deviceLabel:\"Gerät: {{name}} - \",stateLabel:\"Status: {{state}}\",test:\"Test\",delete:\"Löschen\",save:\"Speichern\",add:\"Hinzufügen\",cancel:\"Abbrechen\",failedToStartScan:\"Suche konnte nicht gestartet werden\",nameRequired:\"Name ist erforderlich\",entityRequired:\"Entität ist für Home Assistant Stecker erforderlich\",mqttTopicRequired:\"Mindestens ein MQTT-Topic muss für Leistung, Energie oder Statusüberwachung konfiguriert sein\",loadingEntities:\"Entitäten werden geladen...\",loading:\"Laden...\",failedToLoadEntities:\"Entitäten konnten nicht geladen werden: {{error}}\",noEntitiesMatching:'Keine Entitäten gefunden die \"{{search}}\" entsprechen',noEntitiesAvailable:\"Keine Entitäten verfügbar\",searchingEntities:\"Alle Entitäten durchsuchen ({{count}} gefunden)\",showingEntities:\"Zeige switch, light, input_boolean ({{count}} verfügbar)\",energyMonitoringOptional:\"Energieüberwachung (Optional)\",energyMonitoringHint:\"Sensoren suchen und auswählen, die Leistungs-/Energiedaten liefern.\",powerSensorW:\"Leistungssensor (W)\",energyTodayKwh:\"Energie Heute (kWh)\",totalEnergyKwh:\"Gesamtenergie (kWh)\",noMatchingSensors:\"Keine passenden Sensoren\",none:\"Keine\",mqttNotConfigured:\"MQTT-Broker nicht konfiguriert. Broker-Adresse einstellen unter\",mqttSettingsPath:\"Einstellungen → Netzwerk → MQTT-Veröffentlichung\",mqttNotConfiguredSuffix:\"(Sie müssen die Veröffentlichung nicht aktivieren, nur die Broker-Details ausfüllen).\",mqttMonitorOnlyDescription:\"MQTT-Stecker empfangen Leistungs-/Energiedaten über MQTT-Abonnement. Ein-/Ausschalten ist nicht verfügbar - verwenden Sie Ihren MQTT-Broker oder Ihr Home-Automation-System.\",powerMonitoring:\"Leistungsüberwachung\",energyMonitoring:\"Energieüberwachung\",stateMonitoring:\"Statusüberwachung\",optional:\"optional\",topic:\"Topic\",jsonPath:\"JSON-Pfad\",multiplier:\"Multiplikator\",onValue:\"EIN-Wert\",mqttPowerHint:`JSON-Pfad extrahiert Wert aus JSON-Payload (z.B. \"power_l1\"). Leer lassen wenn Topic rohe numerische Werte sendet.\nMultiplikator 0.001 für mW→W, 1000 für kW→W verwenden.`,mqttEnergyHint:`JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\nMultiplikator 0.001 für Wh→kWh, 1000 für MWh→kWh verwenden.`,mqttStateHint:`JSON-Pfad extrahiert Wert aus JSON-Payload. Leer lassen für rohe Werte.\nEIN-Wert: der genaue String der \"EIN\" bedeutet. Leer lassen für Auto-Erkennung (ON, true, 1).`,restControl:\"Control\",restOnUrl:\"Turn ON URL\",restOffUrl:\"Turn OFF URL\",restOnBody:\"ON Request Body\",restOffBody:\"OFF Request Body\",restMethod:\"HTTP Method\",restHeaders:\"Custom Headers (JSON)\",restStatusUrl:\"Status URL\",restStatusPath:\"State JSON Path\",restStatusOnValue:\"ON Value\",restPowerUrl:\"Power URL\",restPowerPath:\"Power JSON Path\",restPowerMultiplier:\"Power Multiplikator\",restEnergyUrl:\"Energie URL\",restEnergyPath:\"Energy JSON Path\",restEnergyMultiplier:\"Energie Multiplikator\",restUrlRequired:\"At least one URL (ON or OFF) is required for REST plugs\",restHeadersHint:'e.g. {\"Authorization\": \"Bearer your-token\"}',restBodyHint:'e.g. ON, {\"state\": \"on\"}',restStatusHint:\"URL to poll for current state\",restPathHint:\"e.g. state or data.power.status\",restPowerUrlHint:\"Eigene URL für Leistungsdaten (nutzt Status URL wenn leer)\",restEnergyUrlHint:\"Eigene URL für Energiedaten (nutzt Status URL wenn leer)\",restEnergyHint:\"Jeder Wert kann eine eigene URL verwenden oder auf die Status URL zurückgreifen. Multiplikatoren für Einheitenumrechnung verwenden (z.B. 0.001 für Wh zu kWh).\",testConnection:\"Test Connection\",connectionSuccess:\"Connection successful\",noSwitchesInSwitchbar:\"Keine Schalter in der Schaltleiste\",enableSwitchbarHint:'\"In Schaltleiste anzeigen\" unter Einstellungen > Smart Plugs aktivieren'},notifications:{providerTypes:{callmebot:\"CallMeBot/WhatsApp\",ntfy:\"ntfy\",pushover:\"Pushover\",telegram:\"Telegram\",email:\"E-Mail\",discord:\"Discord\",webhook:\"Webhook\",homeassistant:\"Home Assistant\"},providerDescriptions:{email:\"SMTP-E-Mail-Benachrichtigungen\",telegram:\"Benachrichtigungen über Telegram-Bot\",discord:\"An Discord-Kanal per Webhook senden\",ntfy:\"Kostenlose, selbst-hostbare Push-Benachrichtigungen\",pushover:\"Einfache, zuverlässige Push-Benachrichtigungen\",callmebot:\"Kostenlose WhatsApp-Benachrichtigungen über CallMeBot\",webhook:\"Generischer HTTP-POST an beliebige URL\",homeassistant:\"Dauerhafte Benachrichtigungen im Home Assistant Dashboard\"},lastSuccess:\"Zuletzt: {{date}}\",error:\"Fehler\",printer:\"Drucker:\",allPrinters:\"Alle Drucker\",sendTestNotification:\"Testbenachrichtigung senden\",eventSettings:\"Ereigniseinstellungen\",enabled:\"Aktiviert\",sendFromProvider:\"Benachrichtigungen von diesem Anbieter senden\",printEvents:\"Druckereignisse\",printerStatus:\"Druckerstatus\",amsAlarms:\"AMS-Alarme\",amsHtAlarms:\"AMS-HT-Alarme\",printQueue:\"Druckwarteschlange\",start:\"Start\",plateCheck:\"Plattenkontrolle\",complete:\"Abgeschlossen\",failed:\"Fehlgeschlagen\",stopped:\"Gestoppt\",progress:\"Fortschritt\",offline:\"Offline\",lowFilament:\"Filament niedrig\",maintenance:\"Wartung\",amsHumidity:\"AMS-Feuchtigkeit\",amsTemp:\"AMS-Temperatur\",amsHtHumidity:\"AMS-HT-Feuchtigkeit\",amsHtTemp:\"AMS-HT-Temperatur\",bedCooled:\"Bett abgekühlt\",firstLayer:\"Erste Schicht\",quiet:\"Ruhe\",digest:\"Zusammenfassung {{time}}\",printStarted:\"Druck gestartet\",plateNotEmpty:\"Platte nicht leer\",plateNotEmptyDescription:\"Objekte vor dem Druck erkannt\",printCompleted:\"Druck abgeschlossen\",bedCooledLabel:\"Bett abgekühlt\",bedCooledDescription:\"Bett nach dem Druck unter Schwellenwert abgekühlt\",firstLayerCompleteLabel:\"Erste Schicht fertig\",firstLayerCompleteDescription:\"Benachrichtigung mit Foto nach erster Schicht\",missingSpoolAssignmentLabel:\"Fehlende Spulenzuordnung\",missingSpoolAssignmentDescription:\"Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben\",printFailed:\"Druck fehlgeschlagen\",printStopped:\"Druck gestoppt\",progressMilestones:\"Fortschrittsmeilensteine\",progressMilestonesDescription:\"Benachrichtigung bei 25%, 50%, 75%\",printerOffline:\"Drucker offline\",printerError:\"Druckerfehler\",lowFilamentLabel:\"Filament niedrig\",maintenanceDue:\"Wartung fällig\",maintenanceDueDescription:\"Benachrichtigen, wenn Wartung erforderlich ist\",amsHumidityHigh:\"AMS-Feuchtigkeit hoch\",amsHumidityHighDescription:\"Normale AMS-Feuchtigkeit überschreitet Schwellenwert\",amsTemperatureHigh:\"AMS-Temperatur hoch\",amsTemperatureHighDescription:\"Normale AMS-Temperatur überschreitet Schwellenwert\",amsHtHumidityHigh:\"AMS-HT-Feuchtigkeit hoch\",amsHtHumidityHighDescription:\"AMS-HT-Feuchtigkeit überschreitet Schwellenwert\",amsHtTemperatureHigh:\"AMS-HT-Temperatur hoch\",amsHtTemperatureHighDescription:\"AMS-HT-Temperatur überschreitet Schwellenwert\",jobAdded:\"Auftrag hinzugefügt\",jobAddedDescription:\"Auftrag zur Warteschlange hinzugefügt\",jobAssigned:\"Auftrag zugewiesen\",jobAssignedDescription:\"Modellbasierter Auftrag einem Drucker zugewiesen\",jobStarted:\"Auftrag gestartet\",jobStartedDescription:\"Warteschlangenauftrag hat Druck begonnen\",jobWaiting:\"Auftrag wartet\",jobWaitingDescription:\"Auftrag wartet auf Filament oder Drucker\",jobSkipped:\"Auftrag übersprungen\",jobSkippedDescription:\"Auftrag übersprungen (vorheriger fehlgeschlagen)\",jobFailed:\"Auftrag fehlgeschlagen\",jobFailedDescription:\"Auftrag konnte nicht gestartet werden\",queueComplete:\"Warteschlange abgeschlossen\",queueCompleteDescription:\"Alle Warteschlangenaufträge beendet\",quietHours:\"Ruhezeiten\",noNotificationsDuring:\"Keine Benachrichtigungen während dieser Zeiten\",editProviderToChangeQuietHours:\"Anbieter bearbeiten, um Ruhezeiten zu ändern\",dailyDigest:\"Tägliche Zusammenfassung\",batchNotifications:\"Benachrichtigungen zu einer täglichen Zusammenfassung bündeln\",sendAt:\"Senden um {{time}}\",editProviderToChangeDigestTime:\"Anbieter bearbeiten, um Zusammenfassungszeit zu ändern\",edit:\"Bearbeiten\",deleteProvider:\"Benachrichtigungsanbieter löschen\",deleteConfirm:'Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten? Dies kann nicht rückgängig gemacht werden.',delete:\"Löschen\",addTitle:\"Benachrichtigungsanbieter hinzufügen\",editTitle:\"Benachrichtigungsanbieter bearbeiten\",nameLabel:\"Name *\",namePlaceholder:\"Meine Benachrichtigungen\",providerTypeLabel:\"Anbietertyp *\",configuration:\"Konfiguration\",testConfiguration:\"Konfiguration testen\",printerFilter:\"Druckerfilter\",onlyFromPrinter:\"Nur Benachrichtigungen für Ereignisse von diesem Drucker senden\",quietHoursDnd:\"Ruhezeiten (Nicht stören)\",quietStart:\"Start\",quietEnd:\"Ende\",dailyDigestLabel:\"Tägliche Zusammenfassung\",sendDigestAt:\"Zusammenfassung senden um\",digestCollected:\"Ereignisse werden gesammelt und als einzelne Zusammenfassung zu dieser Zeit gesendet\",notificationEvents:\"Benachrichtigungsereignisse\",progressPercent:\"(25%, 50%, 75%)\",bedCooledAfterPrint:\"(nach Druckabschluss)\",cancel:\"Abbrechen\",save:\"Speichern\",add:\"Hinzufügen\",nameRequired:\"Name ist erforderlich\",fieldRequired:\"{{field}} ist erforderlich\",phoneNumber:\"Telefonnummer\",apiKey:\"API-Schlüssel\",serverUrl:\"Server-URL\",topic:\"Thema\",authToken:\"Auth-Token\",userKey:\"Benutzerschlüssel\",appToken:\"App-Token\",priority:\"Priorität\",botToken:\"Bot-Token\",chatId:\"Chat-ID\",smtpServer:\"SMTP-Server\",smtpPort:\"SMTP-Port\",security:\"Sicherheit\",authentication:\"Authentifizierung\",username:\"Benutzername\",password:\"Passwort\",fromEmail:\"Absender-E-Mail\",toEmail:\"Empfänger-E-Mail\",webhookUrl:\"Webhook-URL\",payloadFormat:\"Payload-Format\",authorization:\"Autorisierung\",titleFieldName:\"Titel-Feldname\",messageFieldName:\"Nachrichten-Feldname\",editTemplate:\"Vorlage bearbeiten: {{name}}\",titleLabel:\"Titel\",bodyLabel:\"Inhalt\",titlePlaceholder:\"Benachrichtigungstitel...\",bodyPlaceholder:\"Benachrichtigungsinhalt...\",availableVariables:\"Verfügbare Variablen\",clickToInsert:\"Klicken, um an Cursorposition im Inhalt einzufügen\",livePreview:\"Live-Vorschau\",hide:\"Ausblenden\",show:\"Anzeigen\",loadingPreview:\"Vorschau wird geladen...\",enterTemplateContent:\"Vorlageninhalt eingeben, um Vorschau zu sehen\",titlePreview:\"Titel:\",bodyPreview:\"Inhalt:\",resetToDefault:\"Auf Standard zurücksetzen\",titleRequired:\"Titel ist erforderlich\",bodyRequired:\"Inhalt ist erforderlich\",notificationLog:\"Benachrichtigungsprotokoll\",showFailedOnly:\"Nur fehlgeschlagene\",last24Hours:\"Letzte 24 Stunden\",last7Days:\"Letzte 7 Tage\",last30Days:\"Letzte 30 Tage\",last90Days:\"Letzte 90 Tage\",justNow:\"Gerade eben\",noFailedNotifications:\"Keine fehlgeschlagenen Benachrichtigungen\",noNotificationsLogged:\"Keine Benachrichtigungen protokolliert\",unknownProvider:\"Unbekannter Anbieter\",logTitle:\"Titel\",logMessage:\"Nachricht\",logError:\"Fehler\",logProvider:\"Anbieter: {{type}}\",logTime:\"Zeit: {{time}}\",refresh:\"Aktualisieren\",clearOld:\"Alte löschen\",statsSummary:\"Letzte {{days}} Tage:\",statsNotifications:\"Benachrichtigungen\",statsSent:\"{{count}} gesendet\",statsFailed:\"{{count}} fehlgeschlagen\",eventTypes:{print_start:\"Druck gestartet\",print_complete:\"Druck abgeschlossen\",print_failed:\"Druck fehlgeschlagen\",print_stopped:\"Druck gestoppt\",print_progress:\"Fortschritt\",printer_offline:\"Drucker offline\",printer_error:\"Druckerfehler\",filament_low:\"Filament niedrig\",maintenance_due:\"Wartung fällig\",test:\"Test\"},userEmail:{title:\"Benachrichtigungen\",emailNotifications:\"E-Mail-Benachrichtigungen\",emailNotificationsDesc:\"Erhalten Sie E-Mail-Benachrichtigungen für Ihre eigenen Druckaufträge. E-Mails werden über die in der erweiterten Authentifizierung konfigurierten SMTP-Einstellungen gesendet.\",sendingTo:\"Benachrichtigungen werden gesendet an\",noEmailWarning:\"Ihr Konto hat keine E-Mail-Adresse. Wenden Sie sich an einen Administrator, um eine hinzuzufügen.\",printJobNotifications:\"Druckauftrags-Benachrichtigungen\",printJobNotificationsDesc:\"Wählen Sie aus, welche Ereignisse E-Mail-Benachrichtigungen für von Ihnen gesendete Druckaufträge auslösen.\",printJobStarts:\"Druckauftrag startet\",printJobStartsDesc:\"Benachrichtigt werden, wenn Ihr Druckauftrag beginnt.\",printJobFinishes:\"Druckauftrag fertig\",printJobFinishesDesc:\"Benachrichtigt werden, wenn Ihr Druckauftrag erfolgreich abgeschlossen wurde.\",printErrors:\"Druckfehler\",printErrorsDesc:\"Benachrichtigt werden, wenn Ihr Druckauftrag fehlschlägt oder auf einen Fehler stößt.\",printJobStops:\"Druckauftrag gestoppt\",printJobStopsDesc:\"Benachrichtigt werden, wenn Ihr Druckauftrag abgebrochen oder gestoppt wird.\",saveSuccess:\"Benachrichtigungseinstellungen gespeichert.\",saveError:\"Benachrichtigungseinstellungen konnten nicht gespeichert werden.\"}},richTextEditor:{bold:\"Fett\",italic:\"Kursiv\",underline:\"Unterstrichen\",bulletList:\"Aufzählungsliste\",numberedList:\"Nummerierte Liste\",alignLeft:\"Linksbündig\",alignCenter:\"Zentriert\",alignRight:\"Rechtsbündig\",addLink:\"Link hinzufügen\",removeLink:\"Link entfernen\"},externalLinks:{noLinksConfigured:\"Keine externen Links konfiguriert\",deleteLink:\"Link löschen\",removeCustomIcon:\"Benutzerdefiniertes Symbol entfernen\",openInNewTab:\"In neuem Tab öffnen\",placeholders:{linkName:\"Mein Link\"}},keyboardShortcuts:{title:\"Tastaturkürzel\",navigation:\"Navigation\",archivesSection:\"Archive\",kProfilesSection:\"K-Profile\",generalSection:\"Allgemein\",shortcuts:{goToPrinters:\"Zu Drucker gehen\",goToArchives:\"Zu Archiv gehen\",goToQueue:\"Zur Warteschlange gehen\",goToStats:\"Zu Statistiken gehen\",goToProfiles:\"Zu Cloud-Profilen gehen\",goToSettings:\"Zu Einstellungen gehen\",focusSearch:\"Suche fokussieren\",openUploadModal:\"Upload-Modal öffnen\",clearSelection:\"Auswahl löschen / Eingabe aufheben\",contextMenu:\"Kontextmenü auf Karten\",refreshProfiles:\"Profile aktualisieren\",newProfile:\"Neues Profil\",exitSelectionMode:\"Auswahlmodus beenden\",showHelp:\"Diese Hilfe anzeigen\"},footer:\"Drücken Sie Esc oder klicken Sie außerhalb, um zu schließen\"},notificationLog:{title:\"Benachrichtigungsprotokoll\",events:{printStarted:\"Druck gestartet\",printComplete:\"Druck abgeschlossen\",printFailed:\"Druck fehlgeschlagen\",printStopped:\"Druck gestoppt\",progress:\"Fortschritt\",printerOffline:\"Drucker offline\",printerError:\"Druckerfehler\",lowFilament:\"Wenig Filament\",maintenanceDue:\"Wartung fällig\",test:\"Test\"},timeAgo:{justNow:\"Gerade eben\",minutesAgo:\"vor {{minutes}}m\",hoursAgo:\"vor {{hours}}h\"}},restoreBackup:{title:\"Backup wiederherstellen\",restoring:\"Wird wiederhergestellt...\",restoreComplete:\"Wiederherstellung abgeschlossen\",restoreFailed:\"Wiederherstellung fehlgeschlagen\",importSettings:\"Einstellungen aus Backup-Datei importieren\",pleaseWait:\"Bitte warten Sie, während Ihre Daten wiederhergestellt werden\",clickToSelect:\"Klicken Sie, um Backup-Datei auszuwählen (.json oder .zip)\",howDuplicateHandling:\"So funktioniert die Duplikatbehandlung:\",categories:{printers:\"Drucker\",smartPlugs:\"Smart Plugs\",notificationProviders:\"Benachrichtigungsanbieter\",filaments:\"Filamente\",archives:\"Archive\",pendingUploads:\"Ausstehende Uploads\",settingsTemplates:\"Einstellungen & Vorlagen\"},matchingInfo:{printers:\"abgeglichen nach Seriennummer\",smartPlugs:\"abgeglichen nach IP-Adresse\",notificationProviders:\"abgeglichen nach Name\",filaments:\"abgeglichen nach Name + Typ + Marke\",archives:\"abgeglichen nach Inhalts-Hash\",pendingUploads:\"abgeglichen nach Dateiname\",settingsTemplates:\"immer überschrieben\"},replaceExisting:\"Vorhandene Daten ersetzen\",keepExisting:\"Vorhandene Daten behalten\",replaceDescription:\"Bereits vorhandene Elemente mit Backup-Daten überschreiben\",keepDescription:\"Nur Elemente wiederherstellen, die noch nicht existieren\",caution:\"Vorsicht:\",cautionText:\"Das Überschreiben ersetzt Ihre aktuellen Konfigurationen durch Backup-Daten. Drucker-Zugangscodes werden aus Sicherheitsgründen niemals überschrieben.\",itemsRestored:\"Wiederhergestellte Elemente\",itemsSkipped:\"Übersprungene Elemente\",restored:\"Wiederhergestellt\",skipped:\"Übersprungen (existieren bereits)\",filesLabel:\"Dateien (3MF, Thumbnails, etc.)\",newApiKeysGenerated:\"Neue API-Schlüssel generiert\",newApiKeysWarning:\"Diese Schlüssel werden nur einmal angezeigt. Kopieren Sie sie jetzt!\",processingBackup:\"Backup-Datei wird verarbeitet...\",noDataFound:\"In der Backup-Datei wurden keine wiederherzustellenden Daten gefunden.\",failedToRestore:\"Backup konnte nicht wiederhergestellt werden. Bitte überprüfen Sie das Dateiformat.\"},backupExport:{title:\"Backup exportieren\",selectData:\"Zu exportierende Daten auswählen\",selectAll:\"Alle auswählen\",selectNone:\"Keine auswählen\",categoryDescriptions:{settings:\"Sprache, Theme, Update-Einstellungen\",notifications:\"ntfy, Pushover, Discord, usw.\",templates:\"Benutzerdefinierte Nachrichtenvorlagen\",smartPlugs:\"Tasmota-Plug-Konfigurationen\",externalLinks:\"Seitenleiste Links zu externen Diensten\",printers:\"Druckerinformationen (Zugangscodes ausgeschlossen)\",plateDetection:\"Leere Platten-Referenzbilder\",filaments:\"Filamenttypen und -kosten\",maintenance:\"Benutzerdefinierte Wartungspläne\",archives:\"Alle Druckdaten + Dateien (3MF, Thumbnails, Fotos)\",projects:\"Projekte, BOM-Elemente und Anhänge\",pendingUploads:\"Virtueller Drucker-Uploads zur Überprüfung\",apiKeys:\"Webhook-API-Schlüssel (neue Schlüssel bei Import generiert)\"},requiresPrinters:\"Drucker müssen ausgewählt sein\",zipFileWarning:\"ZIP-Datei wird erstellt.\",zipFileDescription:\"Enthält alle 3MF-Dateien, Thumbnails, Zeitraffer und Fotos. Dies kann eine Weile dauern und zu einer großen Datei führen.\",includeAccessCodes:\"Zugangscodes einschließen\",includeAccessCodesDescription:\"Für die Übertragung auf eine andere Maschine\",includeAccessCodesWarning:\"Zugangscodes werden im Klartext eingeschlossen. Bewahren Sie diese Backup-Datei sicher auf!\",categoriesSelected:\"{{selectedCount}} Kategorien ausgewählt\"},pendingUploads:{placeholders:{notes:\"Notizen zu diesem Druck hinzufügen...\"},discardUpload:\"Upload verwerfen\",archiveAllUploads:\"Alle Uploads archivieren\",discardAllUploads:\"Alle Uploads verwerfen\",archive:\"Archivieren\",timeAgo:{justNow:\"Gerade eben\",minutesAgo:\"vor {{minutes}}m\",hoursAgo:\"vor {{hours}}h\",daysAgo:\"vor {{days}}d\"}},apiBrowser:{placeholders:{requestBody:\"JSON-Anforderungstext...\",searchEndpoints:\"Endpunkte suchen...\"}},configureAmsSlot:{title:\"AMS-Slot konfigurieren\",slotConfigured:\"Slot konfiguriert!\",configuringSlot:\"Slot wird konfiguriert:\",slotLabel:\"{{ams}} Slot {{slot}}\",searchPresets:\"Voreinstellungen suchen...\",colorPlaceholder:\"Farbname oder Hex (z.B. braun, FF8800)\",clearCustomColor:\"Benutzerdefinierte Farbe löschen\",noCloudPresets:\"Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.\",noPresetsAvailable:\"Keine Voreinstellungen verfügbar. Melden Sie sich bei Bambu Cloud an oder importieren Sie lokale Profile.\",noMatchingPresets:\"Keine passenden Voreinstellungen gefunden.\",custom:\"Benutzerdefiniert\",builtin:\"Integriert\",settingsSentToPrinter:\"Einstellungen an Drucker gesendet\",filamentProfile:\"Filamentprofil\",kProfileLabel:\"K-Profil (Pressure Advance)\",filteringFor:\"Filtern nach: {{material}}\",noKProfile:\"Kein K-Profil (Standard 0.020 verwenden)\",noMatchingKProfiles:\"Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.\",selectFilamentFirst:\"Zuerst ein Filamentprofil auswählen\",kFromCalibration:\"K={{value}} aus Druckerkalibrierung\",customColorLabel:\"Benutzerdefinierte Farbe (optional)\",presetColors:\"{{name}} Farben:\",showLessColors:\"Weniger Farben anzeigen\",showMoreColors:\"Mehr Farben anzeigen\",clear:\"Löschen\",hexLabel:\"Hex: #{{hex}}\",resetting:\"Wird zurückgesetzt...\",resetSlot:\"Slot zurücksetzen\",cancel:\"Abbrechen\",configuring:\"Wird konfiguriert...\",configureSlot:\"Slot konfigurieren\"},githubBackup:{title:\"GitHub-Backup\",history:\"Verlauf\",downloadBackup:\"Backup herunterladen\",restoreBackup:\"Backup wiederherstellen\",noBackupsYet:\"Noch keine Backups\"},emailSettings:{placeholders:{fromName:\"BamBuddy\"}},tagManagement:{searchTags:\"Tags suchen...\",renameTag:\"Tag umbenennen\",deleteTag:\"Tag löschen\"},notificationTemplates:{placeholders:{title:\"Benachrichtigungstitel...\",body:\"Benachrichtigungstext...\"}},batchTag:{placeholders:{newTag:\"Neuen Tag eingeben...\"}},photoGallery:{deletePhoto:\"Foto löschen\"},filamentHoverCard:{copySpoolUuid:\"Spulen-UUID kopieren\"},kProfilesView:{hasNote:\"Hat Notiz\",copyProfile:\"Profil kopieren\"},layout:{openMenu:\"Menü öffnen\",noPermissionSystemInfo:\"Sie haben keine Berechtigung zum Anzeigen von Systeminformationen\"},dashboard:{dragToReorder:\"Ziehen zum Neuordnen\",hideWidget:\"Widget ausblenden\"},notificationProviderCard:{deleteNotificationProvider:\"Benachrichtigungsanbieter löschen\"},fileManagerModal:{closeFileManager:\"Dateimanager schließen\",sortFiles:\"Dateien sortieren\",goToParentFolder:\"Zum übergeordneten Ordner gehen\",threeView:\"3D-Ansicht\"},embeddedCameraViewer:{refreshStream:\"Stream aktualisieren\",close:\"Schließen\",zoomOut:\"Verkleinern\",resetZoom:\"Zoom zurücksetzen\",zoomIn:\"Vergrößern\",dragToResize:\"Ziehen zum Größe ändern\"},timelapseViewer:{skipBack5s:\"5s zurückspringen\",skipForward5s:\"5s vorspringen\"},notificationProviders:{descriptions:{email:\"SMTP-E-Mail-Benachrichtigungen\",telegram:\"Benachrichtigungen über Telegram-Bot\",discord:\"An Discord-Kanal über Webhook senden\",ntfy:\"Kostenlose, selbst hostbare Push-Benachrichtigungen\",pushover:\"Einfache, zuverlässige Push-Benachrichtigungen\",callmebot:\"Kostenlose WhatsApp-Benachrichtigungen über CallMeBot\",webhook:\"Generischer HTTP POST zu beliebiger URL\"}},logViewer:{searchPlaceholder:\"Nachricht oder Logger-Name suchen...\",noLogEntries:\"Keine Logeinträge gefunden\"},switchbarPopover:{noSwitchesInSwitchbar:\"Keine Schalter in Schalterleiste\"},projectPageModal:{placeholders:{title:\"Titel\",designer:\"Designer\",license:\"Lizenz\",description:\"Beschreibung eingeben...\",profileTitle:\"Profil-Titel\",profileDescription:\"Profilbeschreibung...\"}},spoolmanSettings:{},time:{unknown:\"-\",waiting:\"Wartend\",justNow:\"Gerade eben\",now:\"Jetzt\",minsAgo:\"vor {{count}}m\",inMins:\"in {{count}}m\",hoursAgo:\"vor {{count}}h\",inHours:\"in {{count}}h\",daysAgo:\"vor {{count}}d\",inDays:\"in {{count}}d\"},spoolbuddy:{nav:{dashboard:\"Dashboard\",ams:\"AMS\",inventory:\"Inventar\",writeTag:\"Schreiben\",settings:\"Einstellungen\"},status:{nfcReady:\"NFC bereit\",nfcOff:\"NFC aus\",offline:\"Offline\",online:\"Online\",noPrinters:\"Keine Drucker\",deviceOffline:\"Gerät offline\",waitingConnection:\"Warte auf Geräteverbindung...\",systemReady:\"System bereit\",status:\"Status\"},dashboard:{readyToScan:\"Bereit zum Scannen\",idleMessage:\"Spule auf die Waage legen zum Identifizieren\",nfcHint:\"NFC-Tag wird automatisch gelesen\",device:\"Gerät\",syncWeight:\"Gewicht sync.\",weightSynced:\"Synchronisiert!\",unknownTag:\"Unbekannter Tag\",newTag:\"Neuer Tag erkannt\",onScale:\"auf der Waage\",linkSpool:\"Mit Spule verknüpfen\",linkTagTitle:\"Tag mit Spule verknüpfen\",linkTag:\"Tag verknüpfen\",selectSpool:\"Spule zum Verknüpfen auswählen:\",noUntagged:\"Keine Spulen ohne Tags gefunden\",tagDetected:\"Tag erkannt\",noTag:\"Kein Tag\",tagId:\"Tag\",grossWeight:\"Bruttogewicht\",spoolSize:\"Spulengröße\",close:\"Schließen\",currentSpool:\"Aktuelle Spule\"},modal:{spoolDetected:\"Spule erkannt\",assignToAms:\"AMS zuweisen\",syncWeight:\"Gewicht sync.\",weightSynced:\"Synchronisiert!\",syncing:\"Synchronisiere...\",newTagDetected:\"Neuer Tag erkannt\",addToInventory:\"Zum Inventar hinzufügen\",assignToAmsTitle:\"AMS zuweisen\",selectSlot:\"Slot auswählen\",assign:\"Zuweisen\",assigning:\"Zuweisen...\",assignSuccess:\"Zugewiesen!\",assignError:\"Fehler beim Zuweisen. Bitte erneut versuchen.\",noPrinterSelected:\"Drucker auswählen...\",noAmsDetected:\"Kein AMS an diesem Drucker erkannt\",slot:\"Slot\"},weight:{noReading:\"Kein Messwert\",stable:\"Stabil\",measuring:\"Messen...\",tare:\"Tarieren\",calibrate:\"Kalibrieren\"},spool:{remaining:\"Verbleibend\",material:\"Material\",brand:\"Marke\",color:\"Farbe\",coreWeight:\"Kern\",labelWeight:\"Etikett\",scaleWeight:\"Waage\",netWeight:\"Netto\",lastUsed:\"Zuletzt verwendet\"},ams:{noData:\"Kein AMS erkannt\",connectAms:\"AMS anschließen um Filament-Slots zu sehen\",noPrinter:\"Kein Drucker ausgewählt\",selectPrinter:\"Drucker in der oberen Leiste auswählen\",printerDisconnected:\"Drucker getrennt\",humidity:\"Feuchtigkeit\",level:\"Stufe\",active:\"Aktiv\",slot:\"Slot\",empty:\"Leer\"},inventory:{search:\"Spulen suchen...\",empty:\"Keine Spulen im Inventar\",noResults:\"Keine passenden Spulen\",spools:\"Spulen\",addSpool:\"Spule hinzufügen\"},settings:{tabDevice:\"Gerät\",tabDisplay:\"Anzeige\",tabScale:\"Waage\",tabUpdates:\"Updates\",nfcReader:\"NFC-Leser\",type:\"Typ\",connection:\"Verbindung\",notConnected:\"N/A\",deviceInfo:\"Geräteinfo\",hostname:\"Host\",uptime:\"Betriebszeit\",systemConfig:\"Backend & Auth\",backendUrl:\"Bambuddy Backend URL\",apiToken:\"API-Token\",apiTokenPlaceholder:\"API-Token eingeben\",saveConfig:\"Konfiguration speichern\",systemQueued:\"Konfiguration in Warteschlange.\",nfcDiagnostic:\"NFC-Diagnose\",scaleDiagnostic:\"Waagen-Diagnose\",readTagDiagnostic:\"Tag-Lese-Diagnose\",testNfc:\"Leser testen\",testScale:\"Genauigkeit testen\",testReadTag:\"Tag lesen\",systemFieldsRequired:\"Backend-URL ist erforderlich.\",brightness:\"Helligkeit\",saved:\"Gespeichert\",noBacklight:\"Keine DSI-Hintergrundbeleuchtung erkannt. Helligkeitssteuerung erfordert ein DSI-Display.\",screenBlank:\"Bildschirm-Abschaltzeit\",screenBlankDesc:\"Bildschirm schaltet sich nach Inaktivität ab. Zum Aufwecken berühren.\",displayNote:\"Helligkeit wird als Software-Filter angewendet.\",scaleCalibration:\"Waagen-Kalibrierung\",currentWeight:\"Aktuelles Gewicht\",tareOffset:\"Tara\",calFactor:\"Faktor\",knownWeight:\"Bekanntes Gewicht\",calStep1:\"Alle Gegenstände von der Waage entfernen und Nullpunkt setzen.\",calStep2:\"Bekanntes Gewicht auf die Waage legen.\",setZero:\"Nullpunkt setzen\",calibrateNow:\"Kalibrieren\",calibrated:\"Kalibriert\",tareSet:\"Tara-Befehl gesendet. Warte auf Gerät...\",tareFailed:\"Tara-Befehl fehlgeschlagen\",zeroSet:\"Nullpunkt gesetzt. Bekanntes Gewicht auf die Waage legen.\",calibrationDone:\"Kalibrierung abgeschlossen!\",calibrationFailed:\"Kalibrierung fehlgeschlagen\",lastCalibrated:\"Zuletzt kalibriert\",stable:\"Stabil\",settling:\"Stabilisierung...\",firmware:\"Firmware\",scale:\"Waage\",noDevice:\"Kein SpoolBuddy-Gerät gefunden\",daemonVersion:\"Daemon-Version\",currentVersion:\"Aktuell\",versionPending:\"Warte auf Daemon...\",checking:\"Prüfe...\",checkUpdates:\"Nach Updates suchen\",updateAvailable:\"Update verfügbar\",updateInstructions:\"Update per SSH: SpoolBuddy-Installationsskript ausführen.\",upToDate:\"Aktuell\",includeBeta:\"Beta-Versionen einschließen\"},writeTag:{tabExisting:\"Vorhandene Spule\",tabNew:\"Neue Spule\",tabReplace:\"Tag ersetzen\",searchPlaceholder:\"Suche nach Material, Farbe, Marke...\",noUntaggedSpools:\"Keine Spulen ohne Tags\",noTaggedSpools:\"Keine Spulen mit Tags\",selectSpool:\"Spule auswählen, dann einen NTAG auf den Leser legen\",placeTag:\"NTAG auf den Leser legen\",tagReady:\"Tag erkannt — bereit zum Schreiben\",writeTag:\"Tag beschreiben\",replaceTag:\"Tag ersetzen\",writing:\"Tag wird beschrieben...\",waiting:\"Warte auf SpoolBuddy...\",writeSuccess:\"Tag erfolgreich beschrieben!\",writeFailed:\"Schreiben fehlgeschlagen\",queueFailed:\"Schreibbefehl konnte nicht eingereiht werden\",tryAgain:\"Erneut versuchen\",cancel:\"Abbrechen\",replaceWarning:\"Alter Tag wird getrennt. Neuer Tag ersetzt ihn.\",deviceOffline:\"SpoolBuddy ist offline\",material:\"Material\",colorName:\"Farbname\",color:\"Farbe\",brand:\"Marke\",weight:\"Gewicht (g)\",createSpool:\"Spule erstellen\",creating:\"Wird erstellt...\",spoolCreated:\"Spule erstellt! Bereit zum Schreiben.\",createFailed:\"Spule konnte nicht erstellt werden\"},quickMenu:{printerPower:\"Drucker-Strom\",systemControls:\"System\",restartDaemon:\"Daemon neustarten\",restartBrowser:\"Browser neustarten\",reboot:\"Neustart\",shutdown:\"Herunterfahren\",swipeToClose:\"Nach unten wischen zum Schließen\",confirmTitle:\"Bestätigen\",confirmShutdown:\"Möchten Sie das SpoolBuddy wirklich herunterfahren? Sie benötigen physischen Zugang, um es wieder einzuschalten.\",confirmReboot:\"Möchten Sie das SpoolBuddy wirklich neu starten?\",confirmRestartDaemon:\"SpoolBuddy-Daemon neustarten? NFC und Waage sind vorübergehend nicht verfügbar.\",confirmRestartBrowser:\"Kiosk-Browser neustarten? Das Display wird kurz schwarz.\",confirm:\"Bestätigen\",confirmPlugOn:\"{{name}} einschalten?\",confirmPlugOff:\"{{name}} ausschalten?\",turnOn:\"Einschalten\",turnOff:\"Ausschalten\"}},bugReport:{title:\"Fehler melden\",description:\"Beschreibung\",descriptionPlaceholder:\"Was ist schiefgelaufen? Bitte beschreiben Sie das Problem...\",email:\"E-Mail (optional)\",emailPlaceholder:\"ihre@email.de\",emailPrivacy:\"Falls angegeben, wird Ihre E-Mail in einem eingeklappten Abschnitt des GitHub-Issues aufgeführt, damit der Betreuer sich melden kann.\",screenshot:\"Screenshot\",uploadOrPaste:\"Bild hochladen, einfügen oder ziehen\",dataCollectedSummary:\"Welche Daten werden im Bericht gesendet?\",dataIncluded:\"Enthalten:\",dataIncludedList:\"App-Version, Betriebssystem, Architektur, Python-Version, Datenbankstatistiken (nur Anzahl), Druckermodelle, Düsenanzahl, Firmware-Versionen, Verbindungsstatus, Integrationsstatus (Spoolman, MQTT, HA), nicht-sensible Einstellungen, Netzwerkschnittstellenanzahl, Docker-Details, Abhängigkeitsversionen.\",dataNeverIncluded:\"Nie enthalten:\",dataNeverIncludedList:\"Druckernamen, Seriennummern, Zugangscodes, Passwörter, IP-Adressen, E-Mail-Adressen, API-Schlüssel, Tokens, Webhook-URLs, Hostnamen oder Benutzernamen.\",submit:\"Absenden\",startLogging:\"Debug-Protokollierung starten\",stepEnableLogging:\"Debug-Protokollierung aktiviert\",stepReproduce:\"Problem jetzt reproduzieren\",stepStopLogging:\"Stoppen & Bericht senden\",stopAndSubmit:\"Stoppen & Senden\",maxDuration:\"Stoppt automatisch nach {{minutes}} Min.\",stoppingLogs:\"Protokolle sammeln & senden...\",submitting:\"Fehlerbericht wird gesendet...\",submitSuccess:\"Fehlerbericht erfolgreich gesendet!\",submitFailed:\"Fehlerbericht konnte nicht gesendet werden\",thankYou:\"Vielen Dank!\",submitted:\"Ihr Fehlerbericht wurde eingereicht.\",viewIssue:\"Issue ansehen\",unexpectedError:\"Ein unerwarteter Fehler ist aufgetreten\"},failureDetection:{title:\"KI-Fehlererkennung\",description:\"Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.\",mlUrl:\"Obico-ML-API-URL\",mlUrlHint:\"Basis-URL deines selbst gehosteten Obico-ml_api-Containers (z. B. http://192.168.1.10:3333).\",test:\"Testen\",testSuccess:\"ML-API erreichbar und funktionsfähig.\",testFailed:\"ML-API konnte nicht erreicht werden.\",sensitivity:\"Empfindlichkeit\",sensitivityLow:\"Niedrig (weniger Fehlalarme)\",sensitivityMedium:\"Mittel (ausgewogen)\",sensitivityHigh:\"Hoch (frühe Erkennung, mehr Fehlalarme)\",sensitivityHint:\"Passt die Konfidenz-Schwellwerte an, die Warnungen und Fehler auslösen.\",action:\"Aktion bei erkanntem Fehler\",actionNotify:\"Nur benachrichtigen\",actionPause:\"Druck pausieren\",actionPauseOff:\"Pausieren und Strom abschalten\",pollInterval:\"Prüfintervall (Sekunden)\",pollIntervalHint:\"Wie oft jeder Drucker während eines laufenden Drucks geprüft wird. Minimum 5 s, Maximum 120 s.\",externalUrlMissing:\"Externe URL ist nicht gesetzt.\",externalUrlHint:\"Die ML-API ruft das Kamera-Snapshot per URL ab. Setze die externe URL in den allgemeinen Einstellungen, damit der ML-API-Container Bambuddy erreichen kann.\",perPrinterTitle:\"Überwachte Drucker\",perPrinterHint:\"Wähle, welche Drucker vom Erkennungsdienst überwacht werden.\",monitorAll:\"Alle verbundenen Drucker überwachen\",statusTitle:\"Status\",serviceRunning:\"Dienst läuft\",thresholds:\"Niedrig / Hoch-Schwellwerte\",activePrinters:\"Aktive Drucke\",noActivePrints:\"Derzeit laufen keine Drucke.\",historyTitle:\"Letzte Erkennungen\",noHistory:\"Noch keine Erkennungen.\"}},Gue={nav:{printers:\"Imprimantes\",archives:\"Archives\",queue:\"File d'attente\",stats:\"Statistiques\",profiles:\"Profils\",maintenance:\"Maintenance\",projects:\"Projets\",inventory:\"Filament\",files:\"Gestionnaire de fichiers\",notifications:\"Notifications\",settings:\"Paramètres\",system:\"Système\",collapseSidebar:\"Réduire la barre latérale\",expandSidebar:\"Développer la barre latérale\",update:\"Mise à jour\",updateAvailable:\"Mise à jour disponible : v{{version}}\",updateAvailableBanner:\"La version {{version}} est disponible !\",viewUpdate:\"Voir la mise à jour\",viewOnGithub:\"Voir sur GitHub\",keyboardShortcuts:\"Raccourcis clavier (?)\",switchToLight:\"Passer au mode clair\",switchToDark:\"Passer au mode sombre\",smartSwitches:\"Interrupteurs intelligents\",logout:\"Déconnexion\"},common:{save:\"Enregistrer\",saving:\"Enregistrement...\",cancel:\"Annuler\",delete:\"Supprimer\",edit:\"Modifier\",add:\"Ajouter\",close:\"Fermer\",confirm:\"Confirmer\",loading:\"Chargement...\",error:\"Erreur\",success:\"Succès\",warning:\"Avertissement\",enabled:\"Activé\",disabled:\"Désactivé\",yes:\"Oui\",no:\"Non\",on:\"On\",off:\"Off\",all:\"Tous\",none:\"Aucun\",search:\"Rechercher\",filter:\"Filtrer\",sort:\"Trier\",refresh:\"Actualiser\",download:\"Télécharger\",upload:\"Téléverser\",uploading:\"Téléversement...\",uploadFailed:\"Échec du téléversement\",actions:\"Actions\",status:\"Statut\",name:\"Nom\",description:\"Description\",date:\"Date\",time:\"Heure\",hours:\"heures\",minutes:\"minutes\",seconds:\"secondes\",days:\"jours\",enable:\"Activer\",disable:\"Désactiver\",permissions:\"Autorisations\",noPrinters:\"Aucune imprimante configurée\",noData:\"Aucune donnée disponible\",linkNotFound:\"Lien non trouvé\",required:\"Requis\",optional:\"Optionnel\",dismiss:\"Ignorer\",apply:\"Appliquer\",reset:\"Réinitialiser\",export:\"Exporter\",import:\"Importer\",clear:\"Effacer\",selectAll:\"Tout sélectionner\",deselectAll:\"Tout désélectionner\",noChange:\"— Aucun changement —\",unchanged:\"Inchangé\",unassigned:\"Non assigné\",unknown:\"Inconnu\",unknownError:\"Erreur inconnue\",today:\"Aujourd'hui\",tomorrow:\"Demain\",asap:\"Dès que possible\",overdue:\"En retard\",now:\"Maintenant\",collapse:\"Réduire\",expand:\"Développer\",viewArchive:\"Voir l'archive\",viewInFileManager:\"Voir dans le gestionnaire de fichiers\",addedBy:\"Ajouté par {{username}}\",prints:\"impressions\",more:\"+{{count}} de plus\",ascending:\"Croissant\",descending:\"Décroissant\",back:\"Retour\",copy:\"Copier\",copied:\"Copié !\",printer:\"Imprimante\",remove:\"Retirer\",type:\"Type\",print:\"Imprimer\",rename:\"Renommer\",move:\"Déplacer\",create:\"Créer\",duplicate:\"Dupliquer\",left:\"Gauche\",right:\"Droite\"},printers:{title:\"Imprimantes\",addPrinter:\"Ajouter une imprimante\",editPrinter:\"Modifier l'imprimante\",deletePrinter:\"Supprimer l'imprimante\",printerName:\"Nom de l'imprimante\",serialNumber:\"Numéro de série\",ipAddress:\"Adresse IP / Nom d'hôte\",accessCode:\"Code d'accès\",model:\"Modèle\",nozzleCount:\"Nombre de buses\",autoArchive:\"Auto-archivage\",status:{available:\"Disponible\",idle:\"Inactif\",printing:\"Impression en cours\",paused:\"En pause\",offline:\"Hors ligne\",problem:\"Problème\",error:\"Erreur\",finished:\"Terminé\",unknown:\"Inconnu\"},temperatures:{nozzle:\"Buse\",bed:\"Plateau\",chamber:\"Chambre\"},progress:\"{{percent}}% terminé\",timeRemaining:\"{{time}} restant\",deleteConfirm:'Êtes-vous sûr de vouloir supprimer \"{{name}}\" ?',maintenanceOk:\"Maintenance OK\",maintenanceWarning:\"{{count}} avertissement\",maintenanceWarning_plural:\"{{count}} avertissements\",maintenanceDue:\"{{count}} échéance\",maintenanceDue_plural:\"{{count}} échéances\",sort:{name:\"Nom\",status:\"Statut\",model:\"Modèle\",location:\"Emplacement\",ascending:\"Tri croissant\",descending:\"Tri décroissant\"},cardSize:{small:\"Petites cartes\",medium:\"Cartes moyennes\",large:\"Grandes cartes\",extraLarge:\"Très grandes cartes\"},hideOffline:\"Masquer hors ligne\",nextAvailable:\"Prochaine disponible\",powerOn:\"Allumer\",offlinePrintersWithPlugs:\"Imprimantes hors ligne avec prises connectées\",noPrintersConfigured:\"Aucune imprimante configurée pour le moment\",search:\"Rechercher des imprimantes...\",noSearchResults:\"Aucune imprimante ne correspond à votre recherche ou à vos filtres\",filter:{allStatuses:\"Tous les statuts\",allLocations:\"Tous les emplacements\"},readyToPrint:\"Prête à imprimer\",external:\"Externe\",extL:\"Ext-L\",extR:\"Ext-R\",deleteArchives:\"Supprimer les archives d'impression\",noLabel:\"Pas d'étiquette\",printPreview:\"Aperçu avant impression\",width:\"Largeur\",height:\"Hauteur\",noObjectsFound:\"Aucun objet trouvé\",objectsLoadedOnPrintStart:\"Les objets sont chargés au début de l'impression\",willBeSkipped:\"Sera sauté\",name:\"Nom\",serialCannotBeChanged:\"Le numéro de série ne peut pas être modifié\",locationHelp:\"Utilisé pour grouper les imprimantes et filtrer la file d'attente\",wifiSignal:{veryWeak:\"Très faible\",weak:\"Faible\",fair:\"Moyen\",good:\"Bon\",excellent:\"Excellent\"},maintenanceUpToDate:\"Maintenance à jour - Cliquez pour voir\",chamberLightOn:\"Allumer la lumière de la chambre\",chamberLightOff:\"Éteindre la lumière de la chambre\",files:\"Fichiers\",browseFiles:\"Parcourir les fichiers de l'imprimante\",autoOffAfterPrint:\"Extinction auto après impression\",autoOffExecuted:\"Extinction auto exécutée - rallumez pour réinitialiser\",hmsErrors:\"Erreurs HMS\",viewHmsErrors:\"Voir {{count}} erreur(s) HMS\",resume:\"Reprendre\",pause:\"Pause\",stop:\"Arrêter\",camera:\"Caméra\",skipObject:\"Sauter l'objet\",reconnect:\"Reconnecter\",forceRefresh:\"Forcer l'actualisation\",forceRefreshSuccess:\"Actualisation demandée\",mqttDebug:\"Débogage MQTT\",printerInformation:\"Informations imprimante\",copyToClipboard:\"Copier\",copied:\"Copié !\",state:\"État\",wifiSignalLabel:\"Signal WiFi\",developerMode:\"Mode développeur\",enabled:\"Activé\",disabled:\"Désactivé\",addedOn:\"Ajoutée le\",sdCard:\"Carte SD\",inserted:\"Insérée\",notInserted:\"Non insérée\",totalPrintHours:\"Heures d'impression\",activeNozzle:\"Active : buse {{nozzle}}\",nozzleRack:\"Rack à buses\",nozzleDocked:\"Rangée\",nozzleMounted:\"Montée\",nozzleActive:\"Active\",nozzleIdle:\"Inactive\",nozzleDiameter:\"Diamètre\",nozzleType:\"Type\",nozzleStatus:\"Statut\",nozzleFilament:\"Filament\",nozzleWear:\"Usure\",nozzleMaxTemp:\"Temp Max\",nozzleSerial:\"Série\",nozzleHardenedSteel:\"Acier Trempé\",nozzleStainlessSteel:\"Acier Inoxydable\",nozzleTungstenCarbide:\"Carbure de Tungstène\",nozzleFlow:\"Débit\",nozzleHighFlow:\"Haut débit\",nozzleStandardFlow:\"Standard\",firmwareUpdate:\"Mise à jour Firmware\",firmwareInstructions:\"Sur l'écran de l'imprimante, allez dans\",firmwareNav:\"Naviguez vers\",settings:\"Paramètres\",firmware:\"Firmware\",discoverPrinters:\"Découvrir les imprimantes\",searching:\"Recherche...\",manualEntry:\"Saisie manuelle\",addFromCloud:\"Ajouter depuis le Cloud\",toast:{printerDeleted:\"Imprimante supprimée\",missingSpoolAssignment:\"Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}\",printerAdded:\"Imprimante ajoutée\",printerUpdated:\"Imprimante mise à jour\",failedToDelete:\"Échec de la suppression\",failedToAdd:\"Échec de l'ajout\",failedToUpdate:\"Échec de la mise à jour\",commandSent:\"Commande envoyée\",failedToSendCommand:\"Échec de l'envoi de la commande\",turnedOn:\"{{name}} allumée\",failedToPowerOn:\"Échec de l'allumage de {{name}}\",scriptTriggered:\"Script déclenché\",printStopped:\"Impression arrêtée\",printPaused:\"Impression en pause\",printResumed:\"Impression reprise\",referenceDeleted:\"Référence supprimée\",detectionAreaSaved:\"Zone de détection enregistrée\",failedToRunScript:\"Échec du script\",failedToStopPrint:\"Échec de l'arrêt\",failedToPausePrint:\"Échec de la mise en pause\",failedToResumePrint:\"Échec de la reprise\",failedToControlChamberLight:\"Échec du contrôle de la lumière\",failedToSetSpeed:\"Échec du réglage de la vitesse\",failedToUpdateSetting:\"Échec de mise à jour du paramètre\",failedToSkipObjects:\"Échec du saut d'objets\",failedToRereadRfid:\"Échec lecture RFID\",failedToCheckPlate:\"Échec vérification plateau\",failedToUpdateLabel:\"Échec mise à jour étiquette\",failedToDeleteReference:\"Échec suppression référence\",failedToSaveDetectionArea:\"Échec enregistrement zone\",plateCheckEnabled:\"Vérification plateau activée\",plateCheckDisabled:\"Vérification plateau désactivée\",calibrationSaved:\"Calibration enregistrée !\",calibrationFailed:\"Échec de la calibration\",rfidRereadInitiated:\"Lecture RFID initiée\"},connection:{connected:\"Connecté\",offline:\"Hors ligne\"},plateStatus:{markCleared:\"Marquer le plateau comme dégagé\",cleared:\"Plateau dégagé\",notCleared:\"Plateau non dégagé\",inUse:\"Plateau en cours d'utilisation\"},queue:{inQueue:\"{{count}} impression en file\",inQueue_plural:\"{{count}} impressions en file\"},controls:\"Contrôles\",rfid:{reread:\"Relire RFID\"},bedJog:{title:\"Déplacer le plateau\",bed:\"Plateau\",step:\"Pas (mm)\",up:\"Monter le plateau\",down:\"Descendre le plateau\",disabledWhilePrinting:\"Désactivé pendant l'impression\",notHomedTitle:\"Imprimante non référencée\",notHomedMessage:\"L'imprimante n'a pas été référencée depuis la dernière impression. Lancez la référence automatique d'abord pour un positionnement sûr (parque la tête d'outil, puis référence X, Y et Z), ou déplacez quand même — les butées logicielles seront ignorées.\",homeZ:\"Référence automatique\",moveAnyway:\"Déplacer quand même\",homingStarted:\"Référencement automatique en cours…\"},permission:{noAdd:\"Pas d'autorisation pour ajouter\",noEdit:\"Pas d'autorisation pour modifier\",noDelete:\"Pas d'autorisation pour supprimer\",noControl:\"Pas d'autorisation pour contrôler\",noFiles:\"Pas d'autorisation pour les fichiers\",noAmsRfid:\"Pas d'autorisation pour le RFID\",noSmartPlugControl:\"Pas d'autorisation pour les prises\",noCamera:\"Pas d'autorisation pour les caméras\"},modal:{addTitle:\"Ajouter une imprimante\",editTitle:\"Modifier l'imprimante\",myPrinter:\"Mon imprimante\",selectModel:\"Choisir un modèle...\",locationGroup:\"Emplacement / Groupe (optionnel)\",locationPlaceholder:\"ex: Atelier, Bureau\",autoArchiveLabel:\"Auto-archiver les impressions terminées\",fromPrinterSettings:\"Depuis les paramètres imprimante\",modelOptional:\"Modèle (optionnel)\",saveChanges:\"Enregistrer les modifications\"},skipObjects:{tooltip:\"Sauter des objets\",onlyWhilePrinting:\"Sauter (uniquement pendant l'impression)\",requiresMultiple:\"Sauter (nécessite 2+ objets)\",title:\"Sauter des objets\",matchIdsInfo:\"Faites correspondre les IDs avec l'écran de l'imprimante\",printerShowsIds:\"L'écran affiche les IDs des objets sur le plateau\",skipSelected:\"Sauter la sélection\",skipping:\"Saut en cours...\",noObjectsSelected:\"Aucun objet sélectionné\",selectObjectsToSkip:\"Sélectionnez les objets à ignorer\",skipped:\"sauté\",objectsSkipped:\"Objets sautés\",activeCount:\"{{count}} actifs\",waitForLayer:\"Attendez la couche 2 pour sauter des objets (actuelle : {{layer}})\",skip:\"Sauter\",confirmTitle:\"Sauter l'objet ?\",confirmMessage:'Voulez-vous vraiment sauter \"{{name}}\" ? Cette action est irréversible.'},confirm:{deleteTitle:\"Supprimer l'imprimante\",deleteMessage:'Supprimer \"{{name}}\" ? Cela retirera tous les paramètres de connexion.',deleteArchivesNote:\"Tout l'historique sera définitivement supprimé.\",keepArchivesNote:\"L'historique sera conservé mais plus associé à cette imprimante.\",stopTitle:\"Arrêter l'impression\",stopMessage:`Arrêter l'impression sur \"{{name}}\" ?`,stopButton:\"Arrêter\",pauseTitle:\"Mettre en pause\",pauseMessage:`Mettre en pause l'impression sur \"{{name}}\" ?`,pauseButton:\"Pause\",resumeTitle:\"Reprendre l'impression\",resumeMessage:`Reprendre l'impression sur \"{{name}}\" ?`,resumeButton:\"Reprendre\",powerOnTitle:\"Allumer l'imprimante\",powerOnMessage:'Allumer \"{{name}}\" ?',powerOnButton:\"Allumer\",powerOffTitle:\"Éteindre l'imprimante\",powerOffMessage:'Éteindre \"{{name}}\" ?',powerOffWarning:`ATTENTION : \"{{name}}\" imprime ! L'éteindre maintenant peut endommager l'imprimante.`,powerOffButton:\"Éteindre\"},bulk:{select:\"Sélectionner\",selectAll:\"Tout sélectionner\",selectByLocation:\"Sélectionner par emplacement\",selected:\"{{count}} sélectionné(s)\",actions:{stop:\"Arrêter\",pause:\"Pause\",resume:\"Reprendre\",clearPlate:\"Vider le plateau\",clearHMS:\"Effacer les notifications\"},confirm:{stopTitle:\"Arrêter {{count}} impressions\",stopMessage:\"Cela annulera les impressions actives sur {{count}} imprimante(s). Cette action est irréversible.\",stopButton:\"Tout arrêter\",pauseTitle:\"Mettre en pause {{count}} impressions\",pauseMessage:\"Cela mettra en pause les impressions actives sur {{count}} imprimante(s).\",pauseButton:\"Tout mettre en pause\",clearPlateTitle:\"Vider {{count}} plateaux\",clearPlateMessage:\"Cela videra le plateau sur {{count}} imprimante(s) et pourrait lancer les travaux en file d'attente.\",clearPlateButton:\"Tout vider\"},success:\"{{action}} terminé sur {{count}} imprimante(s)\",partial:\"{{succeeded}} réussi(s), {{failed}} échoué(s)\",noneApplicable:\"Aucune imprimante sélectionnée n'est dans le bon état pour cette action\",selectByState:\"Sélectionner par état\"},discovery:{title:\"Découvrir les imprimantes\",searching:\"Recherche...\",scanning:\"Scan en cours...\",scanProgress:\"Scan... {{scanned}}/{{total}}\",foundPrinters:\"{{count}} imprimante(s) trouvée(s)\",noPrintersFound:\"Aucune imprimante trouvée\",noPrintersFoundSubnet:\"Aucune imprimante dans ce sous-réseau.\",noPrintersFoundNetwork:\"Aucune imprimante sur le réseau.\",allConfigured:\"Toutes les imprimantes trouvées sont déjà configurées.\",alreadyAdded:\"Déjà ajoutée\",select:\"Sélectionner\",manualEntry:\"Saisie manuelle\",addFromCloud:\"Ajouter depuis le Cloud\",subnetToScan:\"Sous-réseau à scanner\",dockerNote:\"Docker détecté. Entrez le sous-réseau en notation CIDR. Nécessite network_mode: host.\",scanSubnet:\"Scanner le sous-réseau\",discoverNetwork:\"Découvrir sur le réseau\",scanningSubnet:\"Scan du sous-réseau pour imprimantes Bambu...\",scanningNetwork:\"Scan du réseau...\",serialRequired:\"Série requis\",unknown:\"Inconnu\",failedToStart:\"Échec du démarrage de la découverte\"},drying:{start:\"Démarrer le séchage\",stop:\"Arrêter le séchage\",temperature:\"Température\",duration:\"Durée\",hours:\"heures\",timeRemaining:\"{{time}} restant\",active:\"Séchage\",notSupported:\"Séchage non pris en charge\",powerRequired:\"Brancher l'adaptateur secteur AMS pour activer le séchage\",startingDrying:\"Démarrage du séchage...\",stoppingDrying:\"Arrêt du séchage...\",rotateTray:\"Tourner la bobine pendant le séchage\"},filaments:\"Filaments\",openCameraOverlay:\"Ouvrir la caméra en superposition\",openCameraWindow:\"Ouvrir la caméra dans une fenêtre\",firmwareUpdateAvailable:\"Mise à jour firmware : {{current}} → {{latest}}\",firmwareUpToDate:\"Firmware {{version}} — À jour\",firmwareUpdateButton:\"Mettre à jour\",plateDetection:{noPermission:\"Pas d'autorisation de modification\",enabledClick:\"Vérification activée - Cliquez pour désactiver\",disabledClick:\"Vérification désactivée - Cliquez pour activer\",manageCalibration:\"Gérer la calibration de détection\",calibrationRequired:\"Calibration requise\",calibrationInstructions:\"Videz le plateau, puis cliquez sur Calibrer.\",calibrationDescription:\"Capture une image de référence du plateau vide.\",calibrationTip:\"Conseil : Stockez jusqu'à 5 références. Le système utilise la meilleure correspondance.\",plateEmpty:\"Le plateau semble vide\",objectsDetected:\"Objets détectés sur le plateau\",confidence:\"Confiance\",difference:\"Différence\",analysisPreview:\"Aperçu de l'analyse :\",analysisLegend:\"Cadre vert = zone, Rouge = différences\",savedReferences:\"Références ({{count}}/{{max}})\",deleteReference:\"Supprimer la référence\",labelPlaceholder:\"Étiquette...\",clickToEdit:\"{{label}} - Modifier\",clickToAddLabel:\"Ajouter une étiquette\"},speed:{title:\"Vitesse d'impression\",silent:\"Silencieux (50%)\",standard:\"Standard (100%)\",sport:\"Sport (124%)\",ludicrous:\"Ludicrous (166%)\"},airduct:{title:\"Mode conduit d'air\",cooling:\"Refroidissement\",heating:\"Chauffage\"},noSdCard:\"Pas de SD\",door:{open:\"Ouverte\",closed:\"Fermée\"},fans:{partCooling:\"Ventilateur pièce\",auxiliary:\"Ventilateur auxiliaire\",chamber:\"Ventilateur chambre\"},clickToViewHmsErrors:\"Cliquez pour voir les erreurs HMS\",estimatedCompletion:\"Fin estimée\",plateNumber:\"Plaque {{number}}\",slotOptions:\"Options du slot\",amsPopup:{friendlyName:\"Nom AMS\",friendlyNamePlaceholder:\"ex. Nom convivial AMS\",serialNumber:\"Numéro de série\",firmwareVersion:\"Firmware\",save:\"Enregistrer\",clear:\"Effacer\",noEditPermission:\"Vous n'avez pas la permission de renommer les unités AMS\"},firmwareModal:{title:\"Mise à jour Firmware\",titleUpToDate:\"Infos Firmware\",currentVersion:\"Actuelle :\",latestVersion:\"Dernière :\",releaseNotes:\"Notes de version\",checkingPrereqs:\"Vérification des prérequis...\",sdCardReady:\"Carte SD prête. Cliquez pour téléverser.\",uploadedSuccess:\"Firmware téléversé !\",applyInstructions:\"Pour appliquer sur l'imprimante :\",step1:\"Sur l'écran, allez dans Paramètres\",step2:\"Allez dans Firmware\",step3:'Sélectionnez \"Mettre à jour depuis carte SD\"',step4:\"Prévoyez 10-20 minutes\",done:\"Terminé\",starting:\"Démarrage...\",uploadFirmware:\"Téléverser le Firmware\",uploadFailed:\"Échec du téléversement : {{error}}\",uploadedToast:\"Firmware téléversé ! Lancez la mise à jour sur l'écran.\"},accessCodePlaceholder:\"Laissez vide pour garder l'actuel\",roi:{title:\"Zone de détection (ROI)\",xStart:\"Début X\",yStart:\"Début Y\",width:\"Largeur\",height:\"Hauteur\",instruction:\"Ajustez le cadre vert pour cibler le plateau.\"},developerModeWarning:\"Le mode développeur LAN n'est pas activé sur : {{names}}. Certaines fonctionnalités peuvent ne pas fonctionner.\",howToEnable:\"Comment activer\",incompatibleFile:\"Ce fichier a été tranché pour {{slicedFor}}, mais cette imprimante est une {{printerModel}}\",dropNotPrintable:\"Seuls les fichiers .gcode et .gcode.3mf peuvent être imprimés\",dropToPrint:\"Déposer pour imprimer\",cannotPrint:\"Imprimante occupée\"},archives:{title:\"Archives d'impression\",searchPlaceholder:\"Chercher dans les archives...\",filterByPrinter:\"Par imprimante\",filterByStatus:\"Par statut\",sortBy:\"Trier par\",sortNewest:\"Plus récent\",sortOldest:\"Plus ancien\",sortName:\"Nom\",sortDuration:\"Durée\",sortLargest:\"Plus volumineux\",sortSmallest:\"Plus léger\",sortSize:\"Taille\",noArchives:\"Aucune archive trouvée\",noArchivesSearch:\"Aucune archive ne correspond\",originalPrintNotVisible:\"Impression d'origine non visible - essayez d'effacer les filtres\",noArchivesYet:\"Pas encore d'archive\",prints:\"impressions\",pagination:{showing:\"Affichage\",to:\"à\",of:\"sur\",show:\"Afficher\",page:\"Page\",all:\"Tout\"},loadingArchives:\"Chargement...\",releaseToUpload:\"Relâcher pour téléverser\",showAll:\"Tout afficher\",showFavoritesOnly:\"Favoris uniquement\",gridView:\"Grille\",listView:\"Liste\",calendarView:\"Calendrier\",logView:\"Journal d'impression\",manageTags:\"Gérer les tags\",showFailedPrints:\"Afficher les échecs\",hideFailedPrints:\"Masquer les échecs\",hideDuplicates:\"Masquer les doublons\",viewOriginalPrint:\"Cliquez pour afficher l'impression originale (#{{id}})\",printTime:\"Temps d'impression\",filamentUsed:\"Filament utilisé\",cost:\"Coût\",reprint:\"Réimprimer\",preview:\"Aperçu\",deleteArchive:\"Supprimer l'archive\",deleteConfirm:\"Supprimer cette archive ?\",favorite:\"Favori\",unfavorite:\"Retirer des favoris\",viewDetails:\"Détails\",status:{completed:\"Réussi\",failed:\"Échoué\",stopped:\"Arrêté\"},toast:{source3mfAttached:\"Source 3MF attachée : {{filename}}\",failedUploadSource3mf:\"Échec téléversement 3MF\",source3mfRemoved:\"Source 3MF retirée\",failedRemoveSource3mf:\"Échec retrait 3MF\",f3dAttached:\"F3D attaché : {{filename}}\",failedUploadF3d:\"Échec téléversement F3D\",f3dRemoved:\"F3D retiré\",failedRemoveF3d:\"Échec retrait F3D\",timelapseAttached:\"Timelapse attaché : {{filename}}\",timelapseAlreadyAttached:\"Timelapse déjà présent\",noMatchingTimelapse:\"Pas de timelapse correspondant\",failedScanTimelapse:\"Échec scan timelapse\",failedAttachTimelapse:\"Échec attache timelapse\",timelapseRemoved:\"Timelapse supprimé\",failedRemoveTimelapse:\"Échec de la suppression du timelapse\",timelapseUploaded:\"Timelapse importé : {{filename}}\",failedUploadTimelapse:\"Échec de l'importation du timelapse\",archiveDeleted:\"Archive supprimée\",failedDeleteArchive:\"Échec suppression\",addedToFavorites:\"Ajouté aux favoris\",removedFromFavorites:\"Retiré des favoris\",projectUpdated:\"Projet mis à jour\",failedUpdateProject:\"Échec mise à jour projet\",linkCopied:\"Lien copié\",failedCopyLink:\"Échec copie lien\",photoDeleted:\"Photo supprimée\",failedDeletePhoto:\"Échec suppression photo\",failedDeleteArchives:\"Échec suppression archives\",failedUpdateFavorites:\"Échec mise à jour favoris\",exportDownloaded:\"Export téléchargé\",exportFailed:\"Échec export\"},menu:{print:\"Imprimer\",schedule:\"Planifier\",openInBambuStudio:\"Ouvrir dans le Slicer\",slice:\"Découper\",externalLink:\"Lien externe\",viewOnMakerWorld:\"Voir sur MakerWorld\",preview3d:\"Aperçu 3D\",viewTimelapse:\"Voir le Timelapse\",scanForTimelapse:\"Scanner pour Timelapse\",uploadTimelapse:\"Importer un timelapse\",removeTimelapse:\"Supprimer le timelapse\",downloadSource3mf:\"Télécharger Source 3MF\",uploadSource3mf:\"Téléverser Source 3MF\",replaceSource3mf:\"Remplacer Source 3MF\",removeSource3mf:\"Retirer Source 3MF\",uploadF3d:\"Téléverser F3D\",replaceF3d:\"Remplacer F3D\",downloadF3d:\"Télécharger F3D\",removeF3d:\"Retirer F3D\",download:\"Télécharger\",copyDownloadLink:\"Copier lien de téléchargement\",qrCode:\"Code QR\",viewPhotos:\"Voir les photos\",viewPhotosCount:\"Voir les photos ({{count}})\",projectPage:\"Page du Projet\",addToFavorites:\"Ajouter aux favoris\",removeFromFavorites:\"Retirer des favoris\",edit:\"Modifier\",goToProject:\"Aller au Projet : {{name}}\",addToProject:\"Ajouter au Projet\",removeFromProject:\"Retirer du Projet\",loading:\"Chargement...\",noProjectsAvailable:\"Aucun projet disponible\",select:\"Sélectionner\",deselect:\"Désélectionner\",delete:\"Supprimer\"},permission:{noReprint:\"Pas d'autorisation de réimpression\",noAddToQueue:\"Pas d'autorisation pour la file\",noUpdateArchives:\"Pas d'autorisation de mise à jour\",noUploadFiles:\"Pas d'autorisation de téléversement\",noDownload:\"Pas d'autorisation de téléchargement\",noCopyLink:\"Pas d'autorisation de copie lien\",noDelete:\"Pas d'autorisation de suppression\",noCreate:\"Pas d'autorisation de création\"},card:{previousPlate:\"Plateau précédent\",nextPlate:\"Plateau suivant\",plateNumber:\"Plateau {{index}}\",moreOptions:\"Clic droit pour plus d'options\",addToFavorites:\"Ajouter aux favoris\",removeFromFavorites:\"Retirer des favoris\",cancelled:\"annulé\",failed:\"échoué\",duplicate:\"doublon\",duplicateTitle:\"Ce modèle a déjà été imprimé\",openSource3mf:\"Ouvrir 3MF dans Bambu Studio (clic droit pour plus)\",downloadF3d:\"Télécharger fichier Fusion 360\",viewTimelapse:\"Voir timelapse\",viewPhoto:\"Voir 1 photo\",viewPhotos:\"Voir {{count}} photos\",openFolder:\"Ouvrir le dossier : {{name}}\",slicedFile:\"Fichier découpé - prêt\",sourceFile:\"Fichier source uniquement - pas de mapping AMS\",gcode:\"GCODE\",source:\"SOURCE\",project:\"Projet : {{name}}\",estimated:\"Estimé : {{time}}\",actual:\"Réel : {{time}}\",accuracy:\"Précision : {{percent}}%\",filament:\"{{weight}}g\",layer:\"{{count}} couche\",layers:\"{{count}} couches\",object:\"{{count}} objet\",objects:\"{{count}} objets\",slicedFor:\"Découpé pour {{model}}\",uploadedBy:\"Téléversé par\",noPermissionReprint:\"Pas d'autorisation de réimpression\",noFileForReprint:\"Aucun fichier 3MF disponible — le fichier n'a pas pu être téléchargé depuis l'imprimante lors de l'enregistrement\",noPermissionEdit:\"Pas d'autorisation de modification\",noPermissionDelete:\"Pas d'autorisation de suppression\",reprint:\"Réimprimer\",schedulePrint:\"Planifier\",schedule:\"Planifier\",openInBambuStudio:\"Ouvrir dans le Slicer\",openInBambuStudioToSlice:\"Ouvrir dans le Slicer pour découper\",slice:\"Découper\",externalLink:\"Lien externe\",makerWorld:\"MakerWorld : {{designer}}\",viewProject:\"Voir projet\",noExternalLink:\"Aucun lien externe\",preview3d:\"Aperçu 3D\",download:\"Télécharger\",edit:\"Modifier\",delete:\"Supprimer\"},modal:{deleteArchive:\"Supprimer l'archive\",deleteConfirm:'Supprimer \"{{name}}\" ? Cette action est irréversible.',deleteButton:\"Supprimer\",removeSource3mf:\"Retirer Source 3MF\",removeSource3mfConfirm:'Retirer le fichier 3MF de \"{{name}}\" ?',removeButton:\"Retirer\",removeF3d:\"Retirer F3D\",removeF3dConfirm:'Retirer le fichier Fusion 360 de \"{{name}}\" ?',removeTimelapse:\"Supprimer le timelapse\",removeTimelapseConfirm:'Êtes-vous sûr de vouloir supprimer la vidéo timelapse de \"{{name}}\" ?',timelapse:\"{{name}} - Timelapse\",selectTimelapse:\"Choisir un Timelapse\",selectTimelapseDesc:\"Sélectionnez manuellement le timelapse :\",deleteArchives:\"Supprimer les archives\",deleteArchivesConfirm:\"Supprimer {{count}} archive(s) ?\",deleteCount:\"Supprimer {{count}}\"},page:{title:\"Archives\",printsCount:\"{{filtered}} sur {{total}} impressions\",dropFilesHere:\"Déposez les fichiers .3mf ici\",releaseToUpload:\"Relâcher pour téléverser\",only3mfSupported:\"Seuls les fichiers .3mf sont supportés\",close:\"Fermer\",selected:\"{{count}} sélectionnés\",selectAll:\"Tout sélectionner\",tags:\"Tags\",project:\"Projet\",favorite:\"Favori\",delete:\"Supprimer\",toggledFavorites:\"Favoris mis à jour pour {{count}} archive(s)\",failedUpdateFavorites:\"Échec mise à jour favoris\",archivesDeleted:\"{{count}} archive(s) supprimée(s)\",failedDeleteArchives:\"Échec suppression\",photoDeleted:\"Photo supprimée\",failedDeletePhoto:\"Échec suppression photo\"},list:{name:\"Nom\",printer:\"Imprimante\",date:\"Date\",size:\"Taille\",actions:\"Actions\",hasTimelapse:\"A un timelapse\"},log:{date:\"Date\",printName:\"Nom de l'impression\",printer:\"Imprimante\",user:\"Utilisateur\",status:\"Statut\",duration:\"Durée\",filament:\"Filament\",allPrinters:\"Toutes les imprimantes\",allUsers:\"Tous les utilisateurs\",allStatuses:\"Tous les statuts\",cancelled:\"Annulé\",skipped:\"Ignoré\",dateFrom:\"Du\",dateTo:\"Au\",noEntries:\"Aucune entrée de journal trouvée\",showing:\"{{count}} sur {{total}} entrées\",rowsPerPage:\"Lignes\",page:\"Page\",prev:\"Préc.\",next:\"Suiv.\",clearLog:\"Effacer le journal\",clearLogTitle:\"Effacer le journal d'impression\",clearLogConfirm:\"Toutes les entrées du journal d'impression seront supprimées définitivement. Les archives et les éléments de file d'attente ne sont pas affectés. Cette action est irréversible. Êtes-vous sûr ?\",clearLogButton:\"Tout effacer\",cleared:\"{{count}} entrées de journal effacées\",clearFailed:\"Échec de l'effacement du journal d'impression\"}},queue:{title:\"File d'attente\",subtitle:\"Gérez vos travaux d'impression\",addToQueue:\"Ajouter à la file\",print:\"Imprimer\",reprint:\"Réimprimer\",schedulePrint:\"Planifier\",editQueueItem:\"Modifier l'élément\",printToPrinters:\"Imprimer sur {{count}} imprimantes\",queueToPrinters:\"Ajouter à la file pour {{count}} imprimantes\",queueSelectedPlates:\"Ajouter {{count}} plaques à la file\",selectAllPlates:\"Sélectionner les {{count}} plaques\",deselectAll:\"Tout désélectionner\",printQueued:\"Impression ajoutée à la file\",itemsQueued:\"{{count}} éléments ajoutés à la file\",sending:\"Envoi...\",sendingProgress:\"Envoi {{current}}/{{total}}...\",adding:\"Ajout...\",addingProgress:\"Ajout {{current}}/{{total}}...\",savingProgress:\"Enregistrement {{current}}/{{total}}...\",clearQueue:\"Vider la file\",clearHistory:\"Effacer l'historique\",emptyQueue:\"La file est vide\",position:\"Position\",scheduledTime:\"Heure prévue\",moveUp:\"Monter\",moveDown:\"Descendre\",startNow:\"Démarrer maintenant\",printingInProgress:\"Impression en cours...\",viewArchive:\"Voir l'archive\",viewInFileManager:\"Voir dans le gestionnaire\",itemCount:\"{{count}} élément\",itemCount_plural:\"{{count}} éléments\",dragToReorder:\"Glisser pour réordonner (ASAP uniquement)\",reorderHint:\"La position n'affecte que les éléments ASAP.\",sjf:{label:\"SJF\",tooltip:\"Travail le plus court en premier — le planificateur priorise les impressions plus courtes\"},addedBy:\"Ajouté par {{name}}\",nextInQueue:\"Prochain en file\",clearPlateSuccess:\"Plateau vidé — prêt pour l'impression suivante\",plateNumber:\"Plateau {{index}}\",quantity:\"Quantité\",quantityHint:\"Crée {{count}} éléments de file d'attente\",activeBatches:\"Lots actifs\",batchProgress:\"{{completed}} sur {{total}} terminés\",cancelBatch:\"Annuler les restants\",batchCancelled:\"Éléments restants du lot annulés\",cancelBatchConfirmTitle:\"Annuler le lot\",cancelBatchConfirmMessage:\"Annuler tous les éléments en attente restants dans ce lot ?\",batch:\"Lot\",sections:{currentlyPrinting:\"En cours\",queued:\"En attente\",history:\"Historique\"},status:{pending:\"En attente\",waiting:\"En attente\",printing:\"Impression\",paused:\"En pause\",completed:\"Terminé\",failed:\"Échoué\",skipped:\"Sauté\",cancelled:\"Annulé\"},summary:{printing:\"Impressions\",queued:\"En attente\",totalTime:\"Temps total estimé\",totalWeight:\"Poids total estimé\",history:\"Historique\"},filter:{allPrinters:\"Toutes les imprimantes\",unassigned:\"Non assigné\",allStatus:\"Tous les statuts\",allLocations:\"Tous les emplacements\",any:\"Tout\"},sort:{byPosition:\"Par position\",byName:\"Par nom\",byPrinter:\"Par imprimante\",bySchedule:\"Par planification\",byDate:\"Par date\",ascendingOldest:\"Croissant (plus vieux)\",descendingNewest:\"Décroissant (plus récent)\"},badges:{staged:\"Préparé\",requiresPrevious:\"Nécessite succès précédent\",autoPowerOff:\"Extinction auto\",gcodeInjection:\"G-code\"},empty:{title:\"Aucune impression prévue\",description:\"Planifiez depuis les Archives ou glissez des fichiers ici.\"},time:{asap:\"Dès que possible\",overdue:\"En retard\",now:\"Maintenant\",lessThanMinute:\"Dans moins d'une minute\",inMinutes:\"Dans {{count}} min\",inHours:\"Dans {{count}} heures\"},actions:{stopPrint:\"Arrêter\",startPrint:\"Démarrer\",requeue:\"Remettre en file\"},bulkEdit:{title:\"Modifier {{count}} élément\",title_plural:\"Modifier {{count}} éléments\",description:\"Seuls les changements seront appliqués.\",printer:\"Imprimante\",noChange:\"— Aucun changement —\",queueOptions:\"Options de file\",staged:\"Préparé (manuel)\",autoPowerOff:\"Extinction auto après\",requirePrevious:\"Requiert succès précédent\",printOptions:\"Options d'impression\",bedLevelling:\"Nivellement plateau\",flowCalibration:\"Calibration débit\",vibrationCalibration:\"Vibration (Input Shaper)\",layerInspection:\"Inspection 1ère couche\",timelapse:\"Timelapse\",useAms:\"Utiliser AMS\",applyChanges:\"Appliquer\",selectAll:\"Tout sélectionner\",deselectAll:\"Tout désélectionner\",selected:\"{{count}} sélectionnés\",editSelected:\"Modifier la sélection\",cancelSelected:\"Annuler la sélection\"},confirm:{cancelTitle:\"Annuler l'impression prévue\",cancelMessage:'Annuler \"{{name}}\" ?',stopTitle:\"Arrêter l'impression\",stopMessage:`Arrêter \"{{name}}\" sur l'imprimante ?`,removeTitle:\"Retirer de l'historique\",removeMessage:`Retirer \"{{name}}\" de l'historique ?`,clearHistoryTitle:\"Effacer l'historique\",clearHistoryMessage:\"Retirer les {{count}} éléments ?\",cancelButton:\"Annuler l'impression\",stopButton:\"Arrêter l'impression\",thisPrint:\"cette impression\",thisItem:\"cet élément\"},toast:{cancelled:\"Élément annulé\",cancelFailed:\"Échec annulation\",removed:\"Élément retiré\",removeFailed:\"Échec retrait\",stopped:\"Impression arrêtée\",stopFailed:\"Échec arrêt\",released:\"Libéré dans la file\",startFailed:\"Échec démarrage\",reorderFailed:\"Échec réorganisation\",historyCleared:\"{{count}} éléments effacés\",clearHistoryFailed:\"Échec effacement\",updateFailed:\"Échec mise à jour\",bulkCancelled:\"{{count}} éléments annulés\",bulkCancelFailed:\"Échec annulation\"},timeline:{listView:\"Liste\",timelineView:\"Chronologie\",unassigned:\"Non attribué\",noData:\"Aucune impression planifiée pour ce jour\",allDoneBy:\"Toutes les impressions terminées vers {{time}}\",staged:\"En attente\",filterAll:\"Tout afficher\",filterPrinting:\"En cours\",filterQueued:\"En file\",time:{anyMoment:\"imminent\",minutesLeft:\"{{minutes}}m restantes\",hoursLeft:\"{{hours}}h restantes\",hoursMinutesLeft:\"{{hours}}h {{minutes}}m restantes\"},day:{previous:\"Jour précédent\",next:\"Jour suivant\",today:\"Aujourd'hui\"}},permissions:{noStopPrint:\"Pas d'autorisation d'arrêt\",noStartPrint:\"Pas d'autorisation de démarrage\",noEdit:\"Pas d'autorisation de modification\",noCancel:\"Pas d'autorisation d'annulation\",noRequeue:\"Pas d'autorisation de remise en file\",noRemove:\"Pas d'autorisation de retrait\",noClearHistory:\"Pas d'autorisation historique\",noEditItems:\"Pas d'autorisation modification groupée\",noCancelItems:\"Pas d'autorisation annulation groupée\"}},backgroundDispatch:{unknownFile:\"Unknown file\",unknownPrinter:\"Unknown printer\",startingPrints:\"Starting prints\",progressSummary:\"{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}\",expandDetails:\"Expand dispatch details\",collapseDetails:\"Collapse dispatch details\",dismissToast:\"Dismiss dispatch toast\",cancelDispatchJob:\"Cancel dispatch job\",cancel:\"Cancel\",cancelling:\"Cancelling…\",status:{dispatched:\"Dispatched\",processing:\"Processing\",completed:\"Completed\",failed:\"Failed\",cancelled:\"Cancelled\"},toast:{cancellingUpload:\"Cancelling upload...\",cancelled:\"Dispatch cancelled\",cancelFailed:\"Failed to cancel dispatch\",completeWithFailures:\"Background dispatch complete: {{completed}} succeeded, {{failed}} failed\",completeSuccess:\"Background dispatch complete: {{completed}} succeeded\",printStartedRemaining:\"{{completed}} impression(s) lancée(s), {{remaining}} en cours d'envoi...\"}},stats:{title:\"Tableau de bord\",subtitle:\"Glissez les widgets pour réorganiser. Cliquez sur l'œil pour masquer.\",overview:\"Vue d'ensemble\",totalPrints:\"Total impressions\",successRate:\"Taux de succès\",totalPrintTime:\"Temps total d'impression\",printTime:\"Temps d'impression\",totalFilament:\"Total filament utilisé\",filamentUsed:\"Filament utilisé\",filamentCost:\"Coût filament\",totalCost:\"Coût total\",energyUsed:\"Énergie consommée\",energyCost:\"Coût énergie\",energyWarmingUpTooltip:\"Le suivi énergétique collecte encore des instantanés horaires. Les totaux par période deviendront précis dès qu’un instantané existera avant la plage sélectionnée. Les premières valeurs peuvent être sous-estimées.\",averagePrintTime:\"Temps moyen par impression\",printsPerDay:\"Impressions par jour\",byPrinter:\"Par imprimante\",printsByPrinter:\"Impressions par imprimante\",byMaterial:\"Par matériau\",byMonth:\"Par mois\",last7Days:\"7 derniers jours\",last30Days:\"30 derniers jours\",last90Days:\"90 derniers jours\",allTime:\"Tout le temps\",quickStats:\"Stats rapides\",printActivity:\"Activité d'impression\",filamentTypes:\"Types de filament\",filamentTrends:\"Tendances filament\",failureAnalysis:\"Analyse des échecs\",timeAccuracy:\"Précision du temps\",successful:\"Succès :\",failed:\"Échecs :\",perfectEstimate:\"100% = estimation parfaite\",noTimeAccuracyData:\"Pas encore de données de précision\",noFilamentData:\"Aucune donnée de filament\",noPrinterData:\"Aucune donnée d'imprimante\",noPrintData:\"Aucune donnée d'impression\",noPrintDataLast30Days:\"Aucune impression ces 30 derniers jours\",failureReasons:\"Raisons des échecs\",topFailureReasons:\"Top raisons d'échec\",failedPrintsCount:\"{{failed}} / {{total}} impressions ont échoué\",lastWeekRate:\"Semaine dernière : {{rate}}%\",resetLayout:\"Réinitialiser la mise en page\",recalculateCosts:\"Recalculer les coûts\",recalculateCostsHint:\"Recalcule les coûts avec les prix actuels des filaments\",exportStats:\"Exporter les stats\",exportAsCsv:\"Exporter en CSV\",exportAsExcel:\"Exporter en Excel\",hiddenCount:\"{{count}} Masqués\",exportDownloaded:\"Export téléchargé\",exportFailed:\"Échec export\",layoutReset:\"Mise en page réinitialisée\",recalculatedCosts:\"Coûts recalculés pour {{count}} archives\",recalculateFailed:\"Échec du calcul\",loadingStats:\"Chargement des statistiques...\",noPermissionResetLayout:\"Pas d'autorisation de réinitialisation\",noPermissionRecalculate:\"Pas d'autorisation de recalcul\",noPrintDataInRange:\"Aucune donnée dans la période sélectionnée\",periodFilament:\"Filament utilisé\",periodCost:\"Coût\",avgPerPrint:\"Moy. par impression\",usageOverTime:\"Utilisation dans le temps\",filamentByWeight:\"Poids\",printDuration:\"Durée d'impression\",printerUtilization:\"Utilisation imprimante\",filamentSuccess:\"Succès par matériau\",printHabits:\"Habitudes d'impression\",printTimeOfDay:\"Heure d'impression\",colorDistribution:\"Distribution des couleurs\",noColorData:\"Aucune donnée de couleur disponible\",records:\"Records\",longestPrint:\"Plus longue impression\",heaviestPrint:\"Plus lourde impression\",mostExpensivePrint:\"Plus chère\",busiestDay:\"Jour le plus actif\",successStreak:\"Série de succès\",streakPrint:\"impression consécutive\",streakPrints:\"{{count}} impressions consécutives\",printerStats:\"Stats imprimante\",hours:\"heures\",avgPrints:\"Moy. impressions\",noArchiveData:\"Aucune donnée d'impression disponible\",filamentByTime:\"Temps\",avgWeight:\"Moy. poids\",avgTime:\"Moy. temps\",filamentByPrints:\"Impressions\",timeframe:{today:\"Aujourd'hui\",\"this-week\":\"Cette semaine\",\"this-month\":\"Ce mois\",\"last-7\":\"7 derniers jours\",\"last-30\":\"30 derniers jours\",\"last-90\":\"90 derniers jours\",\"this-year\":\"Cette année\",\"all-time\":\"Tout\",custom:\"Personnalisé\",from:\"Du\",to:\"Au\"},allUsers:\"Tous les utilisateurs\",noUser:\"Aucun utilisateur (Système)\",filterByUser:\"Filtrer par utilisateur\"},maintenance:{title:\"Maintenance\",overview:\"Vue d'ensemble\",allOk:\"Toute la maintenance est à jour\",dueCount:\"{{count}} tâche à faire\",dueCount_plural:\"{{count}} tâches à faire\",warningCount:\"{{count}} avertissement\",warningCount_plural:\"{{count}} avertissements\",totalPrintTime:\"Temps d'impression total\",nextMaintenance:\"Prochaine maintenance\",nothingDue:\"Rien de prévu\",tasks:\"Tâches\",lastPerformed:\"Dernière réalisation\",interval:\"Intervalle\",hoursRemaining:\"{{hours}}h restantes\",hoursOverdue:\"{{hours}}h de retard\",markDone:\"Marquer comme fait\",performMaintenance:\"Effectuer la maintenance\",history:\"Historique\",noHistory:\"Aucun historique\",editPrintHours:\"Modifier les heures d'impression\",currentHours:\"Heures actuelles\",statusTab:\"Statut\",settingsTab:\"Paramètres\",overdueCount:\"{{count}} en retard\",dueSoonCount:\"{{count}} bientôt\",dueSoon:\"Bientôt\",allGood:\"Tout va bien\",overdueBy:\"Retard de {{duration}}\",dueIn:\"Dans {{duration}}\",timeLeft:\"{{duration}} restant\",day:\"1 jour\",days:\"{{count}} jours\",week:\"1 semaine\",weeks:\"{{count}} semaines\",month:\"1 mois\",months:\"{{count}} mois\",year:\"1 an\",maintenanceTypes:\"Types de maintenance\",maintenanceTypesDescription:\"Types système et tâches personnalisées\",addCustomType:\"Ajouter un type\",restoreDefaults:\"Restaurer les tâches par défaut\",intervalType:\"Type d'intervalle\",intervalValue:\"Intervalle ({{type}})\",icon:\"Icône\",documentationLink:\"Lien documentation (optionnel)\",assignToPrinters:\"Assigner aux imprimantes\",selectAtLeastOnePrinter:\"Sélectionnez au moins une imprimante\",addType:\"Ajouter le type\",custom:\"Personnalisé\",printHours:\"Heures d'impression\",calendarDays:\"Jours calendaires\",exampleName:\"ex: Remplacement filtre HEPA\",viewDocumentation:\"Voir documentation\",timeBasedInterval:\"Intervalle temporel\",intervalOverrides:\"Exceptions d'intervalle\",intervalOverridesDescription:\"Intervalles spécifiques par imprimante\",assignedToPrinters:\"Assigné aux imprimantes :\",noPrintersAssigned:\"Aucune imprimante assignée\",addPrinterShort:\"Ajouter :\",printersAssignedClick:\"{{count}} imprimante(s) assignée(s) - gérer\",removeFromPrinter:\"Retirer de cette imprimante\",types:{lubricateCarbonRods:\"Lubrifier les tiges carbone\",lubricateRails:\"Lubrifier les rails linéaires\",cleanNozzle:\"Nettoyer la buse / hotend\",checkBelts:\"Vérifier la tension des courroies\",cleanBuildPlate:\"Nettoyer le plateau\",checkExtruder:\"Vérifier les engrenages de l'extrudeur\",checkCooling:\"Vérifier les ventilateurs\",generalInspection:\"Inspection générale\",cleanCarbonRods:\"Nettoyer les tiges carbone\",lubricateSteelRods:\"Lubrifier les tiges en acier\",cleanSteelRods:\"Nettoyer les tiges en acier\",cleanLinearRails:\"Nettoyer les rails linéaires\",checkPtfeTube:\"Vérifier le tube PTFE\",replaceHepaFilter:\"Remplacer le filtre HEPA\",replaceCarbonFilter:\"Remplacer le filtre charbon\",lubricateLeftNozzleRail:\"Lubrifier le rail de buse gauche\"},maintenanceComplete:\"Maintenance marquée comme faite\",typeUpdated:\"Type mis à jour\",typeDeleted:\"Type supprimé\",defaultsRestored:\"{{count}} tâche(s) par défaut restaurée(s)\",printHoursUpdated:\"Heures mises à jour\",printerAssigned:\"Imprimante assignée\",printerRemoved:\"Imprimante retirée\",deleteTypeConfirm:'Supprimer \"{{name}}\" ?',deleteSystemTypeTitle:\"Supprimer la tâche de maintenance par défaut ?\",deleteSystemTypeMessage:'Êtes-vous sûr de vouloir supprimer la tâche de maintenance par défaut \"{{name}}\" ?',noPermissionUpdate:\"Pas d'autorisation de mise à jour\",noPermissionPerform:\"Pas d'autorisation d'action\",noPermissionEditTypes:\"Pas d'autorisation de modification types\",noPermissionDeleteTypes:\"Pas d'autorisation de suppression types\",noPermissionEditHours:\"Pas d'autorisation de modification heures\",noPermissionRemovePrinter:\"Pas d'autorisation retrait imprimante\",noPermissionAssignPrinter:\"Pas d'autorisation assignation\",noPermissionEditIntervals:\"Pas d'autorisation modification intervalles\",configureSettings:\"Configurer types et intervalles\"},settings:{title:\"Paramètres\",general:\"Général\",tabs:{general:\"Général\",smartPlugs:\"Prises connectées\",notifications:\"Notifications\",queue:\"Workflow\",filament:\"Filament\",network:\"Réseau\",apiKeys:\"Clés API\",virtualPrinter:\"Imprimante virtuelle\",failureDetection:\"Détection d'échec\",users:\"Authentification\",backup:\"Sauvegarde\",emailAuth:\"Authentification Email\",ldap:\"LDAP\",twoFa:\"Authentification 2FA\",oidc:\"SSO / OIDC\"},ldap:{title:\"Authentification LDAP\",enabledDesc:\"L'authentification LDAP est activée\",disabledDesc:\"L'authentification LDAP est désactivée\",disabledHint:\"Configurez et enregistrez les paramètres LDAP ci-dessous, puis activez.\",enabled:\"Authentification LDAP activée\",disabled:\"Authentification LDAP désactivée\",feature1:\"Les utilisateurs peuvent se connecter avec leurs identifiants LDAP\",feature2:\"Le compte administrateur local reste disponible en secours\",feature3:\"Les groupes LDAP sont associés aux groupes BamBuddy à la connexion\",serverConfig:\"Configuration du serveur LDAP\",serverUrl:\"URL du serveur\",serverUrlHint:\"Utilisez ldap:// pour standard ou ldaps:// pour les connexions SSL\",security:\"Sécurité\",securityHint:\"StartTLS met à niveau une connexion simple vers TLS. LDAPS utilise TLS dès le début.\",bindDn:\"DN de liaison (compte de service)\",bindPassword:\"Mot de passe de liaison\",searchBase:\"DN de base de recherche\",userFilter:\"Filtre de recherche utilisateur\",userFilterHint:\"{username} est remplacé par le nom d'utilisateur. Utilisez (uid={username}) pour OpenLDAP.\",autoProvision:\"Provisionnement automatique\",autoProvisionHint:\"Créer automatiquement un compte BamBuddy lors de la première connexion LDAP\",defaultGroup:\"Groupe par défaut\",defaultGroupNone:\"— Aucun (pas de repli) —\",defaultGroupHint:\"Groupe de repli attribué lorsqu'un utilisateur LDAP s'authentifie mais n'est dans aucun groupe LDAP mappé. Laissez vide pour laisser les utilisateurs non mappés sans autorisations.\",groupMapping:\"Mappage de groupes (JSON)\",groupMappingHint:\"Associer les DN de groupes LDAP aux groupes BamBuddy. Groupes disponibles : \",testConnection:\"Tester la connexion\",settingsSaved:\"Paramètres LDAP enregistrés\",errors:{serverRequired:\"L'URL du serveur LDAP est requise\",searchBaseRequired:\"Le DN de base de recherche est requis\",enableAuthFirst:\"Activez d'abord l'authentification\",configureLdapFirst:\"Enregistrez d'abord les paramètres LDAP\"}},email:{smtpSettings:\"Configuration SMTP\",smtpHost:\"Serveur SMTP\",smtpPort:\"Port SMTP\",security:\"Sécurité\",authentication:\"Authentification\",username:\"Utilisateur\",password:\"Mot de passe\",fromEmail:\"Email expéditeur\",fromName:\"Nom expéditeur\",testConnection:\"Tester la connexion SMTP\",testRecipient:\"Email test destinataire\",sendTest:\"Envoyer email test\",sending:\"Envoi...\",save:\"Enregistrer les paramètres\",saving:\"Enregistrement...\",advancedAuth:\"Authentification avancée\",advancedAuthEnabled:\"L'authentification avancée est activée\",advancedAuthEnabledDesc:\"La gestion des utilisateurs par email est active. Les nouveaux utilisateurs recevront un mot de passe auto-généré.\",advancedAuthDisabled:\"Authentification avancée désactivée\",advancedAuthDisabledDesc:\"Activez pour les fonctionnalités liées à l'email (mot de passe oublié, etc).\",enable:\"Activer\",disable:\"Désactiver\",feature1:\"Génération auto et envoi de mots de passe par email\",feature2:\"Connexion par utilisateur ou email\",feature3:\"Réinitialisation mot de passe oublié disponible\",feature4:\"Réinitialisation admin par email\",errors:{requiredFields:\"Remplissez tous les champs requis\",usernameRequired:\"L'utilisateur est requis pour l'authentification\",enterTestEmail:\"Entrez une adresse email de test\",smtpServerAndEmail:\"Serveur et expéditeur requis pour le test\",usernamePasswordRequired:\"Utilisateur et mot de passe requis pour l'auth\",configureSmtpFirst:\"Configurez et testez le SMTP d'abord\",enableAuthFirst:\"Veuillez d'abord activer l'authentification pour utiliser les fonctionnalités basées sur le courrier électronique.\"},success:{settingsSaved:\"Paramètres SMTP enregistrés\"},securityOptions:{starttls:\"STARTTLS (Port 587)\",ssl:\"SSL/TLS (Port 465)\",none:\"Aucun (Port 25)\"},authOptions:{enabled:\"Activée\",disabled:\"Désactivée\"}},appearance:\"Apparence\",notifications:\"Notifications\",smartPlugs:\"Prises connectées\",spoolman:\"Spoolman\",updates:\"Mises à jour\",language:\"Langue\",languageDescription:\"Choisissez votre langue\",theme:\"Thème\",themeLight:\"Clair\",themeDark:\"Sombre\",themeSystem:\"Système\",defaultView:\"Vue par défaut\",defaultViewDescription:\"Page affichée au démarrage\",checkForUpdates:\"Vérifier les mises à jour\",autoUpdate:\"Mise à jour auto\",currentVersion:\"Version actuelle\",latestVersion:\"Dernière version\",upToDate:\"Bambuddy est à jour\",updateAvailable:\"Mise à jour disponible\",notificationLanguage:\"Langue des notifications\",notificationLanguageDescription:\"Langue pour les notifications push\",bedCooledThreshold:\"Seuil de refroidissement du plateau\",bedCooledThresholdDescription:\"Température en dessous de laquelle le plateau est considéré comme refroidi\",userNotificationsEnabled:\"Notifications utilisateur\",userNotificationsEnabledDescription:\"Active le menu de notifications utilisateur et les notifications par e-mail pour les événements d'impression. Nécessite l'authentification avancée.\",userNotificationsDisabledHint:\"Activez l'authentification avancée pour utiliser les notifications utilisateur.\",notificationProviders:\"Fournisseurs de notifications\",addProvider:\"Ajouter un fournisseur\",editProvider:\"Modifier le fournisseur\",providerType:\"Type de fournisseur\",testNotification:\"Tester la notification\",testSuccess:\"Notification de test envoyée\",testFailed:\"Échec de l'envoi du test\",quietHours:\"Heures de silence\",quietHoursDescription:\"Ne pas déranger pendant ces heures\",quietHoursStart:\"Début\",quietHoursEnd:\"Fin\",events:{title:\"Événements de notification\",printStart:\"Impression démarrée\",printComplete:\"Impression terminée\",printFailed:\"Impression échouée\",printStopped:\"Impression arrêtée\",printProgress:\"Jalons de progression\",printProgressDescription:\"Notifier à 25%, 50%, 75%\",printerOffline:\"Imprimante hors ligne\",printerError:\"Erreur imprimante\",filamentLow:\"Filament bas\",maintenanceDue:\"Maintenance due\",maintenanceDueDescription:\"Notifier quand une tâche est due\"},smartPlug:{title:\"Prises connectées\",add:\"Ajouter une prise\",edit:\"Modifier la prise\",name:\"Nom\",ipAddress:\"Adresse IP\",linkedPrinter:\"Imprimante liée\",autoOn:\"Allumage auto\",autoOnDescription:\"Allumer au début de l'impression\",autoOff:\"Extinction auto\",autoOffDescription:\"Éteindre après l'impression\",offDelay:\"Délai d'extinction\",offDelayMinutes:\"Minutes après fin\",offDelayTemp:\"Quand la buse est sous\",currentState:\"État actuel\",turnOn:\"Allumer\",turnOff:\"Éteindre\"},filamentTracking:\"Suivi de Filament\",filamentTrackingDesc:\"Choisissez comment suivre vos bobines. Utilisez l'inventaire intégré ou connectez un serveur Spoolman.\",filamentChecks:\"Vérifications du filament\",disableFilamentWarnings:\"Désactiver les avertissements de filament\",disableFilamentWarningsDesc:\"Ne pas afficher les avertissements de filament insuffisant lors de l'impression ou de la mise en file d'attente\",preferLowestFilament:\"Préférer le filament le plus bas\",preferLowestFilamentDesc:\"Lorsque plusieurs bobines correspondent, utiliser celle avec le moins de filament restant\",trackingModeBuiltIn:\"Inventaire Intégré\",trackingModeBuiltInDesc:\"Correspondance RFID et suivi de consommation inclus\",trackingModeSpoolmanDesc:\"Serveur de gestion externe\",builtInFeatureRfid:\"Détecte auto les bobines RFID Bambu Lab dans l'AMS\",builtInFeatureUsage:\"Suit la consommation par impression\",builtInFeatureCatalog:\"Gère bobines, couleurs et profils facteur K\",builtInFeatureThirdParty:\"Les bobines tierces peuvent être assignées aux bobines d'inventaire\",amsSyncButton:\"Synchroniser les poids depuis l'AMS\",amsSyncTitle:\"Synchroniser les poids des bobines depuis l'AMS\",amsSyncMessage:\"Tous les poids des bobines de l'inventaire seront écrasés par les valeurs actuelles de l'AMS des imprimantes connectées. Utilisez ceci pour récupérer des données de poids corrompues. Les imprimantes doivent être en ligne.\",amsSyncing:\"Synchronisation...\",amsSyncSuccess:\"{{synced}} bobine(s) synchronisée(s), {{skipped}} ignorée(s)\",amsSyncError:\"Échec de la synchronisation des poids depuis l'AMS\",spoolmanUrl:\"URL Spoolman\",spoolmanUrlHint:\"URL de votre serveur Spoolman (ex: http://localhost:7912)\",spoolmanConnected:\"Connecté\",spoolmanDisconnected:\"Déconnecté\",status:\"Statut\",connect:\"Connecter\",disconnect:\"Déconnecter\",howSyncWorks:\"Fonctionnement de la Sync\",syncInfoRfidOnly:\"Seules les bobines officielles RFID sont synchronisées\",syncInfoAutoCreate:\"Les bobines sont créées dans Spoolman à la 1ère sync\",syncInfoThirdPartySkipped:\"Les bobines tierces ou rechargées sont ignorées\",linkingExistingSpools:\"Lier des bobines existantes\",linkingExistingSpoolsDesc:'Pour lier une bobine Spoolman, survolez un slot AMS et cliquez sur \"Lier à Spoolman\".',syncMode:\"Mode de Sync\",syncModeAuto:\"Automatique\",syncModeManual:\"Manuel uniquement\",syncModeAutoDesc:\"Sync auto lors de changements AMS\",syncModeManualDesc:\"Sync uniquement sur déclenchement manuel\",syncAmsData:\"Synchroniser AMS\",syncAmsDataDesc:\"Synchroniser manuellement les données vers Spoolman\",allPrinters:\"Toutes les imprimantes\",noDefaultPrinter:\"Aucune par défaut (demander à chaque fois)\",sidebarOrder:\"Ordre de la barre latérale\",saveThumbnails:\"Enregistrer les vignettes\",captureFinishPhoto:\"Prendre une photo à la fin\",noPrintersConfigured:\"Aucune imprimante configurée\",archiveMode:{always:\"Toujours créer une archive\",never:\"Ne jamais créer d'archive\",ask:\"Demander à chaque fois\"},checkForUpdatesLabel:\"Vérifier les mises à jour\",checkPrinterFirmware:\"Vérifier le firmware imprimante\",includeBetaUpdates:\"Inclure les versions bêta\",includeBetaUpdatesDesc:\"Notifier des versions bêta et préliminaires lors de la vérification des mises à jour\",enableRetry:\"Activer la rétentative\",homeAssistantDescription:\"Contrôler les prises via Home Assistant\",environmentManagedLabel:\"(Géré par l'environnement)\",autoEnabledViaEnv:\"Activé via variables d'environnement\",urlFromEnvReadOnly:\"Valeur HA_URL (lecture seule)\",tokenFromEnvReadOnly:\"Valeur HA_TOKEN (lecture seule)\",mqttConnectedTo:\"Connecté à\",prometheusDescription:\"Exposer les données au format Prometheus\",noSmartPlugsTitle:\"Aucune prise configurée\",noSmartPlugsDescription:\"Ajoutez une prise Tasmota pour suivre l'énergie et automatiser.\",noProvidersTitle:\"Aucun fournisseur configuré\",noProvidersDescription:\"Ajoutez un fournisseur pour recevoir des alertes.\",noTemplatesAvailable:\"Aucun modèle dispo. Redémarrez pour les générer.\",apiPermissionView:\"Voir statut et file\",apiPermissionEdit:\"Gérer la file d'attente\",apiKeysEmptyTitle:\"Aucune clé API\",apiKeysEmptyDescription:\"Créez une clé pour vos intégrations.\",noUsersFound:\"Aucun utilisateur trouvé\",noGroupsFound:\"Aucun groupe trouvé\",noGroupsAvailable:\"Aucun groupe disponible\",passwordsDoNotMatch:\"Les mots de passe ne correspondent pas\",systemGroupWarning:\"Les noms des groupes système sont fixes\",authDisabledTitle:\"Authentification désactivée\",authDisabledFeature1:\"Requis pour accéder au système\",authDisabledFeature2:\"Gestion multi-utilisateurs et groupes\",authDisabledFeature3:\"Plus de 50 permissions granulaires\",userHasCreated:\"Cet utilisateur a créé :\",userItemsQuestion:\"Que faire de ces éléments ?\",deleteUserConfirm:\"Supprimer cet utilisateur ?\",actionCannotBeUndone:\"Cette action est irréversible.\",addFirstSmartPlug:\"Ajoutez votre première prise\",providers:\"Fournisseurs\",log:\"Journal\",testAll:\"Tout tester\",testResults:\"Résultats du test\",testPassedCount:\"{{count}} succès\",testFailedCount:\"{{count}} échecs\",messageTemplates:\"Modèles de message\",messageTemplatesDescription:\"Personnalisez les messages par événement.\",apiKeys:\"Clés API\",apiKeysDescription:\"Créez des clés pour webhooks et API.\",createKey:\"Créer une clé\",apiKeyCreated:\"Clé API créée avec succès\",apiKeyCopyWarning:\"Copiez cette clé maintenant - elle ne sera plus affichée !\",useInApiBrowser:\"Utiliser dans l'explorateur API\",createNewApiKey:\"Nouvelle clé API\",keyName:\"Nom de la clé\",keyNamePlaceholder:\"ex: Home Assistant, OctoPrint\",readStatus:\"Lire le statut\",readStatusDescription:\"Voir les imprimantes et la file\",manageQueue:\"Gérer la file\",manageQueueDescription:\"Ajouter/retirer des éléments\",controlPrinter:\"Contrôler l'imprimante\",controlPrinterDescription:\"Pause, reprise, arrêt\",unnamedKey:\"Clé sans nom\",lastUsed:\"Dernière utilisation\",read:\"Lecture\",control:\"Contrôle\",createFirstKey:\"Créez votre première clé\",webhookEndpoints:\"Points de terminaison Webhook\",webhookApiKeyHint:\"Utilisez la clé dans l'en-tête X-API-Key.\",webhook:{getAllStatus:\"Tous les statuts\",getSpecificStatus:\"Statut spécifique\",addToQueue:\"Ajouter à la file\",pausePrint:\"Pause\",resumePrint:\"Reprise\",stopPrint:\"Arrêt\"},apiBrowser:\"Explorateur API\",apiBrowserDescription:\"Testez les endpoints API.\",apiKeyForTesting:\"Clé API pour test\",apiKeyPlaceholder:\"Collez votre clé pour tester...\",apiKeyHint:\"Sera envoyée via l'en-tête X-API-Key.\",deleteApiKeyTitle:\"Supprimer la clé API\",deleteApiKeyMessage:\"Les intégrations utilisant cette clé cesseront de fonctionner.\",deleteKey:\"Supprimer la clé\",amsDisplayThresholds:\"Seuils d'affichage AMS\",amsThresholdsDescription:\"Seuils de couleur pour humidité et température.\",humidity:\"Humidité\",goodGreen:\"Bon (vert)\",fairOrange:\"Moyen (orange)\",aboveFairBad:\"Au-dessus = rouge (mauvais)\",fairAlsoDryingThreshold:\"Ce seuil est aussi utilisé pour déclencher le séchage automatique\",temperature:\"Température\",goodBlue:\"Bon (bleu)\",aboveFairHot:\"Au-dessus = rouge (chaud)\",historyRetention:\"Rétention d'historique\",keepSensorHistory:\"Garder l'historique pendant\",historyRetentionDescription:\"Les anciennes données seront supprimées.\",defaultPrintOptions:\"Options d'impression par défaut\",defaultPrintOptionsDescription:\"Définir les valeurs par défaut des options d'impression. Modifiables dans la boîte de dialogue d'impression.\",defaultBedLevelling:\"Nivellement du lit\",defaultBedLevellingDesc:\"Niveler automatiquement le lit avant l'impression\",defaultFlowCali:\"Calibration du flux\",defaultFlowCaliDesc:\"Calibrer le flux d'extrusion\",defaultVibrationCali:\"Calibration des vibrations\",defaultVibrationCaliDesc:\"Réduire les artefacts de ringing\",defaultLayerInspect:\"Inspection première couche\",defaultLayerInspectDesc:\"Inspection IA de la première couche\",defaultTimelapse:\"Timelapse\",defaultTimelapseDesc:\"Enregistrer une vidéo timelapse\",staggeredStart:\"Staggered Start\",staggeredStartDescription:\"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.\",plateClear:\"Confirmation de plateau libre\",requirePlateClear:\"Exiger la confirmation de plateau libre\",requirePlateClearDescription:\"Lorsque cette option est activée, le planificateur attend une confirmation de plateau libre par imprimante avant de lancer les impressions en file d'attente sur les imprimantes ayant terminé. La désactiver masque également le badge d'état du plateau et le bouton « Marquer le plateau comme dégagé » sur les cartes d'imprimante.\",gcodeInjection:\"Injection de G-code\",gcodeInjectionDescription:\"Configurez du G-code personnalisé à injecter au début et/ou à la fin des impressions pour les systèmes d'auto-impression comme Farmloop, SwapMod, AutoClear et Printflow 3D. Les snippets sont configurés par modèle d'imprimante et appliqués lorsque « Injecter le G-code » est activé sur un élément de file d'attente.\",gcodeInjectionNoPrinters:\"Aucune imprimante trouvée. Ajoutez des imprimantes pour configurer les snippets G-code.\",gcodeStartLabel:\"G-code de début\",gcodeEndLabel:\"G-code de fin\",gcodeStartPlaceholder:\"G-code ajouté avant le début de l'impression...\",gcodeEndPlaceholder:\"G-code ajouté après la fin de l'impression...\",staggerGroupSize:\"Group size\",staggerGroupSizeHelp:\"Printers to start simultaneously per group\",staggerInterval:\"Interval (minutes)\",staggerIntervalHelp:\"Delay between each group starting\",queueDrying:\"Séchage automatique\",queueDryingDescription:\"Sécher automatiquement le filament AMS lorsque l'imprimante est inactive entre les impressions. Utilise le seuil d'humidité ci-dessus.\",queueDryingEnabled:\"Activer le séchage automatique\",queueDryingEnabledDescription:\"Démarrer le séchage AMS automatiquement lorsque l'imprimante est inactive et l'humidité dépasse le seuil\",queueDryingBlock:\"Attendre la fin du séchage\",queueDryingBlockDescription:\"Bloquer la file d'attente jusqu'à la fin du séchage. Désactivé, les impressions sont prioritaires.\",ambientDryingEnabled:\"Séchage ambiant\",ambientDryingEnabledDescription:\"Sécher automatiquement le filament sur les imprimantes inactives lorsque l'humidité dépasse le seuil, même sans impressions en file.\",dryingPresets:\"Préréglages de séchage\",dryingPresetsDescription:\"Température et durée par type de filament. AMS 2 Pro utilise des températures plus basses, AMS-HT supporte des températures plus élevées.\",dryingFilament:\"Filament\",printModal:\"Fenêtre d'impression\",expandCustomMapping:\"Étendre le mapping personnalisé par défaut\",expandCustomMappingDescription:\"Affiche le mapping AMS par imprimante étendu.\",authentication:\"Authentification\",authEnabledDescription:\"L'instance est sécurisée\",authDisabledDescription:\"Activez pour restreindre l'accès\",authDisabledMessage:\"Activez l'authentification pour gérer comptes et permissions.\",enableAuthentication:\"Activer l'authentification\",currentUser:\"Utilisateur actuel\",changePassword:\"Changer le mot de passe\",admin:\"Admin\",users:\"Utilisateurs\",addUser:\"Ajouter un utilisateur\",groups:\"Groupes\",addGroup:\"Ajouter un groupe\",system:\"Système\",noDescription:\"Pas de description\",userCount:\"{{count}} utilisateurs\",permissionCount:\"{{count}} permissions\",createUser:\"Créer un utilisateur\",username:\"Nom d'utilisateur\",enterUsername:\"Entrez l'utilisateur\",password:\"Mot de passe\",enterPassword:\"Mot de passe (min 6 char)\",confirmPassword:\"Confirmer le mot de passe\",confirmPasswordPlaceholder:\"Confirmez le mot de passe\",viewReleaseOnGitHub:\"Voir la version sur GitHub\",turnAllPlugsOn:\"Tout allumer\",turnAllPlugsOff:\"Tout éteindre\",clearNotificationLogs:\"Effacer les journaux\",clearLogsMessage:\"Efface définitivement les logs de plus de 30 jours.\",clearLogs:\"Effacer les logs\",resetUiPreferences:\"Réinitialiser l'UI\",resetUiPreferencesMessage:\"Réinitialise l'ordre, thème, widgets, etc. N'affecte pas vos données.\",resetPreferences:\"Réinitialiser\",deleteGroupTitle:\"Supprimer le groupe\",deleteGroupMessage:\"Les utilisateurs de ce groupe perdront ces permissions.\",deleteGroup:\"Supprimer le groupe\",disableAuthenticationTitle:\"Désactiver l'authentification\",disableAuthenticationMessage:\"Instance accessible sans connexion. Les comptes sont conservés.\",disableAuthentication:\"Désactiver\",configureBambuddy:\"Configurer Bambuddy\",systemDefault:\"Défaut système\",archiveSettings:\"Réglages Archives\",newWindow:\"Nouvelle fenêtre\",embeddedOverlay:\"Superposition intégrée\",preferredSlicer:\"Slicer préféré\",preferredSlicerDescription:\"Application pour ouvrir les fichiers\",externalCameras:\"Caméras externes\",costTracking:\"Suivi des coûts\",printsOnly:\"Impressions uniquement\",totalConsumption:\"Consommation totale\",dataManagement:\"Gestion des données\",storageUsage:\"Utilisation du stockage\",storageUsageDescription:\"Répartition de l'utilisation des données par catégorie\",storageUsageTotal:\"Total\",storageUsageErrors:\"Erreurs\",storageUsageOtherBreakdown:\"Autre (inclut ressources statiques, scripts et fichiers de configuration)\",storageUsageSystem:\"Système\",storageUsageData:\"Données\",storageUsageUnavailable:\"Informations d'utilisation du stockage non disponibles\",clearNotificationLogsDescription:\"Supprimer logs de plus de 30 jours\",resetUiPreferencesDescription:\"Réinitialise thèmes et affichage sans toucher aux données.\",enableHomeAssistant:\"Activer Home Assistant\",enableMqtt:\"Activer MQTT\",useTls:\"Utiliser TLS\",enableMetricsEndpoint:\"Activer l'endpoint Metrics\",availableMetrics:\"Metrics disponibles\",editUser:\"Modifier l'utilisateur\",deleteUserTitle:\"Supprimer l'utilisateur\",groupName:\"Nom du groupe\",leaveEmptyForAnonymous:\"Vide pour anonyme\",leaveEmptyForNoAuth:\"Vide si pas d'auth\",enterNewPassword:\"Nouveau mot de passe\",confirmNewPassword:\"Confirmer nouveau mdp\",enterGroupName:\"Entrez le nom du groupe\",enterDescriptionOptional:\"Description (optionnel)\",enterCurrentPassword:\"Mdp actuel\",enterNewPasswordMin6:\"Nouveau mdp (min 6 char)\",toast:{keyCopied:\"Clé copiée\",copyFailed:\"Échec copie\",keyAddedToBrowser:\"Clé ajoutée à l'explorateur\",clearLogsFailed:\"Échec effacement logs\",uiPreferencesReset:\"Préférences UI réinitialisées. Rafraîchissement...\",authDisabled:\"Authentification désactivée\",authDisableFailed:\"Échec désactivation\",apiKeyCreated:\"Clé API créée\",apiKeyDeleted:\"Clé API supprimée\",userCreated:\"Utilisateur créé\",userUpdated:\"Utilisateur mis à jour\",userDeleted:\"Utilisateur supprimé\",groupCreated:\"Groupe créé\",groupUpdated:\"Groupe mis à jour\",groupDeleted:\"Groupe supprimé\",fillRequiredFields:\"Remplissez les champs requis\",passwordsDoNotMatch:\"Les mots de passe ne correspondent pas\",passwordTooShort:\"Minimum 6 caractères\",enterGroupName:\"Entrez un nom de groupe\",settingsSaved:\"Paramètres enregistrés\",cameraSettingsSaved:\"Réglages caméra enregistrés\",enterCameraUrl:\"Entrez une URL caméra\",passwordChanged:\"Mot de passe modifié\",connectionFailed:\"Échec connexion\",testFailed:\"Échec test\",cameraConnected:\"Caméra connectée {{resolution}}\"},testConnection:\"Tester la connexion\",catalog:{spoolCatalog:\"Catalogue de Bobines\",spoolCatalogDescription:\"Poids des bobines vides par marque/type. Utilisé pour le calcul auto du poids restant.\",searchCatalog:\"Chercher dans le catalogue...\",addNewEntry:\"Nouvelle Entrée\",namePlaceholder:\"Nom (ex: Bambu Lab - Plastique)\",weight:\"Poids\",type:\"Type\",default:\"Défaut\",custom:\"Perso\",noMatch:\"Aucune entrée correspondante\",empty:\"Catalogue vide\",deleteEntry:\"Supprimer l'entrée\",deleteConfirm:'Supprimer \"{{name}}\" ?',resetCatalog:\"Réinitialiser le catalogue\",resetConfirm:\"Réinitialiser aux valeurs par défaut ? Vos entrées personnalisées seront supprimées.\",loadFailed:\"Échec chargement catalogue\",nameWeightRequired:\"Nom et poids requis\",entryAdded:\"Entrée ajoutée\",addFailed:\"Échec ajout\",entryUpdated:\"Entrée mise à jour\",updateFailed:\"Échec mise à jour\",entryDeleted:\"Entrée supprimée\",deleteFailed:\"Échec suppression\",resetSuccess:\"Catalogue réinitialisé\",resetFailed:\"Échec réinitialisation\",exported:\"{{count}} entrées exportées\",imported:\"{{added}} entrées importées ({{skipped}} ignorées)\",importFailed:\"Échec import : format JSON invalide\",exportTooltip:\"Exporter en JSON\",importTooltip:\"Importer depuis JSON\",resetTooltip:\"Réinitialiser par défaut\",selectedCount:\"{{count}} sélectionnés\",deleteSelected:\"Supprimer la sélection\",bulkDeleteConfirm:\"Supprimer {{count}} entrées ?\",bulkDeleted:\"{{count}} entrées supprimées\",bulkDeleteFailed:\"Échec de la suppression\"},colorCatalog:{title:\"Catalogue de Couleurs\",description:\"Couleurs de filament par fabricant. Utilisé pour la recherche auto lors de l'ajout de bobines.\",searchColors:\"Chercher couleurs...\",allManufacturers:\"Tous les fabricants\",addNewColor:\"Nouvelle Couleur\",manufacturer:\"Fabricant\",colorName:\"Nom couleur\",hex:\"Hex\",materialOptional:\"Matériau (optionnel)\",showing:\"Affichage {{filtered}} sur {{total}} couleurs\",noMatch:\"Aucune couleur correspondante\",empty:\"Catalogue vide\",deleteColor:\"Supprimer couleur\",deleteConfirm:'Supprimer \"{{name}}\" ?',resetCatalog:\"Réinitialiser catalogue couleurs\",resetConfirm:\"Réinitialiser aux valeurs par défaut ?\",sync:\"Sync\",starting:\"Démarrage...\",syncTooltip:\"Sync depuis FilamentColors.xyz (2000+ couleurs, peut être long)\",loadFailed:\"Échec chargement catalogue couleurs\",fieldsRequired:\"Fabricant, nom et code Hex requis\",colorAdded:\"Couleur ajoutée\",addFailed:\"Échec ajout\",colorUpdated:\"Couleur mise à jour\",updateFailed:\"Échec mise à jour\",colorDeleted:\"Couleur supprimée\",deleteFailed:\"Échec suppression\",resetSuccess:\"Catalogue réinitialisé\",resetFailed:\"Échec réinitialisation\",syncUpToDate:\"Déjà à jour ({{count}} couleurs vérifiées)\",syncComplete:\"{{added}} couleurs ajoutées ({{skipped}} déjà présentes)\",syncError:\"Erreur de sync\",syncFailed:\"Échec sync FilamentColors.xyz\",exported:\"{{count}} couleurs exportées\",imported:\"{{added}} couleurs importées ({{skipped}} ignorées)\",importFailed:\"Échec import : format JSON invalide\",selectedCount:\"{{count}} sélectionnés\",deleteSelected:\"Supprimer la sélection\",bulkDeleteConfirm:\"Supprimer {{count}} couleurs ?\",bulkDeleted:\"{{count}} couleurs supprimées\",bulkDeleteFailed:\"Échec de la suppression des couleurs\"},dateFormat:\"Format de date\",dateFormatUs:\"US (MM/JJ/AAAA)\",dateFormatEu:\"EU (JJ/MM/AAAA)\",dateFormatIso:\"ISO (AAAA-MM-JJ)\",timeFormat:\"Format horaire\",timeFormat12:\"12 heures (3:30 PM)\",timeFormat24:\"24 heures (15:30)\",defaultPrinter:\"Imprimante par défaut\",defaultPrinterDescription:\"Présélectionner cette imprimante pour les téléversements, réimpressions et autres opérations.\",slicerBambuStudio:\"Bambu Studio\",slicerOrcaSlicer:\"OrcaSlicer\",sidebarOrderDescription:\"Glissez les éléments dans la barre latérale pour réorganiser. Réinitialiser l'ordre par défaut ici.\",setDefault:\"Définir par défaut\",sidebarOrderSetDefaultHint:\"Définir par défaut applique l'ordre actuel du menu aux utilisateurs qui n'ont pas encore personnalisé le leur.\",sidebarDefaultSet:\"L'ordre du menu par défaut a été défini.\",sidebarDefaultCleared:\"Ordre du menu par défaut effacé.\",sidebarDefaultFailed:\"Échec de la définition de l'ordre du menu par défaut.\",reset:\"Réinitialiser\",darkMode:\"Mode sombre\",lightMode:\"Mode clair\",active:\"(actif)\",background:\"Arrière-plan\",accent:\"Accent\",style:\"Style\",bgNeutral:\"Neutre\",bgWarm:\"Chaud\",bgCool:\"Froid\",bgOled:\"OLED Noir\",bgSlate:\"Bleu ardoise\",bgForest:\"Vert forêt\",accentGreen:\"Vert\",accentTeal:\"Sarcelle\",accentBlue:\"Bleu\",accentOrange:\"Orange\",accentPurple:\"Violet\",accentRed:\"Rouge\",styleClassic:\"Classique\",styleGlow:\"Lumineux\",styleVibrant:\"Vibrant\",themeToggleHint:\"Basculer entre le mode sombre et clair avec l'icône soleil/lune dans la barre latérale.\",autoArchivePrints:\"Archiver automatiquement les impressions\",autoArchiveDescription:\"Sauvegarder automatiquement les fichiers 3MF à la fin des impressions\",saveThumbnailsDescription:\"Extraire et sauvegarder les images d'aperçu des fichiers 3MF\",captureFinishPhotoDescription:\"Prendre une photo avec la caméra de l'imprimante à la fin de l'impression\",ffmpegNotInstalled:\"ffmpeg non installé\",ffmpegRequired:\"La capture caméra nécessite ffmpeg. Installez-le via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).\",camera:\"Caméra\",cameraViewMode:\"Mode d'affichage caméra\",cameraOverlayDescription:\"La caméra s'ouvre dans un overlay redimensionnable sur l'écran principal\",cameraWindowDescription:\"La caméra s'ouvre dans une fenêtre de navigateur séparée\",externalCamerasDescription:\"Configurer des caméras externes pour remplacer la caméra intégrée. Supporte les flux MJPEG, RTSP, snapshots HTTP et caméras USB (V4L2). Lorsqu'activée, la caméra externe est utilisée pour la vue en direct et les photos de fin.\",cameraPlaceholderUsb:\"Chemin du périphérique (/dev/video0)\",cameraPlaceholderUrl:\"URL caméra (rtsp://... ou http://...)\",cameraTypeMjpeg:\"Flux MJPEG\",cameraTypeRtsp:\"Flux RTSP\",cameraTypeSnapshot:\"Snapshot HTTP\",cameraTypeUsb:\"Caméra USB (V4L2)\",cameraRotation:\"Rotation\",test:\"Tester\",connected:\"Connecté\",disconnected:\"Déconnecté\",currency:\"Devise\",defaultFilamentCost:\"Coût filament par défaut (par kg)\",electricityCost:\"Coût électricité par kWh\",energyDisplayMode:\"Mode d'affichage énergie\",energyModePrintDescription:\"Le tableau de bord affiche la somme de l'énergie utilisée pendant les impressions\",energyModeTotalDescription:\"Le tableau de bord affiche l'énergie totale des prises connectées\",fileManager:\"Gestionnaire de fichiers\",createArchiveEntry:\"Créer une entrée d'archive lors de l'impression\",createArchiveEntryDescription:\"Lors de l'impression depuis le gestionnaire de fichiers, créer optionnellement une entrée d'archive\",lowDiskSpaceWarning:\"Avertissement espace disque faible\",lowDiskSpaceDescription:\"Afficher un avertissement lorsque l'espace disque libre descend sous ce seuil\",printerFirmware:\"Firmware imprimante\",checkFirmwareDescription:\"Vérifier les mises à jour firmware de Bambu Lab\",bambuddySoftware:\"Logiciel Bambuddy\",autoCheckDescription:\"Vérifier automatiquement les nouvelles versions au démarrage\",checkNow:\"Vérifier maintenant\",updateAvailableVersion:\"Mise à jour disponible : v{{version}}\",releaseNotes:\"Notes de version\",updateViaDocker:\"Mettre à jour via Docker Compose :\",installUpdate:\"Installer la mise à jour\",latestVersionRunning:\"Vous utilisez la dernière version\",failedToCheckUpdates:\"Échec de la vérification des mises à jour : {{error}}\",backupRestore:\"Sauvegarde & Restauration\",backupRestoreDescription:\"Exporter/importer les paramètres et configurer la sauvegarde GitHub\",goToBackup:\"Aller à la sauvegarde\",externalUrl:\"URL externe\",externalUrlDescription:\"L'URL externe où Bambuddy est accessible. Utilisée pour les images de notification et les intégrations externes.\",bambuddyUrl:\"URL Bambuddy\",externalUrlHint:\"Inclure le protocole et le port (ex : http://192.168.1.100:8000)\",ftpRetry:\"Réessai FTP\",ftpRetryDescription:\"Réessayer les opérations FTP lorsque le WiFi de l'imprimante est instable. S'applique aux téléchargements 3MF, uploads d'impression, téléchargements timelapse et mises à jour firmware.\",autoRetryDescription:\"Réessayer automatiquement les opérations FTP échouées\",retryAttempts:\"Tentatives de réessai\",retryDelay:\"Délai de réessai\",connectionTimeout:\"Délai de connexion\",time_one:\"{{count}} fois\",time_other:\"{{count}} fois\",second_one:\"{{count}} seconde\",second_other:\"{{count}} secondes\",nSeconds:\"{{count}} secondes\",increaseForWeakWifi:\"Augmenter pour les imprimantes avec un WiFi faible\",homeAssistant:\"Home Assistant\",homeAssistantFullDescription:\"Se connecter à Home Assistant pour contrôler les prises connectées via l'API REST HA. Supporte les entités switch, light, input_boolean et script.\",homeAssistantUrl:\"URL Home Assistant\",longLivedAccessToken:\"Token d'accès longue durée\",haTokenHint:\"Créer un token dans HA : Profil → Tokens d'accès longue durée → Créer un token\",connectionSuccessful:\"Connexion réussie\",connectionFailed:\"Connexion échouée\",haConnectionSuccess:\"Connexion à Home Assistant réussie.\",haConnectionFailed:\"Échec de la connexion à Home Assistant.\",mqttPublishing:\"Publication MQTT\",mqttDescription:\"Publier les événements BamBuddy vers un broker MQTT externe pour l'intégration avec Node-RED, Home Assistant et d'autres systèmes d'automatisation.\",mqttEnableDescription:\"Publier les événements vers un broker MQTT externe\",brokerHostname:\"Nom d'hôte du broker\",port:\"Port\",usernameOptional:\"Nom d'utilisateur (optionnel)\",passwordOptional:\"Mot de passe (optionnel)\",topicPrefix:\"Préfixe de topic\",topicPrefixHint:\"Les topics seront : {{prefix}}/printers/<serial>/status, etc.\",prometheusMetrics:\"Métriques Prometheus\",prometheusEndpointDescription:\"Exposer les métriques imprimante sur <code>/api/v1/metrics</code> pour la surveillance Prometheus/Grafana.\",bearerTokenOptional:\"Token Bearer (optionnel)\",bearerTokenHint:\"Si défini, les requêtes doivent inclure <code>Authorization: Bearer <token></code>\",metricsConnectionStatus:\"État de connexion\",metricsPrinterState:\"État imprimante (idle/printing/etc)\",metricsPrintProgress:\"Progression impression 0-100%\",metricsBedTemp:\"Température du plateau\",metricsNozzleTemp:\"Température de la buse\",metricsPrintsTotal:\"Total impressions par résultat\",metricsMore:\"...et plus (couches, ventilateurs, file d'attente, utilisation filament)\",smartPlugsDescription:\"Connecter des prises connectées (Tasmota ou Home Assistant) pour automatiser le contrôle de l'alimentation et suivre la consommation d'énergie de vos imprimantes.\",allOn:\"Tout allumer\",allOff:\"Tout éteindre\",addSmartPlug:\"Ajouter une prise\",energySummary:\"Résumé énergétique\",currentPower:\"Puissance actuelle\",plugsOnline:\"{{reachable}}/{{total}} prises en ligne\",today:\"Aujourd'hui\",yesterday:\"Hier\",total:\"Total\",enablePlugsForSummary:\"Activer les prises pour voir le résumé énergétique\",addNotificationProvider:\"Ajouter\",systemBadge:\"(Système)\",creating:\"Création...\",changing:\"Modification...\",deleteUserAndItems:\"Supprimer l'utilisateur ET ses éléments\",deleteUserKeepItems:\"Supprimer l'utilisateur, garder les éléments (deviennent sans propriétaire)\",ok:\"OK\",twoFa:{totpTitle:\"Application Authenticator (TOTP)\",totpDesc:\"Utilisez une application comme Google Authenticator, Aegis ou Authy.\",emailOtpTitle:\"OTP par e-mail\",emailOtpDesc:\"Envoyez un code à usage unique à {{email}} lors de la connexion.\",emailOtpNoEmail:\"Ajoutez une adresse e-mail à votre compte pour activer cette méthode.\",addEmailFirst:\"Votre compte n'a pas d'adresse e-mail. Demandez à un administrateur d'en ajouter une.\",setupTotp:\"Configurer l'application Authenticator\",setupAuthApp:\"Configurer l'application Authenticator\",setupInstructions:\"Scannez le QR code avec votre application authenticator, puis confirmez avec un code.\",manualEntry:\"Impossible de scanner ? Entrez ce secret manuellement :\",scannedContinue:\"Code scanné — continuer\",enterCodeToConfirm:\"Entrez le code à 6 chiffres de votre application authenticator pour confirmer.\",activate:\"Activer\",disableTotp:\"Désactiver l'Authenticator\",disableConfirmHint:\"Entrez un code TOTP valide ou un code de secours pour désactiver l'authenticator.\",totpDisabled:\"Application Authenticator désactivée.\",emailOtpEnabled:\"OTP par e-mail activé.\",emailOtpDisabled:\"OTP par e-mail désactivé.\",smtpRequired:\"Veuillez d'abord configurer et tester les paramètres SMTP.\",invalidCode:\"Code invalide. Veuillez réessayer.\",enableEmailOtp:\"Activer OTP par e-mail\",disableEmailOtp:\"Désactiver OTP par e-mail\",emailSetupEnterCode:\"Un code de vérification a été envoyé à votre adresse e-mail. Entrez-le ci-dessous pour confirmer que vous possédez cette boîte de réception.\",verifyAndEnable:\"Vérifier et activer\",emailDisablePasswordHint:\"Entrez le mot de passe de votre compte pour confirmer la désactivation de l'OTP par e-mail.\",passwordPlaceholder:\"Entrez votre mot de passe\",backupCodesTitle:\"Sauvegardez vos codes de secours\",backupCodesWarning:\"Conservez ces codes en lieu sûr. Chaque code ne peut être utilisé qu'une seule fois et ne sera plus affiché.\",backupCodesRemaining:\"{{count}} codes de secours restants\",savedCodes:\"Codes sauvegardés\",regenBackup:\"Régénérer les codes de secours\",regenBackupHint:\"Entrez votre code TOTP actuel pour générer 10 nouveaux codes de secours. Tous les codes existants seront invalidés.\",newBackupCodes:\"Nouveaux codes de secours\",linkedAccounts:\"Comptes SSO liés\",linkedAccountsDesc:\"Ces fournisseurs d'identité externes sont liés à votre compte.\",oidcUnlinked:\"Compte dissocié.\"},oidc:{title:\"Fournisseurs SSO / OIDC\",desc:\"Configurez des fournisseurs OpenID Connect pour l'authentification unique.\",addProvider:\"Ajouter un fournisseur\",newProvider:\"Nouveau fournisseur\",empty:\"Aucun fournisseur OIDC configuré.\",created:\"Fournisseur créé.\",updated:\"Fournisseur mis à jour.\",deleted:\"Fournisseur supprimé.\",deleteTitle:\"Supprimer le fournisseur\",deleteMessage:'Supprimer \"{{name}}\" ? Tous les comptes liés seront déconnectés.',form:{name:\"Nom d'affichage\",issuerUrl:\"URL de l'émetteur\",clientId:\"ID client\",clientSecret:\"Secret client\",scopes:\"Scopes\",iconUrl:\"URL de l'icône (optionnel)\",enabled:\"Activé\",autoCreate:\"Créer les utilisateurs automatiquement\",autoCreateDesc:\"Crée automatiquement un compte local lors de la première connexion.\",autoLink:\"Lier automatiquement les comptes existants\",autoLinkDesc:\"Lie les comptes locaux existants par e-mail lors de la première connexion.\",secretHint:\"laisser vide pour conserver\",secretPlaceholder:\"nouveau secret\"}}},notification:{printStarted:{title:\"Impression démarrée\",body:\"{{printer}} : {{filename}} commence l'impression\"},printCompleted:{title:\"Impression terminée\",body:\"{{printer}} : {{filename}} a réussi\"},printFailed:{title:\"Impression échouée\",body:\"{{printer}} : {{filename}} a échoué\"},printStopped:{title:\"Impression arrêtée\",body:\"{{printer}} : {{filename}} a été arrêtée\"},printProgress:{title:\"Progression d'impression\",body:\"{{printer}} : {{filename}} est à {{percent}}%\"},printerOffline:{title:\"Imprimante hors ligne\",body:\"{{printer}} est déconnectée\"},printerError:{title:\"Erreur imprimante\",body:\"{{printer}} : {{error}}\"},filamentLow:{title:\"Filament bas\",body:\"{{printer}} : Le filament est presque vide\"},maintenanceDue:{title:\"Maintenance due\",body:\"{{printer}} : {{items}} demandent votre attention\"}},errors:{generic:\"Une erreur est survenue\",networkError:\"Erreur réseau. Vérifiez votre connexion.\",notFound:\"Non trouvé\",unauthorized:\"Non autorisé\",serverError:\"Erreur serveur\",validationError:\"Vérifiez vos saisies\",printerConnectionFailed:\"Échec connexion imprimante\",saveFailed:\"Échec enregistrement\",deleteFailed:\"Échec suppression\",loadFailed:\"Échec chargement\"},hmsErrors:{title:\"Erreurs - {{name}}\",noErrors:\"Aucune erreur\",viewOnWiki:\"Voir sur le Wiki Bambu Lab\",clearInstructions:\"Effacez les erreurs sur l'imprimante pour les retirer ici.\",clearErrors:\"Effacer les erreurs\",clearSuccess:\"Erreurs HMS effacées\",clearFailed:\"Échec de l'effacement des erreurs HMS\"},mqttDebug:{title:\"Journal de débogage MQTT\",searchPlaceholder:\"Chercher topic ou message...\",noMessages:\"Aucun message enregistré\",startLoggingHint:'Cliquez sur \"Démarrer\" pour capturer les messages MQTT',noMessagesMatch:\"Aucun message ne correspond\",adjustFilterHint:\"Ajustez votre recherche\",incoming:\"Entrant\",outgoing:\"Sortant\",loggingStopped:\"Enregistrement arrêté\",loggingActive:\"Enregistrement actif - rafraîchissement auto\",startLogging:\"Démarrer\",stopLogging:\"Arrêter\",clearLog:\"Effacer le journal\",topic:\"Topic\",timestamp:\"Horodatage\",direction:\"Direction\",all:\"Tous\"},printerFiles:{title:\"Gestionnaire de fichiers\",storageUsed:\"Utilisé :\",storageFree:\"Libre :\",filterPlaceholder:\"Filtrer les fichiers...\",deleteButton:\"Supprimer\",deleteFiles:\"Supprimer {{count}} fichiers\",deleteFileConfirm:'Supprimer \"{{name}}\" ?',deleteFilesConfirm:\"Supprimer les {{count}} fichiers sélectionnés ?\",noFiles:\"Aucun fichier sur l'imprimante\",loadingFiles:\"Chargement...\",failedToLoad:\"Échec chargement fichiers\",toast:{filesDeleted:\"{{count}} fichier(s) supprimé(s)\",deleteFailed:\"Échec suppression : {{error}}\"}},confirm:{delete:\"Voulez-vous vraiment supprimer cet élément ?\",unsavedChanges:\"Changements non sauvegardés. Voulez-vous quitter ?\",clearQueue:\"Voulez-vous vraiment vider la file d'attente ?\"},login:{title:\"Connexion Bambuddy\",subtitle:\"Connectez-vous à votre compte\",username:\"Utilisateur\",usernamePlaceholder:\"Entrez votre utilisateur\",usernameOrEmail:\"Utilisateur ou Email\",usernameOrEmailPlaceholder:\"Utilisateur ou @ Email\",password:\"Mot de passe\",passwordPlaceholder:\"Entrez votre mot de passe\",signIn:\"Se connecter\",signingIn:\"Connexion...\",forgotPassword:\"Mot de passe oublié ?\",loginSuccess:\"Connecté avec succès\",loginFailed:\"Échec de connexion\",enterCredentials:\"Entrez vos identifiants\",enterEmail:\"Veuillez entrer votre adresse e-mail\",oidcLoginFailed:\"Échec de la connexion OIDC\",oidcErrors:{providerError:\"Le fournisseur d'identité a renvoyé une erreur\",missingParameters:\"Il manque des paramètres requis dans le callback OIDC\",invalidState:\"L'état OIDC est invalide ou a déjà été utilisé\",stateExpired:\"La session OIDC a expiré — veuillez réessayer\",providerNotFound:\"Fournisseur OIDC introuvable\",discoveryFailed:\"Impossible de récupérer le document de découverte OIDC\",invalidDiscovery:\"Le document de découverte OIDC est invalide\",networkError:\"Erreur réseau lors de l'échange de jeton OIDC\",badResponse:\"Réponse inattendue lors de l'échange de jeton OIDC\",noIdToken:\"Le fournisseur OIDC n'a pas renvoyé de jeton d'identité\",validationFailed:\"La validation du jeton OIDC a échoué\",nonceMismatch:\"Le nonce OIDC ne correspond pas — possible attaque par rejeu\",missingSubClaim:\"Le jeton OIDC est dépourvu de la revendication sub\",noLinkedAccount:\"Aucun compte local est lié à cette identité OIDC\",accountInactive:\"Votre compte est inactif\",userResolutionFailed:\"Impossible de résoudre votre compte\",internalError:\"Une erreur interne est survenue lors de la connexion OIDC\",tokenExchangeFailed:\"L'échange de jeton OIDC a échoué\"},forgotPasswordTitle:\"Mot de passe oublié\",forgotPasswordMessage:\"Contactez votre administrateur pour réinitialiser votre accès.\",forgotPasswordEmailMessage:\"Entrez votre email pour recevoir un nouveau mot de passe.\",emailAddress:\"Adresse Email\",emailPlaceholder:\"votre.email@exemple.com\",cancel:\"Annuler\",sending:\"Envoi...\",sendResetEmail:\"Envoyer l'email\",howToReset:\"Comment réinitialiser :\",resetStep1:\"Contactez votre admin Bambuddy\",resetStep2:\"Demandez une réinitialisation dans la Gestion Utilisateurs\",resetStep3:\"Il vous donnera un mot de passe temporaire\",resetStep4:\"Connectez-vous et changez-le dans les Paramètres\",gotIt:\"Compris\",twoFA:{title:\"Authentification à deux facteurs\",subtitle:\"Votre compte est protégé par la 2FA. Saisissez le code de vérification ci-dessous.\",methodAuthenticator:\"Application d'authentification\",methodEmail:\"Code par e-mail\",methodBackup:\"Code de récupération\",instructionsTotp:\"Ouvrez votre application d'authentification et saisissez le code à 6 chiffres pour Bambuddy.\",instructionsEmail:\"Un code à 6 chiffres a été envoyé à votre adresse e-mail. Il est valable 10 minutes.\",instructionsEmailNotSent:\"Cliquez ci-dessous pour recevoir un code de vérification par e-mail.\",instructionsBackup:\"Saisissez l'un de vos codes de récupération à 8 caractères. Chaque code ne peut être utilisé qu'une seule fois.\",sendCodeButton:\"Envoyer le code par e-mail\",sendingCode:\"Envoi en cours...\",resendCode:\"Renvoyer le code\",codeLabel:\"Code de vérification\",backupCodeLabel:\"Code de récupération\",codePlaceholder:\"000000\",backupCodePlaceholder:\"XXXXXXXX\",verifyButton:\"Vérifier\",verifyingButton:\"Vérification en cours...\",backToLogin:\"← Retour à la connexion\",orContinueWith:\"ou continuer avec\",signInWith:\"Se connecter avec {{provider}}\",enterCode:\"Veuillez entrer le code de vérification\",sendCodeFailed:\"Échec de l'envoi du code de vérification\",invalidCode:\"Code invalide. Veuillez réessayer.\"}},setup:{title:\"Configuration Bambuddy\",subtitle:\"Configurez l'authentification\",enableAuth:\"Activer l'authentification\",adminAccount:\"Compte Admin\",adminAccountDesc:\"Si des admins existent, ils seront utilisés. Sinon, créez-en un ci-dessous.\",adminUsername:\"Utilisateur Admin\",adminPassword:\"Mot de passe Admin\",optionalIfAdminExists:\"(optionnel si admin existe)\",adminUsernamePlaceholder:\"Nom admin (optionnel)\",adminPasswordPlaceholder:\"Mdp admin (optionnel)\",confirmPassword:\"Confirmer mdp\",confirmPasswordPlaceholder:\"Confirmez mdp admin\",settingUp:\"Configuration...\",completeSetup:\"Terminer la configuration\",toast:{authEnabledAdminCreated:\"Authentification activée et admin créé\",authEnabledExistingAdmins:\"Authentification activée avec admins existants\",setupCompleted:\"Configuration terminée\",enterBothCredentials:\"Entrez les deux ou laissez vide pour utiliser les admins existants\",passwordsDoNotMatch:\"Les mots de passe ne correspondent pas\",passwordTooShort:\"Minimum 6 caractères\"}},changePassword:{title:\"Changer le mot de passe\",currentPassword:\"Mot de passe actuel\",currentPasswordPlaceholder:\"Entrez mdp actuel\",newPassword:\"Nouveau mot de passe\",newPasswordPlaceholder:\"Nouveau mdp (min 6 char)\",confirmPassword:\"Confirmer nouveau mdp\",confirmPasswordPlaceholder:\"Confirmez nouveau mdp\",passwordsDoNotMatch:\"Les mots de passe ne correspondent pas\",passwordTooShort:\"Minimum 6 caractères\",changing:\"Changement...\",success:\"Mot de passe modifié\",failed:\"Échec modification\"},plateAlert:{title:\"Impression en Pause !\",message:\"Objets détectés sur le plateau. L'impression a été suspendue automatiquement. Videz le plateau avant de reprendre.\",understand:\"J'ai compris\"},camera:{title:\"Vue Caméra\",invalidPrinterId:\"ID imprimante invalide\",live:\"Direct\",snapshot:\"Instantané\",restartStream:\"Redémarrer le flux\",refreshSnapshot:\"Rafraîchir l'image\",fullscreen:\"Plein écran\",exitFullscreen:\"Quitter plein écran\",connectingToCamera:\"Connexion caméra...\",capturingSnapshot:\"Capture...\",connectionLost:\"Connexion perdue\",connectionFailed:\"Échec connexion caméra\",reconnecting:\"Reconnexion dans {{countdown}}s... (essai {{attempt}}/{{max}})\",reconnectNow:\"Reconnexion immédiate\",cameraUnavailable:\"Caméra indisponible\",cameraUnavailableDesc:\"Vérifiez que l'imprimante est allumée.\",noCamera:\"Aucune caméra disponible\",retry:\"Réessayer\",cameraStream:\"Flux caméra\",zoomOut:\"Zoom arrière\",zoomIn:\"Zoom avant\",resetZoom:\"Réinitialiser zoom\",recording:\"Enregistrement\",startRecording:\"Démarrer l'enregistrement\",stopRecording:\"Arrêter l'enregistrement\",chamberLight:\"Basculer lumière chambre\"},groups:{title:\"Gestion des Groupes\",subtitle:\"Gérez les permissions pour le contrôle d'accès\",backToSettings:\"Retour aux paramètres\",createGroup:\"Créer un groupe\",noPermission:\"Accès refusé.\",system:\"Système\",noDescription:\"Pas de description\",usersCount:\"{{count}} utilisateurs\",permissionsCount:\"{{count}} permissions\",edit:\"Modifier\",delete:\"Supprimer\",toast:{created:\"Groupe créé\",updated:\"Groupe mis à jour\",deleted:\"Groupe supprimé\",enterGroupName:\"Entrez un nom de groupe\"},modal:{editGroup:\"Modifier le groupe\",createGroup:\"Créer un groupe\",cancel:\"Annuler\",saving:\"Enregistrement...\",creating:\"Création...\",saveChanges:\"Enregistrer\"},form:{groupName:\"Nom du groupe\",groupNamePlaceholder:\"Nom du groupe\",systemGroupWarning:\"Les groupes système sont fixes\",description:\"Description\",descriptionPlaceholder:\"Description (optionnel)\",permissions:\"Permissions ({{count}} sélectionnées)\"},deleteModal:{title:\"Supprimer le groupe\",message:\"Les utilisateurs de ce groupe perdront ces permissions.\",confirm:\"Supprimer\"},editor:{title:\"Modifier le groupe\",createTitle:\"Créer un groupe\",search:\"Rechercher des permissions...\",selectAll:\"Tout sélectionner\",clearAll:\"Tout désélectionner\",permissionsSelected:\"{{count}} sélectionnée(s)\",noResults:\"Aucune permission ne correspond à votre recherche\"}},users:{title:\"Gestion des Utilisateurs\",subtitle:\"Gérez les accès à Bambuddy\",backToSettings:\"Retour aux paramètres\",createUser:\"Créer un utilisateur\",noPermission:\"Accès refusé.\",admin:\"Admin\",noGroups:\"Aucun groupe\",active:\"Actif\",inactive:\"Inactif\",edit:\"Modifier\",delete:\"Supprimer\",system:\"Système\",noGroupsAvailable:\"Aucun groupe disponible\",table:{username:\"Utilisateur\",groups:\"Groupes\",status:\"Statut\",actions:\"Actions\"},toast:{created:\"Utilisateur créé\",updated:\"Utilisateur mis à jour\",deleted:\"Utilisateur supprimé\",fillRequired:\"Remplissez les champs requis\",passwordsDoNotMatch:\"Les mots de passe ne correspondent pas\",passwordTooShort:\"Minimum 6 caractères\"},modal:{createUser:\"Créer utilisateur\",editUser:\"Modifier utilisateur\",cancel:\"Annuler\",creating:\"Création...\",saving:\"Enregistrement...\",saveChanges:\"Enregistrer\",advancedAuthSubtitle:\"avec Authentification Avancée\"},form:{username:\"Utilisateur\",usernamePlaceholder:\"Nom utilisateur\",email:\"Email\",emailPlaceholder:\"utilisateur@exemple.com\",password:\"Mot de passe\",passwordPlaceholder:\"Mot de passe\",confirmPassword:\"Confirmer mdp\",confirmPasswordPlaceholder:\"Confirmez mdp\",newPasswordPlaceholder:\"Nouveau mdp\",confirmNewPasswordPlaceholder:\"Confirmez nouveau mdp\",leaveBlankToKeep:\"Laissez vide pour conserver l'actuel\",groups:\"Groupes\",optional:\"optionnel\",autoGeneratedPassword:\"Un mot de passe sera généré et envoyé par email.\",passwordManagedByAdvancedAuth:'Géré par Auth Avancée. Utilisez \"Réinitialiser\" pour envoyer un nouveau mdp par email.',resetPassword:\"Réinitialiser le mot de passe\",resettingPassword:\"Réinitialisation...\"},deleteModal:{title:\"Supprimer utilisateur\",message:\"Cette action est irréversible.\",confirm:\"Supprimer\"}},streamOverlay:{title:\"Superposition Flux\",invalidPrinterId:\"ID invalide\",cameraStream:\"Flux caméra\",progress:\"Progression\",eta:\"Fin estimée\",printerIdle:\"Imprimante inactive\",printerOffline:\"Imprimante hors ligne\",status:{printing:\"Impression\",paused:\"En pause\",finished:\"Terminée\",failed:\"Échouée\",idle:\"Inactive\",unknown:\"Inconnue\"}},profiles:{title:\"Profils\",subtitle:\"Gérez vos presets slicer et calibrations Pressure Advance\",tabs:{cloud:\"Profils Cloud\",local:\"Profils Locaux\",kprofiles:\"K-Profiles\"},localProfiles:{title:\"Profils Locaux\",subtitle:\"Gérez vos presets OrcaSlicer\",import:\"Importer Profils\",importDesc:\"Déposez les fichiers .bbscfg, .bbsflmt, .orca_filament, .zip ou .json\",importing:\"Importation...\",search:\"Chercher un preset...\",noPresets:\"Aucun preset local\",badge:\"Local\",edit:\"Modifier\",delete:\"Supprimer\",cancel:\"Annuler\",deleteConfirmTitle:\"Supprimer Preset\",deleteConfirm:\"Supprimer définitivement ce preset ?\",source:\"Source\",inheritsFrom:\"Hérite de\",filamentType:\"Type\",vendor:\"Vendeur\",compatiblePrinters:\"Imprimantes\",nozzleTemp:\"Temp Buse\",cost:\"Coût\",density:\"Densité\",pressureAdvance:\"Pressure Advance\",filament:\"Filament\",process:\"Processus\",printer:\"Imprimante\",toast:{importSuccess:\"{{count}} profil(s) importé(s)\",importSkipped:\"{{count}} profil(s) ignoré(s) (doublons)\",importError:\"{{count}} erreur(s) d'import\",deleted:\"Preset supprimé\",updated:\"Preset mis à jour\"}},connectedAs:\"Connecté en tant que\",logout:\"Déconnexion\",noLogoutPermission:\"Pas d'autorisation de déconnexion\",failedToLoad:\"Échec chargement profils\",retry:\"Réessayer\",time:{justNow:\"À l'instant\",minsAgo:\"Il y a {{count}}m\",hoursAgo:\"Il y a {{count}}h\",daysAgo:\"Il y a {{count}}j\"},toast:{loggedOut:\"Déconnecté\"},login:{title:\"Connexion Bambu Cloud\",subtitle:\"Synchronisez vos presets slicer\",email:\"Email\",password:\"Mot de passe\",region:\"Région\",regionGlobal:\"Global\",regionChina:\"Chine\",verificationCode:\"Code de vérification\",totpCode:\"Code Authenticator\",checkEmail:\"Code envoyé à {{email}}\",enterTotpHint:\"Entrez le code 2FA\",accessToken:\"Jeton d'accès (Access Token)\",accessTokenHint:\"Collez le jeton (depuis Bambu Studio)\",back:\"Retour\",loginButton:\"Connexion\",verifyButton:\"Vérifier\",setTokenButton:\"Définir Jeton\",useToken:\"Utiliser jeton d'accès\",useEmail:\"Connexion par email\",toast:{loggedIn:\"Connecté avec succès\",codeSent:\"Code envoyé par email\",enterTotp:\"Entrez le code Authenticator\",tokenSet:\"Jeton défini\"}},presets:{myPreset:\"Mon preset (modifiable)\",duplicate:\"Dupliquer\",editable:\"Modifiable\",failedToLoadDetails:\"Échec détails preset\",deleteConfirm:\"Supprimer ce preset ?\",deleteWarning:'Ceci supprimera \"{{name}}\" de Bambu Cloud définitivement.',noDuplicatePermission:\"Pas d'autorisation duplication\",noEditPermission:\"Pas d'autorisation modification\",noDeletePermission:\"Pas d'autorisation suppression\",types:{filament:\"Preset filament\",printer:\"Preset imprimante\",process:\"Preset processus\"},toast:{deleted:\"Preset supprimé\",created:\"Preset créé\",updated:\"Preset mis à jour\",duplicated:\"Preset dupliqué\",fieldAdded:'Champ \"{{key}}\" ajouté',exported:\"Preset exporté\"},baseLabel:\"Base : {{name}}\",currentLabel:\"Actuel : {{name}}\",newPreset:\"Nouveau Preset\",editPreset:\"Modifier Preset\",duplicatePreset:\"Dupliquer Preset\",createNewPreset:\"Créer un nouveau Preset\",customizeSettings:\"Personnalisez vos réglages\",compareWithBase:\"Comparer avec la base\",compare:\"Comparer\",basePreset:\"Preset de base\",selectBasePreset:\"Choisir preset de base...\",presetName:\"Nom du preset\",myCustomPreset:\"Mon preset personnalisé\",inheritsFrom:\"Hérite de\",dropJsonToImport:\"Glissez JSON pour importer\",tabs:{common:\"Commun\",allFields:\"Tous les champs\"},availableFields:\"Champs disponibles\",searchFieldsPlaceholder:\"Chercher un champ...\",noMatchingFields:\"Aucun champ trouvé\",allFieldsAdded:\"Tous les champs sont ajoutés\",addCustomField:\"Ajouter un champ personnalisé\",yourOverrides:\"Vos modifications\",noOverridesYet:\"Aucune modification\",clickFieldsToAdd:\"Cliquez à gauche pour ajouter\",saveAsTemplate:\"Enregistrer comme modèle\",jsonTip:\"Conseil : Glissez un .json pour importer les réglages\"},cloudView:{searchPlaceholder:\"Chercher presets...\",templates:\"Modèles\",refresh:\"Rafraîchir\",newPreset:\"Nouveau Preset\",clearFilters:\"Effacer filtres\",compareMode:\"Mode Comparaison\",selectAnotherPreset:\"Choisir un autre preset {{type}}\",clickTwoPresets:\"Choisissez deux presets de même type\",selectFirst:\"1. Sélectionner premier\",selectSecond:\"2. Sélectionner second\",compareNow:\"Comparer maintenant\",lastSynced:\"Synchronisé :\",showingCount:\"{{showing}} sur {{total}} presets\",noPresetsFound:\"Aucun preset trouvé\",columns:{filament:\"Filament\",process:\"Processus\",printer:\"Imprimante\"},noFilamentPresets:\"Pas de preset filament\",noProcessPresets:\"Pas de preset processus\",noPrinterPresets:\"Pas de preset imprimante\",filters:{type:\"Type\",owner:\"Propriétaire\",printer:\"Imprimante\",nozzle:\"Buse\",filament:\"Filament\",layer:\"Couche\",all:\"Tous\",myPresets:\"Mes Presets\",builtIn:\"Inclus\",process:\"Processus\"},noTemplatesPermission:\"Pas d'autorisation modèles\",noRefreshPermission:\"Pas d'autorisation rafraîchissement\",noCreatePermission:\"Pas d'autorisation création\"},templates:{title:\"Modèles rapides\",noTemplates:\"Aucun modèle\",createFirst:\"Créez-en depuis l'éditeur de preset\",typeFilter:\"Type :\",deleteTitle:\"Supprimer modèle\",deleteWarning:\"Action irréversible\",deleteConfirm:'Supprimer \"{{name}}\" ?',namePlaceholder:\"Nom du modèle\",descriptionPlaceholder:\"Description\",settingsJson:\"Paramètres (JSON)\",fieldsCount:\"{{count}} champs\",shownInModals:\"Visible dans fenêtres\",hiddenInModals:\"Masqué dans fenêtres\",apply:\"Appliquer\",toast:{deleted:\"Modèle supprimé\",updated:\"Modèle mis à jour\",created:\"Modèle créé\",applied:\"Modèle appliqué\"}}},support:{debugLoggingActive:\"Débogage actif\",manageLogs:\"Gérer\",collectItem7:\"Connectivité et versions firmware\",collectItem8:\"Statut intégrations (Spoolman, MQTT, HA)\",collectItem9:\"Interfaces réseau (sous-réseaux)\",collectItem10:\"Versions packages Python\",collectItem11:\"Santé base de données\",collectItem12:\"Détails environnement Docker\"},fileManager:{title:\"Gestionnaire de fichiers\",subtitle:\"Organisez vos fichiers d'impression\",uploadFiles:\"Téléverser fichiers\",newFolder:\"Nouveau dossier\",folderName:\"Nom du dossier\",folderNamePlaceholder:\"ex: Pièces Utiles\",renameFile:\"Renommer fichier\",renameFolder:\"Renommer dossier\",moveFiles:\"Déplacer {{count}} fichier(s)\",rootNoFolder:\"Racine (aucun dossier)\",current:\"actuel\",linkFolder:\"Lier le dossier\",linkFolderDescription:'Lier \"{{name}}\" à un projet ou archive.',project:\"Projet\",archive:\"Archive\",noProjectsFound:\"Aucun projet trouvé\",noArchivesFound:\"Aucune archive trouvée\",unlink:\"Délier\",link:\"Lier\",dragDropFiles:\"Glissez les fichiers ici\",dropFilesHere:\"Déposez ici\",orClickToBrowse:\"ou cliquez pour parcourir\",allFileTypesSupported:\"Tous types supportés. ZIP extraits.\",zipFilesDetected:\"ZIP détectés\",zipExtractOptions:\"Choix de structure pour ZIP :\",preserveZipStructure:\"Garder structure ZIP\",createFolderFromZip:\"Dossier par nom du ZIP\",stlThumbnailGeneration:\"Vignettes STL\",zipMayContainStl:\"Extraction vignettes possible pour STL.\",thumbnailsCanBeGenerated:\"Génération vignettes (peut être long).\",generateThumbnailsForStl:\"Générer vignettes STL\",threemfDetected:\"Fichiers 3MF détectés\",threemfExtractionInfo:\"Réglages extraits auto du 3MF.\",willBeExtracted:\"Sera extrait\",filesExtracted:\"{{count}} fichiers extraits\",uploadComplete:\"Terminé : {{succeeded}} succès\",uploadFailed:\"Échec du téléversement\",zipFilesFailed:\"{{count}} fichiers échoués\",uploading:\"Téléversement...\",changeLink:\"Modifier lien...\",linkTo:\"Lier à...\",linkToProjectOrArchive:\"Lier à projet ou archive\",addToQueue:\"Ajouter à la file\",schedulePrint:\"Planifier\",generateThumbnail:\"Générer vignette\",generateThumbnails:\"Générer vignettes\",generateThumbnailsForMissing:\"Vignettes STL manquantes\",gridView:\"Grille\",listView:\"Liste\",lowDiskSpaceWarning:\"Espace disque faible\",lowDiskSpaceDetails:\"{{free}} libre sur {{total}}. Seuil : {{threshold}} Go.\",files:\"Fichiers\",folders:\"Dossiers\",size:\"Taille\",free:\"Libre\",allFiles:\"Tous les fichiers\",wrap:\"Retour ligne\",enableTextWrapping:\"Activer retour ligne\",disableTextWrapping:\"Désactiver retour ligne\",collapse:\"Réduire\",collapseFoldersByDefault:\"Réduire les dossiers par défaut\",expandFoldersByDefault:\"Développer les dossiers par défaut\",dragToResizeTooltip:\"Glisser pour redimensionner, double-clic reset\",searchFiles:\"Chercher fichiers...\",allTypes:\"Tous types\",prints:\"Impressions\",ascending:\"Croissant\",descending:\"Décroissant\",resultsCount:\"{{showing}} sur {{total}} fichiers\",selectAll:\"Tout sélectionner\",deselectAll:\"Tout désélectionner\",selected:\"{{count}} sélectionnés\",adding:\"Ajout...\",loadingFiles:\"Chargement...\",folderIsEmpty:\"Dossier vide\",noFilesYet:\"Aucun fichier\",folderEmptyDescription:\"Téléversez ou déplacez des fichiers ici.\",noFilesDescription:\"Téléversez des fichiers pour organiser.\",noMatchingFiles:\"Aucun fichier correspondant\",noMatchingFilesDescription:\"Ajustez votre recherche.\",clearFilters:\"Effacer filtres\",printedCount:\"Imprimé {{count}}x\",uploadedBy:\"Téléversé par\",deleteFolder:\"Supprimer dossier\",deleteFile:\"Supprimer fichier\",deleteFilesCount:\"Supprimer {{count}} fichiers\",deleteFolderConfirm:\"Supprimer le dossier et son contenu ?\",deleteFileConfirm:\"Supprimer ce fichier ?\",deleteFilesConfirm:\"Supprimer {{count}} fichiers définitivement ?\",deleting:\"Suppression...\",noPermissionRenameFolder:\"Pas d'autorisation renommage\",noPermissionLinkFolder:\"Pas d'autorisation lien\",noPermissionDeleteFolder:\"Pas d'autorisation suppression dossier\",noPermissionPrint:\"Pas d'autorisation impression\",noPermissionAddToQueue:\"Pas d'autorisation file\",noPermissionDownload:\"Pas d'autorisation téléchargement\",noPermissionRenameFile:\"Pas d'autorisation renommage fichier\",noPermissionGenerateThumbnail:\"Pas d'autorisation vignettes\",noPermissionDeleteFile:\"Pas d'autorisation suppression fichier\",noPermissionCreateFolder:\"Pas d'autorisation nouveau dossier\",noPermissionUpload:\"Pas d'autorisation téléversement\",noPermissionMoveFiles:\"Pas d'autorisation déplacement\",noPermissionDeleteFiles:\"Pas d'autorisation suppression groupée\",linkExternal:\"Lier externe\",linkExternalFolder:\"Lier un dossier externe\",linkExternalFolderDescription:\"Monter un répertoire hôte (NAS, USB, partage réseau) dans le gestionnaire de fichiers. Les fichiers ne sont pas copiés — ils sont lus directement depuis le chemin d'origine.\",externalFolderNamePlaceholder:\"ex. Impressions NAS\",externalPath:\"Chemin hôte\",externalPathHelp:\"Chemin absolu du répertoire sur l'hôte Docker. Doit être monté en bind dans le conteneur.\",readOnly:\"Lecture seule\",readOnlyHelp:\"empêche les téléversements et suppressions\",showHiddenFiles:\"Afficher les fichiers cachés (fichiers point)\",externalFolder:\"Dossier externe\",scanFolder:\"Scanner\",toast:{folderCreated:\"Dossier créé\",folderDeleted:\"Dossier supprimé\",fileDeleted:\"Fichier supprimé\",filesDeleted:\"{{count}} fichiers supprimés\",filesMoved:\"Fichiers déplacés\",folderLinked:\"Dossier lié\",folderUnlinked:\"Dossier délié\",externalFolderLinked:\"Dossier externe lié et scanné\",folderScanned:\"Scan terminé : {{added}} ajoutés, {{removed}} supprimés\",addedToQueue:\"{{count}} fichier(s) ajouté(s)\",addedToQueuePartial:\"{{added}} ajoutés, {{failed}} échecs\",failedToAddToQueue:\"Échec ajout file : {{error}}\",fileRenamed:\"Fichier renommé\",folderRenamed:\"Dossier renommé\",thumbnailsGenerated:\"{{count}} vignette(s) générée(s)\",thumbnailsGeneratedPartial:\"{{succeeded}} succès, {{failed}} échecs\",noStlMissingThumbnails:\"Aucun STL sans vignette\",failedToGenerateThumbnails:\"Échec vignettes : {{error}}\",thumbnailGenerated:\"Vignette générée\",failedToGenerateThumbnail:\"Échec vignette : {{error}}\"}},projects:{title:\"Projets\",subtitle:\"Suivez vos projets d'impression 3D\",newProject:\"Nouveau Projet\",editProject:\"Modifier Projet\",deleteProject:\"Supprimer Projet\",projectName:\"Nom du Projet\",description:\"Description\",noProjects:\"Aucun projet\",noProjectsFiltered:\"Aucun projet {{status}}\",noProjectsFilteredHelp:\"Les projets apparaîtront ici quand leur statut changera.\",createFirst:\"Créez votre premier projet pour organiser vos builds.\",createFirstButton:\"Créer votre premier projet\",create:\"Créer\",files:\"Fichiers\",prints:\"Impressions\",plates:\"plateaux\",parts:\"pièces\",lastModified:\"Modifié le\",deleteConfirm:\"Supprimer ce projet ? Les archives seront déliées mais conservées.\",addFiles:\"Ajouter fichiers\",removeFile:\"Retirer fichier\",viewDetails:\"Détails\",namePlaceholder:\"ex: Build Voron 2.4\",descriptionPlaceholder:\"Description optionnelle...\",color:\"Couleur\",targetPlates:\"Plateaux cibles\",targetPlatesPlaceholder:\"ex: 25\",targetPlatesHelp:\"Nombre total de jobs\",targetParts:\"Pièces cibles\",targetPartsPlaceholder:\"ex: 150\",targetPartsHelp:\"Nombre total d'objets\",tagsLabel:\"Tags (séparés par virgules)\",tagsPlaceholder:\"ex: voron, cadeau\",dueDate:\"Échéance\",priority:\"Priorité\",priorityLow:\"Basse\",priorityNormal:\"Normale\",priorityHigh:\"Haute\",priorityUrgent:\"Urgente\",statusActive:\"Actif\",statusCompleted:\"Terminé\",statusArchived:\"Archivé\",done:\"Fait\",completed:\"terminé\",failed:\"échoué\",inQueue:\"en file\",noPrintsYet:\"Aucune impression\",printJobs:\"Jobs (plateaux)\",partsPrinted:\"Pièces imprimées\",failedParts:\"Pièces échouées\",import:\"Importer\",export:\"Exporter\",importProject:\"Importer projet\",exportAll:\"Exporter tous les projets\",loading:\"Chargement des projets...\",noEditPermission:\"Pas d'autorisation de modification\",noDeletePermission:\"Pas d'autorisation de suppression\",noCreatePermission:\"Pas d'autorisation de création\",noImportPermission:\"Pas d'autorisation d'import\",noExportPermission:\"Pas d'autorisation d'export\",toast:{created:\"Projet créé\",updated:\"Projet mis à jour\",deleted:\"Projet supprimé\",imported:\"Projet importé\",multipleImported:\"{{count}} projets importés\",importFailed:\"Échec d'import\",exported:\"Projets exportés (métadonnées)\"}},projectDetail:{notFound:\"Projet non trouvé\",backToProjects:\"Retour aux Projets\",export:\"Exporter\",exportProject:\"Exporter projet\",noExportPermission:\"Pas d'autorisation export\",noEditPermission:\"Pas d'autorisation modification\",partOf:\"Fait partie de :\",priorityLabel:\"Priorité :\",noPrints:\"Aucune impression dans ce projet\",status:{active:\"Actif\",completed:\"Terminé\",archived:\"Archivé\"},priority:{low:\"Basse\",normal:\"Normale\",high:\"Haute\",urgent:\"Urgente\"},dueDate:{overdue:\"En retard\",today:\"Aujourd'hui\",daysLeft:\"{{count}} jours restants\"},progress:{platesProgress:\"Progression Plateaux\",partsProgress:\"Progression Pièces\",printJobs:\"jobs d'impression\",parts:\"pièces\",percentComplete:\"{{percent}}% terminé\",remaining:\"{{count}} restant(s)\"},stats:{printJobs:\"Jobs d'Impression\",total:\"total\",failed:\"{{count}} échecs\",partsPrinted:\"{{count}} pièces imprimées\",printTime:\"Temps d'Impression\",filamentUsed:\"Filament utilisé\"},cost:{title:\"Suivi des coûts\",filamentCost:\"Coût Filament\",energy:\"Énergie\",totalCost:\"Coût Total\",total:\"Total\",includesBom:\"BOM incluse\",budget:\"Budget\",remaining:\"Restant\"},subProjects:{title:\"Sous-projets ({{count}})\"},notes:{title:\"Notes\",noEditPermission:\"Pas d'autorisation modification\",placeholder:\"Ajouter des notes...\",empty:\"Aucune note. Cliquez sur modifier.\"},files:{title:\"Fichiers\",linkFolders:\"Liez des dossiers depuis le gestionnaire\",forQuickAccess:\"pour un accès rapide.\",fileCount:\"{{count}} fichier(s)\",empty:\"Aucun dossier lié.\",noFiles:\"Aucun fichier dans ce dossier.\",print:\"Imprimer maintenant\",addToQueue:\"Ajouter à la file\"},bom:{title:\"BOM (Liste matériel)\",acquired:\"{{completed}}/{{total}} acquis\",showAll:\"Tout afficher\",hideDone:\"Masquer acquis\",addPart:\"Ajouter matériel\",noAddPermission:\"Pas d'autorisation ajout\",partNamePlaceholder:\"Nom (ex: Vis M3x8)\",partName:\"Nom de pièce\",qty:\"Qté\",price:\"Prix ({{currency}})\",sourcingUrlPlaceholder:\"Lien d'achat (optionnel)\",remarksPlaceholder:\"Remarques (optionnel)\",deletePart:\"Supprimer pièce\",deleteConfirm:'Supprimer \"{{name}}\" ?',noUpdatePermission:\"Pas d'autorisation mise à jour\",noEditPermission:\"Pas d'autorisation modification\",noDeletePermission:\"Pas d'autorisation suppression\",totalCost:\"Coût total :\",empty:\"BOM vide. Ajoutez du matériel ou de l'électronique.\"},timeline:{title:\"Historique d'activité\",empty:\"Aucune activité.\"},template:{saveAsTemplate:\"Enregistrer comme modèle\",noCreatePermission:\"Pas d'autorisation modèle\"},queue:{title:\"File d'attente\",viewAll:\"Tout voir\",printing:\"{{count}} en cours\",queued:\"{{count}} en file\"},prints:{title:\"Impressions ({{count}})\"},toast:{projectUpdated:\"Projet mis à jour\",partAdded:\"Pièce ajoutée\",partRemoved:\"Pièce retirée\",exportFailed:\"Échec export\",projectExported:\"Projet exporté\",templateCreated:\"Modèle créé\"}},system:{title:\"Informations Système\",version:\"Version\",uptime:\"Temps de fonctionnement\",cpuUsage:\"Utilisation CPU\",memoryUsage:\"Utilisation RAM\",diskUsage:\"Utilisation Disque\",networkInfo:\"Infos Réseau\",logs:\"Journaux\",debugMode:\"Mode Débogage\",enableDebug:\"Activer débogage\",disableDebug:\"Désactiver débogage\",downloadLogs:\"Télécharger logs\",clearLogs:\"Effacer logs\",dockerInfo:\"Infos Docker\",containerName:\"Nom conteneur\",imageName:\"Nom image\",platform:\"Plateforme\",architecture:\"Architecture\"},library:{title:\"Bibliothèque Filament\",addFilament:\"Ajouter Filament\",editFilament:\"Modifier Filament\",deleteFilament:\"Supprimer Filament\",vendor:\"Vendeur\",material:\"Matériau\",color:\"Couleur\",kFactor:\"Facteur K\",temperature:\"Température\",noFilaments:\"Bibliothèque vide\",deleteConfirm:\"Supprimer ce filament ?\",importFromPrinter:\"Importer de l'imprimante\",exportToFile:\"Exporter vers fichier\"},spoolman:{title:\"Intégration Spoolman\",enabled:\"Spoolman Activé\",url:\"URL Spoolman\",connected:\"Connecté\",disconnected:\"Non Connecté\",testConnection:\"Tester connexion\",sync:\"Synchroniser\",syncing:\"Sync...\",lastSync:\"Dernière Sync\",linkToSpoolman:\"Lier à Spoolman\",openInSpoolman:\"Ouvrir Spoolman\",unlinkSpool:\"Délier bobine\",unlinkConfirmTitle:\"Dissocier la bobine?\",unlinkConfirmMessage:\"Cette opération déconnectera la bobine de Spoolman. Les données de la bobine dans Spoolman resteront inchangées.\",selectSpool:\"Choisir bobine\",noUnlinkedSpools:\"Pas de bobine libre\",linkSuccess:\"Lien réussi\",linkFailed:\"Échec lien\",unlinkSuccess:\"Bobine dissociée avec succès\",unlinkFailed:\"Échec de la dissociation de la bobine\",spoolId:\"ID Bobine\",fillSourceLabel:\"(Spoolman)\",weight:\"Poids\",remaining:\"Restant\",disableWeightSync:\"Désactiver Sync poids estimé AMS\",disableWeightSyncDesc:\"Ne pas utiliser les estimations AMS. Utile si vous préférez le suivi Spoolman.\",reportPartialUsage:\"Rapporter consommation partielle pour échecs\",reportPartialUsageDesc:\"Si l'impression échoue, rapporte le filament consommé selon les couches.\"},inventory:{title:\"Inventaire de Bobines\",addSpool:\"Ajouter Bobine\",editSpool:\"Modifier Bobine\",material:\"Matériau\",selectMaterial:\"Choisir matériau...\",subtype:\"Sous-type\",brand:\"Marque\",searchBrand:\"Chercher marque...\",useCustomBrand:'Utiliser \"{{brand}}\"',useCustomMaterial:\"Utiliser un matériau personnalisé : {{material}}\",colorName:\"Nom de couleur\",colorNamePlaceholder:\"Jade White, Fire Red...\",color:\"Couleur\",hexColor:\"Code Hex\",pickColor:\"Choisir couleur perso\",labelWeight:\"Poids net\",coreWeight:\"Poids bobine vide\",searchSpoolWeight:\"Chercher poids bobine...\",weightUsed:\"Consommé\",currentWeight:\"Poids restant\",measuredWeight:\"Poids mesuré\",spoolName:\"Bobine\",costPerKg:\"Coût par kg\",measuredWeightError:\"Le poids mesuré doit être entre {{min}}g et {{max}}g.\",slicerFilament:\"Filament Slicer\",slicerFilamentName:\"Nom du Preset Slicer\",slicerPreset:\"Preset Slicer\",searchPresets:\"Chercher presets...\",selectedPreset:\"Sélectionné\",noPresetsFound:\"Aucun preset trouvé\",tempOverrides:\"Exceptions Température\",note:\"Note\",notePlaceholder:\"Notes additionnelles sur cette bobine...\",archive:\"Archiver\",restore:\"Restaurer\",noSpools:\"Aucune bobine. Ajoutez votre première bobine pour commencer.\",noManualSpools:\"Aucune bobine manuelle disponible. Ajoutez-en une d'abord.\",kProfiles:\"K-Profiles\",addKProfile:\"Ajouter K-Profile\",assignSpool:\"Assigner Bobine\",unassignSpool:\"Désassigner\",assignSuccess:\"Bobine assignée et slot AMS configuré\",assignFailed:\"Échec assignation\",selectSpool:\"Choisir une bobine pour ce slot\",assigned:\"Assigné\",assigning:\"Assignation...\",searchSpools:\"Chercher bobines...\",showAllSpools:\"Afficher toutes les bobines\",allMaterials:\"Tous Matériaux\",filterByBrand:\"Filtrer par marque...\",showArchived:\"Afficher archivées\",quickAdd:\"Ajout rapide (Stock)\",quantity:\"Quantité\",stock:\"Stock\",configured:\"Configuré\",spoolsCreated:\"{{count}} bobines créées\",spoolCreated:\"Bobine créée\",spoolUpdated:\"Bobine mise à jour\",spoolDeleted:\"Bobine supprimée\",spoolArchived:\"Bobine archivée\",spoolRestored:\"Bobine restaurée\",deleteConfirm:\"Supprimer définitivement cette bobine ?\",archiveConfirm:\"Voulez-vous vraiment archiver cette bobine ?\",advancedSettings:\"Paramètres Avancés\",filamentInfoTab:\"Infos Filament\",paProfileTab:\"Profil PA\",filamentInfo:\"Filament\",additional:\"Additionnel\",loadingPresets:\"Chargement des presets cloud...\",cloudConnected:\"Cloud connecté\",cloudNotConnected:\"Cloud déconnecté (valeurs par défaut)\",recentColors:\"Récentes\",searchColors:\"Chercher couleurs...\",searchResults:\"Résultats\",allColors:\"Toutes\",commonColors:\"Communes\",showLess:\"Moins\",showAll:\"Toutes\",noColorsFound:\"Aucune couleur correspondante\",noResults:\"Aucun résultat\",selectMaterialFirst:\"Veuillez choisir un matériau dans l'onglet Infos Filament.\",noPrintersConfigured:\"Ajoutez une imprimante pour utiliser les profils PA.\",matchingFilter:\"Correspondant\",anyBrand:\"Toute marque\",anyVariant:\"Toute variante\",autoSelect:\"Auto-sélection\",matches:\"correspondances\",match:\"correspondance\",noMatches:\"Aucune correspondance\",connected:\"Connecté\",offline:\"Hors ligne\",printerOffline:\"Imprimante hors ligne. Connectez-vous pour voir les profils.\",noKProfilesMatch:\"Aucun profil K ne correspond au filament.\",leftNozzle:\"Buse Gauche\",rightNozzle:\"Buse Droite\",profilesSelected:\"profil(s) de calibration sélectionné(s)\",totalInventory:\"Total Inventaire\",totalConsumed:\"Total Consommé\",byMaterial:\"Par Matériau\",inPrinter:\"Dans Imprimante\",lowStock:\"Stock Bas\",sinceTracking:\"Depuis le début du suivi\",loadedInAms:\"Chargé dans AMS/Ext\",remaining:\"Restant\",weightCheck:\"Vérification poids\",lastWeighed:\"Dernière pesée\",neverWeighed:\"Jamais pesé\",search:\"Chercher bobines...\",showing:\"Affichage\",to:\"à\",of:\"sur\",show:\"Voir\",spools:\"bobines\",spool:\"bobine\",page:\"Page\",noSpoolsMatch:\"Aucun résultat trouvé\",noSpoolsMatchDesc:\"Ajustez votre recherche ou vos filtres.\",active:\"Actif\",archived:\"Archivé\",all:\"Tous\",used:\"Occasion\",new:\"Neuf\",clearFilters:\"Effacer filtres\",table:\"Tableau\",cards:\"Cartes\",net:\"Net\",groupSimilar:\"Grouper\",groupedSpools:\"{{count}} bobines identiques\",groupedRows:\"lignes\",columns:\"Colonnes\",configureColumns:\"Configurer Colonnes\",configureColumnsDesc:\"Glissez pour ordonner ou utilisez les flèches. Cliquez sur l'œil pour masquer.\",visible:\"visible\",reset:\"Reset\",cancel:\"Annuler\",applyChanges:\"Appliquer\",moveUp:\"Monter\",moveDown:\"Descendre\",hideColumn:\"Masquer\",showColumn:\"Afficher\",linkToSpool:\"Lier à une Bobine\",tagLinked:\"Tag lié à la bobine\",tagLinkFailed:\"Échec lien tag\",tagAlreadyLinked:\"Tag déjà lié à une autre bobine\",unknownTag:\"Tag RFID inconnu détecté\",usageHistory:\"Historique de Consommation\",noUsageHistory:\"Aucune consommation enregistrée\",printName:\"Nom Impression\",weightConsumed:\"Poids consommé\",clearHistory:\"Effacer\",historyCleared:\"Historique effacé\",fillSourceLabel:\"(Inv)\",lowStockThresholdError:\"Le seuil doit être compris entre 0.1 et 99.9\",assignMismatchTitle:\"Incompatibilité de matériau\",assignMismatchMessage:'Le matériau de la bobine sélectionnée \"{{spoolMaterial}}\" ne correspond pas au matériau du plateau \"{{trayMaterial}}\" pour {{location}}. Assigner quand même ?',assignMismatchConfirm:\"Assigner quand même\",assignPartialMismatchMessage:'Le matériau de la bobine \"{{spoolMaterial}}\" est similaire, mais ne correspond pas exactement à \"{{trayMaterial}}\" dans {{location}}. Voulez-vous continuer ?',assignProfileMismatchMessage:'Le profil de la bobine \"{{spoolProfile}}\" ne correspond pas au profil du plateau \"{{trayProfile}}\" dans {{location}}. Voulez-vous continuer ?'},timelapse:{title:\"Timelapse\",create:\"Créer Timelapse\",download:\"Télécharger\",delete:\"Supprimer\",preview:\"Aperçu\",frameRate:\"Images/sec\",quality:\"Qualité\",processing:\"Traitement...\",noTimelapses:\"Aucun timelapse\"},ams:{title:\"AMS\",slot:\"Slot\",empty:\"Vide\",emptySlot:\"Slot vide\",unknown:\"Inconnu\",humidity:\"Humidité\",temperature:\"Température\",filamentType:\"Type filament\",filamentColor:\"Couleur\",remaining:\"Restant\",history:\"Historique AMS\",noHistory:\"Aucun historique\",configureSlot:\"Configurer Slot\",externalSpool:\"Bobine externe\",profile:\"Profil\",kFactor:\"Facteur K\",fill:\"Remplir\",configure:\"Configurer\",used:\"utilisé\",remainingUnit:\"restant\"},printModal:{title:\"Lancer l'impression\",selectPrinter:\"Choisir l'imprimante\",selectPlate:\"Choisir le plateau\",filamentMapping:\"Mapping Filament\",totalCost:\"Coût total :\",slotRemainingShort:\" - {{grams}}g rest.\",printSettings:\"Réglages d'impression\",bedLeveling:\"Nivellement plateau\",flowCalibration:\"Calibration débit\",vibrationCalibration:\"Vibration (Input Shaper)\",layerInspection:\"Inspection 1ère couche\",timelapse:\"Timelapse\",startPrint:\"Démarrer\",addToQueue:\"Ajouter à la file\",cancel:\"Annuler\",noPrintersAvailable:\"Aucune imprimante disponible\",printerBusy:\"L'imprimante est occupée\",printerOffline:\"L'imprimante est hors ligne\",sameTypeDifferentColor:\"Même type, couleur différente\",filamentTypeNotLoaded:\"Type de filament non chargé\",openCalendar:\"Ouvrir calendrier\",leftNozzle:\"G\",rightNozzle:\"D\",leftNozzleTooltip:\"Buse gauche\",rightNozzleTooltip:\"Buse droite\",filamentOverride:\"Remplacement de filament\",filamentOverrideHint:\"Remplacez optionnellement les filaments pour l'affectation par modèle. Le planificateur utilisera vos filaments sélectionnés au lieu des valeurs 3MF d'origine.\",originalFilament:\"Original\",overrideWith:\"Remplacer par\",resetToOriginal:\"Revenir à l'original\",insufficientFilamentTitle:\"Filament insuffisant\",insufficientFilamentMessage:\"Certaines bobines assignées ont moins de filament restant que nécessaire pour cette impression :\",insufficientFilamentLine:\"{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g\",printAnyway:\"Imprimer quand même\",forceColorMatch:\"Forcer correspondance des couleurs\",staggerPrinterStarts:\"Stagger printer starts\",staggerGroupSize:\"Group size\",staggerInterval:\"Interval (min)\",staggerPreview:\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",staggerLastGroup:\"last group: {{count}}\",staggerTotal:\"total: {{minutes}} min\",staggerToPrinters:\"Échelonner sur {{count}} imprimantes\",gcodeInjection:\"Injecter le G-code auto-impression\"},backup:{title:\"Sauvegarde & Restauration\",createBackup:\"Créer Sauvegarde\",restoreBackup:\"Restaurer Sauvegarde\",restoreDescription:\"Remplace les données par un fichier de sauvegarde\",downloadBackup:\"Télécharger Sauvegarde\",uploadBackup:\"Téléverser Sauvegarde\",lastBackup:\"Dernière sauvegarde\",autoBackup:\"Sauvegarde auto\",backupNow:\"Sauvegarder maintenant\",restoreWarning:\"Attention : Écrase TOUTES les données actuelles.\",includeArchives:\"Inclure Archives\",includeSettings:\"Inclure Paramètres\",includeProfiles:\"Inclure Profils\",backupSuccess:\"Sauvegarde réussie\",restoreSuccess:\"Restauration réussie\",backupFailed:\"Échec sauvegarde\",restoreFailed:\"Échec restauration\",restoreNote:\"L'imprimante virtuelle sera arrêtée pendant la restauration\",githubBackup:\"Sauvegarde GitHub\",enabled:\"Activé\",cloudLoginRequired:\"Connexion Bambu Cloud requise. Connectez-vous sous Profils → Profils Cloud pour activer la sauvegarde GitHub.\",cloudLoginRequiredShort:\"Connexion Cloud requise\",githubDescription:\"Synchronisez automatiquement vos profils vers un dépôt GitHub privé pour la sauvegarde et l'historique des versions.\",repositoryUrl:\"URL du dépôt\",personalAccessToken:\"Jeton d'accès personnel\",tokenSaved:\"(enregistré)\",enterNewToken:\"Entrez un nouveau jeton pour mettre à jour\",tokenHint:\"Jeton à granularité fine avec permission de lecture/écriture du contenu\",branch:\"Branche\",manualOnly:\"Manuel uniquement\",hourly:\"Toutes les heures\",daily:\"Quotidien\",weekly:\"Hebdomadaire\",includeInBackup:\"Inclure dans la sauvegarde\",kProfiles:\"K-Profils\",kProfilesDescription:\"Calibration de l'avance de pression des imprimantes connectées\",noPrintersConnected:\"Aucune imprimante connectée\",printersConnected:\"{{connected}}/{{total}} connectées\",cloudProfiles:\"Profils Cloud\",cloudProfilesDescription:\"Préréglages de filament, imprimante et processus depuis Bambu Cloud\",appSettings:\"Paramètres de l'application\",appSettingsDescription:\"Configuration Bambuddy (base de données complète)\",spoolInventory:\"Inventaire des bobines\",spoolInventoryDescription:\"Bobines de filament, historique d'utilisation et suivi des coûts\",printArchives:\"Archives d'impression\",printArchivesDescription:\"Métadonnées de l'historique d'impression (pas de fichiers gcode/3MF)\",lastBackupAt:\"Dernière sauvegarde :\",noBackupsYet:\"Aucune sauvegarde pour l'instant\",next:\"Prochaine :\",startingBackup:\"Démarrage de la sauvegarde...\",test:\"Tester\",enableBackup:\"Activer la sauvegarde\",testConnection:\"Tester la connexion\",enterRepoUrl:\"Entrez l'URL du dépôt\",enterRepoAndToken:\"Entrez l'URL du dépôt et le jeton d'accès\",repoRequired:\"L'URL du dépôt est requise\",tokenRequired:\"Le jeton d'accès est requis\",githubBackupEnabled:\"Sauvegarde GitHub activée\",tokenUpdated:\"Jeton mis à jour\",settingsSaved:\"Paramètres enregistrés\",failedToSave:\"Échec de l'enregistrement : {{message}}\",backupCompleteFiles:\"Sauvegarde terminée - {{count}} fichiers mis à jour\",backupSkippedNoChanges:\"Sauvegarde ignorée - aucun changement\",backupFailed2:\"Échec de la sauvegarde : {{message}}\",clearedLogs:\"{{count}} journaux supprimés\",failedToClearLogs:\"Échec de la suppression des journaux : {{message}}\",history:\"Historique\",clear:\"Effacer\",date:\"Date\",status:\"Statut\",commit:\"Commit\",localBackup:\"Sauvegarde locale\",localBackupDescription:\"Créez une sauvegarde complète de vos données Bambuddy incluant la base de données, les archives, les téléchargements et tous les fichiers.\",downloadBackupLabel:\"Télécharger la sauvegarde\",completeBackupZip:\"Sauvegarde complète : base de données + tous les fichiers (ZIP)\",download:\"Télécharger\",preparingBackup:\"Préparation de la sauvegarde...\",creatingArchive:\"Création de l'archive de sauvegarde... Cela peut prendre un moment pour les archives volumineuses.\",downloadingFile:\"Téléchargement du fichier de sauvegarde...\",backupDownloaded:\"Sauvegarde téléchargée avec succès\",failedToCreateBackup:\"Échec de la création de la sauvegarde : {{message}}\",restore:\"Restaurer\",restoreReplacesAll:\"La restauration remplace toutes les données.\",restoreReplacesAllDetail:\"Votre base de données et vos fichiers actuels seront complètement remplacés. Un redémarrage est nécessaire après la restauration.\",restoreConfirmTitle:\"Restaurer la sauvegarde\",restoreConfirmMessage:`Êtes-vous sûr de vouloir restaurer depuis \"{{filename}}\" ? Cela remplacera complètement votre base de données et tous vos fichiers. L'application devra être redémarrée après la restauration.`,restoreConfirmButton:\"Restaurer la sauvegarde\",uploadingFile:\"Téléchargement du fichier de sauvegarde...\",backupRestoredRestart:\"Sauvegarde restaurée. Veuillez redémarrer Bambuddy.\",failedToRestore:\"Échec de la restauration. Veuillez vérifier le format du fichier.\",reloadNow:\"Recharger maintenant\",creatingBackup:\"Création de la sauvegarde\",restoringBackup:\"Restauration de la sauvegarde\",preparing:\"Préparation...\",processing:\"Traitement...\",doNotClosePage:\"Veuillez ne pas fermer cette page ni naviguer ailleurs. Cette opération peut prendre plusieurs minutes pour les sauvegardes volumineuses.\",restoring:\"Restauration...\",restoreComplete:\"Restauration terminée\",restoreFailed2:\"Échec de la restauration\",importSettings:\"Importer les paramètres depuis un fichier de sauvegarde\",pleaseWaitRestoring:\"Veuillez patienter pendant la restauration de vos données\",selectBackupFile:\"Cliquez pour sélectionner un fichier de sauvegarde (.json ou .zip)\",duplicateHandling:\"Comment fonctionne la gestion des doublons :\",matchPrinters:\"Imprimantes\",matchPrintersBy:\"correspondance par numéro de série\",matchSmartPlugs:\"Smart Plugs\",matchSmartPlugsBy:\"correspondance par adresse IP\",matchNotificationProviders:\"Fournisseurs de notifications\",matchNotificationProvidersBy:\"correspondance par nom\",matchFilaments:\"Filaments\",matchFilamentsBy:\"correspondance par nom + type + marque\",matchArchives:\"Archives\",matchArchivesBy:\"correspondance par hash de contenu (toujours ignoré)\",matchPendingUploads:\"Téléchargements en attente\",matchPendingUploadsBy:\"correspondance par nom de fichier\",matchSettingsTemplates:\"Paramètres et modèles\",matchSettingsTemplatesBy:\"toujours écrasés\",replaceExisting:\"Remplacer les données existantes\",keepExisting:\"Conserver les données existantes\",overwriteDescription:\"Écraser les éléments qui existent déjà avec les données de sauvegarde\",keepDescription:\"Restaurer uniquement les éléments qui n'existent pas encore\",overwriteCaution:\"Attention :\",overwriteWarning:\"L'écrasement remplacera vos configurations actuelles par les données de la sauvegarde. Les codes d'accès des imprimantes ne sont jamais écrasés pour des raisons de sécurité.\",cancel:\"Annuler\",processingBackup:\"Traitement du fichier de sauvegarde...\",itemsRestored:\"Éléments restaurés\",itemsSkipped:\"Éléments ignorés\",restored:\"Restaurés\",skippedAlreadyExist:\"Ignorés (existent déjà)\",filesCategory:\"Fichiers (3MF, miniatures, etc.)\",andMore:\"...et {{count}} de plus\",newApiKeysGenerated:\"Nouvelles clés API générées\",keysShownOnce:\"Ces clés ne sont affichées qu'une seule fois. Copiez-les maintenant !\",copy:\"Copier\",noDataFound:\"Aucune donnée à restaurer n'a été trouvée dans le fichier de sauvegarde.\",close:\"Fermer\",scheduledBackup:\"Scheduled Backups\",scheduledBackupDescription:\"Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.\",frequency:\"Frequency\",backupTime:\"Time\",retention:\"Retention\",retentionDescription:\"Number of backups to keep\",outputPath:\"Output Path\",outputPathPlaceholder:\"Default: {{path}}\",outputPathDescription:\"Leave empty for default location\",runNow:\"Run Now\",backupFiles:\"Backup Files\",noScheduledBackups:\"No backups yet\",deleteBackup:\"Delete\",deleteBackupConfirm:\"Delete this backup file?\",backupRunning:\"Backup in progress...\",scheduledBackupComplete:\"Backup completed successfully\",scheduledBackupFailed:\"Backup failed\",nextBackup:\"Next backup\",backupSize:\"Size\",utc:\"UTC\",defaultPathLabel:\"Default:\",categories:{settings:\"Paramètres\",notification_providers:\"Fournisseurs de notifications\",notification_templates:\"Modèles de notifications\",smart_plugs:\"Smart Plugs\",printers:\"Imprimantes\",filaments:\"Filaments\",maintenance_types:\"Types de maintenance\",archives:\"Archives\",projects:\"Projets\",pending_uploads:\"Téléchargements en attente\",external_links:\"Liens externes\",api_keys:\"Clés API\"}},tags:{title:\"Tags\",addTag:\"Ajouter Tag\",editTag:\"Modifier Tag\",deleteTag:\"Supprimer Tag\",tagName:\"Nom du Tag\",tagColor:\"Couleur du Tag\",noTags:\"Aucun tag\",deleteConfirm:\"Supprimer ce tag ?\",manageTags:\"Gérer les Tags\"},uploadModal:{title:\"Téléverser des fichiers 3MF\",dragDrop:\"Glissez les fichiers .3mf ici\",or:\"ou\",browseFiles:\"Parcourir\",extractionInfo:\"Le modèle d'imprimante est extrait des métadonnées 3MF.\",uploaded:\"téléversé\",failed:\"échoué\",uploading:\"En cours...\",upload:\"Téléverser\",uploadFailed:\"Échec du téléversement\"},editArchive:{title:\"Modifier l'archive\",name:\"Nom\",namePlaceholder:\"Nom de l'impression\",printer:\"Imprimante\",noPrinter:\"Aucune imprimante\",project:\"Projet\",noProject:\"Aucun projet\",itemsPrinted:\"Nombre de pièces\",itemsPrintedHelp:\"Nombre d'objets produits\",notes:\"Notes\",notesPlaceholder:\"Notes sur l'impression...\",externalLink:\"Lien externe\",externalLinkPlaceholder:\"https://...\",externalLinkHelp:\"Lien vers Printables, Thingiverse, etc.\",tags:\"Tags\",tagsPlaceholder:\"Ajouter des tags...\",addMoreTags:\"Plus de tags...\",matchingTags:'Correspondant à \"{{query}}\"',existingTags:\"Tags existants\",clickToAdd:\"(cliquer pour ajouter)\",status:\"Statut\",failureReason:\"Raison de l'échec\",selectReason:\"Choisir raison...\",photos:\"Photos du résultat\",photosHelp:\"Cliquez sur + pour ajouter des photos\",printResult:\"Résultat d'impression\",saving:\"Enregistrement...\",failureReasons:{adhesionFailure:\"Défaut d'adhésion\",spaghettiDetached:\"Spaghetti / Détaché\",layerShift:\"Décalage de couche\",cloggedNozzle:\"Buse bouchée\",filamentRunout:\"Filament fini\",warping:\"Warping (Déformation)\",stringing:\"Stringing (Cheveux d'ange)\",underExtrusion:\"Sous-extrusion\",powerFailure:\"Coupure courant\",userCancelled:\"Annulé par l'utilisateur\",other:\"Autre\"},statuses:{completed:\"Réussie\",failed:\"Échouée\",aborted:\"Annulée\",printing:\"Impression\"}},kProfiles:{title:\"K-Profiles\",noPrintersConfigured:\"Aucune imprimante configurée\",addPrinterInSettings:\"Ajoutez une imprimante pour gérer les K-profiles\",noActivePrinters:\"Aucune imprimante active\",enablePrinterConnection:\"Activez la connexion pour voir les K-profiles\",loadingProfiles:\"Chargement des K-Profiles...\",printerOffline:\"Imprimante hors ligne\",printerOfflineDesc:\"L'imprimante doit être allumée.\",noMatchingProfiles:\"Aucun profil correspondant\",noMatchingProfilesDesc:\"Ajustez votre recherche\",noKProfiles:\"Aucun K-Profile\",noKProfilesDesc:\"Aucun profil trouvé pour une buse de {{diameter}}mm\",createFirstProfile:\"Créer le premier profil\",printer:\"Imprimante\",nozzle:\"Buse\",refresh:\"Rafraîchir\",addProfile:\"Ajouter Profil\",export:\"Exporter\",import:\"Importer\",select:\"Choisir\",selectAll:\"Tout sélectionner\",delete:\"Supprimer\",searchPlaceholder:\"Nom ou filament...\",allExtruders:\"Tous les extrudeurs\",leftOnly:\"Gauche uniquement\",rightOnly:\"Droite uniquement\",allFlow:\"Tout débit\",hfOnly:\"HF uniquement\",sOnly:\"S uniquement\",sortName:\"Tri : Nom\",sortKValue:\"Tri : Valeur K\",sortFilament:\"Tri : Filament\",leftExtruder:\"Extrudeur gauche\",rightExtruder:\"Extrudeur droit\",modal:{addTitle:\"Ajouter K-Profile\",editTitle:\"Modifier K-Profile\",profileName:\"Nom du profil\",profileNamePlaceholder:\"ex: Mon profil PLA\",kValue:\"Valeur K\",kValuePlaceholder:\"0.020\",kValueHelp:\"Plage type : 0.01-0.06 (PLA), 0.02-0.10 (PETG)\",filament:\"Filament\",selectFilament:\"Choisir filament...\",noFilamentsHelp:\"Créez d'abord un profil dans Bambu Studio.\",flowType:\"Type de débit\",highFlow:\"Haut Débit (HF)\",standard:\"Standard\",nozzleSize:\"Taille buse\",extruder:\"Extrudeur\",extruders:\"Extrudeurs\",left:\"Gauche\",right:\"Droite\",notes:\"Notes (locales)\",notesPlaceholder:\"Notes sur ce profil...\",notesHelp:\"Enregistré dans Bambuddy, pas sur l'imprimante\",syncing:\"Sync avec l'imprimante...\",savingExtruder:\"Sauvegarde extrudeur {{current}}/{{total}}...\",pleaseWait:\"Patientez...\"},deleteConfirm:{title:\"Supprimer profil\",cannotUndo:\"Action irréversible\",message:`Supprimer \"{{name}}\" de l'imprimante ?`},bulkDelete:{title:\"Supprimer les profils\",cannotUndo:\"Action irréversible\",message:\"Supprimer les {{count}} profils de l'imprimante ?\"},toast:{profileSaved:\"Profil K enregistré\",profilesSaved:\"Profil K enregistré sur {{count}} extrudeur(s)\",selectAtLeastOneExtruder:\"Sélectionnez un extrudeur\",profileDeleted:\"Profil K supprimé\",profilesDeleted:\"{{count}} profils supprimés\",exportedProfiles:\"{{count}} profils exportés\",importedProfiles:\"{{count}} sur {{total}} profils importés\",noProfilesToExport:\"Rien à exporter\",invalidFileFormat:\"Format invalide\",failedToParseImport:\"Échec analyse fichier\",failedToSaveBatch:\"Échec enregistrement groupé\",noteSaved:\"Note enregistrée\",failedToSaveNote:\"Échec note\"},permission:{noRead:\"Pas d'autorisation lecture\",noCreate:\"Pas d'autorisation création\",noUpdate:\"Pas d'autorisation mise à jour\",noDelete:\"Pas d'autorisation suppression\",noExport:\"Pas d'autorisation export\",noImport:\"Pas d'autorisation import\"}},virtualPrinter:{title:\"Imprimante Virtuelle\",running:\"En cours\",stopped:\"Arrêtée\",description:{default:\"Active une imprimante qui apparaît dans Bambu Studio. Les fichiers envoyés sont archivés sans impression.\",proxy:\"Active un proxy qui relaie le trafic vers une imprimante réelle, permettant l'impression à distance.\"},enable:{title:\"Activer l'imprimante virtuelle\",visibleInSlicer:'Visible comme \"Bambuddy\" dans le Slicer',proxyingTo:\"Proxy vers {{name}}\",notActive:\"Inactive\"},model:{title:\"Modèle d'imprimante\",description:\"Choisissez le modèle à émuler.\",restartWarning:\"Changer le modèle redémarrera le service\"},accessCode:{title:\"Code d'accès\",isSet:\"Code défini\",notSet:\"Code requis pour activer\",placeholder:\"Code 8 char\",placeholderChange:\"Entrez nouveau code\",hint:\"Exactement 8 caractères. Sert à l'auth du Slicer.\",charCount:\"({{count}}/8)\"},targetPrinter:{title:\"Imprimante cible\",configured:\"Cible configurée\",notConfigured:\"Imprimante requise pour mode Proxy\",placeholder:\"Choisir imprimante...\",hint:\"L'imprimante doit être en mode LAN.\",noPrinters:\"Ajoutez une imprimante réelle d'abord.\"},remoteInterface:{title:\"Exception Interface Réseau\",configured:\"Override actif\",optional:\"Optionnel - si IP auto est fausse (VPN, Docker, multi-NIC).\",placeholder:\"Auto (défaut)...\",hint:\"Force l'IP annoncée via SSDP.\"},mode:{title:\"Mode\",archive:\"Archiver\",archiveDesc:\"Archive immédiatement\",review:\"Revue\",reviewDesc:\"Attendre revue avant archive\",queue:\"File\",queueDesc:\"Archiver et ajouter à la file\",proxy:\"Proxy\",proxyDesc:\"Relais vers imprimante réelle\"},autoDispatch:{title:\"Lancement automatique\",description:\"Lancer automatiquement les impressions ajoutées à la file. Désactivé, les impressions attendent un lancement manuel.\"},setupRequired:{title:\"Configuration requise\",description:\"Nécessite des réglages système (ports, pare-feu).\",readGuide:\"Lire le guide de configuration\"},howItWorks:{title:\"Fonctionnement\",step1:\"Sur le même LAN, les imprimantes virtuelles apparaissent automatiquement dans votre slicer (Bambu Studio / OrcaSlicer). Depuis d'autres réseaux, ajoutez-les manuellement par adresse IP et code d'accès.\",step2:`En mode Archive, Revue et File d'attente, utilisez le bouton \"Envoyer\" dans votre slicer pour envoyer des fichiers 3MF à Bambuddy. Le slicer affichera \"Impression réussie\" — le fichier est stocké, pas imprimé.`,step3:\"En mode Proxy, l'imprimante virtuelle relaie tout le trafic vers une vraie imprimante — les impressions démarrent immédiatement comme en connexion directe.\"},status:{title:\"Détails du statut\",printerName:\"Nom\",model:\"Modèle\",serialNumber:\"Série\",mode:\"Mode\",pendingFiles:\"Fichiers en attente\",targetPrinter:\"Cible\",ftpPort:\"Port FTP\",mqttPort:\"Port MQTT\",ftpConnections:\"Connexions FTP\",mqttConnections:\"Connexions MQTT\"},toast:{updated:\"Réglages virtuels mis à jour\",failedToUpdate:\"Échec mise à jour\",accessCodeRequired:\"Code d'accès requis\",targetPrinterRequired:\"Imprimante cible requise\",bindIpRequired:\"Veuillez d'abord définir une adresse IP\",accessCodeEmpty:\"Le code ne peut pas être vide\",accessCodeLength:\"Le code doit faire 8 caractères\",created:\"Imprimante virtuelle créée\",failedToCreate:\"Échec de la création de l'imprimante virtuelle\",deleted:\"Imprimante virtuelle supprimée\",failedToDelete:\"Échec de la suppression de l'imprimante virtuelle\"},list:{title:\"Imprimantes virtuelles\",add:\"Ajouter\",addFirst:\"Ajouter une imprimante virtuelle\",empty:\"Aucune imprimante virtuelle configurée. Ajoutez-en une pour commencer.\"},bindIp:{title:\"Interface réseau\",placeholder:\"Sélectionner interface...\",hint:\"Interface réseau sur laquelle cette imprimante virtuelle écoute. Doit être unique par imprimante.\"},proxy:{accessCodeHint:\"En mode proxy, utilisez le code d'accès de l'imprimante cible dans le slicer. La connexion est transmise de manière transparente à l'imprimante réelle.\"},addDialog:{title:\"Ajouter une imprimante virtuelle\",name:\"Nom\",hint:\"Vous pourrez configurer le code d'accès, l'imprimante cible et d'autres paramètres après la création.\",create:\"Créer\"},deleteConfirm:{title:\"Supprimer l'imprimante virtuelle\",message:'Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cela arrêtera tous les services de cette imprimante.'}},modelViewer:{openInSlicer:\"Ouvrir dans le Slicer\",tabs:{model:\"Modèle 3D\",gcode:\"Aperçu G-code\"},notAvailable:\"indisponible\",notSliced:\"pas découpé\",plates:\"Plateaux\",allPlates:\"Tous les plateaux\",plateNumber:\"Plateau {{number}}\",plateCount:\"{{count}} plateau\",plateCount_other:\"{{count}} plateaux\",objectCount:\"{{count}} objet\",objectCount_other:\"{{count}} objets\",filamentCount:\"{{count}} filament\",filamentCount_other:\"{{count}} filaments\",eta:\"Fin {{minutes}} min\",noPreview:\"Aucun aperçu pour ce fichier\",pagination:{pageOf:\"Page {{current}} sur {{total}}\",prev:\"Préc\",next:\"Suiv\"},errors:{failedToLoad:\"Échec chargement fichier\",noMeshes:\"Aucun maillage trouvé dans le 3MF\",unsupportedFormat:\"Format non supporté\"}},maintenanceDescriptions:{lubricateCarbonRods:\"Appliquer du lubrifiant sur les tiges carbone pour un mouvement fluide\",lubricateRails:\"Appliquer du lubrifiant sur les rails linéaires\",cleanNozzle:\"Nettoyer buse et hotend anti-bouchage\",checkBelts:\"Tension des courroies pour la précision\",cleanBuildPlate:\"Nettoyage plateau pour l'adhésion\",checkExtruder:\"Usure des engrenages de l'extrudeur\",checkCooling:\"Bon fonctionnement des ventilateurs\",generalInspection:\"Inspection générale de la machine\",cleanCarbonRods:\"Nettoyer les tiges carbone (friction)\",lubricateSteelRods:\"Appliquer du lubrifiant sur les tiges en acier pour un mouvement fluide\",cleanSteelRods:\"Nettoyer les tiges en acier (friction)\",cleanLinearRails:\"Essuyer les rails linéaires (poussière/débris)\",checkPtfeTube:\"Usure ou dommage du tube PTFE\",replaceHepaFilter:\"Filtre HEPA pour la qualité de l'air\",replaceCarbonFilter:\"Filtre charbon actif (odeurs)\",lubricateLeftNozzleRail:\"Lubrifier le rail de buse gauche (Série H2)\"},smartPlugs:{offline:\"Hors ligne\",admin:\"Admin\",openPlugAdminPage:\"Page admin de la prise\",deleteSmartPlug:\"Supprimer la prise\",turnOnSmartPlug:\"Allumer la prise\",turnOffSmartPlug:\"Éteindre la prise\",turnOn:\"Allumer\",turnOff:\"Éteindre\",addSmartPlug:{scanningNetwork:\"Scan réseau...\",chooseEntity:\"Choisir une entité...\",connectionFailed:\"Échec connexion\",searchEntities:\"Chercher entités...\",searchPowerSensors:\"Capteurs puissance...\",searchEnergySensors:\"Capteurs énergie...\",placeholders:{plugName:\"Prise Salon\",mqttStateOnValue:\"ON, true, 1\",mqttSameAsPower:\"Identique au topic puissance, ou différent\"}},linkedTo:\"Lié à :\",monitorOnly:\"Surveillance uniquement\",alerts:\"Alertes\",scheduleOn:\"On {{time}}\",scheduleOff:\"Off {{time}}\",on:\"On\",off:\"Off\",power:\"Puissance\",kwhToday:\"kWh Aujourd'hui\",settings:\"Paramètres\",automationSettings:\"Paramètres d'automatisation\",showInSwitchbar:\"Afficher dans la barre de commutateurs\",quickAccessSidebar:\"Accès rapide depuis la barre latérale\",enabled:\"Activé\",enableAutomation:\"Activer l'automatisation pour cette prise\",autoOn:\"Auto On\",autoOnDescription:\"Allumer au démarrage de l'impression\",autoOff:\"Auto Off\",autoOffDescription:\"Éteindre à la fin de l'impression (unique)\",autoOffPersistent:\"Garder activé\",autoOffPersistentDescription:\"Rester activé entre les impressions au lieu d'une seule fois\",turnOffDelayMode:\"Mode de délai d'extinction\",time:\"Temps\",temp:\"Temp\",delayMinutes:\"Délai (minutes)\",tempThreshold:\"Seuil de température (°C)\",tempThresholdDescription:\"S'éteint lorsque la buse refroidit en dessous de cette température\",edit:\"Modifier\",deleteConfirm:'Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.',turnOnConfirm:'Êtes-vous sûr de vouloir allumer \"{{name}}\" ?',turnOffConfirm:`Êtes-vous sûr de vouloir éteindre \"{{name}}\" ? Cela coupera l'alimentation de l'appareil connecté.`,failedToTurn:'Impossible de {{action}} \"{{name}}\"',unknown:\"Inconnu\",addTitle:\"Ajouter une prise connectée\",editTitle:\"Modifier la prise connectée\",stopScanning:\"Arrêter le scan\",discoverTasmota:\"Découvrir les appareils Tasmota\",foundDevices:\"{{count}} appareil(s) trouvé(s) - cliquez pour sélectionner :\",noDevicesFound:\"Aucun appareil Tasmota trouvé sur votre réseau\",haNotConfigured:\"Home Assistant n'est pas configuré. Configurez-le dans\",haSettingsPath:\"Paramètres → Réseau → Home Assistant\",selectEntity:\"Sélectionner l'entité *\",ipAddress:\"Adresse IP *\",nameLabel:\"Nom *\",username:\"Nom d'utilisateur\",password:\"Mot de passe\",authHint:\"Laissez vide si votre appareil Tasmota ne nécessite pas d'authentification\",linkToPrinter:\"Lier à l'imprimante\",noPrinter:\"Pas d'imprimante (contrôle manuel uniquement)\",linkingDescription:\"La liaison permet l'allumage/extinction automatique au début/fin de l'impression\",powerAlerts:\"Alertes de puissance\",alertAbove:\"Alerte si au-dessus (W)\",alertBelow:\"Alerte si en dessous (W)\",alertDescription:\"Recevoir une notification lorsque la consommation dépasse ces seuils. Laisser vide pour désactiver cette direction.\",dailySchedule:\"Planification quotidienne\",turnOnAt:\"Allumer à\",turnOffAt:\"Éteindre à\",scheduleDescription:\"Allumer/éteindre automatiquement la prise à ces heures chaque jour. Laisser vide pour ignorer cette action.\",showOnPrinterCard:\"Afficher sur la carte imprimante\",displayOnPrinterCard:\"Afficher le bouton sur la carte imprimante\",connectedResult:\"Connecté !\",deviceLabel:\"Appareil : {{name}} - \",stateLabel:\"État : {{state}}\",test:\"Tester\",delete:\"Supprimer\",save:\"Enregistrer\",add:\"Ajouter\",cancel:\"Annuler\",failedToStartScan:\"Impossible de démarrer le scan\",nameRequired:\"Le nom est requis\",entityRequired:\"L'entité est requise pour les prises Home Assistant\",mqttTopicRequired:\"Au moins un topic MQTT doit être configuré pour la puissance, l'énergie ou la surveillance d'état\",loadingEntities:\"Chargement des entités...\",loading:\"Chargement...\",failedToLoadEntities:\"Échec du chargement des entités : {{error}}\",noEntitiesMatching:'Aucune entité trouvée correspondant à \"{{search}}\"',noEntitiesAvailable:\"Aucune entité disponible\",searchingEntities:\"Recherche de toutes les entités ({{count}} trouvées)\",showingEntities:\"Affichage switch, light, input_boolean ({{count}} disponibles)\",energyMonitoringOptional:\"Surveillance énergétique (Optionnel)\",energyMonitoringHint:\"Recherchez et sélectionnez les capteurs fournissant des données de puissance/énergie.\",powerSensorW:\"Capteur de puissance (W)\",energyTodayKwh:\"Énergie aujourd'hui (kWh)\",totalEnergyKwh:\"Énergie totale (kWh)\",noMatchingSensors:\"Aucun capteur correspondant\",none:\"Aucun\",mqttNotConfigured:\"Broker MQTT non configuré. Définissez l'adresse du broker dans\",mqttSettingsPath:\"Paramètres → Réseau → Publication MQTT\",mqttNotConfiguredSuffix:\"(vous n'avez pas besoin d'activer la publication, remplissez simplement les détails du broker).\",mqttMonitorOnlyDescription:\"Les prises MQTT reçoivent les données de puissance/énergie via un abonnement MQTT. Le contrôle on/off n'est pas disponible - utilisez votre broker MQTT ou système domotique.\",powerMonitoring:\"Surveillance de puissance\",energyMonitoring:\"Surveillance énergétique\",stateMonitoring:\"Surveillance d'état\",optional:\"optionnel\",topic:\"Topic\",jsonPath:\"Chemin JSON\",multiplier:\"Multiplicateur\",onValue:\"Valeur ON\",mqttPowerHint:`Le chemin JSON extrait la valeur du payload JSON (ex: \"power_l1\"). Laisser vide si le topic publie des valeurs numériques brutes.\nUtiliser le multiplicateur 0.001 pour mW→W, 1000 pour kW→W.`,mqttEnergyHint:`Le chemin JSON extrait la valeur du payload JSON. Laisser vide pour les valeurs brutes.\nUtiliser le multiplicateur 0.001 pour Wh→kWh, 1000 pour MWh→kWh.`,mqttStateHint:`Le chemin JSON extrait la valeur du payload JSON. Laisser vide pour les valeurs brutes.\nValeur ON : la chaîne exacte signifiant \"ON\". Laisser vide pour la détection auto (ON, true, 1).`,restControl:\"Control\",restOnUrl:\"Turn ON URL\",restOffUrl:\"Turn OFF URL\",restOnBody:\"ON Request Body\",restOffBody:\"OFF Request Body\",restMethod:\"HTTP Method\",restHeaders:\"Custom Headers (JSON)\",restStatusUrl:\"Status URL\",restStatusPath:\"State JSON Path\",restStatusOnValue:\"ON Value\",restPowerUrl:\"URL de puissance\",restPowerPath:\"Power JSON Path\",restPowerMultiplier:\"Multiplicateur de puissance\",restEnergyUrl:\"URL d'énergie\",restEnergyPath:\"Energy JSON Path\",restEnergyMultiplier:\"Multiplicateur d'énergie\",restUrlRequired:\"At least one URL (ON or OFF) is required for REST plugs\",restHeadersHint:'e.g. {\"Authorization\": \"Bearer your-token\"}',restBodyHint:'e.g. ON, {\"state\": \"on\"}',restStatusHint:\"URL to poll for current state\",restPathHint:\"e.g. state or data.power.status\",restPowerUrlHint:\"URL séparée pour les données de puissance (utilise l'URL de statut si vide)\",restEnergyUrlHint:\"URL séparée pour les données d'énergie (utilise l'URL de statut si vide)\",restEnergyHint:\"Chaque valeur peut utiliser sa propre URL ou se rabattre sur l'URL de statut. Utilisez les multiplicateurs pour la conversion d'unités (ex : 0.001 pour convertir Wh en kWh).\",testConnection:\"Test Connection\",connectionSuccess:\"Connection successful\",noSwitchesInSwitchbar:\"Aucun commutateur dans la barre\",enableSwitchbarHint:'Activez \"Afficher dans la barre de commutateurs\" dans Paramètres > Smart Plugs'},notifications:{providerTypes:{callmebot:\"CallMeBot/WhatsApp\",ntfy:\"ntfy\",pushover:\"Pushover\",telegram:\"Telegram\",email:\"E-mail\",discord:\"Discord\",webhook:\"Webhook\",homeassistant:\"Home Assistant\"},providerDescriptions:{email:\"Notifications par e-mail SMTP\",telegram:\"Notifications via un bot Telegram\",discord:\"Envoyer vers un canal Discord via webhook\",ntfy:\"Notifications push gratuites, auto-hébergeables\",pushover:\"Notifications push simples et fiables\",callmebot:\"Notifications WhatsApp gratuites via CallMeBot\",webhook:\"POST HTTP générique vers n'importe quelle URL\",homeassistant:\"Notifications persistantes dans le tableau de bord Home Assistant\"},lastSuccess:\"Dernier : {{date}}\",error:\"Erreur\",printer:\"Imprimante :\",allPrinters:\"Toutes les imprimantes\",sendTestNotification:\"Envoyer une notification de test\",eventSettings:\"Paramètres des événements\",enabled:\"Activé\",sendFromProvider:\"Envoyer des notifications depuis ce fournisseur\",printEvents:\"Événements d'impression\",printerStatus:\"État de l'imprimante\",amsAlarms:\"Alarmes AMS\",amsHtAlarms:\"Alarmes AMS-HT\",printQueue:\"File d'attente d'impression\",start:\"Début\",plateCheck:\"Vérification du plateau\",complete:\"Terminé\",failed:\"Échoué\",stopped:\"Arrêté\",progress:\"Progression\",offline:\"Hors ligne\",lowFilament:\"Filament bas\",maintenance:\"Maintenance\",amsHumidity:\"Humidité AMS\",amsTemp:\"Temp. AMS\",amsHtHumidity:\"Humidité AMS-HT\",amsHtTemp:\"Temp. AMS-HT\",bedCooled:\"Plateau refroidi\",firstLayer:\"Première couche\",quiet:\"Silencieux\",digest:\"Résumé {{time}}\",printStarted:\"Impression démarrée\",plateNotEmpty:\"Plateau non vide\",plateNotEmptyDescription:\"Objets détectés avant l'impression\",printCompleted:\"Impression terminée\",bedCooledLabel:\"Plateau refroidi\",bedCooledDescription:\"Plateau refroidi sous le seuil après l'impression\",firstLayerCompleteLabel:\"Première couche terminée\",firstLayerCompleteDescription:\"Notification avec photo après la première couche\",missingSpoolAssignmentLabel:\"Affectation de bobine manquante\",missingSpoolAssignmentDescription:\"Notifier quand une impression démarre et que des bacs requis n'ont pas de bobine assignée\",printFailed:\"Impression échouée\",printStopped:\"Impression arrêtée\",progressMilestones:\"Jalons de progression\",progressMilestonesDescription:\"Notifier à 25 %, 50 %, 75 %\",printerOffline:\"Imprimante hors ligne\",printerError:\"Erreur de l'imprimante\",lowFilamentLabel:\"Filament bas\",maintenanceDue:\"Maintenance requise\",maintenanceDueDescription:\"Notifier lorsqu'une maintenance est nécessaire\",amsHumidityHigh:\"Humidité AMS élevée\",amsHumidityHighDescription:\"L'humidité de l'AMS standard dépasse le seuil\",amsTemperatureHigh:\"Température AMS élevée\",amsTemperatureHighDescription:\"La température de l'AMS standard dépasse le seuil\",amsHtHumidityHigh:\"Humidité AMS-HT élevée\",amsHtHumidityHighDescription:\"L'humidité de l'AMS-HT dépasse le seuil\",amsHtTemperatureHigh:\"Température AMS-HT élevée\",amsHtTemperatureHighDescription:\"La température de l'AMS-HT dépasse le seuil\",jobAdded:\"Tâche ajoutée\",jobAddedDescription:\"Tâche ajoutée à la file d'attente\",jobAssigned:\"Tâche assignée\",jobAssignedDescription:\"Tâche basée sur le modèle assignée à l'imprimante\",jobStarted:\"Tâche démarrée\",jobStartedDescription:\"La tâche de la file a commencé l'impression\",jobWaiting:\"Tâche en attente\",jobWaitingDescription:\"Tâche en attente de filament ou imprimante\",jobSkipped:\"Tâche ignorée\",jobSkippedDescription:\"Tâche ignorée (échec précédent)\",jobFailed:\"Tâche échouée\",jobFailedDescription:\"La tâche n'a pas pu démarrer\",queueComplete:\"File d'attente terminée\",queueCompleteDescription:\"Toutes les tâches de la file sont terminées\",quietHours:\"Heures silencieuses\",noNotificationsDuring:\"Aucune notification pendant ces heures\",editProviderToChangeQuietHours:\"Modifier le fournisseur pour changer les heures silencieuses\",dailyDigest:\"Résumé quotidien\",batchNotifications:\"Regrouper les notifications en un seul résumé quotidien\",sendAt:\"Envoyer à {{time}}\",editProviderToChangeDigestTime:\"Modifier le fournisseur pour changer l'heure du résumé\",edit:\"Modifier\",deleteProvider:\"Supprimer le fournisseur de notifications\",deleteConfirm:\"Êtes-vous sûr de vouloir supprimer « {{name}} » ? Cette action est irréversible.\",delete:\"Supprimer\",addTitle:\"Ajouter un fournisseur de notifications\",editTitle:\"Modifier le fournisseur de notifications\",nameLabel:\"Nom *\",namePlaceholder:\"Mes notifications\",providerTypeLabel:\"Type de fournisseur *\",configuration:\"Configuration\",testConfiguration:\"Tester la configuration\",printerFilter:\"Filtre d'imprimante\",onlyFromPrinter:\"Envoyer uniquement les notifications pour les événements de cette imprimante\",quietHoursDnd:\"Heures silencieuses (Ne pas déranger)\",quietStart:\"Début\",quietEnd:\"Fin\",dailyDigestLabel:\"Résumé quotidien\",sendDigestAt:\"Envoyer le résumé à\",digestCollected:\"Les événements seront collectés et envoyés en un seul résumé à cette heure\",notificationEvents:\"Événements de notification\",progressPercent:\"(25 %, 50 %, 75 %)\",bedCooledAfterPrint:\"(après la fin de l'impression)\",cancel:\"Annuler\",save:\"Enregistrer\",add:\"Ajouter\",nameRequired:\"Le nom est requis\",fieldRequired:\"{{field}} est requis\",phoneNumber:\"Numéro de téléphone\",apiKey:\"Clé API\",serverUrl:\"URL du serveur\",topic:\"Sujet\",authToken:\"Jeton d'authentification\",userKey:\"Clé utilisateur\",appToken:\"Jeton d'application\",priority:\"Priorité\",botToken:\"Jeton du bot\",chatId:\"ID du chat\",smtpServer:\"Serveur SMTP\",smtpPort:\"Port SMTP\",security:\"Sécurité\",authentication:\"Authentification\",username:\"Nom d'utilisateur\",password:\"Mot de passe\",fromEmail:\"E-mail expéditeur\",toEmail:\"E-mail destinataire\",webhookUrl:\"URL du webhook\",payloadFormat:\"Format du payload\",authorization:\"Autorisation\",titleFieldName:\"Nom du champ titre\",messageFieldName:\"Nom du champ message\",editTemplate:\"Modifier le modèle : {{name}}\",titleLabel:\"Titre\",bodyLabel:\"Corps\",titlePlaceholder:\"Titre de la notification...\",bodyPlaceholder:\"Corps de la notification...\",availableVariables:\"Variables disponibles\",clickToInsert:\"Cliquer pour insérer à la position du curseur dans le corps\",livePreview:\"Aperçu en direct\",hide:\"Masquer\",show:\"Afficher\",loadingPreview:\"Chargement de l'aperçu...\",enterTemplateContent:\"Saisir le contenu du modèle pour voir l'aperçu\",titlePreview:\"Titre :\",bodyPreview:\"Corps :\",resetToDefault:\"Réinitialiser par défaut\",titleRequired:\"Le titre est requis\",bodyRequired:\"Le corps est requis\",notificationLog:\"Journal des notifications\",showFailedOnly:\"Échecs uniquement\",last24Hours:\"Dernières 24 heures\",last7Days:\"7 derniers jours\",last30Days:\"30 derniers jours\",last90Days:\"90 derniers jours\",justNow:\"À l'instant\",noFailedNotifications:\"Aucune notification échouée\",noNotificationsLogged:\"Aucune notification enregistrée\",unknownProvider:\"Fournisseur inconnu\",logTitle:\"Titre\",logMessage:\"Message\",logError:\"Erreur\",logProvider:\"Fournisseur : {{type}}\",logTime:\"Heure : {{time}}\",refresh:\"Actualiser\",clearOld:\"Purger les anciens\",statsSummary:\"{{days}} derniers jours :\",statsNotifications:\"notifications\",statsSent:\"{{count}} envoyées\",statsFailed:\"{{count}} échouées\",eventTypes:{print_start:\"Impression démarrée\",print_complete:\"Impression terminée\",print_failed:\"Impression échouée\",print_stopped:\"Impression arrêtée\",print_progress:\"Progression\",printer_offline:\"Imprimante hors ligne\",printer_error:\"Erreur de l'imprimante\",filament_low:\"Filament bas\",maintenance_due:\"Maintenance requise\",test:\"Test\"},userEmail:{title:\"Notifications\",emailNotifications:\"Notifications par e-mail\",emailNotificationsDesc:\"Recevez des notifications par e-mail pour vos propres travaux d'impression. Les e-mails sont envoyés via les paramètres SMTP configurés dans l'authentification avancée.\",sendingTo:\"Les notifications seront envoyées à\",noEmailWarning:\"Votre compte n'a pas d'adresse e-mail. Contactez un administrateur pour en ajouter une.\",printJobNotifications:\"Notifications de travaux d'impression\",printJobNotificationsDesc:\"Choisissez quels événements déclenchent des notifications par e-mail pour les travaux d'impression que vous soumettez.\",printJobStarts:\"Démarrage du travail d'impression\",printJobStartsDesc:\"Être notifié quand votre travail d'impression commence.\",printJobFinishes:\"Fin du travail d'impression\",printJobFinishesDesc:\"Être notifié quand votre travail d'impression se termine avec succès.\",printErrors:\"Erreurs d'impression\",printErrorsDesc:\"Être notifié quand votre travail d'impression échoue ou rencontre une erreur.\",printJobStops:\"Travail d'impression arrêté\",printJobStopsDesc:\"Être notifié quand votre travail d'impression est annulé ou arrêté.\",saveSuccess:\"Préférences de notification sauvegardées.\",saveError:\"Impossible de sauvegarder les préférences de notification.\"}},richTextEditor:{bold:\"Gras\",italic:\"Italique\",underline:\"Souligné\",bulletList:\"Liste à puces\",numberedList:\"Liste numérotée\",alignLeft:\"Aligner à gauche\",alignCenter:\"Centrer\",alignRight:\"Aligner à droite\",addLink:\"Ajouter lien\",removeLink:\"Retirer lien\"},externalLinks:{noLinksConfigured:\"Aucun lien externe configuré\",deleteLink:\"Supprimer lien\",removeCustomIcon:\"Retirer icône personnalisée\",openInNewTab:\"Ouvrir dans un nouvel onglet\",placeholders:{linkName:\"Mon Lien\"}},keyboardShortcuts:{title:\"Raccourcis Clavier\",navigation:\"Navigation\",archivesSection:\"Archives\",kProfilesSection:\"K-Profiles\",generalSection:\"Général\",shortcuts:{goToPrinters:\"Aller aux Imprimantes\",goToArchives:\"Aller aux Archives\",goToQueue:\"Aller à la File\",goToStats:\"Aller aux Stats\",goToProfiles:\"Aller aux Profils Cloud\",goToSettings:\"Aller aux Paramètres\",focusSearch:\"Focus recherche\",openUploadModal:\"Ouvrir téléversement\",clearSelection:\"Tout déselectionner\",contextMenu:\"Menu contextuel (cartes)\",refreshProfiles:\"Rafraîchir profils\",newProfile:\"Nouveau profil\",exitSelectionMode:\"Quitter mode sélection\",showHelp:\"Afficher cette aide\"},footer:\"Échap ou clic extérieur pour fermer\"},notificationLog:{title:\"Journal de Notification\",events:{printStarted:\"Début impression\",printComplete:\"Fin impression\",printFailed:\"Échec impression\",printStopped:\"Arrêt impression\",progress:\"Progression\",printerOffline:\"Hors ligne\",printerError:\"Erreur\",lowFilament:\"Filament bas\",maintenanceDue:\"Maintenance\",test:\"Test\"},timeAgo:{justNow:\"À l'instant\",minutesAgo:\"Il y a {{minutes}}m\",hoursAgo:\"Il y a {{hours}}h\"}},restoreBackup:{title:\"Restaurer Sauvegarde\",restoring:\"Restauration...\",restoreComplete:\"Restauration terminée\",restoreFailed:\"Échec restauration\",importSettings:\"Importer les réglages d'un fichier\",pleaseWait:\"Patientez pendant la restauration\",clickToSelect:\"Fichier .json ou .zip\",howDuplicateHandling:\"Gestion des doublons :\",categories:{printers:\"Imprimantes\",smartPlugs:\"Prises\",notificationProviders:\"Fournisseurs\",filaments:\"Filaments\",archives:\"Archives\",pendingUploads:\"Téléversements en attente\",settingsTemplates:\"Réglages & Modèles\"},matchingInfo:{printers:\"par numéro de série\",smartPlugs:\"par adresse IP\",notificationProviders:\"par nom\",filaments:\"par nom+type+marque\",archives:\"par empreinte numérique (hash)\",pendingUploads:\"par nom de fichier\",settingsTemplates:\"toujours écrasés\"},replaceExisting:\"Remplacer existant\",keepExisting:\"Garder existant\",replaceDescription:\"Écrase les doublons avec la sauvegarde\",keepDescription:\"Ne restaure que les éléments absents\",caution:\"Attention :\",cautionText:\"L'écrasement remplacera vos réglages. Les codes d'accès imprimantes sont exclus par sécurité.\",itemsRestored:\"Éléments restaurés\",itemsSkipped:\"Éléments ignorés\",restored:\"Restaurés\",skipped:\"Ignorés (déjà présents)\",filesLabel:\"Fichiers (3MF, vignettes, etc.)\",newApiKeysGenerated:\"Nouvelles clés API générées\",newApiKeysWarning:\"Copiées maintenant, elles ne seront plus visibles !\",processingBackup:\"Traitement du fichier...\",noDataFound:\"Aucune donnée trouvée dans le fichier.\",failedToRestore:\"Échec restaure. Vérifiez le format.\"},backupExport:{title:\"Exporter Sauvegarde\",selectData:\"Données à inclure\",selectAll:\"Tout sélectionner\",selectNone:\"Ne rien sélectionner\",categoryDescriptions:{settings:\"Langue, thèmes, préférences\",notifications:\"ntfy, Pushover, Discord, etc.\",templates:\"Modèles de messages personnalisés\",smartPlugs:\"Configuration des prises Tasmota\",externalLinks:\"Liens externes de la barre latérale\",printers:\"Infos imprimantes (codes d'accès exclus par défaut)\",plateDetection:\"Images références des plateaux vides\",filaments:\"Types et coûts filaments\",maintenance:\"Plannings de maintenance personnalisés\",archives:\"Données impressions + fichiers (3MF, vignettes, etc.)\",projects:\"Projets, BOM, pièces jointes\",pendingUploads:\"En attente revue virtuelle\",apiKeys:\"Clés Webhook (nouvelles clés générées à l'import)\"},requiresPrinters:\"Nécessite sélection des imprimantes\",zipFileWarning:\"Fichier ZIP créé.\",zipFileDescription:\"Contient tous les médias. Peut être volumineux.\",includeAccessCodes:\"Inclure Codes d'accès\",includeAccessCodesDescription:\"Pour migrer vers une autre machine\",includeAccessCodesWarning:\"Codes en texte clair. Sécurisez ce fichier !\",categoriesSelected:\"{{selectedCount}} catégories choisies\"},pendingUploads:{placeholders:{notes:\"Notes sur l'impression...\"},discardUpload:\"Rejeter\",archiveAllUploads:\"Tout archiver\",discardAllUploads:\"Tout rejeter\",archive:\"Archiver\",timeAgo:{justNow:\"À l'instant\",minutesAgo:\"Il y a {{minutes}}m\",hoursAgo:\"Il y a {{hours}}h\",daysAgo:\"Il y a {{days}}j\"}},apiBrowser:{placeholders:{requestBody:\"Corps JSON...\",searchEndpoints:\"Chercher endpoints...\"}},configureAmsSlot:{title:\"Configurer le slot AMS\",slotConfigured:\"Slot configuré !\",configuringSlot:\"Configuration du slot :\",slotLabel:\"{{ams}} Slot {{slot}}\",searchPresets:\"Chercher presets...\",colorPlaceholder:\"Nom couleur ou hex (ex: brown, FF8800)\",clearCustomColor:\"Effacer couleur perso\",noCloudPresets:\"Profils Cloud absents. Connectez-vous.\",noPresetsAvailable:\"Aucun preset disponible. Connectez-vous à Bambu Cloud ou importez des profils locaux.\",noMatchingPresets:\"Aucun profil trouvé.\",custom:\"Perso\",builtin:\"Inclus\",settingsSentToPrinter:\"Réglages envoyés\",filamentProfile:\"Profil Filament\",kProfileLabel:\"Profil K (Pressure Advance)\",filteringFor:\"Filtrage pour : {{material}}\",noKProfile:\"Pas de profil K (utiliser défaut 0.020)\",noMatchingKProfiles:\"Aucun profil K trouvé. K=0.020 par défaut sera utilisé.\",selectFilamentFirst:\"Sélectionnez d'abord un profil filament\",kFromCalibration:\"K={{value}} de la calibration imprimante\",customColorLabel:\"Couleur personnalisée (optionnel)\",presetColors:\"Couleurs {{name}} :\",showLessColors:\"Moins de couleurs\",showMoreColors:\"Plus de couleurs\",clear:\"Effacer\",hexLabel:\"Hex : #{{hex}}\",resetting:\"Réinitialisation...\",resetSlot:\"Réinitialiser le slot\",cancel:\"Annuler\",configuring:\"Configuration...\",configureSlot:\"Configurer le slot\"},githubBackup:{title:\"Sauvegarde GitHub\",history:\"Historique\",downloadBackup:\"Télécharger\",restoreBackup:\"Restaurer\",noBackupsYet:\"Aucune sauvegarde\"},emailSettings:{placeholders:{fromName:\"BamBuddy\"}},tagManagement:{searchTags:\"Chercher tags...\",renameTag:\"Renommer tag\",deleteTag:\"Supprimer tag\"},notificationTemplates:{placeholders:{title:\"Titre notification...\",body:\"Message notification...\"}},batchTag:{placeholders:{newTag:\"Nouveau tag...\"}},photoGallery:{deletePhoto:\"Supprimer photo\"},filamentHoverCard:{copySpoolUuid:\"Copier UUID bobine\"},kProfilesView:{hasNote:\"A une note\",copyProfile:\"Copier profil\"},layout:{openMenu:\"Ouvrir menu\",noPermissionSystemInfo:\"Pas d'autorisation système\"},dashboard:{dragToReorder:\"Glisser pour réorganiser\",hideWidget:\"Masquer widget\"},notificationProviderCard:{deleteNotificationProvider:\"Supprimer fournisseur\"},fileManagerModal:{closeFileManager:\"Fermer gestionnaire\",sortFiles:\"Trier fichiers\",goToParentFolder:\"Dossier parent\",threeView:\"Vue 3D\"},embeddedCameraViewer:{refreshStream:\"Actualiser flux\",close:\"Fermer\",zoomOut:\"Zoom -\",resetZoom:\"Reset zoom\",zoomIn:\"Zoom +\",dragToResize:\"Glisser pour dimension\"},timelapseViewer:{skipBack5s:\"-5s\",skipForward5s:\"+5s\"},notificationProviders:{descriptions:{email:\"Notifications par email SMTP\",telegram:\"Via bot Telegram\",discord:\"Via webhook Discord\",ntfy:\"Push auto-hébergé (ntfy)\",pushover:\"Push fiable (Pushover)\",callmebot:\"WhatsApp gratuit via CallMeBot\",webhook:\"Requête HTTP POST personnalisée\"}},logViewer:{searchPlaceholder:\"Message ou nom...\",noLogEntries:\"Aucune entrée journal\"},switchbarPopover:{noSwitchesInSwitchbar:\"Aucun interrupteur\"},projectPageModal:{placeholders:{title:\"Titre\",designer:\"Designer\",license:\"Licence\",description:\"Description...\",profileTitle:\"Titre du profil\",profileDescription:\"Description du profil...\"}},spoolmanSettings:{},time:{unknown:\"-\",waiting:\"En attente\",justNow:\"À l'instant\",now:\"Maintenant\",minsAgo:\"il y a {{count}}m\",inMins:\"dans {{count}}m\",hoursAgo:\"il y a {{count}}h\",inHours:\"dans {{count}}h\",daysAgo:\"il y a {{count}}j\",inDays:\"dans {{count}}j\"},spoolbuddy:{nav:{dashboard:\"Tableau de bord\",ams:\"AMS\",inventory:\"Inventaire\",writeTag:\"Écrire\",settings:\"Paramètres\"},status:{nfcReady:\"NFC prêt\",nfcOff:\"NFC désactivé\",offline:\"Hors ligne\",online:\"En ligne\",noPrinters:\"Aucune imprimante\",deviceOffline:\"Appareil hors ligne\",waitingConnection:\"En attente de connexion...\",systemReady:\"Système prêt\",status:\"Statut\"},dashboard:{readyToScan:\"Prêt à scanner\",idleMessage:\"Placez une bobine sur la balance pour l'identifier\",nfcHint:\"Le tag NFC sera lu automatiquement\",device:\"Appareil\",syncWeight:\"Sync. poids\",weightSynced:\"Synchronisé !\",unknownTag:\"Tag inconnu\",newTag:\"Nouveau tag détecté\",onScale:\"sur la balance\",linkSpool:\"Lier à une bobine\",linkTagTitle:\"Lier le tag à une bobine\",linkTag:\"Lier le tag\",selectSpool:\"Sélectionnez une bobine à lier à ce tag :\",noUntagged:\"Aucune bobine sans tag trouvée\",tagDetected:\"Tag détecté\",noTag:\"Pas de tag\",tagId:\"Tag\",grossWeight:\"Poids brut\",spoolSize:\"Taille bobine\",close:\"Fermer\",currentSpool:\"Bobine actuelle\"},modal:{spoolDetected:\"Bobine détectée\",assignToAms:\"Assigner à l'AMS\",syncWeight:\"Synchroniser le poids\",weightSynced:\"Synchronisé !\",syncing:\"Synchronisation...\",newTagDetected:\"Nouveau tag détecté\",addToInventory:\"Ajouter à l'inventaire\",assignToAmsTitle:\"Assigner à l'AMS\",selectSlot:\"Sélectionner un emplacement\",assign:\"Assigner\",assigning:\"Attribution...\",assignSuccess:\"Assigné !\",assignError:\"Échec de l'attribution de la bobine. Veuillez réessayer.\",noPrinterSelected:\"Sélectionner une imprimante...\",noAmsDetected:\"Aucun AMS détecté sur cette imprimante\",slot:\"Emplacement\"},weight:{noReading:\"Pas de lecture\",stable:\"Stable\",measuring:\"Mesure...\",tare:\"Tarer\",calibrate:\"Calibrer\"},spool:{remaining:\"Restant\",material:\"Matériau\",brand:\"Marque\",color:\"Couleur\",coreWeight:\"Noyau\",labelWeight:\"Étiquette\",scaleWeight:\"Balance\",netWeight:\"Net\",lastUsed:\"Dernière utilisation\"},ams:{noData:\"Aucun AMS détecté\",connectAms:\"Connectez un AMS pour voir les slots\",noPrinter:\"Aucune imprimante sélectionnée\",selectPrinter:\"Sélectionnez une imprimante dans la barre supérieure\",printerDisconnected:\"Imprimante déconnectée\",humidity:\"Humidité\",level:\"Niveau\",active:\"Actif\",slot:\"Slot\",empty:\"Vide\"},inventory:{search:\"Rechercher des bobines...\",empty:\"Aucune bobine dans l'inventaire\",noResults:\"Aucune bobine correspondante\",spools:\"bobines\",addSpool:\"Ajouter une bobine\"},settings:{tabDevice:\"Appareil\",tabDisplay:\"Affichage\",tabScale:\"Balance\",tabUpdates:\"Mises à jour\",nfcReader:\"Lecteur NFC\",type:\"Type\",connection:\"Connexion\",notConnected:\"N/A\",deviceInfo:\"Info appareil\",hostname:\"Hôte\",uptime:\"Temps de fonctionnement\",brightness:\"Luminosité\",saved:\"Enregistré\",noBacklight:\"Aucun rétroéclairage DSI détecté. Le contrôle de luminosité nécessite un écran DSI.\",screenBlank:\"Délai d'extinction\",screenBlankDesc:\"L'écran s'éteint après inactivité. Touchez pour réveiller.\",displayNote:\"La luminosité est appliquée comme filtre logiciel.\",scaleCalibration:\"Calibration de la balance\",currentWeight:\"Poids actuel\",tareOffset:\"Tare\",calFactor:\"Facteur\",knownWeight:\"Poids connu\",calStep1:\"Retirez tout de la balance et appuyez sur Mettre à zéro.\",calStep2:\"Placez le poids connu sur la balance.\",setZero:\"Mettre à zéro\",calibrateNow:\"Calibrer\",calibrated:\"Calibré\",tareSet:\"Commande de tare envoyée. En attente de l'appareil...\",tareFailed:\"Échec de l'envoi de la commande de tare\",zeroSet:\"Point zéro défini. Placez le poids connu sur la balance.\",calibrationDone:\"Calibration terminée !\",calibrationFailed:\"Échec de la calibration\",lastCalibrated:\"Dernière calibration\",stable:\"Stable\",settling:\"Stabilisation...\",firmware:\"Firmware\",scale:\"Balance\",noDevice:\"Aucun appareil SpoolBuddy trouvé\",daemonVersion:\"Version du daemon\",currentVersion:\"Actuelle\",versionPending:\"En attente du daemon...\",checking:\"Vérification...\",checkUpdates:\"Vérifier les mises à jour\",updateAvailable:\"Mise à jour disponible\",updateInstructions:\"Mise à jour via SSH : exécutez le script d'installation SpoolBuddy.\",upToDate:\"À jour\",includeBeta:\"Inclure les versions bêta\"},writeTag:{tabExisting:\"Bobine existante\",tabNew:\"Nouvelle bobine\",tabReplace:\"Remplacer le tag\",searchPlaceholder:\"Rechercher par matériau, couleur, marque...\",noUntaggedSpools:\"Aucune bobine sans tag\",noTaggedSpools:\"Aucune bobine avec tag\",selectSpool:\"Sélectionnez une bobine, puis placez un NTAG sur le lecteur\",placeTag:\"Placez un NTAG sur le lecteur\",tagReady:\"Tag détecté — prêt à écrire\",writeTag:\"Écrire le tag\",replaceTag:\"Remplacer le tag\",writing:\"Écriture du tag...\",waiting:\"En attente de SpoolBuddy...\",writeSuccess:\"Tag écrit avec succès !\",writeFailed:\"Échec de l'écriture\",queueFailed:\"Impossible de mettre en file la commande d'écriture\",tryAgain:\"Réessayer\",cancel:\"Annuler\",replaceWarning:\"L'ancien tag sera dissocié. Le nouveau tag le remplacera.\",deviceOffline:\"SpoolBuddy est hors ligne\",material:\"Matériau\",colorName:\"Nom de la couleur\",color:\"Couleur\",brand:\"Marque\",weight:\"Poids (g)\",createSpool:\"Créer la bobine\",creating:\"Création...\",spoolCreated:\"Bobine créée ! Prêt à écrire.\",createFailed:\"Impossible de créer la bobine\"},quickMenu:{printerPower:\"Alimentation imprimante\",systemControls:\"Système\",restartDaemon:\"Redémarrer le daemon\",restartBrowser:\"Redémarrer le navigateur\",reboot:\"Redémarrer\",shutdown:\"Éteindre\",swipeToClose:\"Glisser vers le bas pour fermer\",confirmTitle:\"Confirmer\",confirmShutdown:\"Êtes-vous sûr de vouloir éteindre le SpoolBuddy ? Vous aurez besoin d'un accès physique pour le rallumer.\",confirmReboot:\"Êtes-vous sûr de vouloir redémarrer le SpoolBuddy ?\",confirmRestartDaemon:\"Redémarrer le daemon SpoolBuddy ? Le NFC et la balance seront temporairement indisponibles.\",confirmRestartBrowser:\"Redémarrer le navigateur kiosque ? L'écran sera brièvement noir.\",confirm:\"Confirmer\",confirmPlugOn:\"Allumer {{name}} ?\",confirmPlugOff:\"Éteindre {{name}} ?\",turnOn:\"Allumer\",turnOff:\"Éteindre\"}},bugReport:{title:\"Signaler un bug\",description:\"Description\",descriptionPlaceholder:\"Qu'est-ce qui n'a pas fonctionné ? Veuillez décrire le problème...\",email:\"E-mail (optionnel)\",emailPlaceholder:\"votre@email.fr\",emailPrivacy:\"Si fourni, votre e-mail sera inclus dans une section repliée de l'issue GitHub pour que le mainteneur puisse vous contacter.\",screenshot:\"Capture d'écran\",uploadOrPaste:\"Télécharger, coller ou glisser une image\",dataCollectedSummary:\"Quelles données sont incluses dans le rapport ?\",dataIncluded:\"Inclus :\",dataIncludedList:\"Version de l'app, OS, architecture, version Python, statistiques de base de données (compteurs uniquement), modèles d'imprimantes, nombre de buses, versions firmware, état de connexion, état des intégrations (Spoolman, MQTT, HA), paramètres non sensibles, nombre d'interfaces réseau, détails Docker, versions des dépendances.\",dataNeverIncluded:\"Jamais inclus :\",dataNeverIncludedList:\"Noms d'imprimantes, numéros de série, codes d'accès, mots de passe, adresses IP, adresses e-mail, clés API, tokens, URLs de webhook, noms d'hôtes ou noms d'utilisateurs.\",submit:\"Envoyer\",startLogging:\"Lancer la journalisation\",stepEnableLogging:\"Journalisation activée\",stepReproduce:\"Reproduisez le problème\",stepStopLogging:\"Arrêter & envoyer\",stopAndSubmit:\"Arrêter & Envoyer\",maxDuration:\"Arrêt auto après {{minutes}} min\",stoppingLogs:\"Collecte des journaux & envoi...\",submitting:\"Envoi du rapport de bug...\",submitSuccess:\"Rapport de bug envoyé avec succès !\",submitFailed:\"Échec de l'envoi du rapport de bug\",thankYou:\"Merci !\",submitted:\"Votre rapport de bug a été soumis.\",viewIssue:\"Voir l'issue\",unexpectedError:\"Une erreur inattendue est survenue\"},failureDetection:{title:\"Détection d'échec par IA\",description:\"Surveille les impressions via une API ML Obico auto-hébergée et agit automatiquement sur les échecs détectés.\",mlUrl:\"URL de l'API ML Obico\",mlUrlHint:\"URL de base de votre conteneur Obico ml_api auto-hébergé (ex. http://192.168.1.10:3333).\",test:\"Tester\",testSuccess:\"API ML accessible et fonctionnelle.\",testFailed:\"Impossible d'atteindre l'API ML.\",sensitivity:\"Sensibilité\",sensitivityLow:\"Basse (moins de faux positifs)\",sensitivityMedium:\"Moyenne (équilibrée)\",sensitivityHigh:\"Haute (détection précoce, plus de faux positifs)\",sensitivityHint:\"Ajuste les seuils de confiance qui déclenchent les avertissements et les échecs.\",action:\"Action sur échec détecté\",actionNotify:\"Notifier uniquement\",actionPause:\"Mettre en pause\",actionPauseOff:\"Pause et couper l'alimentation\",pollInterval:\"Intervalle de vérification (secondes)\",pollIntervalHint:\"Fréquence de vérification de chaque imprimante pendant l'impression. Minimum 5s, maximum 120s.\",externalUrlMissing:\"External URL is not set.\",externalUrlHint:\"The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.\",perPrinterTitle:\"Imprimantes surveillées\",perPrinterHint:\"Choisissez quelles imprimantes le service de détection surveille.\",monitorAll:\"Surveiller toutes les imprimantes connectées\",statusTitle:\"Statut\",serviceRunning:\"Service en cours d'exécution\",thresholds:\"Seuils bas / haut\",activePrinters:\"Impressions actives\",noActivePrints:\"Aucune impression en cours.\",historyTitle:\"Détections récentes\",noHistory:\"Aucune détection pour le moment.\"}},Wue={nav:{printers:\"プリンター\",archives:\"アーカイブ\",queue:\"キュー\",stats:\"統計\",profiles:\"プロファイル\",maintenance:\"メンテナンス\",projects:\"プロジェクト\",inventory:\"フィラメント\",files:\"ファイル管理\",notifications:\"通知\",settings:\"設定\",system:\"システム\",collapseSidebar:\"サイドバーを閉じる\",expandSidebar:\"サイドバーを開く\",update:\"アップデート\",updateAvailable:\"アップデートあり: v{{version}}\",updateAvailableBanner:\"バージョン {{version}} が利用可能です！\",viewUpdate:\"アップデートを表示\",viewOnGithub:\"GitHubで表示\",keyboardShortcuts:\"キーボードショートカット (?)\",switchToLight:\"ライトモードに切替\",switchToDark:\"ダークモードに切替\",smartSwitches:\"スマートスイッチ\",logout:\"ログアウト\"},common:{save:\"保存\",saving:\"保存中...\",cancel:\"キャンセル\",delete:\"削除\",edit:\"編集\",add:\"追加\",close:\"閉じる\",confirm:\"確認\",loading:\"読み込み中...\",error:\"エラー\",success:\"成功\",warning:\"警告\",enabled:\"有効\",disabled:\"無効\",yes:\"はい\",no:\"いいえ\",on:\"オン\",off:\"オフ\",all:\"すべて\",none:\"なし\",search:\"検索\",filter:\"フィルター\",sort:\"並べ替え\",refresh:\"更新\",download:\"ダウンロード\",upload:\"アップロード\",uploading:\"アップロード中...\",uploadFailed:\"アップロード失敗\",actions:\"操作\",status:\"ステータス\",name:\"名前\",description:\"説明\",date:\"日付\",time:\"時間\",hours:\"時間\",minutes:\"分\",seconds:\"秒\",days:\"日\",enable:\"有効化\",disable:\"無効にする\",permissions:\"権限\",noPrinters:\"プリンターが登録されていません\",noData:\"データがありません\",linkNotFound:\"リンクが見つかりません\",required:\"必須\",optional:\"オプション\",dismiss:\"閉じる\",apply:\"適用\",reset:\"リセット\",export:\"エクスポート\",import:\"インポート\",clear:\"クリア\",selectAll:\"すべて選択\",deselectAll:\"すべて選択解除\",noChange:\"— 変更なし —\",unchanged:\"変更なし\",unassigned:\"未割当\",unknown:\"不明\",unknownError:\"不明なエラー\",today:\"今日\",tomorrow:\"明日\",asap:\"即時\",overdue:\"期限超過\",now:\"今すぐ\",collapse:\"折りたたむ\",expand:\"展開\",viewArchive:\"アーカイブを表示\",viewInFileManager:\"ファイルマネージャーで表示\",addedBy:\"{{username}}が追加\",prints:\"プリント\",more:\"もっと見る\",ascending:\"昇順\",descending:\"降順\",back:\"戻る\",copy:\"コピー\",copied:\"コピーしました!\",printer:\"プリンター\",remove:\"削除\",type:\"種類\",print:\"印刷\",rename:\"名前変更\",move:\"移動\",create:\"作成\",duplicate:\"複製\",left:\"左\",right:\"右\"},printers:{title:\"プリンター\",addPrinter:\"プリンターを追加\",editPrinter:\"プリンターを編集\",deletePrinter:\"プリンターを削除\",printerName:\"プリンター名\",serialNumber:\"シリアル番号\",ipAddress:\"IPアドレス / ホスト名\",accessCode:\"アクセスコード\",model:\"モデル\",nozzleCount:\"ノズル数\",autoArchive:\"自動アーカイブ\",status:{available:\"利用可能\",idle:\"待機中\",printing:\"印刷中\",paused:\"一時停止\",offline:\"オフライン\",problem:\"問題\",error:\"エラー\",finished:\"完了\",unknown:\"不明\"},temperatures:{nozzle:\"ノズル\",bed:\"ベッド\",chamber:\"チャンバー\"},progress:\"{{percent}}% 完了\",timeRemaining:\"残り {{time}}\",deleteConfirm:\"「{{name}}」を削除しますか？\",maintenanceOk:\"メンテナンス正常\",maintenanceWarning:\"{{count}}件の警告\",maintenanceWarning_plural:\"{{count}}件の警告\",maintenanceDue:\"{{count}}件のメンテナンス期限\",maintenanceDue_plural:\"{{count}}件の期限\",sort:{name:\"名前\",status:\"ステータス\",model:\"モデル\",location:\"ロケーション\",ascending:\"昇順で並べ替え\",descending:\"降順で並べ替え\"},cardSize:{small:\"小\",medium:\"中\",large:\"大\",extraLarge:\"特大\"},hideOffline:\"オフラインを非表示\",nextAvailable:\"次に完了\",powerOn:\"電源オン\",offlinePrintersWithPlugs:\"スマートプラグ付きオフラインプリンター\",noPrintersConfigured:\"プリンターが設定されていません\",search:\"プリンターを検索...\",noSearchResults:\"検索またはフィルターに一致するプリンターがありません\",filter:{allStatuses:\"すべてのステータス\",allLocations:\"すべての場所\"},readyToPrint:\"印刷可能\",external:\"外部\",extL:\"Ext-L\",extR:\"Ext-R\",deleteArchives:\"印刷アーカイブを削除\",noLabel:\"ラベルなし\",printPreview:\"印刷プレビュー\",width:\"幅\",height:\"高さ\",noObjectsFound:\"オブジェクトが見つかりません\",objectsLoadedOnPrintStart:\"オブジェクトは印刷開始時に読み込まれます\",willBeSkipped:\"スキップされます\",name:\"名前\",serialCannotBeChanged:\"シリアル番号は変更できません\",locationHelp:\"プリンターのグループ化とキュージョブのフィルタリングに使用\",wifiSignal:{veryWeak:\"非常に弱い\",weak:\"弱い\",fair:\"注意\",good:\"良好\",excellent:\"非常に良い\"},maintenanceUpToDate:\"すべてのメンテナンスが最新です\",chamberLightOn:\"チャンバーライトをオンにしました\",chamberLightOff:\"チャンバーライトをオフにしました\",files:\"ファイル\",browseFiles:\"プリンターのファイルを参照\",autoOffAfterPrint:\"印刷後に自動電源オフ\",autoOffExecuted:\"自動オフが実行されました - リセットするにはプリンターの電源を入れてください\",hmsErrors:\"クリックしてHMSエラーを表示\",viewHmsErrors:\"{{count}}件のHMSエラーを表示\",resume:\"再開\",pause:\"一時停止\",stop:\"停止\",camera:\"カメラ\",skipObject:\"オブジェクトスキップ\",reconnect:\"再接続\",forceRefresh:\"強制更新\",forceRefreshSuccess:\"更新をリクエストしました\",mqttDebug:\"MQTTデバッグ\",printerInformation:\"プリンター情報\",copyToClipboard:\"コピー\",copied:\"コピーしました！\",state:\"状態\",wifiSignalLabel:\"WiFi信号\",developerMode:\"開発者モード\",enabled:\"有効\",disabled:\"無効\",addedOn:\"追加日\",sdCard:\"SDカード\",inserted:\"挿入済み\",notInserted:\"未挿入\",totalPrintHours:\"印刷時間\",activeNozzle:\"アクティブ: {{side}}ノズル\",nozzleRack:\"ノズルラック\",nozzleDocked:\"ドッキング中\",nozzleMounted:\"マウント中\",nozzleActive:\"アクティブ\",nozzleIdle:\"アイドル\",nozzleDiameter:\"直径\",nozzleType:\"タイプ\",nozzleStatus:\"ステータス\",nozzleFilament:\"フィラメント\",nozzleWear:\"摩耗\",nozzleMaxTemp:\"最高温度\",nozzleSerial:\"シリアル\",nozzleHardenedSteel:\"焼入れ鋼\",nozzleStainlessSteel:\"ステンレス鋼\",nozzleTungstenCarbide:\"タングステンカーバイド\",nozzleFlow:\"フロー\",nozzleHighFlow:\"ハイフロー\",nozzleStandardFlow:\"スタンダード\",firmwareUpdate:\"ファームウェアアップデート\",firmwareInstructions:\"プリンターのタッチスクリーンで\",firmwareNav:\"に移動\",settings:\"設定\",firmware:\"ファームウェア\",discoverPrinters:\"プリンターを検出\",searching:\"検索中...\",manualEntry:\"手動入力\",addFromCloud:\"クラウドから追加\",toast:{printerDeleted:\"プリンターを削除しました\",missingSpoolAssignment:\"{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}\",printerAdded:\"プリンターを追加しました\",printerUpdated:\"プリンターを更新しました\",failedToDelete:\"プリンターの削除に失敗しました\",failedToAdd:\"プリンターの追加に失敗しました\",failedToUpdate:\"プリンターの更新に失敗しました\",commandSent:\"コマンドを送信しました\",failedToSendCommand:\"コマンドの送信に失敗しました\",turnedOn:\"{{name}} の電源をオンにしました\",failedToPowerOn:\"{{name}} の電源オンに失敗しました\",scriptTriggered:\"スクリプトを実行しました\",printStopped:\"印刷を停止しました\",printPaused:\"印刷を一時停止しました\",printResumed:\"印刷を再開しました\",referenceDeleted:\"リファレンスを削除しました\",detectionAreaSaved:\"検出エリアを保存しました\",failedToRunScript:\"スクリプトの実行に失敗しました\",failedToStopPrint:\"印刷の停止に失敗しました\",failedToPausePrint:\"印刷の一時停止に失敗しました\",failedToResumePrint:\"印刷の再開に失敗しました\",failedToControlChamberLight:\"チャンバーライトの制御に失敗しました\",failedToSetSpeed:\"印刷速度の設定に失敗しました\",failedToUpdateSetting:\"設定の更新に失敗しました\",failedToSkipObjects:\"オブジェクトのスキップに失敗しました\",failedToRereadRfid:\"RFIDの再読み取りに失敗しました\",failedToCheckPlate:\"プレートの確認に失敗しました\",failedToUpdateLabel:\"ラベルの更新に失敗しました\",failedToDeleteReference:\"リファレンスの削除に失敗しました\",failedToSaveDetectionArea:\"検出エリアの保存に失敗しました\",plateCheckEnabled:\"プレートチェックを有効にしました\",plateCheckDisabled:\"プレートチェックを無効にしました\",calibrationSaved:\"キャリブレーションを保存しました！\",calibrationFailed:\"キャリブレーションに失敗しました\",rfidRereadInitiated:\"RFID再読み取りを開始しました\"},connection:{connected:\"接続中\",offline:\"オフライン\"},plateStatus:{markCleared:\"プレートをクリア済みにする\",cleared:\"プレートクリア済み\",notCleared:\"プレート未クリア\",inUse:\"プレート使用中\"},queue:{inQueue:\"キュー内\",inQueue_plural:\"{{count}}件がキュー内\"},controls:\"コントロール\",rfid:{reread:\"RFID再読み取り\"},bedJog:{title:\"ビルドプレートを移動\",bed:\"ベッド\",step:\"ステップ (mm)\",up:\"プレートを上へ\",down:\"プレートを下へ\",disabledWhilePrinting:\"印刷中は無効\",notHomedTitle:\"プリンターがホーミングされていません\",notHomedMessage:\"前回の印刷以降、プリンターがホーミングされていません。安全な位置決めのためにまずオートホーミングを実行するか（ツールヘッドをパークしてからX・Y・Zをホーミングします）、このまま移動してください — ソフトエンドストップはバイパスされます。\",homeZ:\"オートホーミング\",moveAnyway:\"このまま移動\",homingStarted:\"プリンターをオートホーミング中…\"},permission:{noAdd:\"プリンターを追加する権限がありません\",noEdit:\"プリンターを編集する権限がありません\",noDelete:\"プリンターを削除する権限がありません\",noControl:\"プリンターを制御する権限がありません\",noFiles:\"このディレクトリにファイルがありません\",noAmsRfid:\"AMS RFIDを再読み取りする権限がありません\",noSmartPlugControl:\"スマートプラグを制御する権限がありません\",noCamera:\"カメラを表示する権限がありません\"},modal:{addTitle:\"プリンターを追加\",editTitle:\"プリンターを編集\",myPrinter:\"マイプリンター\",selectModel:\"モデルを選択...\",locationGroup:\"ロケーション / グループ\",locationPlaceholder:\"例: 工房、オフィス、地下室\",autoArchiveLabel:\"完了した印刷を自動アーカイブ\",fromPrinterSettings:\"プリンターの設定から取得\",modelOptional:\"モデル（任意）\",saveChanges:\"変更を保存\"},skipObjects:{tooltip:\"オブジェクトスキップ\",onlyWhilePrinting:\"オブジェクトスキップ（印刷中のみ）\",requiresMultiple:\"オブジェクトスキップ（2個以上必要）\",title:\"オブジェクトスキップ\",matchIdsInfo:\"プリンター画面のIDと照合してください\",printerShowsIds:\"プリンター画面にビルドプレート上のオブジェクトIDが表示されます\",skipSelected:\"選択をスキップ\",skipping:\"スキップ中...\",noObjectsSelected:\"オブジェクトが選択されていません\",selectObjectsToSkip:\"現在の印刷からスキップするオブジェクトを選択してください\",skipped:\"スキップ済み\",objectsSkipped:\"オブジェクトをスキップしました\",activeCount:\"{{count}}個アクティブ\",waitForLayer:\"オブジェクトをスキップするにはレイヤー2以降をお待ちください（現在レイヤー{{layer}}）\",skip:\"スキップ\",confirmTitle:\"オブジェクトをスキップしますか？\",confirmMessage:\"「{{name}}」をスキップしますか？この操作は元に戻せません。\"},confirm:{deleteTitle:\"プリンターを削除\",deleteMessage:\"「{{name}}」を削除しますか？すべての接続設定が削除されます。\",deleteArchivesNote:\"このプリンターのすべての印刷履歴が完全に削除されます。\",keepArchivesNote:\"印刷履歴は保持されますが、このプリンターとの関連は解除されます。\",stopTitle:\"印刷を停止\",stopMessage:\"「{{name}}」の現在の印刷を停止しますか？印刷ジョブがキャンセルされます。\",stopButton:\"印刷を停止\",pauseTitle:\"印刷を一時停止\",pauseMessage:\"「{{name}}」の現在の印刷を一時停止しますか？\",pauseButton:\"印刷を一時停止\",resumeTitle:\"印刷を再開\",resumeMessage:\"「{{name}}」の印刷を再開しますか？\",resumeButton:\"印刷を再開\",powerOnTitle:\"プリンターの電源をオン\",powerOnMessage:\"「{{name}}」の電源をオンにしますか？\",powerOnButton:\"電源オン\",powerOffTitle:\"プリンターの電源をオフ\",powerOffMessage:\"「{{name}}」の電源をオフにしますか？\",powerOffWarning:\"警告: 「{{name}}」は現在印刷中です！電源をオフにしますか？印刷が中断され、プリンターが損傷する可能性があります。\",powerOffButton:\"電源オフ\"},bulk:{select:\"選択\",selectAll:\"すべて選択\",selectByLocation:\"場所で選択\",selected:\"{{count}}件選択中\",actions:{stop:\"停止\",pause:\"一時停止\",resume:\"再開\",clearPlate:\"ベッドをクリア\",clearHMS:\"通知をクリア\"},confirm:{stopTitle:\"{{count}}件の印刷を停止\",stopMessage:\"{{count}}台のプリンターのアクティブな印刷をキャンセルします。この操作は元に戻せません。\",stopButton:\"すべて停止\",pauseTitle:\"{{count}}件の印刷を一時停止\",pauseMessage:\"{{count}}台のプリンターのアクティブな印刷を一時停止します。\",pauseButton:\"すべて一時停止\",clearPlateTitle:\"{{count}}台のプリントベッドをクリア\",clearPlateMessage:\"{{count}}台のプリンターのプリントベッドをクリアし、キュー内のジョブが開始される場合があります。\",clearPlateButton:\"すべてクリア\"},success:\"{{count}}台のプリンターで{{action}}が完了\",partial:\"{{succeeded}}件成功、{{failed}}件失敗\",noneApplicable:\"選択したプリンターにこのアクションに適した状態のものがありません\",selectByState:\"ステータスで選択\"},discovery:{title:\"プリンター\",searching:\"検索中...\",scanning:\"スキャン中...\",scanProgress:\"スキャン中... {{scanned}}/{{total}}\",foundPrinters:\"{{count}}台のプリンターを検出\",noPrintersFound:\"プリンターが見つかりません\",noPrintersFoundSubnet:\"指定されたサブネットにプリンターが見つかりません。\",noPrintersFoundNetwork:\"ネットワーク上にプリンターが見つかりません。\",allConfigured:\"検出されたすべてのプリンターは既に設定済みです。\",alreadyAdded:\"追加済み\",select:\"選択\",manualEntry:\"手動入力\",addFromCloud:\"クラウドから追加\",subnetToScan:\"スキャンするサブネット\",dockerNote:\"Dockerを検出しました。プリンターのサブネットをCIDR表記で入力してください。docker-compose.ymlでnetwork_mode: hostが必要です。\",scanSubnet:\"サブネットをスキャンしてプリンターを検出\",discoverNetwork:\"ネットワーク上のプリンターを検出\",scanningSubnet:\"サブネットでBambuプリンターをスキャン中...\",scanningNetwork:\"ネットワークをスキャン中...\",serialRequired:\"シリアル番号が必要です\",unknown:\"不明\",failedToStart:\"印刷の開始に失敗しました\"},drying:{start:\"乾燥開始\",stop:\"乾燥停止\",temperature:\"温度\",duration:\"時間\",hours:\"時間\",timeRemaining:\"残り {{time}}\",active:\"乾燥中\",notSupported:\"乾燥非対応\",powerRequired:\"AMS電源アダプターを接続して乾燥を有効にしてください\",startingDrying:\"乾燥を開始しています...\",stoppingDrying:\"乾燥を停止しています...\",rotateTray:\"乾燥中にスプールを回転\"},filaments:\"フィラメント\",openCameraOverlay:\"カメラオーバーレイを開く\",openCameraWindow:\"カメラを新しいウィンドウで開く\",firmwareUpdateAvailable:\"ファームウェアアップデートあり: {{current}} → {{latest}}\",firmwareUpToDate:\"ファームウェア {{version}} — 最新\",firmwareUpdateButton:\"アップデート\",plateDetection:{noPermission:\"このページにアクセスする権限がありません。\",enabledClick:\"プレートチェック有効 - クリックして無効化\",disabledClick:\"プレートチェック無効 - クリックして有効化\",manageCalibration:\"プレート検出キャリブレーションを管理\",calibrationRequired:\"キャリブレーションが必要です\",calibrationInstructions:\"ビルドプレートが<strong>完全に空</strong>であることを確認してから、キャリブレーションをクリックしてください。\",calibrationDescription:\"キャリブレーションは空のプレートのリファレンス画像を撮影します。以降のチェックではこのリファレンスと比較してオブジェクトを検出します。\",calibrationTip:\"<strong>ヒント:</strong> 異なるプレート用に最大5つのキャリブレーションを保存できます。チェック時に最適なものが自動的に使用されます。\",plateEmpty:\"プレートは空のようです\",objectsDetected:\"オブジェクトを検出しました\",confidence:\"信頼度\",difference:\"差分\",analysisPreview:\"分析プレビュー:\",analysisLegend:\"緑の枠 = 検出エリア、赤のオーバーレイ = キャリブレーションとの差分\",savedReferences:\"保存済みリファレンス ({{count}}/{{max}})\",deleteReference:\"リファレンスを削除\",labelPlaceholder:\"ラベル...\",clickToEdit:\"{{label}} - クリックして編集\",clickToAddLabel:\"クリックしてラベルを追加\"},speed:{title:\"印刷速度\",silent:\"サイレント (50%)\",standard:\"スタンダード (100%)\",sport:\"スポーツ (124%)\",ludicrous:\"ルディクラス (166%)\"},airduct:{title:\"エアダクトモード\",cooling:\"冷却\",heating:\"加熱\"},noSdCard:\"SDなし\",door:{open:\"開\",closed:\"閉\"},fans:{partCooling:\"パーツ冷却ファン\",auxiliary:\"補助ファン\",chamber:\"チャンバーファン\"},clickToViewHmsErrors:\"クリックしてHMSエラーを表示\",estimatedCompletion:\"完了予定時刻\",plateNumber:\"プレート {{number}}\",slotOptions:\"スロットオプション\",amsPopup:{friendlyName:\"AMS名\",friendlyNamePlaceholder:\"例: AMS フレンドリー名\",serialNumber:\"シリアル番号\",firmwareVersion:\"ファームウェア\",save:\"保存\",clear:\"クリア\",noEditPermission:\"AMS ユニットの名前を変更する権限がありません\"},firmwareModal:{title:\"ファームウェアアップデート\",titleUpToDate:\"ファームウェア情報\",currentVersion:\"現在のバージョン\",latestVersion:\"最新バージョン\",releaseNotes:\"リリースノート\",checkingPrereqs:\"前提条件を確認中...\",sdCardReady:\"SDカード準備完了。下をクリックしてファームウェアをアップロードしてください。\",uploadedSuccess:\"ファームウェアをSDカードにアップロードしました！\",applyInstructions:\"プリンターでアップデートを適用するには:\",step1:\"プリンターのタッチスクリーンで設定に移動\",step2:\"ファームウェアに移動\",step3:\"SDカードからアップデートを選択\",step4:\"アップデートには10〜20分かかります\",done:\"完了\",starting:\"開始中...\",uploadFirmware:\"ファームウェアをアップロード\",uploadFailed:\"アップロード開始に失敗しました: {{error}}\",uploadedToast:\"ファームウェアをアップロードしました！プリンター画面からアップデートを実行してください。\"},accessCodePlaceholder:\"プリンター設定から取得\",roi:{title:\"プリンター\",xStart:\"X開始\",yStart:\"Y開始\",width:\"幅\",height:\"高さ\",instruction:\"ビルドプレートに焦点を合わせるように検出エリアを調整してください。プレビューの緑の枠が現在のエリアを示しています。\"},developerModeWarning:\"開発者LANモードが有効になっていません: {{names}}。一部の機能が動作しない可能性があります。\",howToEnable:\"有効化方法\",incompatibleFile:\"このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です\",dropNotPrintable:\".gcodeおよび.gcode.3mfファイルのみ印刷できます\",dropToPrint:\"ドロップして印刷\",cannotPrint:\"プリンター使用中\"},archives:{title:\"印刷アーカイブ\",searchPlaceholder:\"アーカイブを検索...\",filterByPrinter:\"プリンターで絞り込み\",filterByStatus:\"ステータスで絞り込み\",sortBy:\"並べ替え\",sortNewest:\"新しい順\",sortOldest:\"古い順\",sortName:\"名前順\",sortDuration:\"時間順\",sortLargest:\"大きい順\",sortSmallest:\"小さい順\",sortSize:\"サイズ\",noArchives:\"アーカイブが見つかりません\",noArchivesSearch:\"検索条件に一致するアーカイブがありません\",originalPrintNotVisible:\"元の印刷が表示されていません - フィルターをクリアしてみてください\",noArchivesYet:\"アーカイブはまだありません\",prints:\"件\",pagination:{showing:\"表示中\",to:\"〜\",of:\"/\",show:\"表示\",page:\"ページ\",all:\"すべて\"},loadingArchives:\"アーカイブを読み込み中...\",releaseToUpload:\"ドロップしてアップロード\",showAll:\"すべて表示\",showFavoritesOnly:\"お気に入りのみ表示\",gridView:\"グリッド表示\",listView:\"リスト表示\",calendarView:\"カレンダー表示\",logView:\"印刷ログ\",manageTags:\"タグを管理\",showFailedPrints:\"失敗した印刷を表示\",hideFailedPrints:\"失敗した印刷を非表示\",hideDuplicates:\"重複を非表示\",viewOriginalPrint:\"クリックして元の印刷を表示 (#{{id}})\",printTime:\"印刷時間\",filamentUsed:\"フィラメント使用量\",cost:\"コスト\",reprint:\"再印刷\",preview:\"プレビュー\",deleteArchive:\"アーカイブを削除\",deleteConfirm:\"このアーカイブを削除しますか？\",favorite:\"お気に入り\",unfavorite:\"お気に入りから削除\",viewDetails:\"詳細を表示\",status:{completed:\"完了\",failed:\"失敗\",stopped:\"中止\"},toast:{source3mfAttached:\"ソース3MFを添付しました: {{filename}}\",failedUploadSource3mf:\"ソース3MFのアップロードに失敗しました\",source3mfRemoved:\"ソース3MFを削除しました\",failedRemoveSource3mf:\"ソース3MFの削除に失敗しました\",f3dAttached:\"F3Dを添付しました: {{filename}}\",failedUploadF3d:\"F3Dのアップロードに失敗しました\",f3dRemoved:\"F3Dを削除しました\",failedRemoveF3d:\"F3Dの削除に失敗しました\",timelapseAttached:\"タイムラプスを添付しました: {{filename}}\",timelapseAlreadyAttached:\"タイムラプスは既に添付されています\",noMatchingTimelapse:\"一致するタイムラプスが見つかりません\",failedScanTimelapse:\"タイムラプスのスキャンに失敗しました\",failedAttachTimelapse:\"タイムラプスの添付に失敗しました\",timelapseRemoved:\"タイムラプスを削除しました\",failedRemoveTimelapse:\"タイムラプスの削除に失敗しました\",timelapseUploaded:\"タイムラプスをアップロードしました: {{filename}}\",failedUploadTimelapse:\"タイムラプスのアップロードに失敗しました\",archiveDeleted:\"アーカイブを削除しました\",failedDeleteArchive:\"アーカイブの削除に失敗しました\",addedToFavorites:\"お気に入りに追加しました\",removedFromFavorites:\"お気に入りから削除しました\",projectUpdated:\"プロジェクトを更新しました\",failedUpdateProject:\"プロジェクトの更新に失敗しました\",linkCopied:\"リンクをクリップボードにコピーしました\",failedCopyLink:\"リンクのコピーに失敗しました\",photoDeleted:\"写真を削除しました\",failedDeletePhoto:\"写真の削除に失敗しました\",failedDeleteArchives:\"アーカイブの削除に失敗しました\",failedUpdateFavorites:\"お気に入りの更新に失敗しました\",exportDownloaded:\"エクスポートをダウンロードしました\",exportFailed:\"エクスポートに失敗しました\"},menu:{print:\"印刷\",schedule:\"スケジュール\",openInBambuStudio:\"スライサーで開く\",slice:\"スライス\",externalLink:\"外部リンク\",viewOnMakerWorld:\"MakerWorldで表示\",preview3d:\"3Dプレビュー\",viewTimelapse:\"タイムラプスを表示\",scanForTimelapse:\"タイムラプスをスキャン\",uploadTimelapse:\"タイムラプスをアップロード\",removeTimelapse:\"タイムラプスを削除\",downloadSource3mf:\"ソース3MFをダウンロード\",uploadSource3mf:\"ソース3MFをアップロード\",replaceSource3mf:\"ソース3MFを置換\",removeSource3mf:\"ソース3MFを削除\",uploadF3d:\"F3Dをアップロード\",replaceF3d:\"F3Dを置換\",downloadF3d:\"Fusion 360デザインファイルをダウンロード\",removeF3d:\"F3Dを削除\",download:\"ダウンロード\",copyDownloadLink:\"ダウンロードリンクをコピー\",qrCode:\"QRコード\",viewPhotos:\"{{count}}枚の写真を表示\",viewPhotosCount:\"写真を表示 ({{count}})\",projectPage:\"プロジェクトページ\",addToFavorites:\"お気に入りに追加\",removeFromFavorites:\"お気に入りから削除\",edit:\"編集\",goToProject:\"プロジェクトへ: {{name}}\",addToProject:\"プロジェクトに追加\",removeFromProject:\"プロジェクトから削除\",loading:\"アーカイブを読み込み中...\",noProjectsAvailable:\"利用可能なプロジェクトがありません\",select:\"選択\",deselect:\"選択解除\",delete:\"削除\"},permission:{noReprint:\"このアーカイブを再印刷する権限がありません\",noAddToQueue:\"キューに追加する権限がありません\",noUpdateArchives:\"アーカイブを更新する権限がありません\",noUploadFiles:\"ファイルをアップロードする権限がありません\",noDownload:\"アーカイブをダウンロードする権限がありません\",noCopyLink:\"ダウンロードリンクをコピーする権限がありません\",noDelete:\"このアーカイブを削除する権限がありません\",noCreate:\"アーカイブを作成する権限がありません\"},card:{previousPlate:\"前のプレート\",nextPlate:\"次のプレート\",plateNumber:\"プレート {{number}}\",moreOptions:\"その他のオプション\",addToFavorites:\"お気に入りに追加\",removeFromFavorites:\"お気に入りから削除\",cancelled:\"キャンセル\",failed:\"失敗\",duplicate:\"重複\",duplicateTitle:\"このモデルは以前印刷されています\",openSource3mf:\"ソース3MFをBambu Studioで開く（右クリックでオプション表示）\",downloadF3d:\"Fusion 360デザインファイルをダウンロード\",viewTimelapse:\"タイムラプスを表示\",viewPhoto:\"写真を表示\",viewPhotos:\"{{count}}枚の写真を表示\",openFolder:\"フォルダーを開く: {{name}}\",slicedFile:\"スライス済みファイル - 印刷可能\",sourceFile:\"ソースファイルのみ - AMSマッピング不可\",gcode:\"GCODE\",source:\"ソース\",project:\"プロジェクト\",estimated:\"推定: {{time}}\",actual:\"実際: {{time}}\",accuracy:\"精度: {{percent}}%\",filament:\"{{weight}}g\",layer:\"レイヤー\",layers:\"レイヤー\",object:\"{{count}}オブジェクト\",objects:\"{{count}}オブジェクト\",slicedFor:\"{{model}}用にスライス\",uploadedBy:\"アップロード者\",noPermissionReprint:\"再印刷する権限がありません\",noFileForReprint:\"3MFファイルがありません — 印刷記録時にプリンターからファイルをダウンロードできませんでした\",noPermissionEdit:\"プロファイルを編集する権限がありません\",noPermissionDelete:\"アーカイブを削除する権限がありません\",reprint:\"再印刷\",schedulePrint:\"印刷をスケジュール\",schedule:\"スケジュール\",openInBambuStudio:\"スライサーで開く\",openInBambuStudioToSlice:\"スライサーでスライス\",slice:\"スライス\",externalLink:\"外部リンク\",makerWorld:\"MakerWorld: {{designer}}\",viewProject:\"プロジェクトを表示\",noExternalLink:\"外部リンクなし\",preview3d:\"3Dプレビュー\",download:\"ダウンロード\",edit:\"編集\",delete:\"削除\"},modal:{deleteArchive:\"アーカイブを削除\",deleteConfirm:\"このアーカイブを削除しますか？\",deleteButton:\"削除\",removeSource3mf:\"ソース3MFを削除\",removeSource3mfConfirm:'\"{{name}}\"からソース3MFファイルを削除してもよろしいですか？元のスライサープロジェクトファイルが削除されます。',removeButton:\"削除\",removeF3d:\"F3Dを削除\",removeF3dConfirm:'\"{{name}}\"からFusion 360デザインファイルを削除してもよろしいですか？',removeTimelapse:\"タイムラプスを削除\",removeTimelapseConfirm:'\"{{name}}\"からタイムラプス動画を削除してもよろしいですか？',timelapse:\"{{name}} - タイムラプス\",selectTimelapse:\"タイムラプスを選択\",selectTimelapseDesc:\"自動一致が見つかりませんでした。この印刷のタイムラプスを選択してください:\",deleteArchives:\"印刷アーカイブを削除\",deleteArchivesConfirm:\"{{count}}件のアーカイブを削除しますか？この操作は元に戻せません。\",deleteCount:\"{{count}}件を削除\"},page:{title:\"印刷アーカイブ\",printsCount:\"{{count}}回印刷\",dropFilesHere:\".3mfファイルをここにドロップ\",releaseToUpload:\"ドロップしてアップロード\",only3mfSupported:\".3mfファイルのみ対応しています\",close:\"閉じる\",selected:\"{{count}}件選択中\",selectAll:\"すべて選択\",tags:\"タグ\",project:\"プロジェクト\",favorite:\"お気に入り\",delete:\"削除\",toggledFavorites:\"{{count}}件のアーカイブのお気に入りを切替えました\",failedUpdateFavorites:\"お気に入りの更新に失敗しました\",archivesDeleted:\"{{count}}件のアーカイブを削除しました\",failedDeleteArchives:\"アーカイブの削除に失敗しました\",photoDeleted:\"写真を削除しました\",failedDeletePhoto:\"写真の削除に失敗しました\"},list:{name:\"名前\",printer:\"プリンター\",date:\"日付\",size:\"サイズ\",actions:\"操作\",hasTimelapse:\"タイムラプスあり\"},log:{date:\"日時\",printName:\"印刷名\",printer:\"プリンター\",user:\"ユーザー\",status:\"ステータス\",duration:\"所要時間\",filament:\"フィラメント\",allPrinters:\"全プリンター\",allUsers:\"全ユーザー\",allStatuses:\"全ステータス\",cancelled:\"キャンセル\",skipped:\"スキップ\",dateFrom:\"開始日\",dateTo:\"終了日\",noEntries:\"印刷ログが見つかりません\",showing:\"{{total}}件中{{count}}件を表示\",rowsPerPage:\"行数\",page:\"ページ\",prev:\"前へ\",next:\"次へ\",clearLog:\"ログをクリア\",clearLogTitle:\"印刷ログをクリア\",clearLogConfirm:\"すべての印刷ログエントリが完全に削除されます。アーカイブとキューアイテムには影響しません。この操作は元に戻せません。よろしいですか？\",clearLogButton:\"すべてクリア\",cleared:\"{{count}}件のログエントリを削除しました\",clearFailed:\"印刷ログの削除に失敗しました\"}},queue:{title:\"印刷キュー\",subtitle:\"印刷ジョブのスケジュールと管理\",addToQueue:\"キューに追加\",print:\"印刷\",reprint:\"再印刷\",schedulePrint:\"印刷をスケジュール\",editQueueItem:\"キューアイテムを編集\",printToPrinters:\"{{count}}台のプリンターで印刷\",queueToPrinters:\"{{count}}台のプリンターでキュー追加\",queueSelectedPlates:\"{{count}}プレートをキューに追加\",selectAllPlates:\"全{{count}}プレートを選択\",deselectAll:\"全て解除\",printQueued:\"キューに追加しました\",itemsQueued:\"{{count}}件をキューに追加しました\",sending:\"送信中...\",sendingProgress:\"送信中 {{current}}/{{total}}...\",adding:\"追加中...\",addingProgress:\"追加中 {{current}}/{{total}}...\",savingProgress:\"保存中 {{current}}/{{total}}...\",clearQueue:\"キューをクリア\",clearHistory:\"履歴をクリア\",emptyQueue:\"キューは空です\",position:\"順番\",scheduledTime:\"予定時刻\",moveUp:\"上に移動\",moveDown:\"下に移動\",startNow:\"今すぐ開始\",printingInProgress:\"印刷中...\",viewArchive:\"アーカイブを表示\",viewInFileManager:\"ファイルマネージャーで表示\",itemCount:\"{{count}}件\",itemCount_plural:\"{{count}}件のアイテム\",dragToReorder:\"ドラッグして並べ替え（ASAPのみ）\",reorderHint:\"順番はASAPアイテムのみに影響します。スケジュール済みアイテムは設定時刻に実行されます。\",sjf:{label:\"SJF\",tooltip:\"短いジョブ優先 — スケジューラーが短い印刷を優先します\"},addedBy:\"{{username}}が追加\",nextInQueue:\"次のキュー\",clearPlateSuccess:\"プレートをクリアしました — 次の印刷の準備完了\",plateNumber:\"プレート {{index}}\",quantity:\"数量\",quantityHint:\"{{count}}件のキューアイテムを作成\",activeBatches:\"アクティブなバッチ\",batchProgress:\"{{total}}件中{{completed}}件完了\",cancelBatch:\"残りをキャンセル\",batchCancelled:\"残りのバッチアイテムをキャンセルしました\",cancelBatchConfirmTitle:\"バッチをキャンセル\",cancelBatchConfirmMessage:\"このバッチの残りの保留中アイテムをすべてキャンセルしますか？\",batch:\"バッチ\",sections:{currentlyPrinting:\"印刷中\",queued:\"キュー中\",history:\"履歴\"},status:{pending:\"待機中\",waiting:\"待機中\",printing:\"印刷中\",paused:\"一時停止\",completed:\"完了\",failed:\"失敗\",skipped:\"スキップ\",cancelled:\"キャンセル済み\"},summary:{printing:\"印刷中\",queued:\"キュー中\",totalTime:\"キュー合計時間\",totalWeight:\"キュー合計重量\",history:\"履歴\"},filter:{allPrinters:\"すべてのプリンター\",unassigned:\"未割当\",allStatus:\"すべてのステータス\",allLocations:\"すべてのロケーション\",any:\"すべて\"},sort:{byPosition:\"順番で並べ替え\",byName:\"名前で並べ替え\",byPrinter:\"プリンターで並べ替え\",bySchedule:\"スケジュールで並べ替え\",byDate:\"日付で並べ替え\",ascendingOldest:\"昇順（古い順）\",descendingNewest:\"降順（新しい順）\"},badges:{staged:\"ステージ済み\",requiresPrevious:\"前の成功が必要\",autoPowerOff:\"自動電源オフ\",gcodeInjection:\"G-code\"},empty:{title:\"スケジュールされた印刷はありません\",description:\"アーカイブページのコンテキストメニューから「スケジュール」オプションを使用するか、ファイルをドラッグ＆ドロップして始めましょう。\"},time:{asap:\"即時\",overdue:\"期限超過\",now:\"今すぐ\",lessThanMinute:\"1分以内\",inMinutes:\"{{count}}分後\",inHours:\"{{count}}時間後\"},actions:{stopPrint:\"印刷を停止\",startPrint:\"印刷を開始\",requeue:\"再キュー\"},bulkEdit:{title:\"{{count}}件のアイテムを編集\",title_plural:\"{{count}}件のアイテムを編集\",description:\"変更した設定のみが選択されたアイテムに適用されます。\",printer:\"プリンター\",noChange:\"— 変更なし —\",queueOptions:\"キューオプション\",staged:\"ステージ済み\",autoPowerOff:\"印刷後に自動電源オフ\",requirePrevious:\"前の成功を必要とする\",printOptions:\"印刷オプション\",bedLevelling:\"ベッドレベリング\",flowCalibration:\"フローキャリブレーション\",vibrationCalibration:\"振動キャリブレーション\",layerInspection:\"第一層検査\",timelapse:\"タイムラプス\",useAms:\"AMS使用\",applyChanges:\"変更を適用\",selectAll:\"すべて選択\",deselectAll:\"すべて選択解除\",selected:\"{{count}}件選択中\",editSelected:\"選択を編集\",cancelSelected:\"選択をキャンセル\"},confirm:{cancelTitle:\"スケジュール済み印刷をキャンセル\",cancelMessage:\"「{{name}}」をキャンセルしますか？\",stopTitle:\"印刷を停止\",stopMessage:\"現在の印刷「{{name}}」を停止しますか？プリンター上の印刷ジョブがキャンセルされます。\",removeTitle:\"履歴から削除\",removeMessage:\"「{{name}}」をキュー履歴から削除しますか？\",clearHistoryTitle:\"履歴をクリア\",clearHistoryMessage:\"{{count}}件の履歴をすべて削除しますか？\",cancelButton:\"印刷をキャンセル\",stopButton:\"印刷を停止\",thisPrint:\"この印刷\",thisItem:\"このアイテム\"},toast:{cancelled:\"キャンセル済み\",cancelFailed:\"アイテムのキャンセルに失敗しました\",removed:\"キューアイテムを削除しました\",removeFailed:\"プロジェクトからのアーカイブ削除に失敗しました\",stopped:\"印刷を停止しました\",stopFailed:\"印刷の停止に失敗しました\",released:\"印刷をキューにリリースしました\",startFailed:\"印刷の開始に失敗しました\",reorderFailed:\"キューの並べ替えに失敗しました\",historyCleared:\"{{count}}件の履歴をクリアしました\",clearHistoryFailed:\"履歴のクリアに失敗しました\",updateFailed:\"アイテムの更新に失敗しました\",bulkCancelled:\"{{count}}件のアイテムをキャンセルしました\",bulkCancelFailed:\"アイテムのキャンセルに失敗しました\"},timeline:{listView:\"リスト\",timelineView:\"タイムライン\",unassigned:\"未割当\",noData:\"この日の予定された印刷はありません\",allDoneBy:\"すべての印刷は {{time}} までに完了予定\",staged:\"ステージング\",filterAll:\"すべて表示\",filterPrinting:\"印刷中\",filterQueued:\"待機中\",time:{anyMoment:\"まもなく\",minutesLeft:\"残り{{minutes}}分\",hoursLeft:\"残り{{hours}}時間\",hoursMinutesLeft:\"残り{{hours}}時間{{minutes}}分\"},day:{previous:\"前日\",next:\"翌日\",today:\"今日\"}},permissions:{noStopPrint:\"印刷を停止する権限がありません\",noStartPrint:\"印刷を開始する権限がありません\",noEdit:\"このキューアイテムを編集する権限がありません\",noCancel:\"このキューアイテムをキャンセルする権限がありません\",noRequeue:\"アイテムを再キューする権限がありません\",noRemove:\"このキューアイテムを削除する権限がありません\",noClearHistory:\"すべての履歴をクリアする権限がありません\",noEditItems:\"キューアイテムを編集する権限がありません\",noCancelItems:\"キューアイテムをキャンセルする権限がありません\"}},backgroundDispatch:{unknownFile:\"不明なファイル\",unknownPrinter:\"不明なプリンター\",startingPrints:\"印刷開始中\",progressSummary:\"{{complete}}/{{total}} 完了 • 配信済み: {{dispatched}} • 処理中: {{processing}}\",expandDetails:\"配信詳細を展開\",collapseDetails:\"配信詳細を折りたたむ\",dismissToast:\"配信トーストを閉じる\",cancelDispatchJob:\"配信ジョブをキャンセル\",cancel:\"キャンセル\",cancelling:\"キャンセル中…\",status:{dispatched:\"配信済み\",processing:\"処理中\",completed:\"完了\",failed:\"失敗\",cancelled:\"キャンセル済み\"},toast:{cancellingUpload:\"アップロードをキャンセル中...\",cancelled:\"配信をキャンセルしました\",cancelFailed:\"配信のキャンセルに失敗しました\",completeWithFailures:\"バックグラウンド配信完了: {{completed}} 件成功、{{failed}} 件失敗\",completeSuccess:\"バックグラウンド配信完了: {{completed}} 件成功\",printStartedRemaining:\"{{completed}} 件の印刷を開始、残り {{remaining}} 件送信中...\"}},stats:{title:\"統計\",subtitle:\"ウィジェットをドラッグして並べ替え。目のアイコンをクリックして非表示。\",overview:\"概要\",totalPrints:\"総印刷数\",successRate:\"成功率\",totalPrintTime:\"総印刷時間\",printTime:\"印刷時間\",totalFilament:\"総フィラメント使用量\",filamentUsed:\"フィラメント使用量\",filamentCost:\"フィラメントコスト\",totalCost:\"総コスト\",energyUsed:\"エネルギー使用量\",energyCost:\"エネルギーコスト\",energyWarmingUpTooltip:\"エネルギー追跡は毎時スナップショットを収集中です。選択範囲の前に少なくとも1つのスナップショットが存在すると、期間合計が正確になります。初期値は過小になる場合があります。\",averagePrintTime:\"平均印刷時間\",printsPerDay:\"1日あたりの印刷数\",byPrinter:\"プリンター別\",printsByPrinter:\"プリンター別印刷数\",byMaterial:\"素材別\",byMonth:\"月別\",last7Days:\"過去7日間\",last30Days:\"過去30日間\",last90Days:\"過去90日間\",allTime:\"全期間\",quickStats:\"クイック統計\",printActivity:\"印刷アクティビティ\",filamentTypes:\"フィラメントタイプ\",filamentTrends:\"フィラメントトレンド\",failureAnalysis:\"失敗分析\",timeAccuracy:\"時間精度\",successful:\"成功\",failed:\"失敗\",perfectEstimate:\"100% = 完全な推定\",noTimeAccuracyData:\"時間精度データがありません\",noFilamentData:\"フィラメントデータがありません\",noPrinterData:\"プリンターデータがありません\",noPrintData:\"印刷データがありません\",noPrintDataLast30Days:\"過去30日間の印刷データがありません\",failureReasons:\"失敗理由\",topFailureReasons:\"主な失敗理由\",failedPrintsCount:\"{{failed}} / {{total}} 件の印刷が失敗\",lastWeekRate:\"先週: {{rate}}%\",resetLayout:\"レイアウトをリセット\",recalculateCosts:\"コストを再計算\",recalculateCostsHint:\"現在のフィラメント価格ですべてのアーカイブコストを再計算\",exportStats:\"統計をエクスポート\",exportAsCsv:\"CSVでエクスポート\",exportAsExcel:\"Excelでエクスポート\",hiddenCount:\"{{count}}件非表示\",exportDownloaded:\"エクスポートをダウンロードしました\",exportFailed:\"エクスポートに失敗しました\",layoutReset:\"レイアウトをリセットしました\",recalculatedCosts:\"{{count}}件のアーカイブのコストを再計算しました\",recalculateFailed:\"コストの再計算に失敗しました\",loadingStats:\"統計を読み込み中...\",noPermissionResetLayout:\"レイアウトをリセットする権限がありません\",noPermissionRecalculate:\"コストを再計算する権限がありません\",noPrintDataInRange:\"選択した期間にデータがありません\",periodFilament:\"フィラメント使用量\",periodCost:\"コスト\",avgPerPrint:\"1印刷あたりの平均\",usageOverTime:\"時間推移\",filamentByWeight:\"重量\",printDuration:\"印刷時間分布\",printerUtilization:\"プリンター稼働率\",filamentSuccess:\"素材別成功率\",printHabits:\"印刷習慣\",printTimeOfDay:\"時間帯別プリント\",colorDistribution:\"カラー分布\",noColorData:\"カラーデータがありません\",records:\"記録\",longestPrint:\"最長プリント\",heaviestPrint:\"最重プリント\",mostExpensivePrint:\"最高額\",busiestDay:\"最多プリント日\",successStreak:\"連続成功\",streakPrint:\"連続プリント\",streakPrints:\"{{count}}連続プリント\",printerStats:\"プリンター統計\",hours:\"時間\",avgPrints:\"平均印刷数\",noArchiveData:\"印刷データがありません\",filamentByTime:\"推移\",avgWeight:\"平均重量\",avgTime:\"平均時間\",filamentByPrints:\"印刷数\",timeframe:{today:\"今日\",\"this-week\":\"今週\",\"this-month\":\"今月\",\"last-7\":\"過去7日間\",\"last-30\":\"過去30日間\",\"last-90\":\"過去90日間\",\"this-year\":\"今年\",\"all-time\":\"全期間\",custom:\"カスタム\",from:\"開始\",to:\"終了\"},allUsers:\"全ユーザー\",noUser:\"ユーザーなし（システム）\",filterByUser:\"ユーザーでフィルター\"},maintenance:{title:\"メンテナンス\",overview:\"概要\",allOk:\"すべてのメンテナンスは最新です\",dueCount:\"{{count}}件の期限到来\",dueCount_plural:\"{{count}}件の期限到来\",warningCount:\"{{count}}件の警告\",warningCount_plural:\"{{count}}件の警告\",totalPrintTime:\"総印刷時間\",nextMaintenance:\"次回メンテナンス\",nothingDue:\"予定なし\",tasks:\"タスク\",lastPerformed:\"前回実施日\",interval:\"間隔\",hoursRemaining:\"残り{{hours}}時間\",hoursOverdue:\"{{hours}}時間超過\",markDone:\"完了にする\",performMaintenance:\"メンテナンスを実施\",history:\"履歴\",noHistory:\"メンテナンス履歴がありません\",editPrintHours:\"印刷時間を編集\",currentHours:\"現在の時間\",statusTab:\"ステータス\",settingsTab:\"設定\",overdueCount:\"{{count}}件超過\",dueSoonCount:\"{{count}}件まもなく期限\",dueSoon:\"まもなく期限\",allGood:\"問題なし\",overdueBy:\"{{duration}}超過\",dueIn:\"あと{{duration}}\",timeLeft:\"残り{{duration}}\",day:\"1日\",days:\"日\",week:\"1週間\",weeks:\"{{count}}週間\",month:\"1ヶ月\",months:\"{{count}}ヶ月\",year:\"1年\",maintenanceTypes:\"メンテナンスタイプ\",maintenanceTypesDescription:\"システムタイプとカスタムメンテナンスタスク\",addCustomType:\"カスタムタイプを追加\",restoreDefaults:\"デフォルトタスクを復元\",intervalType:\"インターバルタイプ\",intervalValue:\"間隔 ({{type}})\",icon:\"アイコン\",documentationLink:\"ドキュメントリンク（任意）\",assignToPrinters:\"プリンターに割り当て\",selectAtLeastOnePrinter:\"プリンターを1台以上選択してください\",addType:\"タイプを追加\",custom:\"カスタム\",printHours:\"印刷時間\",calendarDays:\"カレンダー日数\",exampleName:\"例: HEPAフィルター交換\",viewDocumentation:\"ドキュメントを表示\",timeBasedInterval:\"時間ベースのインターバル\",intervalOverrides:\"インターバルのオーバーライド\",intervalOverridesDescription:\"特定のプリンターの間隔をカスタマイズ\",assignedToPrinters:\"割り当て済みプリンター：\",noPrintersAssigned:\"プリンター未割り当て\",addPrinterShort:\"追加:\",printersAssignedClick:\"{{count}}台のプリンターを割り当て済み - クリックして管理\",removeFromPrinter:\"このプリンターから削除\",types:{lubricateCarbonRods:\"カーボンロッドの潤滑\",lubricateRails:\"リニアレールの潤滑\",cleanNozzle:\"ノズル/ホットエンドの清掃\",checkBelts:\"ベルト張力の確認\",cleanBuildPlate:\"ビルドプレートの清掃\",checkExtruder:\"エクストルーダーギアの確認\",checkCooling:\"冷却ファンの確認\",generalInspection:\"総合点検\",cleanCarbonRods:\"カーボンロッドの清掃\",lubricateSteelRods:\"スチールロッドの潤滑\",cleanSteelRods:\"スチールロッドの清掃\",cleanLinearRails:\"リニアレールの清掃\",checkPtfeTube:\"PTFEチューブの確認\",replaceHepaFilter:\"HEPAフィルター交換\",replaceCarbonFilter:\"カーボンフィルター交換\",lubricateLeftNozzleRail:\"左ノズルレールの潤滑\"},maintenanceComplete:\"メンテナンスを完了としてマークしました\",typeUpdated:\"メンテナンスタイプを更新しました\",typeDeleted:\"メンテナンスタイプを削除しました\",defaultsRestored:\"デフォルトタスクを{{count}}件復元しました\",printHoursUpdated:\"印刷時間を更新しました\",printerAssigned:\"プリンターを割り当てました\",printerRemoved:\"プリンターを削除しました\",deleteTypeConfirm:\"「{{name}}」を削除しますか？\",deleteSystemTypeTitle:\"デフォルトのメンテナンスタスクを削除しますか？\",deleteSystemTypeMessage:\"デフォルトのメンテナンスタスク「{{name}}」を削除してもよろしいですか？\",noPermissionUpdate:\"メンテナンス記録を更新する権限がありません\",noPermissionPerform:\"メンテナンスを実行する権限がありません\",noPermissionEditTypes:\"メンテナンスタイプを編集する権限がありません\",noPermissionDeleteTypes:\"メンテナンスタイプを削除する権限がありません\",noPermissionEditHours:\"メンテナンス時間を編集する権限がありません\",noPermissionRemovePrinter:\"プリンターの割り当てを解除する権限がありません\",noPermissionAssignPrinter:\"プリンターを割り当てる権限がありません\",noPermissionEditIntervals:\"メンテナンス間隔を編集する権限がありません\",configureSettings:\"メンテナンスタイプと間隔を設定\"},settings:{title:\"設定\",general:\"一般\",tabs:{general:\"一般\",smartPlugs:\"スマートプラグ\",notifications:\"通知\",queue:\"ワークフロー\",filament:\"フィラメント\",network:\"ネットワーク\",apiKeys:\"APIキー\",virtualPrinter:\"仮想プリンター\",spoolbuddy:\"SpoolBuddy\",failureDetection:\"失敗検出\",users:\"認証\",backup:\"バックアップ\",emailAuth:\"メール認証\",ldap:\"LDAP\",twoFa:\"二段階認証\",oidc:\"SSO / OIDC\"},spoolbuddy:{infoTitle:\"SpoolBuddy デバイス\",infoBody:\"SpoolBuddy キオスクはハートビートにより自動的に登録されます。使用されなくなった場合や、デーモンのクラッシュにより古い重複が残った場合は、ここでデバイスの登録を解除してください。\",duplicatesTitle:\"{{count}} 台のデバイスが登録されています\",duplicatesBody:\"キオスク UI は最初に登録されたデバイスのみを使用します。クラッシュによる古い重複がある場合は登録解除してください。オンラインのデバイスは次回のハートビートで自動的に再登録されます。\",empty:\"SpoolBuddy デバイスはまだ登録されていません。\",online:\"オンライン\",offline:\"オフライン\",unregister:\"登録解除\",unregisterSuccess:\"デバイスの登録を解除しました\",unregisterError:\"デバイスの登録解除に失敗しました\",confirmTitle:\"SpoolBuddy デバイスの登録を解除しますか？\",confirmBody:\"「{{hostname}}」（{{deviceId}}）をデータベースから削除します。デバイスがオンラインの場合、次回のハートビートで自動的に再登録されます。\",ipAddress:\"IPアドレス\",firmware:\"ファームウェア\",lastSeen:\"最終接続\",daemonUptime:\"デーモン稼働時間\",systemUptime:\"システム稼働時間\",never:\"なし\",nfc:\"NFC\",scale:\"計量器\",cpuTemp:\"CPU 温度\",memory:\"メモリ\",disk:\"ディスク\"},ldap:{title:\"LDAP認証\",enabledDesc:\"LDAP認証が有効です\",disabledDesc:\"LDAP認証が無効です\",disabledHint:\"以下のLDAP設定を構成して保存し、有効にしてください。\",enabled:\"LDAP認証を有効にしました\",disabled:\"LDAP認証を無効にしました\",feature1:\"LDAP資格情報でログインできます\",feature2:\"ローカル管理者アカウントはフォールバックとして残ります\",feature3:\"ログイン時にLDAPグループがBamBuddyグループにマッピングされます\",serverConfig:\"LDAPサーバー設定\",serverUrl:\"サーバーURL\",serverUrlHint:\"標準はldap://、SSL接続はldaps://を使用\",security:\"セキュリティ\",securityHint:\"StartTLSはプレーン接続をTLSにアップグレードします。LDAPSは最初からTLSを使用します。\",bindDn:\"バインドDN（サービスアカウント）\",bindPassword:\"バインドパスワード\",searchBase:\"検索ベースDN\",userFilter:\"ユーザー検索フィルター\",userFilterHint:\"{username}はログインユーザー名に置き換えられます。OpenLDAPの場合は(uid={username})を使用。\",autoProvision:\"ユーザー自動作成\",autoProvisionHint:\"初回LDAPログイン時にBamBuddyアカウントを自動作成\",defaultGroup:\"デフォルトグループ\",defaultGroupNone:\"— なし（フォールバックなし）—\",defaultGroupHint:\"LDAPユーザーが認証されたがマッピングされたLDAPグループに属していない場合に割り当てられるフォールバックグループ。空欄の場合、マッピングされていないユーザーは権限なしのままになります。\",groupMapping:\"グループマッピング（JSON）\",groupMappingHint:\"LDAPグループDNをBamBuddyグループにマッピング。利用可能なグループ: \",testConnection:\"接続テスト\",settingsSaved:\"LDAP設定を保存しました\",errors:{serverRequired:\"LDAPサーバーURLは必須です\",searchBaseRequired:\"検索ベースDNは必須です\",enableAuthFirst:\"先に認証を有効にしてください\",configureLdapFirst:\"先にLDAP設定を保存してください\"}},email:{smtpSettings:\"SMTP設定\",smtpHost:\"SMTPサーバー\",smtpPort:\"SMTPポート\",security:\"セキュリティ\",authentication:\"認証\",username:\"ユーザー名\",password:\"パスワード\",fromEmail:\"送信元メールアドレス\",fromName:\"送信者名\",testConnection:\"SMTP接続テスト\",testRecipient:\"テスト受信者メール\",sendTest:\"テストメール送信\",sending:\"送信中...\",save:\"設定を保存\",saving:\"保存中...\",advancedAuth:\"高度な認証\",advancedAuthEnabled:\"高度な認証が有効です\",advancedAuthEnabledDesc:\"メールベースのユーザー管理機能が有効になっています。新規ユーザーには自動生成されたパスワードがメールで送信され、ユーザーはパスワード忘れ機能でパスワードをリセットできます。\",advancedAuthDisabled:\"高度な認証が無効です\",advancedAuthDisabledDesc:\"高度な認証を有効にして、ユーザー管理のメールベース機能を有効化してください。\",enable:\"有効にする\",disable:\"無効にする\",feature1:\"パスワードは自動生成され、新規ユーザーにメールで送信されます\",feature2:\"ユーザーはユーザー名またはメールでログインできます\",feature3:\"パスワード忘れ機能が利用可能です\",feature4:\"管理者はメールでユーザーパスワードをリセットできます\",errors:{requiredFields:\"すべての必須フィールドに入力してください\",usernameRequired:\"認証が有効な場合、ユーザー名は必須です\",enterTestEmail:\"テストメールアドレスを入力してください\",smtpServerAndEmail:\"テストする前にSMTPサーバーと送信元メールを入力してください\",usernamePasswordRequired:\"認証が有効な場合、ユーザー名とパスワードは必須です\",configureSmtpFirst:\"最初にSMTP設定を構成してテストしてください\",enableAuthFirst:\"メール関連機能をご利用いただくには、まず認証を有効にしてください。\"},success:{settingsSaved:\"SMTP設定を保存しました\"},securityOptions:{starttls:\"STARTTLS (ポート 587)\",ssl:\"SSL/TLS (ポート 465)\",none:\"なし (ポート 25)\"},authOptions:{enabled:\"有効\",disabled:\"無効\"}},appearance:\"外観\",notifications:\"通知\",smartPlugs:\"スマートプラグ\",spoolman:\"Spoolman\",updates:\"アップデート\",language:\"言語\",languageDescription:\"表示言語を選択してください\",theme:\"テーマ\",themeLight:\"ライト\",themeDark:\"ダーク\",themeSystem:\"システム設定に従う\",defaultView:\"デフォルト画面\",defaultViewDescription:\"アプリ起動時に表示するページ\",checkForUpdates:\"アップデートを確認\",autoUpdate:\"自動アップデート\",currentVersion:\"現在のバージョン\",latestVersion:\"最新バージョン\",upToDate:\"最新です\",updateAvailable:\"アップデートあり\",notificationLanguage:\"通知の言語\",notificationLanguageDescription:\"プッシュ通知の言語\",bedCooledThreshold:\"ベッド冷却しきい値\",bedCooledThresholdDescription:\"印刷後にベッドが冷却されたと見なす温度\",userNotificationsEnabled:\"ユーザー通知\",userNotificationsEnabledDescription:\"ユーザー通知メニューと印刷ジョブイベントのメール通知を有効にします。高度な認証が必要です。\",userNotificationsDisabledHint:\"ユーザー通知を使用するには高度な認証を有効にしてください。\",notificationProviders:\"通知プロバイダー\",addProvider:\"プロバイダーを追加\",editProvider:\"プロバイダーを編集\",providerType:\"プロバイダーの種類\",testNotification:\"テスト通知\",testSuccess:\"テスト通知を送信しました\",testFailed:\"テスト通知の送信に失敗しました\",quietHours:\"おやすみ時間\",quietHoursDescription:\"この時間帯は通知を送信しません\",quietHoursStart:\"開始\",quietHoursEnd:\"終了\",events:{title:\"通知イベント\",printStart:\"印刷開始\",printComplete:\"印刷完了\",printFailed:\"印刷失敗\",printStopped:\"印刷中止\",printProgress:\"進捗マイルストーン\",printProgressDescription:\"25%, 50%, 75%で通知\",printerOffline:\"プリンターオフライン\",printerError:\"プリンターエラー\",filamentLow:\"フィラメント残量低下\",maintenanceDue:\"メンテナンス期限\",maintenanceDueDescription:\"メンテナンスが必要なときに通知\"},smartPlug:{title:\"スマートプラグ\",add:\"スマートプラグを追加\",edit:\"スマートプラグを編集\",name:\"名前\",ipAddress:\"IPアドレス\",linkedPrinter:\"連携プリンター\",autoOn:\"自動電源オン\",autoOnDescription:\"印刷開始時に電源を入れる\",autoOff:\"自動電源オフ\",autoOffDescription:\"印刷完了後に電源を切る\",offDelay:\"オフ遅延\",offDelayMinutes:\"印刷後の待機時間（分）\",offDelayTemp:\"ノズル温度が下回ったとき\",currentState:\"現在の状態\",turnOn:\"電源オン\",turnOff:\"電源オフ\"},filamentTracking:\"フィラメント追跡\",filamentTrackingDesc:\"フィラメントスプールの追跡方法を選択してください。内蔵インベントリまたは外部Spoolmanサーバーを使用できます。\",filamentChecks:\"フィラメントチェック\",disableFilamentWarnings:\"フィラメント警告を無効化\",disableFilamentWarningsDesc:\"印刷またはキュー追加時にフィラメント不足の警告を表示しない\",preferLowestFilament:\"残量が少ないフィラメントを優先\",preferLowestFilamentDesc:\"複数のスプールが一致する場合、残量が最も少ないものを使用します\",trackingModeBuiltIn:\"内蔵インベントリ\",trackingModeBuiltInDesc:\"RFID自動検出と使用量追跡を含む\",trackingModeSpoolmanDesc:\"外部フィラメント管理サーバー\",builtInFeatureRfid:\"AMS内のBambu Lab RFIDスプールを自動検出\",builtInFeatureUsage:\"プリントごとのフィラメント消費量を追跡\",builtInFeatureCatalog:\"スプール、カラー、K値プロファイルを管理\",builtInFeatureThirdParty:\"サードパーティ製スプールをインベントリスプールに割り当て可能\",amsSyncButton:\"AMSから重量を同期\",amsSyncTitle:\"AMSからスプール重量を同期\",amsSyncMessage:\"接続されたプリンターの現在のAMS残量値で、すべてのインベントリスプール重量を上書きします。破損した重量データの復旧に使用してください。プリンターがオンラインである必要があります。\",amsSyncing:\"同期中...\",amsSyncSuccess:\"{{synced}}個のスプールを同期、{{skipped}}個をスキップ\",amsSyncError:\"AMSからの重量同期に失敗しました\",spoolmanUrl:\"Spoolman URL\",spoolmanUrlHint:\"Spoolmanサーバーのurl（例：http://localhost:7912）\",spoolmanConnected:\"接続中\",spoolmanDisconnected:\"未接続\",status:\"ステータス\",connect:\"接続\",disconnect:\"切断\",howSyncWorks:\"同期の仕組み\",syncInfoRfidOnly:\"RFIDを搭載した公式Bambu Labスプールのみ同期されます\",syncInfoAutoCreate:\"新しいスプールは初回同期時にSpoolmanに自動作成されます\",syncInfoThirdPartySkipped:\"Bambu Lab以外のスプール（サードパーティ、リフィル）はスキップされます\",linkingExistingSpools:\"既存スプールのリンク\",linkingExistingSpoolsDesc:\"既存のSpoolmanスプールをAMSにリンクするには、AMSスロットにカーソルを合わせて「Spoolmanにリンク」をクリックしてください。\",syncMode:\"同期モード\",syncModeAuto:\"自動\",syncModeManual:\"手動のみ\",syncModeAutoDesc:\"変更が検出されるとAMSデータが自動的に同期されます\",syncModeManualDesc:\"手動でトリガーした場合のみ同期\",syncAmsData:\"AMSデータを同期\",syncAmsDataDesc:\"プリンターのAMSデータをSpoolmanに手動同期\",allPrinters:\"全プリンター\",noDefaultPrinter:\"デフォルトなし（毎回選択）\",sidebarOrder:\"サイドバーの順序\",saveThumbnails:\"サムネイルを保存\",captureFinishPhoto:\"完了写真を撮影\",noPrintersConfigured:\"プリンターが設定されていません\",archiveMode:{always:\"常にアーカイブを作成\",never:\"アーカイブを作成しない\",ask:\"毎回確認\"},checkForUpdatesLabel:\"アップデートを確認\",checkPrinterFirmware:\"プリンターファームウェアの確認\",includeBetaUpdates:\"ベータ版を含める\",includeBetaUpdatesDesc:\"アップデート確認時にベータ版およびプレリリース版を通知する\",enableRetry:\"リトライを有効化\",homeAssistantDescription:\"Home Assistantに接続してHA REST APIでスマートプラグを制御します。switch、light、input_booleanエンティティに対応しています。\",environmentManagedLabel:\"(環境変数で管理)\",autoEnabledViaEnv:\"環境変数により自動的に有効化されました\",urlFromEnvReadOnly:\"HA_URL環境変数で設定された値（読み取り専用）\",tokenFromEnvReadOnly:\"HA_TOKEN環境変数で設定された値（読み取り専用）\",mqttConnectedTo:\"接続先:\",prometheusDescription:\"プリンターデータをPrometheus形式で公開\",noSmartPlugsTitle:\"スマートプラグが設定されていません\",noSmartPlugsDescription:\"Tasmotaベースのスマートプラグを追加して、エネルギー消費を追跡し、電源制御を自動化します。\",noProvidersTitle:\"プロバイダーが設定されていません\",noProvidersDescription:\"アラートを受信するにはプロバイダーを追加してください。\",noTemplatesAvailable:\"テンプレートがありません。バックエンドを再起動してデフォルトテンプレートを生成してください。\",apiPermissionView:\"プリンターステータスとキューを表示\",apiPermissionEdit:\"印刷キューにアイテムを追加・削除\",apiKeysEmptyTitle:\"APIキーがありません\",apiKeysEmptyDescription:\"外部サービスと連携するためのAPIキーを作成してください。\",noUsersFound:\"ユーザーが見つかりません\",noGroupsFound:\"グループが見つかりません\",noGroupsAvailable:\"利用可能なグループがありません\",passwordsDoNotMatch:\"パスワードが一致しません\",systemGroupWarning:\"システムグループ名は変更できません\",authDisabledTitle:\"認証が無効です\",authDisabledFeature1:\"システムへのアクセスにログインを要求\",authDisabledFeature2:\"グループベースの権限で複数ユーザーを作成\",authDisabledFeature3:\"50以上のきめ細かな権限でアクセスを制御\",userHasCreated:\"このユーザーは以下を作成しています：\",userItemsQuestion:\"これらのアイテムをどうしますか？\",deleteUserConfirm:\"このユーザーを削除してもよろしいですか？この操作は元に戻せません。\",actionCannotBeUndone:\"この操作は元に戻せません\",addFirstSmartPlug:\"最初のスマートプラグを追加\",providers:\"プロバイダー\",log:\"ログ\",testAll:\"すべてテスト\",testResults:\"テスト結果\",testPassedCount:\"{{count}}件成功\",testFailedCount:\"{{count}}件失敗\",messageTemplates:\"メッセージテンプレート\",messageTemplatesDescription:\"各イベントの通知メッセージをカスタマイズ。\",apiKeys:\"APIキー\",apiKeysDescription:\"外部連携やWebhook用のAPIキーを作成します。\",createKey:\"キーを作成\",apiKeyCreated:\"APIキーを作成しました\",apiKeyCopyWarning:\"今すぐこのキーをコピーしてください - 再表示されません！\",useInApiBrowser:\"APIブラウザーで使用\",createNewApiKey:\"新しいAPIキーを作成\",keyName:\"キー名\",keyNamePlaceholder:\"例: Home Assistant, OctoPrint\",readStatus:\"ステータスの読み取り\",readStatusDescription:\"プリンターのステータスとキューを表示\",manageQueue:\"キューの管理\",manageQueueDescription:\"印刷キューへのアイテムの追加と削除\",controlPrinter:\"プリンターの制御\",controlPrinterDescription:\"印刷の一時停止、再開、停止\",unnamedKey:\"名前なしキー\",lastUsed:\"最終使用:\",read:\"読み取り\",control:\"制御\",createFirstKey:\"最初のキーを作成\",webhookEndpoints:\"Webhookエンドポイント\",webhookApiKeyHint:\"X-API-KeyヘッダーでAPIキーを使用してください。\",webhook:{getAllStatus:\"全プリンターステータスを取得\",getSpecificStatus:\"特定のプリンターステータスを取得\",addToQueue:\"印刷キューに追加\",pausePrint:\"印刷を一時停止\",resumePrint:\"印刷を再開\",stopPrint:\"印刷を停止\"},apiBrowser:\"APIブラウザ\",apiBrowserDescription:\"すべての利用可能なAPIエンドポイントを探索してテストします。\",apiKeyForTesting:\"テスト用APIキー\",apiKeyPlaceholder:\"CallMeBot APIキー\",apiKeyHint:\"このキーはX-API-Keyヘッダーとしてリクエストに送信されます。\",deleteApiKeyTitle:\"APIキーを削除\",deleteApiKeyMessage:\"このAPIキーを削除してもよろしいですか？このキーを使用しているすべての連携が動作しなくなります。\",deleteKey:\"キーを削除\",amsDisplayThresholds:\"AMS表示しきい値\",amsThresholdsDescription:\"AMS湿度と温度インジケーターの色しきい値を設定します。\",humidity:\"湿度\",goodGreen:\"良好（緑）≤\",fairOrange:\"普通（オレンジ）≤\",aboveFairBad:\"普通のしきい値以上は赤（悪い）で表示\",fairAlsoDryingThreshold:\"このしきい値は自動乾燥のトリガーにも使用されます\",temperature:\"温度\",goodBlue:\"良好（青）≤\",aboveFairHot:\"普通のしきい値以上は赤（高温）で表示\",historyRetention:\"履歴の保持\",keepSensorHistory:\"センサー履歴の保持期間\",historyRetentionDescription:\"古い湿度と温度データは自動的に削除されます\",defaultPrintOptions:\"デフォルト印刷オプション\",defaultPrintOptionsDescription:\"新しい印刷のデフォルト値を設定します。印刷ダイアログで個別に変更できます。\",defaultBedLevelling:\"ベッドレベリング\",defaultBedLevellingDesc:\"印刷前にベッドを自動レベリング\",defaultFlowCali:\"フローキャリブレーション\",defaultFlowCaliDesc:\"押出フローのキャリブレーション\",defaultVibrationCali:\"振動キャリブレーション\",defaultVibrationCaliDesc:\"リンギングアーティファクトを低減\",defaultLayerInspect:\"第1層検査\",defaultLayerInspectDesc:\"AIによる第1層の検査\",defaultTimelapse:\"タイムラプス\",defaultTimelapseDesc:\"タイムラプス動画を記録\",staggeredStart:\"Staggered Start\",staggeredStartDescription:\"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.\",plateClear:\"プレートクリア確認\",requirePlateClear:\"プレートクリア確認を必須にする\",requirePlateClearDescription:\"有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。無効にすると、プリンターカード上のプレート状態バッジと「プレートをクリア済みにする」ボタンも非表示になります。\",gcodeInjection:\"G-codeインジェクション\",gcodeInjectionDescription:\"Farmloop、SwapMod、AutoClear、Printflow 3Dなどの自動印刷システム用に、印刷の開始と終了時にカスタムG-codeを挿入します。スニペットはプリンターモデルごとに設定し、キューアイテム��「G-codeを挿入」を有効にすると適用されます。\",gcodeInjectionNoPrinters:\"プリンターが見つかりません。G-codeスニペットを設定するにはプリンターを追加してください。\",gcodeStartLabel:\"開始G-code\",gcodeEndLabel:\"終了G-code\",gcodeStartPlaceholder:\"印刷開始前に挿入されるG-code...\",gcodeEndPlaceholder:\"印刷終了後に追加されるG-code...\",staggerGroupSize:\"Group size\",staggerGroupSizeHelp:\"Printers to start simultaneously per group\",staggerInterval:\"Interval (minutes)\",staggerIntervalHelp:\"Delay between each group starting\",queueDrying:\"自動乾燥\",queueDryingDescription:\"キュー印刷の合間にプリンターがアイドル状態の時、AMSフィラメントを自動的に乾燥します。上記の湿度しきい値を使用します。\",queueDryingEnabled:\"自動乾燥を有効にする\",queueDryingEnabledDescription:\"プリンターがアイドル状態で湿度がしきい値を超えた場合、AMS乾燥を自動的に開始\",queueDryingBlock:\"乾燥完了まで待機\",queueDryingBlockDescription:\"乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。\",ambientDryingEnabled:\"常時乾燥\",ambientDryingEnabledDescription:\"キューに関係なく、アイドル状態のプリンターで湿度がしきい値を超えた場合に自動的にフィラメントを乾燥。\",dryingPresets:\"乾燥プリセット\",dryingPresetsDescription:\"フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。\",dryingFilament:\"フィラメント\",printModal:\"印刷ダイアログ\",expandCustomMapping:\"カスタムマッピングをデフォルトで展開\",expandCustomMappingDescription:\"複数プリンターに印刷する際、プリンターごとのAMSマッピングを展開表示\",authentication:\"認証\",authEnabledDescription:\"ユーザー認証でインスタンスが保護されています\",authDisabledDescription:\"認証を有効にして、ユーザーアカウントの作成、権限の管理、Bambuddyインスタンスのセキュリティを確保しましょう。\",authDisabledMessage:\"認証を有効にして、ユーザーアカウントの作成、権限の管理、Bambuddyインスタンスのセキュリティを確保しましょう。\",enableAuthentication:\"認証を有効にする\",currentUser:\"現在のユーザー\",changePassword:\"パスワードを変更\",admin:\"管理者\",users:\"ユーザー\",addUser:\"ユーザーを追加\",groups:\"グループ\",addGroup:\"グループを追加\",system:\"システム\",noDescription:\"説明なし\",userCount:\"{{count}}人のユーザー\",permissionCount:\"{{count}}件の権限\",createUser:\"ユーザーを作成\",username:\"ユーザー名\",enterUsername:\"ユーザー名を入力\",password:\"パスワード\",enterPassword:\"パスワードを入力（6文字以上）\",confirmPassword:\"パスワードの確認\",confirmPasswordPlaceholder:\"パスワードを確認\",viewReleaseOnGitHub:\"GitHubでリリースを表示\",turnAllPlugsOn:\"すべてのプラグをオン\",turnAllPlugsOff:\"すべてのプラグをオフ\",clearNotificationLogs:\"通知ログをクリア\",clearLogsMessage:\"30日以上前のすべての通知ログを完全に削除します。この操作は元に戻せません。\",clearLogs:\"通知ログを削除\",resetUiPreferences:\"UI設定をリセット\",resetUiPreferencesMessage:\"すべてのUI設定をデフォルトにリセットします：サイドバー順序、テーマ、ダッシュボードレイアウト、表示モード、ソート設定。プリンター、アーカイブ、サーバー設定は影響を受けません。クリア後にページがリロードされます。\",resetPreferences:\"設定をリセット\",deleteGroupTitle:\"グループを削除\",deleteGroupMessage:\"このグループを削除しますか？このグループのユーザーはこれらの権限を失います。\",deleteGroup:\"グループを削除\",disableAuthenticationTitle:\"認証を無効化\",disableAuthenticationMessage:\"認証を無効にしますか？Bambuddyインスタンスにログインなしでアクセスできるようになります。ユーザーはデータベースに残りますが、認証は無効になります。\",disableAuthentication:\"認証を無効化\",configureBambuddy:\"Bambuddyを設定\",systemDefault:\"システムデフォルト\",archiveSettings:\"アーカイブ設定\",newWindow:\"新しいウィンドウ\",embeddedOverlay:\"埋め込みオーバーレイ\",preferredSlicer:\"優先スライサー\",preferredSlicerDescription:\"ファイルを開くスライサーアプリケーションを選択\",externalCameras:\"外部カメラ\",costTracking:\"コスト追跡\",printsOnly:\"印刷のみ\",totalConsumption:\"総消費量\",dataManagement:\"データ管理\",storageUsage:\"ストレージ使用量\",storageUsageDescription:\"カテゴリ別のデータ使用量の内訳\",storageUsageTotal:\"合計\",storageUsageErrors:\"エラー\",storageUsageOtherBreakdown:\"その他（静的アセット、スクリプト、設定ファイルを含む）\",storageUsageSystem:\"システム\",storageUsageData:\"データ\",storageUsageUnavailable:\"ストレージ使用量情報は利用できません\",clearNotificationLogsDescription:\"30日以上前の通知ログを削除\",resetUiPreferencesDescription:\"サイドバー順序、テーマ、表示モード、レイアウト設定をリセット。プリンター、アーカイブ、設定は影響を受けません。\",enableHomeAssistant:\"Home Assistantを有効化\",enableMqtt:\"MQTTを有効化\",useTls:\"TLSを使用\",enableMetricsEndpoint:\"メトリクスエンドポイントを有効化\",availableMetrics:\"利用可能なメトリクス\",editUser:\"ユーザーを編集\",deleteUserTitle:\"ユーザーを削除\",groupName:\"グループ名\",leaveEmptyForAnonymous:\"匿名の場合は空のまま\",leaveEmptyForNoAuth:\"認証なしの場合は空のまま\",enterNewPassword:\"新しいパスワードを入力\",confirmNewPassword:\"新しいパスワードを確認\",enterGroupName:\"グループ名を入力\",enterDescriptionOptional:\"説明を入力（任意）\",enterCurrentPassword:\"現在のパスワードを入力\",enterNewPasswordMin6:\"新しいパスワードを入力（6文字以上）\",toast:{keyCopied:\"キーをクリップボードにコピーしました\",copyFailed:\"キーのコピーに失敗しました\",keyAddedToBrowser:\"キーをAPIブラウザに追加しました\",clearLogsFailed:\"ログの削除に失敗しました\",uiPreferencesReset:\"UI設定をリセットしました。更新中...\",authDisabled:\"認証を無効にしました\",authDisableFailed:\"認証の無効化に失敗しました\",apiKeyCreated:\"APIキーを作成しました\",apiKeyDeleted:\"APIキーを削除しました\",userCreated:\"ユーザーが正常に作成されました\",userUpdated:\"ユーザーが正常に更新されました\",userDeleted:\"ユーザーが正常に削除されました\",groupCreated:\"グループを作成しました\",groupUpdated:\"グループを更新しました\",groupDeleted:\"グループを削除しました\",fillRequiredFields:\"必須項目をすべて入力してください\",passwordsDoNotMatch:\"パスワードが一致しません\",passwordTooShort:\"パスワードは6文字以上必要です\",enterGroupName:\"グループ名を入力\",settingsSaved:\"設定を保存しました\",cameraSettingsSaved:\"カメラ設定を保存しました\",enterCameraUrl:\"カメラURLを入力してください\",passwordChanged:\"パスワードが正常に変更されました\",connectionFailed:\"接続失敗\",testFailed:\"テスト通知の送信に失敗しました\",cameraConnected:\"カメラ接続{{resolution}}\"},testConnection:\"接続テスト\",catalog:{spoolCatalog:\"スプールカタログ\",spoolCatalogDescription:\"ブランド/タイプ別の空スプール重量。スプール追加時の自動重量検索に使用されます。\",searchCatalog:\"カタログを検索...\",addNewEntry:\"新しいエントリを追加\",namePlaceholder:\"名前（例：Bambu Lab - プラスチック）\",weight:\"重量\",type:\"タイプ\",default:\"デフォルト\",custom:\"カスタム\",noMatch:\"検索に一致するエントリがありません\",empty:\"カタログにエントリがありません\",deleteEntry:\"エントリを削除\",deleteConfirm:\"「{{name}}」を削除してもよろしいですか？\",resetCatalog:\"カタログをリセット\",resetConfirm:\"カタログをデフォルトにリセットしますか？カスタムエントリはすべて削除されます。\",loadFailed:\"スプールカタログの読み込みに失敗しました\",nameWeightRequired:\"名前と重量は必須です\",entryAdded:\"エントリを追加しました\",addFailed:\"エントリの追加に失敗しました\",entryUpdated:\"エントリを更新しました\",updateFailed:\"エントリの更新に失敗しました\",entryDeleted:\"エントリを削除しました\",deleteFailed:\"エントリの削除に失敗しました\",resetSuccess:\"カタログをデフォルトにリセットしました\",resetFailed:\"カタログのリセットに失敗しました\",exported:\"{{count}}件のエントリをエクスポートしました\",imported:\"{{added}}件のエントリをインポートしました（{{skipped}}件スキップ）\",importFailed:\"インポートに失敗しました：無効なJSON形式\",exportTooltip:\"カタログをJSONにエクスポート\",importTooltip:\"JSONからカタログをインポート\",resetTooltip:\"デフォルトにリセット\",selectedCount:\"{{count}}件選択中\",deleteSelected:\"選択を削除\",bulkDeleteConfirm:\"{{count}}件のエントリーを削除してもよろしいですか？\",bulkDeleted:\"{{count}}件のエントリーを削除しました\",bulkDeleteFailed:\"エントリーの削除に失敗しました\"},colorCatalog:{title:\"カラーカタログ\",description:\"メーカー/素材別のフィラメントカラー。スプール追加時の自動カラー検索に使用されます。\",searchColors:\"カラーを検索...\",allManufacturers:\"すべてのメーカー\",addNewColor:\"新しいカラーを追加\",manufacturer:\"メーカー\",colorName:\"カラー名\",hex:\"Hex\",materialOptional:\"素材（任意）\",showing:\"{{total}}件中{{filtered}}件を表示\",noMatch:\"検索に一致するカラーがありません\",empty:\"カタログにカラーがありません\",deleteColor:\"カラーを削除\",deleteConfirm:\"「{{name}}」を削除してもよろしいですか？\",resetCatalog:\"カラーカタログをリセット\",resetConfirm:\"カタログをデフォルトにリセットしますか？カスタムカラーはすべて削除されます。\",sync:\"同期\",starting:\"開始中...\",syncTooltip:\"FilamentColors.xyzから同期（2000+カラー）\",loadFailed:\"カラーカタログの読み込みに失敗しました\",fieldsRequired:\"メーカー、カラー名、Hexカラーは必須です\",colorAdded:\"カラーを追加しました\",addFailed:\"カラーの追加に失敗しました\",colorUpdated:\"カラーを更新しました\",updateFailed:\"カラーの更新に失敗しました\",colorDeleted:\"カラーを削除しました\",deleteFailed:\"カラーの削除に失敗しました\",resetSuccess:\"カラーカタログをデフォルトにリセットしました\",resetFailed:\"カタログのリセットに失敗しました\",syncUpToDate:\"最新の状態です（{{count}}件のカラーを確認）\",syncComplete:\"{{added}}件の新しいカラーを追加しました（{{skipped}}件は既に存在）\",syncError:\"同期エラー\",syncFailed:\"FilamentColors.xyzからの同期に失敗しました\",exported:\"{{count}}件のカラーをエクスポートしました\",imported:\"{{added}}件のカラーをインポートしました（{{skipped}}件スキップ）\",importFailed:\"インポートに失敗しました：無効なJSON形式\",selectedCount:\"{{count}}件選択中\",deleteSelected:\"選択を削除\",bulkDeleteConfirm:\"{{count}}件のカラーを削除してもよろしいですか？\",bulkDeleted:\"{{count}}件のカラーを削除しました\",bulkDeleteFailed:\"カラーの削除に失敗しました\"},dateFormat:\"日付形式\",dateFormatUs:\"US (MM/DD/YYYY)\",dateFormatEu:\"EU (DD/MM/YYYY)\",dateFormatIso:\"ISO (YYYY-MM-DD)\",timeFormat:\"時刻形式\",timeFormat12:\"12時間制 (3:30 PM)\",timeFormat24:\"24時間制 (15:30)\",defaultPrinter:\"デフォルトプリンター\",defaultPrinterDescription:\"アップロード、再印刷、その他の操作でこのプリンターを事前選択します。\",slicerBambuStudio:\"Bambu Studio\",slicerOrcaSlicer:\"OrcaSlicer\",sidebarOrderDescription:\"サイドバーの項目をドラッグして並べ替え。ここでデフォルトの順序にリセット。\",setDefault:\"デフォルト設定\",sidebarOrderSetDefaultHint:\"デフォルト設定は、まだカスタマイズしていないユーザーに現在のメニュー順序を適用します。\",sidebarDefaultSet:\"デフォルトメニュー順序を設定しました。\",sidebarDefaultCleared:\"デフォルトメニュー順序をクリアしました。\",sidebarDefaultFailed:\"デフォルトメニュー順序の設定に失敗しました。\",reset:\"リセット\",darkMode:\"ダークモード\",lightMode:\"ライトモード\",active:\"(アクティブ)\",background:\"背景\",accent:\"アクセント\",style:\"スタイル\",bgNeutral:\"ニュートラル\",bgWarm:\"ウォーム\",bgCool:\"クール\",bgOled:\"OLEDブラック\",bgSlate:\"スレートブルー\",bgForest:\"フォレストグリーン\",accentGreen:\"グリーン\",accentTeal:\"ティール\",accentBlue:\"ブルー\",accentOrange:\"オレンジ\",accentPurple:\"パープル\",accentRed:\"レッド\",styleClassic:\"クラシック\",styleGlow:\"グロー\",styleVibrant:\"ビビッド\",themeToggleHint:\"サイドバーの太陽/月アイコンでダークモードとライトモードを切り替えます。\",autoArchivePrints:\"印刷を自動アーカイブ\",autoArchiveDescription:\"印刷完了時に3MFファイルを自動保存\",saveThumbnailsDescription:\"3MFファイルからプレビュー画像を抽出して保存\",captureFinishPhotoDescription:\"印刷完了時にプリンターカメラから写真を撮影\",ffmpegNotInstalled:\"ffmpegがインストールされていません\",ffmpegRequired:\"カメラ撮影にはffmpegが必要です。<brew>brew install ffmpeg</brew>（macOS）または<apt>apt install ffmpeg</apt>（Linux）でインストールしてください。\",camera:\"カメラ\",cameraViewMode:\"カメラ表示モード\",cameraOverlayDescription:\"メイン画面上にリサイズ可能なオーバーレイでカメラを表示\",cameraWindowDescription:\"別のブラウザウィンドウでカメラを表示\",externalCamerasDescription:\"内蔵プリンターカメラの代わりに外部カメラを設定。MJPEGストリーム、RTSP、HTTPスナップショット、USBカメラ（V4L2）をサポート。有効にすると、ライブビューと完了写真に外部カメラが使用されます。\",cameraPlaceholderUsb:\"デバイスパス (/dev/video0)\",cameraPlaceholderUrl:\"カメラURL (rtsp://... または http://...)\",cameraTypeMjpeg:\"MJPEGストリーム\",cameraTypeRtsp:\"RTSPストリーム\",cameraTypeSnapshot:\"HTTPスナップショット\",cameraTypeUsb:\"USBカメラ (V4L2)\",cameraRotation:\"回転\",test:\"テスト\",connected:\"接続済み\",disconnected:\"未接続\",currency:\"通貨\",defaultFilamentCost:\"デフォルトフィラメントコスト（kg単価）\",electricityCost:\"電気料金（kWh単価）\",energyDisplayMode:\"エネルギー表示モード\",energyModePrintDescription:\"ダッシュボードに印刷中の消費エネルギーの合計を表示\",energyModeTotalDescription:\"ダッシュボードにスマートプラグの累計エネルギーを表示\",fileManager:\"ファイルマネージャー\",createArchiveEntry:\"印刷時にアーカイブエントリを作成\",createArchiveEntryDescription:\"ファイルマネージャーから印刷時に、オプションでアーカイブエントリを作成\",lowDiskSpaceWarning:\"ディスク容量不足の警告\",lowDiskSpaceDescription:\"空きディスク容量がこのしきい値を下回った場合に警告を表示\",printerFirmware:\"プリンターファームウェア\",checkFirmwareDescription:\"Bambu Labのプリンターファームウェア更新を確認\",bambuddySoftware:\"Bambuddyソフトウェア\",autoCheckDescription:\"起動時に自動的に新しいバージョンを確認\",checkNow:\"今すぐ確認\",updateAvailableVersion:\"アップデート利用可能: v{{version}}\",releaseNotes:\"リリースノート\",updateViaDocker:\"Docker Composeでアップデート:\",installUpdate:\"アップデートをインストール\",latestVersionRunning:\"最新バージョンを使用しています\",failedToCheckUpdates:\"アップデートの確認に失敗しました: {{error}}\",backupRestore:\"バックアップと復元\",backupRestoreDescription:\"設定のエクスポート/インポートとGitHubバックアップの設定\",goToBackup:\"バックアップへ\",externalUrl:\"外部URL\",externalUrlDescription:\"Bambuddyがアクセス可能な外部URL。通知画像や外部連携に使用されます。\",bambuddyUrl:\"Bambuddy URL\",externalUrlHint:\"プロトコルとポートを含めてください（例: http://192.168.1.100:8000）\",ftpRetry:\"FTPリトライ\",ftpRetryDescription:\"プリンターのWi-Fiが不安定な場合にFTP操作をリトライ。3MFダウンロード、印刷アップロード、タイムラプスダウンロード、ファームウェア更新に適用。\",autoRetryDescription:\"失敗したFTP操作を自動的にリトライ\",retryAttempts:\"リトライ回数\",retryDelay:\"リトライ遅延\",connectionTimeout:\"接続タイムアウト\",time_one:\"{{count}}回\",time_other:\"{{count}}回\",second_one:\"{{count}}秒\",second_other:\"{{count}}秒\",nSeconds:\"{{count}}秒\",increaseForWeakWifi:\"Wi-Fiが弱いプリンター用に増やしてください\",homeAssistant:\"Home Assistant\",homeAssistantFullDescription:\"Home Assistantに接続してHA REST API経由でスマートプラグを制御。switch、light、input_boolean、scriptエンティティをサポート。\",homeAssistantUrl:\"Home Assistant URL\",longLivedAccessToken:\"長期アクセストークン\",haTokenHint:\"HAでトークンを作成: プロフィール → 長期アクセストークン → トークンを作成\",connectionSuccessful:\"接続成功\",connectionFailed:\"接続失敗\",haConnectionSuccess:\"Home Assistantへの接続に成功しました。\",haConnectionFailed:\"Home Assistantへの接続に失敗しました。\",mqttPublishing:\"MQTTパブリッシュ\",mqttDescription:\"Node-RED、Home Assistant、その他の自動化システムとの統合のため、外部MQTTブローカーにBamBuddyイベントをパブリッシュ。\",mqttEnableDescription:\"外部MQTTブローカーにイベントをパブリッシュ\",brokerHostname:\"ブローカーホスト名\",port:\"ポート\",usernameOptional:\"ユーザー名（オプション）\",passwordOptional:\"パスワード（オプション）\",topicPrefix:\"トピックプレフィックス\",topicPrefixHint:\"トピック形式: {{prefix}}/printers/<serial>/status 等\",prometheusMetrics:\"Prometheusメトリクス\",prometheusEndpointDescription:\"Prometheus/Grafanaモニタリング用に<code>/api/v1/metrics</code>でプリンターメトリクスを公開。\",bearerTokenOptional:\"Bearerトークン（オプション）\",bearerTokenHint:\"設定時、リクエストに<code>Authorization: Bearer <token></code>が必要\",metricsConnectionStatus:\"接続状態\",metricsPrinterState:\"プリンター状態（idle/printing等）\",metricsPrintProgress:\"印刷進捗 0-100%\",metricsBedTemp:\"ベッド温度\",metricsNozzleTemp:\"ノズル温度\",metricsPrintsTotal:\"結果別の総印刷数\",metricsMore:\"...その他（レイヤー、ファン、キュー、フィラメント使用量）\",smartPlugsDescription:\"スマートプラグ（TasmotaまたはHome Assistant）を接続して、電源制御の自動化とプリンターのエネルギー使用量を追跡。\",allOn:\"すべてオン\",allOff:\"すべてオフ\",addSmartPlug:\"スマートプラグを追加\",energySummary:\"エネルギー概要\",currentPower:\"現在の消費電力\",plugsOnline:\"{{reachable}}/{{total}}プラグオンライン\",today:\"今日\",yesterday:\"昨日\",total:\"合計\",enablePlugsForSummary:\"プラグを有効にしてエネルギー概要を表示\",addNotificationProvider:\"追加\",systemBadge:\"(システム)\",creating:\"作成中...\",changing:\"変更中...\",deleteUserAndItems:\"ユーザーとそのアイテムを削除\",deleteUserKeepItems:\"ユーザーを削除、アイテムは保持（オーナーなしになります）\",ok:\"OK\",twoFa:{totpTitle:\"認証アプリ (TOTP)\",totpDesc:\"Google Authenticator、Aegis、Authyなどのアプリを使用します。\",emailOtpTitle:\"メールOTP\",emailOtpDesc:\"ログイン時に{{email}}にワンタイムコードを送信します。\",emailOtpNoEmail:\"この方法を有効にするには、アカウントにメールアドレスを追加してください。\",addEmailFirst:\"アカウントにメールアドレスがありません。管理者に追加を依頼してください。\",setupTotp:\"認証アプリを設定\",setupAuthApp:\"認証アプリを設定\",setupInstructions:\"認証アプリでQRコードをスキャンし、コードで確認してください。\",manualEntry:\"スキャンできない場合は、このシークレットを手動で入力してください:\",scannedContinue:\"コードをスキャンしました — 続ける\",enterCodeToConfirm:\"認証アプリの6桁のコードを入力して設定を確認してください。\",activate:\"有効化\",disableTotp:\"認証アプリを無効化\",disableConfirmHint:\"認証アプリを無効にするには、有効なTOTPコードまたはバックアップコードを入力してください。\",totpDisabled:\"認証アプリが無効化されました。\",emailOtpEnabled:\"メールOTPが有効化されました。\",emailOtpDisabled:\"メールOTPが無効化されました。\",smtpRequired:\"先にSMTP設定を構成してテストしてください。\",invalidCode:\"無効なコードです。もう一度お試しください。\",enableEmailOtp:\"メールOTPを有効化\",disableEmailOtp:\"メールOTPを無効化\",emailSetupEnterCode:\"確認コードがメールアドレスに送信されました。このメールボックスを所有していることを確認するために、以下に入力してください。\",verifyAndEnable:\"確認して有効化\",emailDisablePasswordHint:\"メールOTPの無効化を確認するには、アカウントのパスワードを入力してください。\",passwordPlaceholder:\"パスワードを入力してください\",backupCodesTitle:\"バックアップコードを保存\",backupCodesWarning:\"これらのコードを安全な場所に保存してください。各コードは一度しか使用できません。\",backupCodesRemaining:\"バックアップコード残り{{count}}個\",savedCodes:\"コードを保存しました\",regenBackup:\"バックアップコードを再生成\",regenBackupHint:\"現在のTOTPコードを入力して10個の新しいバックアップコードを生成します。\",newBackupCodes:\"新しいバックアップコード\",linkedAccounts:\"リンクされたSSOアカウント\",linkedAccountsDesc:\"これらの外部IDプロバイダーがあなたのアカウントにリンクされています。\",oidcUnlinked:\"アカウントのリンクを解除しました。\"},oidc:{title:\"SSO / OIDCプロバイダー\",desc:\"シングルサインオン用のOpenID Connectプロバイダーを設定します。\",addProvider:\"プロバイダーを追加\",newProvider:\"新しいプロバイダー\",empty:\"OIDCプロバイダーがまだ設定されていません。\",created:\"プロバイダーが作成されました。\",updated:\"プロバイダーが更新されました。\",deleted:\"プロバイダーが削除されました。\",deleteTitle:\"プロバイダーを削除\",deleteMessage:'\"{{name}}\"を削除しますか？リンクされたすべてのユーザーアカウントが切断されます。',form:{name:\"表示名\",issuerUrl:\"発行者URL\",clientId:\"クライアントID\",clientSecret:\"クライアントシークレット\",scopes:\"スコープ\",iconUrl:\"アイコンURL (任意)\",enabled:\"有効\",autoCreate:\"ユーザーを自動作成\",autoCreateDesc:\"初回ログイン時にローカルアカウントを自動的に作成します。\",autoLink:\"既存アカウントを自動リンク\",autoLinkDesc:\"初回ログイン時にメールアドレスで既存のローカルアカウントにリンクします。\",secretHint:\"空白のままで現在のものを維持\",secretPlaceholder:\"新しいシークレット\"}}},notification:{printStarted:{title:\"印刷開始\",body:\"{{printer}}: {{filename}} の印刷を開始しました\"},printCompleted:{title:\"印刷完了\",body:\"{{printer}}: {{filename}} が正常に完了しました\"},printFailed:{title:\"印刷失敗\",body:\"{{printer}}: {{filename}} が失敗しました\"},printStopped:{title:\"印刷中止\",body:\"{{printer}}: {{filename}} が中止されました\"},printProgress:{title:\"印刷進捗\",body:\"{{printer}}: {{filename}} は {{percent}}% 完了\"},printerOffline:{title:\"プリンターオフライン\",body:\"{{printer}} がオフラインです\"},printerError:{title:\"プリンターエラー\",body:\"{{printer}}: {{error}}\"},filamentLow:{title:\"フィラメント残量低下\",body:\"{{printer}}: フィラメントが残りわずかです\"},maintenanceDue:{title:\"メンテナンス期限\",body:\"{{printer}}: {{items}} の対応が必要です\"}},errors:{generic:\"問題が発生しました\",networkError:\"ネットワークエラーです。接続を確認してください。\",notFound:\"見つかりません\",unauthorized:\"認証エラー\",serverError:\"サーバーエラー\",validationError:\"入力内容を確認してください\",printerConnectionFailed:\"プリンターへの接続に失敗しました\",saveFailed:\"保存に失敗しました\",deleteFailed:\"削除に失敗しました\",loadFailed:\"データの読み込みに失敗しました\"},hmsErrors:{title:\"エラー - {{name}}\",noErrors:\"エラーなし\",viewOnWiki:\"Bambu Lab Wikiで表示\",clearInstructions:\"プリンターでエラーをクリアするとここからも消えます。\",clearErrors:\"エラーをクリア\",clearSuccess:\"HMSエラーをクリアしました\",clearFailed:\"HMSエラーのクリアに失敗しました\"},mqttDebug:{title:\"MQTTデバッグログ\",searchPlaceholder:\"トピックまたはペイロードで検索...\",noMessages:\"まだメッセージが記録されていません\",startLoggingHint:\"「ログ開始」をクリックしてMQTTメッセージのキャプチャを開始\",noMessagesMatch:\"フィルターに一致するメッセージがありません\",adjustFilterHint:\"検索条件やフィルター条件を調整してみてください\",incoming:\"受信\",outgoing:\"送信\",loggingStopped:\"ログ記録停止\",loggingActive:\"ログ記録中 - メッセージは自動更新されます\",startLogging:\"ログ記録を開始\",stopLogging:\"ログ停止\",clearLog:\"ログをクリア\",topic:\"トピック\",timestamp:\"タイムスタンプ\",direction:\"方向\",all:\"すべて\"},printerFiles:{title:\"ファイル管理\",storageUsed:\"使用中:\",storageFree:\"空き:\",filterPlaceholder:\"ファイルを検索...\",deleteButton:\"削除\",deleteFiles:\"{{count}}件のファイルを削除\",deleteFileConfirm:\"このファイルを削除しますか？\",deleteFilesConfirm:\"選択した{{count}}件のファイルを削除しますか？元に戻せません。\",noFiles:\"このディレクトリにファイルがありません\",loadingFiles:\"ファイルを読み込み中...\",failedToLoad:\"ファイルの読み込みに失敗しました\",toast:{filesDeleted:\"{{count}}件のファイルを削除しました\",deleteFailed:\"削除に失敗: {{error}}\"}},confirm:{delete:\"削除しますか？\",unsavedChanges:\"保存されていない変更があります。このページを離れますか？\",clearQueue:\"キューをクリアしますか？\"},login:{title:\"Bambuddy ログイン\",subtitle:\"アカウントにサインイン\",username:\"ユーザー名\",usernamePlaceholder:\"ユーザー名を入力\",usernameOrEmail:\"ユーザー名またはメール\",usernameOrEmailPlaceholder:\"ユーザー名または @ メール\",password:\"パスワード\",passwordPlaceholder:\"パスワードを入力\",signIn:\"サインイン\",signingIn:\"ログイン中...\",forgotPassword:\"パスワードをお忘れですか？\",loginSuccess:\"ログインしました\",loginFailed:\"ログインに失敗しました\",enterCredentials:\"ユーザー名とパスワードを入力してください\",enterEmail:\"メールアドレスを入力してください\",oidcLoginFailed:\"OIDCログインに失敗しました\",oidcErrors:{providerError:\"IDプロバイダーがエラーを返しました\",missingParameters:\"OIDCコールバックに必須パラメーターがありません\",invalidState:\"OIDCの状態が無効か、すでに使用されています\",stateExpired:\"OIDCログインセッションが期限切れです。もう一度お試しください\",providerNotFound:\"OIDCプロバイダーが見つかりません\",discoveryFailed:\"OIDCディスカバリードキュメントの取得に失敗しました\",invalidDiscovery:\"OIDCディスカバリードキュメントが無効です\",networkError:\"OIDCトークン交換中にネットワークエラーが発生しました\",badResponse:\"OIDCトークン交換中に予期しない応答を受信しました\",noIdToken:\"OIDCプロバイダーがIDトークンを返しませんでした\",validationFailed:\"OIDCトークンの検証に失敗しました\",nonceMismatch:\"OIDCノンスが一致しません。リプレイ攻撃の可能性があります\",missingSubClaim:\"OIDCトークンにsubクレームがありません\",noLinkedAccount:\"このOIDCアイデンティティに関連付けられたローカルアカウントがありません\",accountInactive:\"あなたのアカウントは無効です\",userResolutionFailed:\"アカウントを解決できませんでした\",internalError:\"OIDCログイン中に内部エラーが発生しました\",tokenExchangeFailed:\"OIDCトークン交換に失敗しました\"},forgotPasswordTitle:\"パスワードを忘れた場合\",forgotPasswordMessage:\"パスワードを忘れた場合は、システム管理者に連絡してリセットしてもらってください。\",forgotPasswordEmailMessage:\"メールアドレスを入力すると、新しいパスワードを送信します。\",emailAddress:\"メールアドレス\",emailPlaceholder:\"your.email@example.com\",cancel:\"キャンセル\",sending:\"送信中...\",sendResetEmail:\"リセットメールを送信\",howToReset:\"パスワードのリセット方法：\",resetStep1:\"Bambuddy管理者に連絡\",resetStep2:\"ユーザー管理でパスワードリセットを依頼\",resetStep3:\"管理者が新しい仮パスワードを設定\",resetStep4:\"新しいパスワードでログインし、設定で変更\",gotIt:\"了解\",twoFA:{title:\"二段階認証\",subtitle:\"アカウントは二段階認証で保護されています。確認コードを入力してください。\",methodAuthenticator:\"認証アプリ\",methodEmail:\"メール認証\",methodBackup:\"バックアップコード\",instructionsTotp:\"認証アプリを開いて、Bambuddy用の6桁のコードを入力してください。\",instructionsEmail:\"6桁の確認コードをメールアドレスに送信しました。有効期限は10分です。\",instructionsEmailNotSent:\"下のボタンをクリックして、メールで確認コードを受け取ってください。\",instructionsBackup:\"8文字のバックアップコードをいずれか1つ入力してください。各コードは1回のみ使用可能です。\",sendCodeButton:\"メールでコードを送信する\",sendingCode:\"送信中...\",resendCode:\"コードを再送する\",codeLabel:\"確認コード\",backupCodeLabel:\"バックアップコード\",codePlaceholder:\"000000\",backupCodePlaceholder:\"XXXXXXXX\",verifyButton:\"確認する\",verifyingButton:\"確認中...\",backToLogin:\"← ログイン画面に戻る\",orContinueWith:\"または以下でログイン\",signInWith:\"{{provider}}でログイン\",enterCode:\"確認コードを入力してください\",sendCodeFailed:\"確認コードの送信に失敗しました\",invalidCode:\"無効なコードです。もう一度お試しください。\"}},setup:{title:\"Bambuddy セットアップ\",subtitle:\"Bambuddyインスタンスの認証を設定\",enableAuth:\"認証を有効化\",adminAccount:\"管理者アカウント\",adminAccountDesc:\"既に管理者ユーザーが存在する場合、既存の管理者アカウントを使用して認証が有効化されます。既存の管理者を使用する場合は下のフィールドを空のままにするか、新しい認証情報を入力して新しい管理者ユーザーを作成してください。\",adminUsername:\"管理者ユーザー名\",adminPassword:\"管理者パスワード\",optionalIfAdminExists:\"（管理者ユーザーが存在する場合は任意）\",adminUsernamePlaceholder:\"管理者ユーザー名を入力（任意）\",adminPasswordPlaceholder:\"管理者パスワードを入力（任意）\",confirmPassword:\"パスワードの確認\",confirmPasswordPlaceholder:\"パスワードを確認\",settingUp:\"セットアップ中...\",completeSetup:\"セットアップを完了\",toast:{authEnabledAdminCreated:\"認証が有効になり、管理者ユーザーが作成されました\",authEnabledExistingAdmins:\"既存の管理者ユーザーを使用して認証が有効になりました\",setupCompleted:\"セットアップが完了しました\",enterBothCredentials:\"管理者のユーザー名とパスワードの両方を入力するか、既存の管理者を使用する場合は両方を空にしてください\",passwordsDoNotMatch:\"パスワードが一致しません\",passwordTooShort:\"パスワードは6文字以上必要です\"}},changePassword:{title:\"パスワードを変更\",currentPassword:\"現在のパスワード\",currentPasswordPlaceholder:\"現在のパスワードを入力\",newPassword:\"新しいパスワード\",newPasswordPlaceholder:\"新しいパスワードを入力（6文字以上）\",confirmPassword:\"新しいパスワード確認\",confirmPasswordPlaceholder:\"パスワードを確認\",passwordsDoNotMatch:\"パスワードが一致しません\",passwordTooShort:\"パスワードは6文字以上必要です\",changing:\"変更中...\",success:\"パスワードを変更しました\",failed:\"パスワードの変更に失敗しました\"},plateAlert:{title:\"印刷が一時停止されました！\",message:\"ビルドプレート上にオブジェクトが検出されました。印刷が自動的に一時停止されました。プレートをクリアして印刷を再開してください。\",understand:\"了解\"},camera:{title:\"カメラビュー\",invalidPrinterId:\"無効なプリンターID\",live:\"ライブ\",snapshot:\"スナップショット\",restartStream:\"ストリームを再開\",refreshSnapshot:\"スナップショットを更新\",fullscreen:\"フルスクリーン\",exitFullscreen:\"フルスクリーンを終了\",connectingToCamera:\"カメラに接続中...\",capturingSnapshot:\"スナップショットを撮影中...\",connectionLost:\"接続が切断されました\",connectionFailed:\"カメラ接続に失敗しました\",reconnecting:\"{{countdown}}秒後に再接続... (試行 {{attempt}}/{{max}})\",reconnectNow:\"今すぐ再接続\",cameraUnavailable:\"カメラが利用できません\",cameraUnavailableDesc:\"プリンターの電源がオンで接続されていることを確認してください。\",noCamera:\"カメラがありません\",retry:\"再試行\",cameraStream:\"カメラストリーム\",zoomOut:\"ズームアウト\",zoomIn:\"ズームイン\",resetZoom:\"ズームをリセット\",recording:\"録画中\",startRecording:\"録画開始\",stopRecording:\"録画停止\",chamberLight:\"チャンバーライト切替\"},groups:{title:\"グループ管理\",subtitle:\"アクセス制御の権限グループを管理\",backToSettings:\"設定に戻る\",createGroup:\"グループを作成\",noPermission:\"このページにアクセスする権限がありません。\",system:\"システム\",noDescription:\"説明なし\",usersCount:\"{{count}}人のユーザー\",permissionsCount:\"{{count}}個の権限\",edit:\"編集\",delete:\"削除\",toast:{created:\"グループを作成しました\",updated:\"グループを更新しました\",deleted:\"アーカイブを削除しました\",enterGroupName:\"グループ名を入力\"},modal:{editGroup:\"グループを編集\",createGroup:\"グループを作成\",cancel:\"キャンセル\",saving:\"保存中...\",creating:\"作成中...\",saveChanges:\"変更を保存\"},form:{groupName:\"グループ名\",groupNamePlaceholder:\"グループ名を入力\",systemGroupWarning:\"システムグループ名は変更できません\",description:\"説明\",descriptionPlaceholder:\"説明を入力（任意）\",permissions:\"権限\"},deleteModal:{title:\"グループを削除\",message:\"このグループを削除しますか？このグループのユーザーはこれらの権限を失います。\",confirm:\"確認\"},editor:{title:\"グループを編集\",createTitle:\"グループを作成\",search:\"権限を検索...\",selectAll:\"すべて選択\",clearAll:\"すべて解除\",permissionsSelected:\"{{count}}件選択\",noResults:\"検索に一致する権限がありません\"}},users:{title:\"ユーザー管理\",subtitle:\"ユーザーとBambuddyインスタンスへのアクセスを管理\",backToSettings:\"設定に戻る\",createUser:\"ユーザーを作成\",noPermission:\"このページにアクセスする権限がありません。\",admin:\"管理者\",noGroups:\"グループなし\",active:\"アクティブ\",inactive:\"非アクティブ\",edit:\"編集\",delete:\"削除\",system:\"システム\",noGroupsAvailable:\"利用可能なグループがありません\",table:{username:\"ユーザー名\",groups:\"グループ\",status:\"ステータス\",actions:\"アクション\"},toast:{created:\"ユーザーを作成しました\",updated:\"ユーザーを更新しました\",deleted:\"アーカイブを削除しました\",fillRequired:\"必須項目をすべて入力してください\",passwordsDoNotMatch:\"パスワードが一致しません\",passwordTooShort:\"パスワードは6文字以上必要です\"},modal:{createUser:\"ユーザーを作成\",editUser:\"ユーザーを編集\",cancel:\"キャンセル\",creating:\"作成中...\",saving:\"保存中...\",saveChanges:\"変更を保存\",advancedAuthSubtitle:\"高度な認証を使用\"},form:{username:\"ユーザー名\",usernamePlaceholder:\"ユーザー名を入力\",email:\"メール\",emailPlaceholder:\"user@example.com\",password:\"パスワード\",passwordPlaceholder:\"パスワードを入力\",confirmPassword:\"パスワードの確認\",confirmPasswordPlaceholder:\"パスワードを確認\",newPasswordPlaceholder:\"新しいパスワードを入力\",confirmNewPasswordPlaceholder:\"新しいパスワードを確認\",leaveBlankToKeep:\"（現在のパスワードを維持する場合は空白）\",groups:\"グループ\",optional:\"オプション\",autoGeneratedPassword:\"安全なパスワードが自動的に生成され、ユーザーにメールで送信されます。\",passwordManagedByAdvancedAuth:\"パスワードは高度な認証によって管理されています。「パスワードのリセット」を使用して、メールで新しいパスワードをユーザーに送信してください。\",resetPassword:\"パスワードのリセット\",resettingPassword:\"パスワードをリセット中...\"},deleteModal:{title:\"ユーザーを削除\",message:\"このユーザーを削除してもよろしいですか？この操作は元に戻せません。\",confirm:\"ユーザーを削除\"}},streamOverlay:{title:\"ストリームオーバーレイ\",invalidPrinterId:\"無効なプリンターID\",cameraStream:\"カメラストリーム\",progress:\"進捗\",eta:\"予想時間 {{minutes}} 分\",printerIdle:\"プリンター待機中\",printerOffline:\"プリンターオフライン\",status:{printing:\"印刷中\",paused:\"一時停止\",finished:\"完了\",failed:\"失敗\",idle:\"待機中\",unknown:\"不明\"}},profiles:{title:\"フィラメントプロファイル\",subtitle:\"スライサープリセットと圧力キャリブレーションの管理\",tabs:{cloud:\"クラウドプロファイル\",local:\"ローカルプロファイル\",kprofiles:\"Kプロファイル\"},localProfiles:{title:\"ローカルプロファイル\",subtitle:\"OrcaSlicerからスライサープリセットをインポート・管理\",import:\"プロファイルをインポート\",importDesc:\".bbscfg、.bbsflmt、.orca_filament、.zip、.jsonファイルをここにドロップ\",importing:\"インポート中...\",search:\"ローカルプリセットを検索...\",noPresets:\"ローカルプリセットがまだありません\",badge:\"ローカル\",edit:\"編集\",delete:\"削除\",cancel:\"キャンセル\",deleteConfirmTitle:\"プリセットを削除\",deleteConfirm:\"このプリセットを削除してもよろしいですか？元に戻せません。\",source:\"ソース\",inheritsFrom:\"継承元\",filamentType:\"タイプ\",vendor:\"メーカー\",compatiblePrinters:\"プリンター\",nozzleTemp:\"ノズル温度\",cost:\"コスト\",density:\"密度\",pressureAdvance:\"プレッシャーアドバンス\",filament:\"フィラメント\",process:\"プロセス\",printer:\"プリンター\",toast:{importSuccess:\"{{count}}件のプリセットをインポートしました\",importSkipped:\"{{count}}件のプリセットをスキップしました（重複）\",importError:\"インポート中に{{count}}件のエラーが発生しました\",deleted:\"プリセットを削除しました\",updated:\"プリセットを更新しました\"}},connectedAs:\"接続中:\",logout:\"ログアウト\",noLogoutPermission:\"ログアウトする権限がありません\",failedToLoad:\"ファイルの読み込みに失敗しました\",retry:\"リトライ\",time:{justNow:\"たった今\",minsAgo:\"{{count}}分前\",hoursAgo:\"{{count}}時間前\",daysAgo:\"{{count}}日前\"},toast:{loggedOut:\"ログアウトしました\"},login:{title:\"Bambuddy ログイン\",subtitle:\"アカウントにサインイン\",email:\"メールアドレス\",password:\"パスワード\",region:\"リージョン\",regionGlobal:\"グローバル\",regionChina:\"中国\",verificationCode:\"認証コード\",totpCode:\"認証アプリコード\",checkEmail:\"メール ({{email}}) に届いた6桁のコードを入力してください\",enterTotpHint:\"認証アプリの6桁のコードを入力してください\",accessToken:\"アクセストークン\",accessTokenHint:\"Bambu Labのアクセストークンを貼り付け（Bambu Studioから取得）\",back:\"戻る\",loginButton:\"ログイン\",verifyButton:\"認証\",setTokenButton:\"トークンを設定\",useToken:\"アクセストークンを使用\",useEmail:\"メールでログイン\",toast:{loggedIn:\"ログインしました\",codeSent:\"メールに認証コードを送信しました\",enterTotp:\"認証アプリのコードを入力してください\",tokenSet:\"トークンを設定しました\"}},presets:{myPreset:\"マイプリセット（編集可能）\",duplicate:\"複製\",editable:\"編集可能\",failedToLoadDetails:\"プリセットの詳細を読み込めませんでした\",deleteConfirm:\"このプロファイルを削除しますか？\",deleteWarning:\"「{{name}}」をBambu Cloudから完全に削除します。元に戻せません。\",noDuplicatePermission:\"プリセットを複製する権限がありません\",noEditPermission:\"プリセットを編集する権限がありません\",noDeletePermission:\"プロジェクトを削除する権限がありません\",types:{filament:\"フィラメント\",printer:\"プリンター\",process:\"プロセス\"},toast:{deleted:\"アーカイブを削除しました\",created:\"プリセットを作成しました\",updated:\"プリセットを更新しました\",duplicated:\"プリセットを複製しました\",fieldAdded:'フィールド \"{{key}}\" を追加しました',exported:\"プリセットをエクスポートしました\"},baseLabel:\"ベース: {{name}}\",currentLabel:\"現在: {{name}}\",newPreset:\"新規プリセット\",editPreset:\"プリセットを編集\",duplicatePreset:\"プリセットを複製\",createNewPreset:\"新しいプリセットを作成\",customizeSettings:\"新しいプリセットの設定をカスタマイズ\",compareWithBase:\"ベースプリセットと比較\",compare:\"比較\",basePreset:\"ベースプリセット\",selectBasePreset:\"ベースプリセットを選択...\",presetName:\"プリセット名\",myCustomPreset:\"カスタムプリセット\",inheritsFrom:\"継承元:\",dropJsonToImport:\"JSONファイルをドロップしてインポート\",tabs:{common:\"一般\",allFields:\"すべてのフィールド\"},availableFields:\"利用可能なフィールド\",searchFieldsPlaceholder:\"フィールドを検索...\",noMatchingFields:\"一致するフィールドがありません\",allFieldsAdded:\"すべてのフィールドが追加済みです\",addCustomField:\"カスタムフィールドを追加\",yourOverrides:\"オーバーライド一覧\",noOverridesYet:\"オーバーライドはまだありません\",clickFieldsToAdd:\"左のフィールドをクリックして追加\",saveAsTemplate:\"テンプレートとして保存\",jsonTip:\"ヒント: .jsonファイルをこのモーダルにドラッグ＆ドロップして設定をインポート\"},cloudView:{searchPlaceholder:\"プリセットを検索...\",templates:\"テンプレート\",refresh:\"更新\",newPreset:\"新規プリセット\",clearFilters:\"フィルターをクリア\",compareMode:\"比較モード\",selectAnotherPreset:\"同じタイプ（{{type}}）の別のプリセットを選択\",clickTwoPresets:\"同じタイプのプリセットを2つクリックして比較\",selectFirst:\"1. 最初を選択\",selectSecond:\"2. 2番目を選択\",compareNow:\"比較を実行\",lastSynced:\"最終同期:\",showingCount:\"{{total}}件中{{shown}}件を表示\",noPresetsFound:\"プリセットが見つかりません\",columns:{filament:\"フィラメント\",process:\"プロセス\",printer:\"プリンター\"},noFilamentPresets:\"フィラメントプリセットなし\",noProcessPresets:\"プロセスプリセットなし\",noPrinterPresets:\"プリンタープリセットなし\",filters:{type:\"種類\",owner:\"所有者\",printer:\"プリンター\",nozzle:\"ノズル\",filament:\"フィラメント\",layer:\"レイヤー\",all:\"すべて\",myPresets:\"マイプリセット\",builtIn:\"ビルトイン\",process:\"プロセス\"},noTemplatesPermission:\"テンプレートを管理する権限がありません\",noRefreshPermission:\"プロファイルを更新する権限がありません\",noCreatePermission:\"プロジェクトを作成する権限がありません\"},templates:{title:\"フィラメントプロファイル\",noTemplates:\"テンプレートがありません。バックエンドを再起動してデフォルトテンプレートを作成してください。\",createFirst:\"プリセットエディタからテンプレートを作成\",typeFilter:\"タイプ:\",deleteTitle:\"テンプレートを削除\",deleteWarning:\"この操作は元に戻せません\",deleteConfirm:\"このプロファイルを削除しますか？\",namePlaceholder:\"テンプレート名\",descriptionPlaceholder:\"説明\",settingsJson:\"設定 (JSON)\",fieldsCount:\"{{count}}フィールド\",shownInModals:\"モーダルに表示\",hiddenInModals:\"モーダルで非表示\",apply:\"適用\",toast:{deleted:\"アーカイブを削除しました\",updated:\"テンプレートを更新しました\",created:\"テンプレートを作成しました\",applied:\"テンプレートを適用しました\"}}},support:{debugLoggingActive:\"デバッグログが有効です\",manageLogs:\"管理\",collectItem7:\"プリンター接続状態とファームウェアバージョン\",collectItem8:\"連携状態（Spoolman、MQTT、HA）\",collectItem9:\"ネットワークインターフェース（サブネットのみ）\",collectItem10:\"Pythonパッケージバージョン\",collectItem11:\"データベース健全性チェック\",collectItem12:\"Docker環境の詳細\"},fileManager:{title:\"ファイル管理\",subtitle:\"印刷ファイルの整理と管理\",uploadFiles:\"ファイルをアップロード\",newFolder:\"新しいフォルダ\",folderName:\"フォルダ名\",folderNamePlaceholder:\"例: 機能パーツ\",renameFile:\"ファイル名を変更\",renameFolder:\"フォルダ名を変更\",moveFiles:\"{{count}}件のファイルを移動\",rootNoFolder:\"ルート（フォルダなし）\",current:\"（現在）\",linkFolder:\"フォルダをリンク\",linkFolderDescription:\"「{{name}}」をプロジェクトまたはアーカイブにリンクしてすばやくアクセス。\",project:\"プロジェクト\",archive:\"アーカイブ\",noProjectsFound:\"プロジェクトが見つかりません\",noArchivesFound:\"アーカイブが見つかりません\",unlink:\"リンク解除\",link:\"リンク\",dragDropFiles:\"ファイルをここにドラッグ＆ドロップ\",dropFilesHere:\"ここにファイルをドロップ\",orClickToBrowse:\"またはクリックして選択\",allFileTypesSupported:\"すべてのファイルタイプに対応。ZIPファイルは展開されます。\",zipFilesDetected:\"ZIPファイルを検出\",zipExtractOptions:\"ZIPファイルは展開されます。フォルダー構造の処理方法を選択：\",preserveZipStructure:\"ZIPのフォルダ構造を保持\",createFolderFromZip:\"ZIPファイル名からフォルダーを作成\",stlThumbnailGeneration:\"STLサムネイル生成\",zipMayContainStl:\"ZIPファイルにSTLファイルが含まれている場合があります。展開時にサムネイルを生成できます。\",thumbnailsCanBeGenerated:\"STLファイルのサムネイルを生成できます。大きなモデルは処理に時間がかかる場合があります。\",generateThumbnailsForStl:\"STLファイルのサムネイルを生成\",threemfDetected:\"3MFファイルを検出\",threemfExtractionInfo:\"プリンターモデル、素材、色、印刷設定は3MFファイルから自動的に抽出されます。\",willBeExtracted:\"• 展開予定\",filesExtracted:\"• {{count}}個のファイルを展開済み\",uploadComplete:\"アップロード完了: {{count}}個成功\",uploadFailed:\"アップロード失敗\",zipFilesFailed:\"{{count}}個のファイルが失敗\",uploading:\"アップロード中...\",changeLink:\"リンクを変更...\",linkTo:\"リンク先...\",linkToProjectOrArchive:\"プロジェクトまたはアーカイブにリンク\",addToQueue:\"キューに追加\",schedulePrint:\"印刷をスケジュール\",generateThumbnail:\"サムネイルを生成\",generateThumbnails:\"サムネイルを生成\",generateThumbnailsForMissing:\"サムネイルのないSTLファイルのサムネイルを生成\",gridView:\"グリッド表示\",listView:\"リスト表示\",lowDiskSpaceWarning:\"ディスク容量不足の警告\",lowDiskSpaceDetails:\"{{total}}中{{free}}の空き容量のみ。しきい値は設定で{{threshold}}GBに設定されています。\",files:\"ファイル\",folders:\"フォルダ\",size:\"サイズ\",free:\"空き:\",allFiles:\"すべてのファイル\",wrap:\"折り返し\",enableTextWrapping:\"テキスト折り返しを有効化\",disableTextWrapping:\"テキスト折り返しを無効化\",collapse:\"折りたたむ\",collapseFoldersByDefault:\"フォルダをデフォルトで折りたたむ\",expandFoldersByDefault:\"フォルダをデフォルトで展開する\",dragToResizeTooltip:\"ドラッグしてリサイズ、ダブルクリックでリセット\",searchFiles:\"ファイルを検索...\",allTypes:\"すべての種類\",prints:\"印刷回数\",ascending:\"昇順\",descending:\"降順\",resultsCount:\"{{total}}件中{{showing}}件\",selectAll:\"すべて選択\",deselectAll:\"すべて選択解除\",selected:\"{{count}}件選択中\",adding:\"追加中...\",loadingFiles:\"ファイルを読み込み中...\",folderIsEmpty:\"フォルダーは空です\",noFilesYet:\"ファイルはまだありません\",folderEmptyDescription:\"ファイルをアップロードするか、このフォルダーにファイルを移動して開始しましょう。\",noFilesDescription:\"印刷関連ファイルの整理を始めるにはファイルをアップロードしてください。\",noMatchingFiles:\"一致するファイルがありません\",noMatchingFilesDescription:\"現在の検索またはフィルター条件に一致するファイルがありません。\",clearFilters:\"フィルターをクリア\",printedCount:\"{{count}}回印刷済み\",uploadedBy:\"アップロード者\",deleteFolder:\"フォルダを削除\",deleteFile:\"ファイルを削除\",deleteFilesCount:\"{{count}}件のファイルを削除\",deleteFolderConfirm:\"このフォルダを削除しますか？中のファイルもすべて削除されます。\",deleteFileConfirm:\"このファイルを削除しますか？\",deleteFilesConfirm:\"選択した{{count}}件のファイルを削除しますか？この操作は元に戻せません。\",deleting:\"削除中...\",noPermissionRenameFolder:\"フォルダー名を変更する権限がありません\",noPermissionLinkFolder:\"フォルダーをリンクする権限がありません\",noPermissionDeleteFolder:\"フォルダーを削除する権限がありません\",noPermissionPrint:\"印刷する権限がありません\",noPermissionAddToQueue:\"キューに追加する権限がありません\",noPermissionDownload:\"ファイルをダウンロードする権限がありません\",noPermissionRenameFile:\"このファイル名を変更する権限がありません\",noPermissionGenerateThumbnail:\"サムネイルを生成する権限がありません\",noPermissionDeleteFile:\"このファイルを削除する権限がありません\",noPermissionCreateFolder:\"フォルダーを作成する権限がありません\",noPermissionUpload:\"ファイルをアップロードする権限がありません\",noPermissionMoveFiles:\"ファイルを移動する権限がありません\",noPermissionDeleteFiles:\"ファイルを削除する権限がありません\",linkExternal:\"外部リンク\",linkExternalFolder:\"外部フォルダをリンク\",linkExternalFolderDescription:\"ホストディレクトリ（NAS、USB、ネットワーク共有）をファイルマネージャにマウントします。ファイルはコピーされず、元のパスから直接アクセスされます。\",externalFolderNamePlaceholder:\"例：NASプリント\",externalPath:\"ホストパス\",externalPathHelp:\"Dockerホスト上のディレクトリの絶対パス。コンテナにバインドマウントされている必要があります。\",readOnly:\"読み取り専用\",readOnlyHelp:\"アップロードと削除を防止\",showHiddenFiles:\"隠しファイルを表示（ドットファイル）\",externalFolder:\"外部フォルダ\",scanFolder:\"スキャン\",toast:{folderCreated:\"フォルダを作成しました\",folderDeleted:\"フォルダを削除しました\",fileDeleted:\"ファイルを削除しました\",filesDeleted:\"{{count}}件のファイルを削除しました\",filesMoved:\"ファイルを移動しました\",folderLinked:\"フォルダをリンクしました\",folderUnlinked:\"フォルダのリンクを解除しました\",externalFolderLinked:\"外部フォルダがリンクされスキャンされました\",folderScanned:\"スキャン完了：{{added}}件追加、{{removed}}件削除\",addedToQueue:\"{{count}}個のファイルをキューに追加しました\",addedToQueuePartial:\"{{added}}件追加、{{failed}}件失敗\",failedToAddToQueue:\"ファイルの追加に失敗: {{error}}\",fileRenamed:\"ファイル名を変更しました\",folderRenamed:\"フォルダ名を変更しました\",thumbnailsGenerated:\"{{count}}件のサムネイルを生成しました\",thumbnailsGeneratedPartial:\"{{succeeded}}件生成、{{failed}}件失敗\",noStlMissingThumbnails:\"サムネイルのないSTLファイルはありません\",failedToGenerateThumbnails:\"サムネイルの生成に失敗: {{error}}\",thumbnailGenerated:\"サムネイルを生成しました\",failedToGenerateThumbnail:\"サムネイルの生成に失敗: {{error}}\"}},projects:{title:\"プロジェクト\",subtitle:\"印刷プロジェクトを管理\",newProject:\"新規プロジェクト\",editProject:\"プロジェクトを編集\",deleteProject:\"プロジェクトを削除\",projectName:\"プロジェクト: {{name}}\",description:\"説明\",noProjects:\"プロジェクトはまだありません\",noProjectsFiltered:\"{{status}}のプロジェクトはありません\",noProjectsFilteredHelp:\"{{status}}のプロジェクトがありません。ステータスが変更されるとここに表示されます。\",createFirst:\"最初のプロジェクトを作成して、関連する印刷の整理、進捗管理、ビルドの管理を始めましょう。\",createFirstButton:\"最初のプロジェクトを作成\",create:\"作成\",files:\"ファイル\",prints:\"印刷\",plates:\"プレート\",parts:\"パーツ\",lastModified:\"最終更新日\",deleteConfirm:\"このプロジェクトを削除しますか？アーカイブとキューアイテムはリンク解除されますが、削除されません。\",addFiles:\"ファイルを追加\",removeFile:\"ファイルを削除\",viewDetails:\"詳細を表示\",namePlaceholder:\"プロジェクト名\",descriptionPlaceholder:\"プロジェクトの説明（任意）\",color:\"色\",targetPlates:\"目標プレート数\",targetPlatesPlaceholder:\"例: 10\",targetPlatesHelp:\"印刷ジョブの数\",targetParts:\"目標パーツ数\",targetPartsPlaceholder:\"例: 50\",targetPartsHelp:\"必要なオブジェクトの総数\",tagsLabel:\"タグ（カンマ区切り）\",tagsPlaceholder:\"カンマ区切りのタグ\",dueDate:\"期限\",priority:\"優先度\",priorityLow:\"低\",priorityNormal:\"通常\",priorityHigh:\"高\",priorityUrgent:\"緊急\",statusActive:\"進行中\",statusCompleted:\"完了\",statusArchived:\"アーカイブ済み\",done:\"完了\",completed:\"完了\",failed:\"失敗\",inQueue:\"キュー内\",noPrintsYet:\"印刷履歴なし\",printJobs:\"印刷ジョブ\",partsPrinted:\"印刷済みパーツ\",failedParts:\"失敗パーツ\",import:\"インポート\",export:\"エクスポート\",importProject:\"プロジェクトをインポート\",exportAll:\"すべてのプロジェクトをエクスポート\",loading:\"プロジェクトを読み込み中...\",noEditPermission:\"プロジェクトを編集する権限がありません\",noDeletePermission:\"プロジェクトを削除する権限がありません\",noCreatePermission:\"プロジェクトを作成する権限がありません\",noImportPermission:\"プロジェクトをインポートする権限がありません\",noExportPermission:\"プロジェクトをエクスポートする権限がありません\",toast:{created:\"プロジェクトを作成しました\",updated:\"プロジェクトを更新しました\",deleted:\"アーカイブを削除しました\",imported:\"プロジェクトをインポートしました\",multipleImported:\"{{count}}件のプロジェクトをインポートしました\",importFailed:\"インポートに失敗しました\",exported:\"プロジェクトをエクスポートしました（メタデータのみ）\"}},projectDetail:{notFound:\"見つかりません\",backToProjects:\"プロジェクト一覧に戻る\",export:\"エクスポート\",exportProject:\"プロジェクトをエクスポート\",noExportPermission:\"プロジェクトをエクスポートする権限がありません\",noEditPermission:\"このプロジェクトを編集する権限がありません\",partOf:\"所属先\",priorityLabel:\"優先度\",noPrints:\"このプロジェクトにはまだ印刷がありません\",status:{active:\"進行中\",completed:\"完了\",archived:\"アーカイブ済み\"},priority:{low:\"低\",normal:\"通常\",high:\"高\",urgent:\"緊急\"},dueDate:{overdue:\"期限超過\",today:\"今日が期限\",daysLeft:\"残り{{count}}日\"},progress:{platesProgress:\"プレート進捗\",partsProgress:\"パーツ進捗\",printJobs:\"印刷ジョブ\",parts:\"パーツ\",percentComplete:\"% 完了\",remaining:\"残り\"},stats:{printJobs:\"印刷ジョブ\",total:\"合計\",failed:\"失敗\",partsPrinted:\"印刷済みパーツ\",printTime:\"印刷時間\",filamentUsed:\"フィラメント使用量\"},cost:{title:\"コスト追跡\",filamentCost:\"フィラメント\",energy:\"エネルギー\",totalCost:\"合計コスト\",total:\"合計\",includesBom:\"BOM含む\",budget:\"予算\",remaining:\"残り\"},subProjects:{title:\"サブプロジェクト ({{count}})\"},notes:{title:\"メモ\",noEditPermission:\"このプロジェクトを編集する権限がありません\",placeholder:\"このプロジェクトについてメモを追加...\",empty:\"<空>\"},files:{title:\"ファイル\",linkFolders:\"ファイルマネージャーからフォルダーをリンク\",forQuickAccess:\"してクイックアクセスできるようにします。\",fileCount:\"{{count}}ファイル\",empty:\"<空>\",noFiles:\"このフォルダにファイルはありません。\",print:\"今すぐ印刷\",addToQueue:\"キューに追加\"},bom:{title:\"部品表\",acquired:\"{{completed}}/{{total}} 取得済み\",showAll:\"すべて表示\",hideDone:\"完了を非表示\",addPart:\"パーツを追加\",noAddPermission:\"パーツを追加する権限がありません\",partNamePlaceholder:\"パーツ名\",partName:\"パーツ名\",qty:\"数量\",price:\"価格 ({{currency}})\",sourcingUrlPlaceholder:\"URL（任意）\",remarksPlaceholder:\"備考\",deletePart:\"パーツを削除\",deleteConfirm:\"「{{name}}」を削除しますか？\",noUpdatePermission:\"パーツを更新する権限がありません\",noEditPermission:\"このプロジェクトを編集する権限がありません\",noDeletePermission:\"プロジェクトを削除する権限がありません\",totalCost:\"合計コスト\",empty:\"<空>\"},timeline:{title:\"アクティビティタイムライン\",empty:\"<空>\"},template:{saveAsTemplate:\"テンプレートとして保存\",noCreatePermission:\"プロジェクトを作成する権限がありません\"},queue:{title:\"印刷キュー\",viewAll:\"すべて表示\",printing:\"印刷中\",queued:\"キューに追加\"},prints:{title:\"印刷 ({{count}})\"},toast:{projectUpdated:\"プロジェクトを更新しました\",partAdded:\"パーツを追加しました\",partRemoved:\"パーツを削除しました\",exportFailed:\"エクスポートに失敗しました\",projectExported:\"プロジェクトがエクスポートされました\",templateCreated:\"プロジェクトからテンプレートを作成しました\"}},system:{title:\"システム情報\",version:\"バージョン\",uptime:\"稼働時間\",cpuUsage:\"CPU使用率\",memoryUsage:\"メモリ使用量\",diskUsage:\"ディスク使用量\",networkInfo:\"ネットワーク情報\",logs:\"ログ\",debugMode:\"デバッグモード\",enableDebug:\"デバッグログを有効化\",disableDebug:\"デバッグログを無効化\",downloadLogs:\"ログをダウンロード\",clearLogs:\"通知ログを削除\",dockerInfo:\"Docker情報\",containerName:\"コンテナ名\",imageName:\"イメージ名\",platform:\"プラットフォーム\",architecture:\"アーキテクチャ\"},library:{title:\"フィラメントライブラリ\",addFilament:\"フィラメントを追加\",editFilament:\"フィラメントを編集\",deleteFilament:\"フィラメントを削除\",vendor:\"メーカー\",material:\"素材\",color:\"色\",kFactor:\"K値\",temperature:\"温度\",noFilaments:\"ライブラリにフィラメントがありません\",deleteConfirm:\"このフィラメントを削除しますか？\",importFromPrinter:\"プリンターからインポート\",exportToFile:\"ファイルにエクスポート\"},spoolman:{title:\"Spoolman連携\",enabled:\"Spoolman有効\",url:\"Spoolman URL\",connected:\"接続中\",disconnected:\"未接続\",testConnection:\"接続テスト\",sync:\"同期\",syncing:\"同期中...\",lastSync:\"最終同期\",linkToSpoolman:\"Spoolmanに連携\",openInSpoolman:\"Spoolmanで開く\",unlinkSpool:\"スプールのリンクを解除\",unlinkConfirmTitle:\"スプールのリンクを解除しますか?\",unlinkConfirmMessage:\"これにより、スプールがSpoolmanから切断されます。Spoolman内のスプールデータは変更されません。\",selectSpool:\"スプールを選択\",noUnlinkedSpools:\"Spoolmanに未連携のスプールが見つかりません。\",linkSuccess:\"スプールをSpoolmanにリンクしました\",linkFailed:\"スプールのリンクに失敗しました\",unlinkSuccess:\"スプールをSpoolmanから解除しました\",unlinkFailed:\"スプールのリンク解除に失敗しました\",spoolId:\"スプールID\",fillSourceLabel:\"(Spoolman)\",weight:\"重量\",remaining:\"残り\",disableWeightSync:\"AMS推定重量同期を無効化\",disableWeightSyncDesc:\"AMS推定値から残量を更新しません。AMSの割合ベースの推定よりもSpoolmanの使用量追跡を優先する場合に使用してください。新しいスプールは引き続きAMS推定値を初期重量として使用します。\",reportPartialUsage:\"失敗した印刷の部分使用量を報告\",reportPartialUsageDesc:\"印刷が失敗またはキャンセルされた場合、レイヤー進捗に基づいてその時点までの推定フィラメント使用量を報告します。\"},inventory:{title:\"スプール在庫管理\",addSpool:\"スプールを追加\",editSpool:\"スプールを編集\",material:\"素材\",selectMaterial:\"素材を選択...\",subtype:\"サブタイプ\",brand:\"ブランド\",searchBrand:\"ブランドを検索...\",useCustomBrand:\"「{{brand}}」を使用\",useCustomMaterial:\"カスタム素材を使用: {{material}}\",colorName:\"色名\",colorNamePlaceholder:\"Jade White, Fire Red...\",color:\"色\",hexColor:\"HEXカラー\",pickColor:\"カスタムカラーを選択\",labelWeight:\"表示重量\",coreWeight:\"空スプール重量\",searchSpoolWeight:\"スプール重量を検索...\",weightUsed:\"使用量\",currentWeight:\"残量\",measuredWeight:\"計測重量\",spoolName:\"スプール\",costPerKg:\"kgあたりのコスト\",measuredWeightError:\"計測重量は{{min}}gから{{max}}gの間で入力してください。\",slicerFilament:\"スライサーフィラメント\",slicerFilamentName:\"スライサープリセット名\",slicerPreset:\"スライサープリセット\",searchPresets:\"フィラメントプリセットを検索...\",selectedPreset:\"選択済み\",noPresetsFound:\"プリセットが見つかりません\",tempOverrides:\"温度オーバーライド\",note:\"メモ\",notePlaceholder:\"このスプールに関する追加メモ...\",archive:\"アーカイブ\",restore:\"復元\",noSpools:\"スプールがありません。最初のスプールを追加してください。\",noManualSpools:\"手動で追加されたスプールがありません。先にインベントリにスプールを追加してください。\",kProfiles:\"Kプロファイル\",addKProfile:\"Kプロファイルを追加\",assignSpool:\"スプールを割り当て\",unassignSpool:\"割り当て解除\",assignSuccess:\"スプールを割り当て、AMSスロットを設定しました\",assignFailed:\"スプールの割り当てに失敗しました\",selectSpool:\"このスロットに割り当てるスプールを選択\",assigned:\"割り当て済み\",assigning:\"割り当て中...\",searchSpools:\"スプールを検索...\",showAllSpools:\"すべてのスプールを表示\",allMaterials:\"すべての素材\",filterByBrand:\"ブランドで絞り込み...\",showArchived:\"アーカイブ済みを表示\",quickAdd:\"クイック追加（在庫）\",quantity:\"数量\",stock:\"在庫\",configured:\"設定済み\",spoolsCreated:\"{{count}}本のスプールを作成しました\",spoolCreated:\"スプールを作成しました\",spoolUpdated:\"スプールを更新しました\",spoolDeleted:\"スプールを削除しました\",spoolArchived:\"スプールをアーカイブしました\",spoolRestored:\"スプールを復元しました\",deleteConfirm:\"このスプールを削除しますか？この操作は元に戻せません。\",archiveConfirm:\"このスプールをアーカイブしますか？\",advancedSettings:\"詳細設定\",filamentInfoTab:\"フィラメント情報\",paProfileTab:\"PAプロファイル\",filamentInfo:\"フィラメント\",additional:\"追加情報\",loadingPresets:\"クラウドプリセットを読み込み中...\",cloudConnected:\"クラウド接続済み\",cloudNotConnected:\"クラウド未接続（デフォルト使用）\",recentColors:\"最近\",searchColors:\"色を検索...\",searchResults:\"検索結果\",allColors:\"すべての色\",commonColors:\"一般的な色\",showLess:\"少なく表示\",showAll:\"すべて表示\",noColorsFound:\"一致する色がありません\",noResults:\"結果なし\",selectMaterialFirst:\"フィラメント情報タブで素材を選択してください。\",noPrintersConfigured:\"プリンターが設定されていません。プリンターを追加してください。\",matchingFilter:\"フィルター\",anyBrand:\"すべてのブランド\",anyVariant:\"すべてのバリアント\",autoSelect:\"自動選択\",matches:\"件一致\",match:\"件一致\",noMatches:\"一致なし\",connected:\"接続済み\",offline:\"オフライン\",printerOffline:\"プリンターがオフラインです。接続してキャリブレーションプロファイルを表示してください。\",noKProfilesMatch:\"選択したフィラメントに一致するKプロファイルがありません。\",leftNozzle:\"左ノズル\",rightNozzle:\"右ノズル\",profilesSelected:\"キャリブレーションプロファイル選択済み\",totalInventory:\"在庫合計\",totalConsumed:\"総消費量\",byMaterial:\"素材別\",inPrinter:\"プリンター内\",lowStock:\"残量少\",sinceTracking:\"追跡開始以降\",loadedInAms:\"AMS/Extに装填中\",remaining:\"残り\",weightCheck:\"重量チェック\",lastWeighed:\"最終計量\",neverWeighed:\"未計量\",search:\"スプールを検索...\",showing:\"表示\",to:\"〜\",of:\"/\",show:\"表示\",spools:\"スプール\",spool:\"スプール\",page:\"ページ\",noSpoolsMatch:\"結果なし\",noSpoolsMatchDesc:\"検索やフィルターを調整してみてください。\",active:\"アクティブ\",archived:\"アーカイブ済み\",all:\"すべて\",used:\"使用済み\",new:\"新規\",clearFilters:\"フィルターをクリア\",table:\"テーブル\",cards:\"カード\",net:\"正味\",groupSimilar:\"グループ化\",groupedSpools:\"{{count}}本の同一スプール\",groupedRows:\"行\",columns:\"列\",configureColumns:\"列の設定\",configureColumnsDesc:\"ドラッグして並べ替えるか、矢印を使用してください。目のアイコンで表示/非表示を切り替えます。\",visible:\"表示中\",reset:\"リセット\",cancel:\"キャンセル\",applyChanges:\"変更を適用\",moveUp:\"上へ移動\",moveDown:\"下へ移動\",hideColumn:\"列を非表示\",showColumn:\"列を表示\",linkToSpool:\"スプールにリンク\",tagLinked:\"タグがスプールにリンクされました\",tagLinkFailed:\"タグのリンクに失敗しました\",tagAlreadyLinked:\"タグは既に別のスプールにリンクされています\",unknownTag:\"不明なRFIDタグが検出されました\",usageHistory:\"使用履歴\",noUsageHistory:\"まだ使用記録がありません\",printName:\"プリント名\",weightConsumed:\"消費重量\",clearHistory:\"クリア\",historyCleared:\"使用履歴がクリアされました\",fillSourceLabel:\"(Inv)\",lowStockThresholdError:\"しきい値は0.1から99.9の間でなければなりません\",assignMismatchTitle:\"材料の不一致\",assignMismatchMessage:\"選択したスプールの材料「{{spoolMaterial}}」は、{{location}} のトレイ材料「{{trayMaterial}}」と一致しません。割り当てますか？\",assignMismatchConfirm:\"強制的に割り当て\",assignPartialMismatchMessage:\"スプールの材料「{{spoolMaterial}}」は「{{trayMaterial}}」に似ていますが、{{location}} と完全には一致しません。続行しますか？\",assignProfileMismatchMessage:\"スプールのプロファイル「{{spoolProfile}}」は {{location}} のトレイプロファイル「{{trayProfile}}」と一致しません。続行しますか？\"},timelapse:{title:\"タイムラプス\",create:\"タイムラプスを作成\",download:\"ダウンロード\",delete:\"削除\",preview:\"プレビュー\",frameRate:\"フレームレート\",quality:\"品質\",processing:\"バックアップファイルを処理中...\",noTimelapses:\"利用可能なタイムラプスがありません\"},ams:{title:\"AMS\",slot:\"スロット\",empty:\"<空>\",emptySlot:\"空のスロット\",unknown:\"不明\",humidity:\"湿度\",temperature:\"温度\",filamentType:\"フィラメントタイプ\",filamentColor:\"色\",remaining:\"残り\",history:\"AMS履歴\",noHistory:\"メンテナンス履歴がありません\",configureSlot:\"フィラメントプロファイルとK値でスロットを設定\",externalSpool:\"外部スプール\",profile:\"プロファイル\",kFactor:\"K値\",fill:\"充填率\",configure:\"設定\",used:\"使用済み\",remainingUnit:\"残り\"},printModal:{title:\"印刷を開始\",selectPrinter:\"プリンターを選択\",selectPlate:\"プレートを選択\",filamentMapping:\"フィラメントマッピング\",totalCost:\"合計コスト:\",slotRemainingShort:\" - 残{{grams}}g\",printSettings:\"印刷設定\",bedLeveling:\"ベッドレベリング\",flowCalibration:\"フローキャリブレーション\",vibrationCalibration:\"振動キャリブレーション\",layerInspection:\"第一層検査\",timelapse:\"タイムラプス\",startPrint:\"印刷を開始\",addToQueue:\"キューに追加\",cancel:\"キャンセル\",noPrintersAvailable:\"利用可能なプリンターがありません\",printerBusy:\"プリンターは使用中です\",printerOffline:\"プリンターはオフラインです\",sameTypeDifferentColor:\"同じ種類、異なる色\",filamentTypeNotLoaded:\"フィラメントタイプが未読み込み\",openCalendar:\"カレンダーを開く\",leftNozzle:\"L\",rightNozzle:\"R\",leftNozzleTooltip:\"左ノズル\",rightNozzleTooltip:\"右ノズル\",filamentOverride:\"フィラメントオーバーライド\",filamentOverrideHint:\"モデルベースの割り当てに使用するフィラメントをオプションで上書きします。スケジューラは元の3MF値ではなく、選択したフィラメントに基づいてマッチングします。\",originalFilament:\"オリジナル\",overrideWith:\"変更先\",resetToOriginal:\"オリジナルに戻す\",insufficientFilamentTitle:\"フィラメントが不足しています\",insufficientFilamentMessage:\"割り当てられたスプールの一部は、この印刷に必要な量より残量が少ないです:\",insufficientFilamentLine:\"{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g\",printAnyway:\"それでも印刷\",forceColorMatch:\"カラーマッチを強制\",staggerPrinterStarts:\"Stagger printer starts\",staggerGroupSize:\"Group size\",staggerInterval:\"Interval (min)\",staggerPreview:\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",staggerLastGroup:\"last group: {{count}}\",staggerTotal:\"total: {{minutes}} min\",staggerToPrinters:\"{{count}}台のプリンターに段階的に送信\",gcodeInjection:\"自動印刷G-codeを挿入\"},backup:{title:\"バックアップと復元\",createBackup:\"バックアップを作成\",restoreBackup:\"バックアップの復元\",restoreDescription:\"バックアップファイルからすべてのデータを置き換える\",downloadBackup:\"バックアップをダウンロード\",uploadBackup:\"バックアップをアップロード\",lastBackup:\"最終バックアップ\",autoBackup:\"自動バックアップ\",backupNow:\"今すぐバックアップ\",restoreWarning:\"警告: バックアップの復元は現在のすべてのデータを上書きします。\",includeArchives:\"アーカイブを含む\",includeSettings:\"設定を含む\",includeProfiles:\"プロファイルを含む\",backupSuccess:\"バックアップを作成しました\",restoreSuccess:\"バックアップを復元しました\",backupFailed:\"バックアップに失敗しました: {{error}}\",restoreFailed:\"復元に失敗しました\",restoreNote:\"復元中、仮想プリンターは停止されます\",githubBackup:\"GitHubバックアップ\",enabled:\"有効\",cloudLoginRequired:\"Bambu Cloudログインが必要です。GitHubバックアップを有効にするには、プロファイル → クラウドプロファイルからサインインしてください。\",cloudLoginRequiredShort:\"Cloudログインが必要\",githubDescription:\"プロファイルをプライベートGitHubリポジトリに自動的に同期し、バックアップとバージョン履歴を保持します。\",repositoryUrl:\"リポジトリURL\",personalAccessToken:\"個人アクセストークン\",tokenSaved:\"(保存済み)\",enterNewToken:\"新しいトークンを入力して更新\",tokenHint:\"Contents読み書き権限を持つきめ細かいトークン\",branch:\"ブランチ\",manualOnly:\"手動のみ\",hourly:\"毎時\",daily:\"毎日\",weekly:\"毎週\",includeInBackup:\"バックアップに含める\",kProfiles:\"Kプロファイル\",kProfilesDescription:\"接続されたプリンターからの圧力キャリブレーション\",noPrintersConnected:\"プリンターが接続されていません\",printersConnected:\"{{connected}}/{{total}} 接続済み\",cloudProfiles:\"クラウドプロファイル\",cloudProfilesDescription:\"Bambu Cloudからのフィラメント、プリンター、プロセスプリセット\",appSettings:\"アプリ設定\",appSettingsDescription:\"Bambuddy設定（データベース全体）\",spoolInventory:\"スプール在庫\",spoolInventoryDescription:\"フィラメントスプール、使用履歴、コスト追跡\",printArchives:\"印刷アーカイブ\",printArchivesDescription:\"印刷履歴メタデータ（gcode/3MFファイルなし）\",lastBackupAt:\"最終バックアップ:\",noBackupsYet:\"バックアップはまだありません\",next:\"次回:\",startingBackup:\"バックアップを開始しています...\",test:\"テスト\",enableBackup:\"バックアップを有効化\",testConnection:\"接続テスト\",enterRepoUrl:\"リポジトリURLを入力してください\",enterRepoAndToken:\"リポジトリURLとアクセストークンを入力してください\",repoRequired:\"リポジトリURLは必須です\",tokenRequired:\"アクセストークンは必須です\",githubBackupEnabled:\"GitHubバックアップが有効になりました\",tokenUpdated:\"トークンが更新されました\",settingsSaved:\"設定が保存されました\",failedToSave:\"保存に失敗しました: {{message}}\",backupCompleteFiles:\"バックアップ完了 - {{count}}ファイルが更新されました\",backupSkippedNoChanges:\"バックアップをスキップ - 変更なし\",backupFailed2:\"バックアップに失敗しました: {{message}}\",clearedLogs:\"{{count}}件のログを削除しました\",failedToClearLogs:\"ログの削除に失敗しました: {{message}}\",history:\"履歴\",clear:\"クリア\",date:\"日付\",status:\"ステータス\",commit:\"コミット\",localBackup:\"ローカルバックアップ\",localBackupDescription:\"データベース、アーカイブ、アップロード、すべてのファイルを含むBambuddyデータの完全なバックアップを作成します。\",downloadBackupLabel:\"バックアップをダウンロード\",completeBackupZip:\"完全バックアップ: データベース + 全ファイル (ZIP)\",download:\"ダウンロード\",preparingBackup:\"バックアップを準備しています...\",creatingArchive:\"バックアップアーカイブを作成しています...大きなアーカイブの場合、時間がかかることがあります。\",downloadingFile:\"バックアップファイルをダウンロードしています...\",backupDownloaded:\"バックアップのダウンロードが成功しました\",failedToCreateBackup:\"バックアップの作成に失敗しました: {{message}}\",restore:\"復元\",restoreReplacesAll:\"復元はすべてのデータを置き換えます。\",restoreReplacesAllDetail:\"現在のデータベースとファイルは完全に置き換えられます。復元後に再起動が必要です。\",restoreConfirmTitle:\"バックアップを復元\",restoreConfirmMessage:'\"{{filename}}\"から復元してもよろしいですか？現在のデータベースとすべてのファイルが完全に置き換えられます。復元後にアプリケーションの再起動が必要です。',restoreConfirmButton:\"バックアップを復元\",uploadingFile:\"バックアップファイルをアップロードしています...\",backupRestoredRestart:\"バックアップが復元されました。Bambuddyを再起動してください。\",failedToRestore:\"バックアップの復元に失敗しました。ファイル形式を確認してください。\",reloadNow:\"今すぐリロード\",creatingBackup:\"バックアップを作成中\",restoringBackup:\"バックアップを復元中\",preparing:\"準備中...\",processing:\"処理中...\",doNotClosePage:\"このページを閉じたり、移動しないでください。大きなバックアップの場合、この操作には数分かかることがあります。\",restoring:\"復元中...\",restoreComplete:\"復元完了\",restoreFailed2:\"復元失敗\",importSettings:\"バックアップファイルから設定をインポート\",pleaseWaitRestoring:\"データの復元中です。お待ちください\",selectBackupFile:\"クリックしてバックアップファイルを選択 (.jsonまたは.zip)\",duplicateHandling:\"重複処理の仕組み:\",matchPrinters:\"プリンター\",matchPrintersBy:\"シリアル番号で照合\",matchSmartPlugs:\"スマートプラグ\",matchSmartPlugsBy:\"IPアドレスで照合\",matchNotificationProviders:\"通知プロバイダー\",matchNotificationProvidersBy:\"名前で照合\",matchFilaments:\"フィラメント\",matchFilamentsBy:\"名前 + タイプ + ブランドで照合\",matchArchives:\"アーカイブ\",matchArchivesBy:\"コンテンツハッシュで照合（常にスキップ）\",matchPendingUploads:\"保留中のアップロード\",matchPendingUploadsBy:\"ファイル名で照合\",matchSettingsTemplates:\"設定とテンプレート\",matchSettingsTemplatesBy:\"常に上書き\",replaceExisting:\"既存のデータを置き換え\",keepExisting:\"既存のデータを保持\",overwriteDescription:\"既に存在する項目をバックアップデータで上書き\",keepDescription:\"まだ存在しない項目のみ復元\",overwriteCaution:\"注意:\",overwriteWarning:\"上書きすると、現在の設定がバックアップのデータで置き換えられます。プリンターのアクセスコードはセキュリティのため上書きされません。\",cancel:\"キャンセル\",processingBackup:\"バックアップファイルを処理しています...\",itemsRestored:\"復元済み\",itemsSkipped:\"スキップ済み\",restored:\"復元済み\",skippedAlreadyExist:\"スキップ（既に存在）\",filesCategory:\"ファイル（3MF、サムネイルなど）\",andMore:\"...他{{count}}件\",newApiKeysGenerated:\"新しいAPIキーが生成されました\",keysShownOnce:\"これらのキーは一度だけ表示されます。今すぐコピーしてください！\",copy:\"コピー\",noDataFound:\"バックアップファイルに復元するデータが見つかりませんでした。\",close:\"閉じる\",scheduledBackup:\"Scheduled Backups\",scheduledBackupDescription:\"Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.\",frequency:\"Frequency\",backupTime:\"Time\",retention:\"Retention\",retentionDescription:\"Number of backups to keep\",outputPath:\"Output Path\",outputPathPlaceholder:\"Default: {{path}}\",outputPathDescription:\"Leave empty for default location\",runNow:\"Run Now\",backupFiles:\"Backup Files\",noScheduledBackups:\"No backups yet\",deleteBackup:\"Delete\",deleteBackupConfirm:\"Delete this backup file?\",backupRunning:\"Backup in progress...\",scheduledBackupComplete:\"Backup completed successfully\",scheduledBackupFailed:\"Backup failed\",nextBackup:\"Next backup\",backupSize:\"Size\",utc:\"UTC\",defaultPathLabel:\"Default:\",categories:{settings:\"設定\",notification_providers:\"通知プロバイダー\",notification_templates:\"通知テンプレート\",smart_plugs:\"スマートプラグ\",printers:\"プリンター\",filaments:\"フィラメント\",maintenance_types:\"メンテナンスタイプ\",archives:\"アーカイブ\",projects:\"プロジェクト\",pending_uploads:\"保留中のアップロード\",external_links:\"外部リンク\",api_keys:\"APIキー\"}},tags:{title:\"タグ\",addTag:\"タグを追加\",editTag:\"タグを編集\",deleteTag:\"タグを削除\",tagName:\"タグ名\",tagColor:\"タグの色\",noTags:\"タグがありません\",deleteConfirm:\"このタグを削除しますか？\",manageTags:\"タグを管理\"},uploadModal:{title:\"3MFファイルのアップロード\",dragDrop:\".3mfファイルをここにドラッグ＆ドロップ\",or:\"または\",browseFiles:\"ファイルを参照\",extractionInfo:\"プリンターモデルは3MFファイルのメタデータから自動的に抽出されます。\",uploaded:\"アップロード済み\",failed:\"アップロードに失敗しました\",uploading:\"アップロード中...\",upload:\"アップロード\",uploadFailed:\"アップロード失敗\"},editArchive:{title:\"アーカイブを編集\",name:\"名前\",namePlaceholder:\"印刷名\",printer:\"プリンター\",noPrinter:\"プリンターなし\",project:\"プロジェクト\",noProject:\"プロジェクトなし\",itemsPrinted:\"印刷数\",itemsPrintedHelp:\"この印刷ジョブで製造したアイテム数\",notes:\"メモ\",notesPlaceholder:\"この印刷についてメモを追加...\",externalLink:\"外部リンク\",externalLinkPlaceholder:\"https://...\",externalLinkHelp:\"Printables、Thingiverse、その他のソースへのリンク\",tags:\"タグ\",tagsPlaceholder:\"タグを追加...\",addMoreTags:\"タグをさらに追加...\",matchingTags:'\"{{query}}\" に一致',existingTags:\"既存のタグ\",clickToAdd:\"（クリックして追加）\",status:\"ステータス\",failureReason:\"失敗理由\",selectReason:\"理由を選択...\",photos:\"印刷結果の写真\",photosHelp:\"+ をクリックして印刷結果の写真を追加\",printResult:\"印刷結果\",saving:\"保存中...\",failureReasons:{adhesionFailure:\"定着不良\",spaghettiDetached:\"スパゲッティ / 剥離\",layerShift:\"レイヤーシフト\",cloggedNozzle:\"ノズル詰まり\",filamentRunout:\"フィラメント切れ\",warping:\"反り\",stringing:\"糸引き\",underExtrusion:\"押出不足\",powerFailure:\"電源障害\",userCancelled:\"ユーザーによるキャンセル\",other:\"その他\"},statuses:{completed:\"完了\",failed:\"失敗\",aborted:\"キャンセル\",printing:\"印刷中\"}},kProfiles:{title:\"Kプロファイル\",noPrintersConfigured:\"プリンターが設定されていません\",addPrinterInSettings:\"Kプロファイルを管理するには設定でプリンターを追加してください\",noActivePrinters:\"アクティブなプリンターがありません\",enablePrinterConnection:\"Kプロファイルを表示するにはプリンター接続を有効にしてください\",loadingProfiles:\"Kプロファイルを読み込み中...\",printerOffline:\"プリンターオフライン\",printerOfflineDesc:\"選択したプリンターは接続されていません。電源を入れてKプロファイルを表示してください。\",noMatchingProfiles:\"一致するプロファイルなし\",noMatchingProfilesDesc:\"検索条件に一致するプロファイルがありません\",noKProfiles:\"Kプロファイルなし\",noKProfilesDesc:\"{{diameter}}mmノズル用の圧力キャリブレーションプロファイルが見つかりません\",createFirstProfile:\"最初のプロファイルを作成\",printer:\"プリンター\",nozzle:\"ノズル\",refresh:\"更新\",addProfile:\"K-プロファイルを追加\",export:\"エクスポート\",import:\"インポート\",select:\"選択\",selectAll:\"すべて選択\",delete:\"削除\",searchPlaceholder:\"名前またはフィラメントで検索...\",allExtruders:\"すべてのエクストルーダー\",leftOnly:\"左のみ\",rightOnly:\"右のみ\",allFlow:\"すべてのフロー\",hfOnly:\"HFのみ\",sOnly:\"Sのみ\",sortName:\"ソート: 名前\",sortKValue:\"ソート: K値\",sortFilament:\"ソート: フィラメント\",leftExtruder:\"左エクストルーダー\",rightExtruder:\"右エクストルーダー\",modal:{addTitle:\"Kプロファイルを追加\",editTitle:\"Kプロファイルを編集\",profileName:\"プロファイル名\",profileNamePlaceholder:\"マイPLAプロファイル\",kValue:\"K値\",kValuePlaceholder:\"0.020\",kValueHelp:\"一般的な範囲: PLA 0.01〜0.06、PETG 0.02〜0.10\",filament:\"フィラメント\",selectFilament:\"フィラメントを選択...\",noFilamentsHelp:\"フィラメントが見つかりません。Bambu Studioでまずプロファイルを作成してください。\",flowType:\"フロータイプ\",highFlow:\"ハイフロー\",standard:\"スタンダード\",nozzleSize:\"ノズルサイズ\",extruder:\"エクストルーダー\",extruders:\"エクストルーダー\",left:\"左\",right:\"右\",notes:\"メモ（ローカル保存）\",notesPlaceholder:\"このプロファイルのメモを追加...\",notesHelp:\"メモはBambuddyに保存され、プリンターには保存されません\",syncing:\"プリンターと同期中...\",savingExtruder:\"エクストルーダーに保存中 {{current}}/{{total}}...\",pleaseWait:\"お待ちください\"},deleteConfirm:{title:\"プロファイルを削除\",cannotUndo:\"元に戻せません\",message:\"「{{name}}」をプリンターから削除しますか？\"},bulkDelete:{title:\"プロファイルを削除\",cannotUndo:\"元に戻せません\",message:\"選択した{{count}}件のプロファイルをプリンターから削除しますか？\"},toast:{profileSaved:\"Kプロファイルを保存しました\",profilesSaved:\"Kプロファイルを{{count}}台のエクストルーダーに保存しました\",selectAtLeastOneExtruder:\"エクストルーダーを1つ以上選択してください\",profileDeleted:\"Kプロファイルを削除しました\",profilesDeleted:\"{{count}}件のプロファイルを削除しました\",exportedProfiles:\"{{count}}件のプロファイルをエクスポートしました\",importedProfiles:\"{{total}}件中{{imported}}件のプロファイルをインポートしました\",noProfilesToExport:\"エクスポートするプロファイルがありません\",invalidFileFormat:\"無効なファイル形式\",failedToParseImport:\"インポートファイルの解析に失敗しました\",failedToSaveBatch:\"Kプロファイルの保存に失敗しました\",noteSaved:\"メモを保存しました\",failedToSaveNote:\"メモの保存に失敗しました\"},permission:{noRead:\"プロファイルを更新する権限がありません\",noCreate:\"プロファイルを追加する権限がありません\",noUpdate:\"Kプロファイルを更新する権限がありません\",noDelete:\"Kプロファイルを削除する権限がありません\",noExport:\"プロファイルをエクスポートする権限がありません\",noImport:\"プロファイルをインポートする権限がありません\"}},virtualPrinter:{title:\"仮想プリンター\",running:\"稼働中\",stopped:\"停止\",description:{default:\"Bambu StudioとOrcaSlicerに表示される仮想プリンターを有効化。このプリンターに送信されたファイルは印刷せずに直接アーカイブされます。\",proxy:\"スライサーのトラフィックを実際のプリンターに転送するプロキシを有効化。任意のネットワーク経由でリモート印刷が可能です。\"},enable:{title:\"仮想プリンターを有効化\",visibleInSlicer:\"スライサーの検出リストに「Bambuddy」として表示\",proxyingTo:\"{{name}}にプロキシ中\",notActive:\"非アクティブ\"},model:{title:\"プリンターモデル\",description:\"エミュレートするプリンターモデルを選択。\",restartWarning:\"モデルを変更すると仮想プリンターが再起動されます\"},accessCode:{title:\"アクセスコード\",isSet:\"アクセスコードが設定されています\",notSet:\"アクセスコード未設定 - 有効化に必要です\",placeholder:\"8文字のコードを入力\",placeholderChange:\"新しいコードを入力して変更\",hint:\"正確に8文字必要です。スライサーの認証に使用されます。\",charCount:\"({{count}}/8)\"},targetPrinter:{title:\"ターゲットプリンター\",configured:\"プロキシターゲット設定済み\",notConfigured:\"ターゲットプリンター未選択 - プロキシモードに必要です\",placeholder:\"プリンターを選択...\",hint:\"スライサートラフィックの転送先プリンターを選択。プリンターはLANモードである必要があります。\",noPrinters:\"プリンターが設定されていません。プロキシモードを使用するにはまずプリンターを追加してください。\"},remoteInterface:{title:\"ネットワークインターフェース上書き\",configured:\"インターフェース上書き有効\",optional:\"オプション — 自動検出IPが間違っている場合に使用（複数NIC、Docker、VPNなど）\",placeholder:\"自動検出（デフォルト）...\",hint:\"SSDPで広告され、TLS証明書に使用されるIPアドレスを上書きします。Bambuddyに複数のネットワークインターフェースがある場合に便利です。\"},mode:{title:\"モード\",archive:\"アーカイブ\",archiveDesc:\"ファイルを即座にアーカイブ\",review:\"レビュー\",reviewDesc:\"アーカイブ前にレビュー\",queue:\"キュー\",queueDesc:\"アーカイブしてキューに追加\",proxy:\"プロキシ\",proxyDesc:\"実際のプリンターに転送\"},autoDispatch:{title:\"自動ディスパッチ\",description:\"キューに追加されたときに自動的に印刷を開始します。オフの場合、手動ディスパッチを待ちます。\"},setupRequired:{title:\"セットアップが必要です\",description:\"仮想プリンター機能を使用するには追加のシステム設定が必要です。ポートフォワーディング、ファイアウォールルール、プラットフォーム固有の設定が含まれます。\",readGuide:\"有効にする前にセットアップガイドをお読みください\"},howItWorks:{title:\"仕組み\",step1:\"同じLAN上では、仮想プリンターはスライサー（Bambu Studio / OrcaSlicer）に自動的に表示されます。他のネットワークからは、IPアドレスとアクセスコードで手動で追加してください。\",step2:\"アーカイブ、レビュー、キューモードでは、スライサーの「送信」ボタンを使用して3MFファイルをBambuddyにアップロードします。スライサーは「印刷成功」と表示しますが、ファイルは保存され、印刷はされません。\",step3:\"プロキシモードでは、仮想プリンターはすべてのトラフィックを実際のプリンターに転送します。直接接続されているかのように印刷がすぐに開始されます。\"},status:{title:\"ステータス詳細\",printerName:\"プリンター名\",model:\"モデル\",serialNumber:\"シリアル番号\",mode:\"モード\",pendingFiles:\"保留中のファイル\",targetPrinter:\"ターゲットプリンター\",ftpPort:\"FTPポート\",mqttPort:\"MQTTポート\",ftpConnections:\"FTP接続数\",mqttConnections:\"MQTT接続数\"},toast:{updated:\"仮想プリンター設定を更新しました\",failedToUpdate:\"設定の更新に失敗しました\",accessCodeRequired:\"先にアクセスコードを設定してください\",targetPrinterRequired:\"先にターゲットプリンターを選択してください\",bindIpRequired:\"先にバインドIPを設定してください\",accessCodeEmpty:\"アクセスコードは空にできません\",accessCodeLength:\"アクセスコードは8文字である必要があります\",created:\"仮想プリンターを作成しました\",failedToCreate:\"仮想プリンターの作成に失敗しました\",deleted:\"仮想プリンターを削除しました\",failedToDelete:\"仮想プリンターの削除に失敗しました\"},list:{title:\"仮想プリンター\",add:\"追加\",addFirst:\"仮想プリンターを追加\",empty:\"仮想プリンターが設定されていません。追加して始めましょう。\"},bindIp:{title:\"バインドインターフェース\",placeholder:\"インターフェースを選択...\",hint:\"この仮想プリンターがバインドするネットワークインターフェース。プリンターごとに一意である必要があります。\"},proxy:{accessCodeHint:\"プロキシモードでは、スライサーにターゲットプリンターのアクセスコードを使用してください。接続は実際のプリンターに透過的に転送されます。\"},addDialog:{title:\"仮想プリンターを追加\",name:\"名前\",hint:\"アクセスコード、ターゲットプリンター、その他の設定は作成後に設定できます。\",create:\"作成\"},deleteConfirm:{title:\"仮想プリンターを削除\",message:\"「{{name}}」を削除してもよろしいですか？このプリンターのすべてのサービスが停止されます。\"}},modelViewer:{openInSlicer:\"スライサーで開く\",tabs:{model:\"3Dモデル\",gcode:\"G-codeプレビュー\"},notAvailable:\"利用不可\",notSliced:\"未スライス\",plates:\"プレート\",allPlates:\"全プレート\",plateNumber:\"プレート {{number}}\",plateCount:\"{{count}} プレート\",plateCount_other:\"{{count}} プレート\",objectCount:\"{{count}} オブジェクト\",objectCount_other:\"{{count}} オブジェクト\",filamentCount:\"{{count}} フィラメント\",filamentCount_other:\"{{count}} フィラメント\",eta:\"予想時間 {{minutes}} 分\",noPreview:\"このファイルのプレビューは利用できません\",pagination:{pageOf:\"ページ {{current}} / {{total}}\",prev:\"前へ\",next:\"次へ\"},errors:{failedToLoad:\"ファイルの読み込みに失敗しました\",noMeshes:\"3MFファイルにメッシュが見つかりません\",unsupportedFormat:\"サポートされていないファイル形式です\"}},maintenanceDescriptions:{lubricateCarbonRods:\"カーボンロッドに潤滑剤を塗布してスムーズな動きを確保\",lubricateRails:\"リニアレールの潤滑\",cleanNozzle:\"ノズル/ホットエンドの清掃\",checkBelts:\"ベルト張力の確認\",cleanBuildPlate:\"ビルドプレートの清掃\",checkExtruder:\"エクストルーダーギアの確認\",checkCooling:\"冷却ファンの確認\",generalInspection:\"総合点検\",cleanCarbonRods:\"カーボンロッドの清掃\",lubricateSteelRods:\"スチールロッドに潤滑剤を塗布してスムーズな動きを確保\",cleanSteelRods:\"スチールロッドの清掃\",cleanLinearRails:\"リニアレールを拭いてほこりや汚れを除去\",checkPtfeTube:\"PTFEチューブの確認\",replaceHepaFilter:\"HEPAフィルター交換\",replaceCarbonFilter:\"カーボンフィルター交換\",lubricateLeftNozzleRail:\"左ノズルレールの潤滑\"},smartPlugs:{offline:\"オフライン\",admin:\"管理\",openPlugAdminPage:\"プラグ管理ページを開く\",deleteSmartPlug:\"スマートプラグを削除\",turnOnSmartPlug:\"スマートプラグをオンにする\",turnOffSmartPlug:\"スマートプラグをオフにする\",turnOn:\"オンにする\",turnOff:\"オフにする\",addSmartPlug:{scanningNetwork:\"ネットワークをスキャン中...\",chooseEntity:\"エンティティを選択...\",connectionFailed:\"接続失敗\",searchEntities:\"エンティティを検索...\",searchPowerSensors:\"電力センサーを検索...\",searchEnergySensors:\"エネルギーセンサーを検索...\",placeholders:{plugName:\"リビングルームプラグ\",mqttStateOnValue:\"ON, true, 1\",mqttSameAsPower:\"電力トピックと同じ、または異なる\"}},linkedTo:\"リンク先:\",monitorOnly:\"監視のみ\",alerts:\"アラート\",scheduleOn:\"オン {{time}}\",scheduleOff:\"オフ {{time}}\",on:\"オン\",off:\"オフ\",power:\"電力\",kwhToday:\"本日のkWh\",settings:\"設定\",automationSettings:\"自動化設定\",showInSwitchbar:\"スイッチバーに表示\",quickAccessSidebar:\"サイドバーからクイックアクセス\",enabled:\"有効\",enableAutomation:\"このプラグの自動化を有効にする\",autoOn:\"自動オン\",autoOnDescription:\"印刷開始時にオンにする\",autoOff:\"自動オフ\",autoOffDescription:\"印刷完了時にオフにする（ワンショット）\",autoOffPersistent:\"有効のまま維持\",autoOffPersistentDescription:\"ワンショットではなく印刷間で有効のまま維持\",turnOffDelayMode:\"オフ遅延モード\",time:\"時間\",temp:\"温度\",delayMinutes:\"遅延（分）\",tempThreshold:\"温度しきい値（°C）\",tempThresholdDescription:\"ノズルがこの温度以下に冷却されるとオフになります\",edit:\"編集\",deleteConfirm:'\"{{name}}\"を削除してもよろしいですか？この操作は取り消せません。',turnOnConfirm:'\"{{name}}\"をオンにしてもよろしいですか？',turnOffConfirm:'\"{{name}}\"をオフにしてもよろしいですか？接続されたデバイスの電源が切れます。',failedToTurn:'\"{{name}}\"を{{action}}できませんでした',unknown:\"不明\",addTitle:\"スマートプラグを追加\",editTitle:\"スマートプラグを編集\",stopScanning:\"スキャン停止\",discoverTasmota:\"Tasmotaデバイスを検出\",foundDevices:\"{{count}}台のデバイスが見つかりました - クリックして選択:\",noDevicesFound:\"ネットワーク上にTasmotaデバイスが見つかりません\",haNotConfigured:\"Home Assistantが設定されていません。設定場所:\",haSettingsPath:\"設定 → ネットワーク → Home Assistant\",selectEntity:\"エンティティを選択 *\",ipAddress:\"IPアドレス *\",nameLabel:\"名前 *\",username:\"ユーザー名\",password:\"パスワード\",authHint:\"Tasmotaデバイスが認証を必要としない場合は空のままにしてください\",linkToPrinter:\"プリンターにリンク\",noPrinter:\"プリンターなし（手動制御のみ）\",linkingDescription:\"リンクすると印刷開始/完了時に自動でオン/オフできます\",powerAlerts:\"電力アラート\",alertAbove:\"上限アラート（W）\",alertBelow:\"下限アラート（W）\",alertDescription:\"電力消費がこれらのしきい値を超えた場合に通知します。無効にするには空のままにしてください。\",dailySchedule:\"デイリースケジュール\",turnOnAt:\"オンにする時刻\",turnOffAt:\"オフにする時刻\",scheduleDescription:\"毎日これらの時刻にプラグを自動的にオン/オフします。スキップするには空のままにしてください。\",showOnPrinterCard:\"プリンターカードに表示\",displayOnPrinterCard:\"プリンターカードにボタンを表示\",connectedResult:\"接続成功！\",deviceLabel:\"デバイス: {{name}} - \",stateLabel:\"状態: {{state}}\",test:\"テスト\",delete:\"削除\",save:\"保存\",add:\"追加\",cancel:\"キャンセル\",failedToStartScan:\"スキャンを開始できませんでした\",nameRequired:\"名前は必須です\",entityRequired:\"Home AssistantプラグにはエンティティIDが必要です\",mqttTopicRequired:\"電力、エネルギー、または状態監視用に少なくとも1つのMQTTトピックを設定する必要があります\",loadingEntities:\"エンティティを読み込み中...\",loading:\"読み込み中...\",failedToLoadEntities:\"エンティティの読み込みに失敗しました: {{error}}\",noEntitiesMatching:'\"{{search}}\"に一致するエンティティが見つかりません',noEntitiesAvailable:\"利用可能なエンティティがありません\",searchingEntities:\"すべてのエンティティを検索中（{{count}}件見つかりました）\",showingEntities:\"switch、light、input_booleanを表示（{{count}}件利用可能）\",energyMonitoringOptional:\"エネルギー監視（オプション）\",energyMonitoringHint:\"電力/エネルギーデータを提供するセンサーを検索して選択します。\",powerSensorW:\"電力センサー（W）\",energyTodayKwh:\"本日のエネルギー（kWh）\",totalEnergyKwh:\"総エネルギー（kWh）\",noMatchingSensors:\"一致するセンサーがありません\",none:\"なし\",mqttNotConfigured:\"MQTTブローカーが設定されていません。ブローカーアドレスを設定してください:\",mqttSettingsPath:\"設定 → ネットワーク → MQTT配信\",mqttNotConfiguredSuffix:\"（配信を有効にする必要はありません。ブローカーの詳細を入力するだけです）。\",mqttMonitorOnlyDescription:\"MQTTプラグはMQTTサブスクリプション経由で電力/エネルギーデータを受信します。オン/オフ制御は利用できません - MQTTブローカーまたはホームオートメーションシステムを使用してください。\",powerMonitoring:\"電力監視\",energyMonitoring:\"エネルギー監視\",stateMonitoring:\"状態監視\",optional:\"オプション\",topic:\"トピック\",jsonPath:\"JSONパス\",multiplier:\"乗数\",onValue:\"ON値\",mqttPowerHint:`JSONパスはJSONペイロードから値を抽出します（例: \"power_l1\"）。トピックが生の数値を送信する場合は空のままにしてください。\n乗数: mW→Wは0.001、kW→Wは1000を使用。`,mqttEnergyHint:`JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\n乗数: Wh→kWhは0.001、MWh→kWhは1000を使用。`,mqttStateHint:`JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。\nON値: \"ON\"を意味する正確な文字列。自動検出（ON、true、1）の場合は空のままにしてください。`,restControl:\"Control\",restOnUrl:\"Turn ON URL\",restOffUrl:\"Turn OFF URL\",restOnBody:\"ON Request Body\",restOffBody:\"OFF Request Body\",restMethod:\"HTTP Method\",restHeaders:\"Custom Headers (JSON)\",restStatusUrl:\"Status URL\",restStatusPath:\"State JSON Path\",restStatusOnValue:\"ON Value\",restPowerUrl:\"電力URL\",restPowerPath:\"Power JSON Path\",restPowerMultiplier:\"電力乗数\",restEnergyUrl:\"エネルギーURL\",restEnergyPath:\"Energy JSON Path\",restEnergyMultiplier:\"エネルギー乗数\",restUrlRequired:\"At least one URL (ON or OFF) is required for REST plugs\",restHeadersHint:'e.g. {\"Authorization\": \"Bearer your-token\"}',restBodyHint:'e.g. ON, {\"state\": \"on\"}',restStatusHint:\"URL to poll for current state\",restPathHint:\"e.g. state or data.power.status\",restPowerUrlHint:\"電力データ用の個別URL（空欄の場合はステータスURLを使用）\",restEnergyUrlHint:\"エネルギーデータ用の個別URL（空欄の場合はステータスURLを使用）\",restEnergyHint:\"各値は個別のURLを使用するか、ステータスURLにフォールバックできます。乗数で単位変換が可能です（例：WhからkWhへの変換は0.001）。\",testConnection:\"Test Connection\",connectionSuccess:\"Connection successful\",noSwitchesInSwitchbar:\"スイッチバーにスイッチがありません\",enableSwitchbarHint:\"設定 > スマートプラグで「スイッチバーに表示」を有効にしてください\"},notifications:{providerTypes:{callmebot:\"CallMeBot/WhatsApp\",ntfy:\"ntfy\",pushover:\"Pushover\",telegram:\"Telegram\",email:\"メール\",discord:\"Discord\",webhook:\"Webhook\",homeassistant:\"Home Assistant\"},providerDescriptions:{email:\"SMTPメール通知\",telegram:\"Telegramボット経由の通知\",discord:\"Webhook経由でDiscordチャンネルに送信\",ntfy:\"無料のセルフホスト可能なプッシュ通知\",pushover:\"シンプルで信頼性の高いプッシュ通知\",callmebot:\"CallMeBot経由の無料WhatsApp通知\",webhook:\"任意のURLへの汎用HTTP POST\",homeassistant:\"Home Assistantダッシュボードの永続通知\"},lastSuccess:\"最終: {{date}}\",error:\"エラー\",printer:\"プリンター:\",allPrinters:\"すべてのプリンター\",sendTestNotification:\"テスト通知を送信\",eventSettings:\"イベント設定\",enabled:\"有効\",sendFromProvider:\"このプロバイダーから通知を送信\",printEvents:\"印刷イベント\",printerStatus:\"プリンターステータス\",amsAlarms:\"AMSアラーム\",amsHtAlarms:\"AMS-HTアラーム\",printQueue:\"印刷キュー\",start:\"開始\",plateCheck:\"プレートチェック\",complete:\"完了\",failed:\"失敗\",stopped:\"停止\",progress:\"進捗\",offline:\"オフライン\",lowFilament:\"フィラメント残量低下\",maintenance:\"メンテナンス\",amsHumidity:\"AMS湿度\",amsTemp:\"AMS温度\",amsHtHumidity:\"AMS-HT湿度\",amsHtTemp:\"AMS-HT温度\",bedCooled:\"ベッド冷却済み\",firstLayer:\"第1層完了\",quiet:\"静音\",digest:\"ダイジェスト {{time}}\",printStarted:\"印刷開始\",plateNotEmpty:\"プレートが空でない\",plateNotEmptyDescription:\"印刷前にオブジェクトが検出されました\",printCompleted:\"印刷完了\",bedCooledLabel:\"ベッド冷却済み\",bedCooledDescription:\"印刷後にベッドがしきい値以下に冷却\",firstLayerCompleteLabel:\"第1層完了\",firstLayerCompleteDescription:\"第1層完了時にスナップショット付きで通知\",missingSpoolAssignmentLabel:\"スプール割り当て不足\",missingSpoolAssignmentDescription:\"印刷開始時に必要トレイへスプールが未割り当ての場合に通知\",printFailed:\"印刷失敗\",printStopped:\"印刷停止\",progressMilestones:\"進捗マイルストーン\",progressMilestonesDescription:\"25%、50%、75%で通知\",printerOffline:\"プリンターオフライン\",printerError:\"プリンターエラー\",lowFilamentLabel:\"フィラメント残量低下\",maintenanceDue:\"メンテナンス期限\",maintenanceDueDescription:\"メンテナンスが必要な場合に通知\",amsHumidityHigh:\"AMS湿度高\",amsHumidityHighDescription:\"通常AMSの湿度がしきい値を超過\",amsTemperatureHigh:\"AMS温度高\",amsTemperatureHighDescription:\"通常AMSの温度がしきい値を超過\",amsHtHumidityHigh:\"AMS-HT湿度高\",amsHtHumidityHighDescription:\"AMS-HTの湿度がしきい値を超過\",amsHtTemperatureHigh:\"AMS-HT温度高\",amsHtTemperatureHighDescription:\"AMS-HTの温度がしきい値を超過\",jobAdded:\"ジョブ追加\",jobAddedDescription:\"キューにジョブが追加されました\",jobAssigned:\"ジョブ割り当て\",jobAssignedDescription:\"モデルベースのジョブがプリンターに割り当てられました\",jobStarted:\"ジョブ開始\",jobStartedDescription:\"キュージョブの印刷が開始されました\",jobWaiting:\"ジョブ待機中\",jobWaitingDescription:\"フィラメントまたはプリンター待ちのジョブ\",jobSkipped:\"ジョブスキップ\",jobSkippedDescription:\"ジョブがスキップされました（前のジョブが失敗）\",jobFailed:\"ジョブ失敗\",jobFailedDescription:\"ジョブの開始に失敗しました\",queueComplete:\"キュー完了\",queueCompleteDescription:\"すべてのキュージョブが完了しました\",quietHours:\"静音時間\",noNotificationsDuring:\"この時間帯は通知を送信しません\",editProviderToChangeQuietHours:\"プロバイダーを編集して静音時間を変更\",dailyDigest:\"デイリーダイジェスト\",batchNotifications:\"通知をまとめて1日のサマリーとして送信\",sendAt:\"{{time}}に送信\",editProviderToChangeDigestTime:\"プロバイダーを編集してダイジェスト時刻を変更\",edit:\"編集\",deleteProvider:\"通知プロバイダーを削除\",deleteConfirm:'\"{{name}}\"を削除してもよろしいですか？この操作は取り消せません。',delete:\"削除\",addTitle:\"通知プロバイダーを追加\",editTitle:\"通知プロバイダーを編集\",nameLabel:\"名前 *\",namePlaceholder:\"マイ通知\",providerTypeLabel:\"プロバイダータイプ *\",configuration:\"設定\",testConfiguration:\"設定をテスト\",printerFilter:\"プリンターフィルター\",onlyFromPrinter:\"このプリンターからのイベントのみ通知を送信\",quietHoursDnd:\"静音時間（おやすみモード）\",quietStart:\"開始\",quietEnd:\"終了\",dailyDigestLabel:\"デイリーダイジェスト\",sendDigestAt:\"ダイジェスト送信時刻\",digestCollected:\"イベントが収集され、この時刻にまとめて送信されます\",notificationEvents:\"通知イベント\",progressPercent:\"（25%、50%、75%）\",bedCooledAfterPrint:\"（印刷完了後）\",cancel:\"キャンセル\",save:\"保存\",add:\"追加\",nameRequired:\"名前は必須です\",fieldRequired:\"{{field}}は必須です\",phoneNumber:\"電話番号\",apiKey:\"APIキー\",serverUrl:\"サーバーURL\",topic:\"トピック\",authToken:\"認証トークン\",userKey:\"ユーザーキー\",appToken:\"アプリトークン\",priority:\"優先度\",botToken:\"ボットトークン\",chatId:\"チャットID\",smtpServer:\"SMTPサーバー\",smtpPort:\"SMTPポート\",security:\"セキュリティ\",authentication:\"認証\",username:\"ユーザー名\",password:\"パスワード\",fromEmail:\"送信元メール\",toEmail:\"宛先メール\",webhookUrl:\"Webhook URL\",payloadFormat:\"ペイロード形式\",authorization:\"認可\",titleFieldName:\"タイトルフィールド名\",messageFieldName:\"メッセージフィールド名\",editTemplate:\"テンプレートを編集: {{name}}\",titleLabel:\"タイトル\",bodyLabel:\"本文\",titlePlaceholder:\"通知タイトル...\",bodyPlaceholder:\"通知本文...\",availableVariables:\"利用可能な変数\",clickToInsert:\"クリックして本文のカーソル位置に挿入\",livePreview:\"ライブプレビュー\",hide:\"非表示\",show:\"表示\",loadingPreview:\"プレビューを読み込み中...\",enterTemplateContent:\"テンプレートの内容を入力するとプレビューが表示されます\",titlePreview:\"タイトル:\",bodyPreview:\"本文:\",resetToDefault:\"デフォルトにリセット\",titleRequired:\"タイトルは必須です\",bodyRequired:\"本文は必須です\",notificationLog:\"通知ログ\",showFailedOnly:\"失敗のみ\",last24Hours:\"過去24時間\",last7Days:\"過去7日間\",last30Days:\"過去30日間\",last90Days:\"過去90日間\",justNow:\"たった今\",noFailedNotifications:\"失敗した通知はありません\",noNotificationsLogged:\"記録された通知はありません\",unknownProvider:\"不明なプロバイダー\",logTitle:\"タイトル\",logMessage:\"メッセージ\",logError:\"エラー\",logProvider:\"プロバイダー: {{type}}\",logTime:\"時刻: {{time}}\",refresh:\"更新\",clearOld:\"古いものを削除\",statsSummary:\"過去{{days}}日間:\",statsNotifications:\"通知\",statsSent:\"{{count}}件送信\",statsFailed:\"{{count}}件失敗\",eventTypes:{print_start:\"印刷開始\",print_complete:\"印刷完了\",print_failed:\"印刷失敗\",print_stopped:\"印刷停止\",print_progress:\"進捗\",printer_offline:\"プリンターオフライン\",printer_error:\"プリンターエラー\",filament_low:\"フィラメント残量低下\",maintenance_due:\"メンテナンス期限\",test:\"テスト\"},userEmail:{title:\"通知\",emailNotifications:\"メール通知\",emailNotificationsDesc:\"自分の印刷ジョブに対してメール通知を受け取ります。メールは高度な認証で設定されたSMTP設定を使用して送信されます。\",sendingTo:\"通知の送信先\",noEmailWarning:\"アカウントにメールアドレスが設定されていません。管理者に連絡して追加してもらってください。\",printJobNotifications:\"印刷ジョブ通知\",printJobNotificationsDesc:\"送信した印刷ジョブのどのイベントでメール通知を送るかを選択します。\",printJobStarts:\"印刷ジョブ開始\",printJobStartsDesc:\"印刷ジョブが開始されたときに通知を受け取る。\",printJobFinishes:\"印刷ジョブ完了\",printJobFinishesDesc:\"印刷ジョブが正常に完了したときに通知を受け取る。\",printErrors:\"印刷エラー\",printErrorsDesc:\"印刷ジョブが失敗またはエラーが発生したときに通知を受け取る。\",printJobStops:\"印刷ジョブ停止\",printJobStopsDesc:\"印刷ジョブがキャンセルまたは停止されたときに通知を受け取る。\",saveSuccess:\"通知設定を保存しました。\",saveError:\"通知設定の保存に失敗しました。\"}},richTextEditor:{bold:\"太字\",italic:\"斜体\",underline:\"下線\",bulletList:\"箇条書きリスト\",numberedList:\"番号付きリスト\",alignLeft:\"左揃え\",alignCenter:\"中央揃え\",alignRight:\"右揃え\",addLink:\"リンクを追加\",removeLink:\"リンクを削除\"},externalLinks:{noLinksConfigured:\"外部リンクが設定されていません\",deleteLink:\"リンクを削除\",removeCustomIcon:\"カスタムアイコンを削除\",openInNewTab:\"新しいタブで開く\",placeholders:{linkName:\"マイリンク\"}},keyboardShortcuts:{title:\"キーボードショートカット\",navigation:\"ナビゲーション\",archivesSection:\"アーカイブ\",kProfilesSection:\"Kプロファイル\",generalSection:\"全般\",shortcuts:{goToPrinters:\"プリンターへ移動\",goToArchives:\"アーカイブへ移動\",goToQueue:\"キューへ移動\",goToStats:\"統計へ移動\",goToProfiles:\"クラウドプロファイルへ移動\",goToSettings:\"設定へ移動\",focusSearch:\"検索にフォーカス\",openUploadModal:\"アップロードモーダルを開く\",clearSelection:\"選択をクリア / 入力をぼかす\",contextMenu:\"カードのコンテキストメニュー\",refreshProfiles:\"プロファイルを更新\",newProfile:\"新しいプロファイル\",exitSelectionMode:\"選択モードを終了\",showHelp:\"このヘルプを表示\"},footer:\"Escキーを押すか外側をクリックして閉じます\"},notificationLog:{title:\"通知ログ\",events:{printStarted:\"印刷開始\",printComplete:\"印刷完了\",printFailed:\"印刷失敗\",printStopped:\"印刷停止\",progress:\"進捗\",printerOffline:\"プリンターオフライン\",printerError:\"プリンターエラー\",lowFilament:\"フィラメント残量低下\",maintenanceDue:\"メンテナンス期限\",test:\"テスト\"},timeAgo:{justNow:\"たった今\",minutesAgo:\"{{minutes}}分前\",hoursAgo:\"{{hours}}時間前\"}},restoreBackup:{title:\"バックアップを復元\",restoring:\"復元中...\",restoreComplete:\"復元完了\",restoreFailed:\"復元失敗\",importSettings:\"バックアップファイルから設定をインポート\",pleaseWait:\"データの復元中です。しばらくお待ちください\",clickToSelect:\"クリックしてバックアップファイルを選択（.jsonまたは.zip）\",howDuplicateHandling:\"重複の処理方法:\",categories:{printers:\"プリンター\",smartPlugs:\"スマートプラグ\",notificationProviders:\"通知プロバイダー\",filaments:\"フィラメント\",archives:\"アーカイブ\",pendingUploads:\"保留中のアップロード\",settingsTemplates:\"設定とテンプレート\"},matchingInfo:{printers:\"シリアル番号で照合\",smartPlugs:\"IPアドレスで照合\",notificationProviders:\"名前で照合\",filaments:\"名前+タイプ+ブランドで照合\",archives:\"コンテンツハッシュで照合\",pendingUploads:\"ファイル名で照合\",settingsTemplates:\"常に上書き\"},replaceExisting:\"既存データを置き換え\",keepExisting:\"既存データを保持\",replaceDescription:\"既に存在するアイテムをバックアップデータで上書き\",keepDescription:\"まだ存在しないアイテムのみを復元\",caution:\"注意:\",cautionText:\"上書きすると現在の構成がバックアップデータに置き換えられます。セキュリティ上の理由から、プリンターのアクセスコードは上書きされません。\",itemsRestored:\"復元されたアイテム\",itemsSkipped:\"スキップされたアイテム\",restored:\"復元済み\",skipped:\"スキップ（既に存在）\",filesLabel:\"ファイル（3MF、サムネイルなど）\",newApiKeysGenerated:\"新しいAPIキーが生成されました\",newApiKeysWarning:\"これらのキーは一度だけ表示されます。今すぐコピーしてください！\",processingBackup:\"バックアップファイルを処理中...\",noDataFound:\"バックアップファイルに復元するデータが見つかりませんでした。\",failedToRestore:\"バックアップの復元に失敗しました。ファイル形式を確認してください。\"},backupExport:{title:\"バックアップをエクスポート\",selectData:\"含めるデータを選択\",selectAll:\"すべて選択\",selectNone:\"なし\",categoryDescriptions:{settings:\"言語、テーマ、更新設定\",notifications:\"ntfy、Pushover、Discordなど\",templates:\"カスタムメッセージテンプレート\",smartPlugs:\"Tasmotaプラグ設定\",externalLinks:\"サイドバーの外部サービスへのリンク\",printers:\"プリンター情報（アクセスコード除外）\",plateDetection:\"空プレート参照画像\",filaments:\"フィラメントの種類とコスト\",maintenance:\"カスタムメンテナンススケジュール\",archives:\"すべての印刷データ+ファイル（3MF、サムネイル、写真）\",projects:\"プロジェクト、BOMアイテム、添付ファイル\",pendingUploads:\"仮想プリンターアップロード待機中\",apiKeys:\"Webhook APIキー（インポート時に新しいキーが生成されます）\"},requiresPrinters:\"プリンターを選択する必要があります\",zipFileWarning:\"ZIPファイルが作成されます。\",zipFileDescription:\"すべての3MFファイル、サムネイル、タイムラプス、写真が含まれます。これには時間がかかり、大きなファイルになる可能性があります。\",includeAccessCodes:\"アクセスコードを含める\",includeAccessCodesDescription:\"別のマシンへの転送用\",includeAccessCodesWarning:\"アクセスコードはプレーンテキストで含まれます。このバックアップファイルを安全に保管してください！\",categoriesSelected:\"{{selectedCount}}カテゴリー選択済み\"},pendingUploads:{placeholders:{notes:\"この印刷に関するメモを追加...\"},discardUpload:\"アップロードを破棄\",archiveAllUploads:\"すべてのアップロードをアーカイブ\",discardAllUploads:\"すべてのアップロードを破棄\",archive:\"アーカイブ\",timeAgo:{justNow:\"たった今\",minutesAgo:\"{{minutes}}分前\",hoursAgo:\"{{hours}}時間前\",daysAgo:\"{{days}}日前\"}},apiBrowser:{placeholders:{requestBody:\"JSONリクエストボディ...\",searchEndpoints:\"エンドポイントを検索...\"}},configureAmsSlot:{title:\"AMSスロットの設定\",slotConfigured:\"スロットを設定しました！\",configuringSlot:\"スロットを設定中：\",slotLabel:\"{{ams}} スロット {{slot}}\",searchPresets:\"プリセットを検索...\",colorPlaceholder:\"色名またはHex（例: 茶色、FF8800）\",clearCustomColor:\"カスタム色をクリア\",noCloudPresets:\"クラウドプリセットがありません。Bambu Cloudにログインして同期してください。\",noPresetsAvailable:\"プリセットがありません。Bambu Cloudにログインするか、ローカルプロファイルをインポートしてください。\",noMatchingPresets:\"一致するプリセットが見つかりません。\",custom:\"カスタム\",builtin:\"内蔵\",settingsSentToPrinter:\"設定をプリンターに送信しました\",filamentProfile:\"フィラメントプロファイル\",kProfileLabel:\"Kプロファイル（Pressure Advance）\",filteringFor:\"フィルター中: {{material}}\",noKProfile:\"Kプロファイルなし（デフォルト0.020を使用）\",noMatchingKProfiles:\"一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。\",selectFilamentFirst:\"まずフィラメントプロファイルを選択してください\",kFromCalibration:\"K={{value}}（プリンターキャリブレーションから）\",customColorLabel:\"カスタム色（オプション）\",presetColors:\"{{name}}の色：\",showLessColors:\"色を減らす\",showMoreColors:\"色をもっと表示\",clear:\"クリア\",hexLabel:\"Hex: #{{hex}}\",resetting:\"リセット中...\",resetSlot:\"スロットをリセット\",cancel:\"キャンセル\",configuring:\"設定中...\",configureSlot:\"スロットを設定\"},githubBackup:{title:\"GitHubバックアップ\",history:\"履歴\",downloadBackup:\"バックアップをダウンロード\",restoreBackup:\"バックアップを復元\",noBackupsYet:\"バックアップはまだありません\"},emailSettings:{placeholders:{fromName:\"BamBuddy\"}},tagManagement:{searchTags:\"タグを検索...\",renameTag:\"タグ名を変更\",deleteTag:\"タグを削除\"},notificationTemplates:{placeholders:{title:\"通知タイトル...\",body:\"通知本文...\"}},batchTag:{placeholders:{newTag:\"新しいタグを入力...\"}},photoGallery:{deletePhoto:\"写真を削除\"},filamentHoverCard:{copySpoolUuid:\"スプールUUIDをコピー\"},kProfilesView:{hasNote:\"メモあり\",copyProfile:\"プロファイルをコピー\"},layout:{openMenu:\"メニューを開く\",noPermissionSystemInfo:\"システム情報を表示する権限がありません\"},dashboard:{dragToReorder:\"ドラッグして並べ替え\",hideWidget:\"ウィジェットを非表示\"},notificationProviderCard:{deleteNotificationProvider:\"通知プロバイダーを削除\"},fileManagerModal:{closeFileManager:\"ファイルマネージャーを閉じる\",sortFiles:\"ファイルを並べ替え\",goToParentFolder:\"親フォルダーへ移動\",threeView:\"3Dビュー\"},embeddedCameraViewer:{refreshStream:\"ストリームを更新\",close:\"閉じる\",zoomOut:\"ズームアウト\",resetZoom:\"ズームをリセット\",zoomIn:\"ズームイン\",dragToResize:\"ドラッグしてサイズ変更\"},timelapseViewer:{skipBack5s:\"5秒戻る\",skipForward5s:\"5秒進む\"},notificationProviders:{descriptions:{email:\"SMTP電子メール通知\",telegram:\"Telegramボット経由の通知\",discord:\"Webhookを介してDiscordチャンネルに送信\",ntfy:\"無料でセルフホスト可能なプッシュ通知\",pushover:\"シンプルで信頼性の高いプッシュ通知\",callmebot:\"CallMeBot経由の無料WhatsApp通知\",webhook:\"任意のURLへのジェネリックHTTP POST\"}},logViewer:{searchPlaceholder:\"メッセージまたはロガー名を検索...\",noLogEntries:\"ログエントリが見つかりません\"},switchbarPopover:{noSwitchesInSwitchbar:\"スイッチバーにスイッチがありません\"},projectPageModal:{placeholders:{title:\"タイトル\",designer:\"デザイナー\",license:\"ライセンス\",description:\"説明を入力...\",profileTitle:\"プロファイルタイトル\",profileDescription:\"プロファイルの説明...\"}},spoolmanSettings:{},time:{unknown:\"-\",waiting:\"待機中\",justNow:\"たった今\",now:\"今すぐ\",minsAgo:\"{{count}}分前\",inMins:\"あと{{count}}分\",hoursAgo:\"{{count}}時間前\",inHours:\"あと{{count}}時間\",daysAgo:\"{{count}}日前\",inDays:\"あと{{count}}日\"},spoolbuddy:{nav:{dashboard:\"ダッシュボード\",ams:\"AMS\",inventory:\"インベントリ\",writeTag:\"書込み\",settings:\"設定\"},status:{nfcReady:\"NFC準備完了\",nfcOff:\"NFCオフ\",offline:\"オフライン\",online:\"オンライン\",noPrinters:\"プリンターなし\",deviceOffline:\"デバイスオフライン\",waitingConnection:\"デバイス接続を待っています...\",systemReady:\"システム準備完了\",status:\"ステータス\"},dashboard:{readyToScan:\"スキャン準備完了\",idleMessage:\"スプールを計量台に置いて識別します\",nfcHint:\"NFCタグは自動的に読み取られます\",device:\"デバイス\",syncWeight:\"重量同期\",weightSynced:\"同期完了！\",unknownTag:\"不明なタグ\",newTag:\"新しいタグを検出\",onScale:\"計量中\",linkSpool:\"スプールにリンク\",linkTagTitle:\"タグをスプールにリンク\",linkTag:\"タグをリンク\",selectSpool:\"このタグにリンクするスプールを選択:\",noUntagged:\"タグなしのスプールが見つかりません\",tagDetected:\"タグ検出\",noTag:\"タグなし\",tagId:\"タグ\",grossWeight:\"総重量\",spoolSize:\"スプールサイズ\",close:\"閉じる\",currentSpool:\"現在のスプール\"},modal:{spoolDetected:\"スプール検出\",assignToAms:\"AMSに割り当て\",syncWeight:\"重量同期\",weightSynced:\"同期完了！\",syncing:\"同期中...\",newTagDetected:\"新しいタグを検出\",addToInventory:\"インベントリに追加\",assignToAmsTitle:\"AMSに割り当て\",selectSlot:\"スロットを選択\",assign:\"割り当て\",assigning:\"割り当て中...\",assignSuccess:\"割り当て完了！\",assignError:\"スプールの割り当てに失敗しました。再試行してください。\",noPrinterSelected:\"プリンターを選択...\",noAmsDetected:\"このプリンターにAMSが検出されません\",slot:\"スロット\"},weight:{noReading:\"読み取りなし\",stable:\"安定\",measuring:\"計測中...\",tare:\"風袋引き\",calibrate:\"キャリブレーション\"},spool:{remaining:\"残量\",material:\"素材\",brand:\"ブランド\",color:\"色\",coreWeight:\"コア\",labelWeight:\"ラベル\",scaleWeight:\"計量\",netWeight:\"正味\",lastUsed:\"最終使用\"},ams:{noData:\"AMSが検出されません\",connectAms:\"AMSを接続してスロットを表示\",noPrinter:\"プリンター未選択\",selectPrinter:\"上部バーからプリンターを選択\",printerDisconnected:\"プリンター切断\",humidity:\"湿度\",level:\"レベル\",active:\"アクティブ\",slot:\"スロット\",empty:\"空\"},inventory:{search:\"スプールを検索...\",empty:\"インベントリにスプールがありません\",noResults:\"一致するスプールがありません\",spools:\"スプール\",addSpool:\"スプール追加\"},settings:{tabDevice:\"デバイス\",tabDisplay:\"ディスプレイ\",tabScale:\"計量\",tabUpdates:\"アップデート\",nfcReader:\"NFCリーダー\",type:\"タイプ\",connection:\"接続\",notConnected:\"N/A\",deviceInfo:\"デバイス情報\",hostname:\"ホスト\",uptime:\"稼働時間\",brightness:\"明るさ\",saved:\"保存済み\",noBacklight:\"DSIバックライトが検出されませんでした。明るさ制御にはDSIディスプレイが必要です。\",screenBlank:\"画面オフタイムアウト\",screenBlankDesc:\"操作がないと画面がオフになります。タッチで復帰。\",displayNote:\"明るさはソフトウェアフィルターとして適用されます。\",scaleCalibration:\"計量キャリブレーション\",currentWeight:\"現在の重量\",tareOffset:\"風袋\",calFactor:\"係数\",knownWeight:\"既知の重量\",calStep1:\"計量台からすべてのアイテムを取り除き、ゼロ設定を押してください。\",calStep2:\"既知の重量を計量台に置いてください。\",setZero:\"ゼロ設定\",calibrateNow:\"キャリブレーション\",calibrated:\"キャリブレーション済み\",tareSet:\"風袋コマンドを送信しました。デバイスを待っています...\",tareFailed:\"風袋コマンドの送信に失敗しました\",zeroSet:\"ゼロ点を設定しました。既知の重量を計量台に置いてください。\",calibrationDone:\"キャリブレーション完了！\",calibrationFailed:\"キャリブレーションに失敗しました\",lastCalibrated:\"最終キャリブレーション\",stable:\"安定\",settling:\"安定化中...\",firmware:\"ファームウェア\",scale:\"計量\",noDevice:\"SpoolBuddyデバイスが見つかりません\",daemonVersion:\"デーモンバージョン\",currentVersion:\"現在\",versionPending:\"デーモンを待っています...\",checking:\"確認中...\",checkUpdates:\"アップデートを確認\",updateAvailable:\"アップデートあり\",updateInstructions:\"SSH経由で更新：SpoolBuddyインストールスクリプトを実行してください。\",upToDate:\"最新です\",includeBeta:\"ベータ版を含む\"},writeTag:{tabExisting:\"既存のスプール\",tabNew:\"新規スプール\",tabReplace:\"タグ交換\",searchPlaceholder:\"素材、色、ブランドで検索...\",noUntaggedSpools:\"タグなしのスプールがありません\",noTaggedSpools:\"タグ付きのスプールがありません\",selectSpool:\"スプールを選択し、NTAGをリーダーに置いてください\",placeTag:\"NTAGをリーダーに置いてください\",tagReady:\"タグ検出 — 書込み準備完了\",writeTag:\"タグ書込み\",replaceTag:\"タグ交換\",writing:\"タグ書込み中...\",waiting:\"SpoolBuddyを待機中...\",writeSuccess:\"タグの書込みが完了しました！\",writeFailed:\"書込み失敗\",queueFailed:\"書込みコマンドのキューに失敗しました\",tryAgain:\"再試行\",cancel:\"キャンセル\",replaceWarning:\"古いタグのリンクが解除され、新しいタグに置き換わります。\",deviceOffline:\"SpoolBuddyはオフラインです\",material:\"素材\",colorName:\"色名\",color:\"色\",brand:\"ブランド\",weight:\"重量 (g)\",createSpool:\"スプール作成\",creating:\"作成中...\",spoolCreated:\"スプール作成完了！書込み準備ができました。\",createFailed:\"スプールの作成に失敗しました\"},quickMenu:{printerPower:\"プリンター電源\",systemControls:\"システム\",restartDaemon:\"デーモン再起動\",restartBrowser:\"ブラウザ再起動\",reboot:\"再起動\",shutdown:\"シャットダウン\",swipeToClose:\"下にスワイプして閉じる\",confirmTitle:\"確認\",confirmShutdown:\"SpoolBuddyをシャットダウンしますか？再起動するには物理的なアクセスが必要です。\",confirmReboot:\"SpoolBuddyを再起動しますか？\",confirmRestartDaemon:\"SpoolBuddyデーモンを再起動しますか？NFCとスケールが一時的に使用できなくなります。\",confirmRestartBrowser:\"キオスクブラウザを再起動しますか？画面が一時的に暗くなります。\",confirm:\"確認\",confirmPlugOn:\"{{name}}をオンにしますか？\",confirmPlugOff:\"{{name}}をオフにしますか？\",turnOn:\"オン\",turnOff:\"オフ\"}},bugReport:{title:\"バグを報告\",description:\"説明\",descriptionPlaceholder:\"何が問題でしたか？問題を説明してください...\",email:\"メールアドレス（任意）\",emailPlaceholder:\"your@email.com\",emailPrivacy:\"入力された場合、メールアドレスはGitHub Issueの折りたたみセクションに含まれ、メンテナーがフォローアップできるようになります。\",screenshot:\"スクリーンショット\",uploadOrPaste:\"画像をアップロード、貼り付け、またはドラッグ\",dataCollectedSummary:\"レポートに含まれるデータは？\",dataIncluded:\"含まれるもの:\",dataIncludedList:\"アプリバージョン、OS、アーキテクチャ、Pythonバージョン、データベース統計（件数のみ）、プリンターモデル、ノズル数、ファームウェアバージョン、接続状態、統合状態（Spoolman、MQTT、HA）、非機密設定、ネットワークインターフェース数、Docker詳細、依存関係バージョン。\",dataNeverIncluded:\"含まれないもの:\",dataNeverIncludedList:\"プリンター名、シリアル番号、アクセスコード、パスワード、IPアドレス、メールアドレス、APIキー、トークン、Webhook URL、ホスト名、ユーザー名。\",submit:\"送信\",startLogging:\"デバッグログ開始\",stepEnableLogging:\"デバッグログ有効\",stepReproduce:\"問題を再現してください\",stepStopLogging:\"停止してレポート送信\",stopAndSubmit:\"停止して送信\",maxDuration:\"{{minutes}}分後に自動停止\",stoppingLogs:\"ログ収集・送信中...\",submitting:\"バグレポートを送信中...\",submitSuccess:\"バグレポートが正常に送信されました！\",submitFailed:\"バグレポートの送信に失敗しました\",thankYou:\"ありがとうございます！\",submitted:\"バグレポートが送信されました。\",viewIssue:\"Issueを表示\",unexpectedError:\"予期しないエラーが発生しました\"},failureDetection:{title:\"AI 失敗検出\",description:\"セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。\",mlUrl:\"Obico ML API の URL\",mlUrlHint:\"セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。\",test:\"テスト\",testSuccess:\"ML API に接続でき、正常です。\",testFailed:\"ML API に接続できませんでした。\",sensitivity:\"感度\",sensitivityLow:\"低(誤検出が少ない)\",sensitivityMedium:\"中(バランス型)\",sensitivityHigh:\"高(早期検出、誤検出が増加)\",sensitivityHint:\"警告と失敗をトリガーする信頼度のしきい値を調整します。\",action:\"失敗検出時の動作\",actionNotify:\"通知のみ\",actionPause:\"印刷を一時停止\",actionPauseOff:\"一時停止して電源を切る\",pollInterval:\"ポーリング間隔(秒)\",pollIntervalHint:\"印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。\",externalUrlMissing:\"External URL is not set.\",externalUrlHint:\"The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.\",perPrinterTitle:\"監視対象プリンター\",perPrinterHint:\"検出サービスが監視するプリンターを選択します。\",monitorAll:\"接続されているすべてのプリンターを監視\",statusTitle:\"ステータス\",serviceRunning:\"サービス稼働中\",thresholds:\"低 / 高しきい値\",activePrinters:\"アクティブな印刷\",noActivePrints:\"現在、実行中の印刷はありません。\",historyTitle:\"最近の検出\",noHistory:\"まだ検出はありません。\"}},Kue={nav:{printers:\"Stampanti\",archives:\"Archivi\",queue:\"Coda\",stats:\"Statistiche\",profiles:\"Profili\",maintenance:\"Manutenzione\",projects:\"Progetti\",inventory:\"Filamento\",files:\"File\",notifications:\"Notifiche\",settings:\"Impostazioni\",system:\"Sistema\",collapseSidebar:\"Comprimi barra laterale\",expandSidebar:\"Espandi barra laterale\",update:\"Aggiorna\",updateAvailable:\"Aggiornamento disponibile: v{{version}}\",updateAvailableBanner:\"Versione {{version}} disponibile!\",viewUpdate:\"Vedi aggiornamento\",viewOnGithub:\"Vedi su GitHub\",keyboardShortcuts:\"Scorciatoie da tastiera (?)\",switchToLight:\"Passa a tema chiaro\",switchToDark:\"Passa a tema scuro\",smartSwitches:\"Interruttori Smart\",logout:\"Esci\"},common:{save:\"Salva\",saving:\"Salvataggio...\",cancel:\"Annulla\",delete:\"Elimina\",edit:\"Modifica\",add:\"Aggiungi\",close:\"Chiudi\",confirm:\"Conferma\",loading:\"Caricamento...\",error:\"Errore\",success:\"Successo\",warning:\"Avviso\",enabled:\"Abilitato\",disabled:\"Disabilitato\",yes:\"Si\",no:\"No\",on:\"On\",off:\"Off\",all:\"Tutti\",none:\"Nessuno\",search:\"Cerca\",filter:\"Filtro\",sort:\"Ordina\",refresh:\"Aggiorna\",download:\"Scarica\",upload:\"Carica\",uploading:\"Caricamento...\",uploadFailed:\"Caricamento fallito\",actions:\"Azioni\",status:\"Stato\",name:\"Nome\",description:\"Descrizione\",date:\"Data\",time:\"Ora\",hours:\"ore\",minutes:\"minuti\",seconds:\"secondi\",days:\"giorni\",enable:\"Abilita\",disable:\"Disabilita\",permissions:\"Permessi\",noPrinters:\"Nessuna stampante configurata\",noData:\"Nessun dato disponibile\",linkNotFound:\"Link non trovato\",required:\"Obbligatorio\",optional:\"Opzionale\",dismiss:\"Chiudi\",apply:\"Applica\",reset:\"Reimposta\",export:\"Esporta\",import:\"Importa\",clear:\"Pulisci\",selectAll:\"Seleziona tutto\",deselectAll:\"Deseleziona tutto\",noChange:\"— Nessun cambio —\",unchanged:\"Invariato\",unassigned:\"Non assegnato\",unknown:\"Sconosciuto\",unknownError:\"Errore sconosciuto\",today:\"Oggi\",tomorrow:\"Domani\",asap:\"ASAP\",overdue:\"Scaduto\",now:\"Ora\",collapse:\"Comprimi\",expand:\"Espandi\",viewArchive:\"Vedi archivio\",viewInFileManager:\"Vedi nel Gestore file\",addedBy:\"Aggiunto da {{username}}\",prints:\"stampe\",more:\"+{{count}} altre\",ascending:\"Crescente\",descending:\"Decrescente\",back:\"Indietro\",copy:\"Copia\",copied:\"Copiato!\",printer:\"Stampante\",remove:\"Rimuovi\",type:\"Tipo\",print:\"Stampa\",rename:\"Rinomina\",move:\"Sposta\",create:\"Crea\",duplicate:\"Duplica\",left:\"Sinistra\",right:\"Destra\"},printers:{title:\"Stampanti\",addPrinter:\"Aggiungi Stampante\",editPrinter:\"Modifica Stampante\",deletePrinter:\"Elimina Stampante\",printerName:\"Nome Stampante\",serialNumber:\"Numero Seriale\",ipAddress:\"Indirizzo IP\",accessCode:\"Codice di Accesso\",model:\"Modello\",nozzleCount:\"Numero Ugelli\",autoArchive:\"Auto Archiviazione\",status:{available:\"Disponibile\",idle:\"Inattiva\",printing:\"In stampa\",paused:\"In pausa\",offline:\"Offline\",problem:\"Problema\",error:\"Errore\",finished:\"Finita\",unknown:\"Sconosciuto\"},temperatures:{nozzle:\"Ugello\",bed:\"Piatto\",chamber:\"Camera\"},progress:\"{{percent}}% completato\",timeRemaining:\"{{time}} rimanente\",deleteConfirm:'Sei sicuro di eliminare \"{{name}}\"?',maintenanceOk:\"Manutenzione OK\",maintenanceWarning:\"{{count}} avviso\",maintenanceWarning_plural:\"{{count}} avvisi\",maintenanceDue:\"{{count}} in scadenza\",maintenanceDue_plural:\"{{count}} in scadenza\",sort:{name:\"Nome\",status:\"Stato\",model:\"Modello\",location:\"Posizione\",ascending:\"Ordina crescente\",descending:\"Ordina decrescente\"},cardSize:{small:\"Schede piccole\",medium:\"Schede medie\",large:\"Schede grandi\",extraLarge:\"Schede extra grandi\"},hideOffline:\"Nascondi offline\",nextAvailable:\"Prossima disponibile\",powerOn:\"Accendi\",offlinePrintersWithPlugs:\"Stampanti offline con smart plug\",noPrintersConfigured:\"Nessuna stampante configurata\",search:\"Cerca stampanti...\",noSearchResults:\"Nessuna stampante corrisponde alla tua ricerca o ai tuoi filtri\",filter:{allStatuses:\"Tutti gli stati\",allLocations:\"Tutti i luoghi\"},readyToPrint:\"Pronta a stampare\",external:\"Esterna\",extL:\"Ext-L\",extR:\"Ext-R\",deleteArchives:\"Elimina archivi stampa\",noLabel:\"Nessuna etichetta\",printPreview:\"Anteprima stampa\",width:\"Larghezza\",height:\"Altezza\",noObjectsFound:\"Nessun oggetto trovato\",objectsLoadedOnPrintStart:\"Gli oggetti sono caricati quando inizia una stampa\",willBeSkipped:\"Verra saltato\",name:\"Nome\",serialCannotBeChanged:\"Il numero seriale non può essere cambiato\",locationHelp:\"Usato per raggruppare stampanti e filtrare i lavori in coda\",wifiSignal:{veryWeak:\"Molto debole\",weak:\"Debole\",fair:\"Discreto\",good:\"Buono\",excellent:\"Eccellente\"},maintenanceUpToDate:\"Tutta la manutenzione aggiornata - Clicca per vedere\",chamberLightOn:\"Accendi luce camera\",chamberLightOff:\"Spegni luce camera\",files:\"File\",browseFiles:\"Sfoglia file stampante\",autoOffAfterPrint:\"Spegnimento automatico dopo stampa\",autoOffExecuted:\"Spegnimento automatico eseguito - accendi la stampante per reimpostare\",hmsErrors:\"Errori HMS\",viewHmsErrors:\"Vedi {{count}} errore(i) HMS\",resume:\"Riprendi\",pause:\"Pausa\",stop:\"Ferma\",camera:\"Camera\",skipObject:\"Salta Oggetto\",reconnect:\"Riconnetti\",forceRefresh:\"Forza aggiornamento\",forceRefreshSuccess:\"Aggiornamento richiesto\",mqttDebug:\"Debug MQTT\",printerInformation:\"Informazioni stampante\",copyToClipboard:\"Copia\",copied:\"Copiato!\",state:\"Stato\",wifiSignalLabel:\"Segnale WiFi\",developerMode:\"Modalità sviluppatore\",enabled:\"Attivato\",disabled:\"Disattivato\",addedOn:\"Aggiunta il\",sdCard:\"Scheda SD\",inserted:\"Inserita\",notInserted:\"Non inserita\",totalPrintHours:\"Ore di stampa\",activeNozzle:\"Attivo: ugello {{nozzle}}\",nozzleRack:\"Rack Ugelli\",nozzleDocked:\"Agganciato\",nozzleMounted:\"Montato\",nozzleActive:\"Attivo\",nozzleIdle:\"Inattivo\",nozzleDiameter:\"Diametro\",nozzleType:\"Tipo\",nozzleStatus:\"Stato\",nozzleFilament:\"Filamento\",nozzleWear:\"Usura\",nozzleMaxTemp:\"Temp Max\",nozzleSerial:\"Seriale\",nozzleHardenedSteel:\"Acciaio Temprato\",nozzleStainlessSteel:\"Acciaio Inox\",nozzleTungstenCarbide:\"Carburo di Tungsteno\",nozzleFlow:\"Flusso\",nozzleHighFlow:\"Alto Flusso\",nozzleStandardFlow:\"Standard\",firmwareUpdate:\"Aggiornamento Firmware\",firmwareInstructions:\"Sul touchscreen della stampante, vai a\",firmwareNav:\"Vai a\",settings:\"Impostazioni\",firmware:\"Firmware\",discoverPrinters:\"Trova Stampanti\",searching:\"Ricerca...\",manualEntry:\"Inserimento manuale\",addFromCloud:\"Aggiungi da Cloud\",toast:{printerDeleted:\"Stampante eliminata\",missingSpoolAssignment:\"Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}\",printerAdded:\"Stampante aggiunta\",printerUpdated:\"Stampante aggiornata\",failedToDelete:\"Impossibile eliminare stampante\",failedToAdd:\"Impossibile aggiungere stampante\",failedToUpdate:\"Impossibile aggiornare stampante\",commandSent:\"Comando inviato\",failedToSendCommand:\"Impossibile inviare comando\",turnedOn:\"{{name}} accesa\",failedToPowerOn:\"Impossibile accendere {{name}}\",scriptTriggered:\"Script avviato\",printStopped:\"Stampa fermata\",printPaused:\"Stampa in pausa\",printResumed:\"Stampa ripresa\",referenceDeleted:\"Riferimento eliminato\",detectionAreaSaved:\"Area rilevamento salvata\",failedToRunScript:\"Impossibile eseguire script\",failedToStopPrint:\"Impossibile fermare stampa\",failedToPausePrint:\"Impossibile mettere in pausa stampa\",failedToResumePrint:\"Impossibile riprendere stampa\",failedToControlChamberLight:\"Impossibile controllare luce camera\",failedToSetSpeed:\"Impossibile impostare la velocità di stampa\",failedToUpdateSetting:\"Impossibile aggiornare impostazione\",failedToSkipObjects:\"Impossibile saltare oggetti\",failedToRereadRfid:\"Impossibile rileggere RFID\",failedToCheckPlate:\"Impossibile controllare piatto\",failedToUpdateLabel:\"Impossibile aggiornare etichetta\",failedToDeleteReference:\"Impossibile eliminare riferimento\",failedToSaveDetectionArea:\"Impossibile salvare area rilevamento\",plateCheckEnabled:\"Controllo piatto abilitato\",plateCheckDisabled:\"Controllo piatto disabilitato\",calibrationSaved:\"Calibrazione salvata!\",calibrationFailed:\"Calibrazione non riuscita\",rfidRereadInitiated:\"Rilettura RFID avviata\"},connection:{connected:\"Connesso\",offline:\"Offline\"},plateStatus:{markCleared:\"Segna il piatto come liberato\",cleared:\"Piatto libero\",notCleared:\"Piatto non libero\",inUse:\"Piatto in uso\"},queue:{inQueue:\"{{count}} stampa in coda\",inQueue_plural:\"{{count}} stampe in coda\"},controls:\"Controlli\",rfid:{reread:\"Rileggi RFID\"},bedJog:{title:\"Muovi il piano di stampa\",bed:\"Piano\",step:\"Passo (mm)\",up:\"Sposta piano su\",down:\"Sposta piano giù\",disabledWhilePrinting:\"Disabilitato durante la stampa\",notHomedTitle:\"Stampante non azzerata\",notHomedMessage:\"La stampante non è stata azzerata dall'ultima stampa. Esegui prima l'azzeramento automatico per un posizionamento sicuro (parcheggia la testa di stampa, poi azzera X, Y e Z), oppure muovi comunque — i finecorsa software verranno ignorati.\",homeZ:\"Azzeramento automatico\",moveAnyway:\"Muovi comunque\",homingStarted:\"Azzeramento automatico in corso…\"},permission:{noAdd:\"Non hai il permesso di aggiungere stampanti\",noEdit:\"Non hai il permesso di modificare stampanti\",noDelete:\"Non hai il permesso di eliminare stampanti\",noControl:\"Non hai il permesso di controllare stampanti\",noFiles:\"Non hai il permesso di accedere ai file stampante\",noAmsRfid:\"Non hai il permesso di rileggere AMS RFID\",noSmartPlugControl:\"Non hai il permesso di controllare smart plug\",noCamera:\"Non hai il permesso di visualizzare le telecamere\"},modal:{addTitle:\"Aggiungi Stampante\",editTitle:\"Modifica Stampante\",myPrinter:\"La mia Stampante\",selectModel:\"Seleziona modello...\",locationGroup:\"Posizione / Gruppo (opzionale)\",locationPlaceholder:\"es. Officina, Ufficio, Cantina\",autoArchiveLabel:\"Archivia automaticamente stampe completate\",fromPrinterSettings:\"Dalle impostazioni della stampante\",modelOptional:\"Modello (opzionale)\",saveChanges:\"Salva modifiche\"},skipObjects:{tooltip:\"Salta oggetti\",onlyWhilePrinting:\"Salta oggetti (solo durante la stampa)\",requiresMultiple:\"Salta oggetti (richiede 2+ oggetti)\",title:\"Salta Oggetti\",matchIdsInfo:\"Abbina gli ID con il display della stampante\",printerShowsIds:\"Lo schermo mostra gli ID oggetto sul piatto\",skipSelected:\"Salta selezionati\",skipping:\"Saltando...\",noObjectsSelected:\"Nessun oggetto selezionato\",selectObjectsToSkip:\"Seleziona gli oggetti da saltare nella stampa corrente\",skipped:\"saltato\",objectsSkipped:\"Oggetti saltati\",activeCount:\"{{count}} attivi\",waitForLayer:\"Attendi il layer 2+ per saltare oggetti (attualmente layer {{layer}})\",skip:\"Salta\",confirmTitle:\"Saltare oggetto?\",confirmMessage:'Sei sicuro di voler saltare \"{{name}}\"? Questa azione non può essere annullata.'},confirm:{deleteTitle:\"Elimina Stampante\",deleteMessage:'Sei sicuro di eliminare \"{{name}}\"? Questo rimuoverà tutte le impostazioni di connessione.',deleteArchivesNote:\"Tutta la cronologia di stampa sarà eliminata definitivamente.\",keepArchivesNote:\"La cronologia sarà mantenuta ma non più associata a questa stampante.\",stopTitle:\"Ferma Stampa\",stopMessage:'Sei sicuro di fermare la stampa corrente su \"{{name}}\"? Questo annullerà il lavoro di stampa.',stopButton:\"Ferma Stampa\",pauseTitle:\"Pausa Stampa\",pauseMessage:'Sei sicuro di mettere in pausa la stampa corrente su \"{{name}}\"?',pauseButton:\"Pausa Stampa\",resumeTitle:\"Riprendi Stampa\",resumeMessage:'Sei sicuro di riprendere la stampa su \"{{name}}\"?',resumeButton:\"Riprendi Stampa\",powerOnTitle:\"Accendi Stampante\",powerOnMessage:'Sei sicuro di accendere \"{{name}}\"?',powerOnButton:\"Accendi\",powerOffTitle:\"Spegni Stampante\",powerOffMessage:'Sei sicuro di spegnere \"{{name}}\"?',powerOffWarning:'AVVISO: \"{{name}}\" sta stampando! Sei sicuro di spegnere? Questo interromperà la stampa e potrebbe danneggiare la stampante.',powerOffButton:\"Spegni\"},bulk:{select:\"Seleziona\",selectAll:\"Seleziona tutto\",selectByLocation:\"Seleziona per posizione\",selected:\"{{count}} selezionato/i\",actions:{stop:\"Ferma\",pause:\"Pausa\",resume:\"Riprendi\",clearPlate:\"Svuota piano\",clearHMS:\"Cancella notifiche\"},confirm:{stopTitle:\"Ferma {{count}} stampe\",stopMessage:\"Questo annullerà le stampe attive su {{count}} stampante/i. Questa azione non può essere annullata.\",stopButton:\"Ferma tutte\",pauseTitle:\"Pausa {{count}} stampe\",pauseMessage:\"Questo metterà in pausa le stampe attive su {{count}} stampante/i.\",pauseButton:\"Pausa tutte\",clearPlateTitle:\"Svuota {{count}} piani di stampa\",clearPlateMessage:\"Questo svuoterà il piano di stampa su {{count}} stampante/i e potrebbe avviare i lavori in coda.\",clearPlateButton:\"Svuota tutti\"},success:\"{{action}} completato su {{count}} stampante/i\",partial:\"{{succeeded}} riuscito/i, {{failed}} fallito/i\",noneApplicable:\"Nessuna stampante selezionata è nello stato corretto per questa azione\",selectByState:\"Seleziona per stato\"},discovery:{title:\"Trova Stampanti\",searching:\"Ricerca...\",scanning:\"Scansione...\",scanProgress:\"Scansione... {{scanned}}/{{total}}\",foundPrinters:\"Trovate {{count}} stampante(i)\",noPrintersFound:\"Nessuna stampante trovata\",noPrintersFoundSubnet:\"Nessuna stampante trovata nella sottorete specificata.\",noPrintersFoundNetwork:\"Nessuna stampante trovata sulla rete.\",allConfigured:\"Tutte le stampanti trovate sono già configurate.\",alreadyAdded:\"Già aggiunta\",select:\"Seleziona\",manualEntry:\"Inserimento manuale\",addFromCloud:\"Aggiungi da Cloud\",subnetToScan:\"Sottorete da scansionare\",dockerNote:\"Docker rilevato. Inserisci la sottorete della stampante in notazione CIDR. Richiede network_mode: host in docker-compose.yml.\",scanSubnet:\"Scansiona sottorete per stampanti\",discoverNetwork:\"Trova stampanti in rete\",scanningSubnet:\"Scansione sottorete per stampanti Bambu...\",scanningNetwork:\"Scansione rete...\",serialRequired:\"Seriale richiesto\",unknown:\"Sconosciuto\",failedToStart:\"Avvio ricerca non riuscito\"},drying:{start:\"Avvia essiccazione\",stop:\"Ferma essiccazione\",temperature:\"Temperatura\",duration:\"Durata\",hours:\"ore\",timeRemaining:\"{{time}} rimanente\",active:\"Essiccazione\",notSupported:\"Essiccazione non supportata\",powerRequired:\"Collegare l'alimentatore AMS per abilitare l'asciugatura\",startingDrying:\"Avvio essiccazione...\",stoppingDrying:\"Arresto essiccazione...\",rotateTray:\"Ruota la bobina durante l'essiccazione\"},filaments:\"Filamenti\",openCameraOverlay:\"Apri overlay camera\",openCameraWindow:\"Apri camera in nuova finestra\",firmwareUpdateAvailable:\"Aggiornamento firmware disponibile: {{current}} → {{latest}}\",firmwareUpToDate:\"Firmware {{version}} — Aggiornato\",firmwareUpdateButton:\"Aggiorna\",plateDetection:{noPermission:\"Non hai il permesso di aggiornare le stampanti\",enabledClick:\"Controllo piatto abilitato - Clicca per disabilitare\",disabledClick:\"Controllo piatto disabilitato - Clicca per abilitare\",manageCalibration:\"Gestisci calibrazione rilevamento piatto\",calibrationRequired:\"Calibrazione richiesta\",calibrationInstructions:\"Assicurati che il piatto sia <strong>completamente vuoto</strong>, poi clicca Calibra.\",calibrationDescription:\"La calibrazione salva un'immagine di riferimento del piatto vuoto. I controlli futuri confronteranno con questo riferimento per rilevare oggetti.\",calibrationTip:\"<strong>Suggerimento:</strong> Puoi salvare fino a 5 calibrazioni per piatti diversi. Il sistema usa automaticamente la migliore corrispondenza durante il controllo.\",plateEmpty:\"Il piatto sembra vuoto\",objectsDetected:\"Oggetti rilevati sul piatto\",confidence:\"Confidenza\",difference:\"Differenza\",analysisPreview:\"Anteprima analisi:\",analysisLegend:\"Riquadro verde = area rilevamento, overlay rosso = differenze dalla calibrazione\",savedReferences:\"Riferimenti salvati ({{count}}/{{max}})\",deleteReference:\"Elimina riferimento\",labelPlaceholder:\"Etichetta...\",clickToEdit:\"{{label}} - Clicca per modificare\",clickToAddLabel:\"Clicca per aggiungere etichetta\"},speed:{title:\"Velocità di stampa\",silent:\"Silenzioso (50%)\",standard:\"Standard (100%)\",sport:\"Sport (124%)\",ludicrous:\"Ludicrous (166%)\"},airduct:{title:\"Modalità condotto d'aria\",cooling:\"Raffreddamento\",heating:\"Riscaldamento\"},noSdCard:\"Nessuna SD\",door:{open:\"Aperta\",closed:\"Chiusa\"},fans:{partCooling:\"Ventola raffreddamento parte\",auxiliary:\"Ventola ausiliaria\",chamber:\"Ventola camera\"},clickToViewHmsErrors:\"Clicca per vedere errori HMS\",estimatedCompletion:\"Tempo completamento stimato\",plateNumber:\"Piastra {{number}}\",slotOptions:\"Opzioni slot\",amsPopup:{friendlyName:\"Nome AMS\",friendlyNamePlaceholder:\"es. Nome AMS amichevole\",serialNumber:\"Numero di serie\",firmwareVersion:\"Firmware\",save:\"Salva\",clear:\"Cancella\",noEditPermission:\"Non hai il permesso di rinominare le unità AMS\"},firmwareModal:{title:\"Aggiornamento Firmware\",titleUpToDate:\"Info Firmware\",currentVersion:\"Corrente:\",latestVersion:\"Ultima:\",releaseNotes:\"Note di rilascio\",checkingPrereqs:\"Controllo prerequisiti...\",sdCardReady:\"SD pronta. Clicca sotto per caricare firmware.\",uploadedSuccess:\"Firmware caricato su SD!\",applyInstructions:\"Per applicare l'aggiornamento sulla stampante:\",step1:\"Sul touchscreen della stampante, vai a <strong>Impostazioni</strong>\",step2:\"Vai a <strong>Firmware</strong>\",step3:\"Seleziona <strong>Aggiorna da SD</strong>\",step4:\"L'aggiornamento richiede 10-20 minuti\",done:\"Fatto\",starting:\"Avvio...\",uploadFirmware:\"Carica Firmware\",uploadFailed:\"Avvio caricamento fallito: {{error}}\",uploadedToast:\"Firmware caricato! Avvia aggiornamento dal display.\"},accessCodePlaceholder:\"Lascia vuoto per mantenere quello attuale\",roi:{title:\"Area di rilevamento (ROI)\",xStart:\"X Inizio\",yStart:\"Y Inizio\",width:\"Larghezza\",height:\"Altezza\",instruction:\"Regola l'area di rilevamento per focalizzare il piatto. Il riquadro verde mostra l'area corrente.\"},developerModeWarning:\"La modalità sviluppatore LAN non è attivata su: {{names}}. Alcune funzionalità potrebbero non funzionare.\",howToEnable:\"Come attivare\",incompatibleFile:\"Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}\",dropNotPrintable:\"Solo i file .gcode e .gcode.3mf possono essere stampati\",dropToPrint:\"Rilascia per stampare\",cannotPrint:\"Stampante occupata\"},archives:{title:\"Archivi di stampa\",searchPlaceholder:\"Cerca archivi...\",filterByPrinter:\"Filtra per stampante\",filterByStatus:\"Filtra per stato\",sortBy:\"Ordina per\",sortNewest:\"Più recenti\",sortOldest:\"Meno recenti\",sortName:\"Nome\",sortDuration:\"Durata\",sortLargest:\"Più grandi\",sortSmallest:\"Più piccoli\",sortSize:\"Dimensione\",noArchives:\"Nessun archivio trovato\",noArchivesSearch:\"Nessun archivio corrisponde alla ricerca\",originalPrintNotVisible:\"Stampa originale non visibile - prova a rimuovere i filtri\",noArchivesYet:\"Nessun archivio ancora\",prints:\"stampe\",pagination:{showing:\"Mostrando\",to:\"a\",of:\"di\",show:\"Mostra\",page:\"Pagina\",all:\"Tutti\"},loadingArchives:\"Caricamento archivi...\",releaseToUpload:\"Rilascia per caricare\",showAll:\"Mostra tutti\",showFavoritesOnly:\"Solo preferiti\",gridView:\"Vista griglia\",listView:\"Vista elenco\",calendarView:\"Vista calendario\",logView:\"Registro stampe\",manageTags:\"Gestisci tag\",showFailedPrints:\"Mostra stampe fallite\",hideFailedPrints:\"Nascondi stampe fallite\",hideDuplicates:\"Nascondi duplicati\",viewOriginalPrint:\"Fai clic per visualizzare la stampa originale (#{{id}})\",printTime:\"Tempo di stampa\",filamentUsed:\"Filamento usato\",cost:\"Costo\",reprint:\"Ristampa\",preview:\"Anteprima\",deleteArchive:\"Elimina archivio\",deleteConfirm:\"Sei sicuro di eliminare questo archivio?\",favorite:\"Preferito\",unfavorite:\"Rimuovi dai preferiti\",viewDetails:\"Vedi dettagli\",status:{completed:\"Completato\",failed:\"Fallito\",stopped:\"Fermato\"},toast:{source3mfAttached:\"Sorgente 3MF allegata: {{filename}}\",failedUploadSource3mf:\"Caricamento sorgente 3MF non riuscito\",source3mfRemoved:\"Sorgente 3MF rimossa\",failedRemoveSource3mf:\"Rimozione sorgente 3MF non riuscita\",f3dAttached:\"F3D allegato: {{filename}}\",failedUploadF3d:\"Caricamento F3D non riuscito\",f3dRemoved:\"F3D rimosso\",failedRemoveF3d:\"Rimozione F3D non riuscita\",timelapseAttached:\"Timelapse allegato: {{filename}}\",timelapseAlreadyAttached:\"Timelapse già allegato\",noMatchingTimelapse:\"Nessun timelapse corrispondente\",failedScanTimelapse:\"Scansione timelapse non riuscita\",failedAttachTimelapse:\"Allegato timelapse non riuscito\",timelapseRemoved:\"Timelapse rimosso\",failedRemoveTimelapse:\"Impossibile rimuovere il timelapse\",timelapseUploaded:\"Timelapse caricato: {{filename}}\",failedUploadTimelapse:\"Impossibile caricare il timelapse\",archiveDeleted:\"Archivio eliminato\",failedDeleteArchive:\"Eliminazione archivio non riuscita\",addedToFavorites:\"Aggiunto ai preferiti\",removedFromFavorites:\"Rimosso dai preferiti\",projectUpdated:\"Progetto aggiornato\",failedUpdateProject:\"Aggiornamento progetto non riuscito\",linkCopied:\"Link copiato negli appunti\",failedCopyLink:\"Copia link non riuscita\",photoDeleted:\"Foto eliminata\",failedDeletePhoto:\"Eliminazione foto non riuscita\",failedDeleteArchives:\"Eliminazione archivi non riuscita\",failedUpdateFavorites:\"Aggiornamento preferiti non riuscito\",exportDownloaded:\"Export scaricato\",exportFailed:\"Export non riuscito\"},menu:{print:\"Stampa\",schedule:\"Programma\",openInBambuStudio:\"Apri nello slicer\",slice:\"Slice\",externalLink:\"Link esterno\",viewOnMakerWorld:\"Vedi su MakerWorld\",preview3d:\"Anteprima 3D\",viewTimelapse:\"Vedi Timelapse\",scanForTimelapse:\"Cerca Timelapse\",uploadTimelapse:\"Carica timelapse\",removeTimelapse:\"Rimuovi timelapse\",downloadSource3mf:\"Scarica Sorgente 3MF\",uploadSource3mf:\"Carica Sorgente 3MF\",replaceSource3mf:\"Sostituisci Sorgente 3MF\",removeSource3mf:\"Rimuovi Sorgente 3MF\",uploadF3d:\"Carica F3D\",replaceF3d:\"Sostituisci F3D\",downloadF3d:\"Scarica F3D\",removeF3d:\"Rimuovi F3D\",download:\"Scarica\",copyDownloadLink:\"Copia link download\",qrCode:\"QR Code\",viewPhotos:\"Vedi foto\",viewPhotosCount:\"Vedi foto ({{count}})\",projectPage:\"Pagina progetto\",addToFavorites:\"Aggiungi ai preferiti\",removeFromFavorites:\"Rimuovi dai preferiti\",edit:\"Modifica\",goToProject:\"Vai al progetto: {{name}}\",addToProject:\"Aggiungi al progetto\",removeFromProject:\"Rimuovi dal progetto\",loading:\"Caricamento...\",noProjectsAvailable:\"Nessun progetto disponibile\",select:\"Seleziona\",deselect:\"Deseleziona\",delete:\"Elimina\"},permission:{noReprint:\"Non hai il permesso di ristampare questo archivio\",noAddToQueue:\"Non hai il permesso di aggiungere alla coda\",noUpdateArchives:\"Non hai il permesso di aggiornare archivi\",noUploadFiles:\"Non hai il permesso di caricare file\",noDownload:\"Non hai il permesso di scaricare archivi\",noCopyLink:\"Non hai il permesso di copiare link download\",noDelete:\"Non hai il permesso di eliminare questo archivio\",noCreate:\"Non hai il permesso di creare archivi\"},card:{previousPlate:\"Piatto precedente\",nextPlate:\"Piatto successivo\",plateNumber:\"Piatto {{index}}\",moreOptions:\"Clic destro per altre opzioni\",addToFavorites:\"Aggiungi ai preferiti\",removeFromFavorites:\"Rimuovi dai preferiti\",cancelled:\"annullato\",failed:\"fallito\",duplicate:\"duplicato\",duplicateTitle:\"Questo modello è stato stampato prima\",openSource3mf:\"Apri sorgente 3MF in Bambu Studio (clic destro per altre opzioni)\",downloadF3d:\"Scarica file design Fusion 360\",viewTimelapse:\"Vedi timelapse\",viewPhoto:\"Vedi 1 foto\",viewPhotos:\"Vedi {{count}} foto\",openFolder:\"Apri cartella: {{name}}\",slicedFile:\"File slice - pronto a stampare\",sourceFile:\"Solo file sorgente - nessuna mappatura AMS disponibile\",gcode:\"GCODE\",source:\"SOURCE\",project:\"Progetto: {{name}}\",estimated:\"Stimato: {{time}}\",actual:\"Reale: {{time}}\",accuracy:\"Accuratezza: {{percent}}%\",filament:\"{{weight}}g\",layer:\"{{count}} strato\",layers:\"{{count}} strati\",object:\"{{count}} oggetto\",objects:\"{{count}} oggetti\",slicedFor:\"Sliced per {{model}}\",uploadedBy:\"Caricato da\",noPermissionReprint:\"Non hai il permesso di ristampare\",noFileForReprint:\"Nessun file 3MF disponibile — il file non è stato scaricato dalla stampante durante la registrazione\",noPermissionEdit:\"Non hai il permesso di modificare archivi\",noPermissionDelete:\"Non hai il permesso di eliminare archivi\",reprint:\"Ristampa\",schedulePrint:\"Programma Stampa\",schedule:\"Programma\",openInBambuStudio:\"Apri nello slicer\",openInBambuStudioToSlice:\"Apri nello slicer per slicing\",slice:\"Slice\",externalLink:\"Link esterno\",makerWorld:\"MakerWorld: {{designer}}\",viewProject:\"Vedi progetto\",noExternalLink:\"Nessun link esterno\",preview3d:\"Anteprima 3D\",download:\"Scarica\",edit:\"Modifica\",delete:\"Elimina\"},modal:{deleteArchive:\"Elimina Archivio\",deleteConfirm:'Sei sicuro di eliminare \"{{name}}\"? Questa azione non può essere annullata.',deleteButton:\"Elimina\",removeSource3mf:\"Rimuovi Sorgente 3MF\",removeSource3mfConfirm:'Sei sicuro di rimuovere il file sorgente 3MF da \"{{name}}\"? Questo eliminerà il progetto slicer originale.',removeButton:\"Rimuovi\",removeF3d:\"Rimuovi F3D\",removeF3dConfirm:'Sei sicuro di rimuovere il file Fusion 360 da \"{{name}}\"?',removeTimelapse:\"Rimuovi timelapse\",removeTimelapseConfirm:'Sei sicuro di voler rimuovere il video timelapse da \"{{name}}\"?',timelapse:\"{{name}} - Timelapse\",selectTimelapse:\"Seleziona Timelapse\",selectTimelapseDesc:\"Nessun abbinamento automatico trovato. Seleziona il timelapse per questa stampa:\",deleteArchives:\"Elimina Archivi\",deleteArchivesConfirm:\"Sei sicuro di eliminare {{count}} archivio(i)? Questa azione non può essere annullata.\",deleteCount:\"Elimina {{count}}\"},page:{title:\"Archivi\",printsCount:\"{{filtered}} di {{total}} stampe\",dropFilesHere:\"Rilascia file .3mf qui\",releaseToUpload:\"Rilascia per caricare\",only3mfSupported:\"Solo file .3mf supportati\",close:\"Chiudi\",selected:\"{{count}} selezionati\",selectAll:\"Seleziona tutto\",tags:\"Tag\",project:\"Progetto\",favorite:\"Preferito\",delete:\"Elimina\",toggledFavorites:\"Preferiti aggiornati per {{count}} archivio(i)\",failedUpdateFavorites:\"Aggiornamento preferiti non riuscito\",archivesDeleted:\"{{count}} archivio(i) eliminati\",failedDeleteArchives:\"Eliminazione archivi non riuscita\",photoDeleted:\"Foto eliminata\",failedDeletePhoto:\"Eliminazione foto non riuscita\"},list:{name:\"Nome\",printer:\"Stampante\",date:\"Data\",size:\"Dimensione\",actions:\"Azioni\",hasTimelapse:\"Ha timelapse\"},log:{date:\"Data\",printName:\"Nome stampa\",printer:\"Stampante\",user:\"Utente\",status:\"Stato\",duration:\"Durata\",filament:\"Filamento\",allPrinters:\"Tutte le stampanti\",allUsers:\"Tutti gli utenti\",allStatuses:\"Tutti gli stati\",cancelled:\"Annullato\",skipped:\"Saltato\",dateFrom:\"Dal\",dateTo:\"Al\",noEntries:\"Nessuna voce di registro trovata\",showing:\"{{count}} di {{total}} voci\",rowsPerPage:\"Righe\",page:\"Pagina\",prev:\"Prec.\",next:\"Succ.\",clearLog:\"Cancella registro\",clearLogTitle:\"Cancella registro stampe\",clearLogConfirm:\"Tutte le voci del registro di stampa verranno eliminate permanentemente. Gli archivi e gli elementi della coda non sono interessati. Questa azione non può essere annullata. Sei sicuro?\",clearLogButton:\"Cancella tutto\",cleared:\"{{count}} voci di registro cancellate\",clearFailed:\"Impossibile cancellare il registro stampe\"}},queue:{title:\"Coda di stampa\",subtitle:\"Programma e gestisci i tuoi lavori di stampa\",addToQueue:\"Aggiungi alla coda\",print:\"Stampa\",reprint:\"Ristampa\",schedulePrint:\"Programma Stampa\",editQueueItem:\"Modifica elemento coda\",printToPrinters:\"Stampa su {{count}} Stampanti\",queueToPrinters:\"Metti in coda su {{count}} Stampanti\",queueSelectedPlates:\"Metti in coda {{count}} piastre\",selectAllPlates:\"Seleziona tutte le {{count}} piastre\",deselectAll:\"Deseleziona tutto\",printQueued:\"Stampa in coda\",itemsQueued:\"{{count}} elementi in coda\",sending:\"Invio...\",sendingProgress:\"Invio {{current}}/{{total}}...\",adding:\"Aggiunta...\",addingProgress:\"Aggiunta {{current}}/{{total}}...\",savingProgress:\"Salvataggio {{current}}/{{total}}...\",clearQueue:\"Svuota coda\",clearHistory:\"Svuota cronologia\",emptyQueue:\"La coda è vuota\",position:\"Posizione\",scheduledTime:\"Ora programmata\",moveUp:\"Sposta su\",moveDown:\"Sposta giù\",startNow:\"Avvia ora\",printingInProgress:\"Stampa in corso...\",viewArchive:\"Vedi archivio\",viewInFileManager:\"Vedi nel Gestore file\",itemCount:\"{{count}} elemento\",itemCount_plural:\"{{count}} elementi\",dragToReorder:\"Trascina per riordinare (solo ASAP)\",reorderHint:\"La posizione influisce solo sugli elementi ASAP. Quelli programmati partono all'orario.\",sjf:{label:\"SJF\",tooltip:\"Lavoro più breve prima — lo scheduler dà priorità alle stampe più brevi\"},addedBy:\"Aggiunto da {{name}}\",nextInQueue:\"Prossimo in coda\",clearPlateSuccess:\"Piatto liberato — pronto per la prossima stampa\",plateNumber:\"Piatto {{index}}\",quantity:\"Quantità\",quantityHint:\"Crea {{count}} elementi in coda\",activeBatches:\"Lotti attivi\",batchProgress:\"{{completed}} di {{total}} completati\",cancelBatch:\"Annulla rimanenti\",batchCancelled:\"Elementi rimanenti del lotto annullati\",cancelBatchConfirmTitle:\"Annulla lotto\",cancelBatchConfirmMessage:\"Annullare tutti gli elementi in sospeso rimanenti in questo lotto?\",batch:\"Lotto\",sections:{currentlyPrinting:\"In stampa\",queued:\"In coda\",history:\"Cronologia\"},status:{pending:\"In attesa\",waiting:\"In attesa\",printing:\"In stampa\",paused:\"In pausa\",completed:\"Completato\",failed:\"Fallito\",skipped:\"Saltato\",cancelled:\"Annullato\"},summary:{printing:\"In stampa\",queued:\"In coda\",totalTime:\"Tempo totale coda\",totalWeight:\"Peso totale della coda\",history:\"Cronologia\"},filter:{allPrinters:\"Tutte le stampanti\",unassigned:\"Non assegnato\",allStatus:\"Tutti gli stati\",allLocations:\"Tutte le posizioni\",any:\"Qualsiasi\"},sort:{byPosition:\"Ordina per posizione\",byName:\"Ordina per nome\",byPrinter:\"Ordina per stampante\",bySchedule:\"Ordina per programma\",byDate:\"Ordina per data\",ascendingOldest:\"Crescente (più vecchi)\",descendingNewest:\"Decrescente (più recenti)\"},badges:{staged:\"In staging\",requiresPrevious:\"Richiede successo precedente\",autoPowerOff:\"Spegnimento automatico\",gcodeInjection:\"G-code\"},empty:{title:\"Nessuna stampa programmata\",description:`Programma una stampa dalla pagina Archivi usando l'opzione \"Programma\" nel menu contestuale, o trascina i file per iniziare.`},time:{asap:\"ASAP\",overdue:\"Scaduto\",now:\"Ora\",lessThanMinute:\"Tra meno di un minuto\",inMinutes:\"Tra {{count}} min\",inHours:\"Tra {{count}} ore\"},actions:{stopPrint:\"Ferma Stampa\",startPrint:\"Avvia Stampa\",requeue:\"Rimetti in coda\"},bulkEdit:{title:\"Modifica {{count}} elemento\",title_plural:\"Modifica {{count}} elementi\",description:\"Solo le impostazioni modificate saranno applicate agli elementi selezionati.\",printer:\"Stampante\",noChange:\"— Nessun cambio —\",queueOptions:\"Opzioni coda\",staged:\"In staging (avvio manuale)\",autoPowerOff:\"Spegnimento automatico dopo stampa\",requirePrevious:\"Richiede successo precedente\",printOptions:\"Opzioni stampa\",bedLevelling:\"Livellamento piatto\",flowCalibration:\"Calibrazione flusso\",vibrationCalibration:\"Calibrazione vibrazioni\",layerInspection:\"Controllo primo layer\",timelapse:\"Timelapse\",useAms:\"Usa AMS\",applyChanges:\"Applica modifiche\",selectAll:\"Seleziona tutto\",deselectAll:\"Deseleziona tutto\",selected:\"{{count}} selezionati\",editSelected:\"Modifica selezionati\",cancelSelected:\"Annulla selezionati\"},confirm:{cancelTitle:\"Annulla stampa programmata\",cancelMessage:'Sei sicuro di annullare \"{{name}}\"?',stopTitle:\"Ferma Stampa\",stopMessage:'Sei sicuro di fermare la stampa corrente \"{{name}}\"? Questo annullerà il lavoro sulla stampante.',removeTitle:\"Rimuovi dalla cronologia\",removeMessage:'Sei sicuro di rimuovere \"{{name}}\" dalla cronologia coda?',clearHistoryTitle:\"Svuota cronologia\",clearHistoryMessage:\"Sei sicuro di rimuovere {{count}} elemento(i) dalla cronologia?\",cancelButton:\"Annulla Stampa\",stopButton:\"Ferma Stampa\",thisPrint:\"questa stampa\",thisItem:\"questo elemento\"},toast:{cancelled:\"Elemento coda annullato\",cancelFailed:\"Annullamento non riuscito\",removed:\"Elemento coda rimosso\",removeFailed:\"Rimozione non riuscita\",stopped:\"Stampa fermata\",stopFailed:\"Impossibile fermare stampa\",released:\"Stampa rilasciata in coda\",startFailed:\"Avvio stampa non riuscito\",reorderFailed:\"Riordino coda non riuscito\",historyCleared:\"Cancellati {{count}} elementi cronologia\",clearHistoryFailed:\"Svuotamento cronologia non riuscito\",updateFailed:\"Aggiornamento elementi non riuscito\",bulkCancelled:\"Annullati {{count}} elementi\",bulkCancelFailed:\"Annullamento elementi non riuscito\"},timeline:{listView:\"Lista\",timelineView:\"Cronologia\",unassigned:\"Non assegnato\",noData:\"Nessuna stampa programmata per questo giorno\",allDoneBy:\"Tutte le stampe completate entro le {{time}}\",staged:\"In attesa\",filterAll:\"Mostra tutto\",filterPrinting:\"In stampa\",filterQueued:\"In coda\",time:{anyMoment:\"a momenti\",minutesLeft:\"{{minutes}}m rimanenti\",hoursLeft:\"{{hours}}h rimanenti\",hoursMinutesLeft:\"{{hours}}h {{minutes}}m rimanenti\"},day:{previous:\"Giorno precedente\",next:\"Giorno successivo\",today:\"Oggi\"}},permissions:{noStopPrint:\"Non hai il permesso di fermare stampe\",noStartPrint:\"Non hai il permesso di avviare stampe\",noEdit:\"Non hai il permesso di modificare questo elemento coda\",noCancel:\"Non hai il permesso di annullare questo elemento coda\",noRequeue:\"Non hai il permesso di rimettere in coda elementi\",noRemove:\"Non hai il permesso di rimuovere questo elemento coda\",noClearHistory:\"Non hai il permesso di svuotare tutta la cronologia\",noEditItems:\"Non hai il permesso di modificare elementi coda\",noCancelItems:\"Non hai il permesso di annullare elementi coda\"}},backgroundDispatch:{unknownFile:\"File sconosciuto\",unknownPrinter:\"Stampante sconosciuta\",startingPrints:\"Avvio stampe\",progressSummary:\"{{complete}}/{{total}} completati • Inviati: {{dispatched}} • In elaborazione: {{processing}}\",expandDetails:\"Espandi dettagli dispatch\",collapseDetails:\"Comprimi dettagli dispatch\",dismissToast:\"Chiudi notifica dispatch\",cancelDispatchJob:\"Annulla job dispatch\",cancel:\"Annulla\",cancelling:\"Annullamento…\",status:{dispatched:\"Inviato\",processing:\"In elaborazione\",completed:\"Completato\",failed:\"Fallito\",cancelled:\"Annullato\"},toast:{cancellingUpload:\"Annullamento upload...\",cancelled:\"Dispatch annullato\",cancelFailed:\"Impossibile annullare il dispatch\",completeWithFailures:\"Dispatch in background completato: {{completed}} riusciti, {{failed}} falliti\",completeSuccess:\"Dispatch in background completato: {{completed}} riusciti\",printStartedRemaining:\"{{completed}} stampa/e avviata/e, {{remaining}} in invio...\"}},stats:{title:\"Dashboard\",subtitle:\"Trascina i widget per riordinare. Clicca l'icona occhio per nascondere.\",overview:\"Panoramica\",totalPrints:\"Stampe totali\",successRate:\"Tasso di successo\",totalPrintTime:\"Tempo totale di stampa\",printTime:\"Tempo di stampa\",totalFilament:\"Filamento totale usato\",filamentUsed:\"Filamento usato\",filamentCost:\"Costo filamento\",totalCost:\"Costo totale\",energyUsed:\"Energia usata\",energyCost:\"Costo energia\",energyWarmingUpTooltip:\"Il tracciamento energia sta ancora raccogliendo snapshot orari. I totali per intervallo diventeranno accurati quando esisterà almeno uno snapshot prima dell’intervallo selezionato. I primi valori potrebbero essere sottostimati.\",averagePrintTime:\"Tempo medio di stampa\",printsPerDay:\"Stampe al giorno\",byPrinter:\"Per stampante\",printsByPrinter:\"Stampe per stampante\",byMaterial:\"Per materiale\",byMonth:\"Per mese\",last7Days:\"Ultimi 7 giorni\",last30Days:\"Ultimi 30 giorni\",last90Days:\"Ultimi 90 giorni\",allTime:\"Sempre\",quickStats:\"Statistiche rapide\",printActivity:\"Attivita di stampa\",filamentTypes:\"Tipi di filamento\",filamentTrends:\"Trend filamento\",failureAnalysis:\"Analisi guasti\",timeAccuracy:\"Accuratezza tempo\",successful:\"Riuscite:\",failed:\"Fallite:\",perfectEstimate:\"100% = stima perfetta\",noTimeAccuracyData:\"Nessun dato accuratezza tempo\",noFilamentData:\"Nessun dato filamento\",noPrinterData:\"Nessun dato stampante\",noPrintData:\"Nessun dato stampa\",noPrintDataLast30Days:\"Nessun dato stampa negli ultimi 30 giorni\",failureReasons:\"Cause guasto\",topFailureReasons:\"Cause principali\",failedPrintsCount:\"{{failed}} / {{total}} stampe fallite\",lastWeekRate:\"Settimana scorsa: {{rate}}%\",resetLayout:\"Reimposta layout\",recalculateCosts:\"Ricalcola costi\",recalculateCostsHint:\"Ricalcola tutti i costi archivi usando i prezzi filamento correnti\",exportStats:\"Esporta statistiche\",exportAsCsv:\"Esporta come CSV\",exportAsExcel:\"Esporta come Excel\",hiddenCount:\"{{count}} Nascosti\",exportDownloaded:\"Export scaricato\",exportFailed:\"Export non riuscito\",layoutReset:\"Layout reimpostato\",recalculatedCosts:\"Costi ricalcolati per {{count}} archivi\",recalculateFailed:\"Ricalcolo costi non riuscito\",loadingStats:\"Caricamento statistiche...\",noPermissionResetLayout:\"Non hai il permesso di reimpostare il layout\",noPermissionRecalculate:\"Non hai il permesso di ricalcolare i costi\",noPrintDataInRange:\"Nessun dato nel periodo selezionato\",periodFilament:\"Filamento utilizzato\",periodCost:\"Costo\",avgPerPrint:\"Media per stampa\",usageOverTime:\"Utilizzo nel tempo\",filamentByWeight:\"Peso\",printDuration:\"Durata stampa\",printerUtilization:\"Utilizzo stampante\",filamentSuccess:\"Successo per materiale\",printHabits:\"Abitudini di stampa\",printTimeOfDay:\"Ora di stampa\",colorDistribution:\"Distribuzione colori\",noColorData:\"Nessun dato colore disponibile\",records:\"Record\",longestPrint:\"Stampa più lunga\",heaviestPrint:\"Stampa più pesante\",mostExpensivePrint:\"Più costosa\",busiestDay:\"Giorno più attivo\",successStreak:\"Serie di successi\",streakPrint:\"stampa consecutiva\",streakPrints:\"{{count}} stampe consecutive\",printerStats:\"Statistiche stampante\",hours:\"ore\",avgPrints:\"Media stampe\",noArchiveData:\"Nessun dato di stampa disponibile\",filamentByTime:\"Tempo\",avgWeight:\"Media peso\",avgTime:\"Media tempo\",filamentByPrints:\"Stampe\",timeframe:{today:\"Oggi\",\"this-week\":\"Questa settimana\",\"this-month\":\"Questo mese\",\"last-7\":\"Ultimi 7 giorni\",\"last-30\":\"Ultimi 30 giorni\",\"last-90\":\"Ultimi 90 giorni\",\"this-year\":\"Quest'anno\",\"all-time\":\"Tutto\",custom:\"Personalizzato\",from:\"Da\",to:\"A\"},allUsers:\"Tutti gli utenti\",noUser:\"Nessun utente (Sistema)\",filterByUser:\"Filtra per utente\"},maintenance:{title:\"Manutenzione\",overview:\"Panoramica\",allOk:\"Tutta la manutenzione aggiornata\",dueCount:\"{{count}} elemento in scadenza\",dueCount_plural:\"{{count}} elementi in scadenza\",warningCount:\"{{count}} avviso\",warningCount_plural:\"{{count}} avvisi\",totalPrintTime:\"Tempo totale di stampa\",nextMaintenance:\"Prossima manutenzione\",nothingDue:\"Niente in scadenza\",tasks:\"Attivita\",lastPerformed:\"Ultima esecuzione\",interval:\"Intervallo\",hoursRemaining:\"{{hours}}h rimanenti\",hoursOverdue:\"{{hours}}h in ritardo\",markDone:\"Segna come fatto\",performMaintenance:\"Esegui manutenzione\",history:\"Cronologia\",noHistory:\"Nessuna cronologia manutenzione\",editPrintHours:\"Modifica ore stampa\",currentHours:\"Ore attuali\",statusTab:\"Stato\",settingsTab:\"Impostazioni\",overdueCount:\"{{count}} in ritardo\",dueSoonCount:\"{{count}} in scadenza\",dueSoon:\"In scadenza\",allGood:\"Tutto ok\",overdueBy:\"In ritardo di {{duration}}\",dueIn:\"Scade tra {{duration}}\",timeLeft:\"{{duration}} rimanenti\",day:\"1 giorno\",days:\"{{count}} giorni\",week:\"1 settimana\",weeks:\"{{count}} settimane\",month:\"1 mese\",months:\"{{count}} mesi\",year:\"1 anno\",maintenanceTypes:\"Tipi di manutenzione\",maintenanceTypesDescription:\"Tipi di sistema e tue attivita personalizzate\",addCustomType:\"Aggiungi tipo personalizzato\",restoreDefaults:\"Ripristina attivita predefinite\",intervalType:\"Tipo intervallo\",intervalValue:\"Intervallo ({{type}})\",icon:\"Icona\",documentationLink:\"Link documentazione (opzionale)\",assignToPrinters:\"Assegna alle stampanti\",selectAtLeastOnePrinter:\"Seleziona almeno una stampante\",addType:\"Aggiungi tipo\",custom:\"Personalizzato\",printHours:\"Ore di stampa\",calendarDays:\"Giorni calendario\",exampleName:\"es. Sostituisci filtro HEPA\",viewDocumentation:\"Vedi documentazione\",timeBasedInterval:\"Intervallo basato sul tempo\",intervalOverrides:\"Override intervallo\",intervalOverridesDescription:\"Personalizza intervalli per stampanti specifiche\",assignedToPrinters:\"Assegnato alle stampanti:\",noPrintersAssigned:\"Nessuna stampante assegnata\",addPrinterShort:\"Aggiungi:\",printersAssignedClick:\"{{count}} stampante(i) assegnata - clicca per gestire\",removeFromPrinter:\"Rimuovi da questa stampante\",types:{lubricateCarbonRods:\"Lubrifica aste in carbonio\",lubricateRails:\"Lubrifica guide lineari\",cleanNozzle:\"Pulisci ugello/Hotend\",checkBelts:\"Controlla tensione cinghie\",cleanBuildPlate:\"Pulisci piatto\",checkExtruder:\"Controlla ingranaggi estrusore\",checkCooling:\"Controlla ventole raffreddamento\",generalInspection:\"Ispezione generale\",cleanCarbonRods:\"Pulisci aste in carbonio\",lubricateSteelRods:\"Lubrifica aste in acciaio\",cleanSteelRods:\"Pulisci aste in acciaio\",cleanLinearRails:\"Pulisci guide lineari\",checkPtfeTube:\"Controlla tubo PTFE\",replaceHepaFilter:\"Sostituisci filtro HEPA\",replaceCarbonFilter:\"Sostituisci filtro carbone\",lubricateLeftNozzleRail:\"Lubrifica guida ugello sinistro\"},maintenanceComplete:\"Manutenzione segnata come completata\",typeUpdated:\"Tipo manutenzione aggiornato\",typeDeleted:\"Tipo manutenzione eliminato\",defaultsRestored:\"Ripristinate {{count}} attivita predefinite\",printHoursUpdated:\"Ore di stampa aggiornate\",printerAssigned:\"Stampante assegnata\",printerRemoved:\"Stampante rimossa\",deleteTypeConfirm:'Eliminare \"{{name}}\"?',deleteSystemTypeTitle:\"Eliminare attività di manutenzione predefinita?\",deleteSystemTypeMessage:`Sei sicuro di voler eliminare l'attività di manutenzione predefinita \"{{name}}\"?`,noPermissionUpdate:\"Non hai il permesso di aggiornare elementi manutenzione\",noPermissionPerform:\"Non hai il permesso di eseguire manutenzione\",noPermissionEditTypes:\"Non hai il permesso di modificare tipi manutenzione\",noPermissionDeleteTypes:\"Non hai il permesso di eliminare tipi manutenzione\",noPermissionEditHours:\"Non hai il permesso di modificare ore stampa\",noPermissionRemovePrinter:\"Non hai il permesso di rimuovere assegnazioni stampanti\",noPermissionAssignPrinter:\"Non hai il permesso di assegnare stampanti\",noPermissionEditIntervals:\"Non hai il permesso di modificare intervalli\",configureSettings:\"Configura tipi e intervalli manutenzione\"},settings:{title:\"Impostazioni\",general:\"Generale\",tabs:{general:\"Generale\",smartPlugs:\"Prese smart\",notifications:\"Notifiche\",queue:\"Workflow\",filament:\"Filamento\",network:\"Rete\",apiKeys:\"Chiavi API\",virtualPrinter:\"Stampante virtuale\",failureDetection:\"Rilevamento guasti\",users:\"Utenti\",backup:\"Backup\",emailAuth:\"Autenticazione Email\",ldap:\"LDAP\",twoFa:\"Autenticazione 2FA\",oidc:\"SSO / OIDC\"},ldap:{title:\"Autenticazione LDAP\",enabledDesc:\"L'autenticazione LDAP è attiva\",disabledDesc:\"L'autenticazione LDAP è disattivata\",disabledHint:\"Configura e salva le impostazioni LDAP qui sotto, poi attiva.\",enabled:\"Autenticazione LDAP attivata\",disabled:\"Autenticazione LDAP disattivata\",feature1:\"Gli utenti possono accedere con le credenziali LDAP\",feature2:\"L'account amministratore locale rimane come fallback\",feature3:\"I gruppi LDAP vengono mappati ai gruppi BamBuddy al login\",serverConfig:\"Configurazione server LDAP\",serverUrl:\"URL del server\",serverUrlHint:\"Usa ldap:// per standard o ldaps:// per connessioni SSL\",security:\"Sicurezza\",securityHint:\"StartTLS aggiorna una connessione semplice a TLS. LDAPS usa TLS fin dall'inizio.\",bindDn:\"Bind DN (account di servizio)\",bindPassword:\"Password Bind\",searchBase:\"Base DN di ricerca\",userFilter:\"Filtro di ricerca utente\",userFilterHint:\"{username} viene sostituito con il nome utente. Usa (uid={username}) per OpenLDAP.\",autoProvision:\"Provisioning automatico utenti\",autoProvisionHint:\"Crea automaticamente un account BamBuddy al primo accesso LDAP\",defaultGroup:\"Gruppo predefinito\",defaultGroupNone:\"— Nessuno (nessun fallback) —\",defaultGroupHint:\"Gruppo di fallback assegnato quando un utente LDAP si autentica ma non è presente in nessun gruppo LDAP mappato. Lascia vuoto per lasciare gli utenti non mappati senza autorizzazioni.\",groupMapping:\"Mappatura gruppi (JSON)\",groupMappingHint:\"Mappa i DN dei gruppi LDAP ai gruppi BamBuddy. Gruppi disponibili: \",testConnection:\"Test connessione\",settingsSaved:\"Impostazioni LDAP salvate\",errors:{serverRequired:\"L'URL del server LDAP è obbligatorio\",searchBaseRequired:\"Il Base DN di ricerca è obbligatorio\",enableAuthFirst:\"Attiva prima l'autenticazione\",configureLdapFirst:\"Salva prima le impostazioni LDAP\"}},email:{smtpSettings:\"Configurazione SMTP\",smtpHost:\"Server SMTP\",smtpPort:\"Porta SMTP\",security:\"Sicurezza\",authentication:\"Autenticazione\",username:\"Nome utente\",password:\"Password\",fromEmail:\"Email mittente\",fromName:\"Nome mittente\",testConnection:\"Testa connessione SMTP\",testRecipient:\"Email destinatario test\",sendTest:\"Invia email di test\",sending:\"Invio...\",save:\"Salva impostazioni\",saving:\"Salvataggio...\",advancedAuth:\"Autenticazione avanzata\",advancedAuthEnabled:\"L'autenticazione avanzata è abilitata\",advancedAuthEnabledDesc:\"Le funzionalità di gestione utenti via email sono attive. I nuovi utenti riceveranno password generate automaticamente via email e potranno reimpostare la password tramite la funzione di recupero.\",advancedAuthDisabled:\"L'autenticazione avanzata è disabilitata\",advancedAuthDisabledDesc:\"Abilita l'autenticazione avanzata per attivare le funzionalità email per la gestione utenti.\",enable:\"Abilita\",disable:\"Disabilita\",feature1:\"Le password vengono generate automaticamente e inviate via email ai nuovi utenti\",feature2:\"Gli utenti possono accedere con nome utente o email\",feature3:\"La funzione di recupero password è disponibile\",feature4:\"Gli amministratori possono reimpostare le password utente via email\",errors:{requiredFields:\"Compilare tutti i campi obbligatori\",usernameRequired:\"Il nome utente è obbligatorio quando l'autenticazione è abilitata\",enterTestEmail:\"Inserire un indirizzo email di test\",smtpServerAndEmail:\"Compilare Server SMTP e Email mittente prima di testare\",usernamePasswordRequired:\"Nome utente e password sono obbligatori quando l'autenticazione è abilitata\",configureSmtpFirst:\"Configurare e testare le impostazioni SMTP prima\",enableAuthFirst:\"Per utilizzare le funzionalità basate sulla posta elettronica, è necessario prima abilitare l'autenticazione.\"},success:{settingsSaved:\"Impostazioni SMTP salvate con successo\"},securityOptions:{starttls:\"STARTTLS (Porta 587)\",ssl:\"SSL/TLS (Porta 465)\",none:\"Nessuna (Porta 25)\"},authOptions:{enabled:\"Abilitata\",disabled:\"Disabilitata\"}},appearance:\"Aspetto\",notifications:\"Notifiche\",smartPlugs:\"Prese smart\",spoolman:\"Spoolman\",updates:\"Aggiornamenti\",language:\"Lingua\",languageDescription:\"Seleziona la lingua preferita\",theme:\"Tema\",themeLight:\"Chiaro\",themeDark:\"Scuro\",themeSystem:\"Sistema\",defaultView:\"Vista predefinita\",defaultViewDescription:\"Pagina da mostrare all'apertura dell'app\",checkForUpdates:\"Controlla aggiornamenti\",autoUpdate:\"Aggiornamento automatico\",currentVersion:\"Versione attuale\",latestVersion:\"Ultima versione\",upToDate:\"Sei aggiornato\",updateAvailable:\"Aggiornamento disponibile\",notificationLanguage:\"Lingua notifiche\",notificationLanguageDescription:\"Lingua per notifiche push\",bedCooledThreshold:\"Soglia raffreddamento piatto\",bedCooledThresholdDescription:\"Temperatura sotto la quale il piatto è considerato raffreddato dopo una stampa\",userNotificationsEnabled:\"Notifiche utente\",userNotificationsEnabledDescription:\"Abilita il menu notifiche utente e le notifiche e-mail per gli eventi di stampa. Richiede l'autenticazione avanzata.\",userNotificationsDisabledHint:\"Abilita l'autenticazione avanzata per usare le notifiche utente.\",notificationProviders:\"Provider notifiche\",addProvider:\"Aggiungi provider\",editProvider:\"Modifica provider\",providerType:\"Tipo provider\",testNotification:\"Notifica di test\",testSuccess:\"Notifica di test inviata\",testFailed:\"Invio notifica di test fallito\",quietHours:\"Ore silenziose\",quietHoursDescription:\"Non disturbare in queste ore\",quietHoursStart:\"Inizio\",quietHoursEnd:\"Fine\",events:{title:\"Eventi notifica\",printStart:\"Stampa avviata\",printComplete:\"Stampa completata\",printFailed:\"Stampa fallita\",printStopped:\"Stampa interrotta\",printProgress:\"Avanzamento\",printProgressDescription:\"Notifica al 25%, 50%, 75%\",printerOffline:\"Stampante offline\",printerError:\"Errore stampante\",filamentLow:\"Filamento in esaurimento\",maintenanceDue:\"Manutenzione dovuta\",maintenanceDueDescription:\"Notifica quando serve manutenzione\"},smartPlug:{title:\"Prese smart\",add:\"Aggiungi presa smart\",edit:\"Modifica presa smart\",name:\"Nome\",ipAddress:\"Indirizzo IP\",linkedPrinter:\"Stampante collegata\",autoOn:\"Accensione automatica\",autoOnDescription:\"Accendi all'avvio stampa\",autoOff:\"Spegnimento automatico\",autoOffDescription:\"Spegni dopo il completamento\",offDelay:\"Ritardo spegnimento\",offDelayMinutes:\"Minuti dopo la stampa\",offDelayTemp:\"Quando ugello sotto temperatura\",currentState:\"Stato attuale\",turnOn:\"Accendi\",turnOff:\"Spegni\"},filamentTracking:\"Tracciamento filamento\",filamentTrackingDesc:\"Scegli come tracciare le bobine di filamento. Puoi usare l'inventario integrato o collegare un server Spoolman esterno.\",filamentChecks:\"Controlli filamento\",disableFilamentWarnings:\"Disabilita avvisi filamento\",disableFilamentWarningsDesc:\"Non mostrare avvisi per filamento insufficiente durante la stampa o l'accodamento\",preferLowestFilament:\"Preferisci il filamento con meno residuo\",preferLowestFilamentDesc:\"Quando più bobine corrispondono, usa quella con meno filamento rimanente\",trackingModeBuiltIn:\"Inventario integrato\",trackingModeBuiltInDesc:\"Riconoscimento RFID automatico e tracciamento dell'uso inclusi\",trackingModeSpoolmanDesc:\"Server esterno per la gestione del filamento\",builtInFeatureRfid:\"Rileva automaticamente le bobine Bambu Lab RFID nell'AMS\",builtInFeatureUsage:\"Traccia il consumo di filamento per stampa\",builtInFeatureCatalog:\"Gestisci bobine, colori e profili K-factor\",builtInFeatureThirdParty:\"Le bobine di terze parti possono essere assegnate alle bobine dell'inventario\",amsSyncButton:\"Sincronizza pesi dall'AMS\",amsSyncTitle:\"Sincronizza pesi bobine dall'AMS\",amsSyncMessage:\"Questo sovrascriverà tutti i pesi delle bobine dell'inventario con i valori attuali di percentuale rimanente dell'AMS dalle stampanti connesse. Usa questa funzione per recuperare dati di peso corrotti. Le stampanti devono essere online.\",amsSyncing:\"Sincronizzazione...\",amsSyncSuccess:\"{{synced}} bobina/e sincronizzata/e, {{skipped}} saltata/e\",amsSyncError:\"Impossibile sincronizzare i pesi dall'AMS\",spoolmanUrl:\"URL Spoolman\",spoolmanUrlHint:\"URL del server Spoolman (es. http://localhost:7912)\",spoolmanConnected:\"Connesso\",spoolmanDisconnected:\"Disconnesso\",status:\"Stato\",connect:\"Connetti\",disconnect:\"Disconnetti\",howSyncWorks:\"Come funziona la sincronizzazione\",syncInfoRfidOnly:\"Solo le bobine ufficiali Bambu Lab con RFID vengono sincronizzate\",syncInfoAutoCreate:\"Le nuove bobine vengono create automaticamente in Spoolman alla prima sincronizzazione\",syncInfoThirdPartySkipped:\"Le bobine non Bambu Lab (terze parti, ricaricate) vengono saltate\",linkingExistingSpools:\"Collegamento bobine esistenti\",linkingExistingSpoolsDesc:`Per collegare le bobine Spoolman esistenti all'AMS, passa il mouse su uno slot AMS e clicca \"Collega a Spoolman\".`,syncMode:\"Modalità sincronizzazione\",syncModeAuto:\"Automatica\",syncModeManual:\"Solo manuale\",syncModeAutoDesc:\"I dati AMS si sincronizzano automaticamente quando vengono rilevate modifiche\",syncModeManualDesc:\"Sincronizzazione solo quando attivata manualmente\",syncAmsData:\"Sincronizza dati AMS\",syncAmsDataDesc:\"Sincronizza manualmente i dati AMS della stampante su Spoolman\",allPrinters:\"Tutte le stampanti\",noDefaultPrinter:\"Nessuna predefinita (chiedi ogni volta)\",sidebarOrder:\"Ordine barra laterale\",saveThumbnails:\"Salva miniature\",captureFinishPhoto:\"Acquisisci foto finale\",noPrintersConfigured:\"Nessuna stampante configurata\",archiveMode:{always:\"Crea sempre voce archivio\",never:\"Non creare mai voce archivio\",ask:\"Chiedi ogni volta\"},checkForUpdatesLabel:\"Controlla aggiornamenti\",checkPrinterFirmware:\"Controlla firmware stampante\",includeBetaUpdates:\"Includi versioni beta\",includeBetaUpdatesDesc:\"Notifica versioni beta e prerelease durante il controllo aggiornamenti\",enableRetry:\"Abilita retry\",homeAssistantDescription:\"Controlla prese smart tramite Home Assistant\",environmentManagedLabel:\"(Gestito dall'ambiente)\",autoEnabledViaEnv:\"Abilitato automaticamente tramite variabili d'ambiente\",urlFromEnvReadOnly:\"Valore impostato dalla variabile d'ambiente HA_URL (sola lettura)\",tokenFromEnvReadOnly:\"Valore impostato dalla variabile d'ambiente HA_TOKEN (sola lettura)\",mqttConnectedTo:\"Connesso a\",prometheusDescription:\"Esponi dati stampante in formato Prometheus\",noSmartPlugsTitle:\"Nessuna presa smart configurata\",noSmartPlugsDescription:\"Aggiungi una presa smart Tasmota per monitorare energia e automatizzare il controllo.\",noProvidersTitle:\"Nessun provider configurato\",noProvidersDescription:\"Aggiungi un provider per ricevere avvisi.\",noTemplatesAvailable:\"Nessun template disponibile. Riavvia il backend per generare i template predefiniti.\",apiPermissionView:\"Visualizza stato stampante e coda\",apiPermissionEdit:\"Aggiungi e rimuovi elementi dalla coda di stampa\",apiKeysEmptyTitle:\"Nessuna chiave API\",apiKeysEmptyDescription:\"Crea una chiave API per integrare servizi esterni.\",noUsersFound:\"Nessun utente trovato\",noGroupsFound:\"Nessun gruppo trovato\",noGroupsAvailable:\"Nessun gruppo disponibile\",passwordsDoNotMatch:\"Le password non coincidono\",systemGroupWarning:\"I nomi dei gruppi di sistema non possono essere modificati\",authDisabledTitle:\"Autenticazione disabilitata\",authDisabledFeature1:\"Richiedi accesso per usare il sistema\",authDisabledFeature2:\"Crea più utenti con permessi basati sui gruppi\",authDisabledFeature3:\"Controlla accesso con 50+ permessi granulari\",userHasCreated:\"Questo utente ha creato:\",userItemsQuestion:\"Cosa vuoi fare con questi elementi?\",deleteUserConfirm:\"Sei sicuro di voler eliminare questo utente?\",actionCannotBeUndone:\"Questa azione non può essere annullata.\",addFirstSmartPlug:\"Aggiungi la tua prima presa smart\",providers:\"Provider\",log:\"Log\",testAll:\"Testa tutto\",testResults:\"Risultati test\",testPassedCount:\"{{count}} riusciti\",testFailedCount:\"{{count}} falliti\",messageTemplates:\"Template messaggi\",messageTemplatesDescription:\"Personalizza i messaggi per ogni evento.\",apiKeys:\"Chiavi API\",apiKeysDescription:\"Crea chiavi API per integrazioni esterne e webhook.\",createKey:\"Crea chiave\",apiKeyCreated:\"Chiave API creata con successo\",apiKeyCopyWarning:\"Copia questa chiave ora - non verra mostrata di nuovo!\",useInApiBrowser:\"Usa nel Browser API\",createNewApiKey:\"Crea nuova chiave API\",keyName:\"Nome chiave\",keyNamePlaceholder:\"es., Home Assistant, OctoPrint\",readStatus:\"Leggi stato\",readStatusDescription:\"Visualizza stato stampante e coda\",manageQueue:\"Gestisci coda\",manageQueueDescription:\"Aggiungi e rimuovi elementi dalla coda di stampa\",controlPrinter:\"Controlla stampante\",controlPrinterDescription:\"Metti in pausa, riprendi e ferma stampe\",unnamedKey:\"Chiave senza nome\",lastUsed:\"Ultimo uso\",read:\"Lettura\",control:\"Controllo\",createFirstKey:\"Crea la tua prima chiave\",webhookEndpoints:\"Endpoint webhook\",webhookApiKeyHint:\"Usa la tua chiave API nell'header X-API-Key.\",webhook:{getAllStatus:\"Ottieni stato di tutte le stampanti\",getSpecificStatus:\"Ottieni stato di una stampante\",addToQueue:\"Aggiungi alla coda di stampa\",pausePrint:\"Metti in pausa stampa\",resumePrint:\"Riprendi stampa\",stopPrint:\"Ferma stampa\"},apiBrowser:\"Browser API\",apiBrowserDescription:\"Esplora e testa tutti gli endpoint API disponibili.\",apiKeyForTesting:\"Chiave API per test\",apiKeyPlaceholder:\"Incolla qui la tua chiave API per testare gli endpoint autenticati...\",apiKeyHint:\"Questa chiave verra inviata come header X-API-Key.\",deleteApiKeyTitle:\"Elimina chiave API\",deleteApiKeyMessage:\"Sei sicuro di voler eliminare questa chiave API? Le integrazioni che la usano non funzioneranno più.\",deleteKey:\"Elimina chiave\",amsDisplayThresholds:\"Soglie visualizzazione AMS\",amsThresholdsDescription:\"Configura soglie colore per umidità e temperatura AMS.\",humidity:\"Umidità\",goodGreen:\"Buono (verde)\",fairOrange:\"Discreto (arancione)\",aboveFairBad:\"Sopra soglia discreta mostra rosso (scarso)\",fairAlsoDryingThreshold:\"Questa soglia viene usata anche per attivare l'asciugatura automatica\",temperature:\"Temperatura\",goodBlue:\"Buono (blu)\",aboveFairHot:\"Sopra soglia discreta mostra rosso (caldo)\",historyRetention:\"Conservazione cronologia\",keepSensorHistory:\"Mantieni cronologia sensori per\",historyRetentionDescription:\"I dati più vecchi saranno eliminati automaticamente\",defaultPrintOptions:\"Opzioni di stampa predefinite\",defaultPrintOptionsDescription:\"Imposta i valori predefiniti per le opzioni di stampa. Possono essere modificati nella finestra di stampa.\",defaultBedLevelling:\"Livellamento piatto\",defaultBedLevellingDesc:\"Livellamento automatico del piatto prima della stampa\",defaultFlowCali:\"Calibrazione flusso\",defaultFlowCaliDesc:\"Calibra il flusso di estrusione\",defaultVibrationCali:\"Calibrazione vibrazioni\",defaultVibrationCaliDesc:\"Riduce gli artefatti di ringing\",defaultLayerInspect:\"Ispezione primo strato\",defaultLayerInspectDesc:\"Ispezione IA del primo strato\",defaultTimelapse:\"Timelapse\",defaultTimelapseDesc:\"Registra un video timelapse\",staggeredStart:\"Staggered Start\",staggeredStartDescription:\"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.\",plateClear:\"Conferma piatto libero\",requirePlateClear:\"Richiedi conferma piatto libero\",requirePlateClearDescription:'Quando questa opzione è abilitata, lo scheduler attende una conferma per stampante che il piatto sia libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitandola vengono nascosti anche il badge di stato del piatto e il pulsante \"Segna il piatto come liberato\" sulle schede stampante.',gcodeInjection:\"Iniezione G-code\",gcodeInjectionDescription:`Configura G-code personalizzato da iniettare all'inizio e/o alla fine delle stampe per sistemi di stampa automatica come Farmloop, SwapMod, AutoClear e Printflow 3D. Gli snippet sono configurati per modello di stampante e applicati quando \"Inietta G-code\" è abilitato su un elemento della coda.`,gcodeInjectionNoPrinters:\"Nessuna stampante trovata. Aggiungi stampanti per configurare gli snippet G-code.\",gcodeStartLabel:\"G-code iniziale\",gcodeEndLabel:\"G-code finale\",gcodeStartPlaceholder:\"G-code inserito prima dell'inizio della stampa...\",gcodeEndPlaceholder:\"G-code aggiunto dopo la fine della stampa...\",staggerGroupSize:\"Group size\",staggerGroupSizeHelp:\"Printers to start simultaneously per group\",staggerInterval:\"Interval (minutes)\",staggerIntervalHelp:\"Delay between each group starting\",queueDrying:\"Asciugatura automatica\",queueDryingDescription:\"Asciugare automaticamente il filamento AMS quando la stampante è inattiva tra le stampe in coda. Usa la soglia di umidità sopra.\",queueDryingEnabled:\"Abilita asciugatura automatica\",queueDryingEnabledDescription:\"Avvia l'asciugatura AMS automaticamente quando la stampante è inattiva e l'umidità supera la soglia\",queueDryingBlock:\"Attendi completamento asciugatura\",queueDryingBlockDescription:\"Blocca la coda di stampa fino al completamento dell'asciugatura. Se disattivato, le stampe hanno priorità.\",ambientDryingEnabled:\"Asciugatura ambientale\",ambientDryingEnabledDescription:\"Asciuga automaticamente il filamento sulle stampanti inattive quando l'umidità supera la soglia, anche senza stampe in coda.\",dryingPresets:\"Preset di asciugatura\",dryingPresetsDescription:\"Temperatura e durata per tipo di filamento. AMS 2 Pro usa temperature più basse, AMS-HT supporta temperature più alte.\",dryingFilament:\"Filamento\",printModal:\"Modale stampa\",expandCustomMapping:\"Espandi mapping personalizzato di default\",expandCustomMappingDescription:\"Quando stampi su più stampanti, mostra mapping AMS per stampante espanso\",authentication:\"Autenticazione\",authEnabledDescription:\"La tua istanza è protetta con autenticazione\",authDisabledDescription:\"Abilita per richiedere accesso e gestire utenti\",authDisabledMessage:\"Abilita autenticazione per creare account, gestire permessi e proteggere la tua istanza Bambuddy.\",enableAuthentication:\"Abilita autenticazione\",currentUser:\"Utente corrente\",changePassword:\"Cambia password\",admin:\"Admin\",users:\"Utenti\",addUser:\"Aggiungi utente\",groups:\"Gruppi\",addGroup:\"Aggiungi gruppo\",system:\"Sistema\",noDescription:\"Nessuna descrizione\",userCount:\"{{count}} utenti\",permissionCount:\"{{count}} permessi\",createUser:\"Crea utente\",username:\"Nome utente\",enterUsername:\"Inserisci nome utente\",password:\"Password\",enterPassword:\"Inserisci password (min 6 caratteri)\",confirmPassword:\"Conferma password\",confirmPasswordPlaceholder:\"Conferma password\",viewReleaseOnGitHub:\"Vedi release su GitHub\",turnAllPlugsOn:\"Accendi tutte le prese\",turnAllPlugsOff:\"Spegni tutte le prese\",clearNotificationLogs:\"Cancella log notifiche\",clearLogsMessage:\"Questo eliminerà definitivamente tutti i log notifiche più vecchi di 30 giorni. Questa azione non può essere annullata.\",clearLogs:\"Cancella log\",resetUiPreferences:\"Reimposta preferenze UI\",resetUiPreferencesMessage:\"Questo reimposterà le preferenze UI ai valori predefiniti: ordine barra laterale, tema, layout dashboard, modalità vista e preferenze ordinamento. Stampanti, archivi e impostazioni server NON saranno modificati. La pagina si ricaricherà dopo la cancellazione.\",resetPreferences:\"Reimposta preferenze\",deleteGroupTitle:\"Elimina gruppo\",deleteGroupMessage:\"Sei sicuro di voler eliminare questo gruppo? Gli utenti in questo gruppo perderanno questi permessi.\",deleteGroup:\"Elimina gruppo\",disableAuthenticationTitle:\"Disabilita autenticazione\",disableAuthenticationMessage:\"Sei sicuro di voler disabilitare l'autenticazione? Questo renderà la tua istanza Bambuddy accessibile senza login. Tutti gli utenti resteranno nel database ma l'autenticazione sarà disabilitata.\",disableAuthentication:\"Disabilita autenticazione\",configureBambuddy:\"Configura Bambuddy\",systemDefault:\"Predefinito di sistema\",archiveSettings:\"Impostazioni archivio\",newWindow:\"Nuova finestra\",embeddedOverlay:\"Overlay incorporato\",preferredSlicer:\"Slicer preferito\",preferredSlicerDescription:\"Scegli quale applicazione slicer usare per aprire i file\",externalCameras:\"Camere esterne\",costTracking:\"Tracciamento costi\",printsOnly:\"Solo stampe\",totalConsumption:\"Consumo totale\",dataManagement:\"Gestione dati\",storageUsage:\"Memoria utilizzata\",storageUsageDescription:\"Ripartizione della memoria per categoria\",storageUsageTotal:\"Totale\",storageUsageErrors:\"Errori\",storageUsageOtherBreakdown:\"Altro (include risorse statiche, script e file di configurazione)\",storageUsageSystem:\"Sistema\",storageUsageData:\"Dati\",storageUsageUnavailable:\"Informazioni sull'utilizzo della memoria non disponibili\",clearNotificationLogsDescription:\"Elimina log notifiche più vecchi di 30 giorni\",resetUiPreferencesDescription:\"Reimposta ordine barra laterale, tema, modalità vista e preferenze layout. Stampanti, archivi e impostazioni non vengono modificati.\",enableHomeAssistant:\"Abilita Home Assistant\",enableMqtt:\"Abilita MQTT\",useTls:\"Usa TLS\",enableMetricsEndpoint:\"Abilita endpoint metriche\",availableMetrics:\"Metriche disponibili\",editUser:\"Modifica utente\",deleteUserTitle:\"Elimina utente\",groupName:\"Nome gruppo\",leaveEmptyForAnonymous:\"Lascia vuoto per anonimo\",leaveEmptyForNoAuth:\"Lascia vuoto per nessuna autenticazione\",enterNewPassword:\"Inserisci nuova password\",confirmNewPassword:\"Conferma nuova password\",enterGroupName:\"Inserisci nome gruppo\",enterDescriptionOptional:\"Inserisci descrizione (opzionale)\",enterCurrentPassword:\"Inserisci password attuale\",enterNewPasswordMin6:\"Inserisci nuova password (min 6 caratteri)\",toast:{keyCopied:\"Chiave copiata negli appunti\",copyFailed:\"Copia chiave fallita\",keyAddedToBrowser:\"Chiave aggiunta al Browser API\",clearLogsFailed:\"Eliminazione log fallita\",uiPreferencesReset:\"Preferenze UI reimpostate. Aggiornamento...\",authDisabled:\"Autenticazione disabilitata con successo\",authDisableFailed:\"Disabilitazione autenticazione fallita\",apiKeyCreated:\"Chiave API creata\",apiKeyDeleted:\"Chiave API eliminata\",userCreated:\"Utente creato con successo\",userUpdated:\"Utente aggiornato con successo\",userDeleted:\"Utente eliminato con successo\",groupCreated:\"Gruppo creato con successo\",groupUpdated:\"Gruppo aggiornato con successo\",groupDeleted:\"Gruppo eliminato con successo\",fillRequiredFields:\"Compila tutti i campi obbligatori\",passwordsDoNotMatch:\"Le password non coincidono\",passwordTooShort:\"La password deve essere di almeno 6 caratteri\",enterGroupName:\"Inserisci un nome gruppo\",settingsSaved:\"Impostazioni salvate\",cameraSettingsSaved:\"Impostazioni camera salvate\",enterCameraUrl:\"Inserisci un URL camera\",passwordChanged:\"Password cambiata con successo\",connectionFailed:\"Connessione fallita\",testFailed:\"Test fallito\",cameraConnected:\"Camera connessa{{resolution}}\"},testConnection:\"Testa connessione\",catalog:{spoolCatalog:\"Catalogo bobine\",spoolCatalogDescription:\"Pesi delle bobine vuote per marca/tipo. Utilizzato per la ricerca automatica del peso quando si aggiungono bobine.\",searchCatalog:\"Cerca nel catalogo...\",addNewEntry:\"Aggiungi nuova voce\",namePlaceholder:\"Nome (es. Bambu Lab - Plastica)\",weight:\"Peso\",type:\"Tipo\",default:\"Predefinito\",custom:\"Personalizzato\",noMatch:\"Nessuna voce corrisponde alla ricerca\",empty:\"Nessuna voce nel catalogo\",deleteEntry:\"Elimina voce\",deleteConfirm:'Sei sicuro di voler eliminare \"{{name}}\"?',resetCatalog:\"Ripristina catalogo\",resetConfirm:\"Ripristinare il catalogo ai valori predefiniti? Tutte le voci personalizzate verranno rimosse.\",loadFailed:\"Impossibile caricare il catalogo bobine\",nameWeightRequired:\"Nome e peso sono obbligatori\",entryAdded:\"Voce aggiunta\",addFailed:\"Impossibile aggiungere la voce\",entryUpdated:\"Voce aggiornata\",updateFailed:\"Impossibile aggiornare la voce\",entryDeleted:\"Voce eliminata\",deleteFailed:\"Impossibile eliminare la voce\",resetSuccess:\"Catalogo ripristinato ai valori predefiniti\",resetFailed:\"Impossibile ripristinare il catalogo\",exported:\"{{count}} voci esportate\",imported:\"{{added}} voci importate ({{skipped}} saltate)\",importFailed:\"Impossibile importare: formato JSON non valido\",exportTooltip:\"Esporta catalogo in JSON\",importTooltip:\"Importa catalogo da JSON\",resetTooltip:\"Ripristina valori predefiniti\",selectedCount:\"{{count}} selezionati\",deleteSelected:\"Elimina selezionati\",bulkDeleteConfirm:\"Eliminare {{count}} voci?\",bulkDeleted:\"{{count}} voci eliminate\",bulkDeleteFailed:\"Impossibile eliminare le voci\"},colorCatalog:{title:\"Catalogo colori\",description:\"Colori del filamento per produttore/materiale. Utilizzato per la ricerca automatica del colore quando si aggiungono bobine.\",searchColors:\"Cerca colori...\",allManufacturers:\"Tutti i produttori\",addNewColor:\"Aggiungi nuovo colore\",manufacturer:\"Produttore\",colorName:\"Nome colore\",hex:\"Hex\",materialOptional:\"Materiale (opzionale)\",showing:\"Visualizzazione di {{filtered}} su {{total}} colori\",noMatch:\"Nessun colore corrisponde alla ricerca\",empty:\"Nessun colore nel catalogo\",deleteColor:\"Elimina colore\",deleteConfirm:'Sei sicuro di voler eliminare \"{{name}}\"?',resetCatalog:\"Ripristina catalogo colori\",resetConfirm:\"Ripristinare il catalogo ai valori predefiniti? Tutti i colori personalizzati verranno rimossi.\",sync:\"Sincronizza\",starting:\"Avvio...\",syncTooltip:\"Sincronizza da FilamentColors.xyz (2000+ colori, potrebbe richiedere un minuto)\",loadFailed:\"Impossibile caricare il catalogo colori\",fieldsRequired:\"Produttore, nome colore e colore hex sono obbligatori\",colorAdded:\"Colore aggiunto\",addFailed:\"Impossibile aggiungere il colore\",colorUpdated:\"Colore aggiornato\",updateFailed:\"Impossibile aggiornare il colore\",colorDeleted:\"Colore eliminato\",deleteFailed:\"Impossibile eliminare il colore\",resetSuccess:\"Catalogo colori ripristinato ai valori predefiniti\",resetFailed:\"Impossibile ripristinare il catalogo\",syncUpToDate:\"Già aggiornato ({{count}} colori verificati)\",syncComplete:\"{{added}} nuovi colori aggiunti ({{skipped}} già esistenti)\",syncError:\"Errore di sincronizzazione\",syncFailed:\"Impossibile sincronizzare da FilamentColors.xyz\",exported:\"{{count}} colori esportati\",imported:\"{{added}} colori importati ({{skipped}} saltati)\",importFailed:\"Impossibile importare: formato JSON non valido\",selectedCount:\"{{count}} selezionati\",deleteSelected:\"Elimina selezionati\",bulkDeleteConfirm:\"Eliminare {{count}} colori?\",bulkDeleted:\"{{count}} colori eliminati\",bulkDeleteFailed:\"Impossibile eliminare i colori\"},dateFormat:\"Formato data\",dateFormatUs:\"US (MM/GG/AAAA)\",dateFormatEu:\"EU (GG/MM/AAAA)\",dateFormatIso:\"ISO (AAAA-MM-GG)\",timeFormat:\"Formato ora\",timeFormat12:\"12 ore (3:30 PM)\",timeFormat24:\"24 ore (15:30)\",defaultPrinter:\"Stampante predefinita\",defaultPrinterDescription:\"Preseleziona questa stampante per upload, ristampe e altre operazioni.\",slicerBambuStudio:\"Bambu Studio\",slicerOrcaSlicer:\"OrcaSlicer\",sidebarOrderDescription:\"Trascina gli elementi nella barra laterale per riordinare. Ripristina l'ordine predefinito qui.\",setDefault:\"Imposta predefinito\",sidebarOrderSetDefaultHint:\"Imposta predefinito applica l'ordine attuale del menu agli utenti che non hanno ancora personalizzato il proprio.\",sidebarDefaultSet:\"L'ordine predefinito del menu è stato impostato.\",sidebarDefaultCleared:\"Ordine predefinito del menu cancellato.\",sidebarDefaultFailed:\"Impossibile impostare l'ordine predefinito del menu.\",reset:\"Ripristina\",darkMode:\"Modalità scura\",lightMode:\"Modalità chiara\",active:\"(attivo)\",background:\"Sfondo\",accent:\"Accento\",style:\"Stile\",bgNeutral:\"Neutro\",bgWarm:\"Caldo\",bgCool:\"Freddo\",bgOled:\"OLED Nero\",bgSlate:\"Blu ardesia\",bgForest:\"Verde foresta\",accentGreen:\"Verde\",accentTeal:\"Verde acqua\",accentBlue:\"Blu\",accentOrange:\"Arancione\",accentPurple:\"Viola\",accentRed:\"Rosso\",styleClassic:\"Classico\",styleGlow:\"Luminoso\",styleVibrant:\"Vibrante\",themeToggleHint:\"Passa tra modalità scura e chiara con l'icona sole/luna nella barra laterale.\",autoArchivePrints:\"Archiviazione automatica stampe\",autoArchiveDescription:\"Salva automaticamente i file 3MF al completamento delle stampe\",saveThumbnailsDescription:\"Estrai e salva le immagini di anteprima dai file 3MF\",captureFinishPhotoDescription:\"Scatta una foto dalla fotocamera della stampante al completamento della stampa\",ffmpegNotInstalled:\"ffmpeg non installato\",ffmpegRequired:\"L'acquisizione dalla fotocamera richiede ffmpeg. Installalo tramite <brew>brew install ffmpeg</brew> (macOS) o <apt>apt install ffmpeg</apt> (Linux).\",camera:\"Fotocamera\",cameraViewMode:\"Modalità visualizzazione fotocamera\",cameraOverlayDescription:\"La fotocamera si apre in un overlay ridimensionabile sulla schermata principale\",cameraWindowDescription:\"La fotocamera si apre in una finestra separata del browser\",externalCamerasDescription:\"Configura fotocamere esterne per sostituire la fotocamera integrata della stampante. Supporta stream MJPEG, RTSP, snapshot HTTP e fotocamere USB (V4L2). Quando abilitata, la fotocamera esterna viene usata per la vista in diretta e le foto di completamento.\",cameraPlaceholderUsb:\"Percorso dispositivo (/dev/video0)\",cameraPlaceholderUrl:\"URL fotocamera (rtsp://... o http://...)\",cameraTypeMjpeg:\"Stream MJPEG\",cameraTypeRtsp:\"Stream RTSP\",cameraTypeSnapshot:\"Snapshot HTTP\",cameraTypeUsb:\"Fotocamera USB (V4L2)\",cameraRotation:\"Rotazione\",test:\"Test\",connected:\"Connesso\",disconnected:\"Disconnesso\",currency:\"Valuta\",defaultFilamentCost:\"Costo filamento predefinito (per kg)\",electricityCost:\"Costo elettricità per kWh\",energyDisplayMode:\"Modalità visualizzazione energia\",energyModePrintDescription:\"La dashboard mostra la somma dell'energia usata durante le stampe\",energyModeTotalDescription:\"La dashboard mostra l'energia totale dalle prese smart\",fileManager:\"Gestore file\",createArchiveEntry:\"Crea voce archivio durante la stampa\",createArchiveEntryDescription:\"Quando si stampa dal gestore file, crea opzionalmente una voce di archivio\",lowDiskSpaceWarning:\"Avviso spazio disco insufficiente\",lowDiskSpaceDescription:\"Mostra avviso quando lo spazio disco scende sotto questa soglia\",printerFirmware:\"Firmware stampante\",checkFirmwareDescription:\"Controlla aggiornamenti firmware da Bambu Lab\",bambuddySoftware:\"Software Bambuddy\",autoCheckDescription:\"Controlla automaticamente nuove versioni all'avvio\",checkNow:\"Controlla ora\",updateAvailableVersion:\"Aggiornamento disponibile: v{{version}}\",releaseNotes:\"Note di rilascio\",updateViaDocker:\"Aggiorna tramite Docker Compose:\",installUpdate:\"Installa aggiornamento\",latestVersionRunning:\"Stai usando l'ultima versione\",failedToCheckUpdates:\"Controllo aggiornamenti fallito: {{error}}\",backupRestore:\"Backup e ripristino\",backupRestoreDescription:\"Esporta/importa impostazioni e configura backup GitHub\",goToBackup:\"Vai al backup\",externalUrl:\"URL esterno\",externalUrlDescription:\"L'URL esterno dove Bambuddy è accessibile. Usato per immagini di notifica e integrazioni esterne.\",bambuddyUrl:\"URL Bambuddy\",externalUrlHint:\"Includi protocollo e porta (es. http://192.168.1.100:8000)\",ftpRetry:\"Riprova FTP\",ftpRetryDescription:\"Riprova le operazioni FTP quando il WiFi della stampante è instabile. Si applica a download 3MF, upload stampe, download timelapse e aggiornamenti firmware.\",autoRetryDescription:\"Riprova automaticamente le operazioni FTP fallite\",retryAttempts:\"Tentativi di ripetizione\",retryDelay:\"Ritardo ripetizione\",connectionTimeout:\"Timeout connessione\",time_one:\"{{count}} volta\",time_other:\"{{count}} volte\",second_one:\"{{count}} secondo\",second_other:\"{{count}} secondi\",nSeconds:\"{{count}} secondi\",increaseForWeakWifi:\"Aumenta per stampanti con WiFi debole\",homeAssistant:\"Home Assistant\",homeAssistantFullDescription:\"Connetti a Home Assistant per controllare le prese smart tramite l'API REST HA. Supporta entità switch, light, input_boolean e script.\",homeAssistantUrl:\"URL Home Assistant\",longLivedAccessToken:\"Token di accesso a lunga durata\",haTokenHint:\"Crea un token in HA: Profilo → Token di accesso a lunga durata → Crea token\",connectionSuccessful:\"Connessione riuscita\",connectionFailed:\"Connessione fallita\",haConnectionSuccess:\"Connesso con successo a Home Assistant.\",haConnectionFailed:\"Connessione a Home Assistant fallita.\",mqttPublishing:\"Pubblicazione MQTT\",mqttDescription:\"Pubblica eventi BamBuddy su un broker MQTT esterno per l'integrazione con Node-RED, Home Assistant e altri sistemi di automazione.\",mqttEnableDescription:\"Pubblica eventi su broker MQTT esterno\",brokerHostname:\"Hostname broker\",port:\"Porta\",usernameOptional:\"Nome utente (opzionale)\",passwordOptional:\"Password (opzionale)\",topicPrefix:\"Prefisso topic\",topicPrefixHint:\"I topic saranno: {{prefix}}/printers/<serial>/status, ecc.\",prometheusMetrics:\"Metriche Prometheus\",prometheusEndpointDescription:\"Esponi metriche stampante su <code>/api/v1/metrics</code> per monitoraggio Prometheus/Grafana.\",bearerTokenOptional:\"Token Bearer (opzionale)\",bearerTokenHint:\"Se impostato, le richieste devono includere <code>Authorization: Bearer <token></code>\",metricsConnectionStatus:\"Stato connessione\",metricsPrinterState:\"Stato stampante (idle/printing/ecc)\",metricsPrintProgress:\"Progresso stampa 0-100%\",metricsBedTemp:\"Temperatura piatto\",metricsNozzleTemp:\"Temperatura ugello\",metricsPrintsTotal:\"Stampe totali per risultato\",metricsMore:\"...e altro (strati, ventole, coda, consumo filamento)\",smartPlugsDescription:\"Connetti prese smart (Tasmota o Home Assistant) per automatizzare il controllo dell'alimentazione e monitorare il consumo energetico delle stampanti.\",allOn:\"Tutte accese\",allOff:\"Tutte spente\",addSmartPlug:\"Aggiungi presa smart\",energySummary:\"Riepilogo energia\",currentPower:\"Potenza attuale\",plugsOnline:\"{{reachable}}/{{total}} prese online\",today:\"Oggi\",yesterday:\"Ieri\",total:\"Totale\",enablePlugsForSummary:\"Abilita le prese per vedere il riepilogo energia\",addNotificationProvider:\"Aggiungi\",systemBadge:\"(Sistema)\",creating:\"Creazione...\",changing:\"Modifica...\",deleteUserAndItems:\"Elimina utente E i suoi elementi\",deleteUserKeepItems:\"Elimina utente, mantieni elementi (diventeranno senza proprietario)\",ok:\"OK\",twoFa:{totpTitle:\"App Authenticator (TOTP)\",totpDesc:\"Usa un'app come Google Authenticator, Aegis o Authy.\",emailOtpTitle:\"OTP via e-mail\",emailOtpDesc:\"Invia un codice monouso a {{email}} al momento del login.\",emailOtpNoEmail:\"Aggiungi un indirizzo e-mail al tuo account per abilitare questo metodo.\",addEmailFirst:\"Il tuo account non ha un indirizzo e-mail. Chiedi a un amministratore di aggiungerne uno.\",setupTotp:\"Configura app Authenticator\",setupAuthApp:\"Configura app Authenticator\",setupInstructions:\"Scansiona il codice QR con la tua app authenticator, poi conferma con un codice.\",manualEntry:\"Impossibile scansionare? Inserisci questo segreto manualmente:\",scannedContinue:\"Codice scansionato — continua\",enterCodeToConfirm:\"Inserisci il codice a 6 cifre dalla tua app authenticator per confermare.\",activate:\"Attiva\",disableTotp:\"Disabilita Authenticator\",disableConfirmHint:\"Inserisci un codice TOTP valido o un codice di backup per disabilitare l'authenticator.\",totpDisabled:\"App Authenticator disabilitata.\",emailOtpEnabled:\"OTP via e-mail abilitato.\",emailOtpDisabled:\"OTP via e-mail disabilitato.\",smtpRequired:\"Configura e testa prima le impostazioni SMTP.\",invalidCode:\"Codice non valido. Riprova.\",enableEmailOtp:\"Abilita OTP via e-mail\",disableEmailOtp:\"Disabilita OTP via e-mail\",emailSetupEnterCode:\"È stato inviato un codice di verifica al tuo indirizzo e-mail. Inseriscilo qui sotto per confermare che possiedi questa casella di posta.\",verifyAndEnable:\"Verifica e abilita\",emailDisablePasswordHint:\"Inserisci la password del tuo account per confermare la disabilitazione dell'OTP via e-mail.\",passwordPlaceholder:\"Inserisci la tua password\",backupCodesTitle:\"Salva i tuoi codici di backup\",backupCodesWarning:\"Conserva questi codici in un posto sicuro. Ogni codice può essere usato una sola volta.\",backupCodesRemaining:\"{{count}} codici di backup rimanenti\",savedCodes:\"Codici salvati\",regenBackup:\"Rigenera codici di backup\",regenBackupHint:\"Inserisci il tuo codice TOTP corrente per generare 10 nuovi codici di backup.\",newBackupCodes:\"Nuovi codici di backup\",linkedAccounts:\"Account SSO collegati\",linkedAccountsDesc:\"Questi provider di identità esterni sono collegati al tuo account.\",oidcUnlinked:\"Account scollegato.\"},oidc:{title:\"Provider SSO / OIDC\",desc:\"Configura provider OpenID Connect per il single sign-on.\",addProvider:\"Aggiungi provider\",newProvider:\"Nuovo provider\",empty:\"Nessun provider OIDC configurato.\",created:\"Provider creato.\",updated:\"Provider aggiornato.\",deleted:\"Provider eliminato.\",deleteTitle:\"Elimina provider\",deleteMessage:'Eliminare \"{{name}}\"? Tutti gli account collegati verranno disconnessi.',form:{name:\"Nome visualizzato\",issuerUrl:\"URL emittente\",clientId:\"Client ID\",clientSecret:\"Client secret\",scopes:\"Scope\",iconUrl:\"URL icona (opzionale)\",enabled:\"Abilitato\",autoCreate:\"Crea utenti automaticamente\",autoCreateDesc:\"Crea automaticamente un account locale al primo accesso.\",autoLink:\"Collega automaticamente gli account esistenti\",autoLinkDesc:\"Collega gli account locali esistenti tramite email al primo accesso.\",secretHint:\"lascia vuoto per mantenere\",secretPlaceholder:\"nuovo segreto\"}}},notification:{printStarted:{title:\"Stampa avviata\",body:\"{{printer}}: {{filename}} ha iniziato a stampare\"},printCompleted:{title:\"Stampa completata\",body:\"{{printer}}: {{filename}} completata con successo\"},printFailed:{title:\"Stampa fallita\",body:\"{{printer}}: {{filename}} fallita\"},printStopped:{title:\"Stampa interrotta\",body:\"{{printer}}: {{filename}} interrotta\"},printProgress:{title:\"Avanzamento stampa\",body:\"{{printer}}: {{filename}} al {{percent}}% completamento\"},printerOffline:{title:\"Stampante offline\",body:\"{{printer}} e offline\"},printerError:{title:\"Errore stampante\",body:\"{{printer}}: {{error}}\"},filamentLow:{title:\"Filamento in esaurimento\",body:\"{{printer}}: Filamento in esaurimento\"},maintenanceDue:{title:\"Manutenzione dovuta\",body:\"{{printer}}: {{items}} richiedono attenzione\"}},errors:{generic:\"Qualcosa e andato storto\",networkError:\"Errore di rete. Controlla la connessione.\",notFound:\"Non trovato\",unauthorized:\"Non autorizzato\",serverError:\"Errore server\",validationError:\"Controlla i dati inseriti\",printerConnectionFailed:\"Connessione alla stampante fallita\",saveFailed:\"Salvataggio modifiche fallito\",deleteFailed:\"Eliminazione fallita\",loadFailed:\"Caricamento dati fallito\"},hmsErrors:{title:\"Errori - {{name}}\",noErrors:\"Nessun errore\",viewOnWiki:\"Vedi su Bambu Lab Wiki\",clearInstructions:\"Cancella gli errori sulla stampante per rimuoverli qui.\",clearErrors:\"Cancella errori\",clearSuccess:\"Errori HMS cancellati\",clearFailed:\"Impossibile cancellare gli errori HMS\"},mqttDebug:{title:\"Log debug MQTT\",searchPlaceholder:\"Cerca topic o payload...\",noMessages:\"Nessun messaggio registrato\",startLoggingHint:'Clicca \"Avvia logging\" per iniziare a catturare messaggi MQTT',noMessagesMatch:\"Nessun messaggio corrisponde al filtro\",adjustFilterHint:\"Prova a modificare la ricerca o i filtri\",incoming:\"In ingresso\",outgoing:\"In uscita\",loggingStopped:\"Logging fermato\",loggingActive:\"Logging attivo - i messaggi si aggiornano automaticamente\",startLogging:\"Avvia logging\",stopLogging:\"Ferma logging\",clearLog:\"Pulisci log\",topic:\"Topic\",timestamp:\"Timestamp\",direction:\"Direzione\",all:\"Tutti\"},printerFiles:{title:\"Gestore file\",storageUsed:\"Usato:\",storageFree:\"Libero:\",filterPlaceholder:\"Filtra file...\",deleteButton:\"Elimina\",deleteFiles:\"Elimina {{count}} file\",deleteFileConfirm:'Eliminare \"{{name}}\"? Questa azione non può essere annullata.',deleteFilesConfirm:\"Eliminare {{count}} file selezionati? Questa azione non può essere annullata.\",noFiles:\"Nessun file sulla stampante\",loadingFiles:\"Caricamento file...\",failedToLoad:\"Caricamento file fallito\",toast:{filesDeleted:\"Eliminati {{count}} file\",deleteFailed:\"Eliminazione fallita: {{error}}\"}},confirm:{delete:\"Sei sicuro di voler eliminare questo?\",unsavedChanges:\"Hai modifiche non salvate. Sei sicuro di voler uscire?\",clearQueue:\"Sei sicuro di voler svuotare la coda?\"},login:{title:\"Login Bambuddy\",subtitle:\"Accedi al tuo account\",username:\"Nome utente\",usernamePlaceholder:\"Inserisci il nome utente\",usernameOrEmail:\"Nome utente o email\",usernameOrEmailPlaceholder:\"Nome utente o @ Email\",password:\"Password\",passwordPlaceholder:\"Inserisci la password\",signIn:\"Accedi\",signingIn:\"Accesso in corso...\",forgotPassword:\"Hai dimenticato la password?\",loginSuccess:\"Accesso riuscito\",loginFailed:\"Accesso fallito\",enterCredentials:\"Inserisci nome utente e password\",enterEmail:\"Inserisci il tuo indirizzo e-mail\",oidcLoginFailed:\"Accesso OIDC fallito\",oidcErrors:{providerError:\"Il provider di identità ha restituito un errore\",missingParameters:\"Parametri obbligatori mancanti nel callback OIDC\",invalidState:\"Lo stato OIDC non è valido o è già stato utilizzato\",stateExpired:\"La sessione OIDC è scaduta — riprovare\",providerNotFound:\"Provider OIDC non trovato\",discoveryFailed:\"Impossibile recuperare il documento di discovery OIDC\",invalidDiscovery:\"Il documento di discovery OIDC non è valido\",networkError:\"Errore di rete durante lo scambio di token OIDC\",badResponse:\"Risposta inattesa durante lo scambio di token OIDC\",noIdToken:\"Il provider OIDC non ha restituito un ID token\",validationFailed:\"La validazione del token OIDC non è riuscita\",nonceMismatch:\"Il nonce OIDC non corrisponde — possibile attacco di replay\",missingSubClaim:\"Il token OIDC è privo del claim sub\",noLinkedAccount:\"Nessun account locale è collegato a questa identità OIDC\",accountInactive:\"Il tuo account è inattivo\",userResolutionFailed:\"Impossibile risolvere il tuo account\",internalError:\"Si è verificato un errore interno durante il login OIDC\",tokenExchangeFailed:\"Lo scambio di token OIDC non è riuscito\"},forgotPasswordTitle:\"Password dimenticata\",forgotPasswordMessage:\"Se hai dimenticato la password, contatta il tuo amministratore di sistema per reimpostarla.\",forgotPasswordEmailMessage:\"Inserisci il tuo indirizzo email e ti invieremo una nuova password.\",emailAddress:\"Indirizzo email\",emailPlaceholder:\"tua.email@esempio.com\",cancel:\"Annulla\",sending:\"Invio...\",sendResetEmail:\"Invia email di reimpostazione\",howToReset:\"Come reimpostare la password:\",resetStep1:\"Contatta il tuo amministratore Bambuddy\",resetStep2:\"Chiedi di reimpostare la password in Gestione utenti\",resetStep3:\"Possono impostare una nuova password temporanea\",resetStep4:\"Accedi con la nuova password e cambiala in Impostazioni\",gotIt:\"Capito\",twoFA:{title:\"Autenticazione a due fattori\",subtitle:\"Il tuo account è protetto da 2FA. Inserisci il codice di verifica qui sotto.\",methodAuthenticator:\"App di autenticazione\",methodEmail:\"Codice via e-mail\",methodBackup:\"Codice di recupero\",instructionsTotp:\"Apri la tua app di autenticazione e inserisci il codice a 6 cifre per Bambuddy.\",instructionsEmail:\"Un codice a 6 cifre è stato inviato al tuo indirizzo e-mail. È valido per 10 minuti.\",instructionsEmailNotSent:\"Clicca il pulsante qui sotto per ricevere un codice di verifica via e-mail.\",instructionsBackup:\"Inserisci uno dei tuoi codici di recupero a 8 caratteri. Ogni codice può essere utilizzato una sola volta.\",sendCodeButton:\"Invia codice via e-mail\",sendingCode:\"Invio in corso...\",resendCode:\"Invia nuovamente il codice\",codeLabel:\"Codice di verifica\",backupCodeLabel:\"Codice di recupero\",codePlaceholder:\"000000\",backupCodePlaceholder:\"XXXXXXXX\",verifyButton:\"Verifica\",verifyingButton:\"Verifica in corso...\",backToLogin:\"← Torna alla pagina di accesso\",orContinueWith:\"oppure accedi con\",signInWith:\"Accedi con {{provider}}\",enterCode:\"Inserisci il codice di verifica\",sendCodeFailed:\"Invio del codice di verifica non riuscito\",invalidCode:\"Codice non valido. Riprova.\"}},setup:{title:\"Configurazione Bambuddy\",subtitle:\"Configura autenticazione per la tua istanza Bambuddy\",enableAuth:\"Abilita autenticazione\",adminAccount:\"Account admin\",adminAccountDesc:\"Se esistono già admin, l'autenticazione verrà abilitata usando gli account esistenti. Lascia i campi sotto vuoti per usare gli admin esistenti, oppure inserisci nuove credenziali per creare un nuovo utente admin.\",adminUsername:\"Nome utente admin\",adminPassword:\"Password admin\",optionalIfAdminExists:\"(opzionale se esistono admin)\",adminUsernamePlaceholder:\"Inserisci nome utente admin (opzionale)\",adminPasswordPlaceholder:\"Inserisci password admin (opzionale)\",confirmPassword:\"Conferma password\",confirmPasswordPlaceholder:\"Conferma password admin\",settingUp:\"Configurazione...\",completeSetup:\"Completa configurazione\",toast:{authEnabledAdminCreated:\"Autenticazione abilitata e utente admin creato\",authEnabledExistingAdmins:\"Autenticazione abilitata usando admin esistenti\",setupCompleted:\"Configurazione completata\",enterBothCredentials:\"Inserisci nome utente e password admin, oppure lascia entrambi vuoti per usare admin esistenti\",passwordsDoNotMatch:\"Le password non coincidono\",passwordTooShort:\"La password deve essere di almeno 6 caratteri\"}},changePassword:{title:\"Cambia password\",currentPassword:\"Password attuale\",currentPasswordPlaceholder:\"Inserisci password attuale\",newPassword:\"Nuova password\",newPasswordPlaceholder:\"Inserisci nuova password (min 6 caratteri)\",confirmPassword:\"Conferma nuova password\",confirmPasswordPlaceholder:\"Conferma nuova password\",passwordsDoNotMatch:\"Le password non coincidono\",passwordTooShort:\"La password deve essere di almeno 6 caratteri\",changing:\"Modifica in corso...\",success:\"Password cambiata con successo\",failed:\"Modifica password fallita\"},plateAlert:{title:\"Stampa in pausa!\",message:\"Oggetti rilevati sul piatto. La stampa è stata messa automaticamente in pausa. Svuota il piatto e riprendi la stampa.\",understand:\"Ho capito\"},camera:{title:\"Vista camera\",invalidPrinterId:\"ID stampante non valido\",live:\"Live\",snapshot:\"Snapshot\",restartStream:\"Riavvia stream\",refreshSnapshot:\"Aggiorna snapshot\",fullscreen:\"Schermo intero\",exitFullscreen:\"Esci da schermo intero\",connectingToCamera:\"Connessione alla camera...\",capturingSnapshot:\"Acquisizione snapshot...\",connectionLost:\"Connessione persa\",connectionFailed:\"Connessione camera fallita\",reconnecting:\"Riconnessione tra {{countdown}}s... (tentativo {{attempt}}/{{max}})\",reconnectNow:\"Riconnetti ora\",cameraUnavailable:\"Camera non disponibile\",cameraUnavailableDesc:\"Assicurati che la stampante sia accesa e connessa.\",noCamera:\"Nessuna camera disponibile\",retry:\"Riprova\",cameraStream:\"Stream camera\",zoomOut:\"Zoom indietro\",zoomIn:\"Zoom avanti\",resetZoom:\"Reset zoom\",recording:\"Registrazione\",startRecording:\"Avvia registrazione\",stopRecording:\"Ferma registrazione\",chamberLight:\"Accendi/Spegni luce camera\"},groups:{title:\"Gestione gruppi\",subtitle:\"Gestisci gruppi permessi per controllo accesso\",backToSettings:\"Torna a Impostazioni\",createGroup:\"Crea gruppo\",noPermission:\"Non hai il permesso di accedere a questa pagina.\",system:\"Sistema\",noDescription:\"Nessuna descrizione\",usersCount:\"{{count}} utenti\",permissionsCount:\"{{count}} permessi\",edit:\"Modifica\",delete:\"Elimina\",toast:{created:\"Gruppo creato con successo\",updated:\"Gruppo aggiornato con successo\",deleted:\"Gruppo eliminato con successo\",enterGroupName:\"Inserisci un nome gruppo\"},modal:{editGroup:\"Modifica gruppo\",createGroup:\"Crea gruppo\",cancel:\"Annulla\",saving:\"Salvataggio...\",creating:\"Creazione...\",saveChanges:\"Salva modifiche\"},form:{groupName:\"Nome gruppo\",groupNamePlaceholder:\"Inserisci nome gruppo\",systemGroupWarning:\"I nomi dei gruppi di sistema non possono essere modificati\",description:\"Descrizione\",descriptionPlaceholder:\"Inserisci descrizione (opzionale)\",permissions:\"Permessi ({{count}} selezionati)\"},deleteModal:{title:\"Elimina gruppo\",message:\"Sei sicuro di voler eliminare questo gruppo? Gli utenti in questo gruppo perderanno questi permessi.\",confirm:\"Elimina gruppo\"},editor:{title:\"Modifica gruppo\",createTitle:\"Crea gruppo\",search:\"Cerca permessi...\",selectAll:\"Seleziona tutto\",clearAll:\"Deseleziona tutto\",permissionsSelected:\"{{count}} selezionati\",noResults:\"Nessun permesso corrisponde alla ricerca\"}},users:{title:\"Gestione utenti\",subtitle:\"Gestisci utenti e accesso alla tua istanza Bambuddy\",backToSettings:\"Torna a Impostazioni\",createUser:\"Crea utente\",noPermission:\"Non hai il permesso di accedere a questa pagina.\",admin:\"Admin\",noGroups:\"Nessun gruppo\",active:\"Attivo\",inactive:\"Inattivo\",edit:\"Modifica\",delete:\"Elimina\",system:\"Sistema\",noGroupsAvailable:\"Nessun gruppo disponibile\",table:{username:\"Nome utente\",groups:\"Gruppi\",status:\"Stato\",actions:\"Azioni\"},toast:{created:\"Utente creato con successo\",updated:\"Utente aggiornato con successo\",deleted:\"Utente eliminato con successo\",fillRequired:\"Compila tutti i campi obbligatori\",passwordsDoNotMatch:\"Le password non coincidono\",passwordTooShort:\"La password deve essere di almeno 6 caratteri\"},modal:{createUser:\"Crea utente\",editUser:\"Modifica utente\",cancel:\"Annulla\",creating:\"Creazione...\",saving:\"Salvataggio...\",saveChanges:\"Salva modifiche\",advancedAuthSubtitle:\"con autenticazione avanzata\"},form:{username:\"Nome utente\",usernamePlaceholder:\"Inserisci nome utente\",email:\"Email\",emailPlaceholder:\"utente@esempio.com\",password:\"Password\",passwordPlaceholder:\"Inserisci password\",confirmPassword:\"Conferma password\",confirmPasswordPlaceholder:\"Conferma password\",newPasswordPlaceholder:\"Inserisci nuova password\",confirmNewPasswordPlaceholder:\"Conferma nuova password\",leaveBlankToKeep:\"lascia vuoto per mantenere attuale\",groups:\"Gruppi\",optional:\"opzionale\",autoGeneratedPassword:\"Una password sicura verrà generata automaticamente e inviata via email all'utente.\",passwordManagedByAdvancedAuth:`La password è gestita dall'autenticazione avanzata. Usa \"Reimposta password\" per inviare una nuova password all'utente via email.`,resetPassword:\"Reimposta password\",resettingPassword:\"Reimpostazione password...\"},deleteModal:{title:\"Elimina utente\",message:\"Sei sicuro di voler eliminare questo utente? Questa azione non può essere annullata.\",confirm:\"Elimina utente\"}},streamOverlay:{title:\"Overlay stream\",invalidPrinterId:\"ID stampante non valido\",cameraStream:\"Stream camera\",progress:\"Avanzamento\",eta:\"ETA\",printerIdle:\"Stampante inattiva\",printerOffline:\"Stampante offline\",status:{printing:\"In stampa\",paused:\"In pausa\",finished:\"Completata\",failed:\"Fallita\",idle:\"Inattiva\",unknown:\"Sconosciuto\"}},profiles:{title:\"Profili\",subtitle:\"Gestisci preset slicer e calibrazioni pressure advance\",tabs:{cloud:\"Profili cloud\",local:\"Profili locali\",kprofiles:\"K-Profiles\"},localProfiles:{title:\"Profili locali\",subtitle:\"Importa e gestisci preset slicer da OrcaSlicer\",import:\"Importa profili\",importDesc:\"Trascina file .bbscfg, .bbsflmt, .orca_filament, .zip o .json qui\",importing:\"Importazione...\",search:\"Cerca preset locali...\",noPresets:\"Nessun preset locale ancora\",badge:\"Locale\",edit:\"Modifica\",delete:\"Elimina\",cancel:\"Annulla\",deleteConfirmTitle:\"Elimina preset\",deleteConfirm:\"Sei sicuro di voler eliminare questo preset? Questa azione non può essere annullata.\",source:\"Fonte\",inheritsFrom:\"Eredita da\",filamentType:\"Tipo\",vendor:\"Produttore\",compatiblePrinters:\"Stampanti\",nozzleTemp:\"Temp. ugello\",cost:\"Costo\",density:\"Densità\",pressureAdvance:\"Pressure Advance\",filament:\"Filamento\",process:\"Processo\",printer:\"Stampante\",toast:{importSuccess:\"{{count}} preset importati\",importSkipped:\"{{count}} preset saltati (duplicati)\",importError:\"{{count}} errori durante l'importazione\",deleted:\"Preset eliminato\",updated:\"Preset aggiornato\"}},connectedAs:\"Connesso come\",logout:\"Esci\",noLogoutPermission:\"Non hai il permesso di disconnetterti\",failedToLoad:\"Caricamento profili fallito\",retry:\"Riprova\",time:{justNow:\"Proprio ora\",minsAgo:\"{{count}}m fa\",hoursAgo:\"{{count}}h fa\",daysAgo:\"{{count}}g fa\"},toast:{loggedOut:\"Disconnesso\"},login:{title:\"Connetti a Bambu Cloud\",subtitle:\"Sincronizza i preset del slicer tra dispositivi\",email:\"Email\",password:\"Password\",region:\"Regione\",regionGlobal:\"Globale\",regionChina:\"Cina\",verificationCode:\"Codice di verifica\",totpCode:\"Codice autenticatore\",checkEmail:\"Controlla la tua email ({{email}}) per un codice a 6 cifre\",enterTotpHint:\"Inserisci il codice a 6 cifre dalla tua app autenticatore\",accessToken:\"Access Token\",accessTokenHint:\"Incolla il tuo access token Bambu Lab (da Bambu Studio)\",back:\"Indietro\",loginButton:\"Accedi\",verifyButton:\"Verifica\",setTokenButton:\"Imposta token\",useToken:\"Usa access token invece\",useEmail:\"Accedi con email invece\",toast:{loggedIn:\"Accesso riuscito\",codeSent:\"Codice di verifica inviato via email\",enterTotp:\"Inserisci il codice dalla tua app autenticatore\",tokenSet:\"Token impostato con successo\"}},presets:{myPreset:\"Il mio preset (modificabile)\",duplicate:\"Duplica\",editable:\"Modificabile\",failedToLoadDetails:\"Caricamento dettagli preset fallito\",deleteConfirm:\"Eliminare questo preset?\",deleteWarning:'Questo eliminerà definitivamente \"{{name}}\" da Bambu Cloud. Questa azione non può essere annullata.',noDuplicatePermission:\"Non hai il permesso di duplicare preset\",noEditPermission:\"Non hai il permesso di modificare preset\",noDeletePermission:\"Non hai il permesso di eliminare preset\",types:{filament:\"Preset filamento\",printer:\"Preset stampante\",process:\"Preset processo\"},toast:{deleted:\"Preset eliminato\",created:\"Preset creato\",updated:\"Preset aggiornato\",duplicated:\"Preset duplicato\",fieldAdded:'Campo \"{{key}}\" aggiunto',exported:\"Preset esportato\"},baseLabel:\"Base: {{name}}\",currentLabel:\"Corrente: {{name}}\",newPreset:\"Nuovo preset\",editPreset:\"Modifica preset\",duplicatePreset:\"Duplica preset\",createNewPreset:\"Crea nuovo preset\",customizeSettings:\"Personalizza le impostazioni per il nuovo preset\",compareWithBase:\"Confronta con base\",compare:\"Confronta\",basePreset:\"Preset base\",selectBasePreset:\"Seleziona preset base...\",presetName:\"Nome preset\",myCustomPreset:\"Il mio preset personalizzato\",inheritsFrom:\"Deriva da\",dropJsonToImport:\"Rilascia JSON per importare\",tabs:{common:\"Comune\",allFields:\"Tutti i campi\"},availableFields:\"Campi disponibili\",searchFieldsPlaceholder:\"Cerca campi...\",noMatchingFields:\"Nessun campo corrispondente\",allFieldsAdded:\"Tutti i campi aggiunti\",addCustomField:\"Aggiungi campo personalizzato\",yourOverrides:\"Le tue override\",noOverridesYet:\"Nessun override ancora\",clickFieldsToAdd:\"Clicca i campi a sinistra per aggiungerli\",saveAsTemplate:\"Salva come template\",jsonTip:\"Suggerimento: trascina e rilascia un file .json ovunque in questa modale per importare impostazioni\"},cloudView:{searchPlaceholder:\"Cerca preset...\",templates:\"Template\",refresh:\"Aggiorna\",newPreset:\"Nuovo preset\",clearFilters:\"Pulisci filtri\",compareMode:\"Modalita confronto\",selectAnotherPreset:\"Seleziona un altro preset {{type}}\",clickTwoPresets:\"Clicca due preset dello stesso tipo per confrontare\",selectFirst:\"1. Seleziona il primo\",selectSecond:\"2. Seleziona il secondo\",compareNow:\"Confronta ora\",lastSynced:\"Ultima sincronizzazione:\",showingCount:\"Mostrati {{showing}} di {{total}} preset\",noPresetsFound:\"Nessun preset trovato\",columns:{filament:\"Filamento\",process:\"Processo\",printer:\"Stampante\"},noFilamentPresets:\"Nessun preset filamento\",noProcessPresets:\"Nessun preset processo\",noPrinterPresets:\"Nessun preset stampante\",filters:{type:\"Tipo\",owner:\"Proprietario\",printer:\"Stampante\",nozzle:\"Ugello\",filament:\"Filamento\",layer:\"Layer\",all:\"Tutti\",myPresets:\"I miei preset\",builtIn:\"Integrati\",process:\"Processo\"},noTemplatesPermission:\"Non hai il permesso di gestire i template\",noRefreshPermission:\"Non hai il permesso di aggiornare i profili\",noCreatePermission:\"Non hai il permesso di creare preset\"},templates:{title:\"Template rapidi\",noTemplates:\"Nessun template ancora\",createFirst:\"Crea template dall'editor preset\",typeFilter:\"Tipo:\",deleteTitle:\"Elimina template\",deleteWarning:\"Questa azione non può essere annullata\",deleteConfirm:'Sei sicuro di voler eliminare \"{{name}}\"?',namePlaceholder:\"Nome template\",descriptionPlaceholder:\"Descrizione\",settingsJson:\"Impostazioni (JSON)\",fieldsCount:\"{{count}} campi\",shownInModals:\"Mostrati nelle modali\",hiddenInModals:\"Nascosti nelle modali\",apply:\"Applica\",toast:{deleted:\"Template eliminato\",updated:\"Template aggiornato\",created:\"Template creato\",applied:\"Template applicato\"}}},support:{debugLoggingActive:\"Log debug attivo\",manageLogs:\"Gestisci\",collectItem7:\"Connettività stampante e versioni firmware\",collectItem8:\"Stato integrazioni (Spoolman, MQTT, HA)\",collectItem9:\"Interfacce di rete (solo subnet)\",collectItem10:\"Versioni dei pacchetti Python\",collectItem11:\"Controlli di integrità del database\",collectItem12:\"Dettagli dell'ambiente Docker\"},fileManager:{title:\"Gestore file\",subtitle:\"Organizza e gestisci i tuoi file di stampa\",uploadFiles:\"Carica file\",newFolder:\"Nuova cartella\",folderName:\"Nome cartella\",folderNamePlaceholder:\"es., Parti funzionali\",renameFile:\"Rinomina file\",renameFolder:\"Rinomina cartella\",moveFiles:\"Sposta {{count}} file\",rootNoFolder:\"Root (nessuna cartella)\",current:\"corrente\",linkFolder:\"Collega cartella\",linkFolderDescription:'Collega \"{{name}}\" a un progetto o archivio per accesso rapido.',project:\"Progetto\",archive:\"Archivio\",noProjectsFound:\"Nessun progetto trovato\",noArchivesFound:\"Nessun archivio trovato\",unlink:\"Scollega\",link:\"Collega\",dragDropFiles:\"Trascina e rilascia file qui\",dropFilesHere:\"Rilascia file qui\",orClickToBrowse:\"oppure clicca per sfogliare\",allFileTypesSupported:\"Tutti i tipi di file supportati. I file ZIP saranno estratti.\",zipFilesDetected:\"File ZIP rilevati\",zipExtractOptions:\"I file ZIP saranno estratti. Scegli come gestire la struttura cartelle:\",preserveZipStructure:\"Mantieni struttura cartelle dal ZIP\",createFolderFromZip:\"Crea cartella dal nome ZIP\",stlThumbnailGeneration:\"Generazione miniature STL\",zipMayContainStl:\"I file ZIP possono contenere STL. Le miniature possono essere generate durante l'estrazione.\",thumbnailsCanBeGenerated:\"Le miniature possono essere generate per file STL. I modelli grandi possono richiedere più tempo.\",generateThumbnailsForStl:\"Genera miniature per file STL\",threemfDetected:\"File 3MF rilevati\",threemfExtractionInfo:\"Modello stampante, materiale, colore e impostazioni stampa saranno estratti automaticamente dai file 3MF.\",willBeExtracted:\"Sara estratto\",filesExtracted:\"{{count}} file estratti\",uploadComplete:\"Caricamento completato: {{succeeded}} riusciti\",uploadFailed:\"Caricamento fallito\",zipFilesFailed:\"{{count}} file falliti\",uploading:\"Caricamento...\",changeLink:\"Cambia collegamento...\",linkTo:\"Collega a...\",linkToProjectOrArchive:\"Collega a progetto o archivio\",addToQueue:\"Aggiungi alla coda\",schedulePrint:\"Pianifica\",generateThumbnail:\"Genera miniatura\",generateThumbnails:\"Genera miniature\",generateThumbnailsForMissing:\"Genera miniature per STL senza miniatura\",gridView:\"Vista griglia\",listView:\"Vista elenco\",lowDiskSpaceWarning:\"Avviso spazio disco basso\",lowDiskSpaceDetails:\"Solo {{free}} liberi su {{total}} totali. La soglia e {{threshold}} GB nelle impostazioni.\",files:\"File\",folders:\"Cartelle\",size:\"Dimensione\",free:\"Libero\",allFiles:\"Tutti i file\",wrap:\"A capo\",enableTextWrapping:\"Abilita a capo testo\",disableTextWrapping:\"Disabilita a capo testo\",collapse:\"Comprimi\",collapseFoldersByDefault:\"Comprimi le cartelle per impostazione predefinita\",expandFoldersByDefault:\"Espandi le cartelle per impostazione predefinita\",dragToResizeTooltip:\"Trascina per ridimensionare, doppio clic per reset\",searchFiles:\"Cerca file...\",allTypes:\"Tutti i tipi\",prints:\"Stampe\",ascending:\"Crescente\",descending:\"Decrescente\",resultsCount:\"{{showing}} di {{total}} file\",selectAll:\"Seleziona tutto\",deselectAll:\"Deseleziona tutto\",selected:\"{{count}} selezionati\",adding:\"Aggiunta...\",loadingFiles:\"Caricamento file...\",folderIsEmpty:\"La cartella e vuota\",noFilesYet:\"Nessun file ancora\",folderEmptyDescription:\"Carica file o sposta file in questa cartella per iniziare.\",noFilesDescription:\"Carica file per iniziare a organizzare i file di stampa.\",noMatchingFiles:\"Nessun file corrispondente\",noMatchingFilesDescription:\"Nessun file corrisponde ai criteri di ricerca o filtro.\",clearFilters:\"Pulisci filtri\",printedCount:\"Stampato {{count}}x\",uploadedBy:\"Caricato da\",deleteFolder:\"Elimina cartella\",deleteFile:\"Elimina file\",deleteFilesCount:\"Elimina {{count}} file\",deleteFolderConfirm:\"Sei sicuro di voler eliminare questa cartella? Tutti i file dentro saranno eliminati.\",deleteFileConfirm:\"Sei sicuro di voler eliminare questo file?\",deleteFilesConfirm:\"Sei sicuro di voler eliminare {{count}} file selezionati? Questa azione non può essere annullata.\",deleting:\"Eliminazione...\",noPermissionRenameFolder:\"Non hai il permesso di rinominare cartelle\",noPermissionLinkFolder:\"Non hai il permesso di collegare cartelle\",noPermissionDeleteFolder:\"Non hai il permesso di eliminare cartelle\",noPermissionPrint:\"Non hai il permesso di stampare\",noPermissionAddToQueue:\"Non hai il permesso di aggiungere alla coda\",noPermissionDownload:\"Non hai il permesso di scaricare file\",noPermissionRenameFile:\"Non hai il permesso di rinominare questo file\",noPermissionGenerateThumbnail:\"Non hai il permesso di generare miniature\",noPermissionDeleteFile:\"Non hai il permesso di eliminare questo file\",noPermissionCreateFolder:\"Non hai il permesso di creare cartelle\",noPermissionUpload:\"Non hai il permesso di caricare file\",noPermissionMoveFiles:\"Non hai il permesso di spostare file\",noPermissionDeleteFiles:\"Non hai il permesso di eliminare file\",linkExternal:\"Collega esterno\",linkExternalFolder:\"Collega cartella esterna\",linkExternalFolderDescription:\"Monta una directory host (NAS, USB, condivisione di rete) nel File Manager. I file non vengono copiati — vengono letti direttamente dal percorso originale.\",externalFolderNamePlaceholder:\"es. Stampe NAS\",externalPath:\"Percorso host\",externalPathHelp:\"Percorso assoluto della directory sull'host Docker. Deve essere montato come bind nel container.\",readOnly:\"Sola lettura\",readOnlyHelp:\"impedisce caricamenti e cancellazioni\",showHiddenFiles:\"Mostra file nascosti (file punto)\",externalFolder:\"Cartella esterna\",scanFolder:\"Scansiona\",toast:{folderCreated:\"Cartella creata\",folderDeleted:\"Cartella eliminata\",fileDeleted:\"File eliminato\",filesDeleted:\"Eliminati {{count}} file\",filesMoved:\"File spostati\",folderLinked:\"Cartella collegata\",folderUnlinked:\"Cartella scollegata\",externalFolderLinked:\"Cartella esterna collegata e scansionata\",folderScanned:\"Scansione completata: {{added}} aggiunti, {{removed}} rimossi\",addedToQueue:\"Aggiunti {{count}} file alla coda\",addedToQueuePartial:\"Aggiunti {{added}} file, {{failed}} falliti\",failedToAddToQueue:\"Aggiunta file fallita: {{error}}\",fileRenamed:\"File rinominato\",folderRenamed:\"Cartella rinominata\",thumbnailsGenerated:\"Generate {{count}} miniature\",thumbnailsGeneratedPartial:\"Generate {{succeeded}} miniature, {{failed}} fallite\",noStlMissingThumbnails:\"Nessun file STL senza miniature\",failedToGenerateThumbnails:\"Generazione miniature fallita: {{error}}\",thumbnailGenerated:\"Miniatura generata\",failedToGenerateThumbnail:\"Generazione miniatura fallita: {{error}}\"}},projects:{title:\"Progetti\",subtitle:\"Organizza e traccia i tuoi progetti di stampa 3D\",newProject:\"Nuovo progetto\",editProject:\"Modifica progetto\",deleteProject:\"Elimina progetto\",projectName:\"Nome progetto\",description:\"Descrizione\",noProjects:\"Nessun progetto ancora\",noProjectsFiltered:\"Nessun progetto {{status}}\",noProjectsFilteredHelp:\"Non hai progetti {{status}}. I progetti appariranno qui quando il loro stato cambia.\",createFirst:\"Crea il tuo primo progetto per organizzare stampe correlate, tracciare progressi e gestire i tuoi build.\",createFirstButton:\"Crea il tuo primo progetto\",create:\"Crea\",files:\"File\",prints:\"Stampe\",plates:\"piatti\",parts:\"parti\",lastModified:\"Ultima modifica\",deleteConfirm:\"Sei sicuro di voler eliminare questo progetto? Archivi e elementi in coda saranno scollegati ma non eliminati.\",addFiles:\"Aggiungi file\",removeFile:\"Rimuovi file\",viewDetails:\"Vedi dettagli\",namePlaceholder:\"es., Build Voron 2.4\",descriptionPlaceholder:\"Descrizione opzionale...\",color:\"Colore\",targetPlates:\"Piatti target\",targetPlatesPlaceholder:\"es., 25\",targetPlatesHelp:\"Numero di job di stampa\",targetParts:\"Parti target\",targetPartsPlaceholder:\"es., 150\",targetPartsHelp:\"Totale oggetti necessari\",tagsLabel:\"Tag (separati da virgola)\",tagsPlaceholder:\"es., voron, funzionale, regalo\",dueDate:\"Data scadenza\",priority:\"Priorita\",priorityLow:\"Bassa\",priorityNormal:\"Normale\",priorityHigh:\"Alta\",priorityUrgent:\"Urgente\",statusActive:\"Attivo\",statusCompleted:\"Completato\",statusArchived:\"Archiviato\",done:\"Fatto\",completed:\"completato\",failed:\"fallito\",inQueue:\"in coda\",noPrintsYet:\"Nessuna stampa ancora\",printJobs:\"Job di stampa (piatti)\",partsPrinted:\"Parti stampate\",failedParts:\"Parti fallite\",import:\"Importa\",export:\"Esporta\",importProject:\"Importa progetto\",exportAll:\"Esporta tutti i progetti\",loading:\"Caricamento progetti...\",noEditPermission:\"Non hai il permesso di modificare progetti\",noDeletePermission:\"Non hai il permesso di eliminare progetti\",noCreatePermission:\"Non hai il permesso di creare progetti\",noImportPermission:\"Non hai il permesso di importare progetti\",noExportPermission:\"Non hai il permesso di esportare progetti\",toast:{created:\"Progetto creato\",updated:\"Progetto aggiornato\",deleted:\"Progetto eliminato\",imported:\"Progetto importato\",multipleImported:\"{{count}} progetti importati\",importFailed:\"Import fallito\",exported:\"Progetti esportati (solo metadati)\"}},projectDetail:{notFound:\"Progetto non trovato\",backToProjects:\"Torna a Progetti\",export:\"Esporta\",exportProject:\"Esporta progetto\",noExportPermission:\"Non hai il permesso di esportare progetti\",noEditPermission:\"Non hai il permesso di modificare progetti\",partOf:\"Parte di:\",priorityLabel:\"Priorita:\",noPrints:\"Nessuna stampa in questo progetto ancora\",status:{active:\"Attivo\",completed:\"Completato\",archived:\"Archiviato\"},priority:{low:\"Bassa\",normal:\"Normale\",high:\"Alta\",urgent:\"Urgente\"},dueDate:{overdue:\"Scaduto\",today:\"Scade oggi\",daysLeft:\"{{count}} giorni rimanenti\"},progress:{platesProgress:\"Avanzamento piatti\",partsProgress:\"Avanzamento parti\",printJobs:\"job di stampa\",parts:\"parti\",percentComplete:\"{{percent}}% completato\",remaining:\"{{count}} rimanenti\"},stats:{printJobs:\"Job di stampa\",total:\"totale\",failed:\"{{count}} falliti\",partsPrinted:\"{{count}} parti stampate\",printTime:\"Tempo di stampa\",filamentUsed:\"Filamento usato\"},cost:{title:\"Tracciamento costi\",filamentCost:\"Costo filamento\",energy:\"Energia\",totalCost:\"Costo totale\",total:\"Totale\",includesBom:\"incl. distinta materiali\",budget:\"Budget\",remaining:\"Rimanente\"},subProjects:{title:\"Sotto-progetti ({{count}})\"},notes:{title:\"Note\",noEditPermission:\"Non hai il permesso di modificare le note\",placeholder:\"Aggiungi note su questo progetto...\",empty:\"Nessuna nota ancora. Clicca Modifica per aggiungere note.\"},files:{title:\"File\",linkFolders:\"Collega cartelle dal Gestore file\",forQuickAccess:\"a questo progetto per accesso rapido.\",fileCount:\"{{count}} file\",empty:\"Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.\",noFiles:\"Nessun file in questa cartella.\",print:\"Stampa ora\",addToQueue:\"Aggiungi alla coda\"},bom:{title:\"Distinta materiali\",acquired:\"{{completed}}/{{total}} acquisiti\",showAll:\"Mostra tutti\",hideDone:\"Nascondi completati\",addPart:\"Aggiungi parte\",noAddPermission:\"Non hai il permesso di aggiungere parti\",partNamePlaceholder:\"Nome parte (es., viti M3x8)\",partName:\"Nome parte\",qty:\"Qta\",price:\"Prezzo ({{currency}})\",sourcingUrlPlaceholder:\"URL fornitura (opzionale)\",remarksPlaceholder:\"Note (opzionale)\",deletePart:\"Elimina parte\",deleteConfirm:'Sei sicuro di voler eliminare \"{{name}}\"?',noUpdatePermission:\"Non hai il permesso di aggiornare parti\",noEditPermission:\"Non hai il permesso di modificare parti\",noDeletePermission:\"Non hai il permesso di eliminare parti\",totalCost:\"Costo totale:\",empty:\"Nessuna parte nella distinta materiali. Aggiungi hardware, elettronica o altri componenti da reperire.\"},timeline:{title:\"Timeline attivita\",empty:\"Nessuna attivita ancora.\"},template:{saveAsTemplate:\"Salva come template\",noCreatePermission:\"Non hai il permesso di creare template\"},queue:{title:\"Coda\",viewAll:\"Vedi tutto\",printing:\"{{count}} in stampa\",queued:\"{{count}} in coda\"},prints:{title:\"Stampe ({{count}})\"},toast:{projectUpdated:\"Progetto aggiornato\",partAdded:\"Parte aggiunta\",partRemoved:\"Parte rimossa\",exportFailed:\"Export fallito\",projectExported:\"Progetto esportato\",templateCreated:\"Template creato\"}},system:{title:\"Informazioni sistema\",version:\"Versione\",uptime:\"Tempo attivo\",cpuUsage:\"Uso CPU\",memoryUsage:\"Uso memoria\",diskUsage:\"Uso disco\",networkInfo:\"Info rete\",logs:\"Log\",debugMode:\"Modalita debug\",enableDebug:\"Abilita log debug\",disableDebug:\"Disabilita log debug\",downloadLogs:\"Scarica log\",clearLogs:\"Cancella log\",dockerInfo:\"Info Docker\",containerName:\"Nome container\",imageName:\"Nome immagine\",platform:\"Piattaforma\",architecture:\"Architettura\"},library:{title:\"Libreria filamenti\",addFilament:\"Aggiungi filamento\",editFilament:\"Modifica filamento\",deleteFilament:\"Elimina filamento\",vendor:\"Produttore\",material:\"Materiale\",color:\"Colore\",kFactor:\"K Factor\",temperature:\"Temperatura\",noFilaments:\"Nessun filamento in libreria\",deleteConfirm:\"Sei sicuro di voler eliminare questo filamento?\",importFromPrinter:\"Importa da stampante\",exportToFile:\"Esporta su file\"},spoolman:{title:\"Integrazione Spoolman\",enabled:\"Spoolman abilitato\",url:\"URL Spoolman\",connected:\"Connesso\",disconnected:\"Non connesso\",testConnection:\"Testa connessione\",sync:\"Sincronizza\",syncing:\"Sincronizzazione...\",lastSync:\"Ultima sincronizzazione\",linkToSpoolman:\"Collega a Spoolman\",openInSpoolman:\"Apri in Spoolman\",unlinkSpool:\"Scollega bobina\",unlinkConfirmTitle:\"Scollegare bobina?\",unlinkConfirmMessage:\"Questo disconnetterà lo spool da Spoolman. I dati dello spool in Spoolman rimarranno invariati.\",selectSpool:\"Seleziona bobina\",noUnlinkedSpools:\"Nessuna bobina scollegata disponibile\",linkSuccess:\"Bobina collegata a Spoolman con successo\",linkFailed:\"Collegamento bobina fallito\",unlinkSuccess:\"Bobina scollegata da Spoolman con successo\",unlinkFailed:\"Impossibile scollegare la bobina\",spoolId:\"ID bobina\",fillSourceLabel:\"(Spoolman)\",weight:\"Peso\",remaining:\"Rimanente\",disableWeightSync:\"Disabilita sync peso stimato AMS\",disableWeightSyncDesc:\"Non aggiornare la capacità rimanente dalle stime AMS. Usalo se preferisci il tracciamento di Spoolman rispetto alle stime AMS. Le nuove bobine useranno comunque la stima AMS come peso iniziale.\",reportPartialUsage:\"Segnala uso parziale per stampe fallite\",reportPartialUsageDesc:\"Quando una stampa fallisce o viene annullata, segnala il filamento stimato usato fino a quel punto in base all'avanzamento layer.\"},inventory:{title:\"Inventario Bobine\",addSpool:\"Aggiungi Bobina\",editSpool:\"Modifica Bobina\",material:\"Materiale\",selectMaterial:\"Seleziona materiale...\",subtype:\"Sottotipo\",brand:\"Marchio\",searchBrand:\"Cerca marchio...\",useCustomBrand:'Usa \"{{brand}}\"',useCustomMaterial:\"Usa materiale personalizzato: {{material}}\",colorName:\"Nome Colore\",colorNamePlaceholder:\"Jade White, Fire Red...\",color:\"Colore\",hexColor:\"Colore Hex\",pickColor:\"Scegli colore personalizzato\",labelWeight:\"Peso da Etichetta\",coreWeight:\"Peso Bobina Vuota\",searchSpoolWeight:\"Cerca peso bobina...\",weightUsed:\"Utilizzato\",currentWeight:\"Peso Rimanente\",measuredWeight:\"Peso Misurato\",spoolName:\"Bobina\",costPerKg:\"Costo per kg\",measuredWeightError:\"Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.\",slicerFilament:\"Filamento Slicer\",slicerFilamentName:\"Nome Preset Slicer\",slicerPreset:\"Preset Slicer\",searchPresets:\"Cerca preset filamento...\",selectedPreset:\"Selezionato\",noPresetsFound:\"Nessun preset trovato\",tempOverrides:\"Override Temperatura\",note:\"Nota\",notePlaceholder:\"Eventuali note aggiuntive su questa bobina...\",archive:\"Archivia\",restore:\"Ripristina\",noSpools:\"Ancora nessuna bobina. Aggiungi la tua prima bobina per iniziare.\",noManualSpools:\"Nessuna bobina aggiunta manualmente disponibile. Aggiungi prima una bobina al tuo inventario.\",kProfiles:\"K-Profiles\",addKProfile:\"Aggiungi K-Profile\",assignSpool:\"Assegna Bobina\",unassignSpool:\"Scollega\",assignSuccess:\"Bobina assegnata e slot AMS configurato\",assignFailed:\"Assegnazione bobina fallita\",selectSpool:\"Seleziona una bobina da assegnare a questo slot\",assigned:\"Assegnato\",assigning:\"Assegnazione...\",searchSpools:\"Cerca bobine...\",showAllSpools:\"Mostra tutte le bobine\",allMaterials:\"Tutti i Materiali\",filterByBrand:\"Filtra per marchio...\",showArchived:\"Mostra archiviate\",quickAdd:\"Aggiunta rapida (Scorta)\",quantity:\"Quantità\",stock:\"Scorta\",configured:\"Configurata\",spoolsCreated:\"{{count}} bobine create\",spoolCreated:\"Bobina creata\",spoolUpdated:\"Bobina aggiornata\",spoolDeleted:\"Bobina eliminata\",spoolArchived:\"Bobina archiviata\",spoolRestored:\"Bobina ripristinata\",deleteConfirm:\"Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.\",archiveConfirm:\"Sei sicuro di voler archiviare questa bobina?\",advancedSettings:\"Impostazioni Avanzate\",filamentInfoTab:\"Info filamento\",paProfileTab:\"Profilo PA\",filamentInfo:\"Filamento\",additional:\"Aggiuntivo\",loadingPresets:\"Caricamento preset cloud...\",cloudConnected:\"Cloud connesso\",cloudNotConnected:\"Cloud non connesso (valori predefiniti)\",recentColors:\"Recenti\",searchColors:\"Cerca colori...\",searchResults:\"Risultati della ricerca\",allColors:\"Tutti i colori\",commonColors:\"Colori comuni\",showLess:\"Mostra meno\",showAll:\"Mostra tutto\",noColorsFound:\"Nessun colore corrisponde alla ricerca\",noResults:\"Nessun risultato trovato\",selectMaterialFirst:\"Selezionare prima un materiale nella scheda Info filamento.\",noPrintersConfigured:\"Nessuna stampante configurata. Aggiungi stampanti per usare i profili PA.\",matchingFilter:\"Corrispondenti\",anyBrand:\"Qualsiasi marca\",anyVariant:\"Qualsiasi variante\",autoSelect:\"Selezione automatica\",matches:\"corrispondenze\",match:\"corrispondenza\",noMatches:\"Nessuna corrispondenza\",connected:\"Connessa\",offline:\"Offline\",printerOffline:\"La stampante è offline. Connetti per visualizzare i profili di calibrazione.\",noKProfilesMatch:\"Nessun profilo K corrisponde al filamento selezionato.\",leftNozzle:\"Ugello sinistro\",rightNozzle:\"Ugello destro\",profilesSelected:\"profili di calibrazione selezionati\",totalInventory:\"Inventario totale\",totalConsumed:\"Totale consumato\",byMaterial:\"Per materiale\",inPrinter:\"In stampante\",lowStock:\"Scorta bassa\",sinceTracking:\"Dall'inizio del tracciamento\",loadedInAms:\"Caricato in AMS/Est\",remaining:\"Rimanente\",weightCheck:\"Controllo Peso\",lastWeighed:\"Ultima pesatura\",neverWeighed:\"Mai pesato\",search:\"Cerca bobine...\",showing:\"Visualizzazione\",to:\"a\",of:\"di\",show:\"Mostra\",spools:\"bobine\",spool:\"bobina\",page:\"Pagina\",noSpoolsMatch:\"Nessun risultato trovato\",noSpoolsMatchDesc:\"Prova a modificare la ricerca o i filtri per trovare quello che cerchi.\",active:\"Attive\",archived:\"Archiviate\",all:\"Tutte\",used:\"Usato\",new:\"Nuovo\",clearFilters:\"Cancella filtri\",table:\"Tabella\",cards:\"Schede\",net:\"Netto\",groupSimilar:\"Raggruppa\",groupedSpools:\"{{count}} bobine identiche\",groupedRows:\"righe\",columns:\"Colonne\",configureColumns:\"Configura colonne\",configureColumnsDesc:\"Trascina per riordinare le colonne o usa le frecce. Attiva/disattiva la visibilità con l'icona dell'occhio.\",visible:\"visibili\",reset:\"Ripristina\",cancel:\"Annulla\",applyChanges:\"Applica modifiche\",moveUp:\"Sposta su\",moveDown:\"Sposta giù\",hideColumn:\"Nascondi colonna\",showColumn:\"Mostra colonna\",linkToSpool:\"Collega a bobina\",tagLinked:\"Tag collegato alla bobina\",tagLinkFailed:\"Impossibile collegare il tag\",tagAlreadyLinked:\"Tag già collegato a un'altra bobina\",unknownTag:\"Tag RFID sconosciuto rilevato\",usageHistory:\"Cronologia utilizzo\",noUsageHistory:\"Nessun utilizzo registrato\",printName:\"Nome stampa\",weightConsumed:\"Peso consumato\",clearHistory:\"Cancella\",historyCleared:\"Cronologia utilizzo cancellata\",fillSourceLabel:\"(Inv)\",lowStockThresholdError:\"La soglia deve essere tra 0.1 e 99.9\",assignMismatchTitle:\"Materiale non corrispondente\",assignMismatchMessage:'Il materiale della bobina selezionata \"{{spoolMaterial}}\" non corrisponde al materiale del vassoio \"{{trayMaterial}}\" per {{location}}. Assegnare comunque?',assignMismatchConfirm:\"Assegna comunque\",assignPartialMismatchMessage:'Il materiale della bobina \"{{spoolMaterial}}\" è simile ma non corrisponde esattamente a \"{{trayMaterial}}\" in {{location}}. Vuoi procedere?',assignProfileMismatchMessage:'Il profilo della bobina \"{{spoolProfile}}\" non corrisponde al profilo del vassoio \"{{trayProfile}}\" in {{location}}. Vuoi procedere?'},timelapse:{title:\"Timelapse\",create:\"Crea timelapse\",download:\"Scarica\",delete:\"Elimina\",preview:\"Anteprima\",frameRate:\"Frame rate\",quality:\"Qualità\",processing:\"Elaborazione...\",noTimelapses:\"Nessun timelapse disponibile\"},ams:{title:\"AMS\",slot:\"Slot\",empty:\"Vuoto\",emptySlot:\"Slot vuoto\",unknown:\"Sconosciuto\",humidity:\"Umidità\",temperature:\"Temperatura\",filamentType:\"Tipo filamento\",filamentColor:\"Colore\",remaining:\"Rimanente\",history:\"Cronologia AMS\",noHistory:\"Nessuna cronologia disponibile\",configureSlot:\"Configura slot\",externalSpool:\"Bobina esterna\",profile:\"Profilo\",kFactor:\"K Factor\",fill:\"Livello\",configure:\"Configura\",used:\"utilizzato\",remainingUnit:\"rimanente\"},printModal:{title:\"Avvia stampa\",selectPrinter:\"Seleziona stampante\",selectPlate:\"Seleziona piatto\",filamentMapping:\"Mappatura filamento\",totalCost:\"Costo totale:\",slotRemainingShort:\" - {{grams}}g rim.\",printSettings:\"Impostazioni stampa\",bedLeveling:\"Livellamento piatto\",flowCalibration:\"Calibrazione flusso\",vibrationCalibration:\"Calibrazione vibrazioni\",layerInspection:\"Ispezione primo layer\",timelapse:\"Timelapse\",startPrint:\"Avvia stampa\",addToQueue:\"Aggiungi alla coda\",cancel:\"Annulla\",noPrintersAvailable:\"Nessuna stampante disponibile\",printerBusy:\"Stampante occupata\",printerOffline:\"Stampante offline\",sameTypeDifferentColor:\"Stesso tipo, colore diverso\",filamentTypeNotLoaded:\"Tipo di filamento non caricato\",openCalendar:\"Apri calendario\",leftNozzle:\"L\",rightNozzle:\"R\",leftNozzleTooltip:\"Ugello sinistro\",rightNozzleTooltip:\"Ugello destro\",filamentOverride:\"Sostituzione filamento\",filamentOverrideHint:\"Sostituisci opzionalmente i filamenti per l'assegnazione basata sul modello. Lo scheduler abbinerà i filamenti selezionati invece dei valori 3MF originali.\",originalFilament:\"Originale\",overrideWith:\"Sostituisci con\",resetToOriginal:\"Ripristina originale\",insufficientFilamentTitle:\"Filamento insufficiente\",insufficientFilamentMessage:\"Alcune bobine assegnate hanno meno filamento rimanente di quanto necessario per questa stampa:\",insufficientFilamentLine:\"{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g\",printAnyway:\"Stampa comunque\",forceColorMatch:\"Forza corrispondenza colore\",staggerPrinterStarts:\"Stagger printer starts\",staggerGroupSize:\"Group size\",staggerInterval:\"Interval (min)\",staggerPreview:\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",staggerLastGroup:\"last group: {{count}}\",staggerTotal:\"total: {{minutes}} min\",staggerToPrinters:\"Scagliona a {{count}} stampanti\",gcodeInjection:\"Inietta G-code auto-stampa\"},backup:{title:\"Backup e ripristino\",createBackup:\"Crea backup\",restoreBackup:\"Ripristina backup\",restoreDescription:\"Sostituisci tutti i dati da un file di backup\",downloadBackup:\"Scarica backup\",uploadBackup:\"Carica backup\",lastBackup:\"Ultimo backup\",autoBackup:\"Backup automatico\",backupNow:\"Esegui backup ora\",restoreWarning:\"Avviso: il ripristino sovrascriverà tutti i dati attuali.\",includeArchives:\"Includi archivi\",includeSettings:\"Includi impostazioni\",includeProfiles:\"Includi profili\",backupSuccess:\"Backup creato con successo\",restoreSuccess:\"Backup ripristinato con successo\",backupFailed:\"Backup fallito\",restoreFailed:\"Ripristino fallito\",restoreNote:\"La stampante virtuale verrà fermata durante il ripristino\",githubBackup:\"Backup GitHub\",enabled:\"Abilitato\",cloudLoginRequired:\"Accesso Bambu Cloud richiesto. Accedi in Profili → Profili Cloud per abilitare il backup GitHub.\",cloudLoginRequiredShort:\"Accesso Cloud richiesto\",githubDescription:\"Sincronizza automaticamente i tuoi profili con un repository GitHub privato per backup e cronologia delle versioni.\",repositoryUrl:\"URL del repository\",personalAccessToken:\"Token di accesso personale\",tokenSaved:\"(salvato)\",enterNewToken:\"Inserisci un nuovo token per aggiornare\",tokenHint:\"Token a grana fine con permesso di lettura/scrittura dei contenuti\",branch:\"Branch\",manualOnly:\"Solo manuale\",hourly:\"Ogni ora\",daily:\"Giornaliero\",weekly:\"Settimanale\",includeInBackup:\"Includi nel backup\",kProfiles:\"K-Profili\",kProfilesDescription:\"Calibrazione dell'avanzamento pressione dalle stampanti connesse\",noPrintersConnected:\"Nessuna stampante connessa\",printersConnected:\"{{connected}}/{{total}} connesse\",cloudProfiles:\"Profili Cloud\",cloudProfilesDescription:\"Preset di filamento, stampante e processo da Bambu Cloud\",appSettings:\"Impostazioni App\",appSettingsDescription:\"Configurazione Bambuddy (database completo)\",spoolInventory:\"Inventario bobine\",spoolInventoryDescription:\"Bobine di filamento, cronologia utilizzo e tracciamento costi\",printArchives:\"Archivi di stampa\",printArchivesDescription:\"Metadati della cronologia di stampa (nessun file gcode/3MF)\",lastBackupAt:\"Ultimo backup:\",noBackupsYet:\"Nessun backup ancora\",next:\"Prossimo:\",startingBackup:\"Avvio del backup...\",test:\"Test\",enableBackup:\"Abilita backup\",testConnection:\"Testa connessione\",enterRepoUrl:\"Inserisci l'URL del repository\",enterRepoAndToken:\"Inserisci l'URL del repository e il token di accesso\",repoRequired:\"L'URL del repository è obbligatorio\",tokenRequired:\"Il token di accesso è obbligatorio\",githubBackupEnabled:\"Backup GitHub abilitato\",tokenUpdated:\"Token aggiornato\",settingsSaved:\"Impostazioni salvate\",failedToSave:\"Salvataggio fallito: {{message}}\",backupCompleteFiles:\"Backup completato - {{count}} file aggiornati\",backupSkippedNoChanges:\"Backup saltato - nessuna modifica\",backupFailed2:\"Backup fallito: {{message}}\",clearedLogs:\"{{count}} log eliminati\",failedToClearLogs:\"Eliminazione log fallita: {{message}}\",history:\"Cronologia\",clear:\"Cancella\",date:\"Data\",status:\"Stato\",commit:\"Commit\",localBackup:\"Backup locale\",localBackupDescription:\"Crea un backup completo dei tuoi dati Bambuddy includendo database, archivi, upload e tutti i file.\",downloadBackupLabel:\"Scarica backup\",completeBackupZip:\"Backup completo: database + tutti i file (ZIP)\",download:\"Scarica\",preparingBackup:\"Preparazione del backup...\",creatingArchive:\"Creazione dell'archivio di backup... Potrebbe richiedere del tempo per archivi di grandi dimensioni.\",downloadingFile:\"Download del file di backup...\",backupDownloaded:\"Backup scaricato con successo\",failedToCreateBackup:\"Creazione del backup fallita: {{message}}\",restore:\"Ripristina\",restoreReplacesAll:\"Il ripristino sostituisce tutti i dati.\",restoreReplacesAllDetail:\"Il database e i file attuali verranno completamente sostituiti. È necessario un riavvio dopo il ripristino.\",restoreConfirmTitle:\"Ripristina backup\",restoreConfirmMessage:`Sei sicuro di voler ripristinare da \"{{filename}}\"? Questo sostituirà completamente il tuo database e tutti i file. L'applicazione dovrà essere riavviata dopo il ripristino.`,restoreConfirmButton:\"Ripristina backup\",uploadingFile:\"Caricamento del file di backup...\",backupRestoredRestart:\"Backup ripristinato. Riavvia Bambuddy.\",failedToRestore:\"Ripristino del backup fallito. Controlla il formato del file.\",reloadNow:\"Ricarica ora\",creatingBackup:\"Creazione del backup\",restoringBackup:\"Ripristino del backup\",preparing:\"Preparazione...\",processing:\"Elaborazione...\",doNotClosePage:\"Non chiudere questa pagina e non navigare altrove. Questa operazione potrebbe richiedere diversi minuti per backup di grandi dimensioni.\",restoring:\"Ripristino in corso...\",restoreComplete:\"Ripristino completato\",restoreFailed2:\"Ripristino fallito\",importSettings:\"Importa impostazioni da un file di backup\",pleaseWaitRestoring:\"Attendere durante il ripristino dei dati\",selectBackupFile:\"Clicca per selezionare un file di backup (.json o .zip)\",duplicateHandling:\"Come funziona la gestione dei duplicati:\",matchPrinters:\"Stampanti\",matchPrintersBy:\"corrispondenza per numero di serie\",matchSmartPlugs:\"Smart Plug\",matchSmartPlugsBy:\"corrispondenza per indirizzo IP\",matchNotificationProviders:\"Provider di notifica\",matchNotificationProvidersBy:\"corrispondenza per nome\",matchFilaments:\"Filamenti\",matchFilamentsBy:\"corrispondenza per nome + tipo + marca\",matchArchives:\"Archivi\",matchArchivesBy:\"corrispondenza per hash del contenuto (sempre saltato)\",matchPendingUploads:\"Upload in sospeso\",matchPendingUploadsBy:\"corrispondenza per nome file\",matchSettingsTemplates:\"Impostazioni e modelli\",matchSettingsTemplatesBy:\"sempre sovrascritti\",replaceExisting:\"Sostituisci dati esistenti\",keepExisting:\"Mantieni dati esistenti\",overwriteDescription:\"Sovrascrivi gli elementi già esistenti con i dati del backup\",keepDescription:\"Ripristina solo gli elementi che non esistono ancora\",overwriteCaution:\"Attenzione:\",overwriteWarning:\"La sovrascrittura sostituirà le configurazioni attuali con i dati del backup. I codici di accesso delle stampanti non vengono mai sovrascritti per sicurezza.\",cancel:\"Annulla\",processingBackup:\"Elaborazione del file di backup...\",itemsRestored:\"Elementi ripristinati\",itemsSkipped:\"Elementi saltati\",restored:\"Ripristinati\",skippedAlreadyExist:\"Saltati (già esistenti)\",filesCategory:\"File (3MF, miniature, ecc.)\",andMore:\"...e altri {{count}}\",newApiKeysGenerated:\"Nuove chiavi API generate\",keysShownOnce:\"Queste chiavi vengono mostrate solo una volta. Copiale ora!\",copy:\"Copia\",noDataFound:\"Nessun dato da ripristinare trovato nel file di backup.\",close:\"Chiudi\",scheduledBackup:\"Scheduled Backups\",scheduledBackupDescription:\"Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.\",frequency:\"Frequency\",backupTime:\"Time\",retention:\"Retention\",retentionDescription:\"Number of backups to keep\",outputPath:\"Output Path\",outputPathPlaceholder:\"Default: {{path}}\",outputPathDescription:\"Leave empty for default location\",runNow:\"Run Now\",backupFiles:\"Backup Files\",noScheduledBackups:\"No backups yet\",deleteBackup:\"Delete\",deleteBackupConfirm:\"Delete this backup file?\",backupRunning:\"Backup in progress...\",scheduledBackupComplete:\"Backup completed successfully\",scheduledBackupFailed:\"Backup failed\",nextBackup:\"Next backup\",backupSize:\"Size\",utc:\"UTC\",defaultPathLabel:\"Default:\",categories:{settings:\"Impostazioni\",notification_providers:\"Provider di notifica\",notification_templates:\"Modelli di notifica\",smart_plugs:\"Smart Plug\",printers:\"Stampanti\",filaments:\"Filamenti\",maintenance_types:\"Tipi di manutenzione\",archives:\"Archivi\",projects:\"Progetti\",pending_uploads:\"Upload in sospeso\",external_links:\"Link esterni\",api_keys:\"Chiavi API\"}},tags:{title:\"Tag\",addTag:\"Aggiungi tag\",editTag:\"Modifica tag\",deleteTag:\"Elimina tag\",tagName:\"Nome tag\",tagColor:\"Colore tag\",noTags:\"Nessun tag\",deleteConfirm:\"Sei sicuro di voler eliminare questo tag?\",manageTags:\"Gestisci tag\"},uploadModal:{title:\"Carica file 3MF\",dragDrop:\"Trascina e rilascia file .3mf qui\",or:\"o\",browseFiles:\"Sfoglia file\",extractionInfo:\"Il modello stampante sarà estratto automaticamente dai metadati del file 3MF.\",uploaded:\"caricati\",failed:\"falliti\",uploading:\"Caricamento...\",upload:\"Carica\",uploadFailed:\"Caricamento fallito\"},editArchive:{title:\"Modifica archivio\",name:\"Nome\",namePlaceholder:\"Nome stampa\",printer:\"Stampante\",noPrinter:\"Nessuna stampante\",project:\"Progetto\",noProject:\"Nessun progetto\",itemsPrinted:\"Elementi stampati\",itemsPrintedHelp:\"Numero di elementi prodotti in questo job di stampa\",notes:\"Note\",notesPlaceholder:\"Aggiungi note su questa stampa...\",externalLink:\"Link esterno\",externalLinkPlaceholder:\"https://printables.com/model/...\",externalLinkHelp:\"Link a Printables, Thingiverse o altra fonte\",tags:\"Tag\",tagsPlaceholder:\"Aggiungi tag...\",addMoreTags:\"Aggiungi altri tag...\",matchingTags:'Tag corrispondenti \"{{query}}\"',existingTags:\"Tag esistenti\",clickToAdd:\"(clicca per aggiungere)\",status:\"Stato\",failureReason:\"Motivo fallimento\",selectReason:\"Seleziona motivo...\",photos:\"Foto del risultato stampato\",photosHelp:\"Clicca + per aggiungere foto del risultato stampato\",printResult:\"Risultato stampa\",saving:\"Salvataggio...\",failureReasons:{adhesionFailure:\"Fallimento adesione\",spaghettiDetached:\"Spaghetti / staccato\",layerShift:\"Spostamento layer\",cloggedNozzle:\"Ugello intasato\",filamentRunout:\"Filamento esaurito\",warping:\"Warping\",stringing:\"Stringing\",underExtrusion:\"Sotto-estrusione\",powerFailure:\"Mancanza corrente\",userCancelled:\"Annullato dall'utente\",other:\"Altro\"},statuses:{completed:\"Completato\",failed:\"Fallito\",aborted:\"Annullato\",printing:\"In stampa\"}},kProfiles:{title:\"K-Profiles\",noPrintersConfigured:\"Nessuna stampante configurata\",addPrinterInSettings:\"Aggiungi una stampante in Impostazioni per gestire i K-profiles\",noActivePrinters:\"Nessuna stampante attiva\",enablePrinterConnection:\"Abilita una connessione stampante per vedere i K-profiles\",loadingProfiles:\"Caricamento K-Profiles...\",printerOffline:\"Stampante offline\",printerOfflineDesc:\"La stampante selezionata non e connessa. Accendila per vedere i K-profiles.\",noMatchingProfiles:\"Nessun profilo corrispondente\",noMatchingProfilesDesc:\"Nessun profilo corrisponde ai criteri di ricerca\",noKProfiles:\"Nessun K-Profile\",noKProfilesDesc:\"Nessun profilo pressure advance per ugello da {{diameter}}mm\",createFirstProfile:\"Crea primo profilo\",printer:\"Stampante\",nozzle:\"Ugello\",refresh:\"Aggiorna\",addProfile:\"Aggiungi profilo\",export:\"Esporta\",import:\"Importa\",select:\"Seleziona\",selectAll:\"Seleziona tutto\",delete:\"Elimina\",searchPlaceholder:\"Cerca per nome o filamento...\",allExtruders:\"Tutti gli estrusori\",leftOnly:\"Solo sinistro\",rightOnly:\"Solo destro\",allFlow:\"Tutto flow\",hfOnly:\"Solo HF\",sOnly:\"Solo S\",sortName:\"Ordina: Nome\",sortKValue:\"Ordina: K-Value\",sortFilament:\"Ordina: Filamento\",leftExtruder:\"Estrusore sinistro\",rightExtruder:\"Estrusore destro\",modal:{addTitle:\"Aggiungi K-Profile\",editTitle:\"Modifica K-Profile\",profileName:\"Nome profilo\",profileNamePlaceholder:\"Il mio profilo PLA\",kValue:\"K-Value\",kValuePlaceholder:\"0.020\",kValueHelp:\"Intervallo tipico: 0.01 - 0.06 per PLA, 0.02 - 0.10 per PETG\",filament:\"Filamento\",selectFilament:\"Seleziona filamento...\",noFilamentsHelp:\"Nessun filamento trovato. Crea prima un K-profile in Bambu Studio.\",flowType:\"Tipo flow\",highFlow:\"High Flow\",standard:\"Standard\",nozzleSize:\"Dimensione ugello\",extruder:\"Estrusore\",extruders:\"Estrusori\",left:\"Sinistra\",right:\"Destra\",notes:\"Note (salvate localmente)\",notesPlaceholder:\"Aggiungi note su questo profilo...\",notesHelp:\"Le note sono salvate in Bambuddy, non sulla stampante\",syncing:\"Sincronizzazione con stampante...\",savingExtruder:\"Salvataggio su estrusore {{current}}/{{total}}...\",pleaseWait:\"Attendere\"},deleteConfirm:{title:\"Elimina profilo\",cannotUndo:\"Questo non può essere annullato\",message:'Sei sicuro di voler eliminare \"{{name}}\" dalla stampante?'},bulkDelete:{title:\"Elimina profili\",cannotUndo:\"Questo non può essere annullato\",message:\"Sei sicuro di voler eliminare {{count}} profili selezionati dalla stampante?\"},toast:{profileSaved:\"K-profile salvato\",profilesSaved:\"K-profile salvato su {{count}} estrusori\",selectAtLeastOneExtruder:\"Seleziona almeno un estrusore\",profileDeleted:\"K-profile eliminato\",profilesDeleted:\"Eliminati {{count}} profili\",exportedProfiles:\"Esportati {{count}} profili\",importedProfiles:\"Importati {{count}} di {{total}} profili\",noProfilesToExport:\"Nessun profilo da esportare\",invalidFileFormat:\"Formato file non valido\",failedToParseImport:\"Parsing file import fallito\",failedToSaveBatch:\"Salvataggio K-profiles fallito\",noteSaved:\"Nota salvata\",failedToSaveNote:\"Salvataggio nota fallito\"},permission:{noRead:\"Non hai il permesso di aggiornare i profili\",noCreate:\"Non hai il permesso di aggiungere profili\",noUpdate:\"Non hai il permesso di aggiornare K-profiles\",noDelete:\"Non hai il permesso di eliminare K-profiles\",noExport:\"Non hai il permesso di esportare profili\",noImport:\"Non hai il permesso di importare profili\"}},virtualPrinter:{title:\"Stampante virtuale\",running:\"In esecuzione\",stopped:\"Ferma\",description:{default:\"Abilita una stampante virtuale che appare in Bambu Studio e OrcaSlicer. I file inviati a questa stampante saranno archiviati senza stampare.\",proxy:\"Abilita un proxy che inoltra il traffico slicer a una stampante reale, permettendo la stampa remota su qualsiasi rete.\"},enable:{title:\"Abilita stampante virtuale\",visibleInSlicer:'Visibile come \"Bambuddy\" nella ricerca slicer',proxyingTo:\"In proxy verso {{name}}\",notActive:\"Non attivo\"},model:{title:\"Modello stampante\",description:\"Seleziona il modello stampante da emulare.\",restartWarning:\"Cambiare il modello riavviera la stampante virtuale\"},accessCode:{title:\"Codice accesso\",isSet:\"Codice accesso impostato\",notSet:\"Nessun codice accesso impostato - richiesto per abilitare\",placeholder:\"Inserisci codice 8 caratteri\",placeholderChange:\"Inserisci nuovo codice per cambiare\",hint:\"Deve essere esattamente 8 caratteri. Usato dagli slicer per autenticarsi.\",charCount:\"({{count}}/8)\"},targetPrinter:{title:\"Stampante target\",configured:\"Target proxy configurato\",notConfigured:\"Nessuna stampante target selezionata - richiesta per modalita proxy\",placeholder:\"Seleziona una stampante...\",hint:\"Seleziona la stampante a cui fare proxy. La stampante deve essere in modalita LAN.\",noPrinters:\"Nessuna stampante configurata. Aggiungi una stampante per usare la modalita proxy.\"},remoteInterface:{title:\"Sovrascrittura interfaccia di rete\",configured:\"Sovrascrittura interfaccia attiva\",optional:\"Opzionale - usare se l'IP rilevato automaticamente e sbagliato (es. piu NIC, Docker, VPN)\",placeholder:\"Rilevamento automatico (predefinito)...\",hint:\"Sovrascrive l'indirizzo IP pubblicizzato via SSDP e usato nel certificato TLS. Utile quando Bambuddy ha piu interfacce di rete.\"},mode:{title:\"Modalita\",archive:\"Archivio\",archiveDesc:\"Archivia subito i file\",review:\"Revisione\",reviewDesc:\"Rivedi prima di archiviare\",queue:\"Coda\",queueDesc:\"Archivia e aggiungi alla coda\",proxy:\"Proxy\",proxyDesc:\"Inoltra a stampante reale\"},autoDispatch:{title:\"Avvio automatico\",description:\"Avvia automaticamente le stampe aggiunte alla coda. Se disattivato, le stampe attendono l'avvio manuale.\"},setupRequired:{title:\"Configurazione necessaria\",description:\"La stampante virtuale richiede configurazioni di sistema aggiuntive prima di funzionare. Include port forwarding, regole firewall e impostazioni specifiche della piattaforma.\",readGuide:\"Leggi la guida prima di abilitare\"},howItWorks:{title:\"Come funziona\",step1:\"Sulla stessa LAN, le stampanti virtuali appaiono automaticamente nel tuo slicer (Bambu Studio / OrcaSlicer). Da altre reti, aggiungile manualmente tramite indirizzo IP e codice di accesso.\",step2:'In modalità Archivio, Revisione e Coda, usa il pulsante \"Invia\" nel tuo slicer per caricare file 3MF su Bambuddy. Lo slicer mostrerà \"Stampa riuscita\" — il file viene salvato, non stampato.',step3:\"In modalità Proxy, la stampante virtuale inoltra tutto il traffico a una stampante reale — le stampe partono immediatamente come con una connessione diretta.\"},status:{title:\"Dettagli stato\",printerName:\"Nome stampante\",model:\"Modello\",serialNumber:\"Numero seriale\",mode:\"Modalita\",pendingFiles:\"File in sospeso\",targetPrinter:\"Stampante target\",ftpPort:\"Porta FTP\",mqttPort:\"Porta MQTT\",ftpConnections:\"Connessioni FTP\",mqttConnections:\"Connessioni MQTT\"},toast:{updated:\"Impostazioni stampante virtuale aggiornate\",failedToUpdate:\"Aggiornamento impostazioni fallito\",accessCodeRequired:\"Imposta prima un codice accesso\",targetPrinterRequired:\"Seleziona prima una stampante target\",bindIpRequired:\"Impostare prima un indirizzo IP\",accessCodeEmpty:\"Il codice accesso non può essere vuoto\",accessCodeLength:\"Il codice accesso deve essere esattamente 8 caratteri\",created:\"Stampante virtuale creata\",failedToCreate:\"Impossibile creare la stampante virtuale\",deleted:\"Stampante virtuale eliminata\",failedToDelete:\"Impossibile eliminare la stampante virtuale\"},list:{title:\"Stampanti virtuali\",add:\"Aggiungi\",addFirst:\"Aggiungi stampante virtuale\",empty:\"Nessuna stampante virtuale configurata. Aggiungine una per iniziare.\"},bindIp:{title:\"Interfaccia di rete\",placeholder:\"Seleziona interfaccia...\",hint:\"Interfaccia di rete a cui questa stampante virtuale si collega. Deve essere unica per stampante.\"},proxy:{accessCodeHint:\"In modalita proxy, usa il codice di accesso della stampante di destinazione nello slicer. La connessione viene inoltrata in modo trasparente alla stampante reale.\"},addDialog:{title:\"Aggiungi stampante virtuale\",name:\"Nome\",hint:\"Potrai configurare il codice di accesso, la stampante di destinazione e altre impostazioni dopo la creazione.\",create:\"Crea\"},deleteConfirm:{title:\"Elimina stampante virtuale\",message:'Sei sicuro di voler eliminare \"{{name}}\"? Tutti i servizi di questa stampante verranno interrotti.'}},modelViewer:{openInSlicer:\"Apri nello slicer\",tabs:{model:\"Modello 3D\",gcode:\"Anteprima G-code\"},notAvailable:\"non disponibile\",notSliced:\"non sezionato\",plates:\"Piatti\",allPlates:\"Tutti i piatti\",plateNumber:\"Piatto {{number}}\",plateCount:\"{{count}} piatto\",plateCount_other:\"{{count}} piatti\",objectCount:\"{{count}} oggetto\",objectCount_other:\"{{count}} oggetti\",filamentCount:\"{{count}} filamento\",filamentCount_other:\"{{count}} filamenti\",eta:\"ETA {{minutes}} min\",noPreview:\"Nessuna anteprima disponibile per questo file\",pagination:{pageOf:\"Pagina {{current}} di {{total}}\",prev:\"Prec\",next:\"Succ\"},errors:{failedToLoad:\"Caricamento file fallito\",noMeshes:\"Nessuna mesh trovata nel file 3MF\",unsupportedFormat:\"Formato file non supportato\"}},maintenanceDescriptions:{lubricateCarbonRods:\"Applica lubrificante alle aste in carbonio per un movimento fluido\",lubricateRails:\"Applica lubrificante alle guide lineari per un movimento fluido\",cleanNozzle:\"Pulisci hotend e ugello per prevenire intasamenti\",checkBelts:\"Verifica tensione cinghie per stampe accurate\",cleanBuildPlate:\"Pulisci il piatto per migliorare l'adesione\",checkExtruder:\"Ispeziona ingranaggi estrusore per usura\",checkCooling:\"Assicurati che le ventole di raffreddamento funzionino\",generalInspection:\"Ispezione generale stampante\",cleanCarbonRods:\"Pulisci le aste in carbonio per ridurre attrito\",lubricateSteelRods:\"Applica lubrificante alle aste in acciaio per un movimento fluido\",cleanSteelRods:\"Pulisci le aste in acciaio per ridurre attrito\",cleanLinearRails:\"Pulisci le guide lineari per rimuovere polvere e detriti\",checkPtfeTube:\"Ispeziona il tubo PTFE per usura o danni\",replaceHepaFilter:\"Sostituisci filtro HEPA per qualità aria\",replaceCarbonFilter:\"Sostituisci filtro a carbone attivo\",lubricateLeftNozzleRail:\"Lubrifica guida ugello sinistro (serie H2)\"},smartPlugs:{offline:\"Offline\",admin:\"Amministrazione\",openPlugAdminPage:\"Apri pagina amministrazione presa\",deleteSmartPlug:\"Elimina presa smart\",turnOnSmartPlug:\"Accendi presa smart\",turnOffSmartPlug:\"Spegni presa smart\",turnOn:\"Accendi\",turnOff:\"Spegni\",addSmartPlug:{scanningNetwork:\"Scansione rete...\",chooseEntity:\"Scegli un'entità...\",connectionFailed:\"Connessione fallita\",searchEntities:\"Cerca entità...\",searchPowerSensors:\"Cerca sensori di potenza...\",searchEnergySensors:\"Cerca sensori di energia...\",placeholders:{plugName:\"Presa soggiorno\",mqttStateOnValue:\"ON, true, 1\",mqttSameAsPower:\"Stesso del topic potenza, o diverso\"}},linkedTo:\"Collegato a:\",monitorOnly:\"Solo monitoraggio\",alerts:\"Avvisi\",scheduleOn:\"On {{time}}\",scheduleOff:\"Off {{time}}\",on:\"On\",off:\"Off\",power:\"Potenza\",kwhToday:\"kWh Oggi\",settings:\"Impostazioni\",automationSettings:\"Impostazioni automazione\",showInSwitchbar:\"Mostra nella barra interruttori\",quickAccessSidebar:\"Accesso rapido dalla barra laterale\",enabled:\"Abilitato\",enableAutomation:\"Abilita automazione per questa presa\",autoOn:\"Auto On\",autoOnDescription:\"Accendi quando inizia la stampa\",autoOff:\"Auto Off\",autoOffDescription:\"Spegni quando la stampa è completata (una tantum)\",autoOffPersistent:\"Mantieni attivo\",autoOffPersistentDescription:\"Resta attivo tra le stampe invece di una tantum\",turnOffDelayMode:\"Modalità ritardo spegnimento\",time:\"Tempo\",temp:\"Temp\",delayMinutes:\"Ritardo (minuti)\",tempThreshold:\"Soglia temperatura (°C)\",tempThresholdDescription:\"Si spegne quando l'ugello si raffredda sotto questa temperatura\",edit:\"Modifica\",deleteConfirm:'Sei sicuro di voler eliminare \"{{name}}\"? Questa azione non può essere annullata.',turnOnConfirm:'Sei sicuro di voler accendere \"{{name}}\"?',turnOffConfirm:`Sei sicuro di voler spegnere \"{{name}}\"? Questo interromperà l'alimentazione del dispositivo collegato.`,failedToTurn:'Impossibile {{action}} \"{{name}}\"',unknown:\"Sconosciuto\",addTitle:\"Aggiungi presa smart\",editTitle:\"Modifica presa smart\",stopScanning:\"Interrompi scansione\",discoverTasmota:\"Scopri dispositivi Tasmota\",foundDevices:\"{{count}} dispositivo/i trovato/i - clicca per selezionare:\",noDevicesFound:\"Nessun dispositivo Tasmota trovato nella rete\",haNotConfigured:\"Home Assistant non è configurato. Configuralo in\",haSettingsPath:\"Impostazioni → Rete → Home Assistant\",selectEntity:\"Seleziona entità *\",ipAddress:\"Indirizzo IP *\",nameLabel:\"Nome *\",username:\"Nome utente\",password:\"Password\",authHint:\"Lascia vuoto se il tuo dispositivo Tasmota non richiede autenticazione\",linkToPrinter:\"Collega alla stampante\",noPrinter:\"Nessuna stampante (solo controllo manuale)\",linkingDescription:\"Il collegamento abilita accensione/spegnimento automatico all'inizio/fine stampa\",powerAlerts:\"Avvisi potenza\",alertAbove:\"Avviso se sopra (W)\",alertBelow:\"Avviso se sotto (W)\",alertDescription:\"Ricevi notifiche quando il consumo supera queste soglie. Lascia vuoto per disabilitare quella direzione.\",dailySchedule:\"Programma giornaliero\",turnOnAt:\"Accendi alle\",turnOffAt:\"Spegni alle\",scheduleDescription:\"Accendi/spegni automaticamente la presa a questi orari ogni giorno. Lascia vuoto per saltare quell'azione.\",showOnPrinterCard:\"Mostra sulla scheda stampante\",displayOnPrinterCard:\"Mostra pulsante sulla scheda stampante\",connectedResult:\"Connesso!\",deviceLabel:\"Dispositivo: {{name}} - \",stateLabel:\"Stato: {{state}}\",test:\"Test\",delete:\"Elimina\",save:\"Salva\",add:\"Aggiungi\",cancel:\"Annulla\",failedToStartScan:\"Impossibile avviare la scansione\",nameRequired:\"Il nome è obbligatorio\",entityRequired:\"L'entità è obbligatoria per le prese Home Assistant\",mqttTopicRequired:\"Almeno un topic MQTT deve essere configurato per potenza, energia o monitoraggio stato\",loadingEntities:\"Caricamento entità...\",loading:\"Caricamento...\",failedToLoadEntities:\"Impossibile caricare le entità: {{error}}\",noEntitiesMatching:'Nessuna entità trovata corrispondente a \"{{search}}\"',noEntitiesAvailable:\"Nessuna entità disponibile\",searchingEntities:\"Ricerca in tutte le entità ({{count}} trovate)\",showingEntities:\"Mostrando switch, light, input_boolean ({{count}} disponibili)\",energyMonitoringOptional:\"Monitoraggio energia (Opzionale)\",energyMonitoringHint:\"Cerca e seleziona i sensori che forniscono dati di potenza/energia.\",powerSensorW:\"Sensore potenza (W)\",energyTodayKwh:\"Energia oggi (kWh)\",totalEnergyKwh:\"Energia totale (kWh)\",noMatchingSensors:\"Nessun sensore corrispondente\",none:\"Nessuno\",mqttNotConfigured:\"Broker MQTT non configurato. Imposta l'indirizzo del broker in\",mqttSettingsPath:\"Impostazioni → Rete → Pubblicazione MQTT\",mqttNotConfiguredSuffix:\"(non è necessario abilitare la pubblicazione, basta inserire i dettagli del broker).\",mqttMonitorOnlyDescription:\"Le prese MQTT ricevono dati di potenza/energia tramite sottoscrizione MQTT. Il controllo on/off non è disponibile - usa il tuo broker MQTT o sistema domotico.\",powerMonitoring:\"Monitoraggio potenza\",energyMonitoring:\"Monitoraggio energia\",stateMonitoring:\"Monitoraggio stato\",optional:\"opzionale\",topic:\"Topic\",jsonPath:\"Percorso JSON\",multiplier:\"Moltiplicatore\",onValue:\"Valore ON\",mqttPowerHint:`Il percorso JSON estrae il valore dal payload JSON (es. \"power_l1\"). Lascia vuoto se il topic pubblica valori numerici grezzi.\nUsa moltiplicatore 0.001 per mW→W, 1000 per kW→W.`,mqttEnergyHint:`Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\nUsa moltiplicatore 0.001 per Wh→kWh, 1000 per MWh→kWh.`,mqttStateHint:`Il percorso JSON estrae il valore dal payload JSON. Lascia vuoto per valori grezzi.\nValore ON: la stringa esatta che significa \"ON\". Lascia vuoto per rilevamento auto (ON, true, 1).`,restControl:\"Control\",restOnUrl:\"Turn ON URL\",restOffUrl:\"Turn OFF URL\",restOnBody:\"ON Request Body\",restOffBody:\"OFF Request Body\",restMethod:\"HTTP Method\",restHeaders:\"Custom Headers (JSON)\",restStatusUrl:\"Status URL\",restStatusPath:\"State JSON Path\",restStatusOnValue:\"ON Value\",restPowerUrl:\"URL potenza\",restPowerPath:\"Power JSON Path\",restPowerMultiplier:\"Moltiplicatore potenza\",restEnergyUrl:\"URL energia\",restEnergyPath:\"Energy JSON Path\",restEnergyMultiplier:\"Moltiplicatore energia\",restUrlRequired:\"At least one URL (ON or OFF) is required for REST plugs\",restHeadersHint:'e.g. {\"Authorization\": \"Bearer your-token\"}',restBodyHint:'e.g. ON, {\"state\": \"on\"}',restStatusHint:\"URL to poll for current state\",restPathHint:\"e.g. state or data.power.status\",restPowerUrlHint:\"URL separato per i dati di potenza (usa l'URL di stato se vuoto)\",restEnergyUrlHint:\"URL separato per i dati di energia (usa l'URL di stato se vuoto)\",restEnergyHint:\"Ogni valore può usare il proprio URL o ricadere sull'URL di stato. Usa i moltiplicatori per la conversione delle unità (es. 0.001 per convertire Wh in kWh).\",testConnection:\"Test Connection\",connectionSuccess:\"Connection successful\",noSwitchesInSwitchbar:\"Nessun interruttore nella barra\",enableSwitchbarHint:'Abilita \"Mostra nella barra interruttori\" in Impostazioni > Smart Plugs'},notifications:{providerTypes:{callmebot:\"CallMeBot/WhatsApp\",ntfy:\"ntfy\",pushover:\"Pushover\",telegram:\"Telegram\",email:\"Email\",discord:\"Discord\",webhook:\"Webhook\",homeassistant:\"Home Assistant\"},providerDescriptions:{email:\"Notifiche email tramite SMTP\",telegram:\"Notifiche tramite bot Telegram\",discord:\"Invia a un canale Discord tramite webhook\",ntfy:\"Notifiche push gratuite e self-hostabili\",pushover:\"Notifiche push semplici e affidabili\",callmebot:\"Notifiche WhatsApp gratuite tramite CallMeBot\",webhook:\"POST HTTP generico verso qualsiasi URL\",homeassistant:\"Notifiche persistenti nella dashboard di Home Assistant\"},lastSuccess:\"Ultimo: {{date}}\",error:\"Errore\",printer:\"Stampante:\",allPrinters:\"Tutte le stampanti\",sendTestNotification:\"Invia notifica di prova\",eventSettings:\"Impostazioni eventi\",enabled:\"Abilitato\",sendFromProvider:\"Invia notifiche da questo provider\",printEvents:\"Eventi di stampa\",printerStatus:\"Stato stampante\",amsAlarms:\"Allarmi AMS\",amsHtAlarms:\"Allarmi AMS-HT\",printQueue:\"Coda di stampa\",start:\"Avvio\",plateCheck:\"Controllo piatto\",complete:\"Completato\",failed:\"Fallito\",stopped:\"Interrotto\",progress:\"Avanzamento\",offline:\"Offline\",lowFilament:\"Filamento scarso\",maintenance:\"Manutenzione\",amsHumidity:\"Umidità AMS\",amsTemp:\"Temp AMS\",amsHtHumidity:\"Umidità AMS-HT\",amsHtTemp:\"Temp AMS-HT\",bedCooled:\"Piatto raffreddato\",firstLayer:\"Primo strato\",quiet:\"Silenzioso\",digest:\"Riepilogo {{time}}\",printStarted:\"Stampa avviata\",plateNotEmpty:\"Piatto non vuoto\",plateNotEmptyDescription:\"Oggetti rilevati prima della stampa\",printCompleted:\"Stampa completata\",bedCooledLabel:\"Piatto raffreddato\",bedCooledDescription:\"Piatto raffreddato sotto la soglia dopo la stampa\",firstLayerCompleteLabel:\"Primo strato completato\",firstLayerCompleteDescription:\"Notifica con foto al termine del primo strato\",missingSpoolAssignmentLabel:\"Assegnazione bobina mancante\",missingSpoolAssignmentDescription:\"Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata\",printFailed:\"Stampa fallita\",printStopped:\"Stampa interrotta\",progressMilestones:\"Traguardi di avanzamento\",progressMilestonesDescription:\"Notifica al 25%, 50%, 75%\",printerOffline:\"Stampante offline\",printerError:\"Errore stampante\",lowFilamentLabel:\"Filamento scarso\",maintenanceDue:\"Manutenzione necessaria\",maintenanceDueDescription:\"Notifica quando è necessaria la manutenzione\",amsHumidityHigh:\"Umidità AMS elevata\",amsHumidityHighDescription:\"L'umidità dell'AMS standard supera la soglia\",amsTemperatureHigh:\"Temperatura AMS elevata\",amsTemperatureHighDescription:\"La temperatura dell'AMS standard supera la soglia\",amsHtHumidityHigh:\"Umidità AMS-HT elevata\",amsHtHumidityHighDescription:\"L'umidità dell'AMS-HT supera la soglia\",amsHtTemperatureHigh:\"Temperatura AMS-HT elevata\",amsHtTemperatureHighDescription:\"La temperatura dell'AMS-HT supera la soglia\",jobAdded:\"Lavoro aggiunto\",jobAddedDescription:\"Lavoro aggiunto alla coda\",jobAssigned:\"Lavoro assegnato\",jobAssignedDescription:\"Lavoro basato su modello assegnato alla stampante\",jobStarted:\"Lavoro avviato\",jobStartedDescription:\"Lavoro in coda avviato per la stampa\",jobWaiting:\"Lavoro in attesa\",jobWaitingDescription:\"Lavoro in attesa di filamento o stampante\",jobSkipped:\"Lavoro saltato\",jobSkippedDescription:\"Lavoro saltato (precedente fallito)\",jobFailed:\"Lavoro fallito\",jobFailedDescription:\"Avvio del lavoro fallito\",queueComplete:\"Coda completata\",queueCompleteDescription:\"Tutti i lavori in coda completati\",quietHours:\"Ore silenziose\",noNotificationsDuring:\"Nessuna notifica durante queste ore\",editProviderToChangeQuietHours:\"Modifica il provider per cambiare le ore silenziose\",dailyDigest:\"Riepilogo giornaliero\",batchNotifications:\"Raggruppa le notifiche in un unico riepilogo giornaliero\",sendAt:\"Invia alle {{time}}\",editProviderToChangeDigestTime:\"Modifica il provider per cambiare l'orario del riepilogo\",edit:\"Modifica\",deleteProvider:\"Elimina provider di notifica\",deleteConfirm:'Sei sicuro di voler eliminare \"{{name}}\"? Questa azione non può essere annullata.',delete:\"Elimina\",addTitle:\"Aggiungi provider di notifica\",editTitle:\"Modifica provider di notifica\",nameLabel:\"Nome *\",namePlaceholder:\"Le mie notifiche\",providerTypeLabel:\"Tipo di provider *\",configuration:\"Configurazione\",testConfiguration:\"Testa configurazione\",printerFilter:\"Filtro stampante\",onlyFromPrinter:\"Invia notifiche solo per eventi da questa stampante\",quietHoursDnd:\"Ore silenziose (Non disturbare)\",quietStart:\"Inizio\",quietEnd:\"Fine\",dailyDigestLabel:\"Riepilogo giornaliero\",sendDigestAt:\"Invia riepilogo alle\",digestCollected:\"Gli eventi verranno raccolti e inviati come riepilogo unico a quest'ora\",notificationEvents:\"Eventi di notifica\",progressPercent:\"(25%, 50%, 75%)\",bedCooledAfterPrint:\"(dopo il completamento della stampa)\",cancel:\"Annulla\",save:\"Salva\",add:\"Aggiungi\",nameRequired:\"Il nome è obbligatorio\",fieldRequired:\"{{field}} è obbligatorio\",phoneNumber:\"Numero di telefono\",apiKey:\"Chiave API\",serverUrl:\"URL del server\",topic:\"Argomento\",authToken:\"Token di autenticazione\",userKey:\"Chiave utente\",appToken:\"Token applicazione\",priority:\"Priorità\",botToken:\"Token del bot\",chatId:\"ID chat\",smtpServer:\"Server SMTP\",smtpPort:\"Porta SMTP\",security:\"Sicurezza\",authentication:\"Autenticazione\",username:\"Nome utente\",password:\"Password\",fromEmail:\"Email mittente\",toEmail:\"Email destinatario\",webhookUrl:\"URL webhook\",payloadFormat:\"Formato payload\",authorization:\"Autorizzazione\",titleFieldName:\"Nome campo titolo\",messageFieldName:\"Nome campo messaggio\",editTemplate:\"Modifica modello: {{name}}\",titleLabel:\"Titolo\",bodyLabel:\"Corpo\",titlePlaceholder:\"Titolo della notifica...\",bodyPlaceholder:\"Corpo della notifica...\",availableVariables:\"Variabili disponibili\",clickToInsert:\"Clicca per inserire alla posizione del cursore nel corpo\",livePreview:\"Anteprima live\",hide:\"Nascondi\",show:\"Mostra\",loadingPreview:\"Caricamento anteprima...\",enterTemplateContent:\"Inserisci il contenuto del modello per vedere l'anteprima\",titlePreview:\"Titolo:\",bodyPreview:\"Corpo:\",resetToDefault:\"Ripristina predefinito\",titleRequired:\"Il titolo è obbligatorio\",bodyRequired:\"Il corpo è obbligatorio\",notificationLog:\"Registro notifiche\",showFailedOnly:\"Solo fallite\",last24Hours:\"Ultime 24 ore\",last7Days:\"Ultimi 7 giorni\",last30Days:\"Ultimi 30 giorni\",last90Days:\"Ultimi 90 giorni\",justNow:\"Proprio ora\",noFailedNotifications:\"Nessuna notifica fallita\",noNotificationsLogged:\"Nessuna notifica registrata\",unknownProvider:\"Provider sconosciuto\",logTitle:\"Titolo\",logMessage:\"Messaggio\",logError:\"Errore\",logProvider:\"Provider: {{type}}\",logTime:\"Ora: {{time}}\",refresh:\"Aggiorna\",clearOld:\"Cancella vecchie\",statsSummary:\"Ultimi {{days}} giorni:\",statsNotifications:\"notifiche\",statsSent:\"{{count}} inviate\",statsFailed:\"{{count}} fallite\",eventTypes:{print_start:\"Stampa avviata\",print_complete:\"Stampa completata\",print_failed:\"Stampa fallita\",print_stopped:\"Stampa interrotta\",print_progress:\"Avanzamento\",printer_offline:\"Stampante offline\",printer_error:\"Errore stampante\",filament_low:\"Filamento scarso\",maintenance_due:\"Manutenzione necessaria\",test:\"Prova\"},userEmail:{title:\"Notifiche\",emailNotifications:\"Notifiche via e-mail\",emailNotificationsDesc:\"Ricevi notifiche via e-mail per i tuoi lavori di stampa. Le e-mail vengono inviate tramite le impostazioni SMTP configurate nell'autenticazione avanzata.\",sendingTo:\"Le notifiche verranno inviate a\",noEmailWarning:\"Il tuo account non ha un indirizzo e-mail. Contatta un amministratore per aggiungerne uno.\",printJobNotifications:\"Notifiche lavori di stampa\",printJobNotificationsDesc:\"Scegli quali eventi attivano le notifiche e-mail per i lavori di stampa che invii.\",printJobStarts:\"Inizio lavoro di stampa\",printJobStartsDesc:\"Ricevi una notifica quando il tuo lavoro di stampa inizia.\",printJobFinishes:\"Fine lavoro di stampa\",printJobFinishesDesc:\"Ricevi una notifica quando il tuo lavoro di stampa si completa correttamente.\",printErrors:\"Errori di stampa\",printErrorsDesc:\"Ricevi una notifica quando il tuo lavoro di stampa fallisce o incontra un errore.\",printJobStops:\"Lavoro di stampa interrotto\",printJobStopsDesc:\"Ricevi una notifica quando il tuo lavoro di stampa viene annullato o interrotto.\",saveSuccess:\"Preferenze di notifica salvate.\",saveError:\"Impossibile salvare le preferenze di notifica.\"}},richTextEditor:{bold:\"Grassetto\",italic:\"Corsivo\",underline:\"Sottolineato\",bulletList:\"Elenco puntato\",numberedList:\"Elenco numerato\",alignLeft:\"Allinea a sinistra\",alignCenter:\"Allinea al centro\",alignRight:\"Allinea a destra\",addLink:\"Aggiungi link\",removeLink:\"Rimuovi link\"},externalLinks:{noLinksConfigured:\"Nessun link esterno configurato\",deleteLink:\"Elimina link\",removeCustomIcon:\"Rimuovi icona personalizzata\",openInNewTab:\"Apri in nuova scheda\",placeholders:{linkName:\"Il mio link\"}},keyboardShortcuts:{title:\"Scorciatoie da tastiera\",navigation:\"Navigazione\",archivesSection:\"Archivi\",kProfilesSection:\"Profili K\",generalSection:\"Generale\",shortcuts:{goToPrinters:\"Vai a Stampanti\",goToArchives:\"Vai ad Archivi\",goToQueue:\"Vai a Coda\",goToStats:\"Vai a Statistiche\",goToProfiles:\"Vai a Profili cloud\",goToSettings:\"Vai a Impostazioni\",focusSearch:\"Vai alla ricerca\",openUploadModal:\"Apri finestra di caricamento\",clearSelection:\"Cancella selezione / deseleziona input\",contextMenu:\"Menu contestuale sulle schede\",refreshProfiles:\"Aggiorna profili\",newProfile:\"Nuovo profilo\",exitSelectionMode:\"Esci dalla modalità selezione\",showHelp:\"Mostra questa guida\"},footer:\"Premi Esc o clicca fuori per chiudere\"},notificationLog:{title:\"Registro notifiche\",events:{printStarted:\"Stampa avviata\",printComplete:\"Stampa completata\",printFailed:\"Stampa fallita\",printStopped:\"Stampa interrotta\",progress:\"Avanzamento\",printerOffline:\"Stampante offline\",printerError:\"Errore stampante\",lowFilament:\"Filamento in esaurimento\",maintenanceDue:\"Manutenzione in scadenza\",test:\"Test\"},timeAgo:{justNow:\"Adesso\",minutesAgo:\"{{minutes}} min fa\",hoursAgo:\"{{hours}} ore fa\"}},restoreBackup:{title:\"Ripristina backup\",restoring:\"Ripristino...\",restoreComplete:\"Ripristino completato\",restoreFailed:\"Ripristino fallito\",importSettings:\"Importa impostazioni da un file di backup\",pleaseWait:\"Attendere il ripristino dei dati\",clickToSelect:\"Clicca per selezionare il file di backup (.json o .zip)\",howDuplicateHandling:\"Come funziona la gestione dei duplicati:\",categories:{printers:\"Stampanti\",smartPlugs:\"Prese smart\",notificationProviders:\"Provider di notifica\",filaments:\"Filamenti\",archives:\"Archivi\",pendingUploads:\"Caricamenti in sospeso\",settingsTemplates:\"Impostazioni e modelli\"},matchingInfo:{printers:\"abbinati per numero di serie\",smartPlugs:\"abbinati per indirizzo IP\",notificationProviders:\"abbinati per nome\",filaments:\"abbinati per nome + tipo + marca\",archives:\"abbinati per hash del contenuto\",pendingUploads:\"abbinati per nome file\",settingsTemplates:\"sempre sovrascritti\"},replaceExisting:\"Sostituisci dati esistenti\",keepExisting:\"Mantieni dati esistenti\",replaceDescription:\"Sovrascrivi gli elementi già esistenti con i dati del backup\",keepDescription:\"Ripristina solo gli elementi che non esistono già\",caution:\"Attenzione:\",cautionText:\"La sovrascrittura sostituirà le configurazioni attuali con i dati del backup. I codici di accesso delle stampanti non vengono mai sovrascritti per sicurezza.\",itemsRestored:\"Elementi ripristinati\",itemsSkipped:\"Elementi saltati\",restored:\"Ripristinati\",skipped:\"Saltati (già esistenti)\",filesLabel:\"File (3MF, miniature, ecc.)\",newApiKeysGenerated:\"Nuove chiavi API generate\",newApiKeysWarning:\"Queste chiavi vengono mostrate una sola volta. Copiale adesso!\",processingBackup:\"Elaborazione file di backup...\",noDataFound:\"Nessun dato trovato da ripristinare nel file di backup.\",failedToRestore:\"Impossibile ripristinare il backup. Verificare il formato del file.\"},backupExport:{title:\"Esporta backup\",selectData:\"Seleziona i dati da includere\",selectAll:\"Seleziona tutto\",selectNone:\"Deseleziona tutto\",categoryDescriptions:{settings:\"Lingua, tema, preferenze di aggiornamento\",notifications:\"ntfy, Pushover, Discord, ecc.\",templates:\"Modelli di messaggi personalizzati\",smartPlugs:\"Configurazioni prese Tasmota\",externalLinks:\"Link della barra laterale a servizi esterni\",printers:\"Info stampanti (codici di accesso esclusi)\",plateDetection:\"Immagini di riferimento piatto vuoto\",filaments:\"Tipi di filamento e costi\",maintenance:\"Programmi di manutenzione personalizzati\",archives:\"Tutti i dati di stampa + file (3MF, miniature, foto)\",projects:\"Progetti, elementi BOM e allegati\",pendingUploads:\"Caricamenti della stampante virtuale in attesa di revisione\",apiKeys:\"Chiavi API webhook (nuove chiavi generate all'importazione)\"},requiresPrinters:\"Richiede la selezione di Stampanti\",zipFileWarning:\"Verrà creato un file ZIP.\",zipFileDescription:\"Include tutti i file 3MF, miniature, timelapse e foto. Potrebbe richiedere tempo e produrre un file di grandi dimensioni.\",includeAccessCodes:\"Includi codici di accesso\",includeAccessCodesDescription:\"Per il trasferimento su un'altra macchina\",includeAccessCodesWarning:\"I codici di accesso saranno inclusi in testo semplice. Mantieni sicuro questo file di backup!\",categoriesSelected:\"{{selectedCount}} categorie selezionate\"},pendingUploads:{placeholders:{notes:\"Aggiungi note su questa stampa...\"},discardUpload:\"Scarta caricamento\",archiveAllUploads:\"Archivia tutti i caricamenti\",discardAllUploads:\"Scarta tutti i caricamenti\",archive:\"Archivia\",timeAgo:{justNow:\"Adesso\",minutesAgo:\"{{minutes}} min fa\",hoursAgo:\"{{hours}} ore fa\",daysAgo:\"{{days}} giorni fa\"}},apiBrowser:{placeholders:{requestBody:\"Corpo della richiesta JSON...\",searchEndpoints:\"Cerca endpoint...\"}},configureAmsSlot:{title:\"Configura Slot AMS\",slotConfigured:\"Slot configurato!\",configuringSlot:\"Configurazione slot:\",slotLabel:\"{{ams}} Slot {{slot}}\",searchPresets:\"Cerca preset...\",colorPlaceholder:\"Nome colore o hex (es. marrone, FF8800)\",clearCustomColor:\"Cancella colore personalizzato\",noCloudPresets:\"Nessun preset cloud. Accedi a Bambu Cloud per sincronizzare.\",noPresetsAvailable:\"Nessun preset disponibile. Accedi a Bambu Cloud o importa profili locali.\",noMatchingPresets:\"Nessun preset corrispondente trovato.\",custom:\"Personalizzato\",builtin:\"Integrato\",settingsSentToPrinter:\"Impostazioni inviate alla stampante\",filamentProfile:\"Profilo filamento\",kProfileLabel:\"Profilo K (Pressure Advance)\",filteringFor:\"Filtrando per: {{material}}\",noKProfile:\"Nessun profilo K (usa predefinito 0.020)\",noMatchingKProfiles:\"Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.\",selectFilamentFirst:\"Seleziona prima un profilo filamento\",kFromCalibration:\"K={{value}} dalla calibrazione stampante\",customColorLabel:\"Colore personalizzato (opzionale)\",presetColors:\"Colori {{name}}:\",showLessColors:\"Mostra meno colori\",showMoreColors:\"Mostra più colori\",clear:\"Cancella\",hexLabel:\"Hex: #{{hex}}\",resetting:\"Ripristino...\",resetSlot:\"Ripristina slot\",cancel:\"Annulla\",configuring:\"Configurazione...\",configureSlot:\"Configura slot\"},githubBackup:{title:\"Backup GitHub\",history:\"Cronologia\",downloadBackup:\"Scarica backup\",restoreBackup:\"Ripristina backup\",noBackupsYet:\"Nessun backup ancora\"},emailSettings:{placeholders:{fromName:\"BamBuddy\"}},tagManagement:{searchTags:\"Cerca tag...\",renameTag:\"Rinomina tag\",deleteTag:\"Elimina tag\"},notificationTemplates:{placeholders:{title:\"Titolo notifica...\",body:\"Corpo notifica...\"}},batchTag:{placeholders:{newTag:\"Inserisci nuovo tag...\"}},photoGallery:{deletePhoto:\"Elimina foto\"},filamentHoverCard:{copySpoolUuid:\"Copia UUID bobina\"},kProfilesView:{hasNote:\"Ha una nota\",copyProfile:\"Copia profilo\"},layout:{openMenu:\"Apri menu\",noPermissionSystemInfo:\"Non hai il permesso di visualizzare le informazioni di sistema\"},dashboard:{dragToReorder:\"Trascina per riordinare\",hideWidget:\"Nascondi widget\"},notificationProviderCard:{deleteNotificationProvider:\"Elimina provider di notifica\"},fileManagerModal:{closeFileManager:\"Chiudi gestore file\",sortFiles:\"Ordina file\",goToParentFolder:\"Vai alla cartella superiore\",threeView:\"Vista 3D\"},embeddedCameraViewer:{refreshStream:\"Aggiorna stream\",close:\"Chiudi\",zoomOut:\"Rimpicciolisci\",resetZoom:\"Reimposta zoom\",zoomIn:\"Ingrandisci\",dragToResize:\"Trascina per ridimensionare\"},timelapseViewer:{skipBack5s:\"Indietro 5s\",skipForward5s:\"Avanti 5s\"},notificationProviders:{descriptions:{email:\"Notifiche email via SMTP\",telegram:\"Notifiche tramite bot Telegram\",discord:\"Invia a canale Discord tramite webhook\",ntfy:\"Notifiche push gratuite e self-hostabili\",pushover:\"Notifiche push semplici e affidabili\",callmebot:\"Notifiche WhatsApp gratuite tramite CallMeBot\",webhook:\"POST HTTP generico a qualsiasi URL\"}},logViewer:{searchPlaceholder:\"Cerca messaggio o nome logger...\",noLogEntries:\"Nessuna voce di log trovata\"},switchbarPopover:{noSwitchesInSwitchbar:\"Nessun interruttore nella barra\"},projectPageModal:{placeholders:{title:\"Titolo\",designer:\"Designer\",license:\"Licenza\",description:\"Inserisci descrizione...\",profileTitle:\"Titolo profilo\",profileDescription:\"Descrizione profilo...\"}},spoolmanSettings:{},time:{unknown:\"-\",waiting:\"In attesa\",justNow:\"Proprio ora\",now:\"Ora\",minsAgo:\"{{count}}m fa\",inMins:\"tra {{count}}m\",hoursAgo:\"{{count}}h fa\",inHours:\"tra {{count}}h\",daysAgo:\"{{count}}g fa\",inDays:\"tra {{count}}g\"},spoolbuddy:{nav:{dashboard:\"Dashboard\",ams:\"AMS\",inventory:\"Inventario\",writeTag:\"Scrivi\",settings:\"Impostazioni\"},status:{nfcReady:\"NFC pronto\",nfcOff:\"NFC spento\",offline:\"Offline\",online:\"Online\",noPrinters:\"Nessuna stampante\",deviceOffline:\"Dispositivo offline\",waitingConnection:\"In attesa della connessione...\",systemReady:\"Sistema pronto\",status:\"Stato\"},dashboard:{readyToScan:\"Pronto per la scansione\",idleMessage:\"Posiziona una bobina sulla bilancia per identificarla\",nfcHint:\"Il tag NFC verrà letto automaticamente\",device:\"Dispositivo\",syncWeight:\"Sincronizza peso\",weightSynced:\"Sincronizzato!\",unknownTag:\"Tag sconosciuto\",newTag:\"Nuovo tag rilevato\",onScale:\"sulla bilancia\",linkSpool:\"Collega a bobina\",linkTagTitle:\"Collega tag a bobina\",linkTag:\"Collega tag\",selectSpool:\"Seleziona una bobina da collegare a questo tag:\",noUntagged:\"Nessuna bobina senza tag trovata\",tagDetected:\"Tag rilevato\",noTag:\"Nessun tag\",tagId:\"Tag\",grossWeight:\"Peso lordo\",spoolSize:\"Dimensione bobina\",close:\"Chiudi\",currentSpool:\"Bobina attuale\"},modal:{spoolDetected:\"Bobina rilevata\",assignToAms:\"Assegna all'AMS\",syncWeight:\"Sincronizza peso\",weightSynced:\"Sincronizzato!\",syncing:\"Sincronizzazione...\",newTagDetected:\"Nuovo tag rilevato\",addToInventory:\"Aggiungi all'inventario\",assignToAmsTitle:\"Assegna all'AMS\",selectSlot:\"Seleziona uno slot\",assign:\"Assegna\",assigning:\"Assegnazione...\",assignSuccess:\"Assegnato!\",assignError:\"Impossibile assegnare la bobina. Riprovare.\",noPrinterSelected:\"Seleziona una stampante...\",noAmsDetected:\"Nessun AMS rilevato su questa stampante\",slot:\"Slot\"},weight:{noReading:\"Nessuna lettura\",stable:\"Stabile\",measuring:\"Misurazione...\",tare:\"Tara\",calibrate:\"Calibra\"},spool:{remaining:\"Rimanente\",material:\"Materiale\",brand:\"Marca\",color:\"Colore\",coreWeight:\"Nucleo\",labelWeight:\"Etichetta\",scaleWeight:\"Bilancia\",netWeight:\"Netto\",lastUsed:\"Ultimo utilizzo\"},ams:{noData:\"Nessun AMS rilevato\",connectAms:\"Collega un AMS per vedere gli slot\",noPrinter:\"Nessuna stampante selezionata\",selectPrinter:\"Seleziona una stampante dalla barra superiore\",printerDisconnected:\"Stampante disconnessa\",humidity:\"Umidità\",level:\"Livello\",active:\"Attivo\",slot:\"Slot\",empty:\"Vuoto\"},inventory:{search:\"Cerca bobine...\",empty:\"Nessuna bobina nell'inventario\",noResults:\"Nessuna bobina corrispondente\",spools:\"bobine\",addSpool:\"Aggiungi bobina\"},settings:{tabDevice:\"Dispositivo\",tabDisplay:\"Display\",tabScale:\"Bilancia\",tabUpdates:\"Aggiornamenti\",nfcReader:\"Lettore NFC\",type:\"Tipo\",connection:\"Connessione\",notConnected:\"N/A\",deviceInfo:\"Info dispositivo\",hostname:\"Host\",uptime:\"Tempo di attività\",brightness:\"Luminosità\",saved:\"Salvato\",noBacklight:\"Nessuna retroilluminazione DSI rilevata. Il controllo luminosità richiede un display DSI.\",screenBlank:\"Timeout spegnimento schermo\",screenBlankDesc:\"Lo schermo si spegne dopo inattività. Tocca per riattivare.\",displayNote:\"La luminosità viene applicata come filtro software.\",scaleCalibration:\"Calibrazione bilancia\",currentWeight:\"Peso attuale\",tareOffset:\"Tara\",calFactor:\"Fattore\",knownWeight:\"Peso noto\",calStep1:\"Rimuovere tutto dalla bilancia e premere Imposta zero.\",calStep2:\"Posizionare il peso noto sulla bilancia.\",setZero:\"Imposta zero\",calibrateNow:\"Calibra\",calibrated:\"Calibrato\",tareSet:\"Comando tara inviato. In attesa del dispositivo...\",tareFailed:\"Invio comando tara fallito\",zeroSet:\"Punto zero impostato. Posizionare il peso noto sulla bilancia.\",calibrationDone:\"Calibrazione completata!\",calibrationFailed:\"Calibrazione fallita\",lastCalibrated:\"Ultima calibrazione\",stable:\"Stabile\",settling:\"Stabilizzazione...\",firmware:\"Firmware\",scale:\"Bilancia\",noDevice:\"Nessun dispositivo SpoolBuddy trovato\",daemonVersion:\"Versione daemon\",currentVersion:\"Attuale\",versionPending:\"In attesa del daemon...\",checking:\"Verifica...\",checkUpdates:\"Verifica aggiornamenti\",updateAvailable:\"Aggiornamento disponibile\",updateInstructions:\"Aggiorna via SSH: esegui lo script di installazione SpoolBuddy.\",upToDate:\"Aggiornato\",includeBeta:\"Includi versioni beta\"},writeTag:{tabExisting:\"Bobina esistente\",tabNew:\"Nuova bobina\",tabReplace:\"Sostituisci tag\",searchPlaceholder:\"Cerca per materiale, colore, marca...\",noUntaggedSpools:\"Nessuna bobina senza tag\",noTaggedSpools:\"Nessuna bobina con tag\",selectSpool:\"Seleziona una bobina, poi posiziona un NTAG sul lettore\",placeTag:\"Posiziona un NTAG sul lettore\",tagReady:\"Tag rilevato — pronto per la scrittura\",writeTag:\"Scrivi tag\",replaceTag:\"Sostituisci tag\",writing:\"Scrittura tag...\",waiting:\"In attesa di SpoolBuddy...\",writeSuccess:\"Tag scritto con successo!\",writeFailed:\"Scrittura fallita\",queueFailed:\"Impossibile accodare il comando di scrittura\",tryAgain:\"Riprova\",cancel:\"Annulla\",replaceWarning:\"Il vecchio tag verrà scollegato. Il nuovo tag lo sostituirà.\",deviceOffline:\"SpoolBuddy è offline\",material:\"Materiale\",colorName:\"Nome colore\",color:\"Colore\",brand:\"Marca\",weight:\"Peso (g)\",createSpool:\"Crea bobina\",creating:\"Creazione...\",spoolCreated:\"Bobina creata! Pronto per la scrittura.\",createFailed:\"Impossibile creare la bobina\"},quickMenu:{printerPower:\"Alimentazione stampante\",systemControls:\"Sistema\",restartDaemon:\"Riavvia daemon\",restartBrowser:\"Riavvia browser\",reboot:\"Riavvia\",shutdown:\"Spegni\",swipeToClose:\"Scorri verso il basso per chiudere\",confirmTitle:\"Conferma\",confirmShutdown:\"Sei sicuro di voler spegnere lo SpoolBuddy? Avrai bisogno di accesso fisico per riaccenderlo.\",confirmReboot:\"Sei sicuro di voler riavviare lo SpoolBuddy?\",confirmRestartDaemon:\"Riavviare il daemon SpoolBuddy? NFC e bilancia saranno temporaneamente non disponibili.\",confirmRestartBrowser:\"Riavviare il browser kiosk? Lo schermo diventerà brevemente nero.\",confirm:\"Conferma\",confirmPlugOn:\"Accendere {{name}}?\",confirmPlugOff:\"Spegnere {{name}}?\",turnOn:\"Accendi\",turnOff:\"Spegni\"}},bugReport:{title:\"Segnala un bug\",description:\"Descrizione\",descriptionPlaceholder:\"Cosa è andato storto? Descrivi il problema...\",email:\"Email (opzionale)\",emailPlaceholder:\"tua@email.it\",emailPrivacy:\"Se fornita, la tua email sarà inclusa in una sezione compressa dell'issue GitHub per permettere al manutentore di contattarti.\",screenshot:\"Screenshot\",uploadOrPaste:\"Carica, incolla o trascina un'immagine\",dataCollectedSummary:\"Quali dati sono inclusi nel report?\",dataIncluded:\"Inclusi:\",dataIncludedList:\"Versione app, OS, architettura, versione Python, statistiche database (solo conteggi), modelli stampante, numero ugelli, versioni firmware, stato connessione, stato integrazioni (Spoolman, MQTT, HA), impostazioni non sensibili, conteggio interfacce di rete, dettagli Docker, versioni dipendenze.\",dataNeverIncluded:\"Mai inclusi:\",dataNeverIncludedList:\"Nomi stampanti, numeri di serie, codici di accesso, password, indirizzi IP, indirizzi email, chiavi API, token, URL webhook, nomi host o nomi utente.\",submit:\"Invia\",startLogging:\"Avvia registrazione debug\",stepEnableLogging:\"Registrazione debug attivata\",stepReproduce:\"Riproduci il problema ora\",stepStopLogging:\"Ferma & invia rapporto\",stopAndSubmit:\"Ferma & Invia\",maxDuration:\"Arresto automatico dopo {{minutes}} min\",stoppingLogs:\"Raccolta log & invio...\",submitting:\"Invio segnalazione bug...\",submitSuccess:\"Segnalazione bug inviata con successo!\",submitFailed:\"Impossibile inviare la segnalazione bug\",thankYou:\"Grazie!\",submitted:\"La tua segnalazione bug è stata inviata.\",viewIssue:\"Vedi issue\",unexpectedError:\"Si è verificato un errore imprevisto\"},failureDetection:{title:\"Rilevamento guasti con IA\",description:\"Monitora le stampe tramite un'API ML Obico auto-ospitata e agisce automaticamente sui guasti rilevati.\",mlUrl:\"URL API ML Obico\",mlUrlHint:\"URL base del tuo container Obico ml_api auto-ospitato (es. http://192.168.1.10:3333).\",test:\"Prova\",testSuccess:\"API ML raggiungibile e funzionante.\",testFailed:\"Impossibile raggiungere l'API ML.\",sensitivity:\"Sensibilità\",sensitivityLow:\"Bassa (meno falsi positivi)\",sensitivityMedium:\"Media (bilanciata)\",sensitivityHigh:\"Alta (rilevamento precoce, più falsi positivi)\",sensitivityHint:\"Regola le soglie di confidenza che attivano avvisi e guasti.\",action:\"Azione al guasto rilevato\",actionNotify:\"Solo notifica\",actionPause:\"Metti in pausa\",actionPauseOff:\"Pausa e stacca corrente\",pollInterval:\"Intervallo di controllo (secondi)\",pollIntervalHint:\"Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.\",externalUrlMissing:\"External URL is not set.\",externalUrlHint:\"The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.\",perPrinterTitle:\"Stampanti monitorate\",perPrinterHint:\"Scegli quali stampanti il servizio di rilevamento deve monitorare.\",monitorAll:\"Monitora tutte le stampanti connesse\",statusTitle:\"Stato\",serviceRunning:\"Servizio in esecuzione\",thresholds:\"Soglie bassa / alta\",activePrinters:\"Stampe attive\",noActivePrints:\"Nessuna stampa in corso.\",historyTitle:\"Rilevamenti recenti\",noHistory:\"Nessun rilevamento finora.\"}},Xue={nav:{printers:\"Impressoras\",archives:\"Arquivos\",queue:\"Fila\",stats:\"Estatísticas\",profiles:\"Perfis\",maintenance:\"Manutenção\",projects:\"Projetos\",inventory:\"Inventário\",files:\"Gerenciador de Arquivos\",notifications:\"Notificações\",settings:\"Configurações\",system:\"Sistema\",collapseSidebar:\"Recolher barra lateral\",expandSidebar:\"Expandir barra lateral\",update:\"Atualizar\",updateAvailable:\"Atualização disponível: v{{version}}\",updateAvailableBanner:\"Versão {{version}} está disponível!\",viewUpdate:\"Ver atualização\",viewOnGithub:\"Ver no GitHub\",keyboardShortcuts:\"Atalhos de teclado (?)\",switchToLight:\"Mudar para modo claro\",switchToDark:\"Mudar para modo escuro\",smartSwitches:\"Interruptores inteligentes\",logout:\"Sair\"},common:{save:\"Salvar\",saving:\"Salvando...\",cancel:\"Cancelar\",delete:\"Excluir\",edit:\"Editar\",add:\"Adicionar\",close:\"Fechar\",confirm:\"Confirmar\",loading:\"Carregando...\",error:\"Erro\",success:\"Sucesso\",warning:\"Aviso\",enabled:\"Ativado\",disabled:\"Desativado\",yes:\"Sim\",no:\"Não\",on:\"Ligado\",off:\"Desligado\",all:\"Todos\",none:\"Nenhum\",search:\"Pesquisar\",filter:\"Filtrar\",sort:\"Ordenar\",refresh:\"Atualizar\",download:\"Baixar\",upload:\"Enviar\",uploading:\"Enviando...\",uploadFailed:\"Falha no envio\",actions:\"Ações\",status:\"Status\",name:\"Nome\",description:\"Descrição\",date:\"Data\",time:\"Hora\",hours:\"Horas\",minutes:\"Minutos\",seconds:\"Segundos\",days:\"Dias\",enable:\"Ativar\",disable:\"Desativar\",permissions:\"Permissões\",noPrinters:\"Nenhuma impressora configurada\",noData:\"Nenhum dado disponível\",linkNotFound:\"Link não encontrado\",required:\"Obrigatório\",optional:\"Opcional\",dismiss:\"Dispensar\",apply:\"Aplicar\",reset:\"Redefinir\",export:\"Exportar\",import:\"Importar\",clear:\"Limpar\",selectAll:\"Selecionar tudo\",deselectAll:\"Desmarcar tudo\",noChange:\"— Sem alterações —\",unchanged:\"Inalterado\",unassigned:\"Não atribuído\",unknown:\"Desconhecido\",unknownError:\"Erro desconhecido\",today:\"Hoje\",tomorrow:\"Amanhã\",asap:\"O mais rápido possível\",overdue:\"Atrasado\",now:\"Agora\",collapse:\"Recolher\",expand:\"Expandir\",viewArchive:\"Ver arquivo\",viewInFileManager:\"Ver no Gerenciador de Arquivos\",addedBy:\"Adicionado por {{username}}\",prints:\"impressões\",more:\"+{{count}} mais\",ascending:\"Crescente\",descending:\"Decrescente\",back:\"Voltar\",copy:\"Copiar\",copied:\"Copiado!\",printer:\"Impressora\",remove:\"Remover\",type:\"Tipo\",print:\"Imprimir\",rename:\"Renomear\",move:\"Mover\",create:\"Criar\",duplicate:\"Duplicar\",left:\"Esquerda\",right:\"Direita\"},printers:{title:\"Impressoras\",addPrinter:\"Adicionar Impressora\",editPrinter:\"Editar Impressora\",deletePrinter:\"Excluir Impressora\",printerName:\"Nome da Impressora\",serialNumber:\"Número de Série\",ipAddress:\"Endereço IP / Nome do Host\",accessCode:\"Código de Acesso\",model:\"Modelo\",nozzleCount:\"Número de Bicos\",autoArchive:\"Arquivamento Automático\",status:{available:\"Disponível\",idle:\"Ocioso\",printing:\"Imprimindo\",paused:\"Pausado\",offline:\"Offline\",problem:\"Problema\",error:\"Erro\",finished:\"Concluído\",unknown:\"Desconhecido\"},temperatures:{nozzle:\"Bico\",bed:\"Cama\",chamber:\"Câmara\"},progress:\"{{percent}}% concluído\",timeRemaining:\"{{time}} restante\",deleteConfirm:'Tem certeza de que deseja excluir \"{{name}}\"?',maintenanceOk:\"Manutenção OK\",maintenanceWarning:\"{{count}} aviso\",maintenanceWarning_plural:\"{{count}} avisos\",maintenanceDue:\"{{count}} devido\",maintenanceDue_plural:\"{{count}} devido\",sort:{name:\"Nome\",status:\"Status\",model:\"Modelo\",location:\"Localização\",ascending:\"Ordem crescente\",descending:\"Ordem decrescente\"},cardSize:{small:\"Cartões pequenos\",medium:\"Cartões médios\",large:\"Cartões grandes\",extraLarge:\"Cartões extra grandes\"},hideOffline:\"Ocultar offline\",nextAvailable:\"Próximo disponível\",powerOn:\"Ligar\",offlinePrintersWithPlugs:\"Impressoras offline com tomadas inteligentes\",noPrintersConfigured:\"Nenhuma impressora configurada ainda\",search:\"Pesquisar impressoras...\",noSearchResults:\"Nenhuma impressora corresponde à sua pesquisa ou filtros\",filter:{allStatuses:\"Todos os status\",allLocations:\"Todos os locais\"},readyToPrint:\"Pronto para imprimir\",external:\"Externo\",extL:\"Ext-L\",extR:\"Ext-R\",deleteArchives:\"Excluir arquivos de impressão\",noLabel:\"Sem etiqueta\",printPreview:\"Pré-visualização de impressão\",width:\"Largura\",height:\"Altura\",noObjectsFound:\"Nenhum objeto encontrado\",objectsLoadedOnPrintStart:\"Objetos são carregados quando uma impressão começa\",willBeSkipped:\"Será ignorado\",name:\"Nome\",serialCannotBeChanged:\"Número de série não pode ser alterado\",locationHelp:\"Usado para agrupar impressoras e filtrar trabalhos na fila\",wifiSignal:{veryWeak:\"Muito fraco\",weak:\"Fraco\",fair:\"Regular\",good:\"Bom\",excellent:\"Excelente\"},maintenanceUpToDate:\"Toda a manutenção está em dia - Clique para ver\",chamberLightOn:\"Ligar luz da câmara\",chamberLightOff:\"Desligar luz da câmara\",files:\"Arquivos\",browseFiles:\"Procurar arquivos da impressora\",autoOffAfterPrint:\"Desligamento automático após impressão\",autoOffExecuted:\"Desligamento automático executado - ligue a impressora para reiniciar\",hmsErrors:\"Erros HMS\",viewHmsErrors:\"Ver {{count}} erro(s) HMS\",resume:\"Retomar\",pause:\"Pausar\",stop:\"Parar\",camera:\"âmera\",skipObject:\"Ignorar objeto\",reconnect:\"Reconectar\",forceRefresh:\"Forçar atualização\",forceRefreshSuccess:\"Atualização solicitada\",mqttDebug:\"Depuração MQTT\",printerInformation:\"Informações da impressora\",copyToClipboard:\"Copiar\",copied:\"Copiado!\",state:\"Estado\",wifiSignalLabel:\"Sinal WiFi\",developerMode:\"Modo desenvolvedor\",enabled:\"Ativado\",disabled:\"Desativado\",addedOn:\"Adicionada em\",sdCard:\"Cartão SD\",inserted:\"Inserido\",notInserted:\"Não inserido\",totalPrintHours:\"Horas de impressão\",activeNozzle:\"Ativo: {{nozzle}} bico\",nozzleRack:\"Suporte de bicos\",nozzleDocked:\"Acoplado\",nozzleMounted:\"Montado\",nozzleActive:\"Ativo\",nozzleIdle:\"Ocioso\",nozzleDiameter:\"Diâmetro\",nozzleType:\"Tipo\",nozzleStatus:\"Status\",nozzleFilament:\"Filamento\",nozzleWear:\"Desgaste\",nozzleMaxTemp:\"Temp Máx\",nozzleSerial:\"Serial\",nozzleHardenedSteel:\"Aço Endurecido\",nozzleStainlessSteel:\"Aço Inoxidável\",nozzleTungstenCarbide:\"Carboneto de Tungstênio\",nozzleFlow:\"Fluxo\",nozzleHighFlow:\"Alto Fluxo\",nozzleStandardFlow:\"Fluxo Padrão\",firmwareUpdate:\"Atualização de Firmware\",firmwareInstructions:\"No visor da impressora, vá para\",firmwareNav:\"Navegar para\",settings:\"Configurações\",firmware:\"Firmware\",discoverPrinters:\"Descobrir Impressoras\",searching:\"Procurando...\",manualEntry:\"Entrada Manual\",addFromCloud:\"Adicionar da Nuvem\",toast:{printerDeleted:\"Impressora excluída\",missingSpoolAssignment:\"Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}\",printerAdded:\"Impressora adicionada\",printerUpdated:\"Impressora atualizada\",failedToDelete:\"Falha ao excluir impressora\",failedToAdd:\"Falha ao adicionar impressora\",failedToUpdate:\"Falha ao atualizar impressora\",commandSent:\"Comando enviado\",failedToSendCommand:\"Falha ao enviar comando\",turnedOn:\"{{name}} ligado\",failedToPowerOn:\"Falha ao ligar {{name}}\",scriptTriggered:\"Script acionado\",printStopped:\"Impressão parada\",printPaused:\"Impressão pausada\",printResumed:\"Impressão retomada\",referenceDeleted:\"Referência excluída\",detectionAreaSaved:\"Área de detecção salva\",failedToRunScript:\"Falha ao executar script\",failedToStopPrint:\"Falha ao parar impressão\",failedToPausePrint:\"Falha ao pausar impressão\",failedToResumePrint:\"Falha ao retomar impressão\",failedToControlChamberLight:\"Falha ao controlar a luz da câmara\",failedToSetSpeed:\"Falha ao definir a velocidade de impressão\",failedToUpdateSetting:\"Falha ao atualizar configuração\",failedToSkipObjects:\"Falha ao ignorar objetos\",failedToRereadRfid:\"Falha ao reler RFID\",failedToCheckPlate:\"Falha ao verificar a placa\",failedToUpdateLabel:\"Falha ao atualizar etiqueta\",failedToDeleteReference:\"Falha ao excluir referência\",failedToSaveDetectionArea:\"Falha ao salvar área de detecção\",plateCheckEnabled:\"Verificação da placa ativada\",plateCheckDisabled:\"Verificação da placa desativada\",calibrationSaved:\"Calibração salva!\",calibrationFailed:\"Falha na calibração\",rfidRereadInitiated:\"Releitura de RFID iniciada\"},connection:{connected:\"Conectado\",offline:\"Offline\"},plateStatus:{markCleared:\"Marcar placa como liberada\",cleared:\"Placa liberada\",notCleared:\"Placa não liberada\",inUse:\"Placa em uso\"},queue:{inQueue:\"{{count}} impressão na fila\",inQueue_plural:\"{{count}} impressões na fila\"},controls:\"Controles\",rfid:{reread:\"Releitura de RFID\"},bedJog:{title:\"Mover a mesa de impressão\",bed:\"Mesa\",step:\"Passo (mm)\",up:\"Mover mesa para cima\",down:\"Mover mesa para baixo\",disabledWhilePrinting:\"Desativado durante a impressão\",notHomedTitle:\"Impressora não referenciada\",notHomedMessage:\"A impressora não foi referenciada desde a última impressão. Execute a referência automática primeiro para um posicionamento seguro (estaciona o cabeçote, depois referencia X, Y e Z), ou mova assim mesmo — os fins de curso de software serão ignorados.\",homeZ:\"Referência automática\",moveAnyway:\"Mover assim mesmo\",homingStarted:\"Referenciando impressora automaticamente…\"},permission:{noAdd:\"Você não tem permissão para adicionar impressoras\",noEdit:\"Você não tem permissão para editar impressoras\",noDelete:\"Você não tem permissão para excluir impressoras\",noControl:\"Você não tem permissão para controlar impressoras\",noFiles:\"Você não tem permissão para acessar arquivos de impressora\",noAmsRfid:\"Você não tem permissão para reler RFID AMS\",noSmartPlugControl:\"Você não tem permissão para controlar tomadas inteligentes\",noCamera:\"Você não tem permissão para visualizar câmeras\"},modal:{addTitle:\"Adicionar Impressora\",editTitle:\"Editar Impressora\",myPrinter:\"Minha Impressora\",selectModel:\"Selecionar modelo...\",locationGroup:\"Localização / Grupo (opcional)\",locationPlaceholder:\"ex.: Oficina, Escritório, Porão\",autoArchiveLabel:\"Arquivar automaticamente impressões concluídas\",fromPrinterSettings:\"A partir das configurações da impressora\",modelOptional:\"Modelo (opcional)\",saveChanges:\"Salvar alterações\"},skipObjects:{tooltip:\"Ignorar objetos\",onlyWhilePrinting:\"Ignorar objetos (apenas durante a impressão)\",requiresMultiple:\"Ignorar objetos (requer 2+ objetos)\",title:\"Ignorar Objetos\",matchIdsInfo:\"Correspondência de IDs com o display da sua impressora\",printerShowsIds:\"A tela da impressora mostra os IDs dos objetos na placa de construção\",skipSelected:\"Ignorar Selecionados\",skipping:\"Ignorando...\",noObjectsSelected:\"Nenhum objeto selecionado\",selectObjectsToSkip:\"Selecione os objetos que deseja ignorar na impressão atual\",skipped:\"Ignorado\",objectsSkipped:\"Objetos ignorados\",activeCount:\"{{count}} ativo\",waitForLayer:\"Aguarde a camada 2+ para ignorar objetos (atualmente na camada {{layer}})\",skip:\"Ignorar\",confirmTitle:\"Ignorar Objeto?\",confirmMessage:'Tem certeza de que deseja ignorar \"{{name}}\"? Isso não pode ser desfeito.'},confirm:{deleteTitle:\"Excluir Impressora\",deleteMessage:'Tem certeza de que deseja excluir \"{{name}}\"? Isso removerá todas as configurações de conexão.',deleteArchivesNote:\"Todo o histórico de impressão desta impressora será permanentemente excluído.\",keepArchivesNote:\"O histórico de impressão será mantido, mas não estará mais associado a esta impressora.\",stopTitle:\"Parar Impressão\",stopMessage:'Tem certeza de que deseja parar a impressão atual em \"{{name}}\"? Isso cancelará o trabalho de impressão.',stopButton:\"Parar Impressão\",pauseTitle:\"Pausar Impressão\",pauseMessage:'Tem certeza de que deseja pausar a impressão atual em \"{{name}}\"?',pauseButton:\"Pausar Impressão\",resumeTitle:\"Retomar Impressão\",resumeMessage:'Tem certeza de que deseja retomar a impressão em \"{{name}}\"?',resumeButton:\"Retomar Impressão\",powerOnTitle:\"Ligar Impressora\",powerOnMessage:'Tem certeza de que deseja ligar a impressora \"{{name}}\"?',powerOnButton:\"Ligar\",powerOffTitle:\"Desligar Impressora\",powerOffMessage:'Tem certeza de que deseja desligar a impressora \"{{name}}\"?',powerOffWarning:'AVISO: \"{{name}}\" está imprimindo no momento! Tem certeza de que deseja desligar a impressora? Isso interromperá a impressão e pode danificar a impressora.',powerOffButton:\"Desligar\"},bulk:{select:\"Selecionar\",selectAll:\"Selecionar tudo\",selectByLocation:\"Selecionar por local\",selected:\"{{count}} selecionado(s)\",actions:{stop:\"Parar\",pause:\"Pausar\",resume:\"Retomar\",clearPlate:\"Limpar mesa\",clearHMS:\"Limpar notificações\"},confirm:{stopTitle:\"Parar {{count}} impressões\",stopMessage:\"Isso cancelará as impressões ativas em {{count}} impressora(s). Esta ação não pode ser desfeita.\",stopButton:\"Parar todas\",pauseTitle:\"Pausar {{count}} impressões\",pauseMessage:\"Isso pausará as impressões ativas em {{count}} impressora(s).\",pauseButton:\"Pausar todas\",clearPlateTitle:\"Limpar {{count}} mesas de impressão\",clearPlateMessage:\"Isso limpará a mesa de impressão em {{count}} impressora(s) e pode iniciar trabalhos na fila.\",clearPlateButton:\"Limpar todas\"},success:\"{{action}} concluído em {{count}} impressora(s)\",partial:\"{{succeeded}} bem-sucedido(s), {{failed}} falhou/falharam\",noneApplicable:\"Nenhuma impressora selecionada está no estado correto para esta ação\",selectByState:\"Selecionar por estado\"},discovery:{title:\"Descobrir Impressoras\",searching:\"Procurando...\",scanning:\"Escaneando...\",scanProgress:\"Escaneando... {{scanned}}/{{total}}\",foundPrinters:\"{{count}} impressora(s) encontrada(s)\",noPrintersFound:\"Nenhuma impressora encontrada\",noPrintersFoundSubnet:\"Nenhuma impressora encontrada na sub-rede especificada.\",noPrintersFoundNetwork:\"Nenhuma impressora encontrada na rede.\",allConfigured:\"Todas as impressoras descobertas já estão configuradas.\",alreadyAdded:\"Já adicionada\",select:\"Selecionar\",manualEntry:\"Entrada Manual\",addFromCloud:\"Adicionar da Nuvem\",subnetToScan:\"Sub-rede para escanear\",dockerNote:\"Docker detectado. Insira a sub-rede da sua impressora em notação CIDR. Requer network_mode: host no docker-compose.yml.\",scanSubnet:\"Escanear Sub-rede para Impressoras\",discoverNetwork:\"Descobrir Impressoras na Rede\",scanningSubnet:\"Escaneando sub-rede para impressoras Bambu...\",scanningNetwork:\"Escaneando rede...\",serialRequired:\"Serial necessário\",unknown:\"Desconhecido\",failedToStart:\"Falha ao iniciar a descoberta\"},drying:{start:\"Iniciar secagem\",stop:\"Parar secagem\",temperature:\"Temperatura\",duration:\"Duração\",hours:\"horas\",timeRemaining:\"{{time}} restante\",active:\"Secagem\",notSupported:\"Secagem não suportada\",powerRequired:\"Conecte o adaptador de energia AMS para ativar a secagem\",startingDrying:\"Iniciando secagem...\",stoppingDrying:\"Parando secagem...\",rotateTray:\"Girar o carretel durante a secagem\"},filaments:\"Filamentos\",openCameraOverlay:\"Abrir sobreposição da câmera\",openCameraWindow:\"Abrir câmera em nova janela\",firmwareUpdateAvailable:\"Atualização de firmware disponível: {{current}} → {{latest}}\",firmwareUpToDate:\"Firmware {{version}} — Atualizado\",firmwareUpdateButton:\"Atualizar\",plateDetection:{noPermission:\"Você não tem permissão para atualizar impressoras\",enabledClick:\"Verificação da placa ativada - Clique para desativar\",disabledClick:\"Verificação da placa desativada - Clique para ativar\",manageCalibration:\"Gerenciar calibração da detecção da placa\",calibrationRequired:\"Calibração necessária\",calibrationInstructions:\"Certifique-se de que a placa de construção esteja <strong>completamente vazia</strong>, em seguida clique em Calibrar.\",calibrationDescription:\"A calibração captura uma imagem de referência da placa vazia. Verificações futuras compararão com esta referência para detectar objetos.\",calibrationTip:\"<strong>Dica:</strong> Você pode armazenar até 5 calibrações para diferentes placas. O sistema usa automaticamente a melhor correspondência ao verificar.\",plateEmpty:\"A placa parece vazia\",objectsDetected:\"Objetos detectados na placa\",confidence:\"Confiança\",difference:\"Diferença\",analysisPreview:\"Pré-visualização da análise:\",analysisLegend:\"Caixa verde = área de detecção, Sobreposição vermelha = diferenças em relação à calibração\",savedReferences:\"Referências salvas ({{count}}/{{max}})\",deleteReference:\"Excluir referência\",labelPlaceholder:\"Etiqueta...\",clickToEdit:\"{{label}} - Clique para editar\",clickToAddLabel:\"Clique para adicionar etiqueta\"},speed:{title:\"Velocidade de impressão\",silent:\"Silencioso (50%)\",standard:\"Padrão (100%)\",sport:\"Sport (124%)\",ludicrous:\"Ludicrous (166%)\"},airduct:{title:\"Modo do duto de ar\",cooling:\"Resfriamento\",heating:\"Aquecimento\"},noSdCard:\"Sem SD\",door:{open:\"Aberta\",closed:\"Fechada\"},fans:{partCooling:\"Ventilador de resfriamento da peça\",auxiliary:\"Ventilador auxiliar\",chamber:\"Ventilador da câmara\"},clickToViewHmsErrors:\"Clique para ver erros do HMS\",estimatedCompletion:\"Tempo estimado de conclusão\",plateNumber:\"Placa {{number}}\",slotOptions:\"Opções de slot\",amsPopup:{friendlyName:\"Nome do AMS\",friendlyNamePlaceholder:\"ex.: Nome amigável do AMS\",serialNumber:\"Número de série\",firmwareVersion:\"Firmware\",save:\"Salvar\",clear:\"Limpar\",noEditPermission:\"Você não tem permissão para renomear unidades AMS\"},firmwareModal:{title:\"Atualização de Firmware\",titleUpToDate:\"Informações do Firmware\",currentVersion:\"Atual:\",latestVersion:\"Última:\",releaseNotes:\"Notas de Lançamento\",checkingPrereqs:\"Verificando pré-requisitos...\",sdCardReady:\"Cartão SD pronto. Clique abaixo para enviar o firmware.\",uploadedSuccess:\"Firmware enviado para o cartão SD!\",applyInstructions:\"Para aplicar a atualização na sua impressora:\",step1:\"Na tela sensível ao toque da impressora, vá para <strong>Configurações</strong>\",step2:\"Navegue até <strong>Firmware</strong>\",step3:\"Selecione <strong>Atualizar a partir do cartão SD</strong>\",step4:\"A atualização levará de 10 a 20 minutos\",done:\"Concluído\",starting:\"Iniciando...\",uploadFirmware:\"Enviar Firmware\",uploadFailed:\"Falha ao iniciar o envio: {{error}}\",uploadedToast:\"Firmware enviado! Inicie a atualização na tela da impressora.\"},accessCodePlaceholder:\"Deixe vazio para manter o atual\",roi:{title:\"Área de Detecção (ROI)\",xStart:\"Início X\",yStart:\"Início Y\",width:\"Largura\",height:\"Altura\",instruction:\"Ajuste a área de detecção para focar na placa de construção. A caixa verde na pré-visualização mostra a área atual.\"},developerModeWarning:\"O modo desenvolvedor LAN não está ativado em: {{names}}. Alguns recursos podem não funcionar.\",howToEnable:\"Como ativar\",incompatibleFile:\"Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}\",dropNotPrintable:\"Apenas arquivos .gcode e .gcode.3mf podem ser impressos\",dropToPrint:\"Solte para imprimir\",cannotPrint:\"Impressora ocupada\"},archives:{title:\"Arquivos de Impressão\",searchPlaceholder:\"Pesquisar arquivos...\",filterByPrinter:\"Filtrar por impressora\",filterByStatus:\"Filtrar por status\",sortBy:\"Ordenar por\",sortNewest:\"Mais recentes primeiro\",sortOldest:\"Mais antigos primeiro\",sortName:\"Nome\",sortDuration:\"Duração\",sortLargest:\"Maiores primeiro\",sortSmallest:\"Menores primeiro\",sortSize:\"Tamanho\",noArchives:\"Nenhum arquivo encontrado\",noArchivesSearch:\"Nenhum arquivo corresponde à sua pesquisa\",originalPrintNotVisible:\"Impressão original não visível - tente limpar os filtros\",noArchivesYet:\"Ainda não há arquivos\",prints:\"impressões\",pagination:{showing:\"Mostrando\",to:\"a\",of:\"de\",show:\"Mostrar\",page:\"Página\",all:\"Todos\"},loadingArchives:\"Carregando arquivos...\",releaseToUpload:\"Solte para enviar\",showAll:\"Mostrar todos\",showFavoritesOnly:\"Mostrar apenas favoritos\",gridView:\"Visualização em grade\",listView:\"Visualização em lista\",calendarView:\"Visualização em calendário\",logView:\"Registro de impressão\",manageTags:\"Gerenciar etiquetas\",showFailedPrints:\"Mostrar impressões falhas\",hideFailedPrints:\"Ocultar impressões falhas\",hideDuplicates:\"Ocultar duplicados\",viewOriginalPrint:\"Clique para visualizar a impressão original (#{{id}})\",printTime:\"Tempo de impressão\",filamentUsed:\"Filamento usado\",cost:\"Custo\",reprint:\"Reimprimir\",preview:\"Pré-visualizar\",deleteArchive:\"Excluir arquivo\",deleteConfirm:\"Tem certeza de que deseja excluir este arquivo?\",favorite:\"Favorito\",unfavorite:\"Remover dos favoritos\",viewDetails:\"Ver detalhes\",status:{completed:\"Concluído\",failed:\"Falhou\",stopped:\"Parado\"},toast:{source3mfAttached:\"Arquivo de origem 3MF anexado: {{filename}}\",failedUploadSource3mf:\"Falha ao enviar arquivo de origem 3MF\",source3mfRemoved:\"Arquivo de origem 3MF removido\",failedRemoveSource3mf:\"Falha ao remover arquivo de origem 3MF\",f3dAttached:\"F3D anexado: {{filename}}\",failedUploadF3d:\"Falha ao enviar F3D\",f3dRemoved:\"F3D removido\",failedRemoveF3d:\"Falha ao remover F3D\",timelapseAttached:\"Timelapse anexado: {{filename}}\",timelapseAlreadyAttached:\"Timelapse já anexado\",noMatchingTimelapse:\"Nenhum timelapse correspondente encontrado\",failedScanTimelapse:\"Falha ao escanear timelapse\",failedAttachTimelapse:\"Falha ao anexar timelapse\",timelapseRemoved:\"Timelapse removido\",failedRemoveTimelapse:\"Falha ao remover timelapse\",timelapseUploaded:\"Timelapse enviado: {{filename}}\",failedUploadTimelapse:\"Falha ao enviar timelapse\",archiveDeleted:\"Arquivo excluído\",failedDeleteArchive:\"Falha ao excluir arquivo\",addedToFavorites:\"Adicionado aos favoritos\",removedFromFavorites:\"Removido dos favoritos\",projectUpdated:\"Projeto atualizado\",failedUpdateProject:\"Falha ao atualizar projeto\",linkCopied:\"Link copiado para a área de transferência\",failedCopyLink:\"Falha ao copiar link\",photoDeleted:\"Foto excluída\",failedDeletePhoto:\"Falha ao excluir foto\",failedDeleteArchives:\"Falha ao excluir arquivos\",failedUpdateFavorites:\"Falha ao atualizar favoritos\",exportDownloaded:\"Exportação baixada\",exportFailed:\"Falha na exportação\"},menu:{print:\"Imprimir\",schedule:\"Agendar\",openInBambuStudio:\"Abrir no Slicer\",slice:\"Fatiar\",externalLink:\"Link externo\",viewOnMakerWorld:\"Ver no MakerWorld\",preview3d:\"Pré-visualização 3D\",viewTimelapse:\"Ver Timelapse\",scanForTimelapse:\"Escanear Timelapse\",uploadTimelapse:\"Enviar Timelapse\",removeTimelapse:\"Remover Timelapse\",downloadSource3mf:\"Baixar Source 3MF\",uploadSource3mf:\"Enviar Source 3MF\",replaceSource3mf:\"Substituir Source 3MF\",removeSource3mf:\"Remover Source 3MF\",uploadF3d:\"Enviar F3D\",replaceF3d:\"Substituir F3D\",downloadF3d:\"Baixar F3D\",removeF3d:\"Remover F3D\",download:\"Baixar\",copyDownloadLink:\"Copiar link de download\",qrCode:\"Qr Code\",viewPhotos:\"Ver fotos\",viewPhotosCount:\"Ver fotos ({{count}})\",projectPage:\"Página do projeto\",addToFavorites:\"Adicionar aos favoritos\",removeFromFavorites:\"Remover dos favoritos\",edit:\"Editar\",goToProject:\"Ir para o projeto: {{name}}\",addToProject:\"Adicionar ao projeto\",removeFromProject:\"Remover do projeto\",loading:\"Carregando...\",noProjectsAvailable:\"Nenhum projeto disponível\",select:\"Selecionar\",deselect:\"Desmarcar\",delete:\"Excluir\"},permission:{noReprint:\"Você não tem permissão para reimprimir este arquivo\",noAddToQueue:\"Você não tem permissão para adicionar à fila\",noUpdateArchives:\"Você não tem permissão para atualizar arquivos\",noUploadFiles:\"Você não tem permissão para enviar arquivos\",noDownload:\"Você não tem permissão para baixar arquivos\",noCopyLink:\"Você não tem permissão para copiar links de download\",noDelete:\"Você não tem permissão para excluir este arquivo\",noCreate:\"Você não tem permissão para criar arquivos\"},card:{previousPlate:\"Placa anterior\",nextPlate:\"Próxima placa\",plateNumber:\"Placa {{index}}\",moreOptions:\"Clique com o botão direito para mais opções\",addToFavorites:\"Adicionar aos favoritos\",removeFromFavorites:\"Remover dos favoritos\",cancelled:\"cancelado\",failed:\"falha\",duplicate:\"duplicado\",duplicateTitle:\"Este modelo já foi impresso antes\",openSource3mf:\"Abrir source 3MF no Bambu Studio (clique com o botão direito para mais opções)\",downloadF3d:\"Baixar arquivo de design do Fusion 360\",viewTimelapse:\"Ver timelapse\",viewPhoto:\"Ver 1 foto\",viewPhotos:\"Ver {{count}} fotos\",openFolder:\"Abrir pasta: {{name}}\",slicedFile:\"Arquivo fatiado - pronto para imprimir\",sourceFile:\"Apenas arquivo fonte - nenhum mapeamento AMS disponível\",gcode:\"GCODE\",source:\"SOURCE\",project:\"Projeto: {{name}}\",estimated:\"Estimado: {{time}}\",actual:\"Real: {{time}}\",accuracy:\"Precisão: {{percent}}%\",filament:\"{{weight}}g\",layer:\"{{count}} camada\",layers:\"{{count}} camadas\",object:\"{{count}} objeto\",objects:\"{{count}} objetos\",slicedFor:\"Fatiado para {{model}}\",uploadedBy:\"Enviado por\",noPermissionReprint:\"Você não tem permissão para reimprimir\",noFileForReprint:\"Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada\",noPermissionEdit:\"Você não tem permissão para editar arquivos\",noPermissionDelete:\"Você não tem permissão para excluir arquivos\",reprint:\"Reimprimir\",schedulePrint:\"Agendar impressão\",schedule:\"Agendar\",openInBambuStudio:\"Abrir no Bambu Studio\",openInBambuStudioToSlice:\"Abrir no Bambu Studio para fatiar\",slice:\"Fatiar\",externalLink:\"Link externo\",makerWorld:\"MakerWorld: {{designer}}\",viewProject:\"Ver projeto\",noExternalLink:\"Nenhum link externo\",preview3d:\"Visualização 3D\",download:\"Baixar\",edit:\"Editar\",delete:\"Excluir\"},modal:{deleteArchive:\"Excluir Arquivo\",deleteConfirm:'Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.',deleteButton:\"Excluir\",removeSource3mf:\"Remover Source 3MF\",removeSource3mfConfirm:'Tem certeza de que deseja remover o arquivo source 3MF de \"{{name}}\"? Isso excluirá o arquivo original do projeto do fatiador.',removeButton:\"Remover\",removeF3d:\"Remover F3D\",removeF3dConfirm:'Tem certeza de que deseja remover o arquivo de design do Fusion 360 de \"{{name}}\"?',removeTimelapse:\"Remover Timelapse\",removeTimelapseConfirm:'Tem certeza de que deseja remover o vídeo timelapse de \"{{name}}\"?',timelapse:\"{{name}} - Timelapse\",selectTimelapse:\"Selecionar Timelapse\",selectTimelapseDesc:\"Nenhuma correspondência automática encontrada. Selecione o timelapse para esta impressão:\",deleteArchives:\"Excluir Arquivos\",deleteArchivesConfirm:\"Tem certeza de que deseja excluir {{count}} arquivo(s)? Esta ação não pode ser desfeita.\",deleteCount:\"Excluir {{count}}\"},page:{title:\"Arquivos\",printsCount:\"{{filtered}} de {{total}} impressões\",dropFilesHere:\"Solte arquivos .3mf aqui\",releaseToUpload:\"Solte para enviar\",only3mfSupported:\"Apenas arquivos .3mf são suportados\",close:\"Fechar\",selected:\"{{count}} selecionado(s)\",selectAll:\"Selecionar Todos\",tags:\"Tags\",project:\"Projeto\",favorite:\"Favorito\",delete:\"Excluir\",toggledFavorites:\"Favoritos alternados para {{count}} arquivo(s)\",failedUpdateFavorites:\"Falha ao atualizar favoritos\",archivesDeleted:\"{{count}} arquivo(s) excluído(s)\",failedDeleteArchives:\"Falha ao excluir arquivos\",photoDeleted:\"Foto excluída\",failedDeletePhoto:\"Falha ao excluir foto\"},list:{name:\"Nome\",printer:\"Impressora\",date:\"Data\",size:\"Tamanho\",actions:\"Ações\",hasTimelapse:\"Possui timelapse\"},log:{date:\"Data\",printName:\"Nome da Impressão\",printer:\"Impressora\",user:\"Usuário\",status:\"Status\",duration:\"Duração\",filament:\"Filamento\",allPrinters:\"Todas as Impressoras\",allUsers:\"Todos os Usuários\",allStatuses:\"Todos os Status\",cancelled:\"Cancelado\",skipped:\"Ignorado\",dateFrom:\"De\",dateTo:\"Até\",noEntries:\"Nenhuma entrada de registro de impressão encontrada\",showing:\"Mostrando {{count}} de {{total}} entradas\",rowsPerPage:\"Linhas\",page:\"Página\",prev:\"Anterior\",next:\"Próxima\",clearLog:\"Limpar Registro\",clearLogTitle:\"Limpar Registro de Impressão\",clearLogConfirm:\"Todas as entradas do registro de impressão serão permanentemente excluídas. Arquivos e itens da fila não serão afetados. Esta ação não pode ser desfeita. Tem certeza?\",clearLogButton:\"Limpar Tudo\",cleared:\"{{count}} entradas do registro de impressão limpas\",clearFailed:\"Falha ao limpar o registro de impressão\"}},queue:{title:\"Fila de Impressão\",subtitle:\"Agende e gerencie seus trabalhos de impressão\",addToQueue:\"Adicionar à Fila\",print:\"Imprimir\",reprint:\"Reimprimir\",schedulePrint:\"Agendar Impressão\",editQueueItem:\"Editar Item da Fila\",printToPrinters:\"Imprimir para {{count}} Impressoras\",queueToPrinters:\"Adicionar à Fila para {{count}} Impressoras\",queueSelectedPlates:\"Adicionar {{count}} placas à fila\",selectAllPlates:\"Selecionar todas as {{count}} placas\",deselectAll:\"Desmarcar tudo\",printQueued:\"Impressão adicionada à fila\",itemsQueued:\"{{count}} itens adicionados à fila\",sending:\"Enviando...\",sendingProgress:\"Enviando {{current}}/{{total}}...\",adding:\"Adicionando...\",addingProgress:\"Adicionando {{current}}/{{total}}...\",savingProgress:\"Salvando {{current}}/{{total}}...\",clearQueue:\"Limpar Fila\",clearHistory:\"Limpar Histórico\",emptyQueue:\"Fila vazia\",position:\"Posição\",scheduledTime:\"Hora Agendada\",moveUp:\"Mover para Cima\",moveDown:\"Mover para Baixo\",startNow:\"Iniciar Agora\",printingInProgress:\"Impressão em andamento...\",viewArchive:\"Ver Arquivo\",viewInFileManager:\"Ver no Gerenciador de Arquivos\",itemCount:\"{{count}} item\",itemCount_plural:\"{{count}} itens\",dragToReorder:\"Arraste para reordenar (apenas ASAP)\",reorderHint:\"A posição afeta apenas itens ASAP. Itens agendados são executados no horário definido.\",sjf:{label:\"SJF\",tooltip:\"Trabalho mais curto primeiro — o agendador prioriza impressões mais curtas\"},addedBy:\"Adicionado por {{name}}\",nextInQueue:\"Próximo na fila\",clearPlateSuccess:\"Placa limpa — pronta para a próxima impressão\",plateNumber:\"Placa {{index}}\",quantity:\"Quantidade\",quantityHint:\"Cria {{count}} itens na fila\",activeBatches:\"Lotes ativos\",batchProgress:\"{{completed}} de {{total}} concluídos\",cancelBatch:\"Cancelar restantes\",batchCancelled:\"Itens restantes do lote cancelados\",cancelBatchConfirmTitle:\"Cancelar lote\",cancelBatchConfirmMessage:\"Cancelar todos os itens pendentes restantes neste lote?\",batch:\"Lote\",sections:{currentlyPrinting:\"Imprimindo Atualmente\",queued:\"Na Fila\",history:\"Histórico\"},status:{pending:\"Pendente\",waiting:\"Aguardando\",printing:\"Imprimindo\",paused:\"Pausado\",completed:\"Concluído\",failed:\"Falhou\",skipped:\"Ignorado\",cancelled:\"Cancelado\"},summary:{printing:\"Imprimindo\",queued:\"Na Fila\",totalTime:\"Tempo Total da Fila\",totalWeight:\"Peso Total da Fila\",history:\"Histórico\"},filter:{allPrinters:\"Todas as Impressoras\",unassigned:\"Não Atribuído\",allStatus:\"Todos os Status\",allLocations:\"Todos os Locais\",any:\"Qualquer\"},sort:{byPosition:\"Ordenar por Posição\",byName:\"Ordenar por Nome\",byPrinter:\"Ordenar por Impressora\",bySchedule:\"Ordenar por Agendamento\",byDate:\"Ordenar por Data\",ascendingOldest:\"Crescente (mais antigo primeiro)\",descendingNewest:\"Decrescente (mais recente primeiro)\"},badges:{staged:\"Preparado (início manual)\",requiresPrevious:\"Requer sucesso anterior\",autoPowerOff:\"Desligamento automático\",gcodeInjection:\"G-code\"},empty:{title:\"Nenhuma impressão agendada\",description:'Agende uma impressão a partir da página de Arquivos usando a opção \"Agendar\" no menu de contexto, ou arraste e solte arquivos para começar.'},time:{asap:\"ASAP\",overdue:\"Atrasado\",now:\"Agora\",lessThanMinute:\"Em menos de um minuto\",inMinutes:\"Em {{count}} min\",inHours:\"Em {{count}} horas\"},actions:{stopPrint:\"Parar Impressão\",startPrint:\"Iniciar Impressão\",requeue:\"Reenfileirar\"},bulkEdit:{title:\"Editar {{count}} Item\",title_plural:\"Editar {{count}} Itens\",description:\"Apenas as configurações alteradas serão aplicadas aos itens selecionados.\",printer:\"Impressora\",noChange:\"— Sem alterações —\",queueOptions:\"Opções de Fila\",staged:\"Preparado (início manual)\",autoPowerOff:\"Desligamento automático após impressão\",requirePrevious:\"Requer sucesso anterior\",printOptions:\"Opções de Impressão\",bedLevelling:\"Nivelamento da Mesa\",flowCalibration:\"Calibração de Fluxo\",vibrationCalibration:\"Calibração de Vibração\",layerInspection:\"Inspeção da Primeira Camada\",timelapse:\"Timelapse\",useAms:\"Usar AMS\",applyChanges:\"Aplicar Alterações\",selectAll:\"Selecionar Todos\",deselectAll:\"Desmarcar Todos\",selected:\"{{count}} selecionado(s)\",editSelected:\"Editar Selecionados\",cancelSelected:\"Cancelar Selecionados\"},confirm:{cancelTitle:\"Cancelar Impressão Agendada\",cancelMessage:'Tem certeza de que deseja cancelar \"{{name}}\"?',stopTitle:\"Parar Impressão\",stopMessage:'Tem certeza de que deseja parar a impressão atual \"{{name}}\"? Isso cancelará o trabalho de impressão na impressora.',removeTitle:\"Remover do Histórico\",removeMessage:'Tem certeza de que deseja remover \"{{name}}\" do histórico da fila?',clearHistoryTitle:\"Limpar Histórico\",clearHistoryMessage:\"Tem certeza de que deseja remover todos os {{count}} itens do histórico?\",cancelButton:\"Cancelar Impressão\",stopButton:\"Parar Impressão\",thisPrint:\"esta impressão\",thisItem:\"este item\"},toast:{cancelled:\"Item da fila cancelado\",cancelFailed:\"Falha ao cancelar item\",removed:\"Item da fila removido\",removeFailed:\"Falha ao remover item\",stopped:\"Impressão parada\",stopFailed:\"Falha ao parar impressão\",released:\"Impressão liberada para a fila\",startFailed:\"Falha ao iniciar impressão\",reorderFailed:\"Falha ao reordenar fila\",historyCleared:\"Limpar {{count}} item(s) do histórico\",clearHistoryFailed:\"Falha ao limpar histórico\",updateFailed:\"Falha ao atualizar itens\",bulkCancelled:\"Cancelado {{count}} item(s)\",bulkCancelFailed:\"Falha ao cancelar itens\"},timeline:{listView:\"Lista\",timelineView:\"Linha do tempo\",unassigned:\"Não atribuído\",noData:\"Nenhuma impressão agendada para este dia\",allDoneBy:\"Todas as impressões concluídas até {{time}}\",staged:\"Preparado\",filterAll:\"Mostrar tudo\",filterPrinting:\"Imprimindo\",filterQueued:\"Na fila\",time:{anyMoment:\"a qualquer momento\",minutesLeft:\"{{minutes}}m restantes\",hoursLeft:\"{{hours}}h restantes\",hoursMinutesLeft:\"{{hours}}h {{minutes}}m restantes\"},day:{previous:\"Dia anterior\",next:\"Próximo dia\",today:\"Hoje\"}},permissions:{noStopPrint:\"Você não tem permissão para parar impressões\",noStartPrint:\"Você não tem permissão para iniciar impressões\",noEdit:\"Você não tem permissão para editar este item da fila\",noCancel:\"Você não tem permissão para cancelar este item da fila\",noRequeue:\"Você não tem permissão para reenfileirar itens\",noRemove:\"Você não tem permissão para remover este item da fila\",noClearHistory:\"Você não tem permissão para limpar todo o histórico\",noEditItems:\"Você não tem permissão para editar itens da fila\",noCancelItems:\"Você não tem permissão para cancelar itens da fila\"}},backgroundDispatch:{unknownFile:\"Unknown file\",unknownPrinter:\"Unknown printer\",startingPrints:\"Starting prints\",progressSummary:\"{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}\",expandDetails:\"Expand dispatch details\",collapseDetails:\"Collapse dispatch details\",dismissToast:\"Dismiss dispatch toast\",cancelDispatchJob:\"Cancel dispatch job\",cancel:\"Cancel\",cancelling:\"Cancelling…\",status:{dispatched:\"Dispatched\",processing:\"Processing\",completed:\"Completed\",failed:\"Failed\",cancelled:\"Cancelled\"},toast:{cancellingUpload:\"Cancelling upload...\",cancelled:\"Dispatch cancelled\",cancelFailed:\"Failed to cancel dispatch\",completeWithFailures:\"Background dispatch complete: {{completed}} succeeded, {{failed}} failed\",completeSuccess:\"Background dispatch complete: {{completed}} succeeded\",printStartedRemaining:\"{{completed}} impressão(ões) iniciada(s), {{remaining}} enviando...\"}},stats:{title:\"Dashboard\",subtitle:\"Arraste os widgets para reorganizar. Clique no ícone de olho para ocultar.\",overview:\"Visão Geral\",totalPrints:\"Total de Impressões\",successRate:\"Taxa de Sucesso\",totalPrintTime:\"Tempo Total de Impressão\",printTime:\"Tempo de Impressão\",totalFilament:\"Filamento Total Utilizado\",filamentUsed:\"Filamento Utilizado\",filamentCost:\"Custo do Filamento\",totalCost:\"Custo Total\",energyUsed:\"Energia Utilizada\",energyCost:\"Custo da Energia\",energyWarmingUpTooltip:\"O monitoramento de energia ainda está coletando snapshots por hora. Os totais por período ficarão precisos quando houver pelo menos um snapshot antes do intervalo selecionado. Valores iniciais podem ser subestimados.\",averagePrintTime:\"Tempo Médio de Impressão\",printsPerDay:\"Impressões por Dia\",byPrinter:\"Por Impressora\",printsByPrinter:\"Impressões por Impressora\",byMaterial:\"Por Material\",byMonth:\"Por Mês\",last7Days:\"Últimos 7 Dias\",last30Days:\"Últimos 30 Dias\",last90Days:\"Últimos 90 Dias\",allTime:\"Todo o Tempo\",quickStats:\"Estatísticas Rápidas\",printActivity:\"Atividade de Impressão\",filamentTypes:\"Tipos de Filamento\",filamentTrends:\"Tendências de Filamento\",failureAnalysis:\"Análise de Falhas\",timeAccuracy:\"Precisão do Tempo\",successful:\"Bem-sucedido:\",failed:\"Falhou:\",perfectEstimate:\"100% = estimativa perfeita\",noTimeAccuracyData:\"Nenhum dado de precisão de tempo disponível\",noFilamentData:\"Nenhum dado de filamento disponível\",noPrinterData:\"Nenhum dado de impressora disponível\",noPrintData:\"Nenhum dado de impressão disponível\",noPrintDataLast30Days:\"Nenhum dado de impressão nos últimos 30 dias\",failureReasons:\"Razões de Falha\",topFailureReasons:\"Principais Razões de Falha\",failedPrintsCount:\"{{failed}} / {{total}} impressões falharam\",lastWeekRate:\"Última semana: {{rate}}%\",resetLayout:\"Redefinir Layout\",recalculateCosts:\"Recalcular Custos\",recalculateCostsHint:\"Recalcular todos os custos do arquivo usando os preços atuais do filamento\",exportStats:\"Exportar Estatísticas\",exportAsCsv:\"Exportar como CSV\",exportAsExcel:\"Exportar como Excel\",hiddenCount:\"{{count}} Oculto\",exportDownloaded:\"Exportação baixada\",exportFailed:\"Falha na exportação\",layoutReset:\"Layout redefinido\",recalculatedCosts:\"Custos recalculados para {{count}} arquivos\",recalculateFailed:\"Falha ao recalcular custos\",loadingStats:\"Carregando estatísticas...\",noPermissionResetLayout:\"Você não tem permissão para redefinir o layout\",noPermissionRecalculate:\"Você não tem permissão para recalcular custos\",noPrintDataInRange:\"Sem dados no período selecionado\",periodFilament:\"Filamento usado\",periodCost:\"Custo\",avgPerPrint:\"Média por impressão\",usageOverTime:\"Uso ao longo do tempo\",filamentByWeight:\"Peso\",printDuration:\"Duração da impressão\",printerUtilization:\"Utilização da impressora\",filamentSuccess:\"Sucesso por material\",printHabits:\"Hábitos de impressão\",printTimeOfDay:\"Horário de impressão\",colorDistribution:\"Distribuição de cores\",noColorData:\"Nenhum dado de cor disponível\",records:\"Recordes\",longestPrint:\"Impressão mais longa\",heaviestPrint:\"Impressão mais pesada\",mostExpensivePrint:\"Mais cara\",busiestDay:\"Dia mais movimentado\",successStreak:\"Sequência de sucesso\",streakPrint:\"impressão consecutiva\",streakPrints:\"{{count}} impressões consecutivas\",printerStats:\"Estatísticas da impressora\",hours:\"horas\",avgPrints:\"Méd. impressões\",noArchiveData:\"Nenhum dado de impressão disponível\",filamentByTime:\"Tempo\",avgWeight:\"Méd. peso\",avgTime:\"Méd. tempo\",filamentByPrints:\"Impressões\",timeframe:{today:\"Hoje\",\"this-week\":\"Esta semana\",\"this-month\":\"Este mês\",\"last-7\":\"Últimos 7 dias\",\"last-30\":\"Últimos 30 dias\",\"last-90\":\"Últimos 90 dias\",\"this-year\":\"Este ano\",\"all-time\":\"Todo o período\",custom:\"Personalizado\",from:\"De\",to:\"Até\"},allUsers:\"Todos os Usuários\",noUser:\"Sem Usuário (Sistema)\",filterByUser:\"Filtrar por Usuário\"},maintenance:{title:\"Manutenção\",overview:\"Visão Geral\",allOk:\"Todas as manutenções estão em dia\",dueCount:\"{{count}} item pendente\",dueCount_plural:\"{{count}} itens pendentes\",warningCount:\"{{count}} aviso\",warningCount_plural:\"{{count}} avisos\",totalPrintTime:\"Tempo Total de Impressão\",nextMaintenance:\"Próxima Manutenção\",nothingDue:\"Nada pendente\",tasks:\"Tarefas\",lastPerformed:\"Última execução\",interval:\"Intervalo\",hoursRemaining:\"{{hours}}h restantes\",hoursOverdue:\"{{hours}}h atrasadas\",markDone:\"Marcar como Concluída\",performMaintenance:\"Realizar Manutenção\",history:\"Histórico\",noHistory:\"Nenhum histórico de manutenção\",editPrintHours:\"Editar Horas de Impressão\",currentHours:\"Horas Atuais\",statusTab:\"Status\",settingsTab:\"Configurações\",overdueCount:\"{{count}} atrasado\",dueSoonCount:\"{{count}} prestes a vencer\",dueSoon:\"Prestes a vencer\",allGood:\"Tudo certo\",overdueBy:\"Atrasado por {{duration}}\",dueIn:\"Vence em {{duration}}\",timeLeft:\"{{duration}} restantes\",day:\"1 dia\",days:\"{{count}} dias\",week:\"1 semana\",weeks:\"{{count}} semanas\",month:\"1 mês\",months:\"{{count}} meses\",year:\"1 ano\",maintenanceTypes:\"Tipos de Manutenção\",maintenanceTypesDescription:\"Tipos de sistema e suas tarefas de manutenção personalizadas\",addCustomType:\"Adicionar Tipo Personalizado\",restoreDefaults:\"Restaurar Tarefas Padrão\",intervalType:\"Tipo de Intervalo\",intervalValue:\"Intervalo ({{type}})\",icon:\"Icon\",documentationLink:\"Link da Documentação (opcional)\",assignToPrinters:\"Atribuir a Impressoras\",selectAtLeastOnePrinter:\"Selecione pelo menos uma impressora\",addType:\"Adicionar Tipo\",custom:\"Personalizado\",printHours:\"Horas de Impressão\",calendarDays:\"Dias de Calendário\",exampleName:\"ex., Substituir Filtro HEPA\",viewDocumentation:\"Ver documentação\",timeBasedInterval:\"Intervalo baseado em tempo\",intervalOverrides:\"Substituições de Intervalo\",intervalOverridesDescription:\"Personalize os intervalos para impressoras específicas\",assignedToPrinters:\"Atribuído a impressoras:\",noPrintersAssigned:\"Nenhuma impressora atribuída\",addPrinterShort:\"Adicionar:\",printersAssignedClick:\"{{count}} impressora(s) atribuída(s) - clique para gerenciar\",removeFromPrinter:\"Remover desta impressora\",types:{lubricateCarbonRods:\"Lubricar Barras de Carbono\",lubricateRails:\"Lubricar Trilhos Lineares\",cleanNozzle:\"Limpar Bico/Hotend\",checkBelts:\"Verificar Tensão das Correias\",cleanBuildPlate:\"Limpar Plataforma de Impressão\",checkExtruder:\"Verificar Engrenagens do Extrusor\",checkCooling:\"Verificar Ventiladores de Resfriamento\",generalInspection:\"Inspeção Geral\",cleanCarbonRods:\"Limpar Barras de Carbono\",lubricateSteelRods:\"Lubrificar Barras de Aço\",cleanSteelRods:\"Limpar Barras de Aço\",cleanLinearRails:\"Limpar Trilhos Lineares\",checkPtfeTube:\"Verificar Tubo PTFE\",replaceHepaFilter:\"Substituir Filtro HEPA\",replaceCarbonFilter:\"Substituir Filtro de Carbono\",lubricateLeftNozzleRail:\"Lubrificar Trilho do Bico Esquerdo\"},maintenanceComplete:\"Manutenção marcada como concluída\",typeUpdated:\"Tipo de manutenção atualizado\",typeDeleted:\"Tipo de manutenção excluído\",defaultsRestored:\"Restauradas {{count}} tarefa(s) padrão\",printHoursUpdated:\"Horas de impressão atualizadas\",printerAssigned:\"Impressora atribuída\",printerRemoved:\"Impressora removida\",deleteTypeConfirm:'Excluir \"{{name}}\"?',deleteSystemTypeTitle:\"Excluir tarefa de manutenção padrão?\",deleteSystemTypeMessage:'Tem certeza de que deseja excluir a tarefa de manutenção padrão \"{{name}}\"?',noPermissionUpdate:\"Você não tem permissão para atualizar itens de manutenção\",noPermissionPerform:\"Você não tem permissão para realizar manutenção\",noPermissionEditTypes:\"Você não tem permissão para editar tipos de manutenção\",noPermissionDeleteTypes:\"Você não tem permissão para excluir tipos de manutenção\",noPermissionEditHours:\"Você não tem permissão para editar horas de impressão\",noPermissionRemovePrinter:\"Você não tem permissão para remover atribuições de impressora\",noPermissionAssignPrinter:\"Você não tem permissão para atribuir impressoras\",noPermissionEditIntervals:\"Você não tem permissão para editar intervalos\",configureSettings:\"Configure tipos de manutenção e intervalos\"},settings:{title:\"Configurações\",general:\"Geral\",tabs:{general:\"Geral\",smartPlugs:\"Tomadas Inteligentes\",notifications:\"Notificações\",queue:\"Workflow\",filament:\"Filamento\",network:\"Rede\",apiKeys:\"Chaves API\",virtualPrinter:\"Impressora Virtual\",failureDetection:\"Detecção de Falhas\",users:\"Autenticação\",backup:\"Backup\",emailAuth:\"Autenticação por Email\",ldap:\"LDAP\",twoFa:\"Autenticação 2FA\",oidc:\"SSO / OIDC\"},ldap:{title:\"Autenticação LDAP\",enabledDesc:\"A autenticação LDAP está ativada\",disabledDesc:\"A autenticação LDAP está desativada\",disabledHint:\"Configure e salve as configurações LDAP abaixo, depois ative.\",enabled:\"Autenticação LDAP ativada\",disabled:\"Autenticação LDAP desativada\",feature1:\"Usuários podem fazer login com credenciais LDAP\",feature2:\"A conta de administrador local permanece como fallback\",feature3:\"Grupos LDAP são mapeados para grupos BamBuddy no login\",serverConfig:\"Configuração do Servidor LDAP\",serverUrl:\"URL do servidor\",serverUrlHint:\"Use ldap:// para padrão ou ldaps:// para conexões SSL\",security:\"Segurança\",securityHint:\"StartTLS atualiza uma conexão simples para TLS. LDAPS usa TLS desde o início.\",bindDn:\"Bind DN (conta de serviço)\",bindPassword:\"Senha Bind\",searchBase:\"Base DN de pesquisa\",userFilter:\"Filtro de pesquisa de usuário\",userFilterHint:\"{username} é substituído pelo nome de usuário. Use (uid={username}) para OpenLDAP.\",autoProvision:\"Provisionamento automático de usuários\",autoProvisionHint:\"Criar automaticamente uma conta BamBuddy no primeiro login LDAP\",defaultGroup:\"Grupo padrão\",defaultGroupNone:\"— Nenhum (sem fallback) —\",defaultGroupHint:\"Grupo de fallback atribuído quando um usuário LDAP se autentica mas não está em nenhum grupo LDAP mapeado. Deixe vazio para manter usuários não mapeados sem permissões.\",groupMapping:\"Mapeamento de grupos (JSON)\",groupMappingHint:\"Mapear DNs de grupos LDAP para grupos BamBuddy. Grupos disponíveis: \",testConnection:\"Testar conexão\",settingsSaved:\"Configurações LDAP salvas\",errors:{serverRequired:\"URL do servidor LDAP é obrigatória\",searchBaseRequired:\"Base DN de pesquisa é obrigatória\",enableAuthFirst:\"Ative a autenticação primeiro\",configureLdapFirst:\"Salve as configurações LDAP primeiro\"}},email:{smtpSettings:\"Configuração SMTP\",smtpHost:\"Servidor SMTP\",smtpPort:\"Porta SMTP\",security:\"Segurança\",authentication:\"Autenticação\",username:\"Nome de Usuário\",password:\"Senha\",fromEmail:\"Email de Remetente\",fromName:\"Nome de Remetente\",testConnection:\"Testar Conexão SMTP\",testRecipient:\"Email de Teste\",sendTest:\"Enviar Email de Teste\",sending:\"Enviando...\",save:\"Salvar Configurações\",saving:\"Salvando...\",advancedAuth:\"Autenticação Avançada\",advancedAuthEnabled:\"Autenticação Avançada está habilitada\",advancedAuthEnabledDesc:\"Recursos de gerenciamento de usuários baseados em email estão ativos. Novos usuários receberão senhas geradas automaticamente por email, e os usuários podem redefinir suas senhas através do recurso de esqueci minha senha.\",advancedAuthDisabled:\"Autenticação Avançada está desabilitada\",advancedAuthDisabledDesc:\"Habilite a autenticação avançada para ativar recursos baseados em email para gerenciamento de usuários.\",enable:\"Habilitar\",disable:\"Desabilitar\",feature1:\"Senhas são geradas automaticamente e enviadas por email para novos usuários\",feature2:\"Usuários podem fazer login com nome de usuário ou email\",feature3:\"Recurso de esqueci minha senha está disponível\",feature4:\"Administradores podem redefinir senhas de usuários via email\",errors:{requiredFields:\"Por favor, preencha todos os campos obrigatórios\",usernameRequired:\"Nome de usuário é obrigatório quando a autenticação está habilitada\",enterTestEmail:\"Por favor, insira um endereço de email de teste\",smtpServerAndEmail:\"Por favor, preencha o servidor SMTP e o email de remetente antes de testar\",usernamePasswordRequired:\"Nome de usuário e senha são obrigatórios quando a autenticação está habilitada\",configureSmtpFirst:\"Por favor, configure e teste as configurações SMTP primeiro\",enableAuthFirst:\"Por favor, habilite a autenticação primeiro para usar os recursos baseados em e-mail.\"},success:{settingsSaved:\"Configurações SMTP salvas com sucesso\"},securityOptions:{starttls:\"STARTTLS (Porta 587)\",ssl:\"SSL/TLS (Porta 465)\",none:\"Nenhuma (Porta 25)\"},authOptions:{enabled:\"Habilitado\",disabled:\"Desabilitado\"}},appearance:\"Aparência\",notifications:\"Notificações\",smartPlugs:\"Tomadas Inteligentes\",spoolman:\"Spoolman\",updates:\"Atualizações\",language:\"Idioma\",languageDescription:\"Selecione seu idioma preferido\",theme:\"Tema\",themeLight:\"Claro\",themeDark:\"Escuro\",themeSystem:\"Sistema\",defaultView:\"Visualização Padrão\",defaultViewDescription:\"Página a ser exibida ao abrir o aplicativo\",checkForUpdates:\"Verificar Atualizações\",autoUpdate:\"Atualização Automática\",currentVersion:\"Versão Atual\",latestVersion:\"Última Versão\",upToDate:\"Você está atualizado\",updateAvailable:\"Atualização disponível\",notificationLanguage:\"Idioma das Notificações\",notificationLanguageDescription:\"Idioma para notificações push\",bedCooledThreshold:\"Limite de Resfriamento da Cama\",bedCooledThresholdDescription:\"Temperatura abaixo da qual a cama é considerada resfriada após uma impressão\",userNotificationsEnabled:\"Notificações do Usuário\",userNotificationsEnabledDescription:\"Ativa o menu de notificações do usuário e notificações por e-mail para eventos de impressão. Requer Autenticação Avançada.\",userNotificationsDisabledHint:\"Ative a Autenticação Avançada para usar as notificações do usuário.\",notificationProviders:\"Provedores de Notificação\",addProvider:\"Adicionar Provedor\",editProvider:\"Editar Provedor\",providerType:\"Tipo de Provedor\",testNotification:\"Testar Notificação\",testSuccess:\"Notificação de teste enviada com sucesso\",testFailed:\"Falha ao enviar notificação de teste\",quietHours:\"Horas de Silêncio\",quietHoursDescription:\"Não perturbe durante essas horas\",quietHoursStart:\"Início\",quietHoursEnd:\"Fim\",events:{title:\"Eventos de Notificação\",printStart:\"Impressão Iniciada\",printComplete:\"Impressão Concluída\",printFailed:\"Falha na Impressão\",printStopped:\"Impressão Interrompida\",printProgress:\"Marcos de Progresso\",printProgressDescription:\"Notificar em 25%, 50%, 75%\",printerOffline:\"Impressora Offline\",printerError:\"Erro na Impressora\",filamentLow:\"Filamento Baixo\",maintenanceDue:\"Manutenção Pendente\",maintenanceDueDescription:\"Notificar quando a manutenção for necessária\"},smartPlug:{title:\"Tomadas Inteligentes\",add:\"Adicionar Tomada Inteligente\",edit:\"Editar Tomada Inteligente\",name:\"Nome\",ipAddress:\"Endereço IP\",linkedPrinter:\"Impressora Vinculada\",autoOn:\"Ligar Automaticamente\",autoOnDescription:\"Ligar quando a impressão começar\",autoOff:\"Desligar Automaticamente\",autoOffDescription:\"Desligar após a conclusão da impressão\",offDelay:\"Atraso para Desligar\",offDelayMinutes:\"Minutos após a impressão\",offDelayTemp:\"Quando o bico estiver abaixo da temperatura\",currentState:\"Estado Atual\",turnOn:\"Ligar\",turnOff:\"Desligar\"},filamentTracking:\"Rastreamento de Filamento\",filamentTrackingDesc:\"Escolha como rastrear seus rolos de filamento. Você pode usar o inventário interno ou conectar a um servidor Spoolman externo.\",filamentChecks:\"Verificações de filamento\",disableFilamentWarnings:\"Desativar avisos de filamento\",disableFilamentWarningsDesc:\"Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila\",preferLowestFilament:\"Preferir filamento com menor resto\",preferLowestFilamentDesc:\"Quando vários carretéis correspondem, usar o com menos filamento restante\",trackingModeBuiltIn:\"Inventário Interno\",trackingModeBuiltInDesc:\"Correspondência automática de RFID e rastreamento de uso incluídos\",trackingModeSpoolmanDesc:\"Servidor de gerenciamento de filamento externo\",builtInFeatureRfid:\"Detecta automaticamente rolos RFID da Bambu Lab no AMS\",builtInFeatureUsage:\"Rastreia o consumo de filamento por impressão\",builtInFeatureCatalog:\"Gerencia rolos, cores e perfis de fator K\",builtInFeatureThirdParty:\"Rolos de terceiros podem ser atribuídos aos rolos do inventário\",amsSyncButton:\"Sincronizar Pesos do AMS\",amsSyncTitle:\"Sincronizar Pesos dos Rolos do AMS\",amsSyncMessage:\"Isso substituirá todos os pesos dos rolos do inventário pelos valores atuais de % restante do AMS das impressoras conectadas. Use isso para recuperar dados de peso corrompidos. As impressoras devem estar online.\",amsSyncing:\"Sincronizando...\",amsSyncSuccess:\"{{synced}} rolo(s) sincronizado(s), {{skipped}} ignorado(s)\",amsSyncError:\"Falha ao sincronizar pesos do AMS\",spoolmanUrl:\"Spoolman URL\",spoolmanUrlHint:\"URL do seu servidor Spoolman (por exemplo, http://localhost:7912)\",spoolmanConnected:\"Conectado\",spoolmanDisconnected:\"Desconectado\",status:\"Status\",connect:\"Conectar\",disconnect:\"Desconectar\",howSyncWorks:\"Como a Sincronização Funciona\",syncInfoRfidOnly:\"Apenas rolos oficiais da Bambu Lab com RFID são sincronizados\",syncInfoAutoCreate:\"Novos rolos são criados automaticamente no Spoolman na primeira sincronização\",syncInfoThirdPartySkipped:\"Rolos não oficiais da Bambu Lab (terceiros, reabastecidos) são ignorados\",linkingExistingSpools:\"Vinculando Rolos Existentes\",linkingExistingSpoolsDesc:'Para vincular rolos existentes do Spoolman ao seu AMS, passe o mouse sobre um slot do AMS e clique em \"Vincular ao Spoolman\".',syncMode:\"Modo de Sincronização\",syncModeAuto:\"Automático\",syncModeManual:\"Apenas Manual\",syncModeAutoDesc:\"Os dados do AMS são sincronizados automaticamente quando alterações são detectadas\",syncModeManualDesc:\"Somente sincronize quando acionado manualmente\",syncAmsData:\"Sincronizar Dados do AMS\",syncAmsDataDesc:\"Sincronize manualmente os dados do AMS da impressora com o Spoolman\",allPrinters:\"Todas as Impressoras\",noDefaultPrinter:\"Sem padrão (perguntar a cada vez)\",sidebarOrder:\"Ordem da barra lateral\",saveThumbnails:\"Salvar miniaturas\",captureFinishPhoto:\"Capturar foto de conclusão\",noPrintersConfigured:\"Nenhuma impressora configurada\",archiveMode:{always:\"Sempre criar entrada de arquivo\",never:\"Nunca criar entrada de arquivo\",ask:\"Perguntar a cada vez\"},checkForUpdatesLabel:\"Verificar atualizações\",checkPrinterFirmware:\"Verificar firmware da impressora\",includeBetaUpdates:\"Incluir versões beta\",includeBetaUpdatesDesc:\"Notificar sobre versões beta e pré-lançamento ao verificar atualizações\",enableRetry:\"Habilitar tentativa\",homeAssistantDescription:\"Controlar tomadas inteligentes via Home Assistant\",environmentManagedLabel:\"(Gerenciado pelo Ambiente)\",autoEnabledViaEnv:\"Habilitado automaticamente via variáveis de ambiente\",urlFromEnvReadOnly:\"Valor definido pela variável de ambiente HA_URL (somente leitura)\",tokenFromEnvReadOnly:\"Valor definido pela variável de ambiente HA_TOKEN (somente leitura)\",mqttConnectedTo:\"Conectado a\",prometheusDescription:\"Expor dados da impressora no formato Prometheus\",noSmartPlugsTitle:\"Nenhuma tomada inteligente configurada\",noSmartPlugsDescription:\"Adicione uma tomada inteligente baseada em Tasmota para monitorar o consumo de energia e automatizar o controle de energia.\",noProvidersTitle:\"Nenhum provedor configurado\",noProvidersDescription:\"Adicione um provedor para receber alertas.\",noTemplatesAvailable:\"Nenhum modelo disponível. Reinicie o backend para gerar os modelos padrão.\",apiPermissionView:\"Visualizar status da impressora e fila\",apiPermissionEdit:\"Adicionar e remover itens da fila de impressão\",apiKeysEmptyTitle:\"Nenhuma chave API\",apiKeysEmptyDescription:\"Crie uma chave API para integrar com serviços externos.\",noUsersFound:\"Nenhum usuário encontrado\",noGroupsFound:\"Nenhum grupo encontrado\",noGroupsAvailable:\"Nenhum grupo disponível\",passwordsDoNotMatch:\"As senhas não coincidem\",systemGroupWarning:\"Os nomes dos grupos do sistema não podem ser alterados\",authDisabledTitle:\"Autenticação Desativada\",authDisabledFeature1:\"Exigir login para acessar o sistema\",authDisabledFeature2:\"Criar múltiplos usuários com permissões baseadas em grupos\",authDisabledFeature3:\"Controlar acesso com mais de 50 permissões granulares\",userHasCreated:\"Este usuário criou:\",userItemsQuestion:\"O que você gostaria de fazer com esses itens?\",deleteUserConfirm:\"Tem certeza de que deseja excluir este usuário?\",actionCannotBeUndone:\"Esta ação não pode ser desfeita.\",addFirstSmartPlug:\"Adicione sua primeira tomada inteligente\",providers:\"Provedores\",log:\"Registro\",testAll:\"Testar tudo\",testResults:\"Resultados do teste\",testPassedCount:\"{{count}} aprovado\",testFailedCount:\"{{count}} falhou\",messageTemplates:\"Modelos de mensagem\",messageTemplatesDescription:\"Personalize as mensagens de notificação para cada evento.\",apiKeys:\"Chaves API\",apiKeysDescription:\"Crie chaves API para integrações externas e webhooks.\",createKey:\"Criar Chave\",apiKeyCreated:\"Chave API criada com sucesso\",apiKeyCopyWarning:\"Copie esta chave agora - ela não será exibida novamente!\",useInApiBrowser:\"Usar no Navegador API\",createNewApiKey:\"Criar Nova Chave API\",keyName:\"Nome da Chave\",keyNamePlaceholder:\"e.g., Home Assistant, OctoPrint\",readStatus:\"Status de Leitura\",readStatusDescription:\"Visualizar status da impressora e fila\",manageQueue:\"Gerenciar Fila\",manageQueueDescription:\"Adicionar e remover itens da fila de impressão\",controlPrinter:\"Controlar Impressora\",controlPrinterDescription:\"Pausar, retomar e parar impressões\",unnamedKey:\"Chave Sem Nome\",lastUsed:\"Último uso\",read:\"Ler\",control:\"Controlar\",createFirstKey:\"Crie sua primeira chave\",webhookEndpoints:\"Endpoints de Webhook\",webhookApiKeyHint:\"Use sua chave API no cabeçalho X-API-Key.\",webhook:{getAllStatus:\"Obter status de todas as impressoras\",getSpecificStatus:\"Obter status de uma impressora específica\",addToQueue:\"Adicionar à fila de impressão\",pausePrint:\"Pausar impressão\",resumePrint:\"Retomar impressão\",stopPrint:\"Parar impressão\"},apiBrowser:\"Navegador API\",apiBrowserDescription:\"Explore e teste todos os endpoints de API disponíveis.\",apiKeyForTesting:\"Chave API para Teste\",apiKeyPlaceholder:\"Cole sua chave API aqui para testar endpoints autenticados...\",apiKeyHint:\"Esta chave será enviada como cabeçalho X-API-Key nas solicitações.\",deleteApiKeyTitle:\"Excluir Chave API\",deleteApiKeyMessage:\"Tem certeza de que deseja excluir esta chave API? Quaisquer integrações usando esta chave deixarão de funcionar.\",deleteKey:\"Excluir Chave\",amsDisplayThresholds:\"Limiares de Exibição AMS\",amsThresholdsDescription:\"Configure os limiares de cores para os indicadores de umidade e temperatura do AMS.\",humidity:\"Umidade\",goodGreen:\"Bom (verde)\",fairOrange:\"Razoável (laranja)\",aboveFairBad:\"Acima do limiar razoável mostra como vermelho (ruim)\",fairAlsoDryingThreshold:\"Este limiar também é usado para acionar a secagem automática\",temperature:\"Temperatura\",goodBlue:\"Bom (azul)\",aboveFairHot:\"Acima do limiar razoável mostra como vermelho (quente)\",historyRetention:\"Retenção de Histórico\",keepSensorHistory:\"Manter histórico do sensor por\",historyRetentionDescription:\"Dados antigos de umidade e temperatura serão automaticamente excluídos\",defaultPrintOptions:\"Opções de impressão padrão\",defaultPrintOptionsDescription:\"Defina valores padrão para opções de impressão. Podem ser alterados no diálogo de impressão.\",defaultBedLevelling:\"Nivelamento da mesa\",defaultBedLevellingDesc:\"Nivelar automaticamente a mesa antes da impressão\",defaultFlowCali:\"Calibração de fluxo\",defaultFlowCaliDesc:\"Calibrar fluxo de extrusão\",defaultVibrationCali:\"Calibração de vibração\",defaultVibrationCaliDesc:\"Reduzir artefatos de ringing\",defaultLayerInspect:\"Inspeção da primeira camada\",defaultLayerInspectDesc:\"Inspeção IA da primeira camada\",defaultTimelapse:\"Timelapse\",defaultTimelapseDesc:\"Gravar vídeo timelapse\",staggeredStart:\"Staggered Start\",staggeredStartDescription:\"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.\",plateClear:\"Confirmação de placa livre\",requirePlateClear:\"Exigir confirmação de placa livre\",requirePlateClearDescription:'Quando ativado, o agendador aguarda uma confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desativar isso também oculta o indicador de status da placa e o botão \"Marcar placa como liberada\" nos cartões das impressoras.',gcodeInjection:\"Injeção de G-code\",gcodeInjectionDescription:'Configure G-code personalizado para injetar no início e/ou no final das impressões para sistemas de impressão automática como Farmloop, SwapMod, AutoClear e Printflow 3D. Os snippets são configurados por modelo de impressora e aplicados quando \"Injetar G-code\" está ativado em um item da fila.',gcodeInjectionNoPrinters:\"Nenhuma impressora encontrada. Adicione impressoras para configurar snippets de G-code.\",gcodeStartLabel:\"G-code inicial\",gcodeEndLabel:\"G-code final\",gcodeStartPlaceholder:\"G-code inserido antes do início da impressão...\",gcodeEndPlaceholder:\"G-code adicionado após o término da impressão...\",staggerGroupSize:\"Group size\",staggerGroupSizeHelp:\"Printers to start simultaneously per group\",staggerInterval:\"Interval (minutes)\",staggerIntervalHelp:\"Delay between each group starting\",queueDrying:\"Secagem Automática\",queueDryingDescription:\"Secar automaticamente o filamento AMS quando a impressora estiver ociosa entre impressões na fila. Usa o limite de umidade acima.\",queueDryingEnabled:\"Ativar secagem automática\",queueDryingEnabledDescription:\"Iniciar secagem AMS automaticamente quando a impressora estiver ociosa e a umidade estiver acima do limite\",queueDryingBlock:\"Aguardar conclusão da secagem\",queueDryingBlockDescription:\"Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.\",ambientDryingEnabled:\"Secagem ambiente\",ambientDryingEnabledDescription:\"Secar automaticamente o filamento em impressoras ociosas quando a umidade exceder o limite, mesmo sem impressões na fila.\",dryingPresets:\"Predefinições de secagem\",dryingPresetsDescription:\"Temperatura e duração por tipo de filamento. AMS 2 Pro usa temperaturas mais baixas, AMS-HT suporta temperaturas mais altas.\",dryingFilament:\"Filamento\",printModal:\"Modal de Impressão\",expandCustomMapping:\"Expandir mapeamento personalizado por padrão\",expandCustomMappingDescription:\"Ao imprimir em várias impressoras, mostrar o mapeamento AMS por impressora expandido\",authentication:\"Autenticação\",authEnabledDescription:\"Sua instância está protegida com autenticação de usuário\",authDisabledDescription:\"Ative para exigir login e gerenciar o acesso dos usuários\",authDisabledMessage:\"Ative a autenticação para criar contas de usuário, gerenciar permissões e proteger sua instância do Bambuddy.\",enableAuthentication:\"Ativar Autenticação\",currentUser:\"Usuário Atual\",changePassword:\"Alterar Senha\",admin:\"Administrador\",users:\"Usuários\",addUser:\"Adicionar Usuário\",groups:\"Grupos\",addGroup:\"Adicionar Grupo\",system:\"Sistema\",noDescription:\"Sem descrição\",userCount:\"{{count}} usuários\",permissionCount:\"{{count}} permissões\",createUser:\"Criar Usuário\",username:\"Nome de Usuário\",enterUsername:\"Digite o nome de usuário\",password:\"Senha\",enterPassword:\"Digite a senha (mínimo 6 caracteres)\",confirmPassword:\"Confirmar Senha\",confirmPasswordPlaceholder:\"Confirme a senha\",viewReleaseOnGitHub:\"Ver lançamento no GitHub\",turnAllPlugsOn:\"Ligar todas as tomadas\",turnAllPlugsOff:\"Desligar todas as tomadas\",clearNotificationLogs:\"Limpar Logs de Notificação\",clearLogsMessage:\"Isso excluirá permanentemente todos os logs de notificação com mais de 30 dias. Esta ação não pode ser desfeita.\",clearLogs:\"Limpar Logs\",resetUiPreferences:\"Redefinir Preferências de UI\",resetUiPreferencesMessage:\"Isso redefinirá todas as preferências de UI para os padrões: ordem da barra lateral, tema, layout do painel, modos de exibição e preferências de classificação. Suas impressoras, arquivos e configurações do servidor NÃO serão afetados. A página será recarregada após a limpeza.\",resetPreferences:\"Redefinir Preferências\",deleteGroupTitle:\"Excluir Grupo\",deleteGroupMessage:\"Tem certeza de que deseja excluir este grupo? Usuários neste grupo perderão essas permissões.\",deleteGroup:\"Excluir Grupo\",disableAuthenticationTitle:\"Desativar Autenticação\",disableAuthenticationMessage:\"Tem certeza de que deseja desativar a autenticação? Isso tornará sua instância do Bambuddy acessível sem login. Todos os usuários permanecerão no banco de dados, mas a autenticação será desativada.\",disableAuthentication:\"Desativar Autenticação\",configureBambuddy:\"Configurar Bambuddy\",systemDefault:\"Padrão do Sistema\",archiveSettings:\"Configurações de Arquivo\",newWindow:\"Nova Janela\",embeddedOverlay:\"Sobreposição Incorporada\",preferredSlicer:\"Fatiador Preferido\",preferredSlicerDescription:\"Escolha qual aplicativo de fatiamento abrirá os arquivos\",externalCameras:\"Câmeras Externas\",costTracking:\"Rastreamento de Custos\",printsOnly:\"Apenas Impressões\",totalConsumption:\"Consumo Total\",dataManagement:\"Gerenciamento de Dados\",storageUsage:\"Uso de Armazenamento\",storageUsageDescription:\"Detalhamento do uso de dados por categoria\",storageUsageTotal:\"Total\",storageUsageErrors:\"Erros\",storageUsageOtherBreakdown:\"Outros (inclui ativos estáticos, scripts e arquivos de configuração)\",storageUsageSystem:\"Sistema\",storageUsageData:\"Dados\",storageUsageUnavailable:\"Informações de uso de armazenamento indisponíveis\",clearNotificationLogsDescription:\"Excluir logs de notificação com mais de 30 dias\",resetUiPreferencesDescription:\"Redefinir ordem da barra lateral, tema, modos de exibição e preferências de layout. Impressoras, arquivos e configurações não são afetados.\",enableHomeAssistant:\"Ativar Home Assistant\",enableMqtt:\"Ativar MQTT\",useTls:\"Usar TLS\",enableMetricsEndpoint:\"Ativar Endpoint de Métricas\",availableMetrics:\"Métricas Disponíveis\",editUser:\"Editar Usuário\",deleteUserTitle:\"Excluir Usuário\",groupName:\"Nome do Grupo\",leaveEmptyForAnonymous:\"Deixe vazio para anônimo\",leaveEmptyForNoAuth:\"Deixe vazio para sem autenticação\",enterNewPassword:\"Digite a nova senha\",confirmNewPassword:\"Confirme a nova senha\",enterGroupName:\"Digite o nome do grupo\",enterDescriptionOptional:\"Digite a descrição (opcional)\",enterCurrentPassword:\"Digite a senha atual\",enterNewPasswordMin6:\"Digite a nova senha (mínimo 6 caracteres)\",toast:{keyCopied:\"Chave copiada para a área de transferência\",copyFailed:\"Falha ao copiar a chave\",keyAddedToBrowser:\"Chave adicionada ao Navegador de API\",clearLogsFailed:\"Falha ao limpar logs\",uiPreferencesReset:\"Preferências de UI redefinidas. Atualizando...\",authDisabled:\"Autenticação desativada com sucesso\",authDisableFailed:\"Falha ao desativar a autenticação\",apiKeyCreated:\"Chave de API criada\",apiKeyDeleted:\"Chave de API excluída\",userCreated:\"Usuário criado com sucesso\",userUpdated:\"Usuário atualizado com sucesso\",userDeleted:\"Usuário excluído com sucesso\",groupCreated:\"Grupo criado com sucesso\",groupUpdated:\"Grupo atualizado com sucesso\",groupDeleted:\"Grupo excluído com sucesso\",fillRequiredFields:\"Por favor, preencha todos os campos obrigatórios\",passwordsDoNotMatch:\"As senhas não coincidem\",passwordTooShort:\"A senha deve ter pelo menos 6 caracteres\",enterGroupName:\"Por favor, insira um nome de grupo\",settingsSaved:\"Configurações salvas\",cameraSettingsSaved:\"Configurações da câmera salvas\",enterCameraUrl:\"Por favor, insira a URL da câmera\",passwordChanged:\"Senha alterada com sucesso\",connectionFailed:\"Falha na conexão\",testFailed:\"Falha no teste\",cameraConnected:\"Câmera conectada{{resolution}}\"},testConnection:\"Testar Conexão\",catalog:{spoolCatalog:\"Catálogo de Carretéis\",spoolCatalogDescription:\"Pesos de carretéis vazios por marca/tipo. Usado para pesquisa automática de peso ao adicionar carretéis.\",searchCatalog:\"Pesquisar no catálogo...\",addNewEntry:\"Adicionar Nova Entrada\",namePlaceholder:\"Nome (ex.: Bambu Lab - Plástico)\",weight:\"Peso\",type:\"Tipo\",default:\"Padrão\",custom:\"Personalizado\",noMatch:\"Nenhuma entrada corresponde à sua pesquisa\",empty:\"Nenhuma entrada no catálogo\",deleteEntry:\"Excluir Entrada\",deleteConfirm:'Tem certeza de que deseja excluir \"{{name}}\"?',resetCatalog:\"Redefinir Catálogo\",resetConfirm:\"Redefinir catálogo para os padrões? Isso removerá todas as entradas personalizadas.\",loadFailed:\"Falha ao carregar o catálogo de carretéis\",nameWeightRequired:\"Nome e peso são obrigatórios\",entryAdded:\"Entrada adicionada\",addFailed:\"Falha ao adicionar entrada\",entryUpdated:\"Entrada atualizada\",updateFailed:\"Falha ao atualizar entrada\",entryDeleted:\"Entrada excluída\",deleteFailed:\"Falha ao excluir entrada\",resetSuccess:\"Catálogo redefinido para os padrões\",resetFailed:\"Falha ao redefinir catálogo\",exported:\"Exportadas {{count}} entradas\",imported:\"Importadas {{added}} entradas ({{skipped}} ignoradas)\",importFailed:\"Falha ao importar: formato JSON inválido\",exportTooltip:\"Exportar catálogo para JSON\",importTooltip:\"Importar catálogo de JSON\",resetTooltip:\"Redefinir para os padrões\",selectedCount:\"{{count}} selecionados\",deleteSelected:\"Excluir Selecionados\",bulkDeleteConfirm:\"Tem certeza de que deseja excluir {{count}} entradas?\",bulkDeleted:\"{{count}} entradas excluídas\",bulkDeleteFailed:\"Falha ao excluir entradas\"},colorCatalog:{title:\"Catálogo de Cores\",description:\"Cores de filamento por fabricante/material. Usado para pesquisa automática de cores ao adicionar carretéis.\",searchColors:\"Pesquisar cores...\",allManufacturers:\"Todos os fabricantes\",addNewColor:\"Adicionar Nova Cor\",manufacturer:\"Fabricante\",colorName:\"Nome da Cor\",hex:\"Hex\",materialOptional:\"Material (opcional)\",showing:\"Mostrando {{filtered}} de {{total}} cores\",noMatch:\"Nenhuma cor corresponde à sua pesquisa\",empty:\"Nenhuma cor no catálogo\",deleteColor:\"Excluir Cor\",deleteConfirm:'Tem certeza de que deseja excluir \"{{name}}\"?',resetCatalog:\"Redefinir Catálogo de Cores\",resetConfirm:\"Redefinir catálogo para os padrões? Isso removerá todas as cores personalizadas.\",sync:\"Sincronizar\",starting:\"Iniciando...\",syncTooltip:\"Sincronizar do FilamentColors.xyz (2000+ cores, pode levar um minuto)\",loadFailed:\"Falha ao carregar o catálogo de cores\",fieldsRequired:\"Fabricante, nome da cor e cor hex são obrigatórios\",colorAdded:\"Cor adicionada\",addFailed:\"Falha ao adicionar cor\",colorUpdated:\"Cor atualizada\",updateFailed:\"Falha ao atualizar cor\",colorDeleted:\"Cor excluída\",deleteFailed:\"Falha ao excluir cor\",resetSuccess:\"Catálogo de cores redefinido para os padrões\",resetFailed:\"Falha ao redefinir catálogo\",syncUpToDate:\"Já está atualizado ({{count}} cores verificadas)\",syncComplete:\"Adicionadas {{added}} novas cores ({{skipped}} já existiam)\",syncError:\"Erro de sincronização\",syncFailed:\"Falha ao sincronizar do FilamentColors.xyz\",exported:\"Exportadas {{count}} cores\",imported:\"Importadas {{added}} cores ({{skipped}} ignoradas)\",importFailed:\"Falha ao importar: formato JSON inválido\",selectedCount:\"{{count}} selecionados\",deleteSelected:\"Excluir Selecionados\",bulkDeleteConfirm:\"Tem certeza de que deseja excluir {{count}} cores?\",bulkDeleted:\"{{count}} cores excluídas\",bulkDeleteFailed:\"Falha ao excluir cores\"},dateFormat:\"Formato de data\",dateFormatUs:\"US (MM/DD/AAAA)\",dateFormatEu:\"EU (DD/MM/AAAA)\",dateFormatIso:\"ISO (AAAA-MM-DD)\",timeFormat:\"Formato de hora\",timeFormat12:\"12 horas (3:30 PM)\",timeFormat24:\"24 horas (15:30)\",defaultPrinter:\"Impressora padrão\",defaultPrinterDescription:\"Pré-selecionar esta impressora para uploads, reimpressões e outras operações.\",slicerBambuStudio:\"Bambu Studio\",slicerOrcaSlicer:\"OrcaSlicer\",sidebarOrderDescription:\"Arraste itens na barra lateral para reordenar. Restaurar ordem padrão aqui.\",setDefault:\"Definir padrão\",sidebarOrderSetDefaultHint:\"Definir padrão aplica a ordem atual do menu aos usuários que ainda não personalizaram o seu.\",sidebarDefaultSet:\"Ordem padrão do menu foi definida.\",sidebarDefaultCleared:\"Ordem padrão do menu removida.\",sidebarDefaultFailed:\"Falha ao definir a ordem padrão do menu.\",reset:\"Redefinir\",darkMode:\"Modo escuro\",lightMode:\"Modo claro\",active:\"(ativo)\",background:\"Fundo\",accent:\"Destaque\",style:\"Estilo\",bgNeutral:\"Neutro\",bgWarm:\"Quente\",bgCool:\"Frio\",bgOled:\"OLED Preto\",bgSlate:\"Azul ardósia\",bgForest:\"Verde floresta\",accentGreen:\"Verde\",accentTeal:\"Azul-petróleo\",accentBlue:\"Azul\",accentOrange:\"Laranja\",accentPurple:\"Roxo\",accentRed:\"Vermelho\",styleClassic:\"Clássico\",styleGlow:\"Brilhante\",styleVibrant:\"Vibrante\",themeToggleHint:\"Alternar entre modo escuro e claro usando o ícone de sol/lua na barra lateral.\",autoArchivePrints:\"Arquivar impressões automaticamente\",autoArchiveDescription:\"Salvar automaticamente arquivos 3MF quando impressões forem concluídas\",saveThumbnailsDescription:\"Extrair e salvar imagens de pré-visualização dos arquivos 3MF\",captureFinishPhotoDescription:\"Tirar foto da câmera da impressora quando a impressão for concluída\",ffmpegNotInstalled:\"ffmpeg não instalado\",ffmpegRequired:\"A captura de câmera requer ffmpeg. Instale via <brew>brew install ffmpeg</brew> (macOS) ou <apt>apt install ffmpeg</apt> (Linux).\",camera:\"Câmera\",cameraViewMode:\"Modo de visualização da câmera\",cameraOverlayDescription:\"A câmera abre em uma sobreposição redimensionável na tela principal\",cameraWindowDescription:\"A câmera abre em uma janela separada do navegador\",externalCamerasDescription:\"Configure câmeras externas para substituir a câmera integrada da impressora. Suporta streams MJPEG, RTSP, snapshots HTTP e câmeras USB (V4L2). Quando habilitada, a câmera externa é usada para visualização ao vivo e fotos de conclusão.\",cameraPlaceholderUsb:\"Caminho do dispositivo (/dev/video0)\",cameraPlaceholderUrl:\"URL da câmera (rtsp://... ou http://...)\",cameraTypeMjpeg:\"Stream MJPEG\",cameraTypeRtsp:\"Stream RTSP\",cameraTypeSnapshot:\"Snapshot HTTP\",cameraTypeUsb:\"Câmera USB (V4L2)\",cameraRotation:\"Rotação\",test:\"Testar\",connected:\"Conectado\",disconnected:\"Desconectado\",currency:\"Moeda\",defaultFilamentCost:\"Custo padrão do filamento (por kg)\",electricityCost:\"Custo da eletricidade por kWh\",energyDisplayMode:\"Modo de exibição de energia\",energyModePrintDescription:\"O painel mostra a soma da energia usada durante as impressões\",energyModeTotalDescription:\"O painel mostra a energia total dos plugues inteligentes\",fileManager:\"Gerenciador de arquivos\",createArchiveEntry:\"Criar entrada de arquivo ao imprimir\",createArchiveEntryDescription:\"Ao imprimir pelo gerenciador de arquivos, criar opcionalmente uma entrada de arquivo\",lowDiskSpaceWarning:\"Aviso de pouco espaço em disco\",lowDiskSpaceDescription:\"Mostrar aviso quando o espaço livre em disco ficar abaixo deste limite\",printerFirmware:\"Firmware da impressora\",checkFirmwareDescription:\"Verificar atualizações de firmware da Bambu Lab\",bambuddySoftware:\"Software Bambuddy\",autoCheckDescription:\"Verificar automaticamente novas versões ao iniciar\",checkNow:\"Verificar agora\",updateAvailableVersion:\"Atualização disponível: v{{version}}\",releaseNotes:\"Notas da versão\",updateViaDocker:\"Atualizar via Docker Compose:\",installUpdate:\"Instalar atualização\",latestVersionRunning:\"Você está usando a versão mais recente\",failedToCheckUpdates:\"Falha ao verificar atualizações: {{error}}\",backupRestore:\"Backup e Restauração\",backupRestoreDescription:\"Exportar/importar configurações e configurar backup do GitHub\",goToBackup:\"Ir para backup\",externalUrl:\"URL externa\",externalUrlDescription:\"A URL externa onde o Bambuddy está acessível. Usada para imagens de notificação e integrações externas.\",bambuddyUrl:\"URL do Bambuddy\",externalUrlHint:\"Inclua protocolo e porta (ex: http://192.168.1.100:8000)\",ftpRetry:\"Tentativa FTP\",ftpRetryDescription:\"Tentar novamente operações FTP quando o WiFi da impressora é instável. Aplica-se a downloads 3MF, uploads de impressão, downloads de timelapse e atualizações de firmware.\",autoRetryDescription:\"Tentar novamente automaticamente operações FTP que falharam\",retryAttempts:\"Tentativas de reenvio\",retryDelay:\"Atraso entre tentativas\",connectionTimeout:\"Tempo limite de conexão\",time_one:\"{{count}} vez\",time_other:\"{{count}} vezes\",second_one:\"{{count}} segundo\",second_other:\"{{count}} segundos\",nSeconds:\"{{count}} segundos\",increaseForWeakWifi:\"Aumente para impressoras com WiFi fraco\",homeAssistant:\"Home Assistant\",homeAssistantFullDescription:\"Conecte ao Home Assistant para controlar plugues inteligentes via API REST do HA. Suporta entidades switch, light, input_boolean e script.\",homeAssistantUrl:\"URL do Home Assistant\",longLivedAccessToken:\"Token de acesso de longa duração\",haTokenHint:\"Crie um token no HA: Perfil → Tokens de acesso de longa duração → Criar token\",connectionSuccessful:\"Conexão bem-sucedida\",connectionFailed:\"Falha na conexão\",haConnectionSuccess:\"Conectado com sucesso ao Home Assistant.\",haConnectionFailed:\"Falha ao conectar ao Home Assistant.\",mqttPublishing:\"Publicação MQTT\",mqttDescription:\"Publique eventos do BamBuddy para um broker MQTT externo para integração com Node-RED, Home Assistant e outros sistemas de automação.\",mqttEnableDescription:\"Publicar eventos no broker MQTT externo\",brokerHostname:\"Hostname do broker\",port:\"Porta\",usernameOptional:\"Usuário (opcional)\",passwordOptional:\"Senha (opcional)\",topicPrefix:\"Prefixo do tópico\",topicPrefixHint:\"Os tópicos serão: {{prefix}}/printers/<serial>/status, etc.\",prometheusMetrics:\"Métricas Prometheus\",prometheusEndpointDescription:\"Expor métricas da impressora em <code>/api/v1/metrics</code> para monitoramento Prometheus/Grafana.\",bearerTokenOptional:\"Token Bearer (opcional)\",bearerTokenHint:\"Se definido, as requisições devem incluir <code>Authorization: Bearer <token></code>\",metricsConnectionStatus:\"Status da conexão\",metricsPrinterState:\"Estado da impressora (idle/printing/etc)\",metricsPrintProgress:\"Progresso da impressão 0-100%\",metricsBedTemp:\"Temperatura da mesa\",metricsNozzleTemp:\"Temperatura do bico\",metricsPrintsTotal:\"Total de impressões por resultado\",metricsMore:\"...e mais (camadas, ventoinhas, fila, uso de filamento)\",smartPlugsDescription:\"Conecte plugues inteligentes (Tasmota ou Home Assistant) para automatizar o controle de energia e rastrear o consumo energético das suas impressoras.\",allOn:\"Ligar todos\",allOff:\"Desligar todos\",addSmartPlug:\"Adicionar plugue inteligente\",energySummary:\"Resumo de energia\",currentPower:\"Potência atual\",plugsOnline:\"{{reachable}}/{{total}} plugues online\",today:\"Hoje\",yesterday:\"Ontem\",total:\"Total\",enablePlugsForSummary:\"Habilite os plugues para ver o resumo de energia\",addNotificationProvider:\"Adicionar\",systemBadge:\"(Sistema)\",creating:\"Criando...\",changing:\"Alterando...\",deleteUserAndItems:\"Excluir usuário E seus itens\",deleteUserKeepItems:\"Excluir usuário, manter itens (ficarão sem dono)\",ok:\"OK\",twoFa:{totpTitle:\"App Autenticador (TOTP)\",totpDesc:\"Use um app como Google Authenticator, Aegis ou Authy.\",emailOtpTitle:\"OTP por e-mail\",emailOtpDesc:\"Envie um código único para {{email}} ao fazer login.\",emailOtpNoEmail:\"Adicione um endereço de e-mail à sua conta para ativar este método.\",addEmailFirst:\"Sua conta não tem endereço de e-mail. Peça a um administrador para adicionar um.\",setupTotp:\"Configurar App Autenticador\",setupAuthApp:\"Configurar App Autenticador\",setupInstructions:\"Escaneie o código QR com seu app autenticador e confirme com um código.\",manualEntry:\"Não consegue escanear? Digite este segredo manualmente:\",scannedContinue:\"Código escaneado — continuar\",enterCodeToConfirm:\"Digite o código de 6 dígitos do seu app autenticador para confirmar.\",activate:\"Ativar\",disableTotp:\"Desativar Autenticador\",disableConfirmHint:\"Digite um código TOTP válido ou um código de backup para desativar o autenticador.\",totpDisabled:\"App autenticador desativado.\",emailOtpEnabled:\"OTP por e-mail ativado.\",emailOtpDisabled:\"OTP por e-mail desativado.\",smtpRequired:\"Por favor, configure e teste as configurações SMTP primeiro.\",invalidCode:\"Código inválido. Por favor, tente novamente.\",enableEmailOtp:\"Ativar OTP por e-mail\",disableEmailOtp:\"Desativar OTP por e-mail\",emailSetupEnterCode:\"Um código de verificação foi enviado para o seu endereço de e-mail. Digite-o abaixo para confirmar que você possui esta caixa de entrada.\",verifyAndEnable:\"Verificar e Ativar\",emailDisablePasswordHint:\"Digite a senha da sua conta para confirmar a desativação do OTP por e-mail.\",passwordPlaceholder:\"Digite sua senha\",backupCodesTitle:\"Salve seus códigos de backup\",backupCodesWarning:\"Guarde estes códigos em lugar seguro. Cada código só pode ser usado uma vez.\",backupCodesRemaining:\"{{count}} códigos de backup restantes\",savedCodes:\"Códigos salvos\",regenBackup:\"Regenerar códigos de backup\",regenBackupHint:\"Digite seu código TOTP atual para gerar 10 novos códigos de backup.\",newBackupCodes:\"Novos códigos de backup\",linkedAccounts:\"Contas SSO vinculadas\",linkedAccountsDesc:\"Estes provedores de identidade externos estão vinculados à sua conta.\",oidcUnlinked:\"Conta desvinculada.\"},oidc:{title:\"Provedores SSO / OIDC\",desc:\"Configure provedores OpenID Connect para login único.\",addProvider:\"Adicionar provedor\",newProvider:\"Novo provedor\",empty:\"Nenhum provedor OIDC configurado ainda.\",created:\"Provedor criado.\",updated:\"Provedor atualizado.\",deleted:\"Provedor excluído.\",deleteTitle:\"Excluir provedor\",deleteMessage:'Excluir \"{{name}}\"? Todas as contas vinculadas serão desconectadas.',form:{name:\"Nome de exibição\",issuerUrl:\"URL do emissor\",clientId:\"Client ID\",clientSecret:\"Client secret\",scopes:\"Escopos\",iconUrl:\"URL do ícone (opcional)\",enabled:\"Ativado\",autoCreate:\"Criar usuários automaticamente\",autoCreateDesc:\"Cria automaticamente uma conta local no primeiro login.\",autoLink:\"Vincular contas existentes automaticamente\",autoLinkDesc:\"Vincula contas locais existentes por e-mail no primeiro login.\",secretHint:\"deixe em branco para manter\",secretPlaceholder:\"novo segredo\"}}},notification:{printStarted:{title:\"Impressão Iniciada\",body:\"{{printer}}: {{filename}} iniciou a impressão\"},printCompleted:{title:\"Impressão Concluída\",body:\"{{printer}}: {{filename}} foi concluída com sucesso\"},printFailed:{title:\"Falha na Impressão\",body:\"{{printer}}: {{filename}} falhou\"},printStopped:{title:\"Impressão Interrompida\",body:\"{{printer}}: {{filename}} foi interrompida\"},printProgress:{title:\"Progresso da Impressão\",body:\"{{printer}}: {{filename}} está {{percent}}% concluída\"},printerOffline:{title:\"Impressora Offline\",body:\"{{printer}} está offline\"},printerError:{title:\"Erro na Impressora\",body:\"{{printer}}: {{error}}\"},filamentLow:{title:\"Filamento Baixo\",body:\"{{printer}}: O filamento está acabando\"},maintenanceDue:{title:\"Manutenção Pendente\",body:\"{{printer}}: {{items}} precisam de atenção\"}},errors:{generic:\"Algo deu errado\",networkError:\"Erro de rede. Por favor, verifique sua conexão.\",notFound:\"Não encontrado\",unauthorized:\"Não autorizado\",serverError:\"Erro no servidor\",validationError:\"Por favor, verifique sua entrada\",printerConnectionFailed:\"Falha ao conectar à impressora\",saveFailed:\"Falha ao salvar alterações\",deleteFailed:\"Falha ao excluir\",loadFailed:\"Falha ao carregar dados\"},hmsErrors:{title:\"Erros - {{name}}\",noErrors:\"Nenhum erro\",viewOnWiki:\"Ver no Bambu Lab Wiki\",clearInstructions:\"Limpe os erros na impressora para descartá-los aqui.\",clearErrors:\"Limpar Erros\",clearSuccess:\"Erros HMS limpos\",clearFailed:\"Falha ao limpar erros HMS\"},mqttDebug:{title:\"MQTT Log de Depuração\",searchPlaceholder:\"Pesquisar tópico ou payload...\",noMessages:\"Nenhuma mensagem registrada ainda\",startLoggingHint:'Clique em \"Iniciar Registro\" para começar a capturar mensagens MQTT',noMessagesMatch:\"Nenhuma mensagem corresponde ao seu filtro\",adjustFilterHint:\"Tente ajustar seus critérios de pesquisa ou filtro\",incoming:\"Entrada\",outgoing:\"Saída\",loggingStopped:\"Registro interrompido\",loggingActive:\"Registro ativo - as mensagens serão atualizadas automaticamente\",startLogging:\"Iniciar Registro\",stopLogging:\"Parar Registro\",clearLog:\"Limpar Registro\",topic:\"ópico\",timestamp:\"Carimbo de Data/Hora\",direction:\"Direção\",all:\"Todos\"},printerFiles:{title:\"Gerenciador de Arquivos\",storageUsed:\"Usado:\",storageFree:\"Livre:\",filterPlaceholder:\"Filtrar arquivos...\",deleteButton:\"Excluir\",deleteFiles:\"Excluir {{count}} arquivos\",deleteFileConfirm:'Excluir \"{{name}}\"? Isso não pode ser desfeito.',deleteFilesConfirm:\"Excluir {{count}} arquivos selecionados? Isso não pode ser desfeito.\",noFiles:\"Nenhum arquivo na impressora\",loadingFiles:\"Carregando arquivos...\",failedToLoad:\"Falha ao carregar arquivos\",toast:{filesDeleted:\"Arquivos excluídos: {{count}}\",deleteFailed:\"Falha ao excluir: {{error}}\"}},confirm:{delete:\"Tem certeza de que deseja excluir isso?\",unsavedChanges:\"Você tem alterações não salvas. Tem certeza de que deseja sair?\",clearQueue:\"Tem certeza de que deseja limpar a fila?\"},login:{title:\"Bambuddy Login\",subtitle:\"Faça login na sua conta\",username:\"Nome de usuário\",usernamePlaceholder:\"Digite seu nome de usuário\",usernameOrEmail:\"Nome de usuário ou Email\",usernameOrEmailPlaceholder:\"Nome de usuário ou Email\",password:\"Senha\",passwordPlaceholder:\"Digite sua senha\",signIn:\"Entrar\",signingIn:\"Entrando...\",forgotPassword:\"Esqueceu sua senha?\",loginSuccess:\"Login realizado com sucesso\",loginFailed:\"Falha no login\",enterCredentials:\"Por favor, insira nome de usuário e senha\",enterEmail:\"Por favor, insira seu endereço de e-mail\",oidcLoginFailed:\"Falha no login OIDC\",oidcErrors:{providerError:\"O provedor de identidade retornou um erro\",missingParameters:\"Parâmetros obrigatórios ausentes no callback OIDC\",invalidState:\"Estado OIDC inválido ou já utilizado\",stateExpired:\"Sessão OIDC expirada — tente novamente\",providerNotFound:\"Provedor OIDC não encontrado\",discoveryFailed:\"Falha ao obter o documento de descoberta OIDC\",invalidDiscovery:\"Documento de descoberta OIDC inválido\",networkError:\"Erro de rede durante a troca de token OIDC\",badResponse:\"Resposta inesperada durante a troca de token OIDC\",noIdToken:\"O provedor OIDC não retornou um token de ID\",validationFailed:\"Falha na validação do token OIDC\",nonceMismatch:\"Nonce OIDC não corresponde — possível ataque de replay\",missingSubClaim:\"Token OIDC sem claim sub\",noLinkedAccount:\"Nenhuma conta local vinculada a esta identidade OIDC\",accountInactive:\"Sua conta está inativa\",userResolutionFailed:\"Falha ao resolver sua conta\",internalError:\"Erro interno durante o login OIDC\",tokenExchangeFailed:\"Falha na troca de token OIDC\"},forgotPasswordTitle:\"Esqueceu a Senha\",forgotPasswordMessage:\"Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.\",forgotPasswordEmailMessage:\"Digite seu endereço de email e enviaremos uma nova senha.\",emailAddress:\"Endereço de Email\",emailPlaceholder:\"seu.email@exemplo.com\",cancel:\"Cancelar\",sending:\"Enviando...\",sendResetEmail:\"Enviar Email de Redefinição\",howToReset:\"Como redefinir sua senha:\",resetStep1:\"Entre em contato com o administrador do Bambuddy\",resetStep2:\"Peça para redefinir sua senha na Gestão de Usuários\",resetStep3:\"Eles podem definir uma nova senha temporária para você\",resetStep4:\"Faça login com a nova senha e altere-a nas Configurações\",gotIt:\"Entendi\",resetPassword:{title:\"Definir nova senha\",subtitle:\"Digite e confirme sua nova senha abaixo.\",newPassword:\"Nova senha\",newPasswordPlaceholder:\"Pelo menos 8 caracteres\",confirmPassword:\"Confirmar senha\",confirmPasswordPlaceholder:\"Repetir nova senha\",saving:\"Salvando…\",submit:\"Definir nova senha\",backToLogin:\"Voltar para o login\",passwordsDoNotMatch:\"As senhas não coincidem\",passwordTooShort:\"A senha deve ter pelo menos 8 caracteres\",resetFailed:\"Falha ao redefinir senha. O link pode ter expirado.\"},twoFA:{title:\"Autenticação em dois fatores\",subtitle:\"Sua conta está protegida com 2FA. Insira o código de verificação abaixo.\",methodAuthenticator:\"Aplicativo autenticador\",methodEmail:\"Código por e-mail\",methodBackup:\"Código de recuperação\",instructionsTotp:\"Abra seu aplicativo autenticador e insira o código de 6 dígitos gerado para o Bambuddy.\",instructionsEmail:\"Um código de 6 dígitos foi enviado para o seu e-mail. Ele é válido por 10 minutos.\",instructionsEmailNotSent:\"Clique no botão abaixo para receber um código de verificação por e-mail.\",instructionsBackup:\"Insira um dos seus códigos de recuperação de 8 caracteres. Cada código só pode ser utilizado uma vez.\",sendCodeButton:\"Enviar código por e-mail\",sendingCode:\"Enviando...\",resendCode:\"Reenviar código\",codeLabel:\"Código de verificação\",backupCodeLabel:\"Código de recuperação\",codePlaceholder:\"000000\",backupCodePlaceholder:\"XXXXXXXX\",verifyButton:\"Verificar\",verifyingButton:\"Verificando...\",backToLogin:\"← Voltar para o login\",orContinueWith:\"ou entrar com\",signInWith:\"Entrar com {{provider}}\",enterCode:\"Por favor, insira o código de verificação\",sendCodeFailed:\"Falha ao enviar o código de verificação\",invalidCode:\"Código inválido. Por favor, tente novamente.\"}},setup:{title:\"Bambuddy Configuração\",subtitle:\"Configure a autenticação para sua instância do Bambuddy\",enableAuth:\"Ativar Autenticação\",adminAccount:\"Conta de Administrador\",adminAccountDesc:\"Se usuários administradores já existirem, a autenticação será ativada usando as contas de administrador existentes. Deixe os campos abaixo vazios para usar os administradores existentes ou insira novas credenciais para criar um novo usuário administrador.\",adminUsername:\"Nome de usuário do administrador\",adminPassword:\"Senha do administrador\",optionalIfAdminExists:\"(opcional se usuários administradores existirem)\",adminUsernamePlaceholder:\"Digite o nome de usuário do administrador (opcional)\",adminPasswordPlaceholder:\"Digite a senha do administrador (opcional)\",confirmPassword:\"Confirmar Senha\",confirmPasswordPlaceholder:\"Confirme a senha do administrador\",settingUp:\"Configurando...\",completeSetup:\"Concluir Configuração\",toast:{authEnabledAdminCreated:\"Autenticação ativada e usuário administrador criado\",authEnabledExistingAdmins:\"Autenticação ativada usando usuários administradores existentes\",setupCompleted:\"Configuração concluída\",enterBothCredentials:\"Por favor, insira o nome de usuário e a senha do administrador, ou deixe ambos vazios para usar os usuários administradores existentes\",passwordsDoNotMatch:\"As senhas não coincidem\",passwordTooShort:\"A senha deve ter pelo menos 6 caracteres\"}},changePassword:{title:\"Alterar Senha\",currentPassword:\"Senha Atual\",currentPasswordPlaceholder:\"Digite a senha atual\",newPassword:\"Nova Senha\",newPasswordPlaceholder:\"Digite a nova senha (mínimo 6 caracteres)\",confirmPassword:\"Confirmar Senha\",confirmPasswordPlaceholder:\"Confirme a nova senha\",passwordsDoNotMatch:\"As senhas não coincidem\",passwordTooShort:\"A senha deve ter pelo menos 6 caracteres\",changing:\"Alterando...\",success:\"Senha alterada com sucesso\",failed:\"Falha ao alterar a senha\"},plateAlert:{title:\"Impressão Pausada!\",message:\"Objetos detectados na mesa de impressão. A impressão foi automaticamente pausada. Por favor, limpe a mesa e retome a impressão.\",understand:\"Entendi\"},camera:{title:\"Visualização da Câmera\",invalidPrinterId:\"ID da impressora inválido\",live:\"Ao Vivo\",snapshot:\"Captura\",restartStream:\"Reiniciar transmissão\",refreshSnapshot:\"Atualizar captura\",fullscreen:\"Tela Cheia\",exitFullscreen:\"Sair da Tela Cheia\",connectingToCamera:\"Conectando à câmera...\",capturingSnapshot:\"Capturando imagem...\",connectionLost:\"Conexão perdida\",connectionFailed:\"Falha na conexão com a câmera\",reconnecting:\"Reconectando em {{countdown}}s... (tentativa {{attempt}}/{{max}})\",reconnectNow:\"Reconectar agora\",cameraUnavailable:\"Câmera indisponível\",cameraUnavailableDesc:\"Certifique-se de que a impressora está ligada e conectada.\",noCamera:\"Nenhuma câmera disponível\",retry:\"Tentar novamente\",cameraStream:\"Transmissão da câmera\",zoomOut:\"Reduzir zoom\",zoomIn:\"Aumentar zoom\",resetZoom:\"Redefinir zoom\",recording:\"Gravando\",startRecording:\"Iniciar gravação\",stopRecording:\"Parar gravação\",chamberLight:\"Alternar luz da câmara\"},groups:{title:\"Gerenciamento de Grupos\",subtitle:\"Gerenciar grupos de permissão para controle de acesso\",backToSettings:\"Voltar para Configurações\",createGroup:\"Criar Grupo\",noPermission:\"Você não tem permissão para acessar esta página.\",system:\"Sistema\",noDescription:\"Sem descrição\",usersCount:\"{{count}} usuários\",permissionsCount:\"{{count}} permissões\",edit:\"Editar\",delete:\"Excluir\",toast:{created:\"Grupo criado com sucesso\",updated:\"Grupo atualizado com sucesso\",deleted:\"Grupo excluído com sucesso\",enterGroupName:\"Por favor, insira um nome para o grupo\"},modal:{editGroup:\"Editar Grupo\",createGroup:\"Criar Grupo\",cancel:\"Cancelar\",saving:\"Salvando...\",creating:\"Criando...\",saveChanges:\"Salvar Alterações\"},form:{groupName:\"Nome do Grupo\",groupNamePlaceholder:\"Insira o nome do grupo\",systemGroupWarning:\"Os nomes dos grupos do sistema não podem ser alterados\",description:\"Descrição\",descriptionPlaceholder:\"Insira a descrição (opcional)\",permissions:\"Permissões ({{count}} selecionadas)\"},deleteModal:{title:\"Excluir Grupo\",message:\"Tem certeza de que deseja excluir este grupo? Os usuários deste grupo perderão essas permissões.\",confirm:\"Excluir Grupo\"},editor:{title:\"Editar Grupo\",createTitle:\"Criar Grupo\",search:\"Pesquisar permissões...\",selectAll:\"Selecionar Tudo\",clearAll:\"Limpar Tudo\",permissionsSelected:\"{{count}} selecionada(s)\",noResults:\"Nenhuma permissão corresponde à sua pesquisa\"}},users:{title:\"Gerenciamento de Usuários\",subtitle:\"Gerenciar usuários e seu acesso à sua instância do Bambuddy\",backToSettings:\"Voltar para Configurações\",createUser:\"Criar Usuário\",noPermission:\"Você não tem permissão para acessar esta página.\",admin:\"Admin\",noGroups:\"Sem grupos\",active:\"Ativo\",inactive:\"Inativo\",edit:\"Editar\",delete:\"Excluir\",system:\"Sistema\",noGroupsAvailable:\"Nenhum grupo disponível\",table:{username:\"Nome de Usuário\",groups:\"Grupos\",status:\"Status\",actions:\"Ações\"},toast:{created:\"Usuário criado com sucesso\",updated:\"Usuário atualizado com sucesso\",deleted:\"Usuário excluído com sucesso\",fillRequired:\"Por favor, preencha todos os campos obrigatórios\",passwordsDoNotMatch:\"As senhas não coincidem\",passwordTooShort:\"A senha deve ter pelo menos 6 caracteres\"},modal:{createUser:\"Criar Usuário\",editUser:\"Editar Usuário\",cancel:\"Cancelar\",creating:\"Criando...\",saving:\"Salvando...\",saveChanges:\"Salvar Alterações\",advancedAuthSubtitle:\"com Autenticação Avançada\"},form:{username:\"Nome de Usuário\",usernamePlaceholder:\"Insira o nome de usuário\",email:\"Email\",emailPlaceholder:\"user@example.com\",password:\"Senha\",passwordPlaceholder:\"Insira a senha\",confirmPassword:\"Confirmar Senha\",confirmPasswordPlaceholder:\"Confirme a senha\",newPasswordPlaceholder:\"Insira a nova senha\",confirmNewPasswordPlaceholder:\"Confirme a nova senha\",leaveBlankToKeep:\"deixe em branco para manter a atual\",groups:\"Grupos\",optional:\"opcional\",autoGeneratedPassword:\"Uma senha segura será gerada automaticamente e enviada por e-mail ao usuário.\",passwordManagedByAdvancedAuth:'A senha é gerenciada pela Autenticação Avançada. Use \"Redefinir Senha\" para enviar uma nova senha ao usuário por e-mail.',resetPassword:\"Redefinir Senha\",resettingPassword:\"Redefinindo Senha...\"},deleteModal:{title:\"Excluir Usuário\",message:\"Tem certeza de que deseja excluir este usuário? Esta ação não pode ser desfeita.\",confirm:\"Excluir Usuário\"}},streamOverlay:{title:\"Stream Overlay\",invalidPrinterId:\"ID da impressora inválido\",cameraStream:\"Transmissão da câmera\",progress:\"Progresso da impressão\",eta:\"ETA\",printerIdle:\"Impressora ociosa\",printerOffline:\"Impressora offline\",status:{printing:\"Imprimindo\",paused:\"Pausado\",finished:\"Concluído\",failed:\"Falhou\",idle:\"Ocioso\",unknown:\"Desconhecido\"}},profiles:{title:\"Perfis\",subtitle:\"Gerencie seus presets de fatiador e calibrações de avanço de pressão\",tabs:{cloud:\"Perfis na Nuvem\",local:\"Perfis Locais\",kprofiles:\"K-Perfis\"},localProfiles:{title:\"Perfis Locais\",subtitle:\"Importe e gerencie presets de fatiador do OrcaSlicer\",import:\"Importar Perfis\",importDesc:\"Solte arquivos .bbscfg, .bbsflmt, .orca_filament, .zip ou .json aqui\",importing:\"Importando...\",search:\"Pesquisar presets locais...\",noPresets:\"Nenhum preset local ainda\",badge:\"Local\",edit:\"Editar\",delete:\"Excluir\",cancel:\"Cancelar\",deleteConfirmTitle:\"Excluir Preset\",deleteConfirm:\"Tem certeza de que deseja excluir este preset? Esta ação não pode ser desfeita.\",source:\"Fonte\",inheritsFrom:\"Herdado de\",filamentType:\"Tipo\",vendor:\"Fornecedor\",compatiblePrinters:\"Impressoras Compatíveis\",nozzleTemp:\"Temperatura do Bico\",cost:\"Custo\",density:\"Densidade\",pressureAdvance:\"Avanço de Pressão\",filament:\"Filamento\",process:\"Processo\",printer:\"Impressora\",toast:{importSuccess:\"{{count}} preset(s) importada(s)\",importSkipped:\"{{count}} preset(s) ignorada(s) (duplicadas)\",importError:\"{{count}} erro(s) durante a importação\",deleted:\"Preset excluído\",updated:\"Preset atualizado\"}},connectedAs:\"Conectado como\",logout:\"Sair\",noLogoutPermission:\"Você não tem permissão para sair\",failedToLoad:\"Falha ao carregar perfis\",retry:\"Tentar novamente\",time:{justNow:\"Agora mesmo\",minsAgo:\"há {{count}} minutos\",hoursAgo:\"há {{count}} horas\",daysAgo:\"há {{count}} dias\"},toast:{loggedOut:\"Desconectado\"},login:{title:\"Conectar ao Bambu Cloud\",subtitle:\"Sincronize seus presets de fatiador entre dispositivos\",email:\"Email\",password:\"Senha\",region:\"Região\",regionGlobal:\"Global\",regionChina:\"China\",verificationCode:\"Código de Verificação\",totpCode:\"Código do Autenticador\",checkEmail:\"Verifique seu email ({{email}}) para um código de 6 dígitos\",enterTotpHint:\"Digite o código de 6 dígitos do seu aplicativo autenticador\",accessToken:\"Token de Acesso\",accessTokenHint:\"Cole seu token de acesso Bambu Lab (do Bambu Studio)\",back:\"Voltar\",loginButton:\"Entrar\",verifyButton:\"Verificar\",setTokenButton:\"Definir Token\",useToken:\"Usar token de acesso em vez disso\",useEmail:\"Entrar com email em vez disso\",toast:{loggedIn:\"Conectado com sucesso\",codeSent:\"Código de verificação enviado para seu email\",enterTotp:\"Digite o código do seu aplicativo autenticador\",tokenSet:\"Token definido com sucesso\"}},presets:{myPreset:\"Meu preset (editável)\",duplicate:\"Duplicar\",editable:\"Editável\",failedToLoadDetails:\"Falha ao carregar detalhes do preset\",deleteConfirm:\"Excluir este preset?\",deleteWarning:'Isso excluirá permanentemente \"{{name}}\" do Bambu Cloud. Esta ação não pode ser desfeita.',noDuplicatePermission:\"Você não tem permissão para duplicar presets\",noEditPermission:\"Você não tem permissão para editar presets\",noDeletePermission:\"Você não tem permissão para excluir presets\",types:{filament:\"Preset de filamento\",printer:\"Preset de impressora\",process:\"Preset de processo\"},toast:{deleted:\"Preset excluído\",created:\"Preset criado\",updated:\"Preset atualizado\",duplicated:\"Preset duplicado\",fieldAdded:'Campo \"{{key}}\" adicionado',exported:\"Preset exportado\"},baseLabel:\"Base: {{name}}\",currentLabel:\"Atual: {{name}}\",newPreset:\"Novo Preset\",editPreset:\"Editar Preset\",duplicatePreset:\"Duplicar Preset\",createNewPreset:\"Criar Novo Preset\",customizeSettings:\"Personalizar configurações para seu novo preset\",compareWithBase:\"Comparar com o preset base\",compare:\"Comparar\",basePreset:\"Preset Base\",selectBasePreset:\"Selecionar preset base...\",presetName:\"Nome do Preset\",myCustomPreset:\"Meu preset personalizado\",inheritsFrom:\"Herdado de\",dropJsonToImport:\"Solte o arquivo JSON para importar\",tabs:{common:\"Comum\",allFields:\"Todos os Campos\"},availableFields:\"Campos Disponíveis\",searchFieldsPlaceholder:\"Pesquisar campos...\",noMatchingFields:\"Nenhum campo correspondente\",allFieldsAdded:\"Todos os campos adicionados\",addCustomField:\"Adicionar campo personalizado\",yourOverrides:\"Suas Substituições\",noOverridesYet:\"Nenhuma substituição ainda\",clickFieldsToAdd:\"Clique nos campos à esquerda para adicioná-los\",saveAsTemplate:\"Salvar como modelo\",jsonTip:\"Dica: Arraste e solte um arquivo .json em qualquer lugar deste modal para importar configurações\"},cloudView:{searchPlaceholder:\"Pesquisar presets...\",templates:\"Modelos\",refresh:\"Atualizar\",newPreset:\"Novo Preset\",clearFilters:\"Limpar filtros\",compareMode:\"Modo de Comparação\",selectAnotherPreset:\"Selecionar outro preset {{type}}\",clickTwoPresets:\"Clique em dois presets do mesmo tipo para comparar\",selectFirst:\"1. Selecionar primeiro\",selectSecond:\"2. Selecionar segundo\",compareNow:\"Comparar Agora\",lastSynced:\"Última sincronização:\",showingCount:\"Mostrando {{showing}} de {{total}} presets\",noPresetsFound:\"Nenhum preset encontrado\",columns:{filament:\"Filamento\",process:\"Processo\",printer:\"Impressora\"},noFilamentPresets:\"Nenhum preset de filamento\",noProcessPresets:\"Nenhum preset de processo\",noPrinterPresets:\"Nenhum preset de impressora\",filters:{type:\"Tipo\",owner:\"Proprietário\",printer:\"Impressora\",nozzle:\"Bico\",filament:\"Filamento\",layer:\"Camada\",all:\"Todos\",myPresets:\"Meus Presets\",builtIn:\"Integrado\",process:\"Processo\"},noTemplatesPermission:\"Você não tem permissão para gerenciar modelos\",noRefreshPermission:\"Você não tem permissão para atualizar perfis\",noCreatePermission:\"Você não tem permissão para criar presets\"},templates:{title:\"Modelos Rápidos\",noTemplates:\"Nenhum modelo ainda\",createFirst:\"Crie modelos a partir do editor de presets\",typeFilter:\"Tipo:\",deleteTitle:\"Excluir Modelo\",deleteWarning:\"Esta ação não pode ser desfeita\",deleteConfirm:'Tem certeza de que deseja excluir \"{{name}}\"?',namePlaceholder:\"Nome do modelo\",descriptionPlaceholder:\"Descrição\",settingsJson:\"Configurações (JSON)\",fieldsCount:\"{{count}} campos\",shownInModals:\"Exibido em modais\",hiddenInModals:\"Oculto em modais\",apply:\"Aplicar\",toast:{deleted:\"Modelo excluído\",updated:\"Modelo atualizado\",created:\"Modelo criado\",applied:\"Modelo aplicado\"}}},support:{debugLoggingActive:\"Registro de depuração ativo\",manageLogs:\"Gerenciar\",collectItem7:\"Conectividade da impressora e versões de firmware\",collectItem8:\"Status de integração (Spoolman, MQTT, HA)\",collectItem9:\"Interfaces de rede (somente sub-redes)\",collectItem10:\"Versões de pacotes Python\",collectItem11:\"Verificações de integridade do banco de dados\",collectItem12:\"Detalhes do ambiente Docker\"},fileManager:{title:\"Gerenciador de Arquivos\",subtitle:\"Organize e gerencie seus arquivos de impressão\",uploadFiles:\"Enviar Arquivos\",newFolder:\"Nova Pasta\",folderName:\"Nome da Pasta\",folderNamePlaceholder:\"ex.: Peças Funcionais\",renameFile:\"Renomear Arquivo\",renameFolder:\"Renomear Pasta\",moveFiles:\"Mover {{count}} Arquivo(s)\",rootNoFolder:\"Raiz (Sem Pasta)\",current:\"Atual\",linkFolder:\"Vincular Pasta\",linkFolderDescription:'Vincular \"{{name}}\" a um projeto ou arquivo para acesso rápido.',project:\"Projeto\",archive:\"Arquivo\",noProjectsFound:\"Nenhum projeto encontrado\",noArchivesFound:\"Nenhum arquivo encontrado\",unlink:\"Desvincular\",link:\"Vincular\",dragDropFiles:\"Arraste e solte os arquivos aqui\",dropFilesHere:\"Solte os arquivos aqui\",orClickToBrowse:\"ou clique para procurar\",allFileTypesSupported:\"Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.\",zipFilesDetected:\"Arquivos ZIP detectados\",zipExtractOptions:\"Arquivos ZIP serão extraídos. Escolha como lidar com a estrutura de pastas:\",preserveZipStructure:\"Preservar estrutura de pastas do ZIP\",createFolderFromZip:\"Criar pasta a partir do nome do arquivo ZIP\",stlThumbnailGeneration:\"Geração de miniaturas STL\",zipMayContainStl:\"Arquivos ZIP podem conter arquivos STL. Miniaturas podem ser geradas durante a extração.\",thumbnailsCanBeGenerated:\"Miniaturas podem ser geradas para arquivos STL. Modelos grandes podem levar mais tempo para processar.\",generateThumbnailsForStl:\"Gerar miniaturas para arquivos STL\",threemfDetected:\"Arquivos 3MF detectados\",threemfExtractionInfo:\"Modelo da impressora, material, cor e configurações de impressão serão extraídos automaticamente dos arquivos 3MF.\",willBeExtracted:\"Será extraído\",filesExtracted:\"{{count}} arquivos extraídos\",uploadComplete:\"Upload concluído: {{succeeded}} bem-sucedidos\",uploadFailed:\"Falha no envio\",zipFilesFailed:\"{{count}} arquivos falharam\",uploading:\"Enviando...\",changeLink:\"Alterar link...\",linkTo:\"Vincular a...\",linkToProjectOrArchive:\"Vincular a projeto ou arquivo\",addToQueue:\"Adicionar à fila\",schedulePrint:\"Agendar impressão\",generateThumbnail:\"Gerar miniatura\",generateThumbnails:\"Gerar miniaturas\",generateThumbnailsForMissing:\"Gerar miniaturas para arquivos STL que não possuem\",gridView:\"Visualização em grade\",listView:\"Visualização em lista\",lowDiskSpaceWarning:\"Aviso de pouco espaço em disco\",lowDiskSpaceDetails:\"Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.\",files:\"Arquivos\",folders:\"Pastas\",size:\"Tamanho\",free:\"Livre\",allFiles:\"Todos os arquivos\",wrap:\"Quebrar texto\",enableTextWrapping:\"Ativar quebra de texto\",disableTextWrapping:\"Desativar quebra de texto\",collapse:\"Recolher\",collapseFoldersByDefault:\"Recolher pastas por padrão\",expandFoldersByDefault:\"Expandir pastas por padrão\",dragToResizeTooltip:\"Arraste para redimensionar, clique duas vezes para redefinir\",searchFiles:\"Pesquisar arquivos...\",allTypes:\"Todos os tipos\",prints:\"Impressões\",ascending:\"Crescente\",descending:\"Decrescente\",resultsCount:\"{{showing}} de {{total}} arquivos\",selectAll:\"Selecionar tudo\",deselectAll:\"Desmarcar tudo\",selected:\"{{count}} selecionado(s)\",adding:\"Adicionando...\",loadingFiles:\"Carregando arquivos...\",folderIsEmpty:\"A pasta está vazia\",noFilesYet:\"Nenhum arquivo ainda\",folderEmptyDescription:\"Envie arquivos ou mova arquivos para esta pasta para começar.\",noFilesDescription:\"Envie arquivos para começar a organizar seus arquivos relacionados à impressão.\",noMatchingFiles:\"Nenhum arquivo correspondente\",noMatchingFilesDescription:\"Nenhum arquivo corresponde aos seus critérios de pesquisa ou filtro.\",clearFilters:\"Limpar filtros\",printedCount:\"Impresso {{count}}x\",uploadedBy:\"Enviado por\",deleteFolder:\"Excluir pasta\",deleteFile:\"Excluir arquivo\",deleteFilesCount:\"Excluir {{count}} arquivos\",deleteFolderConfirm:\"Tem certeza de que deseja excluir esta pasta? Todos os arquivos dentro também serão excluídos.\",deleteFileConfirm:\"Tem certeza de que deseja excluir este arquivo?\",deleteFilesConfirm:\"Tem certeza de que deseja excluir {{count}} arquivos selecionados? Esta ação não pode ser desfeita.\",deleting:\"Excluindo...\",noPermissionRenameFolder:\"Você não tem permissão para renomear pastas\",noPermissionLinkFolder:\"Você não tem permissão para vincular pastas\",noPermissionDeleteFolder:\"Você não tem permissão para excluir pastas\",noPermissionPrint:\"Você não tem permissão para imprimir\",noPermissionAddToQueue:\"Você não tem permissão para adicionar à fila\",noPermissionDownload:\"Você não tem permissão para baixar arquivos\",noPermissionRenameFile:\"Você não tem permissão para renomear este arquivo\",noPermissionGenerateThumbnail:\"Você não tem permissão para gerar miniaturas\",noPermissionDeleteFile:\"Você não tem permissão para excluir este arquivo\",noPermissionCreateFolder:\"Você não tem permissão para criar pastas\",noPermissionUpload:\"Você não tem permissão para enviar arquivos\",noPermissionMoveFiles:\"Você não tem permissão para mover arquivos\",noPermissionDeleteFiles:\"Você não tem permissão para excluir arquivos\",linkExternal:\"Vincular externo\",linkExternalFolder:\"Vincular pasta externa\",linkExternalFolderDescription:\"Montar um diretório do host (NAS, USB, compartilhamento de rede) no Gerenciador de Arquivos. Os arquivos não são copiados — são acessados diretamente do caminho original.\",externalFolderNamePlaceholder:\"ex. Impressões NAS\",externalPath:\"Caminho do host\",externalPathHelp:\"Caminho absoluto do diretório no host Docker. Deve estar montado como bind no contêiner.\",readOnly:\"Somente leitura\",readOnlyHelp:\"impede uploads e exclusões\",showHiddenFiles:\"Mostrar arquivos ocultos (arquivos ponto)\",externalFolder:\"Pasta externa\",scanFolder:\"Escanear\",toast:{folderCreated:\"Pasta criada\",folderDeleted:\"Pasta excluída\",fileDeleted:\"Arquivo excluído\",filesDeleted:\"Excluídos {{count}} arquivos\",filesMoved:\"Arquivos movidos\",folderLinked:\"Pasta vinculada\",folderUnlinked:\"Pasta desvinculada\",externalFolderLinked:\"Pasta externa vinculada e escaneada\",folderScanned:\"Escaneamento concluído: {{added}} adicionados, {{removed}} removidos\",addedToQueue:\"Adicionado {{count}} arquivo(s) à fila\",addedToQueuePartial:\"Adicionado {{added}} arquivo(s), {{failed}} falharam\",failedToAddToQueue:\"Falha ao adicionar arquivos: {{error}}\",fileRenamed:\"Arquivo renomeado\",folderRenamed:\"Pasta renomeada\",thumbnailsGenerated:\"Geradas {{count}} miniatura(s)\",thumbnailsGeneratedPartial:\"Geradas {{succeeded}} miniatura(s), {{failed}} falharam\",noStlMissingThumbnails:\"Nenhum arquivo STL sem miniatura\",failedToGenerateThumbnails:\"Falha ao gerar miniaturas: {{error}}\",thumbnailGenerated:\"Miniatura gerada\",failedToGenerateThumbnail:\"Falha ao gerar miniatura: {{error}}\"}},projects:{title:\"Projetos\",subtitle:\"Organize e acompanhe seus projetos de impressão 3D\",newProject:\"Novo Projeto\",editProject:\"Editar Projeto\",deleteProject:\"Excluir Projeto\",projectName:\"Nome do Projeto\",description:\"Descrição\",noProjects:\"Nenhum projeto ainda\",noProjectsFiltered:\"Nenhum projeto {{status}}\",noProjectsFilteredHelp:\"Você não tem nenhum projeto {{status}}. Os projetos aparecerão aqui quando seu status mudar.\",createFirst:\"Crie seu primeiro projeto para começar a organizar impressões relacionadas, acompanhar o progresso e gerenciar suas construções.\",createFirstButton:\"Crie Seu Primeiro Projeto\",create:\"Criar\",files:\"Arquivos\",prints:\"Impressões\",plates:\"Placas\",parts:\"Peças\",lastModified:\"Última Modificação\",deleteConfirm:\"Tem certeza de que deseja excluir este projeto? Arquivos e itens da fila serão desvinculados, mas não excluídos.\",addFiles:\"Adicionar Arquivos\",removeFile:\"Remover Arquivo\",viewDetails:\"Ver Detalhes\",namePlaceholder:\"ex., Voron 2.4 Build\",descriptionPlaceholder:\"Descrição opcional...\",color:\"Cor\",targetPlates:\"Placas Alvo\",targetPlatesPlaceholder:\"ex., 25\",targetPlatesHelp:\"Número de trabalhos de impressão\",targetParts:\"Peças Alvo\",targetPartsPlaceholder:\"ex., 150\",targetPartsHelp:\"Total de objetos necessários\",tagsLabel:\"Tags (separadas por vírgula)\",tagsPlaceholder:\"ex., voron, funcional, presente\",dueDate:\"Data de Vencimento\",priority:\"Prioridade\",priorityLow:\"Baixa\",priorityNormal:\"Normal\",priorityHigh:\"Alta\",priorityUrgent:\"Urgente\",statusActive:\"Ativo\",statusCompleted:\"Concluído\",statusArchived:\"Arquivado\",done:\"Concluído\",completed:\"Concluído\",failed:\"Falhou\",inQueue:\"Na fila\",noPrintsYet:\"Nenhuma impressão ainda\",printJobs:\"Trabalhos de impressão (placas)\",partsPrinted:\"Peças impressas\",failedParts:\"Peças falhadas\",import:\"Importar\",export:\"Exportar\",importProject:\"Importar projeto\",exportAll:\"Exportar todos os projetos\",loading:\"Carregando projetos...\",noEditPermission:\"Você não tem permissão para editar projetos\",noDeletePermission:\"Você não tem permissão para excluir projetos\",noCreatePermission:\"Você não tem permissão para criar projetos\",noImportPermission:\"Você não tem permissão para importar projetos\",noExportPermission:\"Você não tem permissão para exportar projetos\",toast:{created:\"Projeto criado\",updated:\"Projeto atualizado\",deleted:\"Projeto excluído\",imported:\"Projeto importado\",multipleImported:\"{{count}} projetos importados\",importFailed:\"Falha na importação\",exported:\"Projetos exportados (apenas metadados)\"}},projectDetail:{notFound:\"Projeto não encontrado\",backToProjects:\"Voltar para Projetos\",export:\"Exportar\",exportProject:\"Exportar projeto\",noExportPermission:\"Você não tem permissão para exportar projetos\",noEditPermission:\"Você não tem permissão para editar projetos\",partOf:\"Parte de:\",priorityLabel:\"Prioridade:\",noPrints:\"Nenhuma impressão neste projeto ainda\",status:{active:\"Ativo\",completed:\"Concluído\",archived:\"Arquivado\"},priority:{low:\"Baixa\",normal:\"Normal\",high:\"Alta\",urgent:\"Urgente\"},dueDate:{overdue:\"Atrasado\",today:\"Vence hoje\",daysLeft:\"{{count}} dias restantes\"},progress:{platesProgress:\"Progresso das Placas\",partsProgress:\"Progresso das Peças\",printJobs:\"Trabalhos de Impressão\",parts:\"Peças\",percentComplete:\"{{percent}}% concluído\",remaining:\"{{count}} restantes\"},stats:{printJobs:\"Trabalhos de Impressão\",total:\"total\",failed:\"{{count}} falhou\",partsPrinted:\"{{count}} peças impressas\",printTime:\"Tempo de Impressão\",filamentUsed:\"Filamento Usado\"},cost:{title:\"Rastreamento de Custos\",filamentCost:\"Custo do Filamento\",energy:\"Energia\",totalCost:\"Custo Total\",total:\"Total\",includesBom:\"incl. lista de materiais\",budget:\"Orçamento\",remaining:\"Restante\"},subProjects:{title:\"Sub-projetos ({{count}})\"},notes:{title:\"Notas\",noEditPermission:\"Você não tem permissão para editar notas\",placeholder:\"Adicione notas sobre este projeto...\",empty:\"Nenhuma nota ainda. Clique em Editar para adicionar notas.\"},files:{title:\"Arquivos\",linkFolders:\"Vincular pastas do Gerenciador de Arquivos\",forQuickAccess:\"a este projeto para acesso rápido.\",fileCount:\"{{count}} arquivo(s)\",empty:\"Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.\",noFiles:\"Nenhum arquivo nesta pasta.\",print:\"Imprimir agora\",addToQueue:\"Adicionar à fila\"},bom:{title:\"Lista de Materiais\",acquired:\"{{completed}}/{{total}} adquiridos\",showAll:\"Mostrar todos\",hideDone:\"Ocultar concluídos\",addPart:\"Adicionar Peça\",noAddPermission:\"Você não tem permissão para adicionar peças\",partNamePlaceholder:\"Nome da peça (ex.: parafusos M3x8)\",partName:\"Nome da peça\",qty:\"Quantidade\",price:\"Preço ({{currency}})\",sourcingUrlPlaceholder:\"URL de fornecimento (opcional)\",remarksPlaceholder:\"Observações (opcional)\",deletePart:\"Excluir Peça\",deleteConfirm:'Tem certeza de que deseja excluir \"{{name}}\"?',noUpdatePermission:\"Você não tem permissão para atualizar peças\",noEditPermission:\"Você não tem permissão para editar peças\",noDeletePermission:\"Você não tem permissão para excluir peças\",totalCost:\"Custo total:\",empty:\"Nenhuma peça na lista de materiais. Adicione hardware, eletrônicos ou outros componentes para rastrear o que precisa ser adquirido.\"},timeline:{title:\"Linha do Tempo de Atividades\",empty:\"Nenhuma atividade ainda.\"},template:{saveAsTemplate:\"Salvar como Modelo\",noCreatePermission:\"Você não tem permissão para criar modelos\"},queue:{title:\"Fila\",viewAll:\"Ver todos\",printing:\"{{count}} imprimindo\",queued:\"{{count}} na fila\"},prints:{title:\"Impressões ({{count}})\"},toast:{projectUpdated:\"Projeto atualizado\",partAdded:\"Peça adicionada\",partRemoved:\"Peça removida\",exportFailed:\"Falha na exportação\",projectExported:\"Projeto exportado\",templateCreated:\"Modelo criado\"}},system:{title:\"Informações do Sistema\",version:\"Versão\",uptime:\"Tempo de Atividade\",cpuUsage:\"Uso da CPU\",memoryUsage:\"Uso da Memória\",diskUsage:\"Uso do Disco\",networkInfo:\"Informações de Rede\",logs:\"Logs\",debugMode:\"Modo de Depuração\",enableDebug:\"Ativar Registro de Depuração\",disableDebug:\"Desativar Registro de Depuração\",downloadLogs:\"Baixar Logs\",clearLogs:\"Limpar Logs\",dockerInfo:\"Informações do Docker\",containerName:\"Nome do Contêiner\",imageName:\"Nome da Imagem\",platform:\"Plataforma\",architecture:\"Arquitetura\"},library:{title:\"Biblioteca de Filamentos\",addFilament:\"Adicionar Filamento\",editFilament:\"Editar Filamento\",deleteFilament:\"Excluir Filamento\",vendor:\"Fornecedor\",material:\"Material\",color:\"Cor\",kFactor:\"Fator K\",temperature:\"Temperatura\",noFilaments:\"Nenhum filamento na biblioteca\",deleteConfirm:\"Tem certeza de que deseja excluir este filamento?\",importFromPrinter:\"Importar da Impressora\",exportToFile:\"Exportar para Arquivo\"},spoolman:{title:\"Integração com Spoolman\",enabled:\"Spoolman Ativado\",url:\"URL do Spoolman\",connected:\"Conectado\",disconnected:\"Não Conectado\",testConnection:\"Testar Conexão\",sync:\"Sincronizar\",syncing:\"Sincronizando...\",lastSync:\"Última Sincronização\",linkToSpoolman:\"Vincular ao Spoolman\",openInSpoolman:\"Abrir no Spoolman\",unlinkSpool:\"Desvincular Carretel\",unlinkConfirmTitle:\"Desvincular carretel?\",unlinkConfirmMessage:\"Isso desconectará o carretel do Spoolman. Os dados do carretel no Spoolman permanecerão inalterados.\",selectSpool:\"Selecionar Carretel\",noUnlinkedSpools:\"Nenhum carretel desvinculado disponível\",linkSuccess:\"Carretel vinculado ao Spoolman com sucesso\",linkFailed:\"Falha ao vincular carretel\",unlinkSuccess:\"Carretel desvinculado do Spoolman com sucesso\",unlinkFailed:\"Falha ao desvincular carretel\",spoolId:\"Carretel ID (Spool ID)\",fillSourceLabel:\"(Spoolman)\",weight:\"Peso\",remaining:\"Restante\",disableWeightSync:\"Desativar Sincronização de Peso Estimado do AMS\",disableWeightSyncDesc:\"Não atualize a capacidade restante a partir das estimativas do AMS. Use isso se preferir o rastreamento de uso do Spoolman em vez das estimativas baseadas em porcentagem do AMS. Novos carretéis ainda usarão a estimativa do AMS como seu peso inicial.\",reportPartialUsage:\"Relatar Uso Parcial para Impressões Falhadas\",reportPartialUsageDesc:\"Quando uma impressão falha ou é cancelada, relate o filamento estimado usado até aquele ponto com base no progresso das camadas.\"},inventory:{title:\"Inventário de Carretéis\",addSpool:\"Adicionar Carretel\",editSpool:\"Editar Carretel\",material:\"Material\",selectMaterial:\"Selecionar material...\",subtype:\"Subtipo\",brand:\"Marca\",searchBrand:\"Pesquisar marca...\",useCustomBrand:'Usar \"{{brand}}\"',useCustomMaterial:\"Usar material personalizado: {{material}}\",colorName:\"Nome da Cor\",colorNamePlaceholder:\"Jade White, Fire Red...\",color:\"Cor\",hexColor:\"Cor Hexadecimal\",pickColor:\"Escolher cor personalizada\",labelWeight:\"Peso da Etiqueta\",coreWeight:\"Peso do Carretel Vazio\",searchSpoolWeight:\"Pesquisar peso do carretel...\",weightUsed:\"Usado\",currentWeight:\"Peso Restante\",measuredWeight:\"Peso Medido\",spoolName:\"Bobina\",costPerKg:\"Custo por kg\",measuredWeightError:\"O peso medido deve estar entre {{min}}g e {{max}}g.\",slicerFilament:\"Filamento do Fatiador\",slicerFilamentName:\"Nome do Predefinido do Fatiador\",slicerPreset:\"Predefinido do Fatiador\",searchPresets:\"Pesquisar predefinições de filamento...\",selectedPreset:\"Selecionado\",noPresetsFound:\"Nenhuma predefinição encontrada\",tempOverrides:\"Substituições de Temperatura\",note:\"Nota\",notePlaceholder:\"Quaisquer notas adicionais sobre este spool...\",archive:\"Arquivar\",restore:\"Restaurar\",noSpools:\"Nenhum carretel ainda. Adicione seu primeiro carretel para começar.\",noManualSpools:\"Nenhum carretel adicionado manualmente disponível. Adicione um carretel ao seu inventário primeiro.\",kProfiles:\"K-Perfis\",addKProfile:\"Adicionar K-Perfil\",assignSpool:\"Atribuir Carretel\",unassignSpool:\"Desatribuir\",assignSuccess:\"Carretel atribuído e slot AMS configurado\",assignFailed:\"Falha ao atribuir carretel\",selectSpool:\"Selecione um carretel para atribuir a este slot\",assigned:\"Atribuído\",assigning:\"Atribuindo...\",searchSpools:\"Pesquisar carretéis...\",showAllSpools:\"Mostrar todos os carretéis\",allMaterials:\"Todos os Materiais\",filterByBrand:\"Filtrar por marca...\",showArchived:\"Mostrar arquivados\",quickAdd:\"Adição rápida (Estoque)\",quantity:\"Quantidade\",stock:\"Estoque\",configured:\"Configurado\",spoolsCreated:\"{{count}} carretéis criados\",spoolCreated:\"Carretel criado\",spoolUpdated:\"Carretel atualizado\",spoolDeleted:\"Carretel excluído\",spoolArchived:\"Carretel arquivado\",spoolRestored:\"Carretel restaurado\",deleteConfirm:\"Tem certeza de que deseja excluir este carretel? Esta ação não pode ser desfeita.\",archiveConfirm:\"Tem certeza de que deseja arquivar este carretel?\",advancedSettings:\"Configurações Avançadas\",filamentInfoTab:\"Informações do Filamento\",paProfileTab:\"Perfil PA\",filamentInfo:\"Filamento\",additional:\"Adicional\",loadingPresets:\"Carregando predefinições da nuvem...\",cloudConnected:\"Nuvem conectada\",cloudNotConnected:\"Nuvem não conectada (usando padrões)\",recentColors:\"Recentes\",searchColors:\"Pesquisar cores...\",searchResults:\"Resultados da pesquisa\",allColors:\"Todas as cores\",commonColors:\"Cores comuns\",showLess:\"Mostrar menos\",showAll:\"Mostrar tudo\",noColorsFound:\"Nenhuma cor corresponde à sua pesquisa\",noResults:\"Nenhum resultado encontrado\",selectMaterialFirst:\"Por favor, selecione um material primeiro na aba Informações do Filamento.\",noPrintersConfigured:\"Nenhuma impressora configurada. Adicione impressoras para usar perfis PA.\",matchingFilter:\"Correspondente\",anyBrand:\"Qualquer marca\",anyVariant:\"Qualquer variante\",autoSelect:\"Seleção automática\",matches:\"correspondências\",match:\"correspondência\",noMatches:\"Nenhuma correspondência\",connected:\"Conectado\",offline:\"Offline\",printerOffline:\"A impressora está offline. Conecte-se para visualizar os perfis de calibração.\",noKProfilesMatch:\"Nenhum K-perfil corresponde ao filamento selecionado.\",leftNozzle:\"Bico Esquerdo\",rightNozzle:\"Bico Direito\",profilesSelected:\"perfil(is) de calibração selecionado(s)\",totalInventory:\"Inventário Total\",totalConsumed:\"Total Consumido\",byMaterial:\"Por Material\",inPrinter:\"Na Impressora\",lowStock:\"Estoque Baixo\",sinceTracking:\"Desde o início do rastreamento\",loadedInAms:\"Carregado no AMS/Ext\",remaining:\"Restante\",weightCheck:\"Verificação de Peso\",lastWeighed:\"Última pesagem\",neverWeighed:\"Nunca pesado\",search:\"Pesquisar carretéis...\",showing:\"Mostrando\",to:\"até\",of:\"de\",show:\"Mostrar\",spools:\"carretéis\",spool:\"carretel\",page:\"Página\",noSpoolsMatch:\"Nenhum resultado encontrado\",noSpoolsMatchDesc:\"Tente ajustar sua pesquisa ou filtros para encontrar o que você está procurando.\",active:\"Ativo\",archived:\"Arquivado\",all:\"Todos\",used:\"Usado\",new:\"Novo\",clearFilters:\"Limpar filtros\",table:\"Tabela\",cards:\"Cartões\",net:\"Líquido\",groupSimilar:\"Agrupar\",groupedSpools:\"{{count}} carretéis idênticos\",groupedRows:\"linhas\",columns:\"Colunas\",configureColumns:\"Configurar Colunas\",configureColumnsDesc:\"Arraste para reordenar as colunas ou use as setas. Alterne a visibilidade com o ícone de olho.\",visible:\"Visível\",reset:\"Redefinir\",cancel:\"Cancelar\",applyChanges:\"Aplicar Alterações\",moveUp:\"Mover para cima\",moveDown:\"Mover para baixo\",hideColumn:\"Ocultar coluna\",showColumn:\"Mostrar coluna\",linkToSpool:\"Vincular ao Carretel\",tagLinked:\"Tag vinculada ao carretel\",tagLinkFailed:\"Falha ao vincular tag\",tagAlreadyLinked:\"Tag já vinculada a outro carretel\",unknownTag:\"Tag RFID desconhecida detectada\",usageHistory:\"Histórico de Uso\",noUsageHistory:\"Nenhum uso registrado ainda\",printName:\"Nome da Impressão\",weightConsumed:\"Peso Consumido\",clearHistory:\"Limpar\",historyCleared:\"Histórico de uso limpo\",fillSourceLabel:\"(Inv)\",lowStockThresholdError:\"O limite deve estar entre 0.1 e 99.9\",assignMismatchTitle:\"Incompatibilidade de material\",assignMismatchMessage:'O material do carretel selecionado \"{{spoolMaterial}}\" não corresponde ao material da bandeja \"{{trayMaterial}}\" para {{location}}. Atribuir mesmo assim?',assignMismatchConfirm:\"Atribuir mesmo assim\",assignPartialMismatchMessage:'O material do carretel \"{{spoolMaterial}}\" é semelhante, mas não corresponde exatamente a \"{{trayMaterial}}\" em {{location}}. Deseja prosseguir?',assignProfileMismatchMessage:'O perfil do carretel \"{{spoolProfile}}\" não corresponde ao perfil da bandeja \"{{trayProfile}}\" em {{location}}. Deseja prosseguir?'},timelapse:{title:\"Timelapse\",create:\"Criar Timelapse\",download:\"Baixar\",delete:\"Excluir\",preview:\"Visualizar\",frameRate:\"Taxa de Quadros\",quality:\"Qualidade\",processing:\"Processando...\",noTimelapses:\"Nenhum timelapse disponível\"},ams:{title:\"AMS\",slot:\"Slot\",empty:\"Vazio\",emptySlot:\"Slot vazio\",unknown:\"Desconhecido\",humidity:\"Umidade\",temperature:\"Temperatura\",filamentType:\"Tipo de Filamento\",filamentColor:\"Cor\",remaining:\"Restante\",history:\"Histórico do AMS\",noHistory:\"Nenhum histórico disponível\",configureSlot:\"Configurar Slot\",externalSpool:\"Carretel Externo\",profile:\"Perfil\",kFactor:\"Fator K\",fill:\"Preencher\",configure:\"Configurar\",used:\"usado\",remainingUnit:\"restante\"},printModal:{title:\"Iniciar Impressão\",selectPrinter:\"Selecionar Impressora\",selectPlate:\"Selecionar Placa\",filamentMapping:\"Mapeamento de Filamento\",totalCost:\"Custo total:\",slotRemainingShort:\" - {{grams}}g rest.\",printSettings:\"Configurações de Impressão\",bedLeveling:\"Nivelamento da Mesa\",flowCalibration:\"Calibração de Fluxo\",vibrationCalibration:\"Calibração de Vibração\",layerInspection:\"Inspeção da Primeira Camada\",timelapse:\"Timelapse\",startPrint:\"Iniciar Impressão\",addToQueue:\"Adicionar à Fila\",cancel:\"Cancelar\",noPrintersAvailable:\"Nenhuma impressora disponível\",printerBusy:\"Impressora ocupada\",printerOffline:\"Impressora offline\",sameTypeDifferentColor:\"Mesmo tipo, cor diferente\",filamentTypeNotLoaded:\"Tipo de filamento não carregado\",openCalendar:\"Abrir calendário\",leftNozzle:\"L\",rightNozzle:\"R\",leftNozzleTooltip:\"Bico esquerdo\",rightNozzleTooltip:\"Bico direito\",filamentOverride:\"Substituição de Filamento\",filamentOverrideHint:\"Substitua opcionalmente os filamentos para atribuição baseada em modelo. O agendador usará os filamentos selecionados em vez dos valores originais do 3MF.\",originalFilament:\"Original\",overrideWith:\"Substituir por\",resetToOriginal:\"Restaurar original\",insufficientFilamentTitle:\"Filamento insuficiente\",insufficientFilamentMessage:\"Alguns dos carretéis atribuídos têm menos filamento restante do que o necessário para esta impressão:\",insufficientFilamentLine:\"{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g\",printAnyway:\"Imprimir mesmo assim\",forceColorMatch:\"Forçar correspondência de cor\",staggerPrinterStarts:\"Stagger printer starts\",staggerGroupSize:\"Group size\",staggerInterval:\"Interval (min)\",staggerPreview:\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",staggerLastGroup:\"last group: {{count}}\",staggerTotal:\"total: {{minutes}} min\",staggerToPrinters:\"Escalonar para {{count}} impressoras\",gcodeInjection:\"Injetar G-code de auto-impressão\"},backup:{title:\"Bakup e Restauração\",createBackup:\"Criar Backup\",restoreBackup:\"Restaurar Backup\",restoreDescription:\"Substituir todos os dados a partir de um arquivo de backup\",downloadBackup:\"Baixar Backup\",uploadBackup:\"Enviar Backup\",lastBackup:\"Último Backup\",autoBackup:\"Auto Backup\",backupNow:\"Fazer Backup Agora\",restoreWarning:\"Aviso: Restaurar um backup substituirá todos os dados atuais.\",includeArchives:\"Incluir Arquivos\",includeSettings:\"Incluir Configurações\",includeProfiles:\"Incluir Perfis\",backupSuccess:\"Backup criado com sucesso\",restoreSuccess:\"Backup restaurado com sucesso\",backupFailed:\"Falha ao criar backup\",restoreFailed:\"Falha ao restaurar backup\",restoreNote:\"A impressora virtual será parada durante a restauração\",githubBackup:\"Backup GitHub\",enabled:\"Ativado\",cloudLoginRequired:\"Login no Bambu Cloud necessário. Entre em Perfis → Perfis Cloud para ativar o backup GitHub.\",cloudLoginRequiredShort:\"Login Cloud necessário\",githubDescription:\"Sincronize automaticamente seus perfis com um repositório GitHub privado para backup e histórico de versões.\",repositoryUrl:\"URL do repositório\",personalAccessToken:\"Token de acesso pessoal\",tokenSaved:\"(salvo)\",enterNewToken:\"Digite um novo token para atualizar\",tokenHint:\"Token de granularidade fina com permissão de leitura/escrita de conteúdo\",branch:\"Branch\",manualOnly:\"Apenas manual\",hourly:\"A cada hora\",daily:\"Diário\",weekly:\"Semanal\",includeInBackup:\"Incluir no backup\",kProfiles:\"K-Perfis\",kProfilesDescription:\"Calibração de avanço de pressão das impressoras conectadas\",noPrintersConnected:\"Nenhuma impressora conectada\",printersConnected:\"{{connected}}/{{total}} conectadas\",cloudProfiles:\"Perfis Cloud\",cloudProfilesDescription:\"Predefinições de filamento, impressora e processo do Bambu Cloud\",appSettings:\"Configurações do App\",appSettingsDescription:\"Configuração do Bambuddy (banco de dados completo)\",spoolInventory:\"Inventário de bobinas\",spoolInventoryDescription:\"Bobinas de filamento, histórico de uso e rastreamento de custos\",printArchives:\"Arquivos de impressão\",printArchivesDescription:\"Metadados do histórico de impressão (sem arquivos gcode/3MF)\",lastBackupAt:\"Último backup:\",noBackupsYet:\"Nenhum backup ainda\",next:\"Próximo:\",startingBackup:\"Iniciando backup...\",test:\"Testar\",enableBackup:\"Ativar backup\",testConnection:\"Testar conexão\",enterRepoUrl:\"Digite a URL do repositório\",enterRepoAndToken:\"Digite a URL do repositório e o token de acesso\",repoRequired:\"A URL do repositório é obrigatória\",tokenRequired:\"O token de acesso é obrigatório\",githubBackupEnabled:\"Backup GitHub ativado\",tokenUpdated:\"Token atualizado\",settingsSaved:\"Configurações salvas\",failedToSave:\"Falha ao salvar: {{message}}\",backupCompleteFiles:\"Backup concluído - {{count}} arquivos atualizados\",backupSkippedNoChanges:\"Backup ignorado - sem alterações\",backupFailed2:\"Falha no backup: {{message}}\",clearedLogs:\"{{count}} logs removidos\",failedToClearLogs:\"Falha ao limpar logs: {{message}}\",history:\"Histórico\",clear:\"Limpar\",date:\"Data\",status:\"Status\",commit:\"Commit\",localBackup:\"Backup local\",localBackupDescription:\"Crie um backup completo dos seus dados do Bambuddy incluindo banco de dados, arquivos, uploads e todos os ficheiros.\",downloadBackupLabel:\"Baixar backup\",completeBackupZip:\"Backup completo: banco de dados + todos os arquivos (ZIP)\",download:\"Baixar\",preparingBackup:\"Preparando backup...\",creatingArchive:\"Criando arquivo de backup... Isso pode demorar para backups grandes.\",downloadingFile:\"Baixando arquivo de backup...\",backupDownloaded:\"Backup baixado com sucesso\",failedToCreateBackup:\"Falha ao criar backup: {{message}}\",restore:\"Restaurar\",restoreReplacesAll:\"A restauração substitui todos os dados.\",restoreReplacesAllDetail:\"Seu banco de dados e arquivos atuais serão completamente substituídos. É necessário reiniciar após a restauração.\",restoreConfirmTitle:\"Restaurar backup\",restoreConfirmMessage:'Tem certeza de que deseja restaurar de \"{{filename}}\"? Isso substituirá completamente seu banco de dados e todos os arquivos. O aplicativo precisará ser reiniciado após a restauração.',restoreConfirmButton:\"Restaurar backup\",uploadingFile:\"Enviando arquivo de backup...\",backupRestoredRestart:\"Backup restaurado. Por favor, reinicie o Bambuddy.\",failedToRestore:\"Falha ao restaurar backup. Verifique o formato do arquivo.\",reloadNow:\"Recarregar agora\",creatingBackup:\"Criando backup\",restoringBackup:\"Restaurando backup\",preparing:\"Preparando...\",processing:\"Processando...\",doNotClosePage:\"Por favor, não feche esta página nem navegue para outro lugar. Esta operação pode levar vários minutos para backups grandes.\",restoring:\"Restaurando...\",restoreComplete:\"Restauração concluída\",restoreFailed2:\"Falha na restauração\",importSettings:\"Importar configurações de um arquivo de backup\",pleaseWaitRestoring:\"Aguarde enquanto seus dados estão sendo restaurados\",selectBackupFile:\"Clique para selecionar um arquivo de backup (.json ou .zip)\",duplicateHandling:\"Como funciona o tratamento de duplicatas:\",matchPrinters:\"Impressoras\",matchPrintersBy:\"correspondência por número de série\",matchSmartPlugs:\"Smart Plugs\",matchSmartPlugsBy:\"correspondência por endereço IP\",matchNotificationProviders:\"Provedores de notificação\",matchNotificationProvidersBy:\"correspondência por nome\",matchFilaments:\"Filamentos\",matchFilamentsBy:\"correspondência por nome + tipo + marca\",matchArchives:\"Arquivos\",matchArchivesBy:\"correspondência por hash de conteúdo (sempre ignorado)\",matchPendingUploads:\"Uploads pendentes\",matchPendingUploadsBy:\"correspondência por nome do arquivo\",matchSettingsTemplates:\"Configurações e modelos\",matchSettingsTemplatesBy:\"sempre sobrescritos\",replaceExisting:\"Substituir dados existentes\",keepExisting:\"Manter dados existentes\",overwriteDescription:\"Sobrescrever itens que já existem com dados do backup\",keepDescription:\"Restaurar apenas itens que ainda não existem\",overwriteCaution:\"Cuidado:\",overwriteWarning:\"A sobrescrita substituirá suas configurações atuais pelos dados do backup. Códigos de acesso das impressoras nunca são sobrescritos por segurança.\",cancel:\"Cancelar\",processingBackup:\"Processando arquivo de backup...\",itemsRestored:\"Itens restaurados\",itemsSkipped:\"Itens ignorados\",restored:\"Restaurados\",skippedAlreadyExist:\"Ignorados (já existem)\",filesCategory:\"Arquivos (3MF, miniaturas, etc.)\",andMore:\"...e mais {{count}}\",newApiKeysGenerated:\"Novas chaves API geradas\",keysShownOnce:\"Estas chaves são exibidas apenas uma vez. Copie-as agora!\",copy:\"Copiar\",noDataFound:\"Nenhum dado para restaurar foi encontrado no arquivo de backup.\",close:\"Fechar\",scheduledBackup:\"Scheduled Backups\",scheduledBackupDescription:\"Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.\",frequency:\"Frequency\",backupTime:\"Time\",retention:\"Retention\",retentionDescription:\"Number of backups to keep\",outputPath:\"Output Path\",outputPathPlaceholder:\"Default: {{path}}\",outputPathDescription:\"Leave empty for default location\",runNow:\"Run Now\",backupFiles:\"Backup Files\",noScheduledBackups:\"No backups yet\",deleteBackup:\"Delete\",deleteBackupConfirm:\"Delete this backup file?\",backupRunning:\"Backup in progress...\",scheduledBackupComplete:\"Backup completed successfully\",scheduledBackupFailed:\"Backup failed\",nextBackup:\"Next backup\",backupSize:\"Size\",utc:\"UTC\",defaultPathLabel:\"Default:\",categories:{settings:\"Configurações\",notification_providers:\"Provedores de notificação\",notification_templates:\"Modelos de notificação\",smart_plugs:\"Smart Plugs\",printers:\"Impressoras\",filaments:\"Filamentos\",maintenance_types:\"Tipos de manutenção\",archives:\"Arquivos\",projects:\"Projetos\",pending_uploads:\"Uploads pendentes\",external_links:\"Links externos\",api_keys:\"Chaves API\"}},tags:{title:\"Tags\",addTag:\"Adicionar Tag\",editTag:\"Editar Tag\",deleteTag:\"Excluir Tag\",tagName:\"Nome da Tag\",tagColor:\"Cor da Tag\",noTags:\"Nenhuma tag\",deleteConfirm:\"Tem certeza de que deseja excluir esta tag?\",manageTags:\"Gerenciar Tags\"},uploadModal:{title:\"Upload Arquivos 3MF\",dragDrop:\"Arraste e solte arquivos .3mf aqui\",or:\"ou\",browseFiles:\"Procurar Arquivos\",extractionInfo:\"O modelo da impressora será extraído automaticamente dos metadados do arquivo 3MF.\",uploaded:\"enviado\",failed:\"falhou\",uploading:\"Enviando...\",upload:\"Enviar\",uploadFailed:\"Falha no envio\"},editArchive:{title:\"Editar Arquivo\",name:\"Nome\",namePlaceholder:\"Nome da impressão\",printer:\"Impressora\",noPrinter:\"Nenhuma impressora\",project:\"Projeto\",noProject:\"Nenhum projeto\",itemsPrinted:\"Itens Impressos\",itemsPrintedHelp:\"Número de itens produzidos neste trabalho de impressão\",notes:\"Notas\",notesPlaceholder:\"Adicione notas sobre esta impressão...\",externalLink:\"Link Externo\",externalLinkPlaceholder:\"https://printables.com/model/...\",externalLinkHelp:\"Link para Printables, Thingiverse ou outra fonte\",tags:\"Tags\",tagsPlaceholder:\"Adicionar tags...\",addMoreTags:\"Adicionar mais tags...\",matchingTags:'Correspondendo \"{{query}}\"',existingTags:\"Tags existentes\",clickToAdd:\"(clique para adicionar)\",status:\"Status\",failureReason:\"Motivo da Falha\",selectReason:\"Selecione o motivo...\",photos:\"Fotos do Resultado da Impressão\",photosHelp:\"Clique em + para adicionar fotos do seu resultado impresso\",printResult:\"Resultado da Impressão\",saving:\"Salvando...\",failureReasons:{adhesionFailure:\"Falha de adesão\",spaghettiDetached:\"Spaghetti / Destacado\",layerShift:\"Deslocamento de camada\",cloggedNozzle:\"Bico entupido\",filamentRunout:\"Fim do filamento\",warping:\"Warping\",stringing:\"Stringing\",underExtrusion:\"Under-extrusion\",powerFailure:\"Falha de energia\",userCancelled:\"Cancelado pelo usuário\",other:\"Outro\"},statuses:{completed:\"Concluído\",failed:\"Falhou\",aborted:\"Cancelado\",printing:\"Imprimindo\"}},kProfiles:{title:\"K-Profiles\",noPrintersConfigured:\"Nenhuma impressora configurada\",addPrinterInSettings:\"Adicione uma impressora nas Configurações para gerenciar K-profiles\",noActivePrinters:\"Nenhuma impressora ativa\",enablePrinterConnection:\"Ative a conexão da impressora para visualizar seus K-profiles\",loadingProfiles:\"Carregando K-Profiles...\",printerOffline:\"Impressora Offline\",printerOfflineDesc:\"A impressora selecionada não está conectada. Ligue-a para visualizar os K-profiles.\",noMatchingProfiles:\"Nenhum Perfil Correspondente\",noMatchingProfilesDesc:\"Nenhum perfil corresponde aos seus critérios de pesquisa\",noKProfiles:\"Nenhum K-Profile\",noKProfilesDesc:\"Nenhum perfil de avanço de pressão encontrado para bico de {{diameter}}mm\",createFirstProfile:\"Criar Primeiro Perfil\",printer:\"Impressora\",nozzle:\"Bico\",refresh:\"Atualizar\",addProfile:\"Adicionar Perfil\",export:\"Exportar\",import:\"Importar\",select:\"Selecionar\",selectAll:\"Selecionar Todos\",delete:\"Excluir\",searchPlaceholder:\"Pesquisar por nome ou filamento...\",allExtruders:\"Todos os Extrusores\",leftOnly:\"Apenas Esquerdo\",rightOnly:\"Apenas Direito\",allFlow:\"Todo Fluxo\",hfOnly:\"Apenas HF\",sOnly:\"Apenas S\",sortName:\"Ordenar: Nome\",sortKValue:\"Ordenar: Valor K\",sortFilament:\"Ordenar: Filamento\",leftExtruder:\"Extrusor Esquerdo\",rightExtruder:\"Extrusor Direito\",modal:{addTitle:\"Adicionar K-Profile\",editTitle:\"Editar K-Profile\",profileName:\"Nome do Perfil\",profileNamePlaceholder:\"Meu Perfil PLA\",kValue:\"Valor K\",kValuePlaceholder:\"0.020\",kValueHelp:\"Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG\",filament:\"Filamento\",selectFilament:\"Selecionar filamento...\",noFilamentsHelp:\"Nenhum filamento encontrado. Crie um K-profile no Bambu Studio primeiro.\",flowType:\"Tipo de Fluxo\",highFlow:\"Alto Fluxo\",standard:\"Padrão\",nozzleSize:\"Tamanho do Bico\",extruder:\"Extrusor\",extruders:\"Extrusores\",left:\"Esquerdo\",right:\"Direito\",notes:\"Notas (armazenadas localmente)\",notesPlaceholder:\"Adicione notas sobre este perfil...\",notesHelp:\"As notas são salvas no Bambuddy, não na impressora\",syncing:\"Sincronizando com a impressora...\",savingExtruder:\"Salvando no extrusor {{current}}/{{total}}...\",pleaseWait:\"Por favor, aguarde\"},deleteConfirm:{title:\"Excluir Perfil\",cannotUndo:\"Isso não pode ser desfeito\",message:'Tem certeza de que deseja excluir \"{{name}}\" da impressora?'},bulkDelete:{title:\"Excluir Perfis\",cannotUndo:\"Isso não pode ser desfeito\",message:\"Tem certeza de que deseja excluir {{count}} perfis selecionados da impressora?\"},toast:{profileSaved:\"K-profile salvo\",profilesSaved:\"K-profile salvo em {{count}} extrusores\",selectAtLeastOneExtruder:\"Por favor, selecione pelo menos um extrusor\",profileDeleted:\"K-profile excluído\",profilesDeleted:\"{{count}} perfis excluídos\",exportedProfiles:\"{{count}} perfis exportados\",importedProfiles:\"{{count}} de {{total}} perfis importados\",noProfilesToExport:\"Nenhum perfil para exportar\",invalidFileFormat:\"Formato de arquivo inválido\",failedToParseImport:\"Falha ao analisar o arquivo de importação\",failedToSaveBatch:\"Falha ao salvar K-profiles\",noteSaved:\"Nota salva\",failedToSaveNote:\"Falha ao salvar nota\"},permission:{noRead:\"Você não tem permissão para atualizar perfis\",noCreate:\"Você não tem permissão para adicionar perfis\",noUpdate:\"Você não tem permissão para atualizar K-profiles\",noDelete:\"Você não tem permissão para excluir K-profiles\",noExport:\"Você não tem permissão para exportar perfis\",noImport:\"Você não tem permissão para importar perfis\"}},virtualPrinter:{title:\"Impressora Virtual\",running:\"Em execução\",stopped:\"Parada\",description:{default:\"Ative uma impressora virtual que aparece no Bambu Studio e no OrcaSlicer. Os arquivos enviados para esta impressora serão arquivados diretamente sem impressão.\",proxy:\"Ative um proxy que retransmite o tráfego do slicer para uma impressora real, permitindo impressão remota em qualquer rede.\"},enable:{title:\"Ativar Impressora Virtual\",visibleInSlicer:'Visível como \"Bambuddy\" na descoberta do slicer',proxyingTo:\"Proxy para {{name}}\",notActive:\"Não ativo\"},model:{title:\"Modelo da Impressora\",description:\"Selecione qual modelo de impressora emular.\",restartWarning:\"Alterar o modelo reiniciará a impressora virtual\"},accessCode:{title:\"Código de acesso\",isSet:\"O código de acesso está definido\",notSet:\"Nenhum código de acesso definido — necessário para ativar.\",placeholder:\"Digite um código de 8 caracteres\",placeholderChange:\"Digite um novo código para alterar\",hint:\"Deve ter exatamente 8 caracteres. Usado pelos slicers para autenticação.\",charCount:\"({{count}}/8)\"},targetPrinter:{title:\"Impressora Alvo\",configured:\"Proxy alvo configurado\",notConfigured:\"Nenhuma impressora alvo selecionada - necessário para o modo proxy\",placeholder:\"Selecione uma impressora...\",hint:\"Selecione a impressora para a qual o tráfego do slicer será enviado. A impressora deve estar no modo LAN.\",noPrinters:\"Nenhuma impressora configurada. Adicione uma impressora primeiro para usar o modo proxy.\"},remoteInterface:{title:\"Substituição da Interface de Rede\",configured:\"Substituição da interface ativa\",optional:\"Opcional - use se o IP detectado automaticamente estiver errado (por exemplo, várias NICs, Docker, VPN)\",placeholder:\"Detecção automática (padrão)...\",hint:\"Substitua o endereço IP anunciado via SSDP e usado no certificado TLS. Útil quando o Bambuddy possui várias interfaces de rede.\"},mode:{title:\"Modo\",archive:\"Arquivar\",archiveDesc:\"Arquivar arquivos imediatamente\",review:\"Revisar\",reviewDesc:\"Revisar antes de arquivar\",queue:\"Fila\",queueDesc:\"Arquivar e adicionar à fila\",proxy:\"Proxy\",proxyDesc:\"Retransmitir para impressora real\"},autoDispatch:{title:\"Envio automático\",description:\"Iniciar impressões automaticamente quando adicionadas à fila. Quando desativado, as impressões aguardam envio manual.\"},setupRequired:{title:\"Configuração Necessária\",description:\"O recurso de impressora virtual requer configuração adicional do sistema antes de funcionar. Isso inclui encaminhamento de portas, regras de firewall e configurações específicas da plataforma.\",readGuide:\"Leia o guia de configuração antes de ativar\"},howItWorks:{title:\"Como funciona\",step1:\"Complete o guia de configuração para sua plataforma\",step2:\"Ative a impressora virtual e defina um código de acesso\",step3:'No Bambu Studio ou OrcaSlicer, vá para \"Adicionar Impressora\"'},status:{title:\"Detalhes do Status\",printerName:\"Nome da Impressora\",model:\"Modelo\",serialNumber:\"Número de Série\",mode:\"Modo\",pendingFiles:\"Arquivos Pendentes\",targetPrinter:\"Impressora Alvo\",ftpPort:\"Porta FTP\",mqttPort:\"Porta MQTT\",ftpConnections:\"Conexões FTP\",mqttConnections:\"Conexões MQTT\"},toast:{updated:\"Configurações da impressora virtual atualizadas\",failedToUpdate:\"Falha ao atualizar as configurações\",accessCodeRequired:\"Defina um código de acesso primeiro\",targetPrinterRequired:\"Selecione uma impressora alvo primeiro\",bindIpRequired:\"Defina um IP de ligação primeiro\",accessCodeEmpty:\"O código de acesso não pode estar vazio\",accessCodeLength:\"O código de acesso deve ter exatamente 8 caracteres\",created:\"Impressora virtual criada\",failedToCreate:\"Falha ao criar impressora virtual\",deleted:\"Impressora virtual excluída\",failedToDelete:\"Falha ao excluir impressora virtual\"},list:{title:\"Impressoras Virtuais\",add:\"Adicionar\",addFirst:\"Adicionar Impressora Virtual\",empty:\"Nenhuma impressora virtual configurada. Adicione uma para começar.\"},bindIp:{title:\"Interface de Rede\",placeholder:\"Selecionar interface...\",hint:\"Interface de rede para esta impressora virtual. Deve ser única por impressora.\"},proxy:{accessCodeHint:\"No modo proxy, use o código de acesso da impressora alvo no slicer. A conexão é encaminhada de forma transparente para a impressora real.\"},addDialog:{title:\"Adicionar Impressora Virtual\",name:\"Nome\",hint:\"Você pode configurar o código de acesso, impressora alvo e outras configurações após a criação.\",create:\"Criar\"},deleteConfirm:{title:\"Excluir Impressora Virtual\",message:'Tem certeza que deseja excluir \"{{name}}\"? Isso irá parar todos os serviços desta impressora.'}},modelViewer:{openInSlicer:\"Abrir no Slicer\",tabs:{model:\"Modelo 3D\",gcode:\"Pré-visualização G-code\"},notAvailable:\"Não disponível\",notSliced:\"Não fatiado\",plates:\"Placas\",allPlates:\"Todas as Placas\",plateNumber:\"Placa {{number}}\",plateCount:\"{{count}} placa\",plateCount_other:\"{{count}} placas\",objectCount:\"{{count}} objeto\",objectCount_other:\"{{count}} objetos\",filamentCount:\"{{count}} filamento\",filamentCount_other:\"{{count}} filamentos\",eta:\"ETA {{minutes}} min\",noPreview:\"Pré-visualização não disponível para este arquivo\",pagination:{pageOf:\"Página {{current}} de {{total}}\",prev:\"Anterior\",next:\"Próximo\"},errors:{failedToLoad:\"Falha ao carregar o arquivo\",noMeshes:\"Nenhuma malha encontrada no arquivo 3MF\",unsupportedFormat:\"Formato de arquivo não suportado\"}},maintenanceDescriptions:{lubricateCarbonRods:\"Aplique lubrificante nos eixos de carbono para um movimento suave\",lubricateRails:\"Aplique lubrificante nos trilhos lineares para um movimento suave\",cleanNozzle:\"Limpe o hotend e o bico para evitar entupimentos\",checkBelts:\"Verifique a tensão das correias para impressões precisas\",cleanBuildPlate:\"Limpe a placa de construção para melhor adesão\",checkExtruder:\"Verifique as engrenagens do extrusor quanto ao desgaste\",checkCooling:\"Verifique se os ventiladores de resfriamento estão funcionando corretamente\",generalInspection:\"Inspeção geral da impressora\",cleanCarbonRods:\"Limpe os eixos de carbono para reduzir o atrito\",lubricateSteelRods:\"Aplique lubrificante nas barras de aço para um movimento suave\",cleanSteelRods:\"Limpe as barras de aço para reduzir o atrito\",cleanLinearRails:\"Limpe os trilhos lineares para remover poeira e detritos\",checkPtfeTube:\"Verifique o tubo PTFE quanto ao desgaste ou danos\",replaceHepaFilter:\"Substitua o filtro HEPA para qualidade do ar\",replaceCarbonFilter:\"Substitua o filtro de carbono ativado\",lubricateLeftNozzleRail:\"Lubrifique o trilho do bico esquerdo (série H2)\"},smartPlugs:{offline:\"Offline\",admin:\"Admin\",openPlugAdminPage:\"Abrir o painel de administração da tomada inteligente\",deleteSmartPlug:\"Excluir Tomada Inteligente\",turnOnSmartPlug:\"Ligar Tomada Inteligente\",turnOffSmartPlug:\"Desligar Tomada Inteligente\",turnOn:\"Ligar\",turnOff:\"Desligar\",addSmartPlug:{scanningNetwork:\"Procurando na rede...\",chooseEntity:\"Escolha uma entidade...\",connectionFailed:\"Falha na conexão\",searchEntities:\"Pesquisar entidades...\",searchPowerSensors:\"Pesquisar sensores de energia...\",searchEnergySensors:\"Pesquisar sensores de energia...\",placeholders:{plugName:\"Tomada da Sala\",mqttStateOnValue:\"ON, true, 1\",mqttSameAsPower:\"Mesmo que o tópico de energia, ou diferente\"}},linkedTo:\"Vinculado a:\",monitorOnly:\"Apenas monitoramento\",alerts:\"Alertas\",scheduleOn:\"Ligar {{time}}\",scheduleOff:\"Desligar {{time}}\",on:\"Ligado\",off:\"Desligado\",power:\"Potência\",kwhToday:\"kWh Hoje\",settings:\"Configurações\",automationSettings:\"Configurações de automação\",showInSwitchbar:\"Mostrar na barra de interruptores\",quickAccessSidebar:\"Acesso rápido pela barra lateral\",enabled:\"Ativado\",enableAutomation:\"Ativar automação para este plugue\",autoOn:\"Auto Ligar\",autoOnDescription:\"Ligar quando a impressão iniciar\",autoOff:\"Auto Desligar\",autoOffDescription:\"Desligar quando a impressão terminar (única vez)\",autoOffPersistent:\"Manter ativado\",autoOffPersistentDescription:\"Permanecer ativado entre impressões em vez de única vez\",turnOffDelayMode:\"Modo de atraso para desligar\",time:\"Tempo\",temp:\"Temp\",delayMinutes:\"Atraso (minutos)\",tempThreshold:\"Limite de temperatura (°C)\",tempThresholdDescription:\"Desliga quando o bico esfria abaixo desta temperatura\",edit:\"Editar\",deleteConfirm:'Tem certeza que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.',turnOnConfirm:'Tem certeza que deseja ligar \"{{name}}\"?',turnOffConfirm:'Tem certeza que deseja desligar \"{{name}}\"? Isso cortará a energia do dispositivo conectado.',failedToTurn:'Falha ao {{action}} \"{{name}}\"',unknown:\"Desconhecido\",addTitle:\"Adicionar plugue inteligente\",editTitle:\"Editar plugue inteligente\",stopScanning:\"Parar varredura\",discoverTasmota:\"Descobrir dispositivos Tasmota\",foundDevices:\"{{count}} dispositivo(s) encontrado(s) - clique para selecionar:\",noDevicesFound:\"Nenhum dispositivo Tasmota encontrado na sua rede\",haNotConfigured:\"Home Assistant não está configurado. Configure em\",haSettingsPath:\"Configurações → Rede → Home Assistant\",selectEntity:\"Selecionar entidade *\",ipAddress:\"Endereço IP *\",nameLabel:\"Nome *\",username:\"Usuário\",password:\"Senha\",authHint:\"Deixe vazio se seu dispositivo Tasmota não requer autenticação\",linkToPrinter:\"Vincular à impressora\",noPrinter:\"Sem impressora (apenas controle manual)\",linkingDescription:\"A vinculação permite ligar/desligar automaticamente ao iniciar/terminar impressão\",powerAlerts:\"Alertas de potência\",alertAbove:\"Alertar se acima (W)\",alertBelow:\"Alertar se abaixo (W)\",alertDescription:\"Receba notificações quando o consumo de energia ultrapassar estes limites. Deixe vazio para desativar essa direção.\",dailySchedule:\"Programação diária\",turnOnAt:\"Ligar às\",turnOffAt:\"Desligar às\",scheduleDescription:\"Ligar/desligar automaticamente o plugue nestes horários diariamente. Deixe vazio para pular essa ação.\",showOnPrinterCard:\"Mostrar no cartão da impressora\",displayOnPrinterCard:\"Exibir botão no cartão da impressora\",connectedResult:\"Conectado!\",deviceLabel:\"Dispositivo: {{name}} - \",stateLabel:\"Estado: {{state}}\",test:\"Testar\",delete:\"Excluir\",save:\"Salvar\",add:\"Adicionar\",cancel:\"Cancelar\",failedToStartScan:\"Falha ao iniciar varredura\",nameRequired:\"Nome é obrigatório\",entityRequired:\"Entidade é obrigatória para plugues Home Assistant\",mqttTopicRequired:\"Pelo menos um tópico MQTT deve ser configurado para potência, energia ou monitoramento de estado\",loadingEntities:\"Carregando entidades...\",loading:\"Carregando...\",failedToLoadEntities:\"Falha ao carregar entidades: {{error}}\",noEntitiesMatching:'Nenhuma entidade encontrada correspondente a \"{{search}}\"',noEntitiesAvailable:\"Nenhuma entidade disponível\",searchingEntities:\"Buscando todas as entidades ({{count}} encontradas)\",showingEntities:\"Mostrando switch, light, input_boolean ({{count}} disponíveis)\",energyMonitoringOptional:\"Monitoramento de energia (Opcional)\",energyMonitoringHint:\"Pesquise e selecione sensores que fornecem dados de potência/energia.\",powerSensorW:\"Sensor de potência (W)\",energyTodayKwh:\"Energia hoje (kWh)\",totalEnergyKwh:\"Energia total (kWh)\",noMatchingSensors:\"Nenhum sensor correspondente\",none:\"Nenhum\",mqttNotConfigured:\"Broker MQTT não configurado. Defina o endereço do broker em\",mqttSettingsPath:\"Configurações → Rede → Publicação MQTT\",mqttNotConfiguredSuffix:\"(você não precisa ativar a publicação, apenas preencha os detalhes do broker).\",mqttMonitorOnlyDescription:\"Plugues MQTT recebem dados de potência/energia via assinatura MQTT. O controle liga/desliga não está disponível - use seu broker MQTT ou sistema de automação residencial.\",powerMonitoring:\"Monitoramento de potência\",energyMonitoring:\"Monitoramento de energia\",stateMonitoring:\"Monitoramento de estado\",optional:\"opcional\",topic:\"Tópico\",jsonPath:\"Caminho JSON\",multiplier:\"Multiplicador\",onValue:\"Valor ON\",mqttPowerHint:`O caminho JSON extrai o valor do payload JSON (ex: \"power_l1\"). Deixe vazio se o tópico publica valores numéricos brutos.\nUse multiplicador 0.001 para mW→W, 1000 para kW→W.`,mqttEnergyHint:`O caminho JSON extrai o valor do payload JSON. Deixe vazio para valores brutos.\nUse multiplicador 0.001 para Wh→kWh, 1000 para MWh→kWh.`,mqttStateHint:`O caminho JSON extrai o valor do payload JSON. Deixe vazio para valores brutos.\nValor ON: a string exata que significa \"ON\". Deixe vazio para detecção automática (ON, true, 1).`,restControl:\"Control\",restOnUrl:\"Turn ON URL\",restOffUrl:\"Turn OFF URL\",restOnBody:\"ON Request Body\",restOffBody:\"OFF Request Body\",restMethod:\"HTTP Method\",restHeaders:\"Custom Headers (JSON)\",restStatusUrl:\"Status URL\",restStatusPath:\"State JSON Path\",restStatusOnValue:\"ON Value\",restPowerUrl:\"URL de potência\",restPowerPath:\"Power JSON Path\",restPowerMultiplier:\"Multiplicador de potência\",restEnergyUrl:\"URL de energia\",restEnergyPath:\"Energy JSON Path\",restEnergyMultiplier:\"Multiplicador de energia\",restUrlRequired:\"At least one URL (ON or OFF) is required for REST plugs\",restHeadersHint:'e.g. {\"Authorization\": \"Bearer your-token\"}',restBodyHint:'e.g. ON, {\"state\": \"on\"}',restStatusHint:\"URL to poll for current state\",restPathHint:\"e.g. state or data.power.status\",restPowerUrlHint:\"URL separada para dados de potência (usa a URL de status se vazio)\",restEnergyUrlHint:\"URL separada para dados de energia (usa a URL de status se vazio)\",restEnergyHint:\"Cada valor pode usar sua própria URL ou recorrer à URL de status. Use multiplicadores para conversão de unidades (ex: 0.001 para converter Wh em kWh).\",testConnection:\"Test Connection\",connectionSuccess:\"Connection successful\",noSwitchesInSwitchbar:\"Nenhum interruptor na barra\",enableSwitchbarHint:'Ative \"Mostrar na barra de interruptores\" em Configurações > Smart Plugs'},notifications:{providerTypes:{callmebot:\"CallMeBot/WhatsApp\",ntfy:\"ntfy\",pushover:\"Pushover\",telegram:\"Telegram\",email:\"E-mail\",discord:\"Discord\",webhook:\"Webhook\",homeassistant:\"Home Assistant\"},providerDescriptions:{email:\"Notificações por e-mail SMTP\",telegram:\"Notificações via bot do Telegram\",discord:\"Enviar para canal do Discord via webhook\",ntfy:\"Notificações push gratuitas e auto-hospedáveis\",pushover:\"Notificações push simples e confiáveis\",callmebot:\"Notificações gratuitas via WhatsApp pelo CallMeBot\",webhook:\"POST HTTP genérico para qualquer URL\",homeassistant:\"Notificações persistentes no painel do Home Assistant\"},lastSuccess:\"Último: {{date}}\",error:\"Erro\",printer:\"Impressora:\",allPrinters:\"Todas as impressoras\",sendTestNotification:\"Enviar Notificação de Teste\",eventSettings:\"Configurações de Eventos\",enabled:\"Ativado\",sendFromProvider:\"Enviar notificações deste provedor\",printEvents:\"Eventos de Impressão\",printerStatus:\"Status da Impressora\",amsAlarms:\"Alarmes do AMS\",amsHtAlarms:\"Alarmes do AMS-HT\",printQueue:\"Fila de Impressão\",start:\"Início\",plateCheck:\"Verificação da Mesa\",complete:\"Concluído\",failed:\"Falhou\",stopped:\"Parado\",progress:\"Progresso\",offline:\"Offline\",lowFilament:\"Filamento Baixo\",maintenance:\"Manutenção\",amsHumidity:\"Umidade do AMS\",amsTemp:\"Temp. do AMS\",amsHtHumidity:\"Umidade do AMS-HT\",amsHtTemp:\"Temp. do AMS-HT\",bedCooled:\"Mesa Resfriada\",firstLayer:\"Primeira camada\",quiet:\"Silencioso\",digest:\"Resumo {{time}}\",printStarted:\"Impressão Iniciada\",plateNotEmpty:\"Mesa Não Vazia\",plateNotEmptyDescription:\"Objetos detectados antes da impressão\",printCompleted:\"Impressão Concluída\",bedCooledLabel:\"Mesa Resfriada\",bedCooledDescription:\"Mesa resfriou abaixo do limite após a impressão\",firstLayerCompleteLabel:\"Primeira camada concluída\",firstLayerCompleteDescription:\"Notificar com foto quando a primeira camada terminar\",missingSpoolAssignmentLabel:\"Atribuição de bobina ausente\",missingSpoolAssignmentDescription:\"Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída\",printFailed:\"Impressão Falhou\",printStopped:\"Impressão Parada\",progressMilestones:\"Marcos de Progresso\",progressMilestonesDescription:\"Notificar em 25%, 50%, 75%\",printerOffline:\"Impressora Offline\",printerError:\"Erro da Impressora\",lowFilamentLabel:\"Filamento Baixo\",maintenanceDue:\"Manutenção Necessária\",maintenanceDueDescription:\"Notificar quando manutenção for necessária\",amsHumidityHigh:\"Umidade Alta do AMS\",amsHumidityHighDescription:\"Umidade do AMS regular excede o limite\",amsTemperatureHigh:\"Temperatura Alta do AMS\",amsTemperatureHighDescription:\"Temperatura do AMS regular excede o limite\",amsHtHumidityHigh:\"Umidade Alta do AMS-HT\",amsHtHumidityHighDescription:\"Umidade do AMS-HT excede o limite\",amsHtTemperatureHigh:\"Temperatura Alta do AMS-HT\",amsHtTemperatureHighDescription:\"Temperatura do AMS-HT excede o limite\",jobAdded:\"Trabalho Adicionado\",jobAddedDescription:\"Trabalho adicionado à fila\",jobAssigned:\"Trabalho Atribuído\",jobAssignedDescription:\"Trabalho baseado em modelo atribuído à impressora\",jobStarted:\"Trabalho Iniciado\",jobStartedDescription:\"Trabalho da fila começou a imprimir\",jobWaiting:\"Trabalho Aguardando\",jobWaitingDescription:\"Trabalho aguardando filamento ou impressora\",jobSkipped:\"Trabalho Pulado\",jobSkippedDescription:\"Trabalho pulado (anterior falhou)\",jobFailed:\"Trabalho Falhou\",jobFailedDescription:\"Trabalho falhou ao iniciar\",queueComplete:\"Fila Concluída\",queueCompleteDescription:\"Todos os trabalhos da fila finalizados\",quietHours:\"Horário de Silêncio\",noNotificationsDuring:\"Sem notificações durante essas horas\",editProviderToChangeQuietHours:\"Edite o provedor para alterar o horário de silêncio\",dailyDigest:\"Resumo Diário\",batchNotifications:\"Agrupar notificações em um único resumo diário\",sendAt:\"Enviar às {{time}}\",editProviderToChangeDigestTime:\"Edite o provedor para alterar o horário do resumo\",edit:\"Editar\",deleteProvider:\"Excluir Provedor de Notificação\",deleteConfirm:'Tem certeza que deseja excluir \"{{name}}\"? Isso não pode ser desfeito.',delete:\"Excluir\",addTitle:\"Adicionar Provedor de Notificação\",editTitle:\"Editar Provedor de Notificação\",nameLabel:\"Nome *\",namePlaceholder:\"Minhas Notificações\",providerTypeLabel:\"Tipo de Provedor *\",configuration:\"Configuração\",testConfiguration:\"Testar Configuração\",printerFilter:\"Filtro de Impressora\",onlyFromPrinter:\"Enviar notificações apenas para eventos desta impressora\",quietHoursDnd:\"Horário de Silêncio (Não Perturbe)\",quietStart:\"Início\",quietEnd:\"Fim\",dailyDigestLabel:\"Resumo Diário\",sendDigestAt:\"Enviar resumo às\",digestCollected:\"Os eventos serão coletados e enviados como um único resumo neste horário\",notificationEvents:\"Eventos de Notificação\",progressPercent:\"(25%, 50%, 75%)\",bedCooledAfterPrint:\"(após conclusão da impressão)\",cancel:\"Cancelar\",save:\"Salvar\",add:\"Adicionar\",nameRequired:\"Nome é obrigatório\",fieldRequired:\"{{field}} é obrigatório\",phoneNumber:\"Número de Telefone\",apiKey:\"Chave da API\",serverUrl:\"URL do Servidor\",topic:\"Tópico\",authToken:\"Token de Autenticação\",userKey:\"Chave do Usuário\",appToken:\"Token do Aplicativo\",priority:\"Prioridade\",botToken:\"Token do Bot\",chatId:\"ID do Chat\",smtpServer:\"Servidor SMTP\",smtpPort:\"Porta SMTP\",security:\"Segurança\",authentication:\"Autenticação\",username:\"Usuário\",password:\"Senha\",fromEmail:\"E-mail de Origem\",toEmail:\"E-mail de Destino\",webhookUrl:\"URL do Webhook\",payloadFormat:\"Formato do Payload\",authorization:\"Autorização\",titleFieldName:\"Nome do Campo de Título\",messageFieldName:\"Nome do Campo de Mensagem\",editTemplate:\"Editar Modelo: {{name}}\",titleLabel:\"Título\",bodyLabel:\"Corpo\",titlePlaceholder:\"Título da notificação...\",bodyPlaceholder:\"Corpo da notificação...\",availableVariables:\"Variáveis Disponíveis\",clickToInsert:\"Clique para inserir na posição do cursor no corpo\",livePreview:\"Pré-visualização ao Vivo\",hide:\"Ocultar\",show:\"Mostrar\",loadingPreview:\"Carregando pré-visualização...\",enterTemplateContent:\"Insira o conteúdo do modelo para ver a pré-visualização\",titlePreview:\"Título:\",bodyPreview:\"Corpo:\",resetToDefault:\"Restaurar Padrão\",titleRequired:\"Título é obrigatório\",bodyRequired:\"Corpo é obrigatório\",notificationLog:\"Registro de Notificações\",showFailedOnly:\"Apenas falhas\",last24Hours:\"Últimas 24 horas\",last7Days:\"Últimos 7 dias\",last30Days:\"Últimos 30 dias\",last90Days:\"Últimos 90 dias\",justNow:\"Agora mesmo\",noFailedNotifications:\"Nenhuma notificação com falha\",noNotificationsLogged:\"Nenhuma notificação registrada\",unknownProvider:\"Provedor Desconhecido\",logTitle:\"Título\",logMessage:\"Mensagem\",logError:\"Erro\",logProvider:\"Provedor: {{type}}\",logTime:\"Hora: {{time}}\",refresh:\"Atualizar\",clearOld:\"Limpar Antigos\",statsSummary:\"Últimos {{days}} dias:\",statsNotifications:\"notificações\",statsSent:\"{{count}} enviadas\",statsFailed:\"{{count}} com falha\",eventTypes:{print_start:\"Impressão Iniciada\",print_complete:\"Impressão Concluída\",print_failed:\"Impressão Falhou\",print_stopped:\"Impressão Parada\",print_progress:\"Progresso\",printer_offline:\"Impressora Offline\",printer_error:\"Erro da Impressora\",filament_low:\"Filamento Baixo\",maintenance_due:\"Manutenção Necessária\",test:\"Teste\"},userEmail:{title:\"Notificações\",emailNotifications:\"Notificações por E-mail\",emailNotificationsDesc:\"Receba notificações por e-mail para seus próprios trabalhos de impressão. Os e-mails são enviados usando as configurações SMTP definidas na Autenticação Avançada.\",sendingTo:\"As notificações serão enviadas para\",noEmailWarning:\"Sua conta não tem um endereço de e-mail. Entre em contato com um administrador para adicionar um.\",printJobNotifications:\"Notificações de Trabalhos de Impressão\",printJobNotificationsDesc:\"Escolha quais eventos acionam notificações por e-mail para os trabalhos de impressão que você envia.\",printJobStarts:\"Início do Trabalho de Impressão\",printJobStartsDesc:\"Ser notificado quando seu trabalho de impressão começar.\",printJobFinishes:\"Conclusão do Trabalho de Impressão\",printJobFinishesDesc:\"Ser notificado quando seu trabalho de impressão concluir com sucesso.\",printErrors:\"Erros de Impressão\",printErrorsDesc:\"Ser notificado quando seu trabalho de impressão falhar ou encontrar um erro.\",printJobStops:\"Trabalho de Impressão Parado\",printJobStopsDesc:\"Ser notificado quando seu trabalho de impressão for cancelado ou parado.\",saveSuccess:\"Preferências de notificação salvas.\",saveError:\"Falha ao salvar preferências de notificação.\"}},richTextEditor:{bold:\"Negrito\",italic:\"Itálico\",underline:\"Sublinhado\",bulletList:\"Lista com marcadores\",numberedList:\"Lista numerada\",alignLeft:\"Alinhar à esquerda\",alignCenter:\"Centralizar\",alignRight:\"Alinhar à direita\",addLink:\"Adicionar link\",removeLink:\"Remover link\"},externalLinks:{noLinksConfigured:\"Nenhum link externo configurado\",deleteLink:\"Excluir link\",removeCustomIcon:\"Remover ícone personalizado\",openInNewTab:\"Abrir em nova aba\",placeholders:{linkName:\"Meu Link\"}},keyboardShortcuts:{title:\"Atalhos de Teclado\",navigation:\"Navegação\",archivesSection:\"Arquivos\",kProfilesSection:\"K-Profiles\",generalSection:\"Geral\",shortcuts:{goToPrinters:\"Ir para Impressoras\",goToArchives:\"Ir para Arquivos\",goToQueue:\"Ir para Fila\",goToStats:\"Ir para Estatísticas\",goToProfiles:\"Ir para Perfis na Nuvem\",goToSettings:\"Ir para Configurações\",focusSearch:\"Focar na pesquisa\",openUploadModal:\"Abrir modal de upload\",clearSelection:\"Limpar seleção / desfocar input\",contextMenu:\"Menu de contexto nos cartões\",refreshProfiles:\"Atualizar perfis\",newProfile:\"Novo perfil\",exitSelectionMode:\"Sair do modo de seleção\",showHelp:\"Mostrar esta ajuda\"},footer:\"Pressione Esc ou clique fora para fechar\"},notificationLog:{title:\"Registro de Notificações\",events:{printStarted:\"Impressão Iniciada\",printComplete:\"Impressão Concluída\",printFailed:\"Impressão Falhou\",printStopped:\"Impressão Interrompida\",progress:\"Progresso\",printerOffline:\"Impressora Offline\",printerError:\"Erro na Impressora\",lowFilament:\"Filamento Baixo\",maintenanceDue:\"Manutenção Pendente\",test:\"Teste\"},timeAgo:{justNow:\"Agora mesmo\",minutesAgo:\"há {{minutes}} minutos\",hoursAgo:\"há {{hours}} horas\"}},restoreBackup:{title:\"Restaurar Backup\",restoring:\"Restaurando...\",restoreComplete:\"Restauração Concluída\",restoreFailed:\"Falha na Restauração\",importSettings:\"Importar configurações de um arquivo de backup\",pleaseWait:\"Aguarde enquanto seus dados estão sendo restaurados\",clickToSelect:\"Clique para selecionar o arquivo de backup (.json ou .zip)\",howDuplicateHandling:\"Como funciona o tratamento de duplicatas:\",categories:{printers:\"Impressoras\",smartPlugs:\"Tomadas Inteligentes\",notificationProviders:\"Provedores de Notificação\",filaments:\"Filamentos\",archives:\"Arquivos\",pendingUploads:\"Uploads Pendentes\",settingsTemplates:\"Configurações e Modelos\"},matchingInfo:{printers:\"correspondem pelo número de série\",smartPlugs:\"correspondem pelo endereço IP\",notificationProviders:\"correspondem pelo nome\",filaments:\"correspondem pelo nome + tipo + marca\",archives:\"correspondem pelo hash do conteúdo\",pendingUploads:\"correspondem pelo nome do arquivo\",settingsTemplates:\"sempre sobrescrito\"},replaceExisting:\"Substituir dados existentes\",keepExisting:\"Manter dados existentes\",replaceDescription:\"Substituir itens que já existem com os dados do backup\",keepDescription:\"Restaurar apenas itens que não existem\",caution:\"Atenção:\",cautionText:\"Sobrescrever substituirá suas configurações atuais pelos dados do backup. Os códigos de acesso da impressora nunca são sobrescritos por segurança.\",itemsRestored:\"Itens Restaurados\",itemsSkipped:\"Itens Ignorados\",restored:\"Restaurado\",skipped:\"Ignorado (já existe)\",filesLabel:\"Arquivos (3MF, miniaturas, etc.)\",newApiKeysGenerated:\"Novas Chaves API Geradas\",newApiKeysWarning:\"Essas chaves são exibidas apenas uma vez. Copie-as agora!\",processingBackup:\"Processando arquivo de backup...\",noDataFound:\"Nenhum dado foi encontrado para restaurar no arquivo de backup.\",failedToRestore:\"Falha ao restaurar o backup. Verifique o formato do arquivo.\"},backupExport:{title:\"Exportar Backup\",selectData:\"Selecione os dados a incluir\",selectAll:\"Selecionar Todos\",selectNone:\"Selecionar Nenhum\",categoryDescriptions:{settings:\"Idioma, tema, preferências de atualização\",notifications:\"ntfy, Pushover, Discord, etc.\",templates:\"Modelos de mensagens personalizadas\",smartPlugs:\"Configurações de tomadas Tasmota\",externalLinks:\"Links da barra lateral para serviços externos\",printers:\"Informações da impressora (códigos de acesso excluídos)\",plateDetection:\"Imagens de referência de placa vazia\",filaments:\"Tipos e custos de filamento\",maintenance:\"Cronogramas de manutenção personalizados\",archives:\"Todos os dados de impressão + arquivos (3MF, miniaturas, fotos)\",projects:\"Projetos, itens de BOM e anexos\",pendingUploads:\"Uploads de impressora virtual aguardando revisão\",apiKeys:\"Chaves API de webhook (novas chaves geradas na importação)\"},requiresPrinters:\"Requer que as impressoras sejam selecionadas\",zipFileWarning:\"Um arquivo ZIP será criado.\",zipFileDescription:\"Inclui todos os arquivos 3MF, miniaturas, timelapses e fotos. Isso pode levar algum tempo e resultar em um arquivo grande.\",includeAccessCodes:\"Incluir Códigos de Acesso\",includeAccessCodesDescription:\"Para transferir para outra máquina\",includeAccessCodesWarning:\"Os códigos de acesso serão incluídos em texto simples. Mantenha este arquivo de backup seguro!\",categoriesSelected:\"{{selectedCount}} categorias selecionadas\"},pendingUploads:{placeholders:{notes:\"Adicione notas sobre esta impressão...\"},discardUpload:\"Descartar Upload\",archiveAllUploads:\"Arquivar Todos os Uploads\",discardAllUploads:\"Descartar Todos os Uploads\",archive:\"Arquivar\",timeAgo:{justNow:\"Agora mesmo\",minutesAgo:\"há {{minutes}} minutos\",hoursAgo:\"há {{hours}} horas\",daysAgo:\"há {{days}} dias\"}},apiBrowser:{placeholders:{requestBody:\"Corpo da requisição JSON...\",searchEndpoints:\"Pesquisar endpoints...\"}},configureAmsSlot:{title:\"Configurar Slot AMS\",slotConfigured:\"Slot Configurado!\",configuringSlot:\"Configurando slot:\",slotLabel:\"{{ams}} Slot {{slot}}\",searchPresets:\"Pesquisar predefinições...\",colorPlaceholder:\"Nome da cor ou hex (ex.: marrom, FF8800)\",clearCustomColor:\"Limpar cor personalizada\",noCloudPresets:\"Nenhuma predefinição na nuvem. Faça login no Bambu Cloud para sincronizar.\",noPresetsAvailable:\"Nenhuma predefinição disponível. Faça login no Bambu Cloud ou importe perfis locais.\",noMatchingPresets:\"Nenhuma predefinição correspondente encontrada.\",custom:\"Personalizado\",builtin:\"Integrado\",settingsSentToPrinter:\"Configurações enviadas para a impressora\",filamentProfile:\"Perfil de Filamento\",kProfileLabel:\"Perfil K (Avanço de Pressão)\",filteringFor:\"Filtrando por: {{material}}\",noKProfile:\"Nenhum perfil K (usar padrão 0.020)\",noMatchingKProfiles:\"Nenhum perfil K correspondente encontrado. O K padrão=0.020 será usado.\",selectFilamentFirst:\"Selecione um perfil de filamento primeiro\",kFromCalibration:\"K={{value}} da calibração da impressora\",customColorLabel:\"Cor Personalizada (opcional)\",presetColors:\"Cores de {{name}}:\",showLessColors:\"Mostrar menos cores\",showMoreColors:\"Mostrar mais cores\",clear:\"Limpar\",hexLabel:\"Hex: #{{hex}}\",resetting:\"Redefinindo...\",resetSlot:\"Redefinir Slot\",cancel:\"Cancelar\",configuring:\"Configurando...\",configureSlot:\"Configurar Slot\"},githubBackup:{title:\"Backup do GitHub\",history:\"Histórico\",downloadBackup:\"Baixar Backup\",restoreBackup:\"Restaurar Backup\",noBackupsYet:\"Nenhum backup ainda\"},emailSettings:{placeholders:{fromName:\"BamBuddy\"}},tagManagement:{searchTags:\"Pesquisar tags...\",renameTag:\"Renomear tag\",deleteTag:\"Excluir tag\"},notificationTemplates:{placeholders:{title:\"Título da notificação...\",body:\"Corpo da notificação...\"}},batchTag:{placeholders:{newTag:\"Digite uma nova tag...\"}},photoGallery:{deletePhoto:\"Excluir foto\"},filamentHoverCard:{copySpoolUuid:\"Copiar UUID do carretel\"},kProfilesView:{hasNote:\"Possui nota\",copyProfile:\"Copiar perfil\"},layout:{openMenu:\"Abrir menu\",noPermissionSystemInfo:\"Você não tem permissão para visualizar informações do sistema\"},dashboard:{dragToReorder:\"Arrastar para reordenar\",hideWidget:\"Ocultar widget\"},notificationProviderCard:{deleteNotificationProvider:\"Excluir provedor de notificação\"},fileManagerModal:{closeFileManager:\"Fechar gerenciador de arquivos\",sortFiles:\"Ordenar arquivos\",goToParentFolder:\"Ir para a pasta pai\",threeView:\"Visualização 3D\"},embeddedCameraViewer:{refreshStream:\"Atualizar stream\",close:\"Fechar\",zoomOut:\"Reduzir zoom\",resetZoom:\"Redefinir zoom\",zoomIn:\"Aumentar zoom\",dragToResize:\"Arrastar para redimensionar\"},timelapseViewer:{skipBack5s:\"Voltar 5s\",skipForward5s:\"Avançar 5s\"},notificationProviders:{descriptions:{email:\"Notificações por email SMTP\",telegram:\"Notificações via bot do Telegram\",discord:\"Enviar para canal do Discord via webhook\",ntfy:\"Notificações push gratuitas e auto-hospedáveis\",pushover:\"Notificações push simples e confiáveis\",callmebot:\"Notificações gratuitas via WhatsApp pelo CallMeBot\",webhook:\"POST HTTP genérico para qualquer URL\"}},logViewer:{searchPlaceholder:\"Pesquisar mensagem ou nome do logger...\",noLogEntries:\"Nenhuma entrada de log encontrada\"},switchbarPopover:{noSwitchesInSwitchbar:\"Nenhum switch na barra de switches\"},projectPageModal:{placeholders:{title:\"Título\",designer:\"Designer\",license:\"Licença\",description:\"Digite a descrição...\",profileTitle:\"Título do perfil\",profileDescription:\"Descrição do perfil...\"}},spoolmanSettings:{},time:{unknown:\"-\",waiting:\"Aguardando\",justNow:\"Agora mesmo\",now:\"Agora\",minsAgo:\"{{count}}min atrás\",inMins:\"em {{count}}min\",hoursAgo:\"{{count}}h atrás\",inHours:\"em {{count}}h\",daysAgo:\"{{count}}d atrás\",inDays:\"em {{count}}d\"},spoolbuddy:{nav:{dashboard:\"Painel\",ams:\"AMS\",inventory:\"Inventário\",writeTag:\"Escrever\",settings:\"Configurações\"},status:{nfcReady:\"NFC pronto\",nfcOff:\"NFC desligado\",offline:\"Offline\",online:\"Online\",noPrinters:\"Sem impressoras\",deviceOffline:\"Dispositivo offline\",waitingConnection:\"Aguardando conexão do dispositivo...\",systemReady:\"Sistema pronto\",status:\"Status\"},dashboard:{readyToScan:\"Pronto para escanear\",idleMessage:\"Coloque um carretel na balança para identificá-lo\",nfcHint:\"A tag NFC será lida automaticamente\",device:\"Dispositivo\",syncWeight:\"Sincronizar peso\",weightSynced:\"Sincronizado!\",unknownTag:\"Tag desconhecida\",newTag:\"Nova tag detectada\",onScale:\"na balança\",linkSpool:\"Vincular ao carretel\",linkTagTitle:\"Vincular tag ao carretel\",linkTag:\"Vincular tag\",selectSpool:\"Selecione um carretel para vincular a esta tag:\",noUntagged:\"Nenhum carretel sem tag encontrado\",tagDetected:\"Tag detectada\",noTag:\"Sem tag\",tagId:\"Tag\",grossWeight:\"Peso bruto\",spoolSize:\"Tamanho do carretel\",close:\"Fechar\",currentSpool:\"Carretel Atual\"},modal:{spoolDetected:\"Carretel Detectado\",assignToAms:\"Atribuir ao AMS\",syncWeight:\"Sincronizar Peso\",weightSynced:\"Sincronizado!\",syncing:\"Sincronizando...\",newTagDetected:\"Nova Tag Detectada\",addToInventory:\"Adicionar ao Inventário\",assignToAmsTitle:\"Atribuir ao AMS\",selectSlot:\"Selecionar um slot\",assign:\"Atribuir\",assigning:\"Atribuindo...\",assignSuccess:\"Atribuído!\",assignError:\"Falha ao atribuir carretel. Tente novamente.\",noPrinterSelected:\"Selecionar uma impressora...\",noAmsDetected:\"Nenhum AMS detectado nesta impressora\",slot:\"Slot\"},weight:{noReading:\"Sem leitura\",stable:\"Estável\",measuring:\"Medindo...\",tare:\"Tarar\",calibrate:\"Calibrar\"},spool:{remaining:\"Restante\",material:\"Material\",brand:\"Marca\",color:\"Cor\",coreWeight:\"Núcleo\",labelWeight:\"Rótulo\",scaleWeight:\"Balança\",netWeight:\"Líquido\",lastUsed:\"Último uso\"},ams:{noData:\"Nenhum AMS detectado\",connectAms:\"Conecte um AMS para ver os slots\",noPrinter:\"Nenhuma impressora selecionada\",selectPrinter:\"Selecione uma impressora na barra superior\",printerDisconnected:\"Impressora desconectada\",humidity:\"Umidade\",level:\"Nível\",active:\"Ativo\",slot:\"Slot\",empty:\"Vazio\"},inventory:{search:\"Buscar carretéis...\",empty:\"Nenhum carretel no inventário\",noResults:\"Nenhum carretel correspondente\",spools:\"carretéis\",addSpool:\"Adicionar carretel\"},settings:{tabDevice:\"Dispositivo\",tabDisplay:\"Tela\",tabScale:\"Balança\",tabUpdates:\"Atualizações\",nfcReader:\"Leitor NFC\",type:\"Tipo\",connection:\"Conexão\",notConnected:\"N/A\",deviceInfo:\"Info do dispositivo\",hostname:\"Host\",uptime:\"Tempo de atividade\",brightness:\"Brilho\",saved:\"Salvo\",noBacklight:\"Nenhuma retroiluminação DSI detectada. O controle de brilho requer uma tela DSI.\",screenBlank:\"Tempo para desligar tela\",screenBlankDesc:\"A tela desliga após inatividade. Toque para despertar.\",displayNote:\"O brilho é aplicado como filtro de software.\",scaleCalibration:\"Calibração da balança\",currentWeight:\"Peso atual\",tareOffset:\"Tara\",calFactor:\"Fator\",knownWeight:\"Peso conhecido\",calStep1:\"Remova todos os itens da balança e pressione Definir zero.\",calStep2:\"Coloque o peso conhecido na balança.\",setZero:\"Definir zero\",calibrateNow:\"Calibrar\",calibrated:\"Calibrado\",tareSet:\"Comando de tara enviado. Aguardando dispositivo...\",tareFailed:\"Falha ao enviar comando de tara\",zeroSet:\"Ponto zero definido. Coloque o peso conhecido na balança.\",calibrationDone:\"Calibração concluída!\",calibrationFailed:\"Falha na calibração\",lastCalibrated:\"Última calibração\",stable:\"Estável\",settling:\"Estabilizando...\",firmware:\"Firmware\",scale:\"Balança\",noDevice:\"Nenhum dispositivo SpoolBuddy encontrado\",daemonVersion:\"Versão do daemon\",currentVersion:\"Atual\",versionPending:\"Aguardando daemon...\",checking:\"Verificando...\",checkUpdates:\"Verificar atualizações\",updateAvailable:\"Atualização disponível\",updateInstructions:\"Atualize via SSH: execute o script de instalação do SpoolBuddy.\",upToDate:\"Atualizado\",includeBeta:\"Incluir versões beta\"},writeTag:{tabExisting:\"Bobina existente\",tabNew:\"Nova bobina\",tabReplace:\"Substituir tag\",searchPlaceholder:\"Buscar por material, cor, marca...\",noUntaggedSpools:\"Nenhuma bobina sem tag\",noTaggedSpools:\"Nenhuma bobina com tag\",selectSpool:\"Selecione uma bobina e coloque um NTAG no leitor\",placeTag:\"Coloque um NTAG no leitor\",tagReady:\"Tag detectado — pronto para gravar\",writeTag:\"Gravar Tag\",replaceTag:\"Substituir Tag\",writing:\"Gravando tag...\",waiting:\"Aguardando SpoolBuddy...\",writeSuccess:\"Tag gravado com sucesso!\",writeFailed:\"Falha na gravação\",queueFailed:\"Falha ao enfileirar comando de gravação\",tryAgain:\"Tentar novamente\",cancel:\"Cancelar\",replaceWarning:\"O tag antigo será desvinculado. O novo tag o substituirá.\",deviceOffline:\"SpoolBuddy está offline\",material:\"Material\",colorName:\"Nome da cor\",color:\"Cor\",brand:\"Marca\",weight:\"Peso (g)\",createSpool:\"Criar bobina\",creating:\"Criando...\",spoolCreated:\"Bobina criada! Pronto para gravar.\",createFailed:\"Falha ao criar bobina\"},quickMenu:{printerPower:\"Energia da impressora\",systemControls:\"Sistema\",restartDaemon:\"Reiniciar daemon\",restartBrowser:\"Reiniciar navegador\",reboot:\"Reiniciar\",shutdown:\"Desligar\",swipeToClose:\"Deslize para baixo para fechar\",confirmTitle:\"Confirmar\",confirmShutdown:\"Tem certeza de que deseja desligar o SpoolBuddy? Você precisará de acesso físico para ligá-lo novamente.\",confirmReboot:\"Tem certeza de que deseja reiniciar o SpoolBuddy?\",confirmRestartDaemon:\"Reiniciar o daemon do SpoolBuddy? NFC e balança ficarão temporariamente indisponíveis.\",confirmRestartBrowser:\"Reiniciar o navegador kiosk? A tela ficará brevemente preta.\",confirm:\"Confirmar\",confirmPlugOn:\"Ligar {{name}}?\",confirmPlugOff:\"Desligar {{name}}?\",turnOn:\"Ligar\",turnOff:\"Desligar\"}},bugReport:{title:\"Reportar um bug\",description:\"Descrição\",descriptionPlaceholder:\"O que deu errado? Por favor, descreva o problema...\",email:\"Email (opcional)\",emailPlaceholder:\"seu@email.com.br\",emailPrivacy:\"Se fornecido, seu email será incluído em uma seção recolhida da issue no GitHub para que o mantenedor possa entrar em contato.\",screenshot:\"Captura de tela\",uploadOrPaste:\"Enviar, colar ou arrastar uma imagem\",dataCollectedSummary:\"Quais dados são incluídos no relatório?\",dataIncluded:\"Incluídos:\",dataIncludedList:\"Versão do app, SO, arquitetura, versão Python, estatísticas do banco de dados (apenas contagens), modelos de impressora, quantidade de bicos, versões de firmware, status de conexão, status de integrações (Spoolman, MQTT, HA), configurações não sensíveis, contagem de interfaces de rede, detalhes Docker, versões de dependências.\",dataNeverIncluded:\"Nunca incluídos:\",dataNeverIncludedList:\"Nomes de impressoras, números de série, códigos de acesso, senhas, endereços IP, endereços de email, chaves de API, tokens, URLs de webhook, nomes de host ou nomes de usuário.\",submit:\"Enviar\",startLogging:\"Iniciar log de depuração\",stepEnableLogging:\"Log de depuração ativado\",stepReproduce:\"Reproduza o problema agora\",stepStopLogging:\"Parar & enviar relatório\",stopAndSubmit:\"Parar & Enviar\",maxDuration:\"Para automaticamente após {{minutes}} min\",stoppingLogs:\"Coletando logs & enviando...\",submitting:\"Enviando relatório de bug...\",submitSuccess:\"Relatório de bug enviado com sucesso!\",submitFailed:\"Falha ao enviar relatório de bug\",thankYou:\"Obrigado!\",submitted:\"Seu relatório de bug foi enviado.\",viewIssue:\"Ver issue\",unexpectedError:\"Ocorreu um erro inesperado\"},failureDetection:{title:\"Detecção de Falhas por IA\",description:\"Monitora impressões via API ML do Obico auto-hospedada e age automaticamente em falhas detectadas.\",mlUrl:\"URL da API ML do Obico\",mlUrlHint:\"URL base do seu contêiner Obico ml_api auto-hospedado (ex.: http://192.168.1.10:3333).\",test:\"Testar\",testSuccess:\"API ML acessível e operacional.\",testFailed:\"Não foi possível acessar a API ML.\",sensitivity:\"Sensibilidade\",sensitivityLow:\"Baixa (menos falsos positivos)\",sensitivityMedium:\"Média (equilibrada)\",sensitivityHigh:\"Alta (detecção precoce, mais falsos positivos)\",sensitivityHint:\"Ajusta os limiares de confiança que disparam avisos e falhas.\",action:\"Ação em falha detectada\",actionNotify:\"Apenas notificar\",actionPause:\"Pausar impressão\",actionPauseOff:\"Pausar e cortar energia\",pollInterval:\"Intervalo de verificação (segundos)\",pollIntervalHint:\"Frequência de verificação de cada impressora durante a impressão. Mínimo 5s, máximo 120s.\",externalUrlMissing:\"External URL is not set.\",externalUrlHint:\"The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.\",perPrinterTitle:\"Impressoras monitoradas\",perPrinterHint:\"Escolha quais impressoras o serviço de detecção monitora.\",monitorAll:\"Monitorar todas as impressoras conectadas\",statusTitle:\"Status\",serviceRunning:\"Serviço em execução\",thresholds:\"Limiares baixo / alto\",activePrinters:\"Impressões ativas\",noActivePrints:\"Nenhuma impressão em andamento.\",historyTitle:\"Detecções recentes\",noHistory:\"Nenhuma detecção ainda.\"}},Yue={nav:{printers:\"打印机\",archives:\"归档\",queue:\"队列\",stats:\"统计\",profiles:\"配置文件\",maintenance:\"维护\",projects:\"项目\",inventory:\"耗材\",files:\"文件管理器\",notifications:\"通知\",settings:\"设置\",system:\"系统\",collapseSidebar:\"收起侧边栏\",expandSidebar:\"展开侧边栏\",update:\"更新\",updateAvailable:\"有可用更新：v{{version}}\",updateAvailableBanner:\"版本 {{version}} 已发布！\",viewUpdate:\"查看更新\",viewOnGithub:\"在 GitHub 上查看\",keyboardShortcuts:\"键盘快捷键 (?)\",switchToLight:\"切换到浅色模式\",switchToDark:\"切换到深色模式\",smartSwitches:\"智能开关\",logout:\"退出登录\"},common:{save:\"保存\",saving:\"保存中...\",cancel:\"取消\",delete:\"删除\",edit:\"编辑\",add:\"添加\",close:\"关闭\",confirm:\"确认\",loading:\"加载中...\",error:\"错误\",success:\"成功\",warning:\"警告\",enabled:\"已启用\",disabled:\"已禁用\",yes:\"是\",no:\"否\",on:\"开\",off:\"关\",all:\"全部\",none:\"无\",search:\"搜索\",filter:\"筛选\",sort:\"排序\",refresh:\"刷新\",download:\"下载\",upload:\"上传\",uploading:\"上传中...\",uploadFailed:\"上传失败\",actions:\"操作\",status:\"状态\",name:\"名称\",description:\"描述\",date:\"日期\",time:\"时间\",hours:\"小时\",minutes:\"分钟\",seconds:\"秒\",days:\"天\",enable:\"启用\",disable:\"禁用\",permissions:\"权限\",noPrinters:\"未配置打印机\",noData:\"暂无数据\",linkNotFound:\"未找到链接\",required:\"必填\",optional:\"可选\",dismiss:\"关闭\",apply:\"应用\",reset:\"重置\",export:\"导出\",import:\"导入\",clear:\"清除\",selectAll:\"全选\",deselectAll:\"取消全选\",noChange:\"— 不更改 —\",unchanged:\"未更改\",unassigned:\"未分配\",unknown:\"未知\",unknownError:\"未知错误\",today:\"今天\",tomorrow:\"明天\",asap:\"尽快\",overdue:\"已逾期\",now:\"现在\",collapse:\"收起\",expand:\"展开\",viewArchive:\"查看归档\",viewInFileManager:\"在文件管理器中查看\",addedBy:\"由 {{username}} 添加\",prints:\"次打印\",more:\"还有 {{count}} 个\",ascending:\"升序\",descending:\"降序\",back:\"返回\",copy:\"复制\",copied:\"已复制!\",printer:\"打印机\",remove:\"移除\",type:\"类型\",print:\"打印\",rename:\"重命名\",move:\"移动\",create:\"创建\",duplicate:\"复制\",left:\"左\",right:\"右\"},printers:{title:\"打印机\",addPrinter:\"添加打印机\",editPrinter:\"编辑打印机\",deletePrinter:\"删除打印机\",printerName:\"打印机名称\",serialNumber:\"序列号\",ipAddress:\"IP 地址 / 主机名\",accessCode:\"访问码\",model:\"型号\",nozzleCount:\"喷嘴数量\",autoArchive:\"自动归档\",status:{available:\"可用\",idle:\"空闲\",printing:\"打印中\",paused:\"已暂停\",offline:\"离线\",problem:\"故障\",error:\"错误\",finished:\"已完成\",unknown:\"未知\"},temperatures:{nozzle:\"喷嘴\",bed:\"热床\",chamber:\"腔室\"},progress:\"{{percent}}% 完成\",timeRemaining:\"剩余 {{time}}\",deleteConfirm:'确定要删除\"{{name}}\"吗？',maintenanceOk:\"维护正常\",maintenanceWarning:\"{{count}} 个警告\",maintenanceWarning_plural:\"{{count}} 个警告\",maintenanceDue:\"{{count}} 个到期\",maintenanceDue_plural:\"{{count}} 个到期\",sort:{name:\"名称\",status:\"状态\",model:\"型号\",location:\"位置\",ascending:\"升序排列\",descending:\"降序排列\"},cardSize:{small:\"小卡片\",medium:\"中卡片\",large:\"大卡片\",extraLarge:\"超大卡片\"},hideOffline:\"隐藏离线\",nextAvailable:\"下一个可用\",powerOn:\"开机\",offlinePrintersWithPlugs:\"带智能插座的离线打印机\",noPrintersConfigured:\"尚未配置打印机\",search:\"搜索打印机...\",noSearchResults:\"没有打印机符合您的搜索或筛选条件\",filter:{allStatuses:\"所有状态\",allLocations:\"所有位置\"},readyToPrint:\"准备打印\",external:\"外部\",extL:\"外置左\",extR:\"外置右\",deleteArchives:\"删除打印归档\",noLabel:\"无标签\",printPreview:\"打印预览\",width:\"宽度\",height:\"高度\",noObjectsFound:\"未找到对象\",objectsLoadedOnPrintStart:\"对象在打印开始时加载\",willBeSkipped:\"将被跳过\",name:\"名称\",serialCannotBeChanged:\"序列号无法更改\",locationHelp:\"用于分组打印机和筛选队列任务\",wifiSignal:{veryWeak:\"非常弱\",weak:\"弱\",fair:\"一般\",good:\"良好\",excellent:\"优秀\"},maintenanceUpToDate:\"所有维护均已完成 - 点击查看\",chamberLightOn:\"打开腔室灯\",chamberLightOff:\"关闭腔室灯\",files:\"文件\",browseFiles:\"浏览打印机文件\",autoOffAfterPrint:\"打印后自动关机\",autoOffExecuted:\"已执行自动关机 - 开启打印机以重置\",hmsErrors:\"HMS 错误\",viewHmsErrors:\"查看 {{count}} 个 HMS 错误\",resume:\"继续\",pause:\"暂停\",stop:\"停止\",camera:\"摄像头\",skipObject:\"跳过对象\",reconnect:\"重新连接\",forceRefresh:\"强制刷新\",forceRefreshSuccess:\"已请求刷新\",mqttDebug:\"MQTT 调试\",printerInformation:\"打印机信息\",copyToClipboard:\"复制\",copied:\"已复制！\",state:\"状态\",wifiSignalLabel:\"WiFi 信号\",developerMode:\"开发者模式\",enabled:\"已启用\",disabled:\"已禁用\",addedOn:\"添加日期\",sdCard:\"SD 卡\",inserted:\"已插入\",notInserted:\"未插入\",totalPrintHours:\"打印时长\",activeNozzle:\"当前：{{nozzle}} 喷嘴\",nozzleRack:\"喷嘴架\",nozzleDocked:\"已停靠\",nozzleMounted:\"已安装\",nozzleActive:\"使用中\",nozzleIdle:\"空闲\",nozzleDiameter:\"直径\",nozzleType:\"类型\",nozzleStatus:\"状态\",nozzleFilament:\"耗材\",nozzleWear:\"磨损\",nozzleMaxTemp:\"最高温度\",nozzleSerial:\"序列号\",nozzleHardenedSteel:\"硬化钢\",nozzleStainlessSteel:\"不锈钢\",nozzleTungstenCarbide:\"碳化钨\",nozzleFlow:\"流量\",nozzleHighFlow:\"高流量\",nozzleStandardFlow:\"标准\",firmwareUpdate:\"固件更新\",firmwareInstructions:\"在打印机触摸屏上，前往\",firmwareNav:\"导航到\",settings:\"设置\",firmware:\"固件\",discoverPrinters:\"发现打印机\",searching:\"搜索中...\",manualEntry:\"手动输入\",addFromCloud:\"从云端添加\",toast:{printerDeleted:\"打印机已删除\",missingSpoolAssignment:\"已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}\",printerAdded:\"打印机已添加\",printerUpdated:\"打印机已更新\",failedToDelete:\"删除打印机失败\",failedToAdd:\"添加打印机失败\",failedToUpdate:\"更新打印机失败\",commandSent:\"命令已发送\",failedToSendCommand:\"发送命令失败\",turnedOn:\"{{name}} 已开启\",failedToPowerOn:\"开启 {{name}} 失败\",scriptTriggered:\"脚本已触发\",printStopped:\"打印已停止\",printPaused:\"打印已暂停\",printResumed:\"打印已继续\",referenceDeleted:\"参考已删除\",detectionAreaSaved:\"检测区域已保存\",failedToRunScript:\"运行脚本失败\",failedToStopPrint:\"停止打印失败\",failedToPausePrint:\"暂停打印失败\",failedToResumePrint:\"继续打印失败\",failedToControlChamberLight:\"控制腔室灯失败\",failedToSetSpeed:\"设置打印速度失败\",failedToUpdateSetting:\"更新设置失败\",failedToSkipObjects:\"跳过对象失败\",failedToRereadRfid:\"重新读取 RFID 失败\",failedToCheckPlate:\"检查打印板失败\",failedToUpdateLabel:\"更新标签失败\",failedToDeleteReference:\"删除参考失败\",failedToSaveDetectionArea:\"保存检测区域失败\",plateCheckEnabled:\"打印板检查已启用\",plateCheckDisabled:\"打印板检查已禁用\",calibrationSaved:\"校准已保存！\",calibrationFailed:\"校准失败\",rfidRereadInitiated:\"已发起 RFID 重新读取\"},connection:{connected:\"已连接\",offline:\"离线\"},plateStatus:{markCleared:\"将打印板标记为已清理\",cleared:\"打印板已清理\",notCleared:\"打印板未清理\",inUse:\"打印板使用中\"},queue:{inQueue:\"队列中有 {{count}} 个打印任务\",inQueue_plural:\"队列中有 {{count}} 个打印任务\"},controls:\"控制\",rfid:{reread:\"重新读取 RFID\"},bedJog:{title:\"移动热床\",bed:\"热床\",step:\"步长 (mm)\",up:\"热床上移\",down:\"热床下移\",disabledWhilePrinting:\"打印中已禁用\",notHomedTitle:\"打印机未归零\",notHomedMessage:\"打印机自上次打印以来尚未归零。请先执行自动归零以确保安全定位（先停放喷头，然后归零 X、Y 和 Z），或者直接移动 — 软限位将被绕过。\",homeZ:\"自动归零\",moveAnyway:\"强制移动\",homingStarted:\"打印机自动归零中…\"},permission:{noAdd:\"您没有添加打印机的权限\",noEdit:\"您没有编辑打印机的权限\",noDelete:\"您没有删除打印机的权限\",noControl:\"您没有控制打印机的权限\",noFiles:\"您没有访问打印机文件的权限\",noAmsRfid:\"您没有重新读取 AMS RFID 的权限\",noSmartPlugControl:\"您没有控制智能插座的权限\",noCamera:\"您没有查看摄像头的权限\"},modal:{addTitle:\"添加打印机\",editTitle:\"编辑打印机\",myPrinter:\"我的打印机\",selectModel:\"选择型号...\",locationGroup:\"位置 / 分组（可选）\",locationPlaceholder:\"例如：工作室、办公室、地下室\",autoArchiveLabel:\"自动归档已完成的打印\",fromPrinterSettings:\"来自打印机设置\",modelOptional:\"型号（可选）\",saveChanges:\"保存更改\"},skipObjects:{tooltip:\"跳过对象\",onlyWhilePrinting:\"跳过对象（仅在打印时）\",requiresMultiple:\"跳过对象（需要2个以上对象）\",title:\"跳过对象\",matchIdsInfo:\"将 ID 与打印机显示屏上的 ID 进行对照\",printerShowsIds:\"打印机屏幕上显示构建板上对象的 ID\",skipSelected:\"跳过所选\",skipping:\"跳过中...\",noObjectsSelected:\"未选择对象\",selectObjectsToSkip:\"选择要从当前打印中跳过的对象\",skipped:\"已跳过\",objectsSkipped:\"对象已跳过\",activeCount:\"{{count}} 个活跃\",waitForLayer:\"等待第2层以上才能跳过对象（当前第 {{layer}} 层）\",skip:\"跳过\",confirmTitle:\"跳过对象？\",confirmMessage:'确定要跳过\"{{name}}\"吗？此操作无法撤销。'},confirm:{deleteTitle:\"删除打印机\",deleteMessage:'确定要删除\"{{name}}\"吗？这将移除所有连接设置。',deleteArchivesNote:\"此打印机的所有打印历史将被永久删除。\",keepArchivesNote:\"打印历史将保留，但不再与此打印机关联。\",stopTitle:\"停止打印\",stopMessage:'确定要停止\"{{name}}\"上的当前打印吗？这将取消打印任务。',stopButton:\"停止打印\",pauseTitle:\"暂停打印\",pauseMessage:'确定要暂停\"{{name}}\"上的当前打印吗？',pauseButton:\"暂停打印\",resumeTitle:\"继续打印\",resumeMessage:'确定要继续\"{{name}}\"上的打印吗？',resumeButton:\"继续打印\",powerOnTitle:\"开启打印机\",powerOnMessage:'确定要打开\"{{name}}\"的电源吗？',powerOnButton:\"开机\",powerOffTitle:\"关闭打印机\",powerOffMessage:'确定要关闭\"{{name}}\"的电源吗？',powerOffWarning:'警告：\"{{name}}\"正在打印中！确定要关闭电源吗？这将中断打印并可能损坏打印机。',powerOffButton:\"关机\"},bulk:{select:\"选择\",selectAll:\"全选\",selectByLocation:\"按位置选择\",selected:\"已选择{{count}}台\",actions:{stop:\"停止\",pause:\"暂停\",resume:\"继续\",clearPlate:\"清除打印床\",clearHMS:\"清除通知\"},confirm:{stopTitle:\"停止{{count}}个打印任务\",stopMessage:\"这将取消{{count}}台打印机上的活动打印任务。此操作无法撤销。\",stopButton:\"全部停止\",pauseTitle:\"暂停{{count}}个打印任务\",pauseMessage:\"这将暂停{{count}}台打印机上的活动打印任务。\",pauseButton:\"全部暂停\",clearPlateTitle:\"清除{{count}}个打印床\",clearPlateMessage:\"这将清除{{count}}台打印机的打印床，可能会触发队列中的任务。\",clearPlateButton:\"全部清除\"},success:\"{{action}}已在{{count}}台打印机上完成\",partial:\"{{succeeded}}成功，{{failed}}失败\",noneApplicable:\"没有选中的打印机处于适合此操作的状态\",selectByState:\"按状态选择\"},discovery:{title:\"发现打印机\",searching:\"搜索中...\",scanning:\"扫描中...\",scanProgress:\"扫描中... {{scanned}}/{{total}}\",foundPrinters:\"发现 {{count}} 台打印机\",noPrintersFound:\"未找到打印机\",noPrintersFoundSubnet:\"在指定子网中未找到打印机。\",noPrintersFoundNetwork:\"在网络上未找到打印机。\",allConfigured:\"所有发现的打印机已配置完毕。\",alreadyAdded:\"已添加\",select:\"选择\",manualEntry:\"手动输入\",addFromCloud:\"从云端添加\",subnetToScan:\"要扫描的子网\",dockerNote:\"检测到 Docker 环境。请以 CIDR 格式输入打印机所在子网。需要在 docker-compose.yml 中设置 network_mode: host。\",scanSubnet:\"扫描子网查找打印机\",discoverNetwork:\"在网络上发现打印机\",scanningSubnet:\"正在扫描子网查找拓竹打印机...\",scanningNetwork:\"正在扫描网络...\",serialRequired:\"需要序列号\",unknown:\"未知\",failedToStart:\"启动发现失败\"},drying:{start:\"开始干燥\",stop:\"停止干燥\",temperature:\"温度\",duration:\"时长\",hours:\"小时\",timeRemaining:\"剩余 {{time}}\",active:\"干燥中\",notSupported:\"不支持干燥\",powerRequired:\"连接AMS电源适配器以启用干燥\",startingDrying:\"正在启动干燥...\",stoppingDrying:\"正在停止干燥...\",rotateTray:\"干燥时旋转料盘\"},filaments:\"耗材\",openCameraOverlay:\"打开摄像头叠加层\",openCameraWindow:\"在新窗口中打开摄像头\",firmwareUpdateAvailable:\"固件更新可用：{{current}} → {{latest}}\",firmwareUpToDate:\"固件 {{version}} — 已是最新\",firmwareUpdateButton:\"更新\",plateDetection:{noPermission:\"您没有更新打印机的权限\",enabledClick:\"打印板检查已启用 - 点击禁用\",disabledClick:\"打印板检查已禁用 - 点击启用\",manageCalibration:\"管理打印板检测校准\",calibrationRequired:\"需要校准\",calibrationInstructions:\"请确保构建板<strong>完全空置</strong>，然后点击校准。\",calibrationDescription:\"校准会拍摄空置打印板的参考图像。后续检查将与此参考进行比较以检测物体。\",calibrationTip:\"<strong>提示：</strong>您最多可以为不同的打印板存储5个校准。系统会在检查时自动使用最佳匹配。\",plateEmpty:\"打印板似乎是空的\",objectsDetected:\"在打印板上检测到物体\",confidence:\"置信度\",difference:\"差异\",analysisPreview:\"分析预览：\",analysisLegend:\"绿色框 = 检测区域，红色覆盖 = 与校准的差异\",savedReferences:\"已保存的参考 ({{count}}/{{max}})\",deleteReference:\"删除参考\",labelPlaceholder:\"标签...\",clickToEdit:\"{{label}} - 点击编辑\",clickToAddLabel:\"点击添加标签\"},speed:{title:\"打印速度\",silent:\"静音 (50%)\",standard:\"标准 (100%)\",sport:\"运动 (124%)\",ludicrous:\"疯狂 (166%)\"},airduct:{title:\"风道模式\",cooling:\"制冷\",heating:\"加热\"},noSdCard:\"无SD\",door:{open:\"开\",closed:\"关\"},fans:{partCooling:\"零件冷却风扇\",auxiliary:\"辅助风扇\",chamber:\"腔室风扇\"},clickToViewHmsErrors:\"点击查看 HMS 错误\",estimatedCompletion:\"预计完成时间\",plateNumber:\"板 {{number}}\",slotOptions:\"槽位选项\",amsPopup:{friendlyName:\"AMS 名称\",friendlyNamePlaceholder:\"例如 AMS 友好名称\",serialNumber:\"序列号\",firmwareVersion:\"固件\",save:\"保存\",clear:\"清除\",noEditPermission:\"您没有重命名 AMS 单元的权限\"},firmwareModal:{title:\"固件更新\",titleUpToDate:\"固件信息\",currentVersion:\"当前版本：\",latestVersion:\"最新版本：\",releaseNotes:\"发布说明\",checkingPrereqs:\"正在检查前提条件...\",sdCardReady:\"SD 卡已就绪。点击下方上传固件。\",uploadedSuccess:\"固件已上传到 SD 卡！\",applyInstructions:\"在打印机上应用更新：\",step1:\"在打印机触摸屏上，前往<strong>设置</strong>\",step2:\"导航到<strong>固件</strong>\",step3:\"选择<strong>从 SD 卡更新</strong>\",step4:\"更新将需要 10-20 分钟\",done:\"完成\",starting:\"启动中...\",uploadFirmware:\"上传固件\",uploadFailed:\"上传启动失败：{{error}}\",uploadedToast:\"固件已上传！请在打印机屏幕上触发更新。\",availableVersions:\"可用版本\",usable:\"可用\",unavailable:\"不可用\",installed:\"已安装\",newerBadge:\"较新\",olderBadge:\"较旧\",currentBadge:\"当前\"},accessCodePlaceholder:\"留空以保持当前值\",roi:{title:\"检测区域 (ROI)\",xStart:\"X 起点\",yStart:\"Y 起点\",width:\"宽度\",height:\"高度\",instruction:\"调整检测区域以聚焦到构建板。预览中的绿色框显示当前区域。\"},developerModeWarning:\"以下打印机未启用开发者局域网模式：{{names}}。某些功能可能无法使用。\",howToEnable:\"如何启用\",incompatibleFile:\"此文件是为 {{slicedFor}} 切片的，但该打印机是 {{printerModel}}\",dropNotPrintable:\"只能打印 .gcode 和 .gcode.3mf 文件\",dropToPrint:\"拖放以打印\",cannotPrint:\"打印机忙碌\"},archives:{title:\"打印归档\",searchPlaceholder:\"搜索归档...\",filterByPrinter:\"按打印机筛选\",filterByStatus:\"按状态筛选\",sortBy:\"排序方式\",sortNewest:\"最新优先\",sortOldest:\"最旧优先\",sortName:\"名称\",sortDuration:\"时长\",sortLargest:\"最大优先\",sortSmallest:\"最小优先\",sortSize:\"大小\",noArchives:\"未找到归档\",noArchivesSearch:\"没有匹配搜索的归档\",originalPrintNotVisible:\"原始打印不可见 - 请尝试清除筛选条件\",noArchivesYet:\"暂无归档\",prints:\"条打印\",pagination:{showing:\"显示\",to:\"至\",of:\"共\",show:\"每页\",page:\"页\",all:\"全部\"},loadingArchives:\"加载归档中...\",releaseToUpload:\"释放以上传\",showAll:\"显示全部\",showFavoritesOnly:\"仅显示收藏\",gridView:\"网格视图\",listView:\"列表视图\",calendarView:\"日历视图\",logView:\"打印日志\",manageTags:\"管理标签\",showFailedPrints:\"显示失败的打印\",hideFailedPrints:\"隐藏失败的打印\",hideDuplicates:\"隐藏重复项\",viewOriginalPrint:\"点击查看原始打印 (#{{id}})\",printTime:\"打印时间\",filamentUsed:\"耗材用量\",cost:\"成本\",reprint:\"重新打印\",preview:\"预览\",deleteArchive:\"删除归档\",deleteConfirm:\"确定要删除此归档吗？\",favorite:\"收藏\",unfavorite:\"取消收藏\",viewDetails:\"查看详情\",status:{completed:\"已完成\",failed:\"失败\",stopped:\"已停止\"},toast:{source3mfAttached:\"源 3MF 已附加：{{filename}}\",failedUploadSource3mf:\"上传源 3MF 失败\",source3mfRemoved:\"源 3MF 已移除\",failedRemoveSource3mf:\"移除源 3MF 失败\",f3dAttached:\"F3D 已附加：{{filename}}\",failedUploadF3d:\"上传 F3D 失败\",f3dRemoved:\"F3D 已移除\",failedRemoveF3d:\"移除 F3D 失败\",timelapseAttached:\"延时摄影已附加：{{filename}}\",timelapseAlreadyAttached:\"延时摄影已附加\",noMatchingTimelapse:\"未找到匹配的延时摄影\",failedScanTimelapse:\"扫描延时摄影失败\",failedAttachTimelapse:\"附加延时摄影失败\",timelapseRemoved:\"延时摄影已移除\",failedRemoveTimelapse:\"移除延时摄影失败\",timelapseUploaded:\"延时摄影已上传：{{filename}}\",failedUploadTimelapse:\"上传延时摄影失败\",archiveDeleted:\"归档已删除\",failedDeleteArchive:\"删除归档失败\",addedToFavorites:\"已添加到收藏\",removedFromFavorites:\"已从收藏中移除\",projectUpdated:\"项目已更新\",failedUpdateProject:\"更新项目失败\",linkCopied:\"链接已复制到剪贴板\",failedCopyLink:\"复制链接失败\",photoDeleted:\"照片已删除\",failedDeletePhoto:\"删除照片失败\",failedDeleteArchives:\"删除归档失败\",failedUpdateFavorites:\"更新收藏失败\",exportDownloaded:\"导出已下载\",exportFailed:\"导出失败\"},menu:{print:\"打印\",schedule:\"排程\",openInBambuStudio:\"在切片软件中打开\",slice:\"切片\",externalLink:\"外部链接\",viewOnMakerWorld:\"在 MakerWorld 上查看\",preview3d:\"3D 预览\",viewTimelapse:\"查看延时摄影\",scanForTimelapse:\"扫描延时摄影\",uploadTimelapse:\"上传延时摄影\",removeTimelapse:\"移除延时摄影\",downloadSource3mf:\"下载源 3MF\",uploadSource3mf:\"上传源 3MF\",replaceSource3mf:\"替换源 3MF\",removeSource3mf:\"移除源 3MF\",uploadF3d:\"上传 F3D\",replaceF3d:\"替换 F3D\",downloadF3d:\"下载 F3D\",removeF3d:\"移除 F3D\",download:\"下载\",copyDownloadLink:\"复制下载链接\",qrCode:\"二维码\",viewPhotos:\"查看照片\",viewPhotosCount:\"查看照片 ({{count}})\",projectPage:\"项目页面\",addToFavorites:\"添加到收藏\",removeFromFavorites:\"从收藏中移除\",edit:\"编辑\",goToProject:\"前往项目：{{name}}\",addToProject:\"添加到项目\",removeFromProject:\"从项目中移除\",loading:\"加载中...\",noProjectsAvailable:\"无可用项目\",select:\"选择\",deselect:\"取消选择\",delete:\"删除\"},permission:{noReprint:\"您没有重新打印此归档的权限\",noAddToQueue:\"您没有添加到队列的权限\",noUpdateArchives:\"您没有更新归档的权限\",noUploadFiles:\"您没有上传文件的权限\",noDownload:\"您没有下载归档的权限\",noCopyLink:\"您没有复制下载链接的权限\",noDelete:\"您没有删除此归档的权限\",noCreate:\"您没有创建归档的权限\"},card:{previousPlate:\"上一个板\",nextPlate:\"下一个板\",plateNumber:\"板 {{index}}\",moreOptions:\"右键查看更多选项\",addToFavorites:\"添加到收藏\",removeFromFavorites:\"从收藏中移除\",cancelled:\"已取消\",failed:\"失败\",duplicate:\"重复\",duplicateTitle:\"此模型之前已打印过\",openSource3mf:\"在 Bambu Studio 中打开源 3MF（右键查看更多选项）\",downloadF3d:\"下载 Fusion 360 设计文件\",viewTimelapse:\"查看延时摄影\",viewPhoto:\"查看 1 张照片\",viewPhotos:\"查看 {{count}} 张照片\",openFolder:\"打开文件夹：{{name}}\",slicedFile:\"已切片文件 - 可以打印\",sourceFile:\"仅源文件 - 无 AMS 映射可用\",gcode:\"GCODE\",source:\"源文件\",project:\"项目：{{name}}\",estimated:\"预计：{{time}}\",actual:\"实际：{{time}}\",accuracy:\"准确度：{{percent}}%\",filament:\"{{weight}}g\",layer:\"{{count}} 层\",layers:\"{{count}} 层\",object:\"{{count}} 个对象\",objects:\"{{count}} 个对象\",slicedFor:\"为 {{model}} 切片\",uploadedBy:\"上传者\",noPermissionReprint:\"您没有重新打印的权限\",noFileForReprint:\"无可用的 3MF 文件 — 打印记录时无法从打印机下载该文件\",noPermissionEdit:\"您没有编辑归档的权限\",noPermissionDelete:\"您没有删除归档的权限\",reprint:\"重新打印\",schedulePrint:\"排程打印\",schedule:\"排程\",openInBambuStudio:\"在切片软件中打开\",openInBambuStudioToSlice:\"在切片软件中打开进行切片\",slice:\"切片\",externalLink:\"外部链接\",makerWorld:\"MakerWorld：{{designer}}\",viewProject:\"查看项目\",noExternalLink:\"无外部链接\",preview3d:\"3D 预览\",download:\"下载\",edit:\"编辑\",delete:\"删除\"},modal:{deleteArchive:\"删除归档\",deleteConfirm:'确定要删除\"{{name}}\"吗？此操作无法撤销。',deleteButton:\"删除\",removeSource3mf:\"移除源 3MF\",removeSource3mfConfirm:'确定要从\"{{name}}\"中移除源 3MF 文件吗？这将删除原始切片项目文件。',removeButton:\"移除\",removeF3d:\"移除 F3D\",removeF3dConfirm:'确定要从\"{{name}}\"中移除 Fusion 360 设计文件吗？',removeTimelapse:\"移除延时摄影\",removeTimelapseConfirm:'确定要从\"{{name}}\"中移除延时摄影视频吗？',timelapse:\"{{name}} - 延时摄影\",selectTimelapse:\"选择延时摄影\",selectTimelapseDesc:\"未找到自动匹配。请选择此打印的延时摄影：\",deleteArchives:\"删除归档\",deleteArchivesConfirm:\"确定要删除 {{count}} 个归档吗？此操作无法撤销。\",deleteCount:\"删除 {{count}} 个\"},page:{title:\"归档\",printsCount:\"{{filtered}} / {{total}} 次打印\",dropFilesHere:\"将 .3mf 文件拖放到此处\",releaseToUpload:\"释放以上传\",only3mfSupported:\"仅支持 .3mf 文件\",close:\"关闭\",selected:\"已选择 {{count}} 个\",selectAll:\"全选\",tags:\"标签\",project:\"项目\",favorite:\"收藏\",delete:\"删除\",toggledFavorites:\"已切换 {{count}} 个归档的收藏状态\",failedUpdateFavorites:\"更新收藏失败\",archivesDeleted:\"已删除 {{count}} 个归档\",failedDeleteArchives:\"删除归档失败\",photoDeleted:\"照片已删除\",failedDeletePhoto:\"删除照片失败\"},list:{name:\"名称\",printer:\"打印机\",date:\"日期\",size:\"大小\",actions:\"操作\",hasTimelapse:\"有延时摄影\"},log:{date:\"日期\",printName:\"打印名称\",printer:\"打印机\",user:\"用户\",status:\"状态\",duration:\"时长\",filament:\"耗材\",allPrinters:\"所有打印机\",allUsers:\"所有用户\",allStatuses:\"所有状态\",cancelled:\"已取消\",skipped:\"已跳过\",dateFrom:\"从\",dateTo:\"到\",noEntries:\"未找到打印日志条目\",showing:\"显示 {{count}} / {{total}} 条\",rowsPerPage:\"行数\",page:\"页\",prev:\"上一页\",next:\"下一页\",clearLog:\"清除日志\",clearLogTitle:\"清除打印日志\",clearLogConfirm:\"所有打印日志条目将被永久删除。归档和队列项目不受影响。此操作无法撤销。确定要继续吗？\",clearLogButton:\"全部清除\",cleared:\"已清除 {{count}} 条日志\",clearFailed:\"清除打印日志失败\"}},queue:{title:\"打印队列\",subtitle:\"排程和管理您的打印任务\",addToQueue:\"添加到队列\",print:\"打印\",reprint:\"重新打印\",schedulePrint:\"排程打印\",editQueueItem:\"编辑队列项目\",printToPrinters:\"打印到 {{count}} 台打印机\",queueToPrinters:\"排队到 {{count}} 台打印机\",queueSelectedPlates:\"将 {{count}} 个热床加入队列\",selectAllPlates:\"选择全部 {{count}} 个热床\",deselectAll:\"取消全选\",printQueued:\"已加入打印队列\",itemsQueued:\"{{count}} 个任务已加入队列\",sending:\"发送中...\",sendingProgress:\"发送中 {{current}}/{{total}}...\",adding:\"添加中...\",addingProgress:\"添加中 {{current}}/{{total}}...\",savingProgress:\"保存中 {{current}}/{{total}}...\",clearQueue:\"清空队列\",clearHistory:\"清除历史\",emptyQueue:\"队列为空\",position:\"位置\",scheduledTime:\"排程时间\",moveUp:\"上移\",moveDown:\"下移\",startNow:\"立即开始\",printingInProgress:\"打印进行中...\",viewArchive:\"查看归档\",viewInFileManager:\"在文件管理器中查看\",itemCount:\"{{count}} 个项目\",itemCount_plural:\"{{count}} 个项目\",dragToReorder:\"拖动以重新排序（仅限尽快）\",reorderHint:'位置仅影响\"尽快\"项目。排程项目按设定时间运行。',sjf:{label:\"SJF\",tooltip:\"最短任务优先 — 调度器优先处理较短的打印任务\"},addedBy:\"由 {{name}} 添加\",nextInQueue:\"队列中的下一个\",clearPlateSuccess:\"打印板已清理 — 准备进行下一个打印\",plateNumber:\"板 {{index}}\",quantity:\"数量\",quantityHint:\"创建 {{count}} 个队列项目\",activeBatches:\"活跃批次\",batchProgress:\"已完成 {{completed}}/{{total}}\",cancelBatch:\"取消剩余\",batchCancelled:\"已取消剩余批次项目\",cancelBatchConfirmTitle:\"取消批次\",cancelBatchConfirmMessage:\"取消此批次中所有剩余的待处理项目？\",batch:\"批次\",sections:{currentlyPrinting:\"正在打印\",queued:\"排队中\",history:\"历史\"},status:{pending:\"等待中\",waiting:\"等待中\",printing:\"打印中\",paused:\"已暂停\",completed:\"已完成\",failed:\"失败\",skipped:\"已跳过\",cancelled:\"已取消\"},summary:{printing:\"打印中\",queued:\"排队中\",totalTime:\"总队列时间\",totalWeight:\"总队列重量\",history:\"历史\"},filter:{allPrinters:\"所有打印机\",unassigned:\"未分配\",allStatus:\"所有状态\",allLocations:\"所有位置\",any:\"任意\"},sort:{byPosition:\"按位置排序\",byName:\"按名称排序\",byPrinter:\"按打印机排序\",bySchedule:\"按排程排序\",byDate:\"按日期排序\",ascendingOldest:\"升序（最旧优先）\",descendingNewest:\"降序（最新优先）\"},badges:{staged:\"已暂存\",requiresPrevious:\"需要前一个成功\",autoPowerOff:\"自动关机\",gcodeInjection:\"G-code\"},empty:{title:\"没有排程的打印\",description:'从归档页面使用右键菜单中的\"排程\"选项来排程打印，或拖放文件开始。'},time:{asap:\"尽快\",overdue:\"已逾期\",now:\"现在\",lessThanMinute:\"不到一分钟\",inMinutes:\"{{count}} 分钟后\",inHours:\"{{count}} 小时后\"},actions:{stopPrint:\"停止打印\",startPrint:\"开始打印\",requeue:\"重新排队\"},bulkEdit:{title:\"编辑 {{count}} 个项目\",title_plural:\"编辑 {{count}} 个项目\",description:\"仅更改的设置将应用于所选项目。\",printer:\"打印机\",noChange:\"— 不更改 —\",queueOptions:\"队列选项\",staged:\"暂存（手动开始）\",autoPowerOff:\"打印后自动关机\",requirePrevious:\"要求前一个成功\",printOptions:\"打印选项\",bedLevelling:\"热床调平\",flowCalibration:\"流量校准\",vibrationCalibration:\"振动校准\",layerInspection:\"首层检查\",timelapse:\"延时摄影\",useAms:\"使用 AMS\",applyChanges:\"应用更改\",selectAll:\"全选\",deselectAll:\"取消全选\",selected:\"已选择 {{count}} 个\",editSelected:\"编辑所选\",cancelSelected:\"取消所选\"},confirm:{cancelTitle:\"取消排程打印\",cancelMessage:'确定要取消\"{{name}}\"吗？',stopTitle:\"停止打印\",stopMessage:'确定要停止当前打印\"{{name}}\"吗？这将取消打印机上的打印任务。',removeTitle:\"从历史中移除\",removeMessage:'确定要从队列历史中移除\"{{name}}\"吗？',clearHistoryTitle:\"清除历史\",clearHistoryMessage:\"确定要从历史中移除所有 {{count}} 个项目吗？\",cancelButton:\"取消打印\",stopButton:\"停止打印\",thisPrint:\"此打印\",thisItem:\"此项目\"},toast:{cancelled:\"队列项目已取消\",cancelFailed:\"取消项目失败\",removed:\"队列项目已移除\",removeFailed:\"移除项目失败\",stopped:\"打印已停止\",stopFailed:\"停止打印失败\",released:\"打印已释放到队列\",startFailed:\"开始打印失败\",reorderFailed:\"重新排序队列失败\",historyCleared:\"已清除 {{count}} 条历史记录\",clearHistoryFailed:\"清除历史失败\",updateFailed:\"更新项目失败\",bulkCancelled:\"已取消 {{count}} 个项目\",bulkCancelFailed:\"批量取消项目失败\"},timeline:{listView:\"列表\",timelineView:\"时间线\",unassigned:\"未分配\",noData:\"当天没有计划的打印任务\",allDoneBy:\"所有打印预计在 {{time}} 前完成\",staged:\"暂存\",filterAll:\"全部显示\",filterPrinting:\"打印中\",filterQueued:\"排队中\",time:{anyMoment:\"即将完成\",minutesLeft:\"剩余{{minutes}}分钟\",hoursLeft:\"剩余{{hours}}小时\",hoursMinutesLeft:\"剩余{{hours}}小时{{minutes}}分钟\"},day:{previous:\"前一天\",next:\"后一天\",today:\"今天\"}},permissions:{noStopPrint:\"您没有停止打印的权限\",noStartPrint:\"您没有开始打印的权限\",noEdit:\"您没有编辑此队列项目的权限\",noCancel:\"您没有取消此队列项目的权限\",noRequeue:\"您没有重新排队的权限\",noRemove:\"您没有移除此队列项目的权限\",noClearHistory:\"您没有清除所有历史的权限\",noEditItems:\"您没有编辑队列项目的权限\",noCancelItems:\"您没有取消队列项目的权限\"}},backgroundDispatch:{unknownFile:\"未知文件\",unknownPrinter:\"未知打印机\",startingPrints:\"正在开始打印\",progressSummary:\"{{complete}}/{{total}} 完成 • 已分发：{{dispatched}} • 处理中：{{processing}}\",expandDetails:\"展开分发详情\",collapseDetails:\"收起分发详情\",dismissToast:\"关闭分发通知\",cancelDispatchJob:\"取消分发任务\",cancel:\"取消\",cancelling:\"取消中…\",status:{dispatched:\"已分发\",processing:\"处理中\",completed:\"已完成\",failed:\"失败\",cancelled:\"已取消\"},toast:{cancellingUpload:\"取消上传中...\",cancelled:\"分发已取消\",cancelFailed:\"取消分发失败\",completeWithFailures:\"后台分发完成：{{completed}} 成功，{{failed}} 失败\",completeSuccess:\"后台分发完成：{{completed}} 成功\",printStartedRemaining:\"{{completed}} 个打印已开始，{{remaining}} 个正在发送...\"}},stats:{title:\"仪表板\",subtitle:\"拖动小部件以重新排列。点击眼睛图标隐藏。\",overview:\"概览\",totalPrints:\"总打印次数\",successRate:\"成功率\",totalPrintTime:\"总打印时间\",printTime:\"打印时间\",totalFilament:\"总耗材用量\",filamentUsed:\"耗材用量\",filamentCost:\"耗材成本\",totalCost:\"总成本\",energyUsed:\"能耗\",energyCost:\"能源成本\",energyWarmingUpTooltip:\"能耗追踪正在收集每小时快照。当所选范围之前至少存在一个快照时，时间段合计将变得准确。早期数值可能偏低。\",averagePrintTime:\"平均打印时间\",printsPerDay:\"每日打印次数\",byPrinter:\"按打印机\",printsByPrinter:\"各打印机打印次数\",byMaterial:\"按材料\",byMonth:\"按月份\",last7Days:\"最近 7 天\",last30Days:\"最近 30 天\",last90Days:\"最近 90 天\",allTime:\"全部时间\",quickStats:\"快速统计\",printActivity:\"打印活动\",filamentTypes:\"耗材类型\",filamentTrends:\"耗材趋势\",failureAnalysis:\"失败分析\",timeAccuracy:\"时间准确度\",successful:\"成功：\",failed:\"失败：\",perfectEstimate:\"100% = 完美估计\",noTimeAccuracyData:\"暂无时间准确度数据\",noFilamentData:\"暂无耗材数据\",noPrinterData:\"暂无打印机数据\",noPrintData:\"暂无打印数据\",noPrintDataLast30Days:\"最近 30 天无打印数据\",failureReasons:\"失败原因\",topFailureReasons:\"主要失败原因\",failedPrintsCount:\"{{failed}} / {{total}} 次打印失败\",lastWeekRate:\"上周：{{rate}}%\",resetLayout:\"重置布局\",recalculateCosts:\"重新计算成本\",recalculateCostsHint:\"使用当前耗材价格重新计算所有归档成本\",exportStats:\"导出统计\",exportAsCsv:\"导出为 CSV\",exportAsExcel:\"导出为 Excel\",hiddenCount:\"{{count}} 个已隐藏\",exportDownloaded:\"导出已下载\",exportFailed:\"导出失败\",layoutReset:\"布局已重置\",recalculatedCosts:\"已为 {{count}} 个归档重新计算成本\",recalculateFailed:\"重新计算成本失败\",loadingStats:\"加载统计数据中...\",noPermissionResetLayout:\"您没有重置布局的权限\",noPermissionRecalculate:\"您没有重新计算成本的权限\",noPrintDataInRange:\"所选范围内无打印数据\",periodFilament:\"期间耗材\",periodCost:\"期间成本\",avgPerPrint:\"每次打印平均\",usageOverTime:\"随时间的使用量\",filamentByWeight:\"重量\",printDuration:\"打印时长\",printerUtilization:\"打印机利用率\",filamentSuccess:\"按材料成功率\",printHabits:\"打印习惯\",printTimeOfDay:\"打印时段\",colorDistribution:\"颜色分布\",noColorData:\"暂无颜色数据\",records:\"记录\",longestPrint:\"最长打印\",heaviestPrint:\"最重打印\",mostExpensivePrint:\"最贵打印\",busiestDay:\"最忙碌的一天\",successStreak:\"连续成功\",streakPrint:\"连续打印\",streakPrints:\"{{count}} 次连续打印\",printerStats:\"打印机统计\",hours:\"小时\",avgPrints:\"平均打印\",noArchiveData:\"暂无打印数据\",filamentByTime:\"时间\",avgWeight:\"平均重量\",avgTime:\"平均时间\",filamentByPrints:\"打印次数\",timeframe:{today:\"今天\",\"this-week\":\"本周\",\"this-month\":\"本月\",\"last-7\":\"最近 7 天\",\"last-30\":\"最近 30 天\",\"last-90\":\"最近 90 天\",\"this-year\":\"今年\",\"all-time\":\"全部时间\",custom:\"自定义范围\",from:\"从\",to:\"到\"},allUsers:\"所有用户\",noUser:\"无用户（系统）\",filterByUser:\"按用户筛选\"},maintenance:{title:\"维护\",overview:\"概览\",allOk:\"所有维护均已完成\",dueCount:\"{{count}} 项到期\",dueCount_plural:\"{{count}} 项到期\",warningCount:\"{{count}} 个警告\",warningCount_plural:\"{{count}} 个警告\",totalPrintTime:\"总打印时间\",nextMaintenance:\"下次维护\",nothingDue:\"无到期项目\",tasks:\"任务\",lastPerformed:\"上次执行\",interval:\"间隔\",hoursRemaining:\"剩余 {{hours}} 小时\",hoursOverdue:\"逾期 {{hours}} 小时\",markDone:\"标记为完成\",performMaintenance:\"执行维护\",history:\"历史\",noHistory:\"无维护历史\",editPrintHours:\"编辑打印时间\",currentHours:\"当前小时数\",statusTab:\"状态\",settingsTab:\"设置\",overdueCount:\"{{count}} 个逾期\",dueSoonCount:\"{{count}} 个即将到期\",dueSoon:\"即将到期\",allGood:\"一切正常\",overdueBy:\"逾期 {{duration}}\",dueIn:\"{{duration}} 后到期\",timeLeft:\"剩余 {{duration}}\",day:\"1 天\",days:\"{{count}} 天\",week:\"1 周\",weeks:\"{{count}} 周\",month:\"1 个月\",months:\"{{count}} 个月\",year:\"1 年\",maintenanceTypes:\"维护类型\",maintenanceTypesDescription:\"系统类型和您的自定义维护任务\",addCustomType:\"添加自定义类型\",restoreDefaults:\"恢复默认任务\",intervalType:\"间隔类型\",intervalValue:\"间隔 ({{type}})\",icon:\"图标\",documentationLink:\"文档链接（可选）\",assignToPrinters:\"分配给打印机\",selectAtLeastOnePrinter:\"至少选择一台打印机\",addType:\"添加类型\",custom:\"自定义\",printHours:\"打印小时数\",calendarDays:\"日历天数\",exampleName:\"例如：更换 HEPA 过滤器\",viewDocumentation:\"查看文档\",timeBasedInterval:\"基于时间的间隔\",intervalOverrides:\"间隔覆盖\",intervalOverridesDescription:\"为特定打印机自定义间隔\",assignedToPrinters:\"已分配给打印机：\",noPrintersAssigned:\"未分配打印机\",addPrinterShort:\"添加：\",printersAssignedClick:\"已分配 {{count}} 台打印机 - 点击管理\",removeFromPrinter:\"从此打印机移除\",types:{lubricateCarbonRods:\"润滑碳纤维杆\",lubricateRails:\"润滑线性导轨\",cleanNozzle:\"清洁喷嘴/热端\",checkBelts:\"检查皮带张力\",cleanBuildPlate:\"清洁构建板\",checkExtruder:\"检查挤出机齿轮\",checkCooling:\"检查冷却风扇\",generalInspection:\"综合检查\",cleanCarbonRods:\"清洁碳纤维杆\",lubricateSteelRods:\"润滑钢杆\",cleanSteelRods:\"清洁钢杆\",cleanLinearRails:\"清洁线性导轨\",checkPtfeTube:\"检查 PTFE 管\",replaceHepaFilter:\"更换 HEPA 过滤器\",replaceCarbonFilter:\"更换活性炭过滤器\",lubricateLeftNozzleRail:\"润滑左喷嘴导轨\"},maintenanceComplete:\"维护已标记为完成\",typeUpdated:\"维护类型已更新\",typeDeleted:\"维护类型已删除\",defaultsRestored:\"已恢复 {{count}} 个默认任务\",printHoursUpdated:\"打印小时数已更新\",printerAssigned:\"打印机已分配\",printerRemoved:\"打印机已移除\",deleteTypeConfirm:'删除\"{{name}}\"？',deleteSystemTypeTitle:\"删除默认维护任务？\",deleteSystemTypeMessage:'确定要删除默认维护任务\"{{name}}\"吗？',noPermissionUpdate:\"您没有更新维护项目的权限\",noPermissionPerform:\"您没有执行维护的权限\",noPermissionEditTypes:\"您没有编辑维护类型的权限\",noPermissionDeleteTypes:\"您没有删除维护类型的权限\",noPermissionEditHours:\"您没有编辑打印时间的权限\",noPermissionRemovePrinter:\"您没有移除打印机分配的权限\",noPermissionAssignPrinter:\"您没有分配打印机的权限\",noPermissionEditIntervals:\"您没有编辑间隔的权限\",configureSettings:\"配置维护类型和间隔\"},settings:{title:\"设置\",general:\"通用\",tabs:{general:\"通用\",smartPlugs:\"智能插座\",notifications:\"通知\",queue:\"工作流\",filament:\"耗材\",network:\"网络\",apiKeys:\"API 密钥\",virtualPrinter:\"虚拟打印机\",spoolbuddy:\"SpoolBuddy\",failureDetection:\"故障检测\",users:\"身份验证\",backup:\"备份\",emailAuth:\"邮箱认证\",ldap:\"LDAP\",twoFa:\"双因素认证\",oidc:\"SSO / OIDC\"},spoolbuddy:{infoTitle:\"SpoolBuddy 设备\",infoBody:\"SpoolBuddy kiosk 通过心跳自动注册。如果设备不再使用，或守护进程崩溃遗留了陈旧的重复项，可在此注销。\",duplicatesTitle:\"已注册 {{count}} 台设备\",duplicatesBody:\"kiosk 界面只使用最先注册的设备。如果其中有因崩溃遗留的陈旧重复项，请注销它——在线设备会在下次心跳时重新注册自己。\",empty:\"尚未注册任何 SpoolBuddy 设备。\",online:\"在线\",offline:\"离线\",unregister:\"注销\",unregisterSuccess:\"设备已注销\",unregisterError:\"注销设备失败\",confirmTitle:\"注销 SpoolBuddy 设备？\",confirmBody:'将从数据库中移除 \"{{hostname}}\" ({{deviceId}})。如果设备在线，会在下次心跳时重新注册自己。',ipAddress:\"IP 地址\",firmware:\"固件\",lastSeen:\"上次在线\",daemonUptime:\"守护进程运行时间\",systemUptime:\"系统运行时间\",never:\"从未\",nfc:\"NFC\",scale:\"秤\",cpuTemp:\"CPU 温度\",memory:\"内存\",disk:\"磁盘\",update:\"更新\",updateConfirmTitle:\"更新 SpoolBuddy 守护进程？\",updateConfirmBody:'对 \"{{hostname}}\" 触发软件更新？更新完成后守护进程将重启。',restartBrowser:\"重启浏览器\",restartBrowserConfirmTitle:\"重启 kiosk 浏览器？\",restartBrowserConfirmBody:'在 \"{{hostname}}\" 上重启 kiosk 浏览器？显示将短暂黑屏。',restartDaemon:\"重启守护进程\",restartDaemonConfirmTitle:\"重启 SpoolBuddy 守护进程？\",restartDaemonConfirmBody:'在 \"{{hostname}}\" 上重启 SpoolBuddy 守护进程？设备将离线几秒钟。',reboot:\"重启\",rebootConfirmTitle:\"重启设备？\",rebootConfirmBody:'重启 \"{{hostname}}\"？设备将离线约一分钟。',shutdown:\"关机\",shutdownConfirmTitle:\"关闭设备？\",shutdownConfirmBody:'关闭 \"{{hostname}}\"？您需要物理访问才能重新开机。',commandConfirm:\"确认\",commandQueued:\"命令已加入队列\",commandError:\"发送命令失败\"},ldap:{title:\"LDAP 认证\",enabledDesc:\"LDAP 认证已启用\",disabledDesc:\"LDAP 认证已禁用\",disabledHint:\"在下方配置并保存 LDAP 设置，然后启用。\",enabled:\"LDAP 认证已启用\",disabled:\"LDAP 认证已禁用\",feature1:\"用户可以使用 LDAP 凭据登录\",feature2:\"本地管理员帐户作为后备保留\",feature3:\"登录时 LDAP 组映射到 BamBuddy 组\",serverConfig:\"LDAP 服务器配置\",serverUrl:\"服务器 URL\",serverUrlHint:\"使用 ldap:// 进行标准连接或 ldaps:// 进行 SSL 连接\",security:\"安全\",securityHint:\"StartTLS 将普通连接升级为 TLS。LDAPS 从一开始就使用 TLS。\",bindDn:\"绑定 DN（服务帐户）\",bindPassword:\"绑定密码\",searchBase:\"搜索基础 DN\",userFilter:\"用户搜索过滤器\",userFilterHint:\"{username} 替换为登录用户名。OpenLDAP 使用 (uid={username})。\",autoProvision:\"自动创建用户\",autoProvisionHint:\"首次 LDAP 登录时自动创建 BamBuddy 帐户\",defaultGroup:\"默认组\",defaultGroupNone:\"— 无（无回退）—\",defaultGroupHint:\"当 LDAP 用户通过身份验证但不在任何已映射的 LDAP 组中时分配的回退组。留空以使未映射的用户没有权限。\",groupMapping:\"组映射（JSON）\",groupMappingHint:\"将 LDAP 组 DN 映射到 BamBuddy 组。可用组：\",testConnection:\"测试连接\",settingsSaved:\"LDAP 设置已保存\",errors:{serverRequired:\"LDAP 服务器 URL 为必填项\",searchBaseRequired:\"搜索基础 DN 为必填项\",enableAuthFirst:\"请先启用认证\",configureLdapFirst:\"请先保存 LDAP 设置\"}},email:{smtpSettings:\"SMTP 配置\",smtpHost:\"SMTP 服务器\",smtpPort:\"SMTP 端口\",security:\"安全\",authentication:\"认证\",username:\"用户名\",password:\"密码\",fromEmail:\"发件邮箱\",fromName:\"发件人名称\",testConnection:\"测试 SMTP 连接\",testRecipient:\"测试收件邮箱\",sendTest:\"发送测试邮件\",sending:\"发送中...\",save:\"保存设置\",saving:\"保存中...\",advancedAuth:\"高级认证\",advancedAuthEnabled:\"高级认证已启用\",advancedAuthEnabledDesc:\"基于邮箱的用户管理功能已激活。新用户将通过邮件收到自动生成的密码，用户可以通过忘记密码功能重置密码。\",advancedAuthDisabled:\"高级认证已禁用\",advancedAuthDisabledDesc:\"启用高级认证以激活基于邮箱的用户管理功能。\",enable:\"启用\",disable:\"禁用\",feature1:\"密码自动生成并通过邮件发送给新用户\",feature2:\"用户可以使用用户名或邮箱登录\",feature3:\"忘记密码功能可用\",feature4:\"管理员可以通过邮件重置用户密码\",errors:{requiredFields:\"请填写所有必填字段\",usernameRequired:\"启用认证时需要用户名\",enterTestEmail:\"请输入测试邮箱地址\",smtpServerAndEmail:\"测试前请填写 SMTP 服务器和发件邮箱\",usernamePasswordRequired:\"启用认证时需要用户名和密码\",configureSmtpFirst:\"请先配置并测试 SMTP 设置\",enableAuthFirst:\"请先启用身份验证才能使用基于电子邮件的功能。\"},success:{settingsSaved:\"SMTP 设置保存成功\"},securityOptions:{starttls:\"STARTTLS（端口 587）\",ssl:\"SSL/TLS（端口 465）\",none:\"无（端口 25）\"},authOptions:{enabled:\"已启用\",disabled:\"已禁用\"}},appearance:\"外观\",notifications:\"通知\",smartPlugs:\"智能插座\",spoolman:\"Spoolman\",updates:\"更新\",language:\"语言\",languageDescription:\"选择您的首选语言\",theme:\"主题\",themeLight:\"浅色\",themeDark:\"深色\",themeSystem:\"跟随系统\",defaultView:\"默认视图\",defaultViewDescription:\"打开应用时显示的页面\",checkForUpdates:\"检查更新\",autoUpdate:\"自动更新\",currentVersion:\"当前版本\",latestVersion:\"最新版本\",upToDate:\"已是最新版本\",updateAvailable:\"有可用更新\",notificationLanguage:\"通知语言\",notificationLanguageDescription:\"推送通知的语言\",bedCooledThreshold:\"热床冷却阈值\",bedCooledThresholdDescription:\"打印后热床被视为已冷却的温度\",userNotificationsEnabled:\"用户通知\",userNotificationsEnabledDescription:\"启用用户通知菜单和打印任务事件的邮件通知。需要高级身份验证。\",userNotificationsDisabledHint:\"请启用高级身份验证以使用用户通知。\",notificationProviders:\"通知提供商\",addProvider:\"添加提供商\",editProvider:\"编辑提供商\",providerType:\"提供商类型\",testNotification:\"测试通知\",testSuccess:\"测试通知发送成功\",testFailed:\"发送测试通知失败\",quietHours:\"免打扰时间\",quietHoursDescription:\"在此时间段内不发送通知\",quietHoursStart:\"开始\",quietHoursEnd:\"结束\",events:{title:\"通知事件\",printStart:\"打印开始\",printComplete:\"打印完成\",printFailed:\"打印失败\",printStopped:\"打印停止\",printProgress:\"进度里程碑\",printProgressDescription:\"在 25%、50%、75% 时通知\",printerOffline:\"打印机离线\",printerError:\"打印机错误\",filamentLow:\"耗材不足\",maintenanceDue:\"维护到期\",maintenanceDueDescription:\"需要维护时通知\"},smartPlug:{title:\"智能插座\",add:\"添加智能插座\",edit:\"编辑智能插座\",name:\"名称\",ipAddress:\"IP 地址\",linkedPrinter:\"关联打印机\",autoOn:\"自动开启\",autoOnDescription:\"打印开始时开启\",autoOff:\"自动关闭\",autoOffDescription:\"打印完成后关闭\",offDelay:\"关闭延迟\",offDelayMinutes:\"打印后分钟数\",offDelayTemp:\"当喷嘴温度低于\",currentState:\"当前状态\",turnOn:\"开启\",turnOff:\"关闭\"},filamentTracking:\"耗材追踪\",filamentTrackingDesc:\"选择如何追踪您的耗材。您可以使用内置库存或连接外部 Spoolman 服务器。\",filamentChecks:\"耗材检查\",disableFilamentWarnings:\"禁用耗材警告\",disableFilamentWarningsDesc:\"在打印或加入队列时不显示耗材不足警告\",preferLowestFilament:\"优先使用剩余最少的耗材\",preferLowestFilamentDesc:\"当多个料盘匹配时，使用剩余耗材最少的那个\",trackingModeBuiltIn:\"内置库存\",trackingModeBuiltInDesc:\"包含 RFID 自动匹配和用量追踪\",trackingModeSpoolmanDesc:\"外部耗材管理服务器\",builtInFeatureRfid:\"自动检测 AMS 中的拓竹 RFID 耗材\",builtInFeatureUsage:\"追踪每次打印的耗材消耗\",builtInFeatureCatalog:\"管理耗材、颜色和 K 值配置文件\",builtInFeatureThirdParty:\"第三方耗材可分配到库存耗材\",amsSyncButton:\"从 AMS 同步重量\",amsSyncTitle:\"从 AMS 同步耗材重量\",amsSyncMessage:\"这将使用已连接打印机的当前 AMS 剩余百分比值覆盖所有库存耗材重量。用于从损坏的重量数据中恢复。打印机必须在线。\",amsSyncing:\"同步中...\",amsSyncSuccess:\"已同步 {{synced}} 个耗材，跳过 {{skipped}} 个\",amsSyncError:\"从 AMS 同步重量失败\",spoolmanUrl:\"Spoolman URL\",spoolmanUrlHint:\"Spoolman 服务器的 URL（例如 http://localhost:7912）\",spoolmanConnected:\"已连接\",spoolmanDisconnected:\"未连接\",status:\"状态\",connect:\"连接\",disconnect:\"断开\",howSyncWorks:\"同步工作原理\",syncInfoRfidOnly:\"仅同步带有 RFID 的官方拓竹耗材\",syncInfoAutoCreate:\"首次同步时自动在 Spoolman 中创建新耗材\",syncInfoThirdPartySkipped:\"非拓竹耗材（第三方、重新填充的）将被跳过\",linkingExistingSpools:\"链接现有耗材\",linkingExistingSpoolsDesc:'要将现有的 Spoolman 耗材链接到您的 AMS，请将鼠标悬停在 AMS 槽位上并点击\"链接到 Spoolman\"。',syncMode:\"同步模式\",syncModeAuto:\"自动\",syncModeManual:\"仅手动\",syncModeAutoDesc:\"检测到更改时自动同步 AMS 数据\",syncModeManualDesc:\"仅在手动触发时同步\",syncAmsData:\"同步 AMS 数据\",syncAmsDataDesc:\"手动将打印机 AMS 数据同步到 Spoolman\",allPrinters:\"所有打印机\",noDefaultPrinter:\"无默认（每次询问）\",sidebarOrder:\"侧边栏顺序\",saveThumbnails:\"保存缩略图\",captureFinishPhoto:\"拍摄完成照片\",noPrintersConfigured:\"未配置打印机\",archiveMode:{always:\"始终创建归档条目\",never:\"从不创建归档条目\",ask:\"每次询问\"},checkForUpdatesLabel:\"检查更新\",checkPrinterFirmware:\"检查打印机固件\",includeBetaUpdates:\"包含测试版本\",includeBetaUpdatesDesc:\"检查更新时通知测试版和预发布版本\",enableRetry:\"启用重试\",homeAssistantDescription:\"通过 Home Assistant 控制智能插座\",environmentManagedLabel:\"（环境变量管理）\",autoEnabledViaEnv:\"通过环境变量自动启用\",urlFromEnvReadOnly:\"值由 HA_URL 环境变量设置（只读）\",tokenFromEnvReadOnly:\"值由 HA_TOKEN 环境变量设置（只读）\",mqttConnectedTo:\"已连接到\",prometheusDescription:\"以 Prometheus 格式暴露打印机数据\",noSmartPlugsTitle:\"未配置智能插座\",noSmartPlugsDescription:\"添加基于 Tasmota 的智能插座以追踪能耗并自动化电源控制。\",noProvidersTitle:\"未配置提供商\",noProvidersDescription:\"添加提供商以接收警报。\",noTemplatesAvailable:\"无可用模板。重启后端以加载默认模板。\",apiPermissionView:\"查看打印机状态和队列\",apiPermissionEdit:\"添加和移除打印队列中的项目\",apiKeysEmptyTitle:\"无 API 密钥\",apiKeysEmptyDescription:\"创建 API 密钥以与外部服务集成。\",noUsersFound:\"未找到用户\",noGroupsFound:\"未找到组\",noGroupsAvailable:\"无可用组\",passwordsDoNotMatch:\"密码不匹配\",systemGroupWarning:\"系统组名称不可更改\",authDisabledTitle:\"身份验证已禁用\",authDisabledFeature1:\"需要登录才能访问系统\",authDisabledFeature2:\"创建多个用户并基于组的权限管理\",authDisabledFeature3:\"使用 50+ 个细粒度权限控制访问\",userHasCreated:\"此用户已创建：\",userItemsQuestion:\"您想如何处理这些项目？\",deleteUserConfirm:\"确定要删除此用户吗？\",actionCannotBeUndone:\"此操作无法撤销。\",addFirstSmartPlug:\"添加您的第一个智能插座\",providers:\"提供商\",log:\"日志\",testAll:\"全部测试\",testResults:\"测试结果\",testPassedCount:\"{{count}} 个通过\",testFailedCount:\"{{count}} 个失败\",messageTemplates:\"消息模板\",messageTemplatesDescription:\"自定义每个事件的通知消息。\",apiKeys:\"API 密钥\",apiKeysDescription:\"创建 API 密钥用于外部集成和 Webhook。\",createKey:\"创建密钥\",apiKeyCreated:\"API 密钥创建成功\",apiKeyCopyWarning:\"请立即复制此密钥 - 它不会再次显示！\",useInApiBrowser:\"在 API 浏览器中使用\",createNewApiKey:\"创建新 API 密钥\",keyName:\"密钥名称\",keyNamePlaceholder:\"例如：Home Assistant、OctoPrint\",readStatus:\"读取状态\",readStatusDescription:\"查看打印机状态和队列\",manageQueue:\"管理队列\",manageQueueDescription:\"添加和移除打印队列中的项目\",controlPrinter:\"控制打印机\",controlPrinterDescription:\"暂停、继续和停止打印\",unnamedKey:\"未命名密钥\",lastUsed:\"上次使用\",read:\"读取\",control:\"控制\",createFirstKey:\"创建您的第一个密钥\",webhookEndpoints:\"Webhook 端点\",webhookApiKeyHint:\"在 X-API-Key 请求头中使用您的 API 密钥。\",webhook:{getAllStatus:\"获取所有打印机状态\",getSpecificStatus:\"获取特定打印机状态\",addToQueue:\"添加到打印队列\",pausePrint:\"暂停打印\",resumePrint:\"继续打印\",stopPrint:\"停止打印\"},apiBrowser:\"API 浏览器\",apiBrowserDescription:\"浏览和测试所有可用的 API 端点。\",apiKeyForTesting:\"测试用 API 密钥\",apiKeyPlaceholder:\"在此粘贴您的 API 密钥以测试需要认证的端点...\",apiKeyHint:\"此密钥将作为 X-API-Key 请求头随请求发送。\",deleteApiKeyTitle:\"删除 API 密钥\",deleteApiKeyMessage:\"确定要删除此 API 密钥吗？使用此密钥的所有集成将停止工作。\",deleteKey:\"删除密钥\",amsDisplayThresholds:\"AMS 显示阈值\",amsThresholdsDescription:\"配置 AMS 湿度和温度指示器的颜色阈值。\",humidity:\"湿度\",goodGreen:\"良好（绿色）\",fairOrange:\"一般（橙色）\",aboveFairBad:\"超过一般阈值显示为红色（差）\",fairAlsoDryingThreshold:\"此阈值也用于触发自动干燥\",temperature:\"温度\",goodBlue:\"良好（蓝色）\",aboveFairHot:\"超过一般阈值显示为红色（热）\",historyRetention:\"历史保留\",keepSensorHistory:\"保留传感器历史\",historyRetentionDescription:\"较旧的湿度和温度数据将被自动删除\",defaultPrintOptions:\"默认打印选项\",defaultPrintOptionsDescription:\"设置新打印的默认选项值。可在打印对话框中逐次覆盖。\",defaultBedLevelling:\"热床调平\",defaultBedLevellingDesc:\"打印前自动调平热床\",defaultFlowCali:\"流量校准\",defaultFlowCaliDesc:\"校准挤出流量\",defaultVibrationCali:\"振动校准\",defaultVibrationCaliDesc:\"减少振纹伪影\",defaultLayerInspect:\"首层检测\",defaultLayerInspectDesc:\"AI首层检测\",defaultTimelapse:\"延时摄影\",defaultTimelapseDesc:\"录制延时摄影视频\",staggeredStart:\"Staggered Start\",staggeredStartDescription:\"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.\",plateClear:\"热床清空确认\",requirePlateClear:\"需要热床清空确认\",requirePlateClearDescription:\"启用后，调度器会在已完成打印的打印机上启动排队打印之前，等待每台打印机的热床清空确认。禁用后，也会隐藏打印机卡片上的打印板状态标记和“将打印板标记为已清理”按钮。\",gcodeInjection:\"G-code注入\",gcodeInjectionDescription:'为Farmloop、SwapMod、AutoClear和Printflow 3D等自动打印系统配置自定义G-code，在打印开始和/或结束时注入。代码片段按打印机型号配置，在队列项目上启用\"注入G-code\"时应用。',gcodeInjectionNoPrinters:\"未找到打印机。添加打印机以配置G-code代码片段。\",gcodeStartLabel:\"开始G-code\",gcodeEndLabel:\"结束G-code\",gcodeStartPlaceholder:\"在打印开始前插入的G-code...\",gcodeEndPlaceholder:\"在打印结束后追加的G-code...\",staggerGroupSize:\"Group size\",staggerGroupSizeHelp:\"Printers to start simultaneously per group\",staggerInterval:\"Interval (minutes)\",staggerIntervalHelp:\"Delay between each group starting\",queueDrying:\"自动干燥\",queueDryingDescription:\"在队列打印之间，打印机空闲时自动干燥AMS耗材。使用上方的湿度阈值触发干燥。\",queueDryingEnabled:\"启用自动干燥\",queueDryingEnabledDescription:\"当打印机空闲且湿度超过阈值时，自动启动AMS干燥\",queueDryingBlock:\"等待干燥完成\",queueDryingBlockDescription:\"阻止打印队列直到干燥完成。关闭时，打印优先于干燥。\",ambientDryingEnabled:\"环境干燥\",ambientDryingEnabledDescription:\"当空闲打印机的湿度超过阈值时自动干燥耗材，无需排队打印。\",dryingPresets:\"干燥预设\",dryingPresetsDescription:\"每种耗材类型的温度和时长。AMS 2 Pro使用较低温度，AMS-HT支持较高温度。\",dryingFilament:\"耗材\",printModal:\"打印对话框\",expandCustomMapping:\"默认展开自定义映射\",expandCustomMappingDescription:\"打印到多台打印机时，默认展开显示每台打印机的 AMS 映射\",authentication:\"身份验证\",authEnabledDescription:\"您的实例已通过用户身份验证保护\",authDisabledDescription:\"启用以要求登录并管理用户访问\",authDisabledMessage:\"启用身份验证以创建用户账户、管理权限并保护您的 Bambuddy 实例。\",enableAuthentication:\"启用身份验证\",currentUser:\"当前用户\",changePassword:\"修改密码\",admin:\"管理员\",users:\"用户\",addUser:\"添加用户\",groups:\"组\",addGroup:\"添加组\",system:\"系统\",noDescription:\"无描述\",userCount:\"{{count}} 个用户\",permissionCount:\"{{count}} 个权限\",createUser:\"创建用户\",username:\"用户名\",enterUsername:\"输入用户名\",password:\"密码\",enterPassword:\"输入密码（至少 6 个字符）\",confirmPassword:\"确认密码\",confirmPasswordPlaceholder:\"确认密码\",viewReleaseOnGitHub:\"在 GitHub 上查看发布\",turnAllPlugsOn:\"开启所有插座\",turnAllPlugsOff:\"关闭所有插座\",clearNotificationLogs:\"清除通知日志\",clearLogsMessage:\"这将永久删除所有 30 天前的通知日志。此操作无法撤销。\",clearLogs:\"清除日志\",resetUiPreferences:\"重置 UI 偏好\",resetUiPreferencesMessage:\"这将重置所有 UI 偏好为默认值：侧边栏顺序、主题、仪表板布局、视图模式和排序偏好。您的打印机、归档和服务器设置不会受到影响。清除后页面将重新加载。\",resetPreferences:\"重置偏好\",deleteGroupTitle:\"删除组\",deleteGroupMessage:\"确定要删除此组吗？此组中的用户将失去这些权限。\",deleteGroup:\"删除组\",disableAuthenticationTitle:\"禁用身份验证\",disableAuthenticationMessage:\"确定要禁用身份验证吗？这将使您的 Bambuddy 实例无需登录即可访问。所有用户将保留在数据库中但身份验证将被禁用。\",disableAuthentication:\"禁用身份验证\",configureBambuddy:\"配置 Bambuddy\",systemDefault:\"系统默认\",archiveSettings:\"归档设置\",newWindow:\"新窗口\",embeddedOverlay:\"嵌入式叠加层\",preferredSlicer:\"首选切片软件\",preferredSlicerDescription:\"选择要用于打开文件的切片软件\",externalCameras:\"外部摄像头\",costTracking:\"成本追踪\",printsOnly:\"仅打印\",totalConsumption:\"总消耗\",dataManagement:\"数据管理\",storageUsage:\"存储使用情况\",storageUsageDescription:\"按类别的数据使用情况明细\",storageUsageTotal:\"总计\",storageUsageErrors:\"错误\",storageUsageOtherBreakdown:\"其他（包括静态资源、脚本和配置文件）\",storageUsageSystem:\"系统\",storageUsageData:\"数据\",storageUsageUnavailable:\"存储使用信息不可用\",clearNotificationLogsDescription:\"删除 30 天前的通知日志\",resetUiPreferencesDescription:\"重置侧边栏顺序、主题、视图模式和布局偏好。打印机、归档和设置不受影响。\",enableHomeAssistant:\"启用 Home Assistant\",enableMqtt:\"启用 MQTT\",useTls:\"使用 TLS\",enableMetricsEndpoint:\"启用指标端点\",availableMetrics:\"可用指标\",editUser:\"编辑用户\",deleteUserTitle:\"删除用户\",groupName:\"组名称\",leaveEmptyForAnonymous:\"留空为匿名\",leaveEmptyForNoAuth:\"留空为无认证\",enterNewPassword:\"输入新密码\",confirmNewPassword:\"确认新密码\",enterGroupName:\"输入组名称\",enterDescriptionOptional:\"输入描述（可选）\",enterCurrentPassword:\"输入当前密码\",enterNewPasswordMin6:\"输入新密码（至少 6 个字符）\",toast:{keyCopied:\"密钥已复制到剪贴板\",copyFailed:\"复制密钥失败\",keyAddedToBrowser:\"密钥已添加到 API 浏览器\",clearLogsFailed:\"清除日志失败\",uiPreferencesReset:\"UI 偏好已重置。刷新中...\",authDisabled:\"身份验证已成功禁用\",authDisableFailed:\"禁用身份验证失败\",apiKeyCreated:\"API 密钥已创建\",apiKeyDeleted:\"API 密钥已删除\",userCreated:\"用户创建成功\",userUpdated:\"用户更新成功\",userDeleted:\"用户删除成功\",groupCreated:\"组创建成功\",groupUpdated:\"组更新成功\",groupDeleted:\"组删除成功\",fillRequiredFields:\"请填写所有必填字段\",passwordsDoNotMatch:\"密码不匹配\",passwordTooShort:\"密码至少需要 6 个字符\",enterGroupName:\"请输入组名称\",settingsSaved:\"设置已保存\",cameraSettingsSaved:\"摄像头设置已保存\",enterCameraUrl:\"请输入摄像头 URL\",passwordChanged:\"密码修改成功\",connectionFailed:\"连接失败\",testFailed:\"测试失败\",cameraConnected:\"摄像头已连接{{resolution}}\"},testConnection:\"测试连接\",catalog:{spoolCatalog:\"耗材目录\",spoolCatalogDescription:\"按品牌/类型的空耗材重量。用于添加耗材时的自动重量查找。\",searchCatalog:\"搜索目录...\",addNewEntry:\"添加新条目\",namePlaceholder:\"名称（例如：Bambu Lab - 塑料）\",weight:\"重量\",type:\"类型\",default:\"默认\",custom:\"自定义\",noMatch:\"没有条目匹配您的搜索\",empty:\"目录中没有条目\",deleteEntry:\"删除条目\",deleteConfirm:'确定要删除\"{{name}}\"吗？',resetCatalog:\"重置目录\",resetConfirm:\"重置目录为默认值？这将移除所有自定义条目。\",loadFailed:\"加载耗材目录失败\",nameWeightRequired:\"名称和重量为必填项\",entryAdded:\"条目已添加\",addFailed:\"添加条目失败\",entryUpdated:\"条目已更新\",updateFailed:\"更新条目失败\",entryDeleted:\"条目已删除\",deleteFailed:\"删除条目失败\",resetSuccess:\"目录已重置为默认值\",resetFailed:\"重置目录失败\",exported:\"已导出 {{count}} 条\",imported:\"已导入 {{added}} 条（跳过 {{skipped}} 条）\",importFailed:\"导入失败：无效的 JSON 格式\",exportTooltip:\"导出目录为 JSON\",importTooltip:\"从 JSON 导入目录\",resetTooltip:\"重置为默认值\",selectedCount:\"已选择 {{count}} 项\",deleteSelected:\"删除所选\",bulkDeleteConfirm:\"确定要删除 {{count}} 个条目吗？\",bulkDeleted:\"已删除 {{count}} 个条目\",bulkDeleteFailed:\"删除条目失败\"},colorCatalog:{title:\"颜色目录\",description:\"按制造商/材料的耗材颜色。用于添加耗材时的自动颜色查找。\",searchColors:\"搜索颜色...\",allManufacturers:\"所有制造商\",addNewColor:\"添加新颜色\",manufacturer:\"制造商\",colorName:\"颜色名称\",hex:\"十六进制\",materialOptional:\"材料（可选）\",showing:\"显示 {{filtered}} / {{total}} 种颜色\",noMatch:\"没有颜色匹配您的搜索\",empty:\"目录中没有颜色\",deleteColor:\"删除颜色\",deleteConfirm:'确定要删除\"{{name}}\"吗？',resetCatalog:\"重置颜色目录\",resetConfirm:\"重置目录为默认值？这将移除所有自定义颜色。\",sync:\"同步\",starting:\"启动中...\",syncTooltip:\"从 FilamentColors.xyz 同步（2000+ 种颜色，可能需要一分钟）\",loadFailed:\"加载颜色目录失败\",fieldsRequired:\"制造商、颜色名称和十六进制颜色为必填项\",colorAdded:\"颜色已添加\",addFailed:\"添加颜色失败\",colorUpdated:\"颜色已更新\",updateFailed:\"更新颜色失败\",colorDeleted:\"颜色已删除\",deleteFailed:\"删除颜色失败\",resetSuccess:\"颜色目录已重置为默认值\",resetFailed:\"重置目录失败\",syncUpToDate:\"已是最新（检查了 {{count}} 种颜色）\",syncComplete:\"添加了 {{added}} 种新颜色（{{skipped}} 种已存在）\",syncError:\"同步错误\",syncFailed:\"从 FilamentColors.xyz 同步失败\",exported:\"已导出 {{count}} 种颜色\",imported:\"已导入 {{added}} 种颜色（跳过 {{skipped}} 种）\",importFailed:\"导入失败：无效的 JSON 格式\",selectedCount:\"已选择 {{count}} 项\",deleteSelected:\"删除所选\",bulkDeleteConfirm:\"确定要删除 {{count}} 种颜色吗？\",bulkDeleted:\"已删除 {{count}} 种颜色\",bulkDeleteFailed:\"删除颜色失败\"},dateFormat:\"日期格式\",dateFormatUs:\"美式 (MM/DD/YYYY)\",dateFormatEu:\"欧式 (DD/MM/YYYY)\",dateFormatIso:\"ISO (YYYY-MM-DD)\",timeFormat:\"时间格式\",timeFormat12:\"12小时制 (3:30 PM)\",timeFormat24:\"24小时制 (15:30)\",defaultPrinter:\"默认打印机\",defaultPrinterDescription:\"为上传、重印和其他操作预选此打印机。\",slicerBambuStudio:\"Bambu Studio\",slicerOrcaSlicer:\"OrcaSlicer\",sidebarOrderDescription:\"拖拽侧边栏项目以重新排序。在此处重置为默认顺序。\",setDefault:\"设为默认\",sidebarOrderSetDefaultHint:\"设为默认将当前菜单顺序应用于尚未自定义的用户。\",sidebarDefaultSet:\"已设置默认菜单顺序。\",sidebarDefaultCleared:\"已清除默认菜单顺序。\",sidebarDefaultFailed:\"设置默认菜单顺序失败。\",reset:\"重置\",darkMode:\"深色模式\",lightMode:\"浅色模式\",active:\"(当前)\",background:\"背景\",accent:\"强调色\",style:\"样式\",bgNeutral:\"中性\",bgWarm:\"暖色\",bgCool:\"冷色\",bgOled:\"OLED 纯黑\",bgSlate:\"石板蓝\",bgForest:\"森林绿\",accentGreen:\"绿色\",accentTeal:\"青色\",accentBlue:\"蓝色\",accentOrange:\"橙色\",accentPurple:\"紫色\",accentRed:\"红色\",styleClassic:\"经典\",styleGlow:\"发光\",styleVibrant:\"鲜艳\",themeToggleHint:\"使用侧边栏中的太阳/月亮图标在深色和浅色模式之间切换。\",autoArchivePrints:\"自动归档打印\",autoArchiveDescription:\"打印完成时自动保存3MF文件\",saveThumbnailsDescription:\"从3MF文件中提取并保存预览图像\",captureFinishPhotoDescription:\"打印完成时从打印机摄像头拍照\",ffmpegNotInstalled:\"未安装ffmpeg\",ffmpegRequired:\"摄像头捕获需要ffmpeg。通过 <brew>brew install ffmpeg</brew>（macOS）或 <apt>apt install ffmpeg</apt>（Linux）安装。\",camera:\"摄像头\",cameraViewMode:\"摄像头查看模式\",cameraOverlayDescription:\"摄像头在主屏幕上以可调大小的覆盖层打开\",cameraWindowDescription:\"摄像头在单独的浏览器窗口中打开\",externalCamerasDescription:\"配置外部摄像头以替换内置打印机摄像头。支持MJPEG流、RTSP、HTTP快照和USB摄像头（V4L2）。启用后，外部摄像头将用于实时查看和完成照片。\",cameraPlaceholderUsb:\"设备路径 (/dev/video0)\",cameraPlaceholderUrl:\"摄像头URL (rtsp://... 或 http://...)\",cameraTypeMjpeg:\"MJPEG 流\",cameraTypeRtsp:\"RTSP 流\",cameraTypeSnapshot:\"HTTP 快照\",cameraTypeUsb:\"USB 摄像头 (V4L2)\",cameraRotation:\"旋转\",test:\"测试\",connected:\"已连接\",disconnected:\"未连接\",currency:\"货币\",defaultFilamentCost:\"默认耗材成本（每公斤）\",electricityCost:\"每千瓦时电费\",energyDisplayMode:\"能源显示模式\",energyModePrintDescription:\"仪表板显示打印期间使用的能源总和\",energyModeTotalDescription:\"仪表板显示智能插座的累计能源\",fileManager:\"文件管理器\",createArchiveEntry:\"打印时创建归档条目\",createArchiveEntryDescription:\"从文件管理器打印时，可选择创建归档条目\",lowDiskSpaceWarning:\"磁盘空间不足警告\",lowDiskSpaceDescription:\"当可用磁盘空间低于此阈值时显示警告\",printerFirmware:\"打印机固件\",checkFirmwareDescription:\"检查Bambu Lab的打印机固件更新\",bambuddySoftware:\"Bambuddy 软件\",autoCheckDescription:\"启动时自动检查新版本\",checkNow:\"立即检查\",updateAvailableVersion:\"可用更新：v{{version}}\",releaseNotes:\"发布说明\",updateViaDocker:\"通过 Docker Compose 更新：\",installUpdate:\"安装更新\",latestVersionRunning:\"您正在运行最新版本\",failedToCheckUpdates:\"检查更新失败：{{error}}\",backupRestore:\"备份与恢复\",backupRestoreDescription:\"导出/导入设置并配置GitHub备份\",goToBackup:\"前往备份\",externalUrl:\"外部URL\",externalUrlDescription:\"Bambuddy可访问的外部URL。用于通知图像和外部集成。\",bambuddyUrl:\"Bambuddy URL\",externalUrlHint:\"包含协议和端口（例如：http://192.168.1.100:8000）\",ftpRetry:\"FTP重试\",ftpRetryDescription:\"当打印机WiFi不稳定时重试FTP操作。适用于3MF下载、打印上传、延时摄影下载和固件更新。\",autoRetryDescription:\"自动重试失败的FTP操作\",retryAttempts:\"重试次数\",retryDelay:\"重试延迟\",connectionTimeout:\"连接超时\",time_one:\"{{count}}次\",time_other:\"{{count}}次\",second_one:\"{{count}}秒\",second_other:\"{{count}}秒\",nSeconds:\"{{count}}秒\",increaseForWeakWifi:\"对WiFi信号弱的打印机增加此值\",homeAssistant:\"Home Assistant\",homeAssistantFullDescription:\"连接到Home Assistant，通过HA REST API控制智能插座。支持switch、light、input_boolean和script实体。\",homeAssistantUrl:\"Home Assistant URL\",longLivedAccessToken:\"长期访问令牌\",haTokenHint:\"在HA中创建令牌：个人资料 → 长期访问令牌 → 创建令牌\",connectionSuccessful:\"连接成功\",connectionFailed:\"连接失败\",haConnectionSuccess:\"已成功连接到Home Assistant。\",haConnectionFailed:\"连接Home Assistant失败。\",mqttPublishing:\"MQTT发布\",mqttDescription:\"将BamBuddy事件发布到外部MQTT代理，用于与Node-RED、Home Assistant和其他自动化系统集成。\",mqttEnableDescription:\"向外部MQTT代理发布事件\",brokerHostname:\"代理主机名\",port:\"端口\",usernameOptional:\"用户名（可选）\",passwordOptional:\"密码（可选）\",topicPrefix:\"主题前缀\",topicPrefixHint:\"主题格式：{{prefix}}/printers/<serial>/status 等\",prometheusMetrics:\"Prometheus 指标\",prometheusEndpointDescription:\"在 <code>/api/v1/metrics</code> 公开打印机指标，用于Prometheus/Grafana监控。\",bearerTokenOptional:\"Bearer令牌（可选）\",bearerTokenHint:\"设置后，请求必须包含 <code>Authorization: Bearer <token></code>\",metricsConnectionStatus:\"连接状态\",metricsPrinterState:\"打印机状态（空闲/打印中等）\",metricsPrintProgress:\"打印进度 0-100%\",metricsBedTemp:\"热床温度\",metricsNozzleTemp:\"喷嘴温度\",metricsPrintsTotal:\"按结果分类的总打印数\",metricsMore:\"...以及更多（层数、风扇、队列、耗材用量）\",smartPlugsDescription:\"连接智能插座（Tasmota或Home Assistant）以自动化电源控制并跟踪打印机的能源使用情况。\",allOn:\"全部开启\",allOff:\"全部关闭\",addSmartPlug:\"添加智能插座\",energySummary:\"能源概要\",currentPower:\"当前功率\",plugsOnline:\"{{reachable}}/{{total}} 个插座在线\",today:\"今天\",yesterday:\"昨天\",total:\"总计\",enablePlugsForSummary:\"启用插座以查看能源概要\",addNotificationProvider:\"添加\",systemBadge:\"(系统)\",creating:\"创建中...\",changing:\"修改中...\",deleteUserAndItems:\"删除用户及其所有项目\",deleteUserKeepItems:\"删除用户，保留项目（将变为无主项目）\",ok:\"确定\",twoFa:{totpTitle:\"身份验证器应用 (TOTP)\",totpDesc:\"使用 Google Authenticator、Aegis 或 Authy 等应用。\",emailOtpTitle:\"邮件 OTP\",emailOtpDesc:\"登录时向 {{email}} 发送一次性验证码。\",emailOtpNoEmail:\"请先为账户添加邮箱地址以启用此方式。\",addEmailFirst:\"您的账户没有邮箱地址，请联系管理员添加。\",setupTotp:\"设置身份验证器应用\",setupAuthApp:\"设置身份验证器应用\",setupInstructions:\"使用身份验证器应用扫描二维码，然后输入验证码确认。\",manualEntry:\"无法扫描？请手动输入此密钥：\",scannedContinue:\"已扫描 — 继续\",enterCodeToConfirm:\"请输入身份验证器应用中的6位验证码以确认设置。\",activate:\"激活\",disableTotp:\"停用身份验证器\",disableConfirmHint:\"请输入有效的 TOTP 码或备用码来停用身份验证器。\",totpDisabled:\"身份验证器应用已停用。\",emailOtpEnabled:\"邮件 OTP 已启用。\",emailOtpDisabled:\"邮件 OTP 已停用。\",smtpRequired:\"请先配置并测试SMTP设置。\",invalidCode:\"无效验证码，请重试。\",enableEmailOtp:\"启用邮件 OTP\",disableEmailOtp:\"停用邮件 OTP\",emailSetupEnterCode:\"验证码已发送至您的邮箱地址。请在下方输入以确认您拥有此邮箱。\",verifyAndEnable:\"验证并启用\",emailDisablePasswordHint:\"请输入您的账户密码以确认停用邮件 OTP。\",passwordPlaceholder:\"输入您的密码\",backupCodesTitle:\"保存备用码\",backupCodesWarning:\"请将这些码保存在安全的地方。每个码只能使用一次，且不会再次显示。\",backupCodesRemaining:\"剩余 {{count}} 个备用码\",savedCodes:\"已保存\",regenBackup:\"重新生成备用码\",regenBackupHint:\"输入当前 TOTP 码以生成 10 个新备用码，所有现有备用码将失效。\",newBackupCodes:\"新备用码\",linkedAccounts:\"已关联的 SSO 账户\",linkedAccountsDesc:\"以下外部身份提供商已与您的账户关联。\",oidcUnlinked:\"账户已解除关联。\"},oidc:{title:\"SSO / OIDC 提供商\",desc:\"配置 OpenID Connect 提供商以实现单点登录。\",addProvider:\"添加提供商\",newProvider:\"新提供商\",empty:\"尚未配置 OIDC 提供商。\",created:\"提供商已创建。\",updated:\"提供商已更新。\",deleted:\"提供商已删除。\",deleteTitle:\"删除提供商\",deleteMessage:'删除\"{{name}}\"？所有关联账户将断开连接。',form:{name:\"显示名称\",issuerUrl:\"颁发者 URL\",clientId:\"客户端 ID\",clientSecret:\"客户端密钥\",scopes:\"作用域\",iconUrl:\"图标 URL（可选）\",enabled:\"已启用\",autoCreate:\"自动创建用户\",autoCreateDesc:\"首次登录时自动创建本地账户。\",autoLink:\"自动关联已有账户\",autoLinkDesc:\"首次登录时通过邮箱匹配现有本地账户并自动关联。\",secretHint:\"留空以保留当前\",secretPlaceholder:\"新密钥\"}}},notification:{printStarted:{title:\"打印已开始\",body:\"{{printer}}：{{filename}} 已开始打印\"},printCompleted:{title:\"打印已完成\",body:\"{{printer}}：{{filename}} 已成功完成\"},printFailed:{title:\"打印失败\",body:\"{{printer}}：{{filename}} 打印失败\"},printStopped:{title:\"打印已停止\",body:\"{{printer}}：{{filename}} 已停止\"},printProgress:{title:\"打印进度\",body:\"{{printer}}：{{filename}} 已完成 {{percent}}%\"},printerOffline:{title:\"打印机离线\",body:\"{{printer}} 已离线\"},printerError:{title:\"打印机错误\",body:\"{{printer}}：{{error}}\"},filamentLow:{title:\"耗材不足\",body:\"{{printer}}：耗材即将用完\"},maintenanceDue:{title:\"维护到期\",body:\"{{printer}}：{{items}} 需要关注\"}},errors:{generic:\"出了点问题\",networkError:\"网络错误。请检查您的连接。\",notFound:\"未找到\",unauthorized:\"未授权\",serverError:\"服务器错误\",validationError:\"请检查您的输入\",printerConnectionFailed:\"连接打印机失败\",saveFailed:\"保存更改失败\",deleteFailed:\"删除失败\",loadFailed:\"加载数据失败\"},hmsErrors:{title:\"错误 - {{name}}\",noErrors:\"无错误\",viewOnWiki:\"在拓竹 Wiki 上查看\",clearInstructions:\"在打印机上清除错误以在此处消除它们。\",clearErrors:\"清除错误\",clearSuccess:\"HMS 错误已清除\",clearFailed:\"清除 HMS 错误失败\"},mqttDebug:{title:\"MQTT 调试日志\",searchPlaceholder:\"搜索主题或负载...\",noMessages:\"尚未记录消息\",startLoggingHint:'点击\"开始记录\"以开始捕获 MQTT 消息',noMessagesMatch:\"没有消息匹配您的筛选条件\",adjustFilterHint:\"尝试调整您的搜索或筛选条件\",incoming:\"传入\",outgoing:\"传出\",loggingStopped:\"记录已停止\",loggingActive:\"记录中 - 消息将自动刷新\",startLogging:\"开始记录\",stopLogging:\"停止记录\",clearLog:\"清除日志\",topic:\"主题\",timestamp:\"时间戳\",direction:\"方向\",all:\"全部\"},printerFiles:{title:\"文件管理器\",storageUsed:\"已用：\",storageFree:\"剩余：\",filterPlaceholder:\"筛选文件...\",deleteButton:\"删除\",deleteFiles:\"删除 {{count}} 个文件\",deleteFileConfirm:'删除\"{{name}}\"？此操作无法撤销。',deleteFilesConfirm:\"删除 {{count}} 个选中的文件？此操作无法撤销。\",noFiles:\"打印机上没有文件\",loadingFiles:\"加载文件中...\",failedToLoad:\"加载文件失败\",toast:{filesDeleted:\"已删除 {{count}} 个文件\",deleteFailed:\"删除失败：{{error}}\"}},confirm:{delete:\"确定要删除吗？\",unsavedChanges:\"您有未保存的更改。确定要离开吗？\",clearQueue:\"确定要清空队列吗？\"},login:{title:\"Bambuddy 登录\",subtitle:\"登录您的账户\",username:\"用户名\",usernamePlaceholder:\"输入您的用户名\",usernameOrEmail:\"用户名或邮箱\",usernameOrEmailPlaceholder:\"用户名或 @ 邮箱\",password:\"密码\",passwordPlaceholder:\"输入您的密码\",signIn:\"登录\",signingIn:\"登录中...\",forgotPassword:\"忘记密码？\",loginSuccess:\"登录成功\",loginFailed:\"登录失败\",enterCredentials:\"请输入用户名和密码\",enterEmail:\"请输入您的电子邮件地址\",oidcLoginFailed:\"OIDC 登录失败\",oidcErrors:{providerError:\"身份提供商返回了一个错误\",missingParameters:\"OIDC 回调缺少必要参数\",invalidState:\"OIDC 状态无效或已被使用\",stateExpired:\"OIDC 登录会话已过期，请重试\",providerNotFound:\"未找到 OIDC 提供商\",discoveryFailed:\"无法获取 OIDC 发现文档\",invalidDiscovery:\"OIDC 发现文档无效\",networkError:\"OIDC 令牌交换时出现网络错误\",badResponse:\"OIDC 令牌交换时收到意外响应\",noIdToken:\"OIDC 提供商未返回 ID 令牌\",validationFailed:\"OIDC 令牌验证失败\",nonceMismatch:\"OIDC nonce 不匹配，可能存在重放攻击\",missingSubClaim:\"OIDC 令牌缺少 sub 声明\",noLinkedAccount:\"没有与此 OIDC 身份关联的本地帐户\",accountInactive:\"您的帐户已被停用\",userResolutionFailed:\"无法解析您的帐户\",internalError:\"OIDC 登录过程中发生内部错误\",tokenExchangeFailed:\"OIDC 令牌交换失败\"},forgotPasswordTitle:\"忘记密码\",forgotPasswordMessage:\"如果您忘记了密码，请联系系统管理员进行重置。\",forgotPasswordEmailMessage:\"输入您的邮箱地址，我们将向您发送新密码。\",emailAddress:\"邮箱地址\",emailPlaceholder:\"your.email@example.com\",cancel:\"取消\",sending:\"发送中...\",sendResetEmail:\"发送重置邮件\",howToReset:\"如何重置密码：\",resetStep1:\"联系您的 Bambuddy 管理员\",resetStep2:\"请他们在用户管理中重置您的密码\",resetStep3:\"他们可以为您设置一个临时密码\",resetStep4:\"使用新密码登录并在设置中修改密码\",gotIt:\"知道了\",resetPassword:{title:\"设置新密码\",subtitle:\"请在下方输入并确认您的新密码。\",newPassword:\"新密码\",newPasswordPlaceholder:\"至少 8 个字符\",confirmPassword:\"确认密码\",confirmPasswordPlaceholder:\"重复输入新密码\",saving:\"保存中…\",submit:\"设置新密码\",backToLogin:\"返回登录\",passwordsDoNotMatch:\"密码不匹配\",passwordTooShort:\"密码至少需要 8 个字符\",resetFailed:\"密码重置失败。链接可能已过期。\"},twoFA:{title:\"两步验证\",subtitle:\"您的账户已启用两步验证。请在下方输入验证码。\",methodAuthenticator:\"身份验证器应用\",methodEmail:\"邮箱验证码\",methodBackup:\"备用恢复码\",instructionsTotp:\"请打开您的身份验证器应用，输入 Bambuddy 的 6 位验证码。\",instructionsEmail:\"6 位验证码已发送至您的邮箱，有效期为 10 分钟。\",instructionsEmailNotSent:\"点击下方按钮，通过邮件获取验证码。\",instructionsBackup:\"请输入您的一个 8 位备用恢复码。每个恢复码只能使用一次。\",sendCodeButton:\"发送邮箱验证码\",sendingCode:\"发送中...\",resendCode:\"重新发送验证码\",codeLabel:\"验证码\",backupCodeLabel:\"备用恢复码\",codePlaceholder:\"000000\",backupCodePlaceholder:\"XXXXXXXX\",verifyButton:\"验证\",verifyingButton:\"验证中...\",backToLogin:\"← 返回登录页面\",orContinueWith:\"或通过以下方式登录\",signInWith:\"使用 {{provider}} 登录\",enterCode:\"请输入验证码\",sendCodeFailed:\"验证码发送失败\",invalidCode:\"无效验证码，请重试。\"}},setup:{title:\"Bambuddy 设置\",subtitle:\"为您的 Bambuddy 实例配置身份验证\",enableAuth:\"启用身份验证\",adminAccount:\"管理员账户\",adminAccountDesc:\"如果管理员用户已存在，将使用现有管理员账户启用身份验证。如需使用现有管理员，请将下方字段留空，或输入新凭据创建新管理员用户。\",adminUsername:\"管理员用户名\",adminPassword:\"管理员密码\",optionalIfAdminExists:\"（如管理员用户已存在则为可选）\",adminUsernamePlaceholder:\"输入管理员用户名（可选）\",adminPasswordPlaceholder:\"输入管理员密码（可选）\",confirmPassword:\"确认密码\",confirmPasswordPlaceholder:\"确认管理员密码\",settingUp:\"设置中...\",completeSetup:\"完成设置\",toast:{authEnabledAdminCreated:\"身份验证已启用并创建了管理员用户\",authEnabledExistingAdmins:\"使用现有管理员用户启用了身份验证\",setupCompleted:\"设置完成\",enterBothCredentials:\"请输入管理员用户名和密码，或将两者留空以使用现有管理员用户\",passwordsDoNotMatch:\"密码不匹配\",passwordTooShort:\"密码至少需要 6 个字符\"}},changePassword:{title:\"修改密码\",currentPassword:\"当前密码\",currentPasswordPlaceholder:\"输入当前密码\",newPassword:\"新密码\",newPasswordPlaceholder:\"输入新密码（至少 6 个字符）\",confirmPassword:\"确认新密码\",confirmPasswordPlaceholder:\"确认新密码\",passwordsDoNotMatch:\"密码不匹配\",passwordTooShort:\"密码至少需要 6 个字符\",changing:\"修改中...\",success:\"密码修改成功\",failed:\"密码修改失败\"},plateAlert:{title:\"打印已暂停！\",message:\"在构建板上检测到物体。打印已自动暂停。请清理打印板并继续打印。\",understand:\"我知道了\"},camera:{title:\"摄像头视图\",invalidPrinterId:\"无效的打印机 ID\",live:\"实时\",snapshot:\"快照\",restartStream:\"重启流\",refreshSnapshot:\"刷新快照\",fullscreen:\"全屏\",exitFullscreen:\"退出全屏\",connectingToCamera:\"连接摄像头中...\",capturingSnapshot:\"拍摄快照中...\",connectionLost:\"连接已断开\",connectionFailed:\"摄像头连接失败\",reconnecting:\"{{countdown}} 秒后重新连接...（第 {{attempt}}/{{max}} 次尝试）\",reconnectNow:\"立即重新连接\",cameraUnavailable:\"摄像头不可用\",cameraUnavailableDesc:\"请确保打印机已通电并已连接。\",noCamera:\"无可用摄像头\",retry:\"重试\",cameraStream:\"摄像头流\",zoomOut:\"缩小\",zoomIn:\"放大\",resetZoom:\"重置缩放\",recording:\"录制中\",startRecording:\"开始录制\",stopRecording:\"停止录制\",chamberLight:\"切换腔室灯\"},groups:{title:\"组管理\",subtitle:\"管理访问控制的权限组\",backToSettings:\"返回设置\",createGroup:\"创建组\",noPermission:\"您没有访问此页面的权限。\",system:\"系统\",noDescription:\"无描述\",usersCount:\"{{count}} 个用户\",permissionsCount:\"{{count}} 个权限\",edit:\"编辑\",delete:\"删除\",toast:{created:\"组创建成功\",updated:\"组更新成功\",deleted:\"组删除成功\",enterGroupName:\"请输入组名称\"},modal:{editGroup:\"编辑组\",createGroup:\"创建组\",cancel:\"取消\",saving:\"保存中...\",creating:\"创建中...\",saveChanges:\"保存更改\"},form:{groupName:\"组名称\",groupNamePlaceholder:\"输入组名称\",systemGroupWarning:\"系统组名称不可更改\",description:\"描述\",descriptionPlaceholder:\"输入描述（可选）\",permissions:\"权限（已选 {{count}} 个）\"},deleteModal:{title:\"删除组\",message:\"确定要删除此组吗？此组中的用户将失去这些权限。\",confirm:\"删除组\"},editor:{title:\"编辑组\",createTitle:\"创建组\",search:\"搜索权限...\",selectAll:\"全选\",clearAll:\"清除全部\",permissionsSelected:\"已选 {{count}} 个\",noResults:\"没有权限匹配您的搜索\"}},users:{title:\"用户管理\",subtitle:\"管理用户及其对 Bambuddy 实例的访问\",backToSettings:\"返回设置\",createUser:\"创建用户\",noPermission:\"您没有访问此页面的权限。\",admin:\"管理员\",noGroups:\"无组\",active:\"活跃\",inactive:\"非活跃\",edit:\"编辑\",delete:\"删除\",system:\"系统\",noGroupsAvailable:\"无可用组\",table:{username:\"用户名\",groups:\"组\",status:\"状态\",actions:\"操作\"},toast:{created:\"用户创建成功\",updated:\"用户更新成功\",deleted:\"用户删除成功\",fillRequired:\"请填写所有必填字段\",passwordsDoNotMatch:\"密码不匹配\",passwordTooShort:\"密码至少需要 6 个字符\"},modal:{createUser:\"创建用户\",editUser:\"编辑用户\",cancel:\"取消\",creating:\"创建中...\",saving:\"保存中...\",saveChanges:\"保存更改\",advancedAuthSubtitle:\"使用高级认证\"},form:{username:\"用户名\",usernamePlaceholder:\"输入用户名\",email:\"邮箱\",emailPlaceholder:\"user@example.com\",password:\"密码\",passwordPlaceholder:\"输入密码\",confirmPassword:\"确认密码\",confirmPasswordPlaceholder:\"确认密码\",newPasswordPlaceholder:\"输入新密码\",confirmNewPasswordPlaceholder:\"确认新密码\",leaveBlankToKeep:\"留空以保持当前值\",groups:\"组\",optional:\"可选\",autoGeneratedPassword:\"将自动生成安全密码并通过邮件发送给用户。\",passwordManagedByAdvancedAuth:'密码由高级认证管理。使用\"重置密码\"通过邮件向用户发送新密码。',resetPassword:\"重置密码\",resettingPassword:\"重置密码中...\"},deleteModal:{title:\"删除用户\",message:\"确定要删除此用户吗？此操作无法撤销。\",confirm:\"删除用户\"}},streamOverlay:{title:\"流叠加层\",invalidPrinterId:\"无效的打印机 ID\",cameraStream:\"摄像头流\",progress:\"进度\",eta:\"预计完成时间\",printerIdle:\"打印机空闲\",printerOffline:\"打印机离线\",status:{printing:\"打印中\",paused:\"已暂停\",finished:\"已完成\",failed:\"失败\",idle:\"空闲\",unknown:\"未知\"}},profiles:{title:\"配置文件\",subtitle:\"管理您的切片预设和压力推进校准\",tabs:{cloud:\"云端配置文件\",local:\"本地配置文件\",kprofiles:\"K 值配置\"},localProfiles:{title:\"本地配置文件\",subtitle:\"从 OrcaSlicer 导入和管理切片预设\",import:\"导入配置文件\",importDesc:\"将 .bbscfg、.bbsflmt、.orca_filament、.zip 或 .json 文件拖放到此处\",importing:\"导入中...\",search:\"搜索本地预设...\",noPresets:\"暂无本地预设\",badge:\"本地\",edit:\"编辑\",delete:\"删除\",cancel:\"取消\",deleteConfirmTitle:\"删除预设\",deleteConfirm:\"确定要删除此预设吗？此操作无法撤销。\",source:\"来源\",inheritsFrom:\"继承自\",filamentType:\"类型\",vendor:\"厂商\",compatiblePrinters:\"兼容打印机\",nozzleTemp:\"喷嘴温度\",cost:\"成本\",density:\"密度\",pressureAdvance:\"压力推进\",filament:\"耗材\",process:\"工艺\",printer:\"打印机\",toast:{importSuccess:\"已导入 {{count}} 个预设\",importSkipped:\"跳过 {{count}} 个预设（重复）\",importError:\"导入时出现 {{count}} 个错误\",deleted:\"预设已删除\",updated:\"预设已更新\"}},connectedAs:\"已连接为\",logout:\"退出登录\",noLogoutPermission:\"您没有退出登录的权限\",failedToLoad:\"加载配置文件失败\",retry:\"重试\",time:{justNow:\"刚刚\",minsAgo:\"{{count}} 分钟前\",hoursAgo:\"{{count}} 小时前\",daysAgo:\"{{count}} 天前\"},toast:{loggedOut:\"已退出登录\"},login:{title:\"连接到拓竹云\",subtitle:\"跨设备同步您的切片预设\",email:\"邮箱\",password:\"密码\",region:\"地区\",regionGlobal:\"全球\",regionChina:\"中国\",verificationCode:\"验证码\",totpCode:\"验证器代码\",checkEmail:\"检查您的邮箱 ({{email}}) 获取 6 位验证码\",enterTotpHint:\"输入验证器应用中的 6 位代码\",accessToken:\"访问令牌\",accessTokenHint:\"粘贴您的拓竹访问令牌（来自 Bambu Studio）\",back:\"返回\",loginButton:\"登录\",verifyButton:\"验证\",setTokenButton:\"设置令牌\",useToken:\"改用访问令牌\",useEmail:\"改用邮箱登录\",toast:{loggedIn:\"登录成功\",codeSent:\"验证码已发送到您的邮箱\",enterTotp:\"输入验证器应用中的代码\",tokenSet:\"令牌设置成功\"}},presets:{myPreset:\"我的预设（可编辑）\",duplicate:\"复制\",editable:\"可编辑\",failedToLoadDetails:\"加载预设详情失败\",deleteConfirm:\"删除此预设？\",deleteWarning:'这将从拓竹云中永久删除\"{{name}}\"。此操作无法撤销。',noDuplicatePermission:\"您没有复制预设的权限\",noEditPermission:\"您没有编辑预设的权限\",noDeletePermission:\"您没有删除预设的权限\",types:{filament:\"耗材预设\",printer:\"打印机预设\",process:\"工艺预设\"},toast:{deleted:\"预设已删除\",created:\"预设已创建\",updated:\"预设已更新\",duplicated:\"预设已复制\",fieldAdded:'字段\"{{key}}\"已添加',exported:\"预设已导出\"},baseLabel:\"基础：{{name}}\",currentLabel:\"当前：{{name}}\",newPreset:\"新建预设\",editPreset:\"编辑预设\",duplicatePreset:\"复制预设\",createNewPreset:\"创建新预设\",customizeSettings:\"自定义新预设的设置\",compareWithBase:\"与基础预设比较\",compare:\"比较\",basePreset:\"基础预设\",selectBasePreset:\"选择基础预设...\",presetName:\"预设名称\",myCustomPreset:\"我的自定义预设\",inheritsFrom:\"继承自\",dropJsonToImport:\"拖放 JSON 以导入\",tabs:{common:\"常用\",allFields:\"所有字段\"},availableFields:\"可用字段\",searchFieldsPlaceholder:\"搜索字段...\",noMatchingFields:\"没有匹配的字段\",allFieldsAdded:\"所有字段已添加\",addCustomField:\"添加自定义字段\",yourOverrides:\"您的覆盖值\",noOverridesYet:\"暂无覆盖值\",clickFieldsToAdd:\"点击左侧的字段进行添加\",saveAsTemplate:\"保存为模板\",jsonTip:\"提示：将 .json 文件拖放到此对话框的任意位置以导入设置\"},cloudView:{searchPlaceholder:\"搜索预设...\",templates:\"模板\",refresh:\"刷新\",newPreset:\"新建预设\",clearFilters:\"清除筛选\",compareMode:\"比较模式\",selectAnotherPreset:\"选择另一个 {{type}} 预设\",clickTwoPresets:\"点击两个相同类型的预设进行比较\",selectFirst:\"1. 选择第一个\",selectSecond:\"2. 选择第二个\",compareNow:\"立即比较\",lastSynced:\"上次同步：\",showingCount:\"显示 {{showing}} / {{total}} 个预设\",noPresetsFound:\"未找到预设\",columns:{filament:\"耗材\",process:\"工艺\",printer:\"打印机\"},noFilamentPresets:\"无耗材预设\",noProcessPresets:\"无工艺预设\",noPrinterPresets:\"无打印机预设\",filters:{type:\"类型\",owner:\"所有者\",printer:\"打印机\",nozzle:\"喷嘴\",filament:\"耗材\",layer:\"层\",all:\"全部\",myPresets:\"我的预设\",builtIn:\"内置\",process:\"工艺\"},noTemplatesPermission:\"您没有管理模板的权限\",noRefreshPermission:\"您没有刷新配置文件的权限\",noCreatePermission:\"您没有创建预设的权限\"},templates:{title:\"快速模板\",noTemplates:\"暂无模板\",createFirst:\"从预设编辑器创建模板\",typeFilter:\"类型：\",deleteTitle:\"删除模板\",deleteWarning:\"此操作无法撤销\",deleteConfirm:'确定要删除\"{{name}}\"吗？',namePlaceholder:\"模板名称\",descriptionPlaceholder:\"描述\",settingsJson:\"设置 (JSON)\",fieldsCount:\"{{count}} 个字段\",shownInModals:\"在对话框中显示\",hiddenInModals:\"在对话框中隐藏\",apply:\"应用\",toast:{deleted:\"模板已删除\",updated:\"模板已更新\",created:\"模板已创建\",applied:\"模板已应用\"}}},support:{debugLoggingActive:\"调试日志记录已激活\",manageLogs:\"管理\",collectItem7:\"打印机连接和固件版本\",collectItem8:\"集成状态（Spoolman、MQTT、HA）\",collectItem9:\"网络接口（仅子网）\",collectItem10:\"Python 包版本\",collectItem11:\"数据库健康检查\",collectItem12:\"Docker 环境详情\"},fileManager:{title:\"文件管理器\",subtitle:\"组织和管理您的打印文件\",uploadFiles:\"上传文件\",newFolder:\"新建文件夹\",folderName:\"文件夹名称\",folderNamePlaceholder:\"例如：功能零件\",renameFile:\"重命名文件\",renameFolder:\"重命名文件夹\",moveFiles:\"移动 {{count}} 个文件\",rootNoFolder:\"根目录（无文件夹）\",current:\"当前\",linkFolder:\"链接文件夹\",linkFolderDescription:'将\"{{name}}\"链接到项目或归档以便快速访问。',project:\"项目\",archive:\"归档\",noProjectsFound:\"未找到项目\",noArchivesFound:\"未找到归档\",unlink:\"取消链接\",link:\"链接\",dragDropFiles:\"将文件拖放到此处\",dropFilesHere:\"将文件放在此处\",orClickToBrowse:\"或点击浏览\",allFileTypesSupported:\"支持所有文件类型。ZIP 文件将被解压。\",zipFilesDetected:\"检测到 ZIP 文件\",zipExtractOptions:\"ZIP 文件将被解压。选择如何处理文件夹结构：\",preserveZipStructure:\"保留 ZIP 中的文件夹结构\",createFolderFromZip:\"从 ZIP 文件名创建文件夹\",stlThumbnailGeneration:\"STL 缩略图生成\",zipMayContainStl:\"ZIP 文件可能包含 STL 文件。可以在解压时生成缩略图。\",thumbnailsCanBeGenerated:\"可以为 STL 文件生成缩略图。大型模型可能需要更长时间处理。\",generateThumbnailsForStl:\"为 STL 文件生成缩略图\",threemfDetected:\"检测到 3MF 文件\",threemfExtractionInfo:\"将自动从 3MF 文件中提取打印机型号、材料、颜色和打印设置。\",willBeExtracted:\"将被解压\",filesExtracted:\"已解压 {{count}} 个文件\",uploadComplete:\"上传完成：{{succeeded}} 个成功\",uploadFailed:\"上传失败\",zipFilesFailed:\"{{count}} 个文件失败\",uploading:\"上传中...\",changeLink:\"更改链接...\",linkTo:\"链接到...\",linkToProjectOrArchive:\"链接到项目或归档\",addToQueue:\"添加到队列\",schedulePrint:\"排程\",generateThumbnail:\"生成缩略图\",generateThumbnails:\"生成缩略图\",generateThumbnailsForMissing:\"为缺少缩略图的 STL 文件生成缩略图\",gridView:\"网格视图\",listView:\"列表视图\",lowDiskSpaceWarning:\"磁盘空间不足警告\",lowDiskSpaceDetails:\"仅剩 {{free}}（总共 {{total}}）。阈值设置为 {{threshold}} GB。\",files:\"文件\",folders:\"文件夹\",size:\"大小\",free:\"剩余\",allFiles:\"所有文件\",wrap:\"换行\",enableTextWrapping:\"启用文本换行\",disableTextWrapping:\"禁用文本换行\",collapse:\"折叠\",collapseFoldersByDefault:\"默认折叠文件夹\",expandFoldersByDefault:\"默认展开文件夹\",dragToResizeTooltip:\"拖动调整大小，双击重置\",searchFiles:\"搜索文件...\",allTypes:\"所有类型\",prints:\"打印\",ascending:\"升序\",descending:\"降序\",resultsCount:\"{{showing}} / {{total}} 个文件\",selectAll:\"全选\",deselectAll:\"取消全选\",selected:\"已选择 {{count}} 个\",adding:\"添加中...\",loadingFiles:\"加载文件中...\",folderIsEmpty:\"文件夹为空\",noFilesYet:\"暂无文件\",folderEmptyDescription:\"上传文件或将文件移入此文件夹以开始使用。\",noFilesDescription:\"上传文件以开始组织您的打印相关文件。\",noMatchingFiles:\"没有匹配的文件\",noMatchingFilesDescription:\"没有文件匹配您当前的搜索或筛选条件。\",clearFilters:\"清除筛选\",printedCount:\"已打印 {{count}} 次\",uploadedBy:\"上传者\",deleteFolder:\"删除文件夹\",deleteFile:\"删除文件\",deleteFilesCount:\"删除 {{count}} 个文件\",deleteFolderConfirm:\"确定要删除此文件夹吗？其中的所有文件也将被删除。\",deleteFileConfirm:\"确定要删除此文件吗？\",deleteFilesConfirm:\"确定要删除 {{count}} 个选中的文件吗？此操作无法撤销。\",deleting:\"删除中...\",noPermissionRenameFolder:\"您没有重命名文件夹的权限\",noPermissionLinkFolder:\"您没有链接文件夹的权限\",noPermissionDeleteFolder:\"您没有删除文件夹的权限\",noPermissionPrint:\"您没有打印的权限\",noPermissionAddToQueue:\"您没有添加到队列的权限\",noPermissionDownload:\"您没有下载文件的权限\",noPermissionRenameFile:\"您没有重命名此文件的权限\",noPermissionGenerateThumbnail:\"您没有生成缩略图的权限\",noPermissionDeleteFile:\"您没有删除此文件的权限\",noPermissionCreateFolder:\"您没有创建文件夹的权限\",noPermissionUpload:\"您没有上传文件的权限\",noPermissionMoveFiles:\"您没有移动文件的权限\",noPermissionDeleteFiles:\"您没有删除文件的权限\",linkExternal:\"链接外部\",linkExternalFolder:\"链接外部文件夹\",linkExternalFolderDescription:\"将主机目录（NAS、USB、网络共享）挂载到文件管理器中。文件不会被复制——直接从原始路径访问。\",externalFolderNamePlaceholder:\"例如：NAS打印文件\",externalPath:\"主机路径\",externalPathHelp:\"Docker主机上目录的绝对路径。必须以绑定挂载方式挂载到容器中。\",readOnly:\"只读\",readOnlyHelp:\"防止上传和删除\",showHiddenFiles:\"显示隐藏文件（点文件）\",externalFolder:\"外部文件夹\",scanFolder:\"扫描\",toast:{folderCreated:\"文件夹已创建\",folderDeleted:\"文件夹已删除\",fileDeleted:\"文件已删除\",filesDeleted:\"已删除 {{count}} 个文件\",filesMoved:\"文件已移动\",folderLinked:\"文件夹已链接\",folderUnlinked:\"文件夹已取消链接\",externalFolderLinked:\"外部文件夹已链接并扫描\",folderScanned:\"扫描完成：添加 {{added}} 个，移除 {{removed}} 个\",addedToQueue:\"已将 {{count}} 个文件添加到队列\",addedToQueuePartial:\"已添加 {{added}} 个文件，{{failed}} 个失败\",failedToAddToQueue:\"添加文件失败：{{error}}\",fileRenamed:\"文件已重命名\",folderRenamed:\"文件夹已重命名\",thumbnailsGenerated:\"已生成 {{count}} 个缩略图\",thumbnailsGeneratedPartial:\"已生成 {{succeeded}} 个缩略图，{{failed}} 个失败\",noStlMissingThumbnails:\"没有缺少缩略图的 STL 文件\",failedToGenerateThumbnails:\"生成缩略图失败：{{error}}\",thumbnailGenerated:\"缩略图已生成\",failedToGenerateThumbnail:\"生成缩略图失败：{{error}}\"}},projects:{title:\"项目\",subtitle:\"组织和跟踪您的 3D 打印项目\",newProject:\"新建项目\",editProject:\"编辑项目\",deleteProject:\"删除项目\",projectName:\"项目名称\",description:\"描述\",noProjects:\"暂无项目\",noProjectsFiltered:\"没有{{status}}项目\",noProjectsFilteredHelp:\"您没有任何{{status}}项目。当项目状态更改时，它们将出现在这里。\",createFirst:\"创建您的第一个项目以开始组织相关打印、跟踪进度和管理构建。\",createFirstButton:\"创建您的第一个项目\",create:\"创建\",files:\"文件\",prints:\"打印\",plates:\"板\",parts:\"零件\",lastModified:\"最后修改\",deleteConfirm:\"确定要删除此项目吗？归档和队列项目将被取消链接但不会被删除。\",addFiles:\"添加文件\",removeFile:\"移除文件\",viewDetails:\"查看详情\",namePlaceholder:\"例如：Voron 2.4 构建\",descriptionPlaceholder:\"可选描述...\",color:\"颜色\",targetPlates:\"目标板数\",targetPlatesPlaceholder:\"例如：25\",targetPlatesHelp:\"打印任务数量\",targetParts:\"目标零件数\",targetPartsPlaceholder:\"例如：150\",targetPartsHelp:\"所需零件总数\",tagsLabel:\"标签（逗号分隔）\",tagsPlaceholder:\"例如：voron、功能件、礼物\",dueDate:\"截止日期\",priority:\"优先级\",priorityLow:\"低\",priorityNormal:\"普通\",priorityHigh:\"高\",priorityUrgent:\"紧急\",statusActive:\"进行中\",statusCompleted:\"已完成\",statusArchived:\"已归档\",done:\"完成\",completed:\"已完成\",failed:\"失败\",inQueue:\"队列中\",noPrintsYet:\"暂无打印\",printJobs:\"打印任务（板）\",partsPrinted:\"已打印零件\",failedParts:\"失败零件\",import:\"导入\",export:\"导出\",importProject:\"导入项目\",exportAll:\"导出所有项目\",loading:\"加载项目中...\",noEditPermission:\"您没有编辑项目的权限\",noDeletePermission:\"您没有删除项目的权限\",noCreatePermission:\"您没有创建项目的权限\",noImportPermission:\"您没有导入项目的权限\",noExportPermission:\"您没有导出项目的权限\",toast:{created:\"项目已创建\",updated:\"项目已更新\",deleted:\"项目已删除\",imported:\"项目已导入\",multipleImported:\"已导入 {{count}} 个项目\",importFailed:\"导入失败\",exported:\"项目已导出（仅元数据）\"}},projectDetail:{notFound:\"未找到项目\",backToProjects:\"返回项目\",export:\"导出\",exportProject:\"导出项目\",noExportPermission:\"您没有导出项目的权限\",noEditPermission:\"您没有编辑项目的权限\",partOf:\"属于：\",priorityLabel:\"优先级：\",noPrints:\"此项目暂无打印\",status:{active:\"进行中\",completed:\"已完成\",archived:\"已归档\"},priority:{low:\"低\",normal:\"普通\",high:\"高\",urgent:\"紧急\"},dueDate:{overdue:\"已逾期\",today:\"今天到期\",daysLeft:\"还有 {{count}} 天\"},progress:{platesProgress:\"板进度\",partsProgress:\"零件进度\",printJobs:\"打印任务\",parts:\"零件\",percentComplete:\"{{percent}}% 完成\",remaining:\"剩余 {{count}} 个\"},stats:{printJobs:\"打印任务\",total:\"总计\",failed:\"{{count}} 个失败\",partsPrinted:\"已打印 {{count}} 个零件\",printTime:\"打印时间\",filamentUsed:\"耗材用量\"},cost:{title:\"成本追踪\",filamentCost:\"耗材成本\",energy:\"能源\",totalCost:\"总成本\",total:\"总计\",includesBom:\"含物料清单\",budget:\"预算\",remaining:\"剩余\"},subProjects:{title:\"子项目 ({{count}})\"},notes:{title:\"备注\",noEditPermission:\"您没有编辑备注的权限\",placeholder:\"添加关于此项目的备注...\",empty:\"暂无备注。点击编辑添加备注。\"},files:{title:\"文件\",linkFolders:\"从文件管理器链接文件夹\",forQuickAccess:\"到此项目以便快速访问。\",fileCount:\"{{count}} 个文件\",empty:\"未链接文件夹。前往文件管理器将文件夹链接到此项目。\",noFiles:\"此文件夹中没有文件。\",print:\"立即打印\",addToQueue:\"加入队列\"},bom:{title:\"材料清单\",acquired:\"已获取 {{completed}}/{{total}}\",showAll:\"显示全部\",hideDone:\"隐藏已完成\",addPart:\"添加零件\",noAddPermission:\"您没有添加零件的权限\",partNamePlaceholder:\"零件名称（例如：M3x8 螺丝）\",partName:\"零件名称\",qty:\"数量\",price:\"价格 ({{currency}})\",sourcingUrlPlaceholder:\"采购链接（可选）\",remarksPlaceholder:\"备注（可选）\",deletePart:\"删除零件\",deleteConfirm:'确定要删除\"{{name}}\"吗？',noUpdatePermission:\"您没有更新零件的权限\",noEditPermission:\"您没有编辑零件的权限\",noDeletePermission:\"您没有删除零件的权限\",totalCost:\"总成本：\",empty:\"材料清单中没有零件。添加硬件、电子元件或其他组件以跟踪需要采购的物品。\"},timeline:{title:\"活动时间线\",empty:\"暂无活动。\"},template:{saveAsTemplate:\"保存为模板\",noCreatePermission:\"您没有创建模板的权限\"},queue:{title:\"队列\",viewAll:\"查看全部\",printing:\"{{count}} 个打印中\",queued:\"{{count}} 个排队中\"},prints:{title:\"打印 ({{count}})\"},toast:{projectUpdated:\"项目已更新\",partAdded:\"零件已添加\",partRemoved:\"零件已移除\",exportFailed:\"导出失败\",projectExported:\"项目已导出\",templateCreated:\"模板已创建\"}},system:{title:\"系统信息\",version:\"版本\",uptime:\"运行时间\",cpuUsage:\"CPU 使用率\",memoryUsage:\"内存使用率\",diskUsage:\"磁盘使用率\",networkInfo:\"网络信息\",logs:\"日志\",debugMode:\"调试模式\",enableDebug:\"启用调试日志\",disableDebug:\"禁用调试日志\",downloadLogs:\"下载日志\",clearLogs:\"清除日志\",dockerInfo:\"Docker 信息\",containerName:\"容器名称\",imageName:\"镜像名称\",platform:\"平台\",architecture:\"架构\"},library:{title:\"耗材库\",addFilament:\"添加耗材\",editFilament:\"编辑耗材\",deleteFilament:\"删除耗材\",vendor:\"厂商\",material:\"材料\",color:\"颜色\",kFactor:\"K 值\",temperature:\"温度\",noFilaments:\"耗材库中没有耗材\",deleteConfirm:\"确定要删除此耗材吗？\",importFromPrinter:\"从打印机导入\",exportToFile:\"导出到文件\"},spoolman:{title:\"Spoolman 集成\",enabled:\"Spoolman 已启用\",url:\"Spoolman URL\",connected:\"已连接\",disconnected:\"未连接\",testConnection:\"测试连接\",sync:\"同步\",syncing:\"同步中...\",lastSync:\"上次同步\",linkToSpoolman:\"链接到 Spoolman\",openInSpoolman:\"在 Spoolman 中打开\",unlinkSpool:\"取消链接耗材\",unlinkConfirmTitle:\"解开线轴？\",unlinkConfirmMessage:\"这将断开卷轴与 Spoolman 的连接。Spoolman 中的卷轴数据将保持不变。\",selectSpool:\"选择耗材\",noUnlinkedSpools:\"无未链接的耗材\",linkSuccess:\"耗材已成功链接到 Spoolman\",linkFailed:\"链接耗材失败\",unlinkSuccess:\"已成功从 Spoolman 取消链接耗材\",unlinkFailed:\"取消链接耗材失败\",spoolId:\"耗材 ID\",fillSourceLabel:\"(Spoolman)\",weight:\"重量\",remaining:\"剩余\",disableWeightSync:\"禁用 AMS 估计重量同步\",disableWeightSyncDesc:\"不从 AMS 估计值更新剩余容量。如果您更喜欢 Spoolman 的用量追踪而非 AMS 百分比估计，请使用此选项。新耗材仍将使用 AMS 估计值作为初始重量。\",reportPartialUsage:\"报告失败打印的部分用量\",reportPartialUsageDesc:\"当打印失败或被取消时，根据层进度报告估计的耗材使用量。\"},inventory:{title:\"耗材库存\",addSpool:\"添加耗材\",editSpool:\"编辑耗材\",material:\"材料\",selectMaterial:\"选择材料...\",subtype:\"子类型\",brand:\"品牌\",searchBrand:\"搜索品牌...\",useCustomBrand:'使用\"{{brand}}\"',useCustomMaterial:\"使用自定义材料：{{material}}\",colorName:\"颜色名称\",colorNamePlaceholder:\"翡翠白、烈焰红...\",color:\"颜色\",hexColor:\"十六进制颜色\",pickColor:\"选择自定义颜色\",labelWeight:\"标签重量\",coreWeight:\"空盘重量\",searchSpoolWeight:\"搜索耗材重量...\",weightUsed:\"已使用\",currentWeight:\"剩余重量\",measuredWeight:\"称量重量\",spoolName:\"线轴\",costPerKg:\"每公斤成本\",measuredWeightError:\"称量重量必须在 {{min}}g 到 {{max}}g 之间。\",slicerFilament:\"切片耗材\",slicerFilamentName:\"切片预设名称\",slicerPreset:\"切片预设\",searchPresets:\"搜索耗材预设...\",selectedPreset:\"已选择\",noPresetsFound:\"未找到预设\",tempOverrides:\"温度覆盖\",note:\"备注\",notePlaceholder:\"关于此耗材的任何备注...\",archive:\"归档\",restore:\"恢复\",noSpools:\"暂无耗材。添加您的第一个耗材开始使用。\",noManualSpools:\"没有手动添加的耗材。请先向库存中添加耗材。\",kProfiles:\"K 值配置\",addKProfile:\"添加 K 值配置\",assignSpool:\"分配耗材\",unassignSpool:\"取消分配\",assignSuccess:\"耗材已分配，AMS 槽位已配置\",assignFailed:\"分配耗材失败\",assignMismatchTitle:\"材料不匹配\",assignMismatchMessage:'所选线轴材料 \"{{spoolMaterial}}\" 与 {{location}} 的料槽材料 \"{{trayMaterial}}\" 不匹配。仍要分配吗？',assignMismatchConfirm:\"仍然分配\",assignPartialMismatchMessage:'线轴材料 \"{{spoolMaterial}}\" 与 {{location}} 的 \"{{trayMaterial}}\" 相近但不完全一致。是否继续？',assignProfileMismatchMessage:'线轴配置 \"{{spoolProfile}}\" 与 {{location}} 的料槽配置 \"{{trayProfile}}\" 不一致。是否继续？',selectSpool:\"选择要分配到此槽位的耗材\",assigned:\"已分配\",assigning:\"分配中...\",searchSpools:\"搜索耗材...\",showAllSpools:\"显示所有耗材\",allMaterials:\"所有材料\",filterByBrand:\"按品牌筛选...\",showArchived:\"显示已归档\",quickAdd:\"快速添加（库存）\",quantity:\"数量\",stock:\"库存\",configured:\"已配置\",spoolsCreated:\"已创建 {{count}} 个耗材\",spoolCreated:\"耗材已创建\",spoolUpdated:\"耗材已更新\",spoolDeleted:\"耗材已删除\",spoolArchived:\"耗材已归档\",spoolRestored:\"耗材已恢复\",deleteConfirm:\"确定要删除此耗材吗？此操作无法撤销。\",archiveConfirm:\"确定要归档此耗材吗？\",advancedSettings:\"高级设置\",filamentInfoTab:\"耗材信息\",paProfileTab:\"PA 配置\",filamentInfo:\"耗材\",additional:\"附加\",loadingPresets:\"加载云端预设中...\",cloudConnected:\"云端已连接\",cloudNotConnected:\"云端未连接（使用默认值）\",recentColors:\"最近\",searchColors:\"搜索颜色...\",searchResults:\"搜索结果\",allColors:\"所有颜色\",commonColors:\"常用颜色\",showLess:\"显示更少\",showAll:\"显示全部\",noColorsFound:\"没有颜色匹配您的搜索\",noResults:\"未找到匹配项\",selectMaterialFirst:\"请先在耗材信息选项卡中选择材料。\",noPrintersConfigured:\"未配置打印机。添加打印机以使用 PA 配置。\",matchingFilter:\"匹配\",anyBrand:\"任何品牌\",anyVariant:\"任何变体\",autoSelect:\"自动选择\",matches:\"匹配\",match:\"匹配\",noMatches:\"无匹配\",connected:\"已连接\",offline:\"离线\",printerOffline:\"打印机离线。连接后查看校准配置。\",noKProfilesMatch:\"没有 K 值配置匹配所选耗材。\",leftNozzle:\"左喷嘴\",rightNozzle:\"右喷嘴\",profilesSelected:\"个校准配置已选择\",totalInventory:\"总库存\",totalConsumed:\"总消耗\",byMaterial:\"按材料\",inPrinter:\"在打印机中\",lowStock:\"库存不足\",sinceTracking:\"自开始追踪\",loadedInAms:\"已装载到 AMS/外置\",remaining:\"剩余\",weightCheck:\"重量检查\",lastWeighed:\"上次称量\",neverWeighed:\"从未称量\",search:\"搜索耗材...\",showing:\"显示\",to:\"到\",of:\"共\",show:\"显示\",spools:\"个耗材\",spool:\"个耗材\",page:\"页\",noSpoolsMatch:\"未找到结果\",noSpoolsMatchDesc:\"尝试调整您的搜索或筛选条件。\",active:\"活跃\",archived:\"已归档\",all:\"全部\",used:\"已使用\",new:\"新的\",clearFilters:\"清除筛选\",table:\"表格\",cards:\"卡片\",net:\"净重\",groupSimilar:\"分组\",groupedSpools:\"{{count}} 个相同耗材\",groupedRows:\"行\",columns:\"列\",configureColumns:\"配置列\",configureColumnsDesc:\"拖动以重新排序列或使用箭头。使用眼睛图标切换可见性。\",visible:\"可见\",reset:\"重置\",cancel:\"取消\",applyChanges:\"应用更改\",moveUp:\"上移\",moveDown:\"下移\",hideColumn:\"隐藏列\",showColumn:\"显示列\",linkToSpool:\"链接到耗材\",tagLinked:\"标签已链接到耗材\",tagLinkFailed:\"链接标签失败\",tagAlreadyLinked:\"标签已链接到其他耗材\",unknownTag:\"检测到未知 RFID 标签\",usageHistory:\"使用历史\",noUsageHistory:\"暂无使用记录\",printName:\"打印名称\",weightConsumed:\"消耗重量\",clearHistory:\"清除\",historyCleared:\"使用历史已清除\",fillSourceLabel:\"(库存)\",lowStockThresholdError:\"阈值必须在 0.1 到 99.9 之间\"},timelapse:{title:\"延时摄影\",create:\"创建延时摄影\",download:\"下载\",delete:\"删除\",preview:\"预览\",frameRate:\"帧率\",quality:\"质量\",processing:\"处理中...\",noTimelapses:\"无可用延时摄影\"},ams:{title:\"AMS\",slot:\"槽位\",empty:\"空\",emptySlot:\"空槽位\",unknown:\"未知\",humidity:\"湿度\",temperature:\"温度\",filamentType:\"耗材类型\",filamentColor:\"颜色\",remaining:\"剩余\",history:\"AMS 历史\",noHistory:\"无可用历史\",configureSlot:\"配置槽位\",externalSpool:\"外置耗材\",profile:\"配置\",kFactor:\"K 值\",fill:\"填充\",configure:\"配置\",used:\"已使用\",remainingUnit:\"剩余\"},printModal:{title:\"开始打印\",selectPrinter:\"选择打印机\",selectPlate:\"选择板\",filamentMapping:\"耗材映射\",totalCost:\"总成本：\",slotRemainingShort:\" - 剩余 {{grams}}g\",printSettings:\"打印设置\",bedLeveling:\"热床调平\",flowCalibration:\"流量校准\",vibrationCalibration:\"振动校准\",layerInspection:\"首层检查\",timelapse:\"延时摄影\",startPrint:\"开始打印\",addToQueue:\"添加到队列\",cancel:\"取消\",noPrintersAvailable:\"无可用打印机\",printerBusy:\"打印机忙碌\",printerOffline:\"打印机离线\",sameTypeDifferentColor:\"相同类型，不同颜色\",filamentTypeNotLoaded:\"耗材类型未装载\",openCalendar:\"打开日历\",leftNozzle:\"左\",rightNozzle:\"右\",leftNozzleTooltip:\"左喷嘴\",rightNozzleTooltip:\"右喷嘴\",filamentOverride:\"耗材覆盖\",filamentOverrideHint:\"可选覆盖用于基于模型的耗材分配。调度器将使用您选择的耗材而不是原始 3MF 值进行匹配。\",originalFilament:\"原始\",overrideWith:\"覆盖为\",resetToOriginal:\"恢复为原始\",insufficientFilamentTitle:\"耗材不足\",insufficientFilamentMessage:\"部分已分配线轴的剩余耗材少于本次打印所需：\",insufficientFilamentLine:\"{{printer}} - {{slot}}：需要 {{required}}g，剩余 {{remaining}}g\",printAnyway:\"仍然打印\",forceColorMatch:\"强制颜色匹配\",staggerPrinterStarts:\"Stagger printer starts\",staggerGroupSize:\"Group size\",staggerInterval:\"Interval (min)\",staggerPreview:\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",staggerLastGroup:\"last group: {{count}}\",staggerTotal:\"total: {{minutes}} min\",staggerToPrinters:\"分批发送到 {{count}} 台打印机\",gcodeInjection:\"注入自动打印G-code\"},backup:{title:\"备份与恢复\",createBackup:\"创建备份\",restoreBackup:\"恢复备份\",restoreDescription:\"从备份文件替换所有数据\",downloadBackup:\"下载备份\",uploadBackup:\"上传备份\",lastBackup:\"上次备份\",autoBackup:\"自动备份\",backupNow:\"立即备份\",restoreWarning:\"警告：恢复备份将覆盖所有当前数据。\",includeArchives:\"包含归档\",includeSettings:\"包含设置\",includeProfiles:\"包含配置文件\",backupSuccess:\"备份创建成功\",restoreSuccess:\"备份恢复成功\",backupFailed:\"备份失败\",restoreFailed:\"恢复失败\",restoreNote:\"恢复期间虚拟打印机将停止\",githubBackup:\"GitHub 备份\",enabled:\"已启用\",cloudLoginRequired:\"需要登录 Bambu Cloud。请在 配置文件 → 云配置文件 中登录以启用 GitHub 备份。\",cloudLoginRequiredShort:\"需要Cloud登录\",githubDescription:\"自动将您的配置文件同步到私有 GitHub 仓库以进行备份和版本历史记录。\",repositoryUrl:\"仓库 URL\",personalAccessToken:\"个人访问令牌\",tokenSaved:\"（已保存）\",enterNewToken:\"输入新令牌以更新\",tokenHint:\"具有内容读写权限的细粒度令牌\",branch:\"分支\",manualOnly:\"仅手动\",hourly:\"每小时\",daily:\"每天\",weekly:\"每周\",includeInBackup:\"包含在备份中\",kProfiles:\"K 配置文件\",kProfilesDescription:\"来自已连接打印机的压力推进校准\",noPrintersConnected:\"没有打印机连接\",printersConnected:\"{{connected}}/{{total}} 已连接\",cloudProfiles:\"云配置文件\",cloudProfilesDescription:\"来自 Bambu Cloud 的耗材、打印机和工艺预设\",appSettings:\"应用设置\",appSettingsDescription:\"Bambuddy 配置（完整数据库）\",spoolInventory:\"耗材库存\",spoolInventoryDescription:\"耗材卷轴、使用记录和成本追踪\",printArchives:\"打印档案\",printArchivesDescription:\"打印历史元数据（不含 gcode/3MF 文件）\",lastBackupAt:\"上次备份：\",noBackupsYet:\"尚无备份\",next:\"下次：\",startingBackup:\"正在启动备份...\",test:\"测试\",enableBackup:\"启用备份\",testConnection:\"测试连接\",enterRepoUrl:\"请输入仓库 URL\",enterRepoAndToken:\"请输入仓库 URL 和访问令牌\",repoRequired:\"仓库 URL 为必填项\",tokenRequired:\"访问令牌为必填项\",githubBackupEnabled:\"GitHub 备份已启用\",tokenUpdated:\"令牌已更新\",settingsSaved:\"设置已保存\",failedToSave:\"保存失败：{{message}}\",backupCompleteFiles:\"备份完成 - {{count}} 个文件已更新\",backupSkippedNoChanges:\"备份已跳过 - 无更改\",backupFailed2:\"备份失败：{{message}}\",clearedLogs:\"已清除 {{count}} 条日志\",failedToClearLogs:\"清除日志失败：{{message}}\",history:\"历史记录\",clear:\"清除\",date:\"日期\",status:\"状态\",commit:\"提交\",localBackup:\"本地备份\",localBackupDescription:\"创建 Bambuddy 数据的完整备份，包括数据库、档案、上传和所有文件。\",downloadBackupLabel:\"下载备份\",completeBackupZip:\"完整备份：数据库 + 所有文件（ZIP）\",download:\"下载\",preparingBackup:\"正在准备备份...\",creatingArchive:\"正在创建备份归档...对于大型归档可能需要一些时间。\",downloadingFile:\"正在下载备份文件...\",backupDownloaded:\"备份下载成功\",failedToCreateBackup:\"创建备份失败：{{message}}\",restore:\"恢复\",restoreReplacesAll:\"恢复将替换所有数据。\",restoreReplacesAllDetail:\"您当前的数据库和文件将被完全替换。恢复后需要重启。\",restoreConfirmTitle:\"恢复备份\",restoreConfirmMessage:'您确定要从\"{{filename}}\"恢复吗？这将完全替换您当前的数据库和所有文件。恢复后需要重启应用程序。',restoreConfirmButton:\"恢复备份\",uploadingFile:\"正在上传备份文件...\",backupRestoredRestart:\"备份已恢复。请重启 Bambuddy。\",failedToRestore:\"恢复备份失败。请检查文件格式。\",reloadNow:\"立即重新加载\",creatingBackup:\"正在创建备份\",restoringBackup:\"正在恢复备份\",preparing:\"准备中...\",processing:\"处理中...\",doNotClosePage:\"请不要关闭此页面或导航离开。对于大型备份，此操作可能需要几分钟。\",restoring:\"恢复中...\",restoreComplete:\"恢复完成\",restoreFailed2:\"恢复失败\",importSettings:\"从备份文件导入设置\",pleaseWaitRestoring:\"请等待数据恢复中\",selectBackupFile:\"点击选择备份文件（.json 或 .zip）\",duplicateHandling:\"重复项处理方式：\",matchPrinters:\"打印机\",matchPrintersBy:\"按序列号匹配\",matchSmartPlugs:\"智能插座\",matchSmartPlugsBy:\"按 IP 地址匹配\",matchNotificationProviders:\"通知提供者\",matchNotificationProvidersBy:\"按名称匹配\",matchFilaments:\"耗材\",matchFilamentsBy:\"按名称 + 类型 + 品牌匹配\",matchArchives:\"档案\",matchArchivesBy:\"按内容哈希匹配（始终跳过）\",matchPendingUploads:\"待上传\",matchPendingUploadsBy:\"按文件名匹配\",matchSettingsTemplates:\"设置和模板\",matchSettingsTemplatesBy:\"始终覆盖\",replaceExisting:\"替换现有数据\",keepExisting:\"保留现有数据\",overwriteDescription:\"用备份数据覆盖已存在的项目\",keepDescription:\"仅恢复尚不存在的项目\",overwriteCaution:\"注意：\",overwriteWarning:\"覆盖将用备份数据替换您当前的配置。出于安全考虑，打印机访问代码永远不会被覆盖。\",cancel:\"取消\",processingBackup:\"正在处理备份文件...\",itemsRestored:\"已恢复项目\",itemsSkipped:\"已跳过项目\",restored:\"已恢复\",skippedAlreadyExist:\"已跳过（已存在）\",filesCategory:\"文件（3MF、缩略图等）\",andMore:\"...还有 {{count}} 项\",newApiKeysGenerated:\"已生成新的 API 密钥\",keysShownOnce:\"这些密钥仅显示一次。请立即复制！\",copy:\"复制\",noDataFound:\"在备份文件中未找到可恢复的数据。\",close:\"关闭\",scheduledBackup:\"Scheduled Backups\",scheduledBackupDescription:\"Automatically create backup snapshots on a schedule. Output directory can be mounted to a NAS or external storage.\",frequency:\"Frequency\",backupTime:\"Time\",retention:\"Retention\",retentionDescription:\"Number of backups to keep\",outputPath:\"Output Path\",outputPathPlaceholder:\"Default: {{path}}\",outputPathDescription:\"Leave empty for default location\",runNow:\"Run Now\",backupFiles:\"Backup Files\",noScheduledBackups:\"No backups yet\",deleteBackup:\"Delete\",deleteBackupConfirm:\"Delete this backup file?\",backupRunning:\"Backup in progress...\",scheduledBackupComplete:\"Backup completed successfully\",scheduledBackupFailed:\"Backup failed\",nextBackup:\"Next backup\",backupSize:\"Size\",utc:\"UTC\",defaultPathLabel:\"Default:\",categories:{settings:\"设置\",notification_providers:\"通知提供者\",notification_templates:\"通知模板\",smart_plugs:\"智能插座\",printers:\"打印机\",filaments:\"耗材\",maintenance_types:\"维护类型\",archives:\"档案\",projects:\"项目\",pending_uploads:\"待上传\",external_links:\"外部链接\",api_keys:\"API 密钥\"}},tags:{title:\"标签\",addTag:\"添加标签\",editTag:\"编辑标签\",deleteTag:\"删除标签\",tagName:\"标签名称\",tagColor:\"标签颜色\",noTags:\"无标签\",deleteConfirm:\"确定要删除此标签吗？\",manageTags:\"管理标签\"},uploadModal:{title:\"上传 3MF 文件\",dragDrop:\"将 .3mf 文件拖放到此处\",or:\"或\",browseFiles:\"浏览文件\",extractionInfo:\"将从 3MF 文件元数据中自动提取打印机型号。\",uploaded:\"已上传\",failed:\"失败\",uploading:\"上传中...\",upload:\"上传\",uploadFailed:\"上传失败\"},editArchive:{title:\"编辑归档\",name:\"名称\",namePlaceholder:\"打印名称\",printer:\"打印机\",noPrinter:\"无打印机\",project:\"项目\",noProject:\"无项目\",itemsPrinted:\"打印数量\",itemsPrintedHelp:\"此打印任务中生产的物品数量\",notes:\"备注\",notesPlaceholder:\"添加关于此打印的备注...\",externalLink:\"外部链接\",externalLinkPlaceholder:\"https://printables.com/model/...\",externalLinkHelp:\"链接到 Printables、Thingiverse 或其他来源\",tags:\"标签\",tagsPlaceholder:\"添加标签...\",addMoreTags:\"添加更多标签...\",matchingTags:'匹配\"{{query}}\"',existingTags:\"现有标签\",clickToAdd:\"（点击添加）\",status:\"状态\",failureReason:\"失败原因\",selectReason:\"选择原因...\",photos:\"打印成品照片\",photosHelp:\"点击 + 添加打印成品照片\",printResult:\"打印成品\",saving:\"保存中...\",failureReasons:{adhesionFailure:\"附着力失败\",spaghettiDetached:\"拉丝 / 脱落\",layerShift:\"层偏移\",cloggedNozzle:\"喷嘴堵塞\",filamentRunout:\"耗材用完\",warping:\"翘曲\",stringing:\"拉丝\",underExtrusion:\"挤出不足\",powerFailure:\"断电\",userCancelled:\"用户取消\",other:\"其他\"},statuses:{completed:\"已完成\",failed:\"失败\",aborted:\"已取消\",printing:\"打印中\"}},kProfiles:{title:\"K 值配置\",noPrintersConfigured:\"未配置打印机\",addPrinterInSettings:\"在设置中添加打印机以管理 K 值配置\",noActivePrinters:\"无活跃打印机\",enablePrinterConnection:\"启用打印机连接以查看其 K 值配置\",loadingProfiles:\"加载 K 值配置中...\",printerOffline:\"打印机离线\",printerOfflineDesc:\"所选打印机未连接。开启电源以查看 K 值配置。\",noMatchingProfiles:\"无匹配的配置\",noMatchingProfilesDesc:\"没有配置匹配您的搜索条件\",noKProfiles:\"无 K 值配置\",noKProfilesDesc:\"未找到 {{diameter}}mm 喷嘴的压力推进配置\",createFirstProfile:\"创建第一个配置\",printer:\"打印机\",nozzle:\"喷嘴\",refresh:\"刷新\",addProfile:\"添加配置\",export:\"导出\",import:\"导入\",select:\"选择\",selectAll:\"全选\",delete:\"删除\",searchPlaceholder:\"按名称或耗材搜索...\",allExtruders:\"所有挤出机\",leftOnly:\"仅左侧\",rightOnly:\"仅右侧\",allFlow:\"所有流量\",hfOnly:\"仅高流量\",sOnly:\"仅标准\",sortName:\"排序：名称\",sortKValue:\"排序：K 值\",sortFilament:\"排序：耗材\",leftExtruder:\"左挤出机\",rightExtruder:\"右挤出机\",modal:{addTitle:\"添加 K 值配置\",editTitle:\"编辑 K 值配置\",profileName:\"配置名称\",profileNamePlaceholder:\"我的 PLA 配置\",kValue:\"K 值\",kValuePlaceholder:\"0.020\",kValueHelp:\"典型范围：PLA 0.01 - 0.06，PETG 0.02 - 0.10\",filament:\"耗材\",selectFilament:\"选择耗材...\",noFilamentsHelp:\"未找到耗材。请先在 Bambu Studio 中创建 K 值配置。\",flowType:\"流量类型\",highFlow:\"高流量\",standard:\"标准\",nozzleSize:\"喷嘴尺寸\",extruder:\"挤出机\",extruders:\"挤出机\",left:\"左\",right:\"右\",notes:\"备注（本地存储）\",notesPlaceholder:\"添加关于此配置的备注...\",notesHelp:\"备注保存在 Bambuddy 中，不在打印机上\",syncing:\"与打印机同步中...\",savingExtruder:\"保存到挤出机 {{current}}/{{total}}...\",pleaseWait:\"请稍候\"},deleteConfirm:{title:\"删除配置\",cannotUndo:\"此操作无法撤销\",message:'确定要从打印机删除\"{{name}}\"吗？'},bulkDelete:{title:\"删除配置\",cannotUndo:\"此操作无法撤销\",message:\"确定要从打印机删除 {{count}} 个选中的配置吗？\"},toast:{profileSaved:\"K 值配置已保存\",profilesSaved:\"K 值配置已保存到 {{count}} 个挤出机\",selectAtLeastOneExtruder:\"请至少选择一个挤出机\",profileDeleted:\"K 值配置已删除\",profilesDeleted:\"已删除 {{count}} 个配置\",exportedProfiles:\"已导出 {{count}} 个配置\",importedProfiles:\"已导入 {{count}} / {{total}} 个配置\",noProfilesToExport:\"无可导出的配置\",invalidFileFormat:\"无效的文件格式\",failedToParseImport:\"解析导入文件失败\",failedToSaveBatch:\"批量保存 K 值配置失败\",noteSaved:\"备注已保存\",failedToSaveNote:\"保存备注失败\"},permission:{noRead:\"您没有刷新配置的权限\",noCreate:\"您没有添加配置的权限\",noUpdate:\"您没有更新 K 值配置的权限\",noDelete:\"您没有删除 K 值配置的权限\",noExport:\"您没有导出配置的权限\",noImport:\"您没有导入配置的权限\"}},virtualPrinter:{title:\"虚拟打印机\",running:\"运行中\",stopped:\"已停止\",description:{default:\"启用虚拟打印机，使其在 Bambu Studio 和 OrcaSlicer 中可见。发送到此打印机的文件将直接归档而不打印。\",proxy:\"启用代理，将切片软件流量中继到真实打印机，允许在任何网络上远程打印。\"},enable:{title:\"启用虚拟打印机\",visibleInSlicer:'在切片软件发现中显示为\"Bambuddy\"',proxyingTo:\"代理到 {{name}}\",notActive:\"未激活\"},model:{title:\"打印机型号\",description:\"选择要模拟的打印机型号。\",restartWarning:\"更改型号将重启虚拟打印机\"},accessCode:{title:\"访问码\",isSet:\"访问码已设置\",notSet:\"未设置访问码 - 需要设置才能启用\",placeholder:\"输入 8 位字符代码\",placeholderChange:\"输入新代码以更改\",hint:\"必须恰好 8 个字符。切片软件使用此代码进行认证。\",charCount:\"({{count}}/8)\"},targetPrinter:{title:\"目标打印机\",configured:\"代理目标已配置\",notConfigured:\"未选择目标打印机 - 代理模式需要设置\",placeholder:\"选择打印机...\",hint:\"选择要将切片软件流量代理到的打印机。打印机必须处于局域网模式。\",noPrinters:\"未配置打印机。请先添加打印机以使用代理模式。\"},remoteInterface:{title:\"网络接口覆盖\",configured:\"接口覆盖已激活\",optional:\"可选 - 当自动检测的 IP 不正确时使用（例如多网卡、Docker、VPN）\",placeholder:\"自动检测（默认）...\",hint:\"覆盖通过 SSDP 广播并在 TLS 证书中使用的 IP 地址。在 Bambuddy 有多个网络接口时很有用。\"},mode:{title:\"模式\",archive:\"归档\",archiveDesc:\"立即归档文件\",review:\"审核\",reviewDesc:\"归档前审核\",queue:\"队列\",queueDesc:\"归档并添加到队列\",proxy:\"代理\",proxyDesc:\"中继到真实打印机\"},autoDispatch:{title:\"自动派发\",description:\"添加到队列时自动开始打印。关闭后，打印任务等待手动派发。\"},setupRequired:{title:\"需要设置\",description:\"虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。\",readGuide:\"启用前请阅读设置指南\"},howItWorks:{title:\"工作原理\",step1:\"在同一局域网中，虚拟打印机会通过发现机制自动出现在您的切片软件（Bambu Studio / OrcaSlicer）中。从其他网络，通过 IP 地址和访问码手动添加。\",step2:'在归档、审核和队列模式下，使用切片软件中的\"发送\"按钮将 3MF 文件上传到 Bambuddy。切片软件会显示\"打印成功\"— 文件已存储，未打印。',step3:\"在代理模式下，虚拟打印机将所有流量中继到真实打印机 — 打印会立即开始，就像直接连接一样。\"},status:{title:\"状态详情\",printerName:\"打印机名称\",model:\"型号\",serialNumber:\"序列号\",mode:\"模式\",pendingFiles:\"待处理文件\",targetPrinter:\"目标打印机\",ftpPort:\"FTP 端口\",mqttPort:\"MQTT 端口\",ftpConnections:\"FTP 连接\",mqttConnections:\"MQTT 连接\"},toast:{updated:\"虚拟打印机设置已更新\",failedToUpdate:\"更新设置失败\",accessCodeRequired:\"请先设置访问码\",targetPrinterRequired:\"请先选择目标打印机\",bindIpRequired:\"请先设置绑定 IP\",accessCodeEmpty:\"访问码不能为空\",accessCodeLength:\"访问码必须恰好 8 个字符\",created:\"虚拟打印机已创建\",failedToCreate:\"创建虚拟打印机失败\",deleted:\"虚拟打印机已删除\",failedToDelete:\"删除虚拟打印机失败\"},list:{title:\"虚拟打印机\",add:\"添加\",addFirst:\"添加虚拟打印机\",empty:\"未配置虚拟打印机。添加一个以开始使用。\"},bindIp:{title:\"绑定接口\",placeholder:\"选择接口...\",hint:\"此虚拟打印机绑定的网络接口。每台打印机必须唯一。\"},proxy:{accessCodeHint:\"在代理模式下，在切片软件中使用目标打印机的访问码。连接会透明转发到真实打印机。\"},addDialog:{title:\"添加虚拟打印机\",name:\"名称\",hint:\"创建后可以配置访问码、目标打印机和其他设置。\",create:\"创建\"},deleteConfirm:{title:\"删除虚拟打印机\",message:'确定要删除\"{{name}}\"吗？这将停止此打印机的所有服务。'}},modelViewer:{openInSlicer:\"在切片软件中打开\",tabs:{model:\"3D 模型\",gcode:\"G-code 预览\"},notAvailable:\"不可用\",notSliced:\"未切片\",plates:\"板\",allPlates:\"所有板\",plateNumber:\"板 {{number}}\",plateCount:\"{{count}} 个板\",plateCount_other:\"{{count}} 个板\",objectCount:\"{{count}} 个对象\",objectCount_other:\"{{count}} 个对象\",filamentCount:\"{{count}} 种耗材\",filamentCount_other:\"{{count}} 种耗材\",eta:\"预计 {{minutes}} 分钟\",noPreview:\"此文件无可用预览\",pagination:{pageOf:\"第 {{current}} / {{total}} 页\",prev:\"上一页\",next:\"下一页\"},errors:{failedToLoad:\"加载文件失败\",noMeshes:\"3MF 文件中未找到网格\",unsupportedFormat:\"不支持的文件格式\"}},maintenanceDescriptions:{lubricateCarbonRods:\"在碳纤维杆上涂抹润滑剂以确保顺畅运动\",lubricateRails:\"在线性导轨上涂抹润滑剂以确保顺畅运动\",cleanNozzle:\"清洁热端和喷嘴以防止堵塞\",checkBelts:\"检查皮带张力以确保打印精度\",cleanBuildPlate:\"清洁构建板以获得更好的附着力\",checkExtruder:\"检查挤出机齿轮磨损情况\",checkCooling:\"确保冷却风扇正常工作\",generalInspection:\"打印机综合检查\",cleanCarbonRods:\"清洁碳纤维杆以减少摩擦\",lubricateSteelRods:\"在钢杆上涂抹润滑剂以确保顺畅运动\",cleanSteelRods:\"清洁钢杆以减少摩擦\",cleanLinearRails:\"擦拭线性导轨以清除灰尘和碎屑\",checkPtfeTube:\"检查 PTFE 管的磨损或损坏\",replaceHepaFilter:\"更换 HEPA 过滤器以保证空气质量\",replaceCarbonFilter:\"更换活性炭过滤器\",lubricateLeftNozzleRail:\"润滑左喷嘴导轨（H2 系列）\"},smartPlugs:{offline:\"离线\",admin:\"管理\",openPlugAdminPage:\"打开插座管理页面\",deleteSmartPlug:\"删除智能插座\",turnOnSmartPlug:\"开启智能插座\",turnOffSmartPlug:\"关闭智能插座\",turnOn:\"开启\",turnOff:\"关闭\",addSmartPlug:{scanningNetwork:\"扫描网络中...\",chooseEntity:\"选择实体...\",connectionFailed:\"连接失败\",searchEntities:\"搜索实体...\",searchPowerSensors:\"搜索功率传感器...\",searchEnergySensors:\"搜索能量传感器...\",placeholders:{plugName:\"客厅插座\",mqttStateOnValue:\"ON、true、1\",mqttSameAsPower:\"与功率主题相同，或不同\"}},linkedTo:\"关联到：\",monitorOnly:\"仅监控\",alerts:\"警报\",scheduleOn:\"开启 {{time}}\",scheduleOff:\"关闭 {{time}}\",on:\"开启\",off:\"关闭\",power:\"功率\",kwhToday:\"今日kWh\",settings:\"设置\",automationSettings:\"自动化设置\",showInSwitchbar:\"在开关栏显示\",quickAccessSidebar:\"从侧边栏快速访问\",enabled:\"已启用\",enableAutomation:\"为此插座启用自动化\",autoOn:\"自动开启\",autoOnDescription:\"打印开始时开启\",autoOff:\"自动关闭\",autoOffDescription:\"打印完成时关闭（一次性）\",autoOffPersistent:\"保持启用\",autoOffPersistentDescription:\"在打印之间保持启用而非一次性\",turnOffDelayMode:\"关闭延迟模式\",time:\"时间\",temp:\"温度\",delayMinutes:\"延迟（分钟）\",tempThreshold:\"温度阈值（°C）\",tempThresholdDescription:\"当喷嘴冷却到此温度以下时关闭\",edit:\"编辑\",deleteConfirm:'确定要删除\"{{name}}\"吗？此操作无法撤销。',turnOnConfirm:'确定要开启\"{{name}}\"吗？',turnOffConfirm:'确定要关闭\"{{name}}\"吗？这将切断连接设备的电源。',failedToTurn:'无法{{action}}\"{{name}}\"',unknown:\"未知\",addTitle:\"添加智能插座\",editTitle:\"编辑智能插座\",stopScanning:\"停止扫描\",discoverTasmota:\"发现Tasmota设备\",foundDevices:\"找到{{count}}个设备 - 点击选择：\",noDevicesFound:\"未在您的网络中找到Tasmota设备\",haNotConfigured:\"Home Assistant未配置。请在以下位置设置\",haSettingsPath:\"设置 → 网络 → Home Assistant\",selectEntity:\"选择实体 *\",ipAddress:\"IP地址 *\",nameLabel:\"名称 *\",username:\"用户名\",password:\"密码\",authHint:\"如果您的Tasmota设备不需要认证，请留空\",linkToPrinter:\"关联打印机\",noPrinter:\"无打印机（仅手动控制）\",linkingDescription:\"关联后可在打印开始/完成时自动开关\",powerAlerts:\"功率警报\",alertAbove:\"高于时警报（W）\",alertBelow:\"低于时警报（W）\",alertDescription:\"当电力消耗超过这些阈值时收到通知。留空以禁用该方向。\",dailySchedule:\"每日计划\",turnOnAt:\"开启时间\",turnOffAt:\"关闭时间\",scheduleDescription:\"每天在这些时间自动开关插座。留空以跳过该操作。\",showOnPrinterCard:\"在打印机卡片上显示\",displayOnPrinterCard:\"在打印机卡片上显示按钮\",connectedResult:\"已连接！\",deviceLabel:\"设备：{{name}} - \",stateLabel:\"状态：{{state}}\",test:\"测试\",delete:\"删除\",save:\"保存\",add:\"添加\",cancel:\"取消\",failedToStartScan:\"无法开始扫描\",nameRequired:\"名称为必填项\",entityRequired:\"Home Assistant插座需要实体\",mqttTopicRequired:\"必须为功率、能源或状态监控配置至少一个MQTT主题\",loadingEntities:\"正在加载实体...\",loading:\"加载中...\",failedToLoadEntities:\"加载实体失败：{{error}}\",noEntitiesMatching:'未找到匹配\"{{search}}\"的实体',noEntitiesAvailable:\"无可用实体\",searchingEntities:\"搜索所有实体（找到{{count}}个）\",showingEntities:\"显示 switch、light、input_boolean（{{count}}个可用）\",energyMonitoringOptional:\"能源监控（可选）\",energyMonitoringHint:\"搜索并选择提供功率/能源数据的传感器。\",powerSensorW:\"功率传感器（W）\",energyTodayKwh:\"今日能源（kWh）\",totalEnergyKwh:\"总能源（kWh）\",noMatchingSensors:\"无匹配的传感器\",none:\"无\",mqttNotConfigured:\"MQTT代理未配置。请在以下位置设置代理地址\",mqttSettingsPath:\"设置 → 网络 → MQTT发布\",mqttNotConfiguredSuffix:\"（您不需要启用发布，只需填写代理详细信息）。\",mqttMonitorOnlyDescription:\"MQTT插座通过MQTT订阅接收功率/能源数据。开关控制不可用 - 请使用您的MQTT代理或家庭自动化系统。\",powerMonitoring:\"功率监控\",energyMonitoring:\"能源监控\",stateMonitoring:\"状态监控\",optional:\"可选\",topic:\"主题\",jsonPath:\"JSON路径\",multiplier:\"乘数\",onValue:\"ON值\",mqttPowerHint:`JSON路径从JSON负载中提取值（例如\"power_l1\"）。如果主题发布原始数值，请留空。\n乘数：mW→W使用0.001，kW→W使用1000。`,mqttEnergyHint:`JSON路径从JSON负载中提取值。原始值请留空。\n乘数：Wh→kWh使用0.001，MWh→kWh使用1000。`,mqttStateHint:`JSON路径从JSON负载中提取值。原始值请留空。\nON值：表示\"ON\"的确切字符串。留空以自动检测（ON、true、1）。`,restControl:\"Control\",restOnUrl:\"Turn ON URL\",restOffUrl:\"Turn OFF URL\",restOnBody:\"ON Request Body\",restOffBody:\"OFF Request Body\",restMethod:\"HTTP Method\",restHeaders:\"Custom Headers (JSON)\",restStatusUrl:\"Status URL\",restStatusPath:\"State JSON Path\",restStatusOnValue:\"ON Value\",restPowerUrl:\"功率URL\",restPowerPath:\"Power JSON Path\",restPowerMultiplier:\"功率乘数\",restEnergyUrl:\"能耗URL\",restEnergyPath:\"Energy JSON Path\",restEnergyMultiplier:\"能耗乘数\",restUrlRequired:\"At least one URL (ON or OFF) is required for REST plugs\",restHeadersHint:'e.g. {\"Authorization\": \"Bearer your-token\"}',restBodyHint:'e.g. ON, {\"state\": \"on\"}',restStatusHint:\"URL to poll for current state\",restPathHint:\"e.g. state or data.power.status\",restPowerUrlHint:\"功率数据的独立URL（留空则使用状态URL）\",restEnergyUrlHint:\"能耗数据的独立URL（留空则使用状态URL）\",restEnergyHint:\"每个值可以使用独立的URL，或回退到状态URL。使用乘数进行单位转换（例如：0.001 将 Wh 转换为 kWh）。\",testConnection:\"Test Connection\",connectionSuccess:\"Connection successful\",noSwitchesInSwitchbar:\"开关栏中没有开关\",enableSwitchbarHint:'在设置 > 智能插座中启用\"在开关栏显示\"'},notifications:{providerTypes:{callmebot:\"CallMeBot/WhatsApp\",ntfy:\"ntfy\",pushover:\"Pushover\",telegram:\"Telegram\",email:\"电子邮件\",discord:\"Discord\",webhook:\"Webhook\",homeassistant:\"Home Assistant\"},providerDescriptions:{email:\"SMTP 电子邮件通知\",telegram:\"通过 Telegram 机器人发送通知\",discord:\"通过 Webhook 发送到 Discord 频道\",ntfy:\"免费、可自托管的推送通知\",pushover:\"简单、可靠的推送通知\",callmebot:\"通过 CallMeBot 免费发送 WhatsApp 通知\",webhook:\"通用 HTTP POST 到任意 URL\",homeassistant:\"Home Assistant 仪表板中的持久通知\"},lastSuccess:\"上次：{{date}}\",error:\"错误\",printer:\"打印机：\",allPrinters:\"所有打印机\",sendTestNotification:\"发送测试通知\",eventSettings:\"事件设置\",enabled:\"已启用\",sendFromProvider:\"从此提供商发送通知\",printEvents:\"打印事件\",printerStatus:\"打印机状态\",amsAlarms:\"AMS 警报\",amsHtAlarms:\"AMS-HT 警报\",printQueue:\"打印队列\",start:\"开始\",plateCheck:\"热床检测\",complete:\"完成\",failed:\"失败\",stopped:\"已停止\",progress:\"进度\",offline:\"离线\",lowFilament:\"耗材不足\",maintenance:\"维护\",amsHumidity:\"AMS 湿度\",amsTemp:\"AMS 温度\",amsHtHumidity:\"AMS-HT 湿度\",amsHtTemp:\"AMS-HT 温度\",bedCooled:\"热床已冷却\",firstLayer:\"首层完成\",quiet:\"免打扰\",digest:\"摘要 {{time}}\",printStarted:\"打印已开始\",plateNotEmpty:\"热床非空\",plateNotEmptyDescription:\"打印前检测到物体\",printCompleted:\"打印已完成\",bedCooledLabel:\"热床已冷却\",bedCooledDescription:\"打印后热床温度降至阈值以下\",firstLayerCompleteLabel:\"首层打印完成\",firstLayerCompleteDescription:\"首层完成时发送带照片的通知\",missingSpoolAssignmentLabel:\"缺少料卷分配\",missingSpoolAssignmentDescription:\"当打印开始且所需料盘没有分配料卷时发送通知\",printFailed:\"打印失败\",printStopped:\"打印已停止\",progressMilestones:\"进度里程碑\",progressMilestonesDescription:\"在 25%、50%、75% 时通知\",printerOffline:\"打印机离线\",printerError:\"打印机错误\",lowFilamentLabel:\"耗材不足\",maintenanceDue:\"需要维护\",maintenanceDueDescription:\"需要维护时通知\",amsHumidityHigh:\"AMS 湿度过高\",amsHumidityHighDescription:\"普通 AMS 湿度超过阈值\",amsTemperatureHigh:\"AMS 温度过高\",amsTemperatureHighDescription:\"普通 AMS 温度超过阈值\",amsHtHumidityHigh:\"AMS-HT 湿度过高\",amsHtHumidityHighDescription:\"AMS-HT 湿度超过阈值\",amsHtTemperatureHigh:\"AMS-HT 温度过高\",amsHtTemperatureHighDescription:\"AMS-HT 温度超过阈值\",jobAdded:\"任务已添加\",jobAddedDescription:\"任务已添加到队列\",jobAssigned:\"任务已分配\",jobAssignedDescription:\"基于模型的任务已分配给打印机\",jobStarted:\"任务已开始\",jobStartedDescription:\"队列任务已开始打印\",jobWaiting:\"任务等待中\",jobWaitingDescription:\"任务正在等待耗材或打印机\",jobSkipped:\"任务已跳过\",jobSkippedDescription:\"任务已跳过（上一个失败）\",jobFailed:\"任务失败\",jobFailedDescription:\"任务启动失败\",queueComplete:\"队列已完成\",queueCompleteDescription:\"所有队列任务已完成\",quietHours:\"免打扰时段\",noNotificationsDuring:\"在此时段内不发送通知\",editProviderToChangeQuietHours:\"编辑提供商以更改免打扰时段\",dailyDigest:\"每日摘要\",batchNotifications:\"将通知汇总为每日摘要\",sendAt:\"发送于 {{time}}\",editProviderToChangeDigestTime:\"编辑提供商以更改摘要时间\",edit:\"编辑\",deleteProvider:\"删除通知提供商\",deleteConfirm:'确定要删除\"{{name}}\"吗？此操作无法撤销。',delete:\"删除\",addTitle:\"添加通知提供商\",editTitle:\"编辑通知提供商\",nameLabel:\"名称 *\",namePlaceholder:\"我的通知\",providerTypeLabel:\"提供商类型 *\",configuration:\"配置\",testConfiguration:\"测试配置\",printerFilter:\"打印机筛选\",onlyFromPrinter:\"仅发送来自此打印机的事件通知\",quietHoursDnd:\"免打扰时段\",quietStart:\"开始\",quietEnd:\"结束\",dailyDigestLabel:\"每日摘要\",sendDigestAt:\"发送摘要于\",digestCollected:\"事件将被收集并在此时间作为单条摘要发送\",notificationEvents:\"通知事件\",progressPercent:\"（25%、50%、75%）\",bedCooledAfterPrint:\"（打印完成后）\",cancel:\"取消\",save:\"保存\",add:\"添加\",nameRequired:\"名称为必填项\",fieldRequired:\"{{field}}为必填项\",phoneNumber:\"电话号码\",apiKey:\"API 密钥\",serverUrl:\"服务器 URL\",topic:\"主题\",authToken:\"认证令牌\",userKey:\"用户密钥\",appToken:\"应用令牌\",priority:\"优先级\",botToken:\"机器人令牌\",chatId:\"聊天 ID\",smtpServer:\"SMTP 服务器\",smtpPort:\"SMTP 端口\",security:\"安全\",authentication:\"认证\",username:\"用户名\",password:\"密码\",fromEmail:\"发件人邮箱\",toEmail:\"收件人邮箱\",webhookUrl:\"Webhook URL\",payloadFormat:\"负载格式\",authorization:\"授权\",titleFieldName:\"标题字段名\",messageFieldName:\"消息字段名\",editTemplate:\"编辑模板：{{name}}\",titleLabel:\"标题\",bodyLabel:\"正文\",titlePlaceholder:\"通知标题...\",bodyPlaceholder:\"通知正文...\",availableVariables:\"可用变量\",clickToInsert:\"点击插入到正文光标位置\",livePreview:\"实时预览\",hide:\"隐藏\",show:\"显示\",loadingPreview:\"加载预览中...\",enterTemplateContent:\"输入模板内容以查看预览\",titlePreview:\"标题：\",bodyPreview:\"正文：\",resetToDefault:\"恢复默认\",titleRequired:\"标题为必填项\",bodyRequired:\"正文为必填项\",notificationLog:\"通知日志\",showFailedOnly:\"仅显示失败\",last24Hours:\"最近 24 小时\",last7Days:\"最近 7 天\",last30Days:\"最近 30 天\",last90Days:\"最近 90 天\",justNow:\"刚刚\",noFailedNotifications:\"没有失败的通知\",noNotificationsLogged:\"没有通知记录\",unknownProvider:\"未知提供商\",logTitle:\"标题\",logMessage:\"消息\",logError:\"错误\",logProvider:\"提供商：{{type}}\",logTime:\"时间：{{time}}\",refresh:\"刷新\",clearOld:\"清除旧记录\",statsSummary:\"最近 {{days}} 天：\",statsNotifications:\"条通知\",statsSent:\"{{count}} 条已发送\",statsFailed:\"{{count}} 条失败\",eventTypes:{print_start:\"打印已开始\",print_complete:\"打印完成\",print_failed:\"打印失败\",print_stopped:\"打印已停止\",print_progress:\"进度\",printer_offline:\"打印机离线\",printer_error:\"打印机错误\",filament_low:\"耗材不足\",maintenance_due:\"需要维护\",test:\"测试\"},userEmail:{title:\"通知\",emailNotifications:\"邮件通知\",emailNotificationsDesc:\"接收您自己打印任务的邮件通知。邮件将通过高级身份验证中配置的 SMTP 设置发送。\",sendingTo:\"通知将发送至\",noEmailWarning:\"您的账户没有邮件地址。请联系管理员添加。\",printJobNotifications:\"打印任务通知\",printJobNotificationsDesc:\"选择哪些事件会触发您提交的打印任务的邮件通知。\",printJobStarts:\"打印任务开始\",printJobStartsDesc:\"当您的打印任务开始时收到通知。\",printJobFinishes:\"打印任务完成\",printJobFinishesDesc:\"当您的打印任务成功完成时收到通知。\",printErrors:\"打印错误\",printErrorsDesc:\"当您的打印任务失败或遇到错误时收到通知。\",printJobStops:\"打印任务停止\",printJobStopsDesc:\"当您的打印任务被取消或停止时收到通知。\",saveSuccess:\"通知偏好设置已保存。\",saveError:\"保存通知偏好设置失败。\"}},richTextEditor:{bold:\"粗体\",italic:\"斜体\",underline:\"下划线\",bulletList:\"无序列表\",numberedList:\"有序列表\",alignLeft:\"左对齐\",alignCenter:\"居中对齐\",alignRight:\"右对齐\",addLink:\"添加链接\",removeLink:\"移除链接\"},externalLinks:{noLinksConfigured:\"未配置外部链接\",deleteLink:\"删除链接\",removeCustomIcon:\"移除自定义图标\",openInNewTab:\"在新标签页中打开\",placeholders:{linkName:\"我的链接\"}},keyboardShortcuts:{title:\"键盘快捷键\",navigation:\"导航\",archivesSection:\"归档\",kProfilesSection:\"K 值配置\",generalSection:\"通用\",shortcuts:{goToPrinters:\"前往打印机\",goToArchives:\"前往归档\",goToQueue:\"前往队列\",goToStats:\"前往统计\",goToProfiles:\"前往云端配置\",goToSettings:\"前往设置\",focusSearch:\"聚焦搜索\",openUploadModal:\"打开上传对话框\",clearSelection:\"清除选择 / 取消焦点\",contextMenu:\"卡片右键菜单\",refreshProfiles:\"刷新配置\",newProfile:\"新建配置\",exitSelectionMode:\"退出选择模式\",showHelp:\"显示此帮助\"},footer:\"按 Esc 或点击外部关闭\"},notificationLog:{title:\"通知日志\",events:{printStarted:\"打印开始\",printComplete:\"打印完成\",printFailed:\"打印失败\",printStopped:\"打印停止\",progress:\"进度\",printerOffline:\"打印机离线\",printerError:\"打印机错误\",lowFilament:\"耗材不足\",maintenanceDue:\"维护到期\",test:\"测试\"},timeAgo:{justNow:\"刚刚\",minutesAgo:\"{{minutes}} 分钟前\",hoursAgo:\"{{hours}} 小时前\"}},restoreBackup:{title:\"恢复备份\",restoring:\"恢复中...\",restoreComplete:\"恢复完成\",restoreFailed:\"恢复失败\",importSettings:\"从备份文件导入设置\",pleaseWait:\"请稍候，正在恢复您的数据\",clickToSelect:\"点击选择备份文件（.json 或 .zip）\",howDuplicateHandling:\"重复处理方式：\",categories:{printers:\"打印机\",smartPlugs:\"智能插座\",notificationProviders:\"通知提供商\",filaments:\"耗材\",archives:\"归档\",pendingUploads:\"待处理上传\",settingsTemplates:\"设置和模板\"},matchingInfo:{printers:\"按序列号匹配\",smartPlugs:\"按 IP 地址匹配\",notificationProviders:\"按名称匹配\",filaments:\"按名称 + 类型 + 品牌匹配\",archives:\"按内容哈希匹配\",pendingUploads:\"按文件名匹配\",settingsTemplates:\"始终覆盖\"},replaceExisting:\"替换现有数据\",keepExisting:\"保留现有数据\",replaceDescription:\"用备份数据覆盖已存在的项目\",keepDescription:\"仅恢复不存在的项目\",caution:\"注意：\",cautionText:\"覆盖将用备份数据替换您当前的配置。出于安全考虑，打印机访问码永远不会被覆盖。\",itemsRestored:\"已恢复项目\",itemsSkipped:\"已跳过项目\",restored:\"已恢复\",skipped:\"已跳过（已存在）\",filesLabel:\"文件（3MF、缩略图等）\",newApiKeysGenerated:\"已生成新 API 密钥\",newApiKeysWarning:\"这些密钥仅显示一次。请立即复制！\",processingBackup:\"处理备份文件中...\",noDataFound:\"备份文件中未找到可恢复的数据。\",failedToRestore:\"恢复备份失败。请检查文件格式。\"},backupExport:{title:\"导出备份\",selectData:\"选择要包含的数据\",selectAll:\"全选\",selectNone:\"全不选\",categoryDescriptions:{settings:\"语言、主题、更新偏好\",notifications:\"ntfy、Pushover、Discord 等\",templates:\"自定义消息模板\",smartPlugs:\"Tasmota 插座配置\",externalLinks:\"侧边栏外部服务链接\",printers:\"打印机信息（不含访问码）\",plateDetection:\"空打印板参考图像\",filaments:\"耗材类型和成本\",maintenance:\"自定义维护计划\",archives:\"所有打印数据 + 文件（3MF、缩略图、照片）\",projects:\"项目、材料清单和附件\",pendingUploads:\"虚拟打印机待审核的上传\",apiKeys:\"Webhook API 密钥（导入时生成新密钥）\"},requiresPrinters:\"需要选择打印机\",zipFileWarning:\"将创建 ZIP 文件。\",zipFileDescription:\"包括所有 3MF 文件、缩略图、延时摄影和照片。这可能需要一些时间并生成较大的文件。\",includeAccessCodes:\"包含访问码\",includeAccessCodesDescription:\"用于转移到另一台机器\",includeAccessCodesWarning:\"访问码将以明文形式包含。请妥善保管此备份文件！\",categoriesSelected:\"已选择 {{selectedCount}} 个类别\"},pendingUploads:{placeholders:{notes:\"添加关于此打印的备注...\"},discardUpload:\"丢弃上传\",archiveAllUploads:\"归档所有上传\",discardAllUploads:\"丢弃所有上传\",archive:\"归档\",timeAgo:{justNow:\"刚刚\",minutesAgo:\"{{minutes}} 分钟前\",hoursAgo:\"{{hours}} 小时前\",daysAgo:\"{{days}} 天前\"}},apiBrowser:{placeholders:{requestBody:\"JSON 请求体...\",searchEndpoints:\"搜索端点...\"}},configureAmsSlot:{title:\"配置 AMS 槽位\",slotConfigured:\"槽位已配置！\",configuringSlot:\"正在配置槽位：\",slotLabel:\"{{ams}} 槽位 {{slot}}\",searchPresets:\"搜索预设...\",colorPlaceholder:\"颜色名称或十六进制（例如：棕色、FF8800）\",clearCustomColor:\"清除自定义颜色\",noCloudPresets:\"无云端预设。登录拓竹云以同步。\",noPresetsAvailable:\"无可用预设。登录拓竹云或导入本地配置。\",noMatchingPresets:\"未找到匹配的预设。\",custom:\"自定义\",builtin:\"内置\",settingsSentToPrinter:\"设置已发送到打印机\",filamentProfile:\"耗材配置\",kProfileLabel:\"K 值配置（压力推进）\",filteringFor:\"筛选：{{material}}\",noKProfile:\"无 K 值配置（使用默认值 0.020）\",noMatchingKProfiles:\"未找到匹配的 K 值配置。将使用默认 K=0.020。\",selectFilamentFirst:\"请先选择耗材配置\",kFromCalibration:\"K={{value}}（来自打印机校准）\",customColorLabel:\"自定义颜色（可选）\",presetColors:\"{{name}} 颜色：\",showLessColors:\"显示更少颜色\",showMoreColors:\"显示更多颜色\",clear:\"清除\",hexLabel:\"十六进制：#{{hex}}\",resetting:\"重置中...\",resetSlot:\"重置槽位\",cancel:\"取消\",configuring:\"配置中...\",configureSlot:\"配置槽位\"},githubBackup:{title:\"GitHub 备份\",history:\"历史\",downloadBackup:\"下载备份\",restoreBackup:\"恢复备份\",noBackupsYet:\"暂无备份\"},emailSettings:{placeholders:{fromName:\"BamBuddy\"}},tagManagement:{searchTags:\"搜索标签...\",renameTag:\"重命名标签\",deleteTag:\"删除标签\"},notificationTemplates:{placeholders:{title:\"通知标题...\",body:\"通知正文...\"}},batchTag:{placeholders:{newTag:\"输入新标签...\"}},photoGallery:{deletePhoto:\"删除照片\"},filamentHoverCard:{copySpoolUuid:\"复制耗材 UUID\"},kProfilesView:{hasNote:\"有备注\",copyProfile:\"复制配置\"},layout:{openMenu:\"打开菜单\",noPermissionSystemInfo:\"您没有查看系统信息的权限\"},dashboard:{dragToReorder:\"拖动以重新排列\",hideWidget:\"隐藏小部件\"},notificationProviderCard:{deleteNotificationProvider:\"删除通知提供商\"},fileManagerModal:{closeFileManager:\"关闭文件管理器\",sortFiles:\"排序文件\",goToParentFolder:\"返回上级文件夹\",threeView:\"3D 视图\"},embeddedCameraViewer:{refreshStream:\"刷新流\",close:\"关闭\",zoomOut:\"缩小\",resetZoom:\"重置缩放\",zoomIn:\"放大\",dragToResize:\"拖动调整大小\"},timelapseViewer:{skipBack5s:\"后退 5 秒\",skipForward5s:\"前进 5 秒\"},notificationProviders:{descriptions:{email:\"SMTP 邮件通知\",telegram:\"通过 Telegram 机器人通知\",discord:\"通过 Webhook 发送到 Discord 频道\",ntfy:\"免费、可自托管的推送通知\",pushover:\"简单、可靠的推送通知\",callmebot:\"通过 CallMeBot 的免费 WhatsApp 通知\",webhook:\"通用 HTTP POST 到任意 URL\"}},logViewer:{searchPlaceholder:\"搜索消息或日志名称...\",noLogEntries:\"未找到日志条目\"},switchbarPopover:{noSwitchesInSwitchbar:\"切换栏中没有开关\"},projectPageModal:{placeholders:{title:\"标题\",designer:\"设计师\",license:\"许可证\",description:\"输入描述...\",profileTitle:\"配置标题\",profileDescription:\"配置描述...\"}},spoolmanSettings:{},time:{unknown:\"-\",waiting:\"等待中\",justNow:\"刚刚\",now:\"现在\",minsAgo:\"{{count}} 分钟前\",inMins:\"{{count}} 分钟后\",hoursAgo:\"{{count}} 小时前\",inHours:\"{{count}} 小时后\",daysAgo:\"{{count}} 天前\",inDays:\"{{count}} 天后\"},spoolbuddy:{nav:{dashboard:\"仪表板\",ams:\"AMS\",inventory:\"库存\",writeTag:\"写入\",settings:\"设置\"},status:{nfcReady:\"NFC 就绪\",nfcOff:\"NFC 关闭\",offline:\"离线\",online:\"在线\",noPrinters:\"无打印机\",deviceOffline:\"设备离线\",waitingConnection:\"等待设备连接...\",systemReady:\"系统就绪\",status:\"状态\"},dashboard:{readyToScan:\"准备扫描\",idleMessage:\"将耗材放在秤上以识别\",nfcHint:\"NFC 标签将自动读取\",device:\"设备\",syncWeight:\"同步重量\",weightSynced:\"已同步！\",unknownTag:\"未知标签\",newTag:\"检测到新标签\",onScale:\"在秤上\",linkSpool:\"链接到耗材\",linkTagTitle:\"将标签链接到耗材\",linkTag:\"链接标签\",selectSpool:\"选择要链接此标签的耗材：\",noUntagged:\"未找到没有标签的耗材\",tagDetected:\"检测到标签\",noTag:\"无标签\",tagId:\"标签\",grossWeight:\"毛重\",spoolSize:\"耗材盘尺寸\",close:\"关闭\",currentSpool:\"当前耗材\"},modal:{spoolDetected:\"检测到耗材\",assignToAms:\"分配到 AMS\",syncWeight:\"同步重量\",weightSynced:\"已同步！\",syncing:\"同步中...\",newTagDetected:\"检测到新标签\",addToInventory:\"添加到库存\",assignToAmsTitle:\"分配到 AMS\",selectSlot:\"选择槽位\",assign:\"分配\",assigning:\"分配中...\",assignSuccess:\"已分配！\",assignError:\"分配耗材失败。请重试。\",noPrinterSelected:\"选择打印机...\",noAmsDetected:\"此打印机未检测到 AMS\",slot:\"槽位\"},weight:{noReading:\"无读数\",stable:\"稳定\",measuring:\"测量中...\",tare:\"去皮\",calibrate:\"校准\"},spool:{remaining:\"剩余\",material:\"材料\",brand:\"品牌\",color:\"颜色\",coreWeight:\"空盘\",labelWeight:\"标签\",scaleWeight:\"秤重\",netWeight:\"净重\",lastUsed:\"上次使用\"},ams:{noData:\"未检测到 AMS\",connectAms:\"连接 AMS 以查看耗材槽位\",noPrinter:\"未选择打印机\",selectPrinter:\"从顶部栏选择打印机\",printerDisconnected:\"打印机已断开\",humidity:\"湿度\",level:\"余量\",active:\"活跃\",slot:\"槽位\",empty:\"空\"},inventory:{search:\"搜索耗材...\",empty:\"库存中没有耗材\",noResults:\"没有匹配的耗材\",spools:\"个耗材\",addSpool:\"添加耗材\"},settings:{tabDevice:\"设备\",tabDisplay:\"显示\",tabScale:\"秤\",tabUpdates:\"更新\",nfcReader:\"NFC 读卡器\",type:\"类型\",connection:\"连接\",notConnected:\"不适用\",deviceInfo:\"设备信息\",hostname:\"主机\",uptime:\"运行时间\",systemConfig:\"后端与认证\",backendUrl:\"Bambuddy 后端 URL\",apiToken:\"API 令牌\",apiTokenPlaceholder:\"输入 API 令牌\",saveConfig:\"保存配置\",systemQueued:\"配置已加入队列。\",nfcDiagnostic:\"NFC 诊断\",scaleDiagnostic:\"秤诊断\",readTagDiagnostic:\"读取标签诊断\",testNfc:\"测试读卡器\",testScale:\"测试精度\",testReadTag:\"读取标签\",systemFieldsRequired:\"后端 URL 为必填项。\",brightness:\"亮度\",saved:\"已保存\",noBacklight:\"未检测到 DSI 背光。亮度控制需要 DSI 显示屏。\",screenBlank:\"屏幕熄灭超时\",screenBlankDesc:\"不活动后屏幕关闭。触摸唤醒。\",displayNote:\"亮度作为软件滤镜应用。\",scaleCalibration:\"秤校准\",currentWeight:\"当前重量\",tareOffset:\"去皮\",calFactor:\"系数\",knownWeight:\"已知重量\",calStep1:\"移除秤上所有物品并按设置零点。\",calStep2:\"将已知重量放在秤上。\",setZero:\"设置零点\",calibrateNow:\"校准\",calibrated:\"已校准\",tareSet:\"去皮命令已发送。等待设备响应...\",tareFailed:\"发送去皮命令失败\",zeroSet:\"零点已设置。将已知重量放在秤上。\",calibrationDone:\"校准完成！\",calibrationFailed:\"校准失败\",lastCalibrated:\"上次校准\",stable:\"稳定\",settling:\"稳定中...\",firmware:\"固件\",scale:\"秤\",noDevice:\"未找到 SpoolBuddy 设备\",daemonVersion:\"守护进程版本\",currentVersion:\"当前\",versionPending:\"等待守护进程...\",checking:\"检查中...\",checkUpdates:\"检查更新\",updateAvailable:\"有可用更新\",updateInstructions:\"通过 SSH 更新：运行 SpoolBuddy 安装脚本进行升级。\",upToDate:\"已是最新\",includeBeta:\"包含测试版本\"},writeTag:{tabExisting:\"现有耗材\",tabNew:\"新耗材\",tabReplace:\"替换标签\",searchPlaceholder:\"按材料、颜色、品牌搜索...\",noUntaggedSpools:\"没有无标签的耗材\",noTaggedSpools:\"没有有标签的耗材\",selectSpool:\"选择一个耗材，然后将空白 NTAG 放在读卡器上\",placeTag:\"将 NTAG 放在读卡器上\",tagReady:\"检测到标签 — 准备写入\",writeTag:\"写入标签\",replaceTag:\"替换标签\",writing:\"写入标签中...\",waiting:\"等待 SpoolBuddy...\",writeSuccess:\"标签写入成功！\",writeFailed:\"写入失败\",queueFailed:\"排队写入命令失败\",tryAgain:\"重试\",cancel:\"取消\",replaceWarning:\"旧标签将被取消链接。新标签将替换它。\",deviceOffline:\"SpoolBuddy 离线\",material:\"材料\",colorName:\"颜色名称\",color:\"颜色\",brand:\"品牌\",weight:\"重量 (g)\",createSpool:\"创建耗材\",creating:\"创建中...\",spoolCreated:\"耗材已创建！准备写入。\",createFailed:\"创建耗材失败\"},quickMenu:{printerPower:\"打印机电源\",systemControls:\"系统\",restartDaemon:\"重启守护进程\",restartBrowser:\"重启浏览器\",reboot:\"重启\",shutdown:\"关机\",swipeToClose:\"向下滑动关闭\",confirmTitle:\"确认\",confirmShutdown:\"确定要关闭SpoolBuddy吗？您需要物理访问才能重新开启。\",confirmReboot:\"确定要重启SpoolBuddy吗？\",confirmRestartDaemon:\"重启SpoolBuddy守护进程？NFC和秤将暂时不可用。\",confirmRestartBrowser:\"重启kiosk浏览器？屏幕将短暂变黑。\",confirm:\"确认\",confirmPlugOn:\"开启 {{name}}？\",confirmPlugOff:\"关闭 {{name}}？\",turnOn:\"开启\",turnOff:\"关闭\"}},bugReport:{title:\"报告错误\",description:\"描述\",descriptionPlaceholder:\"出了什么问题？请描述问题...\",email:\"邮箱（可选）\",emailPlaceholder:\"your@email.com\",emailPrivacy:\"如果提供，您的邮箱将包含在GitHub Issue的折叠部分中，以便维护者后续跟进。\",screenshot:\"截图\",uploadOrPaste:\"上传、粘贴或拖拽图片\",dataCollectedSummary:\"报告中包含哪些数据？\",dataIncluded:\"包含：\",dataIncludedList:\"应用版本、操作系统、架构、Python版本、数据库统计（仅计数）、打印机型号、喷嘴数量、固件版本、连接状态、集成状态（Spoolman、MQTT、HA）、非敏感设置、网络接口数量、Docker详情、依赖版本。\",dataNeverIncluded:\"绝不包含：\",dataNeverIncludedList:\"打印机名称、序列号、访问代码、密码、IP地址、邮箱地址、API密钥、令牌、Webhook URL、主机名或用户名。\",submit:\"提交\",startLogging:\"开始调试日志\",stepEnableLogging:\"调试日志已启用\",stepReproduce:\"请现在重现问题\",stepStopLogging:\"停止并提交报告\",stopAndSubmit:\"停止并提交\",maxDuration:\"{{minutes}}分钟后自动停止\",stoppingLogs:\"正在收集日志并提交...\",submitting:\"正在提交错误报告...\",submitSuccess:\"错误报告提交成功！\",submitFailed:\"提交错误报告失败\",thankYou:\"谢谢！\",submitted:\"您的错误报告已提交。\",viewIssue:\"查看Issue\",unexpectedError:\"发生了意外错误\"},failureDetection:{title:\"AI 故障检测\",description:\"通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。\",mlUrl:\"Obico ML API 地址\",mlUrlHint:\"您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。\",test:\"测试\",testSuccess:\"ML API 可访问且正常。\",testFailed:\"无法访问 ML API。\",sensitivity:\"灵敏度\",sensitivityLow:\"低(减少误报)\",sensitivityMedium:\"中(平衡)\",sensitivityHigh:\"高(更早检测,更多误报)\",sensitivityHint:\"调整触发警告和故障的置信度阈值。\",action:\"检测到故障时的操作\",actionNotify:\"仅通知\",actionPause:\"暂停打印\",actionPauseOff:\"暂停并切断电源\",pollInterval:\"检查间隔(秒)\",pollIntervalHint:\"打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。\",externalUrlMissing:\"External URL is not set.\",externalUrlHint:\"The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.\",perPrinterTitle:\"监控的打印机\",perPrinterHint:\"选择检测服务要监视哪些打印机。\",monitorAll:\"监控所有已连接的打印机\",statusTitle:\"状态\",serviceRunning:\"服务运行中\",thresholds:\"低 / 高阈值\",activePrinters:\"活动打印\",noActivePrints:\"当前没有正在进行的打印。\",historyTitle:\"最近检测\",noHistory:\"暂无检测记录。\"}},Que={nav:{printers:\"印表機\",archives:\"歸檔\",queue:\"佇列\",stats:\"統計\",profiles:\"設定檔案\",maintenance:\"維護\",projects:\"專案\",inventory:\"耗材\",files:\"檔案管理器\",notifications:\"通知\",settings:\"設定\",system:\"系統\",collapseSidebar:\"收起側邊欄\",expandSidebar:\"展開側邊欄\",update:\"更新\",updateAvailable:\"有可用更新：v{{version}}\",updateAvailableBanner:\"版本 {{version}} 已發布！\",viewUpdate:\"檢視更新\",viewOnGithub:\"在 GitHub 上檢視\",keyboardShortcuts:\"鍵盤快捷鍵 (?)\",switchToLight:\"切換到淺色模式\",switchToDark:\"切換到深色模式\",smartSwitches:\"智慧開關\",logout:\"登出\"},common:{save:\"儲存\",saving:\"儲存中...\",cancel:\"取消\",delete:\"刪除\",edit:\"編輯\",add:\"新增\",close:\"關閉\",confirm:\"確認\",loading:\"載入中...\",error:\"錯誤\",success:\"成功\",warning:\"警告\",enabled:\"已啟用\",disabled:\"已停用\",yes:\"是\",no:\"否\",on:\"開\",off:\"關\",all:\"全部\",none:\"無\",search:\"搜尋\",filter:\"篩選\",sort:\"排序\",refresh:\"重新整理\",download:\"下載\",upload:\"上傳\",uploading:\"上傳中...\",uploadFailed:\"上傳失敗\",actions:\"操作\",status:\"狀態\",name:\"名稱\",description:\"描述\",date:\"日期\",time:\"時間\",hours:\"小時\",minutes:\"分鐘\",seconds:\"秒\",days:\"天\",enable:\"啟用\",disable:\"停用\",permissions:\"權限\",noPrinters:\"未設定印表機\",noData:\"尚無資料\",linkNotFound:\"未找到連結\",required:\"必填\",optional:\"可選\",dismiss:\"關閉\",apply:\"套用\",reset:\"重設\",export:\"匯出\",import:\"匯入\",clear:\"清除\",selectAll:\"全選\",deselectAll:\"取消全選\",noChange:\"— 不更改 —\",unchanged:\"未更改\",unassigned:\"未分配\",unknown:\"未知\",unknownError:\"未知錯誤\",today:\"今天\",tomorrow:\"明天\",asap:\"儘快\",overdue:\"已逾期\",now:\"現在\",collapse:\"收起\",expand:\"展開\",viewArchive:\"檢視歸檔\",viewInFileManager:\"在檔案管理器中檢視\",addedBy:\"由 {{username}} 新增\",prints:\"次列印\",more:\"還有 {{count}} 個\",ascending:\"升序\",descending:\"降序\",back:\"返回\",copy:\"複製\",copied:\"已複製!\",printer:\"印表機\",remove:\"移除\",type:\"類型\",print:\"列印\",rename:\"重新命名\",move:\"移動\",create:\"建立\",duplicate:\"複製\",left:\"左\",right:\"右\"},printers:{title:\"印表機\",addPrinter:\"新增印表機\",editPrinter:\"編輯印表機\",deletePrinter:\"刪除印表機\",printerName:\"印表機名稱\",serialNumber:\"序列號\",ipAddress:\"IP 位址 / 主機名稱\",accessCode:\"存取碼\",model:\"型號\",nozzleCount:\"噴嘴數量\",autoArchive:\"自動歸檔\",status:{available:\"可用\",idle:\"空閒\",printing:\"列印中\",paused:\"已暫停\",offline:\"離線\",problem:\"故障\",error:\"錯誤\",finished:\"已完成\",unknown:\"未知\"},temperatures:{nozzle:\"噴嘴\",bed:\"熱床\",chamber:\"腔室\"},progress:\"{{percent}}% 完成\",timeRemaining:\"剩餘 {{time}}\",deleteConfirm:'確定要刪除\"{{name}}\"嗎？',maintenanceOk:\"維護正常\",maintenanceWarning:\"{{count}} 個警告\",maintenanceWarning_plural:\"{{count}} 個警告\",maintenanceDue:\"{{count}} 個到期\",maintenanceDue_plural:\"{{count}} 個到期\",sort:{name:\"名稱\",status:\"狀態\",model:\"型號\",location:\"位置\",ascending:\"升序排列\",descending:\"降序排列\"},cardSize:{small:\"小卡片\",medium:\"中卡片\",large:\"大卡片\",extraLarge:\"超大卡片\"},hideOffline:\"隱藏離線\",nextAvailable:\"下一個可用\",powerOn:\"開機\",offlinePrintersWithPlugs:\"帶智慧插座的離線印表機\",noPrintersConfigured:\"尚未設定印表機\",search:\"搜尋印表機...\",noSearchResults:\"沒有印表機符合您的搜尋或篩選條件\",filter:{allStatuses:\"所有狀態\",allLocations:\"所有位置\"},readyToPrint:\"準備列印\",external:\"外部\",extL:\"外接左\",extR:\"外接右\",deleteArchives:\"刪除列印歸檔\",noLabel:\"無標籤\",printPreview:\"列印預覽\",width:\"寬度\",height:\"高度\",noObjectsFound:\"未找到物件\",objectsLoadedOnPrintStart:\"物件在列印開始時載入\",willBeSkipped:\"將被跳過\",name:\"名稱\",serialCannotBeChanged:\"序列號無法更改\",locationHelp:\"用於分組印表機和篩選佇列任務\",wifiSignal:{veryWeak:\"非常弱\",weak:\"弱\",fair:\"一般\",good:\"良好\",excellent:\"優秀\"},maintenanceUpToDate:\"所有維護均已完成 - 點選檢視\",chamberLightOn:\"開啟腔室燈\",chamberLightOff:\"關閉腔室燈\",files:\"檔案\",browseFiles:\"瀏覽印表機檔案\",autoOffAfterPrint:\"列印後自動關機\",autoOffExecuted:\"已執行自動關機 - 開啟印表機以重設\",hmsErrors:\"HMS 錯誤\",viewHmsErrors:\"檢視 {{count}} 個 HMS 錯誤\",resume:\"繼續\",pause:\"暫停\",stop:\"停止\",camera:\"攝影機\",skipObject:\"跳過物件\",reconnect:\"重新連線\",forceRefresh:\"強制重新整理\",forceRefreshSuccess:\"已請求重新整理\",mqttDebug:\"MQTT 偵錯\",printerInformation:\"印表機資訊\",copyToClipboard:\"複製\",copied:\"已複製！\",state:\"狀態\",wifiSignalLabel:\"Wi-Fi 訊號\",developerMode:\"開發者模式\",enabled:\"已啟用\",disabled:\"已停用\",addedOn:\"新增日期\",sdCard:\"SD 卡\",inserted:\"已插入\",notInserted:\"未插入\",totalPrintHours:\"列印時長\",activeNozzle:\"目前：{{nozzle}} 噴嘴\",nozzleRack:\"噴嘴架\",nozzleDocked:\"已停靠\",nozzleMounted:\"已安裝\",nozzleActive:\"使用中\",nozzleIdle:\"空閒\",nozzleDiameter:\"直徑\",nozzleType:\"類型\",nozzleStatus:\"狀態\",nozzleFilament:\"耗材\",nozzleWear:\"磨損\",nozzleMaxTemp:\"最高溫度\",nozzleSerial:\"序列號\",nozzleHardenedSteel:\"硬化鋼\",nozzleStainlessSteel:\"不鏽鋼\",nozzleTungstenCarbide:\"碳化鎢\",nozzleFlow:\"流量\",nozzleHighFlow:\"高流量\",nozzleStandardFlow:\"標準\",firmwareUpdate:\"韌體更新\",firmwareInstructions:\"在印表機觸控式螢幕上，前往\",firmwareNav:\"導航到\",settings:\"設定\",firmware:\"韌體\",discoverPrinters:\"發現印表機\",searching:\"搜尋中...\",manualEntry:\"手動輸入\",addFromCloud:\"從雲端新增\",toast:{printerDeleted:\"印表機已刪除\",missingSpoolAssignment:\"已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}\",printerAdded:\"印表機已新增\",printerUpdated:\"印表機已更新\",failedToDelete:\"刪除印表機失敗\",failedToAdd:\"新增印表機失敗\",failedToUpdate:\"更新印表機失敗\",commandSent:\"命令已傳送\",failedToSendCommand:\"傳送命令失敗\",turnedOn:\"{{name}} 已開啟\",failedToPowerOn:\"開啟 {{name}} 失敗\",scriptTriggered:\"腳本已觸發\",printStopped:\"列印已停止\",printPaused:\"列印已暫停\",printResumed:\"列印已繼續\",referenceDeleted:\"參考已刪除\",detectionAreaSaved:\"檢測區域已儲存\",failedToRunScript:\"執行腳本失敗\",failedToStopPrint:\"停止列印失敗\",failedToPausePrint:\"暫停列印失敗\",failedToResumePrint:\"繼續列印失敗\",failedToControlChamberLight:\"控制腔室燈失敗\",failedToSetSpeed:\"設定列印速度失敗\",failedToUpdateSetting:\"更新設定失敗\",failedToSkipObjects:\"跳過物件失敗\",failedToRereadRfid:\"重新讀取 RFID 失敗\",failedToCheckPlate:\"檢查列印板失敗\",failedToUpdateLabel:\"更新標籤失敗\",failedToDeleteReference:\"刪除參考失敗\",failedToSaveDetectionArea:\"儲存檢測區域失敗\",plateCheckEnabled:\"列印板檢查已啟用\",plateCheckDisabled:\"列印板檢查已停用\",calibrationSaved:\"校準已儲存！\",calibrationFailed:\"校準失敗\",rfidRereadInitiated:\"已發起 RFID 重新讀取\"},connection:{connected:\"已連線\",offline:\"離線\"},plateStatus:{markCleared:\"將列印板標記為已清理\",cleared:\"列印板已清理\",notCleared:\"列印板未清理\",inUse:\"列印板使用中\"},queue:{inQueue:\"佇列中有 {{count}} 個列印任務\",inQueue_plural:\"佇列中有 {{count}} 個列印任務\"},controls:\"控制\",rfid:{reread:\"重新讀取 RFID\"},bedJog:{title:\"移動熱床\",bed:\"熱床\",step:\"步長 (mm)\",up:\"熱床上移\",down:\"熱床下移\",disabledWhilePrinting:\"列印中已停用\",notHomedTitle:\"印表機未歸零\",notHomedMessage:\"印表機自上次列印以來尚未歸零。請先執行自動歸零以確保安全定位（先停放噴頭，然後歸零 X、Y 和 Z），或者直接移動 — 軟限位將被繞過。\",homeZ:\"自動歸零\",moveAnyway:\"強制移動\",homingStarted:\"印表機自動歸零中…\"},permission:{noAdd:\"您沒有新增印表機的權限\",noEdit:\"您沒有編輯印表機的權限\",noDelete:\"您沒有刪除印表機的權限\",noControl:\"您沒有控制印表機的權限\",noFiles:\"您沒有存取印表機檔案的權限\",noAmsRfid:\"您沒有重新讀取 AMS RFID 的權限\",noSmartPlugControl:\"您沒有控制智慧插座的權限\",noCamera:\"您沒有檢視攝影機的權限\"},modal:{addTitle:\"新增印表機\",editTitle:\"編輯印表機\",myPrinter:\"我的印表機\",selectModel:\"選擇型號...\",locationGroup:\"位置 / 分組（可選）\",locationPlaceholder:\"例如：工作室、辦公室、地下室\",autoArchiveLabel:\"自動歸檔已完成的列印\",fromPrinterSettings:\"來自印表機設定\",modelOptional:\"型號（可選）\",saveChanges:\"儲存更改\"},skipObjects:{tooltip:\"跳過物件\",onlyWhilePrinting:\"跳過物件（僅在列印時）\",requiresMultiple:\"跳過物件（需要2個以上物件）\",title:\"跳過物件\",matchIdsInfo:\"將 ID 與印表機螢幕上的 ID 進行對照\",printerShowsIds:\"印表機螢幕上顯示列印板上物件的 ID\",skipSelected:\"跳過所選\",skipping:\"跳過中...\",noObjectsSelected:\"未選擇物件\",selectObjectsToSkip:\"選擇要從目前列印中跳過的物件\",skipped:\"已跳過\",objectsSkipped:\"物件已跳過\",activeCount:\"{{count}} 個活躍\",waitForLayer:\"等待第2層以上才能跳過物件（目前第 {{layer}} 層）\",skip:\"跳過\",confirmTitle:\"跳過物件？\",confirmMessage:'確定要跳過\"{{name}}\"嗎？此操作無法復原。'},confirm:{deleteTitle:\"刪除印表機\",deleteMessage:'確定要刪除\"{{name}}\"嗎？這將移除所有連線設定。',deleteArchivesNote:\"此印表機的所有列印歷史將被永久刪除。\",keepArchivesNote:\"列印歷史將保留，但不再與此印表機連結。\",stopTitle:\"停止列印\",stopMessage:'確定要停止\"{{name}}\"上的目前列印嗎？這將取消列印任務。',stopButton:\"停止列印\",pauseTitle:\"暫停列印\",pauseMessage:'確定要暫停\"{{name}}\"上的目前列印嗎？',pauseButton:\"暫停列印\",resumeTitle:\"繼續列印\",resumeMessage:'確定要繼續\"{{name}}\"上的列印嗎？',resumeButton:\"繼續列印\",powerOnTitle:\"開啟印表機\",powerOnMessage:'確定要開啟\"{{name}}\"的電源嗎？',powerOnButton:\"開機\",powerOffTitle:\"關閉印表機\",powerOffMessage:'確定要關閉\"{{name}}\"的電源嗎？',powerOffWarning:'警告：\"{{name}}\"正在列印中！確定要關閉電源嗎？這將中斷列印並可能損壞印表機。',powerOffButton:\"關機\"},bulk:{select:\"選擇\",selectAll:\"全選\",selectByLocation:\"按位置選擇\",selected:\"已選擇{{count}}臺\",actions:{stop:\"停止\",pause:\"暫停\",resume:\"繼續\",clearPlate:\"清除列印床\",clearHMS:\"清除通知\"},confirm:{stopTitle:\"停止{{count}}個列印任務\",stopMessage:\"這將取消{{count}}臺印表機上的活動列印任務。此操作無法復原。\",stopButton:\"全部停止\",pauseTitle:\"暫停{{count}}個列印任務\",pauseMessage:\"這將暫停{{count}}臺印表機上的活動列印任務。\",pauseButton:\"全部暫停\",clearPlateTitle:\"清除{{count}}個列印床\",clearPlateMessage:\"這將清除{{count}}臺印表機的列印床，可能會觸發佇列中的任務。\",clearPlateButton:\"全部清除\"},success:\"{{action}}已在{{count}}臺印表機上完成\",partial:\"{{succeeded}}成功，{{failed}}失敗\",noneApplicable:\"沒有選中的印表機處於適合此操作的狀態\",selectByState:\"按狀態選擇\"},discovery:{title:\"發現印表機\",searching:\"搜尋中...\",scanning:\"掃描中...\",scanProgress:\"掃描中... {{scanned}}/{{total}}\",foundPrinters:\"發現 {{count}} 臺印表機\",noPrintersFound:\"未找到印表機\",noPrintersFoundSubnet:\"在指定子網中未找到印表機。\",noPrintersFoundNetwork:\"在網路上未找到印表機。\",allConfigured:\"所有發現的印表機已設定完畢。\",alreadyAdded:\"已新增\",select:\"選擇\",manualEntry:\"手動輸入\",addFromCloud:\"從雲端新增\",subnetToScan:\"要掃描的子網\",dockerNote:\"偵測到 Docker 環境。請以 CIDR 格式輸入印表機所在子網。需要在 docker-compose.yml 中設定 network_mode: host。\",scanSubnet:\"掃描子網查詢印表機\",discoverNetwork:\"在網路上發現印表機\",scanningSubnet:\"正在掃描子網查詢拓竹印表機...\",scanningNetwork:\"正在掃描網路...\",serialRequired:\"需要序列號\",unknown:\"未知\",failedToStart:\"啟動發現失敗\"},drying:{start:\"開始乾燥\",stop:\"停止乾燥\",temperature:\"溫度\",duration:\"時長\",hours:\"小時\",timeRemaining:\"剩餘 {{time}}\",active:\"乾燥中\",notSupported:\"不支援乾燥\",powerRequired:\"連線AMS電源介面卡以啟用乾燥\",startingDrying:\"正在啟動乾燥...\",stoppingDrying:\"正在停止乾燥...\",rotateTray:\"乾燥時旋轉料盤\"},filaments:\"耗材\",openCameraOverlay:\"開啟攝影機疊加層\",openCameraWindow:\"在新視窗中開啟攝影機\",firmwareUpdateAvailable:\"韌體更新可用：{{current}} → {{latest}}\",firmwareUpToDate:\"韌體 {{version}} — 已是最新\",firmwareUpdateButton:\"更新\",plateDetection:{noPermission:\"您沒有更新印表機的權限\",enabledClick:\"列印板檢查已啟用 - 點選停用\",disabledClick:\"列印板檢查已停用 - 點選啟用\",manageCalibration:\"管理列印板檢測校準\",calibrationRequired:\"需要校準\",calibrationInstructions:\"請確保列印板<strong>完全空置</strong>，然後點選校準。\",calibrationDescription:\"校準會拍攝空置列印板的參考影像。後續檢查將與此參考進行比較以檢測物體。\",calibrationTip:\"<strong>提示：</strong>您最多可以為不同的列印板儲存5個校準。系統會在檢查時自動使用最佳匹配。\",plateEmpty:\"列印板似乎是空的\",objectsDetected:\"在列印板上偵測到物體\",confidence:\"置信度\",difference:\"差異\",analysisPreview:\"分析預覽：\",analysisLegend:\"綠色框 = 檢測區域，紅色覆蓋 = 與校準的差異\",savedReferences:\"已儲存的參考 ({{count}}/{{max}})\",deleteReference:\"刪除參考\",labelPlaceholder:\"標籤...\",clickToEdit:\"{{label}} - 點選編輯\",clickToAddLabel:\"點選新增標籤\"},speed:{title:\"列印速度\",silent:\"靜音 (50%)\",standard:\"標準 (100%)\",sport:\"運動 (124%)\",ludicrous:\"瘋狂 (166%)\"},airduct:{title:\"風道模式\",cooling:\"製冷\",heating:\"加熱\"},noSdCard:\"無SD\",door:{open:\"開\",closed:\"關\"},fans:{partCooling:\"零件冷卻風扇\",auxiliary:\"輔助風扇\",chamber:\"腔室風扇\"},clickToViewHmsErrors:\"點選檢視 HMS 錯誤\",estimatedCompletion:\"預計完成時間\",plateNumber:\"板 {{number}}\",slotOptions:\"槽位選項\",amsPopup:{friendlyName:\"AMS 名稱\",friendlyNamePlaceholder:\"例如 AMS 友好名稱\",serialNumber:\"序列號\",firmwareVersion:\"韌體\",save:\"儲存\",clear:\"清除\",noEditPermission:\"您沒有重新命名 AMS 單元的權限\"},firmwareModal:{title:\"韌體更新\",titleUpToDate:\"韌體資訊\",currentVersion:\"目前版本：\",latestVersion:\"最新版本：\",releaseNotes:\"發布說明\",checkingPrereqs:\"正在檢查前提條件...\",sdCardReady:\"SD 卡已就緒。點選下方上傳韌體。\",uploadedSuccess:\"韌體已上傳到 SD 卡！\",applyInstructions:\"在印表機上套用更新：\",step1:\"在印表機觸控式螢幕上，前往<strong>設定</strong>\",step2:\"導航到<strong>韌體</strong>\",step3:\"選擇<strong>從 SD 卡更新</strong>\",step4:\"更新將需要 10-20 分鐘\",done:\"完成\",starting:\"啟動中...\",uploadFirmware:\"上傳韌體\",uploadFailed:\"上傳啟動失敗：{{error}}\",uploadedToast:\"韌體已上傳！請在印表機螢幕上觸發更新。\",availableVersions:\"可用版本\",usable:\"可用\",unavailable:\"不可用\",installed:\"已安裝\",newerBadge:\"較新\",olderBadge:\"較舊\",currentBadge:\"目前\"},accessCodePlaceholder:\"留空以保持目前值\",roi:{title:\"檢測區域 (ROI)\",xStart:\"X 起點\",yStart:\"Y 起點\",width:\"寬度\",height:\"高度\",instruction:\"調整檢測區域以聚焦到列印板。預覽中的綠色框顯示目前區域。\"},developerModeWarning:\"以下印表機未啟用開發者區域網路模式：{{names}}。某些功能可能無法使用。\",howToEnable:\"如何啟用\",incompatibleFile:\"此檔案是為 {{slicedFor}} 切片的，但該印表機是 {{printerModel}}\",dropNotPrintable:\"只能列印 .gcode 和 .gcode.3mf 檔案\",dropToPrint:\"拖放以列印\",cannotPrint:\"印表機忙碌\"},archives:{title:\"列印歸檔\",searchPlaceholder:\"搜尋歸檔...\",filterByPrinter:\"按印表機篩選\",filterByStatus:\"按狀態篩選\",sortBy:\"排序方式\",sortNewest:\"最新優先\",sortOldest:\"最舊優先\",sortName:\"名稱\",sortDuration:\"時長\",sortLargest:\"最大優先\",sortSmallest:\"最小優先\",sortSize:\"大小\",noArchives:\"未找到歸檔\",noArchivesSearch:\"沒有匹配搜尋的歸檔\",originalPrintNotVisible:\"原始列印不可見 - 請嘗試清除篩選條件\",noArchivesYet:\"尚無歸檔\",prints:\"條列印\",pagination:{showing:\"顯示\",to:\"至\",of:\"共\",show:\"每頁\",page:\"頁\",all:\"全部\"},loadingArchives:\"載入歸檔中...\",releaseToUpload:\"放開以上傳\",showAll:\"顯示全部\",showFavoritesOnly:\"僅顯示收藏\",gridView:\"網格檢視\",listView:\"列表檢視\",calendarView:\"日曆檢視\",logView:\"列印日誌\",manageTags:\"管理標籤\",showFailedPrints:\"顯示失敗的列印\",hideFailedPrints:\"隱藏失敗的列印\",hideDuplicates:\"隱藏重複項\",viewOriginalPrint:\"點選檢視原始列印 (#{{id}})\",printTime:\"列印時間\",filamentUsed:\"耗材用量\",cost:\"成本\",reprint:\"重新列印\",preview:\"預覽\",deleteArchive:\"刪除歸檔\",deleteConfirm:\"確定要刪除此歸檔嗎？\",favorite:\"收藏\",unfavorite:\"取消收藏\",viewDetails:\"檢視詳情\",status:{completed:\"已完成\",failed:\"失敗\",stopped:\"已停止\"},toast:{source3mfAttached:\"源 3MF 已附加：{{filename}}\",failedUploadSource3mf:\"上傳源 3MF 失敗\",source3mfRemoved:\"源 3MF 已移除\",failedRemoveSource3mf:\"移除源 3MF 失敗\",f3dAttached:\"F3D 已附加：{{filename}}\",failedUploadF3d:\"上傳 F3D 失敗\",f3dRemoved:\"F3D 已移除\",failedRemoveF3d:\"移除 F3D 失敗\",timelapseAttached:\"縮時攝影已附加：{{filename}}\",timelapseAlreadyAttached:\"縮時攝影已附加\",noMatchingTimelapse:\"未找到匹配的縮時攝影\",failedScanTimelapse:\"掃描縮時攝影失敗\",failedAttachTimelapse:\"附加縮時攝影失敗\",timelapseRemoved:\"縮時攝影已移除\",failedRemoveTimelapse:\"移除縮時攝影失敗\",timelapseUploaded:\"縮時攝影已上傳：{{filename}}\",failedUploadTimelapse:\"上傳縮時攝影失敗\",archiveDeleted:\"歸檔已刪除\",failedDeleteArchive:\"刪除歸檔失敗\",addedToFavorites:\"已新增到收藏\",removedFromFavorites:\"已從收藏中移除\",projectUpdated:\"專案已更新\",failedUpdateProject:\"更新項目失敗\",linkCopied:\"連結已複製到剪貼簿\",failedCopyLink:\"複製連結失敗\",photoDeleted:\"照片已刪除\",failedDeletePhoto:\"刪除照片失敗\",failedDeleteArchives:\"刪除歸檔失敗\",failedUpdateFavorites:\"更新收藏失敗\",exportDownloaded:\"匯出已下載\",exportFailed:\"匯出失敗\"},menu:{print:\"列印\",schedule:\"排程\",openInBambuStudio:\"在切片軟體中開啟\",slice:\"切片\",externalLink:\"外部連結\",viewOnMakerWorld:\"在 MakerWorld 上檢視\",preview3d:\"3D 預覽\",viewTimelapse:\"檢視縮時攝影\",scanForTimelapse:\"掃描縮時攝影\",uploadTimelapse:\"上傳縮時攝影\",removeTimelapse:\"移除縮時攝影\",downloadSource3mf:\"下載源 3MF\",uploadSource3mf:\"上傳源 3MF\",replaceSource3mf:\"替換源 3MF\",removeSource3mf:\"移除源 3MF\",uploadF3d:\"上傳 F3D\",replaceF3d:\"替換 F3D\",downloadF3d:\"下載 F3D\",removeF3d:\"移除 F3D\",download:\"下載\",copyDownloadLink:\"複製下載連結\",qrCode:\"QR Code\",viewPhotos:\"檢視照片\",viewPhotosCount:\"檢視照片 ({{count}})\",projectPage:\"專案頁面\",addToFavorites:\"新增到收藏\",removeFromFavorites:\"從收藏中移除\",edit:\"編輯\",goToProject:\"前往專案：{{name}}\",addToProject:\"新增到專案\",removeFromProject:\"從專案中移除\",loading:\"載入中...\",noProjectsAvailable:\"無可用專案\",select:\"選擇\",deselect:\"取消選擇\",delete:\"刪除\"},permission:{noReprint:\"您沒有重新列印此歸檔的權限\",noAddToQueue:\"您沒有新增到佇列的權限\",noUpdateArchives:\"您沒有更新歸檔的權限\",noUploadFiles:\"您沒有上傳檔案的權限\",noDownload:\"您沒有下載歸檔的權限\",noCopyLink:\"您沒有複製下載連結的權限\",noDelete:\"您沒有刪除此歸檔的權限\",noCreate:\"您沒有建立歸檔的權限\"},card:{previousPlate:\"上一個板\",nextPlate:\"下一個板\",plateNumber:\"板 {{index}}\",moreOptions:\"右鍵檢視更多選項\",addToFavorites:\"新增到收藏\",removeFromFavorites:\"從收藏中移除\",cancelled:\"已取消\",failed:\"失敗\",duplicate:\"重複\",duplicateTitle:\"此模型之前已列印過\",openSource3mf:\"在 Bambu Studio 中開啟源 3MF（右鍵檢視更多選項）\",downloadF3d:\"下載 Fusion 360 設計檔案\",viewTimelapse:\"檢視縮時攝影\",viewPhoto:\"檢視 1 張照片\",viewPhotos:\"檢視 {{count}} 張照片\",openFolder:\"開啟資料夾：{{name}}\",slicedFile:\"已切片檔案 - 可以列印\",sourceFile:\"僅原始檔 - 無 AMS 對應可用\",gcode:\"GCODE\",source:\"原始檔\",project:\"專案：{{name}}\",estimated:\"預計：{{time}}\",actual:\"實際：{{time}}\",accuracy:\"準確度：{{percent}}%\",filament:\"{{weight}}g\",layer:\"{{count}} 層\",layers:\"{{count}} 層\",object:\"{{count}} 個物件\",objects:\"{{count}} 個物件\",slicedFor:\"為 {{model}} 切片\",uploadedBy:\"上傳者\",noPermissionReprint:\"您沒有重新列印的權限\",noFileForReprint:\"無可用的 3MF 檔案 — 列印紀錄時無法從印表機下載該檔案\",noPermissionEdit:\"您沒有編輯歸檔的權限\",noPermissionDelete:\"您沒有刪除歸檔的權限\",reprint:\"重新列印\",schedulePrint:\"排程列印\",schedule:\"排程\",openInBambuStudio:\"在切片軟體中開啟\",openInBambuStudioToSlice:\"在切片軟體中開啟進行切片\",slice:\"切片\",externalLink:\"外部連結\",makerWorld:\"MakerWorld：{{designer}}\",viewProject:\"檢視專案\",noExternalLink:\"無外部連結\",preview3d:\"3D 預覽\",download:\"下載\",edit:\"編輯\",delete:\"刪除\"},modal:{deleteArchive:\"刪除歸檔\",deleteConfirm:'確定要刪除\"{{name}}\"嗎？此操作無法復原。',deleteButton:\"刪除\",removeSource3mf:\"移除源 3MF\",removeSource3mfConfirm:'確定要從\"{{name}}\"中移除源 3MF 檔案嗎？這將刪除原始切片專案檔案。',removeButton:\"移除\",removeF3d:\"移除 F3D\",removeF3dConfirm:'確定要從\"{{name}}\"中移除 Fusion 360 設計檔案嗎？',removeTimelapse:\"移除縮時攝影\",removeTimelapseConfirm:'確定要從\"{{name}}\"中移除縮時攝影影片嗎？',timelapse:\"{{name}} - 縮時攝影\",selectTimelapse:\"選擇縮時攝影\",selectTimelapseDesc:\"未找到自動匹配。請選擇此列印的縮時攝影：\",deleteArchives:\"刪除歸檔\",deleteArchivesConfirm:\"確定要刪除 {{count}} 個歸檔嗎？此操作無法復原。\",deleteCount:\"刪除 {{count}} 個\"},page:{title:\"歸檔\",printsCount:\"{{filtered}} / {{total}} 次列印\",dropFilesHere:\"將 .3mf 檔案拖放到此處\",releaseToUpload:\"放開以上傳\",only3mfSupported:\"僅支援 .3mf 檔案\",close:\"關閉\",selected:\"已選擇 {{count}} 個\",selectAll:\"全選\",tags:\"標籤\",project:\"專案\",favorite:\"收藏\",delete:\"刪除\",toggledFavorites:\"已切換 {{count}} 個歸檔的收藏狀態\",failedUpdateFavorites:\"更新收藏失敗\",archivesDeleted:\"已刪除 {{count}} 個歸檔\",failedDeleteArchives:\"刪除歸檔失敗\",photoDeleted:\"照片已刪除\",failedDeletePhoto:\"刪除照片失敗\"},list:{name:\"名稱\",printer:\"印表機\",date:\"日期\",size:\"大小\",actions:\"操作\",hasTimelapse:\"有縮時攝影\"},log:{date:\"日期\",printName:\"列印名稱\",printer:\"印表機\",user:\"使用者\",status:\"狀態\",duration:\"時長\",filament:\"耗材\",allPrinters:\"所有印表機\",allUsers:\"所有使用者\",allStatuses:\"所有狀態\",cancelled:\"已取消\",skipped:\"已跳過\",dateFrom:\"從\",dateTo:\"到\",noEntries:\"未找到列印日誌條目\",showing:\"顯示 {{count}} / {{total}} 條\",rowsPerPage:\"行數\",page:\"頁\",prev:\"上一頁\",next:\"下一頁\",clearLog:\"清除日誌\",clearLogTitle:\"清除列印日誌\",clearLogConfirm:\"所有列印日誌條目將被永久刪除。歸檔和佇列項目不受影響。此操作無法復原。確定要繼續嗎？\",clearLogButton:\"全部清除\",cleared:\"已清除 {{count}} 條日誌\",clearFailed:\"清除列印日誌失敗\"}},queue:{title:\"列印佇列\",subtitle:\"排程和管理您的列印任務\",addToQueue:\"新增到佇列\",print:\"列印\",reprint:\"重新列印\",schedulePrint:\"排程列印\",editQueueItem:\"編輯佇列項目\",printToPrinters:\"列印到 {{count}} 臺印表機\",queueToPrinters:\"佇列到 {{count}} 臺印表機\",queueSelectedPlates:\"將 {{count}} 個熱床加入佇列\",selectAllPlates:\"選擇全部 {{count}} 個熱床\",deselectAll:\"取消全選\",printQueued:\"已加入列印佇列\",itemsQueued:\"{{count}} 個任務已加入佇列\",sending:\"傳送中...\",sendingProgress:\"傳送中 {{current}}/{{total}}...\",adding:\"新增中...\",addingProgress:\"新增中 {{current}}/{{total}}...\",savingProgress:\"儲存中 {{current}}/{{total}}...\",clearQueue:\"清空佇列\",clearHistory:\"清除歷史\",emptyQueue:\"佇列為空\",position:\"位置\",scheduledTime:\"排程時間\",moveUp:\"上移\",moveDown:\"下移\",startNow:\"立即開始\",printingInProgress:\"列印進行中...\",viewArchive:\"檢視歸檔\",viewInFileManager:\"在檔案管理器中檢視\",itemCount:\"{{count}} 個項目\",itemCount_plural:\"{{count}} 個項目\",dragToReorder:\"拖曳以重新排序（僅限盡快）\",reorderHint:'位置僅影響\"儘快\"項目。排程項目按設定時間執行。',sjf:{label:\"SJF\",tooltip:\"最短任務優先 — 排程器優先處理較短的列印任務\"},addedBy:\"由 {{name}} 新增\",nextInQueue:\"佇列中的下一個\",clearPlateSuccess:\"列印板已清理 — 準備進行下一個列印\",plateNumber:\"板 {{index}}\",quantity:\"數量\",quantityHint:\"建立 {{count}} 個佇列項目\",activeBatches:\"活躍批次\",batchProgress:\"已完成 {{completed}}/{{total}}\",cancelBatch:\"取消剩餘\",batchCancelled:\"已取消剩餘批次項目\",cancelBatchConfirmTitle:\"取消批次\",cancelBatchConfirmMessage:\"取消此批次中所有剩餘的待處理項目？\",batch:\"批次\",sections:{currentlyPrinting:\"正在列印\",queued:\"佇列中\",history:\"歷史\"},status:{pending:\"等待中\",waiting:\"等待中\",printing:\"列印中\",paused:\"已暫停\",completed:\"已完成\",failed:\"失敗\",skipped:\"已跳過\",cancelled:\"已取消\"},summary:{printing:\"列印中\",queued:\"佇列中\",totalTime:\"總佇列時間\",totalWeight:\"總佇列重量\",history:\"歷史\"},filter:{allPrinters:\"所有印表機\",unassigned:\"未分配\",allStatus:\"所有狀態\",allLocations:\"所有位置\",any:\"任意\"},sort:{byPosition:\"按位置排序\",byName:\"按名稱排序\",byPrinter:\"按印表機排序\",bySchedule:\"按排程排序\",byDate:\"按日期排序\",ascendingOldest:\"升序（最舊優先）\",descendingNewest:\"降序（最新優先）\"},badges:{staged:\"已暫存\",requiresPrevious:\"需要前一個成功\",autoPowerOff:\"自動關機\",gcodeInjection:\"G-code\"},empty:{title:\"沒有排程的列印\",description:'從歸檔頁面使用右鍵選單中的\"排程\"選項來排程列印，或拖放檔案開始。'},time:{asap:\"儘快\",overdue:\"已逾期\",now:\"現在\",lessThanMinute:\"不到一分鐘\",inMinutes:\"{{count}} 分鐘後\",inHours:\"{{count}} 小時後\"},actions:{stopPrint:\"停止列印\",startPrint:\"開始列印\",requeue:\"重新佇列\"},bulkEdit:{title:\"編輯 {{count}} 個項目\",title_plural:\"編輯 {{count}} 個項目\",description:\"僅更改的設定將套用於所選項目。\",printer:\"印表機\",noChange:\"— 不更改 —\",queueOptions:\"佇列選項\",staged:\"暫存（手動開始）\",autoPowerOff:\"列印後自動關機\",requirePrevious:\"要求前一個成功\",printOptions:\"列印選項\",bedLevelling:\"熱床調平\",flowCalibration:\"流量校準\",vibrationCalibration:\"振動校準\",layerInspection:\"首層檢查\",timelapse:\"縮時攝影\",useAms:\"使用 AMS\",applyChanges:\"套用更改\",selectAll:\"全選\",deselectAll:\"取消全選\",selected:\"已選擇 {{count}} 個\",editSelected:\"編輯所選\",cancelSelected:\"取消所選\"},confirm:{cancelTitle:\"取消排程列印\",cancelMessage:'確定要取消\"{{name}}\"嗎？',stopTitle:\"停止列印\",stopMessage:'確定要停止目前列印\"{{name}}\"嗎？這將取消印表機上的列印任務。',removeTitle:\"從歷史中移除\",removeMessage:'確定要從佇列歷史中移除\"{{name}}\"嗎？',clearHistoryTitle:\"清除歷史\",clearHistoryMessage:\"確定要從歷史中移除所有 {{count}} 個項目嗎？\",cancelButton:\"取消列印\",stopButton:\"停止列印\",thisPrint:\"此列印\",thisItem:\"此項目\"},toast:{cancelled:\"佇列項目已取消\",cancelFailed:\"取消項目失敗\",removed:\"佇列項目已移除\",removeFailed:\"移除項目失敗\",stopped:\"列印已停止\",stopFailed:\"停止列印失敗\",released:\"列印已加入佇列\",startFailed:\"開始列印失敗\",reorderFailed:\"重新排序佇列失敗\",historyCleared:\"已清除 {{count}} 條歷史紀錄\",clearHistoryFailed:\"清除歷史失敗\",updateFailed:\"更新項目失敗\",bulkCancelled:\"已取消 {{count}} 個項目\",bulkCancelFailed:\"批次取消項目失敗\"},timeline:{listView:\"列表\",timelineView:\"時間線\",unassigned:\"未分配\",noData:\"當天沒有計畫的列印任務\",allDoneBy:\"所有列印預計在 {{time}} 前完成\",staged:\"暫存\",filterAll:\"全部顯示\",filterPrinting:\"列印中\",filterQueued:\"佇列中\",time:{anyMoment:\"即將完成\",minutesLeft:\"剩餘{{minutes}} 分鐘\",hoursLeft:\"剩餘{{hours}}小時\",hoursMinutesLeft:\"剩餘{{hours}}小時{{minutes}} 分鐘\"},day:{previous:\"前一天\",next:\"後一天\",today:\"今天\"}},permissions:{noStopPrint:\"您沒有停止列印的權限\",noStartPrint:\"您沒有開始列印的權限\",noEdit:\"您沒有編輯此佇列項目的權限\",noCancel:\"您沒有取消此佇列項目的權限\",noRequeue:\"您沒有重新佇列的權限\",noRemove:\"您沒有移除此佇列項目的權限\",noClearHistory:\"您沒有清除所有歷史的權限\",noEditItems:\"您沒有編輯佇列項目的權限\",noCancelItems:\"您沒有取消佇列項目的權限\"}},backgroundDispatch:{unknownFile:\"未知檔案\",unknownPrinter:\"未知印表機\",startingPrints:\"正在開始列印\",progressSummary:\"{{complete}}/{{total}} 完成 • 已分發：{{dispatched}} • 處理中：{{processing}}\",expandDetails:\"展開分發詳情\",collapseDetails:\"收起分發詳情\",dismissToast:\"關閉分發通知\",cancelDispatchJob:\"取消分發任務\",cancel:\"取消\",cancelling:\"取消中…\",status:{dispatched:\"已分發\",processing:\"處理中\",completed:\"已完成\",failed:\"失敗\",cancelled:\"已取消\"},toast:{cancellingUpload:\"取消上傳中...\",cancelled:\"分發已取消\",cancelFailed:\"取消分發失敗\",completeWithFailures:\"後台分發完成：{{completed}} 成功，{{failed}} 失敗\",completeSuccess:\"後台分發完成：{{completed}} 成功\",printStartedRemaining:\"{{completed}} 個列印已開始，{{remaining}} 個正在傳送...\"}},stats:{title:\"儀表板\",subtitle:\"拖曳小工具以重新排列。點選眼睛圖示隱藏。\",overview:\"概覽\",totalPrints:\"總列印次數\",successRate:\"成功率\",totalPrintTime:\"總列印時間\",printTime:\"列印時間\",totalFilament:\"總耗材用量\",filamentUsed:\"耗材用量\",filamentCost:\"耗材成本\",totalCost:\"總成本\",energyUsed:\"能耗\",energyCost:\"能源成本\",energyWarmingUpTooltip:\"能耗追蹤正在收集每小時快照。當所選範圍之前至少存在一個快照時，時間段合計將變得準確。早期數值可能偏低。\",averagePrintTime:\"平均列印時間\",printsPerDay:\"每日列印次數\",byPrinter:\"按印表機\",printsByPrinter:\"各印表機列印次數\",byMaterial:\"按材料\",byMonth:\"按月份\",last7Days:\"最近 7 天\",last30Days:\"最近 30 天\",last90Days:\"最近 90 天\",allTime:\"全部時間\",quickStats:\"快速統計\",printActivity:\"列印活動\",filamentTypes:\"耗材類型\",filamentTrends:\"耗材趨勢\",failureAnalysis:\"失敗分析\",timeAccuracy:\"時間準確度\",successful:\"成功：\",failed:\"失敗：\",perfectEstimate:\"100% = 完美估計\",noTimeAccuracyData:\"尚無時間準確度資料\",noFilamentData:\"尚無耗材資料\",noPrinterData:\"尚無印表機資料\",noPrintData:\"尚無列印資料\",noPrintDataLast30Days:\"最近 30 天無列印資料\",failureReasons:\"失敗原因\",topFailureReasons:\"主要失敗原因\",failedPrintsCount:\"{{failed}} / {{total}} 次列印失敗\",lastWeekRate:\"上週：{{rate}}%\",resetLayout:\"重設佈局\",recalculateCosts:\"重新計算成本\",recalculateCostsHint:\"使用目前耗材價格重新計算所有歸檔成本\",exportStats:\"匯出統計\",exportAsCsv:\"匯出為 CSV\",exportAsExcel:\"匯出為 Excel\",hiddenCount:\"{{count}} 個已隱藏\",exportDownloaded:\"匯出已下載\",exportFailed:\"匯出失敗\",layoutReset:\"佈局已重設\",recalculatedCosts:\"已為 {{count}} 個歸檔重新計算成本\",recalculateFailed:\"重新計算成本失敗\",loadingStats:\"載入統計資料中...\",noPermissionResetLayout:\"您沒有重設佈局的權限\",noPermissionRecalculate:\"您沒有重新計算成本的權限\",noPrintDataInRange:\"所選範圍內無列印資料\",periodFilament:\"期間耗材\",periodCost:\"期間成本\",avgPerPrint:\"每次列印平均\",usageOverTime:\"隨時間的使用量\",filamentByWeight:\"重量\",printDuration:\"列印時長\",printerUtilization:\"印表機利用率\",filamentSuccess:\"按材料成功率\",printHabits:\"列印習慣\",printTimeOfDay:\"列印時段\",colorDistribution:\"顏色分佈\",noColorData:\"尚無顏色資料\",records:\"紀錄\",longestPrint:\"最長列印\",heaviestPrint:\"最重列印\",mostExpensivePrint:\"最貴列印\",busiestDay:\"最忙碌的一天\",successStreak:\"連續成功\",streakPrint:\"連續列印\",streakPrints:\"{{count}} 次連續列印\",printerStats:\"印表機統計\",hours:\"小時\",avgPrints:\"平均列印\",noArchiveData:\"尚無列印資料\",filamentByTime:\"時間\",avgWeight:\"平均重量\",avgTime:\"平均時間\",filamentByPrints:\"列印次數\",timeframe:{today:\"今天\",\"this-week\":\"本週\",\"this-month\":\"本月\",\"last-7\":\"最近 7 天\",\"last-30\":\"最近 30 天\",\"last-90\":\"最近 90 天\",\"this-year\":\"今年\",\"all-time\":\"全部時間\",custom:\"自訂範圍\",from:\"從\",to:\"到\"},allUsers:\"所有使用者\",noUser:\"無使用者（系統）\",filterByUser:\"按使用者篩選\"},maintenance:{title:\"維護\",overview:\"概覽\",allOk:\"所有維護均已完成\",dueCount:\"{{count}} 項到期\",dueCount_plural:\"{{count}} 項到期\",warningCount:\"{{count}} 個警告\",warningCount_plural:\"{{count}} 個警告\",totalPrintTime:\"總列印時間\",nextMaintenance:\"下次維護\",nothingDue:\"無到期項目\",tasks:\"任務\",lastPerformed:\"上次執行\",interval:\"間隔\",hoursRemaining:\"剩餘 {{hours}} 小時\",hoursOverdue:\"逾期 {{hours}} 小時\",markDone:\"標記為完成\",performMaintenance:\"執行維護\",history:\"歷史\",noHistory:\"無維護歷史\",editPrintHours:\"編輯列印時間\",currentHours:\"目前小時數\",statusTab:\"狀態\",settingsTab:\"設定\",overdueCount:\"{{count}} 個逾期\",dueSoonCount:\"{{count}} 個即將到期\",dueSoon:\"即將到期\",allGood:\"一切正常\",overdueBy:\"逾期 {{duration}}\",dueIn:\"{{duration}} 後到期\",timeLeft:\"剩餘 {{duration}}\",day:\"1 天\",days:\"{{count}} 天\",week:\"1 週\",weeks:\"{{count}} 週\",month:\"1 個月\",months:\"{{count}} 個月\",year:\"1 年\",maintenanceTypes:\"維護類型\",maintenanceTypesDescription:\"系統類型和您的自訂維護任務\",addCustomType:\"新增自訂類型\",restoreDefaults:\"恢復預設任務\",intervalType:\"間隔類型\",intervalValue:\"間隔 ({{type}})\",icon:\"圖示\",documentationLink:\"說明文件連結（可選）\",assignToPrinters:\"分配給印表機\",selectAtLeastOnePrinter:\"至少選擇一臺印表機\",addType:\"新增類型\",custom:\"自訂\",printHours:\"列印小時數\",calendarDays:\"日曆天數\",exampleName:\"例如：更換 HEPA 過濾器\",viewDocumentation:\"檢視說明文件\",timeBasedInterval:\"基於時間的間隔\",intervalOverrides:\"間隔覆蓋\",intervalOverridesDescription:\"為特定印表機自訂間隔\",assignedToPrinters:\"已分配給印表機：\",noPrintersAssigned:\"未分配印表機\",addPrinterShort:\"新增：\",printersAssignedClick:\"已分配 {{count}} 臺印表機 - 點選管理\",removeFromPrinter:\"從此印表機移除\",types:{lubricateCarbonRods:\"潤滑碳纖維杆\",lubricateRails:\"潤滑線性導軌\",cleanNozzle:\"清潔噴嘴/熱端\",checkBelts:\"檢查皮帶張力\",cleanBuildPlate:\"清潔列印板\",checkExtruder:\"檢查擠出機齒輪\",checkCooling:\"檢查冷卻風扇\",generalInspection:\"綜合檢查\",cleanCarbonRods:\"清潔碳纖維杆\",lubricateSteelRods:\"潤滑鋼杆\",cleanSteelRods:\"清潔鋼杆\",cleanLinearRails:\"清潔線性導軌\",checkPtfeTube:\"檢查 PTFE 管\",replaceHepaFilter:\"更換 HEPA 過濾器\",replaceCarbonFilter:\"更換活性炭過濾器\",lubricateLeftNozzleRail:\"潤滑左噴嘴導軌\"},maintenanceComplete:\"維護已標記為完成\",typeUpdated:\"維護類型已更新\",typeDeleted:\"維護類型已刪除\",defaultsRestored:\"已恢復 {{count}} 個預設任務\",printHoursUpdated:\"列印小時數已更新\",printerAssigned:\"印表機已分配\",printerRemoved:\"印表機已移除\",deleteTypeConfirm:'刪除\"{{name}}\"？',deleteSystemTypeTitle:\"刪除預設維護任務？\",deleteSystemTypeMessage:'確定要刪除預設維護任務\"{{name}}\"嗎？',noPermissionUpdate:\"您沒有更新維護項目的權限\",noPermissionPerform:\"您沒有執行維護的權限\",noPermissionEditTypes:\"您沒有編輯維護類型的權限\",noPermissionDeleteTypes:\"您沒有刪除維護類型的權限\",noPermissionEditHours:\"您沒有編輯列印時間的權限\",noPermissionRemovePrinter:\"您沒有移除印表機分配的權限\",noPermissionAssignPrinter:\"您沒有分配印表機的權限\",noPermissionEditIntervals:\"您沒有編輯間隔的權限\",configureSettings:\"設定維護類型和間隔\"},settings:{title:\"設定\",general:\"通用\",tabs:{general:\"通用\",smartPlugs:\"智慧插座\",notifications:\"通知\",queue:\"工作流程\",filament:\"耗材\",network:\"網路\",apiKeys:\"API 金鑰\",virtualPrinter:\"虛擬印表機\",spoolbuddy:\"SpoolBuddy\",failureDetection:\"故障檢測\",users:\"身份驗證\",backup:\"備份\",emailAuth:\"信箱認證\",ldap:\"LDAP\",twoFa:\"雙因素認證\",oidc:\"SSO / OIDC\"},spoolbuddy:{infoTitle:\"SpoolBuddy 裝置\",infoBody:\"SpoolBuddy kiosk 透過心跳自動註冊。如果裝置不再使用，或守護程式當機遺留了過時的重複項，可在此取消註冊。\",duplicatesTitle:\"已註冊 {{count}} 台裝置\",duplicatesBody:\"kiosk 介面只使用最先註冊的裝置。如果其中有因當機遺留的過時重複項，請取消註冊它——線上裝置會在下次心跳時重新註冊自己。\",empty:\"尚未註冊任何 SpoolBuddy 裝置。\",online:\"線上\",offline:\"離線\",unregister:\"取消註冊\",unregisterSuccess:\"裝置已取消註冊\",unregisterError:\"取消註冊裝置失敗\",confirmTitle:\"取消註冊 SpoolBuddy 裝置？\",confirmBody:'將從資料庫中移除 \"{{hostname}}\" ({{deviceId}})。如果裝置線上，會在下次心跳時重新註冊自己。',ipAddress:\"IP 位址\",firmware:\"韌體\",lastSeen:\"上次線上\",daemonUptime:\"守護程式執行時間\",systemUptime:\"系統執行時間\",never:\"從未\",nfc:\"NFC\",scale:\"磅秤\",cpuTemp:\"CPU 溫度\",memory:\"記憶體\",disk:\"磁碟\",update:\"更新\",updateConfirmTitle:\"更新 SpoolBuddy 守護程式？\",updateConfirmBody:'對 \"{{hostname}}\" 觸發軟體更新？更新完成後守護程式將重新啟動。',restartBrowser:\"重新啟動瀏覽器\",restartBrowserConfirmTitle:\"重新啟動 kiosk 瀏覽器？\",restartBrowserConfirmBody:'在 \"{{hostname}}\" 上重新啟動 kiosk 瀏覽器？顯示將短暫黑屏。',restartDaemon:\"重新啟動守護程式\",restartDaemonConfirmTitle:\"重新啟動 SpoolBuddy 守護程式？\",restartDaemonConfirmBody:'在 \"{{hostname}}\" 上重新啟動 SpoolBuddy 守護程式？裝置將離線幾秒鐘。',reboot:\"重新開機\",rebootConfirmTitle:\"重新開機？\",rebootConfirmBody:'重新開機 \"{{hostname}}\"？裝置將離線約一分鐘。',shutdown:\"關機\",shutdownConfirmTitle:\"關閉裝置？\",shutdownConfirmBody:'關閉 \"{{hostname}}\"？您需要實體存取才能重新開機。',commandConfirm:\"確認\",commandQueued:\"命令已加入佇列\",commandError:\"傳送命令失敗\"},ldap:{title:\"LDAP 認證\",enabledDesc:\"LDAP 認證已啟用\",disabledDesc:\"LDAP 認證已停用\",disabledHint:\"在下方設定並儲存 LDAP 設定，然後啟用。\",enabled:\"LDAP 認證已啟用\",disabled:\"LDAP 認證已停用\",feature1:\"使用者可以使用 LDAP 憑據登入\",feature2:\"本機管理員帳戶作為後備保留\",feature3:\"登入時 LDAP 群組對應到 BamBuddy 群組\",serverConfig:\"LDAP 伺服器設定\",serverUrl:\"伺服器 URL\",serverUrlHint:\"使用 ldap:// 進行標準連線或 ldaps:// 進行 SSL 連線\",security:\"安全\",securityHint:\"StartTLS 將普通連線升級為 TLS。LDAPS 從一開始就使用 TLS。\",bindDn:\"繫結 DN（服務帳戶）\",bindPassword:\"繫結密碼\",searchBase:\"搜尋基礎 DN\",userFilter:\"使用者搜尋過濾器\",userFilterHint:\"{username} 替換為登入使用者名稱。OpenLDAP 使用 (uid={username})。\",autoProvision:\"自動建立使用者\",autoProvisionHint:\"首次 LDAP 登入時自動建立 BamBuddy 帳戶\",defaultGroup:\"預設群組\",defaultGroupNone:\"— 無（無復原）—\",defaultGroupHint:\"當 LDAP 使用者透過身份驗證但不在任何已對應的 LDAP 群組中時分配的備援群組。留空以使未對應的使用者沒有權限。\",groupMapping:\"群組對應（JSON）\",groupMappingHint:\"將 LDAP 群組 DN 對應到 BamBuddy 群組。可用群組：\",testConnection:\"測試連線\",settingsSaved:\"LDAP 設定已儲存\",errors:{serverRequired:\"LDAP 伺服器 URL 為必填項\",searchBaseRequired:\"搜尋基礎 DN 為必填項\",enableAuthFirst:\"請先啟用認證\",configureLdapFirst:\"請先儲存 LDAP 設定\"}},email:{smtpSettings:\"SMTP 設定\",smtpHost:\"SMTP 伺服器\",smtpPort:\"SMTP 連接埠\",security:\"安全\",authentication:\"認證\",username:\"使用者名稱\",password:\"密碼\",fromEmail:\"寄件信箱\",fromName:\"寄件人名稱\",testConnection:\"測試 SMTP 連線\",testRecipient:\"測試收件信箱\",sendTest:\"傳送測試郵件\",sending:\"傳送中...\",save:\"儲存設定\",saving:\"儲存中...\",advancedAuth:\"進階認證\",advancedAuthEnabled:\"進階認證已啟用\",advancedAuthEnabledDesc:\"基於信箱的使用者管理功能已啟用。新使用者將透過郵件收到自動產生的密碼，使用者可以透過忘記密碼功能重設密碼。\",advancedAuthDisabled:\"進階認證已停用\",advancedAuthDisabledDesc:\"啟用進階認證以啟用基於信箱的使用者管理功能。\",enable:\"啟用\",disable:\"停用\",feature1:\"密碼自動產生並透過郵件傳送給新使用者\",feature2:\"使用者可以使用使用者名稱或信箱登入\",feature3:\"忘記密碼功能可用\",feature4:\"管理員可以透過郵件重設使用者密碼\",errors:{requiredFields:\"請填寫所有必填欄位\",usernameRequired:\"啟用認證時需要使用者名稱\",enterTestEmail:\"請輸入測試信箱地址\",smtpServerAndEmail:\"測試前請填寫 SMTP 伺服器和寄件信箱\",usernamePasswordRequired:\"啟用認證時需要使用者名稱和密碼\",configureSmtpFirst:\"請先設定並測試 SMTP 設定\",enableAuthFirst:\"請先啟用身份驗證才能使用基於電子郵件的功能。\"},success:{settingsSaved:\"SMTP 設定儲存成功\"},securityOptions:{starttls:\"STARTTLS（連接埠 587）\",ssl:\"SSL/TLS（連接埠 465）\",none:\"無（連接埠 25）\"},authOptions:{enabled:\"已啟用\",disabled:\"已停用\"}},appearance:\"外觀\",notifications:\"通知\",smartPlugs:\"智慧插座\",spoolman:\"Spoolman\",updates:\"更新\",language:\"語言\",languageDescription:\"選擇您的首選語言\",theme:\"主題\",themeLight:\"淺色\",themeDark:\"深色\",themeSystem:\"跟隨系統\",defaultView:\"預設檢視\",defaultViewDescription:\"開啟應用程式時顯示的頁面\",checkForUpdates:\"檢查更新\",autoUpdate:\"自動更新\",currentVersion:\"目前版本\",latestVersion:\"最新版本\",upToDate:\"已是最新版本\",updateAvailable:\"有可用更新\",notificationLanguage:\"通知語言\",notificationLanguageDescription:\"推送通知的語言\",bedCooledThreshold:\"熱床冷卻閾值\",bedCooledThresholdDescription:\"列印後熱床被視為已冷卻的溫度\",userNotificationsEnabled:\"使用者通知\",userNotificationsEnabledDescription:\"啟用使用者通知選單和列印任務事件的郵件通知。需要進階身份驗證。\",userNotificationsDisabledHint:\"請啟用進階身份驗證以使用使用者通知。\",notificationProviders:\"通知提供者\",addProvider:\"新增提供者\",editProvider:\"編輯提供者\",providerType:\"提供者類型\",testNotification:\"測試通知\",testSuccess:\"測試通知傳送成功\",testFailed:\"傳送測試通知失敗\",quietHours:\"免打擾時間\",quietHoursDescription:\"在此時間段內不傳送通知\",quietHoursStart:\"開始\",quietHoursEnd:\"結束\",events:{title:\"通知事件\",printStart:\"列印開始\",printComplete:\"列印完成\",printFailed:\"列印失敗\",printStopped:\"列印停止\",printProgress:\"進度里程碑\",printProgressDescription:\"在 25%、50%、75% 時通知\",printerOffline:\"印表機離線\",printerError:\"印表機錯誤\",filamentLow:\"耗材不足\",maintenanceDue:\"維護到期\",maintenanceDueDescription:\"需要維護時通知\"},smartPlug:{title:\"智慧插座\",add:\"新增智慧插座\",edit:\"編輯智慧插座\",name:\"名稱\",ipAddress:\"IP 位址\",linkedPrinter:\"連結印表機\",autoOn:\"自動開啟\",autoOnDescription:\"列印開始時開啟\",autoOff:\"自動關閉\",autoOffDescription:\"列印完成後關閉\",offDelay:\"關閉延遲\",offDelayMinutes:\"列印後分鐘數\",offDelayTemp:\"當噴嘴溫度低於\",currentState:\"目前狀態\",turnOn:\"開啟\",turnOff:\"關閉\"},filamentTracking:\"耗材追蹤\",filamentTrackingDesc:\"選擇如何追蹤您的耗材。您可以使用內建庫存或連線外部 Spoolman 伺服器。\",filamentChecks:\"耗材檢查\",disableFilamentWarnings:\"停用耗材警告\",disableFilamentWarningsDesc:\"在列印或加入佇列時不顯示耗材不足警告\",preferLowestFilament:\"優先使用剩餘最少的耗材\",preferLowestFilamentDesc:\"當多個料盤匹配時，使用剩餘耗材最少的那個\",trackingModeBuiltIn:\"內建庫存\",trackingModeBuiltInDesc:\"包含 RFID 自動匹配和用量追蹤\",trackingModeSpoolmanDesc:\"外部耗材管理伺服器\",builtInFeatureRfid:\"自動檢測 AMS 中的拓竹 RFID 耗材\",builtInFeatureUsage:\"追蹤每次列印的耗材消耗\",builtInFeatureCatalog:\"管理耗材、顏色和 K 值設定檔案\",builtInFeatureThirdParty:\"第三方耗材可分配到庫存耗材\",amsSyncButton:\"從 AMS 同步重量\",amsSyncTitle:\"從 AMS 同步耗材重量\",amsSyncMessage:\"這將使用已連線印表機的目前 AMS 剩餘百分比值覆蓋所有庫存耗材重量。用於從損壞的重量資料中恢復。印表機必須線上。\",amsSyncing:\"同步中...\",amsSyncSuccess:\"已同步 {{synced}} 個耗材，跳過 {{skipped}} 個\",amsSyncError:\"從 AMS 同步重量失敗\",spoolmanUrl:\"Spoolman URL\",spoolmanUrlHint:\"Spoolman 伺服器的 URL（例如 http://localhost:7912）\",spoolmanConnected:\"已連線\",spoolmanDisconnected:\"未連線\",status:\"狀態\",connect:\"連線\",disconnect:\"斷開\",howSyncWorks:\"同步工作原理\",syncInfoRfidOnly:\"僅同步帶有 RFID 的官方拓竹耗材\",syncInfoAutoCreate:\"首次同步時自動在 Spoolman 中建立新耗材\",syncInfoThirdPartySkipped:\"非拓竹耗材（第三方、重新填充的）將被跳過\",linkingExistingSpools:\"連結現有耗材\",linkingExistingSpoolsDesc:'要將現有的 Spoolman 耗材連結到您的 AMS，請將滑鼠懸停在 AMS 槽位上並點選\"連結到 Spoolman\"。',syncMode:\"同步模式\",syncModeAuto:\"自動\",syncModeManual:\"僅手動\",syncModeAutoDesc:\"偵測到更改時自動同步 AMS 資料\",syncModeManualDesc:\"僅在手動觸發時同步\",syncAmsData:\"同步 AMS 資料\",syncAmsDataDesc:\"手動將印表機 AMS 資料同步到 Spoolman\",allPrinters:\"所有印表機\",noDefaultPrinter:\"無預設（每次詢問）\",sidebarOrder:\"側邊欄順序\",saveThumbnails:\"儲存縮圖\",captureFinishPhoto:\"拍攝完成照片\",noPrintersConfigured:\"未設定印表機\",archiveMode:{always:\"始終建立歸檔條目\",never:\"從不建立歸檔條目\",ask:\"每次詢問\"},checkForUpdatesLabel:\"檢查更新\",checkPrinterFirmware:\"檢查印表機韌體\",includeBetaUpdates:\"包含測試版本\",includeBetaUpdatesDesc:\"檢查更新時通知測試版和預發布版本\",enableRetry:\"啟用重試\",homeAssistantDescription:\"透過 Home Assistant 控制智慧插座\",environmentManagedLabel:\"（環境變數管理）\",autoEnabledViaEnv:\"透過環境變數自動啟用\",urlFromEnvReadOnly:\"值由 HA_URL 環境變數設定（只讀）\",tokenFromEnvReadOnly:\"值由 HA_TOKEN 環境變數設定（只讀）\",mqttConnectedTo:\"已連線到\",prometheusDescription:\"以 Prometheus 格式暴露印表機資料\",noSmartPlugsTitle:\"未設定智慧插座\",noSmartPlugsDescription:\"新增基於 Tasmota 的智慧插座以追蹤能耗並自動化電源控制。\",noProvidersTitle:\"未設定提供者\",noProvidersDescription:\"新增提供者以接收警報。\",noTemplatesAvailable:\"無可用範本。重新啟動後端以載入預設範本。\",apiPermissionView:\"檢視印表機狀態和佇列\",apiPermissionEdit:\"新增和移除列印佇列中的項目\",apiKeysEmptyTitle:\"無 API 金鑰\",apiKeysEmptyDescription:\"建立 API 金鑰以與外部服務整合。\",noUsersFound:\"未找到使用者\",noGroupsFound:\"未找到群組\",noGroupsAvailable:\"無可用群組\",passwordsDoNotMatch:\"密碼不符\",systemGroupWarning:\"系統群組名稱不可更改\",authDisabledTitle:\"身份驗證已停用\",authDisabledFeature1:\"需要登入才能存取系統\",authDisabledFeature2:\"建立多個使用者並基於群組的權限管理\",authDisabledFeature3:\"使用 50+ 個細粒度權限控制存取\",userHasCreated:\"此使用者已建立：\",userItemsQuestion:\"您想如何處理這些項目？\",deleteUserConfirm:\"確定要刪除此使用者嗎？\",actionCannotBeUndone:\"此操作無法復原。\",addFirstSmartPlug:\"新增您的第一個智慧插座\",providers:\"提供者\",log:\"日誌\",testAll:\"全部測試\",testResults:\"測試結果\",testPassedCount:\"{{count}} 個透過\",testFailedCount:\"{{count}} 個失敗\",messageTemplates:\"訊息範本\",messageTemplatesDescription:\"自訂每個事件的通知訊息。\",apiKeys:\"API 金鑰\",apiKeysDescription:\"建立 API 金鑰用於外部整合和 Webhook。\",createKey:\"建立金鑰\",apiKeyCreated:\"API 金鑰建立成功\",apiKeyCopyWarning:\"請立即複製此金鑰 - 它不會再次顯示！\",useInApiBrowser:\"在 API 瀏覽器中使用\",createNewApiKey:\"建立新 API 金鑰\",keyName:\"金鑰名稱\",keyNamePlaceholder:\"例如：Home Assistant、OctoPrint\",readStatus:\"讀取狀態\",readStatusDescription:\"檢視印表機狀態和佇列\",manageQueue:\"管理佇列\",manageQueueDescription:\"新增和移除列印佇列中的項目\",controlPrinter:\"控制印表機\",controlPrinterDescription:\"暫停、繼續和停止列印\",unnamedKey:\"未命名金鑰\",lastUsed:\"上次使用\",read:\"讀取\",control:\"控制\",createFirstKey:\"建立您的第一個金鑰\",webhookEndpoints:\"Webhook 端點\",webhookApiKeyHint:\"在 X-API-Key 請求頭中使用您的 API 金鑰。\",webhook:{getAllStatus:\"獲取所有印表機狀態\",getSpecificStatus:\"獲取特定印表機狀態\",addToQueue:\"新增到列印佇列\",pausePrint:\"暫停列印\",resumePrint:\"繼續列印\",stopPrint:\"停止列印\"},apiBrowser:\"API 瀏覽器\",apiBrowserDescription:\"瀏覽和測試所有可用的 API 端點。\",apiKeyForTesting:\"測試用 API 金鑰\",apiKeyPlaceholder:\"在此貼上您的 API 金鑰以測試需要認證的端點...\",apiKeyHint:\"此金鑰將作為 X-API-Key 請求頭隨請求傳送。\",deleteApiKeyTitle:\"刪除 API 金鑰\",deleteApiKeyMessage:\"確定要刪除此 API 金鑰嗎？使用此金鑰的所有整合將停止工作。\",deleteKey:\"刪除金鑰\",amsDisplayThresholds:\"AMS 顯示閾值\",amsThresholdsDescription:\"設定 AMS 濕度和溫度指示器的顏色閾值。\",humidity:\"濕度\",goodGreen:\"良好（綠色）\",fairOrange:\"一般（橙色）\",aboveFairBad:\"超過一般閾值顯示為紅色（差）\",fairAlsoDryingThreshold:\"此閾值也用於觸發自動乾燥\",temperature:\"溫度\",goodBlue:\"良好（藍色）\",aboveFairHot:\"超過一般閾值顯示為紅色（熱）\",historyRetention:\"歷史保留\",keepSensorHistory:\"保留感測器歷史\",historyRetentionDescription:\"較舊的濕度和溫度資料將被自動刪除\",defaultPrintOptions:\"預設列印選項\",defaultPrintOptionsDescription:\"設定新列印的預設選項值。可在列印對話方塊中逐次覆蓋。\",defaultBedLevelling:\"熱床調平\",defaultBedLevellingDesc:\"列印前自動調平熱床\",defaultFlowCali:\"流量校準\",defaultFlowCaliDesc:\"校準擠出流量\",defaultVibrationCali:\"振動校準\",defaultVibrationCaliDesc:\"減少振紋偽影\",defaultLayerInspect:\"首層檢測\",defaultLayerInspectDesc:\"AI首層檢測\",defaultTimelapse:\"縮時攝影\",defaultTimelapseDesc:\"錄製縮時攝影影片\",staggeredStart:\"錯開啟動\",staggeredStartDescription:\"多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。\",plateClear:\"熱床清空確認\",requirePlateClear:\"需要熱床清空確認\",requirePlateClearDescription:\"啟用後，排程器會在已完成列印的印表機上啟動佇列列印之前，等待每臺印表機的熱床清空確認。停用後，也會隱藏印表機卡片上的列印板狀態標記和「將列印板標記為已清理」按鈕。\",gcodeInjection:\"G-code注入\",gcodeInjectionDescription:'為Farmloop、SwapMod、AutoClear和Printflow 3D等自動列印系統設定自訂G-code，在列印開始和/或結束時注入。程式碼片段按印表機型號設定，在佇列項目上啟用\"注入G-code\"時套用。',gcodeInjectionNoPrinters:\"未找到印表機。新增印表機以設定G-code程式碼片段。\",gcodeStartLabel:\"開始G-code\",gcodeEndLabel:\"結束G-code\",gcodeStartPlaceholder:\"在列印開始前插入的G-code...\",gcodeEndPlaceholder:\"在列印結束後追加的G-code...\",staggerGroupSize:\"群組大小\",staggerGroupSizeHelp:\"每個群組要同時啟動的印表機數量\",staggerInterval:\"間隔（分鐘）\",staggerIntervalHelp:\"每個群組啟動之間的延遲\",queueDrying:\"自動乾燥\",queueDryingDescription:\"在佇列列印之間，印表機空閒時自動乾燥AMS耗材。使用上方的濕度閾值觸發乾燥。\",queueDryingEnabled:\"啟用自動乾燥\",queueDryingEnabledDescription:\"當印表機空閒且濕度超過閾值時，自動啟動AMS乾燥\",queueDryingBlock:\"等待乾燥完成\",queueDryingBlockDescription:\"阻止列印佇列直到乾燥完成。關閉時，列印優先於乾燥。\",ambientDryingEnabled:\"環境乾燥\",ambientDryingEnabledDescription:\"當空閒印表機的濕度超過閾值時自動乾燥耗材，無需佇列列印。\",dryingPresets:\"乾燥預設\",dryingPresetsDescription:\"每種耗材類型的溫度和時長。AMS 2 Pro使用較低溫度，AMS-HT支援較高溫度。\",dryingFilament:\"耗材\",printModal:\"列印對話方塊\",expandCustomMapping:\"預設展開自訂對應\",expandCustomMappingDescription:\"列印到多臺印表機時，預設展開顯示每臺印表機的 AMS 對應\",authentication:\"身份驗證\",authEnabledDescription:\"您的實例已透過使用者身份驗證保護\",authDisabledDescription:\"啟用以要求登入並管理使用者存取\",authDisabledMessage:\"啟用身份驗證以建立使用者帳戶、管理權限並保護您的 Bambuddy 實例。\",enableAuthentication:\"啟用身份驗證\",currentUser:\"目前使用者\",changePassword:\"修改密碼\",admin:\"管理員\",users:\"使用者\",addUser:\"新增使用者\",groups:\"群組\",addGroup:\"新增群組\",system:\"系統\",noDescription:\"無描述\",userCount:\"{{count}} 個使用者\",permissionCount:\"{{count}} 個權限\",createUser:\"建立使用者\",username:\"使用者名稱\",enterUsername:\"輸入使用者名稱\",password:\"密碼\",enterPassword:\"輸入密碼（至少 6 個字元）\",confirmPassword:\"確認密碼\",confirmPasswordPlaceholder:\"確認密碼\",viewReleaseOnGitHub:\"在 GitHub 上檢視發布\",turnAllPlugsOn:\"開啟所有插座\",turnAllPlugsOff:\"關閉所有插座\",clearNotificationLogs:\"清除通知日誌\",clearLogsMessage:\"這將永久刪除所有 30 天前的通知日誌。此操作無法復原。\",clearLogs:\"清除日誌\",resetUiPreferences:\"重設 UI 偏好\",resetUiPreferencesMessage:\"這將重設所有 UI 偏好為預設值：側邊欄順序、主題、儀表板佈局、檢視模式和排序偏好。您的印表機、歸檔和伺服器設定不會受到影響。清除後頁面將重新載入。\",resetPreferences:\"重設偏好\",deleteGroupTitle:\"刪除群組\",deleteGroupMessage:\"確定要刪除此群組嗎？此群組中的使用者將失去這些權限。\",deleteGroup:\"刪除群組\",disableAuthenticationTitle:\"停用身份驗證\",disableAuthenticationMessage:\"確定要停用身份驗證嗎？這將使您的 Bambuddy 實例無需登入即可存取。所有使用者將保留在資料庫中但身份驗證將被停用。\",disableAuthentication:\"停用身份驗證\",configureBambuddy:\"設定 Bambuddy\",systemDefault:\"系統預設\",archiveSettings:\"歸檔設定\",newWindow:\"新視窗\",embeddedOverlay:\"嵌入式疊加層\",preferredSlicer:\"首選切片軟體\",preferredSlicerDescription:\"選擇要用於開啟檔案的切片軟體\",externalCameras:\"外部攝影機\",costTracking:\"成本追蹤\",printsOnly:\"僅列印\",totalConsumption:\"總消耗\",dataManagement:\"資料管理\",storageUsage:\"儲存使用情況\",storageUsageDescription:\"按類別的資料使用情況明細\",storageUsageTotal:\"總計\",storageUsageErrors:\"錯誤\",storageUsageOtherBreakdown:\"其他（包括靜態資源、腳本和設定檔案）\",storageUsageSystem:\"系統\",storageUsageData:\"資料\",storageUsageUnavailable:\"儲存使用資訊不可用\",clearNotificationLogsDescription:\"刪除 30 天前的通知日誌\",resetUiPreferencesDescription:\"重設側邊欄順序、主題、檢視模式和佈局偏好。印表機、歸檔和設定不受影響。\",enableHomeAssistant:\"啟用 Home Assistant\",enableMqtt:\"啟用 MQTT\",useTls:\"使用 TLS\",enableMetricsEndpoint:\"啟用指標端點\",availableMetrics:\"可用指標\",editUser:\"編輯使用者\",deleteUserTitle:\"刪除使用者\",groupName:\"群組名稱\",leaveEmptyForAnonymous:\"留空為匿名\",leaveEmptyForNoAuth:\"留空為無認證\",enterNewPassword:\"輸入新密碼\",confirmNewPassword:\"確認新密碼\",enterGroupName:\"輸入群組名稱\",enterDescriptionOptional:\"輸入描述（可選）\",enterCurrentPassword:\"輸入目前密碼\",enterNewPasswordMin6:\"輸入新密碼（至少 6 個字元）\",toast:{keyCopied:\"金鑰已複製到剪貼簿\",copyFailed:\"複製金鑰失敗\",keyAddedToBrowser:\"金鑰已新增到 API 瀏覽器\",clearLogsFailed:\"清除日誌失敗\",uiPreferencesReset:\"UI 偏好已重設。重新整理中...\",authDisabled:\"身份驗證已成功停用\",authDisableFailed:\"停用身份驗證失敗\",apiKeyCreated:\"API 金鑰已建立\",apiKeyDeleted:\"API 金鑰已刪除\",userCreated:\"使用者建立成功\",userUpdated:\"使用者更新成功\",userDeleted:\"使用者刪除成功\",groupCreated:\"群組建立成功\",groupUpdated:\"群組更新成功\",groupDeleted:\"群組刪除成功\",fillRequiredFields:\"請填寫所有必填欄位\",passwordsDoNotMatch:\"密碼不符\",passwordTooShort:\"密碼至少需要 6 個字元\",enterGroupName:\"請輸入群組名稱\",settingsSaved:\"設定已儲存\",cameraSettingsSaved:\"攝影機設定已儲存\",enterCameraUrl:\"請輸入攝影機 URL\",passwordChanged:\"密碼修改成功\",connectionFailed:\"連線失敗\",testFailed:\"測試失敗\",cameraConnected:\"攝影機已連線{{resolution}}\"},testConnection:\"測試連線\",catalog:{spoolCatalog:\"耗材目錄\",spoolCatalogDescription:\"按品牌/類型的空耗材重量。用於新增耗材時的自動重量查詢。\",searchCatalog:\"搜尋目錄...\",addNewEntry:\"新增新條目\",namePlaceholder:\"名稱（例如：Bambu Lab - 塑膠）\",weight:\"重量\",type:\"類型\",default:\"預設\",custom:\"自訂\",noMatch:\"沒有條目匹配您的搜尋\",empty:\"目錄中沒有條目\",deleteEntry:\"刪除條目\",deleteConfirm:'確定要刪除\"{{name}}\"嗎？',resetCatalog:\"重設目錄\",resetConfirm:\"重設目錄為預設值？這將移除所有自訂條目。\",loadFailed:\"載入耗材目錄失敗\",nameWeightRequired:\"名稱和重量為必填項\",entryAdded:\"條目已新增\",addFailed:\"新增條目失敗\",entryUpdated:\"條目已更新\",updateFailed:\"更新條目失敗\",entryDeleted:\"條目已刪除\",deleteFailed:\"刪除條目失敗\",resetSuccess:\"目錄已重設為預設值\",resetFailed:\"重設目錄失敗\",exported:\"已匯出 {{count}} 條\",imported:\"已匯入 {{added}} 條（跳過 {{skipped}} 條）\",importFailed:\"匯入失敗：無效的 JSON 格式\",exportTooltip:\"匯出目錄為 JSON\",importTooltip:\"從 JSON 匯入目錄\",resetTooltip:\"重設為預設值\",selectedCount:\"已選擇 {{count}} 項\",deleteSelected:\"刪除所選\",bulkDeleteConfirm:\"確定要刪除 {{count}} 個條目嗎？\",bulkDeleted:\"已刪除 {{count}} 個條目\",bulkDeleteFailed:\"刪除條目失敗\"},colorCatalog:{title:\"顏色目錄\",description:\"按製造商/材料的耗材顏色。用於新增耗材時的自動顏色查詢。\",searchColors:\"搜尋顏色...\",allManufacturers:\"所有製造商\",addNewColor:\"新增新顏色\",manufacturer:\"製造商\",colorName:\"顏色名稱\",hex:\"十六進位\",materialOptional:\"材料（可選）\",showing:\"顯示 {{filtered}} / {{total}} 種顏色\",noMatch:\"沒有顏色匹配您的搜尋\",empty:\"目錄中沒有顏色\",deleteColor:\"刪除顏色\",deleteConfirm:'確定要刪除\"{{name}}\"嗎？',resetCatalog:\"重設顏色目錄\",resetConfirm:\"重設目錄為預設值？這將移除所有自訂顏色。\",sync:\"同步\",starting:\"啟動中...\",syncTooltip:\"從 FilamentColors.xyz 同步（2000+ 種顏色，可能需要一分鐘）\",loadFailed:\"載入顏色目錄失敗\",fieldsRequired:\"製造商、顏色名稱和十六進位顏色為必填項\",colorAdded:\"顏色已新增\",addFailed:\"新增顏色失敗\",colorUpdated:\"顏色已更新\",updateFailed:\"更新顏色失敗\",colorDeleted:\"顏色已刪除\",deleteFailed:\"刪除顏色失敗\",resetSuccess:\"顏色目錄已重設為預設值\",resetFailed:\"重設目錄失敗\",syncUpToDate:\"已是最新（檢查了 {{count}} 種顏色）\",syncComplete:\"新增了 {{added}} 種新顏色（{{skipped}} 種已存在）\",syncError:\"同步錯誤\",syncFailed:\"從 FilamentColors.xyz 同步失敗\",exported:\"已匯出 {{count}} 種顏色\",imported:\"已匯入 {{added}} 種顏色（跳過 {{skipped}} 種）\",importFailed:\"匯入失敗：無效的 JSON 格式\",selectedCount:\"已選擇 {{count}} 項\",deleteSelected:\"刪除所選\",bulkDeleteConfirm:\"確定要刪除 {{count}} 種顏色嗎？\",bulkDeleted:\"已刪除 {{count}} 種顏色\",bulkDeleteFailed:\"刪除顏色失敗\"},dateFormat:\"日期格式\",dateFormatUs:\"美式 (MM/DD/YYYY)\",dateFormatEu:\"歐式 (DD/MM/YYYY)\",dateFormatIso:\"ISO (YYYY-MM-DD)\",timeFormat:\"時間格式\",timeFormat12:\"12小時制 (3:30 PM)\",timeFormat24:\"24小時制 (15:30)\",defaultPrinter:\"預設印表機\",defaultPrinterDescription:\"為上傳、重印和其他操作預選此印表機。\",slicerBambuStudio:\"Bambu Studio\",slicerOrcaSlicer:\"OrcaSlicer\",sidebarOrderDescription:\"拖曳側邊欄項目以重新排序。在此處重設為預設順序。\",setDefault:\"設為預設\",sidebarOrderSetDefaultHint:\"設為預設將目前選單順序套用於尚未自訂的使用者。\",sidebarDefaultSet:\"已設定預設選單順序。\",sidebarDefaultCleared:\"已清除預設選單順序。\",sidebarDefaultFailed:\"設定預設選單順序失敗。\",reset:\"重設\",darkMode:\"深色模式\",lightMode:\"淺色模式\",active:\"(目前)\",background:\"背景\",accent:\"強調色\",style:\"樣式\",bgNeutral:\"中性\",bgWarm:\"暖色\",bgCool:\"冷色\",bgOled:\"OLED 純黑\",bgSlate:\"石板藍\",bgForest:\"森林綠\",accentGreen:\"綠色\",accentTeal:\"青色\",accentBlue:\"藍色\",accentOrange:\"橙色\",accentPurple:\"紫色\",accentRed:\"紅色\",styleClassic:\"經典\",styleGlow:\"發光\",styleVibrant:\"鮮豔\",themeToggleHint:\"使用側邊欄中的太陽/月亮圖示在深色和淺色模式之間切換。\",autoArchivePrints:\"自動歸檔列印\",autoArchiveDescription:\"列印完成時自動儲存3MF檔案\",saveThumbnailsDescription:\"從3MF檔案中提取並儲存預覽影像\",captureFinishPhotoDescription:\"列印完成時從印表機攝影機拍照\",ffmpegNotInstalled:\"未安裝ffmpeg\",ffmpegRequired:\"攝影機捕獲需要ffmpeg。透過 <brew>brew install ffmpeg</brew>（macOS）或 <apt>apt install ffmpeg</apt>（Linux）安裝。\",camera:\"攝影機\",cameraViewMode:\"攝影機檢視模式\",cameraOverlayDescription:\"攝影機在主螢幕上以可調大小的覆蓋層開啟\",cameraWindowDescription:\"攝影機在單獨的瀏覽器視窗中開啟\",externalCamerasDescription:\"設定外部攝影機以替換內建印表機攝影機。支援MJPEG流、RTSP、HTTP快照和USB攝影機（V4L2）。啟用後，外部攝影機將用於即時檢視和完成照片。\",cameraPlaceholderUsb:\"裝置路徑 (/dev/video0)\",cameraPlaceholderUrl:\"攝影機URL (rtsp://... 或 http://...)\",cameraTypeMjpeg:\"MJPEG 流\",cameraTypeRtsp:\"RTSP 流\",cameraTypeSnapshot:\"HTTP 快照\",cameraTypeUsb:\"USB 攝影機 (V4L2)\",cameraRotation:\"旋轉\",test:\"測試\",connected:\"已連線\",disconnected:\"未連線\",currency:\"貨幣\",defaultFilamentCost:\"預設耗材成本（每公斤）\",electricityCost:\"每千瓦時電費\",energyDisplayMode:\"能源顯示模式\",energyModePrintDescription:\"儀表板顯示列印期間使用的能源總和\",energyModeTotalDescription:\"儀表板顯示智慧插座的累計能源\",fileManager:\"檔案管理器\",createArchiveEntry:\"列印時建立歸檔條目\",createArchiveEntryDescription:\"從檔案管理器列印時，可選擇建立歸檔條目\",lowDiskSpaceWarning:\"磁碟空間不足警告\",lowDiskSpaceDescription:\"當可用磁碟空間低於此閾值時顯示警告\",printerFirmware:\"印表機韌體\",checkFirmwareDescription:\"檢查Bambu Lab的印表機韌體更新\",bambuddySoftware:\"Bambuddy 軟體\",autoCheckDescription:\"啟動時自動檢查新版本\",checkNow:\"立即檢查\",updateAvailableVersion:\"可用更新：v{{version}}\",releaseNotes:\"發布說明\",updateViaDocker:\"透過 Docker Compose 更新：\",installUpdate:\"安裝更新\",latestVersionRunning:\"您正在執行最新版本\",failedToCheckUpdates:\"檢查更新失敗：{{error}}\",backupRestore:\"備份與恢復\",backupRestoreDescription:\"匯出/匯入設定並設定GitHub 備份\",goToBackup:\"前往備份\",externalUrl:\"外部URL\",externalUrlDescription:\"Bambuddy可存取的外部URL。用於通知影像和外部整合。\",bambuddyUrl:\"Bambuddy URL\",externalUrlHint:\"包含協定和連接埠（例如：http://192.168.1.100:8000）\",ftpRetry:\"FTP重試\",ftpRetryDescription:\"當印表機Wi-Fi 不穩定時重試FTP操作。適用於3MF下載、列印上傳、縮時攝影下載和韌體更新。\",autoRetryDescription:\"自動重試失敗的FTP操作\",retryAttempts:\"重試次數\",retryDelay:\"重試延遲\",connectionTimeout:\"連線超時\",time_one:\"{{count}} 次\",time_other:\"{{count}} 次\",second_one:\"{{count}} 秒\",second_other:\"{{count}} 秒\",nSeconds:\"{{count}} 秒\",increaseForWeakWifi:\"對Wi-Fi 訊號弱的印表機增加此值\",homeAssistant:\"Home Assistant\",homeAssistantFullDescription:\"連線到Home Assistant，透過HA REST API控制智慧插座。支援switch、light、input_boolean和script實體。\",homeAssistantUrl:\"Home Assistant URL\",longLivedAccessToken:\"長期存取權杖\",haTokenHint:\"在HA中建立權杖：個人資料 → 長期存取權杖 → 建立權杖\",connectionSuccessful:\"連線成功\",connectionFailed:\"連線失敗\",haConnectionSuccess:\"已成功連線到Home Assistant。\",haConnectionFailed:\"連線Home Assistant失敗。\",mqttPublishing:\"MQTT發布\",mqttDescription:\"將BamBuddy事件發布到外部MQTT代理，用於與Node-RED、Home Assistant和其他自動化系統整合。\",mqttEnableDescription:\"向外部MQTT代理發布事件\",brokerHostname:\"代理主機名稱\",port:\"連接埠\",usernameOptional:\"使用者名稱（可選）\",passwordOptional:\"密碼（可選）\",topicPrefix:\"主題前綴\",topicPrefixHint:\"主題格式：{{prefix}}/printers/<serial>/status 等\",prometheusMetrics:\"Prometheus 指標\",prometheusEndpointDescription:\"在 <code>/api/v1/metrics</code> 公開印表機指標，用於Prometheus/Grafana監控。\",bearerTokenOptional:\"Bearer權杖（可選）\",bearerTokenHint:\"設定後，請求必須包含 <code>Authorization: Bearer <token></code>\",metricsConnectionStatus:\"連線狀態\",metricsPrinterState:\"印表機狀態（空閒/列印中等）\",metricsPrintProgress:\"列印進度 0-100%\",metricsBedTemp:\"熱床溫度\",metricsNozzleTemp:\"噴嘴溫度\",metricsPrintsTotal:\"按結果分類的總列印數\",metricsMore:\"...以及更多（層數、風扇、佇列、耗材用量）\",smartPlugsDescription:\"連線智慧插座（Tasmota或Home Assistant）以自動化電源控制並追蹤印表機的能源使用情況。\",allOn:\"全部開啟\",allOff:\"全部關閉\",addSmartPlug:\"新增智慧插座\",energySummary:\"能源概要\",currentPower:\"目前功率\",plugsOnline:\"{{reachable}}/{{total}} 個插座線上\",today:\"今天\",yesterday:\"昨天\",total:\"總計\",enablePlugsForSummary:\"啟用插座以檢視能源概要\",addNotificationProvider:\"新增\",systemBadge:\"(系統)\",creating:\"建立中...\",changing:\"修改中...\",deleteUserAndItems:\"刪除使用者及其所有項目\",deleteUserKeepItems:\"刪除使用者，保留項目（將變為無主項目）\",ok:\"確定\",twoFa:{totpTitle:\"身份驗證器 App (TOTP)\",totpDesc:\"使用 Google Authenticator、Aegis 或 Authy 等 App。\",emailOtpTitle:\"郵件 OTP\",emailOtpDesc:\"登入時向 {{email}} 傳送一次性驗證碼。\",emailOtpNoEmail:\"請先為帳戶新增信箱地址以啟用此方式。\",addEmailFirst:\"您的帳戶沒有信箱地址，請聯絡管理員新增。\",setupTotp:\"設定身份驗證器 App\",setupAuthApp:\"設定身份驗證器 App\",setupInstructions:\"使用身份驗證器 App 掃描QR Code，然後輸入驗證碼確認。\",manualEntry:\"無法掃描？請手動輸入此金鑰：\",scannedContinue:\"已掃描 — 繼續\",enterCodeToConfirm:\"請輸入身份驗證器 App 中的6位驗證碼以確認設定。\",activate:\"啟用\",disableTotp:\"停用身份驗證器\",disableConfirmHint:\"請輸入有效的 TOTP 碼或備用碼來停用身份驗證器。\",totpDisabled:\"身份驗證器 App 已停用。\",emailOtpEnabled:\"郵件 OTP 已啟用。\",emailOtpDisabled:\"郵件 OTP 已停用。\",smtpRequired:\"請先設定並測試SMTP設定。\",invalidCode:\"無效驗證碼，請重試。\",enableEmailOtp:\"啟用郵件 OTP\",disableEmailOtp:\"停用郵件 OTP\",emailSetupEnterCode:\"驗證碼已傳送至您的信箱地址。請在下方輸入以確認您擁有此信箱。\",verifyAndEnable:\"驗證並啟用\",emailDisablePasswordHint:\"請輸入您的帳戶密碼以確認停用郵件 OTP。\",passwordPlaceholder:\"輸入您的密碼\",backupCodesTitle:\"儲存備用碼\",backupCodesWarning:\"請將這些碼儲存在安全的地方。每個碼只能使用一次，且不會再次顯示。\",backupCodesRemaining:\"剩餘 {{count}} 個備用碼\",savedCodes:\"已儲存\",regenBackup:\"重新產生備用碼\",regenBackupHint:\"輸入目前 TOTP 碼以產生 10 個新備用碼，所有現有備用碼將失效。\",newBackupCodes:\"新備用碼\",linkedAccounts:\"已連結的 SSO 帳戶\",linkedAccountsDesc:\"以下外部身份提供者已與您的帳戶連結。\",oidcUnlinked:\"帳戶已解除連結。\"},oidc:{title:\"SSO / OIDC 提供者\",desc:\"設定 OpenID Connect 提供者以實現單點登入。\",addProvider:\"新增提供者\",newProvider:\"新提供者\",empty:\"尚未設定 OIDC 提供者。\",created:\"提供者已建立。\",updated:\"提供者已更新。\",deleted:\"提供者已刪除。\",deleteTitle:\"刪除提供者\",deleteMessage:'刪除\"{{name}}\"？所有連結帳戶將斷開連線。',form:{name:\"顯示名稱\",issuerUrl:\"頒發者 URL\",clientId:\"客戶端 ID\",clientSecret:\"客戶端金鑰\",scopes:\"作用域\",iconUrl:\"圖示 URL（可選）\",enabled:\"已啟用\",autoCreate:\"自動建立使用者\",autoCreateDesc:\"首次登入時自動建立本機帳戶。\",autoLink:\"自動連結已有帳戶\",autoLinkDesc:\"首次登入時透過信箱匹配現有本機帳戶並自動連結。\",secretHint:\"留空以保留目前\",secretPlaceholder:\"新金鑰\"}}},notification:{printStarted:{title:\"列印已開始\",body:\"{{printer}}：{{filename}} 已開始列印\"},printCompleted:{title:\"列印已完成\",body:\"{{printer}}：{{filename}} 已成功完成\"},printFailed:{title:\"列印失敗\",body:\"{{printer}}：{{filename}} 列印失敗\"},printStopped:{title:\"列印已停止\",body:\"{{printer}}：{{filename}} 已停止\"},printProgress:{title:\"列印進度\",body:\"{{printer}}：{{filename}} 已完成 {{percent}}%\"},printerOffline:{title:\"印表機離線\",body:\"{{printer}} 已離線\"},printerError:{title:\"印表機錯誤\",body:\"{{printer}}：{{error}}\"},filamentLow:{title:\"耗材不足\",body:\"{{printer}}：耗材即將用完\"},maintenanceDue:{title:\"維護到期\",body:\"{{printer}}：{{items}} 需要關注\"}},errors:{generic:\"出了點問題\",networkError:\"網路錯誤。請檢查您的連線。\",notFound:\"未找到\",unauthorized:\"未授權\",serverError:\"伺服器錯誤\",validationError:\"請檢查您的輸入\",printerConnectionFailed:\"連線印表機失敗\",saveFailed:\"儲存更改失敗\",deleteFailed:\"刪除失敗\",loadFailed:\"載入資料失敗\"},hmsErrors:{title:\"錯誤 - {{name}}\",noErrors:\"無錯誤\",viewOnWiki:\"在拓竹 Wiki 上檢視\",clearInstructions:\"在印表機上清除錯誤以在此處消除它們。\",clearErrors:\"清除錯誤\",clearSuccess:\"HMS 錯誤已清除\",clearFailed:\"清除 HMS 錯誤失敗\"},mqttDebug:{title:\"MQTT 偵錯日誌\",searchPlaceholder:\"搜尋主題或負載...\",noMessages:\"尚未紀錄訊息\",startLoggingHint:'點選\"開始紀錄\"以開始捕獲 MQTT 訊息',noMessagesMatch:\"沒有訊息匹配您的篩選條件\",adjustFilterHint:\"嘗試調整您的搜尋或篩選條件\",incoming:\"傳入\",outgoing:\"傳出\",loggingStopped:\"紀錄已停止\",loggingActive:\"紀錄中 - 訊息將自動重新整理\",startLogging:\"開始紀錄\",stopLogging:\"停止紀錄\",clearLog:\"清除日誌\",topic:\"主題\",timestamp:\"時間戳\",direction:\"方向\",all:\"全部\"},printerFiles:{title:\"檔案管理器\",storageUsed:\"已用：\",storageFree:\"剩餘：\",filterPlaceholder:\"篩選檔案...\",deleteButton:\"刪除\",deleteFiles:\"刪除 {{count}} 個檔案\",deleteFileConfirm:'刪除\"{{name}}\"？此操作無法復原。',deleteFilesConfirm:\"刪除 {{count}} 個選中的檔案？此操作無法復原。\",noFiles:\"印表機上沒有檔案\",loadingFiles:\"載入檔案中...\",failedToLoad:\"載入檔案失敗\",toast:{filesDeleted:\"已刪除 {{count}} 個檔案\",deleteFailed:\"刪除失敗：{{error}}\"}},confirm:{delete:\"確定要刪除嗎？\",unsavedChanges:\"您有未儲存的更改。確定要離開嗎？\",clearQueue:\"確定要清空佇列嗎？\"},login:{title:\"Bambuddy 登入\",subtitle:\"登入您的帳戶\",username:\"使用者名稱\",usernamePlaceholder:\"輸入您的使用者名稱\",usernameOrEmail:\"使用者名稱或信箱\",usernameOrEmailPlaceholder:\"使用者名稱或 @ 信箱\",password:\"密碼\",passwordPlaceholder:\"輸入您的密碼\",signIn:\"登入\",signingIn:\"登入中...\",forgotPassword:\"忘記密碼？\",loginSuccess:\"登入成功\",loginFailed:\"登入失敗\",enterCredentials:\"請輸入使用者名稱和密碼\",enterEmail:\"請輸入您的電子郵件地址\",oidcLoginFailed:\"OIDC 登入失敗\",oidcErrors:{providerError:\"身份提供者返回了一個錯誤\",missingParameters:\"OIDC 回呼缺少必要引數\",invalidState:\"OIDC 狀態無效或已被使用\",stateExpired:\"OIDC 登入會話已過期，請重試\",providerNotFound:\"未找到 OIDC 提供者\",discoveryFailed:\"無法獲取 OIDC 探索文件\",invalidDiscovery:\"OIDC 探索文件無效\",networkError:\"OIDC 權杖交換時出現網路錯誤\",badResponse:\"OIDC 權杖交換時收到意外回應\",noIdToken:\"OIDC 提供者未返回 ID 權杖\",validationFailed:\"OIDC 權杖驗證失敗\",nonceMismatch:\"OIDC nonce 不符，可能存在重放攻擊\",missingSubClaim:\"OIDC 權杖缺少 sub 宣告\",noLinkedAccount:\"沒有與此 OIDC 身份連結的本機帳戶\",accountInactive:\"您的帳戶已被停用\",userResolutionFailed:\"無法解析您的帳戶\",internalError:\"OIDC 登入過程中發生內部錯誤\",tokenExchangeFailed:\"OIDC 權杖交換失敗\"},forgotPasswordTitle:\"忘記密碼\",forgotPasswordMessage:\"如果您忘記了密碼，請聯絡系統管理員進行重設。\",forgotPasswordEmailMessage:\"輸入您的信箱地址，我們將向您傳送新密碼。\",emailAddress:\"信箱地址\",emailPlaceholder:\"your.email@example.com\",cancel:\"取消\",sending:\"傳送中...\",sendResetEmail:\"傳送重設郵件\",howToReset:\"如何重設密碼：\",resetStep1:\"聯絡您的 Bambuddy 管理員\",resetStep2:\"請他們在使用者管理中重設您的密碼\",resetStep3:\"他們可以為您設定一個臨時密碼\",resetStep4:\"使用新密碼登入並在設定中修改密碼\",gotIt:\"知道了\",resetPassword:{title:\"設定新密碼\",subtitle:\"請在下方輸入並確認您的新密碼。\",newPassword:\"新密碼\",newPasswordPlaceholder:\"至少 8 個字元\",confirmPassword:\"確認密碼\",confirmPasswordPlaceholder:\"重複輸入新密碼\",saving:\"儲存中…\",submit:\"設定新密碼\",backToLogin:\"回到登入\",passwordsDoNotMatch:\"密碼不符\",passwordTooShort:\"密碼至少需要 8 個字元\",resetFailed:\"密碼重設失敗。連結可能已過期。\"},twoFA:{title:\"兩步驗證\",subtitle:\"您的帳戶已啟用兩步驗證。請在下方輸入驗證碼。\",methodAuthenticator:\"身份驗證器 App\",methodEmail:\"信箱驗證碼\",methodBackup:\"備用恢復碼\",instructionsTotp:\"請開啟您的身份驗證器 App，輸入 Bambuddy 的 6 位驗證碼。\",instructionsEmail:\"6 位驗證碼已傳送至您的信箱，有效期為 10 分鐘。\",instructionsEmailNotSent:\"點選下方按鈕，透過郵件獲取驗證碼。\",instructionsBackup:\"請輸入您的一個 8 位備用恢復碼。每個恢復碼只能使用一次。\",sendCodeButton:\"傳送信箱驗證碼\",sendingCode:\"傳送中...\",resendCode:\"重新傳送驗證碼\",codeLabel:\"驗證碼\",backupCodeLabel:\"備用恢復碼\",codePlaceholder:\"000000\",backupCodePlaceholder:\"XXXXXXXX\",verifyButton:\"驗證\",verifyingButton:\"驗證中...\",backToLogin:\"← 回到登入頁面\",orContinueWith:\"或透過以下方式登入\",signInWith:\"使用 {{provider}} 登入\",enterCode:\"請輸入驗證碼\",sendCodeFailed:\"驗證碼傳送失敗\",invalidCode:\"無效驗證碼，請重試。\"}},setup:{title:\"Bambuddy 設定\",subtitle:\"為您的 Bambuddy 實例設定身份驗證\",enableAuth:\"啟用身份驗證\",adminAccount:\"管理員帳戶\",adminAccountDesc:\"如果管理員使用者已存在，將使用現有管理員帳戶啟用身份驗證。如需使用現有管理員，請將下方欄位留空，或輸入新憑據建立新管理員使用者。\",adminUsername:\"管理員使用者名稱\",adminPassword:\"管理員密碼\",optionalIfAdminExists:\"（如管理員使用者已存在則為可選）\",adminUsernamePlaceholder:\"輸入管理員使用者名稱（可選）\",adminPasswordPlaceholder:\"輸入管理員密碼（可選）\",confirmPassword:\"確認密碼\",confirmPasswordPlaceholder:\"確認管理員密碼\",settingUp:\"設定中...\",completeSetup:\"完成設定\",toast:{authEnabledAdminCreated:\"身份驗證已啟用並建立了管理員使用者\",authEnabledExistingAdmins:\"使用現有管理員使用者啟用了身份驗證\",setupCompleted:\"設定完成\",enterBothCredentials:\"請輸入管理員使用者名稱和密碼，或將兩者留空以使用現有管理員使用者\",passwordsDoNotMatch:\"密碼不符\",passwordTooShort:\"密碼至少需要 6 個字元\"}},changePassword:{title:\"修改密碼\",currentPassword:\"目前密碼\",currentPasswordPlaceholder:\"輸入目前密碼\",newPassword:\"新密碼\",newPasswordPlaceholder:\"輸入新密碼（至少 6 個字元）\",confirmPassword:\"確認新密碼\",confirmPasswordPlaceholder:\"確認新密碼\",passwordsDoNotMatch:\"密碼不符\",passwordTooShort:\"密碼至少需要 6 個字元\",changing:\"修改中...\",success:\"密碼修改成功\",failed:\"密碼修改失敗\"},plateAlert:{title:\"列印已暫停！\",message:\"在列印板上偵測到物體。列印已自動暫停。請清理列印板並繼續列印。\",understand:\"我知道了\"},camera:{title:\"攝影機檢視\",invalidPrinterId:\"無效的印表機 ID\",live:\"即時\",snapshot:\"快照\",restartStream:\"重新啟動流\",refreshSnapshot:\"重新整理快照\",fullscreen:\"全螢幕\",exitFullscreen:\"離開全螢幕\",connectingToCamera:\"連線攝影機中...\",capturingSnapshot:\"拍攝快照中...\",connectionLost:\"連線已斷開\",connectionFailed:\"攝影機連線失敗\",reconnecting:\"{{countdown}} 秒後重新連線...（第 {{attempt}}/{{max}} 次嘗試）\",reconnectNow:\"立即重新連線\",cameraUnavailable:\"攝影機不可用\",cameraUnavailableDesc:\"請確保印表機已通電並已連線。\",noCamera:\"無可用攝影機\",retry:\"重試\",cameraStream:\"攝影機流\",zoomOut:\"縮小\",zoomIn:\"放大\",resetZoom:\"重設縮放\",recording:\"錄製中\",startRecording:\"開始錄製\",stopRecording:\"停止錄製\",chamberLight:\"切換腔室燈\"},groups:{title:\"群組管理\",subtitle:\"管理存取控制的權限群組\",backToSettings:\"返回設定\",createGroup:\"建立群組\",noPermission:\"您沒有存取此頁面的權限。\",system:\"系統\",noDescription:\"無描述\",usersCount:\"{{count}} 個使用者\",permissionsCount:\"{{count}} 個權限\",edit:\"編輯\",delete:\"刪除\",toast:{created:\"群組建立成功\",updated:\"群組更新成功\",deleted:\"群組刪除成功\",enterGroupName:\"請輸入群組名稱\"},modal:{editGroup:\"編輯群組\",createGroup:\"建立群組\",cancel:\"取消\",saving:\"儲存中...\",creating:\"建立中...\",saveChanges:\"儲存更改\"},form:{groupName:\"群組名稱\",groupNamePlaceholder:\"輸入群組名稱\",systemGroupWarning:\"系統群組名稱不可更改\",description:\"描述\",descriptionPlaceholder:\"輸入描述（可選）\",permissions:\"權限（已選 {{count}} 個）\"},deleteModal:{title:\"刪除群組\",message:\"確定要刪除此群組嗎？此群組中的使用者將失去這些權限。\",confirm:\"刪除群組\"},editor:{title:\"編輯群組\",createTitle:\"建立群組\",search:\"搜尋權限...\",selectAll:\"全選\",clearAll:\"清除全部\",permissionsSelected:\"已選 {{count}} 個\",noResults:\"沒有權限匹配您的搜尋\"}},users:{title:\"使用者管理\",subtitle:\"管理使用者及其對 Bambuddy 實例的存取\",backToSettings:\"返回設定\",createUser:\"建立使用者\",noPermission:\"您沒有存取此頁面的權限。\",admin:\"管理員\",noGroups:\"無群組\",active:\"活躍\",inactive:\"非活躍\",edit:\"編輯\",delete:\"刪除\",system:\"系統\",noGroupsAvailable:\"無可用群組\",table:{username:\"使用者名稱\",groups:\"群組\",status:\"狀態\",actions:\"操作\"},toast:{created:\"使用者建立成功\",updated:\"使用者更新成功\",deleted:\"使用者刪除成功\",fillRequired:\"請填寫所有必填欄位\",passwordsDoNotMatch:\"密碼不符\",passwordTooShort:\"密碼至少需要 6 個字元\"},modal:{createUser:\"建立使用者\",editUser:\"編輯使用者\",cancel:\"取消\",creating:\"建立中...\",saving:\"儲存中...\",saveChanges:\"儲存更改\",advancedAuthSubtitle:\"使用進階認證\"},form:{username:\"使用者名稱\",usernamePlaceholder:\"輸入使用者名稱\",email:\"信箱\",emailPlaceholder:\"user@example.com\",password:\"密碼\",passwordPlaceholder:\"輸入密碼\",confirmPassword:\"確認密碼\",confirmPasswordPlaceholder:\"確認密碼\",newPasswordPlaceholder:\"輸入新密碼\",confirmNewPasswordPlaceholder:\"確認新密碼\",leaveBlankToKeep:\"留空以保持目前值\",groups:\"群組\",optional:\"可選\",autoGeneratedPassword:\"將自動產生安全密碼並透過郵件傳送給使用者。\",passwordManagedByAdvancedAuth:'密碼由進階認證管理。使用\"重設密碼\"透過郵件向使用者傳送新密碼。',resetPassword:\"重設密碼\",resettingPassword:\"重設密碼中...\"},deleteModal:{title:\"刪除使用者\",message:\"確定要刪除此使用者嗎？此操作無法復原。\",confirm:\"刪除使用者\"}},streamOverlay:{title:\"流疊加層\",invalidPrinterId:\"無效的印表機 ID\",cameraStream:\"攝影機流\",progress:\"進度\",eta:\"預計完成時間\",printerIdle:\"印表機空閒\",printerOffline:\"印表機離線\",status:{printing:\"列印中\",paused:\"已暫停\",finished:\"已完成\",failed:\"失敗\",idle:\"空閒\",unknown:\"未知\"}},profiles:{title:\"設定檔案\",subtitle:\"管理您的切片預設和壓力推進校準\",tabs:{cloud:\"雲端設定檔案\",local:\"本機設定檔案\",kprofiles:\"K 值設定\"},localProfiles:{title:\"本機設定檔案\",subtitle:\"從 OrcaSlicer 匯入和管理切片預設\",import:\"匯入設定檔案\",importDesc:\"將 .bbscfg、.bbsflmt、.orca_filament、.zip 或 .json 檔案拖放到此處\",importing:\"匯入中...\",search:\"搜尋本機預設...\",noPresets:\"尚無本機預設\",badge:\"本機\",edit:\"編輯\",delete:\"刪除\",cancel:\"取消\",deleteConfirmTitle:\"刪除預設\",deleteConfirm:\"確定要刪除此預設嗎？此操作無法復原。\",source:\"來源\",inheritsFrom:\"繼承自\",filamentType:\"類型\",vendor:\"廠商\",compatiblePrinters:\"相容印表機\",nozzleTemp:\"噴嘴溫度\",cost:\"成本\",density:\"密度\",pressureAdvance:\"壓力推進\",filament:\"耗材\",process:\"工藝\",printer:\"印表機\",toast:{importSuccess:\"已匯入 {{count}} 個預設\",importSkipped:\"跳過 {{count}} 個預設（重複）\",importError:\"匯入時出現 {{count}} 個錯誤\",deleted:\"預設已刪除\",updated:\"預設已更新\"}},connectedAs:\"已連線為\",logout:\"登出\",noLogoutPermission:\"您沒有登出的權限\",failedToLoad:\"載入設定檔案失敗\",retry:\"重試\",time:{justNow:\"剛剛\",minsAgo:\"{{count}} 分鐘前\",hoursAgo:\"{{count}} 小時前\",daysAgo:\"{{count}} 天前\"},toast:{loggedOut:\"已登出\"},login:{title:\"連線到拓竹雲\",subtitle:\"跨裝置同步您的切片預設\",email:\"信箱\",password:\"密碼\",region:\"地區\",regionGlobal:\"全球\",regionChina:\"中國\",verificationCode:\"驗證碼\",totpCode:\"驗證器驗證碼\",checkEmail:\"檢查您的信箱 ({{email}}) 獲取 6 位驗證碼\",enterTotpHint:\"輸入驗證器 App 中的 6 位驗證碼\",accessToken:\"存取權杖\",accessTokenHint:\"貼上您的拓竹存取權杖（來自 Bambu Studio）\",back:\"返回\",loginButton:\"登入\",verifyButton:\"驗證\",setTokenButton:\"設定權杖\",useToken:\"改用存取權杖\",useEmail:\"改用信箱登入\",toast:{loggedIn:\"登入成功\",codeSent:\"驗證碼已傳送到您的信箱\",enterTotp:\"輸入驗證器 App 中的程式碼\",tokenSet:\"權杖設定成功\"}},presets:{myPreset:\"我的預設（可編輯）\",duplicate:\"複製\",editable:\"可編輯\",failedToLoadDetails:\"載入預設詳情失敗\",deleteConfirm:\"刪除此預設？\",deleteWarning:'這將從拓竹雲中永久刪除\"{{name}}\"。此操作無法復原。',noDuplicatePermission:\"您沒有複製預設的權限\",noEditPermission:\"您沒有編輯預設的權限\",noDeletePermission:\"您沒有刪除預設的權限\",types:{filament:\"耗材預設\",printer:\"印表機預設\",process:\"工藝預設\"},toast:{deleted:\"預設已刪除\",created:\"預設已建立\",updated:\"預設已更新\",duplicated:\"預設已複製\",fieldAdded:'欄位\"{{key}}\"已新增',exported:\"預設已匯出\"},baseLabel:\"基礎：{{name}}\",currentLabel:\"目前：{{name}}\",newPreset:\"新增預設\",editPreset:\"編輯預設\",duplicatePreset:\"複製預設\",createNewPreset:\"建立新預設\",customizeSettings:\"自訂新預設的設定\",compareWithBase:\"與基礎預設比較\",compare:\"比較\",basePreset:\"基礎預設\",selectBasePreset:\"選擇基礎預設...\",presetName:\"預設名稱\",myCustomPreset:\"我的自訂預設\",inheritsFrom:\"繼承自\",dropJsonToImport:\"拖放 JSON 以匯入\",tabs:{common:\"常用\",allFields:\"所有欄位\"},availableFields:\"可用欄位\",searchFieldsPlaceholder:\"搜尋欄位...\",noMatchingFields:\"沒有匹配的欄位\",allFieldsAdded:\"所有欄位已新增\",addCustomField:\"新增自訂欄位\",yourOverrides:\"您的覆蓋值\",noOverridesYet:\"尚無覆蓋值\",clickFieldsToAdd:\"點選左側的欄位進行新增\",saveAsTemplate:\"儲存為範本\",jsonTip:\"提示：將 .json 檔案拖放到此對話方塊的任意位置以匯入設定\"},cloudView:{searchPlaceholder:\"搜尋預設...\",templates:\"範本\",refresh:\"重新整理\",newPreset:\"新增預設\",clearFilters:\"清除篩選\",compareMode:\"比較模式\",selectAnotherPreset:\"選擇另一個 {{type}} 預設\",clickTwoPresets:\"點選兩個相同類型的預設進行比較\",selectFirst:\"1. 選擇第一個\",selectSecond:\"2. 選擇第二個\",compareNow:\"立即比較\",lastSynced:\"上次同步：\",showingCount:\"顯示 {{showing}} / {{total}} 個預設\",noPresetsFound:\"未找到預設\",columns:{filament:\"耗材\",process:\"工藝\",printer:\"印表機\"},noFilamentPresets:\"無耗材預設\",noProcessPresets:\"無工藝預設\",noPrinterPresets:\"無印表機預設\",filters:{type:\"類型\",owner:\"所有者\",printer:\"印表機\",nozzle:\"噴嘴\",filament:\"耗材\",layer:\"層\",all:\"全部\",myPresets:\"我的預設\",builtIn:\"內建\",process:\"工藝\"},noTemplatesPermission:\"您沒有管理範本的權限\",noRefreshPermission:\"您沒有重新整理設定檔案的權限\",noCreatePermission:\"您沒有建立預設的權限\"},templates:{title:\"快速範本\",noTemplates:\"尚無範本\",createFirst:\"從預設編輯器建立範本\",typeFilter:\"類型：\",deleteTitle:\"刪除範本\",deleteWarning:\"此操作無法復原\",deleteConfirm:'確定要刪除\"{{name}}\"嗎？',namePlaceholder:\"範本名稱\",descriptionPlaceholder:\"描述\",settingsJson:\"設定 (JSON)\",fieldsCount:\"{{count}} 個欄位\",shownInModals:\"在對話方塊中顯示\",hiddenInModals:\"在對話方塊中隱藏\",apply:\"套用\",toast:{deleted:\"範本已刪除\",updated:\"範本已更新\",created:\"範本已建立\",applied:\"範本已套用\"}}},support:{debugLoggingActive:\"偵錯日誌紀錄已啟用\",manageLogs:\"管理\",collectItem7:\"印表機連線和韌體版本\",collectItem8:\"整合狀態（Spoolman、MQTT、HA）\",collectItem9:\"網路介面（僅子網）\",collectItem10:\"Python 套件版本\",collectItem11:\"資料庫健康檢查\",collectItem12:\"Docker 環境詳情\"},fileManager:{title:\"檔案管理器\",subtitle:\"組織和管理您的列印檔案\",uploadFiles:\"上傳檔案\",newFolder:\"新增資料夾\",folderName:\"資料夾名稱\",folderNamePlaceholder:\"例如：功能零件\",renameFile:\"重新命名檔案\",renameFolder:\"重新命名資料夾\",moveFiles:\"移動 {{count}} 個檔案\",rootNoFolder:\"根目錄（無資料夾）\",current:\"目前\",linkFolder:\"連結資料夾\",linkFolderDescription:'將\"{{name}}\"連結到專案或歸檔以便快速存取。',project:\"專案\",archive:\"歸檔\",noProjectsFound:\"未找到專案\",noArchivesFound:\"未找到歸檔\",unlink:\"取消連結\",link:\"連結\",dragDropFiles:\"將檔案拖放到此處\",dropFilesHere:\"將檔案放在此處\",orClickToBrowse:\"或點選瀏覽\",allFileTypesSupported:\"支援所有檔案類型。ZIP 檔案將被解壓。\",zipFilesDetected:\"偵測到 ZIP 檔案\",zipExtractOptions:\"ZIP 檔案將被解壓。選擇如何處理資料夾結構：\",preserveZipStructure:\"保留 ZIP 中的資料夾結構\",createFolderFromZip:\"從 ZIP 檔名建立資料夾\",stlThumbnailGeneration:\"STL 縮圖產生\",zipMayContainStl:\"ZIP 檔案可能包含 STL 檔案。可以在解壓時產生縮圖。\",thumbnailsCanBeGenerated:\"可以為 STL 檔案產生縮圖。大型模型可能需要更長時間處理。\",generateThumbnailsForStl:\"為 STL 檔案產生縮圖\",threemfDetected:\"偵測到 3MF 檔案\",threemfExtractionInfo:\"將自動從 3MF 檔案中提取印表機型號、材料、顏色和列印設定。\",willBeExtracted:\"將被解壓\",filesExtracted:\"已解壓 {{count}} 個檔案\",uploadComplete:\"上傳完成：{{succeeded}} 個成功\",uploadFailed:\"上傳失敗\",zipFilesFailed:\"{{count}} 個檔案失敗\",uploading:\"上傳中...\",changeLink:\"更改連結...\",linkTo:\"連結到...\",linkToProjectOrArchive:\"連結到專案或歸檔\",addToQueue:\"新增到佇列\",schedulePrint:\"排程\",generateThumbnail:\"產生縮圖\",generateThumbnails:\"產生縮圖\",generateThumbnailsForMissing:\"為缺少縮圖的 STL 檔案產生縮圖\",gridView:\"網格檢視\",listView:\"列表檢視\",lowDiskSpaceWarning:\"磁碟空間不足警告\",lowDiskSpaceDetails:\"僅剩 {{free}}（總共 {{total}}）。閾值設定為 {{threshold}} GB。\",files:\"檔案\",folders:\"資料夾\",size:\"大小\",free:\"剩餘\",allFiles:\"所有檔案\",wrap:\"換行\",enableTextWrapping:\"啟用文字換行\",disableTextWrapping:\"停用文字換行\",collapse:\"折疊\",collapseFoldersByDefault:\"預設折疊資料夾\",expandFoldersByDefault:\"預設展開資料夾\",dragToResizeTooltip:\"拖曳調整大小，雙擊重設\",searchFiles:\"搜尋檔案...\",allTypes:\"所有類型\",prints:\"列印\",ascending:\"升序\",descending:\"降序\",resultsCount:\"{{showing}} / {{total}} 個檔案\",selectAll:\"全選\",deselectAll:\"取消全選\",selected:\"已選擇 {{count}} 個\",adding:\"新增中...\",loadingFiles:\"載入檔案中...\",folderIsEmpty:\"資料夾為空\",noFilesYet:\"尚無檔案\",folderEmptyDescription:\"上傳檔案或將檔案移入此資料夾以開始使用。\",noFilesDescription:\"上傳檔案以開始組織您的列印相關檔案。\",noMatchingFiles:\"沒有匹配的檔案\",noMatchingFilesDescription:\"沒有檔案匹配您目前的搜尋或篩選條件。\",clearFilters:\"清除篩選\",printedCount:\"已列印 {{count}} 次\",uploadedBy:\"上傳者\",deleteFolder:\"刪除資料夾\",deleteFile:\"刪除檔案\",deleteFilesCount:\"刪除 {{count}} 個檔案\",deleteFolderConfirm:\"確定要刪除此資料夾嗎？其中的所有檔案也將被刪除。\",deleteFileConfirm:\"確定要刪除此檔案嗎？\",deleteFilesConfirm:\"確定要刪除 {{count}} 個選中的檔案嗎？此操作無法復原。\",deleting:\"刪除中...\",noPermissionRenameFolder:\"您沒有重新命名資料夾的權限\",noPermissionLinkFolder:\"您沒有連結資料夾的權限\",noPermissionDeleteFolder:\"您沒有刪除資料夾的權限\",noPermissionPrint:\"您沒有列印的權限\",noPermissionAddToQueue:\"您沒有新增到佇列的權限\",noPermissionDownload:\"您沒有下載檔案的權限\",noPermissionRenameFile:\"您沒有重新命名此檔案的權限\",noPermissionGenerateThumbnail:\"您沒有產生縮圖的權限\",noPermissionDeleteFile:\"您沒有刪除此檔案的權限\",noPermissionCreateFolder:\"您沒有建立資料夾的權限\",noPermissionUpload:\"您沒有上傳檔案的權限\",noPermissionMoveFiles:\"您沒有移動檔案的權限\",noPermissionDeleteFiles:\"您沒有刪除檔案的權限\",linkExternal:\"連結外部\",linkExternalFolder:\"連結外部資料夾\",linkExternalFolderDescription:\"將主機目錄（NAS、USB、網路共享）掛載到檔案管理器中。檔案不會被複制——直接從原始路徑存取。\",externalFolderNamePlaceholder:\"例如：NAS列印檔案\",externalPath:\"主機路徑\",externalPathHelp:\"Docker主機上目錄的絕對路徑。必須以繫結掛載方式掛載到容器中。\",readOnly:\"只讀\",readOnlyHelp:\"防止上傳和刪除\",showHiddenFiles:\"顯示隱藏檔案（點檔案）\",externalFolder:\"外部資料夾\",scanFolder:\"掃描\",toast:{folderCreated:\"資料夾已建立\",folderDeleted:\"資料夾已刪除\",fileDeleted:\"檔案已刪除\",filesDeleted:\"已刪除 {{count}} 個檔案\",filesMoved:\"檔案已移動\",folderLinked:\"資料夾已連結\",folderUnlinked:\"資料夾已取消連結\",externalFolderLinked:\"外部資料夾已連結並掃描\",folderScanned:\"掃描完成：新增 {{added}} 個，移除 {{removed}} 個\",addedToQueue:\"已將 {{count}} 個檔案新增到佇列\",addedToQueuePartial:\"已新增 {{added}} 個檔案，{{failed}} 個失敗\",failedToAddToQueue:\"新增檔案失敗：{{error}}\",fileRenamed:\"檔案已重新命名\",folderRenamed:\"資料夾已重新命名\",thumbnailsGenerated:\"已產生 {{count}} 個縮圖\",thumbnailsGeneratedPartial:\"已產生 {{succeeded}} 個縮圖，{{failed}} 個失敗\",noStlMissingThumbnails:\"沒有缺少縮圖的 STL 檔案\",failedToGenerateThumbnails:\"產生縮圖失敗：{{error}}\",thumbnailGenerated:\"縮圖已產生\",failedToGenerateThumbnail:\"產生縮圖失敗：{{error}}\"}},projects:{title:\"專案\",subtitle:\"組織和追蹤您的 3D 列印專案\",newProject:\"新增專案\",editProject:\"編輯專案\",deleteProject:\"刪除專案\",projectName:\"專案名稱\",description:\"描述\",noProjects:\"尚無專案\",noProjectsFiltered:\"沒有{{status}}專案\",noProjectsFilteredHelp:\"您沒有任何{{status}}專案。當專案狀態更改時，它們將出現在這裡。\",createFirst:\"建立您的第一個項目以開始組織相關列印、追蹤進度和管理構建。\",createFirstButton:\"建立您的第一個項目\",create:\"建立\",files:\"檔案\",prints:\"列印\",plates:\"板\",parts:\"零件\",lastModified:\"最後修改\",deleteConfirm:\"確定要刪除此項目嗎？歸檔和佇列項目將被取消連結但不會被刪除。\",addFiles:\"新增檔案\",removeFile:\"移除檔案\",viewDetails:\"檢視詳情\",namePlaceholder:\"例如：Voron 2.4 構建\",descriptionPlaceholder:\"可選描述...\",color:\"顏色\",targetPlates:\"目標板數\",targetPlatesPlaceholder:\"例如：25\",targetPlatesHelp:\"列印任務數量\",targetParts:\"目標零件數\",targetPartsPlaceholder:\"例如：150\",targetPartsHelp:\"所需零件總數\",tagsLabel:\"標籤（逗號分隔）\",tagsPlaceholder:\"例如：voron、功能件、禮物\",dueDate:\"截止日期\",priority:\"優先順序\",priorityLow:\"低\",priorityNormal:\"普通\",priorityHigh:\"高\",priorityUrgent:\"緊急\",statusActive:\"進行中\",statusCompleted:\"已完成\",statusArchived:\"已歸檔\",done:\"完成\",completed:\"已完成\",failed:\"失敗\",inQueue:\"佇列中\",noPrintsYet:\"尚無列印\",printJobs:\"列印任務（板）\",partsPrinted:\"已列印零件\",failedParts:\"失敗零件\",import:\"匯入\",export:\"匯出\",importProject:\"匯入專案\",exportAll:\"匯出所有專案\",loading:\"載入專案中...\",noEditPermission:\"您沒有編輯專案的權限\",noDeletePermission:\"您沒有刪除專案的權限\",noCreatePermission:\"您沒有建立專案的權限\",noImportPermission:\"您沒有匯入專案的權限\",noExportPermission:\"您沒有匯出專案的權限\",toast:{created:\"專案已建立\",updated:\"專案已更新\",deleted:\"專案已刪除\",imported:\"專案已匯入\",multipleImported:\"已匯入 {{count}} 個項目\",importFailed:\"匯入失敗\",exported:\"專案已匯出（僅中繼資料）\"}},projectDetail:{notFound:\"未找到專案\",backToProjects:\"返回專案\",export:\"匯出\",exportProject:\"匯出專案\",noExportPermission:\"您沒有匯出專案的權限\",noEditPermission:\"您沒有編輯專案的權限\",partOf:\"屬於：\",priorityLabel:\"優先順序：\",noPrints:\"此項目尚無列印\",status:{active:\"進行中\",completed:\"已完成\",archived:\"已歸檔\"},priority:{low:\"低\",normal:\"普通\",high:\"高\",urgent:\"緊急\"},dueDate:{overdue:\"已逾期\",today:\"今天到期\",daysLeft:\"還有 {{count}} 天\"},progress:{platesProgress:\"板進度\",partsProgress:\"零件進度\",printJobs:\"列印任務\",parts:\"零件\",percentComplete:\"{{percent}}% 完成\",remaining:\"剩餘 {{count}} 個\"},stats:{printJobs:\"列印任務\",total:\"總計\",failed:\"{{count}} 個失敗\",partsPrinted:\"已列印 {{count}} 個零件\",printTime:\"列印時間\",filamentUsed:\"耗材用量\"},cost:{title:\"成本追蹤\",filamentCost:\"耗材成本\",energy:\"能源\",totalCost:\"總成本\",total:\"總計\",includesBom:\"含物料清單\",budget:\"預算\",remaining:\"剩餘\"},subProjects:{title:\"子專案 ({{count}})\"},notes:{title:\"備註\",noEditPermission:\"您沒有編輯備註的權限\",placeholder:\"新增關於此項目的備註...\",empty:\"尚無備註。點選編輯新增備註。\"},files:{title:\"檔案\",linkFolders:\"從檔案管理器連結資料夾\",forQuickAccess:\"到此項目以便快速存取。\",fileCount:\"{{count}} 個檔案\",empty:\"未連結資料夾。前往檔案管理器將資料夾連結到此項目。\",noFiles:\"此資料夾中沒有檔案。\",print:\"立即列印\",addToQueue:\"加入佇列\"},bom:{title:\"材料清單\",acquired:\"已獲取 {{completed}}/{{total}}\",showAll:\"顯示全部\",hideDone:\"隱藏已完成\",addPart:\"新增零件\",noAddPermission:\"您沒有新增零件的權限\",partNamePlaceholder:\"零件名稱（例如：M3x8 螺絲）\",partName:\"零件名稱\",qty:\"數量\",price:\"價格 ({{currency}})\",sourcingUrlPlaceholder:\"採購連結（可選）\",remarksPlaceholder:\"備註（可選）\",deletePart:\"刪除零件\",deleteConfirm:'確定要刪除\"{{name}}\"嗎？',noUpdatePermission:\"您沒有更新零件的權限\",noEditPermission:\"您沒有編輯零件的權限\",noDeletePermission:\"您沒有刪除零件的權限\",totalCost:\"總成本：\",empty:\"材料清單中沒有零件。新增硬體、電子元件或其他元件以追蹤需要採購的物品。\"},timeline:{title:\"活動時間線\",empty:\"尚無活動。\"},template:{saveAsTemplate:\"儲存為範本\",noCreatePermission:\"您沒有建立範本的權限\"},queue:{title:\"佇列\",viewAll:\"檢視全部\",printing:\"{{count}} 個列印中\",queued:\"{{count}} 個佇列中\"},prints:{title:\"列印 ({{count}})\"},toast:{projectUpdated:\"專案已更新\",partAdded:\"零件已新增\",partRemoved:\"零件已移除\",exportFailed:\"匯出失敗\",projectExported:\"專案已匯出\",templateCreated:\"範本已建立\"}},system:{title:\"系統資訊\",version:\"版本\",uptime:\"執行時間\",cpuUsage:\"CPU 使用率\",memoryUsage:\"記憶體使用率\",diskUsage:\"磁碟使用率\",networkInfo:\"網路資訊\",logs:\"日誌\",debugMode:\"偵錯模式\",enableDebug:\"啟用偵錯日誌\",disableDebug:\"停用偵錯日誌\",downloadLogs:\"下載日誌\",clearLogs:\"清除日誌\",dockerInfo:\"Docker 資訊\",containerName:\"容器名稱\",imageName:\"映象名稱\",platform:\"平臺\",architecture:\"架構\"},library:{title:\"耗材庫\",addFilament:\"新增耗材\",editFilament:\"編輯耗材\",deleteFilament:\"刪除耗材\",vendor:\"廠商\",material:\"材料\",color:\"顏色\",kFactor:\"K 值\",temperature:\"溫度\",noFilaments:\"耗材庫中沒有耗材\",deleteConfirm:\"確定要刪除此耗材嗎？\",importFromPrinter:\"從印表機匯入\",exportToFile:\"匯出到檔案\"},spoolman:{title:\"Spoolman 整合\",enabled:\"Spoolman 已啟用\",url:\"Spoolman URL\",connected:\"已連線\",disconnected:\"未連線\",testConnection:\"測試連線\",sync:\"同步\",syncing:\"同步中...\",lastSync:\"上次同步\",linkToSpoolman:\"連結到 Spoolman\",openInSpoolman:\"在 Spoolman 中開啟\",unlinkSpool:\"取消連結耗材\",unlinkConfirmTitle:\"解開料盤？\",unlinkConfirmMessage:\"這將斷開卷軸與 Spoolman 的連線。Spoolman 中的卷軸資料將保持不變。\",selectSpool:\"選擇耗材\",noUnlinkedSpools:\"無未連結的耗材\",linkSuccess:\"耗材已成功連結到 Spoolman\",linkFailed:\"連結耗材失敗\",unlinkSuccess:\"已成功從 Spoolman 取消連結耗材\",unlinkFailed:\"取消連結耗材失敗\",spoolId:\"耗材 ID\",fillSourceLabel:\"(Spoolman)\",weight:\"重量\",remaining:\"剩餘\",disableWeightSync:\"停用 AMS 估計重量同步\",disableWeightSyncDesc:\"不從 AMS 估計值更新剩餘容量。如果您更喜歡 Spoolman 的用量追蹤而非 AMS 百分比估計，請使用此選項。新耗材仍將使用 AMS 估計值作為初始重量。\",reportPartialUsage:\"報告失敗列印的部分用量\",reportPartialUsageDesc:\"當列印失敗或被取消時，根據層進度報告估計的耗材使用量。\"},inventory:{title:\"耗材庫存\",addSpool:\"新增耗材\",editSpool:\"編輯耗材\",material:\"材料\",selectMaterial:\"選擇材料...\",subtype:\"子類型\",brand:\"品牌\",searchBrand:\"搜尋品牌...\",useCustomBrand:'使用\"{{brand}}\"',useCustomMaterial:\"使用自訂材料：{{material}}\",colorName:\"顏色名稱\",colorNamePlaceholder:\"翡翠白、烈焰紅...\",color:\"顏色\",hexColor:\"十六進位顏色\",pickColor:\"選擇自訂顏色\",labelWeight:\"標籤重量\",coreWeight:\"空盤重量\",searchSpoolWeight:\"搜尋耗材重量...\",weightUsed:\"已使用\",currentWeight:\"剩餘重量\",measuredWeight:\"稱量重量\",spoolName:\"料盤\",costPerKg:\"每公斤成本\",measuredWeightError:\"稱量重量必須在 {{min}}g 到 {{max}}g 之間。\",slicerFilament:\"切片耗材\",slicerFilamentName:\"切片預設名稱\",slicerPreset:\"切片預設\",searchPresets:\"搜尋耗材預設...\",selectedPreset:\"已選擇\",noPresetsFound:\"未找到預設\",tempOverrides:\"溫度覆蓋\",note:\"備註\",notePlaceholder:\"關於此耗材的任何備註...\",archive:\"歸檔\",restore:\"恢復\",noSpools:\"尚無耗材。新增您的第一個耗材開始使用。\",noManualSpools:\"沒有手動新增的耗材。請先向庫存中新增耗材。\",kProfiles:\"K 值設定\",addKProfile:\"新增 K 值設定\",assignSpool:\"分配耗材\",unassignSpool:\"取消分配\",assignSuccess:\"耗材已分配，AMS 槽位已設定\",assignFailed:\"分配耗材失敗\",assignMismatchTitle:\"材料不符\",assignMismatchMessage:'所選料盤材料 \"{{spoolMaterial}}\" 與 {{location}} 的料槽材料 \"{{trayMaterial}}\" 不符。仍要分配嗎？',assignMismatchConfirm:\"仍然分配\",assignPartialMismatchMessage:'料盤材料 \"{{spoolMaterial}}\" 與 {{location}} 的 \"{{trayMaterial}}\" 相近但不完全一致。是否繼續？',assignProfileMismatchMessage:'料盤設定 \"{{spoolProfile}}\" 與 {{location}} 的料槽設定 \"{{trayProfile}}\" 不一致。是否繼續？',selectSpool:\"選擇要分配到此槽位的耗材\",assigned:\"已分配\",assigning:\"分配中...\",searchSpools:\"搜尋耗材...\",showAllSpools:\"顯示所有耗材\",allMaterials:\"所有材料\",filterByBrand:\"按品牌篩選...\",showArchived:\"顯示已歸檔\",quickAdd:\"快速新增（庫存）\",quantity:\"數量\",stock:\"庫存\",configured:\"已設定\",spoolsCreated:\"已建立 {{count}} 個耗材\",spoolCreated:\"耗材已建立\",spoolUpdated:\"耗材已更新\",spoolDeleted:\"耗材已刪除\",spoolArchived:\"耗材已歸檔\",spoolRestored:\"耗材已恢復\",deleteConfirm:\"確定要刪除此耗材嗎？此操作無法復原。\",archiveConfirm:\"確定要歸檔此耗材嗎？\",advancedSettings:\"進階設定\",filamentInfoTab:\"耗材資訊\",paProfileTab:\"PA 設定\",filamentInfo:\"耗材\",additional:\"附加\",loadingPresets:\"載入雲端預設中...\",cloudConnected:\"雲端已連線\",cloudNotConnected:\"雲端未連線（使用預設值）\",recentColors:\"最近\",searchColors:\"搜尋顏色...\",searchResults:\"搜尋結果\",allColors:\"所有顏色\",commonColors:\"常用顏色\",showLess:\"顯示更少\",showAll:\"顯示全部\",noColorsFound:\"沒有顏色匹配您的搜尋\",noResults:\"未找到匹配項\",selectMaterialFirst:\"請先在耗材資訊分頁中選擇材料。\",noPrintersConfigured:\"未設定印表機。新增印表機以使用 PA 設定。\",matchingFilter:\"匹配\",anyBrand:\"任何品牌\",anyVariant:\"任何變體\",autoSelect:\"自動選擇\",matches:\"匹配\",match:\"匹配\",noMatches:\"無匹配\",connected:\"已連線\",offline:\"離線\",printerOffline:\"印表機離線。連線後檢視校準設定。\",noKProfilesMatch:\"沒有 K 值設定匹配所選耗材。\",leftNozzle:\"左噴嘴\",rightNozzle:\"右噴嘴\",profilesSelected:\"個校準設定已選擇\",totalInventory:\"總庫存\",totalConsumed:\"總消耗\",byMaterial:\"按材料\",inPrinter:\"在印表機中\",lowStock:\"庫存不足\",sinceTracking:\"自開始追蹤\",loadedInAms:\"已裝載到 AMS/外接\",remaining:\"剩餘\",weightCheck:\"重量檢查\",lastWeighed:\"上次稱量\",neverWeighed:\"從未稱量\",search:\"搜尋耗材...\",showing:\"顯示\",to:\"到\",of:\"共\",show:\"顯示\",spools:\"個耗材\",spool:\"個耗材\",page:\"頁\",noSpoolsMatch:\"未找到結果\",noSpoolsMatchDesc:\"嘗試調整您的搜尋或篩選條件。\",active:\"活躍\",archived:\"已歸檔\",all:\"全部\",used:\"已使用\",new:\"新的\",clearFilters:\"清除篩選\",table:\"表格\",cards:\"卡片\",net:\"淨重\",groupSimilar:\"分組\",groupedSpools:\"{{count}} 個相同耗材\",groupedRows:\"行\",columns:\"列\",configureColumns:\"設定列\",configureColumnsDesc:\"拖曳以重新排序列或使用箭頭。使用眼睛圖示切換可見性。\",visible:\"可見\",reset:\"重設\",cancel:\"取消\",applyChanges:\"套用更改\",moveUp:\"上移\",moveDown:\"下移\",hideColumn:\"隱藏列\",showColumn:\"顯示列\",linkToSpool:\"連結到耗材\",tagLinked:\"標籤已連結到耗材\",tagLinkFailed:\"連結標籤失敗\",tagAlreadyLinked:\"標籤已連結到其他耗材\",unknownTag:\"偵測到未知 RFID 標籤\",usageHistory:\"使用歷史\",noUsageHistory:\"尚無使用紀錄\",printName:\"列印名稱\",weightConsumed:\"消耗重量\",clearHistory:\"清除\",historyCleared:\"使用歷史已清除\",fillSourceLabel:\"(庫存)\",lowStockThresholdError:\"閾值必須在 0.1 到 99.9 之間\"},timelapse:{title:\"縮時攝影\",create:\"建立縮時攝影\",download:\"下載\",delete:\"刪除\",preview:\"預覽\",frameRate:\"幀率\",quality:\"品質\",processing:\"處理中...\",noTimelapses:\"無可用縮時攝影\"},ams:{title:\"AMS\",slot:\"槽位\",empty:\"空\",emptySlot:\"空槽位\",unknown:\"未知\",humidity:\"濕度\",temperature:\"溫度\",filamentType:\"耗材類型\",filamentColor:\"顏色\",remaining:\"剩餘\",history:\"AMS 歷史\",noHistory:\"無可用歷史\",configureSlot:\"設定槽位\",externalSpool:\"外接耗材\",profile:\"設定\",kFactor:\"K 值\",fill:\"填充\",configure:\"設定\",used:\"已使用\",remainingUnit:\"剩餘\"},printModal:{title:\"開始列印\",selectPrinter:\"選擇印表機\",selectPlate:\"選擇板\",filamentMapping:\"耗材對應\",totalCost:\"總成本：\",slotRemainingShort:\" - 剩餘 {{grams}}g\",printSettings:\"列印設定\",bedLeveling:\"熱床調平\",flowCalibration:\"流量校準\",vibrationCalibration:\"振動校準\",layerInspection:\"首層檢查\",timelapse:\"縮時攝影\",startPrint:\"開始列印\",addToQueue:\"新增到佇列\",cancel:\"取消\",noPrintersAvailable:\"無可用印表機\",printerBusy:\"印表機忙碌\",printerOffline:\"印表機離線\",sameTypeDifferentColor:\"相同類型，不同顏色\",filamentTypeNotLoaded:\"耗材類型未裝載\",openCalendar:\"開啟日曆\",leftNozzle:\"左\",rightNozzle:\"右\",leftNozzleTooltip:\"左噴嘴\",rightNozzleTooltip:\"右噴嘴\",filamentOverride:\"耗材覆蓋\",filamentOverrideHint:\"可選覆蓋用於基於模型的耗材分配。排程器將使用您選擇的耗材而不是原始 3MF 值進行匹配。\",originalFilament:\"原始\",overrideWith:\"覆蓋為\",resetToOriginal:\"恢復為原始\",insufficientFilamentTitle:\"耗材不足\",insufficientFilamentMessage:\"部分已分配料盤的剩餘耗材少於本次列印所需：\",insufficientFilamentLine:\"{{printer}} - {{slot}}：需要 {{required}}g，剩餘 {{remaining}}g\",printAnyway:\"仍然列印\",forceColorMatch:\"強制顏色匹配\",staggerPrinterStarts:\"錯開印表機啟動\",staggerGroupSize:\"群組大小\",staggerInterval:\"間隔（分鐘）\",staggerPreview:\"{{printers}} 台印表機 → 分成 {{groups}} 組，每組 {{size}} 台，每 {{interval}} 分鐘啟動一組\",staggerLastGroup:\"最後一組：{{count}}\",staggerTotal:\"總計：{{minutes}} 分鐘\",staggerToPrinters:\"分批傳送到 {{count}} 臺印表機\",gcodeInjection:\"注入自動列印G-code\"},backup:{title:\"備份與恢復\",createBackup:\"建立備份\",restoreBackup:\"恢復備份\",restoreDescription:\"從備份檔案替換所有資料\",downloadBackup:\"下載備份\",uploadBackup:\"上傳備份\",lastBackup:\"上次備份\",autoBackup:\"自動備份\",backupNow:\"立即備份\",restoreWarning:\"警告：恢復備份將覆蓋所有目前資料。\",includeArchives:\"包含歸檔\",includeSettings:\"包含設定\",includeProfiles:\"包含設定檔案\",backupSuccess:\"備份建立成功\",restoreSuccess:\"備份恢復成功\",backupFailed:\"備份失敗\",restoreFailed:\"恢復失敗\",restoreNote:\"恢復期間虛擬印表機將停止\",githubBackup:\"GitHub 備份\",enabled:\"已啟用\",cloudLoginRequired:\"需要登入 Bambu Cloud。請在 設定檔案 → 雲設定檔案 中登入以啟用 GitHub 備份。\",cloudLoginRequiredShort:\"需要雲端登入\",githubDescription:\"自動將您的設定檔案同步到私有 GitHub 倉庫以進行備份和版本歷史紀錄。\",repositoryUrl:\"倉庫 URL\",personalAccessToken:\"個人存取權杖\",tokenSaved:\"（已儲存）\",enterNewToken:\"輸入新權杖以更新\",tokenHint:\"具有內容讀寫權限的細粒度權杖\",branch:\"分支\",manualOnly:\"僅手動\",hourly:\"每小時\",daily:\"每天\",weekly:\"每週\",includeInBackup:\"包含在備份中\",kProfiles:\"K 設定檔案\",kProfilesDescription:\"來自已連線印表機的壓力推進校準\",noPrintersConnected:\"沒有印表機連線\",printersConnected:\"{{connected}}/{{total}} 已連線\",cloudProfiles:\"雲設定檔案\",cloudProfilesDescription:\"來自 Bambu Cloud 的耗材、印表機和工藝預設\",appSettings:\"應用程式設定\",appSettingsDescription:\"Bambuddy 設定（完整資料庫）\",spoolInventory:\"耗材庫存\",spoolInventoryDescription:\"耗材卷軸、使用紀錄和成本追蹤\",printArchives:\"列印檔案\",printArchivesDescription:\"列印歷史中繼資料（不含 gcode/3MF 檔案）\",lastBackupAt:\"上次備份：\",noBackupsYet:\"尚無備份\",next:\"下次：\",startingBackup:\"正在啟動備份...\",test:\"測試\",enableBackup:\"啟用備份\",testConnection:\"測試連線\",enterRepoUrl:\"請輸入倉庫 URL\",enterRepoAndToken:\"請輸入倉庫 URL 和存取權杖\",repoRequired:\"倉庫 URL 為必填項\",tokenRequired:\"存取權杖為必填項\",githubBackupEnabled:\"GitHub 備份已啟用\",tokenUpdated:\"權杖已更新\",settingsSaved:\"設定已儲存\",failedToSave:\"儲存失敗：{{message}}\",backupCompleteFiles:\"備份完成 - {{count}} 個檔案已更新\",backupSkippedNoChanges:\"備份已跳過 - 無更改\",backupFailed2:\"備份失敗：{{message}}\",clearedLogs:\"已清除 {{count}} 條日誌\",failedToClearLogs:\"清除日誌失敗：{{message}}\",history:\"歷史紀錄\",clear:\"清除\",date:\"日期\",status:\"狀態\",commit:\"提交\",localBackup:\"本機備份\",localBackupDescription:\"建立 Bambuddy 資料的完整備份，包括資料庫、檔案、上傳和所有檔案。\",downloadBackupLabel:\"下載備份\",completeBackupZip:\"完整備份：資料庫 + 所有檔案（ZIP）\",download:\"下載\",preparingBackup:\"正在準備備份...\",creatingArchive:\"正在建立備份歸檔...對於大型歸檔可能需要一些時間。\",downloadingFile:\"正在下載備份檔案...\",backupDownloaded:\"備份下載成功\",failedToCreateBackup:\"建立備份失敗：{{message}}\",restore:\"恢復\",restoreReplacesAll:\"恢復將替換所有資料。\",restoreReplacesAllDetail:\"您目前的資料庫和檔案將被完全替換。恢復後需要重新啟動。\",restoreConfirmTitle:\"恢復備份\",restoreConfirmMessage:'您確定要從\"{{filename}}\"恢復嗎？這將完全替換您目前的資料庫和所有檔案。恢復後需要重新啟動應用程式。',restoreConfirmButton:\"恢復備份\",uploadingFile:\"正在上傳備份檔案...\",backupRestoredRestart:\"備份已恢復。請重新啟動 Bambuddy。\",failedToRestore:\"恢復備份失敗。請檢查檔案格式。\",reloadNow:\"立即重新載入\",creatingBackup:\"正在建立備份\",restoringBackup:\"正在恢復備份\",preparing:\"準備中...\",processing:\"處理中...\",doNotClosePage:\"請不要關閉此頁面或離開頁面。對於大型備份，此操作可能需要幾分鐘。\",restoring:\"恢復中...\",restoreComplete:\"恢復完成\",restoreFailed2:\"恢復失敗\",importSettings:\"從備份檔案匯入設定\",pleaseWaitRestoring:\"請等待資料恢復中\",selectBackupFile:\"點選選擇備份檔案（.json 或 .zip）\",duplicateHandling:\"重複項處理方式：\",matchPrinters:\"印表機\",matchPrintersBy:\"按序列號匹配\",matchSmartPlugs:\"智慧插座\",matchSmartPlugsBy:\"按 IP 位址匹配\",matchNotificationProviders:\"通知提供者\",matchNotificationProvidersBy:\"按名稱匹配\",matchFilaments:\"耗材\",matchFilamentsBy:\"按名稱 + 類型 + 品牌匹配\",matchArchives:\"檔案\",matchArchivesBy:\"按內容雜湊匹配（始終跳過）\",matchPendingUploads:\"待上傳\",matchPendingUploadsBy:\"按檔名匹配\",matchSettingsTemplates:\"設定和範本\",matchSettingsTemplatesBy:\"始終覆蓋\",replaceExisting:\"替換現有資料\",keepExisting:\"保留現有資料\",overwriteDescription:\"用備份資料覆蓋已存在的項目\",keepDescription:\"僅恢復尚不存在的項目\",overwriteCaution:\"注意：\",overwriteWarning:\"覆蓋將用備份資料替換您目前的設定。出於安全考慮，印表機存取碼永遠不會被覆蓋。\",cancel:\"取消\",processingBackup:\"正在處理備份檔案...\",itemsRestored:\"已恢復項目\",itemsSkipped:\"已跳過項目\",restored:\"已恢復\",skippedAlreadyExist:\"已跳過（已存在）\",filesCategory:\"檔案（3MF、縮圖等）\",andMore:\"...還有 {{count}} 項\",newApiKeysGenerated:\"已產生新的 API 金鑰\",keysShownOnce:\"這些金鑰僅顯示一次。請立即複製！\",copy:\"複製\",noDataFound:\"在備份檔案中未找到可恢復的資料。\",close:\"關閉\",scheduledBackup:\"排程備份\",scheduledBackupDescription:\"依排程自動建立備份快照。輸出目錄可掛載到 NAS 或外部儲存。\",frequency:\"頻率\",backupTime:\"時間\",retention:\"保留\",retentionDescription:\"保留的備份數量\",outputPath:\"輸出路徑\",outputPathPlaceholder:\"預設：{{path}}\",outputPathDescription:\"留空以使用預設位置\",runNow:\"立即執行\",backupFiles:\"備份檔案\",noScheduledBackups:\"尚無備份\",deleteBackup:\"刪除\",deleteBackupConfirm:\"要刪除此備份檔案嗎？\",backupRunning:\"備份進行中…\",scheduledBackupComplete:\"備份已成功完成\",scheduledBackupFailed:\"備份失敗\",nextBackup:\"下次備份\",backupSize:\"大小\",utc:\"UTC\",defaultPathLabel:\"預設：\",categories:{settings:\"設定\",notification_providers:\"通知提供者\",notification_templates:\"通知範本\",smart_plugs:\"智慧插座\",printers:\"印表機\",filaments:\"耗材\",maintenance_types:\"維護類型\",archives:\"檔案\",projects:\"專案\",pending_uploads:\"待上傳\",external_links:\"外部連結\",api_keys:\"API 金鑰\"}},tags:{title:\"標籤\",addTag:\"新增標籤\",editTag:\"編輯標籤\",deleteTag:\"刪除標籤\",tagName:\"標籤名稱\",tagColor:\"標籤顏色\",noTags:\"無標籤\",deleteConfirm:\"確定要刪除此標籤嗎？\",manageTags:\"管理標籤\"},uploadModal:{title:\"上傳 3MF 檔案\",dragDrop:\"將 .3mf 檔案拖放到此處\",or:\"或\",browseFiles:\"瀏覽檔案\",extractionInfo:\"將從 3MF 檔案中繼資料中自動提取印表機型號。\",uploaded:\"已上傳\",failed:\"失敗\",uploading:\"上傳中...\",upload:\"上傳\",uploadFailed:\"上傳失敗\"},editArchive:{title:\"編輯歸檔\",name:\"名稱\",namePlaceholder:\"列印名稱\",printer:\"印表機\",noPrinter:\"無印表機\",project:\"專案\",noProject:\"無專案\",itemsPrinted:\"列印數量\",itemsPrintedHelp:\"此列印任務中生產的物品數量\",notes:\"備註\",notesPlaceholder:\"新增關於此列印的備註...\",externalLink:\"外部連結\",externalLinkPlaceholder:\"https://printables.com/model/...\",externalLinkHelp:\"連結到 Printables、Thingiverse 或其他來源\",tags:\"標籤\",tagsPlaceholder:\"新增標籤...\",addMoreTags:\"新增更多標籤...\",matchingTags:'匹配\"{{query}}\"',existingTags:\"現有標籤\",clickToAdd:\"（點選新增）\",status:\"狀態\",failureReason:\"失敗原因\",selectReason:\"選擇原因...\",photos:\"列印成品照片\",photosHelp:\"點選 + 新增列印成品照片\",printResult:\"列印成品\",saving:\"儲存中...\",failureReasons:{adhesionFailure:\"附著力失敗\",spaghettiDetached:\"拉絲 / 脫落\",layerShift:\"層偏移\",cloggedNozzle:\"噴嘴堵塞\",filamentRunout:\"耗材用完\",warping:\"翹曲\",stringing:\"拉絲\",underExtrusion:\"擠出不足\",powerFailure:\"斷電\",userCancelled:\"使用者取消\",other:\"其他\"},statuses:{completed:\"已完成\",failed:\"失敗\",aborted:\"已取消\",printing:\"列印中\"}},kProfiles:{title:\"K 值設定\",noPrintersConfigured:\"未設定印表機\",addPrinterInSettings:\"在設定中新增印表機以管理 K 值設定\",noActivePrinters:\"無活躍印表機\",enablePrinterConnection:\"啟用印表機連線以檢視其 K 值設定\",loadingProfiles:\"載入 K 值設定中...\",printerOffline:\"印表機離線\",printerOfflineDesc:\"所選印表機未連線。開啟電源以檢視 K 值設定。\",noMatchingProfiles:\"無匹配的設定\",noMatchingProfilesDesc:\"沒有設定匹配您的搜尋條件\",noKProfiles:\"無 K 值設定\",noKProfilesDesc:\"未找到 {{diameter}}mm 噴嘴的壓力推進設定\",createFirstProfile:\"建立第一個設定\",printer:\"印表機\",nozzle:\"噴嘴\",refresh:\"重新整理\",addProfile:\"新增設定\",export:\"匯出\",import:\"匯入\",select:\"選擇\",selectAll:\"全選\",delete:\"刪除\",searchPlaceholder:\"按名稱或耗材搜尋...\",allExtruders:\"所有擠出機\",leftOnly:\"僅左側\",rightOnly:\"僅右側\",allFlow:\"所有流量\",hfOnly:\"僅高流量\",sOnly:\"僅標準\",sortName:\"排序：名稱\",sortKValue:\"排序：K 值\",sortFilament:\"排序：耗材\",leftExtruder:\"左擠出機\",rightExtruder:\"右擠出機\",modal:{addTitle:\"新增 K 值設定\",editTitle:\"編輯 K 值設定\",profileName:\"設定名稱\",profileNamePlaceholder:\"我的 PLA 設定\",kValue:\"K 值\",kValuePlaceholder:\"0.020\",kValueHelp:\"典型範圍：PLA 0.01 - 0.06，PETG 0.02 - 0.10\",filament:\"耗材\",selectFilament:\"選擇耗材...\",noFilamentsHelp:\"未找到耗材。請先在 Bambu Studio 中建立 K 值設定。\",flowType:\"流量類型\",highFlow:\"高流量\",standard:\"標準\",nozzleSize:\"噴嘴尺寸\",extruder:\"擠出機\",extruders:\"擠出機\",left:\"左\",right:\"右\",notes:\"備註（本機儲存）\",notesPlaceholder:\"新增關於此設定的備註...\",notesHelp:\"備註儲存在 Bambuddy 中，不在印表機上\",syncing:\"與印表機同步中...\",savingExtruder:\"儲存到擠出機 {{current}}/{{total}}...\",pleaseWait:\"請稍候\"},deleteConfirm:{title:\"刪除設定\",cannotUndo:\"此操作無法復原\",message:'確定要從印表機刪除\"{{name}}\"嗎？'},bulkDelete:{title:\"刪除設定\",cannotUndo:\"此操作無法復原\",message:\"確定要從印表機刪除 {{count}} 個選中的設定嗎？\"},toast:{profileSaved:\"K 值設定已儲存\",profilesSaved:\"K 值設定已儲存到 {{count}} 個擠出機\",selectAtLeastOneExtruder:\"請至少選擇一個擠出機\",profileDeleted:\"K 值設定已刪除\",profilesDeleted:\"已刪除 {{count}} 個設定\",exportedProfiles:\"已匯出 {{count}} 個設定\",importedProfiles:\"已匯入 {{count}} / {{total}} 個設定\",noProfilesToExport:\"無可匯出的設定\",invalidFileFormat:\"無效的檔案格式\",failedToParseImport:\"解析匯入檔案失敗\",failedToSaveBatch:\"批次儲存 K 值設定失敗\",noteSaved:\"備註已儲存\",failedToSaveNote:\"儲存備註失敗\"},permission:{noRead:\"您沒有重新整理設定的權限\",noCreate:\"您沒有新增設定的權限\",noUpdate:\"您沒有更新 K 值設定的權限\",noDelete:\"您沒有刪除 K 值設定的權限\",noExport:\"您沒有匯出設定的權限\",noImport:\"您沒有匯入設定的權限\"}},virtualPrinter:{title:\"虛擬印表機\",running:\"執行中\",stopped:\"已停止\",description:{default:\"啟用虛擬印表機，使其在 Bambu Studio 和 OrcaSlicer 中可見。傳送到此印表機的檔案將直接歸檔而不列印。\",proxy:\"啟用代理，將切片軟體流量中繼到真實印表機，允許在任何網路上遠端列印。\"},enable:{title:\"啟用虛擬印表機\",visibleInSlicer:'在切片軟體發現中顯示為\"Bambuddy\"',proxyingTo:\"代理到 {{name}}\",notActive:\"未啟用\"},model:{title:\"印表機型號\",description:\"選擇要模擬的印表機型號。\",restartWarning:\"更改型號將重新啟動虛擬印表機\"},accessCode:{title:\"存取碼\",isSet:\"存取碼已設定\",notSet:\"未設定存取碼 - 需要設定才能啟用\",placeholder:\"輸入 8 位字元程式碼\",placeholderChange:\"輸入新程式碼以更改\",hint:\"必須恰好 8 個字元。切片軟體使用此程式碼進行認證。\",charCount:\"({{count}}/8)\"},targetPrinter:{title:\"目標印表機\",configured:\"代理目標已設定\",notConfigured:\"未選擇目標印表機 - 代理模式需要設定\",placeholder:\"選擇印表機...\",hint:\"選擇要將切片軟體流量代理到的印表機。印表機必須處於區域網路模式。\",noPrinters:\"未設定印表機。請先新增印表機以使用代理模式。\"},remoteInterface:{title:\"網路介面覆蓋\",configured:\"介面覆蓋已啟用\",optional:\"可選 - 當自動檢測的 IP 不正確時使用（例如多網路卡、Docker、VPN）\",placeholder:\"自動檢測（預設）...\",hint:\"覆蓋透過 SSDP 廣播並在 TLS 憑證中使用的 IP 位址。在 Bambuddy 有多個網路介面時很有用。\"},mode:{title:\"模式\",archive:\"歸檔\",archiveDesc:\"立即歸檔檔案\",review:\"審核\",reviewDesc:\"歸檔前審核\",queue:\"佇列\",queueDesc:\"歸檔並新增到佇列\",proxy:\"代理\",proxyDesc:\"中繼到真實印表機\"},autoDispatch:{title:\"自動派發\",description:\"新增到佇列時自動開始列印。關閉後，列印任務等待手動派發。\"},setupRequired:{title:\"需要設定\",description:\"虛擬印表機功能需要額外的系統設定才能工作。包括埠轉發、防火牆規則和平臺特定設定。\",readGuide:\"啟用前請閱讀設定指南\"},howItWorks:{title:\"工作原理\",step1:\"在同一區域網路中，虛擬印表機會透過發現機制自動出現在您的切片軟體（Bambu Studio / OrcaSlicer）中。從其他網路，透過 IP 位址和存取碼手動新增。\",step2:'在歸檔、審核和佇列模式下，使用切片軟體中的\"傳送\"按鈕將 3MF 檔案上傳到 Bambuddy。切片軟體會顯示\"列印成功\"— 檔案已儲存，未列印。',step3:\"在代理模式下，虛擬印表機將所有流量中繼到真實印表機 — 列印會立即開始，就像直接連線一樣。\"},status:{title:\"狀態詳情\",printerName:\"印表機名稱\",model:\"型號\",serialNumber:\"序列號\",mode:\"模式\",pendingFiles:\"待處理檔案\",targetPrinter:\"目標印表機\",ftpPort:\"FTP 連接埠\",mqttPort:\"MQTT 連接埠\",ftpConnections:\"FTP 連線\",mqttConnections:\"MQTT 連線\"},toast:{updated:\"虛擬印表機設定已更新\",failedToUpdate:\"更新設定失敗\",accessCodeRequired:\"請先設定存取碼\",targetPrinterRequired:\"請先選擇目標印表機\",bindIpRequired:\"請先設定繫結 IP\",accessCodeEmpty:\"存取碼不能為空\",accessCodeLength:\"存取碼必須恰好 8 個字元\",created:\"虛擬印表機已建立\",failedToCreate:\"建立虛擬印表機失敗\",deleted:\"虛擬印表機已刪除\",failedToDelete:\"刪除虛擬印表機失敗\"},list:{title:\"虛擬印表機\",add:\"新增\",addFirst:\"新增虛擬印表機\",empty:\"未設定虛擬印表機。新增一個以開始使用。\"},bindIp:{title:\"繫結介面\",placeholder:\"選擇介面...\",hint:\"此虛擬印表機繫結的網路介面。每臺印表機必須唯一。\"},proxy:{accessCodeHint:\"在代理模式下，在切片軟體中使用目標印表機的存取碼。連線會透明轉發到真實印表機。\"},addDialog:{title:\"新增虛擬印表機\",name:\"名稱\",hint:\"建立後可以設定存取碼、目標印表機和其他設定。\",create:\"建立\"},deleteConfirm:{title:\"刪除虛擬印表機\",message:'確定要刪除\"{{name}}\"嗎？這將停止此印表機的所有服務。'}},modelViewer:{openInSlicer:\"在切片軟體中開啟\",tabs:{model:\"3D 模型\",gcode:\"G-code 預覽\"},notAvailable:\"不可用\",notSliced:\"未切片\",plates:\"板\",allPlates:\"所有板\",plateNumber:\"板 {{number}}\",plateCount:\"{{count}} 個板\",plateCount_other:\"{{count}} 個板\",objectCount:\"{{count}} 個物件\",objectCount_other:\"{{count}} 個物件\",filamentCount:\"{{count}} 種耗材\",filamentCount_other:\"{{count}} 種耗材\",eta:\"預計 {{minutes}} 分鐘\",noPreview:\"此檔案無可用預覽\",pagination:{pageOf:\"第 {{current}} / {{total}} 頁\",prev:\"上一頁\",next:\"下一頁\"},errors:{failedToLoad:\"載入檔案失敗\",noMeshes:\"3MF 檔案中未找到網格\",unsupportedFormat:\"不支援的檔案格式\"}},maintenanceDescriptions:{lubricateCarbonRods:\"在碳纖維杆上塗抹潤滑劑以確保順暢運動\",lubricateRails:\"在線性導軌上塗抹潤滑劑以確保順暢運動\",cleanNozzle:\"清潔熱端和噴嘴以防止堵塞\",checkBelts:\"檢查皮帶張力以確保列印精度\",cleanBuildPlate:\"清潔列印板以獲得更好的附著力\",checkExtruder:\"檢查擠出機齒輪磨損情況\",checkCooling:\"確保冷卻風扇正常工作\",generalInspection:\"印表機綜合檢查\",cleanCarbonRods:\"清潔碳纖維杆以減少摩擦\",lubricateSteelRods:\"在鋼杆上塗抹潤滑劑以確保順暢運動\",cleanSteelRods:\"清潔鋼杆以減少摩擦\",cleanLinearRails:\"擦拭線性導軌以清除灰塵和碎屑\",checkPtfeTube:\"檢查 PTFE 管的磨損或損壞\",replaceHepaFilter:\"更換 HEPA 過濾器以保證空氣品質\",replaceCarbonFilter:\"更換活性炭過濾器\",lubricateLeftNozzleRail:\"潤滑左噴嘴導軌（H2 系列）\"},smartPlugs:{offline:\"離線\",admin:\"管理\",openPlugAdminPage:\"開啟插座管理頁面\",deleteSmartPlug:\"刪除智慧插座\",turnOnSmartPlug:\"開啟智慧插座\",turnOffSmartPlug:\"關閉智慧插座\",turnOn:\"開啟\",turnOff:\"關閉\",addSmartPlug:{scanningNetwork:\"掃描網路中...\",chooseEntity:\"選擇實體...\",connectionFailed:\"連線失敗\",searchEntities:\"搜尋實體...\",searchPowerSensors:\"搜尋功率感測器...\",searchEnergySensors:\"搜尋能量感測器...\",placeholders:{plugName:\"客廳插座\",mqttStateOnValue:\"ON、true、1\",mqttSameAsPower:\"與功率主題相同，或不同\"}},linkedTo:\"連結到：\",monitorOnly:\"僅監控\",alerts:\"警報\",scheduleOn:\"開啟 {{time}}\",scheduleOff:\"關閉 {{time}}\",on:\"開啟\",off:\"關閉\",power:\"功率\",kwhToday:\"今日kWh\",settings:\"設定\",automationSettings:\"自動化設定\",showInSwitchbar:\"在開關欄顯示\",quickAccessSidebar:\"從側邊欄快速存取\",enabled:\"已啟用\",enableAutomation:\"為此插座啟用自動化\",autoOn:\"自動開啟\",autoOnDescription:\"列印開始時開啟\",autoOff:\"自動關閉\",autoOffDescription:\"列印完成時關閉（一次性）\",autoOffPersistent:\"保持啟用\",autoOffPersistentDescription:\"在列印之間保持啟用而非一次性\",turnOffDelayMode:\"關閉延遲模式\",time:\"時間\",temp:\"溫度\",delayMinutes:\"延遲（分鐘）\",tempThreshold:\"溫度閾值（°C）\",tempThresholdDescription:\"當噴嘴冷卻到此溫度以下時關閉\",edit:\"編輯\",deleteConfirm:'確定要刪除\"{{name}}\"嗎？此操作無法復原。',turnOnConfirm:'確定要開啟\"{{name}}\"嗎？',turnOffConfirm:'確定要關閉\"{{name}}\"嗎？這將切斷連線裝置的電源。',failedToTurn:'無法{{action}}\"{{name}}\"',unknown:\"未知\",addTitle:\"新增智慧插座\",editTitle:\"編輯智慧插座\",stopScanning:\"停止掃描\",discoverTasmota:\"發現Tasmota裝置\",foundDevices:\"找到{{count}}個裝置 - 點選選擇：\",noDevicesFound:\"未在您的網路中找到Tasmota裝置\",haNotConfigured:\"Home Assistant未設定。請在以下位置設定\",haSettingsPath:\"設定 → 網路 → Home Assistant\",selectEntity:\"選擇實體 *\",ipAddress:\"IP 位址 *\",nameLabel:\"名稱 *\",username:\"使用者名稱\",password:\"密碼\",authHint:\"如果您的Tasmota裝置不需要認證，請留空\",linkToPrinter:\"連結印表機\",noPrinter:\"無印表機（僅手動控制）\",linkingDescription:\"連結後可在列印開始/完成時自動開關\",powerAlerts:\"功率警報\",alertAbove:\"高於時警報（W）\",alertBelow:\"低於時警報（W）\",alertDescription:\"當電力消耗超過這些閾值時收到通知。留空以停用該方向。\",dailySchedule:\"每日計畫\",turnOnAt:\"開啟時間\",turnOffAt:\"關閉時間\",scheduleDescription:\"每天在這些時間自動開關插座。留空以跳過該操作。\",showOnPrinterCard:\"在印表機卡片上顯示\",displayOnPrinterCard:\"在印表機卡片上顯示按鈕\",connectedResult:\"已連線！\",deviceLabel:\"裝置：{{name}} - \",stateLabel:\"狀態：{{state}}\",test:\"測試\",delete:\"刪除\",save:\"儲存\",add:\"新增\",cancel:\"取消\",failedToStartScan:\"無法開始掃描\",nameRequired:\"名稱為必填項\",entityRequired:\"Home Assistant插座需要實體\",mqttTopicRequired:\"必須為功率、能源或狀態監控設定至少一個MQTT主題\",loadingEntities:\"正在載入實體...\",loading:\"載入中...\",failedToLoadEntities:\"載入實體失敗：{{error}}\",noEntitiesMatching:'未找到匹配\"{{search}}\"的實體',noEntitiesAvailable:\"無可用實體\",searchingEntities:\"搜尋所有實體（找到{{count}}個）\",showingEntities:\"顯示 switch、light、input_boolean（{{count}}個可用）\",energyMonitoringOptional:\"能源監控（可選）\",energyMonitoringHint:\"搜尋並選擇提供功率/能源資料的感測器。\",powerSensorW:\"功率感測器（W）\",energyTodayKwh:\"今日能源（kWh）\",totalEnergyKwh:\"總能源（kWh）\",noMatchingSensors:\"無匹配的感測器\",none:\"無\",mqttNotConfigured:\"MQTT代理未設定。請在以下位置設定代理地址\",mqttSettingsPath:\"設定 → 網路 → MQTT發布\",mqttNotConfiguredSuffix:\"（您不需要啟用發布，只需填寫代理詳細資訊）。\",mqttMonitorOnlyDescription:\"MQTT插座透過MQTT訂閱接收功率/能源資料。開關控制不可用 - 請使用您的MQTT代理或家庭自動化系統。\",powerMonitoring:\"功率監控\",energyMonitoring:\"能源監控\",stateMonitoring:\"狀態監控\",optional:\"可選\",topic:\"主題\",jsonPath:\"JSON路徑\",multiplier:\"乘數\",onValue:\"ON值\",mqttPowerHint:`JSON路徑從JSON負載中提取值（例如\"power_l1\"）。如果主題發布原始數值，請留空。\n乘數：mW→W使用0.001，kW→W使用1000。`,mqttEnergyHint:`JSON路徑從JSON負載中提取值。原始值請留空。\n乘數：Wh→kWh使用0.001，MWh→kWh使用1000。`,mqttStateHint:`JSON路徑從JSON負載中提取值。原始值請留空。\nON值：表示\"ON\"的確切字串。留空以自動檢測（ON、true、1）。`,restControl:\"控制\",restOnUrl:\"開啟 URL\",restOffUrl:\"關閉 URL\",restOnBody:\"開啟請求內容\",restOffBody:\"關閉請求內容\",restMethod:\"HTTP 方法\",restHeaders:\"自訂標頭（JSON）\",restStatusUrl:\"狀態 URL\",restStatusPath:\"狀態 JSON 路徑\",restStatusOnValue:\"ON 值\",restPowerUrl:\"功率URL\",restPowerPath:\"功率 JSON 路徑\",restPowerMultiplier:\"功率乘數\",restEnergyUrl:\"能耗URL\",restEnergyPath:\"能耗 JSON 路徑\",restEnergyMultiplier:\"能耗乘數\",restUrlRequired:\"REST 插座至少需要一個 URL（ON 或 OFF）\",restHeadersHint:'例如：{\"Authorization\": \"Bearer your-token\"}',restBodyHint:'例如：ON、{\"state\": \"on\"}',restStatusHint:\"用於輪詢目前狀態的 URL\",restPathHint:\"例如：state 或 data.power.status\",restPowerUrlHint:\"功率資料的獨立URL（留空則使用狀態URL）\",restEnergyUrlHint:\"能耗資料的獨立URL（留空則使用狀態URL）\",restEnergyHint:\"每個值可以使用獨立的URL，或回退到狀態 URL。使用乘數進行單位轉換（例如：0.001 將 Wh 轉換為 kWh）。\",testConnection:\"測試連線\",connectionSuccess:\"連線成功\",noSwitchesInSwitchbar:\"開關欄中沒有開關\",enableSwitchbarHint:'在設定 > 智慧插座中啟用\"在開關欄顯示\"'},notifications:{providerTypes:{callmebot:\"CallMeBot/WhatsApp\",ntfy:\"ntfy\",pushover:\"Pushover\",telegram:\"Telegram\",email:\"電子郵件\",discord:\"Discord\",webhook:\"Webhook\",homeassistant:\"Home Assistant\"},providerDescriptions:{email:\"SMTP 電子郵件通知\",telegram:\"透過 Telegram 機器人傳送通知\",discord:\"透過 Webhook 傳送到 Discord 頻道\",ntfy:\"免費、可自託管的推送通知\",pushover:\"簡單、可靠的推送通知\",callmebot:\"透過 CallMeBot 免費傳送 WhatsApp 通知\",webhook:\"通用 HTTP POST 到任意 URL\",homeassistant:\"Home Assistant 儀表板中的持久通知\"},lastSuccess:\"上次：{{date}}\",error:\"錯誤\",printer:\"印表機：\",allPrinters:\"所有印表機\",sendTestNotification:\"傳送測試通知\",eventSettings:\"事件設定\",enabled:\"已啟用\",sendFromProvider:\"從此提供者傳送通知\",printEvents:\"列印事件\",printerStatus:\"印表機狀態\",amsAlarms:\"AMS 警報\",amsHtAlarms:\"AMS-HT 警報\",printQueue:\"列印佇列\",start:\"開始\",plateCheck:\"熱床檢測\",complete:\"完成\",failed:\"失敗\",stopped:\"已停止\",progress:\"進度\",offline:\"離線\",lowFilament:\"耗材不足\",maintenance:\"維護\",amsHumidity:\"AMS 濕度\",amsTemp:\"AMS 溫度\",amsHtHumidity:\"AMS-HT 濕度\",amsHtTemp:\"AMS-HT 溫度\",bedCooled:\"熱床已冷卻\",firstLayer:\"首層完成\",quiet:\"免打擾\",digest:\"摘要 {{time}}\",printStarted:\"列印已開始\",plateNotEmpty:\"熱床非空\",plateNotEmptyDescription:\"列印前偵測到物體\",printCompleted:\"列印已完成\",bedCooledLabel:\"熱床已冷卻\",bedCooledDescription:\"列印後熱床溫度降至閾值以下\",firstLayerCompleteLabel:\"首層列印完成\",firstLayerCompleteDescription:\"首層完成時傳送帶照片的通知\",missingSpoolAssignmentLabel:\"缺少料卷分配\",missingSpoolAssignmentDescription:\"當列印開始且所需料盤沒有分配料卷時傳送通知\",printFailed:\"列印失敗\",printStopped:\"列印已停止\",progressMilestones:\"進度里程碑\",progressMilestonesDescription:\"在 25%、50%、75% 時通知\",printerOffline:\"印表機離線\",printerError:\"印表機錯誤\",lowFilamentLabel:\"耗材不足\",maintenanceDue:\"需要維護\",maintenanceDueDescription:\"需要維護時通知\",amsHumidityHigh:\"AMS 濕度過高\",amsHumidityHighDescription:\"普通 AMS 濕度超過閾值\",amsTemperatureHigh:\"AMS 溫度過高\",amsTemperatureHighDescription:\"普通 AMS 溫度超過閾值\",amsHtHumidityHigh:\"AMS-HT 濕度過高\",amsHtHumidityHighDescription:\"AMS-HT 濕度超過閾值\",amsHtTemperatureHigh:\"AMS-HT 溫度過高\",amsHtTemperatureHighDescription:\"AMS-HT 溫度超過閾值\",jobAdded:\"任務已新增\",jobAddedDescription:\"任務已新增到佇列\",jobAssigned:\"任務已分配\",jobAssignedDescription:\"基於模型的任務已分配給印表機\",jobStarted:\"任務已開始\",jobStartedDescription:\"佇列任務已開始列印\",jobWaiting:\"任務等待中\",jobWaitingDescription:\"任務正在等待耗材或印表機\",jobSkipped:\"任務已跳過\",jobSkippedDescription:\"任務已跳過（上一個失敗）\",jobFailed:\"任務失敗\",jobFailedDescription:\"任務啟動失敗\",queueComplete:\"佇列已完成\",queueCompleteDescription:\"所有佇列任務已完成\",quietHours:\"免打擾時段\",noNotificationsDuring:\"在此時段內不傳送通知\",editProviderToChangeQuietHours:\"編輯提供者以更改免打擾時段\",dailyDigest:\"每日摘要\",batchNotifications:\"將通知彙總為每日摘要\",sendAt:\"傳送於 {{time}}\",editProviderToChangeDigestTime:\"編輯提供者以更改摘要時間\",edit:\"編輯\",deleteProvider:\"刪除通知提供者\",deleteConfirm:'確定要刪除\"{{name}}\"嗎？此操作無法復原。',delete:\"刪除\",addTitle:\"新增通知提供者\",editTitle:\"編輯通知提供者\",nameLabel:\"名稱 *\",namePlaceholder:\"我的通知\",providerTypeLabel:\"提供者類型 *\",configuration:\"設定\",testConfiguration:\"測試設定\",printerFilter:\"印表機篩選\",onlyFromPrinter:\"僅傳送來自此印表機的事件通知\",quietHoursDnd:\"免打擾時段\",quietStart:\"開始\",quietEnd:\"結束\",dailyDigestLabel:\"每日摘要\",sendDigestAt:\"傳送摘要於\",digestCollected:\"事件將被收集並在此時間作為單條摘要傳送\",notificationEvents:\"通知事件\",progressPercent:\"（25%、50%、75%）\",bedCooledAfterPrint:\"（列印完成後）\",cancel:\"取消\",save:\"儲存\",add:\"新增\",nameRequired:\"名稱為必填項\",fieldRequired:\"{{field}}為必填項\",phoneNumber:\"電話號碼\",apiKey:\"API 金鑰\",serverUrl:\"伺服器 URL\",topic:\"主題\",authToken:\"認證權杖\",userKey:\"使用者金鑰\",appToken:\"應用程式權杖\",priority:\"優先順序\",botToken:\"機器人權杖\",chatId:\"聊天 ID\",smtpServer:\"SMTP 伺服器\",smtpPort:\"SMTP 連接埠\",security:\"安全\",authentication:\"認證\",username:\"使用者名稱\",password:\"密碼\",fromEmail:\"寄件人信箱\",toEmail:\"收件人信箱\",webhookUrl:\"Webhook URL\",payloadFormat:\"負載格式\",authorization:\"授權\",titleFieldName:\"標題欄位名\",messageFieldName:\"訊息欄位名\",editTemplate:\"編輯範本：{{name}}\",titleLabel:\"標題\",bodyLabel:\"正文\",titlePlaceholder:\"通知標題...\",bodyPlaceholder:\"通知正文...\",availableVariables:\"可用變數\",clickToInsert:\"點選插入到正文游標位置\",livePreview:\"即時預覽\",hide:\"隱藏\",show:\"顯示\",loadingPreview:\"載入預覽中...\",enterTemplateContent:\"輸入範本內容以檢視預覽\",titlePreview:\"標題：\",bodyPreview:\"正文：\",resetToDefault:\"恢復預設\",titleRequired:\"標題為必填項\",bodyRequired:\"正文為必填項\",notificationLog:\"通知日誌\",showFailedOnly:\"僅顯示失敗\",last24Hours:\"最近 24 小時\",last7Days:\"最近 7 天\",last30Days:\"最近 30 天\",last90Days:\"最近 90 天\",justNow:\"剛剛\",noFailedNotifications:\"沒有失敗的通知\",noNotificationsLogged:\"沒有通知紀錄\",unknownProvider:\"未知提供者\",logTitle:\"標題\",logMessage:\"訊息\",logError:\"錯誤\",logProvider:\"提供者：{{type}}\",logTime:\"時間：{{time}}\",refresh:\"重新整理\",clearOld:\"清除舊紀錄\",statsSummary:\"最近 {{days}} 天：\",statsNotifications:\"條通知\",statsSent:\"{{count}} 條已傳送\",statsFailed:\"{{count}} 條失敗\",eventTypes:{print_start:\"列印已開始\",print_complete:\"列印完成\",print_failed:\"列印失敗\",print_stopped:\"列印已停止\",print_progress:\"進度\",printer_offline:\"印表機離線\",printer_error:\"印表機錯誤\",filament_low:\"耗材不足\",maintenance_due:\"需要維護\",test:\"測試\"},userEmail:{title:\"通知\",emailNotifications:\"郵件通知\",emailNotificationsDesc:\"接收您自己列印任務的郵件通知。郵件將透過進階身份驗證中設定的 SMTP 設定傳送。\",sendingTo:\"通知將傳送至\",noEmailWarning:\"您的帳戶沒有郵件地址。請聯絡管理員新增。\",printJobNotifications:\"列印任務通知\",printJobNotificationsDesc:\"選擇哪些事件會觸發您提交的列印任務的郵件通知。\",printJobStarts:\"列印任務開始\",printJobStartsDesc:\"當您的列印任務開始時收到通知。\",printJobFinishes:\"列印任務完成\",printJobFinishesDesc:\"當您的列印任務成功完成時收到通知。\",printErrors:\"列印錯誤\",printErrorsDesc:\"當您的列印任務失敗或遇到錯誤時收到通知。\",printJobStops:\"列印任務停止\",printJobStopsDesc:\"當您的列印任務被取消或停止時收到通知。\",saveSuccess:\"通知偏好設定已儲存。\",saveError:\"儲存通知偏好設定失敗。\"}},richTextEditor:{bold:\"粗體\",italic:\"斜體\",underline:\"底線\",bulletList:\"無序列表\",numberedList:\"有序列表\",alignLeft:\"左對齊\",alignCenter:\"居中對齊\",alignRight:\"右對齊\",addLink:\"新增連結\",removeLink:\"移除連結\"},externalLinks:{noLinksConfigured:\"未設定外部連結\",deleteLink:\"刪除連結\",removeCustomIcon:\"移除自訂圖示\",openInNewTab:\"在新標籤頁中開啟\",placeholders:{linkName:\"我的連結\"}},keyboardShortcuts:{title:\"鍵盤快捷鍵\",navigation:\"導航\",archivesSection:\"歸檔\",kProfilesSection:\"K 值設定\",generalSection:\"通用\",shortcuts:{goToPrinters:\"前往印表機\",goToArchives:\"前往歸檔\",goToQueue:\"前往佇列\",goToStats:\"前往統計\",goToProfiles:\"前往雲端設定\",goToSettings:\"前往設定\",focusSearch:\"聚焦搜尋\",openUploadModal:\"開啟上傳對話方塊\",clearSelection:\"清除選擇 / 取消焦點\",contextMenu:\"卡片右鍵選單\",refreshProfiles:\"重新整理設定\",newProfile:\"新增設定\",exitSelectionMode:\"結束選擇模式\",showHelp:\"顯示此協助\"},footer:\"按 Esc 或點選外部關閉\"},notificationLog:{title:\"通知日誌\",events:{printStarted:\"列印開始\",printComplete:\"列印完成\",printFailed:\"列印失敗\",printStopped:\"列印停止\",progress:\"進度\",printerOffline:\"印表機離線\",printerError:\"印表機錯誤\",lowFilament:\"耗材不足\",maintenanceDue:\"維護到期\",test:\"測試\"},timeAgo:{justNow:\"剛剛\",minutesAgo:\"{{minutes}} 分鐘前\",hoursAgo:\"{{hours}} 小時前\"}},restoreBackup:{title:\"恢復備份\",restoring:\"恢復中...\",restoreComplete:\"恢復完成\",restoreFailed:\"恢復失敗\",importSettings:\"從備份檔案匯入設定\",pleaseWait:\"請稍候，正在恢復您的資料\",clickToSelect:\"點選選擇備份檔案（.json 或 .zip）\",howDuplicateHandling:\"重複處理方式：\",categories:{printers:\"印表機\",smartPlugs:\"智慧插座\",notificationProviders:\"通知提供者\",filaments:\"耗材\",archives:\"歸檔\",pendingUploads:\"待處理上傳\",settingsTemplates:\"設定和範本\"},matchingInfo:{printers:\"按序列號匹配\",smartPlugs:\"按 IP 位址匹配\",notificationProviders:\"按名稱匹配\",filaments:\"按名稱 + 類型 + 品牌匹配\",archives:\"按內容雜湊匹配\",pendingUploads:\"按檔名匹配\",settingsTemplates:\"始終覆蓋\"},replaceExisting:\"替換現有資料\",keepExisting:\"保留現有資料\",replaceDescription:\"用備份資料覆蓋已存在的項目\",keepDescription:\"僅恢復不存在的項目\",caution:\"注意：\",cautionText:\"覆蓋將用備份資料替換您目前的設定。出於安全考慮，印表機存取碼永遠不會被覆蓋。\",itemsRestored:\"已恢復項目\",itemsSkipped:\"已跳過項目\",restored:\"已恢復\",skipped:\"已跳過（已存在）\",filesLabel:\"檔案（3MF、縮圖等）\",newApiKeysGenerated:\"已產生新 API 金鑰\",newApiKeysWarning:\"這些金鑰僅顯示一次。請立即複製！\",processingBackup:\"處理備份檔案中...\",noDataFound:\"備份檔案中未找到可恢復的資料。\",failedToRestore:\"恢復備份失敗。請檢查檔案格式。\"},backupExport:{title:\"匯出備份\",selectData:\"選擇要包含的資料\",selectAll:\"全選\",selectNone:\"全不選\",categoryDescriptions:{settings:\"語言、主題、更新偏好\",notifications:\"ntfy、Pushover、Discord 等\",templates:\"自訂訊息範本\",smartPlugs:\"Tasmota 插座設定\",externalLinks:\"側邊欄外部服務連結\",printers:\"印表機資訊（不含存取碼）\",plateDetection:\"空列印板參考影像\",filaments:\"耗材類型和成本\",maintenance:\"自訂維護計畫\",archives:\"所有列印資料 + 檔案（3MF、縮圖、照片）\",projects:\"專案、材料清單和附件\",pendingUploads:\"虛擬印表機待審核的上傳\",apiKeys:\"Webhook API 金鑰（匯入時產生新金鑰）\"},requiresPrinters:\"需要選擇印表機\",zipFileWarning:\"將建立 ZIP 檔案。\",zipFileDescription:\"包括所有 3MF 檔案、縮圖、縮時攝影和照片。這可能需要一些時間並產生較大的檔案。\",includeAccessCodes:\"包含存取碼\",includeAccessCodesDescription:\"用於轉移到另一臺機器\",includeAccessCodesWarning:\"存取碼將以明文形式包含。請妥善保管此備份檔案！\",categoriesSelected:\"已選擇 {{selectedCount}} 個類別\"},pendingUploads:{placeholders:{notes:\"新增關於此列印的備註...\"},discardUpload:\"丟棄上傳\",archiveAllUploads:\"歸檔所有上傳\",discardAllUploads:\"丟棄所有上傳\",archive:\"歸檔\",timeAgo:{justNow:\"剛剛\",minutesAgo:\"{{minutes}} 分鐘前\",hoursAgo:\"{{hours}} 小時前\",daysAgo:\"{{days}} 天前\"}},apiBrowser:{placeholders:{requestBody:\"JSON 請求體...\",searchEndpoints:\"搜尋端點...\"}},configureAmsSlot:{title:\"設定 AMS 槽位\",slotConfigured:\"槽位已設定！\",configuringSlot:\"正在設定槽位：\",slotLabel:\"{{ams}} 槽位 {{slot}}\",searchPresets:\"搜尋預設...\",colorPlaceholder:\"顏色名稱或十六進位（例如：棕色、FF8800）\",clearCustomColor:\"清除自訂顏色\",noCloudPresets:\"無雲端預設。登入拓竹雲以同步。\",noPresetsAvailable:\"無可用預設。登入拓竹雲或匯入本機設定。\",noMatchingPresets:\"未找到匹配的預設。\",custom:\"自訂\",builtin:\"內建\",settingsSentToPrinter:\"設定已傳送到印表機\",filamentProfile:\"耗材設定\",kProfileLabel:\"K 值設定（壓力推進）\",filteringFor:\"篩選：{{material}}\",noKProfile:\"無 K 值設定（使用預設值 0.020）\",noMatchingKProfiles:\"未找到匹配的 K 值設定。將使用預設 K=0.020。\",selectFilamentFirst:\"請先選擇耗材設定\",kFromCalibration:\"K={{value}}（來自印表機校準）\",customColorLabel:\"自訂顏色（可選）\",presetColors:\"{{name}} 顏色：\",showLessColors:\"顯示更少顏色\",showMoreColors:\"顯示更多顏色\",clear:\"清除\",hexLabel:\"十六進位：#{{hex}}\",resetting:\"重設中...\",resetSlot:\"重設槽位\",cancel:\"取消\",configuring:\"設定中...\",configureSlot:\"設定槽位\"},githubBackup:{title:\"GitHub 備份\",history:\"歷史\",downloadBackup:\"下載備份\",restoreBackup:\"恢復備份\",noBackupsYet:\"尚無備份\"},emailSettings:{placeholders:{fromName:\"BamBuddy\"}},tagManagement:{searchTags:\"搜尋標籤...\",renameTag:\"重新命名標籤\",deleteTag:\"刪除標籤\"},notificationTemplates:{placeholders:{title:\"通知標題...\",body:\"通知正文...\"}},batchTag:{placeholders:{newTag:\"輸入新標籤...\"}},photoGallery:{deletePhoto:\"刪除照片\"},filamentHoverCard:{copySpoolUuid:\"複製耗材 UUID\"},kProfilesView:{hasNote:\"有備註\",copyProfile:\"複製設定\"},layout:{openMenu:\"開啟選單\",noPermissionSystemInfo:\"您沒有檢視系統資訊的權限\"},dashboard:{dragToReorder:\"拖曳以重新排列\",hideWidget:\"隱藏小工具\"},notificationProviderCard:{deleteNotificationProvider:\"刪除通知提供者\"},fileManagerModal:{closeFileManager:\"關閉檔案管理器\",sortFiles:\"排序檔案\",goToParentFolder:\"返回上級資料夾\",threeView:\"3D 檢視\"},embeddedCameraViewer:{refreshStream:\"重新整理流\",close:\"關閉\",zoomOut:\"縮小\",resetZoom:\"重設縮放\",zoomIn:\"放大\",dragToResize:\"拖曳調整大小\"},timelapseViewer:{skipBack5s:\"後退 5 秒\",skipForward5s:\"前進 5 秒\"},notificationProviders:{descriptions:{email:\"SMTP 郵件通知\",telegram:\"透過 Telegram 機器人通知\",discord:\"透過 Webhook 傳送到 Discord 頻道\",ntfy:\"免費、可自託管的推送通知\",pushover:\"簡單、可靠的推送通知\",callmebot:\"透過 CallMeBot 的免費 WhatsApp 通知\",webhook:\"通用 HTTP POST 到任意 URL\"}},logViewer:{searchPlaceholder:\"搜尋訊息或日誌名稱...\",noLogEntries:\"未找到日誌條目\"},switchbarPopover:{noSwitchesInSwitchbar:\"切換欄中沒有開關\"},projectPageModal:{placeholders:{title:\"標題\",designer:\"設計師\",license:\"許可證\",description:\"輸入描述...\",profileTitle:\"設定標題\",profileDescription:\"設定描述...\"}},spoolmanSettings:{},time:{unknown:\"-\",waiting:\"等待中\",justNow:\"剛剛\",now:\"現在\",minsAgo:\"{{count}} 分鐘前\",inMins:\"{{count}} 分鐘後\",hoursAgo:\"{{count}} 小時前\",inHours:\"{{count}} 小時後\",daysAgo:\"{{count}} 天前\",inDays:\"{{count}} 天後\"},spoolbuddy:{nav:{dashboard:\"儀表板\",ams:\"AMS\",inventory:\"庫存\",writeTag:\"寫入\",settings:\"設定\"},status:{nfcReady:\"NFC 就緒\",nfcOff:\"NFC 關閉\",offline:\"離線\",online:\"線上\",noPrinters:\"無印表機\",deviceOffline:\"裝置離線\",waitingConnection:\"等待裝置連線...\",systemReady:\"系統就緒\",status:\"狀態\"},dashboard:{readyToScan:\"準備掃描\",idleMessage:\"將耗材放在磅秤上以識別\",nfcHint:\"NFC 標籤將自動讀取\",device:\"裝置\",syncWeight:\"同步重量\",weightSynced:\"已同步！\",unknownTag:\"未知標籤\",newTag:\"偵測到新標籤\",onScale:\"在磅秤上\",linkSpool:\"連結到耗材\",linkTagTitle:\"將標籤連結到耗材\",linkTag:\"連結標籤\",selectSpool:\"選擇要連結此標籤的耗材：\",noUntagged:\"未找到沒有標籤的耗材\",tagDetected:\"偵測到標籤\",noTag:\"無標籤\",tagId:\"標籤\",grossWeight:\"毛重\",spoolSize:\"耗材盤尺寸\",close:\"關閉\",currentSpool:\"目前耗材\"},modal:{spoolDetected:\"偵測到耗材\",assignToAms:\"分配到 AMS\",syncWeight:\"同步重量\",weightSynced:\"已同步！\",syncing:\"同步中...\",newTagDetected:\"偵測到新標籤\",addToInventory:\"新增到庫存\",assignToAmsTitle:\"分配到 AMS\",selectSlot:\"選擇槽位\",assign:\"分配\",assigning:\"分配中...\",assignSuccess:\"已分配！\",assignError:\"分配耗材失敗。請重試。\",noPrinterSelected:\"選擇印表機...\",noAmsDetected:\"此印表機未偵測到 AMS\",slot:\"槽位\"},weight:{noReading:\"無讀數\",stable:\"穩定\",measuring:\"測量中...\",tare:\"去皮\",calibrate:\"校準\"},spool:{remaining:\"剩餘\",material:\"材料\",brand:\"品牌\",color:\"顏色\",coreWeight:\"軸心重\",labelWeight:\"標籤重\",scaleWeight:\"磅秤重\",netWeight:\"淨重\",lastUsed:\"上次使用\"},ams:{noData:\"未偵測到 AMS\",connectAms:\"連線 AMS 以檢視耗材槽位\",noPrinter:\"未選擇印表機\",selectPrinter:\"從頂部欄選擇印表機\",printerDisconnected:\"印表機已斷開\",humidity:\"濕度\",level:\"餘量\",active:\"活躍\",slot:\"槽位\",empty:\"空\"},inventory:{search:\"搜尋耗材...\",empty:\"庫存中沒有耗材\",noResults:\"沒有匹配的耗材\",spools:\"個耗材\",addSpool:\"新增耗材\"},settings:{tabDevice:\"裝置\",tabDisplay:\"顯示\",tabScale:\"磅秤\",tabUpdates:\"更新\",nfcReader:\"NFC 讀卡器\",type:\"類型\",connection:\"連線\",notConnected:\"不適用\",deviceInfo:\"裝置資訊\",hostname:\"主機\",uptime:\"執行時間\",systemConfig:\"後端與認證\",backendUrl:\"Bambuddy 後端 URL\",apiToken:\"API 權杖\",apiTokenPlaceholder:\"輸入 API 權杖\",saveConfig:\"儲存設定\",systemQueued:\"設定已加入佇列。\",nfcDiagnostic:\"NFC 診斷\",scaleDiagnostic:\"磅秤診斷\",readTagDiagnostic:\"讀取標籤診斷\",testNfc:\"測試讀卡器\",testScale:\"測試精度\",testReadTag:\"讀取標籤\",systemFieldsRequired:\"後端 URL 為必填項。\",brightness:\"亮度\",saved:\"已儲存\",noBacklight:\"未偵測到 DSI 背光。亮度控制需要 DSI 螢幕。\",screenBlank:\"螢幕熄滅超時\",screenBlankDesc:\"不活動後螢幕關閉。觸控喚醒。\",displayNote:\"亮度作為軟體濾鏡套用。\",scaleCalibration:\"磅秤校準\",currentWeight:\"目前重量\",tareOffset:\"去皮\",calFactor:\"係數\",knownWeight:\"已知重量\",calStep1:\"移除磅秤上所有物品並按設定零點。\",calStep2:\"將已知重量放在磅秤上。\",setZero:\"設定零點\",calibrateNow:\"校準\",calibrated:\"已校準\",tareSet:\"去皮命令已傳送。等待裝置回應...\",tareFailed:\"傳送去皮命令失敗\",zeroSet:\"零點已設定。將已知重量放在磅秤上。\",calibrationDone:\"校準完成！\",calibrationFailed:\"校準失敗\",lastCalibrated:\"上次校準\",stable:\"穩定\",settling:\"穩定中...\",firmware:\"韌體\",scale:\"磅秤\",noDevice:\"未找到 SpoolBuddy 裝置\",daemonVersion:\"守護程式版本\",currentVersion:\"目前\",versionPending:\"等待守護程式...\",checking:\"檢查中...\",checkUpdates:\"檢查更新\",updateAvailable:\"有可用更新\",updateInstructions:\"透過 SSH 更新：執行 SpoolBuddy 安裝腳本進行升級。\",upToDate:\"已是最新\",includeBeta:\"包含測試版本\"},writeTag:{tabExisting:\"現有耗材\",tabNew:\"新耗材\",tabReplace:\"替換標籤\",searchPlaceholder:\"按材料、顏色、品牌搜尋...\",noUntaggedSpools:\"沒有無標籤的耗材\",noTaggedSpools:\"沒有有標籤的耗材\",selectSpool:\"選擇一個耗材，然後將空白 NTAG 放在讀卡器上\",placeTag:\"將 NTAG 放在讀卡器上\",tagReady:\"偵測到標籤 — 準備寫入\",writeTag:\"寫入標籤\",replaceTag:\"替換標籤\",writing:\"寫入標籤中...\",waiting:\"等待 SpoolBuddy...\",writeSuccess:\"標籤寫入成功！\",writeFailed:\"寫入失敗\",queueFailed:\"佇列寫入命令失敗\",tryAgain:\"重試\",cancel:\"取消\",replaceWarning:\"舊標籤將被取消連結。新標籤將替換它。\",deviceOffline:\"SpoolBuddy 離線\",material:\"材料\",colorName:\"顏色名稱\",color:\"顏色\",brand:\"品牌\",weight:\"重量 (g)\",createSpool:\"建立耗材\",creating:\"建立中...\",spoolCreated:\"耗材已建立！準備寫入。\",createFailed:\"建立耗材失敗\"},quickMenu:{printerPower:\"印表機電源\",systemControls:\"系統\",restartDaemon:\"重新啟動守護程式\",restartBrowser:\"重新啟動瀏覽器\",reboot:\"重新開機\",shutdown:\"關機\",swipeToClose:\"向下滑動關閉\",confirmTitle:\"確認\",confirmShutdown:\"確定要關閉 SpoolBuddy 嗎？您需要實體存取才能重新開啟。\",confirmReboot:\"確定要重新開機 SpoolBuddy 嗎？\",confirmRestartDaemon:\"重新啟動 SpoolBuddy 守護程式？NFC 和磅秤將暫時不可用。\",confirmRestartBrowser:\"重新啟動kiosk瀏覽器？螢幕將短暫變黑。\",confirm:\"確認\",confirmPlugOn:\"開啟 {{name}}？\",confirmPlugOff:\"關閉 {{name}}？\",turnOn:\"開啟\",turnOff:\"關閉\"}},bugReport:{title:\"報告錯誤\",description:\"描述\",descriptionPlaceholder:\"出了什麼問題？請描述問題...\",email:\"信箱（可選）\",emailPlaceholder:\"your@email.com\",emailPrivacy:\"如果提供，您的信箱將包含在GitHub Issue的摺疊部分中，以便維護者後續跟進。\",screenshot:\"截圖\",uploadOrPaste:\"上傳、貼上或拖曳圖片\",dataCollectedSummary:\"報告中包含哪些資料？\",dataIncluded:\"包含：\",dataIncludedList:\"應用程式版本、作業系統、架構、Python版本、資料庫統計（僅計數）、印表機型號、噴嘴數量、韌體版本、連線狀態、整合狀態（Spoolman、MQTT、HA）、非敏感設定、網路介面數量、Docker詳情、依賴版本。\",dataNeverIncluded:\"絕不包含：\",dataNeverIncludedList:\"印表機名稱、序列號、存取碼、密碼、IP 位址、信箱地址、API金鑰、權杖、Webhook URL、主機名稱或使用者名稱。\",submit:\"提交\",startLogging:\"開始偵錯日誌\",stepEnableLogging:\"偵錯日誌已啟用\",stepReproduce:\"請現在重現問題\",stepStopLogging:\"停止並提交報告\",stopAndSubmit:\"停止並提交\",maxDuration:\"{{minutes}} 分鐘後自動停止\",stoppingLogs:\"正在收集日誌並提交...\",submitting:\"正在提交錯誤報告...\",submitSuccess:\"錯誤報告提交成功！\",submitFailed:\"提交錯誤報告失敗\",thankYou:\"謝謝！\",submitted:\"您的錯誤報告已提交。\",viewIssue:\"檢視 Issue\",unexpectedError:\"發生了意外錯誤\"},failureDetection:{title:\"AI 故障檢測\",description:\"透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。\",mlUrl:\"Obico ML API 地址\",mlUrlHint:\"您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。\",test:\"測試\",testSuccess:\"ML API 可存取且正常。\",testFailed:\"無法存取 ML API。\",sensitivity:\"靈敏度\",sensitivityLow:\"低(減少誤報)\",sensitivityMedium:\"中(平衡)\",sensitivityHigh:\"高(更早檢測,更多誤報)\",sensitivityHint:\"調整觸發警告和故障的置信度閾值。\",action:\"偵測到故障時的操作\",actionNotify:\"僅通知\",actionPause:\"暫停列印\",actionPauseOff:\"暫停並切斷電源\",pollInterval:\"檢查間隔(秒)\",pollIntervalHint:\"列印過程中每臺印表機的檢查頻率。最小 5 秒,最大 120 秒。\",externalUrlMissing:\"尚未設定外部 URL。\",externalUrlHint:\"ML API 透過 URL 擷取攝影機快照。請在一般設定中設定外部 URL，讓 ML API 容器可以連線到 Bambuddy。\",perPrinterTitle:\"監控的印表機\",perPrinterHint:\"選擇檢測服務要監視哪些印表機。\",monitorAll:\"監控所有已連線的印表機\",statusTitle:\"狀態\",serviceRunning:\"服務執行中\",thresholds:\"低 / 高閾值\",activePrinters:\"活動列印\",noActivePrints:\"目前沒有正在進行的列印。\",historyTitle:\"最近檢測\",noHistory:\"尚無檢測紀錄。\"}},Zue={en:{translation:$ue},de:{translation:Vue},fr:{translation:Gue},ja:{translation:Wue},it:{translation:Kue},\"pt-BR\":{translation:Xue},\"zh-CN\":{translation:Yue},\"zh-TW\":{translation:Que}};xo.use(YY).use(Sue).init({resources:Zue,fallbackLng:\"en\",supportedLngs:[\"en\",\"de\",\"fr\",\"ja\",\"it\",\"pt-BR\",\"zh-CN\",\"zh-TW\"],detection:{order:[\"localStorage\",\"navigator\",\"htmlTag\"],lookupLocalStorage:\"bambutrack_language\",caches:[\"localStorage\"]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});const iH=[{code:\"en\",name:\"English\",nativeName:\"English\"},{code:\"de\",name:\"German\",nativeName:\"Deutsch\"},{code:\"fr\",name:\"French\",nativeName:\"Français\"},{code:\"ja\",name:\"Japanese\",nativeName:\"日本語\"},{code:\"it\",name:\"Italian\",nativeName:\"Italiano\"},{code:\"pt-BR\",name:\"Portuguese (Brazil)\",nativeName:\"Português (Brasil)\"},{code:\"zh-CN\",name:\"Chinese (Simplified)\",nativeName:\"简体中文\"},{code:\"zh-TW\",name:\"Chinese (Traditional)\",nativeName:\"繁體中文\"}];var sH=\"popstate\";function Jue(t={}){function e(r,i){let{pathname:s,search:o,hash:l}=r.location;return G3(\"\",{pathname:s,search:o,hash:l},i.state&&i.state.usr||null,i.state&&i.state.key||\"default\")}function n(r,i){return typeof i==\"string\"?i:tw(i)}return tme(e,n,null,t)}function ja(t,e){if(t===!1||t===null||typeof t>\"u\")throw new Error(e)}function lc(t,e){if(!t){typeof console<\"u\"&&console.warn(e);try{throw new Error(e)}catch{}}}function eme(){return Math.random().toString(36).substring(2,10)}function oH(t,e){return{usr:t.state,key:t.key,idx:e}}function G3(t,e,n=null,r){return{pathname:typeof t==\"string\"?t:t.pathname,search:\"\",hash:\"\",...typeof e==\"string\"?gy(e):e,state:n,key:e&&e.key||r||eme()}}function tw({pathname:t=\"/\",search:e=\"\",hash:n=\"\"}){return e&&e!==\"?\"&&(t+=e.charAt(0)===\"?\"?e:\"?\"+e),n&&n!==\"#\"&&(t+=n.charAt(0)===\"#\"?n:\"#\"+n),t}function gy(t){let e={};if(t){let n=t.indexOf(\"#\");n>=0&&(e.hash=t.substring(n),t=t.substring(0,n));let r=t.indexOf(\"?\");r>=0&&(e.search=t.substring(r),t=t.substring(0,r)),t&&(e.pathname=t)}return e}function tme(t,e,n,r={}){let{window:i=document.defaultView,v5Compat:s=!1}=r,o=i.history,l=\"POP\",c=null,d=u();d==null&&(d=0,o.replaceState({...o.state,idx:d},\"\"));function u(){return(o.state||{idx:null}).idx}function m(){l=\"POP\";let b=u(),g=b==null?null:b-d;d=b,c&&c({action:l,location:v.location,delta:g})}function p(b,g){l=\"PUSH\";let _=G3(v.location,b,g);d=u()+1;let C=oH(_,d),P=v.createHref(_);try{o.pushState(C,\"\",P)}catch(N){if(N instanceof DOMException&&N.name===\"DataCloneError\")throw N;i.location.assign(P)}s&&c&&c({action:l,location:v.location,delta:1})}function f(b,g){l=\"REPLACE\";let _=G3(v.location,b,g);d=u();let C=oH(_,d),P=v.createHref(_);o.replaceState(C,\"\",P),s&&c&&c({action:l,location:v.location,delta:0})}function y(b){return nme(b)}let v={get action(){return l},get location(){return t(i,o)},listen(b){if(c)throw new Error(\"A history only accepts one active listener\");return i.addEventListener(sH,m),c=b,()=>{i.removeEventListener(sH,m),c=null}},createHref(b){return e(i,b)},createURL:y,encodeLocation(b){let g=y(b);return{pathname:g.pathname,search:g.search,hash:g.hash}},push:p,replace:f,go(b){return o.go(b)}};return v}function nme(t,e=!1){let n=\"http://localhost\";typeof window<\"u\"&&(n=window.location.origin!==\"null\"?window.location.origin:window.location.href),ja(n,\"No window.location.(origin|href) available to create URL\");let r=typeof t==\"string\"?t:tw(t);return r=r.replace(/ $/,\"%20\"),!e&&r.startsWith(\"//\")&&(r=n+r),new URL(r,n)}function QY(t,e,n=\"/\"){return rme(t,e,n,!1)}function rme(t,e,n,r){let i=typeof e==\"string\"?gy(e):e,s=fm(i.pathname||\"/\",n);if(s==null)return null;let o=ZY(t);ame(o);let l=null;for(let c=0;l==null&&c<o.length;++c){let d=fme(s);l=hme(o[c],d,r)}return l}function ZY(t,e=[],n=[],r=\"\",i=!1){let s=(o,l,c=i,d)=>{let u={relativePath:d===void 0?o.path||\"\":d,caseSensitive:o.caseSensitive===!0,childrenIndex:l,route:o};if(u.relativePath.startsWith(\"/\")){if(!u.relativePath.startsWith(r)&&c)return;ja(u.relativePath.startsWith(r),`Absolute route path \"${u.relativePath}\" nested under path \"${r}\" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),u.relativePath=u.relativePath.slice(r.length)}let m=im([r,u.relativePath]),p=n.concat(u);o.children&&o.children.length>0&&(ja(o.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path \"${m}\".`),ZY(o.children,e,p,m,c)),!(o.path==null&&!o.index)&&e.push({path:m,score:ume(m,o.index),routesMeta:p})};return t.forEach((o,l)=>{if(o.path===\"\"||!o.path?.includes(\"?\"))s(o,l);else for(let c of JY(o.path))s(o,l,!0,c)}),e}function JY(t){let e=t.split(\"/\");if(e.length===0)return[];let[n,...r]=e,i=n.endsWith(\"?\"),s=n.replace(/\\?$/,\"\");if(r.length===0)return i?[s,\"\"]:[s];let o=JY(r.join(\"/\")),l=[];return l.push(...o.map(c=>c===\"\"?s:[s,c].join(\"/\"))),i&&l.push(...o),l.map(c=>t.startsWith(\"/\")&&c===\"\"?\"/\":c)}function ame(t){t.sort((e,n)=>e.score!==n.score?n.score-e.score:mme(e.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var ime=/^:[\\w-]+$/,sme=3,ome=2,lme=1,cme=10,dme=-2,lH=t=>t===\"*\";function ume(t,e){let n=t.split(\"/\"),r=n.length;return n.some(lH)&&(r+=dme),e&&(r+=ome),n.filter(i=>!lH(i)).reduce((i,s)=>i+(ime.test(s)?sme:s===\"\"?lme:cme),r)}function mme(t,e){return t.length===e.length&&t.slice(0,-1).every((r,i)=>r===e[i])?t[t.length-1]-e[e.length-1]:0}function hme(t,e,n=!1){let{routesMeta:r}=t,i={},s=\"/\",o=[];for(let l=0;l<r.length;++l){let c=r[l],d=l===r.length-1,u=s===\"/\"?e:e.slice(s.length)||\"/\",m=fN({path:c.relativePath,caseSensitive:c.caseSensitive,end:d},u),p=c.route;if(!m&&d&&n&&!r[r.length-1].route.index&&(m=fN({path:c.relativePath,caseSensitive:c.caseSensitive,end:!1},u)),!m)return null;Object.assign(i,m.params),o.push({params:i,pathname:im([s,m.pathname]),pathnameBase:yme(im([s,m.pathnameBase])),route:p}),m.pathnameBase!==\"/\"&&(s=im([s,m.pathnameBase]))}return o}function fN(t,e){typeof t==\"string\"&&(t={path:t,caseSensitive:!1,end:!0});let[n,r]=pme(t.path,t.caseSensitive,t.end),i=e.match(n);if(!i)return null;let s=i[0],o=s.replace(/(.)\\/+$/,\"$1\"),l=i.slice(1);return{params:r.reduce((d,{paramName:u,isOptional:m},p)=>{if(u===\"*\"){let y=l[p]||\"\";o=s.slice(0,s.length-y.length).replace(/(.)\\/+$/,\"$1\")}const f=l[p];return m&&!f?d[u]=void 0:d[u]=(f||\"\").replace(/%2F/g,\"/\"),d},{}),pathname:s,pathnameBase:o,pattern:t}}function pme(t,e=!1,n=!0){lc(t===\"*\"||!t.endsWith(\"*\")||t.endsWith(\"/*\"),`Route path \"${t}\" will be treated as if it were \"${t.replace(/\\*$/,\"/*\")}\" because the \\`*\\` character must always follow a \\`/\\` in the pattern. To get rid of this warning, please change the route path to \"${t.replace(/\\*$/,\"/*\")}\".`);let r=[],i=\"^\"+t.replace(/\\/*\\*?$/,\"\").replace(/^\\/*/,\"/\").replace(/[\\\\.*+^${}|()[\\]]/g,\"\\\\$&\").replace(/\\/:([\\w-]+)(\\?)?/g,(o,l,c)=>(r.push({paramName:l,isOptional:c!=null}),c?\"/?([^\\\\/]+)?\":\"/([^\\\\/]+)\")).replace(/\\/([\\w-]+)\\?(\\/|$)/g,\"(/$1)?$2\");return t.endsWith(\"*\")?(r.push({paramName:\"*\"}),i+=t===\"*\"||t===\"/*\"?\"(.*)$\":\"(?:\\\\/(.+)|\\\\/*)$\"):n?i+=\"\\\\/*$\":t!==\"\"&&t!==\"/\"&&(i+=\"(?:(?=\\\\/|$))\"),[new RegExp(i,e?void 0:\"i\"),r]}function fme(t){try{return t.split(\"/\").map(e=>decodeURIComponent(e).replace(/\\//g,\"%2F\")).join(\"/\")}catch(e){return lc(!1,`The URL path \"${t}\" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${e}).`),t}}function fm(t,e){if(e===\"/\")return t;if(!t.toLowerCase().startsWith(e.toLowerCase()))return null;let n=e.endsWith(\"/\")?e.length-1:e.length,r=t.charAt(n);return r&&r!==\"/\"?null:t.slice(n)||\"/\"}var gme=/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i;function bme(t,e=\"/\"){let{pathname:n,search:r=\"\",hash:i=\"\"}=typeof t==\"string\"?gy(t):t,s;return n?(n=n.replace(/\\/\\/+/g,\"/\"),n.startsWith(\"/\")?s=cH(n.substring(1),\"/\"):s=cH(n,e)):s=e,{pathname:s,search:vme(r),hash:wme(i)}}function cH(t,e){let n=e.replace(/\\/+$/,\"\").split(\"/\");return t.split(\"/\").forEach(i=>{i===\"..\"?n.length>1&&n.pop():i!==\".\"&&n.push(i)}),n.length>1?n.join(\"/\"):\"/\"}function NM(t,e,n,r){return`Cannot include a '${t}' character in a manually specified \\`to.${e}\\` field [${JSON.stringify(r)}].  Please separate it out to the \\`to.${n}\\` field. Alternatively you may provide the full path as a string in <Link to=\"...\"> and the router will parse it for you.`}function xme(t){return t.filter((e,n)=>n===0||e.route.path&&e.route.path.length>0)}function pO(t){let e=xme(t);return e.map((n,r)=>r===e.length-1?n.pathname:n.pathnameBase)}function fO(t,e,n,r=!1){let i;typeof t==\"string\"?i=gy(t):(i={...t},ja(!i.pathname||!i.pathname.includes(\"?\"),NM(\"?\",\"pathname\",\"search\",i)),ja(!i.pathname||!i.pathname.includes(\"#\"),NM(\"#\",\"pathname\",\"hash\",i)),ja(!i.search||!i.search.includes(\"#\"),NM(\"#\",\"search\",\"hash\",i)));let s=t===\"\"||i.pathname===\"\",o=s?\"/\":i.pathname,l;if(o==null)l=n;else{let m=e.length-1;if(!r&&o.startsWith(\"..\")){let p=o.split(\"/\");for(;p[0]===\"..\";)p.shift(),m-=1;i.pathname=p.join(\"/\")}l=m>=0?e[m]:\"/\"}let c=bme(i,l),d=o&&o!==\"/\"&&o.endsWith(\"/\"),u=(s||o===\".\")&&n.endsWith(\"/\");return!c.pathname.endsWith(\"/\")&&(d||u)&&(c.pathname+=\"/\"),c}var im=t=>t.join(\"/\").replace(/\\/\\/+/g,\"/\"),yme=t=>t.replace(/\\/+$/,\"\").replace(/^\\/*/,\"/\"),vme=t=>!t||t===\"?\"?\"\":t.startsWith(\"?\")?t:\"?\"+t,wme=t=>!t||t===\"#\"?\"\":t.startsWith(\"#\")?t:\"#\"+t,Sme=class{constructor(t,e,n,r=!1){this.status=t,this.statusText=e||\"\",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function _me(t){return t!=null&&typeof t.status==\"number\"&&typeof t.statusText==\"string\"&&typeof t.internal==\"boolean\"&&\"data\"in t}function kme(t){return t.map(e=>e.route.path).filter(Boolean).join(\"/\").replace(/\\/\\/*/g,\"/\")||\"/\"}var eQ=typeof window<\"u\"&&typeof window.document<\"u\"&&typeof window.document.createElement<\"u\";function tQ(t,e){let n=t;if(typeof n!=\"string\"||!gme.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(eQ)try{let s=new URL(window.location.href),o=n.startsWith(\"//\")?new URL(s.protocol+n):new URL(n),l=fm(o.pathname,e);o.origin===s.origin&&l!=null?n=l+o.search+o.hash:i=!0}catch{lc(!1,`<Link to=\"${n}\"> contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(\"\\0\");var nQ=[\"POST\",\"PUT\",\"PATCH\",\"DELETE\"];new Set(nQ);var Nme=[\"GET\",...nQ];new Set(Nme);var by=w.createContext(null);by.displayName=\"DataRouter\";var zP=w.createContext(null);zP.displayName=\"DataRouterState\";var Cme=w.createContext(!1),rQ=w.createContext({isTransitioning:!1});rQ.displayName=\"ViewTransition\";var Pme=w.createContext(new Map);Pme.displayName=\"Fetchers\";var Tme=w.createContext(null);Tme.displayName=\"Await\";var Sl=w.createContext(null);Sl.displayName=\"Navigation\";var Xw=w.createContext(null);Xw.displayName=\"Location\";var xc=w.createContext({outlet:null,matches:[],isDataRoute:!1});xc.displayName=\"Route\";var gO=w.createContext(null);gO.displayName=\"RouteError\";var aQ=\"REACT_ROUTER_ERROR\",Ame=\"REDIRECT\",jme=\"ROUTE_ERROR_RESPONSE\";function Mme(t){if(t.startsWith(`${aQ}:${Ame}:{`))try{let e=JSON.parse(t.slice(28));if(typeof e==\"object\"&&e&&typeof e.status==\"number\"&&typeof e.statusText==\"string\"&&typeof e.location==\"string\"&&typeof e.reloadDocument==\"boolean\"&&typeof e.replace==\"boolean\")return e}catch{}}function Eme(t){if(t.startsWith(`${aQ}:${jme}:{`))try{let e=JSON.parse(t.slice(40));if(typeof e==\"object\"&&e&&typeof e.status==\"number\"&&typeof e.statusText==\"string\")return new Sme(e.status,e.statusText,e.data)}catch{}}function Dme(t,{relative:e}={}){ja(xy(),\"useHref() may be used only in the context of a <Router> component.\");let{basename:n,navigator:r}=w.useContext(Sl),{hash:i,pathname:s,search:o}=Qw(t,{relative:e}),l=s;return n!==\"/\"&&(l=s===\"/\"?n:im([n,s])),r.createHref({pathname:l,search:o,hash:i})}function xy(){return w.useContext(Xw)!=null}function td(){return ja(xy(),\"useLocation() may be used only in the context of a <Router> component.\"),w.useContext(Xw).location}var iQ=\"You should call navigate() in a React.useEffect(), not when your component is first rendered.\";function sQ(t){w.useContext(Sl).static||w.useLayoutEffect(t)}function qo(){let{isDataRoute:t}=w.useContext(xc);return t?Wme():Fme()}function Fme(){ja(xy(),\"useNavigate() may be used only in the context of a <Router> component.\");let t=w.useContext(by),{basename:e,navigator:n}=w.useContext(Sl),{matches:r}=w.useContext(xc),{pathname:i}=td(),s=JSON.stringify(pO(r)),o=w.useRef(!1);return sQ(()=>{o.current=!0}),w.useCallback((c,d={})=>{if(lc(o.current,iQ),!o.current)return;if(typeof c==\"number\"){n.go(c);return}let u=fO(c,JSON.parse(s),i,d.relative===\"path\");t==null&&e!==\"/\"&&(u.pathname=u.pathname===\"/\"?e:im([e,u.pathname])),(d.replace?n.replace:n.push)(u,d.state,d)},[e,n,s,i,t])}var oQ=w.createContext(null);function yy(){return w.useContext(oQ)}function Rme(t){let e=w.useContext(xc).outlet;return w.useMemo(()=>e&&w.createElement(oQ.Provider,{value:t},e),[e,t])}function Yw(){let{matches:t}=w.useContext(xc),e=t[t.length-1];return e?e.params:{}}function Qw(t,{relative:e}={}){let{matches:n}=w.useContext(xc),{pathname:r}=td(),i=JSON.stringify(pO(n));return w.useMemo(()=>fO(t,JSON.parse(i),r,e===\"path\"),[t,i,r,e])}function Lme(t,e){return lQ(t,e)}function lQ(t,e,n,r,i){ja(xy(),\"useRoutes() may be used only in the context of a <Router> component.\");let{navigator:s}=w.useContext(Sl),{matches:o}=w.useContext(xc),l=o[o.length-1],c=l?l.params:{},d=l?l.pathname:\"/\",u=l?l.pathnameBase:\"/\",m=l&&l.route;{let _=m&&m.path||\"\";dQ(d,!m||_.endsWith(\"*\")||_.endsWith(\"*?\"),`You rendered descendant <Routes> (or called \\`useRoutes()\\`) at \"${d}\" (under <Route path=\"${_}\">) but the parent route path has no trailing \"*\". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render.\n\nPlease change the parent <Route path=\"${_}\"> to <Route path=\"${_===\"/\"?\"*\":`${_}/*`}\">.`)}let p=td(),f;if(e){let _=typeof e==\"string\"?gy(e):e;ja(u===\"/\"||_.pathname?.startsWith(u),`When overriding the location using \\`<Routes location>\\` or \\`useRoutes(routes, location)\\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is \"${u}\" but pathname \"${_.pathname}\" was given in the \\`location\\` prop.`),f=_}else f=p;let y=f.pathname||\"/\",v=y;if(u!==\"/\"){let _=u.replace(/^\\//,\"\").split(\"/\");v=\"/\"+y.replace(/^\\//,\"\").split(\"/\").slice(_.length).join(\"/\")}let b=QY(t,{pathname:v});lc(m||b!=null,`No routes matched location \"${f.pathname}${f.search}${f.hash}\" `),lc(b==null||b[b.length-1].route.element!==void 0||b[b.length-1].route.Component!==void 0||b[b.length-1].route.lazy!==void 0,`Matched leaf route at location \"${f.pathname}${f.search}${f.hash}\" does not have an element or Component. This means it will render an <Outlet /> with a null value by default resulting in an \"empty\" page.`);let g=Bme(b&&b.map(_=>Object.assign({},_,{params:Object.assign({},c,_.params),pathname:im([u,s.encodeLocation?s.encodeLocation(_.pathname.replace(/\\?/g,\"%3F\").replace(/#/g,\"%23\")).pathname:_.pathname]),pathnameBase:_.pathnameBase===\"/\"?u:im([u,s.encodeLocation?s.encodeLocation(_.pathnameBase.replace(/\\?/g,\"%3F\").replace(/#/g,\"%23\")).pathname:_.pathnameBase])})),o,n,r,i);return e&&g?w.createElement(Xw.Provider,{value:{location:{pathname:\"/\",search:\"\",hash:\"\",state:null,key:\"default\",...f},navigationType:\"POP\"}},g):g}function Ome(){let t=Gme(),e=_me(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),n=t instanceof Error?t.stack:null,r=\"rgba(200,200,200, 0.5)\",i={padding:\"0.5rem\",backgroundColor:r},s={padding:\"2px 4px\",backgroundColor:r},o=null;return console.error(\"Error handled by React Router default ErrorBoundary:\",t),o=w.createElement(w.Fragment,null,w.createElement(\"p\",null,\"💿 Hey developer 👋\"),w.createElement(\"p\",null,\"You can provide a way better UX than this when your app throws errors by providing your own \",w.createElement(\"code\",{style:s},\"ErrorBoundary\"),\" or\",\" \",w.createElement(\"code\",{style:s},\"errorElement\"),\" prop on your route.\")),w.createElement(w.Fragment,null,w.createElement(\"h2\",null,\"Unexpected Application Error!\"),w.createElement(\"h3\",{style:{fontStyle:\"italic\"}},e),n?w.createElement(\"pre\",{style:i},n):null,o)}var Ime=w.createElement(Ome,null),cQ=class extends w.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,e){return e.location!==t.location||e.revalidation!==\"idle\"&&t.revalidation===\"idle\"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:e.error,location:e.location,revalidation:t.revalidation||e.revalidation}}componentDidCatch(t,e){this.props.onError?this.props.onError(t,e):console.error(\"React Router caught the following error during render\",t)}render(){let t=this.state.error;if(this.context&&typeof t==\"object\"&&t&&\"digest\"in t&&typeof t.digest==\"string\"){const n=Eme(t.digest);n&&(t=n)}let e=t!==void 0?w.createElement(xc.Provider,{value:this.props.routeContext},w.createElement(gO.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?w.createElement(zme,{error:t},e):e}};cQ.contextType=Cme;var CM=new WeakMap;function zme({children:t,error:e}){let{basename:n}=w.useContext(Sl);if(typeof e==\"object\"&&e&&\"digest\"in e&&typeof e.digest==\"string\"){let r=Mme(e.digest);if(r){let i=CM.get(e);if(i)throw i;let s=tQ(r.location,n);if(eQ&&!CM.get(e))if(s.isExternal||r.reloadDocument)window.location.href=s.absoluteURL||s.to;else{const o=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(s.to,{replace:r.replace}));throw CM.set(e,o),o}return w.createElement(\"meta\",{httpEquiv:\"refresh\",content:`0;url=${s.absoluteURL||s.to}`})}}return t}function Ume({routeContext:t,match:e,children:n}){let r=w.useContext(by);return r&&r.static&&r.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=e.route.id),w.createElement(xc.Provider,{value:t},n)}function Bme(t,e=[],n=null,r=null,i=null){if(t==null){if(!n)return null;if(n.errors)t=n.matches;else if(e.length===0&&!n.initialized&&n.matches.length>0)t=n.matches;else return null}let s=t,o=n?.errors;if(o!=null){let u=s.findIndex(m=>m.route.id&&o?.[m.route.id]!==void 0);ja(u>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(o).join(\",\")}`),s=s.slice(0,Math.min(s.length,u+1))}let l=!1,c=-1;if(n)for(let u=0;u<s.length;u++){let m=s[u];if((m.route.HydrateFallback||m.route.hydrateFallbackElement)&&(c=u),m.route.id){let{loaderData:p,errors:f}=n,y=m.route.loader&&!p.hasOwnProperty(m.route.id)&&(!f||f[m.route.id]===void 0);if(m.route.lazy||y){l=!0,c>=0?s=s.slice(0,c+1):s=[s[0]];break}}}let d=n&&r?(u,m)=>{r(u,{location:n.location,params:n.matches?.[0]?.params??{},unstable_pattern:kme(n.matches),errorInfo:m})}:void 0;return s.reduceRight((u,m,p)=>{let f,y=!1,v=null,b=null;n&&(f=o&&m.route.id?o[m.route.id]:void 0,v=m.route.errorElement||Ime,l&&(c<0&&p===0?(dQ(\"route-fallback\",!1,\"No `HydrateFallback` element provided to render during initial hydration\"),y=!0,b=null):c===p&&(y=!0,b=m.route.hydrateFallbackElement||null)));let g=e.concat(s.slice(0,p+1)),_=()=>{let C;return f?C=v:y?C=b:m.route.Component?C=w.createElement(m.route.Component,null):m.route.element?C=m.route.element:C=u,w.createElement(Ume,{match:m,routeContext:{outlet:u,matches:g,isDataRoute:n!=null},children:C})};return n&&(m.route.ErrorBoundary||m.route.errorElement||p===0)?w.createElement(cQ,{location:n.location,revalidation:n.revalidation,component:v,error:f,children:_(),routeContext:{outlet:null,matches:g,isDataRoute:!0},onError:d}):_()},null)}function bO(t){return`${t} must be used within a data router.  See https://reactrouter.com/en/main/routers/picking-a-router.`}function Hme(t){let e=w.useContext(by);return ja(e,bO(t)),e}function qme(t){let e=w.useContext(zP);return ja(e,bO(t)),e}function $me(t){let e=w.useContext(xc);return ja(e,bO(t)),e}function xO(t){let e=$me(t),n=e.matches[e.matches.length-1];return ja(n.route.id,`${t} can only be used on routes that contain a unique \"id\"`),n.route.id}function Vme(){return xO(\"useRouteId\")}function Gme(){let t=w.useContext(gO),e=qme(\"useRouteError\"),n=xO(\"useRouteError\");return t!==void 0?t:e.errors?.[n]}function Wme(){let{router:t}=Hme(\"useNavigate\"),e=xO(\"useNavigate\"),n=w.useRef(!1);return sQ(()=>{n.current=!0}),w.useCallback(async(i,s={})=>{lc(n.current,iQ),n.current&&(typeof i==\"number\"?await t.navigate(i):await t.navigate(i,{fromRouteId:e,...s}))},[t,e])}var dH={};function dQ(t,e,n){!e&&!dH[t]&&(dH[t]=!0,lc(!1,n))}w.memo(Kme);function Kme({routes:t,future:e,state:n,onError:r}){return lQ(t,void 0,n,r,e)}function Bx({to:t,replace:e,state:n,relative:r}){ja(xy(),\"<Navigate> may be used only in the context of a <Router> component.\");let{static:i}=w.useContext(Sl);lc(!i,\"<Navigate> must not be used on the initial render in a <StaticRouter>. This is a no-op, but you should modify your code so the <Navigate> is only ever rendered in response to some user interaction or state change.\");let{matches:s}=w.useContext(xc),{pathname:o}=td(),l=qo(),c=fO(t,pO(s),o,r===\"path\"),d=JSON.stringify(c);return w.useEffect(()=>{l(JSON.parse(d),{replace:e,state:n,relative:r})},[l,d,r,e,n]),null}function uQ(t){return Rme(t.context)}function Or(t){ja(!1,\"A <Route> is only ever to be used as the child of <Routes> element, never rendered directly. Please wrap your <Route> in a <Routes>.\")}function Xme({basename:t=\"/\",children:e=null,location:n,navigationType:r=\"POP\",navigator:i,static:s=!1,unstable_useTransitions:o}){ja(!xy(),\"You cannot render a <Router> inside another <Router>. You should never have more than one in your app.\");let l=t.replace(/^\\/*/,\"/\"),c=w.useMemo(()=>({basename:l,navigator:i,static:s,unstable_useTransitions:o,future:{}}),[l,i,s,o]);typeof n==\"string\"&&(n=gy(n));let{pathname:d=\"/\",search:u=\"\",hash:m=\"\",state:p=null,key:f=\"default\"}=n,y=w.useMemo(()=>{let v=fm(d,l);return v==null?null:{location:{pathname:v,search:u,hash:m,state:p,key:f},navigationType:r}},[l,d,u,m,p,f,r]);return lc(y!=null,`<Router basename=\"${l}\"> is not able to match the URL \"${d}${u}${m}\" because it does not start with the basename, so the <Router> won't render anything.`),y==null?null:w.createElement(Sl.Provider,{value:c},w.createElement(Xw.Provider,{children:e,value:y}))}function Yme({children:t,location:e}){return Lme(W3(t),e)}function W3(t,e=[]){let n=[];return w.Children.forEach(t,(r,i)=>{if(!w.isValidElement(r))return;let s=[...e,i];if(r.type===w.Fragment){n.push.apply(n,W3(r.props.children,s));return}ja(r.type===Or,`[${typeof r.type==\"string\"?r.type:r.type.name}] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`),ja(!r.props.index||!r.props.children,\"An index route cannot have child routes.\");let o={id:r.props.id||s.join(\"-\"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(o.children=W3(r.props.children,s)),n.push(o)}),n}var Ok=\"get\",Ik=\"application/x-www-form-urlencoded\";function UP(t){return typeof HTMLElement<\"u\"&&t instanceof HTMLElement}function Qme(t){return UP(t)&&t.tagName.toLowerCase()===\"button\"}function Zme(t){return UP(t)&&t.tagName.toLowerCase()===\"form\"}function Jme(t){return UP(t)&&t.tagName.toLowerCase()===\"input\"}function ehe(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function the(t,e){return t.button===0&&(!e||e===\"_self\")&&!ehe(t)}function K3(t=\"\"){return new URLSearchParams(typeof t==\"string\"||Array.isArray(t)||t instanceof URLSearchParams?t:Object.keys(t).reduce((e,n)=>{let r=t[n];return e.concat(Array.isArray(r)?r.map(i=>[n,i]):[[n,r]])},[]))}function nhe(t,e){let n=K3(t);return e&&e.forEach((r,i)=>{n.has(i)||e.getAll(i).forEach(s=>{n.append(i,s)})}),n}var X_=null;function rhe(){if(X_===null)try{new FormData(document.createElement(\"form\"),0),X_=!1}catch{X_=!0}return X_}var ahe=new Set([\"application/x-www-form-urlencoded\",\"multipart/form-data\",\"text/plain\"]);function PM(t){return t!=null&&!ahe.has(t)?(lc(!1,`\"${t}\" is not a valid \\`encType\\` for \\`<Form>\\`/\\`<fetcher.Form>\\` and will default to \"${Ik}\"`),null):t}function ihe(t,e){let n,r,i,s,o;if(Zme(t)){let l=t.getAttribute(\"action\");r=l?fm(l,e):null,n=t.getAttribute(\"method\")||Ok,i=PM(t.getAttribute(\"enctype\"))||Ik,s=new FormData(t)}else if(Qme(t)||Jme(t)&&(t.type===\"submit\"||t.type===\"image\")){let l=t.form;if(l==null)throw new Error('Cannot submit a <button> or <input type=\"submit\"> without a <form>');let c=t.getAttribute(\"formaction\")||l.getAttribute(\"action\");if(r=c?fm(c,e):null,n=t.getAttribute(\"formmethod\")||l.getAttribute(\"method\")||Ok,i=PM(t.getAttribute(\"formenctype\"))||PM(l.getAttribute(\"enctype\"))||Ik,s=new FormData(l,t),!rhe()){let{name:d,type:u,value:m}=t;if(u===\"image\"){let p=d?`${d}.`:\"\";s.append(`${p}x`,\"0\"),s.append(`${p}y`,\"0\")}else d&&s.append(d,m)}}else{if(UP(t))throw new Error('Cannot submit element that is not <form>, <button>, or <input type=\"submit|image\">');n=Ok,r=null,i=Ik,o=t}return s&&i===\"text/plain\"&&(o=s,s=void 0),{action:r,method:n.toLowerCase(),encType:i,formData:s,body:o}}Object.getOwnPropertyNames(Object.prototype).sort().join(\"\\0\");function yO(t,e){if(t===!1||t===null||typeof t>\"u\")throw new Error(e)}function she(t,e,n,r){let i=typeof t==\"string\"?new URL(t,typeof window>\"u\"?\"server://singlefetch/\":window.location.origin):t;return n?i.pathname.endsWith(\"/\")?i.pathname=`${i.pathname}_.${r}`:i.pathname=`${i.pathname}.${r}`:i.pathname===\"/\"?i.pathname=`_root.${r}`:e&&fm(i.pathname,e)===\"/\"?i.pathname=`${e.replace(/\\/$/,\"\")}/_root.${r}`:i.pathname=`${i.pathname.replace(/\\/$/,\"\")}.${r}`,i}async function ohe(t,e){if(t.id in e)return e[t.id];try{let n=await import(t.module);return e[t.id]=n,n}catch(n){return console.error(`Error loading route module \\`${t.module}\\`, reloading page...`),console.error(n),window.__reactRouterContext&&window.__reactRouterContext.isSpaMode,window.location.reload(),new Promise(()=>{})}}function lhe(t){return t==null?!1:t.href==null?t.rel===\"preload\"&&typeof t.imageSrcSet==\"string\"&&typeof t.imageSizes==\"string\":typeof t.rel==\"string\"&&typeof t.href==\"string\"}async function che(t,e,n){let r=await Promise.all(t.map(async i=>{let s=e.routes[i.route.id];if(s){let o=await ohe(s,n);return o.links?o.links():[]}return[]}));return hhe(r.flat(1).filter(lhe).filter(i=>i.rel===\"stylesheet\"||i.rel===\"preload\").map(i=>i.rel===\"stylesheet\"?{...i,rel:\"prefetch\",as:\"style\"}:{...i,rel:\"prefetch\"}))}function uH(t,e,n,r,i,s){let o=(c,d)=>n[d]?c.route.id!==n[d].route.id:!0,l=(c,d)=>n[d].pathname!==c.pathname||n[d].route.path?.endsWith(\"*\")&&n[d].params[\"*\"]!==c.params[\"*\"];return s===\"assets\"?e.filter((c,d)=>o(c,d)||l(c,d)):s===\"data\"?e.filter((c,d)=>{let u=r.routes[c.route.id];if(!u||!u.hasLoader)return!1;if(o(c,d)||l(c,d))return!0;if(c.route.shouldRevalidate){let m=c.route.shouldRevalidate({currentUrl:new URL(i.pathname+i.search+i.hash,window.origin),currentParams:n[0]?.params||{},nextUrl:new URL(t,window.origin),nextParams:c.params,defaultShouldRevalidate:!0});if(typeof m==\"boolean\")return m}return!0}):[]}function dhe(t,e,{includeHydrateFallback:n}={}){return uhe(t.map(r=>{let i=e.routes[r.route.id];if(!i)return[];let s=[i.module];return i.clientActionModule&&(s=s.concat(i.clientActionModule)),i.clientLoaderModule&&(s=s.concat(i.clientLoaderModule)),n&&i.hydrateFallbackModule&&(s=s.concat(i.hydrateFallbackModule)),i.imports&&(s=s.concat(i.imports)),s}).flat(1))}function uhe(t){return[...new Set(t)]}function mhe(t){let e={},n=Object.keys(t).sort();for(let r of n)e[r]=t[r];return e}function hhe(t,e){let n=new Set;return new Set(e),t.reduce((r,i)=>{let s=JSON.stringify(mhe(i));return n.has(s)||(n.add(s),r.push({key:s,link:i})),r},[])}function mQ(){let t=w.useContext(by);return yO(t,\"You must render this element inside a <DataRouterContext.Provider> element\"),t}function phe(){let t=w.useContext(zP);return yO(t,\"You must render this element inside a <DataRouterStateContext.Provider> element\"),t}var vO=w.createContext(void 0);vO.displayName=\"FrameworkContext\";function hQ(){let t=w.useContext(vO);return yO(t,\"You must render this element inside a <HydratedRouter> element\"),t}function fhe(t,e){let n=w.useContext(vO),[r,i]=w.useState(!1),[s,o]=w.useState(!1),{onFocus:l,onBlur:c,onMouseEnter:d,onMouseLeave:u,onTouchStart:m}=e,p=w.useRef(null);w.useEffect(()=>{if(t===\"render\"&&o(!0),t===\"viewport\"){let v=g=>{g.forEach(_=>{o(_.isIntersecting)})},b=new IntersectionObserver(v,{threshold:.5});return p.current&&b.observe(p.current),()=>{b.disconnect()}}},[t]),w.useEffect(()=>{if(r){let v=setTimeout(()=>{o(!0)},100);return()=>{clearTimeout(v)}}},[r]);let f=()=>{i(!0)},y=()=>{i(!1),o(!1)};return n?t!==\"intent\"?[s,p,{}]:[s,p,{onFocus:Mv(l,f),onBlur:Mv(c,y),onMouseEnter:Mv(d,f),onMouseLeave:Mv(u,y),onTouchStart:Mv(m,f)}]:[!1,p,{}]}function Mv(t,e){return n=>{t&&t(n),n.defaultPrevented||e(n)}}function ghe({page:t,...e}){let{router:n}=mQ(),r=w.useMemo(()=>QY(n.routes,t,n.basename),[n.routes,t,n.basename]);return r?w.createElement(xhe,{page:t,matches:r,...e}):null}function bhe(t){let{manifest:e,routeModules:n}=hQ(),[r,i]=w.useState([]);return w.useEffect(()=>{let s=!1;return che(t,e,n).then(o=>{s||i(o)}),()=>{s=!0}},[t,e,n]),r}function xhe({page:t,matches:e,...n}){let r=td(),{future:i,manifest:s,routeModules:o}=hQ(),{basename:l}=mQ(),{loaderData:c,matches:d}=phe(),u=w.useMemo(()=>uH(t,e,d,s,r,\"data\"),[t,e,d,s,r]),m=w.useMemo(()=>uH(t,e,d,s,r,\"assets\"),[t,e,d,s,r]),p=w.useMemo(()=>{if(t===r.pathname+r.search+r.hash)return[];let v=new Set,b=!1;if(e.forEach(_=>{let C=s.routes[_.route.id];!C||!C.hasLoader||(!u.some(P=>P.route.id===_.route.id)&&_.route.id in c&&o[_.route.id]?.shouldRevalidate||C.hasClientLoader?b=!0:v.add(_.route.id))}),v.size===0)return[];let g=she(t,l,i.unstable_trailingSlashAwareDataRequests,\"data\");return b&&v.size>0&&g.searchParams.set(\"_routes\",e.filter(_=>v.has(_.route.id)).map(_=>_.route.id).join(\",\")),[g.pathname+g.search]},[l,i.unstable_trailingSlashAwareDataRequests,c,r,s,u,e,t,o]),f=w.useMemo(()=>dhe(m,s),[m,s]),y=bhe(m);return w.createElement(w.Fragment,null,p.map(v=>w.createElement(\"link\",{key:v,rel:\"prefetch\",as:\"fetch\",href:v,...n})),f.map(v=>w.createElement(\"link\",{key:v,rel:\"modulepreload\",href:v,...n})),y.map(({key:v,link:b})=>w.createElement(\"link\",{key:v,nonce:n.nonce,...b,crossOrigin:b.crossOrigin??n.crossOrigin})))}function yhe(...t){return e=>{t.forEach(n=>{typeof n==\"function\"?n(e):n!=null&&(n.current=e)})}}var vhe=typeof window<\"u\"&&typeof window.document<\"u\"&&typeof window.document.createElement<\"u\";try{vhe&&(window.__reactRouterVersion=\"7.13.0\")}catch{}function whe({basename:t,children:e,unstable_useTransitions:n,window:r}){let i=w.useRef();i.current==null&&(i.current=Jue({window:r,v5Compat:!0}));let s=i.current,[o,l]=w.useState({action:s.action,location:s.location}),c=w.useCallback(d=>{n===!1?l(d):w.startTransition(()=>l(d))},[n]);return w.useLayoutEffect(()=>s.listen(c),[s,c]),w.createElement(Xme,{basename:t,children:e,location:o.location,navigationType:o.action,navigator:s,unstable_useTransitions:n})}var pQ=/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i,Do=w.forwardRef(function({onClick:e,discover:n=\"render\",prefetch:r=\"none\",relative:i,reloadDocument:s,replace:o,state:l,target:c,to:d,preventScrollReset:u,viewTransition:m,unstable_defaultShouldRevalidate:p,...f},y){let{basename:v,unstable_useTransitions:b}=w.useContext(Sl),g=typeof d==\"string\"&&pQ.test(d),_=tQ(d,v);d=_.to;let C=Dme(d,{relative:i}),[P,N,A]=fhe(r,f),T=khe(d,{replace:o,state:l,target:c,preventScrollReset:u,relative:i,viewTransition:m,unstable_defaultShouldRevalidate:p,unstable_useTransitions:b});function F(D){e&&e(D),D.defaultPrevented||T(D)}let k=w.createElement(\"a\",{...f,...A,href:_.absoluteURL||C,onClick:_.isExternal||s?e:F,ref:yhe(y,N),target:c,\"data-discover\":!g&&n===\"render\"?\"true\":void 0});return P&&!g?w.createElement(w.Fragment,null,k,w.createElement(ghe,{page:C})):k});Do.displayName=\"Link\";var xx=w.forwardRef(function({\"aria-current\":e=\"page\",caseSensitive:n=!1,className:r=\"\",end:i=!1,style:s,to:o,viewTransition:l,children:c,...d},u){let m=Qw(o,{relative:d.relative}),p=td(),f=w.useContext(zP),{navigator:y,basename:v}=w.useContext(Sl),b=f!=null&&Ahe(m)&&l===!0,g=y.encodeLocation?y.encodeLocation(m).pathname:m.pathname,_=p.pathname,C=f&&f.navigation&&f.navigation.location?f.navigation.location.pathname:null;n||(_=_.toLowerCase(),C=C?C.toLowerCase():null,g=g.toLowerCase()),C&&v&&(C=fm(C,v)||C);const P=g!==\"/\"&&g.endsWith(\"/\")?g.length-1:g.length;let N=_===g||!i&&_.startsWith(g)&&_.charAt(P)===\"/\",A=C!=null&&(C===g||!i&&C.startsWith(g)&&C.charAt(g.length)===\"/\"),T={isActive:N,isPending:A,isTransitioning:b},F=N?e:void 0,k;typeof r==\"function\"?k=r(T):k=[r,N?\"active\":null,A?\"pending\":null,b?\"transitioning\":null].filter(Boolean).join(\" \");let D=typeof s==\"function\"?s(T):s;return w.createElement(Do,{...d,\"aria-current\":F,className:k,ref:u,style:D,to:o,viewTransition:l},typeof c==\"function\"?c(T):c)});xx.displayName=\"NavLink\";var She=w.forwardRef(({discover:t=\"render\",fetcherKey:e,navigate:n,reloadDocument:r,replace:i,state:s,method:o=Ok,action:l,onSubmit:c,relative:d,preventScrollReset:u,viewTransition:m,unstable_defaultShouldRevalidate:p,...f},y)=>{let{unstable_useTransitions:v}=w.useContext(Sl),b=Phe(),g=The(l,{relative:d}),_=o.toLowerCase()===\"get\"?\"get\":\"post\",C=typeof l==\"string\"&&pQ.test(l),P=N=>{if(c&&c(N),N.defaultPrevented)return;N.preventDefault();let A=N.nativeEvent.submitter,T=A?.getAttribute(\"formmethod\")||o,F=()=>b(A||N.currentTarget,{fetcherKey:e,method:T,navigate:n,replace:i,state:s,relative:d,preventScrollReset:u,viewTransition:m,unstable_defaultShouldRevalidate:p});v&&n!==!1?w.startTransition(()=>F()):F()};return w.createElement(\"form\",{ref:y,method:_,action:g,onSubmit:r?c:P,...f,\"data-discover\":!C&&t===\"render\"?\"true\":void 0})});She.displayName=\"Form\";function _he(t){return`${t} must be used within a data router.  See https://reactrouter.com/en/main/routers/picking-a-router.`}function fQ(t){let e=w.useContext(by);return ja(e,_he(t)),e}function khe(t,{target:e,replace:n,state:r,preventScrollReset:i,relative:s,viewTransition:o,unstable_defaultShouldRevalidate:l,unstable_useTransitions:c}={}){let d=qo(),u=td(),m=Qw(t,{relative:s});return w.useCallback(p=>{if(the(p,e)){p.preventDefault();let f=n!==void 0?n:tw(u)===tw(m),y=()=>d(t,{replace:f,state:r,preventScrollReset:i,relative:s,viewTransition:o,unstable_defaultShouldRevalidate:l});c?w.startTransition(()=>y()):y()}},[u,d,m,n,r,e,t,i,s,o,l,c])}function BP(t){lc(typeof URLSearchParams<\"u\",\"You cannot use the `useSearchParams` hook in a browser that does not support the URLSearchParams API. If you need to support Internet Explorer 11, we recommend you load a polyfill such as https://github.com/ungap/url-search-params.\");let e=w.useRef(K3(t)),n=w.useRef(!1),r=td(),i=w.useMemo(()=>nhe(r.search,n.current?null:e.current),[r.search]),s=qo(),o=w.useCallback((l,c)=>{const d=K3(typeof l==\"function\"?l(new URLSearchParams(i)):l);n.current=!0,s(\"?\"+d,c)},[s,i]);return[i,o]}var Nhe=0,Che=()=>`__${String(++Nhe)}__`;function Phe(){let{router:t}=fQ(\"useSubmit\"),{basename:e}=w.useContext(Sl),n=Vme(),r=t.fetch,i=t.navigate;return w.useCallback(async(s,o={})=>{let{action:l,method:c,encType:d,formData:u,body:m}=ihe(s,e);if(o.navigate===!1){let p=o.fetcherKey||Che();await r(p,n,o.action||l,{unstable_defaultShouldRevalidate:o.unstable_defaultShouldRevalidate,preventScrollReset:o.preventScrollReset,formData:u,body:m,formMethod:o.method||c,formEncType:o.encType||d,flushSync:o.flushSync})}else await i(o.action||l,{unstable_defaultShouldRevalidate:o.unstable_defaultShouldRevalidate,preventScrollReset:o.preventScrollReset,formData:u,body:m,formMethod:o.method||c,formEncType:o.encType||d,replace:o.replace,state:o.state,fromRouteId:n,flushSync:o.flushSync,viewTransition:o.viewTransition})},[r,i,e,n])}function The(t,{relative:e}={}){let{basename:n}=w.useContext(Sl),r=w.useContext(xc);ja(r,\"useFormAction must be used inside a RouteContext\");let[i]=r.matches.slice(-1),s={...Qw(t||\".\",{relative:e})},o=td();if(t==null){s.search=o.search;let l=new URLSearchParams(s.search),c=l.getAll(\"index\");if(c.some(u=>u===\"\")){l.delete(\"index\"),c.filter(m=>m).forEach(m=>l.append(\"index\",m));let u=l.toString();s.search=u?`?${u}`:\"\"}}return(!t||t===\".\")&&i.route.index&&(s.search=s.search?s.search.replace(/^\\?/,\"?index&\"):\"?index\"),n!==\"/\"&&(s.pathname=s.pathname===\"/\"?n:im([n,s.pathname])),tw(s)}function Ahe(t,{relative:e}={}){let n=w.useContext(rQ);ja(n!=null,\"`useViewTransitionState` must be used within `react-router-dom`'s `RouterProvider`.  Did you accidentally import `RouterProvider` from `react-router`?\");let{basename:r}=fQ(\"useViewTransitionState\"),i=Qw(t,{relative:e});if(!n.isTransitioning)return!1;let s=fm(n.currentLocation.pathname,r)||n.currentLocation.pathname,o=fm(n.nextLocation.pathname,r)||n.nextLocation.pathname;return fN(i.pathname,o)!=null||fN(i.pathname,s)!=null}var Yu=UY();const jhe=bc(Yu);var Lg=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(t){return this.listeners.add(t),this.onSubscribe(),()=>{this.listeners.delete(t),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},Mhe={setTimeout:(t,e)=>setTimeout(t,e),clearTimeout:t=>clearTimeout(t),setInterval:(t,e)=>setInterval(t,e),clearInterval:t=>clearInterval(t)},Ehe=class{#t=Mhe;#e=!1;setTimeoutProvider(t){this.#t=t}setTimeout(t,e){return this.#t.setTimeout(t,e)}clearTimeout(t){this.#t.clearTimeout(t)}setInterval(t,e){return this.#t.setInterval(t,e)}clearInterval(t){this.#t.clearInterval(t)}},Ff=new Ehe;function Dhe(t){setTimeout(t,0)}var og=typeof window>\"u\"||\"Deno\"in globalThis;function Is(){}function Fhe(t,e){return typeof t==\"function\"?t(e):t}function X3(t){return typeof t==\"number\"&&t>=0&&t!==1/0}function gQ(t,e){return Math.max(t+(e||0)-Date.now(),0)}function Qh(t,e){return typeof t==\"function\"?t(e):t}function Vl(t,e){return typeof t==\"function\"?t(e):t}function mH(t,e){const{type:n=\"all\",exact:r,fetchStatus:i,predicate:s,queryKey:o,stale:l}=t;if(o){if(r){if(e.queryHash!==wO(o,e.options))return!1}else if(!nw(e.queryKey,o))return!1}if(n!==\"all\"){const c=e.isActive();if(n===\"active\"&&!c||n===\"inactive\"&&c)return!1}return!(typeof l==\"boolean\"&&e.isStale()!==l||i&&i!==e.state.fetchStatus||s&&!s(e))}function hH(t,e){const{exact:n,status:r,predicate:i,mutationKey:s}=t;if(s){if(!e.options.mutationKey)return!1;if(n){if(lg(e.options.mutationKey)!==lg(s))return!1}else if(!nw(e.options.mutationKey,s))return!1}return!(r&&e.state.status!==r||i&&!i(e))}function wO(t,e){return(e?.queryKeyHashFn||lg)(t)}function lg(t){return JSON.stringify(t,(e,n)=>Y3(n)?Object.keys(n).sort().reduce((r,i)=>(r[i]=n[i],r),{}):n)}function nw(t,e){return t===e?!0:typeof t!=typeof e?!1:t&&e&&typeof t==\"object\"&&typeof e==\"object\"?Object.keys(e).every(n=>nw(t[n],e[n])):!1}var Rhe=Object.prototype.hasOwnProperty;function SO(t,e,n=0){if(t===e)return t;if(n>500)return e;const r=pH(t)&&pH(e);if(!r&&!(Y3(t)&&Y3(e)))return e;const s=(r?t:Object.keys(t)).length,o=r?e:Object.keys(e),l=o.length,c=r?new Array(l):{};let d=0;for(let u=0;u<l;u++){const m=r?u:o[u],p=t[m],f=e[m];if(p===f){c[m]=p,(r?u<s:Rhe.call(t,m))&&d++;continue}if(p===null||f===null||typeof p!=\"object\"||typeof f!=\"object\"){c[m]=f;continue}const y=SO(p,f,n+1);c[m]=y,y===p&&d++}return s===l&&d===s?t:c}function rw(t,e){if(!e||Object.keys(t).length!==Object.keys(e).length)return!1;for(const n in t)if(t[n]!==e[n])return!1;return!0}function pH(t){return Array.isArray(t)&&t.length===Object.keys(t).length}function Y3(t){if(!fH(t))return!1;const e=t.constructor;if(e===void 0)return!0;const n=e.prototype;return!(!fH(n)||!n.hasOwnProperty(\"isPrototypeOf\")||Object.getPrototypeOf(t)!==Object.prototype)}function fH(t){return Object.prototype.toString.call(t)===\"[object Object]\"}function Lhe(t){return new Promise(e=>{Ff.setTimeout(e,t)})}function Q3(t,e,n){return typeof n.structuralSharing==\"function\"?n.structuralSharing(t,e):n.structuralSharing!==!1?SO(t,e):e}function Ohe(t,e,n=0){const r=[...t,e];return n&&r.length>n?r.slice(1):r}function Ihe(t,e,n=0){const r=[e,...t];return n&&r.length>n?r.slice(0,-1):r}var _O=Symbol();function bQ(t,e){return!t.queryFn&&e?.initialPromise?()=>e.initialPromise:!t.queryFn||t.queryFn===_O?()=>Promise.reject(new Error(`Missing queryFn: '${t.queryHash}'`)):t.queryFn}function kO(t,e){return typeof t==\"function\"?t(...e):!!t}function zhe(t,e,n){let r=!1,i;return Object.defineProperty(t,\"signal\",{enumerable:!0,get:()=>(i??=e(),r||(r=!0,i.aborted?n():i.addEventListener(\"abort\",n,{once:!0})),i)}),t}var Uhe=class extends Lg{#t;#e;#n;constructor(){super(),this.#n=t=>{if(!og&&window.addEventListener){const e=()=>t();return window.addEventListener(\"visibilitychange\",e,!1),()=>{window.removeEventListener(\"visibilitychange\",e)}}}}onSubscribe(){this.#e||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#e?.(),this.#e=void 0)}setEventListener(t){this.#n=t,this.#e?.(),this.#e=t(e=>{typeof e==\"boolean\"?this.setFocused(e):this.onFocus()})}setFocused(t){this.#t!==t&&(this.#t=t,this.onFocus())}onFocus(){const t=this.isFocused();this.listeners.forEach(e=>{e(t)})}isFocused(){return typeof this.#t==\"boolean\"?this.#t:globalThis.document?.visibilityState!==\"hidden\"}},NO=new Uhe;function Z3(){let t,e;const n=new Promise((i,s)=>{t=i,e=s});n.status=\"pending\",n.catch(()=>{});function r(i){Object.assign(n,i),delete n.resolve,delete n.reject}return n.resolve=i=>{r({status:\"fulfilled\",value:i}),t(i)},n.reject=i=>{r({status:\"rejected\",reason:i}),e(i)},n}var Bhe=Dhe;function Hhe(){let t=[],e=0,n=l=>{l()},r=l=>{l()},i=Bhe;const s=l=>{e?t.push(l):i(()=>{n(l)})},o=()=>{const l=t;t=[],l.length&&i(()=>{r(()=>{l.forEach(c=>{n(c)})})})};return{batch:l=>{let c;e++;try{c=l()}finally{e--,e||o()}return c},batchCalls:l=>(...c)=>{s(()=>{l(...c)})},schedule:s,setNotifyFunction:l=>{n=l},setBatchNotifyFunction:l=>{r=l},setScheduler:l=>{i=l}}}var Qa=Hhe(),qhe=class extends Lg{#t=!0;#e;#n;constructor(){super(),this.#n=t=>{if(!og&&window.addEventListener){const e=()=>t(!0),n=()=>t(!1);return window.addEventListener(\"online\",e,!1),window.addEventListener(\"offline\",n,!1),()=>{window.removeEventListener(\"online\",e),window.removeEventListener(\"offline\",n)}}}}onSubscribe(){this.#e||this.setEventListener(this.#n)}onUnsubscribe(){this.hasListeners()||(this.#e?.(),this.#e=void 0)}setEventListener(t){this.#n=t,this.#e?.(),this.#e=t(this.setOnline.bind(this))}setOnline(t){this.#t!==t&&(this.#t=t,this.listeners.forEach(n=>{n(t)}))}isOnline(){return this.#t}},gN=new qhe;function $he(t){return Math.min(1e3*2**t,3e4)}function xQ(t){return(t??\"online\")===\"online\"?gN.isOnline():!0}var J3=class extends Error{constructor(t){super(\"CancelledError\"),this.revert=t?.revert,this.silent=t?.silent}};function yQ(t){let e=!1,n=0,r;const i=Z3(),s=()=>i.status!==\"pending\",o=v=>{if(!s()){const b=new J3(v);p(b),t.onCancel?.(b)}},l=()=>{e=!0},c=()=>{e=!1},d=()=>NO.isFocused()&&(t.networkMode===\"always\"||gN.isOnline())&&t.canRun(),u=()=>xQ(t.networkMode)&&t.canRun(),m=v=>{s()||(r?.(),i.resolve(v))},p=v=>{s()||(r?.(),i.reject(v))},f=()=>new Promise(v=>{r=b=>{(s()||d())&&v(b)},t.onPause?.()}).then(()=>{r=void 0,s()||t.onContinue?.()}),y=()=>{if(s())return;let v;const b=n===0?t.initialPromise:void 0;try{v=b??t.fn()}catch(g){v=Promise.reject(g)}Promise.resolve(v).then(m).catch(g=>{if(s())return;const _=t.retry??(og?0:3),C=t.retryDelay??$he,P=typeof C==\"function\"?C(n,g):C,N=_===!0||typeof _==\"number\"&&n<_||typeof _==\"function\"&&_(n,g);if(e||!N){p(g);return}n++,t.onFail?.(n,g),Lhe(P).then(()=>d()?void 0:f()).then(()=>{e?p(g):y()})})};return{promise:i,status:()=>i.status,cancel:o,continue:()=>(r?.(),i),cancelRetry:l,continueRetry:c,canStart:u,start:()=>(u()?y():f().then(y),i)}}var vQ=class{#t;destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),X3(this.gcTime)&&(this.#t=Ff.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(t){this.gcTime=Math.max(this.gcTime||0,t??(og?1/0:300*1e3))}clearGcTimeout(){this.#t&&(Ff.clearTimeout(this.#t),this.#t=void 0)}},Vhe=class extends vQ{#t;#e;#n;#a;#r;#i;#o;constructor(t){super(),this.#o=!1,this.#i=t.defaultOptions,this.setOptions(t.options),this.observers=[],this.#a=t.client,this.#n=this.#a.getQueryCache(),this.queryKey=t.queryKey,this.queryHash=t.queryHash,this.#t=bH(this.options),this.state=t.state??this.#t,this.scheduleGc()}get meta(){return this.options.meta}get promise(){return this.#r?.promise}setOptions(t){if(this.options={...this.#i,...t},this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){const e=bH(this.options);e.data!==void 0&&(this.setState(gH(e.data,e.dataUpdatedAt)),this.#t=e)}}optionalRemove(){!this.observers.length&&this.state.fetchStatus===\"idle\"&&this.#n.remove(this)}setData(t,e){const n=Q3(this.state.data,t,this.options);return this.#s({data:n,type:\"success\",dataUpdatedAt:e?.updatedAt,manual:e?.manual}),n}setState(t,e){this.#s({type:\"setState\",state:t,setStateOptions:e})}cancel(t){const e=this.#r?.promise;return this.#r?.cancel(t),e?e.then(Is).catch(Is):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}reset(){this.destroy(),this.setState(this.#t)}isActive(){return this.observers.some(t=>Vl(t.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===_O||this.state.dataUpdateCount+this.state.errorUpdateCount===0}isStatic(){return this.getObserversCount()>0?this.observers.some(t=>Qh(t.options.staleTime,this)===\"static\"):!1}isStale(){return this.getObserversCount()>0?this.observers.some(t=>t.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(t=0){return this.state.data===void 0?!0:t===\"static\"?!1:this.state.isInvalidated?!0:!gQ(this.state.dataUpdatedAt,t)}onFocus(){this.observers.find(e=>e.shouldFetchOnWindowFocus())?.refetch({cancelRefetch:!1}),this.#r?.continue()}onOnline(){this.observers.find(e=>e.shouldFetchOnReconnect())?.refetch({cancelRefetch:!1}),this.#r?.continue()}addObserver(t){this.observers.includes(t)||(this.observers.push(t),this.clearGcTimeout(),this.#n.notify({type:\"observerAdded\",query:this,observer:t}))}removeObserver(t){this.observers.includes(t)&&(this.observers=this.observers.filter(e=>e!==t),this.observers.length||(this.#r&&(this.#o?this.#r.cancel({revert:!0}):this.#r.cancelRetry()),this.scheduleGc()),this.#n.notify({type:\"observerRemoved\",query:this,observer:t}))}getObserversCount(){return this.observers.length}invalidate(){this.state.isInvalidated||this.#s({type:\"invalidate\"})}async fetch(t,e){if(this.state.fetchStatus!==\"idle\"&&this.#r?.status()!==\"rejected\"){if(this.state.data!==void 0&&e?.cancelRefetch)this.cancel({silent:!0});else if(this.#r)return this.#r.continueRetry(),this.#r.promise}if(t&&this.setOptions(t),!this.options.queryFn){const l=this.observers.find(c=>c.options.queryFn);l&&this.setOptions(l.options)}const n=new AbortController,r=l=>{Object.defineProperty(l,\"signal\",{enumerable:!0,get:()=>(this.#o=!0,n.signal)})},i=()=>{const l=bQ(this.options,e),d=(()=>{const u={client:this.#a,queryKey:this.queryKey,meta:this.meta};return r(u),u})();return this.#o=!1,this.options.persister?this.options.persister(l,d,this):l(d)},o=(()=>{const l={fetchOptions:e,options:this.options,queryKey:this.queryKey,client:this.#a,state:this.state,fetchFn:i};return r(l),l})();this.options.behavior?.onFetch(o,this),this.#e=this.state,(this.state.fetchStatus===\"idle\"||this.state.fetchMeta!==o.fetchOptions?.meta)&&this.#s({type:\"fetch\",meta:o.fetchOptions?.meta}),this.#r=yQ({initialPromise:e?.initialPromise,fn:o.fetchFn,onCancel:l=>{l instanceof J3&&l.revert&&this.setState({...this.#e,fetchStatus:\"idle\"}),n.abort()},onFail:(l,c)=>{this.#s({type:\"failed\",failureCount:l,error:c})},onPause:()=>{this.#s({type:\"pause\"})},onContinue:()=>{this.#s({type:\"continue\"})},retry:o.options.retry,retryDelay:o.options.retryDelay,networkMode:o.options.networkMode,canRun:()=>!0});try{const l=await this.#r.start();if(l===void 0)throw new Error(`${this.queryHash} data is undefined`);return this.setData(l),this.#n.config.onSuccess?.(l,this),this.#n.config.onSettled?.(l,this.state.error,this),l}catch(l){if(l instanceof J3){if(l.silent)return this.#r.promise;if(l.revert){if(this.state.data===void 0)throw l;return this.state.data}}throw this.#s({type:\"error\",error:l}),this.#n.config.onError?.(l,this),this.#n.config.onSettled?.(this.state.data,l,this),l}finally{this.scheduleGc()}}#s(t){const e=n=>{switch(t.type){case\"failed\":return{...n,fetchFailureCount:t.failureCount,fetchFailureReason:t.error};case\"pause\":return{...n,fetchStatus:\"paused\"};case\"continue\":return{...n,fetchStatus:\"fetching\"};case\"fetch\":return{...n,...wQ(n.data,this.options),fetchMeta:t.meta??null};case\"success\":const r={...n,...gH(t.data,t.dataUpdatedAt),dataUpdateCount:n.dataUpdateCount+1,...!t.manual&&{fetchStatus:\"idle\",fetchFailureCount:0,fetchFailureReason:null}};return this.#e=t.manual?r:void 0,r;case\"error\":const i=t.error;return{...n,error:i,errorUpdateCount:n.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:n.fetchFailureCount+1,fetchFailureReason:i,fetchStatus:\"idle\",status:\"error\",isInvalidated:!0};case\"invalidate\":return{...n,isInvalidated:!0};case\"setState\":return{...n,...t.state}}};this.state=e(this.state),Qa.batch(()=>{this.observers.forEach(n=>{n.onQueryUpdate()}),this.#n.notify({query:this,type:\"updated\",action:t})})}};function wQ(t,e){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:xQ(e.networkMode)?\"fetching\":\"paused\",...t===void 0&&{error:null,status:\"pending\"}}}function gH(t,e){return{data:t,dataUpdatedAt:e??Date.now(),error:null,isInvalidated:!1,status:\"success\"}}function bH(t){const e=typeof t.initialData==\"function\"?t.initialData():t.initialData,n=e!==void 0,r=n?typeof t.initialDataUpdatedAt==\"function\"?t.initialDataUpdatedAt():t.initialDataUpdatedAt:0;return{data:e,dataUpdateCount:0,dataUpdatedAt:n?r??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:n?\"success\":\"pending\",fetchStatus:\"idle\"}}var CO=class extends Lg{constructor(t,e){super(),this.options=e,this.#t=t,this.#s=null,this.#o=Z3(),this.bindMethods(),this.setOptions(e)}#t;#e=void 0;#n=void 0;#a=void 0;#r;#i;#o;#s;#p;#u;#m;#c;#d;#l;#h=new Set;bindMethods(){this.refetch=this.refetch.bind(this)}onSubscribe(){this.listeners.size===1&&(this.#e.addObserver(this),xH(this.#e,this.options)?this.#f():this.updateResult(),this.#y())}onUnsubscribe(){this.hasListeners()||this.destroy()}shouldFetchOnReconnect(){return eF(this.#e,this.options,this.options.refetchOnReconnect)}shouldFetchOnWindowFocus(){return eF(this.#e,this.options,this.options.refetchOnWindowFocus)}destroy(){this.listeners=new Set,this.#v(),this.#w(),this.#e.removeObserver(this)}setOptions(t){const e=this.options,n=this.#e;if(this.options=this.#t.defaultQueryOptions(t),this.options.enabled!==void 0&&typeof this.options.enabled!=\"boolean\"&&typeof this.options.enabled!=\"function\"&&typeof Vl(this.options.enabled,this.#e)!=\"boolean\")throw new Error(\"Expected enabled to be a boolean or a callback that returns a boolean\");this.#S(),this.#e.setOptions(this.options),e._defaulted&&!rw(this.options,e)&&this.#t.getQueryCache().notify({type:\"observerOptionsUpdated\",query:this.#e,observer:this});const r=this.hasListeners();r&&yH(this.#e,n,this.options,e)&&this.#f(),this.updateResult(),r&&(this.#e!==n||Vl(this.options.enabled,this.#e)!==Vl(e.enabled,this.#e)||Qh(this.options.staleTime,this.#e)!==Qh(e.staleTime,this.#e))&&this.#g();const i=this.#b();r&&(this.#e!==n||Vl(this.options.enabled,this.#e)!==Vl(e.enabled,this.#e)||i!==this.#l)&&this.#x(i)}getOptimisticResult(t){const e=this.#t.getQueryCache().build(this.#t,t),n=this.createResult(e,t);return Whe(this,n)&&(this.#a=n,this.#i=this.options,this.#r=this.#e.state),n}getCurrentResult(){return this.#a}trackResult(t,e){return new Proxy(t,{get:(n,r)=>(this.trackProp(r),e?.(r),r===\"promise\"&&(this.trackProp(\"data\"),!this.options.experimental_prefetchInRender&&this.#o.status===\"pending\"&&this.#o.reject(new Error(\"experimental_prefetchInRender feature flag is not enabled\"))),Reflect.get(n,r))})}trackProp(t){this.#h.add(t)}getCurrentQuery(){return this.#e}refetch({...t}={}){return this.fetch({...t})}fetchOptimistic(t){const e=this.#t.defaultQueryOptions(t),n=this.#t.getQueryCache().build(this.#t,e);return n.fetch().then(()=>this.createResult(n,e))}fetch(t){return this.#f({...t,cancelRefetch:t.cancelRefetch??!0}).then(()=>(this.updateResult(),this.#a))}#f(t){this.#S();let e=this.#e.fetch(this.options,t);return t?.throwOnError||(e=e.catch(Is)),e}#g(){this.#v();const t=Qh(this.options.staleTime,this.#e);if(og||this.#a.isStale||!X3(t))return;const n=gQ(this.#a.dataUpdatedAt,t)+1;this.#c=Ff.setTimeout(()=>{this.#a.isStale||this.updateResult()},n)}#b(){return(typeof this.options.refetchInterval==\"function\"?this.options.refetchInterval(this.#e):this.options.refetchInterval)??!1}#x(t){this.#w(),this.#l=t,!(og||Vl(this.options.enabled,this.#e)===!1||!X3(this.#l)||this.#l===0)&&(this.#d=Ff.setInterval(()=>{(this.options.refetchIntervalInBackground||NO.isFocused())&&this.#f()},this.#l))}#y(){this.#g(),this.#x(this.#b())}#v(){this.#c&&(Ff.clearTimeout(this.#c),this.#c=void 0)}#w(){this.#d&&(Ff.clearInterval(this.#d),this.#d=void 0)}createResult(t,e){const n=this.#e,r=this.options,i=this.#a,s=this.#r,o=this.#i,c=t!==n?t.state:this.#n,{state:d}=t;let u={...d},m=!1,p;if(e._optimisticResults){const F=this.hasListeners(),k=!F&&xH(t,e),D=F&&yH(t,n,e,r);(k||D)&&(u={...u,...wQ(d.data,t.options)}),e._optimisticResults===\"isRestoring\"&&(u.fetchStatus=\"idle\")}let{error:f,errorUpdatedAt:y,status:v}=u;p=u.data;let b=!1;if(e.placeholderData!==void 0&&p===void 0&&v===\"pending\"){let F;i?.isPlaceholderData&&e.placeholderData===o?.placeholderData?(F=i.data,b=!0):F=typeof e.placeholderData==\"function\"?e.placeholderData(this.#m?.state.data,this.#m):e.placeholderData,F!==void 0&&(v=\"success\",p=Q3(i?.data,F,e),m=!0)}if(e.select&&p!==void 0&&!b)if(i&&p===s?.data&&e.select===this.#p)p=this.#u;else try{this.#p=e.select,p=e.select(p),p=Q3(i?.data,p,e),this.#u=p,this.#s=null}catch(F){this.#s=F}this.#s&&(f=this.#s,p=this.#u,y=Date.now(),v=\"error\");const g=u.fetchStatus===\"fetching\",_=v===\"pending\",C=v===\"error\",P=_&&g,N=p!==void 0,T={status:v,fetchStatus:u.fetchStatus,isPending:_,isSuccess:v===\"success\",isError:C,isInitialLoading:P,isLoading:P,data:p,dataUpdatedAt:u.dataUpdatedAt,error:f,errorUpdatedAt:y,failureCount:u.fetchFailureCount,failureReason:u.fetchFailureReason,errorUpdateCount:u.errorUpdateCount,isFetched:u.dataUpdateCount>0||u.errorUpdateCount>0,isFetchedAfterMount:u.dataUpdateCount>c.dataUpdateCount||u.errorUpdateCount>c.errorUpdateCount,isFetching:g,isRefetching:g&&!_,isLoadingError:C&&!N,isPaused:u.fetchStatus===\"paused\",isPlaceholderData:m,isRefetchError:C&&N,isStale:PO(t,e),refetch:this.refetch,promise:this.#o,isEnabled:Vl(e.enabled,t)!==!1};if(this.options.experimental_prefetchInRender){const F=T.data!==void 0,k=T.status===\"error\"&&!F,D=Q=>{k?Q.reject(T.error):F&&Q.resolve(T.data)},H=()=>{const Q=this.#o=T.promise=Z3();D(Q)},z=this.#o;switch(z.status){case\"pending\":t.queryHash===n.queryHash&&D(z);break;case\"fulfilled\":(k||T.data!==z.value)&&H();break;case\"rejected\":(!k||T.error!==z.reason)&&H();break}}return T}updateResult(){const t=this.#a,e=this.createResult(this.#e,this.options);if(this.#r=this.#e.state,this.#i=this.options,this.#r.data!==void 0&&(this.#m=this.#e),rw(e,t))return;this.#a=e;const n=()=>{if(!t)return!0;const{notifyOnChangeProps:r}=this.options,i=typeof r==\"function\"?r():r;if(i===\"all\"||!i&&!this.#h.size)return!0;const s=new Set(i??this.#h);return this.options.throwOnError&&s.add(\"error\"),Object.keys(this.#a).some(o=>{const l=o;return this.#a[l]!==t[l]&&s.has(l)})};this.#_({listeners:n()})}#S(){const t=this.#t.getQueryCache().build(this.#t,this.options);if(t===this.#e)return;const e=this.#e;this.#e=t,this.#n=t.state,this.hasListeners()&&(e?.removeObserver(this),t.addObserver(this))}onQueryUpdate(){this.updateResult(),this.hasListeners()&&this.#y()}#_(t){Qa.batch(()=>{t.listeners&&this.listeners.forEach(e=>{e(this.#a)}),this.#t.getQueryCache().notify({query:this.#e,type:\"observerResultsUpdated\"})})}};function Ghe(t,e){return Vl(e.enabled,t)!==!1&&t.state.data===void 0&&!(t.state.status===\"error\"&&e.retryOnMount===!1)}function xH(t,e){return Ghe(t,e)||t.state.data!==void 0&&eF(t,e,e.refetchOnMount)}function eF(t,e,n){if(Vl(e.enabled,t)!==!1&&Qh(e.staleTime,t)!==\"static\"){const r=typeof n==\"function\"?n(t):n;return r===\"always\"||r!==!1&&PO(t,e)}return!1}function yH(t,e,n,r){return(t!==e||Vl(r.enabled,t)===!1)&&(!n.suspense||t.state.status!==\"error\")&&PO(t,n)}function PO(t,e){return Vl(e.enabled,t)!==!1&&t.isStaleByTime(Qh(e.staleTime,t))}function Whe(t,e){return!rw(t.getCurrentResult(),e)}function vH(t){return{onFetch:(e,n)=>{const r=e.options,i=e.fetchOptions?.meta?.fetchMore?.direction,s=e.state.data?.pages||[],o=e.state.data?.pageParams||[];let l={pages:[],pageParams:[]},c=0;const d=async()=>{let u=!1;const m=y=>{zhe(y,()=>e.signal,()=>u=!0)},p=bQ(e.options,e.fetchOptions),f=async(y,v,b)=>{if(u)return Promise.reject();if(v==null&&y.pages.length)return Promise.resolve(y);const _=(()=>{const A={client:e.client,queryKey:e.queryKey,pageParam:v,direction:b?\"backward\":\"forward\",meta:e.options.meta};return m(A),A})(),C=await p(_),{maxPages:P}=e.options,N=b?Ihe:Ohe;return{pages:N(y.pages,C,P),pageParams:N(y.pageParams,v,P)}};if(i&&s.length){const y=i===\"backward\",v=y?Khe:wH,b={pages:s,pageParams:o},g=v(r,b);l=await f(b,g,y)}else{const y=t??s.length;do{const v=c===0?o[0]??r.initialPageParam:wH(r,l);if(c>0&&v==null)break;l=await f(l,v),c++}while(c<y)}return l};e.options.persister?e.fetchFn=()=>e.options.persister?.(d,{client:e.client,queryKey:e.queryKey,meta:e.options.meta,signal:e.signal},n):e.fetchFn=d}}}function wH(t,{pages:e,pageParams:n}){const r=e.length-1;return e.length>0?t.getNextPageParam(e[r],e,n[r],n):void 0}function Khe(t,{pages:e,pageParams:n}){return e.length>0?t.getPreviousPageParam?.(e[0],e,n[0],n):void 0}var Xhe=class extends vQ{#t;#e;#n;#a;constructor(t){super(),this.#t=t.client,this.mutationId=t.mutationId,this.#n=t.mutationCache,this.#e=[],this.state=t.state||SQ(),this.setOptions(t.options),this.scheduleGc()}setOptions(t){this.options=t,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(t){this.#e.includes(t)||(this.#e.push(t),this.clearGcTimeout(),this.#n.notify({type:\"observerAdded\",mutation:this,observer:t}))}removeObserver(t){this.#e=this.#e.filter(e=>e!==t),this.scheduleGc(),this.#n.notify({type:\"observerRemoved\",mutation:this,observer:t})}optionalRemove(){this.#e.length||(this.state.status===\"pending\"?this.scheduleGc():this.#n.remove(this))}continue(){return this.#a?.continue()??this.execute(this.state.variables)}async execute(t){const e=()=>{this.#r({type:\"continue\"})},n={client:this.#t,meta:this.options.meta,mutationKey:this.options.mutationKey};this.#a=yQ({fn:()=>this.options.mutationFn?this.options.mutationFn(t,n):Promise.reject(new Error(\"No mutationFn found\")),onFail:(s,o)=>{this.#r({type:\"failed\",failureCount:s,error:o})},onPause:()=>{this.#r({type:\"pause\"})},onContinue:e,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>this.#n.canRun(this)});const r=this.state.status===\"pending\",i=!this.#a.canStart();try{if(r)e();else{this.#r({type:\"pending\",variables:t,isPaused:i}),this.#n.config.onMutate&&await this.#n.config.onMutate(t,this,n);const o=await this.options.onMutate?.(t,n);o!==this.state.context&&this.#r({type:\"pending\",context:o,variables:t,isPaused:i})}const s=await this.#a.start();return await this.#n.config.onSuccess?.(s,t,this.state.context,this,n),await this.options.onSuccess?.(s,t,this.state.context,n),await this.#n.config.onSettled?.(s,null,this.state.variables,this.state.context,this,n),await this.options.onSettled?.(s,null,t,this.state.context,n),this.#r({type:\"success\",data:s}),s}catch(s){try{await this.#n.config.onError?.(s,t,this.state.context,this,n)}catch(o){Promise.reject(o)}try{await this.options.onError?.(s,t,this.state.context,n)}catch(o){Promise.reject(o)}try{await this.#n.config.onSettled?.(void 0,s,this.state.variables,this.state.context,this,n)}catch(o){Promise.reject(o)}try{await this.options.onSettled?.(void 0,s,t,this.state.context,n)}catch(o){Promise.reject(o)}throw this.#r({type:\"error\",error:s}),s}finally{this.#n.runNext(this)}}#r(t){const e=n=>{switch(t.type){case\"failed\":return{...n,failureCount:t.failureCount,failureReason:t.error};case\"pause\":return{...n,isPaused:!0};case\"continue\":return{...n,isPaused:!1};case\"pending\":return{...n,context:t.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:t.isPaused,status:\"pending\",variables:t.variables,submittedAt:Date.now()};case\"success\":return{...n,data:t.data,failureCount:0,failureReason:null,error:null,status:\"success\",isPaused:!1};case\"error\":return{...n,data:void 0,error:t.error,failureCount:n.failureCount+1,failureReason:t.error,isPaused:!1,status:\"error\"}}};this.state=e(this.state),Qa.batch(()=>{this.#e.forEach(n=>{n.onMutationUpdate(t)}),this.#n.notify({mutation:this,type:\"updated\",action:t})})}};function SQ(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:\"idle\",variables:void 0,submittedAt:0}}var Yhe=class extends Lg{constructor(t={}){super(),this.config=t,this.#t=new Set,this.#e=new Map,this.#n=0}#t;#e;#n;build(t,e,n){const r=new Xhe({client:t,mutationCache:this,mutationId:++this.#n,options:t.defaultMutationOptions(e),state:n});return this.add(r),r}add(t){this.#t.add(t);const e=Y_(t);if(typeof e==\"string\"){const n=this.#e.get(e);n?n.push(t):this.#e.set(e,[t])}this.notify({type:\"added\",mutation:t})}remove(t){if(this.#t.delete(t)){const e=Y_(t);if(typeof e==\"string\"){const n=this.#e.get(e);if(n)if(n.length>1){const r=n.indexOf(t);r!==-1&&n.splice(r,1)}else n[0]===t&&this.#e.delete(e)}}this.notify({type:\"removed\",mutation:t})}canRun(t){const e=Y_(t);if(typeof e==\"string\"){const r=this.#e.get(e)?.find(i=>i.state.status===\"pending\");return!r||r===t}else return!0}runNext(t){const e=Y_(t);return typeof e==\"string\"?this.#e.get(e)?.find(r=>r!==t&&r.state.isPaused)?.continue()??Promise.resolve():Promise.resolve()}clear(){Qa.batch(()=>{this.#t.forEach(t=>{this.notify({type:\"removed\",mutation:t})}),this.#t.clear(),this.#e.clear()})}getAll(){return Array.from(this.#t)}find(t){const e={exact:!0,...t};return this.getAll().find(n=>hH(e,n))}findAll(t={}){return this.getAll().filter(e=>hH(t,e))}notify(t){Qa.batch(()=>{this.listeners.forEach(e=>{e(t)})})}resumePausedMutations(){const t=this.getAll().filter(e=>e.state.isPaused);return Qa.batch(()=>Promise.all(t.map(e=>e.continue().catch(Is))))}};function Y_(t){return t.options.scope?.id}var Qhe=class extends Lg{#t;#e=void 0;#n;#a;constructor(e,n){super(),this.#t=e,this.setOptions(n),this.bindMethods(),this.#r()}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(e){const n=this.options;this.options=this.#t.defaultMutationOptions(e),rw(this.options,n)||this.#t.getMutationCache().notify({type:\"observerOptionsUpdated\",mutation:this.#n,observer:this}),n?.mutationKey&&this.options.mutationKey&&lg(n.mutationKey)!==lg(this.options.mutationKey)?this.reset():this.#n?.state.status===\"pending\"&&this.#n.setOptions(this.options)}onUnsubscribe(){this.hasListeners()||this.#n?.removeObserver(this)}onMutationUpdate(e){this.#r(),this.#i(e)}getCurrentResult(){return this.#e}reset(){this.#n?.removeObserver(this),this.#n=void 0,this.#r(),this.#i()}mutate(e,n){return this.#a=n,this.#n?.removeObserver(this),this.#n=this.#t.getMutationCache().build(this.#t,this.options),this.#n.addObserver(this),this.#n.execute(e)}#r(){const e=this.#n?.state??SQ();this.#e={...e,isPending:e.status===\"pending\",isSuccess:e.status===\"success\",isError:e.status===\"error\",isIdle:e.status===\"idle\",mutate:this.mutate,reset:this.reset}}#i(e){Qa.batch(()=>{if(this.#a&&this.hasListeners()){const n=this.#e.variables,r=this.#e.context,i={client:this.#t,meta:this.options.meta,mutationKey:this.options.mutationKey};if(e?.type===\"success\"){try{this.#a.onSuccess?.(e.data,n,r,i)}catch(s){Promise.reject(s)}try{this.#a.onSettled?.(e.data,null,n,r,i)}catch(s){Promise.reject(s)}}else if(e?.type===\"error\"){try{this.#a.onError?.(e.error,n,r,i)}catch(s){Promise.reject(s)}try{this.#a.onSettled?.(void 0,e.error,n,r,i)}catch(s){Promise.reject(s)}}}this.listeners.forEach(n=>{n(this.#e)})})}};function SH(t,e){const n=new Set(e);return t.filter(r=>!n.has(r))}function Zhe(t,e,n){const r=t.slice(0);return r[e]=n,r}var Jhe=class extends Lg{#t;#e;#n;#a;#r;#i;#o;#s;#p;#u=[];constructor(t,e,n){super(),this.#t=t,this.#a=n,this.#n=[],this.#r=[],this.#e=[],this.setQueries(e)}onSubscribe(){this.listeners.size===1&&this.#r.forEach(t=>{t.subscribe(e=>{this.#l(t,e)})})}onUnsubscribe(){this.listeners.size||this.destroy()}destroy(){this.listeners=new Set,this.#r.forEach(t=>{t.destroy()})}setQueries(t,e){this.#n=t,this.#a=e,Qa.batch(()=>{const n=this.#r,r=this.#d(this.#n);r.forEach(u=>u.observer.setOptions(u.defaultedQueryOptions));const i=r.map(u=>u.observer),s=i.map(u=>u.getCurrentResult()),o=n.length!==i.length,l=i.some((u,m)=>u!==n[m]),c=o||l,d=c?!0:s.some((u,m)=>{const p=this.#e[m];return!p||!rw(u,p)});!c&&!d||(c&&(this.#u=r,this.#r=i),this.#e=s,this.hasListeners()&&(c&&(SH(n,i).forEach(u=>{u.destroy()}),SH(i,n).forEach(u=>{u.subscribe(m=>{this.#l(u,m)})})),this.#h()))})}getCurrentResult(){return this.#e}getQueries(){return this.#r.map(t=>t.getCurrentQuery())}getObservers(){return this.#r}getOptimisticResult(t,e){const n=this.#d(t),r=n.map(s=>s.observer.getOptimisticResult(s.defaultedQueryOptions)),i=n.map(s=>s.defaultedQueryOptions.queryHash);return[r,s=>this.#c(s??r,e,i),()=>this.#m(r,n)]}#m(t,e){return e.map((n,r)=>{const i=t[r];return n.defaultedQueryOptions.notifyOnChangeProps?i:n.observer.trackResult(i,s=>{e.forEach(o=>{o.observer.trackProp(s)})})})}#c(t,e,n){if(e){const r=this.#p,i=n!==void 0&&r!==void 0&&(r.length!==n.length||n.some((s,o)=>s!==r[o]));return(!this.#i||this.#e!==this.#s||i||e!==this.#o)&&(this.#o=e,this.#s=this.#e,n!==void 0&&(this.#p=n),this.#i=SO(this.#i,e(t))),this.#i}return t}#d(t){const e=new Map;this.#r.forEach(r=>{const i=r.options.queryHash;if(!i)return;const s=e.get(i);s?s.push(r):e.set(i,[r])});const n=[];return t.forEach(r=>{const i=this.#t.defaultQueryOptions(r),o=e.get(i.queryHash)?.shift()??new CO(this.#t,i);n.push({defaultedQueryOptions:i,observer:o})}),n}#l(t,e){const n=this.#r.indexOf(t);n!==-1&&(this.#e=Zhe(this.#e,n,e),this.#h())}#h(){if(this.hasListeners()){const t=this.#i,e=this.#m(this.#e,this.#u),n=this.#c(e,this.#a?.combine);t!==n&&Qa.batch(()=>{this.listeners.forEach(r=>{r(this.#e)})})}}},epe=class extends Lg{constructor(t={}){super(),this.config=t,this.#t=new Map}#t;build(t,e,n){const r=e.queryKey,i=e.queryHash??wO(r,e);let s=this.get(i);return s||(s=new Vhe({client:t,queryKey:r,queryHash:i,options:t.defaultQueryOptions(e),state:n,defaultOptions:t.getQueryDefaults(r)}),this.add(s)),s}add(t){this.#t.has(t.queryHash)||(this.#t.set(t.queryHash,t),this.notify({type:\"added\",query:t}))}remove(t){const e=this.#t.get(t.queryHash);e&&(t.destroy(),e===t&&this.#t.delete(t.queryHash),this.notify({type:\"removed\",query:t}))}clear(){Qa.batch(()=>{this.getAll().forEach(t=>{this.remove(t)})})}get(t){return this.#t.get(t)}getAll(){return[...this.#t.values()]}find(t){const e={exact:!0,...t};return this.getAll().find(n=>mH(e,n))}findAll(t={}){const e=this.getAll();return Object.keys(t).length>0?e.filter(n=>mH(t,n)):e}notify(t){Qa.batch(()=>{this.listeners.forEach(e=>{e(t)})})}onFocus(){Qa.batch(()=>{this.getAll().forEach(t=>{t.onFocus()})})}onOnline(){Qa.batch(()=>{this.getAll().forEach(t=>{t.onOnline()})})}},tpe=class{#t;#e;#n;#a;#r;#i;#o;#s;constructor(t={}){this.#t=t.queryCache||new epe,this.#e=t.mutationCache||new Yhe,this.#n=t.defaultOptions||{},this.#a=new Map,this.#r=new Map,this.#i=0}mount(){this.#i++,this.#i===1&&(this.#o=NO.subscribe(async t=>{t&&(await this.resumePausedMutations(),this.#t.onFocus())}),this.#s=gN.subscribe(async t=>{t&&(await this.resumePausedMutations(),this.#t.onOnline())}))}unmount(){this.#i--,this.#i===0&&(this.#o?.(),this.#o=void 0,this.#s?.(),this.#s=void 0)}isFetching(t){return this.#t.findAll({...t,fetchStatus:\"fetching\"}).length}isMutating(t){return this.#e.findAll({...t,status:\"pending\"}).length}getQueryData(t){const e=this.defaultQueryOptions({queryKey:t});return this.#t.get(e.queryHash)?.state.data}ensureQueryData(t){const e=this.defaultQueryOptions(t),n=this.#t.build(this,e),r=n.state.data;return r===void 0?this.fetchQuery(t):(t.revalidateIfStale&&n.isStaleByTime(Qh(e.staleTime,n))&&this.prefetchQuery(e),Promise.resolve(r))}getQueriesData(t){return this.#t.findAll(t).map(({queryKey:e,state:n})=>{const r=n.data;return[e,r]})}setQueryData(t,e,n){const r=this.defaultQueryOptions({queryKey:t}),s=this.#t.get(r.queryHash)?.state.data,o=Fhe(e,s);if(o!==void 0)return this.#t.build(this,r).setData(o,{...n,manual:!0})}setQueriesData(t,e,n){return Qa.batch(()=>this.#t.findAll(t).map(({queryKey:r})=>[r,this.setQueryData(r,e,n)]))}getQueryState(t){const e=this.defaultQueryOptions({queryKey:t});return this.#t.get(e.queryHash)?.state}removeQueries(t){const e=this.#t;Qa.batch(()=>{e.findAll(t).forEach(n=>{e.remove(n)})})}resetQueries(t,e){const n=this.#t;return Qa.batch(()=>(n.findAll(t).forEach(r=>{r.reset()}),this.refetchQueries({type:\"active\",...t},e)))}cancelQueries(t,e={}){const n={revert:!0,...e},r=Qa.batch(()=>this.#t.findAll(t).map(i=>i.cancel(n)));return Promise.all(r).then(Is).catch(Is)}invalidateQueries(t,e={}){return Qa.batch(()=>(this.#t.findAll(t).forEach(n=>{n.invalidate()}),t?.refetchType===\"none\"?Promise.resolve():this.refetchQueries({...t,type:t?.refetchType??t?.type??\"active\"},e)))}refetchQueries(t,e={}){const n={...e,cancelRefetch:e.cancelRefetch??!0},r=Qa.batch(()=>this.#t.findAll(t).filter(i=>!i.isDisabled()&&!i.isStatic()).map(i=>{let s=i.fetch(void 0,n);return n.throwOnError||(s=s.catch(Is)),i.state.fetchStatus===\"paused\"?Promise.resolve():s}));return Promise.all(r).then(Is)}fetchQuery(t){const e=this.defaultQueryOptions(t);e.retry===void 0&&(e.retry=!1);const n=this.#t.build(this,e);return n.isStaleByTime(Qh(e.staleTime,n))?n.fetch(e):Promise.resolve(n.state.data)}prefetchQuery(t){return this.fetchQuery(t).then(Is).catch(Is)}fetchInfiniteQuery(t){return t.behavior=vH(t.pages),this.fetchQuery(t)}prefetchInfiniteQuery(t){return this.fetchInfiniteQuery(t).then(Is).catch(Is)}ensureInfiniteQueryData(t){return t.behavior=vH(t.pages),this.ensureQueryData(t)}resumePausedMutations(){return gN.isOnline()?this.#e.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#t}getMutationCache(){return this.#e}getDefaultOptions(){return this.#n}setDefaultOptions(t){this.#n=t}setQueryDefaults(t,e){this.#a.set(lg(t),{queryKey:t,defaultOptions:e})}getQueryDefaults(t){const e=[...this.#a.values()],n={};return e.forEach(r=>{nw(t,r.queryKey)&&Object.assign(n,r.defaultOptions)}),n}setMutationDefaults(t,e){this.#r.set(lg(t),{mutationKey:t,defaultOptions:e})}getMutationDefaults(t){const e=[...this.#r.values()],n={};return e.forEach(r=>{nw(t,r.mutationKey)&&Object.assign(n,r.defaultOptions)}),n}defaultQueryOptions(t){if(t._defaulted)return t;const e={...this.#n.queries,...this.getQueryDefaults(t.queryKey),...t,_defaulted:!0};return e.queryHash||(e.queryHash=wO(e.queryKey,e)),e.refetchOnReconnect===void 0&&(e.refetchOnReconnect=e.networkMode!==\"always\"),e.throwOnError===void 0&&(e.throwOnError=!!e.suspense),!e.networkMode&&e.persister&&(e.networkMode=\"offlineFirst\"),e.queryFn===_O&&(e.enabled=!1),e}defaultMutationOptions(t){return t?._defaulted?t:{...this.#n.mutations,...t?.mutationKey&&this.getMutationDefaults(t.mutationKey),...t,_defaulted:!0}}clear(){this.#t.clear(),this.#e.clear()}},_Q=w.createContext(void 0),nn=t=>{const e=w.useContext(_Q);if(!e)throw new Error(\"No QueryClient set, use QueryClientProvider to set one\");return e},npe=({client:t,children:e})=>(w.useEffect(()=>(t.mount(),()=>{t.unmount()}),[t]),a.jsx(_Q.Provider,{value:t,children:e})),kQ=w.createContext(!1),NQ=()=>w.useContext(kQ);kQ.Provider;function rpe(){let t=!1;return{clearReset:()=>{t=!1},reset:()=>{t=!0},isReset:()=>t}}var ape=w.createContext(rpe()),CQ=()=>w.useContext(ape),PQ=(t,e,n)=>{const r=n?.state.error&&typeof t.throwOnError==\"function\"?kO(t.throwOnError,[n.state.error,n]):t.throwOnError;(t.suspense||t.experimental_prefetchInRender||r)&&(e.isReset()||(t.retryOnMount=!1))},TQ=t=>{w.useEffect(()=>{t.clearReset()},[t])},AQ=({result:t,errorResetBoundary:e,throwOnError:n,query:r,suspense:i})=>t.isError&&!e.isReset()&&!t.isFetching&&r&&(i&&t.data===void 0||kO(n,[t.error,r])),jQ=t=>{if(t.suspense){const n=i=>i===\"static\"?i:Math.max(i??1e3,1e3),r=t.staleTime;t.staleTime=typeof r==\"function\"?(...i)=>n(r(...i)):n(r),typeof t.gcTime==\"number\"&&(t.gcTime=Math.max(t.gcTime,1e3))}},MQ=(t,e)=>t.isLoading&&t.isFetching&&!e,tF=(t,e)=>t?.suspense&&e.isPending,bN=(t,e,n)=>e.fetchOptimistic(t).catch(()=>{n.clearReset()});function Pp({queries:t,...e},n){const r=nn(),i=NQ(),s=CQ(),o=w.useMemo(()=>t.map(v=>{const b=r.defaultQueryOptions(v);return b._optimisticResults=i?\"isRestoring\":\"optimistic\",b}),[t,r,i]);o.forEach(v=>{jQ(v);const b=r.getQueryCache().get(v.queryHash);PQ(v,s,b)}),TQ(s);const[l]=w.useState(()=>new Jhe(r,o,e)),[c,d,u]=l.getOptimisticResult(o,e.combine),m=!i&&e.subscribed!==!1;w.useSyncExternalStore(w.useCallback(v=>m?l.subscribe(Qa.batchCalls(v)):Is,[l,m]),()=>l.getCurrentResult(),()=>l.getCurrentResult()),w.useEffect(()=>{l.setQueries(o,e)},[o,e,l]);const f=c.some((v,b)=>tF(o[b],v))?c.flatMap((v,b)=>{const g=o[b];if(g){const _=new CO(r,g);if(tF(g,v))return bN(g,_,s);MQ(v,i)&&bN(g,_,s)}return[]}):[];if(f.length>0)throw Promise.all(f);const y=c.find((v,b)=>{const g=o[b];return g&&AQ({result:v,errorResetBoundary:s,throwOnError:g.throwOnError,query:r.getQueryCache().get(g.queryHash),suspense:g.suspense})});if(y?.error)throw y.error;return d(u())}function ipe(t,e,n){const r=NQ(),i=CQ(),s=nn(),o=s.defaultQueryOptions(t);s.getDefaultOptions().queries?._experimental_beforeQuery?.(o);const l=s.getQueryCache().get(o.queryHash);o._optimisticResults=r?\"isRestoring\":\"optimistic\",jQ(o),PQ(o,i,l),TQ(i);const c=!s.getQueryCache().get(o.queryHash),[d]=w.useState(()=>new e(s,o)),u=d.getOptimisticResult(o),m=!r&&t.subscribed!==!1;if(w.useSyncExternalStore(w.useCallback(p=>{const f=m?d.subscribe(Qa.batchCalls(p)):Is;return d.updateResult(),f},[d,m]),()=>d.getCurrentResult(),()=>d.getCurrentResult()),w.useEffect(()=>{d.setOptions(o)},[o,d]),tF(o,u))throw bN(o,d,i);if(AQ({result:u,errorResetBoundary:i,throwOnError:o.throwOnError,query:l,suspense:o.suspense}))throw u.error;return s.getDefaultOptions().queries?._experimental_afterQuery?.(o,u),o.experimental_prefetchInRender&&!og&&MQ(u,r)&&(c?bN(o,d,i):l?.promise)?.catch(Is).finally(()=>{d.updateResult()}),o.notifyOnChangeProps?u:d.trackResult(u)}function Xe(t,e){return ipe(t,CO)}function it(t,e){const n=nn(),[r]=w.useState(()=>new Qhe(n,t));w.useEffect(()=>{r.setOptions(t)},[r,t]);const i=w.useSyncExternalStore(w.useCallback(o=>r.subscribe(Qa.batchCalls(o)),[r]),()=>r.getCurrentResult(),()=>r.getCurrentResult()),s=w.useCallback((o,l)=>{r.mutate(o,l).catch(Is)},[r]);if(i.error&&kO(r.options.throwOnError,[i.error]))throw i.error;return{...i,mutate:s,mutateAsync:i.mutate}}const spe=t=>t.replace(/([a-z0-9])([A-Z])/g,\"$1-$2\").toLowerCase(),ope=t=>t.replace(/^([A-Z])|[\\s-_]+(\\w)/g,(e,n,r)=>r?r.toUpperCase():n.toLowerCase()),_H=t=>{const e=ope(t);return e.charAt(0).toUpperCase()+e.slice(1)},EQ=(...t)=>t.filter((e,n,r)=>!!e&&e.trim()!==\"\"&&r.indexOf(e)===n).join(\" \").trim(),lpe=t=>{for(const e in t)if(e.startsWith(\"aria-\")||e===\"role\"||e===\"title\")return!0};var cpe={xmlns:\"http://www.w3.org/2000/svg\",width:24,height:24,viewBox:\"0 0 24 24\",fill:\"none\",stroke:\"currentColor\",strokeWidth:2,strokeLinecap:\"round\",strokeLinejoin:\"round\"};const dpe=w.forwardRef(({color:t=\"currentColor\",size:e=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=\"\",children:s,iconNode:o,...l},c)=>w.createElement(\"svg\",{ref:c,...cpe,width:e,height:e,stroke:t,strokeWidth:r?Number(n)*24/Number(e):n,className:EQ(\"lucide\",i),...!s&&!lpe(l)&&{\"aria-hidden\":\"true\"},...l},[...o.map(([d,u])=>w.createElement(d,u)),...Array.isArray(s)?s:[s]]));const st=(t,e)=>{const n=w.forwardRef(({className:r,...i},s)=>w.createElement(dpe,{ref:s,iconNode:e,className:EQ(`lucide-${spe(_H(t))}`,`lucide-${t}`,r),...i}));return n.displayName=_H(t),n};const upe=[[\"path\",{d:\"M18 17.5a2.5 2.5 0 1 1-4 2.03V12\",key:\"yd12zl\"}],[\"path\",{d:\"M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2\",key:\"larmp2\"}],[\"path\",{d:\"M6 8h12\",key:\"6g4wlu\"}],[\"path\",{d:\"M6.6 15.572A2 2 0 1 0 10 17v-5\",key:\"1x1kqn\"}]],mpe=st(\"air-vent\",upe);const hpe=[[\"rect\",{width:\"20\",height:\"5\",x:\"2\",y:\"3\",rx:\"1\",key:\"1wp1u1\"}],[\"path\",{d:\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\",key:\"1s80jp\"}],[\"path\",{d:\"M10 12h4\",key:\"a56b0p\"}]],so=st(\"archive\",hpe);const ppe=[[\"path\",{d:\"m3 16 4 4 4-4\",key:\"1co6wj\"}],[\"path\",{d:\"M7 20V4\",key:\"1yoxec\"}],[\"path\",{d:\"M11 4h10\",key:\"1w87gc\"}],[\"path\",{d:\"M11 8h7\",key:\"djye34\"}],[\"path\",{d:\"M11 12h4\",key:\"q8tih4\"}]],fpe=st(\"arrow-down-wide-narrow\",ppe);const gpe=[[\"path\",{d:\"M12 5v14\",key:\"s699le\"}],[\"path\",{d:\"m19 12-7 7-7-7\",key:\"1idqje\"}]],cg=st(\"arrow-down\",gpe);const bpe=[[\"path\",{d:\"m12 19-7-7 7-7\",key:\"1l729n\"}],[\"path\",{d:\"M19 12H5\",key:\"x3x0zl\"}]],DQ=st(\"arrow-left\",bpe);const xpe=[[\"path\",{d:\"m16 3 4 4-4 4\",key:\"1x1c3m\"}],[\"path\",{d:\"M20 7H4\",key:\"zbl0bi\"}],[\"path\",{d:\"m8 21-4-4 4-4\",key:\"h9nckh\"}],[\"path\",{d:\"M4 17h16\",key:\"g4d7ey\"}]],nF=st(\"arrow-right-left\",xpe);const ype=[[\"path\",{d:\"M5 12h14\",key:\"1ays0h\"}],[\"path\",{d:\"m12 5 7 7-7 7\",key:\"xquz4c\"}]],rF=st(\"arrow-right\",ype);const vpe=[[\"path\",{d:\"m21 16-4 4-4-4\",key:\"f6ql7i\"}],[\"path\",{d:\"M17 20V4\",key:\"1ejh1v\"}],[\"path\",{d:\"m3 8 4-4 4 4\",key:\"11wl7u\"}],[\"path\",{d:\"M7 4v16\",key:\"1glfcx\"}]],TO=st(\"arrow-up-down\",vpe);const wpe=[[\"path\",{d:\"m3 8 4-4 4 4\",key:\"11wl7u\"}],[\"path\",{d:\"M7 4v16\",key:\"1glfcx\"}],[\"path\",{d:\"M11 12h4\",key:\"q8tih4\"}],[\"path\",{d:\"M11 16h7\",key:\"uosisv\"}],[\"path\",{d:\"M11 20h10\",key:\"jvxblo\"}]],Spe=st(\"arrow-up-narrow-wide\",wpe);const _pe=[[\"path\",{d:\"m5 12 7-7 7 7\",key:\"hav0vg\"}],[\"path\",{d:\"M12 19V5\",key:\"x0mq9r\"}]],fp=st(\"arrow-up\",_pe);const kpe=[[\"path\",{d:\"M4.5 3h15\",key:\"c7n0jr\"}],[\"path\",{d:\"M6 3v16a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V3\",key:\"m1uhx7\"}],[\"path\",{d:\"M6 14h12\",key:\"4cwo0f\"}]],Npe=st(\"beaker\",kpe);const Cpe=[[\"path\",{d:\"M10.268 21a2 2 0 0 0 3.464 0\",key:\"vwvbt9\"}],[\"path\",{d:\"M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742\",key:\"178tsu\"}],[\"path\",{d:\"m2 2 20 20\",key:\"1ooewy\"}],[\"path\",{d:\"M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05\",key:\"1hqiys\"}]],Ppe=st(\"bell-off\",Cpe);const Tpe=[[\"path\",{d:\"M10.268 21a2 2 0 0 0 3.464 0\",key:\"vwvbt9\"}],[\"path\",{d:\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\",key:\"11g9vi\"}]],Zh=st(\"bell\",Tpe);const Ape=[[\"path\",{d:\"M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8\",key:\"mg9rjx\"}]],jpe=st(\"bold\",Ape);const Mpe=[[\"path\",{d:\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\",key:\"k3hazp\"}]],Epe=st(\"book\",Mpe);const Dpe=[[\"path\",{d:\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\",key:\"1fy3hk\"}]],Fpe=st(\"bookmark\",Dpe);const Rpe=[[\"path\",{d:\"M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z\",key:\"hh9hay\"}],[\"path\",{d:\"m3.3 7 8.7 5 8.7-5\",key:\"g66t2b\"}],[\"path\",{d:\"M12 22V12\",key:\"d0xqtd\"}]],vi=st(\"box\",Rpe);const Lpe=[[\"path\",{d:\"M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16\",key:\"jecpp\"}],[\"rect\",{width:\"20\",height:\"14\",x:\"2\",y:\"6\",rx:\"2\",key:\"i6l2r4\"}]],FQ=st(\"briefcase\",Lpe);const Ope=[[\"path\",{d:\"M12 20v-9\",key:\"1qisl0\"}],[\"path\",{d:\"M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z\",key:\"uouzyp\"}],[\"path\",{d:\"M14.12 3.88 16 2\",key:\"qol33r\"}],[\"path\",{d:\"M21 21a4 4 0 0 0-3.81-4\",key:\"1b0z45\"}],[\"path\",{d:\"M21 5a4 4 0 0 1-3.55 3.97\",key:\"5cxbf6\"}],[\"path\",{d:\"M22 13h-4\",key:\"1jl80f\"}],[\"path\",{d:\"M3 21a4 4 0 0 1 3.81-4\",key:\"1fjd4g\"}],[\"path\",{d:\"M3 5a4 4 0 0 0 3.55 3.97\",key:\"1d7oge\"}],[\"path\",{d:\"M6 13H2\",key:\"82j7cp\"}],[\"path\",{d:\"m8 2 1.88 1.88\",key:\"fmnt4t\"}],[\"path\",{d:\"M9 7.13V6a3 3 0 1 1 6 0v1.13\",key:\"1vgav8\"}]],Hx=st(\"bug\",Ope);const Ipe=[[\"path\",{d:\"M17 19a1 1 0 0 1-1-1v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2a1 1 0 0 1-1 1z\",key:\"trhst0\"}],[\"path\",{d:\"M17 21v-2\",key:\"ds4u3f\"}],[\"path\",{d:\"M19 14V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V10\",key:\"1mo9zo\"}],[\"path\",{d:\"M21 21v-2\",key:\"eo0ou\"}],[\"path\",{d:\"M3 5V3\",key:\"1k5hjh\"}],[\"path\",{d:\"M4 10a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2z\",key:\"1dd30t\"}],[\"path\",{d:\"M7 5V3\",key:\"1t1388\"}]],AO=st(\"cable\",Ipe);const zpe=[[\"rect\",{width:\"16\",height:\"20\",x:\"4\",y:\"2\",rx:\"2\",key:\"1nb95v\"}],[\"line\",{x1:\"8\",x2:\"16\",y1:\"6\",y2:\"6\",key:\"x4nwl0\"}],[\"line\",{x1:\"16\",x2:\"16\",y1:\"14\",y2:\"18\",key:\"wjye3r\"}],[\"path\",{d:\"M16 10h.01\",key:\"1m94wz\"}],[\"path\",{d:\"M12 10h.01\",key:\"1nrarc\"}],[\"path\",{d:\"M8 10h.01\",key:\"19clt8\"}],[\"path\",{d:\"M12 14h.01\",key:\"1etili\"}],[\"path\",{d:\"M8 14h.01\",key:\"6423bh\"}],[\"path\",{d:\"M12 18h.01\",key:\"mhygvu\"}],[\"path\",{d:\"M8 18h.01\",key:\"lrp35t\"}]],Upe=st(\"calculator\",zpe);const Bpe=[[\"path\",{d:\"M8 2v4\",key:\"1cmpym\"}],[\"path\",{d:\"M16 2v4\",key:\"4m81vk\"}],[\"rect\",{width:\"18\",height:\"18\",x:\"3\",y:\"4\",rx:\"2\",key:\"1hopcy\"}],[\"path\",{d:\"M3 10h18\",key:\"8toen8\"}],[\"path\",{d:\"M8 14h.01\",key:\"6423bh\"}],[\"path\",{d:\"M12 14h.01\",key:\"1etili\"}],[\"path\",{d:\"M16 14h.01\",key:\"1gbofw\"}],[\"path\",{d:\"M8 18h.01\",key:\"lrp35t\"}],[\"path\",{d:\"M12 18h.01\",key:\"mhygvu\"}],[\"path\",{d:\"M16 18h.01\",key:\"kzsmim\"}]],Hpe=st(\"calendar-days\",Bpe);const qpe=[[\"path\",{d:\"M16 19h6\",key:\"xwg31i\"}],[\"path\",{d:\"M16 2v4\",key:\"4m81vk\"}],[\"path\",{d:\"M19 16v6\",key:\"tddt3s\"}],[\"path\",{d:\"M21 12.598V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5\",key:\"1glfrc\"}],[\"path\",{d:\"M3 10h18\",key:\"8toen8\"}],[\"path\",{d:\"M8 2v4\",key:\"1cmpym\"}]],$pe=st(\"calendar-plus\",qpe);const Vpe=[[\"path\",{d:\"M8 2v4\",key:\"1cmpym\"}],[\"path\",{d:\"M16 2v4\",key:\"4m81vk\"}],[\"rect\",{width:\"18\",height:\"18\",x:\"3\",y:\"4\",rx:\"2\",key:\"1hopcy\"}],[\"path\",{d:\"M3 10h18\",key:\"8toen8\"}]],oa=st(\"calendar\",Vpe);const Gpe=[[\"path\",{d:\"M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z\",key:\"18u6gg\"}],[\"circle\",{cx:\"12\",cy:\"13\",r:\"3\",key:\"1vg3eu\"}]],qx=st(\"camera\",Gpe);const Wpe=[[\"path\",{d:\"M3 3v16a2 2 0 0 0 2 2h16\",key:\"c24i48\"}],[\"path\",{d:\"M18 17V9\",key:\"2bz60n\"}],[\"path\",{d:\"M13 17V5\",key:\"1frdt8\"}],[\"path\",{d:\"M8 17v-3\",key:\"17ska0\"}]],Kpe=st(\"chart-column\",Wpe);const Xpe=[[\"path\",{d:\"M6 5h12\",key:\"fvfigv\"}],[\"path\",{d:\"M4 12h10\",key:\"oujl3d\"}],[\"path\",{d:\"M12 19h8\",key:\"baeox8\"}]],Ype=st(\"chart-no-axes-gantt\",Xpe);const Qpe=[[\"path\",{d:\"M20 6 9 17l-5-5\",key:\"1gmf2c\"}]],Ur=st(\"check\",Qpe);const Zpe=[[\"path\",{d:\"m6 9 6 6 6-6\",key:\"qrunsl\"}]],On=st(\"chevron-down\",Zpe);const Jpe=[[\"path\",{d:\"m9 18 6-6-6-6\",key:\"mthhwq\"}]],ti=st(\"chevron-right\",Jpe);const efe=[[\"path\",{d:\"m15 18-6-6 6-6\",key:\"1wnfg3\"}]],xl=st(\"chevron-left\",efe);const tfe=[[\"path\",{d:\"m18 15-6-6-6 6\",key:\"153udz\"}]],cc=st(\"chevron-up\",tfe);const nfe=[[\"path\",{d:\"m11 17-5-5 5-5\",key:\"13zhaf\"}],[\"path\",{d:\"m18 17-5-5 5-5\",key:\"h8a8et\"}]],jO=st(\"chevrons-left\",nfe);const rfe=[[\"path\",{d:\"m6 17 5-5-5-5\",key:\"xnjwq\"}],[\"path\",{d:\"m13 17 5-5-5-5\",key:\"17xmmf\"}]],MO=st(\"chevrons-right\",rfe);const afe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"line\",{x1:\"12\",x2:\"12\",y1:\"8\",y2:\"12\",key:\"1pkeuh\"}],[\"line\",{x1:\"12\",x2:\"12.01\",y1:\"16\",y2:\"16\",key:\"4dfq90\"}]],ei=st(\"circle-alert\",afe);const ife=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"path\",{d:\"m16 12-4-4-4 4\",key:\"177agl\"}],[\"path\",{d:\"M12 16V8\",key:\"1sbj14\"}]],TM=st(\"circle-arrow-up\",ife);const sfe=[[\"path\",{d:\"M21.801 10A10 10 0 1 1 17 3.335\",key:\"yps3ct\"}],[\"path\",{d:\"m9 11 3 3L22 4\",key:\"1pflzl\"}]],yr=st(\"circle-check-big\",sfe);const ofe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"path\",{d:\"m9 12 2 2 4-4\",key:\"dzmm74\"}]],Vc=st(\"circle-check\",ofe);const lfe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"1\",key:\"41hilf\"}]],cfe=st(\"circle-dot\",lfe);const dfe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"rect\",{x:\"9\",y:\"9\",width:\"6\",height:\"6\",rx:\"1\",key:\"1ssd4o\"}]],ufe=st(\"circle-stop\",dfe);const mfe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"path\",{d:\"m15 9-6 6\",key:\"1uzhvr\"}],[\"path\",{d:\"m9 9 6 6\",key:\"z0biqf\"}]],Ma=st(\"circle-x\",mfe);const hfe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}]],aw=st(\"circle\",hfe);const pfe=[[\"rect\",{width:\"8\",height:\"4\",x:\"8\",y:\"2\",rx:\"1\",ry:\"1\",key:\"tgr4d6\"}],[\"path\",{d:\"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2\",key:\"116196\"}],[\"path\",{d:\"M12 11h4\",key:\"1jrz19\"}],[\"path\",{d:\"M12 16h4\",key:\"n85exb\"}],[\"path\",{d:\"M8 11h.01\",key:\"1dfujw\"}],[\"path\",{d:\"M8 16h.01\",key:\"18s6g9\"}]],ffe=st(\"clipboard-list\",pfe);const gfe=[[\"path\",{d:\"M12 6v6l4 2\",key:\"mmk7yg\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}]],Yn=st(\"clock\",gfe);const bfe=[[\"path\",{d:\"m2 2 20 20\",key:\"1ooewy\"}],[\"path\",{d:\"M5.782 5.782A7 7 0 0 0 9 19h8.5a4.5 4.5 0 0 0 1.307-.193\",key:\"yfwify\"}],[\"path\",{d:\"M21.532 16.5A4.5 4.5 0 0 0 17.5 10h-1.79A7.008 7.008 0 0 0 10 5.07\",key:\"jlfiyv\"}]],xfe=st(\"cloud-off\",bfe);const yfe=[[\"path\",{d:\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\",key:\"p7xjir\"}]],vy=st(\"cloud\",yfe);const vfe=[[\"path\",{d:\"m18 16 4-4-4-4\",key:\"1inbqp\"}],[\"path\",{d:\"m6 8-4 4 4 4\",key:\"15zrgr\"}],[\"path\",{d:\"m14.5 4-5 16\",key:\"e7oirm\"}]],wfe=st(\"code-xml\",vfe);const Sfe=[[\"path\",{d:\"m16 18 6-6-6-6\",key:\"eg8j8\"}],[\"path\",{d:\"m8 6-6 6 6 6\",key:\"ppft3o\"}]],wy=st(\"code\",Sfe);const _fe=[[\"path\",{d:\"M10 2v2\",key:\"7u0qdc\"}],[\"path\",{d:\"M14 2v2\",key:\"6buw04\"}],[\"path\",{d:\"M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1\",key:\"pwadti\"}],[\"path\",{d:\"M6 2v2\",key:\"colzsn\"}]],kfe=st(\"coffee\",_fe);const Nfe=[[\"circle\",{cx:\"8\",cy:\"8\",r:\"6\",key:\"3yglwk\"}],[\"path\",{d:\"M18.09 10.37A6 6 0 1 1 10.34 18\",key:\"t5s6rm\"}],[\"path\",{d:\"M7 6h1v4\",key:\"1obek4\"}],[\"path\",{d:\"m16.71 13.88.7.71-2.82 2.82\",key:\"1rbuyh\"}]],Cfe=st(\"coins\",Nfe);const Pfe=[[\"path\",{d:\"M11 10.27 7 3.34\",key:\"16pf9h\"}],[\"path\",{d:\"m11 13.73-4 6.93\",key:\"794ttg\"}],[\"path\",{d:\"M12 22v-2\",key:\"1osdcq\"}],[\"path\",{d:\"M12 2v2\",key:\"tus03m\"}],[\"path\",{d:\"M14 12h8\",key:\"4f43i9\"}],[\"path\",{d:\"m17 20.66-1-1.73\",key:\"eq3orb\"}],[\"path\",{d:\"m17 3.34-1 1.73\",key:\"2wel8s\"}],[\"path\",{d:\"M2 12h2\",key:\"1t8f8n\"}],[\"path\",{d:\"m20.66 17-1.73-1\",key:\"sg0v6f\"}],[\"path\",{d:\"m20.66 7-1.73 1\",key:\"1ow05n\"}],[\"path\",{d:\"m3.34 17 1.73-1\",key:\"nuk764\"}],[\"path\",{d:\"m3.34 7 1.73 1\",key:\"1ulond\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"2\",key:\"1c9p78\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"8\",key:\"46899m\"}]],Tfe=st(\"cog\",Pfe);const Afe=[[\"rect\",{width:\"18\",height:\"18\",x:\"3\",y:\"3\",rx:\"2\",key:\"afitv7\"}],[\"path\",{d:\"M12 3v18\",key:\"108xh3\"}]],jfe=st(\"columns-2\",Afe);const Mfe=[[\"path\",{d:\"m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z\",key:\"9ktpf1\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}]],Efe=st(\"compass\",Mfe);const Dfe=[[\"rect\",{width:\"14\",height:\"14\",x:\"8\",y:\"8\",rx:\"2\",ry:\"2\",key:\"17jyea\"}],[\"path\",{d:\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\",key:\"zix9uf\"}]],qs=st(\"copy\",Dfe);const Ffe=[[\"path\",{d:\"M12 20v2\",key:\"1lh1kg\"}],[\"path\",{d:\"M12 2v2\",key:\"tus03m\"}],[\"path\",{d:\"M17 20v2\",key:\"1rnc9c\"}],[\"path\",{d:\"M17 2v2\",key:\"11trls\"}],[\"path\",{d:\"M2 12h2\",key:\"1t8f8n\"}],[\"path\",{d:\"M2 17h2\",key:\"7oei6x\"}],[\"path\",{d:\"M2 7h2\",key:\"asdhe0\"}],[\"path\",{d:\"M20 12h2\",key:\"1q8mjw\"}],[\"path\",{d:\"M20 17h2\",key:\"1fpfkl\"}],[\"path\",{d:\"M20 7h2\",key:\"1o8tra\"}],[\"path\",{d:\"M7 20v2\",key:\"4gnj0m\"}],[\"path\",{d:\"M7 2v2\",key:\"1i4yhu\"}],[\"rect\",{x:\"4\",y:\"4\",width:\"16\",height:\"16\",rx:\"2\",key:\"1vbyd7\"}],[\"rect\",{x:\"8\",y:\"8\",width:\"8\",height:\"8\",rx:\"1\",key:\"z9xiuo\"}]],u0=st(\"cpu\",Ffe);const Rfe=[[\"ellipse\",{cx:\"12\",cy:\"5\",rx:\"9\",ry:\"3\",key:\"msslwz\"}],[\"path\",{d:\"M3 5v14a9 3 0 0 0 18 0V5\",key:\"aqi0yr\"}]],Lfe=st(\"cylinder\",Rfe);const Ofe=[[\"ellipse\",{cx:\"12\",cy:\"5\",rx:\"9\",ry:\"3\",key:\"msslwz\"}],[\"path\",{d:\"M3 5V19A9 3 0 0 0 21 19V5\",key:\"1wlel7\"}],[\"path\",{d:\"M3 12A9 3 0 0 0 21 12\",key:\"mv7ke4\"}]],Jl=st(\"database\",Ofe);const Ife=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"path\",{d:\"M6 12c0-1.7.7-3.2 1.8-4.2\",key:\"oqkarx\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"2\",key:\"1c9p78\"}],[\"path\",{d:\"M18 12c0 1.7-.7 3.2-1.8 4.2\",key:\"1eah9h\"}]],zfe=st(\"disc-3\",Ife);const Ufe=[[\"line\",{x1:\"12\",x2:\"12\",y1:\"2\",y2:\"22\",key:\"7eqyqh\"}],[\"path\",{d:\"M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6\",key:\"1b0p4s\"}]],xN=st(\"dollar-sign\",Ufe);const Bfe=[[\"path\",{d:\"M10 12h.01\",key:\"1kxr2c\"}],[\"path\",{d:\"M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14\",key:\"36qu9e\"}],[\"path\",{d:\"M2 20h20\",key:\"owomy5\"}]],Hfe=st(\"door-closed\",Bfe);const qfe=[[\"path\",{d:\"M11 20H2\",key:\"nlcfvz\"}],[\"path\",{d:\"M11 4.562v16.157a1 1 0 0 0 1.242.97L19 20V5.562a2 2 0 0 0-1.515-1.94l-4-1A2 2 0 0 0 11 4.561z\",key:\"au4z13\"}],[\"path\",{d:\"M11 4H8a2 2 0 0 0-2 2v14\",key:\"74r1mk\"}],[\"path\",{d:\"M14 12h.01\",key:\"1jfl7z\"}],[\"path\",{d:\"M22 20h-3\",key:\"vhrsz\"}]],$fe=st(\"door-open\",qfe);const Vfe=[[\"path\",{d:\"M12 15V3\",key:\"m9g1x1\"}],[\"path\",{d:\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\",key:\"ih7n3h\"}],[\"path\",{d:\"m7 10 5 5 5-5\",key:\"brsn70\"}]],ga=st(\"download\",Vfe);const Gfe=[[\"path\",{d:\"M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z\",key:\"c7niix\"}]],HP=st(\"droplet\",Gfe);const Wfe=[[\"path\",{d:\"M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z\",key:\"1ptgy4\"}],[\"path\",{d:\"M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2 4.9 4 6.5s3 3.5 3 5.5a6.98 6.98 0 0 1-11.91 4.97\",key:\"1sl1rz\"}]],EO=st(\"droplets\",Wfe);const Kfe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"1\",key:\"41hilf\"}],[\"circle\",{cx:\"12\",cy:\"5\",r:\"1\",key:\"gxeob9\"}],[\"circle\",{cx:\"12\",cy:\"19\",r:\"1\",key:\"lyex9k\"}]],Jh=st(\"ellipsis-vertical\",Kfe);const Xfe=[[\"line\",{x1:\"5\",x2:\"19\",y1:\"9\",y2:\"9\",key:\"1nwqeh\"}],[\"line\",{x1:\"5\",x2:\"19\",y1:\"15\",y2:\"15\",key:\"g8yjpy\"}]],AM=st(\"equal\",Xfe);const Yfe=[[\"path\",{d:\"M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21\",key:\"g5wo59\"}],[\"path\",{d:\"m5.082 11.09 8.828 8.828\",key:\"1wx5vj\"}]],Qfe=st(\"eraser\",Yfe);const Zfe=[[\"path\",{d:\"M15 3h6v6\",key:\"1q9fwt\"}],[\"path\",{d:\"M10 14 21 3\",key:\"gplh6r\"}],[\"path\",{d:\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\",key:\"a6xqqp\"}]],la=st(\"external-link\",Zfe);const Jfe=[[\"path\",{d:\"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49\",key:\"ct8e1f\"}],[\"path\",{d:\"M14.084 14.158a3 3 0 0 1-4.242-4.242\",key:\"151rxh\"}],[\"path\",{d:\"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143\",key:\"13bj9a\"}],[\"path\",{d:\"m2 2 20 20\",key:\"1ooewy\"}]],Og=st(\"eye-off\",Jfe);const ege=[[\"path\",{d:\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\",key:\"1nclc0\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"3\",key:\"1v7zrd\"}]],yl=st(\"eye\",ege);const tge=[[\"path\",{d:\"M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z\",key:\"484a7f\"}],[\"path\",{d:\"M12 12v.01\",key:\"u5ubse\"}]],RQ=st(\"fan\",tge);const nge=[[\"path\",{d:\"M14.5 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v3.8\",key:\"1kchwa\"}],[\"path\",{d:\"M14 2v5a1 1 0 0 0 1 1h5\",key:\"wfsgrz\"}],[\"path\",{d:\"M11.7 14.2 7 17l-4.7-2.8\",key:\"1yk8tc\"}],[\"path\",{d:\"M3 13.1a2 2 0 0 0-.999 1.76v3.24a2 2 0 0 0 .969 1.78L6 21.7a2 2 0 0 0 2.03.01L11 19.9a2 2 0 0 0 1-1.76V14.9a2 2 0 0 0-.97-1.78L8 11.3a2 2 0 0 0-2.03-.01z\",key:\"19flxy\"}],[\"path\",{d:\"M7 17v5\",key:\"1yj1jh\"}]],ep=st(\"file-box\",nge);const rge=[[\"path\",{d:\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\",key:\"1oefj6\"}],[\"path\",{d:\"M14 2v5a1 1 0 0 0 1 1h5\",key:\"wfsgrz\"}],[\"path\",{d:\"M10 12.5 8 15l2 2.5\",key:\"1tg20x\"}],[\"path\",{d:\"m14 12.5 2 2.5-2 2.5\",key:\"yinavb\"}]],yN=st(\"file-code\",rge);const age=[[\"path\",{d:\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\",key:\"1oefj6\"}],[\"path\",{d:\"M12 9v4\",key:\"juzpu7\"}],[\"path\",{d:\"M12 17h.01\",key:\"p32p05\"}]],ige=st(\"file-exclamation-point\",age);const sge=[[\"path\",{d:\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\",key:\"1oefj6\"}],[\"path\",{d:\"M14 2v5a1 1 0 0 0 1 1h5\",key:\"wfsgrz\"}],[\"path\",{d:\"M8 13h2\",key:\"yr2amv\"}],[\"path\",{d:\"M14 13h2\",key:\"un5t4a\"}],[\"path\",{d:\"M8 17h2\",key:\"2yhykz\"}],[\"path\",{d:\"M14 17h2\",key:\"10kma7\"}]],vN=st(\"file-spreadsheet\",sge);const oge=[[\"path\",{d:\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\",key:\"1oefj6\"}],[\"path\",{d:\"M14 2v5a1 1 0 0 0 1 1h5\",key:\"wfsgrz\"}],[\"path\",{d:\"M10 9H8\",key:\"b1mrlr\"}],[\"path\",{d:\"M16 13H8\",key:\"t4e002\"}],[\"path\",{d:\"M16 17H8\",key:\"z1uh3a\"}]],Us=st(\"file-text\",oge);const lge=[[\"path\",{d:\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\",key:\"1oefj6\"}],[\"path\",{d:\"M14 2v5a1 1 0 0 0 1 1h5\",key:\"wfsgrz\"}]],qP=st(\"file\",lge);const cge=[[\"rect\",{width:\"18\",height:\"18\",x:\"3\",y:\"3\",rx:\"2\",key:\"afitv7\"}],[\"path\",{d:\"M7 3v18\",key:\"bbkbws\"}],[\"path\",{d:\"M3 7.5h4\",key:\"zfgn84\"}],[\"path\",{d:\"M3 12h18\",key:\"1i2n21\"}],[\"path\",{d:\"M3 16.5h4\",key:\"1230mu\"}],[\"path\",{d:\"M17 3v18\",key:\"in4fa5\"}],[\"path\",{d:\"M17 7.5h4\",key:\"myr1c1\"}],[\"path\",{d:\"M17 16.5h4\",key:\"go4c1d\"}]],tp=st(\"film\",cge);const dge=[[\"path\",{d:\"M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4\",key:\"1slcih\"}]],Hu=st(\"flame\",dge);const uge=[[\"circle\",{cx:\"15\",cy:\"19\",r:\"2\",key:\"u2pros\"}],[\"path\",{d:\"M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1\",key:\"1jj40k\"}],[\"path\",{d:\"M15 11v-1\",key:\"cntcp\"}],[\"path\",{d:\"M15 17v-2\",key:\"1279jj\"}]],mge=st(\"folder-archive\",uge);const hge=[[\"path\",{d:\"M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z\",key:\"1fr9dc\"}],[\"path\",{d:\"M8 10v4\",key:\"tgpxqk\"}],[\"path\",{d:\"M12 10v2\",key:\"hh53o1\"}],[\"path\",{d:\"M16 10v6\",key:\"1d6xys\"}]],Bs=st(\"folder-kanban\",hge);const pge=[[\"path\",{d:\"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2\",key:\"usdka0\"}]],Qc=st(\"folder-open\",pge);const fge=[[\"path\",{d:\"M12 10v6\",key:\"1bos4e\"}],[\"path\",{d:\"M9 13h6\",key:\"1uhe8q\"}],[\"path\",{d:\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\",key:\"1kt360\"}]],gge=st(\"folder-plus\",fge);const bge=[[\"path\",{d:\"M2 9.35V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h7\",key:\"y8kt7d\"}],[\"path\",{d:\"m8 16 3-3-3-3\",key:\"rlqrt1\"}]],wN=st(\"folder-symlink\",bge);const xge=[[\"path\",{d:\"M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z\",key:\"hod4my\"}],[\"path\",{d:\"M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z\",key:\"w4yl2u\"}],[\"path\",{d:\"M3 5a2 2 0 0 0 2 2h3\",key:\"f2jnh7\"}],[\"path\",{d:\"M3 3v13a2 2 0 0 0 2 2h3\",key:\"k8epm1\"}]],yge=st(\"folder-tree\",xge);const vge=[[\"path\",{d:\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\",key:\"1kt360\"}]],LQ=st(\"folder\",vge);const wge=[[\"path\",{d:\"M3 7V5a2 2 0 0 1 2-2h2\",key:\"aa7l1z\"}],[\"path\",{d:\"M17 3h2a2 2 0 0 1 2 2v2\",key:\"4qcy5o\"}],[\"path\",{d:\"M21 17v2a2 2 0 0 1-2 2h-2\",key:\"6vwrx8\"}],[\"path\",{d:\"M7 21H5a2 2 0 0 1-2-2v-2\",key:\"ioqczr\"}],[\"rect\",{width:\"10\",height:\"8\",x:\"7\",y:\"8\",rx:\"1\",key:\"vys8me\"}]],Sge=st(\"fullscreen\",wge);const _ge=[[\"path\",{d:\"M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z\",key:\"sc7q7i\"}]],$P=st(\"funnel\",_ge);const kge=[[\"path\",{d:\"m12 14 4-4\",key:\"9kzdfg\"}],[\"path\",{d:\"M3.34 19a10 10 0 1 1 17.32 0\",key:\"19p75a\"}]],Zw=st(\"gauge\",kge);const Nge=[[\"rect\",{x:\"3\",y:\"8\",width:\"18\",height:\"4\",rx:\"1\",key:\"bkv52\"}],[\"path\",{d:\"M12 8v13\",key:\"1c76mn\"}],[\"path\",{d:\"M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7\",key:\"6wjy6b\"}],[\"path\",{d:\"M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5\",key:\"1ihvrl\"}]],Cge=st(\"gift\",Nge);const Pge=[[\"line\",{x1:\"6\",x2:\"6\",y1:\"3\",y2:\"15\",key:\"17qcm7\"}],[\"circle\",{cx:\"18\",cy:\"6\",r:\"3\",key:\"1h7g24\"}],[\"circle\",{cx:\"6\",cy:\"18\",r:\"3\",key:\"fqmcym\"}],[\"path\",{d:\"M18 9a9 9 0 0 1-9 9\",key:\"n2h4wq\"}]],OQ=st(\"git-branch\",Pge);const Tge=[[\"circle\",{cx:\"18\",cy:\"18\",r:\"3\",key:\"1xkwt0\"}],[\"circle\",{cx:\"6\",cy:\"6\",r:\"3\",key:\"1lh9wr\"}],[\"path\",{d:\"M13 6h3a2 2 0 0 1 2 2v7\",key:\"1yeb86\"}],[\"path\",{d:\"M11 18H8a2 2 0 0 1-2-2V9\",key:\"19pyzm\"}]],Cx=st(\"git-compare\",Tge);const Age=[[\"path\",{d:\"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4\",key:\"tonef\"}],[\"path\",{d:\"M9 18c-4.51 2-5-2-7-2\",key:\"9comsn\"}]],aF=st(\"github\",Age);const jge=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"path\",{d:\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\",key:\"13o1zl\"}],[\"path\",{d:\"M2 12h20\",key:\"9i4pu4\"}]],ul=st(\"globe\",jge);const Mge=[[\"circle\",{cx:\"9\",cy:\"12\",r:\"1\",key:\"1vctgf\"}],[\"circle\",{cx:\"9\",cy:\"5\",r:\"1\",key:\"hp0tcf\"}],[\"circle\",{cx:\"9\",cy:\"19\",r:\"1\",key:\"fkjjf6\"}],[\"circle\",{cx:\"15\",cy:\"12\",r:\"1\",key:\"1tmaij\"}],[\"circle\",{cx:\"15\",cy:\"5\",r:\"1\",key:\"19l28e\"}],[\"circle\",{cx:\"15\",cy:\"19\",r:\"1\",key:\"f4zoj3\"}]],np=st(\"grip-vertical\",Mge);const Ege=[[\"path\",{d:\"M3 7V5c0-1.1.9-2 2-2h2\",key:\"adw53z\"}],[\"path\",{d:\"M17 3h2c1.1 0 2 .9 2 2v2\",key:\"an4l38\"}],[\"path\",{d:\"M21 17v2c0 1.1-.9 2-2 2h-2\",key:\"144t0e\"}],[\"path\",{d:\"M7 21H5c-1.1 0-2-.9-2-2v-2\",key:\"rtnfgi\"}],[\"rect\",{width:\"7\",height:\"5\",x:\"7\",y:\"7\",rx:\"1\",key:\"1eyiv7\"}],[\"rect\",{width:\"7\",height:\"5\",x:\"10\",y:\"12\",rx:\"1\",key:\"1qlmkx\"}]],Dge=st(\"group\",Ege);const Fge=[[\"path\",{d:\"M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2\",key:\"1fvzgz\"}],[\"path\",{d:\"M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2\",key:\"1kc0my\"}],[\"path\",{d:\"M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8\",key:\"10h0bg\"}],[\"path\",{d:\"M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15\",key:\"1s1gnw\"}]],IQ=st(\"hand\",Fge);const Rge=[[\"line\",{x1:\"22\",x2:\"2\",y1:\"12\",y2:\"12\",key:\"1y58io\"}],[\"path\",{d:\"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z\",key:\"oot6mr\"}],[\"line\",{x1:\"6\",x2:\"6.01\",y1:\"16\",y2:\"16\",key:\"sgf278\"}],[\"line\",{x1:\"10\",x2:\"10.01\",y1:\"16\",y2:\"16\",key:\"1l4acy\"}]],Ig=st(\"hard-drive\",Rge);const Lge=[[\"line\",{x1:\"4\",x2:\"20\",y1:\"9\",y2:\"9\",key:\"4lhtct\"}],[\"line\",{x1:\"4\",x2:\"20\",y1:\"15\",y2:\"15\",key:\"vyu0kd\"}],[\"line\",{x1:\"10\",x2:\"8\",y1:\"3\",y2:\"21\",key:\"1ggp8o\"}],[\"line\",{x1:\"16\",x2:\"14\",y1:\"3\",y2:\"21\",key:\"weycgp\"}]],Oge=st(\"hash\",Lge);const Ige=[[\"path\",{d:\"M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3\",key:\"1xhozi\"}]],zge=st(\"headphones\",Ige);const Uge=[[\"path\",{d:\"M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5\",key:\"mvr1a0\"}]],Bge=st(\"heart\",Uge);const Hge=[[\"path\",{d:\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\",key:\"1357e3\"}],[\"path\",{d:\"M3 3v5h5\",key:\"1xhq8a\"}],[\"path\",{d:\"M12 7v5l4 2\",key:\"1fdv2h\"}]],iw=st(\"history\",Hge);const qge=[[\"path\",{d:\"M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8\",key:\"5wwlr5\"}],[\"path\",{d:\"M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\",key:\"r6nss1\"}]],Jw=st(\"house\",qge);const $ge=[[\"rect\",{width:\"18\",height:\"18\",x:\"3\",y:\"3\",rx:\"2\",ry:\"2\",key:\"1m3agn\"}],[\"circle\",{cx:\"9\",cy:\"9\",r:\"2\",key:\"af1f0g\"}],[\"path\",{d:\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\",key:\"1xmnt7\"}]],gm=st(\"image\",$ge);const Vge=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"path\",{d:\"M12 16v-4\",key:\"1dtifu\"}],[\"path\",{d:\"M12 8h.01\",key:\"e9boi3\"}]],Ss=st(\"info\",Vge);const Gge=[[\"line\",{x1:\"19\",x2:\"10\",y1:\"4\",y2:\"4\",key:\"15jd3p\"}],[\"line\",{x1:\"14\",x2:\"5\",y1:\"20\",y2:\"20\",key:\"bu0au3\"}],[\"line\",{x1:\"15\",x2:\"9\",y1:\"4\",y2:\"20\",key:\"uljnxc\"}]],Wge=st(\"italic\",Gge);const Kge=[[\"path\",{d:\"m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4\",key:\"g0fldk\"}],[\"path\",{d:\"m21 2-9.6 9.6\",key:\"1j0ho8\"}],[\"circle\",{cx:\"7.5\",cy:\"15.5\",r:\"5.5\",key:\"yqb3hr\"}]],no=st(\"key\",Kge);const Xge=[[\"path\",{d:\"M10 8h.01\",key:\"1r9ogq\"}],[\"path\",{d:\"M12 12h.01\",key:\"1mp3jc\"}],[\"path\",{d:\"M14 8h.01\",key:\"1primd\"}],[\"path\",{d:\"M16 12h.01\",key:\"1l6xoz\"}],[\"path\",{d:\"M18 8h.01\",key:\"emo2bl\"}],[\"path\",{d:\"M6 8h.01\",key:\"x9i8wu\"}],[\"path\",{d:\"M7 16h10\",key:\"wp8him\"}],[\"path\",{d:\"M8 12h.01\",key:\"czm47f\"}],[\"rect\",{width:\"20\",height:\"16\",x:\"2\",y:\"4\",rx:\"2\",key:\"18n3k1\"}]],iF=st(\"keyboard\",Xge);const Yge=[[\"path\",{d:\"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z\",key:\"zw3jo\"}],[\"path\",{d:\"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12\",key:\"1wduqc\"}],[\"path\",{d:\"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17\",key:\"kqbvx6\"}]],da=st(\"layers\",Yge);const Qge=[[\"rect\",{width:\"7\",height:\"7\",x:\"3\",y:\"3\",rx:\"1\",key:\"1g98yp\"}],[\"rect\",{width:\"7\",height:\"7\",x:\"14\",y:\"3\",rx:\"1\",key:\"6d4xhi\"}],[\"rect\",{width:\"7\",height:\"7\",x:\"14\",y:\"14\",rx:\"1\",key:\"nxv5o0\"}],[\"rect\",{width:\"7\",height:\"7\",x:\"3\",y:\"14\",rx:\"1\",key:\"1bb6yr\"}]],eS=st(\"layout-grid\",Qge);const Zge=[[\"path\",{d:\"M9 17H7A5 5 0 0 1 7 7\",key:\"10o201\"}],[\"path\",{d:\"M15 7h2a5 5 0 0 1 4 8\",key:\"1d3206\"}],[\"line\",{x1:\"8\",x2:\"12\",y1:\"12\",y2:\"12\",key:\"rvw6j4\"}],[\"line\",{x1:\"2\",x2:\"22\",y1:\"2\",y2:\"22\",key:\"a6p6uj\"}]],Jge=st(\"link-2-off\",Zge);const ebe=[[\"path\",{d:\"M9 17H7A5 5 0 0 1 7 7h2\",key:\"8i5ue5\"}],[\"path\",{d:\"M15 7h2a5 5 0 1 1 0 10h-2\",key:\"1b9ql8\"}],[\"line\",{x1:\"8\",x2:\"16\",y1:\"12\",y2:\"12\",key:\"1jonct\"}]],sm=st(\"link-2\",ebe);const tbe=[[\"path\",{d:\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\",key:\"1cjeqo\"}],[\"path\",{d:\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\",key:\"19qd67\"}]],Sy=st(\"link\",tbe);const nbe=[[\"path\",{d:\"M11 5h10\",key:\"1cz7ny\"}],[\"path\",{d:\"M11 12h10\",key:\"1438ji\"}],[\"path\",{d:\"M11 19h10\",key:\"11t30w\"}],[\"path\",{d:\"M4 4h1v5\",key:\"10yrso\"}],[\"path\",{d:\"M4 9h2\",key:\"r1h2o0\"}],[\"path\",{d:\"M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02\",key:\"xtkcd5\"}]],SN=st(\"list-ordered\",nbe);const rbe=[[\"path\",{d:\"M13 5h8\",key:\"a7qcls\"}],[\"path\",{d:\"M13 12h8\",key:\"h98zly\"}],[\"path\",{d:\"M13 19h8\",key:\"c3s6r1\"}],[\"path\",{d:\"m3 17 2 2 4-4\",key:\"1jhpwq\"}],[\"rect\",{x:\"3\",y:\"4\",width:\"6\",height:\"6\",rx:\"1\",key:\"cif1o7\"}]],sF=st(\"list-todo\",rbe);const abe=[[\"path\",{d:\"M3 5h.01\",key:\"18ugdj\"}],[\"path\",{d:\"M3 12h.01\",key:\"nlz23k\"}],[\"path\",{d:\"M3 19h.01\",key:\"noohij\"}],[\"path\",{d:\"M8 5h13\",key:\"1pao27\"}],[\"path\",{d:\"M8 12h13\",key:\"1za7za\"}],[\"path\",{d:\"M8 19h13\",key:\"m83p4d\"}]],tS=st(\"list\",abe);const ibe=[[\"path\",{d:\"M21 12a9 9 0 1 1-6.219-8.56\",key:\"13zald\"}]],ft=st(\"loader-circle\",ibe);const sbe=[[\"rect\",{width:\"18\",height:\"11\",x:\"3\",y:\"11\",rx:\"2\",ry:\"2\",key:\"1w4ew1\"}],[\"path\",{d:\"M7 11V7a5 5 0 0 1 9.9-1\",key:\"1mm8w8\"}]],A0=st(\"lock-open\",sbe);const obe=[[\"rect\",{width:\"18\",height:\"11\",x:\"3\",y:\"11\",rx:\"2\",ry:\"2\",key:\"1w4ew1\"}],[\"path\",{d:\"M7 11V7a5 5 0 0 1 10 0v4\",key:\"fwvmzm\"}]],kd=st(\"lock\",obe);const lbe=[[\"path\",{d:\"m10 17 5-5-5-5\",key:\"1bsop3\"}],[\"path\",{d:\"M15 12H3\",key:\"6jk70r\"}],[\"path\",{d:\"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4\",key:\"u53s6r\"}]],kH=st(\"log-in\",lbe);const cbe=[[\"path\",{d:\"m16 17 5-5-5-5\",key:\"1bji2h\"}],[\"path\",{d:\"M21 12H9\",key:\"dn1m92\"}],[\"path\",{d:\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\",key:\"1uf3rs\"}]],oF=st(\"log-out\",cbe);const dbe=[[\"path\",{d:\"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7\",key:\"132q7q\"}],[\"rect\",{x:\"2\",y:\"4\",width:\"20\",height:\"16\",rx:\"2\",key:\"izxlao\"}]],bm=st(\"mail\",dbe);const ube=[[\"path\",{d:\"M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z\",key:\"169xi5\"}],[\"path\",{d:\"M15 5.764v15\",key:\"1pn4in\"}],[\"path\",{d:\"M9 3.236v15\",key:\"1uimfh\"}]],mbe=st(\"map\",ube);const hbe=[[\"path\",{d:\"M15 3h6v6\",key:\"1q9fwt\"}],[\"path\",{d:\"m21 3-7 7\",key:\"1l2asr\"}],[\"path\",{d:\"m3 21 7-7\",key:\"tjx5ai\"}],[\"path\",{d:\"M9 21H3v-6\",key:\"wtvkvv\"}]],VP=st(\"maximize-2\",hbe);const pbe=[[\"path\",{d:\"M8 3H5a2 2 0 0 0-2 2v3\",key:\"1dcmit\"}],[\"path\",{d:\"M21 8V5a2 2 0 0 0-2-2h-3\",key:\"1e4gt3\"}],[\"path\",{d:\"M3 16v3a2 2 0 0 0 2 2h3\",key:\"wsl5sc\"}],[\"path\",{d:\"M16 21h3a2 2 0 0 0 2-2v-3\",key:\"18trek\"}]],fbe=st(\"maximize\",pbe);const gbe=[[\"path\",{d:\"M6 19v-3\",key:\"1nvgqn\"}],[\"path\",{d:\"M10 19v-3\",key:\"iu8nkm\"}],[\"path\",{d:\"M14 19v-3\",key:\"kcehxu\"}],[\"path\",{d:\"M18 19v-3\",key:\"1vh91z\"}],[\"path\",{d:\"M8 11V9\",key:\"63erz4\"}],[\"path\",{d:\"M16 11V9\",key:\"fru6f3\"}],[\"path\",{d:\"M12 11V9\",key:\"ha00sb\"}],[\"path\",{d:\"M2 15h20\",key:\"16ne18\"}],[\"path\",{d:\"M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z\",key:\"lhddv3\"}]],bbe=st(\"memory-stick\",gbe);const xbe=[[\"path\",{d:\"M4 5h16\",key:\"1tepv9\"}],[\"path\",{d:\"M4 12h16\",key:\"1lakjw\"}],[\"path\",{d:\"M4 19h16\",key:\"1djgab\"}]],ybe=st(\"menu\",xbe);const vbe=[[\"path\",{d:\"m14 10 7-7\",key:\"oa77jy\"}],[\"path\",{d:\"M20 10h-6V4\",key:\"mjg0md\"}],[\"path\",{d:\"m3 21 7-7\",key:\"tjx5ai\"}],[\"path\",{d:\"M4 14h6v6\",key:\"rmj7iw\"}]],DO=st(\"minimize-2\",vbe);const wbe=[[\"path\",{d:\"M8 3v3a2 2 0 0 1-2 2H3\",key:\"hohbtr\"}],[\"path\",{d:\"M21 8h-3a2 2 0 0 1-2-2V3\",key:\"5jw1f3\"}],[\"path\",{d:\"M3 16h3a2 2 0 0 1 2 2v3\",key:\"198tvr\"}],[\"path\",{d:\"M16 21v-3a2 2 0 0 1 2-2h3\",key:\"ph8mxp\"}]],zQ=st(\"minimize\",wbe);const Sbe=[[\"path\",{d:\"M5 12h14\",key:\"1ays0h\"}]],_N=st(\"minus\",Sbe);const _be=[[\"rect\",{width:\"20\",height:\"14\",x:\"2\",y:\"3\",rx:\"2\",key:\"48i651\"}],[\"line\",{x1:\"8\",x2:\"16\",y1:\"21\",y2:\"21\",key:\"1svkeh\"}],[\"line\",{x1:\"12\",x2:\"12\",y1:\"17\",y2:\"21\",key:\"vw1qmm\"}]],FO=st(\"monitor\",_be);const kbe=[[\"path\",{d:\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\",key:\"kfwtm\"}]],kN=st(\"moon\",kbe);const Nbe=[[\"path\",{d:\"M18 8L22 12L18 16\",key:\"1r0oui\"}],[\"path\",{d:\"M2 12H22\",key:\"1m8cig\"}]],Cbe=st(\"move-right\",Nbe);const Pbe=[[\"path\",{d:\"M12 2v20\",key:\"t6zp3m\"}],[\"path\",{d:\"m8 18 4 4 4-4\",key:\"bh5tu3\"}],[\"path\",{d:\"m8 6 4-4 4 4\",key:\"ybng9g\"}]],Tbe=st(\"move-vertical\",Pbe);const Abe=[[\"path\",{d:\"M9 18V5l12-2v13\",key:\"1jmyc2\"}],[\"circle\",{cx:\"6\",cy:\"18\",r:\"3\",key:\"fqmcym\"}],[\"circle\",{cx:\"18\",cy:\"16\",r:\"3\",key:\"1hluhg\"}]],lF=st(\"music\",Abe);const jbe=[[\"path\",{d:\"M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z\",key:\"1a0edw\"}],[\"path\",{d:\"M12 22V12\",key:\"d0xqtd\"}],[\"polyline\",{points:\"3.29 7 12 12 20.71 7\",key:\"ousv84\"}],[\"path\",{d:\"m7.5 4.27 9 5.15\",key:\"1c824w\"}]],Ra=st(\"package\",jbe);const Mbe=[[\"path\",{d:\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\",key:\"e79jfc\"}],[\"circle\",{cx:\"13.5\",cy:\"6.5\",r:\".5\",fill:\"currentColor\",key:\"1okk4w\"}],[\"circle\",{cx:\"17.5\",cy:\"10.5\",r:\".5\",fill:\"currentColor\",key:\"f64h9f\"}],[\"circle\",{cx:\"6.5\",cy:\"12.5\",r:\".5\",fill:\"currentColor\",key:\"qy21gx\"}],[\"circle\",{cx:\"8.5\",cy:\"7.5\",r:\".5\",fill:\"currentColor\",key:\"fotxhn\"}]],nS=st(\"palette\",Mbe);const Ebe=[[\"rect\",{x:\"14\",y:\"3\",width:\"5\",height:\"18\",rx:\"1\",key:\"kaeet6\"}],[\"rect\",{x:\"5\",y:\"3\",width:\"5\",height:\"18\",rx:\"1\",key:\"1wsw3u\"}]],rS=st(\"pause\",Ebe);const Dbe=[[\"path\",{d:\"M13 21h8\",key:\"1jsn5i\"}],[\"path\",{d:\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\",key:\"1a8usu\"}]],dg=st(\"pen-line\",Dbe);const Fbe=[[\"path\",{d:\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\",key:\"1a8usu\"}]],Qu=st(\"pen\",Fbe);const Rbe=[[\"path\",{d:\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\",key:\"1a8usu\"}],[\"path\",{d:\"m15 5 4 4\",key:\"1mk7zo\"}]],ci=st(\"pencil\",Rbe);const Lbe=[[\"path\",{d:\"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384\",key:\"9njp5v\"}]],Obe=st(\"phone\",Lbe);const Ibe=[[\"path\",{d:\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\",key:\"10ikf1\"}]],es=st(\"play\",Ibe);const zbe=[[\"path\",{d:\"M12 22v-5\",key:\"1ega77\"}],[\"path\",{d:\"M9 8V2\",key:\"14iosj\"}],[\"path\",{d:\"M15 8V2\",key:\"18g5xt\"}],[\"path\",{d:\"M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z\",key:\"osxo6l\"}]],nc=st(\"plug\",zbe);const Ube=[[\"path\",{d:\"M5 12h14\",key:\"1ays0h\"}],[\"path\",{d:\"M12 5v14\",key:\"s699le\"}]],sr=st(\"plus\",Ube);const Bbe=[[\"path\",{d:\"M18.36 6.64A9 9 0 0 1 20.77 15\",key:\"dxknvb\"}],[\"path\",{d:\"M6.16 6.16a9 9 0 1 0 12.68 12.68\",key:\"1x7qb5\"}],[\"path\",{d:\"M12 2v4\",key:\"3427ic\"}],[\"path\",{d:\"m2 2 20 20\",key:\"1ooewy\"}]],aS=st(\"power-off\",Bbe);const Hbe=[[\"path\",{d:\"M12 2v10\",key:\"mnfbl\"}],[\"path\",{d:\"M18.4 6.6a9 9 0 1 1-12.77.04\",key:\"obofu9\"}]],Yd=st(\"power\",Hbe);const qbe=[[\"path\",{d:\"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2\",key:\"143wyd\"}],[\"path\",{d:\"M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6\",key:\"1itne7\"}],[\"rect\",{x:\"6\",y:\"14\",width:\"12\",height:\"8\",rx:\"1\",key:\"1ue0tg\"}]],Er=st(\"printer\",qbe);const $be=[[\"rect\",{width:\"5\",height:\"5\",x:\"3\",y:\"3\",rx:\"1\",key:\"1tu5fj\"}],[\"rect\",{width:\"5\",height:\"5\",x:\"16\",y:\"3\",rx:\"1\",key:\"1v8r4q\"}],[\"rect\",{width:\"5\",height:\"5\",x:\"3\",y:\"16\",rx:\"1\",key:\"1x03jg\"}],[\"path\",{d:\"M21 16h-3a2 2 0 0 0-2 2v3\",key:\"177gqh\"}],[\"path\",{d:\"M21 21v.01\",key:\"ents32\"}],[\"path\",{d:\"M12 7v3a2 2 0 0 1-2 2H7\",key:\"8crl2c\"}],[\"path\",{d:\"M3 12h.01\",key:\"nlz23k\"}],[\"path\",{d:\"M12 3h.01\",key:\"n36tog\"}],[\"path\",{d:\"M12 16v.01\",key:\"133mhm\"}],[\"path\",{d:\"M16 12h1\",key:\"1slzba\"}],[\"path\",{d:\"M21 12v.01\",key:\"1lwtk9\"}],[\"path\",{d:\"M12 21v-1\",key:\"1880an\"}]],UQ=st(\"qr-code\",$be);const Vbe=[[\"path\",{d:\"M16.247 7.761a6 6 0 0 1 0 8.478\",key:\"1fwjs5\"}],[\"path\",{d:\"M19.075 4.933a10 10 0 0 1 0 14.134\",key:\"ehdyv1\"}],[\"path\",{d:\"M4.925 19.067a10 10 0 0 1 0-14.134\",key:\"1q22gi\"}],[\"path\",{d:\"M7.753 16.239a6 6 0 0 1 0-8.478\",key:\"r2q7qm\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"2\",key:\"1c9p78\"}]],RO=st(\"radio\",Vbe);const Gbe=[[\"path\",{d:\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\",key:\"v9h5vc\"}],[\"path\",{d:\"M21 3v5h-5\",key:\"1q7to0\"}],[\"path\",{d:\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\",key:\"3uifl3\"}],[\"path\",{d:\"M8 16H3v5\",key:\"1cv678\"}]],lr=st(\"refresh-cw\",Gbe);const Wbe=[[\"path\",{d:\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\",key:\"1357e3\"}],[\"path\",{d:\"M3 3v5h5\",key:\"1xhq8a\"}]],co=st(\"rotate-ccw\",Wbe);const Kbe=[[\"path\",{d:\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\",key:\"1p45f6\"}],[\"path\",{d:\"M21 3v5h-5\",key:\"1q7to0\"}]],sw=st(\"rotate-cw\",Kbe);const Xbe=[[\"path\",{d:\"M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z\",key:\"icamh8\"}],[\"path\",{d:\"m14.5 12.5 2-2\",key:\"inckbg\"}],[\"path\",{d:\"m11.5 9.5 2-2\",key:\"fmmyf7\"}],[\"path\",{d:\"m8.5 6.5 2-2\",key:\"vc6u1g\"}],[\"path\",{d:\"m17.5 15.5 2-2\",key:\"wo5hmg\"}]],Ybe=st(\"ruler\",Xbe);const Qbe=[[\"path\",{d:\"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z\",key:\"1c8476\"}],[\"path\",{d:\"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7\",key:\"1ydtos\"}],[\"path\",{d:\"M7 3v4a1 1 0 0 0 1 1h7\",key:\"t51u73\"}]],_s=st(\"save\",Qbe);const Zbe=[[\"path\",{d:\"M12 3v18\",key:\"108xh3\"}],[\"path\",{d:\"m19 8 3 8a5 5 0 0 1-6 0zV7\",key:\"zcdpyk\"}],[\"path\",{d:\"M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1\",key:\"1yorad\"}],[\"path\",{d:\"m5 8 3 8a5 5 0 0 1-6 0zV7\",key:\"eua70x\"}],[\"path\",{d:\"M7 21h10\",key:\"1b0cd5\"}]],BQ=st(\"scale\",Zbe);const Jbe=[[\"path\",{d:\"M3 7V5a2 2 0 0 1 2-2h2\",key:\"aa7l1z\"}],[\"path\",{d:\"M17 3h2a2 2 0 0 1 2 2v2\",key:\"4qcy5o\"}],[\"path\",{d:\"M21 17v2a2 2 0 0 1-2 2h-2\",key:\"6vwrx8\"}],[\"path\",{d:\"M7 21H5a2 2 0 0 1-2-2v-2\",key:\"ioqczr\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"1\",key:\"41hilf\"}],[\"path\",{d:\"M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0\",key:\"11ak4c\"}]],HQ=st(\"scan-eye\",Jbe);const exe=[[\"path\",{d:\"M3 7V5a2 2 0 0 1 2-2h2\",key:\"aa7l1z\"}],[\"path\",{d:\"M17 3h2a2 2 0 0 1 2 2v2\",key:\"4qcy5o\"}],[\"path\",{d:\"M21 17v2a2 2 0 0 1-2 2h-2\",key:\"6vwrx8\"}],[\"path\",{d:\"M7 21H5a2 2 0 0 1-2-2v-2\",key:\"ioqczr\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"3\",key:\"1v7zrd\"}],[\"path\",{d:\"m16 16-1.9-1.9\",key:\"1dq9hf\"}]],NN=st(\"scan-search\",exe);const txe=[[\"circle\",{cx:\"6\",cy:\"6\",r:\"3\",key:\"1lh9wr\"}],[\"path\",{d:\"M8.12 8.12 12 12\",key:\"1alkpv\"}],[\"path\",{d:\"M20 4 8.12 15.88\",key:\"xgtan2\"}],[\"circle\",{cx:\"6\",cy:\"18\",r:\"3\",key:\"fqmcym\"}],[\"path\",{d:\"M14.8 14.8 20 20\",key:\"ptml3r\"}]],nxe=st(\"scissors\",txe);const rxe=[[\"path\",{d:\"m21 21-4.34-4.34\",key:\"14j7rj\"}],[\"circle\",{cx:\"11\",cy:\"11\",r:\"8\",key:\"4ej97u\"}]],Pr=st(\"search\",rxe);const axe=[[\"path\",{d:\"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z\",key:\"1ffxy3\"}],[\"path\",{d:\"m21.854 2.147-10.94 10.939\",key:\"12cjpa\"}]],iS=st(\"send\",axe);const ixe=[[\"rect\",{width:\"20\",height:\"8\",x:\"2\",y:\"2\",rx:\"2\",ry:\"2\",key:\"ngkwjq\"}],[\"rect\",{width:\"20\",height:\"8\",x:\"2\",y:\"14\",rx:\"2\",ry:\"2\",key:\"iecqi9\"}],[\"line\",{x1:\"6\",x2:\"6.01\",y1:\"6\",y2:\"6\",key:\"16zg32\"}],[\"line\",{x1:\"6\",x2:\"6.01\",y1:\"18\",y2:\"18\",key:\"nzw8ys\"}]],Sf=st(\"server\",ixe);const sxe=[[\"path\",{d:\"M14 17H5\",key:\"gfn3mx\"}],[\"path\",{d:\"M19 7h-9\",key:\"6i9tg\"}],[\"circle\",{cx:\"17\",cy:\"17\",r:\"3\",key:\"18b49y\"}],[\"circle\",{cx:\"7\",cy:\"7\",r:\"3\",key:\"dfmy0x\"}]],Ud=st(\"settings-2\",sxe);const oxe=[[\"path\",{d:\"M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915\",key:\"1i5ecw\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"3\",key:\"1v7zrd\"}]],sS=st(\"settings\",oxe);const lxe=[[\"path\",{d:\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\",key:\"oel41y\"}],[\"path\",{d:\"M12 8v4\",key:\"1got3b\"}],[\"path\",{d:\"M12 16h.01\",key:\"1drbdi\"}]],cxe=st(\"shield-alert\",lxe);const dxe=[[\"path\",{d:\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\",key:\"oel41y\"}],[\"path\",{d:\"m9 12 2 2 4-4\",key:\"dzmm74\"}]],NH=st(\"shield-check\",dxe);const uxe=[[\"path\",{d:\"m2 2 20 20\",key:\"1ooewy\"}],[\"path\",{d:\"M5 5a1 1 0 0 0-1 1v7c0 5 3.5 7.5 7.67 8.94a1 1 0 0 0 .67.01c2.35-.82 4.48-1.97 5.9-3.71\",key:\"1jlk70\"}],[\"path\",{d:\"M9.309 3.652A12.252 12.252 0 0 0 11.24 2.28a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1v7a9.784 9.784 0 0 1-.08 1.264\",key:\"18rp1v\"}]],CH=st(\"shield-off\",uxe);const mxe=[[\"path\",{d:\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\",key:\"oel41y\"}]],Gu=st(\"shield\",mxe);const hxe=[[\"circle\",{cx:\"8\",cy:\"21\",r:\"1\",key:\"jimo8o\"}],[\"circle\",{cx:\"19\",cy:\"21\",r:\"1\",key:\"13723u\"}],[\"path\",{d:\"M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12\",key:\"9zh506\"}]],qQ=st(\"shopping-cart\",hxe);const pxe=[[\"path\",{d:\"M2 20h.01\",key:\"4haj6o\"}],[\"path\",{d:\"M7 20v-4\",key:\"j294jx\"}],[\"path\",{d:\"M12 20v-8\",key:\"i3yub9\"}],[\"path\",{d:\"M17 20V8\",key:\"1tkaf5\"}],[\"path\",{d:\"M22 4v16\",key:\"sih9yq\"}]],$Q=st(\"signal\",pxe);const fxe=[[\"path\",{d:\"M17.971 4.285A2 2 0 0 1 21 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432z\",key:\"15892j\"}],[\"path\",{d:\"M3 20V4\",key:\"1ptbpl\"}]],gxe=st(\"skip-back\",fxe);const bxe=[[\"path\",{d:\"M21 4v16\",key:\"7j8fe9\"}],[\"path\",{d:\"M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z\",key:\"zs4d6\"}]],GP=st(\"skip-forward\",bxe);const xxe=[[\"path\",{d:\"M10 8h4\",key:\"1sr2af\"}],[\"path\",{d:\"M12 21v-9\",key:\"17s77i\"}],[\"path\",{d:\"M12 8V3\",key:\"13r4qs\"}],[\"path\",{d:\"M17 16h4\",key:\"h1uq16\"}],[\"path\",{d:\"M19 12V3\",key:\"o1uvq1\"}],[\"path\",{d:\"M19 21v-5\",key:\"qua636\"}],[\"path\",{d:\"M3 14h4\",key:\"bcjad9\"}],[\"path\",{d:\"M5 10V3\",key:\"cb8scm\"}],[\"path\",{d:\"M5 21v-7\",key:\"1w1uti\"}]],PH=st(\"sliders-vertical\",xxe);const yxe=[[\"rect\",{width:\"14\",height:\"20\",x:\"5\",y:\"2\",rx:\"2\",ry:\"2\",key:\"1yt0o3\"}],[\"path\",{d:\"M12 18h.01\",key:\"mhygvu\"}]],cF=st(\"smartphone\",yxe);const vxe=[[\"path\",{d:\"M2 13a6 6 0 1 0 12 0 4 4 0 1 0-8 0 2 2 0 0 0 4 0\",key:\"hneq2s\"}],[\"circle\",{cx:\"10\",cy:\"13\",r:\"8\",key:\"194lz3\"}],[\"path\",{d:\"M2 21h12c4.4 0 8-3.6 8-8V7a2 2 0 1 0-4 0v6\",key:\"ixqyt7\"}],[\"path\",{d:\"M18 3 19.1 5.2\",key:\"9tjm43\"}],[\"path\",{d:\"M22 3 20.9 5.2\",key:\"j3odrs\"}]],wxe=st(\"snail\",vxe);const Sxe=[[\"path\",{d:\"m10 20-1.25-2.5L6 18\",key:\"18frcb\"}],[\"path\",{d:\"M10 4 8.75 6.5 6 6\",key:\"7mghy3\"}],[\"path\",{d:\"m14 20 1.25-2.5L18 18\",key:\"1chtki\"}],[\"path\",{d:\"m14 4 1.25 2.5L18 6\",key:\"1b4wsy\"}],[\"path\",{d:\"m17 21-3-6h-4\",key:\"15hhxa\"}],[\"path\",{d:\"m17 3-3 6 1.5 3\",key:\"11697g\"}],[\"path\",{d:\"M2 12h6.5L10 9\",key:\"kv9z4n\"}],[\"path\",{d:\"m20 10-1.5 2 1.5 2\",key:\"1swlpi\"}],[\"path\",{d:\"M22 12h-6.5L14 15\",key:\"1mxi28\"}],[\"path\",{d:\"m4 10 1.5 2L4 14\",key:\"k9enpj\"}],[\"path\",{d:\"m7 21 3-6-1.5-3\",key:\"j8hb9u\"}],[\"path\",{d:\"m7 3 3 6h4\",key:\"1otusx\"}]],TH=st(\"snowflake\",Sxe);const _xe=[[\"path\",{d:\"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z\",key:\"1s2grr\"}],[\"path\",{d:\"M20 2v4\",key:\"1rf3ol\"}],[\"path\",{d:\"M22 4h-4\",key:\"gwowj6\"}],[\"circle\",{cx:\"4\",cy:\"20\",r:\"2\",key:\"6kqj1y\"}]],$x=st(\"sparkles\",_xe);const kxe=[[\"path\",{d:\"M21 10.656V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.344\",key:\"2acyp4\"}],[\"path\",{d:\"m9 11 3 3L22 4\",key:\"1pflzl\"}]],Ns=st(\"square-check-big\",kxe);const Nxe=[[\"rect\",{width:\"18\",height:\"18\",x:\"3\",y:\"3\",rx:\"2\",key:\"afitv7\"}],[\"path\",{d:\"M8 12h8\",key:\"1wcyev\"}]],Cxe=st(\"square-minus\",Nxe);const Pxe=[[\"rect\",{width:\"18\",height:\"18\",x:\"3\",y:\"3\",rx:\"2\",key:\"afitv7\"}]],uo=st(\"square\",Pxe);const Txe=[[\"path\",{d:\"M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z\",key:\"r04s7s\"}]],ug=st(\"star\",Txe);const Axe=[[\"path\",{d:\"M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z\",key:\"1dfntj\"}],[\"path\",{d:\"M15 3v5a1 1 0 0 0 1 1h5\",key:\"6s6qgf\"}]],VQ=st(\"sticky-note\",Axe);const jxe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"4\",key:\"4exip2\"}],[\"path\",{d:\"M12 2v2\",key:\"tus03m\"}],[\"path\",{d:\"M12 20v2\",key:\"1lh1kg\"}],[\"path\",{d:\"m4.93 4.93 1.41 1.41\",key:\"149t6j\"}],[\"path\",{d:\"m17.66 17.66 1.41 1.41\",key:\"ptbguv\"}],[\"path\",{d:\"M2 12h2\",key:\"1t8f8n\"}],[\"path\",{d:\"M20 12h2\",key:\"1q8mjw\"}],[\"path\",{d:\"m6.34 17.66-1.41 1.41\",key:\"1m8zz5\"}],[\"path\",{d:\"m19.07 4.93-1.41 1.41\",key:\"1shlcs\"}]],AH=st(\"sun\",jxe);const Mxe=[[\"path\",{d:\"M15 3v18\",key:\"14nvp0\"}],[\"rect\",{width:\"18\",height:\"18\",x:\"3\",y:\"3\",rx:\"2\",key:\"afitv7\"}],[\"path\",{d:\"M21 9H3\",key:\"1338ky\"}],[\"path\",{d:\"M21 15H3\",key:\"9uk58r\"}]],Exe=st(\"table-properties\",Mxe);const Dxe=[[\"path\",{d:\"M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z\",key:\"vktsd0\"}],[\"circle\",{cx:\"7.5\",cy:\"7.5\",r:\".5\",fill:\"currentColor\",key:\"kqv944\"}]],xm=st(\"tag\",Dxe);const Fxe=[[\"circle\",{cx:\"12\",cy:\"12\",r:\"10\",key:\"1mglay\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"6\",key:\"1vlfrh\"}],[\"circle\",{cx:\"12\",cy:\"12\",r:\"2\",key:\"1c9p78\"}]],GQ=st(\"target\",Fxe);const Rxe=[[\"path\",{d:\"M12 19h8\",key:\"baeox8\"}],[\"path\",{d:\"m4 17 6-6-6-6\",key:\"1yngyt\"}]],WQ=st(\"terminal\",Rxe);const Lxe=[[\"path\",{d:\"M21 5H3\",key:\"1fi0y6\"}],[\"path\",{d:\"M17 12H7\",key:\"16if0g\"}],[\"path\",{d:\"M19 19H5\",key:\"vjpgq2\"}]],Oxe=st(\"text-align-center\",Lxe);const Ixe=[[\"path\",{d:\"M21 5H3\",key:\"1fi0y6\"}],[\"path\",{d:\"M21 12H9\",key:\"dn1m92\"}],[\"path\",{d:\"M21 19H7\",key:\"4cu937\"}]],zxe=st(\"text-align-end\",Ixe);const Uxe=[[\"path\",{d:\"M21 5H3\",key:\"1fi0y6\"}],[\"path\",{d:\"M15 12H3\",key:\"6jk70r\"}],[\"path\",{d:\"M17 19H3\",key:\"z6ezky\"}]],Bxe=st(\"text-align-start\",Uxe);const Hxe=[[\"path\",{d:\"M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z\",key:\"17jzev\"}]],oS=st(\"thermometer\",Hxe);const qxe=[[\"line\",{x1:\"10\",x2:\"14\",y1:\"2\",y2:\"2\",key:\"14vaq8\"}],[\"line\",{x1:\"12\",x2:\"15\",y1:\"14\",y2:\"11\",key:\"17fdiu\"}],[\"circle\",{cx:\"12\",cy:\"14\",r:\"8\",key:\"1e1u0o\"}]],jd=st(\"timer\",qxe);const $xe=[[\"path\",{d:\"M10 11v6\",key:\"nco0om\"}],[\"path\",{d:\"M14 11v6\",key:\"outv1u\"}],[\"path\",{d:\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\",key:\"miytrc\"}],[\"path\",{d:\"M3 6h18\",key:\"d0wm0j\"}],[\"path\",{d:\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\",key:\"e791ji\"}]],en=st(\"trash-2\",$xe);const Vxe=[[\"path\",{d:\"M16 17h6v-6\",key:\"t6n2it\"}],[\"path\",{d:\"m22 17-8.5-8.5-5 5L2 7\",key:\"x473p\"}]],LO=st(\"trending-down\",Vxe);const Gxe=[[\"path\",{d:\"M16 7h6v6\",key:\"box55l\"}],[\"path\",{d:\"m22 7-8.5 8.5-5-5L2 17\",key:\"1t1m79\"}]],dF=st(\"trending-up\",Gxe);const Wxe=[[\"path\",{d:\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\",key:\"wmoenq\"}],[\"path\",{d:\"M12 9v4\",key:\"juzpu7\"}],[\"path\",{d:\"M12 17h.01\",key:\"p32p05\"}]],Dn=st(\"triangle-alert\",Wxe);const Kxe=[[\"path\",{d:\"M6 4v6a6 6 0 0 0 12 0V4\",key:\"9kb039\"}],[\"line\",{x1:\"4\",x2:\"20\",y1:\"20\",y2:\"20\",key:\"nun2al\"}]],Xxe=st(\"underline\",Kxe);const Yxe=[[\"path\",{d:\"m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71\",key:\"yqzxt4\"}],[\"path\",{d:\"m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71\",key:\"4qinb0\"}],[\"line\",{x1:\"8\",x2:\"8\",y1:\"2\",y2:\"5\",key:\"1041cp\"}],[\"line\",{x1:\"2\",x2:\"5\",y1:\"8\",y2:\"8\",key:\"14m1p5\"}],[\"line\",{x1:\"16\",x2:\"16\",y1:\"19\",y2:\"22\",key:\"rzdirn\"}],[\"line\",{x1:\"19\",x2:\"22\",y1:\"16\",y2:\"16\",key:\"ox905f\"}]],gp=st(\"unlink\",Yxe);const Qxe=[[\"path\",{d:\"M12 3v12\",key:\"1x0j5s\"}],[\"path\",{d:\"m17 8-5-5-5 5\",key:\"7q97r8\"}],[\"path\",{d:\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\",key:\"ih7n3h\"}]],La=st(\"upload\",Qxe);const Zxe=[[\"path\",{d:\"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2\",key:\"975kel\"}],[\"circle\",{cx:\"12\",cy:\"7\",r:\"4\",key:\"17ys0d\"}]],ym=st(\"user\",Zxe);const Jxe=[[\"path\",{d:\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\",key:\"1yyitq\"}],[\"path\",{d:\"M16 3.128a4 4 0 0 1 0 7.744\",key:\"16gr8j\"}],[\"path\",{d:\"M22 21v-2a4 4 0 0 0-3-3.87\",key:\"kshegd\"}],[\"circle\",{cx:\"9\",cy:\"7\",r:\"4\",key:\"nufk8\"}]],Wu=st(\"users\",Jxe);const eye=[[\"path\",{d:\"m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5\",key:\"ftymec\"}],[\"rect\",{x:\"2\",y:\"6\",width:\"14\",height:\"12\",rx:\"2\",key:\"158x01\"}]],OO=st(\"video\",eye);const tye=[[\"path\",{d:\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\",key:\"uqj9uw\"}],[\"path\",{d:\"M16 9a5 5 0 0 1 0 6\",key:\"1q6k2b\"}],[\"path\",{d:\"M19.364 18.364a9 9 0 0 0 0-12.728\",key:\"ijwkga\"}]],nye=st(\"volume-2\",tye);const rye=[[\"path\",{d:\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\",key:\"uqj9uw\"}],[\"line\",{x1:\"22\",x2:\"16\",y1:\"9\",y2:\"15\",key:\"1ewh16\"}],[\"line\",{x1:\"16\",x2:\"22\",y1:\"9\",y2:\"15\",key:\"5ykzw1\"}]],aye=st(\"volume-x\",rye);const iye=[[\"path\",{d:\"m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72\",key:\"ul74o6\"}],[\"path\",{d:\"m14 7 3 3\",key:\"1r5n42\"}],[\"path\",{d:\"M5 6v4\",key:\"ilb8ba\"}],[\"path\",{d:\"M19 14v4\",key:\"blhpug\"}],[\"path\",{d:\"M10 2v2\",key:\"7u0qdc\"}],[\"path\",{d:\"M7 8H3\",key:\"zfb6yr\"}],[\"path\",{d:\"M21 16h-4\",key:\"1cnmox\"}],[\"path\",{d:\"M11 3H9\",key:\"1obp7u\"}]],KQ=st(\"wand-sparkles\",iye);const sye=[[\"circle\",{cx:\"12\",cy:\"5\",r:\"3\",key:\"rqqgnr\"}],[\"path\",{d:\"M6.5 8a2 2 0 0 0-1.905 1.46L2.1 18.5A2 2 0 0 0 4 21h16a2 2 0 0 0 1.925-2.54L19.4 9.5A2 2 0 0 0 17.48 8Z\",key:\"56o5sh\"}]],XQ=st(\"weight\",sye);const oye=[[\"path\",{d:\"M12 20h.01\",key:\"zekei9\"}],[\"path\",{d:\"M8.5 16.429a5 5 0 0 1 7 0\",key:\"1bycff\"}],[\"path\",{d:\"M5 12.859a10 10 0 0 1 5.17-2.69\",key:\"1dl1wf\"}],[\"path\",{d:\"M19 12.859a10 10 0 0 0-2.007-1.523\",key:\"4k23kn\"}],[\"path\",{d:\"M2 8.82a15 15 0 0 1 4.177-2.643\",key:\"1grhjp\"}],[\"path\",{d:\"M22 8.82a15 15 0 0 0-11.288-3.764\",key:\"z3jwby\"}],[\"path\",{d:\"m2 2 20 20\",key:\"1ooewy\"}]],Bd=st(\"wifi-off\",oye);const lye=[[\"path\",{d:\"M12 20h.01\",key:\"zekei9\"}],[\"path\",{d:\"M2 8.82a15 15 0 0 1 20 0\",key:\"dnpr2z\"}],[\"path\",{d:\"M5 12.859a10 10 0 0 1 14 0\",key:\"1x1e6c\"}],[\"path\",{d:\"M8.5 16.429a5 5 0 0 1 7 0\",key:\"1bycff\"}]],rp=st(\"wifi\",lye);const cye=[[\"path\",{d:\"M12.8 19.6A2 2 0 1 0 14 16H2\",key:\"148xed\"}],[\"path\",{d:\"M17.5 8a2.5 2.5 0 1 1 2 4H2\",key:\"1u4tom\"}],[\"path\",{d:\"M9.8 4.4A2 2 0 1 1 11 8H2\",key:\"75valh\"}]],YQ=st(\"wind\",cye);const dye=[[\"path\",{d:\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z\",key:\"1ngwbx\"}]],mg=st(\"wrench\",dye);const uye=[[\"path\",{d:\"M18 6 6 18\",key:\"1bl5f8\"}],[\"path\",{d:\"m6 6 12 12\",key:\"d8bk6v\"}]],Ht=st(\"x\",uye);const mye=[[\"circle\",{cx:\"11\",cy:\"11\",r:\"8\",key:\"4ej97u\"}],[\"line\",{x1:\"21\",x2:\"16.65\",y1:\"21\",y2:\"16.65\",key:\"13gj7c\"}],[\"line\",{x1:\"11\",x2:\"11\",y1:\"8\",y2:\"14\",key:\"1vmskp\"}],[\"line\",{x1:\"8\",x2:\"14\",y1:\"11\",y2:\"11\",key:\"durymu\"}]],IO=st(\"zoom-in\",mye);const hye=[[\"path\",{d:\"M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z\",key:\"1xq2db\"}]],dc=st(\"zap\",hye);const pye=[[\"circle\",{cx:\"11\",cy:\"11\",r:\"8\",key:\"4ej97u\"}],[\"line\",{x1:\"21\",x2:\"16.65\",y1:\"21\",y2:\"16.65\",key:\"13gj7c\"}],[\"line\",{x1:\"8\",x2:\"14\",y1:\"11\",y2:\"11\",key:\"durymu\"}]],zO=st(\"zoom-out\",pye),kn=\"/api/v1\";let Cn=sessionStorage.getItem(\"auth_token\")??localStorage.getItem(\"auth_token\");function kh(t,e=!1){Cn=t,t?(sessionStorage.setItem(\"auth_token\",t),e&&localStorage.setItem(\"auth_token\",t)):(sessionStorage.removeItem(\"auth_token\"),localStorage.removeItem(\"auth_token\"))}function vm(){return Cn}let CN=null;function jH(t){CN=t}function QQ(){return CN}function Ga(t){if(!CN)return t;const e=t.includes(\"?\")?\"&\":\"?\";return`${t}${e}token=${encodeURIComponent(CN)}`}function _f(t){if(!t)return null;const e=t.match(/filename\\*=(?:UTF-8|utf-8)''(.+?)(?:;|$)/);if(e)try{return decodeURIComponent(e[1])}catch{}return t.match(/filename=\"?([^\";\\n]+)\"?/)?.[1]||null}async function Ee(t,e={}){const n={\"Content-Type\":\"application/json\",...e.headers};Cn&&(n.Authorization=`Bearer ${Cn}`);const r=await fetch(`${kn}${t}`,{...e,cache:\"no-store\",credentials:\"include\",headers:n});if(!r.ok){const o=(await r.json().catch(()=>({}))).detail,l=typeof o==\"string\"?o:o?JSON.stringify(o):`HTTP ${r.status}`;throw r.status===401&&[\"Could not validate credentials\",\"Token has expired\",\"User not found or inactive\",\"Invalid API key\",\"API key has expired\"].some(d=>l.includes(d))&&kh(null),new Error(l)}const i=r.headers.get(\"content-length\");if(!(r.status===204||i===\"0\"))return await r.json()}const ue={getAuthStatus:()=>Ee(\"/auth/status\"),setupAuth:t=>Ee(\"/auth/setup\",{method:\"POST\",body:JSON.stringify(t)}),login:t=>Ee(\"/auth/login\",{method:\"POST\",body:JSON.stringify(t)}),logout:()=>Ee(\"/auth/logout\",{method:\"POST\"}),getCurrentUser:()=>Ee(\"/auth/me\"),disableAuth:()=>Ee(\"/auth/disable\",{method:\"POST\"}),testSMTP:t=>Ee(\"/auth/smtp/test\",{method:\"POST\",body:JSON.stringify(t)}),getSMTPSettings:()=>Ee(\"/auth/smtp\"),saveSMTPSettings:t=>Ee(\"/auth/smtp\",{method:\"POST\",body:JSON.stringify(t)}),enableAdvancedAuth:()=>Ee(\"/auth/advanced-auth/enable\",{method:\"POST\"}),disableAdvancedAuth:()=>Ee(\"/auth/advanced-auth/disable\",{method:\"POST\"}),getAdvancedAuthStatus:()=>Ee(\"/auth/advanced-auth/status\"),getLDAPStatus:()=>Ee(\"/auth/ldap/status\"),testLDAP:()=>Ee(\"/auth/ldap/test\",{method:\"POST\"}),forgotPassword:t=>Ee(\"/auth/forgot-password\",{method:\"POST\",body:JSON.stringify(t)}),forgotPasswordConfirm:(t,e)=>Ee(\"/auth/forgot-password/confirm\",{method:\"POST\",body:JSON.stringify({token:t,new_password:e})}),resetUserPassword:t=>Ee(\"/auth/reset-password\",{method:\"POST\",body:JSON.stringify(t)}),get2FAStatus:()=>Ee(\"/auth/2fa/status\"),setupTOTP:()=>Ee(\"/auth/2fa/totp/setup\",{method:\"POST\"}),enableTOTP:t=>Ee(\"/auth/2fa/totp/enable\",{method:\"POST\",body:JSON.stringify({code:t})}),disableTOTP:t=>Ee(\"/auth/2fa/totp/disable\",{method:\"POST\",body:JSON.stringify({code:t})}),regenerateBackupCodes:t=>Ee(\"/auth/2fa/totp/regenerate-backup-codes\",{method:\"POST\",body:JSON.stringify({code:t})}),enableEmailOTP:()=>Ee(\"/auth/2fa/email/enable\",{method:\"POST\"}),confirmEnableEmailOTP:(t,e)=>Ee(\"/auth/2fa/email/enable/confirm\",{method:\"POST\",body:JSON.stringify({setup_token:t,code:e})}),disableEmailOTP:t=>Ee(\"/auth/2fa/email/disable\",{method:\"POST\",body:JSON.stringify({password:t})}),sendEmailOTP:t=>Ee(\"/auth/2fa/email/send\",{method:\"POST\",body:JSON.stringify({pre_auth_token:t})}),verify2FA:t=>Ee(\"/auth/2fa/verify\",{method:\"POST\",body:JSON.stringify(t)}),admin2FADisable:t=>Ee(`/auth/2fa/admin/${t}`,{method:\"DELETE\"}),getOIDCProviders:()=>Ee(\"/auth/oidc/providers\"),getOIDCProvidersAll:()=>Ee(\"/auth/oidc/providers/all\"),createOIDCProvider:t=>Ee(\"/auth/oidc/providers\",{method:\"POST\",body:JSON.stringify(t)}),updateOIDCProvider:(t,e)=>Ee(`/auth/oidc/providers/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),deleteOIDCProvider:t=>Ee(`/auth/oidc/providers/${t}`,{method:\"DELETE\"}),getOIDCAuthorizeUrl:t=>Ee(`/auth/oidc/authorize/${t}`),exchangeOIDCToken:t=>Ee(\"/auth/oidc/exchange\",{method:\"POST\",body:JSON.stringify({oidc_token:t})}),getOIDCLinks:()=>Ee(\"/auth/oidc/links\"),deleteOIDCLink:t=>Ee(`/auth/oidc/links/${t}`,{method:\"DELETE\"}),getUsers:()=>Ee(\"/users/\"),getUser:t=>Ee(`/users/${t}`),createUser:t=>Ee(\"/users/\",{method:\"POST\",body:JSON.stringify(t)}),updateUser:(t,e)=>Ee(`/users/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteUser:(t,e=!1)=>Ee(`/users/${t}?delete_items=${e}`,{method:\"DELETE\"}),getUserItemsCount:t=>Ee(`/users/${t}/items-count`),changePassword:(t,e)=>Ee(\"/users/me/change-password\",{method:\"POST\",body:JSON.stringify({current_password:t,new_password:e})}),getUserEmailPreferences:()=>Ee(\"/user-notifications/preferences\"),updateUserEmailPreferences:t=>Ee(\"/user-notifications/preferences\",{method:\"PUT\",body:JSON.stringify(t)}),getPermissions:()=>Ee(\"/groups/permissions\"),getGroups:()=>Ee(\"/groups/\"),getGroup:t=>Ee(`/groups/${t}`),createGroup:t=>Ee(\"/groups/\",{method:\"POST\",body:JSON.stringify(t)}),updateGroup:(t,e)=>Ee(`/groups/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteGroup:t=>Ee(`/groups/${t}`,{method:\"DELETE\"}),addUserToGroup:(t,e)=>Ee(`/groups/${t}/users/${e}`,{method:\"POST\"}),removeUserFromGroup:(t,e)=>Ee(`/groups/${t}/users/${e}`,{method:\"DELETE\"}),getPrinters:()=>Ee(\"/printers/\"),getPrinter:t=>Ee(`/printers/${t}`),createPrinter:t=>Ee(\"/printers/\",{method:\"POST\",body:JSON.stringify(t)}),updatePrinter:(t,e)=>Ee(`/printers/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deletePrinter:(t,e=!0)=>Ee(`/printers/${t}?delete_archives=${e}`,{method:\"DELETE\"}),getDeveloperModeWarnings:()=>Ee(\"/printers/developer-mode-warnings\"),getAvailableFilaments:(t,e)=>{const n=new URLSearchParams({model:t});return e&&n.set(\"location\",e),Ee(`/printers/available-filaments?${n}`)},getPrinterStatus:t=>Ee(`/printers/${t}/status`),refreshPrinterStatus:t=>Ee(`/printers/${t}/refresh-status`,{method:\"POST\"}),connectPrinter:t=>Ee(`/printers/${t}/connect`,{method:\"POST\"}),disconnectPrinter:t=>Ee(`/printers/${t}/disconnect`,{method:\"POST\"}),testExternalCamera:(t,e,n)=>Ee(`/printers/${t}/camera/external/test?url=${encodeURIComponent(e)}&camera_type=${encodeURIComponent(n)}`,{method:\"POST\"}),stopPrint:t=>Ee(`/printers/${t}/print/stop`,{method:\"POST\"}),pausePrint:t=>Ee(`/printers/${t}/print/pause`,{method:\"POST\"}),resumePrint:t=>Ee(`/printers/${t}/print/resume`,{method:\"POST\"}),clearPlate:t=>Ee(`/printers/${t}/clear-plate`,{method:\"POST\"}),getCurrentPrintUser:t=>Ee(`/printers/${t}/current-print-user`),setPrintSpeed:(t,e)=>Ee(`/printers/${t}/print-speed?mode=${e}`,{method:\"POST\"}),setAirductMode:(t,e)=>Ee(`/printers/${t}/airduct-mode?mode=${e}`,{method:\"POST\"}),bedJog:(t,e,n=!1)=>Ee(`/printers/${t}/bed-jog?distance=${e}&force=${n}`,{method:\"POST\"}),homeAxes:(t,e=\"z\")=>Ee(`/printers/${t}/home-axes?axes=${e}`,{method:\"POST\"}),setChamberLight:(t,e)=>Ee(`/printers/${t}/chamber-light?on=${e}`,{method:\"POST\"}),startDrying:(t,e,n,r,i=\"\",s=!1)=>Ee(`/printers/${t}/drying/start?ams_id=${e}&temp=${n}&duration=${r}&filament=${encodeURIComponent(i)}&rotate_tray=${s}`,{method:\"POST\"}),stopDrying:(t,e)=>Ee(`/printers/${t}/drying/stop?ams_id=${e}`,{method:\"POST\"}),getPrintableObjects:t=>Ee(`/printers/${t}/print/objects`),skipObjects:(t,e)=>Ee(`/printers/${t}/print/skip-objects`,{method:\"POST\",body:JSON.stringify(e)}),clearHMSErrors:t=>Ee(`/printers/${t}/hms/clear`,{method:\"POST\"}),refreshAmsSlot:(t,e,n)=>Ee(`/printers/${t}/ams/${e}/slot/${n}/refresh`,{method:\"POST\"}),enableMQTTLogging:t=>Ee(`/printers/${t}/logging/enable`,{method:\"POST\"}),disableMQTTLogging:t=>Ee(`/printers/${t}/logging/disable`,{method:\"POST\"}),getMQTTLogs:t=>Ee(`/printers/${t}/logging`),clearMQTTLogs:t=>Ee(`/printers/${t}/logging`,{method:\"DELETE\"}),getPrinterFiles:(t,e=\"/\")=>Ee(`/printers/${t}/files?path=${encodeURIComponent(e)}`),getPrinterFileDownloadUrl:(t,e)=>`${kn}/printers/${t}/files/download?path=${encodeURIComponent(e)}`,getPrinterFileGcodeUrl:(t,e)=>`${kn}/printers/${t}/files/gcode?path=${encodeURIComponent(e)}`,getPrinterFilePlates:(t,e)=>Ee(`/printers/${t}/files/plates?path=${encodeURIComponent(e)}`),getPrinterFilePlateThumbnail:(t,e,n)=>Ga(`${kn}/printers/${t}/files/plate-thumbnail/${e}?path=${encodeURIComponent(n)}`),downloadPrinterFile:async(t,e)=>{const n={};Cn&&(n.Authorization=`Bearer ${Cn}`);const r=await fetch(`${kn}/printers/${t}/files/download?path=${encodeURIComponent(e)}`,{headers:n});if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.detail||`HTTP ${r.status}`)}const i=r.headers.get(\"Content-Disposition\"),s=_f(i)||e.split(\"/\").pop()||\"download\",o=await r.blob(),l=window.URL.createObjectURL(o),c=document.createElement(\"a\");c.href=l,c.download=s,document.body.appendChild(c),c.click(),document.body.removeChild(c),window.URL.revokeObjectURL(l)},downloadPrinterFilesAsZip:async(t,e)=>{const n={\"Content-Type\":\"application/json\"};Cn&&(n.Authorization=`Bearer ${Cn}`);const r=await fetch(`${kn}/printers/${t}/files/download-zip`,{method:\"POST\",headers:n,body:JSON.stringify({paths:e})});if(!r.ok){const i=await r.json().catch(()=>({}));throw new Error(i.detail||`HTTP ${r.status}`)}return r.blob()},deletePrinterFile:(t,e)=>Ee(`/printers/${t}/files?path=${encodeURIComponent(e)}`,{method:\"DELETE\"}),getPrinterStorage:t=>Ee(`/printers/${t}/storage`),getArchives:(t,e,n=1e4,r=0,i,s)=>{const o=new URLSearchParams;return t&&o.set(\"printer_id\",String(t)),e&&o.set(\"project_id\",String(e)),o.set(\"limit\",String(n)),o.set(\"offset\",String(r)),i&&o.set(\"date_from\",i),s&&o.set(\"date_to\",s),Ee(`/archives/?${o}`)},getArchivesSlim:(t,e,n)=>{const r=new URLSearchParams;t&&r.set(\"date_from\",t),e&&r.set(\"date_to\",e),n!==void 0&&r.set(\"created_by_id\",String(n));const i=r.toString();return Ee(`/archives/slim${i?`?${i}`:\"\"}`)},getArchive:t=>Ee(`/archives/${t}`),searchArchives:(t,e)=>{const n=new URLSearchParams;return n.set(\"q\",t),e?.printerId&&n.set(\"printer_id\",String(e.printerId)),e?.projectId&&n.set(\"project_id\",String(e.projectId)),e?.status&&n.set(\"status\",e.status),e?.limit&&n.set(\"limit\",String(e.limit)),e?.offset&&n.set(\"offset\",String(e.offset)),Ee(`/archives/search?${n}`)},rebuildSearchIndex:()=>Ee(\"/archives/search/rebuild-index\",{method:\"POST\"}),updateArchive:(t,e)=>Ee(`/archives/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),toggleFavorite:t=>Ee(`/archives/${t}/favorite`,{method:\"POST\"}),deleteArchive:t=>Ee(`/archives/${t}`,{method:\"DELETE\"}),getArchiveStats:t=>{const e=new URLSearchParams;t?.dateFrom&&e.set(\"date_from\",t.dateFrom),t?.dateTo&&e.set(\"date_to\",t.dateTo),t?.createdById!==void 0&&e.set(\"created_by_id\",String(t.createdById));const n=e.toString();return Ee(`/archives/stats${n?`?${n}`:\"\"}`)},getTags:()=>Ee(\"/archives/tags\"),renameTag:(t,e)=>Ee(`/archives/tags/${encodeURIComponent(t)}`,{method:\"PUT\",body:JSON.stringify({new_name:e})}),deleteTag:t=>Ee(`/archives/tags/${encodeURIComponent(t)}`,{method:\"DELETE\"}),recalculateCosts:()=>Ee(\"/archives/recalculate-costs\",{method:\"POST\"}),getFailureAnalysis:t=>{const e=new URLSearchParams;t?.days&&e.set(\"days\",String(t.days)),t?.dateFrom&&e.set(\"date_from\",t.dateFrom),t?.dateTo&&e.set(\"date_to\",t.dateTo),t?.printerId&&e.set(\"printer_id\",String(t.printerId)),t?.projectId&&e.set(\"project_id\",String(t.projectId)),t?.createdById!==void 0&&e.set(\"created_by_id\",String(t.createdById));const n=e.toString();return Ee(`/archives/analysis/failures${n?`?${n}`:\"\"}`)},compareArchives:t=>Ee(`/archives/compare?archive_ids=${t.join(\",\")}`),findSimilarArchives:(t,e=10)=>Ee(`/archives/${t}/similar?limit=${e}`),exportArchives:async t=>{const e=new URLSearchParams;t?.format&&e.set(\"format\",t.format),t?.fields&&e.set(\"fields\",t.fields.join(\",\")),t?.printerId&&e.set(\"printer_id\",String(t.printerId)),t?.projectId&&e.set(\"project_id\",String(t.projectId)),t?.status&&e.set(\"status\",t.status),t?.dateFrom&&e.set(\"date_from\",t.dateFrom),t?.dateTo&&e.set(\"date_to\",t.dateTo),t?.search&&e.set(\"search\",t.search);const n={};Cn&&(n.Authorization=`Bearer ${Cn}`);const r=await fetch(`${kn}/archives/export?${e}`,{headers:n});if(!r.ok){const l=await r.json().catch(()=>({}));throw new Error(l.detail||`HTTP ${r.status}`)}const i=r.headers.get(\"Content-Disposition\");let s=t?.format===\"xlsx\"?\"archives_export.xlsx\":\"archives_export.csv\";if(i){const l=i.match(/filename=\"?([^\"]+)\"?/);l&&(s=l[1])}return{blob:await r.blob(),filename:s}},exportStats:async t=>{const e=new URLSearchParams;t?.format&&e.set(\"format\",t.format),t?.days&&e.set(\"days\",String(t.days)),t?.printerId&&e.set(\"printer_id\",String(t.printerId)),t?.projectId&&e.set(\"project_id\",String(t.projectId)),t?.createdById!==void 0&&e.set(\"created_by_id\",String(t.createdById));const n={};Cn&&(n.Authorization=`Bearer ${Cn}`);const r=await fetch(`${kn}/archives/stats/export?${e}`,{headers:n});if(!r.ok){const l=await r.json().catch(()=>({}));throw new Error(l.detail||`HTTP ${r.status}`)}const i=r.headers.get(\"Content-Disposition\");let s=t?.format===\"xlsx\"?\"stats_export.xlsx\":\"stats_export.csv\";if(i){const l=i.match(/filename=\"?([^\"]+)\"?/);l&&(s=l[1])}return{blob:await r.blob(),filename:s}},getArchiveDuplicates:t=>Ee(`/archives/${t}/duplicates`),backfillContentHashes:()=>Ee(\"/archives/backfill-hashes\",{method:\"POST\"}),getArchiveThumbnail:t=>Ga(`${kn}/archives/${t}/thumbnail?v=${Date.now()}`),getArchivePlateThumbnail:(t,e)=>Ga(`${kn}/archives/${t}/plate-thumbnail/${e}`),getArchiveDownload:t=>`${kn}/archives/${t}/download`,downloadArchive:async(t,e)=>{const n={};Cn&&(n.Authorization=`Bearer ${Cn}`);const r=await fetch(`${kn}/archives/${t}/download`,{headers:n});if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.detail||`HTTP ${r.status}`)}const i=r.headers.get(\"Content-Disposition\"),s=_f(i)||e||`archive_${t}.3mf`,o=await r.blob(),l=window.URL.createObjectURL(o),c=document.createElement(\"a\");c.href=l,c.download=s,document.body.appendChild(c),c.click(),document.body.removeChild(c),window.URL.revokeObjectURL(l)},getArchiveGcode:t=>`${kn}/archives/${t}/gcode`,getArchivePlatePreview:t=>Ga(`${kn}/archives/${t}/plate-preview`),getArchiveTimelapse:t=>Ga(`${kn}/archives/${t}/timelapse?v=${Date.now()}`),scanArchiveTimelapse:t=>Ee(`/archives/${t}/timelapse/scan`,{method:\"POST\"}),selectArchiveTimelapse:(t,e)=>Ee(`/archives/${t}/timelapse/select?filename=${encodeURIComponent(e)}`,{method:\"POST\"}),deleteArchiveTimelapse:t=>Ee(`/archives/${t}/timelapse`,{method:\"DELETE\"}),uploadArchiveTimelapse:async(t,e)=>{const n=new FormData;n.append(\"file\",e);const r={};Cn&&(r.Authorization=`Bearer ${Cn}`);const i=await fetch(`${kn}/archives/${t}/timelapse/upload`,{method:\"POST\",headers:r,body:n});if(!i.ok){const s=await i.json().catch(()=>({}));throw new Error(s.detail||`HTTP ${i.status}`)}return i.json()},getTimelapseInfo:t=>Ee(`/archives/${t}/timelapse/info`),getTimelapseThumbnails:(t,e=10)=>Ee(`/archives/${t}/timelapse/thumbnails?count=${e}`),processTimelapse:async(t,e,n)=>{const r=new FormData;r.append(\"trim_start\",String(e.trimStart??0)),e.trimEnd!==void 0&&r.append(\"trim_end\",String(e.trimEnd)),r.append(\"speed\",String(e.speed??1)),r.append(\"save_mode\",e.saveMode),e.outputFilename&&r.append(\"output_filename\",e.outputFilename),n&&r.append(\"audio\",n);const i={};Cn&&(i.Authorization=`Bearer ${Cn}`);const s=await fetch(`${kn}/archives/${t}/timelapse/process`,{method:\"POST\",headers:i,body:r});if(!s.ok){const o=await s.json().catch(()=>({}));throw new Error(o.detail||`HTTP ${s.status}`)}return s.json()},getArchivePhotoUrl:(t,e)=>Ga(`${kn}/archives/${t}/photos/${encodeURIComponent(e)}`),uploadArchivePhoto:async(t,e)=>{const n=new FormData;n.append(\"file\",e);const r={};Cn&&(r.Authorization=`Bearer ${Cn}`);const i=await fetch(`${kn}/archives/${t}/photos`,{headers:r,method:\"POST\",body:n});if(!i.ok){const s=await i.json().catch(()=>({}));throw new Error(s.detail||`HTTP ${i.status}`)}return i.json()},deleteArchivePhoto:(t,e)=>Ee(`/archives/${t}/photos/${encodeURIComponent(e)}`,{method:\"DELETE\"}),getSource3mfDownloadUrl:t=>`${kn}/archives/${t}/source`,downloadSource3mf:async t=>{const e={};Cn&&(e.Authorization=`Bearer ${Cn}`);const n=await fetch(`${kn}/archives/${t}/source`,{headers:e});if(!n.ok){const c=await n.json().catch(()=>({}));throw new Error(c.detail||`HTTP ${n.status}`)}const r=n.headers.get(\"Content-Disposition\"),i=_f(r)||`source_${t}.3mf`,s=await n.blob(),o=window.URL.createObjectURL(s),l=document.createElement(\"a\");l.href=o,l.download=i,document.body.appendChild(l),l.click(),document.body.removeChild(l),window.URL.revokeObjectURL(o)},getSource3mfForSlicer:(t,e)=>{const n=e.replace(/[/\\\\?#]/g,\"_\");return`${kn}/archives/${t}/source/${encodeURIComponent(n.endsWith(\".3mf\")?n:n+\".3mf\")}`},createSourceSlicerToken:t=>Ee(`/archives/${t}/source-slicer-token`,{method:\"POST\"}),getSourceSlicerDownloadUrl:(t,e,n)=>{const r=n.replace(/[/\\\\?#]/g,\"_\");return`${kn}/archives/${t}/source-dl/${e}/${encodeURIComponent(r.endsWith(\".3mf\")?r:r+\".3mf\")}`},uploadSource3mf:async(t,e)=>{const n=new FormData;n.append(\"file\",e);const r={};Cn&&(r.Authorization=`Bearer ${Cn}`);const i=await fetch(`${kn}/archives/${t}/source`,{method:\"POST\",headers:r,body:n});if(!i.ok){const s=await i.json().catch(()=>({}));throw new Error(s.detail||`HTTP ${i.status}`)}return i.json()},deleteSource3mf:t=>Ee(`/archives/${t}/source`,{method:\"DELETE\"}),getF3dDownloadUrl:t=>`${kn}/archives/${t}/f3d`,downloadF3d:async t=>{const e={};Cn&&(e.Authorization=`Bearer ${Cn}`);const n=await fetch(`${kn}/archives/${t}/f3d`,{headers:e});if(!n.ok){const c=await n.json().catch(()=>({}));throw new Error(c.detail||`HTTP ${n.status}`)}const r=n.headers.get(\"Content-Disposition\"),i=_f(r)||`archive_${t}.f3d`,s=await n.blob(),o=window.URL.createObjectURL(s),l=document.createElement(\"a\");l.href=o,l.download=i,document.body.appendChild(l),l.click(),document.body.removeChild(l),window.URL.revokeObjectURL(o)},uploadF3d:async(t,e)=>{const n=new FormData;n.append(\"file\",e);const r={};Cn&&(r.Authorization=`Bearer ${Cn}`);const i=await fetch(`${kn}/archives/${t}/f3d`,{method:\"POST\",headers:r,body:n});if(!i.ok){const s=await i.json().catch(()=>({}));throw new Error(s.detail||`HTTP ${i.status}`)}return i.json()},deleteF3d:t=>Ee(`/archives/${t}/f3d`,{method:\"DELETE\"}),getArchiveQRCodeUrl:(t,e=200)=>Ga(`${kn}/archives/${t}/qrcode?size=${e}`),getArchiveCapabilities:t=>Ee(`/archives/${t}/capabilities`),getArchiveProjectPage:t=>Ee(`/archives/${t}/project-page`),updateArchiveProjectPage:(t,e)=>Ee(`/archives/${t}/project-page`,{method:\"PATCH\",body:JSON.stringify(e)}),getArchiveProjectImageUrl:(t,e)=>Ga(`${kn}/archives/${t}/project-image/${encodeURIComponent(e)}`),getArchiveForSlicer:(t,e)=>{const n=e.replace(/[/\\\\?#]/g,\"_\");return`${kn}/archives/${t}/file/${encodeURIComponent(n.endsWith(\".3mf\")?n:n+\".3mf\")}`},createArchiveSlicerToken:t=>Ee(`/archives/${t}/slicer-token`,{method:\"POST\"}),getArchiveSlicerDownloadUrl:(t,e,n)=>{const r=n.replace(/[/\\\\?#]/g,\"_\");return`${kn}/archives/${t}/dl/${e}/${encodeURIComponent(r.endsWith(\".3mf\")?r:r+\".3mf\")}`},getArchivePlates:t=>Ee(`/archives/${t}/plates`),getArchiveFilamentRequirements:(t,e)=>Ee(`/archives/${t}/filament-requirements${e!==void 0?`?plate_id=${e}`:\"\"}`),reprintArchive:(t,e,n)=>Ee(`/archives/${t}/reprint?printer_id=${e}`,{method:\"POST\",headers:n?{\"Content-Type\":\"application/json\"}:void 0,body:n?JSON.stringify(n):void 0}),uploadArchive:async(t,e)=>{const n=new FormData;n.append(\"file\",t);const r=e?`${kn}/archives/upload?printer_id=${e}`:`${kn}/archives/upload`,i={};Cn&&(i.Authorization=`Bearer ${Cn}`);const s=await fetch(r,{method:\"POST\",headers:i,body:n});if(!s.ok){const o=await s.json().catch(()=>({}));throw new Error(o.detail||`HTTP ${s.status}`)}return s.json()},uploadArchivesBulk:async(t,e)=>{const n=new FormData;t.forEach(o=>n.append(\"files\",o));const r=e?`${kn}/archives/upload-bulk?printer_id=${e}`:`${kn}/archives/upload-bulk`,i={};Cn&&(i.Authorization=`Bearer ${Cn}`);const s=await fetch(r,{method:\"POST\",headers:i,body:n});if(!s.ok){const o=await s.json().catch(()=>({}));throw new Error(o.detail||`HTTP ${s.status}`)}return s.json()},getPrintLog:t=>{const e=new URLSearchParams;return t?.search&&e.set(\"search\",t.search),t?.printerId&&e.set(\"printer_id\",String(t.printerId)),t?.username&&e.set(\"created_by_username\",t.username),t?.status&&e.set(\"status\",t.status),t?.dateFrom&&e.set(\"date_from\",t.dateFrom),t?.dateTo&&e.set(\"date_to\",t.dateTo),t?.limit&&e.set(\"limit\",String(t.limit)),t?.offset!==void 0&&e.set(\"offset\",String(t.offset)),Ee(`/print-log/?${e}`)},getPrintLogThumbnail:t=>Ga(`${kn}/print-log/${t}/thumbnail`),clearPrintLog:()=>Ee(\"/print-log/\",{method:\"DELETE\"}),getSettings:()=>Ee(\"/settings/\"),getDefaultSidebarOrder:()=>Ee(\"/settings/default-sidebar-order\"),updateSettings:t=>Ee(\"/settings/\",{method:\"PUT\",body:JSON.stringify(t)}),getMQTTStatus:()=>Ee(\"/settings/mqtt/status\"),resetSettings:()=>Ee(\"/settings/reset\",{method:\"POST\"}),exportBackup:async()=>{const t=`${kn}/settings/backup`,e={};Cn&&(e.Authorization=`Bearer ${Cn}`);const n=await fetch(t,{headers:e});if(!n.ok){const o=await n.text();throw new Error(o||`Backup failed with status ${n.status}`)}const r=n.headers.get(\"Content-Disposition\");let i=\"bambuddy-backup.zip\";if(r){const o=r.match(/filename=([^;]+)/);o&&(i=o[1].trim().replace(/^\"(.*)\"$/,\"$1\"))}return{blob:await n.blob(),filename:i}},importBackup:async t=>{const e=new FormData;e.append(\"file\",t);const n=`${kn}/settings/restore`,r={};return Cn&&(r.Authorization=`Bearer ${Cn}`),(await fetch(n,{method:\"POST\",headers:r,body:e})).json()},checkFfmpeg:()=>Ee(\"/settings/check-ffmpeg\"),getNetworkInterfaces:()=>Ee(\"/settings/network-interfaces\"),getCloudStatus:()=>Ee(\"/cloud/status\"),cloudLogin:(t,e,n=\"global\")=>Ee(\"/cloud/login\",{method:\"POST\",body:JSON.stringify({email:t,password:e,region:n})}),cloudVerify:(t,e,n,r=\"global\")=>Ee(\"/cloud/verify\",{method:\"POST\",body:JSON.stringify({email:t,code:e,tfa_key:n,region:r})}),cloudSetToken:(t,e=\"global\")=>Ee(\"/cloud/token\",{method:\"POST\",body:JSON.stringify({access_token:t,region:e})}),cloudLogout:()=>Ee(\"/cloud/logout\",{method:\"POST\"}),getCloudSettings:(t=\"02.04.00.70\")=>Ee(`/cloud/settings?version=${t}`),getBuiltinFilaments:()=>Ee(\"/cloud/builtin-filaments\"),getFilamentIdMap:()=>Ee(\"/cloud/filament-id-map\"),getCloudSettingDetail:t=>Ee(`/cloud/settings/${t}`),createCloudSetting:t=>Ee(\"/cloud/settings\",{method:\"POST\",body:JSON.stringify(t)}),updateCloudSetting:(t,e)=>Ee(`/cloud/settings/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),deleteCloudSetting:t=>Ee(`/cloud/settings/${t}`,{method:\"DELETE\"}),getCloudDevices:()=>Ee(\"/cloud/devices\"),getCloudFields:t=>Ee(`/cloud/fields/${t}`),getAllCloudFields:()=>Ee(\"/cloud/fields\"),getFilamentInfo:t=>Ee(\"/cloud/filament-info\",{method:\"POST\",body:JSON.stringify(t)}),getSmartPlugs:()=>Ee(\"/smart-plugs/\"),getSmartPlug:t=>Ee(`/smart-plugs/${t}`),getSmartPlugByPrinter:t=>Ee(`/smart-plugs/by-printer/${t}`),getScriptPlugsByPrinter:t=>Ee(`/smart-plugs/by-printer/${t}/scripts`),createSmartPlug:t=>Ee(\"/smart-plugs/\",{method:\"POST\",body:JSON.stringify(t)}),updateSmartPlug:(t,e)=>Ee(`/smart-plugs/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteSmartPlug:t=>Ee(`/smart-plugs/${t}`,{method:\"DELETE\"}),controlSmartPlug:(t,e)=>Ee(`/smart-plugs/${t}/control`,{method:\"POST\",body:JSON.stringify({action:e})}),getSmartPlugStatus:t=>Ee(`/smart-plugs/${t}/status`),testSmartPlugConnection:(t,e,n)=>Ee(\"/smart-plugs/test-connection\",{method:\"POST\",body:JSON.stringify({ip_address:t,username:e,password:n})}),startTasmotaScan:()=>Ee(\"/smart-plugs/discover/scan\",{method:\"POST\"}),getTasmotaScanStatus:()=>Ee(\"/smart-plugs/discover/status\"),stopTasmotaScan:()=>Ee(\"/smart-plugs/discover/stop\",{method:\"POST\"}),getDiscoveredTasmotaDevices:()=>Ee(\"/smart-plugs/discover/devices\"),testHAConnection:(t,e)=>Ee(\"/smart-plugs/ha/test-connection\",{method:\"POST\",body:JSON.stringify({url:t,token:e})}),getHAEntities:t=>{const e=t?`?search=${encodeURIComponent(t)}`:\"\";return Ee(`/smart-plugs/ha/entities${e}`)},getHASensorEntities:()=>Ee(\"/smart-plugs/ha/sensors\"),testRESTConnection:(t,e=\"GET\",n)=>Ee(\"/smart-plugs/rest/test-connection\",{method:\"POST\",body:JSON.stringify({url:t,method:e,headers:n})}),getQueue:(t,e,n)=>{const r=new URLSearchParams;return t&&r.set(\"printer_id\",String(t)),e&&r.set(\"status\",e),n&&r.set(\"target_model\",n),Ee(`/queue/?${r}`)},getQueueItem:t=>Ee(`/queue/${t}`),addToQueue:t=>Ee(\"/queue/\",{method:\"POST\",body:JSON.stringify(t)}),updateQueueItem:(t,e)=>Ee(`/queue/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),removeFromQueue:t=>Ee(`/queue/${t}`,{method:\"DELETE\"}),reorderQueue:t=>Ee(\"/queue/reorder\",{method:\"POST\",body:JSON.stringify({items:t})}),cancelQueueItem:t=>Ee(`/queue/${t}/cancel`,{method:\"POST\"}),stopQueueItem:t=>Ee(`/queue/${t}/stop`,{method:\"POST\"}),startQueueItem:t=>Ee(`/queue/${t}/start`,{method:\"POST\"}),bulkUpdateQueue:t=>Ee(\"/queue/bulk\",{method:\"PATCH\",body:JSON.stringify(t)}),getBatches:t=>{const e=t?`?status=${t}`:\"\";return Ee(`/queue/batches${e}`)},getBatch:t=>Ee(`/queue/batches/${t}`),cancelBatch:t=>Ee(`/queue/batches/${t}`,{method:\"DELETE\"}),getKProfiles:(t,e=\"0.4\")=>Ee(`/printers/${t}/kprofiles/?nozzle_diameter=${e}`),setKProfile:(t,e)=>Ee(`/printers/${t}/kprofiles/`,{method:\"POST\",body:JSON.stringify(e)}),deleteKProfile:(t,e)=>Ee(`/printers/${t}/kprofiles/`,{method:\"DELETE\",body:JSON.stringify(e)}),setKProfilesBatch:(t,e)=>Ee(`/printers/${t}/kprofiles/batch`,{method:\"POST\",body:JSON.stringify(e)}),getKProfileNotes:t=>Ee(`/printers/${t}/kprofiles/notes`),setKProfileNote:(t,e,n)=>Ee(`/printers/${t}/kprofiles/notes`,{method:\"PUT\",body:JSON.stringify({setting_id:e,note:n})}),deleteKProfileNote:(t,e)=>Ee(`/printers/${t}/kprofiles/notes/${encodeURIComponent(e)}`,{method:\"DELETE\"}),getSlotPresets:t=>Ee(`/printers/${t}/slot-presets`),getSlotPreset:(t,e,n)=>Ee(`/printers/${t}/slot-presets/${e}/${n}`),saveSlotPreset:(t,e,n,r,i,s=\"cloud\")=>Ee(`/printers/${t}/slot-presets/${e}/${n}?preset_id=${encodeURIComponent(r)}&preset_name=${encodeURIComponent(i)}&preset_source=${encodeURIComponent(s)}`,{method:\"PUT\"}),deleteSlotPreset:(t,e,n)=>Ee(`/printers/${t}/slot-presets/${e}/${n}`,{method:\"DELETE\"}),getAmsLabels:t=>Ee(`/printers/${t}/ams-labels`),saveAmsLabel:(t,e,n,r=\"\")=>Ee(`/printers/${t}/ams-labels/${e}`,{method:\"PUT\",body:JSON.stringify({label:n,ams_serial:r})}),deleteAmsLabel:(t,e,n=\"\")=>Ee(`/printers/${t}/ams-labels/${e}?ams_serial=${encodeURIComponent(n)}`,{method:\"DELETE\"}),configureAmsSlot:(t,e,n,r)=>{const i=new URLSearchParams({tray_info_idx:r.tray_info_idx,tray_type:r.tray_type,tray_sub_brands:r.tray_sub_brands,tray_color:r.tray_color,nozzle_temp_min:r.nozzle_temp_min.toString(),nozzle_temp_max:r.nozzle_temp_max.toString(),cali_idx:r.cali_idx.toString(),nozzle_diameter:r.nozzle_diameter});return r.setting_id&&i.set(\"setting_id\",r.setting_id),r.kprofile_filament_id&&i.set(\"kprofile_filament_id\",r.kprofile_filament_id),r.kprofile_setting_id&&i.set(\"kprofile_setting_id\",r.kprofile_setting_id),r.k_value!==void 0&&r.k_value>0&&i.set(\"k_value\",r.k_value.toString()),Ee(`/printers/${t}/slots/${e}/${n}/configure?${i}`,{method:\"POST\"})},resetAmsSlot:(t,e,n)=>Ee(`/printers/${t}/ams/${e}/tray/${n}/reset`,{method:\"POST\"}),listFilaments:()=>Ee(\"/filament-catalog/\"),getFilament:t=>Ee(`/filament-catalog/${t}`),getFilamentsByType:t=>Ee(`/filament-catalog/by-type/${t}`),getNotificationProviders:()=>Ee(\"/notifications/\"),getNotificationProvider:t=>Ee(`/notifications/${t}`),createNotificationProvider:t=>Ee(\"/notifications/\",{method:\"POST\",body:JSON.stringify(t)}),updateNotificationProvider:(t,e)=>Ee(`/notifications/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteNotificationProvider:t=>Ee(`/notifications/${t}`,{method:\"DELETE\"}),testNotificationProvider:t=>Ee(`/notifications/${t}/test`,{method:\"POST\"}),testNotificationConfig:t=>Ee(\"/notifications/test-config\",{method:\"POST\",body:JSON.stringify(t)}),testAllNotificationProviders:()=>Ee(\"/notifications/test-all\",{method:\"POST\"}),getNotificationTemplates:()=>Ee(\"/notification-templates\"),getNotificationTemplate:t=>Ee(`/notification-templates/${t}`),updateNotificationTemplate:(t,e)=>Ee(`/notification-templates/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),resetNotificationTemplate:t=>Ee(`/notification-templates/${t}/reset`,{method:\"POST\"}),getTemplateVariables:()=>Ee(\"/notification-templates/variables\"),previewTemplate:t=>Ee(\"/notification-templates/preview\",{method:\"POST\",body:JSON.stringify(t)}),getNotificationLogs:t=>{const e=new URLSearchParams;return t?.limit&&e.set(\"limit\",String(t.limit)),t?.offset&&e.set(\"offset\",String(t.offset)),t?.provider_id&&e.set(\"provider_id\",String(t.provider_id)),t?.event_type&&e.set(\"event_type\",t.event_type),t?.success!==void 0&&e.set(\"success\",String(t.success)),t?.days&&e.set(\"days\",String(t.days)),Ee(`/notifications/logs?${e}`)},getNotificationLogStats:(t=7)=>Ee(`/notifications/logs/stats?days=${t}`),clearNotificationLogs:(t=30)=>Ee(`/notifications/logs?older_than_days=${t}`,{method:\"DELETE\"}),getSpoolmanStatus:()=>Ee(\"/spoolman/status\"),connectSpoolman:()=>Ee(\"/spoolman/connect\",{method:\"POST\"}),disconnectSpoolman:()=>Ee(\"/spoolman/disconnect\",{method:\"POST\"}),syncPrinterAms:t=>Ee(`/spoolman/sync/${t}`,{method:\"POST\"}),syncAllPrintersAms:()=>Ee(\"/spoolman/sync-all\",{method:\"POST\"}),getSpoolmanSpools:()=>Ee(\"/spoolman/spools\"),getSpoolmanFilaments:()=>Ee(\"/spoolman/filaments\"),getUnlinkedSpools:()=>Ee(\"/spoolman/spools/unlinked\"),getLinkedSpools:()=>Ee(\"/spoolman/spools/linked\"),linkSpool:(t,e)=>Ee(`/spoolman/spools/${t}/link`,{method:\"POST\",body:JSON.stringify({spool_tag:e.spoolTag,printer_id:e.printerId,ams_id:e.amsId,tray_id:e.trayId})}),unlinkSpool:t=>Ee(`/spoolman/spools/${t}/unlink`,{method:\"POST\"}),getSpoolmanSettings:()=>Ee(\"/settings/spoolman\"),updateSpoolmanSettings:t=>Ee(\"/settings/spoolman\",{method:\"PUT\",body:JSON.stringify(t)}),getSpools:(t=!1)=>Ee(`/inventory/spools?include_archived=${t}`),getSpool:t=>Ee(`/inventory/spools/${t}`),createSpool:t=>Ee(\"/inventory/spools\",{method:\"POST\",body:JSON.stringify(t)}),bulkCreateSpools:(t,e)=>Ee(\"/inventory/spools/bulk\",{method:\"POST\",body:JSON.stringify({spool:t,quantity:e})}),updateSpool:(t,e)=>Ee(`/inventory/spools/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteSpool:t=>Ee(`/inventory/spools/${t}`,{method:\"DELETE\"}),archiveSpool:t=>Ee(`/inventory/spools/${t}/archive`,{method:\"POST\"}),restoreSpool:t=>Ee(`/inventory/spools/${t}/restore`,{method:\"POST\"}),getSpoolKProfiles:t=>Ee(`/inventory/spools/${t}/k-profiles`),saveSpoolKProfiles:(t,e)=>Ee(`/inventory/spools/${t}/k-profiles`,{method:\"PUT\",body:JSON.stringify(e)}),getAssignments:t=>Ee(`/inventory/assignments${t?`?printer_id=${t}`:\"\"}`),assignSpool:t=>Ee(\"/inventory/assignments\",{method:\"POST\",body:JSON.stringify(t)}),unassignSpool:(t,e,n)=>Ee(`/inventory/assignments/${t}/${e}/${n}`,{method:\"DELETE\"}),getSpoolCatalog:()=>Ee(\"/inventory/catalog\"),addCatalogEntry:t=>Ee(\"/inventory/catalog\",{method:\"POST\",body:JSON.stringify(t)}),updateCatalogEntry:(t,e)=>Ee(`/inventory/catalog/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),deleteCatalogEntry:t=>Ee(`/inventory/catalog/${t}`,{method:\"DELETE\"}),bulkDeleteCatalogEntries:t=>Ee(\"/inventory/catalog/bulk-delete\",{method:\"POST\",body:JSON.stringify({ids:t})}),resetSpoolCatalog:()=>Ee(\"/inventory/catalog/reset\",{method:\"POST\"}),getColorCatalog:()=>Ee(\"/inventory/colors\"),getColorNameMap:()=>Ee(\"/inventory/colors/map\"),addColorEntry:t=>Ee(\"/inventory/colors\",{method:\"POST\",body:JSON.stringify(t)}),updateColorEntry:(t,e)=>Ee(`/inventory/colors/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),deleteColorEntry:t=>Ee(`/inventory/colors/${t}`,{method:\"DELETE\"}),bulkDeleteColorEntries:t=>Ee(\"/inventory/colors/bulk-delete\",{method:\"POST\",body:JSON.stringify({ids:t})}),resetColorCatalog:()=>Ee(\"/inventory/colors/reset\",{method:\"POST\"}),lookupColor:(t,e,n)=>Ee(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(t)}&color_name=${encodeURIComponent(e)}${n?`&material=${encodeURIComponent(n)}`:\"\"}`),searchColors:(t,e)=>Ee(`/inventory/colors/search?${t?`manufacturer=${encodeURIComponent(t)}`:\"\"}${t&&e?\"&\":\"\"}${e?`material=${encodeURIComponent(e)}`:\"\"}`),linkTagToSpool:(t,e)=>Ee(`/inventory/spools/${t}/link-tag`,{method:\"PATCH\",body:JSON.stringify(e)}),getSpoolUsageHistory:(t,e=50)=>Ee(`/inventory/spools/${t}/usage?limit=${e}`),getAllUsageHistory:(t=100,e)=>Ee(`/inventory/usage?limit=${t}${e?`&printer_id=${e}`:\"\"}`),clearSpoolUsageHistory:t=>Ee(`/inventory/spools/${t}/usage`,{method:\"DELETE\"}),syncWeightsFromAms:()=>Ee(\"/inventory/sync-ams-weights\",{method:\"POST\"}),getFilamentPresets:()=>Ee(\"/cloud/filaments\"),getVersion:()=>Ee(\"/updates/version\"),checkForUpdates:()=>Ee(\"/updates/check\"),applyUpdate:()=>Ee(\"/updates/apply\",{method:\"POST\"}),getUpdateStatus:()=>Ee(\"/updates/status\"),getMaintenanceTypes:()=>Ee(\"/maintenance/types\"),createMaintenanceType:t=>Ee(\"/maintenance/types\",{method:\"POST\",body:JSON.stringify(t)}),updateMaintenanceType:(t,e)=>Ee(`/maintenance/types/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteMaintenanceType:t=>Ee(`/maintenance/types/${t}`,{method:\"DELETE\"}),restoreDefaultMaintenanceTypes:()=>Ee(\"/maintenance/types/restore-defaults\",{method:\"POST\"}),getMaintenanceOverview:()=>Ee(\"/maintenance/overview\"),getPrinterMaintenance:t=>Ee(`/maintenance/printers/${t}`),updateMaintenanceItem:(t,e)=>Ee(`/maintenance/items/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),performMaintenance:(t,e)=>Ee(`/maintenance/items/${t}/perform`,{method:\"POST\",body:JSON.stringify({notes:e})}),getMaintenanceHistory:t=>Ee(`/maintenance/items/${t}/history`),getMaintenanceSummary:()=>Ee(\"/maintenance/summary\"),setPrinterHours:(t,e)=>Ee(`/maintenance/printers/${t}/hours?total_hours=${e}`,{method:\"PATCH\"}),assignMaintenanceType:(t,e)=>Ee(`/maintenance/printers/${t}/assign/${e}`,{method:\"POST\"}),removeMaintenanceItem:t=>Ee(`/maintenance/items/${t}`,{method:\"DELETE\"}),getCameraStreamToken:()=>Ee(\"/printers/camera/stream-token\",{method:\"POST\"}),getCameraStreamUrl:(t,e=10)=>Ga(`${kn}/printers/${t}/camera/stream?fps=${e}`),getCameraSnapshotUrl:t=>Ga(`${kn}/printers/${t}/camera/snapshot`),testCameraConnection:t=>Ee(`/printers/${t}/camera/test`),getCameraStatus:t=>Ee(`/printers/${t}/camera/status`),checkPlateEmpty:(t,e)=>{const n=new URLSearchParams;return n.set(\"use_external\",String(e?.useExternal??!1)),n.set(\"include_debug_image\",String(e?.includeDebugImage??!1)),Ee(`/printers/${t}/camera/check-plate?${n.toString()}`)},getPlateDetectionStatus:t=>Ee(`/printers/${t}/camera/plate-detection/status`),calibratePlateDetection:(t,e)=>{const n=new URLSearchParams;return e?.label&&n.set(\"label\",e.label),n.set(\"use_external\",String(e?.useExternal??!1)),Ee(`/printers/${t}/camera/plate-detection/calibrate?${n.toString()}`,{method:\"POST\"})},deletePlateCalibration:t=>Ee(`/printers/${t}/camera/plate-detection/calibrate`,{method:\"DELETE\"}),getPlateReferences:t=>Ee(`/printers/${t}/camera/plate-detection/references`),getPlateReferenceThumbnailUrl:(t,e)=>Ga(`${kn}/printers/${t}/camera/plate-detection/references/${e}/thumbnail`),updatePlateReferenceLabel:(t,e,n)=>{const r=new URLSearchParams;return r.set(\"label\",n),Ee(`/printers/${t}/camera/plate-detection/references/${e}?${r.toString()}`,{method:\"PUT\"})},deletePlateReference:(t,e)=>Ee(`/printers/${t}/camera/plate-detection/references/${e}`,{method:\"DELETE\"}),getExternalLinks:()=>Ee(\"/external-links/\"),getExternalLink:t=>Ee(`/external-links/${t}`),createExternalLink:t=>Ee(\"/external-links/\",{method:\"POST\",body:JSON.stringify(t)}),updateExternalLink:(t,e)=>Ee(`/external-links/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteExternalLink:t=>Ee(`/external-links/${t}`,{method:\"DELETE\"}),reorderExternalLinks:t=>Ee(\"/external-links/reorder\",{method:\"PUT\",body:JSON.stringify({ids:t})}),uploadExternalLinkIcon:async(t,e)=>{const n=new FormData;n.append(\"file\",e);const r={};Cn&&(r.Authorization=`Bearer ${Cn}`);const i=await fetch(`${kn}/external-links/${t}/icon`,{method:\"POST\",headers:r,body:n});if(!i.ok){const s=await i.json().catch(()=>({}));throw new Error(s.detail||`HTTP ${i.status}`)}return i.json()},deleteExternalLinkIcon:t=>Ee(`/external-links/${t}/icon`,{method:\"DELETE\"}),getExternalLinkIconUrl:t=>Ga(`${kn}/external-links/${t}/icon`),getProjects:t=>{const e=new URLSearchParams;return t&&e.set(\"status\",t),Ee(`/projects/?${e}`)},getProject:t=>Ee(`/projects/${t}`),createProject:t=>Ee(\"/projects/\",{method:\"POST\",body:JSON.stringify(t)}),updateProject:(t,e)=>Ee(`/projects/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteProject:t=>Ee(`/projects/${t}`,{method:\"DELETE\"}),getProjectArchives:(t,e=100,n=0)=>Ee(`/projects/${t}/archives?limit=${e}&offset=${n}`),addArchivesToProject:(t,e)=>Ee(`/projects/${t}/add-archives`,{method:\"POST\",body:JSON.stringify({archive_ids:e})}),removeArchivesFromProject:(t,e)=>Ee(`/projects/${t}/remove-archives`,{method:\"POST\",body:JSON.stringify({archive_ids:e})}),addQueueItemsToProject:(t,e)=>Ee(`/projects/${t}/add-queue`,{method:\"POST\",body:JSON.stringify({queue_item_ids:e})}),uploadProjectAttachment:async(t,e)=>{const n=new FormData;n.append(\"file\",e);const r={};Cn&&(r.Authorization=`Bearer ${Cn}`);const i=await fetch(`${kn}/projects/${t}/attachments`,{method:\"POST\",headers:r,body:n});if(!i.ok){const s=await i.json().catch(()=>({}));throw new Error(s.detail||`HTTP ${i.status}`)}return i.json()},getProjectAttachmentUrl:(t,e)=>`${kn}/projects/${t}/attachments/${encodeURIComponent(e)}`,deleteProjectAttachment:(t,e)=>Ee(`/projects/${t}/attachments/${encodeURIComponent(e)}`,{method:\"DELETE\"}),getProjectBOM:t=>Ee(`/projects/${t}/bom`),createBOMItem:(t,e)=>Ee(`/projects/${t}/bom`,{method:\"POST\",body:JSON.stringify(e)}),updateBOMItem:(t,e,n)=>Ee(`/projects/${t}/bom/${e}`,{method:\"PATCH\",body:JSON.stringify(n)}),deleteBOMItem:(t,e)=>Ee(`/projects/${t}/bom/${e}`,{method:\"DELETE\"}),getTemplates:()=>Ee(\"/projects/templates/\"),createTemplateFromProject:t=>Ee(`/projects/${t}/create-template`,{method:\"POST\"}),createProjectFromTemplate:(t,e)=>Ee(`/projects/from-template/${t}${e?`?name=${encodeURIComponent(e)}`:\"\"}`,{method:\"POST\"}),getProjectTimeline:(t,e=50)=>Ee(`/projects/${t}/timeline?limit=${e}`),exportProjectJson:t=>Ee(`/projects/${t}/export?format=json`),importProject:t=>Ee(\"/projects/import\",{method:\"POST\",body:JSON.stringify(t)}),importProjectFile:async t=>{const e=new FormData;e.append(\"file\",t);const n={};Cn&&(n.Authorization=`Bearer ${Cn}`);const r=await fetch(`${kn}/projects/import/file`,{method:\"POST\",headers:n,body:e});if(!r.ok){const i=await r.json().catch(()=>({}));throw new Error(i.detail||`HTTP ${r.status}`)}return r.json()},exportProjectZip:async t=>{const e={};Cn&&(e.Authorization=`Bearer ${Cn}`);const n=await fetch(`${kn}/projects/${t}/export`,{headers:e});if(!n.ok){const o=await n.json().catch(()=>({}));throw new Error(o.detail||`HTTP ${n.status}`)}const r=n.headers.get(\"Content-Disposition\"),i=_f(r)||`project_${t}.zip`;return{blob:await n.blob(),filename:i}},getAPIKeys:()=>Ee(\"/api-keys/\"),createAPIKey:t=>Ee(\"/api-keys/\",{method:\"POST\",body:JSON.stringify(t)}),updateAPIKey:(t,e)=>Ee(`/api-keys/${t}`,{method:\"PATCH\",body:JSON.stringify(e)}),deleteAPIKey:t=>Ee(`/api-keys/${t}`,{method:\"DELETE\"}),getAMSHistory:(t,e,n=24)=>Ee(`/ams-history/${t}/${e}?hours=${n}`),getSystemInfo:()=>Ee(\"/system/info\"),getStorageUsage:t=>{const e=new URLSearchParams;t?.refresh&&e.set(\"refresh\",\"true\");const n=e.toString();return Ee(`/system/storage-usage${n?`?${n}`:\"\"}`)},getLibraryFolders:()=>Ee(\"/library/folders\"),createLibraryFolder:t=>Ee(\"/library/folders\",{method:\"POST\",body:JSON.stringify(t)}),updateLibraryFolder:(t,e)=>Ee(`/library/folders/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),deleteLibraryFolder:t=>Ee(`/library/folders/${t}`,{method:\"DELETE\"}),createExternalFolder:t=>Ee(\"/library/folders/external\",{method:\"POST\",body:JSON.stringify(t)}),scanExternalFolder:t=>Ee(`/library/folders/${t}/scan`,{method:\"POST\"}),getLibraryFoldersByProject:t=>Ee(`/library/folders/by-project/${t}`),getLibraryFoldersByArchive:t=>Ee(`/library/folders/by-archive/${t}`),getLibraryFiles:(t,e=!0,n)=>{const r=new URLSearchParams;return t!=null&&r.set(\"folder_id\",String(t)),n!==void 0&&r.set(\"project_id\",String(n)),r.set(\"include_root\",String(e)),Ee(`/library/files?${r}`)},getLibraryFile:t=>Ee(`/library/files/${t}`),uploadLibraryFile:async(t,e,n=!0)=>{const r=new FormData;r.append(\"file\",t);const i=new URLSearchParams;e&&i.set(\"folder_id\",String(e)),i.set(\"generate_stl_thumbnails\",String(n));const s={};Cn&&(s.Authorization=`Bearer ${Cn}`);const o=await fetch(`${kn}/library/files?${i}`,{method:\"POST\",headers:s,body:r});if(!o.ok){const l=await o.json().catch(()=>({}));throw new Error(l.detail||`HTTP ${o.status}`)}return o.json()},extractZipFile:async(t,e,n=!0,r=!1,i=!0)=>{const s=new FormData;s.append(\"file\",t);const o=new URLSearchParams;e&&o.set(\"folder_id\",String(e)),o.set(\"preserve_structure\",String(n)),o.set(\"create_folder_from_zip\",String(r)),o.set(\"generate_stl_thumbnails\",String(i));const l={};Cn&&(l.Authorization=`Bearer ${Cn}`);const c=await fetch(`${kn}/library/files/extract-zip?${o}`,{method:\"POST\",headers:l,body:s});if(!c.ok){const d=await c.json().catch(()=>({}));throw new Error(d.detail||`HTTP ${c.status}`)}return c.json()},updateLibraryFile:(t,e)=>Ee(`/library/files/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),deleteLibraryFile:t=>Ee(`/library/files/${t}`,{method:\"DELETE\"}),getLibraryFileDownloadUrl:t=>`${kn}/library/files/${t}/download`,createLibrarySlicerToken:t=>Ee(`/library/files/${t}/slicer-token`,{method:\"POST\"}),getLibrarySlicerDownloadUrl:(t,e,n)=>`${kn}/library/files/${t}/dl/${e}/${encodeURIComponent(n)}`,downloadLibraryFile:async(t,e)=>{const n={};Cn&&(n.Authorization=`Bearer ${Cn}`);const r=await fetch(`${kn}/library/files/${t}/download`,{headers:n});if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.detail||`HTTP ${r.status}`)}const i=r.headers.get(\"Content-Disposition\"),s=_f(i)||e||`file_${t}`,o=await r.blob(),l=window.URL.createObjectURL(o),c=document.createElement(\"a\");c.href=l,c.download=s,document.body.appendChild(c),c.click(),document.body.removeChild(c),window.URL.revokeObjectURL(l)},getLibraryFileThumbnailUrl:t=>Ga(`${kn}/library/files/${t}/thumbnail`),getLibraryFilePlateThumbnail:(t,e)=>Ga(`${kn}/library/files/${t}/plate-thumbnail/${e}`),getLibraryFileGcodeUrl:t=>`${kn}/library/files/${t}/gcode`,moveLibraryFiles:(t,e)=>Ee(\"/library/files/move\",{method:\"POST\",body:JSON.stringify({file_ids:t,folder_id:e})}),bulkDeleteLibrary:(t,e)=>Ee(\"/library/bulk-delete\",{method:\"POST\",body:JSON.stringify({file_ids:t,folder_ids:e})}),getLibraryStats:()=>Ee(\"/library/stats\"),batchGenerateStlThumbnails:t=>Ee(\"/library/generate-stl-thumbnails\",{method:\"POST\",body:JSON.stringify(t)}),addLibraryFilesToQueue:t=>Ee(\"/library/files/add-to-queue\",{method:\"POST\",body:JSON.stringify({file_ids:t})}),printLibraryFile:(t,e,n)=>Ee(`/library/files/${t}/print?printer_id=${e}`,{method:\"POST\",body:n?JSON.stringify(n):void 0}),cancelBackgroundDispatchJob:t=>Ee(`/background-dispatch/${t}`,{method:\"DELETE\"}),getLibraryFilePlates:t=>Ee(`/library/files/${t}/plates`),getLibraryFileFilamentRequirements:(t,e)=>Ee(`/library/files/${t}/filament-requirements${e!==void 0?`?plate_id=${e}`:\"\"}`),getGitHubBackupConfig:()=>Ee(\"/github-backup/config\"),saveGitHubBackupConfig:t=>Ee(\"/github-backup/config\",{method:\"POST\",body:JSON.stringify(t)}),updateGitHubBackupConfig:t=>Ee(\"/github-backup/config\",{method:\"PATCH\",body:JSON.stringify(t)}),deleteGitHubBackupConfig:()=>Ee(\"/github-backup/config\",{method:\"DELETE\"}),testGitHubConnection:(t,e)=>Ee(`/github-backup/test?repo_url=${encodeURIComponent(t)}&token=${encodeURIComponent(e)}`,{method:\"POST\"}),testGitHubStoredConnection:()=>Ee(\"/github-backup/test-stored\",{method:\"POST\"}),triggerGitHubBackup:()=>Ee(\"/github-backup/run\",{method:\"POST\"}),getGitHubBackupStatus:()=>Ee(\"/github-backup/status\"),getGitHubBackupLogs:(t=50)=>Ee(`/github-backup/logs?limit=${t}`),clearGitHubBackupLogs:(t=10)=>Ee(`/github-backup/logs?keep_last=${t}`,{method:\"DELETE\"}),getLocalBackupStatus:()=>Ee(\"/local-backup/status\"),triggerLocalBackup:()=>Ee(\"/local-backup/run\",{method:\"POST\"}),getLocalBackups:()=>Ee(\"/local-backup/backups\"),downloadLocalBackup:async t=>{const e=await fetch(`${kn}/local-backup/backups/${encodeURIComponent(t)}/download`,{headers:Cn?{Authorization:`Bearer ${Cn}`}:{}});if(!e.ok)throw new Error(\"Download failed\");return{blob:await e.blob(),filename:t}},restoreLocalBackup:t=>Ee(`/local-backup/backups/${encodeURIComponent(t)}/restore`,{method:\"POST\"}),deleteLocalBackup:t=>Ee(`/local-backup/backups/${encodeURIComponent(t)}`,{method:\"DELETE\"}),getObicoStatus:()=>Ee(\"/obico/status\"),testObicoConnection:t=>Ee(\"/obico/test-connection\",{method:\"POST\",body:JSON.stringify({url:t})}),getLocalPresets:()=>Ee(\"/local-presets/\"),getLocalPresetDetail:t=>Ee(`/local-presets/${t}`),importLocalPresets:t=>fetch(`${kn}/local-presets/import`,{method:\"POST\",headers:Cn?{Authorization:`Bearer ${Cn}`}:{},body:t}).then(async e=>{if(!e.ok){const n=await e.json().catch(()=>({}));throw new Error(n.detail||`HTTP ${e.status}`)}return e.json()}),createLocalPreset:t=>Ee(\"/local-presets/\",{method:\"POST\",body:JSON.stringify(t)}),updateLocalPreset:(t,e)=>Ee(`/local-presets/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),deleteLocalPreset:t=>Ee(`/local-presets/${t}`,{method:\"DELETE\"}),refreshBaseProfileCache:()=>Ee(\"/local-presets/base-cache/refresh\",{method:\"POST\"})},hd={getInfo:()=>Ee(\"/discovery/info\"),getStatus:()=>Ee(\"/discovery/status\"),startDiscovery:(t=10)=>Ee(`/discovery/start?duration=${t}`,{method:\"POST\"}),stopDiscovery:()=>Ee(\"/discovery/stop\",{method:\"POST\"}),getDiscoveredPrinters:()=>Ee(\"/discovery/printers\"),startSubnetScan:(t,e=1)=>Ee(\"/discovery/scan\",{method:\"POST\",body:JSON.stringify({subnet:t,timeout:e})}),getScanStatus:()=>Ee(\"/discovery/scan/status\"),stopSubnetScan:()=>Ee(\"/discovery/scan/stop\",{method:\"POST\"})},fye={getSettings:()=>Ee(\"/settings/virtual-printer\")},PN={list:()=>Ee(\"/virtual-printers\"),get:t=>Ee(`/virtual-printers/${t}`),create:t=>Ee(\"/virtual-printers\",{method:\"POST\",body:JSON.stringify(t)}),update:(t,e)=>Ee(`/virtual-printers/${t}`,{method:\"PUT\",body:JSON.stringify(e)}),remove:t=>Ee(`/virtual-printers/${t}`,{method:\"DELETE\"})},ux={list:()=>Ee(\"/pending-uploads/\"),getCount:()=>Ee(\"/pending-uploads/count\"),get:t=>Ee(`/pending-uploads/${t}`),archive:(t,e)=>Ee(`/pending-uploads/${t}/archive`,{method:\"POST\",body:JSON.stringify(e||{})}),discard:t=>Ee(`/pending-uploads/${t}`,{method:\"DELETE\"}),archiveAll:()=>Ee(\"/pending-uploads/archive-all\",{method:\"POST\"}),discardAll:()=>Ee(\"/pending-uploads/discard-all\",{method:\"DELETE\"})},zk={checkUpdates:()=>Ee(\"/firmware/updates\"),checkPrinterUpdate:t=>Ee(`/firmware/updates/${t}`),prepareUpload:(t,e)=>Ee(`/firmware/updates/${t}/prepare${e?`?version=${encodeURIComponent(e)}`:\"\"}`),startUpload:(t,e)=>Ee(`/firmware/updates/${t}/upload${e?`?version=${encodeURIComponent(e)}`:\"\"}`,{method:\"POST\"}),getUploadStatus:t=>Ee(`/firmware/updates/${t}/upload/status`)},Px={getDebugLoggingState:()=>Ee(\"/support/debug-logging\"),setDebugLogging:t=>Ee(\"/support/debug-logging\",{method:\"POST\",body:JSON.stringify({enabled:t})}),downloadSupportBundle:async()=>{const t={};Cn&&(t.Authorization=`Bearer ${Cn}`);const e=await fetch(`${kn}/support/bundle`,{headers:t});if(!e.ok){const l=await e.json().catch(()=>({}));throw new Error(l.detail||`HTTP ${e.status}`)}const n=e.headers.get(\"Content-Disposition\"),r=_f(n)||\"bambuddy-support.zip\",i=await e.blob(),s=window.URL.createObjectURL(i),o=document.createElement(\"a\");o.href=s,o.download=r,document.body.appendChild(o),o.click(),document.body.removeChild(o),window.URL.revokeObjectURL(s)},getLogs:t=>{const e=new URLSearchParams;t?.limit&&e.set(\"limit\",t.limit.toString()),t?.level&&e.set(\"level\",t.level),t?.search&&e.set(\"search\",t.search);const n=e.toString();return Ee(`/support/logs${n?`?${n}`:\"\"}`)},clearLogs:()=>Ee(\"/support/logs\",{method:\"DELETE\"})},Gr={getDevices:()=>Ee(\"/spoolbuddy/devices\"),deleteDevice:t=>Ee(`/spoolbuddy/devices/${t}`,{method:\"DELETE\"}),tare:t=>Ee(`/spoolbuddy/devices/${t}/calibration/tare`,{method:\"POST\",body:\"{}\"}),getCalibration:t=>Ee(`/spoolbuddy/devices/${t}/calibration`),setCalibrationFactor:(t,e,n,r)=>Ee(`/spoolbuddy/devices/${t}/calibration/set-factor`,{method:\"POST\",body:JSON.stringify({known_weight_grams:e,raw_adc:n,tare_raw_adc:r})}),updateSpoolWeight:(t,e)=>Ee(\"/spoolbuddy/scale/update-spool-weight\",{method:\"POST\",body:JSON.stringify({spool_id:t,weight_grams:e})}),updateDisplay:(t,e,n)=>Ee(`/spoolbuddy/devices/${t}/display`,{method:\"PUT\",body:JSON.stringify({brightness:e,blank_timeout:n})}),updateSystemConfig:(t,e,n)=>Ee(`/spoolbuddy/devices/${t}/system/config`,{method:\"POST\",body:JSON.stringify({backend_url:e,...n?{api_key:n}:{}})}),checkDaemonUpdate:t=>Ee(`/spoolbuddy/devices/${t}/update-check`),triggerUpdate:t=>Ee(`/spoolbuddy/devices/${t}/update`,{method:\"POST\",body:\"{}\"}),getSSHPublicKey:()=>Ee(\"/spoolbuddy/ssh/public-key\"),writeTag:(t,e)=>Ee(\"/spoolbuddy/nfc/write-tag\",{method:\"POST\",body:JSON.stringify({device_id:t,spool_id:e})}),cancelWrite:t=>Ee(`/spoolbuddy/devices/${t}/cancel-write`,{method:\"POST\",body:\"{}\"}),systemCommand:(t,e)=>Ee(`/spoolbuddy/devices/${t}/system/command`,{method:\"POST\",body:JSON.stringify({command:e})}),queueDiagnostics:(t,e)=>Ee(`/spoolbuddy/diagnostics/${t}/run?diagnostic=${e}`,{method:\"POST\",body:\"{}\"}),getDiagnosticResult:(t,e)=>Ee(`/spoolbuddy/diagnostics/${t}/result?diagnostic=${e}`,{method:\"GET\"})},jM={submit:t=>Ee(\"/bug-report/submit\",{method:\"POST\",body:JSON.stringify(t)}),startLogging:()=>Ee(\"/bug-report/start-logging\",{method:\"POST\"}),stopLogging:t=>Ee(`/bug-report/stop-logging?was_debug=${t}`,{method:\"POST\"})},ZQ=w.createContext(void 0);function gye({children:t}){const[e,n]=w.useState(()=>{const T=localStorage.getItem(\"theme-mode\"),F=localStorage.getItem(\"theme\");return T||F||\"dark\"}),[r,i]=w.useState(()=>localStorage.getItem(\"dark-style\")||\"classic\"),[s,o]=w.useState(()=>localStorage.getItem(\"dark-background\")||\"neutral\"),[l,c]=w.useState(()=>localStorage.getItem(\"dark-accent\")||\"green\"),[d,u]=w.useState(()=>localStorage.getItem(\"light-style\")||\"classic\"),[m,p]=w.useState(()=>localStorage.getItem(\"light-background\")||\"neutral\"),[f,y]=w.useState(()=>localStorage.getItem(\"light-accent\")||\"green\");w.useEffect(()=>{ue.getSettings().then(T=>{T.dark_style&&(i(T.dark_style),localStorage.setItem(\"dark-style\",T.dark_style)),T.dark_background&&(o(T.dark_background),localStorage.setItem(\"dark-background\",T.dark_background)),T.dark_accent&&(c(T.dark_accent),localStorage.setItem(\"dark-accent\",T.dark_accent)),T.light_style&&(u(T.light_style),localStorage.setItem(\"light-style\",T.light_style)),T.light_background&&(p(T.light_background),localStorage.setItem(\"light-background\",T.light_background)),T.light_accent&&(y(T.light_accent),localStorage.setItem(\"light-accent\",T.light_accent))}).catch(()=>{})},[]),w.useEffect(()=>{const T=document.documentElement;T.classList.remove(\"dark\",\"style-classic\",\"style-glow\",\"style-vibrant\",\"bg-neutral\",\"bg-warm\",\"bg-cool\",\"bg-oled\",\"bg-slate\",\"bg-forest\",\"accent-green\",\"accent-teal\",\"accent-blue\",\"accent-orange\",\"accent-purple\",\"accent-red\"),e===\"dark\"?(T.classList.add(\"dark\"),T.classList.add(`style-${r}`),T.classList.add(`bg-${s}`),T.classList.add(`accent-${l}`)):(T.classList.add(`style-${d}`),T.classList.add(`bg-${m}`),T.classList.add(`accent-${f}`)),localStorage.setItem(\"theme-mode\",e),localStorage.removeItem(\"theme\")},[e,r,s,l,d,m,f]);const v=()=>n(T=>T===\"dark\"?\"light\":\"dark\"),b=T=>n(T),g=T=>{i(T),localStorage.setItem(\"dark-style\",T),ue.updateSettings({dark_style:T}).catch(()=>{})},_=T=>{o(T),localStorage.setItem(\"dark-background\",T),ue.updateSettings({dark_background:T}).catch(()=>{})},C=T=>{c(T),localStorage.setItem(\"dark-accent\",T),ue.updateSettings({dark_accent:T}).catch(()=>{})},P=T=>{u(T),localStorage.setItem(\"light-style\",T),ue.updateSettings({light_style:T}).catch(()=>{})},N=T=>{p(T),localStorage.setItem(\"light-background\",T),ue.updateSettings({light_background:T}).catch(()=>{})},A=T=>{y(T),localStorage.setItem(\"light-accent\",T),ue.updateSettings({light_accent:T}).catch(()=>{})};return a.jsx(ZQ.Provider,{value:{mode:e,darkStyle:r,darkBackground:s,darkAccent:l,lightStyle:d,lightBackground:m,lightAccent:f,toggleMode:v,setMode:b,setDarkStyle:g,setDarkBackground:_,setDarkAccent:C,setLightStyle:P,setLightBackground:N,setLightAccent:A},children:t})}function zg(){const t=w.useContext(ZQ);if(!t)throw new Error(\"useTheme must be used within ThemeProvider\");return t}const UO=w.createContext(\"normal\");function bye({density:t,children:e}){return a.jsx(UO.Provider,{value:t,children:e})}function Tt({children:t,className:e=\"\",onClick:n,onContextMenu:r,...i}){return a.jsx(\"div\",{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${e}`,onClick:n,onContextMenu:r,...i,children:t})}function gn({children:t,className:e=\"\",dense:n}){const r=w.useContext(UO)===\"dense\",s=n??r?\"px-4 py-2.5\":\"px-6 py-4\";return a.jsx(\"div\",{className:`${s} border-b border-bambu-dark-tertiary ${e}`,children:t})}function Mt({children:t,className:e=\"\",dense:n}){const r=w.useContext(UO)===\"dense\",s=n??r?\"p-4\":\"p-6\";return a.jsx(\"div\",{className:`${s} ${e}`,children:t})}function xye(t,e,n){return[{category:\"Navigation\",items:t?t.slice(0,9).map((i,s)=>({keys:[String(s+1)],description:i.type===\"external\"?`Open ${i.label}`:`Go to ${i.labelKey?n(i.labelKey):i.label}`,isExternal:i.type===\"external\"})):e?e.map((i,s)=>({keys:[String(s+1)],description:`Go to ${n(i.labelKey)}`,isExternal:!1})):[{keys:[\"1\"],description:\"Go to Printers\",isExternal:!1},{keys:[\"2\"],description:\"Go to Archives\",isExternal:!1},{keys:[\"3\"],description:\"Go to Queue\",isExternal:!1},{keys:[\"4\"],description:\"Go to Statistics\",isExternal:!1},{keys:[\"5\"],description:\"Go to Cloud Profiles\",isExternal:!1},{keys:[\"6\"],description:\"Go to Settings\",isExternal:!1}]},{category:\"Archives\",items:[{keys:[\"/\"],description:\"Focus search\",isExternal:!1},{keys:[\"U\"],description:\"Open upload modal\",isExternal:!1},{keys:[\"Esc\"],description:\"Clear selection / blur input\",isExternal:!1},{keys:[\"Right-click\"],description:\"Context menu on cards\",isExternal:!1}]},{category:\"K-Profiles\",items:[{keys:[\"R\"],description:\"Refresh profiles\",isExternal:!1},{keys:[\"N\"],description:\"New profile\",isExternal:!1},{keys:[\"Esc\"],description:\"Exit selection mode\",isExternal:!1}]},{category:\"General\",items:[{keys:[\"?\"],description:\"Show this help\",isExternal:!1}]}]}function MH({children:t}){return a.jsx(\"kbd\",{className:\"px-2 py-1 text-xs font-mono bg-bambu-dark border border-bambu-dark-tertiary rounded text-white\",children:t})}function yye({onClose:t,navItems:e,sidebarItems:n}){const{t:r}=Ft(),i=xye(n,e,r);return w.useEffect(()=>{const s=o=>{o.key===\"Escape\"&&t()};return window.addEventListener(\"keydown\",s),()=>window.removeEventListener(\"keydown\",s)},[t]),a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",onClick:t,children:a.jsx(Tt,{className:\"w-full max-w-md\",onClick:s=>s.stopPropagation(),children:a.jsxs(Mt,{className:\"p-0\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(iF,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:\"Keyboard Shortcuts\"})]}),a.jsx(\"button\",{onClick:t,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsx(\"div\",{className:\"p-4 space-y-6 max-h-[60vh] overflow-y-auto\",children:i.map(s=>a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-bambu-gray mb-3\",children:s.category}),a.jsx(\"div\",{className:\"space-y-2\",children:s.items.map(o=>a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"span\",{className:\"text-white text-sm flex items-center gap-1.5\",children:[o.description,o.isExternal&&a.jsx(la,{className:\"w-3 h-3 text-bambu-gray\"})]}),a.jsx(\"div\",{className:\"flex gap-1\",children:o.keys.map(l=>a.jsx(MH,{children:l},l))})]},o.description))})]},s.category))}),a.jsx(\"div\",{className:\"p-4 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"p\",{className:\"text-xs text-bambu-gray text-center\",children:[\"Press \",a.jsx(MH,{children:\"Esc\"}),\" or click outside to close\"]})})]})})})}function De({variant:t=\"primary\",size:e=\"md\",className:n=\"\",children:r,...i}){const s=\"inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed\",o={primary:\"bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green\",secondary:\"bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray\",danger:\"bg-red-600 hover:bg-red-700 text-white focus:ring-red-500\",ghost:\"bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white\"},l={sm:\"px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0\",md:\"px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0\",lg:\"px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0\"};return a.jsx(\"button\",{className:`${s} ${o[t]} ${l[e]} ${n}`,...i,children:r})}function yn({title:t,message:e,confirmText:n,cancelText:r,cancelVariant:i,cardClassName:s,variant:o=\"default\",isLoading:l=!1,loadingText:c,onConfirm:d,onCancel:u}){const{t:m}=Ft(),p=n??m(\"common.confirm\"),f=r??m(\"common.cancel\"),y=c??m(\"common.loading\");w.useEffect(()=>{const g=_=>{_.key===\"Escape\"&&!l&&u()};return window.addEventListener(\"keydown\",g),()=>window.removeEventListener(\"keydown\",g)},[u,l]);const b={danger:{icon:\"text-red-400\",button:\"bg-red-500 hover:bg-red-600\"},warning:{icon:\"text-yellow-400\",button:\"bg-yellow-500 hover:bg-yellow-600 text-black\"},default:{icon:\"text-bambu-green\",button:\"bg-bambu-green hover:bg-bambu-green-dark\"}}[o];return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",onClick:l?void 0:u,children:a.jsx(Tt,{className:`w-full max-w-md ${s??\"\"}`,onClick:g=>g.stopPropagation(),children:a.jsxs(Mt,{className:\"p-6\",children:[a.jsxs(\"div\",{className:\"flex items-start gap-4\",children:[a.jsx(\"div\",{className:`p-2 rounded-full bg-bambu-dark ${b.icon}`,children:a.jsx(Dn,{className:\"w-6 h-6\"})}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white mb-2\",children:t}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm whitespace-pre-line\",children:e})]})]}),a.jsxs(\"div\",{className:\"flex gap-3 mt-6\",children:[a.jsx(De,{variant:i??\"secondary\",onClick:u,className:\"flex-1\",disabled:l,children:f}),a.jsx(De,{onClick:d,className:`flex-1 ${b.button}`,disabled:l,children:l?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 mr-2 animate-spin\"}),y]}):p})]})]})})})}function vye({plug:t}){const e=nn(),[n,r]=w.useState(null),{data:i,isLoading:s}=Xe({queryKey:[\"smart-plug-status\",t.id],queryFn:()=>ue.getSmartPlugStatus(t.id),refetchInterval:1e4}),o=it({mutationFn:f=>ue.controlSmartPlug(t.id,f),onSuccess:()=>{e.invalidateQueries({queryKey:[\"smart-plug-status\",t.id]})}}),l=i?.state===\"ON\",c=t.plug_type===\"mqtt\"&&i?.energy?.power!==null&&i?.energy?.power!==void 0,d=(i?.reachable??!1)||c,u=o.isPending,m=t.plug_type===\"mqtt\",p=()=>{n&&(o.mutate(n),r(null))};return a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"div\",{className:`p-1.5 rounded ${m?d?\"bg-teal-500/20\":\"bg-red-500/20\":d?l?\"bg-bambu-green/20\":\"bg-bambu-dark\":\"bg-red-500/20\"}`,children:m?a.jsx(RO,{className:`w-4 h-4 ${d?\"text-teal-400\":\"text-red-400\"}`}):a.jsx(nc,{className:`w-4 h-4 ${d?l?\"text-bambu-green\":\"text-bambu-gray\":\"text-red-400\"}`})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white font-medium\",children:t.name}),a.jsx(\"div\",{className:\"flex items-center gap-1 text-xs\",children:s?a.jsx(ft,{className:\"w-3 h-3 text-bambu-gray animate-spin\"}):m?d?a.jsxs(a.Fragment,{children:[a.jsx(dc,{className:\"w-3 h-3 text-teal-400\"}),a.jsxs(\"span\",{className:\"text-teal-400\",children:[Math.round(i?.energy?.power??0),\"W\"]}),a.jsx(\"span\",{className:\"text-bambu-gray mx-1\",children:\"|\"}),a.jsx(yl,{className:\"w-3 h-3 text-bambu-gray\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"Monitor\"})]}):a.jsxs(a.Fragment,{children:[a.jsx(Bd,{className:\"w-3 h-3 text-status-error\"}),a.jsx(\"span\",{className:\"text-status-error\",children:\"Waiting\"})]}):d?a.jsxs(a.Fragment,{children:[a.jsx(rp,{className:\"w-3 h-3 text-status-ok\"}),a.jsx(\"span\",{className:l?\"text-status-ok\":\"text-bambu-gray\",children:i?.state||\"Unknown\"}),i?.energy?.power!==null&&i?.energy?.power!==void 0&&a.jsxs(a.Fragment,{children:[a.jsx(\"span\",{className:\"text-bambu-gray mx-1\",children:\"|\"}),a.jsx(dc,{className:\"w-3 h-3 text-yellow-400\"}),a.jsxs(\"span\",{className:\"text-yellow-400\",children:[Math.round(i.energy.power),\"W\"]})]})]}):a.jsxs(a.Fragment,{children:[a.jsx(Bd,{className:\"w-3 h-3 text-status-error\"}),a.jsx(\"span\",{className:\"text-status-error\",children:\"Offline\"})]})})]})]}),!m&&a.jsxs(\"div\",{className:\"flex gap-1\",children:[a.jsx(\"button\",{onClick:()=>r(\"on\"),disabled:!d||u,className:`p-1.5 rounded transition-colors ${l?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"} disabled:opacity-50 disabled:cursor-not-allowed`,title:\"Turn On\",children:u?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Yd,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>r(\"off\"),disabled:!d||u,className:`p-1.5 rounded transition-colors ${!l&&d?\"bg-bambu-dark-tertiary text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"} disabled:opacity-50 disabled:cursor-not-allowed`,title:\"Turn Off\",children:u?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(aS,{className:\"w-4 h-4\"})})]})]}),n&&a.jsx(yn,{title:`Turn ${n===\"on\"?\"On\":\"Off\"} Smart Plug`,message:`Are you sure you want to turn ${n===\"on\"?\"on\":\"off\"} \"${t.name}\"?`,confirmText:n===\"on\"?\"Turn On\":\"Turn Off\",variant:n===\"off\"?\"warning\":\"default\",onConfirm:p,onCancel:()=>r(null)})]})}function EH({onClose:t}){const{t:e}=Ft(),{data:n,isLoading:r}=Xe({queryKey:[\"smart-plugs\"],queryFn:ue.getSmartPlugs}),i=n?.filter(s=>s.show_in_switchbar)||[];return a.jsxs(\"div\",{className:\"absolute bottom-full left-0 mb-2 w-72 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-xl z-50\",onMouseLeave:t,children:[a.jsx(\"div\",{className:\"px-4 py-3 border-b border-bambu-dark-tertiary\",children:a.jsxs(\"h3\",{className:\"text-sm font-semibold text-white flex items-center gap-2\",children:[a.jsx(nc,{className:\"w-4 h-4 text-bambu-green\"}),\"Smart Switches\"]})}),a.jsx(\"div\",{className:\"p-2 max-h-80 overflow-y-auto\",children:r?a.jsx(\"div\",{className:\"flex items-center justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-gray animate-spin\"})}):i.length===0?a.jsxs(\"div\",{className:\"text-center py-6 px-4\",children:[a.jsx(nc,{className:\"w-8 h-8 text-bambu-gray mx-auto mb-2\"}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:e(\"smartPlugs.noSwitchesInSwitchbar\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:e(\"smartPlugs.enableSwitchbarHint\")})]}):a.jsx(\"div\",{className:\"space-y-1\",children:i.map(s=>a.jsx(vye,{plug:s},s.id))})})]})}const JQ=[{name:\"globe\",icon:ul},{name:\"link\",icon:Sy},{name:\"external-link\",icon:la},{name:\"book\",icon:Epe},{name:\"file-text\",icon:Us},{name:\"home\",icon:Jw},{name:\"star\",icon:ug},{name:\"heart\",icon:Bge},{name:\"bookmark\",icon:Fpe},{name:\"shopping-cart\",icon:qQ},{name:\"music\",icon:lF},{name:\"video\",icon:OO},{name:\"image\",icon:gm},{name:\"camera\",icon:qx},{name:\"map\",icon:mbe},{name:\"compass\",icon:Efe},{name:\"coffee\",icon:kfe},{name:\"gift\",icon:Cge},{name:\"wrench\",icon:mg},{name:\"zap\",icon:dc},{name:\"cloud\",icon:vy},{name:\"database\",icon:Jl},{name:\"folder\",icon:LQ},{name:\"mail\",icon:bm},{name:\"phone\",icon:Obe},{name:\"user\",icon:ym},{name:\"users\",icon:Wu},{name:\"server\",icon:Sf},{name:\"terminal\",icon:WQ},{name:\"code\",icon:wy}];function WP(t){return JQ.find(n=>n.name===t)?.icon||Sy}function wye({value:t,onChange:e}){const[n,r]=w.useState(!1),i=WP(t);return a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>r(!n),className:\"flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white hover:border-bambu-gray focus:border-bambu-green focus:outline-none w-full\",children:[a.jsx(i,{className:\"w-5 h-5\"}),a.jsx(\"span\",{className:\"text-sm text-bambu-gray flex-1 text-left\",children:t})]}),n&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-40\",onClick:()=>r(!1)}),a.jsx(\"div\",{className:\"absolute z-50 mt-1 w-full max-h-64 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg\",children:a.jsx(\"div\",{className:\"grid grid-cols-5 gap-1 p-2\",children:JQ.map(({name:s,icon:o})=>a.jsx(\"button\",{type:\"button\",onClick:()=>{e(s),r(!1)},className:`p-2 rounded-lg transition-colors flex items-center justify-center ${t===s?\"bg-bambu-green text-white\":\"hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,title:s,children:a.jsx(o,{className:\"w-5 h-5\"})},s))})})]})]})}const DH=1144;function Sye(){const[t,e]=w.useState(()=>typeof window<\"u\"?window.innerWidth<DH:!1);return w.useEffect(()=>{const n=window.matchMedia(`(max-width: ${DH-1}px)`),r=i=>{e(i.matches)};return e(n.matches),n.addEventListener(\"change\",r),()=>n.removeEventListener(\"change\",r)},[]),t}let BO={},eZ=0;const uF=new Set;function _ye(t){const e={};for(const[n,r]of Object.entries(t)){if(!n||!r)continue;const i=n.replace(\"#\",\"\").toLowerCase().slice(0,6);i.length===6&&(e[i]=r)}BO=e,eZ+=1;for(const n of Array.from(uF))n()}function kye(t){return uF.add(t),()=>{uF.delete(t)}}function FH(){return eZ}function RH(t){if(!t||t.length<6)return\"Unknown\";const e=t.replace(\"#\",\"\"),n=parseInt(e.substring(0,2),16),r=parseInt(e.substring(2,4),16),i=parseInt(e.substring(4,6),16),s=Math.max(n,r,i)/255,o=Math.min(n,r,i)/255,l=(s+o)/2;let c=0,d=0;if(s!==o){const u=s-o;d=l>.5?u/(2-s-o):u/(s+o);const m=n/255,p=r/255,f=i/255;s===m?c=((p-f)/u+(p<f?6:0))/6:s===p?c=((f-m)/u+2)/6:c=((m-p)/u+4)/6}return c=c*360,l<.15?\"Black\":l>.85?\"White\":d<.15?l<.4?\"Dark Gray\":l>.6?\"Light Gray\":\"Gray\":c>=15&&c<45&&l<.45||c>=45&&c<70&&l<.4?\"Brown\":c<15||c>=345?\"Red\":c<45?\"Orange\":c<70?\"Yellow\":c<150?\"Green\":c<200?\"Cyan\":c<260?\"Blue\":c<290?\"Purple\":\"Pink\"}function rc(t){if(!t)return RH(t);const e=t.replace(\"#\",\"\").toLowerCase().substring(0,6),n=BO[e];return n||RH(t)}function KP(t,e){if(t&&!/^[A-Z]\\d+-[A-Z]\\d+$/.test(t))return t;if(e&&e.length>=6){const n=e.substring(0,6).toLowerCase(),r=BO[n];if(r)return r}return null}function HO(t){if(!t||t===\"00000000\"||t.length<6)return null;const e=t.slice(0,2),n=t.slice(2,4),r=t.slice(4,6),i=t.length>=8?parseInt(t.slice(6,8),16)/255:1;return i===0?null:`rgba(${parseInt(e,16)}, ${parseInt(n,16)}, ${parseInt(r,16)}, ${i})`}function tZ(t){if(!t||t.length<6)return!1;const e=t.replace(\"#\",\"\"),n=parseInt(e.slice(0,2),16),r=parseInt(e.slice(2,4),16),i=parseInt(e.slice(4,6),16);return(.299*n+.587*r+.114*i)/255>.6}function nZ(){return w.useSyncExternalStore(kye,FH,FH)}const rZ=w.createContext(void 0);function Nye({children:t}){const[e,n]=w.useState(null),[r,i]=w.useState(!1),[s,o]=w.useState(!1),[l,c]=w.useState(!0),d=w.useRef(!1),u=w.useRef(!0),m=async()=>{try{const T=new URLSearchParams(window.location.search),F=T.get(\"token\");if(F){kh(F,!1),T.delete(\"token\");const D=T.toString(),H=window.location.pathname+(D?`?${D}`:\"\")+window.location.hash;window.history.replaceState({},\"\",H)}const k=await ue.getAuthStatus();if(!u.current)return;if(i(k.auth_enabled),o(k.requires_setup),k.auth_enabled){const D=vm();if(D)try{const H=await ue.getCurrentUser();if(!u.current)return;n(H),F&&D===F&&kh(F,!0)}catch{if(kh(null),!u.current)return;n(null)}else n(null)}else n(null)}catch{if(!u.current)return;i(!1),n(null)}finally{u.current&&c(!1)}};w.useEffect(()=>(u.current=!0,m(),()=>{u.current=!1}),[]),w.useEffect(()=>{if(!l&&s&&!r){const T=window.location.pathname;T!==\"/setup\"&&!T.startsWith(\"/camera/\")&&!d.current&&(d.current=!0,window.location.href=\"/setup\")}else s||(d.current=!1)},[l,s,r]);const p=async(T,F)=>{const k=await ue.login({username:T,password:F});return!k.requires_2fa&&k.access_token&&(kh(k.access_token),await m()),k},f=(T,F)=>{kh(T),n(F),i(!0)},y=()=>{kh(null),n(null),ue.logout().catch(()=>{}),window.location.href=\"/login\"},v=async()=>{if(r&&vm())try{const T=await ue.getCurrentUser();u.current&&n(T)}catch{kh(null),u.current&&n(null)}},b=async()=>{await m()},g=w.useMemo(()=>new Set(e?.permissions??[]),[e?.permissions]),_=w.useMemo(()=>r?e?.is_admin??!1:!0,[r,e?.is_admin]),C=w.useCallback(T=>!r||_?!0:g.has(T),[r,_,g]),P=w.useCallback((...T)=>!r||_?!0:T.some(F=>g.has(F)),[r,_,g]),N=w.useCallback((...T)=>!r||_?!0:T.every(F=>g.has(F)),[r,_,g]),A=w.useCallback((T,F,k)=>{if(!r||_)return!0;const D=`${T}:${F}_all`,H=`${T}:${F}_own`;return g.has(D)?!0:g.has(H)?k==null?!1:k===e?.id:!1},[r,_,g,e?.id]);return a.jsx(rZ.Provider,{value:{user:e,authEnabled:r,requiresSetup:s,loading:l,isAdmin:_,login:p,loginWithToken:f,logout:y,refreshUser:v,refreshAuth:b,hasPermission:C,hasAnyPermission:P,hasAllPermissions:N,canModify:A},children:t})}function kr(){const t=w.useContext(rZ);if(t===void 0)throw new Error(\"useAuth must be used within an AuthProvider\");return t}function Lo(t){if(!Number.isFinite(t)||t<=0)return\"0 B\";const e=[\"B\",\"KB\",\"MB\",\"GB\",\"TB\"],n=1024,r=Math.floor(Math.log(t)/Math.log(n)),i=t/Math.pow(n,r);return r===0?`${i} ${e[r]}`:`${i.toFixed(1)} ${e[r]}`}const aZ=w.createContext(void 0);function hn(){const t=w.useContext(aZ);if(!t)throw new Error(\"useToast must be used within a ToastProvider\");return t}const LH={success:a.jsx(yr,{className:\"w-5 h-5 text-green-400\"}),error:a.jsx(Ma,{className:\"w-5 h-5 text-red-400\"}),warning:a.jsx(ei,{className:\"w-5 h-5 text-yellow-400\"}),info:a.jsx(Ss,{className:\"w-5 h-5 text-blue-400\"}),loading:a.jsx(ft,{className:\"w-5 h-5 text-bambu-green animate-spin\"})},Cye={success:\"bg-green-500/10 border-green-500/30\",error:\"bg-red-500/10 border-red-500/30\",warning:\"bg-yellow-500/10 border-yellow-500/30\",info:\"bg-blue-500/10 border-blue-500/30\",loading:\"bg-bambu-green/10 border-bambu-green/30\"};function Pye({children:t}){const{t:e}=Ft(),[n,r]=w.useState([]),[i,s]=w.useState(!1),[o,l]=w.useState(new Set),c=w.useRef(new Map),d=\"background-dispatch\",u=w.useRef(null),m=w.useRef(!0);w.useEffect(()=>{m.current=!0;const b=c.current;return()=>{m.current=!1,b.forEach(g=>clearTimeout(g)),b.clear()}},[]);const p=w.useCallback((b,g=\"success\")=>{if(!m.current)return;const _=Math.random().toString(36).substr(2,9);r(P=>[...P,{id:_,message:b,type:g}]);const C=setTimeout(()=>{m.current&&(r(P=>P.filter(N=>N.id!==_)),c.current.delete(_))},3e3);c.current.set(_,C)},[]),f=w.useCallback((b,g,_=\"info\")=>{m.current&&r(C=>C.find(N=>N.id===b)?C.map(N=>N.id===b?{...N,message:g,type:_,persistent:!0}:N):[...C,{id:b,message:g,type:_,persistent:!0}])},[]),y=w.useCallback(b=>{if(!m.current)return;const g=c.current.get(b);g&&(clearTimeout(g),c.current.delete(b)),r(_=>_.filter(C=>C.id!==b))},[]),v=w.useCallback(async b=>{l(g=>{const _=new Set(g);return _.add(b),_});try{const g=await ue.cancelBackgroundDispatchJob(b);p(g.status===\"cancelling\"?e(\"backgroundDispatch.toast.cancellingUpload\"):e(\"backgroundDispatch.toast.cancelled\"),\"info\")}catch(g){const _=g instanceof Error?g.message:e(\"backgroundDispatch.toast.cancelFailed\");p(_,\"error\")}finally{l(g=>{const _=new Set(g);return _.delete(b),_})}},[p,e]);return w.useEffect(()=>{const b=(C,P,N)=>{const A=C.findIndex(F=>F.jobId===P);if(A===-1)return[...C,{jobId:P,...N}];const T=[...C];return T[A]={...T[A],...N},T},g=C=>{switch(C){case\"failed\":return 0;case\"processing\":return 1;case\"dispatched\":return 2;case\"completed\":return 3;case\"cancelled\":return 4}},_=C=>{const P=C.detail||{},N=P.total??0,A=P.dispatched??0,T=P.processing??0,F=P.completed??0,k=P.failed??0,D=A+T>0,H=N>0&&F+k>=N&&!D,z=P.recent_event?.status;if(z===\"completed\"&&F>0){const Q=`first-complete:${F}:${k}`;if(u.current!==Q){u.current=Q;const L=N-F-k,te=L>0?e(\"backgroundDispatch.toast.printStartedRemaining\",{completed:F,remaining:L}):k>0?e(\"backgroundDispatch.toast.completeWithFailures\",{completed:F,failed:k}):e(\"backgroundDispatch.toast.completeSuccess\",{completed:F});r(oe=>{const fe={id:d,message:te,type:k>0?\"warning\":\"success\",persistent:!0};return oe.find(W=>W.id===d)?oe.map(W=>W.id===d?fe:W):[...oe,fe]});const ie=c.current.get(d);ie&&clearTimeout(ie);const J=setTimeout(()=>{m.current&&(r(oe=>oe.filter(fe=>fe.id!==d)),c.current.delete(d),u.current=null)},3e3);c.current.set(d,J)}return}if(D){u.current=null,r(Q=>{const te=Q.find(be=>be.id===d)?.dispatchData?.jobs||[],ie=(P.dispatched_jobs||[]).map(be=>({jobId:be.job_id,sourceName:be.source_name||e(\"backgroundDispatch.unknownFile\"),printerName:be.printer_name||e(\"backgroundDispatch.unknownPrinter\"),status:\"dispatched\"})),oe=(P.active_jobs&&P.active_jobs.length>0?P.active_jobs:P.active_job?.job_id?[P.active_job]:[]).filter(be=>typeof be.job_id==\"number\").map(be=>({jobId:be.job_id,sourceName:be.source_name||e(\"backgroundDispatch.unknownFile\"),printerName:be.printer_name||e(\"backgroundDispatch.unknownPrinter\"),status:\"processing\",message:be.message,uploadBytes:be.upload_bytes,uploadTotalBytes:be.upload_total_bytes,uploadProgressPct:be.upload_progress_pct})),fe=new Set([...ie,...oe].map(be=>be.jobId)),re=te.filter(be=>!fe.has(be.jobId)&&[\"completed\",\"failed\",\"cancelled\"].includes(be.status));let W=[...ie,...oe,...re];if(P.recent_event?.job_id&&P.recent_event?.status){const be=P.recent_event.status,Ce=be===\"cancelled\"?\"cancelled\":be===\"cancelling\"?\"processing\":be,q=P.recent_event.source_name||e(\"backgroundDispatch.unknownFile\"),Y=P.recent_event.printer_name||e(\"backgroundDispatch.unknownPrinter\");W=b(W,P.recent_event.job_id,{status:Ce,sourceName:q,printerName:Y,message:P.recent_event.message})}oe.forEach(be=>{W=b(W,be.jobId,{status:\"processing\",sourceName:be.sourceName,printerName:be.printerName,message:be.message,uploadBytes:be.uploadBytes,uploadTotalBytes:be.uploadTotalBytes,uploadProgressPct:be.uploadProgressPct})});const ne={total:N,dispatched:A,processing:T,completed:F,failed:k,jobs:[...W].sort((be,Ce)=>{const q=g(be.status)-g(Ce.status);return q!==0?q:be.jobId-Ce.jobId})};return Q.find(be=>be.id===d)?Q.map(be=>be.id===d?{...be,message:e(\"backgroundDispatch.startingPrints\"),type:\"loading\",persistent:!0,dispatchData:ne}:be):[...Q,{id:d,message:e(\"backgroundDispatch.startingPrints\"),type:\"loading\",persistent:!0,dispatchData:ne}]});return}if(H){const Q=`${F}:${k}`;if(u.current===Q)return;u.current=Q;const L=k>0?e(\"backgroundDispatch.toast.completeWithFailures\",{completed:F,failed:k}):e(\"backgroundDispatch.toast.completeSuccess\",{completed:F});r(J=>{const oe={id:d,message:L,type:k>0?\"warning\":\"success\",persistent:!0};return J.find(re=>re.id===d)?J.map(re=>re.id===d?oe:re):[...J,oe]});const te=c.current.get(d);te&&clearTimeout(te);const ie=setTimeout(()=>{r(J=>J.filter(oe=>oe.id!==d)),c.current.delete(d),u.current=null},3e3);c.current.set(d,ie);return}if(!D&&z&&[\"cancelled\",\"failed\",\"completed\",\"idle\"].includes(z)&&(r(Q=>Q.filter(L=>L.id!==d)),u.current=null),P.recent_event?.status===\"idle\"&&!D&&(r(Q=>Q.filter(L=>L.id!==d)),u.current=null),D||l(new Set),P.dispatched_jobs){const Q=new Set(P.dispatched_jobs.map(L=>L.job_id));l(L=>{const te=new Set;return L.forEach(ie=>{Q.has(ie)&&te.add(ie)}),te})}};return window.addEventListener(\"background-dispatch\",_),()=>window.removeEventListener(\"background-dispatch\",_)},[e]),a.jsxs(aZ.Provider,{value:{showToast:p,showPersistentToast:f,dismissToast:y},children:[t,a.jsx(\"div\",{className:\"fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2\",children:n.map(b=>a.jsx(\"div\",{className:`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${Cye[b.type]} ${b.dispatchData?\"w-[420px] p-3\":\"flex items-center gap-3 px-4 py-3\"}`,children:b.dispatchData?a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"flex items-start justify-between gap-3\",children:[a.jsxs(\"div\",{className:\"flex items-start gap-2\",children:[LH[b.type],a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white text-sm font-medium\",children:e(\"backgroundDispatch.startingPrints\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-0.5\",children:e(\"backgroundDispatch.progressSummary\",{complete:b.dispatchData.completed+b.dispatchData.failed,total:b.dispatchData.total,dispatched:b.dispatchData.dispatched,processing:b.dispatchData.processing})})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"button\",{onClick:()=>s(g=>!g),className:\"text-bambu-gray hover:text-white transition-colors\",\"aria-label\":e(i?\"backgroundDispatch.expandDetails\":\"backgroundDispatch.collapseDetails\"),children:i?a.jsx(cc,{className:\"w-4 h-4\"}):a.jsx(On,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>y(b.id),className:\"text-bambu-gray hover:text-white transition-colors\",\"aria-label\":e(\"backgroundDispatch.dismissToast\"),children:a.jsx(Ht,{className:\"w-4 h-4\"})})]})]}),!i&&a.jsx(\"div\",{className:\"mt-3 space-y-2 max-h-64 overflow-y-auto pr-1\",children:b.dispatchData.jobs.map(g=>{const _={dispatched:15,processing:60,completed:100,failed:100,cancelled:100},C={dispatched:\"bg-bambu-gray/60\",processing:\"bg-bambu-green\",completed:\"bg-green-500\",failed:\"bg-red-500\",cancelled:\"bg-yellow-500\"};return a.jsxs(\"div\",{className:\"rounded border border-white/10 bg-black/15 p-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2\",children:[a.jsx(\"span\",{className:\"text-xs text-white truncate\",title:g.sourceName,children:g.sourceName}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[(g.status===\"dispatched\"||g.status===\"processing\")&&a.jsx(\"button\",{onClick:()=>{v(g.jobId)},disabled:o.has(g.jobId),className:\"text-[11px] text-red-300 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed\",title:e(\"backgroundDispatch.cancelDispatchJob\"),children:o.has(g.jobId)?e(\"backgroundDispatch.cancelling\"):e(\"backgroundDispatch.cancel\")}),a.jsx(\"span\",{className:\"text-[11px] uppercase tracking-wide text-bambu-gray\",children:e(`backgroundDispatch.status.${g.status}`)})]})]}),a.jsx(\"div\",{className:\"text-[11px] text-bambu-gray truncate\",title:g.printerName,children:g.printerName}),g.message&&a.jsx(\"div\",{className:\"text-[11px] text-bambu-gray truncate\",title:g.message,children:g.message}),g.status===\"processing\"&&typeof g.uploadBytes==\"number\"&&typeof g.uploadTotalBytes==\"number\"&&g.uploadTotalBytes>0&&a.jsxs(\"div\",{className:\"text-[11px] text-bambu-gray truncate\",children:[Lo(g.uploadBytes),\" / \",Lo(g.uploadTotalBytes),typeof g.uploadProgressPct==\"number\"?` (${g.uploadProgressPct.toFixed(1)}%)`:\"\"]}),a.jsx(\"div\",{className:\"mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden\",children:a.jsx(\"div\",{className:`h-full ${C[g.status]} transition-all duration-300`,style:{width:`${g.status===\"processing\"&&typeof g.uploadProgressPct==\"number\"?Math.max(0,Math.min(100,g.uploadProgressPct)):_[g.status]}%`}})})]},g.jobId)})})]}):a.jsxs(a.Fragment,{children:[LH[b.type],a.jsx(\"span\",{className:\"text-white text-sm\",children:b.message}),a.jsx(\"button\",{onClick:()=>y(b.id),className:\"ml-2 text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]})},b.id))})]})}function Tye(t=\"system\"){const e=t===\"system\"?iZ():t;switch(e){case\"us\":return\"MM/DD/YYYY\";case\"eu\":return\"DD/MM/YYYY\";case\"iso\":return\"YYYY-MM-DD\";default:return e}}function Aye(t=\"system\"){switch(t){case\"12h\":return\"HH:MM AM/PM\";case\"24h\":return\"HH:MM\";default:{const n=new Date(2e3,0,1,14,30).toLocaleTimeString();return n.includes(\"PM\")||n.includes(\"AM\")?\"HH:MM AM/PM\":\"HH:MM\"}}}function mF(t,e=\"system\"){const n=String(t.getDate()).padStart(2,\"0\"),r=String(t.getMonth()+1).padStart(2,\"0\"),i=t.getFullYear();switch(e){case\"us\":return`${r}/${n}/${i}`;case\"eu\":return`${n}/${r}/${i}`;case\"iso\":return`${i}-${r}-${n}`;default:return t.toLocaleDateString()}}function OH(t,e=\"system\"){const n=t.getHours(),r=String(t.getMinutes()).padStart(2,\"0\");switch(e){case\"12h\":{const i=n%12||12,s=n<12?\"AM\":\"PM\";return`${i}:${r} ${s}`}case\"24h\":return`${String(n).padStart(2,\"0\")}:${r}`;default:return t.toLocaleTimeString([],{hour:\"2-digit\",minute:\"2-digit\"})}}function jye(t){for(const e of[\"/\",\".\",\"-\"]){const n=t.split(e);if(n.length===3)return n}return null}function iZ(){const t=new Date(2e3,11,31).toLocaleDateString();return t.startsWith(\"12\")?\"us\":t.startsWith(\"31\")?\"eu\":\"iso\"}function Mye(t,e=\"system\"){if(!t)return null;const n=jye(t);if(!n)return null;const r=e===\"system\"?iZ():e;let i,s,o;switch(r){case\"us\":s=parseInt(n[0],10),i=parseInt(n[1],10),o=parseInt(n[2],10);break;case\"eu\":i=parseInt(n[0],10),s=parseInt(n[1],10),o=parseInt(n[2],10);break;case\"iso\":o=parseInt(n[0],10),s=parseInt(n[1],10),i=parseInt(n[2],10);break}return isNaN(i)||isNaN(s)||isNaN(o)||s<1||s>12||i<1||i>31||o<1900||o>2100?null:new Date(o,s-1,i)}function Eye(t){if(!t)return null;const n=t.trim().match(/^(\\d{1,2}):(\\d{2})\\s*(AM|PM)?$/i);if(!n)return null;let r=parseInt(n[1],10);const i=parseInt(n[2],10),s=n[3]?.toUpperCase();return s===\"PM\"&&r<12&&(r+=12),s===\"AM\"&&r===12&&(r=0),r<0||r>23||i<0||i>59?null:{hours:r,minutes:i}}function hF(t){const e=t.getFullYear(),n=String(t.getMonth()+1).padStart(2,\"0\"),r=String(t.getDate()).padStart(2,\"0\"),i=String(t.getHours()).padStart(2,\"0\"),s=String(t.getMinutes()).padStart(2,\"0\");return`${e}-${n}-${r}T${i}:${s}`}function ow(t,e=\"system\"){return e===\"12h\"?t.hour12=!0:e===\"24h\"&&(t.hour12=!1),t}function Zn(t){return t?t.endsWith(\"Z\")||/[+-]\\d{2}:\\d{2}$/.test(t)?new Date(t):new Date(t+\"Z\"):null}function hg(t,e){const n=Zn(t);if(!n)return\"\";const r={year:\"numeric\",month:\"short\",day:\"numeric\"};return n.toLocaleDateString(void 0,e??r)}function pg(t,e=\"system\",n){const r=Zn(t);if(!r)return\"\";const s=ow(n??{year:\"numeric\",month:\"short\",day:\"numeric\",hour:\"2-digit\",minute:\"2-digit\"},e);return r.toLocaleString(void 0,s)}function sZ(t,e=\"system\",n){const i=ow({...{hour:\"2-digit\",minute:\"2-digit\"},...n},e);return t.toLocaleTimeString([],i)}function qO(t,e=\"system\",n){const r=new Date,i=new Date(r.getTime()+t*60*1e3),s=new Date(r);s.setHours(0,0,0,0);const o=new Date(i);o.setHours(0,0,0,0);const l=ow({hour:\"2-digit\",minute:\"2-digit\"},e),c=i.toLocaleTimeString([],l),d=Math.floor((o.getTime()-s.getTime())/864e5);return d===0?c:d===1?`${n?.(\"common.tomorrow\")??\"Tomorrow\"} ${c}`:`${i.toLocaleDateString([],{weekday:\"short\"})} ${c}`}function ws(t){if(t==null||t<0)return\"--\";const e=Math.floor(t/3600),n=Math.floor(t%3600/60);return e>0?`${e}h ${n}m`:`${n}m`}function ap(t,e=\"system\",n){if(!t)return n?.(\"time.unknown\")??\"-\";const r=Zn(t);if(!r)return n?.(\"time.unknown\")??\"-\";const i=new Date,s=r.getTime()-i.getTime(),o=s<0,l=Math.abs(s),c=Math.floor(l/6e4),d=Math.floor(l/36e5),u=Math.floor(l/864e5);return c<1?o?n?.(\"time.justNow\")??\"Just now\":n?.(\"time.now\")??\"Now\":d<1?o?n?.(\"time.minsAgo\",{count:c})??`${c}m ago`:n?.(\"time.inMins\",{count:c})??`in ${c}m`:u<1?o?n?.(\"time.hoursAgo\",{count:d})??`${d}h ago`:n?.(\"time.inHours\",{count:d})??`in ${d}h`:u<7?o?n?.(\"time.daysAgo\",{count:u})??`${u}d ago`:n?.(\"time.inDays\",{count:u})??`in ${u}d`:pg(t,e)}function Ch(t){const e=Math.floor(t/60),n=Math.floor(t%60);return`${e}:${n.toString().padStart(2,\"0\")}`}function Dye(t){if(t<1)return`${Math.round(t*60)}m`;const e=Math.floor(t),n=Math.round((t-e)*60);return n>0?`${e}h ${n}m`:`${e}h`}const MM=1920,Fye=.7,Rye=300;function Lye(t){return new Promise((e,n)=>{const r=new Image;r.onload=()=>{let{width:i,height:s}=r;if(i>MM||s>MM){const d=MM/Math.max(i,s);i=Math.round(i*d),s=Math.round(s*d)}const o=document.createElement(\"canvas\");o.width=i,o.height=s;const l=o.getContext(\"2d\");if(!l){n(new Error(\"No canvas context\"));return}l.drawImage(r,0,0,i,s);const c=o.toDataURL(\"image/jpeg\",Fye);e(c.replace(/^data:[^;]+;base64,/,\"\"))},r.onerror=n,r.src=URL.createObjectURL(t)})}function Oye(t){const e=Math.floor(t/60),n=t%60;return`${e.toString().padStart(2,\"0\")}:${n.toString().padStart(2,\"0\")}`}function Iye(){const{t}=Ft(),[e,n]=w.useState(!1),[r,i]=w.useState(\"form\"),[s,o]=w.useState(\"\"),[l,c]=w.useState(\"\"),[d,u]=w.useState(null),[m,p]=w.useState(!1),[f,y]=w.useState(null),[v,b]=w.useState(null),[g,_]=w.useState(\"\"),[C,P]=w.useState(0),[N,A]=w.useState(!1),T=w.useRef(null),F=w.useRef(null),k=w.useRef(()=>{});w.useEffect(()=>{if(r!==\"logging\")return;if(C>=Rye){k.current();return}const re=setTimeout(()=>P(W=>W+1),1e3);return()=>clearTimeout(re)},[r,C]);const D=()=>{n(!0),i(\"form\"),o(\"\"),c(\"\"),u(null),y(null),b(null),_(\"\"),P(0),A(!1)},H=()=>{n(!1)},z=w.useCallback(async re=>{if(re.type.startsWith(\"image/\"))try{const W=await Lye(re);u(W)}catch{}},[]),Q=w.useCallback(re=>{const W=re.clipboardData?.items;if(W){for(const ne of W)if(ne.type.startsWith(\"image/\")){const me=ne.getAsFile();me&&z(me);break}}},[z]),L=w.useCallback(re=>{re.preventDefault(),p(!0)},[]),te=w.useCallback(re=>{re.preventDefault(),p(!1)},[]),ie=w.useCallback(re=>{re.preventDefault(),p(!1);const W=re.dataTransfer.files?.[0];W&&z(W)},[z]),J=async()=>{if(s.trim())try{const re=await jM.startLogging();A(re.was_debug),P(0),i(\"logging\")}catch(re){_(re instanceof Error?re.message:t(\"bugReport.unexpectedError\")),i(\"error\")}},oe=async()=>{i(\"stopping\");try{const re=await jM.stopLogging(N);await fe(re.logs)}catch(re){_(re instanceof Error?re.message:t(\"bugReport.unexpectedError\")),i(\"error\")}};k.current=oe;const fe=async re=>{i(\"submitting\");try{const W=await jM.submit({description:s.trim(),email:l.trim()||void 0,screenshot_base64:d||void 0,include_support_info:!0,debug_logs:re||void 0});W.success?(y(W.issue_url||null),b(W.issue_number||null),i(\"success\")):(_(W.message),i(\"error\"))}catch(W){_(W instanceof Error?W.message:t(\"bugReport.unexpectedError\")),i(\"error\")}};return a.jsxs(a.Fragment,{children:[a.jsx(\"button\",{onClick:D,className:\"fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center\",title:t(\"bugReport.title\"),children:a.jsx(Hx,{className:\"w-5 h-5\"})}),e&&a.jsx(\"div\",{id:\"bug-report-modal\",className:\"fixed bottom-20 right-4 z-50 w-full max-w-md\",onPaste:Q,children:a.jsxs(\"div\",{ref:T,className:\"bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] overflow-y-auto\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2\",children:[a.jsx(Hx,{className:\"w-5 h-5 text-red-500\"}),t(\"bugReport.title\")]}),a.jsx(\"button\",{onClick:H,className:\"text-gray-400 hover:text-gray-600 dark:hover:text-gray-300\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-4\",children:[r===\"form\"&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\",children:[t(\"bugReport.description\"),\" *\"]}),a.jsx(\"textarea\",{value:s,onChange:re=>o(re.target.value),placeholder:t(\"bugReport.descriptionPlaceholder\"),rows:3,className:\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\",children:t(\"bugReport.email\")}),a.jsx(\"input\",{type:\"email\",value:l,onChange:re=>c(re.target.value),placeholder:t(\"bugReport.emailPlaceholder\"),className:\"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent\"}),a.jsx(\"p\",{className:\"mt-1 text-xs text-gray-500 dark:text-gray-400\",children:t(\"bugReport.emailPrivacy\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1\",children:t(\"bugReport.screenshot\")}),d?a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"img\",{src:`data:image/jpeg;base64,${d}`,alt:t(\"bugReport.screenshot\"),className:\"w-full max-h-40 object-contain rounded-lg border border-gray-200 dark:border-gray-600\"}),a.jsx(\"button\",{onClick:()=>u(null),className:\"absolute top-2 right-2 p-1 bg-red-500 hover:bg-red-600 text-white rounded-full shadow\",title:t(\"common.delete\"),children:a.jsx(en,{className:\"w-3 h-3\"})})]}):a.jsxs(\"button\",{type:\"button\",onClick:()=>F.current?.click(),onDragOver:L,onDragLeave:te,onDrop:ie,className:`w-full flex flex-col items-center gap-2 px-4 py-4 border-2 border-dashed rounded-lg transition-colors cursor-pointer ${m?\"border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500\":\"border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-gray-400 dark:hover:border-gray-500 hover:text-gray-600 dark:hover:text-gray-300\"}`,children:[a.jsx(La,{className:\"w-5 h-5\"}),a.jsx(\"span\",{className:\"text-sm\",children:t(\"bugReport.uploadOrPaste\")})]}),a.jsx(\"input\",{ref:F,type:\"file\",accept:\"image/*\",className:\"hidden\",onChange:re=>{const W=re.target.files?.[0];W&&z(W),re.target.value=\"\"}})]}),a.jsxs(\"details\",{className:\"text-xs bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3\",children:[a.jsx(\"summary\",{className:\"cursor-pointer font-medium text-amber-700 dark:text-amber-300 hover:text-amber-800 dark:hover:text-amber-200\",children:t(\"bugReport.dataCollectedSummary\")}),a.jsxs(\"div\",{className:\"mt-2 space-y-2 pl-2 border-l-2 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200\",children:[a.jsx(\"p\",{className:\"font-medium\",children:t(\"bugReport.dataIncluded\")}),a.jsx(\"p\",{children:t(\"bugReport.dataIncludedList\")}),a.jsx(\"p\",{className:\"font-medium\",children:t(\"bugReport.dataNeverIncluded\")}),a.jsx(\"p\",{children:t(\"bugReport.dataNeverIncludedList\")})]})]}),a.jsxs(\"div\",{className:\"flex justify-end gap-2 pt-2\",children:[a.jsx(\"button\",{onClick:H,className:\"px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors\",children:t(\"common.cancel\")}),a.jsx(\"button\",{onClick:J,disabled:!s.trim(),className:\"px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors\",children:t(\"bugReport.startLogging\")})]})]}),r===\"logging\"&&a.jsxs(\"div\",{className:\"py-6 space-y-6\",children:[a.jsxs(\"div\",{className:\"space-y-3 px-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(Vc,{className:\"w-5 h-5 text-green-500 flex-shrink-0\"}),a.jsx(\"span\",{className:\"text-sm text-green-700 dark:text-green-400\",children:t(\"bugReport.stepEnableLogging\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsxs(\"span\",{className:\"relative flex h-5 w-5 flex-shrink-0 items-center justify-center\",children:[a.jsx(\"span\",{className:\"animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75\"}),a.jsx(\"span\",{className:\"relative inline-flex rounded-full h-3 w-3 bg-blue-500\"})]}),a.jsx(\"span\",{className:\"text-sm font-medium text-blue-700 dark:text-blue-300\",children:t(\"bugReport.stepReproduce\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(aw,{className:\"w-5 h-5 text-gray-300 dark:text-gray-600 flex-shrink-0\"}),a.jsx(\"span\",{className:\"text-sm text-gray-400 dark:text-gray-500\",children:t(\"bugReport.stepStopLogging\")})]})]}),a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(\"p\",{className:\"text-3xl font-mono text-blue-500\",children:Oye(C)}),a.jsx(\"p\",{className:\"text-xs text-gray-500 dark:text-gray-400 mt-1\",children:t(\"bugReport.maxDuration\",{minutes:5})})]}),a.jsx(\"div\",{className:\"flex justify-center\",children:a.jsx(\"button\",{onClick:oe,className:\"px-6 py-2.5 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors\",children:t(\"bugReport.stopAndSubmit\")})})]}),(r===\"stopping\"||r===\"submitting\")&&a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center py-8 gap-3\",children:[a.jsx(ft,{className:\"w-8 h-8 animate-spin text-blue-500\"}),a.jsx(\"p\",{className:\"text-sm text-gray-600 dark:text-gray-400\",children:t(r===\"stopping\"?\"bugReport.stoppingLogs\":\"bugReport.submitting\")})]}),r===\"success\"&&a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center py-8 gap-3\",children:[a.jsx(yr,{className:\"w-12 h-12 text-green-500\"}),a.jsx(\"p\",{className:\"text-lg font-semibold text-gray-900 dark:text-white\",children:t(\"bugReport.thankYou\")}),a.jsx(\"p\",{className:\"text-sm text-gray-600 dark:text-gray-400\",children:t(\"bugReport.submitted\")}),f&&a.jsxs(\"a\",{href:f,target:\"_blank\",rel:\"noopener noreferrer\",className:\"text-sm text-blue-500 hover:text-blue-600 underline\",children:[t(\"bugReport.viewIssue\"),\" #\",v]}),a.jsx(\"button\",{onClick:H,className:\"mt-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors\",children:t(\"common.close\")})]}),r===\"error\"&&a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center py-8 gap-3\",children:[a.jsx(ei,{className:\"w-12 h-12 text-red-500\"}),a.jsx(\"p\",{className:\"text-lg font-semibold text-gray-900 dark:text-white\",children:t(\"bugReport.submitFailed\")}),a.jsx(\"p\",{className:\"text-sm text-gray-600 dark:text-gray-400 text-center\",children:g}),a.jsxs(\"div\",{className:\"flex gap-2 mt-4\",children:[a.jsx(\"button\",{onClick:()=>i(\"form\"),className:\"px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors\",children:t(\"bugReport.submit\")}),a.jsx(\"button\",{onClick:H,className:\"px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors\",children:t(\"common.close\")})]})]})]})]})})]})}const Zu=[{id:\"printers\",to:\"/\",icon:Er,labelKey:\"nav.printers\"},{id:\"archives\",to:\"/archives\",icon:so,labelKey:\"nav.archives\"},{id:\"queue\",to:\"/queue\",icon:oa,labelKey:\"nav.queue\"},{id:\"stats\",to:\"/stats\",icon:Kpe,labelKey:\"nav.stats\"},{id:\"profiles\",to:\"/profiles\",icon:vy,labelKey:\"nav.profiles\"},{id:\"maintenance\",to:\"/maintenance\",icon:mg,labelKey:\"nav.maintenance\"},{id:\"projects\",to:\"/projects\",icon:Bs,labelKey:\"nav.projects\"},{id:\"inventory\",to:\"/inventory\",icon:zfe,labelKey:\"nav.inventory\"},{id:\"files\",to:\"/files\",icon:Qc,labelKey:\"nav.files\"},{id:\"notifications\",to:\"/notifications\",icon:Zh,labelKey:\"nav.notifications\"},{id:\"settings\",to:\"/settings\",icon:sS,labelKey:\"nav.settings\"}];function zye(){const t=localStorage.getItem(\"sidebarOrder\");if(t)try{return JSON.parse(t)}catch{return Zu.map(e=>e.id)}return Zu.map(e=>e.id)}function IH(t){localStorage.setItem(\"sidebarOrder\",JSON.stringify(t))}function Q_(t){return t.startsWith(\"ext-\")}function oZ(){return localStorage.getItem(\"defaultView\")||\"/\"}function Uye(t){localStorage.setItem(\"defaultView\",t)}function Bye(){const t=qo(),e=td(),{mode:n,toggleMode:r}=zg(),{t:i}=Ft(),s=Sye();nZ();const{user:o,authEnabled:l,logout:c,hasPermission:d}=kr(),{showToast:u}=hn(),[m,p]=w.useState(!1),[f,y]=w.useState({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"}),[v,b]=w.useState(!1),[g,_]=w.useState(()=>localStorage.getItem(\"sidebarExpanded\")!==\"false\"),[C,P]=w.useState(!1),[N,A]=w.useState(!1),[T,F]=w.useState(!1),[k,D]=w.useState(zye),[H,z]=w.useState(null),[Q,L]=w.useState(null),te=w.useRef(!1),[ie,J]=w.useState(()=>sessionStorage.getItem(\"dismissedUpdateVersion\")),[oe,fe]=w.useState(null),{data:re}=Xe({queryKey:[\"version\"],queryFn:ue.getVersion,staleTime:1/0}),{data:W}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings,staleTime:300*1e3}),{data:ne}=Xe({queryKey:[\"default-sidebar-order\"],queryFn:ue.getDefaultSidebarOrder,staleTime:300*1e3});w.useEffect(()=>{const ye=ne?.default_sidebar_order;if(!ye||l&&!o)return;const je=o?`sidebarDefaultApplied_${o.id}`:\"sidebarDefaultApplied\";if(!localStorage.getItem(je))try{const Le=JSON.parse(ye),Me=Array.isArray(Le)?Le:Le.order;if(!Array.isArray(Me)||Me.length===0)return;const Oe=new Set(Zu.map($e=>$e.id)),Re=Me.filter($e=>typeof $e==\"string\"&&(Oe.has($e)||Q_($e)));Re.length>0&&(D(Re),IH(Re),localStorage.setItem(je,\"1\"))}catch(Le){console.error(\"Failed to apply default sidebar order:\",Le)}},[ne?.default_sidebar_order,D,o,l]);const{data:me}=Xe({queryKey:[\"advancedAuthStatus\"],queryFn:ue.getAdvancedAuthStatus,staleTime:300*1e3,enabled:l}),{data:be}=Xe({queryKey:[\"updateCheck\"],queryFn:ue.checkForUpdates,enabled:W?.check_updates!==!1,staleTime:3600*1e3,refetchInterval:3600*1e3}),{data:Ce}=Xe({queryKey:[\"external-links\"],queryFn:ue.getExternalLinks}),{data:q}=Xe({queryKey:[\"smart-plugs\"],queryFn:ue.getSmartPlugs,staleTime:30*1e3}),Y=q?.some(ye=>ye.show_in_switchbar)??!1,{data:E}=Xe({queryKey:[\"debugLogging\"],queryFn:Px.getDebugLoggingState,staleTime:60*1e3,refetchInterval:60*1e3}),{data:j}=Xe({queryKey:[\"developer-mode-warnings\"],queryFn:ue.getDeveloperModeWarnings,staleTime:10*1e3,refetchInterval:30*1e3,refetchOnWindowFocus:!0}),{data:O}=Xe({queryKey:[\"queue\",\"pending\"],queryFn:()=>ue.getQueue(void 0,\"pending\"),staleTime:5*1e3,refetchInterval:5*1e3,refetchOnWindowFocus:!0}),K=O?.length??0,{data:U}=Xe({queryKey:[\"pending-uploads\",\"count\"],queryFn:ux.getCount,staleTime:5*1e3,refetchInterval:5*1e3,refetchOnWindowFocus:!0}),de=U?.count??0,I=w.useMemo(()=>{const ye=new Set;return O?.forEach(je=>{je.printer_id&&ye.add(je.printer_id)}),Array.from(ye)},[O]),X=Pp({queries:I.map(ye=>({queryKey:[\"printerStatus\",ye],queryFn:()=>ue.getPrinterStatus(ye),staleTime:30*1e3}))}).some(ye=>{const je=ye.data;return je?!!je.awaiting_plate_clear:!1}),[V,ee]=w.useState(null);w.useEffect(()=>{if(!E?.enabled||!E.enabled_at){ee(null);return}const ye=Zn(E.enabled_at)?.getTime()??Date.now(),je=()=>{ee(Math.floor((Date.now()-ye)/1e3))};je();const Le=setInterval(je,1e3);return()=>clearInterval(Le)},[E?.enabled,E?.enabled_at]);const se=w.useMemo(()=>new Map(Zu.map(ye=>[ye.id,ye])),[]),ge=w.useMemo(()=>new Map((Ce||[]).map(ye=>[`ext-${ye.id}`,ye])),[Ce]),he=(()=>{const ye=[],je=new Set,Le={archives:\"archives:read\",queue:\"queue:read\",stats:\"stats:read\",profiles:\"kprofiles:read\",maintenance:\"maintenance:read\",projects:\"projects:read\",inventory:\"inventory:read\",files:\"library:read\",settings:\"settings:read\",notifications:\"notifications:user_email\"},Me=Oe=>!!(l&&Oe in Le&&!d(Le[Oe])||Oe===\"notifications\"&&(!l||!me?.advanced_auth_enabled||W?.user_notifications_enabled===!1));for(const Oe of k)Me(Oe)||(se.has(Oe)||ge.has(Oe))&&(ye.push(Oe),je.add(Oe));for(const Oe of Zu)Me(Oe.id)||je.has(Oe.id)||(ye.push(Oe.id),je.add(Oe.id));for(const Oe of Ce||[]){const Re=`ext-${Oe.id}`;je.has(Re)||(ye.push(Re),je.add(Re))}return ye})(),le=(ye,je)=>{z(je),ye.dataTransfer.effectAllowed=\"move\",ye.dataTransfer.setData(\"text/plain\",je)},B=(ye,je)=>{ye.preventDefault(),ye.dataTransfer.dropEffect=\"move\",L(je)},R=()=>{L(null)},ae=(ye,je)=>{if(ye.preventDefault(),H===null||H===je){z(null),L(null);return}const Le=[...he],Me=Le.indexOf(H),Oe=Le.indexOf(je);if(Me===-1||Oe===-1){z(null),L(null);return}Le.splice(Me,1),Le.splice(Oe,0,H),D(Le),IH(Le),z(null),L(null)},_e=()=>{z(null),L(null)},Se=be?.update_available&&be.latest_version&&be.latest_version!==ie,ve=()=>{be?.latest_version&&(sessionStorage.setItem(\"dismissedUpdateVersion\",be.latest_version),J(be.latest_version))};w.useEffect(()=>{if(!te.current&&e.pathname===\"/\"){const ye=oZ();ye!==\"/\"&&(te.current=!0,t(ye,{replace:!0}))}},[e.pathname,t]),w.useEffect(()=>{localStorage.setItem(\"sidebarExpanded\",String(g))},[g]),w.useEffect(()=>{s&&P(!1)},[e.pathname,s]),w.useEffect(()=>{const ye=je=>{if(!d(\"printers:control\"))return;const Le=je.detail;fe({printer_id:Le.printer_id,printer_name:Le.printer_name,message:Le.message})};return window.addEventListener(\"plate-not-empty\",ye),()=>window.removeEventListener(\"plate-not-empty\",ye)},[d]);const Te=w.useCallback(ye=>{const je=ye.target;if(!(je.tagName===\"INPUT\"||je.tagName===\"TEXTAREA\"||je.isContentEditable)&&!ye.metaKey&&!ye.ctrlKey&&!ye.altKey){const Le=parseInt(ye.key);if(Le>=1&&Le<=he.length&&Le<=9){const Me=he[Le-1];if(ye.preventDefault(),Q_(Me)){const Oe=ge.get(Me);if(Oe?.open_in_new_tab)window.open(Oe.url,\"_blank\",\"noopener,noreferrer\");else{const Re=Me.replace(\"ext-\",\"\");t(`/external/${Re}`)}}else{const Oe=se.get(Me);Oe&&t(Oe.to)}return}switch(ye.key){case\"?\":ye.preventDefault(),A(!0);break;case\"Escape\":A(!1);break}}},[t,he,se,ge]);return w.useEffect(()=>(document.addEventListener(\"keydown\",Te),()=>document.removeEventListener(\"keydown\",Te)),[Te]),a.jsxs(\"div\",{className:\"flex min-h-screen\",children:[s&&a.jsxs(\"header\",{className:\"fixed top-0 left-0 right-0 z-40 h-14 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-4\",children:[a.jsx(\"button\",{onClick:()=>P(!0),className:\"p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors\",\"aria-label\":\"Open menu\",children:a.jsx(ybe,{className:\"w-6 h-6 text-white\"})}),a.jsx(\"img\",{src:n===\"dark\"?\"/img/bambuddy_logo_dark_transparent.png\":\"/img/bambuddy_logo_light.png\",alt:\"Bambuddy\",className:\"h-8 ml-3\"})]}),s&&C&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/60 z-40 transition-opacity\",onClick:()=>P(!1)}),a.jsxs(\"aside\",{className:`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${s?`fixed inset-y-0 left-0 z-50 w-72 transform ${C?\"translate-x-0\":\"-translate-x-full\"}`:`fixed inset-y-0 left-0 z-30 ${g?\"w-64\":\"w-16\"}`}`,children:[a.jsx(\"div\",{className:`border-b border-bambu-dark-tertiary flex items-center justify-center ${s||g?\"p-4\":\"p-2\"}`,children:a.jsx(\"img\",{src:n===\"dark\"?\"/img/bambuddy_logo_dark_transparent.png\":\"/img/bambuddy_logo_light.png\",alt:\"Bambuddy\",className:s||g?\"h-16 w-auto\":\"h-8 w-8 object-cover object-left\"})}),a.jsx(\"nav\",{className:\"flex-1 p-2 overflow-y-auto\",children:a.jsx(\"ul\",{className:\"space-y-2\",children:he.map(ye=>{if(Q_(ye)){const Le=ge.get(ye);if(!Le)return null;const Me=Le.custom_icon?null:WP(Le.icon);return a.jsx(\"li\",{draggable:!0,onDragStart:Oe=>le(Oe,ye),onDragOver:Oe=>B(Oe,ye),onDragLeave:R,onDrop:Oe=>ae(Oe,ye),onDragEnd:_e,className:`relative ${H===ye?\"opacity-50\":\"\"} ${Q===ye&&H!==ye?\"before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green\":\"\"}`,children:Le.open_in_new_tab?a.jsxs(\"a\",{href:Le.url,target:\"_blank\",rel:\"noopener noreferrer\",className:`flex items-center ${s||g?\"gap-3 px-4\":\"justify-center px-2\"} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`,title:!s&&!g?Le.name:void 0,children:[g&&!s&&a.jsx(np,{className:\"w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1\"}),Le.custom_icon?a.jsx(\"img\",{src:ue.getExternalLinkIconUrl(Le.id),alt:\"\",className:\"w-5 h-5 flex-shrink-0\"}):Me&&a.jsx(Me,{className:\"w-5 h-5 flex-shrink-0\"}),(s||g)&&a.jsx(\"span\",{children:Le.name})]}):a.jsxs(xx,{to:`/external/${Le.id}`,className:({isActive:Oe})=>`flex items-center ${s||g?\"gap-3 px-4\":\"justify-center px-2\"} py-3 rounded-lg transition-colors group ${Oe?\"bg-bambu-green text-white\":\"text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white\"}`,title:!s&&!g?Le.name:void 0,children:[g&&!s&&a.jsx(np,{className:\"w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1\"}),Le.custom_icon?a.jsx(\"img\",{src:ue.getExternalLinkIconUrl(Le.id),alt:\"\",className:\"w-5 h-5 flex-shrink-0\"}):Me&&a.jsx(Me,{className:\"w-5 h-5 flex-shrink-0\"}),(s||g)&&a.jsx(\"span\",{children:Le.name})]})},ye)}else{const Le=se.get(ye);if(!Le)return null;const{to:Me,icon:Oe,labelKey:Re}=Le,$e=ye===\"queue\"&&K>0,Ye=ye===\"archives\"&&de>0,tt=$e?K:Ye?de:0,pe=$e||Ye,Fe=ye===\"printers\"&&X;return a.jsx(\"li\",{draggable:!0,onDragStart:we=>le(we,ye),onDragOver:we=>B(we,ye),onDragLeave:R,onDrop:we=>ae(we,ye),onDragEnd:_e,className:`relative ${H===ye?\"opacity-50\":\"\"} ${Q===ye&&H!==ye?\"before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green\":\"\"}`,children:a.jsxs(xx,{to:Me,className:({isActive:we})=>`flex items-center ${s||g?\"gap-3 px-4\":\"justify-center px-2\"} py-3 rounded-lg transition-colors group ${we?\"bg-bambu-green text-white\":\"text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white\"}`,title:!s&&!g?i(Re):void 0,children:[g&&!s&&a.jsx(np,{className:\"w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1\"}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Oe,{className:\"w-5 h-5 flex-shrink-0\"}),Fe&&a.jsx(\"span\",{className:\"absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-yellow-500 rounded-full border-2 border-bambu-dark-secondary\"}),pe&&a.jsx(\"span\",{className:`absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full ${Ye?\"bg-blue-500 text-white\":\"bg-yellow-500 text-black\"}`,children:tt>99?\"99+\":tt})]}),(s||g)&&a.jsx(\"span\",{children:i(Re)})]})},ye)}})})}),!s&&a.jsx(\"button\",{onClick:()=>_(!g),className:\"p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center\",title:i(g?\"nav.collapseSidebar\":\"nav.expandSidebar\"),children:g?a.jsx(xl,{className:\"w-5 h-5\"}):a.jsx(ti,{className:\"w-5 h-5\"})}),a.jsx(\"div\",{className:\"flex-shrink-0 p-2 border-t border-bambu-dark-tertiary\",children:s||g?a.jsxs(\"div\",{className:\"flex flex-col gap-2 px-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-center gap-1 flex-wrap\",children:[Y&&a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"button\",{onMouseEnter:()=>F(!0),className:`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${T?\"text-bambu-green\":\"text-bambu-gray-light hover:text-white\"}`,title:i(\"nav.smartSwitches\",{defaultValue:\"Smart Switches\"}),children:a.jsx(nc,{className:\"w-5 h-5\"})}),T&&a.jsx(EH,{onClose:()=>F(!1)})]}),d(\"system:read\")?a.jsx(xx,{to:\"/system\",className:({isActive:ye})=>`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${ye?\"text-bambu-green\":\"text-bambu-gray-light hover:text-white\"}`,title:i(\"nav.system\"),children:a.jsx(Ss,{className:\"w-5 h-5\"})}):a.jsx(\"span\",{className:\"p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed\",title:\"You do not have permission to view system information\",children:a.jsx(Ss,{className:\"w-5 h-5\"})}),a.jsx(\"a\",{href:\"https://github.com/maziggy/bambuddy\",target:\"_blank\",rel:\"noopener noreferrer\",className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(\"nav.viewOnGithub\"),children:a.jsx(aF,{className:\"w-5 h-5\"})}),a.jsx(\"button\",{onClick:()=>A(!0),className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(\"nav.keyboardShortcuts\"),children:a.jsx(iF,{className:\"w-5 h-5\"})}),a.jsx(\"button\",{onClick:r,className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(n===\"dark\"?\"nav.switchToLight\":\"nav.switchToDark\"),children:n===\"dark\"?a.jsx(AH,{className:\"w-5 h-5\"}):a.jsx(kN,{className:\"w-5 h-5\"})}),l&&o&&a.jsxs(a.Fragment,{children:[a.jsx(\"button\",{onClick:()=>p(!0),className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(\"changePassword.title\"),children:a.jsx(no,{className:\"w-5 h-5\"})}),a.jsx(\"button\",{onClick:c,className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(\"nav.logout\",{defaultValue:\"Logout\"}),children:a.jsx(oF,{className:\"w-5 h-5\"})})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-center gap-2\",children:[a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[\"v\",re?.version||\"...\"]}),be?.update_available&&a.jsxs(\"button\",{onClick:()=>t(\"/settings\"),className:\"flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors\",title:i(\"nav.updateAvailable\",{version:be.latest_version}),children:[a.jsx(TM,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:i(\"nav.update\")})]})]})]}):a.jsxs(\"div\",{className:\"flex flex-col items-center gap-1 overflow-y-auto max-h-[50vh]\",children:[be?.update_available&&a.jsx(\"button\",{onClick:()=>t(\"/settings\"),className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80\",title:i(\"nav.updateAvailable\",{version:be.latest_version}),children:a.jsx(TM,{className:\"w-5 h-5\"})}),Y&&a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"button\",{onMouseEnter:()=>F(!0),className:`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${T?\"text-bambu-green\":\"text-bambu-gray-light hover:text-white\"}`,title:i(\"nav.smartSwitches\",{defaultValue:\"Smart Switches\"}),children:a.jsx(nc,{className:\"w-5 h-5\"})}),T&&a.jsx(EH,{onClose:()=>F(!1)})]}),d(\"system:read\")?a.jsx(xx,{to:\"/system\",className:({isActive:ye})=>`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${ye?\"text-bambu-green\":\"text-bambu-gray-light hover:text-white\"}`,title:i(\"nav.system\"),children:a.jsx(Ss,{className:\"w-5 h-5\"})}):a.jsx(\"span\",{className:\"p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed\",title:\"You do not have permission to view system information\",children:a.jsx(Ss,{className:\"w-5 h-5\"})}),a.jsx(\"a\",{href:\"https://github.com/maziggy/bambuddy\",target:\"_blank\",rel:\"noopener noreferrer\",className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(\"nav.viewOnGithub\"),children:a.jsx(aF,{className:\"w-5 h-5\"})}),a.jsx(\"button\",{onClick:()=>A(!0),className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(\"nav.keyboardShortcuts\"),children:a.jsx(iF,{className:\"w-5 h-5\"})}),a.jsx(\"button\",{onClick:r,className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(n===\"dark\"?\"nav.switchToLight\":\"nav.switchToDark\"),children:n===\"dark\"?a.jsx(AH,{className:\"w-5 h-5\"}):a.jsx(kN,{className:\"w-5 h-5\"})}),l&&o&&a.jsxs(a.Fragment,{children:[a.jsx(\"button\",{onClick:()=>p(!0),className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(\"changePassword.title\"),children:a.jsx(no,{className:\"w-5 h-5\"})}),a.jsx(\"button\",{onClick:c,className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white\",title:i(\"nav.logout\",{defaultValue:\"Logout\"}),children:a.jsx(oF,{className:\"w-5 h-5\"})})]})]})})]}),a.jsxs(\"main\",{className:`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${s?\"mt-14\":g?\"ml-64\":\"ml-16\"}`,children:[E?.enabled&&a.jsx(\"div\",{className:\"bg-amber-500/20 border-b border-amber-500/30 px-4 py-2 flex items-center justify-between\",children:a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(Hx,{className:\"w-4 h-4 text-amber-500 animate-pulse\"}),a.jsxs(\"span\",{className:\"text-amber-200\",children:[i(\"support.debugLoggingActive\",{defaultValue:\"Debug logging is active\"}),V!==null&&a.jsxs(\"span\",{className:\"text-amber-300/70 ml-2\",children:[\"(\",Math.floor(V/60),\"m \",V%60,\"s)\"]})]}),a.jsx(\"button\",{onClick:()=>t(\"/system\"),className:\"text-amber-400 hover:text-amber-300 font-medium underline ml-2\",children:i(\"support.manageLogs\",{defaultValue:\"Manage\"})})]})}),j&&j.length>0&&a.jsx(\"div\",{className:\"bg-orange-500/20 border-b border-orange-500/30 px-4 py-2 flex items-center justify-between\",children:a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(cxe,{className:\"w-4 h-4 text-orange-500\"}),a.jsx(\"span\",{className:\"text-orange-200\",children:i(\"printers.developerModeWarning\",{names:j.map(ye=>ye.name).join(\", \"),defaultValue:`Developer LAN mode is not enabled on: ${j.map(ye=>ye.name).join(\", \")}. Some features may not work.`})}),a.jsx(\"a\",{href:\"https://wiki.bambulab.com/en/knowledge-sharing/enable-developer-mode\",target:\"_blank\",rel:\"noopener noreferrer\",className:\"text-orange-400 hover:text-orange-300 font-medium underline ml-2\",children:i(\"printers.howToEnable\",{defaultValue:\"How to enable\"})})]})}),Se&&a.jsxs(\"div\",{className:\"bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(TM,{className:\"w-4 h-4 text-bambu-green\"}),a.jsx(\"span\",{children:i(\"nav.updateAvailableBanner\",{version:be?.latest_version,defaultValue:`Version ${be?.latest_version} is available!`})}),a.jsx(\"button\",{onClick:()=>t(\"/settings\"),className:\"text-bambu-green hover:text-bambu-green/80 font-medium underline\",children:i(\"nav.viewUpdate\",{defaultValue:\"View update\"})})]}),a.jsx(\"button\",{onClick:ve,className:\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\",title:i(\"common.dismiss\",{defaultValue:\"Dismiss\"}),children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),a.jsx(uQ,{})]}),N&&a.jsx(yye,{onClose:()=>A(!1),sidebarItems:he.map(ye=>{if(Q_(ye)){const je=ge.get(ye);return je?{type:\"external\",label:je.name}:null}else{const je=se.get(ye);return je?{type:\"nav\",label:je.labelKey,labelKey:je.labelKey}:null}}).filter(Boolean)}),oe&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-[100] p-4\",children:a.jsx(\"div\",{className:\"bg-bambu-dark-secondary border-2 border-yellow-500 rounded-xl shadow-2xl max-w-md w-full animate-in fade-in zoom-in duration-200\",children:a.jsxs(\"div\",{className:\"p-6 text-center\",children:[a.jsx(\"div\",{className:\"w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/20 flex items-center justify-center\",children:a.jsx(\"svg\",{className:\"w-10 h-10 text-yellow-500\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",strokeWidth:2,children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\"})})}),a.jsx(\"h2\",{className:\"text-xl font-bold text-yellow-400 mb-2\",children:i(\"plateAlert.title\")}),a.jsx(\"p\",{className:\"text-lg text-white mb-2\",children:oe.printer_name}),a.jsx(\"p\",{className:\"text-bambu-gray mb-6\",children:i(\"plateAlert.message\")}),a.jsx(\"button\",{onClick:()=>fe(null),className:\"w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors\",children:i(\"plateAlert.understand\")})]})})}),m&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:()=>{p(!1),y({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"})},children:a.jsxs(Tt,{className:\"w-full max-w-md\",onClick:ye=>ye.stopPropagation(),children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(no,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"changePassword.title\")})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>{p(!1),y({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"})},children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})}),a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"input\",{type:\"text\",name:\"username\",autoComplete:\"username\",value:o?.username??\"\",readOnly:!0,hidden:!0,\"aria-hidden\":\"true\",tabIndex:-1}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"changePassword.currentPassword\")}),a.jsx(\"input\",{type:\"password\",value:f.currentPassword,onChange:ye=>y({...f,currentPassword:ye.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"changePassword.currentPasswordPlaceholder\"),autoComplete:\"current-password\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"changePassword.newPassword\")}),a.jsx(\"input\",{type:\"password\",value:f.newPassword,onChange:ye=>y({...f,newPassword:ye.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"changePassword.newPasswordPlaceholder\"),autoComplete:\"new-password\",minLength:6})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"changePassword.confirmPassword\")}),a.jsx(\"input\",{type:\"password\",value:f.confirmPassword,onChange:ye=>y({...f,confirmPassword:ye.target.value}),className:`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${f.confirmPassword&&f.newPassword!==f.confirmPassword?\"border-red-500\":\"border-bambu-dark-tertiary\"}`,placeholder:i(\"changePassword.confirmPasswordPlaceholder\"),autoComplete:\"new-password\",minLength:6}),f.confirmPassword&&f.newPassword!==f.confirmPassword&&a.jsx(\"p\",{className:\"text-red-400 text-xs mt-1\",children:i(\"changePassword.passwordsDoNotMatch\")})]})]}),a.jsxs(\"div\",{className:\"mt-6 flex justify-end gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{p(!1),y({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"})},children:i(\"common.cancel\")}),a.jsx(De,{onClick:async()=>{if(f.newPassword!==f.confirmPassword){u(i(\"changePassword.passwordsDoNotMatch\"),\"error\");return}if(f.newPassword.length<6){u(i(\"changePassword.passwordTooShort\"),\"error\");return}b(!0);try{await ue.changePassword(f.currentPassword,f.newPassword),u(i(\"changePassword.success\"),\"success\"),p(!1),y({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"})}catch(ye){const je=ye instanceof Error?ye.message:i(\"changePassword.failed\");u(je,\"error\")}finally{b(!1)}},disabled:v||!f.currentPassword||!f.newPassword||f.newPassword!==f.confirmPassword||f.newPassword.length<6,children:v?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),i(\"changePassword.changing\")]}):a.jsxs(a.Fragment,{children:[a.jsx(no,{className:\"w-4 h-4\"}),i(\"changePassword.title\")]})})]})]})]})}),a.jsx(Iye,{})]})}function Hye(t,e){const n=t.split(\".\").map(i=>parseInt(i,10)||0),r=e.split(\".\").map(i=>parseInt(i,10)||0);for(;n.length<4;)n.push(0);for(;r.length<4;)r.push(0);for(let i=0;i<4;i++)if(n[i]!==r[i])return n[i]-r[i];return 0}function zH(t,e,n,r){if(!t)return\"\";if(r)return`${t} — ${r}`;if(!e)return t;const i=e.match(/plate_(\\d+)\\.gcode/);return i&&parseInt(i[1],10)>1?`${t} — ${n(\"printers.plateNumber\",\"Plate {{number}}\",{number:i[1]})}`:t}const pF={\"0300_4000\":\"Z axis homing failed; the task has been stopped.\",\"0300_4001\":\"The printer timed out waiting for the nozzle to cool down before homing.\",\"0300_4002\":\"Auto Bed Leveling failed; the task has been stopped.\",\"0300_4005\":\"The hotend cooling fan speed is abnormal.\",\"0300_4006\":\"The nozzle is clogged.\",\"0300_4008\":\"The AMS failed to change filament.\",\"0300_4009\":\"Homing XY axis failed.\",\"0300_400A\":\"Mechanical resonance frequency identification failed.\",\"0300_400B\":\"Internal communication exception\",\"0300_400C\":\"The task was canceled.\",\"0300_400D\":\"Resume failed after power loss.\",\"0300_400E\":\"The motor self-check failed.\",\"0300_400F\":\"The power supply voltage does not match the printer.\",\"0300_4010\":\"Nozzle offset calibration failed.\",\"0300_4011\":\"Flow Dynamics Calibration failed; please reinitiate printing or calibration.\",\"0300_4013\":\"Printing cannot be initiated while AMS is drying.\",\"0300_4014\":\"Homing Z axis failed: temperature control abnormality.\",\"0300_4015\":\"Nozzle clumping detection calibration failed. Please go to 'Assistant' for troubleshooting.\",\"0300_4016\":\"Nozzle cleaning failed. Please click the Assistant for troubleshooting.\",\"0300_401F\":\"The hotend is not installed, and the toolhead cannot perform homing. Please install the hotend and then continue.\",\"0300_4020\":\"The nozzle presence detection failed. Please check the Assistant for details.\",\"0300_4021\":\"Nozzle offset calibration sensor signal abnormality detected. Please check the sensor and retry.\",\"0300_4042\":\"The Laser Safety Window is not properly installed. The task has been stopped.\",\"0300_4044\":\"The Flame Sensor is abnormal. The sensor may be short-circuited. Please troubleshoot the issue before starting a print job.\",\"0300_404B\":\"Task aborted because the front door or top cover is open.\",\"0300_404D\":\"The current temperature of the hotend, heatbed, or chamber is too high. Please wait for it to cool down to room temperature before restarting the task.\",\"0300_4050\":\"Liveview Camera calibration timeout; please restart the printer.\",\"0300_4052\":\"Blade Z-axis homing failed\",\"0300_4057\":\"Z-axis step loss detected. The task has stopped. Please check if there are any obstructions beneath the heatbed.\",\"0300_4066\":\"Calibration of motion precision failed.\",\"0300_4067\":\"Calibration result is over the threshold.\",\"0300_4068\":\"Step loss occurred during the motion accuracy enhancement process. Please try again.\",\"0300_8000\":\"Printing was paused for unknown reason. You can select 'Resume' to resume the print job.\",\"0300_8001\":\"Printing was paused by the user. You can select 'Resume' to continue printing.\",\"0300_8002\":\"First layer defects were detected by the Micro Lidar. Please check the quality of the printed model before continuing your print.\",\"0300_8003\":\"Spaghetti defects were detected by the AI Print Monitoring. Please check the quality of the printed model before continuing your print.\",\"0300_8004\":\"Filament ran out. Please load new filament.\",\"0300_8005\":\"Toolhead front cover fell off. Please remount the front cover and check to make sure your print is going okay.\",\"0300_8006\":\"The build plate marker was not detected. Please confirm the build plate is correctly positioned on the heatbed with all four corners aligned, and the marker is visible.\",\"0300_8007\":\"There was an unfinished print job when the printer lost power. If the model is still adhered to the build plate, you can try resuming the print job.\",\"0300_8008\":\"Nozzle temperature malfunction\",\"0300_8009\":\"Heatbed temperature malfunction\",\"0300_800A\":\"A Filament pile-up was detected by AI Print Monitoring. Please clean filament from the waste chute.\",\"0300_800B\":\"The cutter is stuck. Please make sure the cutter handle is out and check the filament sensor cable connection.\",\"0300_800C\":\"Skipped step detected: auto-recover complete; please resume print and check if there are any layer shift problems.\",\"0300_800D\":\"Detected that the extruder is not extruding normally. If the defects are acceptable, select 'Resume' to resume the print job.\",\"0300_800E\":\"The print file is not available. Please check to see if the storage media has been removed.\",\"0300_800F\":\"The door seems to be open, so printing was paused.\",\"0300_8010\":\"The hotend cooling fan speed is abnormal.\",\"0300_8011\":\"Detected build plate is not the same as the Gcode file. Please adjust slicer settings or use the correct plate.\",\"0300_8013\":\"Printing paused due to the pause command added to the printing file.\",\"0300_8014\":\"The nozzle is covered with filament, or the build plate is installed incorrectly. Please cancel this print and clean the nozzle or adjust the build plate according to the actual status. You can als...\",\"0300_8015\":\"The filament on external spool has run out; please load new filament. If the filament is loaded, please select 'Resume'.\",\"0300_8016\":\"The nozzle is clogged with filament. Please cancel this print and clean the nozzle or select 'Resume' to resume the print job.\",\"0300_8017\":\"Foreign objects detected on heatbed. Please check and clean the heatbed. Then, select 'Resume' to resume the print job.\",\"0300_8018\":\"Chamber temperature malfunction.\",\"0300_8019\":\"No build plate is placed.\",\"0300_801A\":\"Filament extrusion error; please check the assistant for troubleshooting. After resolving the issue, decide whether to cancel or resume the print job based on the actual print status.\",\"0300_801B\":\"Nozzle temperature problem detected. Refer to Assistant to re-connect the hotend connector. POWER OFF the printer before this operation to avoid short circuits.\",\"0300_801C\":\"The extrusion resistance is abnormal. The extruder may be clogged; please refer to the assistant. After trouble shooting, you can select 'Resume' to resume the print job.\",\"0300_801D\":\"The extruder servo motor position sensor is malfunctioning. Please power off the printer first and check if the connection cable is loose.\",\"0300_801E\":\"The extrusion motor is overloaded, please check the Assistant for details.\",\"0300_8021\":\"The nozzle may not be installed or not properly installed. Please ensure the nozzle is correctly installed before proceeding.\",\"0300_8022\":\"The heatbed may be obstructed while moving downward. Please clear any objects beneath the heatbed and check for any resistance or jamming during its movement.\",\"0300_8028\":\"Nozzle offset calibration sensor error. If using a single hotend or the calibration function is disabled, you may ignore this and continue printing; otherwise, it is recommended to check the sensor...\",\"0300_8041\":\"Platform detection timeout: please restart the printer.\",\"0300_8042\":\"Task paused because the door is open.\",\"0300_8043\":\"The laser module is abnormal.\",\"0300_8044\":\"Fire was detected inside the chamber.\",\"0300_8045\":\"Material detection timeout: please restart the printer.\",\"0300_8046\":\"Foreign object detect timeout: please restart the printer.\",\"0300_8047\":\"Quick-release lever detection time out: please restart the printer.\",\"0300_8048\":\"Laser Module unlock has timed out, and the task cannot proceed. Please restart the printer and try again.\",\"0300_8049\":\"The current plate is invalid.\",\"0300_804A\":\"Emergency stop button improperly installed. Please reinstall according to the Wiki before proceeding.\",\"0300_804B\":\"Task paused. The Laser Safety Window is open.\",\"0300_804E\":\"This is a printing task. Please detach the Laser/Cutting Module from the Toolhead.\",\"0300_804F\":\"The loading/unloading process is currently ongoing. Please stop the process or remove the laser/cutting module.\",\"0300_8050\":\"This device does not support the 40W Laser Module. Please remove it or replace it with a 10W Laser Module.\",\"0300_8051\":\"The cutting module has dropped or the cutting module cable is disconnected; please check the module.\",\"0300_8053\":\"Laser module detected. Please install the right nozzle correctly to ensure proper Laser Module Mounting Calibration.\",\"0300_8054\":\"Please place the paper required for Print Then Cut.\",\"0300_8055\":\"The module mounted on the toolhead does not match the task. Please install the correct module.\",\"0300_8057\":\"The rotary attachment is disconnected. Please ensure it is properly installed and the cable is securely plugged in.\",\"0300_8058\":\"The rotary attachment is detected. Please remove it before continuing.\",\"0300_8061\":\"The mode of Airflow System failed to activate; check the air door condition.\",\"0300_8062\":\"The chamber temperature is too high. It may be due to high environmental temperature.\",\"0300_8063\":\"The chamber temperature is too high. Please open the top cover and front door to cool down.\",\"0300_8064\":\"The chamber temperature is too high. Please open the top cover and front door to cool down. (Open door detection for this print job will be set to 'Notification' level)\",\"0300_8065\":\"The temperature of the MC module is too high. Please check the Wiki for possible explanations.\",\"0300_8071\":\"The Toolhead Enhanced Cooling Fan module is malfunctioning.\",\"0300_807D\":\"Fire Extinguisher not detected, the automatic extinguishing function will be unavailable.\",\"0300_807E\":\"Fire Extinguisher not detected, the automatic extinguishing function will be unavailable.\",\"0300_807F\":\"Fire Extinguisher is malfunctioning.\",\"0300_8080\":\"Fire extinguisher motor reset failed.\",\"0300_8081\":\"Fire extinguisher cylinder not installed. Please confirm on the extinguisher page.\",\"0300_8082\":\"The Fire Extinguisher Gas Cylinder is empty.\",\"0300_C012\":\"Please heat the nozzle to above 170°C.\",\"0300_C056\":\"A minor fire was detected inside the chamber, and the Auto Fire Extinguishing process has been aborted.\",\"0300_C070\":\"The fire extinguisher has been detected and is ready for use after the laser module is connected.\",\"0500_4001\":\"Failed to connect to Bambu Cloud. Please check your network connection.\",\"0500_4002\":\"Unsupported print file path or name. Please resend the print job.\",\"0500_4003\":\"Printing stopped because the printer was unable to parse the file. Please resend your print job.\",\"0500_4004\":\"Device is busy and cannot start new task. Please wait for current task to complete before sending new task.\",\"0500_4005\":\"Print jobs are not allowed to be sent while updating firmware.\",\"0500_4006\":\"There is not enough free storage space for the print job. Restoring to factory settings can free up available space.\",\"0500_4007\":\"The device requires a repair upgrade, and printing is currently unavailable.\",\"0500_4008\":\"Starting printing failed; please power cycle the printer and resend the print job.\",\"0500_4009\":\"Print jobs are not allowed to be sent while updating logs.\",\"0500_400A\":\"The file name is not supported. Please rename and restart the print job.\",\"0500_400B\":\"There was a problem downloading a file. Please check your network connection and resend the print job.\",\"0500_400C\":\"Please insert a MicroSD card and restart the print job.\",\"0500_400D\":\"Please run a self-test and restart the print job.\",\"0500_400E\":\"Printing was cancelled.\",\"0500_400F\":\"AMS is initializing and cannot be upgraded at the moment. Please try again later.\",\"0500_4010\":\"AMS is drying and cannot be upgraded at the moment. Please try again later.\",\"0500_4011\":\"The printer is loading or unloading filament and cannot be upgraded at the moment. Please try again later.\",\"0500_4012\":\"The device is printing and cannot be upgraded at the moment. Please try again later.\",\"0500_4013\":\"AMS is in operation and cannot be upgraded at the moment. Please try again when it is idle.\",\"0500_4014\":\"Slicing for the print job failed. Please check your settings and restart the print job.\",\"0500_4015\":\"There is not enough free storage space for the print job. Please format or clear files from the MicroSD card to free up space.\",\"0500_4016\":\"The MicroSD Card is write-protected. Please replace the MicroSD Card.\",\"0500_4017\":\"Binding failed. Please retry or restart the printer and retry.\",\"0500_4018\":\"Binding configuration information parsing failed; please try again.\",\"0500_4019\":\"The printer has already been bound. Please unbind it and try again.\",\"0500_401A\":\"Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...\",\"0500_401B\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_401C\":\"Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_401D\":\"Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\"0500_401E\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_401F\":\"Authorization timed out. Please make sure that your phone or PC has access to the internet, and ensure that the Bambu Studio/Bambu Handy APP is running in the foreground during the binding operation.\",\"0500_4020\":\"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_4021\":\"Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\"0500_4022\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_4023\":\"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_4024\":\"Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...\",\"0500_4025\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_4026\":\"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_4027\":\"Cloud access failed; this may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\"0500_4028\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_4029\":\"Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0500_402A\":\"Failed to connect to the router, which may be caused by wireless interference or being too far away from the router. Please try again or move the printer closer to the router and try again.\",\"0500_402B\":\"Router connection failed due to incorrect password. Please check the password and try again.\",\"0500_402C\":\"Failed to obtain IP address, which may be caused by wireless interference resulting in data transmission failure or the DHCP address pool of the router being full. Please move the printer closer to...\",\"0500_402D\":\"System exception\",\"0500_402E\":\"System does not support the file system currently used by the USB flash drive. Please replace or format the USB flash drive to FAT32.\",\"0500_402F\":\"The MicroSD card sector data is damaged. Please use the SD card repair tool to repair or format it. If it still cannot be identified, please replace the MicroSD card.\",\"0500_4030\":\"The device is currently upgrading. Please try again when it is idle.\",\"0500_4031\":\"The accessory firmware does not match the printer. Please update it on the 'Firmware' page.\",\"0500_4033\":\"The AMS firmware does not match the printer. Please update it on the 'Firmware' page.\",\"0500_4034\":\"The Laser Module firmware does not match the printer. Please update it on the 'Firmware' page.\",\"0500_4035\":\"The BirdsEye Camera is malfunctioning. Please try restarting the device. If the issue persists after multiple restarts, check the camera connection status or contact customer support.\",\"0500_4037\":\"Your sliced file is not compatible with current printer model. This file can't be printed on this printer.\",\"0500_4038\":\"The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can't be printed.\",\"0500_4039\":\"The current task does not allow the installation of the laser/cutting module, and the task has been halted.\",\"0500_403A\":\"The current temperature is too low. In order to protect you and your printer, printing tasks, moving an axis and other operations are disabled. Please move the printer to an environment above 10 de...\",\"0500_403B\":\"Laser/cutting tasks cannot be initiated on the machine at the moment. Please use the computer software to start the task.\",\"0500_403C\":\"The current nozzle setting does not match the slicing file. Continuing to print may affect print quality. It is recommended to re-slice before starting the print.\",\"0500_403D\":\"The toolhead module is not set up. Please set it up before initiating the task.\",\"0500_403E\":\"The current tool head does not support initialization.\",\"0500_403F\":\"Failed to download print job; please check your network connection.\",\"0500_4040\":\"The printer has reached its power limit. Please connect a dedicated power adapter to this AMS to enable drying.\",\"0500_4041\":\"The AMS drying cannot be started during printing.\",\"0500_4042\":\"Due to power limitations, starting AMS drying will pause current operations such as nozzle heating and fan running. Do you want to proceed with drying?\",\"0500_4043\":\"Due to power limitations, only one AMS is allowed to use the device's power for drying.\",\"0500_4044\":\"BirdsEye Camera malfunction: please contact customer support.\",\"0500_4045\":\"Hotend check in progress. This operation is temporarily unavailable. Please wait.\",\"0500_4050\":\"Error detected on the print board.\",\"0500_4052\":\"Error detected on the hot end.\",\"0500_4054\":\"Error detected on the mat.\",\"0500_405D\":\"Laser module Serial Number error: unable to calibrate or make project.\",\"0500_4065\":\"The task requires a Laser Platform, but the current one is a Cutting Platform. Please replace it, measure the material thickness in the software, and then restart the task.\",\"0500_4070\":\"The laser or cutter module is connected, so the device cannot initiate a 3D printing task.\",\"0500_4075\":\"No Laser Platform was detected, which may affect thickness measurement accuracy. Please place the laser platform correctly and ensure the rear markers are not blocked, then restart the thickness me...\",\"0500_4076\":\"Please place the Laser Platform correctly and ensure the rear markers are not blocked, then restart the thickness measurement in the software before initiating the task.\",\"0500_4097\":\"The device cannot detect the Laser Module. Please reconnect the module cable or restart the printer.\",\"0500_4098\":\"The device cannot detect AMS A. Please reconnect the AMS cable or restart the printer.\",\"0500_4099\":\"The firmware of Cutting Module does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0500_409A\":\"The firmware of the Air Pump does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0500_409B\":\"The firmware of the Laser Module does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0500_409D\":\"The firmware of AMS A does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.\",\"0500_409E\":\"The device cannot detect the Cutting Module. Please reconnect the module cable or restart the printer.\",\"0500_409F\":\"The device cannot detect the Air Pump.  Please reconnect the module cable or restart the printer.\",\"0500_40A0\":\"The Rotary Attachment module is not detected. Please reconnect the cable or restart the printer.\",\"0500_40A1\":\"The Auto Fire Extinguishing System is not detected.  Please reconnect the module cable or restart the printer.\",\"0500_40A3\":\"AMS(or AMS lite) A communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0500_40A4\":\"The current firmware only supports 1 AMS Lite. Please remove all AMS units before reconnecting the supported AMS Lite device.\",\"0500_40A5\":\"The current firmware only supports AMS/AMS 2 Pro/AMS HT, with a maximum of 4 units. Please remove all AMS units before reconnecting the supported one.\",\"0500_8013\":\"The print file is not available. Please check to see if the storage media has been removed.\",\"0500_8036\":\"Your sliced file is not consistent with the current printer model. Continue?\",\"0500_803C\":\"The current nozzle setting does not match the slicing file. Continuing to print may affect print quality. It is recommended to re-slice before starting the print.\",\"0500_8040\":\"Toolhead front cover is detached. Moving the toolhead may damage the printer. Do you want to continue?\",\"0500_8041\":\"The filament in hotend is too cold. Extrusion may damage the extruder. Still feeding in/out the filament?\",\"0500_8048\":\"The module on the toolhead is not calibrated. Please cancel the task to perform calibration or switch to a calibrated module.\",\"0500_8051\":\"Detected build plate is not the same as the Gcode file. Please adjust slicer settings or use the correct plate.\",\"0500_8053\":\"Nozzle mismatch was detected during printing. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.\",\"0500_8055\":\"Laser module is installed, but a Cutting Platform is detected. Please place a Laser Platform and perform laser calibration.\",\"0500_8056\":\"Cutting module is installed, but the laser platform is detected. Please place the cutting platform for calibration.\",\"0500_8058\":\"Please place the light grip cutting mat correctly and ensure the marker is exposed.\",\"0500_8059\":\"Cutting platform base is not correctly aligned. Please ensure that the four corners of the platform are aligned with the heatbed.\",\"0500_805A\":\"Please place the cutting mat on cutting protection base.\",\"0500_805B\":\"The cutting mat type is unknown; please replace it with the correct cutting mat.\",\"0500_805C\":\"The grip cutting mat type does not match; please place a LightGrip cutting mat.\",\"0500_805E\":\"Cutting module Serial Number error: unable to calibrate or make project.\",\"0500_8060\":\"The current module on toolhead does not meet requirements. Please replace the module as per the on-screen instructions.\",\"0500_8061\":\"No print plate detected. Please make sure it is placed correctly.\",\"0500_8062\":\"The print plate marker was not detected. Please confirm the print plate is correctly positioned on the heatbed with all four corners aligned, and the marker is visible. If strong light is shining o...\",\"0500_8063\":\"The platform is not detected during calibration; please make sure the Laser Platform is properly placed.\",\"0500_8064\":\"Please place the Laser Platform correctly and ensure the rear markers are not blocked for laser calibration.\",\"0500_8066\":\"The task requires a Cutting Platform, but the current one is a Laser Platform. Please replace it with a Cutting Platform (Cutting Protection Base + LightGrip cutting mat).\",\"0500_8067\":\"Please place a LightGrip cutting mat on the cutting protection base.\",\"0500_8068\":\"Please place the strong grip cutting mat correctly and ensure the marker is exposed.\",\"0500_8069\":\"Unable to recognize the left and right hotends. They might be third party hotends, or the hotend marks may be dirty. Please manually set the hotend types.\",\"0500_806A\":\"Unable to recognize the left and right hotends. They might be third party hotends, or the hotend marks may be dirty. Please set hotend types on printer screen before next print.\",\"0500_806B\":\"Quick-release Lever is not locked. Please press down the external toolhead module to ensure it is properly seated, then push down the level to lock it in place.\",\"0500_806C\":\"Please place the cutting platform correctly and ensure the marker is exposed.\",\"0500_806D\":\"Material not detected. Please confirm placement and continue.\",\"0500_806E\":\"Foreign objects detected on heatbed; please check and clean up the heatbed.\",\"0500_806F\":\"The grip cutting mat type does not match; please place a StrongGrip cutting mat.\",\"0500_8071\":\"No cutting platform was detected. Please confirm that it has been correctly placed.\",\"0500_8072\":\"Live View camera is blocked\",\"0500_8073\":\"Heatbed limit block is obstructed or contaminated. Please clean and ensure the limit block is visible, otherwise platform position offset detection may be inaccurate.\",\"0500_8074\":\"The Laser Platform is offset. Please ensure that the four corners of the platform are aligned with the heatbed, and the marker is not obstructed.\",\"0500_8077\":\"The visual marker was not detected. Please ensure the paper is properly placed.\",\"0500_8078\":\"Current material does not match the sliced file settings. Please load the correct material and ensure the QR code on the material is not damaged or dirty.\",\"0500_8079\":\"Please place the Laser Test Material (350g paperboard) and position support strips underneath to prevent material warping.\",\"0500_807A\":\"The foreign object detection function is not working. You can continue the task or check the assistant for troubleshooting.\",\"0500_807B\":\"Please place the cutting platform (cutting protection base + LightGrip cutting mat).\",\"0500_807C\":\"Please place the cutting platform (cutting protection base + StrongGrip cutting mat).\",\"0500_807D\":\"This task requires a Cutting Platform, but the current one is a Laser Platform. Please replace it with a Cutting Platform (Cutting Protection Base + StrongGrip Cutting Mat).\",\"0500_807E\":\"Please place a StrongGrip cutting mat on the cutting protection base.\",\"0500_8080\":\"The left and right hotends are not installed.\",\"0500_8081\":\"The left and right hotends are not installed.\",\"0500_8082\":\"Please remove the protective film on the Opaque Glossy Acrylic before processing\",\"0500_8083\":\"Material is not allowed in Mounting Calibration. Please remove the material from the platform.\",\"0500_8084\":\"The Live View Camera is dirty; please clean it and continue.\",\"0500_8085\":\"Toolhead camera is obstructed\",\"0500_8086\":\"Toolhead Camera is dirty, which affects the AI function; please clean the lens surface.\",\"0500_8087\":\"BirdsEye camera is obstructed\",\"0500_8088\":\"The Birdseye Camera is dirty\",\"0500_8089\":\"Task paused due to Presence Check failed. Please check the printer to continue.\",\"0500_808A\":\"The BirdsEye Camera is installed offset. Please refer to the assistant to reinstall it.\",\"0500_808B\":\"The BirdsEye Camera setup failed. Please remove all objects and the mat on the heatbed to ensure the heatbed markers are visible. Meanwhile, please ensure the BirdsEye Camera is installed correctly...\",\"0500_808C\":\"Detected build plate offset. Please align the build plate with the heatbed, and then continue.\",\"0500_808D\":\"The Cutting Module offset calibration failed, which may result in inaccurate cuts. Please ensure the cutting material is properly positioned and check whether the cutting blade tip is worn.\",\"0500_808E\":\"BirdsEye Camera initialization failed. The toolhead camera did not detect the Heatbed features. Please clean the Heatbed, remove all objects and pads, and ensure the bed markings are visible. Check...\",\"0500_808F\":\"Nozzle camera lens is dirty, affecting AI monitoring. Clean the lens with a non-woven cloth and a small amount of alcohol. Beware of hotend heat; wait for it to cool before handling.\",\"0500_8090\":\"Please attach the 80g White Printing Paper to the center area of the platform.\",\"0500_8091\":\"The Cutting Module offset calibration failed, which may result in inaccurate cuts. Please ensure the 80g white printer paper(letter paper thickness) is properly positioned and check whether the cut...\",\"0500_8092\":\"Toolhead Camera initialization failed. This print can still continue, but some AI functions will be disabled. If you encounter this issue again after restarting, please contact customer support.\",\"0500_8093\":\"The nozzle silicone sleeve is not installed; there is a risk of temperature control failure. Please install it correctly and try again.\",\"0500_80A0\":\"The visual encoder board was not detected. Please check if the board is properly placed and aligned at all four corners, and ensure the positioning markings are clear and free from wear.\",\"0500_C010\":\"MicroSD Card read/write exception: please reinsert or replace the MicroSD Card.\",\"0500_C032\":\"Laser/Cutting module connected to the toolhead. The drying process has been automatically stopped.\",\"0500_C036\":\"This is a printing task. Please detach the Laser/Cutting Module from the Toolhead.\",\"0500_C07F\":\"Device is busy and cannot perform this operation. To proceed, please pause or stop the current task.\",\"0501_4017\":\"Binding failed. Please retry or restart the printer and retry.\",\"0501_4018\":\"Binding configuration information parsing failed; please try again.\",\"0501_4019\":\"The printer has already been bound. Please unbind it and try again.\",\"0501_401A\":\"Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...\",\"0501_401B\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_401C\":\"Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_401D\":\"Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\"0501_401E\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_401F\":\"Authorization timed out. Please make sure that your phone or PC has access to the internet, and ensure that the Bambu Studio/Bambu Handy APP is running in the foreground during the binding operation.\",\"0501_4020\":\"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_4021\":\"Cloud access failed, which may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\"0501_4022\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_4023\":\"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_4024\":\"Cloud access failed. Possible reasons include network instability caused by interference, inability to access the internet, or router firewall configuration restrictions. You can try moving the pri...\",\"0501_4025\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_4026\":\"Cloud access rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_4027\":\"Cloud access failed; this may be caused by network instability due to interference. You can try moving the printer closer to the router before you try again.\",\"0501_4028\":\"Cloud response is invalid. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_4029\":\"Cloud access is rejected. If you have tried multiple times and are still failing, please contact customer support.\",\"0501_4031\":\"Device discovery binding is in progress, and the QR code cannot be displayed on the screen. You can wait for the binding to finish or abort the device discovery binding process in the APP/Studio an...\",\"0501_4032\":\"QR code binding is in progress, so device discovery binding cannot be performed. You can scan the QR code on the screen for binding or exit the QR code display page on screen and try device discove...\",\"0501_4033\":\"Your APP region does not match with your printer; please download the APP in the corresponding region and register your account again.\",\"0501_4034\":\"The slicing progress has not been updated for a long time, and the printing task has exited. Please confirm the parameters and reinitiate printing.\",\"0501_4035\":\"The device is in the process of binding and cannot respond to new binding requests.\",\"0501_4038\":\"The regional settings do not match the printer; please check the printer's regional settings.\",\"0501_4039\":\"Device login has expired; please try to bind again.\",\"0501_4098\":\"The device cannot detect AMS B. Please reconnect the AMS cable or restart the printer.\",\"0501_409D\":\"The firmware of AMS B does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0501_40A3\":\"AMS(or AMS lite) B communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0502_4001\":\"Current filament will be used in this print job. Settings cannot be changed.\",\"0502_4002\":\"Please go to “Settings > Calibration” to run the Motion Accuracy Enhancement Calibration before turning on Motion Accuracy Enhancement mode.\",\"0502_4003\":\"The printer is currently printing and the motion accuracy enhancement feature cannot be turned on or off.\",\"0502_4004\":\"Some features are not supported by the current device. Please check the Studio feature settings or update the firmware to the latest version.\",\"0502_4005\":\"The AMS has not been calibrated yet, so printing cannot be initiated.\",\"0502_4006\":\"Unknown module detected; please try updating the firmware to the latest version.\",\"0502_400D\":\"Failed to start a new task: filament loading/unloading not completed.\",\"0502_400E\":\"Failed to start a new task: The nozzle cold pull was not completed.\",\"0502_4013\":\"This device is not compatible with the 40W laser module. Please replace it with a 10W laser module or remove it.\",\"0502_4098\":\"The device cannot detect AMS C. Please reconnect the AMS cable or restart the printer.\",\"0502_409D\":\"The firmware of AMS C does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.\",\"0502_40A3\":\"AMS(or AMS lite) C communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0502_C00F\":\"The device is busy and cannot perform nozzle identification.\",\"0502_C010\":\"Due to printer power limitations, printing, calibration, controls and other actions cannot be performed during AMS drying. Please stop the drying process before proceeding with any other operation.\",\"0502_C011\":\"Currently in 2D production mode. Please continue the operation on the printer\",\"0502_C012\":\"The task cannot be paused.\",\"0502_C014\":\"The AMS Remaining Filament Estimation is enabled by default and cannot be disabled.\",\"0502_C024\":\"The flow dynamic calibration records have exceeded the storage limit. Please delete some historical records in the slicer software before adding new calibration data.\",\"0503_4098\":\"The device cannot detect AMS D. Please reconnect the AMS cable or restart the printer.\",\"0503_409D\":\"The firmware of AMS D does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0503_40A3\":\"AMS(or AMS lite) D communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0580_4096\":\"The device cannot detect AMS-HT A. Please reconnect the AMS-HT cable or restart the printer.\",\"0580_409C\":\"The firmware of AMS-HT A does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0580_40A2\":\"AMS-HT A communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0581_4096\":\"The device cannot detect AMS-HT B. Please reconnect the AMS-HT cable or restart the printer.\",\"0581_409C\":\"The firmware of AMS-HT B does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0581_40A2\":\"AMS-HT B communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0582_4096\":\"The device cannot detect AMS-HT C. Please reconnect the AMS-HT cable or restart the printer.\",\"0582_409C\":\"The firmware of AMS-HT C does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0582_40A2\":\"AMS-HT C communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0583_4096\":\"The device cannot detect AMS-HT D. Please reconnect the AMS-HT cable or restart the printer.\",\"0583_409C\":\"The firmware of AMS-HT D does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0583_40A2\":\"AMS-HT D communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0584_4096\":\"The device cannot detect AMS-HT F. Please reconnect the AMS-HT cable or restart the printer.\",\"0584_409C\":\"The firmware of AMS-HT E does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0584_40A2\":\"AMS-HT E communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0585_4096\":\"The device cannot detect AMS-HT E. Please reconnect the AMS-HT cable or restart the printer.\",\"0585_409C\":\"The firmware of AMS-HT F does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0585_40A2\":\"AMS-HT F communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0586_4096\":\"The device cannot detect AMS-HT G. Please reconnect the AMS-HT cable or restart the printer.\",\"0586_409C\":\"The firmware of AMS-HT G does not match the printer; the device cannot continue working. Please update it on the 'Firmware' page.\",\"0586_40A2\":\"AMS-HT G communication is abnormal. Please reconnect the module cable or restart the printer.\",\"0587_4096\":\"The device cannot detect AMS-HT H. Please reconnect the AMS-HT cable or restart the printer.\",\"0587_409C\":\"The firmware of AMS-HT H does not match the printer; the device cannot continue working. Please upgrade it on the 'Firmware' page.\",\"0587_40A2\":\"AMS-HT H communication is abnormal. Please reconnect the module cable or restart the printer.\",\"05FE_8053\":\"The left nozzle is not matched with slicing file. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.\",\"05FE_8069\":\"Unable to recognize the left hotend. It might be a third party hotend, or the hotend mark may be dirty. Please manually set the hotend type.\",\"05FE_806A\":\"Unable to recognize the left hotend. It might be a third party hotend, or the hotend mark may be dirty. Please set hotend type on printer screen before next print.\",\"05FE_8080\":\"The left hotend is not installed.\",\"05FE_8081\":\"The left hotend is not installed.\",\"05FF_8053\":\"The right nozzle is not matched with slicing file. Please initiate the print after re-slicing, or continue printing after replacing with the correct nozzle. Caution: the hotend temperature is high.\",\"05FF_8069\":\"Unable to recognize the right hotend. It might be a third party hotend, or the hotend mark may be dirty. Please manually set the hotend type.\",\"05FF_806A\":\"Unable to recognize the right hotend. It might be a third party hotend, or the hotend mark may be dirty. Please set hotend type on printer screen before next print.\",\"05FF_8080\":\"The right hotend is not installed.\",\"05FF_8081\":\"The right hotend is not installed.\",\"0700_4001\":\"The AMS has been disabled for a print, but it still has filament loaded. Please unload the AMS filament and switch to the spool holder filament for printing.\",\"0700_4025\":\"Failed to read the filament information.\",\"0700_8001\":\"Failed to cut the filament. Please check the cutter.\",\"0700_8002\":\"The cutter is stuck. Please make sure the cutter handle is out.\",\"0700_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"0700_8004\":\"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"0700_8005\":\"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"0700_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"0700_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"0700_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS A to the extruder is properly connected.\",\"0700_8010\":\"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"0700_8011\":\"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\"0700_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"0700_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"0700_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"0700_8017\":\"AMS A is drying. Please stop drying process before loading/unloading material.\",\"0700_8021\":\"AMS setup failed; please refer to the assistant.\",\"0700_8023\":\"AMS A cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"0700_C069\":\"An error occurred during AMS A drying. Please go to Assistant for more details.\",\"0700_C06A\":\"AMS A is reading RFID. Unable to start drying. Please try again later.\",\"0700_C06B\":\"AMS A is changing filament. Unable to start drying. Please try again later.\",\"0700_C06C\":\"AMS A is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"0700_C06D\":\"AMS A is assisting in filament insertion. Unable to start drying. Please try again later.\",\"0700_C06E\":\"AMS A motor is performing self-test. Unable to start drying. Please try again later.\",\"0701_4001\":\"Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.\",\"0701_4025\":\"Failed to read the filament information.\",\"0701_8001\":\"Failed to cut the filament. Please check the cutter.\",\"0701_8002\":\"The cutter is stuck. Please make sure the cutter handle is out.\",\"0701_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"0701_8004\":\"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"0701_8005\":\"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"0701_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"0701_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"0701_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS B to the extruder is properly connected.\",\"0701_8010\":\"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"0701_8011\":\"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\"0701_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"0701_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"0701_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"0701_8017\":\"AMS B is drying. Please stop drying process before loading/unloading material.\",\"0701_8021\":\"AMS setup failed; please refer to the assistant.\",\"0701_8023\":\"AMS B cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"0701_C069\":\"An error occurred during AMS B drying. Please go to Assistant for more details.\",\"0701_C06A\":\"AMS B is reading RFID. Unable to start drying. Please try again later.\",\"0701_C06B\":\"AMS B is changing filament. Unable to start drying. Please try again later.\",\"0701_C06C\":\"AMS B is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"0701_C06D\":\"AMS B is assisting in filament insertion. Unable to start drying. Please try again later.\",\"0701_C06E\":\"AMS B motor is performing self-test. Unable to start drying. Please try again later.\",\"0702_4001\":\"Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.\",\"0702_4025\":\"Failed to read the filament information.\",\"0702_8001\":\"Failed to cut the filament. Please check the cutter.\",\"0702_8002\":\"The cutter is stuck. Please make sure the cutter handle is out.\",\"0702_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"0702_8004\":\"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"0702_8005\":\"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"0702_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"0702_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"0702_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS C to the extruder is properly connected.\",\"0702_8010\":\"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"0702_8011\":\"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\"0702_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"0702_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"0702_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"0702_8017\":\"AMS C is drying. Please stop drying process before loading/unloading material.\",\"0702_8021\":\"AMS setup failed; please refer to the assistant.\",\"0702_8023\":\"AMS C cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"0702_C069\":\"An error occurred during AMS C drying. Please go to Assistant for more details.\",\"0702_C06A\":\"AMS C is reading RFID. Unable to start drying. Please try again later.\",\"0702_C06B\":\"AMS C is changing filament. Unable to start drying. Please try again later.\",\"0702_C06C\":\"AMS C is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"0702_C06D\":\"AMS C is assisting in filament insertion. Unable to start drying. Please try again later.\",\"0702_C06E\":\"AMS C motor is performing self-test. Unable to start drying. Please try again later.\",\"0703_4001\":\"Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.\",\"0703_4025\":\"Failed to read the filament information.\",\"0703_8001\":\"Failed to cut the filament. Please check the cutter.\",\"0703_8002\":\"The cutter is stuck. Please make sure the cutter handle is out.\",\"0703_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"0703_8004\":\"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"0703_8005\":\"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"0703_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"0703_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"0703_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS D to the extruder is properly connected.\",\"0703_8010\":\"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"0703_8011\":\"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\"0703_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"0703_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"0703_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"0703_8017\":\"AMS D is drying. Please stop drying process before loading/unloading material.\",\"0703_8021\":\"AMS setup failed; please refer to the assistant.\",\"0703_8023\":\"AMS D cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"0703_C069\":\"An error occurred during AMS D drying. Please go to Assistant for more details.\",\"0703_C06A\":\"AMS D is reading RFID. Unable to start drying. Please try again later.\",\"0703_C06B\":\"AMS D is changing filament. Unable to start drying. Please try again later.\",\"0703_C06C\":\"AMS D is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"0703_C06D\":\"AMS D is assisting in filament insertion. Unable to start drying. Please try again later.\",\"0703_C06E\":\"AMS D motor is performing self-test. Unable to start drying. Please try again later.\",\"0704_4025\":\"Failed to read the filament information.\",\"0704_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"0704_8004\":\"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"0704_8005\":\"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"0704_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"0704_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"0704_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS E to the extruder is properly connected.\",\"0704_8010\":\"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"0704_8011\":\"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\"0704_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"0704_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"0704_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"0704_8021\":\"AMS setup failed; please refer to the assistant.\",\"0704_8023\":\"AMS E cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"0705_4025\":\"Failed to read the filament information.\",\"0705_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"0705_8004\":\"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"0705_8005\":\"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"0705_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"0705_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"0705_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS F to the extruder is properly connected.\",\"0705_8010\":\"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"0705_8011\":\"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\"0705_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"0705_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"0705_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"0705_8021\":\"AMS setup failed; please refer to the assistant.\",\"0705_8023\":\"AMS F cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"0706_4025\":\"Failed to read the filament information.\",\"0706_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"0706_8004\":\"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"0706_8005\":\"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"0706_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"0706_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"0706_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS G to the extruder is properly connected.\",\"0706_8010\":\"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"0706_8011\":\"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\"0706_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"0706_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"0706_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"0706_8021\":\"AMS setup failed; please refer to the assistant.\",\"0706_8023\":\"AMS G cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"0707_4025\":\"Failed to read the filament information.\",\"0707_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"0707_8004\":\"AMS failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"0707_8005\":\"The AMS failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"0707_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"0707_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"0707_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS H to the extruder is properly connected.\",\"0707_8010\":\"The AMS assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"0707_8011\":\"AMS filament ran out. Please insert a new filament into the same AMS slot.\",\"0707_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"0707_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"0707_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"0707_8021\":\"AMS setup failed; please refer to the assistant.\",\"0707_8023\":\"AMS H cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"07FE_8001\":\"Failed to cut the filament of the left extruder. Please check the cutter.\",\"07FE_8002\":\"The cutter of the left extruder is stuck. Please pull out the cutter handle.\",\"07FE_8003\":\"Please pull out the filament on the spool holder  of the left extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are ab...\",\"07FE_8004\":\"Failed to pull back the filament from the left extruder. Please check whether the filament is stuck inside the extruder.\",\"07FE_8005\":\"Failed to feed the filament outside the AMS. Please clip the end of the filament flat and check to see if the spool is stuck.\",\"07FE_8006\":\"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\"07FE_8007\":\"Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.\",\"07FE_8010\":\"Check if the left external filament spool or filament is stuck.\",\"07FE_8011\":\"The external filament connected to the left extruder has run out; please load a new filament.\",\"07FE_8012\":\"Failed to get mapping table; please select 'Resume' to retry.\",\"07FE_8013\":\"Timeout purging old filament of the left extruder: Please check if the filament is stuck or the extruder is clogged.\",\"07FE_8020\":\"Extruder change failed; please refer to the assistant.\",\"07FE_8021\":\"AMS setup failed; please refer to the assistant.\",\"07FE_8024\":\"Extruder position calibration failed; please refer to the assistant.\",\"07FE_8025\":\"Cold pull timed out. Please promptly operate or check whether the filament is broken inside the extruder, and click the Assistant for details.\",\"07FE_8030\":\"The filament specified in the slicer has been used up. Printing is paused. Please go to the machine to replace the material and resume printing.\",\"07FE_C003\":\"Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...\",\"07FE_C006\":\"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\"07FE_C008\":\"Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...\",\"07FE_C009\":\"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\"07FE_C00A\":\"Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.\",\"07FE_C010\":\"Insert the filament (over 30cm long) until it stops. You might see slight smoke during flushing. After insertion, close the front door and top cover.\",\"07FE_C011\":\"Please manually and slowly pull out the filament from the extruder. Then click “Continue”.\",\"07FE_C012\":\"Press the black PTFE tube coupler and unplug the PTFE tube. After completing the operation, click 'Continue.'\",\"07FF_4001\":\"Filament is still loaded from the AMS after it has been disabled. Please unload the filament, load from the spool holder, and restart printing.\",\"07FF_8001\":\"Failed to cut the filament of the right extruder. Please check the cutter.\",\"07FF_8002\":\"The cutter is stuck. Please make sure the cutter handle is out.\",\"07FF_8003\":\"Please pull out the filament on the spool holder  of the right extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are a...\",\"07FF_8004\":\"Failed to pull back the filament from the right extruder. Please check whether the filament is stuck inside the extruder.\",\"07FF_8005\":\"Failed to feed the filament outside the AMS. Please clip the end of the filament flat and check to see if the spool is stuck.\",\"07FF_8006\":\"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\"07FF_8007\":\"Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.\",\"07FF_8010\":\"Check if the external filament spool or filament is stuck.\",\"07FF_8011\":\"External filament has run out; please load a new filament.\",\"07FF_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"07FF_8013\":\"Timeout purging old filament of the right extruder: Please check if the filament is stuck or the extruder is clogged.\",\"07FF_8020\":\"Extruder change failed; please refer to the assistant.\",\"07FF_8021\":\"AMS setup failed; please refer to the assistant.\",\"07FF_8024\":\"Extruder position calibration failed; please refer to the assistant.\",\"07FF_8025\":\"Cold pull timed out. Please promptly operate or check whether the filament is broken inside the extruder, and click the Assistant for details.\",\"07FF_8030\":\"The filament specified in the slicer has been used up. Printing is paused. Please go to the machine to replace the material and resume printing.\",\"07FF_C003\":\"Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...\",\"07FF_C006\":\"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\"07FF_C008\":\"Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...\",\"07FF_C009\":\"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\"07FF_C00A\":\"Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.\",\"07FF_C010\":\"Insert the filament (over 30cm long) until it stops. You might see slight smoke during flushing. After insertion, close the front door and top cover.\",\"07FF_C011\":\"Hold the driven wheel bracket, slowly pull the filament from the extruder, then press 'Continue'.\",\"07FF_C012\":\"Press the black PTFE tube coupler and unplug the PTFE tube. After completing the operation, click 'Continue.'\",\"0C00_4020\":\"The setup of BirdsEye Camera failed. Please clear all objects and remove the mat. Make sure the marker is not obstructed. Meanwhile, clean both the BirdsEye Camera and Toolhead Camera, and remove a...\",\"0C00_4021\":\"The setup of BirdsEye Camera failed; please reboot the printer.\",\"0C00_4022\":\"The setup of BirdsEye Camera failed.  Please check if the laser module is working properly.\",\"0C00_4024\":\"The Birdseye Camera is installed offset. Please refer to the assistant to reinstall it.\",\"0C00_4025\":\"The Birdseye Camera is dirty. Please clean it and restart the process.\",\"0C00_4026\":\"The Live View Camera initialization failed; please reboot the printer.\",\"0C00_4027\":\"The Live View Camera calibration failed. Please refer to the assistant for details and recalibrate the camera after processing.\",\"0C00_4029\":\"Material not detected. Please confirm placement and continue.\",\"0C00_402A\":\"The visual marker was not detected. Please re-paste the paper in the correct position.\",\"0C00_402C\":\"Device data link error. Please reboot the printer\",\"0C00_402D\":\"The toolhead camera is not working properly; please reboot the device.\",\"0C00_403D\":\"The vision encoder plate was not detected. Please confirm it is correctly positioned on the heatbed.\",\"0C00_403E\":\"The high-precision nozzle offset calibration has failed, possibly due to a damaged pattern or the similarity of the colors of the two selected filaments. Please clear the printed pattern and replac...\",\"0C00_4041\":\"Toolhead camera calibration failed. Please ensure the Calibration Marker on the heatbed or Height Calibration Marker on the homing area is clean and undamaged, then re-run the calibration process.\",\"0C00_8001\":\"First layer defects were detected. If the defects are acceptable, select 'Resume' to resume the print job.\",\"0C00_8005\":\"Purged filament has piled up in the waste chute, which may cause a tool head collision.\",\"0C00_8009\":\"Build plate localization marker was not found.\",\"0C00_800B\":\"The heatbed marker was not detected. Please clear all objects and remove the mat. Make sure the marker is not obstructed.\",\"0C00_8015\":\"Objects detected on the platform; please clean them up in a timely manner.\",\"0C00_8016\":\"The foreign object detection function is not working. You can continue the task or check assistant for solutions.\",\"0C00_8017\":\"Foreign objects detected on the platform; please clean them up on time.\",\"0C00_8018\":\"The foreign object detection function is not working. You can continue the task or view the assistant for troubleshooting.\",\"0C00_8033\":\"Quick-release Lever is not locked. Please push it down to secure.\",\"0C00_8034\":\"Liveview Camera initialization failed. This print can still continue, but some AI functions will be disabled. If you encounter this issue again after restarting, please contact customer support.\",\"0C00_803F\":\"AI detected nozzle clumping. Please check the nozzle condition. Refer to assistant for solutions.\",\"0C00_8040\":\"AI detected air-printing defect. Please check the hotend extrusion status. Refer to assistant for solutions.\",\"0C00_8042\":\"The AI print monitor has detected a spaghetti defect. Please check the print and take the necessary action.\",\"0C00_8043\":\"AI detected nozzle clumping. Please check the nozzle condition. Refer to assistant for solutions.\",\"0C00_C003\":\"Possible defects were detected in the first layer.\",\"0C00_C004\":\"Possible spaghetti failure was detected.\",\"0C00_C006\":\"Purged filament may have piled up in the waste chute.\",\"1000_C001\":\"High bed temperature may lead to filament clogging in the nozzle. You may open the chamber door.\",\"1000_C002\":\"Printing CF material with stainless steel may cause nozzle damage.\",\"1000_C003\":\"Enabling Timelapse in traditional mode may cause defects; please activate this feature as needed.\",\"1001_4001\":\"Timelapse is not supported as Spiral Vase mode is enabled in slicing presets.\",\"1001_4002\":\"Timelapse is not supported as the Print sequence is set to 'By object'.\",\"1001_8003\":\"The time-lapse mode is set to Traditional in the slicing file. This may cause surface defects. Would you like to enable it?\",\"1001_8004\":\"Prime Tower is not enabled and time-lapse mode is set to Smooth in slicing file. This may cause surface defects. Would you like to enable it?\",\"1200_4001\":\"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\"1200_8001\":\"Cutting the filament failed. Please check to see if the cutter is stuck. Refer to the Assistant for solutions.\",\"1200_8002\":\"The cutter is stuck. Please pull out the cutter handle.\",\"1200_8003\":\"Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.\",\"1200_8004\":\"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\"1200_8005\":\"The filament is not inserted. Please insert the filament.\",\"1200_8006\":\"Unable to feed filament into the extruder. This could be due to tangled filament or a stuck spool. If not, please check if the AMS PTFE tube is connected.\",\"1200_8007\":\"Failed to extrude the filament. This might be caused by clogged extruder or stuck filament. Refer to the Assistant for solutions.\",\"1200_8010\":\"Filament or spool may be stuck.\",\"1200_8011\":\"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\"1200_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1200_8013\":\"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\"1200_8014\":\"The filament location in the toolhead was not found. Refer to the Assistant for solutions.\",\"1200_8015\":\"Failed to pull out the filament from the toolhead. Please check if the filament is stuck, or if it is broken inside the extruder or PTFE tube.\",\"1200_8016\":\"The extruder is not extruding normally. Refer to the Assistant for troubleshooting. There may be defects in this layer, but you may resume if the defects are acceptable.\",\"1201_4001\":\"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\"1201_8001\":\"Failed to cut the filament. Please check the cutter.\",\"1201_8002\":\"The cutter is stuck. Please pull out the cutter handle.\",\"1201_8003\":\"Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.\",\"1201_8004\":\"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\"1201_8005\":\"Failed to feed the filament. Please load the filament and then select 'Retry'.\",\"1201_8006\":\"Failed to feed the filament into the toolhead. Please check whether the filament is stuck.\",\"1201_8007\":\"Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.\",\"1201_8010\":\"Please check if the spool or filament is stuck.\",\"1201_8011\":\"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\"1201_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1201_8013\":\"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\"1201_8014\":\"Failed to check the filament location in the tool head; please refer to the HMS.\",\"1201_8015\":\"Failed to pull back the filament from the toolhead. Please check if the filament is stuck or the filament is broken inside the extruder.\",\"1201_8016\":\"The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.\",\"1202_4001\":\"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\"1202_8001\":\"Failed to cut the filament. Please check the cutter.\",\"1202_8002\":\"The cutter is stuck. Please pull out the cutter handle.\",\"1202_8003\":\"Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.\",\"1202_8004\":\"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\"1202_8005\":\"The filament is not inserted. Please insert the filament.\",\"1202_8006\":\"Failed to feed the filament into the toolhead. Please check whether the filament is stuck.\",\"1202_8007\":\"Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.\",\"1202_8010\":\"Please check if the spool or filament is stuck.\",\"1202_8011\":\"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\"1202_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1202_8013\":\"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\"1202_8014\":\"Failed to check the filament location in the tool head; please refer to the HMS.\",\"1202_8015\":\"Failed to pull back the filament from the toolhead. Please check if the filament is stuck or is broken inside the extruder.\",\"1202_8016\":\"The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.\",\"1203_4001\":\"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\"1203_8001\":\"Failed to cut the filament. Please check the cutter.\",\"1203_8002\":\"The cutter is stuck. Please pull out the cutter handle.\",\"1203_8003\":\"Failed to pull out the filament from the extruder. Please check whether the extruder is clogged or whether the filament is broken inside the extruder.\",\"1203_8004\":\"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\"1203_8005\":\"The filament is not inserted. Please insert the filament.\",\"1203_8006\":\"Failed to feed the filament into the toolhead. Please check whether the filament is stuck.\",\"1203_8007\":\"Failed to extrude the filament. The extruder may be clogged or the filament may be stuck; please refer to HMS.\",\"1203_8010\":\"Please check if the spool or filament is stuck.\",\"1203_8011\":\"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\"1203_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1203_8013\":\"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\"1203_8014\":\"Failed to check the filament location in the tool head; please refer to the HMS.\",\"1203_8015\":\"Failed to pull back the filament from the toolhead. Please check if the filament is stuck or is broken inside the extruder.\",\"1203_8016\":\"The extruder is not extruding normally; please refer to the HMS. After trouble shooting, if the defects are acceptable, please resume printing.\",\"12FF_4001\":\"Filament is still loaded from the AMS when it has been disabled. Please unload AMS filament, load from spool holder, and restart print job.\",\"12FF_8001\":\"Failed to cut the filament. Please check the cutter.\",\"12FF_8002\":\"The cutter is stuck. Please pull out the cutter handle.\",\"12FF_8003\":\"Please pull out the filament on the spool holder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube if you are about to us...\",\"12FF_8004\":\"Failed to pull back the filament from the toolhead. Please check whether the filament is stuck.\",\"12FF_8005\":\"The filament is not inserted. Please insert the filament.\",\"12FF_8006\":\"Please feed filament into the PTFE tube until it can not be pushed any farther.\",\"12FF_8007\":\"Check nozzle. Select 'Done' if filament was extruded, otherwise push filament forward slightly and select 'Retry.'\",\"12FF_8010\":\"Please check if the filament or the spool is stuck.\",\"12FF_8011\":\"AMS filament has run out. Please insert a new filament into the same AMS slot.\",\"12FF_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"12FF_8013\":\"Timeout while purging old filament. Please check if the filament is stuck or the extruder clogged.\",\"12FF_C003\":\"Please pull out the filament on the spool holder. If this message persists, please check to see if there is filament broken in the extruder or PTFE Tube. (Connect a PTFE tube if you are about to us...\",\"12FF_C006\":\"Please feed filament into the PTFE tube until it can not be pushed any farther.\",\"1800_4025\":\"Failed to read the filament information.\",\"1800_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"1800_8004\":\"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"1800_8005\":\"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"1800_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"1800_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"1800_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT A to the extruder is properly connected.\",\"1800_8010\":\"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"1800_8011\":\"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\"1800_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1800_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"1800_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"1800_8017\":\"AMS-HT A is drying. Please stop drying process before loading/unloading material.\",\"1800_8021\":\"AMS setup failed; please refer to the assistant.\",\"1800_8023\":\"AMS-HT A cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"1800_C069\":\"An error occurred during AMS-HT A drying. Please go to Assistant for more details.\",\"1800_C06A\":\"AMS-HT A is reading RFID. Unable to start drying. Please try again later.\",\"1800_C06B\":\"AMS-HT A is changing filament. Unable to start drying. Please try again later.\",\"1800_C06C\":\"AMS-HT A is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"1800_C06D\":\"AMS-HT A is assisting in filament insertion. Unable to start drying. Please try again later.\",\"1800_C06E\":\"AMS-HT A motor is performing self-test. Unable to start drying. Please try again later.\",\"1801_4025\":\"Failed to read the filament information.\",\"1801_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"1801_8004\":\"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"1801_8005\":\"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"1801_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"1801_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"1801_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT B to the extruder is properly connected.\",\"1801_8010\":\"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"1801_8011\":\"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\"1801_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1801_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"1801_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"1801_8017\":\"AMS-HT B is drying. Please stop drying process before loading/unloading material.\",\"1801_8021\":\"AMS setup failed; please refer to the assistant.\",\"1801_8023\":\"AMS-HT B cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"1801_C069\":\"An error occurred during AMS-HT B drying. Please go to Assistant for more details.\",\"1801_C06A\":\"AMS-HT B is reading RFID. Unable to start drying. Please try again later.\",\"1801_C06B\":\"AMS-HT B is changing filament. Unable to start drying. Please try again later.\",\"1801_C06C\":\"AMS-HT B is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"1801_C06D\":\"AMS-HT B is assisting in filament insertion. Unable to start drying. Please try again later.\",\"1801_C06E\":\"AMS-HT B motor is performing self-test. Unable to start drying. Please try again later.\",\"1802_4025\":\"Failed to read the filament information.\",\"1802_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"1802_8004\":\"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"1802_8005\":\"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"1802_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"1802_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"1802_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT C to the extruder is properly connected.\",\"1802_8010\":\"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"1802_8011\":\"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\"1802_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1802_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"1802_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"1802_8017\":\"AMS-HT C is drying. Please stop drying process before loading/unloading material.\",\"1802_8021\":\"AMS setup failed; please refer to the assistant.\",\"1802_8023\":\"AMS-HT C cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"1802_C069\":\"An error occurred during AMS-HT C drying. Please go to Assistant for more details.\",\"1802_C06A\":\"AMS-HT C is reading RFID. Unable to start drying. Please try again later.\",\"1802_C06B\":\"AMS-HT C is changing filament. Unable to start drying. Please try again later.\",\"1802_C06C\":\"AMS-HT C is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"1802_C06D\":\"AMS-HT C is assisting in filament insertion. Unable to start drying. Please try again later.\",\"1802_C06E\":\"AMS-HT C motor is performing self-test. Unable to start drying. Please try again later.\",\"1803_4025\":\"Failed to read the filament information.\",\"1803_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"1803_8004\":\"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"1803_8005\":\"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"1803_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"1803_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"1803_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT D to the extruder is properly connected.\",\"1803_8010\":\"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"1803_8011\":\"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\"1803_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1803_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"1803_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"1803_8017\":\"AMS-HT D is drying. Please stop drying process before loading/unloading material.\",\"1803_8021\":\"AMS setup failed; please refer to the assistant.\",\"1803_8023\":\"AMS-HT D cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"1803_C069\":\"An error occurred during AMS-HT D drying. Please go to Assistant for more details.\",\"1803_C06A\":\"AMS-HT D is reading RFID. Unable to start drying. Please try again later.\",\"1803_C06B\":\"AMS-HT D is changing filament. Unable to start drying. Please try again later.\",\"1803_C06C\":\"AMS-HT D is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"1803_C06D\":\"AMS-HT D is assisting in filament insertion. Unable to start drying. Please try again later.\",\"1803_C06E\":\"AMS-HT D motor is performing self-test. Unable to start drying. Please try again later.\",\"1804_4025\":\"Failed to read the filament information.\",\"1804_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"1804_8004\":\"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"1804_8005\":\"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"1804_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"1804_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"1804_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT E to the extruder is properly connected.\",\"1804_8010\":\"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"1804_8011\":\"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\"1804_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1804_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"1804_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"1804_8021\":\"AMS setup failed; please refer to the assistant.\",\"1804_8023\":\"AMS-HT E cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"1804_C069\":\"An error occurred during AMS-HT E drying. Please go to Assistant for more details.\",\"1804_C06A\":\"AMS-HT E is reading RFID. Unable to start drying. Please try again later.\",\"1804_C06B\":\"AMS-HT E is changing filament. Unable to start drying. Please try again later.\",\"1804_C06C\":\"AMS-HT E is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"1804_C06D\":\"AMS-HT E is assisting in filament insertion. Unable to start drying. Please try again later.\",\"1804_C06E\":\"AMS-HT E motor is performing self-test. Unable to start drying. Please try again later.\",\"1805_4025\":\"Failed to read the filament information.\",\"1805_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"1805_8004\":\"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"1805_8005\":\"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"1805_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"1805_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"1805_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT F to the extruder is properly connected.\",\"1805_8010\":\"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"1805_8011\":\"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\"1805_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1805_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"1805_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"1805_8021\":\"AMS setup failed; please refer to the assistant.\",\"1805_8023\":\"AMS-HT F cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"1805_C069\":\"An error occurred during AMS-HT F drying. Please go to Assistant for more details.\",\"1805_C06A\":\"AMS-HT F is reading RFID. Unable to start drying. Please try again later.\",\"1805_C06B\":\"AMS-HT F is changing filament. Unable to start drying. Please try again later.\",\"1805_C06C\":\"AMS-HT F is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"1805_C06D\":\"AMS-HT F is assisting in filament insertion. Unable to start drying. Please try again later.\",\"1805_C06E\":\"AMS-HT F motor is performing self-test. Unable to start drying. Please try again later.\",\"1806_4025\":\"Failed to read the filament information.\",\"1806_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"1806_8004\":\"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"1806_8005\":\"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"1806_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"1806_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"1806_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT G to the extruder is properly connected.\",\"1806_8010\":\"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"1806_8011\":\"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\"1806_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1806_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"1806_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"1806_8021\":\"AMS setup failed; please refer to the assistant.\",\"1806_8023\":\"AMS-HT G cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"1806_C069\":\"An error occurred during AMS-HT G drying. Please go to Assistant for more details.\",\"1806_C06A\":\"AMS-HT G is reading RFID. Unable to start drying. Please try again later.\",\"1806_C06B\":\"AMS-HT G is changing filament. Unable to start drying. Please try again later.\",\"1806_C06C\":\"AMS-HT G is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"1806_C06D\":\"AMS-HT G is assisting in filament insertion. Unable to start drying. Please try again later.\",\"1806_C06E\":\"AMS-HT G motor is performing self-test. Unable to start drying. Please try again later.\",\"1807_4025\":\"Failed to read the filament information.\",\"1807_8003\":\"Failed to pull out the filament from the extruder. This might be caused by clogged extruder or filament broken inside the extruder.\",\"1807_8004\":\"AMS-HT failed to pull back filament. This could be due to a stuck spool or the end of the filament being stuck in the path.\",\"1807_8005\":\"The AMS-HT failed to send out filament. You can clip the end of your filament flat, and reinsert. If this message persists, please check the PTFE tubes in AMS for any signs of wear and tear.\",\"1807_8006\":\"Unable to feed filament into the extruder. The AMS may be mismatched with the extruder. You can rerun the AMS Setup. This could also be due to an entangled filament or a stuck spool. If not, please...\",\"1807_8007\":\"Extruding filament failed. The extruder might be clogged.\",\"1807_800A\":\"PTFE tube disconnection detected. Please check if the PTFE tube from AMS-HT H to the extruder is properly connected.\",\"1807_8010\":\"The AMS-HT assist motor is overloaded. This could be due to entangled filament or a stuck spool.\",\"1807_8011\":\"AMS-HT filament ran out. Please insert a new filament into the same AMS-HT slot.\",\"1807_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"1807_8013\":\"Timeout purging old filament: Please check if the filament is stuck or the extruder is clogged.\",\"1807_8016\":\"The extruder is not extruding normally; please refer to the Assistant. After trouble shooting. If the defects are acceptable, please resume.\",\"1807_8021\":\"AMS setup failed; please refer to the assistant.\",\"1807_8023\":\"AMS-HT H cooling failed. The ambient temperature may be too high. Please operate the device in a suitable environment.\",\"1807_C069\":\"An error occurred during AMS-HT H drying. Please go to Assistant for more details.\",\"1807_C06A\":\"AMS-HT H is reading RFID. Unable to start drying. Please try again later.\",\"1807_C06B\":\"AMS-HT H is changing filament. Unable to start drying. Please try again later.\",\"1807_C06C\":\"AMS-HT H is in Feed Assist Mode. Unable to start drying. Please try again later.\",\"1807_C06D\":\"AMS-HT H is assisting in filament insertion. Unable to start drying. Please try again later.\",\"1807_C06E\":\"AMS-HT H motor is performing self-test. Unable to start drying. Please try again later.\",\"18FE_8001\":\"Failed to cut the filament of the left extruder. Please check the cutter.\",\"18FE_8002\":\"The cutter of the left extruder is stuck. Please pull out the cutter handle.\",\"18FE_8003\":\"Please pull out the filament on the spool holder  of the left extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are ab...\",\"18FE_8004\":\"Failed to pull back the filament from the left extruder. Please check whether the filament is stuck inside the extruder.\",\"18FE_8005\":\"Failed to feed the filament outside the AMS-HT. Please clip the end of the filament flat and check to see if the spool is stuck.\",\"18FE_8006\":\"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\"18FE_8007\":\"Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.\",\"18FE_8011\":\"The external filament connected to the left extruder has run out; please load a new filament.\",\"18FE_8012\":\"Failed to get mapping table; please select 'Resume' to retry.\",\"18FE_8013\":\"Timeout purging old filament of the left extruder: Please check if the filament is stuck or the extruder is clogged.\",\"18FE_8020\":\"Extruder change failed; please refer to the assistant.\",\"18FE_8021\":\"AMS setup failed; please refer to the assistant.\",\"18FE_8024\":\"Extruder position calibration failed; please refer to the assistant.\",\"18FE_C003\":\"Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...\",\"18FE_C006\":\"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\"18FE_C008\":\"Please pull out the filament on the spool holder of the left extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube i...\",\"18FE_C009\":\"Please feed filament into the PTFE tube of the left extruder until it can not be pushed any farther.\",\"18FE_C00A\":\"Please observe the nozzle of the left extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.\",\"18FF_8001\":\"Failed to cut the filament of the right extruder. Please check the cutter.\",\"18FF_8002\":\"The cutter of the right extruder is stuck. Please pull out the cutter handle.\",\"18FF_8003\":\"Please pull out the filament on the spool holder  of the right extruder. If this message persists, please check to see if there is filament broken in the extruder. (Connect a PTFE tube if you are a...\",\"18FF_8004\":\"Failed to pull back the filament from the right extruder. Please check whether the filament is stuck inside the extruder.\",\"18FF_8005\":\"Failed to feed the filament outside the AMS-HT. Please clip the end of the filament flat and check to see if the spool is stuck.\",\"18FF_8006\":\"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\"18FF_8007\":\"Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if it has not, please push the filament forward slightly, and then select 'Retry'.\",\"18FF_8011\":\"The external filament connected to the right extruder has run out; please load a new filament.\",\"18FF_8012\":\"Failed to get AMS mapping table; please select 'Resume' to retry.\",\"18FF_8013\":\"Timeout purging old filament of the right extruder: Please check if the filament is stuck or the extruder is clogged.\",\"18FF_8020\":\"Extruder change failed; please refer to the assistant.\",\"18FF_8021\":\"AMS setup failed; please refer to the assistant.\",\"18FF_8024\":\"Extruder position calibration failed; please refer to the assistant.\",\"18FF_C003\":\"Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...\",\"18FF_C006\":\"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\"18FF_C008\":\"Please pull out the filament on the spool holder of the right extruder. If this message persists, please check to see if there is filament broken in the extruder or PTFE tube. (Connect a PTFE tube ...\",\"18FF_C009\":\"Please feed filament into the PTFE tube of the right extruder until it can not be pushed any farther.\",\"18FF_C00A\":\"Please observe the nozzle of the right extruder. If the filament has been extruded, select 'Continue'; if not, please push the filament forward slightly and then select 'Retry'.\"};function qye(t){switch(t){case 1:return{label:\"Fatal\",color:\"text-red-500\",bgColor:\"bg-red-500/20\",Icon:Dn};case 2:return{label:\"Serious\",color:\"text-red-400\",bgColor:\"bg-red-500/15\",Icon:Dn};case 3:return{label:\"Warning\",color:\"text-orange-400\",bgColor:\"bg-orange-500/20\",Icon:ei};default:return{label:\"Info\",color:\"text-blue-400\",bgColor:\"bg-blue-500/20\",Icon:Ss}}}function fF(t,e){const n=t>>16&65535||(t>>8&255)<<8|t&255,r=e&65535;return`${n.toString(16).padStart(4,\"0\").toUpperCase()}_${r.toString(16).padStart(4,\"0\").toUpperCase()}`}function om(t){return t.filter(e=>{const n=parseInt(e.code.replace(\"0x\",\"\"),16)||0,r=fF(e.attr,n);return pF[r]!==void 0})}function $ye(){return\"https://wiki.bambulab.com/en/hms/home\"}function Vye({printerName:t,errors:e,onClose:n,printerId:r,hasPermission:i}){const{t:s}=Ft(),{showToast:o}=hn(),l=it({mutationFn:()=>ue.clearHMSErrors(r),onSuccess:()=>{o(s(\"hmsErrors.clearSuccess\"),\"success\"),n()},onError:()=>{o(s(\"hmsErrors.clearFailed\"),\"error\")}}),c=e.filter(d=>{const u=parseInt(d.code.replace(\"0x\",\"\"),16)||0,m=fF(d.attr,u);return pF[m]!==void 0});return w.useEffect(()=>{const d=u=>{u.key===\"Escape\"&&n()};return window.addEventListener(\"keydown\",d),()=>window.removeEventListener(\"keydown\",d)},[n]),a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Dn,{className:\"w-5 h-5 text-orange-400\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:s(\"hmsErrors.title\",{name:t})})]}),a.jsx(\"button\",{onClick:n,className:\"p-1 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]}),a.jsx(\"div\",{className:\"flex-1 overflow-y-auto p-4\",children:c.length===0?a.jsxs(\"div\",{className:\"text-center py-8 text-bambu-gray\",children:[a.jsx(ei,{className:\"w-12 h-12 mx-auto mb-3 opacity-30\"}),a.jsx(\"p\",{children:s(\"hmsErrors.noErrors\")})]}):a.jsx(\"div\",{className:\"space-y-3\",children:c.map((d,u)=>{const{label:m,color:p,bgColor:f,Icon:y}=qye(d.severity),v=parseInt(d.code.replace(\"0x\",\"\"),16)||0,b=fF(d.attr,v),g=pF[b],_=$ye(),C=b.replace(\"_\",\"-\");return a.jsx(\"div\",{className:`p-4 rounded-lg ${f} border border-white/10`,children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(y,{className:`w-5 h-5 ${p} flex-shrink-0 mt-0.5`}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsxs(\"span\",{className:`font-mono text-sm ${p}`,children:[\"[\",C,\"]\"]}),a.jsx(\"span\",{className:`text-xs px-2 py-0.5 rounded-full ${f} ${p}`,children:m})]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-2\",children:g}),a.jsxs(\"a\",{href:_,target:\"_blank\",rel:\"noopener noreferrer\",className:\"inline-flex items-center gap-1 text-xs text-bambu-green hover:underline\",children:[a.jsx(la,{className:\"w-3 h-3\"}),s(\"hmsErrors.viewOnWiki\")]})]})]})},`${d.code}-${u}`)})})}),a.jsxs(\"div\",{className:\"p-4 border-t border-bambu-dark-tertiary flex items-center justify-between gap-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:s(\"hmsErrors.clearInstructions\")}),c.length>0&&a.jsxs(\"button\",{onClick:()=>l.mutate(),disabled:!i(\"printers:control\")||l.isPending,className:\"flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0\",children:[l.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"}),s(\"hmsErrors.clearErrors\")]})]})]})})}const Gye=[{key:\"printing\",dot:\"bg-bambu-green\"},{key:\"paused\",dot:\"bg-status-warning\"},{key:\"finished\",dot:\"bg-blue-400\"},{key:\"idle\",dot:\"bg-bambu-green\"},{key:\"error\",dot:\"bg-status-error\"},{key:\"offline\",dot:\"bg-gray-400\"}];function Wye({selectedIds:t,printers:e,onClose:n,onSelectAll:r,onSelectByLocation:i,onSelectByState:s,onAction:o,actionPending:l}){const{t:c}=Ft(),{hasPermission:d}=kr(),u=nn(),[m,p]=w.useState(!1),[f,y]=w.useState(!1),v=Array.from(t).map(D=>({id:D,status:u.getQueryData([\"printerStatus\",D])})),b=v.some(({status:D})=>D?.connected&&D.state===\"RUNNING\"),g=v.some(({status:D})=>D?.connected&&D.state===\"PAUSE\"),_=b||g,C=v.some(({status:D})=>!!(D?.connected&&D.awaiting_plate_clear)),P=v.some(({status:D})=>!D?.connected||!D.hms_errors?!1:om(D.hms_errors).length>0),N=d(\"printers:control\"),A=d(\"printers:clear_plate\"),T=[...new Set(e.map(D=>D.location).filter(D=>!!D))].sort(),F={printing:0,paused:0,finished:0,idle:0,error:0,offline:0};e.forEach(D=>{const H=u.getQueryData([\"printerStatus\",D.id]);if(!H||!H.connected){F.offline++;return}switch(H.hms_errors&&om(H.hms_errors).length>0&&F.error++,H.state){case\"RUNNING\":F.printing++;break;case\"PAUSE\":F.paused++;break;case\"FINISH\":F.finished++;break;case\"FAILED\":F.error++;break;default:F.idle++;break}});const k={printing:c(\"printers.status.printing\"),paused:c(\"printers.status.paused\",\"Paused\"),finished:c(\"printers.status.finished\",\"Finished\"),idle:c(\"printers.status.idle\"),error:c(\"printers.status.problem\"),offline:c(\"printers.status.offline\")};return a.jsxs(\"div\",{className:\"fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-3 flex-wrap\",children:[a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:n,children:a.jsx(Ht,{className:\"w-4 h-4\"})}),a.jsx(\"div\",{className:\"w-px h-6 bg-bambu-dark-tertiary\"}),a.jsx(\"span\",{className:\"text-white font-medium text-sm\",children:c(\"printers.bulk.selected\",{count:t.size})}),a.jsx(\"div\",{className:\"w-px h-6 bg-bambu-dark-tertiary\"}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:r,children:c(\"printers.bulk.selectAll\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>{y(!f),p(!1)},children:[c(\"printers.bulk.selectByState\"),a.jsx(On,{className:`w-3 h-3 transition-transform ${f?\"rotate-180\":\"\"}`})]}),f&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>y(!1)}),a.jsx(\"div\",{className:\"absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1\",children:Gye.filter(({key:D})=>F[D]>0).map(({key:D,dot:H})=>a.jsxs(\"button\",{onClick:()=>{s(D),y(!1)},className:\"w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2\",children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full ${H}`}),k[D],a.jsx(\"span\",{className:\"ml-auto text-bambu-gray text-xs\",children:F[D]})]},D))})]})]}),T.length>0&&a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>{p(!m),y(!1)},children:[c(\"printers.bulk.selectByLocation\"),a.jsx(On,{className:`w-3 h-3 transition-transform ${m?\"rotate-180\":\"\"}`})]}),m&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>p(!1)}),a.jsx(\"div\",{className:\"absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1\",children:T.map(D=>a.jsx(\"button\",{onClick:()=>{i(D),p(!1)},className:\"w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors\",children:D},D))})]})]}),a.jsx(\"div\",{className:\"w-px h-6 bg-bambu-dark-tertiary\"}),a.jsxs(De,{size:\"sm\",className:\"bg-red-500 hover:bg-red-600\",onClick:()=>o(\"stop\"),disabled:l||!N||!_,title:N?_?void 0:c(\"printers.bulk.noneApplicable\"):c(\"printers.permission.noControl\"),children:[a.jsx(uo,{className:\"w-3.5 h-3.5\"}),c(\"printers.bulk.actions.stop\")]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>o(\"pause\"),disabled:l||!N||!b,title:N?b?void 0:c(\"printers.bulk.noneApplicable\"):c(\"printers.permission.noControl\"),children:[a.jsx(rS,{className:\"w-3.5 h-3.5\"}),c(\"printers.bulk.actions.pause\")]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>o(\"resume\"),disabled:l||!N||!g,title:N?g?void 0:c(\"printers.bulk.noneApplicable\"):c(\"printers.permission.noControl\"),children:[a.jsx(es,{className:\"w-3.5 h-3.5\"}),c(\"printers.bulk.actions.resume\")]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>o(\"clearHMS\"),disabled:l||!N||!P,title:N?P?void 0:c(\"printers.bulk.noneApplicable\"):c(\"printers.permission.noControl\"),children:[a.jsx(Ppe,{className:\"w-3.5 h-3.5\"}),c(\"printers.bulk.actions.clearHMS\")]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>o(\"clearPlate\"),disabled:l||!A||!C,title:A?C?void 0:c(\"printers.bulk.noneApplicable\"):c(\"printers.permission.noControl\"),children:[a.jsx(Qfe,{className:\"w-3.5 h-3.5\"}),c(\"printers.bulk.actions.clearPlate\")]})]})}const $O=\"181\",Tx={ROTATE:0,DOLLY:1,PAN:2},yx={ROTATE:0,PAN:1,DOLLY_PAN:2,DOLLY_ROTATE:3},Kye=0,UH=1,Xye=2,lZ=1,Yye=2,zu=3,bp=0,zo=1,Pd=2,lm=0,Ax=1,BH=2,HH=3,qH=4,Qye=5,Tf=100,Zye=101,Jye=102,eve=103,tve=104,nve=200,rve=201,ave=202,ive=203,gF=204,bF=205,sve=206,ove=207,lve=208,cve=209,dve=210,uve=211,mve=212,hve=213,pve=214,xF=0,yF=1,vF=2,Vx=3,wF=4,SF=5,_F=6,kF=7,VO=0,fve=1,gve=2,ip=0,bve=1,xve=2,yve=3,vve=4,wve=5,Sve=6,_ve=7,cZ=300,Gx=301,Wx=302,NF=303,CF=304,XP=306,PF=1e3,Ju=1001,TF=1002,hl=1003,kve=1004,Z_=1005,ec=1006,EM=1007,Rf=1008,Hd=1009,dZ=1010,uZ=1011,lw=1012,GO=1013,fg=1014,em=1015,_y=1016,WO=1017,KO=1018,cw=1020,mZ=35902,hZ=35899,pZ=1021,fZ=1022,Gc=1023,dw=1026,uw=1027,gZ=1028,XO=1029,YO=1030,QO=1031,ZO=1033,Uk=33776,Bk=33777,Hk=33778,qk=33779,AF=35840,jF=35841,MF=35842,EF=35843,DF=36196,FF=37492,RF=37496,LF=37808,OF=37809,IF=37810,zF=37811,UF=37812,BF=37813,HF=37814,qF=37815,$F=37816,VF=37817,GF=37818,WF=37819,KF=37820,XF=37821,YF=36492,QF=36494,ZF=36495,JF=36283,eR=36284,tR=36285,nR=36286,Nve=3200,Cve=3201,bZ=0,Pve=1,Rh=\"\",ol=\"srgb\",Kx=\"srgb-linear\",TN=\"linear\",Qr=\"srgb\",_b=7680,$H=519,Tve=512,Ave=513,jve=514,xZ=515,Mve=516,Eve=517,Dve=518,Fve=519,VH=35044,GH=\"300 es\",Md=2e3,AN=2001;function yZ(t){for(let e=t.length-1;e>=0;--e)if(t[e]>=65535)return!0;return!1}function jN(t){return document.createElementNS(\"http://www.w3.org/1999/xhtml\",t)}function Rve(){const t=jN(\"canvas\");return t.style.display=\"block\",t}const WH={};function KH(...t){const e=\"THREE.\"+t.shift();console.log(e,...t)}function qn(...t){const e=\"THREE.\"+t.shift();console.warn(e,...t)}function oi(...t){const e=\"THREE.\"+t.shift();console.error(e,...t)}function mw(...t){const e=t.join(\" \");e in WH||(WH[e]=!0,qn(...t))}function Lve(t,e,n){return new Promise(function(r,i){function s(){switch(t.clientWaitSync(e,t.SYNC_FLUSH_COMMANDS_BIT,0)){case t.WAIT_FAILED:i();break;case t.TIMEOUT_EXPIRED:setTimeout(s,n);break;default:r()}}setTimeout(s,n)})}let Ug=class{addEventListener(e,n){this._listeners===void 0&&(this._listeners={});const r=this._listeners;r[e]===void 0&&(r[e]=[]),r[e].indexOf(n)===-1&&r[e].push(n)}hasEventListener(e,n){const r=this._listeners;return r===void 0?!1:r[e]!==void 0&&r[e].indexOf(n)!==-1}removeEventListener(e,n){const r=this._listeners;if(r===void 0)return;const i=r[e];if(i!==void 0){const s=i.indexOf(n);s!==-1&&i.splice(s,1)}}dispatchEvent(e){const n=this._listeners;if(n===void 0)return;const r=n[e.type];if(r!==void 0){e.target=this;const i=r.slice(0);for(let s=0,o=i.length;s<o;s++)i[s].call(this,e);e.target=null}}};const Ms=[\"00\",\"01\",\"02\",\"03\",\"04\",\"05\",\"06\",\"07\",\"08\",\"09\",\"0a\",\"0b\",\"0c\",\"0d\",\"0e\",\"0f\",\"10\",\"11\",\"12\",\"13\",\"14\",\"15\",\"16\",\"17\",\"18\",\"19\",\"1a\",\"1b\",\"1c\",\"1d\",\"1e\",\"1f\",\"20\",\"21\",\"22\",\"23\",\"24\",\"25\",\"26\",\"27\",\"28\",\"29\",\"2a\",\"2b\",\"2c\",\"2d\",\"2e\",\"2f\",\"30\",\"31\",\"32\",\"33\",\"34\",\"35\",\"36\",\"37\",\"38\",\"39\",\"3a\",\"3b\",\"3c\",\"3d\",\"3e\",\"3f\",\"40\",\"41\",\"42\",\"43\",\"44\",\"45\",\"46\",\"47\",\"48\",\"49\",\"4a\",\"4b\",\"4c\",\"4d\",\"4e\",\"4f\",\"50\",\"51\",\"52\",\"53\",\"54\",\"55\",\"56\",\"57\",\"58\",\"59\",\"5a\",\"5b\",\"5c\",\"5d\",\"5e\",\"5f\",\"60\",\"61\",\"62\",\"63\",\"64\",\"65\",\"66\",\"67\",\"68\",\"69\",\"6a\",\"6b\",\"6c\",\"6d\",\"6e\",\"6f\",\"70\",\"71\",\"72\",\"73\",\"74\",\"75\",\"76\",\"77\",\"78\",\"79\",\"7a\",\"7b\",\"7c\",\"7d\",\"7e\",\"7f\",\"80\",\"81\",\"82\",\"83\",\"84\",\"85\",\"86\",\"87\",\"88\",\"89\",\"8a\",\"8b\",\"8c\",\"8d\",\"8e\",\"8f\",\"90\",\"91\",\"92\",\"93\",\"94\",\"95\",\"96\",\"97\",\"98\",\"99\",\"9a\",\"9b\",\"9c\",\"9d\",\"9e\",\"9f\",\"a0\",\"a1\",\"a2\",\"a3\",\"a4\",\"a5\",\"a6\",\"a7\",\"a8\",\"a9\",\"aa\",\"ab\",\"ac\",\"ad\",\"ae\",\"af\",\"b0\",\"b1\",\"b2\",\"b3\",\"b4\",\"b5\",\"b6\",\"b7\",\"b8\",\"b9\",\"ba\",\"bb\",\"bc\",\"bd\",\"be\",\"bf\",\"c0\",\"c1\",\"c2\",\"c3\",\"c4\",\"c5\",\"c6\",\"c7\",\"c8\",\"c9\",\"ca\",\"cb\",\"cc\",\"cd\",\"ce\",\"cf\",\"d0\",\"d1\",\"d2\",\"d3\",\"d4\",\"d5\",\"d6\",\"d7\",\"d8\",\"d9\",\"da\",\"db\",\"dc\",\"dd\",\"de\",\"df\",\"e0\",\"e1\",\"e2\",\"e3\",\"e4\",\"e5\",\"e6\",\"e7\",\"e8\",\"e9\",\"ea\",\"eb\",\"ec\",\"ed\",\"ee\",\"ef\",\"f0\",\"f1\",\"f2\",\"f3\",\"f4\",\"f5\",\"f6\",\"f7\",\"f8\",\"f9\",\"fa\",\"fb\",\"fc\",\"fd\",\"fe\",\"ff\"],$k=Math.PI/180,rR=180/Math.PI;function lS(){const t=Math.random()*4294967295|0,e=Math.random()*4294967295|0,n=Math.random()*4294967295|0,r=Math.random()*4294967295|0;return(Ms[t&255]+Ms[t>>8&255]+Ms[t>>16&255]+Ms[t>>24&255]+\"-\"+Ms[e&255]+Ms[e>>8&255]+\"-\"+Ms[e>>16&15|64]+Ms[e>>24&255]+\"-\"+Ms[n&63|128]+Ms[n>>8&255]+\"-\"+Ms[n>>16&255]+Ms[n>>24&255]+Ms[r&255]+Ms[r>>8&255]+Ms[r>>16&255]+Ms[r>>24&255]).toLowerCase()}function fr(t,e,n){return Math.max(e,Math.min(n,t))}function Ove(t,e){return(t%e+e)%e}function DM(t,e,n){return(1-n)*t+n*e}function Ev(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error(\"Invalid component type.\")}}function jo(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(t*4294967295);case Uint16Array:return Math.round(t*65535);case Uint8Array:return Math.round(t*255);case Int32Array:return Math.round(t*2147483647);case Int16Array:return Math.round(t*32767);case Int8Array:return Math.round(t*127);default:throw new Error(\"Invalid component type.\")}}const Ive={DEG2RAD:$k};let nr=class vZ{constructor(e=0,n=0){vZ.prototype.isVector2=!0,this.x=e,this.y=n}get width(){return this.x}set width(e){this.x=e}get height(){return this.y}set height(e){this.y=e}set(e,n){return this.x=e,this.y=n,this}setScalar(e){return this.x=e,this.y=e,this}setX(e){return this.x=e,this}setY(e){return this.y=e,this}setComponent(e,n){switch(e){case 0:this.x=n;break;case 1:this.y=n;break;default:throw new Error(\"index is out of range: \"+e)}return this}getComponent(e){switch(e){case 0:return this.x;case 1:return this.y;default:throw new Error(\"index is out of range: \"+e)}}clone(){return new this.constructor(this.x,this.y)}copy(e){return this.x=e.x,this.y=e.y,this}add(e){return this.x+=e.x,this.y+=e.y,this}addScalar(e){return this.x+=e,this.y+=e,this}addVectors(e,n){return this.x=e.x+n.x,this.y=e.y+n.y,this}addScaledVector(e,n){return this.x+=e.x*n,this.y+=e.y*n,this}sub(e){return this.x-=e.x,this.y-=e.y,this}subScalar(e){return this.x-=e,this.y-=e,this}subVectors(e,n){return this.x=e.x-n.x,this.y=e.y-n.y,this}multiply(e){return this.x*=e.x,this.y*=e.y,this}multiplyScalar(e){return this.x*=e,this.y*=e,this}divide(e){return this.x/=e.x,this.y/=e.y,this}divideScalar(e){return this.multiplyScalar(1/e)}applyMatrix3(e){const n=this.x,r=this.y,i=e.elements;return this.x=i[0]*n+i[3]*r+i[6],this.y=i[1]*n+i[4]*r+i[7],this}min(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this}max(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this}clamp(e,n){return this.x=fr(this.x,e.x,n.x),this.y=fr(this.y,e.y,n.y),this}clampScalar(e,n){return this.x=fr(this.x,e,n),this.y=fr(this.y,e,n),this}clampLength(e,n){const r=this.length();return this.divideScalar(r||1).multiplyScalar(fr(r,e,n))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this}negate(){return this.x=-this.x,this.y=-this.y,this}dot(e){return this.x*e.x+this.y*e.y}cross(e){return this.x*e.y-this.y*e.x}lengthSq(){return this.x*this.x+this.y*this.y}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)}normalize(){return this.divideScalar(this.length()||1)}angle(){return Math.atan2(-this.y,-this.x)+Math.PI}angleTo(e){const n=Math.sqrt(this.lengthSq()*e.lengthSq());if(n===0)return Math.PI/2;const r=this.dot(e)/n;return Math.acos(fr(r,-1,1))}distanceTo(e){return Math.sqrt(this.distanceToSquared(e))}distanceToSquared(e){const n=this.x-e.x,r=this.y-e.y;return n*n+r*r}manhattanDistanceTo(e){return Math.abs(this.x-e.x)+Math.abs(this.y-e.y)}setLength(e){return this.normalize().multiplyScalar(e)}lerp(e,n){return this.x+=(e.x-this.x)*n,this.y+=(e.y-this.y)*n,this}lerpVectors(e,n,r){return this.x=e.x+(n.x-e.x)*r,this.y=e.y+(n.y-e.y)*r,this}equals(e){return e.x===this.x&&e.y===this.y}fromArray(e,n=0){return this.x=e[n],this.y=e[n+1],this}toArray(e=[],n=0){return e[n]=this.x,e[n+1]=this.y,e}fromBufferAttribute(e,n){return this.x=e.getX(n),this.y=e.getY(n),this}rotateAround(e,n){const r=Math.cos(n),i=Math.sin(n),s=this.x-e.x,o=this.y-e.y;return this.x=s*r-o*i+e.x,this.y=s*i+o*r+e.y,this}random(){return this.x=Math.random(),this.y=Math.random(),this}*[Symbol.iterator](){yield this.x,yield this.y}},gg=class{constructor(e=0,n=0,r=0,i=1){this.isQuaternion=!0,this._x=e,this._y=n,this._z=r,this._w=i}static slerpFlat(e,n,r,i,s,o,l){let c=r[i+0],d=r[i+1],u=r[i+2],m=r[i+3],p=s[o+0],f=s[o+1],y=s[o+2],v=s[o+3];if(l<=0){e[n+0]=c,e[n+1]=d,e[n+2]=u,e[n+3]=m;return}if(l>=1){e[n+0]=p,e[n+1]=f,e[n+2]=y,e[n+3]=v;return}if(m!==v||c!==p||d!==f||u!==y){let b=c*p+d*f+u*y+m*v;b<0&&(p=-p,f=-f,y=-y,v=-v,b=-b);let g=1-l;if(b<.9995){const _=Math.acos(b),C=Math.sin(_);g=Math.sin(g*_)/C,l=Math.sin(l*_)/C,c=c*g+p*l,d=d*g+f*l,u=u*g+y*l,m=m*g+v*l}else{c=c*g+p*l,d=d*g+f*l,u=u*g+y*l,m=m*g+v*l;const _=1/Math.sqrt(c*c+d*d+u*u+m*m);c*=_,d*=_,u*=_,m*=_}}e[n]=c,e[n+1]=d,e[n+2]=u,e[n+3]=m}static multiplyQuaternionsFlat(e,n,r,i,s,o){const l=r[i],c=r[i+1],d=r[i+2],u=r[i+3],m=s[o],p=s[o+1],f=s[o+2],y=s[o+3];return e[n]=l*y+u*m+c*f-d*p,e[n+1]=c*y+u*p+d*m-l*f,e[n+2]=d*y+u*f+l*p-c*m,e[n+3]=u*y-l*m-c*p-d*f,e}get x(){return this._x}set x(e){this._x=e,this._onChangeCallback()}get y(){return this._y}set y(e){this._y=e,this._onChangeCallback()}get z(){return this._z}set z(e){this._z=e,this._onChangeCallback()}get w(){return this._w}set w(e){this._w=e,this._onChangeCallback()}set(e,n,r,i){return this._x=e,this._y=n,this._z=r,this._w=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._w)}copy(e){return this._x=e.x,this._y=e.y,this._z=e.z,this._w=e.w,this._onChangeCallback(),this}setFromEuler(e,n=!0){const r=e._x,i=e._y,s=e._z,o=e._order,l=Math.cos,c=Math.sin,d=l(r/2),u=l(i/2),m=l(s/2),p=c(r/2),f=c(i/2),y=c(s/2);switch(o){case\"XYZ\":this._x=p*u*m+d*f*y,this._y=d*f*m-p*u*y,this._z=d*u*y+p*f*m,this._w=d*u*m-p*f*y;break;case\"YXZ\":this._x=p*u*m+d*f*y,this._y=d*f*m-p*u*y,this._z=d*u*y-p*f*m,this._w=d*u*m+p*f*y;break;case\"ZXY\":this._x=p*u*m-d*f*y,this._y=d*f*m+p*u*y,this._z=d*u*y+p*f*m,this._w=d*u*m-p*f*y;break;case\"ZYX\":this._x=p*u*m-d*f*y,this._y=d*f*m+p*u*y,this._z=d*u*y-p*f*m,this._w=d*u*m+p*f*y;break;case\"YZX\":this._x=p*u*m+d*f*y,this._y=d*f*m+p*u*y,this._z=d*u*y-p*f*m,this._w=d*u*m-p*f*y;break;case\"XZY\":this._x=p*u*m-d*f*y,this._y=d*f*m-p*u*y,this._z=d*u*y+p*f*m,this._w=d*u*m+p*f*y;break;default:qn(\"Quaternion: .setFromEuler() encountered an unknown order: \"+o)}return n===!0&&this._onChangeCallback(),this}setFromAxisAngle(e,n){const r=n/2,i=Math.sin(r);return this._x=e.x*i,this._y=e.y*i,this._z=e.z*i,this._w=Math.cos(r),this._onChangeCallback(),this}setFromRotationMatrix(e){const n=e.elements,r=n[0],i=n[4],s=n[8],o=n[1],l=n[5],c=n[9],d=n[2],u=n[6],m=n[10],p=r+l+m;if(p>0){const f=.5/Math.sqrt(p+1);this._w=.25/f,this._x=(u-c)*f,this._y=(s-d)*f,this._z=(o-i)*f}else if(r>l&&r>m){const f=2*Math.sqrt(1+r-l-m);this._w=(u-c)/f,this._x=.25*f,this._y=(i+o)/f,this._z=(s+d)/f}else if(l>m){const f=2*Math.sqrt(1+l-r-m);this._w=(s-d)/f,this._x=(i+o)/f,this._y=.25*f,this._z=(c+u)/f}else{const f=2*Math.sqrt(1+m-r-l);this._w=(o-i)/f,this._x=(s+d)/f,this._y=(c+u)/f,this._z=.25*f}return this._onChangeCallback(),this}setFromUnitVectors(e,n){let r=e.dot(n)+1;return r<1e-8?(r=0,Math.abs(e.x)>Math.abs(e.z)?(this._x=-e.y,this._y=e.x,this._z=0,this._w=r):(this._x=0,this._y=-e.z,this._z=e.y,this._w=r)):(this._x=e.y*n.z-e.z*n.y,this._y=e.z*n.x-e.x*n.z,this._z=e.x*n.y-e.y*n.x,this._w=r),this.normalize()}angleTo(e){return 2*Math.acos(Math.abs(fr(this.dot(e),-1,1)))}rotateTowards(e,n){const r=this.angleTo(e);if(r===0)return this;const i=Math.min(1,n/r);return this.slerp(e,i),this}identity(){return this.set(0,0,0,1)}invert(){return this.conjugate()}conjugate(){return this._x*=-1,this._y*=-1,this._z*=-1,this._onChangeCallback(),this}dot(e){return this._x*e._x+this._y*e._y+this._z*e._z+this._w*e._w}lengthSq(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w}length(){return Math.sqrt(this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w)}normalize(){let e=this.length();return e===0?(this._x=0,this._y=0,this._z=0,this._w=1):(e=1/e,this._x=this._x*e,this._y=this._y*e,this._z=this._z*e,this._w=this._w*e),this._onChangeCallback(),this}multiply(e){return this.multiplyQuaternions(this,e)}premultiply(e){return this.multiplyQuaternions(e,this)}multiplyQuaternions(e,n){const r=e._x,i=e._y,s=e._z,o=e._w,l=n._x,c=n._y,d=n._z,u=n._w;return this._x=r*u+o*l+i*d-s*c,this._y=i*u+o*c+s*l-r*d,this._z=s*u+o*d+r*c-i*l,this._w=o*u-r*l-i*c-s*d,this._onChangeCallback(),this}slerp(e,n){if(n<=0)return this;if(n>=1)return this.copy(e);let r=e._x,i=e._y,s=e._z,o=e._w,l=this.dot(e);l<0&&(r=-r,i=-i,s=-s,o=-o,l=-l);let c=1-n;if(l<.9995){const d=Math.acos(l),u=Math.sin(d);c=Math.sin(c*d)/u,n=Math.sin(n*d)/u,this._x=this._x*c+r*n,this._y=this._y*c+i*n,this._z=this._z*c+s*n,this._w=this._w*c+o*n,this._onChangeCallback()}else this._x=this._x*c+r*n,this._y=this._y*c+i*n,this._z=this._z*c+s*n,this._w=this._w*c+o*n,this.normalize();return this}slerpQuaternions(e,n,r){return this.copy(e).slerp(n,r)}random(){const e=2*Math.PI*Math.random(),n=2*Math.PI*Math.random(),r=Math.random(),i=Math.sqrt(1-r),s=Math.sqrt(r);return this.set(i*Math.sin(e),i*Math.cos(e),s*Math.sin(n),s*Math.cos(n))}equals(e){return e._x===this._x&&e._y===this._y&&e._z===this._z&&e._w===this._w}fromArray(e,n=0){return this._x=e[n],this._y=e[n+1],this._z=e[n+2],this._w=e[n+3],this._onChangeCallback(),this}toArray(e=[],n=0){return e[n]=this._x,e[n+1]=this._y,e[n+2]=this._z,e[n+3]=this._w,e}fromBufferAttribute(e,n){return this._x=e.getX(n),this._y=e.getY(n),this._z=e.getZ(n),this._w=e.getW(n),this._onChangeCallback(),this}toJSON(){return this.toArray()}_onChange(e){return this._onChangeCallback=e,this}_onChangeCallback(){}*[Symbol.iterator](){yield this._x,yield this._y,yield this._z,yield this._w}},Ct=class wZ{constructor(e=0,n=0,r=0){wZ.prototype.isVector3=!0,this.x=e,this.y=n,this.z=r}set(e,n,r){return r===void 0&&(r=this.z),this.x=e,this.y=n,this.z=r,this}setScalar(e){return this.x=e,this.y=e,this.z=e,this}setX(e){return this.x=e,this}setY(e){return this.y=e,this}setZ(e){return this.z=e,this}setComponent(e,n){switch(e){case 0:this.x=n;break;case 1:this.y=n;break;case 2:this.z=n;break;default:throw new Error(\"index is out of range: \"+e)}return this}getComponent(e){switch(e){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw new Error(\"index is out of range: \"+e)}}clone(){return new this.constructor(this.x,this.y,this.z)}copy(e){return this.x=e.x,this.y=e.y,this.z=e.z,this}add(e){return this.x+=e.x,this.y+=e.y,this.z+=e.z,this}addScalar(e){return this.x+=e,this.y+=e,this.z+=e,this}addVectors(e,n){return this.x=e.x+n.x,this.y=e.y+n.y,this.z=e.z+n.z,this}addScaledVector(e,n){return this.x+=e.x*n,this.y+=e.y*n,this.z+=e.z*n,this}sub(e){return this.x-=e.x,this.y-=e.y,this.z-=e.z,this}subScalar(e){return this.x-=e,this.y-=e,this.z-=e,this}subVectors(e,n){return this.x=e.x-n.x,this.y=e.y-n.y,this.z=e.z-n.z,this}multiply(e){return this.x*=e.x,this.y*=e.y,this.z*=e.z,this}multiplyScalar(e){return this.x*=e,this.y*=e,this.z*=e,this}multiplyVectors(e,n){return this.x=e.x*n.x,this.y=e.y*n.y,this.z=e.z*n.z,this}applyEuler(e){return this.applyQuaternion(XH.setFromEuler(e))}applyAxisAngle(e,n){return this.applyQuaternion(XH.setFromAxisAngle(e,n))}applyMatrix3(e){const n=this.x,r=this.y,i=this.z,s=e.elements;return this.x=s[0]*n+s[3]*r+s[6]*i,this.y=s[1]*n+s[4]*r+s[7]*i,this.z=s[2]*n+s[5]*r+s[8]*i,this}applyNormalMatrix(e){return this.applyMatrix3(e).normalize()}applyMatrix4(e){const n=this.x,r=this.y,i=this.z,s=e.elements,o=1/(s[3]*n+s[7]*r+s[11]*i+s[15]);return this.x=(s[0]*n+s[4]*r+s[8]*i+s[12])*o,this.y=(s[1]*n+s[5]*r+s[9]*i+s[13])*o,this.z=(s[2]*n+s[6]*r+s[10]*i+s[14])*o,this}applyQuaternion(e){const n=this.x,r=this.y,i=this.z,s=e.x,o=e.y,l=e.z,c=e.w,d=2*(o*i-l*r),u=2*(l*n-s*i),m=2*(s*r-o*n);return this.x=n+c*d+o*m-l*u,this.y=r+c*u+l*d-s*m,this.z=i+c*m+s*u-o*d,this}project(e){return this.applyMatrix4(e.matrixWorldInverse).applyMatrix4(e.projectionMatrix)}unproject(e){return this.applyMatrix4(e.projectionMatrixInverse).applyMatrix4(e.matrixWorld)}transformDirection(e){const n=this.x,r=this.y,i=this.z,s=e.elements;return this.x=s[0]*n+s[4]*r+s[8]*i,this.y=s[1]*n+s[5]*r+s[9]*i,this.z=s[2]*n+s[6]*r+s[10]*i,this.normalize()}divide(e){return this.x/=e.x,this.y/=e.y,this.z/=e.z,this}divideScalar(e){return this.multiplyScalar(1/e)}min(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this.z=Math.min(this.z,e.z),this}max(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this.z=Math.max(this.z,e.z),this}clamp(e,n){return this.x=fr(this.x,e.x,n.x),this.y=fr(this.y,e.y,n.y),this.z=fr(this.z,e.z,n.z),this}clampScalar(e,n){return this.x=fr(this.x,e,n),this.y=fr(this.y,e,n),this.z=fr(this.z,e,n),this}clampLength(e,n){const r=this.length();return this.divideScalar(r||1).multiplyScalar(fr(r,e,n))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this.z=Math.trunc(this.z),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this}dot(e){return this.x*e.x+this.y*e.y+this.z*e.z}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)}normalize(){return this.divideScalar(this.length()||1)}setLength(e){return this.normalize().multiplyScalar(e)}lerp(e,n){return this.x+=(e.x-this.x)*n,this.y+=(e.y-this.y)*n,this.z+=(e.z-this.z)*n,this}lerpVectors(e,n,r){return this.x=e.x+(n.x-e.x)*r,this.y=e.y+(n.y-e.y)*r,this.z=e.z+(n.z-e.z)*r,this}cross(e){return this.crossVectors(this,e)}crossVectors(e,n){const r=e.x,i=e.y,s=e.z,o=n.x,l=n.y,c=n.z;return this.x=i*c-s*l,this.y=s*o-r*c,this.z=r*l-i*o,this}projectOnVector(e){const n=e.lengthSq();if(n===0)return this.set(0,0,0);const r=e.dot(this)/n;return this.copy(e).multiplyScalar(r)}projectOnPlane(e){return FM.copy(this).projectOnVector(e),this.sub(FM)}reflect(e){return this.sub(FM.copy(e).multiplyScalar(2*this.dot(e)))}angleTo(e){const n=Math.sqrt(this.lengthSq()*e.lengthSq());if(n===0)return Math.PI/2;const r=this.dot(e)/n;return Math.acos(fr(r,-1,1))}distanceTo(e){return Math.sqrt(this.distanceToSquared(e))}distanceToSquared(e){const n=this.x-e.x,r=this.y-e.y,i=this.z-e.z;return n*n+r*r+i*i}manhattanDistanceTo(e){return Math.abs(this.x-e.x)+Math.abs(this.y-e.y)+Math.abs(this.z-e.z)}setFromSpherical(e){return this.setFromSphericalCoords(e.radius,e.phi,e.theta)}setFromSphericalCoords(e,n,r){const i=Math.sin(n)*e;return this.x=i*Math.sin(r),this.y=Math.cos(n)*e,this.z=i*Math.cos(r),this}setFromCylindrical(e){return this.setFromCylindricalCoords(e.radius,e.theta,e.y)}setFromCylindricalCoords(e,n,r){return this.x=e*Math.sin(n),this.y=r,this.z=e*Math.cos(n),this}setFromMatrixPosition(e){const n=e.elements;return this.x=n[12],this.y=n[13],this.z=n[14],this}setFromMatrixScale(e){const n=this.setFromMatrixColumn(e,0).length(),r=this.setFromMatrixColumn(e,1).length(),i=this.setFromMatrixColumn(e,2).length();return this.x=n,this.y=r,this.z=i,this}setFromMatrixColumn(e,n){return this.fromArray(e.elements,n*4)}setFromMatrix3Column(e,n){return this.fromArray(e.elements,n*3)}setFromEuler(e){return this.x=e._x,this.y=e._y,this.z=e._z,this}setFromColor(e){return this.x=e.r,this.y=e.g,this.z=e.b,this}equals(e){return e.x===this.x&&e.y===this.y&&e.z===this.z}fromArray(e,n=0){return this.x=e[n],this.y=e[n+1],this.z=e[n+2],this}toArray(e=[],n=0){return e[n]=this.x,e[n+1]=this.y,e[n+2]=this.z,e}fromBufferAttribute(e,n){return this.x=e.getX(n),this.y=e.getY(n),this.z=e.getZ(n),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this}randomDirection(){const e=Math.random()*Math.PI*2,n=Math.random()*2-1,r=Math.sqrt(1-n*n);return this.x=r*Math.cos(e),this.y=n,this.z=r*Math.sin(e),this}*[Symbol.iterator](){yield this.x,yield this.y,yield this.z}};const FM=new Ct,XH=new gg;let ir=class SZ{constructor(e,n,r,i,s,o,l,c,d){SZ.prototype.isMatrix3=!0,this.elements=[1,0,0,0,1,0,0,0,1],e!==void 0&&this.set(e,n,r,i,s,o,l,c,d)}set(e,n,r,i,s,o,l,c,d){const u=this.elements;return u[0]=e,u[1]=i,u[2]=l,u[3]=n,u[4]=s,u[5]=c,u[6]=r,u[7]=o,u[8]=d,this}identity(){return this.set(1,0,0,0,1,0,0,0,1),this}copy(e){const n=this.elements,r=e.elements;return n[0]=r[0],n[1]=r[1],n[2]=r[2],n[3]=r[3],n[4]=r[4],n[5]=r[5],n[6]=r[6],n[7]=r[7],n[8]=r[8],this}extractBasis(e,n,r){return e.setFromMatrix3Column(this,0),n.setFromMatrix3Column(this,1),r.setFromMatrix3Column(this,2),this}setFromMatrix4(e){const n=e.elements;return this.set(n[0],n[4],n[8],n[1],n[5],n[9],n[2],n[6],n[10]),this}multiply(e){return this.multiplyMatrices(this,e)}premultiply(e){return this.multiplyMatrices(e,this)}multiplyMatrices(e,n){const r=e.elements,i=n.elements,s=this.elements,o=r[0],l=r[3],c=r[6],d=r[1],u=r[4],m=r[7],p=r[2],f=r[5],y=r[8],v=i[0],b=i[3],g=i[6],_=i[1],C=i[4],P=i[7],N=i[2],A=i[5],T=i[8];return s[0]=o*v+l*_+c*N,s[3]=o*b+l*C+c*A,s[6]=o*g+l*P+c*T,s[1]=d*v+u*_+m*N,s[4]=d*b+u*C+m*A,s[7]=d*g+u*P+m*T,s[2]=p*v+f*_+y*N,s[5]=p*b+f*C+y*A,s[8]=p*g+f*P+y*T,this}multiplyScalar(e){const n=this.elements;return n[0]*=e,n[3]*=e,n[6]*=e,n[1]*=e,n[4]*=e,n[7]*=e,n[2]*=e,n[5]*=e,n[8]*=e,this}determinant(){const e=this.elements,n=e[0],r=e[1],i=e[2],s=e[3],o=e[4],l=e[5],c=e[6],d=e[7],u=e[8];return n*o*u-n*l*d-r*s*u+r*l*c+i*s*d-i*o*c}invert(){const e=this.elements,n=e[0],r=e[1],i=e[2],s=e[3],o=e[4],l=e[5],c=e[6],d=e[7],u=e[8],m=u*o-l*d,p=l*c-u*s,f=d*s-o*c,y=n*m+r*p+i*f;if(y===0)return this.set(0,0,0,0,0,0,0,0,0);const v=1/y;return e[0]=m*v,e[1]=(i*d-u*r)*v,e[2]=(l*r-i*o)*v,e[3]=p*v,e[4]=(u*n-i*c)*v,e[5]=(i*s-l*n)*v,e[6]=f*v,e[7]=(r*c-d*n)*v,e[8]=(o*n-r*s)*v,this}transpose(){let e;const n=this.elements;return e=n[1],n[1]=n[3],n[3]=e,e=n[2],n[2]=n[6],n[6]=e,e=n[5],n[5]=n[7],n[7]=e,this}getNormalMatrix(e){return this.setFromMatrix4(e).invert().transpose()}transposeIntoArray(e){const n=this.elements;return e[0]=n[0],e[1]=n[3],e[2]=n[6],e[3]=n[1],e[4]=n[4],e[5]=n[7],e[6]=n[2],e[7]=n[5],e[8]=n[8],this}setUvTransform(e,n,r,i,s,o,l){const c=Math.cos(s),d=Math.sin(s);return this.set(r*c,r*d,-r*(c*o+d*l)+o+e,-i*d,i*c,-i*(-d*o+c*l)+l+n,0,0,1),this}scale(e,n){return this.premultiply(RM.makeScale(e,n)),this}rotate(e){return this.premultiply(RM.makeRotation(-e)),this}translate(e,n){return this.premultiply(RM.makeTranslation(e,n)),this}makeTranslation(e,n){return e.isVector2?this.set(1,0,e.x,0,1,e.y,0,0,1):this.set(1,0,e,0,1,n,0,0,1),this}makeRotation(e){const n=Math.cos(e),r=Math.sin(e);return this.set(n,-r,0,r,n,0,0,0,1),this}makeScale(e,n){return this.set(e,0,0,0,n,0,0,0,1),this}equals(e){const n=this.elements,r=e.elements;for(let i=0;i<9;i++)if(n[i]!==r[i])return!1;return!0}fromArray(e,n=0){for(let r=0;r<9;r++)this.elements[r]=e[r+n];return this}toArray(e=[],n=0){const r=this.elements;return e[n]=r[0],e[n+1]=r[1],e[n+2]=r[2],e[n+3]=r[3],e[n+4]=r[4],e[n+5]=r[5],e[n+6]=r[6],e[n+7]=r[7],e[n+8]=r[8],e}clone(){return new this.constructor().fromArray(this.elements)}};const RM=new ir,YH=new ir().set(.4123908,.3575843,.1804808,.212639,.7151687,.0721923,.0193308,.1191948,.9505322),QH=new ir().set(3.2409699,-1.5373832,-.4986108,-.9692436,1.8759675,.0415551,.0556301,-.203977,1.0569715);function zve(){const t={enabled:!0,workingColorSpace:Kx,spaces:{},convert:function(i,s,o){return this.enabled===!1||s===o||!s||!o||(this.spaces[s].transfer===Qr&&(i.r=cm(i.r),i.g=cm(i.g),i.b=cm(i.b)),this.spaces[s].primaries!==this.spaces[o].primaries&&(i.applyMatrix3(this.spaces[s].toXYZ),i.applyMatrix3(this.spaces[o].fromXYZ)),this.spaces[o].transfer===Qr&&(i.r=jx(i.r),i.g=jx(i.g),i.b=jx(i.b))),i},workingToColorSpace:function(i,s){return this.convert(i,this.workingColorSpace,s)},colorSpaceToWorking:function(i,s){return this.convert(i,s,this.workingColorSpace)},getPrimaries:function(i){return this.spaces[i].primaries},getTransfer:function(i){return i===Rh?TN:this.spaces[i].transfer},getToneMappingMode:function(i){return this.spaces[i].outputColorSpaceConfig.toneMappingMode||\"standard\"},getLuminanceCoefficients:function(i,s=this.workingColorSpace){return i.fromArray(this.spaces[s].luminanceCoefficients)},define:function(i){Object.assign(this.spaces,i)},_getMatrix:function(i,s,o){return i.copy(this.spaces[s].toXYZ).multiply(this.spaces[o].fromXYZ)},_getDrawingBufferColorSpace:function(i){return this.spaces[i].outputColorSpaceConfig.drawingBufferColorSpace},_getUnpackColorSpace:function(i=this.workingColorSpace){return this.spaces[i].workingColorSpaceConfig.unpackColorSpace},fromWorkingColorSpace:function(i,s){return mw(\"ColorManagement: .fromWorkingColorSpace() has been renamed to .workingToColorSpace().\"),t.workingToColorSpace(i,s)},toWorkingColorSpace:function(i,s){return mw(\"ColorManagement: .toWorkingColorSpace() has been renamed to .colorSpaceToWorking().\"),t.colorSpaceToWorking(i,s)}},e=[.64,.33,.3,.6,.15,.06],n=[.2126,.7152,.0722],r=[.3127,.329];return t.define({[Kx]:{primaries:e,whitePoint:r,transfer:TN,toXYZ:YH,fromXYZ:QH,luminanceCoefficients:n,workingColorSpaceConfig:{unpackColorSpace:ol},outputColorSpaceConfig:{drawingBufferColorSpace:ol}},[ol]:{primaries:e,whitePoint:r,transfer:Qr,toXYZ:YH,fromXYZ:QH,luminanceCoefficients:n,outputColorSpaceConfig:{drawingBufferColorSpace:ol}}}),t}const Mr=zve();function cm(t){return t<.04045?t*.0773993808:Math.pow(t*.9478672986+.0521327014,2.4)}function jx(t){return t<.0031308?t*12.92:1.055*Math.pow(t,.41666)-.055}let kb,Uve=class{static getDataURL(e,n=\"image/png\"){if(/^data:/i.test(e.src)||typeof HTMLCanvasElement>\"u\")return e.src;let r;if(e instanceof HTMLCanvasElement)r=e;else{kb===void 0&&(kb=jN(\"canvas\")),kb.width=e.width,kb.height=e.height;const i=kb.getContext(\"2d\");e instanceof ImageData?i.putImageData(e,0,0):i.drawImage(e,0,0,e.width,e.height),r=kb}return r.toDataURL(n)}static sRGBToLinear(e){if(typeof HTMLImageElement<\"u\"&&e instanceof HTMLImageElement||typeof HTMLCanvasElement<\"u\"&&e instanceof HTMLCanvasElement||typeof ImageBitmap<\"u\"&&e instanceof ImageBitmap){const n=jN(\"canvas\");n.width=e.width,n.height=e.height;const r=n.getContext(\"2d\");r.drawImage(e,0,0,e.width,e.height);const i=r.getImageData(0,0,e.width,e.height),s=i.data;for(let o=0;o<s.length;o++)s[o]=cm(s[o]/255)*255;return r.putImageData(i,0,0),n}else if(e.data){const n=e.data.slice(0);for(let r=0;r<n.length;r++)n instanceof Uint8Array||n instanceof Uint8ClampedArray?n[r]=Math.floor(cm(n[r]/255)*255):n[r]=cm(n[r]);return{data:n,width:e.width,height:e.height}}else return qn(\"ImageUtils.sRGBToLinear(): Unsupported image type. No color space conversion applied.\"),e}},Bve=0,JO=class{constructor(e=null){this.isSource=!0,Object.defineProperty(this,\"id\",{value:Bve++}),this.uuid=lS(),this.data=e,this.dataReady=!0,this.version=0}getSize(e){const n=this.data;return typeof HTMLVideoElement<\"u\"&&n instanceof HTMLVideoElement?e.set(n.videoWidth,n.videoHeight,0):n instanceof VideoFrame?e.set(n.displayHeight,n.displayWidth,0):n!==null?e.set(n.width,n.height,n.depth||0):e.set(0,0,0),e}set needsUpdate(e){e===!0&&this.version++}toJSON(e){const n=e===void 0||typeof e==\"string\";if(!n&&e.images[this.uuid]!==void 0)return e.images[this.uuid];const r={uuid:this.uuid,url:\"\"},i=this.data;if(i!==null){let s;if(Array.isArray(i)){s=[];for(let o=0,l=i.length;o<l;o++)i[o].isDataTexture?s.push(LM(i[o].image)):s.push(LM(i[o]))}else s=LM(i);r.url=s}return n||(e.images[this.uuid]=r),r}};function LM(t){return typeof HTMLImageElement<\"u\"&&t instanceof HTMLImageElement||typeof HTMLCanvasElement<\"u\"&&t instanceof HTMLCanvasElement||typeof ImageBitmap<\"u\"&&t instanceof ImageBitmap?Uve.getDataURL(t):t.data?{data:Array.from(t.data),width:t.width,height:t.height,type:t.data.constructor.name}:(qn(\"Texture: Unable to serialize Texture.\"),{})}let Hve=0;const OM=new Ct;let nd=class Vk extends Ug{constructor(e=Vk.DEFAULT_IMAGE,n=Vk.DEFAULT_MAPPING,r=Ju,i=Ju,s=ec,o=Rf,l=Gc,c=Hd,d=Vk.DEFAULT_ANISOTROPY,u=Rh){super(),this.isTexture=!0,Object.defineProperty(this,\"id\",{value:Hve++}),this.uuid=lS(),this.name=\"\",this.source=new JO(e),this.mipmaps=[],this.mapping=n,this.channel=0,this.wrapS=r,this.wrapT=i,this.magFilter=s,this.minFilter=o,this.anisotropy=d,this.format=l,this.internalFormat=null,this.type=c,this.offset=new nr(0,0),this.repeat=new nr(1,1),this.center=new nr(0,0),this.rotation=0,this.matrixAutoUpdate=!0,this.matrix=new ir,this.generateMipmaps=!0,this.premultiplyAlpha=!1,this.flipY=!0,this.unpackAlignment=4,this.colorSpace=u,this.userData={},this.updateRanges=[],this.version=0,this.onUpdate=null,this.renderTarget=null,this.isRenderTargetTexture=!1,this.isArrayTexture=!!(e&&e.depth&&e.depth>1),this.pmremVersion=0}get width(){return this.source.getSize(OM).x}get height(){return this.source.getSize(OM).y}get depth(){return this.source.getSize(OM).z}get image(){return this.source.data}set image(e=null){this.source.data=e}updateMatrix(){this.matrix.setUvTransform(this.offset.x,this.offset.y,this.repeat.x,this.repeat.y,this.rotation,this.center.x,this.center.y)}addUpdateRange(e,n){this.updateRanges.push({start:e,count:n})}clearUpdateRanges(){this.updateRanges.length=0}clone(){return new this.constructor().copy(this)}copy(e){return this.name=e.name,this.source=e.source,this.mipmaps=e.mipmaps.slice(0),this.mapping=e.mapping,this.channel=e.channel,this.wrapS=e.wrapS,this.wrapT=e.wrapT,this.magFilter=e.magFilter,this.minFilter=e.minFilter,this.anisotropy=e.anisotropy,this.format=e.format,this.internalFormat=e.internalFormat,this.type=e.type,this.offset.copy(e.offset),this.repeat.copy(e.repeat),this.center.copy(e.center),this.rotation=e.rotation,this.matrixAutoUpdate=e.matrixAutoUpdate,this.matrix.copy(e.matrix),this.generateMipmaps=e.generateMipmaps,this.premultiplyAlpha=e.premultiplyAlpha,this.flipY=e.flipY,this.unpackAlignment=e.unpackAlignment,this.colorSpace=e.colorSpace,this.renderTarget=e.renderTarget,this.isRenderTargetTexture=e.isRenderTargetTexture,this.isArrayTexture=e.isArrayTexture,this.userData=JSON.parse(JSON.stringify(e.userData)),this.needsUpdate=!0,this}setValues(e){for(const n in e){const r=e[n];if(r===void 0){qn(`Texture.setValues(): parameter '${n}' has value of undefined.`);continue}const i=this[n];if(i===void 0){qn(`Texture.setValues(): property '${n}' does not exist.`);continue}i&&r&&i.isVector2&&r.isVector2||i&&r&&i.isVector3&&r.isVector3||i&&r&&i.isMatrix3&&r.isMatrix3?i.copy(r):this[n]=r}}toJSON(e){const n=e===void 0||typeof e==\"string\";if(!n&&e.textures[this.uuid]!==void 0)return e.textures[this.uuid];const r={metadata:{version:4.7,type:\"Texture\",generator:\"Texture.toJSON\"},uuid:this.uuid,name:this.name,image:this.source.toJSON(e).uuid,mapping:this.mapping,channel:this.channel,repeat:[this.repeat.x,this.repeat.y],offset:[this.offset.x,this.offset.y],center:[this.center.x,this.center.y],rotation:this.rotation,wrap:[this.wrapS,this.wrapT],format:this.format,internalFormat:this.internalFormat,type:this.type,colorSpace:this.colorSpace,minFilter:this.minFilter,magFilter:this.magFilter,anisotropy:this.anisotropy,flipY:this.flipY,generateMipmaps:this.generateMipmaps,premultiplyAlpha:this.premultiplyAlpha,unpackAlignment:this.unpackAlignment};return Object.keys(this.userData).length>0&&(r.userData=this.userData),n||(e.textures[this.uuid]=r),r}dispose(){this.dispatchEvent({type:\"dispose\"})}transformUv(e){if(this.mapping!==cZ)return e;if(e.applyMatrix3(this.matrix),e.x<0||e.x>1)switch(this.wrapS){case PF:e.x=e.x-Math.floor(e.x);break;case Ju:e.x=e.x<0?0:1;break;case TF:Math.abs(Math.floor(e.x)%2)===1?e.x=Math.ceil(e.x)-e.x:e.x=e.x-Math.floor(e.x);break}if(e.y<0||e.y>1)switch(this.wrapT){case PF:e.y=e.y-Math.floor(e.y);break;case Ju:e.y=e.y<0?0:1;break;case TF:Math.abs(Math.floor(e.y)%2)===1?e.y=Math.ceil(e.y)-e.y:e.y=e.y-Math.floor(e.y);break}return this.flipY&&(e.y=1-e.y),e}set needsUpdate(e){e===!0&&(this.version++,this.source.needsUpdate=!0)}set needsPMREMUpdate(e){e===!0&&this.pmremVersion++}};nd.DEFAULT_IMAGE=null;nd.DEFAULT_MAPPING=cZ;nd.DEFAULT_ANISOTROPY=1;let yi=class _Z{constructor(e=0,n=0,r=0,i=1){_Z.prototype.isVector4=!0,this.x=e,this.y=n,this.z=r,this.w=i}get width(){return this.z}set width(e){this.z=e}get height(){return this.w}set height(e){this.w=e}set(e,n,r,i){return this.x=e,this.y=n,this.z=r,this.w=i,this}setScalar(e){return this.x=e,this.y=e,this.z=e,this.w=e,this}setX(e){return this.x=e,this}setY(e){return this.y=e,this}setZ(e){return this.z=e,this}setW(e){return this.w=e,this}setComponent(e,n){switch(e){case 0:this.x=n;break;case 1:this.y=n;break;case 2:this.z=n;break;case 3:this.w=n;break;default:throw new Error(\"index is out of range: \"+e)}return this}getComponent(e){switch(e){case 0:return this.x;case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw new Error(\"index is out of range: \"+e)}}clone(){return new this.constructor(this.x,this.y,this.z,this.w)}copy(e){return this.x=e.x,this.y=e.y,this.z=e.z,this.w=e.w!==void 0?e.w:1,this}add(e){return this.x+=e.x,this.y+=e.y,this.z+=e.z,this.w+=e.w,this}addScalar(e){return this.x+=e,this.y+=e,this.z+=e,this.w+=e,this}addVectors(e,n){return this.x=e.x+n.x,this.y=e.y+n.y,this.z=e.z+n.z,this.w=e.w+n.w,this}addScaledVector(e,n){return this.x+=e.x*n,this.y+=e.y*n,this.z+=e.z*n,this.w+=e.w*n,this}sub(e){return this.x-=e.x,this.y-=e.y,this.z-=e.z,this.w-=e.w,this}subScalar(e){return this.x-=e,this.y-=e,this.z-=e,this.w-=e,this}subVectors(e,n){return this.x=e.x-n.x,this.y=e.y-n.y,this.z=e.z-n.z,this.w=e.w-n.w,this}multiply(e){return this.x*=e.x,this.y*=e.y,this.z*=e.z,this.w*=e.w,this}multiplyScalar(e){return this.x*=e,this.y*=e,this.z*=e,this.w*=e,this}applyMatrix4(e){const n=this.x,r=this.y,i=this.z,s=this.w,o=e.elements;return this.x=o[0]*n+o[4]*r+o[8]*i+o[12]*s,this.y=o[1]*n+o[5]*r+o[9]*i+o[13]*s,this.z=o[2]*n+o[6]*r+o[10]*i+o[14]*s,this.w=o[3]*n+o[7]*r+o[11]*i+o[15]*s,this}divide(e){return this.x/=e.x,this.y/=e.y,this.z/=e.z,this.w/=e.w,this}divideScalar(e){return this.multiplyScalar(1/e)}setAxisAngleFromQuaternion(e){this.w=2*Math.acos(e.w);const n=Math.sqrt(1-e.w*e.w);return n<1e-4?(this.x=1,this.y=0,this.z=0):(this.x=e.x/n,this.y=e.y/n,this.z=e.z/n),this}setAxisAngleFromRotationMatrix(e){let n,r,i,s;const c=e.elements,d=c[0],u=c[4],m=c[8],p=c[1],f=c[5],y=c[9],v=c[2],b=c[6],g=c[10];if(Math.abs(u-p)<.01&&Math.abs(m-v)<.01&&Math.abs(y-b)<.01){if(Math.abs(u+p)<.1&&Math.abs(m+v)<.1&&Math.abs(y+b)<.1&&Math.abs(d+f+g-3)<.1)return this.set(1,0,0,0),this;n=Math.PI;const C=(d+1)/2,P=(f+1)/2,N=(g+1)/2,A=(u+p)/4,T=(m+v)/4,F=(y+b)/4;return C>P&&C>N?C<.01?(r=0,i=.707106781,s=.707106781):(r=Math.sqrt(C),i=A/r,s=T/r):P>N?P<.01?(r=.707106781,i=0,s=.707106781):(i=Math.sqrt(P),r=A/i,s=F/i):N<.01?(r=.707106781,i=.707106781,s=0):(s=Math.sqrt(N),r=T/s,i=F/s),this.set(r,i,s,n),this}let _=Math.sqrt((b-y)*(b-y)+(m-v)*(m-v)+(p-u)*(p-u));return Math.abs(_)<.001&&(_=1),this.x=(b-y)/_,this.y=(m-v)/_,this.z=(p-u)/_,this.w=Math.acos((d+f+g-1)/2),this}setFromMatrixPosition(e){const n=e.elements;return this.x=n[12],this.y=n[13],this.z=n[14],this.w=n[15],this}min(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this.z=Math.min(this.z,e.z),this.w=Math.min(this.w,e.w),this}max(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this.z=Math.max(this.z,e.z),this.w=Math.max(this.w,e.w),this}clamp(e,n){return this.x=fr(this.x,e.x,n.x),this.y=fr(this.y,e.y,n.y),this.z=fr(this.z,e.z,n.z),this.w=fr(this.w,e.w,n.w),this}clampScalar(e,n){return this.x=fr(this.x,e,n),this.y=fr(this.y,e,n),this.z=fr(this.z,e,n),this.w=fr(this.w,e,n),this}clampLength(e,n){const r=this.length();return this.divideScalar(r||1).multiplyScalar(fr(r,e,n))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this.w=Math.floor(this.w),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this.w=Math.ceil(this.w),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this.w=Math.round(this.w),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this.z=Math.trunc(this.z),this.w=Math.trunc(this.w),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this.w=-this.w,this}dot(e){return this.x*e.x+this.y*e.y+this.z*e.z+this.w*e.w}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)+Math.abs(this.w)}normalize(){return this.divideScalar(this.length()||1)}setLength(e){return this.normalize().multiplyScalar(e)}lerp(e,n){return this.x+=(e.x-this.x)*n,this.y+=(e.y-this.y)*n,this.z+=(e.z-this.z)*n,this.w+=(e.w-this.w)*n,this}lerpVectors(e,n,r){return this.x=e.x+(n.x-e.x)*r,this.y=e.y+(n.y-e.y)*r,this.z=e.z+(n.z-e.z)*r,this.w=e.w+(n.w-e.w)*r,this}equals(e){return e.x===this.x&&e.y===this.y&&e.z===this.z&&e.w===this.w}fromArray(e,n=0){return this.x=e[n],this.y=e[n+1],this.z=e[n+2],this.w=e[n+3],this}toArray(e=[],n=0){return e[n]=this.x,e[n+1]=this.y,e[n+2]=this.z,e[n+3]=this.w,e}fromBufferAttribute(e,n){return this.x=e.getX(n),this.y=e.getY(n),this.z=e.getZ(n),this.w=e.getW(n),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this.w=Math.random(),this}*[Symbol.iterator](){yield this.x,yield this.y,yield this.z,yield this.w}},qve=class extends Ug{constructor(e=1,n=1,r={}){super(),r=Object.assign({generateMipmaps:!1,internalFormat:null,minFilter:ec,depthBuffer:!0,stencilBuffer:!1,resolveDepthBuffer:!0,resolveStencilBuffer:!0,depthTexture:null,samples:0,count:1,depth:1,multiview:!1},r),this.isRenderTarget=!0,this.width=e,this.height=n,this.depth=r.depth,this.scissor=new yi(0,0,e,n),this.scissorTest=!1,this.viewport=new yi(0,0,e,n);const i={width:e,height:n,depth:r.depth},s=new nd(i);this.textures=[];const o=r.count;for(let l=0;l<o;l++)this.textures[l]=s.clone(),this.textures[l].isRenderTargetTexture=!0,this.textures[l].renderTarget=this;this._setTextureOptions(r),this.depthBuffer=r.depthBuffer,this.stencilBuffer=r.stencilBuffer,this.resolveDepthBuffer=r.resolveDepthBuffer,this.resolveStencilBuffer=r.resolveStencilBuffer,this._depthTexture=null,this.depthTexture=r.depthTexture,this.samples=r.samples,this.multiview=r.multiview}_setTextureOptions(e={}){const n={minFilter:ec,generateMipmaps:!1,flipY:!1,internalFormat:null};e.mapping!==void 0&&(n.mapping=e.mapping),e.wrapS!==void 0&&(n.wrapS=e.wrapS),e.wrapT!==void 0&&(n.wrapT=e.wrapT),e.wrapR!==void 0&&(n.wrapR=e.wrapR),e.magFilter!==void 0&&(n.magFilter=e.magFilter),e.minFilter!==void 0&&(n.minFilter=e.minFilter),e.format!==void 0&&(n.format=e.format),e.type!==void 0&&(n.type=e.type),e.anisotropy!==void 0&&(n.anisotropy=e.anisotropy),e.colorSpace!==void 0&&(n.colorSpace=e.colorSpace),e.flipY!==void 0&&(n.flipY=e.flipY),e.generateMipmaps!==void 0&&(n.generateMipmaps=e.generateMipmaps),e.internalFormat!==void 0&&(n.internalFormat=e.internalFormat);for(let r=0;r<this.textures.length;r++)this.textures[r].setValues(n)}get texture(){return this.textures[0]}set texture(e){this.textures[0]=e}set depthTexture(e){this._depthTexture!==null&&(this._depthTexture.renderTarget=null),e!==null&&(e.renderTarget=this),this._depthTexture=e}get depthTexture(){return this._depthTexture}setSize(e,n,r=1){if(this.width!==e||this.height!==n||this.depth!==r){this.width=e,this.height=n,this.depth=r;for(let i=0,s=this.textures.length;i<s;i++)this.textures[i].image.width=e,this.textures[i].image.height=n,this.textures[i].image.depth=r,this.textures[i].isData3DTexture!==!0&&(this.textures[i].isArrayTexture=this.textures[i].image.depth>1);this.dispose()}this.viewport.set(0,0,e,n),this.scissor.set(0,0,e,n)}clone(){return new this.constructor().copy(this)}copy(e){this.width=e.width,this.height=e.height,this.depth=e.depth,this.scissor.copy(e.scissor),this.scissorTest=e.scissorTest,this.viewport.copy(e.viewport),this.textures.length=0;for(let n=0,r=e.textures.length;n<r;n++){this.textures[n]=e.textures[n].clone(),this.textures[n].isRenderTargetTexture=!0,this.textures[n].renderTarget=this;const i=Object.assign({},e.textures[n].image);this.textures[n].source=new JO(i)}return this.depthBuffer=e.depthBuffer,this.stencilBuffer=e.stencilBuffer,this.resolveDepthBuffer=e.resolveDepthBuffer,this.resolveStencilBuffer=e.resolveStencilBuffer,e.depthTexture!==null&&(this.depthTexture=e.depthTexture.clone()),this.samples=e.samples,this}dispose(){this.dispatchEvent({type:\"dispose\"})}},bg=class extends qve{constructor(e=1,n=1,r={}){super(e,n,r),this.isWebGLRenderTarget=!0}},kZ=class extends nd{constructor(e=null,n=1,r=1,i=1){super(null),this.isDataArrayTexture=!0,this.image={data:e,width:n,height:r,depth:i},this.magFilter=hl,this.minFilter=hl,this.wrapR=Ju,this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1,this.layerUpdates=new Set}addLayerUpdate(e){this.layerUpdates.add(e)}clearLayerUpdates(){this.layerUpdates.clear()}},$ve=class extends nd{constructor(e=null,n=1,r=1,i=1){super(null),this.isData3DTexture=!0,this.image={data:e,width:n,height:r,depth:i},this.magFilter=hl,this.minFilter=hl,this.wrapR=Ju,this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1}},sp=class{constructor(e=new Ct(1/0,1/0,1/0),n=new Ct(-1/0,-1/0,-1/0)){this.isBox3=!0,this.min=e,this.max=n}set(e,n){return this.min.copy(e),this.max.copy(n),this}setFromArray(e){this.makeEmpty();for(let n=0,r=e.length;n<r;n+=3)this.expandByPoint(Mc.fromArray(e,n));return this}setFromBufferAttribute(e){this.makeEmpty();for(let n=0,r=e.count;n<r;n++)this.expandByPoint(Mc.fromBufferAttribute(e,n));return this}setFromPoints(e){this.makeEmpty();for(let n=0,r=e.length;n<r;n++)this.expandByPoint(e[n]);return this}setFromCenterAndSize(e,n){const r=Mc.copy(n).multiplyScalar(.5);return this.min.copy(e).sub(r),this.max.copy(e).add(r),this}setFromObject(e,n=!1){return this.makeEmpty(),this.expandByObject(e,n)}clone(){return new this.constructor().copy(this)}copy(e){return this.min.copy(e.min),this.max.copy(e.max),this}makeEmpty(){return this.min.x=this.min.y=this.min.z=1/0,this.max.x=this.max.y=this.max.z=-1/0,this}isEmpty(){return this.max.x<this.min.x||this.max.y<this.min.y||this.max.z<this.min.z}getCenter(e){return this.isEmpty()?e.set(0,0,0):e.addVectors(this.min,this.max).multiplyScalar(.5)}getSize(e){return this.isEmpty()?e.set(0,0,0):e.subVectors(this.max,this.min)}expandByPoint(e){return this.min.min(e),this.max.max(e),this}expandByVector(e){return this.min.sub(e),this.max.add(e),this}expandByScalar(e){return this.min.addScalar(-e),this.max.addScalar(e),this}expandByObject(e,n=!1){e.updateWorldMatrix(!1,!1);const r=e.geometry;if(r!==void 0){const s=r.getAttribute(\"position\");if(n===!0&&s!==void 0&&e.isInstancedMesh!==!0)for(let o=0,l=s.count;o<l;o++)e.isMesh===!0?e.getVertexPosition(o,Mc):Mc.fromBufferAttribute(s,o),Mc.applyMatrix4(e.matrixWorld),this.expandByPoint(Mc);else e.boundingBox!==void 0?(e.boundingBox===null&&e.computeBoundingBox(),J_.copy(e.boundingBox)):(r.boundingBox===null&&r.computeBoundingBox(),J_.copy(r.boundingBox)),J_.applyMatrix4(e.matrixWorld),this.union(J_)}const i=e.children;for(let s=0,o=i.length;s<o;s++)this.expandByObject(i[s],n);return this}containsPoint(e){return e.x>=this.min.x&&e.x<=this.max.x&&e.y>=this.min.y&&e.y<=this.max.y&&e.z>=this.min.z&&e.z<=this.max.z}containsBox(e){return this.min.x<=e.min.x&&e.max.x<=this.max.x&&this.min.y<=e.min.y&&e.max.y<=this.max.y&&this.min.z<=e.min.z&&e.max.z<=this.max.z}getParameter(e,n){return n.set((e.x-this.min.x)/(this.max.x-this.min.x),(e.y-this.min.y)/(this.max.y-this.min.y),(e.z-this.min.z)/(this.max.z-this.min.z))}intersectsBox(e){return e.max.x>=this.min.x&&e.min.x<=this.max.x&&e.max.y>=this.min.y&&e.min.y<=this.max.y&&e.max.z>=this.min.z&&e.min.z<=this.max.z}intersectsSphere(e){return this.clampPoint(e.center,Mc),Mc.distanceToSquared(e.center)<=e.radius*e.radius}intersectsPlane(e){let n,r;return e.normal.x>0?(n=e.normal.x*this.min.x,r=e.normal.x*this.max.x):(n=e.normal.x*this.max.x,r=e.normal.x*this.min.x),e.normal.y>0?(n+=e.normal.y*this.min.y,r+=e.normal.y*this.max.y):(n+=e.normal.y*this.max.y,r+=e.normal.y*this.min.y),e.normal.z>0?(n+=e.normal.z*this.min.z,r+=e.normal.z*this.max.z):(n+=e.normal.z*this.max.z,r+=e.normal.z*this.min.z),n<=-e.constant&&r>=-e.constant}intersectsTriangle(e){if(this.isEmpty())return!1;this.getCenter(Dv),e1.subVectors(this.max,Dv),Nb.subVectors(e.a,Dv),Cb.subVectors(e.b,Dv),Pb.subVectors(e.c,Dv),ch.subVectors(Cb,Nb),dh.subVectors(Pb,Cb),lf.subVectors(Nb,Pb);let n=[0,-ch.z,ch.y,0,-dh.z,dh.y,0,-lf.z,lf.y,ch.z,0,-ch.x,dh.z,0,-dh.x,lf.z,0,-lf.x,-ch.y,ch.x,0,-dh.y,dh.x,0,-lf.y,lf.x,0];return!IM(n,Nb,Cb,Pb,e1)||(n=[1,0,0,0,1,0,0,0,1],!IM(n,Nb,Cb,Pb,e1))?!1:(t1.crossVectors(ch,dh),n=[t1.x,t1.y,t1.z],IM(n,Nb,Cb,Pb,e1))}clampPoint(e,n){return n.copy(e).clamp(this.min,this.max)}distanceToPoint(e){return this.clampPoint(e,Mc).distanceTo(e)}getBoundingSphere(e){return this.isEmpty()?e.makeEmpty():(this.getCenter(e.center),e.radius=this.getSize(Mc).length()*.5),e}intersect(e){return this.min.max(e.min),this.max.min(e.max),this.isEmpty()&&this.makeEmpty(),this}union(e){return this.min.min(e.min),this.max.max(e.max),this}applyMatrix4(e){return this.isEmpty()?this:(_u[0].set(this.min.x,this.min.y,this.min.z).applyMatrix4(e),_u[1].set(this.min.x,this.min.y,this.max.z).applyMatrix4(e),_u[2].set(this.min.x,this.max.y,this.min.z).applyMatrix4(e),_u[3].set(this.min.x,this.max.y,this.max.z).applyMatrix4(e),_u[4].set(this.max.x,this.min.y,this.min.z).applyMatrix4(e),_u[5].set(this.max.x,this.min.y,this.max.z).applyMatrix4(e),_u[6].set(this.max.x,this.max.y,this.min.z).applyMatrix4(e),_u[7].set(this.max.x,this.max.y,this.max.z).applyMatrix4(e),this.setFromPoints(_u),this)}translate(e){return this.min.add(e),this.max.add(e),this}equals(e){return e.min.equals(this.min)&&e.max.equals(this.max)}toJSON(){return{min:this.min.toArray(),max:this.max.toArray()}}fromJSON(e){return this.min.fromArray(e.min),this.max.fromArray(e.max),this}};const _u=[new Ct,new Ct,new Ct,new Ct,new Ct,new Ct,new Ct,new Ct],Mc=new Ct,J_=new sp,Nb=new Ct,Cb=new Ct,Pb=new Ct,ch=new Ct,dh=new Ct,lf=new Ct,Dv=new Ct,e1=new Ct,t1=new Ct,cf=new Ct;function IM(t,e,n,r,i){for(let s=0,o=t.length-3;s<=o;s+=3){cf.fromArray(t,s);const l=i.x*Math.abs(cf.x)+i.y*Math.abs(cf.y)+i.z*Math.abs(cf.z),c=e.dot(cf),d=n.dot(cf),u=r.dot(cf);if(Math.max(-Math.max(c,d,u),Math.min(c,d,u))>l)return!1}return!0}const Vve=new sp,Fv=new Ct,zM=new Ct;let YP=class{constructor(e=new Ct,n=-1){this.isSphere=!0,this.center=e,this.radius=n}set(e,n){return this.center.copy(e),this.radius=n,this}setFromPoints(e,n){const r=this.center;n!==void 0?r.copy(n):Vve.setFromPoints(e).getCenter(r);let i=0;for(let s=0,o=e.length;s<o;s++)i=Math.max(i,r.distanceToSquared(e[s]));return this.radius=Math.sqrt(i),this}copy(e){return this.center.copy(e.center),this.radius=e.radius,this}isEmpty(){return this.radius<0}makeEmpty(){return this.center.set(0,0,0),this.radius=-1,this}containsPoint(e){return e.distanceToSquared(this.center)<=this.radius*this.radius}distanceToPoint(e){return e.distanceTo(this.center)-this.radius}intersectsSphere(e){const n=this.radius+e.radius;return e.center.distanceToSquared(this.center)<=n*n}intersectsBox(e){return e.intersectsSphere(this)}intersectsPlane(e){return Math.abs(e.distanceToPoint(this.center))<=this.radius}clampPoint(e,n){const r=this.center.distanceToSquared(e);return n.copy(e),r>this.radius*this.radius&&(n.sub(this.center).normalize(),n.multiplyScalar(this.radius).add(this.center)),n}getBoundingBox(e){return this.isEmpty()?(e.makeEmpty(),e):(e.set(this.center,this.center),e.expandByScalar(this.radius),e)}applyMatrix4(e){return this.center.applyMatrix4(e),this.radius=this.radius*e.getMaxScaleOnAxis(),this}translate(e){return this.center.add(e),this}expandByPoint(e){if(this.isEmpty())return this.center.copy(e),this.radius=0,this;Fv.subVectors(e,this.center);const n=Fv.lengthSq();if(n>this.radius*this.radius){const r=Math.sqrt(n),i=(r-this.radius)*.5;this.center.addScaledVector(Fv,i/r),this.radius+=i}return this}union(e){return e.isEmpty()?this:this.isEmpty()?(this.copy(e),this):(this.center.equals(e.center)===!0?this.radius=Math.max(this.radius,e.radius):(zM.subVectors(e.center,this.center).setLength(e.radius),this.expandByPoint(Fv.copy(e.center).add(zM)),this.expandByPoint(Fv.copy(e.center).sub(zM))),this)}equals(e){return e.center.equals(this.center)&&e.radius===this.radius}clone(){return new this.constructor().copy(this)}toJSON(){return{radius:this.radius,center:this.center.toArray()}}fromJSON(e){return this.radius=e.radius,this.center.fromArray(e.center),this}};const ku=new Ct,UM=new Ct,n1=new Ct,uh=new Ct,BM=new Ct,r1=new Ct,HM=new Ct;let eI=class{constructor(e=new Ct,n=new Ct(0,0,-1)){this.origin=e,this.direction=n}set(e,n){return this.origin.copy(e),this.direction.copy(n),this}copy(e){return this.origin.copy(e.origin),this.direction.copy(e.direction),this}at(e,n){return n.copy(this.origin).addScaledVector(this.direction,e)}lookAt(e){return this.direction.copy(e).sub(this.origin).normalize(),this}recast(e){return this.origin.copy(this.at(e,ku)),this}closestPointToPoint(e,n){n.subVectors(e,this.origin);const r=n.dot(this.direction);return r<0?n.copy(this.origin):n.copy(this.origin).addScaledVector(this.direction,r)}distanceToPoint(e){return Math.sqrt(this.distanceSqToPoint(e))}distanceSqToPoint(e){const n=ku.subVectors(e,this.origin).dot(this.direction);return n<0?this.origin.distanceToSquared(e):(ku.copy(this.origin).addScaledVector(this.direction,n),ku.distanceToSquared(e))}distanceSqToSegment(e,n,r,i){UM.copy(e).add(n).multiplyScalar(.5),n1.copy(n).sub(e).normalize(),uh.copy(this.origin).sub(UM);const s=e.distanceTo(n)*.5,o=-this.direction.dot(n1),l=uh.dot(this.direction),c=-uh.dot(n1),d=uh.lengthSq(),u=Math.abs(1-o*o);let m,p,f,y;if(u>0)if(m=o*c-l,p=o*l-c,y=s*u,m>=0)if(p>=-y)if(p<=y){const v=1/u;m*=v,p*=v,f=m*(m+o*p+2*l)+p*(o*m+p+2*c)+d}else p=s,m=Math.max(0,-(o*p+l)),f=-m*m+p*(p+2*c)+d;else p=-s,m=Math.max(0,-(o*p+l)),f=-m*m+p*(p+2*c)+d;else p<=-y?(m=Math.max(0,-(-o*s+l)),p=m>0?-s:Math.min(Math.max(-s,-c),s),f=-m*m+p*(p+2*c)+d):p<=y?(m=0,p=Math.min(Math.max(-s,-c),s),f=p*(p+2*c)+d):(m=Math.max(0,-(o*s+l)),p=m>0?s:Math.min(Math.max(-s,-c),s),f=-m*m+p*(p+2*c)+d);else p=o>0?-s:s,m=Math.max(0,-(o*p+l)),f=-m*m+p*(p+2*c)+d;return r&&r.copy(this.origin).addScaledVector(this.direction,m),i&&i.copy(UM).addScaledVector(n1,p),f}intersectSphere(e,n){ku.subVectors(e.center,this.origin);const r=ku.dot(this.direction),i=ku.dot(ku)-r*r,s=e.radius*e.radius;if(i>s)return null;const o=Math.sqrt(s-i),l=r-o,c=r+o;return c<0?null:l<0?this.at(c,n):this.at(l,n)}intersectsSphere(e){return e.radius<0?!1:this.distanceSqToPoint(e.center)<=e.radius*e.radius}distanceToPlane(e){const n=e.normal.dot(this.direction);if(n===0)return e.distanceToPoint(this.origin)===0?0:null;const r=-(this.origin.dot(e.normal)+e.constant)/n;return r>=0?r:null}intersectPlane(e,n){const r=this.distanceToPlane(e);return r===null?null:this.at(r,n)}intersectsPlane(e){const n=e.distanceToPoint(this.origin);return n===0||e.normal.dot(this.direction)*n<0}intersectBox(e,n){let r,i,s,o,l,c;const d=1/this.direction.x,u=1/this.direction.y,m=1/this.direction.z,p=this.origin;return d>=0?(r=(e.min.x-p.x)*d,i=(e.max.x-p.x)*d):(r=(e.max.x-p.x)*d,i=(e.min.x-p.x)*d),u>=0?(s=(e.min.y-p.y)*u,o=(e.max.y-p.y)*u):(s=(e.max.y-p.y)*u,o=(e.min.y-p.y)*u),r>o||s>i||((s>r||isNaN(r))&&(r=s),(o<i||isNaN(i))&&(i=o),m>=0?(l=(e.min.z-p.z)*m,c=(e.max.z-p.z)*m):(l=(e.max.z-p.z)*m,c=(e.min.z-p.z)*m),r>c||l>i)||((l>r||r!==r)&&(r=l),(c<i||i!==i)&&(i=c),i<0)?null:this.at(r>=0?r:i,n)}intersectsBox(e){return this.intersectBox(e,ku)!==null}intersectTriangle(e,n,r,i,s){BM.subVectors(n,e),r1.subVectors(r,e),HM.crossVectors(BM,r1);let o=this.direction.dot(HM),l;if(o>0){if(i)return null;l=1}else if(o<0)l=-1,o=-o;else return null;uh.subVectors(this.origin,e);const c=l*this.direction.dot(r1.crossVectors(uh,r1));if(c<0)return null;const d=l*this.direction.dot(BM.cross(uh));if(d<0||c+d>o)return null;const u=-l*uh.dot(HM);return u<0?null:this.at(u/o,s)}applyMatrix4(e){return this.origin.applyMatrix4(e),this.direction.transformDirection(e),this}equals(e){return e.origin.equals(this.origin)&&e.direction.equals(this.direction)}clone(){return new this.constructor().copy(this)}},_i=class aR{constructor(e,n,r,i,s,o,l,c,d,u,m,p,f,y,v,b){aR.prototype.isMatrix4=!0,this.elements=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],e!==void 0&&this.set(e,n,r,i,s,o,l,c,d,u,m,p,f,y,v,b)}set(e,n,r,i,s,o,l,c,d,u,m,p,f,y,v,b){const g=this.elements;return g[0]=e,g[4]=n,g[8]=r,g[12]=i,g[1]=s,g[5]=o,g[9]=l,g[13]=c,g[2]=d,g[6]=u,g[10]=m,g[14]=p,g[3]=f,g[7]=y,g[11]=v,g[15]=b,this}identity(){return this.set(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1),this}clone(){return new aR().fromArray(this.elements)}copy(e){const n=this.elements,r=e.elements;return n[0]=r[0],n[1]=r[1],n[2]=r[2],n[3]=r[3],n[4]=r[4],n[5]=r[5],n[6]=r[6],n[7]=r[7],n[8]=r[8],n[9]=r[9],n[10]=r[10],n[11]=r[11],n[12]=r[12],n[13]=r[13],n[14]=r[14],n[15]=r[15],this}copyPosition(e){const n=this.elements,r=e.elements;return n[12]=r[12],n[13]=r[13],n[14]=r[14],this}setFromMatrix3(e){const n=e.elements;return this.set(n[0],n[3],n[6],0,n[1],n[4],n[7],0,n[2],n[5],n[8],0,0,0,0,1),this}extractBasis(e,n,r){return e.setFromMatrixColumn(this,0),n.setFromMatrixColumn(this,1),r.setFromMatrixColumn(this,2),this}makeBasis(e,n,r){return this.set(e.x,n.x,r.x,0,e.y,n.y,r.y,0,e.z,n.z,r.z,0,0,0,0,1),this}extractRotation(e){const n=this.elements,r=e.elements,i=1/Tb.setFromMatrixColumn(e,0).length(),s=1/Tb.setFromMatrixColumn(e,1).length(),o=1/Tb.setFromMatrixColumn(e,2).length();return n[0]=r[0]*i,n[1]=r[1]*i,n[2]=r[2]*i,n[3]=0,n[4]=r[4]*s,n[5]=r[5]*s,n[6]=r[6]*s,n[7]=0,n[8]=r[8]*o,n[9]=r[9]*o,n[10]=r[10]*o,n[11]=0,n[12]=0,n[13]=0,n[14]=0,n[15]=1,this}makeRotationFromEuler(e){const n=this.elements,r=e.x,i=e.y,s=e.z,o=Math.cos(r),l=Math.sin(r),c=Math.cos(i),d=Math.sin(i),u=Math.cos(s),m=Math.sin(s);if(e.order===\"XYZ\"){const p=o*u,f=o*m,y=l*u,v=l*m;n[0]=c*u,n[4]=-c*m,n[8]=d,n[1]=f+y*d,n[5]=p-v*d,n[9]=-l*c,n[2]=v-p*d,n[6]=y+f*d,n[10]=o*c}else if(e.order===\"YXZ\"){const p=c*u,f=c*m,y=d*u,v=d*m;n[0]=p+v*l,n[4]=y*l-f,n[8]=o*d,n[1]=o*m,n[5]=o*u,n[9]=-l,n[2]=f*l-y,n[6]=v+p*l,n[10]=o*c}else if(e.order===\"ZXY\"){const p=c*u,f=c*m,y=d*u,v=d*m;n[0]=p-v*l,n[4]=-o*m,n[8]=y+f*l,n[1]=f+y*l,n[5]=o*u,n[9]=v-p*l,n[2]=-o*d,n[6]=l,n[10]=o*c}else if(e.order===\"ZYX\"){const p=o*u,f=o*m,y=l*u,v=l*m;n[0]=c*u,n[4]=y*d-f,n[8]=p*d+v,n[1]=c*m,n[5]=v*d+p,n[9]=f*d-y,n[2]=-d,n[6]=l*c,n[10]=o*c}else if(e.order===\"YZX\"){const p=o*c,f=o*d,y=l*c,v=l*d;n[0]=c*u,n[4]=v-p*m,n[8]=y*m+f,n[1]=m,n[5]=o*u,n[9]=-l*u,n[2]=-d*u,n[6]=f*m+y,n[10]=p-v*m}else if(e.order===\"XZY\"){const p=o*c,f=o*d,y=l*c,v=l*d;n[0]=c*u,n[4]=-m,n[8]=d*u,n[1]=p*m+v,n[5]=o*u,n[9]=f*m-y,n[2]=y*m-f,n[6]=l*u,n[10]=v*m+p}return n[3]=0,n[7]=0,n[11]=0,n[12]=0,n[13]=0,n[14]=0,n[15]=1,this}makeRotationFromQuaternion(e){return this.compose(Gve,e,Wve)}lookAt(e,n,r){const i=this.elements;return rl.subVectors(e,n),rl.lengthSq()===0&&(rl.z=1),rl.normalize(),mh.crossVectors(r,rl),mh.lengthSq()===0&&(Math.abs(r.z)===1?rl.x+=1e-4:rl.z+=1e-4,rl.normalize(),mh.crossVectors(r,rl)),mh.normalize(),a1.crossVectors(rl,mh),i[0]=mh.x,i[4]=a1.x,i[8]=rl.x,i[1]=mh.y,i[5]=a1.y,i[9]=rl.y,i[2]=mh.z,i[6]=a1.z,i[10]=rl.z,this}multiply(e){return this.multiplyMatrices(this,e)}premultiply(e){return this.multiplyMatrices(e,this)}multiplyMatrices(e,n){const r=e.elements,i=n.elements,s=this.elements,o=r[0],l=r[4],c=r[8],d=r[12],u=r[1],m=r[5],p=r[9],f=r[13],y=r[2],v=r[6],b=r[10],g=r[14],_=r[3],C=r[7],P=r[11],N=r[15],A=i[0],T=i[4],F=i[8],k=i[12],D=i[1],H=i[5],z=i[9],Q=i[13],L=i[2],te=i[6],ie=i[10],J=i[14],oe=i[3],fe=i[7],re=i[11],W=i[15];return s[0]=o*A+l*D+c*L+d*oe,s[4]=o*T+l*H+c*te+d*fe,s[8]=o*F+l*z+c*ie+d*re,s[12]=o*k+l*Q+c*J+d*W,s[1]=u*A+m*D+p*L+f*oe,s[5]=u*T+m*H+p*te+f*fe,s[9]=u*F+m*z+p*ie+f*re,s[13]=u*k+m*Q+p*J+f*W,s[2]=y*A+v*D+b*L+g*oe,s[6]=y*T+v*H+b*te+g*fe,s[10]=y*F+v*z+b*ie+g*re,s[14]=y*k+v*Q+b*J+g*W,s[3]=_*A+C*D+P*L+N*oe,s[7]=_*T+C*H+P*te+N*fe,s[11]=_*F+C*z+P*ie+N*re,s[15]=_*k+C*Q+P*J+N*W,this}multiplyScalar(e){const n=this.elements;return n[0]*=e,n[4]*=e,n[8]*=e,n[12]*=e,n[1]*=e,n[5]*=e,n[9]*=e,n[13]*=e,n[2]*=e,n[6]*=e,n[10]*=e,n[14]*=e,n[3]*=e,n[7]*=e,n[11]*=e,n[15]*=e,this}determinant(){const e=this.elements,n=e[0],r=e[4],i=e[8],s=e[12],o=e[1],l=e[5],c=e[9],d=e[13],u=e[2],m=e[6],p=e[10],f=e[14],y=e[3],v=e[7],b=e[11],g=e[15];return y*(+s*c*m-i*d*m-s*l*p+r*d*p+i*l*f-r*c*f)+v*(+n*c*f-n*d*p+s*o*p-i*o*f+i*d*u-s*c*u)+b*(+n*d*m-n*l*f-s*o*m+r*o*f+s*l*u-r*d*u)+g*(-i*l*u-n*c*m+n*l*p+i*o*m-r*o*p+r*c*u)}transpose(){const e=this.elements;let n;return n=e[1],e[1]=e[4],e[4]=n,n=e[2],e[2]=e[8],e[8]=n,n=e[6],e[6]=e[9],e[9]=n,n=e[3],e[3]=e[12],e[12]=n,n=e[7],e[7]=e[13],e[13]=n,n=e[11],e[11]=e[14],e[14]=n,this}setPosition(e,n,r){const i=this.elements;return e.isVector3?(i[12]=e.x,i[13]=e.y,i[14]=e.z):(i[12]=e,i[13]=n,i[14]=r),this}invert(){const e=this.elements,n=e[0],r=e[1],i=e[2],s=e[3],o=e[4],l=e[5],c=e[6],d=e[7],u=e[8],m=e[9],p=e[10],f=e[11],y=e[12],v=e[13],b=e[14],g=e[15],_=m*b*d-v*p*d+v*c*f-l*b*f-m*c*g+l*p*g,C=y*p*d-u*b*d-y*c*f+o*b*f+u*c*g-o*p*g,P=u*v*d-y*m*d+y*l*f-o*v*f-u*l*g+o*m*g,N=y*m*c-u*v*c-y*l*p+o*v*p+u*l*b-o*m*b,A=n*_+r*C+i*P+s*N;if(A===0)return this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);const T=1/A;return e[0]=_*T,e[1]=(v*p*s-m*b*s-v*i*f+r*b*f+m*i*g-r*p*g)*T,e[2]=(l*b*s-v*c*s+v*i*d-r*b*d-l*i*g+r*c*g)*T,e[3]=(m*c*s-l*p*s-m*i*d+r*p*d+l*i*f-r*c*f)*T,e[4]=C*T,e[5]=(u*b*s-y*p*s+y*i*f-n*b*f-u*i*g+n*p*g)*T,e[6]=(y*c*s-o*b*s-y*i*d+n*b*d+o*i*g-n*c*g)*T,e[7]=(o*p*s-u*c*s+u*i*d-n*p*d-o*i*f+n*c*f)*T,e[8]=P*T,e[9]=(y*m*s-u*v*s-y*r*f+n*v*f+u*r*g-n*m*g)*T,e[10]=(o*v*s-y*l*s+y*r*d-n*v*d-o*r*g+n*l*g)*T,e[11]=(u*l*s-o*m*s-u*r*d+n*m*d+o*r*f-n*l*f)*T,e[12]=N*T,e[13]=(u*v*i-y*m*i+y*r*p-n*v*p-u*r*b+n*m*b)*T,e[14]=(y*l*i-o*v*i-y*r*c+n*v*c+o*r*b-n*l*b)*T,e[15]=(o*m*i-u*l*i+u*r*c-n*m*c-o*r*p+n*l*p)*T,this}scale(e){const n=this.elements,r=e.x,i=e.y,s=e.z;return n[0]*=r,n[4]*=i,n[8]*=s,n[1]*=r,n[5]*=i,n[9]*=s,n[2]*=r,n[6]*=i,n[10]*=s,n[3]*=r,n[7]*=i,n[11]*=s,this}getMaxScaleOnAxis(){const e=this.elements,n=e[0]*e[0]+e[1]*e[1]+e[2]*e[2],r=e[4]*e[4]+e[5]*e[5]+e[6]*e[6],i=e[8]*e[8]+e[9]*e[9]+e[10]*e[10];return Math.sqrt(Math.max(n,r,i))}makeTranslation(e,n,r){return e.isVector3?this.set(1,0,0,e.x,0,1,0,e.y,0,0,1,e.z,0,0,0,1):this.set(1,0,0,e,0,1,0,n,0,0,1,r,0,0,0,1),this}makeRotationX(e){const n=Math.cos(e),r=Math.sin(e);return this.set(1,0,0,0,0,n,-r,0,0,r,n,0,0,0,0,1),this}makeRotationY(e){const n=Math.cos(e),r=Math.sin(e);return this.set(n,0,r,0,0,1,0,0,-r,0,n,0,0,0,0,1),this}makeRotationZ(e){const n=Math.cos(e),r=Math.sin(e);return this.set(n,-r,0,0,r,n,0,0,0,0,1,0,0,0,0,1),this}makeRotationAxis(e,n){const r=Math.cos(n),i=Math.sin(n),s=1-r,o=e.x,l=e.y,c=e.z,d=s*o,u=s*l;return this.set(d*o+r,d*l-i*c,d*c+i*l,0,d*l+i*c,u*l+r,u*c-i*o,0,d*c-i*l,u*c+i*o,s*c*c+r,0,0,0,0,1),this}makeScale(e,n,r){return this.set(e,0,0,0,0,n,0,0,0,0,r,0,0,0,0,1),this}makeShear(e,n,r,i,s,o){return this.set(1,r,s,0,e,1,o,0,n,i,1,0,0,0,0,1),this}compose(e,n,r){const i=this.elements,s=n._x,o=n._y,l=n._z,c=n._w,d=s+s,u=o+o,m=l+l,p=s*d,f=s*u,y=s*m,v=o*u,b=o*m,g=l*m,_=c*d,C=c*u,P=c*m,N=r.x,A=r.y,T=r.z;return i[0]=(1-(v+g))*N,i[1]=(f+P)*N,i[2]=(y-C)*N,i[3]=0,i[4]=(f-P)*A,i[5]=(1-(p+g))*A,i[6]=(b+_)*A,i[7]=0,i[8]=(y+C)*T,i[9]=(b-_)*T,i[10]=(1-(p+v))*T,i[11]=0,i[12]=e.x,i[13]=e.y,i[14]=e.z,i[15]=1,this}decompose(e,n,r){const i=this.elements;let s=Tb.set(i[0],i[1],i[2]).length();const o=Tb.set(i[4],i[5],i[6]).length(),l=Tb.set(i[8],i[9],i[10]).length();this.determinant()<0&&(s=-s),e.x=i[12],e.y=i[13],e.z=i[14],Ec.copy(this);const d=1/s,u=1/o,m=1/l;return Ec.elements[0]*=d,Ec.elements[1]*=d,Ec.elements[2]*=d,Ec.elements[4]*=u,Ec.elements[5]*=u,Ec.elements[6]*=u,Ec.elements[8]*=m,Ec.elements[9]*=m,Ec.elements[10]*=m,n.setFromRotationMatrix(Ec),r.x=s,r.y=o,r.z=l,this}makePerspective(e,n,r,i,s,o,l=Md,c=!1){const d=this.elements,u=2*s/(n-e),m=2*s/(r-i),p=(n+e)/(n-e),f=(r+i)/(r-i);let y,v;if(c)y=s/(o-s),v=o*s/(o-s);else if(l===Md)y=-(o+s)/(o-s),v=-2*o*s/(o-s);else if(l===AN)y=-o/(o-s),v=-o*s/(o-s);else throw new Error(\"THREE.Matrix4.makePerspective(): Invalid coordinate system: \"+l);return d[0]=u,d[4]=0,d[8]=p,d[12]=0,d[1]=0,d[5]=m,d[9]=f,d[13]=0,d[2]=0,d[6]=0,d[10]=y,d[14]=v,d[3]=0,d[7]=0,d[11]=-1,d[15]=0,this}makeOrthographic(e,n,r,i,s,o,l=Md,c=!1){const d=this.elements,u=2/(n-e),m=2/(r-i),p=-(n+e)/(n-e),f=-(r+i)/(r-i);let y,v;if(c)y=1/(o-s),v=o/(o-s);else if(l===Md)y=-2/(o-s),v=-(o+s)/(o-s);else if(l===AN)y=-1/(o-s),v=-s/(o-s);else throw new Error(\"THREE.Matrix4.makeOrthographic(): Invalid coordinate system: \"+l);return d[0]=u,d[4]=0,d[8]=0,d[12]=p,d[1]=0,d[5]=m,d[9]=0,d[13]=f,d[2]=0,d[6]=0,d[10]=y,d[14]=v,d[3]=0,d[7]=0,d[11]=0,d[15]=1,this}equals(e){const n=this.elements,r=e.elements;for(let i=0;i<16;i++)if(n[i]!==r[i])return!1;return!0}fromArray(e,n=0){for(let r=0;r<16;r++)this.elements[r]=e[r+n];return this}toArray(e=[],n=0){const r=this.elements;return e[n]=r[0],e[n+1]=r[1],e[n+2]=r[2],e[n+3]=r[3],e[n+4]=r[4],e[n+5]=r[5],e[n+6]=r[6],e[n+7]=r[7],e[n+8]=r[8],e[n+9]=r[9],e[n+10]=r[10],e[n+11]=r[11],e[n+12]=r[12],e[n+13]=r[13],e[n+14]=r[14],e[n+15]=r[15],e}};const Tb=new Ct,Ec=new _i,Gve=new Ct(0,0,0),Wve=new Ct(1,1,1),mh=new Ct,a1=new Ct,rl=new Ct,ZH=new _i,JH=new gg;let xp=class NZ{constructor(e=0,n=0,r=0,i=NZ.DEFAULT_ORDER){this.isEuler=!0,this._x=e,this._y=n,this._z=r,this._order=i}get x(){return this._x}set x(e){this._x=e,this._onChangeCallback()}get y(){return this._y}set y(e){this._y=e,this._onChangeCallback()}get z(){return this._z}set z(e){this._z=e,this._onChangeCallback()}get order(){return this._order}set order(e){this._order=e,this._onChangeCallback()}set(e,n,r,i=this._order){return this._x=e,this._y=n,this._z=r,this._order=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._order)}copy(e){return this._x=e._x,this._y=e._y,this._z=e._z,this._order=e._order,this._onChangeCallback(),this}setFromRotationMatrix(e,n=this._order,r=!0){const i=e.elements,s=i[0],o=i[4],l=i[8],c=i[1],d=i[5],u=i[9],m=i[2],p=i[6],f=i[10];switch(n){case\"XYZ\":this._y=Math.asin(fr(l,-1,1)),Math.abs(l)<.9999999?(this._x=Math.atan2(-u,f),this._z=Math.atan2(-o,s)):(this._x=Math.atan2(p,d),this._z=0);break;case\"YXZ\":this._x=Math.asin(-fr(u,-1,1)),Math.abs(u)<.9999999?(this._y=Math.atan2(l,f),this._z=Math.atan2(c,d)):(this._y=Math.atan2(-m,s),this._z=0);break;case\"ZXY\":this._x=Math.asin(fr(p,-1,1)),Math.abs(p)<.9999999?(this._y=Math.atan2(-m,f),this._z=Math.atan2(-o,d)):(this._y=0,this._z=Math.atan2(c,s));break;case\"ZYX\":this._y=Math.asin(-fr(m,-1,1)),Math.abs(m)<.9999999?(this._x=Math.atan2(p,f),this._z=Math.atan2(c,s)):(this._x=0,this._z=Math.atan2(-o,d));break;case\"YZX\":this._z=Math.asin(fr(c,-1,1)),Math.abs(c)<.9999999?(this._x=Math.atan2(-u,d),this._y=Math.atan2(-m,s)):(this._x=0,this._y=Math.atan2(l,f));break;case\"XZY\":this._z=Math.asin(-fr(o,-1,1)),Math.abs(o)<.9999999?(this._x=Math.atan2(p,d),this._y=Math.atan2(l,s)):(this._x=Math.atan2(-u,f),this._y=0);break;default:qn(\"Euler: .setFromRotationMatrix() encountered an unknown order: \"+n)}return this._order=n,r===!0&&this._onChangeCallback(),this}setFromQuaternion(e,n,r){return ZH.makeRotationFromQuaternion(e),this.setFromRotationMatrix(ZH,n,r)}setFromVector3(e,n=this._order){return this.set(e.x,e.y,e.z,n)}reorder(e){return JH.setFromEuler(this),this.setFromQuaternion(JH,e)}equals(e){return e._x===this._x&&e._y===this._y&&e._z===this._z&&e._order===this._order}fromArray(e){return this._x=e[0],this._y=e[1],this._z=e[2],e[3]!==void 0&&(this._order=e[3]),this._onChangeCallback(),this}toArray(e=[],n=0){return e[n]=this._x,e[n+1]=this._y,e[n+2]=this._z,e[n+3]=this._order,e}_onChange(e){return this._onChangeCallback=e,this}_onChangeCallback(){}*[Symbol.iterator](){yield this._x,yield this._y,yield this._z,yield this._order}};xp.DEFAULT_ORDER=\"XYZ\";let CZ=class{constructor(){this.mask=1}set(e){this.mask=(1<<e|0)>>>0}enable(e){this.mask|=1<<e|0}enableAll(){this.mask=-1}toggle(e){this.mask^=1<<e|0}disable(e){this.mask&=~(1<<e|0)}disableAll(){this.mask=0}test(e){return(this.mask&e.mask)!==0}isEnabled(e){return(this.mask&(1<<e|0))!==0}},Kve=0;const e6=new Ct,Ab=new gg,Nu=new _i,i1=new Ct,Rv=new Ct,Xve=new Ct,Yve=new gg,t6=new Ct(1,0,0),n6=new Ct(0,1,0),r6=new Ct(0,0,1),a6={type:\"added\"},Qve={type:\"removed\"},jb={type:\"childadded\",child:null},qM={type:\"childremoved\",child:null};let vl=class Gk extends Ug{constructor(){super(),this.isObject3D=!0,Object.defineProperty(this,\"id\",{value:Kve++}),this.uuid=lS(),this.name=\"\",this.type=\"Object3D\",this.parent=null,this.children=[],this.up=Gk.DEFAULT_UP.clone();const e=new Ct,n=new xp,r=new gg,i=new Ct(1,1,1);function s(){r.setFromEuler(n,!1)}function o(){n.setFromQuaternion(r,void 0,!1)}n._onChange(s),r._onChange(o),Object.defineProperties(this,{position:{configurable:!0,enumerable:!0,value:e},rotation:{configurable:!0,enumerable:!0,value:n},quaternion:{configurable:!0,enumerable:!0,value:r},scale:{configurable:!0,enumerable:!0,value:i},modelViewMatrix:{value:new _i},normalMatrix:{value:new ir}}),this.matrix=new _i,this.matrixWorld=new _i,this.matrixAutoUpdate=Gk.DEFAULT_MATRIX_AUTO_UPDATE,this.matrixWorldAutoUpdate=Gk.DEFAULT_MATRIX_WORLD_AUTO_UPDATE,this.matrixWorldNeedsUpdate=!1,this.layers=new CZ,this.visible=!0,this.castShadow=!1,this.receiveShadow=!1,this.frustumCulled=!0,this.renderOrder=0,this.animations=[],this.customDepthMaterial=void 0,this.customDistanceMaterial=void 0,this.userData={}}onBeforeShadow(){}onAfterShadow(){}onBeforeRender(){}onAfterRender(){}applyMatrix4(e){this.matrixAutoUpdate&&this.updateMatrix(),this.matrix.premultiply(e),this.matrix.decompose(this.position,this.quaternion,this.scale)}applyQuaternion(e){return this.quaternion.premultiply(e),this}setRotationFromAxisAngle(e,n){this.quaternion.setFromAxisAngle(e,n)}setRotationFromEuler(e){this.quaternion.setFromEuler(e,!0)}setRotationFromMatrix(e){this.quaternion.setFromRotationMatrix(e)}setRotationFromQuaternion(e){this.quaternion.copy(e)}rotateOnAxis(e,n){return Ab.setFromAxisAngle(e,n),this.quaternion.multiply(Ab),this}rotateOnWorldAxis(e,n){return Ab.setFromAxisAngle(e,n),this.quaternion.premultiply(Ab),this}rotateX(e){return this.rotateOnAxis(t6,e)}rotateY(e){return this.rotateOnAxis(n6,e)}rotateZ(e){return this.rotateOnAxis(r6,e)}translateOnAxis(e,n){return e6.copy(e).applyQuaternion(this.quaternion),this.position.add(e6.multiplyScalar(n)),this}translateX(e){return this.translateOnAxis(t6,e)}translateY(e){return this.translateOnAxis(n6,e)}translateZ(e){return this.translateOnAxis(r6,e)}localToWorld(e){return this.updateWorldMatrix(!0,!1),e.applyMatrix4(this.matrixWorld)}worldToLocal(e){return this.updateWorldMatrix(!0,!1),e.applyMatrix4(Nu.copy(this.matrixWorld).invert())}lookAt(e,n,r){e.isVector3?i1.copy(e):i1.set(e,n,r);const i=this.parent;this.updateWorldMatrix(!0,!1),Rv.setFromMatrixPosition(this.matrixWorld),this.isCamera||this.isLight?Nu.lookAt(Rv,i1,this.up):Nu.lookAt(i1,Rv,this.up),this.quaternion.setFromRotationMatrix(Nu),i&&(Nu.extractRotation(i.matrixWorld),Ab.setFromRotationMatrix(Nu),this.quaternion.premultiply(Ab.invert()))}add(e){if(arguments.length>1){for(let n=0;n<arguments.length;n++)this.add(arguments[n]);return this}return e===this?(oi(\"Object3D.add: object can't be added as a child of itself.\",e),this):(e&&e.isObject3D?(e.removeFromParent(),e.parent=this,this.children.push(e),e.dispatchEvent(a6),jb.child=e,this.dispatchEvent(jb),jb.child=null):oi(\"Object3D.add: object not an instance of THREE.Object3D.\",e),this)}remove(e){if(arguments.length>1){for(let r=0;r<arguments.length;r++)this.remove(arguments[r]);return this}const n=this.children.indexOf(e);return n!==-1&&(e.parent=null,this.children.splice(n,1),e.dispatchEvent(Qve),qM.child=e,this.dispatchEvent(qM),qM.child=null),this}removeFromParent(){const e=this.parent;return e!==null&&e.remove(this),this}clear(){return this.remove(...this.children)}attach(e){return this.updateWorldMatrix(!0,!1),Nu.copy(this.matrixWorld).invert(),e.parent!==null&&(e.parent.updateWorldMatrix(!0,!1),Nu.multiply(e.parent.matrixWorld)),e.applyMatrix4(Nu),e.removeFromParent(),e.parent=this,this.children.push(e),e.updateWorldMatrix(!1,!0),e.dispatchEvent(a6),jb.child=e,this.dispatchEvent(jb),jb.child=null,this}getObjectById(e){return this.getObjectByProperty(\"id\",e)}getObjectByName(e){return this.getObjectByProperty(\"name\",e)}getObjectByProperty(e,n){if(this[e]===n)return this;for(let r=0,i=this.children.length;r<i;r++){const o=this.children[r].getObjectByProperty(e,n);if(o!==void 0)return o}}getObjectsByProperty(e,n,r=[]){this[e]===n&&r.push(this);const i=this.children;for(let s=0,o=i.length;s<o;s++)i[s].getObjectsByProperty(e,n,r);return r}getWorldPosition(e){return this.updateWorldMatrix(!0,!1),e.setFromMatrixPosition(this.matrixWorld)}getWorldQuaternion(e){return this.updateWorldMatrix(!0,!1),this.matrixWorld.decompose(Rv,e,Xve),e}getWorldScale(e){return this.updateWorldMatrix(!0,!1),this.matrixWorld.decompose(Rv,Yve,e),e}getWorldDirection(e){this.updateWorldMatrix(!0,!1);const n=this.matrixWorld.elements;return e.set(n[8],n[9],n[10]).normalize()}raycast(){}traverse(e){e(this);const n=this.children;for(let r=0,i=n.length;r<i;r++)n[r].traverse(e)}traverseVisible(e){if(this.visible===!1)return;e(this);const n=this.children;for(let r=0,i=n.length;r<i;r++)n[r].traverseVisible(e)}traverseAncestors(e){const n=this.parent;n!==null&&(e(n),n.traverseAncestors(e))}updateMatrix(){this.matrix.compose(this.position,this.quaternion,this.scale),this.matrixWorldNeedsUpdate=!0}updateMatrixWorld(e){this.matrixAutoUpdate&&this.updateMatrix(),(this.matrixWorldNeedsUpdate||e)&&(this.matrixWorldAutoUpdate===!0&&(this.parent===null?this.matrixWorld.copy(this.matrix):this.matrixWorld.multiplyMatrices(this.parent.matrixWorld,this.matrix)),this.matrixWorldNeedsUpdate=!1,e=!0);const n=this.children;for(let r=0,i=n.length;r<i;r++)n[r].updateMatrixWorld(e)}updateWorldMatrix(e,n){const r=this.parent;if(e===!0&&r!==null&&r.updateWorldMatrix(!0,!1),this.matrixAutoUpdate&&this.updateMatrix(),this.matrixWorldAutoUpdate===!0&&(this.parent===null?this.matrixWorld.copy(this.matrix):this.matrixWorld.multiplyMatrices(this.parent.matrixWorld,this.matrix)),n===!0){const i=this.children;for(let s=0,o=i.length;s<o;s++)i[s].updateWorldMatrix(!1,!0)}}toJSON(e){const n=e===void 0||typeof e==\"string\",r={};n&&(e={geometries:{},materials:{},textures:{},images:{},shapes:{},skeletons:{},animations:{},nodes:{}},r.metadata={version:4.7,type:\"Object\",generator:\"Object3D.toJSON\"});const i={};i.uuid=this.uuid,i.type=this.type,this.name!==\"\"&&(i.name=this.name),this.castShadow===!0&&(i.castShadow=!0),this.receiveShadow===!0&&(i.receiveShadow=!0),this.visible===!1&&(i.visible=!1),this.frustumCulled===!1&&(i.frustumCulled=!1),this.renderOrder!==0&&(i.renderOrder=this.renderOrder),Object.keys(this.userData).length>0&&(i.userData=this.userData),i.layers=this.layers.mask,i.matrix=this.matrix.toArray(),i.up=this.up.toArray(),this.matrixAutoUpdate===!1&&(i.matrixAutoUpdate=!1),this.isInstancedMesh&&(i.type=\"InstancedMesh\",i.count=this.count,i.instanceMatrix=this.instanceMatrix.toJSON(),this.instanceColor!==null&&(i.instanceColor=this.instanceColor.toJSON())),this.isBatchedMesh&&(i.type=\"BatchedMesh\",i.perObjectFrustumCulled=this.perObjectFrustumCulled,i.sortObjects=this.sortObjects,i.drawRanges=this._drawRanges,i.reservedRanges=this._reservedRanges,i.geometryInfo=this._geometryInfo.map(l=>({...l,boundingBox:l.boundingBox?l.boundingBox.toJSON():void 0,boundingSphere:l.boundingSphere?l.boundingSphere.toJSON():void 0})),i.instanceInfo=this._instanceInfo.map(l=>({...l})),i.availableInstanceIds=this._availableInstanceIds.slice(),i.availableGeometryIds=this._availableGeometryIds.slice(),i.nextIndexStart=this._nextIndexStart,i.nextVertexStart=this._nextVertexStart,i.geometryCount=this._geometryCount,i.maxInstanceCount=this._maxInstanceCount,i.maxVertexCount=this._maxVertexCount,i.maxIndexCount=this._maxIndexCount,i.geometryInitialized=this._geometryInitialized,i.matricesTexture=this._matricesTexture.toJSON(e),i.indirectTexture=this._indirectTexture.toJSON(e),this._colorsTexture!==null&&(i.colorsTexture=this._colorsTexture.toJSON(e)),this.boundingSphere!==null&&(i.boundingSphere=this.boundingSphere.toJSON()),this.boundingBox!==null&&(i.boundingBox=this.boundingBox.toJSON()));function s(l,c){return l[c.uuid]===void 0&&(l[c.uuid]=c.toJSON(e)),c.uuid}if(this.isScene)this.background&&(this.background.isColor?i.background=this.background.toJSON():this.background.isTexture&&(i.background=this.background.toJSON(e).uuid)),this.environment&&this.environment.isTexture&&this.environment.isRenderTargetTexture!==!0&&(i.environment=this.environment.toJSON(e).uuid);else if(this.isMesh||this.isLine||this.isPoints){i.geometry=s(e.geometries,this.geometry);const l=this.geometry.parameters;if(l!==void 0&&l.shapes!==void 0){const c=l.shapes;if(Array.isArray(c))for(let d=0,u=c.length;d<u;d++){const m=c[d];s(e.shapes,m)}else s(e.shapes,c)}}if(this.isSkinnedMesh&&(i.bindMode=this.bindMode,i.bindMatrix=this.bindMatrix.toArray(),this.skeleton!==void 0&&(s(e.skeletons,this.skeleton),i.skeleton=this.skeleton.uuid)),this.material!==void 0)if(Array.isArray(this.material)){const l=[];for(let c=0,d=this.material.length;c<d;c++)l.push(s(e.materials,this.material[c]));i.material=l}else i.material=s(e.materials,this.material);if(this.children.length>0){i.children=[];for(let l=0;l<this.children.length;l++)i.children.push(this.children[l].toJSON(e).object)}if(this.animations.length>0){i.animations=[];for(let l=0;l<this.animations.length;l++){const c=this.animations[l];i.animations.push(s(e.animations,c))}}if(n){const l=o(e.geometries),c=o(e.materials),d=o(e.textures),u=o(e.images),m=o(e.shapes),p=o(e.skeletons),f=o(e.animations),y=o(e.nodes);l.length>0&&(r.geometries=l),c.length>0&&(r.materials=c),d.length>0&&(r.textures=d),u.length>0&&(r.images=u),m.length>0&&(r.shapes=m),p.length>0&&(r.skeletons=p),f.length>0&&(r.animations=f),y.length>0&&(r.nodes=y)}return r.object=i,r;function o(l){const c=[];for(const d in l){const u=l[d];delete u.metadata,c.push(u)}return c}}clone(e){return new this.constructor().copy(this,e)}copy(e,n=!0){if(this.name=e.name,this.up.copy(e.up),this.position.copy(e.position),this.rotation.order=e.rotation.order,this.quaternion.copy(e.quaternion),this.scale.copy(e.scale),this.matrix.copy(e.matrix),this.matrixWorld.copy(e.matrixWorld),this.matrixAutoUpdate=e.matrixAutoUpdate,this.matrixWorldAutoUpdate=e.matrixWorldAutoUpdate,this.matrixWorldNeedsUpdate=e.matrixWorldNeedsUpdate,this.layers.mask=e.layers.mask,this.visible=e.visible,this.castShadow=e.castShadow,this.receiveShadow=e.receiveShadow,this.frustumCulled=e.frustumCulled,this.renderOrder=e.renderOrder,this.animations=e.animations.slice(),this.userData=JSON.parse(JSON.stringify(e.userData)),n===!0)for(let r=0;r<e.children.length;r++){const i=e.children[r];this.add(i.clone())}return this}};vl.DEFAULT_UP=new Ct(0,1,0);vl.DEFAULT_MATRIX_AUTO_UPDATE=!0;vl.DEFAULT_MATRIX_WORLD_AUTO_UPDATE=!0;const Dc=new Ct,Cu=new Ct,$M=new Ct,Pu=new Ct,Mb=new Ct,Eb=new Ct,i6=new Ct,VM=new Ct,GM=new Ct,WM=new Ct,KM=new yi,XM=new yi,YM=new yi;let Lv=class mx{constructor(e=new Ct,n=new Ct,r=new Ct){this.a=e,this.b=n,this.c=r}static getNormal(e,n,r,i){i.subVectors(r,n),Dc.subVectors(e,n),i.cross(Dc);const s=i.lengthSq();return s>0?i.multiplyScalar(1/Math.sqrt(s)):i.set(0,0,0)}static getBarycoord(e,n,r,i,s){Dc.subVectors(i,n),Cu.subVectors(r,n),$M.subVectors(e,n);const o=Dc.dot(Dc),l=Dc.dot(Cu),c=Dc.dot($M),d=Cu.dot(Cu),u=Cu.dot($M),m=o*d-l*l;if(m===0)return s.set(0,0,0),null;const p=1/m,f=(d*c-l*u)*p,y=(o*u-l*c)*p;return s.set(1-f-y,y,f)}static containsPoint(e,n,r,i){return this.getBarycoord(e,n,r,i,Pu)===null?!1:Pu.x>=0&&Pu.y>=0&&Pu.x+Pu.y<=1}static getInterpolation(e,n,r,i,s,o,l,c){return this.getBarycoord(e,n,r,i,Pu)===null?(c.x=0,c.y=0,\"z\"in c&&(c.z=0),\"w\"in c&&(c.w=0),null):(c.setScalar(0),c.addScaledVector(s,Pu.x),c.addScaledVector(o,Pu.y),c.addScaledVector(l,Pu.z),c)}static getInterpolatedAttribute(e,n,r,i,s,o){return KM.setScalar(0),XM.setScalar(0),YM.setScalar(0),KM.fromBufferAttribute(e,n),XM.fromBufferAttribute(e,r),YM.fromBufferAttribute(e,i),o.setScalar(0),o.addScaledVector(KM,s.x),o.addScaledVector(XM,s.y),o.addScaledVector(YM,s.z),o}static isFrontFacing(e,n,r,i){return Dc.subVectors(r,n),Cu.subVectors(e,n),Dc.cross(Cu).dot(i)<0}set(e,n,r){return this.a.copy(e),this.b.copy(n),this.c.copy(r),this}setFromPointsAndIndices(e,n,r,i){return this.a.copy(e[n]),this.b.copy(e[r]),this.c.copy(e[i]),this}setFromAttributeAndIndices(e,n,r,i){return this.a.fromBufferAttribute(e,n),this.b.fromBufferAttribute(e,r),this.c.fromBufferAttribute(e,i),this}clone(){return new this.constructor().copy(this)}copy(e){return this.a.copy(e.a),this.b.copy(e.b),this.c.copy(e.c),this}getArea(){return Dc.subVectors(this.c,this.b),Cu.subVectors(this.a,this.b),Dc.cross(Cu).length()*.5}getMidpoint(e){return e.addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)}getNormal(e){return mx.getNormal(this.a,this.b,this.c,e)}getPlane(e){return e.setFromCoplanarPoints(this.a,this.b,this.c)}getBarycoord(e,n){return mx.getBarycoord(e,this.a,this.b,this.c,n)}getInterpolation(e,n,r,i,s){return mx.getInterpolation(e,this.a,this.b,this.c,n,r,i,s)}containsPoint(e){return mx.containsPoint(e,this.a,this.b,this.c)}isFrontFacing(e){return mx.isFrontFacing(this.a,this.b,this.c,e)}intersectsBox(e){return e.intersectsTriangle(this)}closestPointToPoint(e,n){const r=this.a,i=this.b,s=this.c;let o,l;Mb.subVectors(i,r),Eb.subVectors(s,r),VM.subVectors(e,r);const c=Mb.dot(VM),d=Eb.dot(VM);if(c<=0&&d<=0)return n.copy(r);GM.subVectors(e,i);const u=Mb.dot(GM),m=Eb.dot(GM);if(u>=0&&m<=u)return n.copy(i);const p=c*m-u*d;if(p<=0&&c>=0&&u<=0)return o=c/(c-u),n.copy(r).addScaledVector(Mb,o);WM.subVectors(e,s);const f=Mb.dot(WM),y=Eb.dot(WM);if(y>=0&&f<=y)return n.copy(s);const v=f*d-c*y;if(v<=0&&d>=0&&y<=0)return l=d/(d-y),n.copy(r).addScaledVector(Eb,l);const b=u*y-f*m;if(b<=0&&m-u>=0&&f-y>=0)return i6.subVectors(s,i),l=(m-u)/(m-u+(f-y)),n.copy(i).addScaledVector(i6,l);const g=1/(b+v+p);return o=v*g,l=p*g,n.copy(r).addScaledVector(Mb,o).addScaledVector(Eb,l)}equals(e){return e.a.equals(this.a)&&e.b.equals(this.b)&&e.c.equals(this.c)}};const PZ={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074},hh={h:0,s:0,l:0},s1={h:0,s:0,l:0};function QM(t,e,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?t+(e-t)*6*n:n<1/2?e:n<2/3?t+(e-t)*6*(2/3-n):t}let or=class{constructor(e,n,r){return this.isColor=!0,this.r=1,this.g=1,this.b=1,this.set(e,n,r)}set(e,n,r){if(n===void 0&&r===void 0){const i=e;i&&i.isColor?this.copy(i):typeof i==\"number\"?this.setHex(i):typeof i==\"string\"&&this.setStyle(i)}else this.setRGB(e,n,r);return this}setScalar(e){return this.r=e,this.g=e,this.b=e,this}setHex(e,n=ol){return e=Math.floor(e),this.r=(e>>16&255)/255,this.g=(e>>8&255)/255,this.b=(e&255)/255,Mr.colorSpaceToWorking(this,n),this}setRGB(e,n,r,i=Mr.workingColorSpace){return this.r=e,this.g=n,this.b=r,Mr.colorSpaceToWorking(this,i),this}setHSL(e,n,r,i=Mr.workingColorSpace){if(e=Ove(e,1),n=fr(n,0,1),r=fr(r,0,1),n===0)this.r=this.g=this.b=r;else{const s=r<=.5?r*(1+n):r+n-r*n,o=2*r-s;this.r=QM(o,s,e+1/3),this.g=QM(o,s,e),this.b=QM(o,s,e-1/3)}return Mr.colorSpaceToWorking(this,i),this}setStyle(e,n=ol){function r(s){s!==void 0&&parseFloat(s)<1&&qn(\"Color: Alpha component of \"+e+\" will be ignored.\")}let i;if(i=/^(\\w+)\\(([^\\)]*)\\)/.exec(e)){let s;const o=i[1],l=i[2];switch(o){case\"rgb\":case\"rgba\":if(s=/^\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*(\\d*\\.?\\d+)\\s*)?$/.exec(l))return r(s[4]),this.setRGB(Math.min(255,parseInt(s[1],10))/255,Math.min(255,parseInt(s[2],10))/255,Math.min(255,parseInt(s[3],10))/255,n);if(s=/^\\s*(\\d+)\\%\\s*,\\s*(\\d+)\\%\\s*,\\s*(\\d+)\\%\\s*(?:,\\s*(\\d*\\.?\\d+)\\s*)?$/.exec(l))return r(s[4]),this.setRGB(Math.min(100,parseInt(s[1],10))/100,Math.min(100,parseInt(s[2],10))/100,Math.min(100,parseInt(s[3],10))/100,n);break;case\"hsl\":case\"hsla\":if(s=/^\\s*(\\d*\\.?\\d+)\\s*,\\s*(\\d*\\.?\\d+)\\%\\s*,\\s*(\\d*\\.?\\d+)\\%\\s*(?:,\\s*(\\d*\\.?\\d+)\\s*)?$/.exec(l))return r(s[4]),this.setHSL(parseFloat(s[1])/360,parseFloat(s[2])/100,parseFloat(s[3])/100,n);break;default:qn(\"Color: Unknown color model \"+e)}}else if(i=/^\\#([A-Fa-f\\d]+)$/.exec(e)){const s=i[1],o=s.length;if(o===3)return this.setRGB(parseInt(s.charAt(0),16)/15,parseInt(s.charAt(1),16)/15,parseInt(s.charAt(2),16)/15,n);if(o===6)return this.setHex(parseInt(s,16),n);qn(\"Color: Invalid hex color \"+e)}else if(e&&e.length>0)return this.setColorName(e,n);return this}setColorName(e,n=ol){const r=PZ[e.toLowerCase()];return r!==void 0?this.setHex(r,n):qn(\"Color: Unknown color \"+e),this}clone(){return new this.constructor(this.r,this.g,this.b)}copy(e){return this.r=e.r,this.g=e.g,this.b=e.b,this}copySRGBToLinear(e){return this.r=cm(e.r),this.g=cm(e.g),this.b=cm(e.b),this}copyLinearToSRGB(e){return this.r=jx(e.r),this.g=jx(e.g),this.b=jx(e.b),this}convertSRGBToLinear(){return this.copySRGBToLinear(this),this}convertLinearToSRGB(){return this.copyLinearToSRGB(this),this}getHex(e=ol){return Mr.workingToColorSpace(Es.copy(this),e),Math.round(fr(Es.r*255,0,255))*65536+Math.round(fr(Es.g*255,0,255))*256+Math.round(fr(Es.b*255,0,255))}getHexString(e=ol){return(\"000000\"+this.getHex(e).toString(16)).slice(-6)}getHSL(e,n=Mr.workingColorSpace){Mr.workingToColorSpace(Es.copy(this),n);const r=Es.r,i=Es.g,s=Es.b,o=Math.max(r,i,s),l=Math.min(r,i,s);let c,d;const u=(l+o)/2;if(l===o)c=0,d=0;else{const m=o-l;switch(d=u<=.5?m/(o+l):m/(2-o-l),o){case r:c=(i-s)/m+(i<s?6:0);break;case i:c=(s-r)/m+2;break;case s:c=(r-i)/m+4;break}c/=6}return e.h=c,e.s=d,e.l=u,e}getRGB(e,n=Mr.workingColorSpace){return Mr.workingToColorSpace(Es.copy(this),n),e.r=Es.r,e.g=Es.g,e.b=Es.b,e}getStyle(e=ol){Mr.workingToColorSpace(Es.copy(this),e);const n=Es.r,r=Es.g,i=Es.b;return e!==ol?`color(${e} ${n.toFixed(3)} ${r.toFixed(3)} ${i.toFixed(3)})`:`rgb(${Math.round(n*255)},${Math.round(r*255)},${Math.round(i*255)})`}offsetHSL(e,n,r){return this.getHSL(hh),this.setHSL(hh.h+e,hh.s+n,hh.l+r)}add(e){return this.r+=e.r,this.g+=e.g,this.b+=e.b,this}addColors(e,n){return this.r=e.r+n.r,this.g=e.g+n.g,this.b=e.b+n.b,this}addScalar(e){return this.r+=e,this.g+=e,this.b+=e,this}sub(e){return this.r=Math.max(0,this.r-e.r),this.g=Math.max(0,this.g-e.g),this.b=Math.max(0,this.b-e.b),this}multiply(e){return this.r*=e.r,this.g*=e.g,this.b*=e.b,this}multiplyScalar(e){return this.r*=e,this.g*=e,this.b*=e,this}lerp(e,n){return this.r+=(e.r-this.r)*n,this.g+=(e.g-this.g)*n,this.b+=(e.b-this.b)*n,this}lerpColors(e,n,r){return this.r=e.r+(n.r-e.r)*r,this.g=e.g+(n.g-e.g)*r,this.b=e.b+(n.b-e.b)*r,this}lerpHSL(e,n){this.getHSL(hh),e.getHSL(s1);const r=DM(hh.h,s1.h,n),i=DM(hh.s,s1.s,n),s=DM(hh.l,s1.l,n);return this.setHSL(r,i,s),this}setFromVector3(e){return this.r=e.x,this.g=e.y,this.b=e.z,this}applyMatrix3(e){const n=this.r,r=this.g,i=this.b,s=e.elements;return this.r=s[0]*n+s[3]*r+s[6]*i,this.g=s[1]*n+s[4]*r+s[7]*i,this.b=s[2]*n+s[5]*r+s[8]*i,this}equals(e){return e.r===this.r&&e.g===this.g&&e.b===this.b}fromArray(e,n=0){return this.r=e[n],this.g=e[n+1],this.b=e[n+2],this}toArray(e=[],n=0){return e[n]=this.r,e[n+1]=this.g,e[n+2]=this.b,e}fromBufferAttribute(e,n){return this.r=e.getX(n),this.g=e.getY(n),this.b=e.getZ(n),this}toJSON(){return this.getHex()}*[Symbol.iterator](){yield this.r,yield this.g,yield this.b}};const Es=new or;or.NAMES=PZ;let Zve=0,ky=class extends Ug{constructor(){super(),this.isMaterial=!0,Object.defineProperty(this,\"id\",{value:Zve++}),this.uuid=lS(),this.name=\"\",this.type=\"Material\",this.blending=Ax,this.side=bp,this.vertexColors=!1,this.opacity=1,this.transparent=!1,this.alphaHash=!1,this.blendSrc=gF,this.blendDst=bF,this.blendEquation=Tf,this.blendSrcAlpha=null,this.blendDstAlpha=null,this.blendEquationAlpha=null,this.blendColor=new or(0,0,0),this.blendAlpha=0,this.depthFunc=Vx,this.depthTest=!0,this.depthWrite=!0,this.stencilWriteMask=255,this.stencilFunc=$H,this.stencilRef=0,this.stencilFuncMask=255,this.stencilFail=_b,this.stencilZFail=_b,this.stencilZPass=_b,this.stencilWrite=!1,this.clippingPlanes=null,this.clipIntersection=!1,this.clipShadows=!1,this.shadowSide=null,this.colorWrite=!0,this.precision=null,this.polygonOffset=!1,this.polygonOffsetFactor=0,this.polygonOffsetUnits=0,this.dithering=!1,this.alphaToCoverage=!1,this.premultipliedAlpha=!1,this.forceSinglePass=!1,this.allowOverride=!0,this.visible=!0,this.toneMapped=!0,this.userData={},this.version=0,this._alphaTest=0}get alphaTest(){return this._alphaTest}set alphaTest(e){this._alphaTest>0!=e>0&&this.version++,this._alphaTest=e}onBeforeRender(){}onBeforeCompile(){}customProgramCacheKey(){return this.onBeforeCompile.toString()}setValues(e){if(e!==void 0)for(const n in e){const r=e[n];if(r===void 0){qn(`Material: parameter '${n}' has value of undefined.`);continue}const i=this[n];if(i===void 0){qn(`Material: '${n}' is not a property of THREE.${this.type}.`);continue}i&&i.isColor?i.set(r):i&&i.isVector3&&r&&r.isVector3?i.copy(r):this[n]=r}}toJSON(e){const n=e===void 0||typeof e==\"string\";n&&(e={textures:{},images:{}});const r={metadata:{version:4.7,type:\"Material\",generator:\"Material.toJSON\"}};r.uuid=this.uuid,r.type=this.type,this.name!==\"\"&&(r.name=this.name),this.color&&this.color.isColor&&(r.color=this.color.getHex()),this.roughness!==void 0&&(r.roughness=this.roughness),this.metalness!==void 0&&(r.metalness=this.metalness),this.sheen!==void 0&&(r.sheen=this.sheen),this.sheenColor&&this.sheenColor.isColor&&(r.sheenColor=this.sheenColor.getHex()),this.sheenRoughness!==void 0&&(r.sheenRoughness=this.sheenRoughness),this.emissive&&this.emissive.isColor&&(r.emissive=this.emissive.getHex()),this.emissiveIntensity!==void 0&&this.emissiveIntensity!==1&&(r.emissiveIntensity=this.emissiveIntensity),this.specular&&this.specular.isColor&&(r.specular=this.specular.getHex()),this.specularIntensity!==void 0&&(r.specularIntensity=this.specularIntensity),this.specularColor&&this.specularColor.isColor&&(r.specularColor=this.specularColor.getHex()),this.shininess!==void 0&&(r.shininess=this.shininess),this.clearcoat!==void 0&&(r.clearcoat=this.clearcoat),this.clearcoatRoughness!==void 0&&(r.clearcoatRoughness=this.clearcoatRoughness),this.clearcoatMap&&this.clearcoatMap.isTexture&&(r.clearcoatMap=this.clearcoatMap.toJSON(e).uuid),this.clearcoatRoughnessMap&&this.clearcoatRoughnessMap.isTexture&&(r.clearcoatRoughnessMap=this.clearcoatRoughnessMap.toJSON(e).uuid),this.clearcoatNormalMap&&this.clearcoatNormalMap.isTexture&&(r.clearcoatNormalMap=this.clearcoatNormalMap.toJSON(e).uuid,r.clearcoatNormalScale=this.clearcoatNormalScale.toArray()),this.sheenColorMap&&this.sheenColorMap.isTexture&&(r.sheenColorMap=this.sheenColorMap.toJSON(e).uuid),this.sheenRoughnessMap&&this.sheenRoughnessMap.isTexture&&(r.sheenRoughnessMap=this.sheenRoughnessMap.toJSON(e).uuid),this.dispersion!==void 0&&(r.dispersion=this.dispersion),this.iridescence!==void 0&&(r.iridescence=this.iridescence),this.iridescenceIOR!==void 0&&(r.iridescenceIOR=this.iridescenceIOR),this.iridescenceThicknessRange!==void 0&&(r.iridescenceThicknessRange=this.iridescenceThicknessRange),this.iridescenceMap&&this.iridescenceMap.isTexture&&(r.iridescenceMap=this.iridescenceMap.toJSON(e).uuid),this.iridescenceThicknessMap&&this.iridescenceThicknessMap.isTexture&&(r.iridescenceThicknessMap=this.iridescenceThicknessMap.toJSON(e).uuid),this.anisotropy!==void 0&&(r.anisotropy=this.anisotropy),this.anisotropyRotation!==void 0&&(r.anisotropyRotation=this.anisotropyRotation),this.anisotropyMap&&this.anisotropyMap.isTexture&&(r.anisotropyMap=this.anisotropyMap.toJSON(e).uuid),this.map&&this.map.isTexture&&(r.map=this.map.toJSON(e).uuid),this.matcap&&this.matcap.isTexture&&(r.matcap=this.matcap.toJSON(e).uuid),this.alphaMap&&this.alphaMap.isTexture&&(r.alphaMap=this.alphaMap.toJSON(e).uuid),this.lightMap&&this.lightMap.isTexture&&(r.lightMap=this.lightMap.toJSON(e).uuid,r.lightMapIntensity=this.lightMapIntensity),this.aoMap&&this.aoMap.isTexture&&(r.aoMap=this.aoMap.toJSON(e).uuid,r.aoMapIntensity=this.aoMapIntensity),this.bumpMap&&this.bumpMap.isTexture&&(r.bumpMap=this.bumpMap.toJSON(e).uuid,r.bumpScale=this.bumpScale),this.normalMap&&this.normalMap.isTexture&&(r.normalMap=this.normalMap.toJSON(e).uuid,r.normalMapType=this.normalMapType,r.normalScale=this.normalScale.toArray()),this.displacementMap&&this.displacementMap.isTexture&&(r.displacementMap=this.displacementMap.toJSON(e).uuid,r.displacementScale=this.displacementScale,r.displacementBias=this.displacementBias),this.roughnessMap&&this.roughnessMap.isTexture&&(r.roughnessMap=this.roughnessMap.toJSON(e).uuid),this.metalnessMap&&this.metalnessMap.isTexture&&(r.metalnessMap=this.metalnessMap.toJSON(e).uuid),this.emissiveMap&&this.emissiveMap.isTexture&&(r.emissiveMap=this.emissiveMap.toJSON(e).uuid),this.specularMap&&this.specularMap.isTexture&&(r.specularMap=this.specularMap.toJSON(e).uuid),this.specularIntensityMap&&this.specularIntensityMap.isTexture&&(r.specularIntensityMap=this.specularIntensityMap.toJSON(e).uuid),this.specularColorMap&&this.specularColorMap.isTexture&&(r.specularColorMap=this.specularColorMap.toJSON(e).uuid),this.envMap&&this.envMap.isTexture&&(r.envMap=this.envMap.toJSON(e).uuid,this.combine!==void 0&&(r.combine=this.combine)),this.envMapRotation!==void 0&&(r.envMapRotation=this.envMapRotation.toArray()),this.envMapIntensity!==void 0&&(r.envMapIntensity=this.envMapIntensity),this.reflectivity!==void 0&&(r.reflectivity=this.reflectivity),this.refractionRatio!==void 0&&(r.refractionRatio=this.refractionRatio),this.gradientMap&&this.gradientMap.isTexture&&(r.gradientMap=this.gradientMap.toJSON(e).uuid),this.transmission!==void 0&&(r.transmission=this.transmission),this.transmissionMap&&this.transmissionMap.isTexture&&(r.transmissionMap=this.transmissionMap.toJSON(e).uuid),this.thickness!==void 0&&(r.thickness=this.thickness),this.thicknessMap&&this.thicknessMap.isTexture&&(r.thicknessMap=this.thicknessMap.toJSON(e).uuid),this.attenuationDistance!==void 0&&this.attenuationDistance!==1/0&&(r.attenuationDistance=this.attenuationDistance),this.attenuationColor!==void 0&&(r.attenuationColor=this.attenuationColor.getHex()),this.size!==void 0&&(r.size=this.size),this.shadowSide!==null&&(r.shadowSide=this.shadowSide),this.sizeAttenuation!==void 0&&(r.sizeAttenuation=this.sizeAttenuation),this.blending!==Ax&&(r.blending=this.blending),this.side!==bp&&(r.side=this.side),this.vertexColors===!0&&(r.vertexColors=!0),this.opacity<1&&(r.opacity=this.opacity),this.transparent===!0&&(r.transparent=!0),this.blendSrc!==gF&&(r.blendSrc=this.blendSrc),this.blendDst!==bF&&(r.blendDst=this.blendDst),this.blendEquation!==Tf&&(r.blendEquation=this.blendEquation),this.blendSrcAlpha!==null&&(r.blendSrcAlpha=this.blendSrcAlpha),this.blendDstAlpha!==null&&(r.blendDstAlpha=this.blendDstAlpha),this.blendEquationAlpha!==null&&(r.blendEquationAlpha=this.blendEquationAlpha),this.blendColor&&this.blendColor.isColor&&(r.blendColor=this.blendColor.getHex()),this.blendAlpha!==0&&(r.blendAlpha=this.blendAlpha),this.depthFunc!==Vx&&(r.depthFunc=this.depthFunc),this.depthTest===!1&&(r.depthTest=this.depthTest),this.depthWrite===!1&&(r.depthWrite=this.depthWrite),this.colorWrite===!1&&(r.colorWrite=this.colorWrite),this.stencilWriteMask!==255&&(r.stencilWriteMask=this.stencilWriteMask),this.stencilFunc!==$H&&(r.stencilFunc=this.stencilFunc),this.stencilRef!==0&&(r.stencilRef=this.stencilRef),this.stencilFuncMask!==255&&(r.stencilFuncMask=this.stencilFuncMask),this.stencilFail!==_b&&(r.stencilFail=this.stencilFail),this.stencilZFail!==_b&&(r.stencilZFail=this.stencilZFail),this.stencilZPass!==_b&&(r.stencilZPass=this.stencilZPass),this.stencilWrite===!0&&(r.stencilWrite=this.stencilWrite),this.rotation!==void 0&&this.rotation!==0&&(r.rotation=this.rotation),this.polygonOffset===!0&&(r.polygonOffset=!0),this.polygonOffsetFactor!==0&&(r.polygonOffsetFactor=this.polygonOffsetFactor),this.polygonOffsetUnits!==0&&(r.polygonOffsetUnits=this.polygonOffsetUnits),this.linewidth!==void 0&&this.linewidth!==1&&(r.linewidth=this.linewidth),this.dashSize!==void 0&&(r.dashSize=this.dashSize),this.gapSize!==void 0&&(r.gapSize=this.gapSize),this.scale!==void 0&&(r.scale=this.scale),this.dithering===!0&&(r.dithering=!0),this.alphaTest>0&&(r.alphaTest=this.alphaTest),this.alphaHash===!0&&(r.alphaHash=!0),this.alphaToCoverage===!0&&(r.alphaToCoverage=!0),this.premultipliedAlpha===!0&&(r.premultipliedAlpha=!0),this.forceSinglePass===!0&&(r.forceSinglePass=!0),this.wireframe===!0&&(r.wireframe=!0),this.wireframeLinewidth>1&&(r.wireframeLinewidth=this.wireframeLinewidth),this.wireframeLinecap!==\"round\"&&(r.wireframeLinecap=this.wireframeLinecap),this.wireframeLinejoin!==\"round\"&&(r.wireframeLinejoin=this.wireframeLinejoin),this.flatShading===!0&&(r.flatShading=!0),this.visible===!1&&(r.visible=!1),this.toneMapped===!1&&(r.toneMapped=!1),this.fog===!1&&(r.fog=!1),Object.keys(this.userData).length>0&&(r.userData=this.userData);function i(s){const o=[];for(const l in s){const c=s[l];delete c.metadata,o.push(c)}return o}if(n){const s=i(e.textures),o=i(e.images);s.length>0&&(r.textures=s),o.length>0&&(r.images=o)}return r}clone(){return new this.constructor().copy(this)}copy(e){this.name=e.name,this.blending=e.blending,this.side=e.side,this.vertexColors=e.vertexColors,this.opacity=e.opacity,this.transparent=e.transparent,this.blendSrc=e.blendSrc,this.blendDst=e.blendDst,this.blendEquation=e.blendEquation,this.blendSrcAlpha=e.blendSrcAlpha,this.blendDstAlpha=e.blendDstAlpha,this.blendEquationAlpha=e.blendEquationAlpha,this.blendColor.copy(e.blendColor),this.blendAlpha=e.blendAlpha,this.depthFunc=e.depthFunc,this.depthTest=e.depthTest,this.depthWrite=e.depthWrite,this.stencilWriteMask=e.stencilWriteMask,this.stencilFunc=e.stencilFunc,this.stencilRef=e.stencilRef,this.stencilFuncMask=e.stencilFuncMask,this.stencilFail=e.stencilFail,this.stencilZFail=e.stencilZFail,this.stencilZPass=e.stencilZPass,this.stencilWrite=e.stencilWrite;const n=e.clippingPlanes;let r=null;if(n!==null){const i=n.length;r=new Array(i);for(let s=0;s!==i;++s)r[s]=n[s].clone()}return this.clippingPlanes=r,this.clipIntersection=e.clipIntersection,this.clipShadows=e.clipShadows,this.shadowSide=e.shadowSide,this.colorWrite=e.colorWrite,this.precision=e.precision,this.polygonOffset=e.polygonOffset,this.polygonOffsetFactor=e.polygonOffsetFactor,this.polygonOffsetUnits=e.polygonOffsetUnits,this.dithering=e.dithering,this.alphaTest=e.alphaTest,this.alphaHash=e.alphaHash,this.alphaToCoverage=e.alphaToCoverage,this.premultipliedAlpha=e.premultipliedAlpha,this.forceSinglePass=e.forceSinglePass,this.visible=e.visible,this.toneMapped=e.toneMapped,this.userData=JSON.parse(JSON.stringify(e.userData)),this}dispose(){this.dispatchEvent({type:\"dispose\"})}set needsUpdate(e){e===!0&&this.version++}},tI=class extends ky{constructor(e){super(),this.isMeshBasicMaterial=!0,this.type=\"MeshBasicMaterial\",this.color=new or(16777215),this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.envMapRotation=new xp,this.combine=VO,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap=\"round\",this.wireframeLinejoin=\"round\",this.fog=!0,this.setValues(e)}copy(e){return super.copy(e),this.color.copy(e.color),this.map=e.map,this.lightMap=e.lightMap,this.lightMapIntensity=e.lightMapIntensity,this.aoMap=e.aoMap,this.aoMapIntensity=e.aoMapIntensity,this.specularMap=e.specularMap,this.alphaMap=e.alphaMap,this.envMap=e.envMap,this.envMapRotation.copy(e.envMapRotation),this.combine=e.combine,this.reflectivity=e.reflectivity,this.refractionRatio=e.refractionRatio,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.wireframeLinecap=e.wireframeLinecap,this.wireframeLinejoin=e.wireframeLinejoin,this.fog=e.fog,this}};const bi=new Ct,o1=new nr;let Jve=0,oo=class{constructor(e,n,r=!1){if(Array.isArray(e))throw new TypeError(\"THREE.BufferAttribute: array should be a Typed Array.\");this.isBufferAttribute=!0,Object.defineProperty(this,\"id\",{value:Jve++}),this.name=\"\",this.array=e,this.itemSize=n,this.count=e!==void 0?e.length/n:0,this.normalized=r,this.usage=VH,this.updateRanges=[],this.gpuType=em,this.version=0}onUploadCallback(){}set needsUpdate(e){e===!0&&this.version++}setUsage(e){return this.usage=e,this}addUpdateRange(e,n){this.updateRanges.push({start:e,count:n})}clearUpdateRanges(){this.updateRanges.length=0}copy(e){return this.name=e.name,this.array=new e.array.constructor(e.array),this.itemSize=e.itemSize,this.count=e.count,this.normalized=e.normalized,this.usage=e.usage,this.gpuType=e.gpuType,this}copyAt(e,n,r){e*=this.itemSize,r*=n.itemSize;for(let i=0,s=this.itemSize;i<s;i++)this.array[e+i]=n.array[r+i];return this}copyArray(e){return this.array.set(e),this}applyMatrix3(e){if(this.itemSize===2)for(let n=0,r=this.count;n<r;n++)o1.fromBufferAttribute(this,n),o1.applyMatrix3(e),this.setXY(n,o1.x,o1.y);else if(this.itemSize===3)for(let n=0,r=this.count;n<r;n++)bi.fromBufferAttribute(this,n),bi.applyMatrix3(e),this.setXYZ(n,bi.x,bi.y,bi.z);return this}applyMatrix4(e){for(let n=0,r=this.count;n<r;n++)bi.fromBufferAttribute(this,n),bi.applyMatrix4(e),this.setXYZ(n,bi.x,bi.y,bi.z);return this}applyNormalMatrix(e){for(let n=0,r=this.count;n<r;n++)bi.fromBufferAttribute(this,n),bi.applyNormalMatrix(e),this.setXYZ(n,bi.x,bi.y,bi.z);return this}transformDirection(e){for(let n=0,r=this.count;n<r;n++)bi.fromBufferAttribute(this,n),bi.transformDirection(e),this.setXYZ(n,bi.x,bi.y,bi.z);return this}set(e,n=0){return this.array.set(e,n),this}getComponent(e,n){let r=this.array[e*this.itemSize+n];return this.normalized&&(r=Ev(r,this.array)),r}setComponent(e,n,r){return this.normalized&&(r=jo(r,this.array)),this.array[e*this.itemSize+n]=r,this}getX(e){let n=this.array[e*this.itemSize];return this.normalized&&(n=Ev(n,this.array)),n}setX(e,n){return this.normalized&&(n=jo(n,this.array)),this.array[e*this.itemSize]=n,this}getY(e){let n=this.array[e*this.itemSize+1];return this.normalized&&(n=Ev(n,this.array)),n}setY(e,n){return this.normalized&&(n=jo(n,this.array)),this.array[e*this.itemSize+1]=n,this}getZ(e){let n=this.array[e*this.itemSize+2];return this.normalized&&(n=Ev(n,this.array)),n}setZ(e,n){return this.normalized&&(n=jo(n,this.array)),this.array[e*this.itemSize+2]=n,this}getW(e){let n=this.array[e*this.itemSize+3];return this.normalized&&(n=Ev(n,this.array)),n}setW(e,n){return this.normalized&&(n=jo(n,this.array)),this.array[e*this.itemSize+3]=n,this}setXY(e,n,r){return e*=this.itemSize,this.normalized&&(n=jo(n,this.array),r=jo(r,this.array)),this.array[e+0]=n,this.array[e+1]=r,this}setXYZ(e,n,r,i){return e*=this.itemSize,this.normalized&&(n=jo(n,this.array),r=jo(r,this.array),i=jo(i,this.array)),this.array[e+0]=n,this.array[e+1]=r,this.array[e+2]=i,this}setXYZW(e,n,r,i,s){return e*=this.itemSize,this.normalized&&(n=jo(n,this.array),r=jo(r,this.array),i=jo(i,this.array),s=jo(s,this.array)),this.array[e+0]=n,this.array[e+1]=r,this.array[e+2]=i,this.array[e+3]=s,this}onUpload(e){return this.onUploadCallback=e,this}clone(){return new this.constructor(this.array,this.itemSize).copy(this)}toJSON(){const e={itemSize:this.itemSize,type:this.array.constructor.name,array:Array.from(this.array),normalized:this.normalized};return this.name!==\"\"&&(e.name=this.name),this.usage!==VH&&(e.usage=this.usage),e}},TZ=class extends oo{constructor(e,n,r){super(new Uint16Array(e),n,r)}},AZ=class extends oo{constructor(e,n,r){super(new Uint32Array(e),n,r)}},pl=class extends oo{constructor(e,n,r){super(new Float32Array(e),n,r)}},e0e=0;const Ll=new _i,ZM=new vl,Db=new Ct,al=new sp,Ov=new sp,$i=new Ct;let uc=class jZ extends Ug{constructor(){super(),this.isBufferGeometry=!0,Object.defineProperty(this,\"id\",{value:e0e++}),this.uuid=lS(),this.name=\"\",this.type=\"BufferGeometry\",this.index=null,this.indirect=null,this.attributes={},this.morphAttributes={},this.morphTargetsRelative=!1,this.groups=[],this.boundingBox=null,this.boundingSphere=null,this.drawRange={start:0,count:1/0},this.userData={}}getIndex(){return this.index}setIndex(e){return Array.isArray(e)?this.index=new(yZ(e)?AZ:TZ)(e,1):this.index=e,this}setIndirect(e){return this.indirect=e,this}getIndirect(){return this.indirect}getAttribute(e){return this.attributes[e]}setAttribute(e,n){return this.attributes[e]=n,this}deleteAttribute(e){return delete this.attributes[e],this}hasAttribute(e){return this.attributes[e]!==void 0}addGroup(e,n,r=0){this.groups.push({start:e,count:n,materialIndex:r})}clearGroups(){this.groups=[]}setDrawRange(e,n){this.drawRange.start=e,this.drawRange.count=n}applyMatrix4(e){const n=this.attributes.position;n!==void 0&&(n.applyMatrix4(e),n.needsUpdate=!0);const r=this.attributes.normal;if(r!==void 0){const s=new ir().getNormalMatrix(e);r.applyNormalMatrix(s),r.needsUpdate=!0}const i=this.attributes.tangent;return i!==void 0&&(i.transformDirection(e),i.needsUpdate=!0),this.boundingBox!==null&&this.computeBoundingBox(),this.boundingSphere!==null&&this.computeBoundingSphere(),this}applyQuaternion(e){return Ll.makeRotationFromQuaternion(e),this.applyMatrix4(Ll),this}rotateX(e){return Ll.makeRotationX(e),this.applyMatrix4(Ll),this}rotateY(e){return Ll.makeRotationY(e),this.applyMatrix4(Ll),this}rotateZ(e){return Ll.makeRotationZ(e),this.applyMatrix4(Ll),this}translate(e,n,r){return Ll.makeTranslation(e,n,r),this.applyMatrix4(Ll),this}scale(e,n,r){return Ll.makeScale(e,n,r),this.applyMatrix4(Ll),this}lookAt(e){return ZM.lookAt(e),ZM.updateMatrix(),this.applyMatrix4(ZM.matrix),this}center(){return this.computeBoundingBox(),this.boundingBox.getCenter(Db).negate(),this.translate(Db.x,Db.y,Db.z),this}setFromPoints(e){const n=this.getAttribute(\"position\");if(n===void 0){const r=[];for(let i=0,s=e.length;i<s;i++){const o=e[i];r.push(o.x,o.y,o.z||0)}this.setAttribute(\"position\",new pl(r,3))}else{const r=Math.min(e.length,n.count);for(let i=0;i<r;i++){const s=e[i];n.setXYZ(i,s.x,s.y,s.z||0)}e.length>n.count&&qn(\"BufferGeometry: Buffer size too small for points data. Use .dispose() and create a new geometry.\"),n.needsUpdate=!0}return this}computeBoundingBox(){this.boundingBox===null&&(this.boundingBox=new sp);const e=this.attributes.position,n=this.morphAttributes.position;if(e&&e.isGLBufferAttribute){oi(\"BufferGeometry.computeBoundingBox(): GLBufferAttribute requires a manual bounding box.\",this),this.boundingBox.set(new Ct(-1/0,-1/0,-1/0),new Ct(1/0,1/0,1/0));return}if(e!==void 0){if(this.boundingBox.setFromBufferAttribute(e),n)for(let r=0,i=n.length;r<i;r++){const s=n[r];al.setFromBufferAttribute(s),this.morphTargetsRelative?($i.addVectors(this.boundingBox.min,al.min),this.boundingBox.expandByPoint($i),$i.addVectors(this.boundingBox.max,al.max),this.boundingBox.expandByPoint($i)):(this.boundingBox.expandByPoint(al.min),this.boundingBox.expandByPoint(al.max))}}else this.boundingBox.makeEmpty();(isNaN(this.boundingBox.min.x)||isNaN(this.boundingBox.min.y)||isNaN(this.boundingBox.min.z))&&oi('BufferGeometry.computeBoundingBox(): Computed min/max have NaN values. The \"position\" attribute is likely to have NaN values.',this)}computeBoundingSphere(){this.boundingSphere===null&&(this.boundingSphere=new YP);const e=this.attributes.position,n=this.morphAttributes.position;if(e&&e.isGLBufferAttribute){oi(\"BufferGeometry.computeBoundingSphere(): GLBufferAttribute requires a manual bounding sphere.\",this),this.boundingSphere.set(new Ct,1/0);return}if(e){const r=this.boundingSphere.center;if(al.setFromBufferAttribute(e),n)for(let s=0,o=n.length;s<o;s++){const l=n[s];Ov.setFromBufferAttribute(l),this.morphTargetsRelative?($i.addVectors(al.min,Ov.min),al.expandByPoint($i),$i.addVectors(al.max,Ov.max),al.expandByPoint($i)):(al.expandByPoint(Ov.min),al.expandByPoint(Ov.max))}al.getCenter(r);let i=0;for(let s=0,o=e.count;s<o;s++)$i.fromBufferAttribute(e,s),i=Math.max(i,r.distanceToSquared($i));if(n)for(let s=0,o=n.length;s<o;s++){const l=n[s],c=this.morphTargetsRelative;for(let d=0,u=l.count;d<u;d++)$i.fromBufferAttribute(l,d),c&&(Db.fromBufferAttribute(e,d),$i.add(Db)),i=Math.max(i,r.distanceToSquared($i))}this.boundingSphere.radius=Math.sqrt(i),isNaN(this.boundingSphere.radius)&&oi('BufferGeometry.computeBoundingSphere(): Computed radius is NaN. The \"position\" attribute is likely to have NaN values.',this)}}computeTangents(){const e=this.index,n=this.attributes;if(e===null||n.position===void 0||n.normal===void 0||n.uv===void 0){oi(\"BufferGeometry: .computeTangents() failed. Missing required attributes (index, position, normal or uv)\");return}const r=n.position,i=n.normal,s=n.uv;this.hasAttribute(\"tangent\")===!1&&this.setAttribute(\"tangent\",new oo(new Float32Array(4*r.count),4));const o=this.getAttribute(\"tangent\"),l=[],c=[];for(let F=0;F<r.count;F++)l[F]=new Ct,c[F]=new Ct;const d=new Ct,u=new Ct,m=new Ct,p=new nr,f=new nr,y=new nr,v=new Ct,b=new Ct;function g(F,k,D){d.fromBufferAttribute(r,F),u.fromBufferAttribute(r,k),m.fromBufferAttribute(r,D),p.fromBufferAttribute(s,F),f.fromBufferAttribute(s,k),y.fromBufferAttribute(s,D),u.sub(d),m.sub(d),f.sub(p),y.sub(p);const H=1/(f.x*y.y-y.x*f.y);isFinite(H)&&(v.copy(u).multiplyScalar(y.y).addScaledVector(m,-f.y).multiplyScalar(H),b.copy(m).multiplyScalar(f.x).addScaledVector(u,-y.x).multiplyScalar(H),l[F].add(v),l[k].add(v),l[D].add(v),c[F].add(b),c[k].add(b),c[D].add(b))}let _=this.groups;_.length===0&&(_=[{start:0,count:e.count}]);for(let F=0,k=_.length;F<k;++F){const D=_[F],H=D.start,z=D.count;for(let Q=H,L=H+z;Q<L;Q+=3)g(e.getX(Q+0),e.getX(Q+1),e.getX(Q+2))}const C=new Ct,P=new Ct,N=new Ct,A=new Ct;function T(F){N.fromBufferAttribute(i,F),A.copy(N);const k=l[F];C.copy(k),C.sub(N.multiplyScalar(N.dot(k))).normalize(),P.crossVectors(A,k);const H=P.dot(c[F])<0?-1:1;o.setXYZW(F,C.x,C.y,C.z,H)}for(let F=0,k=_.length;F<k;++F){const D=_[F],H=D.start,z=D.count;for(let Q=H,L=H+z;Q<L;Q+=3)T(e.getX(Q+0)),T(e.getX(Q+1)),T(e.getX(Q+2))}}computeVertexNormals(){const e=this.index,n=this.getAttribute(\"position\");if(n!==void 0){let r=this.getAttribute(\"normal\");if(r===void 0)r=new oo(new Float32Array(n.count*3),3),this.setAttribute(\"normal\",r);else for(let p=0,f=r.count;p<f;p++)r.setXYZ(p,0,0,0);const i=new Ct,s=new Ct,o=new Ct,l=new Ct,c=new Ct,d=new Ct,u=new Ct,m=new Ct;if(e)for(let p=0,f=e.count;p<f;p+=3){const y=e.getX(p+0),v=e.getX(p+1),b=e.getX(p+2);i.fromBufferAttribute(n,y),s.fromBufferAttribute(n,v),o.fromBufferAttribute(n,b),u.subVectors(o,s),m.subVectors(i,s),u.cross(m),l.fromBufferAttribute(r,y),c.fromBufferAttribute(r,v),d.fromBufferAttribute(r,b),l.add(u),c.add(u),d.add(u),r.setXYZ(y,l.x,l.y,l.z),r.setXYZ(v,c.x,c.y,c.z),r.setXYZ(b,d.x,d.y,d.z)}else for(let p=0,f=n.count;p<f;p+=3)i.fromBufferAttribute(n,p+0),s.fromBufferAttribute(n,p+1),o.fromBufferAttribute(n,p+2),u.subVectors(o,s),m.subVectors(i,s),u.cross(m),r.setXYZ(p+0,u.x,u.y,u.z),r.setXYZ(p+1,u.x,u.y,u.z),r.setXYZ(p+2,u.x,u.y,u.z);this.normalizeNormals(),r.needsUpdate=!0}}normalizeNormals(){const e=this.attributes.normal;for(let n=0,r=e.count;n<r;n++)$i.fromBufferAttribute(e,n),$i.normalize(),e.setXYZ(n,$i.x,$i.y,$i.z)}toNonIndexed(){function e(l,c){const d=l.array,u=l.itemSize,m=l.normalized,p=new d.constructor(c.length*u);let f=0,y=0;for(let v=0,b=c.length;v<b;v++){l.isInterleavedBufferAttribute?f=c[v]*l.data.stride+l.offset:f=c[v]*u;for(let g=0;g<u;g++)p[y++]=d[f++]}return new oo(p,u,m)}if(this.index===null)return qn(\"BufferGeometry.toNonIndexed(): BufferGeometry is already non-indexed.\"),this;const n=new jZ,r=this.index.array,i=this.attributes;for(const l in i){const c=i[l],d=e(c,r);n.setAttribute(l,d)}const s=this.morphAttributes;for(const l in s){const c=[],d=s[l];for(let u=0,m=d.length;u<m;u++){const p=d[u],f=e(p,r);c.push(f)}n.morphAttributes[l]=c}n.morphTargetsRelative=this.morphTargetsRelative;const o=this.groups;for(let l=0,c=o.length;l<c;l++){const d=o[l];n.addGroup(d.start,d.count,d.materialIndex)}return n}toJSON(){const e={metadata:{version:4.7,type:\"BufferGeometry\",generator:\"BufferGeometry.toJSON\"}};if(e.uuid=this.uuid,e.type=this.type,this.name!==\"\"&&(e.name=this.name),Object.keys(this.userData).length>0&&(e.userData=this.userData),this.parameters!==void 0){const c=this.parameters;for(const d in c)c[d]!==void 0&&(e[d]=c[d]);return e}e.data={attributes:{}};const n=this.index;n!==null&&(e.data.index={type:n.array.constructor.name,array:Array.prototype.slice.call(n.array)});const r=this.attributes;for(const c in r){const d=r[c];e.data.attributes[c]=d.toJSON(e.data)}const i={};let s=!1;for(const c in this.morphAttributes){const d=this.morphAttributes[c],u=[];for(let m=0,p=d.length;m<p;m++){const f=d[m];u.push(f.toJSON(e.data))}u.length>0&&(i[c]=u,s=!0)}s&&(e.data.morphAttributes=i,e.data.morphTargetsRelative=this.morphTargetsRelative);const o=this.groups;o.length>0&&(e.data.groups=JSON.parse(JSON.stringify(o)));const l=this.boundingSphere;return l!==null&&(e.data.boundingSphere=l.toJSON()),e}clone(){return new this.constructor().copy(this)}copy(e){this.index=null,this.attributes={},this.morphAttributes={},this.groups=[],this.boundingBox=null,this.boundingSphere=null;const n={};this.name=e.name;const r=e.index;r!==null&&this.setIndex(r.clone());const i=e.attributes;for(const d in i){const u=i[d];this.setAttribute(d,u.clone(n))}const s=e.morphAttributes;for(const d in s){const u=[],m=s[d];for(let p=0,f=m.length;p<f;p++)u.push(m[p].clone(n));this.morphAttributes[d]=u}this.morphTargetsRelative=e.morphTargetsRelative;const o=e.groups;for(let d=0,u=o.length;d<u;d++){const m=o[d];this.addGroup(m.start,m.count,m.materialIndex)}const l=e.boundingBox;l!==null&&(this.boundingBox=l.clone());const c=e.boundingSphere;return c!==null&&(this.boundingSphere=c.clone()),this.drawRange.start=e.drawRange.start,this.drawRange.count=e.drawRange.count,this.userData=e.userData,this}dispose(){this.dispatchEvent({type:\"dispose\"})}};const s6=new _i,df=new eI,l1=new YP,o6=new Ct,c1=new Ct,d1=new Ct,u1=new Ct,JM=new Ct,m1=new Ct,l6=new Ct,h1=new Ct;let mc=class extends vl{constructor(e=new uc,n=new tI){super(),this.isMesh=!0,this.type=\"Mesh\",this.geometry=e,this.material=n,this.morphTargetDictionary=void 0,this.morphTargetInfluences=void 0,this.count=1,this.updateMorphTargets()}copy(e,n){return super.copy(e,n),e.morphTargetInfluences!==void 0&&(this.morphTargetInfluences=e.morphTargetInfluences.slice()),e.morphTargetDictionary!==void 0&&(this.morphTargetDictionary=Object.assign({},e.morphTargetDictionary)),this.material=Array.isArray(e.material)?e.material.slice():e.material,this.geometry=e.geometry,this}updateMorphTargets(){const n=this.geometry.morphAttributes,r=Object.keys(n);if(r.length>0){const i=n[r[0]];if(i!==void 0){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let s=0,o=i.length;s<o;s++){const l=i[s].name||String(s);this.morphTargetInfluences.push(0),this.morphTargetDictionary[l]=s}}}}getVertexPosition(e,n){const r=this.geometry,i=r.attributes.position,s=r.morphAttributes.position,o=r.morphTargetsRelative;n.fromBufferAttribute(i,e);const l=this.morphTargetInfluences;if(s&&l){m1.set(0,0,0);for(let c=0,d=s.length;c<d;c++){const u=l[c],m=s[c];u!==0&&(JM.fromBufferAttribute(m,e),o?m1.addScaledVector(JM,u):m1.addScaledVector(JM.sub(n),u))}n.add(m1)}return n}raycast(e,n){const r=this.geometry,i=this.material,s=this.matrixWorld;i!==void 0&&(r.boundingSphere===null&&r.computeBoundingSphere(),l1.copy(r.boundingSphere),l1.applyMatrix4(s),df.copy(e.ray).recast(e.near),!(l1.containsPoint(df.origin)===!1&&(df.intersectSphere(l1,o6)===null||df.origin.distanceToSquared(o6)>(e.far-e.near)**2))&&(s6.copy(s).invert(),df.copy(e.ray).applyMatrix4(s6),!(r.boundingBox!==null&&df.intersectsBox(r.boundingBox)===!1)&&this._computeIntersections(e,n,df)))}_computeIntersections(e,n,r){let i;const s=this.geometry,o=this.material,l=s.index,c=s.attributes.position,d=s.attributes.uv,u=s.attributes.uv1,m=s.attributes.normal,p=s.groups,f=s.drawRange;if(l!==null)if(Array.isArray(o))for(let y=0,v=p.length;y<v;y++){const b=p[y],g=o[b.materialIndex],_=Math.max(b.start,f.start),C=Math.min(l.count,Math.min(b.start+b.count,f.start+f.count));for(let P=_,N=C;P<N;P+=3){const A=l.getX(P),T=l.getX(P+1),F=l.getX(P+2);i=p1(this,g,e,r,d,u,m,A,T,F),i&&(i.faceIndex=Math.floor(P/3),i.face.materialIndex=b.materialIndex,n.push(i))}}else{const y=Math.max(0,f.start),v=Math.min(l.count,f.start+f.count);for(let b=y,g=v;b<g;b+=3){const _=l.getX(b),C=l.getX(b+1),P=l.getX(b+2);i=p1(this,o,e,r,d,u,m,_,C,P),i&&(i.faceIndex=Math.floor(b/3),n.push(i))}}else if(c!==void 0)if(Array.isArray(o))for(let y=0,v=p.length;y<v;y++){const b=p[y],g=o[b.materialIndex],_=Math.max(b.start,f.start),C=Math.min(c.count,Math.min(b.start+b.count,f.start+f.count));for(let P=_,N=C;P<N;P+=3){const A=P,T=P+1,F=P+2;i=p1(this,g,e,r,d,u,m,A,T,F),i&&(i.faceIndex=Math.floor(P/3),i.face.materialIndex=b.materialIndex,n.push(i))}}else{const y=Math.max(0,f.start),v=Math.min(c.count,f.start+f.count);for(let b=y,g=v;b<g;b+=3){const _=b,C=b+1,P=b+2;i=p1(this,o,e,r,d,u,m,_,C,P),i&&(i.faceIndex=Math.floor(b/3),n.push(i))}}}};function t0e(t,e,n,r,i,s,o,l){let c;if(e.side===zo?c=r.intersectTriangle(o,s,i,!0,l):c=r.intersectTriangle(i,s,o,e.side===bp,l),c===null)return null;h1.copy(l),h1.applyMatrix4(t.matrixWorld);const d=n.ray.origin.distanceTo(h1);return d<n.near||d>n.far?null:{distance:d,point:h1.clone(),object:t}}function p1(t,e,n,r,i,s,o,l,c,d){t.getVertexPosition(l,c1),t.getVertexPosition(c,d1),t.getVertexPosition(d,u1);const u=t0e(t,e,n,r,c1,d1,u1,l6);if(u){const m=new Ct;Lv.getBarycoord(l6,c1,d1,u1,m),i&&(u.uv=Lv.getInterpolatedAttribute(i,l,c,d,m,new nr)),s&&(u.uv1=Lv.getInterpolatedAttribute(s,l,c,d,m,new nr)),o&&(u.normal=Lv.getInterpolatedAttribute(o,l,c,d,m,new Ct),u.normal.dot(r.direction)>0&&u.normal.multiplyScalar(-1));const p={a:l,b:c,c:d,normal:new Ct,materialIndex:0};Lv.getNormal(c1,d1,u1,p.normal),u.face=p,u.barycoord=m}return u}let nI=class MZ extends uc{constructor(e=1,n=1,r=1,i=1,s=1,o=1){super(),this.type=\"BoxGeometry\",this.parameters={width:e,height:n,depth:r,widthSegments:i,heightSegments:s,depthSegments:o};const l=this;i=Math.floor(i),s=Math.floor(s),o=Math.floor(o);const c=[],d=[],u=[],m=[];let p=0,f=0;y(\"z\",\"y\",\"x\",-1,-1,r,n,e,o,s,0),y(\"z\",\"y\",\"x\",1,-1,r,n,-e,o,s,1),y(\"x\",\"z\",\"y\",1,1,e,r,n,i,o,2),y(\"x\",\"z\",\"y\",1,-1,e,r,-n,i,o,3),y(\"x\",\"y\",\"z\",1,-1,e,n,r,i,s,4),y(\"x\",\"y\",\"z\",-1,-1,e,n,-r,i,s,5),this.setIndex(c),this.setAttribute(\"position\",new pl(d,3)),this.setAttribute(\"normal\",new pl(u,3)),this.setAttribute(\"uv\",new pl(m,2));function y(v,b,g,_,C,P,N,A,T,F,k){const D=P/T,H=N/F,z=P/2,Q=N/2,L=A/2,te=T+1,ie=F+1;let J=0,oe=0;const fe=new Ct;for(let re=0;re<ie;re++){const W=re*H-Q;for(let ne=0;ne<te;ne++){const me=ne*D-z;fe[v]=me*_,fe[b]=W*C,fe[g]=L,d.push(fe.x,fe.y,fe.z),fe[v]=0,fe[b]=0,fe[g]=A>0?1:-1,u.push(fe.x,fe.y,fe.z),m.push(ne/T),m.push(1-re/F),J+=1}}for(let re=0;re<F;re++)for(let W=0;W<T;W++){const ne=p+W+te*re,me=p+W+te*(re+1),be=p+(W+1)+te*(re+1),Ce=p+(W+1)+te*re;c.push(ne,me,Ce),c.push(me,be,Ce),oe+=6}l.addGroup(f,oe,k),f+=oe,p+=J}}copy(e){return super.copy(e),this.parameters=Object.assign({},e.parameters),this}static fromJSON(e){return new MZ(e.width,e.height,e.depth,e.widthSegments,e.heightSegments,e.depthSegments)}};function Xx(t){const e={};for(const n in t){e[n]={};for(const r in t[n]){const i=t[n][r];i&&(i.isColor||i.isMatrix3||i.isMatrix4||i.isVector2||i.isVector3||i.isVector4||i.isTexture||i.isQuaternion)?i.isRenderTargetTexture?(qn(\"UniformsUtils: Textures of render targets cannot be cloned via cloneUniforms() or mergeUniforms().\"),e[n][r]=null):e[n][r]=i.clone():Array.isArray(i)?e[n][r]=i.slice():e[n][r]=i}}return e}function eo(t){const e={};for(let n=0;n<t.length;n++){const r=Xx(t[n]);for(const i in r)e[i]=r[i]}return e}function n0e(t){const e=[];for(let n=0;n<t.length;n++)e.push(t[n].clone());return e}function EZ(t){const e=t.getRenderTarget();return e===null?t.outputColorSpace:e.isXRRenderTarget===!0?e.texture.colorSpace:Mr.workingColorSpace}const r0e={clone:Xx,merge:eo};var a0e=`void main() {\n\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n}`,i0e=`void main() {\n\tgl_FragColor = vec4( 1.0, 0.0, 0.0, 1.0 );\n}`;let wm=class extends ky{constructor(e){super(),this.isShaderMaterial=!0,this.type=\"ShaderMaterial\",this.defines={},this.uniforms={},this.uniformsGroups=[],this.vertexShader=a0e,this.fragmentShader=i0e,this.linewidth=1,this.wireframe=!1,this.wireframeLinewidth=1,this.fog=!1,this.lights=!1,this.clipping=!1,this.forceSinglePass=!0,this.extensions={clipCullDistance:!1,multiDraw:!1},this.defaultAttributeValues={color:[1,1,1],uv:[0,0],uv1:[0,0]},this.index0AttributeName=void 0,this.uniformsNeedUpdate=!1,this.glslVersion=null,e!==void 0&&this.setValues(e)}copy(e){return super.copy(e),this.fragmentShader=e.fragmentShader,this.vertexShader=e.vertexShader,this.uniforms=Xx(e.uniforms),this.uniformsGroups=n0e(e.uniformsGroups),this.defines=Object.assign({},e.defines),this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.fog=e.fog,this.lights=e.lights,this.clipping=e.clipping,this.extensions=Object.assign({},e.extensions),this.glslVersion=e.glslVersion,this}toJSON(e){const n=super.toJSON(e);n.glslVersion=this.glslVersion,n.uniforms={};for(const i in this.uniforms){const o=this.uniforms[i].value;o&&o.isTexture?n.uniforms[i]={type:\"t\",value:o.toJSON(e).uuid}:o&&o.isColor?n.uniforms[i]={type:\"c\",value:o.getHex()}:o&&o.isVector2?n.uniforms[i]={type:\"v2\",value:o.toArray()}:o&&o.isVector3?n.uniforms[i]={type:\"v3\",value:o.toArray()}:o&&o.isVector4?n.uniforms[i]={type:\"v4\",value:o.toArray()}:o&&o.isMatrix3?n.uniforms[i]={type:\"m3\",value:o.toArray()}:o&&o.isMatrix4?n.uniforms[i]={type:\"m4\",value:o.toArray()}:n.uniforms[i]={value:o}}Object.keys(this.defines).length>0&&(n.defines=this.defines),n.vertexShader=this.vertexShader,n.fragmentShader=this.fragmentShader,n.lights=this.lights,n.clipping=this.clipping;const r={};for(const i in this.extensions)this.extensions[i]===!0&&(r[i]=!0);return Object.keys(r).length>0&&(n.extensions=r),n}},DZ=class extends vl{constructor(){super(),this.isCamera=!0,this.type=\"Camera\",this.matrixWorldInverse=new _i,this.projectionMatrix=new _i,this.projectionMatrixInverse=new _i,this.coordinateSystem=Md,this._reversedDepth=!1}get reversedDepth(){return this._reversedDepth}copy(e,n){return super.copy(e,n),this.matrixWorldInverse.copy(e.matrixWorldInverse),this.projectionMatrix.copy(e.projectionMatrix),this.projectionMatrixInverse.copy(e.projectionMatrixInverse),this.coordinateSystem=e.coordinateSystem,this}getWorldDirection(e){return super.getWorldDirection(e).negate()}updateMatrixWorld(e){super.updateMatrixWorld(e),this.matrixWorldInverse.copy(this.matrixWorld).invert()}updateWorldMatrix(e,n){super.updateWorldMatrix(e,n),this.matrixWorldInverse.copy(this.matrixWorld).invert()}clone(){return new this.constructor().copy(this)}};const ph=new Ct,c6=new nr,d6=new nr;let Kl=class extends DZ{constructor(e=50,n=1,r=.1,i=2e3){super(),this.isPerspectiveCamera=!0,this.type=\"PerspectiveCamera\",this.fov=e,this.zoom=1,this.near=r,this.far=i,this.focus=10,this.aspect=n,this.view=null,this.filmGauge=35,this.filmOffset=0,this.updateProjectionMatrix()}copy(e,n){return super.copy(e,n),this.fov=e.fov,this.zoom=e.zoom,this.near=e.near,this.far=e.far,this.focus=e.focus,this.aspect=e.aspect,this.view=e.view===null?null:Object.assign({},e.view),this.filmGauge=e.filmGauge,this.filmOffset=e.filmOffset,this}setFocalLength(e){const n=.5*this.getFilmHeight()/e;this.fov=rR*2*Math.atan(n),this.updateProjectionMatrix()}getFocalLength(){const e=Math.tan($k*.5*this.fov);return .5*this.getFilmHeight()/e}getEffectiveFOV(){return rR*2*Math.atan(Math.tan($k*.5*this.fov)/this.zoom)}getFilmWidth(){return this.filmGauge*Math.min(this.aspect,1)}getFilmHeight(){return this.filmGauge/Math.max(this.aspect,1)}getViewBounds(e,n,r){ph.set(-1,-1,.5).applyMatrix4(this.projectionMatrixInverse),n.set(ph.x,ph.y).multiplyScalar(-e/ph.z),ph.set(1,1,.5).applyMatrix4(this.projectionMatrixInverse),r.set(ph.x,ph.y).multiplyScalar(-e/ph.z)}getViewSize(e,n){return this.getViewBounds(e,c6,d6),n.subVectors(d6,c6)}setViewOffset(e,n,r,i,s,o){this.aspect=e/n,this.view===null&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=e,this.view.fullHeight=n,this.view.offsetX=r,this.view.offsetY=i,this.view.width=s,this.view.height=o,this.updateProjectionMatrix()}clearViewOffset(){this.view!==null&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const e=this.near;let n=e*Math.tan($k*.5*this.fov)/this.zoom,r=2*n,i=this.aspect*r,s=-.5*i;const o=this.view;if(this.view!==null&&this.view.enabled){const c=o.fullWidth,d=o.fullHeight;s+=o.offsetX*i/c,n-=o.offsetY*r/d,i*=o.width/c,r*=o.height/d}const l=this.filmOffset;l!==0&&(s+=e*l/this.getFilmWidth()),this.projectionMatrix.makePerspective(s,s+i,n,n-r,e,this.far,this.coordinateSystem,this.reversedDepth),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(e){const n=super.toJSON(e);return n.object.fov=this.fov,n.object.zoom=this.zoom,n.object.near=this.near,n.object.far=this.far,n.object.focus=this.focus,n.object.aspect=this.aspect,this.view!==null&&(n.object.view=Object.assign({},this.view)),n.object.filmGauge=this.filmGauge,n.object.filmOffset=this.filmOffset,n}};const Fb=-90,Rb=1;let s0e=class extends vl{constructor(e,n,r){super(),this.type=\"CubeCamera\",this.renderTarget=r,this.coordinateSystem=null,this.activeMipmapLevel=0;const i=new Kl(Fb,Rb,e,n);i.layers=this.layers,this.add(i);const s=new Kl(Fb,Rb,e,n);s.layers=this.layers,this.add(s);const o=new Kl(Fb,Rb,e,n);o.layers=this.layers,this.add(o);const l=new Kl(Fb,Rb,e,n);l.layers=this.layers,this.add(l);const c=new Kl(Fb,Rb,e,n);c.layers=this.layers,this.add(c);const d=new Kl(Fb,Rb,e,n);d.layers=this.layers,this.add(d)}updateCoordinateSystem(){const e=this.coordinateSystem,n=this.children.concat(),[r,i,s,o,l,c]=n;for(const d of n)this.remove(d);if(e===Md)r.up.set(0,1,0),r.lookAt(1,0,0),i.up.set(0,1,0),i.lookAt(-1,0,0),s.up.set(0,0,-1),s.lookAt(0,1,0),o.up.set(0,0,1),o.lookAt(0,-1,0),l.up.set(0,1,0),l.lookAt(0,0,1),c.up.set(0,1,0),c.lookAt(0,0,-1);else if(e===AN)r.up.set(0,-1,0),r.lookAt(-1,0,0),i.up.set(0,-1,0),i.lookAt(1,0,0),s.up.set(0,0,1),s.lookAt(0,1,0),o.up.set(0,0,-1),o.lookAt(0,-1,0),l.up.set(0,-1,0),l.lookAt(0,0,1),c.up.set(0,-1,0),c.lookAt(0,0,-1);else throw new Error(\"THREE.CubeCamera.updateCoordinateSystem(): Invalid coordinate system: \"+e);for(const d of n)this.add(d),d.updateMatrixWorld()}update(e,n){this.parent===null&&this.updateMatrixWorld();const{renderTarget:r,activeMipmapLevel:i}=this;this.coordinateSystem!==e.coordinateSystem&&(this.coordinateSystem=e.coordinateSystem,this.updateCoordinateSystem());const[s,o,l,c,d,u]=this.children,m=e.getRenderTarget(),p=e.getActiveCubeFace(),f=e.getActiveMipmapLevel(),y=e.xr.enabled;e.xr.enabled=!1;const v=r.texture.generateMipmaps;r.texture.generateMipmaps=!1,e.setRenderTarget(r,0,i),e.render(n,s),e.setRenderTarget(r,1,i),e.render(n,o),e.setRenderTarget(r,2,i),e.render(n,l),e.setRenderTarget(r,3,i),e.render(n,c),e.setRenderTarget(r,4,i),e.render(n,d),r.texture.generateMipmaps=v,e.setRenderTarget(r,5,i),e.render(n,u),e.setRenderTarget(m,p,f),e.xr.enabled=y,r.texture.needsPMREMUpdate=!0}},FZ=class extends nd{constructor(e=[],n=Gx,r,i,s,o,l,c,d,u){super(e,n,r,i,s,o,l,c,d,u),this.isCubeTexture=!0,this.flipY=!1}get images(){return this.image}set images(e){this.image=e}},o0e=class extends bg{constructor(e=1,n={}){super(e,e,n),this.isWebGLCubeRenderTarget=!0;const r={width:e,height:e,depth:1},i=[r,r,r,r,r,r];this.texture=new FZ(i),this._setTextureOptions(n),this.texture.isRenderTargetTexture=!0}fromEquirectangularTexture(e,n){this.texture.type=n.type,this.texture.colorSpace=n.colorSpace,this.texture.generateMipmaps=n.generateMipmaps,this.texture.minFilter=n.minFilter,this.texture.magFilter=n.magFilter;const r={uniforms:{tEquirect:{value:null}},vertexShader:`\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\tvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\n\t\t\t\t\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvWorldDirection = transformDirection( position, modelMatrix );\n\n\t\t\t\t\t#include <begin_vertex>\n\t\t\t\t\t#include <project_vertex>\n\n\t\t\t\t}\n\t\t\t`,fragmentShader:`\n\n\t\t\t\tuniform sampler2D tEquirect;\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\t#include <common>\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvec3 direction = normalize( vWorldDirection );\n\n\t\t\t\t\tvec2 sampleUV = equirectUv( direction );\n\n\t\t\t\t\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\n\t\t\t\t}\n\t\t\t`},i=new nI(5,5,5),s=new wm({name:\"CubemapFromEquirect\",uniforms:Xx(r.uniforms),vertexShader:r.vertexShader,fragmentShader:r.fragmentShader,side:zo,blending:lm});s.uniforms.tEquirect.value=n;const o=new mc(i,s),l=n.minFilter;return n.minFilter===Rf&&(n.minFilter=ec),new s0e(1,10,this).update(e,o),n.minFilter=l,o.geometry.dispose(),o.material.dispose(),this}clear(e,n=!0,r=!0,i=!0){const s=e.getRenderTarget();for(let o=0;o<6;o++)e.setRenderTarget(this,o),e.clear(n,r,i);e.setRenderTarget(s)}},vx=class extends vl{constructor(){super(),this.isGroup=!0,this.type=\"Group\"}};const l0e={type:\"move\"};let eE=class{constructor(){this._targetRay=null,this._grip=null,this._hand=null}getHandSpace(){return this._hand===null&&(this._hand=new vx,this._hand.matrixAutoUpdate=!1,this._hand.visible=!1,this._hand.joints={},this._hand.inputState={pinching:!1}),this._hand}getTargetRaySpace(){return this._targetRay===null&&(this._targetRay=new vx,this._targetRay.matrixAutoUpdate=!1,this._targetRay.visible=!1,this._targetRay.hasLinearVelocity=!1,this._targetRay.linearVelocity=new Ct,this._targetRay.hasAngularVelocity=!1,this._targetRay.angularVelocity=new Ct),this._targetRay}getGripSpace(){return this._grip===null&&(this._grip=new vx,this._grip.matrixAutoUpdate=!1,this._grip.visible=!1,this._grip.hasLinearVelocity=!1,this._grip.linearVelocity=new Ct,this._grip.hasAngularVelocity=!1,this._grip.angularVelocity=new Ct),this._grip}dispatchEvent(e){return this._targetRay!==null&&this._targetRay.dispatchEvent(e),this._grip!==null&&this._grip.dispatchEvent(e),this._hand!==null&&this._hand.dispatchEvent(e),this}connect(e){if(e&&e.hand){const n=this._hand;if(n)for(const r of e.hand.values())this._getHandJoint(n,r)}return this.dispatchEvent({type:\"connected\",data:e}),this}disconnect(e){return this.dispatchEvent({type:\"disconnected\",data:e}),this._targetRay!==null&&(this._targetRay.visible=!1),this._grip!==null&&(this._grip.visible=!1),this._hand!==null&&(this._hand.visible=!1),this}update(e,n,r){let i=null,s=null,o=null;const l=this._targetRay,c=this._grip,d=this._hand;if(e&&n.session.visibilityState!==\"visible-blurred\"){if(d&&e.hand){o=!0;for(const v of e.hand.values()){const b=n.getJointPose(v,r),g=this._getHandJoint(d,v);b!==null&&(g.matrix.fromArray(b.transform.matrix),g.matrix.decompose(g.position,g.rotation,g.scale),g.matrixWorldNeedsUpdate=!0,g.jointRadius=b.radius),g.visible=b!==null}const u=d.joints[\"index-finger-tip\"],m=d.joints[\"thumb-tip\"],p=u.position.distanceTo(m.position),f=.02,y=.005;d.inputState.pinching&&p>f+y?(d.inputState.pinching=!1,this.dispatchEvent({type:\"pinchend\",handedness:e.handedness,target:this})):!d.inputState.pinching&&p<=f-y&&(d.inputState.pinching=!0,this.dispatchEvent({type:\"pinchstart\",handedness:e.handedness,target:this}))}else c!==null&&e.gripSpace&&(s=n.getPose(e.gripSpace,r),s!==null&&(c.matrix.fromArray(s.transform.matrix),c.matrix.decompose(c.position,c.rotation,c.scale),c.matrixWorldNeedsUpdate=!0,s.linearVelocity?(c.hasLinearVelocity=!0,c.linearVelocity.copy(s.linearVelocity)):c.hasLinearVelocity=!1,s.angularVelocity?(c.hasAngularVelocity=!0,c.angularVelocity.copy(s.angularVelocity)):c.hasAngularVelocity=!1));l!==null&&(i=n.getPose(e.targetRaySpace,r),i===null&&s!==null&&(i=s),i!==null&&(l.matrix.fromArray(i.transform.matrix),l.matrix.decompose(l.position,l.rotation,l.scale),l.matrixWorldNeedsUpdate=!0,i.linearVelocity?(l.hasLinearVelocity=!0,l.linearVelocity.copy(i.linearVelocity)):l.hasLinearVelocity=!1,i.angularVelocity?(l.hasAngularVelocity=!0,l.angularVelocity.copy(i.angularVelocity)):l.hasAngularVelocity=!1,this.dispatchEvent(l0e)))}return l!==null&&(l.visible=i!==null),c!==null&&(c.visible=s!==null),d!==null&&(d.visible=o!==null),this}_getHandJoint(e,n){if(e.joints[n.jointName]===void 0){const r=new vx;r.matrixAutoUpdate=!1,r.visible=!1,e.joints[n.jointName]=r,e.add(r)}return e.joints[n.jointName]}},c0e=class extends vl{constructor(){super(),this.isScene=!0,this.type=\"Scene\",this.background=null,this.environment=null,this.fog=null,this.backgroundBlurriness=0,this.backgroundIntensity=1,this.backgroundRotation=new xp,this.environmentIntensity=1,this.environmentRotation=new xp,this.overrideMaterial=null,typeof __THREE_DEVTOOLS__<\"u\"&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent(\"observe\",{detail:this}))}copy(e,n){return super.copy(e,n),e.background!==null&&(this.background=e.background.clone()),e.environment!==null&&(this.environment=e.environment.clone()),e.fog!==null&&(this.fog=e.fog.clone()),this.backgroundBlurriness=e.backgroundBlurriness,this.backgroundIntensity=e.backgroundIntensity,this.backgroundRotation.copy(e.backgroundRotation),this.environmentIntensity=e.environmentIntensity,this.environmentRotation.copy(e.environmentRotation),e.overrideMaterial!==null&&(this.overrideMaterial=e.overrideMaterial.clone()),this.matrixAutoUpdate=e.matrixAutoUpdate,this}toJSON(e){const n=super.toJSON(e);return this.fog!==null&&(n.object.fog=this.fog.toJSON()),this.backgroundBlurriness>0&&(n.object.backgroundBlurriness=this.backgroundBlurriness),this.backgroundIntensity!==1&&(n.object.backgroundIntensity=this.backgroundIntensity),n.object.backgroundRotation=this.backgroundRotation.toArray(),this.environmentIntensity!==1&&(n.object.environmentIntensity=this.environmentIntensity),n.object.environmentRotation=this.environmentRotation.toArray(),n}},d0e=class extends nd{constructor(e=null,n=1,r=1,i,s,o,l,c,d=hl,u=hl,m,p){super(null,o,l,c,d,u,i,s,m,p),this.isDataTexture=!0,this.image={data:e,width:n,height:r},this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1}};const tE=new Ct,u0e=new Ct,m0e=new ir;let Ph=class{constructor(e=new Ct(1,0,0),n=0){this.isPlane=!0,this.normal=e,this.constant=n}set(e,n){return this.normal.copy(e),this.constant=n,this}setComponents(e,n,r,i){return this.normal.set(e,n,r),this.constant=i,this}setFromNormalAndCoplanarPoint(e,n){return this.normal.copy(e),this.constant=-n.dot(this.normal),this}setFromCoplanarPoints(e,n,r){const i=tE.subVectors(r,n).cross(u0e.subVectors(e,n)).normalize();return this.setFromNormalAndCoplanarPoint(i,e),this}copy(e){return this.normal.copy(e.normal),this.constant=e.constant,this}normalize(){const e=1/this.normal.length();return this.normal.multiplyScalar(e),this.constant*=e,this}negate(){return this.constant*=-1,this.normal.negate(),this}distanceToPoint(e){return this.normal.dot(e)+this.constant}distanceToSphere(e){return this.distanceToPoint(e.center)-e.radius}projectPoint(e,n){return n.copy(e).addScaledVector(this.normal,-this.distanceToPoint(e))}intersectLine(e,n){const r=e.delta(tE),i=this.normal.dot(r);if(i===0)return this.distanceToPoint(e.start)===0?n.copy(e.start):null;const s=-(e.start.dot(this.normal)+this.constant)/i;return s<0||s>1?null:n.copy(e.start).addScaledVector(r,s)}intersectsLine(e){const n=this.distanceToPoint(e.start),r=this.distanceToPoint(e.end);return n<0&&r>0||r<0&&n>0}intersectsBox(e){return e.intersectsPlane(this)}intersectsSphere(e){return e.intersectsPlane(this)}coplanarPoint(e){return e.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(e,n){const r=n||m0e.getNormalMatrix(e),i=this.coplanarPoint(tE).applyMatrix4(e),s=this.normal.applyMatrix3(r).normalize();return this.constant=-i.dot(s),this}translate(e){return this.constant-=e.dot(this.normal),this}equals(e){return e.normal.equals(this.normal)&&e.constant===this.constant}clone(){return new this.constructor().copy(this)}};const uf=new YP,h0e=new nr(.5,.5),f1=new Ct;let rI=class{constructor(e=new Ph,n=new Ph,r=new Ph,i=new Ph,s=new Ph,o=new Ph){this.planes=[e,n,r,i,s,o]}set(e,n,r,i,s,o){const l=this.planes;return l[0].copy(e),l[1].copy(n),l[2].copy(r),l[3].copy(i),l[4].copy(s),l[5].copy(o),this}copy(e){const n=this.planes;for(let r=0;r<6;r++)n[r].copy(e.planes[r]);return this}setFromProjectionMatrix(e,n=Md,r=!1){const i=this.planes,s=e.elements,o=s[0],l=s[1],c=s[2],d=s[3],u=s[4],m=s[5],p=s[6],f=s[7],y=s[8],v=s[9],b=s[10],g=s[11],_=s[12],C=s[13],P=s[14],N=s[15];if(i[0].setComponents(d-o,f-u,g-y,N-_).normalize(),i[1].setComponents(d+o,f+u,g+y,N+_).normalize(),i[2].setComponents(d+l,f+m,g+v,N+C).normalize(),i[3].setComponents(d-l,f-m,g-v,N-C).normalize(),r)i[4].setComponents(c,p,b,P).normalize(),i[5].setComponents(d-c,f-p,g-b,N-P).normalize();else if(i[4].setComponents(d-c,f-p,g-b,N-P).normalize(),n===Md)i[5].setComponents(d+c,f+p,g+b,N+P).normalize();else if(n===AN)i[5].setComponents(c,p,b,P).normalize();else throw new Error(\"THREE.Frustum.setFromProjectionMatrix(): Invalid coordinate system: \"+n);return this}intersectsObject(e){if(e.boundingSphere!==void 0)e.boundingSphere===null&&e.computeBoundingSphere(),uf.copy(e.boundingSphere).applyMatrix4(e.matrixWorld);else{const n=e.geometry;n.boundingSphere===null&&n.computeBoundingSphere(),uf.copy(n.boundingSphere).applyMatrix4(e.matrixWorld)}return this.intersectsSphere(uf)}intersectsSprite(e){uf.center.set(0,0,0);const n=h0e.distanceTo(e.center);return uf.radius=.7071067811865476+n,uf.applyMatrix4(e.matrixWorld),this.intersectsSphere(uf)}intersectsSphere(e){const n=this.planes,r=e.center,i=-e.radius;for(let s=0;s<6;s++)if(n[s].distanceToPoint(r)<i)return!1;return!0}intersectsBox(e){const n=this.planes;for(let r=0;r<6;r++){const i=n[r];if(f1.x=i.normal.x>0?e.max.x:e.min.x,f1.y=i.normal.y>0?e.max.y:e.min.y,f1.z=i.normal.z>0?e.max.z:e.min.z,i.distanceToPoint(f1)<0)return!1}return!0}containsPoint(e){const n=this.planes;for(let r=0;r<6;r++)if(n[r].distanceToPoint(e)<0)return!1;return!0}clone(){return new this.constructor().copy(this)}},RZ=class extends ky{constructor(e){super(),this.isLineBasicMaterial=!0,this.type=\"LineBasicMaterial\",this.color=new or(16777215),this.map=null,this.linewidth=1,this.linecap=\"round\",this.linejoin=\"round\",this.fog=!0,this.setValues(e)}copy(e){return super.copy(e),this.color.copy(e.color),this.map=e.map,this.linewidth=e.linewidth,this.linecap=e.linecap,this.linejoin=e.linejoin,this.fog=e.fog,this}};const MN=new Ct,EN=new Ct,u6=new _i,Iv=new eI,g1=new YP,nE=new Ct,m6=new Ct;let p0e=class extends vl{constructor(e=new uc,n=new RZ){super(),this.isLine=!0,this.type=\"Line\",this.geometry=e,this.material=n,this.morphTargetDictionary=void 0,this.morphTargetInfluences=void 0,this.updateMorphTargets()}copy(e,n){return super.copy(e,n),this.material=Array.isArray(e.material)?e.material.slice():e.material,this.geometry=e.geometry,this}computeLineDistances(){const e=this.geometry;if(e.index===null){const n=e.attributes.position,r=[0];for(let i=1,s=n.count;i<s;i++)MN.fromBufferAttribute(n,i-1),EN.fromBufferAttribute(n,i),r[i]=r[i-1],r[i]+=MN.distanceTo(EN);e.setAttribute(\"lineDistance\",new pl(r,1))}else qn(\"Line.computeLineDistances(): Computation only possible with non-indexed BufferGeometry.\");return this}raycast(e,n){const r=this.geometry,i=this.matrixWorld,s=e.params.Line.threshold,o=r.drawRange;if(r.boundingSphere===null&&r.computeBoundingSphere(),g1.copy(r.boundingSphere),g1.applyMatrix4(i),g1.radius+=s,e.ray.intersectsSphere(g1)===!1)return;u6.copy(i).invert(),Iv.copy(e.ray).applyMatrix4(u6);const l=s/((this.scale.x+this.scale.y+this.scale.z)/3),c=l*l,d=this.isLineSegments?2:1,u=r.index,p=r.attributes.position;if(u!==null){const f=Math.max(0,o.start),y=Math.min(u.count,o.start+o.count);for(let v=f,b=y-1;v<b;v+=d){const g=u.getX(v),_=u.getX(v+1),C=b1(this,e,Iv,c,g,_,v);C&&n.push(C)}if(this.isLineLoop){const v=u.getX(y-1),b=u.getX(f),g=b1(this,e,Iv,c,v,b,y-1);g&&n.push(g)}}else{const f=Math.max(0,o.start),y=Math.min(p.count,o.start+o.count);for(let v=f,b=y-1;v<b;v+=d){const g=b1(this,e,Iv,c,v,v+1,v);g&&n.push(g)}if(this.isLineLoop){const v=b1(this,e,Iv,c,y-1,f,y-1);v&&n.push(v)}}}updateMorphTargets(){const n=this.geometry.morphAttributes,r=Object.keys(n);if(r.length>0){const i=n[r[0]];if(i!==void 0){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let s=0,o=i.length;s<o;s++){const l=i[s].name||String(s);this.morphTargetInfluences.push(0),this.morphTargetDictionary[l]=s}}}}};function b1(t,e,n,r,i,s,o){const l=t.geometry.attributes.position;if(MN.fromBufferAttribute(l,i),EN.fromBufferAttribute(l,s),n.distanceSqToSegment(MN,EN,nE,m6)>r)return;nE.applyMatrix4(t.matrixWorld);const d=e.ray.origin.distanceTo(nE);if(!(d<e.near||d>e.far))return{distance:d,point:m6.clone().applyMatrix4(t.matrixWorld),index:o,face:null,faceIndex:null,barycoord:null,object:t}}const h6=new Ct,p6=new Ct;let f0e=class extends p0e{constructor(e,n){super(e,n),this.isLineSegments=!0,this.type=\"LineSegments\"}computeLineDistances(){const e=this.geometry;if(e.index===null){const n=e.attributes.position,r=[];for(let i=0,s=n.count;i<s;i+=2)h6.fromBufferAttribute(n,i),p6.fromBufferAttribute(n,i+1),r[i]=i===0?0:r[i-1],r[i+1]=r[i]+h6.distanceTo(p6);e.setAttribute(\"lineDistance\",new pl(r,1))}else qn(\"LineSegments.computeLineDistances(): Computation only possible with non-indexed BufferGeometry.\");return this}},LZ=class extends nd{constructor(e,n,r=fg,i,s,o,l=hl,c=hl,d,u=dw,m=1){if(u!==dw&&u!==uw)throw new Error(\"DepthTexture format must be either THREE.DepthFormat or THREE.DepthStencilFormat\");const p={width:e,height:n,depth:m};super(p,i,s,o,l,c,u,r,d),this.isDepthTexture=!0,this.flipY=!1,this.generateMipmaps=!1,this.compareFunction=null}copy(e){return super.copy(e),this.source=new JO(Object.assign({},e.image)),this.compareFunction=e.compareFunction,this}toJSON(e){const n=super.toJSON(e);return this.compareFunction!==null&&(n.compareFunction=this.compareFunction),n}};class OZ extends nd{constructor(e=null){super(),this.sourceTexture=e,this.isExternalTexture=!0}copy(e){return super.copy(e),this.sourceTexture=e.sourceTexture,this}}let aI=class IZ extends uc{constructor(e=1,n=1,r=1,i=1){super(),this.type=\"PlaneGeometry\",this.parameters={width:e,height:n,widthSegments:r,heightSegments:i};const s=e/2,o=n/2,l=Math.floor(r),c=Math.floor(i),d=l+1,u=c+1,m=e/l,p=n/c,f=[],y=[],v=[],b=[];for(let g=0;g<u;g++){const _=g*p-o;for(let C=0;C<d;C++){const P=C*m-s;y.push(P,-_,0),v.push(0,0,1),b.push(C/l),b.push(1-g/c)}}for(let g=0;g<c;g++)for(let _=0;_<l;_++){const C=_+d*g,P=_+d*(g+1),N=_+1+d*(g+1),A=_+1+d*g;f.push(C,P,A),f.push(P,N,A)}this.setIndex(f),this.setAttribute(\"position\",new pl(y,3)),this.setAttribute(\"normal\",new pl(v,3)),this.setAttribute(\"uv\",new pl(b,2))}copy(e){return super.copy(e),this.parameters=Object.assign({},e.parameters),this}static fromJSON(e){return new IZ(e.width,e.height,e.widthSegments,e.heightSegments)}};class zZ extends ky{constructor(e){super(),this.isMeshPhongMaterial=!0,this.type=\"MeshPhongMaterial\",this.color=new or(16777215),this.specular=new or(1118481),this.shininess=30,this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new or(0),this.emissiveIntensity=1,this.emissiveMap=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalMapType=bZ,this.normalScale=new nr(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.envMapRotation=new xp,this.combine=VO,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap=\"round\",this.wireframeLinejoin=\"round\",this.flatShading=!1,this.fog=!0,this.setValues(e)}copy(e){return super.copy(e),this.color.copy(e.color),this.specular.copy(e.specular),this.shininess=e.shininess,this.map=e.map,this.lightMap=e.lightMap,this.lightMapIntensity=e.lightMapIntensity,this.aoMap=e.aoMap,this.aoMapIntensity=e.aoMapIntensity,this.emissive.copy(e.emissive),this.emissiveMap=e.emissiveMap,this.emissiveIntensity=e.emissiveIntensity,this.bumpMap=e.bumpMap,this.bumpScale=e.bumpScale,this.normalMap=e.normalMap,this.normalMapType=e.normalMapType,this.normalScale.copy(e.normalScale),this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this.specularMap=e.specularMap,this.alphaMap=e.alphaMap,this.envMap=e.envMap,this.envMapRotation.copy(e.envMapRotation),this.combine=e.combine,this.reflectivity=e.reflectivity,this.refractionRatio=e.refractionRatio,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.wireframeLinecap=e.wireframeLinecap,this.wireframeLinejoin=e.wireframeLinejoin,this.flatShading=e.flatShading,this.fog=e.fog,this}}let g0e=class extends ky{constructor(e){super(),this.isMeshDepthMaterial=!0,this.type=\"MeshDepthMaterial\",this.depthPacking=Nve,this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.wireframe=!1,this.wireframeLinewidth=1,this.setValues(e)}copy(e){return super.copy(e),this.depthPacking=e.depthPacking,this.map=e.map,this.alphaMap=e.alphaMap,this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this}},b0e=class extends ky{constructor(e){super(),this.isMeshDistanceMaterial=!0,this.type=\"MeshDistanceMaterial\",this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.setValues(e)}copy(e){return super.copy(e),this.map=e.map,this.alphaMap=e.alphaMap,this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this}};const f6={enabled:!1,files:{},add:function(t,e){this.enabled!==!1&&(this.files[t]=e)},get:function(t){if(this.enabled!==!1)return this.files[t]},remove:function(t){delete this.files[t]},clear:function(){this.files={}}};class x0e{constructor(e,n,r){const i=this;let s=!1,o=0,l=0,c;const d=[];this.onStart=void 0,this.onLoad=e,this.onProgress=n,this.onError=r,this._abortController=null,this.itemStart=function(u){l++,s===!1&&i.onStart!==void 0&&i.onStart(u,o,l),s=!0},this.itemEnd=function(u){o++,i.onProgress!==void 0&&i.onProgress(u,o,l),o===l&&(s=!1,i.onLoad!==void 0&&i.onLoad())},this.itemError=function(u){i.onError!==void 0&&i.onError(u)},this.resolveURL=function(u){return c?c(u):u},this.setURLModifier=function(u){return c=u,this},this.addHandler=function(u,m){return d.push(u,m),this},this.removeHandler=function(u){const m=d.indexOf(u);return m!==-1&&d.splice(m,2),this},this.getHandler=function(u){for(let m=0,p=d.length;m<p;m+=2){const f=d[m],y=d[m+1];if(f.global&&(f.lastIndex=0),f.test(u))return y}return null},this.abort=function(){return this.abortController.abort(),this._abortController=null,this}}get abortController(){return this._abortController||(this._abortController=new AbortController),this._abortController}}const y0e=new x0e;class iI{constructor(e){this.manager=e!==void 0?e:y0e,this.crossOrigin=\"anonymous\",this.withCredentials=!1,this.path=\"\",this.resourcePath=\"\",this.requestHeader={}}load(){}loadAsync(e,n){const r=this;return new Promise(function(i,s){r.load(e,i,n,s)})}parse(){}setCrossOrigin(e){return this.crossOrigin=e,this}setWithCredentials(e){return this.withCredentials=e,this}setPath(e){return this.path=e,this}setResourcePath(e){return this.resourcePath=e,this}setRequestHeader(e){return this.requestHeader=e,this}abort(){return this}}iI.DEFAULT_MATERIAL_NAME=\"__DEFAULT\";const Tu={};class v0e extends Error{constructor(e,n){super(e),this.response=n}}class w0e extends iI{constructor(e){super(e),this.mimeType=\"\",this.responseType=\"\",this._abortController=new AbortController}load(e,n,r,i){e===void 0&&(e=\"\"),this.path!==void 0&&(e=this.path+e),e=this.manager.resolveURL(e);const s=f6.get(`file:${e}`);if(s!==void 0)return this.manager.itemStart(e),setTimeout(()=>{n&&n(s),this.manager.itemEnd(e)},0),s;if(Tu[e]!==void 0){Tu[e].push({onLoad:n,onProgress:r,onError:i});return}Tu[e]=[],Tu[e].push({onLoad:n,onProgress:r,onError:i});const o=new Request(e,{headers:new Headers(this.requestHeader),credentials:this.withCredentials?\"include\":\"same-origin\",signal:typeof AbortSignal.any==\"function\"?AbortSignal.any([this._abortController.signal,this.manager.abortController.signal]):this._abortController.signal}),l=this.mimeType,c=this.responseType;fetch(o).then(d=>{if(d.status===200||d.status===0){if(d.status===0&&qn(\"FileLoader: HTTP Status 0 received.\"),typeof ReadableStream>\"u\"||d.body===void 0||d.body.getReader===void 0)return d;const u=Tu[e],m=d.body.getReader(),p=d.headers.get(\"X-File-Size\")||d.headers.get(\"Content-Length\"),f=p?parseInt(p):0,y=f!==0;let v=0;const b=new ReadableStream({start(g){_();function _(){m.read().then(({done:C,value:P})=>{if(C)g.close();else{v+=P.byteLength;const N=new ProgressEvent(\"progress\",{lengthComputable:y,loaded:v,total:f});for(let A=0,T=u.length;A<T;A++){const F=u[A];F.onProgress&&F.onProgress(N)}g.enqueue(P),_()}},C=>{g.error(C)})}}});return new Response(b)}else throw new v0e(`fetch for \"${d.url}\" responded with ${d.status}: ${d.statusText}`,d)}).then(d=>{switch(c){case\"arraybuffer\":return d.arrayBuffer();case\"blob\":return d.blob();case\"document\":return d.text().then(u=>new DOMParser().parseFromString(u,l));case\"json\":return d.json();default:if(l===\"\")return d.text();{const m=/charset=\"?([^;\"\\s]*)\"?/i.exec(l),p=m&&m[1]?m[1].toLowerCase():void 0,f=new TextDecoder(p);return d.arrayBuffer().then(y=>f.decode(y))}}}).then(d=>{f6.add(`file:${e}`,d);const u=Tu[e];delete Tu[e];for(let m=0,p=u.length;m<p;m++){const f=u[m];f.onLoad&&f.onLoad(d)}}).catch(d=>{const u=Tu[e];if(u===void 0)throw this.manager.itemError(e),d;delete Tu[e];for(let m=0,p=u.length;m<p;m++){const f=u[m];f.onError&&f.onError(d)}this.manager.itemError(e)}).finally(()=>{this.manager.itemEnd(e)}),this.manager.itemStart(e)}setResponseType(e){return this.responseType=e,this}setMimeType(e){return this.mimeType=e,this}abort(){return this._abortController.abort(),this._abortController=new AbortController,this}}let UZ=class extends vl{constructor(e,n=1){super(),this.isLight=!0,this.type=\"Light\",this.color=new or(e),this.intensity=n}dispose(){}copy(e,n){return super.copy(e,n),this.color.copy(e.color),this.intensity=e.intensity,this}toJSON(e){const n=super.toJSON(e);return n.object.color=this.color.getHex(),n.object.intensity=this.intensity,this.groundColor!==void 0&&(n.object.groundColor=this.groundColor.getHex()),this.distance!==void 0&&(n.object.distance=this.distance),this.angle!==void 0&&(n.object.angle=this.angle),this.decay!==void 0&&(n.object.decay=this.decay),this.penumbra!==void 0&&(n.object.penumbra=this.penumbra),this.shadow!==void 0&&(n.object.shadow=this.shadow.toJSON()),this.target!==void 0&&(n.object.target=this.target.uuid),n}};const rE=new _i,g6=new Ct,b6=new Ct;let S0e=class{constructor(e){this.camera=e,this.intensity=1,this.bias=0,this.normalBias=0,this.radius=1,this.blurSamples=8,this.mapSize=new nr(512,512),this.mapType=Hd,this.map=null,this.mapPass=null,this.matrix=new _i,this.autoUpdate=!0,this.needsUpdate=!1,this._frustum=new rI,this._frameExtents=new nr(1,1),this._viewportCount=1,this._viewports=[new yi(0,0,1,1)]}getViewportCount(){return this._viewportCount}getFrustum(){return this._frustum}updateMatrices(e){const n=this.camera,r=this.matrix;g6.setFromMatrixPosition(e.matrixWorld),n.position.copy(g6),b6.setFromMatrixPosition(e.target.matrixWorld),n.lookAt(b6),n.updateMatrixWorld(),rE.multiplyMatrices(n.projectionMatrix,n.matrixWorldInverse),this._frustum.setFromProjectionMatrix(rE,n.coordinateSystem,n.reversedDepth),n.reversedDepth?r.set(.5,0,0,.5,0,.5,0,.5,0,0,1,0,0,0,0,1):r.set(.5,0,0,.5,0,.5,0,.5,0,0,.5,.5,0,0,0,1),r.multiply(rE)}getViewport(e){return this._viewports[e]}getFrameExtents(){return this._frameExtents}dispose(){this.map&&this.map.dispose(),this.mapPass&&this.mapPass.dispose()}copy(e){return this.camera=e.camera.clone(),this.intensity=e.intensity,this.bias=e.bias,this.radius=e.radius,this.autoUpdate=e.autoUpdate,this.needsUpdate=e.needsUpdate,this.normalBias=e.normalBias,this.blurSamples=e.blurSamples,this.mapSize.copy(e.mapSize),this}clone(){return new this.constructor().copy(this)}toJSON(){const e={};return this.intensity!==1&&(e.intensity=this.intensity),this.bias!==0&&(e.bias=this.bias),this.normalBias!==0&&(e.normalBias=this.normalBias),this.radius!==1&&(e.radius=this.radius),(this.mapSize.x!==512||this.mapSize.y!==512)&&(e.mapSize=this.mapSize.toArray()),e.camera=this.camera.toJSON(!1).object,delete e.camera.matrix,e}},BZ=class extends DZ{constructor(e=-1,n=1,r=1,i=-1,s=.1,o=2e3){super(),this.isOrthographicCamera=!0,this.type=\"OrthographicCamera\",this.zoom=1,this.view=null,this.left=e,this.right=n,this.top=r,this.bottom=i,this.near=s,this.far=o,this.updateProjectionMatrix()}copy(e,n){return super.copy(e,n),this.left=e.left,this.right=e.right,this.top=e.top,this.bottom=e.bottom,this.near=e.near,this.far=e.far,this.zoom=e.zoom,this.view=e.view===null?null:Object.assign({},e.view),this}setViewOffset(e,n,r,i,s,o){this.view===null&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=e,this.view.fullHeight=n,this.view.offsetX=r,this.view.offsetY=i,this.view.width=s,this.view.height=o,this.updateProjectionMatrix()}clearViewOffset(){this.view!==null&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const e=(this.right-this.left)/(2*this.zoom),n=(this.top-this.bottom)/(2*this.zoom),r=(this.right+this.left)/2,i=(this.top+this.bottom)/2;let s=r-e,o=r+e,l=i+n,c=i-n;if(this.view!==null&&this.view.enabled){const d=(this.right-this.left)/this.view.fullWidth/this.zoom,u=(this.top-this.bottom)/this.view.fullHeight/this.zoom;s+=d*this.view.offsetX,o=s+d*this.view.width,l-=u*this.view.offsetY,c=l-u*this.view.height}this.projectionMatrix.makeOrthographic(s,o,l,c,this.near,this.far,this.coordinateSystem,this.reversedDepth),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(e){const n=super.toJSON(e);return n.object.zoom=this.zoom,n.object.left=this.left,n.object.right=this.right,n.object.top=this.top,n.object.bottom=this.bottom,n.object.near=this.near,n.object.far=this.far,this.view!==null&&(n.object.view=Object.assign({},this.view)),n}};class _0e extends S0e{constructor(){super(new BZ(-5,5,5,-5,.5,500)),this.isDirectionalLightShadow=!0}}class x6 extends UZ{constructor(e,n){super(e,n),this.isDirectionalLight=!0,this.type=\"DirectionalLight\",this.position.copy(vl.DEFAULT_UP),this.updateMatrix(),this.target=new vl,this.shadow=new _0e}dispose(){this.shadow.dispose()}copy(e){return super.copy(e),this.target=e.target.clone(),this.shadow=e.shadow.clone(),this}}let k0e=class extends UZ{constructor(e,n){super(e,n),this.isAmbientLight=!0,this.type=\"AmbientLight\"}},N0e=class extends Kl{constructor(e=[]){super(),this.isArrayCamera=!0,this.isMultiViewCamera=!1,this.cameras=e}},y6=class{constructor(e=1,n=0,r=0){this.radius=e,this.phi=n,this.theta=r}set(e,n,r){return this.radius=e,this.phi=n,this.theta=r,this}copy(e){return this.radius=e.radius,this.phi=e.phi,this.theta=e.theta,this}makeSafe(){return this.phi=fr(this.phi,1e-6,Math.PI-1e-6),this}setFromVector3(e){return this.setFromCartesianCoords(e.x,e.y,e.z)}setFromCartesianCoords(e,n,r){return this.radius=Math.sqrt(e*e+n*n+r*r),this.radius===0?(this.theta=0,this.phi=0):(this.theta=Math.atan2(e,r),this.phi=Math.acos(fr(n/this.radius,-1,1))),this}clone(){return new this.constructor().copy(this)}};class C0e extends f0e{constructor(e=10,n=10,r=4473924,i=8947848){r=new or(r),i=new or(i);const s=n/2,o=e/n,l=e/2,c=[],d=[];for(let p=0,f=0,y=-l;p<=n;p++,y+=o){c.push(-l,0,y,l,0,y),c.push(y,0,-l,y,0,l);const v=p===s?r:i;v.toArray(d,f),f+=3,v.toArray(d,f),f+=3,v.toArray(d,f),f+=3,v.toArray(d,f),f+=3}const u=new uc;u.setAttribute(\"position\",new pl(c,3)),u.setAttribute(\"color\",new pl(d,3));const m=new RZ({vertexColors:!0,toneMapped:!1});super(u,m),this.type=\"GridHelper\"}dispose(){this.geometry.dispose(),this.material.dispose()}}class P0e extends Ug{constructor(e,n=null){super(),this.object=e,this.domElement=n,this.enabled=!0,this.state=-1,this.keys={},this.mouseButtons={LEFT:null,MIDDLE:null,RIGHT:null},this.touches={ONE:null,TWO:null}}connect(e){if(e===void 0){qn(\"Controls: connect() now requires an element.\");return}this.domElement!==null&&this.disconnect(),this.domElement=e}disconnect(){}dispose(){}update(){}}function v6(t,e,n,r){const i=T0e(r);switch(n){case pZ:return t*e;case gZ:return t*e/i.components*i.byteLength;case XO:return t*e/i.components*i.byteLength;case YO:return t*e*2/i.components*i.byteLength;case QO:return t*e*2/i.components*i.byteLength;case fZ:return t*e*3/i.components*i.byteLength;case Gc:return t*e*4/i.components*i.byteLength;case ZO:return t*e*4/i.components*i.byteLength;case Uk:case Bk:return Math.floor((t+3)/4)*Math.floor((e+3)/4)*8;case Hk:case qk:return Math.floor((t+3)/4)*Math.floor((e+3)/4)*16;case jF:case EF:return Math.max(t,16)*Math.max(e,8)/4;case AF:case MF:return Math.max(t,8)*Math.max(e,8)/2;case DF:case FF:return Math.floor((t+3)/4)*Math.floor((e+3)/4)*8;case RF:return Math.floor((t+3)/4)*Math.floor((e+3)/4)*16;case LF:return Math.floor((t+3)/4)*Math.floor((e+3)/4)*16;case OF:return Math.floor((t+4)/5)*Math.floor((e+3)/4)*16;case IF:return Math.floor((t+4)/5)*Math.floor((e+4)/5)*16;case zF:return Math.floor((t+5)/6)*Math.floor((e+4)/5)*16;case UF:return Math.floor((t+5)/6)*Math.floor((e+5)/6)*16;case BF:return Math.floor((t+7)/8)*Math.floor((e+4)/5)*16;case HF:return Math.floor((t+7)/8)*Math.floor((e+5)/6)*16;case qF:return Math.floor((t+7)/8)*Math.floor((e+7)/8)*16;case $F:return Math.floor((t+9)/10)*Math.floor((e+4)/5)*16;case VF:return Math.floor((t+9)/10)*Math.floor((e+5)/6)*16;case GF:return Math.floor((t+9)/10)*Math.floor((e+7)/8)*16;case WF:return Math.floor((t+9)/10)*Math.floor((e+9)/10)*16;case KF:return Math.floor((t+11)/12)*Math.floor((e+9)/10)*16;case XF:return Math.floor((t+11)/12)*Math.floor((e+11)/12)*16;case YF:case QF:case ZF:return Math.ceil(t/4)*Math.ceil(e/4)*16;case JF:case eR:return Math.ceil(t/4)*Math.ceil(e/4)*8;case tR:case nR:return Math.ceil(t/4)*Math.ceil(e/4)*16}throw new Error(`Unable to determine texture byte length for ${n} format.`)}function T0e(t){switch(t){case Hd:case dZ:return{byteLength:1,components:1};case lw:case uZ:case _y:return{byteLength:2,components:1};case WO:case KO:return{byteLength:2,components:4};case fg:case GO:case em:return{byteLength:4,components:1};case mZ:case hZ:return{byteLength:4,components:3}}throw new Error(`Unknown texture type ${t}.`)}typeof __THREE_DEVTOOLS__<\"u\"&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent(\"register\",{detail:{revision:$O}}));typeof window<\"u\"&&(window.__THREE__?qn(\"WARNING: Multiple instances of Three.js being imported.\"):window.__THREE__=$O);function HZ(){let t=null,e=!1,n=null,r=null;function i(s,o){n(s,o),r=t.requestAnimationFrame(i)}return{start:function(){e!==!0&&n!==null&&(r=t.requestAnimationFrame(i),e=!0)},stop:function(){t.cancelAnimationFrame(r),e=!1},setAnimationLoop:function(s){n=s},setContext:function(s){t=s}}}function A0e(t){const e=new WeakMap;function n(l,c){const d=l.array,u=l.usage,m=d.byteLength,p=t.createBuffer();t.bindBuffer(c,p),t.bufferData(c,d,u),l.onUploadCallback();let f;if(d instanceof Float32Array)f=t.FLOAT;else if(typeof Float16Array<\"u\"&&d instanceof Float16Array)f=t.HALF_FLOAT;else if(d instanceof Uint16Array)l.isFloat16BufferAttribute?f=t.HALF_FLOAT:f=t.UNSIGNED_SHORT;else if(d instanceof Int16Array)f=t.SHORT;else if(d instanceof Uint32Array)f=t.UNSIGNED_INT;else if(d instanceof Int32Array)f=t.INT;else if(d instanceof Int8Array)f=t.BYTE;else if(d instanceof Uint8Array)f=t.UNSIGNED_BYTE;else if(d instanceof Uint8ClampedArray)f=t.UNSIGNED_BYTE;else throw new Error(\"THREE.WebGLAttributes: Unsupported buffer data format: \"+d);return{buffer:p,type:f,bytesPerElement:d.BYTES_PER_ELEMENT,version:l.version,size:m}}function r(l,c,d){const u=c.array,m=c.updateRanges;if(t.bindBuffer(d,l),m.length===0)t.bufferSubData(d,0,u);else{m.sort((f,y)=>f.start-y.start);let p=0;for(let f=1;f<m.length;f++){const y=m[p],v=m[f];v.start<=y.start+y.count+1?y.count=Math.max(y.count,v.start+v.count-y.start):(++p,m[p]=v)}m.length=p+1;for(let f=0,y=m.length;f<y;f++){const v=m[f];t.bufferSubData(d,v.start*u.BYTES_PER_ELEMENT,u,v.start,v.count)}c.clearUpdateRanges()}c.onUploadCallback()}function i(l){return l.isInterleavedBufferAttribute&&(l=l.data),e.get(l)}function s(l){l.isInterleavedBufferAttribute&&(l=l.data);const c=e.get(l);c&&(t.deleteBuffer(c.buffer),e.delete(l))}function o(l,c){if(l.isInterleavedBufferAttribute&&(l=l.data),l.isGLBufferAttribute){const u=e.get(l);(!u||u.version<l.version)&&e.set(l,{buffer:l.buffer,type:l.type,bytesPerElement:l.elementSize,version:l.version});return}const d=e.get(l);if(d===void 0)e.set(l,n(l,c));else if(d.version<l.version){if(d.size!==l.array.byteLength)throw new Error(\"THREE.WebGLAttributes: The size of the buffer attribute's array buffer does not match the original size. Resizing buffer attributes is not supported.\");r(d.buffer,l,c),d.version=l.version}}return{get:i,remove:s,update:o}}var j0e=`#ifdef USE_ALPHAHASH\n\tif ( diffuseColor.a < getAlphaHashThreshold( vPosition ) ) discard;\n#endif`,M0e=`#ifdef USE_ALPHAHASH\n\tconst float ALPHA_HASH_SCALE = 0.05;\n\tfloat hash2D( vec2 value ) {\n\t\treturn fract( 1.0e4 * sin( 17.0 * value.x + 0.1 * value.y ) * ( 0.1 + abs( sin( 13.0 * value.y + value.x ) ) ) );\n\t}\n\tfloat hash3D( vec3 value ) {\n\t\treturn hash2D( vec2( hash2D( value.xy ), value.z ) );\n\t}\n\tfloat getAlphaHashThreshold( vec3 position ) {\n\t\tfloat maxDeriv = max(\n\t\t\tlength( dFdx( position.xyz ) ),\n\t\t\tlength( dFdy( position.xyz ) )\n\t\t);\n\t\tfloat pixScale = 1.0 / ( ALPHA_HASH_SCALE * maxDeriv );\n\t\tvec2 pixScales = vec2(\n\t\t\texp2( floor( log2( pixScale ) ) ),\n\t\t\texp2( ceil( log2( pixScale ) ) )\n\t\t);\n\t\tvec2 alpha = vec2(\n\t\t\thash3D( floor( pixScales.x * position.xyz ) ),\n\t\t\thash3D( floor( pixScales.y * position.xyz ) )\n\t\t);\n\t\tfloat lerpFactor = fract( log2( pixScale ) );\n\t\tfloat x = ( 1.0 - lerpFactor ) * alpha.x + lerpFactor * alpha.y;\n\t\tfloat a = min( lerpFactor, 1.0 - lerpFactor );\n\t\tvec3 cases = vec3(\n\t\t\tx * x / ( 2.0 * a * ( 1.0 - a ) ),\n\t\t\t( x - 0.5 * a ) / ( 1.0 - a ),\n\t\t\t1.0 - ( ( 1.0 - x ) * ( 1.0 - x ) / ( 2.0 * a * ( 1.0 - a ) ) )\n\t\t);\n\t\tfloat threshold = ( x < ( 1.0 - a ) )\n\t\t\t? ( ( x < a ) ? cases.x : cases.y )\n\t\t\t: cases.z;\n\t\treturn clamp( threshold , 1.0e-6, 1.0 );\n\t}\n#endif`,E0e=`#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, vAlphaMapUv ).g;\n#endif`,D0e=`#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif`,F0e=`#ifdef USE_ALPHATEST\n\t#ifdef ALPHA_TO_COVERAGE\n\tdiffuseColor.a = smoothstep( alphaTest, alphaTest + fwidth( diffuseColor.a ), diffuseColor.a );\n\tif ( diffuseColor.a == 0.0 ) discard;\n\t#else\n\tif ( diffuseColor.a < alphaTest ) discard;\n\t#endif\n#endif`,R0e=`#ifdef USE_ALPHATEST\n\tuniform float alphaTest;\n#endif`,L0e=`#ifdef USE_AOMAP\n\tfloat ambientOcclusion = ( texture2D( aoMap, vAoMapUv ).r - 1.0 ) * aoMapIntensity + 1.0;\n\treflectedLight.indirectDiffuse *= ambientOcclusion;\n\t#if defined( USE_CLEARCOAT ) \n\t\tclearcoatSpecularIndirect *= ambientOcclusion;\n\t#endif\n\t#if defined( USE_SHEEN ) \n\t\tsheenSpecularIndirect *= ambientOcclusion;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( STANDARD )\n\t\tfloat dotNV = saturate( dot( geometryNormal, geometryViewDir ) );\n\t\treflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.roughness );\n\t#endif\n#endif`,O0e=`#ifdef USE_AOMAP\n\tuniform sampler2D aoMap;\n\tuniform float aoMapIntensity;\n#endif`,I0e=`#ifdef USE_BATCHING\n\t#if ! defined( GL_ANGLE_multi_draw )\n\t#define gl_DrawID _gl_DrawID\n\tuniform int _gl_DrawID;\n\t#endif\n\tuniform highp sampler2D batchingTexture;\n\tuniform highp usampler2D batchingIdTexture;\n\tmat4 getBatchingMatrix( const in float i ) {\n\t\tint size = textureSize( batchingTexture, 0 ).x;\n\t\tint j = int( i ) * 4;\n\t\tint x = j % size;\n\t\tint y = j / size;\n\t\tvec4 v1 = texelFetch( batchingTexture, ivec2( x, y ), 0 );\n\t\tvec4 v2 = texelFetch( batchingTexture, ivec2( x + 1, y ), 0 );\n\t\tvec4 v3 = texelFetch( batchingTexture, ivec2( x + 2, y ), 0 );\n\t\tvec4 v4 = texelFetch( batchingTexture, ivec2( x + 3, y ), 0 );\n\t\treturn mat4( v1, v2, v3, v4 );\n\t}\n\tfloat getIndirectIndex( const in int i ) {\n\t\tint size = textureSize( batchingIdTexture, 0 ).x;\n\t\tint x = i % size;\n\t\tint y = i / size;\n\t\treturn float( texelFetch( batchingIdTexture, ivec2( x, y ), 0 ).r );\n\t}\n#endif\n#ifdef USE_BATCHING_COLOR\n\tuniform sampler2D batchingColorTexture;\n\tvec3 getBatchingColor( const in float i ) {\n\t\tint size = textureSize( batchingColorTexture, 0 ).x;\n\t\tint j = int( i );\n\t\tint x = j % size;\n\t\tint y = j / size;\n\t\treturn texelFetch( batchingColorTexture, ivec2( x, y ), 0 ).rgb;\n\t}\n#endif`,z0e=`#ifdef USE_BATCHING\n\tmat4 batchingMatrix = getBatchingMatrix( getIndirectIndex( gl_DrawID ) );\n#endif`,U0e=`vec3 transformed = vec3( position );\n#ifdef USE_ALPHAHASH\n\tvPosition = vec3( position );\n#endif`,B0e=`vec3 objectNormal = vec3( normal );\n#ifdef USE_TANGENT\n\tvec3 objectTangent = vec3( tangent.xyz );\n#endif`,H0e=`float G_BlinnPhong_Implicit( ) {\n\treturn 0.25;\n}\nfloat D_BlinnPhong( const in float shininess, const in float dotNH ) {\n\treturn RECIPROCAL_PI * ( shininess * 0.5 + 1.0 ) * pow( dotNH, shininess );\n}\nvec3 BRDF_BlinnPhong( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in vec3 specularColor, const in float shininess ) {\n\tvec3 halfDir = normalize( lightDir + viewDir );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat dotVH = saturate( dot( viewDir, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, 1.0, dotVH );\n\tfloat G = G_BlinnPhong_Implicit( );\n\tfloat D = D_BlinnPhong( shininess, dotNH );\n\treturn F * ( G * D );\n} // validated`,q0e=`#ifdef USE_IRIDESCENCE\n\tconst mat3 XYZ_TO_REC709 = mat3(\n\t\t 3.2404542, -0.9692660,  0.0556434,\n\t\t-1.5371385,  1.8760108, -0.2040259,\n\t\t-0.4985314,  0.0415560,  1.0572252\n\t);\n\tvec3 Fresnel0ToIor( vec3 fresnel0 ) {\n\t\tvec3 sqrtF0 = sqrt( fresnel0 );\n\t\treturn ( vec3( 1.0 ) + sqrtF0 ) / ( vec3( 1.0 ) - sqrtF0 );\n\t}\n\tvec3 IorToFresnel0( vec3 transmittedIor, float incidentIor ) {\n\t\treturn pow2( ( transmittedIor - vec3( incidentIor ) ) / ( transmittedIor + vec3( incidentIor ) ) );\n\t}\n\tfloat IorToFresnel0( float transmittedIor, float incidentIor ) {\n\t\treturn pow2( ( transmittedIor - incidentIor ) / ( transmittedIor + incidentIor ));\n\t}\n\tvec3 evalSensitivity( float OPD, vec3 shift ) {\n\t\tfloat phase = 2.0 * PI * OPD * 1.0e-9;\n\t\tvec3 val = vec3( 5.4856e-13, 4.4201e-13, 5.2481e-13 );\n\t\tvec3 pos = vec3( 1.6810e+06, 1.7953e+06, 2.2084e+06 );\n\t\tvec3 var = vec3( 4.3278e+09, 9.3046e+09, 6.6121e+09 );\n\t\tvec3 xyz = val * sqrt( 2.0 * PI * var ) * cos( pos * phase + shift ) * exp( - pow2( phase ) * var );\n\t\txyz.x += 9.7470e-14 * sqrt( 2.0 * PI * 4.5282e+09 ) * cos( 2.2399e+06 * phase + shift[ 0 ] ) * exp( - 4.5282e+09 * pow2( phase ) );\n\t\txyz /= 1.0685e-7;\n\t\tvec3 rgb = XYZ_TO_REC709 * xyz;\n\t\treturn rgb;\n\t}\n\tvec3 evalIridescence( float outsideIOR, float eta2, float cosTheta1, float thinFilmThickness, vec3 baseF0 ) {\n\t\tvec3 I;\n\t\tfloat iridescenceIOR = mix( outsideIOR, eta2, smoothstep( 0.0, 0.03, thinFilmThickness ) );\n\t\tfloat sinTheta2Sq = pow2( outsideIOR / iridescenceIOR ) * ( 1.0 - pow2( cosTheta1 ) );\n\t\tfloat cosTheta2Sq = 1.0 - sinTheta2Sq;\n\t\tif ( cosTheta2Sq < 0.0 ) {\n\t\t\treturn vec3( 1.0 );\n\t\t}\n\t\tfloat cosTheta2 = sqrt( cosTheta2Sq );\n\t\tfloat R0 = IorToFresnel0( iridescenceIOR, outsideIOR );\n\t\tfloat R12 = F_Schlick( R0, 1.0, cosTheta1 );\n\t\tfloat T121 = 1.0 - R12;\n\t\tfloat phi12 = 0.0;\n\t\tif ( iridescenceIOR < outsideIOR ) phi12 = PI;\n\t\tfloat phi21 = PI - phi12;\n\t\tvec3 baseIOR = Fresnel0ToIor( clamp( baseF0, 0.0, 0.9999 ) );\t\tvec3 R1 = IorToFresnel0( baseIOR, iridescenceIOR );\n\t\tvec3 R23 = F_Schlick( R1, 1.0, cosTheta2 );\n\t\tvec3 phi23 = vec3( 0.0 );\n\t\tif ( baseIOR[ 0 ] < iridescenceIOR ) phi23[ 0 ] = PI;\n\t\tif ( baseIOR[ 1 ] < iridescenceIOR ) phi23[ 1 ] = PI;\n\t\tif ( baseIOR[ 2 ] < iridescenceIOR ) phi23[ 2 ] = PI;\n\t\tfloat OPD = 2.0 * iridescenceIOR * thinFilmThickness * cosTheta2;\n\t\tvec3 phi = vec3( phi21 ) + phi23;\n\t\tvec3 R123 = clamp( R12 * R23, 1e-5, 0.9999 );\n\t\tvec3 r123 = sqrt( R123 );\n\t\tvec3 Rs = pow2( T121 ) * R23 / ( vec3( 1.0 ) - R123 );\n\t\tvec3 C0 = R12 + Rs;\n\t\tI = C0;\n\t\tvec3 Cm = Rs - T121;\n\t\tfor ( int m = 1; m <= 2; ++ m ) {\n\t\t\tCm *= r123;\n\t\t\tvec3 Sm = 2.0 * evalSensitivity( float( m ) * OPD, float( m ) * phi );\n\t\t\tI += Cm * Sm;\n\t\t}\n\t\treturn max( I, vec3( 0.0 ) );\n\t}\n#endif`,$0e=`#ifdef USE_BUMPMAP\n\tuniform sampler2D bumpMap;\n\tuniform float bumpScale;\n\tvec2 dHdxy_fwd() {\n\t\tvec2 dSTdx = dFdx( vBumpMapUv );\n\t\tvec2 dSTdy = dFdy( vBumpMapUv );\n\t\tfloat Hll = bumpScale * texture2D( bumpMap, vBumpMapUv ).x;\n\t\tfloat dBx = bumpScale * texture2D( bumpMap, vBumpMapUv + dSTdx ).x - Hll;\n\t\tfloat dBy = bumpScale * texture2D( bumpMap, vBumpMapUv + dSTdy ).x - Hll;\n\t\treturn vec2( dBx, dBy );\n\t}\n\tvec3 perturbNormalArb( vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection ) {\n\t\tvec3 vSigmaX = normalize( dFdx( surf_pos.xyz ) );\n\t\tvec3 vSigmaY = normalize( dFdy( surf_pos.xyz ) );\n\t\tvec3 vN = surf_norm;\n\t\tvec3 R1 = cross( vSigmaY, vN );\n\t\tvec3 R2 = cross( vN, vSigmaX );\n\t\tfloat fDet = dot( vSigmaX, R1 ) * faceDirection;\n\t\tvec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );\n\t\treturn normalize( abs( fDet ) * surf_norm - vGrad );\n\t}\n#endif`,V0e=`#if NUM_CLIPPING_PLANES > 0\n\tvec4 plane;\n\t#ifdef ALPHA_TO_COVERAGE\n\t\tfloat distanceToPlane, distanceGradient;\n\t\tfloat clipOpacity = 1.0;\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tdistanceToPlane = - dot( vClipPosition, plane.xyz ) + plane.w;\n\t\t\tdistanceGradient = fwidth( distanceToPlane ) / 2.0;\n\t\t\tclipOpacity *= smoothstep( - distanceGradient, distanceGradient, distanceToPlane );\n\t\t\tif ( clipOpacity == 0.0 ) discard;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\t\tfloat unionClipOpacity = 1.0;\n\t\t\t#pragma unroll_loop_start\n\t\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\t\tplane = clippingPlanes[ i ];\n\t\t\t\tdistanceToPlane = - dot( vClipPosition, plane.xyz ) + plane.w;\n\t\t\t\tdistanceGradient = fwidth( distanceToPlane ) / 2.0;\n\t\t\t\tunionClipOpacity *= 1.0 - smoothstep( - distanceGradient, distanceGradient, distanceToPlane );\n\t\t\t}\n\t\t\t#pragma unroll_loop_end\n\t\t\tclipOpacity *= 1.0 - unionClipOpacity;\n\t\t#endif\n\t\tdiffuseColor.a *= clipOpacity;\n\t\tif ( diffuseColor.a == 0.0 ) discard;\n\t#else\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tif ( dot( vClipPosition, plane.xyz ) > plane.w ) discard;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\t\tbool clipped = true;\n\t\t\t#pragma unroll_loop_start\n\t\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\t\tplane = clippingPlanes[ i ];\n\t\t\t\tclipped = ( dot( vClipPosition, plane.xyz ) > plane.w ) && clipped;\n\t\t\t}\n\t\t\t#pragma unroll_loop_end\n\t\t\tif ( clipped ) discard;\n\t\t#endif\n\t#endif\n#endif`,G0e=`#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n\tuniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];\n#endif`,W0e=`#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n#endif`,K0e=`#if NUM_CLIPPING_PLANES > 0\n\tvClipPosition = - mvPosition.xyz;\n#endif`,X0e=`#if defined( USE_COLOR_ALPHA )\n\tdiffuseColor *= vColor;\n#elif defined( USE_COLOR )\n\tdiffuseColor.rgb *= vColor;\n#endif`,Y0e=`#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR )\n\tvarying vec3 vColor;\n#endif`,Q0e=`#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR ) || defined( USE_BATCHING_COLOR )\n\tvarying vec3 vColor;\n#endif`,Z0e=`#if defined( USE_COLOR_ALPHA )\n\tvColor = vec4( 1.0 );\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR ) || defined( USE_BATCHING_COLOR )\n\tvColor = vec3( 1.0 );\n#endif\n#ifdef USE_COLOR\n\tvColor *= color;\n#endif\n#ifdef USE_INSTANCING_COLOR\n\tvColor.xyz *= instanceColor.xyz;\n#endif\n#ifdef USE_BATCHING_COLOR\n\tvec3 batchingColor = getBatchingColor( getIndirectIndex( gl_DrawID ) );\n\tvColor.xyz *= batchingColor.xyz;\n#endif`,J0e=`#define PI 3.141592653589793\n#define PI2 6.283185307179586\n#define PI_HALF 1.5707963267948966\n#define RECIPROCAL_PI 0.3183098861837907\n#define RECIPROCAL_PI2 0.15915494309189535\n#define EPSILON 1e-6\n#ifndef saturate\n#define saturate( a ) clamp( a, 0.0, 1.0 )\n#endif\n#define whiteComplement( a ) ( 1.0 - saturate( a ) )\nfloat pow2( const in float x ) { return x*x; }\nvec3 pow2( const in vec3 x ) { return x*x; }\nfloat pow3( const in float x ) { return x*x*x; }\nfloat pow4( const in float x ) { float x2 = x*x; return x2*x2; }\nfloat max3( const in vec3 v ) { return max( max( v.x, v.y ), v.z ); }\nfloat average( const in vec3 v ) { return dot( v, vec3( 0.3333333 ) ); }\nhighp float rand( const in vec2 uv ) {\n\tconst highp float a = 12.9898, b = 78.233, c = 43758.5453;\n\thighp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );\n\treturn fract( sin( sn ) * c );\n}\n#ifdef HIGH_PRECISION\n\tfloat precisionSafeLength( vec3 v ) { return length( v ); }\n#else\n\tfloat precisionSafeLength( vec3 v ) {\n\t\tfloat maxComponent = max3( abs( v ) );\n\t\treturn length( v / maxComponent ) * maxComponent;\n\t}\n#endif\nstruct IncidentLight {\n\tvec3 color;\n\tvec3 direction;\n\tbool visible;\n};\nstruct ReflectedLight {\n\tvec3 directDiffuse;\n\tvec3 directSpecular;\n\tvec3 indirectDiffuse;\n\tvec3 indirectSpecular;\n};\n#ifdef USE_ALPHAHASH\n\tvarying vec3 vPosition;\n#endif\nvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n}\nvec3 inverseTransformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( vec4( dir, 0.0 ) * matrix ).xyz );\n}\nbool isPerspectiveMatrix( mat4 m ) {\n\treturn m[ 2 ][ 3 ] == - 1.0;\n}\nvec2 equirectUv( in vec3 dir ) {\n\tfloat u = atan( dir.z, dir.x ) * RECIPROCAL_PI2 + 0.5;\n\tfloat v = asin( clamp( dir.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\treturn vec2( u, v );\n}\nvec3 BRDF_Lambert( const in vec3 diffuseColor ) {\n\treturn RECIPROCAL_PI * diffuseColor;\n}\nvec3 F_Schlick( const in vec3 f0, const in float f90, const in float dotVH ) {\n\tfloat fresnel = exp2( ( - 5.55473 * dotVH - 6.98316 ) * dotVH );\n\treturn f0 * ( 1.0 - fresnel ) + ( f90 * fresnel );\n}\nfloat F_Schlick( const in float f0, const in float f90, const in float dotVH ) {\n\tfloat fresnel = exp2( ( - 5.55473 * dotVH - 6.98316 ) * dotVH );\n\treturn f0 * ( 1.0 - fresnel ) + ( f90 * fresnel );\n} // validated`,ewe=`#ifdef ENVMAP_TYPE_CUBE_UV\n\t#define cubeUV_minMipLevel 4.0\n\t#define cubeUV_minTileSize 16.0\n\tfloat getFace( vec3 direction ) {\n\t\tvec3 absDirection = abs( direction );\n\t\tfloat face = - 1.0;\n\t\tif ( absDirection.x > absDirection.z ) {\n\t\t\tif ( absDirection.x > absDirection.y )\n\t\t\t\tface = direction.x > 0.0 ? 0.0 : 3.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t} else {\n\t\t\tif ( absDirection.z > absDirection.y )\n\t\t\t\tface = direction.z > 0.0 ? 2.0 : 5.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t}\n\t\treturn face;\n\t}\n\tvec2 getUV( vec3 direction, float face ) {\n\t\tvec2 uv;\n\t\tif ( face == 0.0 ) {\n\t\t\tuv = vec2( direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 1.0 ) {\n\t\t\tuv = vec2( - direction.x, - direction.z ) / abs( direction.y );\n\t\t} else if ( face == 2.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.y ) / abs( direction.z );\n\t\t} else if ( face == 3.0 ) {\n\t\t\tuv = vec2( - direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 4.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.z ) / abs( direction.y );\n\t\t} else {\n\t\t\tuv = vec2( direction.x, direction.y ) / abs( direction.z );\n\t\t}\n\t\treturn 0.5 * ( uv + 1.0 );\n\t}\n\tvec3 bilinearCubeUV( sampler2D envMap, vec3 direction, float mipInt ) {\n\t\tfloat face = getFace( direction );\n\t\tfloat filterInt = max( cubeUV_minMipLevel - mipInt, 0.0 );\n\t\tmipInt = max( mipInt, cubeUV_minMipLevel );\n\t\tfloat faceSize = exp2( mipInt );\n\t\thighp vec2 uv = getUV( direction, face ) * ( faceSize - 2.0 ) + 1.0;\n\t\tif ( face > 2.0 ) {\n\t\t\tuv.y += faceSize;\n\t\t\tface -= 3.0;\n\t\t}\n\t\tuv.x += face * faceSize;\n\t\tuv.x += filterInt * 3.0 * cubeUV_minTileSize;\n\t\tuv.y += 4.0 * ( exp2( CUBEUV_MAX_MIP ) - faceSize );\n\t\tuv.x *= CUBEUV_TEXEL_WIDTH;\n\t\tuv.y *= CUBEUV_TEXEL_HEIGHT;\n\t\t#ifdef texture2DGradEXT\n\t\t\treturn texture2DGradEXT( envMap, uv, vec2( 0.0 ), vec2( 0.0 ) ).rgb;\n\t\t#else\n\t\t\treturn texture2D( envMap, uv ).rgb;\n\t\t#endif\n\t}\n\t#define cubeUV_r0 1.0\n\t#define cubeUV_m0 - 2.0\n\t#define cubeUV_r1 0.8\n\t#define cubeUV_m1 - 1.0\n\t#define cubeUV_r4 0.4\n\t#define cubeUV_m4 2.0\n\t#define cubeUV_r5 0.305\n\t#define cubeUV_m5 3.0\n\t#define cubeUV_r6 0.21\n\t#define cubeUV_m6 4.0\n\tfloat roughnessToMip( float roughness ) {\n\t\tfloat mip = 0.0;\n\t\tif ( roughness >= cubeUV_r1 ) {\n\t\t\tmip = ( cubeUV_r0 - roughness ) * ( cubeUV_m1 - cubeUV_m0 ) / ( cubeUV_r0 - cubeUV_r1 ) + cubeUV_m0;\n\t\t} else if ( roughness >= cubeUV_r4 ) {\n\t\t\tmip = ( cubeUV_r1 - roughness ) * ( cubeUV_m4 - cubeUV_m1 ) / ( cubeUV_r1 - cubeUV_r4 ) + cubeUV_m1;\n\t\t} else if ( roughness >= cubeUV_r5 ) {\n\t\t\tmip = ( cubeUV_r4 - roughness ) * ( cubeUV_m5 - cubeUV_m4 ) / ( cubeUV_r4 - cubeUV_r5 ) + cubeUV_m4;\n\t\t} else if ( roughness >= cubeUV_r6 ) {\n\t\t\tmip = ( cubeUV_r5 - roughness ) * ( cubeUV_m6 - cubeUV_m5 ) / ( cubeUV_r5 - cubeUV_r6 ) + cubeUV_m5;\n\t\t} else {\n\t\t\tmip = - 2.0 * log2( 1.16 * roughness );\t\t}\n\t\treturn mip;\n\t}\n\tvec4 textureCubeUV( sampler2D envMap, vec3 sampleDir, float roughness ) {\n\t\tfloat mip = clamp( roughnessToMip( roughness ), cubeUV_m0, CUBEUV_MAX_MIP );\n\t\tfloat mipF = fract( mip );\n\t\tfloat mipInt = floor( mip );\n\t\tvec3 color0 = bilinearCubeUV( envMap, sampleDir, mipInt );\n\t\tif ( mipF == 0.0 ) {\n\t\t\treturn vec4( color0, 1.0 );\n\t\t} else {\n\t\t\tvec3 color1 = bilinearCubeUV( envMap, sampleDir, mipInt + 1.0 );\n\t\t\treturn vec4( mix( color0, color1, mipF ), 1.0 );\n\t\t}\n\t}\n#endif`,twe=`vec3 transformedNormal = objectNormal;\n#ifdef USE_TANGENT\n\tvec3 transformedTangent = objectTangent;\n#endif\n#ifdef USE_BATCHING\n\tmat3 bm = mat3( batchingMatrix );\n\ttransformedNormal /= vec3( dot( bm[ 0 ], bm[ 0 ] ), dot( bm[ 1 ], bm[ 1 ] ), dot( bm[ 2 ], bm[ 2 ] ) );\n\ttransformedNormal = bm * transformedNormal;\n\t#ifdef USE_TANGENT\n\t\ttransformedTangent = bm * transformedTangent;\n\t#endif\n#endif\n#ifdef USE_INSTANCING\n\tmat3 im = mat3( instanceMatrix );\n\ttransformedNormal /= vec3( dot( im[ 0 ], im[ 0 ] ), dot( im[ 1 ], im[ 1 ] ), dot( im[ 2 ], im[ 2 ] ) );\n\ttransformedNormal = im * transformedNormal;\n\t#ifdef USE_TANGENT\n\t\ttransformedTangent = im * transformedTangent;\n\t#endif\n#endif\ntransformedNormal = normalMatrix * transformedNormal;\n#ifdef FLIP_SIDED\n\ttransformedNormal = - transformedNormal;\n#endif\n#ifdef USE_TANGENT\n\ttransformedTangent = ( modelViewMatrix * vec4( transformedTangent, 0.0 ) ).xyz;\n\t#ifdef FLIP_SIDED\n\t\ttransformedTangent = - transformedTangent;\n\t#endif\n#endif`,nwe=`#ifdef USE_DISPLACEMENTMAP\n\tuniform sampler2D displacementMap;\n\tuniform float displacementScale;\n\tuniform float displacementBias;\n#endif`,rwe=`#ifdef USE_DISPLACEMENTMAP\n\ttransformed += normalize( objectNormal ) * ( texture2D( displacementMap, vDisplacementMapUv ).x * displacementScale + displacementBias );\n#endif`,awe=`#ifdef USE_EMISSIVEMAP\n\tvec4 emissiveColor = texture2D( emissiveMap, vEmissiveMapUv );\n\t#ifdef DECODE_VIDEO_TEXTURE_EMISSIVE\n\t\temissiveColor = sRGBTransferEOTF( emissiveColor );\n\t#endif\n\ttotalEmissiveRadiance *= emissiveColor.rgb;\n#endif`,iwe=`#ifdef USE_EMISSIVEMAP\n\tuniform sampler2D emissiveMap;\n#endif`,swe=\"gl_FragColor = linearToOutputTexel( gl_FragColor );\",owe=`vec4 LinearTransferOETF( in vec4 value ) {\n\treturn value;\n}\nvec4 sRGBTransferEOTF( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), value.rgb * 0.0773993808, vec3( lessThanEqual( value.rgb, vec3( 0.04045 ) ) ) ), value.a );\n}\nvec4 sRGBTransferOETF( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );\n}`,lwe=`#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvec3 cameraToFrag;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToFrag = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToFrag = normalize( vWorldPosition - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( cameraToFrag, worldNormal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( cameraToFrag, worldNormal, refractionRatio );\n\t\t#endif\n\t#else\n\t\tvec3 reflectVec = vReflect;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 envColor = textureCube( envMap, envMapRotation * vec3( flipEnvMap * reflectVec.x, reflectVec.yz ) );\n\t#else\n\t\tvec4 envColor = vec4( 0.0 );\n\t#endif\n\t#ifdef ENVMAP_BLENDING_MULTIPLY\n\t\toutgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_MIX )\n\t\toutgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_ADD )\n\t\toutgoingLight += envColor.xyz * specularStrength * reflectivity;\n\t#endif\n#endif`,cwe=`#ifdef USE_ENVMAP\n\tuniform float envMapIntensity;\n\tuniform float flipEnvMap;\n\tuniform mat3 envMapRotation;\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tuniform samplerCube envMap;\n\t#else\n\t\tuniform sampler2D envMap;\n\t#endif\n#endif`,dwe=`#ifdef USE_ENVMAP\n\tuniform float reflectivity;\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( LAMBERT )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\tvarying vec3 vWorldPosition;\n\t\tuniform float refractionRatio;\n\t#else\n\t\tvarying vec3 vReflect;\n\t#endif\n#endif`,uwe=`#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( LAMBERT )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\t\n\t\tvarying vec3 vWorldPosition;\n\t#else\n\t\tvarying vec3 vReflect;\n\t\tuniform float refractionRatio;\n\t#endif\n#endif`,mwe=`#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvWorldPosition = worldPosition.xyz;\n\t#else\n\t\tvec3 cameraToVertex;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToVertex = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToVertex = normalize( worldPosition.xyz - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvReflect = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvReflect = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#endif\n#endif`,hwe=`#ifdef USE_FOG\n\tvFogDepth = - mvPosition.z;\n#endif`,pwe=`#ifdef USE_FOG\n\tvarying float vFogDepth;\n#endif`,fwe=`#ifdef USE_FOG\n\t#ifdef FOG_EXP2\n\t\tfloat fogFactor = 1.0 - exp( - fogDensity * fogDensity * vFogDepth * vFogDepth );\n\t#else\n\t\tfloat fogFactor = smoothstep( fogNear, fogFar, vFogDepth );\n\t#endif\n\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );\n#endif`,gwe=`#ifdef USE_FOG\n\tuniform vec3 fogColor;\n\tvarying float vFogDepth;\n\t#ifdef FOG_EXP2\n\t\tuniform float fogDensity;\n\t#else\n\t\tuniform float fogNear;\n\t\tuniform float fogFar;\n\t#endif\n#endif`,bwe=`#ifdef USE_GRADIENTMAP\n\tuniform sampler2D gradientMap;\n#endif\nvec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) {\n\tfloat dotNL = dot( normal, lightDirection );\n\tvec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 );\n\t#ifdef USE_GRADIENTMAP\n\t\treturn vec3( texture2D( gradientMap, coord ).r );\n\t#else\n\t\tvec2 fw = fwidth( coord ) * 0.5;\n\t\treturn mix( vec3( 0.7 ), vec3( 1.0 ), smoothstep( 0.7 - fw.x, 0.7 + fw.x, coord.x ) );\n\t#endif\n}`,xwe=`#ifdef USE_LIGHTMAP\n\tuniform sampler2D lightMap;\n\tuniform float lightMapIntensity;\n#endif`,ywe=`LambertMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularStrength = specularStrength;`,vwe=`varying vec3 vViewPosition;\nstruct LambertMaterial {\n\tvec3 diffuseColor;\n\tfloat specularStrength;\n};\nvoid RE_Direct_Lambert( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Lambert( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Lambert\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Lambert`,wwe=`uniform bool receiveShadow;\nuniform vec3 ambientLightColor;\n#if defined( USE_LIGHT_PROBES )\n\tuniform vec3 lightProbe[ 9 ];\n#endif\nvec3 shGetIrradianceAt( in vec3 normal, in vec3 shCoefficients[ 9 ] ) {\n\tfloat x = normal.x, y = normal.y, z = normal.z;\n\tvec3 result = shCoefficients[ 0 ] * 0.886227;\n\tresult += shCoefficients[ 1 ] * 2.0 * 0.511664 * y;\n\tresult += shCoefficients[ 2 ] * 2.0 * 0.511664 * z;\n\tresult += shCoefficients[ 3 ] * 2.0 * 0.511664 * x;\n\tresult += shCoefficients[ 4 ] * 2.0 * 0.429043 * x * y;\n\tresult += shCoefficients[ 5 ] * 2.0 * 0.429043 * y * z;\n\tresult += shCoefficients[ 6 ] * ( 0.743125 * z * z - 0.247708 );\n\tresult += shCoefficients[ 7 ] * 2.0 * 0.429043 * x * z;\n\tresult += shCoefficients[ 8 ] * 0.429043 * ( x * x - y * y );\n\treturn result;\n}\nvec3 getLightProbeIrradiance( const in vec3 lightProbe[ 9 ], const in vec3 normal ) {\n\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\tvec3 irradiance = shGetIrradianceAt( worldNormal, lightProbe );\n\treturn irradiance;\n}\nvec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) {\n\tvec3 irradiance = ambientLightColor;\n\treturn irradiance;\n}\nfloat getDistanceAttenuation( const in float lightDistance, const in float cutoffDistance, const in float decayExponent ) {\n\tfloat distanceFalloff = 1.0 / max( pow( lightDistance, decayExponent ), 0.01 );\n\tif ( cutoffDistance > 0.0 ) {\n\t\tdistanceFalloff *= pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) );\n\t}\n\treturn distanceFalloff;\n}\nfloat getSpotAttenuation( const in float coneCosine, const in float penumbraCosine, const in float angleCosine ) {\n\treturn smoothstep( coneCosine, penumbraCosine, angleCosine );\n}\n#if NUM_DIR_LIGHTS > 0\n\tstruct DirectionalLight {\n\t\tvec3 direction;\n\t\tvec3 color;\n\t};\n\tuniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];\n\tvoid getDirectionalLightInfo( const in DirectionalLight directionalLight, out IncidentLight light ) {\n\t\tlight.color = directionalLight.color;\n\t\tlight.direction = directionalLight.direction;\n\t\tlight.visible = true;\n\t}\n#endif\n#if NUM_POINT_LIGHTS > 0\n\tstruct PointLight {\n\t\tvec3 position;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t};\n\tuniform PointLight pointLights[ NUM_POINT_LIGHTS ];\n\tvoid getPointLightInfo( const in PointLight pointLight, const in vec3 geometryPosition, out IncidentLight light ) {\n\t\tvec3 lVector = pointLight.position - geometryPosition;\n\t\tlight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tlight.color = pointLight.color;\n\t\tlight.color *= getDistanceAttenuation( lightDistance, pointLight.distance, pointLight.decay );\n\t\tlight.visible = ( light.color != vec3( 0.0 ) );\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\tstruct SpotLight {\n\t\tvec3 position;\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tfloat coneCos;\n\t\tfloat penumbraCos;\n\t};\n\tuniform SpotLight spotLights[ NUM_SPOT_LIGHTS ];\n\tvoid getSpotLightInfo( const in SpotLight spotLight, const in vec3 geometryPosition, out IncidentLight light ) {\n\t\tvec3 lVector = spotLight.position - geometryPosition;\n\t\tlight.direction = normalize( lVector );\n\t\tfloat angleCos = dot( light.direction, spotLight.direction );\n\t\tfloat spotAttenuation = getSpotAttenuation( spotLight.coneCos, spotLight.penumbraCos, angleCos );\n\t\tif ( spotAttenuation > 0.0 ) {\n\t\t\tfloat lightDistance = length( lVector );\n\t\t\tlight.color = spotLight.color * spotAttenuation;\n\t\t\tlight.color *= getDistanceAttenuation( lightDistance, spotLight.distance, spotLight.decay );\n\t\t\tlight.visible = ( light.color != vec3( 0.0 ) );\n\t\t} else {\n\t\t\tlight.color = vec3( 0.0 );\n\t\t\tlight.visible = false;\n\t\t}\n\t}\n#endif\n#if NUM_RECT_AREA_LIGHTS > 0\n\tstruct RectAreaLight {\n\t\tvec3 color;\n\t\tvec3 position;\n\t\tvec3 halfWidth;\n\t\tvec3 halfHeight;\n\t};\n\tuniform sampler2D ltc_1;\tuniform sampler2D ltc_2;\n\tuniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ];\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\tstruct HemisphereLight {\n\t\tvec3 direction;\n\t\tvec3 skyColor;\n\t\tvec3 groundColor;\n\t};\n\tuniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ];\n\tvec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in vec3 normal ) {\n\t\tfloat dotNL = dot( normal, hemiLight.direction );\n\t\tfloat hemiDiffuseWeight = 0.5 * dotNL + 0.5;\n\t\tvec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight );\n\t\treturn irradiance;\n\t}\n#endif`,Swe=`#ifdef USE_ENVMAP\n\tvec3 getIBLIrradiance( const in vec3 normal ) {\n\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, envMapRotation * worldNormal, 1.0 );\n\t\t\treturn PI * envMapColor.rgb * envMapIntensity;\n\t\t#else\n\t\t\treturn vec3( 0.0 );\n\t\t#endif\n\t}\n\tvec3 getIBLRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness ) {\n\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\tvec3 reflectVec = reflect( - viewDir, normal );\n\t\t\treflectVec = normalize( mix( reflectVec, normal, pow4( roughness ) ) );\n\t\t\treflectVec = inverseTransformDirection( reflectVec, viewMatrix );\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, envMapRotation * reflectVec, roughness );\n\t\t\treturn envMapColor.rgb * envMapIntensity;\n\t\t#else\n\t\t\treturn vec3( 0.0 );\n\t\t#endif\n\t}\n\t#ifdef USE_ANISOTROPY\n\t\tvec3 getIBLAnisotropyRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness, const in vec3 bitangent, const in float anisotropy ) {\n\t\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\t\tvec3 bentNormal = cross( bitangent, viewDir );\n\t\t\t\tbentNormal = normalize( cross( bentNormal, bitangent ) );\n\t\t\t\tbentNormal = normalize( mix( bentNormal, normal, pow2( pow2( 1.0 - anisotropy * ( 1.0 - roughness ) ) ) ) );\n\t\t\t\treturn getIBLRadiance( viewDir, bentNormal, roughness );\n\t\t\t#else\n\t\t\t\treturn vec3( 0.0 );\n\t\t\t#endif\n\t\t}\n\t#endif\n#endif`,_we=`ToonMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;`,kwe=`varying vec3 vViewPosition;\nstruct ToonMaterial {\n\tvec3 diffuseColor;\n};\nvoid RE_Direct_Toon( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\tvec3 irradiance = getGradientIrradiance( geometryNormal, directLight.direction ) * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Toon( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Toon\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Toon`,Nwe=`BlinnPhongMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularColor = specular;\nmaterial.specularShininess = shininess;\nmaterial.specularStrength = specularStrength;`,Cwe=`varying vec3 vViewPosition;\nstruct BlinnPhongMaterial {\n\tvec3 diffuseColor;\n\tvec3 specularColor;\n\tfloat specularShininess;\n\tfloat specularStrength;\n};\nvoid RE_Direct_BlinnPhong( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n\treflectedLight.directSpecular += irradiance * BRDF_BlinnPhong( directLight.direction, geometryViewDir, geometryNormal, material.specularColor, material.specularShininess ) * material.specularStrength;\n}\nvoid RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_BlinnPhong\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_BlinnPhong`,Pwe=`PhysicalMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb * ( 1.0 - metalnessFactor );\nvec3 dxy = max( abs( dFdx( nonPerturbedNormal ) ), abs( dFdy( nonPerturbedNormal ) ) );\nfloat geometryRoughness = max( max( dxy.x, dxy.y ), dxy.z );\nmaterial.roughness = max( roughnessFactor, 0.0525 );material.roughness += geometryRoughness;\nmaterial.roughness = min( material.roughness, 1.0 );\n#ifdef IOR\n\tmaterial.ior = ior;\n\t#ifdef USE_SPECULAR\n\t\tfloat specularIntensityFactor = specularIntensity;\n\t\tvec3 specularColorFactor = specularColor;\n\t\t#ifdef USE_SPECULAR_COLORMAP\n\t\t\tspecularColorFactor *= texture2D( specularColorMap, vSpecularColorMapUv ).rgb;\n\t\t#endif\n\t\t#ifdef USE_SPECULAR_INTENSITYMAP\n\t\t\tspecularIntensityFactor *= texture2D( specularIntensityMap, vSpecularIntensityMapUv ).a;\n\t\t#endif\n\t\tmaterial.specularF90 = mix( specularIntensityFactor, 1.0, metalnessFactor );\n\t#else\n\t\tfloat specularIntensityFactor = 1.0;\n\t\tvec3 specularColorFactor = vec3( 1.0 );\n\t\tmaterial.specularF90 = 1.0;\n\t#endif\n\tmaterial.specularColor = mix( min( pow2( ( material.ior - 1.0 ) / ( material.ior + 1.0 ) ) * specularColorFactor, vec3( 1.0 ) ) * specularIntensityFactor, diffuseColor.rgb, metalnessFactor );\n#else\n\tmaterial.specularColor = mix( vec3( 0.04 ), diffuseColor.rgb, metalnessFactor );\n\tmaterial.specularF90 = 1.0;\n#endif\n#ifdef USE_CLEARCOAT\n\tmaterial.clearcoat = clearcoat;\n\tmaterial.clearcoatRoughness = clearcoatRoughness;\n\tmaterial.clearcoatF0 = vec3( 0.04 );\n\tmaterial.clearcoatF90 = 1.0;\n\t#ifdef USE_CLEARCOATMAP\n\t\tmaterial.clearcoat *= texture2D( clearcoatMap, vClearcoatMapUv ).x;\n\t#endif\n\t#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\t\tmaterial.clearcoatRoughness *= texture2D( clearcoatRoughnessMap, vClearcoatRoughnessMapUv ).y;\n\t#endif\n\tmaterial.clearcoat = saturate( material.clearcoat );\tmaterial.clearcoatRoughness = max( material.clearcoatRoughness, 0.0525 );\n\tmaterial.clearcoatRoughness += geometryRoughness;\n\tmaterial.clearcoatRoughness = min( material.clearcoatRoughness, 1.0 );\n#endif\n#ifdef USE_DISPERSION\n\tmaterial.dispersion = dispersion;\n#endif\n#ifdef USE_IRIDESCENCE\n\tmaterial.iridescence = iridescence;\n\tmaterial.iridescenceIOR = iridescenceIOR;\n\t#ifdef USE_IRIDESCENCEMAP\n\t\tmaterial.iridescence *= texture2D( iridescenceMap, vIridescenceMapUv ).r;\n\t#endif\n\t#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\t\tmaterial.iridescenceThickness = (iridescenceThicknessMaximum - iridescenceThicknessMinimum) * texture2D( iridescenceThicknessMap, vIridescenceThicknessMapUv ).g + iridescenceThicknessMinimum;\n\t#else\n\t\tmaterial.iridescenceThickness = iridescenceThicknessMaximum;\n\t#endif\n#endif\n#ifdef USE_SHEEN\n\tmaterial.sheenColor = sheenColor;\n\t#ifdef USE_SHEEN_COLORMAP\n\t\tmaterial.sheenColor *= texture2D( sheenColorMap, vSheenColorMapUv ).rgb;\n\t#endif\n\tmaterial.sheenRoughness = clamp( sheenRoughness, 0.07, 1.0 );\n\t#ifdef USE_SHEEN_ROUGHNESSMAP\n\t\tmaterial.sheenRoughness *= texture2D( sheenRoughnessMap, vSheenRoughnessMapUv ).a;\n\t#endif\n#endif\n#ifdef USE_ANISOTROPY\n\t#ifdef USE_ANISOTROPYMAP\n\t\tmat2 anisotropyMat = mat2( anisotropyVector.x, anisotropyVector.y, - anisotropyVector.y, anisotropyVector.x );\n\t\tvec3 anisotropyPolar = texture2D( anisotropyMap, vAnisotropyMapUv ).rgb;\n\t\tvec2 anisotropyV = anisotropyMat * normalize( 2.0 * anisotropyPolar.rg - vec2( 1.0 ) ) * anisotropyPolar.b;\n\t#else\n\t\tvec2 anisotropyV = anisotropyVector;\n\t#endif\n\tmaterial.anisotropy = length( anisotropyV );\n\tif( material.anisotropy == 0.0 ) {\n\t\tanisotropyV = vec2( 1.0, 0.0 );\n\t} else {\n\t\tanisotropyV /= material.anisotropy;\n\t\tmaterial.anisotropy = saturate( material.anisotropy );\n\t}\n\tmaterial.alphaT = mix( pow2( material.roughness ), 1.0, pow2( material.anisotropy ) );\n\tmaterial.anisotropyT = tbn[ 0 ] * anisotropyV.x + tbn[ 1 ] * anisotropyV.y;\n\tmaterial.anisotropyB = tbn[ 1 ] * anisotropyV.x - tbn[ 0 ] * anisotropyV.y;\n#endif`,Twe=`uniform sampler2D dfgLUT;\nstruct PhysicalMaterial {\n\tvec3 diffuseColor;\n\tfloat roughness;\n\tvec3 specularColor;\n\tfloat specularF90;\n\tfloat dispersion;\n\t#ifdef USE_CLEARCOAT\n\t\tfloat clearcoat;\n\t\tfloat clearcoatRoughness;\n\t\tvec3 clearcoatF0;\n\t\tfloat clearcoatF90;\n\t#endif\n\t#ifdef USE_IRIDESCENCE\n\t\tfloat iridescence;\n\t\tfloat iridescenceIOR;\n\t\tfloat iridescenceThickness;\n\t\tvec3 iridescenceFresnel;\n\t\tvec3 iridescenceF0;\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tvec3 sheenColor;\n\t\tfloat sheenRoughness;\n\t#endif\n\t#ifdef IOR\n\t\tfloat ior;\n\t#endif\n\t#ifdef USE_TRANSMISSION\n\t\tfloat transmission;\n\t\tfloat transmissionAlpha;\n\t\tfloat thickness;\n\t\tfloat attenuationDistance;\n\t\tvec3 attenuationColor;\n\t#endif\n\t#ifdef USE_ANISOTROPY\n\t\tfloat anisotropy;\n\t\tfloat alphaT;\n\t\tvec3 anisotropyT;\n\t\tvec3 anisotropyB;\n\t#endif\n};\nvec3 clearcoatSpecularDirect = vec3( 0.0 );\nvec3 clearcoatSpecularIndirect = vec3( 0.0 );\nvec3 sheenSpecularDirect = vec3( 0.0 );\nvec3 sheenSpecularIndirect = vec3(0.0 );\nvec3 Schlick_to_F0( const in vec3 f, const in float f90, const in float dotVH ) {\n    float x = clamp( 1.0 - dotVH, 0.0, 1.0 );\n    float x2 = x * x;\n    float x5 = clamp( x * x2 * x2, 0.0, 0.9999 );\n    return ( f - vec3( f90 ) * x5 ) / ( 1.0 - x5 );\n}\nfloat V_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\tfloat gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\treturn 0.5 / max( gv + gl, EPSILON );\n}\nfloat D_GGX( const in float alpha, const in float dotNH ) {\n\tfloat a2 = pow2( alpha );\n\tfloat denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0;\n\treturn RECIPROCAL_PI * a2 / pow2( denom );\n}\n#ifdef USE_ANISOTROPY\n\tfloat V_GGX_SmithCorrelated_Anisotropic( const in float alphaT, const in float alphaB, const in float dotTV, const in float dotBV, const in float dotTL, const in float dotBL, const in float dotNV, const in float dotNL ) {\n\t\tfloat gv = dotNL * length( vec3( alphaT * dotTV, alphaB * dotBV, dotNV ) );\n\t\tfloat gl = dotNV * length( vec3( alphaT * dotTL, alphaB * dotBL, dotNL ) );\n\t\tfloat v = 0.5 / ( gv + gl );\n\t\treturn saturate(v);\n\t}\n\tfloat D_GGX_Anisotropic( const in float alphaT, const in float alphaB, const in float dotNH, const in float dotTH, const in float dotBH ) {\n\t\tfloat a2 = alphaT * alphaB;\n\t\thighp vec3 v = vec3( alphaB * dotTH, alphaT * dotBH, a2 * dotNH );\n\t\thighp float v2 = dot( v, v );\n\t\tfloat w2 = a2 / v2;\n\t\treturn RECIPROCAL_PI * a2 * pow2 ( w2 );\n\t}\n#endif\n#ifdef USE_CLEARCOAT\n\tvec3 BRDF_GGX_Clearcoat( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material) {\n\t\tvec3 f0 = material.clearcoatF0;\n\t\tfloat f90 = material.clearcoatF90;\n\t\tfloat roughness = material.clearcoatRoughness;\n\t\tfloat alpha = pow2( roughness );\n\t\tvec3 halfDir = normalize( lightDir + viewDir );\n\t\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\t\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\t\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\t\tfloat dotVH = saturate( dot( viewDir, halfDir ) );\n\t\tvec3 F = F_Schlick( f0, f90, dotVH );\n\t\tfloat V = V_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\t\tfloat D = D_GGX( alpha, dotNH );\n\t\treturn F * ( V * D );\n\t}\n#endif\nvec3 BRDF_GGX( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material ) {\n\tvec3 f0 = material.specularColor;\n\tfloat f90 = material.specularF90;\n\tfloat roughness = material.roughness;\n\tfloat alpha = pow2( roughness );\n\tvec3 halfDir = normalize( lightDir + viewDir );\n\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat dotVH = saturate( dot( viewDir, halfDir ) );\n\tvec3 F = F_Schlick( f0, f90, dotVH );\n\t#ifdef USE_IRIDESCENCE\n\t\tF = mix( F, material.iridescenceFresnel, material.iridescence );\n\t#endif\n\t#ifdef USE_ANISOTROPY\n\t\tfloat dotTL = dot( material.anisotropyT, lightDir );\n\t\tfloat dotTV = dot( material.anisotropyT, viewDir );\n\t\tfloat dotTH = dot( material.anisotropyT, halfDir );\n\t\tfloat dotBL = dot( material.anisotropyB, lightDir );\n\t\tfloat dotBV = dot( material.anisotropyB, viewDir );\n\t\tfloat dotBH = dot( material.anisotropyB, halfDir );\n\t\tfloat V = V_GGX_SmithCorrelated_Anisotropic( material.alphaT, alpha, dotTV, dotBV, dotTL, dotBL, dotNV, dotNL );\n\t\tfloat D = D_GGX_Anisotropic( material.alphaT, alpha, dotNH, dotTH, dotBH );\n\t#else\n\t\tfloat V = V_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\t\tfloat D = D_GGX( alpha, dotNH );\n\t#endif\n\treturn F * ( V * D );\n}\nvec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) {\n\tconst float LUT_SIZE = 64.0;\n\tconst float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE;\n\tconst float LUT_BIAS = 0.5 / LUT_SIZE;\n\tfloat dotNV = saturate( dot( N, V ) );\n\tvec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) );\n\tuv = uv * LUT_SCALE + LUT_BIAS;\n\treturn uv;\n}\nfloat LTC_ClippedSphereFormFactor( const in vec3 f ) {\n\tfloat l = length( f );\n\treturn max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 );\n}\nvec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) {\n\tfloat x = dot( v1, v2 );\n\tfloat y = abs( x );\n\tfloat a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y;\n\tfloat b = 3.4175940 + ( 4.1616724 + y ) * y;\n\tfloat v = a / b;\n\tfloat theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v;\n\treturn cross( v1, v2 ) * theta_sintheta;\n}\nvec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) {\n\tvec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ];\n\tvec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ];\n\tvec3 lightNormal = cross( v1, v2 );\n\tif( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 );\n\tvec3 T1, T2;\n\tT1 = normalize( V - N * dot( V, N ) );\n\tT2 = - cross( N, T1 );\n\tmat3 mat = mInv * transpose( mat3( T1, T2, N ) );\n\tvec3 coords[ 4 ];\n\tcoords[ 0 ] = mat * ( rectCoords[ 0 ] - P );\n\tcoords[ 1 ] = mat * ( rectCoords[ 1 ] - P );\n\tcoords[ 2 ] = mat * ( rectCoords[ 2 ] - P );\n\tcoords[ 3 ] = mat * ( rectCoords[ 3 ] - P );\n\tcoords[ 0 ] = normalize( coords[ 0 ] );\n\tcoords[ 1 ] = normalize( coords[ 1 ] );\n\tcoords[ 2 ] = normalize( coords[ 2 ] );\n\tcoords[ 3 ] = normalize( coords[ 3 ] );\n\tvec3 vectorFormFactor = vec3( 0.0 );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] );\n\tfloat result = LTC_ClippedSphereFormFactor( vectorFormFactor );\n\treturn vec3( result );\n}\n#if defined( USE_SHEEN )\nfloat D_Charlie( float roughness, float dotNH ) {\n\tfloat alpha = pow2( roughness );\n\tfloat invAlpha = 1.0 / alpha;\n\tfloat cos2h = dotNH * dotNH;\n\tfloat sin2h = max( 1.0 - cos2h, 0.0078125 );\n\treturn ( 2.0 + invAlpha ) * pow( sin2h, invAlpha * 0.5 ) / ( 2.0 * PI );\n}\nfloat V_Neubelt( float dotNV, float dotNL ) {\n\treturn saturate( 1.0 / ( 4.0 * ( dotNL + dotNV - dotNL * dotNV ) ) );\n}\nvec3 BRDF_Sheen( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, vec3 sheenColor, const in float sheenRoughness ) {\n\tvec3 halfDir = normalize( lightDir + viewDir );\n\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat D = D_Charlie( sheenRoughness, dotNH );\n\tfloat V = V_Neubelt( dotNV, dotNL );\n\treturn sheenColor * ( D * V );\n}\n#endif\nfloat IBLSheenBRDF( const in vec3 normal, const in vec3 viewDir, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat r2 = roughness * roughness;\n\tfloat a = roughness < 0.25 ? -339.2 * r2 + 161.4 * roughness - 25.9 : -8.48 * r2 + 14.3 * roughness - 9.95;\n\tfloat b = roughness < 0.25 ? 44.0 * r2 - 23.7 * roughness + 3.26 : 1.97 * r2 - 3.27 * roughness + 0.72;\n\tfloat DG = exp( a * dotNV + b ) + ( roughness < 0.25 ? 0.0 : 0.1 * ( roughness - 0.25 ) );\n\treturn saturate( DG * RECIPROCAL_PI );\n}\nvec2 DFGApprox( const in vec3 normal, const in vec3 viewDir, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tvec2 uv = vec2( roughness, dotNV );\n\treturn texture2D( dfgLUT, uv ).rg;\n}\nvec3 EnvironmentBRDF( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float roughness ) {\n\tvec2 fab = DFGApprox( normal, viewDir, roughness );\n\treturn specularColor * fab.x + specularF90 * fab.y;\n}\n#ifdef USE_IRIDESCENCE\nvoid computeMultiscatteringIridescence( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float iridescence, const in vec3 iridescenceF0, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n#else\nvoid computeMultiscattering( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n#endif\n\tvec2 fab = DFGApprox( normal, viewDir, roughness );\n\t#ifdef USE_IRIDESCENCE\n\t\tvec3 Fr = mix( specularColor, iridescenceF0, iridescence );\n\t#else\n\t\tvec3 Fr = specularColor;\n\t#endif\n\tvec3 FssEss = Fr * fab.x + specularF90 * fab.y;\n\tfloat Ess = fab.x + fab.y;\n\tfloat Ems = 1.0 - Ess;\n\tvec3 Favg = Fr + ( 1.0 - Fr ) * 0.047619;\tvec3 Fms = FssEss * Favg / ( 1.0 - Ems * Favg );\n\tsingleScatter += FssEss;\n\tmultiScatter += Fms * Ems;\n}\nvec3 BRDF_GGX_Multiscatter( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material ) {\n\tvec3 singleScatter = BRDF_GGX( lightDir, viewDir, normal, material );\n\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tvec2 dfgV = DFGApprox( vec3(0.0, 0.0, 1.0), vec3(sqrt(1.0 - dotNV * dotNV), 0.0, dotNV), material.roughness );\n\tvec2 dfgL = DFGApprox( vec3(0.0, 0.0, 1.0), vec3(sqrt(1.0 - dotNL * dotNL), 0.0, dotNL), material.roughness );\n\tvec3 FssEss_V = material.specularColor * dfgV.x + material.specularF90 * dfgV.y;\n\tvec3 FssEss_L = material.specularColor * dfgL.x + material.specularF90 * dfgL.y;\n\tfloat Ess_V = dfgV.x + dfgV.y;\n\tfloat Ess_L = dfgL.x + dfgL.y;\n\tfloat Ems_V = 1.0 - Ess_V;\n\tfloat Ems_L = 1.0 - Ess_L;\n\tvec3 Favg = material.specularColor + ( 1.0 - material.specularColor ) * 0.047619;\n\tvec3 Fms = FssEss_V * FssEss_L * Favg / ( 1.0 - Ems_V * Ems_L * Favg * Favg + EPSILON );\n\tfloat compensationFactor = Ems_V * Ems_L;\n\tvec3 multiScatter = Fms * compensationFactor;\n\treturn singleScatter + multiScatter;\n}\n#if NUM_RECT_AREA_LIGHTS > 0\n\tvoid RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t\tvec3 normal = geometryNormal;\n\t\tvec3 viewDir = geometryViewDir;\n\t\tvec3 position = geometryPosition;\n\t\tvec3 lightPos = rectAreaLight.position;\n\t\tvec3 halfWidth = rectAreaLight.halfWidth;\n\t\tvec3 halfHeight = rectAreaLight.halfHeight;\n\t\tvec3 lightColor = rectAreaLight.color;\n\t\tfloat roughness = material.roughness;\n\t\tvec3 rectCoords[ 4 ];\n\t\trectCoords[ 0 ] = lightPos + halfWidth - halfHeight;\t\trectCoords[ 1 ] = lightPos - halfWidth - halfHeight;\n\t\trectCoords[ 2 ] = lightPos - halfWidth + halfHeight;\n\t\trectCoords[ 3 ] = lightPos + halfWidth + halfHeight;\n\t\tvec2 uv = LTC_Uv( normal, viewDir, roughness );\n\t\tvec4 t1 = texture2D( ltc_1, uv );\n\t\tvec4 t2 = texture2D( ltc_2, uv );\n\t\tmat3 mInv = mat3(\n\t\t\tvec3( t1.x, 0, t1.y ),\n\t\t\tvec3(    0, 1,    0 ),\n\t\t\tvec3( t1.z, 0, t1.w )\n\t\t);\n\t\tvec3 fresnel = ( material.specularColor * t2.x + ( vec3( 1.0 ) - material.specularColor ) * t2.y );\n\t\treflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords );\n\t\treflectedLight.directDiffuse += lightColor * material.diffuseColor * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords );\n\t}\n#endif\nvoid RE_Direct_Physical( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifdef USE_CLEARCOAT\n\t\tfloat dotNLcc = saturate( dot( geometryClearcoatNormal, directLight.direction ) );\n\t\tvec3 ccIrradiance = dotNLcc * directLight.color;\n\t\tclearcoatSpecularDirect += ccIrradiance * BRDF_GGX_Clearcoat( directLight.direction, geometryViewDir, geometryClearcoatNormal, material );\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tsheenSpecularDirect += irradiance * BRDF_Sheen( directLight.direction, geometryViewDir, geometryNormal, material.sheenColor, material.sheenRoughness );\n\t#endif\n\treflectedLight.directSpecular += irradiance * BRDF_GGX_Multiscatter( directLight.direction, geometryViewDir, geometryNormal, material );\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 irradiance, const in vec3 clearcoatRadiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {\n\t#ifdef USE_CLEARCOAT\n\t\tclearcoatSpecularIndirect += clearcoatRadiance * EnvironmentBRDF( geometryClearcoatNormal, geometryViewDir, material.clearcoatF0, material.clearcoatF90, material.clearcoatRoughness );\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tsheenSpecularIndirect += irradiance * material.sheenColor * IBLSheenBRDF( geometryNormal, geometryViewDir, material.sheenRoughness );\n\t#endif\n\tvec3 singleScattering = vec3( 0.0 );\n\tvec3 multiScattering = vec3( 0.0 );\n\tvec3 cosineWeightedIrradiance = irradiance * RECIPROCAL_PI;\n\t#ifdef USE_IRIDESCENCE\n\t\tcomputeMultiscatteringIridescence( geometryNormal, geometryViewDir, material.specularColor, material.specularF90, material.iridescence, material.iridescenceFresnel, material.roughness, singleScattering, multiScattering );\n\t#else\n\t\tcomputeMultiscattering( geometryNormal, geometryViewDir, material.specularColor, material.specularF90, material.roughness, singleScattering, multiScattering );\n\t#endif\n\tvec3 totalScattering = singleScattering + multiScattering;\n\tvec3 diffuse = material.diffuseColor * ( 1.0 - max( max( totalScattering.r, totalScattering.g ), totalScattering.b ) );\n\treflectedLight.indirectSpecular += radiance * singleScattering;\n\treflectedLight.indirectSpecular += multiScattering * cosineWeightedIrradiance;\n\treflectedLight.indirectDiffuse += diffuse * cosineWeightedIrradiance;\n}\n#define RE_Direct\t\t\t\tRE_Direct_Physical\n#define RE_Direct_RectArea\t\tRE_Direct_RectArea_Physical\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Physical\n#define RE_IndirectSpecular\t\tRE_IndirectSpecular_Physical\nfloat computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) {\n\treturn saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion );\n}`,Awe=`\nvec3 geometryPosition = - vViewPosition;\nvec3 geometryNormal = normal;\nvec3 geometryViewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( vViewPosition );\nvec3 geometryClearcoatNormal = vec3( 0.0 );\n#ifdef USE_CLEARCOAT\n\tgeometryClearcoatNormal = clearcoatNormal;\n#endif\n#ifdef USE_IRIDESCENCE\n\tfloat dotNVi = saturate( dot( normal, geometryViewDir ) );\n\tif ( material.iridescenceThickness == 0.0 ) {\n\t\tmaterial.iridescence = 0.0;\n\t} else {\n\t\tmaterial.iridescence = saturate( material.iridescence );\n\t}\n\tif ( material.iridescence > 0.0 ) {\n\t\tmaterial.iridescenceFresnel = evalIridescence( 1.0, material.iridescenceIOR, dotNVi, material.iridescenceThickness, material.specularColor );\n\t\tmaterial.iridescenceF0 = Schlick_to_F0( material.iridescenceFresnel, 1.0, dotNVi );\n\t}\n#endif\nIncidentLight directLight;\n#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct )\n\tPointLight pointLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tgetPointLightInfo( pointLight, geometryPosition, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_POINT_LIGHT_SHADOWS )\n\t\tpointLightShadow = pointLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getPointShadow( pointShadowMap[ i ], pointLightShadow.shadowMapSize, pointLightShadow.shadowIntensity, pointLightShadow.shadowBias, pointLightShadow.shadowRadius, vPointShadowCoord[ i ], pointLightShadow.shadowCameraNear, pointLightShadow.shadowCameraFar ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct )\n\tSpotLight spotLight;\n\tvec4 spotColor;\n\tvec3 spotLightCoord;\n\tbool inSpotLightMap;\n\t#if defined( USE_SHADOWMAP ) && NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tgetSpotLightInfo( spotLight, geometryPosition, directLight );\n\t\t#if ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS )\n\t\t#define SPOT_LIGHT_MAP_INDEX UNROLLED_LOOP_INDEX\n\t\t#elif ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\t#define SPOT_LIGHT_MAP_INDEX NUM_SPOT_LIGHT_MAPS\n\t\t#else\n\t\t#define SPOT_LIGHT_MAP_INDEX ( UNROLLED_LOOP_INDEX - NUM_SPOT_LIGHT_SHADOWS + NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS )\n\t\t#endif\n\t\t#if ( SPOT_LIGHT_MAP_INDEX < NUM_SPOT_LIGHT_MAPS )\n\t\t\tspotLightCoord = vSpotLightCoord[ i ].xyz / vSpotLightCoord[ i ].w;\n\t\t\tinSpotLightMap = all( lessThan( abs( spotLightCoord * 2. - 1. ), vec3( 1.0 ) ) );\n\t\t\tspotColor = texture2D( spotLightMap[ SPOT_LIGHT_MAP_INDEX ], spotLightCoord.xy );\n\t\t\tdirectLight.color = inSpotLightMap ? directLight.color * spotColor.rgb : directLight.color;\n\t\t#endif\n\t\t#undef SPOT_LIGHT_MAP_INDEX\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\tspotLightShadow = spotLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( spotShadowMap[ i ], spotLightShadow.shadowMapSize, spotLightShadow.shadowIntensity, spotLightShadow.shadowBias, spotLightShadow.shadowRadius, vSpotLightCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n\tDirectionalLight directionalLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tgetDirectionalLightInfo( directionalLight, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS )\n\t\tdirectionalLightShadow = directionalLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowIntensity, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea )\n\tRectAreaLight rectAreaLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) {\n\t\trectAreaLight = rectAreaLights[ i ];\n\t\tRE_Direct_RectArea( rectAreaLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if defined( RE_IndirectDiffuse )\n\tvec3 iblIrradiance = vec3( 0.0 );\n\tvec3 irradiance = getAmbientLightIrradiance( ambientLightColor );\n\t#if defined( USE_LIGHT_PROBES )\n\t\tirradiance += getLightProbeIrradiance( lightProbe, geometryNormal );\n\t#endif\n\t#if ( NUM_HEMI_LIGHTS > 0 )\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\t\tirradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometryNormal );\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if defined( RE_IndirectSpecular )\n\tvec3 radiance = vec3( 0.0 );\n\tvec3 clearcoatRadiance = vec3( 0.0 );\n#endif`,jwe=`#if defined( RE_IndirectDiffuse )\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel = texture2D( lightMap, vLightMapUv );\n\t\tvec3 lightMapIrradiance = lightMapTexel.rgb * lightMapIntensity;\n\t\tirradiance += lightMapIrradiance;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( STANDARD ) && defined( ENVMAP_TYPE_CUBE_UV )\n\t\tiblIrradiance += getIBLIrradiance( geometryNormal );\n\t#endif\n#endif\n#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular )\n\t#ifdef USE_ANISOTROPY\n\t\tradiance += getIBLAnisotropyRadiance( geometryViewDir, geometryNormal, material.roughness, material.anisotropyB, material.anisotropy );\n\t#else\n\t\tradiance += getIBLRadiance( geometryViewDir, geometryNormal, material.roughness );\n\t#endif\n\t#ifdef USE_CLEARCOAT\n\t\tclearcoatRadiance += getIBLRadiance( geometryViewDir, geometryClearcoatNormal, material.clearcoatRoughness );\n\t#endif\n#endif`,Mwe=`#if defined( RE_IndirectDiffuse )\n\tRE_IndirectDiffuse( irradiance, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n#endif\n#if defined( RE_IndirectSpecular )\n\tRE_IndirectSpecular( radiance, iblIrradiance, clearcoatRadiance, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n#endif`,Ewe=`#if defined( USE_LOGARITHMIC_DEPTH_BUFFER )\n\tgl_FragDepth = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;\n#endif`,Dwe=`#if defined( USE_LOGARITHMIC_DEPTH_BUFFER )\n\tuniform float logDepthBufFC;\n\tvarying float vFragDepth;\n\tvarying float vIsPerspective;\n#endif`,Fwe=`#ifdef USE_LOGARITHMIC_DEPTH_BUFFER\n\tvarying float vFragDepth;\n\tvarying float vIsPerspective;\n#endif`,Rwe=`#ifdef USE_LOGARITHMIC_DEPTH_BUFFER\n\tvFragDepth = 1.0 + gl_Position.w;\n\tvIsPerspective = float( isPerspectiveMatrix( projectionMatrix ) );\n#endif`,Lwe=`#ifdef USE_MAP\n\tvec4 sampledDiffuseColor = texture2D( map, vMapUv );\n\t#ifdef DECODE_VIDEO_TEXTURE\n\t\tsampledDiffuseColor = sRGBTransferEOTF( sampledDiffuseColor );\n\t#endif\n\tdiffuseColor *= sampledDiffuseColor;\n#endif`,Owe=`#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif`,Iwe=`#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\t#if defined( USE_POINTS_UV )\n\t\tvec2 uv = vUv;\n\t#else\n\t\tvec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;\n\t#endif\n#endif\n#ifdef USE_MAP\n\tdiffuseColor *= texture2D( map, uv );\n#endif\n#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, uv ).g;\n#endif`,zwe=`#if defined( USE_POINTS_UV )\n\tvarying vec2 vUv;\n#else\n\t#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\t\tuniform mat3 uvTransform;\n\t#endif\n#endif\n#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif`,Uwe=`float metalnessFactor = metalness;\n#ifdef USE_METALNESSMAP\n\tvec4 texelMetalness = texture2D( metalnessMap, vMetalnessMapUv );\n\tmetalnessFactor *= texelMetalness.b;\n#endif`,Bwe=`#ifdef USE_METALNESSMAP\n\tuniform sampler2D metalnessMap;\n#endif`,Hwe=`#ifdef USE_INSTANCING_MORPH\n\tfloat morphTargetInfluences[ MORPHTARGETS_COUNT ];\n\tfloat morphTargetBaseInfluence = texelFetch( morphTexture, ivec2( 0, gl_InstanceID ), 0 ).r;\n\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\tmorphTargetInfluences[i] =  texelFetch( morphTexture, ivec2( i + 1, gl_InstanceID ), 0 ).r;\n\t}\n#endif`,qwe=`#if defined( USE_MORPHCOLORS )\n\tvColor *= morphTargetBaseInfluence;\n\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\t#if defined( USE_COLOR_ALPHA )\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ) * morphTargetInfluences[ i ];\n\t\t#elif defined( USE_COLOR )\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ).rgb * morphTargetInfluences[ i ];\n\t\t#endif\n\t}\n#endif`,$we=`#ifdef USE_MORPHNORMALS\n\tobjectNormal *= morphTargetBaseInfluence;\n\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\tif ( morphTargetInfluences[ i ] != 0.0 ) objectNormal += getMorph( gl_VertexID, i, 1 ).xyz * morphTargetInfluences[ i ];\n\t}\n#endif`,Vwe=`#ifdef USE_MORPHTARGETS\n\t#ifndef USE_INSTANCING_MORPH\n\t\tuniform float morphTargetBaseInfluence;\n\t\tuniform float morphTargetInfluences[ MORPHTARGETS_COUNT ];\n\t#endif\n\tuniform sampler2DArray morphTargetsTexture;\n\tuniform ivec2 morphTargetsTextureSize;\n\tvec4 getMorph( const in int vertexIndex, const in int morphTargetIndex, const in int offset ) {\n\t\tint texelIndex = vertexIndex * MORPHTARGETS_TEXTURE_STRIDE + offset;\n\t\tint y = texelIndex / morphTargetsTextureSize.x;\n\t\tint x = texelIndex - y * morphTargetsTextureSize.x;\n\t\tivec3 morphUV = ivec3( x, y, morphTargetIndex );\n\t\treturn texelFetch( morphTargetsTexture, morphUV, 0 );\n\t}\n#endif`,Gwe=`#ifdef USE_MORPHTARGETS\n\ttransformed *= morphTargetBaseInfluence;\n\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\tif ( morphTargetInfluences[ i ] != 0.0 ) transformed += getMorph( gl_VertexID, i, 0 ).xyz * morphTargetInfluences[ i ];\n\t}\n#endif`,Wwe=`float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;\n#ifdef FLAT_SHADED\n\tvec3 fdx = dFdx( vViewPosition );\n\tvec3 fdy = dFdy( vViewPosition );\n\tvec3 normal = normalize( cross( fdx, fdy ) );\n#else\n\tvec3 normal = normalize( vNormal );\n\t#ifdef DOUBLE_SIDED\n\t\tnormal *= faceDirection;\n\t#endif\n#endif\n#if defined( USE_NORMALMAP_TANGENTSPACE ) || defined( USE_CLEARCOAT_NORMALMAP ) || defined( USE_ANISOTROPY )\n\t#ifdef USE_TANGENT\n\t\tmat3 tbn = mat3( normalize( vTangent ), normalize( vBitangent ), normal );\n\t#else\n\t\tmat3 tbn = getTangentFrame( - vViewPosition, normal,\n\t\t#if defined( USE_NORMALMAP )\n\t\t\tvNormalMapUv\n\t\t#elif defined( USE_CLEARCOAT_NORMALMAP )\n\t\t\tvClearcoatNormalMapUv\n\t\t#else\n\t\t\tvUv\n\t\t#endif\n\t\t);\n\t#endif\n\t#if defined( DOUBLE_SIDED ) && ! defined( FLAT_SHADED )\n\t\ttbn[0] *= faceDirection;\n\t\ttbn[1] *= faceDirection;\n\t#endif\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\t#ifdef USE_TANGENT\n\t\tmat3 tbn2 = mat3( normalize( vTangent ), normalize( vBitangent ), normal );\n\t#else\n\t\tmat3 tbn2 = getTangentFrame( - vViewPosition, normal, vClearcoatNormalMapUv );\n\t#endif\n\t#if defined( DOUBLE_SIDED ) && ! defined( FLAT_SHADED )\n\t\ttbn2[0] *= faceDirection;\n\t\ttbn2[1] *= faceDirection;\n\t#endif\n#endif\nvec3 nonPerturbedNormal = normal;`,Kwe=`#ifdef USE_NORMALMAP_OBJECTSPACE\n\tnormal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;\n\t#ifdef FLIP_SIDED\n\t\tnormal = - normal;\n\t#endif\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\tnormal = normalize( normalMatrix * normal );\n#elif defined( USE_NORMALMAP_TANGENTSPACE )\n\tvec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;\n\tmapN.xy *= normalScale;\n\tnormal = normalize( tbn * mapN );\n#elif defined( USE_BUMPMAP )\n\tnormal = perturbNormalArb( - vViewPosition, normal, dHdxy_fwd(), faceDirection );\n#endif`,Xwe=`#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif`,Ywe=`#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif`,Qwe=`#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif`,Zwe=`#ifdef USE_NORMALMAP\n\tuniform sampler2D normalMap;\n\tuniform vec2 normalScale;\n#endif\n#ifdef USE_NORMALMAP_OBJECTSPACE\n\tuniform mat3 normalMatrix;\n#endif\n#if ! defined ( USE_TANGENT ) && ( defined ( USE_NORMALMAP_TANGENTSPACE ) || defined ( USE_CLEARCOAT_NORMALMAP ) || defined( USE_ANISOTROPY ) )\n\tmat3 getTangentFrame( vec3 eye_pos, vec3 surf_norm, vec2 uv ) {\n\t\tvec3 q0 = dFdx( eye_pos.xyz );\n\t\tvec3 q1 = dFdy( eye_pos.xyz );\n\t\tvec2 st0 = dFdx( uv.st );\n\t\tvec2 st1 = dFdy( uv.st );\n\t\tvec3 N = surf_norm;\n\t\tvec3 q1perp = cross( q1, N );\n\t\tvec3 q0perp = cross( N, q0 );\n\t\tvec3 T = q1perp * st0.x + q0perp * st1.x;\n\t\tvec3 B = q1perp * st0.y + q0perp * st1.y;\n\t\tfloat det = max( dot( T, T ), dot( B, B ) );\n\t\tfloat scale = ( det == 0.0 ) ? 0.0 : inversesqrt( det );\n\t\treturn mat3( T * scale, B * scale, N );\n\t}\n#endif`,Jwe=`#ifdef USE_CLEARCOAT\n\tvec3 clearcoatNormal = nonPerturbedNormal;\n#endif`,eSe=`#ifdef USE_CLEARCOAT_NORMALMAP\n\tvec3 clearcoatMapN = texture2D( clearcoatNormalMap, vClearcoatNormalMapUv ).xyz * 2.0 - 1.0;\n\tclearcoatMapN.xy *= clearcoatNormalScale;\n\tclearcoatNormal = normalize( tbn2 * clearcoatMapN );\n#endif`,tSe=`#ifdef USE_CLEARCOATMAP\n\tuniform sampler2D clearcoatMap;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform sampler2D clearcoatNormalMap;\n\tuniform vec2 clearcoatNormalScale;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform sampler2D clearcoatRoughnessMap;\n#endif`,nSe=`#ifdef USE_IRIDESCENCEMAP\n\tuniform sampler2D iridescenceMap;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tuniform sampler2D iridescenceThicknessMap;\n#endif`,rSe=`#ifdef OPAQUE\ndiffuseColor.a = 1.0;\n#endif\n#ifdef USE_TRANSMISSION\ndiffuseColor.a *= material.transmissionAlpha;\n#endif\ngl_FragColor = vec4( outgoingLight, diffuseColor.a );`,aSe=`vec3 packNormalToRGB( const in vec3 normal ) {\n\treturn normalize( normal ) * 0.5 + 0.5;\n}\nvec3 unpackRGBToNormal( const in vec3 rgb ) {\n\treturn 2.0 * rgb.xyz - 1.0;\n}\nconst float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;const float ShiftRight8 = 1. / 256.;\nconst float Inv255 = 1. / 255.;\nconst vec4 PackFactors = vec4( 1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0 );\nconst vec2 UnpackFactors2 = vec2( UnpackDownscale, 1.0 / PackFactors.g );\nconst vec3 UnpackFactors3 = vec3( UnpackDownscale / PackFactors.rg, 1.0 / PackFactors.b );\nconst vec4 UnpackFactors4 = vec4( UnpackDownscale / PackFactors.rgb, 1.0 / PackFactors.a );\nvec4 packDepthToRGBA( const in float v ) {\n\tif( v <= 0.0 )\n\t\treturn vec4( 0., 0., 0., 0. );\n\tif( v >= 1.0 )\n\t\treturn vec4( 1., 1., 1., 1. );\n\tfloat vuf;\n\tfloat af = modf( v * PackFactors.a, vuf );\n\tfloat bf = modf( vuf * ShiftRight8, vuf );\n\tfloat gf = modf( vuf * ShiftRight8, vuf );\n\treturn vec4( vuf * Inv255, gf * PackUpscale, bf * PackUpscale, af );\n}\nvec3 packDepthToRGB( const in float v ) {\n\tif( v <= 0.0 )\n\t\treturn vec3( 0., 0., 0. );\n\tif( v >= 1.0 )\n\t\treturn vec3( 1., 1., 1. );\n\tfloat vuf;\n\tfloat bf = modf( v * PackFactors.b, vuf );\n\tfloat gf = modf( vuf * ShiftRight8, vuf );\n\treturn vec3( vuf * Inv255, gf * PackUpscale, bf );\n}\nvec2 packDepthToRG( const in float v ) {\n\tif( v <= 0.0 )\n\t\treturn vec2( 0., 0. );\n\tif( v >= 1.0 )\n\t\treturn vec2( 1., 1. );\n\tfloat vuf;\n\tfloat gf = modf( v * 256., vuf );\n\treturn vec2( vuf * Inv255, gf );\n}\nfloat unpackRGBAToDepth( const in vec4 v ) {\n\treturn dot( v, UnpackFactors4 );\n}\nfloat unpackRGBToDepth( const in vec3 v ) {\n\treturn dot( v, UnpackFactors3 );\n}\nfloat unpackRGToDepth( const in vec2 v ) {\n\treturn v.r * UnpackFactors2.r + v.g * UnpackFactors2.g;\n}\nvec4 pack2HalfToRGBA( const in vec2 v ) {\n\tvec4 r = vec4( v.x, fract( v.x * 255.0 ), v.y, fract( v.y * 255.0 ) );\n\treturn vec4( r.x - r.y / 255.0, r.y, r.z - r.w / 255.0, r.w );\n}\nvec2 unpackRGBATo2Half( const in vec4 v ) {\n\treturn vec2( v.x + ( v.y / 255.0 ), v.z + ( v.w / 255.0 ) );\n}\nfloat viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( viewZ + near ) / ( near - far );\n}\nfloat orthographicDepthToViewZ( const in float depth, const in float near, const in float far ) {\n\treturn depth * ( near - far ) - near;\n}\nfloat viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( ( near + viewZ ) * far ) / ( ( far - near ) * viewZ );\n}\nfloat perspectiveDepthToViewZ( const in float depth, const in float near, const in float far ) {\n\treturn ( near * far ) / ( ( far - near ) * depth - far );\n}`,iSe=`#ifdef PREMULTIPLIED_ALPHA\n\tgl_FragColor.rgb *= gl_FragColor.a;\n#endif`,sSe=`vec4 mvPosition = vec4( transformed, 1.0 );\n#ifdef USE_BATCHING\n\tmvPosition = batchingMatrix * mvPosition;\n#endif\n#ifdef USE_INSTANCING\n\tmvPosition = instanceMatrix * mvPosition;\n#endif\nmvPosition = modelViewMatrix * mvPosition;\ngl_Position = projectionMatrix * mvPosition;`,oSe=`#ifdef DITHERING\n\tgl_FragColor.rgb = dithering( gl_FragColor.rgb );\n#endif`,lSe=`#ifdef DITHERING\n\tvec3 dithering( vec3 color ) {\n\t\tfloat grid_position = rand( gl_FragCoord.xy );\n\t\tvec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 );\n\t\tdither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position );\n\t\treturn color + dither_shift_RGB;\n\t}\n#endif`,cSe=`float roughnessFactor = roughness;\n#ifdef USE_ROUGHNESSMAP\n\tvec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv );\n\troughnessFactor *= texelRoughness.g;\n#endif`,dSe=`#ifdef USE_ROUGHNESSMAP\n\tuniform sampler2D roughnessMap;\n#endif`,uSe=`#if NUM_SPOT_LIGHT_COORDS > 0\n\tvarying vec4 vSpotLightCoord[ NUM_SPOT_LIGHT_COORDS ];\n#endif\n#if NUM_SPOT_LIGHT_MAPS > 0\n\tuniform sampler2D spotLightMap[ NUM_SPOT_LIGHT_MAPS ];\n#endif\n#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D directionalShadowMap[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowIntensity;\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D spotShadowMap[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowIntensity;\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D pointShadowMap[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowIntensity;\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n\tfloat texture2DCompare( sampler2D depths, vec2 uv, float compare ) {\n\t\tfloat depth = unpackRGBAToDepth( texture2D( depths, uv ) );\n\t\t#ifdef USE_REVERSED_DEPTH_BUFFER\n\t\t\treturn step( depth, compare );\n\t\t#else\n\t\t\treturn step( compare, depth );\n\t\t#endif\n\t}\n\tvec2 texture2DDistribution( sampler2D shadow, vec2 uv ) {\n\t\treturn unpackRGBATo2Half( texture2D( shadow, uv ) );\n\t}\n\tfloat VSMShadow( sampler2D shadow, vec2 uv, float compare ) {\n\t\tfloat occlusion = 1.0;\n\t\tvec2 distribution = texture2DDistribution( shadow, uv );\n\t\t#ifdef USE_REVERSED_DEPTH_BUFFER\n\t\t\tfloat hard_shadow = step( distribution.x, compare );\n\t\t#else\n\t\t\tfloat hard_shadow = step( compare, distribution.x );\n\t\t#endif\n\t\tif ( hard_shadow != 1.0 ) {\n\t\t\tfloat distance = compare - distribution.x;\n\t\t\tfloat variance = max( 0.00000, distribution.y * distribution.y );\n\t\t\tfloat softness_probability = variance / (variance + distance * distance );\t\t\tsoftness_probability = clamp( ( softness_probability - 0.3 ) / ( 0.95 - 0.3 ), 0.0, 1.0 );\t\t\tocclusion = clamp( max( hard_shadow, softness_probability ), 0.0, 1.0 );\n\t\t}\n\t\treturn occlusion;\n\t}\n\tfloat getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord ) {\n\t\tfloat shadow = 1.0;\n\t\tshadowCoord.xyz /= shadowCoord.w;\n\t\tshadowCoord.z += shadowBias;\n\t\tbool inFrustum = shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0;\n\t\tbool frustumTest = inFrustum && shadowCoord.z <= 1.0;\n\t\tif ( frustumTest ) {\n\t\t#if defined( SHADOWMAP_TYPE_PCF )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tfloat dx2 = dx0 / 2.0;\n\t\t\tfloat dy2 = dy0 / 2.0;\n\t\t\tfloat dx3 = dx1 / 2.0;\n\t\t\tfloat dy3 = dy1 / 2.0;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 17.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx = texelSize.x;\n\t\t\tfloat dy = texelSize.y;\n\t\t\tvec2 uv = shadowCoord.xy;\n\t\t\tvec2 f = fract( uv * shadowMapSize + 0.5 );\n\t\t\tuv -= f * texelSize;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, uv, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( dx, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 0.0, dy ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + texelSize, shadowCoord.z ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, dy ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( 0.0, -dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 0.0, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( mix( texture2DCompare( shadowMap, uv + vec2( -dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t  texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t  f.x ),\n\t\t\t\t\t mix( texture2DCompare( shadowMap, uv + vec2( -dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t  texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t  f.x ),\n\t\t\t\t\t f.y )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_VSM )\n\t\t\tshadow = VSMShadow( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#else\n\t\t\tshadow = texture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#endif\n\t\t}\n\t\treturn mix( 1.0, shadow, shadowIntensity );\n\t}\n\tvec2 cubeToUV( vec3 v, float texelSizeY ) {\n\t\tvec3 absV = abs( v );\n\t\tfloat scaleToCube = 1.0 / max( absV.x, max( absV.y, absV.z ) );\n\t\tabsV *= scaleToCube;\n\t\tv *= scaleToCube * ( 1.0 - 2.0 * texelSizeY );\n\t\tvec2 planar = v.xy;\n\t\tfloat almostATexel = 1.5 * texelSizeY;\n\t\tfloat almostOne = 1.0 - almostATexel;\n\t\tif ( absV.z >= almostOne ) {\n\t\t\tif ( v.z > 0.0 )\n\t\t\t\tplanar.x = 4.0 - v.x;\n\t\t} else if ( absV.x >= almostOne ) {\n\t\t\tfloat signX = sign( v.x );\n\t\t\tplanar.x = v.z * signX + 2.0 * signX;\n\t\t} else if ( absV.y >= almostOne ) {\n\t\t\tfloat signY = sign( v.y );\n\t\t\tplanar.x = v.x + 2.0 * signY + 2.0;\n\t\t\tplanar.y = v.z * signY - 2.0;\n\t\t}\n\t\treturn vec2( 0.125, 0.25 ) * planar + vec2( 0.375, 0.75 );\n\t}\n\tfloat getPointShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowIntensity, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) {\n\t\tfloat shadow = 1.0;\n\t\tvec3 lightToPosition = shadowCoord.xyz;\n\t\t\n\t\tfloat lightToPositionLength = length( lightToPosition );\n\t\tif ( lightToPositionLength - shadowCameraFar <= 0.0 && lightToPositionLength - shadowCameraNear >= 0.0 ) {\n\t\t\tfloat dp = ( lightToPositionLength - shadowCameraNear ) / ( shadowCameraFar - shadowCameraNear );\t\t\tdp += shadowBias;\n\t\t\tvec3 bd3D = normalize( lightToPosition );\n\t\t\tvec2 texelSize = vec2( 1.0 ) / ( shadowMapSize * vec2( 4.0, 2.0 ) );\n\t\t\t#if defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_PCF_SOFT ) || defined( SHADOWMAP_TYPE_VSM )\n\t\t\t\tvec2 offset = vec2( - 1, 1 ) * shadowRadius * texelSize.y;\n\t\t\t\tshadow = (\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyy, texelSize.y ), dp ) +\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyy, texelSize.y ), dp ) +\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyx, texelSize.y ), dp ) +\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyx, texelSize.y ), dp ) +\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp ) +\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxy, texelSize.y ), dp ) +\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxy, texelSize.y ), dp ) +\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxx, texelSize.y ), dp ) +\n\t\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxx, texelSize.y ), dp )\n\t\t\t\t) * ( 1.0 / 9.0 );\n\t\t\t#else\n\t\t\t\tshadow = texture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp );\n\t\t\t#endif\n\t\t}\n\t\treturn mix( 1.0, shadow, shadowIntensity );\n\t}\n#endif`,mSe=`#if NUM_SPOT_LIGHT_COORDS > 0\n\tuniform mat4 spotLightMatrix[ NUM_SPOT_LIGHT_COORDS ];\n\tvarying vec4 vSpotLightCoord[ NUM_SPOT_LIGHT_COORDS ];\n#endif\n#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowIntensity;\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowIntensity;\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 pointShadowMatrix[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowIntensity;\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n#endif`,hSe=`#if ( defined( USE_SHADOWMAP ) && ( NUM_DIR_LIGHT_SHADOWS > 0 || NUM_POINT_LIGHT_SHADOWS > 0 ) ) || ( NUM_SPOT_LIGHT_COORDS > 0 )\n\tvec3 shadowWorldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\tvec4 shadowWorldPosition;\n#endif\n#if defined( USE_SHADOWMAP )\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );\n\t\t\tvDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * shadowWorldPosition;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * pointLightShadows[ i ].shadowNormalBias, 0 );\n\t\t\tvPointShadowCoord[ i ] = pointShadowMatrix[ i ] * shadowWorldPosition;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if NUM_SPOT_LIGHT_COORDS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_COORDS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition;\n\t\t#if ( defined( USE_SHADOWMAP ) && UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\t\tshadowWorldPosition.xyz += shadowWorldNormal * spotLightShadows[ i ].shadowNormalBias;\n\t\t#endif\n\t\tvSpotLightCoord[ i ] = spotLightMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n#endif`,pSe=`float getShadowMask() {\n\tfloat shadow = 1.0;\n\t#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tdirectionalLight = directionalLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowIntensity, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tspotLight = spotLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowIntensity, spotLight.shadowBias, spotLight.shadowRadius, vSpotLightCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tpointLight = pointLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowIntensity, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#endif\n\treturn shadow;\n}`,fSe=`#ifdef USE_SKINNING\n\tmat4 boneMatX = getBoneMatrix( skinIndex.x );\n\tmat4 boneMatY = getBoneMatrix( skinIndex.y );\n\tmat4 boneMatZ = getBoneMatrix( skinIndex.z );\n\tmat4 boneMatW = getBoneMatrix( skinIndex.w );\n#endif`,gSe=`#ifdef USE_SKINNING\n\tuniform mat4 bindMatrix;\n\tuniform mat4 bindMatrixInverse;\n\tuniform highp sampler2D boneTexture;\n\tmat4 getBoneMatrix( const in float i ) {\n\t\tint size = textureSize( boneTexture, 0 ).x;\n\t\tint j = int( i ) * 4;\n\t\tint x = j % size;\n\t\tint y = j / size;\n\t\tvec4 v1 = texelFetch( boneTexture, ivec2( x, y ), 0 );\n\t\tvec4 v2 = texelFetch( boneTexture, ivec2( x + 1, y ), 0 );\n\t\tvec4 v3 = texelFetch( boneTexture, ivec2( x + 2, y ), 0 );\n\t\tvec4 v4 = texelFetch( boneTexture, ivec2( x + 3, y ), 0 );\n\t\treturn mat4( v1, v2, v3, v4 );\n\t}\n#endif`,bSe=`#ifdef USE_SKINNING\n\tvec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );\n\tvec4 skinned = vec4( 0.0 );\n\tskinned += boneMatX * skinVertex * skinWeight.x;\n\tskinned += boneMatY * skinVertex * skinWeight.y;\n\tskinned += boneMatZ * skinVertex * skinWeight.z;\n\tskinned += boneMatW * skinVertex * skinWeight.w;\n\ttransformed = ( bindMatrixInverse * skinned ).xyz;\n#endif`,xSe=`#ifdef USE_SKINNING\n\tmat4 skinMatrix = mat4( 0.0 );\n\tskinMatrix += skinWeight.x * boneMatX;\n\tskinMatrix += skinWeight.y * boneMatY;\n\tskinMatrix += skinWeight.z * boneMatZ;\n\tskinMatrix += skinWeight.w * boneMatW;\n\tskinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;\n\tobjectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;\n\t#ifdef USE_TANGENT\n\t\tobjectTangent = vec4( skinMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#endif\n#endif`,ySe=`float specularStrength;\n#ifdef USE_SPECULARMAP\n\tvec4 texelSpecular = texture2D( specularMap, vSpecularMapUv );\n\tspecularStrength = texelSpecular.r;\n#else\n\tspecularStrength = 1.0;\n#endif`,vSe=`#ifdef USE_SPECULARMAP\n\tuniform sampler2D specularMap;\n#endif`,wSe=`#if defined( TONE_MAPPING )\n\tgl_FragColor.rgb = toneMapping( gl_FragColor.rgb );\n#endif`,SSe=`#ifndef saturate\n#define saturate( a ) clamp( a, 0.0, 1.0 )\n#endif\nuniform float toneMappingExposure;\nvec3 LinearToneMapping( vec3 color ) {\n\treturn saturate( toneMappingExposure * color );\n}\nvec3 ReinhardToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( color / ( vec3( 1.0 ) + color ) );\n}\nvec3 CineonToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\tcolor = max( vec3( 0.0 ), color - 0.004 );\n\treturn pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) );\n}\nvec3 RRTAndODTFit( vec3 v ) {\n\tvec3 a = v * ( v + 0.0245786 ) - 0.000090537;\n\tvec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081;\n\treturn a / b;\n}\nvec3 ACESFilmicToneMapping( vec3 color ) {\n\tconst mat3 ACESInputMat = mat3(\n\t\tvec3( 0.59719, 0.07600, 0.02840 ),\t\tvec3( 0.35458, 0.90834, 0.13383 ),\n\t\tvec3( 0.04823, 0.01566, 0.83777 )\n\t);\n\tconst mat3 ACESOutputMat = mat3(\n\t\tvec3(  1.60475, -0.10208, -0.00327 ),\t\tvec3( -0.53108,  1.10813, -0.07276 ),\n\t\tvec3( -0.07367, -0.00605,  1.07602 )\n\t);\n\tcolor *= toneMappingExposure / 0.6;\n\tcolor = ACESInputMat * color;\n\tcolor = RRTAndODTFit( color );\n\tcolor = ACESOutputMat * color;\n\treturn saturate( color );\n}\nconst mat3 LINEAR_REC2020_TO_LINEAR_SRGB = mat3(\n\tvec3( 1.6605, - 0.1246, - 0.0182 ),\n\tvec3( - 0.5876, 1.1329, - 0.1006 ),\n\tvec3( - 0.0728, - 0.0083, 1.1187 )\n);\nconst mat3 LINEAR_SRGB_TO_LINEAR_REC2020 = mat3(\n\tvec3( 0.6274, 0.0691, 0.0164 ),\n\tvec3( 0.3293, 0.9195, 0.0880 ),\n\tvec3( 0.0433, 0.0113, 0.8956 )\n);\nvec3 agxDefaultContrastApprox( vec3 x ) {\n\tvec3 x2 = x * x;\n\tvec3 x4 = x2 * x2;\n\treturn + 15.5 * x4 * x2\n\t\t- 40.14 * x4 * x\n\t\t+ 31.96 * x4\n\t\t- 6.868 * x2 * x\n\t\t+ 0.4298 * x2\n\t\t+ 0.1191 * x\n\t\t- 0.00232;\n}\nvec3 AgXToneMapping( vec3 color ) {\n\tconst mat3 AgXInsetMatrix = mat3(\n\t\tvec3( 0.856627153315983, 0.137318972929847, 0.11189821299995 ),\n\t\tvec3( 0.0951212405381588, 0.761241990602591, 0.0767994186031903 ),\n\t\tvec3( 0.0482516061458583, 0.101439036467562, 0.811302368396859 )\n\t);\n\tconst mat3 AgXOutsetMatrix = mat3(\n\t\tvec3( 1.1271005818144368, - 0.1413297634984383, - 0.14132976349843826 ),\n\t\tvec3( - 0.11060664309660323, 1.157823702216272, - 0.11060664309660294 ),\n\t\tvec3( - 0.016493938717834573, - 0.016493938717834257, 1.2519364065950405 )\n\t);\n\tconst float AgxMinEv = - 12.47393;\tconst float AgxMaxEv = 4.026069;\n\tcolor *= toneMappingExposure;\n\tcolor = LINEAR_SRGB_TO_LINEAR_REC2020 * color;\n\tcolor = AgXInsetMatrix * color;\n\tcolor = max( color, 1e-10 );\tcolor = log2( color );\n\tcolor = ( color - AgxMinEv ) / ( AgxMaxEv - AgxMinEv );\n\tcolor = clamp( color, 0.0, 1.0 );\n\tcolor = agxDefaultContrastApprox( color );\n\tcolor = AgXOutsetMatrix * color;\n\tcolor = pow( max( vec3( 0.0 ), color ), vec3( 2.2 ) );\n\tcolor = LINEAR_REC2020_TO_LINEAR_SRGB * color;\n\tcolor = clamp( color, 0.0, 1.0 );\n\treturn color;\n}\nvec3 NeutralToneMapping( vec3 color ) {\n\tconst float StartCompression = 0.8 - 0.04;\n\tconst float Desaturation = 0.15;\n\tcolor *= toneMappingExposure;\n\tfloat x = min( color.r, min( color.g, color.b ) );\n\tfloat offset = x < 0.08 ? x - 6.25 * x * x : 0.04;\n\tcolor -= offset;\n\tfloat peak = max( color.r, max( color.g, color.b ) );\n\tif ( peak < StartCompression ) return color;\n\tfloat d = 1. - StartCompression;\n\tfloat newPeak = 1. - d * d / ( peak + d - StartCompression );\n\tcolor *= newPeak / peak;\n\tfloat g = 1. - 1. / ( Desaturation * ( peak - newPeak ) + 1. );\n\treturn mix( color, vec3( newPeak ), g );\n}\nvec3 CustomToneMapping( vec3 color ) { return color; }`,_Se=`#ifdef USE_TRANSMISSION\n\tmaterial.transmission = transmission;\n\tmaterial.transmissionAlpha = 1.0;\n\tmaterial.thickness = thickness;\n\tmaterial.attenuationDistance = attenuationDistance;\n\tmaterial.attenuationColor = attenuationColor;\n\t#ifdef USE_TRANSMISSIONMAP\n\t\tmaterial.transmission *= texture2D( transmissionMap, vTransmissionMapUv ).r;\n\t#endif\n\t#ifdef USE_THICKNESSMAP\n\t\tmaterial.thickness *= texture2D( thicknessMap, vThicknessMapUv ).g;\n\t#endif\n\tvec3 pos = vWorldPosition;\n\tvec3 v = normalize( cameraPosition - pos );\n\tvec3 n = inverseTransformDirection( normal, viewMatrix );\n\tvec4 transmitted = getIBLVolumeRefraction(\n\t\tn, v, material.roughness, material.diffuseColor, material.specularColor, material.specularF90,\n\t\tpos, modelMatrix, viewMatrix, projectionMatrix, material.dispersion, material.ior, material.thickness,\n\t\tmaterial.attenuationColor, material.attenuationDistance );\n\tmaterial.transmissionAlpha = mix( material.transmissionAlpha, transmitted.a, material.transmission );\n\ttotalDiffuse = mix( totalDiffuse, transmitted.rgb, material.transmission );\n#endif`,kSe=`#ifdef USE_TRANSMISSION\n\tuniform float transmission;\n\tuniform float thickness;\n\tuniform float attenuationDistance;\n\tuniform vec3 attenuationColor;\n\t#ifdef USE_TRANSMISSIONMAP\n\t\tuniform sampler2D transmissionMap;\n\t#endif\n\t#ifdef USE_THICKNESSMAP\n\t\tuniform sampler2D thicknessMap;\n\t#endif\n\tuniform vec2 transmissionSamplerSize;\n\tuniform sampler2D transmissionSamplerMap;\n\tuniform mat4 modelMatrix;\n\tuniform mat4 projectionMatrix;\n\tvarying vec3 vWorldPosition;\n\tfloat w0( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a * ( a * ( - a + 3.0 ) - 3.0 ) + 1.0 );\n\t}\n\tfloat w1( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a *  a * ( 3.0 * a - 6.0 ) + 4.0 );\n\t}\n\tfloat w2( float a ){\n\t\treturn ( 1.0 / 6.0 ) * ( a * ( a * ( - 3.0 * a + 3.0 ) + 3.0 ) + 1.0 );\n\t}\n\tfloat w3( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a * a * a );\n\t}\n\tfloat g0( float a ) {\n\t\treturn w0( a ) + w1( a );\n\t}\n\tfloat g1( float a ) {\n\t\treturn w2( a ) + w3( a );\n\t}\n\tfloat h0( float a ) {\n\t\treturn - 1.0 + w1( a ) / ( w0( a ) + w1( a ) );\n\t}\n\tfloat h1( float a ) {\n\t\treturn 1.0 + w3( a ) / ( w2( a ) + w3( a ) );\n\t}\n\tvec4 bicubic( sampler2D tex, vec2 uv, vec4 texelSize, float lod ) {\n\t\tuv = uv * texelSize.zw + 0.5;\n\t\tvec2 iuv = floor( uv );\n\t\tvec2 fuv = fract( uv );\n\t\tfloat g0x = g0( fuv.x );\n\t\tfloat g1x = g1( fuv.x );\n\t\tfloat h0x = h0( fuv.x );\n\t\tfloat h1x = h1( fuv.x );\n\t\tfloat h0y = h0( fuv.y );\n\t\tfloat h1y = h1( fuv.y );\n\t\tvec2 p0 = ( vec2( iuv.x + h0x, iuv.y + h0y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p1 = ( vec2( iuv.x + h1x, iuv.y + h0y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p2 = ( vec2( iuv.x + h0x, iuv.y + h1y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p3 = ( vec2( iuv.x + h1x, iuv.y + h1y ) - 0.5 ) * texelSize.xy;\n\t\treturn g0( fuv.y ) * ( g0x * textureLod( tex, p0, lod ) + g1x * textureLod( tex, p1, lod ) ) +\n\t\t\tg1( fuv.y ) * ( g0x * textureLod( tex, p2, lod ) + g1x * textureLod( tex, p3, lod ) );\n\t}\n\tvec4 textureBicubic( sampler2D sampler, vec2 uv, float lod ) {\n\t\tvec2 fLodSize = vec2( textureSize( sampler, int( lod ) ) );\n\t\tvec2 cLodSize = vec2( textureSize( sampler, int( lod + 1.0 ) ) );\n\t\tvec2 fLodSizeInv = 1.0 / fLodSize;\n\t\tvec2 cLodSizeInv = 1.0 / cLodSize;\n\t\tvec4 fSample = bicubic( sampler, uv, vec4( fLodSizeInv, fLodSize ), floor( lod ) );\n\t\tvec4 cSample = bicubic( sampler, uv, vec4( cLodSizeInv, cLodSize ), ceil( lod ) );\n\t\treturn mix( fSample, cSample, fract( lod ) );\n\t}\n\tvec3 getVolumeTransmissionRay( const in vec3 n, const in vec3 v, const in float thickness, const in float ior, const in mat4 modelMatrix ) {\n\t\tvec3 refractionVector = refract( - v, normalize( n ), 1.0 / ior );\n\t\tvec3 modelScale;\n\t\tmodelScale.x = length( vec3( modelMatrix[ 0 ].xyz ) );\n\t\tmodelScale.y = length( vec3( modelMatrix[ 1 ].xyz ) );\n\t\tmodelScale.z = length( vec3( modelMatrix[ 2 ].xyz ) );\n\t\treturn normalize( refractionVector ) * thickness * modelScale;\n\t}\n\tfloat applyIorToRoughness( const in float roughness, const in float ior ) {\n\t\treturn roughness * clamp( ior * 2.0 - 2.0, 0.0, 1.0 );\n\t}\n\tvec4 getTransmissionSample( const in vec2 fragCoord, const in float roughness, const in float ior ) {\n\t\tfloat lod = log2( transmissionSamplerSize.x ) * applyIorToRoughness( roughness, ior );\n\t\treturn textureBicubic( transmissionSamplerMap, fragCoord.xy, lod );\n\t}\n\tvec3 volumeAttenuation( const in float transmissionDistance, const in vec3 attenuationColor, const in float attenuationDistance ) {\n\t\tif ( isinf( attenuationDistance ) ) {\n\t\t\treturn vec3( 1.0 );\n\t\t} else {\n\t\t\tvec3 attenuationCoefficient = -log( attenuationColor ) / attenuationDistance;\n\t\t\tvec3 transmittance = exp( - attenuationCoefficient * transmissionDistance );\t\t\treturn transmittance;\n\t\t}\n\t}\n\tvec4 getIBLVolumeRefraction( const in vec3 n, const in vec3 v, const in float roughness, const in vec3 diffuseColor,\n\t\tconst in vec3 specularColor, const in float specularF90, const in vec3 position, const in mat4 modelMatrix,\n\t\tconst in mat4 viewMatrix, const in mat4 projMatrix, const in float dispersion, const in float ior, const in float thickness,\n\t\tconst in vec3 attenuationColor, const in float attenuationDistance ) {\n\t\tvec4 transmittedLight;\n\t\tvec3 transmittance;\n\t\t#ifdef USE_DISPERSION\n\t\t\tfloat halfSpread = ( ior - 1.0 ) * 0.025 * dispersion;\n\t\t\tvec3 iors = vec3( ior - halfSpread, ior, ior + halfSpread );\n\t\t\tfor ( int i = 0; i < 3; i ++ ) {\n\t\t\t\tvec3 transmissionRay = getVolumeTransmissionRay( n, v, thickness, iors[ i ], modelMatrix );\n\t\t\t\tvec3 refractedRayExit = position + transmissionRay;\n\t\t\t\tvec4 ndcPos = projMatrix * viewMatrix * vec4( refractedRayExit, 1.0 );\n\t\t\t\tvec2 refractionCoords = ndcPos.xy / ndcPos.w;\n\t\t\t\trefractionCoords += 1.0;\n\t\t\t\trefractionCoords /= 2.0;\n\t\t\t\tvec4 transmissionSample = getTransmissionSample( refractionCoords, roughness, iors[ i ] );\n\t\t\t\ttransmittedLight[ i ] = transmissionSample[ i ];\n\t\t\t\ttransmittedLight.a += transmissionSample.a;\n\t\t\t\ttransmittance[ i ] = diffuseColor[ i ] * volumeAttenuation( length( transmissionRay ), attenuationColor, attenuationDistance )[ i ];\n\t\t\t}\n\t\t\ttransmittedLight.a /= 3.0;\n\t\t#else\n\t\t\tvec3 transmissionRay = getVolumeTransmissionRay( n, v, thickness, ior, modelMatrix );\n\t\t\tvec3 refractedRayExit = position + transmissionRay;\n\t\t\tvec4 ndcPos = projMatrix * viewMatrix * vec4( refractedRayExit, 1.0 );\n\t\t\tvec2 refractionCoords = ndcPos.xy / ndcPos.w;\n\t\t\trefractionCoords += 1.0;\n\t\t\trefractionCoords /= 2.0;\n\t\t\ttransmittedLight = getTransmissionSample( refractionCoords, roughness, ior );\n\t\t\ttransmittance = diffuseColor * volumeAttenuation( length( transmissionRay ), attenuationColor, attenuationDistance );\n\t\t#endif\n\t\tvec3 attenuatedColor = transmittance * transmittedLight.rgb;\n\t\tvec3 F = EnvironmentBRDF( n, v, specularColor, specularF90, roughness );\n\t\tfloat transmittanceFactor = ( transmittance.r + transmittance.g + transmittance.b ) / 3.0;\n\t\treturn vec4( ( 1.0 - F ) * attenuatedColor, 1.0 - ( 1.0 - transmittedLight.a ) * transmittanceFactor );\n\t}\n#endif`,NSe=`#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvarying vec2 vUv;\n#endif\n#ifdef USE_MAP\n\tvarying vec2 vMapUv;\n#endif\n#ifdef USE_ALPHAMAP\n\tvarying vec2 vAlphaMapUv;\n#endif\n#ifdef USE_LIGHTMAP\n\tvarying vec2 vLightMapUv;\n#endif\n#ifdef USE_AOMAP\n\tvarying vec2 vAoMapUv;\n#endif\n#ifdef USE_BUMPMAP\n\tvarying vec2 vBumpMapUv;\n#endif\n#ifdef USE_NORMALMAP\n\tvarying vec2 vNormalMapUv;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tvarying vec2 vEmissiveMapUv;\n#endif\n#ifdef USE_METALNESSMAP\n\tvarying vec2 vMetalnessMapUv;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tvarying vec2 vRoughnessMapUv;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tvarying vec2 vAnisotropyMapUv;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tvarying vec2 vClearcoatMapUv;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tvarying vec2 vClearcoatNormalMapUv;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tvarying vec2 vClearcoatRoughnessMapUv;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tvarying vec2 vIridescenceMapUv;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tvarying vec2 vIridescenceThicknessMapUv;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tvarying vec2 vSheenColorMapUv;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tvarying vec2 vSheenRoughnessMapUv;\n#endif\n#ifdef USE_SPECULARMAP\n\tvarying vec2 vSpecularMapUv;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tvarying vec2 vSpecularColorMapUv;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tvarying vec2 vSpecularIntensityMapUv;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tuniform mat3 transmissionMapTransform;\n\tvarying vec2 vTransmissionMapUv;\n#endif\n#ifdef USE_THICKNESSMAP\n\tuniform mat3 thicknessMapTransform;\n\tvarying vec2 vThicknessMapUv;\n#endif`,CSe=`#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvarying vec2 vUv;\n#endif\n#ifdef USE_MAP\n\tuniform mat3 mapTransform;\n\tvarying vec2 vMapUv;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform mat3 alphaMapTransform;\n\tvarying vec2 vAlphaMapUv;\n#endif\n#ifdef USE_LIGHTMAP\n\tuniform mat3 lightMapTransform;\n\tvarying vec2 vLightMapUv;\n#endif\n#ifdef USE_AOMAP\n\tuniform mat3 aoMapTransform;\n\tvarying vec2 vAoMapUv;\n#endif\n#ifdef USE_BUMPMAP\n\tuniform mat3 bumpMapTransform;\n\tvarying vec2 vBumpMapUv;\n#endif\n#ifdef USE_NORMALMAP\n\tuniform mat3 normalMapTransform;\n\tvarying vec2 vNormalMapUv;\n#endif\n#ifdef USE_DISPLACEMENTMAP\n\tuniform mat3 displacementMapTransform;\n\tvarying vec2 vDisplacementMapUv;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tuniform mat3 emissiveMapTransform;\n\tvarying vec2 vEmissiveMapUv;\n#endif\n#ifdef USE_METALNESSMAP\n\tuniform mat3 metalnessMapTransform;\n\tvarying vec2 vMetalnessMapUv;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tuniform mat3 roughnessMapTransform;\n\tvarying vec2 vRoughnessMapUv;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tuniform mat3 anisotropyMapTransform;\n\tvarying vec2 vAnisotropyMapUv;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tuniform mat3 clearcoatMapTransform;\n\tvarying vec2 vClearcoatMapUv;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform mat3 clearcoatNormalMapTransform;\n\tvarying vec2 vClearcoatNormalMapUv;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform mat3 clearcoatRoughnessMapTransform;\n\tvarying vec2 vClearcoatRoughnessMapUv;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tuniform mat3 sheenColorMapTransform;\n\tvarying vec2 vSheenColorMapUv;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tuniform mat3 sheenRoughnessMapTransform;\n\tvarying vec2 vSheenRoughnessMapUv;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tuniform mat3 iridescenceMapTransform;\n\tvarying vec2 vIridescenceMapUv;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tuniform mat3 iridescenceThicknessMapTransform;\n\tvarying vec2 vIridescenceThicknessMapUv;\n#endif\n#ifdef USE_SPECULARMAP\n\tuniform mat3 specularMapTransform;\n\tvarying vec2 vSpecularMapUv;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tuniform mat3 specularColorMapTransform;\n\tvarying vec2 vSpecularColorMapUv;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tuniform mat3 specularIntensityMapTransform;\n\tvarying vec2 vSpecularIntensityMapUv;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tuniform mat3 transmissionMapTransform;\n\tvarying vec2 vTransmissionMapUv;\n#endif\n#ifdef USE_THICKNESSMAP\n\tuniform mat3 thicknessMapTransform;\n\tvarying vec2 vThicknessMapUv;\n#endif`,PSe=`#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvUv = vec3( uv, 1 ).xy;\n#endif\n#ifdef USE_MAP\n\tvMapUv = ( mapTransform * vec3( MAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ALPHAMAP\n\tvAlphaMapUv = ( alphaMapTransform * vec3( ALPHAMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_LIGHTMAP\n\tvLightMapUv = ( lightMapTransform * vec3( LIGHTMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_AOMAP\n\tvAoMapUv = ( aoMapTransform * vec3( AOMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_BUMPMAP\n\tvBumpMapUv = ( bumpMapTransform * vec3( BUMPMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_NORMALMAP\n\tvNormalMapUv = ( normalMapTransform * vec3( NORMALMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_DISPLACEMENTMAP\n\tvDisplacementMapUv = ( displacementMapTransform * vec3( DISPLACEMENTMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tvEmissiveMapUv = ( emissiveMapTransform * vec3( EMISSIVEMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_METALNESSMAP\n\tvMetalnessMapUv = ( metalnessMapTransform * vec3( METALNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tvRoughnessMapUv = ( roughnessMapTransform * vec3( ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tvAnisotropyMapUv = ( anisotropyMapTransform * vec3( ANISOTROPYMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tvClearcoatMapUv = ( clearcoatMapTransform * vec3( CLEARCOATMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tvClearcoatNormalMapUv = ( clearcoatNormalMapTransform * vec3( CLEARCOAT_NORMALMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tvClearcoatRoughnessMapUv = ( clearcoatRoughnessMapTransform * vec3( CLEARCOAT_ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tvIridescenceMapUv = ( iridescenceMapTransform * vec3( IRIDESCENCEMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tvIridescenceThicknessMapUv = ( iridescenceThicknessMapTransform * vec3( IRIDESCENCE_THICKNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tvSheenColorMapUv = ( sheenColorMapTransform * vec3( SHEEN_COLORMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tvSheenRoughnessMapUv = ( sheenRoughnessMapTransform * vec3( SHEEN_ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULARMAP\n\tvSpecularMapUv = ( specularMapTransform * vec3( SPECULARMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tvSpecularColorMapUv = ( specularColorMapTransform * vec3( SPECULAR_COLORMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tvSpecularIntensityMapUv = ( specularIntensityMapTransform * vec3( SPECULAR_INTENSITYMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tvTransmissionMapUv = ( transmissionMapTransform * vec3( TRANSMISSIONMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_THICKNESSMAP\n\tvThicknessMapUv = ( thicknessMapTransform * vec3( THICKNESSMAP_UV, 1 ) ).xy;\n#endif`,TSe=`#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP ) || defined ( USE_TRANSMISSION ) || NUM_SPOT_LIGHT_COORDS > 0\n\tvec4 worldPosition = vec4( transformed, 1.0 );\n\t#ifdef USE_BATCHING\n\t\tworldPosition = batchingMatrix * worldPosition;\n\t#endif\n\t#ifdef USE_INSTANCING\n\t\tworldPosition = instanceMatrix * worldPosition;\n\t#endif\n\tworldPosition = modelMatrix * worldPosition;\n#endif`;const ASe=`varying vec2 vUv;\nuniform mat3 uvTransform;\nvoid main() {\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\tgl_Position = vec4( position.xy, 1.0, 1.0 );\n}`,jSe=`uniform sampler2D t2D;\nuniform float backgroundIntensity;\nvarying vec2 vUv;\nvoid main() {\n\tvec4 texColor = texture2D( t2D, vUv );\n\t#ifdef DECODE_VIDEO_TEXTURE\n\t\ttexColor = vec4( mix( pow( texColor.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), texColor.rgb * 0.0773993808, vec3( lessThanEqual( texColor.rgb, vec3( 0.04045 ) ) ) ), texColor.w );\n\t#endif\n\ttexColor.rgb *= backgroundIntensity;\n\tgl_FragColor = texColor;\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n}`,MSe=`varying vec3 vWorldDirection;\n#include <common>\nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include <begin_vertex>\n\t#include <project_vertex>\n\tgl_Position.z = gl_Position.w;\n}`,ESe=`#ifdef ENVMAP_TYPE_CUBE\n\tuniform samplerCube envMap;\n#elif defined( ENVMAP_TYPE_CUBE_UV )\n\tuniform sampler2D envMap;\n#endif\nuniform float flipEnvMap;\nuniform float backgroundBlurriness;\nuniform float backgroundIntensity;\nuniform mat3 backgroundRotation;\nvarying vec3 vWorldDirection;\n#include <cube_uv_reflection_fragment>\nvoid main() {\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 texColor = textureCube( envMap, backgroundRotation * vec3( flipEnvMap * vWorldDirection.x, vWorldDirection.yz ) );\n\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\tvec4 texColor = textureCubeUV( envMap, backgroundRotation * vWorldDirection, backgroundBlurriness );\n\t#else\n\t\tvec4 texColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t#endif\n\ttexColor.rgb *= backgroundIntensity;\n\tgl_FragColor = texColor;\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n}`,DSe=`varying vec3 vWorldDirection;\n#include <common>\nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include <begin_vertex>\n\t#include <project_vertex>\n\tgl_Position.z = gl_Position.w;\n}`,FSe=`uniform samplerCube tCube;\nuniform float tFlip;\nuniform float opacity;\nvarying vec3 vWorldDirection;\nvoid main() {\n\tvec4 texColor = textureCube( tCube, vec3( tFlip * vWorldDirection.x, vWorldDirection.yz ) );\n\tgl_FragColor = texColor;\n\tgl_FragColor.a *= opacity;\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n}`,RSe=`#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include <uv_vertex>\n\t#include <batching_vertex>\n\t#include <skinbase_vertex>\n\t#include <morphinstance_vertex>\n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include <beginnormal_vertex>\n\t\t#include <morphnormal_vertex>\n\t\t#include <skinnormal_vertex>\n\t#endif\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvHighPrecisionZW = gl_Position.zw;\n}`,LSe=`#if DEPTH_PACKING == 3200\n\tuniform float opacity;\n#endif\n#include <common>\n#include <packing>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include <clipping_planes_fragment>\n\t#if DEPTH_PACKING == 3200\n\t\tdiffuseColor.a = opacity;\n\t#endif\n\t#include <map_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <logdepthbuf_fragment>\n\t#ifdef USE_REVERSED_DEPTH_BUFFER\n\t\tfloat fragCoordZ = vHighPrecisionZW[ 0 ] / vHighPrecisionZW[ 1 ];\n\t#else\n\t\tfloat fragCoordZ = 0.5 * vHighPrecisionZW[ 0 ] / vHighPrecisionZW[ 1 ] + 0.5;\n\t#endif\n\t#if DEPTH_PACKING == 3200\n\t\tgl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );\n\t#elif DEPTH_PACKING == 3201\n\t\tgl_FragColor = packDepthToRGBA( fragCoordZ );\n\t#elif DEPTH_PACKING == 3202\n\t\tgl_FragColor = vec4( packDepthToRGB( fragCoordZ ), 1.0 );\n\t#elif DEPTH_PACKING == 3203\n\t\tgl_FragColor = vec4( packDepthToRG( fragCoordZ ), 0.0, 1.0 );\n\t#endif\n}`,OSe=`#define DISTANCE\nvarying vec3 vWorldPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <batching_vertex>\n\t#include <skinbase_vertex>\n\t#include <morphinstance_vertex>\n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include <beginnormal_vertex>\n\t\t#include <morphnormal_vertex>\n\t\t#include <skinnormal_vertex>\n\t#endif\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <worldpos_vertex>\n\t#include <clipping_planes_vertex>\n\tvWorldPosition = worldPosition.xyz;\n}`,ISe=`#define DISTANCE\nuniform vec3 referencePosition;\nuniform float nearDistance;\nuniform float farDistance;\nvarying vec3 vWorldPosition;\n#include <common>\n#include <packing>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main () {\n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include <clipping_planes_fragment>\n\t#include <map_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\tfloat dist = length( vWorldPosition - referencePosition );\n\tdist = ( dist - nearDistance ) / ( farDistance - nearDistance );\n\tdist = saturate( dist );\n\tgl_FragColor = packDepthToRGBA( dist );\n}`,zSe=`varying vec3 vWorldDirection;\n#include <common>\nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include <begin_vertex>\n\t#include <project_vertex>\n}`,USe=`uniform sampler2D tEquirect;\nvarying vec3 vWorldDirection;\n#include <common>\nvoid main() {\n\tvec3 direction = normalize( vWorldDirection );\n\tvec2 sampleUV = equirectUv( direction );\n\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n}`,BSe=`uniform float scale;\nattribute float lineDistance;\nvarying float vLineDistance;\n#include <common>\n#include <uv_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\tvLineDistance = scale * lineDistance;\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphcolor_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <fog_vertex>\n}`,HSe=`uniform vec3 diffuse;\nuniform float opacity;\nuniform float dashSize;\nuniform float totalSize;\nvarying float vLineDistance;\n#include <common>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <fog_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\tif ( mod( vLineDistance, totalSize ) > dashSize ) {\n\t\tdiscard;\n\t}\n\tvec3 outgoingLight = vec3( 0.0 );\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\toutgoingLight = diffuseColor.rgb;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n}`,qSe=`#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <envmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )\n\t\t#include <beginnormal_vertex>\n\t\t#include <morphnormal_vertex>\n\t\t#include <skinbase_vertex>\n\t\t#include <skinnormal_vertex>\n\t\t#include <defaultnormal_vertex>\n\t#endif\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <worldpos_vertex>\n\t#include <envmap_vertex>\n\t#include <fog_vertex>\n}`,$Se=`uniform vec3 diffuse;\nuniform float opacity;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include <common>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <envmap_common_pars_fragment>\n#include <envmap_pars_fragment>\n#include <fog_pars_fragment>\n#include <specularmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <specularmap_fragment>\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel = texture2D( lightMap, vLightMapUv );\n\t\treflectedLight.indirectDiffuse += lightMapTexel.rgb * lightMapIntensity * RECIPROCAL_PI;\n\t#else\n\t\treflectedLight.indirectDiffuse += vec3( 1.0 );\n\t#endif\n\t#include <aomap_fragment>\n\treflectedLight.indirectDiffuse *= diffuseColor.rgb;\n\tvec3 outgoingLight = reflectedLight.indirectDiffuse;\n\t#include <envmap_fragment>\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,VSe=`#define LAMBERT\nvarying vec3 vViewPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <envmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <shadowmap_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvViewPosition = - mvPosition.xyz;\n\t#include <worldpos_vertex>\n\t#include <envmap_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n}`,GSe=`#define LAMBERT\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include <common>\n#include <packing>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <emissivemap_pars_fragment>\n#include <envmap_common_pars_fragment>\n#include <envmap_pars_fragment>\n#include <fog_pars_fragment>\n#include <bsdfs>\n#include <lights_pars_begin>\n#include <normal_pars_fragment>\n#include <lights_lambert_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <specularmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <specularmap_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\t#include <emissivemap_fragment>\n\t#include <lights_lambert_fragment>\n\t#include <lights_fragment_begin>\n\t#include <lights_fragment_maps>\n\t#include <lights_fragment_end>\n\t#include <aomap_fragment>\n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include <envmap_fragment>\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,WSe=`#define MATCAP\nvarying vec3 vViewPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <color_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <fog_vertex>\n\tvViewPosition = - mvPosition.xyz;\n}`,KSe=`#define MATCAP\nuniform vec3 diffuse;\nuniform float opacity;\nuniform sampler2D matcap;\nvarying vec3 vViewPosition;\n#include <common>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <fog_pars_fragment>\n#include <normal_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\tvec3 viewDir = normalize( vViewPosition );\n\tvec3 x = normalize( vec3( viewDir.z, 0.0, - viewDir.x ) );\n\tvec3 y = cross( viewDir, x );\n\tvec2 uv = vec2( dot( x, normal ), dot( y, normal ) ) * 0.495 + 0.5;\n\t#ifdef USE_MATCAP\n\t\tvec4 matcapColor = texture2D( matcap, uv );\n\t#else\n\t\tvec4 matcapColor = vec4( vec3( mix( 0.2, 0.8, uv.y ) ), 1.0 );\n\t#endif\n\tvec3 outgoingLight = diffuseColor.rgb * matcapColor.rgb;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,XSe=`#define NORMAL\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvarying vec3 vViewPosition;\n#endif\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n}`,YSe=`#define NORMAL\nuniform float opacity;\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvarying vec3 vViewPosition;\n#endif\n#include <packing>\n#include <uv_pars_fragment>\n#include <normal_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( 0.0, 0.0, 0.0, opacity );\n\t#include <clipping_planes_fragment>\n\t#include <logdepthbuf_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\tgl_FragColor = vec4( packNormalToRGB( normal ), diffuseColor.a );\n\t#ifdef OPAQUE\n\t\tgl_FragColor.a = 1.0;\n\t#endif\n}`,QSe=`#define PHONG\nvarying vec3 vViewPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <envmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <shadowmap_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvViewPosition = - mvPosition.xyz;\n\t#include <worldpos_vertex>\n\t#include <envmap_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n}`,ZSe=`#define PHONG\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform vec3 specular;\nuniform float shininess;\nuniform float opacity;\n#include <common>\n#include <packing>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <emissivemap_pars_fragment>\n#include <envmap_common_pars_fragment>\n#include <envmap_pars_fragment>\n#include <fog_pars_fragment>\n#include <bsdfs>\n#include <lights_pars_begin>\n#include <normal_pars_fragment>\n#include <lights_phong_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <specularmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <specularmap_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\t#include <emissivemap_fragment>\n\t#include <lights_phong_fragment>\n\t#include <lights_fragment_begin>\n\t#include <lights_fragment_maps>\n\t#include <lights_fragment_end>\n\t#include <aomap_fragment>\n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#include <envmap_fragment>\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,JSe=`#define STANDARD\nvarying vec3 vViewPosition;\n#ifdef USE_TRANSMISSION\n\tvarying vec3 vWorldPosition;\n#endif\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <shadowmap_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvViewPosition = - mvPosition.xyz;\n\t#include <worldpos_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n#ifdef USE_TRANSMISSION\n\tvWorldPosition = worldPosition.xyz;\n#endif\n}`,e_e=`#define STANDARD\n#ifdef PHYSICAL\n\t#define IOR\n\t#define USE_SPECULAR\n#endif\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float roughness;\nuniform float metalness;\nuniform float opacity;\n#ifdef IOR\n\tuniform float ior;\n#endif\n#ifdef USE_SPECULAR\n\tuniform float specularIntensity;\n\tuniform vec3 specularColor;\n\t#ifdef USE_SPECULAR_COLORMAP\n\t\tuniform sampler2D specularColorMap;\n\t#endif\n\t#ifdef USE_SPECULAR_INTENSITYMAP\n\t\tuniform sampler2D specularIntensityMap;\n\t#endif\n#endif\n#ifdef USE_CLEARCOAT\n\tuniform float clearcoat;\n\tuniform float clearcoatRoughness;\n#endif\n#ifdef USE_DISPERSION\n\tuniform float dispersion;\n#endif\n#ifdef USE_IRIDESCENCE\n\tuniform float iridescence;\n\tuniform float iridescenceIOR;\n\tuniform float iridescenceThicknessMinimum;\n\tuniform float iridescenceThicknessMaximum;\n#endif\n#ifdef USE_SHEEN\n\tuniform vec3 sheenColor;\n\tuniform float sheenRoughness;\n\t#ifdef USE_SHEEN_COLORMAP\n\t\tuniform sampler2D sheenColorMap;\n\t#endif\n\t#ifdef USE_SHEEN_ROUGHNESSMAP\n\t\tuniform sampler2D sheenRoughnessMap;\n\t#endif\n#endif\n#ifdef USE_ANISOTROPY\n\tuniform vec2 anisotropyVector;\n\t#ifdef USE_ANISOTROPYMAP\n\t\tuniform sampler2D anisotropyMap;\n\t#endif\n#endif\nvarying vec3 vViewPosition;\n#include <common>\n#include <packing>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <emissivemap_pars_fragment>\n#include <iridescence_fragment>\n#include <cube_uv_reflection_fragment>\n#include <envmap_common_pars_fragment>\n#include <envmap_physical_pars_fragment>\n#include <fog_pars_fragment>\n#include <lights_pars_begin>\n#include <normal_pars_fragment>\n#include <lights_physical_pars_fragment>\n#include <transmission_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <clearcoat_pars_fragment>\n#include <iridescence_pars_fragment>\n#include <roughnessmap_pars_fragment>\n#include <metalnessmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <roughnessmap_fragment>\n\t#include <metalnessmap_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\t#include <clearcoat_normal_fragment_begin>\n\t#include <clearcoat_normal_fragment_maps>\n\t#include <emissivemap_fragment>\n\t#include <lights_physical_fragment>\n\t#include <lights_fragment_begin>\n\t#include <lights_fragment_maps>\n\t#include <lights_fragment_end>\n\t#include <aomap_fragment>\n\tvec3 totalDiffuse = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse;\n\tvec3 totalSpecular = reflectedLight.directSpecular + reflectedLight.indirectSpecular;\n\t#include <transmission_fragment>\n\tvec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;\n\t#ifdef USE_SHEEN\n\t\tfloat sheenEnergyComp = 1.0 - 0.157 * max3( material.sheenColor );\n\t\toutgoingLight = outgoingLight * sheenEnergyComp + sheenSpecularDirect + sheenSpecularIndirect;\n\t#endif\n\t#ifdef USE_CLEARCOAT\n\t\tfloat dotNVcc = saturate( dot( geometryClearcoatNormal, geometryViewDir ) );\n\t\tvec3 Fcc = F_Schlick( material.clearcoatF0, material.clearcoatF90, dotNVcc );\n\t\toutgoingLight = outgoingLight * ( 1.0 - material.clearcoat * Fcc ) + ( clearcoatSpecularDirect + clearcoatSpecularIndirect ) * material.clearcoat;\n\t#endif\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,t_e=`#define TOON\nvarying vec3 vViewPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <shadowmap_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvViewPosition = - mvPosition.xyz;\n\t#include <worldpos_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n}`,n_e=`#define TOON\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include <common>\n#include <packing>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <emissivemap_pars_fragment>\n#include <gradientmap_pars_fragment>\n#include <fog_pars_fragment>\n#include <bsdfs>\n#include <lights_pars_begin>\n#include <normal_pars_fragment>\n#include <lights_toon_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\t#include <emissivemap_fragment>\n\t#include <lights_toon_fragment>\n\t#include <lights_fragment_begin>\n\t#include <lights_fragment_maps>\n\t#include <lights_fragment_end>\n\t#include <aomap_fragment>\n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,r_e=`uniform float size;\nuniform float scale;\n#include <common>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\n#ifdef USE_POINTS_UV\n\tvarying vec2 vUv;\n\tuniform mat3 uvTransform;\n#endif\nvoid main() {\n\t#ifdef USE_POINTS_UV\n\t\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\t#endif\n\t#include <color_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphcolor_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <project_vertex>\n\tgl_PointSize = size;\n\t#ifdef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );\n\t#endif\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <worldpos_vertex>\n\t#include <fog_vertex>\n}`,a_e=`uniform vec3 diffuse;\nuniform float opacity;\n#include <common>\n#include <color_pars_fragment>\n#include <map_particle_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <fog_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\tvec3 outgoingLight = vec3( 0.0 );\n\t#include <logdepthbuf_fragment>\n\t#include <map_particle_fragment>\n\t#include <color_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\toutgoingLight = diffuseColor.rgb;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n}`,i_e=`#include <common>\n#include <batching_pars_vertex>\n#include <fog_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <shadowmap_pars_vertex>\nvoid main() {\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphinstance_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <worldpos_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n}`,s_e=`uniform vec3 color;\nuniform float opacity;\n#include <common>\n#include <packing>\n#include <fog_pars_fragment>\n#include <bsdfs>\n#include <lights_pars_begin>\n#include <logdepthbuf_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <shadowmask_pars_fragment>\nvoid main() {\n\t#include <logdepthbuf_fragment>\n\tgl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n}`,o_e=`uniform float rotation;\nuniform vec2 center;\n#include <common>\n#include <uv_pars_vertex>\n#include <fog_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\tvec4 mvPosition = modelViewMatrix[ 3 ];\n\tvec2 scale = vec2( length( modelMatrix[ 0 ].xyz ), length( modelMatrix[ 1 ].xyz ) );\n\t#ifndef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) scale *= - mvPosition.z;\n\t#endif\n\tvec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;\n\tvec2 rotatedPosition;\n\trotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;\n\trotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;\n\tmvPosition.xy += rotatedPosition;\n\tgl_Position = projectionMatrix * mvPosition;\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <fog_vertex>\n}`,l_e=`uniform vec3 diffuse;\nuniform float opacity;\n#include <common>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <fog_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <clipping_planes_fragment>\n\tvec3 outgoingLight = vec3( 0.0 );\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\toutgoingLight = diffuseColor.rgb;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n}`,er={alphahash_fragment:j0e,alphahash_pars_fragment:M0e,alphamap_fragment:E0e,alphamap_pars_fragment:D0e,alphatest_fragment:F0e,alphatest_pars_fragment:R0e,aomap_fragment:L0e,aomap_pars_fragment:O0e,batching_pars_vertex:I0e,batching_vertex:z0e,begin_vertex:U0e,beginnormal_vertex:B0e,bsdfs:H0e,iridescence_fragment:q0e,bumpmap_pars_fragment:$0e,clipping_planes_fragment:V0e,clipping_planes_pars_fragment:G0e,clipping_planes_pars_vertex:W0e,clipping_planes_vertex:K0e,color_fragment:X0e,color_pars_fragment:Y0e,color_pars_vertex:Q0e,color_vertex:Z0e,common:J0e,cube_uv_reflection_fragment:ewe,defaultnormal_vertex:twe,displacementmap_pars_vertex:nwe,displacementmap_vertex:rwe,emissivemap_fragment:awe,emissivemap_pars_fragment:iwe,colorspace_fragment:swe,colorspace_pars_fragment:owe,envmap_fragment:lwe,envmap_common_pars_fragment:cwe,envmap_pars_fragment:dwe,envmap_pars_vertex:uwe,envmap_physical_pars_fragment:Swe,envmap_vertex:mwe,fog_vertex:hwe,fog_pars_vertex:pwe,fog_fragment:fwe,fog_pars_fragment:gwe,gradientmap_pars_fragment:bwe,lightmap_pars_fragment:xwe,lights_lambert_fragment:ywe,lights_lambert_pars_fragment:vwe,lights_pars_begin:wwe,lights_toon_fragment:_we,lights_toon_pars_fragment:kwe,lights_phong_fragment:Nwe,lights_phong_pars_fragment:Cwe,lights_physical_fragment:Pwe,lights_physical_pars_fragment:Twe,lights_fragment_begin:Awe,lights_fragment_maps:jwe,lights_fragment_end:Mwe,logdepthbuf_fragment:Ewe,logdepthbuf_pars_fragment:Dwe,logdepthbuf_pars_vertex:Fwe,logdepthbuf_vertex:Rwe,map_fragment:Lwe,map_pars_fragment:Owe,map_particle_fragment:Iwe,map_particle_pars_fragment:zwe,metalnessmap_fragment:Uwe,metalnessmap_pars_fragment:Bwe,morphinstance_vertex:Hwe,morphcolor_vertex:qwe,morphnormal_vertex:$we,morphtarget_pars_vertex:Vwe,morphtarget_vertex:Gwe,normal_fragment_begin:Wwe,normal_fragment_maps:Kwe,normal_pars_fragment:Xwe,normal_pars_vertex:Ywe,normal_vertex:Qwe,normalmap_pars_fragment:Zwe,clearcoat_normal_fragment_begin:Jwe,clearcoat_normal_fragment_maps:eSe,clearcoat_pars_fragment:tSe,iridescence_pars_fragment:nSe,opaque_fragment:rSe,packing:aSe,premultiplied_alpha_fragment:iSe,project_vertex:sSe,dithering_fragment:oSe,dithering_pars_fragment:lSe,roughnessmap_fragment:cSe,roughnessmap_pars_fragment:dSe,shadowmap_pars_fragment:uSe,shadowmap_pars_vertex:mSe,shadowmap_vertex:hSe,shadowmask_pars_fragment:pSe,skinbase_vertex:fSe,skinning_pars_vertex:gSe,skinning_vertex:bSe,skinnormal_vertex:xSe,specularmap_fragment:ySe,specularmap_pars_fragment:vSe,tonemapping_fragment:wSe,tonemapping_pars_fragment:SSe,transmission_fragment:_Se,transmission_pars_fragment:kSe,uv_pars_fragment:NSe,uv_pars_vertex:CSe,uv_vertex:PSe,worldpos_vertex:TSe,background_vert:ASe,background_frag:jSe,backgroundCube_vert:MSe,backgroundCube_frag:ESe,cube_vert:DSe,cube_frag:FSe,depth_vert:RSe,depth_frag:LSe,distanceRGBA_vert:OSe,distanceRGBA_frag:ISe,equirect_vert:zSe,equirect_frag:USe,linedashed_vert:BSe,linedashed_frag:HSe,meshbasic_vert:qSe,meshbasic_frag:$Se,meshlambert_vert:VSe,meshlambert_frag:GSe,meshmatcap_vert:WSe,meshmatcap_frag:KSe,meshnormal_vert:XSe,meshnormal_frag:YSe,meshphong_vert:QSe,meshphong_frag:ZSe,meshphysical_vert:JSe,meshphysical_frag:e_e,meshtoon_vert:t_e,meshtoon_frag:n_e,points_vert:r_e,points_frag:a_e,shadow_vert:i_e,shadow_frag:s_e,sprite_vert:o_e,sprite_frag:l_e},Qt={common:{diffuse:{value:new or(16777215)},opacity:{value:1},map:{value:null},mapTransform:{value:new ir},alphaMap:{value:null},alphaMapTransform:{value:new ir},alphaTest:{value:0}},specularmap:{specularMap:{value:null},specularMapTransform:{value:new ir}},envmap:{envMap:{value:null},envMapRotation:{value:new ir},flipEnvMap:{value:-1},reflectivity:{value:1},ior:{value:1.5},refractionRatio:{value:.98},dfgLUT:{value:null}},aomap:{aoMap:{value:null},aoMapIntensity:{value:1},aoMapTransform:{value:new ir}},lightmap:{lightMap:{value:null},lightMapIntensity:{value:1},lightMapTransform:{value:new ir}},bumpmap:{bumpMap:{value:null},bumpMapTransform:{value:new ir},bumpScale:{value:1}},normalmap:{normalMap:{value:null},normalMapTransform:{value:new ir},normalScale:{value:new nr(1,1)}},displacementmap:{displacementMap:{value:null},displacementMapTransform:{value:new ir},displacementScale:{value:1},displacementBias:{value:0}},emissivemap:{emissiveMap:{value:null},emissiveMapTransform:{value:new ir}},metalnessmap:{metalnessMap:{value:null},metalnessMapTransform:{value:new ir}},roughnessmap:{roughnessMap:{value:null},roughnessMapTransform:{value:new ir}},gradientmap:{gradientMap:{value:null}},fog:{fogDensity:{value:25e-5},fogNear:{value:1},fogFar:{value:2e3},fogColor:{value:new or(16777215)}},lights:{ambientLightColor:{value:[]},lightProbe:{value:[]},directionalLights:{value:[],properties:{direction:{},color:{}}},directionalLightShadows:{value:[],properties:{shadowIntensity:1,shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},directionalShadowMap:{value:[]},directionalShadowMatrix:{value:[]},spotLights:{value:[],properties:{color:{},position:{},direction:{},distance:{},coneCos:{},penumbraCos:{},decay:{}}},spotLightShadows:{value:[],properties:{shadowIntensity:1,shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},spotLightMap:{value:[]},spotShadowMap:{value:[]},spotLightMatrix:{value:[]},pointLights:{value:[],properties:{color:{},position:{},decay:{},distance:{}}},pointLightShadows:{value:[],properties:{shadowIntensity:1,shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{},shadowCameraNear:{},shadowCameraFar:{}}},pointShadowMap:{value:[]},pointShadowMatrix:{value:[]},hemisphereLights:{value:[],properties:{direction:{},skyColor:{},groundColor:{}}},rectAreaLights:{value:[],properties:{color:{},position:{},width:{},height:{}}},ltc_1:{value:null},ltc_2:{value:null}},points:{diffuse:{value:new or(16777215)},opacity:{value:1},size:{value:1},scale:{value:1},map:{value:null},alphaMap:{value:null},alphaMapTransform:{value:new ir},alphaTest:{value:0},uvTransform:{value:new ir}},sprite:{diffuse:{value:new or(16777215)},opacity:{value:1},center:{value:new nr(.5,.5)},rotation:{value:0},map:{value:null},mapTransform:{value:new ir},alphaMap:{value:null},alphaMapTransform:{value:new ir},alphaTest:{value:0}}},Nd={basic:{uniforms:eo([Qt.common,Qt.specularmap,Qt.envmap,Qt.aomap,Qt.lightmap,Qt.fog]),vertexShader:er.meshbasic_vert,fragmentShader:er.meshbasic_frag},lambert:{uniforms:eo([Qt.common,Qt.specularmap,Qt.envmap,Qt.aomap,Qt.lightmap,Qt.emissivemap,Qt.bumpmap,Qt.normalmap,Qt.displacementmap,Qt.fog,Qt.lights,{emissive:{value:new or(0)}}]),vertexShader:er.meshlambert_vert,fragmentShader:er.meshlambert_frag},phong:{uniforms:eo([Qt.common,Qt.specularmap,Qt.envmap,Qt.aomap,Qt.lightmap,Qt.emissivemap,Qt.bumpmap,Qt.normalmap,Qt.displacementmap,Qt.fog,Qt.lights,{emissive:{value:new or(0)},specular:{value:new or(1118481)},shininess:{value:30}}]),vertexShader:er.meshphong_vert,fragmentShader:er.meshphong_frag},standard:{uniforms:eo([Qt.common,Qt.envmap,Qt.aomap,Qt.lightmap,Qt.emissivemap,Qt.bumpmap,Qt.normalmap,Qt.displacementmap,Qt.roughnessmap,Qt.metalnessmap,Qt.fog,Qt.lights,{emissive:{value:new or(0)},roughness:{value:1},metalness:{value:0},envMapIntensity:{value:1}}]),vertexShader:er.meshphysical_vert,fragmentShader:er.meshphysical_frag},toon:{uniforms:eo([Qt.common,Qt.aomap,Qt.lightmap,Qt.emissivemap,Qt.bumpmap,Qt.normalmap,Qt.displacementmap,Qt.gradientmap,Qt.fog,Qt.lights,{emissive:{value:new or(0)}}]),vertexShader:er.meshtoon_vert,fragmentShader:er.meshtoon_frag},matcap:{uniforms:eo([Qt.common,Qt.bumpmap,Qt.normalmap,Qt.displacementmap,Qt.fog,{matcap:{value:null}}]),vertexShader:er.meshmatcap_vert,fragmentShader:er.meshmatcap_frag},points:{uniforms:eo([Qt.points,Qt.fog]),vertexShader:er.points_vert,fragmentShader:er.points_frag},dashed:{uniforms:eo([Qt.common,Qt.fog,{scale:{value:1},dashSize:{value:1},totalSize:{value:2}}]),vertexShader:er.linedashed_vert,fragmentShader:er.linedashed_frag},depth:{uniforms:eo([Qt.common,Qt.displacementmap]),vertexShader:er.depth_vert,fragmentShader:er.depth_frag},normal:{uniforms:eo([Qt.common,Qt.bumpmap,Qt.normalmap,Qt.displacementmap,{opacity:{value:1}}]),vertexShader:er.meshnormal_vert,fragmentShader:er.meshnormal_frag},sprite:{uniforms:eo([Qt.sprite,Qt.fog]),vertexShader:er.sprite_vert,fragmentShader:er.sprite_frag},background:{uniforms:{uvTransform:{value:new ir},t2D:{value:null},backgroundIntensity:{value:1}},vertexShader:er.background_vert,fragmentShader:er.background_frag},backgroundCube:{uniforms:{envMap:{value:null},flipEnvMap:{value:-1},backgroundBlurriness:{value:0},backgroundIntensity:{value:1},backgroundRotation:{value:new ir}},vertexShader:er.backgroundCube_vert,fragmentShader:er.backgroundCube_frag},cube:{uniforms:{tCube:{value:null},tFlip:{value:-1},opacity:{value:1}},vertexShader:er.cube_vert,fragmentShader:er.cube_frag},equirect:{uniforms:{tEquirect:{value:null}},vertexShader:er.equirect_vert,fragmentShader:er.equirect_frag},distanceRGBA:{uniforms:eo([Qt.common,Qt.displacementmap,{referencePosition:{value:new Ct},nearDistance:{value:1},farDistance:{value:1e3}}]),vertexShader:er.distanceRGBA_vert,fragmentShader:er.distanceRGBA_frag},shadow:{uniforms:eo([Qt.lights,Qt.fog,{color:{value:new or(0)},opacity:{value:1}}]),vertexShader:er.shadow_vert,fragmentShader:er.shadow_frag}};Nd.physical={uniforms:eo([Nd.standard.uniforms,{clearcoat:{value:0},clearcoatMap:{value:null},clearcoatMapTransform:{value:new ir},clearcoatNormalMap:{value:null},clearcoatNormalMapTransform:{value:new ir},clearcoatNormalScale:{value:new nr(1,1)},clearcoatRoughness:{value:0},clearcoatRoughnessMap:{value:null},clearcoatRoughnessMapTransform:{value:new ir},dispersion:{value:0},iridescence:{value:0},iridescenceMap:{value:null},iridescenceMapTransform:{value:new ir},iridescenceIOR:{value:1.3},iridescenceThicknessMinimum:{value:100},iridescenceThicknessMaximum:{value:400},iridescenceThicknessMap:{value:null},iridescenceThicknessMapTransform:{value:new ir},sheen:{value:0},sheenColor:{value:new or(0)},sheenColorMap:{value:null},sheenColorMapTransform:{value:new ir},sheenRoughness:{value:1},sheenRoughnessMap:{value:null},sheenRoughnessMapTransform:{value:new ir},transmission:{value:0},transmissionMap:{value:null},transmissionMapTransform:{value:new ir},transmissionSamplerSize:{value:new nr},transmissionSamplerMap:{value:null},thickness:{value:0},thicknessMap:{value:null},thicknessMapTransform:{value:new ir},attenuationDistance:{value:0},attenuationColor:{value:new or(0)},specularColor:{value:new or(1,1,1)},specularColorMap:{value:null},specularColorMapTransform:{value:new ir},specularIntensity:{value:1},specularIntensityMap:{value:null},specularIntensityMapTransform:{value:new ir},anisotropyVector:{value:new nr},anisotropyMap:{value:null},anisotropyMapTransform:{value:new ir}}]),vertexShader:er.meshphysical_vert,fragmentShader:er.meshphysical_frag};const x1={r:0,b:0,g:0},mf=new xp,c_e=new _i;function d_e(t,e,n,r,i,s,o){const l=new or(0);let c=s===!0?0:1,d,u,m=null,p=0,f=null;function y(C){let P=C.isScene===!0?C.background:null;return P&&P.isTexture&&(P=(C.backgroundBlurriness>0?n:e).get(P)),P}function v(C){let P=!1;const N=y(C);N===null?g(l,c):N&&N.isColor&&(g(N,1),P=!0);const A=t.xr.getEnvironmentBlendMode();A===\"additive\"?r.buffers.color.setClear(0,0,0,1,o):A===\"alpha-blend\"&&r.buffers.color.setClear(0,0,0,0,o),(t.autoClear||P)&&(r.buffers.depth.setTest(!0),r.buffers.depth.setMask(!0),r.buffers.color.setMask(!0),t.clear(t.autoClearColor,t.autoClearDepth,t.autoClearStencil))}function b(C,P){const N=y(P);N&&(N.isCubeTexture||N.mapping===XP)?(u===void 0&&(u=new mc(new nI(1,1,1),new wm({name:\"BackgroundCubeMaterial\",uniforms:Xx(Nd.backgroundCube.uniforms),vertexShader:Nd.backgroundCube.vertexShader,fragmentShader:Nd.backgroundCube.fragmentShader,side:zo,depthTest:!1,depthWrite:!1,fog:!1,allowOverride:!1})),u.geometry.deleteAttribute(\"normal\"),u.geometry.deleteAttribute(\"uv\"),u.onBeforeRender=function(A,T,F){this.matrixWorld.copyPosition(F.matrixWorld)},Object.defineProperty(u.material,\"envMap\",{get:function(){return this.uniforms.envMap.value}}),i.update(u)),mf.copy(P.backgroundRotation),mf.x*=-1,mf.y*=-1,mf.z*=-1,N.isCubeTexture&&N.isRenderTargetTexture===!1&&(mf.y*=-1,mf.z*=-1),u.material.uniforms.envMap.value=N,u.material.uniforms.flipEnvMap.value=N.isCubeTexture&&N.isRenderTargetTexture===!1?-1:1,u.material.uniforms.backgroundBlurriness.value=P.backgroundBlurriness,u.material.uniforms.backgroundIntensity.value=P.backgroundIntensity,u.material.uniforms.backgroundRotation.value.setFromMatrix4(c_e.makeRotationFromEuler(mf)),u.material.toneMapped=Mr.getTransfer(N.colorSpace)!==Qr,(m!==N||p!==N.version||f!==t.toneMapping)&&(u.material.needsUpdate=!0,m=N,p=N.version,f=t.toneMapping),u.layers.enableAll(),C.unshift(u,u.geometry,u.material,0,0,null)):N&&N.isTexture&&(d===void 0&&(d=new mc(new aI(2,2),new wm({name:\"BackgroundMaterial\",uniforms:Xx(Nd.background.uniforms),vertexShader:Nd.background.vertexShader,fragmentShader:Nd.background.fragmentShader,side:bp,depthTest:!1,depthWrite:!1,fog:!1,allowOverride:!1})),d.geometry.deleteAttribute(\"normal\"),Object.defineProperty(d.material,\"map\",{get:function(){return this.uniforms.t2D.value}}),i.update(d)),d.material.uniforms.t2D.value=N,d.material.uniforms.backgroundIntensity.value=P.backgroundIntensity,d.material.toneMapped=Mr.getTransfer(N.colorSpace)!==Qr,N.matrixAutoUpdate===!0&&N.updateMatrix(),d.material.uniforms.uvTransform.value.copy(N.matrix),(m!==N||p!==N.version||f!==t.toneMapping)&&(d.material.needsUpdate=!0,m=N,p=N.version,f=t.toneMapping),d.layers.enableAll(),C.unshift(d,d.geometry,d.material,0,0,null))}function g(C,P){C.getRGB(x1,EZ(t)),r.buffers.color.setClear(x1.r,x1.g,x1.b,P,o)}function _(){u!==void 0&&(u.geometry.dispose(),u.material.dispose(),u=void 0),d!==void 0&&(d.geometry.dispose(),d.material.dispose(),d=void 0)}return{getClearColor:function(){return l},setClearColor:function(C,P=1){l.set(C),c=P,g(l,c)},getClearAlpha:function(){return c},setClearAlpha:function(C){c=C,g(l,c)},render:v,addToRenderList:b,dispose:_}}function u_e(t,e){const n=t.getParameter(t.MAX_VERTEX_ATTRIBS),r={},i=p(null);let s=i,o=!1;function l(D,H,z,Q,L){let te=!1;const ie=m(Q,z,H);s!==ie&&(s=ie,d(s.object)),te=f(D,Q,z,L),te&&y(D,Q,z,L),L!==null&&e.update(L,t.ELEMENT_ARRAY_BUFFER),(te||o)&&(o=!1,P(D,H,z,Q),L!==null&&t.bindBuffer(t.ELEMENT_ARRAY_BUFFER,e.get(L).buffer))}function c(){return t.createVertexArray()}function d(D){return t.bindVertexArray(D)}function u(D){return t.deleteVertexArray(D)}function m(D,H,z){const Q=z.wireframe===!0;let L=r[D.id];L===void 0&&(L={},r[D.id]=L);let te=L[H.id];te===void 0&&(te={},L[H.id]=te);let ie=te[Q];return ie===void 0&&(ie=p(c()),te[Q]=ie),ie}function p(D){const H=[],z=[],Q=[];for(let L=0;L<n;L++)H[L]=0,z[L]=0,Q[L]=0;return{geometry:null,program:null,wireframe:!1,newAttributes:H,enabledAttributes:z,attributeDivisors:Q,object:D,attributes:{},index:null}}function f(D,H,z,Q){const L=s.attributes,te=H.attributes;let ie=0;const J=z.getAttributes();for(const oe in J)if(J[oe].location>=0){const re=L[oe];let W=te[oe];if(W===void 0&&(oe===\"instanceMatrix\"&&D.instanceMatrix&&(W=D.instanceMatrix),oe===\"instanceColor\"&&D.instanceColor&&(W=D.instanceColor)),re===void 0||re.attribute!==W||W&&re.data!==W.data)return!0;ie++}return s.attributesNum!==ie||s.index!==Q}function y(D,H,z,Q){const L={},te=H.attributes;let ie=0;const J=z.getAttributes();for(const oe in J)if(J[oe].location>=0){let re=te[oe];re===void 0&&(oe===\"instanceMatrix\"&&D.instanceMatrix&&(re=D.instanceMatrix),oe===\"instanceColor\"&&D.instanceColor&&(re=D.instanceColor));const W={};W.attribute=re,re&&re.data&&(W.data=re.data),L[oe]=W,ie++}s.attributes=L,s.attributesNum=ie,s.index=Q}function v(){const D=s.newAttributes;for(let H=0,z=D.length;H<z;H++)D[H]=0}function b(D){g(D,0)}function g(D,H){const z=s.newAttributes,Q=s.enabledAttributes,L=s.attributeDivisors;z[D]=1,Q[D]===0&&(t.enableVertexAttribArray(D),Q[D]=1),L[D]!==H&&(t.vertexAttribDivisor(D,H),L[D]=H)}function _(){const D=s.newAttributes,H=s.enabledAttributes;for(let z=0,Q=H.length;z<Q;z++)H[z]!==D[z]&&(t.disableVertexAttribArray(z),H[z]=0)}function C(D,H,z,Q,L,te,ie){ie===!0?t.vertexAttribIPointer(D,H,z,L,te):t.vertexAttribPointer(D,H,z,Q,L,te)}function P(D,H,z,Q){v();const L=Q.attributes,te=z.getAttributes(),ie=H.defaultAttributeValues;for(const J in te){const oe=te[J];if(oe.location>=0){let fe=L[J];if(fe===void 0&&(J===\"instanceMatrix\"&&D.instanceMatrix&&(fe=D.instanceMatrix),J===\"instanceColor\"&&D.instanceColor&&(fe=D.instanceColor)),fe!==void 0){const re=fe.normalized,W=fe.itemSize,ne=e.get(fe);if(ne===void 0)continue;const me=ne.buffer,be=ne.type,Ce=ne.bytesPerElement,q=be===t.INT||be===t.UNSIGNED_INT||fe.gpuType===GO;if(fe.isInterleavedBufferAttribute){const Y=fe.data,E=Y.stride,j=fe.offset;if(Y.isInstancedInterleavedBuffer){for(let O=0;O<oe.locationSize;O++)g(oe.location+O,Y.meshPerAttribute);D.isInstancedMesh!==!0&&Q._maxInstanceCount===void 0&&(Q._maxInstanceCount=Y.meshPerAttribute*Y.count)}else for(let O=0;O<oe.locationSize;O++)b(oe.location+O);t.bindBuffer(t.ARRAY_BUFFER,me);for(let O=0;O<oe.locationSize;O++)C(oe.location+O,W/oe.locationSize,be,re,E*Ce,(j+W/oe.locationSize*O)*Ce,q)}else{if(fe.isInstancedBufferAttribute){for(let Y=0;Y<oe.locationSize;Y++)g(oe.location+Y,fe.meshPerAttribute);D.isInstancedMesh!==!0&&Q._maxInstanceCount===void 0&&(Q._maxInstanceCount=fe.meshPerAttribute*fe.count)}else for(let Y=0;Y<oe.locationSize;Y++)b(oe.location+Y);t.bindBuffer(t.ARRAY_BUFFER,me);for(let Y=0;Y<oe.locationSize;Y++)C(oe.location+Y,W/oe.locationSize,be,re,W*Ce,W/oe.locationSize*Y*Ce,q)}}else if(ie!==void 0){const re=ie[J];if(re!==void 0)switch(re.length){case 2:t.vertexAttrib2fv(oe.location,re);break;case 3:t.vertexAttrib3fv(oe.location,re);break;case 4:t.vertexAttrib4fv(oe.location,re);break;default:t.vertexAttrib1fv(oe.location,re)}}}}_()}function N(){F();for(const D in r){const H=r[D];for(const z in H){const Q=H[z];for(const L in Q)u(Q[L].object),delete Q[L];delete H[z]}delete r[D]}}function A(D){if(r[D.id]===void 0)return;const H=r[D.id];for(const z in H){const Q=H[z];for(const L in Q)u(Q[L].object),delete Q[L];delete H[z]}delete r[D.id]}function T(D){for(const H in r){const z=r[H];if(z[D.id]===void 0)continue;const Q=z[D.id];for(const L in Q)u(Q[L].object),delete Q[L];delete z[D.id]}}function F(){k(),o=!0,s!==i&&(s=i,d(s.object))}function k(){i.geometry=null,i.program=null,i.wireframe=!1}return{setup:l,reset:F,resetDefaultState:k,dispose:N,releaseStatesOfGeometry:A,releaseStatesOfProgram:T,initAttributes:v,enableAttribute:b,disableUnusedAttributes:_}}function m_e(t,e,n){let r;function i(d){r=d}function s(d,u){t.drawArrays(r,d,u),n.update(u,r,1)}function o(d,u,m){m!==0&&(t.drawArraysInstanced(r,d,u,m),n.update(u,r,m))}function l(d,u,m){if(m===0)return;e.get(\"WEBGL_multi_draw\").multiDrawArraysWEBGL(r,d,0,u,0,m);let f=0;for(let y=0;y<m;y++)f+=u[y];n.update(f,r,1)}function c(d,u,m,p){if(m===0)return;const f=e.get(\"WEBGL_multi_draw\");if(f===null)for(let y=0;y<d.length;y++)o(d[y],u[y],p[y]);else{f.multiDrawArraysInstancedWEBGL(r,d,0,u,0,p,0,m);let y=0;for(let v=0;v<m;v++)y+=u[v]*p[v];n.update(y,r,1)}}this.setMode=i,this.render=s,this.renderInstances=o,this.renderMultiDraw=l,this.renderMultiDrawInstances=c}function h_e(t,e,n,r){let i;function s(){if(i!==void 0)return i;if(e.has(\"EXT_texture_filter_anisotropic\")===!0){const T=e.get(\"EXT_texture_filter_anisotropic\");i=t.getParameter(T.MAX_TEXTURE_MAX_ANISOTROPY_EXT)}else i=0;return i}function o(T){return!(T!==Gc&&r.convert(T)!==t.getParameter(t.IMPLEMENTATION_COLOR_READ_FORMAT))}function l(T){const F=T===_y&&(e.has(\"EXT_color_buffer_half_float\")||e.has(\"EXT_color_buffer_float\"));return!(T!==Hd&&r.convert(T)!==t.getParameter(t.IMPLEMENTATION_COLOR_READ_TYPE)&&T!==em&&!F)}function c(T){if(T===\"highp\"){if(t.getShaderPrecisionFormat(t.VERTEX_SHADER,t.HIGH_FLOAT).precision>0&&t.getShaderPrecisionFormat(t.FRAGMENT_SHADER,t.HIGH_FLOAT).precision>0)return\"highp\";T=\"mediump\"}return T===\"mediump\"&&t.getShaderPrecisionFormat(t.VERTEX_SHADER,t.MEDIUM_FLOAT).precision>0&&t.getShaderPrecisionFormat(t.FRAGMENT_SHADER,t.MEDIUM_FLOAT).precision>0?\"mediump\":\"lowp\"}let d=n.precision!==void 0?n.precision:\"highp\";const u=c(d);u!==d&&(qn(\"WebGLRenderer:\",d,\"not supported, using\",u,\"instead.\"),d=u);const m=n.logarithmicDepthBuffer===!0,p=n.reversedDepthBuffer===!0&&e.has(\"EXT_clip_control\"),f=t.getParameter(t.MAX_TEXTURE_IMAGE_UNITS),y=t.getParameter(t.MAX_VERTEX_TEXTURE_IMAGE_UNITS),v=t.getParameter(t.MAX_TEXTURE_SIZE),b=t.getParameter(t.MAX_CUBE_MAP_TEXTURE_SIZE),g=t.getParameter(t.MAX_VERTEX_ATTRIBS),_=t.getParameter(t.MAX_VERTEX_UNIFORM_VECTORS),C=t.getParameter(t.MAX_VARYING_VECTORS),P=t.getParameter(t.MAX_FRAGMENT_UNIFORM_VECTORS),N=y>0,A=t.getParameter(t.MAX_SAMPLES);return{isWebGL2:!0,getMaxAnisotropy:s,getMaxPrecision:c,textureFormatReadable:o,textureTypeReadable:l,precision:d,logarithmicDepthBuffer:m,reversedDepthBuffer:p,maxTextures:f,maxVertexTextures:y,maxTextureSize:v,maxCubemapSize:b,maxAttributes:g,maxVertexUniforms:_,maxVaryings:C,maxFragmentUniforms:P,vertexTextures:N,maxSamples:A}}function p_e(t){const e=this;let n=null,r=0,i=!1,s=!1;const o=new Ph,l=new ir,c={value:null,needsUpdate:!1};this.uniform=c,this.numPlanes=0,this.numIntersection=0,this.init=function(m,p){const f=m.length!==0||p||r!==0||i;return i=p,r=m.length,f},this.beginShadows=function(){s=!0,u(null)},this.endShadows=function(){s=!1},this.setGlobalState=function(m,p){n=u(m,p,0)},this.setState=function(m,p,f){const y=m.clippingPlanes,v=m.clipIntersection,b=m.clipShadows,g=t.get(m);if(!i||y===null||y.length===0||s&&!b)s?u(null):d();else{const _=s?0:r,C=_*4;let P=g.clippingState||null;c.value=P,P=u(y,p,C,f);for(let N=0;N!==C;++N)P[N]=n[N];g.clippingState=P,this.numIntersection=v?this.numPlanes:0,this.numPlanes+=_}};function d(){c.value!==n&&(c.value=n,c.needsUpdate=r>0),e.numPlanes=r,e.numIntersection=0}function u(m,p,f,y){const v=m!==null?m.length:0;let b=null;if(v!==0){if(b=c.value,y!==!0||b===null){const g=f+v*4,_=p.matrixWorldInverse;l.getNormalMatrix(_),(b===null||b.length<g)&&(b=new Float32Array(g));for(let C=0,P=f;C!==v;++C,P+=4)o.copy(m[C]).applyMatrix4(_,l),o.normal.toArray(b,P),b[P+3]=o.constant}c.value=b,c.needsUpdate=!0}return e.numPlanes=v,e.numIntersection=0,b}}function f_e(t){let e=new WeakMap;function n(o,l){return l===NF?o.mapping=Gx:l===CF&&(o.mapping=Wx),o}function r(o){if(o&&o.isTexture){const l=o.mapping;if(l===NF||l===CF)if(e.has(o)){const c=e.get(o).texture;return n(c,o.mapping)}else{const c=o.image;if(c&&c.height>0){const d=new o0e(c.height);return d.fromEquirectangularTexture(t,o),e.set(o,d),o.addEventListener(\"dispose\",i),n(d.texture,o.mapping)}else return null}}return o}function i(o){const l=o.target;l.removeEventListener(\"dispose\",i);const c=e.get(l);c!==void 0&&(e.delete(l),c.dispose())}function s(){e=new WeakMap}return{get:r,dispose:s}}const Hh=4,w6=[.125,.215,.35,.446,.526,.582],Af=20,g_e=256,zv=new BZ,S6=new or;let aE=null,iE=0,sE=0,oE=!1;const b_e=new Ct;let _6=class{constructor(e){this._renderer=e,this._pingPongRenderTarget=null,this._lodMax=0,this._cubeSize=0,this._sizeLods=[],this._sigmas=[],this._lodMeshes=[],this._backgroundBox=null,this._cubemapMaterial=null,this._equirectMaterial=null,this._blurMaterial=null,this._ggxMaterial=null}fromScene(e,n=0,r=.1,i=100,s={}){const{size:o=256,position:l=b_e}=s;aE=this._renderer.getRenderTarget(),iE=this._renderer.getActiveCubeFace(),sE=this._renderer.getActiveMipmapLevel(),oE=this._renderer.xr.enabled,this._renderer.xr.enabled=!1,this._setSize(o);const c=this._allocateTargets();return c.depthBuffer=!0,this._sceneToCubeUV(e,r,i,c,l),n>0&&this._blur(c,0,0,n),this._applyPMREM(c),this._cleanup(c),c}fromEquirectangular(e,n=null){return this._fromTexture(e,n)}fromCubemap(e,n=null){return this._fromTexture(e,n)}compileCubemapShader(){this._cubemapMaterial===null&&(this._cubemapMaterial=C6(),this._compileMaterial(this._cubemapMaterial))}compileEquirectangularShader(){this._equirectMaterial===null&&(this._equirectMaterial=N6(),this._compileMaterial(this._equirectMaterial))}dispose(){this._dispose(),this._cubemapMaterial!==null&&this._cubemapMaterial.dispose(),this._equirectMaterial!==null&&this._equirectMaterial.dispose(),this._backgroundBox!==null&&(this._backgroundBox.geometry.dispose(),this._backgroundBox.material.dispose())}_setSize(e){this._lodMax=Math.floor(Math.log2(e)),this._cubeSize=Math.pow(2,this._lodMax)}_dispose(){this._blurMaterial!==null&&this._blurMaterial.dispose(),this._ggxMaterial!==null&&this._ggxMaterial.dispose(),this._pingPongRenderTarget!==null&&this._pingPongRenderTarget.dispose();for(let e=0;e<this._lodMeshes.length;e++)this._lodMeshes[e].geometry.dispose()}_cleanup(e){this._renderer.setRenderTarget(aE,iE,sE),this._renderer.xr.enabled=oE,e.scissorTest=!1,Lb(e,0,0,e.width,e.height)}_fromTexture(e,n){e.mapping===Gx||e.mapping===Wx?this._setSize(e.image.length===0?16:e.image[0].width||e.image[0].image.width):this._setSize(e.image.width/4),aE=this._renderer.getRenderTarget(),iE=this._renderer.getActiveCubeFace(),sE=this._renderer.getActiveMipmapLevel(),oE=this._renderer.xr.enabled,this._renderer.xr.enabled=!1;const r=n||this._allocateTargets();return this._textureToCubeUV(e,r),this._applyPMREM(r),this._cleanup(r),r}_allocateTargets(){const e=3*Math.max(this._cubeSize,112),n=4*this._cubeSize,r={magFilter:ec,minFilter:ec,generateMipmaps:!1,type:_y,format:Gc,colorSpace:Kx,depthBuffer:!1},i=k6(e,n,r);if(this._pingPongRenderTarget===null||this._pingPongRenderTarget.width!==e||this._pingPongRenderTarget.height!==n){this._pingPongRenderTarget!==null&&this._dispose(),this._pingPongRenderTarget=k6(e,n,r);const{_lodMax:s}=this;({lodMeshes:this._lodMeshes,sizeLods:this._sizeLods,sigmas:this._sigmas}=x_e(s)),this._blurMaterial=v_e(s,e,n),this._ggxMaterial=y_e(s,e,n)}return i}_compileMaterial(e){const n=new mc(new uc,e);this._renderer.compile(n,zv)}_sceneToCubeUV(e,n,r,i,s){const c=new Kl(90,1,n,r),d=[1,-1,1,1,1,1],u=[1,1,1,-1,-1,-1],m=this._renderer,p=m.autoClear,f=m.toneMapping;m.getClearColor(S6),m.toneMapping=ip,m.autoClear=!1,m.state.buffers.depth.getReversed()&&(m.setRenderTarget(i),m.clearDepth(),m.setRenderTarget(null)),this._backgroundBox===null&&(this._backgroundBox=new mc(new nI,new tI({name:\"PMREM.Background\",side:zo,depthWrite:!1,depthTest:!1})));const v=this._backgroundBox,b=v.material;let g=!1;const _=e.background;_?_.isColor&&(b.color.copy(_),e.background=null,g=!0):(b.color.copy(S6),g=!0);for(let C=0;C<6;C++){const P=C%3;P===0?(c.up.set(0,d[C],0),c.position.set(s.x,s.y,s.z),c.lookAt(s.x+u[C],s.y,s.z)):P===1?(c.up.set(0,0,d[C]),c.position.set(s.x,s.y,s.z),c.lookAt(s.x,s.y+u[C],s.z)):(c.up.set(0,d[C],0),c.position.set(s.x,s.y,s.z),c.lookAt(s.x,s.y,s.z+u[C]));const N=this._cubeSize;Lb(i,P*N,C>2?N:0,N,N),m.setRenderTarget(i),g&&m.render(v,c),m.render(e,c)}m.toneMapping=f,m.autoClear=p,e.background=_}_textureToCubeUV(e,n){const r=this._renderer,i=e.mapping===Gx||e.mapping===Wx;i?(this._cubemapMaterial===null&&(this._cubemapMaterial=C6()),this._cubemapMaterial.uniforms.flipEnvMap.value=e.isRenderTargetTexture===!1?-1:1):this._equirectMaterial===null&&(this._equirectMaterial=N6());const s=i?this._cubemapMaterial:this._equirectMaterial,o=this._lodMeshes[0];o.material=s;const l=s.uniforms;l.envMap.value=e;const c=this._cubeSize;Lb(n,0,0,3*c,2*c),r.setRenderTarget(n),r.render(o,zv)}_applyPMREM(e){const n=this._renderer,r=n.autoClear;n.autoClear=!1;const i=this._lodMeshes.length;for(let s=1;s<i;s++)this._applyGGXFilter(e,s-1,s);n.autoClear=r}_applyGGXFilter(e,n,r){const i=this._renderer,s=this._pingPongRenderTarget,o=this._ggxMaterial,l=this._lodMeshes[r];l.material=o;const c=o.uniforms,d=r/(this._lodMeshes.length-1),u=n/(this._lodMeshes.length-1),m=Math.sqrt(d*d-u*u),p=.05+d*.95,f=m*p,{_lodMax:y}=this,v=this._sizeLods[r],b=3*v*(r>y-Hh?r-y+Hh:0),g=4*(this._cubeSize-v);c.envMap.value=e.texture,c.roughness.value=f,c.mipInt.value=y-n,Lb(s,b,g,3*v,2*v),i.setRenderTarget(s),i.render(l,zv),c.envMap.value=s.texture,c.roughness.value=0,c.mipInt.value=y-r,Lb(e,b,g,3*v,2*v),i.setRenderTarget(e),i.render(l,zv)}_blur(e,n,r,i,s){const o=this._pingPongRenderTarget;this._halfBlur(e,o,n,r,i,\"latitudinal\",s),this._halfBlur(o,e,r,r,i,\"longitudinal\",s)}_halfBlur(e,n,r,i,s,o,l){const c=this._renderer,d=this._blurMaterial;o!==\"latitudinal\"&&o!==\"longitudinal\"&&oi(\"blur direction must be either latitudinal or longitudinal!\");const u=3,m=this._lodMeshes[i];m.material=d;const p=d.uniforms,f=this._sizeLods[r]-1,y=isFinite(s)?Math.PI/(2*f):2*Math.PI/(2*Af-1),v=s/y,b=isFinite(s)?1+Math.floor(u*v):Af;b>Af&&qn(`sigmaRadians, ${s}, is too large and will clip, as it requested ${b} samples when the maximum is set to ${Af}`);const g=[];let _=0;for(let T=0;T<Af;++T){const F=T/v,k=Math.exp(-F*F/2);g.push(k),T===0?_+=k:T<b&&(_+=2*k)}for(let T=0;T<g.length;T++)g[T]=g[T]/_;p.envMap.value=e.texture,p.samples.value=b,p.weights.value=g,p.latitudinal.value=o===\"latitudinal\",l&&(p.poleAxis.value=l);const{_lodMax:C}=this;p.dTheta.value=y,p.mipInt.value=C-r;const P=this._sizeLods[i],N=3*P*(i>C-Hh?i-C+Hh:0),A=4*(this._cubeSize-P);Lb(n,N,A,3*P,2*P),c.setRenderTarget(n),c.render(m,zv)}};function x_e(t){const e=[],n=[],r=[];let i=t;const s=t-Hh+1+w6.length;for(let o=0;o<s;o++){const l=Math.pow(2,i);e.push(l);let c=1/l;o>t-Hh?c=w6[o-t+Hh-1]:o===0&&(c=0),n.push(c);const d=1/(l-2),u=-d,m=1+d,p=[u,u,m,u,m,m,u,u,m,m,u,m],f=6,y=6,v=3,b=2,g=1,_=new Float32Array(v*y*f),C=new Float32Array(b*y*f),P=new Float32Array(g*y*f);for(let A=0;A<f;A++){const T=A%3*2/3-1,F=A>2?0:-1,k=[T,F,0,T+2/3,F,0,T+2/3,F+1,0,T,F,0,T+2/3,F+1,0,T,F+1,0];_.set(k,v*y*A),C.set(p,b*y*A);const D=[A,A,A,A,A,A];P.set(D,g*y*A)}const N=new uc;N.setAttribute(\"position\",new oo(_,v)),N.setAttribute(\"uv\",new oo(C,b)),N.setAttribute(\"faceIndex\",new oo(P,g)),r.push(new mc(N,null)),i>Hh&&i--}return{lodMeshes:r,sizeLods:e,sigmas:n}}function k6(t,e,n){const r=new bg(t,e,n);return r.texture.mapping=XP,r.texture.name=\"PMREM.cubeUv\",r.scissorTest=!0,r}function Lb(t,e,n,r,i){t.viewport.set(e,n,r,i),t.scissor.set(e,n,r,i)}function y_e(t,e,n){return new wm({name:\"PMREMGGXConvolution\",defines:{GGX_SAMPLES:g_e,CUBEUV_TEXEL_WIDTH:1/e,CUBEUV_TEXEL_HEIGHT:1/n,CUBEUV_MAX_MIP:`${t}.0`},uniforms:{envMap:{value:null},roughness:{value:0},mipInt:{value:0}},vertexShader:QP(),fragmentShader:`\n\n\t\t\tprecision highp float;\n\t\t\tprecision highp int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\t\t\tuniform float roughness;\n\t\t\tuniform float mipInt;\n\n\t\t\t#define ENVMAP_TYPE_CUBE_UV\n\t\t\t#include <cube_uv_reflection_fragment>\n\n\t\t\t#define PI 3.14159265359\n\n\t\t\t// Van der Corput radical inverse\n\t\t\tfloat radicalInverse_VdC(uint bits) {\n\t\t\t\tbits = (bits << 16u) | (bits >> 16u);\n\t\t\t\tbits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);\n\t\t\t\tbits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);\n\t\t\t\tbits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);\n\t\t\t\tbits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);\n\t\t\t\treturn float(bits) * 2.3283064365386963e-10; // / 0x100000000\n\t\t\t}\n\n\t\t\t// Hammersley sequence\n\t\t\tvec2 hammersley(uint i, uint N) {\n\t\t\t\treturn vec2(float(i) / float(N), radicalInverse_VdC(i));\n\t\t\t}\n\n\t\t\t// GGX VNDF importance sampling (Eric Heitz 2018)\n\t\t\t// \"Sampling the GGX Distribution of Visible Normals\"\n\t\t\t// https://jcgt.org/published/0007/04/01/\n\t\t\tvec3 importanceSampleGGX_VNDF(vec2 Xi, vec3 V, float roughness) {\n\t\t\t\tfloat alpha = roughness * roughness;\n\n\t\t\t\t// Section 3.2: Transform view direction to hemisphere configuration\n\t\t\t\tvec3 Vh = normalize(vec3(alpha * V.x, alpha * V.y, V.z));\n\n\t\t\t\t// Section 4.1: Orthonormal basis\n\t\t\t\tfloat lensq = Vh.x * Vh.x + Vh.y * Vh.y;\n\t\t\t\tvec3 T1 = lensq > 0.0 ? vec3(-Vh.y, Vh.x, 0.0) / sqrt(lensq) : vec3(1.0, 0.0, 0.0);\n\t\t\t\tvec3 T2 = cross(Vh, T1);\n\n\t\t\t\t// Section 4.2: Parameterization of projected area\n\t\t\t\tfloat r = sqrt(Xi.x);\n\t\t\t\tfloat phi = 2.0 * PI * Xi.y;\n\t\t\t\tfloat t1 = r * cos(phi);\n\t\t\t\tfloat t2 = r * sin(phi);\n\t\t\t\tfloat s = 0.5 * (1.0 + Vh.z);\n\t\t\t\tt2 = (1.0 - s) * sqrt(1.0 - t1 * t1) + s * t2;\n\n\t\t\t\t// Section 4.3: Reprojection onto hemisphere\n\t\t\t\tvec3 Nh = t1 * T1 + t2 * T2 + sqrt(max(0.0, 1.0 - t1 * t1 - t2 * t2)) * Vh;\n\n\t\t\t\t// Section 3.4: Transform back to ellipsoid configuration\n\t\t\t\treturn normalize(vec3(alpha * Nh.x, alpha * Nh.y, max(0.0, Nh.z)));\n\t\t\t}\n\n\t\t\tvoid main() {\n\t\t\t\tvec3 N = normalize(vOutputDirection);\n\t\t\t\tvec3 V = N; // Assume view direction equals normal for pre-filtering\n\n\t\t\t\tvec3 prefilteredColor = vec3(0.0);\n\t\t\t\tfloat totalWeight = 0.0;\n\n\t\t\t\t// For very low roughness, just sample the environment directly\n\t\t\t\tif (roughness < 0.001) {\n\t\t\t\t\tgl_FragColor = vec4(bilinearCubeUV(envMap, N, mipInt), 1.0);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Tangent space basis for VNDF sampling\n\t\t\t\tvec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);\n\t\t\t\tvec3 tangent = normalize(cross(up, N));\n\t\t\t\tvec3 bitangent = cross(N, tangent);\n\n\t\t\t\tfor(uint i = 0u; i < uint(GGX_SAMPLES); i++) {\n\t\t\t\t\tvec2 Xi = hammersley(i, uint(GGX_SAMPLES));\n\n\t\t\t\t\t// For PMREM, V = N, so in tangent space V is always (0, 0, 1)\n\t\t\t\t\tvec3 H_tangent = importanceSampleGGX_VNDF(Xi, vec3(0.0, 0.0, 1.0), roughness);\n\n\t\t\t\t\t// Transform H back to world space\n\t\t\t\t\tvec3 H = normalize(tangent * H_tangent.x + bitangent * H_tangent.y + N * H_tangent.z);\n\t\t\t\t\tvec3 L = normalize(2.0 * dot(V, H) * H - V);\n\n\t\t\t\t\tfloat NdotL = max(dot(N, L), 0.0);\n\n\t\t\t\t\tif(NdotL > 0.0) {\n\t\t\t\t\t\t// Sample environment at fixed mip level\n\t\t\t\t\t\t// VNDF importance sampling handles the distribution filtering\n\t\t\t\t\t\tvec3 sampleColor = bilinearCubeUV(envMap, L, mipInt);\n\n\t\t\t\t\t\t// Weight by NdotL for the split-sum approximation\n\t\t\t\t\t\t// VNDF PDF naturally accounts for the visible microfacet distribution\n\t\t\t\t\t\tprefilteredColor += sampleColor * NdotL;\n\t\t\t\t\t\ttotalWeight += NdotL;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (totalWeight > 0.0) {\n\t\t\t\t\tprefilteredColor = prefilteredColor / totalWeight;\n\t\t\t\t}\n\n\t\t\t\tgl_FragColor = vec4(prefilteredColor, 1.0);\n\t\t\t}\n\t\t`,blending:lm,depthTest:!1,depthWrite:!1})}function v_e(t,e,n){const r=new Float32Array(Af),i=new Ct(0,1,0);return new wm({name:\"SphericalGaussianBlur\",defines:{n:Af,CUBEUV_TEXEL_WIDTH:1/e,CUBEUV_TEXEL_HEIGHT:1/n,CUBEUV_MAX_MIP:`${t}.0`},uniforms:{envMap:{value:null},samples:{value:1},weights:{value:r},latitudinal:{value:!1},dTheta:{value:0},mipInt:{value:0},poleAxis:{value:i}},vertexShader:QP(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\t\t\tuniform int samples;\n\t\t\tuniform float weights[ n ];\n\t\t\tuniform bool latitudinal;\n\t\t\tuniform float dTheta;\n\t\t\tuniform float mipInt;\n\t\t\tuniform vec3 poleAxis;\n\n\t\t\t#define ENVMAP_TYPE_CUBE_UV\n\t\t\t#include <cube_uv_reflection_fragment>\n\n\t\t\tvec3 getSample( float theta, vec3 axis ) {\n\n\t\t\t\tfloat cosTheta = cos( theta );\n\t\t\t\t// Rodrigues' axis-angle rotation\n\t\t\t\tvec3 sampleDirection = vOutputDirection * cosTheta\n\t\t\t\t\t+ cross( axis, vOutputDirection ) * sin( theta )\n\t\t\t\t\t+ axis * dot( axis, vOutputDirection ) * ( 1.0 - cosTheta );\n\n\t\t\t\treturn bilinearCubeUV( envMap, sampleDirection, mipInt );\n\n\t\t\t}\n\n\t\t\tvoid main() {\n\n\t\t\t\tvec3 axis = latitudinal ? poleAxis : cross( poleAxis, vOutputDirection );\n\n\t\t\t\tif ( all( equal( axis, vec3( 0.0 ) ) ) ) {\n\n\t\t\t\t\taxis = vec3( vOutputDirection.z, 0.0, - vOutputDirection.x );\n\n\t\t\t\t}\n\n\t\t\t\taxis = normalize( axis );\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t\t\t\tgl_FragColor.rgb += weights[ 0 ] * getSample( 0.0, axis );\n\n\t\t\t\tfor ( int i = 1; i < n; i++ ) {\n\n\t\t\t\t\tif ( i >= samples ) {\n\n\t\t\t\t\t\tbreak;\n\n\t\t\t\t\t}\n\n\t\t\t\t\tfloat theta = dTheta * float( i );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( -1.0 * theta, axis );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( theta, axis );\n\n\t\t\t\t}\n\n\t\t\t}\n\t\t`,blending:lm,depthTest:!1,depthWrite:!1})}function N6(){return new wm({name:\"EquirectangularToCubeUV\",uniforms:{envMap:{value:null}},vertexShader:QP(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\n\t\t\t#include <common>\n\n\t\t\tvoid main() {\n\n\t\t\t\tvec3 outputDirection = normalize( vOutputDirection );\n\t\t\t\tvec2 uv = equirectUv( outputDirection );\n\n\t\t\t\tgl_FragColor = vec4( texture2D ( envMap, uv ).rgb, 1.0 );\n\n\t\t\t}\n\t\t`,blending:lm,depthTest:!1,depthWrite:!1})}function C6(){return new wm({name:\"CubemapToCubeUV\",uniforms:{envMap:{value:null},flipEnvMap:{value:-1}},vertexShader:QP(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tuniform float flipEnvMap;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform samplerCube envMap;\n\n\t\t\tvoid main() {\n\n\t\t\t\tgl_FragColor = textureCube( envMap, vec3( flipEnvMap * vOutputDirection.x, vOutputDirection.yz ) );\n\n\t\t\t}\n\t\t`,blending:lm,depthTest:!1,depthWrite:!1})}function QP(){return`\n\n\t\tprecision mediump float;\n\t\tprecision mediump int;\n\n\t\tattribute float faceIndex;\n\n\t\tvarying vec3 vOutputDirection;\n\n\t\t// RH coordinate system; PMREM face-indexing convention\n\t\tvec3 getDirection( vec2 uv, float face ) {\n\n\t\t\tuv = 2.0 * uv - 1.0;\n\n\t\t\tvec3 direction = vec3( uv, 1.0 );\n\n\t\t\tif ( face == 0.0 ) {\n\n\t\t\t\tdirection = direction.zyx; // ( 1, v, u ) pos x\n\n\t\t\t} else if ( face == 1.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xz *= -1.0; // ( -u, 1, -v ) pos y\n\n\t\t\t} else if ( face == 2.0 ) {\n\n\t\t\t\tdirection.x *= -1.0; // ( -u, v, 1 ) pos z\n\n\t\t\t} else if ( face == 3.0 ) {\n\n\t\t\t\tdirection = direction.zyx;\n\t\t\t\tdirection.xz *= -1.0; // ( -1, v, -u ) neg x\n\n\t\t\t} else if ( face == 4.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xy *= -1.0; // ( -u, -1, v ) neg y\n\n\t\t\t} else if ( face == 5.0 ) {\n\n\t\t\t\tdirection.z *= -1.0; // ( u, v, -1 ) neg z\n\n\t\t\t}\n\n\t\t\treturn direction;\n\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvOutputDirection = getDirection( uv, faceIndex );\n\t\t\tgl_Position = vec4( position, 1.0 );\n\n\t\t}\n\t`}function w_e(t){let e=new WeakMap,n=null;function r(l){if(l&&l.isTexture){const c=l.mapping,d=c===NF||c===CF,u=c===Gx||c===Wx;if(d||u){let m=e.get(l);const p=m!==void 0?m.texture.pmremVersion:0;if(l.isRenderTargetTexture&&l.pmremVersion!==p)return n===null&&(n=new _6(t)),m=d?n.fromEquirectangular(l,m):n.fromCubemap(l,m),m.texture.pmremVersion=l.pmremVersion,e.set(l,m),m.texture;if(m!==void 0)return m.texture;{const f=l.image;return d&&f&&f.height>0||u&&f&&i(f)?(n===null&&(n=new _6(t)),m=d?n.fromEquirectangular(l):n.fromCubemap(l),m.texture.pmremVersion=l.pmremVersion,e.set(l,m),l.addEventListener(\"dispose\",s),m.texture):null}}}return l}function i(l){let c=0;const d=6;for(let u=0;u<d;u++)l[u]!==void 0&&c++;return c===d}function s(l){const c=l.target;c.removeEventListener(\"dispose\",s);const d=e.get(c);d!==void 0&&(e.delete(c),d.dispose())}function o(){e=new WeakMap,n!==null&&(n.dispose(),n=null)}return{get:r,dispose:o}}function S_e(t){const e={};function n(r){if(e[r]!==void 0)return e[r];const i=t.getExtension(r);return e[r]=i,i}return{has:function(r){return n(r)!==null},init:function(){n(\"EXT_color_buffer_float\"),n(\"WEBGL_clip_cull_distance\"),n(\"OES_texture_float_linear\"),n(\"EXT_color_buffer_half_float\"),n(\"WEBGL_multisampled_render_to_texture\"),n(\"WEBGL_render_shared_exponent\")},get:function(r){const i=n(r);return i===null&&mw(\"WebGLRenderer: \"+r+\" extension not supported.\"),i}}}function __e(t,e,n,r){const i={},s=new WeakMap;function o(m){const p=m.target;p.index!==null&&e.remove(p.index);for(const y in p.attributes)e.remove(p.attributes[y]);p.removeEventListener(\"dispose\",o),delete i[p.id];const f=s.get(p);f&&(e.remove(f),s.delete(p)),r.releaseStatesOfGeometry(p),p.isInstancedBufferGeometry===!0&&delete p._maxInstanceCount,n.memory.geometries--}function l(m,p){return i[p.id]===!0||(p.addEventListener(\"dispose\",o),i[p.id]=!0,n.memory.geometries++),p}function c(m){const p=m.attributes;for(const f in p)e.update(p[f],t.ARRAY_BUFFER)}function d(m){const p=[],f=m.index,y=m.attributes.position;let v=0;if(f!==null){const _=f.array;v=f.version;for(let C=0,P=_.length;C<P;C+=3){const N=_[C+0],A=_[C+1],T=_[C+2];p.push(N,A,A,T,T,N)}}else if(y!==void 0){const _=y.array;v=y.version;for(let C=0,P=_.length/3-1;C<P;C+=3){const N=C+0,A=C+1,T=C+2;p.push(N,A,A,T,T,N)}}else return;const b=new(yZ(p)?AZ:TZ)(p,1);b.version=v;const g=s.get(m);g&&e.remove(g),s.set(m,b)}function u(m){const p=s.get(m);if(p){const f=m.index;f!==null&&p.version<f.version&&d(m)}else d(m);return s.get(m)}return{get:l,update:c,getWireframeAttribute:u}}function k_e(t,e,n){let r;function i(p){r=p}let s,o;function l(p){s=p.type,o=p.bytesPerElement}function c(p,f){t.drawElements(r,f,s,p*o),n.update(f,r,1)}function d(p,f,y){y!==0&&(t.drawElementsInstanced(r,f,s,p*o,y),n.update(f,r,y))}function u(p,f,y){if(y===0)return;e.get(\"WEBGL_multi_draw\").multiDrawElementsWEBGL(r,f,0,s,p,0,y);let b=0;for(let g=0;g<y;g++)b+=f[g];n.update(b,r,1)}function m(p,f,y,v){if(y===0)return;const b=e.get(\"WEBGL_multi_draw\");if(b===null)for(let g=0;g<p.length;g++)d(p[g]/o,f[g],v[g]);else{b.multiDrawElementsInstancedWEBGL(r,f,0,s,p,0,v,0,y);let g=0;for(let _=0;_<y;_++)g+=f[_]*v[_];n.update(g,r,1)}}this.setMode=i,this.setIndex=l,this.render=c,this.renderInstances=d,this.renderMultiDraw=u,this.renderMultiDrawInstances=m}function N_e(t){const e={geometries:0,textures:0},n={frame:0,calls:0,triangles:0,points:0,lines:0};function r(s,o,l){switch(n.calls++,o){case t.TRIANGLES:n.triangles+=l*(s/3);break;case t.LINES:n.lines+=l*(s/2);break;case t.LINE_STRIP:n.lines+=l*(s-1);break;case t.LINE_LOOP:n.lines+=l*s;break;case t.POINTS:n.points+=l*s;break;default:oi(\"WebGLInfo: Unknown draw mode:\",o);break}}function i(){n.calls=0,n.triangles=0,n.points=0,n.lines=0}return{memory:e,render:n,programs:null,autoReset:!0,reset:i,update:r}}function C_e(t,e,n){const r=new WeakMap,i=new yi;function s(o,l,c){const d=o.morphTargetInfluences,u=l.morphAttributes.position||l.morphAttributes.normal||l.morphAttributes.color,m=u!==void 0?u.length:0;let p=r.get(l);if(p===void 0||p.count!==m){let D=function(){F.dispose(),r.delete(l),l.removeEventListener(\"dispose\",D)};var f=D;p!==void 0&&p.texture.dispose();const y=l.morphAttributes.position!==void 0,v=l.morphAttributes.normal!==void 0,b=l.morphAttributes.color!==void 0,g=l.morphAttributes.position||[],_=l.morphAttributes.normal||[],C=l.morphAttributes.color||[];let P=0;y===!0&&(P=1),v===!0&&(P=2),b===!0&&(P=3);let N=l.attributes.position.count*P,A=1;N>e.maxTextureSize&&(A=Math.ceil(N/e.maxTextureSize),N=e.maxTextureSize);const T=new Float32Array(N*A*4*m),F=new kZ(T,N,A,m);F.type=em,F.needsUpdate=!0;const k=P*4;for(let H=0;H<m;H++){const z=g[H],Q=_[H],L=C[H],te=N*A*4*H;for(let ie=0;ie<z.count;ie++){const J=ie*k;y===!0&&(i.fromBufferAttribute(z,ie),T[te+J+0]=i.x,T[te+J+1]=i.y,T[te+J+2]=i.z,T[te+J+3]=0),v===!0&&(i.fromBufferAttribute(Q,ie),T[te+J+4]=i.x,T[te+J+5]=i.y,T[te+J+6]=i.z,T[te+J+7]=0),b===!0&&(i.fromBufferAttribute(L,ie),T[te+J+8]=i.x,T[te+J+9]=i.y,T[te+J+10]=i.z,T[te+J+11]=L.itemSize===4?i.w:1)}}p={count:m,texture:F,size:new nr(N,A)},r.set(l,p),l.addEventListener(\"dispose\",D)}if(o.isInstancedMesh===!0&&o.morphTexture!==null)c.getUniforms().setValue(t,\"morphTexture\",o.morphTexture,n);else{let y=0;for(let b=0;b<d.length;b++)y+=d[b];const v=l.morphTargetsRelative?1:1-y;c.getUniforms().setValue(t,\"morphTargetBaseInfluence\",v),c.getUniforms().setValue(t,\"morphTargetInfluences\",d)}c.getUniforms().setValue(t,\"morphTargetsTexture\",p.texture,n),c.getUniforms().setValue(t,\"morphTargetsTextureSize\",p.size)}return{update:s}}function P_e(t,e,n,r){let i=new WeakMap;function s(c){const d=r.render.frame,u=c.geometry,m=e.get(c,u);if(i.get(m)!==d&&(e.update(m),i.set(m,d)),c.isInstancedMesh&&(c.hasEventListener(\"dispose\",l)===!1&&c.addEventListener(\"dispose\",l),i.get(c)!==d&&(n.update(c.instanceMatrix,t.ARRAY_BUFFER),c.instanceColor!==null&&n.update(c.instanceColor,t.ARRAY_BUFFER),i.set(c,d))),c.isSkinnedMesh){const p=c.skeleton;i.get(p)!==d&&(p.update(),i.set(p,d))}return m}function o(){i=new WeakMap}function l(c){const d=c.target;d.removeEventListener(\"dispose\",l),n.remove(d.instanceMatrix),d.instanceColor!==null&&n.remove(d.instanceColor)}return{update:s,dispose:o}}const qZ=new nd,P6=new LZ(1,1),$Z=new kZ,VZ=new $ve,GZ=new FZ,T6=[],A6=[],j6=new Float32Array(16),M6=new Float32Array(9),E6=new Float32Array(4);function Ny(t,e,n){const r=t[0];if(r<=0||r>0)return t;const i=e*n;let s=T6[i];if(s===void 0&&(s=new Float32Array(i),T6[i]=s),e!==0){r.toArray(s,0);for(let o=1,l=0;o!==e;++o)l+=n,t[o].toArray(s,l)}return s}function Mi(t,e){if(t.length!==e.length)return!1;for(let n=0,r=t.length;n<r;n++)if(t[n]!==e[n])return!1;return!0}function Ei(t,e){for(let n=0,r=e.length;n<r;n++)t[n]=e[n]}function ZP(t,e){let n=A6[e];n===void 0&&(n=new Int32Array(e),A6[e]=n);for(let r=0;r!==e;++r)n[r]=t.allocateTextureUnit();return n}function T_e(t,e){const n=this.cache;n[0]!==e&&(t.uniform1f(this.addr,e),n[0]=e)}function A_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y)&&(t.uniform2f(this.addr,e.x,e.y),n[0]=e.x,n[1]=e.y);else{if(Mi(n,e))return;t.uniform2fv(this.addr,e),Ei(n,e)}}function j_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z)&&(t.uniform3f(this.addr,e.x,e.y,e.z),n[0]=e.x,n[1]=e.y,n[2]=e.z);else if(e.r!==void 0)(n[0]!==e.r||n[1]!==e.g||n[2]!==e.b)&&(t.uniform3f(this.addr,e.r,e.g,e.b),n[0]=e.r,n[1]=e.g,n[2]=e.b);else{if(Mi(n,e))return;t.uniform3fv(this.addr,e),Ei(n,e)}}function M_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z||n[3]!==e.w)&&(t.uniform4f(this.addr,e.x,e.y,e.z,e.w),n[0]=e.x,n[1]=e.y,n[2]=e.z,n[3]=e.w);else{if(Mi(n,e))return;t.uniform4fv(this.addr,e),Ei(n,e)}}function E_e(t,e){const n=this.cache,r=e.elements;if(r===void 0){if(Mi(n,e))return;t.uniformMatrix2fv(this.addr,!1,e),Ei(n,e)}else{if(Mi(n,r))return;E6.set(r),t.uniformMatrix2fv(this.addr,!1,E6),Ei(n,r)}}function D_e(t,e){const n=this.cache,r=e.elements;if(r===void 0){if(Mi(n,e))return;t.uniformMatrix3fv(this.addr,!1,e),Ei(n,e)}else{if(Mi(n,r))return;M6.set(r),t.uniformMatrix3fv(this.addr,!1,M6),Ei(n,r)}}function F_e(t,e){const n=this.cache,r=e.elements;if(r===void 0){if(Mi(n,e))return;t.uniformMatrix4fv(this.addr,!1,e),Ei(n,e)}else{if(Mi(n,r))return;j6.set(r),t.uniformMatrix4fv(this.addr,!1,j6),Ei(n,r)}}function R_e(t,e){const n=this.cache;n[0]!==e&&(t.uniform1i(this.addr,e),n[0]=e)}function L_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y)&&(t.uniform2i(this.addr,e.x,e.y),n[0]=e.x,n[1]=e.y);else{if(Mi(n,e))return;t.uniform2iv(this.addr,e),Ei(n,e)}}function O_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z)&&(t.uniform3i(this.addr,e.x,e.y,e.z),n[0]=e.x,n[1]=e.y,n[2]=e.z);else{if(Mi(n,e))return;t.uniform3iv(this.addr,e),Ei(n,e)}}function I_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z||n[3]!==e.w)&&(t.uniform4i(this.addr,e.x,e.y,e.z,e.w),n[0]=e.x,n[1]=e.y,n[2]=e.z,n[3]=e.w);else{if(Mi(n,e))return;t.uniform4iv(this.addr,e),Ei(n,e)}}function z_e(t,e){const n=this.cache;n[0]!==e&&(t.uniform1ui(this.addr,e),n[0]=e)}function U_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y)&&(t.uniform2ui(this.addr,e.x,e.y),n[0]=e.x,n[1]=e.y);else{if(Mi(n,e))return;t.uniform2uiv(this.addr,e),Ei(n,e)}}function B_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z)&&(t.uniform3ui(this.addr,e.x,e.y,e.z),n[0]=e.x,n[1]=e.y,n[2]=e.z);else{if(Mi(n,e))return;t.uniform3uiv(this.addr,e),Ei(n,e)}}function H_e(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z||n[3]!==e.w)&&(t.uniform4ui(this.addr,e.x,e.y,e.z,e.w),n[0]=e.x,n[1]=e.y,n[2]=e.z,n[3]=e.w);else{if(Mi(n,e))return;t.uniform4uiv(this.addr,e),Ei(n,e)}}function q_e(t,e,n){const r=this.cache,i=n.allocateTextureUnit();r[0]!==i&&(t.uniform1i(this.addr,i),r[0]=i);let s;this.type===t.SAMPLER_2D_SHADOW?(P6.compareFunction=xZ,s=P6):s=qZ,n.setTexture2D(e||s,i)}function $_e(t,e,n){const r=this.cache,i=n.allocateTextureUnit();r[0]!==i&&(t.uniform1i(this.addr,i),r[0]=i),n.setTexture3D(e||VZ,i)}function V_e(t,e,n){const r=this.cache,i=n.allocateTextureUnit();r[0]!==i&&(t.uniform1i(this.addr,i),r[0]=i),n.setTextureCube(e||GZ,i)}function G_e(t,e,n){const r=this.cache,i=n.allocateTextureUnit();r[0]!==i&&(t.uniform1i(this.addr,i),r[0]=i),n.setTexture2DArray(e||$Z,i)}function W_e(t){switch(t){case 5126:return T_e;case 35664:return A_e;case 35665:return j_e;case 35666:return M_e;case 35674:return E_e;case 35675:return D_e;case 35676:return F_e;case 5124:case 35670:return R_e;case 35667:case 35671:return L_e;case 35668:case 35672:return O_e;case 35669:case 35673:return I_e;case 5125:return z_e;case 36294:return U_e;case 36295:return B_e;case 36296:return H_e;case 35678:case 36198:case 36298:case 36306:case 35682:return q_e;case 35679:case 36299:case 36307:return $_e;case 35680:case 36300:case 36308:case 36293:return V_e;case 36289:case 36303:case 36311:case 36292:return G_e}}function K_e(t,e){t.uniform1fv(this.addr,e)}function X_e(t,e){const n=Ny(e,this.size,2);t.uniform2fv(this.addr,n)}function Y_e(t,e){const n=Ny(e,this.size,3);t.uniform3fv(this.addr,n)}function Q_e(t,e){const n=Ny(e,this.size,4);t.uniform4fv(this.addr,n)}function Z_e(t,e){const n=Ny(e,this.size,4);t.uniformMatrix2fv(this.addr,!1,n)}function J_e(t,e){const n=Ny(e,this.size,9);t.uniformMatrix3fv(this.addr,!1,n)}function e1e(t,e){const n=Ny(e,this.size,16);t.uniformMatrix4fv(this.addr,!1,n)}function t1e(t,e){t.uniform1iv(this.addr,e)}function n1e(t,e){t.uniform2iv(this.addr,e)}function r1e(t,e){t.uniform3iv(this.addr,e)}function a1e(t,e){t.uniform4iv(this.addr,e)}function i1e(t,e){t.uniform1uiv(this.addr,e)}function s1e(t,e){t.uniform2uiv(this.addr,e)}function o1e(t,e){t.uniform3uiv(this.addr,e)}function l1e(t,e){t.uniform4uiv(this.addr,e)}function c1e(t,e,n){const r=this.cache,i=e.length,s=ZP(n,i);Mi(r,s)||(t.uniform1iv(this.addr,s),Ei(r,s));for(let o=0;o!==i;++o)n.setTexture2D(e[o]||qZ,s[o])}function d1e(t,e,n){const r=this.cache,i=e.length,s=ZP(n,i);Mi(r,s)||(t.uniform1iv(this.addr,s),Ei(r,s));for(let o=0;o!==i;++o)n.setTexture3D(e[o]||VZ,s[o])}function u1e(t,e,n){const r=this.cache,i=e.length,s=ZP(n,i);Mi(r,s)||(t.uniform1iv(this.addr,s),Ei(r,s));for(let o=0;o!==i;++o)n.setTextureCube(e[o]||GZ,s[o])}function m1e(t,e,n){const r=this.cache,i=e.length,s=ZP(n,i);Mi(r,s)||(t.uniform1iv(this.addr,s),Ei(r,s));for(let o=0;o!==i;++o)n.setTexture2DArray(e[o]||$Z,s[o])}function h1e(t){switch(t){case 5126:return K_e;case 35664:return X_e;case 35665:return Y_e;case 35666:return Q_e;case 35674:return Z_e;case 35675:return J_e;case 35676:return e1e;case 5124:case 35670:return t1e;case 35667:case 35671:return n1e;case 35668:case 35672:return r1e;case 35669:case 35673:return a1e;case 5125:return i1e;case 36294:return s1e;case 36295:return o1e;case 36296:return l1e;case 35678:case 36198:case 36298:case 36306:case 35682:return c1e;case 35679:case 36299:case 36307:return d1e;case 35680:case 36300:case 36308:case 36293:return u1e;case 36289:case 36303:case 36311:case 36292:return m1e}}let p1e=class{constructor(e,n,r){this.id=e,this.addr=r,this.cache=[],this.type=n.type,this.setValue=W_e(n.type)}},f1e=class{constructor(e,n,r){this.id=e,this.addr=r,this.cache=[],this.type=n.type,this.size=n.size,this.setValue=h1e(n.type)}},g1e=class{constructor(e){this.id=e,this.seq=[],this.map={}}setValue(e,n,r){const i=this.seq;for(let s=0,o=i.length;s!==o;++s){const l=i[s];l.setValue(e,n[l.id],r)}}};const lE=/(\\w+)(\\])?(\\[|\\.)?/g;function D6(t,e){t.seq.push(e),t.map[e.id]=e}function b1e(t,e,n){const r=t.name,i=r.length;for(lE.lastIndex=0;;){const s=lE.exec(r),o=lE.lastIndex;let l=s[1];const c=s[2]===\"]\",d=s[3];if(c&&(l=l|0),d===void 0||d===\"[\"&&o+2===i){D6(n,d===void 0?new p1e(l,t,e):new f1e(l,t,e));break}else{let m=n.map[l];m===void 0&&(m=new g1e(l),D6(n,m)),n=m}}}let Wk=class{constructor(e,n){this.seq=[],this.map={};const r=e.getProgramParameter(n,e.ACTIVE_UNIFORMS);for(let i=0;i<r;++i){const s=e.getActiveUniform(n,i),o=e.getUniformLocation(n,s.name);b1e(s,o,this)}}setValue(e,n,r,i){const s=this.map[n];s!==void 0&&s.setValue(e,r,i)}setOptional(e,n,r){const i=n[r];i!==void 0&&this.setValue(e,r,i)}static upload(e,n,r,i){for(let s=0,o=n.length;s!==o;++s){const l=n[s],c=r[l.id];c.needsUpdate!==!1&&l.setValue(e,c.value,i)}}static seqWithValue(e,n){const r=[];for(let i=0,s=e.length;i!==s;++i){const o=e[i];o.id in n&&r.push(o)}return r}};function F6(t,e,n){const r=t.createShader(e);return t.shaderSource(r,n),t.compileShader(r),r}const x1e=37297;let y1e=0;function v1e(t,e){const n=t.split(`\n`),r=[],i=Math.max(e-6,0),s=Math.min(e+6,n.length);for(let o=i;o<s;o++){const l=o+1;r.push(`${l===e?\">\":\" \"} ${l}: ${n[o]}`)}return r.join(`\n`)}const R6=new ir;function w1e(t){Mr._getMatrix(R6,Mr.workingColorSpace,t);const e=`mat3( ${R6.elements.map(n=>n.toFixed(4))} )`;switch(Mr.getTransfer(t)){case TN:return[e,\"LinearTransferOETF\"];case Qr:return[e,\"sRGBTransferOETF\"];default:return qn(\"WebGLProgram: Unsupported color space: \",t),[e,\"LinearTransferOETF\"]}}function L6(t,e,n){const r=t.getShaderParameter(e,t.COMPILE_STATUS),s=(t.getShaderInfoLog(e)||\"\").trim();if(r&&s===\"\")return\"\";const o=/ERROR: 0:(\\d+)/.exec(s);if(o){const l=parseInt(o[1]);return n.toUpperCase()+`\n\n`+s+`\n\n`+v1e(t.getShaderSource(e),l)}else return s}function S1e(t,e){const n=w1e(e);return[`vec4 ${t}( vec4 value ) {`,`\treturn ${n[1]}( vec4( value.rgb * ${n[0]}, value.a ) );`,\"}\"].join(`\n`)}function _1e(t,e){let n;switch(e){case bve:n=\"Linear\";break;case xve:n=\"Reinhard\";break;case yve:n=\"Cineon\";break;case vve:n=\"ACESFilmic\";break;case Sve:n=\"AgX\";break;case _ve:n=\"Neutral\";break;case wve:n=\"Custom\";break;default:qn(\"WebGLProgram: Unsupported toneMapping:\",e),n=\"Linear\"}return\"vec3 \"+t+\"( vec3 color ) { return \"+n+\"ToneMapping( color ); }\"}const y1=new Ct;function k1e(){Mr.getLuminanceCoefficients(y1);const t=y1.x.toFixed(4),e=y1.y.toFixed(4),n=y1.z.toFixed(4);return[\"float luminance( const in vec3 rgb ) {\",`\tconst vec3 weights = vec3( ${t}, ${e}, ${n} );`,\"\treturn dot( weights, rgb );\",\"}\"].join(`\n`)}function N1e(t){return[t.extensionClipCullDistance?\"#extension GL_ANGLE_clip_cull_distance : require\":\"\",t.extensionMultiDraw?\"#extension GL_ANGLE_multi_draw : require\":\"\"].filter(m0).join(`\n`)}function C1e(t){const e=[];for(const n in t){const r=t[n];r!==!1&&e.push(\"#define \"+n+\" \"+r)}return e.join(`\n`)}function P1e(t,e){const n={},r=t.getProgramParameter(e,t.ACTIVE_ATTRIBUTES);for(let i=0;i<r;i++){const s=t.getActiveAttrib(e,i),o=s.name;let l=1;s.type===t.FLOAT_MAT2&&(l=2),s.type===t.FLOAT_MAT3&&(l=3),s.type===t.FLOAT_MAT4&&(l=4),n[o]={type:s.type,location:t.getAttribLocation(e,o),locationSize:l}}return n}function m0(t){return t!==\"\"}function O6(t,e){const n=e.numSpotLightShadows+e.numSpotLightMaps-e.numSpotLightShadowsWithMaps;return t.replace(/NUM_DIR_LIGHTS/g,e.numDirLights).replace(/NUM_SPOT_LIGHTS/g,e.numSpotLights).replace(/NUM_SPOT_LIGHT_MAPS/g,e.numSpotLightMaps).replace(/NUM_SPOT_LIGHT_COORDS/g,n).replace(/NUM_RECT_AREA_LIGHTS/g,e.numRectAreaLights).replace(/NUM_POINT_LIGHTS/g,e.numPointLights).replace(/NUM_HEMI_LIGHTS/g,e.numHemiLights).replace(/NUM_DIR_LIGHT_SHADOWS/g,e.numDirLightShadows).replace(/NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS/g,e.numSpotLightShadowsWithMaps).replace(/NUM_SPOT_LIGHT_SHADOWS/g,e.numSpotLightShadows).replace(/NUM_POINT_LIGHT_SHADOWS/g,e.numPointLightShadows)}function I6(t,e){return t.replace(/NUM_CLIPPING_PLANES/g,e.numClippingPlanes).replace(/UNION_CLIPPING_PLANES/g,e.numClippingPlanes-e.numClipIntersection)}const T1e=/^[ \\t]*#include +<([\\w\\d./]+)>/gm;function iR(t){return t.replace(T1e,j1e)}const A1e=new Map;function j1e(t,e){let n=er[e];if(n===void 0){const r=A1e.get(e);if(r!==void 0)n=er[r],qn('WebGLRenderer: Shader chunk \"%s\" has been deprecated. Use \"%s\" instead.',e,r);else throw new Error(\"Can not resolve #include <\"+e+\">\")}return iR(n)}const M1e=/#pragma unroll_loop_start\\s+for\\s*\\(\\s*int\\s+i\\s*=\\s*(\\d+)\\s*;\\s*i\\s*<\\s*(\\d+)\\s*;\\s*i\\s*\\+\\+\\s*\\)\\s*{([\\s\\S]+?)}\\s+#pragma unroll_loop_end/g;function z6(t){return t.replace(M1e,E1e)}function E1e(t,e,n,r){let i=\"\";for(let s=parseInt(e);s<parseInt(n);s++)i+=r.replace(/\\[\\s*i\\s*\\]/g,\"[ \"+s+\" ]\").replace(/UNROLLED_LOOP_INDEX/g,s);return i}function U6(t){let e=`precision ${t.precision} float;\n\tprecision ${t.precision} int;\n\tprecision ${t.precision} sampler2D;\n\tprecision ${t.precision} samplerCube;\n\tprecision ${t.precision} sampler3D;\n\tprecision ${t.precision} sampler2DArray;\n\tprecision ${t.precision} sampler2DShadow;\n\tprecision ${t.precision} samplerCubeShadow;\n\tprecision ${t.precision} sampler2DArrayShadow;\n\tprecision ${t.precision} isampler2D;\n\tprecision ${t.precision} isampler3D;\n\tprecision ${t.precision} isamplerCube;\n\tprecision ${t.precision} isampler2DArray;\n\tprecision ${t.precision} usampler2D;\n\tprecision ${t.precision} usampler3D;\n\tprecision ${t.precision} usamplerCube;\n\tprecision ${t.precision} usampler2DArray;\n\t`;return t.precision===\"highp\"?e+=`\n#define HIGH_PRECISION`:t.precision===\"mediump\"?e+=`\n#define MEDIUM_PRECISION`:t.precision===\"lowp\"&&(e+=`\n#define LOW_PRECISION`),e}function D1e(t){let e=\"SHADOWMAP_TYPE_BASIC\";return t.shadowMapType===lZ?e=\"SHADOWMAP_TYPE_PCF\":t.shadowMapType===Yye?e=\"SHADOWMAP_TYPE_PCF_SOFT\":t.shadowMapType===zu&&(e=\"SHADOWMAP_TYPE_VSM\"),e}function F1e(t){let e=\"ENVMAP_TYPE_CUBE\";if(t.envMap)switch(t.envMapMode){case Gx:case Wx:e=\"ENVMAP_TYPE_CUBE\";break;case XP:e=\"ENVMAP_TYPE_CUBE_UV\";break}return e}function R1e(t){let e=\"ENVMAP_MODE_REFLECTION\";return t.envMap&&t.envMapMode===Wx&&(e=\"ENVMAP_MODE_REFRACTION\"),e}function L1e(t){let e=\"ENVMAP_BLENDING_NONE\";if(t.envMap)switch(t.combine){case VO:e=\"ENVMAP_BLENDING_MULTIPLY\";break;case fve:e=\"ENVMAP_BLENDING_MIX\";break;case gve:e=\"ENVMAP_BLENDING_ADD\";break}return e}function O1e(t){const e=t.envMapCubeUVHeight;if(e===null)return null;const n=Math.log2(e)-2,r=1/e;return{texelWidth:1/(3*Math.max(Math.pow(2,n),112)),texelHeight:r,maxMip:n}}function I1e(t,e,n,r){const i=t.getContext(),s=n.defines;let o=n.vertexShader,l=n.fragmentShader;const c=D1e(n),d=F1e(n),u=R1e(n),m=L1e(n),p=O1e(n),f=N1e(n),y=C1e(s),v=i.createProgram();let b,g,_=n.glslVersion?\"#version \"+n.glslVersion+`\n`:\"\";n.isRawShaderMaterial?(b=[\"#define SHADER_TYPE \"+n.shaderType,\"#define SHADER_NAME \"+n.shaderName,y].filter(m0).join(`\n`),b.length>0&&(b+=`\n`),g=[\"#define SHADER_TYPE \"+n.shaderType,\"#define SHADER_NAME \"+n.shaderName,y].filter(m0).join(`\n`),g.length>0&&(g+=`\n`)):(b=[U6(n),\"#define SHADER_TYPE \"+n.shaderType,\"#define SHADER_NAME \"+n.shaderName,y,n.extensionClipCullDistance?\"#define USE_CLIP_DISTANCE\":\"\",n.batching?\"#define USE_BATCHING\":\"\",n.batchingColor?\"#define USE_BATCHING_COLOR\":\"\",n.instancing?\"#define USE_INSTANCING\":\"\",n.instancingColor?\"#define USE_INSTANCING_COLOR\":\"\",n.instancingMorph?\"#define USE_INSTANCING_MORPH\":\"\",n.useFog&&n.fog?\"#define USE_FOG\":\"\",n.useFog&&n.fogExp2?\"#define FOG_EXP2\":\"\",n.map?\"#define USE_MAP\":\"\",n.envMap?\"#define USE_ENVMAP\":\"\",n.envMap?\"#define \"+u:\"\",n.lightMap?\"#define USE_LIGHTMAP\":\"\",n.aoMap?\"#define USE_AOMAP\":\"\",n.bumpMap?\"#define USE_BUMPMAP\":\"\",n.normalMap?\"#define USE_NORMALMAP\":\"\",n.normalMapObjectSpace?\"#define USE_NORMALMAP_OBJECTSPACE\":\"\",n.normalMapTangentSpace?\"#define USE_NORMALMAP_TANGENTSPACE\":\"\",n.displacementMap?\"#define USE_DISPLACEMENTMAP\":\"\",n.emissiveMap?\"#define USE_EMISSIVEMAP\":\"\",n.anisotropy?\"#define USE_ANISOTROPY\":\"\",n.anisotropyMap?\"#define USE_ANISOTROPYMAP\":\"\",n.clearcoatMap?\"#define USE_CLEARCOATMAP\":\"\",n.clearcoatRoughnessMap?\"#define USE_CLEARCOAT_ROUGHNESSMAP\":\"\",n.clearcoatNormalMap?\"#define USE_CLEARCOAT_NORMALMAP\":\"\",n.iridescenceMap?\"#define USE_IRIDESCENCEMAP\":\"\",n.iridescenceThicknessMap?\"#define USE_IRIDESCENCE_THICKNESSMAP\":\"\",n.specularMap?\"#define USE_SPECULARMAP\":\"\",n.specularColorMap?\"#define USE_SPECULAR_COLORMAP\":\"\",n.specularIntensityMap?\"#define USE_SPECULAR_INTENSITYMAP\":\"\",n.roughnessMap?\"#define USE_ROUGHNESSMAP\":\"\",n.metalnessMap?\"#define USE_METALNESSMAP\":\"\",n.alphaMap?\"#define USE_ALPHAMAP\":\"\",n.alphaHash?\"#define USE_ALPHAHASH\":\"\",n.transmission?\"#define USE_TRANSMISSION\":\"\",n.transmissionMap?\"#define USE_TRANSMISSIONMAP\":\"\",n.thicknessMap?\"#define USE_THICKNESSMAP\":\"\",n.sheenColorMap?\"#define USE_SHEEN_COLORMAP\":\"\",n.sheenRoughnessMap?\"#define USE_SHEEN_ROUGHNESSMAP\":\"\",n.mapUv?\"#define MAP_UV \"+n.mapUv:\"\",n.alphaMapUv?\"#define ALPHAMAP_UV \"+n.alphaMapUv:\"\",n.lightMapUv?\"#define LIGHTMAP_UV \"+n.lightMapUv:\"\",n.aoMapUv?\"#define AOMAP_UV \"+n.aoMapUv:\"\",n.emissiveMapUv?\"#define EMISSIVEMAP_UV \"+n.emissiveMapUv:\"\",n.bumpMapUv?\"#define BUMPMAP_UV \"+n.bumpMapUv:\"\",n.normalMapUv?\"#define NORMALMAP_UV \"+n.normalMapUv:\"\",n.displacementMapUv?\"#define DISPLACEMENTMAP_UV \"+n.displacementMapUv:\"\",n.metalnessMapUv?\"#define METALNESSMAP_UV \"+n.metalnessMapUv:\"\",n.roughnessMapUv?\"#define ROUGHNESSMAP_UV \"+n.roughnessMapUv:\"\",n.anisotropyMapUv?\"#define ANISOTROPYMAP_UV \"+n.anisotropyMapUv:\"\",n.clearcoatMapUv?\"#define CLEARCOATMAP_UV \"+n.clearcoatMapUv:\"\",n.clearcoatNormalMapUv?\"#define CLEARCOAT_NORMALMAP_UV \"+n.clearcoatNormalMapUv:\"\",n.clearcoatRoughnessMapUv?\"#define CLEARCOAT_ROUGHNESSMAP_UV \"+n.clearcoatRoughnessMapUv:\"\",n.iridescenceMapUv?\"#define IRIDESCENCEMAP_UV \"+n.iridescenceMapUv:\"\",n.iridescenceThicknessMapUv?\"#define IRIDESCENCE_THICKNESSMAP_UV \"+n.iridescenceThicknessMapUv:\"\",n.sheenColorMapUv?\"#define SHEEN_COLORMAP_UV \"+n.sheenColorMapUv:\"\",n.sheenRoughnessMapUv?\"#define SHEEN_ROUGHNESSMAP_UV \"+n.sheenRoughnessMapUv:\"\",n.specularMapUv?\"#define SPECULARMAP_UV \"+n.specularMapUv:\"\",n.specularColorMapUv?\"#define SPECULAR_COLORMAP_UV \"+n.specularColorMapUv:\"\",n.specularIntensityMapUv?\"#define SPECULAR_INTENSITYMAP_UV \"+n.specularIntensityMapUv:\"\",n.transmissionMapUv?\"#define TRANSMISSIONMAP_UV \"+n.transmissionMapUv:\"\",n.thicknessMapUv?\"#define THICKNESSMAP_UV \"+n.thicknessMapUv:\"\",n.vertexTangents&&n.flatShading===!1?\"#define USE_TANGENT\":\"\",n.vertexColors?\"#define USE_COLOR\":\"\",n.vertexAlphas?\"#define USE_COLOR_ALPHA\":\"\",n.vertexUv1s?\"#define USE_UV1\":\"\",n.vertexUv2s?\"#define USE_UV2\":\"\",n.vertexUv3s?\"#define USE_UV3\":\"\",n.pointsUvs?\"#define USE_POINTS_UV\":\"\",n.flatShading?\"#define FLAT_SHADED\":\"\",n.skinning?\"#define USE_SKINNING\":\"\",n.morphTargets?\"#define USE_MORPHTARGETS\":\"\",n.morphNormals&&n.flatShading===!1?\"#define USE_MORPHNORMALS\":\"\",n.morphColors?\"#define USE_MORPHCOLORS\":\"\",n.morphTargetsCount>0?\"#define MORPHTARGETS_TEXTURE_STRIDE \"+n.morphTextureStride:\"\",n.morphTargetsCount>0?\"#define MORPHTARGETS_COUNT \"+n.morphTargetsCount:\"\",n.doubleSided?\"#define DOUBLE_SIDED\":\"\",n.flipSided?\"#define FLIP_SIDED\":\"\",n.shadowMapEnabled?\"#define USE_SHADOWMAP\":\"\",n.shadowMapEnabled?\"#define \"+c:\"\",n.sizeAttenuation?\"#define USE_SIZEATTENUATION\":\"\",n.numLightProbes>0?\"#define USE_LIGHT_PROBES\":\"\",n.logarithmicDepthBuffer?\"#define USE_LOGARITHMIC_DEPTH_BUFFER\":\"\",n.reversedDepthBuffer?\"#define USE_REVERSED_DEPTH_BUFFER\":\"\",\"uniform mat4 modelMatrix;\",\"uniform mat4 modelViewMatrix;\",\"uniform mat4 projectionMatrix;\",\"uniform mat4 viewMatrix;\",\"uniform mat3 normalMatrix;\",\"uniform vec3 cameraPosition;\",\"uniform bool isOrthographic;\",\"#ifdef USE_INSTANCING\",\"\tattribute mat4 instanceMatrix;\",\"#endif\",\"#ifdef USE_INSTANCING_COLOR\",\"\tattribute vec3 instanceColor;\",\"#endif\",\"#ifdef USE_INSTANCING_MORPH\",\"\tuniform sampler2D morphTexture;\",\"#endif\",\"attribute vec3 position;\",\"attribute vec3 normal;\",\"attribute vec2 uv;\",\"#ifdef USE_UV1\",\"\tattribute vec2 uv1;\",\"#endif\",\"#ifdef USE_UV2\",\"\tattribute vec2 uv2;\",\"#endif\",\"#ifdef USE_UV3\",\"\tattribute vec2 uv3;\",\"#endif\",\"#ifdef USE_TANGENT\",\"\tattribute vec4 tangent;\",\"#endif\",\"#if defined( USE_COLOR_ALPHA )\",\"\tattribute vec4 color;\",\"#elif defined( USE_COLOR )\",\"\tattribute vec3 color;\",\"#endif\",\"#ifdef USE_SKINNING\",\"\tattribute vec4 skinIndex;\",\"\tattribute vec4 skinWeight;\",\"#endif\",`\n`].filter(m0).join(`\n`),g=[U6(n),\"#define SHADER_TYPE \"+n.shaderType,\"#define SHADER_NAME \"+n.shaderName,y,n.useFog&&n.fog?\"#define USE_FOG\":\"\",n.useFog&&n.fogExp2?\"#define FOG_EXP2\":\"\",n.alphaToCoverage?\"#define ALPHA_TO_COVERAGE\":\"\",n.map?\"#define USE_MAP\":\"\",n.matcap?\"#define USE_MATCAP\":\"\",n.envMap?\"#define USE_ENVMAP\":\"\",n.envMap?\"#define \"+d:\"\",n.envMap?\"#define \"+u:\"\",n.envMap?\"#define \"+m:\"\",p?\"#define CUBEUV_TEXEL_WIDTH \"+p.texelWidth:\"\",p?\"#define CUBEUV_TEXEL_HEIGHT \"+p.texelHeight:\"\",p?\"#define CUBEUV_MAX_MIP \"+p.maxMip+\".0\":\"\",n.lightMap?\"#define USE_LIGHTMAP\":\"\",n.aoMap?\"#define USE_AOMAP\":\"\",n.bumpMap?\"#define USE_BUMPMAP\":\"\",n.normalMap?\"#define USE_NORMALMAP\":\"\",n.normalMapObjectSpace?\"#define USE_NORMALMAP_OBJECTSPACE\":\"\",n.normalMapTangentSpace?\"#define USE_NORMALMAP_TANGENTSPACE\":\"\",n.emissiveMap?\"#define USE_EMISSIVEMAP\":\"\",n.anisotropy?\"#define USE_ANISOTROPY\":\"\",n.anisotropyMap?\"#define USE_ANISOTROPYMAP\":\"\",n.clearcoat?\"#define USE_CLEARCOAT\":\"\",n.clearcoatMap?\"#define USE_CLEARCOATMAP\":\"\",n.clearcoatRoughnessMap?\"#define USE_CLEARCOAT_ROUGHNESSMAP\":\"\",n.clearcoatNormalMap?\"#define USE_CLEARCOAT_NORMALMAP\":\"\",n.dispersion?\"#define USE_DISPERSION\":\"\",n.iridescence?\"#define USE_IRIDESCENCE\":\"\",n.iridescenceMap?\"#define USE_IRIDESCENCEMAP\":\"\",n.iridescenceThicknessMap?\"#define USE_IRIDESCENCE_THICKNESSMAP\":\"\",n.specularMap?\"#define USE_SPECULARMAP\":\"\",n.specularColorMap?\"#define USE_SPECULAR_COLORMAP\":\"\",n.specularIntensityMap?\"#define USE_SPECULAR_INTENSITYMAP\":\"\",n.roughnessMap?\"#define USE_ROUGHNESSMAP\":\"\",n.metalnessMap?\"#define USE_METALNESSMAP\":\"\",n.alphaMap?\"#define USE_ALPHAMAP\":\"\",n.alphaTest?\"#define USE_ALPHATEST\":\"\",n.alphaHash?\"#define USE_ALPHAHASH\":\"\",n.sheen?\"#define USE_SHEEN\":\"\",n.sheenColorMap?\"#define USE_SHEEN_COLORMAP\":\"\",n.sheenRoughnessMap?\"#define USE_SHEEN_ROUGHNESSMAP\":\"\",n.transmission?\"#define USE_TRANSMISSION\":\"\",n.transmissionMap?\"#define USE_TRANSMISSIONMAP\":\"\",n.thicknessMap?\"#define USE_THICKNESSMAP\":\"\",n.vertexTangents&&n.flatShading===!1?\"#define USE_TANGENT\":\"\",n.vertexColors||n.instancingColor||n.batchingColor?\"#define USE_COLOR\":\"\",n.vertexAlphas?\"#define USE_COLOR_ALPHA\":\"\",n.vertexUv1s?\"#define USE_UV1\":\"\",n.vertexUv2s?\"#define USE_UV2\":\"\",n.vertexUv3s?\"#define USE_UV3\":\"\",n.pointsUvs?\"#define USE_POINTS_UV\":\"\",n.gradientMap?\"#define USE_GRADIENTMAP\":\"\",n.flatShading?\"#define FLAT_SHADED\":\"\",n.doubleSided?\"#define DOUBLE_SIDED\":\"\",n.flipSided?\"#define FLIP_SIDED\":\"\",n.shadowMapEnabled?\"#define USE_SHADOWMAP\":\"\",n.shadowMapEnabled?\"#define \"+c:\"\",n.premultipliedAlpha?\"#define PREMULTIPLIED_ALPHA\":\"\",n.numLightProbes>0?\"#define USE_LIGHT_PROBES\":\"\",n.decodeVideoTexture?\"#define DECODE_VIDEO_TEXTURE\":\"\",n.decodeVideoTextureEmissive?\"#define DECODE_VIDEO_TEXTURE_EMISSIVE\":\"\",n.logarithmicDepthBuffer?\"#define USE_LOGARITHMIC_DEPTH_BUFFER\":\"\",n.reversedDepthBuffer?\"#define USE_REVERSED_DEPTH_BUFFER\":\"\",\"uniform mat4 viewMatrix;\",\"uniform vec3 cameraPosition;\",\"uniform bool isOrthographic;\",n.toneMapping!==ip?\"#define TONE_MAPPING\":\"\",n.toneMapping!==ip?er.tonemapping_pars_fragment:\"\",n.toneMapping!==ip?_1e(\"toneMapping\",n.toneMapping):\"\",n.dithering?\"#define DITHERING\":\"\",n.opaque?\"#define OPAQUE\":\"\",er.colorspace_pars_fragment,S1e(\"linearToOutputTexel\",n.outputColorSpace),k1e(),n.useDepthPacking?\"#define DEPTH_PACKING \"+n.depthPacking:\"\",`\n`].filter(m0).join(`\n`)),o=iR(o),o=O6(o,n),o=I6(o,n),l=iR(l),l=O6(l,n),l=I6(l,n),o=z6(o),l=z6(l),n.isRawShaderMaterial!==!0&&(_=`#version 300 es\n`,b=[f,\"#define attribute in\",\"#define varying out\",\"#define texture2D texture\"].join(`\n`)+`\n`+b,g=[\"#define varying in\",n.glslVersion===GH?\"\":\"layout(location = 0) out highp vec4 pc_fragColor;\",n.glslVersion===GH?\"\":\"#define gl_FragColor pc_fragColor\",\"#define gl_FragDepthEXT gl_FragDepth\",\"#define texture2D texture\",\"#define textureCube texture\",\"#define texture2DProj textureProj\",\"#define texture2DLodEXT textureLod\",\"#define texture2DProjLodEXT textureProjLod\",\"#define textureCubeLodEXT textureLod\",\"#define texture2DGradEXT textureGrad\",\"#define texture2DProjGradEXT textureProjGrad\",\"#define textureCubeGradEXT textureGrad\"].join(`\n`)+`\n`+g);const C=_+b+o,P=_+g+l,N=F6(i,i.VERTEX_SHADER,C),A=F6(i,i.FRAGMENT_SHADER,P);i.attachShader(v,N),i.attachShader(v,A),n.index0AttributeName!==void 0?i.bindAttribLocation(v,0,n.index0AttributeName):n.morphTargets===!0&&i.bindAttribLocation(v,0,\"position\"),i.linkProgram(v);function T(H){if(t.debug.checkShaderErrors){const z=i.getProgramInfoLog(v)||\"\",Q=i.getShaderInfoLog(N)||\"\",L=i.getShaderInfoLog(A)||\"\",te=z.trim(),ie=Q.trim(),J=L.trim();let oe=!0,fe=!0;if(i.getProgramParameter(v,i.LINK_STATUS)===!1)if(oe=!1,typeof t.debug.onShaderError==\"function\")t.debug.onShaderError(i,v,N,A);else{const re=L6(i,N,\"vertex\"),W=L6(i,A,\"fragment\");oi(\"THREE.WebGLProgram: Shader Error \"+i.getError()+\" - VALIDATE_STATUS \"+i.getProgramParameter(v,i.VALIDATE_STATUS)+`\n\nMaterial Name: `+H.name+`\nMaterial Type: `+H.type+`\n\nProgram Info Log: `+te+`\n`+re+`\n`+W)}else te!==\"\"?qn(\"WebGLProgram: Program Info Log:\",te):(ie===\"\"||J===\"\")&&(fe=!1);fe&&(H.diagnostics={runnable:oe,programLog:te,vertexShader:{log:ie,prefix:b},fragmentShader:{log:J,prefix:g}})}i.deleteShader(N),i.deleteShader(A),F=new Wk(i,v),k=P1e(i,v)}let F;this.getUniforms=function(){return F===void 0&&T(this),F};let k;this.getAttributes=function(){return k===void 0&&T(this),k};let D=n.rendererExtensionParallelShaderCompile===!1;return this.isReady=function(){return D===!1&&(D=i.getProgramParameter(v,x1e)),D},this.destroy=function(){r.releaseStatesOfProgram(this),i.deleteProgram(v),this.program=void 0},this.type=n.shaderType,this.name=n.shaderName,this.id=y1e++,this.cacheKey=e,this.usedTimes=1,this.program=v,this.vertexShader=N,this.fragmentShader=A,this}let z1e=0,U1e=class{constructor(){this.shaderCache=new Map,this.materialCache=new Map}update(e){const n=e.vertexShader,r=e.fragmentShader,i=this._getShaderStage(n),s=this._getShaderStage(r),o=this._getShaderCacheForMaterial(e);return o.has(i)===!1&&(o.add(i),i.usedTimes++),o.has(s)===!1&&(o.add(s),s.usedTimes++),this}remove(e){const n=this.materialCache.get(e);for(const r of n)r.usedTimes--,r.usedTimes===0&&this.shaderCache.delete(r.code);return this.materialCache.delete(e),this}getVertexShaderID(e){return this._getShaderStage(e.vertexShader).id}getFragmentShaderID(e){return this._getShaderStage(e.fragmentShader).id}dispose(){this.shaderCache.clear(),this.materialCache.clear()}_getShaderCacheForMaterial(e){const n=this.materialCache;let r=n.get(e);return r===void 0&&(r=new Set,n.set(e,r)),r}_getShaderStage(e){const n=this.shaderCache;let r=n.get(e);return r===void 0&&(r=new B1e(e),n.set(e,r)),r}},B1e=class{constructor(e){this.id=z1e++,this.code=e,this.usedTimes=0}};function H1e(t,e,n,r,i,s,o){const l=new CZ,c=new U1e,d=new Set,u=[],m=i.logarithmicDepthBuffer,p=i.vertexTextures;let f=i.precision;const y={MeshDepthMaterial:\"depth\",MeshDistanceMaterial:\"distanceRGBA\",MeshNormalMaterial:\"normal\",MeshBasicMaterial:\"basic\",MeshLambertMaterial:\"lambert\",MeshPhongMaterial:\"phong\",MeshToonMaterial:\"toon\",MeshStandardMaterial:\"physical\",MeshPhysicalMaterial:\"physical\",MeshMatcapMaterial:\"matcap\",LineBasicMaterial:\"basic\",LineDashedMaterial:\"dashed\",PointsMaterial:\"points\",ShadowMaterial:\"shadow\",SpriteMaterial:\"sprite\"};function v(k){return d.add(k),k===0?\"uv\":`uv${k}`}function b(k,D,H,z,Q){const L=z.fog,te=Q.geometry,ie=k.isMeshStandardMaterial?z.environment:null,J=(k.isMeshStandardMaterial?n:e).get(k.envMap||ie),oe=J&&J.mapping===XP?J.image.height:null,fe=y[k.type];k.precision!==null&&(f=i.getMaxPrecision(k.precision),f!==k.precision&&qn(\"WebGLProgram.getParameters:\",k.precision,\"not supported, using\",f,\"instead.\"));const re=te.morphAttributes.position||te.morphAttributes.normal||te.morphAttributes.color,W=re!==void 0?re.length:0;let ne=0;te.morphAttributes.position!==void 0&&(ne=1),te.morphAttributes.normal!==void 0&&(ne=2),te.morphAttributes.color!==void 0&&(ne=3);let me,be,Ce,q;if(fe){const ht=Nd[fe];me=ht.vertexShader,be=ht.fragmentShader}else me=k.vertexShader,be=k.fragmentShader,c.update(k),Ce=c.getVertexShaderID(k),q=c.getFragmentShaderID(k);const Y=t.getRenderTarget(),E=t.state.buffers.depth.getReversed(),j=Q.isInstancedMesh===!0,O=Q.isBatchedMesh===!0,K=!!k.map,U=!!k.matcap,de=!!J,I=!!k.aoMap,G=!!k.lightMap,X=!!k.bumpMap,V=!!k.normalMap,ee=!!k.displacementMap,se=!!k.emissiveMap,ge=!!k.metalnessMap,he=!!k.roughnessMap,le=k.anisotropy>0,B=k.clearcoat>0,R=k.dispersion>0,ae=k.iridescence>0,_e=k.sheen>0,Se=k.transmission>0,ve=le&&!!k.anisotropyMap,Te=B&&!!k.clearcoatMap,ye=B&&!!k.clearcoatNormalMap,je=B&&!!k.clearcoatRoughnessMap,Le=ae&&!!k.iridescenceMap,Me=ae&&!!k.iridescenceThicknessMap,Oe=_e&&!!k.sheenColorMap,Re=_e&&!!k.sheenRoughnessMap,$e=!!k.specularMap,Ye=!!k.specularColorMap,tt=!!k.specularIntensityMap,pe=Se&&!!k.transmissionMap,Fe=Se&&!!k.thicknessMap,we=!!k.gradientMap,Ve=!!k.alphaMap,Ae=k.alphaTest>0,ce=!!k.alphaHash,xe=!!k.extensions;let Be=ip;k.toneMapped&&(Y===null||Y.isXRRenderTarget===!0)&&(Be=t.toneMapping);const Qe={shaderID:fe,shaderType:k.type,shaderName:k.name,vertexShader:me,fragmentShader:be,defines:k.defines,customVertexShaderID:Ce,customFragmentShaderID:q,isRawShaderMaterial:k.isRawShaderMaterial===!0,glslVersion:k.glslVersion,precision:f,batching:O,batchingColor:O&&Q._colorsTexture!==null,instancing:j,instancingColor:j&&Q.instanceColor!==null,instancingMorph:j&&Q.morphTexture!==null,supportsVertexTextures:p,outputColorSpace:Y===null?t.outputColorSpace:Y.isXRRenderTarget===!0?Y.texture.colorSpace:Kx,alphaToCoverage:!!k.alphaToCoverage,map:K,matcap:U,envMap:de,envMapMode:de&&J.mapping,envMapCubeUVHeight:oe,aoMap:I,lightMap:G,bumpMap:X,normalMap:V,displacementMap:p&&ee,emissiveMap:se,normalMapObjectSpace:V&&k.normalMapType===Pve,normalMapTangentSpace:V&&k.normalMapType===bZ,metalnessMap:ge,roughnessMap:he,anisotropy:le,anisotropyMap:ve,clearcoat:B,clearcoatMap:Te,clearcoatNormalMap:ye,clearcoatRoughnessMap:je,dispersion:R,iridescence:ae,iridescenceMap:Le,iridescenceThicknessMap:Me,sheen:_e,sheenColorMap:Oe,sheenRoughnessMap:Re,specularMap:$e,specularColorMap:Ye,specularIntensityMap:tt,transmission:Se,transmissionMap:pe,thicknessMap:Fe,gradientMap:we,opaque:k.transparent===!1&&k.blending===Ax&&k.alphaToCoverage===!1,alphaMap:Ve,alphaTest:Ae,alphaHash:ce,combine:k.combine,mapUv:K&&v(k.map.channel),aoMapUv:I&&v(k.aoMap.channel),lightMapUv:G&&v(k.lightMap.channel),bumpMapUv:X&&v(k.bumpMap.channel),normalMapUv:V&&v(k.normalMap.channel),displacementMapUv:ee&&v(k.displacementMap.channel),emissiveMapUv:se&&v(k.emissiveMap.channel),metalnessMapUv:ge&&v(k.metalnessMap.channel),roughnessMapUv:he&&v(k.roughnessMap.channel),anisotropyMapUv:ve&&v(k.anisotropyMap.channel),clearcoatMapUv:Te&&v(k.clearcoatMap.channel),clearcoatNormalMapUv:ye&&v(k.clearcoatNormalMap.channel),clearcoatRoughnessMapUv:je&&v(k.clearcoatRoughnessMap.channel),iridescenceMapUv:Le&&v(k.iridescenceMap.channel),iridescenceThicknessMapUv:Me&&v(k.iridescenceThicknessMap.channel),sheenColorMapUv:Oe&&v(k.sheenColorMap.channel),sheenRoughnessMapUv:Re&&v(k.sheenRoughnessMap.channel),specularMapUv:$e&&v(k.specularMap.channel),specularColorMapUv:Ye&&v(k.specularColorMap.channel),specularIntensityMapUv:tt&&v(k.specularIntensityMap.channel),transmissionMapUv:pe&&v(k.transmissionMap.channel),thicknessMapUv:Fe&&v(k.thicknessMap.channel),alphaMapUv:Ve&&v(k.alphaMap.channel),vertexTangents:!!te.attributes.tangent&&(V||le),vertexColors:k.vertexColors,vertexAlphas:k.vertexColors===!0&&!!te.attributes.color&&te.attributes.color.itemSize===4,pointsUvs:Q.isPoints===!0&&!!te.attributes.uv&&(K||Ve),fog:!!L,useFog:k.fog===!0,fogExp2:!!L&&L.isFogExp2,flatShading:k.flatShading===!0&&k.wireframe===!1,sizeAttenuation:k.sizeAttenuation===!0,logarithmicDepthBuffer:m,reversedDepthBuffer:E,skinning:Q.isSkinnedMesh===!0,morphTargets:te.morphAttributes.position!==void 0,morphNormals:te.morphAttributes.normal!==void 0,morphColors:te.morphAttributes.color!==void 0,morphTargetsCount:W,morphTextureStride:ne,numDirLights:D.directional.length,numPointLights:D.point.length,numSpotLights:D.spot.length,numSpotLightMaps:D.spotLightMap.length,numRectAreaLights:D.rectArea.length,numHemiLights:D.hemi.length,numDirLightShadows:D.directionalShadowMap.length,numPointLightShadows:D.pointShadowMap.length,numSpotLightShadows:D.spotShadowMap.length,numSpotLightShadowsWithMaps:D.numSpotLightShadowsWithMaps,numLightProbes:D.numLightProbes,numClippingPlanes:o.numPlanes,numClipIntersection:o.numIntersection,dithering:k.dithering,shadowMapEnabled:t.shadowMap.enabled&&H.length>0,shadowMapType:t.shadowMap.type,toneMapping:Be,decodeVideoTexture:K&&k.map.isVideoTexture===!0&&Mr.getTransfer(k.map.colorSpace)===Qr,decodeVideoTextureEmissive:se&&k.emissiveMap.isVideoTexture===!0&&Mr.getTransfer(k.emissiveMap.colorSpace)===Qr,premultipliedAlpha:k.premultipliedAlpha,doubleSided:k.side===Pd,flipSided:k.side===zo,useDepthPacking:k.depthPacking>=0,depthPacking:k.depthPacking||0,index0AttributeName:k.index0AttributeName,extensionClipCullDistance:xe&&k.extensions.clipCullDistance===!0&&r.has(\"WEBGL_clip_cull_distance\"),extensionMultiDraw:(xe&&k.extensions.multiDraw===!0||O)&&r.has(\"WEBGL_multi_draw\"),rendererExtensionParallelShaderCompile:r.has(\"KHR_parallel_shader_compile\"),customProgramCacheKey:k.customProgramCacheKey()};return Qe.vertexUv1s=d.has(1),Qe.vertexUv2s=d.has(2),Qe.vertexUv3s=d.has(3),d.clear(),Qe}function g(k){const D=[];if(k.shaderID?D.push(k.shaderID):(D.push(k.customVertexShaderID),D.push(k.customFragmentShaderID)),k.defines!==void 0)for(const H in k.defines)D.push(H),D.push(k.defines[H]);return k.isRawShaderMaterial===!1&&(_(D,k),C(D,k),D.push(t.outputColorSpace)),D.push(k.customProgramCacheKey),D.join()}function _(k,D){k.push(D.precision),k.push(D.outputColorSpace),k.push(D.envMapMode),k.push(D.envMapCubeUVHeight),k.push(D.mapUv),k.push(D.alphaMapUv),k.push(D.lightMapUv),k.push(D.aoMapUv),k.push(D.bumpMapUv),k.push(D.normalMapUv),k.push(D.displacementMapUv),k.push(D.emissiveMapUv),k.push(D.metalnessMapUv),k.push(D.roughnessMapUv),k.push(D.anisotropyMapUv),k.push(D.clearcoatMapUv),k.push(D.clearcoatNormalMapUv),k.push(D.clearcoatRoughnessMapUv),k.push(D.iridescenceMapUv),k.push(D.iridescenceThicknessMapUv),k.push(D.sheenColorMapUv),k.push(D.sheenRoughnessMapUv),k.push(D.specularMapUv),k.push(D.specularColorMapUv),k.push(D.specularIntensityMapUv),k.push(D.transmissionMapUv),k.push(D.thicknessMapUv),k.push(D.combine),k.push(D.fogExp2),k.push(D.sizeAttenuation),k.push(D.morphTargetsCount),k.push(D.morphAttributeCount),k.push(D.numDirLights),k.push(D.numPointLights),k.push(D.numSpotLights),k.push(D.numSpotLightMaps),k.push(D.numHemiLights),k.push(D.numRectAreaLights),k.push(D.numDirLightShadows),k.push(D.numPointLightShadows),k.push(D.numSpotLightShadows),k.push(D.numSpotLightShadowsWithMaps),k.push(D.numLightProbes),k.push(D.shadowMapType),k.push(D.toneMapping),k.push(D.numClippingPlanes),k.push(D.numClipIntersection),k.push(D.depthPacking)}function C(k,D){l.disableAll(),D.supportsVertexTextures&&l.enable(0),D.instancing&&l.enable(1),D.instancingColor&&l.enable(2),D.instancingMorph&&l.enable(3),D.matcap&&l.enable(4),D.envMap&&l.enable(5),D.normalMapObjectSpace&&l.enable(6),D.normalMapTangentSpace&&l.enable(7),D.clearcoat&&l.enable(8),D.iridescence&&l.enable(9),D.alphaTest&&l.enable(10),D.vertexColors&&l.enable(11),D.vertexAlphas&&l.enable(12),D.vertexUv1s&&l.enable(13),D.vertexUv2s&&l.enable(14),D.vertexUv3s&&l.enable(15),D.vertexTangents&&l.enable(16),D.anisotropy&&l.enable(17),D.alphaHash&&l.enable(18),D.batching&&l.enable(19),D.dispersion&&l.enable(20),D.batchingColor&&l.enable(21),D.gradientMap&&l.enable(22),k.push(l.mask),l.disableAll(),D.fog&&l.enable(0),D.useFog&&l.enable(1),D.flatShading&&l.enable(2),D.logarithmicDepthBuffer&&l.enable(3),D.reversedDepthBuffer&&l.enable(4),D.skinning&&l.enable(5),D.morphTargets&&l.enable(6),D.morphNormals&&l.enable(7),D.morphColors&&l.enable(8),D.premultipliedAlpha&&l.enable(9),D.shadowMapEnabled&&l.enable(10),D.doubleSided&&l.enable(11),D.flipSided&&l.enable(12),D.useDepthPacking&&l.enable(13),D.dithering&&l.enable(14),D.transmission&&l.enable(15),D.sheen&&l.enable(16),D.opaque&&l.enable(17),D.pointsUvs&&l.enable(18),D.decodeVideoTexture&&l.enable(19),D.decodeVideoTextureEmissive&&l.enable(20),D.alphaToCoverage&&l.enable(21),k.push(l.mask)}function P(k){const D=y[k.type];let H;if(D){const z=Nd[D];H=r0e.clone(z.uniforms)}else H=k.uniforms;return H}function N(k,D){let H;for(let z=0,Q=u.length;z<Q;z++){const L=u[z];if(L.cacheKey===D){H=L,++H.usedTimes;break}}return H===void 0&&(H=new I1e(t,D,k,s),u.push(H)),H}function A(k){if(--k.usedTimes===0){const D=u.indexOf(k);u[D]=u[u.length-1],u.pop(),k.destroy()}}function T(k){c.remove(k)}function F(){c.dispose()}return{getParameters:b,getProgramCacheKey:g,getUniforms:P,acquireProgram:N,releaseProgram:A,releaseShaderCache:T,programs:u,dispose:F}}function q1e(){let t=new WeakMap;function e(o){return t.has(o)}function n(o){let l=t.get(o);return l===void 0&&(l={},t.set(o,l)),l}function r(o){t.delete(o)}function i(o,l,c){t.get(o)[l]=c}function s(){t=new WeakMap}return{has:e,get:n,remove:r,update:i,dispose:s}}function $1e(t,e){return t.groupOrder!==e.groupOrder?t.groupOrder-e.groupOrder:t.renderOrder!==e.renderOrder?t.renderOrder-e.renderOrder:t.material.id!==e.material.id?t.material.id-e.material.id:t.z!==e.z?t.z-e.z:t.id-e.id}function B6(t,e){return t.groupOrder!==e.groupOrder?t.groupOrder-e.groupOrder:t.renderOrder!==e.renderOrder?t.renderOrder-e.renderOrder:t.z!==e.z?e.z-t.z:t.id-e.id}function H6(){const t=[];let e=0;const n=[],r=[],i=[];function s(){e=0,n.length=0,r.length=0,i.length=0}function o(m,p,f,y,v,b){let g=t[e];return g===void 0?(g={id:m.id,object:m,geometry:p,material:f,groupOrder:y,renderOrder:m.renderOrder,z:v,group:b},t[e]=g):(g.id=m.id,g.object=m,g.geometry=p,g.material=f,g.groupOrder=y,g.renderOrder=m.renderOrder,g.z=v,g.group=b),e++,g}function l(m,p,f,y,v,b){const g=o(m,p,f,y,v,b);f.transmission>0?r.push(g):f.transparent===!0?i.push(g):n.push(g)}function c(m,p,f,y,v,b){const g=o(m,p,f,y,v,b);f.transmission>0?r.unshift(g):f.transparent===!0?i.unshift(g):n.unshift(g)}function d(m,p){n.length>1&&n.sort(m||$1e),r.length>1&&r.sort(p||B6),i.length>1&&i.sort(p||B6)}function u(){for(let m=e,p=t.length;m<p;m++){const f=t[m];if(f.id===null)break;f.id=null,f.object=null,f.geometry=null,f.material=null,f.group=null}}return{opaque:n,transmissive:r,transparent:i,init:s,push:l,unshift:c,finish:u,sort:d}}function V1e(){let t=new WeakMap;function e(r,i){const s=t.get(r);let o;return s===void 0?(o=new H6,t.set(r,[o])):i>=s.length?(o=new H6,s.push(o)):o=s[i],o}function n(){t=new WeakMap}return{get:e,dispose:n}}function G1e(){const t={};return{get:function(e){if(t[e.id]!==void 0)return t[e.id];let n;switch(e.type){case\"DirectionalLight\":n={direction:new Ct,color:new or};break;case\"SpotLight\":n={position:new Ct,direction:new Ct,color:new or,distance:0,coneCos:0,penumbraCos:0,decay:0};break;case\"PointLight\":n={position:new Ct,color:new or,distance:0,decay:0};break;case\"HemisphereLight\":n={direction:new Ct,skyColor:new or,groundColor:new or};break;case\"RectAreaLight\":n={color:new or,position:new Ct,halfWidth:new Ct,halfHeight:new Ct};break}return t[e.id]=n,n}}}function W1e(){const t={};return{get:function(e){if(t[e.id]!==void 0)return t[e.id];let n;switch(e.type){case\"DirectionalLight\":n={shadowIntensity:1,shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new nr};break;case\"SpotLight\":n={shadowIntensity:1,shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new nr};break;case\"PointLight\":n={shadowIntensity:1,shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new nr,shadowCameraNear:1,shadowCameraFar:1e3};break}return t[e.id]=n,n}}}let K1e=0;function X1e(t,e){return(e.castShadow?2:0)-(t.castShadow?2:0)+(e.map?1:0)-(t.map?1:0)}function Y1e(t){const e=new G1e,n=W1e(),r={version:0,hash:{directionalLength:-1,pointLength:-1,spotLength:-1,rectAreaLength:-1,hemiLength:-1,numDirectionalShadows:-1,numPointShadows:-1,numSpotShadows:-1,numSpotMaps:-1,numLightProbes:-1},ambient:[0,0,0],probe:[],directional:[],directionalShadow:[],directionalShadowMap:[],directionalShadowMatrix:[],spot:[],spotLightMap:[],spotShadow:[],spotShadowMap:[],spotLightMatrix:[],rectArea:[],rectAreaLTC1:null,rectAreaLTC2:null,point:[],pointShadow:[],pointShadowMap:[],pointShadowMatrix:[],hemi:[],numSpotLightShadowsWithMaps:0,numLightProbes:0};for(let d=0;d<9;d++)r.probe.push(new Ct);const i=new Ct,s=new _i,o=new _i;function l(d){let u=0,m=0,p=0;for(let k=0;k<9;k++)r.probe[k].set(0,0,0);let f=0,y=0,v=0,b=0,g=0,_=0,C=0,P=0,N=0,A=0,T=0;d.sort(X1e);for(let k=0,D=d.length;k<D;k++){const H=d[k],z=H.color,Q=H.intensity,L=H.distance,te=H.shadow&&H.shadow.map?H.shadow.map.texture:null;if(H.isAmbientLight)u+=z.r*Q,m+=z.g*Q,p+=z.b*Q;else if(H.isLightProbe){for(let ie=0;ie<9;ie++)r.probe[ie].addScaledVector(H.sh.coefficients[ie],Q);T++}else if(H.isDirectionalLight){const ie=e.get(H);if(ie.color.copy(H.color).multiplyScalar(H.intensity),H.castShadow){const J=H.shadow,oe=n.get(H);oe.shadowIntensity=J.intensity,oe.shadowBias=J.bias,oe.shadowNormalBias=J.normalBias,oe.shadowRadius=J.radius,oe.shadowMapSize=J.mapSize,r.directionalShadow[f]=oe,r.directionalShadowMap[f]=te,r.directionalShadowMatrix[f]=H.shadow.matrix,_++}r.directional[f]=ie,f++}else if(H.isSpotLight){const ie=e.get(H);ie.position.setFromMatrixPosition(H.matrixWorld),ie.color.copy(z).multiplyScalar(Q),ie.distance=L,ie.coneCos=Math.cos(H.angle),ie.penumbraCos=Math.cos(H.angle*(1-H.penumbra)),ie.decay=H.decay,r.spot[v]=ie;const J=H.shadow;if(H.map&&(r.spotLightMap[N]=H.map,N++,J.updateMatrices(H),H.castShadow&&A++),r.spotLightMatrix[v]=J.matrix,H.castShadow){const oe=n.get(H);oe.shadowIntensity=J.intensity,oe.shadowBias=J.bias,oe.shadowNormalBias=J.normalBias,oe.shadowRadius=J.radius,oe.shadowMapSize=J.mapSize,r.spotShadow[v]=oe,r.spotShadowMap[v]=te,P++}v++}else if(H.isRectAreaLight){const ie=e.get(H);ie.color.copy(z).multiplyScalar(Q),ie.halfWidth.set(H.width*.5,0,0),ie.halfHeight.set(0,H.height*.5,0),r.rectArea[b]=ie,b++}else if(H.isPointLight){const ie=e.get(H);if(ie.color.copy(H.color).multiplyScalar(H.intensity),ie.distance=H.distance,ie.decay=H.decay,H.castShadow){const J=H.shadow,oe=n.get(H);oe.shadowIntensity=J.intensity,oe.shadowBias=J.bias,oe.shadowNormalBias=J.normalBias,oe.shadowRadius=J.radius,oe.shadowMapSize=J.mapSize,oe.shadowCameraNear=J.camera.near,oe.shadowCameraFar=J.camera.far,r.pointShadow[y]=oe,r.pointShadowMap[y]=te,r.pointShadowMatrix[y]=H.shadow.matrix,C++}r.point[y]=ie,y++}else if(H.isHemisphereLight){const ie=e.get(H);ie.skyColor.copy(H.color).multiplyScalar(Q),ie.groundColor.copy(H.groundColor).multiplyScalar(Q),r.hemi[g]=ie,g++}}b>0&&(t.has(\"OES_texture_float_linear\")===!0?(r.rectAreaLTC1=Qt.LTC_FLOAT_1,r.rectAreaLTC2=Qt.LTC_FLOAT_2):(r.rectAreaLTC1=Qt.LTC_HALF_1,r.rectAreaLTC2=Qt.LTC_HALF_2)),r.ambient[0]=u,r.ambient[1]=m,r.ambient[2]=p;const F=r.hash;(F.directionalLength!==f||F.pointLength!==y||F.spotLength!==v||F.rectAreaLength!==b||F.hemiLength!==g||F.numDirectionalShadows!==_||F.numPointShadows!==C||F.numSpotShadows!==P||F.numSpotMaps!==N||F.numLightProbes!==T)&&(r.directional.length=f,r.spot.length=v,r.rectArea.length=b,r.point.length=y,r.hemi.length=g,r.directionalShadow.length=_,r.directionalShadowMap.length=_,r.pointShadow.length=C,r.pointShadowMap.length=C,r.spotShadow.length=P,r.spotShadowMap.length=P,r.directionalShadowMatrix.length=_,r.pointShadowMatrix.length=C,r.spotLightMatrix.length=P+N-A,r.spotLightMap.length=N,r.numSpotLightShadowsWithMaps=A,r.numLightProbes=T,F.directionalLength=f,F.pointLength=y,F.spotLength=v,F.rectAreaLength=b,F.hemiLength=g,F.numDirectionalShadows=_,F.numPointShadows=C,F.numSpotShadows=P,F.numSpotMaps=N,F.numLightProbes=T,r.version=K1e++)}function c(d,u){let m=0,p=0,f=0,y=0,v=0;const b=u.matrixWorldInverse;for(let g=0,_=d.length;g<_;g++){const C=d[g];if(C.isDirectionalLight){const P=r.directional[m];P.direction.setFromMatrixPosition(C.matrixWorld),i.setFromMatrixPosition(C.target.matrixWorld),P.direction.sub(i),P.direction.transformDirection(b),m++}else if(C.isSpotLight){const P=r.spot[f];P.position.setFromMatrixPosition(C.matrixWorld),P.position.applyMatrix4(b),P.direction.setFromMatrixPosition(C.matrixWorld),i.setFromMatrixPosition(C.target.matrixWorld),P.direction.sub(i),P.direction.transformDirection(b),f++}else if(C.isRectAreaLight){const P=r.rectArea[y];P.position.setFromMatrixPosition(C.matrixWorld),P.position.applyMatrix4(b),o.identity(),s.copy(C.matrixWorld),s.premultiply(b),o.extractRotation(s),P.halfWidth.set(C.width*.5,0,0),P.halfHeight.set(0,C.height*.5,0),P.halfWidth.applyMatrix4(o),P.halfHeight.applyMatrix4(o),y++}else if(C.isPointLight){const P=r.point[p];P.position.setFromMatrixPosition(C.matrixWorld),P.position.applyMatrix4(b),p++}else if(C.isHemisphereLight){const P=r.hemi[v];P.direction.setFromMatrixPosition(C.matrixWorld),P.direction.transformDirection(b),v++}}}return{setup:l,setupView:c,state:r}}function q6(t){const e=new Y1e(t),n=[],r=[];function i(u){d.camera=u,n.length=0,r.length=0}function s(u){n.push(u)}function o(u){r.push(u)}function l(){e.setup(n)}function c(u){e.setupView(n,u)}const d={lightsArray:n,shadowsArray:r,camera:null,lights:e,transmissionRenderTarget:{}};return{init:i,state:d,setupLights:l,setupLightsView:c,pushLight:s,pushShadow:o}}function Q1e(t){let e=new WeakMap;function n(i,s=0){const o=e.get(i);let l;return o===void 0?(l=new q6(t),e.set(i,[l])):s>=o.length?(l=new q6(t),o.push(l)):l=o[s],l}function r(){e=new WeakMap}return{get:n,dispose:r}}const Z1e=`void main() {\n\tgl_Position = vec4( position, 1.0 );\n}`,J1e=`uniform sampler2D shadow_pass;\nuniform vec2 resolution;\nuniform float radius;\n#include <packing>\nvoid main() {\n\tconst float samples = float( VSM_SAMPLES );\n\tfloat mean = 0.0;\n\tfloat squared_mean = 0.0;\n\tfloat uvStride = samples <= 1.0 ? 0.0 : 2.0 / ( samples - 1.0 );\n\tfloat uvStart = samples <= 1.0 ? 0.0 : - 1.0;\n\tfor ( float i = 0.0; i < samples; i ++ ) {\n\t\tfloat uvOffset = uvStart + i * uvStride;\n\t\t#ifdef HORIZONTAL_PASS\n\t\t\tvec2 distribution = unpackRGBATo2Half( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( uvOffset, 0.0 ) * radius ) / resolution ) );\n\t\t\tmean += distribution.x;\n\t\t\tsquared_mean += distribution.y * distribution.y + distribution.x * distribution.x;\n\t\t#else\n\t\t\tfloat depth = unpackRGBAToDepth( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( 0.0, uvOffset ) * radius ) / resolution ) );\n\t\t\tmean += depth;\n\t\t\tsquared_mean += depth * depth;\n\t\t#endif\n\t}\n\tmean = mean / samples;\n\tsquared_mean = squared_mean / samples;\n\tfloat std_dev = sqrt( squared_mean - mean * mean );\n\tgl_FragColor = pack2HalfToRGBA( vec2( mean, std_dev ) );\n}`;function eke(t,e,n){let r=new rI;const i=new nr,s=new nr,o=new yi,l=new g0e({depthPacking:Cve}),c=new b0e,d={},u=n.maxTextureSize,m={[bp]:zo,[zo]:bp,[Pd]:Pd},p=new wm({defines:{VSM_SAMPLES:8},uniforms:{shadow_pass:{value:null},resolution:{value:new nr},radius:{value:4}},vertexShader:Z1e,fragmentShader:J1e}),f=p.clone();f.defines.HORIZONTAL_PASS=1;const y=new uc;y.setAttribute(\"position\",new oo(new Float32Array([-1,-1,.5,3,-1,.5,-1,3,.5]),3));const v=new mc(y,p),b=this;this.enabled=!1,this.autoUpdate=!0,this.needsUpdate=!1,this.type=lZ;let g=this.type;this.render=function(A,T,F){if(b.enabled===!1||b.autoUpdate===!1&&b.needsUpdate===!1||A.length===0)return;const k=t.getRenderTarget(),D=t.getActiveCubeFace(),H=t.getActiveMipmapLevel(),z=t.state;z.setBlending(lm),z.buffers.depth.getReversed()===!0?z.buffers.color.setClear(0,0,0,0):z.buffers.color.setClear(1,1,1,1),z.buffers.depth.setTest(!0),z.setScissorTest(!1);const Q=g!==zu&&this.type===zu,L=g===zu&&this.type!==zu;for(let te=0,ie=A.length;te<ie;te++){const J=A[te],oe=J.shadow;if(oe===void 0){qn(\"WebGLShadowMap:\",J,\"has no shadow.\");continue}if(oe.autoUpdate===!1&&oe.needsUpdate===!1)continue;i.copy(oe.mapSize);const fe=oe.getFrameExtents();if(i.multiply(fe),s.copy(oe.mapSize),(i.x>u||i.y>u)&&(i.x>u&&(s.x=Math.floor(u/fe.x),i.x=s.x*fe.x,oe.mapSize.x=s.x),i.y>u&&(s.y=Math.floor(u/fe.y),i.y=s.y*fe.y,oe.mapSize.y=s.y)),oe.map===null||Q===!0||L===!0){const W=this.type!==zu?{minFilter:hl,magFilter:hl}:{};oe.map!==null&&oe.map.dispose(),oe.map=new bg(i.x,i.y,W),oe.map.texture.name=J.name+\".shadowMap\",oe.camera.updateProjectionMatrix()}t.setRenderTarget(oe.map),t.clear();const re=oe.getViewportCount();for(let W=0;W<re;W++){const ne=oe.getViewport(W);o.set(s.x*ne.x,s.y*ne.y,s.x*ne.z,s.y*ne.w),z.viewport(o),oe.updateMatrices(J,W),r=oe.getFrustum(),P(T,F,oe.camera,J,this.type)}oe.isPointLightShadow!==!0&&this.type===zu&&_(oe,F),oe.needsUpdate=!1}g=this.type,b.needsUpdate=!1,t.setRenderTarget(k,D,H)};function _(A,T){const F=e.update(v);p.defines.VSM_SAMPLES!==A.blurSamples&&(p.defines.VSM_SAMPLES=A.blurSamples,f.defines.VSM_SAMPLES=A.blurSamples,p.needsUpdate=!0,f.needsUpdate=!0),A.mapPass===null&&(A.mapPass=new bg(i.x,i.y)),p.uniforms.shadow_pass.value=A.map.texture,p.uniforms.resolution.value=A.mapSize,p.uniforms.radius.value=A.radius,t.setRenderTarget(A.mapPass),t.clear(),t.renderBufferDirect(T,null,F,p,v,null),f.uniforms.shadow_pass.value=A.mapPass.texture,f.uniforms.resolution.value=A.mapSize,f.uniforms.radius.value=A.radius,t.setRenderTarget(A.map),t.clear(),t.renderBufferDirect(T,null,F,f,v,null)}function C(A,T,F,k){let D=null;const H=F.isPointLight===!0?A.customDistanceMaterial:A.customDepthMaterial;if(H!==void 0)D=H;else if(D=F.isPointLight===!0?c:l,t.localClippingEnabled&&T.clipShadows===!0&&Array.isArray(T.clippingPlanes)&&T.clippingPlanes.length!==0||T.displacementMap&&T.displacementScale!==0||T.alphaMap&&T.alphaTest>0||T.map&&T.alphaTest>0||T.alphaToCoverage===!0){const z=D.uuid,Q=T.uuid;let L=d[z];L===void 0&&(L={},d[z]=L);let te=L[Q];te===void 0&&(te=D.clone(),L[Q]=te,T.addEventListener(\"dispose\",N)),D=te}if(D.visible=T.visible,D.wireframe=T.wireframe,k===zu?D.side=T.shadowSide!==null?T.shadowSide:T.side:D.side=T.shadowSide!==null?T.shadowSide:m[T.side],D.alphaMap=T.alphaMap,D.alphaTest=T.alphaToCoverage===!0?.5:T.alphaTest,D.map=T.map,D.clipShadows=T.clipShadows,D.clippingPlanes=T.clippingPlanes,D.clipIntersection=T.clipIntersection,D.displacementMap=T.displacementMap,D.displacementScale=T.displacementScale,D.displacementBias=T.displacementBias,D.wireframeLinewidth=T.wireframeLinewidth,D.linewidth=T.linewidth,F.isPointLight===!0&&D.isMeshDistanceMaterial===!0){const z=t.properties.get(D);z.light=F}return D}function P(A,T,F,k,D){if(A.visible===!1)return;if(A.layers.test(T.layers)&&(A.isMesh||A.isLine||A.isPoints)&&(A.castShadow||A.receiveShadow&&D===zu)&&(!A.frustumCulled||r.intersectsObject(A))){A.modelViewMatrix.multiplyMatrices(F.matrixWorldInverse,A.matrixWorld);const Q=e.update(A),L=A.material;if(Array.isArray(L)){const te=Q.groups;for(let ie=0,J=te.length;ie<J;ie++){const oe=te[ie],fe=L[oe.materialIndex];if(fe&&fe.visible){const re=C(A,fe,k,D);A.onBeforeShadow(t,A,T,F,Q,re,oe),t.renderBufferDirect(F,null,Q,re,A,oe),A.onAfterShadow(t,A,T,F,Q,re,oe)}}}else if(L.visible){const te=C(A,L,k,D);A.onBeforeShadow(t,A,T,F,Q,te,null),t.renderBufferDirect(F,null,Q,te,A,null),A.onAfterShadow(t,A,T,F,Q,te,null)}}const z=A.children;for(let Q=0,L=z.length;Q<L;Q++)P(z[Q],T,F,k,D)}function N(A){A.target.removeEventListener(\"dispose\",N);for(const F in d){const k=d[F],D=A.target.uuid;D in k&&(k[D].dispose(),delete k[D])}}}const tke={[xF]:yF,[vF]:_F,[wF]:kF,[Vx]:SF,[yF]:xF,[_F]:vF,[kF]:wF,[SF]:Vx};function nke(t,e){function n(){let pe=!1;const Fe=new yi;let we=null;const Ve=new yi(0,0,0,0);return{setMask:function(Ae){we!==Ae&&!pe&&(t.colorMask(Ae,Ae,Ae,Ae),we=Ae)},setLocked:function(Ae){pe=Ae},setClear:function(Ae,ce,xe,Be,Qe){Qe===!0&&(Ae*=Be,ce*=Be,xe*=Be),Fe.set(Ae,ce,xe,Be),Ve.equals(Fe)===!1&&(t.clearColor(Ae,ce,xe,Be),Ve.copy(Fe))},reset:function(){pe=!1,we=null,Ve.set(-1,0,0,0)}}}function r(){let pe=!1,Fe=!1,we=null,Ve=null,Ae=null;return{setReversed:function(ce){if(Fe!==ce){const xe=e.get(\"EXT_clip_control\");ce?xe.clipControlEXT(xe.LOWER_LEFT_EXT,xe.ZERO_TO_ONE_EXT):xe.clipControlEXT(xe.LOWER_LEFT_EXT,xe.NEGATIVE_ONE_TO_ONE_EXT),Fe=ce;const Be=Ae;Ae=null,this.setClear(Be)}},getReversed:function(){return Fe},setTest:function(ce){ce?Y(t.DEPTH_TEST):E(t.DEPTH_TEST)},setMask:function(ce){we!==ce&&!pe&&(t.depthMask(ce),we=ce)},setFunc:function(ce){if(Fe&&(ce=tke[ce]),Ve!==ce){switch(ce){case xF:t.depthFunc(t.NEVER);break;case yF:t.depthFunc(t.ALWAYS);break;case vF:t.depthFunc(t.LESS);break;case Vx:t.depthFunc(t.LEQUAL);break;case wF:t.depthFunc(t.EQUAL);break;case SF:t.depthFunc(t.GEQUAL);break;case _F:t.depthFunc(t.GREATER);break;case kF:t.depthFunc(t.NOTEQUAL);break;default:t.depthFunc(t.LEQUAL)}Ve=ce}},setLocked:function(ce){pe=ce},setClear:function(ce){Ae!==ce&&(Fe&&(ce=1-ce),t.clearDepth(ce),Ae=ce)},reset:function(){pe=!1,we=null,Ve=null,Ae=null,Fe=!1}}}function i(){let pe=!1,Fe=null,we=null,Ve=null,Ae=null,ce=null,xe=null,Be=null,Qe=null;return{setTest:function(ht){pe||(ht?Y(t.STENCIL_TEST):E(t.STENCIL_TEST))},setMask:function(ht){Fe!==ht&&!pe&&(t.stencilMask(ht),Fe=ht)},setFunc:function(ht,xt,gt){(we!==ht||Ve!==xt||Ae!==gt)&&(t.stencilFunc(ht,xt,gt),we=ht,Ve=xt,Ae=gt)},setOp:function(ht,xt,gt){(ce!==ht||xe!==xt||Be!==gt)&&(t.stencilOp(ht,xt,gt),ce=ht,xe=xt,Be=gt)},setLocked:function(ht){pe=ht},setClear:function(ht){Qe!==ht&&(t.clearStencil(ht),Qe=ht)},reset:function(){pe=!1,Fe=null,we=null,Ve=null,Ae=null,ce=null,xe=null,Be=null,Qe=null}}}const s=new n,o=new r,l=new i,c=new WeakMap,d=new WeakMap;let u={},m={},p=new WeakMap,f=[],y=null,v=!1,b=null,g=null,_=null,C=null,P=null,N=null,A=null,T=new or(0,0,0),F=0,k=!1,D=null,H=null,z=null,Q=null,L=null;const te=t.getParameter(t.MAX_COMBINED_TEXTURE_IMAGE_UNITS);let ie=!1,J=0;const oe=t.getParameter(t.VERSION);oe.indexOf(\"WebGL\")!==-1?(J=parseFloat(/^WebGL (\\d)/.exec(oe)[1]),ie=J>=1):oe.indexOf(\"OpenGL ES\")!==-1&&(J=parseFloat(/^OpenGL ES (\\d)/.exec(oe)[1]),ie=J>=2);let fe=null,re={};const W=t.getParameter(t.SCISSOR_BOX),ne=t.getParameter(t.VIEWPORT),me=new yi().fromArray(W),be=new yi().fromArray(ne);function Ce(pe,Fe,we,Ve){const Ae=new Uint8Array(4),ce=t.createTexture();t.bindTexture(pe,ce),t.texParameteri(pe,t.TEXTURE_MIN_FILTER,t.NEAREST),t.texParameteri(pe,t.TEXTURE_MAG_FILTER,t.NEAREST);for(let xe=0;xe<we;xe++)pe===t.TEXTURE_3D||pe===t.TEXTURE_2D_ARRAY?t.texImage3D(Fe,0,t.RGBA,1,1,Ve,0,t.RGBA,t.UNSIGNED_BYTE,Ae):t.texImage2D(Fe+xe,0,t.RGBA,1,1,0,t.RGBA,t.UNSIGNED_BYTE,Ae);return ce}const q={};q[t.TEXTURE_2D]=Ce(t.TEXTURE_2D,t.TEXTURE_2D,1),q[t.TEXTURE_CUBE_MAP]=Ce(t.TEXTURE_CUBE_MAP,t.TEXTURE_CUBE_MAP_POSITIVE_X,6),q[t.TEXTURE_2D_ARRAY]=Ce(t.TEXTURE_2D_ARRAY,t.TEXTURE_2D_ARRAY,1,1),q[t.TEXTURE_3D]=Ce(t.TEXTURE_3D,t.TEXTURE_3D,1,1),s.setClear(0,0,0,1),o.setClear(1),l.setClear(0),Y(t.DEPTH_TEST),o.setFunc(Vx),X(!1),V(UH),Y(t.CULL_FACE),I(lm);function Y(pe){u[pe]!==!0&&(t.enable(pe),u[pe]=!0)}function E(pe){u[pe]!==!1&&(t.disable(pe),u[pe]=!1)}function j(pe,Fe){return m[pe]!==Fe?(t.bindFramebuffer(pe,Fe),m[pe]=Fe,pe===t.DRAW_FRAMEBUFFER&&(m[t.FRAMEBUFFER]=Fe),pe===t.FRAMEBUFFER&&(m[t.DRAW_FRAMEBUFFER]=Fe),!0):!1}function O(pe,Fe){let we=f,Ve=!1;if(pe){we=p.get(Fe),we===void 0&&(we=[],p.set(Fe,we));const Ae=pe.textures;if(we.length!==Ae.length||we[0]!==t.COLOR_ATTACHMENT0){for(let ce=0,xe=Ae.length;ce<xe;ce++)we[ce]=t.COLOR_ATTACHMENT0+ce;we.length=Ae.length,Ve=!0}}else we[0]!==t.BACK&&(we[0]=t.BACK,Ve=!0);Ve&&t.drawBuffers(we)}function K(pe){return y!==pe?(t.useProgram(pe),y=pe,!0):!1}const U={[Tf]:t.FUNC_ADD,[Zye]:t.FUNC_SUBTRACT,[Jye]:t.FUNC_REVERSE_SUBTRACT};U[eve]=t.MIN,U[tve]=t.MAX;const de={[nve]:t.ZERO,[rve]:t.ONE,[ave]:t.SRC_COLOR,[gF]:t.SRC_ALPHA,[dve]:t.SRC_ALPHA_SATURATE,[lve]:t.DST_COLOR,[sve]:t.DST_ALPHA,[ive]:t.ONE_MINUS_SRC_COLOR,[bF]:t.ONE_MINUS_SRC_ALPHA,[cve]:t.ONE_MINUS_DST_COLOR,[ove]:t.ONE_MINUS_DST_ALPHA,[uve]:t.CONSTANT_COLOR,[mve]:t.ONE_MINUS_CONSTANT_COLOR,[hve]:t.CONSTANT_ALPHA,[pve]:t.ONE_MINUS_CONSTANT_ALPHA};function I(pe,Fe,we,Ve,Ae,ce,xe,Be,Qe,ht){if(pe===lm){v===!0&&(E(t.BLEND),v=!1);return}if(v===!1&&(Y(t.BLEND),v=!0),pe!==Qye){if(pe!==b||ht!==k){if((g!==Tf||P!==Tf)&&(t.blendEquation(t.FUNC_ADD),g=Tf,P=Tf),ht)switch(pe){case Ax:t.blendFuncSeparate(t.ONE,t.ONE_MINUS_SRC_ALPHA,t.ONE,t.ONE_MINUS_SRC_ALPHA);break;case BH:t.blendFunc(t.ONE,t.ONE);break;case HH:t.blendFuncSeparate(t.ZERO,t.ONE_MINUS_SRC_COLOR,t.ZERO,t.ONE);break;case qH:t.blendFuncSeparate(t.DST_COLOR,t.ONE_MINUS_SRC_ALPHA,t.ZERO,t.ONE);break;default:oi(\"WebGLState: Invalid blending: \",pe);break}else switch(pe){case Ax:t.blendFuncSeparate(t.SRC_ALPHA,t.ONE_MINUS_SRC_ALPHA,t.ONE,t.ONE_MINUS_SRC_ALPHA);break;case BH:t.blendFuncSeparate(t.SRC_ALPHA,t.ONE,t.ONE,t.ONE);break;case HH:oi(\"WebGLState: SubtractiveBlending requires material.premultipliedAlpha = true\");break;case qH:oi(\"WebGLState: MultiplyBlending requires material.premultipliedAlpha = true\");break;default:oi(\"WebGLState: Invalid blending: \",pe);break}_=null,C=null,N=null,A=null,T.set(0,0,0),F=0,b=pe,k=ht}return}Ae=Ae||Fe,ce=ce||we,xe=xe||Ve,(Fe!==g||Ae!==P)&&(t.blendEquationSeparate(U[Fe],U[Ae]),g=Fe,P=Ae),(we!==_||Ve!==C||ce!==N||xe!==A)&&(t.blendFuncSeparate(de[we],de[Ve],de[ce],de[xe]),_=we,C=Ve,N=ce,A=xe),(Be.equals(T)===!1||Qe!==F)&&(t.blendColor(Be.r,Be.g,Be.b,Qe),T.copy(Be),F=Qe),b=pe,k=!1}function G(pe,Fe){pe.side===Pd?E(t.CULL_FACE):Y(t.CULL_FACE);let we=pe.side===zo;Fe&&(we=!we),X(we),pe.blending===Ax&&pe.transparent===!1?I(lm):I(pe.blending,pe.blendEquation,pe.blendSrc,pe.blendDst,pe.blendEquationAlpha,pe.blendSrcAlpha,pe.blendDstAlpha,pe.blendColor,pe.blendAlpha,pe.premultipliedAlpha),o.setFunc(pe.depthFunc),o.setTest(pe.depthTest),o.setMask(pe.depthWrite),s.setMask(pe.colorWrite);const Ve=pe.stencilWrite;l.setTest(Ve),Ve&&(l.setMask(pe.stencilWriteMask),l.setFunc(pe.stencilFunc,pe.stencilRef,pe.stencilFuncMask),l.setOp(pe.stencilFail,pe.stencilZFail,pe.stencilZPass)),se(pe.polygonOffset,pe.polygonOffsetFactor,pe.polygonOffsetUnits),pe.alphaToCoverage===!0?Y(t.SAMPLE_ALPHA_TO_COVERAGE):E(t.SAMPLE_ALPHA_TO_COVERAGE)}function X(pe){D!==pe&&(pe?t.frontFace(t.CW):t.frontFace(t.CCW),D=pe)}function V(pe){pe!==Kye?(Y(t.CULL_FACE),pe!==H&&(pe===UH?t.cullFace(t.BACK):pe===Xye?t.cullFace(t.FRONT):t.cullFace(t.FRONT_AND_BACK))):E(t.CULL_FACE),H=pe}function ee(pe){pe!==z&&(ie&&t.lineWidth(pe),z=pe)}function se(pe,Fe,we){pe?(Y(t.POLYGON_OFFSET_FILL),(Q!==Fe||L!==we)&&(t.polygonOffset(Fe,we),Q=Fe,L=we)):E(t.POLYGON_OFFSET_FILL)}function ge(pe){pe?Y(t.SCISSOR_TEST):E(t.SCISSOR_TEST)}function he(pe){pe===void 0&&(pe=t.TEXTURE0+te-1),fe!==pe&&(t.activeTexture(pe),fe=pe)}function le(pe,Fe,we){we===void 0&&(fe===null?we=t.TEXTURE0+te-1:we=fe);let Ve=re[we];Ve===void 0&&(Ve={type:void 0,texture:void 0},re[we]=Ve),(Ve.type!==pe||Ve.texture!==Fe)&&(fe!==we&&(t.activeTexture(we),fe=we),t.bindTexture(pe,Fe||q[pe]),Ve.type=pe,Ve.texture=Fe)}function B(){const pe=re[fe];pe!==void 0&&pe.type!==void 0&&(t.bindTexture(pe.type,null),pe.type=void 0,pe.texture=void 0)}function R(){try{t.compressedTexImage2D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function ae(){try{t.compressedTexImage3D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function _e(){try{t.texSubImage2D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function Se(){try{t.texSubImage3D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function ve(){try{t.compressedTexSubImage2D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function Te(){try{t.compressedTexSubImage3D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function ye(){try{t.texStorage2D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function je(){try{t.texStorage3D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function Le(){try{t.texImage2D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function Me(){try{t.texImage3D(...arguments)}catch(pe){pe(\"WebGLState:\",pe)}}function Oe(pe){me.equals(pe)===!1&&(t.scissor(pe.x,pe.y,pe.z,pe.w),me.copy(pe))}function Re(pe){be.equals(pe)===!1&&(t.viewport(pe.x,pe.y,pe.z,pe.w),be.copy(pe))}function $e(pe,Fe){let we=d.get(Fe);we===void 0&&(we=new WeakMap,d.set(Fe,we));let Ve=we.get(pe);Ve===void 0&&(Ve=t.getUniformBlockIndex(Fe,pe.name),we.set(pe,Ve))}function Ye(pe,Fe){const Ve=d.get(Fe).get(pe);c.get(Fe)!==Ve&&(t.uniformBlockBinding(Fe,Ve,pe.__bindingPointIndex),c.set(Fe,Ve))}function tt(){t.disable(t.BLEND),t.disable(t.CULL_FACE),t.disable(t.DEPTH_TEST),t.disable(t.POLYGON_OFFSET_FILL),t.disable(t.SCISSOR_TEST),t.disable(t.STENCIL_TEST),t.disable(t.SAMPLE_ALPHA_TO_COVERAGE),t.blendEquation(t.FUNC_ADD),t.blendFunc(t.ONE,t.ZERO),t.blendFuncSeparate(t.ONE,t.ZERO,t.ONE,t.ZERO),t.blendColor(0,0,0,0),t.colorMask(!0,!0,!0,!0),t.clearColor(0,0,0,0),t.depthMask(!0),t.depthFunc(t.LESS),o.setReversed(!1),t.clearDepth(1),t.stencilMask(4294967295),t.stencilFunc(t.ALWAYS,0,4294967295),t.stencilOp(t.KEEP,t.KEEP,t.KEEP),t.clearStencil(0),t.cullFace(t.BACK),t.frontFace(t.CCW),t.polygonOffset(0,0),t.activeTexture(t.TEXTURE0),t.bindFramebuffer(t.FRAMEBUFFER,null),t.bindFramebuffer(t.DRAW_FRAMEBUFFER,null),t.bindFramebuffer(t.READ_FRAMEBUFFER,null),t.useProgram(null),t.lineWidth(1),t.scissor(0,0,t.canvas.width,t.canvas.height),t.viewport(0,0,t.canvas.width,t.canvas.height),u={},fe=null,re={},m={},p=new WeakMap,f=[],y=null,v=!1,b=null,g=null,_=null,C=null,P=null,N=null,A=null,T=new or(0,0,0),F=0,k=!1,D=null,H=null,z=null,Q=null,L=null,me.set(0,0,t.canvas.width,t.canvas.height),be.set(0,0,t.canvas.width,t.canvas.height),s.reset(),o.reset(),l.reset()}return{buffers:{color:s,depth:o,stencil:l},enable:Y,disable:E,bindFramebuffer:j,drawBuffers:O,useProgram:K,setBlending:I,setMaterial:G,setFlipSided:X,setCullFace:V,setLineWidth:ee,setPolygonOffset:se,setScissorTest:ge,activeTexture:he,bindTexture:le,unbindTexture:B,compressedTexImage2D:R,compressedTexImage3D:ae,texImage2D:Le,texImage3D:Me,updateUBOMapping:$e,uniformBlockBinding:Ye,texStorage2D:ye,texStorage3D:je,texSubImage2D:_e,texSubImage3D:Se,compressedTexSubImage2D:ve,compressedTexSubImage3D:Te,scissor:Oe,viewport:Re,reset:tt}}function rke(t,e,n,r,i,s,o){const l=e.has(\"WEBGL_multisampled_render_to_texture\")?e.get(\"WEBGL_multisampled_render_to_texture\"):null,c=typeof navigator>\"u\"?!1:/OculusBrowser/g.test(navigator.userAgent),d=new nr,u=new WeakMap;let m;const p=new WeakMap;let f=!1;try{f=typeof OffscreenCanvas<\"u\"&&new OffscreenCanvas(1,1).getContext(\"2d\")!==null}catch{}function y(B,R){return f?new OffscreenCanvas(B,R):jN(\"canvas\")}function v(B,R,ae){let _e=1;const Se=le(B);if((Se.width>ae||Se.height>ae)&&(_e=ae/Math.max(Se.width,Se.height)),_e<1)if(typeof HTMLImageElement<\"u\"&&B instanceof HTMLImageElement||typeof HTMLCanvasElement<\"u\"&&B instanceof HTMLCanvasElement||typeof ImageBitmap<\"u\"&&B instanceof ImageBitmap||typeof VideoFrame<\"u\"&&B instanceof VideoFrame){const ve=Math.floor(_e*Se.width),Te=Math.floor(_e*Se.height);m===void 0&&(m=y(ve,Te));const ye=R?y(ve,Te):m;return ye.width=ve,ye.height=Te,ye.getContext(\"2d\").drawImage(B,0,0,ve,Te),qn(\"WebGLRenderer: Texture has been resized from (\"+Se.width+\"x\"+Se.height+\") to (\"+ve+\"x\"+Te+\").\"),ye}else return\"data\"in B&&qn(\"WebGLRenderer: Image in DataTexture is too big (\"+Se.width+\"x\"+Se.height+\").\"),B;return B}function b(B){return B.generateMipmaps}function g(B){t.generateMipmap(B)}function _(B){return B.isWebGLCubeRenderTarget?t.TEXTURE_CUBE_MAP:B.isWebGL3DRenderTarget?t.TEXTURE_3D:B.isWebGLArrayRenderTarget||B.isCompressedArrayTexture?t.TEXTURE_2D_ARRAY:t.TEXTURE_2D}function C(B,R,ae,_e,Se=!1){if(B!==null){if(t[B]!==void 0)return t[B];qn(\"WebGLRenderer: Attempt to use non-existing WebGL internal format '\"+B+\"'\")}let ve=R;if(R===t.RED&&(ae===t.FLOAT&&(ve=t.R32F),ae===t.HALF_FLOAT&&(ve=t.R16F),ae===t.UNSIGNED_BYTE&&(ve=t.R8)),R===t.RED_INTEGER&&(ae===t.UNSIGNED_BYTE&&(ve=t.R8UI),ae===t.UNSIGNED_SHORT&&(ve=t.R16UI),ae===t.UNSIGNED_INT&&(ve=t.R32UI),ae===t.BYTE&&(ve=t.R8I),ae===t.SHORT&&(ve=t.R16I),ae===t.INT&&(ve=t.R32I)),R===t.RG&&(ae===t.FLOAT&&(ve=t.RG32F),ae===t.HALF_FLOAT&&(ve=t.RG16F),ae===t.UNSIGNED_BYTE&&(ve=t.RG8)),R===t.RG_INTEGER&&(ae===t.UNSIGNED_BYTE&&(ve=t.RG8UI),ae===t.UNSIGNED_SHORT&&(ve=t.RG16UI),ae===t.UNSIGNED_INT&&(ve=t.RG32UI),ae===t.BYTE&&(ve=t.RG8I),ae===t.SHORT&&(ve=t.RG16I),ae===t.INT&&(ve=t.RG32I)),R===t.RGB_INTEGER&&(ae===t.UNSIGNED_BYTE&&(ve=t.RGB8UI),ae===t.UNSIGNED_SHORT&&(ve=t.RGB16UI),ae===t.UNSIGNED_INT&&(ve=t.RGB32UI),ae===t.BYTE&&(ve=t.RGB8I),ae===t.SHORT&&(ve=t.RGB16I),ae===t.INT&&(ve=t.RGB32I)),R===t.RGBA_INTEGER&&(ae===t.UNSIGNED_BYTE&&(ve=t.RGBA8UI),ae===t.UNSIGNED_SHORT&&(ve=t.RGBA16UI),ae===t.UNSIGNED_INT&&(ve=t.RGBA32UI),ae===t.BYTE&&(ve=t.RGBA8I),ae===t.SHORT&&(ve=t.RGBA16I),ae===t.INT&&(ve=t.RGBA32I)),R===t.RGB&&(ae===t.UNSIGNED_INT_5_9_9_9_REV&&(ve=t.RGB9_E5),ae===t.UNSIGNED_INT_10F_11F_11F_REV&&(ve=t.R11F_G11F_B10F)),R===t.RGBA){const Te=Se?TN:Mr.getTransfer(_e);ae===t.FLOAT&&(ve=t.RGBA32F),ae===t.HALF_FLOAT&&(ve=t.RGBA16F),ae===t.UNSIGNED_BYTE&&(ve=Te===Qr?t.SRGB8_ALPHA8:t.RGBA8),ae===t.UNSIGNED_SHORT_4_4_4_4&&(ve=t.RGBA4),ae===t.UNSIGNED_SHORT_5_5_5_1&&(ve=t.RGB5_A1)}return(ve===t.R16F||ve===t.R32F||ve===t.RG16F||ve===t.RG32F||ve===t.RGBA16F||ve===t.RGBA32F)&&e.get(\"EXT_color_buffer_float\"),ve}function P(B,R){let ae;return B?R===null||R===fg||R===cw?ae=t.DEPTH24_STENCIL8:R===em?ae=t.DEPTH32F_STENCIL8:R===lw&&(ae=t.DEPTH24_STENCIL8,qn(\"DepthTexture: 16 bit depth attachment is not supported with stencil. Using 24-bit attachment.\")):R===null||R===fg||R===cw?ae=t.DEPTH_COMPONENT24:R===em?ae=t.DEPTH_COMPONENT32F:R===lw&&(ae=t.DEPTH_COMPONENT16),ae}function N(B,R){return b(B)===!0||B.isFramebufferTexture&&B.minFilter!==hl&&B.minFilter!==ec?Math.log2(Math.max(R.width,R.height))+1:B.mipmaps!==void 0&&B.mipmaps.length>0?B.mipmaps.length:B.isCompressedTexture&&Array.isArray(B.image)?R.mipmaps.length:1}function A(B){const R=B.target;R.removeEventListener(\"dispose\",A),F(R),R.isVideoTexture&&u.delete(R)}function T(B){const R=B.target;R.removeEventListener(\"dispose\",T),D(R)}function F(B){const R=r.get(B);if(R.__webglInit===void 0)return;const ae=B.source,_e=p.get(ae);if(_e){const Se=_e[R.__cacheKey];Se.usedTimes--,Se.usedTimes===0&&k(B),Object.keys(_e).length===0&&p.delete(ae)}r.remove(B)}function k(B){const R=r.get(B);t.deleteTexture(R.__webglTexture);const ae=B.source,_e=p.get(ae);delete _e[R.__cacheKey],o.memory.textures--}function D(B){const R=r.get(B);if(B.depthTexture&&(B.depthTexture.dispose(),r.remove(B.depthTexture)),B.isWebGLCubeRenderTarget)for(let _e=0;_e<6;_e++){if(Array.isArray(R.__webglFramebuffer[_e]))for(let Se=0;Se<R.__webglFramebuffer[_e].length;Se++)t.deleteFramebuffer(R.__webglFramebuffer[_e][Se]);else t.deleteFramebuffer(R.__webglFramebuffer[_e]);R.__webglDepthbuffer&&t.deleteRenderbuffer(R.__webglDepthbuffer[_e])}else{if(Array.isArray(R.__webglFramebuffer))for(let _e=0;_e<R.__webglFramebuffer.length;_e++)t.deleteFramebuffer(R.__webglFramebuffer[_e]);else t.deleteFramebuffer(R.__webglFramebuffer);if(R.__webglDepthbuffer&&t.deleteRenderbuffer(R.__webglDepthbuffer),R.__webglMultisampledFramebuffer&&t.deleteFramebuffer(R.__webglMultisampledFramebuffer),R.__webglColorRenderbuffer)for(let _e=0;_e<R.__webglColorRenderbuffer.length;_e++)R.__webglColorRenderbuffer[_e]&&t.deleteRenderbuffer(R.__webglColorRenderbuffer[_e]);R.__webglDepthRenderbuffer&&t.deleteRenderbuffer(R.__webglDepthRenderbuffer)}const ae=B.textures;for(let _e=0,Se=ae.length;_e<Se;_e++){const ve=r.get(ae[_e]);ve.__webglTexture&&(t.deleteTexture(ve.__webglTexture),o.memory.textures--),r.remove(ae[_e])}r.remove(B)}let H=0;function z(){H=0}function Q(){const B=H;return B>=i.maxTextures&&qn(\"WebGLTextures: Trying to use \"+B+\" texture units while this GPU supports only \"+i.maxTextures),H+=1,B}function L(B){const R=[];return R.push(B.wrapS),R.push(B.wrapT),R.push(B.wrapR||0),R.push(B.magFilter),R.push(B.minFilter),R.push(B.anisotropy),R.push(B.internalFormat),R.push(B.format),R.push(B.type),R.push(B.generateMipmaps),R.push(B.premultiplyAlpha),R.push(B.flipY),R.push(B.unpackAlignment),R.push(B.colorSpace),R.join()}function te(B,R){const ae=r.get(B);if(B.isVideoTexture&&ge(B),B.isRenderTargetTexture===!1&&B.isExternalTexture!==!0&&B.version>0&&ae.__version!==B.version){const _e=B.image;if(_e===null)qn(\"WebGLRenderer: Texture marked for update but no image data found.\");else if(_e.complete===!1)qn(\"WebGLRenderer: Texture marked for update but image is incomplete\");else{q(ae,B,R);return}}else B.isExternalTexture&&(ae.__webglTexture=B.sourceTexture?B.sourceTexture:null);n.bindTexture(t.TEXTURE_2D,ae.__webglTexture,t.TEXTURE0+R)}function ie(B,R){const ae=r.get(B);if(B.isRenderTargetTexture===!1&&B.version>0&&ae.__version!==B.version){q(ae,B,R);return}else B.isExternalTexture&&(ae.__webglTexture=B.sourceTexture?B.sourceTexture:null);n.bindTexture(t.TEXTURE_2D_ARRAY,ae.__webglTexture,t.TEXTURE0+R)}function J(B,R){const ae=r.get(B);if(B.isRenderTargetTexture===!1&&B.version>0&&ae.__version!==B.version){q(ae,B,R);return}n.bindTexture(t.TEXTURE_3D,ae.__webglTexture,t.TEXTURE0+R)}function oe(B,R){const ae=r.get(B);if(B.version>0&&ae.__version!==B.version){Y(ae,B,R);return}n.bindTexture(t.TEXTURE_CUBE_MAP,ae.__webglTexture,t.TEXTURE0+R)}const fe={[PF]:t.REPEAT,[Ju]:t.CLAMP_TO_EDGE,[TF]:t.MIRRORED_REPEAT},re={[hl]:t.NEAREST,[kve]:t.NEAREST_MIPMAP_NEAREST,[Z_]:t.NEAREST_MIPMAP_LINEAR,[ec]:t.LINEAR,[EM]:t.LINEAR_MIPMAP_NEAREST,[Rf]:t.LINEAR_MIPMAP_LINEAR},W={[Tve]:t.NEVER,[Fve]:t.ALWAYS,[Ave]:t.LESS,[xZ]:t.LEQUAL,[jve]:t.EQUAL,[Dve]:t.GEQUAL,[Mve]:t.GREATER,[Eve]:t.NOTEQUAL};function ne(B,R){if(R.type===em&&e.has(\"OES_texture_float_linear\")===!1&&(R.magFilter===ec||R.magFilter===EM||R.magFilter===Z_||R.magFilter===Rf||R.minFilter===ec||R.minFilter===EM||R.minFilter===Z_||R.minFilter===Rf)&&qn(\"WebGLRenderer: Unable to use linear filtering with floating point textures. OES_texture_float_linear not supported on this device.\"),t.texParameteri(B,t.TEXTURE_WRAP_S,fe[R.wrapS]),t.texParameteri(B,t.TEXTURE_WRAP_T,fe[R.wrapT]),(B===t.TEXTURE_3D||B===t.TEXTURE_2D_ARRAY)&&t.texParameteri(B,t.TEXTURE_WRAP_R,fe[R.wrapR]),t.texParameteri(B,t.TEXTURE_MAG_FILTER,re[R.magFilter]),t.texParameteri(B,t.TEXTURE_MIN_FILTER,re[R.minFilter]),R.compareFunction&&(t.texParameteri(B,t.TEXTURE_COMPARE_MODE,t.COMPARE_REF_TO_TEXTURE),t.texParameteri(B,t.TEXTURE_COMPARE_FUNC,W[R.compareFunction])),e.has(\"EXT_texture_filter_anisotropic\")===!0){if(R.magFilter===hl||R.minFilter!==Z_&&R.minFilter!==Rf||R.type===em&&e.has(\"OES_texture_float_linear\")===!1)return;if(R.anisotropy>1||r.get(R).__currentAnisotropy){const ae=e.get(\"EXT_texture_filter_anisotropic\");t.texParameterf(B,ae.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(R.anisotropy,i.getMaxAnisotropy())),r.get(R).__currentAnisotropy=R.anisotropy}}}function me(B,R){let ae=!1;B.__webglInit===void 0&&(B.__webglInit=!0,R.addEventListener(\"dispose\",A));const _e=R.source;let Se=p.get(_e);Se===void 0&&(Se={},p.set(_e,Se));const ve=L(R);if(ve!==B.__cacheKey){Se[ve]===void 0&&(Se[ve]={texture:t.createTexture(),usedTimes:0},o.memory.textures++,ae=!0),Se[ve].usedTimes++;const Te=Se[B.__cacheKey];Te!==void 0&&(Se[B.__cacheKey].usedTimes--,Te.usedTimes===0&&k(R)),B.__cacheKey=ve,B.__webglTexture=Se[ve].texture}return ae}function be(B,R,ae){return Math.floor(Math.floor(B/ae)/R)}function Ce(B,R,ae,_e){const ve=B.updateRanges;if(ve.length===0)n.texSubImage2D(t.TEXTURE_2D,0,0,0,R.width,R.height,ae,_e,R.data);else{ve.sort((Me,Oe)=>Me.start-Oe.start);let Te=0;for(let Me=1;Me<ve.length;Me++){const Oe=ve[Te],Re=ve[Me],$e=Oe.start+Oe.count,Ye=be(Re.start,R.width,4),tt=be(Oe.start,R.width,4);Re.start<=$e+1&&Ye===tt&&be(Re.start+Re.count-1,R.width,4)===Ye?Oe.count=Math.max(Oe.count,Re.start+Re.count-Oe.start):(++Te,ve[Te]=Re)}ve.length=Te+1;const ye=t.getParameter(t.UNPACK_ROW_LENGTH),je=t.getParameter(t.UNPACK_SKIP_PIXELS),Le=t.getParameter(t.UNPACK_SKIP_ROWS);t.pixelStorei(t.UNPACK_ROW_LENGTH,R.width);for(let Me=0,Oe=ve.length;Me<Oe;Me++){const Re=ve[Me],$e=Math.floor(Re.start/4),Ye=Math.ceil(Re.count/4),tt=$e%R.width,pe=Math.floor($e/R.width),Fe=Ye,we=1;t.pixelStorei(t.UNPACK_SKIP_PIXELS,tt),t.pixelStorei(t.UNPACK_SKIP_ROWS,pe),n.texSubImage2D(t.TEXTURE_2D,0,tt,pe,Fe,we,ae,_e,R.data)}B.clearUpdateRanges(),t.pixelStorei(t.UNPACK_ROW_LENGTH,ye),t.pixelStorei(t.UNPACK_SKIP_PIXELS,je),t.pixelStorei(t.UNPACK_SKIP_ROWS,Le)}}function q(B,R,ae){let _e=t.TEXTURE_2D;(R.isDataArrayTexture||R.isCompressedArrayTexture)&&(_e=t.TEXTURE_2D_ARRAY),R.isData3DTexture&&(_e=t.TEXTURE_3D);const Se=me(B,R),ve=R.source;n.bindTexture(_e,B.__webglTexture,t.TEXTURE0+ae);const Te=r.get(ve);if(ve.version!==Te.__version||Se===!0){n.activeTexture(t.TEXTURE0+ae);const ye=Mr.getPrimaries(Mr.workingColorSpace),je=R.colorSpace===Rh?null:Mr.getPrimaries(R.colorSpace),Le=R.colorSpace===Rh||ye===je?t.NONE:t.BROWSER_DEFAULT_WEBGL;t.pixelStorei(t.UNPACK_FLIP_Y_WEBGL,R.flipY),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,R.premultiplyAlpha),t.pixelStorei(t.UNPACK_ALIGNMENT,R.unpackAlignment),t.pixelStorei(t.UNPACK_COLORSPACE_CONVERSION_WEBGL,Le);let Me=v(R.image,!1,i.maxTextureSize);Me=he(R,Me);const Oe=s.convert(R.format,R.colorSpace),Re=s.convert(R.type);let $e=C(R.internalFormat,Oe,Re,R.colorSpace,R.isVideoTexture);ne(_e,R);let Ye;const tt=R.mipmaps,pe=R.isVideoTexture!==!0,Fe=Te.__version===void 0||Se===!0,we=ve.dataReady,Ve=N(R,Me);if(R.isDepthTexture)$e=P(R.format===uw,R.type),Fe&&(pe?n.texStorage2D(t.TEXTURE_2D,1,$e,Me.width,Me.height):n.texImage2D(t.TEXTURE_2D,0,$e,Me.width,Me.height,0,Oe,Re,null));else if(R.isDataTexture)if(tt.length>0){pe&&Fe&&n.texStorage2D(t.TEXTURE_2D,Ve,$e,tt[0].width,tt[0].height);for(let Ae=0,ce=tt.length;Ae<ce;Ae++)Ye=tt[Ae],pe?we&&n.texSubImage2D(t.TEXTURE_2D,Ae,0,0,Ye.width,Ye.height,Oe,Re,Ye.data):n.texImage2D(t.TEXTURE_2D,Ae,$e,Ye.width,Ye.height,0,Oe,Re,Ye.data);R.generateMipmaps=!1}else pe?(Fe&&n.texStorage2D(t.TEXTURE_2D,Ve,$e,Me.width,Me.height),we&&Ce(R,Me,Oe,Re)):n.texImage2D(t.TEXTURE_2D,0,$e,Me.width,Me.height,0,Oe,Re,Me.data);else if(R.isCompressedTexture)if(R.isCompressedArrayTexture){pe&&Fe&&n.texStorage3D(t.TEXTURE_2D_ARRAY,Ve,$e,tt[0].width,tt[0].height,Me.depth);for(let Ae=0,ce=tt.length;Ae<ce;Ae++)if(Ye=tt[Ae],R.format!==Gc)if(Oe!==null)if(pe){if(we)if(R.layerUpdates.size>0){const xe=v6(Ye.width,Ye.height,R.format,R.type);for(const Be of R.layerUpdates){const Qe=Ye.data.subarray(Be*xe/Ye.data.BYTES_PER_ELEMENT,(Be+1)*xe/Ye.data.BYTES_PER_ELEMENT);n.compressedTexSubImage3D(t.TEXTURE_2D_ARRAY,Ae,0,0,Be,Ye.width,Ye.height,1,Oe,Qe)}R.clearLayerUpdates()}else n.compressedTexSubImage3D(t.TEXTURE_2D_ARRAY,Ae,0,0,0,Ye.width,Ye.height,Me.depth,Oe,Ye.data)}else n.compressedTexImage3D(t.TEXTURE_2D_ARRAY,Ae,$e,Ye.width,Ye.height,Me.depth,0,Ye.data,0,0);else qn(\"WebGLRenderer: Attempt to load unsupported compressed texture format in .uploadTexture()\");else pe?we&&n.texSubImage3D(t.TEXTURE_2D_ARRAY,Ae,0,0,0,Ye.width,Ye.height,Me.depth,Oe,Re,Ye.data):n.texImage3D(t.TEXTURE_2D_ARRAY,Ae,$e,Ye.width,Ye.height,Me.depth,0,Oe,Re,Ye.data)}else{pe&&Fe&&n.texStorage2D(t.TEXTURE_2D,Ve,$e,tt[0].width,tt[0].height);for(let Ae=0,ce=tt.length;Ae<ce;Ae++)Ye=tt[Ae],R.format!==Gc?Oe!==null?pe?we&&n.compressedTexSubImage2D(t.TEXTURE_2D,Ae,0,0,Ye.width,Ye.height,Oe,Ye.data):n.compressedTexImage2D(t.TEXTURE_2D,Ae,$e,Ye.width,Ye.height,0,Ye.data):qn(\"WebGLRenderer: Attempt to load unsupported compressed texture format in .uploadTexture()\"):pe?we&&n.texSubImage2D(t.TEXTURE_2D,Ae,0,0,Ye.width,Ye.height,Oe,Re,Ye.data):n.texImage2D(t.TEXTURE_2D,Ae,$e,Ye.width,Ye.height,0,Oe,Re,Ye.data)}else if(R.isDataArrayTexture)if(pe){if(Fe&&n.texStorage3D(t.TEXTURE_2D_ARRAY,Ve,$e,Me.width,Me.height,Me.depth),we)if(R.layerUpdates.size>0){const Ae=v6(Me.width,Me.height,R.format,R.type);for(const ce of R.layerUpdates){const xe=Me.data.subarray(ce*Ae/Me.data.BYTES_PER_ELEMENT,(ce+1)*Ae/Me.data.BYTES_PER_ELEMENT);n.texSubImage3D(t.TEXTURE_2D_ARRAY,0,0,0,ce,Me.width,Me.height,1,Oe,Re,xe)}R.clearLayerUpdates()}else n.texSubImage3D(t.TEXTURE_2D_ARRAY,0,0,0,0,Me.width,Me.height,Me.depth,Oe,Re,Me.data)}else n.texImage3D(t.TEXTURE_2D_ARRAY,0,$e,Me.width,Me.height,Me.depth,0,Oe,Re,Me.data);else if(R.isData3DTexture)pe?(Fe&&n.texStorage3D(t.TEXTURE_3D,Ve,$e,Me.width,Me.height,Me.depth),we&&n.texSubImage3D(t.TEXTURE_3D,0,0,0,0,Me.width,Me.height,Me.depth,Oe,Re,Me.data)):n.texImage3D(t.TEXTURE_3D,0,$e,Me.width,Me.height,Me.depth,0,Oe,Re,Me.data);else if(R.isFramebufferTexture){if(Fe)if(pe)n.texStorage2D(t.TEXTURE_2D,Ve,$e,Me.width,Me.height);else{let Ae=Me.width,ce=Me.height;for(let xe=0;xe<Ve;xe++)n.texImage2D(t.TEXTURE_2D,xe,$e,Ae,ce,0,Oe,Re,null),Ae>>=1,ce>>=1}}else if(tt.length>0){if(pe&&Fe){const Ae=le(tt[0]);n.texStorage2D(t.TEXTURE_2D,Ve,$e,Ae.width,Ae.height)}for(let Ae=0,ce=tt.length;Ae<ce;Ae++)Ye=tt[Ae],pe?we&&n.texSubImage2D(t.TEXTURE_2D,Ae,0,0,Oe,Re,Ye):n.texImage2D(t.TEXTURE_2D,Ae,$e,Oe,Re,Ye);R.generateMipmaps=!1}else if(pe){if(Fe){const Ae=le(Me);n.texStorage2D(t.TEXTURE_2D,Ve,$e,Ae.width,Ae.height)}we&&n.texSubImage2D(t.TEXTURE_2D,0,0,0,Oe,Re,Me)}else n.texImage2D(t.TEXTURE_2D,0,$e,Oe,Re,Me);b(R)&&g(_e),Te.__version=ve.version,R.onUpdate&&R.onUpdate(R)}B.__version=R.version}function Y(B,R,ae){if(R.image.length!==6)return;const _e=me(B,R),Se=R.source;n.bindTexture(t.TEXTURE_CUBE_MAP,B.__webglTexture,t.TEXTURE0+ae);const ve=r.get(Se);if(Se.version!==ve.__version||_e===!0){n.activeTexture(t.TEXTURE0+ae);const Te=Mr.getPrimaries(Mr.workingColorSpace),ye=R.colorSpace===Rh?null:Mr.getPrimaries(R.colorSpace),je=R.colorSpace===Rh||Te===ye?t.NONE:t.BROWSER_DEFAULT_WEBGL;t.pixelStorei(t.UNPACK_FLIP_Y_WEBGL,R.flipY),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,R.premultiplyAlpha),t.pixelStorei(t.UNPACK_ALIGNMENT,R.unpackAlignment),t.pixelStorei(t.UNPACK_COLORSPACE_CONVERSION_WEBGL,je);const Le=R.isCompressedTexture||R.image[0].isCompressedTexture,Me=R.image[0]&&R.image[0].isDataTexture,Oe=[];for(let ce=0;ce<6;ce++)!Le&&!Me?Oe[ce]=v(R.image[ce],!0,i.maxCubemapSize):Oe[ce]=Me?R.image[ce].image:R.image[ce],Oe[ce]=he(R,Oe[ce]);const Re=Oe[0],$e=s.convert(R.format,R.colorSpace),Ye=s.convert(R.type),tt=C(R.internalFormat,$e,Ye,R.colorSpace),pe=R.isVideoTexture!==!0,Fe=ve.__version===void 0||_e===!0,we=Se.dataReady;let Ve=N(R,Re);ne(t.TEXTURE_CUBE_MAP,R);let Ae;if(Le){pe&&Fe&&n.texStorage2D(t.TEXTURE_CUBE_MAP,Ve,tt,Re.width,Re.height);for(let ce=0;ce<6;ce++){Ae=Oe[ce].mipmaps;for(let xe=0;xe<Ae.length;xe++){const Be=Ae[xe];R.format!==Gc?$e!==null?pe?we&&n.compressedTexSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe,0,0,Be.width,Be.height,$e,Be.data):n.compressedTexImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe,tt,Be.width,Be.height,0,Be.data):qn(\"WebGLRenderer: Attempt to load unsupported compressed texture format in .setTextureCube()\"):pe?we&&n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe,0,0,Be.width,Be.height,$e,Ye,Be.data):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe,tt,Be.width,Be.height,0,$e,Ye,Be.data)}}}else{if(Ae=R.mipmaps,pe&&Fe){Ae.length>0&&Ve++;const ce=le(Oe[0]);n.texStorage2D(t.TEXTURE_CUBE_MAP,Ve,tt,ce.width,ce.height)}for(let ce=0;ce<6;ce++)if(Me){pe?we&&n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,0,0,0,Oe[ce].width,Oe[ce].height,$e,Ye,Oe[ce].data):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,0,tt,Oe[ce].width,Oe[ce].height,0,$e,Ye,Oe[ce].data);for(let xe=0;xe<Ae.length;xe++){const Qe=Ae[xe].image[ce].image;pe?we&&n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe+1,0,0,Qe.width,Qe.height,$e,Ye,Qe.data):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe+1,tt,Qe.width,Qe.height,0,$e,Ye,Qe.data)}}else{pe?we&&n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,0,0,0,$e,Ye,Oe[ce]):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,0,tt,$e,Ye,Oe[ce]);for(let xe=0;xe<Ae.length;xe++){const Be=Ae[xe];pe?we&&n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe+1,0,0,$e,Ye,Be.image[ce]):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe+1,tt,$e,Ye,Be.image[ce])}}}b(R)&&g(t.TEXTURE_CUBE_MAP),ve.__version=Se.version,R.onUpdate&&R.onUpdate(R)}B.__version=R.version}function E(B,R,ae,_e,Se,ve){const Te=s.convert(ae.format,ae.colorSpace),ye=s.convert(ae.type),je=C(ae.internalFormat,Te,ye,ae.colorSpace),Le=r.get(R),Me=r.get(ae);if(Me.__renderTarget=R,!Le.__hasExternalTextures){const Oe=Math.max(1,R.width>>ve),Re=Math.max(1,R.height>>ve);Se===t.TEXTURE_3D||Se===t.TEXTURE_2D_ARRAY?n.texImage3D(Se,ve,je,Oe,Re,R.depth,0,Te,ye,null):n.texImage2D(Se,ve,je,Oe,Re,0,Te,ye,null)}n.bindFramebuffer(t.FRAMEBUFFER,B),se(R)?l.framebufferTexture2DMultisampleEXT(t.FRAMEBUFFER,_e,Se,Me.__webglTexture,0,ee(R)):(Se===t.TEXTURE_2D||Se>=t.TEXTURE_CUBE_MAP_POSITIVE_X&&Se<=t.TEXTURE_CUBE_MAP_NEGATIVE_Z)&&t.framebufferTexture2D(t.FRAMEBUFFER,_e,Se,Me.__webglTexture,ve),n.bindFramebuffer(t.FRAMEBUFFER,null)}function j(B,R,ae){if(t.bindRenderbuffer(t.RENDERBUFFER,B),R.depthBuffer){const _e=R.depthTexture,Se=_e&&_e.isDepthTexture?_e.type:null,ve=P(R.stencilBuffer,Se),Te=R.stencilBuffer?t.DEPTH_STENCIL_ATTACHMENT:t.DEPTH_ATTACHMENT,ye=ee(R);se(R)?l.renderbufferStorageMultisampleEXT(t.RENDERBUFFER,ye,ve,R.width,R.height):ae?t.renderbufferStorageMultisample(t.RENDERBUFFER,ye,ve,R.width,R.height):t.renderbufferStorage(t.RENDERBUFFER,ve,R.width,R.height),t.framebufferRenderbuffer(t.FRAMEBUFFER,Te,t.RENDERBUFFER,B)}else{const _e=R.textures;for(let Se=0;Se<_e.length;Se++){const ve=_e[Se],Te=s.convert(ve.format,ve.colorSpace),ye=s.convert(ve.type),je=C(ve.internalFormat,Te,ye,ve.colorSpace),Le=ee(R);ae&&se(R)===!1?t.renderbufferStorageMultisample(t.RENDERBUFFER,Le,je,R.width,R.height):se(R)?l.renderbufferStorageMultisampleEXT(t.RENDERBUFFER,Le,je,R.width,R.height):t.renderbufferStorage(t.RENDERBUFFER,je,R.width,R.height)}}t.bindRenderbuffer(t.RENDERBUFFER,null)}function O(B,R){if(R&&R.isWebGLCubeRenderTarget)throw new Error(\"Depth Texture with cube render targets is not supported\");if(n.bindFramebuffer(t.FRAMEBUFFER,B),!(R.depthTexture&&R.depthTexture.isDepthTexture))throw new Error(\"renderTarget.depthTexture must be an instance of THREE.DepthTexture\");const _e=r.get(R.depthTexture);_e.__renderTarget=R,(!_e.__webglTexture||R.depthTexture.image.width!==R.width||R.depthTexture.image.height!==R.height)&&(R.depthTexture.image.width=R.width,R.depthTexture.image.height=R.height,R.depthTexture.needsUpdate=!0),te(R.depthTexture,0);const Se=_e.__webglTexture,ve=ee(R);if(R.depthTexture.format===dw)se(R)?l.framebufferTexture2DMultisampleEXT(t.FRAMEBUFFER,t.DEPTH_ATTACHMENT,t.TEXTURE_2D,Se,0,ve):t.framebufferTexture2D(t.FRAMEBUFFER,t.DEPTH_ATTACHMENT,t.TEXTURE_2D,Se,0);else if(R.depthTexture.format===uw)se(R)?l.framebufferTexture2DMultisampleEXT(t.FRAMEBUFFER,t.DEPTH_STENCIL_ATTACHMENT,t.TEXTURE_2D,Se,0,ve):t.framebufferTexture2D(t.FRAMEBUFFER,t.DEPTH_STENCIL_ATTACHMENT,t.TEXTURE_2D,Se,0);else throw new Error(\"Unknown depthTexture format\")}function K(B){const R=r.get(B),ae=B.isWebGLCubeRenderTarget===!0;if(R.__boundDepthTexture!==B.depthTexture){const _e=B.depthTexture;if(R.__depthDisposeCallback&&R.__depthDisposeCallback(),_e){const Se=()=>{delete R.__boundDepthTexture,delete R.__depthDisposeCallback,_e.removeEventListener(\"dispose\",Se)};_e.addEventListener(\"dispose\",Se),R.__depthDisposeCallback=Se}R.__boundDepthTexture=_e}if(B.depthTexture&&!R.__autoAllocateDepthBuffer){if(ae)throw new Error(\"target.depthTexture not supported in Cube render targets\");const _e=B.texture.mipmaps;_e&&_e.length>0?O(R.__webglFramebuffer[0],B):O(R.__webglFramebuffer,B)}else if(ae){R.__webglDepthbuffer=[];for(let _e=0;_e<6;_e++)if(n.bindFramebuffer(t.FRAMEBUFFER,R.__webglFramebuffer[_e]),R.__webglDepthbuffer[_e]===void 0)R.__webglDepthbuffer[_e]=t.createRenderbuffer(),j(R.__webglDepthbuffer[_e],B,!1);else{const Se=B.stencilBuffer?t.DEPTH_STENCIL_ATTACHMENT:t.DEPTH_ATTACHMENT,ve=R.__webglDepthbuffer[_e];t.bindRenderbuffer(t.RENDERBUFFER,ve),t.framebufferRenderbuffer(t.FRAMEBUFFER,Se,t.RENDERBUFFER,ve)}}else{const _e=B.texture.mipmaps;if(_e&&_e.length>0?n.bindFramebuffer(t.FRAMEBUFFER,R.__webglFramebuffer[0]):n.bindFramebuffer(t.FRAMEBUFFER,R.__webglFramebuffer),R.__webglDepthbuffer===void 0)R.__webglDepthbuffer=t.createRenderbuffer(),j(R.__webglDepthbuffer,B,!1);else{const Se=B.stencilBuffer?t.DEPTH_STENCIL_ATTACHMENT:t.DEPTH_ATTACHMENT,ve=R.__webglDepthbuffer;t.bindRenderbuffer(t.RENDERBUFFER,ve),t.framebufferRenderbuffer(t.FRAMEBUFFER,Se,t.RENDERBUFFER,ve)}}n.bindFramebuffer(t.FRAMEBUFFER,null)}function U(B,R,ae){const _e=r.get(B);R!==void 0&&E(_e.__webglFramebuffer,B,B.texture,t.COLOR_ATTACHMENT0,t.TEXTURE_2D,0),ae!==void 0&&K(B)}function de(B){const R=B.texture,ae=r.get(B),_e=r.get(R);B.addEventListener(\"dispose\",T);const Se=B.textures,ve=B.isWebGLCubeRenderTarget===!0,Te=Se.length>1;if(Te||(_e.__webglTexture===void 0&&(_e.__webglTexture=t.createTexture()),_e.__version=R.version,o.memory.textures++),ve){ae.__webglFramebuffer=[];for(let ye=0;ye<6;ye++)if(R.mipmaps&&R.mipmaps.length>0){ae.__webglFramebuffer[ye]=[];for(let je=0;je<R.mipmaps.length;je++)ae.__webglFramebuffer[ye][je]=t.createFramebuffer()}else ae.__webglFramebuffer[ye]=t.createFramebuffer()}else{if(R.mipmaps&&R.mipmaps.length>0){ae.__webglFramebuffer=[];for(let ye=0;ye<R.mipmaps.length;ye++)ae.__webglFramebuffer[ye]=t.createFramebuffer()}else ae.__webglFramebuffer=t.createFramebuffer();if(Te)for(let ye=0,je=Se.length;ye<je;ye++){const Le=r.get(Se[ye]);Le.__webglTexture===void 0&&(Le.__webglTexture=t.createTexture(),o.memory.textures++)}if(B.samples>0&&se(B)===!1){ae.__webglMultisampledFramebuffer=t.createFramebuffer(),ae.__webglColorRenderbuffer=[],n.bindFramebuffer(t.FRAMEBUFFER,ae.__webglMultisampledFramebuffer);for(let ye=0;ye<Se.length;ye++){const je=Se[ye];ae.__webglColorRenderbuffer[ye]=t.createRenderbuffer(),t.bindRenderbuffer(t.RENDERBUFFER,ae.__webglColorRenderbuffer[ye]);const Le=s.convert(je.format,je.colorSpace),Me=s.convert(je.type),Oe=C(je.internalFormat,Le,Me,je.colorSpace,B.isXRRenderTarget===!0),Re=ee(B);t.renderbufferStorageMultisample(t.RENDERBUFFER,Re,Oe,B.width,B.height),t.framebufferRenderbuffer(t.FRAMEBUFFER,t.COLOR_ATTACHMENT0+ye,t.RENDERBUFFER,ae.__webglColorRenderbuffer[ye])}t.bindRenderbuffer(t.RENDERBUFFER,null),B.depthBuffer&&(ae.__webglDepthRenderbuffer=t.createRenderbuffer(),j(ae.__webglDepthRenderbuffer,B,!0)),n.bindFramebuffer(t.FRAMEBUFFER,null)}}if(ve){n.bindTexture(t.TEXTURE_CUBE_MAP,_e.__webglTexture),ne(t.TEXTURE_CUBE_MAP,R);for(let ye=0;ye<6;ye++)if(R.mipmaps&&R.mipmaps.length>0)for(let je=0;je<R.mipmaps.length;je++)E(ae.__webglFramebuffer[ye][je],B,R,t.COLOR_ATTACHMENT0,t.TEXTURE_CUBE_MAP_POSITIVE_X+ye,je);else E(ae.__webglFramebuffer[ye],B,R,t.COLOR_ATTACHMENT0,t.TEXTURE_CUBE_MAP_POSITIVE_X+ye,0);b(R)&&g(t.TEXTURE_CUBE_MAP),n.unbindTexture()}else if(Te){for(let ye=0,je=Se.length;ye<je;ye++){const Le=Se[ye],Me=r.get(Le);let Oe=t.TEXTURE_2D;(B.isWebGL3DRenderTarget||B.isWebGLArrayRenderTarget)&&(Oe=B.isWebGL3DRenderTarget?t.TEXTURE_3D:t.TEXTURE_2D_ARRAY),n.bindTexture(Oe,Me.__webglTexture),ne(Oe,Le),E(ae.__webglFramebuffer,B,Le,t.COLOR_ATTACHMENT0+ye,Oe,0),b(Le)&&g(Oe)}n.unbindTexture()}else{let ye=t.TEXTURE_2D;if((B.isWebGL3DRenderTarget||B.isWebGLArrayRenderTarget)&&(ye=B.isWebGL3DRenderTarget?t.TEXTURE_3D:t.TEXTURE_2D_ARRAY),n.bindTexture(ye,_e.__webglTexture),ne(ye,R),R.mipmaps&&R.mipmaps.length>0)for(let je=0;je<R.mipmaps.length;je++)E(ae.__webglFramebuffer[je],B,R,t.COLOR_ATTACHMENT0,ye,je);else E(ae.__webglFramebuffer,B,R,t.COLOR_ATTACHMENT0,ye,0);b(R)&&g(ye),n.unbindTexture()}B.depthBuffer&&K(B)}function I(B){const R=B.textures;for(let ae=0,_e=R.length;ae<_e;ae++){const Se=R[ae];if(b(Se)){const ve=_(B),Te=r.get(Se).__webglTexture;n.bindTexture(ve,Te),g(ve),n.unbindTexture()}}}const G=[],X=[];function V(B){if(B.samples>0){if(se(B)===!1){const R=B.textures,ae=B.width,_e=B.height;let Se=t.COLOR_BUFFER_BIT;const ve=B.stencilBuffer?t.DEPTH_STENCIL_ATTACHMENT:t.DEPTH_ATTACHMENT,Te=r.get(B),ye=R.length>1;if(ye)for(let Le=0;Le<R.length;Le++)n.bindFramebuffer(t.FRAMEBUFFER,Te.__webglMultisampledFramebuffer),t.framebufferRenderbuffer(t.FRAMEBUFFER,t.COLOR_ATTACHMENT0+Le,t.RENDERBUFFER,null),n.bindFramebuffer(t.FRAMEBUFFER,Te.__webglFramebuffer),t.framebufferTexture2D(t.DRAW_FRAMEBUFFER,t.COLOR_ATTACHMENT0+Le,t.TEXTURE_2D,null,0);n.bindFramebuffer(t.READ_FRAMEBUFFER,Te.__webglMultisampledFramebuffer);const je=B.texture.mipmaps;je&&je.length>0?n.bindFramebuffer(t.DRAW_FRAMEBUFFER,Te.__webglFramebuffer[0]):n.bindFramebuffer(t.DRAW_FRAMEBUFFER,Te.__webglFramebuffer);for(let Le=0;Le<R.length;Le++){if(B.resolveDepthBuffer&&(B.depthBuffer&&(Se|=t.DEPTH_BUFFER_BIT),B.stencilBuffer&&B.resolveStencilBuffer&&(Se|=t.STENCIL_BUFFER_BIT)),ye){t.framebufferRenderbuffer(t.READ_FRAMEBUFFER,t.COLOR_ATTACHMENT0,t.RENDERBUFFER,Te.__webglColorRenderbuffer[Le]);const Me=r.get(R[Le]).__webglTexture;t.framebufferTexture2D(t.DRAW_FRAMEBUFFER,t.COLOR_ATTACHMENT0,t.TEXTURE_2D,Me,0)}t.blitFramebuffer(0,0,ae,_e,0,0,ae,_e,Se,t.NEAREST),c===!0&&(G.length=0,X.length=0,G.push(t.COLOR_ATTACHMENT0+Le),B.depthBuffer&&B.resolveDepthBuffer===!1&&(G.push(ve),X.push(ve),t.invalidateFramebuffer(t.DRAW_FRAMEBUFFER,X)),t.invalidateFramebuffer(t.READ_FRAMEBUFFER,G))}if(n.bindFramebuffer(t.READ_FRAMEBUFFER,null),n.bindFramebuffer(t.DRAW_FRAMEBUFFER,null),ye)for(let Le=0;Le<R.length;Le++){n.bindFramebuffer(t.FRAMEBUFFER,Te.__webglMultisampledFramebuffer),t.framebufferRenderbuffer(t.FRAMEBUFFER,t.COLOR_ATTACHMENT0+Le,t.RENDERBUFFER,Te.__webglColorRenderbuffer[Le]);const Me=r.get(R[Le]).__webglTexture;n.bindFramebuffer(t.FRAMEBUFFER,Te.__webglFramebuffer),t.framebufferTexture2D(t.DRAW_FRAMEBUFFER,t.COLOR_ATTACHMENT0+Le,t.TEXTURE_2D,Me,0)}n.bindFramebuffer(t.DRAW_FRAMEBUFFER,Te.__webglMultisampledFramebuffer)}else if(B.depthBuffer&&B.resolveDepthBuffer===!1&&c){const R=B.stencilBuffer?t.DEPTH_STENCIL_ATTACHMENT:t.DEPTH_ATTACHMENT;t.invalidateFramebuffer(t.DRAW_FRAMEBUFFER,[R])}}}function ee(B){return Math.min(i.maxSamples,B.samples)}function se(B){const R=r.get(B);return B.samples>0&&e.has(\"WEBGL_multisampled_render_to_texture\")===!0&&R.__useRenderToTexture!==!1}function ge(B){const R=o.render.frame;u.get(B)!==R&&(u.set(B,R),B.update())}function he(B,R){const ae=B.colorSpace,_e=B.format,Se=B.type;return B.isCompressedTexture===!0||B.isVideoTexture===!0||ae!==Kx&&ae!==Rh&&(Mr.getTransfer(ae)===Qr?(_e!==Gc||Se!==Hd)&&qn(\"WebGLTextures: sRGB encoded textures have to use RGBAFormat and UnsignedByteType.\"):oi(\"WebGLTextures: Unsupported texture color space:\",ae)),R}function le(B){return typeof HTMLImageElement<\"u\"&&B instanceof HTMLImageElement?(d.width=B.naturalWidth||B.width,d.height=B.naturalHeight||B.height):typeof VideoFrame<\"u\"&&B instanceof VideoFrame?(d.width=B.displayWidth,d.height=B.displayHeight):(d.width=B.width,d.height=B.height),d}this.allocateTextureUnit=Q,this.resetTextureUnits=z,this.setTexture2D=te,this.setTexture2DArray=ie,this.setTexture3D=J,this.setTextureCube=oe,this.rebindTextures=U,this.setupRenderTarget=de,this.updateRenderTargetMipmap=I,this.updateMultisampleRenderTarget=V,this.setupDepthRenderbuffer=K,this.setupFrameBufferTexture=E,this.useMultisampledRTT=se}function ake(t,e){function n(r,i=Rh){let s;const o=Mr.getTransfer(i);if(r===Hd)return t.UNSIGNED_BYTE;if(r===WO)return t.UNSIGNED_SHORT_4_4_4_4;if(r===KO)return t.UNSIGNED_SHORT_5_5_5_1;if(r===mZ)return t.UNSIGNED_INT_5_9_9_9_REV;if(r===hZ)return t.UNSIGNED_INT_10F_11F_11F_REV;if(r===dZ)return t.BYTE;if(r===uZ)return t.SHORT;if(r===lw)return t.UNSIGNED_SHORT;if(r===GO)return t.INT;if(r===fg)return t.UNSIGNED_INT;if(r===em)return t.FLOAT;if(r===_y)return t.HALF_FLOAT;if(r===pZ)return t.ALPHA;if(r===fZ)return t.RGB;if(r===Gc)return t.RGBA;if(r===dw)return t.DEPTH_COMPONENT;if(r===uw)return t.DEPTH_STENCIL;if(r===gZ)return t.RED;if(r===XO)return t.RED_INTEGER;if(r===YO)return t.RG;if(r===QO)return t.RG_INTEGER;if(r===ZO)return t.RGBA_INTEGER;if(r===Uk||r===Bk||r===Hk||r===qk)if(o===Qr)if(s=e.get(\"WEBGL_compressed_texture_s3tc_srgb\"),s!==null){if(r===Uk)return s.COMPRESSED_SRGB_S3TC_DXT1_EXT;if(r===Bk)return s.COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT;if(r===Hk)return s.COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT;if(r===qk)return s.COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT}else return null;else if(s=e.get(\"WEBGL_compressed_texture_s3tc\"),s!==null){if(r===Uk)return s.COMPRESSED_RGB_S3TC_DXT1_EXT;if(r===Bk)return s.COMPRESSED_RGBA_S3TC_DXT1_EXT;if(r===Hk)return s.COMPRESSED_RGBA_S3TC_DXT3_EXT;if(r===qk)return s.COMPRESSED_RGBA_S3TC_DXT5_EXT}else return null;if(r===AF||r===jF||r===MF||r===EF)if(s=e.get(\"WEBGL_compressed_texture_pvrtc\"),s!==null){if(r===AF)return s.COMPRESSED_RGB_PVRTC_4BPPV1_IMG;if(r===jF)return s.COMPRESSED_RGB_PVRTC_2BPPV1_IMG;if(r===MF)return s.COMPRESSED_RGBA_PVRTC_4BPPV1_IMG;if(r===EF)return s.COMPRESSED_RGBA_PVRTC_2BPPV1_IMG}else return null;if(r===DF||r===FF||r===RF)if(s=e.get(\"WEBGL_compressed_texture_etc\"),s!==null){if(r===DF||r===FF)return o===Qr?s.COMPRESSED_SRGB8_ETC2:s.COMPRESSED_RGB8_ETC2;if(r===RF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ETC2_EAC:s.COMPRESSED_RGBA8_ETC2_EAC}else return null;if(r===LF||r===OF||r===IF||r===zF||r===UF||r===BF||r===HF||r===qF||r===$F||r===VF||r===GF||r===WF||r===KF||r===XF)if(s=e.get(\"WEBGL_compressed_texture_astc\"),s!==null){if(r===LF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR:s.COMPRESSED_RGBA_ASTC_4x4_KHR;if(r===OF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_5x4_KHR:s.COMPRESSED_RGBA_ASTC_5x4_KHR;if(r===IF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_5x5_KHR:s.COMPRESSED_RGBA_ASTC_5x5_KHR;if(r===zF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_6x5_KHR:s.COMPRESSED_RGBA_ASTC_6x5_KHR;if(r===UF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_6x6_KHR:s.COMPRESSED_RGBA_ASTC_6x6_KHR;if(r===BF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_8x5_KHR:s.COMPRESSED_RGBA_ASTC_8x5_KHR;if(r===HF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_8x6_KHR:s.COMPRESSED_RGBA_ASTC_8x6_KHR;if(r===qF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_8x8_KHR:s.COMPRESSED_RGBA_ASTC_8x8_KHR;if(r===$F)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_10x5_KHR:s.COMPRESSED_RGBA_ASTC_10x5_KHR;if(r===VF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_10x6_KHR:s.COMPRESSED_RGBA_ASTC_10x6_KHR;if(r===GF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_10x8_KHR:s.COMPRESSED_RGBA_ASTC_10x8_KHR;if(r===WF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_10x10_KHR:s.COMPRESSED_RGBA_ASTC_10x10_KHR;if(r===KF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_12x10_KHR:s.COMPRESSED_RGBA_ASTC_12x10_KHR;if(r===XF)return o===Qr?s.COMPRESSED_SRGB8_ALPHA8_ASTC_12x12_KHR:s.COMPRESSED_RGBA_ASTC_12x12_KHR}else return null;if(r===YF||r===QF||r===ZF)if(s=e.get(\"EXT_texture_compression_bptc\"),s!==null){if(r===YF)return o===Qr?s.COMPRESSED_SRGB_ALPHA_BPTC_UNORM_EXT:s.COMPRESSED_RGBA_BPTC_UNORM_EXT;if(r===QF)return s.COMPRESSED_RGB_BPTC_SIGNED_FLOAT_EXT;if(r===ZF)return s.COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT_EXT}else return null;if(r===JF||r===eR||r===tR||r===nR)if(s=e.get(\"EXT_texture_compression_rgtc\"),s!==null){if(r===JF)return s.COMPRESSED_RED_RGTC1_EXT;if(r===eR)return s.COMPRESSED_SIGNED_RED_RGTC1_EXT;if(r===tR)return s.COMPRESSED_RED_GREEN_RGTC2_EXT;if(r===nR)return s.COMPRESSED_SIGNED_RED_GREEN_RGTC2_EXT}else return null;return r===cw?t.UNSIGNED_INT_24_8:t[r]!==void 0?t[r]:null}return{convert:n}}const ike=`\nvoid main() {\n\n\tgl_Position = vec4( position, 1.0 );\n\n}`,ske=`\nuniform sampler2DArray depthColor;\nuniform float depthWidth;\nuniform float depthHeight;\n\nvoid main() {\n\n\tvec2 coord = vec2( gl_FragCoord.x / depthWidth, gl_FragCoord.y / depthHeight );\n\n\tif ( coord.x >= 1.0 ) {\n\n\t\tgl_FragDepth = texture( depthColor, vec3( coord.x - 1.0, coord.y, 1 ) ).r;\n\n\t} else {\n\n\t\tgl_FragDepth = texture( depthColor, vec3( coord.x, coord.y, 0 ) ).r;\n\n\t}\n\n}`;class oke{constructor(){this.texture=null,this.mesh=null,this.depthNear=0,this.depthFar=0}init(e,n){if(this.texture===null){const r=new OZ(e.texture);(e.depthNear!==n.depthNear||e.depthFar!==n.depthFar)&&(this.depthNear=e.depthNear,this.depthFar=e.depthFar),this.texture=r}}getMesh(e){if(this.texture!==null&&this.mesh===null){const n=e.cameras[0].viewport,r=new wm({vertexShader:ike,fragmentShader:ske,uniforms:{depthColor:{value:this.texture},depthWidth:{value:n.z},depthHeight:{value:n.w}}});this.mesh=new mc(new aI(20,20),r)}return this.mesh}reset(){this.texture=null,this.mesh=null}getDepthTexture(){return this.texture}}let lke=class extends Ug{constructor(e,n){super();const r=this;let i=null,s=1,o=null,l=\"local-floor\",c=1,d=null,u=null,m=null,p=null,f=null,y=null;const v=typeof XRWebGLBinding<\"u\",b=new oke,g={},_=n.getContextAttributes();let C=null,P=null;const N=[],A=[],T=new nr;let F=null;const k=new Kl;k.viewport=new yi;const D=new Kl;D.viewport=new yi;const H=[k,D],z=new N0e;let Q=null,L=null;this.cameraAutoUpdate=!0,this.enabled=!1,this.isPresenting=!1,this.getController=function(q){let Y=N[q];return Y===void 0&&(Y=new eE,N[q]=Y),Y.getTargetRaySpace()},this.getControllerGrip=function(q){let Y=N[q];return Y===void 0&&(Y=new eE,N[q]=Y),Y.getGripSpace()},this.getHand=function(q){let Y=N[q];return Y===void 0&&(Y=new eE,N[q]=Y),Y.getHandSpace()};function te(q){const Y=A.indexOf(q.inputSource);if(Y===-1)return;const E=N[Y];E!==void 0&&(E.update(q.inputSource,q.frame,d||o),E.dispatchEvent({type:q.type,data:q.inputSource}))}function ie(){i.removeEventListener(\"select\",te),i.removeEventListener(\"selectstart\",te),i.removeEventListener(\"selectend\",te),i.removeEventListener(\"squeeze\",te),i.removeEventListener(\"squeezestart\",te),i.removeEventListener(\"squeezeend\",te),i.removeEventListener(\"end\",ie),i.removeEventListener(\"inputsourceschange\",J);for(let q=0;q<N.length;q++){const Y=A[q];Y!==null&&(A[q]=null,N[q].disconnect(Y))}Q=null,L=null,b.reset();for(const q in g)delete g[q];e.setRenderTarget(C),f=null,p=null,m=null,i=null,P=null,Ce.stop(),r.isPresenting=!1,e.setPixelRatio(F),e.setSize(T.width,T.height,!1),r.dispatchEvent({type:\"sessionend\"})}this.setFramebufferScaleFactor=function(q){s=q,r.isPresenting===!0&&qn(\"WebXRManager: Cannot change framebuffer scale while presenting.\")},this.setReferenceSpaceType=function(q){l=q,r.isPresenting===!0&&qn(\"WebXRManager: Cannot change reference space type while presenting.\")},this.getReferenceSpace=function(){return d||o},this.setReferenceSpace=function(q){d=q},this.getBaseLayer=function(){return p!==null?p:f},this.getBinding=function(){return m===null&&v&&(m=new XRWebGLBinding(i,n)),m},this.getFrame=function(){return y},this.getSession=function(){return i},this.setSession=async function(q){if(i=q,i!==null){if(C=e.getRenderTarget(),i.addEventListener(\"select\",te),i.addEventListener(\"selectstart\",te),i.addEventListener(\"selectend\",te),i.addEventListener(\"squeeze\",te),i.addEventListener(\"squeezestart\",te),i.addEventListener(\"squeezeend\",te),i.addEventListener(\"end\",ie),i.addEventListener(\"inputsourceschange\",J),_.xrCompatible!==!0&&await n.makeXRCompatible(),F=e.getPixelRatio(),e.getSize(T),v&&\"createProjectionLayer\"in XRWebGLBinding.prototype){let E=null,j=null,O=null;_.depth&&(O=_.stencil?n.DEPTH24_STENCIL8:n.DEPTH_COMPONENT24,E=_.stencil?uw:dw,j=_.stencil?cw:fg);const K={colorFormat:n.RGBA8,depthFormat:O,scaleFactor:s};m=this.getBinding(),p=m.createProjectionLayer(K),i.updateRenderState({layers:[p]}),e.setPixelRatio(1),e.setSize(p.textureWidth,p.textureHeight,!1),P=new bg(p.textureWidth,p.textureHeight,{format:Gc,type:Hd,depthTexture:new LZ(p.textureWidth,p.textureHeight,j,void 0,void 0,void 0,void 0,void 0,void 0,E),stencilBuffer:_.stencil,colorSpace:e.outputColorSpace,samples:_.antialias?4:0,resolveDepthBuffer:p.ignoreDepthValues===!1,resolveStencilBuffer:p.ignoreDepthValues===!1})}else{const E={antialias:_.antialias,alpha:!0,depth:_.depth,stencil:_.stencil,framebufferScaleFactor:s};f=new XRWebGLLayer(i,n,E),i.updateRenderState({baseLayer:f}),e.setPixelRatio(1),e.setSize(f.framebufferWidth,f.framebufferHeight,!1),P=new bg(f.framebufferWidth,f.framebufferHeight,{format:Gc,type:Hd,colorSpace:e.outputColorSpace,stencilBuffer:_.stencil,resolveDepthBuffer:f.ignoreDepthValues===!1,resolveStencilBuffer:f.ignoreDepthValues===!1})}P.isXRRenderTarget=!0,this.setFoveation(c),d=null,o=await i.requestReferenceSpace(l),Ce.setContext(i),Ce.start(),r.isPresenting=!0,r.dispatchEvent({type:\"sessionstart\"})}},this.getEnvironmentBlendMode=function(){if(i!==null)return i.environmentBlendMode},this.getDepthTexture=function(){return b.getDepthTexture()};function J(q){for(let Y=0;Y<q.removed.length;Y++){const E=q.removed[Y],j=A.indexOf(E);j>=0&&(A[j]=null,N[j].disconnect(E))}for(let Y=0;Y<q.added.length;Y++){const E=q.added[Y];let j=A.indexOf(E);if(j===-1){for(let K=0;K<N.length;K++)if(K>=A.length){A.push(E),j=K;break}else if(A[K]===null){A[K]=E,j=K;break}if(j===-1)break}const O=N[j];O&&O.connect(E)}}const oe=new Ct,fe=new Ct;function re(q,Y,E){oe.setFromMatrixPosition(Y.matrixWorld),fe.setFromMatrixPosition(E.matrixWorld);const j=oe.distanceTo(fe),O=Y.projectionMatrix.elements,K=E.projectionMatrix.elements,U=O[14]/(O[10]-1),de=O[14]/(O[10]+1),I=(O[9]+1)/O[5],G=(O[9]-1)/O[5],X=(O[8]-1)/O[0],V=(K[8]+1)/K[0],ee=U*X,se=U*V,ge=j/(-X+V),he=ge*-X;if(Y.matrixWorld.decompose(q.position,q.quaternion,q.scale),q.translateX(he),q.translateZ(ge),q.matrixWorld.compose(q.position,q.quaternion,q.scale),q.matrixWorldInverse.copy(q.matrixWorld).invert(),O[10]===-1)q.projectionMatrix.copy(Y.projectionMatrix),q.projectionMatrixInverse.copy(Y.projectionMatrixInverse);else{const le=U+ge,B=de+ge,R=ee-he,ae=se+(j-he),_e=I*de/B*le,Se=G*de/B*le;q.projectionMatrix.makePerspective(R,ae,_e,Se,le,B),q.projectionMatrixInverse.copy(q.projectionMatrix).invert()}}function W(q,Y){Y===null?q.matrixWorld.copy(q.matrix):q.matrixWorld.multiplyMatrices(Y.matrixWorld,q.matrix),q.matrixWorldInverse.copy(q.matrixWorld).invert()}this.updateCamera=function(q){if(i===null)return;let Y=q.near,E=q.far;b.texture!==null&&(b.depthNear>0&&(Y=b.depthNear),b.depthFar>0&&(E=b.depthFar)),z.near=D.near=k.near=Y,z.far=D.far=k.far=E,(Q!==z.near||L!==z.far)&&(i.updateRenderState({depthNear:z.near,depthFar:z.far}),Q=z.near,L=z.far),z.layers.mask=q.layers.mask|6,k.layers.mask=z.layers.mask&3,D.layers.mask=z.layers.mask&5;const j=q.parent,O=z.cameras;W(z,j);for(let K=0;K<O.length;K++)W(O[K],j);O.length===2?re(z,k,D):z.projectionMatrix.copy(k.projectionMatrix),ne(q,z,j)};function ne(q,Y,E){E===null?q.matrix.copy(Y.matrixWorld):(q.matrix.copy(E.matrixWorld),q.matrix.invert(),q.matrix.multiply(Y.matrixWorld)),q.matrix.decompose(q.position,q.quaternion,q.scale),q.updateMatrixWorld(!0),q.projectionMatrix.copy(Y.projectionMatrix),q.projectionMatrixInverse.copy(Y.projectionMatrixInverse),q.isPerspectiveCamera&&(q.fov=rR*2*Math.atan(1/q.projectionMatrix.elements[5]),q.zoom=1)}this.getCamera=function(){return z},this.getFoveation=function(){if(!(p===null&&f===null))return c},this.setFoveation=function(q){c=q,p!==null&&(p.fixedFoveation=q),f!==null&&f.fixedFoveation!==void 0&&(f.fixedFoveation=q)},this.hasDepthSensing=function(){return b.texture!==null},this.getDepthSensingMesh=function(){return b.getMesh(z)},this.getCameraTexture=function(q){return g[q]};let me=null;function be(q,Y){if(u=Y.getViewerPose(d||o),y=Y,u!==null){const E=u.views;f!==null&&(e.setRenderTargetFramebuffer(P,f.framebuffer),e.setRenderTarget(P));let j=!1;E.length!==z.cameras.length&&(z.cameras.length=0,j=!0);for(let de=0;de<E.length;de++){const I=E[de];let G=null;if(f!==null)G=f.getViewport(I);else{const V=m.getViewSubImage(p,I);G=V.viewport,de===0&&(e.setRenderTargetTextures(P,V.colorTexture,V.depthStencilTexture),e.setRenderTarget(P))}let X=H[de];X===void 0&&(X=new Kl,X.layers.enable(de),X.viewport=new yi,H[de]=X),X.matrix.fromArray(I.transform.matrix),X.matrix.decompose(X.position,X.quaternion,X.scale),X.projectionMatrix.fromArray(I.projectionMatrix),X.projectionMatrixInverse.copy(X.projectionMatrix).invert(),X.viewport.set(G.x,G.y,G.width,G.height),de===0&&(z.matrix.copy(X.matrix),z.matrix.decompose(z.position,z.quaternion,z.scale)),j===!0&&z.cameras.push(X)}const O=i.enabledFeatures;if(O&&O.includes(\"depth-sensing\")&&i.depthUsage==\"gpu-optimized\"&&v){m=r.getBinding();const de=m.getDepthInformation(E[0]);de&&de.isValid&&de.texture&&b.init(de,i.renderState)}if(O&&O.includes(\"camera-access\")&&v){e.state.unbindTexture(),m=r.getBinding();for(let de=0;de<E.length;de++){const I=E[de].camera;if(I){let G=g[I];G||(G=new OZ,g[I]=G);const X=m.getCameraImage(I);G.sourceTexture=X}}}}for(let E=0;E<N.length;E++){const j=A[E],O=N[E];j!==null&&O!==void 0&&O.update(j,Y,d||o)}me&&me(q,Y),Y.detectedPlanes&&r.dispatchEvent({type:\"planesdetected\",data:Y}),y=null}const Ce=new HZ;Ce.setAnimationLoop(be),this.setAnimationLoop=function(q){me=q},this.dispose=function(){}}};const hf=new xp,cke=new _i;function dke(t,e){function n(b,g){b.matrixAutoUpdate===!0&&b.updateMatrix(),g.value.copy(b.matrix)}function r(b,g){g.color.getRGB(b.fogColor.value,EZ(t)),g.isFog?(b.fogNear.value=g.near,b.fogFar.value=g.far):g.isFogExp2&&(b.fogDensity.value=g.density)}function i(b,g,_,C,P){g.isMeshBasicMaterial||g.isMeshLambertMaterial?s(b,g):g.isMeshToonMaterial?(s(b,g),m(b,g)):g.isMeshPhongMaterial?(s(b,g),u(b,g)):g.isMeshStandardMaterial?(s(b,g),p(b,g),g.isMeshPhysicalMaterial&&f(b,g,P)):g.isMeshMatcapMaterial?(s(b,g),y(b,g)):g.isMeshDepthMaterial?s(b,g):g.isMeshDistanceMaterial?(s(b,g),v(b,g)):g.isMeshNormalMaterial?s(b,g):g.isLineBasicMaterial?(o(b,g),g.isLineDashedMaterial&&l(b,g)):g.isPointsMaterial?c(b,g,_,C):g.isSpriteMaterial?d(b,g):g.isShadowMaterial?(b.color.value.copy(g.color),b.opacity.value=g.opacity):g.isShaderMaterial&&(g.uniformsNeedUpdate=!1)}function s(b,g){b.opacity.value=g.opacity,g.color&&b.diffuse.value.copy(g.color),g.emissive&&b.emissive.value.copy(g.emissive).multiplyScalar(g.emissiveIntensity),g.map&&(b.map.value=g.map,n(g.map,b.mapTransform)),g.alphaMap&&(b.alphaMap.value=g.alphaMap,n(g.alphaMap,b.alphaMapTransform)),g.bumpMap&&(b.bumpMap.value=g.bumpMap,n(g.bumpMap,b.bumpMapTransform),b.bumpScale.value=g.bumpScale,g.side===zo&&(b.bumpScale.value*=-1)),g.normalMap&&(b.normalMap.value=g.normalMap,n(g.normalMap,b.normalMapTransform),b.normalScale.value.copy(g.normalScale),g.side===zo&&b.normalScale.value.negate()),g.displacementMap&&(b.displacementMap.value=g.displacementMap,n(g.displacementMap,b.displacementMapTransform),b.displacementScale.value=g.displacementScale,b.displacementBias.value=g.displacementBias),g.emissiveMap&&(b.emissiveMap.value=g.emissiveMap,n(g.emissiveMap,b.emissiveMapTransform)),g.specularMap&&(b.specularMap.value=g.specularMap,n(g.specularMap,b.specularMapTransform)),g.alphaTest>0&&(b.alphaTest.value=g.alphaTest);const _=e.get(g),C=_.envMap,P=_.envMapRotation;C&&(b.envMap.value=C,hf.copy(P),hf.x*=-1,hf.y*=-1,hf.z*=-1,C.isCubeTexture&&C.isRenderTargetTexture===!1&&(hf.y*=-1,hf.z*=-1),b.envMapRotation.value.setFromMatrix4(cke.makeRotationFromEuler(hf)),b.flipEnvMap.value=C.isCubeTexture&&C.isRenderTargetTexture===!1?-1:1,b.reflectivity.value=g.reflectivity,b.ior.value=g.ior,b.refractionRatio.value=g.refractionRatio),g.lightMap&&(b.lightMap.value=g.lightMap,b.lightMapIntensity.value=g.lightMapIntensity,n(g.lightMap,b.lightMapTransform)),g.aoMap&&(b.aoMap.value=g.aoMap,b.aoMapIntensity.value=g.aoMapIntensity,n(g.aoMap,b.aoMapTransform))}function o(b,g){b.diffuse.value.copy(g.color),b.opacity.value=g.opacity,g.map&&(b.map.value=g.map,n(g.map,b.mapTransform))}function l(b,g){b.dashSize.value=g.dashSize,b.totalSize.value=g.dashSize+g.gapSize,b.scale.value=g.scale}function c(b,g,_,C){b.diffuse.value.copy(g.color),b.opacity.value=g.opacity,b.size.value=g.size*_,b.scale.value=C*.5,g.map&&(b.map.value=g.map,n(g.map,b.uvTransform)),g.alphaMap&&(b.alphaMap.value=g.alphaMap,n(g.alphaMap,b.alphaMapTransform)),g.alphaTest>0&&(b.alphaTest.value=g.alphaTest)}function d(b,g){b.diffuse.value.copy(g.color),b.opacity.value=g.opacity,b.rotation.value=g.rotation,g.map&&(b.map.value=g.map,n(g.map,b.mapTransform)),g.alphaMap&&(b.alphaMap.value=g.alphaMap,n(g.alphaMap,b.alphaMapTransform)),g.alphaTest>0&&(b.alphaTest.value=g.alphaTest)}function u(b,g){b.specular.value.copy(g.specular),b.shininess.value=Math.max(g.shininess,1e-4)}function m(b,g){g.gradientMap&&(b.gradientMap.value=g.gradientMap)}function p(b,g){b.metalness.value=g.metalness,g.metalnessMap&&(b.metalnessMap.value=g.metalnessMap,n(g.metalnessMap,b.metalnessMapTransform)),b.roughness.value=g.roughness,g.roughnessMap&&(b.roughnessMap.value=g.roughnessMap,n(g.roughnessMap,b.roughnessMapTransform)),g.envMap&&(b.envMapIntensity.value=g.envMapIntensity)}function f(b,g,_){b.ior.value=g.ior,g.sheen>0&&(b.sheenColor.value.copy(g.sheenColor).multiplyScalar(g.sheen),b.sheenRoughness.value=g.sheenRoughness,g.sheenColorMap&&(b.sheenColorMap.value=g.sheenColorMap,n(g.sheenColorMap,b.sheenColorMapTransform)),g.sheenRoughnessMap&&(b.sheenRoughnessMap.value=g.sheenRoughnessMap,n(g.sheenRoughnessMap,b.sheenRoughnessMapTransform))),g.clearcoat>0&&(b.clearcoat.value=g.clearcoat,b.clearcoatRoughness.value=g.clearcoatRoughness,g.clearcoatMap&&(b.clearcoatMap.value=g.clearcoatMap,n(g.clearcoatMap,b.clearcoatMapTransform)),g.clearcoatRoughnessMap&&(b.clearcoatRoughnessMap.value=g.clearcoatRoughnessMap,n(g.clearcoatRoughnessMap,b.clearcoatRoughnessMapTransform)),g.clearcoatNormalMap&&(b.clearcoatNormalMap.value=g.clearcoatNormalMap,n(g.clearcoatNormalMap,b.clearcoatNormalMapTransform),b.clearcoatNormalScale.value.copy(g.clearcoatNormalScale),g.side===zo&&b.clearcoatNormalScale.value.negate())),g.dispersion>0&&(b.dispersion.value=g.dispersion),g.iridescence>0&&(b.iridescence.value=g.iridescence,b.iridescenceIOR.value=g.iridescenceIOR,b.iridescenceThicknessMinimum.value=g.iridescenceThicknessRange[0],b.iridescenceThicknessMaximum.value=g.iridescenceThicknessRange[1],g.iridescenceMap&&(b.iridescenceMap.value=g.iridescenceMap,n(g.iridescenceMap,b.iridescenceMapTransform)),g.iridescenceThicknessMap&&(b.iridescenceThicknessMap.value=g.iridescenceThicknessMap,n(g.iridescenceThicknessMap,b.iridescenceThicknessMapTransform))),g.transmission>0&&(b.transmission.value=g.transmission,b.transmissionSamplerMap.value=_.texture,b.transmissionSamplerSize.value.set(_.width,_.height),g.transmissionMap&&(b.transmissionMap.value=g.transmissionMap,n(g.transmissionMap,b.transmissionMapTransform)),b.thickness.value=g.thickness,g.thicknessMap&&(b.thicknessMap.value=g.thicknessMap,n(g.thicknessMap,b.thicknessMapTransform)),b.attenuationDistance.value=g.attenuationDistance,b.attenuationColor.value.copy(g.attenuationColor)),g.anisotropy>0&&(b.anisotropyVector.value.set(g.anisotropy*Math.cos(g.anisotropyRotation),g.anisotropy*Math.sin(g.anisotropyRotation)),g.anisotropyMap&&(b.anisotropyMap.value=g.anisotropyMap,n(g.anisotropyMap,b.anisotropyMapTransform))),b.specularIntensity.value=g.specularIntensity,b.specularColor.value.copy(g.specularColor),g.specularColorMap&&(b.specularColorMap.value=g.specularColorMap,n(g.specularColorMap,b.specularColorMapTransform)),g.specularIntensityMap&&(b.specularIntensityMap.value=g.specularIntensityMap,n(g.specularIntensityMap,b.specularIntensityMapTransform))}function y(b,g){g.matcap&&(b.matcap.value=g.matcap)}function v(b,g){const _=e.get(g).light;b.referencePosition.value.setFromMatrixPosition(_.matrixWorld),b.nearDistance.value=_.shadow.camera.near,b.farDistance.value=_.shadow.camera.far}return{refreshFogUniforms:r,refreshMaterialUniforms:i}}function uke(t,e,n,r){let i={},s={},o=[];const l=t.getParameter(t.MAX_UNIFORM_BUFFER_BINDINGS);function c(_,C){const P=C.program;r.uniformBlockBinding(_,P)}function d(_,C){let P=i[_.id];P===void 0&&(y(_),P=u(_),i[_.id]=P,_.addEventListener(\"dispose\",b));const N=C.program;r.updateUBOMapping(_,N);const A=e.render.frame;s[_.id]!==A&&(p(_),s[_.id]=A)}function u(_){const C=m();_.__bindingPointIndex=C;const P=t.createBuffer(),N=_.__size,A=_.usage;return t.bindBuffer(t.UNIFORM_BUFFER,P),t.bufferData(t.UNIFORM_BUFFER,N,A),t.bindBuffer(t.UNIFORM_BUFFER,null),t.bindBufferBase(t.UNIFORM_BUFFER,C,P),P}function m(){for(let _=0;_<l;_++)if(o.indexOf(_)===-1)return o.push(_),_;return oi(\"WebGLRenderer: Maximum number of simultaneously usable uniforms groups reached.\"),0}function p(_){const C=i[_.id],P=_.uniforms,N=_.__cache;t.bindBuffer(t.UNIFORM_BUFFER,C);for(let A=0,T=P.length;A<T;A++){const F=Array.isArray(P[A])?P[A]:[P[A]];for(let k=0,D=F.length;k<D;k++){const H=F[k];if(f(H,A,k,N)===!0){const z=H.__offset,Q=Array.isArray(H.value)?H.value:[H.value];let L=0;for(let te=0;te<Q.length;te++){const ie=Q[te],J=v(ie);typeof ie==\"number\"||typeof ie==\"boolean\"?(H.__data[0]=ie,t.bufferSubData(t.UNIFORM_BUFFER,z+L,H.__data)):ie.isMatrix3?(H.__data[0]=ie.elements[0],H.__data[1]=ie.elements[1],H.__data[2]=ie.elements[2],H.__data[3]=0,H.__data[4]=ie.elements[3],H.__data[5]=ie.elements[4],H.__data[6]=ie.elements[5],H.__data[7]=0,H.__data[8]=ie.elements[6],H.__data[9]=ie.elements[7],H.__data[10]=ie.elements[8],H.__data[11]=0):(ie.toArray(H.__data,L),L+=J.storage/Float32Array.BYTES_PER_ELEMENT)}t.bufferSubData(t.UNIFORM_BUFFER,z,H.__data)}}}t.bindBuffer(t.UNIFORM_BUFFER,null)}function f(_,C,P,N){const A=_.value,T=C+\"_\"+P;if(N[T]===void 0)return typeof A==\"number\"||typeof A==\"boolean\"?N[T]=A:N[T]=A.clone(),!0;{const F=N[T];if(typeof A==\"number\"||typeof A==\"boolean\"){if(F!==A)return N[T]=A,!0}else if(F.equals(A)===!1)return F.copy(A),!0}return!1}function y(_){const C=_.uniforms;let P=0;const N=16;for(let T=0,F=C.length;T<F;T++){const k=Array.isArray(C[T])?C[T]:[C[T]];for(let D=0,H=k.length;D<H;D++){const z=k[D],Q=Array.isArray(z.value)?z.value:[z.value];for(let L=0,te=Q.length;L<te;L++){const ie=Q[L],J=v(ie),oe=P%N,fe=oe%J.boundary,re=oe+fe;P+=fe,re!==0&&N-re<J.storage&&(P+=N-re),z.__data=new Float32Array(J.storage/Float32Array.BYTES_PER_ELEMENT),z.__offset=P,P+=J.storage}}}const A=P%N;return A>0&&(P+=N-A),_.__size=P,_.__cache={},this}function v(_){const C={boundary:0,storage:0};return typeof _==\"number\"||typeof _==\"boolean\"?(C.boundary=4,C.storage=4):_.isVector2?(C.boundary=8,C.storage=8):_.isVector3||_.isColor?(C.boundary=16,C.storage=12):_.isVector4?(C.boundary=16,C.storage=16):_.isMatrix3?(C.boundary=48,C.storage=48):_.isMatrix4?(C.boundary=64,C.storage=64):_.isTexture?qn(\"WebGLRenderer: Texture samplers can not be part of an uniforms group.\"):qn(\"WebGLRenderer: Unsupported uniform value type.\",_),C}function b(_){const C=_.target;C.removeEventListener(\"dispose\",b);const P=o.indexOf(C.__bindingPointIndex);o.splice(P,1),t.deleteBuffer(i[C.id]),delete i[C.id],delete s[C.id]}function g(){for(const _ in i)t.deleteBuffer(i[_]);o=[],i={},s={}}return{bind:c,update:d,dispose:g}}const mke=new Uint16Array([11481,15204,11534,15171,11808,15015,12385,14843,12894,14716,13396,14600,13693,14483,13976,14366,14237,14171,14405,13961,14511,13770,14605,13598,14687,13444,14760,13305,14822,13066,14876,12857,14923,12675,14963,12517,14997,12379,15025,12230,15049,12023,15070,11843,15086,11687,15100,11551,15111,11433,15120,11330,15127,11217,15132,11060,15135,10922,15138,10801,15139,10695,15139,10600,13012,14923,13020,14917,13064,14886,13176,14800,13349,14666,13513,14526,13724,14398,13960,14230,14200,14020,14383,13827,14488,13651,14583,13491,14667,13348,14740,13132,14803,12908,14856,12713,14901,12542,14938,12394,14968,12241,14992,12017,15010,11822,15024,11654,15034,11507,15041,11380,15044,11269,15044,11081,15042,10913,15037,10764,15031,10635,15023,10520,15014,10419,15003,10330,13657,14676,13658,14673,13670,14660,13698,14622,13750,14547,13834,14442,13956,14317,14112,14093,14291,13889,14407,13704,14499,13538,14586,13389,14664,13201,14733,12966,14792,12758,14842,12577,14882,12418,14915,12272,14940,12033,14959,11826,14972,11646,14980,11490,14983,11355,14983,11212,14979,11008,14971,10830,14961,10675,14950,10540,14936,10420,14923,10315,14909,10204,14894,10041,14089,14460,14090,14459,14096,14452,14112,14431,14141,14388,14186,14305,14252,14130,14341,13941,14399,13756,14467,13585,14539,13430,14610,13272,14677,13026,14737,12808,14790,12617,14833,12449,14869,12303,14896,12065,14916,11845,14929,11655,14937,11490,14939,11347,14936,11184,14930,10970,14921,10783,14912,10621,14900,10480,14885,10356,14867,10247,14848,10062,14827,9894,14805,9745,14400,14208,14400,14206,14402,14198,14406,14174,14415,14122,14427,14035,14444,13913,14469,13767,14504,13613,14548,13463,14598,13324,14651,13082,14704,12858,14752,12658,14795,12483,14831,12330,14860,12106,14881,11875,14895,11675,14903,11501,14905,11351,14903,11178,14900,10953,14892,10757,14880,10589,14865,10442,14847,10313,14827,10162,14805,9965,14782,9792,14757,9642,14731,9507,14562,13883,14562,13883,14563,13877,14566,13862,14570,13830,14576,13773,14584,13689,14595,13582,14613,13461,14637,13336,14668,13120,14704,12897,14741,12695,14776,12516,14808,12358,14835,12150,14856,11910,14870,11701,14878,11519,14882,11361,14884,11187,14880,10951,14871,10748,14858,10572,14842,10418,14823,10286,14801,10099,14777,9897,14751,9722,14725,9567,14696,9430,14666,9309,14702,13604,14702,13604,14702,13600,14703,13591,14705,13570,14707,13533,14709,13477,14712,13400,14718,13305,14727,13106,14743,12907,14762,12716,14784,12539,14807,12380,14827,12190,14844,11943,14855,11727,14863,11539,14870,11376,14871,11204,14868,10960,14858,10748,14845,10565,14829,10406,14809,10269,14786,10058,14761,9852,14734,9671,14705,9512,14674,9374,14641,9253,14608,9076,14821,13366,14821,13365,14821,13364,14821,13358,14821,13344,14821,13320,14819,13252,14817,13145,14815,13011,14814,12858,14817,12698,14823,12539,14832,12389,14841,12214,14850,11968,14856,11750,14861,11558,14866,11390,14867,11226,14862,10972,14853,10754,14840,10565,14823,10401,14803,10259,14780,10032,14754,9820,14725,9635,14694,9473,14661,9333,14627,9203,14593,8988,14557,8798,14923,13014,14922,13014,14922,13012,14922,13004,14920,12987,14919,12957,14915,12907,14909,12834,14902,12738,14894,12623,14888,12498,14883,12370,14880,12203,14878,11970,14875,11759,14873,11569,14874,11401,14872,11243,14865,10986,14855,10762,14842,10568,14825,10401,14804,10255,14781,10017,14754,9799,14725,9611,14692,9445,14658,9301,14623,9139,14587,8920,14548,8729,14509,8562,15008,12672,15008,12672,15008,12671,15007,12667,15005,12656,15001,12637,14997,12605,14989,12556,14978,12490,14966,12407,14953,12313,14940,12136,14927,11934,14914,11742,14903,11563,14896,11401,14889,11247,14879,10992,14866,10767,14851,10570,14833,10400,14812,10252,14789,10007,14761,9784,14731,9592,14698,9424,14663,9279,14627,9088,14588,8868,14548,8676,14508,8508,14467,8360,15080,12386,15080,12386,15079,12385,15078,12383,15076,12378,15072,12367,15066,12347,15057,12315,15045,12253,15030,12138,15012,11998,14993,11845,14972,11685,14951,11530,14935,11383,14920,11228,14904,10981,14887,10762,14870,10567,14850,10397,14827,10248,14803,9997,14774,9771,14743,9578,14710,9407,14674,9259,14637,9048,14596,8826,14555,8632,14514,8464,14471,8317,14427,8182,15139,12008,15139,12008,15138,12008,15137,12007,15135,12003,15130,11990,15124,11969,15115,11929,15102,11872,15086,11794,15064,11693,15041,11581,15013,11459,14987,11336,14966,11170,14944,10944,14921,10738,14898,10552,14875,10387,14850,10239,14824,9983,14794,9758,14762,9563,14728,9392,14692,9244,14653,9014,14611,8791,14569,8597,14526,8427,14481,8281,14436,8110,14391,7885,15188,11617,15188,11617,15187,11617,15186,11618,15183,11617,15179,11612,15173,11601,15163,11581,15150,11546,15133,11495,15110,11427,15083,11346,15051,11246,15024,11057,14996,10868,14967,10687,14938,10517,14911,10362,14882,10206,14853,9956,14821,9737,14787,9543,14752,9375,14715,9228,14675,8980,14632,8760,14589,8565,14544,8395,14498,8248,14451,8049,14404,7824,14357,7630,15228,11298,15228,11298,15227,11299,15226,11301,15223,11303,15219,11302,15213,11299,15204,11290,15191,11271,15174,11217,15150,11129,15119,11015,15087,10886,15057,10744,15024,10599,14990,10455,14957,10318,14924,10143,14891,9911,14856,9701,14820,9516,14782,9352,14744,9200,14703,8946,14659,8725,14615,8533,14568,8366,14521,8220,14472,7992,14423,7770,14374,7578,14315,7408,15260,10819,15260,10819,15259,10822,15258,10826,15256,10832,15251,10836,15246,10841,15237,10838,15225,10821,15207,10788,15183,10734,15151,10660,15120,10571,15087,10469,15049,10359,15012,10249,14974,10041,14937,9837,14900,9647,14860,9475,14820,9320,14779,9147,14736,8902,14691,8688,14646,8499,14598,8335,14549,8189,14499,7940,14448,7720,14397,7529,14347,7363,14256,7218,15285,10410,15285,10411,15285,10413,15284,10418,15282,10425,15278,10434,15272,10442,15264,10449,15252,10445,15235,10433,15210,10403,15179,10358,15149,10301,15113,10218,15073,10059,15033,9894,14991,9726,14951,9565,14909,9413,14865,9273,14822,9073,14777,8845,14730,8641,14682,8459,14633,8300,14583,8129,14531,7883,14479,7670,14426,7482,14373,7321,14305,7176,14201,6939,15305,9939,15305,9940,15305,9945,15304,9955,15302,9967,15298,9989,15293,10010,15286,10033,15274,10044,15258,10045,15233,10022,15205,9975,15174,9903,15136,9808,15095,9697,15053,9578,15009,9451,14965,9327,14918,9198,14871,8973,14825,8766,14775,8579,14725,8408,14675,8259,14622,8058,14569,7821,14515,7615,14460,7435,14405,7276,14350,7108,14256,6866,14149,6653,15321,9444,15321,9445,15321,9448,15320,9458,15317,9470,15314,9490,15310,9515,15302,9540,15292,9562,15276,9579,15251,9577,15226,9559,15195,9519,15156,9463,15116,9389,15071,9304,15025,9208,14978,9023,14927,8838,14878,8661,14827,8496,14774,8344,14722,8206,14667,7973,14612,7749,14556,7555,14499,7382,14443,7229,14385,7025,14322,6791,14210,6588,14100,6409,15333,8920,15333,8921,15332,8927,15332,8943,15329,8965,15326,9002,15322,9048,15316,9106,15307,9162,15291,9204,15267,9221,15244,9221,15212,9196,15175,9134,15133,9043,15088,8930,15040,8801,14990,8665,14938,8526,14886,8391,14830,8261,14775,8087,14719,7866,14661,7664,14603,7482,14544,7322,14485,7178,14426,6936,14367,6713,14281,6517,14166,6348,14054,6198,15341,8360,15341,8361,15341,8366,15341,8379,15339,8399,15336,8431,15332,8473,15326,8527,15318,8585,15302,8632,15281,8670,15258,8690,15227,8690,15191,8664,15149,8612,15104,8543,15055,8456,15001,8360,14948,8259,14892,8122,14834,7923,14776,7734,14716,7558,14656,7397,14595,7250,14534,7070,14472,6835,14410,6628,14350,6443,14243,6283,14125,6135,14010,5889,15348,7715,15348,7717,15348,7725,15347,7745,15345,7780,15343,7836,15339,7905,15334,8e3,15326,8103,15310,8193,15293,8239,15270,8270,15240,8287,15204,8283,15163,8260,15118,8223,15067,8143,15014,8014,14958,7873,14899,7723,14839,7573,14778,7430,14715,7293,14652,7164,14588,6931,14524,6720,14460,6531,14396,6362,14330,6210,14207,6015,14086,5781,13969,5576,15352,7114,15352,7116,15352,7128,15352,7159,15350,7195,15348,7237,15345,7299,15340,7374,15332,7457,15317,7544,15301,7633,15280,7703,15251,7754,15216,7775,15176,7767,15131,7733,15079,7670,15026,7588,14967,7492,14906,7387,14844,7278,14779,7171,14714,6965,14648,6770,14581,6587,14515,6420,14448,6269,14382,6123,14299,5881,14172,5665,14049,5477,13929,5310,15355,6329,15355,6330,15355,6339,15355,6362,15353,6410,15351,6472,15349,6572,15344,6688,15337,6835,15323,6985,15309,7142,15287,7220,15260,7277,15226,7310,15188,7326,15142,7318,15090,7285,15036,7239,14976,7177,14914,7045,14849,6892,14782,6736,14714,6581,14645,6433,14576,6293,14506,6164,14438,5946,14369,5733,14270,5540,14140,5369,14014,5216,13892,5043,15357,5483,15357,5484,15357,5496,15357,5528,15356,5597,15354,5692,15351,5835,15347,6011,15339,6195,15328,6317,15314,6446,15293,6566,15268,6668,15235,6746,15197,6796,15152,6811,15101,6790,15046,6748,14985,6673,14921,6583,14854,6479,14785,6371,14714,6259,14643,6149,14571,5946,14499,5750,14428,5567,14358,5401,14242,5250,14109,5111,13980,4870,13856,4657,15359,4555,15359,4557,15358,4573,15358,4633,15357,4715,15355,4841,15353,5061,15349,5216,15342,5391,15331,5577,15318,5770,15299,5967,15274,6150,15243,6223,15206,6280,15161,6310,15111,6317,15055,6300,14994,6262,14928,6208,14860,6141,14788,5994,14715,5838,14641,5684,14566,5529,14492,5384,14418,5247,14346,5121,14216,4892,14079,4682,13948,4496,13822,4330,15359,3498,15359,3501,15359,3520,15359,3598,15358,3719,15356,3860,15355,4137,15351,4305,15344,4563,15334,4809,15321,5116,15303,5273,15280,5418,15250,5547,15214,5653,15170,5722,15120,5761,15064,5763,15002,5733,14935,5673,14865,5597,14792,5504,14716,5400,14640,5294,14563,5185,14486,5041,14410,4841,14335,4655,14191,4482,14051,4325,13918,4183,13790,4012,15360,2282,15360,2285,15360,2306,15360,2401,15359,2547,15357,2748,15355,3103,15352,3349,15345,3675,15336,4020,15324,4272,15307,4496,15285,4716,15255,4908,15220,5086,15178,5170,15128,5214,15072,5234,15010,5231,14943,5206,14871,5166,14796,5102,14718,4971,14639,4833,14559,4687,14480,4541,14402,4401,14315,4268,14167,4142,14025,3958,13888,3747,13759,3556,15360,923,15360,925,15360,946,15360,1052,15359,1214,15357,1494,15356,1892,15352,2274,15346,2663,15338,3099,15326,3393,15309,3679,15288,3980,15260,4183,15226,4325,15185,4437,15136,4517,15080,4570,15018,4591,14950,4581,14877,4545,14800,4485,14720,4411,14638,4325,14556,4231,14475,4136,14395,3988,14297,3803,14145,3628,13999,3465,13861,3314,13729,3177,15360,263,15360,264,15360,272,15360,325,15359,407,15358,548,15356,780,15352,1144,15347,1580,15339,2099,15328,2425,15312,2795,15292,3133,15264,3329,15232,3517,15191,3689,15143,3819,15088,3923,15025,3978,14956,3999,14882,3979,14804,3931,14722,3855,14639,3756,14554,3645,14470,3529,14388,3409,14279,3289,14124,3173,13975,3055,13834,2848,13701,2658,15360,49,15360,49,15360,52,15360,75,15359,111,15358,201,15356,283,15353,519,15348,726,15340,1045,15329,1415,15314,1795,15295,2173,15269,2410,15237,2649,15197,2866,15150,3054,15095,3140,15032,3196,14963,3228,14888,3236,14808,3224,14725,3191,14639,3146,14553,3088,14466,2976,14382,2836,14262,2692,14103,2549,13952,2409,13808,2278,13674,2154,15360,4,15360,4,15360,4,15360,13,15359,33,15358,59,15357,112,15353,199,15348,302,15341,456,15331,628,15316,827,15297,1082,15272,1332,15241,1601,15202,1851,15156,2069,15101,2172,15039,2256,14970,2314,14894,2348,14813,2358,14728,2344,14640,2311,14551,2263,14463,2203,14376,2133,14247,2059,14084,1915,13930,1761,13784,1609,13648,1464,15360,0,15360,0,15360,0,15360,3,15359,18,15358,26,15357,53,15354,80,15348,97,15341,165,15332,238,15318,326,15299,427,15275,529,15245,654,15207,771,15161,885,15108,994,15046,1089,14976,1170,14900,1229,14817,1266,14731,1284,14641,1282,14550,1260,14460,1223,14370,1174,14232,1116,14066,1050,13909,981,13761,910,13623,839]);let Au=null;function hke(){return Au===null&&(Au=new d0e(mke,32,32,YO,_y),Au.minFilter=ec,Au.magFilter=ec,Au.wrapS=Ju,Au.wrapT=Ju,Au.generateMipmaps=!1,Au.needsUpdate=!0),Au}let pke=class{constructor(e={}){const{canvas:n=Rve(),context:r=null,depth:i=!0,stencil:s=!1,alpha:o=!1,antialias:l=!1,premultipliedAlpha:c=!0,preserveDrawingBuffer:d=!1,powerPreference:u=\"default\",failIfMajorPerformanceCaveat:m=!1,reversedDepthBuffer:p=!1}=e;this.isWebGLRenderer=!0;let f;if(r!==null){if(typeof WebGLRenderingContext<\"u\"&&r instanceof WebGLRenderingContext)throw new Error(\"THREE.WebGLRenderer: WebGL 1 is not supported since r163.\");f=r.getContextAttributes().alpha}else f=o;const y=new Set([ZO,QO,XO]),v=new Set([Hd,fg,lw,cw,WO,KO]),b=new Uint32Array(4),g=new Int32Array(4);let _=null,C=null;const P=[],N=[];this.domElement=n,this.debug={checkShaderErrors:!0,onShaderError:null},this.autoClear=!0,this.autoClearColor=!0,this.autoClearDepth=!0,this.autoClearStencil=!0,this.sortObjects=!0,this.clippingPlanes=[],this.localClippingEnabled=!1,this.toneMapping=ip,this.toneMappingExposure=1,this.transmissionResolutionScale=1;const A=this;let T=!1;this._outputColorSpace=ol;let F=0,k=0,D=null,H=-1,z=null;const Q=new yi,L=new yi;let te=null;const ie=new or(0);let J=0,oe=n.width,fe=n.height,re=1,W=null,ne=null;const me=new yi(0,0,oe,fe),be=new yi(0,0,oe,fe);let Ce=!1;const q=new rI;let Y=!1,E=!1;const j=new _i,O=new Ct,K=new yi,U={background:null,fog:null,environment:null,overrideMaterial:null,isScene:!0};let de=!1;function I(){return D===null?re:1}let G=r;function X(Pe,Ge){return n.getContext(Pe,Ge)}try{const Pe={alpha:!0,depth:i,stencil:s,antialias:l,premultipliedAlpha:c,preserveDrawingBuffer:d,powerPreference:u,failIfMajorPerformanceCaveat:m};if(\"setAttribute\"in n&&n.setAttribute(\"data-engine\",`three.js r${$O}`),n.addEventListener(\"webglcontextlost\",Ae,!1),n.addEventListener(\"webglcontextrestored\",ce,!1),n.addEventListener(\"webglcontextcreationerror\",xe,!1),G===null){const Ge=\"webgl2\";if(G=X(Ge,Pe),G===null)throw X(Ge)?new Error(\"Error creating WebGL context with your selected attributes.\"):new Error(\"Error creating WebGL context.\")}}catch(Pe){throw Pe(\"WebGLRenderer: \"+Pe.message),Pe}let V,ee,se,ge,he,le,B,R,ae,_e,Se,ve,Te,ye,je,Le,Me,Oe,Re,$e,Ye,tt,pe,Fe;function we(){V=new S_e(G),V.init(),tt=new ake(G,V),ee=new h_e(G,V,e,tt),se=new nke(G,V),ee.reversedDepthBuffer&&p&&se.buffers.depth.setReversed(!0),ge=new N_e(G),he=new q1e,le=new rke(G,V,se,he,ee,tt,ge),B=new f_e(A),R=new w_e(A),ae=new A0e(G),pe=new u_e(G,ae),_e=new __e(G,ae,ge,pe),Se=new P_e(G,_e,ae,ge),Re=new C_e(G,ee,le),Le=new p_e(he),ve=new H1e(A,B,R,V,ee,pe,Le),Te=new dke(A,he),ye=new V1e,je=new Q1e(V),Oe=new d_e(A,B,R,se,Se,f,c),Me=new eke(A,Se,ee),Fe=new uke(G,ge,ee,se),$e=new m_e(G,V,ge),Ye=new k_e(G,V,ge),ge.programs=ve.programs,A.capabilities=ee,A.extensions=V,A.properties=he,A.renderLists=ye,A.shadowMap=Me,A.state=se,A.info=ge}we();const Ve=new lke(A,G);this.xr=Ve,this.getContext=function(){return G},this.getContextAttributes=function(){return G.getContextAttributes()},this.forceContextLoss=function(){const Pe=V.get(\"WEBGL_lose_context\");Pe&&Pe.loseContext()},this.forceContextRestore=function(){const Pe=V.get(\"WEBGL_lose_context\");Pe&&Pe.restoreContext()},this.getPixelRatio=function(){return re},this.setPixelRatio=function(Pe){Pe!==void 0&&(re=Pe,this.setSize(oe,fe,!1))},this.getSize=function(Pe){return Pe.set(oe,fe)},this.setSize=function(Pe,Ge,Ze=!0){if(Ve.isPresenting){qn(\"WebGLRenderer: Can't change size while VR device is presenting.\");return}oe=Pe,fe=Ge,n.width=Math.floor(Pe*re),n.height=Math.floor(Ge*re),Ze===!0&&(n.style.width=Pe+\"px\",n.style.height=Ge+\"px\"),this.setViewport(0,0,Pe,Ge)},this.getDrawingBufferSize=function(Pe){return Pe.set(oe*re,fe*re).floor()},this.setDrawingBufferSize=function(Pe,Ge,Ze){oe=Pe,fe=Ge,re=Ze,n.width=Math.floor(Pe*Ze),n.height=Math.floor(Ge*Ze),this.setViewport(0,0,Pe,Ge)},this.getCurrentViewport=function(Pe){return Pe.copy(Q)},this.getViewport=function(Pe){return Pe.copy(me)},this.setViewport=function(Pe,Ge,Ze,Je){Pe.isVector4?me.set(Pe.x,Pe.y,Pe.z,Pe.w):me.set(Pe,Ge,Ze,Je),se.viewport(Q.copy(me).multiplyScalar(re).round())},this.getScissor=function(Pe){return Pe.copy(be)},this.setScissor=function(Pe,Ge,Ze,Je){Pe.isVector4?be.set(Pe.x,Pe.y,Pe.z,Pe.w):be.set(Pe,Ge,Ze,Je),se.scissor(L.copy(be).multiplyScalar(re).round())},this.getScissorTest=function(){return Ce},this.setScissorTest=function(Pe){se.setScissorTest(Ce=Pe)},this.setOpaqueSort=function(Pe){W=Pe},this.setTransparentSort=function(Pe){ne=Pe},this.getClearColor=function(Pe){return Pe.copy(Oe.getClearColor())},this.setClearColor=function(){Oe.setClearColor(...arguments)},this.getClearAlpha=function(){return Oe.getClearAlpha()},this.setClearAlpha=function(){Oe.setClearAlpha(...arguments)},this.clear=function(Pe=!0,Ge=!0,Ze=!0){let Je=0;if(Pe){let We=!1;if(D!==null){const Ue=D.texture.format;We=y.has(Ue)}if(We){const Ue=D.texture.type,et=v.has(Ue),jt=Oe.getClearColor(),yt=Oe.getClearAlpha(),qe=jt.r,St=jt.g,Pt=jt.b;et?(b[0]=qe,b[1]=St,b[2]=Pt,b[3]=yt,G.clearBufferuiv(G.COLOR,0,b)):(g[0]=qe,g[1]=St,g[2]=Pt,g[3]=yt,G.clearBufferiv(G.COLOR,0,g))}else Je|=G.COLOR_BUFFER_BIT}Ge&&(Je|=G.DEPTH_BUFFER_BIT),Ze&&(Je|=G.STENCIL_BUFFER_BIT,this.state.buffers.stencil.setMask(4294967295)),G.clear(Je)},this.clearColor=function(){this.clear(!0,!1,!1)},this.clearDepth=function(){this.clear(!1,!0,!1)},this.clearStencil=function(){this.clear(!1,!1,!0)},this.dispose=function(){n.removeEventListener(\"webglcontextlost\",Ae,!1),n.removeEventListener(\"webglcontextrestored\",ce,!1),n.removeEventListener(\"webglcontextcreationerror\",xe,!1),Oe.dispose(),ye.dispose(),je.dispose(),he.dispose(),B.dispose(),R.dispose(),Se.dispose(),pe.dispose(),Fe.dispose(),ve.dispose(),Ve.dispose(),Ve.removeEventListener(\"sessionstart\",Wt),Ve.removeEventListener(\"sessionend\",Zt),Kt.stop()};function Ae(Pe){Pe.preventDefault(),KH(\"WebGLRenderer: Context Lost.\"),T=!0}function ce(){KH(\"WebGLRenderer: Context Restored.\"),T=!1;const Pe=ge.autoReset,Ge=Me.enabled,Ze=Me.autoUpdate,Je=Me.needsUpdate,We=Me.type;we(),ge.autoReset=Pe,Me.enabled=Ge,Me.autoUpdate=Ze,Me.needsUpdate=Je,Me.type=We}function xe(Pe){oi(\"WebGLRenderer: A WebGL context could not be created. Reason: \",Pe.statusMessage)}function Be(Pe){const Ge=Pe.target;Ge.removeEventListener(\"dispose\",Be),Qe(Ge)}function Qe(Pe){ht(Pe),he.remove(Pe)}function ht(Pe){const Ge=he.get(Pe).programs;Ge!==void 0&&(Ge.forEach(function(Ze){ve.releaseProgram(Ze)}),Pe.isShaderMaterial&&ve.releaseShaderCache(Pe))}this.renderBufferDirect=function(Pe,Ge,Ze,Je,We,Ue){Ge===null&&(Ge=U);const et=We.isMesh&&We.matrixWorld.determinant()<0,jt=Ie(Pe,Ge,Ze,Je,We);se.setMaterial(Je,et);let yt=Ze.index,qe=1;if(Je.wireframe===!0){if(yt=_e.getWireframeAttribute(Ze),yt===void 0)return;qe=2}const St=Ze.drawRange,Pt=Ze.attributes.position;let qt=St.start*qe,on=(St.start+St.count)*qe;Ue!==null&&(qt=Math.max(qt,Ue.start*qe),on=Math.min(on,(Ue.start+Ue.count)*qe)),yt!==null?(qt=Math.max(qt,0),on=Math.min(on,yt.count)):Pt!=null&&(qt=Math.max(qt,0),on=Math.min(on,Pt.count));const dn=on-qt;if(dn<0||dn===1/0)return;pe.setup(We,Je,jt,Ze,yt);let Nn,bn=$e;if(yt!==null&&(Nn=ae.get(yt),bn=Ye,bn.setIndex(Nn)),We.isMesh)Je.wireframe===!0?(se.setLineWidth(Je.wireframeLinewidth*I()),bn.setMode(G.LINES)):bn.setMode(G.TRIANGLES);else if(We.isLine){let un=Je.linewidth;un===void 0&&(un=1),se.setLineWidth(un*I()),We.isLineSegments?bn.setMode(G.LINES):We.isLineLoop?bn.setMode(G.LINE_LOOP):bn.setMode(G.LINE_STRIP)}else We.isPoints?bn.setMode(G.POINTS):We.isSprite&&bn.setMode(G.TRIANGLES);if(We.isBatchedMesh)if(We._multiDrawInstances!==null)mw(\"WebGLRenderer: renderMultiDrawInstances has been deprecated and will be removed in r184. Append to renderMultiDraw arguments and use indirection.\"),bn.renderMultiDrawInstances(We._multiDrawStarts,We._multiDrawCounts,We._multiDrawCount,We._multiDrawInstances);else if(V.get(\"WEBGL_multi_draw\"))bn.renderMultiDraw(We._multiDrawStarts,We._multiDrawCounts,We._multiDrawCount);else{const un=We._multiDrawStarts,wn=We._multiDrawCounts,pn=We._multiDrawCount,gr=yt?ae.get(yt).bytesPerElement:1,Nr=he.get(Je).currentProgram.getUniforms();for(let Fn=0;Fn<pn;Fn++)Nr.setValue(G,\"_gl_DrawID\",Fn),bn.render(un[Fn]/gr,wn[Fn])}else if(We.isInstancedMesh)bn.renderInstances(qt,dn,We.count);else if(Ze.isInstancedBufferGeometry){const un=Ze._maxInstanceCount!==void 0?Ze._maxInstanceCount:1/0,wn=Math.min(Ze.instanceCount,un);bn.renderInstances(qt,dn,wn)}else bn.render(qt,dn)};function xt(Pe,Ge,Ze){Pe.transparent===!0&&Pe.side===Pd&&Pe.forceSinglePass===!1?(Pe.side=zo,Pe.needsUpdate=!0,Lt(Pe,Ge,Ze),Pe.side=bp,Pe.needsUpdate=!0,Lt(Pe,Ge,Ze),Pe.side=Pd):Lt(Pe,Ge,Ze)}this.compile=function(Pe,Ge,Ze=null){Ze===null&&(Ze=Pe),C=je.get(Ze),C.init(Ge),N.push(C),Ze.traverseVisible(function(We){We.isLight&&We.layers.test(Ge.layers)&&(C.pushLight(We),We.castShadow&&C.pushShadow(We))}),Pe!==Ze&&Pe.traverseVisible(function(We){We.isLight&&We.layers.test(Ge.layers)&&(C.pushLight(We),We.castShadow&&C.pushShadow(We))}),C.setupLights();const Je=new Set;return Pe.traverse(function(We){if(!(We.isMesh||We.isPoints||We.isLine||We.isSprite))return;const Ue=We.material;if(Ue)if(Array.isArray(Ue))for(let et=0;et<Ue.length;et++){const jt=Ue[et];xt(jt,Ze,We),Je.add(jt)}else xt(Ue,Ze,We),Je.add(Ue)}),C=N.pop(),Je},this.compileAsync=function(Pe,Ge,Ze=null){const Je=this.compile(Pe,Ge,Ze);return new Promise(We=>{function Ue(){if(Je.forEach(function(et){he.get(et).currentProgram.isReady()&&Je.delete(et)}),Je.size===0){We(Pe);return}setTimeout(Ue,10)}V.get(\"KHR_parallel_shader_compile\")!==null?Ue():setTimeout(Ue,10)})};let gt=null;function Ut(Pe){gt&&gt(Pe)}function Wt(){Kt.stop()}function Zt(){Kt.start()}const Kt=new HZ;Kt.setAnimationLoop(Ut),typeof self<\"u\"&&Kt.setContext(self),this.setAnimationLoop=function(Pe){gt=Pe,Ve.setAnimationLoop(Pe),Pe===null?Kt.stop():Kt.start()},Ve.addEventListener(\"sessionstart\",Wt),Ve.addEventListener(\"sessionend\",Zt),this.render=function(Pe,Ge){if(Ge!==void 0&&Ge.isCamera!==!0){oi(\"WebGLRenderer.render: camera is not an instance of THREE.Camera.\");return}if(T===!0)return;if(Pe.matrixWorldAutoUpdate===!0&&Pe.updateMatrixWorld(),Ge.parent===null&&Ge.matrixWorldAutoUpdate===!0&&Ge.updateMatrixWorld(),Ve.enabled===!0&&Ve.isPresenting===!0&&(Ve.cameraAutoUpdate===!0&&Ve.updateCamera(Ge),Ge=Ve.getCamera()),Pe.isScene===!0&&Pe.onBeforeRender(A,Pe,Ge,D),C=je.get(Pe,N.length),C.init(Ge),N.push(C),j.multiplyMatrices(Ge.projectionMatrix,Ge.matrixWorldInverse),q.setFromProjectionMatrix(j,Md,Ge.reversedDepth),E=this.localClippingEnabled,Y=Le.init(this.clippingPlanes,E),_=ye.get(Pe,P.length),_.init(),P.push(_),Ve.enabled===!0&&Ve.isPresenting===!0){const Ue=A.xr.getDepthSensingMesh();Ue!==null&&Xt(Ue,Ge,-1/0,A.sortObjects)}Xt(Pe,Ge,0,A.sortObjects),_.finish(),A.sortObjects===!0&&_.sort(W,ne),de=Ve.enabled===!1||Ve.isPresenting===!1||Ve.hasDepthSensing()===!1,de&&Oe.addToRenderList(_,Pe),this.info.render.frame++,Y===!0&&Le.beginShadows();const Ze=C.state.shadowsArray;Me.render(Ze,Pe,Ge),Y===!0&&Le.endShadows(),this.info.autoReset===!0&&this.info.reset();const Je=_.opaque,We=_.transmissive;if(C.setupLights(),Ge.isArrayCamera){const Ue=Ge.cameras;if(We.length>0)for(let et=0,jt=Ue.length;et<jt;et++){const yt=Ue[et];vn(Je,We,Pe,yt)}de&&Oe.render(Pe);for(let et=0,jt=Ue.length;et<jt;et++){const yt=Ue[et];ln(_,Pe,yt,yt.viewport)}}else We.length>0&&vn(Je,We,Pe,Ge),de&&Oe.render(Pe),ln(_,Pe,Ge);D!==null&&k===0&&(le.updateMultisampleRenderTarget(D),le.updateRenderTargetMipmap(D)),Pe.isScene===!0&&Pe.onAfterRender(A,Pe,Ge),pe.resetDefaultState(),H=-1,z=null,N.pop(),N.length>0?(C=N[N.length-1],Y===!0&&Le.setGlobalState(A.clippingPlanes,C.state.camera)):C=null,P.pop(),P.length>0?_=P[P.length-1]:_=null};function Xt(Pe,Ge,Ze,Je){if(Pe.visible===!1)return;if(Pe.layers.test(Ge.layers)){if(Pe.isGroup)Ze=Pe.renderOrder;else if(Pe.isLOD)Pe.autoUpdate===!0&&Pe.update(Ge);else if(Pe.isLight)C.pushLight(Pe),Pe.castShadow&&C.pushShadow(Pe);else if(Pe.isSprite){if(!Pe.frustumCulled||q.intersectsSprite(Pe)){Je&&K.setFromMatrixPosition(Pe.matrixWorld).applyMatrix4(j);const et=Se.update(Pe),jt=Pe.material;jt.visible&&_.push(Pe,et,jt,Ze,K.z,null)}}else if((Pe.isMesh||Pe.isLine||Pe.isPoints)&&(!Pe.frustumCulled||q.intersectsObject(Pe))){const et=Se.update(Pe),jt=Pe.material;if(Je&&(Pe.boundingSphere!==void 0?(Pe.boundingSphere===null&&Pe.computeBoundingSphere(),K.copy(Pe.boundingSphere.center)):(et.boundingSphere===null&&et.computeBoundingSphere(),K.copy(et.boundingSphere.center)),K.applyMatrix4(Pe.matrixWorld).applyMatrix4(j)),Array.isArray(jt)){const yt=et.groups;for(let qe=0,St=yt.length;qe<St;qe++){const Pt=yt[qe],qt=jt[Pt.materialIndex];qt&&qt.visible&&_.push(Pe,et,qt,Ze,K.z,Pt)}}else jt.visible&&_.push(Pe,et,jt,Ze,K.z,null)}}const Ue=Pe.children;for(let et=0,jt=Ue.length;et<jt;et++)Xt(Ue[et],Ge,Ze,Je)}function ln(Pe,Ge,Ze,Je){const{opaque:We,transmissive:Ue,transparent:et}=Pe;C.setupLightsView(Ze),Y===!0&&Le.setGlobalState(A.clippingPlanes,Ze),Je&&se.viewport(Q.copy(Je)),We.length>0&&Ke(We,Ge,Ze),Ue.length>0&&Ke(Ue,Ge,Ze),et.length>0&&Ke(et,Ge,Ze),se.buffers.depth.setTest(!0),se.buffers.depth.setMask(!0),se.buffers.color.setMask(!0),se.setPolygonOffset(!1)}function vn(Pe,Ge,Ze,Je){if((Ze.isScene===!0?Ze.overrideMaterial:null)!==null)return;C.state.transmissionRenderTarget[Je.id]===void 0&&(C.state.transmissionRenderTarget[Je.id]=new bg(1,1,{generateMipmaps:!0,type:V.has(\"EXT_color_buffer_half_float\")||V.has(\"EXT_color_buffer_float\")?_y:Hd,minFilter:Rf,samples:4,stencilBuffer:s,resolveDepthBuffer:!1,resolveStencilBuffer:!1,colorSpace:Mr.workingColorSpace}));const Ue=C.state.transmissionRenderTarget[Je.id],et=Je.viewport||Q;Ue.setSize(et.z*A.transmissionResolutionScale,et.w*A.transmissionResolutionScale);const jt=A.getRenderTarget(),yt=A.getActiveCubeFace(),qe=A.getActiveMipmapLevel();A.setRenderTarget(Ue),A.getClearColor(ie),J=A.getClearAlpha(),J<1&&A.setClearColor(16777215,.5),A.clear(),de&&Oe.render(Ze);const St=A.toneMapping;A.toneMapping=ip;const Pt=Je.viewport;if(Je.viewport!==void 0&&(Je.viewport=void 0),C.setupLightsView(Je),Y===!0&&Le.setGlobalState(A.clippingPlanes,Je),Ke(Pe,Ze,Je),le.updateMultisampleRenderTarget(Ue),le.updateRenderTargetMipmap(Ue),V.has(\"WEBGL_multisampled_render_to_texture\")===!1){let qt=!1;for(let on=0,dn=Ge.length;on<dn;on++){const Nn=Ge[on],{object:bn,geometry:un,material:wn,group:pn}=Nn;if(wn.side===Pd&&bn.layers.test(Je.layers)){const gr=wn.side;wn.side=zo,wn.needsUpdate=!0,at(bn,Ze,Je,un,wn,pn),wn.side=gr,wn.needsUpdate=!0,qt=!0}}qt===!0&&(le.updateMultisampleRenderTarget(Ue),le.updateRenderTargetMipmap(Ue))}A.setRenderTarget(jt,yt,qe),A.setClearColor(ie,J),Pt!==void 0&&(Je.viewport=Pt),A.toneMapping=St}function Ke(Pe,Ge,Ze){const Je=Ge.isScene===!0?Ge.overrideMaterial:null;for(let We=0,Ue=Pe.length;We<Ue;We++){const et=Pe[We],{object:jt,geometry:yt,group:qe}=et;let St=et.material;St.allowOverride===!0&&Je!==null&&(St=Je),jt.layers.test(Ze.layers)&&at(jt,Ge,Ze,yt,St,qe)}}function at(Pe,Ge,Ze,Je,We,Ue){Pe.onBeforeRender(A,Ge,Ze,Je,We,Ue),Pe.modelViewMatrix.multiplyMatrices(Ze.matrixWorldInverse,Pe.matrixWorld),Pe.normalMatrix.getNormalMatrix(Pe.modelViewMatrix),We.onBeforeRender(A,Ge,Ze,Je,Pe,Ue),We.transparent===!0&&We.side===Pd&&We.forceSinglePass===!1?(We.side=zo,We.needsUpdate=!0,A.renderBufferDirect(Ze,Ge,Je,We,Pe,Ue),We.side=bp,We.needsUpdate=!0,A.renderBufferDirect(Ze,Ge,Je,We,Pe,Ue),We.side=Pd):A.renderBufferDirect(Ze,Ge,Je,We,Pe,Ue),Pe.onAfterRender(A,Ge,Ze,Je,We,Ue)}function Lt(Pe,Ge,Ze){Ge.isScene!==!0&&(Ge=U);const Je=he.get(Pe),We=C.state.lights,Ue=C.state.shadowsArray,et=We.state.version,jt=ve.getParameters(Pe,We.state,Ue,Ge,Ze),yt=ve.getProgramCacheKey(jt);let qe=Je.programs;Je.environment=Pe.isMeshStandardMaterial?Ge.environment:null,Je.fog=Ge.fog,Je.envMap=(Pe.isMeshStandardMaterial?R:B).get(Pe.envMap||Je.environment),Je.envMapRotation=Je.environment!==null&&Pe.envMap===null?Ge.environmentRotation:Pe.envMapRotation,qe===void 0&&(Pe.addEventListener(\"dispose\",Be),qe=new Map,Je.programs=qe);let St=qe.get(yt);if(St!==void 0){if(Je.currentProgram===St&&Je.lightsStateVersion===et)return At(Pe,jt),St}else jt.uniforms=ve.getUniforms(Pe),Pe.onBeforeCompile(jt,A),St=ve.acquireProgram(jt,yt),qe.set(yt,St),Je.uniforms=jt.uniforms;const Pt=Je.uniforms;return(!Pe.isShaderMaterial&&!Pe.isRawShaderMaterial||Pe.clipping===!0)&&(Pt.clippingPlanes=Le.uniform),At(Pe,jt),Je.needsLights=pt(Pe),Je.lightsStateVersion=et,Je.needsLights&&(Pt.ambientLightColor.value=We.state.ambient,Pt.lightProbe.value=We.state.probe,Pt.directionalLights.value=We.state.directional,Pt.directionalLightShadows.value=We.state.directionalShadow,Pt.spotLights.value=We.state.spot,Pt.spotLightShadows.value=We.state.spotShadow,Pt.rectAreaLights.value=We.state.rectArea,Pt.ltc_1.value=We.state.rectAreaLTC1,Pt.ltc_2.value=We.state.rectAreaLTC2,Pt.pointLights.value=We.state.point,Pt.pointLightShadows.value=We.state.pointShadow,Pt.hemisphereLights.value=We.state.hemi,Pt.directionalShadowMap.value=We.state.directionalShadowMap,Pt.directionalShadowMatrix.value=We.state.directionalShadowMatrix,Pt.spotShadowMap.value=We.state.spotShadowMap,Pt.spotLightMatrix.value=We.state.spotLightMatrix,Pt.spotLightMap.value=We.state.spotLightMap,Pt.pointShadowMap.value=We.state.pointShadowMap,Pt.pointShadowMatrix.value=We.state.pointShadowMatrix),Je.currentProgram=St,Je.uniformsList=null,St}function Et(Pe){if(Pe.uniformsList===null){const Ge=Pe.currentProgram.getUniforms();Pe.uniformsList=Wk.seqWithValue(Ge.seq,Pe.uniforms)}return Pe.uniformsList}function At(Pe,Ge){const Ze=he.get(Pe);Ze.outputColorSpace=Ge.outputColorSpace,Ze.batching=Ge.batching,Ze.batchingColor=Ge.batchingColor,Ze.instancing=Ge.instancing,Ze.instancingColor=Ge.instancingColor,Ze.instancingMorph=Ge.instancingMorph,Ze.skinning=Ge.skinning,Ze.morphTargets=Ge.morphTargets,Ze.morphNormals=Ge.morphNormals,Ze.morphColors=Ge.morphColors,Ze.morphTargetsCount=Ge.morphTargetsCount,Ze.numClippingPlanes=Ge.numClippingPlanes,Ze.numIntersection=Ge.numClipIntersection,Ze.vertexAlphas=Ge.vertexAlphas,Ze.vertexTangents=Ge.vertexTangents,Ze.toneMapping=Ge.toneMapping}function Ie(Pe,Ge,Ze,Je,We){Ge.isScene!==!0&&(Ge=U),le.resetTextureUnits();const Ue=Ge.fog,et=Je.isMeshStandardMaterial?Ge.environment:null,jt=D===null?A.outputColorSpace:D.isXRRenderTarget===!0?D.texture.colorSpace:Kx,yt=(Je.isMeshStandardMaterial?R:B).get(Je.envMap||et),qe=Je.vertexColors===!0&&!!Ze.attributes.color&&Ze.attributes.color.itemSize===4,St=!!Ze.attributes.tangent&&(!!Je.normalMap||Je.anisotropy>0),Pt=!!Ze.morphAttributes.position,qt=!!Ze.morphAttributes.normal,on=!!Ze.morphAttributes.color;let dn=ip;Je.toneMapped&&(D===null||D.isXRRenderTarget===!0)&&(dn=A.toneMapping);const Nn=Ze.morphAttributes.position||Ze.morphAttributes.normal||Ze.morphAttributes.color,bn=Nn!==void 0?Nn.length:0,un=he.get(Je),wn=C.state.lights;if(Y===!0&&(E===!0||Pe!==z)){const Fr=Pe===z&&Je.id===H;Le.setState(Je,Pe,Fr)}let pn=!1;Je.version===un.__version?(un.needsLights&&un.lightsStateVersion!==wn.state.version||un.outputColorSpace!==jt||We.isBatchedMesh&&un.batching===!1||!We.isBatchedMesh&&un.batching===!0||We.isBatchedMesh&&un.batchingColor===!0&&We.colorTexture===null||We.isBatchedMesh&&un.batchingColor===!1&&We.colorTexture!==null||We.isInstancedMesh&&un.instancing===!1||!We.isInstancedMesh&&un.instancing===!0||We.isSkinnedMesh&&un.skinning===!1||!We.isSkinnedMesh&&un.skinning===!0||We.isInstancedMesh&&un.instancingColor===!0&&We.instanceColor===null||We.isInstancedMesh&&un.instancingColor===!1&&We.instanceColor!==null||We.isInstancedMesh&&un.instancingMorph===!0&&We.morphTexture===null||We.isInstancedMesh&&un.instancingMorph===!1&&We.morphTexture!==null||un.envMap!==yt||Je.fog===!0&&un.fog!==Ue||un.numClippingPlanes!==void 0&&(un.numClippingPlanes!==Le.numPlanes||un.numIntersection!==Le.numIntersection)||un.vertexAlphas!==qe||un.vertexTangents!==St||un.morphTargets!==Pt||un.morphNormals!==qt||un.morphColors!==on||un.toneMapping!==dn||un.morphTargetsCount!==bn)&&(pn=!0):(pn=!0,un.__version=Je.version);let gr=un.currentProgram;pn===!0&&(gr=Lt(Je,Ge,We));let Nr=!1,Fn=!1,Ba=!1;const In=gr.getUniforms(),ma=un.uniforms;if(se.useProgram(gr.program)&&(Nr=!0,Fn=!0,Ba=!0),Je.id!==H&&(H=Je.id,Fn=!0),Nr||z!==Pe){se.buffers.depth.getReversed()&&Pe.reversedDepth!==!0&&(Pe._reversedDepth=!0,Pe.updateProjectionMatrix()),In.setValue(G,\"projectionMatrix\",Pe.projectionMatrix),In.setValue(G,\"viewMatrix\",Pe.matrixWorldInverse);const Br=In.map.cameraPosition;Br!==void 0&&Br.setValue(G,O.setFromMatrixPosition(Pe.matrixWorld)),ee.logarithmicDepthBuffer&&In.setValue(G,\"logDepthBufFC\",2/(Math.log(Pe.far+1)/Math.LN2)),(Je.isMeshPhongMaterial||Je.isMeshToonMaterial||Je.isMeshLambertMaterial||Je.isMeshBasicMaterial||Je.isMeshStandardMaterial||Je.isShaderMaterial)&&In.setValue(G,\"isOrthographic\",Pe.isOrthographicCamera===!0),z!==Pe&&(z=Pe,Fn=!0,Ba=!0)}if(We.isSkinnedMesh){In.setOptional(G,We,\"bindMatrix\"),In.setOptional(G,We,\"bindMatrixInverse\");const Fr=We.skeleton;Fr&&(Fr.boneTexture===null&&Fr.computeBoneTexture(),In.setValue(G,\"boneTexture\",Fr.boneTexture,le))}We.isBatchedMesh&&(In.setOptional(G,We,\"batchingTexture\"),In.setValue(G,\"batchingTexture\",We._matricesTexture,le),In.setOptional(G,We,\"batchingIdTexture\"),In.setValue(G,\"batchingIdTexture\",We._indirectTexture,le),In.setOptional(G,We,\"batchingColorTexture\"),We._colorsTexture!==null&&In.setValue(G,\"batchingColorTexture\",We._colorsTexture,le));const ra=Ze.morphAttributes;if((ra.position!==void 0||ra.normal!==void 0||ra.color!==void 0)&&Re.update(We,Ze,gr),(Fn||un.receiveShadow!==We.receiveShadow)&&(un.receiveShadow=We.receiveShadow,In.setValue(G,\"receiveShadow\",We.receiveShadow)),Je.isMeshGouraudMaterial&&Je.envMap!==null&&(ma.envMap.value=yt,ma.flipEnvMap.value=yt.isCubeTexture&&yt.isRenderTargetTexture===!1?-1:1),Je.isMeshStandardMaterial&&Je.envMap===null&&Ge.environment!==null&&(ma.envMapIntensity.value=Ge.environmentIntensity),ma.dfgLUT!==void 0&&(ma.dfgLUT.value=hke()),Fn&&(In.setValue(G,\"toneMappingExposure\",A.toneMappingExposure),un.needsLights&&mt(ma,Ba),Ue&&Je.fog===!0&&Te.refreshFogUniforms(ma,Ue),Te.refreshMaterialUniforms(ma,Je,re,fe,C.state.transmissionRenderTarget[Pe.id]),Wk.upload(G,Et(un),ma,le)),Je.isShaderMaterial&&Je.uniformsNeedUpdate===!0&&(Wk.upload(G,Et(un),ma,le),Je.uniformsNeedUpdate=!1),Je.isSpriteMaterial&&In.setValue(G,\"center\",We.center),In.setValue(G,\"modelViewMatrix\",We.modelViewMatrix),In.setValue(G,\"normalMatrix\",We.normalMatrix),In.setValue(G,\"modelMatrix\",We.matrixWorld),Je.isShaderMaterial||Je.isRawShaderMaterial){const Fr=Je.uniformsGroups;for(let Br=0,cr=Fr.length;Br<cr;Br++){const Ci=Fr[Br];Fe.update(Ci,gr),Fe.bind(Ci,gr)}}return gr}function mt(Pe,Ge){Pe.ambientLightColor.needsUpdate=Ge,Pe.lightProbe.needsUpdate=Ge,Pe.directionalLights.needsUpdate=Ge,Pe.directionalLightShadows.needsUpdate=Ge,Pe.pointLights.needsUpdate=Ge,Pe.pointLightShadows.needsUpdate=Ge,Pe.spotLights.needsUpdate=Ge,Pe.spotLightShadows.needsUpdate=Ge,Pe.rectAreaLights.needsUpdate=Ge,Pe.hemisphereLights.needsUpdate=Ge}function pt(Pe){return Pe.isMeshLambertMaterial||Pe.isMeshToonMaterial||Pe.isMeshPhongMaterial||Pe.isMeshStandardMaterial||Pe.isShadowMaterial||Pe.isShaderMaterial&&Pe.lights===!0}this.getActiveCubeFace=function(){return F},this.getActiveMipmapLevel=function(){return k},this.getRenderTarget=function(){return D},this.setRenderTargetTextures=function(Pe,Ge,Ze){const Je=he.get(Pe);Je.__autoAllocateDepthBuffer=Pe.resolveDepthBuffer===!1,Je.__autoAllocateDepthBuffer===!1&&(Je.__useRenderToTexture=!1),he.get(Pe.texture).__webglTexture=Ge,he.get(Pe.depthTexture).__webglTexture=Je.__autoAllocateDepthBuffer?void 0:Ze,Je.__hasExternalTextures=!0},this.setRenderTargetFramebuffer=function(Pe,Ge){const Ze=he.get(Pe);Ze.__webglFramebuffer=Ge,Ze.__useDefaultFramebuffer=Ge===void 0};const nt=G.createFramebuffer();this.setRenderTarget=function(Pe,Ge=0,Ze=0){D=Pe,F=Ge,k=Ze;let Je=!0,We=null,Ue=!1,et=!1;if(Pe){const yt=he.get(Pe);if(yt.__useDefaultFramebuffer!==void 0)se.bindFramebuffer(G.FRAMEBUFFER,null),Je=!1;else if(yt.__webglFramebuffer===void 0)le.setupRenderTarget(Pe);else if(yt.__hasExternalTextures)le.rebindTextures(Pe,he.get(Pe.texture).__webglTexture,he.get(Pe.depthTexture).__webglTexture);else if(Pe.depthBuffer){const Pt=Pe.depthTexture;if(yt.__boundDepthTexture!==Pt){if(Pt!==null&&he.has(Pt)&&(Pe.width!==Pt.image.width||Pe.height!==Pt.image.height))throw new Error(\"WebGLRenderTarget: Attached DepthTexture is initialized to the incorrect size.\");le.setupDepthRenderbuffer(Pe)}}const qe=Pe.texture;(qe.isData3DTexture||qe.isDataArrayTexture||qe.isCompressedArrayTexture)&&(et=!0);const St=he.get(Pe).__webglFramebuffer;Pe.isWebGLCubeRenderTarget?(Array.isArray(St[Ge])?We=St[Ge][Ze]:We=St[Ge],Ue=!0):Pe.samples>0&&le.useMultisampledRTT(Pe)===!1?We=he.get(Pe).__webglMultisampledFramebuffer:Array.isArray(St)?We=St[Ze]:We=St,Q.copy(Pe.viewport),L.copy(Pe.scissor),te=Pe.scissorTest}else Q.copy(me).multiplyScalar(re).floor(),L.copy(be).multiplyScalar(re).floor(),te=Ce;if(Ze!==0&&(We=nt),se.bindFramebuffer(G.FRAMEBUFFER,We)&&Je&&se.drawBuffers(Pe,We),se.viewport(Q),se.scissor(L),se.setScissorTest(te),Ue){const yt=he.get(Pe.texture);G.framebufferTexture2D(G.FRAMEBUFFER,G.COLOR_ATTACHMENT0,G.TEXTURE_CUBE_MAP_POSITIVE_X+Ge,yt.__webglTexture,Ze)}else if(et){const yt=Ge;for(let qe=0;qe<Pe.textures.length;qe++){const St=he.get(Pe.textures[qe]);G.framebufferTextureLayer(G.FRAMEBUFFER,G.COLOR_ATTACHMENT0+qe,St.__webglTexture,Ze,yt)}}else if(Pe!==null&&Ze!==0){const yt=he.get(Pe.texture);G.framebufferTexture2D(G.FRAMEBUFFER,G.COLOR_ATTACHMENT0,G.TEXTURE_2D,yt.__webglTexture,Ze)}H=-1},this.readRenderTargetPixels=function(Pe,Ge,Ze,Je,We,Ue,et,jt=0){if(!(Pe&&Pe.isWebGLRenderTarget)){oi(\"WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.\");return}let yt=he.get(Pe).__webglFramebuffer;if(Pe.isWebGLCubeRenderTarget&&et!==void 0&&(yt=yt[et]),yt){se.bindFramebuffer(G.FRAMEBUFFER,yt);try{const qe=Pe.textures[jt],St=qe.format,Pt=qe.type;if(!ee.textureFormatReadable(St)){oi(\"WebGLRenderer.readRenderTargetPixels: renderTarget is not in RGBA or implementation defined format.\");return}if(!ee.textureTypeReadable(Pt)){oi(\"WebGLRenderer.readRenderTargetPixels: renderTarget is not in UnsignedByteType or implementation defined type.\");return}Ge>=0&&Ge<=Pe.width-Je&&Ze>=0&&Ze<=Pe.height-We&&(Pe.textures.length>1&&G.readBuffer(G.COLOR_ATTACHMENT0+jt),G.readPixels(Ge,Ze,Je,We,tt.convert(St),tt.convert(Pt),Ue))}finally{const qe=D!==null?he.get(D).__webglFramebuffer:null;se.bindFramebuffer(G.FRAMEBUFFER,qe)}}},this.readRenderTargetPixelsAsync=async function(Pe,Ge,Ze,Je,We,Ue,et,jt=0){if(!(Pe&&Pe.isWebGLRenderTarget))throw new Error(\"THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.\");let yt=he.get(Pe).__webglFramebuffer;if(Pe.isWebGLCubeRenderTarget&&et!==void 0&&(yt=yt[et]),yt)if(Ge>=0&&Ge<=Pe.width-Je&&Ze>=0&&Ze<=Pe.height-We){se.bindFramebuffer(G.FRAMEBUFFER,yt);const qe=Pe.textures[jt],St=qe.format,Pt=qe.type;if(!ee.textureFormatReadable(St))throw new Error(\"THREE.WebGLRenderer.readRenderTargetPixelsAsync: renderTarget is not in RGBA or implementation defined format.\");if(!ee.textureTypeReadable(Pt))throw new Error(\"THREE.WebGLRenderer.readRenderTargetPixelsAsync: renderTarget is not in UnsignedByteType or implementation defined type.\");const qt=G.createBuffer();G.bindBuffer(G.PIXEL_PACK_BUFFER,qt),G.bufferData(G.PIXEL_PACK_BUFFER,Ue.byteLength,G.STREAM_READ),Pe.textures.length>1&&G.readBuffer(G.COLOR_ATTACHMENT0+jt),G.readPixels(Ge,Ze,Je,We,tt.convert(St),tt.convert(Pt),0);const on=D!==null?he.get(D).__webglFramebuffer:null;se.bindFramebuffer(G.FRAMEBUFFER,on);const dn=G.fenceSync(G.SYNC_GPU_COMMANDS_COMPLETE,0);return G.flush(),await Lve(G,dn,4),G.bindBuffer(G.PIXEL_PACK_BUFFER,qt),G.getBufferSubData(G.PIXEL_PACK_BUFFER,0,Ue),G.deleteBuffer(qt),G.deleteSync(dn),Ue}else throw new Error(\"THREE.WebGLRenderer.readRenderTargetPixelsAsync: requested read bounds are out of range.\")},this.copyFramebufferToTexture=function(Pe,Ge=null,Ze=0){const Je=Math.pow(2,-Ze),We=Math.floor(Pe.image.width*Je),Ue=Math.floor(Pe.image.height*Je),et=Ge!==null?Ge.x:0,jt=Ge!==null?Ge.y:0;le.setTexture2D(Pe,0),G.copyTexSubImage2D(G.TEXTURE_2D,Ze,0,0,et,jt,We,Ue),se.unbindTexture()};const ze=G.createFramebuffer(),ot=G.createFramebuffer();this.copyTextureToTexture=function(Pe,Ge,Ze=null,Je=null,We=0,Ue=null){Ue===null&&(We!==0?(mw(\"WebGLRenderer: copyTextureToTexture function signature has changed to support src and dst mipmap levels.\"),Ue=We,We=0):Ue=0);let et,jt,yt,qe,St,Pt,qt,on,dn;const Nn=Pe.isCompressedTexture?Pe.mipmaps[Ue]:Pe.image;if(Ze!==null)et=Ze.max.x-Ze.min.x,jt=Ze.max.y-Ze.min.y,yt=Ze.isBox3?Ze.max.z-Ze.min.z:1,qe=Ze.min.x,St=Ze.min.y,Pt=Ze.isBox3?Ze.min.z:0;else{const ra=Math.pow(2,-We);et=Math.floor(Nn.width*ra),jt=Math.floor(Nn.height*ra),Pe.isDataArrayTexture?yt=Nn.depth:Pe.isData3DTexture?yt=Math.floor(Nn.depth*ra):yt=1,qe=0,St=0,Pt=0}Je!==null?(qt=Je.x,on=Je.y,dn=Je.z):(qt=0,on=0,dn=0);const bn=tt.convert(Ge.format),un=tt.convert(Ge.type);let wn;Ge.isData3DTexture?(le.setTexture3D(Ge,0),wn=G.TEXTURE_3D):Ge.isDataArrayTexture||Ge.isCompressedArrayTexture?(le.setTexture2DArray(Ge,0),wn=G.TEXTURE_2D_ARRAY):(le.setTexture2D(Ge,0),wn=G.TEXTURE_2D),G.pixelStorei(G.UNPACK_FLIP_Y_WEBGL,Ge.flipY),G.pixelStorei(G.UNPACK_PREMULTIPLY_ALPHA_WEBGL,Ge.premultiplyAlpha),G.pixelStorei(G.UNPACK_ALIGNMENT,Ge.unpackAlignment);const pn=G.getParameter(G.UNPACK_ROW_LENGTH),gr=G.getParameter(G.UNPACK_IMAGE_HEIGHT),Nr=G.getParameter(G.UNPACK_SKIP_PIXELS),Fn=G.getParameter(G.UNPACK_SKIP_ROWS),Ba=G.getParameter(G.UNPACK_SKIP_IMAGES);G.pixelStorei(G.UNPACK_ROW_LENGTH,Nn.width),G.pixelStorei(G.UNPACK_IMAGE_HEIGHT,Nn.height),G.pixelStorei(G.UNPACK_SKIP_PIXELS,qe),G.pixelStorei(G.UNPACK_SKIP_ROWS,St),G.pixelStorei(G.UNPACK_SKIP_IMAGES,Pt);const In=Pe.isDataArrayTexture||Pe.isData3DTexture,ma=Ge.isDataArrayTexture||Ge.isData3DTexture;if(Pe.isDepthTexture){const ra=he.get(Pe),Fr=he.get(Ge),Br=he.get(ra.__renderTarget),cr=he.get(Fr.__renderTarget);se.bindFramebuffer(G.READ_FRAMEBUFFER,Br.__webglFramebuffer),se.bindFramebuffer(G.DRAW_FRAMEBUFFER,cr.__webglFramebuffer);for(let Ci=0;Ci<yt;Ci++)In&&(G.framebufferTextureLayer(G.READ_FRAMEBUFFER,G.COLOR_ATTACHMENT0,he.get(Pe).__webglTexture,We,Pt+Ci),G.framebufferTextureLayer(G.DRAW_FRAMEBUFFER,G.COLOR_ATTACHMENT0,he.get(Ge).__webglTexture,Ue,dn+Ci)),G.blitFramebuffer(qe,St,et,jt,qt,on,et,jt,G.DEPTH_BUFFER_BIT,G.NEAREST);se.bindFramebuffer(G.READ_FRAMEBUFFER,null),se.bindFramebuffer(G.DRAW_FRAMEBUFFER,null)}else if(We!==0||Pe.isRenderTargetTexture||he.has(Pe)){const ra=he.get(Pe),Fr=he.get(Ge);se.bindFramebuffer(G.READ_FRAMEBUFFER,ze),se.bindFramebuffer(G.DRAW_FRAMEBUFFER,ot);for(let Br=0;Br<yt;Br++)In?G.framebufferTextureLayer(G.READ_FRAMEBUFFER,G.COLOR_ATTACHMENT0,ra.__webglTexture,We,Pt+Br):G.framebufferTexture2D(G.READ_FRAMEBUFFER,G.COLOR_ATTACHMENT0,G.TEXTURE_2D,ra.__webglTexture,We),ma?G.framebufferTextureLayer(G.DRAW_FRAMEBUFFER,G.COLOR_ATTACHMENT0,Fr.__webglTexture,Ue,dn+Br):G.framebufferTexture2D(G.DRAW_FRAMEBUFFER,G.COLOR_ATTACHMENT0,G.TEXTURE_2D,Fr.__webglTexture,Ue),We!==0?G.blitFramebuffer(qe,St,et,jt,qt,on,et,jt,G.COLOR_BUFFER_BIT,G.NEAREST):ma?G.copyTexSubImage3D(wn,Ue,qt,on,dn+Br,qe,St,et,jt):G.copyTexSubImage2D(wn,Ue,qt,on,qe,St,et,jt);se.bindFramebuffer(G.READ_FRAMEBUFFER,null),se.bindFramebuffer(G.DRAW_FRAMEBUFFER,null)}else ma?Pe.isDataTexture||Pe.isData3DTexture?G.texSubImage3D(wn,Ue,qt,on,dn,et,jt,yt,bn,un,Nn.data):Ge.isCompressedArrayTexture?G.compressedTexSubImage3D(wn,Ue,qt,on,dn,et,jt,yt,bn,Nn.data):G.texSubImage3D(wn,Ue,qt,on,dn,et,jt,yt,bn,un,Nn):Pe.isDataTexture?G.texSubImage2D(G.TEXTURE_2D,Ue,qt,on,et,jt,bn,un,Nn.data):Pe.isCompressedTexture?G.compressedTexSubImage2D(G.TEXTURE_2D,Ue,qt,on,Nn.width,Nn.height,bn,Nn.data):G.texSubImage2D(G.TEXTURE_2D,Ue,qt,on,et,jt,bn,un,Nn);G.pixelStorei(G.UNPACK_ROW_LENGTH,pn),G.pixelStorei(G.UNPACK_IMAGE_HEIGHT,gr),G.pixelStorei(G.UNPACK_SKIP_PIXELS,Nr),G.pixelStorei(G.UNPACK_SKIP_ROWS,Fn),G.pixelStorei(G.UNPACK_SKIP_IMAGES,Ba),Ue===0&&Ge.generateMipmaps&&G.generateMipmap(wn),se.unbindTexture()},this.initRenderTarget=function(Pe){he.get(Pe).__webglFramebuffer===void 0&&le.setupRenderTarget(Pe)},this.initTexture=function(Pe){Pe.isCubeTexture?le.setTextureCube(Pe,0):Pe.isData3DTexture?le.setTexture3D(Pe,0):Pe.isDataArrayTexture||Pe.isCompressedArrayTexture?le.setTexture2DArray(Pe,0):le.setTexture2D(Pe,0),se.unbindTexture()},this.resetState=function(){F=0,k=0,D=null,se.reset(),pe.reset()},typeof __THREE_DEVTOOLS__<\"u\"&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent(\"observe\",{detail:this}))}get coordinateSystem(){return Md}get outputColorSpace(){return this._outputColorSpace}set outputColorSpace(e){this._outputColorSpace=e;const n=this.getContext();n.drawingBufferColorSpace=Mr._getDrawingBufferColorSpace(e),n.unpackColorSpace=Mr._getUnpackColorSpace()}};const $6={type:\"change\"},sI={type:\"start\"},WZ={type:\"end\"},v1=new eI,V6=new Ph,fke=Math.cos(70*Ive.DEG2RAD),Ti=new Ct,Mo=2*Math.PI,ea={NONE:-1,ROTATE:0,DOLLY:1,PAN:2,TOUCH_ROTATE:3,TOUCH_PAN:4,TOUCH_DOLLY_PAN:5,TOUCH_DOLLY_ROTATE:6},cE=1e-6;class gke extends P0e{constructor(e,n=null){super(e,n),this.state=ea.NONE,this.target=new Ct,this.cursor=new Ct,this.minDistance=0,this.maxDistance=1/0,this.minZoom=0,this.maxZoom=1/0,this.minTargetRadius=0,this.maxTargetRadius=1/0,this.minPolarAngle=0,this.maxPolarAngle=Math.PI,this.minAzimuthAngle=-1/0,this.maxAzimuthAngle=1/0,this.enableDamping=!1,this.dampingFactor=.05,this.enableZoom=!0,this.zoomSpeed=1,this.enableRotate=!0,this.rotateSpeed=1,this.keyRotateSpeed=1,this.enablePan=!0,this.panSpeed=1,this.screenSpacePanning=!0,this.keyPanSpeed=7,this.zoomToCursor=!1,this.autoRotate=!1,this.autoRotateSpeed=2,this.keys={LEFT:\"ArrowLeft\",UP:\"ArrowUp\",RIGHT:\"ArrowRight\",BOTTOM:\"ArrowDown\"},this.mouseButtons={LEFT:Tx.ROTATE,MIDDLE:Tx.DOLLY,RIGHT:Tx.PAN},this.touches={ONE:yx.ROTATE,TWO:yx.DOLLY_PAN},this.target0=this.target.clone(),this.position0=this.object.position.clone(),this.zoom0=this.object.zoom,this._domElementKeyEvents=null,this._lastPosition=new Ct,this._lastQuaternion=new gg,this._lastTargetPosition=new Ct,this._quat=new gg().setFromUnitVectors(e.up,new Ct(0,1,0)),this._quatInverse=this._quat.clone().invert(),this._spherical=new y6,this._sphericalDelta=new y6,this._scale=1,this._panOffset=new Ct,this._rotateStart=new nr,this._rotateEnd=new nr,this._rotateDelta=new nr,this._panStart=new nr,this._panEnd=new nr,this._panDelta=new nr,this._dollyStart=new nr,this._dollyEnd=new nr,this._dollyDelta=new nr,this._dollyDirection=new Ct,this._mouse=new nr,this._performCursorZoom=!1,this._pointers=[],this._pointerPositions={},this._controlActive=!1,this._onPointerMove=xke.bind(this),this._onPointerDown=bke.bind(this),this._onPointerUp=yke.bind(this),this._onContextMenu=Cke.bind(this),this._onMouseWheel=Ske.bind(this),this._onKeyDown=_ke.bind(this),this._onTouchStart=kke.bind(this),this._onTouchMove=Nke.bind(this),this._onMouseDown=vke.bind(this),this._onMouseMove=wke.bind(this),this._interceptControlDown=Pke.bind(this),this._interceptControlUp=Tke.bind(this),this.domElement!==null&&this.connect(this.domElement),this.update()}connect(e){super.connect(e),this.domElement.addEventListener(\"pointerdown\",this._onPointerDown),this.domElement.addEventListener(\"pointercancel\",this._onPointerUp),this.domElement.addEventListener(\"contextmenu\",this._onContextMenu),this.domElement.addEventListener(\"wheel\",this._onMouseWheel,{passive:!1}),this.domElement.getRootNode().addEventListener(\"keydown\",this._interceptControlDown,{passive:!0,capture:!0}),this.domElement.style.touchAction=\"none\"}disconnect(){this.domElement.removeEventListener(\"pointerdown\",this._onPointerDown),this.domElement.removeEventListener(\"pointermove\",this._onPointerMove),this.domElement.removeEventListener(\"pointerup\",this._onPointerUp),this.domElement.removeEventListener(\"pointercancel\",this._onPointerUp),this.domElement.removeEventListener(\"wheel\",this._onMouseWheel),this.domElement.removeEventListener(\"contextmenu\",this._onContextMenu),this.stopListenToKeyEvents(),this.domElement.getRootNode().removeEventListener(\"keydown\",this._interceptControlDown,{capture:!0}),this.domElement.style.touchAction=\"auto\"}dispose(){this.disconnect()}getPolarAngle(){return this._spherical.phi}getAzimuthalAngle(){return this._spherical.theta}getDistance(){return this.object.position.distanceTo(this.target)}listenToKeyEvents(e){e.addEventListener(\"keydown\",this._onKeyDown),this._domElementKeyEvents=e}stopListenToKeyEvents(){this._domElementKeyEvents!==null&&(this._domElementKeyEvents.removeEventListener(\"keydown\",this._onKeyDown),this._domElementKeyEvents=null)}saveState(){this.target0.copy(this.target),this.position0.copy(this.object.position),this.zoom0=this.object.zoom}reset(){this.target.copy(this.target0),this.object.position.copy(this.position0),this.object.zoom=this.zoom0,this.object.updateProjectionMatrix(),this.dispatchEvent($6),this.update(),this.state=ea.NONE}update(e=null){const n=this.object.position;Ti.copy(n).sub(this.target),Ti.applyQuaternion(this._quat),this._spherical.setFromVector3(Ti),this.autoRotate&&this.state===ea.NONE&&this._rotateLeft(this._getAutoRotationAngle(e)),this.enableDamping?(this._spherical.theta+=this._sphericalDelta.theta*this.dampingFactor,this._spherical.phi+=this._sphericalDelta.phi*this.dampingFactor):(this._spherical.theta+=this._sphericalDelta.theta,this._spherical.phi+=this._sphericalDelta.phi);let r=this.minAzimuthAngle,i=this.maxAzimuthAngle;isFinite(r)&&isFinite(i)&&(r<-Math.PI?r+=Mo:r>Math.PI&&(r-=Mo),i<-Math.PI?i+=Mo:i>Math.PI&&(i-=Mo),r<=i?this._spherical.theta=Math.max(r,Math.min(i,this._spherical.theta)):this._spherical.theta=this._spherical.theta>(r+i)/2?Math.max(r,this._spherical.theta):Math.min(i,this._spherical.theta)),this._spherical.phi=Math.max(this.minPolarAngle,Math.min(this.maxPolarAngle,this._spherical.phi)),this._spherical.makeSafe(),this.enableDamping===!0?this.target.addScaledVector(this._panOffset,this.dampingFactor):this.target.add(this._panOffset),this.target.sub(this.cursor),this.target.clampLength(this.minTargetRadius,this.maxTargetRadius),this.target.add(this.cursor);let s=!1;if(this.zoomToCursor&&this._performCursorZoom||this.object.isOrthographicCamera)this._spherical.radius=this._clampDistance(this._spherical.radius);else{const o=this._spherical.radius;this._spherical.radius=this._clampDistance(this._spherical.radius*this._scale),s=o!=this._spherical.radius}if(Ti.setFromSpherical(this._spherical),Ti.applyQuaternion(this._quatInverse),n.copy(this.target).add(Ti),this.object.lookAt(this.target),this.enableDamping===!0?(this._sphericalDelta.theta*=1-this.dampingFactor,this._sphericalDelta.phi*=1-this.dampingFactor,this._panOffset.multiplyScalar(1-this.dampingFactor)):(this._sphericalDelta.set(0,0,0),this._panOffset.set(0,0,0)),this.zoomToCursor&&this._performCursorZoom){let o=null;if(this.object.isPerspectiveCamera){const l=Ti.length();o=this._clampDistance(l*this._scale);const c=l-o;this.object.position.addScaledVector(this._dollyDirection,c),this.object.updateMatrixWorld(),s=!!c}else if(this.object.isOrthographicCamera){const l=new Ct(this._mouse.x,this._mouse.y,0);l.unproject(this.object);const c=this.object.zoom;this.object.zoom=Math.max(this.minZoom,Math.min(this.maxZoom,this.object.zoom/this._scale)),this.object.updateProjectionMatrix(),s=c!==this.object.zoom;const d=new Ct(this._mouse.x,this._mouse.y,0);d.unproject(this.object),this.object.position.sub(d).add(l),this.object.updateMatrixWorld(),o=Ti.length()}else console.warn(\"WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.\"),this.zoomToCursor=!1;o!==null&&(this.screenSpacePanning?this.target.set(0,0,-1).transformDirection(this.object.matrix).multiplyScalar(o).add(this.object.position):(v1.origin.copy(this.object.position),v1.direction.set(0,0,-1).transformDirection(this.object.matrix),Math.abs(this.object.up.dot(v1.direction))<fke?this.object.lookAt(this.target):(V6.setFromNormalAndCoplanarPoint(this.object.up,this.target),v1.intersectPlane(V6,this.target))))}else if(this.object.isOrthographicCamera){const o=this.object.zoom;this.object.zoom=Math.max(this.minZoom,Math.min(this.maxZoom,this.object.zoom/this._scale)),o!==this.object.zoom&&(this.object.updateProjectionMatrix(),s=!0)}return this._scale=1,this._performCursorZoom=!1,s||this._lastPosition.distanceToSquared(this.object.position)>cE||8*(1-this._lastQuaternion.dot(this.object.quaternion))>cE||this._lastTargetPosition.distanceToSquared(this.target)>cE?(this.dispatchEvent($6),this._lastPosition.copy(this.object.position),this._lastQuaternion.copy(this.object.quaternion),this._lastTargetPosition.copy(this.target),!0):!1}_getAutoRotationAngle(e){return e!==null?Mo/60*this.autoRotateSpeed*e:Mo/60/60*this.autoRotateSpeed}_getZoomScale(e){const n=Math.abs(e*.01);return Math.pow(.95,this.zoomSpeed*n)}_rotateLeft(e){this._sphericalDelta.theta-=e}_rotateUp(e){this._sphericalDelta.phi-=e}_panLeft(e,n){Ti.setFromMatrixColumn(n,0),Ti.multiplyScalar(-e),this._panOffset.add(Ti)}_panUp(e,n){this.screenSpacePanning===!0?Ti.setFromMatrixColumn(n,1):(Ti.setFromMatrixColumn(n,0),Ti.crossVectors(this.object.up,Ti)),Ti.multiplyScalar(e),this._panOffset.add(Ti)}_pan(e,n){const r=this.domElement;if(this.object.isPerspectiveCamera){const i=this.object.position;Ti.copy(i).sub(this.target);let s=Ti.length();s*=Math.tan(this.object.fov/2*Math.PI/180),this._panLeft(2*e*s/r.clientHeight,this.object.matrix),this._panUp(2*n*s/r.clientHeight,this.object.matrix)}else this.object.isOrthographicCamera?(this._panLeft(e*(this.object.right-this.object.left)/this.object.zoom/r.clientWidth,this.object.matrix),this._panUp(n*(this.object.top-this.object.bottom)/this.object.zoom/r.clientHeight,this.object.matrix)):(console.warn(\"WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.\"),this.enablePan=!1)}_dollyOut(e){this.object.isPerspectiveCamera||this.object.isOrthographicCamera?this._scale/=e:(console.warn(\"WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.\"),this.enableZoom=!1)}_dollyIn(e){this.object.isPerspectiveCamera||this.object.isOrthographicCamera?this._scale*=e:(console.warn(\"WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.\"),this.enableZoom=!1)}_updateZoomParameters(e,n){if(!this.zoomToCursor)return;this._performCursorZoom=!0;const r=this.domElement.getBoundingClientRect(),i=e-r.left,s=n-r.top,o=r.width,l=r.height;this._mouse.x=i/o*2-1,this._mouse.y=-(s/l)*2+1,this._dollyDirection.set(this._mouse.x,this._mouse.y,1).unproject(this.object).sub(this.object.position).normalize()}_clampDistance(e){return Math.max(this.minDistance,Math.min(this.maxDistance,e))}_handleMouseDownRotate(e){this._rotateStart.set(e.clientX,e.clientY)}_handleMouseDownDolly(e){this._updateZoomParameters(e.clientX,e.clientX),this._dollyStart.set(e.clientX,e.clientY)}_handleMouseDownPan(e){this._panStart.set(e.clientX,e.clientY)}_handleMouseMoveRotate(e){this._rotateEnd.set(e.clientX,e.clientY),this._rotateDelta.subVectors(this._rotateEnd,this._rotateStart).multiplyScalar(this.rotateSpeed);const n=this.domElement;this._rotateLeft(Mo*this._rotateDelta.x/n.clientHeight),this._rotateUp(Mo*this._rotateDelta.y/n.clientHeight),this._rotateStart.copy(this._rotateEnd),this.update()}_handleMouseMoveDolly(e){this._dollyEnd.set(e.clientX,e.clientY),this._dollyDelta.subVectors(this._dollyEnd,this._dollyStart),this._dollyDelta.y>0?this._dollyOut(this._getZoomScale(this._dollyDelta.y)):this._dollyDelta.y<0&&this._dollyIn(this._getZoomScale(this._dollyDelta.y)),this._dollyStart.copy(this._dollyEnd),this.update()}_handleMouseMovePan(e){this._panEnd.set(e.clientX,e.clientY),this._panDelta.subVectors(this._panEnd,this._panStart).multiplyScalar(this.panSpeed),this._pan(this._panDelta.x,this._panDelta.y),this._panStart.copy(this._panEnd),this.update()}_handleMouseWheel(e){this._updateZoomParameters(e.clientX,e.clientY),e.deltaY<0?this._dollyIn(this._getZoomScale(e.deltaY)):e.deltaY>0&&this._dollyOut(this._getZoomScale(e.deltaY)),this.update()}_handleKeyDown(e){let n=!1;switch(e.code){case this.keys.UP:e.ctrlKey||e.metaKey||e.shiftKey?this.enableRotate&&this._rotateUp(Mo*this.keyRotateSpeed/this.domElement.clientHeight):this.enablePan&&this._pan(0,this.keyPanSpeed),n=!0;break;case this.keys.BOTTOM:e.ctrlKey||e.metaKey||e.shiftKey?this.enableRotate&&this._rotateUp(-Mo*this.keyRotateSpeed/this.domElement.clientHeight):this.enablePan&&this._pan(0,-this.keyPanSpeed),n=!0;break;case this.keys.LEFT:e.ctrlKey||e.metaKey||e.shiftKey?this.enableRotate&&this._rotateLeft(Mo*this.keyRotateSpeed/this.domElement.clientHeight):this.enablePan&&this._pan(this.keyPanSpeed,0),n=!0;break;case this.keys.RIGHT:e.ctrlKey||e.metaKey||e.shiftKey?this.enableRotate&&this._rotateLeft(-Mo*this.keyRotateSpeed/this.domElement.clientHeight):this.enablePan&&this._pan(-this.keyPanSpeed,0),n=!0;break}n&&(e.preventDefault(),this.update())}_handleTouchStartRotate(e){if(this._pointers.length===1)this._rotateStart.set(e.pageX,e.pageY);else{const n=this._getSecondPointerPosition(e),r=.5*(e.pageX+n.x),i=.5*(e.pageY+n.y);this._rotateStart.set(r,i)}}_handleTouchStartPan(e){if(this._pointers.length===1)this._panStart.set(e.pageX,e.pageY);else{const n=this._getSecondPointerPosition(e),r=.5*(e.pageX+n.x),i=.5*(e.pageY+n.y);this._panStart.set(r,i)}}_handleTouchStartDolly(e){const n=this._getSecondPointerPosition(e),r=e.pageX-n.x,i=e.pageY-n.y,s=Math.sqrt(r*r+i*i);this._dollyStart.set(0,s)}_handleTouchStartDollyPan(e){this.enableZoom&&this._handleTouchStartDolly(e),this.enablePan&&this._handleTouchStartPan(e)}_handleTouchStartDollyRotate(e){this.enableZoom&&this._handleTouchStartDolly(e),this.enableRotate&&this._handleTouchStartRotate(e)}_handleTouchMoveRotate(e){if(this._pointers.length==1)this._rotateEnd.set(e.pageX,e.pageY);else{const r=this._getSecondPointerPosition(e),i=.5*(e.pageX+r.x),s=.5*(e.pageY+r.y);this._rotateEnd.set(i,s)}this._rotateDelta.subVectors(this._rotateEnd,this._rotateStart).multiplyScalar(this.rotateSpeed);const n=this.domElement;this._rotateLeft(Mo*this._rotateDelta.x/n.clientHeight),this._rotateUp(Mo*this._rotateDelta.y/n.clientHeight),this._rotateStart.copy(this._rotateEnd)}_handleTouchMovePan(e){if(this._pointers.length===1)this._panEnd.set(e.pageX,e.pageY);else{const n=this._getSecondPointerPosition(e),r=.5*(e.pageX+n.x),i=.5*(e.pageY+n.y);this._panEnd.set(r,i)}this._panDelta.subVectors(this._panEnd,this._panStart).multiplyScalar(this.panSpeed),this._pan(this._panDelta.x,this._panDelta.y),this._panStart.copy(this._panEnd)}_handleTouchMoveDolly(e){const n=this._getSecondPointerPosition(e),r=e.pageX-n.x,i=e.pageY-n.y,s=Math.sqrt(r*r+i*i);this._dollyEnd.set(0,s),this._dollyDelta.set(0,Math.pow(this._dollyEnd.y/this._dollyStart.y,this.zoomSpeed)),this._dollyOut(this._dollyDelta.y),this._dollyStart.copy(this._dollyEnd);const o=(e.pageX+n.x)*.5,l=(e.pageY+n.y)*.5;this._updateZoomParameters(o,l)}_handleTouchMoveDollyPan(e){this.enableZoom&&this._handleTouchMoveDolly(e),this.enablePan&&this._handleTouchMovePan(e)}_handleTouchMoveDollyRotate(e){this.enableZoom&&this._handleTouchMoveDolly(e),this.enableRotate&&this._handleTouchMoveRotate(e)}_addPointer(e){this._pointers.push(e.pointerId)}_removePointer(e){delete this._pointerPositions[e.pointerId];for(let n=0;n<this._pointers.length;n++)if(this._pointers[n]==e.pointerId){this._pointers.splice(n,1);return}}_isTrackingPointer(e){for(let n=0;n<this._pointers.length;n++)if(this._pointers[n]==e.pointerId)return!0;return!1}_trackPointer(e){let n=this._pointerPositions[e.pointerId];n===void 0&&(n=new nr,this._pointerPositions[e.pointerId]=n),n.set(e.pageX,e.pageY)}_getSecondPointerPosition(e){const n=e.pointerId===this._pointers[0]?this._pointers[1]:this._pointers[0];return this._pointerPositions[n]}_customWheelEvent(e){const n=e.deltaMode,r={clientX:e.clientX,clientY:e.clientY,deltaY:e.deltaY};switch(n){case 1:r.deltaY*=16;break;case 2:r.deltaY*=100;break}return e.ctrlKey&&!this._controlActive&&(r.deltaY*=10),r}}function bke(t){this.enabled!==!1&&(this._pointers.length===0&&(this.domElement.setPointerCapture(t.pointerId),this.domElement.addEventListener(\"pointermove\",this._onPointerMove),this.domElement.addEventListener(\"pointerup\",this._onPointerUp)),!this._isTrackingPointer(t)&&(this._addPointer(t),t.pointerType===\"touch\"?this._onTouchStart(t):this._onMouseDown(t)))}function xke(t){this.enabled!==!1&&(t.pointerType===\"touch\"?this._onTouchMove(t):this._onMouseMove(t))}function yke(t){switch(this._removePointer(t),this._pointers.length){case 0:this.domElement.releasePointerCapture(t.pointerId),this.domElement.removeEventListener(\"pointermove\",this._onPointerMove),this.domElement.removeEventListener(\"pointerup\",this._onPointerUp),this.dispatchEvent(WZ),this.state=ea.NONE;break;case 1:const e=this._pointers[0],n=this._pointerPositions[e];this._onTouchStart({pointerId:e,pageX:n.x,pageY:n.y});break}}function vke(t){let e;switch(t.button){case 0:e=this.mouseButtons.LEFT;break;case 1:e=this.mouseButtons.MIDDLE;break;case 2:e=this.mouseButtons.RIGHT;break;default:e=-1}switch(e){case Tx.DOLLY:if(this.enableZoom===!1)return;this._handleMouseDownDolly(t),this.state=ea.DOLLY;break;case Tx.ROTATE:if(t.ctrlKey||t.metaKey||t.shiftKey){if(this.enablePan===!1)return;this._handleMouseDownPan(t),this.state=ea.PAN}else{if(this.enableRotate===!1)return;this._handleMouseDownRotate(t),this.state=ea.ROTATE}break;case Tx.PAN:if(t.ctrlKey||t.metaKey||t.shiftKey){if(this.enableRotate===!1)return;this._handleMouseDownRotate(t),this.state=ea.ROTATE}else{if(this.enablePan===!1)return;this._handleMouseDownPan(t),this.state=ea.PAN}break;default:this.state=ea.NONE}this.state!==ea.NONE&&this.dispatchEvent(sI)}function wke(t){switch(this.state){case ea.ROTATE:if(this.enableRotate===!1)return;this._handleMouseMoveRotate(t);break;case ea.DOLLY:if(this.enableZoom===!1)return;this._handleMouseMoveDolly(t);break;case ea.PAN:if(this.enablePan===!1)return;this._handleMouseMovePan(t);break}}function Ske(t){this.enabled===!1||this.enableZoom===!1||this.state!==ea.NONE||(t.preventDefault(),this.dispatchEvent(sI),this._handleMouseWheel(this._customWheelEvent(t)),this.dispatchEvent(WZ))}function _ke(t){this.enabled!==!1&&this._handleKeyDown(t)}function kke(t){switch(this._trackPointer(t),this._pointers.length){case 1:switch(this.touches.ONE){case yx.ROTATE:if(this.enableRotate===!1)return;this._handleTouchStartRotate(t),this.state=ea.TOUCH_ROTATE;break;case yx.PAN:if(this.enablePan===!1)return;this._handleTouchStartPan(t),this.state=ea.TOUCH_PAN;break;default:this.state=ea.NONE}break;case 2:switch(this.touches.TWO){case yx.DOLLY_PAN:if(this.enableZoom===!1&&this.enablePan===!1)return;this._handleTouchStartDollyPan(t),this.state=ea.TOUCH_DOLLY_PAN;break;case yx.DOLLY_ROTATE:if(this.enableZoom===!1&&this.enableRotate===!1)return;this._handleTouchStartDollyRotate(t),this.state=ea.TOUCH_DOLLY_ROTATE;break;default:this.state=ea.NONE}break;default:this.state=ea.NONE}this.state!==ea.NONE&&this.dispatchEvent(sI)}function Nke(t){switch(this._trackPointer(t),this.state){case ea.TOUCH_ROTATE:if(this.enableRotate===!1)return;this._handleTouchMoveRotate(t),this.update();break;case ea.TOUCH_PAN:if(this.enablePan===!1)return;this._handleTouchMovePan(t),this.update();break;case ea.TOUCH_DOLLY_PAN:if(this.enableZoom===!1&&this.enablePan===!1)return;this._handleTouchMoveDollyPan(t),this.update();break;case ea.TOUCH_DOLLY_ROTATE:if(this.enableZoom===!1&&this.enableRotate===!1)return;this._handleTouchMoveDollyRotate(t),this.update();break;default:this.state=ea.NONE}}function Cke(t){this.enabled!==!1&&t.preventDefault()}function Pke(t){t.key===\"Control\"&&(this._controlActive=!0,this.domElement.getRootNode().addEventListener(\"keyup\",this._interceptControlUp,{passive:!0,capture:!0}))}function Tke(t){t.key===\"Control\"&&(this._controlActive=!1,this.domElement.getRootNode().removeEventListener(\"keyup\",this._interceptControlUp,{passive:!0,capture:!0}))}function Ake(t,e=!1){const n=t[0].index!==null,r=new Set(Object.keys(t[0].attributes)),i=new Set(Object.keys(t[0].morphAttributes)),s={},o={},l=t[0].morphTargetsRelative,c=new uc;let d=0;for(let u=0;u<t.length;++u){const m=t[u];let p=0;if(n!==(m.index!==null))return console.error(\"THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index \"+u+\". All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.\"),null;for(const f in m.attributes){if(!r.has(f))return console.error(\"THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index \"+u+'. All geometries must have compatible attributes; make sure \"'+f+'\" attribute exists among all geometries, or in none of them.'),null;s[f]===void 0&&(s[f]=[]),s[f].push(m.attributes[f]),p++}if(p!==r.size)return console.error(\"THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index \"+u+\". Make sure all geometries have the same number of attributes.\"),null;if(l!==m.morphTargetsRelative)return console.error(\"THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index \"+u+\". .morphTargetsRelative must be consistent throughout all geometries.\"),null;for(const f in m.morphAttributes){if(!i.has(f))return console.error(\"THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index \"+u+\".  .morphAttributes must be consistent throughout all geometries.\"),null;o[f]===void 0&&(o[f]=[]),o[f].push(m.morphAttributes[f])}if(e){let f;if(n)f=m.index.count;else if(m.attributes.position!==void 0)f=m.attributes.position.count;else return console.error(\"THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index \"+u+\". The geometry must have either an index or a position attribute\"),null;c.addGroup(d,f,u),d+=f}}if(n){let u=0;const m=[];for(let p=0;p<t.length;++p){const f=t[p].index;for(let y=0;y<f.count;++y)m.push(f.getX(y)+u);u+=t[p].attributes.position.count}c.setIndex(m)}for(const u in s){const m=G6(s[u]);if(!m)return console.error(\"THREE.BufferGeometryUtils: .mergeGeometries() failed while trying to merge the \"+u+\" attribute.\"),null;c.setAttribute(u,m)}for(const u in o){const m=o[u][0].length;if(m===0)break;c.morphAttributes=c.morphAttributes||{},c.morphAttributes[u]=[];for(let p=0;p<m;++p){const f=[];for(let v=0;v<o[u].length;++v)f.push(o[u][v][p]);const y=G6(f);if(!y)return console.error(\"THREE.BufferGeometryUtils: .mergeGeometries() failed while trying to merge the \"+u+\" morphAttribute.\"),null;c.morphAttributes[u].push(y)}}return c}function G6(t){let e,n,r,i=-1,s=0;for(let d=0;d<t.length;++d){const u=t[d];if(e===void 0&&(e=u.array.constructor),e!==u.array.constructor)return console.error(\"THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.array must be of consistent array types across matching attributes.\"),null;if(n===void 0&&(n=u.itemSize),n!==u.itemSize)return console.error(\"THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.itemSize must be consistent across matching attributes.\"),null;if(r===void 0&&(r=u.normalized),r!==u.normalized)return console.error(\"THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.normalized must be consistent across matching attributes.\"),null;if(i===-1&&(i=u.gpuType),i!==u.gpuType)return console.error(\"THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.gpuType must be consistent across matching attributes.\"),null;s+=u.count*n}const o=new e(s),l=new oo(o,n,r);let c=0;for(let d=0;d<t.length;++d){const u=t[d];if(u.isInterleavedBufferAttribute){const m=c/n;for(let p=0,f=u.count;p<f;p++)for(let y=0;y<n;y++){const v=u.getComponent(p,y);l.setComponent(p+m,y,v)}}else o.set(u.array,c);c+=u.count*n}return i!==void 0&&(l.gpuType=i),l}class jke extends iI{constructor(e){super(e)}load(e,n,r,i){const s=this,o=new w0e(this.manager);o.setPath(this.path),o.setResponseType(\"arraybuffer\"),o.setRequestHeader(this.requestHeader),o.setWithCredentials(this.withCredentials),o.load(e,function(l){try{n(s.parse(l))}catch(c){i?i(c):console.error(c),s.manager.itemError(e)}},r,i)}parse(e){function n(d){const u=new DataView(d),m=32/8*3+32/8*3*3+16/8,p=u.getUint32(80,!0);if(80+32/8+p*m===u.byteLength)return!0;const y=[115,111,108,105,100];for(let v=0;v<5;v++)if(r(y,u,v))return!1;return!0}function r(d,u,m){for(let p=0,f=d.length;p<f;p++)if(d[p]!==u.getUint8(m+p))return!1;return!0}function i(d){const u=new DataView(d),m=u.getUint32(80,!0);let p,f,y,v=!1,b,g,_,C,P;for(let H=0;H<70;H++)u.getUint32(H,!1)==1129270351&&u.getUint8(H+4)==82&&u.getUint8(H+5)==61&&(v=!0,b=new Float32Array(m*3*3),g=u.getUint8(H+6)/255,_=u.getUint8(H+7)/255,C=u.getUint8(H+8)/255,P=u.getUint8(H+9)/255);const N=84,A=50,T=new uc,F=new Float32Array(m*3*3),k=new Float32Array(m*3*3),D=new or;for(let H=0;H<m;H++){const z=N+H*A,Q=u.getFloat32(z,!0),L=u.getFloat32(z+4,!0),te=u.getFloat32(z+8,!0);if(v){const ie=u.getUint16(z+48,!0);(ie&32768)===0?(p=(ie&31)/31,f=(ie>>5&31)/31,y=(ie>>10&31)/31):(p=g,f=_,y=C)}for(let ie=1;ie<=3;ie++){const J=z+ie*12,oe=H*3*3+(ie-1)*3;F[oe]=u.getFloat32(J,!0),F[oe+1]=u.getFloat32(J+4,!0),F[oe+2]=u.getFloat32(J+8,!0),k[oe]=Q,k[oe+1]=L,k[oe+2]=te,v&&(D.setRGB(p,f,y,ol),b[oe]=D.r,b[oe+1]=D.g,b[oe+2]=D.b)}}return T.setAttribute(\"position\",new oo(F,3)),T.setAttribute(\"normal\",new oo(k,3)),v&&(T.setAttribute(\"color\",new oo(b,3)),T.hasColors=!0,T.alpha=P),T}function s(d){const u=new uc,m=/solid([\\s\\S]*?)endsolid/g,p=/facet([\\s\\S]*?)endfacet/g,f=/solid\\s(.+)/;let y=0;const v=/[\\s]+([+-]?(?:\\d*)(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)/.source,b=new RegExp(\"vertex\"+v+v+v,\"g\"),g=new RegExp(\"normal\"+v+v+v,\"g\"),_=[],C=[],P=[],N=new Ct;let A,T=0,F=0,k=0;for(;(A=m.exec(d))!==null;){F=k;const D=A[0],H=(A=f.exec(D))!==null?A[1]:\"\";for(P.push(H);(A=p.exec(D))!==null;){let L=0,te=0;const ie=A[0];for(;(A=g.exec(ie))!==null;)N.x=parseFloat(A[1]),N.y=parseFloat(A[2]),N.z=parseFloat(A[3]),te++;for(;(A=b.exec(ie))!==null;)_.push(parseFloat(A[1]),parseFloat(A[2]),parseFloat(A[3])),C.push(N.x,N.y,N.z),L++,k++;te!==1&&console.error(\"THREE.STLLoader: Something isn't right with the normal of face number \"+y),L!==3&&console.error(\"THREE.STLLoader: Something isn't right with the vertices of face number \"+y),y++}const z=F,Q=k-F;u.userData.groupNames=P,u.addGroup(z,Q,T),T++}return u.setAttribute(\"position\",new pl(_,3)),u.setAttribute(\"normal\",new pl(C,3)),u}function o(d){return typeof d!=\"string\"?new TextDecoder().decode(d):d}function l(d){if(typeof d==\"string\"){const u=new Uint8Array(d.length);for(let m=0;m<d.length;m++)u[m]=d.charCodeAt(m)&255;return u.buffer||u}else return d}const c=l(e);return n(c)?i(c):s(o(e))}}function w1(t){throw new Error('Could not dynamically require \"'+t+'\". Please configure the dynamicRequireTargets or/and ignoreDynamicRequires option of @rollup/plugin-commonjs appropriately for this require call to work.')}var dE={exports:{}};var W6;function Mke(){return W6||(W6=1,(function(t,e){(function(n){t.exports=n()})(function(){return(function n(r,i,s){function o(d,u){if(!i[d]){if(!r[d]){var m=typeof w1==\"function\"&&w1;if(!u&&m)return m(d,!0);if(l)return l(d,!0);var p=new Error(\"Cannot find module '\"+d+\"'\");throw p.code=\"MODULE_NOT_FOUND\",p}var f=i[d]={exports:{}};r[d][0].call(f.exports,function(y){var v=r[d][1][y];return o(v||y)},f,f.exports,n,r,i,s)}return i[d].exports}for(var l=typeof w1==\"function\"&&w1,c=0;c<s.length;c++)o(s[c]);return o})({1:[function(n,r,i){var s=n(\"./utils\"),o=n(\"./support\"),l=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\";i.encode=function(c){for(var d,u,m,p,f,y,v,b=[],g=0,_=c.length,C=_,P=s.getTypeOf(c)!==\"string\";g<c.length;)C=_-g,m=P?(d=c[g++],u=g<_?c[g++]:0,g<_?c[g++]:0):(d=c.charCodeAt(g++),u=g<_?c.charCodeAt(g++):0,g<_?c.charCodeAt(g++):0),p=d>>2,f=(3&d)<<4|u>>4,y=1<C?(15&u)<<2|m>>6:64,v=2<C?63&m:64,b.push(l.charAt(p)+l.charAt(f)+l.charAt(y)+l.charAt(v));return b.join(\"\")},i.decode=function(c){var d,u,m,p,f,y,v=0,b=0,g=\"data:\";if(c.substr(0,g.length)===g)throw new Error(\"Invalid base64 input, it looks like a data url.\");var _,C=3*(c=c.replace(/[^A-Za-z0-9+/=]/g,\"\")).length/4;if(c.charAt(c.length-1)===l.charAt(64)&&C--,c.charAt(c.length-2)===l.charAt(64)&&C--,C%1!=0)throw new Error(\"Invalid base64 input, bad content length.\");for(_=o.uint8array?new Uint8Array(0|C):new Array(0|C);v<c.length;)d=l.indexOf(c.charAt(v++))<<2|(p=l.indexOf(c.charAt(v++)))>>4,u=(15&p)<<4|(f=l.indexOf(c.charAt(v++)))>>2,m=(3&f)<<6|(y=l.indexOf(c.charAt(v++))),_[b++]=d,f!==64&&(_[b++]=u),y!==64&&(_[b++]=m);return _}},{\"./support\":30,\"./utils\":32}],2:[function(n,r,i){var s=n(\"./external\"),o=n(\"./stream/DataWorker\"),l=n(\"./stream/Crc32Probe\"),c=n(\"./stream/DataLengthProbe\");function d(u,m,p,f,y){this.compressedSize=u,this.uncompressedSize=m,this.crc32=p,this.compression=f,this.compressedContent=y}d.prototype={getContentWorker:function(){var u=new o(s.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new c(\"data_length\")),m=this;return u.on(\"end\",function(){if(this.streamInfo.data_length!==m.uncompressedSize)throw new Error(\"Bug : uncompressed data size mismatch\")}),u},getCompressedWorker:function(){return new o(s.Promise.resolve(this.compressedContent)).withStreamInfo(\"compressedSize\",this.compressedSize).withStreamInfo(\"uncompressedSize\",this.uncompressedSize).withStreamInfo(\"crc32\",this.crc32).withStreamInfo(\"compression\",this.compression)}},d.createWorkerFrom=function(u,m,p){return u.pipe(new l).pipe(new c(\"uncompressedSize\")).pipe(m.compressWorker(p)).pipe(new c(\"compressedSize\")).withStreamInfo(\"compression\",m)},r.exports=d},{\"./external\":6,\"./stream/Crc32Probe\":25,\"./stream/DataLengthProbe\":26,\"./stream/DataWorker\":27}],3:[function(n,r,i){var s=n(\"./stream/GenericWorker\");i.STORE={magic:\"\\0\\0\",compressWorker:function(){return new s(\"STORE compression\")},uncompressWorker:function(){return new s(\"STORE decompression\")}},i.DEFLATE=n(\"./flate\")},{\"./flate\":7,\"./stream/GenericWorker\":28}],4:[function(n,r,i){var s=n(\"./utils\"),o=(function(){for(var l,c=[],d=0;d<256;d++){l=d;for(var u=0;u<8;u++)l=1&l?3988292384^l>>>1:l>>>1;c[d]=l}return c})();r.exports=function(l,c){return l!==void 0&&l.length?s.getTypeOf(l)!==\"string\"?(function(d,u,m,p){var f=o,y=p+m;d^=-1;for(var v=p;v<y;v++)d=d>>>8^f[255&(d^u[v])];return-1^d})(0|c,l,l.length,0):(function(d,u,m,p){var f=o,y=p+m;d^=-1;for(var v=p;v<y;v++)d=d>>>8^f[255&(d^u.charCodeAt(v))];return-1^d})(0|c,l,l.length,0):0}},{\"./utils\":32}],5:[function(n,r,i){i.base64=!1,i.binary=!1,i.dir=!1,i.createFolders=!0,i.date=null,i.compression=null,i.compressionOptions=null,i.comment=null,i.unixPermissions=null,i.dosPermissions=null},{}],6:[function(n,r,i){var s=null;s=typeof Promise<\"u\"?Promise:n(\"lie\"),r.exports={Promise:s}},{lie:37}],7:[function(n,r,i){var s=typeof Uint8Array<\"u\"&&typeof Uint16Array<\"u\"&&typeof Uint32Array<\"u\",o=n(\"pako\"),l=n(\"./utils\"),c=n(\"./stream/GenericWorker\"),d=s?\"uint8array\":\"array\";function u(m,p){c.call(this,\"FlateWorker/\"+m),this._pako=null,this._pakoAction=m,this._pakoOptions=p,this.meta={}}i.magic=\"\\b\\0\",l.inherits(u,c),u.prototype.processChunk=function(m){this.meta=m.meta,this._pako===null&&this._createPako(),this._pako.push(l.transformTo(d,m.data),!1)},u.prototype.flush=function(){c.prototype.flush.call(this),this._pako===null&&this._createPako(),this._pako.push([],!0)},u.prototype.cleanUp=function(){c.prototype.cleanUp.call(this),this._pako=null},u.prototype._createPako=function(){this._pako=new o[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var m=this;this._pako.onData=function(p){m.push({data:p,meta:m.meta})}},i.compressWorker=function(m){return new u(\"Deflate\",m)},i.uncompressWorker=function(){return new u(\"Inflate\",{})}},{\"./stream/GenericWorker\":28,\"./utils\":32,pako:38}],8:[function(n,r,i){function s(f,y){var v,b=\"\";for(v=0;v<y;v++)b+=String.fromCharCode(255&f),f>>>=8;return b}function o(f,y,v,b,g,_){var C,P,N=f.file,A=f.compression,T=_!==d.utf8encode,F=l.transformTo(\"string\",_(N.name)),k=l.transformTo(\"string\",d.utf8encode(N.name)),D=N.comment,H=l.transformTo(\"string\",_(D)),z=l.transformTo(\"string\",d.utf8encode(D)),Q=k.length!==N.name.length,L=z.length!==D.length,te=\"\",ie=\"\",J=\"\",oe=N.dir,fe=N.date,re={crc32:0,compressedSize:0,uncompressedSize:0};y&&!v||(re.crc32=f.crc32,re.compressedSize=f.compressedSize,re.uncompressedSize=f.uncompressedSize);var W=0;y&&(W|=8),T||!Q&&!L||(W|=2048);var ne=0,me=0;oe&&(ne|=16),g===\"UNIX\"?(me=798,ne|=(function(Ce,q){var Y=Ce;return Ce||(Y=q?16893:33204),(65535&Y)<<16})(N.unixPermissions,oe)):(me=20,ne|=(function(Ce){return 63&(Ce||0)})(N.dosPermissions)),C=fe.getUTCHours(),C<<=6,C|=fe.getUTCMinutes(),C<<=5,C|=fe.getUTCSeconds()/2,P=fe.getUTCFullYear()-1980,P<<=4,P|=fe.getUTCMonth()+1,P<<=5,P|=fe.getUTCDate(),Q&&(ie=s(1,1)+s(u(F),4)+k,te+=\"up\"+s(ie.length,2)+ie),L&&(J=s(1,1)+s(u(H),4)+z,te+=\"uc\"+s(J.length,2)+J);var be=\"\";return be+=`\n\\0`,be+=s(W,2),be+=A.magic,be+=s(C,2),be+=s(P,2),be+=s(re.crc32,4),be+=s(re.compressedSize,4),be+=s(re.uncompressedSize,4),be+=s(F.length,2),be+=s(te.length,2),{fileRecord:m.LOCAL_FILE_HEADER+be+F+te,dirRecord:m.CENTRAL_FILE_HEADER+s(me,2)+be+s(H.length,2)+\"\\0\\0\\0\\0\"+s(ne,4)+s(b,4)+F+te+H}}var l=n(\"../utils\"),c=n(\"../stream/GenericWorker\"),d=n(\"../utf8\"),u=n(\"../crc32\"),m=n(\"../signature\");function p(f,y,v,b){c.call(this,\"ZipFileWorker\"),this.bytesWritten=0,this.zipComment=y,this.zipPlatform=v,this.encodeFileName=b,this.streamFiles=f,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}l.inherits(p,c),p.prototype.push=function(f){var y=f.meta.percent||0,v=this.entriesCount,b=this._sources.length;this.accumulate?this.contentBuffer.push(f):(this.bytesWritten+=f.data.length,c.prototype.push.call(this,{data:f.data,meta:{currentFile:this.currentFile,percent:v?(y+100*(v-b-1))/v:100}}))},p.prototype.openedSource=function(f){this.currentSourceOffset=this.bytesWritten,this.currentFile=f.file.name;var y=this.streamFiles&&!f.file.dir;if(y){var v=o(f,y,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:v.fileRecord,meta:{percent:0}})}else this.accumulate=!0},p.prototype.closedSource=function(f){this.accumulate=!1;var y=this.streamFiles&&!f.file.dir,v=o(f,y,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(v.dirRecord),y)this.push({data:(function(b){return m.DATA_DESCRIPTOR+s(b.crc32,4)+s(b.compressedSize,4)+s(b.uncompressedSize,4)})(f),meta:{percent:100}});else for(this.push({data:v.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},p.prototype.flush=function(){for(var f=this.bytesWritten,y=0;y<this.dirRecords.length;y++)this.push({data:this.dirRecords[y],meta:{percent:100}});var v=this.bytesWritten-f,b=(function(g,_,C,P,N){var A=l.transformTo(\"string\",N(P));return m.CENTRAL_DIRECTORY_END+\"\\0\\0\\0\\0\"+s(g,2)+s(g,2)+s(_,4)+s(C,4)+s(A.length,2)+A})(this.dirRecords.length,v,f,this.zipComment,this.encodeFileName);this.push({data:b,meta:{percent:100}})},p.prototype.prepareNextSource=function(){this.previous=this._sources.shift(),this.openedSource(this.previous.streamInfo),this.isPaused?this.previous.pause():this.previous.resume()},p.prototype.registerPrevious=function(f){this._sources.push(f);var y=this;return f.on(\"data\",function(v){y.processChunk(v)}),f.on(\"end\",function(){y.closedSource(y.previous.streamInfo),y._sources.length?y.prepareNextSource():y.end()}),f.on(\"error\",function(v){y.error(v)}),this},p.prototype.resume=function(){return!!c.prototype.resume.call(this)&&(!this.previous&&this._sources.length?(this.prepareNextSource(),!0):this.previous||this._sources.length||this.generatedError?void 0:(this.end(),!0))},p.prototype.error=function(f){var y=this._sources;if(!c.prototype.error.call(this,f))return!1;for(var v=0;v<y.length;v++)try{y[v].error(f)}catch{}return!0},p.prototype.lock=function(){c.prototype.lock.call(this);for(var f=this._sources,y=0;y<f.length;y++)f[y].lock()},r.exports=p},{\"../crc32\":4,\"../signature\":23,\"../stream/GenericWorker\":28,\"../utf8\":31,\"../utils\":32}],9:[function(n,r,i){var s=n(\"../compressions\"),o=n(\"./ZipFileWorker\");i.generateWorker=function(l,c,d){var u=new o(c.streamFiles,d,c.platform,c.encodeFileName),m=0;try{l.forEach(function(p,f){m++;var y=(function(_,C){var P=_||C,N=s[P];if(!N)throw new Error(P+\" is not a valid compression method !\");return N})(f.options.compression,c.compression),v=f.options.compressionOptions||c.compressionOptions||{},b=f.dir,g=f.date;f._compressWorker(y,v).withStreamInfo(\"file\",{name:p,dir:b,date:g,comment:f.comment||\"\",unixPermissions:f.unixPermissions,dosPermissions:f.dosPermissions}).pipe(u)}),u.entriesCount=m}catch(p){u.error(p)}return u}},{\"../compressions\":3,\"./ZipFileWorker\":8}],10:[function(n,r,i){function s(){if(!(this instanceof s))return new s;if(arguments.length)throw new Error(\"The constructor with parameters has been removed in JSZip 3.0, please check the upgrade guide.\");this.files=Object.create(null),this.comment=null,this.root=\"\",this.clone=function(){var o=new s;for(var l in this)typeof this[l]!=\"function\"&&(o[l]=this[l]);return o}}(s.prototype=n(\"./object\")).loadAsync=n(\"./load\"),s.support=n(\"./support\"),s.defaults=n(\"./defaults\"),s.version=\"3.10.1\",s.loadAsync=function(o,l){return new s().loadAsync(o,l)},s.external=n(\"./external\"),r.exports=s},{\"./defaults\":5,\"./external\":6,\"./load\":11,\"./object\":15,\"./support\":30}],11:[function(n,r,i){var s=n(\"./utils\"),o=n(\"./external\"),l=n(\"./utf8\"),c=n(\"./zipEntries\"),d=n(\"./stream/Crc32Probe\"),u=n(\"./nodejsUtils\");function m(p){return new o.Promise(function(f,y){var v=p.decompressed.getContentWorker().pipe(new d);v.on(\"error\",function(b){y(b)}).on(\"end\",function(){v.streamInfo.crc32!==p.decompressed.crc32?y(new Error(\"Corrupted zip : CRC32 mismatch\")):f()}).resume()})}r.exports=function(p,f){var y=this;return f=s.extend(f||{},{base64:!1,checkCRC32:!1,optimizedBinaryString:!1,createFolders:!1,decodeFileName:l.utf8decode}),u.isNode&&u.isStream(p)?o.Promise.reject(new Error(\"JSZip can't accept a stream when loading a zip file.\")):s.prepareContent(\"the loaded zip file\",p,!0,f.optimizedBinaryString,f.base64).then(function(v){var b=new c(f);return b.load(v),b}).then(function(v){var b=[o.Promise.resolve(v)],g=v.files;if(f.checkCRC32)for(var _=0;_<g.length;_++)b.push(m(g[_]));return o.Promise.all(b)}).then(function(v){for(var b=v.shift(),g=b.files,_=0;_<g.length;_++){var C=g[_],P=C.fileNameStr,N=s.resolve(C.fileNameStr);y.file(N,C.decompressed,{binary:!0,optimizedBinaryString:!0,date:C.date,dir:C.dir,comment:C.fileCommentStr.length?C.fileCommentStr:null,unixPermissions:C.unixPermissions,dosPermissions:C.dosPermissions,createFolders:f.createFolders}),C.dir||(y.file(N).unsafeOriginalName=P)}return b.zipComment.length&&(y.comment=b.zipComment),y})}},{\"./external\":6,\"./nodejsUtils\":14,\"./stream/Crc32Probe\":25,\"./utf8\":31,\"./utils\":32,\"./zipEntries\":33}],12:[function(n,r,i){var s=n(\"../utils\"),o=n(\"../stream/GenericWorker\");function l(c,d){o.call(this,\"Nodejs stream input adapter for \"+c),this._upstreamEnded=!1,this._bindStream(d)}s.inherits(l,o),l.prototype._bindStream=function(c){var d=this;(this._stream=c).pause(),c.on(\"data\",function(u){d.push({data:u,meta:{percent:0}})}).on(\"error\",function(u){d.isPaused?this.generatedError=u:d.error(u)}).on(\"end\",function(){d.isPaused?d._upstreamEnded=!0:d.end()})},l.prototype.pause=function(){return!!o.prototype.pause.call(this)&&(this._stream.pause(),!0)},l.prototype.resume=function(){return!!o.prototype.resume.call(this)&&(this._upstreamEnded?this.end():this._stream.resume(),!0)},r.exports=l},{\"../stream/GenericWorker\":28,\"../utils\":32}],13:[function(n,r,i){var s=n(\"readable-stream\").Readable;function o(l,c,d){s.call(this,c),this._helper=l;var u=this;l.on(\"data\",function(m,p){u.push(m)||u._helper.pause(),d&&d(p)}).on(\"error\",function(m){u.emit(\"error\",m)}).on(\"end\",function(){u.push(null)})}n(\"../utils\").inherits(o,s),o.prototype._read=function(){this._helper.resume()},r.exports=o},{\"../utils\":32,\"readable-stream\":16}],14:[function(n,r,i){r.exports={isNode:typeof Buffer<\"u\",newBufferFrom:function(s,o){if(Buffer.from&&Buffer.from!==Uint8Array.from)return Buffer.from(s,o);if(typeof s==\"number\")throw new Error('The \"data\" argument must not be a number');return new Buffer(s,o)},allocBuffer:function(s){if(Buffer.alloc)return Buffer.alloc(s);var o=new Buffer(s);return o.fill(0),o},isBuffer:function(s){return Buffer.isBuffer(s)},isStream:function(s){return s&&typeof s.on==\"function\"&&typeof s.pause==\"function\"&&typeof s.resume==\"function\"}}},{}],15:[function(n,r,i){function s(N,A,T){var F,k=l.getTypeOf(A),D=l.extend(T||{},u);D.date=D.date||new Date,D.compression!==null&&(D.compression=D.compression.toUpperCase()),typeof D.unixPermissions==\"string\"&&(D.unixPermissions=parseInt(D.unixPermissions,8)),D.unixPermissions&&16384&D.unixPermissions&&(D.dir=!0),D.dosPermissions&&16&D.dosPermissions&&(D.dir=!0),D.dir&&(N=g(N)),D.createFolders&&(F=b(N))&&_.call(this,F,!0);var H=k===\"string\"&&D.binary===!1&&D.base64===!1;T&&T.binary!==void 0||(D.binary=!H),(A instanceof m&&A.uncompressedSize===0||D.dir||!A||A.length===0)&&(D.base64=!1,D.binary=!0,A=\"\",D.compression=\"STORE\",k=\"string\");var z=null;z=A instanceof m||A instanceof c?A:y.isNode&&y.isStream(A)?new v(N,A):l.prepareContent(N,A,D.binary,D.optimizedBinaryString,D.base64);var Q=new p(N,z,D);this.files[N]=Q}var o=n(\"./utf8\"),l=n(\"./utils\"),c=n(\"./stream/GenericWorker\"),d=n(\"./stream/StreamHelper\"),u=n(\"./defaults\"),m=n(\"./compressedObject\"),p=n(\"./zipObject\"),f=n(\"./generate\"),y=n(\"./nodejsUtils\"),v=n(\"./nodejs/NodejsStreamInputAdapter\"),b=function(N){N.slice(-1)===\"/\"&&(N=N.substring(0,N.length-1));var A=N.lastIndexOf(\"/\");return 0<A?N.substring(0,A):\"\"},g=function(N){return N.slice(-1)!==\"/\"&&(N+=\"/\"),N},_=function(N,A){return A=A!==void 0?A:u.createFolders,N=g(N),this.files[N]||s.call(this,N,null,{dir:!0,createFolders:A}),this.files[N]};function C(N){return Object.prototype.toString.call(N)===\"[object RegExp]\"}var P={load:function(){throw new Error(\"This method has been removed in JSZip 3.0, please check the upgrade guide.\")},forEach:function(N){var A,T,F;for(A in this.files)F=this.files[A],(T=A.slice(this.root.length,A.length))&&A.slice(0,this.root.length)===this.root&&N(T,F)},filter:function(N){var A=[];return this.forEach(function(T,F){N(T,F)&&A.push(F)}),A},file:function(N,A,T){if(arguments.length!==1)return N=this.root+N,s.call(this,N,A,T),this;if(C(N)){var F=N;return this.filter(function(D,H){return!H.dir&&F.test(D)})}var k=this.files[this.root+N];return k&&!k.dir?k:null},folder:function(N){if(!N)return this;if(C(N))return this.filter(function(k,D){return D.dir&&N.test(k)});var A=this.root+N,T=_.call(this,A),F=this.clone();return F.root=T.name,F},remove:function(N){N=this.root+N;var A=this.files[N];if(A||(N.slice(-1)!==\"/\"&&(N+=\"/\"),A=this.files[N]),A&&!A.dir)delete this.files[N];else for(var T=this.filter(function(k,D){return D.name.slice(0,N.length)===N}),F=0;F<T.length;F++)delete this.files[T[F].name];return this},generate:function(){throw new Error(\"This method has been removed in JSZip 3.0, please check the upgrade guide.\")},generateInternalStream:function(N){var A,T={};try{if((T=l.extend(N||{},{streamFiles:!1,compression:\"STORE\",compressionOptions:null,type:\"\",platform:\"DOS\",comment:null,mimeType:\"application/zip\",encodeFileName:o.utf8encode})).type=T.type.toLowerCase(),T.compression=T.compression.toUpperCase(),T.type===\"binarystring\"&&(T.type=\"string\"),!T.type)throw new Error(\"No output type specified.\");l.checkSupport(T.type),T.platform!==\"darwin\"&&T.platform!==\"freebsd\"&&T.platform!==\"linux\"&&T.platform!==\"sunos\"||(T.platform=\"UNIX\"),T.platform===\"win32\"&&(T.platform=\"DOS\");var F=T.comment||this.comment||\"\";A=f.generateWorker(this,T,F)}catch(k){(A=new c(\"error\")).error(k)}return new d(A,T.type||\"string\",T.mimeType)},generateAsync:function(N,A){return this.generateInternalStream(N).accumulate(A)},generateNodeStream:function(N,A){return(N=N||{}).type||(N.type=\"nodebuffer\"),this.generateInternalStream(N).toNodejsStream(A)}};r.exports=P},{\"./compressedObject\":2,\"./defaults\":5,\"./generate\":9,\"./nodejs/NodejsStreamInputAdapter\":12,\"./nodejsUtils\":14,\"./stream/GenericWorker\":28,\"./stream/StreamHelper\":29,\"./utf8\":31,\"./utils\":32,\"./zipObject\":35}],16:[function(n,r,i){r.exports=n(\"stream\")},{stream:void 0}],17:[function(n,r,i){var s=n(\"./DataReader\");function o(l){s.call(this,l);for(var c=0;c<this.data.length;c++)l[c]=255&l[c]}n(\"../utils\").inherits(o,s),o.prototype.byteAt=function(l){return this.data[this.zero+l]},o.prototype.lastIndexOfSignature=function(l){for(var c=l.charCodeAt(0),d=l.charCodeAt(1),u=l.charCodeAt(2),m=l.charCodeAt(3),p=this.length-4;0<=p;--p)if(this.data[p]===c&&this.data[p+1]===d&&this.data[p+2]===u&&this.data[p+3]===m)return p-this.zero;return-1},o.prototype.readAndCheckSignature=function(l){var c=l.charCodeAt(0),d=l.charCodeAt(1),u=l.charCodeAt(2),m=l.charCodeAt(3),p=this.readData(4);return c===p[0]&&d===p[1]&&u===p[2]&&m===p[3]},o.prototype.readData=function(l){if(this.checkOffset(l),l===0)return[];var c=this.data.slice(this.zero+this.index,this.zero+this.index+l);return this.index+=l,c},r.exports=o},{\"../utils\":32,\"./DataReader\":18}],18:[function(n,r,i){var s=n(\"../utils\");function o(l){this.data=l,this.length=l.length,this.index=0,this.zero=0}o.prototype={checkOffset:function(l){this.checkIndex(this.index+l)},checkIndex:function(l){if(this.length<this.zero+l||l<0)throw new Error(\"End of data reached (data length = \"+this.length+\", asked index = \"+l+\"). Corrupted zip ?\")},setIndex:function(l){this.checkIndex(l),this.index=l},skip:function(l){this.setIndex(this.index+l)},byteAt:function(){},readInt:function(l){var c,d=0;for(this.checkOffset(l),c=this.index+l-1;c>=this.index;c--)d=(d<<8)+this.byteAt(c);return this.index+=l,d},readString:function(l){return s.transformTo(\"string\",this.readData(l))},readData:function(){},lastIndexOfSignature:function(){},readAndCheckSignature:function(){},readDate:function(){var l=this.readInt(4);return new Date(Date.UTC(1980+(l>>25&127),(l>>21&15)-1,l>>16&31,l>>11&31,l>>5&63,(31&l)<<1))}},r.exports=o},{\"../utils\":32}],19:[function(n,r,i){var s=n(\"./Uint8ArrayReader\");function o(l){s.call(this,l)}n(\"../utils\").inherits(o,s),o.prototype.readData=function(l){this.checkOffset(l);var c=this.data.slice(this.zero+this.index,this.zero+this.index+l);return this.index+=l,c},r.exports=o},{\"../utils\":32,\"./Uint8ArrayReader\":21}],20:[function(n,r,i){var s=n(\"./DataReader\");function o(l){s.call(this,l)}n(\"../utils\").inherits(o,s),o.prototype.byteAt=function(l){return this.data.charCodeAt(this.zero+l)},o.prototype.lastIndexOfSignature=function(l){return this.data.lastIndexOf(l)-this.zero},o.prototype.readAndCheckSignature=function(l){return l===this.readData(4)},o.prototype.readData=function(l){this.checkOffset(l);var c=this.data.slice(this.zero+this.index,this.zero+this.index+l);return this.index+=l,c},r.exports=o},{\"../utils\":32,\"./DataReader\":18}],21:[function(n,r,i){var s=n(\"./ArrayReader\");function o(l){s.call(this,l)}n(\"../utils\").inherits(o,s),o.prototype.readData=function(l){if(this.checkOffset(l),l===0)return new Uint8Array(0);var c=this.data.subarray(this.zero+this.index,this.zero+this.index+l);return this.index+=l,c},r.exports=o},{\"../utils\":32,\"./ArrayReader\":17}],22:[function(n,r,i){var s=n(\"../utils\"),o=n(\"../support\"),l=n(\"./ArrayReader\"),c=n(\"./StringReader\"),d=n(\"./NodeBufferReader\"),u=n(\"./Uint8ArrayReader\");r.exports=function(m){var p=s.getTypeOf(m);return s.checkSupport(p),p!==\"string\"||o.uint8array?p===\"nodebuffer\"?new d(m):o.uint8array?new u(s.transformTo(\"uint8array\",m)):new l(s.transformTo(\"array\",m)):new c(m)}},{\"../support\":30,\"../utils\":32,\"./ArrayReader\":17,\"./NodeBufferReader\":19,\"./StringReader\":20,\"./Uint8ArrayReader\":21}],23:[function(n,r,i){i.LOCAL_FILE_HEADER=\"PK\u0003\u0004\",i.CENTRAL_FILE_HEADER=\"PK\u0001\u0002\",i.CENTRAL_DIRECTORY_END=\"PK\u0005\u0006\",i.ZIP64_CENTRAL_DIRECTORY_LOCATOR=\"PK\u0006\\x07\",i.ZIP64_CENTRAL_DIRECTORY_END=\"PK\u0006\u0006\",i.DATA_DESCRIPTOR=\"PK\\x07\\b\"},{}],24:[function(n,r,i){var s=n(\"./GenericWorker\"),o=n(\"../utils\");function l(c){s.call(this,\"ConvertWorker to \"+c),this.destType=c}o.inherits(l,s),l.prototype.processChunk=function(c){this.push({data:o.transformTo(this.destType,c.data),meta:c.meta})},r.exports=l},{\"../utils\":32,\"./GenericWorker\":28}],25:[function(n,r,i){var s=n(\"./GenericWorker\"),o=n(\"../crc32\");function l(){s.call(this,\"Crc32Probe\"),this.withStreamInfo(\"crc32\",0)}n(\"../utils\").inherits(l,s),l.prototype.processChunk=function(c){this.streamInfo.crc32=o(c.data,this.streamInfo.crc32||0),this.push(c)},r.exports=l},{\"../crc32\":4,\"../utils\":32,\"./GenericWorker\":28}],26:[function(n,r,i){var s=n(\"../utils\"),o=n(\"./GenericWorker\");function l(c){o.call(this,\"DataLengthProbe for \"+c),this.propName=c,this.withStreamInfo(c,0)}s.inherits(l,o),l.prototype.processChunk=function(c){if(c){var d=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=d+c.data.length}o.prototype.processChunk.call(this,c)},r.exports=l},{\"../utils\":32,\"./GenericWorker\":28}],27:[function(n,r,i){var s=n(\"../utils\"),o=n(\"./GenericWorker\");function l(c){o.call(this,\"DataWorker\");var d=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type=\"\",this._tickScheduled=!1,c.then(function(u){d.dataIsReady=!0,d.data=u,d.max=u&&u.length||0,d.type=s.getTypeOf(u),d.isPaused||d._tickAndRepeat()},function(u){d.error(u)})}s.inherits(l,o),l.prototype.cleanUp=function(){o.prototype.cleanUp.call(this),this.data=null},l.prototype.resume=function(){return!!o.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,s.delay(this._tickAndRepeat,[],this)),!0)},l.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(s.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},l.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var c=null,d=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case\"string\":c=this.data.substring(this.index,d);break;case\"uint8array\":c=this.data.subarray(this.index,d);break;case\"array\":case\"nodebuffer\":c=this.data.slice(this.index,d)}return this.index=d,this.push({data:c,meta:{percent:this.max?this.index/this.max*100:0}})},r.exports=l},{\"../utils\":32,\"./GenericWorker\":28}],28:[function(n,r,i){function s(o){this.name=o||\"default\",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}s.prototype={push:function(o){this.emit(\"data\",o)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit(\"end\"),this.cleanUp(),this.isFinished=!0}catch(o){this.emit(\"error\",o)}return!0},error:function(o){return!this.isFinished&&(this.isPaused?this.generatedError=o:(this.isFinished=!0,this.emit(\"error\",o),this.previous&&this.previous.error(o),this.cleanUp()),!0)},on:function(o,l){return this._listeners[o].push(l),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(o,l){if(this._listeners[o])for(var c=0;c<this._listeners[o].length;c++)this._listeners[o][c].call(this,l)},pipe:function(o){return o.registerPrevious(this)},registerPrevious:function(o){if(this.isLocked)throw new Error(\"The stream '\"+this+\"' has already been used.\");this.streamInfo=o.streamInfo,this.mergeStreamInfo(),this.previous=o;var l=this;return o.on(\"data\",function(c){l.processChunk(c)}),o.on(\"end\",function(){l.end()}),o.on(\"error\",function(c){l.error(c)}),this},pause:function(){return!this.isPaused&&!this.isFinished&&(this.isPaused=!0,this.previous&&this.previous.pause(),!0)},resume:function(){if(!this.isPaused||this.isFinished)return!1;var o=this.isPaused=!1;return this.generatedError&&(this.error(this.generatedError),o=!0),this.previous&&this.previous.resume(),!o},flush:function(){},processChunk:function(o){this.push(o)},withStreamInfo:function(o,l){return this.extraStreamInfo[o]=l,this.mergeStreamInfo(),this},mergeStreamInfo:function(){for(var o in this.extraStreamInfo)Object.prototype.hasOwnProperty.call(this.extraStreamInfo,o)&&(this.streamInfo[o]=this.extraStreamInfo[o])},lock:function(){if(this.isLocked)throw new Error(\"The stream '\"+this+\"' has already been used.\");this.isLocked=!0,this.previous&&this.previous.lock()},toString:function(){var o=\"Worker \"+this.name;return this.previous?this.previous+\" -> \"+o:o}},r.exports=s},{}],29:[function(n,r,i){var s=n(\"../utils\"),o=n(\"./ConvertWorker\"),l=n(\"./GenericWorker\"),c=n(\"../base64\"),d=n(\"../support\"),u=n(\"../external\"),m=null;if(d.nodestream)try{m=n(\"../nodejs/NodejsStreamOutputAdapter\")}catch{}function p(y,v){return new u.Promise(function(b,g){var _=[],C=y._internalType,P=y._outputType,N=y._mimeType;y.on(\"data\",function(A,T){_.push(A),v&&v(T)}).on(\"error\",function(A){_=[],g(A)}).on(\"end\",function(){try{var A=(function(T,F,k){switch(T){case\"blob\":return s.newBlob(s.transformTo(\"arraybuffer\",F),k);case\"base64\":return c.encode(F);default:return s.transformTo(T,F)}})(P,(function(T,F){var k,D=0,H=null,z=0;for(k=0;k<F.length;k++)z+=F[k].length;switch(T){case\"string\":return F.join(\"\");case\"array\":return Array.prototype.concat.apply([],F);case\"uint8array\":for(H=new Uint8Array(z),k=0;k<F.length;k++)H.set(F[k],D),D+=F[k].length;return H;case\"nodebuffer\":return Buffer.concat(F);default:throw new Error(\"concat : unsupported type '\"+T+\"'\")}})(C,_),N);b(A)}catch(T){g(T)}_=[]}).resume()})}function f(y,v,b){var g=v;switch(v){case\"blob\":case\"arraybuffer\":g=\"uint8array\";break;case\"base64\":g=\"string\"}try{this._internalType=g,this._outputType=v,this._mimeType=b,s.checkSupport(g),this._worker=y.pipe(new o(g)),y.lock()}catch(_){this._worker=new l(\"error\"),this._worker.error(_)}}f.prototype={accumulate:function(y){return p(this,y)},on:function(y,v){var b=this;return y===\"data\"?this._worker.on(y,function(g){v.call(b,g.data,g.meta)}):this._worker.on(y,function(){s.delay(v,arguments,b)}),this},resume:function(){return s.delay(this._worker.resume,[],this._worker),this},pause:function(){return this._worker.pause(),this},toNodejsStream:function(y){if(s.checkSupport(\"nodestream\"),this._outputType!==\"nodebuffer\")throw new Error(this._outputType+\" is not supported by this method\");return new m(this,{objectMode:this._outputType!==\"nodebuffer\"},y)}},r.exports=f},{\"../base64\":1,\"../external\":6,\"../nodejs/NodejsStreamOutputAdapter\":13,\"../support\":30,\"../utils\":32,\"./ConvertWorker\":24,\"./GenericWorker\":28}],30:[function(n,r,i){if(i.base64=!0,i.array=!0,i.string=!0,i.arraybuffer=typeof ArrayBuffer<\"u\"&&typeof Uint8Array<\"u\",i.nodebuffer=typeof Buffer<\"u\",i.uint8array=typeof Uint8Array<\"u\",typeof ArrayBuffer>\"u\")i.blob=!1;else{var s=new ArrayBuffer(0);try{i.blob=new Blob([s],{type:\"application/zip\"}).size===0}catch{try{var o=new(self.BlobBuilder||self.WebKitBlobBuilder||self.MozBlobBuilder||self.MSBlobBuilder);o.append(s),i.blob=o.getBlob(\"application/zip\").size===0}catch{i.blob=!1}}}try{i.nodestream=!!n(\"readable-stream\").Readable}catch{i.nodestream=!1}},{\"readable-stream\":16}],31:[function(n,r,i){for(var s=n(\"./utils\"),o=n(\"./support\"),l=n(\"./nodejsUtils\"),c=n(\"./stream/GenericWorker\"),d=new Array(256),u=0;u<256;u++)d[u]=252<=u?6:248<=u?5:240<=u?4:224<=u?3:192<=u?2:1;d[254]=d[254]=1;function m(){c.call(this,\"utf-8 decode\"),this.leftOver=null}function p(){c.call(this,\"utf-8 encode\")}i.utf8encode=function(f){return o.nodebuffer?l.newBufferFrom(f,\"utf-8\"):(function(y){var v,b,g,_,C,P=y.length,N=0;for(_=0;_<P;_++)(64512&(b=y.charCodeAt(_)))==55296&&_+1<P&&(64512&(g=y.charCodeAt(_+1)))==56320&&(b=65536+(b-55296<<10)+(g-56320),_++),N+=b<128?1:b<2048?2:b<65536?3:4;for(v=o.uint8array?new Uint8Array(N):new Array(N),_=C=0;C<N;_++)(64512&(b=y.charCodeAt(_)))==55296&&_+1<P&&(64512&(g=y.charCodeAt(_+1)))==56320&&(b=65536+(b-55296<<10)+(g-56320),_++),b<128?v[C++]=b:(b<2048?v[C++]=192|b>>>6:(b<65536?v[C++]=224|b>>>12:(v[C++]=240|b>>>18,v[C++]=128|b>>>12&63),v[C++]=128|b>>>6&63),v[C++]=128|63&b);return v})(f)},i.utf8decode=function(f){return o.nodebuffer?s.transformTo(\"nodebuffer\",f).toString(\"utf-8\"):(function(y){var v,b,g,_,C=y.length,P=new Array(2*C);for(v=b=0;v<C;)if((g=y[v++])<128)P[b++]=g;else if(4<(_=d[g]))P[b++]=65533,v+=_-1;else{for(g&=_===2?31:_===3?15:7;1<_&&v<C;)g=g<<6|63&y[v++],_--;1<_?P[b++]=65533:g<65536?P[b++]=g:(g-=65536,P[b++]=55296|g>>10&1023,P[b++]=56320|1023&g)}return P.length!==b&&(P.subarray?P=P.subarray(0,b):P.length=b),s.applyFromCharCode(P)})(f=s.transformTo(o.uint8array?\"uint8array\":\"array\",f))},s.inherits(m,c),m.prototype.processChunk=function(f){var y=s.transformTo(o.uint8array?\"uint8array\":\"array\",f.data);if(this.leftOver&&this.leftOver.length){if(o.uint8array){var v=y;(y=new Uint8Array(v.length+this.leftOver.length)).set(this.leftOver,0),y.set(v,this.leftOver.length)}else y=this.leftOver.concat(y);this.leftOver=null}var b=(function(_,C){var P;for((C=C||_.length)>_.length&&(C=_.length),P=C-1;0<=P&&(192&_[P])==128;)P--;return P<0||P===0?C:P+d[_[P]]>C?P:C})(y),g=y;b!==y.length&&(o.uint8array?(g=y.subarray(0,b),this.leftOver=y.subarray(b,y.length)):(g=y.slice(0,b),this.leftOver=y.slice(b,y.length))),this.push({data:i.utf8decode(g),meta:f.meta})},m.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:i.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},i.Utf8DecodeWorker=m,s.inherits(p,c),p.prototype.processChunk=function(f){this.push({data:i.utf8encode(f.data),meta:f.meta})},i.Utf8EncodeWorker=p},{\"./nodejsUtils\":14,\"./stream/GenericWorker\":28,\"./support\":30,\"./utils\":32}],32:[function(n,r,i){var s=n(\"./support\"),o=n(\"./base64\"),l=n(\"./nodejsUtils\"),c=n(\"./external\");function d(v){return v}function u(v,b){for(var g=0;g<v.length;++g)b[g]=255&v.charCodeAt(g);return b}n(\"setimmediate\"),i.newBlob=function(v,b){i.checkSupport(\"blob\");try{return new Blob([v],{type:b})}catch{try{var g=new(self.BlobBuilder||self.WebKitBlobBuilder||self.MozBlobBuilder||self.MSBlobBuilder);return g.append(v),g.getBlob(b)}catch{throw new Error(\"Bug : can't construct the Blob.\")}}};var m={stringifyByChunk:function(v,b,g){var _=[],C=0,P=v.length;if(P<=g)return String.fromCharCode.apply(null,v);for(;C<P;)b===\"array\"||b===\"nodebuffer\"?_.push(String.fromCharCode.apply(null,v.slice(C,Math.min(C+g,P)))):_.push(String.fromCharCode.apply(null,v.subarray(C,Math.min(C+g,P)))),C+=g;return _.join(\"\")},stringifyByChar:function(v){for(var b=\"\",g=0;g<v.length;g++)b+=String.fromCharCode(v[g]);return b},applyCanBeUsed:{uint8array:(function(){try{return s.uint8array&&String.fromCharCode.apply(null,new Uint8Array(1)).length===1}catch{return!1}})(),nodebuffer:(function(){try{return s.nodebuffer&&String.fromCharCode.apply(null,l.allocBuffer(1)).length===1}catch{return!1}})()}};function p(v){var b=65536,g=i.getTypeOf(v),_=!0;if(g===\"uint8array\"?_=m.applyCanBeUsed.uint8array:g===\"nodebuffer\"&&(_=m.applyCanBeUsed.nodebuffer),_)for(;1<b;)try{return m.stringifyByChunk(v,g,b)}catch{b=Math.floor(b/2)}return m.stringifyByChar(v)}function f(v,b){for(var g=0;g<v.length;g++)b[g]=v[g];return b}i.applyFromCharCode=p;var y={};y.string={string:d,array:function(v){return u(v,new Array(v.length))},arraybuffer:function(v){return y.string.uint8array(v).buffer},uint8array:function(v){return u(v,new Uint8Array(v.length))},nodebuffer:function(v){return u(v,l.allocBuffer(v.length))}},y.array={string:p,array:d,arraybuffer:function(v){return new Uint8Array(v).buffer},uint8array:function(v){return new Uint8Array(v)},nodebuffer:function(v){return l.newBufferFrom(v)}},y.arraybuffer={string:function(v){return p(new Uint8Array(v))},array:function(v){return f(new Uint8Array(v),new Array(v.byteLength))},arraybuffer:d,uint8array:function(v){return new Uint8Array(v)},nodebuffer:function(v){return l.newBufferFrom(new Uint8Array(v))}},y.uint8array={string:p,array:function(v){return f(v,new Array(v.length))},arraybuffer:function(v){return v.buffer},uint8array:d,nodebuffer:function(v){return l.newBufferFrom(v)}},y.nodebuffer={string:p,array:function(v){return f(v,new Array(v.length))},arraybuffer:function(v){return y.nodebuffer.uint8array(v).buffer},uint8array:function(v){return f(v,new Uint8Array(v.length))},nodebuffer:d},i.transformTo=function(v,b){if(b=b||\"\",!v)return b;i.checkSupport(v);var g=i.getTypeOf(b);return y[g][v](b)},i.resolve=function(v){for(var b=v.split(\"/\"),g=[],_=0;_<b.length;_++){var C=b[_];C===\".\"||C===\"\"&&_!==0&&_!==b.length-1||(C===\"..\"?g.pop():g.push(C))}return g.join(\"/\")},i.getTypeOf=function(v){return typeof v==\"string\"?\"string\":Object.prototype.toString.call(v)===\"[object Array]\"?\"array\":s.nodebuffer&&l.isBuffer(v)?\"nodebuffer\":s.uint8array&&v instanceof Uint8Array?\"uint8array\":s.arraybuffer&&v instanceof ArrayBuffer?\"arraybuffer\":void 0},i.checkSupport=function(v){if(!s[v.toLowerCase()])throw new Error(v+\" is not supported by this platform\")},i.MAX_VALUE_16BITS=65535,i.MAX_VALUE_32BITS=-1,i.pretty=function(v){var b,g,_=\"\";for(g=0;g<(v||\"\").length;g++)_+=\"\\\\x\"+((b=v.charCodeAt(g))<16?\"0\":\"\")+b.toString(16).toUpperCase();return _},i.delay=function(v,b,g){setImmediate(function(){v.apply(g||null,b||[])})},i.inherits=function(v,b){function g(){}g.prototype=b.prototype,v.prototype=new g},i.extend=function(){var v,b,g={};for(v=0;v<arguments.length;v++)for(b in arguments[v])Object.prototype.hasOwnProperty.call(arguments[v],b)&&g[b]===void 0&&(g[b]=arguments[v][b]);return g},i.prepareContent=function(v,b,g,_,C){return c.Promise.resolve(b).then(function(P){return s.blob&&(P instanceof Blob||[\"[object File]\",\"[object Blob]\"].indexOf(Object.prototype.toString.call(P))!==-1)&&typeof FileReader<\"u\"?new c.Promise(function(N,A){var T=new FileReader;T.onload=function(F){N(F.target.result)},T.onerror=function(F){A(F.target.error)},T.readAsArrayBuffer(P)}):P}).then(function(P){var N=i.getTypeOf(P);return N?(N===\"arraybuffer\"?P=i.transformTo(\"uint8array\",P):N===\"string\"&&(C?P=o.decode(P):g&&_!==!0&&(P=(function(A){return u(A,s.uint8array?new Uint8Array(A.length):new Array(A.length))})(P))),P):c.Promise.reject(new Error(\"Can't read the data of '\"+v+\"'. Is it in a supported JavaScript type (String, Blob, ArrayBuffer, etc) ?\"))})}},{\"./base64\":1,\"./external\":6,\"./nodejsUtils\":14,\"./support\":30,setimmediate:54}],33:[function(n,r,i){var s=n(\"./reader/readerFor\"),o=n(\"./utils\"),l=n(\"./signature\"),c=n(\"./zipEntry\"),d=n(\"./support\");function u(m){this.files=[],this.loadOptions=m}u.prototype={checkSignature:function(m){if(!this.reader.readAndCheckSignature(m)){this.reader.index-=4;var p=this.reader.readString(4);throw new Error(\"Corrupted zip or bug: unexpected signature (\"+o.pretty(p)+\", expected \"+o.pretty(m)+\")\")}},isSignature:function(m,p){var f=this.reader.index;this.reader.setIndex(m);var y=this.reader.readString(4)===p;return this.reader.setIndex(f),y},readBlockEndOfCentral:function(){this.diskNumber=this.reader.readInt(2),this.diskWithCentralDirStart=this.reader.readInt(2),this.centralDirRecordsOnThisDisk=this.reader.readInt(2),this.centralDirRecords=this.reader.readInt(2),this.centralDirSize=this.reader.readInt(4),this.centralDirOffset=this.reader.readInt(4),this.zipCommentLength=this.reader.readInt(2);var m=this.reader.readData(this.zipCommentLength),p=d.uint8array?\"uint8array\":\"array\",f=o.transformTo(p,m);this.zipComment=this.loadOptions.decodeFileName(f)},readBlockZip64EndOfCentral:function(){this.zip64EndOfCentralSize=this.reader.readInt(8),this.reader.skip(4),this.diskNumber=this.reader.readInt(4),this.diskWithCentralDirStart=this.reader.readInt(4),this.centralDirRecordsOnThisDisk=this.reader.readInt(8),this.centralDirRecords=this.reader.readInt(8),this.centralDirSize=this.reader.readInt(8),this.centralDirOffset=this.reader.readInt(8),this.zip64ExtensibleData={};for(var m,p,f,y=this.zip64EndOfCentralSize-44;0<y;)m=this.reader.readInt(2),p=this.reader.readInt(4),f=this.reader.readData(p),this.zip64ExtensibleData[m]={id:m,length:p,value:f}},readBlockZip64EndOfCentralLocator:function(){if(this.diskWithZip64CentralDirStart=this.reader.readInt(4),this.relativeOffsetEndOfZip64CentralDir=this.reader.readInt(8),this.disksCount=this.reader.readInt(4),1<this.disksCount)throw new Error(\"Multi-volumes zip are not supported\")},readLocalFiles:function(){var m,p;for(m=0;m<this.files.length;m++)p=this.files[m],this.reader.setIndex(p.localHeaderOffset),this.checkSignature(l.LOCAL_FILE_HEADER),p.readLocalPart(this.reader),p.handleUTF8(),p.processAttributes()},readCentralDir:function(){var m;for(this.reader.setIndex(this.centralDirOffset);this.reader.readAndCheckSignature(l.CENTRAL_FILE_HEADER);)(m=new c({zip64:this.zip64},this.loadOptions)).readCentralPart(this.reader),this.files.push(m);if(this.centralDirRecords!==this.files.length&&this.centralDirRecords!==0&&this.files.length===0)throw new Error(\"Corrupted zip or bug: expected \"+this.centralDirRecords+\" records in central dir, got \"+this.files.length)},readEndOfCentral:function(){var m=this.reader.lastIndexOfSignature(l.CENTRAL_DIRECTORY_END);if(m<0)throw this.isSignature(0,l.LOCAL_FILE_HEADER)?new Error(\"Corrupted zip: can't find end of central directory\"):new Error(\"Can't find end of central directory : is this a zip file ? If it is, see https://stuk.github.io/jszip/documentation/howto/read_zip.html\");this.reader.setIndex(m);var p=m;if(this.checkSignature(l.CENTRAL_DIRECTORY_END),this.readBlockEndOfCentral(),this.diskNumber===o.MAX_VALUE_16BITS||this.diskWithCentralDirStart===o.MAX_VALUE_16BITS||this.centralDirRecordsOnThisDisk===o.MAX_VALUE_16BITS||this.centralDirRecords===o.MAX_VALUE_16BITS||this.centralDirSize===o.MAX_VALUE_32BITS||this.centralDirOffset===o.MAX_VALUE_32BITS){if(this.zip64=!0,(m=this.reader.lastIndexOfSignature(l.ZIP64_CENTRAL_DIRECTORY_LOCATOR))<0)throw new Error(\"Corrupted zip: can't find the ZIP64 end of central directory locator\");if(this.reader.setIndex(m),this.checkSignature(l.ZIP64_CENTRAL_DIRECTORY_LOCATOR),this.readBlockZip64EndOfCentralLocator(),!this.isSignature(this.relativeOffsetEndOfZip64CentralDir,l.ZIP64_CENTRAL_DIRECTORY_END)&&(this.relativeOffsetEndOfZip64CentralDir=this.reader.lastIndexOfSignature(l.ZIP64_CENTRAL_DIRECTORY_END),this.relativeOffsetEndOfZip64CentralDir<0))throw new Error(\"Corrupted zip: can't find the ZIP64 end of central directory\");this.reader.setIndex(this.relativeOffsetEndOfZip64CentralDir),this.checkSignature(l.ZIP64_CENTRAL_DIRECTORY_END),this.readBlockZip64EndOfCentral()}var f=this.centralDirOffset+this.centralDirSize;this.zip64&&(f+=20,f+=12+this.zip64EndOfCentralSize);var y=p-f;if(0<y)this.isSignature(p,l.CENTRAL_FILE_HEADER)||(this.reader.zero=y);else if(y<0)throw new Error(\"Corrupted zip: missing \"+Math.abs(y)+\" bytes.\")},prepareReader:function(m){this.reader=s(m)},load:function(m){this.prepareReader(m),this.readEndOfCentral(),this.readCentralDir(),this.readLocalFiles()}},r.exports=u},{\"./reader/readerFor\":22,\"./signature\":23,\"./support\":30,\"./utils\":32,\"./zipEntry\":34}],34:[function(n,r,i){var s=n(\"./reader/readerFor\"),o=n(\"./utils\"),l=n(\"./compressedObject\"),c=n(\"./crc32\"),d=n(\"./utf8\"),u=n(\"./compressions\"),m=n(\"./support\");function p(f,y){this.options=f,this.loadOptions=y}p.prototype={isEncrypted:function(){return(1&this.bitFlag)==1},useUTF8:function(){return(2048&this.bitFlag)==2048},readLocalPart:function(f){var y,v;if(f.skip(22),this.fileNameLength=f.readInt(2),v=f.readInt(2),this.fileName=f.readData(this.fileNameLength),f.skip(v),this.compressedSize===-1||this.uncompressedSize===-1)throw new Error(\"Bug or corrupted zip : didn't get enough information from the central directory (compressedSize === -1 || uncompressedSize === -1)\");if((y=(function(b){for(var g in u)if(Object.prototype.hasOwnProperty.call(u,g)&&u[g].magic===b)return u[g];return null})(this.compressionMethod))===null)throw new Error(\"Corrupted zip : compression \"+o.pretty(this.compressionMethod)+\" unknown (inner file : \"+o.transformTo(\"string\",this.fileName)+\")\");this.decompressed=new l(this.compressedSize,this.uncompressedSize,this.crc32,y,f.readData(this.compressedSize))},readCentralPart:function(f){this.versionMadeBy=f.readInt(2),f.skip(2),this.bitFlag=f.readInt(2),this.compressionMethod=f.readString(2),this.date=f.readDate(),this.crc32=f.readInt(4),this.compressedSize=f.readInt(4),this.uncompressedSize=f.readInt(4);var y=f.readInt(2);if(this.extraFieldsLength=f.readInt(2),this.fileCommentLength=f.readInt(2),this.diskNumberStart=f.readInt(2),this.internalFileAttributes=f.readInt(2),this.externalFileAttributes=f.readInt(4),this.localHeaderOffset=f.readInt(4),this.isEncrypted())throw new Error(\"Encrypted zip are not supported\");f.skip(y),this.readExtraFields(f),this.parseZIP64ExtraField(f),this.fileComment=f.readData(this.fileCommentLength)},processAttributes:function(){this.unixPermissions=null,this.dosPermissions=null;var f=this.versionMadeBy>>8;this.dir=!!(16&this.externalFileAttributes),f==0&&(this.dosPermissions=63&this.externalFileAttributes),f==3&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||this.fileNameStr.slice(-1)!==\"/\"||(this.dir=!0)},parseZIP64ExtraField:function(){if(this.extraFields[1]){var f=s(this.extraFields[1].value);this.uncompressedSize===o.MAX_VALUE_32BITS&&(this.uncompressedSize=f.readInt(8)),this.compressedSize===o.MAX_VALUE_32BITS&&(this.compressedSize=f.readInt(8)),this.localHeaderOffset===o.MAX_VALUE_32BITS&&(this.localHeaderOffset=f.readInt(8)),this.diskNumberStart===o.MAX_VALUE_32BITS&&(this.diskNumberStart=f.readInt(4))}},readExtraFields:function(f){var y,v,b,g=f.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});f.index+4<g;)y=f.readInt(2),v=f.readInt(2),b=f.readData(v),this.extraFields[y]={id:y,length:v,value:b};f.setIndex(g)},handleUTF8:function(){var f=m.uint8array?\"uint8array\":\"array\";if(this.useUTF8())this.fileNameStr=d.utf8decode(this.fileName),this.fileCommentStr=d.utf8decode(this.fileComment);else{var y=this.findExtraFieldUnicodePath();if(y!==null)this.fileNameStr=y;else{var v=o.transformTo(f,this.fileName);this.fileNameStr=this.loadOptions.decodeFileName(v)}var b=this.findExtraFieldUnicodeComment();if(b!==null)this.fileCommentStr=b;else{var g=o.transformTo(f,this.fileComment);this.fileCommentStr=this.loadOptions.decodeFileName(g)}}},findExtraFieldUnicodePath:function(){var f=this.extraFields[28789];if(f){var y=s(f.value);return y.readInt(1)!==1||c(this.fileName)!==y.readInt(4)?null:d.utf8decode(y.readData(f.length-5))}return null},findExtraFieldUnicodeComment:function(){var f=this.extraFields[25461];if(f){var y=s(f.value);return y.readInt(1)!==1||c(this.fileComment)!==y.readInt(4)?null:d.utf8decode(y.readData(f.length-5))}return null}},r.exports=p},{\"./compressedObject\":2,\"./compressions\":3,\"./crc32\":4,\"./reader/readerFor\":22,\"./support\":30,\"./utf8\":31,\"./utils\":32}],35:[function(n,r,i){function s(y,v,b){this.name=y,this.dir=b.dir,this.date=b.date,this.comment=b.comment,this.unixPermissions=b.unixPermissions,this.dosPermissions=b.dosPermissions,this._data=v,this._dataBinary=b.binary,this.options={compression:b.compression,compressionOptions:b.compressionOptions}}var o=n(\"./stream/StreamHelper\"),l=n(\"./stream/DataWorker\"),c=n(\"./utf8\"),d=n(\"./compressedObject\"),u=n(\"./stream/GenericWorker\");s.prototype={internalStream:function(y){var v=null,b=\"string\";try{if(!y)throw new Error(\"No output type specified.\");var g=(b=y.toLowerCase())===\"string\"||b===\"text\";b!==\"binarystring\"&&b!==\"text\"||(b=\"string\"),v=this._decompressWorker();var _=!this._dataBinary;_&&!g&&(v=v.pipe(new c.Utf8EncodeWorker)),!_&&g&&(v=v.pipe(new c.Utf8DecodeWorker))}catch(C){(v=new u(\"error\")).error(C)}return new o(v,b,\"\")},async:function(y,v){return this.internalStream(y).accumulate(v)},nodeStream:function(y,v){return this.internalStream(y||\"nodebuffer\").toNodejsStream(v)},_compressWorker:function(y,v){if(this._data instanceof d&&this._data.compression.magic===y.magic)return this._data.getCompressedWorker();var b=this._decompressWorker();return this._dataBinary||(b=b.pipe(new c.Utf8EncodeWorker)),d.createWorkerFrom(b,y,v)},_decompressWorker:function(){return this._data instanceof d?this._data.getContentWorker():this._data instanceof u?this._data:new l(this._data)}};for(var m=[\"asText\",\"asBinary\",\"asNodeBuffer\",\"asUint8Array\",\"asArrayBuffer\"],p=function(){throw new Error(\"This method has been removed in JSZip 3.0, please check the upgrade guide.\")},f=0;f<m.length;f++)s.prototype[m[f]]=p;r.exports=s},{\"./compressedObject\":2,\"./stream/DataWorker\":27,\"./stream/GenericWorker\":28,\"./stream/StreamHelper\":29,\"./utf8\":31}],36:[function(n,r,i){(function(s){var o,l,c=s.MutationObserver||s.WebKitMutationObserver;if(c){var d=0,u=new c(y),m=s.document.createTextNode(\"\");u.observe(m,{characterData:!0}),o=function(){m.data=d=++d%2}}else if(s.setImmediate||s.MessageChannel===void 0)o=\"document\"in s&&\"onreadystatechange\"in s.document.createElement(\"script\")?function(){var v=s.document.createElement(\"script\");v.onreadystatechange=function(){y(),v.onreadystatechange=null,v.parentNode.removeChild(v),v=null},s.document.documentElement.appendChild(v)}:function(){setTimeout(y,0)};else{var p=new s.MessageChannel;p.port1.onmessage=y,o=function(){p.port2.postMessage(0)}}var f=[];function y(){var v,b;l=!0;for(var g=f.length;g;){for(b=f,f=[],v=-1;++v<g;)b[v]();g=f.length}l=!1}r.exports=function(v){f.push(v)!==1||l||o()}}).call(this,typeof W_<\"u\"?W_:typeof self<\"u\"?self:typeof window<\"u\"?window:{})},{}],37:[function(n,r,i){var s=n(\"immediate\");function o(){}var l={},c=[\"REJECTED\"],d=[\"FULFILLED\"],u=[\"PENDING\"];function m(g){if(typeof g!=\"function\")throw new TypeError(\"resolver must be a function\");this.state=u,this.queue=[],this.outcome=void 0,g!==o&&v(this,g)}function p(g,_,C){this.promise=g,typeof _==\"function\"&&(this.onFulfilled=_,this.callFulfilled=this.otherCallFulfilled),typeof C==\"function\"&&(this.onRejected=C,this.callRejected=this.otherCallRejected)}function f(g,_,C){s(function(){var P;try{P=_(C)}catch(N){return l.reject(g,N)}P===g?l.reject(g,new TypeError(\"Cannot resolve promise with itself\")):l.resolve(g,P)})}function y(g){var _=g&&g.then;if(g&&(typeof g==\"object\"||typeof g==\"function\")&&typeof _==\"function\")return function(){_.apply(g,arguments)}}function v(g,_){var C=!1;function P(T){C||(C=!0,l.reject(g,T))}function N(T){C||(C=!0,l.resolve(g,T))}var A=b(function(){_(N,P)});A.status===\"error\"&&P(A.value)}function b(g,_){var C={};try{C.value=g(_),C.status=\"success\"}catch(P){C.status=\"error\",C.value=P}return C}(r.exports=m).prototype.finally=function(g){if(typeof g!=\"function\")return this;var _=this.constructor;return this.then(function(C){return _.resolve(g()).then(function(){return C})},function(C){return _.resolve(g()).then(function(){throw C})})},m.prototype.catch=function(g){return this.then(null,g)},m.prototype.then=function(g,_){if(typeof g!=\"function\"&&this.state===d||typeof _!=\"function\"&&this.state===c)return this;var C=new this.constructor(o);return this.state!==u?f(C,this.state===d?g:_,this.outcome):this.queue.push(new p(C,g,_)),C},p.prototype.callFulfilled=function(g){l.resolve(this.promise,g)},p.prototype.otherCallFulfilled=function(g){f(this.promise,this.onFulfilled,g)},p.prototype.callRejected=function(g){l.reject(this.promise,g)},p.prototype.otherCallRejected=function(g){f(this.promise,this.onRejected,g)},l.resolve=function(g,_){var C=b(y,_);if(C.status===\"error\")return l.reject(g,C.value);var P=C.value;if(P)v(g,P);else{g.state=d,g.outcome=_;for(var N=-1,A=g.queue.length;++N<A;)g.queue[N].callFulfilled(_)}return g},l.reject=function(g,_){g.state=c,g.outcome=_;for(var C=-1,P=g.queue.length;++C<P;)g.queue[C].callRejected(_);return g},m.resolve=function(g){return g instanceof this?g:l.resolve(new this(o),g)},m.reject=function(g){var _=new this(o);return l.reject(_,g)},m.all=function(g){var _=this;if(Object.prototype.toString.call(g)!==\"[object Array]\")return this.reject(new TypeError(\"must be an array\"));var C=g.length,P=!1;if(!C)return this.resolve([]);for(var N=new Array(C),A=0,T=-1,F=new this(o);++T<C;)k(g[T],T);return F;function k(D,H){_.resolve(D).then(function(z){N[H]=z,++A!==C||P||(P=!0,l.resolve(F,N))},function(z){P||(P=!0,l.reject(F,z))})}},m.race=function(g){var _=this;if(Object.prototype.toString.call(g)!==\"[object Array]\")return this.reject(new TypeError(\"must be an array\"));var C=g.length,P=!1;if(!C)return this.resolve([]);for(var N=-1,A=new this(o);++N<C;)T=g[N],_.resolve(T).then(function(F){P||(P=!0,l.resolve(A,F))},function(F){P||(P=!0,l.reject(A,F))});var T;return A}},{immediate:36}],38:[function(n,r,i){var s={};(0,n(\"./lib/utils/common\").assign)(s,n(\"./lib/deflate\"),n(\"./lib/inflate\"),n(\"./lib/zlib/constants\")),r.exports=s},{\"./lib/deflate\":39,\"./lib/inflate\":40,\"./lib/utils/common\":41,\"./lib/zlib/constants\":44}],39:[function(n,r,i){var s=n(\"./zlib/deflate\"),o=n(\"./utils/common\"),l=n(\"./utils/strings\"),c=n(\"./zlib/messages\"),d=n(\"./zlib/zstream\"),u=Object.prototype.toString,m=0,p=-1,f=0,y=8;function v(g){if(!(this instanceof v))return new v(g);this.options=o.assign({level:p,method:y,chunkSize:16384,windowBits:15,memLevel:8,strategy:f,to:\"\"},g||{});var _=this.options;_.raw&&0<_.windowBits?_.windowBits=-_.windowBits:_.gzip&&0<_.windowBits&&_.windowBits<16&&(_.windowBits+=16),this.err=0,this.msg=\"\",this.ended=!1,this.chunks=[],this.strm=new d,this.strm.avail_out=0;var C=s.deflateInit2(this.strm,_.level,_.method,_.windowBits,_.memLevel,_.strategy);if(C!==m)throw new Error(c[C]);if(_.header&&s.deflateSetHeader(this.strm,_.header),_.dictionary){var P;if(P=typeof _.dictionary==\"string\"?l.string2buf(_.dictionary):u.call(_.dictionary)===\"[object ArrayBuffer]\"?new Uint8Array(_.dictionary):_.dictionary,(C=s.deflateSetDictionary(this.strm,P))!==m)throw new Error(c[C]);this._dict_set=!0}}function b(g,_){var C=new v(_);if(C.push(g,!0),C.err)throw C.msg||c[C.err];return C.result}v.prototype.push=function(g,_){var C,P,N=this.strm,A=this.options.chunkSize;if(this.ended)return!1;P=_===~~_?_:_===!0?4:0,typeof g==\"string\"?N.input=l.string2buf(g):u.call(g)===\"[object ArrayBuffer]\"?N.input=new Uint8Array(g):N.input=g,N.next_in=0,N.avail_in=N.input.length;do{if(N.avail_out===0&&(N.output=new o.Buf8(A),N.next_out=0,N.avail_out=A),(C=s.deflate(N,P))!==1&&C!==m)return this.onEnd(C),!(this.ended=!0);N.avail_out!==0&&(N.avail_in!==0||P!==4&&P!==2)||(this.options.to===\"string\"?this.onData(l.buf2binstring(o.shrinkBuf(N.output,N.next_out))):this.onData(o.shrinkBuf(N.output,N.next_out)))}while((0<N.avail_in||N.avail_out===0)&&C!==1);return P===4?(C=s.deflateEnd(this.strm),this.onEnd(C),this.ended=!0,C===m):P!==2||(this.onEnd(m),!(N.avail_out=0))},v.prototype.onData=function(g){this.chunks.push(g)},v.prototype.onEnd=function(g){g===m&&(this.options.to===\"string\"?this.result=this.chunks.join(\"\"):this.result=o.flattenChunks(this.chunks)),this.chunks=[],this.err=g,this.msg=this.strm.msg},i.Deflate=v,i.deflate=b,i.deflateRaw=function(g,_){return(_=_||{}).raw=!0,b(g,_)},i.gzip=function(g,_){return(_=_||{}).gzip=!0,b(g,_)}},{\"./utils/common\":41,\"./utils/strings\":42,\"./zlib/deflate\":46,\"./zlib/messages\":51,\"./zlib/zstream\":53}],40:[function(n,r,i){var s=n(\"./zlib/inflate\"),o=n(\"./utils/common\"),l=n(\"./utils/strings\"),c=n(\"./zlib/constants\"),d=n(\"./zlib/messages\"),u=n(\"./zlib/zstream\"),m=n(\"./zlib/gzheader\"),p=Object.prototype.toString;function f(v){if(!(this instanceof f))return new f(v);this.options=o.assign({chunkSize:16384,windowBits:0,to:\"\"},v||{});var b=this.options;b.raw&&0<=b.windowBits&&b.windowBits<16&&(b.windowBits=-b.windowBits,b.windowBits===0&&(b.windowBits=-15)),!(0<=b.windowBits&&b.windowBits<16)||v&&v.windowBits||(b.windowBits+=32),15<b.windowBits&&b.windowBits<48&&(15&b.windowBits)==0&&(b.windowBits|=15),this.err=0,this.msg=\"\",this.ended=!1,this.chunks=[],this.strm=new u,this.strm.avail_out=0;var g=s.inflateInit2(this.strm,b.windowBits);if(g!==c.Z_OK)throw new Error(d[g]);this.header=new m,s.inflateGetHeader(this.strm,this.header)}function y(v,b){var g=new f(b);if(g.push(v,!0),g.err)throw g.msg||d[g.err];return g.result}f.prototype.push=function(v,b){var g,_,C,P,N,A,T=this.strm,F=this.options.chunkSize,k=this.options.dictionary,D=!1;if(this.ended)return!1;_=b===~~b?b:b===!0?c.Z_FINISH:c.Z_NO_FLUSH,typeof v==\"string\"?T.input=l.binstring2buf(v):p.call(v)===\"[object ArrayBuffer]\"?T.input=new Uint8Array(v):T.input=v,T.next_in=0,T.avail_in=T.input.length;do{if(T.avail_out===0&&(T.output=new o.Buf8(F),T.next_out=0,T.avail_out=F),(g=s.inflate(T,c.Z_NO_FLUSH))===c.Z_NEED_DICT&&k&&(A=typeof k==\"string\"?l.string2buf(k):p.call(k)===\"[object ArrayBuffer]\"?new Uint8Array(k):k,g=s.inflateSetDictionary(this.strm,A)),g===c.Z_BUF_ERROR&&D===!0&&(g=c.Z_OK,D=!1),g!==c.Z_STREAM_END&&g!==c.Z_OK)return this.onEnd(g),!(this.ended=!0);T.next_out&&(T.avail_out!==0&&g!==c.Z_STREAM_END&&(T.avail_in!==0||_!==c.Z_FINISH&&_!==c.Z_SYNC_FLUSH)||(this.options.to===\"string\"?(C=l.utf8border(T.output,T.next_out),P=T.next_out-C,N=l.buf2string(T.output,C),T.next_out=P,T.avail_out=F-P,P&&o.arraySet(T.output,T.output,C,P,0),this.onData(N)):this.onData(o.shrinkBuf(T.output,T.next_out)))),T.avail_in===0&&T.avail_out===0&&(D=!0)}while((0<T.avail_in||T.avail_out===0)&&g!==c.Z_STREAM_END);return g===c.Z_STREAM_END&&(_=c.Z_FINISH),_===c.Z_FINISH?(g=s.inflateEnd(this.strm),this.onEnd(g),this.ended=!0,g===c.Z_OK):_!==c.Z_SYNC_FLUSH||(this.onEnd(c.Z_OK),!(T.avail_out=0))},f.prototype.onData=function(v){this.chunks.push(v)},f.prototype.onEnd=function(v){v===c.Z_OK&&(this.options.to===\"string\"?this.result=this.chunks.join(\"\"):this.result=o.flattenChunks(this.chunks)),this.chunks=[],this.err=v,this.msg=this.strm.msg},i.Inflate=f,i.inflate=y,i.inflateRaw=function(v,b){return(b=b||{}).raw=!0,y(v,b)},i.ungzip=y},{\"./utils/common\":41,\"./utils/strings\":42,\"./zlib/constants\":44,\"./zlib/gzheader\":47,\"./zlib/inflate\":49,\"./zlib/messages\":51,\"./zlib/zstream\":53}],41:[function(n,r,i){var s=typeof Uint8Array<\"u\"&&typeof Uint16Array<\"u\"&&typeof Int32Array<\"u\";i.assign=function(c){for(var d=Array.prototype.slice.call(arguments,1);d.length;){var u=d.shift();if(u){if(typeof u!=\"object\")throw new TypeError(u+\"must be non-object\");for(var m in u)u.hasOwnProperty(m)&&(c[m]=u[m])}}return c},i.shrinkBuf=function(c,d){return c.length===d?c:c.subarray?c.subarray(0,d):(c.length=d,c)};var o={arraySet:function(c,d,u,m,p){if(d.subarray&&c.subarray)c.set(d.subarray(u,u+m),p);else for(var f=0;f<m;f++)c[p+f]=d[u+f]},flattenChunks:function(c){var d,u,m,p,f,y;for(d=m=0,u=c.length;d<u;d++)m+=c[d].length;for(y=new Uint8Array(m),d=p=0,u=c.length;d<u;d++)f=c[d],y.set(f,p),p+=f.length;return y}},l={arraySet:function(c,d,u,m,p){for(var f=0;f<m;f++)c[p+f]=d[u+f]},flattenChunks:function(c){return[].concat.apply([],c)}};i.setTyped=function(c){c?(i.Buf8=Uint8Array,i.Buf16=Uint16Array,i.Buf32=Int32Array,i.assign(i,o)):(i.Buf8=Array,i.Buf16=Array,i.Buf32=Array,i.assign(i,l))},i.setTyped(s)},{}],42:[function(n,r,i){var s=n(\"./common\"),o=!0,l=!0;try{String.fromCharCode.apply(null,[0])}catch{o=!1}try{String.fromCharCode.apply(null,new Uint8Array(1))}catch{l=!1}for(var c=new s.Buf8(256),d=0;d<256;d++)c[d]=252<=d?6:248<=d?5:240<=d?4:224<=d?3:192<=d?2:1;function u(m,p){if(p<65537&&(m.subarray&&l||!m.subarray&&o))return String.fromCharCode.apply(null,s.shrinkBuf(m,p));for(var f=\"\",y=0;y<p;y++)f+=String.fromCharCode(m[y]);return f}c[254]=c[254]=1,i.string2buf=function(m){var p,f,y,v,b,g=m.length,_=0;for(v=0;v<g;v++)(64512&(f=m.charCodeAt(v)))==55296&&v+1<g&&(64512&(y=m.charCodeAt(v+1)))==56320&&(f=65536+(f-55296<<10)+(y-56320),v++),_+=f<128?1:f<2048?2:f<65536?3:4;for(p=new s.Buf8(_),v=b=0;b<_;v++)(64512&(f=m.charCodeAt(v)))==55296&&v+1<g&&(64512&(y=m.charCodeAt(v+1)))==56320&&(f=65536+(f-55296<<10)+(y-56320),v++),f<128?p[b++]=f:(f<2048?p[b++]=192|f>>>6:(f<65536?p[b++]=224|f>>>12:(p[b++]=240|f>>>18,p[b++]=128|f>>>12&63),p[b++]=128|f>>>6&63),p[b++]=128|63&f);return p},i.buf2binstring=function(m){return u(m,m.length)},i.binstring2buf=function(m){for(var p=new s.Buf8(m.length),f=0,y=p.length;f<y;f++)p[f]=m.charCodeAt(f);return p},i.buf2string=function(m,p){var f,y,v,b,g=p||m.length,_=new Array(2*g);for(f=y=0;f<g;)if((v=m[f++])<128)_[y++]=v;else if(4<(b=c[v]))_[y++]=65533,f+=b-1;else{for(v&=b===2?31:b===3?15:7;1<b&&f<g;)v=v<<6|63&m[f++],b--;1<b?_[y++]=65533:v<65536?_[y++]=v:(v-=65536,_[y++]=55296|v>>10&1023,_[y++]=56320|1023&v)}return u(_,y)},i.utf8border=function(m,p){var f;for((p=p||m.length)>m.length&&(p=m.length),f=p-1;0<=f&&(192&m[f])==128;)f--;return f<0||f===0?p:f+c[m[f]]>p?f:p}},{\"./common\":41}],43:[function(n,r,i){r.exports=function(s,o,l,c){for(var d=65535&s|0,u=s>>>16&65535|0,m=0;l!==0;){for(l-=m=2e3<l?2e3:l;u=u+(d=d+o[c++]|0)|0,--m;);d%=65521,u%=65521}return d|u<<16|0}},{}],44:[function(n,r,i){r.exports={Z_NO_FLUSH:0,Z_PARTIAL_FLUSH:1,Z_SYNC_FLUSH:2,Z_FULL_FLUSH:3,Z_FINISH:4,Z_BLOCK:5,Z_TREES:6,Z_OK:0,Z_STREAM_END:1,Z_NEED_DICT:2,Z_ERRNO:-1,Z_STREAM_ERROR:-2,Z_DATA_ERROR:-3,Z_BUF_ERROR:-5,Z_NO_COMPRESSION:0,Z_BEST_SPEED:1,Z_BEST_COMPRESSION:9,Z_DEFAULT_COMPRESSION:-1,Z_FILTERED:1,Z_HUFFMAN_ONLY:2,Z_RLE:3,Z_FIXED:4,Z_DEFAULT_STRATEGY:0,Z_BINARY:0,Z_TEXT:1,Z_UNKNOWN:2,Z_DEFLATED:8}},{}],45:[function(n,r,i){var s=(function(){for(var o,l=[],c=0;c<256;c++){o=c;for(var d=0;d<8;d++)o=1&o?3988292384^o>>>1:o>>>1;l[c]=o}return l})();r.exports=function(o,l,c,d){var u=s,m=d+c;o^=-1;for(var p=d;p<m;p++)o=o>>>8^u[255&(o^l[p])];return-1^o}},{}],46:[function(n,r,i){var s,o=n(\"../utils/common\"),l=n(\"./trees\"),c=n(\"./adler32\"),d=n(\"./crc32\"),u=n(\"./messages\"),m=0,p=4,f=0,y=-2,v=-1,b=4,g=2,_=8,C=9,P=286,N=30,A=19,T=2*P+1,F=15,k=3,D=258,H=D+k+1,z=42,Q=113,L=1,te=2,ie=3,J=4;function oe(I,G){return I.msg=u[G],G}function fe(I){return(I<<1)-(4<I?9:0)}function re(I){for(var G=I.length;0<=--G;)I[G]=0}function W(I){var G=I.state,X=G.pending;X>I.avail_out&&(X=I.avail_out),X!==0&&(o.arraySet(I.output,G.pending_buf,G.pending_out,X,I.next_out),I.next_out+=X,G.pending_out+=X,I.total_out+=X,I.avail_out-=X,G.pending-=X,G.pending===0&&(G.pending_out=0))}function ne(I,G){l._tr_flush_block(I,0<=I.block_start?I.block_start:-1,I.strstart-I.block_start,G),I.block_start=I.strstart,W(I.strm)}function me(I,G){I.pending_buf[I.pending++]=G}function be(I,G){I.pending_buf[I.pending++]=G>>>8&255,I.pending_buf[I.pending++]=255&G}function Ce(I,G){var X,V,ee=I.max_chain_length,se=I.strstart,ge=I.prev_length,he=I.nice_match,le=I.strstart>I.w_size-H?I.strstart-(I.w_size-H):0,B=I.window,R=I.w_mask,ae=I.prev,_e=I.strstart+D,Se=B[se+ge-1],ve=B[se+ge];I.prev_length>=I.good_match&&(ee>>=2),he>I.lookahead&&(he=I.lookahead);do if(B[(X=G)+ge]===ve&&B[X+ge-1]===Se&&B[X]===B[se]&&B[++X]===B[se+1]){se+=2,X++;do;while(B[++se]===B[++X]&&B[++se]===B[++X]&&B[++se]===B[++X]&&B[++se]===B[++X]&&B[++se]===B[++X]&&B[++se]===B[++X]&&B[++se]===B[++X]&&B[++se]===B[++X]&&se<_e);if(V=D-(_e-se),se=_e-D,ge<V){if(I.match_start=G,he<=(ge=V))break;Se=B[se+ge-1],ve=B[se+ge]}}while((G=ae[G&R])>le&&--ee!=0);return ge<=I.lookahead?ge:I.lookahead}function q(I){var G,X,V,ee,se,ge,he,le,B,R,ae=I.w_size;do{if(ee=I.window_size-I.lookahead-I.strstart,I.strstart>=ae+(ae-H)){for(o.arraySet(I.window,I.window,ae,ae,0),I.match_start-=ae,I.strstart-=ae,I.block_start-=ae,G=X=I.hash_size;V=I.head[--G],I.head[G]=ae<=V?V-ae:0,--X;);for(G=X=ae;V=I.prev[--G],I.prev[G]=ae<=V?V-ae:0,--X;);ee+=ae}if(I.strm.avail_in===0)break;if(ge=I.strm,he=I.window,le=I.strstart+I.lookahead,B=ee,R=void 0,R=ge.avail_in,B<R&&(R=B),X=R===0?0:(ge.avail_in-=R,o.arraySet(he,ge.input,ge.next_in,R,le),ge.state.wrap===1?ge.adler=c(ge.adler,he,R,le):ge.state.wrap===2&&(ge.adler=d(ge.adler,he,R,le)),ge.next_in+=R,ge.total_in+=R,R),I.lookahead+=X,I.lookahead+I.insert>=k)for(se=I.strstart-I.insert,I.ins_h=I.window[se],I.ins_h=(I.ins_h<<I.hash_shift^I.window[se+1])&I.hash_mask;I.insert&&(I.ins_h=(I.ins_h<<I.hash_shift^I.window[se+k-1])&I.hash_mask,I.prev[se&I.w_mask]=I.head[I.ins_h],I.head[I.ins_h]=se,se++,I.insert--,!(I.lookahead+I.insert<k)););}while(I.lookahead<H&&I.strm.avail_in!==0)}function Y(I,G){for(var X,V;;){if(I.lookahead<H){if(q(I),I.lookahead<H&&G===m)return L;if(I.lookahead===0)break}if(X=0,I.lookahead>=k&&(I.ins_h=(I.ins_h<<I.hash_shift^I.window[I.strstart+k-1])&I.hash_mask,X=I.prev[I.strstart&I.w_mask]=I.head[I.ins_h],I.head[I.ins_h]=I.strstart),X!==0&&I.strstart-X<=I.w_size-H&&(I.match_length=Ce(I,X)),I.match_length>=k)if(V=l._tr_tally(I,I.strstart-I.match_start,I.match_length-k),I.lookahead-=I.match_length,I.match_length<=I.max_lazy_match&&I.lookahead>=k){for(I.match_length--;I.strstart++,I.ins_h=(I.ins_h<<I.hash_shift^I.window[I.strstart+k-1])&I.hash_mask,X=I.prev[I.strstart&I.w_mask]=I.head[I.ins_h],I.head[I.ins_h]=I.strstart,--I.match_length!=0;);I.strstart++}else I.strstart+=I.match_length,I.match_length=0,I.ins_h=I.window[I.strstart],I.ins_h=(I.ins_h<<I.hash_shift^I.window[I.strstart+1])&I.hash_mask;else V=l._tr_tally(I,0,I.window[I.strstart]),I.lookahead--,I.strstart++;if(V&&(ne(I,!1),I.strm.avail_out===0))return L}return I.insert=I.strstart<k-1?I.strstart:k-1,G===p?(ne(I,!0),I.strm.avail_out===0?ie:J):I.last_lit&&(ne(I,!1),I.strm.avail_out===0)?L:te}function E(I,G){for(var X,V,ee;;){if(I.lookahead<H){if(q(I),I.lookahead<H&&G===m)return L;if(I.lookahead===0)break}if(X=0,I.lookahead>=k&&(I.ins_h=(I.ins_h<<I.hash_shift^I.window[I.strstart+k-1])&I.hash_mask,X=I.prev[I.strstart&I.w_mask]=I.head[I.ins_h],I.head[I.ins_h]=I.strstart),I.prev_length=I.match_length,I.prev_match=I.match_start,I.match_length=k-1,X!==0&&I.prev_length<I.max_lazy_match&&I.strstart-X<=I.w_size-H&&(I.match_length=Ce(I,X),I.match_length<=5&&(I.strategy===1||I.match_length===k&&4096<I.strstart-I.match_start)&&(I.match_length=k-1)),I.prev_length>=k&&I.match_length<=I.prev_length){for(ee=I.strstart+I.lookahead-k,V=l._tr_tally(I,I.strstart-1-I.prev_match,I.prev_length-k),I.lookahead-=I.prev_length-1,I.prev_length-=2;++I.strstart<=ee&&(I.ins_h=(I.ins_h<<I.hash_shift^I.window[I.strstart+k-1])&I.hash_mask,X=I.prev[I.strstart&I.w_mask]=I.head[I.ins_h],I.head[I.ins_h]=I.strstart),--I.prev_length!=0;);if(I.match_available=0,I.match_length=k-1,I.strstart++,V&&(ne(I,!1),I.strm.avail_out===0))return L}else if(I.match_available){if((V=l._tr_tally(I,0,I.window[I.strstart-1]))&&ne(I,!1),I.strstart++,I.lookahead--,I.strm.avail_out===0)return L}else I.match_available=1,I.strstart++,I.lookahead--}return I.match_available&&(V=l._tr_tally(I,0,I.window[I.strstart-1]),I.match_available=0),I.insert=I.strstart<k-1?I.strstart:k-1,G===p?(ne(I,!0),I.strm.avail_out===0?ie:J):I.last_lit&&(ne(I,!1),I.strm.avail_out===0)?L:te}function j(I,G,X,V,ee){this.good_length=I,this.max_lazy=G,this.nice_length=X,this.max_chain=V,this.func=ee}function O(){this.strm=null,this.status=0,this.pending_buf=null,this.pending_buf_size=0,this.pending_out=0,this.pending=0,this.wrap=0,this.gzhead=null,this.gzindex=0,this.method=_,this.last_flush=-1,this.w_size=0,this.w_bits=0,this.w_mask=0,this.window=null,this.window_size=0,this.prev=null,this.head=null,this.ins_h=0,this.hash_size=0,this.hash_bits=0,this.hash_mask=0,this.hash_shift=0,this.block_start=0,this.match_length=0,this.prev_match=0,this.match_available=0,this.strstart=0,this.match_start=0,this.lookahead=0,this.prev_length=0,this.max_chain_length=0,this.max_lazy_match=0,this.level=0,this.strategy=0,this.good_match=0,this.nice_match=0,this.dyn_ltree=new o.Buf16(2*T),this.dyn_dtree=new o.Buf16(2*(2*N+1)),this.bl_tree=new o.Buf16(2*(2*A+1)),re(this.dyn_ltree),re(this.dyn_dtree),re(this.bl_tree),this.l_desc=null,this.d_desc=null,this.bl_desc=null,this.bl_count=new o.Buf16(F+1),this.heap=new o.Buf16(2*P+1),re(this.heap),this.heap_len=0,this.heap_max=0,this.depth=new o.Buf16(2*P+1),re(this.depth),this.l_buf=0,this.lit_bufsize=0,this.last_lit=0,this.d_buf=0,this.opt_len=0,this.static_len=0,this.matches=0,this.insert=0,this.bi_buf=0,this.bi_valid=0}function K(I){var G;return I&&I.state?(I.total_in=I.total_out=0,I.data_type=g,(G=I.state).pending=0,G.pending_out=0,G.wrap<0&&(G.wrap=-G.wrap),G.status=G.wrap?z:Q,I.adler=G.wrap===2?0:1,G.last_flush=m,l._tr_init(G),f):oe(I,y)}function U(I){var G=K(I);return G===f&&(function(X){X.window_size=2*X.w_size,re(X.head),X.max_lazy_match=s[X.level].max_lazy,X.good_match=s[X.level].good_length,X.nice_match=s[X.level].nice_length,X.max_chain_length=s[X.level].max_chain,X.strstart=0,X.block_start=0,X.lookahead=0,X.insert=0,X.match_length=X.prev_length=k-1,X.match_available=0,X.ins_h=0})(I.state),G}function de(I,G,X,V,ee,se){if(!I)return y;var ge=1;if(G===v&&(G=6),V<0?(ge=0,V=-V):15<V&&(ge=2,V-=16),ee<1||C<ee||X!==_||V<8||15<V||G<0||9<G||se<0||b<se)return oe(I,y);V===8&&(V=9);var he=new O;return(I.state=he).strm=I,he.wrap=ge,he.gzhead=null,he.w_bits=V,he.w_size=1<<he.w_bits,he.w_mask=he.w_size-1,he.hash_bits=ee+7,he.hash_size=1<<he.hash_bits,he.hash_mask=he.hash_size-1,he.hash_shift=~~((he.hash_bits+k-1)/k),he.window=new o.Buf8(2*he.w_size),he.head=new o.Buf16(he.hash_size),he.prev=new o.Buf16(he.w_size),he.lit_bufsize=1<<ee+6,he.pending_buf_size=4*he.lit_bufsize,he.pending_buf=new o.Buf8(he.pending_buf_size),he.d_buf=1*he.lit_bufsize,he.l_buf=3*he.lit_bufsize,he.level=G,he.strategy=se,he.method=X,U(I)}s=[new j(0,0,0,0,function(I,G){var X=65535;for(X>I.pending_buf_size-5&&(X=I.pending_buf_size-5);;){if(I.lookahead<=1){if(q(I),I.lookahead===0&&G===m)return L;if(I.lookahead===0)break}I.strstart+=I.lookahead,I.lookahead=0;var V=I.block_start+X;if((I.strstart===0||I.strstart>=V)&&(I.lookahead=I.strstart-V,I.strstart=V,ne(I,!1),I.strm.avail_out===0)||I.strstart-I.block_start>=I.w_size-H&&(ne(I,!1),I.strm.avail_out===0))return L}return I.insert=0,G===p?(ne(I,!0),I.strm.avail_out===0?ie:J):(I.strstart>I.block_start&&(ne(I,!1),I.strm.avail_out),L)}),new j(4,4,8,4,Y),new j(4,5,16,8,Y),new j(4,6,32,32,Y),new j(4,4,16,16,E),new j(8,16,32,32,E),new j(8,16,128,128,E),new j(8,32,128,256,E),new j(32,128,258,1024,E),new j(32,258,258,4096,E)],i.deflateInit=function(I,G){return de(I,G,_,15,8,0)},i.deflateInit2=de,i.deflateReset=U,i.deflateResetKeep=K,i.deflateSetHeader=function(I,G){return I&&I.state?I.state.wrap!==2?y:(I.state.gzhead=G,f):y},i.deflate=function(I,G){var X,V,ee,se;if(!I||!I.state||5<G||G<0)return I?oe(I,y):y;if(V=I.state,!I.output||!I.input&&I.avail_in!==0||V.status===666&&G!==p)return oe(I,I.avail_out===0?-5:y);if(V.strm=I,X=V.last_flush,V.last_flush=G,V.status===z)if(V.wrap===2)I.adler=0,me(V,31),me(V,139),me(V,8),V.gzhead?(me(V,(V.gzhead.text?1:0)+(V.gzhead.hcrc?2:0)+(V.gzhead.extra?4:0)+(V.gzhead.name?8:0)+(V.gzhead.comment?16:0)),me(V,255&V.gzhead.time),me(V,V.gzhead.time>>8&255),me(V,V.gzhead.time>>16&255),me(V,V.gzhead.time>>24&255),me(V,V.level===9?2:2<=V.strategy||V.level<2?4:0),me(V,255&V.gzhead.os),V.gzhead.extra&&V.gzhead.extra.length&&(me(V,255&V.gzhead.extra.length),me(V,V.gzhead.extra.length>>8&255)),V.gzhead.hcrc&&(I.adler=d(I.adler,V.pending_buf,V.pending,0)),V.gzindex=0,V.status=69):(me(V,0),me(V,0),me(V,0),me(V,0),me(V,0),me(V,V.level===9?2:2<=V.strategy||V.level<2?4:0),me(V,3),V.status=Q);else{var ge=_+(V.w_bits-8<<4)<<8;ge|=(2<=V.strategy||V.level<2?0:V.level<6?1:V.level===6?2:3)<<6,V.strstart!==0&&(ge|=32),ge+=31-ge%31,V.status=Q,be(V,ge),V.strstart!==0&&(be(V,I.adler>>>16),be(V,65535&I.adler)),I.adler=1}if(V.status===69)if(V.gzhead.extra){for(ee=V.pending;V.gzindex<(65535&V.gzhead.extra.length)&&(V.pending!==V.pending_buf_size||(V.gzhead.hcrc&&V.pending>ee&&(I.adler=d(I.adler,V.pending_buf,V.pending-ee,ee)),W(I),ee=V.pending,V.pending!==V.pending_buf_size));)me(V,255&V.gzhead.extra[V.gzindex]),V.gzindex++;V.gzhead.hcrc&&V.pending>ee&&(I.adler=d(I.adler,V.pending_buf,V.pending-ee,ee)),V.gzindex===V.gzhead.extra.length&&(V.gzindex=0,V.status=73)}else V.status=73;if(V.status===73)if(V.gzhead.name){ee=V.pending;do{if(V.pending===V.pending_buf_size&&(V.gzhead.hcrc&&V.pending>ee&&(I.adler=d(I.adler,V.pending_buf,V.pending-ee,ee)),W(I),ee=V.pending,V.pending===V.pending_buf_size)){se=1;break}se=V.gzindex<V.gzhead.name.length?255&V.gzhead.name.charCodeAt(V.gzindex++):0,me(V,se)}while(se!==0);V.gzhead.hcrc&&V.pending>ee&&(I.adler=d(I.adler,V.pending_buf,V.pending-ee,ee)),se===0&&(V.gzindex=0,V.status=91)}else V.status=91;if(V.status===91)if(V.gzhead.comment){ee=V.pending;do{if(V.pending===V.pending_buf_size&&(V.gzhead.hcrc&&V.pending>ee&&(I.adler=d(I.adler,V.pending_buf,V.pending-ee,ee)),W(I),ee=V.pending,V.pending===V.pending_buf_size)){se=1;break}se=V.gzindex<V.gzhead.comment.length?255&V.gzhead.comment.charCodeAt(V.gzindex++):0,me(V,se)}while(se!==0);V.gzhead.hcrc&&V.pending>ee&&(I.adler=d(I.adler,V.pending_buf,V.pending-ee,ee)),se===0&&(V.status=103)}else V.status=103;if(V.status===103&&(V.gzhead.hcrc?(V.pending+2>V.pending_buf_size&&W(I),V.pending+2<=V.pending_buf_size&&(me(V,255&I.adler),me(V,I.adler>>8&255),I.adler=0,V.status=Q)):V.status=Q),V.pending!==0){if(W(I),I.avail_out===0)return V.last_flush=-1,f}else if(I.avail_in===0&&fe(G)<=fe(X)&&G!==p)return oe(I,-5);if(V.status===666&&I.avail_in!==0)return oe(I,-5);if(I.avail_in!==0||V.lookahead!==0||G!==m&&V.status!==666){var he=V.strategy===2?(function(le,B){for(var R;;){if(le.lookahead===0&&(q(le),le.lookahead===0)){if(B===m)return L;break}if(le.match_length=0,R=l._tr_tally(le,0,le.window[le.strstart]),le.lookahead--,le.strstart++,R&&(ne(le,!1),le.strm.avail_out===0))return L}return le.insert=0,B===p?(ne(le,!0),le.strm.avail_out===0?ie:J):le.last_lit&&(ne(le,!1),le.strm.avail_out===0)?L:te})(V,G):V.strategy===3?(function(le,B){for(var R,ae,_e,Se,ve=le.window;;){if(le.lookahead<=D){if(q(le),le.lookahead<=D&&B===m)return L;if(le.lookahead===0)break}if(le.match_length=0,le.lookahead>=k&&0<le.strstart&&(ae=ve[_e=le.strstart-1])===ve[++_e]&&ae===ve[++_e]&&ae===ve[++_e]){Se=le.strstart+D;do;while(ae===ve[++_e]&&ae===ve[++_e]&&ae===ve[++_e]&&ae===ve[++_e]&&ae===ve[++_e]&&ae===ve[++_e]&&ae===ve[++_e]&&ae===ve[++_e]&&_e<Se);le.match_length=D-(Se-_e),le.match_length>le.lookahead&&(le.match_length=le.lookahead)}if(le.match_length>=k?(R=l._tr_tally(le,1,le.match_length-k),le.lookahead-=le.match_length,le.strstart+=le.match_length,le.match_length=0):(R=l._tr_tally(le,0,le.window[le.strstart]),le.lookahead--,le.strstart++),R&&(ne(le,!1),le.strm.avail_out===0))return L}return le.insert=0,B===p?(ne(le,!0),le.strm.avail_out===0?ie:J):le.last_lit&&(ne(le,!1),le.strm.avail_out===0)?L:te})(V,G):s[V.level].func(V,G);if(he!==ie&&he!==J||(V.status=666),he===L||he===ie)return I.avail_out===0&&(V.last_flush=-1),f;if(he===te&&(G===1?l._tr_align(V):G!==5&&(l._tr_stored_block(V,0,0,!1),G===3&&(re(V.head),V.lookahead===0&&(V.strstart=0,V.block_start=0,V.insert=0))),W(I),I.avail_out===0))return V.last_flush=-1,f}return G!==p?f:V.wrap<=0?1:(V.wrap===2?(me(V,255&I.adler),me(V,I.adler>>8&255),me(V,I.adler>>16&255),me(V,I.adler>>24&255),me(V,255&I.total_in),me(V,I.total_in>>8&255),me(V,I.total_in>>16&255),me(V,I.total_in>>24&255)):(be(V,I.adler>>>16),be(V,65535&I.adler)),W(I),0<V.wrap&&(V.wrap=-V.wrap),V.pending!==0?f:1)},i.deflateEnd=function(I){var G;return I&&I.state?(G=I.state.status)!==z&&G!==69&&G!==73&&G!==91&&G!==103&&G!==Q&&G!==666?oe(I,y):(I.state=null,G===Q?oe(I,-3):f):y},i.deflateSetDictionary=function(I,G){var X,V,ee,se,ge,he,le,B,R=G.length;if(!I||!I.state||(se=(X=I.state).wrap)===2||se===1&&X.status!==z||X.lookahead)return y;for(se===1&&(I.adler=c(I.adler,G,R,0)),X.wrap=0,R>=X.w_size&&(se===0&&(re(X.head),X.strstart=0,X.block_start=0,X.insert=0),B=new o.Buf8(X.w_size),o.arraySet(B,G,R-X.w_size,X.w_size,0),G=B,R=X.w_size),ge=I.avail_in,he=I.next_in,le=I.input,I.avail_in=R,I.next_in=0,I.input=G,q(X);X.lookahead>=k;){for(V=X.strstart,ee=X.lookahead-(k-1);X.ins_h=(X.ins_h<<X.hash_shift^X.window[V+k-1])&X.hash_mask,X.prev[V&X.w_mask]=X.head[X.ins_h],X.head[X.ins_h]=V,V++,--ee;);X.strstart=V,X.lookahead=k-1,q(X)}return X.strstart+=X.lookahead,X.block_start=X.strstart,X.insert=X.lookahead,X.lookahead=0,X.match_length=X.prev_length=k-1,X.match_available=0,I.next_in=he,I.input=le,I.avail_in=ge,X.wrap=se,f},i.deflateInfo=\"pako deflate (from Nodeca project)\"},{\"../utils/common\":41,\"./adler32\":43,\"./crc32\":45,\"./messages\":51,\"./trees\":52}],47:[function(n,r,i){r.exports=function(){this.text=0,this.time=0,this.xflags=0,this.os=0,this.extra=null,this.extra_len=0,this.name=\"\",this.comment=\"\",this.hcrc=0,this.done=!1}},{}],48:[function(n,r,i){r.exports=function(s,o){var l,c,d,u,m,p,f,y,v,b,g,_,C,P,N,A,T,F,k,D,H,z,Q,L,te;l=s.state,c=s.next_in,L=s.input,d=c+(s.avail_in-5),u=s.next_out,te=s.output,m=u-(o-s.avail_out),p=u+(s.avail_out-257),f=l.dmax,y=l.wsize,v=l.whave,b=l.wnext,g=l.window,_=l.hold,C=l.bits,P=l.lencode,N=l.distcode,A=(1<<l.lenbits)-1,T=(1<<l.distbits)-1;e:do{C<15&&(_+=L[c++]<<C,C+=8,_+=L[c++]<<C,C+=8),F=P[_&A];t:for(;;){if(_>>>=k=F>>>24,C-=k,(k=F>>>16&255)===0)te[u++]=65535&F;else{if(!(16&k)){if((64&k)==0){F=P[(65535&F)+(_&(1<<k)-1)];continue t}if(32&k){l.mode=12;break e}s.msg=\"invalid literal/length code\",l.mode=30;break e}D=65535&F,(k&=15)&&(C<k&&(_+=L[c++]<<C,C+=8),D+=_&(1<<k)-1,_>>>=k,C-=k),C<15&&(_+=L[c++]<<C,C+=8,_+=L[c++]<<C,C+=8),F=N[_&T];n:for(;;){if(_>>>=k=F>>>24,C-=k,!(16&(k=F>>>16&255))){if((64&k)==0){F=N[(65535&F)+(_&(1<<k)-1)];continue n}s.msg=\"invalid distance code\",l.mode=30;break e}if(H=65535&F,C<(k&=15)&&(_+=L[c++]<<C,(C+=8)<k&&(_+=L[c++]<<C,C+=8)),f<(H+=_&(1<<k)-1)){s.msg=\"invalid distance too far back\",l.mode=30;break e}if(_>>>=k,C-=k,(k=u-m)<H){if(v<(k=H-k)&&l.sane){s.msg=\"invalid distance too far back\",l.mode=30;break e}if(Q=g,(z=0)===b){if(z+=y-k,k<D){for(D-=k;te[u++]=g[z++],--k;);z=u-H,Q=te}}else if(b<k){if(z+=y+b-k,(k-=b)<D){for(D-=k;te[u++]=g[z++],--k;);if(z=0,b<D){for(D-=k=b;te[u++]=g[z++],--k;);z=u-H,Q=te}}}else if(z+=b-k,k<D){for(D-=k;te[u++]=g[z++],--k;);z=u-H,Q=te}for(;2<D;)te[u++]=Q[z++],te[u++]=Q[z++],te[u++]=Q[z++],D-=3;D&&(te[u++]=Q[z++],1<D&&(te[u++]=Q[z++]))}else{for(z=u-H;te[u++]=te[z++],te[u++]=te[z++],te[u++]=te[z++],2<(D-=3););D&&(te[u++]=te[z++],1<D&&(te[u++]=te[z++]))}break}}break}}while(c<d&&u<p);c-=D=C>>3,_&=(1<<(C-=D<<3))-1,s.next_in=c,s.next_out=u,s.avail_in=c<d?d-c+5:5-(c-d),s.avail_out=u<p?p-u+257:257-(u-p),l.hold=_,l.bits=C}},{}],49:[function(n,r,i){var s=n(\"../utils/common\"),o=n(\"./adler32\"),l=n(\"./crc32\"),c=n(\"./inffast\"),d=n(\"./inftrees\"),u=1,m=2,p=0,f=-2,y=1,v=852,b=592;function g(z){return(z>>>24&255)+(z>>>8&65280)+((65280&z)<<8)+((255&z)<<24)}function _(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new s.Buf16(320),this.work=new s.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function C(z){var Q;return z&&z.state?(Q=z.state,z.total_in=z.total_out=Q.total=0,z.msg=\"\",Q.wrap&&(z.adler=1&Q.wrap),Q.mode=y,Q.last=0,Q.havedict=0,Q.dmax=32768,Q.head=null,Q.hold=0,Q.bits=0,Q.lencode=Q.lendyn=new s.Buf32(v),Q.distcode=Q.distdyn=new s.Buf32(b),Q.sane=1,Q.back=-1,p):f}function P(z){var Q;return z&&z.state?((Q=z.state).wsize=0,Q.whave=0,Q.wnext=0,C(z)):f}function N(z,Q){var L,te;return z&&z.state?(te=z.state,Q<0?(L=0,Q=-Q):(L=1+(Q>>4),Q<48&&(Q&=15)),Q&&(Q<8||15<Q)?f:(te.window!==null&&te.wbits!==Q&&(te.window=null),te.wrap=L,te.wbits=Q,P(z))):f}function A(z,Q){var L,te;return z?(te=new _,(z.state=te).window=null,(L=N(z,Q))!==p&&(z.state=null),L):f}var T,F,k=!0;function D(z){if(k){var Q;for(T=new s.Buf32(512),F=new s.Buf32(32),Q=0;Q<144;)z.lens[Q++]=8;for(;Q<256;)z.lens[Q++]=9;for(;Q<280;)z.lens[Q++]=7;for(;Q<288;)z.lens[Q++]=8;for(d(u,z.lens,0,288,T,0,z.work,{bits:9}),Q=0;Q<32;)z.lens[Q++]=5;d(m,z.lens,0,32,F,0,z.work,{bits:5}),k=!1}z.lencode=T,z.lenbits=9,z.distcode=F,z.distbits=5}function H(z,Q,L,te){var ie,J=z.state;return J.window===null&&(J.wsize=1<<J.wbits,J.wnext=0,J.whave=0,J.window=new s.Buf8(J.wsize)),te>=J.wsize?(s.arraySet(J.window,Q,L-J.wsize,J.wsize,0),J.wnext=0,J.whave=J.wsize):(te<(ie=J.wsize-J.wnext)&&(ie=te),s.arraySet(J.window,Q,L-te,ie,J.wnext),(te-=ie)?(s.arraySet(J.window,Q,L-te,te,0),J.wnext=te,J.whave=J.wsize):(J.wnext+=ie,J.wnext===J.wsize&&(J.wnext=0),J.whave<J.wsize&&(J.whave+=ie))),0}i.inflateReset=P,i.inflateReset2=N,i.inflateResetKeep=C,i.inflateInit=function(z){return A(z,15)},i.inflateInit2=A,i.inflate=function(z,Q){var L,te,ie,J,oe,fe,re,W,ne,me,be,Ce,q,Y,E,j,O,K,U,de,I,G,X,V,ee=0,se=new s.Buf8(4),ge=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15];if(!z||!z.state||!z.output||!z.input&&z.avail_in!==0)return f;(L=z.state).mode===12&&(L.mode=13),oe=z.next_out,ie=z.output,re=z.avail_out,J=z.next_in,te=z.input,fe=z.avail_in,W=L.hold,ne=L.bits,me=fe,be=re,G=p;e:for(;;)switch(L.mode){case y:if(L.wrap===0){L.mode=13;break}for(;ne<16;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if(2&L.wrap&&W===35615){se[L.check=0]=255&W,se[1]=W>>>8&255,L.check=l(L.check,se,2,0),ne=W=0,L.mode=2;break}if(L.flags=0,L.head&&(L.head.done=!1),!(1&L.wrap)||(((255&W)<<8)+(W>>8))%31){z.msg=\"incorrect header check\",L.mode=30;break}if((15&W)!=8){z.msg=\"unknown compression method\",L.mode=30;break}if(ne-=4,I=8+(15&(W>>>=4)),L.wbits===0)L.wbits=I;else if(I>L.wbits){z.msg=\"invalid window size\",L.mode=30;break}L.dmax=1<<I,z.adler=L.check=1,L.mode=512&W?10:12,ne=W=0;break;case 2:for(;ne<16;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if(L.flags=W,(255&L.flags)!=8){z.msg=\"unknown compression method\",L.mode=30;break}if(57344&L.flags){z.msg=\"unknown header flags set\",L.mode=30;break}L.head&&(L.head.text=W>>8&1),512&L.flags&&(se[0]=255&W,se[1]=W>>>8&255,L.check=l(L.check,se,2,0)),ne=W=0,L.mode=3;case 3:for(;ne<32;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}L.head&&(L.head.time=W),512&L.flags&&(se[0]=255&W,se[1]=W>>>8&255,se[2]=W>>>16&255,se[3]=W>>>24&255,L.check=l(L.check,se,4,0)),ne=W=0,L.mode=4;case 4:for(;ne<16;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}L.head&&(L.head.xflags=255&W,L.head.os=W>>8),512&L.flags&&(se[0]=255&W,se[1]=W>>>8&255,L.check=l(L.check,se,2,0)),ne=W=0,L.mode=5;case 5:if(1024&L.flags){for(;ne<16;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}L.length=W,L.head&&(L.head.extra_len=W),512&L.flags&&(se[0]=255&W,se[1]=W>>>8&255,L.check=l(L.check,se,2,0)),ne=W=0}else L.head&&(L.head.extra=null);L.mode=6;case 6:if(1024&L.flags&&(fe<(Ce=L.length)&&(Ce=fe),Ce&&(L.head&&(I=L.head.extra_len-L.length,L.head.extra||(L.head.extra=new Array(L.head.extra_len)),s.arraySet(L.head.extra,te,J,Ce,I)),512&L.flags&&(L.check=l(L.check,te,Ce,J)),fe-=Ce,J+=Ce,L.length-=Ce),L.length))break e;L.length=0,L.mode=7;case 7:if(2048&L.flags){if(fe===0)break e;for(Ce=0;I=te[J+Ce++],L.head&&I&&L.length<65536&&(L.head.name+=String.fromCharCode(I)),I&&Ce<fe;);if(512&L.flags&&(L.check=l(L.check,te,Ce,J)),fe-=Ce,J+=Ce,I)break e}else L.head&&(L.head.name=null);L.length=0,L.mode=8;case 8:if(4096&L.flags){if(fe===0)break e;for(Ce=0;I=te[J+Ce++],L.head&&I&&L.length<65536&&(L.head.comment+=String.fromCharCode(I)),I&&Ce<fe;);if(512&L.flags&&(L.check=l(L.check,te,Ce,J)),fe-=Ce,J+=Ce,I)break e}else L.head&&(L.head.comment=null);L.mode=9;case 9:if(512&L.flags){for(;ne<16;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if(W!==(65535&L.check)){z.msg=\"header crc mismatch\",L.mode=30;break}ne=W=0}L.head&&(L.head.hcrc=L.flags>>9&1,L.head.done=!0),z.adler=L.check=0,L.mode=12;break;case 10:for(;ne<32;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}z.adler=L.check=g(W),ne=W=0,L.mode=11;case 11:if(L.havedict===0)return z.next_out=oe,z.avail_out=re,z.next_in=J,z.avail_in=fe,L.hold=W,L.bits=ne,2;z.adler=L.check=1,L.mode=12;case 12:if(Q===5||Q===6)break e;case 13:if(L.last){W>>>=7&ne,ne-=7&ne,L.mode=27;break}for(;ne<3;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}switch(L.last=1&W,ne-=1,3&(W>>>=1)){case 0:L.mode=14;break;case 1:if(D(L),L.mode=20,Q!==6)break;W>>>=2,ne-=2;break e;case 2:L.mode=17;break;case 3:z.msg=\"invalid block type\",L.mode=30}W>>>=2,ne-=2;break;case 14:for(W>>>=7&ne,ne-=7&ne;ne<32;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if((65535&W)!=(W>>>16^65535)){z.msg=\"invalid stored block lengths\",L.mode=30;break}if(L.length=65535&W,ne=W=0,L.mode=15,Q===6)break e;case 15:L.mode=16;case 16:if(Ce=L.length){if(fe<Ce&&(Ce=fe),re<Ce&&(Ce=re),Ce===0)break e;s.arraySet(ie,te,J,Ce,oe),fe-=Ce,J+=Ce,re-=Ce,oe+=Ce,L.length-=Ce;break}L.mode=12;break;case 17:for(;ne<14;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if(L.nlen=257+(31&W),W>>>=5,ne-=5,L.ndist=1+(31&W),W>>>=5,ne-=5,L.ncode=4+(15&W),W>>>=4,ne-=4,286<L.nlen||30<L.ndist){z.msg=\"too many length or distance symbols\",L.mode=30;break}L.have=0,L.mode=18;case 18:for(;L.have<L.ncode;){for(;ne<3;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}L.lens[ge[L.have++]]=7&W,W>>>=3,ne-=3}for(;L.have<19;)L.lens[ge[L.have++]]=0;if(L.lencode=L.lendyn,L.lenbits=7,X={bits:L.lenbits},G=d(0,L.lens,0,19,L.lencode,0,L.work,X),L.lenbits=X.bits,G){z.msg=\"invalid code lengths set\",L.mode=30;break}L.have=0,L.mode=19;case 19:for(;L.have<L.nlen+L.ndist;){for(;j=(ee=L.lencode[W&(1<<L.lenbits)-1])>>>16&255,O=65535&ee,!((E=ee>>>24)<=ne);){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if(O<16)W>>>=E,ne-=E,L.lens[L.have++]=O;else{if(O===16){for(V=E+2;ne<V;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if(W>>>=E,ne-=E,L.have===0){z.msg=\"invalid bit length repeat\",L.mode=30;break}I=L.lens[L.have-1],Ce=3+(3&W),W>>>=2,ne-=2}else if(O===17){for(V=E+3;ne<V;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}ne-=E,I=0,Ce=3+(7&(W>>>=E)),W>>>=3,ne-=3}else{for(V=E+7;ne<V;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}ne-=E,I=0,Ce=11+(127&(W>>>=E)),W>>>=7,ne-=7}if(L.have+Ce>L.nlen+L.ndist){z.msg=\"invalid bit length repeat\",L.mode=30;break}for(;Ce--;)L.lens[L.have++]=I}}if(L.mode===30)break;if(L.lens[256]===0){z.msg=\"invalid code -- missing end-of-block\",L.mode=30;break}if(L.lenbits=9,X={bits:L.lenbits},G=d(u,L.lens,0,L.nlen,L.lencode,0,L.work,X),L.lenbits=X.bits,G){z.msg=\"invalid literal/lengths set\",L.mode=30;break}if(L.distbits=6,L.distcode=L.distdyn,X={bits:L.distbits},G=d(m,L.lens,L.nlen,L.ndist,L.distcode,0,L.work,X),L.distbits=X.bits,G){z.msg=\"invalid distances set\",L.mode=30;break}if(L.mode=20,Q===6)break e;case 20:L.mode=21;case 21:if(6<=fe&&258<=re){z.next_out=oe,z.avail_out=re,z.next_in=J,z.avail_in=fe,L.hold=W,L.bits=ne,c(z,be),oe=z.next_out,ie=z.output,re=z.avail_out,J=z.next_in,te=z.input,fe=z.avail_in,W=L.hold,ne=L.bits,L.mode===12&&(L.back=-1);break}for(L.back=0;j=(ee=L.lencode[W&(1<<L.lenbits)-1])>>>16&255,O=65535&ee,!((E=ee>>>24)<=ne);){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if(j&&(240&j)==0){for(K=E,U=j,de=O;j=(ee=L.lencode[de+((W&(1<<K+U)-1)>>K)])>>>16&255,O=65535&ee,!(K+(E=ee>>>24)<=ne);){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}W>>>=K,ne-=K,L.back+=K}if(W>>>=E,ne-=E,L.back+=E,L.length=O,j===0){L.mode=26;break}if(32&j){L.back=-1,L.mode=12;break}if(64&j){z.msg=\"invalid literal/length code\",L.mode=30;break}L.extra=15&j,L.mode=22;case 22:if(L.extra){for(V=L.extra;ne<V;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}L.length+=W&(1<<L.extra)-1,W>>>=L.extra,ne-=L.extra,L.back+=L.extra}L.was=L.length,L.mode=23;case 23:for(;j=(ee=L.distcode[W&(1<<L.distbits)-1])>>>16&255,O=65535&ee,!((E=ee>>>24)<=ne);){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if((240&j)==0){for(K=E,U=j,de=O;j=(ee=L.distcode[de+((W&(1<<K+U)-1)>>K)])>>>16&255,O=65535&ee,!(K+(E=ee>>>24)<=ne);){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}W>>>=K,ne-=K,L.back+=K}if(W>>>=E,ne-=E,L.back+=E,64&j){z.msg=\"invalid distance code\",L.mode=30;break}L.offset=O,L.extra=15&j,L.mode=24;case 24:if(L.extra){for(V=L.extra;ne<V;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}L.offset+=W&(1<<L.extra)-1,W>>>=L.extra,ne-=L.extra,L.back+=L.extra}if(L.offset>L.dmax){z.msg=\"invalid distance too far back\",L.mode=30;break}L.mode=25;case 25:if(re===0)break e;if(Ce=be-re,L.offset>Ce){if((Ce=L.offset-Ce)>L.whave&&L.sane){z.msg=\"invalid distance too far back\",L.mode=30;break}q=Ce>L.wnext?(Ce-=L.wnext,L.wsize-Ce):L.wnext-Ce,Ce>L.length&&(Ce=L.length),Y=L.window}else Y=ie,q=oe-L.offset,Ce=L.length;for(re<Ce&&(Ce=re),re-=Ce,L.length-=Ce;ie[oe++]=Y[q++],--Ce;);L.length===0&&(L.mode=21);break;case 26:if(re===0)break e;ie[oe++]=L.length,re--,L.mode=21;break;case 27:if(L.wrap){for(;ne<32;){if(fe===0)break e;fe--,W|=te[J++]<<ne,ne+=8}if(be-=re,z.total_out+=be,L.total+=be,be&&(z.adler=L.check=L.flags?l(L.check,ie,be,oe-be):o(L.check,ie,be,oe-be)),be=re,(L.flags?W:g(W))!==L.check){z.msg=\"incorrect data check\",L.mode=30;break}ne=W=0}L.mode=28;case 28:if(L.wrap&&L.flags){for(;ne<32;){if(fe===0)break e;fe--,W+=te[J++]<<ne,ne+=8}if(W!==(4294967295&L.total)){z.msg=\"incorrect length check\",L.mode=30;break}ne=W=0}L.mode=29;case 29:G=1;break e;case 30:G=-3;break e;case 31:return-4;default:return f}return z.next_out=oe,z.avail_out=re,z.next_in=J,z.avail_in=fe,L.hold=W,L.bits=ne,(L.wsize||be!==z.avail_out&&L.mode<30&&(L.mode<27||Q!==4))&&H(z,z.output,z.next_out,be-z.avail_out)?(L.mode=31,-4):(me-=z.avail_in,be-=z.avail_out,z.total_in+=me,z.total_out+=be,L.total+=be,L.wrap&&be&&(z.adler=L.check=L.flags?l(L.check,ie,be,z.next_out-be):o(L.check,ie,be,z.next_out-be)),z.data_type=L.bits+(L.last?64:0)+(L.mode===12?128:0)+(L.mode===20||L.mode===15?256:0),(me==0&&be===0||Q===4)&&G===p&&(G=-5),G)},i.inflateEnd=function(z){if(!z||!z.state)return f;var Q=z.state;return Q.window&&(Q.window=null),z.state=null,p},i.inflateGetHeader=function(z,Q){var L;return z&&z.state?(2&(L=z.state).wrap)==0?f:((L.head=Q).done=!1,p):f},i.inflateSetDictionary=function(z,Q){var L,te=Q.length;return z&&z.state?(L=z.state).wrap!==0&&L.mode!==11?f:L.mode===11&&o(1,Q,te,0)!==L.check?-3:H(z,Q,te,te)?(L.mode=31,-4):(L.havedict=1,p):f},i.inflateInfo=\"pako inflate (from Nodeca project)\"},{\"../utils/common\":41,\"./adler32\":43,\"./crc32\":45,\"./inffast\":48,\"./inftrees\":50}],50:[function(n,r,i){var s=n(\"../utils/common\"),o=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],l=[16,16,16,16,16,16,16,16,17,17,17,17,18,18,18,18,19,19,19,19,20,20,20,20,21,21,21,21,16,72,78],c=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577,0,0],d=[16,16,16,16,17,17,18,18,19,19,20,20,21,21,22,22,23,23,24,24,25,25,26,26,27,27,28,28,29,29,64,64];r.exports=function(u,m,p,f,y,v,b,g){var _,C,P,N,A,T,F,k,D,H=g.bits,z=0,Q=0,L=0,te=0,ie=0,J=0,oe=0,fe=0,re=0,W=0,ne=null,me=0,be=new s.Buf16(16),Ce=new s.Buf16(16),q=null,Y=0;for(z=0;z<=15;z++)be[z]=0;for(Q=0;Q<f;Q++)be[m[p+Q]]++;for(ie=H,te=15;1<=te&&be[te]===0;te--);if(te<ie&&(ie=te),te===0)return y[v++]=20971520,y[v++]=20971520,g.bits=1,0;for(L=1;L<te&&be[L]===0;L++);for(ie<L&&(ie=L),z=fe=1;z<=15;z++)if(fe<<=1,(fe-=be[z])<0)return-1;if(0<fe&&(u===0||te!==1))return-1;for(Ce[1]=0,z=1;z<15;z++)Ce[z+1]=Ce[z]+be[z];for(Q=0;Q<f;Q++)m[p+Q]!==0&&(b[Ce[m[p+Q]]++]=Q);if(T=u===0?(ne=q=b,19):u===1?(ne=o,me-=257,q=l,Y-=257,256):(ne=c,q=d,-1),z=L,A=v,oe=Q=W=0,P=-1,N=(re=1<<(J=ie))-1,u===1&&852<re||u===2&&592<re)return 1;for(;;){for(F=z-oe,D=b[Q]<T?(k=0,b[Q]):b[Q]>T?(k=q[Y+b[Q]],ne[me+b[Q]]):(k=96,0),_=1<<z-oe,L=C=1<<J;y[A+(W>>oe)+(C-=_)]=F<<24|k<<16|D|0,C!==0;);for(_=1<<z-1;W&_;)_>>=1;if(_!==0?(W&=_-1,W+=_):W=0,Q++,--be[z]==0){if(z===te)break;z=m[p+b[Q]]}if(ie<z&&(W&N)!==P){for(oe===0&&(oe=ie),A+=L,fe=1<<(J=z-oe);J+oe<te&&!((fe-=be[J+oe])<=0);)J++,fe<<=1;if(re+=1<<J,u===1&&852<re||u===2&&592<re)return 1;y[P=W&N]=ie<<24|J<<16|A-v|0}}return W!==0&&(y[A+W]=z-oe<<24|64<<16|0),g.bits=ie,0}},{\"../utils/common\":41}],51:[function(n,r,i){r.exports={2:\"need dictionary\",1:\"stream end\",0:\"\",\"-1\":\"file error\",\"-2\":\"stream error\",\"-3\":\"data error\",\"-4\":\"insufficient memory\",\"-5\":\"buffer error\",\"-6\":\"incompatible version\"}},{}],52:[function(n,r,i){var s=n(\"../utils/common\"),o=0,l=1;function c(ee){for(var se=ee.length;0<=--se;)ee[se]=0}var d=0,u=29,m=256,p=m+1+u,f=30,y=19,v=2*p+1,b=15,g=16,_=7,C=256,P=16,N=17,A=18,T=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0],F=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],k=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7],D=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],H=new Array(2*(p+2));c(H);var z=new Array(2*f);c(z);var Q=new Array(512);c(Q);var L=new Array(256);c(L);var te=new Array(u);c(te);var ie,J,oe,fe=new Array(f);function re(ee,se,ge,he,le){this.static_tree=ee,this.extra_bits=se,this.extra_base=ge,this.elems=he,this.max_length=le,this.has_stree=ee&&ee.length}function W(ee,se){this.dyn_tree=ee,this.max_code=0,this.stat_desc=se}function ne(ee){return ee<256?Q[ee]:Q[256+(ee>>>7)]}function me(ee,se){ee.pending_buf[ee.pending++]=255&se,ee.pending_buf[ee.pending++]=se>>>8&255}function be(ee,se,ge){ee.bi_valid>g-ge?(ee.bi_buf|=se<<ee.bi_valid&65535,me(ee,ee.bi_buf),ee.bi_buf=se>>g-ee.bi_valid,ee.bi_valid+=ge-g):(ee.bi_buf|=se<<ee.bi_valid&65535,ee.bi_valid+=ge)}function Ce(ee,se,ge){be(ee,ge[2*se],ge[2*se+1])}function q(ee,se){for(var ge=0;ge|=1&ee,ee>>>=1,ge<<=1,0<--se;);return ge>>>1}function Y(ee,se,ge){var he,le,B=new Array(b+1),R=0;for(he=1;he<=b;he++)B[he]=R=R+ge[he-1]<<1;for(le=0;le<=se;le++){var ae=ee[2*le+1];ae!==0&&(ee[2*le]=q(B[ae]++,ae))}}function E(ee){var se;for(se=0;se<p;se++)ee.dyn_ltree[2*se]=0;for(se=0;se<f;se++)ee.dyn_dtree[2*se]=0;for(se=0;se<y;se++)ee.bl_tree[2*se]=0;ee.dyn_ltree[2*C]=1,ee.opt_len=ee.static_len=0,ee.last_lit=ee.matches=0}function j(ee){8<ee.bi_valid?me(ee,ee.bi_buf):0<ee.bi_valid&&(ee.pending_buf[ee.pending++]=ee.bi_buf),ee.bi_buf=0,ee.bi_valid=0}function O(ee,se,ge,he){var le=2*se,B=2*ge;return ee[le]<ee[B]||ee[le]===ee[B]&&he[se]<=he[ge]}function K(ee,se,ge){for(var he=ee.heap[ge],le=ge<<1;le<=ee.heap_len&&(le<ee.heap_len&&O(se,ee.heap[le+1],ee.heap[le],ee.depth)&&le++,!O(se,he,ee.heap[le],ee.depth));)ee.heap[ge]=ee.heap[le],ge=le,le<<=1;ee.heap[ge]=he}function U(ee,se,ge){var he,le,B,R,ae=0;if(ee.last_lit!==0)for(;he=ee.pending_buf[ee.d_buf+2*ae]<<8|ee.pending_buf[ee.d_buf+2*ae+1],le=ee.pending_buf[ee.l_buf+ae],ae++,he===0?Ce(ee,le,se):(Ce(ee,(B=L[le])+m+1,se),(R=T[B])!==0&&be(ee,le-=te[B],R),Ce(ee,B=ne(--he),ge),(R=F[B])!==0&&be(ee,he-=fe[B],R)),ae<ee.last_lit;);Ce(ee,C,se)}function de(ee,se){var ge,he,le,B=se.dyn_tree,R=se.stat_desc.static_tree,ae=se.stat_desc.has_stree,_e=se.stat_desc.elems,Se=-1;for(ee.heap_len=0,ee.heap_max=v,ge=0;ge<_e;ge++)B[2*ge]!==0?(ee.heap[++ee.heap_len]=Se=ge,ee.depth[ge]=0):B[2*ge+1]=0;for(;ee.heap_len<2;)B[2*(le=ee.heap[++ee.heap_len]=Se<2?++Se:0)]=1,ee.depth[le]=0,ee.opt_len--,ae&&(ee.static_len-=R[2*le+1]);for(se.max_code=Se,ge=ee.heap_len>>1;1<=ge;ge--)K(ee,B,ge);for(le=_e;ge=ee.heap[1],ee.heap[1]=ee.heap[ee.heap_len--],K(ee,B,1),he=ee.heap[1],ee.heap[--ee.heap_max]=ge,ee.heap[--ee.heap_max]=he,B[2*le]=B[2*ge]+B[2*he],ee.depth[le]=(ee.depth[ge]>=ee.depth[he]?ee.depth[ge]:ee.depth[he])+1,B[2*ge+1]=B[2*he+1]=le,ee.heap[1]=le++,K(ee,B,1),2<=ee.heap_len;);ee.heap[--ee.heap_max]=ee.heap[1],(function(ve,Te){var ye,je,Le,Me,Oe,Re,$e=Te.dyn_tree,Ye=Te.max_code,tt=Te.stat_desc.static_tree,pe=Te.stat_desc.has_stree,Fe=Te.stat_desc.extra_bits,we=Te.stat_desc.extra_base,Ve=Te.stat_desc.max_length,Ae=0;for(Me=0;Me<=b;Me++)ve.bl_count[Me]=0;for($e[2*ve.heap[ve.heap_max]+1]=0,ye=ve.heap_max+1;ye<v;ye++)Ve<(Me=$e[2*$e[2*(je=ve.heap[ye])+1]+1]+1)&&(Me=Ve,Ae++),$e[2*je+1]=Me,Ye<je||(ve.bl_count[Me]++,Oe=0,we<=je&&(Oe=Fe[je-we]),Re=$e[2*je],ve.opt_len+=Re*(Me+Oe),pe&&(ve.static_len+=Re*(tt[2*je+1]+Oe)));if(Ae!==0){do{for(Me=Ve-1;ve.bl_count[Me]===0;)Me--;ve.bl_count[Me]--,ve.bl_count[Me+1]+=2,ve.bl_count[Ve]--,Ae-=2}while(0<Ae);for(Me=Ve;Me!==0;Me--)for(je=ve.bl_count[Me];je!==0;)Ye<(Le=ve.heap[--ye])||($e[2*Le+1]!==Me&&(ve.opt_len+=(Me-$e[2*Le+1])*$e[2*Le],$e[2*Le+1]=Me),je--)}})(ee,se),Y(B,Se,ee.bl_count)}function I(ee,se,ge){var he,le,B=-1,R=se[1],ae=0,_e=7,Se=4;for(R===0&&(_e=138,Se=3),se[2*(ge+1)+1]=65535,he=0;he<=ge;he++)le=R,R=se[2*(he+1)+1],++ae<_e&&le===R||(ae<Se?ee.bl_tree[2*le]+=ae:le!==0?(le!==B&&ee.bl_tree[2*le]++,ee.bl_tree[2*P]++):ae<=10?ee.bl_tree[2*N]++:ee.bl_tree[2*A]++,B=le,Se=(ae=0)===R?(_e=138,3):le===R?(_e=6,3):(_e=7,4))}function G(ee,se,ge){var he,le,B=-1,R=se[1],ae=0,_e=7,Se=4;for(R===0&&(_e=138,Se=3),he=0;he<=ge;he++)if(le=R,R=se[2*(he+1)+1],!(++ae<_e&&le===R)){if(ae<Se)for(;Ce(ee,le,ee.bl_tree),--ae!=0;);else le!==0?(le!==B&&(Ce(ee,le,ee.bl_tree),ae--),Ce(ee,P,ee.bl_tree),be(ee,ae-3,2)):ae<=10?(Ce(ee,N,ee.bl_tree),be(ee,ae-3,3)):(Ce(ee,A,ee.bl_tree),be(ee,ae-11,7));B=le,Se=(ae=0)===R?(_e=138,3):le===R?(_e=6,3):(_e=7,4)}}c(fe);var X=!1;function V(ee,se,ge,he){be(ee,(d<<1)+(he?1:0),3),(function(le,B,R,ae){j(le),me(le,R),me(le,~R),s.arraySet(le.pending_buf,le.window,B,R,le.pending),le.pending+=R})(ee,se,ge)}i._tr_init=function(ee){X||((function(){var se,ge,he,le,B,R=new Array(b+1);for(le=he=0;le<u-1;le++)for(te[le]=he,se=0;se<1<<T[le];se++)L[he++]=le;for(L[he-1]=le,le=B=0;le<16;le++)for(fe[le]=B,se=0;se<1<<F[le];se++)Q[B++]=le;for(B>>=7;le<f;le++)for(fe[le]=B<<7,se=0;se<1<<F[le]-7;se++)Q[256+B++]=le;for(ge=0;ge<=b;ge++)R[ge]=0;for(se=0;se<=143;)H[2*se+1]=8,se++,R[8]++;for(;se<=255;)H[2*se+1]=9,se++,R[9]++;for(;se<=279;)H[2*se+1]=7,se++,R[7]++;for(;se<=287;)H[2*se+1]=8,se++,R[8]++;for(Y(H,p+1,R),se=0;se<f;se++)z[2*se+1]=5,z[2*se]=q(se,5);ie=new re(H,T,m+1,p,b),J=new re(z,F,0,f,b),oe=new re(new Array(0),k,0,y,_)})(),X=!0),ee.l_desc=new W(ee.dyn_ltree,ie),ee.d_desc=new W(ee.dyn_dtree,J),ee.bl_desc=new W(ee.bl_tree,oe),ee.bi_buf=0,ee.bi_valid=0,E(ee)},i._tr_stored_block=V,i._tr_flush_block=function(ee,se,ge,he){var le,B,R=0;0<ee.level?(ee.strm.data_type===2&&(ee.strm.data_type=(function(ae){var _e,Se=4093624447;for(_e=0;_e<=31;_e++,Se>>>=1)if(1&Se&&ae.dyn_ltree[2*_e]!==0)return o;if(ae.dyn_ltree[18]!==0||ae.dyn_ltree[20]!==0||ae.dyn_ltree[26]!==0)return l;for(_e=32;_e<m;_e++)if(ae.dyn_ltree[2*_e]!==0)return l;return o})(ee)),de(ee,ee.l_desc),de(ee,ee.d_desc),R=(function(ae){var _e;for(I(ae,ae.dyn_ltree,ae.l_desc.max_code),I(ae,ae.dyn_dtree,ae.d_desc.max_code),de(ae,ae.bl_desc),_e=y-1;3<=_e&&ae.bl_tree[2*D[_e]+1]===0;_e--);return ae.opt_len+=3*(_e+1)+5+5+4,_e})(ee),le=ee.opt_len+3+7>>>3,(B=ee.static_len+3+7>>>3)<=le&&(le=B)):le=B=ge+5,ge+4<=le&&se!==-1?V(ee,se,ge,he):ee.strategy===4||B===le?(be(ee,2+(he?1:0),3),U(ee,H,z)):(be(ee,4+(he?1:0),3),(function(ae,_e,Se,ve){var Te;for(be(ae,_e-257,5),be(ae,Se-1,5),be(ae,ve-4,4),Te=0;Te<ve;Te++)be(ae,ae.bl_tree[2*D[Te]+1],3);G(ae,ae.dyn_ltree,_e-1),G(ae,ae.dyn_dtree,Se-1)})(ee,ee.l_desc.max_code+1,ee.d_desc.max_code+1,R+1),U(ee,ee.dyn_ltree,ee.dyn_dtree)),E(ee),he&&j(ee)},i._tr_tally=function(ee,se,ge){return ee.pending_buf[ee.d_buf+2*ee.last_lit]=se>>>8&255,ee.pending_buf[ee.d_buf+2*ee.last_lit+1]=255&se,ee.pending_buf[ee.l_buf+ee.last_lit]=255&ge,ee.last_lit++,se===0?ee.dyn_ltree[2*ge]++:(ee.matches++,se--,ee.dyn_ltree[2*(L[ge]+m+1)]++,ee.dyn_dtree[2*ne(se)]++),ee.last_lit===ee.lit_bufsize-1},i._tr_align=function(ee){be(ee,2,3),Ce(ee,C,H),(function(se){se.bi_valid===16?(me(se,se.bi_buf),se.bi_buf=0,se.bi_valid=0):8<=se.bi_valid&&(se.pending_buf[se.pending++]=255&se.bi_buf,se.bi_buf>>=8,se.bi_valid-=8)})(ee)}},{\"../utils/common\":41}],53:[function(n,r,i){r.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg=\"\",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(n,r,i){(function(s){(function(o,l){if(!o.setImmediate){var c,d,u,m,p=1,f={},y=!1,v=o.document,b=Object.getPrototypeOf&&Object.getPrototypeOf(o);b=b&&b.setTimeout?b:o,c={}.toString.call(o.process)===\"[object process]\"?function(P){process.nextTick(function(){_(P)})}:(function(){if(o.postMessage&&!o.importScripts){var P=!0,N=o.onmessage;return o.onmessage=function(){P=!1},o.postMessage(\"\",\"*\"),o.onmessage=N,P}})()?(m=\"setImmediate$\"+Math.random()+\"$\",o.addEventListener?o.addEventListener(\"message\",C,!1):o.attachEvent(\"onmessage\",C),function(P){o.postMessage(m+P,\"*\")}):o.MessageChannel?((u=new MessageChannel).port1.onmessage=function(P){_(P.data)},function(P){u.port2.postMessage(P)}):v&&\"onreadystatechange\"in v.createElement(\"script\")?(d=v.documentElement,function(P){var N=v.createElement(\"script\");N.onreadystatechange=function(){_(P),N.onreadystatechange=null,d.removeChild(N),N=null},d.appendChild(N)}):function(P){setTimeout(_,0,P)},b.setImmediate=function(P){typeof P!=\"function\"&&(P=new Function(\"\"+P));for(var N=new Array(arguments.length-1),A=0;A<N.length;A++)N[A]=arguments[A+1];var T={callback:P,args:N};return f[p]=T,c(p),p++},b.clearImmediate=g}function g(P){delete f[P]}function _(P){if(y)setTimeout(_,0,P);else{var N=f[P];if(N){y=!0;try{(function(A){var T=A.callback,F=A.args;switch(F.length){case 0:T();break;case 1:T(F[0]);break;case 2:T(F[0],F[1]);break;case 3:T(F[0],F[1],F[2]);break;default:T.apply(l,F)}})(N)}finally{g(P),y=!1}}}}function C(P){P.source===o&&typeof P.data==\"string\"&&P.data.indexOf(m)===0&&_(+P.data.slice(m.length))}})(typeof self>\"u\"?s===void 0?this:s:self)}).call(this,typeof W_<\"u\"?W_:typeof self<\"u\"?self:typeof window<\"u\"?window:{})},{}]},{},[10])(10)})})(dE)),dE.exports}var Eke=Mke();const Dke=bc(Eke);function Fke(t){const e=new _i;if(!t)return e;const n=t.trim().split(/\\s+/).map(parseFloat);return n.length>=12&&e.set(n[0],n[1],n[2],n[9],n[3],n[4],n[5],n[10],n[6],n[7],n[8],n[11],0,0,0,1),e}const K6=Fke;async function X6(t,e=0){const n=[],r=t.getElementsByTagName(\"mesh\");for(let i=0;i<r.length;i++){const s=r[i],o=[],l=[],c=s.getElementsByTagName(\"vertex\");for(let u=0;u<c.length;u++){const m=c[u];o.push(parseFloat(m.getAttribute(\"x\")||\"0\"),parseFloat(m.getAttribute(\"y\")||\"0\"),parseFloat(m.getAttribute(\"z\")||\"0\"))}const d=s.getElementsByTagName(\"triangle\");for(let u=0;u<d.length;u++){const m=d[u];l.push(parseInt(m.getAttribute(\"v1\")||\"0\"),parseInt(m.getAttribute(\"v2\")||\"0\"),parseInt(m.getAttribute(\"v3\")||\"0\"))}o.length>0&&l.length>0&&n.push({vertices:o,triangles:l,extruder:e})}return n}function Y6(t){const e=Array.from(t.attributes).find(r=>{const i=r.name.toLowerCase();return i===\"plate_id\"||i===\"plater_id\"||i===\"plateid\"||i===\"platerid\"||i.endsWith(\":plate_id\")||i.endsWith(\":plater_id\")});if(!e?.value)return null;const n=Number.parseInt(e.value,10);return Number.isFinite(n)?n:null}async function Rke(t){let e;try{e=await Dke.loadAsync(t)}catch{throw new Error(\"Unsupported file format\")}const n=new Map,r=[],i=new Map,s=new Map,o=new DOMParser;async function l(C){const P=C.startsWith(\"/\")?C.slice(1):C,N=e.files[P];if(!N)return null;const A=await N.async(\"string\");return o.parseFromString(A,\"application/xml\")}const c=new Map,d=new Map,u=new Map,m=new Map,p=e.files[\"Metadata/model_settings.config\"];if(p)try{const C=await p.async(\"string\"),P=o.parseFromString(C,\"application/xml\"),N=P.getElementsByTagName(\"object\");for(let T=0;T<N.length;T++){const F=N[T],k=F.getAttribute(\"id\");if(!k)continue;const D=Array.from(F.children).filter(L=>L.tagName===\"metadata\"&&L.getAttribute(\"key\")===\"extruder\");if(D.length>0){const L=D[0].getAttribute(\"value\");L&&c.set(k,Math.max(0,parseInt(L,10)-1))}const z=Array.from(F.children).find(L=>L.tagName===\"metadata\"&&L.getAttribute(\"key\")===\"name\")?.getAttribute(\"value\");z&&u.set(k,z);const Q=F.getElementsByTagName(\"part\");for(let L=0;L<Q.length;L++){const te=Q[L],ie=te.getAttribute(\"id\");if(!ie)continue;const J=Array.from(te.children).filter(oe=>oe.tagName===\"metadata\"&&oe.getAttribute(\"key\")===\"extruder\");if(J.length>0){const oe=J[0].getAttribute(\"value\");oe&&d.set(`${k}:${ie}`,Math.max(0,parseInt(oe,10)-1))}}}const A=P.getElementsByTagName(\"plate\");for(let T=0;T<A.length;T++){const F=A[T];let k=null;const D=F.getElementsByTagName(\"metadata\");let H=0,z=0;for(let L=0;L<D.length;L++){const te=D[L],ie=te.getAttribute(\"key\");if(ie===\"plater_id\"||ie===\"plate_id\"){const J=te.getAttribute(\"value\");if(J){const oe=Number.parseInt(J,10);Number.isFinite(oe)&&(k=oe)}}else if(ie===\"pos_x\"){const J=te.getAttribute(\"value\"),oe=J?Number.parseFloat(J):Number.NaN;Number.isFinite(oe)&&(H=oe)}else if(ie===\"pos_y\"){const J=te.getAttribute(\"value\"),oe=J?Number.parseFloat(J):Number.NaN;Number.isFinite(oe)&&(z=oe)}}if(k==null)continue;(H!==0||z!==0)&&s.set(k,{offsetX:H,offsetY:z});const Q=F.getElementsByTagName(\"model_instance\");for(let L=0;L<Q.length;L++){const ie=Q[L].getElementsByTagName(\"metadata\");for(let J=0;J<ie.length;J++){const oe=ie[J];if(oe.getAttribute(\"key\")===\"object_id\"){const fe=oe.getAttribute(\"value\");fe&&m.set(fe,k)}}}}}catch{}const f=new Map,y=Object.keys(e.files).filter(C=>C.startsWith(\"Metadata/plate_\")&&C.endsWith(\".json\"));for(const C of y){const P=C.match(/^Metadata\\/plate_(\\d+)\\.json$/);if(!P)continue;const N=Number.parseInt(P[1],10);if(Number.isFinite(N))try{const A=await e.files[C].async(\"string\"),T=JSON.parse(A),F=T.bbox_objects??[];for(const k of F)k?.name&&f.set(k.name,N);if(Array.isArray(T.bbox_all)&&T.bbox_all.length>=4){const[k,D,H,z]=T.bbox_all;[k,D,H,z].every(Q=>Number.isFinite(Q))&&i.set(N,{minX:k,minY:D,maxX:H,maxY:z})}}catch{}}const v=Object.keys(e.files).find(C=>C===\"3D/3dmodel.model\"||C.endsWith(\"/3dmodel.model\"));if(!v){const C=Object.keys(e.files).find(P=>P.endsWith(\".model\"));if(C){const P=await l(C);if(P){const N=await X6(P,0);N.length>0&&n.set(\"1\",{id:\"1\",meshes:N,defaultExtruder:0})}}return{objects:n,buildItems:r,plateBounds:i,plateOffsets:s}}const b=await l(v);if(!b)return{objects:n,buildItems:r,plateBounds:i,plateOffsets:s};const g=b.getElementsByTagName(\"object\");for(let C=0;C<g.length;C++){const P=g[C],N=P.getAttribute(\"id\");if(!N)continue;const A=Y6(P)??m.get(N)??null;let T=c.get(N)??-1;if(T<0){const H=P.getAttribute(\"p:extruder\")||P.getAttributeNS(\"http://schemas.microsoft.com/3dmanufacturing/production/2015/06\",\"extruder\")||\"1\";T=Math.max(0,parseInt(H,10)-1)}const F=[],k=P.getElementsByTagName(\"mesh\");for(let H=0;H<k.length;H++){const z=k[H],Q=[],L=[],te=z.getElementsByTagName(\"vertex\");for(let J=0;J<te.length;J++){const oe=te[J];Q.push(parseFloat(oe.getAttribute(\"x\")||\"0\"),parseFloat(oe.getAttribute(\"y\")||\"0\"),parseFloat(oe.getAttribute(\"z\")||\"0\"))}const ie=z.getElementsByTagName(\"triangle\");for(let J=0;J<ie.length;J++){const oe=ie[J];L.push(parseInt(oe.getAttribute(\"v1\")||\"0\"),parseInt(oe.getAttribute(\"v2\")||\"0\"),parseInt(oe.getAttribute(\"v3\")||\"0\"))}Q.length>0&&L.length>0&&F.push({vertices:Q,triangles:L,extruder:T})}const D=P.getElementsByTagName(\"component\");for(let H=0;H<D.length;H++){const z=D[H],Q=z.getAttribute(\"p:path\")||z.getAttributeNS(\"http://schemas.microsoft.com/3dmanufacturing/production/2015/06\",\"path\"),L=z.getAttribute(\"objectid\");if(Q){const te=await l(Q);if(te){const ie=L?`${N}:${L}`:null,J=ie?d.get(ie)??T:T,oe=await X6(te,J),fe=z.getAttribute(\"transform\"),re=K6(fe);for(const W of oe)if(fe){const ne=[];for(let me=0;me<W.vertices.length;me+=3){const be=new Ct(W.vertices[me],W.vertices[me+1],W.vertices[me+2]);be.applyMatrix4(re),ne.push(be.x,be.y,be.z)}F.push({vertices:ne,triangles:W.triangles,extruder:W.extruder})}else F.push(W)}}}F.length>0&&n.set(N,{id:N,meshes:F,defaultExtruder:T,plateId:A})}const _=b.getElementsByTagName(\"build\");if(_.length>0){const C=_[0].getElementsByTagName(\"item\");for(let P=0;P<C.length;P++){const N=C[P],A=N.getAttribute(\"objectid\");if(!A)continue;const T=K6(N.getAttribute(\"transform\")),F=Y6(N),k=n.get(A)?.plateId??null,D=u.get(A),H=D?f.get(D)??null:null;r.push({objectId:A,transform:T,plateId:F??k??H??null})}}return{objects:n,buildItems:r,plateBounds:i,plateOffsets:s}}function Q6(t){const e=new uc,n=new Float32Array(t.vertices.length);for(let r=0;r<t.vertices.length;r+=3)n[r]=t.vertices[r],n[r+1]=t.vertices[r+2],n[r+2]=t.vertices[r+1];return e.setAttribute(\"position\",new oo(n,3)),e.setIndex(t.triangles),e.computeVertexNormals(),e}function Lke(t){t.traverse(e=>{if(e instanceof mc)if(e.geometry.dispose(),Array.isArray(e.material))for(const n of e.material)n.dispose();else e.material.dispose()})}function Oke(t,e,n){const{objects:r,buildItems:i}=t,s=new vx,o=m=>{const f=n?.[m]||\"#00ae42\",y=new or(f);return new zZ({color:y,shininess:30,flatShading:!1})},l=new Map,c=i.some(m=>m.plateId!=null),d=e==null||!c?i:i.filter(m=>m.plateId===e),u=d.length>0?d:i;if(u.length>0)for(const m of u){const p=r.get(m.objectId);if(p)for(const f of p.meshes){const y=m.extruder??f.extruder,v=[];for(let g=0;g<f.vertices.length;g+=3){const _=new Ct(f.vertices[g],f.vertices[g+1],f.vertices[g+2]);_.applyMatrix4(m.transform),v.push(_.x,_.y,_.z)}const b=Q6({vertices:v,triangles:f.triangles});l.has(y)||l.set(y,[]),l.get(y).push(b)}}else for(const m of r.values())for(const p of m.meshes){const f=p.extruder,y=Q6(p);l.has(f)||l.set(f,[]),l.get(f).push(y)}for(const[m,p]of l){if(p.length===0)continue;const f=p.length===1?p[0]:Ake(p,!1);if(f){const y=o(m),v=new mc(f,y);s.add(v)}if(p.length>1)for(const y of p)y.dispose()}return s}function KZ({url:t,fileType:e,buildVolume:n={x:256,y:256,z:256},filamentColors:r,selectedPlateId:i=null,className:s=\"\"}){const{t:o}=Ft(),l=w.useRef(null),c=w.useRef(null),d=w.useRef(null),u=w.useRef(null),m=w.useRef(null),p=w.useRef(null),f=w.useRef(null),y=w.useRef(null),[v,b]=w.useState(!0),[g,_]=w.useState(null),[C,P]=w.useState(null),[N,A]=w.useState(null);w.useEffect(()=>{if(!l.current)return;const k=l.current,D=k.clientWidth,H=k.clientHeight,z=new c0e;z.background=new or(1710618),d.current=z;const Q=new Kl(45,D/H,.1,1e4);Q.position.set(150,150,150),u.current=Q;const L=new pke({antialias:!0});L.setSize(D,H),L.setPixelRatio(window.devicePixelRatio),k.appendChild(L.domElement),c.current=L;const te=new gke(Q,L.domElement);te.enableDamping=!0,te.dampingFactor=.05,m.current=te;const ie=new k0e(16777215,.6);z.add(ie);const J=new x6(16777215,.8);J.position.set(100,100,100),z.add(J);const oe=new x6(16777215,.4);oe.position.set(-100,50,-100),z.add(oe);const fe=Math.max(n.x,n.y),re=Math.ceil(fe/16),W=new C0e(fe,re,4473924,3355443);z.add(W),y.current=W;const ne=new aI(n.x,n.y),me=new tI({color:44610,transparent:!0,opacity:.15,side:Pd}),be=new mc(ne,me);be.rotation.x=-Math.PI/2,be.position.y=-.5,z.add(be),f.current=be;let Ce;const q=()=>{Ce=requestAnimationFrame(q),te.update(),L.render(z,Q)};q(),b(!0),_(null),P(null),A(null);const Y=(e||t.split(\"?\")[0].split(\".\").pop()||\"\").toLowerCase(),E={},j=vm();j&&(E.Authorization=`Bearer ${j}`),Y===\"stl\"?fetch(t,{headers:E}).then(U=>{if(!U.ok)throw new Error(o(\"modelViewer.errors.failedToLoad\"));return U.arrayBuffer()}).then(U=>{const I=new jke().parse(U);I.computeVertexNormals(),I.rotateX(-Math.PI/2),A(I)}).catch(U=>{_(U.message),b(!1)}):Y===\"3mf\"?fetch(t,{headers:E}).then(U=>{if(!U.ok)throw new Error(o(\"modelViewer.errors.failedToLoad\"));return U.arrayBuffer()}).then(Rke).then(U=>{if(U.objects.size===0)throw new Error(o(\"modelViewer.errors.noMeshes\"));P(U)}).catch(U=>{_(U.message),b(!1)}):(_(o(\"modelViewer.errors.unsupportedFormat\")),b(!1));const O=()=>{if(!k)return;const U=k.clientWidth,de=k.clientHeight;U===0||de===0||(Q.aspect=U/de,Q.updateProjectionMatrix(),L.setSize(U,de))};window.addEventListener(\"resize\",O);const K=new ResizeObserver(()=>{O()});return K.observe(k),()=>{window.removeEventListener(\"resize\",O),K.disconnect(),cancelAnimationFrame(Ce),te.dispose(),L.dispose(),k.removeChild(L.domElement),p.current=null,f.current=null,y.current=null}},[t,n,e,o]),w.useEffect(()=>{if(!d.current||!u.current||!m.current||!C&&!N)return;p.current&&(d.current.remove(p.current),Lke(p.current));const k=!!N,D=k?(()=>{const Y=r?.[0]||\"#00ae42\",E=new zZ({color:new or(Y),shininess:30}),j=new mc(N,E),O=new vx;return O.add(j),O})():Oke(C,i??null,r);p.current=D,d.current.add(D);const H=new sp().setFromObject(D),z=H.getCenter(new Ct);D.position.y=-H.min.y;const Q=!k&&i!=null&&C.buildItems.length>0?C.plateBounds.get(i):void 0,L=!k&&i!=null?C.plateOffsets.get(i):void 0,te=k||C.buildItems.length===0||i!=null&&!Q&&!L,ie=te?-z.x:0,J=te?-z.z:0;let oe=0,fe=0;if(!k&&i!=null&&C.buildItems.length>0&&Q){const Y=new sp().setFromObject(D);oe=Y.min.x-Q.minX,fe=Y.min.z-Q.minY}const re=n.x/2,W=n.y/2;!k&&i!=null&&C.buildItems.length>0&&Q?(D.position.x=ie-oe,D.position.z=J-fe):!k&&i!=null&&L?(D.position.x=ie+(re-L.offsetX),D.position.z=J+(W-L.offsetY)):te?(D.position.x=ie+re,D.position.z=J+W):(D.position.x=ie,D.position.z=J),f.current&&(f.current.position.x=re,f.current.position.z=W),y.current&&(y.current.position.x=re,y.current.position.z=W);const ne=new sp().setFromObject(D),me=ne.getCenter(new Ct),be=ne.getSize(new Ct),q=Math.max(be.x,be.y,be.z)*1.8;u.current.position.set(me.x+q*.7,me.y+q*.5,me.z+q*.7),m.current.target.copy(me),m.current.update(),b(!1)},[C,N,i,r,n]);const T=()=>{u.current&&m.current&&(u.current.position.set(150,150,150),m.current.target.set(0,50,0),m.current.update())},F=k=>{u.current&&u.current.position.multiplyScalar(k)};return a.jsxs(\"div\",{className:`relative ${s}`,children:[a.jsx(\"div\",{ref:l,className:\"w-full h-full min-h-[400px]\"}),v&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}),g&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\",children:a.jsx(\"p\",{className:\"text-red-400\",children:g})}),!v&&!g&&a.jsxs(\"div\",{className:\"absolute bottom-4 right-4 flex gap-2\",children:[a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>F(.8),children:a.jsx(IO,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>F(1.25),children:a.jsx(zO,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:T,children:a.jsx(co,{className:\"w-4 h-4\"})})]})]})}const JP=\"159\",Ob={ROTATE:0,DOLLY:1,PAN:2},Ib={ROTATE:0,PAN:1,DOLLY_PAN:2,DOLLY_ROTATE:3},Ike=0,Z6=1,zke=2,XZ=1,Uke=2,Uu=3,yp=0,Uo=1,Ku=2,op=0,Mx=1,J6=2,eq=3,tq=4,Bke=5,jf=100,Hke=101,qke=102,nq=103,rq=104,$ke=200,Vke=201,Gke=202,Wke=203,sR=204,oR=205,Kke=206,Xke=207,Yke=208,Qke=209,Zke=210,Jke=211,eNe=212,tNe=213,nNe=214,rNe=0,aNe=1,iNe=2,DN=3,sNe=4,oNe=5,lNe=6,cNe=7,oI=0,dNe=1,uNe=2,lp=0,mNe=1,hNe=2,pNe=3,fNe=4,gNe=5,YZ=300,Yx=301,Qx=302,lR=303,cR=304,eT=306,dR=1e3,Uc=1001,uR=1002,gs=1003,aq=1004,uE=1005,Xl=1006,bNe=1007,hw=1008,cp=1009,xNe=1010,yNe=1011,lI=1012,QZ=1013,qh=1014,tm=1015,pw=1016,ZZ=1017,JZ=1018,Kf=1020,vNe=1021,Yl=1023,wNe=1024,SNe=1025,Xf=1026,Zx=1027,_Ne=1028,eJ=1029,kNe=1030,tJ=1031,nJ=1033,mE=33776,hE=33777,pE=33778,fE=33779,iq=35840,sq=35841,oq=35842,lq=35843,rJ=36196,cq=37492,dq=37496,uq=37808,mq=37809,hq=37810,pq=37811,fq=37812,gq=37813,bq=37814,xq=37815,yq=37816,vq=37817,wq=37818,Sq=37819,_q=37820,kq=37821,gE=36492,Nq=36494,Cq=36495,NNe=36283,Pq=36284,Tq=36285,Aq=36286,aJ=3e3,Yf=3001,CNe=3200,PNe=3201,iJ=0,TNe=1,Ql=\"\",bs=\"srgb\",Sm=\"srgb-linear\",cI=\"display-p3\",tT=\"display-p3-linear\",FN=\"linear\",Sa=\"srgb\",RN=\"rec709\",LN=\"p3\",zb=7680,jq=519,ANe=512,jNe=513,MNe=514,sJ=515,ENe=516,DNe=517,FNe=518,RNe=519,mR=35044,Mq=\"300 es\",hR=1035,Ed=2e3,fw=2001;class Bg{addEventListener(e,n){this._listeners===void 0&&(this._listeners={});const r=this._listeners;r[e]===void 0&&(r[e]=[]),r[e].indexOf(n)===-1&&r[e].push(n)}hasEventListener(e,n){if(this._listeners===void 0)return!1;const r=this._listeners;return r[e]!==void 0&&r[e].indexOf(n)!==-1}removeEventListener(e,n){if(this._listeners===void 0)return;const i=this._listeners[e];if(i!==void 0){const s=i.indexOf(n);s!==-1&&i.splice(s,1)}}dispatchEvent(e){if(this._listeners===void 0)return;const r=this._listeners[e.type];if(r!==void 0){e.target=this;const i=r.slice(0);for(let s=0,o=i.length;s<o;s++)i[s].call(this,e);e.target=null}}}const Ds=[\"00\",\"01\",\"02\",\"03\",\"04\",\"05\",\"06\",\"07\",\"08\",\"09\",\"0a\",\"0b\",\"0c\",\"0d\",\"0e\",\"0f\",\"10\",\"11\",\"12\",\"13\",\"14\",\"15\",\"16\",\"17\",\"18\",\"19\",\"1a\",\"1b\",\"1c\",\"1d\",\"1e\",\"1f\",\"20\",\"21\",\"22\",\"23\",\"24\",\"25\",\"26\",\"27\",\"28\",\"29\",\"2a\",\"2b\",\"2c\",\"2d\",\"2e\",\"2f\",\"30\",\"31\",\"32\",\"33\",\"34\",\"35\",\"36\",\"37\",\"38\",\"39\",\"3a\",\"3b\",\"3c\",\"3d\",\"3e\",\"3f\",\"40\",\"41\",\"42\",\"43\",\"44\",\"45\",\"46\",\"47\",\"48\",\"49\",\"4a\",\"4b\",\"4c\",\"4d\",\"4e\",\"4f\",\"50\",\"51\",\"52\",\"53\",\"54\",\"55\",\"56\",\"57\",\"58\",\"59\",\"5a\",\"5b\",\"5c\",\"5d\",\"5e\",\"5f\",\"60\",\"61\",\"62\",\"63\",\"64\",\"65\",\"66\",\"67\",\"68\",\"69\",\"6a\",\"6b\",\"6c\",\"6d\",\"6e\",\"6f\",\"70\",\"71\",\"72\",\"73\",\"74\",\"75\",\"76\",\"77\",\"78\",\"79\",\"7a\",\"7b\",\"7c\",\"7d\",\"7e\",\"7f\",\"80\",\"81\",\"82\",\"83\",\"84\",\"85\",\"86\",\"87\",\"88\",\"89\",\"8a\",\"8b\",\"8c\",\"8d\",\"8e\",\"8f\",\"90\",\"91\",\"92\",\"93\",\"94\",\"95\",\"96\",\"97\",\"98\",\"99\",\"9a\",\"9b\",\"9c\",\"9d\",\"9e\",\"9f\",\"a0\",\"a1\",\"a2\",\"a3\",\"a4\",\"a5\",\"a6\",\"a7\",\"a8\",\"a9\",\"aa\",\"ab\",\"ac\",\"ad\",\"ae\",\"af\",\"b0\",\"b1\",\"b2\",\"b3\",\"b4\",\"b5\",\"b6\",\"b7\",\"b8\",\"b9\",\"ba\",\"bb\",\"bc\",\"bd\",\"be\",\"bf\",\"c0\",\"c1\",\"c2\",\"c3\",\"c4\",\"c5\",\"c6\",\"c7\",\"c8\",\"c9\",\"ca\",\"cb\",\"cc\",\"cd\",\"ce\",\"cf\",\"d0\",\"d1\",\"d2\",\"d3\",\"d4\",\"d5\",\"d6\",\"d7\",\"d8\",\"d9\",\"da\",\"db\",\"dc\",\"dd\",\"de\",\"df\",\"e0\",\"e1\",\"e2\",\"e3\",\"e4\",\"e5\",\"e6\",\"e7\",\"e8\",\"e9\",\"ea\",\"eb\",\"ec\",\"ed\",\"ee\",\"ef\",\"f0\",\"f1\",\"f2\",\"f3\",\"f4\",\"f5\",\"f6\",\"f7\",\"f8\",\"f9\",\"fa\",\"fb\",\"fc\",\"fd\",\"fe\",\"ff\"];let Eq=1234567;const j0=Math.PI/180,gw=180/Math.PI;function dm(){const t=Math.random()*4294967295|0,e=Math.random()*4294967295|0,n=Math.random()*4294967295|0,r=Math.random()*4294967295|0;return(Ds[t&255]+Ds[t>>8&255]+Ds[t>>16&255]+Ds[t>>24&255]+\"-\"+Ds[e&255]+Ds[e>>8&255]+\"-\"+Ds[e>>16&15|64]+Ds[e>>24&255]+\"-\"+Ds[n&63|128]+Ds[n>>8&255]+\"-\"+Ds[n>>16&255]+Ds[n>>24&255]+Ds[r&255]+Ds[r>>8&255]+Ds[r>>16&255]+Ds[r>>24&255]).toLowerCase()}function xs(t,e,n){return Math.max(e,Math.min(n,t))}function dI(t,e){return(t%e+e)%e}function LNe(t,e,n,r,i){return r+(t-e)*(i-r)/(n-e)}function ONe(t,e,n){return t!==e?(n-t)/(e-t):0}function M0(t,e,n){return(1-n)*t+n*e}function INe(t,e,n,r){return M0(t,e,1-Math.exp(-n*r))}function zNe(t,e=1){return e-Math.abs(dI(t,e*2)-e)}function UNe(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e),t*t*(3-2*t))}function BNe(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e),t*t*t*(t*(t*6-15)+10))}function HNe(t,e){return t+Math.floor(Math.random()*(e-t+1))}function qNe(t,e){return t+Math.random()*(e-t)}function $Ne(t){return t*(.5-Math.random())}function VNe(t){t!==void 0&&(Eq=t);let e=Eq+=1831565813;return e=Math.imul(e^e>>>15,e|1),e^=e+Math.imul(e^e>>>7,e|61),((e^e>>>14)>>>0)/4294967296}function GNe(t){return t*j0}function WNe(t){return t*gw}function pR(t){return(t&t-1)===0&&t!==0}function KNe(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))}function ON(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function XNe(t,e,n,r,i){const s=Math.cos,o=Math.sin,l=s(n/2),c=o(n/2),d=s((e+r)/2),u=o((e+r)/2),m=s((e-r)/2),p=o((e-r)/2),f=s((r-e)/2),y=o((r-e)/2);switch(i){case\"XYX\":t.set(l*u,c*m,c*p,l*d);break;case\"YZY\":t.set(c*p,l*u,c*m,l*d);break;case\"ZXZ\":t.set(c*m,c*p,l*u,l*d);break;case\"XZX\":t.set(l*u,c*y,c*f,l*d);break;case\"YXY\":t.set(c*f,l*u,c*y,l*d);break;case\"ZYZ\":t.set(c*y,c*f,l*u,l*d);break;default:console.warn(\"THREE.MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: \"+i)}}function Td(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error(\"Invalid component type.\")}}function Zr(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(t*4294967295);case Uint16Array:return Math.round(t*65535);case Uint8Array:return Math.round(t*255);case Int32Array:return Math.round(t*2147483647);case Int16Array:return Math.round(t*32767);case Int8Array:return Math.round(t*127);default:throw new Error(\"Invalid component type.\")}}const oJ={DEG2RAD:j0,RAD2DEG:gw,generateUUID:dm,clamp:xs,euclideanModulo:dI,mapLinear:LNe,inverseLerp:ONe,lerp:M0,damp:INe,pingpong:zNe,smoothstep:UNe,smootherstep:BNe,randInt:HNe,randFloat:qNe,randFloatSpread:$Ne,seededRandom:VNe,degToRad:GNe,radToDeg:WNe,isPowerOfTwo:pR,ceilPowerOfTwo:KNe,floorPowerOfTwo:ON,setQuaternionFromProperEuler:XNe,normalize:Zr,denormalize:Td};class Vn{constructor(e=0,n=0){Vn.prototype.isVector2=!0,this.x=e,this.y=n}get width(){return this.x}set width(e){this.x=e}get height(){return this.y}set height(e){this.y=e}set(e,n){return this.x=e,this.y=n,this}setScalar(e){return this.x=e,this.y=e,this}setX(e){return this.x=e,this}setY(e){return this.y=e,this}setComponent(e,n){switch(e){case 0:this.x=n;break;case 1:this.y=n;break;default:throw new Error(\"index is out of range: \"+e)}return this}getComponent(e){switch(e){case 0:return this.x;case 1:return this.y;default:throw new Error(\"index is out of range: \"+e)}}clone(){return new this.constructor(this.x,this.y)}copy(e){return this.x=e.x,this.y=e.y,this}add(e){return this.x+=e.x,this.y+=e.y,this}addScalar(e){return this.x+=e,this.y+=e,this}addVectors(e,n){return this.x=e.x+n.x,this.y=e.y+n.y,this}addScaledVector(e,n){return this.x+=e.x*n,this.y+=e.y*n,this}sub(e){return this.x-=e.x,this.y-=e.y,this}subScalar(e){return this.x-=e,this.y-=e,this}subVectors(e,n){return this.x=e.x-n.x,this.y=e.y-n.y,this}multiply(e){return this.x*=e.x,this.y*=e.y,this}multiplyScalar(e){return this.x*=e,this.y*=e,this}divide(e){return this.x/=e.x,this.y/=e.y,this}divideScalar(e){return this.multiplyScalar(1/e)}applyMatrix3(e){const n=this.x,r=this.y,i=e.elements;return this.x=i[0]*n+i[3]*r+i[6],this.y=i[1]*n+i[4]*r+i[7],this}min(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this}max(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this}clamp(e,n){return this.x=Math.max(e.x,Math.min(n.x,this.x)),this.y=Math.max(e.y,Math.min(n.y,this.y)),this}clampScalar(e,n){return this.x=Math.max(e,Math.min(n,this.x)),this.y=Math.max(e,Math.min(n,this.y)),this}clampLength(e,n){const r=this.length();return this.divideScalar(r||1).multiplyScalar(Math.max(e,Math.min(n,r)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this}negate(){return this.x=-this.x,this.y=-this.y,this}dot(e){return this.x*e.x+this.y*e.y}cross(e){return this.x*e.y-this.y*e.x}lengthSq(){return this.x*this.x+this.y*this.y}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)}normalize(){return this.divideScalar(this.length()||1)}angle(){return Math.atan2(-this.y,-this.x)+Math.PI}angleTo(e){const n=Math.sqrt(this.lengthSq()*e.lengthSq());if(n===0)return Math.PI/2;const r=this.dot(e)/n;return Math.acos(xs(r,-1,1))}distanceTo(e){return Math.sqrt(this.distanceToSquared(e))}distanceToSquared(e){const n=this.x-e.x,r=this.y-e.y;return n*n+r*r}manhattanDistanceTo(e){return Math.abs(this.x-e.x)+Math.abs(this.y-e.y)}setLength(e){return this.normalize().multiplyScalar(e)}lerp(e,n){return this.x+=(e.x-this.x)*n,this.y+=(e.y-this.y)*n,this}lerpVectors(e,n,r){return this.x=e.x+(n.x-e.x)*r,this.y=e.y+(n.y-e.y)*r,this}equals(e){return e.x===this.x&&e.y===this.y}fromArray(e,n=0){return this.x=e[n],this.y=e[n+1],this}toArray(e=[],n=0){return e[n]=this.x,e[n+1]=this.y,e}fromBufferAttribute(e,n){return this.x=e.getX(n),this.y=e.getY(n),this}rotateAround(e,n){const r=Math.cos(n),i=Math.sin(n),s=this.x-e.x,o=this.y-e.y;return this.x=s*r-o*i+e.x,this.y=s*i+o*r+e.y,this}random(){return this.x=Math.random(),this.y=Math.random(),this}*[Symbol.iterator](){yield this.x,yield this.y}}class mr{constructor(e,n,r,i,s,o,l,c,d){mr.prototype.isMatrix3=!0,this.elements=[1,0,0,0,1,0,0,0,1],e!==void 0&&this.set(e,n,r,i,s,o,l,c,d)}set(e,n,r,i,s,o,l,c,d){const u=this.elements;return u[0]=e,u[1]=i,u[2]=l,u[3]=n,u[4]=s,u[5]=c,u[6]=r,u[7]=o,u[8]=d,this}identity(){return this.set(1,0,0,0,1,0,0,0,1),this}copy(e){const n=this.elements,r=e.elements;return n[0]=r[0],n[1]=r[1],n[2]=r[2],n[3]=r[3],n[4]=r[4],n[5]=r[5],n[6]=r[6],n[7]=r[7],n[8]=r[8],this}extractBasis(e,n,r){return e.setFromMatrix3Column(this,0),n.setFromMatrix3Column(this,1),r.setFromMatrix3Column(this,2),this}setFromMatrix4(e){const n=e.elements;return this.set(n[0],n[4],n[8],n[1],n[5],n[9],n[2],n[6],n[10]),this}multiply(e){return this.multiplyMatrices(this,e)}premultiply(e){return this.multiplyMatrices(e,this)}multiplyMatrices(e,n){const r=e.elements,i=n.elements,s=this.elements,o=r[0],l=r[3],c=r[6],d=r[1],u=r[4],m=r[7],p=r[2],f=r[5],y=r[8],v=i[0],b=i[3],g=i[6],_=i[1],C=i[4],P=i[7],N=i[2],A=i[5],T=i[8];return s[0]=o*v+l*_+c*N,s[3]=o*b+l*C+c*A,s[6]=o*g+l*P+c*T,s[1]=d*v+u*_+m*N,s[4]=d*b+u*C+m*A,s[7]=d*g+u*P+m*T,s[2]=p*v+f*_+y*N,s[5]=p*b+f*C+y*A,s[8]=p*g+f*P+y*T,this}multiplyScalar(e){const n=this.elements;return n[0]*=e,n[3]*=e,n[6]*=e,n[1]*=e,n[4]*=e,n[7]*=e,n[2]*=e,n[5]*=e,n[8]*=e,this}determinant(){const e=this.elements,n=e[0],r=e[1],i=e[2],s=e[3],o=e[4],l=e[5],c=e[6],d=e[7],u=e[8];return n*o*u-n*l*d-r*s*u+r*l*c+i*s*d-i*o*c}invert(){const e=this.elements,n=e[0],r=e[1],i=e[2],s=e[3],o=e[4],l=e[5],c=e[6],d=e[7],u=e[8],m=u*o-l*d,p=l*c-u*s,f=d*s-o*c,y=n*m+r*p+i*f;if(y===0)return this.set(0,0,0,0,0,0,0,0,0);const v=1/y;return e[0]=m*v,e[1]=(i*d-u*r)*v,e[2]=(l*r-i*o)*v,e[3]=p*v,e[4]=(u*n-i*c)*v,e[5]=(i*s-l*n)*v,e[6]=f*v,e[7]=(r*c-d*n)*v,e[8]=(o*n-r*s)*v,this}transpose(){let e;const n=this.elements;return e=n[1],n[1]=n[3],n[3]=e,e=n[2],n[2]=n[6],n[6]=e,e=n[5],n[5]=n[7],n[7]=e,this}getNormalMatrix(e){return this.setFromMatrix4(e).invert().transpose()}transposeIntoArray(e){const n=this.elements;return e[0]=n[0],e[1]=n[3],e[2]=n[6],e[3]=n[1],e[4]=n[4],e[5]=n[7],e[6]=n[2],e[7]=n[5],e[8]=n[8],this}setUvTransform(e,n,r,i,s,o,l){const c=Math.cos(s),d=Math.sin(s);return this.set(r*c,r*d,-r*(c*o+d*l)+o+e,-i*d,i*c,-i*(-d*o+c*l)+l+n,0,0,1),this}scale(e,n){return this.premultiply(bE.makeScale(e,n)),this}rotate(e){return this.premultiply(bE.makeRotation(-e)),this}translate(e,n){return this.premultiply(bE.makeTranslation(e,n)),this}makeTranslation(e,n){return e.isVector2?this.set(1,0,e.x,0,1,e.y,0,0,1):this.set(1,0,e,0,1,n,0,0,1),this}makeRotation(e){const n=Math.cos(e),r=Math.sin(e);return this.set(n,-r,0,r,n,0,0,0,1),this}makeScale(e,n){return this.set(e,0,0,0,n,0,0,0,1),this}equals(e){const n=this.elements,r=e.elements;for(let i=0;i<9;i++)if(n[i]!==r[i])return!1;return!0}fromArray(e,n=0){for(let r=0;r<9;r++)this.elements[r]=e[r+n];return this}toArray(e=[],n=0){const r=this.elements;return e[n]=r[0],e[n+1]=r[1],e[n+2]=r[2],e[n+3]=r[3],e[n+4]=r[4],e[n+5]=r[5],e[n+6]=r[6],e[n+7]=r[7],e[n+8]=r[8],e}clone(){return new this.constructor().fromArray(this.elements)}}const bE=new mr;function lJ(t){for(let e=t.length-1;e>=0;--e)if(t[e]>=65535)return!0;return!1}function IN(t){return document.createElementNS(\"http://www.w3.org/1999/xhtml\",t)}function YNe(){const t=IN(\"canvas\");return t.style.display=\"block\",t}const Dq={};function E0(t){t in Dq||(Dq[t]=!0,console.warn(t))}const Fq=new mr().set(.8224621,.177538,0,.0331941,.9668058,0,.0170827,.0723974,.9105199),Rq=new mr().set(1.2249401,-.2249404,0,-.0420569,1.0420571,0,-.0196376,-.0786361,1.0982735),S1={[Sm]:{transfer:FN,primaries:RN,toReference:t=>t,fromReference:t=>t},[bs]:{transfer:Sa,primaries:RN,toReference:t=>t.convertSRGBToLinear(),fromReference:t=>t.convertLinearToSRGB()},[tT]:{transfer:FN,primaries:LN,toReference:t=>t.applyMatrix3(Rq),fromReference:t=>t.applyMatrix3(Fq)},[cI]:{transfer:Sa,primaries:LN,toReference:t=>t.convertSRGBToLinear().applyMatrix3(Rq),fromReference:t=>t.applyMatrix3(Fq).convertLinearToSRGB()}},QNe=new Set([Sm,tT]),Jr={enabled:!0,_workingColorSpace:Sm,get legacyMode(){return console.warn(\"THREE.ColorManagement: .legacyMode=false renamed to .enabled=true in r150.\"),!this.enabled},set legacyMode(t){console.warn(\"THREE.ColorManagement: .legacyMode=false renamed to .enabled=true in r150.\"),this.enabled=!t},get workingColorSpace(){return this._workingColorSpace},set workingColorSpace(t){if(!QNe.has(t))throw new Error(`Unsupported working color space, \"${t}\".`);this._workingColorSpace=t},convert:function(t,e,n){if(this.enabled===!1||e===n||!e||!n)return t;const r=S1[e].toReference,i=S1[n].fromReference;return i(r(t))},fromWorkingColorSpace:function(t,e){return this.convert(t,this._workingColorSpace,e)},toWorkingColorSpace:function(t,e){return this.convert(t,e,this._workingColorSpace)},getPrimaries:function(t){return S1[t].primaries},getTransfer:function(t){return t===Ql?FN:S1[t].transfer}};function Ex(t){return t<.04045?t*.0773993808:Math.pow(t*.9478672986+.0521327014,2.4)}function xE(t){return t<.0031308?t*12.92:1.055*Math.pow(t,.41666)-.055}let Ub;class cJ{static getDataURL(e){if(/^data:/i.test(e.src)||typeof HTMLCanvasElement>\"u\")return e.src;let n;if(e instanceof HTMLCanvasElement)n=e;else{Ub===void 0&&(Ub=IN(\"canvas\")),Ub.width=e.width,Ub.height=e.height;const r=Ub.getContext(\"2d\");e instanceof ImageData?r.putImageData(e,0,0):r.drawImage(e,0,0,e.width,e.height),n=Ub}return n.width>2048||n.height>2048?(console.warn(\"THREE.ImageUtils.getDataURL: Image converted to jpg for performance reasons\",e),n.toDataURL(\"image/jpeg\",.6)):n.toDataURL(\"image/png\")}static sRGBToLinear(e){if(typeof HTMLImageElement<\"u\"&&e instanceof HTMLImageElement||typeof HTMLCanvasElement<\"u\"&&e instanceof HTMLCanvasElement||typeof ImageBitmap<\"u\"&&e instanceof ImageBitmap){const n=IN(\"canvas\");n.width=e.width,n.height=e.height;const r=n.getContext(\"2d\");r.drawImage(e,0,0,e.width,e.height);const i=r.getImageData(0,0,e.width,e.height),s=i.data;for(let o=0;o<s.length;o++)s[o]=Ex(s[o]/255)*255;return r.putImageData(i,0,0),n}else if(e.data){const n=e.data.slice(0);for(let r=0;r<n.length;r++)n instanceof Uint8Array||n instanceof Uint8ClampedArray?n[r]=Math.floor(Ex(n[r]/255)*255):n[r]=Ex(n[r]);return{data:n,width:e.width,height:e.height}}else return console.warn(\"THREE.ImageUtils.sRGBToLinear(): Unsupported image type. No color space conversion applied.\"),e}}let ZNe=0;class dJ{constructor(e=null){this.isSource=!0,Object.defineProperty(this,\"id\",{value:ZNe++}),this.uuid=dm(),this.data=e,this.version=0}set needsUpdate(e){e===!0&&this.version++}toJSON(e){const n=e===void 0||typeof e==\"string\";if(!n&&e.images[this.uuid]!==void 0)return e.images[this.uuid];const r={uuid:this.uuid,url:\"\"},i=this.data;if(i!==null){let s;if(Array.isArray(i)){s=[];for(let o=0,l=i.length;o<l;o++)i[o].isDataTexture?s.push(yE(i[o].image)):s.push(yE(i[o]))}else s=yE(i);r.url=s}return n||(e.images[this.uuid]=r),r}}function yE(t){return typeof HTMLImageElement<\"u\"&&t instanceof HTMLImageElement||typeof HTMLCanvasElement<\"u\"&&t instanceof HTMLCanvasElement||typeof ImageBitmap<\"u\"&&t instanceof ImageBitmap?cJ.getDataURL(t):t.data?{data:Array.from(t.data),width:t.width,height:t.height,type:t.data.constructor.name}:(console.warn(\"THREE.Texture: Unable to serialize Texture.\"),{})}let JNe=0;class Bo extends Bg{constructor(e=Bo.DEFAULT_IMAGE,n=Bo.DEFAULT_MAPPING,r=Uc,i=Uc,s=Xl,o=hw,l=Yl,c=cp,d=Bo.DEFAULT_ANISOTROPY,u=Ql){super(),this.isTexture=!0,Object.defineProperty(this,\"id\",{value:JNe++}),this.uuid=dm(),this.name=\"\",this.source=new dJ(e),this.mipmaps=[],this.mapping=n,this.channel=0,this.wrapS=r,this.wrapT=i,this.magFilter=s,this.minFilter=o,this.anisotropy=d,this.format=l,this.internalFormat=null,this.type=c,this.offset=new Vn(0,0),this.repeat=new Vn(1,1),this.center=new Vn(0,0),this.rotation=0,this.matrixAutoUpdate=!0,this.matrix=new mr,this.generateMipmaps=!0,this.premultiplyAlpha=!1,this.flipY=!0,this.unpackAlignment=4,typeof u==\"string\"?this.colorSpace=u:(E0(\"THREE.Texture: Property .encoding has been replaced by .colorSpace.\"),this.colorSpace=u===Yf?bs:Ql),this.userData={},this.version=0,this.onUpdate=null,this.isRenderTargetTexture=!1,this.needsPMREMUpdate=!1}get image(){return this.source.data}set image(e=null){this.source.data=e}updateMatrix(){this.matrix.setUvTransform(this.offset.x,this.offset.y,this.repeat.x,this.repeat.y,this.rotation,this.center.x,this.center.y)}clone(){return new this.constructor().copy(this)}copy(e){return this.name=e.name,this.source=e.source,this.mipmaps=e.mipmaps.slice(0),this.mapping=e.mapping,this.channel=e.channel,this.wrapS=e.wrapS,this.wrapT=e.wrapT,this.magFilter=e.magFilter,this.minFilter=e.minFilter,this.anisotropy=e.anisotropy,this.format=e.format,this.internalFormat=e.internalFormat,this.type=e.type,this.offset.copy(e.offset),this.repeat.copy(e.repeat),this.center.copy(e.center),this.rotation=e.rotation,this.matrixAutoUpdate=e.matrixAutoUpdate,this.matrix.copy(e.matrix),this.generateMipmaps=e.generateMipmaps,this.premultiplyAlpha=e.premultiplyAlpha,this.flipY=e.flipY,this.unpackAlignment=e.unpackAlignment,this.colorSpace=e.colorSpace,this.userData=JSON.parse(JSON.stringify(e.userData)),this.needsUpdate=!0,this}toJSON(e){const n=e===void 0||typeof e==\"string\";if(!n&&e.textures[this.uuid]!==void 0)return e.textures[this.uuid];const r={metadata:{version:4.6,type:\"Texture\",generator:\"Texture.toJSON\"},uuid:this.uuid,name:this.name,image:this.source.toJSON(e).uuid,mapping:this.mapping,channel:this.channel,repeat:[this.repeat.x,this.repeat.y],offset:[this.offset.x,this.offset.y],center:[this.center.x,this.center.y],rotation:this.rotation,wrap:[this.wrapS,this.wrapT],format:this.format,internalFormat:this.internalFormat,type:this.type,colorSpace:this.colorSpace,minFilter:this.minFilter,magFilter:this.magFilter,anisotropy:this.anisotropy,flipY:this.flipY,generateMipmaps:this.generateMipmaps,premultiplyAlpha:this.premultiplyAlpha,unpackAlignment:this.unpackAlignment};return Object.keys(this.userData).length>0&&(r.userData=this.userData),n||(e.textures[this.uuid]=r),r}dispose(){this.dispatchEvent({type:\"dispose\"})}transformUv(e){if(this.mapping!==YZ)return e;if(e.applyMatrix3(this.matrix),e.x<0||e.x>1)switch(this.wrapS){case dR:e.x=e.x-Math.floor(e.x);break;case Uc:e.x=e.x<0?0:1;break;case uR:Math.abs(Math.floor(e.x)%2)===1?e.x=Math.ceil(e.x)-e.x:e.x=e.x-Math.floor(e.x);break}if(e.y<0||e.y>1)switch(this.wrapT){case dR:e.y=e.y-Math.floor(e.y);break;case Uc:e.y=e.y<0?0:1;break;case uR:Math.abs(Math.floor(e.y)%2)===1?e.y=Math.ceil(e.y)-e.y:e.y=e.y-Math.floor(e.y);break}return this.flipY&&(e.y=1-e.y),e}set needsUpdate(e){e===!0&&(this.version++,this.source.needsUpdate=!0)}get encoding(){return E0(\"THREE.Texture: Property .encoding has been replaced by .colorSpace.\"),this.colorSpace===bs?Yf:aJ}set encoding(e){E0(\"THREE.Texture: Property .encoding has been replaced by .colorSpace.\"),this.colorSpace=e===Yf?bs:Ql}}Bo.DEFAULT_IMAGE=null;Bo.DEFAULT_MAPPING=YZ;Bo.DEFAULT_ANISOTROPY=1;class ta{constructor(e=0,n=0,r=0,i=1){ta.prototype.isVector4=!0,this.x=e,this.y=n,this.z=r,this.w=i}get width(){return this.z}set width(e){this.z=e}get height(){return this.w}set height(e){this.w=e}set(e,n,r,i){return this.x=e,this.y=n,this.z=r,this.w=i,this}setScalar(e){return this.x=e,this.y=e,this.z=e,this.w=e,this}setX(e){return this.x=e,this}setY(e){return this.y=e,this}setZ(e){return this.z=e,this}setW(e){return this.w=e,this}setComponent(e,n){switch(e){case 0:this.x=n;break;case 1:this.y=n;break;case 2:this.z=n;break;case 3:this.w=n;break;default:throw new Error(\"index is out of range: \"+e)}return this}getComponent(e){switch(e){case 0:return this.x;case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw new Error(\"index is out of range: \"+e)}}clone(){return new this.constructor(this.x,this.y,this.z,this.w)}copy(e){return this.x=e.x,this.y=e.y,this.z=e.z,this.w=e.w!==void 0?e.w:1,this}add(e){return this.x+=e.x,this.y+=e.y,this.z+=e.z,this.w+=e.w,this}addScalar(e){return this.x+=e,this.y+=e,this.z+=e,this.w+=e,this}addVectors(e,n){return this.x=e.x+n.x,this.y=e.y+n.y,this.z=e.z+n.z,this.w=e.w+n.w,this}addScaledVector(e,n){return this.x+=e.x*n,this.y+=e.y*n,this.z+=e.z*n,this.w+=e.w*n,this}sub(e){return this.x-=e.x,this.y-=e.y,this.z-=e.z,this.w-=e.w,this}subScalar(e){return this.x-=e,this.y-=e,this.z-=e,this.w-=e,this}subVectors(e,n){return this.x=e.x-n.x,this.y=e.y-n.y,this.z=e.z-n.z,this.w=e.w-n.w,this}multiply(e){return this.x*=e.x,this.y*=e.y,this.z*=e.z,this.w*=e.w,this}multiplyScalar(e){return this.x*=e,this.y*=e,this.z*=e,this.w*=e,this}applyMatrix4(e){const n=this.x,r=this.y,i=this.z,s=this.w,o=e.elements;return this.x=o[0]*n+o[4]*r+o[8]*i+o[12]*s,this.y=o[1]*n+o[5]*r+o[9]*i+o[13]*s,this.z=o[2]*n+o[6]*r+o[10]*i+o[14]*s,this.w=o[3]*n+o[7]*r+o[11]*i+o[15]*s,this}divideScalar(e){return this.multiplyScalar(1/e)}setAxisAngleFromQuaternion(e){this.w=2*Math.acos(e.w);const n=Math.sqrt(1-e.w*e.w);return n<1e-4?(this.x=1,this.y=0,this.z=0):(this.x=e.x/n,this.y=e.y/n,this.z=e.z/n),this}setAxisAngleFromRotationMatrix(e){let n,r,i,s;const c=e.elements,d=c[0],u=c[4],m=c[8],p=c[1],f=c[5],y=c[9],v=c[2],b=c[6],g=c[10];if(Math.abs(u-p)<.01&&Math.abs(m-v)<.01&&Math.abs(y-b)<.01){if(Math.abs(u+p)<.1&&Math.abs(m+v)<.1&&Math.abs(y+b)<.1&&Math.abs(d+f+g-3)<.1)return this.set(1,0,0,0),this;n=Math.PI;const C=(d+1)/2,P=(f+1)/2,N=(g+1)/2,A=(u+p)/4,T=(m+v)/4,F=(y+b)/4;return C>P&&C>N?C<.01?(r=0,i=.707106781,s=.707106781):(r=Math.sqrt(C),i=A/r,s=T/r):P>N?P<.01?(r=.707106781,i=0,s=.707106781):(i=Math.sqrt(P),r=A/i,s=F/i):N<.01?(r=.707106781,i=.707106781,s=0):(s=Math.sqrt(N),r=T/s,i=F/s),this.set(r,i,s,n),this}let _=Math.sqrt((b-y)*(b-y)+(m-v)*(m-v)+(p-u)*(p-u));return Math.abs(_)<.001&&(_=1),this.x=(b-y)/_,this.y=(m-v)/_,this.z=(p-u)/_,this.w=Math.acos((d+f+g-1)/2),this}min(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this.z=Math.min(this.z,e.z),this.w=Math.min(this.w,e.w),this}max(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this.z=Math.max(this.z,e.z),this.w=Math.max(this.w,e.w),this}clamp(e,n){return this.x=Math.max(e.x,Math.min(n.x,this.x)),this.y=Math.max(e.y,Math.min(n.y,this.y)),this.z=Math.max(e.z,Math.min(n.z,this.z)),this.w=Math.max(e.w,Math.min(n.w,this.w)),this}clampScalar(e,n){return this.x=Math.max(e,Math.min(n,this.x)),this.y=Math.max(e,Math.min(n,this.y)),this.z=Math.max(e,Math.min(n,this.z)),this.w=Math.max(e,Math.min(n,this.w)),this}clampLength(e,n){const r=this.length();return this.divideScalar(r||1).multiplyScalar(Math.max(e,Math.min(n,r)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this.w=Math.floor(this.w),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this.w=Math.ceil(this.w),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this.w=Math.round(this.w),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this.z=Math.trunc(this.z),this.w=Math.trunc(this.w),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this.w=-this.w,this}dot(e){return this.x*e.x+this.y*e.y+this.z*e.z+this.w*e.w}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)+Math.abs(this.w)}normalize(){return this.divideScalar(this.length()||1)}setLength(e){return this.normalize().multiplyScalar(e)}lerp(e,n){return this.x+=(e.x-this.x)*n,this.y+=(e.y-this.y)*n,this.z+=(e.z-this.z)*n,this.w+=(e.w-this.w)*n,this}lerpVectors(e,n,r){return this.x=e.x+(n.x-e.x)*r,this.y=e.y+(n.y-e.y)*r,this.z=e.z+(n.z-e.z)*r,this.w=e.w+(n.w-e.w)*r,this}equals(e){return e.x===this.x&&e.y===this.y&&e.z===this.z&&e.w===this.w}fromArray(e,n=0){return this.x=e[n],this.y=e[n+1],this.z=e[n+2],this.w=e[n+3],this}toArray(e=[],n=0){return e[n]=this.x,e[n+1]=this.y,e[n+2]=this.z,e[n+3]=this.w,e}fromBufferAttribute(e,n){return this.x=e.getX(n),this.y=e.getY(n),this.z=e.getZ(n),this.w=e.getW(n),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this.w=Math.random(),this}*[Symbol.iterator](){yield this.x,yield this.y,yield this.z,yield this.w}}class eCe extends Bg{constructor(e=1,n=1,r={}){super(),this.isRenderTarget=!0,this.width=e,this.height=n,this.depth=1,this.scissor=new ta(0,0,e,n),this.scissorTest=!1,this.viewport=new ta(0,0,e,n);const i={width:e,height:n,depth:1};r.encoding!==void 0&&(E0(\"THREE.WebGLRenderTarget: option.encoding has been replaced by option.colorSpace.\"),r.colorSpace=r.encoding===Yf?bs:Ql),r=Object.assign({generateMipmaps:!1,internalFormat:null,minFilter:Xl,depthBuffer:!0,stencilBuffer:!1,depthTexture:null,samples:0},r),this.texture=new Bo(i,r.mapping,r.wrapS,r.wrapT,r.magFilter,r.minFilter,r.format,r.type,r.anisotropy,r.colorSpace),this.texture.isRenderTargetTexture=!0,this.texture.flipY=!1,this.texture.generateMipmaps=r.generateMipmaps,this.texture.internalFormat=r.internalFormat,this.depthBuffer=r.depthBuffer,this.stencilBuffer=r.stencilBuffer,this.depthTexture=r.depthTexture,this.samples=r.samples}setSize(e,n,r=1){(this.width!==e||this.height!==n||this.depth!==r)&&(this.width=e,this.height=n,this.depth=r,this.texture.image.width=e,this.texture.image.height=n,this.texture.image.depth=r,this.dispose()),this.viewport.set(0,0,e,n),this.scissor.set(0,0,e,n)}clone(){return new this.constructor().copy(this)}copy(e){this.width=e.width,this.height=e.height,this.depth=e.depth,this.scissor.copy(e.scissor),this.scissorTest=e.scissorTest,this.viewport.copy(e.viewport),this.texture=e.texture.clone(),this.texture.isRenderTargetTexture=!0;const n=Object.assign({},e.texture.image);return this.texture.source=new dJ(n),this.depthBuffer=e.depthBuffer,this.stencilBuffer=e.stencilBuffer,e.depthTexture!==null&&(this.depthTexture=e.depthTexture.clone()),this.samples=e.samples,this}dispose(){this.dispatchEvent({type:\"dispose\"})}}class xg extends eCe{constructor(e=1,n=1,r={}){super(e,n,r),this.isWebGLRenderTarget=!0}}class uJ extends Bo{constructor(e=null,n=1,r=1,i=1){super(null),this.isDataArrayTexture=!0,this.image={data:e,width:n,height:r,depth:i},this.magFilter=gs,this.minFilter=gs,this.wrapR=Uc,this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1}}class tCe extends Bo{constructor(e=null,n=1,r=1,i=1){super(null),this.isData3DTexture=!0,this.image={data:e,width:n,height:r,depth:i},this.magFilter=gs,this.minFilter=gs,this.wrapR=Uc,this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1}}class yg{constructor(e=0,n=0,r=0,i=1){this.isQuaternion=!0,this._x=e,this._y=n,this._z=r,this._w=i}static slerpFlat(e,n,r,i,s,o,l){let c=r[i+0],d=r[i+1],u=r[i+2],m=r[i+3];const p=s[o+0],f=s[o+1],y=s[o+2],v=s[o+3];if(l===0){e[n+0]=c,e[n+1]=d,e[n+2]=u,e[n+3]=m;return}if(l===1){e[n+0]=p,e[n+1]=f,e[n+2]=y,e[n+3]=v;return}if(m!==v||c!==p||d!==f||u!==y){let b=1-l;const g=c*p+d*f+u*y+m*v,_=g>=0?1:-1,C=1-g*g;if(C>Number.EPSILON){const N=Math.sqrt(C),A=Math.atan2(N,g*_);b=Math.sin(b*A)/N,l=Math.sin(l*A)/N}const P=l*_;if(c=c*b+p*P,d=d*b+f*P,u=u*b+y*P,m=m*b+v*P,b===1-l){const N=1/Math.sqrt(c*c+d*d+u*u+m*m);c*=N,d*=N,u*=N,m*=N}}e[n]=c,e[n+1]=d,e[n+2]=u,e[n+3]=m}static multiplyQuaternionsFlat(e,n,r,i,s,o){const l=r[i],c=r[i+1],d=r[i+2],u=r[i+3],m=s[o],p=s[o+1],f=s[o+2],y=s[o+3];return e[n]=l*y+u*m+c*f-d*p,e[n+1]=c*y+u*p+d*m-l*f,e[n+2]=d*y+u*f+l*p-c*m,e[n+3]=u*y-l*m-c*p-d*f,e}get x(){return this._x}set x(e){this._x=e,this._onChangeCallback()}get y(){return this._y}set y(e){this._y=e,this._onChangeCallback()}get z(){return this._z}set z(e){this._z=e,this._onChangeCallback()}get w(){return this._w}set w(e){this._w=e,this._onChangeCallback()}set(e,n,r,i){return this._x=e,this._y=n,this._z=r,this._w=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._w)}copy(e){return this._x=e.x,this._y=e.y,this._z=e.z,this._w=e.w,this._onChangeCallback(),this}setFromEuler(e,n){const r=e._x,i=e._y,s=e._z,o=e._order,l=Math.cos,c=Math.sin,d=l(r/2),u=l(i/2),m=l(s/2),p=c(r/2),f=c(i/2),y=c(s/2);switch(o){case\"XYZ\":this._x=p*u*m+d*f*y,this._y=d*f*m-p*u*y,this._z=d*u*y+p*f*m,this._w=d*u*m-p*f*y;break;case\"YXZ\":this._x=p*u*m+d*f*y,this._y=d*f*m-p*u*y,this._z=d*u*y-p*f*m,this._w=d*u*m+p*f*y;break;case\"ZXY\":this._x=p*u*m-d*f*y,this._y=d*f*m+p*u*y,this._z=d*u*y+p*f*m,this._w=d*u*m-p*f*y;break;case\"ZYX\":this._x=p*u*m-d*f*y,this._y=d*f*m+p*u*y,this._z=d*u*y-p*f*m,this._w=d*u*m+p*f*y;break;case\"YZX\":this._x=p*u*m+d*f*y,this._y=d*f*m+p*u*y,this._z=d*u*y-p*f*m,this._w=d*u*m-p*f*y;break;case\"XZY\":this._x=p*u*m-d*f*y,this._y=d*f*m-p*u*y,this._z=d*u*y+p*f*m,this._w=d*u*m+p*f*y;break;default:console.warn(\"THREE.Quaternion: .setFromEuler() encountered an unknown order: \"+o)}return n!==!1&&this._onChangeCallback(),this}setFromAxisAngle(e,n){const r=n/2,i=Math.sin(r);return this._x=e.x*i,this._y=e.y*i,this._z=e.z*i,this._w=Math.cos(r),this._onChangeCallback(),this}setFromRotationMatrix(e){const n=e.elements,r=n[0],i=n[4],s=n[8],o=n[1],l=n[5],c=n[9],d=n[2],u=n[6],m=n[10],p=r+l+m;if(p>0){const f=.5/Math.sqrt(p+1);this._w=.25/f,this._x=(u-c)*f,this._y=(s-d)*f,this._z=(o-i)*f}else if(r>l&&r>m){const f=2*Math.sqrt(1+r-l-m);this._w=(u-c)/f,this._x=.25*f,this._y=(i+o)/f,this._z=(s+d)/f}else if(l>m){const f=2*Math.sqrt(1+l-r-m);this._w=(s-d)/f,this._x=(i+o)/f,this._y=.25*f,this._z=(c+u)/f}else{const f=2*Math.sqrt(1+m-r-l);this._w=(o-i)/f,this._x=(s+d)/f,this._y=(c+u)/f,this._z=.25*f}return this._onChangeCallback(),this}setFromUnitVectors(e,n){let r=e.dot(n)+1;return r<Number.EPSILON?(r=0,Math.abs(e.x)>Math.abs(e.z)?(this._x=-e.y,this._y=e.x,this._z=0,this._w=r):(this._x=0,this._y=-e.z,this._z=e.y,this._w=r)):(this._x=e.y*n.z-e.z*n.y,this._y=e.z*n.x-e.x*n.z,this._z=e.x*n.y-e.y*n.x,this._w=r),this.normalize()}angleTo(e){return 2*Math.acos(Math.abs(xs(this.dot(e),-1,1)))}rotateTowards(e,n){const r=this.angleTo(e);if(r===0)return this;const i=Math.min(1,n/r);return this.slerp(e,i),this}identity(){return this.set(0,0,0,1)}invert(){return this.conjugate()}conjugate(){return this._x*=-1,this._y*=-1,this._z*=-1,this._onChangeCallback(),this}dot(e){return this._x*e._x+this._y*e._y+this._z*e._z+this._w*e._w}lengthSq(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w}length(){return Math.sqrt(this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w)}normalize(){let e=this.length();return e===0?(this._x=0,this._y=0,this._z=0,this._w=1):(e=1/e,this._x=this._x*e,this._y=this._y*e,this._z=this._z*e,this._w=this._w*e),this._onChangeCallback(),this}multiply(e){return this.multiplyQuaternions(this,e)}premultiply(e){return this.multiplyQuaternions(e,this)}multiplyQuaternions(e,n){const r=e._x,i=e._y,s=e._z,o=e._w,l=n._x,c=n._y,d=n._z,u=n._w;return this._x=r*u+o*l+i*d-s*c,this._y=i*u+o*c+s*l-r*d,this._z=s*u+o*d+r*c-i*l,this._w=o*u-r*l-i*c-s*d,this._onChangeCallback(),this}slerp(e,n){if(n===0)return this;if(n===1)return this.copy(e);const r=this._x,i=this._y,s=this._z,o=this._w;let l=o*e._w+r*e._x+i*e._y+s*e._z;if(l<0?(this._w=-e._w,this._x=-e._x,this._y=-e._y,this._z=-e._z,l=-l):this.copy(e),l>=1)return this._w=o,this._x=r,this._y=i,this._z=s,this;const c=1-l*l;if(c<=Number.EPSILON){const f=1-n;return this._w=f*o+n*this._w,this._x=f*r+n*this._x,this._y=f*i+n*this._y,this._z=f*s+n*this._z,this.normalize(),this._onChangeCallback(),this}const d=Math.sqrt(c),u=Math.atan2(d,l),m=Math.sin((1-n)*u)/d,p=Math.sin(n*u)/d;return this._w=o*m+this._w*p,this._x=r*m+this._x*p,this._y=i*m+this._y*p,this._z=s*m+this._z*p,this._onChangeCallback(),this}slerpQuaternions(e,n,r){return this.copy(e).slerp(n,r)}random(){const e=Math.random(),n=Math.sqrt(1-e),r=Math.sqrt(e),i=2*Math.PI*Math.random(),s=2*Math.PI*Math.random();return this.set(n*Math.cos(i),r*Math.sin(s),r*Math.cos(s),n*Math.sin(i))}equals(e){return e._x===this._x&&e._y===this._y&&e._z===this._z&&e._w===this._w}fromArray(e,n=0){return this._x=e[n],this._y=e[n+1],this._z=e[n+2],this._w=e[n+3],this._onChangeCallback(),this}toArray(e=[],n=0){return e[n]=this._x,e[n+1]=this._y,e[n+2]=this._z,e[n+3]=this._w,e}fromBufferAttribute(e,n){return this._x=e.getX(n),this._y=e.getY(n),this._z=e.getZ(n),this._w=e.getW(n),this}toJSON(){return this.toArray()}_onChange(e){return this._onChangeCallback=e,this}_onChangeCallback(){}*[Symbol.iterator](){yield this._x,yield this._y,yield this._z,yield this._w}}class ut{constructor(e=0,n=0,r=0){ut.prototype.isVector3=!0,this.x=e,this.y=n,this.z=r}set(e,n,r){return r===void 0&&(r=this.z),this.x=e,this.y=n,this.z=r,this}setScalar(e){return this.x=e,this.y=e,this.z=e,this}setX(e){return this.x=e,this}setY(e){return this.y=e,this}setZ(e){return this.z=e,this}setComponent(e,n){switch(e){case 0:this.x=n;break;case 1:this.y=n;break;case 2:this.z=n;break;default:throw new Error(\"index is out of range: \"+e)}return this}getComponent(e){switch(e){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw new Error(\"index is out of range: \"+e)}}clone(){return new this.constructor(this.x,this.y,this.z)}copy(e){return this.x=e.x,this.y=e.y,this.z=e.z,this}add(e){return this.x+=e.x,this.y+=e.y,this.z+=e.z,this}addScalar(e){return this.x+=e,this.y+=e,this.z+=e,this}addVectors(e,n){return this.x=e.x+n.x,this.y=e.y+n.y,this.z=e.z+n.z,this}addScaledVector(e,n){return this.x+=e.x*n,this.y+=e.y*n,this.z+=e.z*n,this}sub(e){return this.x-=e.x,this.y-=e.y,this.z-=e.z,this}subScalar(e){return this.x-=e,this.y-=e,this.z-=e,this}subVectors(e,n){return this.x=e.x-n.x,this.y=e.y-n.y,this.z=e.z-n.z,this}multiply(e){return this.x*=e.x,this.y*=e.y,this.z*=e.z,this}multiplyScalar(e){return this.x*=e,this.y*=e,this.z*=e,this}multiplyVectors(e,n){return this.x=e.x*n.x,this.y=e.y*n.y,this.z=e.z*n.z,this}applyEuler(e){return this.applyQuaternion(Lq.setFromEuler(e))}applyAxisAngle(e,n){return this.applyQuaternion(Lq.setFromAxisAngle(e,n))}applyMatrix3(e){const n=this.x,r=this.y,i=this.z,s=e.elements;return this.x=s[0]*n+s[3]*r+s[6]*i,this.y=s[1]*n+s[4]*r+s[7]*i,this.z=s[2]*n+s[5]*r+s[8]*i,this}applyNormalMatrix(e){return this.applyMatrix3(e).normalize()}applyMatrix4(e){const n=this.x,r=this.y,i=this.z,s=e.elements,o=1/(s[3]*n+s[7]*r+s[11]*i+s[15]);return this.x=(s[0]*n+s[4]*r+s[8]*i+s[12])*o,this.y=(s[1]*n+s[5]*r+s[9]*i+s[13])*o,this.z=(s[2]*n+s[6]*r+s[10]*i+s[14])*o,this}applyQuaternion(e){const n=this.x,r=this.y,i=this.z,s=e.x,o=e.y,l=e.z,c=e.w,d=2*(o*i-l*r),u=2*(l*n-s*i),m=2*(s*r-o*n);return this.x=n+c*d+o*m-l*u,this.y=r+c*u+l*d-s*m,this.z=i+c*m+s*u-o*d,this}project(e){return this.applyMatrix4(e.matrixWorldInverse).applyMatrix4(e.projectionMatrix)}unproject(e){return this.applyMatrix4(e.projectionMatrixInverse).applyMatrix4(e.matrixWorld)}transformDirection(e){const n=this.x,r=this.y,i=this.z,s=e.elements;return this.x=s[0]*n+s[4]*r+s[8]*i,this.y=s[1]*n+s[5]*r+s[9]*i,this.z=s[2]*n+s[6]*r+s[10]*i,this.normalize()}divide(e){return this.x/=e.x,this.y/=e.y,this.z/=e.z,this}divideScalar(e){return this.multiplyScalar(1/e)}min(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this.z=Math.min(this.z,e.z),this}max(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this.z=Math.max(this.z,e.z),this}clamp(e,n){return this.x=Math.max(e.x,Math.min(n.x,this.x)),this.y=Math.max(e.y,Math.min(n.y,this.y)),this.z=Math.max(e.z,Math.min(n.z,this.z)),this}clampScalar(e,n){return this.x=Math.max(e,Math.min(n,this.x)),this.y=Math.max(e,Math.min(n,this.y)),this.z=Math.max(e,Math.min(n,this.z)),this}clampLength(e,n){const r=this.length();return this.divideScalar(r||1).multiplyScalar(Math.max(e,Math.min(n,r)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this.z=Math.trunc(this.z),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this}dot(e){return this.x*e.x+this.y*e.y+this.z*e.z}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)}normalize(){return this.divideScalar(this.length()||1)}setLength(e){return this.normalize().multiplyScalar(e)}lerp(e,n){return this.x+=(e.x-this.x)*n,this.y+=(e.y-this.y)*n,this.z+=(e.z-this.z)*n,this}lerpVectors(e,n,r){return this.x=e.x+(n.x-e.x)*r,this.y=e.y+(n.y-e.y)*r,this.z=e.z+(n.z-e.z)*r,this}cross(e){return this.crossVectors(this,e)}crossVectors(e,n){const r=e.x,i=e.y,s=e.z,o=n.x,l=n.y,c=n.z;return this.x=i*c-s*l,this.y=s*o-r*c,this.z=r*l-i*o,this}projectOnVector(e){const n=e.lengthSq();if(n===0)return this.set(0,0,0);const r=e.dot(this)/n;return this.copy(e).multiplyScalar(r)}projectOnPlane(e){return vE.copy(this).projectOnVector(e),this.sub(vE)}reflect(e){return this.sub(vE.copy(e).multiplyScalar(2*this.dot(e)))}angleTo(e){const n=Math.sqrt(this.lengthSq()*e.lengthSq());if(n===0)return Math.PI/2;const r=this.dot(e)/n;return Math.acos(xs(r,-1,1))}distanceTo(e){return Math.sqrt(this.distanceToSquared(e))}distanceToSquared(e){const n=this.x-e.x,r=this.y-e.y,i=this.z-e.z;return n*n+r*r+i*i}manhattanDistanceTo(e){return Math.abs(this.x-e.x)+Math.abs(this.y-e.y)+Math.abs(this.z-e.z)}setFromSpherical(e){return this.setFromSphericalCoords(e.radius,e.phi,e.theta)}setFromSphericalCoords(e,n,r){const i=Math.sin(n)*e;return this.x=i*Math.sin(r),this.y=Math.cos(n)*e,this.z=i*Math.cos(r),this}setFromCylindrical(e){return this.setFromCylindricalCoords(e.radius,e.theta,e.y)}setFromCylindricalCoords(e,n,r){return this.x=e*Math.sin(n),this.y=r,this.z=e*Math.cos(n),this}setFromMatrixPosition(e){const n=e.elements;return this.x=n[12],this.y=n[13],this.z=n[14],this}setFromMatrixScale(e){const n=this.setFromMatrixColumn(e,0).length(),r=this.setFromMatrixColumn(e,1).length(),i=this.setFromMatrixColumn(e,2).length();return this.x=n,this.y=r,this.z=i,this}setFromMatrixColumn(e,n){return this.fromArray(e.elements,n*4)}setFromMatrix3Column(e,n){return this.fromArray(e.elements,n*3)}setFromEuler(e){return this.x=e._x,this.y=e._y,this.z=e._z,this}setFromColor(e){return this.x=e.r,this.y=e.g,this.z=e.b,this}equals(e){return e.x===this.x&&e.y===this.y&&e.z===this.z}fromArray(e,n=0){return this.x=e[n],this.y=e[n+1],this.z=e[n+2],this}toArray(e=[],n=0){return e[n]=this.x,e[n+1]=this.y,e[n+2]=this.z,e}fromBufferAttribute(e,n){return this.x=e.getX(n),this.y=e.getY(n),this.z=e.getZ(n),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this}randomDirection(){const e=(Math.random()-.5)*2,n=Math.random()*Math.PI*2,r=Math.sqrt(1-e**2);return this.x=r*Math.cos(n),this.y=r*Math.sin(n),this.z=e,this}*[Symbol.iterator](){yield this.x,yield this.y,yield this.z}}const vE=new ut,Lq=new yg;class ac{constructor(e=new ut(1/0,1/0,1/0),n=new ut(-1/0,-1/0,-1/0)){this.isBox3=!0,this.min=e,this.max=n}set(e,n){return this.min.copy(e),this.max.copy(n),this}setFromArray(e){this.makeEmpty();for(let n=0,r=e.length;n<r;n+=3)this.expandByPoint(Fc.fromArray(e,n));return this}setFromBufferAttribute(e){this.makeEmpty();for(let n=0,r=e.count;n<r;n++)this.expandByPoint(Fc.fromBufferAttribute(e,n));return this}setFromPoints(e){this.makeEmpty();for(let n=0,r=e.length;n<r;n++)this.expandByPoint(e[n]);return this}setFromCenterAndSize(e,n){const r=Fc.copy(n).multiplyScalar(.5);return this.min.copy(e).sub(r),this.max.copy(e).add(r),this}setFromObject(e,n=!1){return this.makeEmpty(),this.expandByObject(e,n)}clone(){return new this.constructor().copy(this)}copy(e){return this.min.copy(e.min),this.max.copy(e.max),this}makeEmpty(){return this.min.x=this.min.y=this.min.z=1/0,this.max.x=this.max.y=this.max.z=-1/0,this}isEmpty(){return this.max.x<this.min.x||this.max.y<this.min.y||this.max.z<this.min.z}getCenter(e){return this.isEmpty()?e.set(0,0,0):e.addVectors(this.min,this.max).multiplyScalar(.5)}getSize(e){return this.isEmpty()?e.set(0,0,0):e.subVectors(this.max,this.min)}expandByPoint(e){return this.min.min(e),this.max.max(e),this}expandByVector(e){return this.min.sub(e),this.max.add(e),this}expandByScalar(e){return this.min.addScalar(-e),this.max.addScalar(e),this}expandByObject(e,n=!1){e.updateWorldMatrix(!1,!1);const r=e.geometry;if(r!==void 0){const s=r.getAttribute(\"position\");if(n===!0&&s!==void 0&&e.isInstancedMesh!==!0)for(let o=0,l=s.count;o<l;o++)e.isMesh===!0?e.getVertexPosition(o,Fc):Fc.fromBufferAttribute(s,o),Fc.applyMatrix4(e.matrixWorld),this.expandByPoint(Fc);else e.boundingBox!==void 0?(e.boundingBox===null&&e.computeBoundingBox(),_1.copy(e.boundingBox)):(r.boundingBox===null&&r.computeBoundingBox(),_1.copy(r.boundingBox)),_1.applyMatrix4(e.matrixWorld),this.union(_1)}const i=e.children;for(let s=0,o=i.length;s<o;s++)this.expandByObject(i[s],n);return this}containsPoint(e){return!(e.x<this.min.x||e.x>this.max.x||e.y<this.min.y||e.y>this.max.y||e.z<this.min.z||e.z>this.max.z)}containsBox(e){return this.min.x<=e.min.x&&e.max.x<=this.max.x&&this.min.y<=e.min.y&&e.max.y<=this.max.y&&this.min.z<=e.min.z&&e.max.z<=this.max.z}getParameter(e,n){return n.set((e.x-this.min.x)/(this.max.x-this.min.x),(e.y-this.min.y)/(this.max.y-this.min.y),(e.z-this.min.z)/(this.max.z-this.min.z))}intersectsBox(e){return!(e.max.x<this.min.x||e.min.x>this.max.x||e.max.y<this.min.y||e.min.y>this.max.y||e.max.z<this.min.z||e.min.z>this.max.z)}intersectsSphere(e){return this.clampPoint(e.center,Fc),Fc.distanceToSquared(e.center)<=e.radius*e.radius}intersectsPlane(e){let n,r;return e.normal.x>0?(n=e.normal.x*this.min.x,r=e.normal.x*this.max.x):(n=e.normal.x*this.max.x,r=e.normal.x*this.min.x),e.normal.y>0?(n+=e.normal.y*this.min.y,r+=e.normal.y*this.max.y):(n+=e.normal.y*this.max.y,r+=e.normal.y*this.min.y),e.normal.z>0?(n+=e.normal.z*this.min.z,r+=e.normal.z*this.max.z):(n+=e.normal.z*this.max.z,r+=e.normal.z*this.min.z),n<=-e.constant&&r>=-e.constant}intersectsTriangle(e){if(this.isEmpty())return!1;this.getCenter(Uv),k1.subVectors(this.max,Uv),Bb.subVectors(e.a,Uv),Hb.subVectors(e.b,Uv),qb.subVectors(e.c,Uv),fh.subVectors(Hb,Bb),gh.subVectors(qb,Hb),pf.subVectors(Bb,qb);let n=[0,-fh.z,fh.y,0,-gh.z,gh.y,0,-pf.z,pf.y,fh.z,0,-fh.x,gh.z,0,-gh.x,pf.z,0,-pf.x,-fh.y,fh.x,0,-gh.y,gh.x,0,-pf.y,pf.x,0];return!wE(n,Bb,Hb,qb,k1)||(n=[1,0,0,0,1,0,0,0,1],!wE(n,Bb,Hb,qb,k1))?!1:(N1.crossVectors(fh,gh),n=[N1.x,N1.y,N1.z],wE(n,Bb,Hb,qb,k1))}clampPoint(e,n){return n.copy(e).clamp(this.min,this.max)}distanceToPoint(e){return this.clampPoint(e,Fc).distanceTo(e)}getBoundingSphere(e){return this.isEmpty()?e.makeEmpty():(this.getCenter(e.center),e.radius=this.getSize(Fc).length()*.5),e}intersect(e){return this.min.max(e.min),this.max.min(e.max),this.isEmpty()&&this.makeEmpty(),this}union(e){return this.min.min(e.min),this.max.max(e.max),this}applyMatrix4(e){return this.isEmpty()?this:(ju[0].set(this.min.x,this.min.y,this.min.z).applyMatrix4(e),ju[1].set(this.min.x,this.min.y,this.max.z).applyMatrix4(e),ju[2].set(this.min.x,this.max.y,this.min.z).applyMatrix4(e),ju[3].set(this.min.x,this.max.y,this.max.z).applyMatrix4(e),ju[4].set(this.max.x,this.min.y,this.min.z).applyMatrix4(e),ju[5].set(this.max.x,this.min.y,this.max.z).applyMatrix4(e),ju[6].set(this.max.x,this.max.y,this.min.z).applyMatrix4(e),ju[7].set(this.max.x,this.max.y,this.max.z).applyMatrix4(e),this.setFromPoints(ju),this)}translate(e){return this.min.add(e),this.max.add(e),this}equals(e){return e.min.equals(this.min)&&e.max.equals(this.max)}}const ju=[new ut,new ut,new ut,new ut,new ut,new ut,new ut,new ut],Fc=new ut,_1=new ac,Bb=new ut,Hb=new ut,qb=new ut,fh=new ut,gh=new ut,pf=new ut,Uv=new ut,k1=new ut,N1=new ut,ff=new ut;function wE(t,e,n,r,i){for(let s=0,o=t.length-3;s<=o;s+=3){ff.fromArray(t,s);const l=i.x*Math.abs(ff.x)+i.y*Math.abs(ff.y)+i.z*Math.abs(ff.z),c=e.dot(ff),d=n.dot(ff),u=r.dot(ff);if(Math.max(-Math.max(c,d,u),Math.min(c,d,u))>l)return!1}return!0}const nCe=new ac,Bv=new ut,SE=new ut;class Ld{constructor(e=new ut,n=-1){this.center=e,this.radius=n}set(e,n){return this.center.copy(e),this.radius=n,this}setFromPoints(e,n){const r=this.center;n!==void 0?r.copy(n):nCe.setFromPoints(e).getCenter(r);let i=0;for(let s=0,o=e.length;s<o;s++)i=Math.max(i,r.distanceToSquared(e[s]));return this.radius=Math.sqrt(i),this}copy(e){return this.center.copy(e.center),this.radius=e.radius,this}isEmpty(){return this.radius<0}makeEmpty(){return this.center.set(0,0,0),this.radius=-1,this}containsPoint(e){return e.distanceToSquared(this.center)<=this.radius*this.radius}distanceToPoint(e){return e.distanceTo(this.center)-this.radius}intersectsSphere(e){const n=this.radius+e.radius;return e.center.distanceToSquared(this.center)<=n*n}intersectsBox(e){return e.intersectsSphere(this)}intersectsPlane(e){return Math.abs(e.distanceToPoint(this.center))<=this.radius}clampPoint(e,n){const r=this.center.distanceToSquared(e);return n.copy(e),r>this.radius*this.radius&&(n.sub(this.center).normalize(),n.multiplyScalar(this.radius).add(this.center)),n}getBoundingBox(e){return this.isEmpty()?(e.makeEmpty(),e):(e.set(this.center,this.center),e.expandByScalar(this.radius),e)}applyMatrix4(e){return this.center.applyMatrix4(e),this.radius=this.radius*e.getMaxScaleOnAxis(),this}translate(e){return this.center.add(e),this}expandByPoint(e){if(this.isEmpty())return this.center.copy(e),this.radius=0,this;Bv.subVectors(e,this.center);const n=Bv.lengthSq();if(n>this.radius*this.radius){const r=Math.sqrt(n),i=(r-this.radius)*.5;this.center.addScaledVector(Bv,i/r),this.radius+=i}return this}union(e){return e.isEmpty()?this:this.isEmpty()?(this.copy(e),this):(this.center.equals(e.center)===!0?this.radius=Math.max(this.radius,e.radius):(SE.subVectors(e.center,this.center).setLength(e.radius),this.expandByPoint(Bv.copy(e.center).add(SE)),this.expandByPoint(Bv.copy(e.center).sub(SE))),this)}equals(e){return e.center.equals(this.center)&&e.radius===this.radius}clone(){return new this.constructor().copy(this)}}const Mu=new ut,_E=new ut,C1=new ut,bh=new ut,kE=new ut,P1=new ut,NE=new ut;class uI{constructor(e=new ut,n=new ut(0,0,-1)){this.origin=e,this.direction=n}set(e,n){return this.origin.copy(e),this.direction.copy(n),this}copy(e){return this.origin.copy(e.origin),this.direction.copy(e.direction),this}at(e,n){return n.copy(this.origin).addScaledVector(this.direction,e)}lookAt(e){return this.direction.copy(e).sub(this.origin).normalize(),this}recast(e){return this.origin.copy(this.at(e,Mu)),this}closestPointToPoint(e,n){n.subVectors(e,this.origin);const r=n.dot(this.direction);return r<0?n.copy(this.origin):n.copy(this.origin).addScaledVector(this.direction,r)}distanceToPoint(e){return Math.sqrt(this.distanceSqToPoint(e))}distanceSqToPoint(e){const n=Mu.subVectors(e,this.origin).dot(this.direction);return n<0?this.origin.distanceToSquared(e):(Mu.copy(this.origin).addScaledVector(this.direction,n),Mu.distanceToSquared(e))}distanceSqToSegment(e,n,r,i){_E.copy(e).add(n).multiplyScalar(.5),C1.copy(n).sub(e).normalize(),bh.copy(this.origin).sub(_E);const s=e.distanceTo(n)*.5,o=-this.direction.dot(C1),l=bh.dot(this.direction),c=-bh.dot(C1),d=bh.lengthSq(),u=Math.abs(1-o*o);let m,p,f,y;if(u>0)if(m=o*c-l,p=o*l-c,y=s*u,m>=0)if(p>=-y)if(p<=y){const v=1/u;m*=v,p*=v,f=m*(m+o*p+2*l)+p*(o*m+p+2*c)+d}else p=s,m=Math.max(0,-(o*p+l)),f=-m*m+p*(p+2*c)+d;else p=-s,m=Math.max(0,-(o*p+l)),f=-m*m+p*(p+2*c)+d;else p<=-y?(m=Math.max(0,-(-o*s+l)),p=m>0?-s:Math.min(Math.max(-s,-c),s),f=-m*m+p*(p+2*c)+d):p<=y?(m=0,p=Math.min(Math.max(-s,-c),s),f=p*(p+2*c)+d):(m=Math.max(0,-(o*s+l)),p=m>0?s:Math.min(Math.max(-s,-c),s),f=-m*m+p*(p+2*c)+d);else p=o>0?-s:s,m=Math.max(0,-(o*p+l)),f=-m*m+p*(p+2*c)+d;return r&&r.copy(this.origin).addScaledVector(this.direction,m),i&&i.copy(_E).addScaledVector(C1,p),f}intersectSphere(e,n){Mu.subVectors(e.center,this.origin);const r=Mu.dot(this.direction),i=Mu.dot(Mu)-r*r,s=e.radius*e.radius;if(i>s)return null;const o=Math.sqrt(s-i),l=r-o,c=r+o;return c<0?null:l<0?this.at(c,n):this.at(l,n)}intersectsSphere(e){return this.distanceSqToPoint(e.center)<=e.radius*e.radius}distanceToPlane(e){const n=e.normal.dot(this.direction);if(n===0)return e.distanceToPoint(this.origin)===0?0:null;const r=-(this.origin.dot(e.normal)+e.constant)/n;return r>=0?r:null}intersectPlane(e,n){const r=this.distanceToPlane(e);return r===null?null:this.at(r,n)}intersectsPlane(e){const n=e.distanceToPoint(this.origin);return n===0||e.normal.dot(this.direction)*n<0}intersectBox(e,n){let r,i,s,o,l,c;const d=1/this.direction.x,u=1/this.direction.y,m=1/this.direction.z,p=this.origin;return d>=0?(r=(e.min.x-p.x)*d,i=(e.max.x-p.x)*d):(r=(e.max.x-p.x)*d,i=(e.min.x-p.x)*d),u>=0?(s=(e.min.y-p.y)*u,o=(e.max.y-p.y)*u):(s=(e.max.y-p.y)*u,o=(e.min.y-p.y)*u),r>o||s>i||((s>r||isNaN(r))&&(r=s),(o<i||isNaN(i))&&(i=o),m>=0?(l=(e.min.z-p.z)*m,c=(e.max.z-p.z)*m):(l=(e.max.z-p.z)*m,c=(e.min.z-p.z)*m),r>c||l>i)||((l>r||r!==r)&&(r=l),(c<i||i!==i)&&(i=c),i<0)?null:this.at(r>=0?r:i,n)}intersectsBox(e){return this.intersectBox(e,Mu)!==null}intersectTriangle(e,n,r,i,s){kE.subVectors(n,e),P1.subVectors(r,e),NE.crossVectors(kE,P1);let o=this.direction.dot(NE),l;if(o>0){if(i)return null;l=1}else if(o<0)l=-1,o=-o;else return null;bh.subVectors(this.origin,e);const c=l*this.direction.dot(P1.crossVectors(bh,P1));if(c<0)return null;const d=l*this.direction.dot(kE.cross(bh));if(d<0||c+d>o)return null;const u=-l*bh.dot(NE);return u<0?null:this.at(u/o,s)}applyMatrix4(e){return this.origin.applyMatrix4(e),this.direction.transformDirection(e),this}equals(e){return e.origin.equals(this.origin)&&e.direction.equals(this.direction)}clone(){return new this.constructor().copy(this)}}class ba{constructor(e,n,r,i,s,o,l,c,d,u,m,p,f,y,v,b){ba.prototype.isMatrix4=!0,this.elements=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],e!==void 0&&this.set(e,n,r,i,s,o,l,c,d,u,m,p,f,y,v,b)}set(e,n,r,i,s,o,l,c,d,u,m,p,f,y,v,b){const g=this.elements;return g[0]=e,g[4]=n,g[8]=r,g[12]=i,g[1]=s,g[5]=o,g[9]=l,g[13]=c,g[2]=d,g[6]=u,g[10]=m,g[14]=p,g[3]=f,g[7]=y,g[11]=v,g[15]=b,this}identity(){return this.set(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1),this}clone(){return new ba().fromArray(this.elements)}copy(e){const n=this.elements,r=e.elements;return n[0]=r[0],n[1]=r[1],n[2]=r[2],n[3]=r[3],n[4]=r[4],n[5]=r[5],n[6]=r[6],n[7]=r[7],n[8]=r[8],n[9]=r[9],n[10]=r[10],n[11]=r[11],n[12]=r[12],n[13]=r[13],n[14]=r[14],n[15]=r[15],this}copyPosition(e){const n=this.elements,r=e.elements;return n[12]=r[12],n[13]=r[13],n[14]=r[14],this}setFromMatrix3(e){const n=e.elements;return this.set(n[0],n[3],n[6],0,n[1],n[4],n[7],0,n[2],n[5],n[8],0,0,0,0,1),this}extractBasis(e,n,r){return e.setFromMatrixColumn(this,0),n.setFromMatrixColumn(this,1),r.setFromMatrixColumn(this,2),this}makeBasis(e,n,r){return this.set(e.x,n.x,r.x,0,e.y,n.y,r.y,0,e.z,n.z,r.z,0,0,0,0,1),this}extractRotation(e){const n=this.elements,r=e.elements,i=1/$b.setFromMatrixColumn(e,0).length(),s=1/$b.setFromMatrixColumn(e,1).length(),o=1/$b.setFromMatrixColumn(e,2).length();return n[0]=r[0]*i,n[1]=r[1]*i,n[2]=r[2]*i,n[3]=0,n[4]=r[4]*s,n[5]=r[5]*s,n[6]=r[6]*s,n[7]=0,n[8]=r[8]*o,n[9]=r[9]*o,n[10]=r[10]*o,n[11]=0,n[12]=0,n[13]=0,n[14]=0,n[15]=1,this}makeRotationFromEuler(e){const n=this.elements,r=e.x,i=e.y,s=e.z,o=Math.cos(r),l=Math.sin(r),c=Math.cos(i),d=Math.sin(i),u=Math.cos(s),m=Math.sin(s);if(e.order===\"XYZ\"){const p=o*u,f=o*m,y=l*u,v=l*m;n[0]=c*u,n[4]=-c*m,n[8]=d,n[1]=f+y*d,n[5]=p-v*d,n[9]=-l*c,n[2]=v-p*d,n[6]=y+f*d,n[10]=o*c}else if(e.order===\"YXZ\"){const p=c*u,f=c*m,y=d*u,v=d*m;n[0]=p+v*l,n[4]=y*l-f,n[8]=o*d,n[1]=o*m,n[5]=o*u,n[9]=-l,n[2]=f*l-y,n[6]=v+p*l,n[10]=o*c}else if(e.order===\"ZXY\"){const p=c*u,f=c*m,y=d*u,v=d*m;n[0]=p-v*l,n[4]=-o*m,n[8]=y+f*l,n[1]=f+y*l,n[5]=o*u,n[9]=v-p*l,n[2]=-o*d,n[6]=l,n[10]=o*c}else if(e.order===\"ZYX\"){const p=o*u,f=o*m,y=l*u,v=l*m;n[0]=c*u,n[4]=y*d-f,n[8]=p*d+v,n[1]=c*m,n[5]=v*d+p,n[9]=f*d-y,n[2]=-d,n[6]=l*c,n[10]=o*c}else if(e.order===\"YZX\"){const p=o*c,f=o*d,y=l*c,v=l*d;n[0]=c*u,n[4]=v-p*m,n[8]=y*m+f,n[1]=m,n[5]=o*u,n[9]=-l*u,n[2]=-d*u,n[6]=f*m+y,n[10]=p-v*m}else if(e.order===\"XZY\"){const p=o*c,f=o*d,y=l*c,v=l*d;n[0]=c*u,n[4]=-m,n[8]=d*u,n[1]=p*m+v,n[5]=o*u,n[9]=f*m-y,n[2]=y*m-f,n[6]=l*u,n[10]=v*m+p}return n[3]=0,n[7]=0,n[11]=0,n[12]=0,n[13]=0,n[14]=0,n[15]=1,this}makeRotationFromQuaternion(e){return this.compose(rCe,e,aCe)}lookAt(e,n,r){const i=this.elements;return il.subVectors(e,n),il.lengthSq()===0&&(il.z=1),il.normalize(),xh.crossVectors(r,il),xh.lengthSq()===0&&(Math.abs(r.z)===1?il.x+=1e-4:il.z+=1e-4,il.normalize(),xh.crossVectors(r,il)),xh.normalize(),T1.crossVectors(il,xh),i[0]=xh.x,i[4]=T1.x,i[8]=il.x,i[1]=xh.y,i[5]=T1.y,i[9]=il.y,i[2]=xh.z,i[6]=T1.z,i[10]=il.z,this}multiply(e){return this.multiplyMatrices(this,e)}premultiply(e){return this.multiplyMatrices(e,this)}multiplyMatrices(e,n){const r=e.elements,i=n.elements,s=this.elements,o=r[0],l=r[4],c=r[8],d=r[12],u=r[1],m=r[5],p=r[9],f=r[13],y=r[2],v=r[6],b=r[10],g=r[14],_=r[3],C=r[7],P=r[11],N=r[15],A=i[0],T=i[4],F=i[8],k=i[12],D=i[1],H=i[5],z=i[9],Q=i[13],L=i[2],te=i[6],ie=i[10],J=i[14],oe=i[3],fe=i[7],re=i[11],W=i[15];return s[0]=o*A+l*D+c*L+d*oe,s[4]=o*T+l*H+c*te+d*fe,s[8]=o*F+l*z+c*ie+d*re,s[12]=o*k+l*Q+c*J+d*W,s[1]=u*A+m*D+p*L+f*oe,s[5]=u*T+m*H+p*te+f*fe,s[9]=u*F+m*z+p*ie+f*re,s[13]=u*k+m*Q+p*J+f*W,s[2]=y*A+v*D+b*L+g*oe,s[6]=y*T+v*H+b*te+g*fe,s[10]=y*F+v*z+b*ie+g*re,s[14]=y*k+v*Q+b*J+g*W,s[3]=_*A+C*D+P*L+N*oe,s[7]=_*T+C*H+P*te+N*fe,s[11]=_*F+C*z+P*ie+N*re,s[15]=_*k+C*Q+P*J+N*W,this}multiplyScalar(e){const n=this.elements;return n[0]*=e,n[4]*=e,n[8]*=e,n[12]*=e,n[1]*=e,n[5]*=e,n[9]*=e,n[13]*=e,n[2]*=e,n[6]*=e,n[10]*=e,n[14]*=e,n[3]*=e,n[7]*=e,n[11]*=e,n[15]*=e,this}determinant(){const e=this.elements,n=e[0],r=e[4],i=e[8],s=e[12],o=e[1],l=e[5],c=e[9],d=e[13],u=e[2],m=e[6],p=e[10],f=e[14],y=e[3],v=e[7],b=e[11],g=e[15];return y*(+s*c*m-i*d*m-s*l*p+r*d*p+i*l*f-r*c*f)+v*(+n*c*f-n*d*p+s*o*p-i*o*f+i*d*u-s*c*u)+b*(+n*d*m-n*l*f-s*o*m+r*o*f+s*l*u-r*d*u)+g*(-i*l*u-n*c*m+n*l*p+i*o*m-r*o*p+r*c*u)}transpose(){const e=this.elements;let n;return n=e[1],e[1]=e[4],e[4]=n,n=e[2],e[2]=e[8],e[8]=n,n=e[6],e[6]=e[9],e[9]=n,n=e[3],e[3]=e[12],e[12]=n,n=e[7],e[7]=e[13],e[13]=n,n=e[11],e[11]=e[14],e[14]=n,this}setPosition(e,n,r){const i=this.elements;return e.isVector3?(i[12]=e.x,i[13]=e.y,i[14]=e.z):(i[12]=e,i[13]=n,i[14]=r),this}invert(){const e=this.elements,n=e[0],r=e[1],i=e[2],s=e[3],o=e[4],l=e[5],c=e[6],d=e[7],u=e[8],m=e[9],p=e[10],f=e[11],y=e[12],v=e[13],b=e[14],g=e[15],_=m*b*d-v*p*d+v*c*f-l*b*f-m*c*g+l*p*g,C=y*p*d-u*b*d-y*c*f+o*b*f+u*c*g-o*p*g,P=u*v*d-y*m*d+y*l*f-o*v*f-u*l*g+o*m*g,N=y*m*c-u*v*c-y*l*p+o*v*p+u*l*b-o*m*b,A=n*_+r*C+i*P+s*N;if(A===0)return this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);const T=1/A;return e[0]=_*T,e[1]=(v*p*s-m*b*s-v*i*f+r*b*f+m*i*g-r*p*g)*T,e[2]=(l*b*s-v*c*s+v*i*d-r*b*d-l*i*g+r*c*g)*T,e[3]=(m*c*s-l*p*s-m*i*d+r*p*d+l*i*f-r*c*f)*T,e[4]=C*T,e[5]=(u*b*s-y*p*s+y*i*f-n*b*f-u*i*g+n*p*g)*T,e[6]=(y*c*s-o*b*s-y*i*d+n*b*d+o*i*g-n*c*g)*T,e[7]=(o*p*s-u*c*s+u*i*d-n*p*d-o*i*f+n*c*f)*T,e[8]=P*T,e[9]=(y*m*s-u*v*s-y*r*f+n*v*f+u*r*g-n*m*g)*T,e[10]=(o*v*s-y*l*s+y*r*d-n*v*d-o*r*g+n*l*g)*T,e[11]=(u*l*s-o*m*s-u*r*d+n*m*d+o*r*f-n*l*f)*T,e[12]=N*T,e[13]=(u*v*i-y*m*i+y*r*p-n*v*p-u*r*b+n*m*b)*T,e[14]=(y*l*i-o*v*i-y*r*c+n*v*c+o*r*b-n*l*b)*T,e[15]=(o*m*i-u*l*i+u*r*c-n*m*c-o*r*p+n*l*p)*T,this}scale(e){const n=this.elements,r=e.x,i=e.y,s=e.z;return n[0]*=r,n[4]*=i,n[8]*=s,n[1]*=r,n[5]*=i,n[9]*=s,n[2]*=r,n[6]*=i,n[10]*=s,n[3]*=r,n[7]*=i,n[11]*=s,this}getMaxScaleOnAxis(){const e=this.elements,n=e[0]*e[0]+e[1]*e[1]+e[2]*e[2],r=e[4]*e[4]+e[5]*e[5]+e[6]*e[6],i=e[8]*e[8]+e[9]*e[9]+e[10]*e[10];return Math.sqrt(Math.max(n,r,i))}makeTranslation(e,n,r){return e.isVector3?this.set(1,0,0,e.x,0,1,0,e.y,0,0,1,e.z,0,0,0,1):this.set(1,0,0,e,0,1,0,n,0,0,1,r,0,0,0,1),this}makeRotationX(e){const n=Math.cos(e),r=Math.sin(e);return this.set(1,0,0,0,0,n,-r,0,0,r,n,0,0,0,0,1),this}makeRotationY(e){const n=Math.cos(e),r=Math.sin(e);return this.set(n,0,r,0,0,1,0,0,-r,0,n,0,0,0,0,1),this}makeRotationZ(e){const n=Math.cos(e),r=Math.sin(e);return this.set(n,-r,0,0,r,n,0,0,0,0,1,0,0,0,0,1),this}makeRotationAxis(e,n){const r=Math.cos(n),i=Math.sin(n),s=1-r,o=e.x,l=e.y,c=e.z,d=s*o,u=s*l;return this.set(d*o+r,d*l-i*c,d*c+i*l,0,d*l+i*c,u*l+r,u*c-i*o,0,d*c-i*l,u*c+i*o,s*c*c+r,0,0,0,0,1),this}makeScale(e,n,r){return this.set(e,0,0,0,0,n,0,0,0,0,r,0,0,0,0,1),this}makeShear(e,n,r,i,s,o){return this.set(1,r,s,0,e,1,o,0,n,i,1,0,0,0,0,1),this}compose(e,n,r){const i=this.elements,s=n._x,o=n._y,l=n._z,c=n._w,d=s+s,u=o+o,m=l+l,p=s*d,f=s*u,y=s*m,v=o*u,b=o*m,g=l*m,_=c*d,C=c*u,P=c*m,N=r.x,A=r.y,T=r.z;return i[0]=(1-(v+g))*N,i[1]=(f+P)*N,i[2]=(y-C)*N,i[3]=0,i[4]=(f-P)*A,i[5]=(1-(p+g))*A,i[6]=(b+_)*A,i[7]=0,i[8]=(y+C)*T,i[9]=(b-_)*T,i[10]=(1-(p+v))*T,i[11]=0,i[12]=e.x,i[13]=e.y,i[14]=e.z,i[15]=1,this}decompose(e,n,r){const i=this.elements;let s=$b.set(i[0],i[1],i[2]).length();const o=$b.set(i[4],i[5],i[6]).length(),l=$b.set(i[8],i[9],i[10]).length();this.determinant()<0&&(s=-s),e.x=i[12],e.y=i[13],e.z=i[14],Rc.copy(this);const d=1/s,u=1/o,m=1/l;return Rc.elements[0]*=d,Rc.elements[1]*=d,Rc.elements[2]*=d,Rc.elements[4]*=u,Rc.elements[5]*=u,Rc.elements[6]*=u,Rc.elements[8]*=m,Rc.elements[9]*=m,Rc.elements[10]*=m,n.setFromRotationMatrix(Rc),r.x=s,r.y=o,r.z=l,this}makePerspective(e,n,r,i,s,o,l=Ed){const c=this.elements,d=2*s/(n-e),u=2*s/(r-i),m=(n+e)/(n-e),p=(r+i)/(r-i);let f,y;if(l===Ed)f=-(o+s)/(o-s),y=-2*o*s/(o-s);else if(l===fw)f=-o/(o-s),y=-o*s/(o-s);else throw new Error(\"THREE.Matrix4.makePerspective(): Invalid coordinate system: \"+l);return c[0]=d,c[4]=0,c[8]=m,c[12]=0,c[1]=0,c[5]=u,c[9]=p,c[13]=0,c[2]=0,c[6]=0,c[10]=f,c[14]=y,c[3]=0,c[7]=0,c[11]=-1,c[15]=0,this}makeOrthographic(e,n,r,i,s,o,l=Ed){const c=this.elements,d=1/(n-e),u=1/(r-i),m=1/(o-s),p=(n+e)*d,f=(r+i)*u;let y,v;if(l===Ed)y=(o+s)*m,v=-2*m;else if(l===fw)y=s*m,v=-1*m;else throw new Error(\"THREE.Matrix4.makeOrthographic(): Invalid coordinate system: \"+l);return c[0]=2*d,c[4]=0,c[8]=0,c[12]=-p,c[1]=0,c[5]=2*u,c[9]=0,c[13]=-f,c[2]=0,c[6]=0,c[10]=v,c[14]=-y,c[3]=0,c[7]=0,c[11]=0,c[15]=1,this}equals(e){const n=this.elements,r=e.elements;for(let i=0;i<16;i++)if(n[i]!==r[i])return!1;return!0}fromArray(e,n=0){for(let r=0;r<16;r++)this.elements[r]=e[r+n];return this}toArray(e=[],n=0){const r=this.elements;return e[n]=r[0],e[n+1]=r[1],e[n+2]=r[2],e[n+3]=r[3],e[n+4]=r[4],e[n+5]=r[5],e[n+6]=r[6],e[n+7]=r[7],e[n+8]=r[8],e[n+9]=r[9],e[n+10]=r[10],e[n+11]=r[11],e[n+12]=r[12],e[n+13]=r[13],e[n+14]=r[14],e[n+15]=r[15],e}}const $b=new ut,Rc=new ba,rCe=new ut(0,0,0),aCe=new ut(1,1,1),xh=new ut,T1=new ut,il=new ut,Oq=new ba,Iq=new yg;class cS{constructor(e=0,n=0,r=0,i=cS.DEFAULT_ORDER){this.isEuler=!0,this._x=e,this._y=n,this._z=r,this._order=i}get x(){return this._x}set x(e){this._x=e,this._onChangeCallback()}get y(){return this._y}set y(e){this._y=e,this._onChangeCallback()}get z(){return this._z}set z(e){this._z=e,this._onChangeCallback()}get order(){return this._order}set order(e){this._order=e,this._onChangeCallback()}set(e,n,r,i=this._order){return this._x=e,this._y=n,this._z=r,this._order=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._order)}copy(e){return this._x=e._x,this._y=e._y,this._z=e._z,this._order=e._order,this._onChangeCallback(),this}setFromRotationMatrix(e,n=this._order,r=!0){const i=e.elements,s=i[0],o=i[4],l=i[8],c=i[1],d=i[5],u=i[9],m=i[2],p=i[6],f=i[10];switch(n){case\"XYZ\":this._y=Math.asin(xs(l,-1,1)),Math.abs(l)<.9999999?(this._x=Math.atan2(-u,f),this._z=Math.atan2(-o,s)):(this._x=Math.atan2(p,d),this._z=0);break;case\"YXZ\":this._x=Math.asin(-xs(u,-1,1)),Math.abs(u)<.9999999?(this._y=Math.atan2(l,f),this._z=Math.atan2(c,d)):(this._y=Math.atan2(-m,s),this._z=0);break;case\"ZXY\":this._x=Math.asin(xs(p,-1,1)),Math.abs(p)<.9999999?(this._y=Math.atan2(-m,f),this._z=Math.atan2(-o,d)):(this._y=0,this._z=Math.atan2(c,s));break;case\"ZYX\":this._y=Math.asin(-xs(m,-1,1)),Math.abs(m)<.9999999?(this._x=Math.atan2(p,f),this._z=Math.atan2(c,s)):(this._x=0,this._z=Math.atan2(-o,d));break;case\"YZX\":this._z=Math.asin(xs(c,-1,1)),Math.abs(c)<.9999999?(this._x=Math.atan2(-u,d),this._y=Math.atan2(-m,s)):(this._x=0,this._y=Math.atan2(l,f));break;case\"XZY\":this._z=Math.asin(-xs(o,-1,1)),Math.abs(o)<.9999999?(this._x=Math.atan2(p,d),this._y=Math.atan2(l,s)):(this._x=Math.atan2(-u,f),this._y=0);break;default:console.warn(\"THREE.Euler: .setFromRotationMatrix() encountered an unknown order: \"+n)}return this._order=n,r===!0&&this._onChangeCallback(),this}setFromQuaternion(e,n,r){return Oq.makeRotationFromQuaternion(e),this.setFromRotationMatrix(Oq,n,r)}setFromVector3(e,n=this._order){return this.set(e.x,e.y,e.z,n)}reorder(e){return Iq.setFromEuler(this),this.setFromQuaternion(Iq,e)}equals(e){return e._x===this._x&&e._y===this._y&&e._z===this._z&&e._order===this._order}fromArray(e){return this._x=e[0],this._y=e[1],this._z=e[2],e[3]!==void 0&&(this._order=e[3]),this._onChangeCallback(),this}toArray(e=[],n=0){return e[n]=this._x,e[n+1]=this._y,e[n+2]=this._z,e[n+3]=this._order,e}_onChange(e){return this._onChangeCallback=e,this}_onChangeCallback(){}*[Symbol.iterator](){yield this._x,yield this._y,yield this._z,yield this._order}}cS.DEFAULT_ORDER=\"XYZ\";class mJ{constructor(){this.mask=1}set(e){this.mask=(1<<e|0)>>>0}enable(e){this.mask|=1<<e|0}enableAll(){this.mask=-1}toggle(e){this.mask^=1<<e|0}disable(e){this.mask&=~(1<<e|0)}disableAll(){this.mask=0}test(e){return(this.mask&e.mask)!==0}isEnabled(e){return(this.mask&(1<<e|0))!==0}}let iCe=0;const zq=new ut,Vb=new yg,Eu=new ba,A1=new ut,Hv=new ut,sCe=new ut,oCe=new yg,Uq=new ut(1,0,0),Bq=new ut(0,1,0),Hq=new ut(0,0,1),lCe={type:\"added\"},cCe={type:\"removed\"};class lo extends Bg{constructor(){super(),this.isObject3D=!0,Object.defineProperty(this,\"id\",{value:iCe++}),this.uuid=dm(),this.name=\"\",this.type=\"Object3D\",this.parent=null,this.children=[],this.up=lo.DEFAULT_UP.clone();const e=new ut,n=new cS,r=new yg,i=new ut(1,1,1);function s(){r.setFromEuler(n,!1)}function o(){n.setFromQuaternion(r,void 0,!1)}n._onChange(s),r._onChange(o),Object.defineProperties(this,{position:{configurable:!0,enumerable:!0,value:e},rotation:{configurable:!0,enumerable:!0,value:n},quaternion:{configurable:!0,enumerable:!0,value:r},scale:{configurable:!0,enumerable:!0,value:i},modelViewMatrix:{value:new ba},normalMatrix:{value:new mr}}),this.matrix=new ba,this.matrixWorld=new ba,this.matrixAutoUpdate=lo.DEFAULT_MATRIX_AUTO_UPDATE,this.matrixWorldAutoUpdate=lo.DEFAULT_MATRIX_WORLD_AUTO_UPDATE,this.matrixWorldNeedsUpdate=!1,this.layers=new mJ,this.visible=!0,this.castShadow=!1,this.receiveShadow=!1,this.frustumCulled=!0,this.renderOrder=0,this.animations=[],this.userData={}}onBeforeShadow(){}onAfterShadow(){}onBeforeRender(){}onAfterRender(){}applyMatrix4(e){this.matrixAutoUpdate&&this.updateMatrix(),this.matrix.premultiply(e),this.matrix.decompose(this.position,this.quaternion,this.scale)}applyQuaternion(e){return this.quaternion.premultiply(e),this}setRotationFromAxisAngle(e,n){this.quaternion.setFromAxisAngle(e,n)}setRotationFromEuler(e){this.quaternion.setFromEuler(e,!0)}setRotationFromMatrix(e){this.quaternion.setFromRotationMatrix(e)}setRotationFromQuaternion(e){this.quaternion.copy(e)}rotateOnAxis(e,n){return Vb.setFromAxisAngle(e,n),this.quaternion.multiply(Vb),this}rotateOnWorldAxis(e,n){return Vb.setFromAxisAngle(e,n),this.quaternion.premultiply(Vb),this}rotateX(e){return this.rotateOnAxis(Uq,e)}rotateY(e){return this.rotateOnAxis(Bq,e)}rotateZ(e){return this.rotateOnAxis(Hq,e)}translateOnAxis(e,n){return zq.copy(e).applyQuaternion(this.quaternion),this.position.add(zq.multiplyScalar(n)),this}translateX(e){return this.translateOnAxis(Uq,e)}translateY(e){return this.translateOnAxis(Bq,e)}translateZ(e){return this.translateOnAxis(Hq,e)}localToWorld(e){return this.updateWorldMatrix(!0,!1),e.applyMatrix4(this.matrixWorld)}worldToLocal(e){return this.updateWorldMatrix(!0,!1),e.applyMatrix4(Eu.copy(this.matrixWorld).invert())}lookAt(e,n,r){e.isVector3?A1.copy(e):A1.set(e,n,r);const i=this.parent;this.updateWorldMatrix(!0,!1),Hv.setFromMatrixPosition(this.matrixWorld),this.isCamera||this.isLight?Eu.lookAt(Hv,A1,this.up):Eu.lookAt(A1,Hv,this.up),this.quaternion.setFromRotationMatrix(Eu),i&&(Eu.extractRotation(i.matrixWorld),Vb.setFromRotationMatrix(Eu),this.quaternion.premultiply(Vb.invert()))}add(e){if(arguments.length>1){for(let n=0;n<arguments.length;n++)this.add(arguments[n]);return this}return e===this?(console.error(\"THREE.Object3D.add: object can't be added as a child of itself.\",e),this):(e&&e.isObject3D?(e.parent!==null&&e.parent.remove(e),e.parent=this,this.children.push(e),e.dispatchEvent(lCe)):console.error(\"THREE.Object3D.add: object not an instance of THREE.Object3D.\",e),this)}remove(e){if(arguments.length>1){for(let r=0;r<arguments.length;r++)this.remove(arguments[r]);return this}const n=this.children.indexOf(e);return n!==-1&&(e.parent=null,this.children.splice(n,1),e.dispatchEvent(cCe)),this}removeFromParent(){const e=this.parent;return e!==null&&e.remove(this),this}clear(){return this.remove(...this.children)}attach(e){return this.updateWorldMatrix(!0,!1),Eu.copy(this.matrixWorld).invert(),e.parent!==null&&(e.parent.updateWorldMatrix(!0,!1),Eu.multiply(e.parent.matrixWorld)),e.applyMatrix4(Eu),this.add(e),e.updateWorldMatrix(!1,!0),this}getObjectById(e){return this.getObjectByProperty(\"id\",e)}getObjectByName(e){return this.getObjectByProperty(\"name\",e)}getObjectByProperty(e,n){if(this[e]===n)return this;for(let r=0,i=this.children.length;r<i;r++){const o=this.children[r].getObjectByProperty(e,n);if(o!==void 0)return o}}getObjectsByProperty(e,n,r=[]){this[e]===n&&r.push(this);const i=this.children;for(let s=0,o=i.length;s<o;s++)i[s].getObjectsByProperty(e,n,r);return r}getWorldPosition(e){return this.updateWorldMatrix(!0,!1),e.setFromMatrixPosition(this.matrixWorld)}getWorldQuaternion(e){return this.updateWorldMatrix(!0,!1),this.matrixWorld.decompose(Hv,e,sCe),e}getWorldScale(e){return this.updateWorldMatrix(!0,!1),this.matrixWorld.decompose(Hv,oCe,e),e}getWorldDirection(e){this.updateWorldMatrix(!0,!1);const n=this.matrixWorld.elements;return e.set(n[8],n[9],n[10]).normalize()}raycast(){}traverse(e){e(this);const n=this.children;for(let r=0,i=n.length;r<i;r++)n[r].traverse(e)}traverseVisible(e){if(this.visible===!1)return;e(this);const n=this.children;for(let r=0,i=n.length;r<i;r++)n[r].traverseVisible(e)}traverseAncestors(e){const n=this.parent;n!==null&&(e(n),n.traverseAncestors(e))}updateMatrix(){this.matrix.compose(this.position,this.quaternion,this.scale),this.matrixWorldNeedsUpdate=!0}updateMatrixWorld(e){this.matrixAutoUpdate&&this.updateMatrix(),(this.matrixWorldNeedsUpdate||e)&&(this.parent===null?this.matrixWorld.copy(this.matrix):this.matrixWorld.multiplyMatrices(this.parent.matrixWorld,this.matrix),this.matrixWorldNeedsUpdate=!1,e=!0);const n=this.children;for(let r=0,i=n.length;r<i;r++){const s=n[r];(s.matrixWorldAutoUpdate===!0||e===!0)&&s.updateMatrixWorld(e)}}updateWorldMatrix(e,n){const r=this.parent;if(e===!0&&r!==null&&r.matrixWorldAutoUpdate===!0&&r.updateWorldMatrix(!0,!1),this.matrixAutoUpdate&&this.updateMatrix(),this.parent===null?this.matrixWorld.copy(this.matrix):this.matrixWorld.multiplyMatrices(this.parent.matrixWorld,this.matrix),n===!0){const i=this.children;for(let s=0,o=i.length;s<o;s++){const l=i[s];l.matrixWorldAutoUpdate===!0&&l.updateWorldMatrix(!1,!0)}}}toJSON(e){const n=e===void 0||typeof e==\"string\",r={};n&&(e={geometries:{},materials:{},textures:{},images:{},shapes:{},skeletons:{},animations:{},nodes:{}},r.metadata={version:4.6,type:\"Object\",generator:\"Object3D.toJSON\"});const i={};i.uuid=this.uuid,i.type=this.type,this.name!==\"\"&&(i.name=this.name),this.castShadow===!0&&(i.castShadow=!0),this.receiveShadow===!0&&(i.receiveShadow=!0),this.visible===!1&&(i.visible=!1),this.frustumCulled===!1&&(i.frustumCulled=!1),this.renderOrder!==0&&(i.renderOrder=this.renderOrder),Object.keys(this.userData).length>0&&(i.userData=this.userData),i.layers=this.layers.mask,i.matrix=this.matrix.toArray(),i.up=this.up.toArray(),this.matrixAutoUpdate===!1&&(i.matrixAutoUpdate=!1),this.isInstancedMesh&&(i.type=\"InstancedMesh\",i.count=this.count,i.instanceMatrix=this.instanceMatrix.toJSON(),this.instanceColor!==null&&(i.instanceColor=this.instanceColor.toJSON())),this.isBatchedMesh&&(i.type=\"BatchedMesh\",i.perObjectFrustumCulled=this.perObjectFrustumCulled,i.sortObjects=this.sortObjects,i.drawRanges=this._drawRanges,i.reservedRanges=this._reservedRanges,i.visibility=this._visibility,i.active=this._active,i.bounds=this._bounds.map(l=>({boxInitialized:l.boxInitialized,boxMin:l.box.min.toArray(),boxMax:l.box.max.toArray(),sphereInitialized:l.sphereInitialized,sphereRadius:l.sphere.radius,sphereCenter:l.sphere.center.toArray()})),i.maxGeometryCount=this._maxGeometryCount,i.maxVertexCount=this._maxVertexCount,i.maxIndexCount=this._maxIndexCount,i.geometryInitialized=this._geometryInitialized,i.geometryCount=this._geometryCount,i.matricesTexture=this._matricesTexture.toJSON(e),this.boundingSphere!==null&&(i.boundingSphere={center:i.boundingSphere.center.toArray(),radius:i.boundingSphere.radius}),this.boundingBox!==null&&(i.boundingBox={min:i.boundingBox.min.toArray(),max:i.boundingBox.max.toArray()}));function s(l,c){return l[c.uuid]===void 0&&(l[c.uuid]=c.toJSON(e)),c.uuid}if(this.isScene)this.background&&(this.background.isColor?i.background=this.background.toJSON():this.background.isTexture&&(i.background=this.background.toJSON(e).uuid)),this.environment&&this.environment.isTexture&&this.environment.isRenderTargetTexture!==!0&&(i.environment=this.environment.toJSON(e).uuid);else if(this.isMesh||this.isLine||this.isPoints){i.geometry=s(e.geometries,this.geometry);const l=this.geometry.parameters;if(l!==void 0&&l.shapes!==void 0){const c=l.shapes;if(Array.isArray(c))for(let d=0,u=c.length;d<u;d++){const m=c[d];s(e.shapes,m)}else s(e.shapes,c)}}if(this.isSkinnedMesh&&(i.bindMode=this.bindMode,i.bindMatrix=this.bindMatrix.toArray(),this.skeleton!==void 0&&(s(e.skeletons,this.skeleton),i.skeleton=this.skeleton.uuid)),this.material!==void 0)if(Array.isArray(this.material)){const l=[];for(let c=0,d=this.material.length;c<d;c++)l.push(s(e.materials,this.material[c]));i.material=l}else i.material=s(e.materials,this.material);if(this.children.length>0){i.children=[];for(let l=0;l<this.children.length;l++)i.children.push(this.children[l].toJSON(e).object)}if(this.animations.length>0){i.animations=[];for(let l=0;l<this.animations.length;l++){const c=this.animations[l];i.animations.push(s(e.animations,c))}}if(n){const l=o(e.geometries),c=o(e.materials),d=o(e.textures),u=o(e.images),m=o(e.shapes),p=o(e.skeletons),f=o(e.animations),y=o(e.nodes);l.length>0&&(r.geometries=l),c.length>0&&(r.materials=c),d.length>0&&(r.textures=d),u.length>0&&(r.images=u),m.length>0&&(r.shapes=m),p.length>0&&(r.skeletons=p),f.length>0&&(r.animations=f),y.length>0&&(r.nodes=y)}return r.object=i,r;function o(l){const c=[];for(const d in l){const u=l[d];delete u.metadata,c.push(u)}return c}}clone(e){return new this.constructor().copy(this,e)}copy(e,n=!0){if(this.name=e.name,this.up.copy(e.up),this.position.copy(e.position),this.rotation.order=e.rotation.order,this.quaternion.copy(e.quaternion),this.scale.copy(e.scale),this.matrix.copy(e.matrix),this.matrixWorld.copy(e.matrixWorld),this.matrixAutoUpdate=e.matrixAutoUpdate,this.matrixWorldAutoUpdate=e.matrixWorldAutoUpdate,this.matrixWorldNeedsUpdate=e.matrixWorldNeedsUpdate,this.layers.mask=e.layers.mask,this.visible=e.visible,this.castShadow=e.castShadow,this.receiveShadow=e.receiveShadow,this.frustumCulled=e.frustumCulled,this.renderOrder=e.renderOrder,this.animations=e.animations.slice(),this.userData=JSON.parse(JSON.stringify(e.userData)),n===!0)for(let r=0;r<e.children.length;r++){const i=e.children[r];this.add(i.clone())}return this}}lo.DEFAULT_UP=new ut(0,1,0);lo.DEFAULT_MATRIX_AUTO_UPDATE=!0;lo.DEFAULT_MATRIX_WORLD_AUTO_UPDATE=!0;const Lc=new ut,Du=new ut,CE=new ut,Fu=new ut,Gb=new ut,Wb=new ut,qq=new ut,PE=new ut,TE=new ut,AE=new ut;let j1=!1;class zc{constructor(e=new ut,n=new ut,r=new ut){this.a=e,this.b=n,this.c=r}static getNormal(e,n,r,i){i.subVectors(r,n),Lc.subVectors(e,n),i.cross(Lc);const s=i.lengthSq();return s>0?i.multiplyScalar(1/Math.sqrt(s)):i.set(0,0,0)}static getBarycoord(e,n,r,i,s){Lc.subVectors(i,n),Du.subVectors(r,n),CE.subVectors(e,n);const o=Lc.dot(Lc),l=Lc.dot(Du),c=Lc.dot(CE),d=Du.dot(Du),u=Du.dot(CE),m=o*d-l*l;if(m===0)return s.set(-2,-1,-1);const p=1/m,f=(d*c-l*u)*p,y=(o*u-l*c)*p;return s.set(1-f-y,y,f)}static containsPoint(e,n,r,i){return this.getBarycoord(e,n,r,i,Fu),Fu.x>=0&&Fu.y>=0&&Fu.x+Fu.y<=1}static getUV(e,n,r,i,s,o,l,c){return j1===!1&&(console.warn(\"THREE.Triangle.getUV() has been renamed to THREE.Triangle.getInterpolation().\"),j1=!0),this.getInterpolation(e,n,r,i,s,o,l,c)}static getInterpolation(e,n,r,i,s,o,l,c){return this.getBarycoord(e,n,r,i,Fu),c.setScalar(0),c.addScaledVector(s,Fu.x),c.addScaledVector(o,Fu.y),c.addScaledVector(l,Fu.z),c}static isFrontFacing(e,n,r,i){return Lc.subVectors(r,n),Du.subVectors(e,n),Lc.cross(Du).dot(i)<0}set(e,n,r){return this.a.copy(e),this.b.copy(n),this.c.copy(r),this}setFromPointsAndIndices(e,n,r,i){return this.a.copy(e[n]),this.b.copy(e[r]),this.c.copy(e[i]),this}setFromAttributeAndIndices(e,n,r,i){return this.a.fromBufferAttribute(e,n),this.b.fromBufferAttribute(e,r),this.c.fromBufferAttribute(e,i),this}clone(){return new this.constructor().copy(this)}copy(e){return this.a.copy(e.a),this.b.copy(e.b),this.c.copy(e.c),this}getArea(){return Lc.subVectors(this.c,this.b),Du.subVectors(this.a,this.b),Lc.cross(Du).length()*.5}getMidpoint(e){return e.addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)}getNormal(e){return zc.getNormal(this.a,this.b,this.c,e)}getPlane(e){return e.setFromCoplanarPoints(this.a,this.b,this.c)}getBarycoord(e,n){return zc.getBarycoord(e,this.a,this.b,this.c,n)}getUV(e,n,r,i,s){return j1===!1&&(console.warn(\"THREE.Triangle.getUV() has been renamed to THREE.Triangle.getInterpolation().\"),j1=!0),zc.getInterpolation(e,this.a,this.b,this.c,n,r,i,s)}getInterpolation(e,n,r,i,s){return zc.getInterpolation(e,this.a,this.b,this.c,n,r,i,s)}containsPoint(e){return zc.containsPoint(e,this.a,this.b,this.c)}isFrontFacing(e){return zc.isFrontFacing(this.a,this.b,this.c,e)}intersectsBox(e){return e.intersectsTriangle(this)}closestPointToPoint(e,n){const r=this.a,i=this.b,s=this.c;let o,l;Gb.subVectors(i,r),Wb.subVectors(s,r),PE.subVectors(e,r);const c=Gb.dot(PE),d=Wb.dot(PE);if(c<=0&&d<=0)return n.copy(r);TE.subVectors(e,i);const u=Gb.dot(TE),m=Wb.dot(TE);if(u>=0&&m<=u)return n.copy(i);const p=c*m-u*d;if(p<=0&&c>=0&&u<=0)return o=c/(c-u),n.copy(r).addScaledVector(Gb,o);AE.subVectors(e,s);const f=Gb.dot(AE),y=Wb.dot(AE);if(y>=0&&f<=y)return n.copy(s);const v=f*d-c*y;if(v<=0&&d>=0&&y<=0)return l=d/(d-y),n.copy(r).addScaledVector(Wb,l);const b=u*y-f*m;if(b<=0&&m-u>=0&&f-y>=0)return qq.subVectors(s,i),l=(m-u)/(m-u+(f-y)),n.copy(i).addScaledVector(qq,l);const g=1/(b+v+p);return o=v*g,l=p*g,n.copy(r).addScaledVector(Gb,o).addScaledVector(Wb,l)}equals(e){return e.a.equals(this.a)&&e.b.equals(this.b)&&e.c.equals(this.c)}}const hJ={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074},yh={h:0,s:0,l:0},M1={h:0,s:0,l:0};function jE(t,e,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?t+(e-t)*6*n:n<1/2?e:n<2/3?t+(e-t)*6*(2/3-n):t}let Mn=class{constructor(e,n,r){return this.isColor=!0,this.r=1,this.g=1,this.b=1,this.set(e,n,r)}set(e,n,r){if(n===void 0&&r===void 0){const i=e;i&&i.isColor?this.copy(i):typeof i==\"number\"?this.setHex(i):typeof i==\"string\"&&this.setStyle(i)}else this.setRGB(e,n,r);return this}setScalar(e){return this.r=e,this.g=e,this.b=e,this}setHex(e,n=bs){return e=Math.floor(e),this.r=(e>>16&255)/255,this.g=(e>>8&255)/255,this.b=(e&255)/255,Jr.toWorkingColorSpace(this,n),this}setRGB(e,n,r,i=Jr.workingColorSpace){return this.r=e,this.g=n,this.b=r,Jr.toWorkingColorSpace(this,i),this}setHSL(e,n,r,i=Jr.workingColorSpace){if(e=dI(e,1),n=xs(n,0,1),r=xs(r,0,1),n===0)this.r=this.g=this.b=r;else{const s=r<=.5?r*(1+n):r+n-r*n,o=2*r-s;this.r=jE(o,s,e+1/3),this.g=jE(o,s,e),this.b=jE(o,s,e-1/3)}return Jr.toWorkingColorSpace(this,i),this}setStyle(e,n=bs){function r(s){s!==void 0&&parseFloat(s)<1&&console.warn(\"THREE.Color: Alpha component of \"+e+\" will be ignored.\")}let i;if(i=/^(\\w+)\\(([^\\)]*)\\)/.exec(e)){let s;const o=i[1],l=i[2];switch(o){case\"rgb\":case\"rgba\":if(s=/^\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*(\\d*\\.?\\d+)\\s*)?$/.exec(l))return r(s[4]),this.setRGB(Math.min(255,parseInt(s[1],10))/255,Math.min(255,parseInt(s[2],10))/255,Math.min(255,parseInt(s[3],10))/255,n);if(s=/^\\s*(\\d+)\\%\\s*,\\s*(\\d+)\\%\\s*,\\s*(\\d+)\\%\\s*(?:,\\s*(\\d*\\.?\\d+)\\s*)?$/.exec(l))return r(s[4]),this.setRGB(Math.min(100,parseInt(s[1],10))/100,Math.min(100,parseInt(s[2],10))/100,Math.min(100,parseInt(s[3],10))/100,n);break;case\"hsl\":case\"hsla\":if(s=/^\\s*(\\d*\\.?\\d+)\\s*,\\s*(\\d*\\.?\\d+)\\%\\s*,\\s*(\\d*\\.?\\d+)\\%\\s*(?:,\\s*(\\d*\\.?\\d+)\\s*)?$/.exec(l))return r(s[4]),this.setHSL(parseFloat(s[1])/360,parseFloat(s[2])/100,parseFloat(s[3])/100,n);break;default:console.warn(\"THREE.Color: Unknown color model \"+e)}}else if(i=/^\\#([A-Fa-f\\d]+)$/.exec(e)){const s=i[1],o=s.length;if(o===3)return this.setRGB(parseInt(s.charAt(0),16)/15,parseInt(s.charAt(1),16)/15,parseInt(s.charAt(2),16)/15,n);if(o===6)return this.setHex(parseInt(s,16),n);console.warn(\"THREE.Color: Invalid hex color \"+e)}else if(e&&e.length>0)return this.setColorName(e,n);return this}setColorName(e,n=bs){const r=hJ[e.toLowerCase()];return r!==void 0?this.setHex(r,n):console.warn(\"THREE.Color: Unknown color \"+e),this}clone(){return new this.constructor(this.r,this.g,this.b)}copy(e){return this.r=e.r,this.g=e.g,this.b=e.b,this}copySRGBToLinear(e){return this.r=Ex(e.r),this.g=Ex(e.g),this.b=Ex(e.b),this}copyLinearToSRGB(e){return this.r=xE(e.r),this.g=xE(e.g),this.b=xE(e.b),this}convertSRGBToLinear(){return this.copySRGBToLinear(this),this}convertLinearToSRGB(){return this.copyLinearToSRGB(this),this}getHex(e=bs){return Jr.fromWorkingColorSpace(Fs.copy(this),e),Math.round(xs(Fs.r*255,0,255))*65536+Math.round(xs(Fs.g*255,0,255))*256+Math.round(xs(Fs.b*255,0,255))}getHexString(e=bs){return(\"000000\"+this.getHex(e).toString(16)).slice(-6)}getHSL(e,n=Jr.workingColorSpace){Jr.fromWorkingColorSpace(Fs.copy(this),n);const r=Fs.r,i=Fs.g,s=Fs.b,o=Math.max(r,i,s),l=Math.min(r,i,s);let c,d;const u=(l+o)/2;if(l===o)c=0,d=0;else{const m=o-l;switch(d=u<=.5?m/(o+l):m/(2-o-l),o){case r:c=(i-s)/m+(i<s?6:0);break;case i:c=(s-r)/m+2;break;case s:c=(r-i)/m+4;break}c/=6}return e.h=c,e.s=d,e.l=u,e}getRGB(e,n=Jr.workingColorSpace){return Jr.fromWorkingColorSpace(Fs.copy(this),n),e.r=Fs.r,e.g=Fs.g,e.b=Fs.b,e}getStyle(e=bs){Jr.fromWorkingColorSpace(Fs.copy(this),e);const n=Fs.r,r=Fs.g,i=Fs.b;return e!==bs?`color(${e} ${n.toFixed(3)} ${r.toFixed(3)} ${i.toFixed(3)})`:`rgb(${Math.round(n*255)},${Math.round(r*255)},${Math.round(i*255)})`}offsetHSL(e,n,r){return this.getHSL(yh),this.setHSL(yh.h+e,yh.s+n,yh.l+r)}add(e){return this.r+=e.r,this.g+=e.g,this.b+=e.b,this}addColors(e,n){return this.r=e.r+n.r,this.g=e.g+n.g,this.b=e.b+n.b,this}addScalar(e){return this.r+=e,this.g+=e,this.b+=e,this}sub(e){return this.r=Math.max(0,this.r-e.r),this.g=Math.max(0,this.g-e.g),this.b=Math.max(0,this.b-e.b),this}multiply(e){return this.r*=e.r,this.g*=e.g,this.b*=e.b,this}multiplyScalar(e){return this.r*=e,this.g*=e,this.b*=e,this}lerp(e,n){return this.r+=(e.r-this.r)*n,this.g+=(e.g-this.g)*n,this.b+=(e.b-this.b)*n,this}lerpColors(e,n,r){return this.r=e.r+(n.r-e.r)*r,this.g=e.g+(n.g-e.g)*r,this.b=e.b+(n.b-e.b)*r,this}lerpHSL(e,n){this.getHSL(yh),e.getHSL(M1);const r=M0(yh.h,M1.h,n),i=M0(yh.s,M1.s,n),s=M0(yh.l,M1.l,n);return this.setHSL(r,i,s),this}setFromVector3(e){return this.r=e.x,this.g=e.y,this.b=e.z,this}applyMatrix3(e){const n=this.r,r=this.g,i=this.b,s=e.elements;return this.r=s[0]*n+s[3]*r+s[6]*i,this.g=s[1]*n+s[4]*r+s[7]*i,this.b=s[2]*n+s[5]*r+s[8]*i,this}equals(e){return e.r===this.r&&e.g===this.g&&e.b===this.b}fromArray(e,n=0){return this.r=e[n],this.g=e[n+1],this.b=e[n+2],this}toArray(e=[],n=0){return e[n]=this.r,e[n+1]=this.g,e[n+2]=this.b,e}fromBufferAttribute(e,n){return this.r=e.getX(n),this.g=e.getY(n),this.b=e.getZ(n),this}toJSON(){return this.getHex()}*[Symbol.iterator](){yield this.r,yield this.g,yield this.b}};const Fs=new Mn;Mn.NAMES=hJ;let dCe=0;class Cy extends Bg{constructor(){super(),this.isMaterial=!0,Object.defineProperty(this,\"id\",{value:dCe++}),this.uuid=dm(),this.name=\"\",this.type=\"Material\",this.blending=Mx,this.side=yp,this.vertexColors=!1,this.opacity=1,this.transparent=!1,this.alphaHash=!1,this.blendSrc=sR,this.blendDst=oR,this.blendEquation=jf,this.blendSrcAlpha=null,this.blendDstAlpha=null,this.blendEquationAlpha=null,this.blendColor=new Mn(0,0,0),this.blendAlpha=0,this.depthFunc=DN,this.depthTest=!0,this.depthWrite=!0,this.stencilWriteMask=255,this.stencilFunc=jq,this.stencilRef=0,this.stencilFuncMask=255,this.stencilFail=zb,this.stencilZFail=zb,this.stencilZPass=zb,this.stencilWrite=!1,this.clippingPlanes=null,this.clipIntersection=!1,this.clipShadows=!1,this.shadowSide=null,this.colorWrite=!0,this.precision=null,this.polygonOffset=!1,this.polygonOffsetFactor=0,this.polygonOffsetUnits=0,this.dithering=!1,this.alphaToCoverage=!1,this.premultipliedAlpha=!1,this.forceSinglePass=!1,this.visible=!0,this.toneMapped=!0,this.userData={},this.version=0,this._alphaTest=0}get alphaTest(){return this._alphaTest}set alphaTest(e){this._alphaTest>0!=e>0&&this.version++,this._alphaTest=e}onBuild(){}onBeforeRender(){}onBeforeCompile(){}customProgramCacheKey(){return this.onBeforeCompile.toString()}setValues(e){if(e!==void 0)for(const n in e){const r=e[n];if(r===void 0){console.warn(`THREE.Material: parameter '${n}' has value of undefined.`);continue}const i=this[n];if(i===void 0){console.warn(`THREE.Material: '${n}' is not a property of THREE.${this.type}.`);continue}i&&i.isColor?i.set(r):i&&i.isVector3&&r&&r.isVector3?i.copy(r):this[n]=r}}toJSON(e){const n=e===void 0||typeof e==\"string\";n&&(e={textures:{},images:{}});const r={metadata:{version:4.6,type:\"Material\",generator:\"Material.toJSON\"}};r.uuid=this.uuid,r.type=this.type,this.name!==\"\"&&(r.name=this.name),this.color&&this.color.isColor&&(r.color=this.color.getHex()),this.roughness!==void 0&&(r.roughness=this.roughness),this.metalness!==void 0&&(r.metalness=this.metalness),this.sheen!==void 0&&(r.sheen=this.sheen),this.sheenColor&&this.sheenColor.isColor&&(r.sheenColor=this.sheenColor.getHex()),this.sheenRoughness!==void 0&&(r.sheenRoughness=this.sheenRoughness),this.emissive&&this.emissive.isColor&&(r.emissive=this.emissive.getHex()),this.emissiveIntensity&&this.emissiveIntensity!==1&&(r.emissiveIntensity=this.emissiveIntensity),this.specular&&this.specular.isColor&&(r.specular=this.specular.getHex()),this.specularIntensity!==void 0&&(r.specularIntensity=this.specularIntensity),this.specularColor&&this.specularColor.isColor&&(r.specularColor=this.specularColor.getHex()),this.shininess!==void 0&&(r.shininess=this.shininess),this.clearcoat!==void 0&&(r.clearcoat=this.clearcoat),this.clearcoatRoughness!==void 0&&(r.clearcoatRoughness=this.clearcoatRoughness),this.clearcoatMap&&this.clearcoatMap.isTexture&&(r.clearcoatMap=this.clearcoatMap.toJSON(e).uuid),this.clearcoatRoughnessMap&&this.clearcoatRoughnessMap.isTexture&&(r.clearcoatRoughnessMap=this.clearcoatRoughnessMap.toJSON(e).uuid),this.clearcoatNormalMap&&this.clearcoatNormalMap.isTexture&&(r.clearcoatNormalMap=this.clearcoatNormalMap.toJSON(e).uuid,r.clearcoatNormalScale=this.clearcoatNormalScale.toArray()),this.iridescence!==void 0&&(r.iridescence=this.iridescence),this.iridescenceIOR!==void 0&&(r.iridescenceIOR=this.iridescenceIOR),this.iridescenceThicknessRange!==void 0&&(r.iridescenceThicknessRange=this.iridescenceThicknessRange),this.iridescenceMap&&this.iridescenceMap.isTexture&&(r.iridescenceMap=this.iridescenceMap.toJSON(e).uuid),this.iridescenceThicknessMap&&this.iridescenceThicknessMap.isTexture&&(r.iridescenceThicknessMap=this.iridescenceThicknessMap.toJSON(e).uuid),this.anisotropy!==void 0&&(r.anisotropy=this.anisotropy),this.anisotropyRotation!==void 0&&(r.anisotropyRotation=this.anisotropyRotation),this.anisotropyMap&&this.anisotropyMap.isTexture&&(r.anisotropyMap=this.anisotropyMap.toJSON(e).uuid),this.map&&this.map.isTexture&&(r.map=this.map.toJSON(e).uuid),this.matcap&&this.matcap.isTexture&&(r.matcap=this.matcap.toJSON(e).uuid),this.alphaMap&&this.alphaMap.isTexture&&(r.alphaMap=this.alphaMap.toJSON(e).uuid),this.lightMap&&this.lightMap.isTexture&&(r.lightMap=this.lightMap.toJSON(e).uuid,r.lightMapIntensity=this.lightMapIntensity),this.aoMap&&this.aoMap.isTexture&&(r.aoMap=this.aoMap.toJSON(e).uuid,r.aoMapIntensity=this.aoMapIntensity),this.bumpMap&&this.bumpMap.isTexture&&(r.bumpMap=this.bumpMap.toJSON(e).uuid,r.bumpScale=this.bumpScale),this.normalMap&&this.normalMap.isTexture&&(r.normalMap=this.normalMap.toJSON(e).uuid,r.normalMapType=this.normalMapType,r.normalScale=this.normalScale.toArray()),this.displacementMap&&this.displacementMap.isTexture&&(r.displacementMap=this.displacementMap.toJSON(e).uuid,r.displacementScale=this.displacementScale,r.displacementBias=this.displacementBias),this.roughnessMap&&this.roughnessMap.isTexture&&(r.roughnessMap=this.roughnessMap.toJSON(e).uuid),this.metalnessMap&&this.metalnessMap.isTexture&&(r.metalnessMap=this.metalnessMap.toJSON(e).uuid),this.emissiveMap&&this.emissiveMap.isTexture&&(r.emissiveMap=this.emissiveMap.toJSON(e).uuid),this.specularMap&&this.specularMap.isTexture&&(r.specularMap=this.specularMap.toJSON(e).uuid),this.specularIntensityMap&&this.specularIntensityMap.isTexture&&(r.specularIntensityMap=this.specularIntensityMap.toJSON(e).uuid),this.specularColorMap&&this.specularColorMap.isTexture&&(r.specularColorMap=this.specularColorMap.toJSON(e).uuid),this.envMap&&this.envMap.isTexture&&(r.envMap=this.envMap.toJSON(e).uuid,this.combine!==void 0&&(r.combine=this.combine)),this.envMapIntensity!==void 0&&(r.envMapIntensity=this.envMapIntensity),this.reflectivity!==void 0&&(r.reflectivity=this.reflectivity),this.refractionRatio!==void 0&&(r.refractionRatio=this.refractionRatio),this.gradientMap&&this.gradientMap.isTexture&&(r.gradientMap=this.gradientMap.toJSON(e).uuid),this.transmission!==void 0&&(r.transmission=this.transmission),this.transmissionMap&&this.transmissionMap.isTexture&&(r.transmissionMap=this.transmissionMap.toJSON(e).uuid),this.thickness!==void 0&&(r.thickness=this.thickness),this.thicknessMap&&this.thicknessMap.isTexture&&(r.thicknessMap=this.thicknessMap.toJSON(e).uuid),this.attenuationDistance!==void 0&&this.attenuationDistance!==1/0&&(r.attenuationDistance=this.attenuationDistance),this.attenuationColor!==void 0&&(r.attenuationColor=this.attenuationColor.getHex()),this.size!==void 0&&(r.size=this.size),this.shadowSide!==null&&(r.shadowSide=this.shadowSide),this.sizeAttenuation!==void 0&&(r.sizeAttenuation=this.sizeAttenuation),this.blending!==Mx&&(r.blending=this.blending),this.side!==yp&&(r.side=this.side),this.vertexColors===!0&&(r.vertexColors=!0),this.opacity<1&&(r.opacity=this.opacity),this.transparent===!0&&(r.transparent=!0),this.blendSrc!==sR&&(r.blendSrc=this.blendSrc),this.blendDst!==oR&&(r.blendDst=this.blendDst),this.blendEquation!==jf&&(r.blendEquation=this.blendEquation),this.blendSrcAlpha!==null&&(r.blendSrcAlpha=this.blendSrcAlpha),this.blendDstAlpha!==null&&(r.blendDstAlpha=this.blendDstAlpha),this.blendEquationAlpha!==null&&(r.blendEquationAlpha=this.blendEquationAlpha),this.blendColor&&this.blendColor.isColor&&(r.blendColor=this.blendColor.getHex()),this.blendAlpha!==0&&(r.blendAlpha=this.blendAlpha),this.depthFunc!==DN&&(r.depthFunc=this.depthFunc),this.depthTest===!1&&(r.depthTest=this.depthTest),this.depthWrite===!1&&(r.depthWrite=this.depthWrite),this.colorWrite===!1&&(r.colorWrite=this.colorWrite),this.stencilWriteMask!==255&&(r.stencilWriteMask=this.stencilWriteMask),this.stencilFunc!==jq&&(r.stencilFunc=this.stencilFunc),this.stencilRef!==0&&(r.stencilRef=this.stencilRef),this.stencilFuncMask!==255&&(r.stencilFuncMask=this.stencilFuncMask),this.stencilFail!==zb&&(r.stencilFail=this.stencilFail),this.stencilZFail!==zb&&(r.stencilZFail=this.stencilZFail),this.stencilZPass!==zb&&(r.stencilZPass=this.stencilZPass),this.stencilWrite===!0&&(r.stencilWrite=this.stencilWrite),this.rotation!==void 0&&this.rotation!==0&&(r.rotation=this.rotation),this.polygonOffset===!0&&(r.polygonOffset=!0),this.polygonOffsetFactor!==0&&(r.polygonOffsetFactor=this.polygonOffsetFactor),this.polygonOffsetUnits!==0&&(r.polygonOffsetUnits=this.polygonOffsetUnits),this.linewidth!==void 0&&this.linewidth!==1&&(r.linewidth=this.linewidth),this.dashSize!==void 0&&(r.dashSize=this.dashSize),this.gapSize!==void 0&&(r.gapSize=this.gapSize),this.scale!==void 0&&(r.scale=this.scale),this.dithering===!0&&(r.dithering=!0),this.alphaTest>0&&(r.alphaTest=this.alphaTest),this.alphaHash===!0&&(r.alphaHash=!0),this.alphaToCoverage===!0&&(r.alphaToCoverage=!0),this.premultipliedAlpha===!0&&(r.premultipliedAlpha=!0),this.forceSinglePass===!0&&(r.forceSinglePass=!0),this.wireframe===!0&&(r.wireframe=!0),this.wireframeLinewidth>1&&(r.wireframeLinewidth=this.wireframeLinewidth),this.wireframeLinecap!==\"round\"&&(r.wireframeLinecap=this.wireframeLinecap),this.wireframeLinejoin!==\"round\"&&(r.wireframeLinejoin=this.wireframeLinejoin),this.flatShading===!0&&(r.flatShading=!0),this.visible===!1&&(r.visible=!1),this.toneMapped===!1&&(r.toneMapped=!1),this.fog===!1&&(r.fog=!1),Object.keys(this.userData).length>0&&(r.userData=this.userData);function i(s){const o=[];for(const l in s){const c=s[l];delete c.metadata,o.push(c)}return o}if(n){const s=i(e.textures),o=i(e.images);s.length>0&&(r.textures=s),o.length>0&&(r.images=o)}return r}clone(){return new this.constructor().copy(this)}copy(e){this.name=e.name,this.blending=e.blending,this.side=e.side,this.vertexColors=e.vertexColors,this.opacity=e.opacity,this.transparent=e.transparent,this.blendSrc=e.blendSrc,this.blendDst=e.blendDst,this.blendEquation=e.blendEquation,this.blendSrcAlpha=e.blendSrcAlpha,this.blendDstAlpha=e.blendDstAlpha,this.blendEquationAlpha=e.blendEquationAlpha,this.blendColor.copy(e.blendColor),this.blendAlpha=e.blendAlpha,this.depthFunc=e.depthFunc,this.depthTest=e.depthTest,this.depthWrite=e.depthWrite,this.stencilWriteMask=e.stencilWriteMask,this.stencilFunc=e.stencilFunc,this.stencilRef=e.stencilRef,this.stencilFuncMask=e.stencilFuncMask,this.stencilFail=e.stencilFail,this.stencilZFail=e.stencilZFail,this.stencilZPass=e.stencilZPass,this.stencilWrite=e.stencilWrite;const n=e.clippingPlanes;let r=null;if(n!==null){const i=n.length;r=new Array(i);for(let s=0;s!==i;++s)r[s]=n[s].clone()}return this.clippingPlanes=r,this.clipIntersection=e.clipIntersection,this.clipShadows=e.clipShadows,this.shadowSide=e.shadowSide,this.colorWrite=e.colorWrite,this.precision=e.precision,this.polygonOffset=e.polygonOffset,this.polygonOffsetFactor=e.polygonOffsetFactor,this.polygonOffsetUnits=e.polygonOffsetUnits,this.dithering=e.dithering,this.alphaTest=e.alphaTest,this.alphaHash=e.alphaHash,this.alphaToCoverage=e.alphaToCoverage,this.premultipliedAlpha=e.premultipliedAlpha,this.forceSinglePass=e.forceSinglePass,this.visible=e.visible,this.toneMapped=e.toneMapped,this.userData=JSON.parse(JSON.stringify(e.userData)),this}dispose(){this.dispatchEvent({type:\"dispose\"})}set needsUpdate(e){e===!0&&this.version++}}class pJ extends Cy{constructor(e){super(),this.isMeshBasicMaterial=!0,this.type=\"MeshBasicMaterial\",this.color=new Mn(16777215),this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.combine=oI,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap=\"round\",this.wireframeLinejoin=\"round\",this.fog=!0,this.setValues(e)}copy(e){return super.copy(e),this.color.copy(e.color),this.map=e.map,this.lightMap=e.lightMap,this.lightMapIntensity=e.lightMapIntensity,this.aoMap=e.aoMap,this.aoMapIntensity=e.aoMapIntensity,this.specularMap=e.specularMap,this.alphaMap=e.alphaMap,this.envMap=e.envMap,this.combine=e.combine,this.reflectivity=e.reflectivity,this.refractionRatio=e.refractionRatio,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.wireframeLinecap=e.wireframeLinecap,this.wireframeLinejoin=e.wireframeLinejoin,this.fog=e.fog,this}}const xi=new ut,E1=new Vn;class fl{constructor(e,n,r=!1){if(Array.isArray(e))throw new TypeError(\"THREE.BufferAttribute: array should be a Typed Array.\");this.isBufferAttribute=!0,this.name=\"\",this.array=e,this.itemSize=n,this.count=e!==void 0?e.length/n:0,this.normalized=r,this.usage=mR,this._updateRange={offset:0,count:-1},this.updateRanges=[],this.gpuType=tm,this.version=0}onUploadCallback(){}set needsUpdate(e){e===!0&&this.version++}get updateRange(){return console.warn('THREE.BufferAttribute: \"updateRange\" is deprecated and removed in r169. Use \"addUpdateRange()\" instead.'),this._updateRange}setUsage(e){return this.usage=e,this}addUpdateRange(e,n){this.updateRanges.push({start:e,count:n})}clearUpdateRanges(){this.updateRanges.length=0}copy(e){return this.name=e.name,this.array=new e.array.constructor(e.array),this.itemSize=e.itemSize,this.count=e.count,this.normalized=e.normalized,this.usage=e.usage,this.gpuType=e.gpuType,this}copyAt(e,n,r){e*=this.itemSize,r*=n.itemSize;for(let i=0,s=this.itemSize;i<s;i++)this.array[e+i]=n.array[r+i];return this}copyArray(e){return this.array.set(e),this}applyMatrix3(e){if(this.itemSize===2)for(let n=0,r=this.count;n<r;n++)E1.fromBufferAttribute(this,n),E1.applyMatrix3(e),this.setXY(n,E1.x,E1.y);else if(this.itemSize===3)for(let n=0,r=this.count;n<r;n++)xi.fromBufferAttribute(this,n),xi.applyMatrix3(e),this.setXYZ(n,xi.x,xi.y,xi.z);return this}applyMatrix4(e){for(let n=0,r=this.count;n<r;n++)xi.fromBufferAttribute(this,n),xi.applyMatrix4(e),this.setXYZ(n,xi.x,xi.y,xi.z);return this}applyNormalMatrix(e){for(let n=0,r=this.count;n<r;n++)xi.fromBufferAttribute(this,n),xi.applyNormalMatrix(e),this.setXYZ(n,xi.x,xi.y,xi.z);return this}transformDirection(e){for(let n=0,r=this.count;n<r;n++)xi.fromBufferAttribute(this,n),xi.transformDirection(e),this.setXYZ(n,xi.x,xi.y,xi.z);return this}set(e,n=0){return this.array.set(e,n),this}getComponent(e,n){let r=this.array[e*this.itemSize+n];return this.normalized&&(r=Td(r,this.array)),r}setComponent(e,n,r){return this.normalized&&(r=Zr(r,this.array)),this.array[e*this.itemSize+n]=r,this}getX(e){let n=this.array[e*this.itemSize];return this.normalized&&(n=Td(n,this.array)),n}setX(e,n){return this.normalized&&(n=Zr(n,this.array)),this.array[e*this.itemSize]=n,this}getY(e){let n=this.array[e*this.itemSize+1];return this.normalized&&(n=Td(n,this.array)),n}setY(e,n){return this.normalized&&(n=Zr(n,this.array)),this.array[e*this.itemSize+1]=n,this}getZ(e){let n=this.array[e*this.itemSize+2];return this.normalized&&(n=Td(n,this.array)),n}setZ(e,n){return this.normalized&&(n=Zr(n,this.array)),this.array[e*this.itemSize+2]=n,this}getW(e){let n=this.array[e*this.itemSize+3];return this.normalized&&(n=Td(n,this.array)),n}setW(e,n){return this.normalized&&(n=Zr(n,this.array)),this.array[e*this.itemSize+3]=n,this}setXY(e,n,r){return e*=this.itemSize,this.normalized&&(n=Zr(n,this.array),r=Zr(r,this.array)),this.array[e+0]=n,this.array[e+1]=r,this}setXYZ(e,n,r,i){return e*=this.itemSize,this.normalized&&(n=Zr(n,this.array),r=Zr(r,this.array),i=Zr(i,this.array)),this.array[e+0]=n,this.array[e+1]=r,this.array[e+2]=i,this}setXYZW(e,n,r,i,s){return e*=this.itemSize,this.normalized&&(n=Zr(n,this.array),r=Zr(r,this.array),i=Zr(i,this.array),s=Zr(s,this.array)),this.array[e+0]=n,this.array[e+1]=r,this.array[e+2]=i,this.array[e+3]=s,this}onUpload(e){return this.onUploadCallback=e,this}clone(){return new this.constructor(this.array,this.itemSize).copy(this)}toJSON(){const e={itemSize:this.itemSize,type:this.array.constructor.name,array:Array.from(this.array),normalized:this.normalized};return this.name!==\"\"&&(e.name=this.name),this.usage!==mR&&(e.usage=this.usage),e}}class fJ extends fl{constructor(e,n,r){super(new Uint16Array(e),n,r)}}class gJ extends fl{constructor(e,n,r){super(new Uint32Array(e),n,r)}}class li extends fl{constructor(e,n,r){super(new Float32Array(e),n,r)}}let uCe=0;const Ol=new ba,ME=new lo,Kb=new ut,sl=new ac,qv=new ac,Vi=new ut;class Vs extends Bg{constructor(){super(),this.isBufferGeometry=!0,Object.defineProperty(this,\"id\",{value:uCe++}),this.uuid=dm(),this.name=\"\",this.type=\"BufferGeometry\",this.index=null,this.attributes={},this.morphAttributes={},this.morphTargetsRelative=!1,this.groups=[],this.boundingBox=null,this.boundingSphere=null,this.drawRange={start:0,count:1/0},this.userData={}}getIndex(){return this.index}setIndex(e){return Array.isArray(e)?this.index=new(lJ(e)?gJ:fJ)(e,1):this.index=e,this}getAttribute(e){return this.attributes[e]}setAttribute(e,n){return this.attributes[e]=n,this}deleteAttribute(e){return delete this.attributes[e],this}hasAttribute(e){return this.attributes[e]!==void 0}addGroup(e,n,r=0){this.groups.push({start:e,count:n,materialIndex:r})}clearGroups(){this.groups=[]}setDrawRange(e,n){this.drawRange.start=e,this.drawRange.count=n}applyMatrix4(e){const n=this.attributes.position;n!==void 0&&(n.applyMatrix4(e),n.needsUpdate=!0);const r=this.attributes.normal;if(r!==void 0){const s=new mr().getNormalMatrix(e);r.applyNormalMatrix(s),r.needsUpdate=!0}const i=this.attributes.tangent;return i!==void 0&&(i.transformDirection(e),i.needsUpdate=!0),this.boundingBox!==null&&this.computeBoundingBox(),this.boundingSphere!==null&&this.computeBoundingSphere(),this}applyQuaternion(e){return Ol.makeRotationFromQuaternion(e),this.applyMatrix4(Ol),this}rotateX(e){return Ol.makeRotationX(e),this.applyMatrix4(Ol),this}rotateY(e){return Ol.makeRotationY(e),this.applyMatrix4(Ol),this}rotateZ(e){return Ol.makeRotationZ(e),this.applyMatrix4(Ol),this}translate(e,n,r){return Ol.makeTranslation(e,n,r),this.applyMatrix4(Ol),this}scale(e,n,r){return Ol.makeScale(e,n,r),this.applyMatrix4(Ol),this}lookAt(e){return ME.lookAt(e),ME.updateMatrix(),this.applyMatrix4(ME.matrix),this}center(){return this.computeBoundingBox(),this.boundingBox.getCenter(Kb).negate(),this.translate(Kb.x,Kb.y,Kb.z),this}setFromPoints(e){const n=[];for(let r=0,i=e.length;r<i;r++){const s=e[r];n.push(s.x,s.y,s.z||0)}return this.setAttribute(\"position\",new li(n,3)),this}computeBoundingBox(){this.boundingBox===null&&(this.boundingBox=new ac);const e=this.attributes.position,n=this.morphAttributes.position;if(e&&e.isGLBufferAttribute){console.error('THREE.BufferGeometry.computeBoundingBox(): GLBufferAttribute requires a manual bounding box. Alternatively set \"mesh.frustumCulled\" to \"false\".',this),this.boundingBox.set(new ut(-1/0,-1/0,-1/0),new ut(1/0,1/0,1/0));return}if(e!==void 0){if(this.boundingBox.setFromBufferAttribute(e),n)for(let r=0,i=n.length;r<i;r++){const s=n[r];sl.setFromBufferAttribute(s),this.morphTargetsRelative?(Vi.addVectors(this.boundingBox.min,sl.min),this.boundingBox.expandByPoint(Vi),Vi.addVectors(this.boundingBox.max,sl.max),this.boundingBox.expandByPoint(Vi)):(this.boundingBox.expandByPoint(sl.min),this.boundingBox.expandByPoint(sl.max))}}else this.boundingBox.makeEmpty();(isNaN(this.boundingBox.min.x)||isNaN(this.boundingBox.min.y)||isNaN(this.boundingBox.min.z))&&console.error('THREE.BufferGeometry.computeBoundingBox(): Computed min/max have NaN values. The \"position\" attribute is likely to have NaN values.',this)}computeBoundingSphere(){this.boundingSphere===null&&(this.boundingSphere=new Ld);const e=this.attributes.position,n=this.morphAttributes.position;if(e&&e.isGLBufferAttribute){console.error('THREE.BufferGeometry.computeBoundingSphere(): GLBufferAttribute requires a manual bounding sphere. Alternatively set \"mesh.frustumCulled\" to \"false\".',this),this.boundingSphere.set(new ut,1/0);return}if(e){const r=this.boundingSphere.center;if(sl.setFromBufferAttribute(e),n)for(let s=0,o=n.length;s<o;s++){const l=n[s];qv.setFromBufferAttribute(l),this.morphTargetsRelative?(Vi.addVectors(sl.min,qv.min),sl.expandByPoint(Vi),Vi.addVectors(sl.max,qv.max),sl.expandByPoint(Vi)):(sl.expandByPoint(qv.min),sl.expandByPoint(qv.max))}sl.getCenter(r);let i=0;for(let s=0,o=e.count;s<o;s++)Vi.fromBufferAttribute(e,s),i=Math.max(i,r.distanceToSquared(Vi));if(n)for(let s=0,o=n.length;s<o;s++){const l=n[s],c=this.morphTargetsRelative;for(let d=0,u=l.count;d<u;d++)Vi.fromBufferAttribute(l,d),c&&(Kb.fromBufferAttribute(e,d),Vi.add(Kb)),i=Math.max(i,r.distanceToSquared(Vi))}this.boundingSphere.radius=Math.sqrt(i),isNaN(this.boundingSphere.radius)&&console.error('THREE.BufferGeometry.computeBoundingSphere(): Computed radius is NaN. The \"position\" attribute is likely to have NaN values.',this)}}computeTangents(){const e=this.index,n=this.attributes;if(e===null||n.position===void 0||n.normal===void 0||n.uv===void 0){console.error(\"THREE.BufferGeometry: .computeTangents() failed. Missing required attributes (index, position, normal or uv)\");return}const r=e.array,i=n.position.array,s=n.normal.array,o=n.uv.array,l=i.length/3;this.hasAttribute(\"tangent\")===!1&&this.setAttribute(\"tangent\",new fl(new Float32Array(4*l),4));const c=this.getAttribute(\"tangent\").array,d=[],u=[];for(let D=0;D<l;D++)d[D]=new ut,u[D]=new ut;const m=new ut,p=new ut,f=new ut,y=new Vn,v=new Vn,b=new Vn,g=new ut,_=new ut;function C(D,H,z){m.fromArray(i,D*3),p.fromArray(i,H*3),f.fromArray(i,z*3),y.fromArray(o,D*2),v.fromArray(o,H*2),b.fromArray(o,z*2),p.sub(m),f.sub(m),v.sub(y),b.sub(y);const Q=1/(v.x*b.y-b.x*v.y);isFinite(Q)&&(g.copy(p).multiplyScalar(b.y).addScaledVector(f,-v.y).multiplyScalar(Q),_.copy(f).multiplyScalar(v.x).addScaledVector(p,-b.x).multiplyScalar(Q),d[D].add(g),d[H].add(g),d[z].add(g),u[D].add(_),u[H].add(_),u[z].add(_))}let P=this.groups;P.length===0&&(P=[{start:0,count:r.length}]);for(let D=0,H=P.length;D<H;++D){const z=P[D],Q=z.start,L=z.count;for(let te=Q,ie=Q+L;te<ie;te+=3)C(r[te+0],r[te+1],r[te+2])}const N=new ut,A=new ut,T=new ut,F=new ut;function k(D){T.fromArray(s,D*3),F.copy(T);const H=d[D];N.copy(H),N.sub(T.multiplyScalar(T.dot(H))).normalize(),A.crossVectors(F,H);const Q=A.dot(u[D])<0?-1:1;c[D*4]=N.x,c[D*4+1]=N.y,c[D*4+2]=N.z,c[D*4+3]=Q}for(let D=0,H=P.length;D<H;++D){const z=P[D],Q=z.start,L=z.count;for(let te=Q,ie=Q+L;te<ie;te+=3)k(r[te+0]),k(r[te+1]),k(r[te+2])}}computeVertexNormals(){const e=this.index,n=this.getAttribute(\"position\");if(n!==void 0){let r=this.getAttribute(\"normal\");if(r===void 0)r=new fl(new Float32Array(n.count*3),3),this.setAttribute(\"normal\",r);else for(let p=0,f=r.count;p<f;p++)r.setXYZ(p,0,0,0);const i=new ut,s=new ut,o=new ut,l=new ut,c=new ut,d=new ut,u=new ut,m=new ut;if(e)for(let p=0,f=e.count;p<f;p+=3){const y=e.getX(p+0),v=e.getX(p+1),b=e.getX(p+2);i.fromBufferAttribute(n,y),s.fromBufferAttribute(n,v),o.fromBufferAttribute(n,b),u.subVectors(o,s),m.subVectors(i,s),u.cross(m),l.fromBufferAttribute(r,y),c.fromBufferAttribute(r,v),d.fromBufferAttribute(r,b),l.add(u),c.add(u),d.add(u),r.setXYZ(y,l.x,l.y,l.z),r.setXYZ(v,c.x,c.y,c.z),r.setXYZ(b,d.x,d.y,d.z)}else for(let p=0,f=n.count;p<f;p+=3)i.fromBufferAttribute(n,p+0),s.fromBufferAttribute(n,p+1),o.fromBufferAttribute(n,p+2),u.subVectors(o,s),m.subVectors(i,s),u.cross(m),r.setXYZ(p+0,u.x,u.y,u.z),r.setXYZ(p+1,u.x,u.y,u.z),r.setXYZ(p+2,u.x,u.y,u.z);this.normalizeNormals(),r.needsUpdate=!0}}normalizeNormals(){const e=this.attributes.normal;for(let n=0,r=e.count;n<r;n++)Vi.fromBufferAttribute(e,n),Vi.normalize(),e.setXYZ(n,Vi.x,Vi.y,Vi.z)}toNonIndexed(){function e(l,c){const d=l.array,u=l.itemSize,m=l.normalized,p=new d.constructor(c.length*u);let f=0,y=0;for(let v=0,b=c.length;v<b;v++){l.isInterleavedBufferAttribute?f=c[v]*l.data.stride+l.offset:f=c[v]*u;for(let g=0;g<u;g++)p[y++]=d[f++]}return new fl(p,u,m)}if(this.index===null)return console.warn(\"THREE.BufferGeometry.toNonIndexed(): BufferGeometry is already non-indexed.\"),this;const n=new Vs,r=this.index.array,i=this.attributes;for(const l in i){const c=i[l],d=e(c,r);n.setAttribute(l,d)}const s=this.morphAttributes;for(const l in s){const c=[],d=s[l];for(let u=0,m=d.length;u<m;u++){const p=d[u],f=e(p,r);c.push(f)}n.morphAttributes[l]=c}n.morphTargetsRelative=this.morphTargetsRelative;const o=this.groups;for(let l=0,c=o.length;l<c;l++){const d=o[l];n.addGroup(d.start,d.count,d.materialIndex)}return n}toJSON(){const e={metadata:{version:4.6,type:\"BufferGeometry\",generator:\"BufferGeometry.toJSON\"}};if(e.uuid=this.uuid,e.type=this.type,this.name!==\"\"&&(e.name=this.name),Object.keys(this.userData).length>0&&(e.userData=this.userData),this.parameters!==void 0){const c=this.parameters;for(const d in c)c[d]!==void 0&&(e[d]=c[d]);return e}e.data={attributes:{}};const n=this.index;n!==null&&(e.data.index={type:n.array.constructor.name,array:Array.prototype.slice.call(n.array)});const r=this.attributes;for(const c in r){const d=r[c];e.data.attributes[c]=d.toJSON(e.data)}const i={};let s=!1;for(const c in this.morphAttributes){const d=this.morphAttributes[c],u=[];for(let m=0,p=d.length;m<p;m++){const f=d[m];u.push(f.toJSON(e.data))}u.length>0&&(i[c]=u,s=!0)}s&&(e.data.morphAttributes=i,e.data.morphTargetsRelative=this.morphTargetsRelative);const o=this.groups;o.length>0&&(e.data.groups=JSON.parse(JSON.stringify(o)));const l=this.boundingSphere;return l!==null&&(e.data.boundingSphere={center:l.center.toArray(),radius:l.radius}),e}clone(){return new this.constructor().copy(this)}copy(e){this.index=null,this.attributes={},this.morphAttributes={},this.groups=[],this.boundingBox=null,this.boundingSphere=null;const n={};this.name=e.name;const r=e.index;r!==null&&this.setIndex(r.clone(n));const i=e.attributes;for(const d in i){const u=i[d];this.setAttribute(d,u.clone(n))}const s=e.morphAttributes;for(const d in s){const u=[],m=s[d];for(let p=0,f=m.length;p<f;p++)u.push(m[p].clone(n));this.morphAttributes[d]=u}this.morphTargetsRelative=e.morphTargetsRelative;const o=e.groups;for(let d=0,u=o.length;d<u;d++){const m=o[d];this.addGroup(m.start,m.count,m.materialIndex)}const l=e.boundingBox;l!==null&&(this.boundingBox=l.clone());const c=e.boundingSphere;return c!==null&&(this.boundingSphere=c.clone()),this.drawRange.start=e.drawRange.start,this.drawRange.count=e.drawRange.count,this.userData=e.userData,this}dispose(){this.dispatchEvent({type:\"dispose\"})}}const $q=new ba,gf=new uI,D1=new Ld,Vq=new ut,Xb=new ut,Yb=new ut,Qb=new ut,EE=new ut,F1=new ut,R1=new Vn,L1=new Vn,O1=new Vn,Gq=new ut,Wq=new ut,Kq=new ut,I1=new ut,z1=new ut;class Wc extends lo{constructor(e=new Vs,n=new pJ){super(),this.isMesh=!0,this.type=\"Mesh\",this.geometry=e,this.material=n,this.updateMorphTargets()}copy(e,n){return super.copy(e,n),e.morphTargetInfluences!==void 0&&(this.morphTargetInfluences=e.morphTargetInfluences.slice()),e.morphTargetDictionary!==void 0&&(this.morphTargetDictionary=Object.assign({},e.morphTargetDictionary)),this.material=Array.isArray(e.material)?e.material.slice():e.material,this.geometry=e.geometry,this}updateMorphTargets(){const n=this.geometry.morphAttributes,r=Object.keys(n);if(r.length>0){const i=n[r[0]];if(i!==void 0){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let s=0,o=i.length;s<o;s++){const l=i[s].name||String(s);this.morphTargetInfluences.push(0),this.morphTargetDictionary[l]=s}}}}getVertexPosition(e,n){const r=this.geometry,i=r.attributes.position,s=r.morphAttributes.position,o=r.morphTargetsRelative;n.fromBufferAttribute(i,e);const l=this.morphTargetInfluences;if(s&&l){F1.set(0,0,0);for(let c=0,d=s.length;c<d;c++){const u=l[c],m=s[c];u!==0&&(EE.fromBufferAttribute(m,e),o?F1.addScaledVector(EE,u):F1.addScaledVector(EE.sub(n),u))}n.add(F1)}return n}raycast(e,n){const r=this.geometry,i=this.material,s=this.matrixWorld;i!==void 0&&(r.boundingSphere===null&&r.computeBoundingSphere(),D1.copy(r.boundingSphere),D1.applyMatrix4(s),gf.copy(e.ray).recast(e.near),!(D1.containsPoint(gf.origin)===!1&&(gf.intersectSphere(D1,Vq)===null||gf.origin.distanceToSquared(Vq)>(e.far-e.near)**2))&&($q.copy(s).invert(),gf.copy(e.ray).applyMatrix4($q),!(r.boundingBox!==null&&gf.intersectsBox(r.boundingBox)===!1)&&this._computeIntersections(e,n,gf)))}_computeIntersections(e,n,r){let i;const s=this.geometry,o=this.material,l=s.index,c=s.attributes.position,d=s.attributes.uv,u=s.attributes.uv1,m=s.attributes.normal,p=s.groups,f=s.drawRange;if(l!==null)if(Array.isArray(o))for(let y=0,v=p.length;y<v;y++){const b=p[y],g=o[b.materialIndex],_=Math.max(b.start,f.start),C=Math.min(l.count,Math.min(b.start+b.count,f.start+f.count));for(let P=_,N=C;P<N;P+=3){const A=l.getX(P),T=l.getX(P+1),F=l.getX(P+2);i=U1(this,g,e,r,d,u,m,A,T,F),i&&(i.faceIndex=Math.floor(P/3),i.face.materialIndex=b.materialIndex,n.push(i))}}else{const y=Math.max(0,f.start),v=Math.min(l.count,f.start+f.count);for(let b=y,g=v;b<g;b+=3){const _=l.getX(b),C=l.getX(b+1),P=l.getX(b+2);i=U1(this,o,e,r,d,u,m,_,C,P),i&&(i.faceIndex=Math.floor(b/3),n.push(i))}}else if(c!==void 0)if(Array.isArray(o))for(let y=0,v=p.length;y<v;y++){const b=p[y],g=o[b.materialIndex],_=Math.max(b.start,f.start),C=Math.min(c.count,Math.min(b.start+b.count,f.start+f.count));for(let P=_,N=C;P<N;P+=3){const A=P,T=P+1,F=P+2;i=U1(this,g,e,r,d,u,m,A,T,F),i&&(i.faceIndex=Math.floor(P/3),i.face.materialIndex=b.materialIndex,n.push(i))}}else{const y=Math.max(0,f.start),v=Math.min(c.count,f.start+f.count);for(let b=y,g=v;b<g;b+=3){const _=b,C=b+1,P=b+2;i=U1(this,o,e,r,d,u,m,_,C,P),i&&(i.faceIndex=Math.floor(b/3),n.push(i))}}}}function mCe(t,e,n,r,i,s,o,l){let c;if(e.side===Uo?c=r.intersectTriangle(o,s,i,!0,l):c=r.intersectTriangle(i,s,o,e.side===yp,l),c===null)return null;z1.copy(l),z1.applyMatrix4(t.matrixWorld);const d=n.ray.origin.distanceTo(z1);return d<n.near||d>n.far?null:{distance:d,point:z1.clone(),object:t}}function U1(t,e,n,r,i,s,o,l,c,d){t.getVertexPosition(l,Xb),t.getVertexPosition(c,Yb),t.getVertexPosition(d,Qb);const u=mCe(t,e,n,r,Xb,Yb,Qb,I1);if(u){i&&(R1.fromBufferAttribute(i,l),L1.fromBufferAttribute(i,c),O1.fromBufferAttribute(i,d),u.uv=zc.getInterpolation(I1,Xb,Yb,Qb,R1,L1,O1,new Vn)),s&&(R1.fromBufferAttribute(s,l),L1.fromBufferAttribute(s,c),O1.fromBufferAttribute(s,d),u.uv1=zc.getInterpolation(I1,Xb,Yb,Qb,R1,L1,O1,new Vn),u.uv2=u.uv1),o&&(Gq.fromBufferAttribute(o,l),Wq.fromBufferAttribute(o,c),Kq.fromBufferAttribute(o,d),u.normal=zc.getInterpolation(I1,Xb,Yb,Qb,Gq,Wq,Kq,new ut),u.normal.dot(r.direction)>0&&u.normal.multiplyScalar(-1));const m={a:l,b:c,c:d,normal:new ut,materialIndex:0};zc.getNormal(Xb,Yb,Qb,m.normal),u.face=m}return u}class dS extends Vs{constructor(e=1,n=1,r=1,i=1,s=1,o=1){super(),this.type=\"BoxGeometry\",this.parameters={width:e,height:n,depth:r,widthSegments:i,heightSegments:s,depthSegments:o};const l=this;i=Math.floor(i),s=Math.floor(s),o=Math.floor(o);const c=[],d=[],u=[],m=[];let p=0,f=0;y(\"z\",\"y\",\"x\",-1,-1,r,n,e,o,s,0),y(\"z\",\"y\",\"x\",1,-1,r,n,-e,o,s,1),y(\"x\",\"z\",\"y\",1,1,e,r,n,i,o,2),y(\"x\",\"z\",\"y\",1,-1,e,r,-n,i,o,3),y(\"x\",\"y\",\"z\",1,-1,e,n,r,i,s,4),y(\"x\",\"y\",\"z\",-1,-1,e,n,-r,i,s,5),this.setIndex(c),this.setAttribute(\"position\",new li(d,3)),this.setAttribute(\"normal\",new li(u,3)),this.setAttribute(\"uv\",new li(m,2));function y(v,b,g,_,C,P,N,A,T,F,k){const D=P/T,H=N/F,z=P/2,Q=N/2,L=A/2,te=T+1,ie=F+1;let J=0,oe=0;const fe=new ut;for(let re=0;re<ie;re++){const W=re*H-Q;for(let ne=0;ne<te;ne++){const me=ne*D-z;fe[v]=me*_,fe[b]=W*C,fe[g]=L,d.push(fe.x,fe.y,fe.z),fe[v]=0,fe[b]=0,fe[g]=A>0?1:-1,u.push(fe.x,fe.y,fe.z),m.push(ne/T),m.push(1-re/F),J+=1}}for(let re=0;re<F;re++)for(let W=0;W<T;W++){const ne=p+W+te*re,me=p+W+te*(re+1),be=p+(W+1)+te*(re+1),Ce=p+(W+1)+te*re;c.push(ne,me,Ce),c.push(me,be,Ce),oe+=6}l.addGroup(f,oe,k),f+=oe,p+=J}}copy(e){return super.copy(e),this.parameters=Object.assign({},e.parameters),this}static fromJSON(e){return new dS(e.width,e.height,e.depth,e.widthSegments,e.heightSegments,e.depthSegments)}}function Jx(t){const e={};for(const n in t){e[n]={};for(const r in t[n]){const i=t[n][r];i&&(i.isColor||i.isMatrix3||i.isMatrix4||i.isVector2||i.isVector3||i.isVector4||i.isTexture||i.isQuaternion)?i.isRenderTargetTexture?(console.warn(\"UniformsUtils: Textures of render targets cannot be cloned via cloneUniforms() or mergeUniforms().\"),e[n][r]=null):e[n][r]=i.clone():Array.isArray(i)?e[n][r]=i.slice():e[n][r]=i}}return e}function to(t){const e={};for(let n=0;n<t.length;n++){const r=Jx(t[n]);for(const i in r)e[i]=r[i]}return e}function hCe(t){const e=[];for(let n=0;n<t.length;n++)e.push(t[n].clone());return e}function bJ(t){return t.getRenderTarget()===null?t.outputColorSpace:Jr.workingColorSpace}const mI={clone:Jx,merge:to};var pCe=`void main() {\n\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n}`,fCe=`void main() {\n\tgl_FragColor = vec4( 1.0, 0.0, 0.0, 1.0 );\n}`;class vp extends Cy{constructor(e){super(),this.isShaderMaterial=!0,this.type=\"ShaderMaterial\",this.defines={},this.uniforms={},this.uniformsGroups=[],this.vertexShader=pCe,this.fragmentShader=fCe,this.linewidth=1,this.wireframe=!1,this.wireframeLinewidth=1,this.fog=!1,this.lights=!1,this.clipping=!1,this.forceSinglePass=!0,this.extensions={derivatives:!1,fragDepth:!1,drawBuffers:!1,shaderTextureLOD:!1},this.defaultAttributeValues={color:[1,1,1],uv:[0,0],uv1:[0,0]},this.index0AttributeName=void 0,this.uniformsNeedUpdate=!1,this.glslVersion=null,e!==void 0&&this.setValues(e)}copy(e){return super.copy(e),this.fragmentShader=e.fragmentShader,this.vertexShader=e.vertexShader,this.uniforms=Jx(e.uniforms),this.uniformsGroups=hCe(e.uniformsGroups),this.defines=Object.assign({},e.defines),this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.fog=e.fog,this.lights=e.lights,this.clipping=e.clipping,this.extensions=Object.assign({},e.extensions),this.glslVersion=e.glslVersion,this}toJSON(e){const n=super.toJSON(e);n.glslVersion=this.glslVersion,n.uniforms={};for(const i in this.uniforms){const o=this.uniforms[i].value;o&&o.isTexture?n.uniforms[i]={type:\"t\",value:o.toJSON(e).uuid}:o&&o.isColor?n.uniforms[i]={type:\"c\",value:o.getHex()}:o&&o.isVector2?n.uniforms[i]={type:\"v2\",value:o.toArray()}:o&&o.isVector3?n.uniforms[i]={type:\"v3\",value:o.toArray()}:o&&o.isVector4?n.uniforms[i]={type:\"v4\",value:o.toArray()}:o&&o.isMatrix3?n.uniforms[i]={type:\"m3\",value:o.toArray()}:o&&o.isMatrix4?n.uniforms[i]={type:\"m4\",value:o.toArray()}:n.uniforms[i]={value:o}}Object.keys(this.defines).length>0&&(n.defines=this.defines),n.vertexShader=this.vertexShader,n.fragmentShader=this.fragmentShader,n.lights=this.lights,n.clipping=this.clipping;const r={};for(const i in this.extensions)this.extensions[i]===!0&&(r[i]=!0);return Object.keys(r).length>0&&(n.extensions=r),n}}class xJ extends lo{constructor(){super(),this.isCamera=!0,this.type=\"Camera\",this.matrixWorldInverse=new ba,this.projectionMatrix=new ba,this.projectionMatrixInverse=new ba,this.coordinateSystem=Ed}copy(e,n){return super.copy(e,n),this.matrixWorldInverse.copy(e.matrixWorldInverse),this.projectionMatrix.copy(e.projectionMatrix),this.projectionMatrixInverse.copy(e.projectionMatrixInverse),this.coordinateSystem=e.coordinateSystem,this}getWorldDirection(e){return super.getWorldDirection(e).negate()}updateMatrixWorld(e){super.updateMatrixWorld(e),this.matrixWorldInverse.copy(this.matrixWorld).invert()}updateWorldMatrix(e,n){super.updateWorldMatrix(e,n),this.matrixWorldInverse.copy(this.matrixWorld).invert()}clone(){return new this.constructor().copy(this)}}class ll extends xJ{constructor(e=50,n=1,r=.1,i=2e3){super(),this.isPerspectiveCamera=!0,this.type=\"PerspectiveCamera\",this.fov=e,this.zoom=1,this.near=r,this.far=i,this.focus=10,this.aspect=n,this.view=null,this.filmGauge=35,this.filmOffset=0,this.updateProjectionMatrix()}copy(e,n){return super.copy(e,n),this.fov=e.fov,this.zoom=e.zoom,this.near=e.near,this.far=e.far,this.focus=e.focus,this.aspect=e.aspect,this.view=e.view===null?null:Object.assign({},e.view),this.filmGauge=e.filmGauge,this.filmOffset=e.filmOffset,this}setFocalLength(e){const n=.5*this.getFilmHeight()/e;this.fov=gw*2*Math.atan(n),this.updateProjectionMatrix()}getFocalLength(){const e=Math.tan(j0*.5*this.fov);return .5*this.getFilmHeight()/e}getEffectiveFOV(){return gw*2*Math.atan(Math.tan(j0*.5*this.fov)/this.zoom)}getFilmWidth(){return this.filmGauge*Math.min(this.aspect,1)}getFilmHeight(){return this.filmGauge/Math.max(this.aspect,1)}setViewOffset(e,n,r,i,s,o){this.aspect=e/n,this.view===null&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=e,this.view.fullHeight=n,this.view.offsetX=r,this.view.offsetY=i,this.view.width=s,this.view.height=o,this.updateProjectionMatrix()}clearViewOffset(){this.view!==null&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const e=this.near;let n=e*Math.tan(j0*.5*this.fov)/this.zoom,r=2*n,i=this.aspect*r,s=-.5*i;const o=this.view;if(this.view!==null&&this.view.enabled){const c=o.fullWidth,d=o.fullHeight;s+=o.offsetX*i/c,n-=o.offsetY*r/d,i*=o.width/c,r*=o.height/d}const l=this.filmOffset;l!==0&&(s+=e*l/this.getFilmWidth()),this.projectionMatrix.makePerspective(s,s+i,n,n-r,e,this.far,this.coordinateSystem),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(e){const n=super.toJSON(e);return n.object.fov=this.fov,n.object.zoom=this.zoom,n.object.near=this.near,n.object.far=this.far,n.object.focus=this.focus,n.object.aspect=this.aspect,this.view!==null&&(n.object.view=Object.assign({},this.view)),n.object.filmGauge=this.filmGauge,n.object.filmOffset=this.filmOffset,n}}const Zb=-90,Jb=1;class gCe extends lo{constructor(e,n,r){super(),this.type=\"CubeCamera\",this.renderTarget=r,this.coordinateSystem=null,this.activeMipmapLevel=0;const i=new ll(Zb,Jb,e,n);i.layers=this.layers,this.add(i);const s=new ll(Zb,Jb,e,n);s.layers=this.layers,this.add(s);const o=new ll(Zb,Jb,e,n);o.layers=this.layers,this.add(o);const l=new ll(Zb,Jb,e,n);l.layers=this.layers,this.add(l);const c=new ll(Zb,Jb,e,n);c.layers=this.layers,this.add(c);const d=new ll(Zb,Jb,e,n);d.layers=this.layers,this.add(d)}updateCoordinateSystem(){const e=this.coordinateSystem,n=this.children.concat(),[r,i,s,o,l,c]=n;for(const d of n)this.remove(d);if(e===Ed)r.up.set(0,1,0),r.lookAt(1,0,0),i.up.set(0,1,0),i.lookAt(-1,0,0),s.up.set(0,0,-1),s.lookAt(0,1,0),o.up.set(0,0,1),o.lookAt(0,-1,0),l.up.set(0,1,0),l.lookAt(0,0,1),c.up.set(0,1,0),c.lookAt(0,0,-1);else if(e===fw)r.up.set(0,-1,0),r.lookAt(-1,0,0),i.up.set(0,-1,0),i.lookAt(1,0,0),s.up.set(0,0,1),s.lookAt(0,1,0),o.up.set(0,0,-1),o.lookAt(0,-1,0),l.up.set(0,-1,0),l.lookAt(0,0,1),c.up.set(0,-1,0),c.lookAt(0,0,-1);else throw new Error(\"THREE.CubeCamera.updateCoordinateSystem(): Invalid coordinate system: \"+e);for(const d of n)this.add(d),d.updateMatrixWorld()}update(e,n){this.parent===null&&this.updateMatrixWorld();const{renderTarget:r,activeMipmapLevel:i}=this;this.coordinateSystem!==e.coordinateSystem&&(this.coordinateSystem=e.coordinateSystem,this.updateCoordinateSystem());const[s,o,l,c,d,u]=this.children,m=e.getRenderTarget(),p=e.getActiveCubeFace(),f=e.getActiveMipmapLevel(),y=e.xr.enabled;e.xr.enabled=!1;const v=r.texture.generateMipmaps;r.texture.generateMipmaps=!1,e.setRenderTarget(r,0,i),e.render(n,s),e.setRenderTarget(r,1,i),e.render(n,o),e.setRenderTarget(r,2,i),e.render(n,l),e.setRenderTarget(r,3,i),e.render(n,c),e.setRenderTarget(r,4,i),e.render(n,d),r.texture.generateMipmaps=v,e.setRenderTarget(r,5,i),e.render(n,u),e.setRenderTarget(m,p,f),e.xr.enabled=y,r.texture.needsPMREMUpdate=!0}}class yJ extends Bo{constructor(e,n,r,i,s,o,l,c,d,u){e=e!==void 0?e:[],n=n!==void 0?n:Yx,super(e,n,r,i,s,o,l,c,d,u),this.isCubeTexture=!0,this.flipY=!1}get images(){return this.image}set images(e){this.image=e}}class bCe extends xg{constructor(e=1,n={}){super(e,e,n),this.isWebGLCubeRenderTarget=!0;const r={width:e,height:e,depth:1},i=[r,r,r,r,r,r];n.encoding!==void 0&&(E0(\"THREE.WebGLCubeRenderTarget: option.encoding has been replaced by option.colorSpace.\"),n.colorSpace=n.encoding===Yf?bs:Ql),this.texture=new yJ(i,n.mapping,n.wrapS,n.wrapT,n.magFilter,n.minFilter,n.format,n.type,n.anisotropy,n.colorSpace),this.texture.isRenderTargetTexture=!0,this.texture.generateMipmaps=n.generateMipmaps!==void 0?n.generateMipmaps:!1,this.texture.minFilter=n.minFilter!==void 0?n.minFilter:Xl}fromEquirectangularTexture(e,n){this.texture.type=n.type,this.texture.colorSpace=n.colorSpace,this.texture.generateMipmaps=n.generateMipmaps,this.texture.minFilter=n.minFilter,this.texture.magFilter=n.magFilter;const r={uniforms:{tEquirect:{value:null}},vertexShader:`\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\tvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\n\t\t\t\t\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvWorldDirection = transformDirection( position, modelMatrix );\n\n\t\t\t\t\t#include <begin_vertex>\n\t\t\t\t\t#include <project_vertex>\n\n\t\t\t\t}\n\t\t\t`,fragmentShader:`\n\n\t\t\t\tuniform sampler2D tEquirect;\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\t#include <common>\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvec3 direction = normalize( vWorldDirection );\n\n\t\t\t\t\tvec2 sampleUV = equirectUv( direction );\n\n\t\t\t\t\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\n\t\t\t\t}\n\t\t\t`},i=new dS(5,5,5),s=new vp({name:\"CubemapFromEquirect\",uniforms:Jx(r.uniforms),vertexShader:r.vertexShader,fragmentShader:r.fragmentShader,side:Uo,blending:op});s.uniforms.tEquirect.value=n;const o=new Wc(i,s),l=n.minFilter;return n.minFilter===hw&&(n.minFilter=Xl),new gCe(1,10,this).update(e,o),n.minFilter=l,o.geometry.dispose(),o.material.dispose(),this}clear(e,n,r,i){const s=e.getRenderTarget();for(let o=0;o<6;o++)e.setRenderTarget(this,o),e.clear(n,r,i);e.setRenderTarget(s)}}const DE=new ut,xCe=new ut,yCe=new mr;class Th{constructor(e=new ut(1,0,0),n=0){this.isPlane=!0,this.normal=e,this.constant=n}set(e,n){return this.normal.copy(e),this.constant=n,this}setComponents(e,n,r,i){return this.normal.set(e,n,r),this.constant=i,this}setFromNormalAndCoplanarPoint(e,n){return this.normal.copy(e),this.constant=-n.dot(this.normal),this}setFromCoplanarPoints(e,n,r){const i=DE.subVectors(r,n).cross(xCe.subVectors(e,n)).normalize();return this.setFromNormalAndCoplanarPoint(i,e),this}copy(e){return this.normal.copy(e.normal),this.constant=e.constant,this}normalize(){const e=1/this.normal.length();return this.normal.multiplyScalar(e),this.constant*=e,this}negate(){return this.constant*=-1,this.normal.negate(),this}distanceToPoint(e){return this.normal.dot(e)+this.constant}distanceToSphere(e){return this.distanceToPoint(e.center)-e.radius}projectPoint(e,n){return n.copy(e).addScaledVector(this.normal,-this.distanceToPoint(e))}intersectLine(e,n){const r=e.delta(DE),i=this.normal.dot(r);if(i===0)return this.distanceToPoint(e.start)===0?n.copy(e.start):null;const s=-(e.start.dot(this.normal)+this.constant)/i;return s<0||s>1?null:n.copy(e.start).addScaledVector(r,s)}intersectsLine(e){const n=this.distanceToPoint(e.start),r=this.distanceToPoint(e.end);return n<0&&r>0||r<0&&n>0}intersectsBox(e){return e.intersectsPlane(this)}intersectsSphere(e){return e.intersectsPlane(this)}coplanarPoint(e){return e.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(e,n){const r=n||yCe.getNormalMatrix(e),i=this.coplanarPoint(DE).applyMatrix4(e),s=this.normal.applyMatrix3(r).normalize();return this.constant=-i.dot(s),this}translate(e){return this.constant-=e.dot(this.normal),this}equals(e){return e.normal.equals(this.normal)&&e.constant===this.constant}clone(){return new this.constructor().copy(this)}}const bf=new Ld,B1=new ut;class nT{constructor(e=new Th,n=new Th,r=new Th,i=new Th,s=new Th,o=new Th){this.planes=[e,n,r,i,s,o]}set(e,n,r,i,s,o){const l=this.planes;return l[0].copy(e),l[1].copy(n),l[2].copy(r),l[3].copy(i),l[4].copy(s),l[5].copy(o),this}copy(e){const n=this.planes;for(let r=0;r<6;r++)n[r].copy(e.planes[r]);return this}setFromProjectionMatrix(e,n=Ed){const r=this.planes,i=e.elements,s=i[0],o=i[1],l=i[2],c=i[3],d=i[4],u=i[5],m=i[6],p=i[7],f=i[8],y=i[9],v=i[10],b=i[11],g=i[12],_=i[13],C=i[14],P=i[15];if(r[0].setComponents(c-s,p-d,b-f,P-g).normalize(),r[1].setComponents(c+s,p+d,b+f,P+g).normalize(),r[2].setComponents(c+o,p+u,b+y,P+_).normalize(),r[3].setComponents(c-o,p-u,b-y,P-_).normalize(),r[4].setComponents(c-l,p-m,b-v,P-C).normalize(),n===Ed)r[5].setComponents(c+l,p+m,b+v,P+C).normalize();else if(n===fw)r[5].setComponents(l,m,v,C).normalize();else throw new Error(\"THREE.Frustum.setFromProjectionMatrix(): Invalid coordinate system: \"+n);return this}intersectsObject(e){if(e.boundingSphere!==void 0)e.boundingSphere===null&&e.computeBoundingSphere(),bf.copy(e.boundingSphere).applyMatrix4(e.matrixWorld);else{const n=e.geometry;n.boundingSphere===null&&n.computeBoundingSphere(),bf.copy(n.boundingSphere).applyMatrix4(e.matrixWorld)}return this.intersectsSphere(bf)}intersectsSprite(e){return bf.center.set(0,0,0),bf.radius=.7071067811865476,bf.applyMatrix4(e.matrixWorld),this.intersectsSphere(bf)}intersectsSphere(e){const n=this.planes,r=e.center,i=-e.radius;for(let s=0;s<6;s++)if(n[s].distanceToPoint(r)<i)return!1;return!0}intersectsBox(e){const n=this.planes;for(let r=0;r<6;r++){const i=n[r];if(B1.x=i.normal.x>0?e.max.x:e.min.x,B1.y=i.normal.y>0?e.max.y:e.min.y,B1.z=i.normal.z>0?e.max.z:e.min.z,i.distanceToPoint(B1)<0)return!1}return!0}containsPoint(e){const n=this.planes;for(let r=0;r<6;r++)if(n[r].distanceToPoint(e)<0)return!1;return!0}clone(){return new this.constructor().copy(this)}}function vJ(){let t=null,e=!1,n=null,r=null;function i(s,o){n(s,o),r=t.requestAnimationFrame(i)}return{start:function(){e!==!0&&n!==null&&(r=t.requestAnimationFrame(i),e=!0)},stop:function(){t.cancelAnimationFrame(r),e=!1},setAnimationLoop:function(s){n=s},setContext:function(s){t=s}}}function vCe(t,e){const n=e.isWebGL2,r=new WeakMap;function i(d,u){const m=d.array,p=d.usage,f=m.byteLength,y=t.createBuffer();t.bindBuffer(u,y),t.bufferData(u,m,p),d.onUploadCallback();let v;if(m instanceof Float32Array)v=t.FLOAT;else if(m instanceof Uint16Array)if(d.isFloat16BufferAttribute)if(n)v=t.HALF_FLOAT;else throw new Error(\"THREE.WebGLAttributes: Usage of Float16BufferAttribute requires WebGL2.\");else v=t.UNSIGNED_SHORT;else if(m instanceof Int16Array)v=t.SHORT;else if(m instanceof Uint32Array)v=t.UNSIGNED_INT;else if(m instanceof Int32Array)v=t.INT;else if(m instanceof Int8Array)v=t.BYTE;else if(m instanceof Uint8Array)v=t.UNSIGNED_BYTE;else if(m instanceof Uint8ClampedArray)v=t.UNSIGNED_BYTE;else throw new Error(\"THREE.WebGLAttributes: Unsupported buffer data format: \"+m);return{buffer:y,type:v,bytesPerElement:m.BYTES_PER_ELEMENT,version:d.version,size:f}}function s(d,u,m){const p=u.array,f=u._updateRange,y=u.updateRanges;if(t.bindBuffer(m,d),f.count===-1&&y.length===0&&t.bufferSubData(m,0,p),y.length!==0){for(let v=0,b=y.length;v<b;v++){const g=y[v];n?t.bufferSubData(m,g.start*p.BYTES_PER_ELEMENT,p,g.start,g.count):t.bufferSubData(m,g.start*p.BYTES_PER_ELEMENT,p.subarray(g.start,g.start+g.count))}u.clearUpdateRanges()}f.count!==-1&&(n?t.bufferSubData(m,f.offset*p.BYTES_PER_ELEMENT,p,f.offset,f.count):t.bufferSubData(m,f.offset*p.BYTES_PER_ELEMENT,p.subarray(f.offset,f.offset+f.count)),f.count=-1),u.onUploadCallback()}function o(d){return d.isInterleavedBufferAttribute&&(d=d.data),r.get(d)}function l(d){d.isInterleavedBufferAttribute&&(d=d.data);const u=r.get(d);u&&(t.deleteBuffer(u.buffer),r.delete(d))}function c(d,u){if(d.isGLBufferAttribute){const p=r.get(d);(!p||p.version<d.version)&&r.set(d,{buffer:d.buffer,type:d.type,bytesPerElement:d.elementSize,version:d.version});return}d.isInterleavedBufferAttribute&&(d=d.data);const m=r.get(d);if(m===void 0)r.set(d,i(d,u));else if(m.version<d.version){if(m.size!==d.array.byteLength)throw new Error(\"THREE.WebGLAttributes: The size of the buffer attribute's array buffer does not match the original size. Resizing buffer attributes is not supported.\");s(m.buffer,d,u),m.version=d.version}}return{get:o,remove:l,update:c}}class hI extends Vs{constructor(e=1,n=1,r=1,i=1){super(),this.type=\"PlaneGeometry\",this.parameters={width:e,height:n,widthSegments:r,heightSegments:i};const s=e/2,o=n/2,l=Math.floor(r),c=Math.floor(i),d=l+1,u=c+1,m=e/l,p=n/c,f=[],y=[],v=[],b=[];for(let g=0;g<u;g++){const _=g*p-o;for(let C=0;C<d;C++){const P=C*m-s;y.push(P,-_,0),v.push(0,0,1),b.push(C/l),b.push(1-g/c)}}for(let g=0;g<c;g++)for(let _=0;_<l;_++){const C=_+d*g,P=_+d*(g+1),N=_+1+d*(g+1),A=_+1+d*g;f.push(C,P,A),f.push(P,N,A)}this.setIndex(f),this.setAttribute(\"position\",new li(y,3)),this.setAttribute(\"normal\",new li(v,3)),this.setAttribute(\"uv\",new li(b,2))}copy(e){return super.copy(e),this.parameters=Object.assign({},e.parameters),this}static fromJSON(e){return new hI(e.width,e.height,e.widthSegments,e.heightSegments)}}var wCe=`#ifdef USE_ALPHAHASH\n\tif ( diffuseColor.a < getAlphaHashThreshold( vPosition ) ) discard;\n#endif`,SCe=`#ifdef USE_ALPHAHASH\n\tconst float ALPHA_HASH_SCALE = 0.05;\n\tfloat hash2D( vec2 value ) {\n\t\treturn fract( 1.0e4 * sin( 17.0 * value.x + 0.1 * value.y ) * ( 0.1 + abs( sin( 13.0 * value.y + value.x ) ) ) );\n\t}\n\tfloat hash3D( vec3 value ) {\n\t\treturn hash2D( vec2( hash2D( value.xy ), value.z ) );\n\t}\n\tfloat getAlphaHashThreshold( vec3 position ) {\n\t\tfloat maxDeriv = max(\n\t\t\tlength( dFdx( position.xyz ) ),\n\t\t\tlength( dFdy( position.xyz ) )\n\t\t);\n\t\tfloat pixScale = 1.0 / ( ALPHA_HASH_SCALE * maxDeriv );\n\t\tvec2 pixScales = vec2(\n\t\t\texp2( floor( log2( pixScale ) ) ),\n\t\t\texp2( ceil( log2( pixScale ) ) )\n\t\t);\n\t\tvec2 alpha = vec2(\n\t\t\thash3D( floor( pixScales.x * position.xyz ) ),\n\t\t\thash3D( floor( pixScales.y * position.xyz ) )\n\t\t);\n\t\tfloat lerpFactor = fract( log2( pixScale ) );\n\t\tfloat x = ( 1.0 - lerpFactor ) * alpha.x + lerpFactor * alpha.y;\n\t\tfloat a = min( lerpFactor, 1.0 - lerpFactor );\n\t\tvec3 cases = vec3(\n\t\t\tx * x / ( 2.0 * a * ( 1.0 - a ) ),\n\t\t\t( x - 0.5 * a ) / ( 1.0 - a ),\n\t\t\t1.0 - ( ( 1.0 - x ) * ( 1.0 - x ) / ( 2.0 * a * ( 1.0 - a ) ) )\n\t\t);\n\t\tfloat threshold = ( x < ( 1.0 - a ) )\n\t\t\t? ( ( x < a ) ? cases.x : cases.y )\n\t\t\t: cases.z;\n\t\treturn clamp( threshold , 1.0e-6, 1.0 );\n\t}\n#endif`,_Ce=`#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, vAlphaMapUv ).g;\n#endif`,kCe=`#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif`,NCe=`#ifdef USE_ALPHATEST\n\tif ( diffuseColor.a < alphaTest ) discard;\n#endif`,CCe=`#ifdef USE_ALPHATEST\n\tuniform float alphaTest;\n#endif`,PCe=`#ifdef USE_AOMAP\n\tfloat ambientOcclusion = ( texture2D( aoMap, vAoMapUv ).r - 1.0 ) * aoMapIntensity + 1.0;\n\treflectedLight.indirectDiffuse *= ambientOcclusion;\n\t#if defined( USE_CLEARCOAT ) \n\t\tclearcoatSpecularIndirect *= ambientOcclusion;\n\t#endif\n\t#if defined( USE_SHEEN ) \n\t\tsheenSpecularIndirect *= ambientOcclusion;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( STANDARD )\n\t\tfloat dotNV = saturate( dot( geometryNormal, geometryViewDir ) );\n\t\treflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.roughness );\n\t#endif\n#endif`,TCe=`#ifdef USE_AOMAP\n\tuniform sampler2D aoMap;\n\tuniform float aoMapIntensity;\n#endif`,ACe=`#ifdef USE_BATCHING\n\tattribute float batchId;\n\tuniform highp sampler2D batchingTexture;\n\tmat4 getBatchingMatrix( const in float i ) {\n\t\tint size = textureSize( batchingTexture, 0 ).x;\n\t\tint j = int( i ) * 4;\n\t\tint x = j % size;\n\t\tint y = j / size;\n\t\tvec4 v1 = texelFetch( batchingTexture, ivec2( x, y ), 0 );\n\t\tvec4 v2 = texelFetch( batchingTexture, ivec2( x + 1, y ), 0 );\n\t\tvec4 v3 = texelFetch( batchingTexture, ivec2( x + 2, y ), 0 );\n\t\tvec4 v4 = texelFetch( batchingTexture, ivec2( x + 3, y ), 0 );\n\t\treturn mat4( v1, v2, v3, v4 );\n\t}\n#endif`,jCe=`#ifdef USE_BATCHING\n\tmat4 batchingMatrix = getBatchingMatrix( batchId );\n#endif`,MCe=`vec3 transformed = vec3( position );\n#ifdef USE_ALPHAHASH\n\tvPosition = vec3( position );\n#endif`,ECe=`vec3 objectNormal = vec3( normal );\n#ifdef USE_TANGENT\n\tvec3 objectTangent = vec3( tangent.xyz );\n#endif`,DCe=`float G_BlinnPhong_Implicit( ) {\n\treturn 0.25;\n}\nfloat D_BlinnPhong( const in float shininess, const in float dotNH ) {\n\treturn RECIPROCAL_PI * ( shininess * 0.5 + 1.0 ) * pow( dotNH, shininess );\n}\nvec3 BRDF_BlinnPhong( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in vec3 specularColor, const in float shininess ) {\n\tvec3 halfDir = normalize( lightDir + viewDir );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat dotVH = saturate( dot( viewDir, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, 1.0, dotVH );\n\tfloat G = G_BlinnPhong_Implicit( );\n\tfloat D = D_BlinnPhong( shininess, dotNH );\n\treturn F * ( G * D );\n} // validated`,FCe=`#ifdef USE_IRIDESCENCE\n\tconst mat3 XYZ_TO_REC709 = mat3(\n\t\t 3.2404542, -0.9692660,  0.0556434,\n\t\t-1.5371385,  1.8760108, -0.2040259,\n\t\t-0.4985314,  0.0415560,  1.0572252\n\t);\n\tvec3 Fresnel0ToIor( vec3 fresnel0 ) {\n\t\tvec3 sqrtF0 = sqrt( fresnel0 );\n\t\treturn ( vec3( 1.0 ) + sqrtF0 ) / ( vec3( 1.0 ) - sqrtF0 );\n\t}\n\tvec3 IorToFresnel0( vec3 transmittedIor, float incidentIor ) {\n\t\treturn pow2( ( transmittedIor - vec3( incidentIor ) ) / ( transmittedIor + vec3( incidentIor ) ) );\n\t}\n\tfloat IorToFresnel0( float transmittedIor, float incidentIor ) {\n\t\treturn pow2( ( transmittedIor - incidentIor ) / ( transmittedIor + incidentIor ));\n\t}\n\tvec3 evalSensitivity( float OPD, vec3 shift ) {\n\t\tfloat phase = 2.0 * PI * OPD * 1.0e-9;\n\t\tvec3 val = vec3( 5.4856e-13, 4.4201e-13, 5.2481e-13 );\n\t\tvec3 pos = vec3( 1.6810e+06, 1.7953e+06, 2.2084e+06 );\n\t\tvec3 var = vec3( 4.3278e+09, 9.3046e+09, 6.6121e+09 );\n\t\tvec3 xyz = val * sqrt( 2.0 * PI * var ) * cos( pos * phase + shift ) * exp( - pow2( phase ) * var );\n\t\txyz.x += 9.7470e-14 * sqrt( 2.0 * PI * 4.5282e+09 ) * cos( 2.2399e+06 * phase + shift[ 0 ] ) * exp( - 4.5282e+09 * pow2( phase ) );\n\t\txyz /= 1.0685e-7;\n\t\tvec3 rgb = XYZ_TO_REC709 * xyz;\n\t\treturn rgb;\n\t}\n\tvec3 evalIridescence( float outsideIOR, float eta2, float cosTheta1, float thinFilmThickness, vec3 baseF0 ) {\n\t\tvec3 I;\n\t\tfloat iridescenceIOR = mix( outsideIOR, eta2, smoothstep( 0.0, 0.03, thinFilmThickness ) );\n\t\tfloat sinTheta2Sq = pow2( outsideIOR / iridescenceIOR ) * ( 1.0 - pow2( cosTheta1 ) );\n\t\tfloat cosTheta2Sq = 1.0 - sinTheta2Sq;\n\t\tif ( cosTheta2Sq < 0.0 ) {\n\t\t\treturn vec3( 1.0 );\n\t\t}\n\t\tfloat cosTheta2 = sqrt( cosTheta2Sq );\n\t\tfloat R0 = IorToFresnel0( iridescenceIOR, outsideIOR );\n\t\tfloat R12 = F_Schlick( R0, 1.0, cosTheta1 );\n\t\tfloat T121 = 1.0 - R12;\n\t\tfloat phi12 = 0.0;\n\t\tif ( iridescenceIOR < outsideIOR ) phi12 = PI;\n\t\tfloat phi21 = PI - phi12;\n\t\tvec3 baseIOR = Fresnel0ToIor( clamp( baseF0, 0.0, 0.9999 ) );\t\tvec3 R1 = IorToFresnel0( baseIOR, iridescenceIOR );\n\t\tvec3 R23 = F_Schlick( R1, 1.0, cosTheta2 );\n\t\tvec3 phi23 = vec3( 0.0 );\n\t\tif ( baseIOR[ 0 ] < iridescenceIOR ) phi23[ 0 ] = PI;\n\t\tif ( baseIOR[ 1 ] < iridescenceIOR ) phi23[ 1 ] = PI;\n\t\tif ( baseIOR[ 2 ] < iridescenceIOR ) phi23[ 2 ] = PI;\n\t\tfloat OPD = 2.0 * iridescenceIOR * thinFilmThickness * cosTheta2;\n\t\tvec3 phi = vec3( phi21 ) + phi23;\n\t\tvec3 R123 = clamp( R12 * R23, 1e-5, 0.9999 );\n\t\tvec3 r123 = sqrt( R123 );\n\t\tvec3 Rs = pow2( T121 ) * R23 / ( vec3( 1.0 ) - R123 );\n\t\tvec3 C0 = R12 + Rs;\n\t\tI = C0;\n\t\tvec3 Cm = Rs - T121;\n\t\tfor ( int m = 1; m <= 2; ++ m ) {\n\t\t\tCm *= r123;\n\t\t\tvec3 Sm = 2.0 * evalSensitivity( float( m ) * OPD, float( m ) * phi );\n\t\t\tI += Cm * Sm;\n\t\t}\n\t\treturn max( I, vec3( 0.0 ) );\n\t}\n#endif`,RCe=`#ifdef USE_BUMPMAP\n\tuniform sampler2D bumpMap;\n\tuniform float bumpScale;\n\tvec2 dHdxy_fwd() {\n\t\tvec2 dSTdx = dFdx( vBumpMapUv );\n\t\tvec2 dSTdy = dFdy( vBumpMapUv );\n\t\tfloat Hll = bumpScale * texture2D( bumpMap, vBumpMapUv ).x;\n\t\tfloat dBx = bumpScale * texture2D( bumpMap, vBumpMapUv + dSTdx ).x - Hll;\n\t\tfloat dBy = bumpScale * texture2D( bumpMap, vBumpMapUv + dSTdy ).x - Hll;\n\t\treturn vec2( dBx, dBy );\n\t}\n\tvec3 perturbNormalArb( vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection ) {\n\t\tvec3 vSigmaX = normalize( dFdx( surf_pos.xyz ) );\n\t\tvec3 vSigmaY = normalize( dFdy( surf_pos.xyz ) );\n\t\tvec3 vN = surf_norm;\n\t\tvec3 R1 = cross( vSigmaY, vN );\n\t\tvec3 R2 = cross( vN, vSigmaX );\n\t\tfloat fDet = dot( vSigmaX, R1 ) * faceDirection;\n\t\tvec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );\n\t\treturn normalize( abs( fDet ) * surf_norm - vGrad );\n\t}\n#endif`,LCe=`#if NUM_CLIPPING_PLANES > 0\n\tvec4 plane;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\tplane = clippingPlanes[ i ];\n\t\tif ( dot( vClipPosition, plane.xyz ) > plane.w ) discard;\n\t}\n\t#pragma unroll_loop_end\n\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\tbool clipped = true;\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tclipped = ( dot( vClipPosition, plane.xyz ) > plane.w ) && clipped;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t\tif ( clipped ) discard;\n\t#endif\n#endif`,OCe=`#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n\tuniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];\n#endif`,ICe=`#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n#endif`,zCe=`#if NUM_CLIPPING_PLANES > 0\n\tvClipPosition = - mvPosition.xyz;\n#endif`,UCe=`#if defined( USE_COLOR_ALPHA )\n\tdiffuseColor *= vColor;\n#elif defined( USE_COLOR )\n\tdiffuseColor.rgb *= vColor;\n#endif`,BCe=`#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR )\n\tvarying vec3 vColor;\n#endif`,HCe=`#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvarying vec3 vColor;\n#endif`,qCe=`#if defined( USE_COLOR_ALPHA )\n\tvColor = vec4( 1.0 );\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvColor = vec3( 1.0 );\n#endif\n#ifdef USE_COLOR\n\tvColor *= color;\n#endif\n#ifdef USE_INSTANCING_COLOR\n\tvColor.xyz *= instanceColor.xyz;\n#endif`,$Ce=`#define PI 3.141592653589793\n#define PI2 6.283185307179586\n#define PI_HALF 1.5707963267948966\n#define RECIPROCAL_PI 0.3183098861837907\n#define RECIPROCAL_PI2 0.15915494309189535\n#define EPSILON 1e-6\n#ifndef saturate\n#define saturate( a ) clamp( a, 0.0, 1.0 )\n#endif\n#define whiteComplement( a ) ( 1.0 - saturate( a ) )\nfloat pow2( const in float x ) { return x*x; }\nvec3 pow2( const in vec3 x ) { return x*x; }\nfloat pow3( const in float x ) { return x*x*x; }\nfloat pow4( const in float x ) { float x2 = x*x; return x2*x2; }\nfloat max3( const in vec3 v ) { return max( max( v.x, v.y ), v.z ); }\nfloat average( const in vec3 v ) { return dot( v, vec3( 0.3333333 ) ); }\nhighp float rand( const in vec2 uv ) {\n\tconst highp float a = 12.9898, b = 78.233, c = 43758.5453;\n\thighp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );\n\treturn fract( sin( sn ) * c );\n}\n#ifdef HIGH_PRECISION\n\tfloat precisionSafeLength( vec3 v ) { return length( v ); }\n#else\n\tfloat precisionSafeLength( vec3 v ) {\n\t\tfloat maxComponent = max3( abs( v ) );\n\t\treturn length( v / maxComponent ) * maxComponent;\n\t}\n#endif\nstruct IncidentLight {\n\tvec3 color;\n\tvec3 direction;\n\tbool visible;\n};\nstruct ReflectedLight {\n\tvec3 directDiffuse;\n\tvec3 directSpecular;\n\tvec3 indirectDiffuse;\n\tvec3 indirectSpecular;\n};\n#ifdef USE_ALPHAHASH\n\tvarying vec3 vPosition;\n#endif\nvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n}\nvec3 inverseTransformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( vec4( dir, 0.0 ) * matrix ).xyz );\n}\nmat3 transposeMat3( const in mat3 m ) {\n\tmat3 tmp;\n\ttmp[ 0 ] = vec3( m[ 0 ].x, m[ 1 ].x, m[ 2 ].x );\n\ttmp[ 1 ] = vec3( m[ 0 ].y, m[ 1 ].y, m[ 2 ].y );\n\ttmp[ 2 ] = vec3( m[ 0 ].z, m[ 1 ].z, m[ 2 ].z );\n\treturn tmp;\n}\nfloat luminance( const in vec3 rgb ) {\n\tconst vec3 weights = vec3( 0.2126729, 0.7151522, 0.0721750 );\n\treturn dot( weights, rgb );\n}\nbool isPerspectiveMatrix( mat4 m ) {\n\treturn m[ 2 ][ 3 ] == - 1.0;\n}\nvec2 equirectUv( in vec3 dir ) {\n\tfloat u = atan( dir.z, dir.x ) * RECIPROCAL_PI2 + 0.5;\n\tfloat v = asin( clamp( dir.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\treturn vec2( u, v );\n}\nvec3 BRDF_Lambert( const in vec3 diffuseColor ) {\n\treturn RECIPROCAL_PI * diffuseColor;\n}\nvec3 F_Schlick( const in vec3 f0, const in float f90, const in float dotVH ) {\n\tfloat fresnel = exp2( ( - 5.55473 * dotVH - 6.98316 ) * dotVH );\n\treturn f0 * ( 1.0 - fresnel ) + ( f90 * fresnel );\n}\nfloat F_Schlick( const in float f0, const in float f90, const in float dotVH ) {\n\tfloat fresnel = exp2( ( - 5.55473 * dotVH - 6.98316 ) * dotVH );\n\treturn f0 * ( 1.0 - fresnel ) + ( f90 * fresnel );\n} // validated`,VCe=`#ifdef ENVMAP_TYPE_CUBE_UV\n\t#define cubeUV_minMipLevel 4.0\n\t#define cubeUV_minTileSize 16.0\n\tfloat getFace( vec3 direction ) {\n\t\tvec3 absDirection = abs( direction );\n\t\tfloat face = - 1.0;\n\t\tif ( absDirection.x > absDirection.z ) {\n\t\t\tif ( absDirection.x > absDirection.y )\n\t\t\t\tface = direction.x > 0.0 ? 0.0 : 3.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t} else {\n\t\t\tif ( absDirection.z > absDirection.y )\n\t\t\t\tface = direction.z > 0.0 ? 2.0 : 5.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t}\n\t\treturn face;\n\t}\n\tvec2 getUV( vec3 direction, float face ) {\n\t\tvec2 uv;\n\t\tif ( face == 0.0 ) {\n\t\t\tuv = vec2( direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 1.0 ) {\n\t\t\tuv = vec2( - direction.x, - direction.z ) / abs( direction.y );\n\t\t} else if ( face == 2.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.y ) / abs( direction.z );\n\t\t} else if ( face == 3.0 ) {\n\t\t\tuv = vec2( - direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 4.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.z ) / abs( direction.y );\n\t\t} else {\n\t\t\tuv = vec2( direction.x, direction.y ) / abs( direction.z );\n\t\t}\n\t\treturn 0.5 * ( uv + 1.0 );\n\t}\n\tvec3 bilinearCubeUV( sampler2D envMap, vec3 direction, float mipInt ) {\n\t\tfloat face = getFace( direction );\n\t\tfloat filterInt = max( cubeUV_minMipLevel - mipInt, 0.0 );\n\t\tmipInt = max( mipInt, cubeUV_minMipLevel );\n\t\tfloat faceSize = exp2( mipInt );\n\t\thighp vec2 uv = getUV( direction, face ) * ( faceSize - 2.0 ) + 1.0;\n\t\tif ( face > 2.0 ) {\n\t\t\tuv.y += faceSize;\n\t\t\tface -= 3.0;\n\t\t}\n\t\tuv.x += face * faceSize;\n\t\tuv.x += filterInt * 3.0 * cubeUV_minTileSize;\n\t\tuv.y += 4.0 * ( exp2( CUBEUV_MAX_MIP ) - faceSize );\n\t\tuv.x *= CUBEUV_TEXEL_WIDTH;\n\t\tuv.y *= CUBEUV_TEXEL_HEIGHT;\n\t\t#ifdef texture2DGradEXT\n\t\t\treturn texture2DGradEXT( envMap, uv, vec2( 0.0 ), vec2( 0.0 ) ).rgb;\n\t\t#else\n\t\t\treturn texture2D( envMap, uv ).rgb;\n\t\t#endif\n\t}\n\t#define cubeUV_r0 1.0\n\t#define cubeUV_v0 0.339\n\t#define cubeUV_m0 - 2.0\n\t#define cubeUV_r1 0.8\n\t#define cubeUV_v1 0.276\n\t#define cubeUV_m1 - 1.0\n\t#define cubeUV_r4 0.4\n\t#define cubeUV_v4 0.046\n\t#define cubeUV_m4 2.0\n\t#define cubeUV_r5 0.305\n\t#define cubeUV_v5 0.016\n\t#define cubeUV_m5 3.0\n\t#define cubeUV_r6 0.21\n\t#define cubeUV_v6 0.0038\n\t#define cubeUV_m6 4.0\n\tfloat roughnessToMip( float roughness ) {\n\t\tfloat mip = 0.0;\n\t\tif ( roughness >= cubeUV_r1 ) {\n\t\t\tmip = ( cubeUV_r0 - roughness ) * ( cubeUV_m1 - cubeUV_m0 ) / ( cubeUV_r0 - cubeUV_r1 ) + cubeUV_m0;\n\t\t} else if ( roughness >= cubeUV_r4 ) {\n\t\t\tmip = ( cubeUV_r1 - roughness ) * ( cubeUV_m4 - cubeUV_m1 ) / ( cubeUV_r1 - cubeUV_r4 ) + cubeUV_m1;\n\t\t} else if ( roughness >= cubeUV_r5 ) {\n\t\t\tmip = ( cubeUV_r4 - roughness ) * ( cubeUV_m5 - cubeUV_m4 ) / ( cubeUV_r4 - cubeUV_r5 ) + cubeUV_m4;\n\t\t} else if ( roughness >= cubeUV_r6 ) {\n\t\t\tmip = ( cubeUV_r5 - roughness ) * ( cubeUV_m6 - cubeUV_m5 ) / ( cubeUV_r5 - cubeUV_r6 ) + cubeUV_m5;\n\t\t} else {\n\t\t\tmip = - 2.0 * log2( 1.16 * roughness );\t\t}\n\t\treturn mip;\n\t}\n\tvec4 textureCubeUV( sampler2D envMap, vec3 sampleDir, float roughness ) {\n\t\tfloat mip = clamp( roughnessToMip( roughness ), cubeUV_m0, CUBEUV_MAX_MIP );\n\t\tfloat mipF = fract( mip );\n\t\tfloat mipInt = floor( mip );\n\t\tvec3 color0 = bilinearCubeUV( envMap, sampleDir, mipInt );\n\t\tif ( mipF == 0.0 ) {\n\t\t\treturn vec4( color0, 1.0 );\n\t\t} else {\n\t\t\tvec3 color1 = bilinearCubeUV( envMap, sampleDir, mipInt + 1.0 );\n\t\t\treturn vec4( mix( color0, color1, mipF ), 1.0 );\n\t\t}\n\t}\n#endif`,GCe=`vec3 transformedNormal = objectNormal;\n#ifdef USE_TANGENT\n\tvec3 transformedTangent = objectTangent;\n#endif\n#ifdef USE_BATCHING\n\tmat3 bm = mat3( batchingMatrix );\n\ttransformedNormal /= vec3( dot( bm[ 0 ], bm[ 0 ] ), dot( bm[ 1 ], bm[ 1 ] ), dot( bm[ 2 ], bm[ 2 ] ) );\n\ttransformedNormal = bm * transformedNormal;\n\t#ifdef USE_TANGENT\n\t\ttransformedTangent = bm * transformedTangent;\n\t#endif\n#endif\n#ifdef USE_INSTANCING\n\tmat3 im = mat3( instanceMatrix );\n\ttransformedNormal /= vec3( dot( im[ 0 ], im[ 0 ] ), dot( im[ 1 ], im[ 1 ] ), dot( im[ 2 ], im[ 2 ] ) );\n\ttransformedNormal = im * transformedNormal;\n\t#ifdef USE_TANGENT\n\t\ttransformedTangent = im * transformedTangent;\n\t#endif\n#endif\ntransformedNormal = normalMatrix * transformedNormal;\n#ifdef FLIP_SIDED\n\ttransformedNormal = - transformedNormal;\n#endif\n#ifdef USE_TANGENT\n\ttransformedTangent = ( modelViewMatrix * vec4( transformedTangent, 0.0 ) ).xyz;\n\t#ifdef FLIP_SIDED\n\t\ttransformedTangent = - transformedTangent;\n\t#endif\n#endif`,WCe=`#ifdef USE_DISPLACEMENTMAP\n\tuniform sampler2D displacementMap;\n\tuniform float displacementScale;\n\tuniform float displacementBias;\n#endif`,KCe=`#ifdef USE_DISPLACEMENTMAP\n\ttransformed += normalize( objectNormal ) * ( texture2D( displacementMap, vDisplacementMapUv ).x * displacementScale + displacementBias );\n#endif`,XCe=`#ifdef USE_EMISSIVEMAP\n\tvec4 emissiveColor = texture2D( emissiveMap, vEmissiveMapUv );\n\ttotalEmissiveRadiance *= emissiveColor.rgb;\n#endif`,YCe=`#ifdef USE_EMISSIVEMAP\n\tuniform sampler2D emissiveMap;\n#endif`,QCe=\"gl_FragColor = linearToOutputTexel( gl_FragColor );\",ZCe=`\nconst mat3 LINEAR_SRGB_TO_LINEAR_DISPLAY_P3 = mat3(\n\tvec3( 0.8224621, 0.177538, 0.0 ),\n\tvec3( 0.0331941, 0.9668058, 0.0 ),\n\tvec3( 0.0170827, 0.0723974, 0.9105199 )\n);\nconst mat3 LINEAR_DISPLAY_P3_TO_LINEAR_SRGB = mat3(\n\tvec3( 1.2249401, - 0.2249404, 0.0 ),\n\tvec3( - 0.0420569, 1.0420571, 0.0 ),\n\tvec3( - 0.0196376, - 0.0786361, 1.0982735 )\n);\nvec4 LinearSRGBToLinearDisplayP3( in vec4 value ) {\n\treturn vec4( value.rgb * LINEAR_SRGB_TO_LINEAR_DISPLAY_P3, value.a );\n}\nvec4 LinearDisplayP3ToLinearSRGB( in vec4 value ) {\n\treturn vec4( value.rgb * LINEAR_DISPLAY_P3_TO_LINEAR_SRGB, value.a );\n}\nvec4 LinearTransferOETF( in vec4 value ) {\n\treturn value;\n}\nvec4 sRGBTransferOETF( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );\n}\nvec4 LinearToLinear( in vec4 value ) {\n\treturn value;\n}\nvec4 LinearTosRGB( in vec4 value ) {\n\treturn sRGBTransferOETF( value );\n}`,JCe=`#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvec3 cameraToFrag;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToFrag = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToFrag = normalize( vWorldPosition - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( cameraToFrag, worldNormal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( cameraToFrag, worldNormal, refractionRatio );\n\t\t#endif\n\t#else\n\t\tvec3 reflectVec = vReflect;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 envColor = textureCube( envMap, vec3( flipEnvMap * reflectVec.x, reflectVec.yz ) );\n\t#else\n\t\tvec4 envColor = vec4( 0.0 );\n\t#endif\n\t#ifdef ENVMAP_BLENDING_MULTIPLY\n\t\toutgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_MIX )\n\t\toutgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_ADD )\n\t\toutgoingLight += envColor.xyz * specularStrength * reflectivity;\n\t#endif\n#endif`,ePe=`#ifdef USE_ENVMAP\n\tuniform float envMapIntensity;\n\tuniform float flipEnvMap;\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tuniform samplerCube envMap;\n\t#else\n\t\tuniform sampler2D envMap;\n\t#endif\n\t\n#endif`,tPe=`#ifdef USE_ENVMAP\n\tuniform float reflectivity;\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( LAMBERT )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\tvarying vec3 vWorldPosition;\n\t\tuniform float refractionRatio;\n\t#else\n\t\tvarying vec3 vReflect;\n\t#endif\n#endif`,nPe=`#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( LAMBERT )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\t\n\t\tvarying vec3 vWorldPosition;\n\t#else\n\t\tvarying vec3 vReflect;\n\t\tuniform float refractionRatio;\n\t#endif\n#endif`,rPe=`#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvWorldPosition = worldPosition.xyz;\n\t#else\n\t\tvec3 cameraToVertex;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToVertex = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToVertex = normalize( worldPosition.xyz - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvReflect = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvReflect = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#endif\n#endif`,aPe=`#ifdef USE_FOG\n\tvFogDepth = - mvPosition.z;\n#endif`,iPe=`#ifdef USE_FOG\n\tvarying float vFogDepth;\n#endif`,sPe=`#ifdef USE_FOG\n\t#ifdef FOG_EXP2\n\t\tfloat fogFactor = 1.0 - exp( - fogDensity * fogDensity * vFogDepth * vFogDepth );\n\t#else\n\t\tfloat fogFactor = smoothstep( fogNear, fogFar, vFogDepth );\n\t#endif\n\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );\n#endif`,oPe=`#ifdef USE_FOG\n\tuniform vec3 fogColor;\n\tvarying float vFogDepth;\n\t#ifdef FOG_EXP2\n\t\tuniform float fogDensity;\n\t#else\n\t\tuniform float fogNear;\n\t\tuniform float fogFar;\n\t#endif\n#endif`,lPe=`#ifdef USE_GRADIENTMAP\n\tuniform sampler2D gradientMap;\n#endif\nvec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) {\n\tfloat dotNL = dot( normal, lightDirection );\n\tvec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 );\n\t#ifdef USE_GRADIENTMAP\n\t\treturn vec3( texture2D( gradientMap, coord ).r );\n\t#else\n\t\tvec2 fw = fwidth( coord ) * 0.5;\n\t\treturn mix( vec3( 0.7 ), vec3( 1.0 ), smoothstep( 0.7 - fw.x, 0.7 + fw.x, coord.x ) );\n\t#endif\n}`,cPe=`#ifdef USE_LIGHTMAP\n\tvec4 lightMapTexel = texture2D( lightMap, vLightMapUv );\n\tvec3 lightMapIrradiance = lightMapTexel.rgb * lightMapIntensity;\n\treflectedLight.indirectDiffuse += lightMapIrradiance;\n#endif`,dPe=`#ifdef USE_LIGHTMAP\n\tuniform sampler2D lightMap;\n\tuniform float lightMapIntensity;\n#endif`,uPe=`LambertMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularStrength = specularStrength;`,mPe=`varying vec3 vViewPosition;\nstruct LambertMaterial {\n\tvec3 diffuseColor;\n\tfloat specularStrength;\n};\nvoid RE_Direct_Lambert( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Lambert( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Lambert\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Lambert`,hPe=`uniform bool receiveShadow;\nuniform vec3 ambientLightColor;\n#if defined( USE_LIGHT_PROBES )\n\tuniform vec3 lightProbe[ 9 ];\n#endif\nvec3 shGetIrradianceAt( in vec3 normal, in vec3 shCoefficients[ 9 ] ) {\n\tfloat x = normal.x, y = normal.y, z = normal.z;\n\tvec3 result = shCoefficients[ 0 ] * 0.886227;\n\tresult += shCoefficients[ 1 ] * 2.0 * 0.511664 * y;\n\tresult += shCoefficients[ 2 ] * 2.0 * 0.511664 * z;\n\tresult += shCoefficients[ 3 ] * 2.0 * 0.511664 * x;\n\tresult += shCoefficients[ 4 ] * 2.0 * 0.429043 * x * y;\n\tresult += shCoefficients[ 5 ] * 2.0 * 0.429043 * y * z;\n\tresult += shCoefficients[ 6 ] * ( 0.743125 * z * z - 0.247708 );\n\tresult += shCoefficients[ 7 ] * 2.0 * 0.429043 * x * z;\n\tresult += shCoefficients[ 8 ] * 0.429043 * ( x * x - y * y );\n\treturn result;\n}\nvec3 getLightProbeIrradiance( const in vec3 lightProbe[ 9 ], const in vec3 normal ) {\n\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\tvec3 irradiance = shGetIrradianceAt( worldNormal, lightProbe );\n\treturn irradiance;\n}\nvec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) {\n\tvec3 irradiance = ambientLightColor;\n\treturn irradiance;\n}\nfloat getDistanceAttenuation( const in float lightDistance, const in float cutoffDistance, const in float decayExponent ) {\n\t#if defined ( LEGACY_LIGHTS )\n\t\tif ( cutoffDistance > 0.0 && decayExponent > 0.0 ) {\n\t\t\treturn pow( saturate( - lightDistance / cutoffDistance + 1.0 ), decayExponent );\n\t\t}\n\t\treturn 1.0;\n\t#else\n\t\tfloat distanceFalloff = 1.0 / max( pow( lightDistance, decayExponent ), 0.01 );\n\t\tif ( cutoffDistance > 0.0 ) {\n\t\t\tdistanceFalloff *= pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) );\n\t\t}\n\t\treturn distanceFalloff;\n\t#endif\n}\nfloat getSpotAttenuation( const in float coneCosine, const in float penumbraCosine, const in float angleCosine ) {\n\treturn smoothstep( coneCosine, penumbraCosine, angleCosine );\n}\n#if NUM_DIR_LIGHTS > 0\n\tstruct DirectionalLight {\n\t\tvec3 direction;\n\t\tvec3 color;\n\t};\n\tuniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];\n\tvoid getDirectionalLightInfo( const in DirectionalLight directionalLight, out IncidentLight light ) {\n\t\tlight.color = directionalLight.color;\n\t\tlight.direction = directionalLight.direction;\n\t\tlight.visible = true;\n\t}\n#endif\n#if NUM_POINT_LIGHTS > 0\n\tstruct PointLight {\n\t\tvec3 position;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t};\n\tuniform PointLight pointLights[ NUM_POINT_LIGHTS ];\n\tvoid getPointLightInfo( const in PointLight pointLight, const in vec3 geometryPosition, out IncidentLight light ) {\n\t\tvec3 lVector = pointLight.position - geometryPosition;\n\t\tlight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tlight.color = pointLight.color;\n\t\tlight.color *= getDistanceAttenuation( lightDistance, pointLight.distance, pointLight.decay );\n\t\tlight.visible = ( light.color != vec3( 0.0 ) );\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\tstruct SpotLight {\n\t\tvec3 position;\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tfloat coneCos;\n\t\tfloat penumbraCos;\n\t};\n\tuniform SpotLight spotLights[ NUM_SPOT_LIGHTS ];\n\tvoid getSpotLightInfo( const in SpotLight spotLight, const in vec3 geometryPosition, out IncidentLight light ) {\n\t\tvec3 lVector = spotLight.position - geometryPosition;\n\t\tlight.direction = normalize( lVector );\n\t\tfloat angleCos = dot( light.direction, spotLight.direction );\n\t\tfloat spotAttenuation = getSpotAttenuation( spotLight.coneCos, spotLight.penumbraCos, angleCos );\n\t\tif ( spotAttenuation > 0.0 ) {\n\t\t\tfloat lightDistance = length( lVector );\n\t\t\tlight.color = spotLight.color * spotAttenuation;\n\t\t\tlight.color *= getDistanceAttenuation( lightDistance, spotLight.distance, spotLight.decay );\n\t\t\tlight.visible = ( light.color != vec3( 0.0 ) );\n\t\t} else {\n\t\t\tlight.color = vec3( 0.0 );\n\t\t\tlight.visible = false;\n\t\t}\n\t}\n#endif\n#if NUM_RECT_AREA_LIGHTS > 0\n\tstruct RectAreaLight {\n\t\tvec3 color;\n\t\tvec3 position;\n\t\tvec3 halfWidth;\n\t\tvec3 halfHeight;\n\t};\n\tuniform sampler2D ltc_1;\tuniform sampler2D ltc_2;\n\tuniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ];\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\tstruct HemisphereLight {\n\t\tvec3 direction;\n\t\tvec3 skyColor;\n\t\tvec3 groundColor;\n\t};\n\tuniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ];\n\tvec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in vec3 normal ) {\n\t\tfloat dotNL = dot( normal, hemiLight.direction );\n\t\tfloat hemiDiffuseWeight = 0.5 * dotNL + 0.5;\n\t\tvec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight );\n\t\treturn irradiance;\n\t}\n#endif`,pPe=`#ifdef USE_ENVMAP\n\tvec3 getIBLIrradiance( const in vec3 normal ) {\n\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, worldNormal, 1.0 );\n\t\t\treturn PI * envMapColor.rgb * envMapIntensity;\n\t\t#else\n\t\t\treturn vec3( 0.0 );\n\t\t#endif\n\t}\n\tvec3 getIBLRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness ) {\n\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\tvec3 reflectVec = reflect( - viewDir, normal );\n\t\t\treflectVec = normalize( mix( reflectVec, normal, roughness * roughness) );\n\t\t\treflectVec = inverseTransformDirection( reflectVec, viewMatrix );\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, reflectVec, roughness );\n\t\t\treturn envMapColor.rgb * envMapIntensity;\n\t\t#else\n\t\t\treturn vec3( 0.0 );\n\t\t#endif\n\t}\n\t#ifdef USE_ANISOTROPY\n\t\tvec3 getIBLAnisotropyRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness, const in vec3 bitangent, const in float anisotropy ) {\n\t\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\t\tvec3 bentNormal = cross( bitangent, viewDir );\n\t\t\t\tbentNormal = normalize( cross( bentNormal, bitangent ) );\n\t\t\t\tbentNormal = normalize( mix( bentNormal, normal, pow2( pow2( 1.0 - anisotropy * ( 1.0 - roughness ) ) ) ) );\n\t\t\t\treturn getIBLRadiance( viewDir, bentNormal, roughness );\n\t\t\t#else\n\t\t\t\treturn vec3( 0.0 );\n\t\t\t#endif\n\t\t}\n\t#endif\n#endif`,fPe=`ToonMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;`,gPe=`varying vec3 vViewPosition;\nstruct ToonMaterial {\n\tvec3 diffuseColor;\n};\nvoid RE_Direct_Toon( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\tvec3 irradiance = getGradientIrradiance( geometryNormal, directLight.direction ) * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Toon( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Toon\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Toon`,bPe=`BlinnPhongMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularColor = specular;\nmaterial.specularShininess = shininess;\nmaterial.specularStrength = specularStrength;`,xPe=`varying vec3 vViewPosition;\nstruct BlinnPhongMaterial {\n\tvec3 diffuseColor;\n\tvec3 specularColor;\n\tfloat specularShininess;\n\tfloat specularStrength;\n};\nvoid RE_Direct_BlinnPhong( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n\treflectedLight.directSpecular += irradiance * BRDF_BlinnPhong( directLight.direction, geometryViewDir, geometryNormal, material.specularColor, material.specularShininess ) * material.specularStrength;\n}\nvoid RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_BlinnPhong\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_BlinnPhong`,yPe=`PhysicalMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb * ( 1.0 - metalnessFactor );\nvec3 dxy = max( abs( dFdx( nonPerturbedNormal ) ), abs( dFdy( nonPerturbedNormal ) ) );\nfloat geometryRoughness = max( max( dxy.x, dxy.y ), dxy.z );\nmaterial.roughness = max( roughnessFactor, 0.0525 );material.roughness += geometryRoughness;\nmaterial.roughness = min( material.roughness, 1.0 );\n#ifdef IOR\n\tmaterial.ior = ior;\n\t#ifdef USE_SPECULAR\n\t\tfloat specularIntensityFactor = specularIntensity;\n\t\tvec3 specularColorFactor = specularColor;\n\t\t#ifdef USE_SPECULAR_COLORMAP\n\t\t\tspecularColorFactor *= texture2D( specularColorMap, vSpecularColorMapUv ).rgb;\n\t\t#endif\n\t\t#ifdef USE_SPECULAR_INTENSITYMAP\n\t\t\tspecularIntensityFactor *= texture2D( specularIntensityMap, vSpecularIntensityMapUv ).a;\n\t\t#endif\n\t\tmaterial.specularF90 = mix( specularIntensityFactor, 1.0, metalnessFactor );\n\t#else\n\t\tfloat specularIntensityFactor = 1.0;\n\t\tvec3 specularColorFactor = vec3( 1.0 );\n\t\tmaterial.specularF90 = 1.0;\n\t#endif\n\tmaterial.specularColor = mix( min( pow2( ( material.ior - 1.0 ) / ( material.ior + 1.0 ) ) * specularColorFactor, vec3( 1.0 ) ) * specularIntensityFactor, diffuseColor.rgb, metalnessFactor );\n#else\n\tmaterial.specularColor = mix( vec3( 0.04 ), diffuseColor.rgb, metalnessFactor );\n\tmaterial.specularF90 = 1.0;\n#endif\n#ifdef USE_CLEARCOAT\n\tmaterial.clearcoat = clearcoat;\n\tmaterial.clearcoatRoughness = clearcoatRoughness;\n\tmaterial.clearcoatF0 = vec3( 0.04 );\n\tmaterial.clearcoatF90 = 1.0;\n\t#ifdef USE_CLEARCOATMAP\n\t\tmaterial.clearcoat *= texture2D( clearcoatMap, vClearcoatMapUv ).x;\n\t#endif\n\t#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\t\tmaterial.clearcoatRoughness *= texture2D( clearcoatRoughnessMap, vClearcoatRoughnessMapUv ).y;\n\t#endif\n\tmaterial.clearcoat = saturate( material.clearcoat );\tmaterial.clearcoatRoughness = max( material.clearcoatRoughness, 0.0525 );\n\tmaterial.clearcoatRoughness += geometryRoughness;\n\tmaterial.clearcoatRoughness = min( material.clearcoatRoughness, 1.0 );\n#endif\n#ifdef USE_IRIDESCENCE\n\tmaterial.iridescence = iridescence;\n\tmaterial.iridescenceIOR = iridescenceIOR;\n\t#ifdef USE_IRIDESCENCEMAP\n\t\tmaterial.iridescence *= texture2D( iridescenceMap, vIridescenceMapUv ).r;\n\t#endif\n\t#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\t\tmaterial.iridescenceThickness = (iridescenceThicknessMaximum - iridescenceThicknessMinimum) * texture2D( iridescenceThicknessMap, vIridescenceThicknessMapUv ).g + iridescenceThicknessMinimum;\n\t#else\n\t\tmaterial.iridescenceThickness = iridescenceThicknessMaximum;\n\t#endif\n#endif\n#ifdef USE_SHEEN\n\tmaterial.sheenColor = sheenColor;\n\t#ifdef USE_SHEEN_COLORMAP\n\t\tmaterial.sheenColor *= texture2D( sheenColorMap, vSheenColorMapUv ).rgb;\n\t#endif\n\tmaterial.sheenRoughness = clamp( sheenRoughness, 0.07, 1.0 );\n\t#ifdef USE_SHEEN_ROUGHNESSMAP\n\t\tmaterial.sheenRoughness *= texture2D( sheenRoughnessMap, vSheenRoughnessMapUv ).a;\n\t#endif\n#endif\n#ifdef USE_ANISOTROPY\n\t#ifdef USE_ANISOTROPYMAP\n\t\tmat2 anisotropyMat = mat2( anisotropyVector.x, anisotropyVector.y, - anisotropyVector.y, anisotropyVector.x );\n\t\tvec3 anisotropyPolar = texture2D( anisotropyMap, vAnisotropyMapUv ).rgb;\n\t\tvec2 anisotropyV = anisotropyMat * normalize( 2.0 * anisotropyPolar.rg - vec2( 1.0 ) ) * anisotropyPolar.b;\n\t#else\n\t\tvec2 anisotropyV = anisotropyVector;\n\t#endif\n\tmaterial.anisotropy = length( anisotropyV );\n\tif( material.anisotropy == 0.0 ) {\n\t\tanisotropyV = vec2( 1.0, 0.0 );\n\t} else {\n\t\tanisotropyV /= material.anisotropy;\n\t\tmaterial.anisotropy = saturate( material.anisotropy );\n\t}\n\tmaterial.alphaT = mix( pow2( material.roughness ), 1.0, pow2( material.anisotropy ) );\n\tmaterial.anisotropyT = tbn[ 0 ] * anisotropyV.x + tbn[ 1 ] * anisotropyV.y;\n\tmaterial.anisotropyB = tbn[ 1 ] * anisotropyV.x - tbn[ 0 ] * anisotropyV.y;\n#endif`,vPe=`struct PhysicalMaterial {\n\tvec3 diffuseColor;\n\tfloat roughness;\n\tvec3 specularColor;\n\tfloat specularF90;\n\t#ifdef USE_CLEARCOAT\n\t\tfloat clearcoat;\n\t\tfloat clearcoatRoughness;\n\t\tvec3 clearcoatF0;\n\t\tfloat clearcoatF90;\n\t#endif\n\t#ifdef USE_IRIDESCENCE\n\t\tfloat iridescence;\n\t\tfloat iridescenceIOR;\n\t\tfloat iridescenceThickness;\n\t\tvec3 iridescenceFresnel;\n\t\tvec3 iridescenceF0;\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tvec3 sheenColor;\n\t\tfloat sheenRoughness;\n\t#endif\n\t#ifdef IOR\n\t\tfloat ior;\n\t#endif\n\t#ifdef USE_TRANSMISSION\n\t\tfloat transmission;\n\t\tfloat transmissionAlpha;\n\t\tfloat thickness;\n\t\tfloat attenuationDistance;\n\t\tvec3 attenuationColor;\n\t#endif\n\t#ifdef USE_ANISOTROPY\n\t\tfloat anisotropy;\n\t\tfloat alphaT;\n\t\tvec3 anisotropyT;\n\t\tvec3 anisotropyB;\n\t#endif\n};\nvec3 clearcoatSpecularDirect = vec3( 0.0 );\nvec3 clearcoatSpecularIndirect = vec3( 0.0 );\nvec3 sheenSpecularDirect = vec3( 0.0 );\nvec3 sheenSpecularIndirect = vec3(0.0 );\nvec3 Schlick_to_F0( const in vec3 f, const in float f90, const in float dotVH ) {\n    float x = clamp( 1.0 - dotVH, 0.0, 1.0 );\n    float x2 = x * x;\n    float x5 = clamp( x * x2 * x2, 0.0, 0.9999 );\n    return ( f - vec3( f90 ) * x5 ) / ( 1.0 - x5 );\n}\nfloat V_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\tfloat gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\treturn 0.5 / max( gv + gl, EPSILON );\n}\nfloat D_GGX( const in float alpha, const in float dotNH ) {\n\tfloat a2 = pow2( alpha );\n\tfloat denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0;\n\treturn RECIPROCAL_PI * a2 / pow2( denom );\n}\n#ifdef USE_ANISOTROPY\n\tfloat V_GGX_SmithCorrelated_Anisotropic( const in float alphaT, const in float alphaB, const in float dotTV, const in float dotBV, const in float dotTL, const in float dotBL, const in float dotNV, const in float dotNL ) {\n\t\tfloat gv = dotNL * length( vec3( alphaT * dotTV, alphaB * dotBV, dotNV ) );\n\t\tfloat gl = dotNV * length( vec3( alphaT * dotTL, alphaB * dotBL, dotNL ) );\n\t\tfloat v = 0.5 / ( gv + gl );\n\t\treturn saturate(v);\n\t}\n\tfloat D_GGX_Anisotropic( const in float alphaT, const in float alphaB, const in float dotNH, const in float dotTH, const in float dotBH ) {\n\t\tfloat a2 = alphaT * alphaB;\n\t\thighp vec3 v = vec3( alphaB * dotTH, alphaT * dotBH, a2 * dotNH );\n\t\thighp float v2 = dot( v, v );\n\t\tfloat w2 = a2 / v2;\n\t\treturn RECIPROCAL_PI * a2 * pow2 ( w2 );\n\t}\n#endif\n#ifdef USE_CLEARCOAT\n\tvec3 BRDF_GGX_Clearcoat( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material) {\n\t\tvec3 f0 = material.clearcoatF0;\n\t\tfloat f90 = material.clearcoatF90;\n\t\tfloat roughness = material.clearcoatRoughness;\n\t\tfloat alpha = pow2( roughness );\n\t\tvec3 halfDir = normalize( lightDir + viewDir );\n\t\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\t\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\t\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\t\tfloat dotVH = saturate( dot( viewDir, halfDir ) );\n\t\tvec3 F = F_Schlick( f0, f90, dotVH );\n\t\tfloat V = V_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\t\tfloat D = D_GGX( alpha, dotNH );\n\t\treturn F * ( V * D );\n\t}\n#endif\nvec3 BRDF_GGX( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material ) {\n\tvec3 f0 = material.specularColor;\n\tfloat f90 = material.specularF90;\n\tfloat roughness = material.roughness;\n\tfloat alpha = pow2( roughness );\n\tvec3 halfDir = normalize( lightDir + viewDir );\n\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat dotVH = saturate( dot( viewDir, halfDir ) );\n\tvec3 F = F_Schlick( f0, f90, dotVH );\n\t#ifdef USE_IRIDESCENCE\n\t\tF = mix( F, material.iridescenceFresnel, material.iridescence );\n\t#endif\n\t#ifdef USE_ANISOTROPY\n\t\tfloat dotTL = dot( material.anisotropyT, lightDir );\n\t\tfloat dotTV = dot( material.anisotropyT, viewDir );\n\t\tfloat dotTH = dot( material.anisotropyT, halfDir );\n\t\tfloat dotBL = dot( material.anisotropyB, lightDir );\n\t\tfloat dotBV = dot( material.anisotropyB, viewDir );\n\t\tfloat dotBH = dot( material.anisotropyB, halfDir );\n\t\tfloat V = V_GGX_SmithCorrelated_Anisotropic( material.alphaT, alpha, dotTV, dotBV, dotTL, dotBL, dotNV, dotNL );\n\t\tfloat D = D_GGX_Anisotropic( material.alphaT, alpha, dotNH, dotTH, dotBH );\n\t#else\n\t\tfloat V = V_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\t\tfloat D = D_GGX( alpha, dotNH );\n\t#endif\n\treturn F * ( V * D );\n}\nvec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) {\n\tconst float LUT_SIZE = 64.0;\n\tconst float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE;\n\tconst float LUT_BIAS = 0.5 / LUT_SIZE;\n\tfloat dotNV = saturate( dot( N, V ) );\n\tvec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) );\n\tuv = uv * LUT_SCALE + LUT_BIAS;\n\treturn uv;\n}\nfloat LTC_ClippedSphereFormFactor( const in vec3 f ) {\n\tfloat l = length( f );\n\treturn max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 );\n}\nvec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) {\n\tfloat x = dot( v1, v2 );\n\tfloat y = abs( x );\n\tfloat a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y;\n\tfloat b = 3.4175940 + ( 4.1616724 + y ) * y;\n\tfloat v = a / b;\n\tfloat theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v;\n\treturn cross( v1, v2 ) * theta_sintheta;\n}\nvec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) {\n\tvec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ];\n\tvec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ];\n\tvec3 lightNormal = cross( v1, v2 );\n\tif( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 );\n\tvec3 T1, T2;\n\tT1 = normalize( V - N * dot( V, N ) );\n\tT2 = - cross( N, T1 );\n\tmat3 mat = mInv * transposeMat3( mat3( T1, T2, N ) );\n\tvec3 coords[ 4 ];\n\tcoords[ 0 ] = mat * ( rectCoords[ 0 ] - P );\n\tcoords[ 1 ] = mat * ( rectCoords[ 1 ] - P );\n\tcoords[ 2 ] = mat * ( rectCoords[ 2 ] - P );\n\tcoords[ 3 ] = mat * ( rectCoords[ 3 ] - P );\n\tcoords[ 0 ] = normalize( coords[ 0 ] );\n\tcoords[ 1 ] = normalize( coords[ 1 ] );\n\tcoords[ 2 ] = normalize( coords[ 2 ] );\n\tcoords[ 3 ] = normalize( coords[ 3 ] );\n\tvec3 vectorFormFactor = vec3( 0.0 );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] );\n\tfloat result = LTC_ClippedSphereFormFactor( vectorFormFactor );\n\treturn vec3( result );\n}\n#if defined( USE_SHEEN )\nfloat D_Charlie( float roughness, float dotNH ) {\n\tfloat alpha = pow2( roughness );\n\tfloat invAlpha = 1.0 / alpha;\n\tfloat cos2h = dotNH * dotNH;\n\tfloat sin2h = max( 1.0 - cos2h, 0.0078125 );\n\treturn ( 2.0 + invAlpha ) * pow( sin2h, invAlpha * 0.5 ) / ( 2.0 * PI );\n}\nfloat V_Neubelt( float dotNV, float dotNL ) {\n\treturn saturate( 1.0 / ( 4.0 * ( dotNL + dotNV - dotNL * dotNV ) ) );\n}\nvec3 BRDF_Sheen( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, vec3 sheenColor, const in float sheenRoughness ) {\n\tvec3 halfDir = normalize( lightDir + viewDir );\n\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat D = D_Charlie( sheenRoughness, dotNH );\n\tfloat V = V_Neubelt( dotNV, dotNL );\n\treturn sheenColor * ( D * V );\n}\n#endif\nfloat IBLSheenBRDF( const in vec3 normal, const in vec3 viewDir, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat r2 = roughness * roughness;\n\tfloat a = roughness < 0.25 ? -339.2 * r2 + 161.4 * roughness - 25.9 : -8.48 * r2 + 14.3 * roughness - 9.95;\n\tfloat b = roughness < 0.25 ? 44.0 * r2 - 23.7 * roughness + 3.26 : 1.97 * r2 - 3.27 * roughness + 0.72;\n\tfloat DG = exp( a * dotNV + b ) + ( roughness < 0.25 ? 0.0 : 0.1 * ( roughness - 0.25 ) );\n\treturn saturate( DG * RECIPROCAL_PI );\n}\nvec2 DFGApprox( const in vec3 normal, const in vec3 viewDir, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tconst vec4 c0 = vec4( - 1, - 0.0275, - 0.572, 0.022 );\n\tconst vec4 c1 = vec4( 1, 0.0425, 1.04, - 0.04 );\n\tvec4 r = roughness * c0 + c1;\n\tfloat a004 = min( r.x * r.x, exp2( - 9.28 * dotNV ) ) * r.x + r.y;\n\tvec2 fab = vec2( - 1.04, 1.04 ) * a004 + r.zw;\n\treturn fab;\n}\nvec3 EnvironmentBRDF( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float roughness ) {\n\tvec2 fab = DFGApprox( normal, viewDir, roughness );\n\treturn specularColor * fab.x + specularF90 * fab.y;\n}\n#ifdef USE_IRIDESCENCE\nvoid computeMultiscatteringIridescence( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float iridescence, const in vec3 iridescenceF0, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n#else\nvoid computeMultiscattering( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n#endif\n\tvec2 fab = DFGApprox( normal, viewDir, roughness );\n\t#ifdef USE_IRIDESCENCE\n\t\tvec3 Fr = mix( specularColor, iridescenceF0, iridescence );\n\t#else\n\t\tvec3 Fr = specularColor;\n\t#endif\n\tvec3 FssEss = Fr * fab.x + specularF90 * fab.y;\n\tfloat Ess = fab.x + fab.y;\n\tfloat Ems = 1.0 - Ess;\n\tvec3 Favg = Fr + ( 1.0 - Fr ) * 0.047619;\tvec3 Fms = FssEss * Favg / ( 1.0 - Ems * Favg );\n\tsingleScatter += FssEss;\n\tmultiScatter += Fms * Ems;\n}\n#if NUM_RECT_AREA_LIGHTS > 0\n\tvoid RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t\tvec3 normal = geometryNormal;\n\t\tvec3 viewDir = geometryViewDir;\n\t\tvec3 position = geometryPosition;\n\t\tvec3 lightPos = rectAreaLight.position;\n\t\tvec3 halfWidth = rectAreaLight.halfWidth;\n\t\tvec3 halfHeight = rectAreaLight.halfHeight;\n\t\tvec3 lightColor = rectAreaLight.color;\n\t\tfloat roughness = material.roughness;\n\t\tvec3 rectCoords[ 4 ];\n\t\trectCoords[ 0 ] = lightPos + halfWidth - halfHeight;\t\trectCoords[ 1 ] = lightPos - halfWidth - halfHeight;\n\t\trectCoords[ 2 ] = lightPos - halfWidth + halfHeight;\n\t\trectCoords[ 3 ] = lightPos + halfWidth + halfHeight;\n\t\tvec2 uv = LTC_Uv( normal, viewDir, roughness );\n\t\tvec4 t1 = texture2D( ltc_1, uv );\n\t\tvec4 t2 = texture2D( ltc_2, uv );\n\t\tmat3 mInv = mat3(\n\t\t\tvec3( t1.x, 0, t1.y ),\n\t\t\tvec3(    0, 1,    0 ),\n\t\t\tvec3( t1.z, 0, t1.w )\n\t\t);\n\t\tvec3 fresnel = ( material.specularColor * t2.x + ( vec3( 1.0 ) - material.specularColor ) * t2.y );\n\t\treflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords );\n\t\treflectedLight.directDiffuse += lightColor * material.diffuseColor * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords );\n\t}\n#endif\nvoid RE_Direct_Physical( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifdef USE_CLEARCOAT\n\t\tfloat dotNLcc = saturate( dot( geometryClearcoatNormal, directLight.direction ) );\n\t\tvec3 ccIrradiance = dotNLcc * directLight.color;\n\t\tclearcoatSpecularDirect += ccIrradiance * BRDF_GGX_Clearcoat( directLight.direction, geometryViewDir, geometryClearcoatNormal, material );\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tsheenSpecularDirect += irradiance * BRDF_Sheen( directLight.direction, geometryViewDir, geometryNormal, material.sheenColor, material.sheenRoughness );\n\t#endif\n\treflectedLight.directSpecular += irradiance * BRDF_GGX( directLight.direction, geometryViewDir, geometryNormal, material );\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 irradiance, const in vec3 clearcoatRadiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {\n\t#ifdef USE_CLEARCOAT\n\t\tclearcoatSpecularIndirect += clearcoatRadiance * EnvironmentBRDF( geometryClearcoatNormal, geometryViewDir, material.clearcoatF0, material.clearcoatF90, material.clearcoatRoughness );\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tsheenSpecularIndirect += irradiance * material.sheenColor * IBLSheenBRDF( geometryNormal, geometryViewDir, material.sheenRoughness );\n\t#endif\n\tvec3 singleScattering = vec3( 0.0 );\n\tvec3 multiScattering = vec3( 0.0 );\n\tvec3 cosineWeightedIrradiance = irradiance * RECIPROCAL_PI;\n\t#ifdef USE_IRIDESCENCE\n\t\tcomputeMultiscatteringIridescence( geometryNormal, geometryViewDir, material.specularColor, material.specularF90, material.iridescence, material.iridescenceFresnel, material.roughness, singleScattering, multiScattering );\n\t#else\n\t\tcomputeMultiscattering( geometryNormal, geometryViewDir, material.specularColor, material.specularF90, material.roughness, singleScattering, multiScattering );\n\t#endif\n\tvec3 totalScattering = singleScattering + multiScattering;\n\tvec3 diffuse = material.diffuseColor * ( 1.0 - max( max( totalScattering.r, totalScattering.g ), totalScattering.b ) );\n\treflectedLight.indirectSpecular += radiance * singleScattering;\n\treflectedLight.indirectSpecular += multiScattering * cosineWeightedIrradiance;\n\treflectedLight.indirectDiffuse += diffuse * cosineWeightedIrradiance;\n}\n#define RE_Direct\t\t\t\tRE_Direct_Physical\n#define RE_Direct_RectArea\t\tRE_Direct_RectArea_Physical\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Physical\n#define RE_IndirectSpecular\t\tRE_IndirectSpecular_Physical\nfloat computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) {\n\treturn saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion );\n}`,wPe=`\nvec3 geometryPosition = - vViewPosition;\nvec3 geometryNormal = normal;\nvec3 geometryViewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( vViewPosition );\nvec3 geometryClearcoatNormal = vec3( 0.0 );\n#ifdef USE_CLEARCOAT\n\tgeometryClearcoatNormal = clearcoatNormal;\n#endif\n#ifdef USE_IRIDESCENCE\n\tfloat dotNVi = saturate( dot( normal, geometryViewDir ) );\n\tif ( material.iridescenceThickness == 0.0 ) {\n\t\tmaterial.iridescence = 0.0;\n\t} else {\n\t\tmaterial.iridescence = saturate( material.iridescence );\n\t}\n\tif ( material.iridescence > 0.0 ) {\n\t\tmaterial.iridescenceFresnel = evalIridescence( 1.0, material.iridescenceIOR, dotNVi, material.iridescenceThickness, material.specularColor );\n\t\tmaterial.iridescenceF0 = Schlick_to_F0( material.iridescenceFresnel, 1.0, dotNVi );\n\t}\n#endif\nIncidentLight directLight;\n#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct )\n\tPointLight pointLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tgetPointLightInfo( pointLight, geometryPosition, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_POINT_LIGHT_SHADOWS )\n\t\tpointLightShadow = pointLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getPointShadow( pointShadowMap[ i ], pointLightShadow.shadowMapSize, pointLightShadow.shadowBias, pointLightShadow.shadowRadius, vPointShadowCoord[ i ], pointLightShadow.shadowCameraNear, pointLightShadow.shadowCameraFar ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct )\n\tSpotLight spotLight;\n\tvec4 spotColor;\n\tvec3 spotLightCoord;\n\tbool inSpotLightMap;\n\t#if defined( USE_SHADOWMAP ) && NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tgetSpotLightInfo( spotLight, geometryPosition, directLight );\n\t\t#if ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS )\n\t\t#define SPOT_LIGHT_MAP_INDEX UNROLLED_LOOP_INDEX\n\t\t#elif ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\t#define SPOT_LIGHT_MAP_INDEX NUM_SPOT_LIGHT_MAPS\n\t\t#else\n\t\t#define SPOT_LIGHT_MAP_INDEX ( UNROLLED_LOOP_INDEX - NUM_SPOT_LIGHT_SHADOWS + NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS )\n\t\t#endif\n\t\t#if ( SPOT_LIGHT_MAP_INDEX < NUM_SPOT_LIGHT_MAPS )\n\t\t\tspotLightCoord = vSpotLightCoord[ i ].xyz / vSpotLightCoord[ i ].w;\n\t\t\tinSpotLightMap = all( lessThan( abs( spotLightCoord * 2. - 1. ), vec3( 1.0 ) ) );\n\t\t\tspotColor = texture2D( spotLightMap[ SPOT_LIGHT_MAP_INDEX ], spotLightCoord.xy );\n\t\t\tdirectLight.color = inSpotLightMap ? directLight.color * spotColor.rgb : directLight.color;\n\t\t#endif\n\t\t#undef SPOT_LIGHT_MAP_INDEX\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\tspotLightShadow = spotLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( spotShadowMap[ i ], spotLightShadow.shadowMapSize, spotLightShadow.shadowBias, spotLightShadow.shadowRadius, vSpotLightCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n\tDirectionalLight directionalLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tgetDirectionalLightInfo( directionalLight, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS )\n\t\tdirectionalLightShadow = directionalLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea )\n\tRectAreaLight rectAreaLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) {\n\t\trectAreaLight = rectAreaLights[ i ];\n\t\tRE_Direct_RectArea( rectAreaLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if defined( RE_IndirectDiffuse )\n\tvec3 iblIrradiance = vec3( 0.0 );\n\tvec3 irradiance = getAmbientLightIrradiance( ambientLightColor );\n\t#if defined( USE_LIGHT_PROBES )\n\t\tirradiance += getLightProbeIrradiance( lightProbe, geometryNormal );\n\t#endif\n\t#if ( NUM_HEMI_LIGHTS > 0 )\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\t\tirradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometryNormal );\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if defined( RE_IndirectSpecular )\n\tvec3 radiance = vec3( 0.0 );\n\tvec3 clearcoatRadiance = vec3( 0.0 );\n#endif`,SPe=`#if defined( RE_IndirectDiffuse )\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel = texture2D( lightMap, vLightMapUv );\n\t\tvec3 lightMapIrradiance = lightMapTexel.rgb * lightMapIntensity;\n\t\tirradiance += lightMapIrradiance;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( STANDARD ) && defined( ENVMAP_TYPE_CUBE_UV )\n\t\tiblIrradiance += getIBLIrradiance( geometryNormal );\n\t#endif\n#endif\n#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular )\n\t#ifdef USE_ANISOTROPY\n\t\tradiance += getIBLAnisotropyRadiance( geometryViewDir, geometryNormal, material.roughness, material.anisotropyB, material.anisotropy );\n\t#else\n\t\tradiance += getIBLRadiance( geometryViewDir, geometryNormal, material.roughness );\n\t#endif\n\t#ifdef USE_CLEARCOAT\n\t\tclearcoatRadiance += getIBLRadiance( geometryViewDir, geometryClearcoatNormal, material.clearcoatRoughness );\n\t#endif\n#endif`,_Pe=`#if defined( RE_IndirectDiffuse )\n\tRE_IndirectDiffuse( irradiance, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n#endif\n#if defined( RE_IndirectSpecular )\n\tRE_IndirectSpecular( radiance, iblIrradiance, clearcoatRadiance, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n#endif`,kPe=`#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tgl_FragDepthEXT = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;\n#endif`,NPe=`#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tuniform float logDepthBufFC;\n\tvarying float vFragDepth;\n\tvarying float vIsPerspective;\n#endif`,CPe=`#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t\tvarying float vIsPerspective;\n\t#else\n\t\tuniform float logDepthBufFC;\n\t#endif\n#endif`,PPe=`#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvFragDepth = 1.0 + gl_Position.w;\n\t\tvIsPerspective = float( isPerspectiveMatrix( projectionMatrix ) );\n\t#else\n\t\tif ( isPerspectiveMatrix( projectionMatrix ) ) {\n\t\t\tgl_Position.z = log2( max( EPSILON, gl_Position.w + 1.0 ) ) * logDepthBufFC - 1.0;\n\t\t\tgl_Position.z *= gl_Position.w;\n\t\t}\n\t#endif\n#endif`,TPe=`#ifdef USE_MAP\n\tvec4 sampledDiffuseColor = texture2D( map, vMapUv );\n\t#ifdef DECODE_VIDEO_TEXTURE\n\t\tsampledDiffuseColor = vec4( mix( pow( sampledDiffuseColor.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), sampledDiffuseColor.rgb * 0.0773993808, vec3( lessThanEqual( sampledDiffuseColor.rgb, vec3( 0.04045 ) ) ) ), sampledDiffuseColor.w );\n\t\n\t#endif\n\tdiffuseColor *= sampledDiffuseColor;\n#endif`,APe=`#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif`,jPe=`#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\t#if defined( USE_POINTS_UV )\n\t\tvec2 uv = vUv;\n\t#else\n\t\tvec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;\n\t#endif\n#endif\n#ifdef USE_MAP\n\tdiffuseColor *= texture2D( map, uv );\n#endif\n#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, uv ).g;\n#endif`,MPe=`#if defined( USE_POINTS_UV )\n\tvarying vec2 vUv;\n#else\n\t#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\t\tuniform mat3 uvTransform;\n\t#endif\n#endif\n#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif`,EPe=`float metalnessFactor = metalness;\n#ifdef USE_METALNESSMAP\n\tvec4 texelMetalness = texture2D( metalnessMap, vMetalnessMapUv );\n\tmetalnessFactor *= texelMetalness.b;\n#endif`,DPe=`#ifdef USE_METALNESSMAP\n\tuniform sampler2D metalnessMap;\n#endif`,FPe=`#if defined( USE_MORPHCOLORS ) && defined( MORPHTARGETS_TEXTURE )\n\tvColor *= morphTargetBaseInfluence;\n\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\t#if defined( USE_COLOR_ALPHA )\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ) * morphTargetInfluences[ i ];\n\t\t#elif defined( USE_COLOR )\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ).rgb * morphTargetInfluences[ i ];\n\t\t#endif\n\t}\n#endif`,RPe=`#ifdef USE_MORPHNORMALS\n\tobjectNormal *= morphTargetBaseInfluence;\n\t#ifdef MORPHTARGETS_TEXTURE\n\t\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) objectNormal += getMorph( gl_VertexID, i, 1 ).xyz * morphTargetInfluences[ i ];\n\t\t}\n\t#else\n\t\tobjectNormal += morphNormal0 * morphTargetInfluences[ 0 ];\n\t\tobjectNormal += morphNormal1 * morphTargetInfluences[ 1 ];\n\t\tobjectNormal += morphNormal2 * morphTargetInfluences[ 2 ];\n\t\tobjectNormal += morphNormal3 * morphTargetInfluences[ 3 ];\n\t#endif\n#endif`,LPe=`#ifdef USE_MORPHTARGETS\n\tuniform float morphTargetBaseInfluence;\n\t#ifdef MORPHTARGETS_TEXTURE\n\t\tuniform float morphTargetInfluences[ MORPHTARGETS_COUNT ];\n\t\tuniform sampler2DArray morphTargetsTexture;\n\t\tuniform ivec2 morphTargetsTextureSize;\n\t\tvec4 getMorph( const in int vertexIndex, const in int morphTargetIndex, const in int offset ) {\n\t\t\tint texelIndex = vertexIndex * MORPHTARGETS_TEXTURE_STRIDE + offset;\n\t\t\tint y = texelIndex / morphTargetsTextureSize.x;\n\t\t\tint x = texelIndex - y * morphTargetsTextureSize.x;\n\t\t\tivec3 morphUV = ivec3( x, y, morphTargetIndex );\n\t\t\treturn texelFetch( morphTargetsTexture, morphUV, 0 );\n\t\t}\n\t#else\n\t\t#ifndef USE_MORPHNORMALS\n\t\t\tuniform float morphTargetInfluences[ 8 ];\n\t\t#else\n\t\t\tuniform float morphTargetInfluences[ 4 ];\n\t\t#endif\n\t#endif\n#endif`,OPe=`#ifdef USE_MORPHTARGETS\n\ttransformed *= morphTargetBaseInfluence;\n\t#ifdef MORPHTARGETS_TEXTURE\n\t\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) transformed += getMorph( gl_VertexID, i, 0 ).xyz * morphTargetInfluences[ i ];\n\t\t}\n\t#else\n\t\ttransformed += morphTarget0 * morphTargetInfluences[ 0 ];\n\t\ttransformed += morphTarget1 * morphTargetInfluences[ 1 ];\n\t\ttransformed += morphTarget2 * morphTargetInfluences[ 2 ];\n\t\ttransformed += morphTarget3 * morphTargetInfluences[ 3 ];\n\t\t#ifndef USE_MORPHNORMALS\n\t\t\ttransformed += morphTarget4 * morphTargetInfluences[ 4 ];\n\t\t\ttransformed += morphTarget5 * morphTargetInfluences[ 5 ];\n\t\t\ttransformed += morphTarget6 * morphTargetInfluences[ 6 ];\n\t\t\ttransformed += morphTarget7 * morphTargetInfluences[ 7 ];\n\t\t#endif\n\t#endif\n#endif`,IPe=`float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;\n#ifdef FLAT_SHADED\n\tvec3 fdx = dFdx( vViewPosition );\n\tvec3 fdy = dFdy( vViewPosition );\n\tvec3 normal = normalize( cross( fdx, fdy ) );\n#else\n\tvec3 normal = normalize( vNormal );\n\t#ifdef DOUBLE_SIDED\n\t\tnormal *= faceDirection;\n\t#endif\n#endif\n#if defined( USE_NORMALMAP_TANGENTSPACE ) || defined( USE_CLEARCOAT_NORMALMAP ) || defined( USE_ANISOTROPY )\n\t#ifdef USE_TANGENT\n\t\tmat3 tbn = mat3( normalize( vTangent ), normalize( vBitangent ), normal );\n\t#else\n\t\tmat3 tbn = getTangentFrame( - vViewPosition, normal,\n\t\t#if defined( USE_NORMALMAP )\n\t\t\tvNormalMapUv\n\t\t#elif defined( USE_CLEARCOAT_NORMALMAP )\n\t\t\tvClearcoatNormalMapUv\n\t\t#else\n\t\t\tvUv\n\t\t#endif\n\t\t);\n\t#endif\n\t#if defined( DOUBLE_SIDED ) && ! defined( FLAT_SHADED )\n\t\ttbn[0] *= faceDirection;\n\t\ttbn[1] *= faceDirection;\n\t#endif\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\t#ifdef USE_TANGENT\n\t\tmat3 tbn2 = mat3( normalize( vTangent ), normalize( vBitangent ), normal );\n\t#else\n\t\tmat3 tbn2 = getTangentFrame( - vViewPosition, normal, vClearcoatNormalMapUv );\n\t#endif\n\t#if defined( DOUBLE_SIDED ) && ! defined( FLAT_SHADED )\n\t\ttbn2[0] *= faceDirection;\n\t\ttbn2[1] *= faceDirection;\n\t#endif\n#endif\nvec3 nonPerturbedNormal = normal;`,zPe=`#ifdef USE_NORMALMAP_OBJECTSPACE\n\tnormal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;\n\t#ifdef FLIP_SIDED\n\t\tnormal = - normal;\n\t#endif\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\tnormal = normalize( normalMatrix * normal );\n#elif defined( USE_NORMALMAP_TANGENTSPACE )\n\tvec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;\n\tmapN.xy *= normalScale;\n\tnormal = normalize( tbn * mapN );\n#elif defined( USE_BUMPMAP )\n\tnormal = perturbNormalArb( - vViewPosition, normal, dHdxy_fwd(), faceDirection );\n#endif`,UPe=`#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif`,BPe=`#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif`,HPe=`#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif`,qPe=`#ifdef USE_NORMALMAP\n\tuniform sampler2D normalMap;\n\tuniform vec2 normalScale;\n#endif\n#ifdef USE_NORMALMAP_OBJECTSPACE\n\tuniform mat3 normalMatrix;\n#endif\n#if ! defined ( USE_TANGENT ) && ( defined ( USE_NORMALMAP_TANGENTSPACE ) || defined ( USE_CLEARCOAT_NORMALMAP ) || defined( USE_ANISOTROPY ) )\n\tmat3 getTangentFrame( vec3 eye_pos, vec3 surf_norm, vec2 uv ) {\n\t\tvec3 q0 = dFdx( eye_pos.xyz );\n\t\tvec3 q1 = dFdy( eye_pos.xyz );\n\t\tvec2 st0 = dFdx( uv.st );\n\t\tvec2 st1 = dFdy( uv.st );\n\t\tvec3 N = surf_norm;\n\t\tvec3 q1perp = cross( q1, N );\n\t\tvec3 q0perp = cross( N, q0 );\n\t\tvec3 T = q1perp * st0.x + q0perp * st1.x;\n\t\tvec3 B = q1perp * st0.y + q0perp * st1.y;\n\t\tfloat det = max( dot( T, T ), dot( B, B ) );\n\t\tfloat scale = ( det == 0.0 ) ? 0.0 : inversesqrt( det );\n\t\treturn mat3( T * scale, B * scale, N );\n\t}\n#endif`,$Pe=`#ifdef USE_CLEARCOAT\n\tvec3 clearcoatNormal = nonPerturbedNormal;\n#endif`,VPe=`#ifdef USE_CLEARCOAT_NORMALMAP\n\tvec3 clearcoatMapN = texture2D( clearcoatNormalMap, vClearcoatNormalMapUv ).xyz * 2.0 - 1.0;\n\tclearcoatMapN.xy *= clearcoatNormalScale;\n\tclearcoatNormal = normalize( tbn2 * clearcoatMapN );\n#endif`,GPe=`#ifdef USE_CLEARCOATMAP\n\tuniform sampler2D clearcoatMap;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform sampler2D clearcoatNormalMap;\n\tuniform vec2 clearcoatNormalScale;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform sampler2D clearcoatRoughnessMap;\n#endif`,WPe=`#ifdef USE_IRIDESCENCEMAP\n\tuniform sampler2D iridescenceMap;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tuniform sampler2D iridescenceThicknessMap;\n#endif`,KPe=`#ifdef OPAQUE\ndiffuseColor.a = 1.0;\n#endif\n#ifdef USE_TRANSMISSION\ndiffuseColor.a *= material.transmissionAlpha;\n#endif\ngl_FragColor = vec4( outgoingLight, diffuseColor.a );`,XPe=`vec3 packNormalToRGB( const in vec3 normal ) {\n\treturn normalize( normal ) * 0.5 + 0.5;\n}\nvec3 unpackRGBToNormal( const in vec3 rgb ) {\n\treturn 2.0 * rgb.xyz - 1.0;\n}\nconst float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;\nconst vec3 PackFactors = vec3( 256. * 256. * 256., 256. * 256., 256. );\nconst vec4 UnpackFactors = UnpackDownscale / vec4( PackFactors, 1. );\nconst float ShiftRight8 = 1. / 256.;\nvec4 packDepthToRGBA( const in float v ) {\n\tvec4 r = vec4( fract( v * PackFactors ), v );\n\tr.yzw -= r.xyz * ShiftRight8;\treturn r * PackUpscale;\n}\nfloat unpackRGBAToDepth( const in vec4 v ) {\n\treturn dot( v, UnpackFactors );\n}\nvec2 packDepthToRG( in highp float v ) {\n\treturn packDepthToRGBA( v ).yx;\n}\nfloat unpackRGToDepth( const in highp vec2 v ) {\n\treturn unpackRGBAToDepth( vec4( v.xy, 0.0, 0.0 ) );\n}\nvec4 pack2HalfToRGBA( vec2 v ) {\n\tvec4 r = vec4( v.x, fract( v.x * 255.0 ), v.y, fract( v.y * 255.0 ) );\n\treturn vec4( r.x - r.y / 255.0, r.y, r.z - r.w / 255.0, r.w );\n}\nvec2 unpackRGBATo2Half( vec4 v ) {\n\treturn vec2( v.x + ( v.y / 255.0 ), v.z + ( v.w / 255.0 ) );\n}\nfloat viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( viewZ + near ) / ( near - far );\n}\nfloat orthographicDepthToViewZ( const in float depth, const in float near, const in float far ) {\n\treturn depth * ( near - far ) - near;\n}\nfloat viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( ( near + viewZ ) * far ) / ( ( far - near ) * viewZ );\n}\nfloat perspectiveDepthToViewZ( const in float depth, const in float near, const in float far ) {\n\treturn ( near * far ) / ( ( far - near ) * depth - far );\n}`,YPe=`#ifdef PREMULTIPLIED_ALPHA\n\tgl_FragColor.rgb *= gl_FragColor.a;\n#endif`,QPe=`vec4 mvPosition = vec4( transformed, 1.0 );\n#ifdef USE_BATCHING\n\tmvPosition = batchingMatrix * mvPosition;\n#endif\n#ifdef USE_INSTANCING\n\tmvPosition = instanceMatrix * mvPosition;\n#endif\nmvPosition = modelViewMatrix * mvPosition;\ngl_Position = projectionMatrix * mvPosition;`,ZPe=`#ifdef DITHERING\n\tgl_FragColor.rgb = dithering( gl_FragColor.rgb );\n#endif`,JPe=`#ifdef DITHERING\n\tvec3 dithering( vec3 color ) {\n\t\tfloat grid_position = rand( gl_FragCoord.xy );\n\t\tvec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 );\n\t\tdither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position );\n\t\treturn color + dither_shift_RGB;\n\t}\n#endif`,eTe=`float roughnessFactor = roughness;\n#ifdef USE_ROUGHNESSMAP\n\tvec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv );\n\troughnessFactor *= texelRoughness.g;\n#endif`,tTe=`#ifdef USE_ROUGHNESSMAP\n\tuniform sampler2D roughnessMap;\n#endif`,nTe=`#if NUM_SPOT_LIGHT_COORDS > 0\n\tvarying vec4 vSpotLightCoord[ NUM_SPOT_LIGHT_COORDS ];\n#endif\n#if NUM_SPOT_LIGHT_MAPS > 0\n\tuniform sampler2D spotLightMap[ NUM_SPOT_LIGHT_MAPS ];\n#endif\n#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D directionalShadowMap[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D spotShadowMap[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D pointShadowMap[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n\tfloat texture2DCompare( sampler2D depths, vec2 uv, float compare ) {\n\t\treturn step( compare, unpackRGBAToDepth( texture2D( depths, uv ) ) );\n\t}\n\tvec2 texture2DDistribution( sampler2D shadow, vec2 uv ) {\n\t\treturn unpackRGBATo2Half( texture2D( shadow, uv ) );\n\t}\n\tfloat VSMShadow (sampler2D shadow, vec2 uv, float compare ){\n\t\tfloat occlusion = 1.0;\n\t\tvec2 distribution = texture2DDistribution( shadow, uv );\n\t\tfloat hard_shadow = step( compare , distribution.x );\n\t\tif (hard_shadow != 1.0 ) {\n\t\t\tfloat distance = compare - distribution.x ;\n\t\t\tfloat variance = max( 0.00000, distribution.y * distribution.y );\n\t\t\tfloat softness_probability = variance / (variance + distance * distance );\t\t\tsoftness_probability = clamp( ( softness_probability - 0.3 ) / ( 0.95 - 0.3 ), 0.0, 1.0 );\t\t\tocclusion = clamp( max( hard_shadow, softness_probability ), 0.0, 1.0 );\n\t\t}\n\t\treturn occlusion;\n\t}\n\tfloat getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord ) {\n\t\tfloat shadow = 1.0;\n\t\tshadowCoord.xyz /= shadowCoord.w;\n\t\tshadowCoord.z += shadowBias;\n\t\tbool inFrustum = shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0;\n\t\tbool frustumTest = inFrustum && shadowCoord.z <= 1.0;\n\t\tif ( frustumTest ) {\n\t\t#if defined( SHADOWMAP_TYPE_PCF )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tfloat dx2 = dx0 / 2.0;\n\t\t\tfloat dy2 = dy0 / 2.0;\n\t\t\tfloat dx3 = dx1 / 2.0;\n\t\t\tfloat dy3 = dy1 / 2.0;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 17.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx = texelSize.x;\n\t\t\tfloat dy = texelSize.y;\n\t\t\tvec2 uv = shadowCoord.xy;\n\t\t\tvec2 f = fract( uv * shadowMapSize + 0.5 );\n\t\t\tuv -= f * texelSize;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, uv, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( dx, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 0.0, dy ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + texelSize, shadowCoord.z ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, dy ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( 0.0, -dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 0.0, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( mix( texture2DCompare( shadowMap, uv + vec2( -dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t  texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t  f.x ),\n\t\t\t\t\t mix( texture2DCompare( shadowMap, uv + vec2( -dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t  texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t  f.x ),\n\t\t\t\t\t f.y )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_VSM )\n\t\t\tshadow = VSMShadow( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#else\n\t\t\tshadow = texture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#endif\n\t\t}\n\t\treturn shadow;\n\t}\n\tvec2 cubeToUV( vec3 v, float texelSizeY ) {\n\t\tvec3 absV = abs( v );\n\t\tfloat scaleToCube = 1.0 / max( absV.x, max( absV.y, absV.z ) );\n\t\tabsV *= scaleToCube;\n\t\tv *= scaleToCube * ( 1.0 - 2.0 * texelSizeY );\n\t\tvec2 planar = v.xy;\n\t\tfloat almostATexel = 1.5 * texelSizeY;\n\t\tfloat almostOne = 1.0 - almostATexel;\n\t\tif ( absV.z >= almostOne ) {\n\t\t\tif ( v.z > 0.0 )\n\t\t\t\tplanar.x = 4.0 - v.x;\n\t\t} else if ( absV.x >= almostOne ) {\n\t\t\tfloat signX = sign( v.x );\n\t\t\tplanar.x = v.z * signX + 2.0 * signX;\n\t\t} else if ( absV.y >= almostOne ) {\n\t\t\tfloat signY = sign( v.y );\n\t\t\tplanar.x = v.x + 2.0 * signY + 2.0;\n\t\t\tplanar.y = v.z * signY - 2.0;\n\t\t}\n\t\treturn vec2( 0.125, 0.25 ) * planar + vec2( 0.375, 0.75 );\n\t}\n\tfloat getPointShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) {\n\t\tvec2 texelSize = vec2( 1.0 ) / ( shadowMapSize * vec2( 4.0, 2.0 ) );\n\t\tvec3 lightToPosition = shadowCoord.xyz;\n\t\tfloat dp = ( length( lightToPosition ) - shadowCameraNear ) / ( shadowCameraFar - shadowCameraNear );\t\tdp += shadowBias;\n\t\tvec3 bd3D = normalize( lightToPosition );\n\t\t#if defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_PCF_SOFT ) || defined( SHADOWMAP_TYPE_VSM )\n\t\t\tvec2 offset = vec2( - 1, 1 ) * shadowRadius * texelSize.y;\n\t\t\treturn (\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxx, texelSize.y ), dp )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\treturn texture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp );\n\t\t#endif\n\t}\n#endif`,rTe=`#if NUM_SPOT_LIGHT_COORDS > 0\n\tuniform mat4 spotLightMatrix[ NUM_SPOT_LIGHT_COORDS ];\n\tvarying vec4 vSpotLightCoord[ NUM_SPOT_LIGHT_COORDS ];\n#endif\n#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 pointShadowMatrix[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n#endif`,aTe=`#if ( defined( USE_SHADOWMAP ) && ( NUM_DIR_LIGHT_SHADOWS > 0 || NUM_POINT_LIGHT_SHADOWS > 0 ) ) || ( NUM_SPOT_LIGHT_COORDS > 0 )\n\tvec3 shadowWorldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\tvec4 shadowWorldPosition;\n#endif\n#if defined( USE_SHADOWMAP )\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );\n\t\t\tvDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * shadowWorldPosition;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * pointLightShadows[ i ].shadowNormalBias, 0 );\n\t\t\tvPointShadowCoord[ i ] = pointShadowMatrix[ i ] * shadowWorldPosition;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if NUM_SPOT_LIGHT_COORDS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_COORDS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition;\n\t\t#if ( defined( USE_SHADOWMAP ) && UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\t\tshadowWorldPosition.xyz += shadowWorldNormal * spotLightShadows[ i ].shadowNormalBias;\n\t\t#endif\n\t\tvSpotLightCoord[ i ] = spotLightMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n#endif`,iTe=`float getShadowMask() {\n\tfloat shadow = 1.0;\n\t#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tdirectionalLight = directionalLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tspotLight = spotLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotLightCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tpointLight = pointLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#endif\n\treturn shadow;\n}`,sTe=`#ifdef USE_SKINNING\n\tmat4 boneMatX = getBoneMatrix( skinIndex.x );\n\tmat4 boneMatY = getBoneMatrix( skinIndex.y );\n\tmat4 boneMatZ = getBoneMatrix( skinIndex.z );\n\tmat4 boneMatW = getBoneMatrix( skinIndex.w );\n#endif`,oTe=`#ifdef USE_SKINNING\n\tuniform mat4 bindMatrix;\n\tuniform mat4 bindMatrixInverse;\n\tuniform highp sampler2D boneTexture;\n\tmat4 getBoneMatrix( const in float i ) {\n\t\tint size = textureSize( boneTexture, 0 ).x;\n\t\tint j = int( i ) * 4;\n\t\tint x = j % size;\n\t\tint y = j / size;\n\t\tvec4 v1 = texelFetch( boneTexture, ivec2( x, y ), 0 );\n\t\tvec4 v2 = texelFetch( boneTexture, ivec2( x + 1, y ), 0 );\n\t\tvec4 v3 = texelFetch( boneTexture, ivec2( x + 2, y ), 0 );\n\t\tvec4 v4 = texelFetch( boneTexture, ivec2( x + 3, y ), 0 );\n\t\treturn mat4( v1, v2, v3, v4 );\n\t}\n#endif`,lTe=`#ifdef USE_SKINNING\n\tvec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );\n\tvec4 skinned = vec4( 0.0 );\n\tskinned += boneMatX * skinVertex * skinWeight.x;\n\tskinned += boneMatY * skinVertex * skinWeight.y;\n\tskinned += boneMatZ * skinVertex * skinWeight.z;\n\tskinned += boneMatW * skinVertex * skinWeight.w;\n\ttransformed = ( bindMatrixInverse * skinned ).xyz;\n#endif`,cTe=`#ifdef USE_SKINNING\n\tmat4 skinMatrix = mat4( 0.0 );\n\tskinMatrix += skinWeight.x * boneMatX;\n\tskinMatrix += skinWeight.y * boneMatY;\n\tskinMatrix += skinWeight.z * boneMatZ;\n\tskinMatrix += skinWeight.w * boneMatW;\n\tskinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;\n\tobjectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;\n\t#ifdef USE_TANGENT\n\t\tobjectTangent = vec4( skinMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#endif\n#endif`,dTe=`float specularStrength;\n#ifdef USE_SPECULARMAP\n\tvec4 texelSpecular = texture2D( specularMap, vSpecularMapUv );\n\tspecularStrength = texelSpecular.r;\n#else\n\tspecularStrength = 1.0;\n#endif`,uTe=`#ifdef USE_SPECULARMAP\n\tuniform sampler2D specularMap;\n#endif`,mTe=`#if defined( TONE_MAPPING )\n\tgl_FragColor.rgb = toneMapping( gl_FragColor.rgb );\n#endif`,hTe=`#ifndef saturate\n#define saturate( a ) clamp( a, 0.0, 1.0 )\n#endif\nuniform float toneMappingExposure;\nvec3 LinearToneMapping( vec3 color ) {\n\treturn saturate( toneMappingExposure * color );\n}\nvec3 ReinhardToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( color / ( vec3( 1.0 ) + color ) );\n}\nvec3 OptimizedCineonToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\tcolor = max( vec3( 0.0 ), color - 0.004 );\n\treturn pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) );\n}\nvec3 RRTAndODTFit( vec3 v ) {\n\tvec3 a = v * ( v + 0.0245786 ) - 0.000090537;\n\tvec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081;\n\treturn a / b;\n}\nvec3 ACESFilmicToneMapping( vec3 color ) {\n\tconst mat3 ACESInputMat = mat3(\n\t\tvec3( 0.59719, 0.07600, 0.02840 ),\t\tvec3( 0.35458, 0.90834, 0.13383 ),\n\t\tvec3( 0.04823, 0.01566, 0.83777 )\n\t);\n\tconst mat3 ACESOutputMat = mat3(\n\t\tvec3(  1.60475, -0.10208, -0.00327 ),\t\tvec3( -0.53108,  1.10813, -0.07276 ),\n\t\tvec3( -0.07367, -0.00605,  1.07602 )\n\t);\n\tcolor *= toneMappingExposure / 0.6;\n\tcolor = ACESInputMat * color;\n\tcolor = RRTAndODTFit( color );\n\tcolor = ACESOutputMat * color;\n\treturn saturate( color );\n}\nvec3 CustomToneMapping( vec3 color ) { return color; }`,pTe=`#ifdef USE_TRANSMISSION\n\tmaterial.transmission = transmission;\n\tmaterial.transmissionAlpha = 1.0;\n\tmaterial.thickness = thickness;\n\tmaterial.attenuationDistance = attenuationDistance;\n\tmaterial.attenuationColor = attenuationColor;\n\t#ifdef USE_TRANSMISSIONMAP\n\t\tmaterial.transmission *= texture2D( transmissionMap, vTransmissionMapUv ).r;\n\t#endif\n\t#ifdef USE_THICKNESSMAP\n\t\tmaterial.thickness *= texture2D( thicknessMap, vThicknessMapUv ).g;\n\t#endif\n\tvec3 pos = vWorldPosition;\n\tvec3 v = normalize( cameraPosition - pos );\n\tvec3 n = inverseTransformDirection( normal, viewMatrix );\n\tvec4 transmitted = getIBLVolumeRefraction(\n\t\tn, v, material.roughness, material.diffuseColor, material.specularColor, material.specularF90,\n\t\tpos, modelMatrix, viewMatrix, projectionMatrix, material.ior, material.thickness,\n\t\tmaterial.attenuationColor, material.attenuationDistance );\n\tmaterial.transmissionAlpha = mix( material.transmissionAlpha, transmitted.a, material.transmission );\n\ttotalDiffuse = mix( totalDiffuse, transmitted.rgb, material.transmission );\n#endif`,fTe=`#ifdef USE_TRANSMISSION\n\tuniform float transmission;\n\tuniform float thickness;\n\tuniform float attenuationDistance;\n\tuniform vec3 attenuationColor;\n\t#ifdef USE_TRANSMISSIONMAP\n\t\tuniform sampler2D transmissionMap;\n\t#endif\n\t#ifdef USE_THICKNESSMAP\n\t\tuniform sampler2D thicknessMap;\n\t#endif\n\tuniform vec2 transmissionSamplerSize;\n\tuniform sampler2D transmissionSamplerMap;\n\tuniform mat4 modelMatrix;\n\tuniform mat4 projectionMatrix;\n\tvarying vec3 vWorldPosition;\n\tfloat w0( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a * ( a * ( - a + 3.0 ) - 3.0 ) + 1.0 );\n\t}\n\tfloat w1( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a *  a * ( 3.0 * a - 6.0 ) + 4.0 );\n\t}\n\tfloat w2( float a ){\n\t\treturn ( 1.0 / 6.0 ) * ( a * ( a * ( - 3.0 * a + 3.0 ) + 3.0 ) + 1.0 );\n\t}\n\tfloat w3( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a * a * a );\n\t}\n\tfloat g0( float a ) {\n\t\treturn w0( a ) + w1( a );\n\t}\n\tfloat g1( float a ) {\n\t\treturn w2( a ) + w3( a );\n\t}\n\tfloat h0( float a ) {\n\t\treturn - 1.0 + w1( a ) / ( w0( a ) + w1( a ) );\n\t}\n\tfloat h1( float a ) {\n\t\treturn 1.0 + w3( a ) / ( w2( a ) + w3( a ) );\n\t}\n\tvec4 bicubic( sampler2D tex, vec2 uv, vec4 texelSize, float lod ) {\n\t\tuv = uv * texelSize.zw + 0.5;\n\t\tvec2 iuv = floor( uv );\n\t\tvec2 fuv = fract( uv );\n\t\tfloat g0x = g0( fuv.x );\n\t\tfloat g1x = g1( fuv.x );\n\t\tfloat h0x = h0( fuv.x );\n\t\tfloat h1x = h1( fuv.x );\n\t\tfloat h0y = h0( fuv.y );\n\t\tfloat h1y = h1( fuv.y );\n\t\tvec2 p0 = ( vec2( iuv.x + h0x, iuv.y + h0y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p1 = ( vec2( iuv.x + h1x, iuv.y + h0y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p2 = ( vec2( iuv.x + h0x, iuv.y + h1y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p3 = ( vec2( iuv.x + h1x, iuv.y + h1y ) - 0.5 ) * texelSize.xy;\n\t\treturn g0( fuv.y ) * ( g0x * textureLod( tex, p0, lod ) + g1x * textureLod( tex, p1, lod ) ) +\n\t\t\tg1( fuv.y ) * ( g0x * textureLod( tex, p2, lod ) + g1x * textureLod( tex, p3, lod ) );\n\t}\n\tvec4 textureBicubic( sampler2D sampler, vec2 uv, float lod ) {\n\t\tvec2 fLodSize = vec2( textureSize( sampler, int( lod ) ) );\n\t\tvec2 cLodSize = vec2( textureSize( sampler, int( lod + 1.0 ) ) );\n\t\tvec2 fLodSizeInv = 1.0 / fLodSize;\n\t\tvec2 cLodSizeInv = 1.0 / cLodSize;\n\t\tvec4 fSample = bicubic( sampler, uv, vec4( fLodSizeInv, fLodSize ), floor( lod ) );\n\t\tvec4 cSample = bicubic( sampler, uv, vec4( cLodSizeInv, cLodSize ), ceil( lod ) );\n\t\treturn mix( fSample, cSample, fract( lod ) );\n\t}\n\tvec3 getVolumeTransmissionRay( const in vec3 n, const in vec3 v, const in float thickness, const in float ior, const in mat4 modelMatrix ) {\n\t\tvec3 refractionVector = refract( - v, normalize( n ), 1.0 / ior );\n\t\tvec3 modelScale;\n\t\tmodelScale.x = length( vec3( modelMatrix[ 0 ].xyz ) );\n\t\tmodelScale.y = length( vec3( modelMatrix[ 1 ].xyz ) );\n\t\tmodelScale.z = length( vec3( modelMatrix[ 2 ].xyz ) );\n\t\treturn normalize( refractionVector ) * thickness * modelScale;\n\t}\n\tfloat applyIorToRoughness( const in float roughness, const in float ior ) {\n\t\treturn roughness * clamp( ior * 2.0 - 2.0, 0.0, 1.0 );\n\t}\n\tvec4 getTransmissionSample( const in vec2 fragCoord, const in float roughness, const in float ior ) {\n\t\tfloat lod = log2( transmissionSamplerSize.x ) * applyIorToRoughness( roughness, ior );\n\t\treturn textureBicubic( transmissionSamplerMap, fragCoord.xy, lod );\n\t}\n\tvec3 volumeAttenuation( const in float transmissionDistance, const in vec3 attenuationColor, const in float attenuationDistance ) {\n\t\tif ( isinf( attenuationDistance ) ) {\n\t\t\treturn vec3( 1.0 );\n\t\t} else {\n\t\t\tvec3 attenuationCoefficient = -log( attenuationColor ) / attenuationDistance;\n\t\t\tvec3 transmittance = exp( - attenuationCoefficient * transmissionDistance );\t\t\treturn transmittance;\n\t\t}\n\t}\n\tvec4 getIBLVolumeRefraction( const in vec3 n, const in vec3 v, const in float roughness, const in vec3 diffuseColor,\n\t\tconst in vec3 specularColor, const in float specularF90, const in vec3 position, const in mat4 modelMatrix,\n\t\tconst in mat4 viewMatrix, const in mat4 projMatrix, const in float ior, const in float thickness,\n\t\tconst in vec3 attenuationColor, const in float attenuationDistance ) {\n\t\tvec3 transmissionRay = getVolumeTransmissionRay( n, v, thickness, ior, modelMatrix );\n\t\tvec3 refractedRayExit = position + transmissionRay;\n\t\tvec4 ndcPos = projMatrix * viewMatrix * vec4( refractedRayExit, 1.0 );\n\t\tvec2 refractionCoords = ndcPos.xy / ndcPos.w;\n\t\trefractionCoords += 1.0;\n\t\trefractionCoords /= 2.0;\n\t\tvec4 transmittedLight = getTransmissionSample( refractionCoords, roughness, ior );\n\t\tvec3 transmittance = diffuseColor * volumeAttenuation( length( transmissionRay ), attenuationColor, attenuationDistance );\n\t\tvec3 attenuatedColor = transmittance * transmittedLight.rgb;\n\t\tvec3 F = EnvironmentBRDF( n, v, specularColor, specularF90, roughness );\n\t\tfloat transmittanceFactor = ( transmittance.r + transmittance.g + transmittance.b ) / 3.0;\n\t\treturn vec4( ( 1.0 - F ) * attenuatedColor, 1.0 - ( 1.0 - transmittedLight.a ) * transmittanceFactor );\n\t}\n#endif`,gTe=`#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvarying vec2 vUv;\n#endif\n#ifdef USE_MAP\n\tvarying vec2 vMapUv;\n#endif\n#ifdef USE_ALPHAMAP\n\tvarying vec2 vAlphaMapUv;\n#endif\n#ifdef USE_LIGHTMAP\n\tvarying vec2 vLightMapUv;\n#endif\n#ifdef USE_AOMAP\n\tvarying vec2 vAoMapUv;\n#endif\n#ifdef USE_BUMPMAP\n\tvarying vec2 vBumpMapUv;\n#endif\n#ifdef USE_NORMALMAP\n\tvarying vec2 vNormalMapUv;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tvarying vec2 vEmissiveMapUv;\n#endif\n#ifdef USE_METALNESSMAP\n\tvarying vec2 vMetalnessMapUv;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tvarying vec2 vRoughnessMapUv;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tvarying vec2 vAnisotropyMapUv;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tvarying vec2 vClearcoatMapUv;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tvarying vec2 vClearcoatNormalMapUv;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tvarying vec2 vClearcoatRoughnessMapUv;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tvarying vec2 vIridescenceMapUv;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tvarying vec2 vIridescenceThicknessMapUv;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tvarying vec2 vSheenColorMapUv;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tvarying vec2 vSheenRoughnessMapUv;\n#endif\n#ifdef USE_SPECULARMAP\n\tvarying vec2 vSpecularMapUv;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tvarying vec2 vSpecularColorMapUv;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tvarying vec2 vSpecularIntensityMapUv;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tuniform mat3 transmissionMapTransform;\n\tvarying vec2 vTransmissionMapUv;\n#endif\n#ifdef USE_THICKNESSMAP\n\tuniform mat3 thicknessMapTransform;\n\tvarying vec2 vThicknessMapUv;\n#endif`,bTe=`#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvarying vec2 vUv;\n#endif\n#ifdef USE_MAP\n\tuniform mat3 mapTransform;\n\tvarying vec2 vMapUv;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform mat3 alphaMapTransform;\n\tvarying vec2 vAlphaMapUv;\n#endif\n#ifdef USE_LIGHTMAP\n\tuniform mat3 lightMapTransform;\n\tvarying vec2 vLightMapUv;\n#endif\n#ifdef USE_AOMAP\n\tuniform mat3 aoMapTransform;\n\tvarying vec2 vAoMapUv;\n#endif\n#ifdef USE_BUMPMAP\n\tuniform mat3 bumpMapTransform;\n\tvarying vec2 vBumpMapUv;\n#endif\n#ifdef USE_NORMALMAP\n\tuniform mat3 normalMapTransform;\n\tvarying vec2 vNormalMapUv;\n#endif\n#ifdef USE_DISPLACEMENTMAP\n\tuniform mat3 displacementMapTransform;\n\tvarying vec2 vDisplacementMapUv;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tuniform mat3 emissiveMapTransform;\n\tvarying vec2 vEmissiveMapUv;\n#endif\n#ifdef USE_METALNESSMAP\n\tuniform mat3 metalnessMapTransform;\n\tvarying vec2 vMetalnessMapUv;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tuniform mat3 roughnessMapTransform;\n\tvarying vec2 vRoughnessMapUv;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tuniform mat3 anisotropyMapTransform;\n\tvarying vec2 vAnisotropyMapUv;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tuniform mat3 clearcoatMapTransform;\n\tvarying vec2 vClearcoatMapUv;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform mat3 clearcoatNormalMapTransform;\n\tvarying vec2 vClearcoatNormalMapUv;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform mat3 clearcoatRoughnessMapTransform;\n\tvarying vec2 vClearcoatRoughnessMapUv;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tuniform mat3 sheenColorMapTransform;\n\tvarying vec2 vSheenColorMapUv;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tuniform mat3 sheenRoughnessMapTransform;\n\tvarying vec2 vSheenRoughnessMapUv;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tuniform mat3 iridescenceMapTransform;\n\tvarying vec2 vIridescenceMapUv;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tuniform mat3 iridescenceThicknessMapTransform;\n\tvarying vec2 vIridescenceThicknessMapUv;\n#endif\n#ifdef USE_SPECULARMAP\n\tuniform mat3 specularMapTransform;\n\tvarying vec2 vSpecularMapUv;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tuniform mat3 specularColorMapTransform;\n\tvarying vec2 vSpecularColorMapUv;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tuniform mat3 specularIntensityMapTransform;\n\tvarying vec2 vSpecularIntensityMapUv;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tuniform mat3 transmissionMapTransform;\n\tvarying vec2 vTransmissionMapUv;\n#endif\n#ifdef USE_THICKNESSMAP\n\tuniform mat3 thicknessMapTransform;\n\tvarying vec2 vThicknessMapUv;\n#endif`,xTe=`#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvUv = vec3( uv, 1 ).xy;\n#endif\n#ifdef USE_MAP\n\tvMapUv = ( mapTransform * vec3( MAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ALPHAMAP\n\tvAlphaMapUv = ( alphaMapTransform * vec3( ALPHAMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_LIGHTMAP\n\tvLightMapUv = ( lightMapTransform * vec3( LIGHTMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_AOMAP\n\tvAoMapUv = ( aoMapTransform * vec3( AOMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_BUMPMAP\n\tvBumpMapUv = ( bumpMapTransform * vec3( BUMPMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_NORMALMAP\n\tvNormalMapUv = ( normalMapTransform * vec3( NORMALMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_DISPLACEMENTMAP\n\tvDisplacementMapUv = ( displacementMapTransform * vec3( DISPLACEMENTMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tvEmissiveMapUv = ( emissiveMapTransform * vec3( EMISSIVEMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_METALNESSMAP\n\tvMetalnessMapUv = ( metalnessMapTransform * vec3( METALNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tvRoughnessMapUv = ( roughnessMapTransform * vec3( ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tvAnisotropyMapUv = ( anisotropyMapTransform * vec3( ANISOTROPYMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tvClearcoatMapUv = ( clearcoatMapTransform * vec3( CLEARCOATMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tvClearcoatNormalMapUv = ( clearcoatNormalMapTransform * vec3( CLEARCOAT_NORMALMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tvClearcoatRoughnessMapUv = ( clearcoatRoughnessMapTransform * vec3( CLEARCOAT_ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tvIridescenceMapUv = ( iridescenceMapTransform * vec3( IRIDESCENCEMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tvIridescenceThicknessMapUv = ( iridescenceThicknessMapTransform * vec3( IRIDESCENCE_THICKNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tvSheenColorMapUv = ( sheenColorMapTransform * vec3( SHEEN_COLORMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tvSheenRoughnessMapUv = ( sheenRoughnessMapTransform * vec3( SHEEN_ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULARMAP\n\tvSpecularMapUv = ( specularMapTransform * vec3( SPECULARMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tvSpecularColorMapUv = ( specularColorMapTransform * vec3( SPECULAR_COLORMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tvSpecularIntensityMapUv = ( specularIntensityMapTransform * vec3( SPECULAR_INTENSITYMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tvTransmissionMapUv = ( transmissionMapTransform * vec3( TRANSMISSIONMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_THICKNESSMAP\n\tvThicknessMapUv = ( thicknessMapTransform * vec3( THICKNESSMAP_UV, 1 ) ).xy;\n#endif`,yTe=`#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP ) || defined ( USE_TRANSMISSION ) || NUM_SPOT_LIGHT_COORDS > 0\n\tvec4 worldPosition = vec4( transformed, 1.0 );\n\t#ifdef USE_BATCHING\n\t\tworldPosition = batchingMatrix * worldPosition;\n\t#endif\n\t#ifdef USE_INSTANCING\n\t\tworldPosition = instanceMatrix * worldPosition;\n\t#endif\n\tworldPosition = modelMatrix * worldPosition;\n#endif`;const vTe=`varying vec2 vUv;\nuniform mat3 uvTransform;\nvoid main() {\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\tgl_Position = vec4( position.xy, 1.0, 1.0 );\n}`,wTe=`uniform sampler2D t2D;\nuniform float backgroundIntensity;\nvarying vec2 vUv;\nvoid main() {\n\tvec4 texColor = texture2D( t2D, vUv );\n\t#ifdef DECODE_VIDEO_TEXTURE\n\t\ttexColor = vec4( mix( pow( texColor.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), texColor.rgb * 0.0773993808, vec3( lessThanEqual( texColor.rgb, vec3( 0.04045 ) ) ) ), texColor.w );\n\t#endif\n\ttexColor.rgb *= backgroundIntensity;\n\tgl_FragColor = texColor;\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n}`,STe=`varying vec3 vWorldDirection;\n#include <common>\nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include <begin_vertex>\n\t#include <project_vertex>\n\tgl_Position.z = gl_Position.w;\n}`,_Te=`#ifdef ENVMAP_TYPE_CUBE\n\tuniform samplerCube envMap;\n#elif defined( ENVMAP_TYPE_CUBE_UV )\n\tuniform sampler2D envMap;\n#endif\nuniform float flipEnvMap;\nuniform float backgroundBlurriness;\nuniform float backgroundIntensity;\nvarying vec3 vWorldDirection;\n#include <cube_uv_reflection_fragment>\nvoid main() {\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 texColor = textureCube( envMap, vec3( flipEnvMap * vWorldDirection.x, vWorldDirection.yz ) );\n\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\tvec4 texColor = textureCubeUV( envMap, vWorldDirection, backgroundBlurriness );\n\t#else\n\t\tvec4 texColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t#endif\n\ttexColor.rgb *= backgroundIntensity;\n\tgl_FragColor = texColor;\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n}`,kTe=`varying vec3 vWorldDirection;\n#include <common>\nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include <begin_vertex>\n\t#include <project_vertex>\n\tgl_Position.z = gl_Position.w;\n}`,NTe=`uniform samplerCube tCube;\nuniform float tFlip;\nuniform float opacity;\nvarying vec3 vWorldDirection;\nvoid main() {\n\tvec4 texColor = textureCube( tCube, vec3( tFlip * vWorldDirection.x, vWorldDirection.yz ) );\n\tgl_FragColor = texColor;\n\tgl_FragColor.a *= opacity;\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n}`,CTe=`#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include <uv_vertex>\n\t#include <batching_vertex>\n\t#include <skinbase_vertex>\n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include <beginnormal_vertex>\n\t\t#include <morphnormal_vertex>\n\t\t#include <skinnormal_vertex>\n\t#endif\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvHighPrecisionZW = gl_Position.zw;\n}`,PTe=`#if DEPTH_PACKING == 3200\n\tuniform float opacity;\n#endif\n#include <common>\n#include <packing>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec4 diffuseColor = vec4( 1.0 );\n\t#if DEPTH_PACKING == 3200\n\t\tdiffuseColor.a = opacity;\n\t#endif\n\t#include <map_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <logdepthbuf_fragment>\n\tfloat fragCoordZ = 0.5 * vHighPrecisionZW[0] / vHighPrecisionZW[1] + 0.5;\n\t#if DEPTH_PACKING == 3200\n\t\tgl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );\n\t#elif DEPTH_PACKING == 3201\n\t\tgl_FragColor = packDepthToRGBA( fragCoordZ );\n\t#endif\n}`,TTe=`#define DISTANCE\nvarying vec3 vWorldPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <batching_vertex>\n\t#include <skinbase_vertex>\n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include <beginnormal_vertex>\n\t\t#include <morphnormal_vertex>\n\t\t#include <skinnormal_vertex>\n\t#endif\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <worldpos_vertex>\n\t#include <clipping_planes_vertex>\n\tvWorldPosition = worldPosition.xyz;\n}`,ATe=`#define DISTANCE\nuniform vec3 referencePosition;\nuniform float nearDistance;\nuniform float farDistance;\nvarying vec3 vWorldPosition;\n#include <common>\n#include <packing>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main () {\n\t#include <clipping_planes_fragment>\n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include <map_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\tfloat dist = length( vWorldPosition - referencePosition );\n\tdist = ( dist - nearDistance ) / ( farDistance - nearDistance );\n\tdist = saturate( dist );\n\tgl_FragColor = packDepthToRGBA( dist );\n}`,jTe=`varying vec3 vWorldDirection;\n#include <common>\nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include <begin_vertex>\n\t#include <project_vertex>\n}`,MTe=`uniform sampler2D tEquirect;\nvarying vec3 vWorldDirection;\n#include <common>\nvoid main() {\n\tvec3 direction = normalize( vWorldDirection );\n\tvec2 sampleUV = equirectUv( direction );\n\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n}`,ETe=`uniform float scale;\nattribute float lineDistance;\nvarying float vLineDistance;\n#include <common>\n#include <uv_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\tvLineDistance = scale * lineDistance;\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <fog_vertex>\n}`,DTe=`uniform vec3 diffuse;\nuniform float opacity;\nuniform float dashSize;\nuniform float totalSize;\nvarying float vLineDistance;\n#include <common>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <fog_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tif ( mod( vLineDistance, totalSize ) > dashSize ) {\n\t\tdiscard;\n\t}\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\toutgoingLight = diffuseColor.rgb;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n}`,FTe=`#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <envmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )\n\t\t#include <beginnormal_vertex>\n\t\t#include <morphnormal_vertex>\n\t\t#include <skinbase_vertex>\n\t\t#include <skinnormal_vertex>\n\t\t#include <defaultnormal_vertex>\n\t#endif\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <worldpos_vertex>\n\t#include <envmap_vertex>\n\t#include <fog_vertex>\n}`,RTe=`uniform vec3 diffuse;\nuniform float opacity;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include <common>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <envmap_common_pars_fragment>\n#include <envmap_pars_fragment>\n#include <fog_pars_fragment>\n#include <specularmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <specularmap_fragment>\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel = texture2D( lightMap, vLightMapUv );\n\t\treflectedLight.indirectDiffuse += lightMapTexel.rgb * lightMapIntensity * RECIPROCAL_PI;\n\t#else\n\t\treflectedLight.indirectDiffuse += vec3( 1.0 );\n\t#endif\n\t#include <aomap_fragment>\n\treflectedLight.indirectDiffuse *= diffuseColor.rgb;\n\tvec3 outgoingLight = reflectedLight.indirectDiffuse;\n\t#include <envmap_fragment>\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,LTe=`#define LAMBERT\nvarying vec3 vViewPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <envmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <shadowmap_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvViewPosition = - mvPosition.xyz;\n\t#include <worldpos_vertex>\n\t#include <envmap_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n}`,OTe=`#define LAMBERT\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include <common>\n#include <packing>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <emissivemap_pars_fragment>\n#include <envmap_common_pars_fragment>\n#include <envmap_pars_fragment>\n#include <fog_pars_fragment>\n#include <bsdfs>\n#include <lights_pars_begin>\n#include <normal_pars_fragment>\n#include <lights_lambert_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <specularmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <specularmap_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\t#include <emissivemap_fragment>\n\t#include <lights_lambert_fragment>\n\t#include <lights_fragment_begin>\n\t#include <lights_fragment_maps>\n\t#include <lights_fragment_end>\n\t#include <aomap_fragment>\n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include <envmap_fragment>\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,ITe=`#define MATCAP\nvarying vec3 vViewPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <color_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <fog_vertex>\n\tvViewPosition = - mvPosition.xyz;\n}`,zTe=`#define MATCAP\nuniform vec3 diffuse;\nuniform float opacity;\nuniform sampler2D matcap;\nvarying vec3 vViewPosition;\n#include <common>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <fog_pars_fragment>\n#include <normal_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\tvec3 viewDir = normalize( vViewPosition );\n\tvec3 x = normalize( vec3( viewDir.z, 0.0, - viewDir.x ) );\n\tvec3 y = cross( viewDir, x );\n\tvec2 uv = vec2( dot( x, normal ), dot( y, normal ) ) * 0.495 + 0.5;\n\t#ifdef USE_MATCAP\n\t\tvec4 matcapColor = texture2D( matcap, uv );\n\t#else\n\t\tvec4 matcapColor = vec4( vec3( mix( 0.2, 0.8, uv.y ) ), 1.0 );\n\t#endif\n\tvec3 outgoingLight = diffuseColor.rgb * matcapColor.rgb;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,UTe=`#define NORMAL\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvarying vec3 vViewPosition;\n#endif\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n}`,BTe=`#define NORMAL\nuniform float opacity;\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvarying vec3 vViewPosition;\n#endif\n#include <packing>\n#include <uv_pars_fragment>\n#include <normal_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\t#include <logdepthbuf_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\tgl_FragColor = vec4( packNormalToRGB( normal ), opacity );\n\t#ifdef OPAQUE\n\t\tgl_FragColor.a = 1.0;\n\t#endif\n}`,HTe=`#define PHONG\nvarying vec3 vViewPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <envmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <shadowmap_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvViewPosition = - mvPosition.xyz;\n\t#include <worldpos_vertex>\n\t#include <envmap_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n}`,qTe=`#define PHONG\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform vec3 specular;\nuniform float shininess;\nuniform float opacity;\n#include <common>\n#include <packing>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <emissivemap_pars_fragment>\n#include <envmap_common_pars_fragment>\n#include <envmap_pars_fragment>\n#include <fog_pars_fragment>\n#include <bsdfs>\n#include <lights_pars_begin>\n#include <normal_pars_fragment>\n#include <lights_phong_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <specularmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <specularmap_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\t#include <emissivemap_fragment>\n\t#include <lights_phong_fragment>\n\t#include <lights_fragment_begin>\n\t#include <lights_fragment_maps>\n\t#include <lights_fragment_end>\n\t#include <aomap_fragment>\n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#include <envmap_fragment>\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,$Te=`#define STANDARD\nvarying vec3 vViewPosition;\n#ifdef USE_TRANSMISSION\n\tvarying vec3 vWorldPosition;\n#endif\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <shadowmap_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvViewPosition = - mvPosition.xyz;\n\t#include <worldpos_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n#ifdef USE_TRANSMISSION\n\tvWorldPosition = worldPosition.xyz;\n#endif\n}`,VTe=`#define STANDARD\n#ifdef PHYSICAL\n\t#define IOR\n\t#define USE_SPECULAR\n#endif\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float roughness;\nuniform float metalness;\nuniform float opacity;\n#ifdef IOR\n\tuniform float ior;\n#endif\n#ifdef USE_SPECULAR\n\tuniform float specularIntensity;\n\tuniform vec3 specularColor;\n\t#ifdef USE_SPECULAR_COLORMAP\n\t\tuniform sampler2D specularColorMap;\n\t#endif\n\t#ifdef USE_SPECULAR_INTENSITYMAP\n\t\tuniform sampler2D specularIntensityMap;\n\t#endif\n#endif\n#ifdef USE_CLEARCOAT\n\tuniform float clearcoat;\n\tuniform float clearcoatRoughness;\n#endif\n#ifdef USE_IRIDESCENCE\n\tuniform float iridescence;\n\tuniform float iridescenceIOR;\n\tuniform float iridescenceThicknessMinimum;\n\tuniform float iridescenceThicknessMaximum;\n#endif\n#ifdef USE_SHEEN\n\tuniform vec3 sheenColor;\n\tuniform float sheenRoughness;\n\t#ifdef USE_SHEEN_COLORMAP\n\t\tuniform sampler2D sheenColorMap;\n\t#endif\n\t#ifdef USE_SHEEN_ROUGHNESSMAP\n\t\tuniform sampler2D sheenRoughnessMap;\n\t#endif\n#endif\n#ifdef USE_ANISOTROPY\n\tuniform vec2 anisotropyVector;\n\t#ifdef USE_ANISOTROPYMAP\n\t\tuniform sampler2D anisotropyMap;\n\t#endif\n#endif\nvarying vec3 vViewPosition;\n#include <common>\n#include <packing>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <emissivemap_pars_fragment>\n#include <iridescence_fragment>\n#include <cube_uv_reflection_fragment>\n#include <envmap_common_pars_fragment>\n#include <envmap_physical_pars_fragment>\n#include <fog_pars_fragment>\n#include <lights_pars_begin>\n#include <normal_pars_fragment>\n#include <lights_physical_pars_fragment>\n#include <transmission_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <clearcoat_pars_fragment>\n#include <iridescence_pars_fragment>\n#include <roughnessmap_pars_fragment>\n#include <metalnessmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <roughnessmap_fragment>\n\t#include <metalnessmap_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\t#include <clearcoat_normal_fragment_begin>\n\t#include <clearcoat_normal_fragment_maps>\n\t#include <emissivemap_fragment>\n\t#include <lights_physical_fragment>\n\t#include <lights_fragment_begin>\n\t#include <lights_fragment_maps>\n\t#include <lights_fragment_end>\n\t#include <aomap_fragment>\n\tvec3 totalDiffuse = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse;\n\tvec3 totalSpecular = reflectedLight.directSpecular + reflectedLight.indirectSpecular;\n\t#include <transmission_fragment>\n\tvec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;\n\t#ifdef USE_SHEEN\n\t\tfloat sheenEnergyComp = 1.0 - 0.157 * max3( material.sheenColor );\n\t\toutgoingLight = outgoingLight * sheenEnergyComp + sheenSpecularDirect + sheenSpecularIndirect;\n\t#endif\n\t#ifdef USE_CLEARCOAT\n\t\tfloat dotNVcc = saturate( dot( geometryClearcoatNormal, geometryViewDir ) );\n\t\tvec3 Fcc = F_Schlick( material.clearcoatF0, material.clearcoatF90, dotNVcc );\n\t\toutgoingLight = outgoingLight * ( 1.0 - material.clearcoat * Fcc ) + ( clearcoatSpecularDirect + clearcoatSpecularIndirect ) * material.clearcoat;\n\t#endif\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,GTe=`#define TOON\nvarying vec3 vViewPosition;\n#include <common>\n#include <batching_pars_vertex>\n#include <uv_pars_vertex>\n#include <displacementmap_pars_vertex>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <normal_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <shadowmap_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <normal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <displacementmap_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\tvViewPosition = - mvPosition.xyz;\n\t#include <worldpos_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n}`,WTe=`#define TOON\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include <common>\n#include <packing>\n#include <dithering_pars_fragment>\n#include <color_pars_fragment>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <aomap_pars_fragment>\n#include <lightmap_pars_fragment>\n#include <emissivemap_pars_fragment>\n#include <gradientmap_pars_fragment>\n#include <fog_pars_fragment>\n#include <bsdfs>\n#include <lights_pars_begin>\n#include <normal_pars_fragment>\n#include <lights_toon_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <bumpmap_pars_fragment>\n#include <normalmap_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <color_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\t#include <normal_fragment_begin>\n\t#include <normal_fragment_maps>\n\t#include <emissivemap_fragment>\n\t#include <lights_toon_fragment>\n\t#include <lights_fragment_begin>\n\t#include <lights_fragment_maps>\n\t#include <lights_fragment_end>\n\t#include <aomap_fragment>\n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n\t#include <dithering_fragment>\n}`,KTe=`uniform float size;\nuniform float scale;\n#include <common>\n#include <color_pars_vertex>\n#include <fog_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\n#ifdef USE_POINTS_UV\n\tvarying vec2 vUv;\n\tuniform mat3 uvTransform;\n#endif\nvoid main() {\n\t#ifdef USE_POINTS_UV\n\t\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\t#endif\n\t#include <color_vertex>\n\t#include <morphcolor_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <project_vertex>\n\tgl_PointSize = size;\n\t#ifdef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );\n\t#endif\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <worldpos_vertex>\n\t#include <fog_vertex>\n}`,XTe=`uniform vec3 diffuse;\nuniform float opacity;\n#include <common>\n#include <color_pars_fragment>\n#include <map_particle_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <fog_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <logdepthbuf_fragment>\n\t#include <map_particle_fragment>\n\t#include <color_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\toutgoingLight = diffuseColor.rgb;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n\t#include <premultiplied_alpha_fragment>\n}`,YTe=`#include <common>\n#include <batching_pars_vertex>\n#include <fog_pars_vertex>\n#include <morphtarget_pars_vertex>\n#include <skinning_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <shadowmap_pars_vertex>\nvoid main() {\n\t#include <batching_vertex>\n\t#include <beginnormal_vertex>\n\t#include <morphnormal_vertex>\n\t#include <skinbase_vertex>\n\t#include <skinnormal_vertex>\n\t#include <defaultnormal_vertex>\n\t#include <begin_vertex>\n\t#include <morphtarget_vertex>\n\t#include <skinning_vertex>\n\t#include <project_vertex>\n\t#include <logdepthbuf_vertex>\n\t#include <worldpos_vertex>\n\t#include <shadowmap_vertex>\n\t#include <fog_vertex>\n}`,QTe=`uniform vec3 color;\nuniform float opacity;\n#include <common>\n#include <packing>\n#include <fog_pars_fragment>\n#include <bsdfs>\n#include <lights_pars_begin>\n#include <logdepthbuf_pars_fragment>\n#include <shadowmap_pars_fragment>\n#include <shadowmask_pars_fragment>\nvoid main() {\n\t#include <logdepthbuf_fragment>\n\tgl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n}`,ZTe=`uniform float rotation;\nuniform vec2 center;\n#include <common>\n#include <uv_pars_vertex>\n#include <fog_pars_vertex>\n#include <logdepthbuf_pars_vertex>\n#include <clipping_planes_pars_vertex>\nvoid main() {\n\t#include <uv_vertex>\n\tvec4 mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 );\n\tvec2 scale;\n\tscale.x = length( vec3( modelMatrix[ 0 ].x, modelMatrix[ 0 ].y, modelMatrix[ 0 ].z ) );\n\tscale.y = length( vec3( modelMatrix[ 1 ].x, modelMatrix[ 1 ].y, modelMatrix[ 1 ].z ) );\n\t#ifndef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) scale *= - mvPosition.z;\n\t#endif\n\tvec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;\n\tvec2 rotatedPosition;\n\trotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;\n\trotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;\n\tmvPosition.xy += rotatedPosition;\n\tgl_Position = projectionMatrix * mvPosition;\n\t#include <logdepthbuf_vertex>\n\t#include <clipping_planes_vertex>\n\t#include <fog_vertex>\n}`,JTe=`uniform vec3 diffuse;\nuniform float opacity;\n#include <common>\n#include <uv_pars_fragment>\n#include <map_pars_fragment>\n#include <alphamap_pars_fragment>\n#include <alphatest_pars_fragment>\n#include <alphahash_pars_fragment>\n#include <fog_pars_fragment>\n#include <logdepthbuf_pars_fragment>\n#include <clipping_planes_pars_fragment>\nvoid main() {\n\t#include <clipping_planes_fragment>\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include <logdepthbuf_fragment>\n\t#include <map_fragment>\n\t#include <alphamap_fragment>\n\t#include <alphatest_fragment>\n\t#include <alphahash_fragment>\n\toutgoingLight = diffuseColor.rgb;\n\t#include <opaque_fragment>\n\t#include <tonemapping_fragment>\n\t#include <colorspace_fragment>\n\t#include <fog_fragment>\n}`,tr={alphahash_fragment:wCe,alphahash_pars_fragment:SCe,alphamap_fragment:_Ce,alphamap_pars_fragment:kCe,alphatest_fragment:NCe,alphatest_pars_fragment:CCe,aomap_fragment:PCe,aomap_pars_fragment:TCe,batching_pars_vertex:ACe,batching_vertex:jCe,begin_vertex:MCe,beginnormal_vertex:ECe,bsdfs:DCe,iridescence_fragment:FCe,bumpmap_pars_fragment:RCe,clipping_planes_fragment:LCe,clipping_planes_pars_fragment:OCe,clipping_planes_pars_vertex:ICe,clipping_planes_vertex:zCe,color_fragment:UCe,color_pars_fragment:BCe,color_pars_vertex:HCe,color_vertex:qCe,common:$Ce,cube_uv_reflection_fragment:VCe,defaultnormal_vertex:GCe,displacementmap_pars_vertex:WCe,displacementmap_vertex:KCe,emissivemap_fragment:XCe,emissivemap_pars_fragment:YCe,colorspace_fragment:QCe,colorspace_pars_fragment:ZCe,envmap_fragment:JCe,envmap_common_pars_fragment:ePe,envmap_pars_fragment:tPe,envmap_pars_vertex:nPe,envmap_physical_pars_fragment:pPe,envmap_vertex:rPe,fog_vertex:aPe,fog_pars_vertex:iPe,fog_fragment:sPe,fog_pars_fragment:oPe,gradientmap_pars_fragment:lPe,lightmap_fragment:cPe,lightmap_pars_fragment:dPe,lights_lambert_fragment:uPe,lights_lambert_pars_fragment:mPe,lights_pars_begin:hPe,lights_toon_fragment:fPe,lights_toon_pars_fragment:gPe,lights_phong_fragment:bPe,lights_phong_pars_fragment:xPe,lights_physical_fragment:yPe,lights_physical_pars_fragment:vPe,lights_fragment_begin:wPe,lights_fragment_maps:SPe,lights_fragment_end:_Pe,logdepthbuf_fragment:kPe,logdepthbuf_pars_fragment:NPe,logdepthbuf_pars_vertex:CPe,logdepthbuf_vertex:PPe,map_fragment:TPe,map_pars_fragment:APe,map_particle_fragment:jPe,map_particle_pars_fragment:MPe,metalnessmap_fragment:EPe,metalnessmap_pars_fragment:DPe,morphcolor_vertex:FPe,morphnormal_vertex:RPe,morphtarget_pars_vertex:LPe,morphtarget_vertex:OPe,normal_fragment_begin:IPe,normal_fragment_maps:zPe,normal_pars_fragment:UPe,normal_pars_vertex:BPe,normal_vertex:HPe,normalmap_pars_fragment:qPe,clearcoat_normal_fragment_begin:$Pe,clearcoat_normal_fragment_maps:VPe,clearcoat_pars_fragment:GPe,iridescence_pars_fragment:WPe,opaque_fragment:KPe,packing:XPe,premultiplied_alpha_fragment:YPe,project_vertex:QPe,dithering_fragment:ZPe,dithering_pars_fragment:JPe,roughnessmap_fragment:eTe,roughnessmap_pars_fragment:tTe,shadowmap_pars_fragment:nTe,shadowmap_pars_vertex:rTe,shadowmap_vertex:aTe,shadowmask_pars_fragment:iTe,skinbase_vertex:sTe,skinning_pars_vertex:oTe,skinning_vertex:lTe,skinnormal_vertex:cTe,specularmap_fragment:dTe,specularmap_pars_fragment:uTe,tonemapping_fragment:mTe,tonemapping_pars_fragment:hTe,transmission_fragment:pTe,transmission_pars_fragment:fTe,uv_pars_fragment:gTe,uv_pars_vertex:bTe,uv_vertex:xTe,worldpos_vertex:yTe,background_vert:vTe,background_frag:wTe,backgroundCube_vert:STe,backgroundCube_frag:_Te,cube_vert:kTe,cube_frag:NTe,depth_vert:CTe,depth_frag:PTe,distanceRGBA_vert:TTe,distanceRGBA_frag:ATe,equirect_vert:jTe,equirect_frag:MTe,linedashed_vert:ETe,linedashed_frag:DTe,meshbasic_vert:FTe,meshbasic_frag:RTe,meshlambert_vert:LTe,meshlambert_frag:OTe,meshmatcap_vert:ITe,meshmatcap_frag:zTe,meshnormal_vert:UTe,meshnormal_frag:BTe,meshphong_vert:HTe,meshphong_frag:qTe,meshphysical_vert:$Te,meshphysical_frag:VTe,meshtoon_vert:GTe,meshtoon_frag:WTe,points_vert:KTe,points_frag:XTe,shadow_vert:YTe,shadow_frag:QTe,sprite_vert:ZTe,sprite_frag:JTe},Yt={common:{diffuse:{value:new Mn(16777215)},opacity:{value:1},map:{value:null},mapTransform:{value:new mr},alphaMap:{value:null},alphaMapTransform:{value:new mr},alphaTest:{value:0}},specularmap:{specularMap:{value:null},specularMapTransform:{value:new mr}},envmap:{envMap:{value:null},flipEnvMap:{value:-1},reflectivity:{value:1},ior:{value:1.5},refractionRatio:{value:.98}},aomap:{aoMap:{value:null},aoMapIntensity:{value:1},aoMapTransform:{value:new mr}},lightmap:{lightMap:{value:null},lightMapIntensity:{value:1},lightMapTransform:{value:new mr}},bumpmap:{bumpMap:{value:null},bumpMapTransform:{value:new mr},bumpScale:{value:1}},normalmap:{normalMap:{value:null},normalMapTransform:{value:new mr},normalScale:{value:new Vn(1,1)}},displacementmap:{displacementMap:{value:null},displacementMapTransform:{value:new mr},displacementScale:{value:1},displacementBias:{value:0}},emissivemap:{emissiveMap:{value:null},emissiveMapTransform:{value:new mr}},metalnessmap:{metalnessMap:{value:null},metalnessMapTransform:{value:new mr}},roughnessmap:{roughnessMap:{value:null},roughnessMapTransform:{value:new mr}},gradientmap:{gradientMap:{value:null}},fog:{fogDensity:{value:25e-5},fogNear:{value:1},fogFar:{value:2e3},fogColor:{value:new Mn(16777215)}},lights:{ambientLightColor:{value:[]},lightProbe:{value:[]},directionalLights:{value:[],properties:{direction:{},color:{}}},directionalLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},directionalShadowMap:{value:[]},directionalShadowMatrix:{value:[]},spotLights:{value:[],properties:{color:{},position:{},direction:{},distance:{},coneCos:{},penumbraCos:{},decay:{}}},spotLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},spotLightMap:{value:[]},spotShadowMap:{value:[]},spotLightMatrix:{value:[]},pointLights:{value:[],properties:{color:{},position:{},decay:{},distance:{}}},pointLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{},shadowCameraNear:{},shadowCameraFar:{}}},pointShadowMap:{value:[]},pointShadowMatrix:{value:[]},hemisphereLights:{value:[],properties:{direction:{},skyColor:{},groundColor:{}}},rectAreaLights:{value:[],properties:{color:{},position:{},width:{},height:{}}},ltc_1:{value:null},ltc_2:{value:null}},points:{diffuse:{value:new Mn(16777215)},opacity:{value:1},size:{value:1},scale:{value:1},map:{value:null},alphaMap:{value:null},alphaMapTransform:{value:new mr},alphaTest:{value:0},uvTransform:{value:new mr}},sprite:{diffuse:{value:new Mn(16777215)},opacity:{value:1},center:{value:new Vn(.5,.5)},rotation:{value:0},map:{value:null},mapTransform:{value:new mr},alphaMap:{value:null},alphaMapTransform:{value:new mr},alphaTest:{value:0}}},Fo={basic:{uniforms:to([Yt.common,Yt.specularmap,Yt.envmap,Yt.aomap,Yt.lightmap,Yt.fog]),vertexShader:tr.meshbasic_vert,fragmentShader:tr.meshbasic_frag},lambert:{uniforms:to([Yt.common,Yt.specularmap,Yt.envmap,Yt.aomap,Yt.lightmap,Yt.emissivemap,Yt.bumpmap,Yt.normalmap,Yt.displacementmap,Yt.fog,Yt.lights,{emissive:{value:new Mn(0)}}]),vertexShader:tr.meshlambert_vert,fragmentShader:tr.meshlambert_frag},phong:{uniforms:to([Yt.common,Yt.specularmap,Yt.envmap,Yt.aomap,Yt.lightmap,Yt.emissivemap,Yt.bumpmap,Yt.normalmap,Yt.displacementmap,Yt.fog,Yt.lights,{emissive:{value:new Mn(0)},specular:{value:new Mn(1118481)},shininess:{value:30}}]),vertexShader:tr.meshphong_vert,fragmentShader:tr.meshphong_frag},standard:{uniforms:to([Yt.common,Yt.envmap,Yt.aomap,Yt.lightmap,Yt.emissivemap,Yt.bumpmap,Yt.normalmap,Yt.displacementmap,Yt.roughnessmap,Yt.metalnessmap,Yt.fog,Yt.lights,{emissive:{value:new Mn(0)},roughness:{value:1},metalness:{value:0},envMapIntensity:{value:1}}]),vertexShader:tr.meshphysical_vert,fragmentShader:tr.meshphysical_frag},toon:{uniforms:to([Yt.common,Yt.aomap,Yt.lightmap,Yt.emissivemap,Yt.bumpmap,Yt.normalmap,Yt.displacementmap,Yt.gradientmap,Yt.fog,Yt.lights,{emissive:{value:new Mn(0)}}]),vertexShader:tr.meshtoon_vert,fragmentShader:tr.meshtoon_frag},matcap:{uniforms:to([Yt.common,Yt.bumpmap,Yt.normalmap,Yt.displacementmap,Yt.fog,{matcap:{value:null}}]),vertexShader:tr.meshmatcap_vert,fragmentShader:tr.meshmatcap_frag},points:{uniforms:to([Yt.points,Yt.fog]),vertexShader:tr.points_vert,fragmentShader:tr.points_frag},dashed:{uniforms:to([Yt.common,Yt.fog,{scale:{value:1},dashSize:{value:1},totalSize:{value:2}}]),vertexShader:tr.linedashed_vert,fragmentShader:tr.linedashed_frag},depth:{uniforms:to([Yt.common,Yt.displacementmap]),vertexShader:tr.depth_vert,fragmentShader:tr.depth_frag},normal:{uniforms:to([Yt.common,Yt.bumpmap,Yt.normalmap,Yt.displacementmap,{opacity:{value:1}}]),vertexShader:tr.meshnormal_vert,fragmentShader:tr.meshnormal_frag},sprite:{uniforms:to([Yt.sprite,Yt.fog]),vertexShader:tr.sprite_vert,fragmentShader:tr.sprite_frag},background:{uniforms:{uvTransform:{value:new mr},t2D:{value:null},backgroundIntensity:{value:1}},vertexShader:tr.background_vert,fragmentShader:tr.background_frag},backgroundCube:{uniforms:{envMap:{value:null},flipEnvMap:{value:-1},backgroundBlurriness:{value:0},backgroundIntensity:{value:1}},vertexShader:tr.backgroundCube_vert,fragmentShader:tr.backgroundCube_frag},cube:{uniforms:{tCube:{value:null},tFlip:{value:-1},opacity:{value:1}},vertexShader:tr.cube_vert,fragmentShader:tr.cube_frag},equirect:{uniforms:{tEquirect:{value:null}},vertexShader:tr.equirect_vert,fragmentShader:tr.equirect_frag},distanceRGBA:{uniforms:to([Yt.common,Yt.displacementmap,{referencePosition:{value:new ut},nearDistance:{value:1},farDistance:{value:1e3}}]),vertexShader:tr.distanceRGBA_vert,fragmentShader:tr.distanceRGBA_frag},shadow:{uniforms:to([Yt.lights,Yt.fog,{color:{value:new Mn(0)},opacity:{value:1}}]),vertexShader:tr.shadow_vert,fragmentShader:tr.shadow_frag}};Fo.physical={uniforms:to([Fo.standard.uniforms,{clearcoat:{value:0},clearcoatMap:{value:null},clearcoatMapTransform:{value:new mr},clearcoatNormalMap:{value:null},clearcoatNormalMapTransform:{value:new mr},clearcoatNormalScale:{value:new Vn(1,1)},clearcoatRoughness:{value:0},clearcoatRoughnessMap:{value:null},clearcoatRoughnessMapTransform:{value:new mr},iridescence:{value:0},iridescenceMap:{value:null},iridescenceMapTransform:{value:new mr},iridescenceIOR:{value:1.3},iridescenceThicknessMinimum:{value:100},iridescenceThicknessMaximum:{value:400},iridescenceThicknessMap:{value:null},iridescenceThicknessMapTransform:{value:new mr},sheen:{value:0},sheenColor:{value:new Mn(0)},sheenColorMap:{value:null},sheenColorMapTransform:{value:new mr},sheenRoughness:{value:1},sheenRoughnessMap:{value:null},sheenRoughnessMapTransform:{value:new mr},transmission:{value:0},transmissionMap:{value:null},transmissionMapTransform:{value:new mr},transmissionSamplerSize:{value:new Vn},transmissionSamplerMap:{value:null},thickness:{value:0},thicknessMap:{value:null},thicknessMapTransform:{value:new mr},attenuationDistance:{value:0},attenuationColor:{value:new Mn(0)},specularColor:{value:new Mn(1,1,1)},specularColorMap:{value:null},specularColorMapTransform:{value:new mr},specularIntensity:{value:1},specularIntensityMap:{value:null},specularIntensityMapTransform:{value:new mr},anisotropyVector:{value:new Vn},anisotropyMap:{value:null},anisotropyMapTransform:{value:new mr}}]),vertexShader:tr.meshphysical_vert,fragmentShader:tr.meshphysical_frag};const H1={r:0,b:0,g:0};function eAe(t,e,n,r,i,s,o){const l=new Mn(0);let c=s===!0?0:1,d,u,m=null,p=0,f=null;function y(b,g){let _=!1,C=g.isScene===!0?g.background:null;C&&C.isTexture&&(C=(g.backgroundBlurriness>0?n:e).get(C)),C===null?v(l,c):C&&C.isColor&&(v(C,1),_=!0);const P=t.xr.getEnvironmentBlendMode();P===\"additive\"?r.buffers.color.setClear(0,0,0,1,o):P===\"alpha-blend\"&&r.buffers.color.setClear(0,0,0,0,o),(t.autoClear||_)&&t.clear(t.autoClearColor,t.autoClearDepth,t.autoClearStencil),C&&(C.isCubeTexture||C.mapping===eT)?(u===void 0&&(u=new Wc(new dS(1,1,1),new vp({name:\"BackgroundCubeMaterial\",uniforms:Jx(Fo.backgroundCube.uniforms),vertexShader:Fo.backgroundCube.vertexShader,fragmentShader:Fo.backgroundCube.fragmentShader,side:Uo,depthTest:!1,depthWrite:!1,fog:!1})),u.geometry.deleteAttribute(\"normal\"),u.geometry.deleteAttribute(\"uv\"),u.onBeforeRender=function(N,A,T){this.matrixWorld.copyPosition(T.matrixWorld)},Object.defineProperty(u.material,\"envMap\",{get:function(){return this.uniforms.envMap.value}}),i.update(u)),u.material.uniforms.envMap.value=C,u.material.uniforms.flipEnvMap.value=C.isCubeTexture&&C.isRenderTargetTexture===!1?-1:1,u.material.uniforms.backgroundBlurriness.value=g.backgroundBlurriness,u.material.uniforms.backgroundIntensity.value=g.backgroundIntensity,u.material.toneMapped=Jr.getTransfer(C.colorSpace)!==Sa,(m!==C||p!==C.version||f!==t.toneMapping)&&(u.material.needsUpdate=!0,m=C,p=C.version,f=t.toneMapping),u.layers.enableAll(),b.unshift(u,u.geometry,u.material,0,0,null)):C&&C.isTexture&&(d===void 0&&(d=new Wc(new hI(2,2),new vp({name:\"BackgroundMaterial\",uniforms:Jx(Fo.background.uniforms),vertexShader:Fo.background.vertexShader,fragmentShader:Fo.background.fragmentShader,side:yp,depthTest:!1,depthWrite:!1,fog:!1})),d.geometry.deleteAttribute(\"normal\"),Object.defineProperty(d.material,\"map\",{get:function(){return this.uniforms.t2D.value}}),i.update(d)),d.material.uniforms.t2D.value=C,d.material.uniforms.backgroundIntensity.value=g.backgroundIntensity,d.material.toneMapped=Jr.getTransfer(C.colorSpace)!==Sa,C.matrixAutoUpdate===!0&&C.updateMatrix(),d.material.uniforms.uvTransform.value.copy(C.matrix),(m!==C||p!==C.version||f!==t.toneMapping)&&(d.material.needsUpdate=!0,m=C,p=C.version,f=t.toneMapping),d.layers.enableAll(),b.unshift(d,d.geometry,d.material,0,0,null))}function v(b,g){b.getRGB(H1,bJ(t)),r.buffers.color.setClear(H1.r,H1.g,H1.b,g,o)}return{getClearColor:function(){return l},setClearColor:function(b,g=1){l.set(b),c=g,v(l,c)},getClearAlpha:function(){return c},setClearAlpha:function(b){c=b,v(l,c)},render:y}}function tAe(t,e,n,r){const i=t.getParameter(t.MAX_VERTEX_ATTRIBS),s=r.isWebGL2?null:e.get(\"OES_vertex_array_object\"),o=r.isWebGL2||s!==null,l={},c=b(null);let d=c,u=!1;function m(L,te,ie,J,oe){let fe=!1;if(o){const re=v(J,ie,te);d!==re&&(d=re,f(d.object)),fe=g(L,J,ie,oe),fe&&_(L,J,ie,oe)}else{const re=te.wireframe===!0;(d.geometry!==J.id||d.program!==ie.id||d.wireframe!==re)&&(d.geometry=J.id,d.program=ie.id,d.wireframe=re,fe=!0)}oe!==null&&n.update(oe,t.ELEMENT_ARRAY_BUFFER),(fe||u)&&(u=!1,F(L,te,ie,J),oe!==null&&t.bindBuffer(t.ELEMENT_ARRAY_BUFFER,n.get(oe).buffer))}function p(){return r.isWebGL2?t.createVertexArray():s.createVertexArrayOES()}function f(L){return r.isWebGL2?t.bindVertexArray(L):s.bindVertexArrayOES(L)}function y(L){return r.isWebGL2?t.deleteVertexArray(L):s.deleteVertexArrayOES(L)}function v(L,te,ie){const J=ie.wireframe===!0;let oe=l[L.id];oe===void 0&&(oe={},l[L.id]=oe);let fe=oe[te.id];fe===void 0&&(fe={},oe[te.id]=fe);let re=fe[J];return re===void 0&&(re=b(p()),fe[J]=re),re}function b(L){const te=[],ie=[],J=[];for(let oe=0;oe<i;oe++)te[oe]=0,ie[oe]=0,J[oe]=0;return{geometry:null,program:null,wireframe:!1,newAttributes:te,enabledAttributes:ie,attributeDivisors:J,object:L,attributes:{},index:null}}function g(L,te,ie,J){const oe=d.attributes,fe=te.attributes;let re=0;const W=ie.getAttributes();for(const ne in W)if(W[ne].location>=0){const be=oe[ne];let Ce=fe[ne];if(Ce===void 0&&(ne===\"instanceMatrix\"&&L.instanceMatrix&&(Ce=L.instanceMatrix),ne===\"instanceColor\"&&L.instanceColor&&(Ce=L.instanceColor)),be===void 0||be.attribute!==Ce||Ce&&be.data!==Ce.data)return!0;re++}return d.attributesNum!==re||d.index!==J}function _(L,te,ie,J){const oe={},fe=te.attributes;let re=0;const W=ie.getAttributes();for(const ne in W)if(W[ne].location>=0){let be=fe[ne];be===void 0&&(ne===\"instanceMatrix\"&&L.instanceMatrix&&(be=L.instanceMatrix),ne===\"instanceColor\"&&L.instanceColor&&(be=L.instanceColor));const Ce={};Ce.attribute=be,be&&be.data&&(Ce.data=be.data),oe[ne]=Ce,re++}d.attributes=oe,d.attributesNum=re,d.index=J}function C(){const L=d.newAttributes;for(let te=0,ie=L.length;te<ie;te++)L[te]=0}function P(L){N(L,0)}function N(L,te){const ie=d.newAttributes,J=d.enabledAttributes,oe=d.attributeDivisors;ie[L]=1,J[L]===0&&(t.enableVertexAttribArray(L),J[L]=1),oe[L]!==te&&((r.isWebGL2?t:e.get(\"ANGLE_instanced_arrays\"))[r.isWebGL2?\"vertexAttribDivisor\":\"vertexAttribDivisorANGLE\"](L,te),oe[L]=te)}function A(){const L=d.newAttributes,te=d.enabledAttributes;for(let ie=0,J=te.length;ie<J;ie++)te[ie]!==L[ie]&&(t.disableVertexAttribArray(ie),te[ie]=0)}function T(L,te,ie,J,oe,fe,re){re===!0?t.vertexAttribIPointer(L,te,ie,oe,fe):t.vertexAttribPointer(L,te,ie,J,oe,fe)}function F(L,te,ie,J){if(r.isWebGL2===!1&&(L.isInstancedMesh||J.isInstancedBufferGeometry)&&e.get(\"ANGLE_instanced_arrays\")===null)return;C();const oe=J.attributes,fe=ie.getAttributes(),re=te.defaultAttributeValues;for(const W in fe){const ne=fe[W];if(ne.location>=0){let me=oe[W];if(me===void 0&&(W===\"instanceMatrix\"&&L.instanceMatrix&&(me=L.instanceMatrix),W===\"instanceColor\"&&L.instanceColor&&(me=L.instanceColor)),me!==void 0){const be=me.normalized,Ce=me.itemSize,q=n.get(me);if(q===void 0)continue;const Y=q.buffer,E=q.type,j=q.bytesPerElement,O=r.isWebGL2===!0&&(E===t.INT||E===t.UNSIGNED_INT||me.gpuType===QZ);if(me.isInterleavedBufferAttribute){const K=me.data,U=K.stride,de=me.offset;if(K.isInstancedInterleavedBuffer){for(let I=0;I<ne.locationSize;I++)N(ne.location+I,K.meshPerAttribute);L.isInstancedMesh!==!0&&J._maxInstanceCount===void 0&&(J._maxInstanceCount=K.meshPerAttribute*K.count)}else for(let I=0;I<ne.locationSize;I++)P(ne.location+I);t.bindBuffer(t.ARRAY_BUFFER,Y);for(let I=0;I<ne.locationSize;I++)T(ne.location+I,Ce/ne.locationSize,E,be,U*j,(de+Ce/ne.locationSize*I)*j,O)}else{if(me.isInstancedBufferAttribute){for(let K=0;K<ne.locationSize;K++)N(ne.location+K,me.meshPerAttribute);L.isInstancedMesh!==!0&&J._maxInstanceCount===void 0&&(J._maxInstanceCount=me.meshPerAttribute*me.count)}else for(let K=0;K<ne.locationSize;K++)P(ne.location+K);t.bindBuffer(t.ARRAY_BUFFER,Y);for(let K=0;K<ne.locationSize;K++)T(ne.location+K,Ce/ne.locationSize,E,be,Ce*j,Ce/ne.locationSize*K*j,O)}}else if(re!==void 0){const be=re[W];if(be!==void 0)switch(be.length){case 2:t.vertexAttrib2fv(ne.location,be);break;case 3:t.vertexAttrib3fv(ne.location,be);break;case 4:t.vertexAttrib4fv(ne.location,be);break;default:t.vertexAttrib1fv(ne.location,be)}}}}A()}function k(){z();for(const L in l){const te=l[L];for(const ie in te){const J=te[ie];for(const oe in J)y(J[oe].object),delete J[oe];delete te[ie]}delete l[L]}}function D(L){if(l[L.id]===void 0)return;const te=l[L.id];for(const ie in te){const J=te[ie];for(const oe in J)y(J[oe].object),delete J[oe];delete te[ie]}delete l[L.id]}function H(L){for(const te in l){const ie=l[te];if(ie[L.id]===void 0)continue;const J=ie[L.id];for(const oe in J)y(J[oe].object),delete J[oe];delete ie[L.id]}}function z(){Q(),u=!0,d!==c&&(d=c,f(d.object))}function Q(){c.geometry=null,c.program=null,c.wireframe=!1}return{setup:m,reset:z,resetDefaultState:Q,dispose:k,releaseStatesOfGeometry:D,releaseStatesOfProgram:H,initAttributes:C,enableAttribute:P,disableUnusedAttributes:A}}function nAe(t,e,n,r){const i=r.isWebGL2;let s;function o(u){s=u}function l(u,m){t.drawArrays(s,u,m),n.update(m,s,1)}function c(u,m,p){if(p===0)return;let f,y;if(i)f=t,y=\"drawArraysInstanced\";else if(f=e.get(\"ANGLE_instanced_arrays\"),y=\"drawArraysInstancedANGLE\",f===null){console.error(\"THREE.WebGLBufferRenderer: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.\");return}f[y](s,u,m,p),n.update(m,s,p)}function d(u,m,p){if(p===0)return;const f=e.get(\"WEBGL_multi_draw\");if(f===null)for(let y=0;y<p;y++)this.render(u[y],m[y]);else{f.multiDrawArraysWEBGL(s,u,0,m,0,p);let y=0;for(let v=0;v<p;v++)y+=m[v];n.update(y,s,1)}}this.setMode=o,this.render=l,this.renderInstances=c,this.renderMultiDraw=d}function rAe(t,e,n){let r;function i(){if(r!==void 0)return r;if(e.has(\"EXT_texture_filter_anisotropic\")===!0){const T=e.get(\"EXT_texture_filter_anisotropic\");r=t.getParameter(T.MAX_TEXTURE_MAX_ANISOTROPY_EXT)}else r=0;return r}function s(T){if(T===\"highp\"){if(t.getShaderPrecisionFormat(t.VERTEX_SHADER,t.HIGH_FLOAT).precision>0&&t.getShaderPrecisionFormat(t.FRAGMENT_SHADER,t.HIGH_FLOAT).precision>0)return\"highp\";T=\"mediump\"}return T===\"mediump\"&&t.getShaderPrecisionFormat(t.VERTEX_SHADER,t.MEDIUM_FLOAT).precision>0&&t.getShaderPrecisionFormat(t.FRAGMENT_SHADER,t.MEDIUM_FLOAT).precision>0?\"mediump\":\"lowp\"}const o=typeof WebGL2RenderingContext<\"u\"&&t.constructor.name===\"WebGL2RenderingContext\";let l=n.precision!==void 0?n.precision:\"highp\";const c=s(l);c!==l&&(console.warn(\"THREE.WebGLRenderer:\",l,\"not supported, using\",c,\"instead.\"),l=c);const d=o||e.has(\"WEBGL_draw_buffers\"),u=n.logarithmicDepthBuffer===!0,m=t.getParameter(t.MAX_TEXTURE_IMAGE_UNITS),p=t.getParameter(t.MAX_VERTEX_TEXTURE_IMAGE_UNITS),f=t.getParameter(t.MAX_TEXTURE_SIZE),y=t.getParameter(t.MAX_CUBE_MAP_TEXTURE_SIZE),v=t.getParameter(t.MAX_VERTEX_ATTRIBS),b=t.getParameter(t.MAX_VERTEX_UNIFORM_VECTORS),g=t.getParameter(t.MAX_VARYING_VECTORS),_=t.getParameter(t.MAX_FRAGMENT_UNIFORM_VECTORS),C=p>0,P=o||e.has(\"OES_texture_float\"),N=C&&P,A=o?t.getParameter(t.MAX_SAMPLES):0;return{isWebGL2:o,drawBuffers:d,getMaxAnisotropy:i,getMaxPrecision:s,precision:l,logarithmicDepthBuffer:u,maxTextures:m,maxVertexTextures:p,maxTextureSize:f,maxCubemapSize:y,maxAttributes:v,maxVertexUniforms:b,maxVaryings:g,maxFragmentUniforms:_,vertexTextures:C,floatFragmentTextures:P,floatVertexTextures:N,maxSamples:A}}function aAe(t){const e=this;let n=null,r=0,i=!1,s=!1;const o=new Th,l=new mr,c={value:null,needsUpdate:!1};this.uniform=c,this.numPlanes=0,this.numIntersection=0,this.init=function(m,p){const f=m.length!==0||p||r!==0||i;return i=p,r=m.length,f},this.beginShadows=function(){s=!0,u(null)},this.endShadows=function(){s=!1},this.setGlobalState=function(m,p){n=u(m,p,0)},this.setState=function(m,p,f){const y=m.clippingPlanes,v=m.clipIntersection,b=m.clipShadows,g=t.get(m);if(!i||y===null||y.length===0||s&&!b)s?u(null):d();else{const _=s?0:r,C=_*4;let P=g.clippingState||null;c.value=P,P=u(y,p,C,f);for(let N=0;N!==C;++N)P[N]=n[N];g.clippingState=P,this.numIntersection=v?this.numPlanes:0,this.numPlanes+=_}};function d(){c.value!==n&&(c.value=n,c.needsUpdate=r>0),e.numPlanes=r,e.numIntersection=0}function u(m,p,f,y){const v=m!==null?m.length:0;let b=null;if(v!==0){if(b=c.value,y!==!0||b===null){const g=f+v*4,_=p.matrixWorldInverse;l.getNormalMatrix(_),(b===null||b.length<g)&&(b=new Float32Array(g));for(let C=0,P=f;C!==v;++C,P+=4)o.copy(m[C]).applyMatrix4(_,l),o.normal.toArray(b,P),b[P+3]=o.constant}c.value=b,c.needsUpdate=!0}return e.numPlanes=v,e.numIntersection=0,b}}function iAe(t){let e=new WeakMap;function n(o,l){return l===lR?o.mapping=Yx:l===cR&&(o.mapping=Qx),o}function r(o){if(o&&o.isTexture){const l=o.mapping;if(l===lR||l===cR)if(e.has(o)){const c=e.get(o).texture;return n(c,o.mapping)}else{const c=o.image;if(c&&c.height>0){const d=new bCe(c.height/2);return d.fromEquirectangularTexture(t,o),e.set(o,d),o.addEventListener(\"dispose\",i),n(d.texture,o.mapping)}else return null}}return o}function i(o){const l=o.target;l.removeEventListener(\"dispose\",i);const c=e.get(l);c!==void 0&&(e.delete(l),c.dispose())}function s(){e=new WeakMap}return{get:r,dispose:s}}class sAe extends xJ{constructor(e=-1,n=1,r=1,i=-1,s=.1,o=2e3){super(),this.isOrthographicCamera=!0,this.type=\"OrthographicCamera\",this.zoom=1,this.view=null,this.left=e,this.right=n,this.top=r,this.bottom=i,this.near=s,this.far=o,this.updateProjectionMatrix()}copy(e,n){return super.copy(e,n),this.left=e.left,this.right=e.right,this.top=e.top,this.bottom=e.bottom,this.near=e.near,this.far=e.far,this.zoom=e.zoom,this.view=e.view===null?null:Object.assign({},e.view),this}setViewOffset(e,n,r,i,s,o){this.view===null&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=e,this.view.fullHeight=n,this.view.offsetX=r,this.view.offsetY=i,this.view.width=s,this.view.height=o,this.updateProjectionMatrix()}clearViewOffset(){this.view!==null&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const e=(this.right-this.left)/(2*this.zoom),n=(this.top-this.bottom)/(2*this.zoom),r=(this.right+this.left)/2,i=(this.top+this.bottom)/2;let s=r-e,o=r+e,l=i+n,c=i-n;if(this.view!==null&&this.view.enabled){const d=(this.right-this.left)/this.view.fullWidth/this.zoom,u=(this.top-this.bottom)/this.view.fullHeight/this.zoom;s+=d*this.view.offsetX,o=s+d*this.view.width,l-=u*this.view.offsetY,c=l-u*this.view.height}this.projectionMatrix.makeOrthographic(s,o,l,c,this.near,this.far,this.coordinateSystem),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(e){const n=super.toJSON(e);return n.object.zoom=this.zoom,n.object.left=this.left,n.object.right=this.right,n.object.top=this.top,n.object.bottom=this.bottom,n.object.near=this.near,n.object.far=this.far,this.view!==null&&(n.object.view=Object.assign({},this.view)),n}}const wx=4,Xq=[.125,.215,.35,.446,.526,.582],Mf=20,FE=new sAe,Yq=new Mn;let RE=null,LE=0,OE=0;const kf=(1+Math.sqrt(5))/2,ex=1/kf,Qq=[new ut(1,1,1),new ut(-1,1,1),new ut(1,1,-1),new ut(-1,1,-1),new ut(0,kf,ex),new ut(0,kf,-ex),new ut(ex,0,kf),new ut(-ex,0,kf),new ut(kf,ex,0),new ut(-kf,ex,0)];class Zq{constructor(e){this._renderer=e,this._pingPongRenderTarget=null,this._lodMax=0,this._cubeSize=0,this._lodPlanes=[],this._sizeLods=[],this._sigmas=[],this._blurMaterial=null,this._cubemapMaterial=null,this._equirectMaterial=null,this._compileMaterial(this._blurMaterial)}fromScene(e,n=0,r=.1,i=100){RE=this._renderer.getRenderTarget(),LE=this._renderer.getActiveCubeFace(),OE=this._renderer.getActiveMipmapLevel(),this._setSize(256);const s=this._allocateTargets();return s.depthBuffer=!0,this._sceneToCubeUV(e,r,i,s),n>0&&this._blur(s,0,0,n),this._applyPMREM(s),this._cleanup(s),s}fromEquirectangular(e,n=null){return this._fromTexture(e,n)}fromCubemap(e,n=null){return this._fromTexture(e,n)}compileCubemapShader(){this._cubemapMaterial===null&&(this._cubemapMaterial=t$(),this._compileMaterial(this._cubemapMaterial))}compileEquirectangularShader(){this._equirectMaterial===null&&(this._equirectMaterial=e$(),this._compileMaterial(this._equirectMaterial))}dispose(){this._dispose(),this._cubemapMaterial!==null&&this._cubemapMaterial.dispose(),this._equirectMaterial!==null&&this._equirectMaterial.dispose()}_setSize(e){this._lodMax=Math.floor(Math.log2(e)),this._cubeSize=Math.pow(2,this._lodMax)}_dispose(){this._blurMaterial!==null&&this._blurMaterial.dispose(),this._pingPongRenderTarget!==null&&this._pingPongRenderTarget.dispose();for(let e=0;e<this._lodPlanes.length;e++)this._lodPlanes[e].dispose()}_cleanup(e){this._renderer.setRenderTarget(RE,LE,OE),e.scissorTest=!1,q1(e,0,0,e.width,e.height)}_fromTexture(e,n){e.mapping===Yx||e.mapping===Qx?this._setSize(e.image.length===0?16:e.image[0].width||e.image[0].image.width):this._setSize(e.image.width/4),RE=this._renderer.getRenderTarget(),LE=this._renderer.getActiveCubeFace(),OE=this._renderer.getActiveMipmapLevel();const r=n||this._allocateTargets();return this._textureToCubeUV(e,r),this._applyPMREM(r),this._cleanup(r),r}_allocateTargets(){const e=3*Math.max(this._cubeSize,112),n=4*this._cubeSize,r={magFilter:Xl,minFilter:Xl,generateMipmaps:!1,type:pw,format:Yl,colorSpace:Sm,depthBuffer:!1},i=Jq(e,n,r);if(this._pingPongRenderTarget===null||this._pingPongRenderTarget.width!==e||this._pingPongRenderTarget.height!==n){this._pingPongRenderTarget!==null&&this._dispose(),this._pingPongRenderTarget=Jq(e,n,r);const{_lodMax:s}=this;({sizeLods:this._sizeLods,lodPlanes:this._lodPlanes,sigmas:this._sigmas}=oAe(s)),this._blurMaterial=lAe(s,e,n)}return i}_compileMaterial(e){const n=new Wc(this._lodPlanes[0],e);this._renderer.compile(n,FE)}_sceneToCubeUV(e,n,r,i){const l=new ll(90,1,n,r),c=[1,-1,1,1,1,1],d=[1,1,1,-1,-1,-1],u=this._renderer,m=u.autoClear,p=u.toneMapping;u.getClearColor(Yq),u.toneMapping=lp,u.autoClear=!1;const f=new pJ({name:\"PMREM.Background\",side:Uo,depthWrite:!1,depthTest:!1}),y=new Wc(new dS,f);let v=!1;const b=e.background;b?b.isColor&&(f.color.copy(b),e.background=null,v=!0):(f.color.copy(Yq),v=!0);for(let g=0;g<6;g++){const _=g%3;_===0?(l.up.set(0,c[g],0),l.lookAt(d[g],0,0)):_===1?(l.up.set(0,0,c[g]),l.lookAt(0,d[g],0)):(l.up.set(0,c[g],0),l.lookAt(0,0,d[g]));const C=this._cubeSize;q1(i,_*C,g>2?C:0,C,C),u.setRenderTarget(i),v&&u.render(y,l),u.render(e,l)}y.geometry.dispose(),y.material.dispose(),u.toneMapping=p,u.autoClear=m,e.background=b}_textureToCubeUV(e,n){const r=this._renderer,i=e.mapping===Yx||e.mapping===Qx;i?(this._cubemapMaterial===null&&(this._cubemapMaterial=t$()),this._cubemapMaterial.uniforms.flipEnvMap.value=e.isRenderTargetTexture===!1?-1:1):this._equirectMaterial===null&&(this._equirectMaterial=e$());const s=i?this._cubemapMaterial:this._equirectMaterial,o=new Wc(this._lodPlanes[0],s),l=s.uniforms;l.envMap.value=e;const c=this._cubeSize;q1(n,0,0,3*c,2*c),r.setRenderTarget(n),r.render(o,FE)}_applyPMREM(e){const n=this._renderer,r=n.autoClear;n.autoClear=!1;for(let i=1;i<this._lodPlanes.length;i++){const s=Math.sqrt(this._sigmas[i]*this._sigmas[i]-this._sigmas[i-1]*this._sigmas[i-1]),o=Qq[(i-1)%Qq.length];this._blur(e,i-1,i,s,o)}n.autoClear=r}_blur(e,n,r,i,s){const o=this._pingPongRenderTarget;this._halfBlur(e,o,n,r,i,\"latitudinal\",s),this._halfBlur(o,e,r,r,i,\"longitudinal\",s)}_halfBlur(e,n,r,i,s,o,l){const c=this._renderer,d=this._blurMaterial;o!==\"latitudinal\"&&o!==\"longitudinal\"&&console.error(\"blur direction must be either latitudinal or longitudinal!\");const u=3,m=new Wc(this._lodPlanes[i],d),p=d.uniforms,f=this._sizeLods[r]-1,y=isFinite(s)?Math.PI/(2*f):2*Math.PI/(2*Mf-1),v=s/y,b=isFinite(s)?1+Math.floor(u*v):Mf;b>Mf&&console.warn(`sigmaRadians, ${s}, is too large and will clip, as it requested ${b} samples when the maximum is set to ${Mf}`);const g=[];let _=0;for(let T=0;T<Mf;++T){const F=T/v,k=Math.exp(-F*F/2);g.push(k),T===0?_+=k:T<b&&(_+=2*k)}for(let T=0;T<g.length;T++)g[T]=g[T]/_;p.envMap.value=e.texture,p.samples.value=b,p.weights.value=g,p.latitudinal.value=o===\"latitudinal\",l&&(p.poleAxis.value=l);const{_lodMax:C}=this;p.dTheta.value=y,p.mipInt.value=C-r;const P=this._sizeLods[i],N=3*P*(i>C-wx?i-C+wx:0),A=4*(this._cubeSize-P);q1(n,N,A,3*P,2*P),c.setRenderTarget(n),c.render(m,FE)}}function oAe(t){const e=[],n=[],r=[];let i=t;const s=t-wx+1+Xq.length;for(let o=0;o<s;o++){const l=Math.pow(2,i);n.push(l);let c=1/l;o>t-wx?c=Xq[o-t+wx-1]:o===0&&(c=0),r.push(c);const d=1/(l-2),u=-d,m=1+d,p=[u,u,m,u,m,m,u,u,m,m,u,m],f=6,y=6,v=3,b=2,g=1,_=new Float32Array(v*y*f),C=new Float32Array(b*y*f),P=new Float32Array(g*y*f);for(let A=0;A<f;A++){const T=A%3*2/3-1,F=A>2?0:-1,k=[T,F,0,T+2/3,F,0,T+2/3,F+1,0,T,F,0,T+2/3,F+1,0,T,F+1,0];_.set(k,v*y*A),C.set(p,b*y*A);const D=[A,A,A,A,A,A];P.set(D,g*y*A)}const N=new Vs;N.setAttribute(\"position\",new fl(_,v)),N.setAttribute(\"uv\",new fl(C,b)),N.setAttribute(\"faceIndex\",new fl(P,g)),e.push(N),i>wx&&i--}return{lodPlanes:e,sizeLods:n,sigmas:r}}function Jq(t,e,n){const r=new xg(t,e,n);return r.texture.mapping=eT,r.texture.name=\"PMREM.cubeUv\",r.scissorTest=!0,r}function q1(t,e,n,r,i){t.viewport.set(e,n,r,i),t.scissor.set(e,n,r,i)}function lAe(t,e,n){const r=new Float32Array(Mf),i=new ut(0,1,0);return new vp({name:\"SphericalGaussianBlur\",defines:{n:Mf,CUBEUV_TEXEL_WIDTH:1/e,CUBEUV_TEXEL_HEIGHT:1/n,CUBEUV_MAX_MIP:`${t}.0`},uniforms:{envMap:{value:null},samples:{value:1},weights:{value:r},latitudinal:{value:!1},dTheta:{value:0},mipInt:{value:0},poleAxis:{value:i}},vertexShader:pI(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\t\t\tuniform int samples;\n\t\t\tuniform float weights[ n ];\n\t\t\tuniform bool latitudinal;\n\t\t\tuniform float dTheta;\n\t\t\tuniform float mipInt;\n\t\t\tuniform vec3 poleAxis;\n\n\t\t\t#define ENVMAP_TYPE_CUBE_UV\n\t\t\t#include <cube_uv_reflection_fragment>\n\n\t\t\tvec3 getSample( float theta, vec3 axis ) {\n\n\t\t\t\tfloat cosTheta = cos( theta );\n\t\t\t\t// Rodrigues' axis-angle rotation\n\t\t\t\tvec3 sampleDirection = vOutputDirection * cosTheta\n\t\t\t\t\t+ cross( axis, vOutputDirection ) * sin( theta )\n\t\t\t\t\t+ axis * dot( axis, vOutputDirection ) * ( 1.0 - cosTheta );\n\n\t\t\t\treturn bilinearCubeUV( envMap, sampleDirection, mipInt );\n\n\t\t\t}\n\n\t\t\tvoid main() {\n\n\t\t\t\tvec3 axis = latitudinal ? poleAxis : cross( poleAxis, vOutputDirection );\n\n\t\t\t\tif ( all( equal( axis, vec3( 0.0 ) ) ) ) {\n\n\t\t\t\t\taxis = vec3( vOutputDirection.z, 0.0, - vOutputDirection.x );\n\n\t\t\t\t}\n\n\t\t\t\taxis = normalize( axis );\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t\t\t\tgl_FragColor.rgb += weights[ 0 ] * getSample( 0.0, axis );\n\n\t\t\t\tfor ( int i = 1; i < n; i++ ) {\n\n\t\t\t\t\tif ( i >= samples ) {\n\n\t\t\t\t\t\tbreak;\n\n\t\t\t\t\t}\n\n\t\t\t\t\tfloat theta = dTheta * float( i );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( -1.0 * theta, axis );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( theta, axis );\n\n\t\t\t\t}\n\n\t\t\t}\n\t\t`,blending:op,depthTest:!1,depthWrite:!1})}function e$(){return new vp({name:\"EquirectangularToCubeUV\",uniforms:{envMap:{value:null}},vertexShader:pI(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\n\t\t\t#include <common>\n\n\t\t\tvoid main() {\n\n\t\t\t\tvec3 outputDirection = normalize( vOutputDirection );\n\t\t\t\tvec2 uv = equirectUv( outputDirection );\n\n\t\t\t\tgl_FragColor = vec4( texture2D ( envMap, uv ).rgb, 1.0 );\n\n\t\t\t}\n\t\t`,blending:op,depthTest:!1,depthWrite:!1})}function t$(){return new vp({name:\"CubemapToCubeUV\",uniforms:{envMap:{value:null},flipEnvMap:{value:-1}},vertexShader:pI(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tuniform float flipEnvMap;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform samplerCube envMap;\n\n\t\t\tvoid main() {\n\n\t\t\t\tgl_FragColor = textureCube( envMap, vec3( flipEnvMap * vOutputDirection.x, vOutputDirection.yz ) );\n\n\t\t\t}\n\t\t`,blending:op,depthTest:!1,depthWrite:!1})}function pI(){return`\n\n\t\tprecision mediump float;\n\t\tprecision mediump int;\n\n\t\tattribute float faceIndex;\n\n\t\tvarying vec3 vOutputDirection;\n\n\t\t// RH coordinate system; PMREM face-indexing convention\n\t\tvec3 getDirection( vec2 uv, float face ) {\n\n\t\t\tuv = 2.0 * uv - 1.0;\n\n\t\t\tvec3 direction = vec3( uv, 1.0 );\n\n\t\t\tif ( face == 0.0 ) {\n\n\t\t\t\tdirection = direction.zyx; // ( 1, v, u ) pos x\n\n\t\t\t} else if ( face == 1.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xz *= -1.0; // ( -u, 1, -v ) pos y\n\n\t\t\t} else if ( face == 2.0 ) {\n\n\t\t\t\tdirection.x *= -1.0; // ( -u, v, 1 ) pos z\n\n\t\t\t} else if ( face == 3.0 ) {\n\n\t\t\t\tdirection = direction.zyx;\n\t\t\t\tdirection.xz *= -1.0; // ( -1, v, -u ) neg x\n\n\t\t\t} else if ( face == 4.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xy *= -1.0; // ( -u, -1, v ) neg y\n\n\t\t\t} else if ( face == 5.0 ) {\n\n\t\t\t\tdirection.z *= -1.0; // ( u, v, -1 ) neg z\n\n\t\t\t}\n\n\t\t\treturn direction;\n\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvOutputDirection = getDirection( uv, faceIndex );\n\t\t\tgl_Position = vec4( position, 1.0 );\n\n\t\t}\n\t`}function cAe(t){let e=new WeakMap,n=null;function r(l){if(l&&l.isTexture){const c=l.mapping,d=c===lR||c===cR,u=c===Yx||c===Qx;if(d||u)if(l.isRenderTargetTexture&&l.needsPMREMUpdate===!0){l.needsPMREMUpdate=!1;let m=e.get(l);return n===null&&(n=new Zq(t)),m=d?n.fromEquirectangular(l,m):n.fromCubemap(l,m),e.set(l,m),m.texture}else{if(e.has(l))return e.get(l).texture;{const m=l.image;if(d&&m&&m.height>0||u&&m&&i(m)){n===null&&(n=new Zq(t));const p=d?n.fromEquirectangular(l):n.fromCubemap(l);return e.set(l,p),l.addEventListener(\"dispose\",s),p.texture}else return null}}}return l}function i(l){let c=0;const d=6;for(let u=0;u<d;u++)l[u]!==void 0&&c++;return c===d}function s(l){const c=l.target;c.removeEventListener(\"dispose\",s);const d=e.get(c);d!==void 0&&(e.delete(c),d.dispose())}function o(){e=new WeakMap,n!==null&&(n.dispose(),n=null)}return{get:r,dispose:o}}function dAe(t){const e={};function n(r){if(e[r]!==void 0)return e[r];let i;switch(r){case\"WEBGL_depth_texture\":i=t.getExtension(\"WEBGL_depth_texture\")||t.getExtension(\"MOZ_WEBGL_depth_texture\")||t.getExtension(\"WEBKIT_WEBGL_depth_texture\");break;case\"EXT_texture_filter_anisotropic\":i=t.getExtension(\"EXT_texture_filter_anisotropic\")||t.getExtension(\"MOZ_EXT_texture_filter_anisotropic\")||t.getExtension(\"WEBKIT_EXT_texture_filter_anisotropic\");break;case\"WEBGL_compressed_texture_s3tc\":i=t.getExtension(\"WEBGL_compressed_texture_s3tc\")||t.getExtension(\"MOZ_WEBGL_compressed_texture_s3tc\")||t.getExtension(\"WEBKIT_WEBGL_compressed_texture_s3tc\");break;case\"WEBGL_compressed_texture_pvrtc\":i=t.getExtension(\"WEBGL_compressed_texture_pvrtc\")||t.getExtension(\"WEBKIT_WEBGL_compressed_texture_pvrtc\");break;default:i=t.getExtension(r)}return e[r]=i,i}return{has:function(r){return n(r)!==null},init:function(r){r.isWebGL2?n(\"EXT_color_buffer_float\"):(n(\"WEBGL_depth_texture\"),n(\"OES_texture_float\"),n(\"OES_texture_half_float\"),n(\"OES_texture_half_float_linear\"),n(\"OES_standard_derivatives\"),n(\"OES_element_index_uint\"),n(\"OES_vertex_array_object\"),n(\"ANGLE_instanced_arrays\")),n(\"OES_texture_float_linear\"),n(\"EXT_color_buffer_half_float\"),n(\"WEBGL_multisampled_render_to_texture\")},get:function(r){const i=n(r);return i===null&&console.warn(\"THREE.WebGLRenderer: \"+r+\" extension not supported.\"),i}}}function uAe(t,e,n,r){const i={},s=new WeakMap;function o(m){const p=m.target;p.index!==null&&e.remove(p.index);for(const y in p.attributes)e.remove(p.attributes[y]);for(const y in p.morphAttributes){const v=p.morphAttributes[y];for(let b=0,g=v.length;b<g;b++)e.remove(v[b])}p.removeEventListener(\"dispose\",o),delete i[p.id];const f=s.get(p);f&&(e.remove(f),s.delete(p)),r.releaseStatesOfGeometry(p),p.isInstancedBufferGeometry===!0&&delete p._maxInstanceCount,n.memory.geometries--}function l(m,p){return i[p.id]===!0||(p.addEventListener(\"dispose\",o),i[p.id]=!0,n.memory.geometries++),p}function c(m){const p=m.attributes;for(const y in p)e.update(p[y],t.ARRAY_BUFFER);const f=m.morphAttributes;for(const y in f){const v=f[y];for(let b=0,g=v.length;b<g;b++)e.update(v[b],t.ARRAY_BUFFER)}}function d(m){const p=[],f=m.index,y=m.attributes.position;let v=0;if(f!==null){const _=f.array;v=f.version;for(let C=0,P=_.length;C<P;C+=3){const N=_[C+0],A=_[C+1],T=_[C+2];p.push(N,A,A,T,T,N)}}else if(y!==void 0){const _=y.array;v=y.version;for(let C=0,P=_.length/3-1;C<P;C+=3){const N=C+0,A=C+1,T=C+2;p.push(N,A,A,T,T,N)}}else return;const b=new(lJ(p)?gJ:fJ)(p,1);b.version=v;const g=s.get(m);g&&e.remove(g),s.set(m,b)}function u(m){const p=s.get(m);if(p){const f=m.index;f!==null&&p.version<f.version&&d(m)}else d(m);return s.get(m)}return{get:l,update:c,getWireframeAttribute:u}}function mAe(t,e,n,r){const i=r.isWebGL2;let s;function o(f){s=f}let l,c;function d(f){l=f.type,c=f.bytesPerElement}function u(f,y){t.drawElements(s,y,l,f*c),n.update(y,s,1)}function m(f,y,v){if(v===0)return;let b,g;if(i)b=t,g=\"drawElementsInstanced\";else if(b=e.get(\"ANGLE_instanced_arrays\"),g=\"drawElementsInstancedANGLE\",b===null){console.error(\"THREE.WebGLIndexedBufferRenderer: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.\");return}b[g](s,y,l,f*c,v),n.update(y,s,v)}function p(f,y,v){if(v===0)return;const b=e.get(\"WEBGL_multi_draw\");if(b===null)for(let g=0;g<v;g++)this.render(f[g]/c,y[g]);else{b.multiDrawElementsWEBGL(s,y,0,l,f,0,v);let g=0;for(let _=0;_<v;_++)g+=y[_];n.update(g,s,1)}}this.setMode=o,this.setIndex=d,this.render=u,this.renderInstances=m,this.renderMultiDraw=p}function hAe(t){const e={geometries:0,textures:0},n={frame:0,calls:0,triangles:0,points:0,lines:0};function r(s,o,l){switch(n.calls++,o){case t.TRIANGLES:n.triangles+=l*(s/3);break;case t.LINES:n.lines+=l*(s/2);break;case t.LINE_STRIP:n.lines+=l*(s-1);break;case t.LINE_LOOP:n.lines+=l*s;break;case t.POINTS:n.points+=l*s;break;default:console.error(\"THREE.WebGLInfo: Unknown draw mode:\",o);break}}function i(){n.calls=0,n.triangles=0,n.points=0,n.lines=0}return{memory:e,render:n,programs:null,autoReset:!0,reset:i,update:r}}function pAe(t,e){return t[0]-e[0]}function fAe(t,e){return Math.abs(e[1])-Math.abs(t[1])}function gAe(t,e,n){const r={},i=new Float32Array(8),s=new WeakMap,o=new ta,l=[];for(let d=0;d<8;d++)l[d]=[d,0];function c(d,u,m){const p=d.morphTargetInfluences;if(e.isWebGL2===!0){const y=u.morphAttributes.position||u.morphAttributes.normal||u.morphAttributes.color,v=y!==void 0?y.length:0;let b=s.get(u);if(b===void 0||b.count!==v){let te=function(){Q.dispose(),s.delete(u),u.removeEventListener(\"dispose\",te)};var f=te;b!==void 0&&b.texture.dispose();const C=u.morphAttributes.position!==void 0,P=u.morphAttributes.normal!==void 0,N=u.morphAttributes.color!==void 0,A=u.morphAttributes.position||[],T=u.morphAttributes.normal||[],F=u.morphAttributes.color||[];let k=0;C===!0&&(k=1),P===!0&&(k=2),N===!0&&(k=3);let D=u.attributes.position.count*k,H=1;D>e.maxTextureSize&&(H=Math.ceil(D/e.maxTextureSize),D=e.maxTextureSize);const z=new Float32Array(D*H*4*v),Q=new uJ(z,D,H,v);Q.type=tm,Q.needsUpdate=!0;const L=k*4;for(let ie=0;ie<v;ie++){const J=A[ie],oe=T[ie],fe=F[ie],re=D*H*4*ie;for(let W=0;W<J.count;W++){const ne=W*L;C===!0&&(o.fromBufferAttribute(J,W),z[re+ne+0]=o.x,z[re+ne+1]=o.y,z[re+ne+2]=o.z,z[re+ne+3]=0),P===!0&&(o.fromBufferAttribute(oe,W),z[re+ne+4]=o.x,z[re+ne+5]=o.y,z[re+ne+6]=o.z,z[re+ne+7]=0),N===!0&&(o.fromBufferAttribute(fe,W),z[re+ne+8]=o.x,z[re+ne+9]=o.y,z[re+ne+10]=o.z,z[re+ne+11]=fe.itemSize===4?o.w:1)}}b={count:v,texture:Q,size:new Vn(D,H)},s.set(u,b),u.addEventListener(\"dispose\",te)}let g=0;for(let C=0;C<p.length;C++)g+=p[C];const _=u.morphTargetsRelative?1:1-g;m.getUniforms().setValue(t,\"morphTargetBaseInfluence\",_),m.getUniforms().setValue(t,\"morphTargetInfluences\",p),m.getUniforms().setValue(t,\"morphTargetsTexture\",b.texture,n),m.getUniforms().setValue(t,\"morphTargetsTextureSize\",b.size)}else{const y=p===void 0?0:p.length;let v=r[u.id];if(v===void 0||v.length!==y){v=[];for(let P=0;P<y;P++)v[P]=[P,0];r[u.id]=v}for(let P=0;P<y;P++){const N=v[P];N[0]=P,N[1]=p[P]}v.sort(fAe);for(let P=0;P<8;P++)P<y&&v[P][1]?(l[P][0]=v[P][0],l[P][1]=v[P][1]):(l[P][0]=Number.MAX_SAFE_INTEGER,l[P][1]=0);l.sort(pAe);const b=u.morphAttributes.position,g=u.morphAttributes.normal;let _=0;for(let P=0;P<8;P++){const N=l[P],A=N[0],T=N[1];A!==Number.MAX_SAFE_INTEGER&&T?(b&&u.getAttribute(\"morphTarget\"+P)!==b[A]&&u.setAttribute(\"morphTarget\"+P,b[A]),g&&u.getAttribute(\"morphNormal\"+P)!==g[A]&&u.setAttribute(\"morphNormal\"+P,g[A]),i[P]=T,_+=T):(b&&u.hasAttribute(\"morphTarget\"+P)===!0&&u.deleteAttribute(\"morphTarget\"+P),g&&u.hasAttribute(\"morphNormal\"+P)===!0&&u.deleteAttribute(\"morphNormal\"+P),i[P]=0)}const C=u.morphTargetsRelative?1:1-_;m.getUniforms().setValue(t,\"morphTargetBaseInfluence\",C),m.getUniforms().setValue(t,\"morphTargetInfluences\",i)}}return{update:c}}function bAe(t,e,n,r){let i=new WeakMap;function s(c){const d=r.render.frame,u=c.geometry,m=e.get(c,u);if(i.get(m)!==d&&(e.update(m),i.set(m,d)),c.isInstancedMesh&&(c.hasEventListener(\"dispose\",l)===!1&&c.addEventListener(\"dispose\",l),i.get(c)!==d&&(n.update(c.instanceMatrix,t.ARRAY_BUFFER),c.instanceColor!==null&&n.update(c.instanceColor,t.ARRAY_BUFFER),i.set(c,d))),c.isSkinnedMesh){const p=c.skeleton;i.get(p)!==d&&(p.update(),i.set(p,d))}return m}function o(){i=new WeakMap}function l(c){const d=c.target;d.removeEventListener(\"dispose\",l),n.remove(d.instanceMatrix),d.instanceColor!==null&&n.remove(d.instanceColor)}return{update:s,dispose:o}}class wJ extends Bo{constructor(e,n,r,i,s,o,l,c,d,u){if(u=u!==void 0?u:Xf,u!==Xf&&u!==Zx)throw new Error(\"DepthTexture format must be either THREE.DepthFormat or THREE.DepthStencilFormat\");r===void 0&&u===Xf&&(r=qh),r===void 0&&u===Zx&&(r=Kf),super(null,i,s,o,l,c,u,r,d),this.isDepthTexture=!0,this.image={width:e,height:n},this.magFilter=l!==void 0?l:gs,this.minFilter=c!==void 0?c:gs,this.flipY=!1,this.generateMipmaps=!1,this.compareFunction=null}copy(e){return super.copy(e),this.compareFunction=e.compareFunction,this}toJSON(e){const n=super.toJSON(e);return this.compareFunction!==null&&(n.compareFunction=this.compareFunction),n}}const SJ=new Bo,_J=new wJ(1,1);_J.compareFunction=sJ;const kJ=new uJ,NJ=new tCe,CJ=new yJ,n$=[],r$=[],a$=new Float32Array(16),i$=new Float32Array(9),s$=new Float32Array(4);function Py(t,e,n){const r=t[0];if(r<=0||r>0)return t;const i=e*n;let s=n$[i];if(s===void 0&&(s=new Float32Array(i),n$[i]=s),e!==0){r.toArray(s,0);for(let o=1,l=0;o!==e;++o)l+=n,t[o].toArray(s,l)}return s}function Di(t,e){if(t.length!==e.length)return!1;for(let n=0,r=t.length;n<r;n++)if(t[n]!==e[n])return!1;return!0}function Fi(t,e){for(let n=0,r=e.length;n<r;n++)t[n]=e[n]}function rT(t,e){let n=r$[e];n===void 0&&(n=new Int32Array(e),r$[e]=n);for(let r=0;r!==e;++r)n[r]=t.allocateTextureUnit();return n}function xAe(t,e){const n=this.cache;n[0]!==e&&(t.uniform1f(this.addr,e),n[0]=e)}function yAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y)&&(t.uniform2f(this.addr,e.x,e.y),n[0]=e.x,n[1]=e.y);else{if(Di(n,e))return;t.uniform2fv(this.addr,e),Fi(n,e)}}function vAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z)&&(t.uniform3f(this.addr,e.x,e.y,e.z),n[0]=e.x,n[1]=e.y,n[2]=e.z);else if(e.r!==void 0)(n[0]!==e.r||n[1]!==e.g||n[2]!==e.b)&&(t.uniform3f(this.addr,e.r,e.g,e.b),n[0]=e.r,n[1]=e.g,n[2]=e.b);else{if(Di(n,e))return;t.uniform3fv(this.addr,e),Fi(n,e)}}function wAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z||n[3]!==e.w)&&(t.uniform4f(this.addr,e.x,e.y,e.z,e.w),n[0]=e.x,n[1]=e.y,n[2]=e.z,n[3]=e.w);else{if(Di(n,e))return;t.uniform4fv(this.addr,e),Fi(n,e)}}function SAe(t,e){const n=this.cache,r=e.elements;if(r===void 0){if(Di(n,e))return;t.uniformMatrix2fv(this.addr,!1,e),Fi(n,e)}else{if(Di(n,r))return;s$.set(r),t.uniformMatrix2fv(this.addr,!1,s$),Fi(n,r)}}function _Ae(t,e){const n=this.cache,r=e.elements;if(r===void 0){if(Di(n,e))return;t.uniformMatrix3fv(this.addr,!1,e),Fi(n,e)}else{if(Di(n,r))return;i$.set(r),t.uniformMatrix3fv(this.addr,!1,i$),Fi(n,r)}}function kAe(t,e){const n=this.cache,r=e.elements;if(r===void 0){if(Di(n,e))return;t.uniformMatrix4fv(this.addr,!1,e),Fi(n,e)}else{if(Di(n,r))return;a$.set(r),t.uniformMatrix4fv(this.addr,!1,a$),Fi(n,r)}}function NAe(t,e){const n=this.cache;n[0]!==e&&(t.uniform1i(this.addr,e),n[0]=e)}function CAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y)&&(t.uniform2i(this.addr,e.x,e.y),n[0]=e.x,n[1]=e.y);else{if(Di(n,e))return;t.uniform2iv(this.addr,e),Fi(n,e)}}function PAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z)&&(t.uniform3i(this.addr,e.x,e.y,e.z),n[0]=e.x,n[1]=e.y,n[2]=e.z);else{if(Di(n,e))return;t.uniform3iv(this.addr,e),Fi(n,e)}}function TAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z||n[3]!==e.w)&&(t.uniform4i(this.addr,e.x,e.y,e.z,e.w),n[0]=e.x,n[1]=e.y,n[2]=e.z,n[3]=e.w);else{if(Di(n,e))return;t.uniform4iv(this.addr,e),Fi(n,e)}}function AAe(t,e){const n=this.cache;n[0]!==e&&(t.uniform1ui(this.addr,e),n[0]=e)}function jAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y)&&(t.uniform2ui(this.addr,e.x,e.y),n[0]=e.x,n[1]=e.y);else{if(Di(n,e))return;t.uniform2uiv(this.addr,e),Fi(n,e)}}function MAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z)&&(t.uniform3ui(this.addr,e.x,e.y,e.z),n[0]=e.x,n[1]=e.y,n[2]=e.z);else{if(Di(n,e))return;t.uniform3uiv(this.addr,e),Fi(n,e)}}function EAe(t,e){const n=this.cache;if(e.x!==void 0)(n[0]!==e.x||n[1]!==e.y||n[2]!==e.z||n[3]!==e.w)&&(t.uniform4ui(this.addr,e.x,e.y,e.z,e.w),n[0]=e.x,n[1]=e.y,n[2]=e.z,n[3]=e.w);else{if(Di(n,e))return;t.uniform4uiv(this.addr,e),Fi(n,e)}}function DAe(t,e,n){const r=this.cache,i=n.allocateTextureUnit();r[0]!==i&&(t.uniform1i(this.addr,i),r[0]=i);const s=this.type===t.SAMPLER_2D_SHADOW?_J:SJ;n.setTexture2D(e||s,i)}function FAe(t,e,n){const r=this.cache,i=n.allocateTextureUnit();r[0]!==i&&(t.uniform1i(this.addr,i),r[0]=i),n.setTexture3D(e||NJ,i)}function RAe(t,e,n){const r=this.cache,i=n.allocateTextureUnit();r[0]!==i&&(t.uniform1i(this.addr,i),r[0]=i),n.setTextureCube(e||CJ,i)}function LAe(t,e,n){const r=this.cache,i=n.allocateTextureUnit();r[0]!==i&&(t.uniform1i(this.addr,i),r[0]=i),n.setTexture2DArray(e||kJ,i)}function OAe(t){switch(t){case 5126:return xAe;case 35664:return yAe;case 35665:return vAe;case 35666:return wAe;case 35674:return SAe;case 35675:return _Ae;case 35676:return kAe;case 5124:case 35670:return NAe;case 35667:case 35671:return CAe;case 35668:case 35672:return PAe;case 35669:case 35673:return TAe;case 5125:return AAe;case 36294:return jAe;case 36295:return MAe;case 36296:return EAe;case 35678:case 36198:case 36298:case 36306:case 35682:return DAe;case 35679:case 36299:case 36307:return FAe;case 35680:case 36300:case 36308:case 36293:return RAe;case 36289:case 36303:case 36311:case 36292:return LAe}}function IAe(t,e){t.uniform1fv(this.addr,e)}function zAe(t,e){const n=Py(e,this.size,2);t.uniform2fv(this.addr,n)}function UAe(t,e){const n=Py(e,this.size,3);t.uniform3fv(this.addr,n)}function BAe(t,e){const n=Py(e,this.size,4);t.uniform4fv(this.addr,n)}function HAe(t,e){const n=Py(e,this.size,4);t.uniformMatrix2fv(this.addr,!1,n)}function qAe(t,e){const n=Py(e,this.size,9);t.uniformMatrix3fv(this.addr,!1,n)}function $Ae(t,e){const n=Py(e,this.size,16);t.uniformMatrix4fv(this.addr,!1,n)}function VAe(t,e){t.uniform1iv(this.addr,e)}function GAe(t,e){t.uniform2iv(this.addr,e)}function WAe(t,e){t.uniform3iv(this.addr,e)}function KAe(t,e){t.uniform4iv(this.addr,e)}function XAe(t,e){t.uniform1uiv(this.addr,e)}function YAe(t,e){t.uniform2uiv(this.addr,e)}function QAe(t,e){t.uniform3uiv(this.addr,e)}function ZAe(t,e){t.uniform4uiv(this.addr,e)}function JAe(t,e,n){const r=this.cache,i=e.length,s=rT(n,i);Di(r,s)||(t.uniform1iv(this.addr,s),Fi(r,s));for(let o=0;o!==i;++o)n.setTexture2D(e[o]||SJ,s[o])}function eje(t,e,n){const r=this.cache,i=e.length,s=rT(n,i);Di(r,s)||(t.uniform1iv(this.addr,s),Fi(r,s));for(let o=0;o!==i;++o)n.setTexture3D(e[o]||NJ,s[o])}function tje(t,e,n){const r=this.cache,i=e.length,s=rT(n,i);Di(r,s)||(t.uniform1iv(this.addr,s),Fi(r,s));for(let o=0;o!==i;++o)n.setTextureCube(e[o]||CJ,s[o])}function nje(t,e,n){const r=this.cache,i=e.length,s=rT(n,i);Di(r,s)||(t.uniform1iv(this.addr,s),Fi(r,s));for(let o=0;o!==i;++o)n.setTexture2DArray(e[o]||kJ,s[o])}function rje(t){switch(t){case 5126:return IAe;case 35664:return zAe;case 35665:return UAe;case 35666:return BAe;case 35674:return HAe;case 35675:return qAe;case 35676:return $Ae;case 5124:case 35670:return VAe;case 35667:case 35671:return GAe;case 35668:case 35672:return WAe;case 35669:case 35673:return KAe;case 5125:return XAe;case 36294:return YAe;case 36295:return QAe;case 36296:return ZAe;case 35678:case 36198:case 36298:case 36306:case 35682:return JAe;case 35679:case 36299:case 36307:return eje;case 35680:case 36300:case 36308:case 36293:return tje;case 36289:case 36303:case 36311:case 36292:return nje}}class aje{constructor(e,n,r){this.id=e,this.addr=r,this.cache=[],this.type=n.type,this.setValue=OAe(n.type)}}class ije{constructor(e,n,r){this.id=e,this.addr=r,this.cache=[],this.type=n.type,this.size=n.size,this.setValue=rje(n.type)}}class sje{constructor(e){this.id=e,this.seq=[],this.map={}}setValue(e,n,r){const i=this.seq;for(let s=0,o=i.length;s!==o;++s){const l=i[s];l.setValue(e,n[l.id],r)}}}const IE=/(\\w+)(\\])?(\\[|\\.)?/g;function o$(t,e){t.seq.push(e),t.map[e.id]=e}function oje(t,e,n){const r=t.name,i=r.length;for(IE.lastIndex=0;;){const s=IE.exec(r),o=IE.lastIndex;let l=s[1];const c=s[2]===\"]\",d=s[3];if(c&&(l=l|0),d===void 0||d===\"[\"&&o+2===i){o$(n,d===void 0?new aje(l,t,e):new ije(l,t,e));break}else{let m=n.map[l];m===void 0&&(m=new sje(l),o$(n,m)),n=m}}}class Kk{constructor(e,n){this.seq=[],this.map={};const r=e.getProgramParameter(n,e.ACTIVE_UNIFORMS);for(let i=0;i<r;++i){const s=e.getActiveUniform(n,i),o=e.getUniformLocation(n,s.name);oje(s,o,this)}}setValue(e,n,r,i){const s=this.map[n];s!==void 0&&s.setValue(e,r,i)}setOptional(e,n,r){const i=n[r];i!==void 0&&this.setValue(e,r,i)}static upload(e,n,r,i){for(let s=0,o=n.length;s!==o;++s){const l=n[s],c=r[l.id];c.needsUpdate!==!1&&l.setValue(e,c.value,i)}}static seqWithValue(e,n){const r=[];for(let i=0,s=e.length;i!==s;++i){const o=e[i];o.id in n&&r.push(o)}return r}}function l$(t,e,n){const r=t.createShader(e);return t.shaderSource(r,n),t.compileShader(r),r}const lje=37297;let cje=0;function dje(t,e){const n=t.split(`\n`),r=[],i=Math.max(e-6,0),s=Math.min(e+6,n.length);for(let o=i;o<s;o++){const l=o+1;r.push(`${l===e?\">\":\" \"} ${l}: ${n[o]}`)}return r.join(`\n`)}function uje(t){const e=Jr.getPrimaries(Jr.workingColorSpace),n=Jr.getPrimaries(t);let r;switch(e===n?r=\"\":e===LN&&n===RN?r=\"LinearDisplayP3ToLinearSRGB\":e===RN&&n===LN&&(r=\"LinearSRGBToLinearDisplayP3\"),t){case Sm:case tT:return[r,\"LinearTransferOETF\"];case bs:case cI:return[r,\"sRGBTransferOETF\"];default:return console.warn(\"THREE.WebGLProgram: Unsupported color space:\",t),[r,\"LinearTransferOETF\"]}}function c$(t,e,n){const r=t.getShaderParameter(e,t.COMPILE_STATUS),i=t.getShaderInfoLog(e).trim();if(r&&i===\"\")return\"\";const s=/ERROR: 0:(\\d+)/.exec(i);if(s){const o=parseInt(s[1]);return n.toUpperCase()+`\n\n`+i+`\n\n`+dje(t.getShaderSource(e),o)}else return i}function mje(t,e){const n=uje(e);return`vec4 ${t}( vec4 value ) { return ${n[0]}( ${n[1]}( value ) ); }`}function hje(t,e){let n;switch(e){case mNe:n=\"Linear\";break;case hNe:n=\"Reinhard\";break;case pNe:n=\"OptimizedCineon\";break;case fNe:n=\"ACESFilmic\";break;case gNe:n=\"Custom\";break;default:console.warn(\"THREE.WebGLProgram: Unsupported toneMapping:\",e),n=\"Linear\"}return\"vec3 \"+t+\"( vec3 color ) { return \"+n+\"ToneMapping( color ); }\"}function pje(t){return[t.extensionDerivatives||t.envMapCubeUVHeight||t.bumpMap||t.normalMapTangentSpace||t.clearcoatNormalMap||t.flatShading||t.shaderID===\"physical\"?\"#extension GL_OES_standard_derivatives : enable\":\"\",(t.extensionFragDepth||t.logarithmicDepthBuffer)&&t.rendererExtensionFragDepth?\"#extension GL_EXT_frag_depth : enable\":\"\",t.extensionDrawBuffers&&t.rendererExtensionDrawBuffers?\"#extension GL_EXT_draw_buffers : require\":\"\",(t.extensionShaderTextureLOD||t.envMap||t.transmission)&&t.rendererExtensionShaderTextureLod?\"#extension GL_EXT_shader_texture_lod : enable\":\"\"].filter(h0).join(`\n`)}function fje(t){const e=[];for(const n in t){const r=t[n];r!==!1&&e.push(\"#define \"+n+\" \"+r)}return e.join(`\n`)}function gje(t,e){const n={},r=t.getProgramParameter(e,t.ACTIVE_ATTRIBUTES);for(let i=0;i<r;i++){const s=t.getActiveAttrib(e,i),o=s.name;let l=1;s.type===t.FLOAT_MAT2&&(l=2),s.type===t.FLOAT_MAT3&&(l=3),s.type===t.FLOAT_MAT4&&(l=4),n[o]={type:s.type,location:t.getAttribLocation(e,o),locationSize:l}}return n}function h0(t){return t!==\"\"}function d$(t,e){const n=e.numSpotLightShadows+e.numSpotLightMaps-e.numSpotLightShadowsWithMaps;return t.replace(/NUM_DIR_LIGHTS/g,e.numDirLights).replace(/NUM_SPOT_LIGHTS/g,e.numSpotLights).replace(/NUM_SPOT_LIGHT_MAPS/g,e.numSpotLightMaps).replace(/NUM_SPOT_LIGHT_COORDS/g,n).replace(/NUM_RECT_AREA_LIGHTS/g,e.numRectAreaLights).replace(/NUM_POINT_LIGHTS/g,e.numPointLights).replace(/NUM_HEMI_LIGHTS/g,e.numHemiLights).replace(/NUM_DIR_LIGHT_SHADOWS/g,e.numDirLightShadows).replace(/NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS/g,e.numSpotLightShadowsWithMaps).replace(/NUM_SPOT_LIGHT_SHADOWS/g,e.numSpotLightShadows).replace(/NUM_POINT_LIGHT_SHADOWS/g,e.numPointLightShadows)}function u$(t,e){return t.replace(/NUM_CLIPPING_PLANES/g,e.numClippingPlanes).replace(/UNION_CLIPPING_PLANES/g,e.numClippingPlanes-e.numClipIntersection)}const bje=/^[ \\t]*#include +<([\\w\\d./]+)>/gm;function fR(t){return t.replace(bje,yje)}const xje=new Map([[\"encodings_fragment\",\"colorspace_fragment\"],[\"encodings_pars_fragment\",\"colorspace_pars_fragment\"],[\"output_fragment\",\"opaque_fragment\"]]);function yje(t,e){let n=tr[e];if(n===void 0){const r=xje.get(e);if(r!==void 0)n=tr[r],console.warn('THREE.WebGLRenderer: Shader chunk \"%s\" has been deprecated. Use \"%s\" instead.',e,r);else throw new Error(\"Can not resolve #include <\"+e+\">\")}return fR(n)}const vje=/#pragma unroll_loop_start\\s+for\\s*\\(\\s*int\\s+i\\s*=\\s*(\\d+)\\s*;\\s*i\\s*<\\s*(\\d+)\\s*;\\s*i\\s*\\+\\+\\s*\\)\\s*{([\\s\\S]+?)}\\s+#pragma unroll_loop_end/g;function m$(t){return t.replace(vje,wje)}function wje(t,e,n,r){let i=\"\";for(let s=parseInt(e);s<parseInt(n);s++)i+=r.replace(/\\[\\s*i\\s*\\]/g,\"[ \"+s+\" ]\").replace(/UNROLLED_LOOP_INDEX/g,s);return i}function h$(t){let e=\"precision \"+t.precision+` float;\nprecision `+t.precision+\" int;\";return t.precision===\"highp\"?e+=`\n#define HIGH_PRECISION`:t.precision===\"mediump\"?e+=`\n#define MEDIUM_PRECISION`:t.precision===\"lowp\"&&(e+=`\n#define LOW_PRECISION`),e}function Sje(t){let e=\"SHADOWMAP_TYPE_BASIC\";return t.shadowMapType===XZ?e=\"SHADOWMAP_TYPE_PCF\":t.shadowMapType===Uke?e=\"SHADOWMAP_TYPE_PCF_SOFT\":t.shadowMapType===Uu&&(e=\"SHADOWMAP_TYPE_VSM\"),e}function _je(t){let e=\"ENVMAP_TYPE_CUBE\";if(t.envMap)switch(t.envMapMode){case Yx:case Qx:e=\"ENVMAP_TYPE_CUBE\";break;case eT:e=\"ENVMAP_TYPE_CUBE_UV\";break}return e}function kje(t){let e=\"ENVMAP_MODE_REFLECTION\";return t.envMap&&t.envMapMode===Qx&&(e=\"ENVMAP_MODE_REFRACTION\"),e}function Nje(t){let e=\"ENVMAP_BLENDING_NONE\";if(t.envMap)switch(t.combine){case oI:e=\"ENVMAP_BLENDING_MULTIPLY\";break;case dNe:e=\"ENVMAP_BLENDING_MIX\";break;case uNe:e=\"ENVMAP_BLENDING_ADD\";break}return e}function Cje(t){const e=t.envMapCubeUVHeight;if(e===null)return null;const n=Math.log2(e)-2,r=1/e;return{texelWidth:1/(3*Math.max(Math.pow(2,n),112)),texelHeight:r,maxMip:n}}function Pje(t,e,n,r){const i=t.getContext(),s=n.defines;let o=n.vertexShader,l=n.fragmentShader;const c=Sje(n),d=_je(n),u=kje(n),m=Nje(n),p=Cje(n),f=n.isWebGL2?\"\":pje(n),y=fje(s),v=i.createProgram();let b,g,_=n.glslVersion?\"#version \"+n.glslVersion+`\n`:\"\";n.isRawShaderMaterial?(b=[\"#define SHADER_TYPE \"+n.shaderType,\"#define SHADER_NAME \"+n.shaderName,y].filter(h0).join(`\n`),b.length>0&&(b+=`\n`),g=[f,\"#define SHADER_TYPE \"+n.shaderType,\"#define SHADER_NAME \"+n.shaderName,y].filter(h0).join(`\n`),g.length>0&&(g+=`\n`)):(b=[h$(n),\"#define SHADER_TYPE \"+n.shaderType,\"#define SHADER_NAME \"+n.shaderName,y,n.batching?\"#define USE_BATCHING\":\"\",n.instancing?\"#define USE_INSTANCING\":\"\",n.instancingColor?\"#define USE_INSTANCING_COLOR\":\"\",n.useFog&&n.fog?\"#define USE_FOG\":\"\",n.useFog&&n.fogExp2?\"#define FOG_EXP2\":\"\",n.map?\"#define USE_MAP\":\"\",n.envMap?\"#define USE_ENVMAP\":\"\",n.envMap?\"#define \"+u:\"\",n.lightMap?\"#define USE_LIGHTMAP\":\"\",n.aoMap?\"#define USE_AOMAP\":\"\",n.bumpMap?\"#define USE_BUMPMAP\":\"\",n.normalMap?\"#define USE_NORMALMAP\":\"\",n.normalMapObjectSpace?\"#define USE_NORMALMAP_OBJECTSPACE\":\"\",n.normalMapTangentSpace?\"#define USE_NORMALMAP_TANGENTSPACE\":\"\",n.displacementMap?\"#define USE_DISPLACEMENTMAP\":\"\",n.emissiveMap?\"#define USE_EMISSIVEMAP\":\"\",n.anisotropy?\"#define USE_ANISOTROPY\":\"\",n.anisotropyMap?\"#define USE_ANISOTROPYMAP\":\"\",n.clearcoatMap?\"#define USE_CLEARCOATMAP\":\"\",n.clearcoatRoughnessMap?\"#define USE_CLEARCOAT_ROUGHNESSMAP\":\"\",n.clearcoatNormalMap?\"#define USE_CLEARCOAT_NORMALMAP\":\"\",n.iridescenceMap?\"#define USE_IRIDESCENCEMAP\":\"\",n.iridescenceThicknessMap?\"#define USE_IRIDESCENCE_THICKNESSMAP\":\"\",n.specularMap?\"#define USE_SPECULARMAP\":\"\",n.specularColorMap?\"#define USE_SPECULAR_COLORMAP\":\"\",n.specularIntensityMap?\"#define USE_SPECULAR_INTENSITYMAP\":\"\",n.roughnessMap?\"#define USE_ROUGHNESSMAP\":\"\",n.metalnessMap?\"#define USE_METALNESSMAP\":\"\",n.alphaMap?\"#define USE_ALPHAMAP\":\"\",n.alphaHash?\"#define USE_ALPHAHASH\":\"\",n.transmission?\"#define USE_TRANSMISSION\":\"\",n.transmissionMap?\"#define USE_TRANSMISSIONMAP\":\"\",n.thicknessMap?\"#define USE_THICKNESSMAP\":\"\",n.sheenColorMap?\"#define USE_SHEEN_COLORMAP\":\"\",n.sheenRoughnessMap?\"#define USE_SHEEN_ROUGHNESSMAP\":\"\",n.mapUv?\"#define MAP_UV \"+n.mapUv:\"\",n.alphaMapUv?\"#define ALPHAMAP_UV \"+n.alphaMapUv:\"\",n.lightMapUv?\"#define LIGHTMAP_UV \"+n.lightMapUv:\"\",n.aoMapUv?\"#define AOMAP_UV \"+n.aoMapUv:\"\",n.emissiveMapUv?\"#define EMISSIVEMAP_UV \"+n.emissiveMapUv:\"\",n.bumpMapUv?\"#define BUMPMAP_UV \"+n.bumpMapUv:\"\",n.normalMapUv?\"#define NORMALMAP_UV \"+n.normalMapUv:\"\",n.displacementMapUv?\"#define DISPLACEMENTMAP_UV \"+n.displacementMapUv:\"\",n.metalnessMapUv?\"#define METALNESSMAP_UV \"+n.metalnessMapUv:\"\",n.roughnessMapUv?\"#define ROUGHNESSMAP_UV \"+n.roughnessMapUv:\"\",n.anisotropyMapUv?\"#define ANISOTROPYMAP_UV \"+n.anisotropyMapUv:\"\",n.clearcoatMapUv?\"#define CLEARCOATMAP_UV \"+n.clearcoatMapUv:\"\",n.clearcoatNormalMapUv?\"#define CLEARCOAT_NORMALMAP_UV \"+n.clearcoatNormalMapUv:\"\",n.clearcoatRoughnessMapUv?\"#define CLEARCOAT_ROUGHNESSMAP_UV \"+n.clearcoatRoughnessMapUv:\"\",n.iridescenceMapUv?\"#define IRIDESCENCEMAP_UV \"+n.iridescenceMapUv:\"\",n.iridescenceThicknessMapUv?\"#define IRIDESCENCE_THICKNESSMAP_UV \"+n.iridescenceThicknessMapUv:\"\",n.sheenColorMapUv?\"#define SHEEN_COLORMAP_UV \"+n.sheenColorMapUv:\"\",n.sheenRoughnessMapUv?\"#define SHEEN_ROUGHNESSMAP_UV \"+n.sheenRoughnessMapUv:\"\",n.specularMapUv?\"#define SPECULARMAP_UV \"+n.specularMapUv:\"\",n.specularColorMapUv?\"#define SPECULAR_COLORMAP_UV \"+n.specularColorMapUv:\"\",n.specularIntensityMapUv?\"#define SPECULAR_INTENSITYMAP_UV \"+n.specularIntensityMapUv:\"\",n.transmissionMapUv?\"#define TRANSMISSIONMAP_UV \"+n.transmissionMapUv:\"\",n.thicknessMapUv?\"#define THICKNESSMAP_UV \"+n.thicknessMapUv:\"\",n.vertexTangents&&n.flatShading===!1?\"#define USE_TANGENT\":\"\",n.vertexColors?\"#define USE_COLOR\":\"\",n.vertexAlphas?\"#define USE_COLOR_ALPHA\":\"\",n.vertexUv1s?\"#define USE_UV1\":\"\",n.vertexUv2s?\"#define USE_UV2\":\"\",n.vertexUv3s?\"#define USE_UV3\":\"\",n.pointsUvs?\"#define USE_POINTS_UV\":\"\",n.flatShading?\"#define FLAT_SHADED\":\"\",n.skinning?\"#define USE_SKINNING\":\"\",n.morphTargets?\"#define USE_MORPHTARGETS\":\"\",n.morphNormals&&n.flatShading===!1?\"#define USE_MORPHNORMALS\":\"\",n.morphColors&&n.isWebGL2?\"#define USE_MORPHCOLORS\":\"\",n.morphTargetsCount>0&&n.isWebGL2?\"#define MORPHTARGETS_TEXTURE\":\"\",n.morphTargetsCount>0&&n.isWebGL2?\"#define MORPHTARGETS_TEXTURE_STRIDE \"+n.morphTextureStride:\"\",n.morphTargetsCount>0&&n.isWebGL2?\"#define MORPHTARGETS_COUNT \"+n.morphTargetsCount:\"\",n.doubleSided?\"#define DOUBLE_SIDED\":\"\",n.flipSided?\"#define FLIP_SIDED\":\"\",n.shadowMapEnabled?\"#define USE_SHADOWMAP\":\"\",n.shadowMapEnabled?\"#define \"+c:\"\",n.sizeAttenuation?\"#define USE_SIZEATTENUATION\":\"\",n.numLightProbes>0?\"#define USE_LIGHT_PROBES\":\"\",n.useLegacyLights?\"#define LEGACY_LIGHTS\":\"\",n.logarithmicDepthBuffer?\"#define USE_LOGDEPTHBUF\":\"\",n.logarithmicDepthBuffer&&n.rendererExtensionFragDepth?\"#define USE_LOGDEPTHBUF_EXT\":\"\",\"uniform mat4 modelMatrix;\",\"uniform mat4 modelViewMatrix;\",\"uniform mat4 projectionMatrix;\",\"uniform mat4 viewMatrix;\",\"uniform mat3 normalMatrix;\",\"uniform vec3 cameraPosition;\",\"uniform bool isOrthographic;\",\"#ifdef USE_INSTANCING\",\"\tattribute mat4 instanceMatrix;\",\"#endif\",\"#ifdef USE_INSTANCING_COLOR\",\"\tattribute vec3 instanceColor;\",\"#endif\",\"attribute vec3 position;\",\"attribute vec3 normal;\",\"attribute vec2 uv;\",\"#ifdef USE_UV1\",\"\tattribute vec2 uv1;\",\"#endif\",\"#ifdef USE_UV2\",\"\tattribute vec2 uv2;\",\"#endif\",\"#ifdef USE_UV3\",\"\tattribute vec2 uv3;\",\"#endif\",\"#ifdef USE_TANGENT\",\"\tattribute vec4 tangent;\",\"#endif\",\"#if defined( USE_COLOR_ALPHA )\",\"\tattribute vec4 color;\",\"#elif defined( USE_COLOR )\",\"\tattribute vec3 color;\",\"#endif\",\"#if ( defined( USE_MORPHTARGETS ) && ! defined( MORPHTARGETS_TEXTURE ) )\",\"\tattribute vec3 morphTarget0;\",\"\tattribute vec3 morphTarget1;\",\"\tattribute vec3 morphTarget2;\",\"\tattribute vec3 morphTarget3;\",\"\t#ifdef USE_MORPHNORMALS\",\"\t\tattribute vec3 morphNormal0;\",\"\t\tattribute vec3 morphNormal1;\",\"\t\tattribute vec3 morphNormal2;\",\"\t\tattribute vec3 morphNormal3;\",\"\t#else\",\"\t\tattribute vec3 morphTarget4;\",\"\t\tattribute vec3 morphTarget5;\",\"\t\tattribute vec3 morphTarget6;\",\"\t\tattribute vec3 morphTarget7;\",\"\t#endif\",\"#endif\",\"#ifdef USE_SKINNING\",\"\tattribute vec4 skinIndex;\",\"\tattribute vec4 skinWeight;\",\"#endif\",`\n`].filter(h0).join(`\n`),g=[f,h$(n),\"#define SHADER_TYPE \"+n.shaderType,\"#define SHADER_NAME \"+n.shaderName,y,n.useFog&&n.fog?\"#define USE_FOG\":\"\",n.useFog&&n.fogExp2?\"#define FOG_EXP2\":\"\",n.map?\"#define USE_MAP\":\"\",n.matcap?\"#define USE_MATCAP\":\"\",n.envMap?\"#define USE_ENVMAP\":\"\",n.envMap?\"#define \"+d:\"\",n.envMap?\"#define \"+u:\"\",n.envMap?\"#define \"+m:\"\",p?\"#define CUBEUV_TEXEL_WIDTH \"+p.texelWidth:\"\",p?\"#define CUBEUV_TEXEL_HEIGHT \"+p.texelHeight:\"\",p?\"#define CUBEUV_MAX_MIP \"+p.maxMip+\".0\":\"\",n.lightMap?\"#define USE_LIGHTMAP\":\"\",n.aoMap?\"#define USE_AOMAP\":\"\",n.bumpMap?\"#define USE_BUMPMAP\":\"\",n.normalMap?\"#define USE_NORMALMAP\":\"\",n.normalMapObjectSpace?\"#define USE_NORMALMAP_OBJECTSPACE\":\"\",n.normalMapTangentSpace?\"#define USE_NORMALMAP_TANGENTSPACE\":\"\",n.emissiveMap?\"#define USE_EMISSIVEMAP\":\"\",n.anisotropy?\"#define USE_ANISOTROPY\":\"\",n.anisotropyMap?\"#define USE_ANISOTROPYMAP\":\"\",n.clearcoat?\"#define USE_CLEARCOAT\":\"\",n.clearcoatMap?\"#define USE_CLEARCOATMAP\":\"\",n.clearcoatRoughnessMap?\"#define USE_CLEARCOAT_ROUGHNESSMAP\":\"\",n.clearcoatNormalMap?\"#define USE_CLEARCOAT_NORMALMAP\":\"\",n.iridescence?\"#define USE_IRIDESCENCE\":\"\",n.iridescenceMap?\"#define USE_IRIDESCENCEMAP\":\"\",n.iridescenceThicknessMap?\"#define USE_IRIDESCENCE_THICKNESSMAP\":\"\",n.specularMap?\"#define USE_SPECULARMAP\":\"\",n.specularColorMap?\"#define USE_SPECULAR_COLORMAP\":\"\",n.specularIntensityMap?\"#define USE_SPECULAR_INTENSITYMAP\":\"\",n.roughnessMap?\"#define USE_ROUGHNESSMAP\":\"\",n.metalnessMap?\"#define USE_METALNESSMAP\":\"\",n.alphaMap?\"#define USE_ALPHAMAP\":\"\",n.alphaTest?\"#define USE_ALPHATEST\":\"\",n.alphaHash?\"#define USE_ALPHAHASH\":\"\",n.sheen?\"#define USE_SHEEN\":\"\",n.sheenColorMap?\"#define USE_SHEEN_COLORMAP\":\"\",n.sheenRoughnessMap?\"#define USE_SHEEN_ROUGHNESSMAP\":\"\",n.transmission?\"#define USE_TRANSMISSION\":\"\",n.transmissionMap?\"#define USE_TRANSMISSIONMAP\":\"\",n.thicknessMap?\"#define USE_THICKNESSMAP\":\"\",n.vertexTangents&&n.flatShading===!1?\"#define USE_TANGENT\":\"\",n.vertexColors||n.instancingColor?\"#define USE_COLOR\":\"\",n.vertexAlphas?\"#define USE_COLOR_ALPHA\":\"\",n.vertexUv1s?\"#define USE_UV1\":\"\",n.vertexUv2s?\"#define USE_UV2\":\"\",n.vertexUv3s?\"#define USE_UV3\":\"\",n.pointsUvs?\"#define USE_POINTS_UV\":\"\",n.gradientMap?\"#define USE_GRADIENTMAP\":\"\",n.flatShading?\"#define FLAT_SHADED\":\"\",n.doubleSided?\"#define DOUBLE_SIDED\":\"\",n.flipSided?\"#define FLIP_SIDED\":\"\",n.shadowMapEnabled?\"#define USE_SHADOWMAP\":\"\",n.shadowMapEnabled?\"#define \"+c:\"\",n.premultipliedAlpha?\"#define PREMULTIPLIED_ALPHA\":\"\",n.numLightProbes>0?\"#define USE_LIGHT_PROBES\":\"\",n.useLegacyLights?\"#define LEGACY_LIGHTS\":\"\",n.decodeVideoTexture?\"#define DECODE_VIDEO_TEXTURE\":\"\",n.logarithmicDepthBuffer?\"#define USE_LOGDEPTHBUF\":\"\",n.logarithmicDepthBuffer&&n.rendererExtensionFragDepth?\"#define USE_LOGDEPTHBUF_EXT\":\"\",\"uniform mat4 viewMatrix;\",\"uniform vec3 cameraPosition;\",\"uniform bool isOrthographic;\",n.toneMapping!==lp?\"#define TONE_MAPPING\":\"\",n.toneMapping!==lp?tr.tonemapping_pars_fragment:\"\",n.toneMapping!==lp?hje(\"toneMapping\",n.toneMapping):\"\",n.dithering?\"#define DITHERING\":\"\",n.opaque?\"#define OPAQUE\":\"\",tr.colorspace_pars_fragment,mje(\"linearToOutputTexel\",n.outputColorSpace),n.useDepthPacking?\"#define DEPTH_PACKING \"+n.depthPacking:\"\",`\n`].filter(h0).join(`\n`)),o=fR(o),o=d$(o,n),o=u$(o,n),l=fR(l),l=d$(l,n),l=u$(l,n),o=m$(o),l=m$(l),n.isWebGL2&&n.isRawShaderMaterial!==!0&&(_=`#version 300 es\n`,b=[\"precision mediump sampler2DArray;\",\"#define attribute in\",\"#define varying out\",\"#define texture2D texture\"].join(`\n`)+`\n`+b,g=[\"precision mediump sampler2DArray;\",\"#define varying in\",n.glslVersion===Mq?\"\":\"layout(location = 0) out highp vec4 pc_fragColor;\",n.glslVersion===Mq?\"\":\"#define gl_FragColor pc_fragColor\",\"#define gl_FragDepthEXT gl_FragDepth\",\"#define texture2D texture\",\"#define textureCube texture\",\"#define texture2DProj textureProj\",\"#define texture2DLodEXT textureLod\",\"#define texture2DProjLodEXT textureProjLod\",\"#define textureCubeLodEXT textureLod\",\"#define texture2DGradEXT textureGrad\",\"#define texture2DProjGradEXT textureProjGrad\",\"#define textureCubeGradEXT textureGrad\"].join(`\n`)+`\n`+g);const C=_+b+o,P=_+g+l,N=l$(i,i.VERTEX_SHADER,C),A=l$(i,i.FRAGMENT_SHADER,P);i.attachShader(v,N),i.attachShader(v,A),n.index0AttributeName!==void 0?i.bindAttribLocation(v,0,n.index0AttributeName):n.morphTargets===!0&&i.bindAttribLocation(v,0,\"position\"),i.linkProgram(v);function T(H){if(t.debug.checkShaderErrors){const z=i.getProgramInfoLog(v).trim(),Q=i.getShaderInfoLog(N).trim(),L=i.getShaderInfoLog(A).trim();let te=!0,ie=!0;if(i.getProgramParameter(v,i.LINK_STATUS)===!1)if(te=!1,typeof t.debug.onShaderError==\"function\")t.debug.onShaderError(i,v,N,A);else{const J=c$(i,N,\"vertex\"),oe=c$(i,A,\"fragment\");console.error(\"THREE.WebGLProgram: Shader Error \"+i.getError()+\" - VALIDATE_STATUS \"+i.getProgramParameter(v,i.VALIDATE_STATUS)+`\n\nProgram Info Log: `+z+`\n`+J+`\n`+oe)}else z!==\"\"?console.warn(\"THREE.WebGLProgram: Program Info Log:\",z):(Q===\"\"||L===\"\")&&(ie=!1);ie&&(H.diagnostics={runnable:te,programLog:z,vertexShader:{log:Q,prefix:b},fragmentShader:{log:L,prefix:g}})}i.deleteShader(N),i.deleteShader(A),F=new Kk(i,v),k=gje(i,v)}let F;this.getUniforms=function(){return F===void 0&&T(this),F};let k;this.getAttributes=function(){return k===void 0&&T(this),k};let D=n.rendererExtensionParallelShaderCompile===!1;return this.isReady=function(){return D===!1&&(D=i.getProgramParameter(v,lje)),D},this.destroy=function(){r.releaseStatesOfProgram(this),i.deleteProgram(v),this.program=void 0},this.type=n.shaderType,this.name=n.shaderName,this.id=cje++,this.cacheKey=e,this.usedTimes=1,this.program=v,this.vertexShader=N,this.fragmentShader=A,this}let Tje=0;class Aje{constructor(){this.shaderCache=new Map,this.materialCache=new Map}update(e){const n=e.vertexShader,r=e.fragmentShader,i=this._getShaderStage(n),s=this._getShaderStage(r),o=this._getShaderCacheForMaterial(e);return o.has(i)===!1&&(o.add(i),i.usedTimes++),o.has(s)===!1&&(o.add(s),s.usedTimes++),this}remove(e){const n=this.materialCache.get(e);for(const r of n)r.usedTimes--,r.usedTimes===0&&this.shaderCache.delete(r.code);return this.materialCache.delete(e),this}getVertexShaderID(e){return this._getShaderStage(e.vertexShader).id}getFragmentShaderID(e){return this._getShaderStage(e.fragmentShader).id}dispose(){this.shaderCache.clear(),this.materialCache.clear()}_getShaderCacheForMaterial(e){const n=this.materialCache;let r=n.get(e);return r===void 0&&(r=new Set,n.set(e,r)),r}_getShaderStage(e){const n=this.shaderCache;let r=n.get(e);return r===void 0&&(r=new jje(e),n.set(e,r)),r}}class jje{constructor(e){this.id=Tje++,this.code=e,this.usedTimes=0}}function Mje(t,e,n,r,i,s,o){const l=new mJ,c=new Aje,d=[],u=i.isWebGL2,m=i.logarithmicDepthBuffer,p=i.vertexTextures;let f=i.precision;const y={MeshDepthMaterial:\"depth\",MeshDistanceMaterial:\"distanceRGBA\",MeshNormalMaterial:\"normal\",MeshBasicMaterial:\"basic\",MeshLambertMaterial:\"lambert\",MeshPhongMaterial:\"phong\",MeshToonMaterial:\"toon\",MeshStandardMaterial:\"physical\",MeshPhysicalMaterial:\"physical\",MeshMatcapMaterial:\"matcap\",LineBasicMaterial:\"basic\",LineDashedMaterial:\"dashed\",PointsMaterial:\"points\",ShadowMaterial:\"shadow\",SpriteMaterial:\"sprite\"};function v(k){return k===0?\"uv\":`uv${k}`}function b(k,D,H,z,Q){const L=z.fog,te=Q.geometry,ie=k.isMeshStandardMaterial?z.environment:null,J=(k.isMeshStandardMaterial?n:e).get(k.envMap||ie),oe=J&&J.mapping===eT?J.image.height:null,fe=y[k.type];k.precision!==null&&(f=i.getMaxPrecision(k.precision),f!==k.precision&&console.warn(\"THREE.WebGLProgram.getParameters:\",k.precision,\"not supported, using\",f,\"instead.\"));const re=te.morphAttributes.position||te.morphAttributes.normal||te.morphAttributes.color,W=re!==void 0?re.length:0;let ne=0;te.morphAttributes.position!==void 0&&(ne=1),te.morphAttributes.normal!==void 0&&(ne=2),te.morphAttributes.color!==void 0&&(ne=3);let me,be,Ce,q;if(fe){const xt=Fo[fe];me=xt.vertexShader,be=xt.fragmentShader}else me=k.vertexShader,be=k.fragmentShader,c.update(k),Ce=c.getVertexShaderID(k),q=c.getFragmentShaderID(k);const Y=t.getRenderTarget(),E=Q.isInstancedMesh===!0,j=Q.isBatchedMesh===!0,O=!!k.map,K=!!k.matcap,U=!!J,de=!!k.aoMap,I=!!k.lightMap,G=!!k.bumpMap,X=!!k.normalMap,V=!!k.displacementMap,ee=!!k.emissiveMap,se=!!k.metalnessMap,ge=!!k.roughnessMap,he=k.anisotropy>0,le=k.clearcoat>0,B=k.iridescence>0,R=k.sheen>0,ae=k.transmission>0,_e=he&&!!k.anisotropyMap,Se=le&&!!k.clearcoatMap,ve=le&&!!k.clearcoatNormalMap,Te=le&&!!k.clearcoatRoughnessMap,ye=B&&!!k.iridescenceMap,je=B&&!!k.iridescenceThicknessMap,Le=R&&!!k.sheenColorMap,Me=R&&!!k.sheenRoughnessMap,Oe=!!k.specularMap,Re=!!k.specularColorMap,$e=!!k.specularIntensityMap,Ye=ae&&!!k.transmissionMap,tt=ae&&!!k.thicknessMap,pe=!!k.gradientMap,Fe=!!k.alphaMap,we=k.alphaTest>0,Ve=!!k.alphaHash,Ae=!!k.extensions,ce=!!te.attributes.uv1,xe=!!te.attributes.uv2,Be=!!te.attributes.uv3;let Qe=lp;return k.toneMapped&&(Y===null||Y.isXRRenderTarget===!0)&&(Qe=t.toneMapping),{isWebGL2:u,shaderID:fe,shaderType:k.type,shaderName:k.name,vertexShader:me,fragmentShader:be,defines:k.defines,customVertexShaderID:Ce,customFragmentShaderID:q,isRawShaderMaterial:k.isRawShaderMaterial===!0,glslVersion:k.glslVersion,precision:f,batching:j,instancing:E,instancingColor:E&&Q.instanceColor!==null,supportsVertexTextures:p,outputColorSpace:Y===null?t.outputColorSpace:Y.isXRRenderTarget===!0?Y.texture.colorSpace:Sm,map:O,matcap:K,envMap:U,envMapMode:U&&J.mapping,envMapCubeUVHeight:oe,aoMap:de,lightMap:I,bumpMap:G,normalMap:X,displacementMap:p&&V,emissiveMap:ee,normalMapObjectSpace:X&&k.normalMapType===TNe,normalMapTangentSpace:X&&k.normalMapType===iJ,metalnessMap:se,roughnessMap:ge,anisotropy:he,anisotropyMap:_e,clearcoat:le,clearcoatMap:Se,clearcoatNormalMap:ve,clearcoatRoughnessMap:Te,iridescence:B,iridescenceMap:ye,iridescenceThicknessMap:je,sheen:R,sheenColorMap:Le,sheenRoughnessMap:Me,specularMap:Oe,specularColorMap:Re,specularIntensityMap:$e,transmission:ae,transmissionMap:Ye,thicknessMap:tt,gradientMap:pe,opaque:k.transparent===!1&&k.blending===Mx,alphaMap:Fe,alphaTest:we,alphaHash:Ve,combine:k.combine,mapUv:O&&v(k.map.channel),aoMapUv:de&&v(k.aoMap.channel),lightMapUv:I&&v(k.lightMap.channel),bumpMapUv:G&&v(k.bumpMap.channel),normalMapUv:X&&v(k.normalMap.channel),displacementMapUv:V&&v(k.displacementMap.channel),emissiveMapUv:ee&&v(k.emissiveMap.channel),metalnessMapUv:se&&v(k.metalnessMap.channel),roughnessMapUv:ge&&v(k.roughnessMap.channel),anisotropyMapUv:_e&&v(k.anisotropyMap.channel),clearcoatMapUv:Se&&v(k.clearcoatMap.channel),clearcoatNormalMapUv:ve&&v(k.clearcoatNormalMap.channel),clearcoatRoughnessMapUv:Te&&v(k.clearcoatRoughnessMap.channel),iridescenceMapUv:ye&&v(k.iridescenceMap.channel),iridescenceThicknessMapUv:je&&v(k.iridescenceThicknessMap.channel),sheenColorMapUv:Le&&v(k.sheenColorMap.channel),sheenRoughnessMapUv:Me&&v(k.sheenRoughnessMap.channel),specularMapUv:Oe&&v(k.specularMap.channel),specularColorMapUv:Re&&v(k.specularColorMap.channel),specularIntensityMapUv:$e&&v(k.specularIntensityMap.channel),transmissionMapUv:Ye&&v(k.transmissionMap.channel),thicknessMapUv:tt&&v(k.thicknessMap.channel),alphaMapUv:Fe&&v(k.alphaMap.channel),vertexTangents:!!te.attributes.tangent&&(X||he),vertexColors:k.vertexColors,vertexAlphas:k.vertexColors===!0&&!!te.attributes.color&&te.attributes.color.itemSize===4,vertexUv1s:ce,vertexUv2s:xe,vertexUv3s:Be,pointsUvs:Q.isPoints===!0&&!!te.attributes.uv&&(O||Fe),fog:!!L,useFog:k.fog===!0,fogExp2:L&&L.isFogExp2,flatShading:k.flatShading===!0,sizeAttenuation:k.sizeAttenuation===!0,logarithmicDepthBuffer:m,skinning:Q.isSkinnedMesh===!0,morphTargets:te.morphAttributes.position!==void 0,morphNormals:te.morphAttributes.normal!==void 0,morphColors:te.morphAttributes.color!==void 0,morphTargetsCount:W,morphTextureStride:ne,numDirLights:D.directional.length,numPointLights:D.point.length,numSpotLights:D.spot.length,numSpotLightMaps:D.spotLightMap.length,numRectAreaLights:D.rectArea.length,numHemiLights:D.hemi.length,numDirLightShadows:D.directionalShadowMap.length,numPointLightShadows:D.pointShadowMap.length,numSpotLightShadows:D.spotShadowMap.length,numSpotLightShadowsWithMaps:D.numSpotLightShadowsWithMaps,numLightProbes:D.numLightProbes,numClippingPlanes:o.numPlanes,numClipIntersection:o.numIntersection,dithering:k.dithering,shadowMapEnabled:t.shadowMap.enabled&&H.length>0,shadowMapType:t.shadowMap.type,toneMapping:Qe,useLegacyLights:t._useLegacyLights,decodeVideoTexture:O&&k.map.isVideoTexture===!0&&Jr.getTransfer(k.map.colorSpace)===Sa,premultipliedAlpha:k.premultipliedAlpha,doubleSided:k.side===Ku,flipSided:k.side===Uo,useDepthPacking:k.depthPacking>=0,depthPacking:k.depthPacking||0,index0AttributeName:k.index0AttributeName,extensionDerivatives:Ae&&k.extensions.derivatives===!0,extensionFragDepth:Ae&&k.extensions.fragDepth===!0,extensionDrawBuffers:Ae&&k.extensions.drawBuffers===!0,extensionShaderTextureLOD:Ae&&k.extensions.shaderTextureLOD===!0,rendererExtensionFragDepth:u||r.has(\"EXT_frag_depth\"),rendererExtensionDrawBuffers:u||r.has(\"WEBGL_draw_buffers\"),rendererExtensionShaderTextureLod:u||r.has(\"EXT_shader_texture_lod\"),rendererExtensionParallelShaderCompile:r.has(\"KHR_parallel_shader_compile\"),customProgramCacheKey:k.customProgramCacheKey()}}function g(k){const D=[];if(k.shaderID?D.push(k.shaderID):(D.push(k.customVertexShaderID),D.push(k.customFragmentShaderID)),k.defines!==void 0)for(const H in k.defines)D.push(H),D.push(k.defines[H]);return k.isRawShaderMaterial===!1&&(_(D,k),C(D,k),D.push(t.outputColorSpace)),D.push(k.customProgramCacheKey),D.join()}function _(k,D){k.push(D.precision),k.push(D.outputColorSpace),k.push(D.envMapMode),k.push(D.envMapCubeUVHeight),k.push(D.mapUv),k.push(D.alphaMapUv),k.push(D.lightMapUv),k.push(D.aoMapUv),k.push(D.bumpMapUv),k.push(D.normalMapUv),k.push(D.displacementMapUv),k.push(D.emissiveMapUv),k.push(D.metalnessMapUv),k.push(D.roughnessMapUv),k.push(D.anisotropyMapUv),k.push(D.clearcoatMapUv),k.push(D.clearcoatNormalMapUv),k.push(D.clearcoatRoughnessMapUv),k.push(D.iridescenceMapUv),k.push(D.iridescenceThicknessMapUv),k.push(D.sheenColorMapUv),k.push(D.sheenRoughnessMapUv),k.push(D.specularMapUv),k.push(D.specularColorMapUv),k.push(D.specularIntensityMapUv),k.push(D.transmissionMapUv),k.push(D.thicknessMapUv),k.push(D.combine),k.push(D.fogExp2),k.push(D.sizeAttenuation),k.push(D.morphTargetsCount),k.push(D.morphAttributeCount),k.push(D.numDirLights),k.push(D.numPointLights),k.push(D.numSpotLights),k.push(D.numSpotLightMaps),k.push(D.numHemiLights),k.push(D.numRectAreaLights),k.push(D.numDirLightShadows),k.push(D.numPointLightShadows),k.push(D.numSpotLightShadows),k.push(D.numSpotLightShadowsWithMaps),k.push(D.numLightProbes),k.push(D.shadowMapType),k.push(D.toneMapping),k.push(D.numClippingPlanes),k.push(D.numClipIntersection),k.push(D.depthPacking)}function C(k,D){l.disableAll(),D.isWebGL2&&l.enable(0),D.supportsVertexTextures&&l.enable(1),D.instancing&&l.enable(2),D.instancingColor&&l.enable(3),D.matcap&&l.enable(4),D.envMap&&l.enable(5),D.normalMapObjectSpace&&l.enable(6),D.normalMapTangentSpace&&l.enable(7),D.clearcoat&&l.enable(8),D.iridescence&&l.enable(9),D.alphaTest&&l.enable(10),D.vertexColors&&l.enable(11),D.vertexAlphas&&l.enable(12),D.vertexUv1s&&l.enable(13),D.vertexUv2s&&l.enable(14),D.vertexUv3s&&l.enable(15),D.vertexTangents&&l.enable(16),D.anisotropy&&l.enable(17),D.alphaHash&&l.enable(18),D.batching&&l.enable(19),k.push(l.mask),l.disableAll(),D.fog&&l.enable(0),D.useFog&&l.enable(1),D.flatShading&&l.enable(2),D.logarithmicDepthBuffer&&l.enable(3),D.skinning&&l.enable(4),D.morphTargets&&l.enable(5),D.morphNormals&&l.enable(6),D.morphColors&&l.enable(7),D.premultipliedAlpha&&l.enable(8),D.shadowMapEnabled&&l.enable(9),D.useLegacyLights&&l.enable(10),D.doubleSided&&l.enable(11),D.flipSided&&l.enable(12),D.useDepthPacking&&l.enable(13),D.dithering&&l.enable(14),D.transmission&&l.enable(15),D.sheen&&l.enable(16),D.opaque&&l.enable(17),D.pointsUvs&&l.enable(18),D.decodeVideoTexture&&l.enable(19),k.push(l.mask)}function P(k){const D=y[k.type];let H;if(D){const z=Fo[D];H=mI.clone(z.uniforms)}else H=k.uniforms;return H}function N(k,D){let H;for(let z=0,Q=d.length;z<Q;z++){const L=d[z];if(L.cacheKey===D){H=L,++H.usedTimes;break}}return H===void 0&&(H=new Pje(t,D,k,s),d.push(H)),H}function A(k){if(--k.usedTimes===0){const D=d.indexOf(k);d[D]=d[d.length-1],d.pop(),k.destroy()}}function T(k){c.remove(k)}function F(){c.dispose()}return{getParameters:b,getProgramCacheKey:g,getUniforms:P,acquireProgram:N,releaseProgram:A,releaseShaderCache:T,programs:d,dispose:F}}function Eje(){let t=new WeakMap;function e(s){let o=t.get(s);return o===void 0&&(o={},t.set(s,o)),o}function n(s){t.delete(s)}function r(s,o,l){t.get(s)[o]=l}function i(){t=new WeakMap}return{get:e,remove:n,update:r,dispose:i}}function Dje(t,e){return t.groupOrder!==e.groupOrder?t.groupOrder-e.groupOrder:t.renderOrder!==e.renderOrder?t.renderOrder-e.renderOrder:t.material.id!==e.material.id?t.material.id-e.material.id:t.z!==e.z?t.z-e.z:t.id-e.id}function p$(t,e){return t.groupOrder!==e.groupOrder?t.groupOrder-e.groupOrder:t.renderOrder!==e.renderOrder?t.renderOrder-e.renderOrder:t.z!==e.z?e.z-t.z:t.id-e.id}function f$(){const t=[];let e=0;const n=[],r=[],i=[];function s(){e=0,n.length=0,r.length=0,i.length=0}function o(m,p,f,y,v,b){let g=t[e];return g===void 0?(g={id:m.id,object:m,geometry:p,material:f,groupOrder:y,renderOrder:m.renderOrder,z:v,group:b},t[e]=g):(g.id=m.id,g.object=m,g.geometry=p,g.material=f,g.groupOrder=y,g.renderOrder=m.renderOrder,g.z=v,g.group=b),e++,g}function l(m,p,f,y,v,b){const g=o(m,p,f,y,v,b);f.transmission>0?r.push(g):f.transparent===!0?i.push(g):n.push(g)}function c(m,p,f,y,v,b){const g=o(m,p,f,y,v,b);f.transmission>0?r.unshift(g):f.transparent===!0?i.unshift(g):n.unshift(g)}function d(m,p){n.length>1&&n.sort(m||Dje),r.length>1&&r.sort(p||p$),i.length>1&&i.sort(p||p$)}function u(){for(let m=e,p=t.length;m<p;m++){const f=t[m];if(f.id===null)break;f.id=null,f.object=null,f.geometry=null,f.material=null,f.group=null}}return{opaque:n,transmissive:r,transparent:i,init:s,push:l,unshift:c,finish:u,sort:d}}function Fje(){let t=new WeakMap;function e(r,i){const s=t.get(r);let o;return s===void 0?(o=new f$,t.set(r,[o])):i>=s.length?(o=new f$,s.push(o)):o=s[i],o}function n(){t=new WeakMap}return{get:e,dispose:n}}function Rje(){const t={};return{get:function(e){if(t[e.id]!==void 0)return t[e.id];let n;switch(e.type){case\"DirectionalLight\":n={direction:new ut,color:new Mn};break;case\"SpotLight\":n={position:new ut,direction:new ut,color:new Mn,distance:0,coneCos:0,penumbraCos:0,decay:0};break;case\"PointLight\":n={position:new ut,color:new Mn,distance:0,decay:0};break;case\"HemisphereLight\":n={direction:new ut,skyColor:new Mn,groundColor:new Mn};break;case\"RectAreaLight\":n={color:new Mn,position:new ut,halfWidth:new ut,halfHeight:new ut};break}return t[e.id]=n,n}}}function Lje(){const t={};return{get:function(e){if(t[e.id]!==void 0)return t[e.id];let n;switch(e.type){case\"DirectionalLight\":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new Vn};break;case\"SpotLight\":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new Vn};break;case\"PointLight\":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new Vn,shadowCameraNear:1,shadowCameraFar:1e3};break}return t[e.id]=n,n}}}let Oje=0;function Ije(t,e){return(e.castShadow?2:0)-(t.castShadow?2:0)+(e.map?1:0)-(t.map?1:0)}function zje(t,e){const n=new Rje,r=Lje(),i={version:0,hash:{directionalLength:-1,pointLength:-1,spotLength:-1,rectAreaLength:-1,hemiLength:-1,numDirectionalShadows:-1,numPointShadows:-1,numSpotShadows:-1,numSpotMaps:-1,numLightProbes:-1},ambient:[0,0,0],probe:[],directional:[],directionalShadow:[],directionalShadowMap:[],directionalShadowMatrix:[],spot:[],spotLightMap:[],spotShadow:[],spotShadowMap:[],spotLightMatrix:[],rectArea:[],rectAreaLTC1:null,rectAreaLTC2:null,point:[],pointShadow:[],pointShadowMap:[],pointShadowMatrix:[],hemi:[],numSpotLightShadowsWithMaps:0,numLightProbes:0};for(let u=0;u<9;u++)i.probe.push(new ut);const s=new ut,o=new ba,l=new ba;function c(u,m){let p=0,f=0,y=0;for(let z=0;z<9;z++)i.probe[z].set(0,0,0);let v=0,b=0,g=0,_=0,C=0,P=0,N=0,A=0,T=0,F=0,k=0;u.sort(Ije);const D=m===!0?Math.PI:1;for(let z=0,Q=u.length;z<Q;z++){const L=u[z],te=L.color,ie=L.intensity,J=L.distance,oe=L.shadow&&L.shadow.map?L.shadow.map.texture:null;if(L.isAmbientLight)p+=te.r*ie*D,f+=te.g*ie*D,y+=te.b*ie*D;else if(L.isLightProbe){for(let fe=0;fe<9;fe++)i.probe[fe].addScaledVector(L.sh.coefficients[fe],ie);k++}else if(L.isDirectionalLight){const fe=n.get(L);if(fe.color.copy(L.color).multiplyScalar(L.intensity*D),L.castShadow){const re=L.shadow,W=r.get(L);W.shadowBias=re.bias,W.shadowNormalBias=re.normalBias,W.shadowRadius=re.radius,W.shadowMapSize=re.mapSize,i.directionalShadow[v]=W,i.directionalShadowMap[v]=oe,i.directionalShadowMatrix[v]=L.shadow.matrix,P++}i.directional[v]=fe,v++}else if(L.isSpotLight){const fe=n.get(L);fe.position.setFromMatrixPosition(L.matrixWorld),fe.color.copy(te).multiplyScalar(ie*D),fe.distance=J,fe.coneCos=Math.cos(L.angle),fe.penumbraCos=Math.cos(L.angle*(1-L.penumbra)),fe.decay=L.decay,i.spot[g]=fe;const re=L.shadow;if(L.map&&(i.spotLightMap[T]=L.map,T++,re.updateMatrices(L),L.castShadow&&F++),i.spotLightMatrix[g]=re.matrix,L.castShadow){const W=r.get(L);W.shadowBias=re.bias,W.shadowNormalBias=re.normalBias,W.shadowRadius=re.radius,W.shadowMapSize=re.mapSize,i.spotShadow[g]=W,i.spotShadowMap[g]=oe,A++}g++}else if(L.isRectAreaLight){const fe=n.get(L);fe.color.copy(te).multiplyScalar(ie),fe.halfWidth.set(L.width*.5,0,0),fe.halfHeight.set(0,L.height*.5,0),i.rectArea[_]=fe,_++}else if(L.isPointLight){const fe=n.get(L);if(fe.color.copy(L.color).multiplyScalar(L.intensity*D),fe.distance=L.distance,fe.decay=L.decay,L.castShadow){const re=L.shadow,W=r.get(L);W.shadowBias=re.bias,W.shadowNormalBias=re.normalBias,W.shadowRadius=re.radius,W.shadowMapSize=re.mapSize,W.shadowCameraNear=re.camera.near,W.shadowCameraFar=re.camera.far,i.pointShadow[b]=W,i.pointShadowMap[b]=oe,i.pointShadowMatrix[b]=L.shadow.matrix,N++}i.point[b]=fe,b++}else if(L.isHemisphereLight){const fe=n.get(L);fe.skyColor.copy(L.color).multiplyScalar(ie*D),fe.groundColor.copy(L.groundColor).multiplyScalar(ie*D),i.hemi[C]=fe,C++}}_>0&&(e.isWebGL2||t.has(\"OES_texture_float_linear\")===!0?(i.rectAreaLTC1=Yt.LTC_FLOAT_1,i.rectAreaLTC2=Yt.LTC_FLOAT_2):t.has(\"OES_texture_half_float_linear\")===!0?(i.rectAreaLTC1=Yt.LTC_HALF_1,i.rectAreaLTC2=Yt.LTC_HALF_2):console.error(\"THREE.WebGLRenderer: Unable to use RectAreaLight. Missing WebGL extensions.\")),i.ambient[0]=p,i.ambient[1]=f,i.ambient[2]=y;const H=i.hash;(H.directionalLength!==v||H.pointLength!==b||H.spotLength!==g||H.rectAreaLength!==_||H.hemiLength!==C||H.numDirectionalShadows!==P||H.numPointShadows!==N||H.numSpotShadows!==A||H.numSpotMaps!==T||H.numLightProbes!==k)&&(i.directional.length=v,i.spot.length=g,i.rectArea.length=_,i.point.length=b,i.hemi.length=C,i.directionalShadow.length=P,i.directionalShadowMap.length=P,i.pointShadow.length=N,i.pointShadowMap.length=N,i.spotShadow.length=A,i.spotShadowMap.length=A,i.directionalShadowMatrix.length=P,i.pointShadowMatrix.length=N,i.spotLightMatrix.length=A+T-F,i.spotLightMap.length=T,i.numSpotLightShadowsWithMaps=F,i.numLightProbes=k,H.directionalLength=v,H.pointLength=b,H.spotLength=g,H.rectAreaLength=_,H.hemiLength=C,H.numDirectionalShadows=P,H.numPointShadows=N,H.numSpotShadows=A,H.numSpotMaps=T,H.numLightProbes=k,i.version=Oje++)}function d(u,m){let p=0,f=0,y=0,v=0,b=0;const g=m.matrixWorldInverse;for(let _=0,C=u.length;_<C;_++){const P=u[_];if(P.isDirectionalLight){const N=i.directional[p];N.direction.setFromMatrixPosition(P.matrixWorld),s.setFromMatrixPosition(P.target.matrixWorld),N.direction.sub(s),N.direction.transformDirection(g),p++}else if(P.isSpotLight){const N=i.spot[y];N.position.setFromMatrixPosition(P.matrixWorld),N.position.applyMatrix4(g),N.direction.setFromMatrixPosition(P.matrixWorld),s.setFromMatrixPosition(P.target.matrixWorld),N.direction.sub(s),N.direction.transformDirection(g),y++}else if(P.isRectAreaLight){const N=i.rectArea[v];N.position.setFromMatrixPosition(P.matrixWorld),N.position.applyMatrix4(g),l.identity(),o.copy(P.matrixWorld),o.premultiply(g),l.extractRotation(o),N.halfWidth.set(P.width*.5,0,0),N.halfHeight.set(0,P.height*.5,0),N.halfWidth.applyMatrix4(l),N.halfHeight.applyMatrix4(l),v++}else if(P.isPointLight){const N=i.point[f];N.position.setFromMatrixPosition(P.matrixWorld),N.position.applyMatrix4(g),f++}else if(P.isHemisphereLight){const N=i.hemi[b];N.direction.setFromMatrixPosition(P.matrixWorld),N.direction.transformDirection(g),b++}}}return{setup:c,setupView:d,state:i}}function g$(t,e){const n=new zje(t,e),r=[],i=[];function s(){r.length=0,i.length=0}function o(m){r.push(m)}function l(m){i.push(m)}function c(m){n.setup(r,m)}function d(m){n.setupView(r,m)}return{init:s,state:{lightsArray:r,shadowsArray:i,lights:n},setupLights:c,setupLightsView:d,pushLight:o,pushShadow:l}}function Uje(t,e){let n=new WeakMap;function r(s,o=0){const l=n.get(s);let c;return l===void 0?(c=new g$(t,e),n.set(s,[c])):o>=l.length?(c=new g$(t,e),l.push(c)):c=l[o],c}function i(){n=new WeakMap}return{get:r,dispose:i}}class Bje extends Cy{constructor(e){super(),this.isMeshDepthMaterial=!0,this.type=\"MeshDepthMaterial\",this.depthPacking=CNe,this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.wireframe=!1,this.wireframeLinewidth=1,this.setValues(e)}copy(e){return super.copy(e),this.depthPacking=e.depthPacking,this.map=e.map,this.alphaMap=e.alphaMap,this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this}}class Hje extends Cy{constructor(e){super(),this.isMeshDistanceMaterial=!0,this.type=\"MeshDistanceMaterial\",this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.setValues(e)}copy(e){return super.copy(e),this.map=e.map,this.alphaMap=e.alphaMap,this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this}}const qje=`void main() {\n\tgl_Position = vec4( position, 1.0 );\n}`,$je=`uniform sampler2D shadow_pass;\nuniform vec2 resolution;\nuniform float radius;\n#include <packing>\nvoid main() {\n\tconst float samples = float( VSM_SAMPLES );\n\tfloat mean = 0.0;\n\tfloat squared_mean = 0.0;\n\tfloat uvStride = samples <= 1.0 ? 0.0 : 2.0 / ( samples - 1.0 );\n\tfloat uvStart = samples <= 1.0 ? 0.0 : - 1.0;\n\tfor ( float i = 0.0; i < samples; i ++ ) {\n\t\tfloat uvOffset = uvStart + i * uvStride;\n\t\t#ifdef HORIZONTAL_PASS\n\t\t\tvec2 distribution = unpackRGBATo2Half( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( uvOffset, 0.0 ) * radius ) / resolution ) );\n\t\t\tmean += distribution.x;\n\t\t\tsquared_mean += distribution.y * distribution.y + distribution.x * distribution.x;\n\t\t#else\n\t\t\tfloat depth = unpackRGBAToDepth( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( 0.0, uvOffset ) * radius ) / resolution ) );\n\t\t\tmean += depth;\n\t\t\tsquared_mean += depth * depth;\n\t\t#endif\n\t}\n\tmean = mean / samples;\n\tsquared_mean = squared_mean / samples;\n\tfloat std_dev = sqrt( squared_mean - mean * mean );\n\tgl_FragColor = pack2HalfToRGBA( vec2( mean, std_dev ) );\n}`;function Vje(t,e,n){let r=new nT;const i=new Vn,s=new Vn,o=new ta,l=new Bje({depthPacking:PNe}),c=new Hje,d={},u=n.maxTextureSize,m={[yp]:Uo,[Uo]:yp,[Ku]:Ku},p=new vp({defines:{VSM_SAMPLES:8},uniforms:{shadow_pass:{value:null},resolution:{value:new Vn},radius:{value:4}},vertexShader:qje,fragmentShader:$je}),f=p.clone();f.defines.HORIZONTAL_PASS=1;const y=new Vs;y.setAttribute(\"position\",new fl(new Float32Array([-1,-1,.5,3,-1,.5,-1,3,.5]),3));const v=new Wc(y,p),b=this;this.enabled=!1,this.autoUpdate=!0,this.needsUpdate=!1,this.type=XZ;let g=this.type;this.render=function(N,A,T){if(b.enabled===!1||b.autoUpdate===!1&&b.needsUpdate===!1||N.length===0)return;const F=t.getRenderTarget(),k=t.getActiveCubeFace(),D=t.getActiveMipmapLevel(),H=t.state;H.setBlending(op),H.buffers.color.setClear(1,1,1,1),H.buffers.depth.setTest(!0),H.setScissorTest(!1);const z=g!==Uu&&this.type===Uu,Q=g===Uu&&this.type!==Uu;for(let L=0,te=N.length;L<te;L++){const ie=N[L],J=ie.shadow;if(J===void 0){console.warn(\"THREE.WebGLShadowMap:\",ie,\"has no shadow.\");continue}if(J.autoUpdate===!1&&J.needsUpdate===!1)continue;i.copy(J.mapSize);const oe=J.getFrameExtents();if(i.multiply(oe),s.copy(J.mapSize),(i.x>u||i.y>u)&&(i.x>u&&(s.x=Math.floor(u/oe.x),i.x=s.x*oe.x,J.mapSize.x=s.x),i.y>u&&(s.y=Math.floor(u/oe.y),i.y=s.y*oe.y,J.mapSize.y=s.y)),J.map===null||z===!0||Q===!0){const re=this.type!==Uu?{minFilter:gs,magFilter:gs}:{};J.map!==null&&J.map.dispose(),J.map=new xg(i.x,i.y,re),J.map.texture.name=ie.name+\".shadowMap\",J.camera.updateProjectionMatrix()}t.setRenderTarget(J.map),t.clear();const fe=J.getViewportCount();for(let re=0;re<fe;re++){const W=J.getViewport(re);o.set(s.x*W.x,s.y*W.y,s.x*W.z,s.y*W.w),H.viewport(o),J.updateMatrices(ie,re),r=J.getFrustum(),P(A,T,J.camera,ie,this.type)}J.isPointLightShadow!==!0&&this.type===Uu&&_(J,T),J.needsUpdate=!1}g=this.type,b.needsUpdate=!1,t.setRenderTarget(F,k,D)};function _(N,A){const T=e.update(v);p.defines.VSM_SAMPLES!==N.blurSamples&&(p.defines.VSM_SAMPLES=N.blurSamples,f.defines.VSM_SAMPLES=N.blurSamples,p.needsUpdate=!0,f.needsUpdate=!0),N.mapPass===null&&(N.mapPass=new xg(i.x,i.y)),p.uniforms.shadow_pass.value=N.map.texture,p.uniforms.resolution.value=N.mapSize,p.uniforms.radius.value=N.radius,t.setRenderTarget(N.mapPass),t.clear(),t.renderBufferDirect(A,null,T,p,v,null),f.uniforms.shadow_pass.value=N.mapPass.texture,f.uniforms.resolution.value=N.mapSize,f.uniforms.radius.value=N.radius,t.setRenderTarget(N.map),t.clear(),t.renderBufferDirect(A,null,T,f,v,null)}function C(N,A,T,F){let k=null;const D=T.isPointLight===!0?N.customDistanceMaterial:N.customDepthMaterial;if(D!==void 0)k=D;else if(k=T.isPointLight===!0?c:l,t.localClippingEnabled&&A.clipShadows===!0&&Array.isArray(A.clippingPlanes)&&A.clippingPlanes.length!==0||A.displacementMap&&A.displacementScale!==0||A.alphaMap&&A.alphaTest>0||A.map&&A.alphaTest>0){const H=k.uuid,z=A.uuid;let Q=d[H];Q===void 0&&(Q={},d[H]=Q);let L=Q[z];L===void 0&&(L=k.clone(),Q[z]=L),k=L}if(k.visible=A.visible,k.wireframe=A.wireframe,F===Uu?k.side=A.shadowSide!==null?A.shadowSide:A.side:k.side=A.shadowSide!==null?A.shadowSide:m[A.side],k.alphaMap=A.alphaMap,k.alphaTest=A.alphaTest,k.map=A.map,k.clipShadows=A.clipShadows,k.clippingPlanes=A.clippingPlanes,k.clipIntersection=A.clipIntersection,k.displacementMap=A.displacementMap,k.displacementScale=A.displacementScale,k.displacementBias=A.displacementBias,k.wireframeLinewidth=A.wireframeLinewidth,k.linewidth=A.linewidth,T.isPointLight===!0&&k.isMeshDistanceMaterial===!0){const H=t.properties.get(k);H.light=T}return k}function P(N,A,T,F,k){if(N.visible===!1)return;if(N.layers.test(A.layers)&&(N.isMesh||N.isLine||N.isPoints)&&(N.castShadow||N.receiveShadow&&k===Uu)&&(!N.frustumCulled||r.intersectsObject(N))){N.modelViewMatrix.multiplyMatrices(T.matrixWorldInverse,N.matrixWorld);const z=e.update(N),Q=N.material;if(Array.isArray(Q)){const L=z.groups;for(let te=0,ie=L.length;te<ie;te++){const J=L[te],oe=Q[J.materialIndex];if(oe&&oe.visible){const fe=C(N,oe,F,k);N.onBeforeShadow(t,N,A,T,z,fe,J),t.renderBufferDirect(T,null,z,fe,N,J),N.onAfterShadow(t,N,A,T,z,fe,J)}}}else if(Q.visible){const L=C(N,Q,F,k);N.onBeforeShadow(t,N,A,T,z,L,null),t.renderBufferDirect(T,null,z,L,N,null),N.onAfterShadow(t,N,A,T,z,L,null)}}const H=N.children;for(let z=0,Q=H.length;z<Q;z++)P(H[z],A,T,F,k)}}function Gje(t,e,n){const r=n.isWebGL2;function i(){let we=!1;const Ve=new ta;let Ae=null;const ce=new ta(0,0,0,0);return{setMask:function(xe){Ae!==xe&&!we&&(t.colorMask(xe,xe,xe,xe),Ae=xe)},setLocked:function(xe){we=xe},setClear:function(xe,Be,Qe,ht,xt){xt===!0&&(xe*=ht,Be*=ht,Qe*=ht),Ve.set(xe,Be,Qe,ht),ce.equals(Ve)===!1&&(t.clearColor(xe,Be,Qe,ht),ce.copy(Ve))},reset:function(){we=!1,Ae=null,ce.set(-1,0,0,0)}}}function s(){let we=!1,Ve=null,Ae=null,ce=null;return{setTest:function(xe){xe?j(t.DEPTH_TEST):O(t.DEPTH_TEST)},setMask:function(xe){Ve!==xe&&!we&&(t.depthMask(xe),Ve=xe)},setFunc:function(xe){if(Ae!==xe){switch(xe){case rNe:t.depthFunc(t.NEVER);break;case aNe:t.depthFunc(t.ALWAYS);break;case iNe:t.depthFunc(t.LESS);break;case DN:t.depthFunc(t.LEQUAL);break;case sNe:t.depthFunc(t.EQUAL);break;case oNe:t.depthFunc(t.GEQUAL);break;case lNe:t.depthFunc(t.GREATER);break;case cNe:t.depthFunc(t.NOTEQUAL);break;default:t.depthFunc(t.LEQUAL)}Ae=xe}},setLocked:function(xe){we=xe},setClear:function(xe){ce!==xe&&(t.clearDepth(xe),ce=xe)},reset:function(){we=!1,Ve=null,Ae=null,ce=null}}}function o(){let we=!1,Ve=null,Ae=null,ce=null,xe=null,Be=null,Qe=null,ht=null,xt=null;return{setTest:function(gt){we||(gt?j(t.STENCIL_TEST):O(t.STENCIL_TEST))},setMask:function(gt){Ve!==gt&&!we&&(t.stencilMask(gt),Ve=gt)},setFunc:function(gt,Ut,Wt){(Ae!==gt||ce!==Ut||xe!==Wt)&&(t.stencilFunc(gt,Ut,Wt),Ae=gt,ce=Ut,xe=Wt)},setOp:function(gt,Ut,Wt){(Be!==gt||Qe!==Ut||ht!==Wt)&&(t.stencilOp(gt,Ut,Wt),Be=gt,Qe=Ut,ht=Wt)},setLocked:function(gt){we=gt},setClear:function(gt){xt!==gt&&(t.clearStencil(gt),xt=gt)},reset:function(){we=!1,Ve=null,Ae=null,ce=null,xe=null,Be=null,Qe=null,ht=null,xt=null}}}const l=new i,c=new s,d=new o,u=new WeakMap,m=new WeakMap;let p={},f={},y=new WeakMap,v=[],b=null,g=!1,_=null,C=null,P=null,N=null,A=null,T=null,F=null,k=new Mn(0,0,0),D=0,H=!1,z=null,Q=null,L=null,te=null,ie=null;const J=t.getParameter(t.MAX_COMBINED_TEXTURE_IMAGE_UNITS);let oe=!1,fe=0;const re=t.getParameter(t.VERSION);re.indexOf(\"WebGL\")!==-1?(fe=parseFloat(/^WebGL (\\d)/.exec(re)[1]),oe=fe>=1):re.indexOf(\"OpenGL ES\")!==-1&&(fe=parseFloat(/^OpenGL ES (\\d)/.exec(re)[1]),oe=fe>=2);let W=null,ne={};const me=t.getParameter(t.SCISSOR_BOX),be=t.getParameter(t.VIEWPORT),Ce=new ta().fromArray(me),q=new ta().fromArray(be);function Y(we,Ve,Ae,ce){const xe=new Uint8Array(4),Be=t.createTexture();t.bindTexture(we,Be),t.texParameteri(we,t.TEXTURE_MIN_FILTER,t.NEAREST),t.texParameteri(we,t.TEXTURE_MAG_FILTER,t.NEAREST);for(let Qe=0;Qe<Ae;Qe++)r&&(we===t.TEXTURE_3D||we===t.TEXTURE_2D_ARRAY)?t.texImage3D(Ve,0,t.RGBA,1,1,ce,0,t.RGBA,t.UNSIGNED_BYTE,xe):t.texImage2D(Ve+Qe,0,t.RGBA,1,1,0,t.RGBA,t.UNSIGNED_BYTE,xe);return Be}const E={};E[t.TEXTURE_2D]=Y(t.TEXTURE_2D,t.TEXTURE_2D,1),E[t.TEXTURE_CUBE_MAP]=Y(t.TEXTURE_CUBE_MAP,t.TEXTURE_CUBE_MAP_POSITIVE_X,6),r&&(E[t.TEXTURE_2D_ARRAY]=Y(t.TEXTURE_2D_ARRAY,t.TEXTURE_2D_ARRAY,1,1),E[t.TEXTURE_3D]=Y(t.TEXTURE_3D,t.TEXTURE_3D,1,1)),l.setClear(0,0,0,1),c.setClear(1),d.setClear(0),j(t.DEPTH_TEST),c.setFunc(DN),ee(!1),se(Z6),j(t.CULL_FACE),X(op);function j(we){p[we]!==!0&&(t.enable(we),p[we]=!0)}function O(we){p[we]!==!1&&(t.disable(we),p[we]=!1)}function K(we,Ve){return f[we]!==Ve?(t.bindFramebuffer(we,Ve),f[we]=Ve,r&&(we===t.DRAW_FRAMEBUFFER&&(f[t.FRAMEBUFFER]=Ve),we===t.FRAMEBUFFER&&(f[t.DRAW_FRAMEBUFFER]=Ve)),!0):!1}function U(we,Ve){let Ae=v,ce=!1;if(we)if(Ae=y.get(Ve),Ae===void 0&&(Ae=[],y.set(Ve,Ae)),we.isWebGLMultipleRenderTargets){const xe=we.texture;if(Ae.length!==xe.length||Ae[0]!==t.COLOR_ATTACHMENT0){for(let Be=0,Qe=xe.length;Be<Qe;Be++)Ae[Be]=t.COLOR_ATTACHMENT0+Be;Ae.length=xe.length,ce=!0}}else Ae[0]!==t.COLOR_ATTACHMENT0&&(Ae[0]=t.COLOR_ATTACHMENT0,ce=!0);else Ae[0]!==t.BACK&&(Ae[0]=t.BACK,ce=!0);ce&&(n.isWebGL2?t.drawBuffers(Ae):e.get(\"WEBGL_draw_buffers\").drawBuffersWEBGL(Ae))}function de(we){return b!==we?(t.useProgram(we),b=we,!0):!1}const I={[jf]:t.FUNC_ADD,[Hke]:t.FUNC_SUBTRACT,[qke]:t.FUNC_REVERSE_SUBTRACT};if(r)I[nq]=t.MIN,I[rq]=t.MAX;else{const we=e.get(\"EXT_blend_minmax\");we!==null&&(I[nq]=we.MIN_EXT,I[rq]=we.MAX_EXT)}const G={[$ke]:t.ZERO,[Vke]:t.ONE,[Gke]:t.SRC_COLOR,[sR]:t.SRC_ALPHA,[Zke]:t.SRC_ALPHA_SATURATE,[Yke]:t.DST_COLOR,[Kke]:t.DST_ALPHA,[Wke]:t.ONE_MINUS_SRC_COLOR,[oR]:t.ONE_MINUS_SRC_ALPHA,[Qke]:t.ONE_MINUS_DST_COLOR,[Xke]:t.ONE_MINUS_DST_ALPHA,[Jke]:t.CONSTANT_COLOR,[eNe]:t.ONE_MINUS_CONSTANT_COLOR,[tNe]:t.CONSTANT_ALPHA,[nNe]:t.ONE_MINUS_CONSTANT_ALPHA};function X(we,Ve,Ae,ce,xe,Be,Qe,ht,xt,gt){if(we===op){g===!0&&(O(t.BLEND),g=!1);return}if(g===!1&&(j(t.BLEND),g=!0),we!==Bke){if(we!==_||gt!==H){if((C!==jf||A!==jf)&&(t.blendEquation(t.FUNC_ADD),C=jf,A=jf),gt)switch(we){case Mx:t.blendFuncSeparate(t.ONE,t.ONE_MINUS_SRC_ALPHA,t.ONE,t.ONE_MINUS_SRC_ALPHA);break;case J6:t.blendFunc(t.ONE,t.ONE);break;case eq:t.blendFuncSeparate(t.ZERO,t.ONE_MINUS_SRC_COLOR,t.ZERO,t.ONE);break;case tq:t.blendFuncSeparate(t.ZERO,t.SRC_COLOR,t.ZERO,t.SRC_ALPHA);break;default:console.error(\"THREE.WebGLState: Invalid blending: \",we);break}else switch(we){case Mx:t.blendFuncSeparate(t.SRC_ALPHA,t.ONE_MINUS_SRC_ALPHA,t.ONE,t.ONE_MINUS_SRC_ALPHA);break;case J6:t.blendFunc(t.SRC_ALPHA,t.ONE);break;case eq:t.blendFuncSeparate(t.ZERO,t.ONE_MINUS_SRC_COLOR,t.ZERO,t.ONE);break;case tq:t.blendFunc(t.ZERO,t.SRC_COLOR);break;default:console.error(\"THREE.WebGLState: Invalid blending: \",we);break}P=null,N=null,T=null,F=null,k.set(0,0,0),D=0,_=we,H=gt}return}xe=xe||Ve,Be=Be||Ae,Qe=Qe||ce,(Ve!==C||xe!==A)&&(t.blendEquationSeparate(I[Ve],I[xe]),C=Ve,A=xe),(Ae!==P||ce!==N||Be!==T||Qe!==F)&&(t.blendFuncSeparate(G[Ae],G[ce],G[Be],G[Qe]),P=Ae,N=ce,T=Be,F=Qe),(ht.equals(k)===!1||xt!==D)&&(t.blendColor(ht.r,ht.g,ht.b,xt),k.copy(ht),D=xt),_=we,H=!1}function V(we,Ve){we.side===Ku?O(t.CULL_FACE):j(t.CULL_FACE);let Ae=we.side===Uo;Ve&&(Ae=!Ae),ee(Ae),we.blending===Mx&&we.transparent===!1?X(op):X(we.blending,we.blendEquation,we.blendSrc,we.blendDst,we.blendEquationAlpha,we.blendSrcAlpha,we.blendDstAlpha,we.blendColor,we.blendAlpha,we.premultipliedAlpha),c.setFunc(we.depthFunc),c.setTest(we.depthTest),c.setMask(we.depthWrite),l.setMask(we.colorWrite);const ce=we.stencilWrite;d.setTest(ce),ce&&(d.setMask(we.stencilWriteMask),d.setFunc(we.stencilFunc,we.stencilRef,we.stencilFuncMask),d.setOp(we.stencilFail,we.stencilZFail,we.stencilZPass)),he(we.polygonOffset,we.polygonOffsetFactor,we.polygonOffsetUnits),we.alphaToCoverage===!0?j(t.SAMPLE_ALPHA_TO_COVERAGE):O(t.SAMPLE_ALPHA_TO_COVERAGE)}function ee(we){z!==we&&(we?t.frontFace(t.CW):t.frontFace(t.CCW),z=we)}function se(we){we!==Ike?(j(t.CULL_FACE),we!==Q&&(we===Z6?t.cullFace(t.BACK):we===zke?t.cullFace(t.FRONT):t.cullFace(t.FRONT_AND_BACK))):O(t.CULL_FACE),Q=we}function ge(we){we!==L&&(oe&&t.lineWidth(we),L=we)}function he(we,Ve,Ae){we?(j(t.POLYGON_OFFSET_FILL),(te!==Ve||ie!==Ae)&&(t.polygonOffset(Ve,Ae),te=Ve,ie=Ae)):O(t.POLYGON_OFFSET_FILL)}function le(we){we?j(t.SCISSOR_TEST):O(t.SCISSOR_TEST)}function B(we){we===void 0&&(we=t.TEXTURE0+J-1),W!==we&&(t.activeTexture(we),W=we)}function R(we,Ve,Ae){Ae===void 0&&(W===null?Ae=t.TEXTURE0+J-1:Ae=W);let ce=ne[Ae];ce===void 0&&(ce={type:void 0,texture:void 0},ne[Ae]=ce),(ce.type!==we||ce.texture!==Ve)&&(W!==Ae&&(t.activeTexture(Ae),W=Ae),t.bindTexture(we,Ve||E[we]),ce.type=we,ce.texture=Ve)}function ae(){const we=ne[W];we!==void 0&&we.type!==void 0&&(t.bindTexture(we.type,null),we.type=void 0,we.texture=void 0)}function _e(){try{t.compressedTexImage2D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function Se(){try{t.compressedTexImage3D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function ve(){try{t.texSubImage2D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function Te(){try{t.texSubImage3D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function ye(){try{t.compressedTexSubImage2D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function je(){try{t.compressedTexSubImage3D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function Le(){try{t.texStorage2D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function Me(){try{t.texStorage3D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function Oe(){try{t.texImage2D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function Re(){try{t.texImage3D.apply(t,arguments)}catch(we){console.error(\"THREE.WebGLState:\",we)}}function $e(we){Ce.equals(we)===!1&&(t.scissor(we.x,we.y,we.z,we.w),Ce.copy(we))}function Ye(we){q.equals(we)===!1&&(t.viewport(we.x,we.y,we.z,we.w),q.copy(we))}function tt(we,Ve){let Ae=m.get(Ve);Ae===void 0&&(Ae=new WeakMap,m.set(Ve,Ae));let ce=Ae.get(we);ce===void 0&&(ce=t.getUniformBlockIndex(Ve,we.name),Ae.set(we,ce))}function pe(we,Ve){const ce=m.get(Ve).get(we);u.get(Ve)!==ce&&(t.uniformBlockBinding(Ve,ce,we.__bindingPointIndex),u.set(Ve,ce))}function Fe(){t.disable(t.BLEND),t.disable(t.CULL_FACE),t.disable(t.DEPTH_TEST),t.disable(t.POLYGON_OFFSET_FILL),t.disable(t.SCISSOR_TEST),t.disable(t.STENCIL_TEST),t.disable(t.SAMPLE_ALPHA_TO_COVERAGE),t.blendEquation(t.FUNC_ADD),t.blendFunc(t.ONE,t.ZERO),t.blendFuncSeparate(t.ONE,t.ZERO,t.ONE,t.ZERO),t.blendColor(0,0,0,0),t.colorMask(!0,!0,!0,!0),t.clearColor(0,0,0,0),t.depthMask(!0),t.depthFunc(t.LESS),t.clearDepth(1),t.stencilMask(4294967295),t.stencilFunc(t.ALWAYS,0,4294967295),t.stencilOp(t.KEEP,t.KEEP,t.KEEP),t.clearStencil(0),t.cullFace(t.BACK),t.frontFace(t.CCW),t.polygonOffset(0,0),t.activeTexture(t.TEXTURE0),t.bindFramebuffer(t.FRAMEBUFFER,null),r===!0&&(t.bindFramebuffer(t.DRAW_FRAMEBUFFER,null),t.bindFramebuffer(t.READ_FRAMEBUFFER,null)),t.useProgram(null),t.lineWidth(1),t.scissor(0,0,t.canvas.width,t.canvas.height),t.viewport(0,0,t.canvas.width,t.canvas.height),p={},W=null,ne={},f={},y=new WeakMap,v=[],b=null,g=!1,_=null,C=null,P=null,N=null,A=null,T=null,F=null,k=new Mn(0,0,0),D=0,H=!1,z=null,Q=null,L=null,te=null,ie=null,Ce.set(0,0,t.canvas.width,t.canvas.height),q.set(0,0,t.canvas.width,t.canvas.height),l.reset(),c.reset(),d.reset()}return{buffers:{color:l,depth:c,stencil:d},enable:j,disable:O,bindFramebuffer:K,drawBuffers:U,useProgram:de,setBlending:X,setMaterial:V,setFlipSided:ee,setCullFace:se,setLineWidth:ge,setPolygonOffset:he,setScissorTest:le,activeTexture:B,bindTexture:R,unbindTexture:ae,compressedTexImage2D:_e,compressedTexImage3D:Se,texImage2D:Oe,texImage3D:Re,updateUBOMapping:tt,uniformBlockBinding:pe,texStorage2D:Le,texStorage3D:Me,texSubImage2D:ve,texSubImage3D:Te,compressedTexSubImage2D:ye,compressedTexSubImage3D:je,scissor:$e,viewport:Ye,reset:Fe}}function Wje(t,e,n,r,i,s,o){const l=i.isWebGL2,c=i.maxTextures,d=i.maxCubemapSize,u=i.maxTextureSize,m=i.maxSamples,p=e.has(\"WEBGL_multisampled_render_to_texture\")?e.get(\"WEBGL_multisampled_render_to_texture\"):null,f=typeof navigator>\"u\"?!1:/OculusBrowser/g.test(navigator.userAgent),y=new WeakMap;let v;const b=new WeakMap;let g=!1;try{g=typeof OffscreenCanvas<\"u\"&&new OffscreenCanvas(1,1).getContext(\"2d\")!==null}catch{}function _(B,R){return g?new OffscreenCanvas(B,R):IN(\"canvas\")}function C(B,R,ae,_e){let Se=1;if((B.width>_e||B.height>_e)&&(Se=_e/Math.max(B.width,B.height)),Se<1||R===!0)if(typeof HTMLImageElement<\"u\"&&B instanceof HTMLImageElement||typeof HTMLCanvasElement<\"u\"&&B instanceof HTMLCanvasElement||typeof ImageBitmap<\"u\"&&B instanceof ImageBitmap){const ve=R?ON:Math.floor,Te=ve(Se*B.width),ye=ve(Se*B.height);v===void 0&&(v=_(Te,ye));const je=ae?_(Te,ye):v;return je.width=Te,je.height=ye,je.getContext(\"2d\").drawImage(B,0,0,Te,ye),console.warn(\"THREE.WebGLRenderer: Texture has been resized from (\"+B.width+\"x\"+B.height+\") to (\"+Te+\"x\"+ye+\").\"),je}else return\"data\"in B&&console.warn(\"THREE.WebGLRenderer: Image in DataTexture is too big (\"+B.width+\"x\"+B.height+\").\"),B;return B}function P(B){return pR(B.width)&&pR(B.height)}function N(B){return l?!1:B.wrapS!==Uc||B.wrapT!==Uc||B.minFilter!==gs&&B.minFilter!==Xl}function A(B,R){return B.generateMipmaps&&R&&B.minFilter!==gs&&B.minFilter!==Xl}function T(B){t.generateMipmap(B)}function F(B,R,ae,_e,Se=!1){if(l===!1)return R;if(B!==null){if(t[B]!==void 0)return t[B];console.warn(\"THREE.WebGLRenderer: Attempt to use non-existing WebGL internal format '\"+B+\"'\")}let ve=R;if(R===t.RED&&(ae===t.FLOAT&&(ve=t.R32F),ae===t.HALF_FLOAT&&(ve=t.R16F),ae===t.UNSIGNED_BYTE&&(ve=t.R8)),R===t.RED_INTEGER&&(ae===t.UNSIGNED_BYTE&&(ve=t.R8UI),ae===t.UNSIGNED_SHORT&&(ve=t.R16UI),ae===t.UNSIGNED_INT&&(ve=t.R32UI),ae===t.BYTE&&(ve=t.R8I),ae===t.SHORT&&(ve=t.R16I),ae===t.INT&&(ve=t.R32I)),R===t.RG&&(ae===t.FLOAT&&(ve=t.RG32F),ae===t.HALF_FLOAT&&(ve=t.RG16F),ae===t.UNSIGNED_BYTE&&(ve=t.RG8)),R===t.RGBA){const Te=Se?FN:Jr.getTransfer(_e);ae===t.FLOAT&&(ve=t.RGBA32F),ae===t.HALF_FLOAT&&(ve=t.RGBA16F),ae===t.UNSIGNED_BYTE&&(ve=Te===Sa?t.SRGB8_ALPHA8:t.RGBA8),ae===t.UNSIGNED_SHORT_4_4_4_4&&(ve=t.RGBA4),ae===t.UNSIGNED_SHORT_5_5_5_1&&(ve=t.RGB5_A1)}return(ve===t.R16F||ve===t.R32F||ve===t.RG16F||ve===t.RG32F||ve===t.RGBA16F||ve===t.RGBA32F)&&e.get(\"EXT_color_buffer_float\"),ve}function k(B,R,ae){return A(B,ae)===!0||B.isFramebufferTexture&&B.minFilter!==gs&&B.minFilter!==Xl?Math.log2(Math.max(R.width,R.height))+1:B.mipmaps!==void 0&&B.mipmaps.length>0?B.mipmaps.length:B.isCompressedTexture&&Array.isArray(B.image)?R.mipmaps.length:1}function D(B){return B===gs||B===aq||B===uE?t.NEAREST:t.LINEAR}function H(B){const R=B.target;R.removeEventListener(\"dispose\",H),Q(R),R.isVideoTexture&&y.delete(R)}function z(B){const R=B.target;R.removeEventListener(\"dispose\",z),te(R)}function Q(B){const R=r.get(B);if(R.__webglInit===void 0)return;const ae=B.source,_e=b.get(ae);if(_e){const Se=_e[R.__cacheKey];Se.usedTimes--,Se.usedTimes===0&&L(B),Object.keys(_e).length===0&&b.delete(ae)}r.remove(B)}function L(B){const R=r.get(B);t.deleteTexture(R.__webglTexture);const ae=B.source,_e=b.get(ae);delete _e[R.__cacheKey],o.memory.textures--}function te(B){const R=B.texture,ae=r.get(B),_e=r.get(R);if(_e.__webglTexture!==void 0&&(t.deleteTexture(_e.__webglTexture),o.memory.textures--),B.depthTexture&&B.depthTexture.dispose(),B.isWebGLCubeRenderTarget)for(let Se=0;Se<6;Se++){if(Array.isArray(ae.__webglFramebuffer[Se]))for(let ve=0;ve<ae.__webglFramebuffer[Se].length;ve++)t.deleteFramebuffer(ae.__webglFramebuffer[Se][ve]);else t.deleteFramebuffer(ae.__webglFramebuffer[Se]);ae.__webglDepthbuffer&&t.deleteRenderbuffer(ae.__webglDepthbuffer[Se])}else{if(Array.isArray(ae.__webglFramebuffer))for(let Se=0;Se<ae.__webglFramebuffer.length;Se++)t.deleteFramebuffer(ae.__webglFramebuffer[Se]);else t.deleteFramebuffer(ae.__webglFramebuffer);if(ae.__webglDepthbuffer&&t.deleteRenderbuffer(ae.__webglDepthbuffer),ae.__webglMultisampledFramebuffer&&t.deleteFramebuffer(ae.__webglMultisampledFramebuffer),ae.__webglColorRenderbuffer)for(let Se=0;Se<ae.__webglColorRenderbuffer.length;Se++)ae.__webglColorRenderbuffer[Se]&&t.deleteRenderbuffer(ae.__webglColorRenderbuffer[Se]);ae.__webglDepthRenderbuffer&&t.deleteRenderbuffer(ae.__webglDepthRenderbuffer)}if(B.isWebGLMultipleRenderTargets)for(let Se=0,ve=R.length;Se<ve;Se++){const Te=r.get(R[Se]);Te.__webglTexture&&(t.deleteTexture(Te.__webglTexture),o.memory.textures--),r.remove(R[Se])}r.remove(R),r.remove(B)}let ie=0;function J(){ie=0}function oe(){const B=ie;return B>=c&&console.warn(\"THREE.WebGLTextures: Trying to use \"+B+\" texture units while this GPU supports only \"+c),ie+=1,B}function fe(B){const R=[];return R.push(B.wrapS),R.push(B.wrapT),R.push(B.wrapR||0),R.push(B.magFilter),R.push(B.minFilter),R.push(B.anisotropy),R.push(B.internalFormat),R.push(B.format),R.push(B.type),R.push(B.generateMipmaps),R.push(B.premultiplyAlpha),R.push(B.flipY),R.push(B.unpackAlignment),R.push(B.colorSpace),R.join()}function re(B,R){const ae=r.get(B);if(B.isVideoTexture&&he(B),B.isRenderTargetTexture===!1&&B.version>0&&ae.__version!==B.version){const _e=B.image;if(_e===null)console.warn(\"THREE.WebGLRenderer: Texture marked for update but no image data found.\");else if(_e.complete===!1)console.warn(\"THREE.WebGLRenderer: Texture marked for update but image is incomplete\");else{j(ae,B,R);return}}n.bindTexture(t.TEXTURE_2D,ae.__webglTexture,t.TEXTURE0+R)}function W(B,R){const ae=r.get(B);if(B.version>0&&ae.__version!==B.version){j(ae,B,R);return}n.bindTexture(t.TEXTURE_2D_ARRAY,ae.__webglTexture,t.TEXTURE0+R)}function ne(B,R){const ae=r.get(B);if(B.version>0&&ae.__version!==B.version){j(ae,B,R);return}n.bindTexture(t.TEXTURE_3D,ae.__webglTexture,t.TEXTURE0+R)}function me(B,R){const ae=r.get(B);if(B.version>0&&ae.__version!==B.version){O(ae,B,R);return}n.bindTexture(t.TEXTURE_CUBE_MAP,ae.__webglTexture,t.TEXTURE0+R)}const be={[dR]:t.REPEAT,[Uc]:t.CLAMP_TO_EDGE,[uR]:t.MIRRORED_REPEAT},Ce={[gs]:t.NEAREST,[aq]:t.NEAREST_MIPMAP_NEAREST,[uE]:t.NEAREST_MIPMAP_LINEAR,[Xl]:t.LINEAR,[bNe]:t.LINEAR_MIPMAP_NEAREST,[hw]:t.LINEAR_MIPMAP_LINEAR},q={[ANe]:t.NEVER,[RNe]:t.ALWAYS,[jNe]:t.LESS,[sJ]:t.LEQUAL,[MNe]:t.EQUAL,[FNe]:t.GEQUAL,[ENe]:t.GREATER,[DNe]:t.NOTEQUAL};function Y(B,R,ae){if(ae?(t.texParameteri(B,t.TEXTURE_WRAP_S,be[R.wrapS]),t.texParameteri(B,t.TEXTURE_WRAP_T,be[R.wrapT]),(B===t.TEXTURE_3D||B===t.TEXTURE_2D_ARRAY)&&t.texParameteri(B,t.TEXTURE_WRAP_R,be[R.wrapR]),t.texParameteri(B,t.TEXTURE_MAG_FILTER,Ce[R.magFilter]),t.texParameteri(B,t.TEXTURE_MIN_FILTER,Ce[R.minFilter])):(t.texParameteri(B,t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE),t.texParameteri(B,t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE),(B===t.TEXTURE_3D||B===t.TEXTURE_2D_ARRAY)&&t.texParameteri(B,t.TEXTURE_WRAP_R,t.CLAMP_TO_EDGE),(R.wrapS!==Uc||R.wrapT!==Uc)&&console.warn(\"THREE.WebGLRenderer: Texture is not power of two. Texture.wrapS and Texture.wrapT should be set to THREE.ClampToEdgeWrapping.\"),t.texParameteri(B,t.TEXTURE_MAG_FILTER,D(R.magFilter)),t.texParameteri(B,t.TEXTURE_MIN_FILTER,D(R.minFilter)),R.minFilter!==gs&&R.minFilter!==Xl&&console.warn(\"THREE.WebGLRenderer: Texture is not power of two. Texture.minFilter should be set to THREE.NearestFilter or THREE.LinearFilter.\")),R.compareFunction&&(t.texParameteri(B,t.TEXTURE_COMPARE_MODE,t.COMPARE_REF_TO_TEXTURE),t.texParameteri(B,t.TEXTURE_COMPARE_FUNC,q[R.compareFunction])),e.has(\"EXT_texture_filter_anisotropic\")===!0){const _e=e.get(\"EXT_texture_filter_anisotropic\");if(R.magFilter===gs||R.minFilter!==uE&&R.minFilter!==hw||R.type===tm&&e.has(\"OES_texture_float_linear\")===!1||l===!1&&R.type===pw&&e.has(\"OES_texture_half_float_linear\")===!1)return;(R.anisotropy>1||r.get(R).__currentAnisotropy)&&(t.texParameterf(B,_e.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(R.anisotropy,i.getMaxAnisotropy())),r.get(R).__currentAnisotropy=R.anisotropy)}}function E(B,R){let ae=!1;B.__webglInit===void 0&&(B.__webglInit=!0,R.addEventListener(\"dispose\",H));const _e=R.source;let Se=b.get(_e);Se===void 0&&(Se={},b.set(_e,Se));const ve=fe(R);if(ve!==B.__cacheKey){Se[ve]===void 0&&(Se[ve]={texture:t.createTexture(),usedTimes:0},o.memory.textures++,ae=!0),Se[ve].usedTimes++;const Te=Se[B.__cacheKey];Te!==void 0&&(Se[B.__cacheKey].usedTimes--,Te.usedTimes===0&&L(R)),B.__cacheKey=ve,B.__webglTexture=Se[ve].texture}return ae}function j(B,R,ae){let _e=t.TEXTURE_2D;(R.isDataArrayTexture||R.isCompressedArrayTexture)&&(_e=t.TEXTURE_2D_ARRAY),R.isData3DTexture&&(_e=t.TEXTURE_3D);const Se=E(B,R),ve=R.source;n.bindTexture(_e,B.__webglTexture,t.TEXTURE0+ae);const Te=r.get(ve);if(ve.version!==Te.__version||Se===!0){n.activeTexture(t.TEXTURE0+ae);const ye=Jr.getPrimaries(Jr.workingColorSpace),je=R.colorSpace===Ql?null:Jr.getPrimaries(R.colorSpace),Le=R.colorSpace===Ql||ye===je?t.NONE:t.BROWSER_DEFAULT_WEBGL;t.pixelStorei(t.UNPACK_FLIP_Y_WEBGL,R.flipY),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,R.premultiplyAlpha),t.pixelStorei(t.UNPACK_ALIGNMENT,R.unpackAlignment),t.pixelStorei(t.UNPACK_COLORSPACE_CONVERSION_WEBGL,Le);const Me=N(R)&&P(R.image)===!1;let Oe=C(R.image,Me,!1,u);Oe=le(R,Oe);const Re=P(Oe)||l,$e=s.convert(R.format,R.colorSpace);let Ye=s.convert(R.type),tt=F(R.internalFormat,$e,Ye,R.colorSpace,R.isVideoTexture);Y(_e,R,Re);let pe;const Fe=R.mipmaps,we=l&&R.isVideoTexture!==!0&&tt!==rJ,Ve=Te.__version===void 0||Se===!0,Ae=k(R,Oe,Re);if(R.isDepthTexture)tt=t.DEPTH_COMPONENT,l?R.type===tm?tt=t.DEPTH_COMPONENT32F:R.type===qh?tt=t.DEPTH_COMPONENT24:R.type===Kf?tt=t.DEPTH24_STENCIL8:tt=t.DEPTH_COMPONENT16:R.type===tm&&console.error(\"WebGLRenderer: Floating point depth texture requires WebGL2.\"),R.format===Xf&&tt===t.DEPTH_COMPONENT&&R.type!==lI&&R.type!==qh&&(console.warn(\"THREE.WebGLRenderer: Use UnsignedShortType or UnsignedIntType for DepthFormat DepthTexture.\"),R.type=qh,Ye=s.convert(R.type)),R.format===Zx&&tt===t.DEPTH_COMPONENT&&(tt=t.DEPTH_STENCIL,R.type!==Kf&&(console.warn(\"THREE.WebGLRenderer: Use UnsignedInt248Type for DepthStencilFormat DepthTexture.\"),R.type=Kf,Ye=s.convert(R.type))),Ve&&(we?n.texStorage2D(t.TEXTURE_2D,1,tt,Oe.width,Oe.height):n.texImage2D(t.TEXTURE_2D,0,tt,Oe.width,Oe.height,0,$e,Ye,null));else if(R.isDataTexture)if(Fe.length>0&&Re){we&&Ve&&n.texStorage2D(t.TEXTURE_2D,Ae,tt,Fe[0].width,Fe[0].height);for(let ce=0,xe=Fe.length;ce<xe;ce++)pe=Fe[ce],we?n.texSubImage2D(t.TEXTURE_2D,ce,0,0,pe.width,pe.height,$e,Ye,pe.data):n.texImage2D(t.TEXTURE_2D,ce,tt,pe.width,pe.height,0,$e,Ye,pe.data);R.generateMipmaps=!1}else we?(Ve&&n.texStorage2D(t.TEXTURE_2D,Ae,tt,Oe.width,Oe.height),n.texSubImage2D(t.TEXTURE_2D,0,0,0,Oe.width,Oe.height,$e,Ye,Oe.data)):n.texImage2D(t.TEXTURE_2D,0,tt,Oe.width,Oe.height,0,$e,Ye,Oe.data);else if(R.isCompressedTexture)if(R.isCompressedArrayTexture){we&&Ve&&n.texStorage3D(t.TEXTURE_2D_ARRAY,Ae,tt,Fe[0].width,Fe[0].height,Oe.depth);for(let ce=0,xe=Fe.length;ce<xe;ce++)pe=Fe[ce],R.format!==Yl?$e!==null?we?n.compressedTexSubImage3D(t.TEXTURE_2D_ARRAY,ce,0,0,0,pe.width,pe.height,Oe.depth,$e,pe.data,0,0):n.compressedTexImage3D(t.TEXTURE_2D_ARRAY,ce,tt,pe.width,pe.height,Oe.depth,0,pe.data,0,0):console.warn(\"THREE.WebGLRenderer: Attempt to load unsupported compressed texture format in .uploadTexture()\"):we?n.texSubImage3D(t.TEXTURE_2D_ARRAY,ce,0,0,0,pe.width,pe.height,Oe.depth,$e,Ye,pe.data):n.texImage3D(t.TEXTURE_2D_ARRAY,ce,tt,pe.width,pe.height,Oe.depth,0,$e,Ye,pe.data)}else{we&&Ve&&n.texStorage2D(t.TEXTURE_2D,Ae,tt,Fe[0].width,Fe[0].height);for(let ce=0,xe=Fe.length;ce<xe;ce++)pe=Fe[ce],R.format!==Yl?$e!==null?we?n.compressedTexSubImage2D(t.TEXTURE_2D,ce,0,0,pe.width,pe.height,$e,pe.data):n.compressedTexImage2D(t.TEXTURE_2D,ce,tt,pe.width,pe.height,0,pe.data):console.warn(\"THREE.WebGLRenderer: Attempt to load unsupported compressed texture format in .uploadTexture()\"):we?n.texSubImage2D(t.TEXTURE_2D,ce,0,0,pe.width,pe.height,$e,Ye,pe.data):n.texImage2D(t.TEXTURE_2D,ce,tt,pe.width,pe.height,0,$e,Ye,pe.data)}else if(R.isDataArrayTexture)we?(Ve&&n.texStorage3D(t.TEXTURE_2D_ARRAY,Ae,tt,Oe.width,Oe.height,Oe.depth),n.texSubImage3D(t.TEXTURE_2D_ARRAY,0,0,0,0,Oe.width,Oe.height,Oe.depth,$e,Ye,Oe.data)):n.texImage3D(t.TEXTURE_2D_ARRAY,0,tt,Oe.width,Oe.height,Oe.depth,0,$e,Ye,Oe.data);else if(R.isData3DTexture)we?(Ve&&n.texStorage3D(t.TEXTURE_3D,Ae,tt,Oe.width,Oe.height,Oe.depth),n.texSubImage3D(t.TEXTURE_3D,0,0,0,0,Oe.width,Oe.height,Oe.depth,$e,Ye,Oe.data)):n.texImage3D(t.TEXTURE_3D,0,tt,Oe.width,Oe.height,Oe.depth,0,$e,Ye,Oe.data);else if(R.isFramebufferTexture){if(Ve)if(we)n.texStorage2D(t.TEXTURE_2D,Ae,tt,Oe.width,Oe.height);else{let ce=Oe.width,xe=Oe.height;for(let Be=0;Be<Ae;Be++)n.texImage2D(t.TEXTURE_2D,Be,tt,ce,xe,0,$e,Ye,null),ce>>=1,xe>>=1}}else if(Fe.length>0&&Re){we&&Ve&&n.texStorage2D(t.TEXTURE_2D,Ae,tt,Fe[0].width,Fe[0].height);for(let ce=0,xe=Fe.length;ce<xe;ce++)pe=Fe[ce],we?n.texSubImage2D(t.TEXTURE_2D,ce,0,0,$e,Ye,pe):n.texImage2D(t.TEXTURE_2D,ce,tt,$e,Ye,pe);R.generateMipmaps=!1}else we?(Ve&&n.texStorage2D(t.TEXTURE_2D,Ae,tt,Oe.width,Oe.height),n.texSubImage2D(t.TEXTURE_2D,0,0,0,$e,Ye,Oe)):n.texImage2D(t.TEXTURE_2D,0,tt,$e,Ye,Oe);A(R,Re)&&T(_e),Te.__version=ve.version,R.onUpdate&&R.onUpdate(R)}B.__version=R.version}function O(B,R,ae){if(R.image.length!==6)return;const _e=E(B,R),Se=R.source;n.bindTexture(t.TEXTURE_CUBE_MAP,B.__webglTexture,t.TEXTURE0+ae);const ve=r.get(Se);if(Se.version!==ve.__version||_e===!0){n.activeTexture(t.TEXTURE0+ae);const Te=Jr.getPrimaries(Jr.workingColorSpace),ye=R.colorSpace===Ql?null:Jr.getPrimaries(R.colorSpace),je=R.colorSpace===Ql||Te===ye?t.NONE:t.BROWSER_DEFAULT_WEBGL;t.pixelStorei(t.UNPACK_FLIP_Y_WEBGL,R.flipY),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,R.premultiplyAlpha),t.pixelStorei(t.UNPACK_ALIGNMENT,R.unpackAlignment),t.pixelStorei(t.UNPACK_COLORSPACE_CONVERSION_WEBGL,je);const Le=R.isCompressedTexture||R.image[0].isCompressedTexture,Me=R.image[0]&&R.image[0].isDataTexture,Oe=[];for(let ce=0;ce<6;ce++)!Le&&!Me?Oe[ce]=C(R.image[ce],!1,!0,d):Oe[ce]=Me?R.image[ce].image:R.image[ce],Oe[ce]=le(R,Oe[ce]);const Re=Oe[0],$e=P(Re)||l,Ye=s.convert(R.format,R.colorSpace),tt=s.convert(R.type),pe=F(R.internalFormat,Ye,tt,R.colorSpace),Fe=l&&R.isVideoTexture!==!0,we=ve.__version===void 0||_e===!0;let Ve=k(R,Re,$e);Y(t.TEXTURE_CUBE_MAP,R,$e);let Ae;if(Le){Fe&&we&&n.texStorage2D(t.TEXTURE_CUBE_MAP,Ve,pe,Re.width,Re.height);for(let ce=0;ce<6;ce++){Ae=Oe[ce].mipmaps;for(let xe=0;xe<Ae.length;xe++){const Be=Ae[xe];R.format!==Yl?Ye!==null?Fe?n.compressedTexSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe,0,0,Be.width,Be.height,Ye,Be.data):n.compressedTexImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe,pe,Be.width,Be.height,0,Be.data):console.warn(\"THREE.WebGLRenderer: Attempt to load unsupported compressed texture format in .setTextureCube()\"):Fe?n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe,0,0,Be.width,Be.height,Ye,tt,Be.data):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe,pe,Be.width,Be.height,0,Ye,tt,Be.data)}}}else{Ae=R.mipmaps,Fe&&we&&(Ae.length>0&&Ve++,n.texStorage2D(t.TEXTURE_CUBE_MAP,Ve,pe,Oe[0].width,Oe[0].height));for(let ce=0;ce<6;ce++)if(Me){Fe?n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,0,0,0,Oe[ce].width,Oe[ce].height,Ye,tt,Oe[ce].data):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,0,pe,Oe[ce].width,Oe[ce].height,0,Ye,tt,Oe[ce].data);for(let xe=0;xe<Ae.length;xe++){const Qe=Ae[xe].image[ce].image;Fe?n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe+1,0,0,Qe.width,Qe.height,Ye,tt,Qe.data):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe+1,pe,Qe.width,Qe.height,0,Ye,tt,Qe.data)}}else{Fe?n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,0,0,0,Ye,tt,Oe[ce]):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,0,pe,Ye,tt,Oe[ce]);for(let xe=0;xe<Ae.length;xe++){const Be=Ae[xe];Fe?n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe+1,0,0,Ye,tt,Be.image[ce]):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+ce,xe+1,pe,Ye,tt,Be.image[ce])}}}A(R,$e)&&T(t.TEXTURE_CUBE_MAP),ve.__version=Se.version,R.onUpdate&&R.onUpdate(R)}B.__version=R.version}function K(B,R,ae,_e,Se,ve){const Te=s.convert(ae.format,ae.colorSpace),ye=s.convert(ae.type),je=F(ae.internalFormat,Te,ye,ae.colorSpace);if(!r.get(R).__hasExternalTextures){const Me=Math.max(1,R.width>>ve),Oe=Math.max(1,R.height>>ve);Se===t.TEXTURE_3D||Se===t.TEXTURE_2D_ARRAY?n.texImage3D(Se,ve,je,Me,Oe,R.depth,0,Te,ye,null):n.texImage2D(Se,ve,je,Me,Oe,0,Te,ye,null)}n.bindFramebuffer(t.FRAMEBUFFER,B),ge(R)?p.framebufferTexture2DMultisampleEXT(t.FRAMEBUFFER,_e,Se,r.get(ae).__webglTexture,0,se(R)):(Se===t.TEXTURE_2D||Se>=t.TEXTURE_CUBE_MAP_POSITIVE_X&&Se<=t.TEXTURE_CUBE_MAP_NEGATIVE_Z)&&t.framebufferTexture2D(t.FRAMEBUFFER,_e,Se,r.get(ae).__webglTexture,ve),n.bindFramebuffer(t.FRAMEBUFFER,null)}function U(B,R,ae){if(t.bindRenderbuffer(t.RENDERBUFFER,B),R.depthBuffer&&!R.stencilBuffer){let _e=l===!0?t.DEPTH_COMPONENT24:t.DEPTH_COMPONENT16;if(ae||ge(R)){const Se=R.depthTexture;Se&&Se.isDepthTexture&&(Se.type===tm?_e=t.DEPTH_COMPONENT32F:Se.type===qh&&(_e=t.DEPTH_COMPONENT24));const ve=se(R);ge(R)?p.renderbufferStorageMultisampleEXT(t.RENDERBUFFER,ve,_e,R.width,R.height):t.renderbufferStorageMultisample(t.RENDERBUFFER,ve,_e,R.width,R.height)}else t.renderbufferStorage(t.RENDERBUFFER,_e,R.width,R.height);t.framebufferRenderbuffer(t.FRAMEBUFFER,t.DEPTH_ATTACHMENT,t.RENDERBUFFER,B)}else if(R.depthBuffer&&R.stencilBuffer){const _e=se(R);ae&&ge(R)===!1?t.renderbufferStorageMultisample(t.RENDERBUFFER,_e,t.DEPTH24_STENCIL8,R.width,R.height):ge(R)?p.renderbufferStorageMultisampleEXT(t.RENDERBUFFER,_e,t.DEPTH24_STENCIL8,R.width,R.height):t.renderbufferStorage(t.RENDERBUFFER,t.DEPTH_STENCIL,R.width,R.height),t.framebufferRenderbuffer(t.FRAMEBUFFER,t.DEPTH_STENCIL_ATTACHMENT,t.RENDERBUFFER,B)}else{const _e=R.isWebGLMultipleRenderTargets===!0?R.texture:[R.texture];for(let Se=0;Se<_e.length;Se++){const ve=_e[Se],Te=s.convert(ve.format,ve.colorSpace),ye=s.convert(ve.type),je=F(ve.internalFormat,Te,ye,ve.colorSpace),Le=se(R);ae&&ge(R)===!1?t.renderbufferStorageMultisample(t.RENDERBUFFER,Le,je,R.width,R.height):ge(R)?p.renderbufferStorageMultisampleEXT(t.RENDERBUFFER,Le,je,R.width,R.height):t.renderbufferStorage(t.RENDERBUFFER,je,R.width,R.height)}}t.bindRenderbuffer(t.RENDERBUFFER,null)}function de(B,R){if(R&&R.isWebGLCubeRenderTarget)throw new Error(\"Depth Texture with cube render targets is not supported\");if(n.bindFramebuffer(t.FRAMEBUFFER,B),!(R.depthTexture&&R.depthTexture.isDepthTexture))throw new Error(\"renderTarget.depthTexture must be an instance of THREE.DepthTexture\");(!r.get(R.depthTexture).__webglTexture||R.depthTexture.image.width!==R.width||R.depthTexture.image.height!==R.height)&&(R.depthTexture.image.width=R.width,R.depthTexture.image.height=R.height,R.depthTexture.needsUpdate=!0),re(R.depthTexture,0);const _e=r.get(R.depthTexture).__webglTexture,Se=se(R);if(R.depthTexture.format===Xf)ge(R)?p.framebufferTexture2DMultisampleEXT(t.FRAMEBUFFER,t.DEPTH_ATTACHMENT,t.TEXTURE_2D,_e,0,Se):t.framebufferTexture2D(t.FRAMEBUFFER,t.DEPTH_ATTACHMENT,t.TEXTURE_2D,_e,0);else if(R.depthTexture.format===Zx)ge(R)?p.framebufferTexture2DMultisampleEXT(t.FRAMEBUFFER,t.DEPTH_STENCIL_ATTACHMENT,t.TEXTURE_2D,_e,0,Se):t.framebufferTexture2D(t.FRAMEBUFFER,t.DEPTH_STENCIL_ATTACHMENT,t.TEXTURE_2D,_e,0);else throw new Error(\"Unknown depthTexture format\")}function I(B){const R=r.get(B),ae=B.isWebGLCubeRenderTarget===!0;if(B.depthTexture&&!R.__autoAllocateDepthBuffer){if(ae)throw new Error(\"target.depthTexture not supported in Cube render targets\");de(R.__webglFramebuffer,B)}else if(ae){R.__webglDepthbuffer=[];for(let _e=0;_e<6;_e++)n.bindFramebuffer(t.FRAMEBUFFER,R.__webglFramebuffer[_e]),R.__webglDepthbuffer[_e]=t.createRenderbuffer(),U(R.__webglDepthbuffer[_e],B,!1)}else n.bindFramebuffer(t.FRAMEBUFFER,R.__webglFramebuffer),R.__webglDepthbuffer=t.createRenderbuffer(),U(R.__webglDepthbuffer,B,!1);n.bindFramebuffer(t.FRAMEBUFFER,null)}function G(B,R,ae){const _e=r.get(B);R!==void 0&&K(_e.__webglFramebuffer,B,B.texture,t.COLOR_ATTACHMENT0,t.TEXTURE_2D,0),ae!==void 0&&I(B)}function X(B){const R=B.texture,ae=r.get(B),_e=r.get(R);B.addEventListener(\"dispose\",z),B.isWebGLMultipleRenderTargets!==!0&&(_e.__webglTexture===void 0&&(_e.__webglTexture=t.createTexture()),_e.__version=R.version,o.memory.textures++);const Se=B.isWebGLCubeRenderTarget===!0,ve=B.isWebGLMultipleRenderTargets===!0,Te=P(B)||l;if(Se){ae.__webglFramebuffer=[];for(let ye=0;ye<6;ye++)if(l&&R.mipmaps&&R.mipmaps.length>0){ae.__webglFramebuffer[ye]=[];for(let je=0;je<R.mipmaps.length;je++)ae.__webglFramebuffer[ye][je]=t.createFramebuffer()}else ae.__webglFramebuffer[ye]=t.createFramebuffer()}else{if(l&&R.mipmaps&&R.mipmaps.length>0){ae.__webglFramebuffer=[];for(let ye=0;ye<R.mipmaps.length;ye++)ae.__webglFramebuffer[ye]=t.createFramebuffer()}else ae.__webglFramebuffer=t.createFramebuffer();if(ve)if(i.drawBuffers){const ye=B.texture;for(let je=0,Le=ye.length;je<Le;je++){const Me=r.get(ye[je]);Me.__webglTexture===void 0&&(Me.__webglTexture=t.createTexture(),o.memory.textures++)}}else console.warn(\"THREE.WebGLRenderer: WebGLMultipleRenderTargets can only be used with WebGL2 or WEBGL_draw_buffers extension.\");if(l&&B.samples>0&&ge(B)===!1){const ye=ve?R:[R];ae.__webglMultisampledFramebuffer=t.createFramebuffer(),ae.__webglColorRenderbuffer=[],n.bindFramebuffer(t.FRAMEBUFFER,ae.__webglMultisampledFramebuffer);for(let je=0;je<ye.length;je++){const Le=ye[je];ae.__webglColorRenderbuffer[je]=t.createRenderbuffer(),t.bindRenderbuffer(t.RENDERBUFFER,ae.__webglColorRenderbuffer[je]);const Me=s.convert(Le.format,Le.colorSpace),Oe=s.convert(Le.type),Re=F(Le.internalFormat,Me,Oe,Le.colorSpace,B.isXRRenderTarget===!0),$e=se(B);t.renderbufferStorageMultisample(t.RENDERBUFFER,$e,Re,B.width,B.height),t.framebufferRenderbuffer(t.FRAMEBUFFER,t.COLOR_ATTACHMENT0+je,t.RENDERBUFFER,ae.__webglColorRenderbuffer[je])}t.bindRenderbuffer(t.RENDERBUFFER,null),B.depthBuffer&&(ae.__webglDepthRenderbuffer=t.createRenderbuffer(),U(ae.__webglDepthRenderbuffer,B,!0)),n.bindFramebuffer(t.FRAMEBUFFER,null)}}if(Se){n.bindTexture(t.TEXTURE_CUBE_MAP,_e.__webglTexture),Y(t.TEXTURE_CUBE_MAP,R,Te);for(let ye=0;ye<6;ye++)if(l&&R.mipmaps&&R.mipmaps.length>0)for(let je=0;je<R.mipmaps.length;je++)K(ae.__webglFramebuffer[ye][je],B,R,t.COLOR_ATTACHMENT0,t.TEXTURE_CUBE_MAP_POSITIVE_X+ye,je);else K(ae.__webglFramebuffer[ye],B,R,t.COLOR_ATTACHMENT0,t.TEXTURE_CUBE_MAP_POSITIVE_X+ye,0);A(R,Te)&&T(t.TEXTURE_CUBE_MAP),n.unbindTexture()}else if(ve){const ye=B.texture;for(let je=0,Le=ye.length;je<Le;je++){const Me=ye[je],Oe=r.get(Me);n.bindTexture(t.TEXTURE_2D,Oe.__webglTexture),Y(t.TEXTURE_2D,Me,Te),K(ae.__webglFramebuffer,B,Me,t.COLOR_ATTACHMENT0+je,t.TEXTURE_2D,0),A(Me,Te)&&T(t.TEXTURE_2D)}n.unbindTexture()}else{let ye=t.TEXTURE_2D;if((B.isWebGL3DRenderTarget||B.isWebGLArrayRenderTarget)&&(l?ye=B.isWebGL3DRenderTarget?t.TEXTURE_3D:t.TEXTURE_2D_ARRAY:console.error(\"THREE.WebGLTextures: THREE.Data3DTexture and THREE.DataArrayTexture only supported with WebGL2.\")),n.bindTexture(ye,_e.__webglTexture),Y(ye,R,Te),l&&R.mipmaps&&R.mipmaps.length>0)for(let je=0;je<R.mipmaps.length;je++)K(ae.__webglFramebuffer[je],B,R,t.COLOR_ATTACHMENT0,ye,je);else K(ae.__webglFramebuffer,B,R,t.COLOR_ATTACHMENT0,ye,0);A(R,Te)&&T(ye),n.unbindTexture()}B.depthBuffer&&I(B)}function V(B){const R=P(B)||l,ae=B.isWebGLMultipleRenderTargets===!0?B.texture:[B.texture];for(let _e=0,Se=ae.length;_e<Se;_e++){const ve=ae[_e];if(A(ve,R)){const Te=B.isWebGLCubeRenderTarget?t.TEXTURE_CUBE_MAP:t.TEXTURE_2D,ye=r.get(ve).__webglTexture;n.bindTexture(Te,ye),T(Te),n.unbindTexture()}}}function ee(B){if(l&&B.samples>0&&ge(B)===!1){const R=B.isWebGLMultipleRenderTargets?B.texture:[B.texture],ae=B.width,_e=B.height;let Se=t.COLOR_BUFFER_BIT;const ve=[],Te=B.stencilBuffer?t.DEPTH_STENCIL_ATTACHMENT:t.DEPTH_ATTACHMENT,ye=r.get(B),je=B.isWebGLMultipleRenderTargets===!0;if(je)for(let Le=0;Le<R.length;Le++)n.bindFramebuffer(t.FRAMEBUFFER,ye.__webglMultisampledFramebuffer),t.framebufferRenderbuffer(t.FRAMEBUFFER,t.COLOR_ATTACHMENT0+Le,t.RENDERBUFFER,null),n.bindFramebuffer(t.FRAMEBUFFER,ye.__webglFramebuffer),t.framebufferTexture2D(t.DRAW_FRAMEBUFFER,t.COLOR_ATTACHMENT0+Le,t.TEXTURE_2D,null,0);n.bindFramebuffer(t.READ_FRAMEBUFFER,ye.__webglMultisampledFramebuffer),n.bindFramebuffer(t.DRAW_FRAMEBUFFER,ye.__webglFramebuffer);for(let Le=0;Le<R.length;Le++){ve.push(t.COLOR_ATTACHMENT0+Le),B.depthBuffer&&ve.push(Te);const Me=ye.__ignoreDepthValues!==void 0?ye.__ignoreDepthValues:!1;if(Me===!1&&(B.depthBuffer&&(Se|=t.DEPTH_BUFFER_BIT),B.stencilBuffer&&(Se|=t.STENCIL_BUFFER_BIT)),je&&t.framebufferRenderbuffer(t.READ_FRAMEBUFFER,t.COLOR_ATTACHMENT0,t.RENDERBUFFER,ye.__webglColorRenderbuffer[Le]),Me===!0&&(t.invalidateFramebuffer(t.READ_FRAMEBUFFER,[Te]),t.invalidateFramebuffer(t.DRAW_FRAMEBUFFER,[Te])),je){const Oe=r.get(R[Le]).__webglTexture;t.framebufferTexture2D(t.DRAW_FRAMEBUFFER,t.COLOR_ATTACHMENT0,t.TEXTURE_2D,Oe,0)}t.blitFramebuffer(0,0,ae,_e,0,0,ae,_e,Se,t.NEAREST),f&&t.invalidateFramebuffer(t.READ_FRAMEBUFFER,ve)}if(n.bindFramebuffer(t.READ_FRAMEBUFFER,null),n.bindFramebuffer(t.DRAW_FRAMEBUFFER,null),je)for(let Le=0;Le<R.length;Le++){n.bindFramebuffer(t.FRAMEBUFFER,ye.__webglMultisampledFramebuffer),t.framebufferRenderbuffer(t.FRAMEBUFFER,t.COLOR_ATTACHMENT0+Le,t.RENDERBUFFER,ye.__webglColorRenderbuffer[Le]);const Me=r.get(R[Le]).__webglTexture;n.bindFramebuffer(t.FRAMEBUFFER,ye.__webglFramebuffer),t.framebufferTexture2D(t.DRAW_FRAMEBUFFER,t.COLOR_ATTACHMENT0+Le,t.TEXTURE_2D,Me,0)}n.bindFramebuffer(t.DRAW_FRAMEBUFFER,ye.__webglMultisampledFramebuffer)}}function se(B){return Math.min(m,B.samples)}function ge(B){const R=r.get(B);return l&&B.samples>0&&e.has(\"WEBGL_multisampled_render_to_texture\")===!0&&R.__useRenderToTexture!==!1}function he(B){const R=o.render.frame;y.get(B)!==R&&(y.set(B,R),B.update())}function le(B,R){const ae=B.colorSpace,_e=B.format,Se=B.type;return B.isCompressedTexture===!0||B.isVideoTexture===!0||B.format===hR||ae!==Sm&&ae!==Ql&&(Jr.getTransfer(ae)===Sa?l===!1?e.has(\"EXT_sRGB\")===!0&&_e===Yl?(B.format=hR,B.minFilter=Xl,B.generateMipmaps=!1):R=cJ.sRGBToLinear(R):(_e!==Yl||Se!==cp)&&console.warn(\"THREE.WebGLTextures: sRGB encoded textures have to use RGBAFormat and UnsignedByteType.\"):console.error(\"THREE.WebGLTextures: Unsupported texture color space:\",ae)),R}this.allocateTextureUnit=oe,this.resetTextureUnits=J,this.setTexture2D=re,this.setTexture2DArray=W,this.setTexture3D=ne,this.setTextureCube=me,this.rebindTextures=G,this.setupRenderTarget=X,this.updateRenderTargetMipmap=V,this.updateMultisampleRenderTarget=ee,this.setupDepthRenderbuffer=I,this.setupFrameBufferTexture=K,this.useMultisampledRTT=ge}function Kje(t,e,n){const r=n.isWebGL2;function i(s,o=Ql){let l;const c=Jr.getTransfer(o);if(s===cp)return t.UNSIGNED_BYTE;if(s===ZZ)return t.UNSIGNED_SHORT_4_4_4_4;if(s===JZ)return t.UNSIGNED_SHORT_5_5_5_1;if(s===xNe)return t.BYTE;if(s===yNe)return t.SHORT;if(s===lI)return t.UNSIGNED_SHORT;if(s===QZ)return t.INT;if(s===qh)return t.UNSIGNED_INT;if(s===tm)return t.FLOAT;if(s===pw)return r?t.HALF_FLOAT:(l=e.get(\"OES_texture_half_float\"),l!==null?l.HALF_FLOAT_OES:null);if(s===vNe)return t.ALPHA;if(s===Yl)return t.RGBA;if(s===wNe)return t.LUMINANCE;if(s===SNe)return t.LUMINANCE_ALPHA;if(s===Xf)return t.DEPTH_COMPONENT;if(s===Zx)return t.DEPTH_STENCIL;if(s===hR)return l=e.get(\"EXT_sRGB\"),l!==null?l.SRGB_ALPHA_EXT:null;if(s===_Ne)return t.RED;if(s===eJ)return t.RED_INTEGER;if(s===kNe)return t.RG;if(s===tJ)return t.RG_INTEGER;if(s===nJ)return t.RGBA_INTEGER;if(s===mE||s===hE||s===pE||s===fE)if(c===Sa)if(l=e.get(\"WEBGL_compressed_texture_s3tc_srgb\"),l!==null){if(s===mE)return l.COMPRESSED_SRGB_S3TC_DXT1_EXT;if(s===hE)return l.COMPRESSED_SRGB_ALPHA_S3TC_DXT1_EXT;if(s===pE)return l.COMPRESSED_SRGB_ALPHA_S3TC_DXT3_EXT;if(s===fE)return l.COMPRESSED_SRGB_ALPHA_S3TC_DXT5_EXT}else return null;else if(l=e.get(\"WEBGL_compressed_texture_s3tc\"),l!==null){if(s===mE)return l.COMPRESSED_RGB_S3TC_DXT1_EXT;if(s===hE)return l.COMPRESSED_RGBA_S3TC_DXT1_EXT;if(s===pE)return l.COMPRESSED_RGBA_S3TC_DXT3_EXT;if(s===fE)return l.COMPRESSED_RGBA_S3TC_DXT5_EXT}else return null;if(s===iq||s===sq||s===oq||s===lq)if(l=e.get(\"WEBGL_compressed_texture_pvrtc\"),l!==null){if(s===iq)return l.COMPRESSED_RGB_PVRTC_4BPPV1_IMG;if(s===sq)return l.COMPRESSED_RGB_PVRTC_2BPPV1_IMG;if(s===oq)return l.COMPRESSED_RGBA_PVRTC_4BPPV1_IMG;if(s===lq)return l.COMPRESSED_RGBA_PVRTC_2BPPV1_IMG}else return null;if(s===rJ)return l=e.get(\"WEBGL_compressed_texture_etc1\"),l!==null?l.COMPRESSED_RGB_ETC1_WEBGL:null;if(s===cq||s===dq)if(l=e.get(\"WEBGL_compressed_texture_etc\"),l!==null){if(s===cq)return c===Sa?l.COMPRESSED_SRGB8_ETC2:l.COMPRESSED_RGB8_ETC2;if(s===dq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ETC2_EAC:l.COMPRESSED_RGBA8_ETC2_EAC}else return null;if(s===uq||s===mq||s===hq||s===pq||s===fq||s===gq||s===bq||s===xq||s===yq||s===vq||s===wq||s===Sq||s===_q||s===kq)if(l=e.get(\"WEBGL_compressed_texture_astc\"),l!==null){if(s===uq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_4x4_KHR:l.COMPRESSED_RGBA_ASTC_4x4_KHR;if(s===mq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_5x4_KHR:l.COMPRESSED_RGBA_ASTC_5x4_KHR;if(s===hq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_5x5_KHR:l.COMPRESSED_RGBA_ASTC_5x5_KHR;if(s===pq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_6x5_KHR:l.COMPRESSED_RGBA_ASTC_6x5_KHR;if(s===fq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_6x6_KHR:l.COMPRESSED_RGBA_ASTC_6x6_KHR;if(s===gq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_8x5_KHR:l.COMPRESSED_RGBA_ASTC_8x5_KHR;if(s===bq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_8x6_KHR:l.COMPRESSED_RGBA_ASTC_8x6_KHR;if(s===xq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_8x8_KHR:l.COMPRESSED_RGBA_ASTC_8x8_KHR;if(s===yq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_10x5_KHR:l.COMPRESSED_RGBA_ASTC_10x5_KHR;if(s===vq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_10x6_KHR:l.COMPRESSED_RGBA_ASTC_10x6_KHR;if(s===wq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_10x8_KHR:l.COMPRESSED_RGBA_ASTC_10x8_KHR;if(s===Sq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_10x10_KHR:l.COMPRESSED_RGBA_ASTC_10x10_KHR;if(s===_q)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_12x10_KHR:l.COMPRESSED_RGBA_ASTC_12x10_KHR;if(s===kq)return c===Sa?l.COMPRESSED_SRGB8_ALPHA8_ASTC_12x12_KHR:l.COMPRESSED_RGBA_ASTC_12x12_KHR}else return null;if(s===gE||s===Nq||s===Cq)if(l=e.get(\"EXT_texture_compression_bptc\"),l!==null){if(s===gE)return c===Sa?l.COMPRESSED_SRGB_ALPHA_BPTC_UNORM_EXT:l.COMPRESSED_RGBA_BPTC_UNORM_EXT;if(s===Nq)return l.COMPRESSED_RGB_BPTC_SIGNED_FLOAT_EXT;if(s===Cq)return l.COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT_EXT}else return null;if(s===NNe||s===Pq||s===Tq||s===Aq)if(l=e.get(\"EXT_texture_compression_rgtc\"),l!==null){if(s===gE)return l.COMPRESSED_RED_RGTC1_EXT;if(s===Pq)return l.COMPRESSED_SIGNED_RED_RGTC1_EXT;if(s===Tq)return l.COMPRESSED_RED_GREEN_RGTC2_EXT;if(s===Aq)return l.COMPRESSED_SIGNED_RED_GREEN_RGTC2_EXT}else return null;return s===Kf?r?t.UNSIGNED_INT_24_8:(l=e.get(\"WEBGL_depth_texture\"),l!==null?l.UNSIGNED_INT_24_8_WEBGL:null):t[s]!==void 0?t[s]:null}return{convert:i}}class Xje extends ll{constructor(e=[]){super(),this.isArrayCamera=!0,this.cameras=e}}class p0 extends lo{constructor(){super(),this.isGroup=!0,this.type=\"Group\"}}const Yje={type:\"move\"};class zE{constructor(){this._targetRay=null,this._grip=null,this._hand=null}getHandSpace(){return this._hand===null&&(this._hand=new p0,this._hand.matrixAutoUpdate=!1,this._hand.visible=!1,this._hand.joints={},this._hand.inputState={pinching:!1}),this._hand}getTargetRaySpace(){return this._targetRay===null&&(this._targetRay=new p0,this._targetRay.matrixAutoUpdate=!1,this._targetRay.visible=!1,this._targetRay.hasLinearVelocity=!1,this._targetRay.linearVelocity=new ut,this._targetRay.hasAngularVelocity=!1,this._targetRay.angularVelocity=new ut),this._targetRay}getGripSpace(){return this._grip===null&&(this._grip=new p0,this._grip.matrixAutoUpdate=!1,this._grip.visible=!1,this._grip.hasLinearVelocity=!1,this._grip.linearVelocity=new ut,this._grip.hasAngularVelocity=!1,this._grip.angularVelocity=new ut),this._grip}dispatchEvent(e){return this._targetRay!==null&&this._targetRay.dispatchEvent(e),this._grip!==null&&this._grip.dispatchEvent(e),this._hand!==null&&this._hand.dispatchEvent(e),this}connect(e){if(e&&e.hand){const n=this._hand;if(n)for(const r of e.hand.values())this._getHandJoint(n,r)}return this.dispatchEvent({type:\"connected\",data:e}),this}disconnect(e){return this.dispatchEvent({type:\"disconnected\",data:e}),this._targetRay!==null&&(this._targetRay.visible=!1),this._grip!==null&&(this._grip.visible=!1),this._hand!==null&&(this._hand.visible=!1),this}update(e,n,r){let i=null,s=null,o=null;const l=this._targetRay,c=this._grip,d=this._hand;if(e&&n.session.visibilityState!==\"visible-blurred\"){if(d&&e.hand){o=!0;for(const v of e.hand.values()){const b=n.getJointPose(v,r),g=this._getHandJoint(d,v);b!==null&&(g.matrix.fromArray(b.transform.matrix),g.matrix.decompose(g.position,g.rotation,g.scale),g.matrixWorldNeedsUpdate=!0,g.jointRadius=b.radius),g.visible=b!==null}const u=d.joints[\"index-finger-tip\"],m=d.joints[\"thumb-tip\"],p=u.position.distanceTo(m.position),f=.02,y=.005;d.inputState.pinching&&p>f+y?(d.inputState.pinching=!1,this.dispatchEvent({type:\"pinchend\",handedness:e.handedness,target:this})):!d.inputState.pinching&&p<=f-y&&(d.inputState.pinching=!0,this.dispatchEvent({type:\"pinchstart\",handedness:e.handedness,target:this}))}else c!==null&&e.gripSpace&&(s=n.getPose(e.gripSpace,r),s!==null&&(c.matrix.fromArray(s.transform.matrix),c.matrix.decompose(c.position,c.rotation,c.scale),c.matrixWorldNeedsUpdate=!0,s.linearVelocity?(c.hasLinearVelocity=!0,c.linearVelocity.copy(s.linearVelocity)):c.hasLinearVelocity=!1,s.angularVelocity?(c.hasAngularVelocity=!0,c.angularVelocity.copy(s.angularVelocity)):c.hasAngularVelocity=!1));l!==null&&(i=n.getPose(e.targetRaySpace,r),i===null&&s!==null&&(i=s),i!==null&&(l.matrix.fromArray(i.transform.matrix),l.matrix.decompose(l.position,l.rotation,l.scale),l.matrixWorldNeedsUpdate=!0,i.linearVelocity?(l.hasLinearVelocity=!0,l.linearVelocity.copy(i.linearVelocity)):l.hasLinearVelocity=!1,i.angularVelocity?(l.hasAngularVelocity=!0,l.angularVelocity.copy(i.angularVelocity)):l.hasAngularVelocity=!1,this.dispatchEvent(Yje)))}return l!==null&&(l.visible=i!==null),c!==null&&(c.visible=s!==null),d!==null&&(d.visible=o!==null),this}_getHandJoint(e,n){if(e.joints[n.jointName]===void 0){const r=new p0;r.matrixAutoUpdate=!1,r.visible=!1,e.joints[n.jointName]=r,e.add(r)}return e.joints[n.jointName]}}class Qje extends Bg{constructor(e,n){super();const r=this;let i=null,s=1,o=null,l=\"local-floor\",c=1,d=null,u=null,m=null,p=null,f=null,y=null;const v=n.getContextAttributes();let b=null,g=null;const _=[],C=[],P=new Vn;let N=null;const A=new ll;A.layers.enable(1),A.viewport=new ta;const T=new ll;T.layers.enable(2),T.viewport=new ta;const F=[A,T],k=new Xje;k.layers.enable(1),k.layers.enable(2);let D=null,H=null;this.cameraAutoUpdate=!0,this.enabled=!1,this.isPresenting=!1,this.getController=function(me){let be=_[me];return be===void 0&&(be=new zE,_[me]=be),be.getTargetRaySpace()},this.getControllerGrip=function(me){let be=_[me];return be===void 0&&(be=new zE,_[me]=be),be.getGripSpace()},this.getHand=function(me){let be=_[me];return be===void 0&&(be=new zE,_[me]=be),be.getHandSpace()};function z(me){const be=C.indexOf(me.inputSource);if(be===-1)return;const Ce=_[be];Ce!==void 0&&(Ce.update(me.inputSource,me.frame,d||o),Ce.dispatchEvent({type:me.type,data:me.inputSource}))}function Q(){i.removeEventListener(\"select\",z),i.removeEventListener(\"selectstart\",z),i.removeEventListener(\"selectend\",z),i.removeEventListener(\"squeeze\",z),i.removeEventListener(\"squeezestart\",z),i.removeEventListener(\"squeezeend\",z),i.removeEventListener(\"end\",Q),i.removeEventListener(\"inputsourceschange\",L);for(let me=0;me<_.length;me++){const be=C[me];be!==null&&(C[me]=null,_[me].disconnect(be))}D=null,H=null,e.setRenderTarget(b),f=null,p=null,m=null,i=null,g=null,ne.stop(),r.isPresenting=!1,e.setPixelRatio(N),e.setSize(P.width,P.height,!1),r.dispatchEvent({type:\"sessionend\"})}this.setFramebufferScaleFactor=function(me){s=me,r.isPresenting===!0&&console.warn(\"THREE.WebXRManager: Cannot change framebuffer scale while presenting.\")},this.setReferenceSpaceType=function(me){l=me,r.isPresenting===!0&&console.warn(\"THREE.WebXRManager: Cannot change reference space type while presenting.\")},this.getReferenceSpace=function(){return d||o},this.setReferenceSpace=function(me){d=me},this.getBaseLayer=function(){return p!==null?p:f},this.getBinding=function(){return m},this.getFrame=function(){return y},this.getSession=function(){return i},this.setSession=async function(me){if(i=me,i!==null){if(b=e.getRenderTarget(),i.addEventListener(\"select\",z),i.addEventListener(\"selectstart\",z),i.addEventListener(\"selectend\",z),i.addEventListener(\"squeeze\",z),i.addEventListener(\"squeezestart\",z),i.addEventListener(\"squeezeend\",z),i.addEventListener(\"end\",Q),i.addEventListener(\"inputsourceschange\",L),v.xrCompatible!==!0&&await n.makeXRCompatible(),N=e.getPixelRatio(),e.getSize(P),i.renderState.layers===void 0||e.capabilities.isWebGL2===!1){const be={antialias:i.renderState.layers===void 0?v.antialias:!0,alpha:!0,depth:v.depth,stencil:v.stencil,framebufferScaleFactor:s};f=new XRWebGLLayer(i,n,be),i.updateRenderState({baseLayer:f}),e.setPixelRatio(1),e.setSize(f.framebufferWidth,f.framebufferHeight,!1),g=new xg(f.framebufferWidth,f.framebufferHeight,{format:Yl,type:cp,colorSpace:e.outputColorSpace,stencilBuffer:v.stencil})}else{let be=null,Ce=null,q=null;v.depth&&(q=v.stencil?n.DEPTH24_STENCIL8:n.DEPTH_COMPONENT24,be=v.stencil?Zx:Xf,Ce=v.stencil?Kf:qh);const Y={colorFormat:n.RGBA8,depthFormat:q,scaleFactor:s};m=new XRWebGLBinding(i,n),p=m.createProjectionLayer(Y),i.updateRenderState({layers:[p]}),e.setPixelRatio(1),e.setSize(p.textureWidth,p.textureHeight,!1),g=new xg(p.textureWidth,p.textureHeight,{format:Yl,type:cp,depthTexture:new wJ(p.textureWidth,p.textureHeight,Ce,void 0,void 0,void 0,void 0,void 0,void 0,be),stencilBuffer:v.stencil,colorSpace:e.outputColorSpace,samples:v.antialias?4:0});const E=e.properties.get(g);E.__ignoreDepthValues=p.ignoreDepthValues}g.isXRRenderTarget=!0,this.setFoveation(c),d=null,o=await i.requestReferenceSpace(l),ne.setContext(i),ne.start(),r.isPresenting=!0,r.dispatchEvent({type:\"sessionstart\"})}},this.getEnvironmentBlendMode=function(){if(i!==null)return i.environmentBlendMode};function L(me){for(let be=0;be<me.removed.length;be++){const Ce=me.removed[be],q=C.indexOf(Ce);q>=0&&(C[q]=null,_[q].disconnect(Ce))}for(let be=0;be<me.added.length;be++){const Ce=me.added[be];let q=C.indexOf(Ce);if(q===-1){for(let E=0;E<_.length;E++)if(E>=C.length){C.push(Ce),q=E;break}else if(C[E]===null){C[E]=Ce,q=E;break}if(q===-1)break}const Y=_[q];Y&&Y.connect(Ce)}}const te=new ut,ie=new ut;function J(me,be,Ce){te.setFromMatrixPosition(be.matrixWorld),ie.setFromMatrixPosition(Ce.matrixWorld);const q=te.distanceTo(ie),Y=be.projectionMatrix.elements,E=Ce.projectionMatrix.elements,j=Y[14]/(Y[10]-1),O=Y[14]/(Y[10]+1),K=(Y[9]+1)/Y[5],U=(Y[9]-1)/Y[5],de=(Y[8]-1)/Y[0],I=(E[8]+1)/E[0],G=j*de,X=j*I,V=q/(-de+I),ee=V*-de;be.matrixWorld.decompose(me.position,me.quaternion,me.scale),me.translateX(ee),me.translateZ(V),me.matrixWorld.compose(me.position,me.quaternion,me.scale),me.matrixWorldInverse.copy(me.matrixWorld).invert();const se=j+V,ge=O+V,he=G-ee,le=X+(q-ee),B=K*O/ge*se,R=U*O/ge*se;me.projectionMatrix.makePerspective(he,le,B,R,se,ge),me.projectionMatrixInverse.copy(me.projectionMatrix).invert()}function oe(me,be){be===null?me.matrixWorld.copy(me.matrix):me.matrixWorld.multiplyMatrices(be.matrixWorld,me.matrix),me.matrixWorldInverse.copy(me.matrixWorld).invert()}this.updateCamera=function(me){if(i===null)return;k.near=T.near=A.near=me.near,k.far=T.far=A.far=me.far,(D!==k.near||H!==k.far)&&(i.updateRenderState({depthNear:k.near,depthFar:k.far}),D=k.near,H=k.far);const be=me.parent,Ce=k.cameras;oe(k,be);for(let q=0;q<Ce.length;q++)oe(Ce[q],be);Ce.length===2?J(k,A,T):k.projectionMatrix.copy(A.projectionMatrix),fe(me,k,be)};function fe(me,be,Ce){Ce===null?me.matrix.copy(be.matrixWorld):(me.matrix.copy(Ce.matrixWorld),me.matrix.invert(),me.matrix.multiply(be.matrixWorld)),me.matrix.decompose(me.position,me.quaternion,me.scale),me.updateMatrixWorld(!0),me.projectionMatrix.copy(be.projectionMatrix),me.projectionMatrixInverse.copy(be.projectionMatrixInverse),me.isPerspectiveCamera&&(me.fov=gw*2*Math.atan(1/me.projectionMatrix.elements[5]),me.zoom=1)}this.getCamera=function(){return k},this.getFoveation=function(){if(!(p===null&&f===null))return c},this.setFoveation=function(me){c=me,p!==null&&(p.fixedFoveation=me),f!==null&&f.fixedFoveation!==void 0&&(f.fixedFoveation=me)};let re=null;function W(me,be){if(u=be.getViewerPose(d||o),y=be,u!==null){const Ce=u.views;f!==null&&(e.setRenderTargetFramebuffer(g,f.framebuffer),e.setRenderTarget(g));let q=!1;Ce.length!==k.cameras.length&&(k.cameras.length=0,q=!0);for(let Y=0;Y<Ce.length;Y++){const E=Ce[Y];let j=null;if(f!==null)j=f.getViewport(E);else{const K=m.getViewSubImage(p,E);j=K.viewport,Y===0&&(e.setRenderTargetTextures(g,K.colorTexture,p.ignoreDepthValues?void 0:K.depthStencilTexture),e.setRenderTarget(g))}let O=F[Y];O===void 0&&(O=new ll,O.layers.enable(Y),O.viewport=new ta,F[Y]=O),O.matrix.fromArray(E.transform.matrix),O.matrix.decompose(O.position,O.quaternion,O.scale),O.projectionMatrix.fromArray(E.projectionMatrix),O.projectionMatrixInverse.copy(O.projectionMatrix).invert(),O.viewport.set(j.x,j.y,j.width,j.height),Y===0&&(k.matrix.copy(O.matrix),k.matrix.decompose(k.position,k.quaternion,k.scale)),q===!0&&k.cameras.push(O)}}for(let Ce=0;Ce<_.length;Ce++){const q=C[Ce],Y=_[Ce];q!==null&&Y!==void 0&&Y.update(q,be,d||o)}re&&re(me,be),be.detectedPlanes&&r.dispatchEvent({type:\"planesdetected\",data:be}),y=null}const ne=new vJ;ne.setAnimationLoop(W),this.setAnimationLoop=function(me){re=me},this.dispose=function(){}}}function Zje(t,e){function n(b,g){b.matrixAutoUpdate===!0&&b.updateMatrix(),g.value.copy(b.matrix)}function r(b,g){g.color.getRGB(b.fogColor.value,bJ(t)),g.isFog?(b.fogNear.value=g.near,b.fogFar.value=g.far):g.isFogExp2&&(b.fogDensity.value=g.density)}function i(b,g,_,C,P){g.isMeshBasicMaterial||g.isMeshLambertMaterial?s(b,g):g.isMeshToonMaterial?(s(b,g),m(b,g)):g.isMeshPhongMaterial?(s(b,g),u(b,g)):g.isMeshStandardMaterial?(s(b,g),p(b,g),g.isMeshPhysicalMaterial&&f(b,g,P)):g.isMeshMatcapMaterial?(s(b,g),y(b,g)):g.isMeshDepthMaterial?s(b,g):g.isMeshDistanceMaterial?(s(b,g),v(b,g)):g.isMeshNormalMaterial?s(b,g):g.isLineBasicMaterial?(o(b,g),g.isLineDashedMaterial&&l(b,g)):g.isPointsMaterial?c(b,g,_,C):g.isSpriteMaterial?d(b,g):g.isShadowMaterial?(b.color.value.copy(g.color),b.opacity.value=g.opacity):g.isShaderMaterial&&(g.uniformsNeedUpdate=!1)}function s(b,g){b.opacity.value=g.opacity,g.color&&b.diffuse.value.copy(g.color),g.emissive&&b.emissive.value.copy(g.emissive).multiplyScalar(g.emissiveIntensity),g.map&&(b.map.value=g.map,n(g.map,b.mapTransform)),g.alphaMap&&(b.alphaMap.value=g.alphaMap,n(g.alphaMap,b.alphaMapTransform)),g.bumpMap&&(b.bumpMap.value=g.bumpMap,n(g.bumpMap,b.bumpMapTransform),b.bumpScale.value=g.bumpScale,g.side===Uo&&(b.bumpScale.value*=-1)),g.normalMap&&(b.normalMap.value=g.normalMap,n(g.normalMap,b.normalMapTransform),b.normalScale.value.copy(g.normalScale),g.side===Uo&&b.normalScale.value.negate()),g.displacementMap&&(b.displacementMap.value=g.displacementMap,n(g.displacementMap,b.displacementMapTransform),b.displacementScale.value=g.displacementScale,b.displacementBias.value=g.displacementBias),g.emissiveMap&&(b.emissiveMap.value=g.emissiveMap,n(g.emissiveMap,b.emissiveMapTransform)),g.specularMap&&(b.specularMap.value=g.specularMap,n(g.specularMap,b.specularMapTransform)),g.alphaTest>0&&(b.alphaTest.value=g.alphaTest);const _=e.get(g).envMap;if(_&&(b.envMap.value=_,b.flipEnvMap.value=_.isCubeTexture&&_.isRenderTargetTexture===!1?-1:1,b.reflectivity.value=g.reflectivity,b.ior.value=g.ior,b.refractionRatio.value=g.refractionRatio),g.lightMap){b.lightMap.value=g.lightMap;const C=t._useLegacyLights===!0?Math.PI:1;b.lightMapIntensity.value=g.lightMapIntensity*C,n(g.lightMap,b.lightMapTransform)}g.aoMap&&(b.aoMap.value=g.aoMap,b.aoMapIntensity.value=g.aoMapIntensity,n(g.aoMap,b.aoMapTransform))}function o(b,g){b.diffuse.value.copy(g.color),b.opacity.value=g.opacity,g.map&&(b.map.value=g.map,n(g.map,b.mapTransform))}function l(b,g){b.dashSize.value=g.dashSize,b.totalSize.value=g.dashSize+g.gapSize,b.scale.value=g.scale}function c(b,g,_,C){b.diffuse.value.copy(g.color),b.opacity.value=g.opacity,b.size.value=g.size*_,b.scale.value=C*.5,g.map&&(b.map.value=g.map,n(g.map,b.uvTransform)),g.alphaMap&&(b.alphaMap.value=g.alphaMap,n(g.alphaMap,b.alphaMapTransform)),g.alphaTest>0&&(b.alphaTest.value=g.alphaTest)}function d(b,g){b.diffuse.value.copy(g.color),b.opacity.value=g.opacity,b.rotation.value=g.rotation,g.map&&(b.map.value=g.map,n(g.map,b.mapTransform)),g.alphaMap&&(b.alphaMap.value=g.alphaMap,n(g.alphaMap,b.alphaMapTransform)),g.alphaTest>0&&(b.alphaTest.value=g.alphaTest)}function u(b,g){b.specular.value.copy(g.specular),b.shininess.value=Math.max(g.shininess,1e-4)}function m(b,g){g.gradientMap&&(b.gradientMap.value=g.gradientMap)}function p(b,g){b.metalness.value=g.metalness,g.metalnessMap&&(b.metalnessMap.value=g.metalnessMap,n(g.metalnessMap,b.metalnessMapTransform)),b.roughness.value=g.roughness,g.roughnessMap&&(b.roughnessMap.value=g.roughnessMap,n(g.roughnessMap,b.roughnessMapTransform)),e.get(g).envMap&&(b.envMapIntensity.value=g.envMapIntensity)}function f(b,g,_){b.ior.value=g.ior,g.sheen>0&&(b.sheenColor.value.copy(g.sheenColor).multiplyScalar(g.sheen),b.sheenRoughness.value=g.sheenRoughness,g.sheenColorMap&&(b.sheenColorMap.value=g.sheenColorMap,n(g.sheenColorMap,b.sheenColorMapTransform)),g.sheenRoughnessMap&&(b.sheenRoughnessMap.value=g.sheenRoughnessMap,n(g.sheenRoughnessMap,b.sheenRoughnessMapTransform))),g.clearcoat>0&&(b.clearcoat.value=g.clearcoat,b.clearcoatRoughness.value=g.clearcoatRoughness,g.clearcoatMap&&(b.clearcoatMap.value=g.clearcoatMap,n(g.clearcoatMap,b.clearcoatMapTransform)),g.clearcoatRoughnessMap&&(b.clearcoatRoughnessMap.value=g.clearcoatRoughnessMap,n(g.clearcoatRoughnessMap,b.clearcoatRoughnessMapTransform)),g.clearcoatNormalMap&&(b.clearcoatNormalMap.value=g.clearcoatNormalMap,n(g.clearcoatNormalMap,b.clearcoatNormalMapTransform),b.clearcoatNormalScale.value.copy(g.clearcoatNormalScale),g.side===Uo&&b.clearcoatNormalScale.value.negate())),g.iridescence>0&&(b.iridescence.value=g.iridescence,b.iridescenceIOR.value=g.iridescenceIOR,b.iridescenceThicknessMinimum.value=g.iridescenceThicknessRange[0],b.iridescenceThicknessMaximum.value=g.iridescenceThicknessRange[1],g.iridescenceMap&&(b.iridescenceMap.value=g.iridescenceMap,n(g.iridescenceMap,b.iridescenceMapTransform)),g.iridescenceThicknessMap&&(b.iridescenceThicknessMap.value=g.iridescenceThicknessMap,n(g.iridescenceThicknessMap,b.iridescenceThicknessMapTransform))),g.transmission>0&&(b.transmission.value=g.transmission,b.transmissionSamplerMap.value=_.texture,b.transmissionSamplerSize.value.set(_.width,_.height),g.transmissionMap&&(b.transmissionMap.value=g.transmissionMap,n(g.transmissionMap,b.transmissionMapTransform)),b.thickness.value=g.thickness,g.thicknessMap&&(b.thicknessMap.value=g.thicknessMap,n(g.thicknessMap,b.thicknessMapTransform)),b.attenuationDistance.value=g.attenuationDistance,b.attenuationColor.value.copy(g.attenuationColor)),g.anisotropy>0&&(b.anisotropyVector.value.set(g.anisotropy*Math.cos(g.anisotropyRotation),g.anisotropy*Math.sin(g.anisotropyRotation)),g.anisotropyMap&&(b.anisotropyMap.value=g.anisotropyMap,n(g.anisotropyMap,b.anisotropyMapTransform))),b.specularIntensity.value=g.specularIntensity,b.specularColor.value.copy(g.specularColor),g.specularColorMap&&(b.specularColorMap.value=g.specularColorMap,n(g.specularColorMap,b.specularColorMapTransform)),g.specularIntensityMap&&(b.specularIntensityMap.value=g.specularIntensityMap,n(g.specularIntensityMap,b.specularIntensityMapTransform))}function y(b,g){g.matcap&&(b.matcap.value=g.matcap)}function v(b,g){const _=e.get(g).light;b.referencePosition.value.setFromMatrixPosition(_.matrixWorld),b.nearDistance.value=_.shadow.camera.near,b.farDistance.value=_.shadow.camera.far}return{refreshFogUniforms:r,refreshMaterialUniforms:i}}function Jje(t,e,n,r){let i={},s={},o=[];const l=n.isWebGL2?t.getParameter(t.MAX_UNIFORM_BUFFER_BINDINGS):0;function c(_,C){const P=C.program;r.uniformBlockBinding(_,P)}function d(_,C){let P=i[_.id];P===void 0&&(y(_),P=u(_),i[_.id]=P,_.addEventListener(\"dispose\",b));const N=C.program;r.updateUBOMapping(_,N);const A=e.render.frame;s[_.id]!==A&&(p(_),s[_.id]=A)}function u(_){const C=m();_.__bindingPointIndex=C;const P=t.createBuffer(),N=_.__size,A=_.usage;return t.bindBuffer(t.UNIFORM_BUFFER,P),t.bufferData(t.UNIFORM_BUFFER,N,A),t.bindBuffer(t.UNIFORM_BUFFER,null),t.bindBufferBase(t.UNIFORM_BUFFER,C,P),P}function m(){for(let _=0;_<l;_++)if(o.indexOf(_)===-1)return o.push(_),_;return console.error(\"THREE.WebGLRenderer: Maximum number of simultaneously usable uniforms groups reached.\"),0}function p(_){const C=i[_.id],P=_.uniforms,N=_.__cache;t.bindBuffer(t.UNIFORM_BUFFER,C);for(let A=0,T=P.length;A<T;A++){const F=P[A];if(f(F,A,N)===!0){const k=F.__offset,D=Array.isArray(F.value)?F.value:[F.value];let H=0;for(let z=0;z<D.length;z++){const Q=D[z],L=v(Q);typeof Q==\"number\"?(F.__data[0]=Q,t.bufferSubData(t.UNIFORM_BUFFER,k+H,F.__data)):Q.isMatrix3?(F.__data[0]=Q.elements[0],F.__data[1]=Q.elements[1],F.__data[2]=Q.elements[2],F.__data[3]=Q.elements[0],F.__data[4]=Q.elements[3],F.__data[5]=Q.elements[4],F.__data[6]=Q.elements[5],F.__data[7]=Q.elements[0],F.__data[8]=Q.elements[6],F.__data[9]=Q.elements[7],F.__data[10]=Q.elements[8],F.__data[11]=Q.elements[0]):(Q.toArray(F.__data,H),H+=L.storage/Float32Array.BYTES_PER_ELEMENT)}t.bufferSubData(t.UNIFORM_BUFFER,k,F.__data)}}t.bindBuffer(t.UNIFORM_BUFFER,null)}function f(_,C,P){const N=_.value;if(P[C]===void 0){if(typeof N==\"number\")P[C]=N;else{const A=Array.isArray(N)?N:[N],T=[];for(let F=0;F<A.length;F++)T.push(A[F].clone());P[C]=T}return!0}else if(typeof N==\"number\"){if(P[C]!==N)return P[C]=N,!0}else{const A=Array.isArray(P[C])?P[C]:[P[C]],T=Array.isArray(N)?N:[N];for(let F=0;F<A.length;F++){const k=A[F];if(k.equals(T[F])===!1)return k.copy(T[F]),!0}}return!1}function y(_){const C=_.uniforms;let P=0;const N=16;let A=0;for(let T=0,F=C.length;T<F;T++){const k=C[T],D={boundary:0,storage:0},H=Array.isArray(k.value)?k.value:[k.value];for(let z=0,Q=H.length;z<Q;z++){const L=H[z],te=v(L);D.boundary+=te.boundary,D.storage+=te.storage}if(k.__data=new Float32Array(D.storage/Float32Array.BYTES_PER_ELEMENT),k.__offset=P,T>0){A=P%N;const z=N-A;A!==0&&z-D.boundary<0&&(P+=N-A,k.__offset=P)}P+=D.storage}return A=P%N,A>0&&(P+=N-A),_.__size=P,_.__cache={},this}function v(_){const C={boundary:0,storage:0};return typeof _==\"number\"?(C.boundary=4,C.storage=4):_.isVector2?(C.boundary=8,C.storage=8):_.isVector3||_.isColor?(C.boundary=16,C.storage=12):_.isVector4?(C.boundary=16,C.storage=16):_.isMatrix3?(C.boundary=48,C.storage=48):_.isMatrix4?(C.boundary=64,C.storage=64):_.isTexture?console.warn(\"THREE.WebGLRenderer: Texture samplers can not be part of an uniforms group.\"):console.warn(\"THREE.WebGLRenderer: Unsupported uniform value type.\",_),C}function b(_){const C=_.target;C.removeEventListener(\"dispose\",b);const P=o.indexOf(C.__bindingPointIndex);o.splice(P,1),t.deleteBuffer(i[C.id]),delete i[C.id],delete s[C.id]}function g(){for(const _ in i)t.deleteBuffer(i[_]);o=[],i={},s={}}return{bind:c,update:d,dispose:g}}class gR{constructor(e={}){const{canvas:n=YNe(),context:r=null,depth:i=!0,stencil:s=!0,alpha:o=!1,antialias:l=!1,premultipliedAlpha:c=!0,preserveDrawingBuffer:d=!1,powerPreference:u=\"default\",failIfMajorPerformanceCaveat:m=!1}=e;this.isWebGLRenderer=!0;let p;r!==null?p=r.getContextAttributes().alpha:p=o;const f=new Uint32Array(4),y=new Int32Array(4);let v=null,b=null;const g=[],_=[];this.domElement=n,this.debug={checkShaderErrors:!0,onShaderError:null},this.autoClear=!0,this.autoClearColor=!0,this.autoClearDepth=!0,this.autoClearStencil=!0,this.sortObjects=!0,this.clippingPlanes=[],this.localClippingEnabled=!1,this._outputColorSpace=bs,this._useLegacyLights=!1,this.toneMapping=lp,this.toneMappingExposure=1;const C=this;let P=!1,N=0,A=0,T=null,F=-1,k=null;const D=new ta,H=new ta;let z=null;const Q=new Mn(0);let L=0,te=n.width,ie=n.height,J=1,oe=null,fe=null;const re=new ta(0,0,te,ie),W=new ta(0,0,te,ie);let ne=!1;const me=new nT;let be=!1,Ce=!1,q=null;const Y=new ba,E=new Vn,j=new ut,O={background:null,fog:null,environment:null,overrideMaterial:null,isScene:!0};function K(){return T===null?J:1}let U=r;function de(Ie,mt){for(let pt=0;pt<Ie.length;pt++){const nt=Ie[pt],ze=n.getContext(nt,mt);if(ze!==null)return ze}return null}try{const Ie={alpha:!0,depth:i,stencil:s,antialias:l,premultipliedAlpha:c,preserveDrawingBuffer:d,powerPreference:u,failIfMajorPerformanceCaveat:m};if(\"setAttribute\"in n&&n.setAttribute(\"data-engine\",`three.js r${JP}`),n.addEventListener(\"webglcontextlost\",Fe,!1),n.addEventListener(\"webglcontextrestored\",we,!1),n.addEventListener(\"webglcontextcreationerror\",Ve,!1),U===null){const mt=[\"webgl2\",\"webgl\",\"experimental-webgl\"];if(C.isWebGL1Renderer===!0&&mt.shift(),U=de(mt,Ie),U===null)throw de(mt)?new Error(\"Error creating WebGL context with your selected attributes.\"):new Error(\"Error creating WebGL context.\")}typeof WebGLRenderingContext<\"u\"&&U instanceof WebGLRenderingContext&&console.warn(\"THREE.WebGLRenderer: WebGL 1 support was deprecated in r153 and will be removed in r163.\"),U.getShaderPrecisionFormat===void 0&&(U.getShaderPrecisionFormat=function(){return{rangeMin:1,rangeMax:1,precision:1}})}catch(Ie){throw console.error(\"THREE.WebGLRenderer: \"+Ie.message),Ie}let I,G,X,V,ee,se,ge,he,le,B,R,ae,_e,Se,ve,Te,ye,je,Le,Me,Oe,Re,$e,Ye;function tt(){I=new dAe(U),G=new rAe(U,I,e),I.init(G),Re=new Kje(U,I,G),X=new Gje(U,I,G),V=new hAe(U),ee=new Eje,se=new Wje(U,I,X,ee,G,Re,V),ge=new iAe(C),he=new cAe(C),le=new vCe(U,G),$e=new tAe(U,I,le,G),B=new uAe(U,le,V,$e),R=new bAe(U,B,le,V),Le=new gAe(U,G,se),Te=new aAe(ee),ae=new Mje(C,ge,he,I,G,$e,Te),_e=new Zje(C,ee),Se=new Fje,ve=new Uje(I,G),je=new eAe(C,ge,he,X,R,p,c),ye=new Vje(C,R,G),Ye=new Jje(U,V,G,X),Me=new nAe(U,I,V,G),Oe=new mAe(U,I,V,G),V.programs=ae.programs,C.capabilities=G,C.extensions=I,C.properties=ee,C.renderLists=Se,C.shadowMap=ye,C.state=X,C.info=V}tt();const pe=new Qje(C,U);this.xr=pe,this.getContext=function(){return U},this.getContextAttributes=function(){return U.getContextAttributes()},this.forceContextLoss=function(){const Ie=I.get(\"WEBGL_lose_context\");Ie&&Ie.loseContext()},this.forceContextRestore=function(){const Ie=I.get(\"WEBGL_lose_context\");Ie&&Ie.restoreContext()},this.getPixelRatio=function(){return J},this.setPixelRatio=function(Ie){Ie!==void 0&&(J=Ie,this.setSize(te,ie,!1))},this.getSize=function(Ie){return Ie.set(te,ie)},this.setSize=function(Ie,mt,pt=!0){if(pe.isPresenting){console.warn(\"THREE.WebGLRenderer: Can't change size while VR device is presenting.\");return}te=Ie,ie=mt,n.width=Math.floor(Ie*J),n.height=Math.floor(mt*J),pt===!0&&(n.style.width=Ie+\"px\",n.style.height=mt+\"px\"),this.setViewport(0,0,Ie,mt)},this.getDrawingBufferSize=function(Ie){return Ie.set(te*J,ie*J).floor()},this.setDrawingBufferSize=function(Ie,mt,pt){te=Ie,ie=mt,J=pt,n.width=Math.floor(Ie*pt),n.height=Math.floor(mt*pt),this.setViewport(0,0,Ie,mt)},this.getCurrentViewport=function(Ie){return Ie.copy(D)},this.getViewport=function(Ie){return Ie.copy(re)},this.setViewport=function(Ie,mt,pt,nt){Ie.isVector4?re.set(Ie.x,Ie.y,Ie.z,Ie.w):re.set(Ie,mt,pt,nt),X.viewport(D.copy(re).multiplyScalar(J).floor())},this.getScissor=function(Ie){return Ie.copy(W)},this.setScissor=function(Ie,mt,pt,nt){Ie.isVector4?W.set(Ie.x,Ie.y,Ie.z,Ie.w):W.set(Ie,mt,pt,nt),X.scissor(H.copy(W).multiplyScalar(J).floor())},this.getScissorTest=function(){return ne},this.setScissorTest=function(Ie){X.setScissorTest(ne=Ie)},this.setOpaqueSort=function(Ie){oe=Ie},this.setTransparentSort=function(Ie){fe=Ie},this.getClearColor=function(Ie){return Ie.copy(je.getClearColor())},this.setClearColor=function(){je.setClearColor.apply(je,arguments)},this.getClearAlpha=function(){return je.getClearAlpha()},this.setClearAlpha=function(){je.setClearAlpha.apply(je,arguments)},this.clear=function(Ie=!0,mt=!0,pt=!0){let nt=0;if(Ie){let ze=!1;if(T!==null){const ot=T.texture.format;ze=ot===nJ||ot===tJ||ot===eJ}if(ze){const ot=T.texture.type,Pe=ot===cp||ot===qh||ot===lI||ot===Kf||ot===ZZ||ot===JZ,Ge=je.getClearColor(),Ze=je.getClearAlpha(),Je=Ge.r,We=Ge.g,Ue=Ge.b;Pe?(f[0]=Je,f[1]=We,f[2]=Ue,f[3]=Ze,U.clearBufferuiv(U.COLOR,0,f)):(y[0]=Je,y[1]=We,y[2]=Ue,y[3]=Ze,U.clearBufferiv(U.COLOR,0,y))}else nt|=U.COLOR_BUFFER_BIT}mt&&(nt|=U.DEPTH_BUFFER_BIT),pt&&(nt|=U.STENCIL_BUFFER_BIT,this.state.buffers.stencil.setMask(4294967295)),U.clear(nt)},this.clearColor=function(){this.clear(!0,!1,!1)},this.clearDepth=function(){this.clear(!1,!0,!1)},this.clearStencil=function(){this.clear(!1,!1,!0)},this.dispose=function(){n.removeEventListener(\"webglcontextlost\",Fe,!1),n.removeEventListener(\"webglcontextrestored\",we,!1),n.removeEventListener(\"webglcontextcreationerror\",Ve,!1),Se.dispose(),ve.dispose(),ee.dispose(),ge.dispose(),he.dispose(),R.dispose(),$e.dispose(),Ye.dispose(),ae.dispose(),pe.dispose(),pe.removeEventListener(\"sessionstart\",xt),pe.removeEventListener(\"sessionend\",gt),q&&(q.dispose(),q=null),Ut.stop()};function Fe(Ie){Ie.preventDefault(),console.log(\"THREE.WebGLRenderer: Context Lost.\"),P=!0}function we(){console.log(\"THREE.WebGLRenderer: Context Restored.\"),P=!1;const Ie=V.autoReset,mt=ye.enabled,pt=ye.autoUpdate,nt=ye.needsUpdate,ze=ye.type;tt(),V.autoReset=Ie,ye.enabled=mt,ye.autoUpdate=pt,ye.needsUpdate=nt,ye.type=ze}function Ve(Ie){console.error(\"THREE.WebGLRenderer: A WebGL context could not be created. Reason: \",Ie.statusMessage)}function Ae(Ie){const mt=Ie.target;mt.removeEventListener(\"dispose\",Ae),ce(mt)}function ce(Ie){xe(Ie),ee.remove(Ie)}function xe(Ie){const mt=ee.get(Ie).programs;mt!==void 0&&(mt.forEach(function(pt){ae.releaseProgram(pt)}),Ie.isShaderMaterial&&ae.releaseShaderCache(Ie))}this.renderBufferDirect=function(Ie,mt,pt,nt,ze,ot){mt===null&&(mt=O);const Pe=ze.isMesh&&ze.matrixWorld.determinant()<0,Ge=Lt(Ie,mt,pt,nt,ze);X.setMaterial(nt,Pe);let Ze=pt.index,Je=1;if(nt.wireframe===!0){if(Ze=B.getWireframeAttribute(pt),Ze===void 0)return;Je=2}const We=pt.drawRange,Ue=pt.attributes.position;let et=We.start*Je,jt=(We.start+We.count)*Je;ot!==null&&(et=Math.max(et,ot.start*Je),jt=Math.min(jt,(ot.start+ot.count)*Je)),Ze!==null?(et=Math.max(et,0),jt=Math.min(jt,Ze.count)):Ue!=null&&(et=Math.max(et,0),jt=Math.min(jt,Ue.count));const yt=jt-et;if(yt<0||yt===1/0)return;$e.setup(ze,nt,Ge,pt,Ze);let qe,St=Me;if(Ze!==null&&(qe=le.get(Ze),St=Oe,St.setIndex(qe)),ze.isMesh)nt.wireframe===!0?(X.setLineWidth(nt.wireframeLinewidth*K()),St.setMode(U.LINES)):St.setMode(U.TRIANGLES);else if(ze.isLine){let Pt=nt.linewidth;Pt===void 0&&(Pt=1),X.setLineWidth(Pt*K()),ze.isLineSegments?St.setMode(U.LINES):ze.isLineLoop?St.setMode(U.LINE_LOOP):St.setMode(U.LINE_STRIP)}else ze.isPoints?St.setMode(U.POINTS):ze.isSprite&&St.setMode(U.TRIANGLES);if(ze.isBatchedMesh)St.renderMultiDraw(ze._multiDrawStarts,ze._multiDrawCounts,ze._multiDrawCount);else if(ze.isInstancedMesh)St.renderInstances(et,yt,ze.count);else if(pt.isInstancedBufferGeometry){const Pt=pt._maxInstanceCount!==void 0?pt._maxInstanceCount:1/0,qt=Math.min(pt.instanceCount,Pt);St.renderInstances(et,yt,qt)}else St.render(et,yt)};function Be(Ie,mt,pt){Ie.transparent===!0&&Ie.side===Ku&&Ie.forceSinglePass===!1?(Ie.side=Uo,Ie.needsUpdate=!0,vn(Ie,mt,pt),Ie.side=yp,Ie.needsUpdate=!0,vn(Ie,mt,pt),Ie.side=Ku):vn(Ie,mt,pt)}this.compile=function(Ie,mt,pt=null){pt===null&&(pt=Ie),b=ve.get(pt),b.init(),_.push(b),pt.traverseVisible(function(ze){ze.isLight&&ze.layers.test(mt.layers)&&(b.pushLight(ze),ze.castShadow&&b.pushShadow(ze))}),Ie!==pt&&Ie.traverseVisible(function(ze){ze.isLight&&ze.layers.test(mt.layers)&&(b.pushLight(ze),ze.castShadow&&b.pushShadow(ze))}),b.setupLights(C._useLegacyLights);const nt=new Set;return Ie.traverse(function(ze){const ot=ze.material;if(ot)if(Array.isArray(ot))for(let Pe=0;Pe<ot.length;Pe++){const Ge=ot[Pe];Be(Ge,pt,ze),nt.add(Ge)}else Be(ot,pt,ze),nt.add(ot)}),_.pop(),b=null,nt},this.compileAsync=function(Ie,mt,pt=null){const nt=this.compile(Ie,mt,pt);return new Promise(ze=>{function ot(){if(nt.forEach(function(Pe){ee.get(Pe).currentProgram.isReady()&&nt.delete(Pe)}),nt.size===0){ze(Ie);return}setTimeout(ot,10)}I.get(\"KHR_parallel_shader_compile\")!==null?ot():setTimeout(ot,10)})};let Qe=null;function ht(Ie){Qe&&Qe(Ie)}function xt(){Ut.stop()}function gt(){Ut.start()}const Ut=new vJ;Ut.setAnimationLoop(ht),typeof self<\"u\"&&Ut.setContext(self),this.setAnimationLoop=function(Ie){Qe=Ie,pe.setAnimationLoop(Ie),Ie===null?Ut.stop():Ut.start()},pe.addEventListener(\"sessionstart\",xt),pe.addEventListener(\"sessionend\",gt),this.render=function(Ie,mt){if(mt!==void 0&&mt.isCamera!==!0){console.error(\"THREE.WebGLRenderer.render: camera is not an instance of THREE.Camera.\");return}if(P===!0)return;Ie.matrixWorldAutoUpdate===!0&&Ie.updateMatrixWorld(),mt.parent===null&&mt.matrixWorldAutoUpdate===!0&&mt.updateMatrixWorld(),pe.enabled===!0&&pe.isPresenting===!0&&(pe.cameraAutoUpdate===!0&&pe.updateCamera(mt),mt=pe.getCamera()),Ie.isScene===!0&&Ie.onBeforeRender(C,Ie,mt,T),b=ve.get(Ie,_.length),b.init(),_.push(b),Y.multiplyMatrices(mt.projectionMatrix,mt.matrixWorldInverse),me.setFromProjectionMatrix(Y),Ce=this.localClippingEnabled,be=Te.init(this.clippingPlanes,Ce),v=Se.get(Ie,g.length),v.init(),g.push(v),Wt(Ie,mt,0,C.sortObjects),v.finish(),C.sortObjects===!0&&v.sort(oe,fe),this.info.render.frame++,be===!0&&Te.beginShadows();const pt=b.state.shadowsArray;if(ye.render(pt,Ie,mt),be===!0&&Te.endShadows(),this.info.autoReset===!0&&this.info.reset(),je.render(v,Ie),b.setupLights(C._useLegacyLights),mt.isArrayCamera){const nt=mt.cameras;for(let ze=0,ot=nt.length;ze<ot;ze++){const Pe=nt[ze];Zt(v,Ie,Pe,Pe.viewport)}}else Zt(v,Ie,mt);T!==null&&(se.updateMultisampleRenderTarget(T),se.updateRenderTargetMipmap(T)),Ie.isScene===!0&&Ie.onAfterRender(C,Ie,mt),$e.resetDefaultState(),F=-1,k=null,_.pop(),_.length>0?b=_[_.length-1]:b=null,g.pop(),g.length>0?v=g[g.length-1]:v=null};function Wt(Ie,mt,pt,nt){if(Ie.visible===!1)return;if(Ie.layers.test(mt.layers)){if(Ie.isGroup)pt=Ie.renderOrder;else if(Ie.isLOD)Ie.autoUpdate===!0&&Ie.update(mt);else if(Ie.isLight)b.pushLight(Ie),Ie.castShadow&&b.pushShadow(Ie);else if(Ie.isSprite){if(!Ie.frustumCulled||me.intersectsSprite(Ie)){nt&&j.setFromMatrixPosition(Ie.matrixWorld).applyMatrix4(Y);const Pe=R.update(Ie),Ge=Ie.material;Ge.visible&&v.push(Ie,Pe,Ge,pt,j.z,null)}}else if((Ie.isMesh||Ie.isLine||Ie.isPoints)&&(!Ie.frustumCulled||me.intersectsObject(Ie))){const Pe=R.update(Ie),Ge=Ie.material;if(nt&&(Ie.boundingSphere!==void 0?(Ie.boundingSphere===null&&Ie.computeBoundingSphere(),j.copy(Ie.boundingSphere.center)):(Pe.boundingSphere===null&&Pe.computeBoundingSphere(),j.copy(Pe.boundingSphere.center)),j.applyMatrix4(Ie.matrixWorld).applyMatrix4(Y)),Array.isArray(Ge)){const Ze=Pe.groups;for(let Je=0,We=Ze.length;Je<We;Je++){const Ue=Ze[Je],et=Ge[Ue.materialIndex];et&&et.visible&&v.push(Ie,Pe,et,pt,j.z,Ue)}}else Ge.visible&&v.push(Ie,Pe,Ge,pt,j.z,null)}}const ot=Ie.children;for(let Pe=0,Ge=ot.length;Pe<Ge;Pe++)Wt(ot[Pe],mt,pt,nt)}function Zt(Ie,mt,pt,nt){const ze=Ie.opaque,ot=Ie.transmissive,Pe=Ie.transparent;b.setupLightsView(pt),be===!0&&Te.setGlobalState(C.clippingPlanes,pt),ot.length>0&&Kt(ze,ot,mt,pt),nt&&X.viewport(D.copy(nt)),ze.length>0&&Xt(ze,mt,pt),ot.length>0&&Xt(ot,mt,pt),Pe.length>0&&Xt(Pe,mt,pt),X.buffers.depth.setTest(!0),X.buffers.depth.setMask(!0),X.buffers.color.setMask(!0),X.setPolygonOffset(!1)}function Kt(Ie,mt,pt,nt){if((pt.isScene===!0?pt.overrideMaterial:null)!==null)return;const ot=G.isWebGL2;q===null&&(q=new xg(1,1,{generateMipmaps:!0,type:I.has(\"EXT_color_buffer_half_float\")?pw:cp,minFilter:hw,samples:ot?4:0})),C.getDrawingBufferSize(E),ot?q.setSize(E.x,E.y):q.setSize(ON(E.x),ON(E.y));const Pe=C.getRenderTarget();C.setRenderTarget(q),C.getClearColor(Q),L=C.getClearAlpha(),L<1&&C.setClearColor(16777215,.5),C.clear();const Ge=C.toneMapping;C.toneMapping=lp,Xt(Ie,pt,nt),se.updateMultisampleRenderTarget(q),se.updateRenderTargetMipmap(q);let Ze=!1;for(let Je=0,We=mt.length;Je<We;Je++){const Ue=mt[Je],et=Ue.object,jt=Ue.geometry,yt=Ue.material,qe=Ue.group;if(yt.side===Ku&&et.layers.test(nt.layers)){const St=yt.side;yt.side=Uo,yt.needsUpdate=!0,ln(et,pt,nt,jt,yt,qe),yt.side=St,yt.needsUpdate=!0,Ze=!0}}Ze===!0&&(se.updateMultisampleRenderTarget(q),se.updateRenderTargetMipmap(q)),C.setRenderTarget(Pe),C.setClearColor(Q,L),C.toneMapping=Ge}function Xt(Ie,mt,pt){const nt=mt.isScene===!0?mt.overrideMaterial:null;for(let ze=0,ot=Ie.length;ze<ot;ze++){const Pe=Ie[ze],Ge=Pe.object,Ze=Pe.geometry,Je=nt===null?Pe.material:nt,We=Pe.group;Ge.layers.test(pt.layers)&&ln(Ge,mt,pt,Ze,Je,We)}}function ln(Ie,mt,pt,nt,ze,ot){Ie.onBeforeRender(C,mt,pt,nt,ze,ot),Ie.modelViewMatrix.multiplyMatrices(pt.matrixWorldInverse,Ie.matrixWorld),Ie.normalMatrix.getNormalMatrix(Ie.modelViewMatrix),ze.onBeforeRender(C,mt,pt,nt,Ie,ot),ze.transparent===!0&&ze.side===Ku&&ze.forceSinglePass===!1?(ze.side=Uo,ze.needsUpdate=!0,C.renderBufferDirect(pt,mt,nt,ze,Ie,ot),ze.side=yp,ze.needsUpdate=!0,C.renderBufferDirect(pt,mt,nt,ze,Ie,ot),ze.side=Ku):C.renderBufferDirect(pt,mt,nt,ze,Ie,ot),Ie.onAfterRender(C,mt,pt,nt,ze,ot)}function vn(Ie,mt,pt){mt.isScene!==!0&&(mt=O);const nt=ee.get(Ie),ze=b.state.lights,ot=b.state.shadowsArray,Pe=ze.state.version,Ge=ae.getParameters(Ie,ze.state,ot,mt,pt),Ze=ae.getProgramCacheKey(Ge);let Je=nt.programs;nt.environment=Ie.isMeshStandardMaterial?mt.environment:null,nt.fog=mt.fog,nt.envMap=(Ie.isMeshStandardMaterial?he:ge).get(Ie.envMap||nt.environment),Je===void 0&&(Ie.addEventListener(\"dispose\",Ae),Je=new Map,nt.programs=Je);let We=Je.get(Ze);if(We!==void 0){if(nt.currentProgram===We&&nt.lightsStateVersion===Pe)return at(Ie,Ge),We}else Ge.uniforms=ae.getUniforms(Ie),Ie.onBuild(pt,Ge,C),Ie.onBeforeCompile(Ge,C),We=ae.acquireProgram(Ge,Ze),Je.set(Ze,We),nt.uniforms=Ge.uniforms;const Ue=nt.uniforms;return(!Ie.isShaderMaterial&&!Ie.isRawShaderMaterial||Ie.clipping===!0)&&(Ue.clippingPlanes=Te.uniform),at(Ie,Ge),nt.needsLights=At(Ie),nt.lightsStateVersion=Pe,nt.needsLights&&(Ue.ambientLightColor.value=ze.state.ambient,Ue.lightProbe.value=ze.state.probe,Ue.directionalLights.value=ze.state.directional,Ue.directionalLightShadows.value=ze.state.directionalShadow,Ue.spotLights.value=ze.state.spot,Ue.spotLightShadows.value=ze.state.spotShadow,Ue.rectAreaLights.value=ze.state.rectArea,Ue.ltc_1.value=ze.state.rectAreaLTC1,Ue.ltc_2.value=ze.state.rectAreaLTC2,Ue.pointLights.value=ze.state.point,Ue.pointLightShadows.value=ze.state.pointShadow,Ue.hemisphereLights.value=ze.state.hemi,Ue.directionalShadowMap.value=ze.state.directionalShadowMap,Ue.directionalShadowMatrix.value=ze.state.directionalShadowMatrix,Ue.spotShadowMap.value=ze.state.spotShadowMap,Ue.spotLightMatrix.value=ze.state.spotLightMatrix,Ue.spotLightMap.value=ze.state.spotLightMap,Ue.pointShadowMap.value=ze.state.pointShadowMap,Ue.pointShadowMatrix.value=ze.state.pointShadowMatrix),nt.currentProgram=We,nt.uniformsList=null,We}function Ke(Ie){if(Ie.uniformsList===null){const mt=Ie.currentProgram.getUniforms();Ie.uniformsList=Kk.seqWithValue(mt.seq,Ie.uniforms)}return Ie.uniformsList}function at(Ie,mt){const pt=ee.get(Ie);pt.outputColorSpace=mt.outputColorSpace,pt.batching=mt.batching,pt.instancing=mt.instancing,pt.instancingColor=mt.instancingColor,pt.skinning=mt.skinning,pt.morphTargets=mt.morphTargets,pt.morphNormals=mt.morphNormals,pt.morphColors=mt.morphColors,pt.morphTargetsCount=mt.morphTargetsCount,pt.numClippingPlanes=mt.numClippingPlanes,pt.numIntersection=mt.numClipIntersection,pt.vertexAlphas=mt.vertexAlphas,pt.vertexTangents=mt.vertexTangents,pt.toneMapping=mt.toneMapping}function Lt(Ie,mt,pt,nt,ze){mt.isScene!==!0&&(mt=O),se.resetTextureUnits();const ot=mt.fog,Pe=nt.isMeshStandardMaterial?mt.environment:null,Ge=T===null?C.outputColorSpace:T.isXRRenderTarget===!0?T.texture.colorSpace:Sm,Ze=(nt.isMeshStandardMaterial?he:ge).get(nt.envMap||Pe),Je=nt.vertexColors===!0&&!!pt.attributes.color&&pt.attributes.color.itemSize===4,We=!!pt.attributes.tangent&&(!!nt.normalMap||nt.anisotropy>0),Ue=!!pt.morphAttributes.position,et=!!pt.morphAttributes.normal,jt=!!pt.morphAttributes.color;let yt=lp;nt.toneMapped&&(T===null||T.isXRRenderTarget===!0)&&(yt=C.toneMapping);const qe=pt.morphAttributes.position||pt.morphAttributes.normal||pt.morphAttributes.color,St=qe!==void 0?qe.length:0,Pt=ee.get(nt),qt=b.state.lights;if(be===!0&&(Ce===!0||Ie!==k)){const Nr=Ie===k&&nt.id===F;Te.setState(nt,Ie,Nr)}let on=!1;nt.version===Pt.__version?(Pt.needsLights&&Pt.lightsStateVersion!==qt.state.version||Pt.outputColorSpace!==Ge||ze.isBatchedMesh&&Pt.batching===!1||!ze.isBatchedMesh&&Pt.batching===!0||ze.isInstancedMesh&&Pt.instancing===!1||!ze.isInstancedMesh&&Pt.instancing===!0||ze.isSkinnedMesh&&Pt.skinning===!1||!ze.isSkinnedMesh&&Pt.skinning===!0||ze.isInstancedMesh&&Pt.instancingColor===!0&&ze.instanceColor===null||ze.isInstancedMesh&&Pt.instancingColor===!1&&ze.instanceColor!==null||Pt.envMap!==Ze||nt.fog===!0&&Pt.fog!==ot||Pt.numClippingPlanes!==void 0&&(Pt.numClippingPlanes!==Te.numPlanes||Pt.numIntersection!==Te.numIntersection)||Pt.vertexAlphas!==Je||Pt.vertexTangents!==We||Pt.morphTargets!==Ue||Pt.morphNormals!==et||Pt.morphColors!==jt||Pt.toneMapping!==yt||G.isWebGL2===!0&&Pt.morphTargetsCount!==St)&&(on=!0):(on=!0,Pt.__version=nt.version);let dn=Pt.currentProgram;on===!0&&(dn=vn(nt,mt,ze));let Nn=!1,bn=!1,un=!1;const wn=dn.getUniforms(),pn=Pt.uniforms;if(X.useProgram(dn.program)&&(Nn=!0,bn=!0,un=!0),nt.id!==F&&(F=nt.id,bn=!0),Nn||k!==Ie){wn.setValue(U,\"projectionMatrix\",Ie.projectionMatrix),wn.setValue(U,\"viewMatrix\",Ie.matrixWorldInverse);const Nr=wn.map.cameraPosition;Nr!==void 0&&Nr.setValue(U,j.setFromMatrixPosition(Ie.matrixWorld)),G.logarithmicDepthBuffer&&wn.setValue(U,\"logDepthBufFC\",2/(Math.log(Ie.far+1)/Math.LN2)),(nt.isMeshPhongMaterial||nt.isMeshToonMaterial||nt.isMeshLambertMaterial||nt.isMeshBasicMaterial||nt.isMeshStandardMaterial||nt.isShaderMaterial)&&wn.setValue(U,\"isOrthographic\",Ie.isOrthographicCamera===!0),k!==Ie&&(k=Ie,bn=!0,un=!0)}if(ze.isSkinnedMesh){wn.setOptional(U,ze,\"bindMatrix\"),wn.setOptional(U,ze,\"bindMatrixInverse\");const Nr=ze.skeleton;Nr&&(G.floatVertexTextures?(Nr.boneTexture===null&&Nr.computeBoneTexture(),wn.setValue(U,\"boneTexture\",Nr.boneTexture,se)):console.warn(\"THREE.WebGLRenderer: SkinnedMesh can only be used with WebGL 2. With WebGL 1 OES_texture_float and vertex textures support is required.\"))}ze.isBatchedMesh&&(wn.setOptional(U,ze,\"batchingTexture\"),wn.setValue(U,\"batchingTexture\",ze._matricesTexture,se));const gr=pt.morphAttributes;if((gr.position!==void 0||gr.normal!==void 0||gr.color!==void 0&&G.isWebGL2===!0)&&Le.update(ze,pt,dn),(bn||Pt.receiveShadow!==ze.receiveShadow)&&(Pt.receiveShadow=ze.receiveShadow,wn.setValue(U,\"receiveShadow\",ze.receiveShadow)),nt.isMeshGouraudMaterial&&nt.envMap!==null&&(pn.envMap.value=Ze,pn.flipEnvMap.value=Ze.isCubeTexture&&Ze.isRenderTargetTexture===!1?-1:1),bn&&(wn.setValue(U,\"toneMappingExposure\",C.toneMappingExposure),Pt.needsLights&&Et(pn,un),ot&&nt.fog===!0&&_e.refreshFogUniforms(pn,ot),_e.refreshMaterialUniforms(pn,nt,J,ie,q),Kk.upload(U,Ke(Pt),pn,se)),nt.isShaderMaterial&&nt.uniformsNeedUpdate===!0&&(Kk.upload(U,Ke(Pt),pn,se),nt.uniformsNeedUpdate=!1),nt.isSpriteMaterial&&wn.setValue(U,\"center\",ze.center),wn.setValue(U,\"modelViewMatrix\",ze.modelViewMatrix),wn.setValue(U,\"normalMatrix\",ze.normalMatrix),wn.setValue(U,\"modelMatrix\",ze.matrixWorld),nt.isShaderMaterial||nt.isRawShaderMaterial){const Nr=nt.uniformsGroups;for(let Fn=0,Ba=Nr.length;Fn<Ba;Fn++)if(G.isWebGL2){const In=Nr[Fn];Ye.update(In,dn),Ye.bind(In,dn)}else console.warn(\"THREE.WebGLRenderer: Uniform Buffer Objects can only be used with WebGL 2.\")}return dn}function Et(Ie,mt){Ie.ambientLightColor.needsUpdate=mt,Ie.lightProbe.needsUpdate=mt,Ie.directionalLights.needsUpdate=mt,Ie.directionalLightShadows.needsUpdate=mt,Ie.pointLights.needsUpdate=mt,Ie.pointLightShadows.needsUpdate=mt,Ie.spotLights.needsUpdate=mt,Ie.spotLightShadows.needsUpdate=mt,Ie.rectAreaLights.needsUpdate=mt,Ie.hemisphereLights.needsUpdate=mt}function At(Ie){return Ie.isMeshLambertMaterial||Ie.isMeshToonMaterial||Ie.isMeshPhongMaterial||Ie.isMeshStandardMaterial||Ie.isShadowMaterial||Ie.isShaderMaterial&&Ie.lights===!0}this.getActiveCubeFace=function(){return N},this.getActiveMipmapLevel=function(){return A},this.getRenderTarget=function(){return T},this.setRenderTargetTextures=function(Ie,mt,pt){ee.get(Ie.texture).__webglTexture=mt,ee.get(Ie.depthTexture).__webglTexture=pt;const nt=ee.get(Ie);nt.__hasExternalTextures=!0,nt.__hasExternalTextures&&(nt.__autoAllocateDepthBuffer=pt===void 0,nt.__autoAllocateDepthBuffer||I.has(\"WEBGL_multisampled_render_to_texture\")===!0&&(console.warn(\"THREE.WebGLRenderer: Render-to-texture extension was disabled because an external texture was provided\"),nt.__useRenderToTexture=!1))},this.setRenderTargetFramebuffer=function(Ie,mt){const pt=ee.get(Ie);pt.__webglFramebuffer=mt,pt.__useDefaultFramebuffer=mt===void 0},this.setRenderTarget=function(Ie,mt=0,pt=0){T=Ie,N=mt,A=pt;let nt=!0,ze=null,ot=!1,Pe=!1;if(Ie){const Ze=ee.get(Ie);Ze.__useDefaultFramebuffer!==void 0?(X.bindFramebuffer(U.FRAMEBUFFER,null),nt=!1):Ze.__webglFramebuffer===void 0?se.setupRenderTarget(Ie):Ze.__hasExternalTextures&&se.rebindTextures(Ie,ee.get(Ie.texture).__webglTexture,ee.get(Ie.depthTexture).__webglTexture);const Je=Ie.texture;(Je.isData3DTexture||Je.isDataArrayTexture||Je.isCompressedArrayTexture)&&(Pe=!0);const We=ee.get(Ie).__webglFramebuffer;Ie.isWebGLCubeRenderTarget?(Array.isArray(We[mt])?ze=We[mt][pt]:ze=We[mt],ot=!0):G.isWebGL2&&Ie.samples>0&&se.useMultisampledRTT(Ie)===!1?ze=ee.get(Ie).__webglMultisampledFramebuffer:Array.isArray(We)?ze=We[pt]:ze=We,D.copy(Ie.viewport),H.copy(Ie.scissor),z=Ie.scissorTest}else D.copy(re).multiplyScalar(J).floor(),H.copy(W).multiplyScalar(J).floor(),z=ne;if(X.bindFramebuffer(U.FRAMEBUFFER,ze)&&G.drawBuffers&&nt&&X.drawBuffers(Ie,ze),X.viewport(D),X.scissor(H),X.setScissorTest(z),ot){const Ze=ee.get(Ie.texture);U.framebufferTexture2D(U.FRAMEBUFFER,U.COLOR_ATTACHMENT0,U.TEXTURE_CUBE_MAP_POSITIVE_X+mt,Ze.__webglTexture,pt)}else if(Pe){const Ze=ee.get(Ie.texture),Je=mt||0;U.framebufferTextureLayer(U.FRAMEBUFFER,U.COLOR_ATTACHMENT0,Ze.__webglTexture,pt||0,Je)}F=-1},this.readRenderTargetPixels=function(Ie,mt,pt,nt,ze,ot,Pe){if(!(Ie&&Ie.isWebGLRenderTarget)){console.error(\"THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.\");return}let Ge=ee.get(Ie).__webglFramebuffer;if(Ie.isWebGLCubeRenderTarget&&Pe!==void 0&&(Ge=Ge[Pe]),Ge){X.bindFramebuffer(U.FRAMEBUFFER,Ge);try{const Ze=Ie.texture,Je=Ze.format,We=Ze.type;if(Je!==Yl&&Re.convert(Je)!==U.getParameter(U.IMPLEMENTATION_COLOR_READ_FORMAT)){console.error(\"THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in RGBA or implementation defined format.\");return}const Ue=We===pw&&(I.has(\"EXT_color_buffer_half_float\")||G.isWebGL2&&I.has(\"EXT_color_buffer_float\"));if(We!==cp&&Re.convert(We)!==U.getParameter(U.IMPLEMENTATION_COLOR_READ_TYPE)&&!(We===tm&&(G.isWebGL2||I.has(\"OES_texture_float\")||I.has(\"WEBGL_color_buffer_float\")))&&!Ue){console.error(\"THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in UnsignedByteType or implementation defined type.\");return}mt>=0&&mt<=Ie.width-nt&&pt>=0&&pt<=Ie.height-ze&&U.readPixels(mt,pt,nt,ze,Re.convert(Je),Re.convert(We),ot)}finally{const Ze=T!==null?ee.get(T).__webglFramebuffer:null;X.bindFramebuffer(U.FRAMEBUFFER,Ze)}}},this.copyFramebufferToTexture=function(Ie,mt,pt=0){const nt=Math.pow(2,-pt),ze=Math.floor(mt.image.width*nt),ot=Math.floor(mt.image.height*nt);se.setTexture2D(mt,0),U.copyTexSubImage2D(U.TEXTURE_2D,pt,0,0,Ie.x,Ie.y,ze,ot),X.unbindTexture()},this.copyTextureToTexture=function(Ie,mt,pt,nt=0){const ze=mt.image.width,ot=mt.image.height,Pe=Re.convert(pt.format),Ge=Re.convert(pt.type);se.setTexture2D(pt,0),U.pixelStorei(U.UNPACK_FLIP_Y_WEBGL,pt.flipY),U.pixelStorei(U.UNPACK_PREMULTIPLY_ALPHA_WEBGL,pt.premultiplyAlpha),U.pixelStorei(U.UNPACK_ALIGNMENT,pt.unpackAlignment),mt.isDataTexture?U.texSubImage2D(U.TEXTURE_2D,nt,Ie.x,Ie.y,ze,ot,Pe,Ge,mt.image.data):mt.isCompressedTexture?U.compressedTexSubImage2D(U.TEXTURE_2D,nt,Ie.x,Ie.y,mt.mipmaps[0].width,mt.mipmaps[0].height,Pe,mt.mipmaps[0].data):U.texSubImage2D(U.TEXTURE_2D,nt,Ie.x,Ie.y,Pe,Ge,mt.image),nt===0&&pt.generateMipmaps&&U.generateMipmap(U.TEXTURE_2D),X.unbindTexture()},this.copyTextureToTexture3D=function(Ie,mt,pt,nt,ze=0){if(C.isWebGL1Renderer){console.warn(\"THREE.WebGLRenderer.copyTextureToTexture3D: can only be used with WebGL2.\");return}const ot=Ie.max.x-Ie.min.x+1,Pe=Ie.max.y-Ie.min.y+1,Ge=Ie.max.z-Ie.min.z+1,Ze=Re.convert(nt.format),Je=Re.convert(nt.type);let We;if(nt.isData3DTexture)se.setTexture3D(nt,0),We=U.TEXTURE_3D;else if(nt.isDataArrayTexture)se.setTexture2DArray(nt,0),We=U.TEXTURE_2D_ARRAY;else{console.warn(\"THREE.WebGLRenderer.copyTextureToTexture3D: only supports THREE.DataTexture3D and THREE.DataTexture2DArray.\");return}U.pixelStorei(U.UNPACK_FLIP_Y_WEBGL,nt.flipY),U.pixelStorei(U.UNPACK_PREMULTIPLY_ALPHA_WEBGL,nt.premultiplyAlpha),U.pixelStorei(U.UNPACK_ALIGNMENT,nt.unpackAlignment);const Ue=U.getParameter(U.UNPACK_ROW_LENGTH),et=U.getParameter(U.UNPACK_IMAGE_HEIGHT),jt=U.getParameter(U.UNPACK_SKIP_PIXELS),yt=U.getParameter(U.UNPACK_SKIP_ROWS),qe=U.getParameter(U.UNPACK_SKIP_IMAGES),St=pt.isCompressedTexture?pt.mipmaps[0]:pt.image;U.pixelStorei(U.UNPACK_ROW_LENGTH,St.width),U.pixelStorei(U.UNPACK_IMAGE_HEIGHT,St.height),U.pixelStorei(U.UNPACK_SKIP_PIXELS,Ie.min.x),U.pixelStorei(U.UNPACK_SKIP_ROWS,Ie.min.y),U.pixelStorei(U.UNPACK_SKIP_IMAGES,Ie.min.z),pt.isDataTexture||pt.isData3DTexture?U.texSubImage3D(We,ze,mt.x,mt.y,mt.z,ot,Pe,Ge,Ze,Je,St.data):pt.isCompressedArrayTexture?(console.warn(\"THREE.WebGLRenderer.copyTextureToTexture3D: untested support for compressed srcTexture.\"),U.compressedTexSubImage3D(We,ze,mt.x,mt.y,mt.z,ot,Pe,Ge,Ze,St.data)):U.texSubImage3D(We,ze,mt.x,mt.y,mt.z,ot,Pe,Ge,Ze,Je,St),U.pixelStorei(U.UNPACK_ROW_LENGTH,Ue),U.pixelStorei(U.UNPACK_IMAGE_HEIGHT,et),U.pixelStorei(U.UNPACK_SKIP_PIXELS,jt),U.pixelStorei(U.UNPACK_SKIP_ROWS,yt),U.pixelStorei(U.UNPACK_SKIP_IMAGES,qe),ze===0&&nt.generateMipmaps&&U.generateMipmap(We),X.unbindTexture()},this.initTexture=function(Ie){Ie.isCubeTexture?se.setTextureCube(Ie,0):Ie.isData3DTexture?se.setTexture3D(Ie,0):Ie.isDataArrayTexture||Ie.isCompressedArrayTexture?se.setTexture2DArray(Ie,0):se.setTexture2D(Ie,0),X.unbindTexture()},this.resetState=function(){N=0,A=0,T=null,X.reset(),$e.reset()},typeof __THREE_DEVTOOLS__<\"u\"&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent(\"observe\",{detail:this}))}get coordinateSystem(){return Ed}get outputColorSpace(){return this._outputColorSpace}set outputColorSpace(e){this._outputColorSpace=e;const n=this.getContext();n.drawingBufferColorSpace=e===cI?\"display-p3\":\"srgb\",n.unpackColorSpace=Jr.workingColorSpace===tT?\"display-p3\":\"srgb\"}get physicallyCorrectLights(){return console.warn(\"THREE.WebGLRenderer: The property .physicallyCorrectLights has been removed. Set renderer.useLegacyLights instead.\"),!this.useLegacyLights}set physicallyCorrectLights(e){console.warn(\"THREE.WebGLRenderer: The property .physicallyCorrectLights has been removed. Set renderer.useLegacyLights instead.\"),this.useLegacyLights=!e}get outputEncoding(){return console.warn(\"THREE.WebGLRenderer: Property .outputEncoding has been removed. Use .outputColorSpace instead.\"),this.outputColorSpace===bs?Yf:aJ}set outputEncoding(e){console.warn(\"THREE.WebGLRenderer: Property .outputEncoding has been removed. Use .outputColorSpace instead.\"),this.outputColorSpace=e===Yf?bs:Sm}get useLegacyLights(){return console.warn(\"THREE.WebGLRenderer: The property .useLegacyLights has been deprecated. Migrate your lighting according to the following guide: https://discourse.threejs.org/t/updates-to-lighting-in-three-js-r155/53733.\"),this._useLegacyLights}set useLegacyLights(e){console.warn(\"THREE.WebGLRenderer: The property .useLegacyLights has been deprecated. Migrate your lighting according to the following guide: https://discourse.threejs.org/t/updates-to-lighting-in-three-js-r155/53733.\"),this._useLegacyLights=e}}class eMe extends gR{}eMe.prototype.isWebGL1Renderer=!0;class fI{constructor(e,n=1,r=1e3){this.isFog=!0,this.name=\"\",this.color=new Mn(e),this.near=n,this.far=r}clone(){return new fI(this.color,this.near,this.far)}toJSON(){return{type:\"Fog\",name:this.name,color:this.color.getHex(),near:this.near,far:this.far}}}class tMe extends lo{constructor(){super(),this.isScene=!0,this.type=\"Scene\",this.background=null,this.environment=null,this.fog=null,this.backgroundBlurriness=0,this.backgroundIntensity=1,this.overrideMaterial=null,typeof __THREE_DEVTOOLS__<\"u\"&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent(\"observe\",{detail:this}))}copy(e,n){return super.copy(e,n),e.background!==null&&(this.background=e.background.clone()),e.environment!==null&&(this.environment=e.environment.clone()),e.fog!==null&&(this.fog=e.fog.clone()),this.backgroundBlurriness=e.backgroundBlurriness,this.backgroundIntensity=e.backgroundIntensity,e.overrideMaterial!==null&&(this.overrideMaterial=e.overrideMaterial.clone()),this.matrixAutoUpdate=e.matrixAutoUpdate,this}toJSON(e){const n=super.toJSON(e);return this.fog!==null&&(n.object.fog=this.fog.toJSON()),this.backgroundBlurriness>0&&(n.object.backgroundBlurriness=this.backgroundBlurriness),this.backgroundIntensity!==1&&(n.object.backgroundIntensity=this.backgroundIntensity),n}}class nMe{constructor(e,n){this.isInterleavedBuffer=!0,this.array=e,this.stride=n,this.count=e!==void 0?e.length/n:0,this.usage=mR,this._updateRange={offset:0,count:-1},this.updateRanges=[],this.version=0,this.uuid=dm()}onUploadCallback(){}set needsUpdate(e){e===!0&&this.version++}get updateRange(){return console.warn('THREE.InterleavedBuffer: \"updateRange\" is deprecated and removed in r169. Use \"addUpdateRange()\" instead.'),this._updateRange}setUsage(e){return this.usage=e,this}addUpdateRange(e,n){this.updateRanges.push({start:e,count:n})}clearUpdateRanges(){this.updateRanges.length=0}copy(e){return this.array=new e.array.constructor(e.array),this.count=e.count,this.stride=e.stride,this.usage=e.usage,this}copyAt(e,n,r){e*=this.stride,r*=n.stride;for(let i=0,s=this.stride;i<s;i++)this.array[e+i]=n.array[r+i];return this}set(e,n=0){return this.array.set(e,n),this}clone(e){e.arrayBuffers===void 0&&(e.arrayBuffers={}),this.array.buffer._uuid===void 0&&(this.array.buffer._uuid=dm()),e.arrayBuffers[this.array.buffer._uuid]===void 0&&(e.arrayBuffers[this.array.buffer._uuid]=this.array.slice(0).buffer);const n=new this.array.constructor(e.arrayBuffers[this.array.buffer._uuid]),r=new this.constructor(n,this.stride);return r.setUsage(this.usage),r}onUpload(e){return this.onUploadCallback=e,this}toJSON(e){return e.arrayBuffers===void 0&&(e.arrayBuffers={}),this.array.buffer._uuid===void 0&&(this.array.buffer._uuid=dm()),e.arrayBuffers[this.array.buffer._uuid]===void 0&&(e.arrayBuffers[this.array.buffer._uuid]=Array.from(new Uint32Array(this.array.buffer))),{uuid:this.uuid,buffer:this.array.buffer._uuid,type:this.array.constructor.name,stride:this.stride}}}const Zs=new ut;class $h{constructor(e,n,r,i=!1){this.isInterleavedBufferAttribute=!0,this.name=\"\",this.data=e,this.itemSize=n,this.offset=r,this.normalized=i}get count(){return this.data.count}get array(){return this.data.array}set needsUpdate(e){this.data.needsUpdate=e}applyMatrix4(e){for(let n=0,r=this.data.count;n<r;n++)Zs.fromBufferAttribute(this,n),Zs.applyMatrix4(e),this.setXYZ(n,Zs.x,Zs.y,Zs.z);return this}applyNormalMatrix(e){for(let n=0,r=this.count;n<r;n++)Zs.fromBufferAttribute(this,n),Zs.applyNormalMatrix(e),this.setXYZ(n,Zs.x,Zs.y,Zs.z);return this}transformDirection(e){for(let n=0,r=this.count;n<r;n++)Zs.fromBufferAttribute(this,n),Zs.transformDirection(e),this.setXYZ(n,Zs.x,Zs.y,Zs.z);return this}setX(e,n){return this.normalized&&(n=Zr(n,this.array)),this.data.array[e*this.data.stride+this.offset]=n,this}setY(e,n){return this.normalized&&(n=Zr(n,this.array)),this.data.array[e*this.data.stride+this.offset+1]=n,this}setZ(e,n){return this.normalized&&(n=Zr(n,this.array)),this.data.array[e*this.data.stride+this.offset+2]=n,this}setW(e,n){return this.normalized&&(n=Zr(n,this.array)),this.data.array[e*this.data.stride+this.offset+3]=n,this}getX(e){let n=this.data.array[e*this.data.stride+this.offset];return this.normalized&&(n=Td(n,this.array)),n}getY(e){let n=this.data.array[e*this.data.stride+this.offset+1];return this.normalized&&(n=Td(n,this.array)),n}getZ(e){let n=this.data.array[e*this.data.stride+this.offset+2];return this.normalized&&(n=Td(n,this.array)),n}getW(e){let n=this.data.array[e*this.data.stride+this.offset+3];return this.normalized&&(n=Td(n,this.array)),n}setXY(e,n,r){return e=e*this.data.stride+this.offset,this.normalized&&(n=Zr(n,this.array),r=Zr(r,this.array)),this.data.array[e+0]=n,this.data.array[e+1]=r,this}setXYZ(e,n,r,i){return e=e*this.data.stride+this.offset,this.normalized&&(n=Zr(n,this.array),r=Zr(r,this.array),i=Zr(i,this.array)),this.data.array[e+0]=n,this.data.array[e+1]=r,this.data.array[e+2]=i,this}setXYZW(e,n,r,i,s){return e=e*this.data.stride+this.offset,this.normalized&&(n=Zr(n,this.array),r=Zr(r,this.array),i=Zr(i,this.array),s=Zr(s,this.array)),this.data.array[e+0]=n,this.data.array[e+1]=r,this.data.array[e+2]=i,this.data.array[e+3]=s,this}clone(e){if(e===void 0){console.log(\"THREE.InterleavedBufferAttribute.clone(): Cloning an interleaved buffer attribute will de-interleave buffer data.\");const n=[];for(let r=0;r<this.count;r++){const i=r*this.data.stride+this.offset;for(let s=0;s<this.itemSize;s++)n.push(this.data.array[i+s])}return new fl(new this.array.constructor(n),this.itemSize,this.normalized)}else return e.interleavedBuffers===void 0&&(e.interleavedBuffers={}),e.interleavedBuffers[this.data.uuid]===void 0&&(e.interleavedBuffers[this.data.uuid]=this.data.clone(e)),new $h(e.interleavedBuffers[this.data.uuid],this.itemSize,this.offset,this.normalized)}toJSON(e){if(e===void 0){console.log(\"THREE.InterleavedBufferAttribute.toJSON(): Serializing an interleaved buffer attribute will de-interleave buffer data.\");const n=[];for(let r=0;r<this.count;r++){const i=r*this.data.stride+this.offset;for(let s=0;s<this.itemSize;s++)n.push(this.data.array[i+s])}return{itemSize:this.itemSize,type:this.array.constructor.name,array:n,normalized:this.normalized}}else return e.interleavedBuffers===void 0&&(e.interleavedBuffers={}),e.interleavedBuffers[this.data.uuid]===void 0&&(e.interleavedBuffers[this.data.uuid]=this.data.toJSON(e)),{isInterleavedBufferAttribute:!0,itemSize:this.itemSize,data:this.data.uuid,offset:this.offset,normalized:this.normalized}}}class rMe extends Bo{constructor(e=null,n=1,r=1,i,s,o,l,c,d=gs,u=gs,m,p){super(null,o,l,c,d,u,i,s,m,p),this.isDataTexture=!0,this.image={data:e,width:n,height:r},this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1}}function aMe(t,e){return t.z-e.z}function iMe(t,e){return e.z-t.z}class sMe{constructor(){this.index=0,this.pool=[],this.list=[]}push(e,n){const r=this.pool,i=this.list;this.index>=r.length&&r.push({start:-1,count:-1,z:-1});const s=r[this.index];i.push(s),this.index++,s.start=e.start,s.count=e.count,s.z=n}reset(){this.list.length=0,this.index=0}}const tx=\"batchId\",vh=new ba,b$=new ba,oMe=new ba,x$=new ba,UE=new nT,$1=new ac,xf=new Ld,$v=new ut,BE=new sMe,Rs=new Wc,V1=[];function lMe(t,e,n=0){const r=e.itemSize;if(t.isInterleavedBufferAttribute||t.array.constructor!==e.array.constructor){const i=t.count;for(let s=0;s<i;s++)for(let o=0;o<r;o++)e.setComponent(s+n,o,t.getComponent(s,o))}else e.array.set(t.array,n*r);e.needsUpdate=!0}class cMe extends Wc{get maxGeometryCount(){return this._maxGeometryCount}constructor(e,n,r=n*2,i){super(new Vs,i),this.isBatchedMesh=!0,this.perObjectFrustumCulled=!0,this.sortObjects=!0,this.boundingBox=null,this.boundingSphere=null,this.customSort=null,this._drawRanges=[],this._reservedRanges=[],this._visibility=[],this._active=[],this._bounds=[],this._maxGeometryCount=e,this._maxVertexCount=n,this._maxIndexCount=r,this._geometryInitialized=!1,this._geometryCount=0,this._multiDrawCounts=new Int32Array(e),this._multiDrawStarts=new Int32Array(e),this._multiDrawCount=0,this._visibilityChanged=!0,this._matricesTexture=null,this._initMatricesTexture()}_initMatricesTexture(){let e=Math.sqrt(this._maxGeometryCount*4);e=Math.ceil(e/4)*4,e=Math.max(e,4);const n=new Float32Array(e*e*4),r=new rMe(n,e,e,Yl,tm);this._matricesTexture=r}_initializeGeometry(e){const n=this.geometry,r=this._maxVertexCount,i=this._maxGeometryCount,s=this._maxIndexCount;if(this._geometryInitialized===!1){for(const l in e.attributes){const c=e.getAttribute(l),{array:d,itemSize:u,normalized:m}=c,p=new d.constructor(r*u),f=new c.constructor(p,u,m);f.setUsage(c.usage),n.setAttribute(l,f)}if(e.getIndex()!==null){const l=r>65536?new Uint32Array(s):new Uint16Array(s);n.setIndex(new fl(l,1))}const o=i>65536?new Uint32Array(r):new Uint16Array(r);n.setAttribute(tx,new fl(o,1)),this._geometryInitialized=!0}}_validateGeometry(e){if(e.getAttribute(tx))throw new Error(`BatchedMesh: Geometry cannot use attribute \"${tx}\"`);const n=this.geometry;if(!!e.getIndex()!=!!n.getIndex())throw new Error('BatchedMesh: All geometries must consistently have \"index\".');for(const r in n.attributes){if(r===tx)continue;if(!e.hasAttribute(r))throw new Error(`BatchedMesh: Added geometry missing \"${r}\". All geometries must have consistent attributes.`);const i=e.getAttribute(r),s=n.getAttribute(r);if(i.itemSize!==s.itemSize||i.normalized!==s.normalized)throw new Error(\"BatchedMesh: All attributes must have a consistent itemSize and normalized value.\")}}setCustomSort(e){return this.customSort=e,this}computeBoundingBox(){this.boundingBox===null&&(this.boundingBox=new ac);const e=this._geometryCount,n=this.boundingBox,r=this._active;n.makeEmpty();for(let i=0;i<e;i++)r[i]!==!1&&(this.getMatrixAt(i,vh),this.getBoundingBoxAt(i,$1).applyMatrix4(vh),n.union($1))}computeBoundingSphere(){this.boundingSphere===null&&(this.boundingSphere=new Ld);const e=this._geometryCount,n=this.boundingSphere,r=this._active;n.makeEmpty();for(let i=0;i<e;i++)r[i]!==!1&&(this.getMatrixAt(i,vh),this.getBoundingSphereAt(i,xf).applyMatrix4(vh),n.union(xf))}addGeometry(e,n=-1,r=-1){if(this._initializeGeometry(e),this._validateGeometry(e),this._geometryCount>=this._maxGeometryCount)throw new Error(\"BatchedMesh: Maximum geometry count reached.\");const i={vertexStart:-1,vertexCount:-1,indexStart:-1,indexCount:-1};let s=null;const o=this._reservedRanges,l=this._drawRanges,c=this._bounds;this._geometryCount!==0&&(s=o[o.length-1]),n===-1?i.vertexCount=e.getAttribute(\"position\").count:i.vertexCount=n,s===null?i.vertexStart=0:i.vertexStart=s.vertexStart+s.vertexCount;const d=e.getIndex(),u=d!==null;if(u&&(r===-1?i.indexCount=d.count:i.indexCount=r,s===null?i.indexStart=0:i.indexStart=s.indexStart+s.indexCount),i.indexStart!==-1&&i.indexStart+i.indexCount>this._maxIndexCount||i.vertexStart+i.vertexCount>this._maxVertexCount)throw new Error(\"BatchedMesh: Reserved space request exceeds the maximum buffer size.\");const m=this._visibility,p=this._active,f=this._matricesTexture,y=this._matricesTexture.image.data;m.push(!0),p.push(!0);const v=this._geometryCount;this._geometryCount++,oMe.toArray(y,v*16),f.needsUpdate=!0,o.push(i),l.push({start:u?i.indexStart:i.vertexStart,count:-1}),c.push({boxInitialized:!1,box:new ac,sphereInitialized:!1,sphere:new Ld});const b=this.geometry.getAttribute(tx);for(let g=0;g<i.vertexCount;g++)b.setX(i.vertexStart+g,v);return b.needsUpdate=!0,this.setGeometryAt(v,e),v}setGeometryAt(e,n){if(e>=this._geometryCount)throw new Error(\"BatchedMesh: Maximum geometry count reached.\");this._validateGeometry(n);const r=this.geometry,i=r.getIndex()!==null,s=r.getIndex(),o=n.getIndex(),l=this._reservedRanges[e];if(i&&o.count>l.indexCount||n.attributes.position.count>l.vertexCount)throw new Error(\"BatchedMesh: Reserved space not large enough for provided geometry.\");const c=l.vertexStart,d=l.vertexCount;for(const f in r.attributes){if(f===tx)continue;const y=n.getAttribute(f),v=r.getAttribute(f);lMe(y,v,c);const b=y.itemSize;for(let g=y.count,_=d;g<_;g++){const C=c+g;for(let P=0;P<b;P++)v.setComponent(C,P,0)}v.needsUpdate=!0}if(i){const f=l.indexStart;for(let y=0;y<o.count;y++)s.setX(f+y,c+o.getX(y));for(let y=o.count,v=l.indexCount;y<v;y++)s.setX(f+y,c);s.needsUpdate=!0}const u=this._bounds[e];n.boundingBox!==null?(u.box.copy(n.boundingBox),u.boxInitialized=!0):u.boxInitialized=!1,n.boundingSphere!==null?(u.sphere.copy(n.boundingSphere),u.sphereInitialized=!0):u.sphereInitialized=!1;const m=this._drawRanges[e],p=n.getAttribute(\"position\");return m.count=i?o.count:p.count,this._visibilityChanged=!0,e}deleteGeometry(e){const n=this._active;return e>=n.length||n[e]===!1?this:(n[e]=!1,this._visibilityChanged=!0,this)}getBoundingBoxAt(e,n){if(this._active[e]===!1)return this;const i=this._bounds[e],s=i.box,o=this.geometry;if(i.boxInitialized===!1){s.makeEmpty();const l=o.index,c=o.attributes.position,d=this._drawRanges[e];for(let u=d.start,m=d.start+d.count;u<m;u++){let p=u;l&&(p=l.getX(p)),s.expandByPoint($v.fromBufferAttribute(c,p))}i.boxInitialized=!0}return n.copy(s),n}getBoundingSphereAt(e,n){if(this._active[e]===!1)return this;const i=this._bounds[e],s=i.sphere,o=this.geometry;if(i.sphereInitialized===!1){s.makeEmpty(),this.getBoundingBoxAt(e,$1),$1.getCenter(s.center);const l=o.index,c=o.attributes.position,d=this._drawRanges[e];let u=0;for(let m=d.start,p=d.start+d.count;m<p;m++){let f=m;l&&(f=l.getX(f)),$v.fromBufferAttribute(c,f),u=Math.max(u,s.center.distanceToSquared($v))}s.radius=Math.sqrt(u),i.sphereInitialized=!0}return n.copy(s),n}setMatrixAt(e,n){const r=this._active,i=this._matricesTexture,s=this._matricesTexture.image.data,o=this._geometryCount;return e>=o||r[e]===!1?this:(n.toArray(s,e*16),i.needsUpdate=!0,this)}getMatrixAt(e,n){const r=this._active,i=this._matricesTexture.image.data,s=this._geometryCount;return e>=s||r[e]===!1?null:n.fromArray(i,e*16)}setVisibleAt(e,n){const r=this._visibility,i=this._active,s=this._geometryCount;return e>=s||i[e]===!1||r[e]===n?this:(r[e]=n,this._visibilityChanged=!0,this)}getVisibleAt(e){const n=this._visibility,r=this._active,i=this._geometryCount;return e>=i||r[e]===!1?!1:n[e]}raycast(e,n){const r=this._visibility,i=this._active,s=this._drawRanges,o=this._geometryCount,l=this.matrixWorld,c=this.geometry;Rs.material=this.material,Rs.geometry.index=c.index,Rs.geometry.attributes=c.attributes,Rs.geometry.boundingBox===null&&(Rs.geometry.boundingBox=new ac),Rs.geometry.boundingSphere===null&&(Rs.geometry.boundingSphere=new Ld);for(let d=0;d<o;d++){if(!r[d]||!i[d])continue;const u=s[d];Rs.geometry.setDrawRange(u.start,u.count),this.getMatrixAt(d,Rs.matrixWorld).premultiply(l),this.getBoundingBoxAt(d,Rs.geometry.boundingBox),this.getBoundingSphereAt(d,Rs.geometry.boundingSphere),Rs.raycast(e,V1);for(let m=0,p=V1.length;m<p;m++){const f=V1[m];f.object=this,f.batchId=d,n.push(f)}V1.length=0}Rs.material=null,Rs.geometry.index=null,Rs.geometry.attributes={},Rs.geometry.setDrawRange(0,1/0)}copy(e){return super.copy(e),this.geometry=e.geometry.clone(),this.perObjectFrustumCulled=e.perObjectFrustumCulled,this.sortObjects=e.sortObjects,this.boundingBox=e.boundingBox!==null?e.boundingBox.clone():null,this.boundingSphere=e.boundingSphere!==null?e.boundingSphere.clone():null,this._drawRanges=e._drawRanges.map(n=>({...n})),this._reservedRanges=e._reservedRanges.map(n=>({...n})),this._visibility=e._visibility.slice(),this._active=e._active.slice(),this._bounds=e._bounds.map(n=>({boxInitialized:n.boxInitialized,box:n.box.clone(),sphereInitialized:n.sphereInitialized,sphere:n.sphere.clone()})),this._maxGeometryCount=e._maxGeometryCount,this._maxVertexCount=e._maxVertexCount,this._maxIndexCount=e._maxIndexCount,this._geometryInitialized=e._geometryInitialized,this._geometryCount=e._geometryCount,this._multiDrawCounts=e._multiDrawCounts.slice(),this._multiDrawStarts=e._multiDrawStarts.slice(),this._matricesTexture=e._matricesTexture.clone(),this._matricesTexture.image.data=this._matricesTexture.image.slice(),this}dispose(){return this.geometry.dispose(),this._matricesTexture.dispose(),this._matricesTexture=null,this}onBeforeRender(e,n,r,i,s){if(!this._visibilityChanged&&!this.perObjectFrustumCulled&&!this.sortObjects)return;const o=i.getIndex(),l=o===null?1:o.array.BYTES_PER_ELEMENT,c=this._visibility,d=this._multiDrawStarts,u=this._multiDrawCounts,m=this._drawRanges,p=this.perObjectFrustumCulled;p&&(x$.multiplyMatrices(r.projectionMatrix,r.matrixWorldInverse).multiply(this.matrixWorld),UE.setFromProjectionMatrix(x$,e.isWebGPURenderer?fw:Ed));let f=0;if(this.sortObjects){b$.copy(this.matrixWorld).invert(),$v.setFromMatrixPosition(r.matrixWorld).applyMatrix4(b$);for(let b=0,g=c.length;b<g;b++)if(c[b]){this.getMatrixAt(b,vh),this.getBoundingSphereAt(b,xf).applyMatrix4(vh);let _=!1;if(p&&(_=!UE.intersectsSphere(xf)),!_){const C=$v.distanceTo(xf.center);BE.push(m[b],C)}}const y=BE.list,v=this.customSort;v===null?y.sort(s.transparent?iMe:aMe):v.call(this,y,r);for(let b=0,g=y.length;b<g;b++){const _=y[b];d[f]=_.start*l,u[f]=_.count,f++}BE.reset()}else for(let y=0,v=c.length;y<v;y++)if(c[y]){let b=!1;if(p&&(this.getMatrixAt(y,vh),this.getBoundingSphereAt(y,xf).applyMatrix4(vh),b=!UE.intersectsSphere(xf)),!b){const g=m[y];d[f]=g.start*l,u[f]=g.count,f++}}this._multiDrawCount=f,this._visibilityChanged=!1}onBeforeShadow(e,n,r,i,s,o){this.onBeforeRender(e,null,i,s,o)}}class uS extends Cy{constructor(e){super(),this.isLineBasicMaterial=!0,this.type=\"LineBasicMaterial\",this.color=new Mn(16777215),this.map=null,this.linewidth=1,this.linecap=\"round\",this.linejoin=\"round\",this.fog=!0,this.setValues(e)}copy(e){return super.copy(e),this.color.copy(e.color),this.map=e.map,this.linewidth=e.linewidth,this.linecap=e.linecap,this.linejoin=e.linejoin,this.fog=e.fog,this}}const y$=new ut,v$=new ut,w$=new ba,HE=new uI,G1=new Ld;let dMe=class extends lo{constructor(e=new Vs,n=new uS){super(),this.isLine=!0,this.type=\"Line\",this.geometry=e,this.material=n,this.updateMorphTargets()}copy(e,n){return super.copy(e,n),this.material=Array.isArray(e.material)?e.material.slice():e.material,this.geometry=e.geometry,this}computeLineDistances(){const e=this.geometry;if(e.index===null){const n=e.attributes.position,r=[0];for(let i=1,s=n.count;i<s;i++)y$.fromBufferAttribute(n,i-1),v$.fromBufferAttribute(n,i),r[i]=r[i-1],r[i]+=y$.distanceTo(v$);e.setAttribute(\"lineDistance\",new li(r,1))}else console.warn(\"THREE.Line.computeLineDistances(): Computation only possible with non-indexed BufferGeometry.\");return this}raycast(e,n){const r=this.geometry,i=this.matrixWorld,s=e.params.Line.threshold,o=r.drawRange;if(r.boundingSphere===null&&r.computeBoundingSphere(),G1.copy(r.boundingSphere),G1.applyMatrix4(i),G1.radius+=s,e.ray.intersectsSphere(G1)===!1)return;w$.copy(i).invert(),HE.copy(e.ray).applyMatrix4(w$);const l=s/((this.scale.x+this.scale.y+this.scale.z)/3),c=l*l,d=new ut,u=new ut,m=new ut,p=new ut,f=this.isLineSegments?2:1,y=r.index,b=r.attributes.position;if(y!==null){const g=Math.max(0,o.start),_=Math.min(y.count,o.start+o.count);for(let C=g,P=_-1;C<P;C+=f){const N=y.getX(C),A=y.getX(C+1);if(d.fromBufferAttribute(b,N),u.fromBufferAttribute(b,A),HE.distanceSqToSegment(d,u,p,m)>c)continue;p.applyMatrix4(this.matrixWorld);const F=e.ray.origin.distanceTo(p);F<e.near||F>e.far||n.push({distance:F,point:m.clone().applyMatrix4(this.matrixWorld),index:C,face:null,faceIndex:null,object:this})}}else{const g=Math.max(0,o.start),_=Math.min(b.count,o.start+o.count);for(let C=g,P=_-1;C<P;C+=f){if(d.fromBufferAttribute(b,C),u.fromBufferAttribute(b,C+1),HE.distanceSqToSegment(d,u,p,m)>c)continue;p.applyMatrix4(this.matrixWorld);const A=e.ray.origin.distanceTo(p);A<e.near||A>e.far||n.push({distance:A,point:m.clone().applyMatrix4(this.matrixWorld),index:C,face:null,faceIndex:null,object:this})}}}updateMorphTargets(){const n=this.geometry.morphAttributes,r=Object.keys(n);if(r.length>0){const i=n[r[0]];if(i!==void 0){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let s=0,o=i.length;s<o;s++){const l=i[s].name||String(s);this.morphTargetInfluences.push(0),this.morphTargetDictionary[l]=s}}}}};const S$=new ut,_$=new ut;class aT extends dMe{constructor(e,n){super(e,n),this.isLineSegments=!0,this.type=\"LineSegments\"}computeLineDistances(){const e=this.geometry;if(e.index===null){const n=e.attributes.position,r=[];for(let i=0,s=n.count;i<s;i+=2)S$.fromBufferAttribute(n,i),_$.fromBufferAttribute(n,i+1),r[i]=i===0?0:r[i-1],r[i+1]=r[i]+S$.distanceTo(_$);e.setAttribute(\"lineDistance\",new li(r,1))}else console.warn(\"THREE.LineSegments.computeLineDistances(): Computation only possible with non-indexed BufferGeometry.\");return this}}class uMe extends Vs{constructor(e=null){if(super(),this.type=\"WireframeGeometry\",this.parameters={geometry:e},e!==null){const n=[],r=new Set,i=new ut,s=new ut;if(e.index!==null){const o=e.attributes.position,l=e.index;let c=e.groups;c.length===0&&(c=[{start:0,count:l.count,materialIndex:0}]);for(let d=0,u=c.length;d<u;++d){const m=c[d],p=m.start,f=m.count;for(let y=p,v=p+f;y<v;y+=3)for(let b=0;b<3;b++){const g=l.getX(y+b),_=l.getX(y+(b+1)%3);i.fromBufferAttribute(o,g),s.fromBufferAttribute(o,_),k$(i,s,r)===!0&&(n.push(i.x,i.y,i.z),n.push(s.x,s.y,s.z))}}}else{const o=e.attributes.position;for(let l=0,c=o.count/3;l<c;l++)for(let d=0;d<3;d++){const u=3*l+d,m=3*l+(d+1)%3;i.fromBufferAttribute(o,u),s.fromBufferAttribute(o,m),k$(i,s,r)===!0&&(n.push(i.x,i.y,i.z),n.push(s.x,s.y,s.z))}}this.setAttribute(\"position\",new li(n,3))}}copy(e){return super.copy(e),this.parameters=Object.assign({},e.parameters),this}}function k$(t,e,n){const r=`${t.x},${t.y},${t.z}-${e.x},${e.y},${e.z}`,i=`${e.x},${e.y},${e.z}-${t.x},${t.y},${t.z}`;return n.has(r)===!0||n.has(i)===!0?!1:(n.add(r),n.add(i),!0)}class mMe extends Cy{constructor(e){super(),this.isMeshLambertMaterial=!0,this.type=\"MeshLambertMaterial\",this.color=new Mn(16777215),this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new Mn(0),this.emissiveIntensity=1,this.emissiveMap=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalMapType=iJ,this.normalScale=new Vn(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.combine=oI,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap=\"round\",this.wireframeLinejoin=\"round\",this.flatShading=!1,this.fog=!0,this.setValues(e)}copy(e){return super.copy(e),this.color.copy(e.color),this.map=e.map,this.lightMap=e.lightMap,this.lightMapIntensity=e.lightMapIntensity,this.aoMap=e.aoMap,this.aoMapIntensity=e.aoMapIntensity,this.emissive.copy(e.emissive),this.emissiveMap=e.emissiveMap,this.emissiveIntensity=e.emissiveIntensity,this.bumpMap=e.bumpMap,this.bumpScale=e.bumpScale,this.normalMap=e.normalMap,this.normalMapType=e.normalMapType,this.normalScale.copy(e.normalScale),this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this.specularMap=e.specularMap,this.alphaMap=e.alphaMap,this.envMap=e.envMap,this.combine=e.combine,this.reflectivity=e.reflectivity,this.refractionRatio=e.refractionRatio,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.wireframeLinecap=e.wireframeLinecap,this.wireframeLinejoin=e.wireframeLinejoin,this.flatShading=e.flatShading,this.fog=e.fog,this}}class hMe extends uS{constructor(e){super(),this.isLineDashedMaterial=!0,this.type=\"LineDashedMaterial\",this.scale=1,this.dashSize=3,this.gapSize=1,this.setValues(e)}copy(e){return super.copy(e),this.scale=e.scale,this.dashSize=e.dashSize,this.gapSize=e.gapSize,this}}class PJ extends lo{constructor(e,n=1){super(),this.isLight=!0,this.type=\"Light\",this.color=new Mn(e),this.intensity=n}dispose(){}copy(e,n){return super.copy(e,n),this.color.copy(e.color),this.intensity=e.intensity,this}toJSON(e){const n=super.toJSON(e);return n.object.color=this.color.getHex(),n.object.intensity=this.intensity,this.groundColor!==void 0&&(n.object.groundColor=this.groundColor.getHex()),this.distance!==void 0&&(n.object.distance=this.distance),this.angle!==void 0&&(n.object.angle=this.angle),this.decay!==void 0&&(n.object.decay=this.decay),this.penumbra!==void 0&&(n.object.penumbra=this.penumbra),this.shadow!==void 0&&(n.object.shadow=this.shadow.toJSON()),n}}const qE=new ba,N$=new ut,C$=new ut;class pMe{constructor(e){this.camera=e,this.bias=0,this.normalBias=0,this.radius=1,this.blurSamples=8,this.mapSize=new Vn(512,512),this.map=null,this.mapPass=null,this.matrix=new ba,this.autoUpdate=!0,this.needsUpdate=!1,this._frustum=new nT,this._frameExtents=new Vn(1,1),this._viewportCount=1,this._viewports=[new ta(0,0,1,1)]}getViewportCount(){return this._viewportCount}getFrustum(){return this._frustum}updateMatrices(e){const n=this.camera,r=this.matrix;N$.setFromMatrixPosition(e.matrixWorld),n.position.copy(N$),C$.setFromMatrixPosition(e.target.matrixWorld),n.lookAt(C$),n.updateMatrixWorld(),qE.multiplyMatrices(n.projectionMatrix,n.matrixWorldInverse),this._frustum.setFromProjectionMatrix(qE),r.set(.5,0,0,.5,0,.5,0,.5,0,0,.5,.5,0,0,0,1),r.multiply(qE)}getViewport(e){return this._viewports[e]}getFrameExtents(){return this._frameExtents}dispose(){this.map&&this.map.dispose(),this.mapPass&&this.mapPass.dispose()}copy(e){return this.camera=e.camera.clone(),this.bias=e.bias,this.radius=e.radius,this.mapSize.copy(e.mapSize),this}clone(){return new this.constructor().copy(this)}toJSON(){const e={};return this.bias!==0&&(e.bias=this.bias),this.normalBias!==0&&(e.normalBias=this.normalBias),this.radius!==1&&(e.radius=this.radius),(this.mapSize.x!==512||this.mapSize.y!==512)&&(e.mapSize=this.mapSize.toArray()),e.camera=this.camera.toJSON(!1).object,delete e.camera.matrix,e}}const P$=new ba,Vv=new ut,$E=new ut;class fMe extends pMe{constructor(){super(new ll(90,1,.5,500)),this.isPointLightShadow=!0,this._frameExtents=new Vn(4,2),this._viewportCount=6,this._viewports=[new ta(2,1,1,1),new ta(0,1,1,1),new ta(3,1,1,1),new ta(1,1,1,1),new ta(3,0,1,1),new ta(1,0,1,1)],this._cubeDirections=[new ut(1,0,0),new ut(-1,0,0),new ut(0,0,1),new ut(0,0,-1),new ut(0,1,0),new ut(0,-1,0)],this._cubeUps=[new ut(0,1,0),new ut(0,1,0),new ut(0,1,0),new ut(0,1,0),new ut(0,0,1),new ut(0,0,-1)]}updateMatrices(e,n=0){const r=this.camera,i=this.matrix,s=e.distance||r.far;s!==r.far&&(r.far=s,r.updateProjectionMatrix()),Vv.setFromMatrixPosition(e.matrixWorld),r.position.copy(Vv),$E.copy(r.position),$E.add(this._cubeDirections[n]),r.up.copy(this._cubeUps[n]),r.lookAt($E),r.updateMatrixWorld(),i.makeTranslation(-Vv.x,-Vv.y,-Vv.z),P$.multiplyMatrices(r.projectionMatrix,r.matrixWorldInverse),this._frustum.setFromProjectionMatrix(P$)}}class gMe extends PJ{constructor(e,n,r=0,i=2){super(e,n),this.isPointLight=!0,this.type=\"PointLight\",this.distance=r,this.decay=i,this.shadow=new fMe}get power(){return this.intensity*4*Math.PI}set power(e){this.intensity=e/(4*Math.PI)}dispose(){this.shadow.dispose()}copy(e,n){return super.copy(e,n),this.distance=e.distance,this.decay=e.decay,this.shadow=e.shadow.clone(),this}}class bMe extends PJ{constructor(e,n){super(e,n),this.isAmbientLight=!0,this.type=\"AmbientLight\"}}class xMe extends Vs{constructor(){super(),this.isInstancedBufferGeometry=!0,this.type=\"InstancedBufferGeometry\",this.instanceCount=1/0}copy(e){return super.copy(e),this.instanceCount=e.instanceCount,this}toJSON(){const e=super.toJSON();return e.instanceCount=this.instanceCount,e.isInstancedBufferGeometry=!0,e}}class bR extends nMe{constructor(e,n,r=1){super(e,n),this.isInstancedInterleavedBuffer=!0,this.meshPerAttribute=r}copy(e){return super.copy(e),this.meshPerAttribute=e.meshPerAttribute,this}clone(e){const n=super.clone(e);return n.meshPerAttribute=this.meshPerAttribute,n}toJSON(e){const n=super.toJSON(e);return n.isInstancedInterleavedBuffer=!0,n.meshPerAttribute=this.meshPerAttribute,n}}class T${constructor(e=1,n=0,r=0){return this.radius=e,this.phi=n,this.theta=r,this}set(e,n,r){return this.radius=e,this.phi=n,this.theta=r,this}copy(e){return this.radius=e.radius,this.phi=e.phi,this.theta=e.theta,this}makeSafe(){return this.phi=Math.max(1e-6,Math.min(Math.PI-1e-6,this.phi)),this}setFromVector3(e){return this.setFromCartesianCoords(e.x,e.y,e.z)}setFromCartesianCoords(e,n,r){return this.radius=Math.sqrt(e*e+n*n+r*r),this.radius===0?(this.theta=0,this.phi=0):(this.theta=Math.atan2(e,r),this.phi=Math.acos(xs(n/this.radius,-1,1))),this}clone(){return new this.constructor().copy(this)}}const A$=new ut,W1=new ut;class yMe{constructor(e=new ut,n=new ut){this.start=e,this.end=n}set(e,n){return this.start.copy(e),this.end.copy(n),this}copy(e){return this.start.copy(e.start),this.end.copy(e.end),this}getCenter(e){return e.addVectors(this.start,this.end).multiplyScalar(.5)}delta(e){return e.subVectors(this.end,this.start)}distanceSq(){return this.start.distanceToSquared(this.end)}distance(){return this.start.distanceTo(this.end)}at(e,n){return this.delta(n).multiplyScalar(e).add(this.start)}closestPointToPointParameter(e,n){A$.subVectors(e,this.start),W1.subVectors(this.end,this.start);const r=W1.dot(W1);let s=W1.dot(A$)/r;return n&&(s=xs(s,0,1)),s}closestPointToPoint(e,n,r){const i=this.closestPointToPointParameter(e,n);return this.delta(r).multiplyScalar(i).add(this.start)}applyMatrix4(e){return this.start.applyMatrix4(e),this.end.applyMatrix4(e),this}equals(e){return e.start.equals(this.start)&&e.end.equals(this.end)}clone(){return new this.constructor().copy(this)}}class vMe extends aT{constructor(e=1){const n=[0,0,0,e,0,0,0,0,0,0,e,0,0,0,0,0,0,e],r=[1,0,0,1,.6,0,0,1,0,.6,1,0,0,0,1,0,.6,1],i=new Vs;i.setAttribute(\"position\",new li(n,3)),i.setAttribute(\"color\",new li(r,3));const s=new uS({vertexColors:!0,toneMapped:!1});super(i,s),this.type=\"AxesHelper\"}setColors(e,n,r){const i=new Mn,s=this.geometry.attributes.color.array;return i.set(e),i.toArray(s,0),i.toArray(s,3),i.set(n),i.toArray(s,6),i.toArray(s,9),i.set(r),i.toArray(s,12),i.toArray(s,15),this.geometry.attributes.color.needsUpdate=!0,this}dispose(){this.geometry.dispose(),this.material.dispose()}}typeof __THREE_DEVTOOLS__<\"u\"&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent(\"register\",{detail:{revision:JP}}));typeof window<\"u\"&&(window.__THREE__?console.warn(\"WARNING: Multiple instances of Three.js being imported.\"):window.__THREE__=JP);class Od{constructor(e,n,r,i,s=\"div\"){this.parent=e,this.object=n,this.property=r,this._disabled=!1,this._hidden=!1,this.initialValue=this.getValue(),this.domElement=document.createElement(s),this.domElement.classList.add(\"controller\"),this.domElement.classList.add(i),this.$name=document.createElement(\"div\"),this.$name.classList.add(\"name\"),Od.nextNameID=Od.nextNameID||0,this.$name.id=`lil-gui-name-${++Od.nextNameID}`,this.$widget=document.createElement(\"div\"),this.$widget.classList.add(\"widget\"),this.$disable=this.$widget,this.domElement.appendChild(this.$name),this.domElement.appendChild(this.$widget),this.domElement.addEventListener(\"keydown\",o=>o.stopPropagation()),this.domElement.addEventListener(\"keyup\",o=>o.stopPropagation()),this.parent.children.push(this),this.parent.controllers.push(this),this.parent.$children.appendChild(this.domElement),this._listenCallback=this._listenCallback.bind(this),this.name(r)}name(e){return this._name=e,this.$name.textContent=e,this}onChange(e){return this._onChange=e,this}_callOnChange(){this.parent._callOnChange(this),this._onChange!==void 0&&this._onChange.call(this,this.getValue()),this._changed=!0}onFinishChange(e){return this._onFinishChange=e,this}_callOnFinishChange(){this._changed&&(this.parent._callOnFinishChange(this),this._onFinishChange!==void 0&&this._onFinishChange.call(this,this.getValue())),this._changed=!1}reset(){return this.setValue(this.initialValue),this._callOnFinishChange(),this}enable(e=!0){return this.disable(!e)}disable(e=!0){return e===this._disabled?this:(this._disabled=e,this.domElement.classList.toggle(\"disabled\",e),this.$disable.toggleAttribute(\"disabled\",e),this)}show(e=!0){return this._hidden=!e,this.domElement.style.display=this._hidden?\"none\":\"\",this}hide(){return this.show(!1)}options(e){const n=this.parent.add(this.object,this.property,e);return n.name(this._name),this.destroy(),n}min(e){return this}max(e){return this}step(e){return this}decimals(e){return this}listen(e=!0){return this._listening=e,this._listenCallbackID!==void 0&&(cancelAnimationFrame(this._listenCallbackID),this._listenCallbackID=void 0),this._listening&&this._listenCallback(),this}_listenCallback(){this._listenCallbackID=requestAnimationFrame(this._listenCallback);const e=this.save();e!==this._listenPrevValue&&this.updateDisplay(),this._listenPrevValue=e}getValue(){return this.object[this.property]}setValue(e){return this.getValue()!==e&&(this.object[this.property]=e,this._callOnChange(),this.updateDisplay()),this}updateDisplay(){return this}load(e){return this.setValue(e),this._callOnFinishChange(),this}save(){return this.getValue()}destroy(){this.listen(!1),this.parent.children.splice(this.parent.children.indexOf(this),1),this.parent.controllers.splice(this.parent.controllers.indexOf(this),1),this.parent.$children.removeChild(this.domElement)}}class wMe extends Od{constructor(e,n,r){super(e,n,r,\"boolean\",\"label\"),this.$input=document.createElement(\"input\"),this.$input.setAttribute(\"type\",\"checkbox\"),this.$input.setAttribute(\"aria-labelledby\",this.$name.id),this.$widget.appendChild(this.$input),this.$input.addEventListener(\"change\",()=>{this.setValue(this.$input.checked),this._callOnFinishChange()}),this.$disable=this.$input,this.updateDisplay()}updateDisplay(){return this.$input.checked=this.getValue(),this}}function xR(t){let e,n;return(e=t.match(/(#|0x)?([a-f0-9]{6})/i))?n=e[2]:(e=t.match(/rgb\\(\\s*(\\d*)\\s*,\\s*(\\d*)\\s*,\\s*(\\d*)\\s*\\)/))?n=parseInt(e[1]).toString(16).padStart(2,0)+parseInt(e[2]).toString(16).padStart(2,0)+parseInt(e[3]).toString(16).padStart(2,0):(e=t.match(/^#?([a-f0-9])([a-f0-9])([a-f0-9])$/i))&&(n=e[1]+e[1]+e[2]+e[2]+e[3]+e[3]),n?\"#\"+n:!1}const SMe={isPrimitive:!0,match:t=>typeof t==\"string\",fromHexString:xR,toHexString:xR},bw={isPrimitive:!0,match:t=>typeof t==\"number\",fromHexString:t=>parseInt(t.substring(1),16),toHexString:t=>\"#\"+t.toString(16).padStart(6,0)},_Me={isPrimitive:!1,match:t=>Array.isArray(t),fromHexString(t,e,n=1){const r=bw.fromHexString(t);e[0]=(r>>16&255)/255*n,e[1]=(r>>8&255)/255*n,e[2]=(r&255)/255*n},toHexString([t,e,n],r=1){r=255/r;const i=t*r<<16^e*r<<8^n*r<<0;return bw.toHexString(i)}},kMe={isPrimitive:!1,match:t=>Object(t)===t,fromHexString(t,e,n=1){const r=bw.fromHexString(t);e.r=(r>>16&255)/255*n,e.g=(r>>8&255)/255*n,e.b=(r&255)/255*n},toHexString({r:t,g:e,b:n},r=1){r=255/r;const i=t*r<<16^e*r<<8^n*r<<0;return bw.toHexString(i)}},NMe=[SMe,bw,_Me,kMe];function CMe(t){return NMe.find(e=>e.match(t))}class PMe extends Od{constructor(e,n,r,i){super(e,n,r,\"color\"),this.$input=document.createElement(\"input\"),this.$input.setAttribute(\"type\",\"color\"),this.$input.setAttribute(\"tabindex\",-1),this.$input.setAttribute(\"aria-labelledby\",this.$name.id),this.$text=document.createElement(\"input\"),this.$text.setAttribute(\"type\",\"text\"),this.$text.setAttribute(\"spellcheck\",\"false\"),this.$text.setAttribute(\"aria-labelledby\",this.$name.id),this.$display=document.createElement(\"div\"),this.$display.classList.add(\"display\"),this.$display.appendChild(this.$input),this.$widget.appendChild(this.$display),this.$widget.appendChild(this.$text),this._format=CMe(this.initialValue),this._rgbScale=i,this._initialValueHexString=this.save(),this._textFocused=!1,this.$input.addEventListener(\"input\",()=>{this._setValueFromHexString(this.$input.value)}),this.$input.addEventListener(\"blur\",()=>{this._callOnFinishChange()}),this.$text.addEventListener(\"input\",()=>{const s=xR(this.$text.value);s&&this._setValueFromHexString(s)}),this.$text.addEventListener(\"focus\",()=>{this._textFocused=!0,this.$text.select()}),this.$text.addEventListener(\"blur\",()=>{this._textFocused=!1,this.updateDisplay(),this._callOnFinishChange()}),this.$disable=this.$text,this.updateDisplay()}reset(){return this._setValueFromHexString(this._initialValueHexString),this}_setValueFromHexString(e){if(this._format.isPrimitive){const n=this._format.fromHexString(e);this.setValue(n)}else this._format.fromHexString(e,this.getValue(),this._rgbScale),this._callOnChange(),this.updateDisplay()}save(){return this._format.toHexString(this.getValue(),this._rgbScale)}load(e){return this._setValueFromHexString(e),this._callOnFinishChange(),this}updateDisplay(){return this.$input.value=this._format.toHexString(this.getValue(),this._rgbScale),this._textFocused||(this.$text.value=this.$input.value.substring(1)),this.$display.style.backgroundColor=this.$input.value,this}}class VE extends Od{constructor(e,n,r){super(e,n,r,\"function\"),this.$button=document.createElement(\"button\"),this.$button.appendChild(this.$name),this.$widget.appendChild(this.$button),this.$button.addEventListener(\"click\",i=>{i.preventDefault(),this.getValue().call(this.object),this._callOnChange()}),this.$button.addEventListener(\"touchstart\",()=>{},{passive:!0}),this.$disable=this.$button}}class TMe extends Od{constructor(e,n,r,i,s,o){super(e,n,r,\"number\"),this._initInput(),this.min(i),this.max(s);const l=o!==void 0;this.step(l?o:this._getImplicitStep(),l),this.updateDisplay()}decimals(e){return this._decimals=e,this.updateDisplay(),this}min(e){return this._min=e,this._onUpdateMinMax(),this}max(e){return this._max=e,this._onUpdateMinMax(),this}step(e,n=!0){return this._step=e,this._stepExplicit=n,this}updateDisplay(){const e=this.getValue();if(this._hasSlider){let n=(e-this._min)/(this._max-this._min);n=Math.max(0,Math.min(n,1)),this.$fill.style.width=n*100+\"%\"}return this._inputFocused||(this.$input.value=this._decimals===void 0?e:e.toFixed(this._decimals)),this}_initInput(){this.$input=document.createElement(\"input\"),this.$input.setAttribute(\"type\",\"text\"),this.$input.setAttribute(\"aria-labelledby\",this.$name.id),window.matchMedia(\"(pointer: coarse)\").matches&&(this.$input.setAttribute(\"type\",\"number\"),this.$input.setAttribute(\"step\",\"any\")),this.$widget.appendChild(this.$input),this.$disable=this.$input;const n=()=>{let _=parseFloat(this.$input.value);isNaN(_)||(this._stepExplicit&&(_=this._snap(_)),this.setValue(this._clamp(_)))},r=_=>{const C=parseFloat(this.$input.value);isNaN(C)||(this._snapClampSetValue(C+_),this.$input.value=this.getValue())},i=_=>{_.key===\"Enter\"&&this.$input.blur(),_.code===\"ArrowUp\"&&(_.preventDefault(),r(this._step*this._arrowKeyMultiplier(_))),_.code===\"ArrowDown\"&&(_.preventDefault(),r(this._step*this._arrowKeyMultiplier(_)*-1))},s=_=>{this._inputFocused&&(_.preventDefault(),r(this._step*this._normalizeMouseWheel(_)))};let o=!1,l,c,d,u,m;const p=5,f=_=>{l=_.clientX,c=d=_.clientY,o=!0,u=this.getValue(),m=0,window.addEventListener(\"mousemove\",y),window.addEventListener(\"mouseup\",v)},y=_=>{if(o){const C=_.clientX-l,P=_.clientY-c;Math.abs(P)>p?(_.preventDefault(),this.$input.blur(),o=!1,this._setDraggingStyle(!0,\"vertical\")):Math.abs(C)>p&&v()}if(!o){const C=_.clientY-d;m-=C*this._step*this._arrowKeyMultiplier(_),u+m>this._max?m=this._max-u:u+m<this._min&&(m=this._min-u),this._snapClampSetValue(u+m)}d=_.clientY},v=()=>{this._setDraggingStyle(!1,\"vertical\"),this._callOnFinishChange(),window.removeEventListener(\"mousemove\",y),window.removeEventListener(\"mouseup\",v)},b=()=>{this._inputFocused=!0},g=()=>{this._inputFocused=!1,this.updateDisplay(),this._callOnFinishChange()};this.$input.addEventListener(\"input\",n),this.$input.addEventListener(\"keydown\",i),this.$input.addEventListener(\"wheel\",s,{passive:!1}),this.$input.addEventListener(\"mousedown\",f),this.$input.addEventListener(\"focus\",b),this.$input.addEventListener(\"blur\",g)}_initSlider(){this._hasSlider=!0,this.$slider=document.createElement(\"div\"),this.$slider.classList.add(\"slider\"),this.$fill=document.createElement(\"div\"),this.$fill.classList.add(\"fill\"),this.$slider.appendChild(this.$fill),this.$widget.insertBefore(this.$slider,this.$input),this.domElement.classList.add(\"hasSlider\");const e=(g,_,C,P,N)=>(g-_)/(C-_)*(N-P)+P,n=g=>{const _=this.$slider.getBoundingClientRect();let C=e(g,_.left,_.right,this._min,this._max);this._snapClampSetValue(C)},r=g=>{this._setDraggingStyle(!0),n(g.clientX),window.addEventListener(\"mousemove\",i),window.addEventListener(\"mouseup\",s)},i=g=>{n(g.clientX)},s=()=>{this._callOnFinishChange(),this._setDraggingStyle(!1),window.removeEventListener(\"mousemove\",i),window.removeEventListener(\"mouseup\",s)};let o=!1,l,c;const d=g=>{g.preventDefault(),this._setDraggingStyle(!0),n(g.touches[0].clientX),o=!1},u=g=>{g.touches.length>1||(this._hasScrollBar?(l=g.touches[0].clientX,c=g.touches[0].clientY,o=!0):d(g),window.addEventListener(\"touchmove\",m,{passive:!1}),window.addEventListener(\"touchend\",p))},m=g=>{if(o){const _=g.touches[0].clientX-l,C=g.touches[0].clientY-c;Math.abs(_)>Math.abs(C)?d(g):(window.removeEventListener(\"touchmove\",m),window.removeEventListener(\"touchend\",p))}else g.preventDefault(),n(g.touches[0].clientX)},p=()=>{this._callOnFinishChange(),this._setDraggingStyle(!1),window.removeEventListener(\"touchmove\",m),window.removeEventListener(\"touchend\",p)},f=this._callOnFinishChange.bind(this),y=400;let v;const b=g=>{if(Math.abs(g.deltaX)<Math.abs(g.deltaY)&&this._hasScrollBar)return;g.preventDefault();const C=this._normalizeMouseWheel(g)*this._step;this._snapClampSetValue(this.getValue()+C),this.$input.value=this.getValue(),clearTimeout(v),v=setTimeout(f,y)};this.$slider.addEventListener(\"mousedown\",r),this.$slider.addEventListener(\"touchstart\",u,{passive:!1}),this.$slider.addEventListener(\"wheel\",b,{passive:!1})}_setDraggingStyle(e,n=\"horizontal\"){this.$slider&&this.$slider.classList.toggle(\"active\",e),document.body.classList.toggle(\"lil-gui-dragging\",e),document.body.classList.toggle(`lil-gui-${n}`,e)}_getImplicitStep(){return this._hasMin&&this._hasMax?(this._max-this._min)/1e3:.1}_onUpdateMinMax(){!this._hasSlider&&this._hasMin&&this._hasMax&&(this._stepExplicit||this.step(this._getImplicitStep(),!1),this._initSlider(),this.updateDisplay())}_normalizeMouseWheel(e){let{deltaX:n,deltaY:r}=e;return Math.floor(e.deltaY)!==e.deltaY&&e.wheelDelta&&(n=0,r=-e.wheelDelta/120,r*=this._stepExplicit?1:10),n+-r}_arrowKeyMultiplier(e){let n=this._stepExplicit?1:10;return e.shiftKey?n*=10:e.altKey&&(n/=10),n}_snap(e){const n=Math.round(e/this._step)*this._step;return parseFloat(n.toPrecision(15))}_clamp(e){return e<this._min&&(e=this._min),e>this._max&&(e=this._max),e}_snapClampSetValue(e){this.setValue(this._clamp(this._snap(e)))}get _hasScrollBar(){const e=this.parent.root.$children;return e.scrollHeight>e.clientHeight}get _hasMin(){return this._min!==void 0}get _hasMax(){return this._max!==void 0}}class AMe extends Od{constructor(e,n,r,i){super(e,n,r,\"option\"),this.$select=document.createElement(\"select\"),this.$select.setAttribute(\"aria-labelledby\",this.$name.id),this.$display=document.createElement(\"div\"),this.$display.classList.add(\"display\"),this.$select.addEventListener(\"change\",()=>{this.setValue(this._values[this.$select.selectedIndex]),this._callOnFinishChange()}),this.$select.addEventListener(\"focus\",()=>{this.$display.classList.add(\"focus\")}),this.$select.addEventListener(\"blur\",()=>{this.$display.classList.remove(\"focus\")}),this.$widget.appendChild(this.$select),this.$widget.appendChild(this.$display),this.$disable=this.$select,this.options(i)}options(e){return this._values=Array.isArray(e)?e:Object.values(e),this._names=Array.isArray(e)?e:Object.keys(e),this.$select.replaceChildren(),this._names.forEach(n=>{const r=document.createElement(\"option\");r.textContent=n,this.$select.appendChild(r)}),this.updateDisplay(),this}updateDisplay(){const e=this.getValue(),n=this._values.indexOf(e);return this.$select.selectedIndex=n,this.$display.textContent=n===-1?e:this._names[n],this}}class jMe extends Od{constructor(e,n,r){super(e,n,r,\"string\"),this.$input=document.createElement(\"input\"),this.$input.setAttribute(\"type\",\"text\"),this.$input.setAttribute(\"spellcheck\",\"false\"),this.$input.setAttribute(\"aria-labelledby\",this.$name.id),this.$input.addEventListener(\"input\",()=>{this.setValue(this.$input.value)}),this.$input.addEventListener(\"keydown\",i=>{i.code===\"Enter\"&&this.$input.blur()}),this.$input.addEventListener(\"blur\",()=>{this._callOnFinishChange()}),this.$widget.appendChild(this.$input),this.$disable=this.$input,this.updateDisplay()}updateDisplay(){return this.$input.value=this.getValue(),this}}const MMe=`.lil-gui {\n  font-family: var(--font-family);\n  font-size: var(--font-size);\n  line-height: 1;\n  font-weight: normal;\n  font-style: normal;\n  text-align: left;\n  color: var(--text-color);\n  user-select: none;\n  -webkit-user-select: none;\n  touch-action: manipulation;\n  --background-color: #1f1f1f;\n  --text-color: #ebebeb;\n  --title-background-color: #111111;\n  --title-text-color: #ebebeb;\n  --widget-color: #424242;\n  --hover-color: #4f4f4f;\n  --focus-color: #595959;\n  --number-color: #2cc9ff;\n  --string-color: #a2db3c;\n  --font-size: 11px;\n  --input-font-size: 11px;\n  --font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Arial, sans-serif;\n  --font-family-mono: Menlo, Monaco, Consolas, \"Droid Sans Mono\", monospace;\n  --padding: 4px;\n  --spacing: 4px;\n  --widget-height: 20px;\n  --title-height: calc(var(--widget-height) + var(--spacing) * 1.25);\n  --name-width: 45%;\n  --slider-knob-width: 2px;\n  --slider-input-width: 27%;\n  --color-input-width: 27%;\n  --slider-input-min-width: 45px;\n  --color-input-min-width: 45px;\n  --folder-indent: 7px;\n  --widget-padding: 0 0 0 3px;\n  --widget-border-radius: 2px;\n  --checkbox-size: calc(0.75 * var(--widget-height));\n  --scrollbar-width: 5px;\n}\n.lil-gui, .lil-gui * {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n}\n.lil-gui.root {\n  width: var(--width, 245px);\n  display: flex;\n  flex-direction: column;\n  background: var(--background-color);\n}\n.lil-gui.root > .title {\n  background: var(--title-background-color);\n  color: var(--title-text-color);\n}\n.lil-gui.root > .children {\n  overflow-x: hidden;\n  overflow-y: auto;\n}\n.lil-gui.root > .children::-webkit-scrollbar {\n  width: var(--scrollbar-width);\n  height: var(--scrollbar-width);\n  background: var(--background-color);\n}\n.lil-gui.root > .children::-webkit-scrollbar-thumb {\n  border-radius: var(--scrollbar-width);\n  background: var(--focus-color);\n}\n@media (pointer: coarse) {\n  .lil-gui.allow-touch-styles, .lil-gui.allow-touch-styles .lil-gui {\n    --widget-height: 28px;\n    --padding: 6px;\n    --spacing: 6px;\n    --font-size: 13px;\n    --input-font-size: 16px;\n    --folder-indent: 10px;\n    --scrollbar-width: 7px;\n    --slider-input-min-width: 50px;\n    --color-input-min-width: 65px;\n  }\n}\n.lil-gui.force-touch-styles, .lil-gui.force-touch-styles .lil-gui {\n  --widget-height: 28px;\n  --padding: 6px;\n  --spacing: 6px;\n  --font-size: 13px;\n  --input-font-size: 16px;\n  --folder-indent: 10px;\n  --scrollbar-width: 7px;\n  --slider-input-min-width: 50px;\n  --color-input-min-width: 65px;\n}\n.lil-gui.autoPlace {\n  max-height: 100%;\n  position: fixed;\n  top: 0;\n  right: 15px;\n  z-index: 1001;\n}\n\n.lil-gui .controller {\n  display: flex;\n  align-items: center;\n  padding: 0 var(--padding);\n  margin: var(--spacing) 0;\n}\n.lil-gui .controller.disabled {\n  opacity: 0.5;\n}\n.lil-gui .controller.disabled, .lil-gui .controller.disabled * {\n  pointer-events: none !important;\n}\n.lil-gui .controller > .name {\n  min-width: var(--name-width);\n  flex-shrink: 0;\n  white-space: pre;\n  padding-right: var(--spacing);\n  line-height: var(--widget-height);\n}\n.lil-gui .controller .widget {\n  position: relative;\n  display: flex;\n  align-items: center;\n  width: 100%;\n  min-height: var(--widget-height);\n}\n.lil-gui .controller.string input {\n  color: var(--string-color);\n}\n.lil-gui .controller.boolean {\n  cursor: pointer;\n}\n.lil-gui .controller.color .display {\n  width: 100%;\n  height: var(--widget-height);\n  border-radius: var(--widget-border-radius);\n  position: relative;\n}\n@media (hover: hover) {\n  .lil-gui .controller.color .display:hover:before {\n    content: \" \";\n    display: block;\n    position: absolute;\n    border-radius: var(--widget-border-radius);\n    border: 1px solid #fff9;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n  }\n}\n.lil-gui .controller.color input[type=color] {\n  opacity: 0;\n  width: 100%;\n  height: 100%;\n  cursor: pointer;\n}\n.lil-gui .controller.color input[type=text] {\n  margin-left: var(--spacing);\n  font-family: var(--font-family-mono);\n  min-width: var(--color-input-min-width);\n  width: var(--color-input-width);\n  flex-shrink: 0;\n}\n.lil-gui .controller.option select {\n  opacity: 0;\n  position: absolute;\n  width: 100%;\n  max-width: 100%;\n}\n.lil-gui .controller.option .display {\n  position: relative;\n  pointer-events: none;\n  border-radius: var(--widget-border-radius);\n  height: var(--widget-height);\n  line-height: var(--widget-height);\n  max-width: 100%;\n  overflow: hidden;\n  word-break: break-all;\n  padding-left: 0.55em;\n  padding-right: 1.75em;\n  background: var(--widget-color);\n}\n@media (hover: hover) {\n  .lil-gui .controller.option .display.focus {\n    background: var(--focus-color);\n  }\n}\n.lil-gui .controller.option .display.active {\n  background: var(--focus-color);\n}\n.lil-gui .controller.option .display:after {\n  font-family: \"lil-gui\";\n  content: \"↕\";\n  position: absolute;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  padding-right: 0.375em;\n}\n.lil-gui .controller.option .widget,\n.lil-gui .controller.option select {\n  cursor: pointer;\n}\n@media (hover: hover) {\n  .lil-gui .controller.option .widget:hover .display {\n    background: var(--hover-color);\n  }\n}\n.lil-gui .controller.number input {\n  color: var(--number-color);\n}\n.lil-gui .controller.number.hasSlider input {\n  margin-left: var(--spacing);\n  width: var(--slider-input-width);\n  min-width: var(--slider-input-min-width);\n  flex-shrink: 0;\n}\n.lil-gui .controller.number .slider {\n  width: 100%;\n  height: var(--widget-height);\n  background: var(--widget-color);\n  border-radius: var(--widget-border-radius);\n  padding-right: var(--slider-knob-width);\n  overflow: hidden;\n  cursor: ew-resize;\n  touch-action: pan-y;\n}\n@media (hover: hover) {\n  .lil-gui .controller.number .slider:hover {\n    background: var(--hover-color);\n  }\n}\n.lil-gui .controller.number .slider.active {\n  background: var(--focus-color);\n}\n.lil-gui .controller.number .slider.active .fill {\n  opacity: 0.95;\n}\n.lil-gui .controller.number .fill {\n  height: 100%;\n  border-right: var(--slider-knob-width) solid var(--number-color);\n  box-sizing: content-box;\n}\n\n.lil-gui-dragging .lil-gui {\n  --hover-color: var(--widget-color);\n}\n.lil-gui-dragging * {\n  cursor: ew-resize !important;\n}\n\n.lil-gui-dragging.lil-gui-vertical * {\n  cursor: ns-resize !important;\n}\n\n.lil-gui .title {\n  height: var(--title-height);\n  line-height: calc(var(--title-height) - 4px);\n  font-weight: 600;\n  padding: 0 var(--padding);\n  -webkit-tap-highlight-color: transparent;\n  cursor: pointer;\n  outline: none;\n  text-decoration-skip: objects;\n}\n.lil-gui .title:before {\n  font-family: \"lil-gui\";\n  content: \"▾\";\n  padding-right: 2px;\n  display: inline-block;\n}\n.lil-gui .title:active {\n  background: var(--title-background-color);\n  opacity: 0.75;\n}\n@media (hover: hover) {\n  body:not(.lil-gui-dragging) .lil-gui .title:hover {\n    background: var(--title-background-color);\n    opacity: 0.85;\n  }\n  .lil-gui .title:focus {\n    text-decoration: underline var(--focus-color);\n  }\n}\n.lil-gui.root > .title:focus {\n  text-decoration: none !important;\n}\n.lil-gui.closed > .title:before {\n  content: \"▸\";\n}\n.lil-gui.closed > .children {\n  transform: translateY(-7px);\n  opacity: 0;\n}\n.lil-gui.closed:not(.transition) > .children {\n  display: none;\n}\n.lil-gui.transition > .children {\n  transition-duration: 300ms;\n  transition-property: height, opacity, transform;\n  transition-timing-function: cubic-bezier(0.2, 0.6, 0.35, 1);\n  overflow: hidden;\n  pointer-events: none;\n}\n.lil-gui .children:empty:before {\n  content: \"Empty\";\n  padding: 0 var(--padding);\n  margin: var(--spacing) 0;\n  display: block;\n  height: var(--widget-height);\n  font-style: italic;\n  line-height: var(--widget-height);\n  opacity: 0.5;\n}\n.lil-gui.root > .children > .lil-gui > .title {\n  border: 0 solid var(--widget-color);\n  border-width: 1px 0;\n  transition: border-color 300ms;\n}\n.lil-gui.root > .children > .lil-gui.closed > .title {\n  border-bottom-color: transparent;\n}\n.lil-gui + .controller {\n  border-top: 1px solid var(--widget-color);\n  margin-top: 0;\n  padding-top: var(--spacing);\n}\n.lil-gui .lil-gui .lil-gui > .title {\n  border: none;\n}\n.lil-gui .lil-gui .lil-gui > .children {\n  border: none;\n  margin-left: var(--folder-indent);\n  border-left: 2px solid var(--widget-color);\n}\n.lil-gui .lil-gui .controller {\n  border: none;\n}\n\n.lil-gui label, .lil-gui input, .lil-gui button {\n  -webkit-tap-highlight-color: transparent;\n}\n.lil-gui input {\n  border: 0;\n  outline: none;\n  font-family: var(--font-family);\n  font-size: var(--input-font-size);\n  border-radius: var(--widget-border-radius);\n  height: var(--widget-height);\n  background: var(--widget-color);\n  color: var(--text-color);\n  width: 100%;\n}\n@media (hover: hover) {\n  .lil-gui input:hover {\n    background: var(--hover-color);\n  }\n  .lil-gui input:active {\n    background: var(--focus-color);\n  }\n}\n.lil-gui input:disabled {\n  opacity: 1;\n}\n.lil-gui input[type=text],\n.lil-gui input[type=number] {\n  padding: var(--widget-padding);\n  -moz-appearance: textfield;\n}\n.lil-gui input[type=text]:focus,\n.lil-gui input[type=number]:focus {\n  background: var(--focus-color);\n}\n.lil-gui input[type=checkbox] {\n  appearance: none;\n  width: var(--checkbox-size);\n  height: var(--checkbox-size);\n  border-radius: var(--widget-border-radius);\n  text-align: center;\n  cursor: pointer;\n}\n.lil-gui input[type=checkbox]:checked:before {\n  font-family: \"lil-gui\";\n  content: \"✓\";\n  font-size: var(--checkbox-size);\n  line-height: var(--checkbox-size);\n}\n@media (hover: hover) {\n  .lil-gui input[type=checkbox]:focus {\n    box-shadow: inset 0 0 0 1px var(--focus-color);\n  }\n}\n.lil-gui button {\n  outline: none;\n  cursor: pointer;\n  font-family: var(--font-family);\n  font-size: var(--font-size);\n  color: var(--text-color);\n  width: 100%;\n  height: var(--widget-height);\n  text-transform: none;\n  background: var(--widget-color);\n  border-radius: var(--widget-border-radius);\n  border: none;\n}\n@media (hover: hover) {\n  .lil-gui button:hover {\n    background: var(--hover-color);\n  }\n  .lil-gui button:focus {\n    box-shadow: inset 0 0 0 1px var(--focus-color);\n  }\n}\n.lil-gui button:active {\n  background: var(--focus-color);\n}\n\n@font-face {\n  font-family: \"lil-gui\";\n  src: url(\"data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAAAUsAAsAAAAACJwAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAAH4AAADAImwmYE9TLzIAAAGIAAAAPwAAAGBKqH5SY21hcAAAAcgAAAD0AAACrukyyJBnbHlmAAACvAAAAF8AAACEIZpWH2hlYWQAAAMcAAAAJwAAADZfcj2zaGhlYQAAA0QAAAAYAAAAJAC5AHhobXR4AAADXAAAABAAAABMAZAAAGxvY2EAAANsAAAAFAAAACgCEgIybWF4cAAAA4AAAAAeAAAAIAEfABJuYW1lAAADoAAAASIAAAIK9SUU/XBvc3QAAATEAAAAZgAAAJCTcMc2eJxVjbEOgjAURU+hFRBK1dGRL+ALnAiToyMLEzFpnPz/eAshwSa97517c/MwwJmeB9kwPl+0cf5+uGPZXsqPu4nvZabcSZldZ6kfyWnomFY/eScKqZNWupKJO6kXN3K9uCVoL7iInPr1X5baXs3tjuMqCtzEuagm/AAlzQgPAAB4nGNgYRBlnMDAysDAYM/gBiT5oLQBAwuDJAMDEwMrMwNWEJDmmsJwgCFeXZghBcjlZMgFCzOiKOIFAB71Bb8AeJy1kjFuwkAQRZ+DwRAwBtNQRUGKQ8OdKCAWUhAgKLhIuAsVSpWz5Bbkj3dEgYiUIszqWdpZe+Z7/wB1oCYmIoboiwiLT2WjKl/jscrHfGg/pKdMkyklC5Zs2LEfHYpjcRoPzme9MWWmk3dWbK9ObkWkikOetJ554fWyoEsmdSlt+uR0pCJR34b6t/TVg1SY3sYvdf8vuiKrpyaDXDISiegp17p7579Gp3p++y7HPAiY9pmTibljrr85qSidtlg4+l25GLCaS8e6rRxNBmsnERunKbaOObRz7N72ju5vdAjYpBXHgJylOAVsMseDAPEP8LYoUHicY2BiAAEfhiAGJgZWBgZ7RnFRdnVJELCQlBSRlATJMoLV2DK4glSYs6ubq5vbKrJLSbGrgEmovDuDJVhe3VzcXFwNLCOILB/C4IuQ1xTn5FPilBTj5FPmBAB4WwoqAHicY2BkYGAA4sk1sR/j+W2+MnAzpDBgAyEMQUCSg4EJxAEAwUgFHgB4nGNgZGBgSGFggJMhDIwMqEAYAByHATJ4nGNgAIIUNEwmAABl3AGReJxjYAACIQYlBiMGJ3wQAEcQBEV4nGNgZGBgEGZgY2BiAAEQyQWEDAz/wXwGAAsPATIAAHicXdBNSsNAHAXwl35iA0UQXYnMShfS9GPZA7T7LgIu03SSpkwzYTIt1BN4Ak/gKTyAeCxfw39jZkjymzcvAwmAW/wgwHUEGDb36+jQQ3GXGot79L24jxCP4gHzF/EIr4jEIe7wxhOC3g2TMYy4Q7+Lu/SHuEd/ivt4wJd4wPxbPEKMX3GI5+DJFGaSn4qNzk8mcbKSR6xdXdhSzaOZJGtdapd4vVPbi6rP+cL7TGXOHtXKll4bY1Xl7EGnPtp7Xy2n00zyKLVHfkHBa4IcJ2oD3cgggWvt/V/FbDrUlEUJhTn/0azVWbNTNr0Ens8de1tceK9xZmfB1CPjOmPH4kitmvOubcNpmVTN3oFJyjzCvnmrwhJTzqzVj9jiSX911FjeAAB4nG3HMRKCMBBA0f0giiKi4DU8k0V2GWbIZDOh4PoWWvq6J5V8If9NVNQcaDhyouXMhY4rPTcG7jwYmXhKq8Wz+p762aNaeYXom2n3m2dLTVgsrCgFJ7OTmIkYbwIbC6vIB7WmFfAAAA==\") format(\"woff\");\n}`;function EMe(t){const e=document.createElement(\"style\");e.innerHTML=t;const n=document.querySelector(\"head link[rel=stylesheet], head style\");n?document.head.insertBefore(e,n):document.head.appendChild(e)}let j$=!1;class zN{constructor({parent:e,autoPlace:n=e===void 0,container:r,width:i,title:s=\"Controls\",closeFolders:o=!1,injectStyles:l=!0,touchStyles:c=!0}={}){if(this.parent=e,this.root=e?e.root:this,this.children=[],this.controllers=[],this.folders=[],this._closed=!1,this._hidden=!1,this.domElement=document.createElement(\"div\"),this.domElement.classList.add(\"lil-gui\"),this.$title=document.createElement(\"div\"),this.$title.classList.add(\"title\"),this.$title.setAttribute(\"role\",\"button\"),this.$title.setAttribute(\"aria-expanded\",!0),this.$title.setAttribute(\"tabindex\",0),this.$title.addEventListener(\"click\",()=>this.openAnimated(this._closed)),this.$title.addEventListener(\"keydown\",d=>{(d.code===\"Enter\"||d.code===\"Space\")&&(d.preventDefault(),this.$title.click())}),this.$title.addEventListener(\"touchstart\",()=>{},{passive:!0}),this.$children=document.createElement(\"div\"),this.$children.classList.add(\"children\"),this.domElement.appendChild(this.$title),this.domElement.appendChild(this.$children),this.title(s),this.parent){this.parent.children.push(this),this.parent.folders.push(this),this.parent.$children.appendChild(this.domElement);return}this.domElement.classList.add(\"root\"),c&&this.domElement.classList.add(\"allow-touch-styles\"),!j$&&l&&(EMe(MMe),j$=!0),r?r.appendChild(this.domElement):n&&(this.domElement.classList.add(\"autoPlace\"),document.body.appendChild(this.domElement)),i&&this.domElement.style.setProperty(\"--width\",i+\"px\"),this._closeFolders=o}add(e,n,r,i,s){if(Object(r)===r)return new AMe(this,e,n,r);const o=e[n];switch(typeof o){case\"number\":return new TMe(this,e,n,r,i,s);case\"boolean\":return new wMe(this,e,n);case\"string\":return new jMe(this,e,n);case\"function\":return new VE(this,e,n)}console.error(`gui.add failed\n\tproperty:`,n,`\n\tobject:`,e,`\n\tvalue:`,o)}addColor(e,n,r=1){return new PMe(this,e,n,r)}addFolder(e){const n=new zN({parent:this,title:e});return this.root._closeFolders&&n.close(),n}load(e,n=!0){return e.controllers&&this.controllers.forEach(r=>{r instanceof VE||r._name in e.controllers&&r.load(e.controllers[r._name])}),n&&e.folders&&this.folders.forEach(r=>{r._title in e.folders&&r.load(e.folders[r._title])}),this}save(e=!0){const n={controllers:{},folders:{}};return this.controllers.forEach(r=>{if(!(r instanceof VE)){if(r._name in n.controllers)throw new Error(`Cannot save GUI with duplicate property \"${r._name}\"`);n.controllers[r._name]=r.save()}}),e&&this.folders.forEach(r=>{if(r._title in n.folders)throw new Error(`Cannot save GUI with duplicate folder \"${r._title}\"`);n.folders[r._title]=r.save()}),n}open(e=!0){return this._setClosed(!e),this.$title.setAttribute(\"aria-expanded\",!this._closed),this.domElement.classList.toggle(\"closed\",this._closed),this}close(){return this.open(!1)}_setClosed(e){this._closed!==e&&(this._closed=e,this._callOnOpenClose(this))}show(e=!0){return this._hidden=!e,this.domElement.style.display=this._hidden?\"none\":\"\",this}hide(){return this.show(!1)}openAnimated(e=!0){return this._setClosed(!e),this.$title.setAttribute(\"aria-expanded\",!this._closed),requestAnimationFrame(()=>{const n=this.$children.clientHeight;this.$children.style.height=n+\"px\",this.domElement.classList.add(\"transition\");const r=s=>{s.target===this.$children&&(this.$children.style.height=\"\",this.domElement.classList.remove(\"transition\"),this.$children.removeEventListener(\"transitionend\",r))};this.$children.addEventListener(\"transitionend\",r);const i=e?this.$children.scrollHeight:0;this.domElement.classList.toggle(\"closed\",!e),requestAnimationFrame(()=>{this.$children.style.height=i+\"px\"})}),this}title(e){return this._title=e,this.$title.textContent=e,this}reset(e=!0){return(e?this.controllersRecursive():this.controllers).forEach(r=>r.reset()),this}onChange(e){return this._onChange=e,this}_callOnChange(e){this.parent&&this.parent._callOnChange(e),this._onChange!==void 0&&this._onChange.call(this,{object:e.object,property:e.property,value:e.getValue(),controller:e})}onFinishChange(e){return this._onFinishChange=e,this}_callOnFinishChange(e){this.parent&&this.parent._callOnFinishChange(e),this._onFinishChange!==void 0&&this._onFinishChange.call(this,{object:e.object,property:e.property,value:e.getValue(),controller:e})}onOpenClose(e){return this._onOpenClose=e,this}_callOnOpenClose(e){this.parent&&this.parent._callOnOpenClose(e),this._onOpenClose!==void 0&&this._onOpenClose.call(this,e)}destroy(){this.parent&&(this.parent.children.splice(this.parent.children.indexOf(this),1),this.parent.folders.splice(this.parent.folders.indexOf(this),1)),this.domElement.parentElement&&this.domElement.parentElement.removeChild(this.domElement),Array.from(this.children).forEach(e=>e.destroy())}controllersRecursive(){let e=Array.from(this.controllers);return this.folders.forEach(n=>{e=e.concat(n.controllersRecursive())}),e}foldersRecursive(){let e=Array.from(this.folders);return this.folders.forEach(n=>{e=e.concat(n.foldersRecursive())}),e}}function GE(t,e,n,r){return new(n||(n=Promise))((function(i,s){function o(d){try{c(r.next(d))}catch(u){s(u)}}function l(d){try{c(r.throw(d))}catch(u){s(u)}}function c(d){var u;d.done?i(d.value):(u=d.value,u instanceof n?u:new n((function(m){m(u)}))).then(o,l)}c((r=r.apply(t,[])).next())}))}class gI{constructor(e,n,r,i){this.size=e,this.width=n,this.height=r,this.charLength=i,this.chars=\"\"}static parse(e){const n=e.split(\" \"),r=n[0],i=r.split(\"x\");return new gI(r,+i[0],+i[1],+n[1])}get src(){return\"data:image/jpeg;base64,\"+this.chars}get isValid(){return this.chars.length==this.charLength&&/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(this.chars)}}class bI{constructor(e,n,r,i){this.src=e,this.gcode=n,this.params=r,this.comment=i}}class M$ extends bI{constructor(e,n,r,i){super(e,n,r,i),this.params=r}}class wh extends bI{constructor(e,n,r,i){super(e,n,void 0,r),this.toolIndex=i}}class E${constructor(e,n,r,i=0){this.layer=e,this.commands=n,this.lineNumber=r,this.height=i}}class UN{constructor(e){this.lines=[],this.preamble=new E$(-1,[],0),this.layers=[],this.curZ=0,this.maxZ=0,this.metadata={thumbnails:{}},this.tolerance=0,this.tolerance=e??this.tolerance}parseGCode(e){const n=Array.isArray(e)?e:e.split(`\n`);this.lines=this.lines.concat(n);const r=this.lines2commands(n);this.groupIntoLayers(r);const i=this.parseMetadata(r.filter((s=>s.comment))).thumbnails;for(const[s,o]of Object.entries(i))this.metadata.thumbnails[s]=o;return{layers:this.layers,metadata:this.metadata}}lines2commands(e){return e.map((n=>this.parseCommand(n)))}parseCommand(e,n=!0){var r;const i=e.trim().split(\";\"),s=i[0],o=n&&i[1]||void 0,l=s.split(/([a-zA-Z])/g).slice(1).map((u=>u.trim())),c=l.length?`${(r=l[0])===null||r===void 0?void 0:r.toLowerCase()}${l[1]}`:\"\",d=this.parseParams(l.slice(2));switch(c){case\"g0\":case\"g00\":case\"g1\":case\"g01\":case\"g2\":case\"g02\":case\"g3\":case\"g03\":return new M$(e,c,d,o);case\"t0\":return new wh(e,c,o,0);case\"t1\":return new wh(e,c,o,1);case\"t2\":return new wh(e,c,o,2);case\"t3\":return new wh(e,c,o,3);case\"t4\":return new wh(e,c,o,4);case\"t5\":return new wh(e,c,o,5);case\"t6\":return new wh(e,c,o,6);case\"t7\":return new wh(e,c,o,7);default:return new bI(e,c,d,o)}}parseMove(e){return e.reduce(((n,r)=>{const i=r.charAt(0).toLowerCase();return i!=\"x\"&&i!=\"y\"&&i!=\"z\"&&i!=\"e\"&&i!=\"r\"&&i!=\"f\"&&i!=\"i\"&&i!=\"j\"||(n[i]=parseFloat(r.slice(1))),n}),{})}isAlpha(e){const n=e.charCodeAt(0);return n>=97&&n<=122||n>=65&&n<=90}parseParams(e){return e.reduce(((n,r,i,s)=>{if(i%2==0)return n;let o=s[i-1];return o=o.toLowerCase(),this.isAlpha(o)&&(n[o]=parseFloat(r)),n}),{})}groupIntoLayers(e){var n;for(let r=0;r<e.length;r++){const i=e[r];if(i instanceof M$){const s=i.params;if(s.z&&(this.curZ=s.z),((n=s.e)!==null&&n!==void 0?n:0)>0&&(s.x!=null||s.y!=null)&&Math.abs(this.curZ-(this.maxZ||-1/0))>this.tolerance){const o=Math.abs(this.curZ-this.maxZ);this.maxZ=this.curZ,this.layers.push(new E$(this.layers.length,[],r,o))}}this.maxLayer.commands.push(i)}return this.layers}get maxLayer(){var e;return(e=this.layers[this.layers.length-1])!==null&&e!==void 0?e:this.preamble}parseMetadata(e){const n={};let r;for(const i of e){const s=i.comment;if(!s)continue;const o=s.indexOf(\"thumbnail begin\"),l=s.indexOf(\"thumbnail end\");o>-1?r=gI.parse(s.slice(o+15).trim()):r&&(l==-1?r.chars+=s.trim():(r.isValid&&(n[r.size]=r),r=void 0))}return{thumbnails:n}}}UN.prototype.parseGcode=UN.prototype.parseGCode;const D$={type:\"change\"},WE={type:\"start\"},F$={type:\"end\"},K1=new uI,R$=new Th,DMe=Math.cos(70*oJ.DEG2RAD);class FMe extends Bg{constructor(e,n){super(),this.object=e,this.domElement=n,this.domElement.style.touchAction=\"none\",this.enabled=!0,this.target=new ut,this.cursor=new ut,this.minDistance=0,this.maxDistance=1/0,this.minZoom=0,this.maxZoom=1/0,this.minTargetRadius=0,this.maxTargetRadius=1/0,this.minPolarAngle=0,this.maxPolarAngle=Math.PI,this.minAzimuthAngle=-1/0,this.maxAzimuthAngle=1/0,this.enableDamping=!1,this.dampingFactor=.05,this.enableZoom=!0,this.zoomSpeed=1,this.enableRotate=!0,this.rotateSpeed=1,this.enablePan=!0,this.panSpeed=1,this.screenSpacePanning=!0,this.keyPanSpeed=7,this.zoomToCursor=!1,this.autoRotate=!1,this.autoRotateSpeed=2,this.keys={LEFT:\"ArrowLeft\",UP:\"ArrowUp\",RIGHT:\"ArrowRight\",BOTTOM:\"ArrowDown\"},this.mouseButtons={LEFT:Ob.ROTATE,MIDDLE:Ob.DOLLY,RIGHT:Ob.PAN},this.touches={ONE:Ib.ROTATE,TWO:Ib.DOLLY_PAN},this.target0=this.target.clone(),this.position0=this.object.position.clone(),this.zoom0=this.object.zoom,this._domElementKeyEvents=null,this.getPolarAngle=function(){return l.phi},this.getAzimuthalAngle=function(){return l.theta},this.getDistance=function(){return this.object.position.distanceTo(this.target)},this.listenToKeyEvents=function(G){G.addEventListener(\"keydown\",K),this._domElementKeyEvents=G},this.stopListenToKeyEvents=function(){this._domElementKeyEvents.removeEventListener(\"keydown\",K),this._domElementKeyEvents=null},this.saveState=function(){r.target0.copy(r.target),r.position0.copy(r.object.position),r.zoom0=r.object.zoom},this.reset=function(){r.target.copy(r.target0),r.object.position.copy(r.position0),r.object.zoom=r.zoom0,r.object.updateProjectionMatrix(),r.dispatchEvent(D$),r.update(),s=i.NONE},this.update=(function(){const G=new ut,X=new yg().setFromUnitVectors(e.up,new ut(0,1,0)),V=X.clone().invert(),ee=new ut,se=new yg,ge=new ut,he=2*Math.PI;return function(le=null){const B=r.object.position;G.copy(B).sub(r.target),G.applyQuaternion(X),l.setFromVector3(G),r.autoRotate&&s===i.NONE&&D((function(Se){return Se!==null?2*Math.PI/60*r.autoRotateSpeed*Se:2*Math.PI/60/60*r.autoRotateSpeed})(le)),r.enableDamping?(l.theta+=c.theta*r.dampingFactor,l.phi+=c.phi*r.dampingFactor):(l.theta+=c.theta,l.phi+=c.phi);let R=r.minAzimuthAngle,ae=r.maxAzimuthAngle;isFinite(R)&&isFinite(ae)&&(R<-Math.PI?R+=he:R>Math.PI&&(R-=he),ae<-Math.PI?ae+=he:ae>Math.PI&&(ae-=he),l.theta=R<=ae?Math.max(R,Math.min(ae,l.theta)):l.theta>(R+ae)/2?Math.max(R,l.theta):Math.min(ae,l.theta)),l.phi=Math.max(r.minPolarAngle,Math.min(r.maxPolarAngle,l.phi)),l.makeSafe(),r.enableDamping===!0?r.target.addScaledVector(u,r.dampingFactor):r.target.add(u),r.target.sub(r.cursor),r.target.clampLength(r.minTargetRadius,r.maxTargetRadius),r.target.add(r.cursor),r.zoomToCursor&&A||r.object.isOrthographicCamera?l.radius=oe(l.radius):l.radius=oe(l.radius*d),G.setFromSpherical(l),G.applyQuaternion(V),B.copy(r.target).add(G),r.object.lookAt(r.target),r.enableDamping===!0?(c.theta*=1-r.dampingFactor,c.phi*=1-r.dampingFactor,u.multiplyScalar(1-r.dampingFactor)):(c.set(0,0,0),u.set(0,0,0));let _e=!1;if(r.zoomToCursor&&A){let Se=null;if(r.object.isPerspectiveCamera){const ve=G.length();Se=oe(ve*d);const Te=ve-Se;r.object.position.addScaledVector(P,Te),r.object.updateMatrixWorld()}else if(r.object.isOrthographicCamera){const ve=new ut(N.x,N.y,0);ve.unproject(r.object),r.object.zoom=Math.max(r.minZoom,Math.min(r.maxZoom,r.object.zoom/d)),r.object.updateProjectionMatrix(),_e=!0;const Te=new ut(N.x,N.y,0);Te.unproject(r.object),r.object.position.sub(Te).add(ve),r.object.updateMatrixWorld(),Se=G.length()}else console.warn(\"WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.\"),r.zoomToCursor=!1;Se!==null&&(this.screenSpacePanning?r.target.set(0,0,-1).transformDirection(r.object.matrix).multiplyScalar(Se).add(r.object.position):(K1.origin.copy(r.object.position),K1.direction.set(0,0,-1).transformDirection(r.object.matrix),Math.abs(r.object.up.dot(K1.direction))<DMe?e.lookAt(r.target):(R$.setFromNormalAndCoplanarPoint(r.object.up,r.target),K1.intersectPlane(R$,r.target))))}else r.object.isOrthographicCamera&&(r.object.zoom=Math.max(r.minZoom,Math.min(r.maxZoom,r.object.zoom/d)),r.object.updateProjectionMatrix(),_e=!0);return d=1,A=!1,!!(_e||ee.distanceToSquared(r.object.position)>o||8*(1-se.dot(r.object.quaternion))>o||ge.distanceToSquared(r.target)>0)&&(r.dispatchEvent(D$),ee.copy(r.object.position),se.copy(r.object.quaternion),ge.copy(r.target),!0)}})(),this.dispose=function(){r.domElement.removeEventListener(\"contextmenu\",U),r.domElement.removeEventListener(\"pointerdown\",Y),r.domElement.removeEventListener(\"pointercancel\",j),r.domElement.removeEventListener(\"wheel\",O),r.domElement.removeEventListener(\"pointermove\",E),r.domElement.removeEventListener(\"pointerup\",j),r._domElementKeyEvents!==null&&(r._domElementKeyEvents.removeEventListener(\"keydown\",K),r._domElementKeyEvents=null)};const r=this,i={NONE:-1,ROTATE:0,DOLLY:1,PAN:2,TOUCH_ROTATE:3,TOUCH_PAN:4,TOUCH_DOLLY_PAN:5,TOUCH_DOLLY_ROTATE:6};let s=i.NONE;const o=1e-6,l=new T$,c=new T$;let d=1;const u=new ut,m=new Vn,p=new Vn,f=new Vn,y=new Vn,v=new Vn,b=new Vn,g=new Vn,_=new Vn,C=new Vn,P=new ut,N=new Vn;let A=!1;const T=[],F={};function k(){return Math.pow(.95,r.zoomSpeed)}function D(G){c.theta-=G}function H(G){c.phi-=G}const z=(function(){const G=new ut;return function(X,V){G.setFromMatrixColumn(V,0),G.multiplyScalar(-X),u.add(G)}})(),Q=(function(){const G=new ut;return function(X,V){r.screenSpacePanning===!0?G.setFromMatrixColumn(V,1):(G.setFromMatrixColumn(V,0),G.crossVectors(r.object.up,G)),G.multiplyScalar(X),u.add(G)}})(),L=(function(){const G=new ut;return function(X,V){const ee=r.domElement;if(r.object.isPerspectiveCamera){const se=r.object.position;G.copy(se).sub(r.target);let ge=G.length();ge*=Math.tan(r.object.fov/2*Math.PI/180),z(2*X*ge/ee.clientHeight,r.object.matrix),Q(2*V*ge/ee.clientHeight,r.object.matrix)}else r.object.isOrthographicCamera?(z(X*(r.object.right-r.object.left)/r.object.zoom/ee.clientWidth,r.object.matrix),Q(V*(r.object.top-r.object.bottom)/r.object.zoom/ee.clientHeight,r.object.matrix)):(console.warn(\"WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.\"),r.enablePan=!1)}})();function te(G){r.object.isPerspectiveCamera||r.object.isOrthographicCamera?d/=G:(console.warn(\"WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.\"),r.enableZoom=!1)}function ie(G){r.object.isPerspectiveCamera||r.object.isOrthographicCamera?d*=G:(console.warn(\"WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.\"),r.enableZoom=!1)}function J(G){if(!r.zoomToCursor)return;A=!0;const X=r.domElement.getBoundingClientRect(),V=G.clientX-X.left,ee=G.clientY-X.top,se=X.width,ge=X.height;N.x=V/se*2-1,N.y=-ee/ge*2+1,P.set(N.x,N.y,1).unproject(r.object).sub(r.object.position).normalize()}function oe(G){return Math.max(r.minDistance,Math.min(r.maxDistance,G))}function fe(G){m.set(G.clientX,G.clientY)}function re(G){y.set(G.clientX,G.clientY)}function W(){if(T.length===1)m.set(T[0].pageX,T[0].pageY);else{const G=.5*(T[0].pageX+T[1].pageX),X=.5*(T[0].pageY+T[1].pageY);m.set(G,X)}}function ne(){if(T.length===1)y.set(T[0].pageX,T[0].pageY);else{const G=.5*(T[0].pageX+T[1].pageX),X=.5*(T[0].pageY+T[1].pageY);y.set(G,X)}}function me(){const G=T[0].pageX-T[1].pageX,X=T[0].pageY-T[1].pageY,V=Math.sqrt(G*G+X*X);g.set(0,V)}function be(G){if(T.length==1)p.set(G.pageX,G.pageY);else{const V=I(G),ee=.5*(G.pageX+V.x),se=.5*(G.pageY+V.y);p.set(ee,se)}f.subVectors(p,m).multiplyScalar(r.rotateSpeed);const X=r.domElement;D(2*Math.PI*f.x/X.clientHeight),H(2*Math.PI*f.y/X.clientHeight),m.copy(p)}function Ce(G){if(T.length===1)v.set(G.pageX,G.pageY);else{const X=I(G),V=.5*(G.pageX+X.x),ee=.5*(G.pageY+X.y);v.set(V,ee)}b.subVectors(v,y).multiplyScalar(r.panSpeed),L(b.x,b.y),y.copy(v)}function q(G){const X=I(G),V=G.pageX-X.x,ee=G.pageY-X.y,se=Math.sqrt(V*V+ee*ee);_.set(0,se),C.set(0,Math.pow(_.y/g.y,r.zoomSpeed)),te(C.y),g.copy(_)}function Y(G){r.enabled!==!1&&(T.length===0&&(r.domElement.setPointerCapture(G.pointerId),r.domElement.addEventListener(\"pointermove\",E),r.domElement.addEventListener(\"pointerup\",j)),(function(X){T.push(X)})(G),G.pointerType===\"touch\"?(function(X){switch(de(X),T.length){case 1:switch(r.touches.ONE){case Ib.ROTATE:if(r.enableRotate===!1)return;W(),s=i.TOUCH_ROTATE;break;case Ib.PAN:if(r.enablePan===!1)return;ne(),s=i.TOUCH_PAN;break;default:s=i.NONE}break;case 2:switch(r.touches.TWO){case Ib.DOLLY_PAN:if(r.enableZoom===!1&&r.enablePan===!1)return;r.enableZoom&&me(),r.enablePan&&ne(),s=i.TOUCH_DOLLY_PAN;break;case Ib.DOLLY_ROTATE:if(r.enableZoom===!1&&r.enableRotate===!1)return;r.enableZoom&&me(),r.enableRotate&&W(),s=i.TOUCH_DOLLY_ROTATE;break;default:s=i.NONE}break;default:s=i.NONE}s!==i.NONE&&r.dispatchEvent(WE)})(G):(function(X){let V;switch(X.button){case 0:V=r.mouseButtons.LEFT;break;case 1:V=r.mouseButtons.MIDDLE;break;case 2:V=r.mouseButtons.RIGHT;break;default:V=-1}switch(V){case Ob.DOLLY:if(r.enableZoom===!1)return;(function(ee){J(ee),g.set(ee.clientX,ee.clientY)})(X),s=i.DOLLY;break;case Ob.ROTATE:if(X.ctrlKey||X.metaKey||X.shiftKey){if(r.enablePan===!1)return;re(X),s=i.PAN}else{if(r.enableRotate===!1)return;fe(X),s=i.ROTATE}break;case Ob.PAN:if(X.ctrlKey||X.metaKey||X.shiftKey){if(r.enableRotate===!1)return;fe(X),s=i.ROTATE}else{if(r.enablePan===!1)return;re(X),s=i.PAN}break;default:s=i.NONE}s!==i.NONE&&r.dispatchEvent(WE)})(G))}function E(G){r.enabled!==!1&&(G.pointerType===\"touch\"?(function(X){switch(de(X),s){case i.TOUCH_ROTATE:if(r.enableRotate===!1)return;be(X),r.update();break;case i.TOUCH_PAN:if(r.enablePan===!1)return;Ce(X),r.update();break;case i.TOUCH_DOLLY_PAN:if(r.enableZoom===!1&&r.enablePan===!1)return;(function(V){r.enableZoom&&q(V),r.enablePan&&Ce(V)})(X),r.update();break;case i.TOUCH_DOLLY_ROTATE:if(r.enableZoom===!1&&r.enableRotate===!1)return;(function(V){r.enableZoom&&q(V),r.enableRotate&&be(V)})(X),r.update();break;default:s=i.NONE}})(G):(function(X){switch(s){case i.ROTATE:if(r.enableRotate===!1)return;(function(V){p.set(V.clientX,V.clientY),f.subVectors(p,m).multiplyScalar(r.rotateSpeed);const ee=r.domElement;D(2*Math.PI*f.x/ee.clientHeight),H(2*Math.PI*f.y/ee.clientHeight),m.copy(p),r.update()})(X);break;case i.DOLLY:if(r.enableZoom===!1)return;(function(V){_.set(V.clientX,V.clientY),C.subVectors(_,g),C.y>0?te(k()):C.y<0&&ie(k()),g.copy(_),r.update()})(X);break;case i.PAN:if(r.enablePan===!1)return;(function(V){v.set(V.clientX,V.clientY),b.subVectors(v,y).multiplyScalar(r.panSpeed),L(b.x,b.y),y.copy(v),r.update()})(X)}})(G))}function j(G){(function(X){delete F[X.pointerId];for(let V=0;V<T.length;V++)if(T[V].pointerId==X.pointerId)return void T.splice(V,1)})(G),T.length===0&&(r.domElement.releasePointerCapture(G.pointerId),r.domElement.removeEventListener(\"pointermove\",E),r.domElement.removeEventListener(\"pointerup\",j)),r.dispatchEvent(F$),s=i.NONE}function O(G){r.enabled!==!1&&r.enableZoom!==!1&&s===i.NONE&&(G.preventDefault(),r.dispatchEvent(WE),(function(X){J(X),X.deltaY<0?ie(k()):X.deltaY>0&&te(k()),r.update()})(G),r.dispatchEvent(F$))}function K(G){r.enabled!==!1&&r.enablePan!==!1&&(function(X){let V=!1;switch(X.code){case r.keys.UP:X.ctrlKey||X.metaKey||X.shiftKey?H(2*Math.PI*r.rotateSpeed/r.domElement.clientHeight):L(0,r.keyPanSpeed),V=!0;break;case r.keys.BOTTOM:X.ctrlKey||X.metaKey||X.shiftKey?H(-2*Math.PI*r.rotateSpeed/r.domElement.clientHeight):L(0,-r.keyPanSpeed),V=!0;break;case r.keys.LEFT:X.ctrlKey||X.metaKey||X.shiftKey?D(2*Math.PI*r.rotateSpeed/r.domElement.clientHeight):L(r.keyPanSpeed,0),V=!0;break;case r.keys.RIGHT:X.ctrlKey||X.metaKey||X.shiftKey?D(-2*Math.PI*r.rotateSpeed/r.domElement.clientHeight):L(-r.keyPanSpeed,0),V=!0}V&&(X.preventDefault(),r.update())})(G)}function U(G){r.enabled!==!1&&G.preventDefault()}function de(G){let X=F[G.pointerId];X===void 0&&(X=new Vn,F[G.pointerId]=X),X.set(G.pageX,G.pageY)}function I(G){const X=G.pointerId===T[0].pointerId?T[1]:T[0];return F[X.pointerId]}r.domElement.addEventListener(\"contextmenu\",U),r.domElement.addEventListener(\"pointerdown\",Y),r.domElement.addEventListener(\"pointercancel\",j),r.domElement.addEventListener(\"wheel\",O,{passive:!1}),this.update()}}Yt.line={worldUnits:{value:1},linewidth:{value:1},resolution:{value:new Vn(1,1)},dashOffset:{value:0},dashScale:{value:1},dashSize:{value:1},gapSize:{value:1}},Fo.line={uniforms:mI.merge([Yt.common,Yt.fog,Yt.line]),vertexShader:`\n\t\t#include <common>\n\t\t#include <color_pars_vertex>\n\t\t#include <fog_pars_vertex>\n\t\t#include <logdepthbuf_pars_vertex>\n\t\t#include <clipping_planes_pars_vertex>\n\n\t\tuniform float linewidth;\n\t\tuniform vec2 resolution;\n\n\t\tattribute vec3 instanceStart;\n\t\tattribute vec3 instanceEnd;\n\n\t\tattribute vec3 instanceColorStart;\n\t\tattribute vec3 instanceColorEnd;\n\n\t\t#ifdef WORLD_UNITS\n\n\t\t\tvarying vec4 worldPos;\n\t\t\tvarying vec3 worldStart;\n\t\t\tvarying vec3 worldEnd;\n\n\t\t\t#ifdef USE_DASH\n\n\t\t\t\tvarying vec2 vUv;\n\n\t\t\t#endif\n\n\t\t#else\n\n\t\t\tvarying vec2 vUv;\n\n\t\t#endif\n\n\t\t#ifdef USE_DASH\n\n\t\t\tuniform float dashScale;\n\t\t\tattribute float instanceDistanceStart;\n\t\t\tattribute float instanceDistanceEnd;\n\t\t\tvarying float vLineDistance;\n\n\t\t#endif\n\n\t\tvoid trimSegment( const in vec4 start, inout vec4 end ) {\n\n\t\t\t// trim end segment so it terminates between the camera plane and the near plane\n\n\t\t\t// conservative estimate of the near plane\n\t\t\tfloat a = projectionMatrix[ 2 ][ 2 ]; // 3nd entry in 3th column\n\t\t\tfloat b = projectionMatrix[ 3 ][ 2 ]; // 3nd entry in 4th column\n\t\t\tfloat nearEstimate = - 0.5 * b / a;\n\n\t\t\tfloat alpha = ( nearEstimate - start.z ) / ( end.z - start.z );\n\n\t\t\tend.xyz = mix( start.xyz, end.xyz, alpha );\n\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\t#ifdef USE_COLOR\n\n\t\t\t\tvColor.xyz = ( position.y < 0.5 ) ? instanceColorStart : instanceColorEnd;\n\n\t\t\t#endif\n\n\t\t\t#ifdef USE_DASH\n\n\t\t\t\tvLineDistance = ( position.y < 0.5 ) ? dashScale * instanceDistanceStart : dashScale * instanceDistanceEnd;\n\t\t\t\tvUv = uv;\n\n\t\t\t#endif\n\n\t\t\tfloat aspect = resolution.x / resolution.y;\n\n\t\t\t// camera space\n\t\t\tvec4 start = modelViewMatrix * vec4( instanceStart, 1.0 );\n\t\t\tvec4 end = modelViewMatrix * vec4( instanceEnd, 1.0 );\n\n\t\t\t#ifdef WORLD_UNITS\n\n\t\t\t\tworldStart = start.xyz;\n\t\t\t\tworldEnd = end.xyz;\n\n\t\t\t#else\n\n\t\t\t\tvUv = uv;\n\n\t\t\t#endif\n\n\t\t\t// special case for perspective projection, and segments that terminate either in, or behind, the camera plane\n\t\t\t// clearly the gpu firmware has a way of addressing this issue when projecting into ndc space\n\t\t\t// but we need to perform ndc-space calculations in the shader, so we must address this issue directly\n\t\t\t// perhaps there is a more elegant solution -- WestLangley\n\n\t\t\tbool perspective = ( projectionMatrix[ 2 ][ 3 ] == - 1.0 ); // 4th entry in the 3rd column\n\n\t\t\tif ( perspective ) {\n\n\t\t\t\tif ( start.z < 0.0 && end.z >= 0.0 ) {\n\n\t\t\t\t\ttrimSegment( start, end );\n\n\t\t\t\t} else if ( end.z < 0.0 && start.z >= 0.0 ) {\n\n\t\t\t\t\ttrimSegment( end, start );\n\n\t\t\t\t}\n\n\t\t\t}\n\n\t\t\t// clip space\n\t\t\tvec4 clipStart = projectionMatrix * start;\n\t\t\tvec4 clipEnd = projectionMatrix * end;\n\n\t\t\t// ndc space\n\t\t\tvec3 ndcStart = clipStart.xyz / clipStart.w;\n\t\t\tvec3 ndcEnd = clipEnd.xyz / clipEnd.w;\n\n\t\t\t// direction\n\t\t\tvec2 dir = ndcEnd.xy - ndcStart.xy;\n\n\t\t\t// account for clip-space aspect ratio\n\t\t\tdir.x *= aspect;\n\t\t\tdir = normalize( dir );\n\n\t\t\t#ifdef WORLD_UNITS\n\n\t\t\t\t// get the offset direction as perpendicular to the view vector\n\t\t\t\tvec3 worldDir = normalize( end.xyz - start.xyz );\n\t\t\t\tvec3 offset;\n\t\t\t\tif ( position.y < 0.5 ) {\n\n\t\t\t\t\toffset = normalize( cross( start.xyz, worldDir ) );\n\n\t\t\t\t} else {\n\n\t\t\t\t\toffset = normalize( cross( end.xyz, worldDir ) );\n\n\t\t\t\t}\n\n\t\t\t\t// sign flip\n\t\t\t\tif ( position.x < 0.0 ) offset *= - 1.0;\n\n\t\t\t\tfloat forwardOffset = dot( worldDir, vec3( 0.0, 0.0, 1.0 ) );\n\n\t\t\t\t// don't extend the line if we're rendering dashes because we\n\t\t\t\t// won't be rendering the endcaps\n\t\t\t\t#ifndef USE_DASH\n\n\t\t\t\t\t// extend the line bounds to encompass  endcaps\n\t\t\t\t\tstart.xyz += - worldDir * linewidth * 0.5;\n\t\t\t\t\tend.xyz += worldDir * linewidth * 0.5;\n\n\t\t\t\t\t// shift the position of the quad so it hugs the forward edge of the line\n\t\t\t\t\toffset.xy -= dir * forwardOffset;\n\t\t\t\t\toffset.z += 0.5;\n\n\t\t\t\t#endif\n\n\t\t\t\t// endcaps\n\t\t\t\tif ( position.y > 1.0 || position.y < 0.0 ) {\n\n\t\t\t\t\toffset.xy += dir * 2.0 * forwardOffset;\n\n\t\t\t\t}\n\n\t\t\t\t// adjust for linewidth\n\t\t\t\toffset *= linewidth * 0.5;\n\n\t\t\t\t// set the world position\n\t\t\t\tworldPos = ( position.y < 0.5 ) ? start : end;\n\t\t\t\tworldPos.xyz += offset;\n\n\t\t\t\t// project the worldpos\n\t\t\t\tvec4 clip = projectionMatrix * worldPos;\n\n\t\t\t\t// shift the depth of the projected points so the line\n\t\t\t\t// segments overlap neatly\n\t\t\t\tvec3 clipPose = ( position.y < 0.5 ) ? ndcStart : ndcEnd;\n\t\t\t\tclip.z = clipPose.z * clip.w;\n\n\t\t\t#else\n\n\t\t\t\tvec2 offset = vec2( dir.y, - dir.x );\n\t\t\t\t// undo aspect ratio adjustment\n\t\t\t\tdir.x /= aspect;\n\t\t\t\toffset.x /= aspect;\n\n\t\t\t\t// sign flip\n\t\t\t\tif ( position.x < 0.0 ) offset *= - 1.0;\n\n\t\t\t\t// endcaps\n\t\t\t\tif ( position.y < 0.0 ) {\n\n\t\t\t\t\toffset += - dir;\n\n\t\t\t\t} else if ( position.y > 1.0 ) {\n\n\t\t\t\t\toffset += dir;\n\n\t\t\t\t}\n\n\t\t\t\t// adjust for linewidth\n\t\t\t\toffset *= linewidth;\n\n\t\t\t\t// adjust for clip-space to screen-space conversion // maybe resolution should be based on viewport ...\n\t\t\t\toffset /= resolution.y;\n\n\t\t\t\t// select end\n\t\t\t\tvec4 clip = ( position.y < 0.5 ) ? clipStart : clipEnd;\n\n\t\t\t\t// back to clip space\n\t\t\t\toffset *= clip.w;\n\n\t\t\t\tclip.xy += offset;\n\n\t\t\t#endif\n\n\t\t\tgl_Position = clip;\n\n\t\t\tvec4 mvPosition = ( position.y < 0.5 ) ? start : end; // this is an approximation\n\n\t\t\t#include <logdepthbuf_vertex>\n\t\t\t#include <clipping_planes_vertex>\n\t\t\t#include <fog_vertex>\n\n\t\t}\n\t\t`,fragmentShader:`\n\t\tuniform vec3 diffuse;\n\t\tuniform float opacity;\n\t\tuniform float linewidth;\n\n\t\t#ifdef USE_DASH\n\n\t\t\tuniform float dashOffset;\n\t\t\tuniform float dashSize;\n\t\t\tuniform float gapSize;\n\n\t\t#endif\n\n\t\tvarying float vLineDistance;\n\n\t\t#ifdef WORLD_UNITS\n\n\t\t\tvarying vec4 worldPos;\n\t\t\tvarying vec3 worldStart;\n\t\t\tvarying vec3 worldEnd;\n\n\t\t\t#ifdef USE_DASH\n\n\t\t\t\tvarying vec2 vUv;\n\n\t\t\t#endif\n\n\t\t#else\n\n\t\t\tvarying vec2 vUv;\n\n\t\t#endif\n\n\t\t#include <common>\n\t\t#include <color_pars_fragment>\n\t\t#include <fog_pars_fragment>\n\t\t#include <logdepthbuf_pars_fragment>\n\t\t#include <clipping_planes_pars_fragment>\n\n\t\tvec2 closestLineToLine(vec3 p1, vec3 p2, vec3 p3, vec3 p4) {\n\n\t\t\tfloat mua;\n\t\t\tfloat mub;\n\n\t\t\tvec3 p13 = p1 - p3;\n\t\t\tvec3 p43 = p4 - p3;\n\n\t\t\tvec3 p21 = p2 - p1;\n\n\t\t\tfloat d1343 = dot( p13, p43 );\n\t\t\tfloat d4321 = dot( p43, p21 );\n\t\t\tfloat d1321 = dot( p13, p21 );\n\t\t\tfloat d4343 = dot( p43, p43 );\n\t\t\tfloat d2121 = dot( p21, p21 );\n\n\t\t\tfloat denom = d2121 * d4343 - d4321 * d4321;\n\n\t\t\tfloat numer = d1343 * d4321 - d1321 * d4343;\n\n\t\t\tmua = numer / denom;\n\t\t\tmua = clamp( mua, 0.0, 1.0 );\n\t\t\tmub = ( d1343 + d4321 * ( mua ) ) / d4343;\n\t\t\tmub = clamp( mub, 0.0, 1.0 );\n\n\t\t\treturn vec2( mua, mub );\n\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\t#include <clipping_planes_fragment>\n\n\t\t\t#ifdef USE_DASH\n\n\t\t\t\tif ( vUv.y < - 1.0 || vUv.y > 1.0 ) discard; // discard endcaps\n\n\t\t\t\tif ( mod( vLineDistance + dashOffset, dashSize + gapSize ) > dashSize ) discard; // todo - FIX\n\n\t\t\t#endif\n\n\t\t\tfloat alpha = opacity;\n\n\t\t\t#ifdef WORLD_UNITS\n\n\t\t\t\t// Find the closest points on the view ray and the line segment\n\t\t\t\tvec3 rayEnd = normalize( worldPos.xyz ) * 1e5;\n\t\t\t\tvec3 lineDir = worldEnd - worldStart;\n\t\t\t\tvec2 params = closestLineToLine( worldStart, worldEnd, vec3( 0.0, 0.0, 0.0 ), rayEnd );\n\n\t\t\t\tvec3 p1 = worldStart + lineDir * params.x;\n\t\t\t\tvec3 p2 = rayEnd * params.y;\n\t\t\t\tvec3 delta = p1 - p2;\n\t\t\t\tfloat len = length( delta );\n\t\t\t\tfloat norm = len / linewidth;\n\n\t\t\t\t#ifndef USE_DASH\n\n\t\t\t\t\t#ifdef USE_ALPHA_TO_COVERAGE\n\n\t\t\t\t\t\tfloat dnorm = fwidth( norm );\n\t\t\t\t\t\talpha = 1.0 - smoothstep( 0.5 - dnorm, 0.5 + dnorm, norm );\n\n\t\t\t\t\t#else\n\n\t\t\t\t\t\tif ( norm > 0.5 ) {\n\n\t\t\t\t\t\t\tdiscard;\n\n\t\t\t\t\t\t}\n\n\t\t\t\t\t#endif\n\n\t\t\t\t#endif\n\n\t\t\t#else\n\n\t\t\t\t#ifdef USE_ALPHA_TO_COVERAGE\n\n\t\t\t\t\t// artifacts appear on some hardware if a derivative is taken within a conditional\n\t\t\t\t\tfloat a = vUv.x;\n\t\t\t\t\tfloat b = ( vUv.y > 0.0 ) ? vUv.y - 1.0 : vUv.y + 1.0;\n\t\t\t\t\tfloat len2 = a * a + b * b;\n\t\t\t\t\tfloat dlen = fwidth( len2 );\n\n\t\t\t\t\tif ( abs( vUv.y ) > 1.0 ) {\n\n\t\t\t\t\t\talpha = 1.0 - smoothstep( 1.0 - dlen, 1.0 + dlen, len2 );\n\n\t\t\t\t\t}\n\n\t\t\t\t#else\n\n\t\t\t\t\tif ( abs( vUv.y ) > 1.0 ) {\n\n\t\t\t\t\t\tfloat a = vUv.x;\n\t\t\t\t\t\tfloat b = ( vUv.y > 0.0 ) ? vUv.y - 1.0 : vUv.y + 1.0;\n\t\t\t\t\t\tfloat len2 = a * a + b * b;\n\n\t\t\t\t\t\tif ( len2 > 1.0 ) discard;\n\n\t\t\t\t\t}\n\n\t\t\t\t#endif\n\n\t\t\t#endif\n\n\t\t\tvec4 diffuseColor = vec4( diffuse, alpha );\n\n\t\t\t#include <logdepthbuf_fragment>\n\t\t\t#include <color_fragment>\n\n\t\t\tgl_FragColor = vec4( diffuseColor.rgb, alpha );\n\n\t\t\t#include <tonemapping_fragment>\n\t\t\t#include <colorspace_fragment>\n\t\t\t#include <fog_fragment>\n\t\t\t#include <premultiplied_alpha_fragment>\n\n\t\t}\n\t\t`};class TJ extends vp{constructor(e){super({type:\"LineMaterial\",uniforms:mI.clone(Fo.line.uniforms),vertexShader:Fo.line.vertexShader,fragmentShader:Fo.line.fragmentShader,clipping:!0}),this.isLineMaterial=!0,this.setValues(e)}get color(){return this.uniforms.diffuse.value}set color(e){this.uniforms.diffuse.value=e}get worldUnits(){return\"WORLD_UNITS\"in this.defines}set worldUnits(e){e===!0?this.defines.WORLD_UNITS=\"\":delete this.defines.WORLD_UNITS}get linewidth(){return this.uniforms.linewidth.value}set linewidth(e){this.uniforms.linewidth&&(this.uniforms.linewidth.value=e)}get dashed(){return\"USE_DASH\"in this.defines}set dashed(e){e===!0!==this.dashed&&(this.needsUpdate=!0),e===!0?this.defines.USE_DASH=\"\":delete this.defines.USE_DASH}get dashScale(){return this.uniforms.dashScale.value}set dashScale(e){this.uniforms.dashScale.value=e}get dashSize(){return this.uniforms.dashSize.value}set dashSize(e){this.uniforms.dashSize.value=e}get dashOffset(){return this.uniforms.dashOffset.value}set dashOffset(e){this.uniforms.dashOffset.value=e}get gapSize(){return this.uniforms.gapSize.value}set gapSize(e){this.uniforms.gapSize.value=e}get opacity(){return this.uniforms.opacity.value}set opacity(e){this.uniforms&&(this.uniforms.opacity.value=e)}get resolution(){return this.uniforms.resolution.value}set resolution(e){this.uniforms.resolution.value.copy(e)}get alphaToCoverage(){return\"USE_ALPHA_TO_COVERAGE\"in this.defines}set alphaToCoverage(e){this.defines&&(e===!0!==this.alphaToCoverage&&(this.needsUpdate=!0),e===!0?(this.defines.USE_ALPHA_TO_COVERAGE=\"\",this.extensions.derivatives=!0):(delete this.defines.USE_ALPHA_TO_COVERAGE,this.extensions.derivatives=!1))}}const L$=new ac,X1=new ut;class AJ extends xMe{constructor(){super(),this.isLineSegmentsGeometry=!0,this.type=\"LineSegmentsGeometry\",this.setIndex([0,2,1,2,3,1,2,4,3,4,5,3,4,6,5,6,7,5]),this.setAttribute(\"position\",new li([-1,2,0,1,2,0,-1,1,0,1,1,0,-1,0,0,1,0,0,-1,-1,0,1,-1,0],3)),this.setAttribute(\"uv\",new li([-1,2,1,2,-1,1,1,1,-1,-1,1,-1,-1,-2,1,-2],2))}applyMatrix4(e){const n=this.attributes.instanceStart,r=this.attributes.instanceEnd;return n!==void 0&&(n.applyMatrix4(e),r.applyMatrix4(e),n.needsUpdate=!0),this.boundingBox!==null&&this.computeBoundingBox(),this.boundingSphere!==null&&this.computeBoundingSphere(),this}setPositions(e){let n;e instanceof Float32Array?n=e:Array.isArray(e)&&(n=new Float32Array(e));const r=new bR(n,6,1);return this.setAttribute(\"instanceStart\",new $h(r,3,0)),this.setAttribute(\"instanceEnd\",new $h(r,3,3)),this.computeBoundingBox(),this.computeBoundingSphere(),this}setColors(e){let n;e instanceof Float32Array?n=e:Array.isArray(e)&&(n=new Float32Array(e));const r=new bR(n,6,1);return this.setAttribute(\"instanceColorStart\",new $h(r,3,0)),this.setAttribute(\"instanceColorEnd\",new $h(r,3,3)),this}fromWireframeGeometry(e){return this.setPositions(e.attributes.position.array),this}fromEdgesGeometry(e){return this.setPositions(e.attributes.position.array),this}fromMesh(e){return this.fromWireframeGeometry(new uMe(e.geometry)),this}fromLineSegments(e){const n=e.geometry;return this.setPositions(n.attributes.position.array),this}computeBoundingBox(){this.boundingBox===null&&(this.boundingBox=new ac);const e=this.attributes.instanceStart,n=this.attributes.instanceEnd;e!==void 0&&n!==void 0&&(this.boundingBox.setFromBufferAttribute(e),L$.setFromBufferAttribute(n),this.boundingBox.union(L$))}computeBoundingSphere(){this.boundingSphere===null&&(this.boundingSphere=new Ld),this.boundingBox===null&&this.computeBoundingBox();const e=this.attributes.instanceStart,n=this.attributes.instanceEnd;if(e!==void 0&&n!==void 0){const r=this.boundingSphere.center;this.boundingBox.getCenter(r);let i=0;for(let s=0,o=e.count;s<o;s++)X1.fromBufferAttribute(e,s),i=Math.max(i,r.distanceToSquared(X1)),X1.fromBufferAttribute(n,s),i=Math.max(i,r.distanceToSquared(X1));this.boundingSphere.radius=Math.sqrt(i),isNaN(this.boundingSphere.radius)&&console.error(\"THREE.LineSegmentsGeometry.computeBoundingSphere(): Computed radius is NaN. The instanced position data is likely to have NaN values.\",this)}}toJSON(){}applyMatrix(e){return console.warn(\"THREE.LineSegmentsGeometry: applyMatrix() has been renamed to applyMatrix4().\"),this.applyMatrix4(e)}}const O$=new ut,I$=new ut,ds=new ta,us=new ta,pd=new ta,KE=new ut,XE=new ba,ms=new yMe,z$=new ut,Y1=new ac,Q1=new Ld,fd=new ta;let gd,Ef;function U$(t,e,n){return fd.set(0,0,-e,1).applyMatrix4(t.projectionMatrix),fd.multiplyScalar(1/fd.w),fd.x=Ef/n.width,fd.y=Ef/n.height,fd.applyMatrix4(t.projectionMatrixInverse),fd.multiplyScalar(1/fd.w),Math.abs(Math.max(fd.x,fd.y))}class RMe extends Wc{constructor(e=new AJ,n=new TJ({color:16777215*Math.random()})){super(e,n),this.isLineSegments2=!0,this.type=\"LineSegments2\"}computeLineDistances(){const e=this.geometry,n=e.attributes.instanceStart,r=e.attributes.instanceEnd,i=new Float32Array(2*n.count);for(let o=0,l=0,c=n.count;o<c;o++,l+=2)O$.fromBufferAttribute(n,o),I$.fromBufferAttribute(r,o),i[l]=l===0?0:i[l-1],i[l+1]=i[l]+O$.distanceTo(I$);const s=new bR(i,2,1);return e.setAttribute(\"instanceDistanceStart\",new $h(s,1,0)),e.setAttribute(\"instanceDistanceEnd\",new $h(s,1,1)),this}raycast(e,n){const r=this.material.worldUnits,i=e.camera;i!==null||r||console.error('LineSegments2: \"Raycaster.camera\" needs to be set in order to raycast against LineSegments2 while worldUnits is set to false.');const s=e.params.Line2!==void 0&&e.params.Line2.threshold||0;gd=e.ray;const o=this.matrixWorld,l=this.geometry,c=this.material;let d,u;Ef=c.linewidth+s,l.boundingSphere===null&&l.computeBoundingSphere(),Q1.copy(l.boundingSphere).applyMatrix4(o),r?d=.5*Ef:d=U$(i,Math.max(i.near,Q1.distanceToPoint(gd.origin)),c.resolution),Q1.radius+=d,gd.intersectsSphere(Q1)!==!1&&(l.boundingBox===null&&l.computeBoundingBox(),Y1.copy(l.boundingBox).applyMatrix4(o),r?u=.5*Ef:u=U$(i,Math.max(i.near,Y1.distanceToPoint(gd.origin)),c.resolution),Y1.expandByScalar(u),gd.intersectsBox(Y1)!==!1&&(r?(function(m,p){const f=m.matrixWorld,y=m.geometry,v=y.attributes.instanceStart,b=y.attributes.instanceEnd;for(let g=0,_=Math.min(y.instanceCount,v.count);g<_;g++){ms.start.fromBufferAttribute(v,g),ms.end.fromBufferAttribute(b,g),ms.applyMatrix4(f);const C=new ut,P=new ut;gd.distanceSqToSegment(ms.start,ms.end,P,C),P.distanceTo(C)<.5*Ef&&p.push({point:P,pointOnLine:C,distance:gd.origin.distanceTo(P),object:m,face:null,faceIndex:g,uv:null,uv1:null})}})(this,n):(function(m,p,f){const y=p.projectionMatrix,v=m.material.resolution,b=m.matrixWorld,g=m.geometry,_=g.attributes.instanceStart,C=g.attributes.instanceEnd,P=Math.min(g.instanceCount,_.count),N=-p.near;gd.at(1,pd),pd.w=1,pd.applyMatrix4(p.matrixWorldInverse),pd.applyMatrix4(y),pd.multiplyScalar(1/pd.w),pd.x*=v.x/2,pd.y*=v.y/2,pd.z=0,KE.copy(pd),XE.multiplyMatrices(p.matrixWorldInverse,b);for(let A=0,T=P;A<T;A++){if(ds.fromBufferAttribute(_,A),us.fromBufferAttribute(C,A),ds.w=1,us.w=1,ds.applyMatrix4(XE),us.applyMatrix4(XE),ds.z>N&&us.z>N)continue;if(ds.z>N){const z=ds.z-us.z,Q=(ds.z-N)/z;ds.lerp(us,Q)}else if(us.z>N){const z=us.z-ds.z,Q=(us.z-N)/z;us.lerp(ds,Q)}ds.applyMatrix4(y),us.applyMatrix4(y),ds.multiplyScalar(1/ds.w),us.multiplyScalar(1/us.w),ds.x*=v.x/2,ds.y*=v.y/2,us.x*=v.x/2,us.y*=v.y/2,ms.start.copy(ds),ms.start.z=0,ms.end.copy(us),ms.end.z=0;const F=ms.closestPointToPointParameter(KE,!0);ms.at(F,z$);const k=oJ.lerp(ds.z,us.z,F),D=k>=-1&&k<=1,H=KE.distanceTo(z$)<.5*Ef;if(D&&H){ms.start.fromBufferAttribute(_,A),ms.end.fromBufferAttribute(C,A),ms.start.applyMatrix4(b),ms.end.applyMatrix4(b);const z=new ut,Q=new ut;gd.distanceSqToSegment(ms.start,ms.end,Q,z),f.push({point:Q,pointOnLine:z,distance:gd.origin.distanceTo(Q),object:m,face:null,faceIndex:A,uv:null,uv1:null})}}})(this,i,n)))}}class LMe extends aT{constructor(e,n,r,i,s=4473924,o=8947848){s=new Mn(s),o=new Mn(o);const l=Math.round(e/n);r=Math.round(r/i)*i/2;const c=[],d=[];let u=0;for(let p=-1*(e=l*n/2);p<=e;p+=n){c.push(p,0,-1*r,p,0,r);const f=p===0?s:o;f.toArray(d,u),u+=3,f.toArray(d,u),u+=3,f.toArray(d,u),u+=3,f.toArray(d,u),u+=3}for(let p=-1*r;p<=r;p+=i){c.push(-1*e,0,p,e,0,p);const f=p===0?s:o;f.toArray(d,u),u+=3,f.toArray(d,u),u+=3,f.toArray(d,u),u+=3,f.toArray(d,u),u+=3}const m=new Vs;m.setAttribute(\"position\",new li(c,3)),m.setAttribute(\"color\",new li(d,3)),super(m,new uS({vertexColors:!0,toneMapped:!1}))}}function OMe(t,e,n,r){const i=(function(o,l,c){o*=.5,l*=.5,c*=.5;const d=new Vs,u=[];return u.push(-o,-l,-c,-o,l,-c,-o,l,-c,o,l,-c,o,l,-c,o,-l,-c,o,-l,-c,-o,-l,-c,-o,-l,c,-o,l,c,-o,l,c,o,l,c,o,l,c,o,-l,c,o,-l,c,-o,-l,c,-o,-l,-c,-o,-l,c,-o,l,-c,-o,l,c,o,l,-c,o,l,c,o,-l,-c,o,-l,c),d.setAttribute(\"position\",new li(u,3)),d})(t,e,n),s=new aT(i,new hMe({color:new Mn(r),dashSize:3,gapSize:1}));return s.computeLineDistances(),s}var D0=function(){var t=0,e=document.createElement(\"div\");function n(u){return e.appendChild(u.dom),u}function r(u){for(var m=0;m<e.children.length;m++)e.children[m].style.display=m===u?\"block\":\"none\";t=u}e.style.cssText=\"position:fixed;top:0;left:0;cursor:pointer;opacity:0.9;z-index:10000\",e.addEventListener(\"click\",(function(u){u.preventDefault(),r(++t%e.children.length)}),!1);var i=(performance||Date).now(),s=i,o=0,l=n(new D0.Panel(\"FPS\",\"#0ff\",\"#002\")),c=n(new D0.Panel(\"MS\",\"#0f0\",\"#020\"));if(self.performance&&self.performance.memory)var d=n(new D0.Panel(\"MB\",\"#f08\",\"#201\"));return r(0),{REVISION:16,dom:e,addPanel:n,showPanel:r,begin:function(){i=(performance||Date).now()},end:function(){o++;var u=(performance||Date).now();if(c.update(u-i,200),u>=s+1e3&&(l.update(1e3*o/(u-s),100),s=u,o=0,d)){var m=performance.memory;d.update(m.usedJSHeapSize/1048576,m.jsHeapSizeLimit/1048576)}return u},update:function(){i=this.end()},domElement:e,setMode:r}};D0.Panel=function(t,e,n){var r=1/0,i=0,s=Math.round,o=s(window.devicePixelRatio||1),l=80*o,c=48*o,d=3*o,u=2*o,m=3*o,p=15*o,f=74*o,y=30*o,v=document.createElement(\"canvas\");v.width=l,v.height=c,v.style.cssText=\"width:80px;height:48px\";var b=v.getContext(\"2d\");return b.font=\"bold \"+9*o+\"px Helvetica,Arial,sans-serif\",b.textBaseline=\"top\",b.fillStyle=n,b.fillRect(0,0,l,c),b.fillStyle=e,b.fillText(t,d,u),b.fillRect(m,p,f,y),b.fillStyle=n,b.globalAlpha=.9,b.fillRect(m,p,f,y),{dom:v,update:function(g,_){r=Math.min(r,g),i=Math.max(i,g),b.fillStyle=n,b.globalAlpha=1,b.fillRect(0,0,l,p),b.fillStyle=e,b.fillText(s(g)+\" \"+t+\" (\"+s(r)+\"-\"+s(i)+\")\",d,u),b.drawImage(v,m+o,p,f-o,y,m,p,f-o,y),b.fillRect(m+f-o,p,o,y),b.fillStyle=n,b.globalAlpha=.9,b.fillRect(m+f-o,p,o,s((1-g/_)*y))}}};var IMe=D0;class B${constructor(e,n){this.openFolders=[],this.watchedObject=e,this.options=n,this.gui=new zN,this.gui.title(\"Dev info\"),this.setup()}setup(){this.loadOpenFolders(),this.options&&!this.options.renderer||this.setupRedererFolder(),this.options&&!this.options.camera||this.setupCameraFolder(),this.options&&!this.options.parser||this.setupParserFolder(),this.options&&!this.options.buildVolume||this.setupBuildVolumeFolder(),this.options&&!this.options.devHelpers||this.setupDevHelpers()}reset(){this.gui.destroy(),this.gui=new zN,this.gui.title(\"Dev info\"),this.setup()}loadOpenFolders(){this.openFolders=JSON.parse(localStorage.getItem(\"dev-gui-open\")||\"{}\").open||[]}saveOpenFolders(){this.openFolders=this.gui.foldersRecursive().filter((e=>!e._closed)).map((e=>e._title)),console.log(this.openFolders),localStorage.setItem(\"dev-gui-open\",JSON.stringify({open:this.openFolders}))}setupRedererFolder(){const e=this.gui.addFolder(\"Render Info\");this.openFolders.includes(\"Render Info\")||e.close(),e.onOpenClose((()=>{this.saveOpenFolders()})),e.add(this.watchedObject.renderer.info.render,\"triangles\").listen(),e.add(this.watchedObject.renderer.info.render,\"calls\").listen(),e.add(this.watchedObject.renderer.info.render,\"lines\").listen(),e.add(this.watchedObject.renderer.info.render,\"points\").listen(),e.add(this.watchedObject.renderer.info.memory,\"geometries\").listen(),e.add(this.watchedObject.renderer.info.memory,\"textures\").listen(),e.add(this.watchedObject,\"_lastRenderTime\").listen()}setupCameraFolder(){const e=this.gui.addFolder(\"Camera\");this.openFolders.includes(\"Camera\")||e.close(),e.onOpenClose((()=>{this.saveOpenFolders()}));const n=e.addFolder(\"Camera position\");n.add(this.watchedObject.camera.position,\"x\").listen(),n.add(this.watchedObject.camera.position,\"y\").listen(),n.add(this.watchedObject.camera.position,\"z\").listen();const r=e.addFolder(\"Camera rotation\");r.add(this.watchedObject.camera.rotation,\"x\").listen(),r.add(this.watchedObject.camera.rotation,\"y\").listen(),r.add(this.watchedObject.camera.rotation,\"z\").listen()}setupParserFolder(){const e=this.gui.addFolder(\"Parser\");this.openFolders.includes(\"Parser\")||e.close(),e.onOpenClose((()=>{this.saveOpenFolders()})),e.add(this.watchedObject.parser,\"curZ\").listen(),e.add(this.watchedObject.parser,\"maxZ\").listen(),e.add(this.watchedObject.parser,\"tolerance\").listen(),e.add(this.watchedObject.parser.layers,\"length\").name(\"layers.count\").listen(),e.add(this.watchedObject.parser.lines,\"length\").name(\"lines.count\").listen()}setupBuildVolumeFolder(){const e=this.gui.addFolder(\"Build Volume\");this.openFolders.includes(\"Build Volume\")||e.close(),e.onOpenClose((()=>{this.saveOpenFolders()})),e.add(this.watchedObject.buildVolume,\"x\").min(0).max(600).listen().onChange((()=>{this.watchedObject.render()})),e.add(this.watchedObject.buildVolume,\"y\").min(0).max(600).listen().onChange((()=>{this.watchedObject.render()})),e.add(this.watchedObject.buildVolume,\"z\").min(0).max(600).listen().onChange((()=>{this.watchedObject.render()}))}setupDevHelpers(){const e=this.gui.addFolder(\"Dev Helpers\");this.openFolders.includes(\"Dev Helpers\")||e.close(),e.onOpenClose((()=>{this.saveOpenFolders()})),e.add(this.watchedObject,\"_wireframe\").listen().onChange((()=>{this.watchedObject.render()})),e.add(this.watchedObject,\"render\").listen(),e.add(this.watchedObject,\"clear\").listen()}}class zMe extends Vs{constructor(e=[new ut],n=.6,r=.2,i=8){super(),this.type=\"ExtrusionGeometry\",this.parameters={points:e,lineWidth:n,lineHeight:r,radialSegments:i,closed:!1};const s=new ut,o=new ut,l=new Vn,c=[],d=[],u=[],m=[];function p(f){const[y,v,b]=(function(g){const _=e[g],C=new ut,P=new ut,N=new ut,A=new ut;C.copy(_).sub(e[g-1]||_).normalize().add((e[g+1]||_).clone().sub(_).normalize()).normalize();let T=Number.MAX_VALUE;const F=Math.abs(C.x),k=Math.abs(C.y),D=Math.abs(C.z);return F<=T&&(T=F,P.set(1,0,0)),k<=T&&(T=k,P.set(0,1,0)),D<=T&&P.set(0,0,1),A.crossVectors(C,P).normalize(),P.crossVectors(C,A),N.crossVectors(C,P),[_,P,N]})(f);for(let g=0;g<=i;g++){const _=g/i*Math.PI*2,C=Math.sin(_),P=-Math.cos(_);o.x=P*v.x+C*b.x,o.y=P*v.y+C*b.y,o.z=P*v.z+C*b.z,o.normalize(),d.push(o.x,o.y,o.z),s.x=y.x+n*o.x*.5,s.y=y.y+n*o.y*.5,s.z=y.z+r*o.z*.5,c.push(s.x,s.y,s.z)}}(function(){for(let f=0;f<e.length;f++)p(f);p(closed===!1?e.length-1:0),(function(){for(let f=0;f<e.length;f++)for(let y=0;y<=i;y++)l.x=f/e.length,l.y=y/i,u.push(l.x,l.y)})(),(function(){for(let f=1;f<e.length;f++)for(let y=1;y<=i;y++){const v=(i+1)*(f-1)+(y-1),b=(i+1)*f+(y-1),g=(i+1)*f+y,_=(i+1)*(f-1)+y;m.push(v,b,_),m.push(b,g,_)}})()})(),this.setIndex(m),this.setAttribute(\"position\",new li(c,3)),this.setAttribute(\"normal\",new li(d,3)),this.setAttribute(\"uv\",new li(u,2))}}class F0{static get initial(){const e=new F0;return Object.assign(e,{x:0,y:0,z:0,r:0,e:0,i:0,j:0,t:0}),e}}const YE={h:0,s:0,l:0};class Dx{constructor(e){var n,r,i,s,o,l,c,d,u;if(this.minLayerThreshold=.05,this.renderExtrusion=!0,this.renderTravel=!1,this.renderTubes=!1,this.extrusionWidth=.6,this.singleLayerMode=!1,this.initialCameraPosition=[-100,400,450],this.debug=!1,this.inches=!1,this.nonTravelmoves=[],this.disableGradient=!1,this.state=F0.initial,this.beyondFirstMove=!1,this.disposables=[],this._extrusionColor=Dx.defaultExtrusionColor,this.renderLayerIndex=0,this._geometries={},this._backgroundColor=new Mn(14737632),this._travelColor=new Mn(10027008),this._toolColors={},this.devMode=!1,this._lastRenderTime=0,this._wireframe=!1,this.minLayerThreshold=(n=e.minLayerThreshold)!==null&&n!==void 0?n:this.minLayerThreshold,this.parser=new UN(this.minLayerThreshold),this.scene=new tMe,this.scene.background=this._backgroundColor,e.backgroundColor!==void 0&&(this.backgroundColor=new Mn(e.backgroundColor)),this.targetId=e.targetId,this.endLayer=e.endLayer,this.startLayer=e.startLayer,this.lineWidth=e.lineWidth,this.lineHeight=e.lineHeight,this.buildVolume=e.buildVolume,this.initialCameraPosition=(r=e.initialCameraPosition)!==null&&r!==void 0?r:this.initialCameraPosition,this.debug=(i=e.debug)!==null&&i!==void 0?i:this.debug,this.renderExtrusion=(s=e.renderExtrusion)!==null&&s!==void 0?s:this.renderExtrusion,this.renderTravel=(o=e.renderTravel)!==null&&o!==void 0?o:this.renderTravel,this.nonTravelmoves=(l=e.nonTravelMoves)!==null&&l!==void 0?l:this.nonTravelmoves,this.renderTubes=(c=e.renderTubes)!==null&&c!==void 0?c:this.renderTubes,this.extrusionWidth=(d=e.extrusionWidth)!==null&&d!==void 0?d:this.extrusionWidth,this.devMode=(u=e.devMode)!==null&&u!==void 0?u:this.devMode,this.stats=this.devMode?new IMe:void 0,e.extrusionColor!==void 0&&(this.extrusionColor=e.extrusionColor),e.travelColor!==void 0&&(this.travelColor=new Mn(e.travelColor)),e.topLayerColor!==void 0&&(this.topLayerColor=new Mn(e.topLayerColor)),e.lastSegmentColor!==void 0&&(this.lastSegmentColor=new Mn(e.lastSegmentColor)),e.toolColors){this._toolColors={};for(const[f,y]of Object.entries(e.toolColors))this._toolColors[parseInt(f)]=new Mn(y)}if(e.disableGradient!==void 0&&(this.disableGradient=e.disableGradient),console.info(\"Using THREE r\"+JP),console.debug(\"opts\",e),this.targetId&&console.warn(\"`targetId` is deprecated and will removed in the future. Use `canvas` instead.\"),e.canvas)this.canvas=e.canvas,this.renderer=new gR({canvas:this.canvas,preserveDrawingBuffer:!0});else{if(!this.targetId)throw Error(\"Set either opts.canvas or opts.targetId\");const f=document.getElementById(this.targetId);if(!f)throw new Error(\"Unable to find element \"+this.targetId);this.renderer=new gR({preserveDrawingBuffer:!0}),this.canvas=this.renderer.domElement,f.appendChild(this.canvas)}this.camera=new ll(25,this.canvas.offsetWidth/this.canvas.offsetHeight,10,5e3),this.camera.position.fromArray(this.initialCameraPosition);const m=this.camera.far,p=.8*m;this.scene.fog=new fI(this._backgroundColor,p,m),this.resize(),this.controls=new FMe(this.camera,this.renderer.domElement),this.initScene(),this.animate(),e.allowDragNDrop&&this._enableDropHandler(),this.initStats()}get extrusionColor(){return this._extrusionColor}set extrusionColor(e){if(Array.isArray(e)){this._extrusionColor=[];for(const[n,r]of e.entries())this._extrusionColor[n]=new Mn(r)}else this._extrusionColor=new Mn(e)}get currentToolColor(){var e;return this._extrusionColor===void 0?Dx.defaultExtrusionColor:this._extrusionColor instanceof Mn?this._extrusionColor:(e=this._extrusionColor[this.state.t])!==null&&e!==void 0?e:Dx.defaultExtrusionColor}get backgroundColor(){return this._backgroundColor}set backgroundColor(e){this._backgroundColor=new Mn(e),this.scene.background=this._backgroundColor}get travelColor(){return this._travelColor}set travelColor(e){this._travelColor=new Mn(e)}get topLayerColor(){return this._topLayerColor}set topLayerColor(e){this._topLayerColor=e!==void 0?new Mn(e):void 0}get lastSegmentColor(){return this._lastSegmentColor}set lastSegmentColor(e){this._lastSegmentColor=e!==void 0?new Mn(e):void 0}get layers(){return[this.parser.preamble].concat(this.parser.layers.concat())}get maxLayerIndex(){var e;return((e=this.endLayer)!==null&&e!==void 0?e:this.layers.length)-1}get minLayerIndex(){var e;return this.singleLayerMode?this.maxLayerIndex:((e=this.startLayer)!==null&&e!==void 0?e:0)-1}animate(){var e;this.animationFrameId=requestAnimationFrame((()=>this.animate())),this.controls.update(),this.renderer.render(this.scene,this.camera),(e=this.stats)===null||e===void 0||e.update()}processGCode(e){this.parser.parseGCode(e),this.render()}initScene(){for(;this.scene.children.length>0;)this.scene.remove(this.scene.children[0]);for(;this.disposables.length>0;){const e=this.disposables.pop();e&&e.dispose()}if(this.debug&&this.buildVolume){const e=new vMe(Math.max(this.buildVolume.x/2,this.buildVolume.y/2)+20);this.scene.add(e)}if(this.buildVolume&&this.drawBuildVolume(),this.renderTubes){console.warn(\"Volumetric rendering is experimental. It may not work as expected or change in the future.\");const e=new bMe(13421772,.3*Math.PI),n=new gMe(16777215,Math.PI,void 0,.001);n.position.set(0,500,500),this.scene.add(e),this.scene.add(n)}}createGroup(e){const n=new p0;return n.name=e,n.quaternion.setFromEuler(new cS(-Math.PI/2,0,0)),this.buildVolume?n.position.set(-this.buildVolume.x/2,0,this.buildVolume.y/2):n.position.set(-100,0,100),n}render(){const e=performance.now();this.group=this.createGroup(\"allLayers\"),this.state=F0.initial,this.initScene();for(let n=0;n<this.layers.length;n++)this.renderLayer(n);this.batchGeometries(),this.scene.add(this.group),this.renderer.render(this.scene,this.camera),this._lastRenderTime=performance.now()-e}renderAnimated(e=1){return GE(this,void 0,void 0,(function*(){return this.initScene(),this.renderLayerIndex=0,this.renderFrameLoop(e>0?e:1)}))}renderFrameLoop(e){return new Promise((n=>{const r=()=>{this.renderLayerIndex>this.layers.length-1?n():(this.renderFrame(e),requestAnimationFrame(r))};r()}))}renderFrame(e){this.group=this.createGroup(\"layer\"+this.renderLayerIndex);for(let n=0;n<e&&this.renderLayerIndex+n<this.layers.length;n++)this.renderLayer(this.renderLayerIndex),this.renderLayerIndex++;this.batchGeometries(),this.scene.add(this.group)}renderLayer(e){var n,r,i,s,o,l,c,d;if(e>this.maxLayerIndex)return;const u=this.layers[e],m={extrusion:[],travel:[],z:this.state.z,height:u.height};for(const p of u.commands)if(p.gcode!=\"g20\"){if(p.gcode.startsWith(\"t\")){this.doRenderExtrusion(m,e),m.extrusion=[];const f=p;this.state.t=f.toolIndex}else if([\"g0\",\"g00\",\"g1\",\"g01\",\"g2\",\"g02\",\"g3\",\"g03\"].indexOf(p.gcode)>-1){const f=p,y={x:(n=f.params.x)!==null&&n!==void 0?n:this.state.x,y:(r=f.params.y)!==null&&r!==void 0?r:this.state.y,z:(i=f.params.z)!==null&&i!==void 0?i:this.state.z,r:(s=f.params.r)!==null&&s!==void 0?s:this.state.r,e:(o=f.params.e)!==null&&o!==void 0?o:this.state.e,i:(l=f.params.i)!==null&&l!==void 0?l:this.state.i,j:(c=f.params.j)!==null&&c!==void 0?c:this.state.j,t:this.state.t};if(e>=this.minLayerIndex){const v=((d=f.params.e)!==null&&d!==void 0?d:0)>0||this.nonTravelmoves.indexOf(p.gcode)>-1;(y.x!=this.state.x||y.y!=this.state.y||y.z!=this.state.z)&&(v&&this.renderExtrusion||!v&&this.renderTravel)&&(p.gcode==\"g2\"||p.gcode==\"g3\"||p.gcode==\"g02\"||p.gcode==\"g03\"?this.addArcSegment(m,this.state,y,v,p.gcode==\"g2\"||p.gcode==\"g02\"):this.addLineSegment(m,this.state,y,v))}this.state.x=y.x,this.state.y=y.y,this.state.z=y.z,this.beyondFirstMove||(this.beyondFirstMove=!0)}}else this.setInches();this.doRenderExtrusion(m,e)}doRenderExtrusion(e,n){var r,i;if(this.renderExtrusion){let s=this.currentToolColor;if(!this.singleLayerMode&&!this.renderTubes&&!this.disableGradient){const o=.1+.7*n/this.layers.length;s.getHSL(YE),s=new Mn().setHSL(YE.h,YE.s,o)}if(n==this.layers.length-1){const o=(r=this._topLayerColor)!==null&&r!==void 0?r:s,l=(i=this._lastSegmentColor)!==null&&i!==void 0?i:o,c=e.extrusion.splice(-3),d=e.extrusion.splice(-3);this.renderTubes?(this.addTubeLine(e.extrusion,o.getHex(),e.height),this.addTubeLine([...d,...c],l.getHex(),e.height)):(this.addLine(e.extrusion,o.getHex()),this.addLine([...d,...c],l.getHex()))}else this.renderTubes?this.addTubeLine(e.extrusion,s.getHex(),e.height):this.addLine(e.extrusion,s.getHex())}this.renderTravel&&this.addLine(e.travel,this._travelColor.getHex())}setInches(){this.beyondFirstMove?console.warn(\"Switching units after movement is already made is discouraged and is not supported.\"):this.inches=!0}drawBuildVolume(){if(!this.buildVolume)return;this.scene.add(new LMe(this.buildVolume.x,10,this.buildVolume.y,10));const e=OMe(this.buildVolume.x,this.buildVolume.z,this.buildVolume.y,8947848);e.position.setY(this.buildVolume.z/2),this.scene.add(e)}clear(){this.resetState(),this.parser=new UN(this.minLayerThreshold)}resetState(){var e;this.startLayer=1,this.endLayer=1/0,this.singleLayerMode=!1,this.beyondFirstMove=!1,this.state=F0.initial,(e=this.devGui)===null||e===void 0||e.reset(),this._geometries={}}resize(){const[e,n]=[this.canvas.offsetWidth,this.canvas.offsetHeight];this.camera.aspect=e/n,this.camera.updateProjectionMatrix(),this.renderer.setPixelRatio(window.devicePixelRatio),this.renderer.setSize(e,n,!1)}addLineSegment(e,n,r,i){(i?e.extrusion:e.travel).push(n.x,n.y,n.z,r.x,r.y,r.z)}addArcSegment(e,n,r,i,s){const o=i?e.extrusion:e.travel,l=n.x,c=n.y,d=n.z,u=r.x,m=r.y,p=r.z;let f=r.r,y=r.i,v=r.j;if(f){const te=u-l,ie=m-c,J=Math.sqrt(Math.pow(te/2,2)+Math.pow(ie/2,2));f=Math.max(f,J);const oe=Math.pow(te,2)+Math.pow(ie,2),fe=Math.pow(f,2)-oe/4;let re=Math.sqrt(fe/oe);(s&&f<0||!s&&f>0)&&(re=-re),y=te/2+ie*re,v=ie/2-te*re}const b=l==u&&c==m,g=l+y,_=c+v,C=Math.sqrt(y*y+v*v),P=Math.atan2(-v,-y),N=Math.atan2(m-_,u-g);let A;b?A=2*Math.PI:(A=s?P-N:N-P,A<0&&(A+=2*Math.PI));let T=C*A/1.8;this.inches&&(T*=25),T<1&&(T=1);let F=A/T;F*=s?-1:1;const k=[];k.push({x:l,y:c,z:d});const D=(d-p)/T;let H=l,z=c,Q=d,L=P;for(let te=0;te<T-1;te++)L+=F,H=g+C*Math.cos(L),z=_+C*Math.sin(L),Q+=D,k.push({x:H,y:z,z:Q});k.push({x:r.x,y:r.y,z:r.z});for(let te=0;te<k.length-1;te++)o.push(k[te].x,k[te].y,k[te].z,k[te+1].x,k[te+1].y,k[te+1].z)}addLine(e,n){var r;if(typeof this.lineWidth==\"number\"&&this.lineWidth>0)return void this.addThickLine(e,n);const i=new Vs;i.setAttribute(\"position\",new li(e,3)),this.disposables.push(i);const s=new uS({color:n});this.disposables.push(s);const o=new aT(i,s);(r=this.group)===null||r===void 0||r.add(o)}addTubeLine(e,n,r=.2){let i=[];const s=[];for(let o=0;o<e.length;o+=6){const l=e.slice(o,o+9),c=new ut(l[0],l[1],l[2]),d=new ut(l[3],l[4],l[5]),u=new ut(l[6],l[7],l[8]);i.push(c),d.equals(u)||(i.push(d),s.push(i),i=[])}s.forEach((o=>{var l;const c=new zMe(o,this.extrusionWidth,this.lineHeight||r,4);(l=this._geometries)[n]||(l[n]=[]),this._geometries[n].push(c)}))}addThickLine(e,n){var r;if(!e.length||!this.lineWidth)return;const i=new AJ;this.disposables.push(i);const s=new TJ({color:n,linewidth:this.lineWidth/(1e3*window.devicePixelRatio)});this.disposables.push(s),i.setPositions(e);const o=new RMe(i,s);(r=this.group)===null||r===void 0||r.add(o)}dispose(){this.disposables.forEach((e=>e.dispose())),this.disposables=[],this.controls.dispose(),this.renderer.dispose(),this.cancelAnimation()}cancelAnimation(){this.animationFrameId!==void 0&&cancelAnimationFrame(this.animationFrameId),this.animationFrameId=void 0}_enableDropHandler(){console.warn(\"Drag and drop is deprecated as a library feature. See the demo how to implement your own.\"),this.canvas.addEventListener(\"dragover\",(e=>{e.stopPropagation(),e.preventDefault(),e.dataTransfer&&(e.dataTransfer.dropEffect=\"copy\"),this.canvas.classList.add(\"dragging\")})),this.canvas.addEventListener(\"dragleave\",(e=>{e.stopPropagation(),e.preventDefault(),this.canvas.classList.remove(\"dragging\")})),this.canvas.addEventListener(\"drop\",(e=>GE(this,void 0,void 0,(function*(){var n,r;e.stopPropagation(),e.preventDefault(),this.canvas.classList.remove(\"dragging\");const i=((r=(n=e.dataTransfer)===null||n===void 0?void 0:n.files)!==null&&r!==void 0?r:[])[0];this.clear(),yield this._readFromStream(i.stream()),this.render()}))))}batchGeometries(){if(this._geometries)for(const e in this._geometries){const n=this.createBatchMesh(parseInt(e));for(;this._geometries[e].length>0;){const r=this._geometries[e].pop();n.addGeometry(r)}}}createBatchMesh(e){var n;const r=this._geometries[e],i=new mMe({color:e,wireframe:this._wireframe});this.disposables.push(i);const s=r.reduce(((l,c)=>3*c.attributes.position.count+l),0),o=new cMe(r.length,s,void 0,i);return this.disposables.push(o),(n=this.group)===null||n===void 0||n.add(o),o}_readFromStream(e){var n,r;return GE(this,void 0,void 0,(function*(){const i=e.getReader();let s,o=\"\",l=0;do{console.debug(\"reading from stream\"),s=yield i.read(),l+=(r=(n=s.value)===null||n===void 0?void 0:n.length)!==null&&r!==void 0?r:0;const d=(c=s.value,new TextDecoder(\"utf-8\").decode(c)),u=d.lastIndexOf(`\n`),m=d.slice(0,u);this.parser.parseGCode(o+m),o=d.slice(u)}while(!s.done);var c;console.debug(\"read from stream\",l)}))}initGui(){typeof this.devMode==\"boolean\"&&this.devMode===!0?this.devGui=new B$(this):typeof this.devMode==\"object\"&&(this.devGui=new B$(this,this.devMode))}initStats(){var e;this.stats&&(typeof this.devMode==\"object\"&&(this.statsContainer=this.devMode.statsContainer),((e=this.statsContainer)!==null&&e!==void 0?e:document.body).appendChild(this.stats.dom),this.stats.dom.classList.add(\"stats\"),this.initGui())}}Dx.defaultExtrusionColor=new Mn(\"hotpink\");function jJ({gcodeUrl:t,buildVolume:e={x:256,y:256,z:256},filamentColors:n,className:r=\"\"}){const i=w.useRef(null),s=w.useRef(null),o=w.useRef(null),l=w.useRef(!1),[c,d]=w.useState(!0),[u,m]=w.useState(null),[p,f]=w.useState(!1),[y,v]=w.useState(0),[b,g]=w.useState(0),_=w.useMemo(()=>JSON.stringify(n),[n]);w.useEffect(()=>{if(!i.current||l.current)return;l.current=!0;const N=i.current,A=N.parentElement?.getBoundingClientRect();A&&(N.width=A.width,N.height=A.height);const T=n&&n.length>1,F=n?.[0]||\"#00ae42\",k=new Dx({canvas:N,buildVolume:e,backgroundColor:1710618,extrusionColor:T?n:F,disableGradient:!0,lineHeight:.2,lineWidth:2,renderTravel:!1,renderExtrusion:!0});s.current=k;const D={},H=vm();H&&(D.Authorization=`Bearer ${H}`),fetch(t,{headers:D}).then(async Q=>{if(!Q.ok)throw Q.status===404&&(await Q.json().catch(()=>({}))).detail?.includes(\"sliced\")?(f(!0),new Error(\"not_sliced\")):new Error(\"Failed to load G-code\");return Q.text()}).then(Q=>{const L=new Set,te=/^(\\s*)T(\\d+)(\\s*;.*)?$/gim;let ie;for(;(ie=te.exec(Q))!==null;){const ne=parseInt(ie[2],10);ne<=15&&L.add(ne)}const J=new Map,oe=Array.from(L).sort((ne,me)=>ne-me);oe.forEach((ne,me)=>{J.set(ne,me%8)});const fe=[];oe.forEach((ne,me)=>{const be=n?.[ne]||\"#00ae42\";fe[me%8]=be});const re=Q.split(`\n`).map(ne=>{const me=ne.match(/^(\\s*)T(\\d+)(\\s*;.*)?$/i);if(me){const be=parseInt(me[2],10);if(be>15)return`; FILTERED: ${ne.trim()}`;const Ce=J.get(be)??0;return`${me[1]}T${Ce}${me[3]||\"\"}`}return ne}).join(`\n`);fe.length>0&&(k.extrusionColor=fe),k.processGCode(re);const W=k.layers?.length||0;g(W),v(W),k.render(),d(!1)}).catch(Q=>{Q.message!==\"not_sliced\"&&m(Q.message),d(!1)});const z=()=>{if(N.parentElement&&s.current){const Q=N.parentElement.getBoundingClientRect();N.width=Q.width,N.height=Q.height,s.current.resize()}};return window.addEventListener(\"resize\",z),()=>{window.removeEventListener(\"resize\",z),o.current&&cancelAnimationFrame(o.current),s.current&&(s.current.dispose(),s.current=null),l.current=!1}},[t,_]);const C=w.useCallback(N=>{if(!s.current)return;const A=Math.max(1,Math.min(N,b));v(A),o.current&&cancelAnimationFrame(o.current),o.current=requestAnimationFrame(()=>{s.current&&(s.current.endLayer=A,s.current.render())})},[b]),P=N=>{C(parseInt(N.target.value,10))};return a.jsxs(\"div\",{className:`relative flex flex-col h-full ${r}`,children:[a.jsxs(\"div\",{className:\"flex-1 relative bg-bambu-dark rounded-lg overflow-hidden\",children:[a.jsx(\"canvas\",{ref:i,className:\"w-full h-full\"}),c&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\",children:a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green mx-auto mb-2\"}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:\"Loading G-code...\"})]})}),p&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\",children:a.jsxs(\"div\",{className:\"text-center max-w-sm px-4\",children:[a.jsx(ige,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-3\"}),a.jsx(\"p\",{className:\"text-white font-medium mb-2\",children:\"G-code not available\"}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:\"This file hasn't been sliced yet. G-code preview is only available after slicing in Bambu Studio or Orca Slicer.\"})]})}),u&&!p&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-bambu-dark/80\",children:a.jsx(\"div\",{className:\"text-center text-red-400\",children:a.jsx(\"p\",{className:\"text-sm\",children:u})})})]}),!c&&!u&&!p&&b>0&&a.jsx(\"div\",{className:\"mt-4 px-2\",children:a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(da,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"}),a.jsx(\"button\",{onClick:()=>C(y-1),disabled:y<=1,className:\"p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed\",children:a.jsx(xl,{className:\"w-4 h-4\"})}),a.jsx(\"input\",{type:\"range\",min:1,max:b,value:y,onChange:P,className:\"flex-1 h-2 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer accent-bambu-green\"}),a.jsx(\"button\",{onClick:()=>C(y+1),disabled:y>=b,className:\"p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-30 disabled:cursor-not-allowed\",children:a.jsx(ti,{className:\"w-4 h-4\"})}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray min-w-[80px] text-right\",children:[y,\" / \",b]})]})})]})}function UMe({printerId:t,filePath:e,filename:n,onClose:r}){const[i,s]=w.useState(null),[o,l]=w.useState([]),[c,d]=w.useState(!1),[u,m]=w.useState(null),p=n.toLowerCase().split(\".\").pop()||\"\",f=p===\"3mf\"||p===\"stl\",y=p===\"gcode\"||p===\"3mf\";w.useEffect(()=>{s(f?\"3d\":y?\"gcode\":null)},[f,y]),w.useEffect(()=>{l([]),m(null),f&&(d(!0),ue.getPrinterFilePlates(t,e).then(g=>l(g.plates||[])).catch(()=>l([])).finally(()=>d(!1)))},[e,f,t]);const v=o.length>1,b=u==null?null:o.find(g=>g.index===u)??null;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-6\",onClick:r,children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-4xl h-[80vh] flex flex-col\",onClick:g=>g.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white truncate flex-1 mr-4\",children:n}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:r,children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"flex border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"button\",{onClick:()=>f&&s(\"3d\"),disabled:!f,className:`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${i===\"3d\"?\"text-bambu-green border-b-2 border-bambu-green\":f?\"text-bambu-gray hover:text-white\":\"text-bambu-gray/30 cursor-not-allowed\"}`,children:[a.jsx(vi,{className:\"w-4 h-4\"}),\"3D Model\",!f&&a.jsx(\"span\",{className:\"text-xs\",children:\"(not available)\"})]}),a.jsxs(\"button\",{onClick:()=>y&&s(\"gcode\"),disabled:!y,className:`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${i===\"gcode\"?\"text-bambu-green border-b-2 border-bambu-green\":y?\"text-bambu-gray hover:text-white\":\"text-bambu-gray/30 cursor-not-allowed\"}`,children:[a.jsx(Us,{className:\"w-4 h-4\"}),\"G-code Preview\",!y&&a.jsx(\"span\",{className:\"text-xs\",children:\"(not sliced)\"})]})]}),a.jsx(\"div\",{className:\"flex-1 overflow-hidden p-4\",children:i===\"3d\"&&f?a.jsxs(\"div\",{className:\"w-full h-full flex flex-col gap-3\",children:[v&&a.jsxs(\"div\",{className:\"rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray mb-2\",children:[a.jsx(vi,{className:\"w-4 h-4\"}),\"Plates\",c&&a.jsx(ft,{className:\"w-3 h-3 animate-spin\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 md:grid-cols-3 gap-2\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>m(null),className:`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${u==null?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray\"}`,children:[a.jsx(\"div\",{className:\"w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center\",children:a.jsx(vi,{className:\"w-5 h-5 text-bambu-gray\"})}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"text-sm text-white font-medium truncate\",children:\"All Plates\"}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray truncate\",children:[o.length,\" plate\",o.length!==1?\"s\":\"\"]})]}),u==null&&a.jsx(Ns,{className:\"w-4 h-4 text-bambu-green flex-shrink-0\"})]}),o.map(g=>a.jsxs(\"button\",{type:\"button\",onClick:()=>m(g.index),className:`flex items-center gap-2 rounded-lg border p-2 text-left transition-colors ${u===g.index?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray\"}`,children:[g.has_thumbnail?a.jsx(\"img\",{src:ue.getPrinterFilePlateThumbnail(t,g.index,e),alt:`Plate ${g.index}`,className:\"w-10 h-10 rounded object-cover bg-bambu-dark-tertiary\"}):a.jsx(\"div\",{className:\"w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center\",children:a.jsx(vi,{className:\"w-5 h-5 text-bambu-gray\"})}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"text-sm text-white font-medium truncate\",children:g.name||`Plate ${g.index}`}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray truncate\",children:g.objects.length>0?g.objects.slice(0,2).join(\", \")+(g.objects.length>2?\"…\":\"\"):`${g.filaments.length} filament${g.filaments.length!==1?\"s\":\"\"}`})]}),u===g.index&&a.jsx(Ns,{className:\"w-4 h-4 text-bambu-green flex-shrink-0\"})]},g.index))]}),b&&a.jsxs(\"div\",{className:\"mt-3 text-xs text-bambu-gray flex flex-wrap gap-x-4 gap-y-1\",children:[a.jsxs(\"span\",{children:[\"Plate \",b.index]}),b.print_time_seconds!=null&&a.jsxs(\"span\",{children:[\"ETA \",Math.round(b.print_time_seconds/60),\" min\"]}),b.filament_used_grams!=null&&a.jsxs(\"span\",{children:[b.filament_used_grams.toFixed(1),\" g\"]}),b.filaments.length>0&&a.jsxs(\"span\",{children:[b.filaments.length,\" filament\",b.filaments.length!==1?\"s\":\"\"]})]})]}),a.jsx(\"div\",{className:\"flex-1\",children:a.jsx(KZ,{url:ue.getPrinterFileDownloadUrl(t,e),fileType:p,selectedPlateId:u,className:\"w-full h-full\"})})]}):i===\"gcode\"&&y?a.jsx(jJ,{gcodeUrl:ue.getPrinterFileGcodeUrl(t,e),className:\"w-full h-full\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center text-bambu-gray\",children:\"No preview available for this file\"})})]})})}function H$(t){if(t===0)return\"0 GB\";const e=t/(1024*1024*1024);return e>=1?`${e.toFixed(1)} GB`:`${(t/(1024*1024)).toFixed(0)} MB`}function BMe(t,e){if(e)return LQ;switch(t.toLowerCase().split(\".\").pop()||\"\"){case\"3mf\":return ep;case\"gcode\":return Us;case\"mp4\":case\"avi\":return tp;case\"png\":case\"jpg\":case\"jpeg\":return gm;default:return qP}}const HMe=[{value:\"name-asc\",label:\"Name (A-Z)\"},{value:\"name-desc\",label:\"Name (Z-A)\"},{value:\"size-asc\",label:\"Size (smallest)\"},{value:\"size-desc\",label:\"Size (largest)\"},{value:\"date-asc\",label:\"Date (oldest)\"},{value:\"date-desc\",label:\"Date (newest)\"}];function qMe({printerId:t,printerName:e,onClose:n}){const{t:r}=Ft(),{showToast:i}=hn(),s=nn(),[o,l]=w.useState(\"/\"),[c,d]=w.useState(new Set),[u,m]=w.useState(\"\"),[p,f]=w.useState([]),[y,v]=w.useState(\"name-asc\"),[b,g]=w.useState(null),[_,C]=w.useState(null);w.useEffect(()=>{const J=oe=>{oe.key===\"Escape\"&&n()};return window.addEventListener(\"keydown\",J),()=>window.removeEventListener(\"keydown\",J)},[n]);const{data:P,isLoading:N,refetch:A}=Xe({queryKey:[\"printerFiles\",t,o],queryFn:()=>ue.getPrinterFiles(t,o),refetchInterval:3e4}),{data:T}=Xe({queryKey:[\"printerStorage\",t],queryFn:()=>ue.getPrinterStorage(t),staleTime:3e4}),F=it({mutationFn:async J=>{for(const oe of J)await ue.deletePrinterFile(t,oe)},onSuccess:()=>{i(r(\"printerFiles.toast.filesDeleted\",{count:p.length})),s.invalidateQueries({queryKey:[\"printerFiles\",t]}),d(new Set),f([])},onError:J=>{i(r(\"printerFiles.toast.deleteFailed\",{error:J.message}),\"error\")}}),k=J=>{l(J),d(new Set)},D=()=>{if(o===\"/\")return;const J=o.split(\"/\").filter(Boolean);J.pop(),l(J.length?\"/\"+J.join(\"/\"):\"/\"),d(new Set)},H=(J,oe)=>{oe.stopPropagation(),d(fe=>{const re=new Set(fe);return re.has(J)?re.delete(J):re.add(J),re})},z=()=>{if(!P?.files)return;const J=P.files.filter(oe=>!oe.is_directory&&(!u||oe.name.toLowerCase().includes(u.toLowerCase()))).map(oe=>oe.path);d(new Set(J))},Q=()=>{d(new Set)},L=async()=>{if(c.size===0)return;const J=Array.from(c);if(J.length===1){ue.downloadPrinterFile(t,J[0]).catch(oe=>{console.error(\"Printer file download failed:\",oe)}),d(new Set);return}g({current:0,total:J.length});try{const oe=await ue.downloadPrinterFilesAsZip(t,J),fe=URL.createObjectURL(oe),re=document.createElement(\"a\");re.href=fe,re.download=`${e.replace(/[^a-zA-Z0-9]/g,\"_\")}-files.zip`,document.body.appendChild(re),re.click(),document.body.removeChild(re),URL.revokeObjectURL(fe),i(`Downloaded ${J.length} files as ZIP`),d(new Set)}catch(oe){i(`Download failed: ${oe instanceof Error?oe.message:\"Unknown error\"}`,\"error\")}finally{g(null)}},te=()=>{c.size!==0&&f(Array.from(c))},ie=[{path:\"/\",label:\"Root\"},{path:\"/cache\",label:\"Cache\"},{path:\"/model\",label:\"Models\"},{path:\"/timelapse\",label:\"Timelapse\"}];return a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",onClick:n,children:[a.jsxs(\"div\",{className:\"w-full max-w-3xl max-h-[85vh] flex flex-col bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden\",onClick:J=>J.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(Ig,{className:\"w-5 h-5 text-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:r(\"printerFiles.title\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:e})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-4\",children:[T&&(T.used_bytes!=null||T.free_bytes!=null)&&a.jsxs(\"div\",{className:\"text-sm text-bambu-gray flex items-center gap-2\",children:[T.used_bytes!=null&&a.jsxs(\"span\",{children:[r(\"printerFiles.storageUsed\"),\" \",H$(T.used_bytes)]}),T.used_bytes!=null&&T.free_bytes!=null&&a.jsx(\"span\",{className:\"text-bambu-dark-tertiary\",children:\"|\"}),T.free_bytes!=null&&a.jsxs(\"span\",{children:[r(\"printerFiles.storageFree\"),\" \",H$(T.free_bytes)]})]}),a.jsx(\"button\",{onClick:n,className:\"text-bambu-gray hover:text-white transition-colors\",title:\"Close file manager\",\"aria-label\":\"Close file manager\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 p-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0\",children:[ie.map(J=>a.jsx(\"button\",{onClick:()=>{k(J.path),m(\"\")},className:`px-3 py-1 text-sm rounded-full transition-colors ${o===J.path?\"bg-bambu-green text-white\":\"bg-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,children:J.label},J.path)),a.jsx(\"div\",{className:\"flex-1\"}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",placeholder:r(\"printerFiles.filterPlaceholder\"),value:u,onChange:J=>m(J.target.value),className:\"w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"relative flex items-center gap-1\",children:[a.jsx(TO,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"select\",{value:y,onChange:J=>v(J.target.value),className:\"appearance-none bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm py-1.5 pl-2 pr-6 focus:border-bambu-green focus:outline-none cursor-pointer\",title:\"Sort files\",\"aria-label\":\"Sort files\",children:HMe.map(J=>a.jsx(\"option\",{value:J.value,children:J.label},J.value))})]}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>A(),disabled:N,children:a.jsx(lr,{className:`w-4 h-4 ${N?\"animate-spin\":\"\"}`})})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 px-4 py-2 bg-bambu-dark text-sm flex-shrink-0\",children:[a.jsx(\"button\",{onClick:D,disabled:o===\"/\",className:\"p-1 rounded hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed\",title:\"Go to parent folder\",\"aria-label\":\"Go to parent folder\",children:a.jsx(xl,{className:\"w-4 h-4\"})}),a.jsx(\"span\",{className:\"text-bambu-gray font-mono\",children:o})]}),a.jsx(\"div\",{className:\"flex-1 overflow-y-auto p-2 min-h-0\",children:N?a.jsx(\"div\",{className:\"flex items-center justify-center py-12\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):P?.files?.length?a.jsx(\"div\",{className:\"space-y-1\",children:[...P.files].filter(J=>!u||J.name.toLowerCase().includes(u.toLowerCase())).sort((J,oe)=>{if(J.is_directory&&!oe.is_directory)return-1;if(!J.is_directory&&oe.is_directory)return 1;switch(y){case\"name-asc\":return J.name.localeCompare(oe.name);case\"name-desc\":return oe.name.localeCompare(J.name);case\"size-asc\":return J.size-oe.size;case\"size-desc\":return oe.size-J.size;case\"date-asc\":{const fe=J.mtime?Zn(J.mtime)?.getTime()??0:0,re=oe.mtime?Zn(oe.mtime)?.getTime()??0:0;return fe-re}case\"date-desc\":{const fe=J.mtime?Zn(J.mtime)?.getTime()??0:0;return(oe.mtime?Zn(oe.mtime)?.getTime()??0:0)-fe}default:return J.name.localeCompare(oe.name)}}).map(J=>{const oe=BMe(J.name,J.is_directory),fe=c.has(J.path);return a.jsxs(\"div\",{className:`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${fe?\"bg-bambu-green/20 border border-bambu-green/50\":\"hover:bg-bambu-dark-tertiary\"}`,onClick:()=>{J.is_directory&&k(J.path)},children:[J.is_directory?null:a.jsx(\"button\",{onClick:re=>H(J.path,re),className:\"flex-shrink-0 text-bambu-gray hover:text-white\",children:fe?a.jsx(Ns,{className:\"w-5 h-5 text-bambu-green\"}):a.jsx(uo,{className:\"w-5 h-5\"})}),a.jsx(oe,{className:`w-5 h-5 flex-shrink-0 ${J.is_directory?\"text-bambu-green\":\"text-bambu-gray\"}`}),a.jsx(\"span\",{className:\"flex-1 text-white truncate\",children:J.name}),!J.is_directory&&a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:Lo(J.size)}),(J.name.toLowerCase().endsWith(\".3mf\")||J.name.toLowerCase().endsWith(\".gcode\")||J.name.toLowerCase().endsWith(\".stl\"))&&a.jsx(\"button\",{onClick:re=>{re.stopPropagation(),C({path:J.path,name:J.name})},className:\"p-1 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green\",title:\"3D View\",children:a.jsx(vi,{className:\"w-4 h-4\"})})]}),J.is_directory&&a.jsx(xl,{className:\"w-4 h-4 text-bambu-gray rotate-180\"})]},J.path)})}):a.jsx(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:\"No files in this directory\"})}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-t border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-4\",children:[a.jsx(\"div\",{className:\"text-sm text-bambu-gray\",children:c.size>0?`${c.size} selected`:u?`${P?.files?.filter(J=>J.name.toLowerCase().includes(u.toLowerCase())).length||0} of ${P?.files?.length||0} items`:`${P?.files?.length||0} items`}),P?.files?.some(J=>!J.is_directory)&&a.jsx(\"div\",{className:\"flex items-center gap-2\",children:c.size>0?a.jsxs(\"button\",{onClick:Q,className:\"flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors\",children:[a.jsx(Cxe,{className:\"w-4 h-4\"}),\"Deselect All\"]}):a.jsxs(\"button\",{onClick:z,className:\"flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors\",children:[a.jsx(Ns,{className:\"w-4 h-4\"}),\"Select All\"]})})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{variant:\"secondary\",disabled:c.size===0||b!==null,onClick:L,children:b?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),b.current,\"/\",b.total]}):a.jsxs(a.Fragment,{children:[a.jsx(ga,{className:\"w-4 h-4\"}),\"Download\",c.size>1?` (${c.size})`:\"\"]})}),a.jsxs(De,{variant:\"secondary\",disabled:c.size===0||F.isPending,onClick:te,className:\"text-red-400 hover:text-red-300\",children:[F.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"}),r(\"printerFiles.deleteButton\"),c.size>1?` (${c.size})`:\"\"]})]})]})]}),p.length>0&&a.jsx(yn,{title:p.length>1?r(\"printerFiles.deleteFiles\",{count:p.length}):r(\"fileManager.deleteFile\"),message:p.length>1?r(\"printerFiles.deleteFilesConfirm\",{count:p.length}):r(\"printerFiles.deleteFileConfirm\",{name:p[0].split(\"/\").pop()}),confirmText:r(\"common.delete\"),variant:\"danger\",onConfirm:()=>{F.mutate(p)},onCancel:()=>f([])}),_&&a.jsx(UMe,{printerId:t,filePath:_.path,filename:_.name,onClose:()=>C(null)})]})}function xI({on:t,className:e=\"w-5 h-5\"}){const n=t?\"#facc15\":\"none\",r=t?\"#78350f\":\"currentColor\",i=t?1:0;return a.jsxs(\"svg\",{viewBox:\"0 0 32 32\",fill:\"none\",strokeWidth:\"2\",strokeLinecap:\"round\",strokeLinejoin:\"round\",className:e,children:[a.jsxs(\"g\",{stroke:r,opacity:i,children:[a.jsx(\"line\",{x1:\"16\",y1:\"2\",x2:\"16\",y2:\"6\"}),a.jsx(\"line\",{x1:\"6.1\",y1:\"6.1\",x2:\"8.9\",y2:\"8.9\"}),a.jsx(\"line\",{x1:\"25.9\",y1:\"6.1\",x2:\"23.1\",y2:\"8.9\"}),a.jsx(\"line\",{x1:\"2\",y1:\"16\",x2:\"6\",y2:\"16\"}),a.jsx(\"line\",{x1:\"30\",y1:\"16\",x2:\"26\",y2:\"16\"})]}),a.jsx(\"path\",{d:\"M12 24v-2.3c0-.9-.4-1.7-1-2.3C9.2 17.6 8 15.4 8 13c0-4.4 3.6-8 8-8s8 3.6 8 8c0 2.4-1.2 4.6-3 6.4-.6.6-1 1.4-1 2.3V24\",fill:n,stroke:r}),a.jsx(\"path\",{d:\"M12 24h8\",stroke:r}),a.jsx(\"path\",{d:\"M12 27h8\",stroke:r}),a.jsx(\"path\",{d:\"M13 30h6\",stroke:r})]})}const iT=({className:t})=>a.jsxs(\"svg\",{viewBox:\"0 0 24 24\",fill:\"none\",stroke:\"currentColor\",strokeWidth:\"2\",strokeLinecap:\"round\",strokeLinejoin:\"round\",className:t,children:[a.jsx(\"rect\",{x:\"2\",y:\"15\",width:\"5\",height:\"5\",rx:\"0.5\"}),a.jsx(\"rect\",{x:\"9.5\",y:\"15\",width:\"5\",height:\"5\",rx:\"0.5\",fill:\"currentColor\",opacity:\"0.3\"}),a.jsx(\"rect\",{x:\"17\",y:\"15\",width:\"5\",height:\"5\",rx:\"0.5\"}),a.jsx(\"path\",{d:\"M4 12 C4 6, 14 6, 14 12\"}),a.jsx(\"polyline\",{points:\"12,10 14,12 12,14\"})]});function yI({printerId:t,isOpen:e,onClose:n}){const{t:r}=Ft(),{showToast:i}=hn(),{hasPermission:s}=kr(),[o,l]=w.useState(null),[c,d]=w.useState(!1),{data:u}=Xe({queryKey:[\"printerStatus\",t],queryFn:()=>ue.getPrinterStatus(t),refetchInterval:3e4,enabled:e}),{data:m,refetch:p}=Xe({queryKey:[\"printableObjects\",t],queryFn:()=>ue.getPrintableObjects(t),enabled:e,refetchInterval:e?5e3:!1}),f=it({mutationFn:y=>ue.skipObjects(t,y),onSuccess:y=>{i(y.message||r(\"printers.skipObjects.objectsSkipped\")),l(null),p()},onError:y=>i(y.message||r(\"printers.toast.failedToSkipObjects\"),\"error\")});return e?a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center\",onClick:n,onKeyDown:y=>{y.key===\"Escape\"&&(c?d(!1):n())},tabIndex:-1,ref:y=>y?.focus(),children:[a.jsx(\"div\",{className:\"absolute inset-0 bg-black/50 z-0\"}),a.jsxs(\"div\",{className:\"relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden\",onClick:y=>y.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(iT,{className:\"w-4 h-4 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-sm font-medium text-gray-900 dark:text-white\",children:r(\"printers.skipObjects.title\")})]}),a.jsx(\"button\",{onClick:n,className:\"p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),m?m.objects.length===0?a.jsxs(\"div\",{className:\"text-center py-8 px-4 text-bambu-gray\",children:[a.jsx(\"p\",{className:\"text-sm\",children:r(\"printers.noObjectsFound\")}),a.jsx(\"p\",{className:\"text-xs mt-1 opacity-70\",children:r(\"printers.objectsLoadedOnPrintStart\")})]}):a.jsxs(\"div\",{className:\"flex flex-col overflow-hidden\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center\",children:a.jsx(FO,{className:\"w-4 h-4 text-blue-500 dark:text-blue-400\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-xs text-blue-600 dark:text-blue-300\",children:r(\"printers.skipObjects.matchIdsInfo\")}),a.jsx(\"p\",{className:\"text-[10px] text-blue-500/70 dark:text-blue-300/60\",children:r(\"printers.skipObjects.printerShowsIds\")})]}),a.jsxs(\"div\",{className:\"flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray\",children:[m.skipped_count,\"/\",m.total,\" \",r(\"printers.skipObjects.skipped\")]})]}),(u?.layer_num??0)<=1&&a.jsxs(\"div\",{className:\"flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary\",children:[a.jsx(ei,{className:\"w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0\"}),a.jsx(\"p\",{className:\"text-xs text-amber-600 dark:text-amber-400\",children:r(\"printers.skipObjects.waitForLayer\",{layer:u?.layer_num??0})})]}),a.jsxs(\"div\",{className:\"flex flex-1 overflow-hidden\",children:[a.jsx(\"div\",{className:\"w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto\",children:a.jsxs(\"div\",{className:\"relative cursor-pointer group\",onClick:()=>d(!0),children:[u?.cover_url?a.jsx(\"img\",{src:Ga(`${u.cover_url}?view=top`),alt:r(\"printers.printPreview\"),className:\"w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600\"}):a.jsx(\"div\",{className:\"w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center\",children:a.jsx(vi,{className:\"w-8 h-8 text-gray-300 dark:text-bambu-gray/30\"})}),a.jsx(\"div\",{className:\"absolute top-2 right-2 p-1 bg-black/60 rounded opacity-0 group-hover:opacity-100 transition-opacity\",children:a.jsx(VP,{className:\"w-3.5 h-3.5 text-white\"})}),m.objects.length>0&&a.jsx(\"div\",{className:\"absolute inset-0 pointer-events-none\",children:m.objects.map((y,v)=>{let b,g;if(y.x!=null&&y.y!=null&&m.bbox_all){const[_,C,P,N]=m.bbox_all,A=P-_,T=N-C,F=8,k=100-F*2;b=F+(y.x-_)/A*k,g=F+(N-y.y)/T*k,b=Math.max(5,Math.min(95,b)),g=Math.max(5,Math.min(95,g))}else if(y.x!=null&&y.y!=null)b=y.x/256*100,g=100-y.y/256*100,b=Math.max(5,Math.min(95,b)),g=Math.max(5,Math.min(95,g));else{const _=Math.ceil(Math.sqrt(m.objects.length)),C=Math.floor(v/_),P=v%_,N=Math.ceil(m.objects.length/_);b=15+P*(70/_)+35/_,g=15+C*(70/N)+35/N}return a.jsx(\"div\",{className:`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${y.skipped?\"bg-red-500 text-white line-through\":\"bg-bambu-green text-black\"}`,style:{left:`${b}%`,top:`${g}%`,transform:\"translate(-50%, -50%)\"},title:y.name,children:y.id},y.id)})}),a.jsx(\"div\",{className:\"absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm\",children:r(\"printers.skipObjects.activeCount\",{count:m.objects.filter(y=>!y.skipped).length})})]})}),a.jsx(\"div\",{className:\"flex-1 min-w-0 overflow-y-auto\",children:m.objects.map(y=>a.jsxs(\"div\",{className:`\n                      flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0\n                      ${y.skipped?\"bg-red-50 dark:bg-red-500/10\":\"hover:bg-gray-50 dark:hover:bg-bambu-dark/50\"}\n                    `,children:[a.jsxs(\"div\",{className:`\n                      w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center\n                      ${y.skipped?\"bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40\":\"bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40\"}\n                    `,children:[a.jsx(\"span\",{className:`text-lg font-mono font-bold ${y.skipped?\"text-red-500 dark:text-red-400\":\"text-green-600 dark:text-bambu-green\"}`,children:y.id}),a.jsx(\"span\",{className:`text-[8px] uppercase tracking-wider ${y.skipped?\"text-red-400/60\":\"text-green-500/60 dark:text-bambu-green/60\"}`,children:\"ID\"})]}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"span\",{className:`block text-sm truncate ${y.skipped?\"text-red-500 dark:text-red-400 line-through\":\"text-gray-900 dark:text-white\"}`,children:y.name}),y.skipped&&a.jsx(\"span\",{className:\"text-[10px] text-red-400/60\",children:r(\"printers.willBeSkipped\")})]}),y.skipped?a.jsx(\"span\",{className:\"px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg\",children:r(\"printers.skipObjects.skipped\")}):a.jsx(\"button\",{onClick:()=>l({id:y.id,name:y.name}),disabled:f.isPending||(u?.layer_num??0)<=1||!s(\"printers:control\"),className:`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${(u?.layer_num??0)<=1||!s(\"printers:control\")?\"bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed\":\"bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30\"}`,title:s(\"printers:control\")?(u?.layer_num??0)<=1?r(\"printers.skipObjects.waitForLayer\",{layer:u?.layer_num??0}):r(\"printers.skipObjects.skip\"):r(\"printers.permission.noControl\"),children:r(\"printers.skipObjects.skip\")})]},y.id))})]})]}):a.jsx(\"div\",{className:\"flex items-center justify-center py-12\",children:a.jsx(ft,{className:\"w-5 h-5 animate-spin text-bambu-gray\"})})]})]}),o&&a.jsx(yn,{variant:\"warning\",title:r(\"printers.skipObjects.confirmTitle\"),message:r(\"printers.skipObjects.confirmMessage\",{name:o.name}),confirmText:r(\"printers.skipObjects.skip\"),isLoading:f.isPending,onConfirm:()=>f.mutate([o.id]),onCancel:()=>l(null)}),c&&m&&a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/90 flex items-center justify-center z-60\",onClick:()=>d(!1),children:[a.jsx(\"button\",{onClick:()=>d(!1),className:\"absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-6 h-6\"})}),a.jsxs(\"div\",{className:\"relative max-w-[600px] max-h-[80vh] aspect-square\",onClick:y=>y.stopPropagation(),children:[u?.cover_url?a.jsx(\"img\",{src:Ga(`${u.cover_url}?view=top`),alt:r(\"printers.printPreview\"),className:\"w-full h-full object-contain rounded-lg bg-gray-900\"}):a.jsx(\"div\",{className:\"w-full h-full rounded-lg bg-gray-800 flex items-center justify-center\",children:a.jsx(vi,{className:\"w-16 h-16 text-gray-500\"})}),m.objects.length>0&&a.jsx(\"div\",{className:\"absolute inset-0 pointer-events-none\",children:m.objects.map((y,v)=>{let b,g;if(y.x!=null&&y.y!=null&&m.bbox_all){const[_,C,P,N]=m.bbox_all,A=P-_,T=N-C,F=8,k=100-F*2;b=F+(y.x-_)/A*k,g=F+(N-y.y)/T*k,b=Math.max(5,Math.min(95,b)),g=Math.max(5,Math.min(95,g))}else if(y.x!=null&&y.y!=null)b=y.x/256*100,g=100-y.y/256*100,b=Math.max(5,Math.min(95,b)),g=Math.max(5,Math.min(95,g));else{const _=Math.ceil(Math.sqrt(m.objects.length)),C=Math.floor(v/_),P=v%_,N=Math.ceil(m.objects.length/_);b=15+P*(70/_)+35/_,g=15+C*(70/N)+35/N}return a.jsx(\"div\",{className:`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${y.skipped?\"bg-red-500 text-white line-through\":\"bg-bambu-green text-black\"}`,style:{left:`${b}%`,top:`${g}%`,transform:\"translate(-50%, -50%)\"},title:y.name,children:y.id},y.id)})}),a.jsx(\"div\",{className:\"absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm\",children:r(\"printers.skipObjects.activeCount\",{count:m.objects.filter(y=>!y.skipped).length})})]})]})]}):null}const $Me=\"embeddedCameraState_\",q$=5,VMe=2e3,GMe=3e4,WMe=5e3,QE={x:window.innerWidth-420,y:20,width:400,height:300};function KMe({printerId:t,printerName:e,viewerIndex:n=0,onClose:r}){const{t:i}=Ft(),s=nn(),{showToast:o}=hn(),{hasPermission:l}=kr(),c=`${$Me}${t}`,d=()=>{try{const Be=localStorage.getItem(c);if(Be){const Qe=JSON.parse(Be);return{x:Math.min(Math.max(0,Qe.x),window.innerWidth-100),y:Math.min(Math.max(0,Qe.y),window.innerHeight-100),width:Math.max(200,Math.min(Qe.width,window.innerWidth-20)),height:Math.max(150,Math.min(Qe.height,window.innerHeight-20))}}}catch{}const xe=n*30;return{...QE,x:Math.max(0,QE.x-xe),y:Math.max(0,QE.y+xe)}},[u,m]=w.useState(d),[p,f]=w.useState(!1),[y,v]=w.useState(!1),[b,g]=w.useState({x:0,y:0}),[_,C]=w.useState(!1),[P,N]=w.useState(!1),[A,T]=w.useState(1),[F,k]=w.useState({x:0,y:0}),[D,H]=w.useState(!1),[z,Q]=w.useState({x:0,y:0}),[L,te]=w.useState(null),[ie,J]=w.useState(null),[oe,fe]=w.useState(!1),[re,W]=w.useState(!0),[ne,me]=w.useState(Date.now()),[be,Ce]=w.useState(0),[q,Y]=w.useState(!1),[E,j]=w.useState(0),O=w.useRef(null),K=w.useRef(null),U=w.useRef(null),de=w.useRef(null),I=w.useRef(null),[G,X]=w.useState(!1),{data:V}=Xe({queryKey:[\"printer\",t],queryFn:()=>ue.getPrinter(t),enabled:t>0}),{data:ee}=Xe({queryKey:[\"printerStatus\",t],queryFn:()=>ue.getPrinterStatus(t),refetchInterval:3e4,enabled:t>0}),se=it({mutationFn:xe=>ue.setChamberLight(t,xe),onMutate:async xe=>{await s.cancelQueries({queryKey:[\"printerStatus\",t]});const Be=s.getQueryData([\"printerStatus\",t]);return s.setQueryData([\"printerStatus\",t],Qe=>({...Qe,chamber_light:xe})),{previousStatus:Be}},onSuccess:(xe,Be)=>{o(`Chamber light ${Be?\"on\":\"off\"}`)},onError:(xe,Be,Qe)=>{Qe?.previousStatus&&s.setQueryData([\"printerStatus\",t],Qe.previousStatus),o(xe.message||i(\"printers.toast.failedToControlChamberLight\"),\"error\")}}),ge=(ee?.state===\"RUNNING\"||ee?.state===\"PAUSE\")&&(ee?.printable_objects_count??0)>=2;w.useEffect(()=>{const xe=setTimeout(()=>{localStorage.setItem(c,JSON.stringify(u))},500);return()=>clearTimeout(xe)},[u,c]);const he=w.useRef(!1);w.useEffect(()=>{he.current=!1;const xe=`/api/v1/printers/${t}/camera/stop`,Be=()=>{if(t>0&&!he.current){he.current=!0;const ht={},xt=vm();xt&&(ht.Authorization=`Bearer ${xt}`),fetch(xe,{method:\"POST\",keepalive:!0,headers:ht}).catch(()=>{})}},Qe=K.current;return()=>{Qe&&(Qe.src=\"\"),Be(),U.current&&clearTimeout(U.current),de.current&&clearInterval(de.current),I.current&&clearInterval(I.current)}},[t]),w.useEffect(()=>{if(re){const xe=setTimeout(()=>W(!1),3e3);return()=>clearTimeout(xe)}},[re,ne]);const le=w.useCallback(()=>{if(be>=q$){Y(!1),fe(!0);return}const xe=Math.min(VMe*Math.pow(2,be),GMe);Y(!0),j(Math.ceil(xe/1e3)),de.current=setInterval(()=>{j(Be=>Be<=1?(de.current&&clearInterval(de.current),0):Be-1)},1e3),U.current=setTimeout(()=>{Ce(Be=>Be+1),Y(!1),W(!0),fe(!1),K.current&&(K.current.src=\"\"),me(Date.now())},xe)},[be]);w.useEffect(()=>{if(re||q||_){I.current&&(clearInterval(I.current),I.current=null);return}return I.current=setInterval(async()=>{try{const xe=await ue.getCameraStatus(t);(xe.stalled||!xe.active&&!oe)&&(I.current&&(clearInterval(I.current),I.current=null),W(!1),le())}catch{}},WMe),()=>{I.current&&(clearInterval(I.current),I.current=null)}},[re,oe,q,_,t,le]),w.useEffect(()=>{const xe=()=>{const Be=!!document.fullscreenElement;N(Be),Be||(T(1),k({x:0,y:0}))};return document.addEventListener(\"fullscreenchange\",xe),()=>document.removeEventListener(\"fullscreenchange\",xe)},[]);const B=()=>{O.current&&(document.fullscreenElement?document.exitFullscreen():O.current.requestFullscreen())},R=()=>{T(xe=>Math.min(xe+.5,4))},ae=()=>{T(xe=>{const Be=Math.max(xe-.5,1);return Be===1&&k({x:0,y:0}),Be})},_e=xe=>{xe.preventDefault(),xe.deltaY<0?R():ae()},Se=xe=>{A>1&&(xe.preventDefault(),H(!0),Q({x:xe.clientX-F.x,y:xe.clientY-F.y}))},ve=w.useCallback(()=>{if(!O.current||!K.current)return{x:200,y:150};const xe=O.current.getBoundingClientRect(),Be=xe.width*(A-1)/2,Qe=xe.height*(A-1)/2;return{x:Math.max(50,Be),y:Math.max(50,Qe)}},[A]),Te=xe=>{if(D&&A>1){const Be=xe.clientX-z.x,Qe=xe.clientY-z.y,ht=ve();k({x:Math.max(-ht.x,Math.min(ht.x,Be)),y:Math.max(-ht.y,Math.min(ht.y,Qe))})}},ye=()=>{H(!1)},je=xe=>{if(xe.length<2)return 0;const Be=xe[0].clientX-xe[1].clientX,Qe=xe[0].clientY-xe[1].clientY;return Math.sqrt(Be*Be+Qe*Qe)},Le=xe=>xe.length<2?{x:xe[0].clientX,y:xe[0].clientY}:{x:(xe[0].clientX+xe[1].clientX)/2,y:(xe[0].clientY+xe[1].clientY)/2},Me=xe=>{xe.touches.length===2?(xe.preventDefault(),te(je(xe.touches)),J(Le(xe.touches))):xe.touches.length===1&&A>1&&(xe.preventDefault(),H(!0),Q({x:xe.touches[0].clientX-F.x,y:xe.touches[0].clientY-F.y}))},Oe=xe=>{if(xe.touches.length===2&&L!==null){xe.preventDefault();const Be=je(xe.touches),Qe=Be/L;T(xt=>{const gt=Math.max(1,Math.min(4,xt*Qe));return gt===1&&k({x:0,y:0}),gt}),te(Be);const ht=Le(xe.touches);if(ie){const xt=ve();k(gt=>({x:Math.max(-xt.x,Math.min(xt.x,gt.x+(ht.x-ie.x))),y:Math.max(-xt.y,Math.min(xt.y,gt.y+(ht.y-ie.y)))}))}J(ht)}else if(xe.touches.length===1&&D&&A>1){xe.preventDefault();const Be=xe.touches[0].clientX-z.x,Qe=xe.touches[0].clientY-z.y,ht=ve();k({x:Math.max(-ht.x,Math.min(ht.x,Be)),y:Math.max(-ht.y,Math.min(ht.y,Qe))})}},Re=xe=>{xe.touches.length<2&&(te(null),J(null)),xe.touches.length===0&&H(!1)},$e=()=>{T(1),k({x:0,y:0})},Ye=()=>{W(!1),be<q$?le():fe(!0)},tt=()=>{W(!1),fe(!1),Ce(0),Y(!1),U.current&&clearTimeout(U.current),de.current&&clearInterval(de.current)},pe=()=>{W(!0),fe(!1),Ce(0),Y(!1),U.current&&clearTimeout(U.current),de.current&&clearInterval(de.current);const xe={},Be=vm();Be&&(xe.Authorization=`Bearer ${Be}`),fetch(`/api/v1/printers/${t}/camera/stop`,{method:\"POST\",headers:xe}).catch(()=>{}),K.current&&(K.current.src=\"\"),setTimeout(()=>me(Date.now()),100)},Fe=xe=>{xe.target.closest(\".no-drag\")||(f(!0),g({x:xe.clientX-u.x,y:xe.clientY-u.y}))},we=xe=>{if(xe.target.closest(\".no-drag\"))return;const Be=xe.touches[0];f(!0),g({x:Be.clientX-u.x,y:Be.clientY-u.y})},Ve=xe=>{xe.stopPropagation(),v(!0)},Ae=xe=>{xe.stopPropagation(),v(!0)};w.useEffect(()=>{const xe=ht=>{if(p)m(xt=>({...xt,x:Math.max(0,Math.min(ht.clientX-b.x,window.innerWidth-xt.width)),y:Math.max(0,Math.min(ht.clientY-b.y,window.innerHeight-xt.height))}));else if(y&&O.current){const xt=O.current.getBoundingClientRect();m(gt=>({...gt,width:Math.max(200,Math.min(ht.clientX-xt.left,window.innerWidth-gt.x-10)),height:Math.max(150,Math.min(ht.clientY-xt.top,window.innerHeight-gt.y-10))}))}},Be=ht=>{if(!p&&!y)return;ht.preventDefault();const xt=ht.touches[0];if(p)m(gt=>({...gt,x:Math.max(0,Math.min(xt.clientX-b.x,window.innerWidth-gt.width)),y:Math.max(0,Math.min(xt.clientY-b.y,window.innerHeight-gt.height))}));else if(y&&O.current){const gt=O.current.getBoundingClientRect();m(Ut=>({...Ut,width:Math.max(200,Math.min(xt.clientX-gt.left,window.innerWidth-Ut.x-10)),height:Math.max(150,Math.min(xt.clientY-gt.top,window.innerHeight-Ut.y-10))}))}},Qe=()=>{f(!1),v(!1)};if(p||y)return document.addEventListener(\"mousemove\",xe),document.addEventListener(\"mouseup\",Qe),document.addEventListener(\"touchmove\",Be,{passive:!1}),document.addEventListener(\"touchend\",Qe),document.addEventListener(\"touchcancel\",Qe),()=>{document.removeEventListener(\"mousemove\",xe),document.removeEventListener(\"mouseup\",Qe),document.removeEventListener(\"touchmove\",Be),document.removeEventListener(\"touchend\",Qe),document.removeEventListener(\"touchcancel\",Qe)}},[p,y,b]);const ce=Ga(`/api/v1/printers/${t}/camera/stream?fps=15&t=${ne}`);return a.jsxs(\"div\",{ref:O,className:`${P?\"fixed inset-0 z-[100]\":\"fixed z-40 rounded-lg shadow-2xl border border-bambu-dark-tertiary\"} bg-bambu-dark-secondary overflow-hidden`,style:P?void 0:{left:u.x,top:u.y,width:_?200:u.width,height:_?40:u.height,cursor:p?\"grabbing\":\"default\"},children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-3 py-2 bg-bambu-dark border-b border-bambu-dark-tertiary cursor-grab active:cursor-grabbing\",onMouseDown:Fe,onTouchStart:we,children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-white truncate\",children:[a.jsx(np,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"}),a.jsx(\"span\",{className:\"truncate\",children:V?.name||e})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 no-drag\",children:[a.jsx(\"button\",{onClick:()=>se.mutate(!ee?.chamber_light),disabled:!ee?.connected||se.isPending||!l(\"printers:control\"),className:`p-1 rounded disabled:opacity-50 ${ee?.chamber_light?\"bg-yellow-500/20 hover:bg-yellow-500/30\":\"hover:bg-bambu-dark-tertiary\"}`,title:l(\"printers:control\")?i(\"camera.chamberLight\"):i(\"printers.permission.noControl\"),children:a.jsx(xI,{on:ee?.chamber_light??!1,className:\"w-3.5 h-3.5\"})}),a.jsx(\"button\",{onClick:()=>X(!0),disabled:!ge||!l(\"printers:control\"),className:`p-1 rounded disabled:opacity-50 ${ge&&l(\"printers:control\")?\"hover:bg-bambu-dark-tertiary\":\"\"}`,title:l(\"printers:control\")?i(ge?\"printers.skipObjects.tooltip\":\"printers.skipObjects.onlyWhilePrinting\"):i(\"printers.permission.noControl\"),children:a.jsx(iT,{className:\"w-3.5 h-3.5 text-bambu-gray\"})}),a.jsx(\"button\",{onClick:pe,disabled:re||q,className:\"p-1 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50\",title:\"Refresh stream\",children:a.jsx(lr,{className:`w-3.5 h-3.5 text-bambu-gray ${re?\"animate-spin\":\"\"}`})}),a.jsx(\"button\",{onClick:B,className:\"p-1 hover:bg-bambu-dark-tertiary rounded\",title:P?\"Exit fullscreen\":\"Fullscreen\",children:P?a.jsx(zQ,{className:\"w-3.5 h-3.5 text-bambu-gray\"}):a.jsx(Sge,{className:\"w-3.5 h-3.5 text-bambu-gray\"})}),a.jsx(\"button\",{onClick:()=>C(!_),className:\"p-1 hover:bg-bambu-dark-tertiary rounded\",title:_?\"Expand\":\"Minimize\",children:_?a.jsx(VP,{className:\"w-3.5 h-3.5 text-bambu-gray\"}):a.jsx(DO,{className:\"w-3.5 h-3.5 text-bambu-gray\"})}),a.jsx(\"button\",{onClick:r,className:\"p-1 hover:bg-red-500/20 rounded\",title:\"Close\",children:a.jsx(Ht,{className:\"w-3.5 h-3.5 text-bambu-gray hover:text-red-400\"})})]})]}),!_&&a.jsxs(\"div\",{className:\"relative w-full bg-black flex items-center justify-center overflow-hidden h-[calc(100%-40px)]\",onWheel:_e,onMouseMove:Te,onMouseUp:ye,onMouseLeave:ye,onTouchStart:Me,onTouchMove:Oe,onTouchEnd:Re,style:{touchAction:\"none\"},children:[re&&!q&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-black/50 z-10\",children:a.jsx(lr,{className:\"w-6 h-6 text-bambu-gray animate-spin\"})}),q&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-black/80 z-10\",children:a.jsxs(\"div\",{className:\"text-center p-2\",children:[a.jsx(Bd,{className:\"w-6 h-6 text-orange-400 mx-auto mb-2\"}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[\"Reconnecting in \",E,\"s...\"]})]})}),oe&&!q&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-black z-10\",children:a.jsxs(\"div\",{className:\"text-center p-2\",children:[a.jsx(Dn,{className:\"w-6 h-6 text-orange-400 mx-auto mb-2\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:\"Camera unavailable\"}),a.jsx(\"button\",{onClick:pe,className:\"px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80\",children:\"Retry\"})]})}),a.jsx(\"img\",{ref:K,src:ce,alt:\"Camera stream\",className:\"max-w-full max-h-full object-contain select-none\",style:{transform:`scale(${A}) translate(${F.x/A}px, ${F.y/A}px) rotate(${V?.camera_rotation||0}deg)`,...V?.camera_rotation===90||V?.camera_rotation===270?{maxWidth:\"100%\",maxHeight:\"100%\"}:{},cursor:A>1?D?\"grabbing\":\"grab\":\"default\"},onError:Ye,onLoad:tt,onMouseDown:Se,draggable:!1},ne),a.jsxs(\"div\",{className:\"absolute bottom-2 left-2 flex items-center gap-1 bg-black/60 rounded px-1.5 py-1 no-drag\",children:[a.jsx(\"button\",{onClick:ae,disabled:A<=1,className:\"p-1 hover:bg-white/10 rounded disabled:opacity-30\",title:\"Zoom out\",children:a.jsx(zO,{className:\"w-3.5 h-3.5 text-white\"})}),a.jsxs(\"button\",{onClick:$e,className:\"px-1.5 py-0.5 text-xs text-white hover:bg-white/10 rounded min-w-[32px]\",title:\"Reset zoom\",children:[Math.round(A*100),\"%\"]}),a.jsx(\"button\",{onClick:R,disabled:A>=4,className:\"p-1 hover:bg-white/10 rounded disabled:opacity-30\",title:\"Zoom in\",children:a.jsx(IO,{className:\"w-3.5 h-3.5 text-white\"})})]}),!P&&a.jsx(\"div\",{className:\"absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors\",onMouseDown:Ve,onTouchStart:Ae,title:\"Drag to resize\",children:a.jsx(\"svg\",{className:\"w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray\",viewBox:\"0 0 24 24\",fill:\"currentColor\",children:a.jsx(\"path\",{d:\"M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z\"})})})]}),a.jsx(yI,{printerId:t,isOpen:G,onClose:()=>X(!1)})]})}function XMe({printerId:t,printerName:e,onClose:n}){const{t:r}=Ft(),i=nn(),[s,o]=w.useState(!0),[l,c]=w.useState(new Set),[d,u]=w.useState(\"\"),[m,p]=w.useState(\"all\"),f=w.useRef(null),{data:y,isLoading:v,refetch:b}=Xe({queryKey:[\"mqtt-logs\",t],queryFn:()=>ue.getMQTTLogs(t),refetchInterval:1e3}),g=it({mutationFn:()=>ue.enableMQTTLogging(t),onSuccess:()=>{i.invalidateQueries({queryKey:[\"mqtt-logs\",t]})}}),_=it({mutationFn:()=>ue.disableMQTTLogging(t),onSuccess:()=>{i.invalidateQueries({queryKey:[\"mqtt-logs\",t]})}}),C=it({mutationFn:()=>ue.clearMQTTLogs(t),onSuccess:()=>{i.invalidateQueries({queryKey:[\"mqtt-logs\",t]})}});w.useEffect(()=>{const D=H=>{H.key===\"Escape\"&&n()};return window.addEventListener(\"keydown\",D),()=>window.removeEventListener(\"keydown\",D)},[n]),w.useEffect(()=>{s&&f.current&&(f.current.scrollTop=f.current.scrollHeight)},[y?.logs,s]);const P=D=>{c(H=>{const z=new Set(H);return z.has(D)?z.delete(D):z.add(D),z})},N=D=>new Date(D).toLocaleTimeString(\"en-US\",{hour12:!1,fractionalSecondDigits:3}),A=(D,H)=>{if(D==null)return\"<empty>\";const z=typeof D==\"string\"?JSON.parse(D):D,Q=JSON.stringify(z,null,H?2:0);return!H&&Q.length>100?Q.substring(0,100)+\"...\":Q},T=y?.logging_enabled??!1,F=w.useMemo(()=>y?.logs??[],[y?.logs]),k=w.useMemo(()=>F.filter(D=>{if(m!==\"all\"&&D.direction!==m)return!1;if(d.trim()){const H=d.toLowerCase(),z=D.topic.toLowerCase().includes(H),L=JSON.stringify(D.payload).toLowerCase().includes(H);return z||L}return!0}),[F,d,m]);return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[85vh] flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:r(\"mqttDebug.title\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:e})]}),a.jsx(\"button\",{onClick:n,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[T?a.jsxs(De,{size:\"sm\",variant:\"secondary\",onClick:()=>_.mutate(),disabled:_.isPending,children:[a.jsx(uo,{className:\"w-4 h-4\"}),r(\"mqttDebug.stopLogging\")]}):a.jsxs(De,{size:\"sm\",onClick:()=>g.mutate(),disabled:g.isPending,children:[a.jsx(es,{className:\"w-4 h-4\"}),r(\"mqttDebug.startLogging\")]}),a.jsxs(De,{size:\"sm\",variant:\"secondary\",onClick:()=>C.mutate(),disabled:C.isPending||F.length===0,children:[a.jsx(en,{className:\"w-4 h-4\"}),r(\"mqttDebug.clearLog\")]}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:()=>b(),disabled:v,children:a.jsx(lr,{className:`w-4 h-4 ${v?\"animate-spin\":\"\"}`})}),a.jsx(\"div\",{className:\"flex-1\"}),a.jsxs(\"label\",{className:\"flex items-center gap-2 text-sm text-bambu-gray cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:s,onChange:D=>o(D.target.checked),className:\"rounded border-bambu-dark-tertiary\"}),\"Auto-scroll\"]}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[k.length,\"/\",F.length]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(Pr,{className:\"absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",placeholder:r(\"mqttDebug.searchPlaceholder\"),value:d,onChange:D=>u(D.target.value),className:\"w-full pl-8 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"}),d&&a.jsx(\"button\",{onClick:()=>u(\"\"),className:\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 bg-bambu-dark rounded border border-bambu-dark-tertiary\",children:[a.jsx(\"button\",{onClick:()=>p(\"all\"),className:`px-2 py-1.5 text-xs rounded-l transition-colors ${m===\"all\"?\"bg-bambu-green text-white\":\"text-bambu-gray hover:text-white\"}`,children:r(\"mqttDebug.all\")}),a.jsxs(\"button\",{onClick:()=>p(\"in\"),className:`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${m===\"in\"?\"bg-blue-500 text-white\":\"text-bambu-gray hover:text-white\"}`,children:[a.jsx(cg,{className:\"w-3 h-3\"}),r(\"mqttDebug.incoming\")]}),a.jsxs(\"button\",{onClick:()=>p(\"out\"),className:`px-2 py-1.5 text-xs rounded-r transition-colors flex items-center gap-1 ${m===\"out\"?\"bg-green-500 text-white\":\"text-bambu-gray hover:text-white\"}`,children:[a.jsx(fp,{className:\"w-3 h-3\"}),r(\"mqttDebug.outgoing\")]})]})]})]}),a.jsx(\"div\",{ref:f,className:\"flex-1 overflow-auto p-4 font-mono text-xs bg-black min-h-[400px]\",children:F.length===0?a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center h-full text-bambu-gray\",children:[a.jsx(\"p\",{className:\"mb-2\",children:r(\"mqttDebug.noMessages\")}),!T&&a.jsx(\"p\",{className:\"text-sm\",children:r(\"mqttDebug.startLoggingHint\")})]}):k.length===0?a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center h-full text-bambu-gray\",children:[a.jsx(\"p\",{className:\"mb-2\",children:r(\"mqttDebug.noMessagesMatch\")}),a.jsx(\"p\",{className:\"text-sm\",children:r(\"mqttDebug.adjustFilterHint\")})]}):a.jsx(\"div\",{className:\"space-y-1\",children:k.map((D,H)=>{const z=l.has(H),Q=D.direction===\"in\";return a.jsxs(\"div\",{className:`p-2 rounded cursor-pointer hover:bg-bambu-dark-secondary transition-colors ${z?\"bg-bambu-dark-secondary\":\"\"}`,onClick:()=>P(H),children:[a.jsxs(\"div\",{className:\"flex items-start gap-2\",children:[a.jsx(\"span\",{className:\"text-bambu-gray shrink-0\",children:N(D.timestamp)}),a.jsx(\"span\",{className:`shrink-0 ${Q?\"text-blue-400\":\"text-green-400\"}`,title:r(Q?\"mqttDebug.incoming\":\"mqttDebug.outgoing\"),children:Q?a.jsx(cg,{className:\"w-3 h-3\"}):a.jsx(fp,{className:\"w-3 h-3\"})}),a.jsx(\"span\",{className:\"text-purple-400 shrink-0\",children:D.topic})]}),z?a.jsx(\"pre\",{className:\"mt-2 p-3 bg-gray-900 border border-gray-700 rounded text-green-400 overflow-x-auto whitespace-pre-wrap break-all max-h-96 overflow-y-auto text-xs\",children:A(D.payload,!0)}):a.jsx(\"pre\",{className:\"mt-1 text-white/80 truncate\",children:A(D.payload,!1)})]},H)})})}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"text-sm text-bambu-gray\",children:T?a.jsxs(\"span\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"w-2 h-2 bg-green-500 rounded-full animate-pulse\"}),r(\"mqttDebug.loggingActive\")]}):a.jsx(\"span\",{children:r(\"mqttDebug.loggingStopped\")})}),a.jsx(De,{variant:\"secondary\",onClick:n,children:r(\"common.close\")})]})]})})}function MJ(t){if(!t)return\"/img/printers/default.png\";const e=t.toLowerCase().replace(/\\s+/g,\"\");return e.includes(\"x1e\")?\"/img/printers/x1e.png\":e.includes(\"x1c\")||e.includes(\"x1carbon\")||e.includes(\"x1\")?\"/img/printers/x1c.png\":e.includes(\"x2d\")||e===\"n6\"?\"/img/printers/x2d.png\":e.includes(\"h2dpro\")||e.includes(\"h2d-pro\")?\"/img/printers/h2dpro.png\":e.includes(\"h2d\")?\"/img/printers/h2d.png\":e.includes(\"h2c\")?\"/img/printers/h2c.png\":e.includes(\"h2s\")?\"/img/printers/h2d.png\":e.includes(\"p2s\")||e.includes(\"p1s\")?\"/img/printers/p1s.png\":e.includes(\"p1p\")?\"/img/printers/p1p.png\":e.includes(\"a1mini\")?\"/img/printers/a1mini.png\":e.includes(\"a1\")?\"/img/printers/a1.png\":\"/img/printers/default.png\"}function EJ(t){return t>=-50?{labelKey:\"printers.wifiSignal.excellent\",color:\"text-bambu-green\",bars:4}:t>=-60?{labelKey:\"printers.wifiSignal.good\",color:\"text-bambu-green\",bars:3}:t>=-70?{labelKey:\"printers.wifiSignal.fair\",color:\"text-yellow-400\",bars:2}:t>=-80?{labelKey:\"printers.wifiSignal.weak\",color:\"text-orange-400\",bars:1}:{labelKey:\"printers.wifiSignal.veryWeak\",color:\"text-red-400\",bars:1}}function DJ(t,e,n){return t.filter(r=>{if(r.required_filament_types&&r.required_filament_types.length>0&&e!==void 0&&!r.required_filament_types.every(i=>e.has(i.toUpperCase())))return!1;if(r.filament_overrides&&r.filament_overrides.length>0&&n!==void 0){const i=r.filament_overrides.filter(o=>o.force_color_match===!0),s=r.filament_overrides.filter(o=>o.force_color_match!==!0);if(i.length>0&&!i.every(l=>{const c=(l.type||\"\").toUpperCase(),d=(l.color||\"\").replace(\"#\",\"\").toLowerCase().slice(0,6);return n.has(`${c}:${d}`)})||s.length>0&&i.length===0&&!s.some(l=>{const c=(l.type||\"\").toUpperCase(),d=(l.color||\"\").replace(\"#\",\"\").toLowerCase().slice(0,6);return n.has(`${c}:${d}`)}))return!1}return!0})}function YMe({printerId:t,printerModel:e,loadedFilamentTypes:n,loadedFilaments:r}){const{t:i}=Ft(),{data:s}=Xe({queryKey:[\"queue\",t,\"pending\",e],queryFn:()=>ue.getQueue(t,\"pending\",e||void 0),refetchInterval:3e4}),o=s?DJ(s,n,r):void 0,l=o?.length||0;if(l===0)return null;const c=o?.[0];return a.jsx(Do,{to:\"/queue\",className:\"block mb-3 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between gap-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 min-w-0 flex-1\",children:[a.jsx(oa,{className:\"w-5 h-5 text-yellow-400 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"queue.nextInQueue\")}),a.jsx(\"p\",{className:\"text-sm text-white truncate\",children:c?.archive_name||c?.library_file_name||`File #${c?.archive_id||c?.library_file_id}`})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0\",children:[a.jsxs(\"span\",{className:\"text-xs text-bambu-gray flex items-center gap-1\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),c?.scheduled_time?ap(c.scheduled_time,\"system\",i):i(\"time.waiting\")]}),l>1&&a.jsxs(\"span\",{className:\"text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded\",children:[\"+\",l-1]}),a.jsx(ti,{className:\"w-4 h-4 text-bambu-gray\"})]})]})})}function FJ(t){var e,n,r=\"\";if(typeof t==\"string\"||typeof t==\"number\")r+=t;else if(typeof t==\"object\")if(Array.isArray(t)){var i=t.length;for(e=0;e<i;e++)t[e]&&(n=FJ(t[e]))&&(r&&(r+=\" \"),r+=n)}else for(n in t)t[n]&&(r&&(r+=\" \"),r+=n);return r}function _r(){for(var t,e,n=0,r=\"\",i=arguments.length;n<i;n++)(t=arguments[n])&&(e=FJ(t))&&(r&&(r+=\" \"),r+=e);return r}var QMe=[\"dangerouslySetInnerHTML\",\"onCopy\",\"onCopyCapture\",\"onCut\",\"onCutCapture\",\"onPaste\",\"onPasteCapture\",\"onCompositionEnd\",\"onCompositionEndCapture\",\"onCompositionStart\",\"onCompositionStartCapture\",\"onCompositionUpdate\",\"onCompositionUpdateCapture\",\"onFocus\",\"onFocusCapture\",\"onBlur\",\"onBlurCapture\",\"onChange\",\"onChangeCapture\",\"onBeforeInput\",\"onBeforeInputCapture\",\"onInput\",\"onInputCapture\",\"onReset\",\"onResetCapture\",\"onSubmit\",\"onSubmitCapture\",\"onInvalid\",\"onInvalidCapture\",\"onLoad\",\"onLoadCapture\",\"onError\",\"onErrorCapture\",\"onKeyDown\",\"onKeyDownCapture\",\"onKeyPress\",\"onKeyPressCapture\",\"onKeyUp\",\"onKeyUpCapture\",\"onAbort\",\"onAbortCapture\",\"onCanPlay\",\"onCanPlayCapture\",\"onCanPlayThrough\",\"onCanPlayThroughCapture\",\"onDurationChange\",\"onDurationChangeCapture\",\"onEmptied\",\"onEmptiedCapture\",\"onEncrypted\",\"onEncryptedCapture\",\"onEnded\",\"onEndedCapture\",\"onLoadedData\",\"onLoadedDataCapture\",\"onLoadedMetadata\",\"onLoadedMetadataCapture\",\"onLoadStart\",\"onLoadStartCapture\",\"onPause\",\"onPauseCapture\",\"onPlay\",\"onPlayCapture\",\"onPlaying\",\"onPlayingCapture\",\"onProgress\",\"onProgressCapture\",\"onRateChange\",\"onRateChangeCapture\",\"onSeeked\",\"onSeekedCapture\",\"onSeeking\",\"onSeekingCapture\",\"onStalled\",\"onStalledCapture\",\"onSuspend\",\"onSuspendCapture\",\"onTimeUpdate\",\"onTimeUpdateCapture\",\"onVolumeChange\",\"onVolumeChangeCapture\",\"onWaiting\",\"onWaitingCapture\",\"onAuxClick\",\"onAuxClickCapture\",\"onClick\",\"onClickCapture\",\"onContextMenu\",\"onContextMenuCapture\",\"onDoubleClick\",\"onDoubleClickCapture\",\"onDrag\",\"onDragCapture\",\"onDragEnd\",\"onDragEndCapture\",\"onDragEnter\",\"onDragEnterCapture\",\"onDragExit\",\"onDragExitCapture\",\"onDragLeave\",\"onDragLeaveCapture\",\"onDragOver\",\"onDragOverCapture\",\"onDragStart\",\"onDragStartCapture\",\"onDrop\",\"onDropCapture\",\"onMouseDown\",\"onMouseDownCapture\",\"onMouseEnter\",\"onMouseLeave\",\"onMouseMove\",\"onMouseMoveCapture\",\"onMouseOut\",\"onMouseOutCapture\",\"onMouseOver\",\"onMouseOverCapture\",\"onMouseUp\",\"onMouseUpCapture\",\"onSelect\",\"onSelectCapture\",\"onTouchCancel\",\"onTouchCancelCapture\",\"onTouchEnd\",\"onTouchEndCapture\",\"onTouchMove\",\"onTouchMoveCapture\",\"onTouchStart\",\"onTouchStartCapture\",\"onPointerDown\",\"onPointerDownCapture\",\"onPointerMove\",\"onPointerMoveCapture\",\"onPointerUp\",\"onPointerUpCapture\",\"onPointerCancel\",\"onPointerCancelCapture\",\"onPointerEnter\",\"onPointerEnterCapture\",\"onPointerLeave\",\"onPointerLeaveCapture\",\"onPointerOver\",\"onPointerOverCapture\",\"onPointerOut\",\"onPointerOutCapture\",\"onGotPointerCapture\",\"onGotPointerCaptureCapture\",\"onLostPointerCapture\",\"onLostPointerCaptureCapture\",\"onScroll\",\"onScrollCapture\",\"onWheel\",\"onWheelCapture\",\"onAnimationStart\",\"onAnimationStartCapture\",\"onAnimationEnd\",\"onAnimationEndCapture\",\"onAnimationIteration\",\"onAnimationIterationCapture\",\"onTransitionEnd\",\"onTransitionEndCapture\"];function vI(t){if(typeof t!=\"string\")return!1;var e=QMe;return e.includes(t)}var ZMe=[\"aria-activedescendant\",\"aria-atomic\",\"aria-autocomplete\",\"aria-busy\",\"aria-checked\",\"aria-colcount\",\"aria-colindex\",\"aria-colspan\",\"aria-controls\",\"aria-current\",\"aria-describedby\",\"aria-details\",\"aria-disabled\",\"aria-errormessage\",\"aria-expanded\",\"aria-flowto\",\"aria-haspopup\",\"aria-hidden\",\"aria-invalid\",\"aria-keyshortcuts\",\"aria-label\",\"aria-labelledby\",\"aria-level\",\"aria-live\",\"aria-modal\",\"aria-multiline\",\"aria-multiselectable\",\"aria-orientation\",\"aria-owns\",\"aria-placeholder\",\"aria-posinset\",\"aria-pressed\",\"aria-readonly\",\"aria-relevant\",\"aria-required\",\"aria-roledescription\",\"aria-rowcount\",\"aria-rowindex\",\"aria-rowspan\",\"aria-selected\",\"aria-setsize\",\"aria-sort\",\"aria-valuemax\",\"aria-valuemin\",\"aria-valuenow\",\"aria-valuetext\",\"className\",\"color\",\"height\",\"id\",\"lang\",\"max\",\"media\",\"method\",\"min\",\"name\",\"style\",\"target\",\"width\",\"role\",\"tabIndex\",\"accentHeight\",\"accumulate\",\"additive\",\"alignmentBaseline\",\"allowReorder\",\"alphabetic\",\"amplitude\",\"arabicForm\",\"ascent\",\"attributeName\",\"attributeType\",\"autoReverse\",\"azimuth\",\"baseFrequency\",\"baselineShift\",\"baseProfile\",\"bbox\",\"begin\",\"bias\",\"by\",\"calcMode\",\"capHeight\",\"clip\",\"clipPath\",\"clipPathUnits\",\"clipRule\",\"colorInterpolation\",\"colorInterpolationFilters\",\"colorProfile\",\"colorRendering\",\"contentScriptType\",\"contentStyleType\",\"cursor\",\"cx\",\"cy\",\"d\",\"decelerate\",\"descent\",\"diffuseConstant\",\"direction\",\"display\",\"divisor\",\"dominantBaseline\",\"dur\",\"dx\",\"dy\",\"edgeMode\",\"elevation\",\"enableBackground\",\"end\",\"exponent\",\"externalResourcesRequired\",\"fill\",\"fillOpacity\",\"fillRule\",\"filter\",\"filterRes\",\"filterUnits\",\"floodColor\",\"floodOpacity\",\"focusable\",\"fontFamily\",\"fontSize\",\"fontSizeAdjust\",\"fontStretch\",\"fontStyle\",\"fontVariant\",\"fontWeight\",\"format\",\"from\",\"fx\",\"fy\",\"g1\",\"g2\",\"glyphName\",\"glyphOrientationHorizontal\",\"glyphOrientationVertical\",\"glyphRef\",\"gradientTransform\",\"gradientUnits\",\"hanging\",\"horizAdvX\",\"horizOriginX\",\"href\",\"ideographic\",\"imageRendering\",\"in2\",\"in\",\"intercept\",\"k1\",\"k2\",\"k3\",\"k4\",\"k\",\"kernelMatrix\",\"kernelUnitLength\",\"kerning\",\"keyPoints\",\"keySplines\",\"keyTimes\",\"lengthAdjust\",\"letterSpacing\",\"lightingColor\",\"limitingConeAngle\",\"local\",\"markerEnd\",\"markerHeight\",\"markerMid\",\"markerStart\",\"markerUnits\",\"markerWidth\",\"mask\",\"maskContentUnits\",\"maskUnits\",\"mathematical\",\"mode\",\"numOctaves\",\"offset\",\"opacity\",\"operator\",\"order\",\"orient\",\"orientation\",\"origin\",\"overflow\",\"overlinePosition\",\"overlineThickness\",\"paintOrder\",\"panose1\",\"pathLength\",\"patternContentUnits\",\"patternTransform\",\"patternUnits\",\"pointerEvents\",\"pointsAtX\",\"pointsAtY\",\"pointsAtZ\",\"preserveAlpha\",\"preserveAspectRatio\",\"primitiveUnits\",\"r\",\"radius\",\"refX\",\"refY\",\"renderingIntent\",\"repeatCount\",\"repeatDur\",\"requiredExtensions\",\"requiredFeatures\",\"restart\",\"result\",\"rotate\",\"rx\",\"ry\",\"seed\",\"shapeRendering\",\"slope\",\"spacing\",\"specularConstant\",\"specularExponent\",\"speed\",\"spreadMethod\",\"startOffset\",\"stdDeviation\",\"stemh\",\"stemv\",\"stitchTiles\",\"stopColor\",\"stopOpacity\",\"strikethroughPosition\",\"strikethroughThickness\",\"string\",\"stroke\",\"strokeDasharray\",\"strokeDashoffset\",\"strokeLinecap\",\"strokeLinejoin\",\"strokeMiterlimit\",\"strokeOpacity\",\"strokeWidth\",\"surfaceScale\",\"systemLanguage\",\"tableValues\",\"targetX\",\"targetY\",\"textAnchor\",\"textDecoration\",\"textLength\",\"textRendering\",\"to\",\"transform\",\"u1\",\"u2\",\"underlinePosition\",\"underlineThickness\",\"unicode\",\"unicodeBidi\",\"unicodeRange\",\"unitsPerEm\",\"vAlphabetic\",\"values\",\"vectorEffect\",\"version\",\"vertAdvY\",\"vertOriginX\",\"vertOriginY\",\"vHanging\",\"vIdeographic\",\"viewTarget\",\"visibility\",\"vMathematical\",\"widths\",\"wordSpacing\",\"writingMode\",\"x1\",\"x2\",\"x\",\"xChannelSelector\",\"xHeight\",\"xlinkActuate\",\"xlinkArcrole\",\"xlinkHref\",\"xlinkRole\",\"xlinkShow\",\"xlinkTitle\",\"xlinkType\",\"xmlBase\",\"xmlLang\",\"xmlns\",\"xmlnsXlink\",\"xmlSpace\",\"y1\",\"y2\",\"y\",\"yChannelSelector\",\"z\",\"zoomAndPan\",\"ref\",\"key\",\"angle\"],JMe=new Set(ZMe);function RJ(t){return typeof t!=\"string\"?!1:JMe.has(t)}function LJ(t){return typeof t==\"string\"&&t.startsWith(\"data-\")}function mo(t){if(typeof t!=\"object\"||t===null)return{};var e={};for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(RJ(n)||LJ(n))&&(e[n]=t[n]);return e}function vg(t){if(t==null)return null;if(w.isValidElement(t)&&typeof t.props==\"object\"&&t.props!==null){var e=t.props;return mo(e)}return typeof t==\"object\"&&!Array.isArray(t)?mo(t):null}function Cs(t){var e={};for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(RJ(n)||LJ(n)||vI(n))&&(e[n]=t[n]);return e}function eEe(t){return t==null?null:w.isValidElement(t)?Cs(t.props):typeof t==\"object\"&&!Array.isArray(t)?Cs(t):null}var tEe=[\"children\",\"width\",\"height\",\"viewBox\",\"className\",\"style\",\"title\",\"desc\"];function yR(){return yR=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},yR.apply(null,arguments)}function nEe(t,e){if(t==null)return{};var n,r,i=rEe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function rEe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var wI=w.forwardRef((t,e)=>{var{children:n,width:r,height:i,viewBox:s,className:o,style:l,title:c,desc:d}=t,u=nEe(t,tEe),m=s||{width:r,height:i,x:0,y:0},p=_r(\"recharts-surface\",o);return w.createElement(\"svg\",yR({},Cs(u),{className:p,width:r,height:i,style:l,viewBox:\"\".concat(m.x,\" \").concat(m.y,\" \").concat(m.width,\" \").concat(m.height),ref:e}),w.createElement(\"title\",null,c),w.createElement(\"desc\",null,d),n)}),aEe=[\"children\",\"className\"];function vR(){return vR=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},vR.apply(null,arguments)}function iEe(t,e){if(t==null)return{};var n,r,i=sEe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function sEe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var Oa=w.forwardRef((t,e)=>{var{children:n,className:r}=t,i=iEe(t,aEe),s=_r(\"recharts-layer\",r);return w.createElement(\"g\",vR({className:s},Cs(i),{ref:e}),n)}),OJ=w.createContext(null),oEe=()=>w.useContext(OJ);function fa(t){return function(){return t}}const IJ=Math.cos,BN=Math.sin,rd=Math.sqrt,HN=Math.PI,sT=2*HN,wR=Math.PI,SR=2*wR,Nf=1e-6,lEe=SR-Nf;function zJ(t){this._+=t[0];for(let e=1,n=t.length;e<n;++e)this._+=arguments[e]+t[e]}function cEe(t){let e=Math.floor(t);if(!(e>=0))throw new Error(`invalid digits: ${t}`);if(e>15)return zJ;const n=10**e;return function(r){this._+=r[0];for(let i=1,s=r.length;i<s;++i)this._+=Math.round(arguments[i]*n)/n+r[i]}}class dEe{constructor(e){this._x0=this._y0=this._x1=this._y1=null,this._=\"\",this._append=e==null?zJ:cEe(e)}moveTo(e,n){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+n}`}closePath(){this._x1!==null&&(this._x1=this._x0,this._y1=this._y0,this._append`Z`)}lineTo(e,n){this._append`L${this._x1=+e},${this._y1=+n}`}quadraticCurveTo(e,n,r,i){this._append`Q${+e},${+n},${this._x1=+r},${this._y1=+i}`}bezierCurveTo(e,n,r,i,s,o){this._append`C${+e},${+n},${+r},${+i},${this._x1=+s},${this._y1=+o}`}arcTo(e,n,r,i,s){if(e=+e,n=+n,r=+r,i=+i,s=+s,s<0)throw new Error(`negative radius: ${s}`);let o=this._x1,l=this._y1,c=r-e,d=i-n,u=o-e,m=l-n,p=u*u+m*m;if(this._x1===null)this._append`M${this._x1=e},${this._y1=n}`;else if(p>Nf)if(!(Math.abs(m*c-d*u)>Nf)||!s)this._append`L${this._x1=e},${this._y1=n}`;else{let f=r-o,y=i-l,v=c*c+d*d,b=f*f+y*y,g=Math.sqrt(v),_=Math.sqrt(p),C=s*Math.tan((wR-Math.acos((v+p-b)/(2*g*_)))/2),P=C/_,N=C/g;Math.abs(P-1)>Nf&&this._append`L${e+P*u},${n+P*m}`,this._append`A${s},${s},0,0,${+(m*f>u*y)},${this._x1=e+N*c},${this._y1=n+N*d}`}}arc(e,n,r,i,s,o){if(e=+e,n=+n,r=+r,o=!!o,r<0)throw new Error(`negative radius: ${r}`);let l=r*Math.cos(i),c=r*Math.sin(i),d=e+l,u=n+c,m=1^o,p=o?i-s:s-i;this._x1===null?this._append`M${d},${u}`:(Math.abs(this._x1-d)>Nf||Math.abs(this._y1-u)>Nf)&&this._append`L${d},${u}`,r&&(p<0&&(p=p%SR+SR),p>lEe?this._append`A${r},${r},0,1,${m},${e-l},${n-c}A${r},${r},0,1,${m},${this._x1=d},${this._y1=u}`:p>Nf&&this._append`A${r},${r},0,${+(p>=wR)},${m},${this._x1=e+r*Math.cos(s)},${this._y1=n+r*Math.sin(s)}`)}rect(e,n,r,i){this._append`M${this._x0=this._x1=+e},${this._y0=this._y1=+n}h${r=+r}v${+i}h${-r}Z`}toString(){return this._}}function SI(t){let e=3;return t.digits=function(n){if(!arguments.length)return e;if(n==null)e=null;else{const r=Math.floor(n);if(!(r>=0))throw new RangeError(`invalid digits: ${n}`);e=r}return t},()=>new dEe(e)}function _I(t){return typeof t==\"object\"&&\"length\"in t?t:Array.from(t)}function UJ(t){this._context=t}UJ.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:this._context.lineTo(t,e);break}}};function oT(t){return new UJ(t)}function BJ(t){return t[0]}function HJ(t){return t[1]}function qJ(t,e){var n=fa(!0),r=null,i=oT,s=null,o=SI(l);t=typeof t==\"function\"?t:t===void 0?BJ:fa(t),e=typeof e==\"function\"?e:e===void 0?HJ:fa(e);function l(c){var d,u=(c=_I(c)).length,m,p=!1,f;for(r==null&&(s=i(f=o())),d=0;d<=u;++d)!(d<u&&n(m=c[d],d,c))===p&&((p=!p)?s.lineStart():s.lineEnd()),p&&s.point(+t(m,d,c),+e(m,d,c));if(f)return s=null,f+\"\"||null}return l.x=function(c){return arguments.length?(t=typeof c==\"function\"?c:fa(+c),l):t},l.y=function(c){return arguments.length?(e=typeof c==\"function\"?c:fa(+c),l):e},l.defined=function(c){return arguments.length?(n=typeof c==\"function\"?c:fa(!!c),l):n},l.curve=function(c){return arguments.length?(i=c,r!=null&&(s=i(r)),l):i},l.context=function(c){return arguments.length?(c==null?r=s=null:s=i(r=c),l):r},l}function Z1(t,e,n){var r=null,i=fa(!0),s=null,o=oT,l=null,c=SI(d);t=typeof t==\"function\"?t:t===void 0?BJ:fa(+t),e=typeof e==\"function\"?e:fa(e===void 0?0:+e),n=typeof n==\"function\"?n:n===void 0?HJ:fa(+n);function d(m){var p,f,y,v=(m=_I(m)).length,b,g=!1,_,C=new Array(v),P=new Array(v);for(s==null&&(l=o(_=c())),p=0;p<=v;++p){if(!(p<v&&i(b=m[p],p,m))===g)if(g=!g)f=p,l.areaStart(),l.lineStart();else{for(l.lineEnd(),l.lineStart(),y=p-1;y>=f;--y)l.point(C[y],P[y]);l.lineEnd(),l.areaEnd()}g&&(C[p]=+t(b,p,m),P[p]=+e(b,p,m),l.point(r?+r(b,p,m):C[p],n?+n(b,p,m):P[p]))}if(_)return l=null,_+\"\"||null}function u(){return qJ().defined(i).curve(o).context(s)}return d.x=function(m){return arguments.length?(t=typeof m==\"function\"?m:fa(+m),r=null,d):t},d.x0=function(m){return arguments.length?(t=typeof m==\"function\"?m:fa(+m),d):t},d.x1=function(m){return arguments.length?(r=m==null?null:typeof m==\"function\"?m:fa(+m),d):r},d.y=function(m){return arguments.length?(e=typeof m==\"function\"?m:fa(+m),n=null,d):e},d.y0=function(m){return arguments.length?(e=typeof m==\"function\"?m:fa(+m),d):e},d.y1=function(m){return arguments.length?(n=m==null?null:typeof m==\"function\"?m:fa(+m),d):n},d.lineX0=d.lineY0=function(){return u().x(t).y(e)},d.lineY1=function(){return u().x(t).y(n)},d.lineX1=function(){return u().x(r).y(e)},d.defined=function(m){return arguments.length?(i=typeof m==\"function\"?m:fa(!!m),d):i},d.curve=function(m){return arguments.length?(o=m,s!=null&&(l=o(s)),d):o},d.context=function(m){return arguments.length?(m==null?s=l=null:l=o(s=m),d):s},d}class $J{constructor(e,n){this._context=e,this._x=n}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line}point(e,n){switch(e=+e,n=+n,this._point){case 0:{this._point=1,this._line?this._context.lineTo(e,n):this._context.moveTo(e,n);break}case 1:this._point=2;default:{this._x?this._context.bezierCurveTo(this._x0=(this._x0+e)/2,this._y0,this._x0,n,e,n):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+n)/2,e,this._y0,e,n);break}}this._x0=e,this._y0=n}}function uEe(t){return new $J(t,!0)}function mEe(t){return new $J(t,!1)}const kI={draw(t,e){const n=rd(e/HN);t.moveTo(n,0),t.arc(0,0,n,0,sT)}},hEe={draw(t,e){const n=rd(e/5)/2;t.moveTo(-3*n,-n),t.lineTo(-n,-n),t.lineTo(-n,-3*n),t.lineTo(n,-3*n),t.lineTo(n,-n),t.lineTo(3*n,-n),t.lineTo(3*n,n),t.lineTo(n,n),t.lineTo(n,3*n),t.lineTo(-n,3*n),t.lineTo(-n,n),t.lineTo(-3*n,n),t.closePath()}},VJ=rd(1/3),pEe=VJ*2,fEe={draw(t,e){const n=rd(e/pEe),r=n*VJ;t.moveTo(0,-n),t.lineTo(r,0),t.lineTo(0,n),t.lineTo(-r,0),t.closePath()}},gEe={draw(t,e){const n=rd(e),r=-n/2;t.rect(r,r,n,n)}},bEe=.8908130915292852,GJ=BN(HN/10)/BN(7*HN/10),xEe=BN(sT/10)*GJ,yEe=-IJ(sT/10)*GJ,vEe={draw(t,e){const n=rd(e*bEe),r=xEe*n,i=yEe*n;t.moveTo(0,-n),t.lineTo(r,i);for(let s=1;s<5;++s){const o=sT*s/5,l=IJ(o),c=BN(o);t.lineTo(c*n,-l*n),t.lineTo(l*r-c*i,c*r+l*i)}t.closePath()}},ZE=rd(3),wEe={draw(t,e){const n=-rd(e/(ZE*3));t.moveTo(0,n*2),t.lineTo(-ZE*n,-n),t.lineTo(ZE*n,-n),t.closePath()}},Il=-.5,zl=rd(3)/2,_R=1/rd(12),SEe=(_R/2+1)*3,_Ee={draw(t,e){const n=rd(e/SEe),r=n/2,i=n*_R,s=r,o=n*_R+n,l=-s,c=o;t.moveTo(r,i),t.lineTo(s,o),t.lineTo(l,c),t.lineTo(Il*r-zl*i,zl*r+Il*i),t.lineTo(Il*s-zl*o,zl*s+Il*o),t.lineTo(Il*l-zl*c,zl*l+Il*c),t.lineTo(Il*r+zl*i,Il*i-zl*r),t.lineTo(Il*s+zl*o,Il*o-zl*s),t.lineTo(Il*l+zl*c,Il*c-zl*l),t.closePath()}};function kEe(t,e){let n=null,r=SI(i);t=typeof t==\"function\"?t:fa(t||kI),e=typeof e==\"function\"?e:fa(e===void 0?64:+e);function i(){let s;if(n||(n=s=r()),t.apply(this,arguments).draw(n,+e.apply(this,arguments)),s)return n=null,s+\"\"||null}return i.type=function(s){return arguments.length?(t=typeof s==\"function\"?s:fa(s),i):t},i.size=function(s){return arguments.length?(e=typeof s==\"function\"?s:fa(+s),i):e},i.context=function(s){return arguments.length?(n=s??null,i):n},i}function qN(){}function $N(t,e,n){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+e)/6,(t._y0+4*t._y1+n)/6)}function WJ(t){this._context=t}WJ.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:$N(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1);break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:$N(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}};function NEe(t){return new WJ(t)}function KJ(t){this._context=t}KJ.prototype={areaStart:qN,areaEnd:qN,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:{this._context.moveTo(this._x2,this._y2),this._context.closePath();break}case 2:{this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break}case 3:{this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4);break}}},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x2=t,this._y2=e;break;case 1:this._point=2,this._x3=t,this._y3=e;break;case 2:this._point=3,this._x4=t,this._y4=e,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+e)/6);break;default:$N(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}};function CEe(t){return new KJ(t)}function XJ(t){this._context=t}XJ.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||this._line!==0&&this._point===3)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var n=(this._x0+4*this._x1+t)/6,r=(this._y0+4*this._y1+e)/6;this._line?this._context.lineTo(n,r):this._context.moveTo(n,r);break;case 3:this._point=4;default:$N(this,t,e);break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}};function PEe(t){return new XJ(t)}function YJ(t){this._context=t}YJ.prototype={areaStart:qN,areaEnd:qN,lineStart:function(){this._point=0},lineEnd:function(){this._point&&this._context.closePath()},point:function(t,e){t=+t,e=+e,this._point?this._context.lineTo(t,e):(this._point=1,this._context.moveTo(t,e))}};function TEe(t){return new YJ(t)}function $$(t){return t<0?-1:1}function V$(t,e,n){var r=t._x1-t._x0,i=e-t._x1,s=(t._y1-t._y0)/(r||i<0&&-0),o=(n-t._y1)/(i||r<0&&-0),l=(s*i+o*r)/(r+i);return($$(s)+$$(o))*Math.min(Math.abs(s),Math.abs(o),.5*Math.abs(l))||0}function G$(t,e){var n=t._x1-t._x0;return n?(3*(t._y1-t._y0)/n-e)/2:e}function JE(t,e,n){var r=t._x0,i=t._y0,s=t._x1,o=t._y1,l=(s-r)/3;t._context.bezierCurveTo(r+l,i+l*e,s-l,o-l*n,s,o)}function VN(t){this._context=t}VN.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=this._t0=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x1,this._y1);break;case 3:JE(this,this._t0,G$(this,this._t0));break}(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){var n=NaN;if(t=+t,e=+e,!(t===this._x1&&e===this._y1)){switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,JE(this,G$(this,n=V$(this,t,e)),n);break;default:JE(this,this._t0,n=V$(this,t,e));break}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e,this._t0=n}}};function QJ(t){this._context=new ZJ(t)}(QJ.prototype=Object.create(VN.prototype)).point=function(t,e){VN.prototype.point.call(this,e,t)};function ZJ(t){this._context=t}ZJ.prototype={moveTo:function(t,e){this._context.moveTo(e,t)},closePath:function(){this._context.closePath()},lineTo:function(t,e){this._context.lineTo(e,t)},bezierCurveTo:function(t,e,n,r,i,s){this._context.bezierCurveTo(e,t,r,n,s,i)}};function AEe(t){return new VN(t)}function jEe(t){return new QJ(t)}function JJ(t){this._context=t}JJ.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x=[],this._y=[]},lineEnd:function(){var t=this._x,e=this._y,n=t.length;if(n)if(this._line?this._context.lineTo(t[0],e[0]):this._context.moveTo(t[0],e[0]),n===2)this._context.lineTo(t[1],e[1]);else for(var r=W$(t),i=W$(e),s=0,o=1;o<n;++s,++o)this._context.bezierCurveTo(r[0][s],i[0][s],r[1][s],i[1][s],t[o],e[o]);(this._line||this._line!==0&&n===1)&&this._context.closePath(),this._line=1-this._line,this._x=this._y=null},point:function(t,e){this._x.push(+t),this._y.push(+e)}};function W$(t){var e,n=t.length-1,r,i=new Array(n),s=new Array(n),o=new Array(n);for(i[0]=0,s[0]=2,o[0]=t[0]+2*t[1],e=1;e<n-1;++e)i[e]=1,s[e]=4,o[e]=4*t[e]+2*t[e+1];for(i[n-1]=2,s[n-1]=7,o[n-1]=8*t[n-1]+t[n],e=1;e<n;++e)r=i[e]/s[e-1],s[e]-=r,o[e]-=r*o[e-1];for(i[n-1]=o[n-1]/s[n-1],e=n-2;e>=0;--e)i[e]=(o[e]-i[e+1])/s[e];for(s[n-1]=(t[n]+i[n-1])/2,e=0;e<n-1;++e)s[e]=2*t[e+1]-i[e+1];return[i,s]}function MEe(t){return new JJ(t)}function lT(t,e){this._context=t,this._t=e}lT.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x=this._y=NaN,this._point=0},lineEnd:function(){0<this._t&&this._t<1&&this._point===2&&this._context.lineTo(this._x,this._y),(this._line||this._line!==0&&this._point===1)&&this._context.closePath(),this._line>=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:{if(this._t<=0)this._context.lineTo(this._x,e),this._context.lineTo(t,e);else{var n=this._x*(1-this._t)+t*this._t;this._context.lineTo(n,this._y),this._context.lineTo(n,e)}break}}this._x=t,this._y=e}};function EEe(t){return new lT(t,.5)}function DEe(t){return new lT(t,0)}function FEe(t){return new lT(t,1)}function wg(t,e){if((o=t.length)>1)for(var n=1,r,i,s=t[e[0]],o,l=s.length;n<o;++n)for(i=s,s=t[e[n]],r=0;r<l;++r)s[r][1]+=s[r][0]=isNaN(i[r][1])?i[r][0]:i[r][1]}function kR(t){for(var e=t.length,n=new Array(e);--e>=0;)n[e]=e;return n}function REe(t,e){return t[e]}function LEe(t){const e=[];return e.key=t,e}function OEe(){var t=fa([]),e=kR,n=wg,r=REe;function i(s){var o=Array.from(t.apply(this,arguments),LEe),l,c=o.length,d=-1,u;for(const m of s)for(l=0,++d;l<c;++l)(o[l][d]=[0,+r(m,o[l].key,d,s)]).data=m;for(l=0,u=_I(e(o));l<c;++l)o[u[l]].index=l;return n(o,u),o}return i.keys=function(s){return arguments.length?(t=typeof s==\"function\"?s:fa(Array.from(s)),i):t},i.value=function(s){return arguments.length?(r=typeof s==\"function\"?s:fa(+s),i):r},i.order=function(s){return arguments.length?(e=s==null?kR:typeof s==\"function\"?s:fa(Array.from(s)),i):e},i.offset=function(s){return arguments.length?(n=s??wg,i):n},i}function IEe(t,e){if((r=t.length)>0){for(var n,r,i=0,s=t[0].length,o;i<s;++i){for(o=n=0;n<r;++n)o+=t[n][i][1]||0;if(o)for(n=0;n<r;++n)t[n][i][1]/=o}wg(t,e)}}function zEe(t,e){if((i=t.length)>0){for(var n=0,r=t[e[0]],i,s=r.length;n<s;++n){for(var o=0,l=0;o<i;++o)l+=t[o][n][1]||0;r[n][1]+=r[n][0]=-l/2}wg(t,e)}}function UEe(t,e){if(!(!((o=t.length)>0)||!((s=(i=t[e[0]]).length)>0))){for(var n=0,r=1,i,s,o;r<s;++r){for(var l=0,c=0,d=0;l<o;++l){for(var u=t[e[l]],m=u[r][1]||0,p=u[r-1][1]||0,f=(m-p)/2,y=0;y<l;++y){var v=t[e[y]],b=v[r][1]||0,g=v[r-1][1]||0;f+=b-g}c+=m,d+=f*m}i[r-1][1]+=i[r-1][0]=n,c&&(n-=d/c)}i[r-1][1]+=i[r-1][0]=n,wg(t,e)}}var e2={},t2={},K$;function BEe(){return K$||(K$=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return n===\"__proto__\"}t.isUnsafeProperty=e})(t2)),t2}var n2={},X$;function eee(){return X$||(X$=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){switch(typeof n){case\"number\":case\"symbol\":return!1;case\"string\":return n.includes(\".\")||n.includes(\"[\")||n.includes(\"]\")}}t.isDeepKey=e})(n2)),n2}var r2={},Y$;function NI(){return Y$||(Y$=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return typeof n==\"string\"||typeof n==\"symbol\"?n:Object.is(n?.valueOf?.(),-0)?\"-0\":String(n)}t.toKey=e})(r2)),r2}var a2={},i2={},Q$;function HEe(){return Q$||(Q$=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){if(n==null)return\"\";if(typeof n==\"string\")return n;if(Array.isArray(n))return n.map(e).join(\",\");const r=String(n);return r===\"0\"&&Object.is(Number(n),-0)?\"-0\":r}t.toString=e})(i2)),i2}var Z$;function CI(){return Z$||(Z$=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=HEe(),n=NI();function r(i){if(Array.isArray(i))return i.map(n.toKey);if(typeof i==\"symbol\")return[i];i=e.toString(i);const s=[],o=i.length;if(o===0)return s;let l=0,c=\"\",d=\"\",u=!1;for(i.charCodeAt(0)===46&&(s.push(\"\"),l++);l<o;){const m=i[l];d?m===\"\\\\\"&&l+1<o?(l++,c+=i[l]):m===d?d=\"\":c+=m:u?m==='\"'||m===\"'\"?d=m:m===\"]\"?(u=!1,s.push(c),c=\"\"):c+=m:m===\"[\"?(u=!0,c&&(s.push(c),c=\"\")):m===\".\"?c&&(s.push(c),c=\"\"):c+=m,l++}return c&&s.push(c),s}t.toPath=r})(a2)),a2}var J$;function PI(){return J$||(J$=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=BEe(),n=eee(),r=NI(),i=CI();function s(l,c,d){if(l==null)return d;switch(typeof c){case\"string\":{if(e.isUnsafeProperty(c))return d;const u=l[c];return u===void 0?n.isDeepKey(c)?s(l,i.toPath(c),d):d:u}case\"number\":case\"symbol\":{typeof c==\"number\"&&(c=r.toKey(c));const u=l[c];return u===void 0?d:u}default:{if(Array.isArray(c))return o(l,c,d);if(Object.is(c?.valueOf(),-0)?c=\"-0\":c=String(c),e.isUnsafeProperty(c))return d;const u=l[c];return u===void 0?d:u}}}function o(l,c,d){if(c.length===0)return d;let u=l;for(let m=0;m<c.length;m++){if(u==null||e.isUnsafeProperty(c[m]))return d;u=u[c[m]]}return u===void 0?d:u}t.get=s})(e2)),e2}var s2,eV;function qEe(){return eV||(eV=1,s2=PI().get),s2}var $Ee=qEe();const Sg=bc($Ee);var VEe=4;function Vh(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:VEe,n=10**e,r=Math.round(t*n)/n;return Object.is(r,-0)?0:r}function Ka(t){for(var e=arguments.length,n=new Array(e>1?e-1:0),r=1;r<e;r++)n[r-1]=arguments[r];return t.reduce((i,s,o)=>{var l=n[o-1];return typeof l==\"string\"?i+l+s:l!==void 0?i+Vh(l)+s:i+s},\"\")}var Qi=t=>t===0?0:t>0?1:-1,Zc=t=>typeof t==\"number\"&&t!=+t,_g=t=>typeof t==\"string\"&&t.indexOf(\"%\")===t.length-1,tn=t=>(typeof t==\"number\"||t instanceof Number)&&!Zc(t),hc=t=>tn(t)||typeof t==\"string\",GEe=0,xw=t=>{var e=++GEe;return\"\".concat(t||\"\").concat(e)},Hs=function(e,n){var r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:0,i=arguments.length>3&&arguments[3]!==void 0?arguments[3]:!1;if(!tn(e)&&typeof e!=\"string\")return r;var s;if(_g(e)){if(n==null)return r;var o=e.indexOf(\"%\");s=n*parseFloat(e.slice(0,o))/100}else s=+e;return Zc(s)&&(s=r),i&&n!=null&&s>n&&(s=n),s},tee=t=>{if(!Array.isArray(t))return!1;for(var e=t.length,n={},r=0;r<e;r++)if(!n[String(t[r])])n[String(t[r])]=!0;else return!0;return!1};function Ir(t,e,n){return tn(t)&&tn(e)?Vh(t+n*(e-t)):e}function nee(t,e,n){if(!(!t||!t.length))return t.find(r=>r&&(typeof e==\"function\"?e(r):Sg(r,e))===n)}var Ea=t=>t===null||typeof t>\"u\",mS=t=>Ea(t)?t:\"\".concat(t.charAt(0).toUpperCase()).concat(t.slice(1));function Oo(t){return t!=null}function Tp(){}var WEe=[\"type\",\"size\",\"sizeType\"];function NR(){return NR=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},NR.apply(null,arguments)}function tV(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function nV(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?tV(Object(n),!0).forEach(function(r){KEe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):tV(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function KEe(t,e,n){return(e=XEe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function XEe(t){var e=YEe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function YEe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function QEe(t,e){if(t==null)return{};var n,r,i=ZEe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function ZEe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var ree={symbolCircle:kI,symbolCross:hEe,symbolDiamond:fEe,symbolSquare:gEe,symbolStar:vEe,symbolTriangle:wEe,symbolWye:_Ee},JEe=Math.PI/180,e2e=t=>{var e=\"symbol\".concat(mS(t));return ree[e]||kI},t2e=(t,e,n)=>{if(e===\"area\")return t;switch(n){case\"cross\":return 5*t*t/9;case\"diamond\":return .5*t*t/Math.sqrt(3);case\"square\":return t*t;case\"star\":{var r=18*JEe;return 1.25*t*t*(Math.tan(r)-Math.tan(r*2)*Math.tan(r)**2)}case\"triangle\":return Math.sqrt(3)*t*t/4;case\"wye\":return(21-10*Math.sqrt(3))*t*t/8;default:return Math.PI*t*t/4}},n2e=(t,e)=>{ree[\"symbol\".concat(mS(t))]=e},TI=t=>{var{type:e=\"circle\",size:n=64,sizeType:r=\"area\"}=t,i=QEe(t,WEe),s=nV(nV({},i),{},{type:e,size:n,sizeType:r}),o=\"circle\";typeof e==\"string\"&&(o=e);var l=()=>{var p=e2e(o),f=kEe().type(p).size(t2e(n,r,o)),y=f();if(y!==null)return y},{className:c,cx:d,cy:u}=s,m=Cs(s);return tn(d)&&tn(u)&&tn(n)?w.createElement(\"path\",NR({},m,{className:_r(\"recharts-symbols\",c),transform:\"translate(\".concat(d,\", \").concat(u,\")\"),d:l()})):null};TI.registerSymbol=n2e;var aee=t=>\"radius\"in t&&\"startAngle\"in t&&\"endAngle\"in t,AI=(t,e)=>{if(!t||typeof t==\"function\"||typeof t==\"boolean\")return null;var n=t;if(w.isValidElement(t)&&(n=t.props),typeof n!=\"object\"&&typeof n!=\"function\")return null;var r={};return Object.keys(n).forEach(i=>{vI(i)&&(r[i]=(s=>n[i](n,s)))}),r},r2e=(t,e,n)=>r=>(t(e,n,r),null),hS=(t,e,n)=>{if(t===null||typeof t!=\"object\"&&typeof t!=\"function\")return null;var r=null;return Object.keys(t).forEach(i=>{var s=t[i];vI(i)&&typeof s==\"function\"&&(r||(r={}),r[i]=r2e(s,e,n))}),r};function rV(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function a2e(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?rV(Object(n),!0).forEach(function(r){i2e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):rV(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function i2e(t,e,n){return(e=s2e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function s2e(t){var e=o2e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function o2e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function Ia(t,e){var n=a2e({},t),r=e,i=Object.keys(e),s=i.reduce((o,l)=>(o[l]===void 0&&r[l]!==void 0&&(o[l]=r[l]),o),n);return s}function GN(){return GN=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},GN.apply(null,arguments)}function aV(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function l2e(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?aV(Object(n),!0).forEach(function(r){c2e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):aV(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function c2e(t,e,n){return(e=d2e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function d2e(t){var e=u2e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function u2e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var ql=32,m2e={align:\"center\",iconSize:14,inactiveColor:\"#ccc\",layout:\"horizontal\",verticalAlign:\"middle\"};function h2e(t){var{data:e,iconType:n,inactiveColor:r}=t,i=ql/2,s=ql/6,o=ql/3,l=e.inactive?r:e.color,c=n??e.type;if(c===\"none\")return null;if(c===\"plainline\"){var d;return w.createElement(\"line\",{strokeWidth:4,fill:\"none\",stroke:l,strokeDasharray:(d=e.payload)===null||d===void 0?void 0:d.strokeDasharray,x1:0,y1:i,x2:ql,y2:i,className:\"recharts-legend-icon\"})}if(c===\"line\")return w.createElement(\"path\",{strokeWidth:4,fill:\"none\",stroke:l,d:\"M0,\".concat(i,\"h\").concat(o,`\n            A`).concat(s,\",\").concat(s,\",0,1,1,\").concat(2*o,\",\").concat(i,`\n            H`).concat(ql,\"M\").concat(2*o,\",\").concat(i,`\n            A`).concat(s,\",\").concat(s,\",0,1,1,\").concat(o,\",\").concat(i),className:\"recharts-legend-icon\"});if(c===\"rect\")return w.createElement(\"path\",{stroke:\"none\",fill:l,d:\"M0,\".concat(ql/8,\"h\").concat(ql,\"v\").concat(ql*3/4,\"h\").concat(-ql,\"z\"),className:\"recharts-legend-icon\"});if(w.isValidElement(e.legendIcon)){var u=l2e({},e);return delete u.legendIcon,w.cloneElement(e.legendIcon,u)}return w.createElement(TI,{fill:l,cx:i,cy:i,size:ql,sizeType:\"diameter\",type:c})}function p2e(t){var{payload:e,iconSize:n,layout:r,formatter:i,inactiveColor:s,iconType:o}=t,l={x:0,y:0,width:ql,height:ql},c={display:r===\"horizontal\"?\"inline-block\":\"block\",marginRight:10},d={display:\"inline-block\",verticalAlign:\"middle\",marginRight:4};return e.map((u,m)=>{var p=u.formatter||i,f=_r({\"recharts-legend-item\":!0,[\"legend-item-\".concat(m)]:!0,inactive:u.inactive});if(u.type===\"none\")return null;var y=u.inactive?s:u.color,v=p?p(u.value,u,m):u.value;return w.createElement(\"li\",GN({className:f,style:c,key:\"legend-item-\".concat(m)},hS(t,u,m)),w.createElement(wI,{width:n,height:n,viewBox:l,style:d,\"aria-label\":\"\".concat(v,\" legend icon\")},w.createElement(h2e,{data:u,iconType:o,inactiveColor:s})),w.createElement(\"span\",{className:\"recharts-legend-item-text\",style:{color:y}},v))})}var f2e=t=>{var e=Ia(t,m2e),{payload:n,layout:r,align:i}=e;if(!n||!n.length)return null;var s={padding:0,margin:0,textAlign:r===\"horizontal\"?i:\"left\"};return w.createElement(\"ul\",{className:\"recharts-default-legend\",style:s},w.createElement(p2e,GN({},e,{payload:n})))},o2={},l2={},iV;function g2e(){return iV||(iV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n,r){const i=new Map;for(let s=0;s<n.length;s++){const o=n[s],l=r(o,s,n);i.has(l)||i.set(l,o)}return Array.from(i.values())}t.uniqBy=e})(l2)),l2}var c2={},sV;function b2e(){return sV||(sV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n,r){return function(...i){return n.apply(this,i.slice(0,r))}}t.ary=e})(c2)),c2}var d2={},oV;function iee(){return oV||(oV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return n}t.identity=e})(d2)),d2}var u2={},m2={},h2={},lV;function x2e(){return lV||(lV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return Number.isSafeInteger(n)&&n>=0}t.isLength=e})(h2)),h2}var cV;function jI(){return cV||(cV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=x2e();function n(r){return r!=null&&typeof r!=\"function\"&&e.isLength(r.length)}t.isArrayLike=n})(m2)),m2}var p2={},dV;function y2e(){return dV||(dV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return typeof n==\"object\"&&n!==null}t.isObjectLike=e})(p2)),p2}var uV;function v2e(){return uV||(uV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=jI(),n=y2e();function r(i){return n.isObjectLike(i)&&e.isArrayLike(i)}t.isArrayLikeObject=r})(u2)),u2}var f2={},g2={},mV;function w2e(){return mV||(mV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=PI();function n(r){return function(i){return e.get(i,r)}}t.property=n})(g2)),g2}var b2={},x2={},y2={},v2={},hV;function see(){return hV||(hV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return n!==null&&(typeof n==\"object\"||typeof n==\"function\")}t.isObject=e})(v2)),v2}var w2={},pV;function oee(){return pV||(pV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return n==null||typeof n!=\"object\"&&typeof n!=\"function\"}t.isPrimitive=e})(w2)),w2}var S2={},fV;function lee(){return fV||(fV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n,r){return n===r||Number.isNaN(n)&&Number.isNaN(r)}t.isEqualsSameValueZero=e})(S2)),S2}var gV;function S2e(){return gV||(gV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=see(),n=oee(),r=lee();function i(u,m,p){return typeof p!=\"function\"?i(u,m,()=>{}):s(u,m,function f(y,v,b,g,_,C){const P=p(y,v,b,g,_,C);return P!==void 0?!!P:s(y,v,f,C)},new Map)}function s(u,m,p,f){if(m===u)return!0;switch(typeof m){case\"object\":return o(u,m,p,f);case\"function\":return Object.keys(m).length>0?s(u,{...m},p,f):r.isEqualsSameValueZero(u,m);default:return e.isObject(u)?typeof m==\"string\"?m===\"\":!0:r.isEqualsSameValueZero(u,m)}}function o(u,m,p,f){if(m==null)return!0;if(Array.isArray(m))return c(u,m,p,f);if(m instanceof Map)return l(u,m,p,f);if(m instanceof Set)return d(u,m,p,f);const y=Object.keys(m);if(u==null||n.isPrimitive(u))return y.length===0;if(y.length===0)return!0;if(f?.has(m))return f.get(m)===u;f?.set(m,u);try{for(let v=0;v<y.length;v++){const b=y[v];if(!n.isPrimitive(u)&&!(b in u)||m[b]===void 0&&u[b]!==void 0||m[b]===null&&u[b]!==null||!p(u[b],m[b],b,u,m,f))return!1}return!0}finally{f?.delete(m)}}function l(u,m,p,f){if(m.size===0)return!0;if(!(u instanceof Map))return!1;for(const[y,v]of m.entries()){const b=u.get(y);if(p(b,v,y,u,m,f)===!1)return!1}return!0}function c(u,m,p,f){if(m.length===0)return!0;if(!Array.isArray(u))return!1;const y=new Set;for(let v=0;v<m.length;v++){const b=m[v];let g=!1;for(let _=0;_<u.length;_++){if(y.has(_))continue;const C=u[_];let P=!1;if(p(C,b,v,u,m,f)&&(P=!0),P){y.add(_),g=!0;break}}if(!g)return!1}return!0}function d(u,m,p,f){return m.size===0?!0:u instanceof Set?c([...u],[...m],p,f):!1}t.isMatchWith=i,t.isSetMatch=d})(y2)),y2}var bV;function cee(){return bV||(bV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=S2e();function n(r,i){return e.isMatchWith(r,i,()=>{})}t.isMatch=n})(x2)),x2}var _2={},k2={},N2={},xV;function _2e(){return xV||(xV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return Object.getOwnPropertySymbols(n).filter(r=>Object.prototype.propertyIsEnumerable.call(n,r))}t.getSymbols=e})(N2)),N2}var C2={},yV;function MI(){return yV||(yV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return n==null?n===void 0?\"[object Undefined]\":\"[object Null]\":Object.prototype.toString.call(n)}t.getTag=e})(C2)),C2}var P2={},vV;function dee(){return vV||(vV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=\"[object RegExp]\",n=\"[object String]\",r=\"[object Number]\",i=\"[object Boolean]\",s=\"[object Arguments]\",o=\"[object Symbol]\",l=\"[object Date]\",c=\"[object Map]\",d=\"[object Set]\",u=\"[object Array]\",m=\"[object Function]\",p=\"[object ArrayBuffer]\",f=\"[object Object]\",y=\"[object Error]\",v=\"[object DataView]\",b=\"[object Uint8Array]\",g=\"[object Uint8ClampedArray]\",_=\"[object Uint16Array]\",C=\"[object Uint32Array]\",P=\"[object BigUint64Array]\",N=\"[object Int8Array]\",A=\"[object Int16Array]\",T=\"[object Int32Array]\",F=\"[object BigInt64Array]\",k=\"[object Float32Array]\",D=\"[object Float64Array]\";t.argumentsTag=s,t.arrayBufferTag=p,t.arrayTag=u,t.bigInt64ArrayTag=F,t.bigUint64ArrayTag=P,t.booleanTag=i,t.dataViewTag=v,t.dateTag=l,t.errorTag=y,t.float32ArrayTag=k,t.float64ArrayTag=D,t.functionTag=m,t.int16ArrayTag=A,t.int32ArrayTag=T,t.int8ArrayTag=N,t.mapTag=c,t.numberTag=r,t.objectTag=f,t.regexpTag=e,t.setTag=d,t.stringTag=n,t.symbolTag=o,t.uint16ArrayTag=_,t.uint32ArrayTag=C,t.uint8ArrayTag=b,t.uint8ClampedArrayTag=g})(P2)),P2}var T2={},wV;function k2e(){return wV||(wV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return ArrayBuffer.isView(n)&&!(n instanceof DataView)}t.isTypedArray=e})(T2)),T2}var SV;function uee(){return SV||(SV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=_2e(),n=MI(),r=dee(),i=oee(),s=k2e();function o(u,m){return l(u,void 0,u,new Map,m)}function l(u,m,p,f=new Map,y=void 0){const v=y?.(u,m,p,f);if(v!==void 0)return v;if(i.isPrimitive(u))return u;if(f.has(u))return f.get(u);if(Array.isArray(u)){const b=new Array(u.length);f.set(u,b);for(let g=0;g<u.length;g++)b[g]=l(u[g],g,p,f,y);return Object.hasOwn(u,\"index\")&&(b.index=u.index),Object.hasOwn(u,\"input\")&&(b.input=u.input),b}if(u instanceof Date)return new Date(u.getTime());if(u instanceof RegExp){const b=new RegExp(u.source,u.flags);return b.lastIndex=u.lastIndex,b}if(u instanceof Map){const b=new Map;f.set(u,b);for(const[g,_]of u)b.set(g,l(_,g,p,f,y));return b}if(u instanceof Set){const b=new Set;f.set(u,b);for(const g of u)b.add(l(g,void 0,p,f,y));return b}if(typeof Buffer<\"u\"&&Buffer.isBuffer(u))return u.subarray();if(s.isTypedArray(u)){const b=new(Object.getPrototypeOf(u)).constructor(u.length);f.set(u,b);for(let g=0;g<u.length;g++)b[g]=l(u[g],g,p,f,y);return b}if(u instanceof ArrayBuffer||typeof SharedArrayBuffer<\"u\"&&u instanceof SharedArrayBuffer)return u.slice(0);if(u instanceof DataView){const b=new DataView(u.buffer.slice(0),u.byteOffset,u.byteLength);return f.set(u,b),c(b,u,p,f,y),b}if(typeof File<\"u\"&&u instanceof File){const b=new File([u],u.name,{type:u.type});return f.set(u,b),c(b,u,p,f,y),b}if(typeof Blob<\"u\"&&u instanceof Blob){const b=new Blob([u],{type:u.type});return f.set(u,b),c(b,u,p,f,y),b}if(u instanceof Error){const b=new u.constructor;return f.set(u,b),b.message=u.message,b.name=u.name,b.stack=u.stack,b.cause=u.cause,c(b,u,p,f,y),b}if(u instanceof Boolean){const b=new Boolean(u.valueOf());return f.set(u,b),c(b,u,p,f,y),b}if(u instanceof Number){const b=new Number(u.valueOf());return f.set(u,b),c(b,u,p,f,y),b}if(u instanceof String){const b=new String(u.valueOf());return f.set(u,b),c(b,u,p,f,y),b}if(typeof u==\"object\"&&d(u)){const b=Object.create(Object.getPrototypeOf(u));return f.set(u,b),c(b,u,p,f,y),b}return u}function c(u,m,p=u,f,y){const v=[...Object.keys(m),...e.getSymbols(m)];for(let b=0;b<v.length;b++){const g=v[b],_=Object.getOwnPropertyDescriptor(u,g);(_==null||_.writable)&&(u[g]=l(m[g],g,p,f,y))}}function d(u){switch(n.getTag(u)){case r.argumentsTag:case r.arrayTag:case r.arrayBufferTag:case r.dataViewTag:case r.booleanTag:case r.dateTag:case r.float32ArrayTag:case r.float64ArrayTag:case r.int8ArrayTag:case r.int16ArrayTag:case r.int32ArrayTag:case r.mapTag:case r.numberTag:case r.objectTag:case r.regexpTag:case r.setTag:case r.stringTag:case r.symbolTag:case r.uint8ArrayTag:case r.uint8ClampedArrayTag:case r.uint16ArrayTag:case r.uint32ArrayTag:return!0;default:return!1}}t.cloneDeepWith=o,t.cloneDeepWithImpl=l,t.copyProperties=c})(k2)),k2}var _V;function N2e(){return _V||(_V=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=uee();function n(r){return e.cloneDeepWithImpl(r,void 0,r,new Map,void 0)}t.cloneDeep=n})(_2)),_2}var kV;function C2e(){return kV||(kV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=cee(),n=N2e();function r(i){return i=n.cloneDeep(i),s=>e.isMatch(s,i)}t.matches=r})(b2)),b2}var A2={},j2={},M2={},NV;function P2e(){return NV||(NV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=uee(),n=MI(),r=dee();function i(s,o){return e.cloneDeepWith(s,(l,c,d,u)=>{const m=o?.(l,c,d,u);if(m!==void 0)return m;if(typeof s==\"object\"){if(n.getTag(s)===r.objectTag&&typeof s.constructor!=\"function\"){const p={};return u.set(s,p),e.copyProperties(p,s,d,u),p}switch(Object.prototype.toString.call(s)){case r.numberTag:case r.stringTag:case r.booleanTag:{const p=new s.constructor(s?.valueOf());return e.copyProperties(p,s),p}case r.argumentsTag:{const p={};return e.copyProperties(p,s),p.length=s.length,p[Symbol.iterator]=s[Symbol.iterator],p}default:return}}})}t.cloneDeepWith=i})(M2)),M2}var CV;function T2e(){return CV||(CV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=P2e();function n(r){return e.cloneDeepWith(r)}t.cloneDeep=n})(j2)),j2}var E2={},D2={},PV;function mee(){return PV||(PV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=/^(?:0|[1-9]\\d*)$/;function n(r,i=Number.MAX_SAFE_INTEGER){switch(typeof r){case\"number\":return Number.isInteger(r)&&r>=0&&r<i;case\"symbol\":return!1;case\"string\":return e.test(r)}}t.isIndex=n})(D2)),D2}var F2={},TV;function A2e(){return TV||(TV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=MI();function n(r){return r!==null&&typeof r==\"object\"&&e.getTag(r)===\"[object Arguments]\"}t.isArguments=n})(F2)),F2}var AV;function j2e(){return AV||(AV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=eee(),n=mee(),r=A2e(),i=CI();function s(o,l){let c;if(Array.isArray(l)?c=l:typeof l==\"string\"&&e.isDeepKey(l)&&o?.[l]==null?c=i.toPath(l):c=[l],c.length===0)return!1;let d=o;for(let u=0;u<c.length;u++){const m=c[u];if((d==null||!Object.hasOwn(d,m))&&!((Array.isArray(d)||r.isArguments(d))&&n.isIndex(m)&&m<d.length))return!1;d=d[m]}return!0}t.has=s})(E2)),E2}var jV;function M2e(){return jV||(jV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=cee(),n=NI(),r=T2e(),i=PI(),s=j2e();function o(l,c){switch(typeof l){case\"object\":{Object.is(l?.valueOf(),-0)&&(l=\"-0\");break}case\"number\":{l=n.toKey(l);break}}return c=r.cloneDeep(c),function(d){const u=i.get(d,l);return u===void 0?s.has(d,l):c===void 0?u===void 0:e.isMatch(u,c)}}t.matchesProperty=o})(A2)),A2}var MV;function E2e(){return MV||(MV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=iee(),n=w2e(),r=C2e(),i=M2e();function s(o){if(o==null)return e.identity;switch(typeof o){case\"function\":return o;case\"object\":return Array.isArray(o)&&o.length===2?i.matchesProperty(o[0],o[1]):r.matches(o);case\"string\":case\"symbol\":case\"number\":return n.property(o)}}t.iteratee=s})(f2)),f2}var EV;function D2e(){return EV||(EV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=g2e(),n=b2e(),r=iee(),i=v2e(),s=E2e();function o(l,c=r.identity){return i.isArrayLikeObject(l)?e.uniqBy(Array.from(l),n.ary(s.iteratee(c),1)):[]}t.uniqBy=o})(o2)),o2}var R2,DV;function F2e(){return DV||(DV=1,R2=D2e().uniqBy),R2}var R2e=F2e();const FV=bc(R2e);function hee(t,e,n){return e===!0?FV(t,n):typeof e==\"function\"?FV(t,e):t}var L2={exports:{}},O2={};var RV;function L2e(){if(RV)return O2;RV=1;var t=Rg(),e=WY();function n(d,u){return d===u&&(d!==0||1/d===1/u)||d!==d&&u!==u}var r=typeof Object.is==\"function\"?Object.is:n,i=e.useSyncExternalStore,s=t.useRef,o=t.useEffect,l=t.useMemo,c=t.useDebugValue;return O2.useSyncExternalStoreWithSelector=function(d,u,m,p,f){var y=s(null);if(y.current===null){var v={hasValue:!1,value:null};y.current=v}else v=y.current;y=l(function(){function g(A){if(!_){if(_=!0,C=A,A=p(A),f!==void 0&&v.hasValue){var T=v.value;if(f(T,A))return P=T}return P=A}if(T=P,r(C,A))return T;var F=p(A);return f!==void 0&&f(T,F)?(C=A,T):(C=A,P=F)}var _=!1,C,P,N=m===void 0?null:m;return[function(){return g(u())},N===null?void 0:function(){return g(N())}]},[u,m,p,f]);var b=i(d,y[0],y[1]);return o(function(){v.hasValue=!0,v.value=b},[b]),c(b),b},O2}var LV;function O2e(){return LV||(LV=1,L2.exports=L2e()),L2.exports}var pee=O2e(),EI=w.createContext(null),I2e=t=>t,ua=()=>{var t=w.useContext(EI);return t?t.store.dispatch:I2e},Xk=()=>{},z2e=()=>Xk,U2e=(t,e)=>t===e;function sn(t){var e=w.useContext(EI),n=w.useMemo(()=>e?r=>{if(r!=null)return t(r)}:Xk,[e,t]);return pee.useSyncExternalStoreWithSelector(e?e.subscription.addNestedSub:z2e,e?e.store.getState:Xk,e?e.store.getState:Xk,n,U2e)}function B2e(t,e=`expected a function, instead received ${typeof t}`){if(typeof t!=\"function\")throw new TypeError(e)}function H2e(t,e=`expected an object, instead received ${typeof t}`){if(typeof t!=\"object\")throw new TypeError(e)}function q2e(t,e=\"expected all items to be functions, instead received the following types: \"){if(!t.every(n=>typeof n==\"function\")){const n=t.map(r=>typeof r==\"function\"?`function ${r.name||\"unnamed\"}()`:typeof r).join(\", \");throw new TypeError(`${e}[${n}]`)}}var OV=t=>Array.isArray(t)?t:[t];function $2e(t){const e=Array.isArray(t[0])?t[0]:t;return q2e(e,\"createSelector expects all input-selectors to be functions, but received the following types: \"),e}function V2e(t,e){const n=[],{length:r}=t;for(let i=0;i<r;i++)n.push(t[i].apply(null,e));return n}var G2e=class{constructor(t){this.value=t}deref(){return this.value}},W2e=typeof WeakRef<\"u\"?WeakRef:G2e,K2e=0,IV=1;function J1(){return{s:K2e,v:void 0,o:null,p:null}}function fee(t,e={}){let n=J1();const{resultEqualityCheck:r}=e;let i,s=0;function o(){let l=n;const{length:c}=arguments;for(let m=0,p=c;m<p;m++){const f=arguments[m];if(typeof f==\"function\"||typeof f==\"object\"&&f!==null){let y=l.o;y===null&&(l.o=y=new WeakMap);const v=y.get(f);v===void 0?(l=J1(),y.set(f,l)):l=v}else{let y=l.p;y===null&&(l.p=y=new Map);const v=y.get(f);v===void 0?(l=J1(),y.set(f,l)):l=v}}const d=l;let u;if(l.s===IV)u=l.v;else if(u=t.apply(null,arguments),s++,r){const m=i?.deref?.()??i;m!=null&&r(m,u)&&(u=m,s!==0&&s--),i=typeof u==\"object\"&&u!==null||typeof u==\"function\"?new W2e(u):u}return d.s=IV,d.v=u,u}return o.clearCache=()=>{n=J1(),o.resetResultsCount()},o.resultsCount=()=>s,o.resetResultsCount=()=>{s=0},o}function X2e(t,...e){const n=typeof t==\"function\"?{memoize:t,memoizeOptions:e}:t,r=(...i)=>{let s=0,o=0,l,c={},d=i.pop();typeof d==\"object\"&&(c=d,d=i.pop()),B2e(d,`createSelector expects an output function after the inputs, but received: [${typeof d}]`);const u={...n,...c},{memoize:m,memoizeOptions:p=[],argsMemoize:f=fee,argsMemoizeOptions:y=[]}=u,v=OV(p),b=OV(y),g=$2e(i),_=m(function(){return s++,d.apply(null,arguments)},...v),C=f(function(){o++;const N=V2e(g,arguments);return l=_.apply(null,N),l},...b);return Object.assign(C,{resultFunc:d,memoizedResultFunc:_,dependencies:g,dependencyRecomputations:()=>o,resetDependencyRecomputations:()=>{o=0},lastResult:()=>l,recomputations:()=>s,resetRecomputations:()=>{s=0},memoize:m,argsMemoize:f})};return Object.assign(r,{withTypes:()=>r}),r}var wt=X2e(fee),Y2e=Object.assign((t,e=wt)=>{H2e(t,`createStructuredSelector expects first argument to be an object where each property is a selector, instead received a ${typeof t}`);const n=Object.keys(t),r=n.map(s=>t[s]);return e(r,(...s)=>s.reduce((o,l,c)=>(o[n[c]]=l,o),{}))},{withTypes:()=>Y2e}),I2={},z2={},U2={},zV;function Q2e(){return zV||(zV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(r){return typeof r==\"symbol\"?1:r===null?2:r===void 0?3:r!==r?4:0}const n=(r,i,s)=>{if(r!==i){const o=e(r),l=e(i);if(o===l&&o===0){if(r<i)return s===\"desc\"?1:-1;if(r>i)return s===\"desc\"?-1:1}return s===\"desc\"?l-o:o-l}return 0};t.compareValues=n})(U2)),U2}var B2={},H2={},UV;function gee(){return UV||(UV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return typeof n==\"symbol\"||n instanceof Symbol}t.isSymbol=e})(H2)),H2}var BV;function Z2e(){return BV||(BV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=gee(),n=/\\.|\\[(?:[^[\\]]*|([\"'])(?:(?!\\1)[^\\\\]|\\\\.)*?\\1)\\]/,r=/^\\w*$/;function i(s,o){return Array.isArray(s)?!1:typeof s==\"number\"||typeof s==\"boolean\"||s==null||e.isSymbol(s)?!0:typeof s==\"string\"&&(r.test(s)||!n.test(s))||o!=null&&Object.hasOwn(o,s)}t.isKey=i})(B2)),B2}var HV;function J2e(){return HV||(HV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=Q2e(),n=Z2e(),r=CI();function i(s,o,l,c){if(s==null)return[];l=c?void 0:l,Array.isArray(s)||(s=Object.values(s)),Array.isArray(o)||(o=o==null?[null]:[o]),o.length===0&&(o=[null]),Array.isArray(l)||(l=l==null?[]:[l]),l=l.map(f=>String(f));const d=(f,y)=>{let v=f;for(let b=0;b<y.length&&v!=null;++b)v=v[y[b]];return v},u=(f,y)=>y==null||f==null?y:typeof f==\"object\"&&\"key\"in f?Object.hasOwn(y,f.key)?y[f.key]:d(y,f.path):typeof f==\"function\"?f(y):Array.isArray(f)?d(y,f):typeof y==\"object\"?y[f]:y,m=o.map(f=>(Array.isArray(f)&&f.length===1&&(f=f[0]),f==null||typeof f==\"function\"||Array.isArray(f)||n.isKey(f)?f:{key:f,path:r.toPath(f)}));return s.map(f=>({original:f,criteria:m.map(y=>u(y,f))})).slice().sort((f,y)=>{for(let v=0;v<m.length;v++){const b=e.compareValues(f.criteria[v],y.criteria[v],l[v]);if(b!==0)return b}return 0}).map(f=>f.original)}t.orderBy=i})(z2)),z2}var q2={},qV;function eDe(){return qV||(qV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n,r=1){const i=[],s=Math.floor(r),o=(l,c)=>{for(let d=0;d<l.length;d++){const u=l[d];Array.isArray(u)&&c<s?o(u,c+1):i.push(u)}};return o(n,0),i}t.flatten=e})(q2)),q2}var $2={},$V;function bee(){return $V||($V=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=mee(),n=jI(),r=see(),i=lee();function s(o,l,c){return r.isObject(c)&&(typeof l==\"number\"&&n.isArrayLike(c)&&e.isIndex(l)&&l<c.length||typeof l==\"string\"&&l in c)?i.isEqualsSameValueZero(c[l],o):!1}t.isIterateeCall=s})($2)),$2}var VV;function tDe(){return VV||(VV=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=J2e(),n=eDe(),r=bee();function i(s,...o){const l=o.length;return l>1&&r.isIterateeCall(s,o[0],o[1])?o=[]:l>2&&r.isIterateeCall(o[0],o[1],o[2])&&(o=[o[0]]),e.orderBy(s,n.flatten(o),[\"asc\"])}t.sortBy=i})(I2)),I2}var V2,GV;function nDe(){return GV||(GV=1,V2=tDe().sortBy),V2}var rDe=nDe();const cT=bc(rDe);var xee=t=>t.legend.settings,aDe=t=>t.legend.size,iDe=t=>t.legend.payload,sDe=wt([iDe,xee],(t,e)=>{var{itemSorter:n}=e,r=t.flat(1);return n?cT(r,n):r});function oDe(){return sn(sDe)}var ek=1;function yee(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:[],[e,n]=w.useState({height:0,left:0,top:0,width:0}),r=w.useCallback(i=>{if(i!=null){var s=i.getBoundingClientRect(),o={height:s.height,left:s.left,top:s.top,width:s.width};(Math.abs(o.height-e.height)>ek||Math.abs(o.left-e.left)>ek||Math.abs(o.top-e.top)>ek||Math.abs(o.width-e.width)>ek)&&n({height:o.height,left:o.left,top:o.top,width:o.width})}},[e.width,e.height,e.top,e.left,...t]);return[e,r]}function hs(t){return`Minified Redux error #${t}; visit https://redux.js.org/Errors?code=${t} for the full message or use the non-minified dev environment for full errors. `}var lDe=typeof Symbol==\"function\"&&Symbol.observable||\"@@observable\",WV=lDe,G2=()=>Math.random().toString(36).substring(7).split(\"\").join(\".\"),cDe={INIT:`@@redux/INIT${G2()}`,REPLACE:`@@redux/REPLACE${G2()}`,PROBE_UNKNOWN_ACTION:()=>`@@redux/PROBE_UNKNOWN_ACTION${G2()}`},WN=cDe;function DI(t){if(typeof t!=\"object\"||t===null)return!1;let e=t;for(;Object.getPrototypeOf(e)!==null;)e=Object.getPrototypeOf(e);return Object.getPrototypeOf(t)===e||Object.getPrototypeOf(t)===null}function vee(t,e,n){if(typeof t!=\"function\")throw new Error(hs(2));if(typeof e==\"function\"&&typeof n==\"function\"||typeof n==\"function\"&&typeof arguments[3]==\"function\")throw new Error(hs(0));if(typeof e==\"function\"&&typeof n>\"u\"&&(n=e,e=void 0),typeof n<\"u\"){if(typeof n!=\"function\")throw new Error(hs(1));return n(vee)(t,e)}let r=t,i=e,s=new Map,o=s,l=0,c=!1;function d(){o===s&&(o=new Map,s.forEach((b,g)=>{o.set(g,b)}))}function u(){if(c)throw new Error(hs(3));return i}function m(b){if(typeof b!=\"function\")throw new Error(hs(4));if(c)throw new Error(hs(5));let g=!0;d();const _=l++;return o.set(_,b),function(){if(g){if(c)throw new Error(hs(6));g=!1,d(),o.delete(_),s=null}}}function p(b){if(!DI(b))throw new Error(hs(7));if(typeof b.type>\"u\")throw new Error(hs(8));if(typeof b.type!=\"string\")throw new Error(hs(17));if(c)throw new Error(hs(9));try{c=!0,i=r(i,b)}finally{c=!1}return(s=o).forEach(_=>{_()}),b}function f(b){if(typeof b!=\"function\")throw new Error(hs(10));r=b,p({type:WN.REPLACE})}function y(){const b=m;return{subscribe(g){if(typeof g!=\"object\"||g===null)throw new Error(hs(11));function _(){const P=g;P.next&&P.next(u())}return _(),{unsubscribe:b(_)}},[WV](){return this}}}return p({type:WN.INIT}),{dispatch:p,subscribe:m,getState:u,replaceReducer:f,[WV]:y}}function dDe(t){Object.keys(t).forEach(e=>{const n=t[e];if(typeof n(void 0,{type:WN.INIT})>\"u\")throw new Error(hs(12));if(typeof n(void 0,{type:WN.PROBE_UNKNOWN_ACTION()})>\"u\")throw new Error(hs(13))})}function wee(t){const e=Object.keys(t),n={};for(let s=0;s<e.length;s++){const o=e[s];typeof t[o]==\"function\"&&(n[o]=t[o])}const r=Object.keys(n);let i;try{dDe(n)}catch(s){i=s}return function(o={},l){if(i)throw i;let c=!1;const d={};for(let u=0;u<r.length;u++){const m=r[u],p=n[m],f=o[m],y=p(f,l);if(typeof y>\"u\")throw l&&l.type,new Error(hs(14));d[m]=y,c=c||y!==f}return c=c||r.length!==Object.keys(o).length,c?d:o}}function KN(...t){return t.length===0?e=>e:t.length===1?t[0]:t.reduce((e,n)=>(...r)=>e(n(...r)))}function uDe(...t){return e=>(n,r)=>{const i=e(n,r);let s=()=>{throw new Error(hs(15))};const o={getState:i.getState,dispatch:(c,...d)=>s(c,...d)},l=t.map(c=>c(o));return s=KN(...l)(i.dispatch),{...i,dispatch:s}}}function See(t){return DI(t)&&\"type\"in t&&typeof t.type==\"string\"}var _ee=Symbol.for(\"immer-nothing\"),KV=Symbol.for(\"immer-draftable\"),ho=Symbol.for(\"immer-state\");function Bc(t,...e){throw new Error(`[Immer] minified error nr: ${t}. Full error at: https://bit.ly/3cXEKWf`)}var ml=Object,ey=ml.getPrototypeOf,XN=\"constructor\",dT=\"prototype\",CR=\"configurable\",YN=\"enumerable\",Yk=\"writable\",yw=\"value\",_m=t=>!!t&&!!t[ho];function Jc(t){return t?kee(t)||mT(t)||!!t[KV]||!!t[XN]?.[KV]||hT(t)||pT(t):!1}var mDe=ml[dT][XN].toString(),XV=new WeakMap;function kee(t){if(!t||!FI(t))return!1;const e=ey(t);if(e===null||e===ml[dT])return!0;const n=ml.hasOwnProperty.call(e,XN)&&e[XN];if(n===Object)return!0;if(!hx(n))return!1;let r=XV.get(n);return r===void 0&&(r=Function.toString.call(n),XV.set(n,r)),r===mDe}function uT(t,e,n=!0){pS(t)===0?(n?Reflect.ownKeys(t):ml.keys(t)).forEach(i=>{e(i,t[i],t)}):t.forEach((r,i)=>e(i,r,t))}function pS(t){const e=t[ho];return e?e.type_:mT(t)?1:hT(t)?2:pT(t)?3:0}var YV=(t,e,n=pS(t))=>n===2?t.has(e):ml[dT].hasOwnProperty.call(t,e),PR=(t,e,n=pS(t))=>n===2?t.get(e):t[e],QN=(t,e,n,r=pS(t))=>{r===2?t.set(e,n):r===3?t.add(n):t[e]=n};function hDe(t,e){return t===e?t!==0||1/t===1/e:t!==t&&e!==e}var mT=Array.isArray,hT=t=>t instanceof Map,pT=t=>t instanceof Set,FI=t=>typeof t==\"object\",hx=t=>typeof t==\"function\",W2=t=>typeof t==\"boolean\";function pDe(t){const e=+t;return Number.isInteger(e)&&String(e)===t}var $u=t=>t.copy_||t.base_,RI=t=>t.modified_?t.copy_:t.base_;function TR(t,e){if(hT(t))return new Map(t);if(pT(t))return new Set(t);if(mT(t))return Array[dT].slice.call(t);const n=kee(t);if(e===!0||e===\"class_only\"&&!n){const r=ml.getOwnPropertyDescriptors(t);delete r[ho];let i=Reflect.ownKeys(r);for(let s=0;s<i.length;s++){const o=i[s],l=r[o];l[Yk]===!1&&(l[Yk]=!0,l[CR]=!0),(l.get||l.set)&&(r[o]={[CR]:!0,[Yk]:!0,[YN]:l[YN],[yw]:t[o]})}return ml.create(ey(t),r)}else{const r=ey(t);if(r!==null&&n)return{...t};const i=ml.create(r);return ml.assign(i,t)}}function LI(t,e=!1){return fT(t)||_m(t)||!Jc(t)||(pS(t)>1&&ml.defineProperties(t,{set:tk,add:tk,clear:tk,delete:tk}),ml.freeze(t),e&&uT(t,(n,r)=>{LI(r,!0)},!1)),t}function fDe(){Bc(2)}var tk={[yw]:fDe};function fT(t){return t===null||!FI(t)?!0:ml.isFrozen(t)}var ZN=\"MapSet\",AR=\"Patches\",QV=\"ArrayMethods\",Nee={};function kg(t){const e=Nee[t];return e||Bc(0,t),e}var ZV=t=>!!Nee[t],vw,Cee=()=>vw,gDe=(t,e)=>({drafts_:[],parent_:t,immer_:e,canAutoFreeze_:!0,unfinalizedDrafts_:0,handledSet_:new Set,processedForPatches_:new Set,mapSetPlugin_:ZV(ZN)?kg(ZN):void 0,arrayMethodsPlugin_:ZV(QV)?kg(QV):void 0});function JV(t,e){e&&(t.patchPlugin_=kg(AR),t.patches_=[],t.inversePatches_=[],t.patchListener_=e)}function jR(t){MR(t),t.drafts_.forEach(bDe),t.drafts_=null}function MR(t){t===vw&&(vw=t.parent_)}var e7=t=>vw=gDe(vw,t);function bDe(t){const e=t[ho];e.type_===0||e.type_===1?e.revoke_():e.revoked_=!0}function t7(t,e){e.unfinalizedDrafts_=e.drafts_.length;const n=e.drafts_[0];if(t!==void 0&&t!==n){n[ho].modified_&&(jR(e),Bc(4)),Jc(t)&&(t=n7(e,t));const{patchPlugin_:i}=e;i&&i.generateReplacementPatches_(n[ho].base_,t,e)}else t=n7(e,n);return xDe(e,t,!0),jR(e),e.patches_&&e.patchListener_(e.patches_,e.inversePatches_),t!==_ee?t:void 0}function n7(t,e){if(fT(e))return e;const n=e[ho];if(!n)return JN(e,t.handledSet_,t);if(!gT(n,t))return e;if(!n.modified_)return n.base_;if(!n.finalized_){const{callbacks_:r}=n;if(r)for(;r.length>0;)r.pop()(t);Aee(n,t)}return n.copy_}function xDe(t,e,n=!1){!t.parent_&&t.immer_.autoFreeze_&&t.canAutoFreeze_&&LI(e,n)}function Pee(t){t.finalized_=!0,t.scope_.unfinalizedDrafts_--}var gT=(t,e)=>t.scope_===e,yDe=[];function Tee(t,e,n,r){const i=$u(t),s=t.type_;if(r!==void 0&&PR(i,r,s)===e){QN(i,r,n,s);return}if(!t.draftLocations_){const l=t.draftLocations_=new Map;uT(i,(c,d)=>{if(_m(d)){const u=l.get(d)||[];u.push(c),l.set(d,u)}})}const o=t.draftLocations_.get(e)??yDe;for(const l of o)QN(i,l,n,s)}function vDe(t,e,n){t.callbacks_.push(function(i){const s=e;if(!s||!gT(s,i))return;i.mapSetPlugin_?.fixSetContents(s);const o=RI(s);Tee(t,s.draft_??s,o,n),Aee(s,i)})}function Aee(t,e){if(t.modified_&&!t.finalized_&&(t.type_===3||t.type_===1&&t.allIndicesReassigned_||(t.assigned_?.size??0)>0)){const{patchPlugin_:r}=e;if(r){const i=r.getPath(t);i&&r.generatePatches_(t,i,e)}Pee(t)}}function wDe(t,e,n){const{scope_:r}=t;if(_m(n)){const i=n[ho];gT(i,r)&&i.callbacks_.push(function(){Qk(t);const o=RI(i);Tee(t,n,o,e)})}else Jc(n)&&t.callbacks_.push(function(){const s=$u(t);t.type_===3?s.has(n)&&JN(n,r.handledSet_,r):PR(s,e,t.type_)===n&&r.drafts_.length>1&&(t.assigned_.get(e)??!1)===!0&&t.copy_&&JN(PR(t.copy_,e,t.type_),r.handledSet_,r)})}function JN(t,e,n){return!n.immer_.autoFreeze_&&n.unfinalizedDrafts_<1||_m(t)||e.has(t)||!Jc(t)||fT(t)||(e.add(t),uT(t,(r,i)=>{if(_m(i)){const s=i[ho];if(gT(s,n)){const o=RI(s);QN(t,r,o,t.type_),Pee(s)}}else Jc(i)&&JN(i,e,n)})),t}function SDe(t,e){const n=mT(t),r={type_:n?1:0,scope_:e?e.scope_:Cee(),modified_:!1,finalized_:!1,assigned_:void 0,parent_:e,base_:t,draft_:null,copy_:null,revoke_:null,isManual_:!1,callbacks_:void 0};let i=r,s=eC;n&&(i=[r],s=ww);const{revoke:o,proxy:l}=Proxy.revocable(i,s);return r.draft_=l,r.revoke_=o,[l,r]}var eC={get(t,e){if(e===ho)return t;let n=t.scope_.arrayMethodsPlugin_;const r=t.type_===1&&typeof e==\"string\";if(r&&n?.isArrayOperationMethod(e))return n.createMethodInterceptor(t,e);const i=$u(t);if(!YV(i,e,t.type_))return _De(t,i,e);const s=i[e];if(t.finalized_||!Jc(s)||r&&t.operationMethod&&n?.isMutatingArrayMethod(t.operationMethod)&&pDe(e))return s;if(s===K2(t.base_,e)){Qk(t);const o=t.type_===1?+e:e,l=DR(t.scope_,s,t,o);return t.copy_[o]=l}return s},has(t,e){return e in $u(t)},ownKeys(t){return Reflect.ownKeys($u(t))},set(t,e,n){const r=jee($u(t),e);if(r?.set)return r.set.call(t.draft_,n),!0;if(!t.modified_){const i=K2($u(t),e),s=i?.[ho];if(s&&s.base_===n)return t.copy_[e]=n,t.assigned_.set(e,!1),!0;if(hDe(n,i)&&(n!==void 0||YV(t.base_,e,t.type_)))return!0;Qk(t),ER(t)}return t.copy_[e]===n&&(n!==void 0||e in t.copy_)||Number.isNaN(n)&&Number.isNaN(t.copy_[e])||(t.copy_[e]=n,t.assigned_.set(e,!0),wDe(t,e,n)),!0},deleteProperty(t,e){return Qk(t),K2(t.base_,e)!==void 0||e in t.base_?(t.assigned_.set(e,!1),ER(t)):t.assigned_.delete(e),t.copy_&&delete t.copy_[e],!0},getOwnPropertyDescriptor(t,e){const n=$u(t),r=Reflect.getOwnPropertyDescriptor(n,e);return r&&{[Yk]:!0,[CR]:t.type_!==1||e!==\"length\",[YN]:r[YN],[yw]:n[e]}},defineProperty(){Bc(11)},getPrototypeOf(t){return ey(t.base_)},setPrototypeOf(){Bc(12)}},ww={};for(let t in eC){let e=eC[t];ww[t]=function(){const n=arguments;return n[0]=n[0][0],e.apply(this,n)}}ww.deleteProperty=function(t,e){return ww.set.call(this,t,e,void 0)};ww.set=function(t,e,n){return eC.set.call(this,t[0],e,n,t[0])};function K2(t,e){const n=t[ho];return(n?$u(n):t)[e]}function _De(t,e,n){const r=jee(e,n);return r?yw in r?r[yw]:r.get?.call(t.draft_):void 0}function jee(t,e){if(!(e in t))return;let n=ey(t);for(;n;){const r=Object.getOwnPropertyDescriptor(n,e);if(r)return r;n=ey(n)}}function ER(t){t.modified_||(t.modified_=!0,t.parent_&&ER(t.parent_))}function Qk(t){t.copy_||(t.assigned_=new Map,t.copy_=TR(t.base_,t.scope_.immer_.useStrictShallowCopy_))}var kDe=class{constructor(e){this.autoFreeze_=!0,this.useStrictShallowCopy_=!1,this.useStrictIteration_=!1,this.produce=(n,r,i)=>{if(hx(n)&&!hx(r)){const o=r;r=n;const l=this;return function(d=o,...u){return l.produce(d,m=>r.call(this,m,...u))}}hx(r)||Bc(6),i!==void 0&&!hx(i)&&Bc(7);let s;if(Jc(n)){const o=e7(this),l=DR(o,n,void 0);let c=!0;try{s=r(l),c=!1}finally{c?jR(o):MR(o)}return JV(o,i),t7(s,o)}else if(!n||!FI(n)){if(s=r(n),s===void 0&&(s=n),s===_ee&&(s=void 0),this.autoFreeze_&&LI(s,!0),i){const o=[],l=[];kg(AR).generateReplacementPatches_(n,s,{patches_:o,inversePatches_:l}),i(o,l)}return s}else Bc(1,n)},this.produceWithPatches=(n,r)=>{if(hx(n))return(l,...c)=>this.produceWithPatches(l,d=>n(d,...c));let i,s;return[this.produce(n,r,(l,c)=>{i=l,s=c}),i,s]},W2(e?.autoFreeze)&&this.setAutoFreeze(e.autoFreeze),W2(e?.useStrictShallowCopy)&&this.setUseStrictShallowCopy(e.useStrictShallowCopy),W2(e?.useStrictIteration)&&this.setUseStrictIteration(e.useStrictIteration)}createDraft(e){Jc(e)||Bc(8),_m(e)&&(e=Xc(e));const n=e7(this),r=DR(n,e,void 0);return r[ho].isManual_=!0,MR(n),r}finishDraft(e,n){const r=e&&e[ho];(!r||!r.isManual_)&&Bc(9);const{scope_:i}=r;return JV(i,n),t7(void 0,i)}setAutoFreeze(e){this.autoFreeze_=e}setUseStrictShallowCopy(e){this.useStrictShallowCopy_=e}setUseStrictIteration(e){this.useStrictIteration_=e}shouldUseStrictIteration(){return this.useStrictIteration_}applyPatches(e,n){let r;for(r=n.length-1;r>=0;r--){const s=n[r];if(s.path.length===0&&s.op===\"replace\"){e=s.value;break}}r>-1&&(n=n.slice(r+1));const i=kg(AR).applyPatches_;return _m(e)?i(e,n):this.produce(e,s=>i(s,n))}};function DR(t,e,n,r){const[i,s]=hT(e)?kg(ZN).proxyMap_(e,n):pT(e)?kg(ZN).proxySet_(e,n):SDe(e,n);return(n?.scope_??Cee()).drafts_.push(i),s.callbacks_=n?.callbacks_??[],s.key_=r,n&&r!==void 0?vDe(n,s,r):s.callbacks_.push(function(c){c.mapSetPlugin_?.fixSetContents(s);const{patchPlugin_:d}=c;s.modified_&&d&&d.generatePatches_(s,[],c)}),i}function Xc(t){return _m(t)||Bc(10,t),Mee(t)}function Mee(t){if(!Jc(t)||fT(t))return t;const e=t[ho];let n,r=!0;if(e){if(!e.modified_)return e.base_;e.finalized_=!0,n=TR(t,e.scope_.immer_.useStrictShallowCopy_),r=e.scope_.immer_.shouldUseStrictIteration()}else n=TR(t,!0);return uT(n,(i,s)=>{QN(n,i,Mee(s))},r),e&&(e.finalized_=!1),n}var NDe=new kDe,Eee=NDe.produce;function Dee(t){return({dispatch:n,getState:r})=>i=>s=>typeof s==\"function\"?s(n,r,t):i(s)}var CDe=Dee(),PDe=Dee,TDe=typeof window<\"u\"&&window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__:function(){if(arguments.length!==0)return typeof arguments[0]==\"object\"?KN:KN.apply(null,arguments)};function pc(t,e){function n(...r){if(e){let i=e(...r);if(!i)throw new Error(gl(0));return{type:t,payload:i.payload,...\"meta\"in i&&{meta:i.meta},...\"error\"in i&&{error:i.error}}}return{type:t,payload:r[0]}}return n.toString=()=>`${t}`,n.type=t,n.match=r=>See(r)&&r.type===t,n}var Fee=class f0 extends Array{constructor(...e){super(...e),Object.setPrototypeOf(this,f0.prototype)}static get[Symbol.species](){return f0}concat(...e){return super.concat.apply(this,e)}prepend(...e){return e.length===1&&Array.isArray(e[0])?new f0(...e[0].concat(this)):new f0(...e.concat(this))}};function r7(t){return Jc(t)?Eee(t,()=>{}):t}function nk(t,e,n){return t.has(e)?t.get(e):t.set(e,n(e)).get(e)}function ADe(t){return typeof t==\"boolean\"}var jDe=()=>function(e){const{thunk:n=!0,immutableCheck:r=!0,serializableCheck:i=!0,actionCreatorCheck:s=!0}=e??{};let o=new Fee;return n&&(ADe(n)?o.push(CDe):o.push(PDe(n.extraArgument))),o},Ree=\"RTK_autoBatch\",Ta=()=>t=>({payload:t,meta:{[Ree]:!0}}),a7=t=>e=>{setTimeout(e,t)},Lee=(t={type:\"raf\"})=>e=>(...n)=>{const r=e(...n);let i=!0,s=!1,o=!1;const l=new Set,c=t.type===\"tick\"?queueMicrotask:t.type===\"raf\"?typeof window<\"u\"&&window.requestAnimationFrame?window.requestAnimationFrame:a7(10):t.type===\"callback\"?t.queueNotification:a7(t.timeout),d=()=>{o=!1,s&&(s=!1,l.forEach(u=>u()))};return Object.assign({},r,{subscribe(u){const m=()=>i&&u(),p=r.subscribe(m);return l.add(u),()=>{p(),l.delete(u)}},dispatch(u){try{return i=!u?.meta?.[Ree],s=!i,s&&(o||(o=!0,c(d))),r.dispatch(u)}finally{i=!0}}})},MDe=t=>function(n){const{autoBatch:r=!0}=n??{};let i=new Fee(t);return r&&i.push(Lee(typeof r==\"object\"?r:void 0)),i};function EDe(t){const e=jDe(),{reducer:n=void 0,middleware:r,devTools:i=!0,preloadedState:s=void 0,enhancers:o=void 0}=t||{};let l;if(typeof n==\"function\")l=n;else if(DI(n))l=wee(n);else throw new Error(gl(1));let c;typeof r==\"function\"?c=r(e):c=e();let d=KN;i&&(d=TDe({trace:!1,...typeof i==\"object\"&&i}));const u=uDe(...c),m=MDe(u);let p=typeof o==\"function\"?o(m):m();const f=d(...p);return vee(l,s,f)}function Oee(t){const e={},n=[];let r;const i={addCase(s,o){const l=typeof s==\"string\"?s:s.type;if(!l)throw new Error(gl(28));if(l in e)throw new Error(gl(29));return e[l]=o,i},addAsyncThunk(s,o){return o.pending&&(e[s.pending.type]=o.pending),o.rejected&&(e[s.rejected.type]=o.rejected),o.fulfilled&&(e[s.fulfilled.type]=o.fulfilled),o.settled&&n.push({matcher:s.settled,reducer:o.settled}),i},addMatcher(s,o){return n.push({matcher:s,reducer:o}),i},addDefaultCase(s){return r=s,i}};return t(i),[e,n,r]}function DDe(t){return typeof t==\"function\"}function FDe(t,e){let[n,r,i]=Oee(e),s;if(DDe(t))s=()=>r7(t());else{const l=r7(t);s=()=>l}function o(l=s(),c){let d=[n[c.type],...r.filter(({matcher:u})=>u(c)).map(({reducer:u})=>u)];return d.filter(u=>!!u).length===0&&(d=[i]),d.reduce((u,m)=>{if(m)if(_m(u)){const f=m(u,c);return f===void 0?u:f}else{if(Jc(u))return Eee(u,p=>m(p,c));{const p=m(u,c);if(p===void 0){if(u===null)return u;throw Error(\"A case reducer on a non-draftable value must not return undefined\")}return p}}return u},l)}return o.getInitialState=s,o}var RDe=\"ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW\",LDe=(t=21)=>{let e=\"\",n=t;for(;n--;)e+=RDe[Math.random()*64|0];return e},ODe=Symbol.for(\"rtk-slice-createasyncthunk\");function IDe(t,e){return`${t}/${e}`}function zDe({creators:t}={}){const e=t?.asyncThunk?.[ODe];return function(r){const{name:i,reducerPath:s=i}=r;if(!i)throw new Error(gl(11));const o=(typeof r.reducers==\"function\"?r.reducers(BDe()):r.reducers)||{},l=Object.keys(o),c={sliceCaseReducersByName:{},sliceCaseReducersByType:{},actionCreators:{},sliceMatchers:[]},d={addCase(C,P){const N=typeof C==\"string\"?C:C.type;if(!N)throw new Error(gl(12));if(N in c.sliceCaseReducersByType)throw new Error(gl(13));return c.sliceCaseReducersByType[N]=P,d},addMatcher(C,P){return c.sliceMatchers.push({matcher:C,reducer:P}),d},exposeAction(C,P){return c.actionCreators[C]=P,d},exposeCaseReducer(C,P){return c.sliceCaseReducersByName[C]=P,d}};l.forEach(C=>{const P=o[C],N={reducerName:C,type:IDe(i,C),createNotation:typeof r.reducers==\"function\"};qDe(P)?VDe(N,P,d,e):HDe(N,P,d)});function u(){const[C={},P=[],N=void 0]=typeof r.extraReducers==\"function\"?Oee(r.extraReducers):[r.extraReducers],A={...C,...c.sliceCaseReducersByType};return FDe(r.initialState,T=>{for(let F in A)T.addCase(F,A[F]);for(let F of c.sliceMatchers)T.addMatcher(F.matcher,F.reducer);for(let F of P)T.addMatcher(F.matcher,F.reducer);N&&T.addDefaultCase(N)})}const m=C=>C,p=new Map,f=new WeakMap;let y;function v(C,P){return y||(y=u()),y(C,P)}function b(){return y||(y=u()),y.getInitialState()}function g(C,P=!1){function N(T){let F=T[C];return typeof F>\"u\"&&P&&(F=nk(f,N,b)),F}function A(T=m){const F=nk(p,P,()=>new WeakMap);return nk(F,T,()=>{const k={};for(const[D,H]of Object.entries(r.selectors??{}))k[D]=UDe(H,T,()=>nk(f,T,b),P);return k})}return{reducerPath:C,getSelectors:A,get selectors(){return A(N)},selectSlice:N}}const _={name:i,reducer:v,actions:c.actionCreators,caseReducers:c.sliceCaseReducersByName,getInitialState:b,...g(s),injectInto(C,{reducerPath:P,...N}={}){const A=P??s;return C.inject({reducerPath:A,reducer:v},N),{..._,...g(A,!0)}}};return _}}function UDe(t,e,n,r){function i(s,...o){let l=e(s);return typeof l>\"u\"&&r&&(l=n()),t(l,...o)}return i.unwrapped=t,i}var $o=zDe();function BDe(){function t(e,n){return{_reducerDefinitionType:\"asyncThunk\",payloadCreator:e,...n}}return t.withTypes=()=>t,{reducer(e){return Object.assign({[e.name](...n){return e(...n)}}[e.name],{_reducerDefinitionType:\"reducer\"})},preparedReducer(e,n){return{_reducerDefinitionType:\"reducerWithPrepare\",prepare:e,reducer:n}},asyncThunk:t}}function HDe({type:t,reducerName:e,createNotation:n},r,i){let s,o;if(\"reducer\"in r){if(n&&!$De(r))throw new Error(gl(17));s=r.reducer,o=r.prepare}else s=r;i.addCase(t,s).exposeCaseReducer(e,s).exposeAction(e,o?pc(t,o):pc(t))}function qDe(t){return t._reducerDefinitionType===\"asyncThunk\"}function $De(t){return t._reducerDefinitionType===\"reducerWithPrepare\"}function VDe({type:t,reducerName:e},n,r,i){if(!i)throw new Error(gl(18));const{payloadCreator:s,fulfilled:o,pending:l,rejected:c,settled:d,options:u}=n,m=i(t,s,u);r.exposeAction(e,m),o&&r.addCase(m.fulfilled,o),l&&r.addCase(m.pending,l),c&&r.addCase(m.rejected,c),d&&r.addMatcher(m.settled,d),r.exposeCaseReducer(e,{fulfilled:o||rk,pending:l||rk,rejected:c||rk,settled:d||rk})}function rk(){}var GDe=\"task\",Iee=\"listener\",zee=\"completed\",OI=\"cancelled\",WDe=`task-${OI}`,KDe=`task-${zee}`,FR=`${Iee}-${OI}`,XDe=`${Iee}-${zee}`,bT=class{constructor(t){this.code=t,this.message=`${GDe} ${OI} (reason: ${t})`}name=\"TaskAbortError\";message},II=(t,e)=>{if(typeof t!=\"function\")throw new TypeError(gl(32))},tC=()=>{},Uee=(t,e=tC)=>(t.catch(e),t),Bee=(t,e)=>(t.addEventListener(\"abort\",e,{once:!0}),()=>t.removeEventListener(\"abort\",e)),Qf=t=>{if(t.aborted)throw new bT(t.reason)};function Hee(t,e){let n=tC;return new Promise((r,i)=>{const s=()=>i(new bT(t.reason));if(t.aborted){s();return}n=Bee(t,s),e.finally(()=>n()).then(r,i)}).finally(()=>{n=tC})}var YDe=async(t,e)=>{try{return await Promise.resolve(),{status:\"ok\",value:await t()}}catch(n){return{status:n instanceof bT?\"cancelled\":\"rejected\",error:n}}finally{e?.()}},nC=t=>e=>Uee(Hee(t,e).then(n=>(Qf(t),n))),qee=t=>{const e=nC(t);return n=>e(new Promise(r=>setTimeout(r,n)))},{assign:Fx}=Object,i7={},xT=\"listenerMiddleware\",QDe=(t,e)=>{const n=r=>Bee(t,()=>r.abort(t.reason));return(r,i)=>{II(r);const s=new AbortController;n(s);const o=YDe(async()=>{Qf(t),Qf(s.signal);const l=await r({pause:nC(s.signal),delay:qee(s.signal),signal:s.signal});return Qf(s.signal),l},()=>s.abort(KDe));return i?.autoJoin&&e.push(o.catch(tC)),{result:nC(t)(o),cancel(){s.abort(WDe)}}}},ZDe=(t,e)=>{const n=async(r,i)=>{Qf(e);let s=()=>{};const l=[new Promise((c,d)=>{let u=t({predicate:r,effect:(m,p)=>{p.unsubscribe(),c([m,p.getState(),p.getOriginalState()])}});s=()=>{u(),d()}})];i!=null&&l.push(new Promise(c=>setTimeout(c,i,null)));try{const c=await Hee(e,Promise.race(l));return Qf(e),c}finally{s()}};return(r,i)=>Uee(n(r,i))},$ee=t=>{let{type:e,actionCreator:n,matcher:r,predicate:i,effect:s}=t;if(e)i=pc(e).match;else if(n)e=n.type,i=n.match;else if(r)i=r;else if(!i)throw new Error(gl(21));return II(s),{predicate:i,type:e,effect:s}},Vee=Fx(t=>{const{type:e,predicate:n,effect:r}=$ee(t);return{id:LDe(),effect:r,type:e,predicate:n,pending:new Set,unsubscribe:()=>{throw new Error(gl(22))}}},{withTypes:()=>Vee}),s7=(t,e)=>{const{type:n,effect:r,predicate:i}=$ee(e);return Array.from(t.values()).find(s=>(typeof n==\"string\"?s.type===n:s.predicate===i)&&s.effect===r)},RR=t=>{t.pending.forEach(e=>{e.abort(FR)})},JDe=(t,e)=>()=>{for(const n of e.keys())RR(n);t.clear()},o7=(t,e,n)=>{try{t(e,n)}catch(r){setTimeout(()=>{throw r},0)}},Gee=Fx(pc(`${xT}/add`),{withTypes:()=>Gee}),e3e=pc(`${xT}/removeAll`),Wee=Fx(pc(`${xT}/remove`),{withTypes:()=>Wee}),t3e=(...t)=>{console.error(`${xT}/error`,...t)},fS=(t={})=>{const e=new Map,n=new Map,r=f=>{const y=n.get(f)??0;n.set(f,y+1)},i=f=>{const y=n.get(f)??1;y===1?n.delete(f):n.set(f,y-1)},{extra:s,onError:o=t3e}=t;II(o);const l=f=>(f.unsubscribe=()=>e.delete(f.id),e.set(f.id,f),y=>{f.unsubscribe(),y?.cancelActive&&RR(f)}),c=f=>{const y=s7(e,f)??Vee(f);return l(y)};Fx(c,{withTypes:()=>c});const d=f=>{const y=s7(e,f);return y&&(y.unsubscribe(),f.cancelActive&&RR(y)),!!y};Fx(d,{withTypes:()=>d});const u=async(f,y,v,b)=>{const g=new AbortController,_=ZDe(c,g.signal),C=[];try{f.pending.add(g),r(f),await Promise.resolve(f.effect(y,Fx({},v,{getOriginalState:b,condition:(P,N)=>_(P,N).then(Boolean),take:_,delay:qee(g.signal),pause:nC(g.signal),extra:s,signal:g.signal,fork:QDe(g.signal,C),unsubscribe:f.unsubscribe,subscribe:()=>{e.set(f.id,f)},cancelActiveListeners:()=>{f.pending.forEach((P,N,A)=>{P!==g&&(P.abort(FR),A.delete(P))})},cancel:()=>{g.abort(FR),f.pending.delete(g)},throwIfCancelled:()=>{Qf(g.signal)}})))}catch(P){P instanceof bT||o7(o,P,{raisedBy:\"effect\"})}finally{await Promise.all(C),g.abort(XDe),i(f),f.pending.delete(g)}},m=JDe(e,n);return{middleware:f=>y=>v=>{if(!See(v))return y(v);if(Gee.match(v))return c(v.payload);if(e3e.match(v)){m();return}if(Wee.match(v))return d(v.payload);let b=f.getState();const g=()=>{if(b===i7)throw new Error(gl(23));return b};let _;try{if(_=y(v),e.size>0){const C=f.getState(),P=Array.from(e.values());for(const N of P){let A=!1;try{A=N.predicate(v,C,b)}catch(T){A=!1,o7(o,T,{raisedBy:\"predicate\"})}A&&u(N,v,f,g)}}}finally{b=i7}return _},startListening:c,stopListening:d,clearListeners:m}};function gl(t){return`Minified Redux Toolkit error #${t}; visit https://redux-toolkit.js.org/Errors?code=${t} for the full message or use the non-minified dev environment for full errors. `}var n3e={layoutType:\"horizontal\",width:0,height:0,margin:{top:5,right:5,bottom:5,left:5},scale:1},Kee=$o({name:\"chartLayout\",initialState:n3e,reducers:{setLayout(t,e){t.layoutType=e.payload},setChartSize(t,e){t.width=e.payload.width,t.height=e.payload.height},setMargin(t,e){var n,r,i,s;t.margin.top=(n=e.payload.top)!==null&&n!==void 0?n:0,t.margin.right=(r=e.payload.right)!==null&&r!==void 0?r:0,t.margin.bottom=(i=e.payload.bottom)!==null&&i!==void 0?i:0,t.margin.left=(s=e.payload.left)!==null&&s!==void 0?s:0},setScale(t,e){t.scale=e.payload}}}),{setMargin:r3e,setLayout:a3e,setChartSize:i3e,setScale:s3e}=Kee.actions,o3e=Kee.reducer;function Xee(t,e,n){return Array.isArray(t)&&t&&e+n!==0?t.slice(e,n+1):t}function Gn(t){return Number.isFinite(t)}function qd(t){return typeof t==\"number\"&&t>0&&Number.isFinite(t)}function l7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function Sx(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?l7(Object(n),!0).forEach(function(r){l3e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):l7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function l3e(t,e,n){return(e=c3e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function c3e(t){var e=d3e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function d3e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function zr(t,e,n){return Ea(t)||Ea(e)?n:hc(e)?Sg(t,e,n):typeof e==\"function\"?e(t):n}var u3e=(t,e,n)=>{if(e&&n){var{width:r,height:i}=n,{align:s,verticalAlign:o,layout:l}=e;if((l===\"vertical\"||l===\"horizontal\"&&o===\"middle\")&&s!==\"center\"&&tn(t[s]))return Sx(Sx({},t),{},{[s]:t[s]+(r||0)});if((l===\"horizontal\"||l===\"vertical\"&&s===\"center\")&&o!==\"middle\"&&tn(t[o]))return Sx(Sx({},t),{},{[o]:t[o]+(i||0)})}return t},ad=(t,e)=>t===\"horizontal\"&&e===\"xAxis\"||t===\"vertical\"&&e===\"yAxis\"||t===\"centric\"&&e===\"angleAxis\"||t===\"radial\"&&e===\"radiusAxis\",Yee=(t,e,n,r)=>{if(r)return t.map(l=>l.coordinate);var i,s,o=t.map(l=>(l.coordinate===e&&(i=!0),l.coordinate===n&&(s=!0),l.coordinate));return i||o.push(e),s||o.push(n),o},Qee=(t,e,n)=>{if(!t)return null;var{duplicateDomain:r,type:i,range:s,scale:o,realScaleType:l,isCategorical:c,categoricalDomain:d,tickCount:u,ticks:m,niceTicks:p,axisType:f}=t;if(!o)return null;var y=l===\"scaleBand\"&&o.bandwidth?o.bandwidth()/2:2,v=i===\"category\"&&o.bandwidth?o.bandwidth()/y:0;if(v=f===\"angleAxis\"&&s&&s.length>=2?Qi(s[0]-s[1])*2*v:v,m||p){var b=(m||p||[]).map((g,_)=>{var C=r?r.indexOf(g):g,P=o.map(C);return Gn(P)?{coordinate:P+v,value:g,offset:v,index:_}:null}).filter(Oo);return b}return c&&d?d.map((g,_)=>{var C=o.map(g);return Gn(C)?{coordinate:C+v,value:g,index:_,offset:v}:null}).filter(Oo):o.ticks&&u!=null?o.ticks(u).map((g,_)=>{var C=o.map(g);return Gn(C)?{coordinate:C+v,value:g,index:_,offset:v}:null}).filter(Oo):o.domain().map((g,_)=>{var C=o.map(g);return Gn(C)?{coordinate:C+v,value:r?r[g]:g,index:_,offset:v}:null}).filter(Oo)},m3e=(t,e)=>{if(!e||e.length!==2||!tn(e[0])||!tn(e[1]))return t;var n=Math.min(e[0],e[1]),r=Math.max(e[0],e[1]),i=[t[0],t[1]];return(!tn(t[0])||t[0]<n)&&(i[0]=n),(!tn(t[1])||t[1]>r)&&(i[1]=r),i[0]>r&&(i[0]=r),i[1]<n&&(i[1]=n),i},h3e=t=>{var e,n=t.length;if(!(n<=0)){var r=(e=t[0])===null||e===void 0?void 0:e.length;if(!(r==null||r<=0))for(var i=0;i<r;++i)for(var s=0,o=0,l=0;l<n;++l){var c=t[l],d=c?.[i];if(d!=null){var u=d[1],m=d[0],p=Zc(u)?m:u;p>=0?(d[0]=s,s+=p,d[1]=s):(d[0]=o,o+=p,d[1]=o)}}}},p3e=t=>{var e,n=t.length;if(!(n<=0)){var r=(e=t[0])===null||e===void 0?void 0:e.length;if(!(r==null||r<=0))for(var i=0;i<r;++i)for(var s=0,o=0;o<n;++o){var l=t[o],c=l?.[i];if(c!=null){var d=Zc(c[1])?c[0]:c[1];d>=0?(c[0]=s,s+=d,c[1]=s):(c[0]=0,c[1]=0)}}}},f3e={sign:h3e,expand:IEe,none:wg,silhouette:zEe,wiggle:UEe,positive:p3e},g3e=(t,e,n)=>{var r,i=(r=f3e[n])!==null&&r!==void 0?r:wg,s=OEe().keys(e).value((l,c)=>Number(zr(l,c,0))).order(kR).offset(i),o=s(t);return o.forEach((l,c)=>{l.forEach((d,u)=>{var m=zr(t[u],e[c],0);Array.isArray(m)&&m.length===2&&tn(m[0])&&tn(m[1])&&(d[0]=m[0],d[1]=m[1])})}),o};function Zee(t){return t==null?void 0:String(t)}function rC(t){var{axis:e,ticks:n,bandSize:r,entry:i,index:s,dataKey:o}=t;if(e.type===\"category\"){if(!e.allowDuplicatedCategory&&e.dataKey&&!Ea(i[e.dataKey])){var l=nee(n,\"value\",i[e.dataKey]);if(l)return l.coordinate+r/2}return n!=null&&n[s]?n[s].coordinate+r/2:null}var c=zr(i,Ea(o)?e.dataKey:o),d=e.scale.map(c);return tn(d)?d:null}var c7=t=>{var{axis:e,ticks:n,offset:r,bandSize:i,entry:s,index:o}=t;if(e.type===\"category\")return n[o]?n[o].coordinate+r:null;var l=zr(s,e.dataKey,e.scale.domain()[o]);if(Ea(l))return null;var c=e.scale.map(l);return tn(c)?c-i/2+r:null},b3e=t=>{var{numericAxis:e}=t,n=e.scale.domain();if(e.type===\"number\"){var r=Math.min(n[0],n[1]),i=Math.max(n[0],n[1]);return r<=0&&i>=0?0:i<0?i:r}return n[0]},x3e=t=>{var e=t.flat(2).filter(tn);return[Math.min(...e),Math.max(...e)]},y3e=t=>[t[0]===1/0?0:t[0],t[1]===-1/0?0:t[1]],v3e=(t,e,n)=>{if(t!=null)return y3e(Object.keys(t).reduce((r,i)=>{var s=t[i];if(!s)return r;var{stackedData:o}=s,l=o.reduce((c,d)=>{var u=Xee(d,e,n),m=x3e(u);return!Gn(m[0])||!Gn(m[1])?c:[Math.min(c[0],m[0]),Math.max(c[1],m[1])]},[1/0,-1/0]);return[Math.min(l[0],r[0]),Math.max(l[1],r[1])]},[1/0,-1/0]))},d7=/^dataMin[\\s]*-[\\s]*([0-9]+([.]{1}[0-9]+){0,1})$/,u7=/^dataMax[\\s]*\\+[\\s]*([0-9]+([.]{1}[0-9]+){0,1})$/,wp=(t,e,n)=>{if(t&&t.scale&&t.scale.bandwidth){var r=t.scale.bandwidth();if(!n||r>0)return r}if(t&&e&&e.length>=2){for(var i=cT(e,u=>u.coordinate),s=1/0,o=1,l=i.length;o<l;o++){var c=i[o],d=i[o-1];s=Math.min((c?.coordinate||0)-(d?.coordinate||0),s)}return s===1/0?0:s}return n?void 0:0};function m7(t){var{tooltipEntrySettings:e,dataKey:n,payload:r,value:i,name:s}=t;return Sx(Sx({},e),{},{dataKey:n,payload:r,value:i,name:s})}function Ap(t,e){if(t)return String(t);if(typeof e==\"string\")return e}var w3e=(t,e)=>{if(e===\"horizontal\")return t.chartX;if(e===\"vertical\")return t.chartY},S3e=(t,e)=>e===\"centric\"?t.angle:t.radius,jm=t=>t.layout.width,Mm=t=>t.layout.height,_3e=t=>t.layout.scale,Jee=t=>t.layout.margin,yT=wt(t=>t.cartesianAxis.xAxis,t=>Object.values(t)),vT=wt(t=>t.cartesianAxis.yAxis,t=>Object.values(t)),ete=\"data-recharts-item-index\",tte=\"data-recharts-item-id\",gS=60;function h7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function ak(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?h7(Object(n),!0).forEach(function(r){k3e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):h7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function k3e(t,e,n){return(e=N3e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function N3e(t){var e=C3e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function C3e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var P3e=t=>t.brush.height;function T3e(t){var e=vT(t);return e.reduce((n,r)=>{if(r.orientation===\"left\"&&!r.mirror&&!r.hide){var i=typeof r.width==\"number\"?r.width:gS;return n+i}return n},0)}function A3e(t){var e=vT(t);return e.reduce((n,r)=>{if(r.orientation===\"right\"&&!r.mirror&&!r.hide){var i=typeof r.width==\"number\"?r.width:gS;return n+i}return n},0)}function j3e(t){var e=yT(t);return e.reduce((n,r)=>r.orientation===\"top\"&&!r.mirror&&!r.hide?n+r.height:n,0)}function M3e(t){var e=yT(t);return e.reduce((n,r)=>r.orientation===\"bottom\"&&!r.mirror&&!r.hide?n+r.height:n,0)}var Ri=wt([jm,Mm,Jee,P3e,T3e,A3e,j3e,M3e,xee,aDe],(t,e,n,r,i,s,o,l,c,d)=>{var u={left:(n.left||0)+i,right:(n.right||0)+s},m={top:(n.top||0)+o,bottom:(n.bottom||0)+l},p=ak(ak({},m),u),f=p.bottom;p.bottom+=r,p=u3e(p,c,d);var y=t-p.left-p.right,v=e-p.top-p.bottom;return ak(ak({brushBottom:f},p),{},{width:Math.max(y,0),height:Math.max(v,0)})}),E3e=wt(Ri,t=>({x:t.left,y:t.top,width:t.width,height:t.height})),zI=wt(jm,Mm,(t,e)=>({x:0,y:0,width:t,height:e})),D3e=w.createContext(null),Li=()=>w.useContext(D3e)!=null,wT=t=>t.brush,ST=wt([wT,Ri,Jee],(t,e,n)=>({height:t.height,x:tn(t.x)?t.x:e.left,y:tn(t.y)?t.y:e.top+e.height+e.brushBottom-(n?.bottom||0),width:tn(t.width)?t.width:e.width})),X2={},Y2={},Q2={},p7;function F3e(){return p7||(p7=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n,r,{signal:i,edges:s}={}){let o,l=null;const c=s!=null&&s.includes(\"leading\"),d=s==null||s.includes(\"trailing\"),u=()=>{l!==null&&(n.apply(o,l),o=void 0,l=null)},m=()=>{d&&u(),v()};let p=null;const f=()=>{p!=null&&clearTimeout(p),p=setTimeout(()=>{p=null,m()},r)},y=()=>{p!==null&&(clearTimeout(p),p=null)},v=()=>{y(),o=void 0,l=null},b=()=>{u()},g=function(..._){if(i?.aborted)return;o=this,l=_;const C=p==null;f(),c&&C&&u()};return g.schedule=f,g.cancel=v,g.flush=b,i?.addEventListener(\"abort\",v,{once:!0}),g}t.debounce=e})(Q2)),Q2}var f7;function R3e(){return f7||(f7=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=F3e();function n(r,i=0,s={}){typeof s!=\"object\"&&(s={});const{leading:o=!1,trailing:l=!0,maxWait:c}=s,d=Array(2);o&&(d[0]=\"leading\"),l&&(d[1]=\"trailing\");let u,m=null;const p=e.debounce(function(...v){u=r.apply(this,v),m=null},i,{edges:d}),f=function(...v){return c!=null&&(m===null&&(m=Date.now()),Date.now()-m>=c)?(u=r.apply(this,v),m=Date.now(),p.cancel(),p.schedule(),u):(p.apply(this,v),u)},y=()=>(p.flush(),u);return f.cancel=p.cancel,f.flush=y,f}t.debounce=n})(Y2)),Y2}var g7;function L3e(){return g7||(g7=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=R3e();function n(r,i=0,s={}){const{leading:o=!0,trailing:l=!0}=s;return e.debounce(r,i,{leading:o,maxWait:i,trailing:l})}t.throttle=n})(X2)),X2}var Z2,b7;function O3e(){return b7||(b7=1,Z2=L3e().throttle),Z2}var I3e=O3e();const z3e=bc(I3e);var aC=function(e,n){for(var r=arguments.length,i=new Array(r>2?r-2:0),s=2;s<r;s++)i[s-2]=arguments[s];if(typeof console<\"u\"&&console.warn&&(n===void 0&&console.warn(\"LogUtils requires an error message argument\"),!e))if(n===void 0)console.warn(\"Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.\");else{var o=0;console.warn(n.replace(/%s/g,()=>i[o++]))}},Dd={width:\"100%\",height:\"100%\",debounce:0,minWidth:0,initialDimension:{width:-1,height:-1}},nte=(t,e,n)=>{var{width:r=Dd.width,height:i=Dd.height,aspect:s,maxHeight:o}=n,l=_g(r)?t:Number(r),c=_g(i)?e:Number(i);return s&&s>0&&(l?c=l/s:c&&(l=c*s),o&&c!=null&&c>o&&(c=o)),{calculatedWidth:l,calculatedHeight:c}},U3e={width:0,height:0,overflow:\"visible\"},B3e={width:0,overflowX:\"visible\"},H3e={height:0,overflowY:\"visible\"},q3e={},$3e=t=>{var{width:e,height:n}=t,r=_g(e),i=_g(n);return r&&i?U3e:r?B3e:i?H3e:q3e};function V3e(t){var{width:e,height:n,aspect:r}=t,i=e,s=n;return i===void 0&&s===void 0?(i=Dd.width,s=Dd.height):i===void 0?i=r&&r>0?void 0:Dd.width:s===void 0&&(s=r&&r>0?void 0:Dd.height),{width:i,height:s}}function LR(){return LR=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},LR.apply(null,arguments)}function x7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function y7(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?x7(Object(n),!0).forEach(function(r){G3e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):x7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function G3e(t,e,n){return(e=W3e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function W3e(t){var e=K3e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function K3e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var rte=w.createContext(Dd.initialDimension);function X3e(t){return qd(t.width)&&qd(t.height)}function ate(t){var{children:e,width:n,height:r}=t,i=w.useMemo(()=>({width:n,height:r}),[n,r]);return X3e(i)?w.createElement(rte.Provider,{value:i},e):null}var UI=()=>w.useContext(rte),Y3e=w.forwardRef((t,e)=>{var{aspect:n,initialDimension:r=Dd.initialDimension,width:i,height:s,minWidth:o=Dd.minWidth,minHeight:l,maxHeight:c,children:d,debounce:u=Dd.debounce,id:m,className:p,onResize:f,style:y={}}=t,v=w.useRef(null),b=w.useRef();b.current=f,w.useImperativeHandle(e,()=>v.current);var[g,_]=w.useState({containerWidth:r.width,containerHeight:r.height}),C=w.useCallback((F,k)=>{_(D=>{var H=Math.round(F),z=Math.round(k);return D.containerWidth===H&&D.containerHeight===z?D:{containerWidth:H,containerHeight:z}})},[]);w.useEffect(()=>{if(v.current==null||typeof ResizeObserver>\"u\")return Tp;var F=z=>{var Q,L=z[0];if(L!=null){var{width:te,height:ie}=L.contentRect;C(te,ie),(Q=b.current)===null||Q===void 0||Q.call(b,te,ie)}};u>0&&(F=z3e(F,u,{trailing:!0,leading:!1}));var k=new ResizeObserver(F),{width:D,height:H}=v.current.getBoundingClientRect();return C(D,H),k.observe(v.current),()=>{k.disconnect()}},[C,u]);var{containerWidth:P,containerHeight:N}=g;aC(!n||n>0,\"The aspect(%s) must be greater than zero.\",n);var{calculatedWidth:A,calculatedHeight:T}=nte(P,N,{width:i,height:s,aspect:n,maxHeight:c});return aC(A!=null&&A>0||T!=null&&T>0,`The width(%s) and height(%s) of chart should be greater than 0,\n       please check the style of container, or the props width(%s) and height(%s),\n       or add a minWidth(%s) or minHeight(%s) or use aspect(%s) to control the\n       height and width.`,A,T,i,s,o,l,n),w.createElement(\"div\",{id:m?\"\".concat(m):void 0,className:_r(\"recharts-responsive-container\",p),style:y7(y7({},y),{},{width:i,height:s,minWidth:o,minHeight:l,maxHeight:c}),ref:v},w.createElement(\"div\",{style:$3e({width:i,height:s})},w.createElement(ate,{width:A,height:T},d)))}),Gh=w.forwardRef((t,e)=>{var n=UI();if(qd(n.width)&&qd(n.height))return t.children;var{width:r,height:i}=V3e({width:t.width,height:t.height,aspect:t.aspect}),{calculatedWidth:s,calculatedHeight:o}=nte(void 0,void 0,{width:r,height:i,aspect:t.aspect,maxHeight:t.maxHeight});return tn(s)&&tn(o)?w.createElement(ate,{width:s,height:o},t.children):w.createElement(Y3e,LR({},t,{width:r,height:i,ref:e}))});function BI(t){if(t)return{x:t.x,y:t.y,upperWidth:\"upperWidth\"in t?t.upperWidth:t.width,lowerWidth:\"lowerWidth\"in t?t.lowerWidth:t.width,width:t.width,height:t.height}}var bS=()=>{var t,e=Li(),n=sn(E3e),r=sn(ST),i=(t=sn(wT))===null||t===void 0?void 0:t.padding;return!e||!r||!i?n:{width:r.width-i.left-i.right,height:r.height-i.top-i.bottom,x:i.left,y:i.top}},Q3e={top:0,bottom:0,left:0,right:0,width:0,height:0,brushBottom:0},ite=()=>{var t;return(t=sn(Ri))!==null&&t!==void 0?t:Q3e},HI=()=>sn(jm),qI=()=>sn(Mm),Z3e=()=>sn(t=>t.layout.margin),Sr=t=>t.layout.layoutType,jp=()=>sn(Sr),$I=()=>{var t=jp();if(t===\"horizontal\"||t===\"vertical\")return t},ste=t=>{var e=t.layout.layoutType;if(e===\"centric\"||e===\"radial\")return e},J3e=()=>{var t=jp();return t!==void 0},xS=t=>{var e=ua(),n=Li(),{width:r,height:i}=t,s=UI(),o=r,l=i;return s&&(o=s.width>0?s.width:r,l=s.height>0?s.height:i),w.useEffect(()=>{!n&&qd(o)&&qd(l)&&e(i3e({width:o,height:l}))},[e,n,o,l]),null},ote=Symbol.for(\"immer-nothing\"),v7=Symbol.for(\"immer-draftable\"),wl=Symbol.for(\"immer-state\");function Hc(t,...e){throw new Error(`[Immer] minified error nr: ${t}. Full error at: https://bit.ly/3cXEKWf`)}var Sw=Object.getPrototypeOf;function ty(t){return!!t&&!!t[wl]}function Ng(t){return t?lte(t)||Array.isArray(t)||!!t[v7]||!!t.constructor?.[v7]||yS(t)||kT(t):!1}var eFe=Object.prototype.constructor.toString(),w7=new WeakMap;function lte(t){if(!t||typeof t!=\"object\")return!1;const e=Object.getPrototypeOf(t);if(e===null||e===Object.prototype)return!0;const n=Object.hasOwnProperty.call(e,\"constructor\")&&e.constructor;if(n===Object)return!0;if(typeof n!=\"function\")return!1;let r=w7.get(n);return r===void 0&&(r=Function.toString.call(n),w7.set(n,r)),r===eFe}function iC(t,e,n=!0){_T(t)===0?(n?Reflect.ownKeys(t):Object.keys(t)).forEach(i=>{e(i,t[i],t)}):t.forEach((r,i)=>e(i,r,t))}function _T(t){const e=t[wl];return e?e.type_:Array.isArray(t)?1:yS(t)?2:kT(t)?3:0}function OR(t,e){return _T(t)===2?t.has(e):Object.prototype.hasOwnProperty.call(t,e)}function cte(t,e,n){const r=_T(t);r===2?t.set(e,n):r===3?t.add(n):t[e]=n}function tFe(t,e){return t===e?t!==0||1/t===1/e:t!==t&&e!==e}function yS(t){return t instanceof Map}function kT(t){return t instanceof Set}function Cf(t){return t.copy_||t.base_}function IR(t,e){if(yS(t))return new Map(t);if(kT(t))return new Set(t);if(Array.isArray(t))return Array.prototype.slice.call(t);const n=lte(t);if(e===!0||e===\"class_only\"&&!n){const r=Object.getOwnPropertyDescriptors(t);delete r[wl];let i=Reflect.ownKeys(r);for(let s=0;s<i.length;s++){const o=i[s],l=r[o];l.writable===!1&&(l.writable=!0,l.configurable=!0),(l.get||l.set)&&(r[o]={configurable:!0,writable:!0,enumerable:l.enumerable,value:t[o]})}return Object.create(Sw(t),r)}else{const r=Sw(t);if(r!==null&&n)return{...t};const i=Object.create(r);return Object.assign(i,t)}}function VI(t,e=!1){return NT(t)||ty(t)||!Ng(t)||(_T(t)>1&&Object.defineProperties(t,{set:ik,add:ik,clear:ik,delete:ik}),Object.freeze(t),e&&Object.values(t).forEach(n=>VI(n,!0))),t}function nFe(){Hc(2)}var ik={value:nFe};function NT(t){return t===null||typeof t!=\"object\"?!0:Object.isFrozen(t)}var rFe={};function Cg(t){const e=rFe[t];return e||Hc(0,t),e}var _w;function dte(){return _w}function aFe(t,e){return{drafts_:[],parent_:t,immer_:e,canAutoFreeze_:!0,unfinalizedDrafts_:0}}function S7(t,e){e&&(Cg(\"Patches\"),t.patches_=[],t.inversePatches_=[],t.patchListener_=e)}function zR(t){UR(t),t.drafts_.forEach(iFe),t.drafts_=null}function UR(t){t===_w&&(_w=t.parent_)}function _7(t){return _w=aFe(_w,t)}function iFe(t){const e=t[wl];e.type_===0||e.type_===1?e.revoke_():e.revoked_=!0}function k7(t,e){e.unfinalizedDrafts_=e.drafts_.length;const n=e.drafts_[0];return t!==void 0&&t!==n?(n[wl].modified_&&(zR(e),Hc(4)),Ng(t)&&(t=sC(e,t),e.parent_||oC(e,t)),e.patches_&&Cg(\"Patches\").generateReplacementPatches_(n[wl].base_,t,e.patches_,e.inversePatches_)):t=sC(e,n,[]),zR(e),e.patches_&&e.patchListener_(e.patches_,e.inversePatches_),t!==ote?t:void 0}function sC(t,e,n){if(NT(e))return e;const r=t.immer_.shouldUseStrictIteration(),i=e[wl];if(!i)return iC(e,(s,o)=>N7(t,i,e,s,o,n),r),e;if(i.scope_!==t)return e;if(!i.modified_)return oC(t,i.base_,!0),i.base_;if(!i.finalized_){i.finalized_=!0,i.scope_.unfinalizedDrafts_--;const s=i.copy_;let o=s,l=!1;i.type_===3&&(o=new Set(s),s.clear(),l=!0),iC(o,(c,d)=>N7(t,i,s,c,d,n,l),r),oC(t,s,!1),n&&t.patches_&&Cg(\"Patches\").generatePatches_(i,n,t.patches_,t.inversePatches_)}return i.copy_}function N7(t,e,n,r,i,s,o){if(i==null||typeof i!=\"object\"&&!o)return;const l=NT(i);if(!(l&&!o)){if(ty(i)){const c=s&&e&&e.type_!==3&&!OR(e.assigned_,r)?s.concat(r):void 0,d=sC(t,i,c);if(cte(n,r,d),ty(d))t.canAutoFreeze_=!1;else return}else o&&n.add(i);if(Ng(i)&&!l){if(!t.immer_.autoFreeze_&&t.unfinalizedDrafts_<1||e&&e.base_&&e.base_[r]===i&&l)return;sC(t,i),(!e||!e.scope_.parent_)&&typeof r!=\"symbol\"&&(yS(n)?n.has(r):Object.prototype.propertyIsEnumerable.call(n,r))&&oC(t,i)}}}function oC(t,e,n=!1){!t.parent_&&t.immer_.autoFreeze_&&t.canAutoFreeze_&&VI(e,n)}function sFe(t,e){const n=Array.isArray(t),r={type_:n?1:0,scope_:e?e.scope_:dte(),modified_:!1,finalized_:!1,assigned_:{},parent_:e,base_:t,draft_:null,copy_:null,revoke_:null,isManual_:!1};let i=r,s=GI;n&&(i=[r],s=kw);const{revoke:o,proxy:l}=Proxy.revocable(i,s);return r.draft_=l,r.revoke_=o,l}var GI={get(t,e){if(e===wl)return t;const n=Cf(t);if(!OR(n,e))return oFe(t,n,e);const r=n[e];return t.finalized_||!Ng(r)?r:r===J2(t.base_,e)?(eD(t),t.copy_[e]=HR(r,t)):r},has(t,e){return e in Cf(t)},ownKeys(t){return Reflect.ownKeys(Cf(t))},set(t,e,n){const r=ute(Cf(t),e);if(r?.set)return r.set.call(t.draft_,n),!0;if(!t.modified_){const i=J2(Cf(t),e),s=i?.[wl];if(s&&s.base_===n)return t.copy_[e]=n,t.assigned_[e]=!1,!0;if(tFe(n,i)&&(n!==void 0||OR(t.base_,e)))return!0;eD(t),BR(t)}return t.copy_[e]===n&&(n!==void 0||e in t.copy_)||Number.isNaN(n)&&Number.isNaN(t.copy_[e])||(t.copy_[e]=n,t.assigned_[e]=!0),!0},deleteProperty(t,e){return J2(t.base_,e)!==void 0||e in t.base_?(t.assigned_[e]=!1,eD(t),BR(t)):delete t.assigned_[e],t.copy_&&delete t.copy_[e],!0},getOwnPropertyDescriptor(t,e){const n=Cf(t),r=Reflect.getOwnPropertyDescriptor(n,e);return r&&{writable:!0,configurable:t.type_!==1||e!==\"length\",enumerable:r.enumerable,value:n[e]}},defineProperty(){Hc(11)},getPrototypeOf(t){return Sw(t.base_)},setPrototypeOf(){Hc(12)}},kw={};iC(GI,(t,e)=>{kw[t]=function(){return arguments[0]=arguments[0][0],e.apply(this,arguments)}});kw.deleteProperty=function(t,e){return kw.set.call(this,t,e,void 0)};kw.set=function(t,e,n){return GI.set.call(this,t[0],e,n,t[0])};function J2(t,e){const n=t[wl];return(n?Cf(n):t)[e]}function oFe(t,e,n){const r=ute(e,n);return r?\"value\"in r?r.value:r.get?.call(t.draft_):void 0}function ute(t,e){if(!(e in t))return;let n=Sw(t);for(;n;){const r=Object.getOwnPropertyDescriptor(n,e);if(r)return r;n=Sw(n)}}function BR(t){t.modified_||(t.modified_=!0,t.parent_&&BR(t.parent_))}function eD(t){t.copy_||(t.copy_=IR(t.base_,t.scope_.immer_.useStrictShallowCopy_))}var lFe=class{constructor(t){this.autoFreeze_=!0,this.useStrictShallowCopy_=!1,this.useStrictIteration_=!0,this.produce=(e,n,r)=>{if(typeof e==\"function\"&&typeof n!=\"function\"){const s=n;n=e;const o=this;return function(c=s,...d){return o.produce(c,u=>n.call(this,u,...d))}}typeof n!=\"function\"&&Hc(6),r!==void 0&&typeof r!=\"function\"&&Hc(7);let i;if(Ng(e)){const s=_7(this),o=HR(e,void 0);let l=!0;try{i=n(o),l=!1}finally{l?zR(s):UR(s)}return S7(s,r),k7(i,s)}else if(!e||typeof e!=\"object\"){if(i=n(e),i===void 0&&(i=e),i===ote&&(i=void 0),this.autoFreeze_&&VI(i,!0),r){const s=[],o=[];Cg(\"Patches\").generateReplacementPatches_(e,i,s,o),r(s,o)}return i}else Hc(1,e)},this.produceWithPatches=(e,n)=>{if(typeof e==\"function\")return(o,...l)=>this.produceWithPatches(o,c=>e(c,...l));let r,i;return[this.produce(e,n,(o,l)=>{r=o,i=l}),r,i]},typeof t?.autoFreeze==\"boolean\"&&this.setAutoFreeze(t.autoFreeze),typeof t?.useStrictShallowCopy==\"boolean\"&&this.setUseStrictShallowCopy(t.useStrictShallowCopy),typeof t?.useStrictIteration==\"boolean\"&&this.setUseStrictIteration(t.useStrictIteration)}createDraft(t){Ng(t)||Hc(8),ty(t)&&(t=cFe(t));const e=_7(this),n=HR(t,void 0);return n[wl].isManual_=!0,UR(e),n}finishDraft(t,e){const n=t&&t[wl];(!n||!n.isManual_)&&Hc(9);const{scope_:r}=n;return S7(r,e),k7(void 0,r)}setAutoFreeze(t){this.autoFreeze_=t}setUseStrictShallowCopy(t){this.useStrictShallowCopy_=t}setUseStrictIteration(t){this.useStrictIteration_=t}shouldUseStrictIteration(){return this.useStrictIteration_}applyPatches(t,e){let n;for(n=e.length-1;n>=0;n--){const i=e[n];if(i.path.length===0&&i.op===\"replace\"){t=i.value;break}}n>-1&&(e=e.slice(n+1));const r=Cg(\"Patches\").applyPatches_;return ty(t)?r(t,e):this.produce(t,i=>r(i,e))}};function HR(t,e){const n=yS(t)?Cg(\"MapSet\").proxyMap_(t,e):kT(t)?Cg(\"MapSet\").proxySet_(t,e):sFe(t,e);return(e?e.scope_:dte()).drafts_.push(n),n}function cFe(t){return ty(t)||Hc(10,t),mte(t)}function mte(t){if(!Ng(t)||NT(t))return t;const e=t[wl];let n,r=!0;if(e){if(!e.modified_)return e.base_;e.finalized_=!0,n=IR(t,e.scope_.immer_.useStrictShallowCopy_),r=e.scope_.immer_.shouldUseStrictIteration()}else n=IR(t,!0);return iC(n,(i,s)=>{cte(n,i,mte(s))},r),e&&(e.finalized_=!1),n}var dFe=new lFe;dFe.produce;var uFe={settings:{layout:\"horizontal\",align:\"center\",verticalAlign:\"middle\",itemSorter:\"value\"},size:{width:0,height:0},payload:[]},hte=$o({name:\"legend\",initialState:uFe,reducers:{setLegendSize(t,e){t.size.width=e.payload.width,t.size.height=e.payload.height},setLegendSettings(t,e){t.settings.align=e.payload.align,t.settings.layout=e.payload.layout,t.settings.verticalAlign=e.payload.verticalAlign,t.settings.itemSorter=e.payload.itemSorter},addLegendPayload:{reducer(t,e){t.payload.push(e.payload)},prepare:Ta()},replaceLegendPayload:{reducer(t,e){var{prev:n,next:r}=e.payload,i=Xc(t).payload.indexOf(n);i>-1&&(t.payload[i]=r)},prepare:Ta()},removeLegendPayload:{reducer(t,e){var n=Xc(t).payload.indexOf(e.payload);n>-1&&t.payload.splice(n,1)},prepare:Ta()}}}),{setLegendSize:C7,setLegendSettings:mFe,addLegendPayload:pte,replaceLegendPayload:fte,removeLegendPayload:gte}=hte.actions,hFe=hte.reducer,pFe=[\"contextPayload\"];function qR(){return qR=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},qR.apply(null,arguments)}function P7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function ny(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?P7(Object(n),!0).forEach(function(r){fFe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):P7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function fFe(t,e,n){return(e=gFe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function gFe(t){var e=bFe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function bFe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function xFe(t,e){if(t==null)return{};var n,r,i=yFe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function yFe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function vFe(t){return t.value}function wFe(t){var{contextPayload:e}=t,n=xFe(t,pFe),r=hee(e,t.payloadUniqBy,vFe),i=ny(ny({},n),{},{payload:r});return w.isValidElement(t.content)?w.cloneElement(t.content,i):typeof t.content==\"function\"?w.createElement(t.content,i):w.createElement(f2e,i)}function SFe(t,e,n,r,i,s){var{layout:o,align:l,verticalAlign:c}=e,d,u;return(!t||(t.left===void 0||t.left===null)&&(t.right===void 0||t.right===null))&&(l===\"center\"&&o===\"vertical\"?d={left:((r||0)-s.width)/2}:d=l===\"right\"?{right:n&&n.right||0}:{left:n&&n.left||0}),(!t||(t.top===void 0||t.top===null)&&(t.bottom===void 0||t.bottom===null))&&(c===\"middle\"?u={top:((i||0)-s.height)/2}:u=c===\"bottom\"?{bottom:n&&n.bottom||0}:{top:n&&n.top||0}),ny(ny({},d),u)}function _Fe(t){var e=ua();return w.useEffect(()=>{e(mFe(t))},[e,t]),null}function kFe(t){var e=ua();return w.useEffect(()=>(e(C7(t)),()=>{e(C7({width:0,height:0}))}),[e,t]),null}function NFe(t,e,n,r){return t===\"vertical\"&&e!=null?{height:e}:t===\"horizontal\"?{width:n||r}:null}var CFe={align:\"center\",iconSize:14,inactiveColor:\"#ccc\",itemSorter:\"value\",layout:\"horizontal\",verticalAlign:\"bottom\"};function bte(t){var e=Ia(t,CFe),n=oDe(),r=oEe(),i=Z3e(),{width:s,height:o,wrapperStyle:l,portal:c}=e,[d,u]=yee([n]),m=HI(),p=qI();if(m==null||p==null)return null;var f=m-(i?.left||0)-(i?.right||0),y=NFe(e.layout,o,s,f),v=c?l:ny(ny({position:\"absolute\",width:y?.width||s||\"auto\",height:y?.height||o||\"auto\"},SFe(l,e,i,m,p,d)),l),b=c??r;if(b==null||n==null)return null;var g=w.createElement(\"div\",{className:\"recharts-legend-wrapper\",style:v,ref:u},w.createElement(_Fe,{layout:e.layout,align:e.align,verticalAlign:e.verticalAlign,itemSorter:e.itemSorter}),!c&&w.createElement(kFe,{width:d.width,height:d.height}),w.createElement(wFe,qR({},e,y,{margin:i,chartWidth:m,chartHeight:p,contextPayload:n})));return Yu.createPortal(g,b)}bte.displayName=\"Legend\";function $R(){return $R=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},$R.apply(null,arguments)}function T7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function Gv(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?T7(Object(n),!0).forEach(function(r){PFe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):T7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function PFe(t,e,n){return(e=TFe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function TFe(t){var e=AFe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function AFe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function jFe(t){return Array.isArray(t)&&hc(t[0])&&hc(t[1])?t.join(\" ~ \"):t}var nx={separator:\" : \",contentStyle:{margin:0,padding:10,backgroundColor:\"#fff\",border:\"1px solid #ccc\",whiteSpace:\"nowrap\"},itemStyle:{display:\"block\",paddingTop:4,paddingBottom:4,color:\"#000\"},labelStyle:{},accessibilityLayer:!1},MFe=t=>{var{separator:e=nx.separator,contentStyle:n,itemStyle:r,labelStyle:i=nx.labelStyle,payload:s,formatter:o,itemSorter:l,wrapperClassName:c,labelClassName:d,label:u,labelFormatter:m,accessibilityLayer:p=nx.accessibilityLayer}=t,f=()=>{if(s&&s.length){var N={padding:0,margin:0},A=(l?cT(s,l):s).map((T,F)=>{if(T.type===\"none\")return null;var k=T.formatter||o||jFe,{value:D,name:H}=T,z=D,Q=H;if(k){var L=k(D,H,T,F,s);if(Array.isArray(L))[z,Q]=L;else if(L!=null)z=L;else return null}var te=Gv(Gv({},nx.itemStyle),{},{color:T.color||nx.itemStyle.color},r);return w.createElement(\"li\",{className:\"recharts-tooltip-item\",key:\"tooltip-item-\".concat(F),style:te},hc(Q)?w.createElement(\"span\",{className:\"recharts-tooltip-item-name\"},Q):null,hc(Q)?w.createElement(\"span\",{className:\"recharts-tooltip-item-separator\"},e):null,w.createElement(\"span\",{className:\"recharts-tooltip-item-value\"},z),w.createElement(\"span\",{className:\"recharts-tooltip-item-unit\"},T.unit||\"\"))});return w.createElement(\"ul\",{className:\"recharts-tooltip-item-list\",style:N},A)}return null},y=Gv(Gv({},nx.contentStyle),n),v=Gv({margin:0},i),b=!Ea(u),g=b?u:\"\",_=_r(\"recharts-default-tooltip\",c),C=_r(\"recharts-tooltip-label\",d);b&&m&&s!==void 0&&s!==null&&(g=m(u,s));var P=p?{role:\"status\",\"aria-live\":\"assertive\"}:{};return w.createElement(\"div\",$R({className:_,style:y},P),w.createElement(\"p\",{className:C,style:v},w.isValidElement(g)?g:\"\".concat(g)),f())},Wv=\"recharts-tooltip-wrapper\",EFe={visibility:\"hidden\"};function DFe(t){var{coordinate:e,translateX:n,translateY:r}=t;return _r(Wv,{[\"\".concat(Wv,\"-right\")]:tn(n)&&e&&tn(e.x)&&n>=e.x,[\"\".concat(Wv,\"-left\")]:tn(n)&&e&&tn(e.x)&&n<e.x,[\"\".concat(Wv,\"-bottom\")]:tn(r)&&e&&tn(e.y)&&r>=e.y,[\"\".concat(Wv,\"-top\")]:tn(r)&&e&&tn(e.y)&&r<e.y})}function A7(t){var{allowEscapeViewBox:e,coordinate:n,key:r,offset:i,position:s,reverseDirection:o,tooltipDimension:l,viewBox:c,viewBoxDimension:d}=t;if(s&&tn(s[r]))return s[r];var u=n[r]-l-(i>0?i:0),m=n[r]+i;if(e[r])return o[r]?u:m;var p=c[r];if(p==null)return 0;if(o[r]){var f=u,y=p;return f<y?Math.max(m,p):Math.max(u,p)}if(d==null)return 0;var v=m+l,b=p+d;return v>b?Math.max(u,p):Math.max(m,p)}function FFe(t){var{translateX:e,translateY:n,useTranslate3d:r}=t;return{transform:r?\"translate3d(\".concat(e,\"px, \").concat(n,\"px, 0)\"):\"translate(\".concat(e,\"px, \").concat(n,\"px)\")}}function RFe(t){var{allowEscapeViewBox:e,coordinate:n,offsetTop:r,offsetLeft:i,position:s,reverseDirection:o,tooltipBox:l,useTranslate3d:c,viewBox:d}=t,u,m,p;return l.height>0&&l.width>0&&n?(m=A7({allowEscapeViewBox:e,coordinate:n,key:\"x\",offset:i,position:s,reverseDirection:o,tooltipDimension:l.width,viewBox:d,viewBoxDimension:d.width}),p=A7({allowEscapeViewBox:e,coordinate:n,key:\"y\",offset:r,position:s,reverseDirection:o,tooltipDimension:l.height,viewBox:d,viewBoxDimension:d.height}),u=FFe({translateX:m,translateY:p,useTranslate3d:c})):u=EFe,{cssProperties:u,cssClasses:DFe({translateX:m,translateY:p,coordinate:n})}}function j7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function sk(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?j7(Object(n),!0).forEach(function(r){VR(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):j7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function VR(t,e,n){return(e=LFe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function LFe(t){var e=OFe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function OFe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}class IFe extends w.PureComponent{constructor(){super(...arguments),VR(this,\"state\",{dismissed:!1,dismissedAtCoordinate:{x:0,y:0}}),VR(this,\"handleKeyDown\",e=>{if(e.key===\"Escape\"){var n,r,i,s;this.setState({dismissed:!0,dismissedAtCoordinate:{x:(n=(r=this.props.coordinate)===null||r===void 0?void 0:r.x)!==null&&n!==void 0?n:0,y:(i=(s=this.props.coordinate)===null||s===void 0?void 0:s.y)!==null&&i!==void 0?i:0}})}})}componentDidMount(){document.addEventListener(\"keydown\",this.handleKeyDown)}componentWillUnmount(){document.removeEventListener(\"keydown\",this.handleKeyDown)}componentDidUpdate(){var e,n;this.state.dismissed&&(((e=this.props.coordinate)===null||e===void 0?void 0:e.x)!==this.state.dismissedAtCoordinate.x||((n=this.props.coordinate)===null||n===void 0?void 0:n.y)!==this.state.dismissedAtCoordinate.y)&&(this.state.dismissed=!1)}render(){var{active:e,allowEscapeViewBox:n,animationDuration:r,animationEasing:i,children:s,coordinate:o,hasPayload:l,isAnimationActive:c,offset:d,position:u,reverseDirection:m,useTranslate3d:p,viewBox:f,wrapperStyle:y,lastBoundingBox:v,innerRef:b,hasPortalFromProps:g}=this.props,_=typeof d==\"number\"?d:d.x,C=typeof d==\"number\"?d:d.y,{cssClasses:P,cssProperties:N}=RFe({allowEscapeViewBox:n,coordinate:o,offsetLeft:_,offsetTop:C,position:u,reverseDirection:m,tooltipBox:{height:v.height,width:v.width},useTranslate3d:p,viewBox:f}),A=g?{}:sk(sk({transition:c&&e?\"transform \".concat(r,\"ms \").concat(i):void 0},N),{},{pointerEvents:\"none\",visibility:!this.state.dismissed&&e&&l?\"visible\":\"hidden\",position:\"absolute\",top:0,left:0}),T=sk(sk({},A),{},{visibility:!this.state.dismissed&&e&&l?\"visible\":\"hidden\"},y);return w.createElement(\"div\",{xmlns:\"http://www.w3.org/1999/xhtml\",tabIndex:-1,className:P,style:T,ref:b},s)}}var xte=()=>{var t;return(t=sn(e=>e.rootProps.accessibilityLayer))!==null&&t!==void 0?t:!0};function GR(){return GR=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},GR.apply(null,arguments)}function M7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function E7(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?M7(Object(n),!0).forEach(function(r){zFe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):M7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function zFe(t,e,n){return(e=UFe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function UFe(t){var e=BFe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function BFe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var D7={curveBasisClosed:CEe,curveBasisOpen:PEe,curveBasis:NEe,curveBumpX:uEe,curveBumpY:mEe,curveLinearClosed:TEe,curveLinear:oT,curveMonotoneX:AEe,curveMonotoneY:jEe,curveNatural:MEe,curveStep:EEe,curveStepAfter:FEe,curveStepBefore:DEe},lC=t=>Gn(t.x)&&Gn(t.y),F7=t=>t.base!=null&&lC(t.base)&&lC(t),Kv=t=>t.x,Xv=t=>t.y,HFe=(t,e)=>{if(typeof t==\"function\")return t;var n=\"curve\".concat(mS(t));if((n===\"curveMonotone\"||n===\"curveBump\")&&e){var r=D7[\"\".concat(n).concat(e===\"vertical\"?\"Y\":\"X\")];if(r)return r}return D7[n]||oT},R7={connectNulls:!1,type:\"linear\"},qFe=t=>{var{type:e=R7.type,points:n=[],baseLine:r,layout:i,connectNulls:s=R7.connectNulls}=t,o=HFe(e,i),l=s?n.filter(lC):n;if(Array.isArray(r)){var c,d=n.map((y,v)=>E7(E7({},y),{},{base:r[v]}));i===\"vertical\"?c=Z1().y(Xv).x1(Kv).x0(y=>y.base.x):c=Z1().x(Kv).y1(Xv).y0(y=>y.base.y);var u=c.defined(F7).curve(o),m=s?d.filter(F7):d;return u(m)}var p;i===\"vertical\"&&tn(r)?p=Z1().y(Xv).x1(Kv).x0(r):tn(r)?p=Z1().x(Kv).y1(Xv).y0(r):p=qJ().x(Kv).y(Xv);var f=p.defined(lC).curve(o);return f(l)},Rx=t=>{var{className:e,points:n,path:r,pathRef:i}=t,s=jp();if((!n||!n.length)&&!r)return null;var o={type:t.type,points:t.points,baseLine:t.baseLine,layout:t.layout||s,connectNulls:t.connectNulls},l=n&&n.length?qFe(o):r;return w.createElement(\"path\",GR({},mo(t),AI(t),{className:_r(\"recharts-curve\",e),d:l===null?void 0:l,ref:i}))},$Fe=[\"x\",\"y\",\"top\",\"left\",\"width\",\"height\",\"className\"];function WR(){return WR=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},WR.apply(null,arguments)}function L7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function VFe(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?L7(Object(n),!0).forEach(function(r){GFe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):L7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function GFe(t,e,n){return(e=WFe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function WFe(t){var e=KFe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function KFe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function XFe(t,e){if(t==null)return{};var n,r,i=YFe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function YFe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var QFe=(t,e,n,r,i,s)=>\"M\".concat(t,\",\").concat(i,\"v\").concat(r,\"M\").concat(s,\",\").concat(e,\"h\").concat(n),ZFe=t=>{var{x:e=0,y:n=0,top:r=0,left:i=0,width:s=0,height:o=0,className:l}=t,c=XFe(t,$Fe),d=VFe({x:e,y:n,top:r,left:i,width:s,height:o},c);return!tn(e)||!tn(n)||!tn(s)||!tn(o)||!tn(r)||!tn(i)?null:w.createElement(\"path\",WR({},Cs(d),{className:_r(\"recharts-cross\",l),d:QFe(e,n,s,o,r,i)}))};function JFe(t,e,n,r){var i=r/2;return{stroke:\"none\",fill:\"#ccc\",x:t===\"horizontal\"?e.x-i:n.left+.5,y:t===\"horizontal\"?n.top+.5:e.y-i,width:t===\"horizontal\"?r:n.width-1,height:t===\"horizontal\"?n.height-1:r}}function O7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function I7(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?O7(Object(n),!0).forEach(function(r){eRe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):O7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function eRe(t,e,n){return(e=tRe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function tRe(t){var e=nRe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function nRe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var rRe=t=>t.replace(/([A-Z])/g,e=>\"-\".concat(e.toLowerCase())),yte=(t,e,n)=>t.map(r=>\"\".concat(rRe(r),\" \").concat(e,\"ms \").concat(n)).join(\",\"),aRe=(t,e)=>[Object.keys(t),Object.keys(e)].reduce((n,r)=>n.filter(i=>r.includes(i))),Nw=(t,e)=>Object.keys(e).reduce((n,r)=>I7(I7({},n),{},{[r]:t(r,e[r])}),{});function z7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function Ai(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?z7(Object(n),!0).forEach(function(r){iRe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):z7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function iRe(t,e,n){return(e=sRe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function sRe(t){var e=oRe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function oRe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var cC=(t,e,n)=>t+(e-t)*n,KR=t=>{var{from:e,to:n}=t;return e!==n},vte=(t,e,n)=>{var r=Nw((i,s)=>{if(KR(s)){var[o,l]=t(s.from,s.to,s.velocity);return Ai(Ai({},s),{},{from:o,velocity:l})}return s},e);return n<1?Nw((i,s)=>KR(s)&&r[i]!=null?Ai(Ai({},s),{},{velocity:cC(s.velocity,r[i].velocity,n),from:cC(s.from,r[i].from,n)}):s,e):vte(t,r,n-1)};function lRe(t,e,n,r,i,s){var o,l=r.reduce((p,f)=>Ai(Ai({},p),{},{[f]:{from:t[f],velocity:0,to:e[f]}}),{}),c=()=>Nw((p,f)=>f.from,l),d=()=>!Object.values(l).filter(KR).length,u=null,m=p=>{o||(o=p);var f=p-o,y=f/n.dt;l=vte(n,l,y),i(Ai(Ai(Ai({},t),e),c())),o=p,d()||(u=s.setTimeout(m))};return()=>(u=s.setTimeout(m),()=>{var p;(p=u)===null||p===void 0||p()})}function cRe(t,e,n,r,i,s,o){var l=null,c=i.reduce((m,p)=>{var f=t[p],y=e[p];return f==null||y==null?m:Ai(Ai({},m),{},{[p]:[f,y]})},{}),d,u=m=>{d||(d=m);var p=(m-d)/r,f=Nw((v,b)=>cC(...b,n(p)),c);if(s(Ai(Ai(Ai({},t),e),f)),p<1)l=o.setTimeout(u);else{var y=Nw((v,b)=>cC(...b,n(1)),c);s(Ai(Ai(Ai({},t),e),y))}};return()=>(l=o.setTimeout(u),()=>{var m;(m=l)===null||m===void 0||m()})}const dRe=(t,e,n,r,i,s)=>{var o=aRe(t,e);return n==null?()=>(i(Ai(Ai({},t),e)),()=>{}):n.isStepper===!0?lRe(t,e,n,o,i,s):cRe(t,e,n,r,o,i,s)};var dC=1e-4,wte=(t,e)=>[0,3*t,3*e-6*t,3*t-3*e+1],Ste=(t,e)=>t.map((n,r)=>n*e**r).reduce((n,r)=>n+r),U7=(t,e)=>n=>{var r=wte(t,e);return Ste(r,n)},uRe=(t,e)=>n=>{var r=wte(t,e),i=[...r.map((s,o)=>s*o).slice(1),0];return Ste(i,n)},mRe=t=>{var e,n=t.split(\"(\");if(n.length!==2||n[0]!==\"cubic-bezier\")return null;var r=(e=n[1])===null||e===void 0||(e=e.split(\")\")[0])===null||e===void 0?void 0:e.split(\",\");if(r==null||r.length!==4)return null;var i=r.map(s=>parseFloat(s));return[i[0],i[1],i[2],i[3]]},hRe=function(){for(var e=arguments.length,n=new Array(e),r=0;r<e;r++)n[r]=arguments[r];if(n.length===1)switch(n[0]){case\"linear\":return[0,0,1,1];case\"ease\":return[.25,.1,.25,1];case\"ease-in\":return[.42,0,1,1];case\"ease-out\":return[.42,0,.58,1];case\"ease-in-out\":return[0,0,.58,1];default:{var i=mRe(n[0]);if(i)return i}}return n.length===4?n:[0,0,1,1]},pRe=(t,e,n,r)=>{var i=U7(t,n),s=U7(e,r),o=uRe(t,n),l=d=>d>1?1:d<0?0:d,c=d=>{for(var u=d>1?1:d,m=u,p=0;p<8;++p){var f=i(m)-u,y=o(m);if(Math.abs(f-u)<dC||y<dC)return s(m);m=l(m-f/y)}return s(m)};return c.isStepper=!1,c},B7=function(){return pRe(...hRe(...arguments))},fRe=function(){var e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},{stiff:n=100,damping:r=8,dt:i=17}=e,s=(o,l,c)=>{var d=-(o-l)*n,u=c*r,m=c+(d-u)*i/1e3,p=c*i/1e3+o;return Math.abs(p-l)<dC&&Math.abs(m)<dC?[l,0]:[p,m]};return s.isStepper=!0,s.dt=i,s},gRe=t=>{if(typeof t==\"string\")switch(t){case\"ease\":case\"ease-in-out\":case\"ease-out\":case\"ease-in\":case\"linear\":return B7(t);case\"spring\":return fRe();default:if(t.split(\"(\")[0]===\"cubic-bezier\")return B7(t)}return typeof t==\"function\"?t:null};function bRe(t){var e,n=()=>null,r=!1,i=null,s=o=>{if(!r){if(Array.isArray(o)){if(!o.length)return;var l=o,[c,...d]=l;if(typeof c==\"number\"){i=t.setTimeout(s.bind(null,d),c);return}s(c),i=t.setTimeout(s.bind(null,d));return}typeof o==\"string\"&&(e=o,n(e)),typeof o==\"object\"&&(e=o,n(e)),typeof o==\"function\"&&o()}};return{stop:()=>{r=!0},start:o=>{r=!1,i&&(i(),i=null),s(o)},subscribe:o=>(n=o,()=>{n=()=>null}),getTimeoutController:()=>t}}class xRe{setTimeout(e){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:0,r=performance.now(),i=null,s=o=>{o-r>=n?e(o):typeof requestAnimationFrame==\"function\"&&(i=requestAnimationFrame(s))};return i=requestAnimationFrame(s),()=>{i!=null&&cancelAnimationFrame(i)}}}function yRe(){return bRe(new xRe)}var vRe=w.createContext(yRe);function wRe(t,e){var n=w.useContext(vRe);return w.useMemo(()=>e??n(t),[t,e,n])}var SRe=()=>!(typeof window<\"u\"&&window.document&&window.document.createElement&&window.setTimeout),vS={isSsr:SRe()},_Re={begin:0,duration:1e3,easing:\"ease\",isActive:!0,canBegin:!0,onAnimationEnd:()=>{},onAnimationStart:()=>{}},H7={t:0},tD={t:1};function Ty(t){var e=Ia(t,_Re),{isActive:n,canBegin:r,duration:i,easing:s,begin:o,onAnimationEnd:l,onAnimationStart:c,children:d}=e,u=n===\"auto\"?!vS.isSsr:n,m=wRe(e.animationId,e.animationManager),[p,f]=w.useState(u?H7:tD),y=w.useRef(null);return w.useEffect(()=>{u||f(tD)},[u]),w.useEffect(()=>{if(!u||!r)return Tp;var v=dRe(H7,tD,gRe(s),i,f,m.getTimeoutController()),b=()=>{y.current=v()};return m.start([c,o,b,i,l]),()=>{m.stop(),y.current&&y.current(),l()}},[u,r,i,s,o,c,l,m]),d(p.t)}function Ay(t){var e=arguments.length>1&&arguments[1]!==void 0?arguments[1]:\"animation-\",n=w.useRef(xw(e)),r=w.useRef(t);return r.current!==t&&(n.current=xw(e),r.current=t),n.current}var kRe=[\"radius\"],NRe=[\"radius\"],q7,$7,V7,G7,W7,K7,X7,Y7,Q7,Z7;function J7(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function eG(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?J7(Object(n),!0).forEach(function(r){CRe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):J7(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function CRe(t,e,n){return(e=PRe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function PRe(t){var e=TRe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function TRe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function uC(){return uC=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},uC.apply(null,arguments)}function tG(t,e){if(t==null)return{};var n,r,i=ARe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function ARe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function bd(t,e){return e||(e=t.slice(0)),Object.freeze(Object.defineProperties(t,{raw:{value:Object.freeze(e)}}))}var nG=(t,e,n,r,i)=>{var s=Vh(n),o=Vh(r),l=Math.min(Math.abs(s)/2,Math.abs(o)/2),c=o>=0?1:-1,d=s>=0?1:-1,u=o>=0&&s>=0||o<0&&s<0?1:0,m;if(l>0&&Array.isArray(i)){for(var p=[0,0,0,0],f=0,y=4;f<y;f++){var v,b=(v=i[f])!==null&&v!==void 0?v:0;p[f]=b>l?l:b}m=Ka(q7||(q7=bd([\"M\",\",\",\"\"])),t,e+c*p[0]),p[0]>0&&(m+=Ka($7||($7=bd([\"A \",\",\",\",0,0,\",\",\",\",\",\"\"])),p[0],p[0],u,t+d*p[0],e)),m+=Ka(V7||(V7=bd([\"L \",\",\",\"\"])),t+n-d*p[1],e),p[1]>0&&(m+=Ka(G7||(G7=bd([\"A \",\",\",\",0,0,\",`,\n        `,\",\",\"\"])),p[1],p[1],u,t+n,e+c*p[1])),m+=Ka(W7||(W7=bd([\"L \",\",\",\"\"])),t+n,e+r-c*p[2]),p[2]>0&&(m+=Ka(K7||(K7=bd([\"A \",\",\",\",0,0,\",`,\n        `,\",\",\"\"])),p[2],p[2],u,t+n-d*p[2],e+r)),m+=Ka(X7||(X7=bd([\"L \",\",\",\"\"])),t+d*p[3],e+r),p[3]>0&&(m+=Ka(Y7||(Y7=bd([\"A \",\",\",\",0,0,\",`,\n        `,\",\",\"\"])),p[3],p[3],u,t,e+r-c*p[3])),m+=\"Z\"}else if(l>0&&i===+i&&i>0){var g=Math.min(l,i);m=Ka(Q7||(Q7=bd([\"M \",\",\",`\n            A `,\",\",\",0,0,\",\",\",\",\",`\n            L `,\",\",`\n            A `,\",\",\",0,0,\",\",\",\",\",`\n            L `,\",\",`\n            A `,\",\",\",0,0,\",\",\",\",\",`\n            L `,\",\",`\n            A `,\",\",\",0,0,\",\",\",\",\",\" Z\"])),t,e+c*g,g,g,u,t+d*g,e,t+n-d*g,e,g,g,u,t+n,e+c*g,t+n,e+r-c*g,g,g,u,t+n-d*g,e+r,t+d*g,e+r,g,g,u,t,e+r-c*g)}else m=Ka(Z7||(Z7=bd([\"M \",\",\",\" h \",\" v \",\" h \",\" Z\"])),t,e,n,r,-n);return m},rG={x:0,y:0,width:0,height:0,radius:0,isAnimationActive:!1,isUpdateAnimationActive:!1,animationBegin:0,animationDuration:1500,animationEasing:\"ease\"},_te=t=>{var e=Ia(t,rG),n=w.useRef(null),[r,i]=w.useState(-1);w.useEffect(()=>{if(n.current&&n.current.getTotalLength)try{var J=n.current.getTotalLength();J&&i(J)}catch{}},[]);var{x:s,y:o,width:l,height:c,radius:d,className:u}=e,{animationEasing:m,animationDuration:p,animationBegin:f,isAnimationActive:y,isUpdateAnimationActive:v}=e,b=w.useRef(l),g=w.useRef(c),_=w.useRef(s),C=w.useRef(o),P=w.useMemo(()=>({x:s,y:o,width:l,height:c,radius:d}),[s,o,l,c,d]),N=Ay(P,\"rectangle-\");if(s!==+s||o!==+o||l!==+l||c!==+c||l===0||c===0)return null;var A=_r(\"recharts-rectangle\",u);if(!v){var T=Cs(e),{radius:F}=T,k=tG(T,kRe);return w.createElement(\"path\",uC({},k,{x:Vh(s),y:Vh(o),width:Vh(l),height:Vh(c),radius:typeof d==\"number\"?d:void 0,className:A,d:nG(s,o,l,c,d)}))}var D=b.current,H=g.current,z=_.current,Q=C.current,L=\"0px \".concat(r===-1?1:r,\"px\"),te=\"\".concat(r,\"px 0px\"),ie=yte([\"strokeDasharray\"],p,typeof m==\"string\"?m:rG.animationEasing);return w.createElement(Ty,{animationId:N,key:N,canBegin:r>0,duration:p,easing:m,isActive:v,begin:f},J=>{var oe=Ir(D,l,J),fe=Ir(H,c,J),re=Ir(z,s,J),W=Ir(Q,o,J);n.current&&(b.current=oe,g.current=fe,_.current=re,C.current=W);var ne;y?J>0?ne={transition:ie,strokeDasharray:te}:ne={strokeDasharray:L}:ne={strokeDasharray:te};var me=Cs(e),{radius:be}=me,Ce=tG(me,NRe);return w.createElement(\"path\",uC({},Ce,{radius:typeof d==\"number\"?d:void 0,className:A,d:nG(re,W,oe,fe,d),ref:n,style:eG(eG({},ne),e.style)}))})};function aG(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function iG(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?aG(Object(n),!0).forEach(function(r){jRe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):aG(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function jRe(t,e,n){return(e=MRe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function MRe(t){var e=ERe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function ERe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var mC=Math.PI/180,DRe=t=>t*180/Math.PI,wi=(t,e,n,r)=>({x:t+Math.cos(-mC*r)*n,y:e+Math.sin(-mC*r)*n}),kte=function(e,n){var r=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{top:0,right:0,bottom:0,left:0};return Math.min(Math.abs(e-(r.left||0)-(r.right||0)),Math.abs(n-(r.top||0)-(r.bottom||0)))/2},FRe=(t,e)=>{var{x:n,y:r}=t,{x:i,y:s}=e;return Math.sqrt((n-i)**2+(r-s)**2)},RRe=(t,e)=>{var{x:n,y:r}=t,{cx:i,cy:s}=e,o=FRe({x:n,y:r},{x:i,y:s});if(o<=0)return{radius:o,angle:0};var l=(n-i)/o,c=Math.acos(l);return r>s&&(c=2*Math.PI-c),{radius:o,angle:DRe(c),angleInRadian:c}},LRe=t=>{var{startAngle:e,endAngle:n}=t,r=Math.floor(e/360),i=Math.floor(n/360),s=Math.min(r,i);return{startAngle:e-s*360,endAngle:n-s*360}},ORe=(t,e)=>{var{startAngle:n,endAngle:r}=e,i=Math.floor(n/360),s=Math.floor(r/360),o=Math.min(i,s);return t+o*360},IRe=(t,e)=>{var{chartX:n,chartY:r}=t,{radius:i,angle:s}=RRe({x:n,y:r},e),{innerRadius:o,outerRadius:l}=e;if(i<o||i>l||i===0)return null;var{startAngle:c,endAngle:d}=LRe(e),u=s,m;if(c<=d){for(;u>d;)u-=360;for(;u<c;)u+=360;m=u>=c&&u<=d}else{for(;u>c;)u-=360;for(;u<d;)u+=360;m=u>=d&&u<=c}return m?iG(iG({},e),{},{radius:i,angle:ORe(u,e)}):null};function Nte(t){var{cx:e,cy:n,radius:r,startAngle:i,endAngle:s}=t,o=wi(e,n,r,i),l=wi(e,n,r,s);return{points:[o,l],cx:e,cy:n,radius:r,startAngle:i,endAngle:s}}var sG,oG,lG,cG,dG,uG,mG;function XR(){return XR=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},XR.apply(null,arguments)}function Lf(t,e){return e||(e=t.slice(0)),Object.freeze(Object.defineProperties(t,{raw:{value:Object.freeze(e)}}))}var zRe=(t,e)=>{var n=Qi(e-t),r=Math.min(Math.abs(e-t),359.999);return n*r},ok=t=>{var{cx:e,cy:n,radius:r,angle:i,sign:s,isExternal:o,cornerRadius:l,cornerIsExternal:c}=t,d=l*(o?1:-1)+r,u=Math.asin(l/d)/mC,m=c?i:i+s*u,p=wi(e,n,d,m),f=wi(e,n,r,m),y=c?i-s*u:i,v=wi(e,n,d*Math.cos(u*mC),y);return{center:p,circleTangency:f,lineTangency:v,theta:u}},Cte=t=>{var{cx:e,cy:n,innerRadius:r,outerRadius:i,startAngle:s,endAngle:o}=t,l=zRe(s,o),c=s+l,d=wi(e,n,i,s),u=wi(e,n,i,c),m=Ka(sG||(sG=Lf([\"M \",\",\",`\n    A `,\",\",`,0,\n    `,\",\",`,\n    `,\",\",`\n  `])),d.x,d.y,i,i,+(Math.abs(l)>180),+(s>c),u.x,u.y);if(r>0){var p=wi(e,n,r,s),f=wi(e,n,r,c);m+=Ka(oG||(oG=Lf([\"L \",\",\",`\n            A `,\",\",`,0,\n            `,\",\",`,\n            `,\",\",\" Z\"])),f.x,f.y,r,r,+(Math.abs(l)>180),+(s<=c),p.x,p.y)}else m+=Ka(lG||(lG=Lf([\"L \",\",\",\" Z\"])),e,n);return m},URe=t=>{var{cx:e,cy:n,innerRadius:r,outerRadius:i,cornerRadius:s,forceCornerRadius:o,cornerIsExternal:l,startAngle:c,endAngle:d}=t,u=Qi(d-c),{circleTangency:m,lineTangency:p,theta:f}=ok({cx:e,cy:n,radius:i,angle:c,sign:u,cornerRadius:s,cornerIsExternal:l}),{circleTangency:y,lineTangency:v,theta:b}=ok({cx:e,cy:n,radius:i,angle:d,sign:-u,cornerRadius:s,cornerIsExternal:l}),g=l?Math.abs(c-d):Math.abs(c-d)-f-b;if(g<0)return o?Ka(cG||(cG=Lf([\"M \",\",\",`\n        a`,\",\",\",0,0,1,\",`,0\n        a`,\",\",\",0,0,1,\",`,0\n      `])),p.x,p.y,s,s,s*2,s,s,-s*2):Cte({cx:e,cy:n,innerRadius:r,outerRadius:i,startAngle:c,endAngle:d});var _=Ka(dG||(dG=Lf([\"M \",\",\",`\n    A`,\",\",\",0,0,\",\",\",\",\",`\n    A`,\",\",\",0,\",\",\",\",\",\",\",`\n    A`,\",\",\",0,0,\",\",\",\",\",`\n  `])),p.x,p.y,s,s,+(u<0),m.x,m.y,i,i,+(g>180),+(u<0),y.x,y.y,s,s,+(u<0),v.x,v.y);if(r>0){var{circleTangency:C,lineTangency:P,theta:N}=ok({cx:e,cy:n,radius:r,angle:c,sign:u,isExternal:!0,cornerRadius:s,cornerIsExternal:l}),{circleTangency:A,lineTangency:T,theta:F}=ok({cx:e,cy:n,radius:r,angle:d,sign:-u,isExternal:!0,cornerRadius:s,cornerIsExternal:l}),k=l?Math.abs(c-d):Math.abs(c-d)-N-F;if(k<0&&s===0)return\"\".concat(_,\"L\").concat(e,\",\").concat(n,\"Z\");_+=Ka(uG||(uG=Lf([\"L\",\",\",`\n      A`,\",\",\",0,0,\",\",\",\",\",`\n      A`,\",\",\",0,\",\",\",\",\",\",\",`\n      A`,\",\",\",0,0,\",\",\",\",\",\"Z\"])),T.x,T.y,s,s,+(u<0),A.x,A.y,r,r,+(k>180),+(u>0),C.x,C.y,s,s,+(u<0),P.x,P.y)}else _+=Ka(mG||(mG=Lf([\"L\",\",\",\"Z\"])),e,n);return _},BRe={cx:0,cy:0,innerRadius:0,outerRadius:0,startAngle:0,endAngle:0,cornerRadius:0,forceCornerRadius:!1,cornerIsExternal:!1},Pte=t=>{var e=Ia(t,BRe),{cx:n,cy:r,innerRadius:i,outerRadius:s,cornerRadius:o,forceCornerRadius:l,cornerIsExternal:c,startAngle:d,endAngle:u,className:m}=e;if(s<i||d===u)return null;var p=_r(\"recharts-sector\",m),f=s-i,y=Hs(o,f,0,!0),v;return y>0&&Math.abs(d-u)<360?v=URe({cx:n,cy:r,innerRadius:i,outerRadius:s,cornerRadius:Math.min(y,f/2),forceCornerRadius:l,cornerIsExternal:c,startAngle:d,endAngle:u}):v=Cte({cx:n,cy:r,innerRadius:i,outerRadius:s,startAngle:d,endAngle:u}),w.createElement(\"path\",XR({},Cs(e),{className:p,d:v}))};function HRe(t,e,n){if(t===\"horizontal\")return[{x:e.x,y:n.top},{x:e.x,y:n.top+n.height}];if(t===\"vertical\")return[{x:n.left,y:e.y},{x:n.left+n.width,y:e.y}];if(aee(e)){if(t===\"centric\"){var{cx:r,cy:i,innerRadius:s,outerRadius:o,angle:l}=e,c=wi(r,i,s,l),d=wi(r,i,o,l);return[{x:c.x,y:c.y},{x:d.x,y:d.y}]}return Nte(e)}}var nD={},rD={},aD={},hG;function qRe(){return hG||(hG=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=gee();function n(r){return e.isSymbol(r)?NaN:Number(r)}t.toNumber=n})(aD)),aD}var pG;function $Re(){return pG||(pG=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=qRe();function n(r){return r?(r=e.toNumber(r),r===1/0||r===-1/0?(r<0?-1:1)*Number.MAX_VALUE:r===r?r:0):r===0?r:0}t.toFinite=n})(rD)),rD}var fG;function VRe(){return fG||(fG=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=bee(),n=$Re();function r(i,s,o){o&&typeof o!=\"number\"&&e.isIterateeCall(i,s,o)&&(s=o=void 0),i=n.toFinite(i),s===void 0?(s=i,i=0):s=n.toFinite(s),o=o===void 0?i<s?1:-1:n.toFinite(o);const l=Math.max(Math.ceil((s-i)/(o||1)),0),c=new Array(l);for(let d=0;d<l;d++)c[d]=i,i+=o;return c}t.range=r})(nD)),nD}var iD,gG;function GRe(){return gG||(gG=1,iD=VRe().range),iD}var WRe=GRe();const Tte=bc(WRe);function dp(t,e){return t==null||e==null?NaN:t<e?-1:t>e?1:t>=e?0:NaN}function KRe(t,e){return t==null||e==null?NaN:e<t?-1:e>t?1:e>=t?0:NaN}function WI(t){let e,n,r;t.length!==2?(e=dp,n=(l,c)=>dp(t(l),c),r=(l,c)=>t(l)-c):(e=t===dp||t===KRe?t:XRe,n=t,r=t);function i(l,c,d=0,u=l.length){if(d<u){if(e(c,c)!==0)return u;do{const m=d+u>>>1;n(l[m],c)<0?d=m+1:u=m}while(d<u)}return d}function s(l,c,d=0,u=l.length){if(d<u){if(e(c,c)!==0)return u;do{const m=d+u>>>1;n(l[m],c)<=0?d=m+1:u=m}while(d<u)}return d}function o(l,c,d=0,u=l.length){const m=i(l,c,d,u-1);return m>d&&r(l[m-1],c)>-r(l[m],c)?m-1:m}return{left:i,center:o,right:s}}function XRe(){return 0}function Ate(t){return t===null?NaN:+t}function*YRe(t,e){for(let n of t)n!=null&&(n=+n)>=n&&(yield n)}const QRe=WI(dp),wS=QRe.right;WI(Ate).center;class bG extends Map{constructor(e,n=eLe){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),e!=null)for(const[r,i]of e)this.set(r,i)}get(e){return super.get(xG(this,e))}has(e){return super.has(xG(this,e))}set(e,n){return super.set(ZRe(this,e),n)}delete(e){return super.delete(JRe(this,e))}}function xG({_intern:t,_key:e},n){const r=e(n);return t.has(r)?t.get(r):n}function ZRe({_intern:t,_key:e},n){const r=e(n);return t.has(r)?t.get(r):(t.set(r,n),n)}function JRe({_intern:t,_key:e},n){const r=e(n);return t.has(r)&&(n=t.get(r),t.delete(r)),n}function eLe(t){return t!==null&&typeof t==\"object\"?t.valueOf():t}function tLe(t=dp){if(t===dp)return jte;if(typeof t!=\"function\")throw new TypeError(\"compare is not a function\");return(e,n)=>{const r=t(e,n);return r||r===0?r:(t(n,n)===0)-(t(e,e)===0)}}function jte(t,e){return(t==null||!(t>=t))-(e==null||!(e>=e))||(t<e?-1:t>e?1:0)}const nLe=Math.sqrt(50),rLe=Math.sqrt(10),aLe=Math.sqrt(2);function hC(t,e,n){const r=(e-t)/Math.max(0,n),i=Math.floor(Math.log10(r)),s=r/Math.pow(10,i),o=s>=nLe?10:s>=rLe?5:s>=aLe?2:1;let l,c,d;return i<0?(d=Math.pow(10,-i)/o,l=Math.round(t*d),c=Math.round(e*d),l/d<t&&++l,c/d>e&&--c,d=-d):(d=Math.pow(10,i)*o,l=Math.round(t/d),c=Math.round(e/d),l*d<t&&++l,c*d>e&&--c),c<l&&.5<=n&&n<2?hC(t,e,n*2):[l,c,d]}function YR(t,e,n){if(e=+e,t=+t,n=+n,!(n>0))return[];if(t===e)return[t];const r=e<t,[i,s,o]=r?hC(e,t,n):hC(t,e,n);if(!(s>=i))return[];const l=s-i+1,c=new Array(l);if(r)if(o<0)for(let d=0;d<l;++d)c[d]=(s-d)/-o;else for(let d=0;d<l;++d)c[d]=(s-d)*o;else if(o<0)for(let d=0;d<l;++d)c[d]=(i+d)/-o;else for(let d=0;d<l;++d)c[d]=(i+d)*o;return c}function QR(t,e,n){return e=+e,t=+t,n=+n,hC(t,e,n)[2]}function ZR(t,e,n){e=+e,t=+t,n=+n;const r=e<t,i=r?QR(e,t,n):QR(t,e,n);return(r?-1:1)*(i<0?1/-i:i)}function yG(t,e){let n;for(const r of t)r!=null&&(n<r||n===void 0&&r>=r)&&(n=r);return n}function vG(t,e){let n;for(const r of t)r!=null&&(n>r||n===void 0&&r>=r)&&(n=r);return n}function Mte(t,e,n=0,r=1/0,i){if(e=Math.floor(e),n=Math.floor(Math.max(0,n)),r=Math.floor(Math.min(t.length-1,r)),!(n<=e&&e<=r))return t;for(i=i===void 0?jte:tLe(i);r>n;){if(r-n>600){const c=r-n+1,d=e-n+1,u=Math.log(c),m=.5*Math.exp(2*u/3),p=.5*Math.sqrt(u*m*(c-m)/c)*(d-c/2<0?-1:1),f=Math.max(n,Math.floor(e-d*m/c+p)),y=Math.min(r,Math.floor(e+(c-d)*m/c+p));Mte(t,e,f,y,i)}const s=t[e];let o=n,l=r;for(Yv(t,n,e),i(t[r],s)>0&&Yv(t,n,r);o<l;){for(Yv(t,o,l),++o,--l;i(t[o],s)<0;)++o;for(;i(t[l],s)>0;)--l}i(t[n],s)===0?Yv(t,n,l):(++l,Yv(t,l,r)),l<=e&&(n=l+1),e<=l&&(r=l-1)}return t}function Yv(t,e,n){const r=t[e];t[e]=t[n],t[n]=r}function iLe(t,e,n){if(t=Float64Array.from(YRe(t)),!(!(r=t.length)||isNaN(e=+e))){if(e<=0||r<2)return vG(t);if(e>=1)return yG(t);var r,i=(r-1)*e,s=Math.floor(i),o=yG(Mte(t,s).subarray(0,s+1)),l=vG(t.subarray(s+1));return o+(l-o)*(i-s)}}function sLe(t,e,n=Ate){if(!(!(r=t.length)||isNaN(e=+e))){if(e<=0||r<2)return+n(t[0],0,t);if(e>=1)return+n(t[r-1],r-1,t);var r,i=(r-1)*e,s=Math.floor(i),o=+n(t[s],s,t),l=+n(t[s+1],s+1,t);return o+(l-o)*(i-s)}}function oLe(t,e,n){t=+t,e=+e,n=(i=arguments.length)<2?(e=t,t=0,1):i<3?1:+n;for(var r=-1,i=Math.max(0,Math.ceil((e-t)/n))|0,s=new Array(i);++r<i;)s[r]=t+r*n;return s}function yc(t,e){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(e).domain(t);break}return this}function Em(t,e){switch(arguments.length){case 0:break;case 1:{typeof t==\"function\"?this.interpolator(t):this.range(t);break}default:{this.domain(t),typeof e==\"function\"?this.interpolator(e):this.range(e);break}}return this}const JR=Symbol(\"implicit\");function KI(){var t=new bG,e=[],n=[],r=JR;function i(s){let o=t.get(s);if(o===void 0){if(r!==JR)return r;t.set(s,o=e.push(s)-1)}return n[o%n.length]}return i.domain=function(s){if(!arguments.length)return e.slice();e=[],t=new bG;for(const o of s)t.has(o)||t.set(o,e.push(o)-1);return i},i.range=function(s){return arguments.length?(n=Array.from(s),i):n.slice()},i.unknown=function(s){return arguments.length?(r=s,i):r},i.copy=function(){return KI(e,n).unknown(r)},yc.apply(i,arguments),i}function XI(){var t=KI().unknown(void 0),e=t.domain,n=t.range,r=0,i=1,s,o,l=!1,c=0,d=0,u=.5;delete t.unknown;function m(){var p=e().length,f=i<r,y=f?i:r,v=f?r:i;s=(v-y)/Math.max(1,p-c+d*2),l&&(s=Math.floor(s)),y+=(v-y-s*(p-c))*u,o=s*(1-c),l&&(y=Math.round(y),o=Math.round(o));var b=oLe(p).map(function(g){return y+s*g});return n(f?b.reverse():b)}return t.domain=function(p){return arguments.length?(e(p),m()):e()},t.range=function(p){return arguments.length?([r,i]=p,r=+r,i=+i,m()):[r,i]},t.rangeRound=function(p){return[r,i]=p,r=+r,i=+i,l=!0,m()},t.bandwidth=function(){return o},t.step=function(){return s},t.round=function(p){return arguments.length?(l=!!p,m()):l},t.padding=function(p){return arguments.length?(c=Math.min(1,d=+p),m()):c},t.paddingInner=function(p){return arguments.length?(c=Math.min(1,p),m()):c},t.paddingOuter=function(p){return arguments.length?(d=+p,m()):d},t.align=function(p){return arguments.length?(u=Math.max(0,Math.min(1,p)),m()):u},t.copy=function(){return XI(e(),[r,i]).round(l).paddingInner(c).paddingOuter(d).align(u)},yc.apply(m(),arguments)}function Ete(t){var e=t.copy;return t.padding=t.paddingOuter,delete t.paddingInner,delete t.paddingOuter,t.copy=function(){return Ete(e())},t}function lLe(){return Ete(XI.apply(null,arguments).paddingInner(1))}function YI(t,e,n){t.prototype=e.prototype=n,n.constructor=t}function Dte(t,e){var n=Object.create(t.prototype);for(var r in e)n[r]=e[r];return n}function SS(){}var Cw=.7,pC=1/Cw,Lx=\"\\\\s*([+-]?\\\\d+)\\\\s*\",Pw=\"\\\\s*([+-]?(?:\\\\d*\\\\.)?\\\\d+(?:[eE][+-]?\\\\d+)?)\\\\s*\",Id=\"\\\\s*([+-]?(?:\\\\d*\\\\.)?\\\\d+(?:[eE][+-]?\\\\d+)?)%\\\\s*\",cLe=/^#([0-9a-f]{3,8})$/,dLe=new RegExp(`^rgb\\\\(${Lx},${Lx},${Lx}\\\\)$`),uLe=new RegExp(`^rgb\\\\(${Id},${Id},${Id}\\\\)$`),mLe=new RegExp(`^rgba\\\\(${Lx},${Lx},${Lx},${Pw}\\\\)$`),hLe=new RegExp(`^rgba\\\\(${Id},${Id},${Id},${Pw}\\\\)$`),pLe=new RegExp(`^hsl\\\\(${Pw},${Id},${Id}\\\\)$`),fLe=new RegExp(`^hsla\\\\(${Pw},${Id},${Id},${Pw}\\\\)$`),wG={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};YI(SS,Tw,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:SG,formatHex:SG,formatHex8:gLe,formatHsl:bLe,formatRgb:_G,toString:_G});function SG(){return this.rgb().formatHex()}function gLe(){return this.rgb().formatHex8()}function bLe(){return Fte(this).formatHsl()}function _G(){return this.rgb().formatRgb()}function Tw(t){var e,n;return t=(t+\"\").trim().toLowerCase(),(e=cLe.exec(t))?(n=e[1].length,e=parseInt(e[1],16),n===6?kG(e):n===3?new Io(e>>8&15|e>>4&240,e>>4&15|e&240,(e&15)<<4|e&15,1):n===8?lk(e>>24&255,e>>16&255,e>>8&255,(e&255)/255):n===4?lk(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|e&240,((e&15)<<4|e&15)/255):null):(e=dLe.exec(t))?new Io(e[1],e[2],e[3],1):(e=uLe.exec(t))?new Io(e[1]*255/100,e[2]*255/100,e[3]*255/100,1):(e=mLe.exec(t))?lk(e[1],e[2],e[3],e[4]):(e=hLe.exec(t))?lk(e[1]*255/100,e[2]*255/100,e[3]*255/100,e[4]):(e=pLe.exec(t))?PG(e[1],e[2]/100,e[3]/100,1):(e=fLe.exec(t))?PG(e[1],e[2]/100,e[3]/100,e[4]):wG.hasOwnProperty(t)?kG(wG[t]):t===\"transparent\"?new Io(NaN,NaN,NaN,0):null}function kG(t){return new Io(t>>16&255,t>>8&255,t&255,1)}function lk(t,e,n,r){return r<=0&&(t=e=n=NaN),new Io(t,e,n,r)}function xLe(t){return t instanceof SS||(t=Tw(t)),t?(t=t.rgb(),new Io(t.r,t.g,t.b,t.opacity)):new Io}function eL(t,e,n,r){return arguments.length===1?xLe(t):new Io(t,e,n,r??1)}function Io(t,e,n,r){this.r=+t,this.g=+e,this.b=+n,this.opacity=+r}YI(Io,eL,Dte(SS,{brighter(t){return t=t==null?pC:Math.pow(pC,t),new Io(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=t==null?Cw:Math.pow(Cw,t),new Io(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new Io(Zf(this.r),Zf(this.g),Zf(this.b),fC(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:NG,formatHex:NG,formatHex8:yLe,formatRgb:CG,toString:CG}));function NG(){return`#${Of(this.r)}${Of(this.g)}${Of(this.b)}`}function yLe(){return`#${Of(this.r)}${Of(this.g)}${Of(this.b)}${Of((isNaN(this.opacity)?1:this.opacity)*255)}`}function CG(){const t=fC(this.opacity);return`${t===1?\"rgb(\":\"rgba(\"}${Zf(this.r)}, ${Zf(this.g)}, ${Zf(this.b)}${t===1?\")\":`, ${t})`}`}function fC(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function Zf(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function Of(t){return t=Zf(t),(t<16?\"0\":\"\")+t.toString(16)}function PG(t,e,n,r){return r<=0?t=e=n=NaN:n<=0||n>=1?t=e=NaN:e<=0&&(t=NaN),new qc(t,e,n,r)}function Fte(t){if(t instanceof qc)return new qc(t.h,t.s,t.l,t.opacity);if(t instanceof SS||(t=Tw(t)),!t)return new qc;if(t instanceof qc)return t;t=t.rgb();var e=t.r/255,n=t.g/255,r=t.b/255,i=Math.min(e,n,r),s=Math.max(e,n,r),o=NaN,l=s-i,c=(s+i)/2;return l?(e===s?o=(n-r)/l+(n<r)*6:n===s?o=(r-e)/l+2:o=(e-n)/l+4,l/=c<.5?s+i:2-s-i,o*=60):l=c>0&&c<1?0:o,new qc(o,l,c,t.opacity)}function vLe(t,e,n,r){return arguments.length===1?Fte(t):new qc(t,e,n,r??1)}function qc(t,e,n,r){this.h=+t,this.s=+e,this.l=+n,this.opacity=+r}YI(qc,vLe,Dte(SS,{brighter(t){return t=t==null?pC:Math.pow(pC,t),new qc(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=t==null?Cw:Math.pow(Cw,t),new qc(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+(this.h<0)*360,e=isNaN(t)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*e,i=2*n-r;return new Io(sD(t>=240?t-240:t+120,i,r),sD(t,i,r),sD(t<120?t+240:t-120,i,r),this.opacity)},clamp(){return new qc(TG(this.h),ck(this.s),ck(this.l),fC(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=fC(this.opacity);return`${t===1?\"hsl(\":\"hsla(\"}${TG(this.h)}, ${ck(this.s)*100}%, ${ck(this.l)*100}%${t===1?\")\":`, ${t})`}`}}));function TG(t){return t=(t||0)%360,t<0?t+360:t}function ck(t){return Math.max(0,Math.min(1,t||0))}function sD(t,e,n){return(t<60?e+(n-e)*t/60:t<180?n:t<240?e+(n-e)*(240-t)/60:e)*255}const QI=t=>()=>t;function wLe(t,e){return function(n){return t+n*e}}function SLe(t,e,n){return t=Math.pow(t,n),e=Math.pow(e,n)-t,n=1/n,function(r){return Math.pow(t+r*e,n)}}function _Le(t){return(t=+t)==1?Rte:function(e,n){return n-e?SLe(e,n,t):QI(isNaN(e)?n:e)}}function Rte(t,e){var n=e-t;return n?wLe(t,n):QI(isNaN(t)?e:t)}const AG=(function t(e){var n=_Le(e);function r(i,s){var o=n((i=eL(i)).r,(s=eL(s)).r),l=n(i.g,s.g),c=n(i.b,s.b),d=Rte(i.opacity,s.opacity);return function(u){return i.r=o(u),i.g=l(u),i.b=c(u),i.opacity=d(u),i+\"\"}}return r.gamma=t,r})(1);function kLe(t,e){e||(e=[]);var n=t?Math.min(e.length,t.length):0,r=e.slice(),i;return function(s){for(i=0;i<n;++i)r[i]=t[i]*(1-s)+e[i]*s;return r}}function NLe(t){return ArrayBuffer.isView(t)&&!(t instanceof DataView)}function CLe(t,e){var n=e?e.length:0,r=t?Math.min(n,t.length):0,i=new Array(r),s=new Array(n),o;for(o=0;o<r;++o)i[o]=jy(t[o],e[o]);for(;o<n;++o)s[o]=e[o];return function(l){for(o=0;o<r;++o)s[o]=i[o](l);return s}}function PLe(t,e){var n=new Date;return t=+t,e=+e,function(r){return n.setTime(t*(1-r)+e*r),n}}function gC(t,e){return t=+t,e=+e,function(n){return t*(1-n)+e*n}}function TLe(t,e){var n={},r={},i;(t===null||typeof t!=\"object\")&&(t={}),(e===null||typeof e!=\"object\")&&(e={});for(i in e)i in t?n[i]=jy(t[i],e[i]):r[i]=e[i];return function(s){for(i in n)r[i]=n[i](s);return r}}var tL=/[-+]?(?:\\d+\\.?\\d*|\\.?\\d+)(?:[eE][-+]?\\d+)?/g,oD=new RegExp(tL.source,\"g\");function ALe(t){return function(){return t}}function jLe(t){return function(e){return t(e)+\"\"}}function MLe(t,e){var n=tL.lastIndex=oD.lastIndex=0,r,i,s,o=-1,l=[],c=[];for(t=t+\"\",e=e+\"\";(r=tL.exec(t))&&(i=oD.exec(e));)(s=i.index)>n&&(s=e.slice(n,s),l[o]?l[o]+=s:l[++o]=s),(r=r[0])===(i=i[0])?l[o]?l[o]+=i:l[++o]=i:(l[++o]=null,c.push({i:o,x:gC(r,i)})),n=oD.lastIndex;return n<e.length&&(s=e.slice(n),l[o]?l[o]+=s:l[++o]=s),l.length<2?c[0]?jLe(c[0].x):ALe(e):(e=c.length,function(d){for(var u=0,m;u<e;++u)l[(m=c[u]).i]=m.x(d);return l.join(\"\")})}function jy(t,e){var n=typeof e,r;return e==null||n===\"boolean\"?QI(e):(n===\"number\"?gC:n===\"string\"?(r=Tw(e))?(e=r,AG):MLe:e instanceof Tw?AG:e instanceof Date?PLe:NLe(e)?kLe:Array.isArray(e)?CLe:typeof e.valueOf!=\"function\"&&typeof e.toString!=\"function\"||isNaN(e)?TLe:gC)(t,e)}function ZI(t,e){return t=+t,e=+e,function(n){return Math.round(t*(1-n)+e*n)}}function ELe(t,e){e===void 0&&(e=t,t=jy);for(var n=0,r=e.length-1,i=e[0],s=new Array(r<0?0:r);n<r;)s[n]=t(i,i=e[++n]);return function(o){var l=Math.max(0,Math.min(r-1,Math.floor(o*=r)));return s[l](o-l)}}function DLe(t){return function(){return t}}function bC(t){return+t}var jG=[0,1];function ao(t){return t}function nL(t,e){return(e-=t=+t)?function(n){return(n-t)/e}:DLe(isNaN(e)?NaN:.5)}function FLe(t,e){var n;return t>e&&(n=t,t=e,e=n),function(r){return Math.max(t,Math.min(e,r))}}function RLe(t,e,n){var r=t[0],i=t[1],s=e[0],o=e[1];return i<r?(r=nL(i,r),s=n(o,s)):(r=nL(r,i),s=n(s,o)),function(l){return s(r(l))}}function LLe(t,e,n){var r=Math.min(t.length,e.length)-1,i=new Array(r),s=new Array(r),o=-1;for(t[r]<t[0]&&(t=t.slice().reverse(),e=e.slice().reverse());++o<r;)i[o]=nL(t[o],t[o+1]),s[o]=n(e[o],e[o+1]);return function(l){var c=wS(t,l,1,r)-1;return s[c](i[c](l))}}function _S(t,e){return e.domain(t.domain()).range(t.range()).interpolate(t.interpolate()).clamp(t.clamp()).unknown(t.unknown())}function CT(){var t=jG,e=jG,n=jy,r,i,s,o=ao,l,c,d;function u(){var p=Math.min(t.length,e.length);return o!==ao&&(o=FLe(t[0],t[p-1])),l=p>2?LLe:RLe,c=d=null,m}function m(p){return p==null||isNaN(p=+p)?s:(c||(c=l(t.map(r),e,n)))(r(o(p)))}return m.invert=function(p){return o(i((d||(d=l(e,t.map(r),gC)))(p)))},m.domain=function(p){return arguments.length?(t=Array.from(p,bC),u()):t.slice()},m.range=function(p){return arguments.length?(e=Array.from(p),u()):e.slice()},m.rangeRound=function(p){return e=Array.from(p),n=ZI,u()},m.clamp=function(p){return arguments.length?(o=p?!0:ao,u()):o!==ao},m.interpolate=function(p){return arguments.length?(n=p,u()):n},m.unknown=function(p){return arguments.length?(s=p,m):s},function(p,f){return r=p,i=f,u()}}function JI(){return CT()(ao,ao)}function OLe(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString(\"en\").replace(/,/g,\"\"):t.toString(10)}function xC(t,e){if(!isFinite(t)||t===0)return null;var n=(t=e?t.toExponential(e-1):t.toExponential()).indexOf(\"e\"),r=t.slice(0,n);return[r.length>1?r[0]+r.slice(2):r,+t.slice(n+1)]}function ry(t){return t=xC(Math.abs(t)),t?t[1]:NaN}function ILe(t,e){return function(n,r){for(var i=n.length,s=[],o=0,l=t[0],c=0;i>0&&l>0&&(c+l+1>r&&(l=Math.max(1,r-c)),s.push(n.substring(i-=l,i+l)),!((c+=l+1)>r));)l=t[o=(o+1)%t.length];return s.reverse().join(e)}}function zLe(t){return function(e){return e.replace(/[0-9]/g,function(n){return t[+n]})}}var ULe=/^(?:(.)?([<>=^]))?([+\\-( ])?([$#])?(0)?(\\d+)?(,)?(\\.\\d+)?(~)?([a-z%])?$/i;function Aw(t){if(!(e=ULe.exec(t)))throw new Error(\"invalid format: \"+t);var e;return new e4({fill:e[1],align:e[2],sign:e[3],symbol:e[4],zero:e[5],width:e[6],comma:e[7],precision:e[8]&&e[8].slice(1),trim:e[9],type:e[10]})}Aw.prototype=e4.prototype;function e4(t){this.fill=t.fill===void 0?\" \":t.fill+\"\",this.align=t.align===void 0?\">\":t.align+\"\",this.sign=t.sign===void 0?\"-\":t.sign+\"\",this.symbol=t.symbol===void 0?\"\":t.symbol+\"\",this.zero=!!t.zero,this.width=t.width===void 0?void 0:+t.width,this.comma=!!t.comma,this.precision=t.precision===void 0?void 0:+t.precision,this.trim=!!t.trim,this.type=t.type===void 0?\"\":t.type+\"\"}e4.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?\"0\":\"\")+(this.width===void 0?\"\":Math.max(1,this.width|0))+(this.comma?\",\":\"\")+(this.precision===void 0?\"\":\".\"+Math.max(0,this.precision|0))+(this.trim?\"~\":\"\")+this.type};function BLe(t){e:for(var e=t.length,n=1,r=-1,i;n<e;++n)switch(t[n]){case\".\":r=i=n;break;case\"0\":r===0&&(r=n),i=n;break;default:if(!+t[n])break e;r>0&&(r=0);break}return r>0?t.slice(0,r)+t.slice(i+1):t}var yC;function HLe(t,e){var n=xC(t,e);if(!n)return yC=void 0,t.toPrecision(e);var r=n[0],i=n[1],s=i-(yC=Math.max(-8,Math.min(8,Math.floor(i/3)))*3)+1,o=r.length;return s===o?r:s>o?r+new Array(s-o+1).join(\"0\"):s>0?r.slice(0,s)+\".\"+r.slice(s):\"0.\"+new Array(1-s).join(\"0\")+xC(t,Math.max(0,e+s-1))[0]}function MG(t,e){var n=xC(t,e);if(!n)return t+\"\";var r=n[0],i=n[1];return i<0?\"0.\"+new Array(-i).join(\"0\")+r:r.length>i+1?r.slice(0,i+1)+\".\"+r.slice(i+1):r+new Array(i-r.length+2).join(\"0\")}const EG={\"%\":(t,e)=>(t*100).toFixed(e),b:t=>Math.round(t).toString(2),c:t=>t+\"\",d:OLe,e:(t,e)=>t.toExponential(e),f:(t,e)=>t.toFixed(e),g:(t,e)=>t.toPrecision(e),o:t=>Math.round(t).toString(8),p:(t,e)=>MG(t*100,e),r:MG,s:HLe,X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function DG(t){return t}var FG=Array.prototype.map,RG=[\"y\",\"z\",\"a\",\"f\",\"p\",\"n\",\"µ\",\"m\",\"\",\"k\",\"M\",\"G\",\"T\",\"P\",\"E\",\"Z\",\"Y\"];function qLe(t){var e=t.grouping===void 0||t.thousands===void 0?DG:ILe(FG.call(t.grouping,Number),t.thousands+\"\"),n=t.currency===void 0?\"\":t.currency[0]+\"\",r=t.currency===void 0?\"\":t.currency[1]+\"\",i=t.decimal===void 0?\".\":t.decimal+\"\",s=t.numerals===void 0?DG:zLe(FG.call(t.numerals,String)),o=t.percent===void 0?\"%\":t.percent+\"\",l=t.minus===void 0?\"−\":t.minus+\"\",c=t.nan===void 0?\"NaN\":t.nan+\"\";function d(m,p){m=Aw(m);var f=m.fill,y=m.align,v=m.sign,b=m.symbol,g=m.zero,_=m.width,C=m.comma,P=m.precision,N=m.trim,A=m.type;A===\"n\"?(C=!0,A=\"g\"):EG[A]||(P===void 0&&(P=12),N=!0,A=\"g\"),(g||f===\"0\"&&y===\"=\")&&(g=!0,f=\"0\",y=\"=\");var T=(p&&p.prefix!==void 0?p.prefix:\"\")+(b===\"$\"?n:b===\"#\"&&/[boxX]/.test(A)?\"0\"+A.toLowerCase():\"\"),F=(b===\"$\"?r:/[%p]/.test(A)?o:\"\")+(p&&p.suffix!==void 0?p.suffix:\"\"),k=EG[A],D=/[defgprs%]/.test(A);P=P===void 0?6:/[gprs]/.test(A)?Math.max(1,Math.min(21,P)):Math.max(0,Math.min(20,P));function H(z){var Q=T,L=F,te,ie,J;if(A===\"c\")L=k(z)+L,z=\"\";else{z=+z;var oe=z<0||1/z<0;if(z=isNaN(z)?c:k(Math.abs(z),P),N&&(z=BLe(z)),oe&&+z==0&&v!==\"+\"&&(oe=!1),Q=(oe?v===\"(\"?v:l:v===\"-\"||v===\"(\"?\"\":v)+Q,L=(A===\"s\"&&!isNaN(z)&&yC!==void 0?RG[8+yC/3]:\"\")+L+(oe&&v===\"(\"?\")\":\"\"),D){for(te=-1,ie=z.length;++te<ie;)if(J=z.charCodeAt(te),48>J||J>57){L=(J===46?i+z.slice(te+1):z.slice(te))+L,z=z.slice(0,te);break}}}C&&!g&&(z=e(z,1/0));var fe=Q.length+z.length+L.length,re=fe<_?new Array(_-fe+1).join(f):\"\";switch(C&&g&&(z=e(re+z,re.length?_-L.length:1/0),re=\"\"),y){case\"<\":z=Q+z+L+re;break;case\"=\":z=Q+re+z+L;break;case\"^\":z=re.slice(0,fe=re.length>>1)+Q+z+L+re.slice(fe);break;default:z=re+Q+z+L;break}return s(z)}return H.toString=function(){return m+\"\"},H}function u(m,p){var f=Math.max(-8,Math.min(8,Math.floor(ry(p)/3)))*3,y=Math.pow(10,-f),v=d((m=Aw(m),m.type=\"f\",m),{suffix:RG[8+f/3]});return function(b){return v(y*b)}}return{format:d,formatPrefix:u}}var dk,t4,Lte;$Le({thousands:\",\",grouping:[3],currency:[\"$\",\"\"]});function $Le(t){return dk=qLe(t),t4=dk.format,Lte=dk.formatPrefix,dk}function VLe(t){return Math.max(0,-ry(Math.abs(t)))}function GLe(t,e){return Math.max(0,Math.max(-8,Math.min(8,Math.floor(ry(e)/3)))*3-ry(Math.abs(t)))}function WLe(t,e){return t=Math.abs(t),e=Math.abs(e)-t,Math.max(0,ry(e)-ry(t))+1}function Ote(t,e,n,r){var i=ZR(t,e,n),s;switch(r=Aw(r??\",f\"),r.type){case\"s\":{var o=Math.max(Math.abs(t),Math.abs(e));return r.precision==null&&!isNaN(s=GLe(i,o))&&(r.precision=s),Lte(r,o)}case\"\":case\"e\":case\"g\":case\"p\":case\"r\":{r.precision==null&&!isNaN(s=WLe(i,Math.max(Math.abs(t),Math.abs(e))))&&(r.precision=s-(r.type===\"e\"));break}case\"f\":case\"%\":{r.precision==null&&!isNaN(s=VLe(i))&&(r.precision=s-(r.type===\"%\")*2);break}}return t4(r)}function Mp(t){var e=t.domain;return t.ticks=function(n){var r=e();return YR(r[0],r[r.length-1],n??10)},t.tickFormat=function(n,r){var i=e();return Ote(i[0],i[i.length-1],n??10,r)},t.nice=function(n){n==null&&(n=10);var r=e(),i=0,s=r.length-1,o=r[i],l=r[s],c,d,u=10;for(l<o&&(d=o,o=l,l=d,d=i,i=s,s=d);u-- >0;){if(d=QR(o,l,n),d===c)return r[i]=o,r[s]=l,e(r);if(d>0)o=Math.floor(o/d)*d,l=Math.ceil(l/d)*d;else if(d<0)o=Math.ceil(o*d)/d,l=Math.floor(l*d)/d;else break;c=d}return t},t}function Ite(){var t=JI();return t.copy=function(){return _S(t,Ite())},yc.apply(t,arguments),Mp(t)}function zte(t){var e;function n(r){return r==null||isNaN(r=+r)?e:r}return n.invert=n,n.domain=n.range=function(r){return arguments.length?(t=Array.from(r,bC),n):t.slice()},n.unknown=function(r){return arguments.length?(e=r,n):e},n.copy=function(){return zte(t).unknown(e)},t=arguments.length?Array.from(t,bC):[0,1],Mp(n)}function Ute(t,e){t=t.slice();var n=0,r=t.length-1,i=t[n],s=t[r],o;return s<i&&(o=n,n=r,r=o,o=i,i=s,s=o),t[n]=e.floor(i),t[r]=e.ceil(s),t}function LG(t){return Math.log(t)}function OG(t){return Math.exp(t)}function KLe(t){return-Math.log(-t)}function XLe(t){return-Math.exp(-t)}function YLe(t){return isFinite(t)?+(\"1e\"+t):t<0?0:t}function QLe(t){return t===10?YLe:t===Math.E?Math.exp:e=>Math.pow(t,e)}function ZLe(t){return t===Math.E?Math.log:t===10&&Math.log10||t===2&&Math.log2||(t=Math.log(t),e=>Math.log(e)/t)}function IG(t){return(e,n)=>-t(-e,n)}function n4(t){const e=t(LG,OG),n=e.domain;let r=10,i,s;function o(){return i=ZLe(r),s=QLe(r),n()[0]<0?(i=IG(i),s=IG(s),t(KLe,XLe)):t(LG,OG),e}return e.base=function(l){return arguments.length?(r=+l,o()):r},e.domain=function(l){return arguments.length?(n(l),o()):n()},e.ticks=l=>{const c=n();let d=c[0],u=c[c.length-1];const m=u<d;m&&([d,u]=[u,d]);let p=i(d),f=i(u),y,v;const b=l==null?10:+l;let g=[];if(!(r%1)&&f-p<b){if(p=Math.floor(p),f=Math.ceil(f),d>0){for(;p<=f;++p)for(y=1;y<r;++y)if(v=p<0?y/s(-p):y*s(p),!(v<d)){if(v>u)break;g.push(v)}}else for(;p<=f;++p)for(y=r-1;y>=1;--y)if(v=p>0?y/s(-p):y*s(p),!(v<d)){if(v>u)break;g.push(v)}g.length*2<b&&(g=YR(d,u,b))}else g=YR(p,f,Math.min(f-p,b)).map(s);return m?g.reverse():g},e.tickFormat=(l,c)=>{if(l==null&&(l=10),c==null&&(c=r===10?\"s\":\",\"),typeof c!=\"function\"&&(!(r%1)&&(c=Aw(c)).precision==null&&(c.trim=!0),c=t4(c)),l===1/0)return c;const d=Math.max(1,r*l/e.ticks().length);return u=>{let m=u/s(Math.round(i(u)));return m*r<r-.5&&(m*=r),m<=d?c(u):\"\"}},e.nice=()=>n(Ute(n(),{floor:l=>s(Math.floor(i(l))),ceil:l=>s(Math.ceil(i(l)))})),e}function Bte(){const t=n4(CT()).domain([1,10]);return t.copy=()=>_S(t,Bte()).base(t.base()),yc.apply(t,arguments),t}function zG(t){return function(e){return Math.sign(e)*Math.log1p(Math.abs(e/t))}}function UG(t){return function(e){return Math.sign(e)*Math.expm1(Math.abs(e))*t}}function r4(t){var e=1,n=t(zG(e),UG(e));return n.constant=function(r){return arguments.length?t(zG(e=+r),UG(e)):e},Mp(n)}function Hte(){var t=r4(CT());return t.copy=function(){return _S(t,Hte()).constant(t.constant())},yc.apply(t,arguments)}function BG(t){return function(e){return e<0?-Math.pow(-e,t):Math.pow(e,t)}}function JLe(t){return t<0?-Math.sqrt(-t):Math.sqrt(t)}function eOe(t){return t<0?-t*t:t*t}function a4(t){var e=t(ao,ao),n=1;function r(){return n===1?t(ao,ao):n===.5?t(JLe,eOe):t(BG(n),BG(1/n))}return e.exponent=function(i){return arguments.length?(n=+i,r()):n},Mp(e)}function i4(){var t=a4(CT());return t.copy=function(){return _S(t,i4()).exponent(t.exponent())},yc.apply(t,arguments),t}function tOe(){return i4.apply(null,arguments).exponent(.5)}function HG(t){return Math.sign(t)*t*t}function nOe(t){return Math.sign(t)*Math.sqrt(Math.abs(t))}function qte(){var t=JI(),e=[0,1],n=!1,r;function i(s){var o=nOe(t(s));return isNaN(o)?r:n?Math.round(o):o}return i.invert=function(s){return t.invert(HG(s))},i.domain=function(s){return arguments.length?(t.domain(s),i):t.domain()},i.range=function(s){return arguments.length?(t.range((e=Array.from(s,bC)).map(HG)),i):e.slice()},i.rangeRound=function(s){return i.range(s).round(!0)},i.round=function(s){return arguments.length?(n=!!s,i):n},i.clamp=function(s){return arguments.length?(t.clamp(s),i):t.clamp()},i.unknown=function(s){return arguments.length?(r=s,i):r},i.copy=function(){return qte(t.domain(),e).round(n).clamp(t.clamp()).unknown(r)},yc.apply(i,arguments),Mp(i)}function $te(){var t=[],e=[],n=[],r;function i(){var o=0,l=Math.max(1,e.length);for(n=new Array(l-1);++o<l;)n[o-1]=sLe(t,o/l);return s}function s(o){return o==null||isNaN(o=+o)?r:e[wS(n,o)]}return s.invertExtent=function(o){var l=e.indexOf(o);return l<0?[NaN,NaN]:[l>0?n[l-1]:t[0],l<n.length?n[l]:t[t.length-1]]},s.domain=function(o){if(!arguments.length)return t.slice();t=[];for(let l of o)l!=null&&!isNaN(l=+l)&&t.push(l);return t.sort(dp),i()},s.range=function(o){return arguments.length?(e=Array.from(o),i()):e.slice()},s.unknown=function(o){return arguments.length?(r=o,s):r},s.quantiles=function(){return n.slice()},s.copy=function(){return $te().domain(t).range(e).unknown(r)},yc.apply(s,arguments)}function Vte(){var t=0,e=1,n=1,r=[.5],i=[0,1],s;function o(c){return c!=null&&c<=c?i[wS(r,c,0,n)]:s}function l(){var c=-1;for(r=new Array(n);++c<n;)r[c]=((c+1)*e-(c-n)*t)/(n+1);return o}return o.domain=function(c){return arguments.length?([t,e]=c,t=+t,e=+e,l()):[t,e]},o.range=function(c){return arguments.length?(n=(i=Array.from(c)).length-1,l()):i.slice()},o.invertExtent=function(c){var d=i.indexOf(c);return d<0?[NaN,NaN]:d<1?[t,r[0]]:d>=n?[r[n-1],e]:[r[d-1],r[d]]},o.unknown=function(c){return arguments.length&&(s=c),o},o.thresholds=function(){return r.slice()},o.copy=function(){return Vte().domain([t,e]).range(i).unknown(s)},yc.apply(Mp(o),arguments)}function Gte(){var t=[.5],e=[0,1],n,r=1;function i(s){return s!=null&&s<=s?e[wS(t,s,0,r)]:n}return i.domain=function(s){return arguments.length?(t=Array.from(s),r=Math.min(t.length,e.length-1),i):t.slice()},i.range=function(s){return arguments.length?(e=Array.from(s),r=Math.min(t.length,e.length-1),i):e.slice()},i.invertExtent=function(s){var o=e.indexOf(s);return[t[o-1],t[o]]},i.unknown=function(s){return arguments.length?(n=s,i):n},i.copy=function(){return Gte().domain(t).range(e).unknown(n)},yc.apply(i,arguments)}const lD=new Date,cD=new Date;function Oi(t,e,n,r){function i(s){return t(s=arguments.length===0?new Date:new Date(+s)),s}return i.floor=s=>(t(s=new Date(+s)),s),i.ceil=s=>(t(s=new Date(s-1)),e(s,1),t(s),s),i.round=s=>{const o=i(s),l=i.ceil(s);return s-o<l-s?o:l},i.offset=(s,o)=>(e(s=new Date(+s),o==null?1:Math.floor(o)),s),i.range=(s,o,l)=>{const c=[];if(s=i.ceil(s),l=l==null?1:Math.floor(l),!(s<o)||!(l>0))return c;let d;do c.push(d=new Date(+s)),e(s,l),t(s);while(d<s&&s<o);return c},i.filter=s=>Oi(o=>{if(o>=o)for(;t(o),!s(o);)o.setTime(o-1)},(o,l)=>{if(o>=o)if(l<0)for(;++l<=0;)for(;e(o,-1),!s(o););else for(;--l>=0;)for(;e(o,1),!s(o););}),n&&(i.count=(s,o)=>(lD.setTime(+s),cD.setTime(+o),t(lD),t(cD),Math.floor(n(lD,cD))),i.every=s=>(s=Math.floor(s),!isFinite(s)||!(s>0)?null:s>1?i.filter(r?o=>r(o)%s===0:o=>i.count(0,o)%s===0):i)),i}const vC=Oi(()=>{},(t,e)=>{t.setTime(+t+e)},(t,e)=>e-t);vC.every=t=>(t=Math.floor(t),!isFinite(t)||!(t>0)?null:t>1?Oi(e=>{e.setTime(Math.floor(e/t)*t)},(e,n)=>{e.setTime(+e+n*t)},(e,n)=>(n-e)/t):vC);vC.range;const nm=1e3,tc=nm*60,rm=tc*60,km=rm*24,s4=km*7,qG=km*30,dD=km*365,If=Oi(t=>{t.setTime(t-t.getMilliseconds())},(t,e)=>{t.setTime(+t+e*nm)},(t,e)=>(e-t)/nm,t=>t.getUTCSeconds());If.range;const o4=Oi(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*nm)},(t,e)=>{t.setTime(+t+e*tc)},(t,e)=>(e-t)/tc,t=>t.getMinutes());o4.range;const l4=Oi(t=>{t.setUTCSeconds(0,0)},(t,e)=>{t.setTime(+t+e*tc)},(t,e)=>(e-t)/tc,t=>t.getUTCMinutes());l4.range;const c4=Oi(t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*nm-t.getMinutes()*tc)},(t,e)=>{t.setTime(+t+e*rm)},(t,e)=>(e-t)/rm,t=>t.getHours());c4.range;const d4=Oi(t=>{t.setUTCMinutes(0,0,0)},(t,e)=>{t.setTime(+t+e*rm)},(t,e)=>(e-t)/rm,t=>t.getUTCHours());d4.range;const kS=Oi(t=>t.setHours(0,0,0,0),(t,e)=>t.setDate(t.getDate()+e),(t,e)=>(e-t-(e.getTimezoneOffset()-t.getTimezoneOffset())*tc)/km,t=>t.getDate()-1);kS.range;const PT=Oi(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/km,t=>t.getUTCDate()-1);PT.range;const Wte=Oi(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/km,t=>Math.floor(t/km));Wte.range;function Hg(t){return Oi(e=>{e.setDate(e.getDate()-(e.getDay()+7-t)%7),e.setHours(0,0,0,0)},(e,n)=>{e.setDate(e.getDate()+n*7)},(e,n)=>(n-e-(n.getTimezoneOffset()-e.getTimezoneOffset())*tc)/s4)}const TT=Hg(0),wC=Hg(1),rOe=Hg(2),aOe=Hg(3),ay=Hg(4),iOe=Hg(5),sOe=Hg(6);TT.range;wC.range;rOe.range;aOe.range;ay.range;iOe.range;sOe.range;function qg(t){return Oi(e=>{e.setUTCDate(e.getUTCDate()-(e.getUTCDay()+7-t)%7),e.setUTCHours(0,0,0,0)},(e,n)=>{e.setUTCDate(e.getUTCDate()+n*7)},(e,n)=>(n-e)/s4)}const AT=qg(0),SC=qg(1),oOe=qg(2),lOe=qg(3),iy=qg(4),cOe=qg(5),dOe=qg(6);AT.range;SC.range;oOe.range;lOe.range;iy.range;cOe.range;dOe.range;const u4=Oi(t=>{t.setDate(1),t.setHours(0,0,0,0)},(t,e)=>{t.setMonth(t.getMonth()+e)},(t,e)=>e.getMonth()-t.getMonth()+(e.getFullYear()-t.getFullYear())*12,t=>t.getMonth());u4.range;const m4=Oi(t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCMonth(t.getUTCMonth()+e)},(t,e)=>e.getUTCMonth()-t.getUTCMonth()+(e.getUTCFullYear()-t.getUTCFullYear())*12,t=>t.getUTCMonth());m4.range;const Nm=Oi(t=>{t.setMonth(0,1),t.setHours(0,0,0,0)},(t,e)=>{t.setFullYear(t.getFullYear()+e)},(t,e)=>e.getFullYear()-t.getFullYear(),t=>t.getFullYear());Nm.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:Oi(e=>{e.setFullYear(Math.floor(e.getFullYear()/t)*t),e.setMonth(0,1),e.setHours(0,0,0,0)},(e,n)=>{e.setFullYear(e.getFullYear()+n*t)});Nm.range;const Cm=Oi(t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCFullYear(t.getUTCFullYear()+e)},(t,e)=>e.getUTCFullYear()-t.getUTCFullYear(),t=>t.getUTCFullYear());Cm.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:Oi(e=>{e.setUTCFullYear(Math.floor(e.getUTCFullYear()/t)*t),e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)},(e,n)=>{e.setUTCFullYear(e.getUTCFullYear()+n*t)});Cm.range;function Kte(t,e,n,r,i,s){const o=[[If,1,nm],[If,5,5*nm],[If,15,15*nm],[If,30,30*nm],[s,1,tc],[s,5,5*tc],[s,15,15*tc],[s,30,30*tc],[i,1,rm],[i,3,3*rm],[i,6,6*rm],[i,12,12*rm],[r,1,km],[r,2,2*km],[n,1,s4],[e,1,qG],[e,3,3*qG],[t,1,dD]];function l(d,u,m){const p=u<d;p&&([d,u]=[u,d]);const f=m&&typeof m.range==\"function\"?m:c(d,u,m),y=f?f.range(d,+u+1):[];return p?y.reverse():y}function c(d,u,m){const p=Math.abs(u-d)/m,f=WI(([,,b])=>b).right(o,p);if(f===o.length)return t.every(ZR(d/dD,u/dD,m));if(f===0)return vC.every(Math.max(ZR(d,u,m),1));const[y,v]=o[p/o[f-1][2]<o[f][2]/p?f-1:f];return y.every(v)}return[l,c]}const[uOe,mOe]=Kte(Cm,m4,AT,Wte,d4,l4),[hOe,pOe]=Kte(Nm,u4,TT,kS,c4,o4);function uD(t){if(0<=t.y&&t.y<100){var e=new Date(-1,t.m,t.d,t.H,t.M,t.S,t.L);return e.setFullYear(t.y),e}return new Date(t.y,t.m,t.d,t.H,t.M,t.S,t.L)}function mD(t){if(0<=t.y&&t.y<100){var e=new Date(Date.UTC(-1,t.m,t.d,t.H,t.M,t.S,t.L));return e.setUTCFullYear(t.y),e}return new Date(Date.UTC(t.y,t.m,t.d,t.H,t.M,t.S,t.L))}function Qv(t,e,n){return{y:t,m:e,d:n,H:0,M:0,S:0,L:0}}function fOe(t){var e=t.dateTime,n=t.date,r=t.time,i=t.periods,s=t.days,o=t.shortDays,l=t.months,c=t.shortMonths,d=Zv(i),u=Jv(i),m=Zv(s),p=Jv(s),f=Zv(o),y=Jv(o),v=Zv(l),b=Jv(l),g=Zv(c),_=Jv(c),C={a:J,A:oe,b:fe,B:re,c:null,d:XG,e:XG,f:IOe,g:KOe,G:YOe,H:ROe,I:LOe,j:OOe,L:Xte,m:zOe,M:UOe,p:W,q:ne,Q:ZG,s:JG,S:BOe,u:HOe,U:qOe,V:$Oe,w:VOe,W:GOe,x:null,X:null,y:WOe,Y:XOe,Z:QOe,\"%\":QG},P={a:me,A:be,b:Ce,B:q,c:null,d:YG,e:YG,f:tIe,g:uIe,G:hIe,H:ZOe,I:JOe,j:eIe,L:Qte,m:nIe,M:rIe,p:Y,q:E,Q:ZG,s:JG,S:aIe,u:iIe,U:sIe,V:oIe,w:lIe,W:cIe,x:null,X:null,y:dIe,Y:mIe,Z:pIe,\"%\":QG},N={a:D,A:H,b:z,B:Q,c:L,d:WG,e:WG,f:MOe,g:GG,G:VG,H:KG,I:KG,j:POe,L:jOe,m:COe,M:TOe,p:k,q:NOe,Q:DOe,s:FOe,S:AOe,u:vOe,U:wOe,V:SOe,w:yOe,W:_Oe,x:te,X:ie,y:GG,Y:VG,Z:kOe,\"%\":EOe};C.x=A(n,C),C.X=A(r,C),C.c=A(e,C),P.x=A(n,P),P.X=A(r,P),P.c=A(e,P);function A(j,O){return function(K){var U=[],de=-1,I=0,G=j.length,X,V,ee;for(K instanceof Date||(K=new Date(+K));++de<G;)j.charCodeAt(de)===37&&(U.push(j.slice(I,de)),(V=$G[X=j.charAt(++de)])!=null?X=j.charAt(++de):V=X===\"e\"?\" \":\"0\",(ee=O[X])&&(X=ee(K,V)),U.push(X),I=de+1);return U.push(j.slice(I,de)),U.join(\"\")}}function T(j,O){return function(K){var U=Qv(1900,void 0,1),de=F(U,j,K+=\"\",0),I,G;if(de!=K.length)return null;if(\"Q\"in U)return new Date(U.Q);if(\"s\"in U)return new Date(U.s*1e3+(\"L\"in U?U.L:0));if(O&&!(\"Z\"in U)&&(U.Z=0),\"p\"in U&&(U.H=U.H%12+U.p*12),U.m===void 0&&(U.m=\"q\"in U?U.q:0),\"V\"in U){if(U.V<1||U.V>53)return null;\"w\"in U||(U.w=1),\"Z\"in U?(I=mD(Qv(U.y,0,1)),G=I.getUTCDay(),I=G>4||G===0?SC.ceil(I):SC(I),I=PT.offset(I,(U.V-1)*7),U.y=I.getUTCFullYear(),U.m=I.getUTCMonth(),U.d=I.getUTCDate()+(U.w+6)%7):(I=uD(Qv(U.y,0,1)),G=I.getDay(),I=G>4||G===0?wC.ceil(I):wC(I),I=kS.offset(I,(U.V-1)*7),U.y=I.getFullYear(),U.m=I.getMonth(),U.d=I.getDate()+(U.w+6)%7)}else(\"W\"in U||\"U\"in U)&&(\"w\"in U||(U.w=\"u\"in U?U.u%7:\"W\"in U?1:0),G=\"Z\"in U?mD(Qv(U.y,0,1)).getUTCDay():uD(Qv(U.y,0,1)).getDay(),U.m=0,U.d=\"W\"in U?(U.w+6)%7+U.W*7-(G+5)%7:U.w+U.U*7-(G+6)%7);return\"Z\"in U?(U.H+=U.Z/100|0,U.M+=U.Z%100,mD(U)):uD(U)}}function F(j,O,K,U){for(var de=0,I=O.length,G=K.length,X,V;de<I;){if(U>=G)return-1;if(X=O.charCodeAt(de++),X===37){if(X=O.charAt(de++),V=N[X in $G?O.charAt(de++):X],!V||(U=V(j,K,U))<0)return-1}else if(X!=K.charCodeAt(U++))return-1}return U}function k(j,O,K){var U=d.exec(O.slice(K));return U?(j.p=u.get(U[0].toLowerCase()),K+U[0].length):-1}function D(j,O,K){var U=f.exec(O.slice(K));return U?(j.w=y.get(U[0].toLowerCase()),K+U[0].length):-1}function H(j,O,K){var U=m.exec(O.slice(K));return U?(j.w=p.get(U[0].toLowerCase()),K+U[0].length):-1}function z(j,O,K){var U=g.exec(O.slice(K));return U?(j.m=_.get(U[0].toLowerCase()),K+U[0].length):-1}function Q(j,O,K){var U=v.exec(O.slice(K));return U?(j.m=b.get(U[0].toLowerCase()),K+U[0].length):-1}function L(j,O,K){return F(j,e,O,K)}function te(j,O,K){return F(j,n,O,K)}function ie(j,O,K){return F(j,r,O,K)}function J(j){return o[j.getDay()]}function oe(j){return s[j.getDay()]}function fe(j){return c[j.getMonth()]}function re(j){return l[j.getMonth()]}function W(j){return i[+(j.getHours()>=12)]}function ne(j){return 1+~~(j.getMonth()/3)}function me(j){return o[j.getUTCDay()]}function be(j){return s[j.getUTCDay()]}function Ce(j){return c[j.getUTCMonth()]}function q(j){return l[j.getUTCMonth()]}function Y(j){return i[+(j.getUTCHours()>=12)]}function E(j){return 1+~~(j.getUTCMonth()/3)}return{format:function(j){var O=A(j+=\"\",C);return O.toString=function(){return j},O},parse:function(j){var O=T(j+=\"\",!1);return O.toString=function(){return j},O},utcFormat:function(j){var O=A(j+=\"\",P);return O.toString=function(){return j},O},utcParse:function(j){var O=T(j+=\"\",!0);return O.toString=function(){return j},O}}}var $G={\"-\":\"\",_:\" \",0:\"0\"},ts=/^\\s*\\d+/,gOe=/^%/,bOe=/[\\\\^$*+?|[\\]().{}]/g;function Dr(t,e,n){var r=t<0?\"-\":\"\",i=(r?-t:t)+\"\",s=i.length;return r+(s<n?new Array(n-s+1).join(e)+i:i)}function xOe(t){return t.replace(bOe,\"\\\\$&\")}function Zv(t){return new RegExp(\"^(?:\"+t.map(xOe).join(\"|\")+\")\",\"i\")}function Jv(t){return new Map(t.map((e,n)=>[e.toLowerCase(),n]))}function yOe(t,e,n){var r=ts.exec(e.slice(n,n+1));return r?(t.w=+r[0],n+r[0].length):-1}function vOe(t,e,n){var r=ts.exec(e.slice(n,n+1));return r?(t.u=+r[0],n+r[0].length):-1}function wOe(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.U=+r[0],n+r[0].length):-1}function SOe(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.V=+r[0],n+r[0].length):-1}function _Oe(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.W=+r[0],n+r[0].length):-1}function VG(t,e,n){var r=ts.exec(e.slice(n,n+4));return r?(t.y=+r[0],n+r[0].length):-1}function GG(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.y=+r[0]+(+r[0]>68?1900:2e3),n+r[0].length):-1}function kOe(t,e,n){var r=/^(Z)|([+-]\\d\\d)(?::?(\\d\\d))?/.exec(e.slice(n,n+6));return r?(t.Z=r[1]?0:-(r[2]+(r[3]||\"00\")),n+r[0].length):-1}function NOe(t,e,n){var r=ts.exec(e.slice(n,n+1));return r?(t.q=r[0]*3-3,n+r[0].length):-1}function COe(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.m=r[0]-1,n+r[0].length):-1}function WG(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.d=+r[0],n+r[0].length):-1}function POe(t,e,n){var r=ts.exec(e.slice(n,n+3));return r?(t.m=0,t.d=+r[0],n+r[0].length):-1}function KG(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.H=+r[0],n+r[0].length):-1}function TOe(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.M=+r[0],n+r[0].length):-1}function AOe(t,e,n){var r=ts.exec(e.slice(n,n+2));return r?(t.S=+r[0],n+r[0].length):-1}function jOe(t,e,n){var r=ts.exec(e.slice(n,n+3));return r?(t.L=+r[0],n+r[0].length):-1}function MOe(t,e,n){var r=ts.exec(e.slice(n,n+6));return r?(t.L=Math.floor(r[0]/1e3),n+r[0].length):-1}function EOe(t,e,n){var r=gOe.exec(e.slice(n,n+1));return r?n+r[0].length:-1}function DOe(t,e,n){var r=ts.exec(e.slice(n));return r?(t.Q=+r[0],n+r[0].length):-1}function FOe(t,e,n){var r=ts.exec(e.slice(n));return r?(t.s=+r[0],n+r[0].length):-1}function XG(t,e){return Dr(t.getDate(),e,2)}function ROe(t,e){return Dr(t.getHours(),e,2)}function LOe(t,e){return Dr(t.getHours()%12||12,e,2)}function OOe(t,e){return Dr(1+kS.count(Nm(t),t),e,3)}function Xte(t,e){return Dr(t.getMilliseconds(),e,3)}function IOe(t,e){return Xte(t,e)+\"000\"}function zOe(t,e){return Dr(t.getMonth()+1,e,2)}function UOe(t,e){return Dr(t.getMinutes(),e,2)}function BOe(t,e){return Dr(t.getSeconds(),e,2)}function HOe(t){var e=t.getDay();return e===0?7:e}function qOe(t,e){return Dr(TT.count(Nm(t)-1,t),e,2)}function Yte(t){var e=t.getDay();return e>=4||e===0?ay(t):ay.ceil(t)}function $Oe(t,e){return t=Yte(t),Dr(ay.count(Nm(t),t)+(Nm(t).getDay()===4),e,2)}function VOe(t){return t.getDay()}function GOe(t,e){return Dr(wC.count(Nm(t)-1,t),e,2)}function WOe(t,e){return Dr(t.getFullYear()%100,e,2)}function KOe(t,e){return t=Yte(t),Dr(t.getFullYear()%100,e,2)}function XOe(t,e){return Dr(t.getFullYear()%1e4,e,4)}function YOe(t,e){var n=t.getDay();return t=n>=4||n===0?ay(t):ay.ceil(t),Dr(t.getFullYear()%1e4,e,4)}function QOe(t){var e=t.getTimezoneOffset();return(e>0?\"-\":(e*=-1,\"+\"))+Dr(e/60|0,\"0\",2)+Dr(e%60,\"0\",2)}function YG(t,e){return Dr(t.getUTCDate(),e,2)}function ZOe(t,e){return Dr(t.getUTCHours(),e,2)}function JOe(t,e){return Dr(t.getUTCHours()%12||12,e,2)}function eIe(t,e){return Dr(1+PT.count(Cm(t),t),e,3)}function Qte(t,e){return Dr(t.getUTCMilliseconds(),e,3)}function tIe(t,e){return Qte(t,e)+\"000\"}function nIe(t,e){return Dr(t.getUTCMonth()+1,e,2)}function rIe(t,e){return Dr(t.getUTCMinutes(),e,2)}function aIe(t,e){return Dr(t.getUTCSeconds(),e,2)}function iIe(t){var e=t.getUTCDay();return e===0?7:e}function sIe(t,e){return Dr(AT.count(Cm(t)-1,t),e,2)}function Zte(t){var e=t.getUTCDay();return e>=4||e===0?iy(t):iy.ceil(t)}function oIe(t,e){return t=Zte(t),Dr(iy.count(Cm(t),t)+(Cm(t).getUTCDay()===4),e,2)}function lIe(t){return t.getUTCDay()}function cIe(t,e){return Dr(SC.count(Cm(t)-1,t),e,2)}function dIe(t,e){return Dr(t.getUTCFullYear()%100,e,2)}function uIe(t,e){return t=Zte(t),Dr(t.getUTCFullYear()%100,e,2)}function mIe(t,e){return Dr(t.getUTCFullYear()%1e4,e,4)}function hIe(t,e){var n=t.getUTCDay();return t=n>=4||n===0?iy(t):iy.ceil(t),Dr(t.getUTCFullYear()%1e4,e,4)}function pIe(){return\"+0000\"}function QG(){return\"%\"}function ZG(t){return+t}function JG(t){return Math.floor(+t/1e3)}var rx,Jte,ene;fIe({dateTime:\"%x, %X\",date:\"%-m/%-d/%Y\",time:\"%-I:%M:%S %p\",periods:[\"AM\",\"PM\"],days:[\"Sunday\",\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\",\"Saturday\"],shortDays:[\"Sun\",\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\"],months:[\"January\",\"February\",\"March\",\"April\",\"May\",\"June\",\"July\",\"August\",\"September\",\"October\",\"November\",\"December\"],shortMonths:[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"]});function fIe(t){return rx=fOe(t),Jte=rx.format,rx.parse,ene=rx.utcFormat,rx.utcParse,rx}function gIe(t){return new Date(t)}function bIe(t){return t instanceof Date?+t:+new Date(+t)}function h4(t,e,n,r,i,s,o,l,c,d){var u=JI(),m=u.invert,p=u.domain,f=d(\".%L\"),y=d(\":%S\"),v=d(\"%I:%M\"),b=d(\"%I %p\"),g=d(\"%a %d\"),_=d(\"%b %d\"),C=d(\"%B\"),P=d(\"%Y\");function N(A){return(c(A)<A?f:l(A)<A?y:o(A)<A?v:s(A)<A?b:r(A)<A?i(A)<A?g:_:n(A)<A?C:P)(A)}return u.invert=function(A){return new Date(m(A))},u.domain=function(A){return arguments.length?p(Array.from(A,bIe)):p().map(gIe)},u.ticks=function(A){var T=p();return t(T[0],T[T.length-1],A??10)},u.tickFormat=function(A,T){return T==null?N:d(T)},u.nice=function(A){var T=p();return(!A||typeof A.range!=\"function\")&&(A=e(T[0],T[T.length-1],A??10)),A?p(Ute(T,A)):u},u.copy=function(){return _S(u,h4(t,e,n,r,i,s,o,l,c,d))},u}function xIe(){return yc.apply(h4(hOe,pOe,Nm,u4,TT,kS,c4,o4,If,Jte).domain([new Date(2e3,0,1),new Date(2e3,0,2)]),arguments)}function yIe(){return yc.apply(h4(uOe,mOe,Cm,m4,AT,PT,d4,l4,If,ene).domain([Date.UTC(2e3,0,1),Date.UTC(2e3,0,2)]),arguments)}function jT(){var t=0,e=1,n,r,i,s,o=ao,l=!1,c;function d(m){return m==null||isNaN(m=+m)?c:o(i===0?.5:(m=(s(m)-n)*i,l?Math.max(0,Math.min(1,m)):m))}d.domain=function(m){return arguments.length?([t,e]=m,n=s(t=+t),r=s(e=+e),i=n===r?0:1/(r-n),d):[t,e]},d.clamp=function(m){return arguments.length?(l=!!m,d):l},d.interpolator=function(m){return arguments.length?(o=m,d):o};function u(m){return function(p){var f,y;return arguments.length?([f,y]=p,o=m(f,y),d):[o(0),o(1)]}}return d.range=u(jy),d.rangeRound=u(ZI),d.unknown=function(m){return arguments.length?(c=m,d):c},function(m){return s=m,n=m(t),r=m(e),i=n===r?0:1/(r-n),d}}function Ep(t,e){return e.domain(t.domain()).interpolator(t.interpolator()).clamp(t.clamp()).unknown(t.unknown())}function tne(){var t=Mp(jT()(ao));return t.copy=function(){return Ep(t,tne())},Em.apply(t,arguments)}function nne(){var t=n4(jT()).domain([1,10]);return t.copy=function(){return Ep(t,nne()).base(t.base())},Em.apply(t,arguments)}function rne(){var t=r4(jT());return t.copy=function(){return Ep(t,rne()).constant(t.constant())},Em.apply(t,arguments)}function p4(){var t=a4(jT());return t.copy=function(){return Ep(t,p4()).exponent(t.exponent())},Em.apply(t,arguments)}function vIe(){return p4.apply(null,arguments).exponent(.5)}function ane(){var t=[],e=ao;function n(r){if(r!=null&&!isNaN(r=+r))return e((wS(t,r,1)-1)/(t.length-1))}return n.domain=function(r){if(!arguments.length)return t.slice();t=[];for(let i of r)i!=null&&!isNaN(i=+i)&&t.push(i);return t.sort(dp),n},n.interpolator=function(r){return arguments.length?(e=r,n):e},n.range=function(){return t.map((r,i)=>e(i/(t.length-1)))},n.quantiles=function(r){return Array.from({length:r+1},(i,s)=>iLe(t,s/r))},n.copy=function(){return ane(e).domain(t)},Em.apply(n,arguments)}function MT(){var t=0,e=.5,n=1,r=1,i,s,o,l,c,d=ao,u,m=!1,p;function f(v){return isNaN(v=+v)?p:(v=.5+((v=+u(v))-s)*(r*v<r*s?l:c),d(m?Math.max(0,Math.min(1,v)):v))}f.domain=function(v){return arguments.length?([t,e,n]=v,i=u(t=+t),s=u(e=+e),o=u(n=+n),l=i===s?0:.5/(s-i),c=s===o?0:.5/(o-s),r=s<i?-1:1,f):[t,e,n]},f.clamp=function(v){return arguments.length?(m=!!v,f):m},f.interpolator=function(v){return arguments.length?(d=v,f):d};function y(v){return function(b){var g,_,C;return arguments.length?([g,_,C]=b,d=ELe(v,[g,_,C]),f):[d(0),d(.5),d(1)]}}return f.range=y(jy),f.rangeRound=y(ZI),f.unknown=function(v){return arguments.length?(p=v,f):p},function(v){return u=v,i=v(t),s=v(e),o=v(n),l=i===s?0:.5/(s-i),c=s===o?0:.5/(o-s),r=s<i?-1:1,f}}function ine(){var t=Mp(MT()(ao));return t.copy=function(){return Ep(t,ine())},Em.apply(t,arguments)}function sne(){var t=n4(MT()).domain([.1,1,10]);return t.copy=function(){return Ep(t,sne()).base(t.base())},Em.apply(t,arguments)}function one(){var t=r4(MT());return t.copy=function(){return Ep(t,one()).constant(t.constant())},Em.apply(t,arguments)}function f4(){var t=a4(MT());return t.copy=function(){return Ep(t,f4()).exponent(t.exponent())},Em.apply(t,arguments)}function wIe(){return f4.apply(null,arguments).exponent(.5)}const g0=Object.freeze(Object.defineProperty({__proto__:null,scaleBand:XI,scaleDiverging:ine,scaleDivergingLog:sne,scaleDivergingPow:f4,scaleDivergingSqrt:wIe,scaleDivergingSymlog:one,scaleIdentity:zte,scaleImplicit:JR,scaleLinear:Ite,scaleLog:Bte,scaleOrdinal:KI,scalePoint:lLe,scalePow:i4,scaleQuantile:$te,scaleQuantize:Vte,scaleRadial:qte,scaleSequential:tne,scaleSequentialLog:nne,scaleSequentialPow:p4,scaleSequentialQuantile:ane,scaleSequentialSqrt:vIe,scaleSequentialSymlog:rne,scaleSqrt:tOe,scaleSymlog:Hte,scaleThreshold:Gte,scaleTime:xIe,scaleUtc:yIe,tickFormat:Ote},Symbol.toStringTag,{value:\"Module\"}));var Dm=t=>t.chartData,ET=wt([Dm],t=>{var e=t.chartData!=null?t.chartData.length-1:0;return{chartData:t.chartData,computedData:t.computedData,dataEndIndex:e,dataStartIndex:0}}),g4=(t,e,n,r)=>r?ET(t):Dm(t),lne=(t,e,n)=>n?ET(t):Dm(t);function Pm(t){if(Array.isArray(t)&&t.length===2){var[e,n]=t;if(Gn(e)&&Gn(n))return!0}return!1}function e9(t,e,n){return n?t:[Math.min(t[0],e[0]),Math.max(t[1],e[1])]}function cne(t,e){if(e&&typeof t!=\"function\"&&Array.isArray(t)&&t.length===2){var[n,r]=t,i,s;if(Gn(n))i=n;else if(typeof n==\"function\")return;if(Gn(r))s=r;else if(typeof r==\"function\")return;var o=[i,s];if(Pm(o))return o}}function SIe(t,e,n){if(!(!n&&e==null)){if(typeof t==\"function\"&&e!=null)try{var r=t(e,n);if(Pm(r))return e9(r,e,n)}catch{}if(Array.isArray(t)&&t.length===2){var[i,s]=t,o,l;if(i===\"auto\")e!=null&&(o=Math.min(...e));else if(tn(i))o=i;else if(typeof i==\"function\")try{e!=null&&(o=i(e?.[0]))}catch{}else if(typeof i==\"string\"&&d7.test(i)){var c=d7.exec(i);if(c==null||c[1]==null||e==null)o=void 0;else{var d=+c[1];o=e[0]-d}}else o=e?.[0];if(s===\"auto\")e!=null&&(l=Math.max(...e));else if(tn(s))l=s;else if(typeof s==\"function\")try{e!=null&&(l=s(e?.[1]))}catch{}else if(typeof s==\"string\"&&u7.test(s)){var u=u7.exec(s);if(u==null||u[1]==null||e==null)l=void 0;else{var m=+u[1];l=e[1]+m}}else l=e?.[1];var p=[o,l];if(Pm(p))return e==null?p:e9(p,e,n)}}}var My=1e9,_Ie={precision:20,rounding:4,toExpNeg:-7,toExpPos:21,LN10:\"2.302585092994045684017991454684364207601101488628772976033327900967572609677352480235997205089598298341967784042286\"},x4,Aa=!0,fc=\"[DecimalError] \",Jf=fc+\"Invalid argument: \",b4=fc+\"Exponent out of range: \",Ey=Math.floor,Pf=Math.pow,kIe=/^(\\d+(\\.\\d*)?|\\.\\d+)(e[+-]?\\d+)?$/i,cl,Ki=1e7,ka=7,dne=9007199254740991,_C=Ey(dne/ka),cn={};cn.absoluteValue=cn.abs=function(){var t=new this.constructor(this);return t.s&&(t.s=1),t};cn.comparedTo=cn.cmp=function(t){var e,n,r,i,s=this;if(t=new s.constructor(t),s.s!==t.s)return s.s||-t.s;if(s.e!==t.e)return s.e>t.e^s.s<0?1:-1;for(r=s.d.length,i=t.d.length,e=0,n=r<i?r:i;e<n;++e)if(s.d[e]!==t.d[e])return s.d[e]>t.d[e]^s.s<0?1:-1;return r===i?0:r>i^s.s<0?1:-1};cn.decimalPlaces=cn.dp=function(){var t=this,e=t.d.length-1,n=(e-t.e)*ka;if(e=t.d[e],e)for(;e%10==0;e/=10)n--;return n<0?0:n};cn.dividedBy=cn.div=function(t){return um(this,new this.constructor(t))};cn.dividedToIntegerBy=cn.idiv=function(t){var e=this,n=e.constructor;return ca(um(e,new n(t),0,1),n.precision)};cn.equals=cn.eq=function(t){return!this.cmp(t)};cn.exponent=function(){return ki(this)};cn.greaterThan=cn.gt=function(t){return this.cmp(t)>0};cn.greaterThanOrEqualTo=cn.gte=function(t){return this.cmp(t)>=0};cn.isInteger=cn.isint=function(){return this.e>this.d.length-2};cn.isNegative=cn.isneg=function(){return this.s<0};cn.isPositive=cn.ispos=function(){return this.s>0};cn.isZero=function(){return this.s===0};cn.lessThan=cn.lt=function(t){return this.cmp(t)<0};cn.lessThanOrEqualTo=cn.lte=function(t){return this.cmp(t)<1};cn.logarithm=cn.log=function(t){var e,n=this,r=n.constructor,i=r.precision,s=i+5;if(t===void 0)t=new r(10);else if(t=new r(t),t.s<1||t.eq(cl))throw Error(fc+\"NaN\");if(n.s<1)throw Error(fc+(n.s?\"NaN\":\"-Infinity\"));return n.eq(cl)?new r(0):(Aa=!1,e=um(jw(n,s),jw(t,s),s),Aa=!0,ca(e,i))};cn.minus=cn.sub=function(t){var e=this;return t=new e.constructor(t),e.s==t.s?hne(e,t):une(e,(t.s=-t.s,t))};cn.modulo=cn.mod=function(t){var e,n=this,r=n.constructor,i=r.precision;if(t=new r(t),!t.s)throw Error(fc+\"NaN\");return n.s?(Aa=!1,e=um(n,t,0,1).times(t),Aa=!0,n.minus(e)):ca(new r(n),i)};cn.naturalExponential=cn.exp=function(){return mne(this)};cn.naturalLogarithm=cn.ln=function(){return jw(this)};cn.negated=cn.neg=function(){var t=new this.constructor(this);return t.s=-t.s||0,t};cn.plus=cn.add=function(t){var e=this;return t=new e.constructor(t),e.s==t.s?une(e,t):hne(e,(t.s=-t.s,t))};cn.precision=cn.sd=function(t){var e,n,r,i=this;if(t!==void 0&&t!==!!t&&t!==1&&t!==0)throw Error(Jf+t);if(e=ki(i)+1,r=i.d.length-1,n=r*ka+1,r=i.d[r],r){for(;r%10==0;r/=10)n--;for(r=i.d[0];r>=10;r/=10)n++}return t&&e>n?e:n};cn.squareRoot=cn.sqrt=function(){var t,e,n,r,i,s,o,l=this,c=l.constructor;if(l.s<1){if(!l.s)return new c(0);throw Error(fc+\"NaN\")}for(t=ki(l),Aa=!1,i=Math.sqrt(+l),i==0||i==1/0?(e=Fd(l.d),(e.length+t)%2==0&&(e+=\"0\"),i=Math.sqrt(e),t=Ey((t+1)/2)-(t<0||t%2),i==1/0?e=\"5e\"+t:(e=i.toExponential(),e=e.slice(0,e.indexOf(\"e\")+1)+t),r=new c(e)):r=new c(i.toString()),n=c.precision,i=o=n+3;;)if(s=r,r=s.plus(um(l,s,o+2)).times(.5),Fd(s.d).slice(0,o)===(e=Fd(r.d)).slice(0,o)){if(e=e.slice(o-3,o+1),i==o&&e==\"4999\"){if(ca(s,n+1,0),s.times(s).eq(l)){r=s;break}}else if(e!=\"9999\")break;o+=4}return Aa=!0,ca(r,n)};cn.times=cn.mul=function(t){var e,n,r,i,s,o,l,c,d,u=this,m=u.constructor,p=u.d,f=(t=new m(t)).d;if(!u.s||!t.s)return new m(0);for(t.s*=u.s,n=u.e+t.e,c=p.length,d=f.length,c<d&&(s=p,p=f,f=s,o=c,c=d,d=o),s=[],o=c+d,r=o;r--;)s.push(0);for(r=d;--r>=0;){for(e=0,i=c+r;i>r;)l=s[i]+f[r]*p[i-r-1]+e,s[i--]=l%Ki|0,e=l/Ki|0;s[i]=(s[i]+e)%Ki|0}for(;!s[--o];)s.pop();return e?++n:s.shift(),t.d=s,t.e=n,Aa?ca(t,m.precision):t};cn.toDecimalPlaces=cn.todp=function(t,e){var n=this,r=n.constructor;return n=new r(n),t===void 0?n:($d(t,0,My),e===void 0?e=r.rounding:$d(e,0,8),ca(n,t+ki(n)+1,e))};cn.toExponential=function(t,e){var n,r=this,i=r.constructor;return t===void 0?n=Pg(r,!0):($d(t,0,My),e===void 0?e=i.rounding:$d(e,0,8),r=ca(new i(r),t+1,e),n=Pg(r,!0,t+1)),n};cn.toFixed=function(t,e){var n,r,i=this,s=i.constructor;return t===void 0?Pg(i):($d(t,0,My),e===void 0?e=s.rounding:$d(e,0,8),r=ca(new s(i),t+ki(i)+1,e),n=Pg(r.abs(),!1,t+ki(r)+1),i.isneg()&&!i.isZero()?\"-\"+n:n)};cn.toInteger=cn.toint=function(){var t=this,e=t.constructor;return ca(new e(t),ki(t)+1,e.rounding)};cn.toNumber=function(){return+this};cn.toPower=cn.pow=function(t){var e,n,r,i,s,o,l=this,c=l.constructor,d=12,u=+(t=new c(t));if(!t.s)return new c(cl);if(l=new c(l),!l.s){if(t.s<1)throw Error(fc+\"Infinity\");return l}if(l.eq(cl))return l;if(r=c.precision,t.eq(cl))return ca(l,r);if(e=t.e,n=t.d.length-1,o=e>=n,s=l.s,o){if((n=u<0?-u:u)<=dne){for(i=new c(cl),e=Math.ceil(r/ka+4),Aa=!1;n%2&&(i=i.times(l),n9(i.d,e)),n=Ey(n/2),n!==0;)l=l.times(l),n9(l.d,e);return Aa=!0,t.s<0?new c(cl).div(i):ca(i,r)}}else if(s<0)throw Error(fc+\"NaN\");return s=s<0&&t.d[Math.max(e,n)]&1?-1:1,l.s=1,Aa=!1,i=t.times(jw(l,r+d)),Aa=!0,i=mne(i),i.s=s,i};cn.toPrecision=function(t,e){var n,r,i=this,s=i.constructor;return t===void 0?(n=ki(i),r=Pg(i,n<=s.toExpNeg||n>=s.toExpPos)):($d(t,1,My),e===void 0?e=s.rounding:$d(e,0,8),i=ca(new s(i),t,e),n=ki(i),r=Pg(i,t<=n||n<=s.toExpNeg,t)),r};cn.toSignificantDigits=cn.tosd=function(t,e){var n=this,r=n.constructor;return t===void 0?(t=r.precision,e=r.rounding):($d(t,1,My),e===void 0?e=r.rounding:$d(e,0,8)),ca(new r(n),t,e)};cn.toString=cn.valueOf=cn.val=cn.toJSON=cn[Symbol.for(\"nodejs.util.inspect.custom\")]=function(){var t=this,e=ki(t),n=t.constructor;return Pg(t,e<=n.toExpNeg||e>=n.toExpPos)};function une(t,e){var n,r,i,s,o,l,c,d,u=t.constructor,m=u.precision;if(!t.s||!e.s)return e.s||(e=new u(t)),Aa?ca(e,m):e;if(c=t.d,d=e.d,o=t.e,i=e.e,c=c.slice(),s=o-i,s){for(s<0?(r=c,s=-s,l=d.length):(r=d,i=o,l=c.length),o=Math.ceil(m/ka),l=o>l?o+1:l+1,s>l&&(s=l,r.length=1),r.reverse();s--;)r.push(0);r.reverse()}for(l=c.length,s=d.length,l-s<0&&(s=l,r=d,d=c,c=r),n=0;s;)n=(c[--s]=c[s]+d[s]+n)/Ki|0,c[s]%=Ki;for(n&&(c.unshift(n),++i),l=c.length;c[--l]==0;)c.pop();return e.d=c,e.e=i,Aa?ca(e,m):e}function $d(t,e,n){if(t!==~~t||t<e||t>n)throw Error(Jf+t)}function Fd(t){var e,n,r,i=t.length-1,s=\"\",o=t[0];if(i>0){for(s+=o,e=1;e<i;e++)r=t[e]+\"\",n=ka-r.length,n&&(s+=Ah(n)),s+=r;o=t[e],r=o+\"\",n=ka-r.length,n&&(s+=Ah(n))}else if(o===0)return\"0\";for(;o%10===0;)o/=10;return s+o}var um=(function(){function t(r,i){var s,o=0,l=r.length;for(r=r.slice();l--;)s=r[l]*i+o,r[l]=s%Ki|0,o=s/Ki|0;return o&&r.unshift(o),r}function e(r,i,s,o){var l,c;if(s!=o)c=s>o?1:-1;else for(l=c=0;l<s;l++)if(r[l]!=i[l]){c=r[l]>i[l]?1:-1;break}return c}function n(r,i,s){for(var o=0;s--;)r[s]-=o,o=r[s]<i[s]?1:0,r[s]=o*Ki+r[s]-i[s];for(;!r[0]&&r.length>1;)r.shift()}return function(r,i,s,o){var l,c,d,u,m,p,f,y,v,b,g,_,C,P,N,A,T,F,k=r.constructor,D=r.s==i.s?1:-1,H=r.d,z=i.d;if(!r.s)return new k(r);if(!i.s)throw Error(fc+\"Division by zero\");for(c=r.e-i.e,T=z.length,N=H.length,f=new k(D),y=f.d=[],d=0;z[d]==(H[d]||0);)++d;if(z[d]>(H[d]||0)&&--c,s==null?_=s=k.precision:o?_=s+(ki(r)-ki(i))+1:_=s,_<0)return new k(0);if(_=_/ka+2|0,d=0,T==1)for(u=0,z=z[0],_++;(d<N||u)&&_--;d++)C=u*Ki+(H[d]||0),y[d]=C/z|0,u=C%z|0;else{for(u=Ki/(z[0]+1)|0,u>1&&(z=t(z,u),H=t(H,u),T=z.length,N=H.length),P=T,v=H.slice(0,T),b=v.length;b<T;)v[b++]=0;F=z.slice(),F.unshift(0),A=z[0],z[1]>=Ki/2&&++A;do u=0,l=e(z,v,T,b),l<0?(g=v[0],T!=b&&(g=g*Ki+(v[1]||0)),u=g/A|0,u>1?(u>=Ki&&(u=Ki-1),m=t(z,u),p=m.length,b=v.length,l=e(m,v,p,b),l==1&&(u--,n(m,T<p?F:z,p))):(u==0&&(l=u=1),m=z.slice()),p=m.length,p<b&&m.unshift(0),n(v,m,b),l==-1&&(b=v.length,l=e(z,v,T,b),l<1&&(u++,n(v,T<b?F:z,b))),b=v.length):l===0&&(u++,v=[0]),y[d++]=u,l&&v[0]?v[b++]=H[P]||0:(v=[H[P]],b=1);while((P++<N||v[0]!==void 0)&&_--)}return y[0]||y.shift(),f.e=c,ca(f,o?s+ki(f)+1:s)}})();function mne(t,e){var n,r,i,s,o,l,c=0,d=0,u=t.constructor,m=u.precision;if(ki(t)>16)throw Error(b4+ki(t));if(!t.s)return new u(cl);for(Aa=!1,l=m,o=new u(.03125);t.abs().gte(.1);)t=t.times(o),d+=5;for(r=Math.log(Pf(2,d))/Math.LN10*2+5|0,l+=r,n=i=s=new u(cl),u.precision=l;;){if(i=ca(i.times(t),l),n=n.times(++c),o=s.plus(um(i,n,l)),Fd(o.d).slice(0,l)===Fd(s.d).slice(0,l)){for(;d--;)s=ca(s.times(s),l);return u.precision=m,e==null?(Aa=!0,ca(s,m)):s}s=o}}function ki(t){for(var e=t.e*ka,n=t.d[0];n>=10;n/=10)e++;return e}function hD(t,e,n){if(e>t.LN10.sd())throw Aa=!0,n&&(t.precision=n),Error(fc+\"LN10 precision limit exceeded\");return ca(new t(t.LN10),e)}function Ah(t){for(var e=\"\";t--;)e+=\"0\";return e}function jw(t,e){var n,r,i,s,o,l,c,d,u,m=1,p=10,f=t,y=f.d,v=f.constructor,b=v.precision;if(f.s<1)throw Error(fc+(f.s?\"NaN\":\"-Infinity\"));if(f.eq(cl))return new v(0);if(e==null?(Aa=!1,d=b):d=e,f.eq(10))return e==null&&(Aa=!0),hD(v,d);if(d+=p,v.precision=d,n=Fd(y),r=n.charAt(0),s=ki(f),Math.abs(s)<15e14){for(;r<7&&r!=1||r==1&&n.charAt(1)>3;)f=f.times(t),n=Fd(f.d),r=n.charAt(0),m++;s=ki(f),r>1?(f=new v(\"0.\"+n),s++):f=new v(r+\".\"+n.slice(1))}else return c=hD(v,d+2,b).times(s+\"\"),f=jw(new v(r+\".\"+n.slice(1)),d-p).plus(c),v.precision=b,e==null?(Aa=!0,ca(f,b)):f;for(l=o=f=um(f.minus(cl),f.plus(cl),d),u=ca(f.times(f),d),i=3;;){if(o=ca(o.times(u),d),c=l.plus(um(o,new v(i),d)),Fd(c.d).slice(0,d)===Fd(l.d).slice(0,d))return l=l.times(2),s!==0&&(l=l.plus(hD(v,d+2,b).times(s+\"\"))),l=um(l,new v(m),d),v.precision=b,e==null?(Aa=!0,ca(l,b)):l;l=c,i+=2}}function t9(t,e){var n,r,i;for((n=e.indexOf(\".\"))>-1&&(e=e.replace(\".\",\"\")),(r=e.search(/e/i))>0?(n<0&&(n=r),n+=+e.slice(r+1),e=e.substring(0,r)):n<0&&(n=e.length),r=0;e.charCodeAt(r)===48;)++r;for(i=e.length;e.charCodeAt(i-1)===48;)--i;if(e=e.slice(r,i),e){if(i-=r,n=n-r-1,t.e=Ey(n/ka),t.d=[],r=(n+1)%ka,n<0&&(r+=ka),r<i){for(r&&t.d.push(+e.slice(0,r)),i-=ka;r<i;)t.d.push(+e.slice(r,r+=ka));e=e.slice(r),r=ka-e.length}else r-=i;for(;r--;)e+=\"0\";if(t.d.push(+e),Aa&&(t.e>_C||t.e<-_C))throw Error(b4+n)}else t.s=0,t.e=0,t.d=[0];return t}function ca(t,e,n){var r,i,s,o,l,c,d,u,m=t.d;for(o=1,s=m[0];s>=10;s/=10)o++;if(r=e-o,r<0)r+=ka,i=e,d=m[u=0];else{if(u=Math.ceil((r+1)/ka),s=m.length,u>=s)return t;for(d=s=m[u],o=1;s>=10;s/=10)o++;r%=ka,i=r-ka+o}if(n!==void 0&&(s=Pf(10,o-i-1),l=d/s%10|0,c=e<0||m[u+1]!==void 0||d%s,c=n<4?(l||c)&&(n==0||n==(t.s<0?3:2)):l>5||l==5&&(n==4||c||n==6&&(r>0?i>0?d/Pf(10,o-i):0:m[u-1])%10&1||n==(t.s<0?8:7))),e<1||!m[0])return c?(s=ki(t),m.length=1,e=e-s-1,m[0]=Pf(10,(ka-e%ka)%ka),t.e=Ey(-e/ka)||0):(m.length=1,m[0]=t.e=t.s=0),t;if(r==0?(m.length=u,s=1,u--):(m.length=u+1,s=Pf(10,ka-r),m[u]=i>0?(d/Pf(10,o-i)%Pf(10,i)|0)*s:0),c)for(;;)if(u==0){(m[0]+=s)==Ki&&(m[0]=1,++t.e);break}else{if(m[u]+=s,m[u]!=Ki)break;m[u--]=0,s=1}for(r=m.length;m[--r]===0;)m.pop();if(Aa&&(t.e>_C||t.e<-_C))throw Error(b4+ki(t));return t}function hne(t,e){var n,r,i,s,o,l,c,d,u,m,p=t.constructor,f=p.precision;if(!t.s||!e.s)return e.s?e.s=-e.s:e=new p(t),Aa?ca(e,f):e;if(c=t.d,m=e.d,r=e.e,d=t.e,c=c.slice(),o=d-r,o){for(u=o<0,u?(n=c,o=-o,l=m.length):(n=m,r=d,l=c.length),i=Math.max(Math.ceil(f/ka),l)+2,o>i&&(o=i,n.length=1),n.reverse(),i=o;i--;)n.push(0);n.reverse()}else{for(i=c.length,l=m.length,u=i<l,u&&(l=i),i=0;i<l;i++)if(c[i]!=m[i]){u=c[i]<m[i];break}o=0}for(u&&(n=c,c=m,m=n,e.s=-e.s),l=c.length,i=m.length-l;i>0;--i)c[l++]=0;for(i=m.length;i>o;){if(c[--i]<m[i]){for(s=i;s&&c[--s]===0;)c[s]=Ki-1;--c[s],c[i]+=Ki}c[i]-=m[i]}for(;c[--l]===0;)c.pop();for(;c[0]===0;c.shift())--r;return c[0]?(e.d=c,e.e=r,Aa?ca(e,f):e):new p(0)}function Pg(t,e,n){var r,i=ki(t),s=Fd(t.d),o=s.length;return e?(n&&(r=n-o)>0?s=s.charAt(0)+\".\"+s.slice(1)+Ah(r):o>1&&(s=s.charAt(0)+\".\"+s.slice(1)),s=s+(i<0?\"e\":\"e+\")+i):i<0?(s=\"0.\"+Ah(-i-1)+s,n&&(r=n-o)>0&&(s+=Ah(r))):i>=o?(s+=Ah(i+1-o),n&&(r=n-i-1)>0&&(s=s+\".\"+Ah(r))):((r=i+1)<o&&(s=s.slice(0,r)+\".\"+s.slice(r)),n&&(r=n-o)>0&&(i+1===o&&(s+=\".\"),s+=Ah(r))),t.s<0?\"-\"+s:s}function n9(t,e){if(t.length>e)return t.length=e,!0}function pne(t){var e,n,r;function i(s){var o=this;if(!(o instanceof i))return new i(s);if(o.constructor=i,s instanceof i){o.s=s.s,o.e=s.e,o.d=(s=s.d)?s.slice():s;return}if(typeof s==\"number\"){if(s*0!==0)throw Error(Jf+s);if(s>0)o.s=1;else if(s<0)s=-s,o.s=-1;else{o.s=0,o.e=0,o.d=[0];return}if(s===~~s&&s<1e7){o.e=0,o.d=[s];return}return t9(o,s.toString())}else if(typeof s!=\"string\")throw Error(Jf+s);if(s.charCodeAt(0)===45?(s=s.slice(1),o.s=-1):o.s=1,kIe.test(s))t9(o,s);else throw Error(Jf+s)}if(i.prototype=cn,i.ROUND_UP=0,i.ROUND_DOWN=1,i.ROUND_CEIL=2,i.ROUND_FLOOR=3,i.ROUND_HALF_UP=4,i.ROUND_HALF_DOWN=5,i.ROUND_HALF_EVEN=6,i.ROUND_HALF_CEIL=7,i.ROUND_HALF_FLOOR=8,i.clone=pne,i.config=i.set=NIe,t===void 0&&(t={}),t)for(r=[\"precision\",\"rounding\",\"toExpNeg\",\"toExpPos\",\"LN10\"],e=0;e<r.length;)t.hasOwnProperty(n=r[e++])||(t[n]=this[n]);return i.config(t),i}function NIe(t){if(!t||typeof t!=\"object\")throw Error(fc+\"Object expected\");var e,n,r,i=[\"precision\",1,My,\"rounding\",0,8,\"toExpNeg\",-1/0,0,\"toExpPos\",0,1/0];for(e=0;e<i.length;e+=3)if((r=t[n=i[e]])!==void 0)if(Ey(r)===r&&r>=i[e+1]&&r<=i[e+2])this[n]=r;else throw Error(Jf+n+\": \"+r);if((r=t[n=\"LN10\"])!==void 0)if(r==Math.LN10)this[n]=new this(r);else throw Error(Jf+n+\": \"+r);return this}var x4=pne(_Ie);cl=new x4(1);const Vr=x4;function fne(t){var e;return t===0?e=1:e=Math.floor(new Vr(t).abs().log(10).toNumber())+1,e}function gne(t,e,n){for(var r=new Vr(t),i=0,s=[];r.lt(e)&&i<1e5;)s.push(r.toNumber()),r=r.add(n),i++;return s}var bne=t=>{var[e,n]=t,[r,i]=[e,n];return e>n&&([r,i]=[n,e]),[r,i]},xne=(t,e,n)=>{if(t.lte(0))return new Vr(0);var r=fne(t.toNumber()),i=new Vr(10).pow(r),s=t.div(i),o=r!==1?.05:.1,l=new Vr(Math.ceil(s.div(o).toNumber())).add(n).mul(o),c=l.mul(i);return e?new Vr(c.toNumber()):new Vr(Math.ceil(c.toNumber()))},CIe=(t,e,n)=>{var r=new Vr(1),i=new Vr(t);if(!i.isint()&&n){var s=Math.abs(t);s<1?(r=new Vr(10).pow(fne(t)-1),i=new Vr(Math.floor(i.div(r).toNumber())).mul(r)):s>1&&(i=new Vr(Math.floor(t)))}else t===0?i=new Vr(Math.floor((e-1)/2)):n||(i=new Vr(Math.floor(t)));for(var o=Math.floor((e-1)/2),l=[],c=0;c<e;c++)l.push(i.add(new Vr(c-o).mul(r)).toNumber());return l},yne=function(e,n,r,i){var s=arguments.length>4&&arguments[4]!==void 0?arguments[4]:0;if(!Number.isFinite((n-e)/(r-1)))return{step:new Vr(0),tickMin:new Vr(0),tickMax:new Vr(0)};var o=xne(new Vr(n).sub(e).div(r-1),i,s),l;e<=0&&n>=0?l=new Vr(0):(l=new Vr(e).add(n).div(2),l=l.sub(new Vr(l).mod(o)));var c=Math.ceil(l.sub(e).div(o).toNumber()),d=Math.ceil(new Vr(n).sub(l).div(o).toNumber()),u=c+d+1;return u>r?yne(e,n,r,i,s+1):(u<r&&(d=n>0?d+(r-u):d,c=n>0?c:c+(r-u)),{step:o,tickMin:l.sub(new Vr(c).mul(o)),tickMax:l.add(new Vr(d).mul(o))})},PIe=function(e){var[n,r]=e,i=arguments.length>1&&arguments[1]!==void 0?arguments[1]:6,s=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!0,o=Math.max(i,2),[l,c]=bne([n,r]);if(l===-1/0||c===1/0){var d=c===1/0?[l,...Array(i-1).fill(1/0)]:[...Array(i-1).fill(-1/0),c];return n>r?d.reverse():d}if(l===c)return CIe(l,i,s);var{step:u,tickMin:m,tickMax:p}=yne(l,c,o,s,0),f=gne(m,p.add(new Vr(.1).mul(u)),u);return n>r?f.reverse():f},TIe=function(e,n){var[r,i]=e,s=arguments.length>2&&arguments[2]!==void 0?arguments[2]:!0,[o,l]=bne([r,i]);if(o===-1/0||l===1/0)return[r,i];if(o===l)return[o];var c=Math.max(n,2),d=xne(new Vr(l).sub(o).div(c-1),s,0),u=[...gne(new Vr(o),new Vr(l),d),l];return s===!1&&(u=u.map(m=>Math.round(m))),r>i?u.reverse():u},vne=t=>t.rootProps.maxBarSize,AIe=t=>t.rootProps.barGap,wne=t=>t.rootProps.barCategoryGap,jIe=t=>t.rootProps.barSize,NS=t=>t.rootProps.stackOffset,Sne=t=>t.rootProps.reverseStackOrder,y4=t=>t.options.chartName,v4=t=>t.rootProps.syncId,_ne=t=>t.rootProps.syncMethod,w4=t=>t.options.eventEmitter,MIe=t=>t.rootProps.baseValue,Ja={grid:-100,barBackground:-50,area:100,cursorRectangle:200,bar:300,line:400,axis:500,scatter:600,activeBar:1e3,cursorLine:1100,activeDot:1200,label:2e3},yf={allowDecimals:!1,allowDataOverflow:!1,angleAxisId:0,reversed:!1,scale:\"auto\",tick:!0,type:\"auto\"},xd={allowDataOverflow:!1,allowDecimals:!1,allowDuplicatedCategory:!0,includeHidden:!1,radiusAxisId:0,reversed:!1,scale:\"auto\",tick:!0,tickCount:5,type:\"auto\"},DT=(t,e)=>{if(!(!t||!e))return t!=null&&t.reversed?[e[1],e[0]]:e};function FT(t,e,n){if(n!==\"auto\")return n;if(t!=null)return ad(t,e)?\"category\":\"number\"}function r9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function kC(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?r9(Object(n),!0).forEach(function(r){EIe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):r9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function EIe(t,e,n){return(e=DIe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function DIe(t){var e=FIe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function FIe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var a9={allowDataOverflow:yf.allowDataOverflow,allowDecimals:yf.allowDecimals,allowDuplicatedCategory:!1,dataKey:void 0,domain:void 0,id:yf.angleAxisId,includeHidden:!1,name:void 0,reversed:yf.reversed,scale:yf.scale,tick:yf.tick,tickCount:void 0,ticks:void 0,type:yf.type,unit:void 0},i9={allowDataOverflow:xd.allowDataOverflow,allowDecimals:xd.allowDecimals,allowDuplicatedCategory:xd.allowDuplicatedCategory,dataKey:void 0,domain:void 0,id:xd.radiusAxisId,includeHidden:xd.includeHidden,name:void 0,reversed:xd.reversed,scale:xd.scale,tick:xd.tick,tickCount:xd.tickCount,ticks:void 0,type:xd.type,unit:void 0},RIe=(t,e)=>{if(e!=null)return t.polarAxis.angleAxis[e]},S4=wt([RIe,ste],(t,e)=>{var n;if(t!=null)return t;var r=(n=FT(e,\"angleAxis\",a9.type))!==null&&n!==void 0?n:\"category\";return kC(kC({},a9),{},{type:r})}),LIe=(t,e)=>t.polarAxis.radiusAxis[e],_4=wt([LIe,ste],(t,e)=>{var n;if(t!=null)return t;var r=(n=FT(e,\"radiusAxis\",i9.type))!==null&&n!==void 0?n:\"category\";return kC(kC({},i9),{},{type:r})}),RT=t=>t.polarOptions,k4=wt([jm,Mm,Ri],kte),kne=wt([RT,k4],(t,e)=>{if(t!=null)return Hs(t.innerRadius,e,0)}),Nne=wt([RT,k4],(t,e)=>{if(t!=null)return Hs(t.outerRadius,e,e*.8)}),OIe=t=>{if(t==null)return[0,0];var{startAngle:e,endAngle:n}=t;return[e,n]},Cne=wt([RT],OIe);wt([S4,Cne],DT);var Pne=wt([k4,kne,Nne],(t,e,n)=>{if(!(t==null||e==null||n==null))return[e,n]});wt([_4,Pne],DT);var Tne=wt([Sr,RT,kne,Nne,jm,Mm],(t,e,n,r,i,s)=>{if(!(t!==\"centric\"&&t!==\"radial\"||e==null||n==null||r==null)){var{cx:o,cy:l,startAngle:c,endAngle:d}=e;return{cx:Hs(o,i,i/2),cy:Hs(l,s,s/2),innerRadius:n,outerRadius:r,startAngle:c,endAngle:d,clockWise:!1}}}),za=(t,e)=>e,CS=(t,e,n)=>n;function LT(t){return t?.id}function Ane(t,e,n){var{chartData:r=[]}=e,{allowDuplicatedCategory:i,dataKey:s}=n,o=new Map;return t.forEach(l=>{var c,d=(c=l.data)!==null&&c!==void 0?c:r;if(!(d==null||d.length===0)){var u=LT(l);d.forEach((m,p)=>{var f=s==null||i?p:String(zr(m,s,null)),y=zr(m,l.dataKey,0),v;o.has(f)?v=o.get(f):v={},Object.assign(v,{[u]:y}),o.set(f,v)})}}),Array.from(o.values())}function OT(t){return\"stackId\"in t&&t.stackId!=null&&t.dataKey!=null}var IT=(t,e)=>t===e?!0:t==null||e==null?!1:t[0]===e[0]&&t[1]===e[1];function zT(t,e){return Array.isArray(t)&&Array.isArray(e)&&t.length===0&&e.length===0?!0:t===e}function IIe(t,e){if(t.length===e.length){for(var n=0;n<t.length;n++)if(t[n]!==e[n])return!1;return!0}return!1}var ns=t=>{var e=Sr(t);return e===\"horizontal\"?\"xAxis\":e===\"vertical\"?\"yAxis\":e===\"centric\"?\"angleAxis\":\"radiusAxis\"},Dy=t=>t.tooltip.settings.axisId;function zIe(t){if(t in g0)return g0[t]();var e=\"scale\".concat(mS(t));if(e in g0)return g0[e]()}function s9(t){var e=t.ticks,n=t.bandwidth,r=t.range(),i=[Math.min(...r),Math.max(...r)];return{domain:()=>t.domain(),range:(function(s){function o(){return s.apply(this,arguments)}return o.toString=function(){return s.toString()},o})(()=>i),rangeMin:()=>i[0],rangeMax:()=>i[1],isInRange(s){var o=i[0],l=i[1];return o<=l?s>=o&&s<=l:s>=l&&s<=o},bandwidth:n?()=>n.call(t):void 0,ticks:e?s=>e.call(t,s):void 0,map:(s,o)=>{var l=t(s);if(l!=null){if(t.bandwidth&&o!==null&&o!==void 0&&o.position){var c=t.bandwidth();switch(o.position){case\"middle\":l+=c/2;break;case\"end\":l+=c;break}}return l}}}}function o9(t,e,n){if(typeof t==\"function\")return s9(t.copy().domain(e).range(n));if(t!=null){var r=zIe(t);if(r!=null)return r.domain(e).range(n),s9(r)}}var jne=(t,e)=>{if(e!=null)switch(t){case\"linear\":{if(!Pm(e)){for(var n,r,i=0;i<e.length;i++){var s=e[i];Gn(s)&&((n===void 0||s<n)&&(n=s),(r===void 0||s>r)&&(r=s))}return n!==void 0&&r!==void 0?[n,r]:void 0}return e}default:return e}};function l9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function NC(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?l9(Object(n),!0).forEach(function(r){UIe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):l9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function UIe(t,e,n){return(e=BIe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function BIe(t){var e=HIe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function HIe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var rL=[0,\"auto\"],Gi={allowDataOverflow:!1,allowDecimals:!0,allowDuplicatedCategory:!0,angle:0,dataKey:void 0,domain:void 0,height:30,hide:!0,id:0,includeHidden:!1,interval:\"preserveEnd\",minTickGap:5,mirror:!1,name:void 0,orientation:\"bottom\",padding:{left:0,right:0},reversed:!1,scale:\"auto\",tick:!0,tickCount:5,tickFormatter:void 0,ticks:void 0,type:\"category\",unit:void 0},Mne=(t,e)=>t.cartesianAxis.xAxis[e],Qd=(t,e)=>{var n=Mne(t,e);return n??Gi},Wi={allowDataOverflow:!1,allowDecimals:!0,allowDuplicatedCategory:!0,angle:0,dataKey:void 0,domain:rL,hide:!0,id:0,includeHidden:!1,interval:\"preserveEnd\",minTickGap:5,mirror:!1,name:void 0,orientation:\"left\",padding:{top:0,bottom:0},reversed:!1,scale:\"auto\",tick:!0,tickCount:5,tickFormatter:void 0,ticks:void 0,type:\"number\",unit:void 0,width:gS},Ene=(t,e)=>t.cartesianAxis.yAxis[e],Zd=(t,e)=>{var n=Ene(t,e);return n??Wi},qIe={domain:[0,\"auto\"],includeHidden:!1,reversed:!1,allowDataOverflow:!1,allowDuplicatedCategory:!1,dataKey:void 0,id:0,name:\"\",range:[64,64],scale:\"auto\",type:\"number\",unit:\"\"},N4=(t,e)=>{var n=t.cartesianAxis.zAxis[e];return n??qIe},ri=(t,e,n)=>{switch(e){case\"xAxis\":return Qd(t,n);case\"yAxis\":return Zd(t,n);case\"zAxis\":return N4(t,n);case\"angleAxis\":return S4(t,n);case\"radiusAxis\":return _4(t,n);default:throw new Error(\"Unexpected axis type: \".concat(e))}},$Ie=(t,e,n)=>{switch(e){case\"xAxis\":return Qd(t,n);case\"yAxis\":return Zd(t,n);default:throw new Error(\"Unexpected axis type: \".concat(e))}},Fy=(t,e,n)=>{switch(e){case\"xAxis\":return Qd(t,n);case\"yAxis\":return Zd(t,n);case\"angleAxis\":return S4(t,n);case\"radiusAxis\":return _4(t,n);default:throw new Error(\"Unexpected axis type: \".concat(e))}},Dne=t=>t.graphicalItems.cartesianItems.some(e=>e.type===\"bar\")||t.graphicalItems.polarItems.some(e=>e.type===\"radialBar\");function C4(t,e){return n=>{switch(t){case\"xAxis\":return\"xAxisId\"in n&&n.xAxisId===e;case\"yAxis\":return\"yAxisId\"in n&&n.yAxisId===e;case\"zAxis\":return\"zAxisId\"in n&&n.zAxisId===e;case\"angleAxis\":return\"angleAxisId\"in n&&n.angleAxisId===e;case\"radiusAxis\":return\"radiusAxisId\"in n&&n.radiusAxisId===e;default:return!1}}}var PS=t=>t.graphicalItems.cartesianItems,VIe=wt([za,CS],C4),P4=(t,e,n)=>t.filter(n).filter(r=>e?.includeHidden===!0?!0:!r.hide),TS=wt([PS,ri,VIe],P4,{memoizeOptions:{resultEqualityCheck:zT}}),Fne=wt([TS],t=>t.filter(e=>e.type===\"area\"||e.type===\"bar\").filter(OT)),Rne=t=>t.filter(e=>!(\"stackId\"in e)||e.stackId===void 0),GIe=wt([TS],Rne),T4=t=>t.map(e=>e.data).filter(Boolean).flat(1),WIe=wt([TS],T4,{memoizeOptions:{resultEqualityCheck:zT}}),A4=(t,e)=>{var{chartData:n=[],dataStartIndex:r,dataEndIndex:i}=e;return t.length>0?t:n.slice(r,i+1)},j4=wt([WIe,g4],A4),M4=(t,e,n)=>e?.dataKey!=null?t.map(r=>({value:zr(r,e.dataKey)})):n.length>0?n.map(r=>r.dataKey).flatMap(r=>t.map(i=>({value:zr(i,r)}))):t.map(r=>({value:r})),UT=wt([j4,ri,TS],M4);function Lne(t,e){switch(t){case\"xAxis\":return e.direction===\"x\";case\"yAxis\":return e.direction===\"y\";default:return!1}}function Zk(t){if(hc(t)||t instanceof Date){var e=Number(t);if(Gn(e))return e}}function c9(t){if(Array.isArray(t)){var e=[Zk(t[0]),Zk(t[1])];return Pm(e)?e:void 0}var n=Zk(t);if(n!=null)return[n,n]}function Tm(t){return t.map(Zk).filter(Oo)}function KIe(t,e,n){return!n||typeof e!=\"number\"||Zc(e)?[]:n.length?Tm(n.flatMap(r=>{var i=zr(t,r.dataKey),s,o;if(Array.isArray(i)?[s,o]=i:s=o=i,!(!Gn(s)||!Gn(o)))return[e-s,e+o]})):[]}var Ii=t=>{var e=ns(t),n=Dy(t);return Fy(t,e,n)},AS=wt([Ii],t=>t?.dataKey),XIe=wt([Fne,g4,Ii],Ane),One=(t,e,n,r)=>{var i={},s=e.reduce((o,l)=>{if(l.stackId==null)return o;var c=o[l.stackId];return c==null&&(c=[]),c.push(l),o[l.stackId]=c,o},i);return Object.fromEntries(Object.entries(s).map(o=>{var[l,c]=o,d=r?[...c].reverse():c,u=d.map(LT);return[l,{stackedData:g3e(t,u,n),graphicalItems:d}]}))},CC=wt([XIe,Fne,NS,Sne],One),Ine=(t,e,n,r)=>{var{dataStartIndex:i,dataEndIndex:s}=e;if(r==null&&n!==\"zAxis\"){var o=v3e(t,i,s);if(!(o!=null&&o[0]===0&&o[1]===0))return o}},YIe=wt([ri],t=>t.allowDataOverflow),E4=t=>{var e;if(t==null||!(\"domain\"in t))return rL;if(t.domain!=null)return t.domain;if(\"ticks\"in t&&t.ticks!=null){if(t.type===\"number\"){var n=Tm(t.ticks);return[Math.min(...n),Math.max(...n)]}if(t.type===\"category\")return t.ticks.map(String)}return(e=t?.domain)!==null&&e!==void 0?e:rL},D4=wt([ri],E4),F4=wt([D4,YIe],cne),QIe=wt([CC,Dm,za,F4],Ine,{memoizeOptions:{resultEqualityCheck:IT}}),BT=t=>t.errorBars,ZIe=(t,e,n)=>t.flatMap(r=>e[r.id]).filter(Boolean).filter(r=>Lne(n,r)),PC=function(){for(var e=arguments.length,n=new Array(e),r=0;r<e;r++)n[r]=arguments[r];var i=n.filter(Boolean);if(i.length!==0){var s=i.flat(),o=Math.min(...s),l=Math.max(...s);return[o,l]}},R4=(t,e,n,r,i)=>{var s,o;if(n.length>0&&t.forEach(l=>{n.forEach(c=>{var d,u,m=(d=r[c.id])===null||d===void 0?void 0:d.filter(g=>Lne(i,g)),p=zr(l,(u=e.dataKey)!==null&&u!==void 0?u:c.dataKey),f=KIe(l,p,m);if(f.length>=2){var y=Math.min(...f),v=Math.max(...f);(s==null||y<s)&&(s=y),(o==null||v>o)&&(o=v)}var b=c9(p);b!=null&&(s=s==null?b[0]:Math.min(s,b[0]),o=o==null?b[1]:Math.max(o,b[1]))})}),e?.dataKey!=null&&t.forEach(l=>{var c=c9(zr(l,e.dataKey));c!=null&&(s=s==null?c[0]:Math.min(s,c[0]),o=o==null?c[1]:Math.max(o,c[1]))}),Gn(s)&&Gn(o))return[s,o]},JIe=wt([j4,ri,GIe,BT,za],R4,{memoizeOptions:{resultEqualityCheck:IT}});function e4e(t){var{value:e}=t;if(hc(e)||e instanceof Date)return e}var t4e=(t,e,n)=>{var r=t.map(e4e).filter(i=>i!=null);return n&&(e.dataKey==null||e.allowDuplicatedCategory&&tee(r))?Tte(0,t.length):e.allowDuplicatedCategory?r:Array.from(new Set(r))},zne=t=>t.referenceElements.dots,Ry=(t,e,n)=>t.filter(r=>r.ifOverflow===\"extendDomain\").filter(r=>e===\"xAxis\"?r.xAxisId===n:r.yAxisId===n),n4e=wt([zne,za,CS],Ry),Une=t=>t.referenceElements.areas,r4e=wt([Une,za,CS],Ry),Bne=t=>t.referenceElements.lines,a4e=wt([Bne,za,CS],Ry),Hne=(t,e)=>{if(t!=null){var n=Tm(t.map(r=>e===\"xAxis\"?r.x:r.y));if(n.length!==0)return[Math.min(...n),Math.max(...n)]}},i4e=wt(n4e,za,Hne),qne=(t,e)=>{if(t!=null){var n=Tm(t.flatMap(r=>[e===\"xAxis\"?r.x1:r.y1,e===\"xAxis\"?r.x2:r.y2]));if(n.length!==0)return[Math.min(...n),Math.max(...n)]}},s4e=wt([r4e,za],qne);function o4e(t){var e;if(t.x!=null)return Tm([t.x]);var n=(e=t.segment)===null||e===void 0?void 0:e.map(r=>r.x);return n==null||n.length===0?[]:Tm(n)}function l4e(t){var e;if(t.y!=null)return Tm([t.y]);var n=(e=t.segment)===null||e===void 0?void 0:e.map(r=>r.y);return n==null||n.length===0?[]:Tm(n)}var $ne=(t,e)=>{if(t!=null){var n=t.flatMap(r=>e===\"xAxis\"?o4e(r):l4e(r));if(n.length!==0)return[Math.min(...n),Math.max(...n)]}},c4e=wt([a4e,za],$ne),d4e=wt(i4e,c4e,s4e,(t,e,n)=>PC(t,n,e)),L4=(t,e,n,r,i,s,o,l)=>{if(n!=null)return n;var c=o===\"vertical\"&&l===\"xAxis\"||o===\"horizontal\"&&l===\"yAxis\",d=c?PC(r,s,i):PC(s,i);return SIe(e,d,t.allowDataOverflow)},u4e=wt([ri,D4,F4,QIe,JIe,d4e,Sr,za],L4,{memoizeOptions:{resultEqualityCheck:IT}}),m4e=[0,1],O4=(t,e,n,r,i,s,o)=>{if(!((t==null||n==null||n.length===0)&&o===void 0)){var{dataKey:l,type:c}=t,d=ad(e,s);if(d&&l==null){var u;return Tte(0,(u=n?.length)!==null&&u!==void 0?u:0)}return c===\"category\"?t4e(r,t,d):i===\"expand\"?m4e:o}},I4=wt([ri,Sr,j4,UT,NS,za,u4e],O4);function h4e(t){return t in g0}var Vne=(t,e,n)=>{if(t!=null){var{scale:r,type:i}=t;if(r===\"auto\")return i===\"category\"&&n&&(n.indexOf(\"LineChart\")>=0||n.indexOf(\"AreaChart\")>=0||n.indexOf(\"ComposedChart\")>=0&&!e)?\"point\":i===\"category\"?\"band\":\"linear\";if(typeof r==\"string\"){var s=\"scale\".concat(mS(r));return h4e(s)?s:\"point\"}}},Dp=wt([ri,Dne,y4],Vne);function z4(t,e,n,r){if(!(n==null||r==null))return typeof t.scale==\"function\"?o9(t.scale,n,r):o9(e,n,r)}var U4=(t,e,n)=>{var r=E4(e);if(!(n!==\"auto\"&&n!==\"linear\")){if(e!=null&&e.tickCount&&Array.isArray(r)&&(r[0]===\"auto\"||r[1]===\"auto\")&&Pm(t))return PIe(t,e.tickCount,e.allowDecimals);if(e!=null&&e.tickCount&&e.type===\"number\"&&Pm(t))return TIe(t,e.tickCount,e.allowDecimals)}},B4=wt([I4,Fy,Dp],U4),H4=(t,e,n,r)=>{if(r!==\"angleAxis\"&&t?.type===\"number\"&&Pm(e)&&Array.isArray(n)&&n.length>0){var i,s,o=e[0],l=(i=n[0])!==null&&i!==void 0?i:0,c=e[1],d=(s=n[n.length-1])!==null&&s!==void 0?s:0;return[Math.min(o,l),Math.max(c,d)]}return e},p4e=wt([ri,I4,B4,za],H4),f4e=wt(UT,ri,(t,e)=>{if(!(!e||e.type!==\"number\")){var n=1/0,r=Array.from(Tm(t.map(m=>m.value))).sort((m,p)=>m-p),i=r[0],s=r[r.length-1];if(i==null||s==null)return 1/0;var o=s-i;if(o===0)return 1/0;for(var l=0;l<r.length-1;l++){var c=r[l],d=r[l+1];if(!(c==null||d==null)){var u=d-c;n=Math.min(n,u)}}return n/o}}),Gne=wt(f4e,Sr,wne,Ri,(t,e,n,r,i)=>i,(t,e,n,r,i)=>{if(!Gn(t))return 0;var s=e===\"vertical\"?r.height:r.width;if(i===\"gap\")return t*s/2;if(i===\"no-gap\"){var o=Hs(n,t*s),l=t*s/2;return l-o-(l-o)/s*o}return 0}),g4e=(t,e,n)=>{var r=Qd(t,e);return r==null||typeof r.padding!=\"string\"?0:Gne(t,\"xAxis\",e,n,r.padding)},b4e=(t,e,n)=>{var r=Zd(t,e);return r==null||typeof r.padding!=\"string\"?0:Gne(t,\"yAxis\",e,n,r.padding)},x4e=wt(Qd,g4e,(t,e)=>{var n,r;if(t==null)return{left:0,right:0};var{padding:i}=t;return typeof i==\"string\"?{left:e,right:e}:{left:((n=i.left)!==null&&n!==void 0?n:0)+e,right:((r=i.right)!==null&&r!==void 0?r:0)+e}}),y4e=wt(Zd,b4e,(t,e)=>{var n,r;if(t==null)return{top:0,bottom:0};var{padding:i}=t;return typeof i==\"string\"?{top:e,bottom:e}:{top:((n=i.top)!==null&&n!==void 0?n:0)+e,bottom:((r=i.bottom)!==null&&r!==void 0?r:0)+e}}),v4e=wt([Ri,x4e,ST,wT,(t,e,n)=>n],(t,e,n,r,i)=>{var{padding:s}=r;return i?[s.left,n.width-s.right]:[t.left+e.left,t.left+t.width-e.right]}),w4e=wt([Ri,Sr,y4e,ST,wT,(t,e,n)=>n],(t,e,n,r,i,s)=>{var{padding:o}=i;return s?[r.height-o.bottom,o.top]:e===\"horizontal\"?[t.top+t.height-n.bottom,t.top+n.top]:[t.top+n.top,t.top+t.height-n.bottom]}),jS=(t,e,n,r)=>{var i;switch(e){case\"xAxis\":return v4e(t,n,r);case\"yAxis\":return w4e(t,n,r);case\"zAxis\":return(i=N4(t,n))===null||i===void 0?void 0:i.range;case\"angleAxis\":return Cne(t);case\"radiusAxis\":return Pne(t,n);default:return}},Wne=wt([ri,jS],DT),S4e=wt([Dp,p4e],jne),sy=wt([ri,Dp,S4e,Wne],z4);wt([TS,BT,za],ZIe);function Kne(t,e){return t.id<e.id?-1:t.id>e.id?1:0}var HT=(t,e)=>e,qT=(t,e,n)=>n,_4e=wt(yT,HT,qT,(t,e,n)=>t.filter(r=>r.orientation===e).filter(r=>r.mirror===n).sort(Kne)),k4e=wt(vT,HT,qT,(t,e,n)=>t.filter(r=>r.orientation===e).filter(r=>r.mirror===n).sort(Kne)),Xne=(t,e)=>({width:t.width,height:e.height}),N4e=(t,e)=>{var n=typeof e.width==\"number\"?e.width:gS;return{width:n,height:t.height}},Yne=wt(Ri,Qd,Xne),C4e=(t,e,n)=>{switch(e){case\"top\":return t.top;case\"bottom\":return n-t.bottom;default:return 0}},P4e=(t,e,n)=>{switch(e){case\"left\":return t.left;case\"right\":return n-t.right;default:return 0}},T4e=wt(Mm,Ri,_4e,HT,qT,(t,e,n,r,i)=>{var s={},o;return n.forEach(l=>{var c=Xne(e,l);o==null&&(o=C4e(e,r,t));var d=r===\"top\"&&!i||r===\"bottom\"&&i;s[l.id]=o-Number(d)*c.height,o+=(d?-1:1)*c.height}),s}),A4e=wt(jm,Ri,k4e,HT,qT,(t,e,n,r,i)=>{var s={},o;return n.forEach(l=>{var c=N4e(e,l);o==null&&(o=P4e(e,r,t));var d=r===\"left\"&&!i||r===\"right\"&&i;s[l.id]=o-Number(d)*c.width,o+=(d?-1:1)*c.width}),s}),j4e=(t,e)=>{var n=Qd(t,e);if(n!=null)return T4e(t,n.orientation,n.mirror)},M4e=wt([Ri,Qd,j4e,(t,e)=>e],(t,e,n,r)=>{if(e!=null){var i=n?.[r];return i==null?{x:t.left,y:0}:{x:t.left,y:i}}}),E4e=(t,e)=>{var n=Zd(t,e);if(n!=null)return A4e(t,n.orientation,n.mirror)},D4e=wt([Ri,Zd,E4e,(t,e)=>e],(t,e,n,r)=>{if(e!=null){var i=n?.[r];return i==null?{x:0,y:t.top}:{x:i,y:t.top}}}),Qne=wt(Ri,Zd,(t,e)=>{var n=typeof e.width==\"number\"?e.width:gS;return{width:n,height:t.height}}),d9=(t,e,n)=>{switch(e){case\"xAxis\":return Yne(t,n).width;case\"yAxis\":return Qne(t,n).height;default:return}},Zne=(t,e,n,r)=>{if(n!=null){var{allowDuplicatedCategory:i,type:s,dataKey:o}=n,l=ad(t,r),c=e.map(d=>d.value);if(o&&l&&s===\"category\"&&i&&tee(c))return c}},q4=wt([Sr,UT,ri,za],Zne),Jne=(t,e,n,r)=>{if(!(n==null||n.dataKey==null)){var{type:i,scale:s}=n,o=ad(t,r);if(o&&(i===\"number\"||s!==\"auto\"))return e.map(l=>l.value)}},$4=wt([Sr,UT,Fy,za],Jne),u9=wt([Sr,$Ie,Dp,sy,q4,$4,jS,B4,za],(t,e,n,r,i,s,o,l,c)=>{if(e!=null){var d=ad(t,c);return{angle:e.angle,interval:e.interval,minTickGap:e.minTickGap,orientation:e.orientation,tick:e.tick,tickCount:e.tickCount,tickFormatter:e.tickFormatter,ticks:e.ticks,type:e.type,unit:e.unit,axisType:c,categoricalDomain:s,duplicateDomain:i,isCategorical:d,niceTicks:l,range:o,realScaleType:n,scale:r}}}),F4e=(t,e,n,r,i,s,o,l,c)=>{if(!(e==null||r==null)){var d=ad(t,c),{type:u,ticks:m,tickCount:p}=e,f=n===\"scaleBand\"&&typeof r.bandwidth==\"function\"?r.bandwidth()/2:2,y=u===\"category\"&&r.bandwidth?r.bandwidth()/f:0;y=c===\"angleAxis\"&&s!=null&&s.length>=2?Qi(s[0]-s[1])*2*y:y;var v=m||i;return v?v.map((b,g)=>{var _=o?o.indexOf(b):b,C=r.map(_);return Gn(C)?{index:g,coordinate:C+y,value:b,offset:y}:null}).filter(Oo):d&&l?l.map((b,g)=>{var _=r.map(b);return Gn(_)?{coordinate:_+y,value:b,index:g,offset:y}:null}).filter(Oo):r.ticks?r.ticks(p).map((b,g)=>{var _=r.map(b);return Gn(_)?{coordinate:_+y,value:b,index:g,offset:y}:null}).filter(Oo):r.domain().map((b,g)=>{var _=r.map(b);return Gn(_)?{coordinate:_+y,value:o?o[b]:b,index:g,offset:y}:null}).filter(Oo)}},ere=wt([Sr,Fy,Dp,sy,B4,jS,q4,$4,za],F4e),R4e=(t,e,n,r,i,s,o)=>{if(!(e==null||n==null||r==null||r[0]===r[1])){var l=ad(t,o),{tickCount:c}=e,d=0;return d=o===\"angleAxis\"&&r?.length>=2?Qi(r[0]-r[1])*2*d:d,l&&s?s.map((u,m)=>{var p=n.map(u);return Gn(p)?{coordinate:p+d,value:u,index:m,offset:d}:null}).filter(Oo):n.ticks?n.ticks(c).map((u,m)=>{var p=n.map(u);return Gn(p)?{coordinate:p+d,value:u,index:m,offset:d}:null}).filter(Oo):n.domain().map((u,m)=>{var p=n.map(u);return Gn(p)?{coordinate:p+d,value:i?i[u]:u,index:m,offset:d}:null}).filter(Oo)}},Vd=wt([Sr,Fy,sy,jS,q4,$4,za],R4e),Gd=wt(ri,sy,(t,e)=>{if(!(t==null||e==null))return NC(NC({},t),{},{scale:e})}),L4e=wt([ri,Dp,I4,Wne],z4);wt((t,e,n)=>N4(t,n),L4e,(t,e)=>{if(!(t==null||e==null))return NC(NC({},t),{},{scale:e})});var O4e=wt([Sr,yT,vT],(t,e,n)=>{switch(t){case\"horizontal\":return e.some(r=>r.reversed)?\"right-to-left\":\"left-to-right\";case\"vertical\":return n.some(r=>r.reversed)?\"bottom-to-top\":\"top-to-bottom\";case\"centric\":case\"radial\":return\"left-to-right\";default:return}}),tre=t=>t.options.defaultTooltipEventType,nre=t=>t.options.validateTooltipEventTypes;function rre(t,e,n){if(t==null)return e;var r=t?\"axis\":\"item\";return n==null?e:n.includes(r)?r:e}function V4(t,e){var n=tre(t),r=nre(t);return rre(e,n,r)}function I4e(t){return sn(e=>V4(e,t))}var are=(t,e)=>{var n,r=Number(e);if(!(Zc(r)||e==null))return r>=0?t==null||(n=t[r])===null||n===void 0?void 0:n.value:void 0},z4e=t=>t.tooltip.settings,Lh={active:!1,index:null,dataKey:void 0,graphicalItemId:void 0,coordinate:void 0},U4e={itemInteraction:{click:Lh,hover:Lh},axisInteraction:{click:Lh,hover:Lh},keyboardInteraction:Lh,syncInteraction:{active:!1,index:null,dataKey:void 0,label:void 0,coordinate:void 0,sourceViewBox:void 0,graphicalItemId:void 0},tooltipItemPayloads:[],settings:{shared:void 0,trigger:\"hover\",axisId:0,active:!1,defaultIndex:void 0}},ire=$o({name:\"tooltip\",initialState:U4e,reducers:{addTooltipEntrySettings:{reducer(t,e){t.tooltipItemPayloads.push(e.payload)},prepare:Ta()},replaceTooltipEntrySettings:{reducer(t,e){var{prev:n,next:r}=e.payload,i=Xc(t).tooltipItemPayloads.indexOf(n);i>-1&&(t.tooltipItemPayloads[i]=r)},prepare:Ta()},removeTooltipEntrySettings:{reducer(t,e){var n=Xc(t).tooltipItemPayloads.indexOf(e.payload);n>-1&&t.tooltipItemPayloads.splice(n,1)},prepare:Ta()},setTooltipSettingsState(t,e){t.settings=e.payload},setActiveMouseOverItemIndex(t,e){t.syncInteraction.active=!1,t.keyboardInteraction.active=!1,t.itemInteraction.hover.active=!0,t.itemInteraction.hover.index=e.payload.activeIndex,t.itemInteraction.hover.dataKey=e.payload.activeDataKey,t.itemInteraction.hover.graphicalItemId=e.payload.activeGraphicalItemId,t.itemInteraction.hover.coordinate=e.payload.activeCoordinate},mouseLeaveChart(t){t.itemInteraction.hover.active=!1,t.axisInteraction.hover.active=!1},mouseLeaveItem(t){t.itemInteraction.hover.active=!1},setActiveClickItemIndex(t,e){t.syncInteraction.active=!1,t.itemInteraction.click.active=!0,t.keyboardInteraction.active=!1,t.itemInteraction.click.index=e.payload.activeIndex,t.itemInteraction.click.dataKey=e.payload.activeDataKey,t.itemInteraction.click.graphicalItemId=e.payload.activeGraphicalItemId,t.itemInteraction.click.coordinate=e.payload.activeCoordinate},setMouseOverAxisIndex(t,e){t.syncInteraction.active=!1,t.axisInteraction.hover.active=!0,t.keyboardInteraction.active=!1,t.axisInteraction.hover.index=e.payload.activeIndex,t.axisInteraction.hover.dataKey=e.payload.activeDataKey,t.axisInteraction.hover.coordinate=e.payload.activeCoordinate},setMouseClickAxisIndex(t,e){t.syncInteraction.active=!1,t.keyboardInteraction.active=!1,t.axisInteraction.click.active=!0,t.axisInteraction.click.index=e.payload.activeIndex,t.axisInteraction.click.dataKey=e.payload.activeDataKey,t.axisInteraction.click.coordinate=e.payload.activeCoordinate},setSyncInteraction(t,e){t.syncInteraction=e.payload},setKeyboardInteraction(t,e){t.keyboardInteraction.active=e.payload.active,t.keyboardInteraction.index=e.payload.activeIndex,t.keyboardInteraction.coordinate=e.payload.activeCoordinate}}}),{addTooltipEntrySettings:B4e,replaceTooltipEntrySettings:H4e,removeTooltipEntrySettings:q4e,setTooltipSettingsState:$4e,setActiveMouseOverItemIndex:sre,mouseLeaveItem:V4e,mouseLeaveChart:ore,setActiveClickItemIndex:G4e,setMouseOverAxisIndex:lre,setMouseClickAxisIndex:W4e,setSyncInteraction:aL,setKeyboardInteraction:iL}=ire.actions,K4e=ire.reducer;function m9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function uk(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?m9(Object(n),!0).forEach(function(r){X4e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):m9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function X4e(t,e,n){return(e=Y4e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function Y4e(t){var e=Q4e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function Q4e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function Z4e(t,e,n){return e===\"axis\"?n===\"click\"?t.axisInteraction.click:t.axisInteraction.hover:n===\"click\"?t.itemInteraction.click:t.itemInteraction.hover}function J4e(t){return t.index!=null}var cre=(t,e,n,r)=>{if(e==null)return Lh;var i=Z4e(t,e,n);if(i==null)return Lh;if(i.active)return i;if(t.keyboardInteraction.active)return t.keyboardInteraction;if(t.syncInteraction.active&&t.syncInteraction.index!=null)return t.syncInteraction;var s=t.settings.active===!0;if(J4e(i)){if(s)return uk(uk({},i),{},{active:!0})}else if(r!=null)return{active:!0,coordinate:void 0,dataKey:void 0,index:r,graphicalItemId:void 0};return uk(uk({},Lh),{},{coordinate:i.coordinate})};function eze(t){if(typeof t==\"number\")return Number.isFinite(t)?t:void 0;if(t instanceof Date){var e=t.valueOf();return Number.isFinite(e)?e:void 0}var n=Number(t);return Number.isFinite(n)?n:void 0}function tze(t,e){var n=eze(t),r=e[0],i=e[1];if(n===void 0)return!1;var s=Math.min(r,i),o=Math.max(r,i);return n>=s&&n<=o}function nze(t,e,n){if(n==null||e==null)return!0;var r=zr(t,e);return r==null||!Pm(n)?!0:tze(r,n)}var G4=(t,e,n,r)=>{var i=t?.index;if(i==null)return null;var s=Number(i);if(!Gn(s))return i;var o=0,l=1/0;e.length>0&&(l=e.length-1);var c=Math.max(o,Math.min(s,l)),d=e[c];return d==null||nze(d,n,r)?String(c):null},dre=(t,e,n,r,i,s,o)=>{if(s!=null){var l=o[0],c=l?.getPosition(s);if(c!=null)return c;var d=i?.[Number(s)];if(d)return n===\"horizontal\"?{x:d.coordinate,y:(r.top+e)/2}:{x:(r.left+t)/2,y:d.coordinate}}},ure=(t,e,n,r)=>{if(e===\"axis\")return t.tooltipItemPayloads;if(t.tooltipItemPayloads.length===0)return[];var i;if(n===\"hover\"?i=t.itemInteraction.hover.graphicalItemId:i=t.itemInteraction.click.graphicalItemId,i==null&&r!=null){var s=t.tooltipItemPayloads[0];return s!=null?[s]:[]}return t.tooltipItemPayloads.filter(o=>{var l;return((l=o.settings)===null||l===void 0?void 0:l.graphicalItemId)===i})},mre=t=>t.options.tooltipPayloadSearcher,Ly=t=>t.tooltip;function h9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function p9(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?h9(Object(n),!0).forEach(function(r){rze(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):h9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function rze(t,e,n){return(e=aze(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function aze(t){var e=ize(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function ize(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function sze(t,e){return t??e}var hre=(t,e,n,r,i,s,o)=>{if(!(e==null||s==null)){var{chartData:l,computedData:c,dataStartIndex:d,dataEndIndex:u}=n,m=[];return t.reduce((p,f)=>{var y,{dataDefinedOnItem:v,settings:b}=f,g=sze(v,l),_=Array.isArray(g)?Xee(g,d,u):g,C=(y=b?.dataKey)!==null&&y!==void 0?y:r,P=b?.nameKey,N;if(r&&Array.isArray(_)&&!Array.isArray(_[0])&&o===\"axis\"?N=nee(_,r,i):N=s(_,e,c,P),Array.isArray(N))N.forEach(T=>{var F=p9(p9({},b),{},{name:T.name,unit:T.unit,color:void 0,fill:void 0});p.push(m7({tooltipEntrySettings:F,dataKey:T.dataKey,payload:T.payload,value:zr(T.payload,T.dataKey),name:T.name}))});else{var A;p.push(m7({tooltipEntrySettings:b,dataKey:C,payload:N,value:zr(N,C),name:(A=zr(N,P))!==null&&A!==void 0?A:b?.name}))}return p},m)}},W4=wt([Ii,Dne,y4],Vne),oze=wt([t=>t.graphicalItems.cartesianItems,t=>t.graphicalItems.polarItems],(t,e)=>[...t,...e]),lze=wt([ns,Dy],C4),Oy=wt([oze,Ii,lze],P4,{memoizeOptions:{resultEqualityCheck:zT}}),cze=wt([Oy],t=>t.filter(OT)),dze=wt([Oy],T4,{memoizeOptions:{resultEqualityCheck:zT}}),Iy=wt([dze,Dm],A4),uze=wt([cze,Dm,Ii],Ane),K4=wt([Iy,Ii,Oy],M4),pre=wt([Ii],E4),mze=wt([Ii],t=>t.allowDataOverflow),fre=wt([pre,mze],cne),hze=wt([Oy],t=>t.filter(OT)),pze=wt([uze,hze,NS,Sne],One),fze=wt([pze,Dm,ns,fre],Ine),gze=wt([Oy],Rne),bze=wt([Iy,Ii,gze,BT,ns],R4,{memoizeOptions:{resultEqualityCheck:IT}}),xze=wt([zne,ns,Dy],Ry),yze=wt([xze,ns],Hne),vze=wt([Une,ns,Dy],Ry),wze=wt([vze,ns],qne),Sze=wt([Bne,ns,Dy],Ry),_ze=wt([Sze,ns],$ne),kze=wt([yze,_ze,wze],PC),Nze=wt([Ii,pre,fre,fze,bze,kze,Sr,ns],L4),MS=wt([Ii,Sr,Iy,K4,NS,ns,Nze],O4),Cze=wt([MS,Ii,W4],U4),Pze=wt([Ii,MS,Cze,ns],H4),gre=t=>{var e=ns(t),n=Dy(t),r=!1;return jS(t,e,n,r)},bre=wt([Ii,gre],DT),xre=wt([Ii,W4,Pze,bre],z4),Tze=wt([Sr,K4,Ii,ns],Zne),Aze=wt([Sr,K4,Ii,ns],Jne),jze=(t,e,n,r,i,s,o,l)=>{if(e){var{type:c}=e,d=ad(t,l);if(r){var u=n===\"scaleBand\"&&r.bandwidth?r.bandwidth()/2:2,m=c===\"category\"&&r.bandwidth?r.bandwidth()/u:0;return m=l===\"angleAxis\"&&i!=null&&i?.length>=2?Qi(i[0]-i[1])*2*m:m,d&&o?o.map((p,f)=>{var y=r.map(p);return Gn(y)?{coordinate:y+m,value:p,index:f,offset:m}:null}).filter(Oo):r.domain().map((p,f)=>{var y=r.map(p);return Gn(y)?{coordinate:y+m,value:s?s[p]:p,index:f,offset:m}:null}).filter(Oo)}}},Fm=wt([Sr,Ii,W4,xre,gre,Tze,Aze,ns],jze),X4=wt([tre,nre,z4e],(t,e,n)=>rre(n.shared,t,e)),yre=t=>t.tooltip.settings.trigger,Y4=t=>t.tooltip.settings.defaultIndex,ES=wt([Ly,X4,yre,Y4],cre),Sp=wt([ES,Iy,AS,MS],G4),vre=wt([Fm,Sp],are),Q4=wt([ES],t=>{if(t)return t.dataKey}),Mze=wt([ES],t=>{if(t)return t.graphicalItemId}),wre=wt([Ly,X4,yre,Y4],ure),Eze=wt([jm,Mm,Sr,Ri,Fm,Y4,wre],dre),Dze=wt([ES,Eze],(t,e)=>t!=null&&t.coordinate?t.coordinate:e),Fze=wt([ES],t=>{var e;return(e=t?.active)!==null&&e!==void 0?e:!1}),Rze=wt([wre,Sp,Dm,AS,vre,mre,X4],hre),Lze=wt([Rze],t=>{if(t!=null){var e=t.map(n=>n.payload).filter(n=>n!=null);return Array.from(new Set(e))}});function f9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function g9(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?f9(Object(n),!0).forEach(function(r){Oze(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):f9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function Oze(t,e,n){return(e=Ize(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function Ize(t){var e=zze(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function zze(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var Uze=()=>sn(Ii),Bze=()=>{var t=Uze(),e=sn(Fm),n=sn(xre);return wp(!t||!n?void 0:g9(g9({},t),{},{scale:n}),e)};function b9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function ax(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?b9(Object(n),!0).forEach(function(r){Hze(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):b9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function Hze(t,e,n){return(e=qze(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function qze(t){var e=$ze(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function $ze(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var Vze=(t,e,n,r)=>{var i=e.find(s=>s&&s.index===n);if(i){if(t===\"horizontal\")return{x:i.coordinate,y:r.chartY};if(t===\"vertical\")return{x:r.chartX,y:i.coordinate}}return{x:0,y:0}},Gze=(t,e,n,r)=>{var i=e.find(d=>d&&d.index===n);if(i){if(t===\"centric\"){var s=i.coordinate,{radius:o}=r;return ax(ax(ax({},r),wi(r.cx,r.cy,o,s)),{},{angle:s,radius:o})}var l=i.coordinate,{angle:c}=r;return ax(ax(ax({},r),wi(r.cx,r.cy,l,c)),{},{angle:c,radius:l})}return{angle:0,clockWise:!1,cx:0,cy:0,endAngle:0,innerRadius:0,outerRadius:0,radius:0,startAngle:0,x:0,y:0}};function Wze(t,e){var{chartX:n,chartY:r}=t;return n>=e.left&&n<=e.left+e.width&&r>=e.top&&r<=e.top+e.height}var Sre=(t,e,n,r,i)=>{var s,o=(s=e?.length)!==null&&s!==void 0?s:0;if(o<=1||t==null)return 0;if(r===\"angleAxis\"&&i!=null&&Math.abs(Math.abs(i[1]-i[0])-360)<=1e-6)for(var l=0;l<o;l++){var c,d,u,m,p,f=l>0?(c=n[l-1])===null||c===void 0?void 0:c.coordinate:(d=n[o-1])===null||d===void 0?void 0:d.coordinate,y=(u=n[l])===null||u===void 0?void 0:u.coordinate,v=l>=o-1?(m=n[0])===null||m===void 0?void 0:m.coordinate:(p=n[l+1])===null||p===void 0?void 0:p.coordinate,b=void 0;if(!(f==null||y==null||v==null))if(Qi(y-f)!==Qi(v-y)){var g=[];if(Qi(v-y)===Qi(i[1]-i[0])){b=v;var _=y+i[1]-i[0];g[0]=Math.min(_,(_+f)/2),g[1]=Math.max(_,(_+f)/2)}else{b=f;var C=v+i[1]-i[0];g[0]=Math.min(y,(C+y)/2),g[1]=Math.max(y,(C+y)/2)}var P=[Math.min(y,(b+y)/2),Math.max(y,(b+y)/2)];if(t>P[0]&&t<=P[1]||t>=g[0]&&t<=g[1]){var N;return(N=n[l])===null||N===void 0?void 0:N.index}}else{var A=Math.min(f,v),T=Math.max(f,v);if(t>(A+y)/2&&t<=(T+y)/2){var F;return(F=n[l])===null||F===void 0?void 0:F.index}}}else if(e)for(var k=0;k<o;k++){var D=e[k];if(D!=null){var H=e[k+1],z=e[k-1];if(k===0&&H!=null&&t<=(D.coordinate+H.coordinate)/2||k===o-1&&z!=null&&t>(D.coordinate+z.coordinate)/2||k>0&&k<o-1&&z!=null&&H!=null&&t>(D.coordinate+z.coordinate)/2&&t<=(D.coordinate+H.coordinate)/2)return D.index}}return-1},_re=()=>sn(y4),Z4=(t,e)=>e,kre=(t,e,n)=>n,J4=(t,e,n,r)=>r,Kze=wt(Fm,t=>cT(t,e=>e.coordinate)),ez=wt([Ly,Z4,kre,J4],cre),tz=wt([ez,Iy,AS,MS],G4),Xze=(t,e,n)=>{if(e!=null){var r=Ly(t);return e===\"axis\"?n===\"hover\"?r.axisInteraction.hover.dataKey:r.axisInteraction.click.dataKey:n===\"hover\"?r.itemInteraction.hover.dataKey:r.itemInteraction.click.dataKey}},Nre=wt([Ly,Z4,kre,J4],ure),TC=wt([jm,Mm,Sr,Ri,Fm,J4,Nre],dre),Yze=wt([ez,TC],(t,e)=>{var n;return(n=t.coordinate)!==null&&n!==void 0?n:e}),Cre=wt([Fm,tz],are),Qze=wt([Nre,tz,Dm,AS,Cre,mre,Z4],hre),Zze=wt([ez,tz],(t,e)=>({isActive:t.active&&e!=null,activeIndex:e})),Jze=(t,e,n,r,i,s,o)=>{if(!(!t||!n||!r||!i)&&Wze(t,o)){var l=w3e(t,e),c=Sre(l,s,i,n,r),d=Vze(e,i,c,t);return{activeIndex:String(c),activeCoordinate:d}}},e5e=(t,e,n,r,i,s,o)=>{if(!(!t||!r||!i||!s||!n)){var l=IRe(t,n);if(l){var c=S3e(l,e),d=Sre(c,o,s,r,i),u=Gze(e,s,d,l);return{activeIndex:String(d),activeCoordinate:u}}}},t5e=(t,e,n,r,i,s,o,l)=>{if(!(!t||!e||!r||!i||!s))return e===\"horizontal\"||e===\"vertical\"?Jze(t,e,r,i,s,o,l):e5e(t,e,n,r,i,s,o)},n5e=wt(t=>t.zIndex.zIndexMap,(t,e)=>e,(t,e,n)=>n,(t,e,n)=>{if(e!=null){var r=t[e];if(r!=null)return n?r.panoramaElement:r.element}}),r5e=wt(t=>t.zIndex.zIndexMap,t=>{var e=Object.keys(t).map(r=>parseInt(r,10)).concat(Object.values(Ja)),n=Array.from(new Set(e));return n.sort((r,i)=>r-i)},{memoizeOptions:{resultEqualityCheck:IIe}});function x9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function y9(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?x9(Object(n),!0).forEach(function(r){a5e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):x9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function a5e(t,e,n){return(e=i5e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function i5e(t){var e=s5e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function s5e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var o5e={},l5e={zIndexMap:Object.values(Ja).reduce((t,e)=>y9(y9({},t),{},{[e]:{element:void 0,panoramaElement:void 0,consumers:0}}),o5e)},c5e=new Set(Object.values(Ja));function d5e(t){return c5e.has(t)}var Pre=$o({name:\"zIndex\",initialState:l5e,reducers:{registerZIndexPortal:{reducer:(t,e)=>{var{zIndex:n}=e.payload;t.zIndexMap[n]?t.zIndexMap[n].consumers+=1:t.zIndexMap[n]={consumers:1,element:void 0,panoramaElement:void 0}},prepare:Ta()},unregisterZIndexPortal:{reducer:(t,e)=>{var{zIndex:n}=e.payload;t.zIndexMap[n]&&(t.zIndexMap[n].consumers-=1,t.zIndexMap[n].consumers<=0&&!d5e(n)&&delete t.zIndexMap[n])},prepare:Ta()},registerZIndexPortalElement:{reducer:(t,e)=>{var{zIndex:n,element:r,isPanorama:i}=e.payload;t.zIndexMap[n]?i?t.zIndexMap[n].panoramaElement=r:t.zIndexMap[n].element=r:t.zIndexMap[n]={consumers:0,element:i?void 0:r,panoramaElement:i?r:void 0}},prepare:Ta()},unregisterZIndexPortalElement:{reducer:(t,e)=>{var{zIndex:n}=e.payload;t.zIndexMap[n]&&(e.payload.isPanorama?t.zIndexMap[n].panoramaElement=void 0:t.zIndexMap[n].element=void 0)},prepare:Ta()}}}),{registerZIndexPortal:u5e,unregisterZIndexPortal:m5e,registerZIndexPortalElement:h5e,unregisterZIndexPortalElement:p5e}=Pre.actions,f5e=Pre.reducer;function Gs(t){var{zIndex:e,children:n}=t,r=J3e(),i=r&&e!==void 0&&e!==0,s=Li(),o=ua();w.useLayoutEffect(()=>i?(o(u5e({zIndex:e})),()=>{o(m5e({zIndex:e}))}):Tp,[o,e,i]);var l=sn(c=>n5e(c,e,s));return i?l?Yu.createPortal(n,l):null:n}function sL(){return sL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},sL.apply(null,arguments)}function v9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function mk(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?v9(Object(n),!0).forEach(function(r){g5e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):v9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function g5e(t,e,n){return(e=b5e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function b5e(t){var e=x5e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function x5e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function y5e(t){var{cursor:e,cursorComp:n,cursorProps:r}=t;return w.isValidElement(e)?w.cloneElement(e,r):w.createElement(n,r)}function v5e(t){var e,{coordinate:n,payload:r,index:i,offset:s,tooltipAxisBandSize:o,layout:l,cursor:c,tooltipEventType:d,chartName:u}=t,m=n,p=r,f=i;if(!c||!m||u!==\"ScatterChart\"&&d!==\"axis\")return null;var y,v,b;if(u===\"ScatterChart\")y=m,v=ZFe,b=Ja.cursorLine;else if(u===\"BarChart\")y=JFe(l,m,s,o),v=_te,b=Ja.cursorRectangle;else if(l===\"radial\"&&aee(m)){var{cx:g,cy:_,radius:C,startAngle:P,endAngle:N}=Nte(m);y={cx:g,cy:_,startAngle:P,endAngle:N,innerRadius:C,outerRadius:C},v=Pte,b=Ja.cursorLine}else y={points:HRe(l,m,s)},v=Rx,b=Ja.cursorLine;var A=typeof c==\"object\"&&\"className\"in c?c.className:void 0,T=mk(mk(mk(mk({stroke:\"#ccc\",pointerEvents:\"none\"},s),y),vg(c)),{},{payload:p,payloadIndex:f,className:_r(\"recharts-tooltip-cursor\",A)});return w.createElement(Gs,{zIndex:(e=t.zIndex)!==null&&e!==void 0?e:b},w.createElement(y5e,{cursor:c,cursorComp:v,cursorProps:T}))}function w5e(t){var e=Bze(),n=ite(),r=jp(),i=_re();return e==null||n==null||r==null||i==null?null:w.createElement(v5e,sL({},t,{offset:n,layout:r,tooltipAxisBandSize:e,chartName:i}))}var Tre=w.createContext(null),S5e=()=>w.useContext(Tre),pD={exports:{}},w9;function _5e(){return w9||(w9=1,(function(t){var e=Object.prototype.hasOwnProperty,n=\"~\";function r(){}Object.create&&(r.prototype=Object.create(null),new r().__proto__||(n=!1));function i(c,d,u){this.fn=c,this.context=d,this.once=u||!1}function s(c,d,u,m,p){if(typeof u!=\"function\")throw new TypeError(\"The listener must be a function\");var f=new i(u,m||c,p),y=n?n+d:d;return c._events[y]?c._events[y].fn?c._events[y]=[c._events[y],f]:c._events[y].push(f):(c._events[y]=f,c._eventsCount++),c}function o(c,d){--c._eventsCount===0?c._events=new r:delete c._events[d]}function l(){this._events=new r,this._eventsCount=0}l.prototype.eventNames=function(){var d=[],u,m;if(this._eventsCount===0)return d;for(m in u=this._events)e.call(u,m)&&d.push(n?m.slice(1):m);return Object.getOwnPropertySymbols?d.concat(Object.getOwnPropertySymbols(u)):d},l.prototype.listeners=function(d){var u=n?n+d:d,m=this._events[u];if(!m)return[];if(m.fn)return[m.fn];for(var p=0,f=m.length,y=new Array(f);p<f;p++)y[p]=m[p].fn;return y},l.prototype.listenerCount=function(d){var u=n?n+d:d,m=this._events[u];return m?m.fn?1:m.length:0},l.prototype.emit=function(d,u,m,p,f,y){var v=n?n+d:d;if(!this._events[v])return!1;var b=this._events[v],g=arguments.length,_,C;if(b.fn){switch(b.once&&this.removeListener(d,b.fn,void 0,!0),g){case 1:return b.fn.call(b.context),!0;case 2:return b.fn.call(b.context,u),!0;case 3:return b.fn.call(b.context,u,m),!0;case 4:return b.fn.call(b.context,u,m,p),!0;case 5:return b.fn.call(b.context,u,m,p,f),!0;case 6:return b.fn.call(b.context,u,m,p,f,y),!0}for(C=1,_=new Array(g-1);C<g;C++)_[C-1]=arguments[C];b.fn.apply(b.context,_)}else{var P=b.length,N;for(C=0;C<P;C++)switch(b[C].once&&this.removeListener(d,b[C].fn,void 0,!0),g){case 1:b[C].fn.call(b[C].context);break;case 2:b[C].fn.call(b[C].context,u);break;case 3:b[C].fn.call(b[C].context,u,m);break;case 4:b[C].fn.call(b[C].context,u,m,p);break;default:if(!_)for(N=1,_=new Array(g-1);N<g;N++)_[N-1]=arguments[N];b[C].fn.apply(b[C].context,_)}}return!0},l.prototype.on=function(d,u,m){return s(this,d,u,m,!1)},l.prototype.once=function(d,u,m){return s(this,d,u,m,!0)},l.prototype.removeListener=function(d,u,m,p){var f=n?n+d:d;if(!this._events[f])return this;if(!u)return o(this,f),this;var y=this._events[f];if(y.fn)y.fn===u&&(!p||y.once)&&(!m||y.context===m)&&o(this,f);else{for(var v=0,b=[],g=y.length;v<g;v++)(y[v].fn!==u||p&&!y[v].once||m&&y[v].context!==m)&&b.push(y[v]);b.length?this._events[f]=b.length===1?b[0]:b:o(this,f)}return this},l.prototype.removeAllListeners=function(d){var u;return d?(u=n?n+d:d,this._events[u]&&o(this,u)):(this._events=new r,this._eventsCount=0),this},l.prototype.off=l.prototype.removeListener,l.prototype.addListener=l.prototype.on,l.prefixed=n,l.EventEmitter=l,t.exports=l})(pD)),pD.exports}var k5e=_5e();const N5e=bc(k5e);var Mw=new N5e,oL=\"recharts.syncEvent.tooltip\",S9=\"recharts.syncEvent.brush\",$T=(t,e)=>{if(e&&Array.isArray(t)){var n=Number.parseInt(e,10);if(!Zc(n))return t[n]}},C5e={chartName:\"\",tooltipPayloadSearcher:()=>{},eventEmitter:void 0,defaultTooltipEventType:\"axis\"},Are=$o({name:\"options\",initialState:C5e,reducers:{createEventEmitter:t=>{t.eventEmitter==null&&(t.eventEmitter=Symbol(\"rechartsEventEmitter\"))}}}),P5e=Are.reducer,{createEventEmitter:T5e}=Are.actions;function A5e(t){return t.tooltip.syncInteraction}var j5e={chartData:void 0,computedData:void 0,dataStartIndex:0,dataEndIndex:0},jre=$o({name:\"chartData\",initialState:j5e,reducers:{setChartData(t,e){if(t.chartData=e.payload,e.payload==null){t.dataStartIndex=0,t.dataEndIndex=0;return}e.payload.length>0&&t.dataEndIndex!==e.payload.length-1&&(t.dataEndIndex=e.payload.length-1)},setComputedData(t,e){t.computedData=e.payload},setDataStartEndIndexes(t,e){var{startIndex:n,endIndex:r}=e.payload;n!=null&&(t.dataStartIndex=n),r!=null&&(t.dataEndIndex=r)}}}),{setChartData:_9,setDataStartEndIndexes:M5e,setComputedData:Nst}=jre.actions,E5e=jre.reducer,D5e=[\"x\",\"y\"];function k9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function ix(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?k9(Object(n),!0).forEach(function(r){F5e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):k9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function F5e(t,e,n){return(e=R5e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function R5e(t){var e=L5e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function L5e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function O5e(t,e){if(t==null)return{};var n,r,i=I5e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function I5e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function z5e(){var t=sn(v4),e=sn(w4),n=ua(),r=sn(_ne),i=sn(Fm),s=jp(),o=bS(),l=sn(c=>c.rootProps.className);w.useEffect(()=>{if(t==null)return Tp;var c=(d,u,m)=>{if(e!==m&&t===d){if(r===\"index\"){var p;if(o&&u!==null&&u!==void 0&&(p=u.payload)!==null&&p!==void 0&&p.coordinate&&u.payload.sourceViewBox){var f=u.payload.coordinate,{x:y,y:v}=f,b=O5e(f,D5e),{x:g,y:_,width:C,height:P}=u.payload.sourceViewBox,N=ix(ix({},b),{},{x:o.x+(C?(y-g)/C:0)*o.width,y:o.y+(P?(v-_)/P:0)*o.height});n(ix(ix({},u),{},{payload:ix(ix({},u.payload),{},{coordinate:N})}))}else n(u);return}if(i!=null){var A;if(typeof r==\"function\"){var T={activeTooltipIndex:u.payload.index==null?void 0:Number(u.payload.index),isTooltipActive:u.payload.active,activeIndex:u.payload.index==null?void 0:Number(u.payload.index),activeLabel:u.payload.label,activeDataKey:u.payload.dataKey,activeCoordinate:u.payload.coordinate},F=r(i,T);A=i[F]}else r===\"value\"&&(A=i.find(ie=>String(ie.value)===u.payload.label));var{coordinate:k}=u.payload;if(A==null||u.payload.active===!1||k==null||o==null){n(aL({active:!1,coordinate:void 0,dataKey:void 0,index:null,label:void 0,sourceViewBox:void 0,graphicalItemId:void 0}));return}var{x:D,y:H}=k,z=Math.min(D,o.x+o.width),Q=Math.min(H,o.y+o.height),L={x:s===\"horizontal\"?A.coordinate:z,y:s===\"horizontal\"?Q:A.coordinate},te=aL({active:u.payload.active,coordinate:L,dataKey:u.payload.dataKey,index:String(A.index),label:u.payload.label,sourceViewBox:u.payload.sourceViewBox,graphicalItemId:u.payload.graphicalItemId});n(te)}}};return Mw.on(oL,c),()=>{Mw.off(oL,c)}},[l,n,e,t,r,i,s,o])}function U5e(){var t=sn(v4),e=sn(w4),n=ua();w.useEffect(()=>{if(t==null)return Tp;var r=(i,s,o)=>{e!==o&&t===i&&n(M5e(s))};return Mw.on(S9,r),()=>{Mw.off(S9,r)}},[n,e,t])}function B5e(){var t=ua();w.useEffect(()=>{t(T5e())},[t]),z5e(),U5e()}function H5e(t,e,n,r,i,s){var o=sn(f=>Xze(f,t,e)),l=sn(w4),c=sn(v4),d=sn(_ne),u=sn(A5e),m=u?.active,p=bS();w.useEffect(()=>{if(!m&&c!=null&&l!=null){var f=aL({active:s,coordinate:n,dataKey:o,index:i,label:typeof r==\"number\"?String(r):r,sourceViewBox:p,graphicalItemId:void 0});Mw.emit(oL,c,f,l)}},[m,n,o,i,r,l,c,d,s,p])}function N9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function C9(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?N9(Object(n),!0).forEach(function(r){q5e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):N9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function q5e(t,e,n){return(e=$5e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function $5e(t){var e=V5e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function V5e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function G5e(t){return t.dataKey}function W5e(t,e){return w.isValidElement(t)?w.cloneElement(t,e):typeof t==\"function\"?w.createElement(t,e):w.createElement(MFe,e)}var P9=[],K5e={allowEscapeViewBox:{x:!1,y:!1},animationDuration:400,animationEasing:\"ease\",axisId:0,contentStyle:{},cursor:!0,filterNull:!0,includeHidden:!1,isAnimationActive:\"auto\",itemSorter:\"name\",itemStyle:{},labelStyle:{},offset:10,reverseDirection:{x:!1,y:!1},separator:\" : \",trigger:\"hover\",useTranslate3d:!1,wrapperStyle:{}};function Wh(t){var e,n,r=Ia(t,K5e),{active:i,allowEscapeViewBox:s,animationDuration:o,animationEasing:l,content:c,filterNull:d,isAnimationActive:u,offset:m,payloadUniqBy:p,position:f,reverseDirection:y,useTranslate3d:v,wrapperStyle:b,cursor:g,shared:_,trigger:C,defaultIndex:P,portal:N,axisId:A}=r,T=ua(),F=typeof P==\"number\"?String(P):P;w.useEffect(()=>{T($4e({shared:_,trigger:C,axisId:A,active:i,defaultIndex:F}))},[T,_,C,A,i,F]);var k=bS(),D=xte(),H=I4e(_),{activeIndex:z,isActive:Q}=(e=sn(Y=>Zze(Y,H,C,F)))!==null&&e!==void 0?e:{},L=sn(Y=>Qze(Y,H,C,F)),te=sn(Y=>Cre(Y,H,C,F)),ie=sn(Y=>Yze(Y,H,C,F)),J=L,oe=S5e(),fe=(n=i??Q)!==null&&n!==void 0?n:!1,[re,W]=yee([J,fe]),ne=H===\"axis\"?te:void 0;H5e(H,C,ie,ne,z,fe);var me=N??oe;if(me==null||k==null||H==null)return null;var be=J??P9;fe||(be=P9),d&&be.length&&(be=hee(be.filter(Y=>Y.value!=null&&(Y.hide!==!0||r.includeHidden)),p,G5e));var Ce=be.length>0,q=w.createElement(IFe,{allowEscapeViewBox:s,animationDuration:o,animationEasing:l,isAnimationActive:u,active:fe,coordinate:ie,hasPayload:Ce,offset:m,position:f,reverseDirection:y,useTranslate3d:v,viewBox:k,wrapperStyle:b,lastBoundingBox:re,innerRef:W,hasPortalFromProps:!!N},W5e(c,C9(C9({},r),{},{payload:be,label:ne,active:fe,activeIndex:z,coordinate:ie,accessibilityLayer:D})));return w.createElement(w.Fragment,null,Yu.createPortal(q,me),fe&&w.createElement(w5e,{cursor:g,tooltipEventType:H,coordinate:ie,payload:be,index:z}))}var oy=t=>null;oy.displayName=\"Cell\";function X5e(t,e,n){return(e=Y5e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function Y5e(t){var e=Q5e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function Q5e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}class Z5e{constructor(e){X5e(this,\"cache\",new Map),this.maxSize=e}get(e){var n=this.cache.get(e);return n!==void 0&&(this.cache.delete(e),this.cache.set(e,n)),n}set(e,n){if(this.cache.has(e))this.cache.delete(e);else if(this.cache.size>=this.maxSize){var r=this.cache.keys().next().value;r!=null&&this.cache.delete(r)}this.cache.set(e,n)}clear(){this.cache.clear()}size(){return this.cache.size}}function T9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function J5e(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?T9(Object(n),!0).forEach(function(r){eUe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):T9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function eUe(t,e,n){return(e=tUe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function tUe(t){var e=nUe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function nUe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var rUe={cacheSize:2e3,enableCache:!0},Mre=J5e({},rUe),A9=new Z5e(Mre.cacheSize),aUe={position:\"absolute\",top:\"-20000px\",left:0,padding:0,margin:0,border:\"none\",whiteSpace:\"pre\"},j9=\"recharts_measurement_span\";function iUe(t,e){var n=e.fontSize||\"\",r=e.fontFamily||\"\",i=e.fontWeight||\"\",s=e.fontStyle||\"\",o=e.letterSpacing||\"\",l=e.textTransform||\"\";return\"\".concat(t,\"|\").concat(n,\"|\").concat(r,\"|\").concat(i,\"|\").concat(s,\"|\").concat(o,\"|\").concat(l)}var M9=(t,e)=>{try{var n=document.getElementById(j9);n||(n=document.createElement(\"span\"),n.setAttribute(\"id\",j9),n.setAttribute(\"aria-hidden\",\"true\"),document.body.appendChild(n)),Object.assign(n.style,aUe,e),n.textContent=\"\".concat(t);var r=n.getBoundingClientRect();return{width:r.width,height:r.height}}catch{return{width:0,height:0}}},R0=function(e){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};if(e==null||vS.isSsr)return{width:0,height:0};if(!Mre.enableCache)return M9(e,n);var r=iUe(e,n),i=A9.get(r);if(i)return i;var s=M9(e,n);return A9.set(r,s),s},Ere;function sUe(t,e,n){return(e=oUe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function oUe(t){var e=lUe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function lUe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var E9=/(-?\\d+(?:\\.\\d+)?[a-zA-Z%]*)([*/])(-?\\d+(?:\\.\\d+)?[a-zA-Z%]*)/,D9=/(-?\\d+(?:\\.\\d+)?[a-zA-Z%]*)([+-])(-?\\d+(?:\\.\\d+)?[a-zA-Z%]*)/,cUe=/^px|cm|vh|vw|em|rem|%|mm|in|pt|pc|ex|ch|vmin|vmax|Q$/,dUe=/(-?\\d+(?:\\.\\d+)?)([a-zA-Z%]+)?/,uUe={cm:96/2.54,mm:96/25.4,pt:96/72,pc:96/6,in:96,Q:96/(2.54*40),px:1},mUe=[\"cm\",\"mm\",\"pt\",\"pc\",\"in\",\"Q\",\"px\"];function hUe(t){return mUe.includes(t)}var _x=\"NaN\";function pUe(t,e){return t*uUe[e]}class fs{static parse(e){var n,[,r,i]=(n=dUe.exec(e))!==null&&n!==void 0?n:[];return r==null?fs.NaN:new fs(parseFloat(r),i??\"\")}constructor(e,n){this.num=e,this.unit=n,this.num=e,this.unit=n,Zc(e)&&(this.unit=\"\"),n!==\"\"&&!cUe.test(n)&&(this.num=NaN,this.unit=\"\"),hUe(n)&&(this.num=pUe(e,n),this.unit=\"px\")}add(e){return this.unit!==e.unit?new fs(NaN,\"\"):new fs(this.num+e.num,this.unit)}subtract(e){return this.unit!==e.unit?new fs(NaN,\"\"):new fs(this.num-e.num,this.unit)}multiply(e){return this.unit!==\"\"&&e.unit!==\"\"&&this.unit!==e.unit?new fs(NaN,\"\"):new fs(this.num*e.num,this.unit||e.unit)}divide(e){return this.unit!==\"\"&&e.unit!==\"\"&&this.unit!==e.unit?new fs(NaN,\"\"):new fs(this.num/e.num,this.unit||e.unit)}toString(){return\"\".concat(this.num).concat(this.unit)}isNaN(){return Zc(this.num)}}Ere=fs;sUe(fs,\"NaN\",new Ere(NaN,\"\"));function Dre(t){if(t==null||t.includes(_x))return _x;for(var e=t;e.includes(\"*\")||e.includes(\"/\");){var n,[,r,i,s]=(n=E9.exec(e))!==null&&n!==void 0?n:[],o=fs.parse(r??\"\"),l=fs.parse(s??\"\"),c=i===\"*\"?o.multiply(l):o.divide(l);if(c.isNaN())return _x;e=e.replace(E9,c.toString())}for(;e.includes(\"+\")||/.-\\d+(?:\\.\\d+)?/.test(e);){var d,[,u,m,p]=(d=D9.exec(e))!==null&&d!==void 0?d:[],f=fs.parse(u??\"\"),y=fs.parse(p??\"\"),v=m===\"+\"?f.add(y):f.subtract(y);if(v.isNaN())return _x;e=e.replace(D9,v.toString())}return e}var F9=/\\(([^()]*)\\)/;function fUe(t){for(var e=t,n;(n=F9.exec(e))!=null;){var[,r]=n;e=e.replace(F9,Dre(r))}return e}function gUe(t){var e=t.replace(/\\s+/g,\"\");return e=fUe(e),e=Dre(e),e}function bUe(t){try{return gUe(t)}catch{return _x}}function fD(t){var e=bUe(t.slice(5,-1));return e===_x?\"\":e}var xUe=[\"x\",\"y\",\"lineHeight\",\"capHeight\",\"fill\",\"scaleToFit\",\"textAnchor\",\"verticalAnchor\"],yUe=[\"dx\",\"dy\",\"angle\",\"className\",\"breakAll\"];function lL(){return lL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},lL.apply(null,arguments)}function R9(t,e){if(t==null)return{};var n,r,i=vUe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function vUe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var Fre=/[ \\f\\n\\r\\t\\v\\u2028\\u2029]+/,Rre=t=>{var{children:e,breakAll:n,style:r}=t;try{var i=[];Ea(e)||(n?i=e.toString().split(\"\"):i=e.toString().split(Fre));var s=i.map(l=>({word:l,width:R0(l,r).width})),o=n?0:R0(\" \",r).width;return{wordsWithComputedWidth:s,spaceWidth:o}}catch{return null}};function wUe(t){return t===\"start\"||t===\"middle\"||t===\"end\"||t===\"inherit\"}var Lre=(t,e,n,r)=>t.reduce((i,s)=>{var{word:o,width:l}=s,c=i[i.length-1];if(c&&l!=null&&(e==null||r||c.width+l+n<Number(e)))c.words.push(o),c.width+=l+n;else{var d={words:[o],width:l};i.push(d)}return i},[]),Ore=t=>t.reduce((e,n)=>e.width>n.width?e:n),SUe=\"…\",L9=(t,e,n,r,i,s,o,l)=>{var c=t.slice(0,e),d=Rre({breakAll:n,style:r,children:c+SUe});if(!d)return[!1,[]];var u=Lre(d.wordsWithComputedWidth,s,o,l),m=u.length>i||Ore(u).width>Number(s);return[m,u]},_Ue=(t,e,n,r,i)=>{var{maxLines:s,children:o,style:l,breakAll:c}=t,d=tn(s),u=String(o),m=Lre(e,r,n,i);if(!d||i)return m;var p=m.length>s||Ore(m).width>Number(r);if(!p)return m;for(var f=0,y=u.length-1,v=0,b;f<=y&&v<=u.length-1;){var g=Math.floor((f+y)/2),_=g-1,[C,P]=L9(u,_,c,l,s,r,n,i),[N]=L9(u,g,c,l,s,r,n,i);if(!C&&!N&&(f=g+1),C&&N&&(y=g-1),!C&&N){b=P;break}v++}return b||m},O9=t=>{var e=Ea(t)?[]:t.toString().split(Fre);return[{words:e,width:void 0}]},kUe=t=>{var{width:e,scaleToFit:n,children:r,style:i,breakAll:s,maxLines:o}=t;if((e||n)&&!vS.isSsr){var l,c,d=Rre({breakAll:s,children:r,style:i});if(d){var{wordsWithComputedWidth:u,spaceWidth:m}=d;l=u,c=m}else return O9(r);return _Ue({breakAll:s,children:r,maxLines:o,style:i},l,c,e,!!n)}return O9(r)},Ire=\"#808080\",NUe={angle:0,breakAll:!1,capHeight:\"0.71em\",fill:Ire,lineHeight:\"1em\",scaleToFit:!1,textAnchor:\"start\",verticalAnchor:\"end\",x:0,y:0},VT=w.forwardRef((t,e)=>{var n=Ia(t,NUe),{x:r,y:i,lineHeight:s,capHeight:o,fill:l,scaleToFit:c,textAnchor:d,verticalAnchor:u}=n,m=R9(n,xUe),p=w.useMemo(()=>kUe({breakAll:m.breakAll,children:m.children,maxLines:m.maxLines,scaleToFit:c,style:m.style,width:m.width}),[m.breakAll,m.children,m.maxLines,c,m.style,m.width]),{dx:f,dy:y,angle:v,className:b,breakAll:g}=m,_=R9(m,yUe);if(!hc(r)||!hc(i)||p.length===0)return null;var C=Number(r)+(tn(f)?f:0),P=Number(i)+(tn(y)?y:0);if(!Gn(C)||!Gn(P))return null;var N;switch(u){case\"start\":N=fD(\"calc(\".concat(o,\")\"));break;case\"middle\":N=fD(\"calc(\".concat((p.length-1)/2,\" * -\").concat(s,\" + (\").concat(o,\" / 2))\"));break;default:N=fD(\"calc(\".concat(p.length-1,\" * -\").concat(s,\")\"));break}var A=[],T=p[0];if(c&&T!=null){var F=T.width,{width:k}=m;A.push(\"scale(\".concat(tn(k)&&tn(F)?k/F:1,\")\"))}return v&&A.push(\"rotate(\".concat(v,\", \").concat(C,\", \").concat(P,\")\")),A.length&&(_.transform=A.join(\" \")),w.createElement(\"text\",lL({},Cs(_),{ref:e,x:C,y:P,className:_r(\"recharts-text\",b),textAnchor:d,fill:l.includes(\"url\")?Ire:l}),p.map((D,H)=>{var z=D.words.join(g?\"\":\" \");return w.createElement(\"tspan\",{x:C,dy:H===0?N:s,key:\"\".concat(z,\"-\").concat(H)},z)}))});VT.displayName=\"Text\";function I9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function yd(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?I9(Object(n),!0).forEach(function(r){CUe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):I9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function CUe(t,e,n){return(e=PUe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function PUe(t){var e=TUe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function TUe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var AUe=t=>{var{viewBox:e,position:n,offset:r=0,parentViewBox:i}=t,{x:s,y:o,height:l,upperWidth:c,lowerWidth:d}=BI(e),u=s,m=s+(c-d)/2,p=(u+m)/2,f=(c+d)/2,y=u+c/2,v=l>=0?1:-1,b=v*r,g=v>0?\"end\":\"start\",_=v>0?\"start\":\"end\",C=c>=0?1:-1,P=C*r,N=C>0?\"end\":\"start\",A=C>0?\"start\":\"end\",T=i;if(n===\"top\"){var F={x:u+c/2,y:o-b,horizontalAnchor:\"middle\",verticalAnchor:g};return T&&(F.height=Math.max(o-T.y,0),F.width=c),F}if(n===\"bottom\"){var k={x:m+d/2,y:o+l+b,horizontalAnchor:\"middle\",verticalAnchor:_};return T&&(k.height=Math.max(T.y+T.height-(o+l),0),k.width=d),k}if(n===\"left\"){var D={x:p-P,y:o+l/2,horizontalAnchor:N,verticalAnchor:\"middle\"};return T&&(D.width=Math.max(D.x-T.x,0),D.height=l),D}if(n===\"right\"){var H={x:p+f+P,y:o+l/2,horizontalAnchor:A,verticalAnchor:\"middle\"};return T&&(H.width=Math.max(T.x+T.width-H.x,0),H.height=l),H}var z=T?{width:f,height:l}:{};return n===\"insideLeft\"?yd({x:p+P,y:o+l/2,horizontalAnchor:A,verticalAnchor:\"middle\"},z):n===\"insideRight\"?yd({x:p+f-P,y:o+l/2,horizontalAnchor:N,verticalAnchor:\"middle\"},z):n===\"insideTop\"?yd({x:u+c/2,y:o+b,horizontalAnchor:\"middle\",verticalAnchor:_},z):n===\"insideBottom\"?yd({x:m+d/2,y:o+l-b,horizontalAnchor:\"middle\",verticalAnchor:g},z):n===\"insideTopLeft\"?yd({x:u+P,y:o+b,horizontalAnchor:A,verticalAnchor:_},z):n===\"insideTopRight\"?yd({x:u+c-P,y:o+b,horizontalAnchor:N,verticalAnchor:_},z):n===\"insideBottomLeft\"?yd({x:m+P,y:o+l-b,horizontalAnchor:A,verticalAnchor:g},z):n===\"insideBottomRight\"?yd({x:m+d-P,y:o+l-b,horizontalAnchor:N,verticalAnchor:g},z):n&&typeof n==\"object\"&&(tn(n.x)||_g(n.x))&&(tn(n.y)||_g(n.y))?yd({x:s+Hs(n.x,f),y:o+Hs(n.y,l),horizontalAnchor:\"end\",verticalAnchor:\"end\"},z):yd({x:y,y:o+l/2,horizontalAnchor:\"middle\",verticalAnchor:\"middle\"},z)},jUe=[\"labelRef\"],MUe=[\"content\"];function z9(t,e){if(t==null)return{};var n,r,i=EUe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function EUe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function U9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function b0(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?U9(Object(n),!0).forEach(function(r){DUe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):U9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function DUe(t,e,n){return(e=FUe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function FUe(t){var e=RUe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function RUe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function Xu(){return Xu=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},Xu.apply(null,arguments)}var zre=w.createContext(null),Ure=t=>{var{x:e,y:n,upperWidth:r,lowerWidth:i,width:s,height:o,children:l}=t,c=w.useMemo(()=>({x:e,y:n,upperWidth:r,lowerWidth:i,width:s,height:o}),[e,n,r,i,s,o]);return w.createElement(zre.Provider,{value:c},l)},Bre=()=>{var t=w.useContext(zre),e=bS();return t||(e?BI(e):void 0)},LUe=w.createContext(null),OUe=()=>{var t=w.useContext(LUe),e=sn(Tne);return t||e},IUe=t=>{var{value:e,formatter:n}=t,r=Ea(t.children)?e:t.children;return typeof n==\"function\"?n(r):r},nz=t=>t!=null&&typeof t==\"function\",zUe=(t,e)=>{var n=Qi(e-t),r=Math.min(Math.abs(e-t),360);return n*r},UUe=(t,e,n,r,i)=>{var{offset:s,className:o}=t,{cx:l,cy:c,innerRadius:d,outerRadius:u,startAngle:m,endAngle:p,clockWise:f}=i,y=(d+u)/2,v=zUe(m,p),b=v>=0?1:-1,g,_;switch(e){case\"insideStart\":g=m+b*s,_=f;break;case\"insideEnd\":g=p-b*s,_=!f;break;case\"end\":g=p+b*s,_=f;break;default:throw new Error(\"Unsupported position \".concat(e))}_=v<=0?_:!_;var C=wi(l,c,y,g),P=wi(l,c,y,g+(_?1:-1)*359),N=\"M\".concat(C.x,\",\").concat(C.y,`\n    A`).concat(y,\",\").concat(y,\",0,1,\").concat(_?0:1,`,\n    `).concat(P.x,\",\").concat(P.y),A=Ea(t.id)?xw(\"recharts-radial-line-\"):t.id;return w.createElement(\"text\",Xu({},r,{dominantBaseline:\"central\",className:_r(\"recharts-radial-bar-label\",o)}),w.createElement(\"defs\",null,w.createElement(\"path\",{id:A,d:N})),w.createElement(\"textPath\",{xlinkHref:\"#\".concat(A)},n))},BUe=(t,e,n)=>{var{cx:r,cy:i,innerRadius:s,outerRadius:o,startAngle:l,endAngle:c}=t,d=(l+c)/2;if(n===\"outside\"){var{x:u,y:m}=wi(r,i,o+e,d);return{x:u,y:m,textAnchor:u>=r?\"start\":\"end\",verticalAnchor:\"middle\"}}if(n===\"center\")return{x:r,y:i,textAnchor:\"middle\",verticalAnchor:\"middle\"};if(n===\"centerTop\")return{x:r,y:i,textAnchor:\"middle\",verticalAnchor:\"start\"};if(n===\"centerBottom\")return{x:r,y:i,textAnchor:\"middle\",verticalAnchor:\"end\"};var p=(s+o)/2,{x:f,y}=wi(r,i,p,d);return{x:f,y,textAnchor:\"middle\",verticalAnchor:\"middle\"}},Jk=t=>t!=null&&\"cx\"in t&&tn(t.cx),HUe={angle:0,offset:5,zIndex:Ja.label,position:\"middle\",textBreakAll:!1};function qUe(t){if(!Jk(t))return t;var{cx:e,cy:n,outerRadius:r}=t,i=r*2;return{x:e-r,y:n-r,width:i,upperWidth:i,lowerWidth:i,height:i}}function jh(t){var e=Ia(t,HUe),{viewBox:n,parentViewBox:r,position:i,value:s,children:o,content:l,className:c=\"\",textBreakAll:d,labelRef:u}=e,m=OUe(),p=Bre(),f=i===\"center\"?p:m??p,y,v,b;n==null?y=f:Jk(n)?y=n:y=BI(n);var g=qUe(y);if(!y||Ea(s)&&Ea(o)&&!w.isValidElement(l)&&typeof l!=\"function\")return null;var _=b0(b0({},e),{},{viewBox:y});if(w.isValidElement(l)){var{labelRef:C}=_,P=z9(_,jUe);return w.cloneElement(l,P)}if(typeof l==\"function\"){var{content:N}=_,A=z9(_,MUe);if(v=w.createElement(l,A),w.isValidElement(v))return v}else v=IUe(e);var T=Cs(e);if(Jk(y)){if(i===\"insideStart\"||i===\"insideEnd\"||i===\"end\")return UUe(e,i,v,T,y);b=BUe(y,e.offset,e.position)}else{if(!g)return null;var F=AUe({viewBox:g,position:i,offset:e.offset,parentViewBox:Jk(r)?void 0:r});b=b0(b0({x:F.x,y:F.y,textAnchor:F.horizontalAnchor,verticalAnchor:F.verticalAnchor},F.width!==void 0?{width:F.width}:{}),F.height!==void 0?{height:F.height}:{})}return w.createElement(Gs,{zIndex:e.zIndex},w.createElement(VT,Xu({ref:u,className:_r(\"recharts-label\",c)},T,b,{textAnchor:wUe(T.textAnchor)?T.textAnchor:b.textAnchor,breakAll:d}),v))}jh.displayName=\"Label\";var $Ue=(t,e,n)=>{if(!t)return null;var r={viewBox:e,labelRef:n};return t===!0?w.createElement(jh,Xu({key:\"label-implicit\"},r)):hc(t)?w.createElement(jh,Xu({key:\"label-implicit\",value:t},r)):w.isValidElement(t)?t.type===jh?w.cloneElement(t,b0({key:\"label-implicit\"},r)):w.createElement(jh,Xu({key:\"label-implicit\",content:t},r)):nz(t)?w.createElement(jh,Xu({key:\"label-implicit\",content:t},r)):t&&typeof t==\"object\"?w.createElement(jh,Xu({},t,{key:\"label-implicit\"},r)):null};function Hre(t){var{label:e,labelRef:n}=t,r=Bre();return $Ue(e,r,n)||null}var gD={},bD={},B9;function VUe(){return B9||(B9=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return n[n.length-1]}t.last=e})(bD)),bD}var xD={},H9;function GUe(){return H9||(H9=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){return Array.isArray(n)?n:Array.from(n)}t.toArray=e})(xD)),xD}var q9;function WUe(){return q9||(q9=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const e=VUe(),n=GUe(),r=jI();function i(s){if(r.isArrayLike(s))return e.last(n.toArray(s))}t.last=i})(gD)),gD}var yD,$9;function KUe(){return $9||($9=1,yD=WUe().last),yD}var XUe=KUe();const YUe=bc(XUe);var QUe=[\"valueAccessor\"],ZUe=[\"dataKey\",\"clockWise\",\"id\",\"textBreakAll\",\"zIndex\"];function AC(){return AC=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},AC.apply(null,arguments)}function V9(t,e){if(t==null)return{};var n,r,i=JUe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function JUe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var eBe=t=>Array.isArray(t.value)?YUe(t.value):t.value,qre=w.createContext(void 0),rz=qre.Provider,$re=w.createContext(void 0),tBe=$re.Provider;function nBe(){return w.useContext(qre)}function rBe(){return w.useContext($re)}function eN(t){var{valueAccessor:e=eBe}=t,n=V9(t,QUe),{dataKey:r,clockWise:i,id:s,textBreakAll:o,zIndex:l}=n,c=V9(n,ZUe),d=nBe(),u=rBe(),m=d||u;return!m||!m.length?null:w.createElement(Gs,{zIndex:l??Ja.label},w.createElement(Oa,{className:\"recharts-label-list\"},m.map((p,f)=>{var y,v=Ea(r)?e(p,f):zr(p.payload,r),b=Ea(s)?{}:{id:\"\".concat(s,\"-\").concat(f)};return w.createElement(jh,AC({key:\"label-\".concat(f)},Cs(p),c,b,{fill:(y=n.fill)!==null&&y!==void 0?y:p.fill,parentViewBox:p.parentViewBox,value:v,textBreakAll:o,viewBox:p.viewBox,index:f,zIndex:0}))})))}eN.displayName=\"LabelList\";function GT(t){var{label:e}=t;return e?e===!0?w.createElement(eN,{key:\"labelList-implicit\"}):w.isValidElement(e)||nz(e)?w.createElement(eN,{key:\"labelList-implicit\",content:e}):typeof e==\"object\"?w.createElement(eN,AC({key:\"labelList-implicit\"},e,{type:String(e.type)})):null:null}function cL(){return cL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},cL.apply(null,arguments)}var Vre=t=>{var{cx:e,cy:n,r,className:i}=t,s=_r(\"recharts-dot\",i);return tn(e)&&tn(n)&&tn(r)?w.createElement(\"circle\",cL({},mo(t),AI(t),{className:s,cx:e,cy:n,r})):null},Gre=t=>t.graphicalItems.polarItems,aBe=wt([za,CS],C4),WT=wt([Gre,ri,aBe],P4),iBe=wt([WT],T4),KT=wt([iBe,ET],A4),sBe=wt([KT,ri,WT],M4);wt([KT,ri,WT],(t,e,n)=>n.length>0?t.flatMap(r=>n.flatMap(i=>{var s,o=zr(r,(s=e.dataKey)!==null&&s!==void 0?s:i.dataKey);return{value:o,errorDomain:[]}})).filter(Boolean):e?.dataKey!=null?t.map(r=>({value:zr(r,e.dataKey),errorDomain:[]})):t.map(r=>({value:r,errorDomain:[]})));var G9=()=>{},oBe=wt([KT,ri,WT,BT,za],R4),lBe=wt([ri,D4,F4,G9,oBe,G9,Sr,za],L4),Wre=wt([ri,Sr,KT,sBe,NS,za,lBe],O4),cBe=wt([Wre,Fy,Dp],U4),dBe=wt([ri,Wre,cBe,za],H4);wt([Dp,dBe],jne);var uBe={radiusAxis:{},angleAxis:{}},Kre=$o({name:\"polarAxis\",initialState:uBe,reducers:{addRadiusAxis(t,e){t.radiusAxis[e.payload.id]=e.payload},removeRadiusAxis(t,e){delete t.radiusAxis[e.payload.id]},addAngleAxis(t,e){t.angleAxis[e.payload.id]=e.payload},removeAngleAxis(t,e){delete t.angleAxis[e.payload.id]}}}),{addRadiusAxis:Cst,removeRadiusAxis:Pst,addAngleAxis:Tst,removeAngleAxis:Ast}=Kre.actions,mBe=Kre.reducer;function Xre(t){return t&&typeof t==\"object\"&&\"className\"in t&&typeof t.className==\"string\"?t.className:\"\"}function W9(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function K9(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?W9(Object(n),!0).forEach(function(r){hBe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):W9(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function hBe(t,e,n){return(e=pBe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function pBe(t){var e=fBe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function fBe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var gBe=(t,e)=>e,az=wt([Gre,gBe],(t,e)=>t.filter(n=>n.type===\"pie\").find(n=>n.id===e)),bBe=[],iz=(t,e,n)=>n?.length===0?bBe:n,Yre=wt([ET,az,iz],(t,e,n)=>{var{chartData:r}=t;if(e!=null){var i;if(e?.data!=null&&e.data.length>0?i=e.data:i=r,(!i||!i.length)&&n!=null&&(i=n.map(s=>K9(K9({},e.presentationProps),s.props))),i!=null)return i}}),xBe=wt([Yre,az,iz],(t,e,n)=>{if(!(t==null||e==null))return t.map((r,i)=>{var s,o=zr(r,e.nameKey,e.name),l;return n!=null&&(s=n[i])!==null&&s!==void 0&&(s=s.props)!==null&&s!==void 0&&s.fill?l=n[i].props.fill:typeof r==\"object\"&&r!=null&&\"fill\"in r?l=r.fill:l=e.fill,{value:Ap(o,e.dataKey),color:l,payload:r,type:e.legendType}})}),yBe=wt([Yre,az,iz,Ri],(t,e,n,r)=>{if(!(e==null||t==null))return v8e({offset:r,pieSettings:e,displayedData:t,cells:n})}),vD={exports:{}},Yr={};var X9;function vBe(){if(X9)return Yr;X9=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\"),s=Symbol.for(\"react.consumer\"),o=Symbol.for(\"react.context\"),l=Symbol.for(\"react.forward_ref\"),c=Symbol.for(\"react.suspense\"),d=Symbol.for(\"react.suspense_list\"),u=Symbol.for(\"react.memo\"),m=Symbol.for(\"react.lazy\"),p=Symbol.for(\"react.view_transition\"),f=Symbol.for(\"react.client.reference\");function y(v){if(typeof v==\"object\"&&v!==null){var b=v.$$typeof;switch(b){case t:switch(v=v.type,v){case n:case i:case r:case c:case d:case p:return v;default:switch(v=v&&v.$$typeof,v){case o:case l:case m:case u:return v;case s:return v;default:return b}}case e:return b}}}return Yr.ContextConsumer=s,Yr.ContextProvider=o,Yr.Element=t,Yr.ForwardRef=l,Yr.Fragment=n,Yr.Lazy=m,Yr.Memo=u,Yr.Portal=e,Yr.Profiler=i,Yr.StrictMode=r,Yr.Suspense=c,Yr.SuspenseList=d,Yr.isContextConsumer=function(v){return y(v)===s},Yr.isContextProvider=function(v){return y(v)===o},Yr.isElement=function(v){return typeof v==\"object\"&&v!==null&&v.$$typeof===t},Yr.isForwardRef=function(v){return y(v)===l},Yr.isFragment=function(v){return y(v)===n},Yr.isLazy=function(v){return y(v)===m},Yr.isMemo=function(v){return y(v)===u},Yr.isPortal=function(v){return y(v)===e},Yr.isProfiler=function(v){return y(v)===i},Yr.isStrictMode=function(v){return y(v)===r},Yr.isSuspense=function(v){return y(v)===c},Yr.isSuspenseList=function(v){return y(v)===d},Yr.isValidElementType=function(v){return typeof v==\"string\"||typeof v==\"function\"||v===n||v===i||v===r||v===c||v===d||typeof v==\"object\"&&v!==null&&(v.$$typeof===m||v.$$typeof===u||v.$$typeof===o||v.$$typeof===s||v.$$typeof===l||v.$$typeof===f||v.getModuleId!==void 0)},Yr.typeOf=y,Yr}var Y9;function wBe(){return Y9||(Y9=1,vD.exports=vBe()),vD.exports}var SBe=wBe(),Q9=t=>typeof t==\"string\"?t:t?t.displayName||t.name||\"Component\":\"\",Z9=null,wD=null,Qre=t=>{if(t===Z9&&Array.isArray(wD))return wD;var e=[];return w.Children.forEach(t,n=>{Ea(n)||(SBe.isFragment(n)?e=e.concat(Qre(n.props.children)):e.push(n))}),wD=e,Z9=t,e};function sz(t,e){var n=[],r=[];return Array.isArray(e)?r=e.map(i=>Q9(i)):r=[Q9(e)],Qre(t).forEach(i=>{var s=Sg(i,\"type.displayName\")||Sg(i,\"type.name\");s&&r.indexOf(s)!==-1&&n.push(i)}),n}var oz=t=>t&&typeof t==\"object\"&&\"clipDot\"in t?!!t.clipDot:!0,SD={},J9;function _Be(){return J9||(J9=1,(function(t){Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});function e(n){if(typeof n!=\"object\"||n==null)return!1;if(Object.getPrototypeOf(n)===null)return!0;if(Object.prototype.toString.call(n)!==\"[object Object]\"){const i=n[Symbol.toStringTag];return i==null||!Object.getOwnPropertyDescriptor(n,Symbol.toStringTag)?.writable?!1:n.toString()===`[object ${i}]`}let r=n;for(;Object.getPrototypeOf(r)!==null;)r=Object.getPrototypeOf(r);return Object.getPrototypeOf(n)===r}t.isPlainObject=e})(SD)),SD}var _D,eW;function kBe(){return eW||(eW=1,_D=_Be().isPlainObject),_D}var NBe=kBe();const CBe=bc(NBe);var tW,nW,rW,aW,iW;function sW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function oW(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?sW(Object(n),!0).forEach(function(r){PBe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):sW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function PBe(t,e,n){return(e=TBe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function TBe(t){var e=ABe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function ABe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function jC(){return jC=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},jC.apply(null,arguments)}function e0(t,e){return e||(e=t.slice(0)),Object.freeze(Object.defineProperties(t,{raw:{value:Object.freeze(e)}}))}var lW=(t,e,n,r,i)=>{var s=n-r,o;return o=Ka(tW||(tW=e0([\"M \",\",\",\"\"])),t,e),o+=Ka(nW||(nW=e0([\"L \",\",\",\"\"])),t+n,e),o+=Ka(rW||(rW=e0([\"L \",\",\",\"\"])),t+n-s/2,e+i),o+=Ka(aW||(aW=e0([\"L \",\",\",\"\"])),t+n-s/2-r,e+i),o+=Ka(iW||(iW=e0([\"L \",\",\",\" Z\"])),t,e),o},jBe={x:0,y:0,upperWidth:0,lowerWidth:0,height:0,isUpdateAnimationActive:!1,animationBegin:0,animationDuration:1500,animationEasing:\"ease\"},MBe=t=>{var e=Ia(t,jBe),{x:n,y:r,upperWidth:i,lowerWidth:s,height:o,className:l}=e,{animationEasing:c,animationDuration:d,animationBegin:u,isUpdateAnimationActive:m}=e,p=w.useRef(null),[f,y]=w.useState(-1),v=w.useRef(i),b=w.useRef(s),g=w.useRef(o),_=w.useRef(n),C=w.useRef(r),P=Ay(t,\"trapezoid-\");if(w.useEffect(()=>{if(p.current&&p.current.getTotalLength)try{var L=p.current.getTotalLength();L&&y(L)}catch{}},[]),n!==+n||r!==+r||i!==+i||s!==+s||o!==+o||i===0&&s===0||o===0)return null;var N=_r(\"recharts-trapezoid\",l);if(!m)return w.createElement(\"g\",null,w.createElement(\"path\",jC({},Cs(e),{className:N,d:lW(n,r,i,s,o)})));var A=v.current,T=b.current,F=g.current,k=_.current,D=C.current,H=\"0px \".concat(f===-1?1:f,\"px\"),z=\"\".concat(f,\"px 0px\"),Q=yte([\"strokeDasharray\"],d,c);return w.createElement(Ty,{animationId:P,key:P,canBegin:f>0,duration:d,easing:c,isActive:m,begin:u},L=>{var te=Ir(A,i,L),ie=Ir(T,s,L),J=Ir(F,o,L),oe=Ir(k,n,L),fe=Ir(D,r,L);p.current&&(v.current=te,b.current=ie,g.current=J,_.current=oe,C.current=fe);var re=L>0?{transition:Q,strokeDasharray:z}:{strokeDasharray:H};return w.createElement(\"path\",jC({},Cs(e),{className:N,d:lW(oe,fe,te,ie,J),ref:p,style:oW(oW({},re),e.style)}))})},EBe=[\"option\",\"shapeType\",\"activeClassName\"];function DBe(t,e){if(t==null)return{};var n,r,i=FBe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function FBe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function cW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function MC(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?cW(Object(n),!0).forEach(function(r){RBe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):cW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function RBe(t,e,n){return(e=LBe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function LBe(t){var e=OBe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function OBe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function IBe(t,e){return MC(MC({},e),t)}function zBe(t,e){return t===\"symbols\"}function dW(t){var{shapeType:e,elementProps:n}=t;switch(e){case\"rectangle\":return w.createElement(_te,n);case\"trapezoid\":return w.createElement(MBe,n);case\"sector\":return w.createElement(Pte,n);case\"symbols\":if(zBe(e))return w.createElement(TI,n);break;case\"curve\":return w.createElement(Rx,n);default:return null}}function UBe(t){return w.isValidElement(t)?t.props:t}function lz(t){var{option:e,shapeType:n,activeClassName:r=\"recharts-active-shape\"}=t,i=DBe(t,EBe),s;if(w.isValidElement(e))s=w.cloneElement(e,MC(MC({},i),UBe(e)));else if(typeof e==\"function\")s=e(i,i.index);else if(CBe(e)&&typeof e!=\"boolean\"){var o=IBe(e,i);s=w.createElement(dW,{shapeType:n,elementProps:o})}else{var l=i;s=w.createElement(dW,{shapeType:n,elementProps:l})}return i.isActive?w.createElement(Oa,{className:r},s):s}var cz=(t,e,n)=>{var r=ua();return(i,s)=>o=>{t?.(i,s,o),r(sre({activeIndex:String(s),activeDataKey:e,activeCoordinate:i.tooltipPosition,activeGraphicalItemId:n}))}},dz=t=>{var e=ua();return(n,r)=>i=>{t?.(n,r,i),e(V4e())}},uz=(t,e,n)=>{var r=ua();return(i,s)=>o=>{t?.(i,s,o),r(G4e({activeIndex:String(s),activeDataKey:e,activeCoordinate:i.tooltipPosition,activeGraphicalItemId:n}))}};function XT(t){var{tooltipEntrySettings:e}=t,n=ua(),r=Li(),i=w.useRef(null);return w.useLayoutEffect(()=>{r||(i.current===null?n(B4e(e)):i.current!==e&&n(H4e({prev:i.current,next:e})),i.current=e)},[e,n,r]),w.useLayoutEffect(()=>()=>{i.current&&(n(q4e(i.current)),i.current=null)},[n]),null}function mz(t){var{legendPayload:e}=t,n=ua(),r=Li(),i=w.useRef(null);return w.useLayoutEffect(()=>{r||(i.current===null?n(pte(e)):i.current!==e&&n(fte({prev:i.current,next:e})),i.current=e)},[n,r,e]),w.useLayoutEffect(()=>()=>{i.current&&(n(gte(i.current)),i.current=null)},[n]),null}function BBe(t){var{legendPayload:e}=t,n=ua(),r=sn(Sr),i=w.useRef(null);return w.useLayoutEffect(()=>{r!==\"centric\"&&r!==\"radial\"||(i.current===null?n(pte(e)):i.current!==e&&n(fte({prev:i.current,next:e})),i.current=e)},[n,r,e]),w.useLayoutEffect(()=>()=>{i.current&&(n(gte(i.current)),i.current=null)},[n]),null}var kD,HBe=()=>{var[t]=w.useState(()=>xw(\"uid-\"));return t},qBe=(kD=Ide.useId)!==null&&kD!==void 0?kD:HBe;function $Be(t,e){var n=qBe();return e||(t?\"\".concat(t,\"-\").concat(n):n)}var VBe=w.createContext(void 0),YT=t=>{var{id:e,type:n,children:r}=t,i=$Be(\"recharts-\".concat(n),e);return w.createElement(VBe.Provider,{value:i},r(i))},GBe={cartesianItems:[],polarItems:[]},Zre=$o({name:\"graphicalItems\",initialState:GBe,reducers:{addCartesianGraphicalItem:{reducer(t,e){t.cartesianItems.push(e.payload)},prepare:Ta()},replaceCartesianGraphicalItem:{reducer(t,e){var{prev:n,next:r}=e.payload,i=Xc(t).cartesianItems.indexOf(n);i>-1&&(t.cartesianItems[i]=r)},prepare:Ta()},removeCartesianGraphicalItem:{reducer(t,e){var n=Xc(t).cartesianItems.indexOf(e.payload);n>-1&&t.cartesianItems.splice(n,1)},prepare:Ta()},addPolarGraphicalItem:{reducer(t,e){t.polarItems.push(e.payload)},prepare:Ta()},removePolarGraphicalItem:{reducer(t,e){var n=Xc(t).polarItems.indexOf(e.payload);n>-1&&t.polarItems.splice(n,1)},prepare:Ta()}}}),{addCartesianGraphicalItem:WBe,replaceCartesianGraphicalItem:KBe,removeCartesianGraphicalItem:XBe,addPolarGraphicalItem:YBe,removePolarGraphicalItem:QBe}=Zre.actions,ZBe=Zre.reducer,JBe=t=>{var e=ua(),n=w.useRef(null);return w.useLayoutEffect(()=>{n.current===null?e(WBe(t)):n.current!==t&&e(KBe({prev:n.current,next:t})),n.current=t},[e,t]),w.useLayoutEffect(()=>()=>{n.current&&(e(XBe(n.current)),n.current=null)},[e]),null},hz=w.memo(JBe);function e8e(t){var e=ua();return w.useLayoutEffect(()=>(e(YBe(t)),()=>{e(QBe(t))}),[e,t]),null}var t8e=[\"key\"],n8e=[\"onMouseEnter\",\"onClick\",\"onMouseLeave\"],r8e=[\"id\"],a8e=[\"id\"];function uW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function Xa(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?uW(Object(n),!0).forEach(function(r){i8e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):uW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function i8e(t,e,n){return(e=s8e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function s8e(t){var e=o8e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function o8e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function _p(){return _p=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},_p.apply(null,arguments)}function QT(t,e){if(t==null)return{};var n,r,i=l8e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function l8e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function c8e(t){var e=w.useMemo(()=>sz(t.children,oy),[t.children]),n=sn(r=>xBe(r,t.id,e));return n==null?null:w.createElement(BBe,{legendPayload:n})}var d8e=w.memo(t=>{var{dataKey:e,nameKey:n,sectors:r,stroke:i,strokeWidth:s,fill:o,name:l,hide:c,tooltipType:d,id:u}=t,m={dataDefinedOnItem:r.map(p=>p.tooltipPayload),getPosition:p=>{var f;return(f=r[Number(p)])===null||f===void 0?void 0:f.tooltipPosition},settings:{stroke:i,strokeWidth:s,fill:o,dataKey:e,nameKey:n,name:Ap(l,e),hide:c,type:d,color:o,unit:\"\",graphicalItemId:u}};return w.createElement(XT,{tooltipEntrySettings:m})}),u8e=(t,e)=>t>e?\"start\":t<e?\"end\":\"middle\",m8e=(t,e,n)=>Hs(typeof e==\"function\"?e(t):e,n,n*.8),h8e=(t,e,n)=>{var{top:r,left:i,width:s,height:o}=e,l=kte(s,o),c=i+Hs(t.cx,s,s/2),d=r+Hs(t.cy,o,o/2),u=Hs(t.innerRadius,l,0),m=m8e(n,t.outerRadius,l),p=t.maxRadius||Math.sqrt(s*s+o*o)/2;return{cx:c,cy:d,innerRadius:u,outerRadius:m,maxRadius:p}},p8e=(t,e)=>{var n=Qi(e-t),r=Math.min(Math.abs(e-t),360);return n*r},f8e=(t,e)=>{if(w.isValidElement(t))return w.cloneElement(t,e);if(typeof t==\"function\")return t(e);var n=_r(\"recharts-pie-label-line\",typeof t!=\"boolean\"?t.className:\"\"),{key:r}=e,i=QT(e,t8e);return w.createElement(Rx,_p({},i,{type:\"linear\",className:n}))},g8e=(t,e,n)=>{if(w.isValidElement(t))return w.cloneElement(t,e);var r=n;if(typeof t==\"function\"&&(r=t(e),w.isValidElement(r)))return r;var i=_r(\"recharts-pie-label-text\",Xre(t));return w.createElement(VT,_p({},e,{alignmentBaseline:\"middle\",className:i}),r)};function b8e(t){var{sectors:e,props:n,showLabels:r}=t,{label:i,labelLine:s,dataKey:o}=n;if(!r||!i||!e)return null;var l=mo(n),c=vg(i),d=vg(s),u=typeof i==\"object\"&&\"offsetRadius\"in i&&typeof i.offsetRadius==\"number\"&&i.offsetRadius||20,m=e.map((p,f)=>{var y=(p.startAngle+p.endAngle)/2,v=wi(p.cx,p.cy,p.outerRadius+u,y),b=Xa(Xa(Xa(Xa({},l),p),{},{stroke:\"none\"},c),{},{index:f,textAnchor:u8e(v.x,p.cx)},v),g=Xa(Xa(Xa(Xa({},l),p),{},{fill:\"none\",stroke:p.fill},d),{},{index:f,points:[wi(p.cx,p.cy,p.outerRadius,y),v],key:\"line\"});return w.createElement(Gs,{zIndex:Ja.label,key:\"label-\".concat(p.startAngle,\"-\").concat(p.endAngle,\"-\").concat(p.midAngle,\"-\").concat(f)},w.createElement(Oa,null,s&&f8e(s,g),g8e(i,b,zr(p,o))))});return w.createElement(Oa,{className:\"recharts-pie-labels\"},m)}function x8e(t){var{sectors:e,props:n,showLabels:r}=t,{label:i}=n;return typeof i==\"object\"&&i!=null&&\"position\"in i?w.createElement(GT,{label:i}):w.createElement(b8e,{sectors:e,props:n,showLabels:r})}function y8e(t){var{sectors:e,activeShape:n,inactiveShape:r,allOtherPieProps:i,shape:s,id:o}=t,l=sn(Sp),c=sn(Q4),d=sn(Mze),{onMouseEnter:u,onClick:m,onMouseLeave:p}=i,f=QT(i,n8e),y=cz(u,i.dataKey,o),v=dz(p),b=uz(m,i.dataKey,o);return e==null||e.length===0?null:w.createElement(w.Fragment,null,e.map((g,_)=>{if(g?.startAngle===0&&g?.endAngle===0&&e.length!==1)return null;var C=d==null||d===o,P=String(_)===l&&(c==null||i.dataKey===c)&&C,N=l?r:null,A=n&&P?n:N,T=Xa(Xa({},g),{},{stroke:g.stroke,tabIndex:-1,[ete]:_,[tte]:o});return w.createElement(Oa,_p({key:\"sector-\".concat(g?.startAngle,\"-\").concat(g?.endAngle,\"-\").concat(g.midAngle,\"-\").concat(_),tabIndex:-1,className:\"recharts-pie-sector\"},hS(f,g,_),{onMouseEnter:y(g,_),onMouseLeave:v(g,_),onClick:b(g,_)}),w.createElement(lz,_p({option:s??A,index:_,shapeType:\"sector\",isActive:P},T)))}))}function v8e(t){var e,{pieSettings:n,displayedData:r,cells:i,offset:s}=t,{cornerRadius:o,startAngle:l,endAngle:c,dataKey:d,nameKey:u,tooltipType:m}=n,p=Math.abs(n.minAngle),f=p8e(l,c),y=Math.abs(f),v=r.length<=1?0:(e=n.paddingAngle)!==null&&e!==void 0?e:0,b=r.filter(A=>zr(A,d,0)!==0).length,g=(y>=360?b:b-1)*v,_=y-b*p-g,C=r.reduce((A,T)=>{var F=zr(T,d,0);return A+(tn(F)?F:0)},0),P;if(C>0){var N;P=r.map((A,T)=>{var F=zr(A,d,0),k=zr(A,u,T),D=h8e(n,s,A),H=(tn(F)?F:0)/C,z,Q=Xa(Xa({},A),i&&i[T]&&i[T].props);T?z=N.endAngle+Qi(f)*v*(F!==0?1:0):z=l;var L=z+Qi(f)*((F!==0?p:0)+H*_),te=(z+L)/2,ie=(D.innerRadius+D.outerRadius)/2,J=[{name:k,value:F,payload:Q,dataKey:d,type:m,graphicalItemId:n.id}],oe=wi(D.cx,D.cy,ie,te);return N=Xa(Xa(Xa(Xa({},n.presentationProps),{},{percent:H,cornerRadius:typeof o==\"string\"?parseFloat(o):o,name:k,tooltipPayload:J,midAngle:te,middleRadius:ie,tooltipPosition:oe},Q),D),{},{value:F,dataKey:d,startAngle:z,endAngle:L,payload:Q,paddingAngle:Qi(f)*v}),N})}return P}function w8e(t){var{showLabels:e,sectors:n,children:r}=t,i=w.useMemo(()=>!e||!n?[]:n.map(s=>({value:s.value,payload:s.payload,clockWise:!1,parentViewBox:void 0,viewBox:{cx:s.cx,cy:s.cy,innerRadius:s.innerRadius,outerRadius:s.outerRadius,startAngle:s.startAngle,endAngle:s.endAngle,clockWise:!1},fill:s.fill})),[n,e]);return w.createElement(tBe,{value:e?i:void 0},r)}function S8e(t){var{props:e,previousSectorsRef:n,id:r}=t,{sectors:i,isAnimationActive:s,animationBegin:o,animationDuration:l,animationEasing:c,activeShape:d,inactiveShape:u,onAnimationStart:m,onAnimationEnd:p}=e,f=Ay(e,\"recharts-pie-\"),y=n.current,[v,b]=w.useState(!1),g=w.useCallback(()=>{typeof p==\"function\"&&p(),b(!1)},[p]),_=w.useCallback(()=>{typeof m==\"function\"&&m(),b(!0)},[m]);return w.createElement(w8e,{showLabels:!v,sectors:i},w.createElement(Ty,{animationId:f,begin:o,duration:l,isActive:s,easing:c,onAnimationStart:_,onAnimationEnd:g,key:f},C=>{var P,N=[],A=i&&i[0],T=(P=A?.startAngle)!==null&&P!==void 0?P:0;return i?.forEach((F,k)=>{var D=y&&y[k],H=k>0?Sg(F,\"paddingAngle\",0):0;if(D){var z=Ir(D.endAngle-D.startAngle,F.endAngle-F.startAngle,C),Q=Xa(Xa({},F),{},{startAngle:T+H,endAngle:T+z+H});N.push(Q),T=Q.endAngle}else{var{endAngle:L,startAngle:te}=F,ie=Ir(0,L-te,C),J=Xa(Xa({},F),{},{startAngle:T+H,endAngle:T+ie+H});N.push(J),T=J.endAngle}}),n.current=N,w.createElement(Oa,null,w.createElement(y8e,{sectors:N,activeShape:d,inactiveShape:u,allOtherPieProps:e,shape:e.shape,id:r}))}),w.createElement(x8e,{showLabels:!v,sectors:i,props:e}),e.children)}var _8e={animationBegin:400,animationDuration:1500,animationEasing:\"ease\",cx:\"50%\",cy:\"50%\",dataKey:\"value\",endAngle:360,fill:\"#808080\",hide:!1,innerRadius:0,isAnimationActive:\"auto\",label:!1,labelLine:!0,legendType:\"rect\",minAngle:0,nameKey:\"name\",outerRadius:\"80%\",paddingAngle:0,rootTabIndex:0,startAngle:0,stroke:\"#fff\",zIndex:Ja.area};function k8e(t){var{id:e}=t,n=QT(t,r8e),{hide:r,className:i,rootTabIndex:s}=t,o=w.useMemo(()=>sz(t.children,oy),[t.children]),l=sn(u=>yBe(u,e,o)),c=w.useRef(null),d=_r(\"recharts-pie\",i);return r||l==null?(c.current=null,w.createElement(Oa,{tabIndex:s,className:d})):w.createElement(Gs,{zIndex:t.zIndex},w.createElement(d8e,{dataKey:t.dataKey,nameKey:t.nameKey,sectors:l,stroke:t.stroke,strokeWidth:t.strokeWidth,fill:t.fill,name:t.name,hide:t.hide,tooltipType:t.tooltipType,id:e}),w.createElement(Oa,{tabIndex:s,className:d},w.createElement(S8e,{props:Xa(Xa({},n),{},{sectors:l}),previousSectorsRef:c,id:e})))}function dL(t){var e=Ia(t,_8e),{id:n}=e,r=QT(e,a8e),i=mo(r);return w.createElement(YT,{id:n,type:\"pie\"},s=>w.createElement(w.Fragment,null,w.createElement(e8e,{type:\"pie\",id:s,data:r.data,dataKey:r.dataKey,hide:r.hide,angleAxisId:0,radiusAxisId:0,name:r.name,nameKey:r.nameKey,tooltipType:r.tooltipType,legendType:r.legendType,fill:r.fill,cx:r.cx,cy:r.cy,startAngle:r.startAngle,endAngle:r.endAngle,paddingAngle:r.paddingAngle,minAngle:r.minAngle,innerRadius:r.innerRadius,outerRadius:r.outerRadius,cornerRadius:r.cornerRadius,presentationProps:i,maxRadius:e.maxRadius}),w.createElement(c8e,_p({},r,{id:s})),w.createElement(k8e,_p({},r,{id:s}))))}dL.displayName=\"Pie\";var N8e=[\"points\"];function mW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function ND(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?mW(Object(n),!0).forEach(function(r){C8e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):mW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function C8e(t,e,n){return(e=P8e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function P8e(t){var e=T8e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function T8e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function EC(){return EC=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},EC.apply(null,arguments)}function A8e(t,e){if(t==null)return{};var n,r,i=j8e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function j8e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function M8e(t){var{option:e,dotProps:n,className:r}=t;if(w.isValidElement(e))return w.cloneElement(e,n);if(typeof e==\"function\")return e(n);var i=_r(r,typeof e!=\"boolean\"?e.className:\"\"),s=n??{},{points:o}=s,l=A8e(s,N8e);return w.createElement(Vre,EC({},l,{className:i}))}function E8e(t,e){return t==null?!1:e?!0:t.length===1}function Jre(t){var{points:e,dot:n,className:r,dotClassName:i,dataKey:s,baseProps:o,needClip:l,clipPathId:c,zIndex:d=Ja.scatter}=t;if(!E8e(e,n))return null;var u=oz(n),m=eEe(n),p=e.map((y,v)=>{var b,g,_=ND(ND(ND({r:3},o),m),{},{index:v,cx:(b=y.x)!==null&&b!==void 0?b:void 0,cy:(g=y.y)!==null&&g!==void 0?g:void 0,dataKey:s,value:y.value,payload:y.payload,points:e});return w.createElement(M8e,{key:\"dot-\".concat(v),option:n,dotProps:_,className:i})}),f={};return l&&c!=null&&(f.clipPath=\"url(#clipPath-\".concat(u?\"\":\"dots-\").concat(c,\")\")),w.createElement(Gs,{zIndex:d},w.createElement(Oa,EC({className:r},f),p))}function hW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function pW(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?hW(Object(n),!0).forEach(function(r){D8e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):hW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function D8e(t,e,n){return(e=F8e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function F8e(t){var e=R8e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function R8e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var eae=0,L8e={xAxis:{},yAxis:{},zAxis:{}},tae=$o({name:\"cartesianAxis\",initialState:L8e,reducers:{addXAxis:{reducer(t,e){t.xAxis[e.payload.id]=e.payload},prepare:Ta()},replaceXAxis:{reducer(t,e){var{prev:n,next:r}=e.payload;t.xAxis[n.id]!==void 0&&(n.id!==r.id&&delete t.xAxis[n.id],t.xAxis[r.id]=r)},prepare:Ta()},removeXAxis:{reducer(t,e){delete t.xAxis[e.payload.id]},prepare:Ta()},addYAxis:{reducer(t,e){t.yAxis[e.payload.id]=e.payload},prepare:Ta()},replaceYAxis:{reducer(t,e){var{prev:n,next:r}=e.payload;t.yAxis[n.id]!==void 0&&(n.id!==r.id&&delete t.yAxis[n.id],t.yAxis[r.id]=r)},prepare:Ta()},removeYAxis:{reducer(t,e){delete t.yAxis[e.payload.id]},prepare:Ta()},addZAxis:{reducer(t,e){t.zAxis[e.payload.id]=e.payload},prepare:Ta()},replaceZAxis:{reducer(t,e){var{prev:n,next:r}=e.payload;t.zAxis[n.id]!==void 0&&(n.id!==r.id&&delete t.zAxis[n.id],t.zAxis[r.id]=r)},prepare:Ta()},removeZAxis:{reducer(t,e){delete t.zAxis[e.payload.id]},prepare:Ta()},updateYAxisWidth(t,e){var{id:n,width:r}=e.payload,i=t.yAxis[n];if(i){var s,o=i.widthHistory||[];if(o.length===3&&o[0]===o[2]&&r===o[1]&&r!==i.width&&Math.abs(r-((s=o[0])!==null&&s!==void 0?s:0))<=1)return;var l=[...o,r].slice(-3);t.yAxis[n]=pW(pW({},i),{},{width:r,widthHistory:l})}}}}),{addXAxis:O8e,replaceXAxis:I8e,removeXAxis:z8e,addYAxis:U8e,replaceYAxis:B8e,removeYAxis:H8e,addZAxis:jst,replaceZAxis:Mst,removeZAxis:Est,updateYAxisWidth:q8e}=tae.actions,$8e=tae.reducer,V8e=wt([Ri],t=>({top:t.top,bottom:t.bottom,left:t.left,right:t.right})),G8e=wt([V8e,jm,Mm],(t,e,n)=>{if(!(!t||e==null||n==null))return{x:t.left,y:t.top,width:Math.max(0,e-t.left-t.right),height:Math.max(0,n-t.top-t.bottom)}}),ZT=()=>sn(G8e),W8e=()=>sn(Lze);function fW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function CD(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?fW(Object(n),!0).forEach(function(r){K8e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):fW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function K8e(t,e,n){return(e=X8e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function X8e(t){var e=Y8e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function Y8e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var Q8e=t=>{var{point:e,childIndex:n,mainColor:r,activeDot:i,dataKey:s,clipPath:o}=t;if(i===!1||e.x==null||e.y==null)return null;var l={index:n,dataKey:s,cx:e.x,cy:e.y,r:4,fill:r??\"none\",strokeWidth:2,stroke:\"#fff\",payload:e.payload,value:e.value},c=CD(CD(CD({},l),vg(i)),AI(i)),d;return w.isValidElement(i)?d=w.cloneElement(i,c):typeof i==\"function\"?d=i(c):d=w.createElement(Vre,c),w.createElement(Oa,{className:\"recharts-active-dot\",clipPath:o},d)};function uL(t){var{points:e,mainColor:n,activeDot:r,itemDataKey:i,clipPath:s,zIndex:o=Ja.activeDot}=t,l=sn(Sp),c=W8e();if(e==null||c==null)return null;var d=e.find(u=>c.includes(u.payload));return Ea(d)?null:w.createElement(Gs,{zIndex:o},w.createElement(Q8e,{point:d,childIndex:Number(l),mainColor:n,dataKey:i,activeDot:r,clipPath:s}))}var gW=(t,e,n)=>{var r=n??t;if(!Ea(r))return Hs(r,e,0)},Z8e=(t,e,n)=>{var r={},i=t.filter(OT),s=t.filter(d=>d.stackId==null),o=i.reduce((d,u)=>{var m=d[u.stackId];return m==null&&(m=[]),m.push(u),d[u.stackId]=m,d},r),l=Object.entries(o).map(d=>{var u,[m,p]=d,f=p.map(v=>v.dataKey),y=gW(e,n,(u=p[0])===null||u===void 0?void 0:u.barSize);return{stackId:m,dataKeys:f,barSize:y}}),c=s.map(d=>{var u=[d.dataKey].filter(p=>p!=null),m=gW(e,n,d.barSize);return{stackId:void 0,dataKeys:u,barSize:m}});return[...l,...c]};function bW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function hk(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?bW(Object(n),!0).forEach(function(r){J8e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):bW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function J8e(t,e,n){return(e=eHe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function eHe(t){var e=tHe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function tHe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function nHe(t,e,n,r,i){var s,o=r.length;if(!(o<1)){var l=Hs(t,n,0,!0),c,d=[];if(Gn((s=r[0])===null||s===void 0?void 0:s.barSize)){var u=!1,m=n/o,p=r.reduce((_,C)=>_+(C.barSize||0),0);p+=(o-1)*l,p>=n&&(p-=(o-1)*l,l=0),p>=n&&m>0&&(u=!0,m*=.9,p=o*m);var f=(n-p)/2>>0,y={offset:f-l,size:0};c=r.reduce((_,C)=>{var P,N={stackId:C.stackId,dataKeys:C.dataKeys,position:{offset:y.offset+y.size+l,size:u?m:(P=C.barSize)!==null&&P!==void 0?P:0}},A=[..._,N];return y=N.position,A},d)}else{var v=Hs(e,n,0,!0);n-2*v-(o-1)*l<=0&&(l=0);var b=(n-2*v-(o-1)*l)/o;b>1&&(b>>=0);var g=Gn(i)?Math.min(b,i):b;c=r.reduce((_,C,P)=>[..._,{stackId:C.stackId,dataKeys:C.dataKeys,position:{offset:v+(b+l)*P+(b-g)/2,size:g}}],d)}return c}}var rHe=(t,e,n,r,i,s,o)=>{var l=Ea(o)?e:o,c=nHe(n,r,i!==s?i:s,t,l);return i!==s&&c!=null&&(c=c.map(d=>hk(hk({},d),{},{position:hk(hk({},d.position),{},{offset:d.position.offset-i/2})}))),c},aHe=(t,e)=>{var n=LT(e);if(!(!t||n==null||e==null)){var{stackId:r}=e;if(r!=null){var i=t[r];if(i){var{stackedData:s}=i;if(s)return s.find(o=>o.key===n)}}}},iHe=(t,e)=>{if(!(t==null||e==null)){var n=t.find(r=>r.stackId===e.stackId&&e.dataKey!=null&&r.dataKeys.includes(e.dataKey));if(n!=null)return n.position}};function sHe(t,e){return t&&typeof t==\"object\"&&\"zIndex\"in t&&typeof t.zIndex==\"number\"&&Gn(t.zIndex)?t.zIndex:e}var nae=t=>{var{chartData:e}=t,n=ua(),r=Li();return w.useEffect(()=>r?()=>{}:(n(_9(e)),()=>{n(_9(void 0))}),[e,n,r]),null},xW={x:0,y:0,width:0,height:0,padding:{top:0,right:0,bottom:0,left:0}},rae=$o({name:\"brush\",initialState:xW,reducers:{setBrushSettings(t,e){return e.payload==null?xW:e.payload}}}),{setBrushSettings:Dst}=rae.actions,oHe=rae.reducer,lHe=(t,e)=>{var{x:n,y:r}=t,{x:i,y:s}=e;return{x:Math.min(n,i),y:Math.min(r,s),width:Math.abs(i-n),height:Math.abs(s-r)}},cHe=t=>{var{x1:e,y1:n,x2:r,y2:i}=t;return lHe({x:e,y:n},{x:r,y:i})};function dHe(t){return(t%180+180)%180}var uHe=function(e){var{width:n,height:r}=e,i=arguments.length>1&&arguments[1]!==void 0?arguments[1]:0,s=dHe(i),o=s*Math.PI/180,l=Math.atan(r/n),c=o>l&&o<Math.PI-l?r/Math.sin(o):n/Math.cos(o);return Math.abs(c)},mHe={dots:[],areas:[],lines:[]},aae=$o({name:\"referenceElements\",initialState:mHe,reducers:{addDot:(t,e)=>{t.dots.push(e.payload)},removeDot:(t,e)=>{var n=Xc(t).dots.findIndex(r=>r===e.payload);n!==-1&&t.dots.splice(n,1)},addArea:(t,e)=>{t.areas.push(e.payload)},removeArea:(t,e)=>{var n=Xc(t).areas.findIndex(r=>r===e.payload);n!==-1&&t.areas.splice(n,1)},addLine:(t,e)=>{t.lines.push(e.payload)},removeLine:(t,e)=>{var n=Xc(t).lines.findIndex(r=>r===e.payload);n!==-1&&t.lines.splice(n,1)}}}),{addDot:Fst,removeDot:Rst,addArea:Lst,removeArea:Ost,addLine:hHe,removeLine:pHe}=aae.actions,fHe=aae.reducer,iae=w.createContext(void 0),gHe=t=>{var{children:e}=t,[n]=w.useState(\"\".concat(xw(\"recharts\"),\"-clip\")),r=ZT();if(r==null)return null;var{x:i,y:s,width:o,height:l}=r;return w.createElement(iae.Provider,{value:n},w.createElement(\"defs\",null,w.createElement(\"clipPath\",{id:n},w.createElement(\"rect\",{x:i,y:s,height:l,width:o}))),e)},bHe=()=>w.useContext(iae);class xHe{constructor(e){var{x:n,y:r}=e;this.xAxisScale=n,this.yAxisScale=r}map(e,n){var r,i,{position:s}=n;return{x:(r=this.xAxisScale.map(e.x,{position:s}))!==null&&r!==void 0?r:0,y:(i=this.yAxisScale.map(e.y,{position:s}))!==null&&i!==void 0?i:0}}mapWithFallback(e,n){var r,i,{position:s,fallback:o}=n,l,c;return o===\"rangeMin\"?l=this.yAxisScale.rangeMin():o===\"rangeMax\"?l=this.yAxisScale.rangeMax():l=0,o===\"rangeMin\"?c=this.xAxisScale.rangeMin():o===\"rangeMax\"?c=this.xAxisScale.rangeMax():c=0,{x:(r=this.xAxisScale.map(e.x,{position:s}))!==null&&r!==void 0?r:c,y:(i=this.yAxisScale.map(e.y,{position:s}))!==null&&i!==void 0?i:l}}isInRange(e){var{x:n,y:r}=e,i=n==null||this.xAxisScale.isInRange(n),s=r==null||this.yAxisScale.isInRange(r);return i&&s}}function yW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function vW(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?yW(Object(n),!0).forEach(function(r){yHe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):yW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function yHe(t,e,n){return(e=vHe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function vHe(t){var e=wHe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function wHe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function DC(){return DC=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},DC.apply(null,arguments)}var SHe=(t,e)=>{var n;if(w.isValidElement(t))n=w.cloneElement(t,e);else if(typeof t==\"function\")n=t(e);else{if(!Gn(e.x1)||!Gn(e.y1)||!Gn(e.x2)||!Gn(e.y2))return null;n=w.createElement(\"line\",DC({},e,{className:\"recharts-reference-line-line\"}))}return n},_He=(t,e,n,r,i,s)=>{var{x:o,width:l}=s,c=i.map(t,{position:n});if(!Gn(c)||e===\"discard\"&&!i.isInRange(c))return null;var d=[{x:o+l,y:c},{x:o,y:c}];return r===\"left\"?d.reverse():d},kHe=(t,e,n,r,i,s)=>{var{y:o,height:l}=s,c=i.map(t,{position:n});if(!Gn(c)||e===\"discard\"&&!i.isInRange(c))return null;var d=[{x:c,y:o+l},{x:c,y:o}];return r===\"top\"?d.reverse():d},NHe=(t,e,n,r)=>{var i=[r.mapWithFallback(t[0],{position:n,fallback:\"rangeMin\"}),r.mapWithFallback(t[1],{position:n,fallback:\"rangeMax\"})];return e===\"discard\"&&i.some(s=>!r.isInRange(s))?null:i},CHe=(t,e,n,r,i,s,o)=>{var{x:l,y:c,segment:d,ifOverflow:u}=o,m=hc(l),p=hc(c);return p?_He(c,u,r,s,e,n):m?kHe(l,u,r,i,t,n):d!=null&&d.length===2?NHe(d,u,r,new xHe({x:t,y:e})):null};function PHe(t){var e=ua();return w.useEffect(()=>(e(hHe(t)),()=>{e(pHe(t))})),null}function THe(t){var{xAxisId:e,yAxisId:n,shape:r,className:i,ifOverflow:s}=t,o=Li(),l=bHe(),c=sn(T=>Qd(T,e)),d=sn(T=>Zd(T,n)),u=sn(T=>sy(T,\"xAxis\",e,o)),m=sn(T=>sy(T,\"yAxis\",n,o)),p=bS();if(!l||!p||c==null||d==null||u==null||m==null)return null;var f=CHe(u,m,p,t.position,c.orientation,d.orientation,t);if(!f)return null;var y=f[0],v=f[1];if(y==null||v==null)return null;var{x:b,y:g}=y,{x:_,y:C}=v,P=s===\"hidden\"?\"url(#\".concat(l,\")\"):void 0,N=vW(vW({clipPath:P},Cs(t)),{},{x1:b,y1:g,x2:_,y2:C}),A=cHe({x1:b,y1:g,x2:_,y2:C});return w.createElement(Gs,{zIndex:t.zIndex},w.createElement(Oa,{className:_r(\"recharts-reference-line\",i)},SHe(r,N),w.createElement(Ure,DC({},A,{lowerWidth:A.width,upperWidth:A.width}),w.createElement(Hre,{label:t.label}),t.children)))}var AHe={ifOverflow:\"discard\",xAxisId:0,yAxisId:0,fill:\"none\",label:!1,stroke:\"#ccc\",fillOpacity:1,strokeWidth:1,position:\"middle\",zIndex:Ja.line};function x0(t){var e=Ia(t,AHe);return w.createElement(w.Fragment,null,w.createElement(PHe,{yAxisId:e.yAxisId,xAxisId:e.xAxisId,ifOverflow:e.ifOverflow,x:e.x,y:e.y,segment:e.segment}),w.createElement(THe,e))}x0.displayName=\"ReferenceLine\";function sae(t,e){if(e<1)return[];if(e===1)return t;for(var n=[],r=0;r<t.length;r+=e){var i=t[r];i!==void 0&&n.push(i)}return n}function jHe(t,e,n){var r={width:t.width+e.width,height:t.height+e.height};return uHe(r,n)}function MHe(t,e,n){var r=n===\"width\",{x:i,y:s,width:o,height:l}=t;return e===1?{start:r?i:s,end:r?i+o:s+l}:{start:r?i+o:s+l,end:r?i:s}}function Ew(t,e,n,r,i){if(t*e<t*r||t*e>t*i)return!1;var s=n();return t*(e-t*s/2-r)>=0&&t*(e+t*s/2-i)<=0}function EHe(t,e){return sae(t,e+1)}function DHe(t,e,n,r,i){for(var s=(r||[]).slice(),{start:o,end:l}=e,c=0,d=1,u=o,m=function(){var y=r?.[c];if(y===void 0)return{v:sae(r,d)};var v=c,b,g=()=>(b===void 0&&(b=n(y,v)),b),_=y.coordinate,C=c===0||Ew(t,_,g,u,l);C||(c=0,u=o,d+=1),C&&(u=_+t*(g()/2+i),c+=d)},p;d<=s.length;)if(p=m(),p)return p.v;return[]}function FHe(t,e,n,r,i){var s=(r||[]).slice(),o=s.length;if(o===0)return[];for(var{start:l,end:c}=e,d=1;d<=o;d++){for(var u=(o-1)%d,m=l,p=!0,f=function(){var P=r[v];if(P==null)return 0;var N=v,A,T=()=>(A===void 0&&(A=n(P,N)),A),F=P.coordinate,k=v===u||Ew(t,F,T,m,c);if(!k)return p=!1,1;k&&(m=F+t*(T()/2+i))},y,v=u;v<o&&(y=f(),!(y!==0&&y===1));v+=d);if(p){for(var b=[],g=u;g<o;g+=d){var _=r[g];_!=null&&b.push(_)}return b}}return[]}function wW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function Ls(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?wW(Object(n),!0).forEach(function(r){RHe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):wW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function RHe(t,e,n){return(e=LHe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function LHe(t){var e=OHe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function OHe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function IHe(t,e,n,r,i){for(var s=(r||[]).slice(),o=s.length,{start:l}=e,{end:c}=e,d=function(p){var f=s[p];if(f==null)return 1;var y=f,v,b=()=>(v===void 0&&(v=n(f,p)),v);if(p===o-1){var g=t*(y.coordinate+t*b()/2-c);s[p]=y=Ls(Ls({},y),{},{tickCoord:g>0?y.coordinate-g*t:y.coordinate})}else s[p]=y=Ls(Ls({},y),{},{tickCoord:y.coordinate});if(y.tickCoord!=null){var _=Ew(t,y.tickCoord,b,l,c);_&&(c=y.tickCoord-t*(b()/2+i),s[p]=Ls(Ls({},y),{},{isShow:!0}))}},u=o-1;u>=0;u--)d(u);return s}function zHe(t,e,n,r,i,s){var o=(r||[]).slice(),l=o.length,{start:c,end:d}=e;if(s){var u=r[l-1];if(u!=null){var m=n(u,l-1),p=t*(u.coordinate+t*m/2-d);if(o[l-1]=u=Ls(Ls({},u),{},{tickCoord:p>0?u.coordinate-p*t:u.coordinate}),u.tickCoord!=null){var f=Ew(t,u.tickCoord,()=>m,c,d);f&&(d=u.tickCoord-t*(m/2+i),o[l-1]=Ls(Ls({},u),{},{isShow:!0}))}}}for(var y=s?l-1:l,v=function(_){var C=o[_];if(C==null)return 1;var P=C,N,A=()=>(N===void 0&&(N=n(C,_)),N);if(_===0){var T=t*(P.coordinate-t*A()/2-c);o[_]=P=Ls(Ls({},P),{},{tickCoord:T<0?P.coordinate-T*t:P.coordinate})}else o[_]=P=Ls(Ls({},P),{},{tickCoord:P.coordinate});if(P.tickCoord!=null){var F=Ew(t,P.tickCoord,A,c,d);F&&(c=P.tickCoord+t*(A()/2+i),o[_]=Ls(Ls({},P),{},{isShow:!0}))}},b=0;b<y;b++)v(b);return o}function pz(t,e,n){var{tick:r,ticks:i,viewBox:s,minTickGap:o,orientation:l,interval:c,tickFormatter:d,unit:u,angle:m}=t;if(!i||!i.length||!r)return[];if(tn(c)||vS.isSsr){var p;return(p=EHe(i,tn(c)?c:0))!==null&&p!==void 0?p:[]}var f=[],y=l===\"top\"||l===\"bottom\"?\"width\":\"height\",v=u&&y===\"width\"?R0(u,{fontSize:e,letterSpacing:n}):{width:0,height:0},b=(N,A)=>{var T=typeof d==\"function\"?d(N.value,A):N.value;return y===\"width\"?jHe(R0(T,{fontSize:e,letterSpacing:n}),v,m):R0(T,{fontSize:e,letterSpacing:n})[y]},g=i[0],_=i[1],C=i.length>=2&&g!=null&&_!=null?Qi(_.coordinate-g.coordinate):1,P=MHe(s,C,y);return c===\"equidistantPreserveStart\"?DHe(C,P,b,i,o):c===\"equidistantPreserveEnd\"?FHe(C,P,b,i,o):(c===\"preserveStart\"||c===\"preserveStartEnd\"?f=zHe(C,P,b,i,o,c===\"preserveStartEnd\"):f=IHe(C,P,b,i,o),f.filter(N=>N.isShow))}var UHe=t=>{var{ticks:e,label:n,labelGapWithTick:r=5,tickSize:i=0,tickMargin:s=0}=t,o=0;if(e){Array.from(e).forEach(u=>{if(u){var m=u.getBoundingClientRect();m.width>o&&(o=m.width)}});var l=n?n.getBoundingClientRect().width:0,c=i+s,d=o+c+l+(n?r:0);return Math.round(d)}return 0},BHe=[\"axisLine\",\"width\",\"height\",\"className\",\"hide\",\"ticks\",\"axisType\"];function HHe(t,e){if(t==null)return{};var n,r,i=qHe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function qHe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function Tg(){return Tg=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},Tg.apply(null,arguments)}function SW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function Wa(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?SW(Object(n),!0).forEach(function(r){$He(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):SW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function $He(t,e,n){return(e=VHe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function VHe(t){var e=GHe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function GHe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var mm={x:0,y:0,width:0,height:0,viewBox:{x:0,y:0,width:0,height:0},orientation:\"bottom\",ticks:[],stroke:\"#666\",tickLine:!0,axisLine:!0,tick:!0,mirror:!1,minTickGap:5,tickSize:6,tickMargin:2,interval:\"preserveEnd\",zIndex:Ja.axis};function WHe(t){var{x:e,y:n,width:r,height:i,orientation:s,mirror:o,axisLine:l,otherSvgProps:c}=t;if(!l)return null;var d=Wa(Wa(Wa({},c),mo(l)),{},{fill:\"none\"});if(s===\"top\"||s===\"bottom\"){var u=+(s===\"top\"&&!o||s===\"bottom\"&&o);d=Wa(Wa({},d),{},{x1:e,y1:n+u*i,x2:e+r,y2:n+u*i})}else{var m=+(s===\"left\"&&!o||s===\"right\"&&o);d=Wa(Wa({},d),{},{x1:e+m*r,y1:n,x2:e+m*r,y2:n+i})}return w.createElement(\"line\",Tg({},d,{className:_r(\"recharts-cartesian-axis-line\",Sg(l,\"className\"))}))}function KHe(t,e,n,r,i,s,o,l,c){var d,u,m,p,f,y,v=l?-1:1,b=t.tickSize||o,g=tn(t.tickCoord)?t.tickCoord:t.coordinate;switch(s){case\"top\":d=u=t.coordinate,p=n+ +!l*i,m=p-v*b,y=m-v*c,f=g;break;case\"left\":m=p=t.coordinate,u=e+ +!l*r,d=u-v*b,f=d-v*c,y=g;break;case\"right\":m=p=t.coordinate,u=e+ +l*r,d=u+v*b,f=d+v*c,y=g;break;default:d=u=t.coordinate,p=n+ +l*i,m=p+v*b,y=m+v*c,f=g;break}return{line:{x1:d,y1:m,x2:u,y2:p},tick:{x:f,y}}}function XHe(t,e){switch(t){case\"left\":return e?\"start\":\"end\";case\"right\":return e?\"end\":\"start\";default:return\"middle\"}}function YHe(t,e){switch(t){case\"left\":case\"right\":return\"middle\";case\"top\":return e?\"start\":\"end\";default:return e?\"end\":\"start\"}}function QHe(t){var{option:e,tickProps:n,value:r}=t,i,s=_r(n.className,\"recharts-cartesian-axis-tick-value\");if(w.isValidElement(e))i=w.cloneElement(e,Wa(Wa({},n),{},{className:s}));else if(typeof e==\"function\")i=e(Wa(Wa({},n),{},{className:s}));else{var o=\"recharts-cartesian-axis-tick-value\";typeof e!=\"boolean\"&&(o=_r(o,Xre(e))),i=w.createElement(VT,Tg({},n,{className:o}),r)}return i}var ZHe=w.forwardRef((t,e)=>{var{ticks:n=[],tick:r,tickLine:i,stroke:s,tickFormatter:o,unit:l,padding:c,tickTextProps:d,orientation:u,mirror:m,x:p,y:f,width:y,height:v,tickSize:b,tickMargin:g,fontSize:_,letterSpacing:C,getTicksConfig:P,events:N,axisType:A}=t,T=pz(Wa(Wa({},P),{},{ticks:n}),_,C),F=XHe(u,m),k=YHe(u,m),D=mo(P),H=vg(r),z={};typeof i==\"object\"&&(z=i);var Q=Wa(Wa({},D),{},{fill:\"none\"},z),L=T.map(J=>Wa({entry:J},KHe(J,p,f,y,v,u,b,m,g))),te=L.map(J=>{var{entry:oe,line:fe}=J;return w.createElement(Oa,{className:\"recharts-cartesian-axis-tick\",key:\"tick-\".concat(oe.value,\"-\").concat(oe.coordinate,\"-\").concat(oe.tickCoord)},i&&w.createElement(\"line\",Tg({},Q,fe,{className:_r(\"recharts-cartesian-axis-tick-line\",Sg(i,\"className\"))})))}),ie=L.map((J,oe)=>{var fe,re,{entry:W,tick:ne}=J,me=Wa(Wa(Wa(Wa({verticalAnchor:k},D),{},{textAnchor:F,stroke:\"none\",fill:s},ne),{},{index:oe,payload:W,visibleTicksCount:T.length,tickFormatter:o,padding:c},d),{},{angle:(fe=(re=d?.angle)!==null&&re!==void 0?re:D.angle)!==null&&fe!==void 0?fe:0}),be=Wa(Wa({},me),H);return w.createElement(Oa,Tg({className:\"recharts-cartesian-axis-tick-label\",key:\"tick-label-\".concat(W.value,\"-\").concat(W.coordinate,\"-\").concat(W.tickCoord)},hS(N,W,oe)),r&&w.createElement(QHe,{option:r,tickProps:be,value:\"\".concat(typeof o==\"function\"?o(W.value,oe):W.value).concat(l||\"\")}))});return w.createElement(\"g\",{className:\"recharts-cartesian-axis-ticks recharts-\".concat(A,\"-ticks\")},ie.length>0&&w.createElement(Gs,{zIndex:Ja.label},w.createElement(\"g\",{className:\"recharts-cartesian-axis-tick-labels recharts-\".concat(A,\"-tick-labels\"),ref:e},ie)),te.length>0&&w.createElement(\"g\",{className:\"recharts-cartesian-axis-tick-lines recharts-\".concat(A,\"-tick-lines\")},te))}),JHe=w.forwardRef((t,e)=>{var{axisLine:n,width:r,height:i,className:s,hide:o,ticks:l,axisType:c}=t,d=HHe(t,BHe),[u,m]=w.useState(\"\"),[p,f]=w.useState(\"\"),y=w.useRef(null);w.useImperativeHandle(e,()=>({getCalculatedWidth:()=>{var b;return UHe({ticks:y.current,label:(b=t.labelRef)===null||b===void 0?void 0:b.current,labelGapWithTick:5,tickSize:t.tickSize,tickMargin:t.tickMargin})}}));var v=w.useCallback(b=>{if(b){var g=b.getElementsByClassName(\"recharts-cartesian-axis-tick-value\");y.current=g;var _=g[0];if(_){var C=window.getComputedStyle(_),P=C.fontSize,N=C.letterSpacing;(P!==u||N!==p)&&(m(P),f(N))}}},[u,p]);return o||r!=null&&r<=0||i!=null&&i<=0?null:w.createElement(Gs,{zIndex:t.zIndex},w.createElement(Oa,{className:_r(\"recharts-cartesian-axis\",s)},w.createElement(WHe,{x:t.x,y:t.y,width:r,height:i,orientation:t.orientation,mirror:t.mirror,axisLine:n,otherSvgProps:mo(t)}),w.createElement(ZHe,{ref:v,axisType:c,events:d,fontSize:u,getTicksConfig:t,height:t.height,letterSpacing:p,mirror:t.mirror,orientation:t.orientation,padding:t.padding,stroke:t.stroke,tick:t.tick,tickFormatter:t.tickFormatter,tickLine:t.tickLine,tickMargin:t.tickMargin,tickSize:t.tickSize,tickTextProps:t.tickTextProps,ticks:l,unit:t.unit,width:t.width,x:t.x,y:t.y}),w.createElement(Ure,{x:t.x,y:t.y,width:t.width,height:t.height,lowerWidth:t.width,upperWidth:t.width},w.createElement(Hre,{label:t.label,labelRef:t.labelRef}),t.children)))}),fz=w.forwardRef((t,e)=>{var n=Ia(t,mm);return w.createElement(JHe,Tg({},n,{ref:e}))});fz.displayName=\"CartesianAxis\";var e6e=[\"x1\",\"y1\",\"x2\",\"y2\",\"key\"],t6e=[\"offset\"],n6e=[\"xAxisId\",\"yAxisId\"],r6e=[\"xAxisId\",\"yAxisId\"];function _W(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function zs(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?_W(Object(n),!0).forEach(function(r){a6e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):_W(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function a6e(t,e,n){return(e=i6e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function i6e(t){var e=s6e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function s6e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function zf(){return zf=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},zf.apply(null,arguments)}function FC(t,e){if(t==null)return{};var n,r,i=o6e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function o6e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var l6e=t=>{var{fill:e}=t;if(!e||e===\"none\")return null;var{fillOpacity:n,x:r,y:i,width:s,height:o,ry:l}=t;return w.createElement(\"rect\",{x:r,y:i,ry:l,width:s,height:o,stroke:\"none\",fill:e,fillOpacity:n,className:\"recharts-cartesian-grid-bg\"})};function oae(t){var{option:e,lineItemProps:n}=t,r;if(w.isValidElement(e))r=w.cloneElement(e,n);else if(typeof e==\"function\")r=e(n);else{var i,{x1:s,y1:o,x2:l,y2:c,key:d}=n,u=FC(n,e6e),m=(i=mo(u))!==null&&i!==void 0?i:{},{offset:p}=m,f=FC(m,t6e);r=w.createElement(\"line\",zf({},f,{x1:s,y1:o,x2:l,y2:c,fill:\"none\",key:d}))}return r}function c6e(t){var{x:e,width:n,horizontal:r=!0,horizontalPoints:i}=t;if(!r||!i||!i.length)return null;var{xAxisId:s,yAxisId:o}=t,l=FC(t,n6e),c=i.map((d,u)=>{var m=zs(zs({},l),{},{x1:e,y1:d,x2:e+n,y2:d,key:\"line-\".concat(u),index:u});return w.createElement(oae,{key:\"line-\".concat(u),option:r,lineItemProps:m})});return w.createElement(\"g\",{className:\"recharts-cartesian-grid-horizontal\"},c)}function d6e(t){var{y:e,height:n,vertical:r=!0,verticalPoints:i}=t;if(!r||!i||!i.length)return null;var{xAxisId:s,yAxisId:o}=t,l=FC(t,r6e),c=i.map((d,u)=>{var m=zs(zs({},l),{},{x1:d,y1:e,x2:d,y2:e+n,key:\"line-\".concat(u),index:u});return w.createElement(oae,{option:r,lineItemProps:m,key:\"line-\".concat(u)})});return w.createElement(\"g\",{className:\"recharts-cartesian-grid-vertical\"},c)}function u6e(t){var{horizontalFill:e,fillOpacity:n,x:r,y:i,width:s,height:o,horizontalPoints:l,horizontal:c=!0}=t;if(!c||!e||!e.length||l==null)return null;var d=l.map(m=>Math.round(m+i-i)).sort((m,p)=>m-p);i!==d[0]&&d.unshift(0);var u=d.map((m,p)=>{var f=d[p+1],y=f==null,v=y?i+o-m:f-m;if(v<=0)return null;var b=p%e.length;return w.createElement(\"rect\",{key:\"react-\".concat(p),y:m,x:r,height:v,width:s,stroke:\"none\",fill:e[b],fillOpacity:n,className:\"recharts-cartesian-grid-bg\"})});return w.createElement(\"g\",{className:\"recharts-cartesian-gridstripes-horizontal\"},u)}function m6e(t){var{vertical:e=!0,verticalFill:n,fillOpacity:r,x:i,y:s,width:o,height:l,verticalPoints:c}=t;if(!e||!n||!n.length)return null;var d=c.map(m=>Math.round(m+i-i)).sort((m,p)=>m-p);i!==d[0]&&d.unshift(0);var u=d.map((m,p)=>{var f=d[p+1],y=f==null,v=y?i+o-m:f-m;if(v<=0)return null;var b=p%n.length;return w.createElement(\"rect\",{key:\"react-\".concat(p),x:m,y:s,width:v,height:l,stroke:\"none\",fill:n[b],fillOpacity:r,className:\"recharts-cartesian-grid-bg\"})});return w.createElement(\"g\",{className:\"recharts-cartesian-gridstripes-vertical\"},u)}var h6e=(t,e)=>{var{xAxis:n,width:r,height:i,offset:s}=t;return Yee(pz(zs(zs(zs({},mm),n),{},{ticks:Qee(n),viewBox:{x:0,y:0,width:r,height:i}})),s.left,s.left+s.width,e)},p6e=(t,e)=>{var{yAxis:n,width:r,height:i,offset:s}=t;return Yee(pz(zs(zs(zs({},mm),n),{},{ticks:Qee(n),viewBox:{x:0,y:0,width:r,height:i}})),s.top,s.top+s.height,e)},f6e={horizontal:!0,vertical:!0,horizontalPoints:[],verticalPoints:[],stroke:\"#ccc\",fill:\"none\",verticalFill:[],horizontalFill:[],xAxisId:0,yAxisId:0,syncWithTicks:!1,zIndex:Ja.grid};function Uf(t){var e=HI(),n=qI(),r=ite(),i=zs(zs({},Ia(t,f6e)),{},{x:tn(t.x)?t.x:r.left,y:tn(t.y)?t.y:r.top,width:tn(t.width)?t.width:r.width,height:tn(t.height)?t.height:r.height}),{xAxisId:s,yAxisId:o,x:l,y:c,width:d,height:u,syncWithTicks:m,horizontalValues:p,verticalValues:f}=i,y=Li(),v=sn(k=>u9(k,\"xAxis\",s,y)),b=sn(k=>u9(k,\"yAxis\",o,y));if(!qd(d)||!qd(u)||!tn(l)||!tn(c))return null;var g=i.verticalCoordinatesGenerator||h6e,_=i.horizontalCoordinatesGenerator||p6e,{horizontalPoints:C,verticalPoints:P}=i;if((!C||!C.length)&&typeof _==\"function\"){var N=p&&p.length,A=_({yAxis:b?zs(zs({},b),{},{ticks:N?p:b.ticks}):void 0,width:e??d,height:n??u,offset:r},N?!0:m);aC(Array.isArray(A),\"horizontalCoordinatesGenerator should return Array but instead it returned [\".concat(typeof A,\"]\")),Array.isArray(A)&&(C=A)}if((!P||!P.length)&&typeof g==\"function\"){var T=f&&f.length,F=g({xAxis:v?zs(zs({},v),{},{ticks:T?f:v.ticks}):void 0,width:e??d,height:n??u,offset:r},T?!0:m);aC(Array.isArray(F),\"verticalCoordinatesGenerator should return Array but instead it returned [\".concat(typeof F,\"]\")),Array.isArray(F)&&(P=F)}return w.createElement(Gs,{zIndex:i.zIndex},w.createElement(\"g\",{className:\"recharts-cartesian-grid\"},w.createElement(l6e,{fill:i.fill,fillOpacity:i.fillOpacity,x:i.x,y:i.y,width:i.width,height:i.height,ry:i.ry}),w.createElement(u6e,zf({},i,{horizontalPoints:C})),w.createElement(m6e,zf({},i,{verticalPoints:P})),w.createElement(c6e,zf({},i,{offset:r,horizontalPoints:C,xAxis:v,yAxis:b})),w.createElement(d6e,zf({},i,{offset:r,verticalPoints:P,xAxis:v,yAxis:b}))))}Uf.displayName=\"CartesianGrid\";var g6e={},lae=$o({name:\"errorBars\",initialState:g6e,reducers:{addErrorBar:(t,e)=>{var{itemId:n,errorBar:r}=e.payload;t[n]||(t[n]=[]),t[n].push(r)},replaceErrorBar:(t,e)=>{var{itemId:n,prev:r,next:i}=e.payload;t[n]&&(t[n]=t[n].map(s=>s.dataKey===r.dataKey&&s.direction===r.direction?i:s))},removeErrorBar:(t,e)=>{var{itemId:n,errorBar:r}=e.payload;t[n]&&(t[n]=t[n].filter(i=>i.dataKey!==r.dataKey||i.direction!==r.direction))}}}),{addErrorBar:Ist,replaceErrorBar:zst,removeErrorBar:Ust}=lae.actions,b6e=lae.reducer,x6e=[\"children\"];function y6e(t,e){if(t==null)return{};var n,r,i=v6e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function v6e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var w6e={data:[],xAxisId:\"xAxis-0\",yAxisId:\"yAxis-0\",dataPointFormatter:()=>({x:0,y:0,value:0}),errorBarOffset:0},S6e=w.createContext(w6e);function cae(t){var{children:e}=t,n=y6e(t,x6e);return w.createElement(S6e.Provider,{value:n},e)}function JT(t,e){var n,r,i=sn(d=>Qd(d,t)),s=sn(d=>Zd(d,e)),o=(n=i?.allowDataOverflow)!==null&&n!==void 0?n:Gi.allowDataOverflow,l=(r=s?.allowDataOverflow)!==null&&r!==void 0?r:Wi.allowDataOverflow,c=o||l;return{needClip:c,needClipX:o,needClipY:l}}function gz(t){var{xAxisId:e,yAxisId:n,clipPathId:r}=t,i=ZT(),{needClipX:s,needClipY:o,needClip:l}=JT(e,n);if(!l||!i)return null;var{x:c,y:d,width:u,height:m}=i;return w.createElement(\"clipPath\",{id:\"clipPath-\".concat(r)},w.createElement(\"rect\",{x:s?c:c-u/2,y:o?d:d-m/2,width:s?u:u*2,height:o?m:m*2}))}var dae=(t,e,n,r)=>Gd(t,\"xAxis\",e,r),uae=(t,e,n,r)=>Vd(t,\"xAxis\",e,r),mae=(t,e,n,r)=>Gd(t,\"yAxis\",n,r),hae=(t,e,n,r)=>Vd(t,\"yAxis\",n,r),_6e=wt([Sr,dae,mae,uae,hae],(t,e,n,r,i)=>ad(t,\"xAxis\")?wp(e,r,!1):wp(n,i,!1)),k6e=(t,e,n,r,i)=>i;function N6e(t){return t.type===\"line\"}var C6e=wt([PS,k6e],(t,e)=>t.filter(N6e).find(n=>n.id===e)),P6e=wt([Sr,dae,mae,uae,hae,C6e,_6e,g4],(t,e,n,r,i,s,o,l)=>{var{chartData:c,dataStartIndex:d,dataEndIndex:u}=l;if(!(s==null||e==null||n==null||r==null||i==null||r.length===0||i.length===0||o==null||t!==\"horizontal\"&&t!==\"vertical\")){var{dataKey:m,data:p}=s,f;if(p!=null&&p.length>0?f=p:f=c?.slice(d,u+1),f!=null)return hqe({layout:t,xAxis:e,yAxis:n,xAxisTicks:r,yAxisTicks:i,dataKey:m,bandSize:o,displayedData:f})}});function pae(t){var e=vg(t),n=3,r=2;if(e!=null){var{r:i,strokeWidth:s}=e,o=Number(i),l=Number(s);return(Number.isNaN(o)||o<0)&&(o=n),(Number.isNaN(l)||l<0)&&(l=r),{r:o,strokeWidth:l}}return{r:n,strokeWidth:r}}var PD={exports:{}},TD={};var kW;function T6e(){if(kW)return TD;kW=1;var t=Rg();function e(c,d){return c===d&&(c!==0||1/c===1/d)||c!==c&&d!==d}var n=typeof Object.is==\"function\"?Object.is:e,r=t.useSyncExternalStore,i=t.useRef,s=t.useEffect,o=t.useMemo,l=t.useDebugValue;return TD.useSyncExternalStoreWithSelector=function(c,d,u,m,p){var f=i(null);if(f.current===null){var y={hasValue:!1,value:null};f.current=y}else y=f.current;f=o(function(){function b(N){if(!g){if(g=!0,_=N,N=m(N),p!==void 0&&y.hasValue){var A=y.value;if(p(A,N))return C=A}return C=N}if(A=C,n(_,N))return A;var T=m(N);return p!==void 0&&p(A,T)?(_=N,A):(_=N,C=T)}var g=!1,_,C,P=u===void 0?null:u;return[function(){return b(d())},P===null?void 0:function(){return b(P())}]},[d,u,m,p]);var v=r(c,f[0],f[1]);return s(function(){y.hasValue=!0,y.value=v},[v]),l(v),v},TD}var NW;function A6e(){return NW||(NW=1,PD.exports=T6e()),PD.exports}A6e();function j6e(t){t()}function M6e(){let t=null,e=null;return{clear(){t=null,e=null},notify(){j6e(()=>{let n=t;for(;n;)n.callback(),n=n.next})},get(){const n=[];let r=t;for(;r;)n.push(r),r=r.next;return n},subscribe(n){let r=!0;const i=e={callback:n,next:null,prev:e};return i.prev?i.prev.next=i:t=i,function(){!r||t===null||(r=!1,i.next?i.next.prev=i.prev:e=i.prev,i.prev?i.prev.next=i.next:t=i.next)}}}}var CW={notify(){},get:()=>[]};function E6e(t,e){let n,r=CW,i=0,s=!1;function o(v){u();const b=r.subscribe(v);let g=!1;return()=>{g||(g=!0,b(),m())}}function l(){r.notify()}function c(){y.onStateChange&&y.onStateChange()}function d(){return s}function u(){i++,n||(n=t.subscribe(c),r=M6e())}function m(){i--,n&&i===0&&(n(),n=void 0,r.clear(),r=CW)}function p(){s||(s=!0,u())}function f(){s&&(s=!1,m())}const y={addNestedSub:o,notifyNestedSubs:l,handleChangeWrapper:c,isSubscribed:d,trySubscribe:p,tryUnsubscribe:f,getListeners:()=>r};return y}var D6e=()=>typeof window<\"u\"&&typeof window.document<\"u\"&&typeof window.document.createElement<\"u\",F6e=D6e(),R6e=()=>typeof navigator<\"u\"&&navigator.product===\"ReactNative\",L6e=R6e(),O6e=()=>F6e||L6e?w.useLayoutEffect:w.useEffect,I6e=O6e();function PW(t,e){return t===e?t!==0||e!==0||1/t===1/e:t!==t&&e!==e}function z6e(t,e){if(PW(t,e))return!0;if(typeof t!=\"object\"||t===null||typeof e!=\"object\"||e===null)return!1;const n=Object.keys(t),r=Object.keys(e);if(n.length!==r.length)return!1;for(let i=0;i<n.length;i++)if(!Object.prototype.hasOwnProperty.call(e,n[i])||!PW(t[n[i]],e[n[i]]))return!1;return!0}var U6e=Symbol.for(\"react-redux-context\"),B6e=typeof globalThis<\"u\"?globalThis:{};function H6e(){if(!w.createContext)return{};const t=B6e[U6e]??=new Map;let e=t.get(w.createContext);return e||(e=w.createContext(null),t.set(w.createContext,e)),e}var q6e=H6e();function $6e(t){const{children:e,context:n,serverState:r,store:i}=t,s=w.useMemo(()=>{const c=E6e(i);return{store:i,subscription:c,getServerState:r?()=>r:void 0}},[i,r]),o=w.useMemo(()=>i.getState(),[i]);I6e(()=>{const{subscription:c}=s;return c.onStateChange=c.notifyNestedSubs,c.trySubscribe(),o!==i.getState()&&c.notifyNestedSubs(),()=>{c.tryUnsubscribe(),c.onStateChange=void 0}},[s,o]);const l=n||q6e;return w.createElement(l.Provider,{value:s},e)}var V6e=$6e,G6e=new Set([\"axisLine\",\"tickLine\",\"activeBar\",\"activeDot\",\"activeLabel\",\"activeShape\",\"allowEscapeViewBox\",\"background\",\"cursor\",\"dot\",\"label\",\"line\",\"margin\",\"padding\",\"position\",\"shape\",\"style\",\"tick\",\"wrapperStyle\",\"radius\"]);function W6e(t,e){return t==null&&e==null?!0:typeof t==\"number\"&&typeof e==\"number\"?t===e||t!==t&&e!==e:t===e}function DS(t,e){var n=new Set([...Object.keys(t),...Object.keys(e)]);for(var r of n)if(G6e.has(r)){if(t[r]==null&&e[r]==null)continue;if(!z6e(t[r],e[r]))return!1}else if(!W6e(t[r],e[r]))return!1;return!0}var K6e=[\"id\"],X6e=[\"type\",\"layout\",\"connectNulls\",\"needClip\",\"shape\"],Y6e=[\"activeDot\",\"animateNewValues\",\"animationBegin\",\"animationDuration\",\"animationEasing\",\"connectNulls\",\"dot\",\"hide\",\"isAnimationActive\",\"label\",\"legendType\",\"xAxisId\",\"yAxisId\",\"id\"];function Dw(){return Dw=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},Dw.apply(null,arguments)}function TW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function Cd(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?TW(Object(n),!0).forEach(function(r){Q6e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):TW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function Q6e(t,e,n){return(e=Z6e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function Z6e(t){var e=J6e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function J6e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function bz(t,e){if(t==null)return{};var n,r,i=eqe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function eqe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var tqe=t=>{var{dataKey:e,name:n,stroke:r,legendType:i,hide:s}=t;return[{inactive:s,dataKey:e,type:i,color:r,value:Ap(n,e),payload:t}]},nqe=w.memo(t=>{var{dataKey:e,data:n,stroke:r,strokeWidth:i,fill:s,name:o,hide:l,unit:c,tooltipType:d,id:u}=t,m={dataDefinedOnItem:n,getPosition:Tp,settings:{stroke:r,strokeWidth:i,fill:s,dataKey:e,nameKey:void 0,name:Ap(o,e),hide:l,type:d,color:r,unit:c,graphicalItemId:u}};return w.createElement(XT,{tooltipEntrySettings:m})}),fae=(t,e)=>\"\".concat(e,\"px \").concat(t-e,\"px\");function rqe(t,e){for(var n=t.length%2!==0?[...t,0]:t,r=[],i=0;i<e;++i)r=[...r,...n];return r}var aqe=(t,e,n)=>{var r=n.reduce((f,y)=>f+y);if(!r)return fae(e,t);for(var i=Math.floor(t/r),s=t%r,o=e-t,l=[],c=0,d=0;c<n.length;d+=(u=n[c])!==null&&u!==void 0?u:0,++c){var u,m=n[c];if(m!=null&&d+m>s){l=[...n.slice(0,c),s-d];break}}var p=l.length%2===0?[0,o]:[o];return[...rqe(n,i),...l,...p].map(f=>\"\".concat(f,\"px\")).join(\", \")};function iqe(t){var{clipPathId:e,points:n,props:r}=t,{dot:i,dataKey:s,needClip:o}=r,{id:l}=r,c=bz(r,K6e),d=mo(c);return w.createElement(Jre,{points:n,dot:i,className:\"recharts-line-dots\",dotClassName:\"recharts-line-dot\",dataKey:s,baseProps:d,needClip:o,clipPathId:e})}function sqe(t){var{showLabels:e,children:n,points:r}=t,i=w.useMemo(()=>r?.map(s=>{var o,l,c={x:(o=s.x)!==null&&o!==void 0?o:0,y:(l=s.y)!==null&&l!==void 0?l:0,width:0,lowerWidth:0,upperWidth:0,height:0};return Cd(Cd({},c),{},{value:s.value,payload:s.payload,viewBox:c,parentViewBox:void 0,fill:void 0})}),[r]);return w.createElement(rz,{value:e?i:void 0},n)}function AW(t){var{clipPathId:e,pathRef:n,points:r,strokeDasharray:i,props:s}=t,{type:o,layout:l,connectNulls:c,needClip:d,shape:u}=s,m=bz(s,X6e),p=Cd(Cd({},Cs(m)),{},{fill:\"none\",className:\"recharts-line-curve\",clipPath:d?\"url(#clipPath-\".concat(e,\")\"):void 0,points:r,type:o,layout:l,connectNulls:c,strokeDasharray:i??s.strokeDasharray});return w.createElement(w.Fragment,null,r?.length>1&&w.createElement(lz,Dw({shapeType:\"curve\",option:u},p,{pathRef:n})),w.createElement(iqe,{points:r,clipPathId:e,props:s}))}function oqe(t){try{return t&&t.getTotalLength&&t.getTotalLength()||0}catch{return 0}}function lqe(t){var{clipPathId:e,props:n,pathRef:r,previousPointsRef:i,longestAnimatedLengthRef:s}=t,{points:o,strokeDasharray:l,isAnimationActive:c,animationBegin:d,animationDuration:u,animationEasing:m,animateNewValues:p,width:f,height:y,onAnimationEnd:v,onAnimationStart:b}=n,g=i.current,_=Ay(o,\"recharts-line-\"),C=w.useRef(_),[P,N]=w.useState(!1),A=!P,T=w.useCallback(()=>{typeof v==\"function\"&&v(),N(!1)},[v]),F=w.useCallback(()=>{typeof b==\"function\"&&b(),N(!0)},[b]),k=oqe(r.current),D=w.useRef(0);C.current!==_&&(D.current=s.current,C.current=_);var H=D.current;return w.createElement(sqe,{points:o,showLabels:A},n.children,w.createElement(Ty,{animationId:_,begin:d,duration:u,isActive:c,easing:m,onAnimationEnd:T,onAnimationStart:F,key:_},z=>{var Q=Ir(H,k+H,z),L=Math.min(Q,k),te;if(c)if(l){var ie=\"\".concat(l).split(/[,\\s]+/gim).map(fe=>parseFloat(fe));te=aqe(L,k,ie)}else te=fae(k,L);else te=l==null?void 0:String(l);if(z>0&&k>0&&(i.current=o,s.current=Math.max(s.current,L)),g){var J=g.length/o.length,oe=z===1?o:o.map((fe,re)=>{var W=Math.floor(re*J);if(g[W]){var ne=g[W];return Cd(Cd({},fe),{},{x:Ir(ne.x,fe.x,z),y:Ir(ne.y,fe.y,z)})}return p?Cd(Cd({},fe),{},{x:Ir(f*2,fe.x,z),y:Ir(y/2,fe.y,z)}):Cd(Cd({},fe),{},{x:fe.x,y:fe.y})});return i.current=oe,w.createElement(AW,{props:n,points:oe,clipPathId:e,pathRef:r,strokeDasharray:te})}return w.createElement(AW,{props:n,points:o,clipPathId:e,pathRef:r,strokeDasharray:te})}),w.createElement(GT,{label:n.label}))}function cqe(t){var{clipPathId:e,props:n}=t,r=w.useRef(null),i=w.useRef(0),s=w.useRef(null);return w.createElement(lqe,{props:n,clipPathId:e,previousPointsRef:r,longestAnimatedLengthRef:i,pathRef:s})}var dqe=(t,e)=>{var n,r;return{x:(n=t.x)!==null&&n!==void 0?n:void 0,y:(r=t.y)!==null&&r!==void 0?r:void 0,value:t.value,errorVal:zr(t.payload,e)}};class uqe extends w.Component{render(){var{hide:e,dot:n,points:r,className:i,xAxisId:s,yAxisId:o,top:l,left:c,width:d,height:u,id:m,needClip:p,zIndex:f}=this.props;if(e)return null;var y=_r(\"recharts-line\",i),v=m,{r:b,strokeWidth:g}=pae(n),_=oz(n),C=b*2+g,P=p?\"url(#clipPath-\".concat(_?\"\":\"dots-\").concat(v,\")\"):void 0;return w.createElement(Gs,{zIndex:f},w.createElement(Oa,{className:y},p&&w.createElement(\"defs\",null,w.createElement(gz,{clipPathId:v,xAxisId:s,yAxisId:o}),!_&&w.createElement(\"clipPath\",{id:\"clipPath-dots-\".concat(v)},w.createElement(\"rect\",{x:c-C/2,y:l-C/2,width:d+C,height:u+C}))),w.createElement(cae,{xAxisId:s,yAxisId:o,data:r,dataPointFormatter:dqe,errorBarOffset:0},w.createElement(cqe,{props:this.props,clipPathId:v}))),w.createElement(uL,{activeDot:this.props.activeDot,points:r,mainColor:this.props.stroke,itemDataKey:this.props.dataKey,clipPath:P}))}}var gae={activeDot:!0,animateNewValues:!0,animationBegin:0,animationDuration:1500,animationEasing:\"ease\",connectNulls:!1,dot:!0,fill:\"#fff\",hide:!1,isAnimationActive:\"auto\",label:!1,legendType:\"line\",stroke:\"#3182bd\",strokeWidth:1,xAxisId:0,yAxisId:0,zIndex:Ja.line,type:\"linear\"};function mqe(t){var e=Ia(t,gae),{activeDot:n,animateNewValues:r,animationBegin:i,animationDuration:s,animationEasing:o,connectNulls:l,dot:c,hide:d,isAnimationActive:u,label:m,legendType:p,xAxisId:f,yAxisId:y,id:v}=e,b=bz(e,Y6e),{needClip:g}=JT(f,y),_=ZT(),C=jp(),P=Li(),N=sn(D=>P6e(D,f,y,P,v));if(C!==\"horizontal\"&&C!==\"vertical\"||N==null||_==null)return null;var{height:A,width:T,x:F,y:k}=_;return w.createElement(uqe,Dw({},b,{id:v,connectNulls:l,dot:c,activeDot:n,animateNewValues:r,animationBegin:i,animationDuration:s,animationEasing:o,isAnimationActive:u,hide:d,label:m,legendType:p,xAxisId:f,yAxisId:y,points:N,layout:C,height:A,width:T,left:F,top:k,needClip:g}))}function hqe(t){var{layout:e,xAxis:n,yAxis:r,xAxisTicks:i,yAxisTicks:s,dataKey:o,bandSize:l,displayedData:c}=t;return c.map((d,u)=>{var m=zr(d,o);if(e===\"horizontal\"){var p=rC({axis:n,ticks:i,bandSize:l,entry:d,index:u}),f=Ea(m)?null:r.scale.map(m);return{x:p,y:f??null,value:m,payload:d}}var y=Ea(m)?null:n.scale.map(m),v=rC({axis:r,ticks:s,bandSize:l,entry:d,index:u});return y==null||v==null?null:{x:y,y:v,value:m,payload:d}}).filter(Boolean)}function pqe(t){var e=Ia(t,gae),n=Li();return w.createElement(YT,{id:e.id,type:\"line\"},r=>w.createElement(w.Fragment,null,w.createElement(mz,{legendPayload:tqe(e)}),w.createElement(nqe,{dataKey:e.dataKey,data:e.data,stroke:e.stroke,strokeWidth:e.strokeWidth,fill:e.fill,name:e.name,hide:e.hide,unit:e.unit,tooltipType:e.tooltipType,id:r}),w.createElement(hz,{type:\"line\",id:r,data:e.data,xAxisId:e.xAxisId,yAxisId:e.yAxisId,zAxisId:0,dataKey:e.dataKey,hide:e.hide,isPanorama:n}),w.createElement(mqe,Dw({},e,{id:r}))))}var bae=w.memo(pqe,DS);bae.displayName=\"Line\";function Jd(t,e){var n,r;return(n=(r=t.graphicalItems.cartesianItems.find(i=>i.id===e))===null||r===void 0?void 0:r.xAxisId)!==null&&n!==void 0?n:eae}function eu(t,e){var n,r;return(n=(r=t.graphicalItems.cartesianItems.find(i=>i.id===e))===null||r===void 0?void 0:r.yAxisId)!==null&&n!==void 0?n:eae}var xae=(t,e,n)=>Gd(t,\"xAxis\",Jd(t,e),n),yae=(t,e,n)=>Vd(t,\"xAxis\",Jd(t,e),n),vae=(t,e,n)=>Gd(t,\"yAxis\",eu(t,e),n),wae=(t,e,n)=>Vd(t,\"yAxis\",eu(t,e),n),fqe=wt([Sr,xae,vae,yae,wae],(t,e,n,r,i)=>ad(t,\"xAxis\")?wp(e,r,!1):wp(n,i,!1)),gqe=(t,e)=>e,Sae=wt([PS,gqe],(t,e)=>t.filter(n=>n.type===\"area\").find(n=>n.id===e)),_ae=t=>{var e=Sr(t),n=ad(e,\"xAxis\");return n?\"yAxis\":\"xAxis\"},bqe=(t,e)=>{var n=_ae(t);return n===\"yAxis\"?eu(t,e):Jd(t,e)},xqe=(t,e,n)=>CC(t,_ae(t),bqe(t,e),n),yqe=wt([Sae,xqe],(t,e)=>{var n;if(!(t==null||e==null)){var{stackId:r}=t,i=LT(t);if(!(r==null||i==null)){var s=(n=e[r])===null||n===void 0?void 0:n.stackedData,o=s?.find(l=>l.key===i);if(o!=null)return o.map(l=>[l[0],l[1]])}}}),vqe=wt([Sr,xae,vae,yae,wae,yqe,lne,fqe,Sae,MIe],(t,e,n,r,i,s,o,l,c,d)=>{var{chartData:u,dataStartIndex:m,dataEndIndex:p}=o;if(!(c==null||t!==\"horizontal\"&&t!==\"vertical\"||e==null||n==null||r==null||i==null||r.length===0||i.length===0||l==null)){var{data:f}=c,y;if(f&&f.length>0?y=f:y=u?.slice(m,p+1),y!=null)return zqe({layout:t,xAxis:e,yAxis:n,xAxisTicks:r,yAxisTicks:i,dataStartIndex:m,areaSettings:c,stackedData:s,displayedData:y,chartBaseValue:d,bandSize:l})}}),wqe=[\"id\"],Sqe=[\"activeDot\",\"animationBegin\",\"animationDuration\",\"animationEasing\",\"connectNulls\",\"dot\",\"fill\",\"fillOpacity\",\"hide\",\"isAnimationActive\",\"legendType\",\"stroke\",\"xAxisId\",\"yAxisId\"];function eg(){return eg=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},eg.apply(null,arguments)}function kae(t,e){if(t==null)return{};var n,r,i=_qe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function _qe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function jW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function kx(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?jW(Object(n),!0).forEach(function(r){kqe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):jW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function kqe(t,e,n){return(e=Nqe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function Nqe(t){var e=Cqe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function Cqe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function RC(t,e){return t&&t!==\"none\"?t:e}var Pqe=t=>{var{dataKey:e,name:n,stroke:r,fill:i,legendType:s,hide:o}=t;return[{inactive:o,dataKey:e,type:s,color:RC(r,i),value:Ap(n,e),payload:t}]},Tqe=w.memo(t=>{var{dataKey:e,data:n,stroke:r,strokeWidth:i,fill:s,name:o,hide:l,unit:c,tooltipType:d,id:u}=t,m={dataDefinedOnItem:n,getPosition:Tp,settings:{stroke:r,strokeWidth:i,fill:s,dataKey:e,nameKey:void 0,name:Ap(o,e),hide:l,type:d,color:RC(r,s),unit:c,graphicalItemId:u}};return w.createElement(XT,{tooltipEntrySettings:m})});function Aqe(t){var{clipPathId:e,points:n,props:r}=t,{needClip:i,dot:s,dataKey:o}=r,l=mo(r);return w.createElement(Jre,{points:n,dot:s,className:\"recharts-area-dots\",dotClassName:\"recharts-area-dot\",dataKey:o,baseProps:l,needClip:i,clipPathId:e})}function jqe(t){var{showLabels:e,children:n,points:r}=t,i=r.map(s=>{var o,l,c={x:(o=s.x)!==null&&o!==void 0?o:0,y:(l=s.y)!==null&&l!==void 0?l:0,width:0,lowerWidth:0,upperWidth:0,height:0};return kx(kx({},c),{},{value:s.value,payload:s.payload,parentViewBox:void 0,viewBox:c,fill:void 0})});return w.createElement(rz,{value:e?i:void 0},n)}function MW(t){var{points:e,baseLine:n,needClip:r,clipPathId:i,props:s}=t,{layout:o,type:l,stroke:c,connectNulls:d,isRange:u}=s,{id:m}=s,p=kae(s,wqe),f=mo(p),y=Cs(p);return w.createElement(w.Fragment,null,e?.length>1&&w.createElement(Oa,{clipPath:r?\"url(#clipPath-\".concat(i,\")\"):void 0},w.createElement(Rx,eg({},y,{id:m,points:e,connectNulls:d,type:l,baseLine:n,layout:o,stroke:\"none\",className:\"recharts-area-area\"})),c!==\"none\"&&w.createElement(Rx,eg({},f,{className:\"recharts-area-curve\",layout:o,type:l,connectNulls:d,fill:\"none\",points:e})),c!==\"none\"&&u&&w.createElement(Rx,eg({},f,{className:\"recharts-area-curve\",layout:o,type:l,connectNulls:d,fill:\"none\",points:n}))),w.createElement(Aqe,{points:e,props:p,clipPathId:i}))}function Mqe(t){var e,n,{alpha:r,baseLine:i,points:s,strokeWidth:o}=t,l=(e=s[0])===null||e===void 0?void 0:e.y,c=(n=s[s.length-1])===null||n===void 0?void 0:n.y;if(!Gn(l)||!Gn(c))return null;var d=r*Math.abs(l-c),u=Math.max(...s.map(m=>m.x||0));return tn(i)?u=Math.max(i,u):i&&Array.isArray(i)&&i.length&&(u=Math.max(...i.map(m=>m.x||0),u)),tn(u)?w.createElement(\"rect\",{x:0,y:l<c?l:l-d,width:u+(o?parseInt(\"\".concat(o),10):1),height:Math.floor(d)}):null}function Eqe(t){var e,n,{alpha:r,baseLine:i,points:s,strokeWidth:o}=t,l=(e=s[0])===null||e===void 0?void 0:e.x,c=(n=s[s.length-1])===null||n===void 0?void 0:n.x;if(!Gn(l)||!Gn(c))return null;var d=r*Math.abs(l-c),u=Math.max(...s.map(m=>m.y||0));return tn(i)?u=Math.max(i,u):i&&Array.isArray(i)&&i.length&&(u=Math.max(...i.map(m=>m.y||0),u)),tn(u)?w.createElement(\"rect\",{x:l<c?l:l-d,y:0,width:d,height:Math.floor(u+(o?parseInt(\"\".concat(o),10):1))}):null}function Dqe(t){var{alpha:e,layout:n,points:r,baseLine:i,strokeWidth:s}=t;return n===\"vertical\"?w.createElement(Mqe,{alpha:e,points:r,baseLine:i,strokeWidth:s}):w.createElement(Eqe,{alpha:e,points:r,baseLine:i,strokeWidth:s})}function Fqe(t){var{needClip:e,clipPathId:n,props:r,previousPointsRef:i,previousBaselineRef:s}=t,{points:o,baseLine:l,isAnimationActive:c,animationBegin:d,animationDuration:u,animationEasing:m,onAnimationStart:p,onAnimationEnd:f}=r,y=w.useMemo(()=>({points:o,baseLine:l}),[o,l]),v=Ay(y,\"recharts-area-\"),b=$I(),[g,_]=w.useState(!1),C=!g,P=w.useCallback(()=>{typeof f==\"function\"&&f(),_(!1)},[f]),N=w.useCallback(()=>{typeof p==\"function\"&&p(),_(!0)},[p]);if(b==null)return null;var A=i.current,T=s.current;return w.createElement(jqe,{showLabels:C,points:o},r.children,w.createElement(Ty,{animationId:v,begin:d,duration:u,isActive:c,easing:m,onAnimationEnd:P,onAnimationStart:N,key:v},F=>{if(A){var k=A.length/o.length,D=F===1?o:o.map((z,Q)=>{var L=Math.floor(Q*k);if(A[L]){var te=A[L];return kx(kx({},z),{},{x:Ir(te.x,z.x,F),y:Ir(te.y,z.y,F)})}return z}),H;return tn(l)?H=Ir(T,l,F):Ea(l)||Zc(l)?H=Ir(T,0,F):H=l.map((z,Q)=>{var L=Math.floor(Q*k);if(Array.isArray(T)&&T[L]){var te=T[L];return kx(kx({},z),{},{x:Ir(te.x,z.x,F),y:Ir(te.y,z.y,F)})}return z}),F>0&&(i.current=D,s.current=H),w.createElement(MW,{points:D,baseLine:H,needClip:e,clipPathId:n,props:r})}return F>0&&(i.current=o,s.current=l),w.createElement(Oa,null,c&&w.createElement(\"defs\",null,w.createElement(\"clipPath\",{id:\"animationClipPath-\".concat(n)},w.createElement(Dqe,{alpha:F,points:o,baseLine:l,layout:b,strokeWidth:r.strokeWidth}))),w.createElement(Oa,{clipPath:\"url(#animationClipPath-\".concat(n,\")\")},w.createElement(MW,{points:o,baseLine:l,needClip:e,clipPathId:n,props:r})))}),w.createElement(GT,{label:r.label}))}function Rqe(t){var{needClip:e,clipPathId:n,props:r}=t,i=w.useRef(null),s=w.useRef();return w.createElement(Fqe,{needClip:e,clipPathId:n,props:r,previousPointsRef:i,previousBaselineRef:s})}class Lqe extends w.PureComponent{render(){var{hide:e,dot:n,points:r,className:i,top:s,left:o,needClip:l,xAxisId:c,yAxisId:d,width:u,height:m,id:p,baseLine:f,zIndex:y}=this.props;if(e)return null;var v=_r(\"recharts-area\",i),b=p,{r:g,strokeWidth:_}=pae(n),C=oz(n),P=g*2+_,N=l?\"url(#clipPath-\".concat(C?\"\":\"dots-\").concat(b,\")\"):void 0;return w.createElement(Gs,{zIndex:y},w.createElement(Oa,{className:v},l&&w.createElement(\"defs\",null,w.createElement(gz,{clipPathId:b,xAxisId:c,yAxisId:d}),!C&&w.createElement(\"clipPath\",{id:\"clipPath-dots-\".concat(b)},w.createElement(\"rect\",{x:o-P/2,y:s-P/2,width:u+P,height:m+P}))),w.createElement(Rqe,{needClip:l,clipPathId:b,props:this.props})),w.createElement(uL,{points:r,mainColor:RC(this.props.stroke,this.props.fill),itemDataKey:this.props.dataKey,activeDot:this.props.activeDot,clipPath:N}),this.props.isRange&&Array.isArray(f)&&w.createElement(uL,{points:f,mainColor:RC(this.props.stroke,this.props.fill),itemDataKey:this.props.dataKey,activeDot:this.props.activeDot,clipPath:N}))}}var Nae={activeDot:!0,animationBegin:0,animationDuration:1500,animationEasing:\"ease\",connectNulls:!1,dot:!1,fill:\"#3182bd\",fillOpacity:.6,hide:!1,isAnimationActive:\"auto\",legendType:\"line\",stroke:\"#3182bd\",strokeWidth:1,type:\"linear\",label:!1,xAxisId:0,yAxisId:0,zIndex:Ja.area};function Oqe(t){var e,n=Ia(t,Nae),{activeDot:r,animationBegin:i,animationDuration:s,animationEasing:o,connectNulls:l,dot:c,fill:d,fillOpacity:u,hide:m,isAnimationActive:p,legendType:f,stroke:y,xAxisId:v,yAxisId:b}=n,g=kae(n,Sqe),_=jp(),C=_re(),{needClip:P}=JT(v,b),N=Li(),{points:A,isRange:T,baseLine:F}=(e=sn(L=>vqe(L,t.id,N)))!==null&&e!==void 0?e:{},k=ZT();if(_!==\"horizontal\"&&_!==\"vertical\"||k==null||C!==\"AreaChart\"&&C!==\"ComposedChart\")return null;var{height:D,width:H,x:z,y:Q}=k;return!A||!A.length?null:w.createElement(Lqe,eg({},g,{activeDot:r,animationBegin:i,animationDuration:s,animationEasing:o,baseLine:F,connectNulls:l,dot:c,fill:d,fillOpacity:u,height:D,hide:m,layout:_,isAnimationActive:p===\"auto\"?!vS.isSsr:p,isRange:T,legendType:f,needClip:P,points:A,stroke:y,width:H,left:z,top:Q,xAxisId:v,yAxisId:b}))}var Iqe=(t,e,n,r,i)=>{var s=n??e;if(tn(s))return s;var o=t===\"horizontal\"?i:r,l=o.scale.domain();if(o.type===\"number\"){var c=Math.max(l[0],l[1]),d=Math.min(l[0],l[1]);return s===\"dataMin\"?d:s===\"dataMax\"||c<0?c:Math.max(Math.min(l[0],l[1]),0)}return s===\"dataMin\"?l[0]:s===\"dataMax\"?l[1]:l[0]};function zqe(t){var{areaSettings:{connectNulls:e,baseValue:n,dataKey:r},stackedData:i,layout:s,chartBaseValue:o,xAxis:l,yAxis:c,displayedData:d,dataStartIndex:u,xAxisTicks:m,yAxisTicks:p,bandSize:f}=t,y=i&&i.length,v=Iqe(s,o,n,l,c),b=s===\"horizontal\",g=!1,_=d.map((P,N)=>{var A,T,F,k;if(y)k=i[u+N];else{var D=zr(P,r);Array.isArray(D)?(k=D,g=!0):k=[v,D]}var H=(A=(T=k)===null||T===void 0?void 0:T[1])!==null&&A!==void 0?A:null,z=H==null||y&&!e&&zr(P,r)==null;if(b){var Q;return{x:rC({axis:l,ticks:m,bandSize:f,entry:P,index:N}),y:z?null:(Q=c.scale.map(H))!==null&&Q!==void 0?Q:null,value:k,payload:P}}return{x:z?null:(F=l.scale.map(H))!==null&&F!==void 0?F:null,y:rC({axis:c,ticks:p,bandSize:f,entry:P,index:N}),value:k,payload:P}}),C;return y||g?C=_.map(P=>{var N,A=Array.isArray(P.value)?P.value[0]:null;if(b){var T;return{x:P.x,y:A!=null&&P.y!=null&&(T=c.scale.map(A))!==null&&T!==void 0?T:null,payload:P.payload}}return{x:A!=null&&(N=l.scale.map(A))!==null&&N!==void 0?N:null,y:P.y,payload:P.payload}}):C=b?c.scale.map(v):l.scale.map(v),{points:_,baseLine:C??0,isRange:g}}function Uqe(t){var e=Ia(t,Nae),n=Li();return w.createElement(YT,{id:e.id,type:\"area\"},r=>w.createElement(w.Fragment,null,w.createElement(mz,{legendPayload:Pqe(e)}),w.createElement(Tqe,{dataKey:e.dataKey,data:e.data,stroke:e.stroke,strokeWidth:e.strokeWidth,fill:e.fill,name:e.name,hide:e.hide,unit:e.unit,tooltipType:e.tooltipType,id:r}),w.createElement(hz,{type:\"area\",id:r,data:e.data,dataKey:e.dataKey,xAxisId:e.xAxisId,yAxisId:e.yAxisId,zAxisId:0,stackId:Zee(e.stackId),hide:e.hide,barSize:void 0,baseValue:e.baseValue,isPanorama:n,connectNulls:e.connectNulls}),w.createElement(Oqe,eg({},e,{id:r}))))}var Cae=w.memo(Uqe,DS);Cae.displayName=\"Area\";var Bqe=\"Invariant failed\";function Hqe(t,e){throw new Error(Bqe)}function mL(){return mL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},mL.apply(null,arguments)}function LC(t){return w.createElement(lz,mL({shapeType:\"rectangle\",activeClassName:\"recharts-active-bar\"},t))}var qqe=function(e){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:0;return(r,i)=>{if(tn(e))return e;var s=tn(r)||Ea(r);return s?e(r,i):(s||Hqe(),n)}},$qe=(t,e,n)=>n,Vqe=(t,e)=>e,FS=wt([PS,Vqe],(t,e)=>t.filter(n=>n.type===\"bar\").find(n=>n.id===e)),Gqe=wt([FS],t=>t?.maxBarSize),Wqe=(t,e,n,r)=>r,Kqe=wt([Sr,PS,Jd,eu,$qe],(t,e,n,r,i)=>e.filter(s=>t===\"horizontal\"?s.xAxisId===n:s.yAxisId===r).filter(s=>s.isPanorama===i).filter(s=>s.hide===!1).filter(s=>s.type===\"bar\")),Xqe=(t,e,n)=>{var r=Sr(t),i=Jd(t,e),s=eu(t,e);if(!(i==null||s==null))return r===\"horizontal\"?CC(t,\"yAxis\",s,n):CC(t,\"xAxis\",i,n)},Yqe=(t,e)=>{var n=Sr(t),r=Jd(t,e),i=eu(t,e);if(!(r==null||i==null))return n===\"horizontal\"?d9(t,\"xAxis\",r):d9(t,\"yAxis\",i)},Qqe=wt([Kqe,jIe,Yqe],Z8e),Zqe=(t,e,n)=>{var r,i,s=FS(t,e);if(s==null)return 0;var o=Jd(t,e),l=eu(t,e);if(o==null||l==null)return 0;var c=Sr(t),d=vne(t),{maxBarSize:u}=s,m=Ea(u)?d:u,p,f;return c===\"horizontal\"?(p=Gd(t,\"xAxis\",o,n),f=Vd(t,\"xAxis\",o,n)):(p=Gd(t,\"yAxis\",l,n),f=Vd(t,\"yAxis\",l,n)),(r=(i=wp(p,f,!0))!==null&&i!==void 0?i:m)!==null&&r!==void 0?r:0},Pae=(t,e,n)=>{var r=Sr(t),i=Jd(t,e),s=eu(t,e);if(!(i==null||s==null)){var o,l;return r===\"horizontal\"?(o=Gd(t,\"xAxis\",i,n),l=Vd(t,\"xAxis\",i,n)):(o=Gd(t,\"yAxis\",s,n),l=Vd(t,\"yAxis\",s,n)),wp(o,l)}},Jqe=wt([Qqe,vne,AIe,wne,Zqe,Pae,Gqe],rHe),e$e=(t,e,n)=>{var r=Jd(t,e);if(r!=null)return Gd(t,\"xAxis\",r,n)},t$e=(t,e,n)=>{var r=eu(t,e);if(r!=null)return Gd(t,\"yAxis\",r,n)},n$e=(t,e,n)=>{var r=Jd(t,e);if(r!=null)return Vd(t,\"xAxis\",r,n)},r$e=(t,e,n)=>{var r=eu(t,e);if(r!=null)return Vd(t,\"yAxis\",r,n)},a$e=wt([Jqe,FS],iHe),i$e=wt([Xqe,FS],aHe),s$e=wt([Ri,zI,e$e,t$e,n$e,r$e,a$e,Sr,lne,Pae,i$e,FS,Wqe],(t,e,n,r,i,s,o,l,c,d,u,m,p)=>{var{chartData:f,dataStartIndex:y,dataEndIndex:v}=c;if(!(m==null||o==null||e==null||l!==\"horizontal\"&&l!==\"vertical\"||n==null||r==null||i==null||s==null||d==null)){var{data:b}=m,g;if(b!=null&&b.length>0?g=b:g=f?.slice(y,v+1),g!=null)return F$e({layout:l,barSettings:m,pos:o,parentViewBox:e,bandSize:d,xAxis:n,yAxis:r,xAxisTicks:i,yAxisTicks:s,stackedData:u,displayedData:g,offset:t,cells:p,dataStartIndex:y})}}),o$e=[\"index\"];function hL(){return hL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},hL.apply(null,arguments)}function l$e(t,e){if(t==null)return{};var n,r,i=c$e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function c$e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var Tae=w.createContext(void 0),d$e=t=>{var e=w.useContext(Tae);if(e!=null)return e.stackId;if(t!=null)return Zee(t)},u$e=(t,e)=>\"recharts-bar-stack-clip-path-\".concat(t,\"-\").concat(e),m$e=t=>{var e=w.useContext(Tae);if(e!=null){var{stackId:n}=e;return\"url(#\".concat(u$e(n,t),\")\")}},Aae=t=>{var{index:e}=t,n=l$e(t,o$e),r=m$e(e);return w.createElement(Oa,hL({className:\"recharts-bar-stack-layer\",clipPath:r},n))},h$e=[\"onMouseEnter\",\"onMouseLeave\",\"onClick\"],p$e=[\"value\",\"background\",\"tooltipPosition\"],f$e=[\"id\"],g$e=[\"onMouseEnter\",\"onClick\",\"onMouseLeave\"];function Am(){return Am=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},Am.apply(null,arguments)}function EW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function ro(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?EW(Object(n),!0).forEach(function(r){b$e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):EW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function b$e(t,e,n){return(e=x$e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function x$e(t){var e=y$e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function y$e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function OC(t,e){if(t==null)return{};var n,r,i=v$e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function v$e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var w$e=t=>{var{dataKey:e,name:n,fill:r,legendType:i,hide:s}=t;return[{inactive:s,dataKey:e,type:i,color:r,value:Ap(n,e),payload:t}]},S$e=w.memo(t=>{var{dataKey:e,stroke:n,strokeWidth:r,fill:i,name:s,hide:o,unit:l,tooltipType:c,id:d}=t,u={dataDefinedOnItem:void 0,getPosition:Tp,settings:{stroke:n,strokeWidth:r,fill:i,dataKey:e,nameKey:void 0,name:Ap(s,e),hide:o,type:c,color:i,unit:l,graphicalItemId:d}};return w.createElement(XT,{tooltipEntrySettings:u})});function _$e(t){var e=sn(Sp),{data:n,dataKey:r,background:i,allOtherBarProps:s}=t,{onMouseEnter:o,onMouseLeave:l,onClick:c}=s,d=OC(s,h$e),u=cz(o,r,s.id),m=dz(l),p=uz(c,r,s.id);if(!i||n==null)return null;var f=vg(i);return w.createElement(Gs,{zIndex:sHe(i,Ja.barBackground)},n.map((y,v)=>{var{value:b,background:g,tooltipPosition:_}=y,C=OC(y,p$e);if(!g)return null;var P=u(y,v),N=m(y,v),A=p(y,v),T=ro(ro(ro(ro(ro({option:i,isActive:String(v)===e},C),{},{fill:\"#eee\"},g),f),hS(d,y,v)),{},{onMouseEnter:P,onMouseLeave:N,onClick:A,dataKey:r,index:v,className:\"recharts-bar-background-rectangle\"});return w.createElement(LC,Am({key:\"background-bar-\".concat(v)},T))}))}function k$e(t){var{showLabels:e,children:n,rects:r}=t,i=r?.map(s=>{var o={x:s.x,y:s.y,width:s.width,lowerWidth:s.width,upperWidth:s.width,height:s.height};return ro(ro({},o),{},{value:s.value,payload:s.payload,parentViewBox:s.parentViewBox,viewBox:o,fill:s.fill})});return w.createElement(rz,{value:e?i:void 0},n)}function N$e(t){var{shape:e,activeBar:n,baseProps:r,entry:i,index:s,dataKey:o}=t,l=sn(Sp),c=sn(Q4),d=n&&String(s)===l&&(c==null||o===c),u=d?n:e;return d?w.createElement(Gs,{zIndex:Ja.activeBar},w.createElement(Aae,{index:s},w.createElement(LC,Am({},r,{name:String(r.name)},i,{isActive:d,option:u,index:s,dataKey:o})))):w.createElement(LC,Am({},r,{name:String(r.name)},i,{isActive:d,option:u,index:s,dataKey:o}))}function C$e(t){var{shape:e,baseProps:n,entry:r,index:i,dataKey:s}=t;return w.createElement(LC,Am({},n,{name:String(n.name)},r,{isActive:!1,option:e,index:i,dataKey:s}))}function P$e(t){var e,{data:n,props:r}=t,i=(e=mo(r))!==null&&e!==void 0?e:{},{id:s}=i,o=OC(i,f$e),{shape:l,dataKey:c,activeBar:d}=r,{onMouseEnter:u,onClick:m,onMouseLeave:p}=r,f=OC(r,g$e),y=cz(u,c,s),v=dz(p),b=uz(m,c,s);return n?w.createElement(w.Fragment,null,n.map((g,_)=>w.createElement(Aae,Am({index:_,key:\"rectangle-\".concat(g?.x,\"-\").concat(g?.y,\"-\").concat(g?.value,\"-\").concat(_),className:\"recharts-bar-rectangle\"},hS(f,g,_),{onMouseEnter:y(g,_),onMouseLeave:v(g,_),onClick:b(g,_)}),d?w.createElement(N$e,{shape:l,activeBar:d,baseProps:o,entry:g,index:_,dataKey:c}):w.createElement(C$e,{shape:l,baseProps:o,entry:g,index:_,dataKey:c})))):null}function T$e(t){var{props:e,previousRectanglesRef:n}=t,{data:r,layout:i,isAnimationActive:s,animationBegin:o,animationDuration:l,animationEasing:c,onAnimationEnd:d,onAnimationStart:u}=e,m=n.current,p=Ay(e,\"recharts-bar-\"),[f,y]=w.useState(!1),v=!f,b=w.useCallback(()=>{typeof d==\"function\"&&d(),y(!1)},[d]),g=w.useCallback(()=>{typeof u==\"function\"&&u(),y(!0)},[u]);return w.createElement(k$e,{showLabels:v,rects:r},w.createElement(Ty,{animationId:p,begin:o,duration:l,isActive:s,easing:c,onAnimationEnd:b,onAnimationStart:g,key:p},_=>{var C=_===1?r:r?.map((P,N)=>{var A=m&&m[N];if(A)return ro(ro({},P),{},{x:Ir(A.x,P.x,_),y:Ir(A.y,P.y,_),width:Ir(A.width,P.width,_),height:Ir(A.height,P.height,_)});if(i===\"horizontal\"){var T=Ir(0,P.height,_),F=Ir(P.stackedBarStart,P.y,_);return ro(ro({},P),{},{y:F,height:T})}var k=Ir(0,P.width,_),D=Ir(P.stackedBarStart,P.x,_);return ro(ro({},P),{},{width:k,x:D})});return _>0&&(n.current=C??null),C==null?null:w.createElement(Oa,null,w.createElement(P$e,{props:e,data:C}))}),w.createElement(GT,{label:e.label}),e.children)}function A$e(t){var e=w.useRef(null);return w.createElement(T$e,{previousRectanglesRef:e,props:t})}var jae=0,j$e=(t,e)=>{var n=Array.isArray(t.value)?t.value[1]:t.value;return{x:t.x,y:t.y,value:n,errorVal:zr(t,e)}};class M$e extends w.PureComponent{render(){var{hide:e,data:n,dataKey:r,className:i,xAxisId:s,yAxisId:o,needClip:l,background:c,id:d}=this.props;if(e||n==null)return null;var u=_r(\"recharts-bar\",i),m=d;return w.createElement(Oa,{className:u,id:d},l&&w.createElement(\"defs\",null,w.createElement(gz,{clipPathId:m,xAxisId:s,yAxisId:o})),w.createElement(Oa,{className:\"recharts-bar-rectangles\",clipPath:l?\"url(#clipPath-\".concat(m,\")\"):void 0},w.createElement(_$e,{data:n,dataKey:r,background:c,allOtherBarProps:this.props}),w.createElement(A$e,this.props)))}}var E$e={activeBar:!1,animationBegin:0,animationDuration:400,animationEasing:\"ease\",background:!1,hide:!1,isAnimationActive:\"auto\",label:!1,legendType:\"rect\",minPointSize:jae,xAxisId:0,yAxisId:0,zIndex:Ja.bar};function D$e(t){var{xAxisId:e,yAxisId:n,hide:r,legendType:i,minPointSize:s,activeBar:o,animationBegin:l,animationDuration:c,animationEasing:d,isAnimationActive:u}=t,{needClip:m}=JT(e,n),p=jp(),f=Li(),y=sz(t.children,oy),v=sn(_=>s$e(_,t.id,f,y));if(p!==\"vertical\"&&p!==\"horizontal\")return null;var b,g=v?.[0];return g==null||g.height==null||g.width==null?b=0:b=p===\"vertical\"?g.height/2:g.width/2,w.createElement(cae,{xAxisId:e,yAxisId:n,data:v,dataPointFormatter:j$e,errorBarOffset:b},w.createElement(M$e,Am({},t,{layout:p,needClip:m,data:v,xAxisId:e,yAxisId:n,hide:r,legendType:i,minPointSize:s,activeBar:o,animationBegin:l,animationDuration:c,animationEasing:d,isAnimationActive:u})))}function F$e(t){var{layout:e,barSettings:{dataKey:n,minPointSize:r},pos:i,bandSize:s,xAxis:o,yAxis:l,xAxisTicks:c,yAxisTicks:d,stackedData:u,displayedData:m,offset:p,cells:f,parentViewBox:y,dataStartIndex:v}=t,b=e===\"horizontal\"?l:o,g=u?b.scale.domain():null,_=b3e({numericAxis:b}),C=b.scale.map(_);return m.map((P,N)=>{var A,T,F,k,D,H;if(u){var z=u[N+v];if(z==null)return null;A=m3e(z,g)}else A=zr(P,n),Array.isArray(A)||(A=[_,A]);var Q=qqe(r,jae)(A[1],N);if(e===\"horizontal\"){var L,te=l.scale.map(A[0]),ie=l.scale.map(A[1]);if(te==null||ie==null)return null;T=c7({axis:o,ticks:c,bandSize:s,offset:i.offset,entry:P,index:N}),F=(L=ie??te)!==null&&L!==void 0?L:void 0,k=i.size;var J=te-ie;if(D=Zc(J)?0:J,H={x:T,y:p.top,width:k,height:p.height},Math.abs(Q)>0&&Math.abs(D)<Math.abs(Q)){var oe=Qi(D||Q)*(Math.abs(Q)-Math.abs(D));F-=oe,D+=oe}}else{var fe=o.scale.map(A[0]),re=o.scale.map(A[1]);if(fe==null||re==null)return null;if(T=fe,F=c7({axis:l,ticks:d,bandSize:s,offset:i.offset,entry:P,index:N}),k=re-fe,D=i.size,H={x:p.left,y:F,width:p.width,height:D},Math.abs(Q)>0&&Math.abs(k)<Math.abs(Q)){var W=Qi(k||Q)*(Math.abs(Q)-Math.abs(k));k+=W}}if(T==null||F==null||k==null||D==null)return null;var ne=ro(ro({},P),{},{stackedBarStart:C,x:T,y:F,width:k,height:D,value:u?A:A[1],payload:P,background:H,tooltipPosition:{x:T+k/2,y:F+D/2},parentViewBox:y},f&&f[N]&&f[N].props);return ne}).filter(Boolean)}function R$e(t){var e=Ia(t,E$e),n=d$e(e.stackId),r=Li();return w.createElement(YT,{id:e.id,type:\"bar\"},i=>w.createElement(w.Fragment,null,w.createElement(mz,{legendPayload:w$e(e)}),w.createElement(S$e,{dataKey:e.dataKey,stroke:e.stroke,strokeWidth:e.strokeWidth,fill:e.fill,name:e.name,hide:e.hide,unit:e.unit,tooltipType:e.tooltipType,id:i}),w.createElement(hz,{type:\"bar\",id:i,data:void 0,xAxisId:e.xAxisId,yAxisId:e.yAxisId,zAxisId:0,dataKey:e.dataKey,stackId:n,hide:e.hide,barSize:e.barSize,minPointSize:e.minPointSize,maxBarSize:e.maxBarSize,isPanorama:r}),w.createElement(Gs,{zIndex:e.zIndex},w.createElement(D$e,Am({},e,{id:i})))))}var px=w.memo(R$e,DS);px.displayName=\"Bar\";var L$e=[\"domain\",\"range\"],O$e=[\"domain\",\"range\"];function DW(t,e){if(t==null)return{};var n,r,i=I$e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function I$e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function FW(t,e){return t===e?!0:Array.isArray(t)&&t.length===2&&Array.isArray(e)&&e.length===2?t[0]===e[0]&&t[1]===e[1]:!1}function Mae(t,e){if(t===e)return!0;var{domain:n,range:r}=t,i=DW(t,L$e),{domain:s,range:o}=e,l=DW(e,O$e);return!FW(n,s)||!FW(r,o)?!1:DS(i,l)}var z$e=[\"type\"],U$e=[\"dangerouslySetInnerHTML\",\"ticks\",\"scale\"],B$e=[\"id\",\"scale\"];function pL(){return pL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},pL.apply(null,arguments)}function RW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function LW(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?RW(Object(n),!0).forEach(function(r){H$e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):RW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function H$e(t,e,n){return(e=q$e(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function q$e(t){var e=$$e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function $$e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function fL(t,e){if(t==null)return{};var n,r,i=V$e(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function V$e(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function G$e(t){var e=ua(),n=w.useRef(null),r=$I(),{type:i}=t,s=fL(t,z$e),o=FT(r,\"xAxis\",i),l=w.useMemo(()=>{if(o!=null)return LW(LW({},s),{},{type:o})},[s,o]);return w.useLayoutEffect(()=>{l!=null&&(n.current===null?e(O8e(l)):n.current!==l&&e(I8e({prev:n.current,next:l})),n.current=l)},[l,e]),w.useLayoutEffect(()=>()=>{n.current&&(e(z8e(n.current)),n.current=null)},[e]),null}var W$e=t=>{var{xAxisId:e,className:n}=t,r=sn(zI),i=Li(),s=\"xAxis\",o=sn(g=>ere(g,s,e,i)),l=sn(g=>Yne(g,e)),c=sn(g=>M4e(g,e)),d=sn(g=>Mne(g,e));if(l==null||c==null||d==null)return null;var{dangerouslySetInnerHTML:u,ticks:m,scale:p}=t,f=fL(t,U$e),{id:y,scale:v}=d,b=fL(d,B$e);return w.createElement(fz,pL({},f,b,{x:c.x,y:c.y,width:l.width,height:l.height,className:_r(\"recharts-\".concat(s,\" \").concat(s),n),viewBox:r,ticks:o,axisType:s}))},K$e={allowDataOverflow:Gi.allowDataOverflow,allowDecimals:Gi.allowDecimals,allowDuplicatedCategory:Gi.allowDuplicatedCategory,angle:Gi.angle,axisLine:mm.axisLine,height:Gi.height,hide:!1,includeHidden:Gi.includeHidden,interval:Gi.interval,label:!1,minTickGap:Gi.minTickGap,mirror:Gi.mirror,orientation:Gi.orientation,padding:Gi.padding,reversed:Gi.reversed,scale:Gi.scale,tick:Gi.tick,tickCount:Gi.tickCount,tickLine:mm.tickLine,tickSize:mm.tickSize,type:Gi.type,xAxisId:0},X$e=t=>{var e=Ia(t,K$e);return w.createElement(w.Fragment,null,w.createElement(G$e,{allowDataOverflow:e.allowDataOverflow,allowDecimals:e.allowDecimals,allowDuplicatedCategory:e.allowDuplicatedCategory,angle:e.angle,dataKey:e.dataKey,domain:e.domain,height:e.height,hide:e.hide,id:e.xAxisId,includeHidden:e.includeHidden,interval:e.interval,minTickGap:e.minTickGap,mirror:e.mirror,name:e.name,orientation:e.orientation,padding:e.padding,reversed:e.reversed,scale:e.scale,tick:e.tick,tickCount:e.tickCount,tickFormatter:e.tickFormatter,ticks:e.ticks,type:e.type,unit:e.unit}),w.createElement(W$e,e))},Bf=w.memo(X$e,Mae);Bf.displayName=\"XAxis\";var Y$e=[\"type\"],Q$e=[\"dangerouslySetInnerHTML\",\"ticks\",\"scale\"],Z$e=[\"id\",\"scale\"];function gL(){return gL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},gL.apply(null,arguments)}function OW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function IW(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?OW(Object(n),!0).forEach(function(r){J$e(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):OW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function J$e(t,e,n){return(e=eVe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function eVe(t){var e=tVe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function tVe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function bL(t,e){if(t==null)return{};var n,r,i=nVe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function nVe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function rVe(t){var e=ua(),n=w.useRef(null),r=$I(),{type:i}=t,s=bL(t,Y$e),o=FT(r,\"yAxis\",i),l=w.useMemo(()=>{if(o!=null)return IW(IW({},s),{},{type:o})},[o,s]);return w.useLayoutEffect(()=>{l!=null&&(n.current===null?e(U8e(l)):n.current!==l&&e(B8e({prev:n.current,next:l})),n.current=l)},[l,e]),w.useLayoutEffect(()=>()=>{n.current&&(e(H8e(n.current)),n.current=null)},[e]),null}function aVe(t){var{yAxisId:e,className:n,width:r,label:i}=t,s=w.useRef(null),o=w.useRef(null),l=sn(zI),c=Li(),d=ua(),u=\"yAxis\",m=sn(A=>Qne(A,e)),p=sn(A=>D4e(A,e)),f=sn(A=>ere(A,u,e,c)),y=sn(A=>Ene(A,e));if(w.useLayoutEffect(()=>{if(!(r!==\"auto\"||!m||nz(i)||w.isValidElement(i)||y==null)){var A=s.current;if(A){var T=A.getCalculatedWidth();Math.round(m.width)!==Math.round(T)&&d(q8e({id:e,width:T}))}}},[f,m,d,i,e,r,y]),m==null||p==null||y==null)return null;var{dangerouslySetInnerHTML:v,ticks:b,scale:g}=t,_=bL(t,Q$e),{id:C,scale:P}=y,N=bL(y,Z$e);return w.createElement(fz,gL({},_,N,{ref:s,labelRef:o,x:p.x,y:p.y,tickTextProps:r===\"auto\"?{width:void 0}:{width:r},width:m.width,height:m.height,className:_r(\"recharts-\".concat(u,\" \").concat(u),n),viewBox:l,ticks:f,axisType:u}))}var iVe={allowDataOverflow:Wi.allowDataOverflow,allowDecimals:Wi.allowDecimals,allowDuplicatedCategory:Wi.allowDuplicatedCategory,angle:Wi.angle,axisLine:mm.axisLine,hide:!1,includeHidden:Wi.includeHidden,interval:Wi.interval,label:!1,minTickGap:Wi.minTickGap,mirror:Wi.mirror,orientation:Wi.orientation,padding:Wi.padding,reversed:Wi.reversed,scale:Wi.scale,tick:Wi.tick,tickCount:Wi.tickCount,tickLine:mm.tickLine,tickSize:mm.tickSize,type:Wi.type,width:Wi.width,yAxisId:0},sVe=t=>{var e=Ia(t,iVe);return w.createElement(w.Fragment,null,w.createElement(rVe,{interval:e.interval,id:e.yAxisId,scale:e.scale,type:e.type,domain:e.domain,allowDataOverflow:e.allowDataOverflow,dataKey:e.dataKey,allowDuplicatedCategory:e.allowDuplicatedCategory,allowDecimals:e.allowDecimals,tickCount:e.tickCount,padding:e.padding,includeHidden:e.includeHidden,reversed:e.reversed,ticks:e.ticks,width:e.width,orientation:e.orientation,mirror:e.mirror,hide:e.hide,unit:e.unit,name:e.name,angle:e.angle,minTickGap:e.minTickGap,tick:e.tick,tickFormatter:e.tickFormatter}),w.createElement(aVe,e))},Hf=w.memo(sVe,Mae);Hf.displayName=\"YAxis\";var oVe=(t,e)=>e,xz=wt([oVe,Sr,Tne,ns,bre,Fm,Kze,Ri],t5e),yz=t=>{var e=t.currentTarget.getBoundingClientRect(),n=e.width/t.currentTarget.offsetWidth,r=e.height/t.currentTarget.offsetHeight;return{chartX:Math.round((t.clientX-e.left)/n),chartY:Math.round((t.clientY-e.top)/r)}},Eae=pc(\"mouseClick\"),Dae=fS();Dae.startListening({actionCreator:Eae,effect:(t,e)=>{var n=t.payload,r=xz(e.getState(),yz(n));r?.activeIndex!=null&&e.dispatch(W4e({activeIndex:r.activeIndex,activeDataKey:void 0,activeCoordinate:r.activeCoordinate}))}});var xL=pc(\"mouseMove\"),Fae=fS(),pk=null;Fae.startListening({actionCreator:xL,effect:(t,e)=>{var n=t.payload;pk!==null&&cancelAnimationFrame(pk);var r=yz(n);pk=requestAnimationFrame(()=>{var i=e.getState(),s=V4(i,i.tooltip.settings.shared);if(s===\"axis\"){var o=xz(i,r);o?.activeIndex!=null?e.dispatch(lre({activeIndex:o.activeIndex,activeDataKey:void 0,activeCoordinate:o.activeCoordinate})):e.dispatch(ore())}pk=null})}});function lVe(t,e){return e instanceof HTMLElement?\"HTMLElement <\".concat(e.tagName,' class=\"').concat(e.className,'\">'):e===window?\"global.window\":t===\"children\"&&typeof e==\"object\"&&e!==null?\"<<CHILDREN>>\":e}var zW={accessibilityLayer:!0,barCategoryGap:\"10%\",barGap:4,barSize:void 0,className:void 0,maxBarSize:void 0,stackOffset:\"none\",syncId:void 0,syncMethod:\"index\",baseValue:void 0,reverseStackOrder:!1},Rae=$o({name:\"rootProps\",initialState:zW,reducers:{updateOptions:(t,e)=>{var n;t.accessibilityLayer=e.payload.accessibilityLayer,t.barCategoryGap=e.payload.barCategoryGap,t.barGap=(n=e.payload.barGap)!==null&&n!==void 0?n:zW.barGap,t.barSize=e.payload.barSize,t.maxBarSize=e.payload.maxBarSize,t.stackOffset=e.payload.stackOffset,t.syncId=e.payload.syncId,t.syncMethod=e.payload.syncMethod,t.className=e.payload.className,t.baseValue=e.payload.baseValue,t.reverseStackOrder=e.payload.reverseStackOrder}}}),cVe=Rae.reducer,{updateOptions:dVe}=Rae.actions,uVe=null,mVe={updatePolarOptions:(t,e)=>e.payload},Lae=$o({name:\"polarOptions\",initialState:uVe,reducers:mVe}),{updatePolarOptions:hVe}=Lae.actions,pVe=Lae.reducer,Oae=pc(\"keyDown\"),Iae=pc(\"focus\"),vz=fS();vz.startListening({actionCreator:Oae,effect:(t,e)=>{var n=e.getState(),r=n.rootProps.accessibilityLayer!==!1;if(r){var{keyboardInteraction:i}=n.tooltip,s=t.payload;if(!(s!==\"ArrowRight\"&&s!==\"ArrowLeft\"&&s!==\"Enter\")){var o=G4(i,Iy(n),AS(n),MS(n)),l=o==null?-1:Number(o);if(!(!Number.isFinite(l)||l<0)){var c=Fm(n);if(s===\"Enter\"){var d=TC(n,\"axis\",\"hover\",String(i.index));e.dispatch(iL({active:!i.active,activeIndex:i.index,activeCoordinate:d}));return}var u=O4e(n),m=u===\"left-to-right\"?1:-1,p=s===\"ArrowRight\"?1:-1,f=l+p*m;if(!(c==null||f>=c.length||f<0)){var y=TC(n,\"axis\",\"hover\",String(f));e.dispatch(iL({active:!0,activeIndex:f.toString(),activeCoordinate:y}))}}}}}});vz.startListening({actionCreator:Iae,effect:(t,e)=>{var n=e.getState(),r=n.rootProps.accessibilityLayer!==!1;if(r){var{keyboardInteraction:i}=n.tooltip;if(!i.active&&i.index==null){var s=\"0\",o=TC(n,\"axis\",\"hover\",String(s));e.dispatch(iL({active:!0,activeIndex:s,activeCoordinate:o}))}}}});var Hl=pc(\"externalEvent\"),zae=fS(),AD=new Map;zae.startListening({actionCreator:Hl,effect:(t,e)=>{var{handler:n,reactEvent:r}=t.payload;if(n!=null){r.persist();var i=r.type,s=AD.get(i);s!==void 0&&cancelAnimationFrame(s);var o=requestAnimationFrame(()=>{try{var l=e.getState(),c={activeCoordinate:Dze(l),activeDataKey:Q4(l),activeIndex:Sp(l),activeLabel:vre(l),activeTooltipIndex:Sp(l),isTooltipActive:Fze(l)};n(c,r)}finally{AD.delete(i)}});AD.set(i,o)}}});var fVe=wt([Ly],t=>t.tooltipItemPayloads),gVe=wt([fVe,(t,e)=>e,(t,e,n)=>n],(t,e,n)=>{if(e!=null){var r=t.find(s=>s.settings.graphicalItemId===n);if(r!=null){var{getPosition:i}=r;if(i!=null)return i(e)}}}),Uae=pc(\"touchMove\"),Bae=fS();Bae.startListening({actionCreator:Uae,effect:(t,e)=>{var n=t.payload;if(!(n.touches==null||n.touches.length===0)){var r=e.getState(),i=V4(r,r.tooltip.settings.shared);if(i===\"axis\"){var s=n.touches[0];if(s==null)return;var o=xz(r,yz({clientX:s.clientX,clientY:s.clientY,currentTarget:n.currentTarget}));o?.activeIndex!=null&&e.dispatch(lre({activeIndex:o.activeIndex,activeDataKey:void 0,activeCoordinate:o.activeCoordinate}))}else if(i===\"item\"){var l,c=n.touches[0];if(document.elementFromPoint==null||c==null)return;var d=document.elementFromPoint(c.clientX,c.clientY);if(!d||!d.getAttribute)return;var u=d.getAttribute(ete),m=(l=d.getAttribute(tte))!==null&&l!==void 0?l:void 0,p=Oy(r).find(v=>v.id===m);if(u==null||p==null||m==null)return;var{dataKey:f}=p,y=gVe(r,u,m);e.dispatch(sre({activeDataKey:f,activeIndex:u,activeCoordinate:y,activeGraphicalItemId:m}))}}}});var bVe=wee({brush:oHe,cartesianAxis:$8e,chartData:E5e,errorBars:b6e,graphicalItems:ZBe,layout:o3e,legend:hFe,options:P5e,polarAxis:mBe,polarOptions:pVe,referenceElements:fHe,rootProps:cVe,tooltip:K4e,zIndex:f5e}),xVe=function(e){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:\"Chart\";return EDe({reducer:bVe,preloadedState:e,middleware:r=>{var i;return r({serializableCheck:!1,immutableCheck:![\"commonjs\",\"es6\",\"production\"].includes((i=\"es6\")!==null&&i!==void 0?i:\"\")}).concat([Dae.middleware,Fae.middleware,vz.middleware,zae.middleware,Bae.middleware])},enhancers:r=>{var i=r;return typeof r==\"function\"&&(i=r()),i.concat(Lee({type:\"raf\"}))},devTools:{serialize:{replacer:lVe},name:\"recharts-\".concat(n)}})};function Hae(t){var{preloadedState:e,children:n,reduxStoreName:r}=t,i=Li(),s=w.useRef(null);if(i)return n;s.current==null&&(s.current=xVe(e,r));var o=EI;return w.createElement(V6e,{context:o,store:s.current},n)}function yVe(t){var{layout:e,margin:n}=t,r=ua(),i=Li();return w.useEffect(()=>{i||(r(a3e(e)),r(r3e(n)))},[r,i,e,n]),null}var qae=w.memo(yVe,DS);function $ae(t){var e=ua();return w.useEffect(()=>{e(dVe(t))},[e,t]),null}function UW(t){var{zIndex:e,isPanorama:n}=t,r=w.useRef(null),i=ua();return w.useLayoutEffect(()=>(r.current&&i(h5e({zIndex:e,element:r.current,isPanorama:n})),()=>{i(p5e({zIndex:e,isPanorama:n}))}),[i,e,n]),w.createElement(\"g\",{tabIndex:-1,ref:r})}function BW(t){var{children:e,isPanorama:n}=t,r=sn(r5e);if(!r||r.length===0)return e;var i=r.filter(o=>o<0),s=r.filter(o=>o>0);return w.createElement(w.Fragment,null,i.map(o=>w.createElement(UW,{key:o,zIndex:o,isPanorama:n})),e,s.map(o=>w.createElement(UW,{key:o,zIndex:o,isPanorama:n})))}var vVe=[\"children\"];function wVe(t,e){if(t==null)return{};var n,r,i=SVe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function SVe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}function IC(){return IC=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},IC.apply(null,arguments)}var _Ve={width:\"100%\",height:\"100%\",display:\"block\"},kVe=w.forwardRef((t,e)=>{var n=HI(),r=qI(),i=xte();if(!qd(n)||!qd(r))return null;var{children:s,otherAttributes:o,title:l,desc:c}=t,d,u;return o!=null&&(typeof o.tabIndex==\"number\"?d=o.tabIndex:d=i?0:void 0,typeof o.role==\"string\"?u=o.role:u=i?\"application\":void 0),w.createElement(wI,IC({},o,{title:l,desc:c,role:u,tabIndex:d,width:n,height:r,style:_Ve,ref:e}),s)}),NVe=t=>{var{children:e}=t,n=sn(ST);if(!n)return null;var{width:r,height:i,y:s,x:o}=n;return w.createElement(wI,{width:r,height:i,x:o,y:s},e)},HW=w.forwardRef((t,e)=>{var{children:n}=t,r=wVe(t,vVe),i=Li();return i?w.createElement(NVe,null,w.createElement(BW,{isPanorama:!0},n)):w.createElement(kVe,IC({ref:e},r),w.createElement(BW,{isPanorama:!1},n))});function CVe(){var t=ua(),[e,n]=w.useState(null),r=sn(_3e);return w.useEffect(()=>{if(e!=null){var i=e.getBoundingClientRect(),s=i.width/e.offsetWidth;Gn(s)&&s!==r&&t(s3e(s))}},[e,t,r]),n}function qW(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function PVe(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?qW(Object(n),!0).forEach(function(r){TVe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):qW(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function TVe(t,e,n){return(e=AVe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function AVe(t){var e=jVe(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function jVe(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}function up(){return up=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},up.apply(null,arguments)}var MVe=()=>(B5e(),null);function zC(t){if(typeof t==\"number\")return t;if(typeof t==\"string\"){var e=parseFloat(t);if(!Number.isNaN(e))return e}return 0}var EVe=w.forwardRef((t,e)=>{var n,r,i=w.useRef(null),[s,o]=w.useState({containerWidth:zC((n=t.style)===null||n===void 0?void 0:n.width),containerHeight:zC((r=t.style)===null||r===void 0?void 0:r.height)}),l=w.useCallback((d,u)=>{o(m=>{var p=Math.round(d),f=Math.round(u);return m.containerWidth===p&&m.containerHeight===f?m:{containerWidth:p,containerHeight:f}})},[]),c=w.useCallback(d=>{if(typeof e==\"function\"&&e(d),d!=null&&typeof ResizeObserver<\"u\"){var{width:u,height:m}=d.getBoundingClientRect();l(u,m);var p=y=>{var v=y[0];if(v!=null){var{width:b,height:g}=v.contentRect;l(b,g)}},f=new ResizeObserver(p);f.observe(d),i.current=f}},[e,l]);return w.useEffect(()=>()=>{var d=i.current;d?.disconnect()},[l]),w.createElement(w.Fragment,null,w.createElement(xS,{width:s.containerWidth,height:s.containerHeight}),w.createElement(\"div\",up({ref:c},t)))}),DVe=w.forwardRef((t,e)=>{var{width:n,height:r}=t,[i,s]=w.useState({containerWidth:zC(n),containerHeight:zC(r)}),o=w.useCallback((c,d)=>{s(u=>{var m=Math.round(c),p=Math.round(d);return u.containerWidth===m&&u.containerHeight===p?u:{containerWidth:m,containerHeight:p}})},[]),l=w.useCallback(c=>{if(typeof e==\"function\"&&e(c),c!=null){var{width:d,height:u}=c.getBoundingClientRect();o(d,u)}},[e,o]);return w.createElement(w.Fragment,null,w.createElement(xS,{width:i.containerWidth,height:i.containerHeight}),w.createElement(\"div\",up({ref:l},t)))}),FVe=w.forwardRef((t,e)=>{var{width:n,height:r}=t;return w.createElement(w.Fragment,null,w.createElement(xS,{width:n,height:r}),w.createElement(\"div\",up({ref:e},t)))}),RVe=w.forwardRef((t,e)=>{var{width:n,height:r}=t;return typeof n==\"string\"||typeof r==\"string\"?w.createElement(DVe,up({},t,{ref:e})):typeof n==\"number\"&&typeof r==\"number\"?w.createElement(FVe,up({},t,{width:n,height:r,ref:e})):w.createElement(w.Fragment,null,w.createElement(xS,{width:n,height:r}),w.createElement(\"div\",up({ref:e},t)))});function LVe(t){return t?EVe:RVe}var OVe=w.forwardRef((t,e)=>{var{children:n,className:r,height:i,onClick:s,onContextMenu:o,onDoubleClick:l,onMouseDown:c,onMouseEnter:d,onMouseLeave:u,onMouseMove:m,onMouseUp:p,onTouchEnd:f,onTouchMove:y,onTouchStart:v,style:b,width:g,responsive:_,dispatchTouchEvents:C=!0}=t,P=w.useRef(null),N=ua(),[A,T]=w.useState(null),[F,k]=w.useState(null),D=CVe(),H=UI(),z=H?.width>0?H.width:g,Q=H?.height>0?H.height:i,L=w.useCallback(j=>{D(j),typeof e==\"function\"&&e(j),T(j),k(j),j!=null&&(P.current=j)},[D,e,T,k]),te=w.useCallback(j=>{N(Eae(j)),N(Hl({handler:s,reactEvent:j}))},[N,s]),ie=w.useCallback(j=>{N(xL(j)),N(Hl({handler:d,reactEvent:j}))},[N,d]),J=w.useCallback(j=>{N(ore()),N(Hl({handler:u,reactEvent:j}))},[N,u]),oe=w.useCallback(j=>{N(xL(j)),N(Hl({handler:m,reactEvent:j}))},[N,m]),fe=w.useCallback(()=>{N(Iae())},[N]),re=w.useCallback(j=>{N(Oae(j.key))},[N]),W=w.useCallback(j=>{N(Hl({handler:o,reactEvent:j}))},[N,o]),ne=w.useCallback(j=>{N(Hl({handler:l,reactEvent:j}))},[N,l]),me=w.useCallback(j=>{N(Hl({handler:c,reactEvent:j}))},[N,c]),be=w.useCallback(j=>{N(Hl({handler:p,reactEvent:j}))},[N,p]),Ce=w.useCallback(j=>{N(Hl({handler:v,reactEvent:j}))},[N,v]),q=w.useCallback(j=>{C&&N(Uae(j)),N(Hl({handler:y,reactEvent:j}))},[N,C,y]),Y=w.useCallback(j=>{N(Hl({handler:f,reactEvent:j}))},[N,f]),E=LVe(_);return w.createElement(Tre.Provider,{value:A},w.createElement(OJ.Provider,{value:F},w.createElement(E,{width:z??b?.width,height:Q??b?.height,className:_r(\"recharts-wrapper\",r),style:PVe({position:\"relative\",cursor:\"default\",width:z,height:Q},b),onClick:te,onContextMenu:W,onDoubleClick:ne,onFocus:fe,onKeyDown:re,onMouseDown:me,onMouseEnter:ie,onMouseLeave:J,onMouseMove:oe,onMouseUp:be,onTouchEnd:Y,onTouchMove:q,onTouchStart:Ce,ref:L},w.createElement(MVe,null),n)))}),IVe=[\"width\",\"height\",\"responsive\",\"children\",\"className\",\"style\",\"compact\",\"title\",\"desc\"];function zVe(t,e){if(t==null)return{};var n,r,i=UVe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function UVe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var Vae=w.forwardRef((t,e)=>{var{width:n,height:r,responsive:i,children:s,className:o,style:l,compact:c,title:d,desc:u}=t,m=zVe(t,IVe),p=mo(m);return c?w.createElement(w.Fragment,null,w.createElement(xS,{width:n,height:r}),w.createElement(HW,{otherAttributes:p,title:d,desc:u},s)):w.createElement(OVe,{className:o,style:l,width:n,height:r,responsive:i??!1,onClick:t.onClick,onMouseLeave:t.onMouseLeave,onMouseEnter:t.onMouseEnter,onMouseMove:t.onMouseMove,onMouseDown:t.onMouseDown,onMouseUp:t.onMouseUp,onContextMenu:t.onContextMenu,onDoubleClick:t.onDoubleClick,onTouchStart:t.onTouchStart,onTouchMove:t.onTouchMove,onTouchEnd:t.onTouchEnd},w.createElement(HW,{otherAttributes:p,title:d,desc:u,ref:e},w.createElement(gHe,null,s)))});function yL(){return yL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},yL.apply(null,arguments)}var BVe={top:5,right:5,bottom:5,left:5},HVe={accessibilityLayer:!0,barCategoryGap:\"10%\",barGap:4,layout:\"horizontal\",margin:BVe,responsive:!1,reverseStackOrder:!1,stackOffset:\"none\",syncMethod:\"index\"},wz=w.forwardRef(function(e,n){var r,i=Ia(e.categoricalChartProps,HVe),{chartName:s,defaultTooltipEventType:o,validateTooltipEventTypes:l,tooltipPayloadSearcher:c,categoricalChartProps:d}=e,u={chartName:s,defaultTooltipEventType:o,validateTooltipEventTypes:l,tooltipPayloadSearcher:c,eventEmitter:void 0};return w.createElement(Hae,{preloadedState:{options:u},reduxStoreName:(r=d.id)!==null&&r!==void 0?r:s},w.createElement(nae,{chartData:d.data}),w.createElement(qae,{layout:i.layout,margin:i.margin}),w.createElement($ae,{baseValue:i.baseValue,accessibilityLayer:i.accessibilityLayer,barCategoryGap:i.barCategoryGap,maxBarSize:i.maxBarSize,stackOffset:i.stackOffset,barGap:i.barGap,barSize:i.barSize,syncId:i.syncId,syncMethod:i.syncMethod,className:i.className,reverseStackOrder:i.reverseStackOrder}),w.createElement(Vae,yL({},i,{ref:n})))}),qVe=[\"axis\"],$Ve=w.forwardRef((t,e)=>w.createElement(wz,{chartName:\"LineChart\",defaultTooltipEventType:\"axis\",validateTooltipEventTypes:qVe,tooltipPayloadSearcher:$T,categoricalChartProps:t,ref:e})),VVe=[\"axis\",\"item\"],fk=w.forwardRef((t,e)=>w.createElement(wz,{chartName:\"BarChart\",defaultTooltipEventType:\"axis\",validateTooltipEventTypes:VVe,tooltipPayloadSearcher:$T,categoricalChartProps:t,ref:e}));function GVe(t){var e=ua();return w.useEffect(()=>{e(hVe(t))},[e,t]),null}var WVe=[\"layout\"];function vL(){return vL=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n=arguments[e];for(var r in n)({}).hasOwnProperty.call(n,r)&&(t[r]=n[r])}return t},vL.apply(null,arguments)}function KVe(t,e){if(t==null)return{};var n,r,i=XVe(t,e);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);for(r=0;r<s.length;r++)n=s[r],e.indexOf(n)===-1&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}function XVe(t,e){if(t==null)return{};var n={};for(var r in t)if({}.hasOwnProperty.call(t,r)){if(e.indexOf(r)!==-1)continue;n[r]=t[r]}return n}var YVe={top:5,right:5,bottom:5,left:5},Gae={accessibilityLayer:!0,stackOffset:\"none\",barCategoryGap:\"10%\",barGap:4,margin:YVe,reverseStackOrder:!1,syncMethod:\"index\",layout:\"radial\",responsive:!1,cx:\"50%\",cy:\"50%\",innerRadius:0,outerRadius:\"80%\"},QVe=w.forwardRef(function(e,n){var r,i=Ia(e.categoricalChartProps,Gae),{layout:s}=i,o=KVe(i,WVe),{chartName:l,defaultTooltipEventType:c,validateTooltipEventTypes:d,tooltipPayloadSearcher:u}=e,m={chartName:l,defaultTooltipEventType:c,validateTooltipEventTypes:d,tooltipPayloadSearcher:u,eventEmitter:void 0};return w.createElement(Hae,{preloadedState:{options:m},reduxStoreName:(r=i.id)!==null&&r!==void 0?r:l},w.createElement(nae,{chartData:i.data}),w.createElement(qae,{layout:s,margin:i.margin}),w.createElement($ae,{baseValue:void 0,accessibilityLayer:i.accessibilityLayer,barCategoryGap:i.barCategoryGap,maxBarSize:i.maxBarSize,stackOffset:i.stackOffset,barGap:i.barGap,barSize:i.barSize,syncId:i.syncId,syncMethod:i.syncMethod,className:i.className,reverseStackOrder:i.reverseStackOrder}),w.createElement(GVe,{cx:i.cx,cy:i.cy,startAngle:i.startAngle,endAngle:i.endAngle,innerRadius:i.innerRadius,outerRadius:i.outerRadius}),w.createElement(Vae,vL({},o,{ref:n})))});function $W(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(i){return Object.getOwnPropertyDescriptor(t,i).enumerable})),n.push.apply(n,r)}return n}function VW(t){for(var e=1;e<arguments.length;e++){var n=arguments[e]!=null?arguments[e]:{};e%2?$W(Object(n),!0).forEach(function(r){ZVe(t,r,n[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):$W(Object(n)).forEach(function(r){Object.defineProperty(t,r,Object.getOwnPropertyDescriptor(n,r))})}return t}function ZVe(t,e,n){return(e=JVe(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function JVe(t){var e=e7e(t,\"string\");return typeof e==\"symbol\"?e:e+\"\"}function e7e(t,e){if(typeof t!=\"object\"||!t)return t;var n=t[Symbol.toPrimitive];if(n!==void 0){var r=n.call(t,e);if(typeof r!=\"object\")return r;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(e===\"string\"?String:Number)(t)}var t7e=[\"item\"],n7e=VW(VW({},Gae),{},{layout:\"centric\",startAngle:0,endAngle:360}),GW=w.forwardRef((t,e)=>{var n=Ia(t,n7e);return w.createElement(QVe,{chartName:\"PieChart\",defaultTooltipEventType:\"item\",validateTooltipEventTypes:t7e,tooltipPayloadSearcher:$T,categoricalChartProps:n,ref:e})}),r7e=[\"axis\"],a7e=w.forwardRef((t,e)=>w.createElement(wz,{chartName:\"AreaChart\",defaultTooltipEventType:\"axis\",validateTooltipEventTypes:r7e,tooltipPayloadSearcher:$T,categoricalChartProps:t,ref:e}));const WW=[{value:\"6h\",label:\"6h\",hours:6},{value:\"24h\",label:\"24h\",hours:24},{value:\"48h\",label:\"48h\",hours:48},{value:\"7d\",label:\"7d\",hours:168}];function i7e({isOpen:t,onClose:e,printerId:n,printerName:r,amsId:i,amsLabel:s,initialMode:o=\"humidity\",thresholds:l}){const{t:c}=Ft(),{mode:d}=zg(),[u,m]=w.useState(\"24h\"),[p,f]=w.useState(o),y=d===\"dark\",{data:v}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),b=v?.time_format||\"system\";w.useEffect(()=>{if(!t)return;const Y=E=>{E.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",Y),()=>window.removeEventListener(\"keydown\",Y)},[t,e]);const g=WW.find(Y=>Y.value===u)?.hours||24,{data:_,isLoading:C,error:P}=Xe({queryKey:[\"ams-history\",n,i,g],queryFn:()=>ue.getAMSHistory(n,i,g),enabled:t,refetchInterval:6e4});if(!t)return null;const N=_?.data.map(Y=>({time:(Zn(Y.recorded_at)||new Date).getTime(),humidity:Y.humidity,temperature:Y.temperature}))||[],A=Date.now()-g*60*60*1e3,T=Date.now(),F=[...N];if(F.length>0){const Y=F[0];Y.time>A&&F.unshift({...Y,time:A});const E=F[F.length-1];E.time<T&&F.push({...E,time:T})}const k=l?.humidityGood||40,D=l?.humidityFair||60,H=l?.tempGood||30,z=l?.tempFair||35,Q=F[F.length-1],L=Q?.humidity,te=Q?.temperature,ie=Y=>{const E=Y.filter(I=>I!=null);if(E.length<4)return\"stable\";const j=E.slice(0,Math.floor(E.length/4)),O=E.slice(-Math.floor(E.length/4)),K=j.reduce((I,G)=>I+G,0)/j.length,de=O.reduce((I,G)=>I+G,0)/O.length-K;return Math.abs(de)<2?\"stable\":de>0?\"up\":\"down\"},J=ie(F.map(Y=>Y.humidity)),oe=ie(F.map(Y=>Y.temperature)),fe=({trend:Y})=>Y===\"up\"?a.jsx(dF,{className:\"w-4 h-4 text-red-400\"}):Y===\"down\"?a.jsx(LO,{className:\"w-4 h-4 text-green-400\"}):a.jsx(_N,{className:\"w-4 h-4 text-gray-400 dark:text-bambu-gray\"}),re=Y=>Y==null?\"#9ca3af\":Y<=k?\"#22a352\":Y<=D?\"#d4a017\":\"#c62828\",W=Y=>Y==null?\"#9ca3af\":Y<=H?\"#22a352\":Y<=z?\"#d4a017\":\"#c62828\",ne=y?\"#2d2d2d\":\"#ffffff\",me=y?\"#1d1d1d\":\"#f3f4f6\",be=y?\"#3d3d3d\":\"#e5e7eb\",Ce=y?\"#ffffff\":\"#111827\",q=y?\"#9ca3af\":\"#4b5563\";return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\",onClick:e,children:a.jsxs(\"div\",{className:\"rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl\",style:{backgroundColor:ne},onClick:Y=>Y.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 border-b\",style:{borderColor:be},children:[a.jsxs(\"div\",{children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold\",style:{color:Ce},children:[s,\" \",c(\"common.history\",\"History\")]}),a.jsx(\"p\",{className:\"text-sm\",style:{color:q},children:r})]}),a.jsx(\"button\",{onClick:e,className:\"p-2 rounded-lg transition-colors\",style:{color:q},children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-3\",children:[a.jsxs(\"div\",{className:\"inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit\",style:{backgroundColor:me},children:[a.jsxs(\"button\",{onClick:()=>f(\"humidity\"),className:`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${p===\"humidity\"?\"bg-blue-600 text-white\":\"\"}`,style:p!==\"humidity\"?{color:q}:void 0,children:[a.jsx(EO,{className:\"w-4 h-4\"}),c(\"common.humidity\",\"Humidity\")]}),a.jsxs(\"button\",{onClick:()=>f(\"temperature\"),className:`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${p===\"temperature\"?\"bg-orange-600 text-white\":\"\"}`,style:p!==\"temperature\"?{color:q}:void 0,children:[a.jsx(oS,{className:\"w-4 h-4\"}),c(\"common.temperature\",\"Temperature\")]})]}),a.jsx(\"div\",{className:\"inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit\",style:{backgroundColor:me},children:WW.map(Y=>a.jsx(\"button\",{onClick:()=>m(Y.value),className:`px-3 py-1 text-sm rounded-md transition-colors ${u===Y.value?\"bg-bambu-green text-white\":\"\"}`,style:u!==Y.value?{color:q}:void 0,children:Y.label},Y.value))})]}),a.jsx(\"div\",{className:\"grid grid-cols-4 gap-4 max-[550px]:grid-cols-2\",children:p===\"humidity\"?a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"rounded-lg p-4 max-[550px]:order-2\",style:{backgroundColor:me},children:[a.jsx(\"p\",{className:\"text-xs\",style:{color:q},children:c(\"common.current\",\"Current\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"p\",{className:\"text-2xl font-bold\",style:{color:re(L)},children:L!=null?`${L}%`:\"—\"}),a.jsx(fe,{trend:J})]})]}),a.jsxs(\"div\",{className:\"rounded-lg p-4 max-[550px]:order-4\",style:{backgroundColor:me},children:[a.jsx(\"p\",{className:\"text-xs\",style:{color:q},children:c(\"common.average\",\"Average\")}),a.jsx(\"p\",{className:\"text-2xl font-bold\",style:{color:Ce},children:_?.avg_humidity!=null?`${_.avg_humidity}%`:\"—\"})]}),a.jsxs(\"div\",{className:\"rounded-lg p-4 max-[550px]:order-1\",style:{backgroundColor:me},children:[a.jsx(\"p\",{className:\"text-xs\",style:{color:q},children:c(\"common.min\",\"Min\")}),a.jsx(\"p\",{className:\"text-2xl font-bold text-green-500\",children:_?.min_humidity!=null?`${_.min_humidity}%`:\"—\"})]}),a.jsxs(\"div\",{className:\"rounded-lg p-4 max-[550px]:order-3\",style:{backgroundColor:me},children:[a.jsx(\"p\",{className:\"text-xs\",style:{color:q},children:c(\"common.max\",\"Max\")}),a.jsx(\"p\",{className:\"text-2xl font-bold text-red-500\",children:_?.max_humidity!=null?`${_.max_humidity}%`:\"—\"})]})]}):a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"rounded-lg p-4 max-[550px]:order-2\",style:{backgroundColor:me},children:[a.jsx(\"p\",{className:\"text-xs\",style:{color:q},children:c(\"common.current\",\"Current\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"p\",{className:\"text-2xl font-bold\",style:{color:W(te)},children:te!=null?`${te}°C`:\"—\"}),a.jsx(fe,{trend:oe})]})]}),a.jsxs(\"div\",{className:\"rounded-lg p-4 max-[550px]:order-4\",style:{backgroundColor:me},children:[a.jsx(\"p\",{className:\"text-xs\",style:{color:q},children:c(\"common.average\",\"Average\")}),a.jsx(\"p\",{className:\"text-2xl font-bold\",style:{color:Ce},children:_?.avg_temperature!=null?`${_.avg_temperature}°C`:\"—\"})]}),a.jsxs(\"div\",{className:\"rounded-lg p-4 max-[550px]:order-1\",style:{backgroundColor:me},children:[a.jsx(\"p\",{className:\"text-xs\",style:{color:q},children:c(\"common.min\",\"Min\")}),a.jsx(\"p\",{className:\"text-2xl font-bold text-blue-500\",children:_?.min_temperature!=null?`${_.min_temperature}°C`:\"—\"})]}),a.jsxs(\"div\",{className:\"rounded-lg p-4 max-[550px]:order-3\",style:{backgroundColor:me},children:[a.jsx(\"p\",{className:\"text-xs\",style:{color:q},children:c(\"common.max\",\"Max\")}),a.jsx(\"p\",{className:\"text-2xl font-bold text-red-500\",children:_?.max_temperature!=null?`${_.max_temperature}°C`:\"—\"})]})]})}),a.jsx(\"div\",{className:\"rounded-lg p-4\",style:{backgroundColor:me},children:C?a.jsx(\"div\",{className:\"h-[300px] flex items-center justify-center\",style:{color:q},children:c(\"common.loading\",\"Loading...\")}):P?a.jsx(\"div\",{className:\"h-[300px] flex items-center justify-center text-red-500\",children:c(\"common.error\",\"Error loading data\")}):F.length===0?a.jsx(\"div\",{className:\"h-[300px] flex items-center justify-center\",style:{color:q},children:c(\"common.noData\",\"No data available for this time range\")}):a.jsx(Gh,{width:\"100%\",height:300,children:a.jsxs($Ve,{data:F,children:[a.jsx(Uf,{strokeDasharray:\"3 3\",stroke:y?\"#3d3d3d\":\"#e5e7eb\"}),a.jsx(Bf,{dataKey:\"time\",type:\"number\",domain:[Date.now()-g*60*60*1e3,Date.now()],tickFormatter:Y=>{const E=new Date(Y);return g>24?E.toLocaleDateString([],{day:\"numeric\",month:\"short\"}):E.toLocaleTimeString([],ow({hour:\"2-digit\",minute:\"2-digit\"},b))},stroke:y?\"#9ca3af\":\"#6b7280\",tick:{fontSize:12}}),a.jsx(Hf,{stroke:y?\"#9ca3af\":\"#6b7280\",tick:{fontSize:12},domain:p===\"humidity\"?[0,100]:[\"auto\",\"auto\"],tickFormatter:Y=>p===\"humidity\"?`${Y}%`:`${Y}°C`}),a.jsx(Wh,{contentStyle:{backgroundColor:y?\"#2d2d2d\":\"#ffffff\",border:`1px solid ${y?\"#3d3d3d\":\"#e5e7eb\"}`,borderRadius:\"8px\",color:y?\"#fff\":\"#000\"},labelFormatter:Y=>new Date(Y).toLocaleString(void 0,ow({year:\"numeric\",month:\"short\",day:\"numeric\",hour:\"2-digit\",minute:\"2-digit\"},b)),formatter:Y=>[p===\"humidity\"?`${Y??0}%`:`${Y??0}°C`,p===\"humidity\"?\"Humidity\":\"Temperature\"]}),a.jsx(bte,{}),p===\"humidity\"?a.jsxs(a.Fragment,{children:[a.jsx(x0,{y:k,stroke:\"#22a352\",strokeDasharray:\"5 5\",label:{value:\"Good\",fill:\"#22a352\",fontSize:10}}),a.jsx(x0,{y:D,stroke:\"#d4a017\",strokeDasharray:\"5 5\",label:{value:\"Fair\",fill:\"#d4a017\",fontSize:10}})]}):a.jsxs(a.Fragment,{children:[a.jsx(x0,{y:H,stroke:\"#22a352\",strokeDasharray:\"5 5\",label:{value:\"Good\",fill:\"#22a352\",fontSize:10}}),a.jsx(x0,{y:z,stroke:\"#d4a017\",strokeDasharray:\"5 5\",label:{value:\"Fair\",fill:\"#d4a017\",fontSize:10}})]}),a.jsx(bae,{type:\"monotone\",dataKey:p,name:p===\"humidity\"?\"Humidity\":\"Temperature\",stroke:p===\"humidity\"?\"#3b82f6\":\"#f97316\",strokeWidth:2,dot:!1,activeDot:{r:4},connectNulls:!0})]})})}),a.jsx(\"div\",{className:\"text-xs text-center\",style:{color:q},children:c(\"amsHistory.recordingInfo\",\"Data is recorded every 5 minutes while the printer is connected\")})]})]})})}function jD({data:t,children:e,disabled:n,className:r=\"\",spoolman:i,inventory:s,configureSlot:o}){const{t:l}=Ft(),[c,d]=w.useState(!1),[u,m]=w.useState(\"top\"),[p,f]=w.useState(!1),[y,v]=w.useState(!1),b=w.useRef(null),g=w.useRef(null),_=w.useRef(null),C=()=>{const D=t.trayUuid;D&&(navigator.clipboard&&window.isSecureContext?navigator.clipboard.writeText(D).then(()=>{f(!0),setTimeout(()=>f(!1),2e3)}).catch(()=>{P(D)}):P(D))},P=D=>{const H=document.createElement(\"textarea\");H.value=D,H.style.position=\"fixed\",H.style.opacity=\"0\",document.body.appendChild(H),H.select();try{document.execCommand(\"copy\"),f(!0),setTimeout(()=>f(!1),2e3)}catch{console.error(\"Failed to copy to clipboard\")}document.body.removeChild(H)};w.useEffect(()=>{if(c&&b.current&&g.current){const D=b.current.getBoundingClientRect(),H=g.current.offsetHeight,Q=D.top-56,L=window.innerHeight-D.bottom;Q<H+12&&L>Q?m(\"bottom\"):m(\"top\")}},[c]);const N=()=>{n||(_.current&&clearTimeout(_.current),_.current=setTimeout(()=>d(!0),80))},A=()=>{_.current&&clearTimeout(_.current),_.current=setTimeout(()=>d(!1),100)};w.useEffect(()=>()=>{_.current&&clearTimeout(_.current)},[]);const T=D=>D<=15?\"#ef4444\":D<=30?\"#f97316\":D<=50?\"#eab308\":\"#22c55e\",F=t.colorHex?`#${t.colorHex.replace(\"#\",\"\")}`:null,k=s?.assignedSpool?.remainingWeightGrams??null;return a.jsxs(\"div\",{ref:b,className:`relative ${r}`,onMouseEnter:N,onMouseLeave:A,children:[e,c&&a.jsxs(\"div\",{ref:g,className:`\n            absolute left-1/2 -translate-x-1/2 z-[60]\n            ${u===\"top\"?\"bottom-full mb-2\":\"top-full mt-2\"}\n            animate-in fade-in-0 zoom-in-95 duration-150\n          `,style:{maxWidth:\"calc(100vw - 24px)\"},children:[a.jsxs(\"div\",{className:`\n            w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary\n            rounded-lg shadow-xl overflow-hidden\n            backdrop-blur-sm\n          `,children:[a.jsxs(\"div\",{className:\"h-12 relative overflow-hidden\",style:{backgroundColor:F||\"#3d3d3d\"},children:[a.jsx(\"div\",{className:\"absolute inset-0 bg-gradient-to-b from-white/10 to-transparent\"}),a.jsx(\"div\",{className:`\n                absolute inset-0 flex items-center justify-center\n                font-semibold text-sm tracking-wide\n                ${tZ(F)?\"text-black/80\":\"text-white/90\"}\n              `,children:t.colorName}),a.jsx(\"div\",{className:`\n                absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider\n                ${t.vendor===\"Bambu Lab\"?\"bg-black/60 text-white\":\"bg-black/50 text-white/90\"}\n              `,children:t.vendor===\"Bambu Lab\"?\"BBL\":\"GEN\"})]}),a.jsxs(\"div\",{className:\"p-3 space-y-2.5\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:l(\"ams.profile\")}),a.jsx(\"span\",{className:\"text-xs text-white font-semibold truncate max-w-[120px]\",children:t.profile})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:l(\"ams.kFactor\")}),a.jsx(\"span\",{className:\"text-xs text-bambu-green font-mono font-bold\",children:t.kFactor})]}),a.jsxs(\"div\",{className:\"space-y-1\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1\",children:[a.jsx(EO,{className:\"w-3 h-3\"}),l(\"ams.fill\")]}),a.jsxs(\"span\",{className:\"text-xs text-white font-semibold flex items-center gap-1\",children:[a.jsx(\"span\",{children:t.fillLevel!==null?`${t.fillLevel}%`:\"—\"}),k!==null&&t.fillLevel!==null&&a.jsxs(\"span\",{className:\"text-[9px] text-bambu-gray font-normal\",children:[\"• \",k,\"g\"]}),t.fillSource===\"spoolman\"&&t.fillLevel!==null&&a.jsx(\"span\",{className:\"text-[9px] text-bambu-gray font-normal\",children:l(\"spoolman.fillSourceLabel\")}),t.fillSource===\"inventory\"&&t.fillLevel!==null&&a.jsx(\"span\",{className:\"text-[9px] text-bambu-gray font-normal\",children:l(\"inventory.fillSourceLabel\")})]})]}),a.jsx(\"div\",{className:\"h-1.5 bg-black/40 rounded-full overflow-hidden\",children:t.fillLevel!==null?a.jsx(\"div\",{className:\"h-full rounded-full transition-all duration-300\",style:{width:`${t.fillLevel}%`,backgroundColor:T(t.fillLevel)}}):a.jsx(\"div\",{className:\"h-full w-full bg-bambu-gray/30 rounded-full\"})})]}),i?.enabled&&a.jsxs(\"div\",{className:\"pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:l(\"spoolman.spoolId\")}),t.trayUuid?a.jsxs(\"button\",{onClick:D=>{D.stopPropagation(),C()},className:\"flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors\",title:\"Copy spool UUID\",children:[a.jsxs(\"span\",{className:\"font-mono text-[10px] truncate max-w-[80px]\",children:[t.trayUuid.slice(0,8),\"...\"]}),p?a.jsx(Ur,{className:\"w-3 h-3 text-bambu-green\"}):a.jsx(qs,{className:\"w-3 h-3\"})]}):a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:\"—\"})]}),i.linkedSpoolId&&i.spoolmanUrl&&a.jsxs(a.Fragment,{children:[a.jsxs(\"a\",{href:`${i.spoolmanUrl.replace(/\\/$/,\"\")}/spool/show/${i.linkedSpoolId}`,target:\"_blank\",rel:\"noopener noreferrer\",onClick:D=>D.stopPropagation(),className:\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green\",title:l(\"spoolman.openInSpoolman\"),children:[a.jsx(la,{className:\"w-3.5 h-3.5\"}),l(\"spoolman.openInSpoolman\")]}),i.onUnlinkSpool&&(t.vendor!==\"Bambu Lab\"||i.syncMode===\"manual\")&&a.jsxs(\"button\",{onClick:D=>{D.stopPropagation(),v(!0)},className:\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400\",title:l(\"spoolman.unlinkSpool\"),children:[a.jsx(gp,{className:\"w-3.5 h-3.5\"}),l(\"spoolman.unlinkSpool\")]})]}),!i.linkedSpoolId&&a.jsxs(\"button\",{onClick:D=>{D.stopPropagation(),i.onLinkSpool&&i.onLinkSpool?.()},disabled:!i.onLinkSpool,className:`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors ${i.onLinkSpool?\"bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green\":\"bg-bambu-gray/10 text-bambu-gray cursor-not-allowed\"}`,children:[a.jsx(sm,{className:\"w-3.5 h-3.5\"}),l(\"spoolman.linkToSpoolman\")]})]}),s&&t.vendor!==\"Bambu Lab\"&&a.jsx(\"div\",{className:\"pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2\",children:s.assignedSpool?a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(Ra,{className:\"w-3 h-3 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:l(\"inventory.assigned\")})]}),a.jsxs(\"p\",{className:\"text-xs text-white truncate\",children:[s.assignedSpool.brand?`${s.assignedSpool.brand} `:\"\",s.assignedSpool.material,s.assignedSpool.color_name?` - ${s.assignedSpool.color_name}`:\"\"]}),s.onUnassignSpool&&a.jsxs(\"button\",{onClick:D=>{D.stopPropagation(),s.onUnassignSpool?.()},className:\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400\",children:[a.jsx(gp,{className:\"w-3.5 h-3.5\"}),l(\"inventory.unassignSpool\")]})]}):s.onAssignSpool?a.jsxs(\"button\",{onClick:D=>{D.stopPropagation(),s.onAssignSpool?.()},className:\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue\",children:[a.jsx(Ra,{className:\"w-3.5 h-3.5\"}),l(\"inventory.assignSpool\")]}):null}),o?.enabled&&a.jsx(\"div\",{className:`${i?.enabled&&t.trayUuid?\"\":\"pt-2 mt-2 border-t border-bambu-dark-tertiary\"}`,children:a.jsxs(\"button\",{onClick:D=>{D.stopPropagation(),o.onConfigure?.()},className:\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue\",title:l(\"ams.configureSlot\"),children:[a.jsx(Ud,{className:\"w-3.5 h-3.5\"}),l(\"ams.configure\")]})})]})]}),a.jsx(\"div\",{className:`\n              absolute left-1/2 -translate-x-1/2 w-0 h-0\n              border-l-[6px] border-l-transparent\n              border-r-[6px] border-r-transparent\n              ${u===\"top\"?\"top-full border-t-[6px] border-t-bambu-dark-tertiary\":\"bottom-full border-b-[6px] border-b-bambu-dark-tertiary\"}\n            `})]}),y&&a.jsxs(\"div\",{className:\"fixed inset-0 z-[100] flex items-center justify-center\",onClick:()=>v(!1),children:[a.jsx(\"div\",{className:\"absolute inset-0 bg-black/60 backdrop-blur-sm\"}),a.jsx(\"div\",{className:\"relative bg-bambu-dark-secondary rounded-lg shadow-xl w-full max-w-sm mx-4 border border-bambu-dark-tertiary\",onClick:D=>D.stopPropagation(),children:a.jsxs(\"div\",{className:\"p-4 space-y-4\",children:[a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"h3\",{className:\"text-base font-semibold text-white\",children:l(\"spoolman.unlinkConfirmTitle\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:l(\"spoolman.unlinkConfirmMessage\")})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"button\",{onClick:()=>v(!1),className:\"flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-bambu-dark hover:bg-bambu-dark-tertiary text-white\",children:l(\"common.cancel\")}),a.jsx(\"button\",{onClick:()=>{i?.onUnlinkSpool?.(),v(!1)},className:\"flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400\",children:l(\"spoolman.unlinkSpool\")})]})]})})]})]})}function MD({children:t,className:e=\"\",configureSlot:n,inventory:r}){const{t:i}=Ft(),[s,o]=w.useState(!1),l=w.useRef(null),c=()=>{l.current&&clearTimeout(l.current),l.current=setTimeout(()=>o(!0),80)},d=()=>{l.current&&clearTimeout(l.current),l.current=setTimeout(()=>o(!1),100)};return w.useEffect(()=>()=>{l.current&&clearTimeout(l.current)},[]),a.jsxs(\"div\",{className:`relative ${e}`,onMouseEnter:c,onMouseLeave:d,children:[t,s&&a.jsxs(\"div\",{className:`\n          absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-50\n          animate-in fade-in-0 zoom-in-95 duration-150\n        `,children:[a.jsxs(\"div\",{className:`\n            bg-bambu-dark-secondary border border-bambu-dark-tertiary\n            rounded-md shadow-lg overflow-hidden\n          `,children:[a.jsx(\"div\",{className:\"px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap\",children:i(\"ams.emptySlot\")}),n?.enabled&&a.jsx(\"div\",{className:\"px-2 pb-2\",children:a.jsxs(\"button\",{onClick:u=>{u.stopPropagation(),n.onConfigure?.()},className:\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue\",title:i(\"ams.configureSlot\"),children:[a.jsx(Ud,{className:\"w-3.5 h-3.5\"}),i(\"ams.configure\")]})}),r?.onAssignSpool&&a.jsx(\"div\",{className:\"px-2 pb-2\",children:a.jsxs(\"button\",{onClick:u=>{u.stopPropagation(),r.onAssignSpool?.()},className:\"w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue\",children:[a.jsx(Ra,{className:\"w-3.5 h-3.5\"}),i(\"inventory.assignSpool\")]})})]}),a.jsx(\"div\",{className:`\n            absolute left-1/2 -translate-x-1/2 top-full w-0 h-0\n            border-l-[5px] border-l-transparent\n            border-r-[5px] border-r-transparent\n            border-t-[5px] border-t-bambu-dark-tertiary\n          `})]})]})}function Wae({isOpen:t,onClose:e,tagUid:n,trayUuid:r,printerId:i,amsId:s,trayId:o}){const{t:l}=Ft(),c=nn(),{showToast:d}=hn(),[u,m]=w.useState(\"\"),p=r||n,{data:f,isLoading:y}=Xe({queryKey:[\"unlinked-spools\"],queryFn:ue.getUnlinkedSpools,enabled:t}),v=w.useMemo(()=>f?f.filter(g=>{if(!u)return!0;const _=u.toLowerCase();return g.filament_name&&g.filament_name.toLowerCase().includes(_)||g.filament_vendor&&g.filament_vendor.toLowerCase().includes(_)||g.filament_material&&g.filament_material.toLowerCase().includes(_)||String(g.id).includes(_)}):[],[f,u]),b=it({mutationFn:g=>ue.linkSpool(g,{spoolTag:p,printerId:i,amsId:s,trayId:o}),onSuccess:()=>{c.invalidateQueries({queryKey:[\"unlinked-spools\"]}),c.invalidateQueries({queryKey:[\"linked-spools\"]}),d(l(\"spoolman.linkSuccess\"),\"success\"),e()},onError:g=>{d(g.message||l(\"spoolman.linkFailed\"),\"error\")}});return t?a.jsxs(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center\",children:[a.jsx(\"div\",{className:\"absolute inset-0 bg-black/60 backdrop-blur-sm\",onClick:e}),a.jsxs(\"div\",{className:\"relative bg-bambu-dark-secondary rounded-xl shadow-xl w-full max-w-md mx-4 max-h-[80vh] flex flex-col border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-white/10\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"h3\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(Sy,{className:\"w-5 h-5 text-bambu-green\"}),l(\"spoolman.selectSpool\")]}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:[\"AMS \",s,\" T\",o,\" · Printer #\",i]})]}),a.jsx(\"button\",{onClick:e,className:\"p-1 text-bambu-gray hover:text-white rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-4 border-b border-white/10\",children:[a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:u,onChange:g=>m(g.target.value),placeholder:l(\"inventory.searchSpools\"),className:\"w-full pl-9 pr-3 py-2 bg-bambu-dark rounded-lg border border-white/10 text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green\"})]}),(r||n)&&a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mt-2 font-mono truncate\",title:r||n,children:[\"Tag: \",r||n]})]}),a.jsx(\"div\",{className:\"flex-1 overflow-y-auto p-2 min-h-0\",children:y?a.jsx(\"div\",{className:\"flex justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-green\"})}):v.length===0?a.jsx(\"p\",{className:\"text-center text-bambu-gray py-8 text-sm\",children:l(\"inventory.noSpoolsMatch\")}):v.map(g=>a.jsxs(\"button\",{onClick:()=>b.mutate(g.id),disabled:b.isPending||!p,className:\"w-full flex items-center gap-3 p-3 rounded-lg hover:bg-white/5 transition-colors text-left\",children:[a.jsx(\"span\",{className:\"w-6 h-6 rounded-full border border-black/20 flex-shrink-0\",style:{backgroundColor:g.filament_color_hex?`#${g.filament_color_hex}`:\"#808080\"}}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"div\",{className:\"text-sm text-white font-medium truncate\",children:g.filament_name||l(\"spoolman.spoolId\")}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray truncate\",children:[g.filament_vendor?`${g.filament_vendor} · `:\"\",g.filament_material||\"Unknown\",\" · #\",g.id]})]}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:g.remaining_weight!=null?`${Math.round(g.remaining_weight)}g`:\"—\"})]},g.id))}),a.jsx(\"div\",{className:\"p-4 border-t border-white/10 flex justify-end\",children:a.jsx(De,{variant:\"ghost\",onClick:e,children:l(\"inventory.cancel\")||\"Cancel\"})})]})]}):null}function Kae({isOpen:t,onClose:e,printerId:n,amsId:r,trayId:i,trayInfo:s}){const{t:o}=Ft(),l=nn(),{showToast:c}=hn(),[d,u]=w.useState(!1),[m,p]=w.useState(null);w.useEffect(()=>{p(null)},[d]);const[f,y]=w.useState(\"\"),[v,b]=w.useState(null),[g,_]=w.useState(!1),[C,P]=w.useState(null);w.useEffect(()=>{t&&u(!1)},[t]);const{data:N,isLoading:A}=Xe({queryKey:[\"inventory-spools\"],queryFn:()=>ue.getSpools(),enabled:t}),{data:T}=Xe({queryKey:[\"spool-assignments\"],queryFn:()=>ue.getAssignments(),enabled:t}),{data:F}=Xe({queryKey:[\"settings\"],queryFn:()=>ue.getSettings(),enabled:t}),k=it({mutationFn:re=>ue.assignSpool({spool_id:re,printer_id:n,ams_id:r,tray_id:i}),onSuccess:re=>{l.setQueryData([\"spool-assignments\"],W=>{const ne=(W||[]).filter(me=>!(me.printer_id===n&&me.ams_id===r&&me.tray_id===i));return ne.push(re),ne}),l.invalidateQueries({queryKey:[\"spool-assignments\"]}),c(o(\"inventory.assignSuccess\"),\"success\"),_(!1),b(null),P(null),e()},onError:re=>{c(`${o(\"inventory.assignFailed\")}: ${re.message}`,\"error\")}}),D=re=>(re??\"\").trim().toUpperCase(),H=(re,W)=>{const ne=D(re),me=D(W);return!ne||!me?\"none\":ne===me?\"exact\":me.includes(ne)||ne.includes(me)?\"partial\":\"none\"},z=re=>re.split(\"@\")[0].trim(),Q=(re,W)=>{const ne=z(D(re)),me=z(D(W));return!ne||!me?!1:ne===me};if(!t)return null;const L=new Set((T||[]).filter(re=>!(re.printer_id===n&&re.ams_id===r&&re.tray_id===i)).map(re=>re.spool_id)),te=r===254||r===255,ie=N?.filter(re=>!L.has(re.id)&&(te||!re.tag_uid&&!re.tray_uuid));let J=ie;if(!d){const re=z(D(s?.profile)),W=D(s?.material||s?.type);(re||W)&&(J=J?.filter(ne=>{const me=z(D(ne.slicer_filament_name||ne.slicer_filament)),be=D(ne.material);return re&&me&&me===re?!0:W&&be?be===W||W.includes(be)||be.includes(W):!me&&!be}))}if(f&&J){const re=f.toLowerCase();J=J.filter(W=>W.material.toLowerCase().includes(re)||(W.brand?.toLowerCase().includes(re)??!1)||(W.color_name?.toLowerCase().includes(re)??!1)||(W.subtype?.toLowerCase().includes(re)??!1))}const oe=()=>{if(!m)return;const re=N?.find(W=>W.id===m);if(!re){c(o(\"inventory.assignFailed\"),\"error\");return}if(!F?.disable_filament_warnings&&s){const W=s.material||s.type,ne=H(re.material,W),me=re.slicer_filament_name||re.slicer_filament,be=s.profile||s.type,Ce=Q(me,be);if(ne!==\"exact\"||!Ce){let q=\"profile\";ne===\"none\"&&!Ce?q=\"material_profile\":ne===\"partial\"&&!Ce?q=\"partial_profile\":ne===\"none\"?q=\"material\":ne===\"partial\"&&(q=\"partial\"),b(m),P({type:q,spoolMaterial:re.material||\"\",trayMaterial:W||\"\",spoolProfile:me||void 0,trayProfile:be||void 0}),_(!0);return}}k.mutate(m)},fe=()=>{v&&(k.mutate(v),_(!1),b(null))};return a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"fixed inset-0 z-50 flex items-start sm:items-center justify-center p-4 overflow-y-auto\",children:[a.jsx(\"div\",{className:\"absolute inset-0 bg-black/60 backdrop-blur-sm\",onClick:e}),a.jsxs(\"div\",{className:\"relative w-full max-w-2xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Ra,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:o(\"inventory.assignSpool\")})]}),a.jsx(\"button\",{onClick:e,className:\"p-1 text-bambu-gray hover:text-white rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-4 overflow-y-auto\",children:[s&&a.jsxs(\"div\",{className:\"p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mb-1\",children:[o(\"inventory.selectSpool\"),\":\"]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[s.color&&a.jsx(\"span\",{className:\"w-4 h-4 rounded-full border border-black/20\",style:{backgroundColor:`#${s.color}`}}),a.jsx(\"span\",{className:\"text-white font-medium\",children:s.type||o(\"ams.emptySlot\")}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"(\",s.location,\")\"]})]})]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:f,onChange:re=>y(re.target.value),placeholder:o(\"inventory.searchSpools\"),className:\"w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green\"})]}),a.jsx(\"div\",{children:A?a.jsx(\"div\",{className:\"flex justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})}):J&&J.length>0?a.jsx(\"div\",{className:\"max-h-96 overflow-y-auto grid grid-cols-2 sm:grid-cols-3 gap-2\",children:J.map(re=>a.jsxs(\"button\",{onClick:()=>p(re.id),title:re.note||void 0,className:`p-2.5 rounded-lg border text-left transition-colors ${m===re.id?\"bg-bambu-green/20 border-bambu-green\":\"bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray\"}`,children:[a.jsxs(\"p\",{className:\"text-white text-sm font-medium truncate\",children:[re.brand?`${re.brand} `:\"\",re.material,re.subtype?` ${re.subtype}`:\"\"]}),a.jsxs(\"div\",{className:\"flex items-center gap-1.5 mt-1\",children:[re.rgba&&a.jsx(\"span\",{className:\"w-3 h-3 rounded-full border border-black/20 flex-shrink-0\",style:{backgroundColor:`#${re.rgba.substring(0,6)}`}}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray truncate\",children:re.color_name||\"\"})]}),re.label_weight&&a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:[Math.max(0,Math.round(re.label_weight-re.weight_used)),\" / \",re.label_weight,\"g\"]})]},re.id))}):ie&&ie.length===0?a.jsx(\"div\",{className:\"text-center py-8 text-bambu-gray\",children:a.jsx(\"p\",{children:o(\"inventory.noManualSpools\")})}):a.jsx(\"div\",{className:\"text-center py-8 text-bambu-gray\",children:a.jsx(\"p\",{children:o(\"inventory.noSpoolsMatch\")})})})]}),a.jsxs(\"div\",{className:\"flex justify-between items-center p-4 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{id:\"disable-filtering-toggle\",type:\"checkbox\",checked:d,onChange:()=>u(re=>!re),className:\"accent-bambu-green w-4 h-4 rounded focus:ring-0 border-bambu-dark-tertiary\"}),a.jsx(\"label\",{htmlFor:\"disable-filtering-toggle\",className:\"text-xs text-bambu-gray select-none cursor-pointer\",children:o(\"inventory.showAllSpools\")})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:e,children:o(\"common.cancel\")}),a.jsx(De,{onClick:oe,disabled:!m||k.isPending,children:k.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),o(\"inventory.assigning\")]}):a.jsxs(a.Fragment,{children:[a.jsx(Ra,{className:\"w-4 h-4\"}),o(\"inventory.assignSpool\")]})})]})]}),k.isError&&a.jsx(\"div\",{className:\"mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400\",children:k.error.message})]})]}),g&&s&&m&&C&&(()=>{let re=\"\";return C.type===\"material\"?re=o(\"inventory.assignMismatchMessage\",{spoolMaterial:C.spoolMaterial,trayMaterial:C.trayMaterial,location:s.location}):C.type===\"partial\"?re=o(\"inventory.assignPartialMismatchMessage\",{spoolMaterial:C.spoolMaterial,trayMaterial:C.trayMaterial,location:s.location}):C.type===\"material_profile\"?re=`${o(\"inventory.assignMismatchMessage\",{spoolMaterial:C.spoolMaterial,trayMaterial:C.trayMaterial,location:s.location})}\n\n${o(\"inventory.assignProfileMismatchMessage\",{spoolProfile:C.spoolProfile||o(\"common.unknown\"),trayProfile:C.trayProfile||o(\"common.unknown\"),location:s.location})}`:C.type===\"partial_profile\"?re=`${o(\"inventory.assignPartialMismatchMessage\",{spoolMaterial:C.spoolMaterial,trayMaterial:C.trayMaterial,location:s.location})}\n\n${o(\"inventory.assignProfileMismatchMessage\",{spoolProfile:C.spoolProfile||o(\"common.unknown\"),trayProfile:C.trayProfile||o(\"common.unknown\"),location:s.location})}`:C.type===\"profile\"&&(re=o(\"inventory.assignProfileMismatchMessage\",{spoolProfile:C.spoolProfile||o(\"common.unknown\"),trayProfile:C.trayProfile||o(\"common.unknown\"),location:s.location})),a.jsx(yn,{title:o(\"inventory.assignMismatchTitle\"),message:re,confirmText:o(\"inventory.assignMismatchConfirm\"),variant:\"warning\",isLoading:k.isPending,onConfirm:fe,onCancel:()=>{k.isPending||(_(!1),b(null),P(null))}})})()]})}function KW(t,e){if(t===255)return\"External\";let n,r=!1;t>=128&&t<=135?(n=t-128,r=!0):t>=0&&t<=3?(n=t,r=e===1):n=0,n=Math.max(0,Math.min(n,7));const i=String.fromCharCode(65+n);return r?`HT-${i}`:`AMS-${i}`}function ED(t){const e=t.includes(\"_\")?t.split(\"_\")[0]:t;return e.startsWith(\"GFS\")?\"GF\"+e.slice(3):(e.startsWith(\"PFUS\")||e.startsWith(\"PFSP\"),e)}const L0=[\"PLA\",\"PETG\",\"PCTG\",\"ABS\",\"ASA\",\"TPU\",\"PC\",\"PA\",\"NYLON\",\"PVA\",\"HIPS\",\"PP\",\"PET\"];function XW(t){const e=t.replace(/@.+$/,\"\").trim(),n=e.toUpperCase(),r=n.match(/\\bSUPPORT\\s+FOR\\s+/);if(r){const s=n.slice(r.index+r[0].length);for(const o of L0)if(new RegExp(`\\\\b${o}\\\\b`).test(s)){const c=e.slice(0,r.index).trim();return{material:o,brand:c,variant:\"Support\"}}}for(const s of L0){const o=new RegExp(`\\\\b${s}\\\\b`,\"i\");if(o.test(n)){const l=e.split(o),c=l[0]?.trim()||\"\",d=l[1]?.trim()||\"\";return{material:s,brand:c,variant:d}}}const i=e.split(/\\s+/);return i.length>=2?{material:i[1],brand:i[0],variant:i.slice(2).join(\" \")}:{material:e,brand:\"\",variant:\"\"}}function s7e(t){return!t.startsWith(\"GF\")&&!t.startsWith(\"P1\")}const o7e={white:\"FFFFFF\",black:\"000000\",red:\"FF0000\",green:\"00FF00\",blue:\"0000FF\",yellow:\"FFFF00\",cyan:\"00FFFF\",magenta:\"FF00FF\",orange:\"FFA500\",purple:\"800080\",pink:\"FFC0CB\",brown:\"8B4513\",gray:\"808080\",grey:\"808080\",\"jade white\":\"FFFEF2\",ivory:\"FFFFF0\",beige:\"F5F5DC\",cream:\"FFFDD0\",silver:\"C0C0C0\",gold:\"FFD700\",bronze:\"CD7F32\",copper:\"B87333\",navy:\"000080\",teal:\"008080\",olive:\"808000\",maroon:\"800000\",coral:\"FF7F50\",salmon:\"FA8072\",lime:\"32CD32\",mint:\"98FF98\",\"forest green\":\"228B22\",\"sky blue\":\"87CEEB\",\"royal blue\":\"4169E1\",turquoise:\"40E0D0\",lavender:\"E6E6FA\",violet:\"EE82EE\",plum:\"DDA0DD\",tan:\"D2B48C\",chocolate:\"D2691E\",charcoal:\"36454F\",slate:\"708090\",transparent:\"000000\",natural:\"F5F5DC\",wood:\"DEB887\"},YW=[{name:\"White\",hex:\"FFFFFF\"},{name:\"Black\",hex:\"000000\"},{name:\"Red\",hex:\"FF0000\"},{name:\"Blue\",hex:\"0000FF\"},{name:\"Green\",hex:\"00AA00\"},{name:\"Yellow\",hex:\"FFFF00\"},{name:\"Orange\",hex:\"FFA500\"},{name:\"Gray\",hex:\"808080\"}],QW=[{name:\"Cyan\",hex:\"00FFFF\"},{name:\"Magenta\",hex:\"FF00FF\"},{name:\"Purple\",hex:\"800080\"},{name:\"Pink\",hex:\"FFC0CB\"},{name:\"Brown\",hex:\"8B4513\"},{name:\"Beige\",hex:\"F5F5DC\"},{name:\"Navy\",hex:\"000080\"},{name:\"Teal\",hex:\"008080\"},{name:\"Lime\",hex:\"32CD32\"},{name:\"Gold\",hex:\"FFD700\"},{name:\"Silver\",hex:\"C0C0C0\"},{name:\"Maroon\",hex:\"800000\"},{name:\"Olive\",hex:\"808000\"},{name:\"Coral\",hex:\"FF7F50\"},{name:\"Salmon\",hex:\"FA8072\"},{name:\"Turquoise\",hex:\"40E0D0\"},{name:\"Violet\",hex:\"EE82EE\"},{name:\"Indigo\",hex:\"4B0082\"},{name:\"Chocolate\",hex:\"D2691E\"},{name:\"Tan\",hex:\"D2B48C\"},{name:\"Slate\",hex:\"708090\"},{name:\"Charcoal\",hex:\"36454F\"},{name:\"Ivory\",hex:\"FFFFF0\"},{name:\"Cream\",hex:\"FFFDD0\"}];function ZW(t){const e=t.toLowerCase().trim();return o7e[e]||null}function l7e(t){const e=t.indexOf(\"@\");if(e<0)return null;const r=t.slice(e+1).trim().match(/^BBL\\s+(.+?)(?:\\s+[\\d.]+\\s*nozzle)?$/i);return r?r[1].trim():null}function Xae({isOpen:t,onClose:e,printerId:n,slotInfo:r,nozzleDiameter:i=\"0.4\",printerModel:s,onSuccess:o,fullScreen:l}){const{t:c}=Ft(),[d,u]=w.useState(\"\"),[m,p]=w.useState(null),[f,y]=w.useState(\"\"),[v,b]=w.useState(\"\"),[g,_]=w.useState(\"\"),[C,P]=w.useState(!1),[N,A]=w.useState(!1),T=w.useRef(\"\"),{data:F,isLoading:k,isError:D}=Xe({queryKey:[\"cloudSettings\"],queryFn:()=>ue.getCloudSettings(),enabled:t,retry:!1}),{data:H,isLoading:z}=Xe({queryKey:[\"localPresets\"],queryFn:()=>ue.getLocalPresets(),enabled:t}),{data:Q,isLoading:L}=Xe({queryKey:[\"builtinFilaments\"],queryFn:()=>ue.getBuiltinFilaments(),enabled:t,staleTime:1/0}),{data:te,isLoading:ie}=Xe({queryKey:[\"kprofiles\",n,i],queryFn:()=>ue.getKProfiles(n,i),enabled:t&&!!n}),{data:J}=Xe({queryKey:[\"colorCatalog\"],queryFn:()=>ue.getColorCatalog(),enabled:t,staleTime:1/0}),oe=it({mutationFn:async()=>{if(!d)throw new Error(\"No filament preset selected\");const j=d.startsWith(\"local_\"),O=d.startsWith(\"builtin_\"),K=j?parseInt(d.replace(\"local_\",\"\"),10):null,U=O?d.replace(\"builtin_\",\"\"):null,de=j?H?.filament.find(je=>je.id===K):null,I=O?Q?.find(je=>je.filament_id===U):null,G=!j&&!O?F?.filament.find(je=>je.setting_id===d):null;if(!j&&!O&&!G)throw new Error(\"Selected preset not found\");if(j&&!de)throw new Error(\"Selected local preset not found\");if(O&&!I)throw new Error(\"Selected builtin preset not found\");const X=j?de.name:O?I.name:G.name,V=XW(X),ee=m?.slot_id??-1,se=f||r.trayColor?.slice(0,6)||\"FFFFFF\",ge=X.replace(/@.+$/,\"\").trim();let he,le;const B=V.material.toUpperCase();if(j){const je=(L0.includes(B)?B:de?.filament_type||V.material||\"\").toUpperCase(),Le={PLA:\"GFL99\",\"PLA-CF\":\"GFL98\",\"PLA SILK\":\"GFL96\",\"PLA HIGH SPEED\":\"GFL95\",PETG:\"GFG99\",\"PETG HF\":\"GFG96\",\"PETG-CF\":\"GFG98\",PCTG:\"GFG97\",ABS:\"GFB99\",ASA:\"GFB98\",PC:\"GFC99\",PA:\"GFN99\",\"PA-CF\":\"GFN98\",NYLON:\"GFN99\",TPU:\"GFU99\",PVA:\"GFS99\",HIPS:\"GFS98\",PE:\"GFP99\",PP:\"GFP97\"};he=Le[je]||Le[je.replace(/[-\\s]?CF$/,\"\")]||Le[je.replace(/\\+$/,\"\")]||Le[je.split(/[-\\s]/)[0]]||\"\",le=\"\"}else if(O)he=U,le=\"\";else if(he=ED(d),le=d,!d.startsWith(\"GFS\"))try{const je=await ue.getCloudSettingDetail(d);je.filament_id&&(he=je.filament_id)}catch(je){console.warn(\"Failed to fetch preset detail for filament_id:\",je)}let R=j&&de?.nozzle_temp_min?de.nozzle_temp_min:190,ae=j&&de?.nozzle_temp_max?de.nozzle_temp_max:230;if(!j||O||!de?.nozzle_temp_min&&!de?.nozzle_temp_max){const je=(j?L0.includes(B)?B:de?.filament_type||V.material||\"\":V.material).toUpperCase();je.includes(\"PLA\")?(R=190,ae=230):je.includes(\"PETG\")?(R=220,ae=260):je.includes(\"ABS\")||je.includes(\"ASA\")?(R=240,ae=280):je.includes(\"TPU\")?(R=200,ae=240):je===\"PCTG\"?(R=220,ae=260):je.includes(\"PC\")?(R=260,ae=300):(je.includes(\"PA\")||je.includes(\"NYLON\"))&&(R=250,ae=290)}const _e=m?.k_value?parseFloat(m.k_value):0,Se=j?L0.includes(B)?B:de?.filament_type||V.material||\"PLA\":V.material||\"PLA\",ve=await ue.configureAmsSlot(n,r.amsId,r.trayId,{tray_info_idx:he,tray_type:Se,tray_sub_brands:ge,tray_color:se+\"FF\",nozzle_temp_min:R,nozzle_temp_max:ae,cali_idx:ee,nozzle_diameter:i,setting_id:le,kprofile_filament_id:m?.filament_id,kprofile_setting_id:m?.setting_id||void 0,k_value:_e}),Te=j?`local_${K}`:O?`builtin_${U}`:d,ye=j?\"local\":O?\"builtin\":\"cloud\";try{await ue.saveSlotPreset(n,r.amsId,r.trayId,Te,ge,ye)}catch(je){console.warn(\"Failed to save slot preset mapping:\",je)}return ve},onSuccess:()=>{P(!0),o?.(),setTimeout(()=>{P(!1),e()},1500)}}),fe=it({mutationFn:async()=>ue.resetAmsSlot(n,r.amsId,r.trayId),onSuccess:()=>{P(!0),o?.(),setTimeout(()=>{P(!1),e()},1500)}}),re=w.useMemo(()=>{const j=g.toLowerCase(),O=[],K=new Set,U=r.savedPresetId,de=r.trayInfoIdx;if(F?.filament)for(const I of F.filament){K.add(I.setting_id);const X=U===I.setting_id||de&&(I.setting_id===de||ED(I.setting_id)===de);if(!(j&&!I.name.toLowerCase().includes(j))){if(!X&&s){const V=l7e(I.name);if(V&&V.toUpperCase()!==s.toUpperCase())continue}O.push({id:I.setting_id,name:I.name,source:\"cloud\",isUser:s7e(I.setting_id)})}}if(H?.filament)for(const I of H.filament){const G=`local_${I.id}`;j&&!I.name.toLowerCase().includes(j)||O.push({id:G,name:I.name,source:\"local\",isUser:!1})}if(Q)for(const I of Q){if(K.has(I.filament_id))continue;const G=I.filament_id.startsWith(\"GF\")?\"GFS\"+I.filament_id.slice(2):I.filament_id;K.has(G)||(!j||I.name.toLowerCase().includes(j))&&O.push({id:`builtin_${I.filament_id}`,name:I.name,source:\"builtin\",isUser:!1})}return O.sort((I,G)=>{const X={cloud:0,local:1,builtin:2};return I.source!==G.source?X[I.source]-X[G.source]:I.isUser&&!G.isUser?-1:!I.isUser&&G.isUser?1:I.name.localeCompare(G.name)})},[F?.filament,H?.filament,Q,g,s,r.savedPresetId,r.trayInfoIdx]),W=w.useMemo(()=>{if(!d)return null;let j=null;if(d.startsWith(\"local_\")){const U=parseInt(d.replace(\"local_\",\"\"),10);j=H?.filament.find(I=>I.id===U)?.name||null}else if(d.startsWith(\"builtin_\")){const U=d.replace(\"builtin_\",\"\");j=Q?.find(I=>I.filament_id===U)?.name||null}else F?.filament&&(j=F.filament.find(de=>de.setting_id===d)?.name||null);if(!j)return null;let O=j.replace(/@.+$/,\"\").trim();O.startsWith(\"# \")&&(O=O.slice(2).trim());const K=XW(O);return{fullName:O,material:K.material,brand:K.brand}},[d,F?.filament,H?.filament,Q]),ne=W?.fullName||\"\",me=w.useMemo(()=>{if(!J||!W)return[];const{fullName:j,brand:O}=W,K=j.replace(/^(Bambu\\s*(Lab)?|eSUN|Polymaker|Overture|Sunlu|Hatchbox)\\s*/i,\"\").trim();return J.filter(U=>{const de=(U.material||\"\").toUpperCase(),I=U.manufacturer.toUpperCase();if(!(de===K.toUpperCase()||de.includes(K.toUpperCase())||K.toUpperCase().includes(de)))return!1;if(O){const X=O.toUpperCase();if(!I.includes(X)&&!X.includes(I))return!1}return!0})},[J,W]),be=w.useMemo(()=>{if(!te?.profiles||!W)return[];const{fullName:j,material:O,brand:K}=W,U=j.toUpperCase(),de=O.toUpperCase(),I=K.toUpperCase();if(!de||de.length<2)return[];const G=te.profiles.filter(V=>{const ee=V.name.toUpperCase();if(I)return!(!ee.includes(I)||!ee.includes(de));if(ee.includes(U)||ee.includes(de))return!0;const ge={NYLON:[\"PA\",\"PA-CF\",\"PA6\"],PA:[\"NYLON\"]}[de]||[];for(const he of ge)if(ee.includes(he))return!0;return!1}),X=new Map;for(const V of G){const ee=`${V.name}|${V.k_value}`,se=X.get(ee);se?r.extruderId!==void 0&&V.extruder_id===r.extruderId&&se.extruder_id!==r.extruderId&&X.set(ee,V):X.set(ee,V)}return Array.from(X.values())},[te?.profiles,W,r.extruderId]);w.useEffect(()=>{if(t){if(r.savedPresetId)u(r.savedPresetId);else if(r.trayInfoIdx&&F?.filament){let j=F.filament.find(O=>O.setting_id===r.trayInfoIdx);j||(j=F.filament.find(O=>ED(O.setting_id)===r.trayInfoIdx)),j&&u(j.setting_id)}else if(r.trayInfoIdx&&Q?.length){const j=r.trayInfoIdx,O=Q.find(K=>K.filament_id===j);O&&u(`builtin_${O.filament_id}`)}if(r.trayColor){const j=r.trayColor.slice(0,6);j&&y(j)}}else u(\"\"),p(null),y(\"\"),b(\"\"),_(\"\"),P(!1),T.current=\"\"},[t,r.savedPresetId,r.trayInfoIdx,r.trayColor,F?.filament,Q]),w.useEffect(()=>{if(be.length>0){if(r.caliIdx!=null&&r.caliIdx>0){const j=be.find(O=>O.slot_id===r.caliIdx);if(j){p(j);return}}p(be[0])}else p(null)},[d,be,r.caliIdx]);const Ce=w.useCallback(j=>{j.key===\"Escape\"&&e()},[e]);w.useEffect(()=>{if(t)return document.addEventListener(\"keydown\",Ce),()=>document.removeEventListener(\"keydown\",Ce)},[t,Ce]);const q=k&&!D||z||L||ie;if(w.useEffect(()=>{if(!q&&d&&d!==T.current){const j=requestAnimationFrame(()=>{const K=document.querySelector('[class*=\"fixed inset-0 z-50\"]')?.querySelector(`[data-preset-id=\"${CSS.escape(d)}\"]`);K&&(T.current=d,K.scrollIntoView({block:\"nearest\"}))});return()=>cancelAnimationFrame(j)}},[d,q]),!t)return null;const Y=d&&!oe.isPending,E=f||r.trayColor?.slice(0,6)||\"FFFFFF\";return a.jsxs(\"div\",{className:`fixed inset-0 z-50 flex ${l?\"\":\"items-center justify-center\"}`,children:[!l&&a.jsx(\"div\",{className:\"absolute inset-0 bg-black/60 backdrop-blur-sm\",onClick:e}),a.jsxs(\"div\",{className:l?\"relative w-full h-full bg-bambu-dark-secondary flex flex-col\":\"relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Ud,{className:\"w-5 h-5 text-bambu-blue\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:c(\"configureAmsSlot.title\")}),l&&a.jsxs(\"div\",{className:\"flex items-center gap-2 ml-4 text-sm text-bambu-gray\",children:[a.jsx(\"span\",{className:\"text-white/30\",children:\"|\"}),r.trayColor&&a.jsx(\"span\",{className:\"w-4 h-4 rounded-full border border-black/20\",style:{backgroundColor:`#${r.trayColor.slice(0,6)}`}}),a.jsx(\"span\",{className:\"text-white/70\",children:c(\"configureAmsSlot.slotLabel\",{ams:KW(r.amsId,r.trayCount),slot:r.trayId+1})}),r.traySubBrands&&a.jsxs(\"span\",{children:[\"(\",r.traySubBrands,\")\"]})]})]}),a.jsx(\"button\",{onClick:e,className:\"p-1 text-bambu-gray hover:text-white rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:`p-4 overflow-y-auto ${l?\"flex-1 min-h-0\":\"space-y-4 max-h-[60vh]\"}`,children:[C&&a.jsx(\"div\",{className:\"absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl\",children:a.jsxs(\"div\",{className:\"text-center space-y-3\",children:[a.jsx(Vc,{className:\"w-16 h-16 text-bambu-green mx-auto\"}),a.jsx(\"p\",{className:\"text-lg font-semibold text-white\",children:c(\"configureAmsSlot.slotConfigured\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:c(\"configureAmsSlot.settingsSentToPrinter\")})]})}),!l&&a.jsxs(\"div\",{className:\"p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-1\",children:c(\"configureAmsSlot.configuringSlot\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[r.trayColor&&a.jsx(\"span\",{className:\"w-4 h-4 rounded-full border border-black/20\",style:{backgroundColor:`#${r.trayColor.slice(0,6)}`}}),a.jsx(\"span\",{className:\"text-white font-medium\",children:c(\"configureAmsSlot.slotLabel\",{ams:KW(r.amsId,r.trayCount),slot:r.trayId+1})}),r.traySubBrands&&a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"(\",r.traySubBrands,\")\"]})]})]}),q?a.jsx(\"div\",{className:\"flex justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})}):l?a.jsxs(\"div\",{className:\"flex gap-4 h-full\",children:[a.jsxs(\"div\",{className:\"w-1/2 flex flex-col min-h-0\",children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:[c(\"configureAmsSlot.filamentProfile\"),\" \",a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"})]}),a.jsx(\"input\",{type:\"text\",placeholder:c(\"configureAmsSlot.searchPresets\"),value:g,onChange:j=>_(j.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2 shrink-0\"}),a.jsx(\"div\",{className:\"flex-1 min-h-0 overflow-y-auto space-y-1\",children:re.length===0?a.jsx(\"p\",{className:\"text-center py-4 text-bambu-gray\",children:F?.filament?.length===0&&!H?.filament?.length?c(\"configureAmsSlot.noPresetsAvailable\"):c(\"configureAmsSlot.noMatchingPresets\")}):re.map(j=>a.jsx(\"button\",{\"data-preset-id\":j.id,onClick:()=>u(j.id),className:`w-full p-2 rounded-lg border text-left transition-colors ${d===j.id?\"bg-bambu-green/20 border-bambu-green\":\"bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray\"}`,children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-white text-sm truncate\",children:j.name}),a.jsxs(\"div\",{className:\"flex items-center gap-1 flex-shrink-0\",children:[j.source===\"local\"&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400\",children:c(\"profiles.localProfiles.badge\")}),j.source===\"builtin\"&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400\",children:c(\"configureAmsSlot.builtin\")}),j.isUser&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue\",children:c(\"configureAmsSlot.custom\")})]})]})},j.id))})]}),a.jsxs(\"div\",{className:\"w-1/2 flex flex-col gap-4 min-h-0 overflow-y-auto\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:[c(\"configureAmsSlot.kProfileLabel\"),ne&&a.jsx(\"span\",{className:\"ml-2 text-xs text-bambu-blue\",children:c(\"configureAmsSlot.filteringFor\",{material:ne})})]}),be.length>0?a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:m?.name||\"\",onChange:j=>{const O=be.find(K=>K.name===j.target.value);p(O||null)},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 appearance-none pr-10\",children:[a.jsx(\"option\",{value:\"\",children:c(\"configureAmsSlot.noKProfile\")}),be.map(j=>a.jsxs(\"option\",{value:j.name,children:[j.name,\" (K=\",j.k_value,\")\"]},`${j.name}-${j.extruder_id}`))]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}):d?a.jsx(\"p\",{className:\"text-sm text-bambu-gray italic py-2\",children:c(\"configureAmsSlot.noMatchingKProfiles\")}):a.jsx(\"span\",{className:\"inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30\",children:c(\"configureAmsSlot.selectFilamentFirst\")}),m&&a.jsx(\"p\",{className:\"text-xs text-bambu-green mt-1\",children:c(\"configureAmsSlot.kFromCalibration\",{value:m.k_value})})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:c(\"configureAmsSlot.customColorLabel\")}),me.length>0&&a.jsxs(\"div\",{className:\"mb-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-1.5\",children:c(\"configureAmsSlot.presetColors\",{name:W?.fullName})}),a.jsx(\"div\",{className:\"flex flex-wrap gap-1.5\",children:me.map(j=>a.jsxs(\"button\",{onClick:()=>{const O=j.hex_color.replace(\"#\",\"\").toUpperCase();y(O),b(j.color_name)},className:`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${f===j.hex_color.replace(\"#\",\"\").toUpperCase()?\"border-bambu-green scale-105\":\"border-white/20 hover:border-white/40\"}`,title:j.color_name,children:[a.jsx(\"span\",{className:\"w-4 h-4 rounded-full border border-black/20 flex-shrink-0\",style:{backgroundColor:j.hex_color}}),a.jsx(\"span\",{className:\"text-xs text-white/80 whitespace-nowrap\",children:j.color_name})]},j.id))})]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-1.5 mb-2\",children:[YW.map(j=>a.jsx(\"button\",{onClick:()=>{y(j.hex),b(j.name)},className:`w-7 h-7 rounded-md border-2 transition-all ${f===j.hex?\"border-bambu-green scale-110\":\"border-white/20 hover:border-white/40\"}`,style:{backgroundColor:`#${j.hex}`},title:j.name},j.hex)),a.jsx(\"button\",{onClick:()=>A(!N),className:\"w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs\",title:c(N?\"configureAmsSlot.showLessColors\":\"configureAmsSlot.showMoreColors\"),children:N?\"−\":\"+\"})]}),N&&a.jsx(\"div\",{className:\"flex flex-wrap gap-1.5 mb-2\",children:QW.map(j=>a.jsx(\"button\",{onClick:()=>{y(j.hex),b(j.name)},className:`w-7 h-7 rounded-md border-2 transition-all ${f===j.hex?\"border-bambu-green scale-110\":\"border-white/20 hover:border-white/40\"}`,style:{backgroundColor:`#${j.hex}`},title:j.name},j.hex))}),a.jsxs(\"div\",{className:\"flex gap-2 items-center\",children:[a.jsx(\"div\",{className:\"w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0\",style:{backgroundColor:`#${E}`}}),a.jsx(\"input\",{type:\"text\",placeholder:c(\"configureAmsSlot.colorPlaceholder\"),value:v,onChange:j=>{const O=j.target.value;b(O);const K=ZW(O);if(K)y(K);else{const U=O.replace(/[^0-9A-Fa-f]/g,\"\").toUpperCase();U.length===6?y(U):U.length===3&&y(U.split(\"\").map(de=>de+de).join(\"\"))}},className:\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm\"}),f&&a.jsx(\"button\",{onClick:()=>{y(\"\"),b(\"\")},className:\"px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded\",title:c(\"configureAmsSlot.clearCustomColor\"),children:c(\"configureAmsSlot.clear\")})]}),f&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1.5\",children:c(\"configureAmsSlot.hexLabel\",{hex:f})})]})]})]}):a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:[c(\"configureAmsSlot.filamentProfile\"),\" \",a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"})]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"input\",{type:\"text\",placeholder:c(\"configureAmsSlot.searchPresets\"),value:g,onChange:j=>_(j.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2\"}),a.jsx(\"div\",{className:\"max-h-48 overflow-y-auto space-y-1\",children:re.length===0?a.jsx(\"p\",{className:\"text-center py-4 text-bambu-gray\",children:F?.filament?.length===0&&!H?.filament?.length?c(\"configureAmsSlot.noPresetsAvailable\"):c(\"configureAmsSlot.noMatchingPresets\")}):re.map(j=>a.jsx(\"button\",{\"data-preset-id\":j.id,onClick:()=>u(j.id),className:`w-full p-2 rounded-lg border text-left transition-colors ${d===j.id?\"bg-bambu-green/20 border-bambu-green\":\"bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray\"}`,children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-white text-sm truncate\",children:j.name}),a.jsxs(\"div\",{className:\"flex items-center gap-1 flex-shrink-0\",children:[j.source===\"local\"&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400\",children:c(\"profiles.localProfiles.badge\")}),j.source===\"builtin\"&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400\",children:c(\"configureAmsSlot.builtin\")}),j.isUser&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue\",children:c(\"configureAmsSlot.custom\")})]})]})},j.id))})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:[c(\"configureAmsSlot.kProfileLabel\"),ne&&a.jsx(\"span\",{className:\"ml-2 text-xs text-bambu-blue\",children:c(\"configureAmsSlot.filteringFor\",{material:ne})})]}),be.length>0?a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:m?.name||\"\",onChange:j=>{const O=be.find(K=>K.name===j.target.value);p(O||null)},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 appearance-none pr-10\",children:[a.jsx(\"option\",{value:\"\",children:c(\"configureAmsSlot.noKProfile\")}),be.map(j=>a.jsxs(\"option\",{value:j.name,children:[j.name,\" (K=\",j.k_value,\")\"]},`${j.name}-${j.extruder_id}`))]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}):d?a.jsx(\"p\",{className:\"text-sm text-bambu-gray italic py-2\",children:c(\"configureAmsSlot.noMatchingKProfiles\")}):a.jsx(\"span\",{className:\"inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30\",children:c(\"configureAmsSlot.selectFilamentFirst\")}),m&&a.jsx(\"p\",{className:\"text-xs text-bambu-green mt-1\",children:c(\"configureAmsSlot.kFromCalibration\",{value:m.k_value})})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:c(\"configureAmsSlot.customColorLabel\")}),me.length>0&&a.jsxs(\"div\",{className:\"mb-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-1.5\",children:c(\"configureAmsSlot.presetColors\",{name:W?.fullName})}),a.jsx(\"div\",{className:\"flex flex-wrap gap-1.5\",children:me.map(j=>a.jsxs(\"button\",{onClick:()=>{const O=j.hex_color.replace(\"#\",\"\").toUpperCase();y(O),b(j.color_name)},className:`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${f===j.hex_color.replace(\"#\",\"\").toUpperCase()?\"border-bambu-green scale-105\":\"border-white/20 hover:border-white/40\"}`,title:j.color_name,children:[a.jsx(\"span\",{className:\"w-4 h-4 rounded-full border border-black/20 flex-shrink-0\",style:{backgroundColor:j.hex_color}}),a.jsx(\"span\",{className:\"text-xs text-white/80 whitespace-nowrap\",children:j.color_name})]},j.id))})]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-1.5 mb-2\",children:[YW.map(j=>a.jsx(\"button\",{onClick:()=>{y(j.hex),b(j.name)},className:`w-7 h-7 rounded-md border-2 transition-all ${f===j.hex?\"border-bambu-green scale-110\":\"border-white/20 hover:border-white/40\"}`,style:{backgroundColor:`#${j.hex}`},title:j.name},j.hex)),a.jsx(\"button\",{onClick:()=>A(!N),className:\"w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs\",title:c(N?\"configureAmsSlot.showLessColors\":\"configureAmsSlot.showMoreColors\"),children:N?\"−\":\"+\"})]}),N&&a.jsx(\"div\",{className:\"flex flex-wrap gap-1.5 mb-2\",children:QW.map(j=>a.jsx(\"button\",{onClick:()=>{y(j.hex),b(j.name)},className:`w-7 h-7 rounded-md border-2 transition-all ${f===j.hex?\"border-bambu-green scale-110\":\"border-white/20 hover:border-white/40\"}`,style:{backgroundColor:`#${j.hex}`},title:j.name},j.hex))}),a.jsxs(\"div\",{className:\"flex gap-2 items-center\",children:[a.jsx(\"div\",{className:\"w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0\",style:{backgroundColor:`#${E}`}}),a.jsx(\"input\",{type:\"text\",placeholder:c(\"configureAmsSlot.colorPlaceholder\"),value:v,onChange:j=>{const O=j.target.value;b(O);const K=ZW(O);if(K)y(K);else{const U=O.replace(/[^0-9A-Fa-f]/g,\"\").toUpperCase();U.length===6?y(U):U.length===3&&y(U.split(\"\").map(de=>de+de).join(\"\"))}},className:\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm\"}),f&&a.jsx(\"button\",{onClick:()=>{y(\"\"),b(\"\")},className:\"px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded\",title:c(\"configureAmsSlot.clearCustomColor\"),children:c(\"configureAmsSlot.clear\")})]}),f&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1.5\",children:c(\"configureAmsSlot.hexLabel\",{hex:f})})]})]})]}),a.jsxs(\"div\",{className:\"flex justify-between p-4 border-t border-bambu-dark-tertiary shrink-0\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>fe.mutate(),disabled:fe.isPending||oe.isPending,className:\"text-red-400 hover:text-red-300 hover:bg-red-500/10\",children:fe.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),c(\"configureAmsSlot.resetting\")]}):a.jsxs(a.Fragment,{children:[a.jsx(co,{className:\"w-4 h-4\"}),c(\"configureAmsSlot.resetSlot\")]})}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:e,children:c(\"configureAmsSlot.cancel\")}),a.jsx(De,{onClick:()=>oe.mutate(),disabled:!Y,children:oe.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),c(\"configureAmsSlot.configuring\")]}):a.jsxs(a.Fragment,{children:[a.jsx(Ud,{className:\"w-4 h-4\"}),c(\"configureAmsSlot.configureSlot\")]})})]})]}),(oe.isError||fe.isError)&&a.jsx(\"div\",{className:\"mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400\",children:oe.error?.message||fe.error?.message})]})]})}function JW({className:t=\"w-4 h-4\"}){return a.jsx(\"svg\",{viewBox:\"0 0 1945 1370\",fill:\"none\",className:t,\"aria-hidden\":\"true\",children:a.jsxs(\"g\",{transform:\"translate(-754.293 -471.685)\",children:[a.jsxs(\"g\",{transform:\"translate(0.18191 255.976)\",children:[a.jsx(\"g\",{transform:\"matrix(1.05469 0 0 0.241063 -153.484 1120.2)\",children:a.jsx(\"rect\",{x:\"922.048\",y:\"1195.15\",width:\"1721.5\",height:\"470.135\",stroke:\"currentColor\",strokeOpacity:\"0.99\",strokeWidth:\"168.84\",strokeLinecap:\"round\",strokeLinejoin:\"round\"})}),a.jsx(\"g\",{transform:\"matrix(0.983656 0 0 1.0767 -62.2035 141.539)\",children:a.jsx(\"path\",{d:\"M2741.42,1175.93L895.832,1175.93L1125.16,621.902L2512.09,621.902L2741.42,1175.93Z\",fill:\"currentColor\",fillOpacity:\"0.05\",stroke:\"currentColor\",strokeOpacity:\"0.99\",strokeWidth:\"125.26\",strokeLinecap:\"round\",strokeLinejoin:\"round\"})})]}),a.jsx(\"g\",{transform:\"translate(21.1916 0.684817)\",children:a.jsx(\"path\",{d:\"M1981.31,567.518C1954.86,567.518 1933.39,546.047 1933.39,519.601C1933.39,493.156 1954.86,471.685 1981.31,471.685L2146.61,471.685C2173.07,471.685 2194.53,493.138 2194.53,519.601L2194.53,688.741C2194.53,715.187 2173.05,736.658 2146.61,736.658C2120.16,736.658 2098.69,715.187 2098.69,688.741L2098.69,567.518L1981.31,567.518ZM2098.69,1252.54C2098.69,1226.1 2120.16,1204.62 2146.61,1204.62C2173.05,1204.62 2194.53,1226.1 2194.53,1252.54L2194.53,1421.68C2194.53,1448.14 2173.07,1469.6 2146.61,1469.6L1981.31,1469.6C1954.86,1469.6 1933.39,1448.13 1933.39,1421.68C1933.39,1395.24 1954.86,1373.76 1981.31,1373.76L2098.69,1373.76L2098.69,1252.54ZM1430.29,1373.76C1456.74,1373.76 1478.21,1395.24 1478.21,1421.68C1478.21,1448.13 1456.74,1469.6 1430.29,1469.6L1264.99,1469.6C1238.53,1469.6 1217.07,1448.14 1217.07,1421.68L1217.07,1252.54C1217.07,1226.1 1238.55,1204.62 1264.99,1204.62C1291.44,1204.62 1312.91,1226.1 1312.91,1252.54L1312.91,1373.76L1430.29,1373.76ZM1312.91,688.741C1312.91,715.187 1291.44,736.658 1264.99,736.658C1238.55,736.658 1217.07,715.187 1217.07,688.741L1217.07,519.601C1217.07,493.138 1238.53,471.685 1264.99,471.685L1430.29,471.685C1456.74,471.685 1478.21,493.156 1478.21,519.601C1478.21,546.047 1456.74,567.518 1430.29,567.518L1312.91,567.518L1312.91,688.741Z\",fill:\"currentColor\",fillOpacity:\"0.99\"})})]})})}function Yae({folderId:t,onClose:e,onUploadComplete:n,onFileUploaded:r,autoUpload:i,validateFile:s,accept:o}){const{t:l}=Ft(),[c,d]=w.useState([]),[u,m]=w.useState(!1),[p,f]=w.useState(!1),[y,v]=w.useState(!0),[b,g]=w.useState(!1),[_,C]=w.useState(!0),[P,N]=w.useState(null),A=w.useRef(null),T=re=>{re.preventDefault(),m(!0)},F=re=>{re.preventDefault(),m(!1)},k=re=>{re.preventDefault(),m(!1),Q(Array.from(re.dataTransfer.files))},D=re=>{re.target.files&&Q(Array.from(re.target.files))},H=(re,W)=>{d(ne=>ne.map(me=>me.file===re?{...me,...W}:me))},z=async re=>{f(!0);for(const W of re)if(W.status===\"pending\"){H(W.file,{status:\"uploading\"});try{if(W.isZip){const ne=await ue.extractZipFile(W.file,t,y,b,_);H(W.file,{status:ne.errors.length>0&&ne.extracted===0?\"error\":\"success\",extractedCount:ne.extracted,error:ne.errors.length>0?l(\"fileManager.zipFilesFailed\",\"{{count}} files failed\",{count:ne.errors.length}):void 0})}else{const ne=await ue.uploadLibraryFile(W.file,t,_);H(W.file,{status:\"success\"});const me=r?.(ne);if(me){N(me),d([]),f(!1);return}}}catch(ne){H(W.file,{status:\"error\",error:ne instanceof Error?ne.message:l(\"fileManager.uploadFailed\",\"Upload failed\")})}}f(!1),n(),e()},Q=re=>{if(N(null),s)for(const ne of re){const me=s(ne);if(me){N(me);return}}const W=re.map(ne=>({file:ne,status:\"pending\",isZip:ne.name.toLowerCase().endsWith(\".zip\"),is3mf:ne.name.toLowerCase().endsWith(\".3mf\")}));d(ne=>[...ne,...W]),i&&re.length>0&&z(W)},L=re=>{d(W=>W.filter((ne,me)=>me!==re))},te=c.some(re=>re.isZip&&re.status===\"pending\"),ie=c.some(re=>re.file.name.toLowerCase().endsWith(\".stl\")&&re.status===\"pending\"),J=c.some(re=>re.is3mf&&re.status===\"pending\"),oe=c.filter(re=>re.status===\"pending\").length,fe=c.length>0&&oe===0&&!p;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary flex items-center justify-between\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:l(\"fileManager.uploadFiles\")}),a.jsx(\"button\",{onClick:e,className:\"p-1 hover:bg-bambu-dark rounded\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-4\",children:[a.jsxs(\"div\",{onDragOver:T,onDragLeave:F,onDrop:k,onClick:()=>A.current?.click(),className:`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${u?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary hover:border-bambu-green/50\"}`,children:[a.jsx(La,{className:`w-10 h-10 mx-auto mb-3 ${u?\"text-bambu-green\":\"text-bambu-gray\"}`}),a.jsx(\"p\",{className:\"text-white font-medium\",children:l(u?\"fileManager.dropFilesHere\":\"fileManager.dragDropFiles\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:l(\"fileManager.orClickToBrowse\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray/70 mt-2\",children:l(\"fileManager.allFileTypesSupported\")})]}),a.jsx(\"input\",{ref:A,type:\"file\",multiple:!0,accept:o,className:\"hidden\",onChange:D}),te&&a.jsx(\"div\",{className:\"p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(so,{className:\"w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"p\",{className:\"text-sm text-blue-300 font-medium\",children:l(\"fileManager.zipFilesDetected\")}),a.jsx(\"p\",{className:\"text-xs text-blue-300/70 mt-1\",children:l(\"fileManager.zipExtractOptions\")}),a.jsxs(\"label\",{className:\"flex items-center gap-2 mt-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:y,onChange:re=>v(re.target.checked),className:\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:l(\"fileManager.preserveZipStructure\")})]}),a.jsxs(\"label\",{className:\"flex items-center gap-2 mt-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:b,onChange:re=>g(re.target.checked),className:\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:l(\"fileManager.createFolderFromZip\")})]})]})]})}),J&&a.jsx(\"div\",{className:\"p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(Er,{className:\"w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"p\",{className:\"text-sm text-purple-300 font-medium\",children:l(\"fileManager.threemfDetected\")}),a.jsx(\"p\",{className:\"text-xs text-purple-300/70 mt-1\",children:l(\"fileManager.threemfExtractionInfo\")})]})]})}),(ie||te)&&a.jsx(\"div\",{className:\"p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(gm,{className:\"w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-green font-medium\",children:l(\"fileManager.stlThumbnailGeneration\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-green/70 mt-1\",children:l(te&&!ie?\"fileManager.zipMayContainStl\":\"fileManager.thumbnailsCanBeGenerated\")}),a.jsxs(\"label\",{className:\"flex items-center gap-2 mt-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:_,onChange:re=>C(re.target.checked),className:\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:l(\"fileManager.generateThumbnailsForStl\")})]})]})]})}),c.length>0&&a.jsx(\"div\",{className:\"max-h-48 overflow-y-auto space-y-2\",children:c.map((re,W)=>a.jsxs(\"div\",{className:\"flex items-center gap-3 p-2 bg-bambu-dark rounded-lg\",children:[re.isZip?a.jsx(so,{className:\"w-4 h-4 text-blue-400 flex-shrink-0\"}):a.jsx(qP,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white truncate\",children:re.file.name}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[(re.file.size/1024/1024).toFixed(2),\" MB\",re.isZip&&re.status===\"pending\"&&a.jsxs(\"span\",{className:\"text-blue-400 ml-2\",children:[\"• \",l(\"fileManager.willBeExtracted\")]}),re.extractedCount!==void 0&&a.jsxs(\"span\",{className:\"text-green-400 ml-2\",children:[\"• \",l(\"fileManager.filesExtracted\",{count:re.extractedCount})]})]})]}),re.status===\"pending\"&&a.jsx(\"button\",{onClick:()=>L(W),className:\"p-1 hover:bg-bambu-dark-tertiary rounded\",children:a.jsx(Ht,{className:\"w-4 h-4 text-bambu-gray\"})}),re.status===\"uploading\"&&a.jsx(ft,{className:\"w-4 h-4 text-bambu-green animate-spin\"}),re.status===\"success\"&&a.jsx(yr,{className:\"w-4 h-4 text-green-500\"}),re.status===\"error\"&&a.jsx(\"span\",{title:re.error,children:a.jsx(Ma,{className:\"w-4 h-4 text-red-500\"})})]},W))}),P&&a.jsx(\"div\",{className:\"p-3 bg-red-500/10 border border-red-500/30 rounded-lg\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(Ma,{className:\"w-5 h-5 text-red-400 mt-0.5 flex-shrink-0\"}),a.jsx(\"p\",{className:\"text-sm text-red-300\",children:P})]})})]}),a.jsxs(\"div\",{className:\"p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:e,children:l(\"common.cancel\")}),!fe&&a.jsx(De,{onClick:()=>z(c),disabled:oe===0||p,children:p?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 mr-2 animate-spin\"}),l(\"fileManager.uploading\")]}):a.jsxs(a.Fragment,{children:[a.jsx(La,{className:\"w-4 h-4 mr-2\"}),l(\"common.upload\"),\" \",oe>0?`(${oe})`:\"\"]})})]})]})})}function eK(t){return t?`#${t.replace(\"#\",\"\").substring(0,6)}`:\"#808080\"}function Za(t){return t?t.replace(\"#\",\"\").toLowerCase().substring(0,6):\"\"}const c7e=[[\"PA-CF\",\"PA12-CF\",\"PAHT-CF\"]],Qae={};for(const t of c7e){const e=t[0];for(const n of t)Qae[n.toUpperCase()]=e.toUpperCase()}function UC(t){if(!t)return\"\";const e=t.toUpperCase();return Qae[e]??e}function DD(t,e){return UC(t)===UC(e)}function zd(t,e,n=40){const r=Za(t),i=Za(e);if(!r||!i||r.length<6||i.length<6)return!1;const s=parseInt(r.substring(0,2),16),o=parseInt(r.substring(2,4),16),l=parseInt(r.substring(4,6),16),c=parseInt(i.substring(0,2),16),d=parseInt(i.substring(2,4),16),u=parseInt(i.substring(4,6),16);return Math.abs(s-c)<=n&&Math.abs(o-d)<=n&&Math.abs(l-u)<=n}function RS(t,e,n,r){if(r)return\"Ext\";const i=String.fromCharCode(65+(t>=128?t-128:t));return n?`HT-${i}`:`${i}${e+1}`}function Ag(t,e,n){return n?254+e:t>=128?t:t*4+e}function Ox(t){return t>50?\"#00ae42\":t>=15?\"#f59e0b\":\"#ef4444\"}function tN(t){return!t?.remaining_weight||!t?.filament_weight||t.filament_weight<=0?null:Math.min(100,Math.round(t.remaining_weight/t.filament_weight*100))}function tK(t,e){return(Number.isFinite(t)?Math.max(0,Math.trunc(t)):0).toString(16).toUpperCase().padStart(e,\"0\").slice(-e)}function d7e(t){const e=(t||\"\").trim().toUpperCase();let n=2166136261;for(let r=0;r<e.length;r++)n^=e.charCodeAt(r),n=Math.imul(n,16777619);return(n>>>0).toString(16).toUpperCase().padStart(8,\"0\")}function Vu(t,e,n){return`${d7e(t)}${tK(e,4)}${tK(n,4)}`}function nK(t){if(!t)return!1;const e=Date.now()+4320*60*60*1e3;return(Zn(t)?.getTime()??0)>e}function u7e(t,e,n,r){let i=Zae(e,t.nozzle_id);r&&(i=[...i].sort((c,d)=>{const u=(c.remain??-1)>=0?c.remain??-1:101,m=(d.remain??-1)>=0?d.remain??-1:101;return u-m}));const s=i.find(c=>!n.has(c.globalTrayId)&&DD(c.type,t.type)&&Za(c.color)===Za(t.color)),o=s?void 0:i.find(c=>!n.has(c.globalTrayId)&&DD(c.type,t.type)&&zd(c.color,t.color)),l=s||o?void 0:i.find(c=>!n.has(c.globalTrayId)&&DD(c.type,t.type));return s??o??l}function Zae(t,e){return t.filter(n=>e==null||n.extruderId===e)}function LS(t){const e=[],n=t?.ams_extruder_map,r=n&&Object.keys(n).length>0;t?.ams?.forEach(i=>{const s=i.tray.length===1;i.tray.forEach(o=>{if(o.tray_type){const l=eK(o.tray_color);e.push({type:o.tray_type,color:l,colorName:rc(l),amsId:i.id,trayId:o.id,isHt:s,isExternal:!1,label:RS(i.id,o.id,s,!1),globalTrayId:Ag(i.id,o.id,!1),trayInfoIdx:o.tray_info_idx||\"\",traySubBrands:o.tray_sub_brands||\"\",extruderId:n?.[String(i.id)],remain:o.remain??-1})}})});for(const i of t?.vt_tray??[])if(i.tray_type){const s=eK(i.tray_color),o=i.id??254,l=(t?.vt_tray?.length??0)>1;e.push({type:i.tray_type,color:s,colorName:rc(s),amsId:-1,trayId:o-254,isHt:!1,isExternal:!0,label:l?o===254?\"Ext-L\":\"Ext-R\":\"External\",globalTrayId:o,trayInfoIdx:i.tray_info_idx||\"\",traySubBrands:i.tray_sub_brands||\"\",extruderId:r?255-o:void 0,remain:i.remain??-1})}return e}function rK(t,e,n){if(!t?.filaments||t.filaments.length===0)return;const r=LS(e);if(r.length===0)return;const i=new Set,s=t.filaments.map(c=>{const d=c.tray_info_idx||\"\";let u=r.filter(b=>!i.has(b.globalTrayId));c.nozzle_id!=null&&(u=u.filter(b=>b.extruderId===c.nozzle_id)),n&&(u=[...u].sort((b,g)=>{const _=b.remain>=0?b.remain:101,C=g.remain>=0?g.remain:101;return _-C}));let m,p,f,y;if(d){const b=u.filter(g=>g.trayInfoIdx===d);b.length===1?m=b[0]:b.length>1&&(n&&b.sort((g,_)=>{const C=g.remain>=0?g.remain:101,P=_.remain>=0?_.remain:101;return C-P}),p=b.find(g=>g.type?.toUpperCase()===c.type?.toUpperCase()&&Za(g.color)===Za(c.color)),p||(f=b.find(g=>g.type?.toUpperCase()===c.type?.toUpperCase()&&zd(g.color,c.color))),!p&&!f&&(y=b.find(g=>g.type?.toUpperCase()===c.type?.toUpperCase())))}!m&&!p&&!f&&!y&&(p=u.find(b=>b.type?.toUpperCase()===c.type?.toUpperCase()&&Za(b.color)===Za(c.color)),p||(f=u.find(b=>b.type?.toUpperCase()===c.type?.toUpperCase()&&zd(b.color,c.color))),!p&&!f&&(y=u.find(b=>b.type?.toUpperCase()===c.type?.toUpperCase())));const v=m||p||f||y||void 0;return v&&i.add(v.globalTrayId),{slot_id:c.slot_id,globalTrayId:v?.globalTrayId??-1}}),o=Math.max(...s.map(c=>c.slot_id||0));if(o<=0)return;const l=new Array(o).fill(-1);return s.forEach(c=>{c.slot_id&&c.slot_id>0&&(l[c.slot_id-1]=c.globalTrayId)}),l}function m7e(t){return w.useMemo(()=>LS(t),[t])}function Jae(t,e,n,r){const i=m7e(e),s=w.useMemo(()=>{if(!t?.filaments||t.filaments.length===0)return[];const d=new Set(Object.values(n));return t.filaments.map(u=>{const m=u.slot_id||0;if(m>0&&n[m]!==void 0){const T=n[m],F=i.find(k=>k.globalTrayId===T);if(F){const k=F.type?.toUpperCase()===u.type?.toUpperCase(),D=Za(F.color)===Za(u.color)||zd(F.color,u.color);let H;return k&&D?H=\"match\":k?H=\"type_only\":H=\"mismatch\",{...u,loaded:F,hasFilament:!0,typeMatch:k,colorMatch:D,status:H,isManual:!0}}}const p=u.tray_info_idx||\"\";let f=i.filter(T=>!d.has(T.globalTrayId));u.nozzle_id!=null&&(f=f.filter(T=>T.extruderId===u.nozzle_id)),r&&(f=[...f].sort((T,F)=>{const k=T.remain>=0?T.remain:101,D=F.remain>=0?F.remain:101;return k-D}));let y,v,b,g;if(p){const T=f.filter(F=>F.trayInfoIdx===p);T.length===1?y=T[0]:T.length>1&&(r&&T.sort((F,k)=>{const D=F.remain>=0?F.remain:101,H=k.remain>=0?k.remain:101;return D-H}),v=T.find(F=>F.type?.toUpperCase()===u.type?.toUpperCase()&&Za(F.color)===Za(u.color)),v||(b=T.find(F=>F.type?.toUpperCase()===u.type?.toUpperCase()&&zd(F.color,u.color))),!v&&!b&&(g=T.find(F=>F.type?.toUpperCase()===u.type?.toUpperCase())))}!y&&!v&&!b&&!g&&(v=f.find(T=>T.type?.toUpperCase()===u.type?.toUpperCase()&&Za(T.color)===Za(u.color)),v||(b=f.find(T=>T.type?.toUpperCase()===u.type?.toUpperCase()&&zd(T.color,u.color))),!v&&!b&&(g=f.find(T=>T.type?.toUpperCase()===u.type?.toUpperCase())));const _=y||v||b||g||void 0;_&&d.add(_.globalTrayId);const C=!!_,P=C,N=!!y||!!v||!!b;let A;return y||v||b?A=\"match\":g?A=\"type_only\":A=\"mismatch\",{...u,loaded:_,hasFilament:C,typeMatch:P,colorMatch:N,status:A,isManual:!1}})},[t,i,n,r]),o=w.useMemo(()=>{if(s.length===0)return;const d=Math.max(...s.map(m=>m.slot_id||0));if(d<=0)return;const u=new Array(d).fill(-1);return s.forEach(m=>{m.slot_id&&m.slot_id>0&&(u[m.slot_id-1]=m.loaded?.globalTrayId??-1)}),u},[s]),l=s.some(d=>d.status===\"mismatch\"),c=s.some(d=>d.status===\"type_only\");return{loadedFilaments:i,filamentComparison:s,amsMapping:o,hasTypeMismatch:l,hasColorMismatch:c}}function h7e(t,e,n,r){if(!t||t.length===0)return{exactMatches:0,typeOnlyMatches:0,missingTypes:0,totalSlots:0,status:\"full\"};let i=0,s=0,o=0;const l=new Set(Object.values(n));for(const u of t){const m=u.slot_id||0;if(m>0&&n[m]!==void 0){const g=n[m],_=e.find(C=>C.globalTrayId===g);if(_){const C=_.type?.toUpperCase()===u.type?.toUpperCase(),P=Za(_.color)===Za(u.color)||zd(_.color,u.color);C&&P?i++:C?s++:o++;continue}}let p=e.filter(g=>!l.has(g.globalTrayId));if(u.nozzle_id!=null){const g=p.filter(_=>_.extruderId===u.nozzle_id);g.length>0&&(p=g)}r&&(p=[...p].sort((g,_)=>{const C=g.remain>=0?g.remain:101,P=_.remain>=0?_.remain:101;return C-P}));const f=p.find(g=>g.type?.toUpperCase()===u.type?.toUpperCase()&&Za(g.color)===Za(u.color)),y=f?void 0:p.find(g=>g.type?.toUpperCase()===u.type?.toUpperCase()&&zd(g.color,u.color)),v=f||y?void 0:p.find(g=>g.type?.toUpperCase()===u.type?.toUpperCase()),b=f??y??v;b&&l.add(b.globalTrayId),f||y?i++:v?s++:o++}const c=t.length;let d=\"full\";return o>0?d=\"missing\":s>0&&(d=\"partial\"),{exactMatches:i,typeOnlyMatches:s,missingTypes:o,totalSlots:c,status:d}}function p7e(t,e,n,r){if(!t?.filaments||t.filaments.length===0)return;const i=LS(e);if(i.length===0)return;const s=new Set(Object.values(n)),o=[];for(const d of t.filaments){const u=d.slot_id||0;if(u>0&&n[u]!==void 0){o.push({slot_id:u,globalTrayId:n[u]});continue}let m=i.filter(b=>!s.has(b.globalTrayId));if(d.nozzle_id!=null){const b=m.filter(g=>g.extruderId===d.nozzle_id);b.length>0&&(m=b)}r&&(m=[...m].sort((b,g)=>{const _=b.remain>=0?b.remain:101,C=g.remain>=0?g.remain:101;return _-C}));const p=m.find(b=>b.type?.toUpperCase()===d.type?.toUpperCase()&&Za(b.color)===Za(d.color)),f=p?void 0:m.find(b=>b.type?.toUpperCase()===d.type?.toUpperCase()&&zd(b.color,d.color)),y=p||f?void 0:m.find(b=>b.type?.toUpperCase()===d.type?.toUpperCase()),v=p??f??y;v&&s.add(v.globalTrayId),o.push({slot_id:u,globalTrayId:v?.globalTrayId??-1})}const l=Math.max(...o.map(d=>d.slot_id||0));if(l<=0)return;const c=new Array(l).fill(-1);return o.forEach(d=>{d.slot_id&&d.slot_id>0&&(c[d.slot_id-1]=d.globalTrayId)}),c}const aK={useDefault:!0,manualMappings:{},autoConfigured:!1};function f7e(t,e,n,r,i,s,o){const l=Pp({queries:t.map(v=>({queryKey:[\"printer-status\",v],queryFn:()=>ue.getPrinterStatus(v),enabled:t.length>0,staleTime:5e3}))}),c=w.useMemo(()=>t.map((v,b)=>{const g=l[b],_=g?.data,P=e?.find(H=>H.id===v)?.name||`Printer ${v}`,N=LS(_),A=i[v]||aK,T=rK(n,_,o),F=A.useDefault?r:A.manualMappings,k=p7e(n,_,F,o),D=h7e(n?.filaments,N,F,o);return{printerId:v,printerName:P,status:_,isLoading:g?.isLoading??!1,loadedFilaments:N,autoMapping:T,finalMapping:k,matchStatus:D.status,exactMatches:D.exactMatches,typeOnlyMatches:D.typeOnlyMatches,missingTypes:D.missingTypes,totalSlots:D.totalSlots,config:A}}),[t,l,e,n,i,r,o]),d=l.some(v=>v.isLoading),u=(v,b)=>{s(g=>({...g,[v]:{...g[v]||aK,...b}}))},m=v=>{const b=c.find(C=>C.printerId===v);if(!b||!b.status||!n?.filaments)return;const g=rK(n,b.status,o);if(!g)return;const _={};g.forEach((C,P)=>{C!==-1&&(_[P+1]=C)}),u(v,{useDefault:!1,manualMappings:_,autoConfigured:!0})},p=()=>{for(const v of t)m(v)},f=v=>c.find(g=>g.printerId===v)?.finalMapping,y=c.every(v=>v.matchStatus!==\"missing\");return{printerResults:c,isLoading:d,perPrinterConfigs:i,updatePrinterConfig:u,autoConfigureAll:p,autoConfigurePrinter:m,getFinalMapping:f,allPrintersReady:y}}const eie={USD:\"$\",EUR:\"€\",GBP:\"£\",CHF:\"Fr.\",JPY:\"¥\",CNY:\"¥\",CAD:\"$\",AUD:\"$\",INR:\"₹\",HKD:\"HK$\",KRW:\"₩\",SEK:\"kr\",NOK:\"kr\",DKK:\"kr\",PLN:\"zł\",BRL:\"R$\",TWD:\"NT$\",SGD:\"S$\",NZD:\"NZ$\",MXN:\"MX$\",MYR:\"RM\",CZK:\"Kč\",THB:\"฿\",ZAR:\"R\",TRY:\"₺\",RUB:\"₽\",HUF:\"Ft\",ILS:\"₪\",UAH:\"₴\"};function Eo(t){return eie[t.toUpperCase()]||t}const g7e=Object.entries(eie).map(([t,e])=>({code:t,label:`${t} (${e})`}));function b7e({printerId:t,filamentReqs:e,manualMappings:n,onManualMappingChange:r,currencySymbol:i,defaultCostPerKg:s,defaultExpanded:o=!1}){const{t:l}=Ft(),c=nn(),[d,u]=w.useState(!1),[m,p]=w.useState(o),{data:f}=Xe({queryKey:[\"printer-status\",t],queryFn:()=>ue.getPrinterStatus(t),enabled:!!t}),{data:y}=Xe({queryKey:[\"spool-assignments\",t],queryFn:()=>ue.getAssignments(t),enabled:!!t}),{loadedFilaments:v,filamentComparison:b,hasTypeMismatch:g,hasColorMismatch:_}=Jae(e,f,n),C=w.useMemo(()=>{const z=new Map;for(const Q of y||[]){const L=Q.ams_id===255,te=Ag(Q.ams_id,Q.tray_id,L);z.set(te,Q.spool?.cost_per_kg??null)}return z},[y]),P=w.useMemo(()=>{const z=new Map;for(const Q of y||[]){const L=Q.ams_id===255,te=Ag(Q.ams_id,Q.tray_id,L),ie=Q.spool;if(!ie){z.set(te,null);continue}z.set(te,Math.max(0,Math.round((ie.label_weight??0)-(ie.weight_used??0))))}return z},[y]),N=w.useMemo(()=>{let z=0;for(const Q of b){const L=Q.loaded?.globalTrayId;if(L==null)continue;const ie=C.get(L)??null??s;ie>0&&(z+=Q.used_grams/1e3*ie)}return z},[b,C,s]),A=w.useMemo(()=>Array.from(C.values()).some(z=>z!=null&&z>0),[C]),T=e?.filaments&&e.filaments.length>0,F=e?.filaments?.some(z=>z.nozzle_id!=null)??!1;if(!T||!f)return null;const k=g?\"#f97316\":_?\"#facc15\":\"#00ae42\",D=(z,Q)=>{if(z>0)if(Q===\"\"){const L={...n};delete L[z],r(L)}else r({...n,[z]:parseInt(Q,10)})},H=async()=>{u(!0);try{await ue.refreshPrinterStatus(t),await new Promise(z=>setTimeout(z,500)),await c.refetchQueries({queryKey:[\"printer-status\",t]})}finally{u(!1)}};return a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>p(!m),className:\"flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full\",children:[a.jsx(aw,{className:\"w-4 h-4\",fill:k,stroke:\"none\"}),a.jsx(\"span\",{children:l(\"printModal.filamentMapping\")}),g?a.jsx(\"span\",{className:\"text-xs text-orange-400\",children:\"(Type not found)\"}):_?a.jsx(\"span\",{className:\"text-xs text-yellow-400\",children:\"(Color mismatch)\"}):a.jsx(\"span\",{className:\"text-xs text-bambu-green\",children:\"(Ready)\"}),m?a.jsx(cc,{className:\"w-4 h-4 ml-auto\"}):a.jsx(On,{className:\"w-4 h-4 ml-auto\"})]}),m&&a.jsxs(\"div\",{className:\"mt-2 bg-bambu-dark rounded-lg p-3 space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"Click to change slot assignment\"}),a.jsxs(\"button\",{type:\"button\",onClick:H,className:\"flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white\",disabled:d,children:[a.jsx(lr,{className:`w-3 h-3 ${d?\"animate-spin\":\"\"}`}),a.jsx(\"span\",{children:\"Re-read\"})]})]}),b.map((z,Q)=>a.jsxs(\"div\",{className:\"grid items-center gap-2 text-xs\",style:{gridTemplateColumns:\"16px minmax(70px, 1fr) auto 2fr 16px\"},children:[a.jsx(\"span\",{title:`Required: ${z.type} - ${rc(z.color)}`,children:a.jsx(aw,{className:\"w-3 h-3\",fill:z.color,stroke:z.color})}),a.jsxs(\"span\",{className:\"text-white truncate flex items-center gap-1\",children:[F&&z.nozzle_id!=null&&a.jsx(\"span\",{className:\"inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0\",title:z.nozzle_id===1?l(\"printModal.leftNozzleTooltip\"):l(\"printModal.rightNozzleTooltip\"),children:z.nozzle_id===1?l(\"printModal.leftNozzle\"):l(\"printModal.rightNozzle\")}),z.type,\" \",a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"(\",z.used_grams,\"g)\"]})]}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"→\"}),a.jsxs(\"select\",{value:z.loaded?.globalTrayId??\"\",onChange:L=>D(z.slot_id||0,L.target.value),className:`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${z.status===\"match\"?\"border-bambu-green/50 text-bambu-green\":z.status===\"type_only\"?\"border-yellow-400/50 text-yellow-400\":\"border-orange-400/50 text-orange-400\"} ${z.isManual?\"ring-1 ring-blue-400/50\":\"\"}`,title:z.isManual?\"Manually selected\":\"Auto-matched\",children:[a.jsx(\"option\",{value:\"\",className:\"bg-bambu-dark text-bambu-gray\",children:\"-- Select slot --\"}),v.filter(L=>z.nozzle_id==null||L.extruderId===z.nozzle_id).map(L=>{const te=P.get(L.globalTrayId),ie=te!=null?l(\"printModal.slotRemainingShort\",{grams:te,defaultValue:` - ${te}g left`}):\"\";return a.jsxs(\"option\",{value:L.globalTrayId,className:\"bg-bambu-dark text-white\",children:[L.label,\": \",L.traySubBrands||L.type,\" (\",L.colorName,\")\",ie]},L.globalTrayId)})]}),z.status===\"match\"?a.jsx(Ur,{className:\"w-3 h-3 text-bambu-green\"}):z.status===\"type_only\"?a.jsx(\"span\",{title:\"Same type, different color\",children:a.jsx(Dn,{className:\"w-3 h-3 text-yellow-400\"})}):a.jsx(\"span\",{title:\"Filament type not loaded\",children:a.jsx(Dn,{className:\"w-3 h-3 text-orange-400\"})})]},Q)),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray\",children:[l(\"printModal.totalCost\"),\" \",a.jsx(\"span\",{className:\"text-white\",children:N>0||A?`${i}${N.toFixed(2)}`:\"N/A\"})]}),g&&a.jsx(\"p\",{className:\"text-xs text-orange-400 mt-2\",children:\"Required filament type not found in printer.\"})]})]})}function x7e({filamentReqs:t,availableFilaments:e,overrides:n,onChange:r,forceColorMatch:i,onForceColorMatchChange:s}){const{t:o}=Ft(),l=w.useMemo(()=>{const u={};for(const m of e){const p=UC(m.type);u[p]||(u[p]=[]),u[p].push(m)}return u},[e]),c=t?.filaments;if(!c||c.length===0||e.length===0)return null;const d=(u,m)=>{if(m===\"\"){const p={...n};delete p[u],r(p)}else{const[p,f]=m.split(\"|\");r({...n,[u]:{type:p,color:f}})}};return a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsx(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray mb-2\",children:a.jsx(\"span\",{children:o(\"printModal.filamentOverride\")})}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:o(\"printModal.filamentOverrideHint\")}),a.jsx(\"div\",{className:\"bg-bambu-dark rounded-lg p-3 space-y-2\",children:c.map(u=>{const m=n[u.slot_id],p=!!m,f=l[UC(u.type)]||[],y=u.nozzle_id!=null?f.filter(v=>v.extruder_id==null||v.extruder_id===u.nozzle_id):f;return a.jsxs(\"div\",{className:\"space-y-1\",children:[a.jsxs(\"div\",{className:\"grid items-center gap-2 text-xs\",style:{gridTemplateColumns:\"16px minmax(70px, 1fr) auto 2fr 20px\"},children:[a.jsx(\"span\",{title:`${o(\"printModal.originalFilament\")}: ${u.type} - ${rc(u.color)}`,children:a.jsx(aw,{className:\"w-3 h-3\",fill:u.color,stroke:u.color})}),a.jsxs(\"span\",{className:\"text-white truncate\",children:[u.type,\" \",a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"(\",u.used_grams,\"g)\"]})]}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"→\"}),a.jsxs(\"select\",{value:p?`${m.type}|${m.color}`:\"\",onChange:v=>d(u.slot_id,v.target.value),disabled:y.length===0,className:`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${p?\"border-blue-400/50 text-blue-400\":\"border-bambu-gray/30 text-bambu-gray\"}`,children:[a.jsxs(\"option\",{value:\"\",className:\"bg-bambu-dark text-bambu-gray\",children:[o(\"printModal.originalFilament\"),\": \",u.type,\" (\",rc(u.color),\")\"]}),y.map((v,b)=>a.jsxs(\"option\",{value:`${v.type}|${v.color}`,className:\"bg-bambu-dark text-white\",children:[v.tray_sub_brands||v.type,\" (\",rc(v.color),\")\"]},`${v.type}-${v.color}-${v.tray_sub_brands}-${b}`))]}),p?a.jsx(\"button\",{type:\"button\",onClick:()=>d(u.slot_id,\"\"),className:\"text-bambu-gray hover:text-white transition-colors\",title:o(\"printModal.resetToOriginal\"),children:a.jsx(co,{className:\"w-3 h-3\"})}):a.jsx(\"span\",{className:\"w-3\"})]}),a.jsxs(\"label\",{className:\"inline-flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer select-none pl-5\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:i?.[u.slot_id]??!1,onChange:v=>s?.(u.slot_id,v.target.checked),className:\"accent-bambu-green w-3 h-3\"}),a.jsx(nS,{className:\"w-3 h-3\"}),o(\"printModal.forceColorMatch\")]})]},u.slot_id)})})]})}function y7e({plates:t,isMultiPlate:e,selectedPlates:n,onToggle:r,onSelectAll:i,onDeselectAll:s,multiSelect:o}){const{t:l}=Ft();if(!e||t.length<=1)return null;const c=n.size===t.length;return a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-2\",children:[a.jsx(da,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[\"Select Plate\",o?\"s\":\"\",\" to Print\"]}),n.size===0&&a.jsxs(\"span\",{className:\"text-xs text-orange-400 flex items-center gap-1\",children:[a.jsx(Dn,{className:\"w-3 h-3\"}),\"Selection required\"]}),o&&i&&s&&a.jsx(\"button\",{type:\"button\",onClick:c?s:i,className:`ml-auto text-xs px-2 py-0.5 rounded-full border transition-colors ${c?\"border-bambu-green bg-bambu-green/10 text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray\"}`,children:c?l(\"queue.deselectAll\"):l(\"queue.selectAllPlates\",{count:t.length})})]}),a.jsx(\"div\",{className:\"grid grid-cols-2 gap-2\",children:t.map(d=>{const u=n.has(d.index);return a.jsxs(\"button\",{type:\"button\",onClick:()=>r(d.index),className:`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${u?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray\"}`,children:[o&&(u?a.jsx(Ns,{className:\"w-4 h-4 text-bambu-green flex-shrink-0\"}):a.jsx(uo,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"})),d.has_thumbnail&&d.thumbnail_url!=null?a.jsx(\"img\",{src:Ga(d.thumbnail_url),alt:`Plate ${d.index}`,className:\"w-10 h-10 rounded object-cover bg-bambu-dark-tertiary\"}):a.jsx(\"div\",{className:\"w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center\",children:a.jsx(da,{className:\"w-5 h-5 text-bambu-gray\"})}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"text-sm text-white font-medium truncate\",children:d.name||`Plate ${d.index}`}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray truncate\",children:[d.objects.length>0?d.objects.slice(0,3).join(\", \")+(d.objects.length>3?\"...\":\"\"):`${d.filaments.length} filament${d.filaments.length!==1?\"s\":\"\"}`,d.print_time_seconds!=null?` • ${ws(d.print_time_seconds)}`:\"\"]})]}),!o&&u&&a.jsx(Ur,{className:\"w-4 h-4 text-bambu-green flex-shrink-0\"})]},d.index)})})]})}const v7e=new Set([\"IDLE\",\"FINISH\",\"FAILED\"]);function w7e({printerResult:t,filamentReqs:e,onUpdateConfig:n}){const r=nn(),[i,s]=w.useState(!1),o=(d,u)=>{if(d<=0)return;const m={...t.config.manualMappings};u===\"\"?delete m[d]:m[d]=parseInt(u,10),n({useDefault:!1,manualMappings:m,autoConfigured:!1})},l=async()=>{s(!0);try{await ue.refreshPrinterStatus(t.printerId),await new Promise(d=>setTimeout(d,500)),await r.refetchQueries({queryKey:[\"printer-status\",t.printerId]})}finally{s(!1)}},c=e.map(d=>{const u=d.slot_id||0,m=t.config.manualMappings[u];let p,f=!1;if(m!==void 0)p=t.loadedFilaments.find(v=>v.globalTrayId===m),f=!0;else{const v=new Set(Object.values(t.config.manualMappings)),b=r.getQueryData([\"settings\"]);p=u7e(d,t.loadedFilaments,v,b?.prefer_lowest_filament)}let y=\"mismatch\";if(p){const v=p.type?.toUpperCase()===d.type?.toUpperCase(),b=Za(p.color)===Za(d.color)||zd(p.color,d.color);v&&b?y=\"match\":v&&(y=\"type_only\")}return{req:d,loaded:p,status:y,isManual:f}});return a.jsxs(\"div\",{className:\"mt-2 bg-bambu-dark rounded-lg p-3 space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"Custom slot mapping\"}),a.jsxs(\"button\",{type:\"button\",onClick:l,className:\"flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white\",disabled:i,children:[a.jsx(lr,{className:`w-3 h-3 ${i?\"animate-spin\":\"\"}`}),a.jsx(\"span\",{children:\"Re-read\"})]})]}),c.map(({req:d,loaded:u,status:m,isManual:p},f)=>a.jsxs(\"div\",{className:\"grid items-center gap-2 text-xs\",style:{gridTemplateColumns:\"16px minmax(70px, 1fr) auto 2fr 16px\"},children:[a.jsx(\"span\",{title:`Required: ${d.type} - ${rc(d.color)}`,children:a.jsx(aw,{className:\"w-3 h-3\",fill:d.color,stroke:d.color})}),a.jsxs(\"span\",{className:\"text-white truncate\",children:[d.type,\" \",a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"(\",d.used_grams,\"g)\"]})]}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"→\"}),a.jsxs(\"select\",{value:u?.globalTrayId??\"\",onChange:y=>o(d.slot_id||0,y.target.value),className:`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${m===\"match\"?\"border-bambu-green/50 text-bambu-green\":m===\"type_only\"?\"border-yellow-400/50 text-yellow-400\":\"border-orange-400/50 text-orange-400\"} ${p?\"ring-1 ring-blue-400/50\":\"\"}`,title:p?\"Manually selected\":\"Auto-matched\",children:[a.jsx(\"option\",{value:\"\",className:\"bg-bambu-dark text-bambu-gray\",children:\"-- Select slot --\"}),Zae(t.loadedFilaments,d.nozzle_id).map(y=>a.jsxs(\"option\",{value:y.globalTrayId,className:\"bg-bambu-dark text-white\",children:[y.label,\": \",y.traySubBrands||y.type,\" (\",y.colorName,\")\"]},y.globalTrayId))]}),m===\"match\"?a.jsx(Ur,{className:\"w-3 h-3 text-bambu-green\"}):m===\"type_only\"?a.jsx(\"span\",{title:\"Same type, different color\",children:a.jsx(Dn,{className:\"w-3 h-3 text-yellow-400\"})}):a.jsx(\"span\",{title:\"Filament type not loaded\",children:a.jsx(Dn,{className:\"w-3 h-3 text-orange-400\"})})]},f))]})}function S7e({printers:t,selectedPrinterIds:e,onMultiSelect:n,isLoading:r=!1,allowMultiple:i=!1,showInactive:s=!1,disableBusy:o=!1,printerMappingResults:l,filamentReqs:c,onAutoConfigurePrinter:d,onUpdatePrinterConfig:u,assignmentMode:m=\"printer\",onAssignmentModeChange:p,targetModel:f,onTargetModelChange:y,targetLocation:v,onTargetLocationChange:b,slicedForModel:g}){const[_,C]=w.useState(!1),P=s?t:t.filter(ne=>ne.is_active),N=Pp({queries:P.map(ne=>({queryKey:[\"printerStatus\",ne.id],queryFn:()=>ue.getPrinterStatus(ne.id),staleTime:5e3}))}),A=w.useMemo(()=>{const ne=new Map;return P.forEach((me,be)=>{const Ce=N[be];Ce?.data&&ne.set(me.id,Ce.data)}),ne},[P,N]),T=ne=>{const me=A.get(ne);return me?me.connected?!v7e.has(me.state??\"\"):!0:!1},F=ne=>{const me=A.get(ne);if(!me)return null;if(!me.connected)return\"Offline\";const be=me.state;return be?be===\"RUNNING\"?me.stg_cur_name||\"Printing\":be===\"PREPARE\"?\"Preparing\":be===\"PAUSE\"?\"Paused\":be===\"IDLE\"?\"Idle\":be===\"FINISH\"?\"Finished\":be===\"FAILED\"?\"Failed\":be:null},k=w.useMemo(()=>{if(m!==\"printer\"||!g||_)return P;const ne=P.filter(me=>me.model===g);return ne.length>0?ne:P},[P,m,g,_]),D=P.length-k.length,H=w.useMemo(()=>{const ne=P.map(me=>me.model).filter(me=>!!me);return[...new Set(ne)].sort()},[P]),z=w.useMemo(()=>{if(!f)return[];const ne=P.filter(me=>me.model===f&&me.location).map(me=>me.location).filter(me=>!!me);return[...new Set(ne)].sort()},[P,f]),Q=p&&y&&H.length>0,L=i&&e.length>1&&l&&c?.filaments&&c.filaments.length>0&&d&&u;if(r)return a.jsx(\"div\",{className:\"flex justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})});if(k.length===0)return a.jsxs(\"div\",{className:\"flex items-center gap-2 text-red-400 text-sm mb-4\",children:[a.jsx(ei,{className:\"w-4 h-4\"}),\"No \",s?\"\":\"active \",\"printers available\"]});const te=ne=>{o&&T(ne)||(i?e.includes(ne)?n(e.filter(me=>me!==ne)):n([...e,ne]):n([ne]))},ie=()=>{const ne=o?k.filter(me=>!T(me.id)):k;n(ne.map(me=>me.id))},J=()=>{n([])},oe=(ne,me,be)=>{be.stopPropagation(),!(!d||!u)&&(me?d(ne):u(ne,{useDefault:!0,manualMappings:{},autoConfigured:!1}))},fe=ne=>e.includes(ne),re=e.length,W=ne=>l?.find(me=>me.printerId===ne);return a.jsxs(\"div\",{className:\"space-y-2 mb-6\",children:[Q&&a.jsxs(\"div\",{className:\"flex gap-2 mb-4\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>{p(\"printer\"),y(null)},className:`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${m===\"printer\"?\"border-bambu-green bg-bambu-green/10 text-white\":\"border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray\"}`,children:[a.jsx(Er,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"text-sm\",children:\"Specific Printer\"})]}),a.jsxs(\"button\",{type:\"button\",onClick:()=>{p(\"model\"),n([]);const ne=g&&H.includes(g)?g:H[0];y(ne)},className:`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${m===\"model\"?\"border-bambu-green bg-bambu-green/10 text-white\":\"border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray\"}`,children:[a.jsx(Wu,{className:\"w-4 h-4\"}),a.jsxs(\"span\",{className:\"text-sm\",children:[\"Any \",g||\"Model\"]})]})]}),m===\"model\"&&Q&&a.jsxs(\"div\",{className:\"space-y-3 mb-4\",children:[!g&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:\"Target Model\"}),a.jsxs(\"select\",{value:f||\"\",onChange:ne=>{y(ne.target.value||null),b&&b(null)},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 text-sm\",children:[a.jsx(\"option\",{value:\"\",children:\"Select a model...\"}),H.map(ne=>a.jsx(\"option\",{value:ne,children:ne},ne))]})]}),f&&z.length>0&&b&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:\"Location Filter (optional)\"}),a.jsxs(\"select\",{value:v||\"\",onChange:ne=>b(ne.target.value||null),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 text-sm\",children:[a.jsx(\"option\",{value:\"\",children:\"Any location\"}),z.map(ne=>a.jsx(\"option\",{value:ne,children:ne},ne))]})]}),f&&a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[\"Scheduler will assign to first available idle \",f,\" printer\",v?` in ${v}`:\"\"]})]}),m===\"printer\"&&i&&k.length>1&&a.jsxs(\"div\",{className:\"flex items-center justify-between text-xs text-bambu-gray mb-2\",children:[a.jsx(\"span\",{children:re===0?\"Select printers\":`${re} printer${re!==1?\"s\":\"\"} selected`}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[re<k.length&&a.jsx(\"button\",{type:\"button\",onClick:ie,className:\"text-bambu-green hover:text-bambu-green/80 transition-colors\",children:\"Select all\"}),re>0&&a.jsx(\"button\",{type:\"button\",onClick:J,className:\"text-bambu-gray hover:text-white transition-colors\",children:\"Clear\"})]})]}),m===\"printer\"&&k.map(ne=>{const me=fe(ne.id),be=W(ne.id),Ce=be&&!be.config.useDefault,q=T(ne.id),Y=o&&q,E=F(ne.id);return a.jsxs(\"div\",{children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>te(ne.id),disabled:Y,className:`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${Y?\"border-bambu-dark-tertiary bg-bambu-dark opacity-50 cursor-not-allowed\":me?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray\"} ${ne.is_active?\"\":\"opacity-60\"}`,children:[a.jsx(\"div\",{className:`p-2 rounded-lg ${Y?\"bg-bambu-dark-tertiary\":me?\"bg-bambu-green/20\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(Er,{className:`w-5 h-5 ${Y?\"text-bambu-gray/50\":me?\"text-bambu-green\":\"text-bambu-gray\"}`})}),a.jsxs(\"div\",{className:\"text-left flex-1\",children:[a.jsxs(\"p\",{className:`font-medium ${Y?\"text-bambu-gray\":\"text-white\"}`,children:[ne.name,!ne.is_active&&a.jsx(\"span\",{className:\"text-bambu-gray text-xs ml-2\",children:\"(inactive)\"})]}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[ne.model||\"Unknown model\",\" • \",ne.ip_address]})]}),E&&a.jsx(\"span\",{className:`text-xs px-2 py-0.5 rounded-full ${q?\"bg-yellow-500/20 text-yellow-400\":\"bg-bambu-green/20 text-bambu-green\"}`,children:E}),i&&a.jsx(\"div\",{className:`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${Y?\"border-bambu-gray/30\":me?\"bg-bambu-green border-bambu-green\":\"border-bambu-gray/50\"}`,children:me&&a.jsx(Ur,{className:\"w-3 h-3 text-white\"})})]}),me&&L&&be&&a.jsxs(\"div\",{className:\"ml-4 mt-2 mb-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"label\",{className:\"flex items-center gap-2 cursor-pointer\",onClick:j=>j.stopPropagation(),children:[a.jsx(\"input\",{type:\"checkbox\",checked:Ce,onChange:j=>oe(ne.id,j.target.checked,j),className:\"w-3.5 h-3.5 rounded border-bambu-gray/30 bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"Custom mapping\"})]}),a.jsxs(\"span\",{className:`text-xs ml-2 ${be.matchStatus===\"full\"?\"text-bambu-green\":be.matchStatus===\"partial\"?\"text-yellow-400\":\"text-orange-400\"}`,children:[\"(\",be.exactMatches,\"/\",be.totalSlots,\" matched)\"]}),be.isLoading&&a.jsx(lr,{className:\"w-3 h-3 text-bambu-gray animate-spin\"}),Ce&&a.jsxs(\"button\",{type:\"button\",onClick:j=>{j.stopPropagation(),d(ne.id)},className:\"ml-auto flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white\",children:[a.jsx(KQ,{className:\"w-3 h-3\"}),\"Auto\"]})]}),Ce&&a.jsx(w7e,{printerResult:be,filamentReqs:c.filaments,onUpdateConfig:j=>u(ne.id,j)})]})]},ne.id)}),m===\"printer\"&&D>0&&!_&&a.jsxs(\"button\",{type:\"button\",onClick:()=>C(!0),className:\"text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1\",children:[a.jsx(Dn,{className:\"w-3 h-3 text-yellow-400\"}),D,\" other printer\",D>1?\"s\":\"\",\" hidden (different model) —\",a.jsx(\"span\",{className:\"underline\",children:\"show all\"})]}),m===\"printer\"&&_&&g&&a.jsx(\"button\",{type:\"button\",onClick:()=>C(!1),className:\"text-xs text-bambu-gray hover:text-white transition-colors mt-2\",children:a.jsxs(\"span\",{className:\"underline\",children:[\"Show only \",g,\" printers\"]})}),m===\"printer\"&&re===0&&a.jsxs(\"p\",{className:\"text-xs text-orange-400 mt-1 flex items-center gap-1\",children:[a.jsx(ei,{className:\"w-3 h-3\"}),\"Select at least one printer\"]}),m===\"model\"&&!f&&a.jsxs(\"p\",{className:\"text-xs text-orange-400 mt-1 flex items-center gap-1\",children:[a.jsx(ei,{className:\"w-3 h-3\"}),\"Select a target printer model\"]})]})}const _7e=[{key:\"bed_levelling\",label:\"Bed Levelling\",desc:\"Auto-level bed before print\"},{key:\"flow_cali\",label:\"Flow Calibration\",desc:\"Calibrate extrusion flow\"},{key:\"vibration_cali\",label:\"Vibration Calibration\",desc:\"Reduce ringing artifacts\"},{key:\"layer_inspect\",label:\"First Layer Inspection\",desc:\"AI inspection of first layer\"},{key:\"timelapse\",label:\"Timelapse\",desc:\"Record timelapse video\"}];function k7e({options:t,onChange:e,defaultExpanded:n=!1}){const[r,i]=w.useState(n),s=o=>{e({...t,[o]:!t[o]})};return a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>i(!r),className:\"flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full\",children:[a.jsx(sS,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:\"Print Options\"}),r?a.jsx(cc,{className:\"w-4 h-4 ml-auto\"}):a.jsx(On,{className:\"w-4 h-4 ml-auto\"})]}),r&&a.jsx(\"div\",{className:\"mt-2 bg-bambu-dark rounded-lg p-3 space-y-2\",children:_7e.map(({key:o,label:l,desc:c})=>a.jsxs(\"label\",{className:\"flex items-center justify-between cursor-pointer group\",children:[a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:l}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:c})]}),a.jsx(\"div\",{className:`relative w-10 h-5 rounded-full transition-colors ${t[o]?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,onClick:()=>s(o),children:a.jsx(\"div\",{className:`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${t[o]?\"translate-x-5\":\"translate-x-0.5\"}`})})]},o))})]})}function N7e({options:t,onChange:e,dateFormat:n=\"system\",timeFormat:r=\"system\",canControlPrinter:i=!0,showStagger:s=!1,printerCount:o=0,hasGcodeSnippets:l=!1}){const{t:c}=Ft(),[d,u]=w.useState(\"\"),[m,p]=w.useState(\"\"),[f,y]=w.useState(!0),[v,b]=w.useState(!0),g=w.useRef(null),_=w.useRef(!1);w.useEffect(()=>{if(t.scheduleType!==\"scheduled\"){_.current=!1;return}if(!_.current){_.current=!0;let k;t.scheduledTime?(k=new Date(t.scheduledTime),isNaN(k.getTime())&&(k=new Date,k.setHours(k.getHours()+1,0,0,0))):(k=new Date,k.setHours(k.getHours()+1,0,0,0),e({...t,scheduledTime:hF(k)})),u(mF(k,n)),p(OH(k,r)),y(!0),b(!0)}},[t.scheduleType,t.scheduledTime,n,r,e,t]);const C=k=>{e({...t,scheduleType:k})},P=(k,D)=>{const H=Mye(k,n),z=Eye(D);y(!!H),b(!!z),H&&z&&(H.setHours(z.hours,z.minutes,0,0),H>new Date&&e({...t,scheduledTime:hF(H)}))},N=k=>{u(k),P(k,m)},A=k=>{p(k),P(d,k)},T=k=>{const D=k.target.value;if(D){const H=new Date(D);isNaN(H.getTime())||(u(mF(H,n)),p(OH(H,r)),y(!0),b(!0),e({...t,scheduledTime:D}))}},F=()=>{g.current?.showPicker()};return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:\"When to print\"}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"button\",{type:\"button\",className:`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${t.scheduleType===\"asap\"?\"bg-bambu-green border-bambu-green text-white\":\"bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,onClick:()=>C(\"asap\"),children:[a.jsx(Yn,{className:\"w-4 h-4\"}),\"ASAP\"]}),a.jsxs(\"button\",{type:\"button\",className:`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${t.scheduleType===\"scheduled\"?\"bg-bambu-green border-bambu-green text-white\":\"bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,onClick:()=>C(\"scheduled\"),children:[a.jsx(oa,{className:\"w-4 h-4\"}),\"Scheduled\"]}),a.jsxs(\"button\",{type:\"button\",className:`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${t.scheduleType===\"manual\"?\"bg-bambu-green border-bambu-green text-white\":\"bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,onClick:()=>C(\"manual\"),children:[a.jsx(IQ,{className:\"w-4 h-4\"}),\"Queue Only\"]})]})]}),t.scheduleType===\"scheduled\"&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:\"Date & Time\"}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"div\",{className:\"flex-1 relative\",children:[a.jsx(\"input\",{type:\"text\",className:`w-full px-3 py-2 pr-10 bg-bambu-dark border rounded-lg text-white focus:outline-none ${f?\"border-bambu-dark-tertiary focus:border-bambu-green\":\"border-red-500\"}`,value:d,onChange:k=>N(k.target.value),placeholder:Tye(n)}),a.jsx(\"button\",{type:\"button\",onClick:F,className:\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\",title:\"Open calendar\",children:a.jsx(oa,{className:\"w-4 h-4\"})}),a.jsx(\"input\",{ref:g,type:\"datetime-local\",className:\"absolute top-0 left-0 w-0 h-0 opacity-0 pointer-events-none\",value:t.scheduledTime,onChange:T,tabIndex:-1})]}),a.jsx(\"div\",{className:\"w-32\",children:a.jsx(\"input\",{type:\"text\",className:`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:outline-none ${v?\"border-bambu-dark-tertiary focus:border-bambu-green\":\"border-red-500\"}`,value:m,onChange:k=>A(k.target.value),placeholder:Aye(r)})})]}),(!f||!v)&&a.jsx(\"p\",{className:\"mt-1 text-xs text-red-400\",children:\"Please enter a valid date and time\"})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"checkbox\",id:\"requirePrevious\",checked:t.requirePreviousSuccess,onChange:k=>e({...t,requirePreviousSuccess:k.target.checked}),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"label\",{htmlFor:\"requirePrevious\",className:\"text-sm text-bambu-gray\",children:\"Only start if previous print succeeded\"})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"checkbox\",id:\"autoOffAfter\",checked:t.autoOffAfter,onChange:k=>e({...t,autoOffAfter:k.target.checked}),disabled:!i,className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green disabled:opacity-50\"}),a.jsxs(\"label\",{htmlFor:\"autoOffAfter\",className:`text-sm flex items-center gap-1 ${i?\"text-bambu-gray\":\"text-bambu-gray/50\"}`,children:[a.jsx(Yd,{className:\"w-3.5 h-3.5\"}),\"Power off printer when done\"]})]}),l&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"checkbox\",id:\"gcodeInjection\",checked:t.gcodeInjection,onChange:k=>e({...t,gcodeInjection:k.target.checked}),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsxs(\"label\",{htmlFor:\"gcodeInjection\",className:\"text-sm flex items-center gap-1 text-bambu-gray\",children:[a.jsx(wy,{className:\"w-3.5 h-3.5\"}),c(\"printModal.gcodeInjection\",\"Inject auto-print G-code\")]})]}),s&&t.scheduleType!==\"manual\"&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"checkbox\",id:\"staggerEnabled\",checked:t.staggerEnabled,onChange:k=>e({...t,staggerEnabled:k.target.checked}),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsxs(\"label\",{htmlFor:\"staggerEnabled\",className:\"text-sm flex items-center gap-1 text-bambu-gray\",children:[a.jsx(da,{className:\"w-3.5 h-3.5\"}),c(\"printModal.staggerPrinterStarts\",\"Stagger printer starts\")]})]}),t.staggerEnabled&&a.jsxs(\"div\",{className:\"ml-6 space-y-3\",children:[a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:c(\"printModal.staggerGroupSize\",\"Group size\")}),a.jsx(\"input\",{type:\"number\",min:1,max:o,value:t.staggerGroupSize,onChange:k=>e({...t,staggerGroupSize:Math.max(1,parseInt(k.target.value)||1)}),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"})]}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:c(\"printModal.staggerInterval\",\"Interval (min)\")}),a.jsx(\"input\",{type:\"number\",min:1,max:60,value:t.staggerIntervalMinutes,onChange:k=>e({...t,staggerIntervalMinutes:Math.max(1,parseInt(k.target.value)||1)}),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"})]})]}),o>0&&(()=>{const k=Math.ceil(o/t.staggerGroupSize),D=o%t.staggerGroupSize,H=(k-1)*t.staggerIntervalMinutes;return a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[c(\"printModal.staggerPreview\",\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",{printers:o,groups:k,size:t.staggerGroupSize,interval:t.staggerIntervalMinutes}),D!==0&&t.staggerGroupSize<o?` (${c(\"printModal.staggerLastGroup\",\"last group: {{count}}\",{count:D})})`:\"\",k>1?` (${c(\"printModal.staggerTotal\",\"total: {{minutes}} min\",{minutes:H})})`:\"\"]})})()]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:t.scheduleType===\"asap\"?\"Print will start as soon as the printer is idle.\":t.scheduleType===\"scheduled\"?\"Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.\":\"Print will be staged but won't start automatically. Use the Start button to release it to the queue.\"})]})}const Oc={bed_levelling:!0,flow_cali:!1,vibration_cali:!0,layer_inspect:!1,timelapse:!1},FD={scheduleType:\"asap\",scheduledTime:\"\",requirePreviousSuccess:!1,autoOffAfter:!1,gcodeInjection:!1,staggerEnabled:!1,staggerGroupSize:2,staggerIntervalMinutes:5};function ic({mode:t,archiveId:e,libraryFileId:n,archiveName:r,queueItem:i,initialSelectedPrinterIds:s,onClose:o,onSuccess:l,projectId:c,cleanupLibraryAfterDispatch:d}){const{t:u}=Ft(),m=nn(),{showToast:p}=hn(),{hasPermission:f}=kr(),y=!!n&&!e,[v,b]=w.useState(()=>t===\"edit-queue-item\"&&i?.printer_id?[i.printer_id]:s?.length?s:[]),[g,_]=w.useState(()=>t===\"edit-queue-item\"&&i?.plate_id!=null?new Set([i.plate_id]):new Set),C=g.size===1?[...g][0]:null,[P,N]=w.useState(1),[A,T]=w.useState(()=>t===\"edit-queue-item\"&&i?{bed_levelling:i.bed_levelling??Oc.bed_levelling,flow_cali:i.flow_cali??Oc.flow_cali,vibration_cali:i.vibration_cali??Oc.vibration_cali,layer_inspect:i.layer_inspect??Oc.layer_inspect,timelapse:i.timelapse??Oc.timelapse}:Oc),[F,k]=w.useState(()=>{if(t===\"edit-queue-item\"&&i){let Et=\"asap\";i.manual_start?Et=\"manual\":i.scheduled_time&&!nK(i.scheduled_time)&&(Et=\"scheduled\");let At=\"\";if(i.scheduled_time&&!nK(i.scheduled_time)){const Ie=Zn(i.scheduled_time)??new Date;At=hF(Ie)}return{scheduleType:Et,scheduledTime:At,requirePreviousSuccess:i.require_previous_success,autoOffAfter:i.auto_off_after,gcodeInjection:i.gcode_injection??!1,staggerEnabled:!1,staggerGroupSize:FD.staggerGroupSize,staggerIntervalMinutes:FD.staggerIntervalMinutes}}return FD}),[D,H]=w.useState(()=>{if(t===\"edit-queue-item\"&&i?.ams_mapping&&Array.isArray(i.ams_mapping)){const Et={};return i.ams_mapping.forEach((At,Ie)=>{At!==-1&&(Et[Ie+1]=At)}),Et}return{}}),[z,Q]=w.useState({}),[L,te]=w.useState(()=>t===\"edit-queue-item\"&&i?.target_model?\"model\":\"printer\"),[ie,J]=w.useState(()=>t===\"edit-queue-item\"&&i?.target_model?i.target_model:null),[oe,fe]=w.useState(()=>t===\"edit-queue-item\"&&i?.target_location?i.target_location:null),[re,W]=w.useState(()=>{if(t===\"edit-queue-item\"&&i?.filament_overrides){const Et={};for(const At of i.filament_overrides)Et[At.slot_id]={type:At.type,color:At.color};return Et}return{}}),[ne,me]=w.useState(()=>{if(t===\"edit-queue-item\"&&i?.filament_overrides){const Et={};for(const At of i.filament_overrides)Et[At.slot_id]=At.force_color_match===!0;return Et}return{}}),[be]=w.useState(()=>t===\"edit-queue-item\"&&i?.printer_id?[i.printer_id]:[]),[Ce]=w.useState(()=>t===\"edit-queue-item\"&&i?i.plate_id:null),[q,Y]=w.useState(!1),[E,j]=w.useState({current:0,total:0}),[O,K]=w.useState(null),[U,de]=w.useState(new Set),I=v.length,G=v.length>0?v[0]:null,{data:X}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),V=w.useRef(!1);w.useEffect(()=>{!X||V.current||t===\"edit-queue-item\"||(V.current=!0,T({bed_levelling:X.default_bed_levelling??Oc.bed_levelling,flow_cali:X.default_flow_cali??Oc.flow_cali,vibration_cali:X.default_vibration_cali??Oc.vibration_cali,layer_inspect:X.default_layer_inspect??Oc.layer_inspect,timelapse:X.default_timelapse??Oc.timelapse}))},[X,t]);const ee=w.useRef(!1);w.useEffect(()=>{!X||ee.current||t===\"edit-queue-item\"||(ee.current=!0,k(Et=>({...Et,staggerGroupSize:X.stagger_group_size??Et.staggerGroupSize,staggerIntervalMinutes:X.stagger_interval_minutes??Et.staggerIntervalMinutes})))},[X,t]);const se=Eo(X?.currency||\"USD\"),ge=X?.default_filament_cost??0,{data:he,isLoading:le}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:B}=Xe({queryKey:[\"spool-assignments\"],queryFn:()=>ue.getAssignments(),staleTime:30*1e3,enabled:(t===\"reprint\"||t===\"add-to-queue\")&&L===\"printer\"||y&&t===\"reprint\"}),{data:R}=Xe({queryKey:[\"archive\",e],queryFn:()=>ue.getArchive(e),enabled:!!e&&!y}),{data:ae}=Xe({queryKey:[\"library-file\",n],queryFn:()=>ue.getLibraryFile(n),enabled:y&&!!n}),_e=R?.sliced_for_model||ae?.sliced_for_model||null,{data:Se,isError:ve}=Xe({queryKey:[\"archive-plates\",e],queryFn:()=>ue.getArchivePlates(e),enabled:!!e&&!y,retry:!1}),{data:Te}=Xe({queryKey:[\"library-file-plates\",n],queryFn:()=>ue.getLibraryFilePlates(n),enabled:y&&!!n}),ye=y?Te:Se,{data:je,isError:Le}=Xe({queryKey:[\"archive-filaments\",e,C],queryFn:()=>ue.getArchiveFilamentRequirements(e,C??void 0),enabled:!!e&&!y&&(C!==null||!ye?.is_multi_plate),retry:!1}),{data:Me}=Xe({queryKey:[\"library-file-filaments\",n,C],queryFn:()=>ue.getLibraryFileFilamentRequirements(n,C??void 0),enabled:y&&!!n&&(C!==null||!ye?.is_multi_plate)}),Oe=!y&&(ve||Le),Re=y?Me:je,$e=w.useMemo(()=>{if(!(C===null||!ye?.plates?.length))return ye.plates.find(Et=>Et.index===C)?.name||void 0},[ye,C]),{data:Ye}=Xe({queryKey:[\"available-filaments\",ie,oe],queryFn:()=>ue.getAvailableFilaments(ie,oe??void 0),enabled:L===\"model\"&&!!ie}),{data:tt}=Xe({queryKey:[\"printer-status\",G],queryFn:()=>ue.getPrinterStatus(G),enabled:!!G}),{amsMapping:pe}=Jae(Re,tt,D,X?.prefer_lowest_filament),Fe=f7e(v,he,Re,D,z,Q,X?.prefer_lowest_filament);w.useEffect(()=>{ye?.plates&&ye.plates.length>=1&&g.size===0&&_(new Set([ye.plates[0].index]))},[ye,g.size]),w.useEffect(()=>{if(t===\"edit-queue-item\")return;const Et=he?.filter(At=>At.is_active)||[];Et.length===1&&v.length===0&&b([Et[0].id])},[t,he,v.length]),w.useEffect(()=>{t===\"edit-queue-item\"?(JSON.stringify(v.sort())!==JSON.stringify(be.sort())||C!==Ce)&&(H({}),Q({}),de(new Set)):(H({}),Q({}),de(new Set))},[t,v,C,be,Ce]);const[we,Ve]=w.useState(ie),[Ae,ce]=w.useState(C);w.useEffect(()=>{(ie!==we||C!==Ae)&&(Ve(ie),ce(C),(t!==\"edit-queue-item\"||we!==null)&&(W({}),me({})))},[ie,C,we,Ae,t]),w.useEffect(()=>{if(!X?.per_printer_mapping_expanded||v.length<=1)return;const Et=v.filter(At=>{if(U.has(At))return!1;const Ie=Fe.printerResults.find(mt=>mt.printerId===At);return Ie&&Ie.status&&!Ie.isLoading});Et.length>0&&(de(At=>{const Ie=new Set(At);return Et.forEach(mt=>Ie.add(mt)),Ie}),Et.forEach(At=>{Fe.autoConfigurePrinter(At)}))},[X?.per_printer_mapping_expanded,v,U,Fe]),w.useEffect(()=>{const Et=At=>{At.key===\"Escape\"&&!q&&o()};return window.addEventListener(\"keydown\",Et),()=>window.removeEventListener(\"keydown\",Et)},[o,q]);const xe=ye?.is_multi_plate??!1,Be=ye?.plates??[],Qe=w.useMemo(()=>{const Et=new Map;return B&&B.forEach(At=>{const Ie=At.ams_id===255,mt=Ag(At.ams_id,At.tray_id,Ie),pt=Et.get(At.printer_id)??new Map;pt.set(mt,At),Et.set(At.printer_id,pt)}),Et},[B]),ht=w.useMemo(()=>{if(!O||O.length===0)return\"\";const Et=O.map(At=>u(\"printModal.insufficientFilamentLine\",{printer:At.printerName,slot:At.slotLabel,required:Math.round(At.requiredGrams),remaining:Math.round(At.remainingGrams)}));return[u(\"printModal.insufficientFilamentMessage\"),...Et].join(`\n`)},[O,u]),xt=it({mutationFn:Et=>ue.addToQueue(Et)}),gt=it({mutationFn:Et=>ue.updateQueueItem(i.id,Et),onSuccess:()=>{m.invalidateQueries({queryKey:[\"queue\"]}),p(\"Queue item updated\"),l?.(),o()},onError:Et=>{p(Et.message||\"Failed to update queue item\",\"error\")}}),Ut=F.staggerEnabled&&v.length>1,Wt=async(Et,At)=>{if(Et?.preventDefault(),!At?.skipFilamentCheck&&!X?.disable_filament_warnings&&(t===\"reprint\"||t===\"add-to-queue\")&&L===\"printer\"){const Ge=[],Ze=Re?.filaments??[];if(Ze.length>0&&Qe.size>0){const Je=(We,Ue)=>!Number.isFinite(We)||We<=0||!Number.isFinite(Ue)||Ue<0?null:Math.max(0,We-Ue);for(const We of v){const Ue=v.length>1?Fe.getFinalMapping(We):pe;if(!Ue)continue;const et=v.length>1?Fe.printerResults.find(Pt=>Pt.printerId===We)?.status:tt,jt=LS(et),yt=new Map(jt.map(Pt=>[Pt.globalTrayId,Pt.label])),qe=Qe.get(We),St=he?.find(Pt=>Pt.id===We)?.name??`Printer ${We}`;qe&&Ze.forEach(Pt=>{if(!Pt.slot_id||Pt.slot_id<=0)return;const qt=Ue[Pt.slot_id-1];if(!Number.isFinite(qt)||qt<0)return;const dn=qe.get(qt)?.spool;if(!dn)return;const Nn=Je(dn.label_weight,dn.weight_used);Nn!==null&&(Nn>=Pt.used_grams||Ge.push({printerName:St,slotLabel:yt.get(qt)??`Slot ${Pt.slot_id}`,requiredGrams:Pt.used_grams,remainingGrams:Nn}))})}}if(Ge.length>0){K(Ge);return}}if(L===\"printer\"&&v.length===0){p(\"Please select at least one printer\",\"error\");return}if(L===\"model\"&&!ie){p(\"Please select a target printer model\",\"error\");return}Y(!0);const Ie=g.size>1?Be.filter(Ge=>g.has(Ge.index)):[null],mt=L===\"model\"?Ie.length:v.length*Ie.length;j({current:0,total:mt});const pt={success:0,failed:0,errors:[]},nt=Ge=>{if(v.length>1){const Ze=z[Ge];if(Ze&&!Ze.useDefault)return Fe.getFinalMapping(Ge)}return pe},ot=(()=>{const Ge=[];if(Re?.filaments)for(const Ze of Re.filaments){const Je=re[Ze.slot_id],We=ne[Ze.slot_id]??!1,Ue=Je?.type??Ze.type,et=Je?.color??Ze.color;(Je||We)&&Ge.push({slot_id:Ze.slot_id,type:Ue,color:et,color_name:rc(et),force_color_match:We})}else for(const[Ze,{type:Je,color:We}]of Object.entries(re)){const Ue=parseInt(Ze,10),et=ne[Ue]??!1;Ge.push({slot_id:Ue,type:Je,color:We,color_name:rc(We),force_color_match:et})}return Ge.length>0?Ge:void 0})(),Pe=(Ge,Ze)=>({printer_id:L===\"printer\"?Ge:null,target_model:L===\"model\"?ie:null,target_location:L===\"model\"?oe:null,filament_overrides:L===\"model\"?ot:void 0,archive_id:y?void 0:e,library_file_id:y?n:void 0,require_previous_success:F.requirePreviousSuccess,auto_off_after:F.autoOffAfter,gcode_injection:F.gcodeInjection,manual_start:F.scheduleType===\"manual\",ams_mapping:Ge?nt(Ge):void 0,plate_id:Ze!==void 0?Ze:C,scheduled_time:F.scheduleType===\"scheduled\"&&F.scheduledTime?new Date(F.scheduledTime).toISOString():void 0,...A,project_id:c??void 0});if(L===\"model\"){if(t===\"reprint\"){p(\"Model-based assignment only works with queue mode\",\"error\"),Y(!1);return}let Ge=0;for(const Ze of Ie){Ge++,j({current:Ge,total:mt});const Je=Ze?Ze.index:C;try{if(t===\"edit-queue-item\"&&!Ze){const We={printer_id:null,target_model:ie,target_location:oe,filament_overrides:ot||null,require_previous_success:F.requirePreviousSuccess,auto_off_after:F.autoOffAfter,gcode_injection:F.gcodeInjection,manual_start:F.scheduleType===\"manual\",ams_mapping:void 0,plate_id:Je,scheduled_time:F.scheduleType===\"scheduled\"&&F.scheduledTime?new Date(F.scheduledTime).toISOString():null,...A};await gt.mutateAsync(We)}else{const We=Pe(null,Je);Xt>1&&(We.quantity=Xt),await xt.mutateAsync(We)}pt.success++}catch(We){pt.failed++;const Ue=Ze?Ze.name||`Plate ${Ze.index}`:\"\";pt.errors.push(Ue?`${Ue}: ${We.message}`:We.message)}}}else{const Ge=F.staggerEnabled&&(t===\"add-to-queue\"||t===\"reprint\")&&v.length>1,Ze=Ge?F.scheduleType===\"scheduled\"&&F.scheduledTime?new Date(F.scheduledTime).getTime():Date.now():0;let Je=0;for(const We of Ie){const Ue=We?We.index:C;for(let et=0;et<v.length;et++){const jt=v[et];Je++,j({current:Je,total:mt});try{if(t===\"reprint\"&&!Ge){const yt=nt(jt);if(y?await ue.printLibraryFile(n,jt,{plate_id:C??void 0,plate_name:$e,ams_mapping:yt,...A,project_id:c,cleanup_library_after_dispatch:d}):await ue.reprintArchive(e,jt,{plate_id:C??void 0,plate_name:$e,ams_mapping:yt,...A}),Xt>1){const qe=Pe(jt,Ue);qe.quantity=Xt-1,await xt.mutateAsync(qe)}}else if(t===\"edit-queue-item\"&&Je===1){const yt=nt(jt),qe={printer_id:jt,target_model:null,target_location:null,require_previous_success:F.requirePreviousSuccess,auto_off_after:F.autoOffAfter,gcode_injection:F.gcodeInjection,manual_start:F.scheduleType===\"manual\",ams_mapping:yt,plate_id:Ue,scheduled_time:F.scheduleType===\"scheduled\"&&F.scheduledTime?new Date(F.scheduledTime).toISOString():null,...A};await gt.mutateAsync(qe)}else{const yt=Pe(jt,Ue);if(Xt>1&&(yt.quantity=Xt),Ge){const qe=Math.floor(et/F.staggerGroupSize);if(qe>0){const St=qe*F.staggerIntervalMinutes*6e4;yt.scheduled_time=new Date(Ze+St).toISOString()}}await xt.mutateAsync(yt)}pt.success++}catch(yt){pt.failed++;const qe=he?.find(qt=>qt.id===jt)?.name||`Printer ${jt}`,St=We?We.name||`Plate ${We.index}`:\"\",Pt=St?`${qe} (${St})`:qe;pt.errors.push(`${Pt}: ${yt.message}`)}}}}Y(!1),pt.failed===0?(t===\"reprint\"&&Ut?p(u(\"queue.itemsQueued\",{count:pt.success})):t!==\"reprint\"&&(t===\"edit-queue-item\"?p(\"Queue item updated\"):pt.success===1?p(L===\"model\"?`Queued for any ${ie}`:u(\"queue.printQueued\")):p(u(\"queue.itemsQueued\",{count:pt.success}))),m.invalidateQueries({queryKey:[\"queue\"]}),l?.(),o()):pt.success===0?p(`Failed: ${pt.errors[0]}`,\"error\"):(p(`${pt.success} succeeded, ${pt.failed} failed`,\"error\"),m.invalidateQueries({queryKey:[\"queue\"]}))},Zt=q||gt.isPending,Kt=w.useMemo(()=>!(Zt||L===\"printer\"&&v.length===0||L===\"model\"&&!ie||L===\"model\"&&t===\"reprint\"||xe&&g.size===0),[v.length,L,ie,t,xe,g.size,Zt]),Xt=L===\"printer\"&&v.length>1?1:P,vn=(()=>{const Et=v.length;if(t===\"reprint\"){const At=Ut&&Et>1;let Ie=At?u(\"printModal.staggerToPrinters\",{count:Et,defaultValue:\"Stagger to {{count}} printers\"}):Et>1?u(\"queue.printToPrinters\",{count:Et}):u(\"queue.print\");return Xt>1&&(Ie=`${Ie} ×${Xt}`),{title:u(y?\"queue.print\":\"queue.reprint\"),icon:Er,submitText:Ie,submitIcon:At?oa:Er,loadingText:E.total>1?u(\"queue.sendingProgress\",{current:E.current,total:E.total}):u(\"queue.sending\")}}if(t===\"add-to-queue\"){let At=u(\"queue.addToQueue\");return g.size>1?At=u(\"queue.queueSelectedPlates\",{count:g.size}):Et>1&&(At=u(\"queue.queueToPrinters\",{count:Et})),Xt>1&&(At=`${At} ×${Xt}`),{title:u(\"queue.schedulePrint\"),icon:oa,submitText:At,submitIcon:oa,loadingText:E.total>1?u(\"queue.addingProgress\",{current:E.current,total:E.total}):u(\"queue.adding\")}}return{title:u(\"queue.editQueueItem\"),icon:ci,submitText:u(\"common.save\"),submitIcon:ci,loadingText:E.total>1?u(\"queue.savingProgress\",{current:E.current,total:E.total}):u(\"common.saving\")}})(),Ke=vn.icon,at=vn.submitIcon,Lt=G&&g.size<=1&&(y||(xe?C!==null:!0));return a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:q?void 0:o,children:[a.jsx(Tt,{className:\"w-full max-w-2xl max-h-[90vh] overflow-y-auto\",onClick:Et=>Et.stopPropagation(),children:a.jsxs(Mt,{className:t===\"reprint\"?\"\":\"p-0\",children:[a.jsxs(\"div\",{className:`flex items-center justify-between ${t===\"reprint\"?\"mb-4\":\"p-4 border-b border-bambu-dark-tertiary\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Ke,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:vn.title})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:o,disabled:q,children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"form\",{onSubmit:Wt,className:t===\"reprint\"?\"\":\"p-4 space-y-4\",children:[a.jsx(\"p\",{className:`text-sm text-bambu-gray ${t===\"reprint\"?\"mb-4\":\"\"}`,children:t===\"reprint\"?a.jsxs(a.Fragment,{children:[\"Send \",a.jsx(\"span\",{className:\"text-white\",children:r}),\" to\",\" \",s?.length===1&&he?a.jsx(\"span\",{className:\"text-white\",children:he.find(Et=>Et.id===s[0])?.name??\"printer(s)\"}):\"printer(s)\"]}):a.jsxs(a.Fragment,{children:[a.jsx(\"span\",{className:\"block text-bambu-gray mb-1\",children:\"Print Job\"}),a.jsx(\"span\",{className:\"text-white font-medium truncate block\",children:r})]})}),a.jsx(y7e,{plates:Be,isMultiPlate:xe,selectedPlates:g,onToggle:Et=>{_(At=>{const Ie=new Set(At);return t===\"add-to-queue\"?Ie.has(Et)?Ie.delete(Et):Ie.add(Et):(Ie.clear(),Ie.add(Et)),Ie})},onSelectAll:t===\"add-to-queue\"?()=>_(new Set(Be.map(Et=>Et.index))):void 0,onDeselectAll:t===\"add-to-queue\"?()=>_(new Set):void 0,multiSelect:t===\"add-to-queue\"}),!s?.length&&a.jsx(S7e,{printers:he||[],selectedPrinterIds:v,onMultiSelect:b,isLoading:le,allowMultiple:!0,showInactive:t===\"edit-queue-item\",disableBusy:t===\"reprint\",printerMappingResults:Fe.printerResults,filamentReqs:Re,onAutoConfigurePrinter:Fe.autoConfigurePrinter,onUpdatePrinterConfig:Fe.updatePrinterConfig,assignmentMode:t===\"reprint\"?\"printer\":L,onAssignmentModeChange:t!==\"reprint\"?te:void 0,targetModel:ie,onTargetModelChange:t!==\"reprint\"?J:void 0,targetLocation:oe,onTargetLocationChange:t!==\"reprint\"?fe:void 0,slicedForModel:_e}),L===\"model\"&&ie&&Re&&Ye&&Ye.length>0&&a.jsx(x7e,{filamentReqs:Re,availableFilaments:Ye,overrides:re,onChange:W,forceColorMatch:ne,onForceColorMatchChange:(Et,At)=>me(Ie=>({...Ie,[Et]:At}))}),_e&&L===\"printer\"&&v.length===1&&(()=>{const Et=he?.find(At=>At.id===v[0]);return Et&&Et.model&&_e!==Et.model?a.jsxs(\"div\",{className:\"p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2\",children:[a.jsx(Dn,{className:\"w-4 h-4 text-yellow-400 flex-shrink-0\"}),a.jsxs(\"span\",{className:\"text-sm text-yellow-400\",children:[\"File was sliced for \",_e,\", but printing on \",Et.model]})]}):null})(),Oe&&a.jsxs(\"div\",{className:\"flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm\",children:[a.jsx(ei,{className:\"w-4 h-4 text-orange-400 mt-0.5 flex-shrink-0\"}),a.jsx(\"p\",{className:\"text-orange-400\",children:\"Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.\"})]}),Lt&&!Oe&&v.length===1&&a.jsx(b7e,{printerId:G,filamentReqs:Re,manualMappings:D,onManualMappingChange:H,defaultExpanded:!!s?.length||(X?.per_printer_mapping_expanded??!1),currencySymbol:se,defaultCostPerKg:ge}),(t===\"reprint\"||I>0||L===\"model\"&&ie)&&a.jsx(k7e,{options:A,onChange:T,defaultExpanded:!!s?.length}),t!==\"edit-queue-item\"&&(L===\"model\"||v.length<=1)&&a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"label\",{htmlFor:\"printQuantity\",className:\"text-sm text-bambu-gray whitespace-nowrap\",children:u(\"queue.quantity\",\"Quantity\")}),a.jsx(\"input\",{id:\"printQuantity\",type:\"number\",min:1,max:999,value:P,onChange:Et=>N(Math.max(1,Math.min(999,parseInt(Et.target.value)||1))),className:\"w-20 px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green\"}),P>1&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:u(\"queue.quantityHint\",\"Creates {{count}} queue items\",{count:P})})]}),t===\"reprint\"&&L===\"printer\"&&v.length>1&&a.jsxs(\"div\",{className:\"space-y-2 pb-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"checkbox\",id:\"staggerEnabledReprint\",checked:F.staggerEnabled,onChange:Et=>k({...F,staggerEnabled:Et.target.checked}),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsxs(\"label\",{htmlFor:\"staggerEnabledReprint\",className:\"text-sm flex items-center gap-1 text-bambu-gray\",children:[a.jsx(da,{className:\"w-3.5 h-3.5\"}),u(\"printModal.staggerPrinterStarts\",\"Stagger printer starts\")]})]}),F.staggerEnabled&&(()=>{const Et=F.staggerGroupSize,At=F.staggerIntervalMinutes,Ie=Math.ceil(v.length/Et),mt=(Ie-1)*At;return a.jsxs(\"p\",{className:\"ml-6 text-xs text-bambu-gray\",children:[u(\"printModal.staggerPreview\",\"{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min\",{printers:v.length,groups:Ie,size:Et,interval:At}),Ie>1?` (${u(\"printModal.staggerTotal\",\"total: {{minutes}} min\",{minutes:mt})})`:\"\"]})})()]}),t!==\"reprint\"&&a.jsx(N7e,{options:F,onChange:k,dateFormat:X?.date_format||\"system\",timeFormat:X?.time_format||\"system\",canControlPrinter:f(\"printers:control\"),showStagger:t===\"add-to-queue\"&&L===\"printer\"&&v.length>1,printerCount:v.length,hasGcodeSnippets:!!X?.gcode_snippets}),t===\"reprint\"&&!!X?.gcode_snippets&&Xt>1&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"checkbox\",id:\"gcodeInjectionReprint\",checked:F.gcodeInjection,onChange:Et=>k({...F,gcodeInjection:Et.target.checked}),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsxs(\"label\",{htmlFor:\"gcodeInjectionReprint\",className:\"text-sm flex items-center gap-1 text-bambu-gray\",children:[a.jsx(wy,{className:\"w-3.5 h-3.5\"}),u(\"printModal.gcodeInjection\",\"Inject auto-print G-code\")]})]}),gt.isError&&a.jsx(\"div\",{className:\"mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\",children:gt.error?.message||\"Failed to complete operation\"}),a.jsxs(\"div\",{className:`flex gap-3 ${t===\"reprint\"?\"\":\"pt-2\"}`,children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:o,className:\"flex-1\",disabled:q,children:\"Cancel\"}),a.jsx(De,{type:\"submit\",disabled:!Kt,className:\"flex-1\",children:Zt?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),vn.loadingText]}):a.jsxs(a.Fragment,{children:[a.jsx(at,{className:\"w-4 h-4\"}),vn.submitText]})})]})]})]})}),O&&O.length>0&&a.jsx(yn,{title:u(\"printModal.insufficientFilamentTitle\"),message:ht,confirmText:u(\"printModal.printAnyway\"),cancelText:u(\"common.cancel\"),variant:\"warning\",onConfirm:()=>{K(null),Wt(void 0,{skipFilamentCheck:!0})},onCancel:()=>K(null)})]})}function iK({value:t}){const{t:e}=Ft(),[n,r]=w.useState(!1),i=async()=>{try{await navigator.clipboard.writeText(t),r(!0),setTimeout(()=>r(!1),2e3)}catch{}};return a.jsx(\"button\",{onClick:i,className:\"ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors\",title:e(n?\"printers.copied\":\"printers.copyToClipboard\"),children:n?a.jsx(Ur,{className:\"w-3.5 h-3.5 text-bambu-green\"}):a.jsx(qs,{className:\"w-3.5 h-3.5\"})})}function C7e({printer:t,status:e,totalPrintHours:n,onClose:r}){const{t:i}=Ft();w.useEffect(()=>{const o=l=>{l.key===\"Escape\"&&r()};return window.addEventListener(\"keydown\",o),()=>window.removeEventListener(\"keydown\",o)},[r]);const s=[];if(s.push({label:i(\"printers.model\"),value:t.model??\"—\"}),s.push({label:i(\"common.status\"),value:a.jsxs(\"span\",{className:`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${e?.connected?\"bg-bambu-green/20 text-bambu-green\":\"bg-red-500/20 text-red-400\"}`,children:[a.jsx(\"span\",{className:`w-1.5 h-1.5 rounded-full ${e?.connected?\"bg-bambu-green\":\"bg-red-400\"}`}),e?.connected?i(\"printers.status.available\"):i(\"printers.status.offline\")]})}),e?.state){const o={IDLE:\"printers.status.idle\",RUNNING:\"printers.status.printing\",PAUSE:\"printers.status.paused\",FINISH:\"printers.status.finished\",FAILED:\"printers.status.error\"};s.push({label:i(\"printers.state\"),value:i(o[e.state]??\"printers.status.unknown\")})}if(s.push({label:i(\"printers.ipAddress\"),value:a.jsxs(\"span\",{className:\"flex items-center\",children:[a.jsx(\"span\",{className:\"font-mono\",children:t.ip_address}),a.jsx(iK,{value:t.ip_address})]})}),s.push({label:i(\"printers.serialNumber\"),value:a.jsxs(\"span\",{className:\"flex items-center\",children:[a.jsx(\"span\",{className:\"font-mono truncate\",children:t.serial_number}),a.jsx(iK,{value:t.serial_number})]})}),e?.wired_network)s.push({label:i(\"printers.networkLabel\",\"Network\"),value:a.jsxs(\"span\",{className:\"flex items-center gap-2\",children:[a.jsx(AO,{className:\"w-4 h-4 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-bambu-green\",children:i(\"printers.connection.ethernet\",\"Ethernet\")})]})});else if(e?.wifi_signal!=null){const o=EJ(e.wifi_signal);s.push({label:i(\"printers.wifiSignalLabel\"),value:a.jsxs(\"span\",{className:\"flex items-center gap-2\",children:[a.jsx($Q,{className:`w-4 h-4 ${o.color}`}),a.jsx(\"span\",{className:o.color,children:i(o.labelKey)}),a.jsxs(\"span\",{className:\"text-bambu-gray text-xs\",children:[\"(\",e.wifi_signal,\" dBm)\"]})]})})}return s.push({label:i(\"printers.firmware\"),value:e?.firmware_version??\"—\"}),e?.developer_mode!=null&&s.push({label:i(\"printers.developerMode\"),value:a.jsx(\"span\",{className:`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${e.developer_mode?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark-tertiary text-bambu-gray\"}`,children:e.developer_mode?i(\"printers.enabled\"):i(\"printers.disabled\")})}),s.push({label:i(\"printers.nozzleCount\"),value:t.nozzle_count}),s.push({label:i(\"printers.autoArchive\"),value:a.jsx(\"span\",{className:`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${t.auto_archive?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark-tertiary text-bambu-gray\"}`,children:t.auto_archive?i(\"printers.enabled\"):i(\"printers.disabled\")})}),n!=null&&n>0&&s.push({label:i(\"printers.totalPrintHours\"),value:`${Math.round(n)}h`}),t.location&&s.push({label:i(\"printers.sort.location\"),value:t.location}),s.push({label:i(\"printers.addedOn\"),value:hg(t.created_at)}),a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",role:\"dialog\",\"aria-modal\":\"true\",onClick:r,children:a.jsx(Tt,{className:\"w-full max-w-md\",onClick:o=>o.stopPropagation(),children:a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-4\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t.name}),a.jsx(\"button\",{onClick:r,className:\"p-1 hover:bg-bambu-dark rounded flex-shrink-0\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]}),a.jsx(\"div\",{className:\"flex justify-center mb-4\",children:a.jsx(\"img\",{src:MJ(t.model),alt:t.model??t.name,className:\"h-24 object-contain\"})}),a.jsx(\"div\",{className:\"space-y-0\",children:s.map((o,l)=>a.jsxs(\"div\",{className:\"flex items-center justify-between gap-4 py-2.5 border-b border-bambu-dark-tertiary last:border-0\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray whitespace-nowrap\",children:o.label}),a.jsx(\"span\",{className:\"text-sm text-white text-right\",children:o.value})]},l))})]})})})}function P7e(t){if(!t||t.length<6)return!1;const e=parseInt(t.slice(0,2),16),n=parseInt(t.slice(2,4),16),r=parseInt(t.slice(4,6),16);return(.299*e+.587*n+.114*r)/255>.6}function RD({trayColor:t,trayType:e,isEmpty:n,slotNumber:r}){return a.jsx(\"div\",{className:\"w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2 flex items-center justify-center\",style:{backgroundColor:t?`#${t}`:e?\"#333\":\"transparent\",borderColor:n?\"#666\":\"rgba(255,255,255,0.1)\",borderStyle:n?\"dashed\":\"solid\"},children:a.jsx(\"span\",{className:\"text-[6px] font-bold leading-none select-none\",style:{color:t&&P7e(t)?\"#000\":\"#fff\"},children:r})})}function Sz({summary:t,children:e,defaultOpen:n=!1,className:r=\"\",summaryClassName:i=\"\",open:s,onToggle:o}){const[l,c]=w.useState(n),d=s!==void 0,u=d?s:l,m=()=>{const p=!u;d||c(p),o?.(p)};return a.jsxs(\"div\",{className:r,children:[a.jsxs(\"div\",{role:\"button\",tabIndex:0,onClick:m,onKeyDown:p=>{(p.key===\"Enter\"||p.key===\" \")&&(p.preventDefault(),m())},className:`w-full flex items-center justify-between gap-2 text-left cursor-pointer ${i}`,\"aria-expanded\":u,children:[a.jsx(\"div\",{className:\"flex-1 min-w-0\",children:t}),a.jsx(On,{className:`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${u?\"rotate-180\":\"\"}`})]}),u&&a.jsx(\"div\",{className:\"mt-3\",children:e})]})}function LD(t){return(t??.02).toFixed(3)}function sK({side:t}){const{mode:e}=zg(),n=e===\"dark\"?\"#1a4d2e\":\"#e7f5e9\";return a.jsx(\"span\",{className:\"inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded\",style:{backgroundColor:n,color:\"#00ae42\"},children:t})}function tie(t,e){if(!t)return\"\";if(t.includes(\"hardened\"))return e(\"printers.nozzleHardenedSteel\");if(t.includes(\"stainless\"))return e(\"printers.nozzleStainlessSteel\");if(t.includes(\"tungsten\"))return e(\"printers.nozzleTungstenCarbide\");if(t.length>=4){const n=t.slice(2,4);if(n===\"00\")return e(\"printers.nozzleStainlessSteel\");if(n===\"01\")return e(\"printers.nozzleHardenedSteel\");if(n===\"05\")return e(\"printers.nozzleTungstenCarbide\")}return t===\"00\"?e(\"printers.nozzleStainlessSteel\"):t===\"01\"?e(\"printers.nozzleHardenedSteel\"):t===\"05\"?e(\"printers.nozzleTungstenCarbide\"):t.startsWith(\"H\")?e(\"printers.nozzleHardenedSteel\"):t}function nie(t,e){return t?t.startsWith(\"HH\")?e(\"printers.nozzleHighFlow\"):t.startsWith(\"HS\")?e(\"printers.nozzleStandardFlow\"):\"\":\"\"}function rie({slot:t,index:e,activeStatus:n,filamentName:r,children:i}){const{t:s}=Ft(),[o,l]=w.useState(!1),[c,d]=w.useState(\"top\"),u=w.useRef(null),m=w.useRef(null),p=w.useRef(null),f=!t.nozzle_diameter&&!t.nozzle_type,y=t.stat===1;w.useEffect(()=>{if(o&&u.current&&m.current){const P=u.current.getBoundingClientRect(),N=m.current.offsetHeight,T=P.top-56,F=window.innerHeight-P.bottom;T<N+12&&F>T?d(\"bottom\"):d(\"top\")}},[o]);const v=()=>{p.current&&clearTimeout(p.current),p.current=setTimeout(()=>l(!0),80)},b=()=>{p.current&&clearTimeout(p.current),p.current=setTimeout(()=>l(!1),100)};w.useEffect(()=>()=>{p.current&&clearTimeout(p.current)},[]);const g=HO(t.filament_color),_=tie(t.nozzle_type,s),C=nie(t.nozzle_type,s);return a.jsxs(\"div\",{ref:u,className:\"relative\",onMouseEnter:v,onMouseLeave:b,children:[i,o&&a.jsxs(\"div\",{ref:m,className:`\n            absolute left-1/2 -translate-x-1/2 z-50\n            ${c===\"top\"?\"bottom-full mb-2\":\"top-full mt-2\"}\n            animate-in fade-in-0 zoom-in-95 duration-150\n          `,style:{maxWidth:\"calc(100vw - 24px)\"},children:[a.jsx(\"div\",{className:\"w-44 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm\",children:f?a.jsxs(\"div\",{className:\"px-3 py-2 text-xs text-bambu-gray text-center whitespace-nowrap\",children:[\"Slot \",e+1,\" — Empty\"]}):a.jsxs(\"div\",{className:\"p-2.5 space-y-1.5\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:s(\"printers.nozzleDiameter\")}),a.jsxs(\"span\",{className:\"text-xs text-white font-semibold\",children:[t.nozzle_diameter,\" mm\"]})]}),_&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:s(\"printers.nozzleType\")}),a.jsx(\"span\",{className:\"text-xs text-white font-semibold truncate max-w-[100px]\",children:_})]}),C&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:s(\"printers.nozzleFlow\")}),a.jsx(\"span\",{className:\"text-xs text-white font-semibold\",children:C})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:s(\"printers.nozzleStatus\")}),a.jsx(\"span\",{className:`text-[10px] font-bold px-1.5 py-0.5 rounded ${n||y?\"bg-green-900/50 text-green-400\":\"bg-bambu-dark-tertiary text-bambu-gray\"}`,children:s(n?\"printers.nozzleActive\":y?\"printers.nozzleMounted\":\"printers.nozzleDocked\")})]}),t.wear!=null&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:s(\"printers.nozzleWear\")}),a.jsxs(\"span\",{className:\"text-xs text-white font-semibold\",children:[t.wear,\"%\"]})]}),t.max_temp>0&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:s(\"printers.nozzleMaxTemp\")}),a.jsxs(\"span\",{className:\"text-xs text-white font-semibold\",children:[t.max_temp,\"°C\"]})]}),t.serial_number&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:s(\"printers.nozzleSerial\")}),a.jsx(\"span\",{className:\"text-[10px] text-white font-mono truncate max-w-[80px]\",children:t.serial_number})]}),(g||t.filament_type)&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:s(\"printers.nozzleFilament\")}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[g&&a.jsx(\"div\",{className:\"w-3 h-3 rounded-sm border border-white/20\",style:{backgroundColor:g}}),a.jsx(\"span\",{className:\"text-[10px] text-white font-semibold truncate max-w-[100px]\",children:r||t.filament_type||t.filament_id||\"\"})]})]})]})}),a.jsx(\"div\",{className:`\n              absolute left-1/2 -translate-x-1/2 w-0 h-0\n              border-l-[6px] border-l-transparent\n              border-r-[6px] border-r-transparent\n              ${c===\"top\"?\"top-full border-t-[6px] border-t-bambu-dark-tertiary\":\"bottom-full border-b-[6px] border-b-bambu-dark-tertiary\"}\n            `})]})]})}function T7e({leftSlot:t,rightSlot:e,activeNozzle:n,filamentInfo:r,children:i}){const{t:s}=Ft(),[o,l]=w.useState(!1),[c,d]=w.useState(\"top\"),u=w.useRef(null),m=w.useRef(null),p=w.useRef(null);w.useEffect(()=>{if(o&&u.current&&m.current){const b=u.current.getBoundingClientRect(),g=m.current.offsetHeight,C=b.top-56,P=window.innerHeight-b.bottom;C<g+12&&P>C?d(\"bottom\"):d(\"top\")}},[o]);const f=()=>{p.current&&clearTimeout(p.current),p.current=setTimeout(()=>l(!0),80)},y=()=>{p.current&&clearTimeout(p.current),p.current=setTimeout(()=>l(!1),100)};if(w.useEffect(()=>()=>{p.current&&clearTimeout(p.current)},[]),!t&&!e)return a.jsx(a.Fragment,{children:i});const v=(b,g)=>{const _=n===g,C=tie(b.nozzle_type,s),P=nie(b.nozzle_type,s),N=HO(b.filament_color),A=b.filament_id?r?.[b.filament_id]?.name:void 0;return a.jsxs(\"div\",{className:\"flex-1 space-y-1.5\",children:[a.jsx(\"div\",{className:`text-[10px] font-bold pb-1 border-b border-bambu-dark-tertiary/50 ${_?\"text-amber-400\":\"text-bambu-gray\"}`,children:s(g===\"L\"?\"common.left\":\"common.right\")}),b.nozzle_diameter&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:s(\"printers.nozzleDiameter\")}),a.jsxs(\"span\",{className:\"text-xs text-white font-semibold\",children:[b.nozzle_diameter,\" mm\"]})]}),C&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:s(\"printers.nozzleType\")}),a.jsx(\"span\",{className:\"text-[10px] text-white font-semibold\",children:C})]}),P&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:s(\"printers.nozzleFlow\")}),a.jsx(\"span\",{className:\"text-[10px] text-white font-semibold\",children:P})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:s(\"printers.nozzleStatus\")}),a.jsx(\"span\",{className:`text-[10px] font-bold px-1.5 py-0.5 rounded ${_?\"bg-green-900/50 text-green-400\":\"bg-bambu-dark-tertiary text-bambu-gray\"}`,children:s(_?\"printers.nozzleActive\":\"printers.nozzleIdle\")})]}),b.wear!=null&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:s(\"printers.nozzleWear\")}),a.jsxs(\"span\",{className:\"text-xs text-white font-semibold\",children:[b.wear,\"%\"]})]}),g===\"R\"&&b.max_temp>0&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:s(\"printers.nozzleMaxTemp\")}),a.jsxs(\"span\",{className:\"text-xs text-white font-semibold\",children:[b.max_temp,\"°C\"]})]}),g===\"R\"&&b.serial_number&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:s(\"printers.nozzleSerial\")}),a.jsx(\"span\",{className:\"text-[10px] text-white font-mono\",children:b.serial_number})]}),(N||b.filament_type||b.filament_id)&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:s(\"printers.nozzleFilament\")}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[N&&a.jsx(\"div\",{className:\"w-3 h-3 rounded-sm border border-white/20\",style:{backgroundColor:N}}),a.jsx(\"span\",{className:\"text-[10px] text-white font-semibold truncate max-w-[100px]\",children:A||b.filament_type||b.filament_id||\"\"})]})]})]})};return a.jsxs(\"div\",{ref:u,className:\"relative flex-1\",onMouseEnter:f,onMouseLeave:y,children:[i,o&&a.jsxs(\"div\",{ref:m,className:`\n            absolute left-1/2 -translate-x-1/2 z-50\n            ${c===\"top\"?\"bottom-full mb-2\":\"top-full mt-2\"}\n            animate-in fade-in-0 zoom-in-95 duration-150\n          `,style:{maxWidth:\"calc(100vw - 24px)\"},children:[a.jsx(\"div\",{className:\"w-96 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm\",children:a.jsxs(\"div\",{className:\"p-2.5 flex gap-3\",children:[t&&v(t,\"L\"),t&&e&&a.jsx(\"div\",{className:\"w-px bg-bambu-dark-tertiary/50\"}),e&&v(e,\"R\")]})}),a.jsx(\"div\",{className:`\n              absolute left-1/2 -translate-x-1/2 w-0 h-0\n              border-l-[6px] border-l-transparent\n              border-r-[6px] border-r-transparent\n              ${c===\"top\"?\"top-full border-t-[6px] border-t-bambu-dark-tertiary\":\"bottom-full border-b-[6px] border-b-bambu-dark-tertiary\"}\n            `})]})]})}function A7e({slots:t,filamentInfo:e}){const{t:n}=Ft(),r=t.filter(l=>l.id>=2),i=6,s=16,o=Array.from({length:i},(l,c)=>r.find(d=>d.id===s+c)??{id:-(c+1),nozzle_type:\"\",nozzle_diameter:\"\",wear:null,stat:null,max_temp:0,serial_number:\"\",filament_color:\"\",filament_id:\"\",filament_type:\"\"});return a.jsxs(\"div\",{className:\"text-center px-2.5 py-1.5 bg-bambu-dark rounded-lg flex-[2_1_190px] flex flex-col justify-center\",children:[a.jsx(\"p\",{className:\"text-[9px] text-bambu-gray mb-1\",children:n(\"printers.nozzleRack\")}),a.jsx(\"div\",{className:\"flex gap-[3px] justify-center\",children:o.map((l,c)=>{const d=!l.nozzle_diameter&&!l.nozzle_type,u=d?null:HO(l.filament_color),m=u?tZ(l.filament_color):!1;return a.jsx(rie,{slot:l,index:c,filamentName:l.filament_id?e?.[l.filament_id]?.name:void 0,children:a.jsx(\"div\",{className:`w-7 h-7 rounded flex items-center justify-center cursor-default transition-colors border-b-2 ${d?\"bg-bambu-dark-tertiary/20 border-bambu-dark-tertiary/20\":\"bg-bambu-dark-tertiary/40 border-bambu-dark-tertiary/40\"}`,style:u?{backgroundColor:u}:void 0,children:a.jsx(\"span\",{className:`text-[10px] font-semibold ${d?\"text-bambu-gray/30\":m?\"text-black/80\":\"text-white\"}`,style:u&&!m?{textShadow:\"0 1px 3px rgba(0,0,0,0.9)\"}:void 0,children:d?\"—\":l.nozzle_diameter||\"?\"})})},l.id>=0?l.id:`empty-${c}`)})})]})}function oK({className:t}){return a.jsx(\"svg\",{className:t,viewBox:\"0 0 36 54\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:a.jsx(\"path\",{d:\"M17.8131 0.00538C18.4463 -0.15091 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00538ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z\",fill:\"#C3C2C1\"})})}function j7e({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 35 53\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"path\",{d:\"M17.3165 0.0038C17.932 -0.14959 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.0038ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z\",fill:\"#C3C2C1\"}),a.jsx(\"path\",{d:\"M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z\",fill:\"#1F8FEB\"})]})}function M7e({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 36 54\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"path\",{d:\"M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z\",fill:\"#1F8FEB\"}),a.jsx(\"path\",{d:\"M17.7948 0.00538C18.4273 -0.15091 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0134 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00538ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z\",fill:\"#C3C2C1\"})]})}function E7e({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 12 20\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"path\",{d:\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\",stroke:\"#C3C2C1\",strokeWidth:\"1\",fill:\"none\"}),a.jsx(\"circle\",{cx:\"6\",cy:\"15\",r:\"2.5\",stroke:\"#C3C2C1\",strokeWidth:\"1\",fill:\"none\"})]})}function D7e({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 12 20\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"rect\",{x:\"4.5\",y:\"8\",width:\"3\",height:\"4.5\",fill:\"#d4a017\",rx:\"0.5\"}),a.jsx(\"circle\",{cx:\"6\",cy:\"15\",r:\"2\",fill:\"#d4a017\"}),a.jsx(\"path\",{d:\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\",stroke:\"#C3C2C1\",strokeWidth:\"1\",fill:\"none\"})]})}function F7e({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 12 20\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"rect\",{x:\"4.5\",y:\"3\",width:\"3\",height:\"9.5\",fill:\"#c62828\",rx:\"0.5\"}),a.jsx(\"circle\",{cx:\"6\",cy:\"15\",r:\"2\",fill:\"#c62828\"}),a.jsx(\"path\",{d:\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\",stroke:\"#C3C2C1\",strokeWidth:\"1\",fill:\"none\"})]})}function OD({className:t,color:e,isHeating:n}){const i={\"text-orange-400\":\"#fb923c\",\"text-blue-400\":\"#60a5fa\",\"text-green-400\":\"#4ade80\"}[e]||\"#888\",s=n?{filter:`drop-shadow(0 0 4px ${i}) drop-shadow(0 0 8px ${i})`}:{};return n?a.jsxs(\"svg\",{className:t,style:s,viewBox:\"0 0 12 20\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"rect\",{x:\"4.5\",y:\"3\",width:\"3\",height:\"9.5\",fill:i,rx:\"0.5\"}),a.jsx(\"circle\",{cx:\"6\",cy:\"15\",r:\"2\",fill:i}),a.jsx(\"path\",{d:\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\",stroke:i,strokeWidth:\"1\",fill:\"none\"})]}):a.jsxs(\"svg\",{className:t,viewBox:\"0 0 12 20\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"path\",{d:\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\",stroke:i,strokeWidth:\"1\",fill:\"none\"}),a.jsx(\"circle\",{cx:\"6\",cy:\"15\",r:\"2.5\",stroke:i,strokeWidth:\"1\",fill:\"none\"})]})}function lK({humidity:t,goodThreshold:e=40,fairThreshold:n=60,onClick:r,compact:i}){const s=typeof t==\"string\"?parseInt(t,10):t,o=typeof e==\"number\"?e:40,l=typeof n==\"number\"?n:60;let c,d;isNaN(s)?(c=\"#C3C2C1\",d=\"Unknown\"):s<=o?(c=\"#22a352\",d=\"Good\"):s<=l?(c=\"#d4a017\",d=\"Fair\"):(c=\"#c62828\",d=\"Bad\");let u;return isNaN(s)||s<=o?u=oK:s<=l?u=j7e:u=M7e,a.jsxs(\"button\",{type:\"button\",onClick:r,className:`flex items-center gap-1 ${r?\"cursor-pointer hover:opacity-80 transition-opacity\":\"\"}`,title:`Humidity: ${s}% - ${d}${r?\" (click for history)\":\"\"}`,children:[a.jsx(u,{className:i?\"w-2.5 h-3\":\"w-3 h-4\"}),a.jsxs(\"span\",{className:`font-medium tabular-nums ${i?\"text-[10px]\":\"text-xs\"}`,style:{color:c},children:[s,\"%\"]})]})}function cK({temp:t,goodThreshold:e=28,fairThreshold:n=35,onClick:r,compact:i}){const s=typeof e==\"number\"?e:28,o=typeof n==\"number\"?n:35;let l,c,d;return t<=s?(l=\"#22a352\",c=\"Good\",d=E7e):t<=o?(l=\"#d4a017\",c=\"Fair\",d=D7e):(l=\"#c62828\",c=\"Bad\",d=F7e),a.jsxs(\"button\",{type:\"button\",onClick:r,className:`flex items-center gap-1 ${r?\"cursor-pointer hover:opacity-80 transition-opacity\":\"\"}`,title:`Temperature: ${t}°C - ${c}${r?\" (click for history)\":\"\"}`,children:[a.jsx(d,{className:i?\"w-2.5 h-3\":\"w-3 h-4\"}),a.jsxs(\"span\",{className:`tabular-nums text-right ${i?\"text-[10px] w-8\":\"w-12\"}`,style:{color:l},children:[t,\"°C\"]})]})}function Ul(t,e){const n=typeof t==\"string\"?parseInt(t,10):t,r=isNaN(n)?0:n,i=e===1,s=r>=128?r-128:r,o=String.fromCharCode(65+s);return i?`HT-${o}`:`AMS-${o}`}function ID(t){return t?!!(t.tray_uuid&&t.tray_uuid!==\"00000000000000000000000000000000\"||t.tag_uid&&t.tag_uid!==\"0000000000000000\"):!1}function R7e({url:t,printName:e}){const{t:n}=Ft(),[r,i]=w.useState(!1),[s,o]=w.useState(!1),[l,c]=w.useState(!1),d=w.useMemo(()=>{if(!t)return null;const u=t.includes(\"?\")?\"&\":\"?\";return Ga(`${t}${u}v=${encodeURIComponent(e||Date.now().toString())}`)},[t,e]);return w.useEffect(()=>{i(!1),o(!1)},[d]),a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${d&&r?\"cursor-pointer\":\"\"}`,onClick:()=>d&&r&&c(!0),children:d&&!s?a.jsxs(a.Fragment,{children:[a.jsx(\"img\",{src:d,alt:n(\"printers.printPreview\"),className:`w-full h-full object-cover ${r?\"block\":\"hidden\"}`,onLoad:()=>i(!0),onError:()=>o(!0)}),!r&&a.jsx(vi,{className:\"w-8 h-8 text-bambu-gray\"})]}):a.jsx(vi,{className:\"w-8 h-8 text-bambu-gray\"})}),l&&d&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8\",onClick:()=>c(!1),children:a.jsxs(\"div\",{className:\"relative max-w-2xl max-h-full\",children:[a.jsx(\"img\",{src:d,alt:n(\"printers.printPreview\"),className:\"max-w-full max-h-[80vh] rounded-lg shadow-2xl\"}),e&&a.jsx(\"p\",{className:\"text-white text-center mt-4 text-lg\",children:e})]})})]})}function L7e({printers:t}){const{t:e}=Ft(),n=nn(),[r,i]=w.useState(0);w.useEffect(()=>{let c=!1;const d=n.getQueryCache().subscribe(()=>{c||(c=!0,requestAnimationFrame(()=>{i(u=>u+1),c=!1}))});return()=>d()},[n]);const{counts:s,nextFinish:o}=w.useMemo(()=>{let c=0,d=0,u=0,m=0,p=0,f=0,y=0,v=null,b=null,g=0;return t?.forEach(_=>{const C=n.getQueryData([\"printerStatus\",_.id]);if(C===void 0)f++;else if(!C.connected)p++;else switch(C.hms_errors&&om(C.hms_errors).length>0&&y++,C.state){case\"RUNNING\":c++,C.remaining_time!=null&&C.remaining_time>0&&(b===null||C.remaining_time<b)&&(b=C.remaining_time,v=_.name,g=C.progress||0);break;case\"PAUSE\":d++;break;case\"FINISH\":u++;break;case\"FAILED\":y++;break;default:m++;break}}),{counts:{printing:c,paused:d,finished:u,idle:m,offline:p,loading:f,error:y,total:t?.length||0},nextFinish:v&&b?{name:v,remainingMin:b,progress:g}:null}},[t,n,r]);if(!t?.length)return null;const l=[{count:s.printing,dot:\"bg-bambu-green animate-pulse\",label:e(\"printers.status.printing\").toLowerCase()},{count:s.paused,dot:\"bg-status-warning\",label:e(\"printers.status.paused\",\"paused\").toLowerCase()},{count:s.finished,dot:\"bg-blue-400\",label:e(\"printers.status.finished\",\"finished\").toLowerCase()},{count:s.idle,dot:s.idle>0?\"bg-bambu-green\":\"bg-gray-500\",label:e(\"printers.status.available\").toLowerCase()},{count:s.error,dot:\"bg-status-error\",label:e(\"printers.status.problem\").toLowerCase()},{count:s.offline,dot:\"bg-gray-400\",label:e(\"printers.status.offline\").toLowerCase()}];return a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-4 gap-y-2 text-sm\",children:[l.map(({count:c,dot:d,label:u})=>c>0&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full ${d}`}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[a.jsx(\"span\",{className:\"text-white font-medium\",children:c}),\" \",u]})]},u)),o&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"w-px h-4 bg-bambu-dark-tertiary\"}),a.jsxs(\"div\",{className:\"flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"span\",{className:\"text-bambu-green font-medium\",children:[e(\"printers.nextAvailable\"),\":\"]}),a.jsx(\"span\",{className:\"text-white font-medium\",children:o.name})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 w-full sm:w-auto\",children:[a.jsx(\"div\",{className:\"w-full sm:w-16 bg-bambu-dark-tertiary rounded-full h-1.5\",children:a.jsx(\"div\",{className:\"bg-bambu-green h-1.5 rounded-full transition-all\",style:{width:`${o.progress}%`}})}),a.jsxs(\"span\",{className:\"text-white font-medium\",children:[Math.round(o.progress),\"%\"]}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"(\",ws(o.remainingMin*60),\")\"]})]})]})]})]})}const O7e=[\"error\",\"printing\",\"paused\",\"finished\",\"idle\",\"offline\"],dK={error:{labelKey:\"printers.status.problem\",dot:\"bg-status-error\"},printing:{labelKey:\"printers.status.printing\",dot:\"bg-bambu-green animate-pulse\"},paused:{labelKey:\"printers.status.paused\",dot:\"bg-status-warning\"},finished:{labelKey:\"printers.status.finished\",dot:\"bg-blue-400\"},idle:{labelKey:\"printers.status.idle\",dot:\"bg-bambu-green\"},offline:{labelKey:\"printers.status.offline\",dot:\"bg-gray-400\"}};function uK(t){if(!t?.connected)return\"offline\";if((t.hms_errors?om(t.hms_errors):[]).length>0)return\"error\";switch(t.state){case\"RUNNING\":return\"printing\";case\"PAUSE\":return\"paused\";case\"FINISH\":return\"finished\";case\"FAILED\":return\"error\";default:return\"idle\"}}function zD(t,e){if(e)return e;switch(t){case\"RUNNING\":return\"Printing\";case\"PAUSE\":return\"Paused\";case\"FINISH\":return\"Finished\";case\"FAILED\":return\"Failed\";case\"IDLE\":return\"Idle\";default:return t?t.charAt(0)+t.slice(1).toLowerCase():\"Idle\"}}function O0(t){return t?{O1D:\"H2D\",O1E:\"H2D Pro\",O2D:\"H2D Pro\",O1C:\"H2C\",O1C2:\"H2C\",O1S:\"H2S\",\"BL-P001\":\"X1C\",\"BL-P002\":\"X1\",\"BL-P003\":\"X1E\",N6:\"X2D\",C11:\"P1S\",C12:\"P1P\",C13:\"P2S\",N2S:\"A1\",N1:\"A1 Mini\",X1C:\"X1C\",X1:\"X1\",X1E:\"X1E\",X2D:\"X2D\",P1S:\"P1S\",P1P:\"P1P\",P2S:\"P2S\",A1:\"A1\",\"A1 Mini\":\"A1 Mini\",H2D:\"H2D\",\"H2D Pro\":\"H2D Pro\",H2C:\"H2C\",H2S:\"H2S\"}[t]||t:\"\"}function mK({ams:t,printerId:e,label:n,amsLabels:r,canEdit:i,onSaved:s,children:o}){const{t:l}=Ft(),[c,d]=w.useState(!1),[u,m]=w.useState(\"top\"),[p,f]=w.useState(\"\"),[y,v]=w.useState(!1),[b,g]=w.useState(null),[_,C]=w.useState(!1),P=w.useRef(null),N=w.useRef(null),A=w.useRef(null);w.useEffect(()=>{c&&(f(r?.[t.id]??\"\"),g(null),requestAnimationFrame(()=>{if(P.current&&N.current){const H=P.current.getBoundingClientRect(),z=H.top-56,Q=window.innerHeight-H.bottom;m(z<N.current.offsetHeight+12&&Q>z?\"bottom\":\"top\")}}))},[c,r,t.id]);const T=()=>{A.current&&clearTimeout(A.current),A.current=setTimeout(()=>d(!0),80)},F=()=>{A.current&&clearTimeout(A.current),_||(A.current=setTimeout(()=>d(!1),200))};w.useEffect(()=>()=>{A.current&&clearTimeout(A.current)},[]);const k=async()=>{if(i){v(!0),g(null);try{const H=p.trim();H?await ue.saveAmsLabel(e,t.id,H,t.serial_number):await ue.deleteAmsLabel(e,t.id,t.serial_number),s(),d(!1)}catch(H){g(H instanceof Error?H.message:String(H))}finally{v(!1)}}},D=async()=>{if(i){v(!0),g(null);try{await ue.deleteAmsLabel(e,t.id,t.serial_number),s(),d(!1)}catch(H){g(H instanceof Error?H.message:String(H))}finally{v(!1)}}};return a.jsxs(\"div\",{ref:P,className:\"relative inline-block\",onMouseEnter:T,onMouseLeave:F,children:[o,c&&a.jsx(\"div\",{ref:N,className:`\n            absolute left-0 z-50\n            ${u===\"top\"?\"bottom-full mb-2\":\"top-full mt-2\"}\n            animate-in fade-in-0 zoom-in-95 duration-150\n          `,style:{maxWidth:\"calc(100vw - 24px)\"},onMouseEnter:T,onMouseLeave:F,children:a.jsxs(\"div\",{className:\"w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm p-2.5 space-y-2\",children:[a.jsx(\"div\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:n}),a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2\",children:[a.jsx(\"span\",{className:\"text-[10px] tracking-wide text-bambu-gray font-medium shrink-0\",children:l(\"printers.amsPopup.serialNumber\")}),a.jsx(\"span\",{className:\"text-[10px] text-white font-mono truncate\",children:t.serial_number||\"—\"})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2\",children:[a.jsx(\"span\",{className:\"text-[10px] tracking-wide text-bambu-gray font-medium shrink-0\",children:l(\"printers.amsPopup.firmwareVersion\")}),a.jsx(\"span\",{className:\"text-[10px] text-white font-mono truncate\",children:t.sw_ver||\"—\"})]}),a.jsx(\"div\",{className:\"h-px bg-bambu-dark-tertiary/50\"}),a.jsxs(\"div\",{className:\"space-y-1\",children:[a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray font-medium block\",children:l(\"printers.amsPopup.friendlyName\")}),a.jsx(\"input\",{type:\"text\",value:p,onChange:H=>i&&f(H.target.value),onKeyDown:H=>H.key===\"Enter\"&&k(),onFocus:()=>C(!0),onBlur:()=>{C(!1),A.current&&clearTimeout(A.current),A.current=setTimeout(()=>d(!1),200)},placeholder:i?l(\"printers.amsPopup.friendlyNamePlaceholder\"):r?.[t.id]||\"—\",disabled:!i,title:i?void 0:l(\"printers.amsPopup.noEditPermission\"),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-xs text-white placeholder-bambu-gray/60 focus:outline-none focus:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed\",maxLength:100}),i&&a.jsxs(\"div\",{className:\"space-y-1\",children:[b&&a.jsx(\"p\",{className:\"text-[10px] text-red-400 break-words\",children:b}),a.jsxs(\"div\",{className:\"flex gap-1 justify-end\",children:[a.jsx(\"button\",{onClick:k,disabled:y,className:\"px-2 py-0.5 text-[10px] bg-bambu-green text-white rounded hover:bg-bambu-green/80 disabled:opacity-50\",children:l(\"printers.amsPopup.save\")}),r?.[t.id]&&a.jsx(\"button\",{onClick:D,disabled:y,className:\"px-2 py-0.5 text-[10px] bg-bambu-dark-tertiary text-bambu-gray rounded hover:bg-bambu-dark-tertiary/70 disabled:opacity-50\",children:l(\"printers.amsPopup.clear\")})]})]})]})]})})]})}const wL={PLA:{n3f:45,n3s:45,n3f_hours:12,n3s_hours:12},PETG:{n3f:65,n3s:65,n3f_hours:12,n3s_hours:12},TPU:{n3f:65,n3s:75,n3f_hours:12,n3s_hours:18},ABS:{n3f:65,n3s:80,n3f_hours:12,n3s_hours:8},ASA:{n3f:65,n3s:80,n3f_hours:12,n3s_hours:8},PA:{n3f:65,n3s:85,n3f_hours:12,n3s_hours:12},PC:{n3f:65,n3s:80,n3f_hours:12,n3s_hours:8},PVA:{n3f:65,n3s:85,n3f_hours:12,n3s_hours:18}};function hK({printer:t,hideIfDisconnected:e,maintenanceInfo:n,viewMode:r=\"expanded\",cardSize:i=2,amsThresholds:s,spoolmanEnabled:o=!1,linkedSpools:l,spoolmanUrl:c,spoolmanSyncMode:d,onGetAssignment:u,onUnassignSpool:m,timeFormat:p=\"system\",cameraViewMode:f=\"window\",onOpenEmbeddedCamera:y,checkPrinterFirmware:v=!0,dryingPresets:b=wL,requirePlateClear:g=!1,selectionMode:_=!1,isSelected:C=!1,onToggleSelect:P}){const{t:N}=Ft(),A=nn(),T=qo(),{showToast:F}=hn(),{hasPermission:k}=kr(),[D,H]=w.useState(!1),[z,Q]=w.useState(!1),[L,te]=w.useState(!0),[ie,J]=w.useState(!1),[oe,fe]=w.useState(!1),[re,W]=w.useState(!1),[ne,me]=w.useState(!1),[be,Ce]=w.useState(!1),[q,Y]=w.useState(!1),[E,j]=w.useState(!1),[O,K]=w.useState(!1),[U,de]=w.useState(null),[I,G]=w.useState(null),[X,V]=w.useState(null),[ee,se]=w.useState(10),[ge,he]=w.useState(null),[le,B]=w.useState(!1),[R,ae]=w.useState(!1),[_e,Se]=w.useState(!1),[ve,Te]=w.useState(!1),ye=w.useCallback(()=>Te(!1),[]),[je,Le]=w.useState(null),[Me,Oe]=w.useState(null),[Re,$e]=w.useState(\"n3f\"),[Ye,tt]=w.useState(\"PLA\"),[pe,Fe]=w.useState(50),[we,Ve]=w.useState(4),[Ae,ce]=w.useState(!1),[xe,Be]=w.useState(null),[Qe,ht]=w.useState(!1),[xt,gt]=w.useState(!1),Ut=w.useRef(0),[Wt,Zt]=w.useState(null),[Kt,Xt]=w.useState(null),[ln,vn]=w.useState(null),[Ke,at]=w.useState(null),[Lt,Et]=w.useState(!1),[At,Ie]=w.useState(null),[mt,pt]=w.useState(!1),[nt,ze]=w.useState(!1),[ot,Pe]=w.useState(null),[Ge,Ze]=w.useState(!1),[Je,We]=w.useState(!1),{data:Ue}=Xe({queryKey:[\"printerStatus\",t.id],queryFn:()=>ue.getPrinterStatus(t.id),refetchInterval:3e4}),{data:et}=Xe({queryKey:[\"firmwareUpdate\",t.id],queryFn:()=>zk.checkPrinterUpdate(t.id),staleTime:300*1e3,refetchInterval:300*1e3,enabled:v&&k(\"firmware:read\")}),jt=w.useMemo(()=>{const lt=new Set;if(Ue?.ams)for(const Bt of Ue.ams)for(const Jt of Bt.tray||[])Jt.tray_info_idx&&lt.add(Jt.tray_info_idx);for(const Bt of Ue?.vt_tray??[])Bt.tray_info_idx&&lt.add(Bt.tray_info_idx);if(Ue?.nozzle_rack)for(const Bt of Ue.nozzle_rack)Bt.filament_id&&lt.add(Bt.filament_id);return Array.from(lt)},[Ue?.ams,Ue?.vt_tray,Ue?.nozzle_rack]),yt=w.useMemo(()=>{const lt=new Set;if(Ue?.ams)for(const Bt of Ue.ams)for(const Jt of Bt.tray||[])Jt.tray_type&&lt.add(Jt.tray_type.toUpperCase());for(const Bt of Ue?.vt_tray??[])Bt.tray_type&&lt.add(Bt.tray_type.toUpperCase());return lt},[Ue?.ams,Ue?.vt_tray]),qe=w.useMemo(()=>{const lt=new Set;if(Ue?.ams){for(const Bt of Ue.ams)for(const Jt of Bt.tray||[])if(Jt.tray_type&&Jt.tray_color){const ct=Jt.tray_color.replace(\"#\",\"\").toLowerCase().slice(0,6);lt.add(`${Jt.tray_type.toUpperCase()}:${ct}`)}}for(const Bt of Ue?.vt_tray??[])if(Bt.tray_type&&Bt.tray_color){const Jt=Bt.tray_color.replace(\"#\",\"\").toLowerCase().slice(0,6);lt.add(`${Bt.tray_type.toUpperCase()}:${Jt}`)}return lt},[Ue?.ams,Ue?.vt_tray]),{data:St}=Xe({queryKey:[\"filamentInfo\",jt],queryFn:()=>ue.getFilamentInfo(jt),enabled:jt.length>0,staleTime:300*1e3}),{data:Pt}=Xe({queryKey:[\"slotPresets\",t.id],queryFn:()=>ue.getSlotPresets(t.id),staleTime:120*1e3}),qt=Ue?.state===\"RUNNING\"||Ue?.state===\"PAUSE\"?Ue?.current_archive_id??null:null,{data:on}=Xe({queryKey:[\"archive-plates\",qt],queryFn:()=>ue.getArchivePlates(qt),enabled:qt!=null,staleTime:300*1e3}),dn=!on?.is_multi_plate||Ue?.current_plate_id==null?null:on.plates.find(Bt=>Bt.index===Ue.current_plate_id)?.name||N(\"printers.plateNumber\",\"Plate {{number}}\",{number:Ue.current_plate_id}),{data:Nn,refetch:bn}=Xe({queryKey:[\"amsLabels\",t.id],queryFn:()=>ue.getAmsLabels(t.id),staleTime:300*1e3}),[un,wn]=w.useState(null);w.useEffect(()=>{Ue?.wifi_signal!=null&&wn(Ue.wifi_signal)},[Ue?.wifi_signal]);const pn=Ue?.wifi_signal??un,gr=w.useRef(void 0);w.useEffect(()=>{Ue?.connected!==void 0&&(gr.current=Ue.connected)},[Ue?.connected]);const Nr=Ue?.connected??gr.current,Fn=w.useRef({});w.useEffect(()=>{Ue?.ams_extruder_map&&Object.keys(Ue.ams_extruder_map).length>0&&(Fn.current=Ue.ams_extruder_map)},[Ue?.ams_extruder_map]);const Ba=Ue?.ams_extruder_map&&Object.keys(Ue.ams_extruder_map).length>0?Ue.ams_extruder_map:Fn.current,In=w.useRef([]);w.useEffect(()=>{Ue?.ams&&Ue.ams.length>0&&(In.current=Ue.ams)},[Ue?.ams]);const ma=Ue?.ams&&Ue.ams.length>0?Ue.ams:In.current,ra=w.useRef(void 0),Fr=Ue?.tray_now;Fr!==void 0&&Fr!==255?ra.current=Fr:Fr===255&&(ra.current=void 0);const Br=Fr!==void 0&&Fr!==255?Fr:ra.current,{data:cr}=Xe({queryKey:[\"smartPlugByPrinter\",t.id],queryFn:()=>ue.getSmartPlugByPrinter(t.id)}),{data:Ci}=Xe({queryKey:[\"scriptPlugsByPrinter\",t.id],queryFn:()=>ue.getScriptPlugsByPrinter(t.id)}),{data:Ui}=Xe({queryKey:[\"smartPlugStatus\",cr?.id],queryFn:()=>cr?ue.getSmartPlugStatus(cr.id):null,enabled:!!cr,refetchInterval:1e4}),{data:vc}=Xe({queryKey:[\"queue\",t.id,\"pending\"],queryFn:()=>ue.getQueue(t.id,\"pending\")}),_l=w.useMemo(()=>vc?.length?DJ(vc,yt,qe).length:0,[vc,yt,qe]),{data:vt}=Xe({queryKey:[\"queue\",t.id,\"printing\"],queryFn:()=>ue.getQueue(t.id,\"printing\"),enabled:Ue?.state===\"RUNNING\"}),{data:Kn}=Xe({queryKey:[\"currentPrintUser\",t.id],queryFn:()=>ue.getCurrentPrintUser(t.id),enabled:Ue?.state===\"RUNNING\"}),dr=vt?.[0]?.created_by_username||Kn?.username,{data:$t}=Xe({queryKey:[\"archives\",t.id,\"last\"],queryFn:()=>ue.getArchives(t.id,1,0),enabled:Ue?.connected&&Ue?.state!==\"RUNNING\"}),Ks=$t?.[0],rs=Ue?.state===\"RUNNING\"||Ue?.state===\"PAUSE\",wc=g&&Ue?.awaiting_plate_clear===!0,Sc=Ue?.connected&&wc&&!rs,Ha=!g||!Ue?.connected?null:rs?{label:N(\"printers.plateStatus.inUse\"),className:\"bg-blue-500/20 text-blue-400\"}:Ue.awaiting_plate_clear?{label:N(\"printers.plateStatus.notCleared\"),className:\"bg-yellow-500/20 text-yellow-400\"}:{label:N(\"printers.plateStatus.cleared\"),className:\"bg-status-ok/20 text-status-ok\"},id=Ha?a.jsx(\"span\",{className:`inline-flex flex-shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${Ha.className}`,children:Ha.label}):null,Ip=e&&Nr===!1,tu=it({mutationFn:lt=>ue.deletePrinter(t.id,lt.deleteArchives),onSuccess:()=>{A.invalidateQueries({queryKey:[\"printers\"]}),A.invalidateQueries({queryKey:[\"archives\"]}),A.invalidateQueries({queryKey:[\"maintenanceOverview\"]})},onError:lt=>F(lt.message||N(\"printers.toast.failedToDelete\"),\"error\")}),yo=it({mutationFn:()=>ue.connectPrinter(t.id),onSuccess:()=>{A.invalidateQueries({queryKey:[\"printerStatus\",t.id]})}}),nu=it({mutationFn:()=>ue.refreshPrinterStatus(t.id),onSuccess:()=>{A.invalidateQueries({queryKey:[\"printerStatus\",t.id]}),F(N(\"printers.forceRefreshSuccess\"),\"success\")},onError:lt=>F(lt.message||N(\"printers.toast.failedToSendCommand\"),\"error\")}),Ps=it({mutationFn:lt=>ue.unlinkSpool(lt),onSuccess:lt=>{F(N(\"spoolman.unlinkSuccess\")||lt?.message,\"success\"),A.invalidateQueries({queryKey:[\"linked-spools\"]}),A.invalidateQueries({queryKey:[\"unlinked-spools\"]})},onError:lt=>{F(lt.message||N(\"spoolman.unlinkFailed\"),\"error\")}}),vo=it({mutationFn:({amsId:lt,temp:Bt,duration:Jt,filament:ct,rotateTray:Un})=>ue.startDrying(t.id,lt,Bt,Jt,ct,Un),onSuccess:()=>{Oe(null),A.invalidateQueries({queryKey:[\"printerStatus\",t.id]})},onError:lt=>F(lt.message||N(\"printers.toast.failedToSendCommand\"),\"error\")}),Ts=it({mutationFn:lt=>ue.stopDrying(t.id,lt),onSuccess:()=>{A.invalidateQueries({queryKey:[\"printerStatus\",t.id]})},onError:lt=>F(lt.message||N(\"printers.toast.failedToSendCommand\"),\"error\")}),Wo=it({mutationFn:lt=>cr?ue.controlSmartPlug(cr.id,lt):Promise.reject(\"No plug\"),onSuccess:()=>{A.invalidateQueries({queryKey:[\"smartPlugStatus\",cr?.id]})}}),wo=it({mutationFn:lt=>cr?ue.updateSmartPlug(cr.id,{auto_off:lt}):Promise.reject(\"No plug\"),onSuccess:()=>{A.invalidateQueries({queryKey:[\"smartPlugByPrinter\",t.id]}),A.invalidateQueries({queryKey:[\"smart-plugs\"]})}}),_c=it({mutationFn:({id:lt,action:Bt})=>ue.controlSmartPlug(lt,Bt),onSuccess:()=>{F(N(\"printers.toast.scriptTriggered\"))},onError:lt=>F(lt.message||N(\"printers.toast.failedToRunScript\"),\"error\")}),ru=it({mutationFn:()=>ue.stopPrint(t.id),onSuccess:()=>{F(N(\"printers.toast.printStopped\")),A.invalidateQueries({queryKey:[\"printerStatus\",t.id]})},onError:lt=>F(lt.message||N(\"printers.toast.failedToStopPrint\"),\"error\")}),zp=it({mutationFn:()=>ue.pausePrint(t.id),onSuccess:()=>{F(N(\"printers.toast.printPaused\")),A.invalidateQueries({queryKey:[\"printerStatus\",t.id]})},onError:lt=>F(lt.message||N(\"printers.toast.failedToPausePrint\"),\"error\")}),di=it({mutationFn:()=>ue.resumePrint(t.id),onSuccess:()=>{F(N(\"printers.toast.printResumed\")),A.invalidateQueries({queryKey:[\"printerStatus\",t.id]})},onError:lt=>F(lt.message||N(\"printers.toast.failedToResumePrint\"),\"error\")}),As=it({mutationFn:()=>ue.clearPlate(t.id),onSuccess:()=>{F(N(\"queue.clearPlateSuccess\")),A.setQueryData([\"printerStatus\",t.id],lt=>lt&&{...lt,awaiting_plate_clear:!1}),A.invalidateQueries({queryKey:[\"printerStatus\",t.id]}),A.invalidateQueries({queryKey:[\"queue\",t.id]})},onError:lt=>F(lt.message||N(\"printers.toast.failedToSendCommand\"),\"error\")}),au=it({mutationFn:lt=>ue.setChamberLight(t.id,lt),onMutate:async lt=>{await A.cancelQueries({queryKey:[\"printerStatus\",t.id]});const Bt=A.getQueryData([\"printerStatus\",t.id]);return A.setQueryData([\"printerStatus\",t.id],Jt=>({...Jt,chamber_light:lt})),{previousStatus:Bt}},onSuccess:(lt,Bt)=>{F(`Chamber light ${Bt?\"on\":\"off\"}`)},onError:(lt,Bt,Jt)=>{Jt?.previousStatus&&A.setQueryData([\"printerStatus\",t.id],Jt.previousStatus),F(lt.message||N(\"printers.toast.failedToControlChamberLight\"),\"error\")}}),sd=it({mutationFn:lt=>ue.setPrintSpeed(t.id,lt),onMutate:async lt=>{await A.cancelQueries({queryKey:[\"printerStatus\",t.id]});const Bt=A.getQueryData([\"printerStatus\",t.id]);return A.setQueryData([\"printerStatus\",t.id],Jt=>({...Jt,speed_level:lt})),{previousStatus:Bt}},onError:(lt,Bt,Jt)=>{Jt?.previousStatus&&A.setQueryData([\"printerStatus\",t.id],Jt.previousStatus),F(lt.message||N(\"printers.toast.failedToSetSpeed\"),\"error\")}}),Up=it({mutationFn:lt=>ue.setAirductMode(t.id,lt),onMutate:async lt=>{await A.cancelQueries({queryKey:[\"printerStatus\",t.id]});const Bt=A.getQueryData([\"printerStatus\",t.id]);return A.setQueryData([\"printerStatus\",t.id],Jt=>({...Jt,airduct_mode:lt===\"cooling\"?0:1})),{previousStatus:Bt}},onError:(lt,Bt,Jt)=>{Jt?.previousStatus&&A.setQueryData([\"printerStatus\",t.id],Jt.previousStatus),F(lt.message||N(\"printers.toast.failedToSendCommand\"),\"error\")}}),Ko=it({mutationFn:({distance:lt,force:Bt})=>ue.bedJog(t.id,lt,Bt??!1),onError:lt=>F(lt.message||N(\"printers.toast.failedToSendCommand\"),\"error\")}),iu=it({mutationFn:lt=>ue.homeAxes(t.id,lt),onSuccess:()=>{try{sessionStorage.setItem(`bambuddy.bedJog.warned.${t.id}`,\"1\")}catch{}F(N(\"printers.bedJog.homingStarted\"))},onError:lt=>F(lt.message||N(\"printers.toast.failedToSendCommand\"),\"error\")}),So=it({mutationFn:lt=>ue.updatePrinter(t.id,{plate_detection_enabled:lt}),onSuccess:()=>{A.invalidateQueries({queryKey:[\"printers\"]}),F(So.variables?N(\"printers.toast.plateCheckEnabled\"):N(\"printers.toast.plateCheckDisabled\"))},onError:lt=>F(lt.message||N(\"printers.toast.failedToUpdateSetting\"),\"error\")}),Xo=(Ue?.state===\"RUNNING\"||Ue?.state===\"PAUSE\")&&(Ue?.printable_objects_count??0)>=2,{data:kl}=Xe({queryKey:[\"printableObjects\",t.id],queryFn:()=>ue.getPrintableObjects(t.id),enabled:R||Xo,refetchInterval:R?5e3:Xo?3e4:!1}),[Yo,Nl]=w.useState(null),Lm=w.useRef(!1),as=w.useRef(null),Om=w.useRef(!1),xn=it({mutationFn:({amsId:lt,slotId:Bt})=>ue.refreshAmsSlot(t.id,lt,Bt),onMutate:({amsId:lt,slotId:Bt})=>{as.current&&clearTimeout(as.current),Lm.current=!1,Om.current=!1,Nl({amsId:lt,slotId:Bt}),setTimeout(()=>{Om.current=!0},2e3),as.current=setTimeout(()=>{Nl(null)},3e4)},onSuccess:lt=>{F(lt.message||N(\"printers.toast.rfidRereadInitiated\"))},onError:lt=>{F(lt.message||N(\"printers.toast.failedToRereadRfid\"),\"error\"),as.current&&clearTimeout(as.current),Nl(null)}}),[kc,Bp]=w.useState(null),[od,Nc]=w.useState(null),su=async()=>{try{const lt=await ue.getPlateReferences(t.id);Bp(lt)}catch{}},qy=()=>{So.mutate(!t.plate_detection_enabled)},Hp=async()=>{pt(!0),Ie(null);const lt=Ue?.chamber_light===!1;We(lt),lt&&(await ue.setChamberLight(t.id,!0),await new Promise(Bt=>setTimeout(Bt,2500)));try{const Bt=await ue.checkPlateEmpty(t.id,{includeDebugImage:!0});Ie(Bt),su()}catch(Bt){F(Bt instanceof Error?Bt.message:N(\"printers.toast.failedToCheckPlate\"),\"error\"),lt&&(await ue.setChamberLight(t.id,!1),We(!1))}finally{pt(!1)}},ld=w.useCallback(async()=>{Ie(null),Je&&(await ue.setChamberLight(t.id,!1),We(!1))},[Je,t.id]),Cc=async lt=>{ze(!0);try{const Bt=await ue.calibratePlateDetection(t.id,{label:lt});if(Bt.success){F(Bt.message||N(\"printers.toast.calibrationSaved\"),\"success\"),su();const Jt=await ue.checkPlateEmpty(t.id,{includeDebugImage:!0});Ie(Jt)}else F(Bt.message||N(\"printers.toast.calibrationFailed\"),\"error\")}catch(Bt){F(Bt instanceof Error?Bt.message:N(\"printers.toast.calibrationFailed\"),\"error\")}finally{ze(!1)}},Im=async(lt,Bt)=>{try{await ue.updatePlateReferenceLabel(t.id,lt,Bt),Nc(null),su()}catch(Jt){F(Jt instanceof Error?Jt.message:N(\"printers.toast.failedToUpdateLabel\"),\"error\")}},$y=async lt=>{try{await ue.deletePlateReference(t.id,lt),F(N(\"printers.toast.referenceDeleted\"),\"success\"),su();const Bt=await ue.checkPlateEmpty(t.id,{includeDebugImage:!0});Ie(Bt)}catch(Bt){F(Bt instanceof Error?Bt.message:N(\"printers.toast.failedToDeleteReference\"),\"error\")}},ke=async()=>{if(ot){Ze(!0);try{await ue.updatePrinter(t.id,{plate_detection_roi:ot}),F(N(\"printers.toast.detectionAreaSaved\"),\"success\"),Pe(null);const lt=await ue.checkPlateEmpty(t.id,{includeDebugImage:!0});Ie(lt)}catch(lt){F(lt instanceof Error?lt.message:N(\"printers.toast.failedToSaveDetectionArea\"),\"error\")}finally{Ze(!1)}}};w.useEffect(()=>{const lt=Bt=>{Bt.key===\"Escape\"&&At&&ld()};return window.addEventListener(\"keydown\",lt),()=>window.removeEventListener(\"keydown\",lt)},[At,ld]);const Rt=w.useRef(null);w.useEffect(()=>{if(!Yo)return;const lt=Ue?.ams_status_main??0;return lt!==0&&(Lm.current=!0,Rt.current&&(clearTimeout(Rt.current),Rt.current=null)),Lm.current&&lt===0&&(Om.current?(as.current&&clearTimeout(as.current),Nl(null)):Rt.current||(Rt.current=setTimeout(()=>{as.current&&clearTimeout(as.current),Nl(null)},2e3))),()=>{Rt.current&&clearTimeout(Rt.current)}},[Ue?.ams_status_main,Yo]);const[Sn,_n]=w.useState(null);if(Ip)return null;const Bn=()=>{switch(i){case 1:return\"w-10 h-10\";case 2:return\"w-14 h-14\";case 3:return\"w-16 h-16\";case 4:return\"w-20 h-20\";default:return\"w-14 h-14\"}},xa=()=>{switch(i){case 1:return\"text-base truncate\";case 2:return\"text-lg\";case 3:return\"text-xl\";case 4:return\"text-2xl\";default:return\"text-lg\"}},ui=()=>{switch(i){case 1:return\"mb-2\";case 2:return\"mb-4\";case 3:return\"mb-5\";case 4:return\"mb-6\";default:return\"mb-4\"}},Cr=Nr&&Ue?.state!==\"RUNNING\"&&Ue?.state!==\"PAUSE\"&&k(\"printers:control\"),zm=lt=>{lt.preventDefault(),Ut.current++,Ut.current===1&&ht(!0)},qp=lt=>{lt.preventDefault(),lt.dataTransfer.dropEffect=Cr?\"copy\":\"none\"},_A=lt=>{lt.preventDefault(),Ut.current--,Ut.current===0&&ht(!1)},Vy=async lt=>{if(lt.preventDefault(),Ut.current=0,ht(!1),!Cr)return;const Jt=Array.from(lt.dataTransfer.files)[0];if(!Jt)return;const ct=Jt.name.toLowerCase();if(!ct.endsWith(\".gcode\")&&!ct.includes(\".gcode.\")){F(N(\"printers.dropNotPrintable\",\"Only .gcode and .gcode.3mf files can be printed\"),\"error\");return}gt(!0);try{const Un=await ue.uploadLibraryFile(Jt,null),Tr=Un.metadata?.sliced_for_model,ar=O0(t.model);if(Tr&&ar&&Tr.toLowerCase()!==ar.toLowerCase()){await ue.deleteLibraryFile(Un.id).catch(()=>{}),F(N(\"printers.incompatibleFile\",\"This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}\",{slicedFor:Tr,printerModel:ar}),\"error\");return}Le({id:Un.id,filename:Un.filename})}catch{F(N(\"common.uploadFailed\",\"Upload failed\"),\"error\")}finally{gt(!1)}};return a.jsxs(Tt,{className:`relative ${C?\"ring-2 ring-bambu-green\":\"\"} ${_?\"cursor-pointer\":\"\"}`,onDragEnter:zm,onDragOver:qp,onDragLeave:_A,onDrop:Vy,children:[_&&a.jsx(\"div\",{className:\"absolute inset-0 z-20 flex items-start p-2\",onClick:lt=>{lt.stopPropagation(),P?.(t.id)},children:C?a.jsx(Ns,{className:\"w-5 h-5 text-bambu-green\"}):a.jsx(uo,{className:\"w-5 h-5 text-bambu-gray\"})}),(Qe||xt)&&a.jsx(\"div\",{className:`absolute inset-0 z-10 rounded-xl border-2 border-dashed flex items-center justify-center transition-colors ${xt?\"bg-bambu-green/10 border-bambu-green/50\":Cr?\"bg-bambu-green/10 border-bambu-green\":\"bg-red-500/10 border-red-500/50\"}`,children:a.jsx(\"div\",{className:\"text-center\",children:xt?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-8 h-8 mx-auto mb-2 text-bambu-green animate-spin\"}),a.jsx(\"p\",{className:\"text-sm font-medium text-bambu-green\",children:N(\"common.uploading\",\"Uploading...\")})]}):Cr?a.jsxs(a.Fragment,{children:[a.jsx(Er,{className:\"w-8 h-8 mx-auto mb-2 text-bambu-green\"}),a.jsx(\"p\",{className:\"text-sm font-medium text-bambu-green\",children:N(\"printers.dropToPrint\",\"Drop to print\")})]}):a.jsxs(a.Fragment,{children:[a.jsx(Ht,{className:\"w-8 h-8 mx-auto mb-2 text-red-400\"}),a.jsx(\"p\",{className:\"text-sm font-medium text-red-400\",children:N(\"printers.cannotPrint\",\"Printer busy\")})]})})}),a.jsxs(Mt,{className:i>=3?\"p-5\":\"\",children:[a.jsxs(\"div\",{className:ui(),children:[a.jsxs(\"div\",{className:\"flex items-start justify-between gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 min-w-0 flex-1\",children:[a.jsx(\"img\",{src:MJ(t.model),alt:t.model||N(\"common.printer\"),className:`object-contain rounded-lg bg-bambu-dark flex-shrink-0 ${Bn()}`}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"h3\",{className:`font-semibold text-white ${xa()}`,children:t.name}),r===\"compact\"&&(()=>{const lt=Ue?.connected&&Ue.hms_errors?om(Ue.hms_errors):[],Bt=lt.some(Tr=>Tr.severity<=2),Jt=lt.length>0,ct=Ue?.connected?Bt?\"bg-status-error\":Jt?\"bg-status-warning\":\"bg-status-ok\":\"bg-status-error\",Un=Ue?.connected?Jt?`${lt.length} HMS ${lt.length===1?\"error\":\"errors\"}`:N(\"printers.connection.connected\"):N(\"printers.connection.offline\");return a.jsx(\"div\",{className:`w-2 h-2 rounded-full flex-shrink-0 ${ct}`,title:Un})})()]}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[t.model||\"Unknown Model\",r===\"expanded\"&&Ue?.nozzles&&Ue.nozzles[0]?.nozzle_diameter&&a.jsxs(\"span\",{className:\"ml-1.5 text-bambu-gray\",title:Ue.nozzles[0].nozzle_type||\"Nozzle\",children:[\"• \",Ue.nozzles[0].nozzle_diameter,\"mm\"]}),r===\"expanded\"&&n&&n.total_print_hours>0&&a.jsxs(\"span\",{className:\"ml-2 text-bambu-gray\",children:[a.jsx(Yn,{className:\"w-3 h-3 inline-block mr-1\"}),Math.round(n.total_print_hours),\"h\"]})]})]})]}),a.jsxs(\"div\",{className:\"relative flex-shrink-0\",children:[a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>H(!D),children:a.jsx(Jh,{className:\"w-4 h-4\"})}),D&&a.jsxs(\"div\",{className:\"absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20\",children:[a.jsxs(\"button\",{className:`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${k(\"printers:update\")?\"hover:bg-bambu-dark-tertiary\":\"opacity-50 cursor-not-allowed\"}`,onClick:()=>{k(\"printers:update\")&&(J(!0),H(!1))},title:k(\"printers:update\")?void 0:N(\"printers.permission.noEdit\"),children:[a.jsx(ci,{className:\"w-4 h-4\"}),N(\"common.edit\")]}),a.jsxs(\"button\",{className:\"w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2\",onClick:()=>{Te(!0),H(!1)},children:[a.jsx(Ss,{className:\"w-4 h-4\"}),N(\"printers.printerInformation\")]}),a.jsxs(\"button\",{className:\"w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2\",onClick:()=>{yo.mutate(),H(!1)},children:[a.jsx(lr,{className:\"w-4 h-4\"}),N(\"printers.reconnect\")]}),a.jsxs(\"button\",{className:\"w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2 disabled:opacity-50\",disabled:nu.isPending,onClick:()=>{nu.mutate(),H(!1)},children:[a.jsx(sw,{className:`w-4 h-4 ${nu.isPending?\"animate-spin\":\"\"}`}),N(\"printers.forceRefresh\")]}),a.jsxs(\"button\",{className:\"w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2\",onClick:()=>{W(!0),H(!1)},children:[a.jsx(WQ,{className:\"w-4 h-4\"}),N(\"printers.mqttDebug\")]}),a.jsxs(\"button\",{className:`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${k(\"printers:delete\")?\"text-red-400 hover:bg-bambu-dark-tertiary\":\"text-red-400/50 cursor-not-allowed\"}`,onClick:()=>{k(\"printers:delete\")&&(Q(!0),H(!1))},title:k(\"printers:delete\")?void 0:N(\"printers.permission.noDelete\"),children:[a.jsx(en,{className:\"w-4 h-4\"}),N(\"common.delete\")]})]})]})]}),r===\"expanded\"&&a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-2 mt-2\",children:[a.jsxs(\"span\",{className:`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${Ue?.connected?\"bg-status-ok/20 text-status-ok\":\"bg-status-error/20 text-status-error\"}`,children:[Ue?.connected?a.jsx(Sy,{className:\"w-3 h-3\"}):a.jsx(gp,{className:\"w-3 h-3\"}),Ue?.connected?N(\"printers.connection.connected\"):N(\"printers.connection.offline\")]}),Ue?.connected&&Ue?.wired_network&&a.jsxs(\"span\",{className:\"flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-status-ok/20 text-status-ok\",title:N(\"printers.connection.ethernet\",\"Ethernet\"),children:[a.jsx(AO,{className:\"w-3 h-3\"}),N(\"printers.connection.ethernet\",\"Ethernet\")]}),Ue?.connected&&!Ue?.wired_network&&pn!=null&&a.jsxs(\"span\",{className:`flex items-center gap-1 px-2 py-1 rounded-full text-xs ${pn>=-50||pn>=-60?\"bg-status-ok/20 text-status-ok\":pn>=-70?\"bg-status-warning/20 text-status-warning\":pn>=-80?\"bg-orange-500/20 text-orange-600\":\"bg-status-error/20 text-status-error\"}`,title:`WiFi: ${pn} dBm - ${N(EJ(pn).labelKey)}`,children:[a.jsx($Q,{className:\"w-3 h-3\"}),pn,\"dBm\"]}),Ue?.connected&&(()=>{const lt=Ue.hms_errors?om(Ue.hms_errors):[];return a.jsxs(\"button\",{onClick:()=>Y(!0),className:`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${lt.length>0?lt.some(Bt=>Bt.severity<=2)?\"bg-status-error/20 text-status-error\":\"bg-status-warning/20 text-status-warning\":\"bg-status-ok/20 text-status-ok\"}`,title:N(\"printers.clickToViewHmsErrors\"),children:[a.jsx(Dn,{className:\"w-3 h-3\"}),lt.length>0?lt.length:\"OK\"]})})(),n&&a.jsxs(\"button\",{onClick:()=>T(\"/maintenance\"),className:`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${n.due_count>0?\"bg-status-error/20 text-status-error\":n.warning_count>0?\"bg-status-warning/20 text-status-warning\":\"bg-status-ok/20 text-status-ok\"}`,title:n.due_count>0||n.warning_count>0?`${n.due_count>0?`${n.due_count} maintenance due`:\"\"}${n.due_count>0&&n.warning_count>0?\", \":\"\"}${n.warning_count>0?`${n.warning_count} due soon`:\"\"} - Click to view`:N(\"printers.maintenanceUpToDate\"),children:[a.jsx(mg,{className:\"w-3 h-3\"}),n.due_count>0||n.warning_count>0?n.due_count+n.warning_count:\"OK\"]}),_l>0&&a.jsxs(\"button\",{onClick:()=>T(\"/queue\"),className:\"flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-indigo-500/20 text-indigo-400 hover:opacity-80 transition-opacity\",title:N(\"printers.queue.inQueue\",{count:_l}),children:[a.jsx(da,{className:\"w-3 h-3\"}),_l]}),v&&et?.current_version&&et?.latest_version?a.jsxs(\"button\",{onClick:()=>Et(!0),className:`flex items-center gap-1 px-2 py-1 rounded-full text-xs hover:opacity-80 transition-opacity ${et.update_available?\"bg-orange-500/20 text-orange-400\":\"bg-status-ok/20 text-status-ok\"}`,title:et.update_available?N(\"printers.firmwareUpdateAvailable\",{current:et.current_version,latest:et.latest_version}):N(\"printers.firmwareUpToDate\",{version:et.current_version}),children:[et.update_available?a.jsx(ga,{className:\"w-3 h-3\"}):a.jsx(yr,{className:\"w-3 h-3\"}),et.current_version]}):Ue?.firmware_version?a.jsx(\"span\",{className:\"flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-bambu-dark-tertiary/50 text-bambu-gray\",children:Ue.firmware_version}):null,Ue?.connected&&[\"X1C\",\"X1\",\"X1E\",\"X2D\",\"P1S\",\"P1P\",\"P2S\",\"H2D\",\"H2D Pro\",\"H2C\",\"H2S\"].includes(t.model??\"\")&&a.jsx(\"span\",{className:`flex items-center px-2 py-1 rounded-full text-xs ${Ue.door_open?\"bg-yellow-500/20 text-yellow-400\":\"bg-status-ok/20 text-status-ok\"}`,title:Ue.door_open?N(\"printers.door.open\"):N(\"printers.door.closed\"),children:Ue.door_open?a.jsx($fe,{className:\"w-3 h-3\"}):a.jsx(Hfe,{className:\"w-3 h-3\"})})]})]}),z&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\",children:a.jsx(Tt,{className:\"w-full max-w-md mx-4\",children:a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"flex items-start gap-3 mb-4\",children:[a.jsx(\"div\",{className:\"p-2 rounded-full bg-red-500/20\",children:a.jsx(Dn,{className:\"w-5 h-5 text-red-400\"})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white\",children:N(\"printers.confirm.deleteTitle\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:N(\"printers.confirm.deleteMessage\",{name:t.name})})]})]}),a.jsx(\"div\",{className:\"bg-bambu-dark rounded-lg p-3 mb-4\",children:a.jsxs(\"label\",{className:\"flex items-start gap-3 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:L,onChange:lt=>te(lt.target.checked),className:\"mt-0.5 w-4 h-4 rounded border-bambu-gray bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:N(\"printers.deleteArchives\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-0.5\",children:N(L?\"printers.confirm.deleteArchivesNote\":\"printers.confirm.keepArchivesNote\")})]})]})}),a.jsxs(\"div\",{className:\"flex justify-end gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{Q(!1),te(!0)},children:N(\"common.cancel\")}),a.jsx(De,{variant:\"danger\",onClick:()=>{tu.mutate({deleteArchives:L}),Q(!1),te(!0)},children:\"Delete\"})]})]})})}),Ue?.connected&&a.jsxs(a.Fragment,{children:[r===\"compact\"?a.jsx(\"div\",{className:\"mt-2\",children:Ue.state===\"RUNNING\"||Ue.state===\"PAUSE\"?a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"div\",{className:\"flex-1 bg-bambu-dark-tertiary rounded-full h-1.5\",children:a.jsx(\"div\",{className:`${Ue.state===\"PAUSE\"?\"bg-status-warning\":\"bg-bambu-green\"} h-1.5 rounded-full transition-all`,style:{width:`${Ue.progress||0}%`}})}),a.jsxs(\"div\",{className:\"flex flex-shrink-0 items-center gap-1.5\",children:[a.jsxs(\"span\",{className:\"text-xs text-white\",children:[Math.round(Ue.progress||0),\"%\"]}),id]})]}):a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2\",children:[a.jsxs(\"div\",{className:\"min-w-0 flex-1 flex items-center gap-1.5\",children:[a.jsx(\"p\",{className:\"min-w-0 truncate text-xs text-bambu-gray\",children:zD(Ue.state,Ue.stg_cur_name)}),id]}),Sc&&a.jsx(\"button\",{type:\"button\",onClick:()=>As.mutate(),disabled:As.isPending||!k(\"printers:clear_plate\"),\"aria-label\":N(\"printers.plateStatus.markCleared\"),className:\"inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-yellow-500/20 border border-yellow-400/40 text-yellow-400 hover:bg-yellow-500/30 transition-colors disabled:opacity-50\",title:k(\"printers:clear_plate\")?N(\"printers.plateStatus.markCleared\"):N(\"printers.permission.noControl\"),children:As.isPending?a.jsx(ft,{className:\"w-3 h-3 animate-spin\"}):a.jsx(JW,{className:\"w-3 h-3\"})})]})}):a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"mb-4 p-3 bg-bambu-dark rounded-lg relative\",children:[a.jsxs(\"button\",{onClick:()=>ae(!0),disabled:!(Ue.state===\"RUNNING\"||Ue.state===\"PAUSE\")||(Ue.printable_objects_count??0)<2||!k(\"printers:control\"),className:`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${(Ue.state===\"RUNNING\"||Ue.state===\"PAUSE\")&&(Ue.printable_objects_count??0)>=2&&k(\"printers:control\")?\"text-bambu-gray hover:text-white hover:bg-white/10\":\"text-bambu-gray/30 cursor-not-allowed\"}`,title:k(\"printers:control\")?Ue.state===\"RUNNING\"||Ue.state===\"PAUSE\"?(Ue.printable_objects_count??0)>=2?N(\"printers.skipObjects.tooltip\"):N(\"printers.skipObjects.requiresMultiple\"):N(\"printers.skipObjects.onlyWhilePrinting\"):N(\"printers.permission.noControl\"),children:[a.jsx(iT,{className:\"w-4 h-4\"}),kl&&kl.skipped_count>0&&a.jsx(\"span\",{className:\"absolute -top-1 -right-1 min-w-[16px] h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-red-500 text-white rounded-full\",children:kl.skipped_count})]}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(R7e,{url:Ue.state===\"RUNNING\"||Ue.state===\"PAUSE\"?Ue.cover_url:null,printName:(Ue.state===\"RUNNING\"||Ue.state===\"PAUSE\")&&zH(Ue.subtask_name||Ue.current_print||null,Ue.gcode_file,N,dn)||void 0}),a.jsx(\"div\",{className:\"flex-1 min-w-0\",children:Ue.current_print&&(Ue.state===\"RUNNING\"||Ue.state===\"PAUSE\")?a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"mb-1 flex items-center gap-2\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:zD(Ue.state,Ue.stg_cur_name)}),id]}),a.jsx(\"p\",{className:\"text-white text-sm mb-2 truncate\",children:zH(Ue.subtask_name||Ue.current_print||null,Ue.gcode_file,N,dn)}),a.jsxs(\"div\",{className:\"flex items-center justify-between text-sm\",children:[a.jsx(\"div\",{className:\"flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3\",children:a.jsx(\"div\",{className:`${Ue.state===\"PAUSE\"?\"bg-status-warning\":\"bg-bambu-green\"} h-2 rounded-full transition-all`,style:{width:`${Ue.progress||0}%`}})}),a.jsxs(\"span\",{className:\"text-white\",children:[Math.round(Ue.progress||0),\"%\"]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3 mt-2 text-xs text-bambu-gray\",children:[Ue.remaining_time!=null&&Ue.remaining_time>0&&a.jsxs(a.Fragment,{children:[a.jsxs(\"span\",{className:\"flex items-center gap-1\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),ws(Ue.remaining_time*60)]}),a.jsxs(\"span\",{className:\"text-bambu-green font-medium\",title:N(\"printers.estimatedCompletion\"),children:[\"ETA \",qO(Ue.remaining_time,p,N)]})]}),Ue.layer_num!=null&&Ue.total_layers!=null&&Ue.total_layers>0&&a.jsxs(\"span\",{className:\"flex items-center gap-1\",children:[a.jsx(da,{className:\"w-3 h-3\"}),Ue.layer_num,\"/\",Ue.total_layers]}),dr&&a.jsxs(\"span\",{className:\"flex items-center gap-1\",title:`Started by ${dr}`,children:[a.jsx(ym,{className:\"w-3 h-3\"}),dr]})]})]}):a.jsxs(a.Fragment,{children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-1\",children:N(\"printers.sort.status\")}),a.jsxs(\"div\",{className:\"mb-2 flex items-center gap-2\",children:[a.jsx(\"p\",{className:\"text-white text-sm\",children:zD(Ue.state,Ue.stg_cur_name)}),id]}),a.jsxs(\"div\",{className:\"flex items-center justify-between text-sm\",children:[a.jsx(\"div\",{className:\"flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3\",children:a.jsx(\"div\",{className:\"bg-bambu-dark-tertiary h-2 rounded-full\"})}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"—\"})]}),Ks?a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mt-2 truncate\",title:Ks.print_name||Ks.filename,children:[\"Last: \",Ks.print_name||Ks.filename,Ks.completed_at&&a.jsxs(\"span\",{className:\"ml-1 text-bambu-gray/60\",children:[\"• \",hg(Ks.completed_at,{month:\"short\",day:\"numeric\"})]})]}):a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-2\",children:N(\"printers.readyToPrint\")})]})})]})]}),a.jsx(YMe,{printerId:t.id,printerModel:t.model,loadedFilamentTypes:yt,loadedFilaments:qe})]}),Ue.temperatures&&r===\"expanded\"&&(()=>{const lt=Ue.temperatures.nozzle_heating||Ue.temperatures.nozzle_2_heating||!1,Bt=Ue.temperatures.bed_heating||!1,Jt=Ue.temperatures.chamber_heating||!1,ct=t.nozzle_count===2||Ue.temperatures.nozzle_2!==void 0,Un=Ue.active_extruder===1?\"L\":\"R\",Tr=Ue.nozzle_rack?.find(Da=>Da.id===1),ar=Ue.nozzle_rack?.find(Da=>Da.id===0),Rr=ar||Tr;return a.jsxs(\"div\",{className:\"flex items-stretch gap-1.5 flex-wrap\",children:[a.jsxs(\"div\",{className:\"text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center\",children:[a.jsx(OD,{className:\"w-3.5 h-3.5 mb-0.5\",color:\"text-orange-400\",isHeating:lt}),Ue.temperatures.nozzle_2!==void 0?a.jsxs(a.Fragment,{children:[a.jsx(\"p\",{className:\"text-[9px] text-bambu-gray\",children:\"L / R\"}),a.jsxs(\"p\",{className:\"text-[11px] text-white\",children:[Math.round(Ue.temperatures.nozzle||0),\"° / \",Math.round(Ue.temperatures.nozzle_2||0),\"°\"]})]}):Rr?a.jsx(rie,{slot:Rr,index:0,activeStatus:!0,filamentName:Rr.filament_id?St?.[Rr.filament_id]?.name:void 0,children:a.jsxs(\"div\",{className:\"cursor-default\",children:[a.jsx(\"p\",{className:\"text-[9px] text-bambu-gray\",children:N(\"printers.temperatures.nozzle\")}),a.jsxs(\"p\",{className:\"text-[11px] text-white\",children:[Math.round(Ue.temperatures.nozzle||0),\"°C\"]})]})}):a.jsxs(a.Fragment,{children:[a.jsx(\"p\",{className:\"text-[9px] text-bambu-gray\",children:N(\"printers.temperatures.nozzle\")}),a.jsxs(\"p\",{className:\"text-[11px] text-white\",children:[Math.round(Ue.temperatures.nozzle||0),\"°C\"]})]})]}),a.jsxs(\"div\",{className:\"text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center\",children:[a.jsx(OD,{className:\"w-3.5 h-3.5 mb-0.5\",color:\"text-blue-400\",isHeating:Bt}),a.jsx(\"p\",{className:\"text-[9px] text-bambu-gray\",children:N(\"printers.temperatures.bed\")}),a.jsxs(\"p\",{className:\"text-[11px] text-white\",children:[Math.round(Ue.temperatures.bed||0),\"°C\"]})]}),Ue.temperatures.chamber!==void 0&&a.jsxs(\"div\",{className:\"text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center\",children:[a.jsx(OD,{className:\"w-3.5 h-3.5 mb-0.5\",color:\"text-green-400\",isHeating:Jt}),a.jsx(\"p\",{className:\"text-[9px] text-bambu-gray\",children:N(\"printers.temperatures.chamber\")}),a.jsxs(\"p\",{className:\"text-[11px] text-white\",children:[Math.round(Ue.temperatures.chamber||0),\"°C\"]})]}),ct&&a.jsx(T7e,{leftSlot:Tr,rightSlot:ar,activeNozzle:Un,filamentInfo:St,children:a.jsxs(\"div\",{className:\"text-center px-3 py-1.5 bg-bambu-dark rounded-lg h-full flex flex-col justify-center items-center cursor-default\",title:N(\"printers.activeNozzle\",{nozzle:N(Un===\"L\"?\"common.left\":\"common.right\")}),children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsxs(\"span\",{className:`text-[11px] font-bold ${Un===\"L\"?\"text-amber-400\":\"text-gray-500\"}`,children:[\"L\",Tr?.nozzle_diameter?` ${Tr.nozzle_diameter}`:\"\"]}),a.jsx(\"span\",{className:\"text-[9px] text-bambu-gray/40\",children:\"·\"}),a.jsxs(\"span\",{className:`text-[11px] font-bold ${Un===\"R\"?\"text-amber-400\":\"text-gray-500\"}`,children:[\"R\",ar?.nozzle_diameter?` ${ar.nozzle_diameter}`:\"\"]})]}),a.jsx(\"p\",{className:\"text-[9px] text-bambu-gray\",children:N(\"printers.temperatures.nozzle\")})]})}),Ue.nozzle_rack&&Ue.nozzle_rack.some(Da=>Da.id>=2)&&a.jsx(A7e,{slots:Ue.nozzle_rack,filamentInfo:St})]})})(),r===\"expanded\"&&Sc&&a.jsxs(\"button\",{type:\"button\",onClick:()=>As.mutate(),disabled:As.isPending||!k(\"printers:clear_plate\"),className:\"mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-500/20 border border-yellow-400/40 text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50\",title:k(\"printers:clear_plate\")?N(\"printers.plateStatus.markCleared\"):N(\"printers.permission.noControl\"),children:[As.isPending?a.jsx(ft,{className:\"w-3 h-3 animate-spin\"}):a.jsx(JW,{className:\"w-4 h-4\"}),N(\"printers.plateStatus.markCleared\")]}),r===\"expanded\"&&(()=>{const lt=Ue.state===\"RUNNING\",Bt=Ue.state===\"PAUSE\",Jt=lt||Bt,ct=ru.isPending||zp.isPending||di.isPending,Un=Ue.cooling_fan_speed,Tr=Ue.big_fan1_speed,ar=Ue.big_fan2_speed;return a.jsxs(\"div\",{className:\"mt-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-2\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:N(\"printers.controls\")}),a.jsx(\"div\",{className:\"flex-1 h-px bg-bambu-dark-tertiary/30\"})]}),a.jsxs(\"div\",{className:\"flex flex-wrap items-start justify-between gap-x-2 gap-y-2\",children:[a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-x-2 gap-y-1.5 min-w-0\",children:[a.jsxs(\"div\",{className:`flex items-center gap-1 px-1.5 py-1 rounded ${Un&&Un>0?\"bg-cyan-500/10\":\"bg-bambu-dark\"}`,title:N(\"printers.fans.partCooling\"),children:[a.jsx(RQ,{className:`w-3.5 h-3.5 ${Un&&Un>0?\"text-cyan-400\":\"text-bambu-gray/50\"}`}),a.jsxs(\"span\",{className:`text-[10px] ${Un&&Un>0?\"text-cyan-400\":\"text-bambu-gray/50\"}`,children:[Un??0,\"%\"]})]}),a.jsxs(\"div\",{className:`flex items-center gap-1 px-1.5 py-1 rounded ${Tr&&Tr>0?\"bg-blue-500/10\":\"bg-bambu-dark\"}`,title:N(\"printers.fans.auxiliary\"),children:[a.jsx(YQ,{className:`w-3.5 h-3.5 ${Tr&&Tr>0?\"text-blue-400\":\"text-bambu-gray/50\"}`}),a.jsxs(\"span\",{className:`text-[10px] ${Tr&&Tr>0?\"text-blue-400\":\"text-bambu-gray/50\"}`,children:[Tr??0,\"%\"]})]}),a.jsxs(\"div\",{className:`flex items-center gap-1 px-1.5 py-1 rounded ${ar&&ar>0?\"bg-green-500/10\":\"bg-bambu-dark\"}`,title:N(\"printers.fans.chamber\"),children:[a.jsx(mpe,{className:`w-3.5 h-3.5 ${ar&&ar>0?\"text-green-400\":\"text-bambu-gray/50\"}`}),a.jsxs(\"span\",{className:`text-[10px] ${ar&&ar>0?\"text-green-400\":\"text-bambu-gray/50\"}`,children:[ar??0,\"%\"]})]}),a.jsx(\"div\",{className:\"w-px h-5 bg-bambu-gray/30\"}),[\"P2S\",\"X2D\",\"H2D\",\"H2C\",\"H2S\"].includes(t.model??\"\")&&(()=>{const Rr=Ue.airduct_mode===1,Da=Rr?Hu:TH,rn=Rr?\"text-orange-400\":\"text-sky-400\",En=Rr?\"bg-orange-500/10 hover:bg-orange-500/20\":\"bg-sky-500/10 hover:bg-sky-500/20\";return a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"button\",{onClick:()=>G(I===t.id?null:t.id),disabled:!k(\"printers:control\"),className:`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${En} disabled:opacity-50 disabled:cursor-not-allowed`,title:N(\"printers.airduct.title\"),children:[a.jsx(Da,{className:`w-3.5 h-3.5 ${rn}`}),a.jsx(\"span\",{className:`text-[10px] ${rn}`,children:N(Rr?\"printers.airduct.heating\":\"printers.airduct.cooling\")})]}),I===t.id&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-40\",onClick:()=>G(null)}),a.jsx(\"div\",{className:\"absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg py-1 min-w-[130px]\",children:[{mode:\"cooling\",label:N(\"printers.airduct.cooling\"),modeId:0},{mode:\"heating\",label:N(\"printers.airduct.heating\"),modeId:1}].map(({mode:Na,label:ai,modeId:mi})=>a.jsxs(\"button\",{onClick:()=>{Up.mutate(Na),G(null)},className:`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2 ${Ue.airduct_mode===mi?\"text-bambu-green bg-bambu-green/10\":\"text-white hover:bg-bambu-dark-tertiary\"}`,children:[Na===\"heating\"?a.jsx(Hu,{className:\"w-3 h-3\"}):a.jsx(TH,{className:\"w-3 h-3\"}),ai]},Na))})]})]})})(),(()=>{const Da={1:\"50%\",2:\"100%\",3:\"124%\",4:\"166%\"}[Ue.speed_level]||\"100%\";return a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"button\",{onClick:()=>de(U===t.id?null:t.id),disabled:!Jt||!k(\"printers:control\"),className:`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${Jt?\"bg-amber-500/10 hover:bg-amber-500/20\":\"bg-bambu-dark cursor-not-allowed\"}`,title:Jt?N(\"printers.speed.title\"):void 0,children:[a.jsx(Zw,{className:`w-3.5 h-3.5 ${Jt?\"text-amber-400\":\"text-bambu-gray/50\"}`}),a.jsx(\"span\",{className:`text-[10px] ${Jt?\"text-amber-400\":\"text-bambu-gray/50\"}`,children:Da})]}),U===t.id&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-40\",onClick:()=>de(null)}),a.jsx(\"div\",{className:\"absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg py-1 min-w-[130px]\",children:[{mode:1,label:N(\"printers.speed.silent\")},{mode:2,label:N(\"printers.speed.standard\")},{mode:3,label:N(\"printers.speed.sport\")},{mode:4,label:N(\"printers.speed.ludicrous\")}].map(({mode:rn,label:En})=>a.jsx(\"button\",{onClick:()=>{sd.mutate(rn),de(null)},className:`w-full text-left px-3 py-1.5 text-xs transition-colors ${Ue.speed_level===rn?\"text-bambu-green bg-bambu-green/10\":\"text-white hover:bg-bambu-dark-tertiary\"}`,children:En},rn))})]})]})})(),a.jsx(\"div\",{className:\"w-px h-5 bg-bambu-gray/30\"}),(()=>{const Rr=k(\"printers:control\"),Da=Jt||!Rr,rn=En=>{const Na=En*ee*1,ai=`bambuddy.bedJog.warned.${t.id}`,mi=(()=>{try{return sessionStorage.getItem(ai)===\"1\"}catch{return!1}})();V(null),mi?Ko.mutate({distance:Na,force:!0}):he({distance:Na})};return a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"button\",{onClick:()=>V(X===t.id?null:t.id),disabled:Da,className:`flex items-center gap-1 px-1.5 py-1 rounded transition-colors ${Da?\"bg-bambu-dark cursor-not-allowed\":\"bg-indigo-500/10 hover:bg-indigo-500/20\"}`,title:N(Rr?Jt?\"printers.bedJog.disabledWhilePrinting\":\"printers.bedJog.title\":\"printers.permission.noControl\"),children:[a.jsx(Tbe,{className:`w-3.5 h-3.5 ${Da?\"text-bambu-gray/50\":\"text-indigo-400\"}`}),a.jsx(\"span\",{className:`text-[10px] ${Da?\"text-bambu-gray/50\":\"text-indigo-400\"}`,children:N(\"printers.bedJog.bed\")}),a.jsxs(\"span\",{className:`text-[10px] tabular-nums opacity-70 ${Da?\"text-bambu-gray/50\":\"text-indigo-400\"}`,children:[ee,\"mm\"]})]}),X===t.id&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-40\",onClick:()=>V(null)}),a.jsxs(\"div\",{className:\"absolute bottom-full left-0 mb-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg p-2 min-w-[140px]\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between gap-1 mb-2\",children:[a.jsx(\"button\",{onClick:()=>rn(-1),className:\"flex-1 flex items-center justify-center py-1.5 rounded bg-indigo-500/15 hover:bg-indigo-500/30 text-indigo-300\",\"aria-label\":N(\"printers.bedJog.up\"),children:a.jsx(fp,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>rn(1),className:\"flex-1 flex items-center justify-center py-1.5 rounded bg-indigo-500/15 hover:bg-indigo-500/30 text-indigo-300\",\"aria-label\":N(\"printers.bedJog.down\"),children:a.jsx(cg,{className:\"w-4 h-4\"})})]}),a.jsx(\"div\",{className:\"text-[9px] uppercase tracking-wider text-bambu-gray/70 px-1 mb-1\",children:N(\"printers.bedJog.step\")}),a.jsx(\"div\",{className:\"flex gap-1\",children:[1,10,50].map(En=>a.jsx(\"button\",{onClick:()=>se(En),className:`flex-1 px-1 py-1 rounded text-[10px] transition-colors ${ee===En?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:En},En))})]})]})]})})()]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0 max-[550px]:self-start\",children:[a.jsxs(\"button\",{onClick:()=>j(!0),disabled:!Jt||ct||!k(\"printers:control\"),className:`\n                          flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium\n                          transition-colors\n                          ${Jt&&k(\"printers:control\")?\"bg-red-500/20 text-red-400 hover:bg-red-500/30\":\"bg-bambu-dark text-bambu-gray/50 cursor-not-allowed\"}\n                        `,title:k(\"printers:control\")?N(\"printers.stop\"):N(\"printers.permission.noControl\"),children:[a.jsx(uo,{className:\"w-3 h-3\"}),N(\"printers.stop\")]}),a.jsxs(\"button\",{onClick:()=>Bt?B(!0):K(!0),disabled:!Jt||ct||!k(\"printers:control\"),className:`\n                          flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium\n                          transition-colors\n                          ${Jt&&k(\"printers:control\")?Bt?\"bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30\":\"bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30\":\"bg-bambu-dark text-bambu-gray/50 cursor-not-allowed\"}\n                        `,title:k(\"printers:control\")?N(Bt?\"printers.resume\":\"printers.pause\"):N(\"printers.permission.noControl\"),children:[Bt?a.jsx(es,{className:\"w-3 h-3\"}):a.jsx(rS,{className:\"w-3 h-3\"}),N(Bt?\"printers.resume\":\"printers.pause\")]})]})]})]})})(),(ma?.length>0||Ue.vt_tray.length>0)&&r===\"expanded\"&&(()=>{const lt=ma.filter(ct=>ct.tray.length>1),Bt=ma.filter(ct=>ct.tray.length===1),Jt=t.nozzle_count===2||Ue?.temperatures?.nozzle_2!==void 0;return a.jsxs(\"div\",{className:\"mt-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-2\",children:[a.jsx(\"span\",{className:\"text-[10px] uppercase tracking-wider text-bambu-gray font-medium\",children:N(\"printers.filaments\")}),a.jsx(\"div\",{className:\"flex-1 h-px bg-bambu-dark-tertiary/30\"})]}),a.jsxs(\"div\",{className:\"space-y-3\",children:[lt.length>0&&a.jsx(\"div\",{className:\"grid grid-cols-2 gap-3\",children:lt.map(ct=>{const Un=Ba[String(ct.id)],Tr=ct.id>=128?ct.id-128:ct.id,ar=Un!==void 0?Un:Tr,Rr=ar===1,Da=ar===0;return a.jsxs(\"div\",{className:\"p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(mK,{ams:ct,printerId:t.id,label:Ul(ct.id,ct.tray.length),amsLabels:Nn,canEdit:k(\"printers:update\"),onSaved:bn,children:a.jsx(\"span\",{className:\"text-[10px] text-white font-medium cursor-default select-none\",children:Nn?.[ct.id]||Ul(ct.id,ct.tray.length)})}),Jt&&(Rr||Da)&&a.jsx(sK,{side:Rr?\"L\":\"R\"})]}),(ct.humidity!=null||ct.temp!=null)&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 max-[550px]:flex-col max-[550px]:items-start\",children:[ct.humidity!=null&&a.jsx(lK,{humidity:ct.humidity,goodThreshold:s?.humidityGood,fairThreshold:s?.humidityFair,onClick:()=>Zt({amsId:ct.id,amsLabel:Ul(ct.id,ct.tray.length),mode:\"humidity\"}),compact:!0}),ct.temp!=null&&a.jsx(cK,{temp:ct.temp,goodThreshold:s?.tempGood,fairThreshold:s?.tempFair,onClick:()=>Zt({amsId:ct.id,amsLabel:Ul(ct.id,ct.tray.length),mode:\"temperature\"}),compact:!0}),Ue.supports_drying&&(ct.module_type===\"n3f\"||ct.module_type===\"n3s\")&&k(\"printers:control\")&&a.jsx(\"button\",{disabled:!!(ct.dry_sf_reason?.length&&ct.dry_time===0),onClick:rn=>{if(ct.dry_time>0)Ts.mutate(ct.id);else if(Me===ct.id)Oe(null);else{const Na=(ct.tray.find(Cl=>Cl.tray_type)?.tray_type||\"PLA\").split(\" \")[0].toUpperCase(),ai=b[Na]||b.PLA,mi=ct.module_type;tt(Na),Fe(ai[mi]||ai.n3f),Ve(mi===\"n3s\"?ai.n3s_hours:ai.n3f_hours),ce(!1),$e(ct.module_type),Oe(ct.id);const Xs=rn.currentTarget.getBoundingClientRect();Be({top:Xs.bottom+4,left:Math.max(8,Xs.right-240)})}},className:`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${ct.dry_time>0?\"bg-amber-500/20 text-amber-400\":ct.dry_sf_reason?.length?\"bg-bambu-dark-tertiary/30 text-bambu-gray/50 cursor-not-allowed\":\"bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\"}`,title:ct.dry_time>0?N(\"printers.drying.stop\"):ct.dry_sf_reason?.length?N(\"printers.drying.powerRequired\"):N(\"printers.drying.start\"),children:a.jsx(Hu,{className:\"w-3 h-3\"})})]})]}),ct.dry_time>0&&a.jsxs(\"div\",{className:\"flex items-center gap-2 px-2 py-1 mb-1 bg-amber-500/10 border border-amber-500/20 rounded text-[9px]\",children:[a.jsx(Hu,{className:\"w-3 h-3 text-amber-400 shrink-0\"}),a.jsx(\"span\",{className:\"text-amber-400 font-medium\",children:N(\"printers.drying.active\")}),a.jsx(\"span\",{className:\"text-amber-300/70\",children:N(\"printers.drying.timeRemaining\",{time:ct.dry_time>=60?`${Math.floor(ct.dry_time/60)}h ${ct.dry_time%60}m`:`${ct.dry_time}m`})}),a.jsx(\"button\",{onClick:()=>Ts.mutate(ct.id),disabled:Ts.isPending,className:\"ml-auto text-amber-400 hover:text-amber-300 transition-colors disabled:opacity-50\",title:N(\"printers.drying.stop\"),children:a.jsx(Ht,{className:\"w-3 h-3\"})})]}),a.jsx(\"div\",{className:\"grid grid-cols-4 gap-1.5\",children:[0,1,2,3].map(rn=>{const En=ct.tray[rn]||ct.tray.find(zn=>zn.id===rn),Na=En?.tray_type&&En.remain>=0,ai=!En?.tray_type,mi=ct.id*4+rn,Xs=Br===mi,Cl=En?.tray_info_idx?St?.[En.tray_info_idx]:null,ha=Pt?.[mi],Bi=(En?.tray_uuid||En?.tag_uid||Vu(t.serial_number,ct.id,rn))?.toUpperCase(),Pl=Bi?l?.[Bi]:void 0,Hi=tN(Pl),Ys=u?.(t.id,ct.id,rn),Pc=(()=>{const zn=Ys?.spool;return zn&&zn.label_weight>0&&zn.weight_used!=null?Math.round(Math.max(0,zn.label_weight-zn.weight_used)/zn.label_weight*100):null})(),pa=Pc===0&&Na&&En.remain>0?null:Pc,_o=Hi??pa??(Na?En.remain:null),Gy=Hi!==null?\"spoolman\":pa!==null?\"inventory\":Na?\"ams\":void 0,ya=En?.tray_type?{vendor:ID(En)?\"Bambu Lab\":\"Generic\",profile:ha?.preset_name||Cl?.name||Ys?.spool?.slicer_filament_name||En.tray_sub_brands||En.tray_type,colorName:rc(En.tray_color||\"\"),colorHex:En.tray_color||null,kFactor:LD(En.k),fillLevel:_o,trayUuid:En.tray_uuid||null,tagUid:En.tag_uid||null,fillSource:Gy}:null,ou=Yo?.amsId===ct.id&&Yo?.slotId===rn,$p=a.jsxs(\"div\",{className:`bg-bambu-dark-tertiary rounded p-1 text-center ${ai?\"opacity-50\":\"\"} ${Xs?\"ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark\":\"\"}`,children:[a.jsx(RD,{trayColor:En?.tray_color,trayType:En?.tray_type,isEmpty:ai,slotNumber:rn+1}),a.jsx(\"div\",{className:\"text-[9px] text-white font-bold truncate\",children:En?.tray_type||\"—\"}),a.jsx(\"div\",{className:\"mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden\",children:_o!==null&&_o>=0&&!ai&&En&&a.jsx(\"div\",{className:\"h-full rounded-full transition-all\",style:{width:`${_o}%`,backgroundColor:Ox(_o)}})})]});return a.jsxs(\"div\",{className:\"relative group\",children:[ou&&a.jsx(\"div\",{className:\"absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20\",children:a.jsx(lr,{className:\"w-4 h-4 text-bambu-green animate-spin\"})}),Ue?.state!==\"RUNNING\"&&a.jsx(\"button\",{onClick:zn=>{zn.stopPropagation(),_n(Sn?.amsId===ct.id&&Sn?.slotId===rn?null:{amsId:ct.id,slotId:rn})},className:\"absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary\",title:N(\"printers.slotOptions\"),children:a.jsx(Jh,{className:\"w-2.5 h-2.5 text-bambu-gray\"})}),Ue?.state!==\"RUNNING\"&&Sn?.amsId===ct.id&&Sn?.slotId===rn&&a.jsx(\"div\",{className:\"absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]\",children:a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${k(\"printers:ams_rfid\")?\"text-white hover:bg-bambu-dark-tertiary\":\"text-bambu-gray/50 cursor-not-allowed\"}`,onClick:zn=>{zn.stopPropagation(),k(\"printers:ams_rfid\")&&(xn.mutate({amsId:ct.id,slotId:rn}),_n(null))},disabled:ou||!k(\"printers:ams_rfid\"),title:k(\"printers:ams_rfid\")?void 0:N(\"printers.permission.noAmsRfid\"),children:[a.jsx(lr,{className:`w-3 h-3 ${ou?\"animate-spin\":\"\"}`}),N(\"printers.rfid.reread\")]})}),ya?a.jsx(jD,{data:ya,spoolman:{enabled:o,linkedSpoolId:Bi?l?.[Bi]?.id:void 0,spoolmanUrl:c,syncMode:d,onLinkSpool:o?()=>{const zn=(ya.trayUuid||ya.tagUid||Vu(t.serial_number,ct.id,rn)).toUpperCase();Xt({tagUid:ya.tagUid||zn,trayUuid:ya.trayUuid||\"\",printerId:t.id,amsId:ct.id,trayId:rn})}:void 0,onUnlinkSpool:Pl?.id?()=>Ps.mutate(Pl.id):void 0},inventory:o?void 0:(()=>{const zn=u?.(t.id,ct.id,rn);return{assignedSpool:zn?.spool?{id:zn.spool.id,material:zn.spool.material,brand:zn.spool.brand,color_name:zn.spool.color_name,remainingWeightGrams:Math.max(0,Math.round(zn.spool.label_weight-zn.spool.weight_used))}:null,onAssignSpool:ya.vendor!==\"Bambu Lab\"?()=>vn({printerId:t.id,amsId:ct.id,trayId:rn,trayInfo:{type:En?.tray_type||ya.profile,material:En?.tray_type??void 0,profile:ya.profile,color:ya.colorHex||\"\",location:`${Ul(ct.id,ct.tray.length)} Slot ${rn+1}`}}):void 0,onUnassignSpool:zn&&ya.vendor!==\"Bambu Lab\"?()=>m?.(t.id,ct.id,rn):void 0}})(),configureSlot:{enabled:k(\"printers:control\"),onConfigure:()=>at({amsId:ct.id,trayId:rn,trayCount:ct.tray.length,trayType:En?.tray_type||void 0,trayColor:En?.tray_color||void 0,traySubBrands:En?.tray_sub_brands||void 0,trayInfoIdx:En?.tray_info_idx||void 0,extruderId:Un,caliIdx:En?.cali_idx,savedPresetId:ha?.preset_id})},children:$p}):a.jsx(MD,{configureSlot:{enabled:k(\"printers:control\"),onConfigure:()=>at({amsId:ct.id,trayId:rn,trayCount:ct.tray.length,extruderId:Un})},inventory:o?void 0:{onAssignSpool:()=>vn({printerId:t.id,amsId:ct.id,trayId:rn,trayInfo:{type:\"\",color:\"\",location:`${Ul(ct.id,ct.tray.length)} Slot ${rn+1}`}})},children:$p})]},rn)})})]},ct.id)})}),(Bt.length>0||Ue.vt_tray.length>0)&&a.jsxs(\"div\",{className:\"grid grid-cols-4 gap-3\",children:[Bt.map(ct=>{const Un=Ba[String(ct.id)],Tr=ct.id>=128?ct.id-128:ct.id,ar=Un!==void 0?Un:Tr,Rr=ar===1,Da=ar===0,rn=ct.tray[0],En=rn?.tray_type&&rn.remain>=0,Na=!rn?.tray_type,ai=Ag(ct.id,rn?.id??0,!1),mi=Br===ai,Xs=rn?.tray_info_idx?St?.[rn.tray_info_idx]:null,Cl=Pt?.[ai],ha=rn?.id??0,Bi=(rn?.tray_uuid||rn?.tag_uid||Vu(t.serial_number,ct.id,ha))?.toUpperCase(),Pl=Bi?l?.[Bi]:void 0,Hi=tN(Pl),Ys=u?.(t.id,ct.id,ha),Pc=(()=>{const zn=Ys?.spool;return zn&&zn.label_weight>0&&zn.weight_used!=null?Math.round(Math.max(0,zn.label_weight-zn.weight_used)/zn.label_weight*100):null})(),pa=Pc===0&&En&&rn.remain>0?null:Pc,_o=Hi??pa??(En?rn.remain:null),Gy=Hi!==null?\"spoolman\":pa!==null?\"inventory\":En?\"ams\":void 0,ya=rn?.tray_type?{vendor:ID(rn)?\"Bambu Lab\":\"Generic\",profile:Cl?.preset_name||Xs?.name||Ys?.spool?.slicer_filament_name||rn.tray_sub_brands||rn.tray_type,colorName:rc(rn.tray_color||\"\"),colorHex:rn.tray_color||null,kFactor:LD(rn.k),fillLevel:_o,trayUuid:rn.tray_uuid||null,tagUid:rn.tag_uid||null,fillSource:Gy}:null,ou=Yo?.amsId===ct.id&&Yo?.slotId===ha,$p=a.jsxs(\"div\",{className:`bg-bambu-dark-tertiary rounded p-1 text-center ${Na?\"opacity-50\":\"\"} ${mi?\"ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark\":\"\"}`,children:[a.jsx(RD,{trayColor:rn?.tray_color,trayType:rn?.tray_type,isEmpty:Na,slotNumber:1}),a.jsx(\"div\",{className:\"text-[9px] text-white font-bold truncate\",children:rn?.tray_type||\"—\"}),a.jsx(\"div\",{className:\"mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden\",children:_o!==null&&_o>=0&&!Na&&a.jsx(\"div\",{className:\"h-full rounded-full transition-all\",style:{width:`${_o}%`,backgroundColor:Ox(_o)}})})]});return a.jsxs(\"div\",{className:\"p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1 mb-2\",children:[a.jsx(mK,{ams:ct,printerId:t.id,label:Ul(ct.id,ct.tray.length),amsLabels:Nn,canEdit:k(\"printers:update\"),onSaved:bn,children:a.jsx(\"span\",{className:\"text-[10px] text-white font-medium cursor-default select-none\",children:Nn?.[ct.id]||Ul(ct.id,ct.tray.length)})}),Jt&&(Rr||Da)&&a.jsx(sK,{side:Rr?\"L\":\"R\"}),Ue.supports_drying&&(ct.module_type===\"n3f\"||ct.module_type===\"n3s\")&&k(\"printers:control\")&&a.jsx(\"div\",{className:\"relative ml-auto\",children:a.jsx(\"button\",{onClick:zn=>{if(ct.dry_time>0)Ts.mutate(ct.id);else if(Me===ct.id)Oe(null);else{const Um=(ct.tray.find($S=>$S.tray_type)?.tray_type||\"PLA\").split(\" \")[0].toUpperCase(),Vp=b[Um]||b.PLA,Wy=ct.module_type;tt(Um),Fe(Vp[Wy]||Vp.n3f),Ve(Wy===\"n3s\"?Vp.n3s_hours:Vp.n3f_hours),ce(!1),$e(ct.module_type),Oe(ct.id);const Ky=zn.currentTarget.getBoundingClientRect();Be({top:Ky.bottom+4,left:Math.max(8,Ky.right-240)})}},className:`flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] transition-colors ${ct.dry_time>0?\"bg-amber-500/20 text-amber-400\":\"bg-bambu-dark-tertiary/50 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\"}`,title:ct.dry_time>0?N(\"printers.drying.stop\"):N(\"printers.drying.start\"),children:a.jsx(Hu,{className:\"w-3 h-3\"})})})]}),ct.dry_time>0&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 px-2 py-1 mb-1 bg-amber-500/10 border border-amber-500/20 rounded text-[9px] whitespace-nowrap overflow-hidden\",children:[a.jsx(Hu,{className:\"w-3 h-3 text-amber-400 shrink-0\"}),a.jsx(\"span\",{className:\"text-amber-300/70 text-[8px] truncate\",children:ct.dry_time>=60?`${Math.floor(ct.dry_time/60)}h ${ct.dry_time%60}m`:`${ct.dry_time}m`}),a.jsx(\"button\",{onClick:()=>Ts.mutate(ct.id),disabled:Ts.isPending,className:\"ml-auto text-amber-400 hover:text-amber-300 transition-colors disabled:opacity-50 shrink-0\",title:N(\"printers.drying.stop\"),children:a.jsx(Ht,{className:\"w-3 h-3\"})})]}),a.jsxs(\"div\",{className:\"flex gap-1.5 max-[550px]:flex-col max-[550px]:items-start\",children:[a.jsxs(\"div\",{className:\"relative group flex-1 max-[550px]:w-full\",children:[ou&&a.jsx(\"div\",{className:\"absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20\",children:a.jsx(lr,{className:\"w-4 h-4 text-bambu-green animate-spin\"})}),Ue?.state!==\"RUNNING\"&&a.jsx(\"button\",{onClick:zn=>{zn.stopPropagation(),_n(Sn?.amsId===ct.id&&Sn?.slotId===ha?null:{amsId:ct.id,slotId:ha})},className:\"absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary\",title:N(\"printers.slotOptions\"),children:a.jsx(Jh,{className:\"w-2.5 h-2.5 text-bambu-gray\"})}),Ue?.state!==\"RUNNING\"&&Sn?.amsId===ct.id&&Sn?.slotId===ha&&a.jsx(\"div\",{className:\"absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]\",children:a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${k(\"printers:ams_rfid\")?\"text-white hover:bg-bambu-dark-tertiary\":\"text-bambu-gray/50 cursor-not-allowed\"}`,onClick:zn=>{zn.stopPropagation(),k(\"printers:ams_rfid\")&&(xn.mutate({amsId:ct.id,slotId:ha}),_n(null))},disabled:ou||!k(\"printers:ams_rfid\"),title:k(\"printers:ams_rfid\")?void 0:N(\"printers.permission.noAmsRfid\"),children:[a.jsx(lr,{className:`w-3 h-3 ${ou?\"animate-spin\":\"\"}`}),N(\"printers.rfid.reread\")]})}),ya?a.jsx(jD,{data:ya,spoolman:{enabled:o,linkedSpoolId:Bi?l?.[Bi]?.id:void 0,spoolmanUrl:c,syncMode:d,onLinkSpool:o?()=>{const zn=(ya.trayUuid||ya.tagUid||Vu(t.serial_number,ct.id,ha)).toUpperCase();Xt({tagUid:ya.tagUid||zn,trayUuid:ya.trayUuid||\"\",printerId:t.id,amsId:ct.id,trayId:ha})}:void 0,onUnlinkSpool:Pl?.id?()=>Ps.mutate(Pl.id):void 0},inventory:o?void 0:(()=>{const zn=u?.(t.id,ct.id,ha);return{assignedSpool:zn?.spool?{id:zn.spool.id,material:zn.spool.material,brand:zn.spool.brand,color_name:zn.spool.color_name,remainingWeightGrams:Math.max(0,Math.round(zn.spool.label_weight-zn.spool.weight_used))}:null,onAssignSpool:ya.vendor!==\"Bambu Lab\"?()=>vn({printerId:t.id,amsId:ct.id,trayId:ha,trayInfo:{type:rn?.tray_type||ya.profile,material:rn?.tray_type??void 0,profile:ya.profile,color:ya.colorHex||\"\",location:Ul(ct.id,ct.tray.length)}}):void 0,onUnassignSpool:zn&&ya.vendor!==\"Bambu Lab\"?()=>m?.(t.id,ct.id,ha):void 0}})(),configureSlot:{enabled:k(\"printers:control\"),onConfigure:()=>at({amsId:ct.id,trayId:ha,trayCount:ct.tray.length,trayType:rn?.tray_type||void 0,trayColor:rn?.tray_color||void 0,traySubBrands:rn?.tray_sub_brands||void 0,trayInfoIdx:rn?.tray_info_idx||void 0,extruderId:Un,caliIdx:rn?.cali_idx,savedPresetId:Cl?.preset_id})},children:$p}):a.jsx(MD,{configureSlot:{enabled:k(\"printers:control\"),onConfigure:()=>at({amsId:ct.id,trayId:ha,trayCount:ct.tray.length,extruderId:Un})},inventory:o?void 0:{onAssignSpool:()=>vn({printerId:t.id,amsId:ct.id,trayId:ha,trayInfo:{type:\"\",color:\"\",location:Ul(ct.id,ct.tray.length)}})},children:$p})]}),(ct.humidity!=null||ct.temp!=null)&&a.jsxs(\"div\",{className:\"flex flex-col justify-center gap-1 shrink-0 max-[550px]:w-full\",children:[ct.temp!=null&&a.jsx(cK,{temp:ct.temp,goodThreshold:s?.tempGood,fairThreshold:s?.tempFair,onClick:()=>Zt({amsId:ct.id,amsLabel:Ul(ct.id,ct.tray.length),mode:\"temperature\"}),compact:!0}),ct.humidity!=null&&a.jsx(lK,{humidity:ct.humidity,goodThreshold:s?.humidityGood,fairThreshold:s?.humidityFair,onClick:()=>Zt({amsId:ct.id,amsLabel:Ul(ct.id,ct.tray.length),mode:\"humidity\"}),compact:!0})]})]})]},ct.id)}),Ue.vt_tray.length>0&&a.jsxs(\"div\",{className:`p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30 ${Ue.vt_tray.length===1?\"max-w-[50%]\":\"\"}`,children:[a.jsx(\"div\",{className:\"flex items-center gap-1 mb-2\",children:a.jsx(\"span\",{className:\"text-[10px] text-white font-medium\",children:N(\"printers.external\")})}),a.jsx(\"div\",{className:`grid ${Ue.vt_tray.length>1?\"grid-cols-2\":\"grid-cols-1\"} gap-1.5`,children:[...Ue.vt_tray].sort((ct,Un)=>(ct.id??254)-(Un.id??254)).map(ct=>{const Un=ct.id??254,Tr=Jt&&Br===254?Un===254&&Ue.active_extruder===1||Un===255&&Ue.active_extruder===0:Br===Un,ar=Un-254,Rr=Jt?N(Un===254?\"printers.extL\":\"printers.extR\"):\"\",Da=ct.tray_info_idx?St?.[ct.tray_info_idx]:null,rn=Pt?.[1020+ar],En=(ct.tray_uuid||ct.tag_uid||Vu(t.serial_number,255,ar))?.toUpperCase(),Na=En?l?.[En]:void 0,ai=tN(Na),mi=u?.(t.id,255,ar),Xs=(()=>{const pa=mi?.spool;return pa&&pa.label_weight>0&&pa.weight_used!=null?Math.round(Math.max(0,pa.label_weight-pa.weight_used)/pa.label_weight*100):null})(),Cl=ct.tray_type&&ct.remain>=0,ha=Xs===0&&Cl&&ct.remain>0?null:Xs,Bi=ai??ha??(Cl?ct.remain:null),Pl=ai!==null?\"spoolman\":ha!==null?\"inventory\":Cl?\"ams\":void 0,Hi={vendor:ID(ct)?\"Bambu Lab\":\"Generic\",profile:rn?.preset_name||Da?.name||mi?.spool?.slicer_filament_name||ct.tray_sub_brands||ct.tray_type||\"Unknown\",colorName:rc(ct.tray_color||\"\"),colorHex:ct.tray_color||null,kFactor:LD(ct.k),fillLevel:Bi,trayUuid:ct.tray_uuid||null,tagUid:ct.tag_uid||null,fillSource:Pl},Ys=!ct.tray_type,Pc=a.jsxs(\"div\",{className:`bg-bambu-dark-tertiary rounded p-1 text-center ${Ys?\"opacity-50\":\"\"} ${Tr?\"ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark\":\"\"}`,children:[a.jsx(RD,{trayColor:ct.tray_color,trayType:ct.tray_type,isEmpty:Ys,slotNumber:ar+1}),a.jsx(\"div\",{className:`text-[9px] font-bold truncate ${Ys?\"text-white/40\":\"text-white\"}`,children:ct.tray_type||\"—\"}),a.jsx(\"div\",{className:\"mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden\",children:Bi!==null&&Bi>=0&&!Ys&&a.jsx(\"div\",{className:\"h-full rounded-full transition-all\",style:{width:`${Bi}%`,backgroundColor:Ox(Bi)}})}),Rr&&a.jsx(\"div\",{className:\"text-[7px] text-white/40 mt-0.5 truncate\",children:Rr})]});return a.jsx(\"div\",{className:\"relative group\",children:Ys?a.jsx(MD,{configureSlot:{enabled:k(\"printers:control\"),onConfigure:()=>at({amsId:255,trayId:ar,trayCount:1,extruderId:Jt?Un===254?1:0:void 0})},inventory:o?void 0:{onAssignSpool:()=>vn({printerId:t.id,amsId:255,trayId:ar,trayInfo:{type:\"\",color:\"\",location:Rr||N(\"printers.external\")}})},children:Pc}):a.jsx(jD,{data:Hi,spoolman:{enabled:o,linkedSpoolId:En?l?.[En]?.id:void 0,spoolmanUrl:c,syncMode:d,onLinkSpool:o?()=>{const pa=(Hi.trayUuid||Hi.tagUid||Vu(t.serial_number,255,ar)).toUpperCase();Xt({tagUid:Hi.tagUid||pa,trayUuid:Hi.trayUuid||\"\",printerId:t.id,amsId:255,trayId:ar})}:void 0,onUnlinkSpool:Na?.id?()=>Ps.mutate(Na.id):void 0},inventory:o?void 0:(()=>{const pa=u?.(t.id,255,ar);return{assignedSpool:pa?.spool?{id:pa.spool.id,material:pa.spool.material,brand:pa.spool.brand,color_name:pa.spool.color_name,remainingWeightGrams:Math.max(0,Math.round(pa.spool.label_weight-pa.spool.weight_used))}:null,onAssignSpool:()=>vn({printerId:t.id,amsId:255,trayId:ar,trayInfo:{type:ct.tray_type||Hi.profile,material:ct.tray_type??void 0,profile:Hi.profile,color:Hi.colorHex||\"\",location:Rr||N(\"printers.external\")}}),onUnassignSpool:pa?()=>m?.(t.id,255,ar):void 0}})(),configureSlot:{enabled:k(\"printers:control\"),onConfigure:()=>at({amsId:255,trayId:ar,trayCount:1,trayType:ct.tray_type||void 0,trayColor:ct.tray_color||void 0,traySubBrands:ct.tray_sub_brands||void 0,trayInfoIdx:ct.tray_info_idx||void 0,extruderId:Jt?Un===254?1:0:void 0,caliIdx:ct.cali_idx,savedPresetId:rn?.preset_id})},children:Pc})},Un)})})]})]})]})]})})()]}),cr&&r===\"expanded\"&&a.jsxs(\"div\",{className:\"mt-4 pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 min-w-0\",children:[a.jsx(dc,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"}),a.jsx(\"span\",{className:\"text-sm text-white truncate\",children:cr.name}),Ui&&a.jsxs(\"span\",{className:`text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${Ui.state===\"ON\"?\"bg-bambu-green/20 text-bambu-green\":Ui.state===\"OFF\"?\"bg-red-500/20 text-red-400\":\"bg-bambu-gray/20 text-bambu-gray\"}`,children:[Ui.state||\"?\",Ui.state===\"ON\"&&Ui.energy?.power!=null&&a.jsxs(\"span\",{className:\"text-yellow-400 ml-1.5\",children:[\"· \",Ui.energy.power,\"W\"]})]})]}),a.jsx(\"div\",{className:\"flex-1\"}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsxs(\"button\",{onClick:()=>me(!0),disabled:Wo.isPending||Ui?.state===\"ON\"||!k(\"smart_plugs:control\"),className:`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${k(\"smart_plugs:control\")?Ui?.state===\"ON\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\":\"bg-bambu-dark text-bambu-gray/50 cursor-not-allowed\"}`,title:k(\"smart_plugs:control\")?void 0:N(\"printers.permission.noSmartPlugControl\"),children:[a.jsx(Yd,{className:\"w-3 h-3\"}),\"On\"]}),a.jsxs(\"button\",{onClick:()=>Ce(!0),disabled:Wo.isPending||Ui?.state===\"OFF\"||!k(\"smart_plugs:control\"),className:`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${k(\"smart_plugs:control\")?Ui?.state===\"OFF\"?\"bg-red-500/30 text-red-400\":\"bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\":\"bg-bambu-dark text-bambu-gray/50 cursor-not-allowed\"}`,title:k(\"smart_plugs:control\")?void 0:N(\"printers.permission.noSmartPlugControl\"),children:[a.jsx(aS,{className:\"w-3 h-3\"}),\"Off\"]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0\",children:[a.jsx(\"span\",{className:`text-xs hidden sm:inline ${cr.auto_off_executed?\"text-bambu-green\":\"text-bambu-gray\"}`,children:cr.auto_off_executed?\"Auto-off done\":\"Auto-off\"}),a.jsx(\"button\",{onClick:()=>wo.mutate(!cr.auto_off),disabled:wo.isPending||cr.auto_off_executed||!k(\"smart_plugs:control\"),title:k(\"smart_plugs:control\")?cr.auto_off_executed?N(\"printers.autoOffExecuted\"):N(\"printers.autoOffAfterPrint\"):N(\"printers.permission.noSmartPlugControl\"),className:`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${k(\"smart_plugs:control\")?cr.auto_off_executed?\"bg-bambu-green/50 cursor-not-allowed\":cr.auto_off?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\":\"bg-bambu-dark-tertiary/50 cursor-not-allowed\"}`,children:a.jsx(\"span\",{className:`absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full transition-transform ${cr.auto_off||cr.auto_off_executed?\"translate-x-4\":\"translate-x-0\"}`})})]})]}),Ci&&Ci.length>0&&a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50\",children:[a.jsx(Jw,{className:\"w-3.5 h-3.5 text-blue-400 flex-shrink-0\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"HA:\"}),a.jsx(\"div\",{className:\"flex flex-wrap gap-1\",children:Ci.map(lt=>{const Bt=lt.ha_entity_id?.startsWith(\"script.\");return a.jsxs(\"button\",{onClick:()=>_c.mutate({id:lt.id,action:Bt?\"on\":\"toggle\"}),disabled:_c.isPending,title:`${Bt?\"Run\":\"Toggle\"} ${lt.ha_entity_id}`,className:\"px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1\",children:[a.jsx(es,{className:\"w-2.5 h-2.5\"}),lt.name]},lt.id)})})]})]}),r===\"expanded\"&&a.jsxs(\"div\",{className:\"mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-end gap-2 flex-wrap\",children:[a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>au.mutate(!Ue?.chamber_light),disabled:!Ue?.connected||au.isPending||!k(\"printers:control\"),title:k(\"printers:control\")?Ue?.chamber_light?N(\"printers.chamberLightOff\"):N(\"printers.chamberLightOn\"):N(\"printers.permission.noControl\"),className:Ue?.chamber_light?\"!border-yellow-500 !text-yellow-400 hover:!bg-yellow-500/20\":\"\",children:a.jsx(xI,{on:Ue?.chamber_light??!1,className:`w-4 h-4 ${Ue?.chamber_light?\"text-yellow-400\":\"\"}`})}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>{if(f===\"embedded\"&&y)y(t.id,t.name);else{const lt=localStorage.getItem(\"cameraWindowState\"),Bt=lt?JSON.parse(lt):{width:640,height:400},Jt=[`width=${Bt.width}`,`height=${Bt.height}`,Bt.left!==void 0?`left=${Bt.left}`:\"\",Bt.top!==void 0?`top=${Bt.top}`:\"\",\"menubar=no,toolbar=no,location=no,status=no\"].filter(Boolean).join(\",\");window.open(`/camera/${t.id}`,`camera-${t.id}`,Jt)}},disabled:!Ue?.connected||!k(\"camera:view\"),title:k(\"camera:view\")?N(f===\"embedded\"?\"printers.openCameraOverlay\":\"printers.openCameraWindow\"):N(\"printers.permission.noCamera\"),children:a.jsx(OO,{className:\"w-4 h-4\"})}),a.jsxs(\"div\",{className:`inline-flex rounded-md ${t.plate_detection_enabled?\"ring-1 ring-green-500\":\"\"}`,children:[a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:qy,disabled:!Ue?.connected||So.isPending||!k(\"printers:update\"),title:k(\"printers:update\")?t.plate_detection_enabled?N(\"printers.plateDetection.enabledClick\"):N(\"printers.plateDetection.disabledClick\"):N(\"printers.plateDetection.noPermission\"),className:`!rounded-r-none !border-r-0 ${t.plate_detection_enabled?\"!border-green-500 !text-green-400 hover:!bg-green-500/20\":\"\"}`,children:So.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(NN,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:Hp,disabled:!Ue?.connected||mt||!k(\"printers:update\"),title:k(\"printers:update\")?N(\"printers.plateDetection.manageCalibration\"):N(\"printers.plateDetection.noPermission\"),className:`!rounded-l-none !px-1.5 ${t.plate_detection_enabled?\"!border-green-500 !text-green-400 hover:!bg-green-500/20\":\"\"}`,children:mt?a.jsx(ft,{className:\"w-3 h-3 animate-spin\"}):a.jsx(On,{className:\"w-3 h-3\"})})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>fe(!0),disabled:!Nr||!k(\"printers:files\"),title:k(\"printers:files\")?N(\"printers.browseFiles\"):N(\"printers.permission.noFiles\"),children:[a.jsx(Ig,{className:\"w-4 h-4\"}),N(\"printers.files\")]}),Nr&&Ue?.state!==\"RUNNING\"&&Ue?.state!==\"PAUSE\"&&a.jsxs(De,{size:\"sm\",onClick:()=>Se(!0),disabled:!k(\"printers:control\"),title:k(\"printers:control\")?N(\"common.print\"):N(\"printers.permission.noControl\"),className:\"!bg-bambu-green hover:!bg-bambu-green/80 !text-white\",children:[a.jsx(Er,{className:\"w-4 h-4\"}),N(\"common.print\")]})]})]}),oe&&a.jsx(qMe,{printerId:t.id,printerName:t.name,onClose:()=>fe(!1)}),_e&&a.jsx(Yae,{folderId:null,onClose:()=>Se(!1),onUploadComplete:()=>{},autoUpload:!0,accept:\".gcode,.3mf\",validateFile:lt=>{const Bt=lt.name.toLowerCase();if(!Bt.endsWith(\".gcode\")&&!Bt.includes(\".gcode.\"))return N(\"printers.dropNotPrintable\",\"Only .gcode and .gcode.3mf files can be printed\")},onFileUploaded:lt=>{const Bt=lt.metadata?.sliced_for_model,Jt=O0(t.model);if(Bt&&Jt&&Bt.toLowerCase()!==Jt.toLowerCase())return ue.deleteLibraryFile(lt.id).catch(()=>{}),N(\"printers.incompatibleFile\",\"This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}\",{slicedFor:Bt,printerModel:Jt});Le({id:lt.id,filename:lt.filename})}}),je&&a.jsx(ic,{mode:\"reprint\",libraryFileId:je.id,archiveName:je.filename,initialSelectedPrinterIds:[t.id],onClose:()=>Le(null),onSuccess:()=>Le(null),cleanupLibraryAfterDispatch:!0}),re&&a.jsx(XMe,{printerId:t.id,printerName:t.name,onClose:()=>W(!1)}),ve&&a.jsx(C7e,{printer:t,status:Ue,totalPrintHours:n?.total_print_hours,onClose:ye}),At&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",onClick:()=>ld(),children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-w-lg w-full\",onClick:lt=>lt.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[At.needs_calibration?a.jsx(NN,{className:\"w-5 h-5 text-blue-500\"}):At.is_empty?a.jsx(yr,{className:\"w-5 h-5 text-green-500\"}):a.jsx(Ma,{className:\"w-5 h-5 text-yellow-500\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:\"Build Plate Check\"}),At.reference_count!==void 0&&At.max_references&&a.jsxs(\"span\",{className:\"text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded\",children:[At.reference_count,\"/\",At.max_references,\" refs\"]})]}),a.jsx(\"button\",{onClick:()=>ld(),className:\"p-1 text-bambu-gray hover:text-white rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-4\",children:[At.needs_calibration?a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"p-3 rounded-lg bg-blue-500/20 border border-blue-500/50\",children:[a.jsx(\"p\",{className:\"font-medium text-blue-400\",children:N(\"printers.plateDetection.calibrationRequired\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",dangerouslySetInnerHTML:{__html:N(\"printers.plateDetection.calibrationInstructions\")}})]}),a.jsxs(\"div\",{className:\"text-sm text-bambu-gray space-y-2\",children:[a.jsx(\"p\",{children:N(\"printers.plateDetection.calibrationDescription\")}),a.jsx(\"p\",{dangerouslySetInnerHTML:{__html:N(\"printers.plateDetection.calibrationTip\")}})]})]}):a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:`p-3 rounded-lg ${At.is_empty?\"bg-green-500/20 border border-green-500/50\":\"bg-yellow-500/20 border border-yellow-500/50\"}`,children:[a.jsx(\"p\",{className:`font-medium ${At.is_empty?\"text-green-400\":\"text-yellow-400\"}`,children:At.is_empty?N(\"printers.plateDetection.plateEmpty\"):N(\"printers.plateDetection.objectsDetected\")}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:[N(\"printers.plateDetection.confidence\"),\": \",Math.round(At.confidence*100),\"% | \",N(\"printers.plateDetection.difference\"),\": \",At.difference_percent.toFixed(1),\"%\"]})]}),At.debug_image_url&&a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-2\",children:N(\"printers.plateDetection.analysisPreview\")}),a.jsx(\"img\",{src:At.debug_image_url,alt:N(\"printers.plateDetection.analysisPreview\"),className:\"w-full rounded-lg border border-bambu-dark-tertiary\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-2\",children:N(\"printers.plateDetection.analysisLegend\")})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:At.message})]}),kc&&kc.references.length>0&&a.jsxs(\"div\",{className:\"mt-4 pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-sm font-medium text-white mb-2\",children:N(\"printers.plateDetection.savedReferences\",{count:kc.references.length,max:kc.max_references})}),a.jsx(\"div\",{className:\"grid grid-cols-5 gap-2\",children:kc.references.map(lt=>a.jsxs(\"div\",{className:\"relative group\",children:[a.jsx(\"img\",{src:ue.getPlateReferenceThumbnailUrl(t.id,lt.index),alt:lt.label||`Reference ${lt.index+1}`,className:\"w-full aspect-video object-cover rounded border border-bambu-dark-tertiary\"}),a.jsx(\"button\",{onClick:()=>$y(lt.index),className:\"absolute top-1 right-1 p-0.5 bg-red-500/80 rounded opacity-0 group-hover:opacity-100 transition-opacity\",title:N(\"printers.plateDetection.deleteReference\"),children:a.jsx(Ht,{className:\"w-3 h-3 text-white\"})}),od?.index===lt.index?a.jsx(\"input\",{type:\"text\",value:od.label,onChange:Bt=>Nc({...od,label:Bt.target.value}),onBlur:()=>Im(lt.index,od.label),onKeyDown:Bt=>{Bt.key===\"Enter\"&&Im(lt.index,od.label),Bt.key===\"Escape\"&&Nc(null)},className:\"w-full mt-1 px-1 py-0.5 text-xs bg-bambu-dark-tertiary border border-bambu-green rounded text-white\",autoFocus:!0,placeholder:N(\"printers.plateDetection.labelPlaceholder\")}):a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1 truncate cursor-pointer hover:text-white\",onClick:()=>Nc({index:lt.index,label:lt.label}),title:lt.label?N(\"printers.plateDetection.clickToEdit\",{label:lt.label}):N(\"printers.plateDetection.clickToAddLabel\"),children:lt.label||a.jsx(\"span\",{className:\"italic opacity-50\",children:N(\"printers.noLabel\")})}),a.jsx(\"p\",{className:\"text-[10px] text-bambu-gray/60\",children:lt.timestamp?Zn(lt.timestamp)?.toLocaleDateString()??\"\":\"\"})]},lt.index))})]}),!At.needs_calibration&&a.jsxs(\"div\",{className:\"mt-4 pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"p\",{className:\"text-sm font-medium text-white\",children:N(\"printers.roi.title\")}),ot?a.jsxs(\"div\",{className:\"flex gap-1\",children:[a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>Pe(null),disabled:Ge,children:N(\"common.cancel\")}),a.jsx(De,{size:\"sm\",onClick:ke,disabled:Ge,children:Ge?a.jsx(ft,{className:\"w-3 h-3 animate-spin\"}):N(\"common.save\")})]}):a.jsxs(De,{variant:\"ghost\",size:\"sm\",onClick:()=>Pe(At.roi||{x:.15,y:.35,w:.7,h:.55}),children:[a.jsx(ci,{className:\"w-3 h-3 mr-1\"}),N(\"common.edit\")]})]}),ot?a.jsxs(\"div\",{className:\"space-y-3 bg-bambu-dark-tertiary/50 p-3 rounded-lg\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray\",children:N(\"printers.roi.xStart\")}),a.jsx(\"input\",{type:\"range\",min:\"0\",max:\"0.9\",step:\"0.01\",value:ot.x,onChange:lt=>Pe({...ot,x:parseFloat(lt.target.value)}),className:\"w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500\"}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[Math.round(ot.x*100),\"%\"]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray\",children:N(\"printers.roi.yStart\")}),a.jsx(\"input\",{type:\"range\",min:\"0\",max:\"0.9\",step:\"0.01\",value:ot.y,onChange:lt=>Pe({...ot,y:parseFloat(lt.target.value)}),className:\"w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500\"}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[Math.round(ot.y*100),\"%\"]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray\",children:N(\"printers.width\")}),a.jsx(\"input\",{type:\"range\",min:\"0.1\",max:\"1\",step:\"0.01\",value:ot.w,onChange:lt=>Pe({...ot,w:parseFloat(lt.target.value)}),className:\"w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500\"}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[Math.round(ot.w*100),\"%\"]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray\",children:N(\"printers.height\")}),a.jsx(\"input\",{type:\"range\",min:\"0.1\",max:\"1\",step:\"0.01\",value:ot.h,onChange:lt=>Pe({...ot,h:parseFloat(lt.target.value)}),className:\"w-full h-1.5 bg-bambu-dark-tertiary rounded-lg cursor-pointer accent-green-500\"}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[Math.round(ot.h*100),\"%\"]})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:N(\"printers.roi.instruction\")})]}):a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[\"Current: X=\",Math.round((At.roi?.x||.15)*100),\"%, Y=\",Math.round((At.roi?.y||.35)*100),\"%, W=\",Math.round((At.roi?.w||.7)*100),\"%, H=\",Math.round((At.roi?.h||.55)*100),\"%\"]})]})]}),a.jsx(\"div\",{className:\"flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary\",children:At.needs_calibration?a.jsxs(a.Fragment,{children:[a.jsx(De,{variant:\"ghost\",onClick:()=>ld(),children:N(\"common.cancel\")}),a.jsx(De,{onClick:()=>Cc(),disabled:nt,children:nt?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 mr-2 animate-spin\"}),\"Calibrating...\"]}):\"Calibrate Empty Plate\"})]}):a.jsxs(a.Fragment,{children:[a.jsx(De,{variant:\"ghost\",onClick:()=>Cc(),disabled:nt,children:nt?\"Adding...\":`Add Reference (${kc?.references.length||0}/${kc?.max_references||5})`}),a.jsx(De,{onClick:()=>ld(),children:\"Close\"})]})})]})}),ne&&cr&&a.jsx(yn,{title:N(\"printers.confirm.powerOnTitle\"),message:N(\"printers.confirm.powerOnMessage\",{name:t.name}),confirmText:N(\"printers.confirm.powerOnButton\"),variant:\"default\",onConfirm:()=>{Wo.mutate(\"on\"),me(!1)},onCancel:()=>me(!1)}),be&&cr&&a.jsx(yn,{title:N(\"printers.confirm.powerOffTitle\"),message:Ue?.state===\"RUNNING\"?N(\"printers.confirm.powerOffWarning\",{name:t.name}):N(\"printers.confirm.powerOffMessage\",{name:t.name}),confirmText:N(\"printers.confirm.powerOffButton\"),variant:\"danger\",onConfirm:()=>{Wo.mutate(\"off\"),Ce(!1)},onCancel:()=>Ce(!1)}),E&&a.jsx(yn,{title:N(\"printers.confirm.stopTitle\"),message:N(\"printers.confirm.stopMessage\",{name:t.name}),confirmText:N(\"printers.confirm.stopButton\"),variant:\"danger\",onConfirm:()=>{ru.mutate(),j(!1)},onCancel:()=>j(!1)}),O&&a.jsx(yn,{title:N(\"printers.confirm.pauseTitle\"),message:N(\"printers.confirm.pauseMessage\",{name:t.name}),confirmText:N(\"printers.confirm.pauseButton\"),variant:\"default\",onConfirm:()=>{zp.mutate(),K(!1)},onCancel:()=>K(!1)}),le&&a.jsx(yn,{title:N(\"printers.confirm.resumeTitle\"),message:N(\"printers.confirm.resumeMessage\",{name:t.name}),confirmText:N(\"printers.confirm.resumeButton\"),variant:\"default\",onConfirm:()=>{di.mutate(),B(!1)},onCancel:()=>B(!1)}),ge&&a.jsx(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl w-full max-w-sm p-5\",children:[a.jsxs(\"div\",{className:\"flex items-start gap-3 mb-4\",children:[a.jsx(Dn,{className:\"w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5\"}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-white mb-1\",children:N(\"printers.bedJog.notHomedTitle\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray leading-relaxed\",children:N(\"printers.bedJog.notHomedMessage\")})]})]}),a.jsxs(\"div\",{className:\"flex flex-col gap-2\",children:[a.jsx(\"button\",{onClick:()=>{iu.mutate(\"all\"),he(null)},className:\"w-full px-3 py-2 rounded-lg text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors\",children:N(\"printers.bedJog.homeZ\")}),a.jsx(\"button\",{onClick:()=>{const lt=ge.distance;try{sessionStorage.setItem(`bambuddy.bedJog.warned.${t.id}`,\"1\")}catch{}Ko.mutate({distance:lt,force:!0}),he(null)},className:\"w-full px-3 py-2 rounded-lg text-xs font-medium bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition-colors\",children:N(\"printers.bedJog.moveAnyway\")}),a.jsx(\"button\",{onClick:()=>he(null),className:\"w-full px-3 py-2 rounded-lg text-xs font-medium bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary transition-colors\",children:N(\"common.cancel\")})]})]})}),a.jsx(yI,{printerId:t.id,isOpen:R,onClose:()=>ae(!1)}),q&&a.jsx(Vye,{printerName:t.name,errors:Ue?.hms_errors||[],onClose:()=>Y(!1),printerId:t.id,hasPermission:k}),Wt&&a.jsx(i7e,{isOpen:!!Wt,onClose:()=>Zt(null),printerId:t.id,printerName:t.name,amsId:Wt.amsId,amsLabel:Wt.amsLabel,initialMode:Wt.mode,thresholds:s}),Kt&&a.jsx(Wae,{isOpen:!!Kt,onClose:()=>Xt(null),tagUid:Kt.tagUid,trayUuid:Kt.trayUuid,printerId:Kt.printerId,amsId:Kt.amsId,trayId:Kt.trayId}),ln&&a.jsx(Kae,{isOpen:!!ln,onClose:()=>vn(null),printerId:ln.printerId,amsId:ln.amsId,trayId:ln.trayId,trayInfo:ln.trayInfo}),Ke&&a.jsx(Xae,{isOpen:!!Ke,onClose:()=>at(null),printerId:t.id,slotInfo:Ke,printerModel:O0(t.model)||void 0,onSuccess:()=>{A.invalidateQueries({queryKey:[\"slotPresets\",t.id]}),A.invalidateQueries({queryKey:[\"printerStatus\",t.id]})}}),ie&&a.jsx(U7e,{printer:t,onClose:()=>J(!1)}),Lt&&et&&a.jsx(z7e,{printer:t,firmwareInfo:et,onClose:()=>Et(!1)}),Sn&&a.jsx(\"div\",{className:\"fixed inset-0 z-40\",onClick:()=>_n(null)}),Me!==null&&xe&&(()=>{const lt=Re===\"n3s\"?85:65,Bt=35,Jt=lt+10;return a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-[100]\",onClick:()=>Oe(null)}),a.jsxs(\"div\",{className:\"fixed z-[101] w-[240px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl overflow-hidden\",style:{top:xe.top,left:xe.left},onClick:ct=>ct.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 px-3 py-2.5 border-b border-bambu-dark-tertiary\",children:[a.jsx(Hu,{className:\"w-3.5 h-3.5 text-amber-400\"}),a.jsx(\"span\",{className:\"text-xs text-white font-medium\",children:N(\"printers.drying.start\")})]}),a.jsxs(\"div\",{className:\"px-3 py-2.5 space-y-2.5\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-[10px] text-bambu-gray mb-1 block\",children:N(\"printers.filaments\")}),a.jsx(\"select\",{value:Ye,onChange:ct=>{const Un=ct.target.value;tt(Un);const Tr=b[Un];Tr&&(Fe(Tr[Re===\"n3s\"?\"n3s\":\"n3f\"]),Ve(Re===\"n3s\"?Tr.n3s_hours:Tr.n3f_hours))},className:\"w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs focus:outline-none focus:border-amber-500/50\",children:Object.keys(b).map(ct=>a.jsx(\"option\",{value:ct,children:ct},ct))})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-1\",children:[a.jsx(\"label\",{className:\"text-[10px] text-bambu-gray\",children:N(\"printers.drying.temperature\")}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"input\",{type:\"number\",min:45,max:lt,value:pe,onChange:ct=>Fe(Math.min(lt,Math.max(45,Number(ct.target.value)||45))),className:\"w-12 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"}),a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:\"°C\"})]})]}),a.jsx(\"input\",{type:\"range\",min:Bt,max:Jt,value:pe,onChange:ct=>Fe(Math.min(lt,Math.max(45,Number(ct.target.value)))),className:\"w-full h-1 accent-amber-500 cursor-pointer\"}),a.jsxs(\"div\",{className:\"flex justify-between text-[9px] text-bambu-gray/50 mt-0.5\",children:[a.jsx(\"span\",{children:\"45°C\"}),a.jsxs(\"span\",{children:[lt,\"°C\"]})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-1\",children:[a.jsx(\"label\",{className:\"text-[10px] text-bambu-gray\",children:N(\"printers.drying.duration\")}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"input\",{type:\"number\",min:1,max:24,value:we,onChange:ct=>Ve(Math.min(24,Math.max(1,Number(ct.target.value)||1))),className:\"w-10 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[11px] text-center focus:outline-none focus:border-amber-500/50 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"}),a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray\",children:N(\"printers.drying.hours\")})]})]}),a.jsx(\"input\",{type:\"range\",min:1,max:24,value:we,onChange:ct=>Ve(Number(ct.target.value)),className:\"w-full h-1 accent-amber-500 cursor-pointer\"}),a.jsxs(\"div\",{className:\"flex justify-between text-[9px] text-bambu-gray/50 mt-0.5\",children:[a.jsx(\"span\",{children:\"1h\"}),a.jsx(\"span\",{children:\"24h\"})]})]}),a.jsxs(\"label\",{className:\"flex items-center gap-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:Ae,onChange:ct=>ce(ct.target.checked),className:\"w-3.5 h-3.5 accent-amber-500 rounded cursor-pointer\"}),a.jsx(\"span\",{className:\"text-[11px] text-bambu-gray\",children:N(\"printers.drying.rotateTray\")})]})]}),a.jsx(\"div\",{className:\"px-3 pb-3\",children:a.jsx(\"button\",{onClick:()=>{Me!==null&&vo.mutate({amsId:Me,temp:pe,duration:we,filament:Ye,rotateTray:Ae})},disabled:vo.isPending,className:\"w-full py-1.5 bg-amber-500 hover:bg-amber-400 text-white text-xs font-medium rounded-lg transition-colors disabled:opacity-50\",children:vo.isPending?N(\"printers.drying.startingDrying\"):N(\"printers.drying.start\")})})]})]})})()]})}function I7e({onClose:t,onAdd:e,existingSerials:n}){const{t:r}=Ft(),[i,s]=w.useState({name:\"\",serial_number:\"\",ip_address:\"\",access_code:\"\",model:\"\",location:\"\",auto_archive:!0}),[o,l]=w.useState(!1),[c,d]=w.useState([]),[u,m]=w.useState(\"\"),[p,f]=w.useState(!1),[y,v]=w.useState(!1),[b,g]=w.useState([]),[_,C]=w.useState(\"\"),[P,N]=w.useState({scanned:0,total:0});w.useEffect(()=>{hd.getInfo().then(k=>{v(k.is_docker),k.subnets.length>0&&(g(k.subnets),C(k.subnets[0]))}).catch(()=>{})},[]);const A=c.filter(k=>!n.includes(k.serial)),T=async()=>{m(\"\"),d([]),l(!0),f(!1),N({scanned:0,total:0});try{if(y){await hd.startSubnetScan(_);const k=setInterval(async()=>{try{const D=await hd.getScanStatus();N({scanned:D.scanned,total:D.total});const H=await hd.getDiscoveredPrinters();d(H),D.running||(clearInterval(k),l(!1),f(!0))}catch(D){console.error(\"Failed to get scan status:\",D)}},500)}else{await hd.startDiscovery(10);const k=setInterval(async()=>{try{const D=await hd.getDiscoveredPrinters();d(D)}catch(D){console.error(\"Failed to get discovered printers:\",D)}},1e3);setTimeout(async()=>{clearInterval(k);try{await hd.stopDiscovery()}catch{}l(!1),f(!0);try{const D=await hd.getDiscoveredPrinters();d(D)}catch(D){console.error(\"Failed to get final discovered printers:\",D)}},1e4)}}catch(k){console.error(\"Failed to start discovery:\",k),m(k instanceof Error?k.message:r(\"printers.discovery.failedToStart\")),l(!1),f(!0)}},F=k=>{const D=k.serial.startsWith(\"unknown-\")?\"\":k.serial;s({...i,name:k.name||\"\",serial_number:D,ip_address:k.ip_address,model:O0(k.model)}),d([])};return w.useEffect(()=>()=>{hd.stopDiscovery().catch(()=>{}),hd.stopSubnetScan().catch(()=>{})},[]),w.useEffect(()=>{const k=D=>{D.key===\"Escape\"&&t()};return window.addEventListener(\"keydown\",k),()=>window.removeEventListener(\"keydown\",k)},[t]),a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-start sm:items-center justify-center z-50 p-4 overflow-y-auto\",onClick:t,children:a.jsx(Tt,{className:\"w-full max-w-md my-auto max-h-[calc(100vh-2rem)] overflow-y-auto\",onClick:k=>k.stopPropagation(),children:a.jsxs(Mt,{children:[a.jsx(\"h2\",{className:\"text-xl font-semibold mb-4\",children:r(\"printers.addPrinter\")}),a.jsxs(\"div\",{className:\"mb-4 pb-4 border-b border-bambu-dark-tertiary\",children:[y&&a.jsxs(\"div\",{className:\"mb-3\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"printers.discovery.subnetToScan\")}),b.length>0?a.jsx(\"select\",{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 text-sm\",value:_,onChange:k=>C(k.target.value),disabled:o,children:b.map(k=>a.jsx(\"option\",{value:k,children:k},k))}):a.jsx(\"input\",{type:\"text\",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 text-sm\",value:_,onChange:k=>C(k.target.value),placeholder:\"192.168.1.0/24\",disabled:o}),a.jsx(\"p\",{className:\"mt-1 text-xs text-bambu-gray\",children:r(\"printers.discovery.dockerNote\")})]}),a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:T,disabled:o,className:\"w-full\",children:o?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),y&&P.total>0?r(\"printers.discovery.scanProgress\",{scanned:P.scanned,total:P.total}):r(\"printers.discovery.scanning\")]}):a.jsxs(a.Fragment,{children:[a.jsx(Pr,{className:\"w-4 h-4\"}),r(y?\"printers.discovery.scanSubnet\":\"printers.discovery.discoverNetwork\")]})}),u&&a.jsx(\"div\",{className:\"mt-2 text-sm text-red-400\",children:u}),A.length>0&&a.jsx(\"div\",{className:\"mt-3 space-y-2 max-h-40 overflow-y-auto\",children:A.map(k=>a.jsxs(\"div\",{className:\"flex items-center justify-between p-2 bg-bambu-dark rounded-lg hover:bg-bambu-dark-secondary cursor-pointer transition-colors\",onClick:()=>F(k),children:[a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"font-medium text-white text-sm truncate\",children:k.name||k.serial}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray truncate\",children:[O0(k.model)||r(\"printers.discovery.unknown\"),\" • \",k.ip_address,k.serial.startsWith(\"unknown-\")&&a.jsxs(\"span\",{className:\"text-yellow-500\",children:[\" • \",r(\"printers.discovery.serialRequired\")]})]})]}),a.jsx(On,{className:\"w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2\"})]},k.serial))}),o&&a.jsx(\"p\",{className:\"mt-2 text-sm text-bambu-gray text-center\",children:r(y?\"printers.discovery.scanningSubnet\":\"printers.discovery.scanningNetwork\")}),p&&!o&&c.length===0&&a.jsx(\"p\",{className:\"mt-2 text-sm text-bambu-gray text-center\",children:r(y?\"printers.discovery.noPrintersFoundSubnet\":\"printers.discovery.noPrintersFoundNetwork\")}),p&&!o&&c.length>0&&A.length===0&&a.jsx(\"p\",{className:\"mt-2 text-sm text-bambu-gray text-center\",children:r(\"printers.discovery.allConfigured\")})]}),a.jsxs(\"form\",{onSubmit:k=>{k.preventDefault(),e(i)},className:\"space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"printers.name\")}),a.jsx(\"input\",{type:\"text\",required:!0,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\",value:i.name,onChange:k=>s({...i,name:k.target.value}),placeholder:r(\"printers.modal.myPrinter\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"printers.ipAddress\")}),a.jsx(\"input\",{type:\"text\",required:!0,pattern:\"(\\\\d{1,3}(\\\\.\\\\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\\\\-]{0,61}[a-zA-Z0-9])?(\\\\.[a-zA-Z0-9]([a-zA-Z0-9\\\\-]{0,61}[a-zA-Z0-9])?)*)\",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\",value:i.ip_address,onChange:k=>s({...i,ip_address:k.target.value}),placeholder:\"192.168.1.100 or printer.local\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"printers.serialNumber\")}),a.jsx(\"input\",{type:\"text\",required:!0,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\",value:i.serial_number,onChange:k=>s({...i,serial_number:k.target.value}),placeholder:\"01P00A000000000\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"printers.accessCode\")}),a.jsx(\"input\",{type:\"password\",required:!0,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\",value:i.access_code,onChange:k=>s({...i,access_code:k.target.value}),placeholder:r(\"printers.modal.fromPrinterSettings\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"printers.modal.modelOptional\")}),a.jsxs(\"select\",{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\",value:i.model||\"\",onChange:k=>s({...i,model:k.target.value}),children:[a.jsx(\"option\",{value:\"\",children:r(\"printers.modal.selectModel\")}),a.jsxs(\"optgroup\",{label:\"H2 Series\",children:[a.jsx(\"option\",{value:\"H2C\",children:\"H2C\"}),a.jsx(\"option\",{value:\"H2D\",children:\"H2D\"}),a.jsx(\"option\",{value:\"H2D Pro\",children:\"H2D Pro\"}),a.jsx(\"option\",{value:\"H2S\",children:\"H2S\"})]}),a.jsx(\"optgroup\",{label:\"X2 Series\",children:a.jsx(\"option\",{value:\"X2D\",children:\"X2D\"})}),a.jsxs(\"optgroup\",{label:\"X1 Series\",children:[a.jsx(\"option\",{value:\"X1E\",children:\"X1E\"}),a.jsx(\"option\",{value:\"X1C\",children:\"X1 Carbon\"}),a.jsx(\"option\",{value:\"X1\",children:\"X1\"})]}),a.jsxs(\"optgroup\",{label:\"P Series\",children:[a.jsx(\"option\",{value:\"P2S\",children:\"P2S\"}),a.jsx(\"option\",{value:\"P1S\",children:\"P1S\"}),a.jsx(\"option\",{value:\"P1P\",children:\"P1P\"})]}),a.jsxs(\"optgroup\",{label:\"A1 Series\",children:[a.jsx(\"option\",{value:\"A1\",children:\"A1\"}),a.jsx(\"option\",{value:\"A1 Mini\",children:\"A1 Mini\"})]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"printers.modal.locationGroup\")}),a.jsx(\"input\",{type:\"text\",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\",value:i.location||\"\",onChange:k=>s({...i,location:k.target.value}),placeholder:r(\"printers.modal.locationPlaceholder\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:r(\"printers.locationHelp\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"checkbox\",id:\"auto_archive\",checked:i.auto_archive,onChange:k=>s({...i,auto_archive:k.target.checked}),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"label\",{htmlFor:\"auto_archive\",className:\"text-sm text-bambu-gray\",children:r(\"printers.modal.autoArchiveLabel\")})]}),a.jsxs(\"div\",{className:\"flex gap-3 pt-4\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:t,className:\"flex-1\",children:r(\"common.cancel\")}),a.jsx(De,{type:\"submit\",className:\"flex-1\",children:r(\"printers.addPrinter\")})]})]})]})})})}function z7e({printer:t,firmwareInfo:e,onClose:n}){const{t:r}=Ft(),i=nn(),{showToast:s}=hn(),{hasPermission:o}=kr(),l=o(\"firmware:update\"),[c,d]=w.useState(null),[u,m]=w.useState(!1),[p,f]=w.useState(null),[y,v]=w.useState(e.update_available?e.latest_version:null),{data:b,isLoading:g}=Xe({queryKey:[\"firmwarePrepare\",t.id,y],queryFn:()=>zk.prepareUpload(t.id,y??void 0),staleTime:3e4,enabled:!!y&&l&&!u}),_=it({mutationFn:()=>zk.startUpload(t.id,y??void 0),onSuccess:()=>{m(!0);const P=setInterval(async()=>{try{const N=await zk.getUploadStatus(t.id);d(N),(N.status===\"complete\"||N.status===\"error\")&&(clearInterval(P),f(null),m(!1),N.status===\"complete\"&&(s(r(\"printers.firmwareModal.uploadedToast\"),\"success\"),i.invalidateQueries({queryKey:[\"firmwareUpdate\",t.id]})))}catch{}},2e3);f(P)},onError:P=>{s(r(\"printers.firmwareModal.uploadFailed\",{error:P.message}),\"error\"),m(!1)}});w.useEffect(()=>()=>{p&&clearInterval(p)},[p]);const C=()=>{d(null),_.mutate()};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\",children:a.jsx(Tt,{className:\"w-full max-w-md mx-4\",children:a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"flex items-start gap-3 mb-4\",children:[a.jsx(\"div\",{className:`p-2 rounded-full ${e.update_available?\"bg-orange-500/20\":\"bg-status-ok/20\"}`,children:e.update_available?a.jsx(ga,{className:\"w-5 h-5 text-orange-400\"}):a.jsx(yr,{className:\"w-5 h-5 text-status-ok\"})}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white\",children:e.update_available?r(\"printers.firmwareModal.title\"):r(\"printers.firmwareModal.titleUpToDate\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:t.name})]})]}),(()=>{const P=y?e.available_versions?.find(F=>F.version===y):null,N=y??e.latest_version,A=P?.release_notes??e.release_notes,T=!!N&&N!==e.current_version;return a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-3 mb-4\",children:[a.jsxs(\"div\",{className:\"flex justify-between items-center text-sm\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:r(\"printers.firmwareModal.currentVersion\")}),a.jsx(\"span\",{className:`font-mono ${T?\"text-white\":\"text-status-ok\"}`,children:e.current_version||r(\"common.unknown\")})]}),T&&a.jsxs(\"div\",{className:\"flex justify-between items-center text-sm mt-1\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:r(\"printers.firmwareModal.latestVersion\")}),a.jsx(\"span\",{className:\"text-orange-400 font-mono\",children:N})]}),A&&a.jsxs(\"details\",{className:\"mt-3 text-sm\",open:!T,children:[a.jsx(\"summary\",{className:`cursor-pointer hover:underline ${T?\"text-orange-400\":\"text-status-ok\"}`,children:r(\"printers.firmwareModal.releaseNotes\")}),a.jsx(\"div\",{className:\"mt-2 text-bambu-gray text-xs max-h-40 overflow-y-auto whitespace-pre-wrap\",children:A})]},N??\"none\")]})})(),e.available_versions&&e.available_versions.length>0&&!u&&c?.status!==\"complete\"&&a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsx(\"div\",{className:\"text-xs text-bambu-gray mb-2\",children:r(\"printers.firmwareModal.availableVersions\")}),a.jsx(\"div\",{className:\"max-h-56 overflow-y-auto border border-bambu-dark-tertiary rounded-lg divide-y divide-bambu-dark-tertiary\",children:e.available_versions.map(P=>{const N=e.current_version===P.version,A=y===P.version,T=e.current_version?Hye(P.version,e.current_version):0,F=N?r(\"printers.firmwareModal.currentBadge\"):T>0?r(\"printers.firmwareModal.newerBadge\"):r(\"printers.firmwareModal.olderBadge\"),k=N?\"text-bambu-gray\":T>0?\"text-orange-400\":\"text-blue-400\";return a.jsxs(\"button\",{type:\"button\",disabled:!P.file_available||!l||N,onClick:()=>v(P.version),className:`w-full text-left px-3 py-2 text-sm flex items-center justify-between gap-2 transition-colors ${A?\"bg-orange-500/10\":\"hover:bg-bambu-dark\"} ${!P.file_available||!l||N?\"opacity-60 cursor-not-allowed\":\"cursor-pointer\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 min-w-0\",children:[a.jsx(\"span\",{className:\"font-mono text-white\",children:P.version}),a.jsx(\"span\",{className:`text-xs ${k}`,children:F})]}),a.jsx(\"span\",{className:`text-xs px-2 py-0.5 rounded-full ${N?\"bg-blue-500/15 text-blue-400 border border-blue-500/30\":P.file_available?\"bg-bambu-green/15 text-bambu-green border border-bambu-green/30\":\"bg-bambu-gray/10 text-bambu-gray border border-bambu-gray/30\"}`,children:N?r(\"printers.firmwareModal.installed\"):P.file_available?r(\"printers.firmwareModal.usable\"):r(\"printers.firmwareModal.unavailable\")})]},P.version)})})]}),y?g?a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray text-sm mb-4\",children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),r(\"printers.firmwareModal.checkingPrereqs\")]}):b&&!u&&!c?a.jsx(\"div\",{className:\"mb-4\",children:b.can_proceed?a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-green text-sm\",children:[a.jsx(vi,{className:\"w-4 h-4\"}),r(\"printers.firmwareModal.sdCardReady\")]}):a.jsx(\"div\",{className:\"space-y-1\",children:b.errors.map((P,N)=>a.jsxs(\"div\",{className:\"flex items-center gap-2 text-red-400 text-sm\",children:[a.jsx(ei,{className:\"w-4 h-4 flex-shrink-0\"}),P]},N))})}):null:null,(u||c)&&c&&a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between text-sm mb-1\",children:[a.jsx(\"span\",{className:\"text-bambu-gray capitalize\",children:c.status}),a.jsxs(\"span\",{className:\"text-white\",children:[c.progress,\"%\"]})]}),a.jsx(\"div\",{className:\"w-full bg-bambu-dark-tertiary rounded-full h-2\",children:a.jsx(\"div\",{className:`h-2 rounded-full transition-all ${c.status===\"error\"?\"bg-status-error\":c.status===\"complete\"?\"bg-status-ok\":\"bg-orange-500\"} ${c.status===\"uploading\"?\"animate-pulse\":\"\"}`,style:{width:`${c.progress}%`}})}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:c.message}),c.error&&a.jsx(\"p\",{className:\"text-xs text-red-400 mt-1\",children:c.error})]}),c?.status===\"complete\"&&a.jsxs(\"div\",{className:\"bg-bambu-green/10 border border-bambu-green/30 rounded-lg p-3 mb-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-green font-medium mb-2\",children:r(\"printers.firmwareModal.uploadedSuccess\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:r(\"printers.firmwareModal.applyInstructions\")}),a.jsxs(\"ol\",{className:\"text-xs text-bambu-gray mt-1 list-decimal list-inside space-y-1\",children:[a.jsx(\"li\",{dangerouslySetInnerHTML:{__html:r(\"printers.firmwareModal.step1\")}}),a.jsx(\"li\",{dangerouslySetInnerHTML:{__html:r(\"printers.firmwareModal.step2\")}}),a.jsx(\"li\",{dangerouslySetInnerHTML:{__html:r(\"printers.firmwareModal.step3\")}}),a.jsx(\"li\",{children:r(\"printers.firmwareModal.step4\")})]})]}),a.jsxs(\"div\",{className:\"flex gap-2 justify-end\",children:[a.jsx(De,{variant:\"secondary\",onClick:n,children:c?.status===\"complete\"?r(\"printers.firmwareModal.done\"):r(\"common.cancel\")}),b?.can_proceed&&!u&&c?.status!==\"complete\"&&l&&a.jsx(De,{onClick:C,disabled:_.isPending,children:_.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin mr-2\"}),r(\"printers.firmwareModal.starting\")]}):a.jsxs(a.Fragment,{children:[a.jsx(ga,{className:\"w-4 h-4 mr-2\"}),r(\"printers.firmwareModal.uploadFirmware\")]})})]})]})})})}function U7e({printer:t,onClose:e}){const{t:n}=Ft(),r=nn(),{showToast:i}=hn(),[s,o]=w.useState({name:t.name,ip_address:t.ip_address,access_code:\"\",model:t.model||\"\",location:t.location||\"\",auto_archive:t.auto_archive}),l=it({mutationFn:d=>ue.updatePrinter(t.id,d),onSuccess:()=>{r.invalidateQueries({queryKey:[\"printers\"]}),r.invalidateQueries({queryKey:[\"printerStatus\",t.id]}),e()},onError:d=>i(d.message||n(\"printers.toast.failedToUpdate\"),\"error\")});w.useEffect(()=>{const d=u=>{u.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",d),()=>window.removeEventListener(\"keydown\",d)},[e]);const c=d=>{d.preventDefault();const u={name:s.name,ip_address:s.ip_address,model:s.model||void 0,location:s.location||void 0,auto_archive:s.auto_archive};s.access_code&&(u.access_code=s.access_code),l.mutate(u)};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-start sm:items-center justify-center z-50 p-4 overflow-y-auto\",onClick:e,children:a.jsx(Tt,{className:\"w-full max-w-md my-auto max-h-[calc(100vh-2rem)] overflow-y-auto\",onClick:d=>d.stopPropagation(),children:a.jsxs(Mt,{children:[a.jsx(\"h2\",{className:\"text-xl font-semibold mb-4\",children:n(\"printers.editPrinter\")}),a.jsxs(\"form\",{onSubmit:c,className:\"space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"printers.name\")}),a.jsx(\"input\",{type:\"text\",required:!0,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\",value:s.name,onChange:d=>o({...s,name:d.target.value}),placeholder:n(\"printers.modal.myPrinter\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"printers.ipAddress\")}),a.jsx(\"input\",{type:\"text\",required:!0,pattern:\"(\\\\d{1,3}(\\\\.\\\\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\\\\-]{0,61}[a-zA-Z0-9])?(\\\\.[a-zA-Z0-9]([a-zA-Z0-9\\\\-]{0,61}[a-zA-Z0-9])?)*)\",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\",value:s.ip_address,onChange:d=>o({...s,ip_address:d.target.value}),placeholder:\"192.168.1.100 or printer.local\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"printers.serialNumber\")}),a.jsx(\"input\",{type:\"text\",disabled:!0,className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed\",value:t.serial_number}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(\"printers.serialCannotBeChanged\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"printers.accessCode\")}),a.jsx(\"input\",{type:\"password\",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\",value:s.access_code,onChange:d=>o({...s,access_code:d.target.value}),placeholder:n(\"printers.accessCodePlaceholder\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"printers.model\")}),a.jsxs(\"select\",{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\",value:s.model,onChange:d=>o({...s,model:d.target.value}),children:[a.jsx(\"option\",{value:\"\",children:n(\"printers.modal.selectModel\")}),a.jsxs(\"optgroup\",{label:\"H2 Series\",children:[a.jsx(\"option\",{value:\"H2C\",children:\"H2C\"}),a.jsx(\"option\",{value:\"H2D\",children:\"H2D\"}),a.jsx(\"option\",{value:\"H2D Pro\",children:\"H2D Pro\"}),a.jsx(\"option\",{value:\"H2S\",children:\"H2S\"})]}),a.jsx(\"optgroup\",{label:\"X2 Series\",children:a.jsx(\"option\",{value:\"X2D\",children:\"X2D\"})}),a.jsxs(\"optgroup\",{label:\"X1 Series\",children:[a.jsx(\"option\",{value:\"X1E\",children:\"X1E\"}),a.jsx(\"option\",{value:\"X1C\",children:\"X1 Carbon\"}),a.jsx(\"option\",{value:\"X1\",children:\"X1\"})]}),a.jsxs(\"optgroup\",{label:\"P Series\",children:[a.jsx(\"option\",{value:\"P2S\",children:\"P2S\"}),a.jsx(\"option\",{value:\"P1S\",children:\"P1S\"}),a.jsx(\"option\",{value:\"P1P\",children:\"P1P\"})]}),a.jsxs(\"optgroup\",{label:\"A1 Series\",children:[a.jsx(\"option\",{value:\"A1\",children:\"A1\"}),a.jsx(\"option\",{value:\"A1 Mini\",children:\"A1 Mini\"})]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:\"Location / Group\"}),a.jsx(\"input\",{type:\"text\",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\",value:s.location,onChange:d=>o({...s,location:d.target.value}),placeholder:n(\"printers.modal.locationPlaceholder\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(\"printers.locationHelp\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"checkbox\",id:\"edit_auto_archive\",checked:s.auto_archive,onChange:d=>o({...s,auto_archive:d.target.checked}),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"label\",{htmlFor:\"edit_auto_archive\",className:\"text-sm text-bambu-gray\",children:n(\"printers.modal.autoArchiveLabel\")})]}),a.jsxs(\"div\",{className:\"flex gap-3 pt-4\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:e,className:\"flex-1\",children:n(\"common.cancel\")}),a.jsx(De,{type:\"submit\",className:\"flex-1\",disabled:l.isPending,children:l.isPending?n(\"common.saving\"):n(\"printers.modal.saveChanges\")})]})]})]})})})}function B7e(t){const{data:e}=Xe({queryKey:[\"printerStatus\",t],queryFn:()=>ue.getPrinterStatus(t),refetchInterval:3e4});return!e?.connected}function H7e({printer:t,plug:e,onPowerOn:n,isPowering:r}){const i=B7e(t.id),{data:s}=Xe({queryKey:[\"smartPlugStatus\",e.id],queryFn:()=>ue.getSmartPlugStatus(e.id),refetchInterval:1e4});return i?a.jsxs(\"div\",{className:\"flex items-center justify-between px-3 py-2 hover:bg-gray-100 dark:hover:bg-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 min-w-0\",children:[a.jsx(\"span\",{className:\"text-sm text-gray-900 dark:text-white truncate\",children:t.name}),s&&a.jsx(\"span\",{className:`text-xs px-1.5 py-0.5 rounded ${s.state===\"ON\"?\"bg-bambu-green/20 text-bambu-green\":\"bg-red-500/20 text-red-400\"}`,children:s.state||\"?\"})]}),a.jsxs(\"button\",{onClick:()=>n(e.id),disabled:r||s?.state===\"ON\",className:`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${s?.state===\"ON\"?\"bg-bambu-green/20 text-bambu-green cursor-default\":\"bg-bambu-green/20 text-bambu-green hover:bg-bambu-green hover:text-white\"}`,children:[a.jsx(Yd,{className:\"w-3 h-3\"}),r?\"...\":\"On\"]})]}):null}function q7e(){const{t}=Ft(),[e,n]=w.useState(!1),[r,i]=w.useState(()=>localStorage.getItem(\"hideDisconnectedPrinters\")===\"true\"),[s,o]=w.useState(!1),[l,c]=w.useState(null),[d,u]=w.useState(()=>localStorage.getItem(\"printerSortBy\")||\"name\"),[m,p]=w.useState(()=>localStorage.getItem(\"printerSortAsc\")!==\"false\"),[f,y]=w.useState(()=>{const Ae=localStorage.getItem(\"printerCardSize\");return Ae?parseInt(Ae,10):2}),v=f===1?\"compact\":\"expanded\",[b,g]=w.useState(\"\"),[_,C]=w.useState(\"all\"),[P,N]=w.useState(\"all\"),[A,T]=w.useState(0),[F,k]=w.useState(()=>{try{const Ae=localStorage.getItem(\"printerCollapsedSections\");return Ae?JSON.parse(Ae):{}}catch{return{}}}),D=nn(),{showToast:H}=hn(),{hasPermission:z}=kr(),[Q,L]=w.useState(()=>{const Ae=localStorage.getItem(\"openEmbeddedCameras\");if(Ae)try{const ce=JSON.parse(Ae);return new Map(ce.map(xe=>[xe.id,xe]))}catch{return new Map}return new Map});w.useEffect(()=>{const Ae=Array.from(Q.values());Ae.length>0?localStorage.setItem(\"openEmbeddedCameras\",JSON.stringify(Ae)):localStorage.removeItem(\"openEmbeddedCameras\")},[Q]);const{data:te,isLoading:ie}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:J}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),oe=w.useMemo(()=>{if(J?.drying_presets)try{const Ae=JSON.parse(J.drying_presets);if(typeof Ae==\"object\"&&Ae!==null&&Object.keys(Ae).length>0)return{...wL,...Ae}}catch{}return wL},[J?.drying_presets]);w.useEffect(()=>{J?.camera_view_mode===\"window\"&&Q.size>0&&L(new Map)},[J?.camera_view_mode,Q.size]);const{data:fe}=Xe({queryKey:[\"smart-plugs\"],queryFn:ue.getSmartPlugs}),{data:re}=Xe({queryKey:[\"maintenanceOverview\"],queryFn:ue.getMaintenanceOverview,staleTime:60*1e3}),{data:W}=Xe({queryKey:[\"spoolman-status\"],queryFn:ue.getSpoolmanStatus,staleTime:60*1e3}),ne=W?.enabled&&W?.connected,{data:me}=Xe({queryKey:[\"spoolman-settings\"],queryFn:ue.getSpoolmanSettings,enabled:!!ne,staleTime:60*1e3}),be=me?.spoolman_sync_mode,{data:Ce}=Xe({queryKey:[\"unlinked-spools\"],queryFn:ue.getUnlinkedSpools,enabled:!!ne,staleTime:30*1e3}),q=Ce&&Ce.length>0,{data:Y}=Xe({queryKey:[\"linked-spools\"],queryFn:ue.getLinkedSpools,enabled:!!ne,staleTime:30*1e3}),E=Y?.linked,{data:j}=Xe({queryKey:[\"spool-assignments\"],queryFn:()=>ue.getAssignments(),enabled:z(\"inventory:view_assignments\"),staleTime:30*1e3}),O=it({mutationFn:({printerId:Ae,amsId:ce,trayId:xe})=>ue.unassignSpool(Ae,ce,xe),onSuccess:()=>{D.invalidateQueries({queryKey:[\"spool-assignments\"]})}}),K=(Ae,ce,xe)=>j?.find(Be=>Be.printer_id===Ae&&Be.ams_id===Number(ce)&&Be.tray_id===Number(xe)),U=re?.reduce((Ae,ce)=>(Ae[ce.printer_id]={due_count:ce.due_count,warning_count:ce.warning_count,total_print_hours:ce.total_print_hours},Ae),{})||{},de=fe?.reduce((Ae,ce)=>(ce.printer_id&&(Ae[ce.printer_id]=ce),Ae),{})||{},I=it({mutationFn:ue.createPrinter,onSuccess:()=>{D.invalidateQueries({queryKey:[\"printers\"]}),D.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),n(!1)},onError:Ae=>H(Ae.message||t(\"printers.toast.failedToAdd\"),\"error\")}),G=it({mutationFn:Ae=>ue.controlSmartPlug(Ae,\"on\"),onSuccess:()=>{D.invalidateQueries({queryKey:[\"smart-plugs\"]}),c(null)},onError:()=>{c(null)}}),[X,V]=w.useState(new Set),[ee,se]=w.useState(!1),[ge,he]=w.useState(null),[le,B]=w.useState(!1),R=ee||X.size>0,ae=w.useCallback(Ae=>{V(ce=>{const xe=new Set(ce);return xe.has(Ae)?xe.delete(Ae):xe.add(Ae),xe})},[]),_e=w.useCallback(()=>{V(new Set),se(!1)},[]);w.useEffect(()=>{const Ae=ce=>{ce.key===\"Escape\"&&R&&_e()};return window.addEventListener(\"keydown\",Ae),()=>window.removeEventListener(\"keydown\",Ae)},[R,_e]);const Se=w.useCallback(async Ae=>{B(!0);const xe=Array.from(X).filter(gt=>{const Ut=D.getQueryData([\"printerStatus\",gt]);if(!Ut?.connected)return!1;switch(Ae){case\"stop\":return Ut.state===\"RUNNING\"||Ut.state===\"PAUSE\";case\"pause\":return Ut.state===\"RUNNING\";case\"resume\":return Ut.state===\"PAUSE\";case\"clearPlate\":return!!Ut.awaiting_plate_clear;case\"clearHMS\":return Ut.hms_errors&&om(Ut.hms_errors).length>0;default:return!1}});if(xe.length===0){H(t(\"printers.bulk.noneApplicable\"),\"error\"),B(!1),he(null);return}const Be={stop:ue.stopPrint,pause:ue.pausePrint,resume:ue.resumePrint,clearPlate:ue.clearPlate,clearHMS:ue.clearHMSErrors}[Ae],Qe=await Promise.allSettled(xe.map(gt=>Be(gt))),ht=Qe.filter(gt=>gt.status===\"fulfilled\").length,xt=Qe.filter(gt=>gt.status===\"rejected\").length;xt===0?H(t(\"printers.bulk.success\",{action:t(`printers.bulk.actions.${Ae}`),count:ht})):H(t(\"printers.bulk.partial\",{succeeded:ht,failed:xt}),\"error\"),xe.forEach(gt=>{D.invalidateQueries({queryKey:[\"printerStatus\",gt]})}),B(!1),he(null)},[X,D,H,t]),ve=w.useCallback(Ae=>{Ae===\"stop\"||Ae===\"pause\"||Ae===\"clearPlate\"?he(Ae):Se(Ae)},[Se]),Te=()=>{const Ae=!r;i(Ae),localStorage.setItem(\"hideDisconnectedPrinters\",String(Ae))},ye=Ae=>{u(Ae),localStorage.setItem(\"printerSortBy\",Ae)},je=()=>{const Ae=!m;p(Ae),localStorage.setItem(\"printerSortAsc\",String(Ae))},Le=()=>{switch(f){case 1:return\"grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5\";case 2:return\"grid-cols-1 md:grid-cols-2 xl:grid-cols-3\";case 3:return\"grid-cols-1 lg:grid-cols-2\";case 4:return\"grid-cols-1\";default:return\"grid-cols-1 md:grid-cols-2 xl:grid-cols-3\"}},Me=[\"S\",\"M\",\"L\",\"XL\"];w.useEffect(()=>D.getQueryCache().subscribe(ce=>{ce.type===\"updated\"&&Array.isArray(ce.query.queryKey)&&ce.query.queryKey[0]===\"printerStatus\"&&T(xe=>xe+1)}),[D]);const Oe=w.useMemo(()=>{if(!te)return[];let Ae=te;if(b.trim()){const ce=b.trim().toLowerCase();Ae=Ae.filter(xe=>xe.name.toLowerCase().includes(ce)||(xe.model||\"\").toLowerCase().includes(ce)||(xe.location||\"\").toLowerCase().includes(ce)||(xe.serial_number||\"\").toLowerCase().includes(ce))}return P!==\"all\"&&(Ae=Ae.filter(ce=>(ce.location||\"\")===P)),_!==\"all\"&&(Ae=Ae.filter(ce=>{const xe=D.getQueryData([\"printerStatus\",ce.id]);if(!xe?.connected)return _===\"offline\";const Be=xe.hms_errors?om(xe.hms_errors):[];switch(_){case\"printing\":return xe.state===\"RUNNING\";case\"paused\":return xe.state===\"PAUSE\";case\"finished\":return xe.state===\"FINISH\";case\"error\":return xe.state===\"FAILED\"||Be.length>0;case\"idle\":return xe.state!==\"RUNNING\"&&xe.state!==\"PAUSE\"&&xe.state!==\"FINISH\"&&xe.state!==\"FAILED\"&&Be.length===0;case\"offline\":return!1;default:return!0}})),Ae},[te,b,_,P,D,A]),Re=w.useMemo(()=>te?[...new Set(te.map(Ae=>Ae.location||\"\").filter(Boolean))].sort():[],[te]),$e=w.useMemo(()=>{const Ae=[...Oe];switch(d){case\"name\":Ae.sort((ce,xe)=>ce.name.localeCompare(xe.name));break;case\"model\":Ae.sort((ce,xe)=>(ce.model||\"\").localeCompare(xe.model||\"\"));break;case\"location\":Ae.sort((ce,xe)=>{const Be=ce.location||\"\",Qe=xe.location||\"\";return!Be&&Qe?1:Be&&!Qe?-1:Be.localeCompare(Qe)||ce.name.localeCompare(xe.name)});break;case\"status\":Ae.sort((ce,xe)=>{const Be=D.getQueryData([\"printerStatus\",ce.id]),Qe=D.getQueryData([\"printerStatus\",xe.id]),ht=xt=>xt?.connected?(xt.hms_errors?om(xt.hms_errors):[]).length>0?0:xt.state===\"RUNNING\"?1:2:3;return ht(Be)-ht(Qe)});break}return m||Ae.reverse(),Ae},[Oe,d,m,D]),Ye=w.useCallback(()=>{V(new Set($e.map(Ae=>Ae.id))),se(!0)},[$e]),tt=w.useCallback(Ae=>{V(ce=>{const xe=new Set(ce);return $e.forEach(Be=>{const Qe=D.getQueryData([\"printerStatus\",Be.id]);uK(Qe)===Ae&&xe.add(Be.id)}),xe}),se(!0)},[$e,D]),pe=w.useCallback(Ae=>{V(ce=>{const xe=new Set(ce);return $e.filter(Be=>(Be.location||\"\")===Ae).forEach(Be=>xe.add(Be.id)),xe}),se(!0)},[$e]),Fe=w.useCallback(Ae=>{V(ce=>{const xe=new Set(ce);return $e.filter(Be=>(Be.model||\"Unknown\")===Ae).forEach(Be=>xe.add(Be.id)),xe}),se(!0)},[$e]),we=w.useCallback(Ae=>{k(ce=>{const xe={...ce,[Ae]:!ce[Ae]};try{localStorage.setItem(\"printerCollapsedSections\",JSON.stringify(xe))}catch{}return xe})},[]),Ve=w.useMemo(()=>{if(d===\"name\")return null;const Ae={};return d===\"location\"?$e.forEach(ce=>{const xe=ce.location||\"Ungrouped\";Ae[xe]||(Ae[xe]=[]),Ae[xe].push(ce)}):d===\"model\"?$e.forEach(ce=>{const xe=ce.model||\"Unknown\";Ae[xe]||(Ae[xe]=[]),Ae[xe].push(ce)}):d===\"status\"&&$e.forEach(ce=>{const xe=D.getQueryData([\"printerStatus\",ce.id]),Be=uK(xe);Ae[Be]||(Ae[Be]=[]),Ae[Be].push(ce)}),Ae},[d,$e,D,A]);return a.jsxs(\"div\",{className:\"p-4 md:p-8\",children:[a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:t(\"printers.title\")}),a.jsx(L7e,{printers:te}),te&&te.length>0&&a.jsxs(\"div\",{className:\"relative w-full sm:max-w-sm mt-3\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50\"}),a.jsx(\"input\",{type:\"search\",name:\"printer-search\",autoComplete:\"off\",\"data-1p-ignore\":!0,\"data-lpignore\":\"true\",value:b,onChange:Ae=>g(Ae.target.value),placeholder:t(\"printers.search\"),\"aria-label\":t(\"printers.search\"),className:\"w-full pl-10 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"}),b&&a.jsx(\"button\",{type:\"button\",\"aria-label\":t(\"common.clear\"),onClick:()=>g(\"\"),className:\"absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 sm:gap-3 flex-wrap\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsxs(\"select\",{value:d,onChange:Ae=>ye(Ae.target.value),className:\"text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"name\",children:t(\"printers.sort.name\")}),a.jsx(\"option\",{value:\"status\",children:t(\"printers.sort.status\")}),a.jsx(\"option\",{value:\"model\",children:t(\"printers.sort.model\")}),a.jsx(\"option\",{value:\"location\",children:t(\"printers.sort.location\")})]}),a.jsx(\"button\",{onClick:je,className:\"p-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors\",title:t(m?\"printers.sort.descending\":\"printers.sort.ascending\"),children:m?a.jsx(fp,{className:\"w-4 h-4 text-bambu-gray\"}):a.jsx(cg,{className:\"w-4 h-4 text-bambu-gray\"})})]}),te&&te.length>0&&a.jsxs(\"select\",{value:_,onChange:Ae=>C(Ae.target.value),className:\"text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"all\",children:t(\"printers.filter.allStatuses\")}),a.jsx(\"option\",{value:\"printing\",children:t(\"printers.status.printing\")}),a.jsx(\"option\",{value:\"paused\",children:t(\"printers.status.paused\")}),a.jsx(\"option\",{value:\"idle\",children:t(\"printers.status.idle\")}),a.jsx(\"option\",{value:\"finished\",children:t(\"printers.status.finished\")}),a.jsx(\"option\",{value:\"error\",children:t(\"printers.status.error\")}),a.jsx(\"option\",{value:\"offline\",children:t(\"printers.status.offline\")})]}),te&&te.length>0&&Re.length>0&&a.jsxs(\"select\",{value:P,onChange:Ae=>N(Ae.target.value),className:\"text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"all\",children:t(\"printers.filter.allLocations\")}),Re.map(Ae=>a.jsx(\"option\",{value:Ae,children:Ae},Ae))]}),a.jsx(\"div\",{className:\"flex items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:Me.map((Ae,ce)=>{const xe=ce+1,Be=f===xe;return a.jsx(\"button\",{onClick:()=>{y(xe),localStorage.setItem(\"printerCardSize\",String(xe))},className:`px-2 py-1.5 text-xs font-medium transition-colors ${ce===0?\"rounded-l-lg\":\"\"} ${ce===Me.length-1?\"rounded-r-lg\":\"\"} ${Be?\"bg-bambu-green text-white\":\"text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white\"}`,title:t(Ae===\"S\"?\"printers.cardSize.small\":Ae===\"M\"?\"printers.cardSize.medium\":Ae===\"L\"?\"printers.cardSize.large\":\"printers.cardSize.extraLarge\"),children:Ae},Ae)})}),a.jsx(\"button\",{onClick:()=>{R?_e():se(!0)},className:`p-1.5 rounded-lg transition-colors ${R?\"bg-bambu-green text-white\":\"hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,title:t(\"printers.bulk.select\"),disabled:!z(\"printers:control\"),children:a.jsx(Ns,{className:\"w-4 h-4\"})}),a.jsx(\"div\",{className:\"w-px h-6 bg-bambu-dark-tertiary\"}),a.jsxs(\"label\",{className:\"flex items-center gap-2 text-sm text-bambu-gray cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:r,onChange:Te,className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),t(\"printers.hideOffline\")]}),r&&Object.keys(de).length>0&&a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"button\",{onClick:()=>o(!s),className:\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white dark:bg-bambu-dark-secondary border border-gray-200 dark:border-bambu-dark-tertiary rounded-lg text-gray-600 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white hover:border-bambu-green transition-colors\",children:[a.jsx(Yd,{className:\"w-4 h-4\"}),t(\"printers.powerOn\"),a.jsx(On,{className:`w-3 h-3 transition-transform ${s?\"rotate-180\":\"\"}`})]}),s&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>o(!1)}),a.jsxs(\"div\",{className:\"absolute right-0 mt-2 w-56 bg-white dark:bg-bambu-dark-secondary border border-gray-200 dark:border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1\",children:[a.jsx(\"div\",{className:\"px-3 py-2 text-xs text-gray-500 dark:text-bambu-gray border-b border-gray-200 dark:border-bambu-dark-tertiary\",children:t(\"printers.offlinePrintersWithPlugs\")}),te?.filter(Ae=>de[Ae.id]).map(Ae=>a.jsx(H7e,{printer:Ae,plug:de[Ae.id],onPowerOn:ce=>{c(ce),G.mutate(ce)},isPowering:l===de[Ae.id]?.id},Ae.id)),te?.filter(Ae=>de[Ae.id]).length===0&&a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:\"No printers with smart plugs\"})]})]})]}),a.jsxs(De,{onClick:()=>n(!0),disabled:!z(\"printers:create\"),title:z(\"printers:create\")?void 0:t(\"printers.permission.noAdd\"),children:[a.jsx(sr,{className:\"w-4 h-4\"}),t(\"printers.addPrinter\")]})]})]}),ie?a.jsx(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:t(\"common.loading\")}):te?.length===0?a.jsx(Tt,{children:a.jsxs(Mt,{className:\"text-center py-12\",children:[a.jsx(\"p\",{className:\"text-bambu-gray mb-4\",children:t(\"printers.noPrintersConfigured\")}),a.jsxs(De,{onClick:()=>n(!0),disabled:!z(\"printers:create\"),title:z(\"printers:create\")?void 0:t(\"printers.permission.noAdd\"),children:[a.jsx(sr,{className:\"w-4 h-4\"}),t(\"printers.addPrinter\")]})]})}):$e.length===0&&(b.trim()||_!==\"all\"||P!==\"all\")?a.jsx(Tt,{children:a.jsx(Mt,{className:\"text-center py-12\",children:a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"printers.noSearchResults\")})})}):Ve?a.jsx(\"div\",{className:\"space-y-6\",children:(()=>{const Ae=d===\"status\"?O7e.filter(ce=>Ve[ce]?.length>0):Object.keys(Ve);return m?Ae:[...Ae].reverse()})().map(Ae=>{const ce=Ve[Ae],xe=`${d}:${Ae}`,Be=!F[xe],Qe=d===\"status\"&&dK[Ae]?.dot||\"bg-bambu-green\",ht=d===\"status\"?t(dK[Ae]?.labelKey||Ae):Ae;return a.jsx(Sz,{open:Be,onToggle:()=>we(xe),summaryClassName:\"py-1\",summary:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${Qe}`}),ht,a.jsxs(\"span\",{className:\"text-sm font-normal text-bambu-gray\",children:[\"(\",ce.length,\")\"]}),R&&a.jsx(\"button\",{onClick:xt=>{xt.stopPropagation(),d===\"location\"?pe(Ae===\"Ungrouped\"?\"\":Ae):d===\"status\"?tt(Ae):d===\"model\"&&Fe(Ae)},className:\"text-xs text-bambu-green hover:text-bambu-green-light transition-colors ml-1\",children:t(\"printers.bulk.selectAll\")})]}),children:a.jsx(\"div\",{className:`grid gap-4 ${f>=3?\"gap-6\":\"\"} ${Le()}`,children:ce.map(xt=>a.jsx(hK,{printer:xt,hideIfDisconnected:r,maintenanceInfo:U[xt.id],viewMode:v,cardSize:f,amsThresholds:J?{humidityGood:Number(J.ams_humidity_good)||40,humidityFair:Number(J.ams_humidity_fair)||60,tempGood:Number(J.ams_temp_good)||28,tempFair:Number(J.ams_temp_fair)||35}:void 0,spoolmanEnabled:ne,hasUnlinkedSpools:q,linkedSpools:E,spoolmanUrl:W?.url,spoolmanSyncMode:be,onGetAssignment:K,onUnassignSpool:(gt,Ut,Wt)=>O.mutate({printerId:gt,amsId:Ut,trayId:Wt}),timeFormat:J?.time_format||\"system\",cameraViewMode:J?.camera_view_mode||\"window\",onOpenEmbeddedCamera:(gt,Ut)=>L(Wt=>new Map(Wt).set(gt,{id:gt,name:Ut})),checkPrinterFirmware:J?.check_printer_firmware!==!1,dryingPresets:oe,requirePlateClear:J?.require_plate_clear===!0,selectionMode:R,isSelected:X.has(xt.id),onToggleSelect:ae},xt.id))})},Ae)})}):a.jsx(\"div\",{className:`grid gap-4 ${f>=3?\"gap-6\":\"\"} ${Le()}`,children:$e.map(Ae=>a.jsx(hK,{printer:Ae,hideIfDisconnected:r,maintenanceInfo:U[Ae.id],viewMode:v,cardSize:f,spoolmanEnabled:ne,hasUnlinkedSpools:q,linkedSpools:E,spoolmanUrl:W?.url,spoolmanSyncMode:be,onGetAssignment:K,onUnassignSpool:(ce,xe,Be)=>O.mutate({printerId:ce,amsId:xe,trayId:Be}),amsThresholds:J?{humidityGood:Number(J.ams_humidity_good)||40,humidityFair:Number(J.ams_humidity_fair)||60,tempGood:Number(J.ams_temp_good)||28,tempFair:Number(J.ams_temp_fair)||35}:void 0,timeFormat:J?.time_format||\"system\",cameraViewMode:J?.camera_view_mode||\"window\",onOpenEmbeddedCamera:(ce,xe)=>L(Be=>new Map(Be).set(ce,{id:ce,name:xe})),checkPrinterFirmware:J?.check_printer_firmware!==!1,dryingPresets:oe,requirePlateClear:J?.require_plate_clear===!0,selectionMode:R,isSelected:X.has(Ae.id),onToggleSelect:ae},Ae.id))}),e&&a.jsx(I7e,{onClose:()=>n(!1),onAdd:Ae=>I.mutate(Ae),existingSerials:te?.map(Ae=>Ae.serial_number)||[]}),R&&te&&a.jsx(Wye,{selectedIds:X,printers:te,onClose:_e,onSelectAll:Ye,onSelectByLocation:pe,onSelectByState:tt,onAction:ve,actionPending:le}),ge===\"stop\"&&a.jsx(yn,{title:t(\"printers.bulk.confirm.stopTitle\",{count:X.size}),message:t(\"printers.bulk.confirm.stopMessage\",{count:X.size}),confirmText:t(\"printers.bulk.confirm.stopButton\"),variant:\"danger\",isLoading:le,onConfirm:()=>Se(\"stop\"),onCancel:()=>he(null)}),ge===\"pause\"&&a.jsx(yn,{title:t(\"printers.bulk.confirm.pauseTitle\",{count:X.size}),message:t(\"printers.bulk.confirm.pauseMessage\",{count:X.size}),confirmText:t(\"printers.bulk.confirm.pauseButton\"),isLoading:le,onConfirm:()=>Se(\"pause\"),onCancel:()=>he(null)}),ge===\"clearPlate\"&&a.jsx(yn,{title:t(\"printers.bulk.confirm.clearPlateTitle\",{count:X.size}),message:t(\"printers.bulk.confirm.clearPlateMessage\",{count:X.size}),confirmText:t(\"printers.bulk.confirm.clearPlateButton\"),isLoading:le,onConfirm:()=>Se(\"clearPlate\"),onCancel:()=>he(null)}),Array.from(Q.values()).map((Ae,ce)=>a.jsx(KMe,{printerId:Ae.id,printerName:Ae.name,viewerIndex:ce,onClose:()=>L(xe=>{const Be=new Map(xe);return Be.delete(Ae.id),Be})},Ae.id))]})}function $7e(){const t=navigator.userAgent.toLowerCase(),e=navigator.platform?.toLowerCase()||\"\";return t.includes(\"win\")||e.includes(\"win\")?\"windows\":t.includes(\"mac\")||e.includes(\"mac\")?\"macos\":t.includes(\"linux\")||e.includes(\"linux\")?\"linux\":\"unknown\"}function qf(t,e=\"bambu_studio\"){let n;e===\"orcaslicer\"?n=`orcaslicer://open?file=${t}`:$7e()===\"macos\"?n=`bambustudioopen://${encodeURIComponent(t)}`:n=`bambustudio://open?file=${t}`;const r=document.createElement(\"a\");r.href=n,r.style.display=\"none\",document.body.appendChild(r),r.click(),document.body.removeChild(r)}const pK=768;function aie(){const[t,e]=w.useState(()=>typeof window<\"u\"?window.innerWidth<pK:!1);return w.useEffect(()=>{const n=window.matchMedia(`(max-width: ${pK-1}px)`),r=i=>{e(i.matches)};return e(n.matches),n.addEventListener(\"change\",r),()=>n.removeEventListener(\"change\",r)},[]),t}function _z({archiveId:t,libraryFileId:e,title:n,fileType:r,onClose:i}){const{t:s}=Ft(),{data:o}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),l=o?.preferred_slicer||\"bambu_studio\",c=e!=null,[d,u]=w.useState(null),[m,p]=w.useState(null),[f,y]=w.useState(!0),[v,b]=w.useState(null),[g,_]=w.useState(!1),[C,P]=w.useState(null),[N,A]=w.useState(0),[T,F]=w.useState(!1),[k,D]=w.useState(null),[H,z]=w.useState(!1),[Q,L]=w.useState(!1),te=w.useRef(null),ie=w.useRef(null),J=10,oe=160,fe=240,re=.35;w.useEffect(()=>{const he=le=>{le.key===\"Escape\"&&i()};return window.addEventListener(\"keydown\",he),()=>window.removeEventListener(\"keydown\",he)},[i]),w.useEffect(()=>{if(y(!0),c){const he=(r||\"\").toLowerCase(),le=he===\"3mf\"||he===\"stl\",B=he===\"gcode\"||he===\"3mf\";p({has_model:le,has_gcode:B,has_source:!1,build_volume:{x:256,y:256,z:256},filament_colors:[]}),u(le?\"3d\":B?\"gcode\":null),y(!1);return}if(!t){p(null),u(null),y(!1);return}ue.getArchiveCapabilities(t).then(he=>{p(he),he.has_model?u(\"3d\"):he.has_gcode&&u(\"gcode\"),y(!1)}).catch(()=>{p({has_model:!0,has_gcode:!1,has_source:!1,build_volume:{x:256,y:256,z:256},filament_colors:[]}),u(\"3d\"),y(!1)})},[t,r,c]),w.useEffect(()=>{if(_(!0),P(null),A(0),c){const he=(r||\"\").toLowerCase();if(!e||he!==\"3mf\"){b(null),_(!1);return}ue.getLibraryFilePlates(e).then(le=>b(le)).catch(()=>b(null)).finally(()=>_(!1));return}if(!t){b(null),_(!1);return}ue.getArchivePlates(t).then(he=>b(he)).catch(()=>b(null)).finally(()=>_(!1))},[t,r,c,e]);const W=w.useMemo(()=>v?.plates??[],[v]),ne=(v?.is_multi_plate??!1)&&W.length>1,me=T&&ne,be=C==null?null:W.find(he=>he.index===C)??null,Ce=he=>he.object_count??he.objects?.length??0,q=W.reduce((he,le)=>he+Ce(le),0),Y=be?Ce(be):q,E=be?s(\"modelViewer.plateNumber\",{number:be.index}):s(\"modelViewer.allPlates\"),j=W.length>0,O=w.useRef(null),K=w.useRef(null),[U,de]=w.useState(10),[I,G]=w.useState(3),X=W.length>U,V=Math.max(1,Math.ceil(W.length/U)),ee=X?W.slice(N*U,(N+1)*U):W;w.useEffect(()=>{if(!me){de(10),G(3);return}const he=O.current,le=K.current;if(!he||!le)return;let B=0;const R=()=>{const Se=le.clientWidth,Te=Math.floor(Se/210),ye=Math.max(3,Math.min(5,Te||3));G(pe=>pe===ye?pe:ye);const je=window.getComputedStyle(he),Le=Number.parseFloat(je.rowGap||\"0\"),Oe=he.querySelector(\"button\")?.getBoundingClientRect().height??44,Re=le.clientHeight,Ye=Math.max(1,Math.floor((Re+Le)/(Oe+Le)))*ye,tt=Math.max(1,Ye-1);de(pe=>pe===tt?pe:tt)},ae=()=>{B&&cancelAnimationFrame(B),B=requestAnimationFrame(R)};ae();const _e=new ResizeObserver(ae);return _e.observe(le),_e.observe(he),()=>{B&&cancelAnimationFrame(B),_e.disconnect()}},[me,W.length]),w.useEffect(()=>{if(!X){A(0);return}A(he=>Math.min(he,V-1))},[W.length,X,V]),w.useEffect(()=>{if(!X||C==null)return;const he=W.findIndex(B=>B.index===C);if(he<0)return;const le=Math.floor(he/U);A(B=>B===le?B:le)},[W,U,C,X]),w.useEffect(()=>{if(!me){D(null),L(!1);return}if(Q)return;const he=te.current,le=ie.current;if(!he||!le)return;const B=he.clientHeight;if(!B)return;const R=Math.max(fe,B*re),ae=Math.max(oe,B-J-R),_e=Math.min(le.scrollHeight,ae);D(Math.max(oe,_e))},[me,Q,W.length,N,J,oe,fe,re]),w.useEffect(()=>{if(!H)return;const he=B=>{const R=te.current;if(!R)return;const ae=R.getBoundingClientRect(),_e=ae.height;if(!_e)return;const Se=Math.max(fe,_e*re),ve=Math.max(oe,_e-J-Se),Te=Math.min(ve,Math.max(oe,B.clientY-ae.top));D(Te)},le=()=>{z(!1),L(!0)};return document.addEventListener(\"mousemove\",he),document.addEventListener(\"mouseup\",le),document.body.style.cursor=\"row-resize\",document.body.style.userSelect=\"none\",()=>{document.removeEventListener(\"mousemove\",he),document.removeEventListener(\"mouseup\",le),document.body.style.cursor=\"\",document.body.style.userSelect=\"\"}},[H,J,oe,fe,re]);const se=c?(r||\"\").toLowerCase()===\"3mf\":!0,ge=async()=>{if(!se)return;const he=n||\"model\";try{if(c){const{token:le}=await ue.createLibrarySlicerToken(e),B=ue.getLibrarySlicerDownloadUrl(e,le,he);qf(`${window.location.origin}${B}`,l)}else{const{token:le}=await ue.createArchiveSlicerToken(t),B=ue.getArchiveSlicerDownloadUrl(t,le,he);qf(`${window.location.origin}${B}`,l)}}catch{if(c){const le=`${window.location.origin}${ue.getLibraryFileDownloadUrl(e)}`;qf(le,l)}else{const le=`${window.location.origin}${ue.getArchiveForSlicer(t,he)}`;qf(le,l)}}};return a.jsx(\"div\",{className:`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${T?\"p-0\":\"p-8\"}`,onClick:i,children:a.jsxs(\"div\",{className:`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${T?\"h-full max-w-none rounded-none\":\"h-[80vh] max-w-4xl rounded-xl\"}`,onClick:he=>he.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 min-w-0 flex-1 mr-4\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white truncate\",children:n}),j&&a.jsxs(\"span\",{className:\"text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap\",children:[E,\": \",s(\"modelViewer.objectCount\",{count:Y})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:ge,disabled:!se,children:[a.jsx(la,{className:\"w-4 h-4\"}),s(\"modelViewer.openInSlicer\")]}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>F(he=>!he),title:T?\"Exit fullscreen\":\"Enter fullscreen\",children:T?a.jsx(DO,{className:\"w-4 h-4\"}):a.jsx(VP,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:i,children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})]}),m&&a.jsxs(\"div\",{className:\"flex border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"button\",{onClick:()=>m.has_model&&u(\"3d\"),disabled:!m.has_model,className:`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${d===\"3d\"?\"text-bambu-green border-b-2 border-bambu-green\":m.has_model?\"text-bambu-gray hover:text-white\":\"text-bambu-gray/30 cursor-not-allowed\"}`,children:[a.jsx(vi,{className:\"w-4 h-4\"}),s(\"modelViewer.tabs.model\"),!m.has_model&&a.jsxs(\"span\",{className:\"text-xs\",children:[\"(\",s(\"modelViewer.notAvailable\"),\")\"]})]}),a.jsxs(\"button\",{onClick:()=>m.has_gcode&&u(\"gcode\"),disabled:!m.has_gcode,className:`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${d===\"gcode\"?\"text-bambu-green border-b-2 border-bambu-green\":m.has_gcode?\"text-bambu-gray hover:text-white\":\"text-bambu-gray/30 cursor-not-allowed\"}`,children:[a.jsx(wfe,{className:\"w-4 h-4\"}),s(\"modelViewer.tabs.gcode\"),!m.has_gcode&&a.jsxs(\"span\",{className:\"text-xs\",children:[\"(\",s(\"modelViewer.notSliced\"),\")\"]})]})]}),a.jsx(\"div\",{className:\"flex-1 overflow-hidden p-4\",children:f?a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center\",children:a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green\"})}):d===\"3d\"&&m?a.jsxs(\"div\",{ref:te,className:`w-full h-full flex flex-col ${me?\"gap-0 min-h-0\":\"gap-3\"}`,children:[ne&&a.jsxs(\"div\",{ref:ie,style:me&&k!=null?{height:k}:void 0,className:`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${me?\"flex flex-col shrink-0\":\"\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray mb-2\",children:[a.jsx(da,{className:\"w-4 h-4\"}),s(\"modelViewer.plates\"),g&&a.jsx(ft,{className:\"w-3 h-3 animate-spin\"})]}),a.jsxs(\"div\",{className:me?\"flex flex-col min-h-0 flex-1\":void 0,children:[a.jsx(\"div\",{ref:K,className:me?\"min-h-0 overflow-hidden pr-1 flex-1\":void 0,children:a.jsxs(\"div\",{ref:O,className:me?\"grid gap-2\":\"grid grid-cols-2 md:grid-cols-3 gap-2\",style:me?{gridTemplateColumns:`repeat(${I}, minmax(0, 1fr))`}:void 0,children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>P(null),className:`flex items-center rounded-lg border text-left transition-colors ${me?\"gap-1.5 p-1.5 w-full\":\"gap-2 p-2\"} ${C==null?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray\"}`,children:[a.jsx(\"div\",{className:`rounded bg-bambu-dark-tertiary flex items-center justify-center ${me?\"w-8 h-8\":\"w-10 h-10\"}`,children:a.jsx(da,{className:`${me?\"w-4 h-4\":\"w-5 h-5\"} text-bambu-gray`})}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:`${me?\"text-xs\":\"text-sm\"} text-white font-medium truncate`,children:s(\"modelViewer.allPlates\")}),a.jsx(\"p\",{className:`${me?\"text-[10px]\":\"text-xs\"} text-bambu-gray truncate`,children:s(\"modelViewer.plateCount\",{count:W.length})})]}),C==null&&a.jsx(Ur,{className:`${me?\"w-3.5 h-3.5\":\"w-4 h-4\"} text-bambu-green flex-shrink-0`})]}),ee.map(he=>a.jsxs(\"button\",{type:\"button\",onClick:()=>P(he.index),className:`flex items-center rounded-lg border text-left transition-colors ${me?\"gap-1.5 p-1.5 w-full\":\"gap-2 p-2\"} ${C===he.index?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray\"}`,children:[he.has_thumbnail&&he.thumbnail_url?a.jsx(\"img\",{src:he.thumbnail_url,alt:`Plate ${he.index}`,className:`${me?\"w-8 h-8\":\"w-10 h-10\"} rounded object-cover bg-bambu-dark-tertiary`}):a.jsx(\"div\",{className:`rounded bg-bambu-dark-tertiary flex items-center justify-center ${me?\"w-8 h-8\":\"w-10 h-10\"}`,children:a.jsx(da,{className:`${me?\"w-4 h-4\":\"w-5 h-5\"} text-bambu-gray`})}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:`${me?\"text-xs\":\"text-sm\"} text-white font-medium truncate`,children:he.name||s(\"modelViewer.plateNumber\",{number:he.index})}),a.jsx(\"p\",{className:`${me?\"text-[10px]\":\"text-xs\"} text-bambu-gray truncate`,children:s(\"modelViewer.objectCount\",{count:he.object_count??he.objects?.length??0})})]}),C===he.index&&a.jsx(Ur,{className:`${me?\"w-3.5 h-3.5\":\"w-4 h-4\"} text-bambu-green flex-shrink-0`})]},he.index))]})}),(be||X)&&a.jsxs(\"div\",{className:\"mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto\",children:[be&&a.jsxs(\"div\",{className:\"flex items-center gap-3 whitespace-nowrap\",children:[a.jsx(\"span\",{children:s(\"modelViewer.plateNumber\",{number:be.index})}),be.print_time_seconds!=null&&a.jsx(\"span\",{children:s(\"modelViewer.eta\",{minutes:Math.round(be.print_time_seconds/60)})}),be.filament_used_grams!=null&&a.jsxs(\"span\",{children:[be.filament_used_grams.toFixed(1),\" g\"]}),be.filaments.length>0&&a.jsx(\"span\",{children:s(\"modelViewer.filamentCount\",{count:be.filaments.length})})]}),X&&a.jsxs(\"div\",{className:`flex items-center gap-2 whitespace-nowrap ${be?\"ml-auto\":\"\"}`,children:[a.jsx(\"span\",{children:s(\"modelViewer.pagination.pageOf\",{current:N+1,total:V})}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"button\",{type:\"button\",onClick:()=>A(he=>Math.max(he-1,0)),disabled:N===0,className:`px-2 py-1 rounded border text-xs ${N===0?\"border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed\":\"border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray\"}`,children:s(\"modelViewer.pagination.prev\")}),(()=>{let le=Math.max(0,N-Math.floor(2.5));const B=Math.min(V,le+5);B-le<5&&(le=Math.max(0,B-5));const R=Array.from({length:B-le},(ae,_e)=>le+_e);return a.jsxs(a.Fragment,{children:[le>0&&a.jsx(\"button\",{type:\"button\",onClick:()=>A(0),className:`px-2 py-1 rounded border text-xs ${N===0?\"border-bambu-green text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray\"}`,children:\"1\"}),le>1&&a.jsx(\"span\",{className:\"px-1\",children:\"…\"}),R.map(ae=>a.jsx(\"button\",{type:\"button\",onClick:()=>A(ae),className:`px-2 py-1 rounded border text-xs ${N===ae?\"border-bambu-green text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray\"}`,children:ae+1},ae)),B<V-1&&a.jsx(\"span\",{className:\"px-1\",children:\"…\"}),B<V&&a.jsx(\"button\",{type:\"button\",onClick:()=>A(V-1),className:`px-2 py-1 rounded border text-xs ${N===V-1?\"border-bambu-green text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray\"}`,children:V})]})})(),a.jsx(\"button\",{type:\"button\",onClick:()=>A(he=>Math.min(he+1,V-1)),disabled:N>=V-1,className:`px-2 py-1 rounded border text-xs ${N>=V-1?\"border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed\":\"border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray\"}`,children:s(\"modelViewer.pagination.next\")})]})]})]})]})]}),me&&a.jsx(\"div\",{role:\"separator\",\"aria-orientation\":\"horizontal\",onMouseDown:he=>{he.preventDefault(),z(!0),L(!0)},className:`h-2 cursor-row-resize flex items-center justify-center ${H?\"bg-bambu-dark-tertiary\":\"bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary\"}`,children:a.jsx(\"div\",{className:\"w-12 h-1 rounded-full bg-bambu-gray/50\"})}),a.jsx(\"div\",{className:`flex-1 ${me?\"min-h-0\":\"\"}`,children:a.jsx(KZ,{url:c?ue.getLibraryFileDownloadUrl(e):m.has_source?ue.getSource3mfDownloadUrl(t):ue.getArchiveDownload(t),fileType:r,buildVolume:m.build_volume,filamentColors:m.filament_colors,selectedPlateId:C,className:\"w-full h-full\"})})]}):d===\"gcode\"&&m?a.jsx(jJ,{gcodeUrl:c?ue.getLibraryFileGcodeUrl(e):ue.getArchiveGcode(t),filamentColors:m.filament_colors,className:\"w-full h-full\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center text-bambu-gray\",children:s(\"modelViewer.noPreview\")})})]})})}function V7e({onClose:t,initialFiles:e}){const{t:n}=Ft(),r=nn(),{showToast:i}=hn(),s=w.useRef(null),[o,l]=w.useState(()=>e?.filter(N=>N.name.endsWith(\".3mf\")).map(N=>({file:N,status:\"pending\"}))||[]),[c,d]=w.useState(!1),[u,m]=w.useState(null);w.useEffect(()=>{const N=A=>{A.key===\"Escape\"&&t()};return window.addEventListener(\"keydown\",N),()=>window.removeEventListener(\"keydown\",N)},[t]);const p=it({mutationFn:N=>ue.uploadArchivesBulk(N),onSuccess:N=>{m(N),r.invalidateQueries({queryKey:[\"archives\"]}),r.invalidateQueries({queryKey:[\"archiveStats\"]}),l(A=>A.map(T=>{const F=N.results.find(D=>D.filename===T.file.name),k=N.errors.find(D=>D.filename===T.file.name);return F?{...T,status:\"success\",archiveId:F.id}:k?{...T,status:\"error\",error:k.error}:T})),N.failed===0?i(`${N.uploaded} file${N.uploaded!==1?\"s\":\"\"} uploaded`):N.uploaded===0?i(`Failed to upload ${N.failed} file${N.failed!==1?\"s\":\"\"}`,\"error\"):i(`${N.uploaded} uploaded, ${N.failed} failed`,\"warning\")},onError:()=>{l(N=>N.map(A=>({...A,status:\"error\",error:\"Upload failed\"}))),i(\"Upload failed\",\"error\")}}),f=w.useCallback(N=>{N.preventDefault(),d(!0)},[]),y=w.useCallback(N=>{N.preventDefault(),d(!1)},[]),v=w.useCallback(N=>{N.preventDefault(),d(!1);const A=Array.from(N.dataTransfer.files).filter(T=>T.name.endsWith(\".3mf\"));A.length>0&&l(T=>[...T,...A.map(F=>({file:F,status:\"pending\"}))])},[]),b=w.useCallback(N=>{const A=Array.from(N.target.files||[]).filter(T=>T.name.endsWith(\".3mf\"));A.length>0&&l(T=>[...T,...A.map(F=>({file:F,status:\"pending\"}))]),s.current&&(s.current.value=\"\")},[]),g=w.useCallback(N=>{l(A=>A.filter((T,F)=>F!==N))},[]),_=()=>{if(o.length===0)return;const N=o.filter(A=>A.status===\"pending\");N.length!==0&&(l(A=>A.map(T=>T.status===\"pending\"?{...T,status:\"uploading\"}:T)),p.mutate(N.map(A=>A.file)))},C=o.filter(N=>N.status===\"pending\").length,P=p.isPending;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:a.jsx(Tt,{className:\"w-full max-w-2xl max-h-[90vh] flex flex-col\",children:a.jsxs(Mt,{className:\"p-0 flex flex-col h-full\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:n(\"uploadModal.title\")}),a.jsx(\"button\",{onClick:t,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsx(\"div\",{className:\"p-4\",children:a.jsxs(\"div\",{className:`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${c?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary hover:border-bambu-gray\"}`,onDragOver:f,onDragLeave:y,onDrop:v,children:[a.jsx(La,{className:\"w-12 h-12 mx-auto mb-4 text-bambu-gray\"}),a.jsx(\"p\",{className:\"text-white mb-2\",children:n(\"uploadModal.dragDrop\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm mb-4\",children:n(\"uploadModal.or\")}),a.jsx(De,{variant:\"secondary\",onClick:()=>s.current?.click(),disabled:P,children:n(\"uploadModal.browseFiles\")}),a.jsx(\"input\",{ref:s,type:\"file\",accept:\".3mf\",multiple:!0,className:\"hidden\",onChange:b})]})}),a.jsx(\"div\",{className:\"px-4 pb-4\",children:a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"uploadModal.extractionInfo\")})}),o.length>0&&a.jsx(\"div\",{className:\"px-4 pb-4 max-h-60 overflow-y-auto\",children:a.jsx(\"div\",{className:\"space-y-2\",children:o.map((N,A)=>a.jsxs(\"div\",{className:\"flex items-center gap-3 p-3 bg-bambu-dark rounded-lg\",children:[a.jsx(qP,{className:\"w-5 h-5 text-bambu-gray flex-shrink-0\"}),a.jsx(\"span\",{className:\"flex-1 text-white text-sm truncate\",children:N.file.name}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[(N.file.size/(1024*1024)).toFixed(1),\" MB\"]}),N.status===\"pending\"&&a.jsx(\"button\",{onClick:()=>g(A),className:\"text-bambu-gray hover:text-red-400 transition-colors\",disabled:P,children:a.jsx(Ht,{className:\"w-4 h-4\"})}),N.status===\"uploading\"&&a.jsx(ft,{className:\"w-4 h-4 text-bambu-green animate-spin\"}),N.status===\"success\"&&a.jsx(yr,{className:\"w-4 h-4 text-bambu-green\"}),N.status===\"error\"&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-xs text-red-400\",children:N.error}),a.jsx(ei,{className:\"w-4 h-4 text-red-400\"})]})]},`${N.file.name}-${A}`))})}),u&&a.jsx(\"div\",{className:\"px-4 pb-4\",children:a.jsx(\"div\",{className:\"p-3 bg-bambu-dark rounded-lg\",children:a.jsxs(\"p\",{className:\"text-sm text-white\",children:[a.jsx(\"span\",{className:\"text-bambu-green\",children:u.uploaded}),\" \",n(\"uploadModal.uploaded\"),u.failed>0&&a.jsxs(a.Fragment,{children:[\", \",a.jsx(\"span\",{className:\"text-red-400\",children:u.failed}),\" \",n(\"uploadModal.failed\")]})]})})}),a.jsxs(\"div\",{className:\"flex gap-3 p-4 border-t border-bambu-dark-tertiary\",children:[a.jsx(De,{variant:\"secondary\",onClick:t,className:\"flex-1\",children:n(u?\"common.close\":\"common.cancel\")}),!u&&a.jsx(De,{onClick:_,disabled:C===0||P,className:\"flex-1\",children:P?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),n(\"uploadModal.uploading\")]}):a.jsxs(a.Fragment,{children:[a.jsx(La,{className:\"w-4 h-4\"}),n(\"uploadModal.upload\"),\" \",C>0&&`(${C})`]})})]})]})})})}const G7e=[\"adhesionFailure\",\"spaghettiDetached\",\"layerShift\",\"cloggedNozzle\",\"filamentRunout\",\"warping\",\"stringing\",\"underExtrusion\",\"powerFailure\",\"userCancelled\",\"other\"],W7e=[\"completed\",\"failed\",\"aborted\",\"printing\"];function iie({archive:t,onClose:e,existingTags:n=[]}){const{t:r}=Ft();w.useEffect(()=>{const j=O=>{O.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",j),()=>window.removeEventListener(\"keydown\",j)},[e]);const i=nn(),[s,o]=w.useState(t.print_name||\"\"),[l,c]=w.useState(t.printer_id),[d,u]=w.useState(t.project_id??null),[m,p]=w.useState(t.notes||\"\"),[f,y]=w.useState(t.tags||\"\"),[v,b]=w.useState(t.failure_reason||\"\"),[g,_]=w.useState(t.status),[C,P]=w.useState(t.quantity??1),[N,A]=w.useState(t.photos||[]),[T,F]=w.useState(t.external_url||\"\"),[k,D]=w.useState(!1),[H,z]=w.useState(!1),Q=w.useRef(null),L=w.useRef(null),te=w.useRef(null),{data:ie}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:J}=Xe({queryKey:[\"projects\"],queryFn:()=>ue.getProjects()}),{data:oe}=Xe({queryKey:[\"tags\"],queryFn:ue.getTags,enabled:n.length===0}),fe=n.length>0?n:oe?.map(j=>j.name)||[],re=f.split(\",\").map(j=>j.trim()).filter(Boolean),W=f.includes(\",\")?f.substring(f.lastIndexOf(\",\")+1).trim().toLowerCase():f.trim().toLowerCase(),ne=fe.filter(j=>!re.includes(j)&&(W===\"\"||j.toLowerCase().includes(W))),me=j=>{let O;if(W&&!fe.includes(W)?O=f.includes(\",\")?f.substring(0,f.lastIndexOf(\",\")).split(\",\").map(K=>K.trim()).filter(Boolean):[]:O=re,!O.includes(j)){const K=[...O,j].join(\", \");y(K)}te.current!==null&&clearTimeout(te.current),Q.current?.focus()},be=j=>{const O=re.filter(K=>K!==j).join(\", \");y(O)},Ce=it({mutationFn:j=>ue.updateArchive(t.id,j),onSuccess:()=>{i.invalidateQueries({queryKey:[\"archives\"]}),i.invalidateQueries({queryKey:[\"projects\"]}),e()}}),q=async j=>{const O=j.target.files?.[0];if(O){D(!0);try{const K=await ue.uploadArchivePhoto(t.id,O);A(K.photos),i.invalidateQueries({queryKey:[\"archives\"]})}catch(K){console.error(\"Failed to upload photo:\",K)}finally{D(!1),L.current&&(L.current.value=\"\")}}},Y=async j=>{try{const O=await ue.deleteArchivePhoto(t.id,j);A(O.photos||[]),i.invalidateQueries({queryKey:[\"archives\"]})}catch(O){console.error(\"Failed to delete photo:\",O)}},E=j=>{j.preventDefault();const O={print_name:s||void 0,printer_id:l,project_id:d,notes:m||void 0,tags:f||void 0,quantity:C,external_url:T||null};g!==t.status&&(O.status=g),g===\"failed\"||g===\"aborted\"?O.failure_reason=v||void 0:(t.status===\"failed\"||t.status===\"aborted\")&&(O.failure_reason=null),Ce.mutate(O)};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:e,children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col\",onClick:j=>j.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:r(\"editArchive.title\")}),a.jsx(\"button\",{onClick:e,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"form\",{onSubmit:E,className:\"p-6 space-y-4 overflow-y-auto flex-1\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"editArchive.name\")}),a.jsx(\"input\",{type:\"text\",value:s,onChange:j=>o(j.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\",placeholder:r(\"editArchive.namePlaceholder\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"editArchive.printer\")}),a.jsxs(\"select\",{value:l??\"\",onChange:j=>c(j.target.value?Number(j.target.value):null),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\",children:[a.jsx(\"option\",{value:\"\",children:r(\"editArchive.noPrinter\")}),ie?.map(j=>a.jsx(\"option\",{value:j.id,children:j.name},j.id))]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[a.jsx(Bs,{className:\"w-4 h-4 inline mr-1\"}),r(\"editArchive.project\")]}),a.jsxs(\"select\",{value:d??\"\",onChange:j=>u(j.target.value?Number(j.target.value):null),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\",children:[a.jsx(\"option\",{value:\"\",children:r(\"editArchive.noProject\")}),J?.map(j=>a.jsx(\"option\",{value:j.id,children:j.name},j.id))]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[a.jsx(Oge,{className:\"w-4 h-4 inline mr-1\"}),r(\"editArchive.itemsPrinted\")]}),a.jsx(\"input\",{type:\"number\",min:1,value:C,onChange:j=>P(Math.max(1,parseInt(j.target.value)||1)),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\",placeholder:\"1\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:r(\"editArchive.itemsPrintedHelp\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"editArchive.notes\")}),a.jsx(\"textarea\",{value:m,onChange:j=>p(j.target.value),rows:3,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 resize-none\",placeholder:r(\"editArchive.notesPlaceholder\")})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[a.jsx(Sy,{className:\"w-4 h-4 inline mr-1\"}),r(\"editArchive.externalLink\")]}),a.jsx(\"input\",{type:\"url\",value:T,onChange:j=>F(j.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\",placeholder:\"https://printables.com/model/...\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:r(\"editArchive.externalLinkHelp\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"editArchive.tags\")}),re.length>0&&a.jsx(\"div\",{className:\"flex flex-wrap gap-1.5 mb-2\",children:re.map(j=>a.jsxs(\"span\",{className:\"inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white\",children:[a.jsx(xm,{className:\"w-3 h-3\"}),j,a.jsx(\"button\",{type:\"button\",onClick:()=>be(j),className:\"ml-0.5 text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-3 h-3\"})})]},j))}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"input\",{ref:Q,type:\"text\",value:f,onChange:j=>y(j.target.value),onFocus:()=>{te.current!==null&&clearTimeout(te.current),z(!0)},onBlur:()=>{te.current=window.setTimeout(()=>z(!1),200)},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\",placeholder:re.length>0?r(\"editArchive.addMoreTags\"):r(\"editArchive.tagsPlaceholder\")}),H&&ne.length>0&&a.jsxs(\"div\",{className:\"absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto\",children:[a.jsxs(\"div\",{className:\"p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary\",children:[W?r(\"editArchive.matchingTags\",{query:W}):r(\"editArchive.existingTags\"),\" \",r(\"editArchive.clickToAdd\")]}),a.jsx(\"div\",{className:\"p-2 flex flex-wrap gap-1.5\",children:ne.map(j=>a.jsx(\"button\",{type:\"button\",onClick:()=>me(j),className:\"px-2 py-0.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded text-sm text-bambu-gray hover:text-white transition-colors\",children:j},j))})]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"editArchive.status\")}),a.jsx(\"select\",{value:g,onChange:j=>{_(j.target.value),j.target.value===\"completed\"&&b(\"\")},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\",children:W7e.map(j=>a.jsx(\"option\",{value:j,children:r(`editArchive.statuses.${j}`)},j))})]}),(g===\"failed\"||g===\"aborted\")&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"editArchive.failureReason\")}),a.jsxs(\"select\",{value:v,onChange:j=>b(j.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\",children:[a.jsx(\"option\",{value:\"\",children:r(\"editArchive.selectReason\")}),G7e.map(j=>a.jsx(\"option\",{value:r(`editArchive.failureReasons.${j}`),children:r(`editArchive.failureReasons.${j}`)},j))]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[a.jsx(qx,{className:\"w-4 h-4 inline mr-1\"}),r(\"editArchive.photos\")]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-2 mb-2\",children:[N.map(j=>a.jsxs(\"div\",{className:\"relative group\",children:[a.jsx(\"img\",{src:ue.getArchivePhotoUrl(t.id,j),alt:r(\"editArchive.printResult\"),className:\"w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary\"}),a.jsx(\"button\",{type:\"button\",onClick:()=>Y(j),className:\"absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity\",children:a.jsx(en,{className:\"w-3 h-3 text-white\"})})]},j)),a.jsxs(\"label\",{className:\"w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors\",children:[a.jsx(\"input\",{ref:L,type:\"file\",accept:\"image/jpeg,image/png,image/webp\",onChange:q,className:\"hidden\",disabled:k}),k?a.jsx(ft,{className:\"w-6 h-6 text-bambu-gray animate-spin\"}):a.jsx(sr,{className:\"w-6 h-6 text-bambu-gray\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:r(\"editArchive.photosHelp\")})]}),a.jsxs(\"div\",{className:\"flex gap-3 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:e,className:\"flex-1\",children:r(\"common.cancel\")}),a.jsxs(De,{type:\"submit\",disabled:Ce.isPending,className:\"flex-1\",children:[a.jsx(_s,{className:\"w-4 h-4\"}),Ce.isPending?r(\"common.saving\"):r(\"common.save\")]})]})]})]})})}function sie({x:t,y:e,items:n,onClose:r}){const i=w.useRef(null),[s,o]=w.useState(null),l=w.useRef(null),[c,d]=w.useState({x:t,y:e,visible:!1}),[u,m]=w.useState(!1),[p,f]=w.useState({});w.useEffect(()=>{const b=C=>{i.current&&!i.current.contains(C.target)&&r()},g=C=>{C.key===\"Escape\"&&r()},_=()=>{r()};return document.addEventListener(\"mousedown\",b),document.addEventListener(\"keydown\",g),document.addEventListener(\"scroll\",_,!0),()=>{document.removeEventListener(\"mousedown\",b),document.removeEventListener(\"keydown\",g),document.removeEventListener(\"scroll\",_,!0),l.current&&clearTimeout(l.current)}},[r]),w.useLayoutEffect(()=>{if(i.current){i.current.style.visibility=\"hidden\",i.current.style.display=\"block\";const b=i.current.getBoundingClientRect(),g=window.innerWidth,_=window.innerHeight,C=8;let P=t,N=e;t+b.width>g-C&&(P=Math.max(C,g-b.width-C)),P<C&&(P=C),e+b.height>_-C&&(N=Math.max(C,_-b.height-C)),N<C&&(N=C);const A=180,T=g-P-b.width,F=P;m(T<A&&F>A),d({x:P,y:N,visible:!0})}},[t,e]);const y=(b,g)=>{l.current&&(clearTimeout(l.current),l.current=null);const _=g.getBoundingClientRect(),C=window.innerHeight,P=300,T=C-_.top-8<P&&_.top>P;f(F=>({...F,[b]:T?\"bottom\":\"top\"})),o(b)},v=()=>{l.current=window.setTimeout(()=>{o(null)},150)};return a.jsx(\"div\",{ref:i,className:\"fixed z-50 min-w-[180px] max-w-[280px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1\",style:{left:c.x,top:c.y,visibility:c.visible?\"visible\":\"hidden\"},children:n.map((b,g)=>{if(b.divider)return a.jsx(\"div\",{className:\"my-1 border-t border-bambu-dark-tertiary\"},g);const _=b.submenu&&b.submenu.length>0;return a.jsxs(\"div\",{className:\"relative\",onMouseEnter:C=>_&&y(g,C.currentTarget),onMouseLeave:()=>_&&v(),children:[a.jsxs(\"button\",{onMouseEnter:C=>_&&y(g,C.currentTarget.parentElement),onClick:()=>{_?o(s===g?null:g):b.disabled||(b.onClick(),r())},disabled:b.disabled,title:b.title,className:`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${b.disabled?\"text-bambu-gray cursor-not-allowed\":b.danger?\"text-red-400 hover:bg-red-400/10\":\"text-white hover:bg-bambu-dark-tertiary\"} ${_&&s===g?\"bg-bambu-dark-tertiary\":\"\"}`,children:[b.icon&&a.jsx(\"span\",{className:\"w-4 h-4 flex-shrink-0 flex items-center justify-center\",children:b.icon}),a.jsx(\"span\",{className:\"flex-1\",children:b.label}),_&&a.jsx(ti,{className:\"w-4 h-4 text-bambu-gray\"})]}),_&&s===g&&a.jsx(\"div\",{className:`absolute min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden max-h-[300px] overflow-y-auto z-[60] ${u?\"right-full mr-1\":\"left-full ml-1\"} ${p[g]===\"bottom\"?\"bottom-0\":\"top-0\"}`,onMouseEnter:()=>{l.current&&(clearTimeout(l.current),l.current=null)},onMouseLeave:()=>v(),children:b.submenu.map((C,P)=>a.jsxs(\"button\",{onClick:()=>{C.disabled||(C.onClick(),r())},disabled:C.disabled,className:`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${C.disabled?\"text-bambu-gray cursor-not-allowed\":C.danger?\"text-red-400 hover:bg-red-400/10\":\"text-white hover:bg-bambu-dark-tertiary\"}`,children:[C.icon&&a.jsx(\"span\",{className:\"w-4 h-4 flex-shrink-0 flex items-center justify-center\",children:C.icon}),C.label]},P))})]},g)})})}function K7e({selectedIds:t,existingTags:e,onClose:n}){const r=nn(),{showToast:i}=hn(),[s,o]=w.useState(\"\"),[l,c]=w.useState(new Set),[d,u]=w.useState(\"add\");w.useEffect(()=>{const v=b=>{b.key===\"Escape\"&&n()};return window.addEventListener(\"keydown\",v),()=>window.removeEventListener(\"keydown\",v)},[n]);const m=it({mutationFn:async()=>{const v=Array.from(l);let b=0;for(const g of t)try{const _=await ue.getArchive(g),C=_.tags?_.tags.split(\",\").map(N=>N.trim()).filter(Boolean):[];let P;d===\"add\"?P=[...new Set([...C,...v])]:P=C.filter(N=>!l.has(N)),await ue.updateArchive(g,{tags:P.join(\", \")}),b++}catch(_){throw console.error(`Failed to update archive ${g}:`,_),new Error(`Failed on archive ${g}: ${_ instanceof Error?_.message:\"Unknown error\"}`)}return{count:b,mode:d,tags:v}},onSuccess:({count:v,mode:b,tags:g})=>{r.invalidateQueries({queryKey:[\"archives\"]}),i(`${b===\"add\"?\"Added\":\"Removed\"} ${g.length} tag${g.length!==1?\"s\":\"\"} ${b===\"add\"?\"to\":\"from\"} ${v} archive${v!==1?\"s\":\"\"}`),n()},onError:v=>{i(v.message||\"Failed to update tags\",\"error\")}}),p=v=>{c(b=>{const g=new Set(b);return g.has(v)?g.delete(v):g.add(v),g})},f=()=>{s.trim()&&!l.has(s.trim())&&(c(v=>new Set([...v,s.trim()])),o(\"\"))},y=v=>{v.key===\"Enter\"&&(v.preventDefault(),f())};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:a.jsx(Tt,{className:\"w-full max-w-md\",children:a.jsxs(Mt,{className:\"p-0\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(xm,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:d===\"add\"?\"Add Tags\":\"Remove Tags\"})]}),a.jsx(\"button\",{onClick:n,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-4\",children:[a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[d===\"add\"?\"Add\":\"Remove\",\" tags for \",t.length,\" selected archive\",t.length!==1?\"s\":\"\"]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{size:\"sm\",variant:d===\"add\"?\"primary\":\"secondary\",onClick:()=>u(\"add\"),children:\"Add Tags\"}),a.jsx(De,{size:\"sm\",variant:d===\"remove\"?\"primary\":\"secondary\",onClick:()=>u(\"remove\"),children:\"Remove Tags\"})]}),d===\"add\"&&a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"input\",{type:\"text\",placeholder:\"Enter new tag...\",className:\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",value:s,onChange:v=>o(v.target.value),onKeyDown:y}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:f,disabled:!s.trim(),children:a.jsx(sr,{className:\"w-4 h-4\"})})]}),e.length>0&&a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:\"Existing tags:\"}),a.jsx(\"div\",{className:\"flex flex-wrap gap-2\",children:e.map(v=>a.jsx(\"button\",{onClick:()=>p(v),className:`px-2 py-1 rounded text-sm transition-colors ${l.has(v)?\"bg-bambu-green text-white\":\"bg-bambu-dark-tertiary text-bambu-gray-light hover:bg-bambu-dark\"}`,children:v},v))})]}),l.size>0&&a.jsxs(\"div\",{children:[a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:[\"Tags to \",d===\"add\"?\"add\":\"remove\",\":\"]}),a.jsx(\"div\",{className:\"flex flex-wrap gap-2\",children:Array.from(l).map(v=>a.jsxs(\"span\",{className:`px-2 py-1 rounded text-sm flex items-center gap-1 ${d===\"add\"?\"bg-green-500/20 text-green-400\":\"bg-red-500/20 text-red-400\"}`,children:[v,a.jsx(\"button\",{onClick:()=>p(v),className:\"hover:opacity-70\",children:a.jsx(Ht,{className:\"w-3 h-3\"})})]},v))})]})]}),a.jsxs(\"div\",{className:\"flex gap-3 p-4 border-t border-bambu-dark-tertiary\",children:[a.jsx(De,{variant:\"secondary\",onClick:n,className:\"flex-1\",children:\"Cancel\"}),a.jsx(De,{onClick:()=>m.mutate(),disabled:l.size===0||m.isPending,className:\"flex-1\",children:m.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),\"Processing...\"]}):a.jsxs(a.Fragment,{children:[a.jsx(xm,{className:\"w-4 h-4\"}),d===\"add\"?\"Add Tags\":\"Remove Tags\"]})})]})]})})})}function X7e({selectedIds:t,onClose:e}){const n=nn(),{showToast:r}=hn(),{data:i,isLoading:s}=Xe({queryKey:[\"projects\"],queryFn:()=>ue.getProjects()});w.useEffect(()=>{const u=m=>{m.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",u),()=>window.removeEventListener(\"keydown\",u)},[e]);const o=()=>{n.invalidateQueries({queryKey:[\"archives\"]}),n.invalidateQueries({queryKey:[\"projects\"]}),n.invalidateQueries({queryKey:[\"project\"]}),n.invalidateQueries({queryKey:[\"project-archives\"]})},l=it({mutationFn:async u=>(await ue.addArchivesToProject(u,t),u),onSuccess:u=>{const m=i?.find(p=>p.id===u);o(),r(`Added ${t.length} archive${t.length!==1?\"s\":\"\"} to \"${m?.name}\"`),e()},onError:()=>{r(\"Failed to assign project\",\"error\")}}),c=it({mutationFn:async()=>{for(const u of t)await ue.updateArchive(u,{project_id:null});return t.length},onSuccess:u=>{o(),r(`Removed ${u} archive${u!==1?\"s\":\"\"} from project`),e()},onError:()=>{r(\"Failed to remove from project\",\"error\")}}),d=l.isPending||c.isPending;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:a.jsx(Tt,{className:\"w-full max-w-md max-h-[80vh] flex flex-col\",children:a.jsxs(Mt,{className:\"p-0 flex flex-col min-h-0\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Bs,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:\"Assign to Project\"})]}),a.jsx(\"button\",{onClick:e,className:\"text-bambu-gray hover:text-white transition-colors\",disabled:d,children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-3 overflow-y-auto min-h-0\",children:[a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[\"Assign \",t.length,\" selected archive\",t.length!==1?\"s\":\"\",\" to a project\"]}),s?a.jsx(\"div\",{className:\"flex items-center justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-gray\"})}):a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"button\",{onClick:()=>c.mutate(),disabled:d,className:\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50\",children:[a.jsx(\"div\",{className:\"w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0\",children:a.jsx(Ma,{className:\"w-4 h-4 text-red-400\"})}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:\"Remove from project\"}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray truncate\",children:\"Clear project assignment\"})]}),c.isPending&&a.jsx(ft,{className:\"w-4 h-4 animate-spin text-bambu-gray shrink-0\"})]}),i&&i.length>0&&a.jsxs(\"div\",{className:\"flex items-center gap-2 py-2\",children:[a.jsx(\"div\",{className:\"flex-1 h-px bg-bambu-dark-tertiary\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"or assign to\"}),a.jsx(\"div\",{className:\"flex-1 h-px bg-bambu-dark-tertiary\"})]}),i?.map(u=>a.jsxs(\"button\",{onClick:()=>l.mutate(u.id),disabled:d,className:\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50\",children:[a.jsx(\"div\",{className:\"w-8 h-8 rounded-full flex items-center justify-center shrink-0\",style:{backgroundColor:u.color?`${u.color}20`:\"rgb(var(--bambu-green) / 0.2)\"},children:a.jsx(Bs,{className:\"w-4 h-4\",style:{color:u.color||\"rgb(var(--bambu-green))\"}})}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"text-white font-medium truncate\",children:u.name}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray truncate\",children:[u.archive_count,\" archive\",u.archive_count!==1?\"s\":\"\",u.status&&` • ${u.status}`]})]}),l.isPending&&l.variables===u.id&&a.jsx(ft,{className:\"w-4 h-4 animate-spin text-bambu-gray shrink-0\"})]},u.id)),(!i||i.length===0)&&a.jsx(\"p\",{className:\"text-center text-bambu-gray py-4\",children:\"No projects yet. Create one from the Projects page.\"})]})]}),a.jsx(\"div\",{className:\"flex gap-3 p-4 border-t border-bambu-dark-tertiary shrink-0\",children:a.jsx(De,{variant:\"secondary\",onClick:e,className:\"flex-1\",disabled:d,children:\"Cancel\"})})]})})})}function Y7e(t,e){return new Date(t,e+1,0).getDate()}function Q7e(t,e){return new Date(t,e,1).getDay()}const Z7e=[\"January\",\"February\",\"March\",\"April\",\"May\",\"June\",\"July\",\"August\",\"September\",\"October\",\"November\",\"December\"],J7e=[\"Sun\",\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\"];function eGe({archives:t,onArchiveClick:e,highlightedArchiveId:n}){const r=new Date,[i,s]=w.useState(r.getMonth()),[o,l]=w.useState(r.getFullYear()),[c,d]=w.useState(null),[u,m]=w.useState(null),p=w.useMemo(()=>{const N=new Map;return t.forEach(A=>{const T=Zn(A.completed_at||A.created_at)||new Date,F=`${T.getFullYear()}-${String(T.getMonth()+1).padStart(2,\"0\")}-${String(T.getDate()).padStart(2,\"0\")}`,k=N.get(F)||[];k.push(A),N.set(F,k)}),N},[t]),f=Y7e(o,i),y=Q7e(o,i),v=()=>{i===0?(s(11),l(o-1)):s(i-1)},b=()=>{i===11?(s(0),l(o+1)):s(i+1)},g=()=>{s(r.getMonth()),l(r.getFullYear())},_=[];for(let N=0;N<y;N++)_.push(null);for(let N=1;N<=f;N++)_.push(N);const C=c?p.get(c)||[]:[],P=N=>{N!==c&&m(null),d(N)};return a.jsxs(\"div\",{className:\"flex flex-col lg:flex-row gap-6\",children:[a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-4\",children:[a.jsx(\"button\",{onClick:v,className:\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",children:a.jsx(xl,{className:\"w-5 h-5 text-bambu-gray\"})}),a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white\",children:[Z7e[i],\" \",o]}),a.jsx(\"button\",{onClick:g,className:\"px-2 py-1 text-xs bg-bambu-dark-tertiary hover:bg-bambu-green/20 text-bambu-gray hover:text-white rounded transition-colors\",children:\"Today\"})]}),a.jsx(\"button\",{onClick:b,className:\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",children:a.jsx(ti,{className:\"w-5 h-5 text-bambu-gray\"})})]}),a.jsx(\"div\",{className:\"grid grid-cols-7 gap-1 mb-1\",children:J7e.map(N=>a.jsx(\"div\",{className:\"text-center text-xs text-bambu-gray py-2\",children:N},N))}),a.jsx(\"div\",{className:\"grid grid-cols-7 gap-1\",children:_.map((N,A)=>{if(N===null)return a.jsx(\"div\",{className:\"aspect-square\"},`empty-${A}`);const T=`${o}-${String(i+1).padStart(2,\"0\")}-${String(N).padStart(2,\"0\")}`,F=p.get(T)||[],k=F.length>0,D=N===r.getDate()&&i===r.getMonth()&&o===r.getFullYear(),H=T===c,z=F.filter(L=>L.status===\"completed\").length,Q=F.filter(L=>L.status===\"failed\").length;return a.jsxs(\"button\",{onClick:()=>P(H?null:T),className:`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${H?\"bg-bambu-green text-white\":D?\"bg-bambu-green/20 text-white ring-2 ring-bambu-green\":k?\"bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 text-white\":\"hover:bg-bambu-dark-tertiary/50 text-bambu-gray\"}`,children:[a.jsx(\"span\",{className:`text-sm font-medium ${D&&!H?\"text-bambu-green\":\"\"}`,children:N}),k&&a.jsxs(\"div\",{className:\"absolute bottom-1 left-1/2 -translate-x-1/2 flex items-center gap-1\",children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full ${Q>0&&z===0?\"bg-red-400\":Q>0?\"bg-yellow-400\":\"bg-green-400\"}`}),a.jsx(\"span\",{className:\"text-xs font-medium\",children:F.length})]})]},N)})}),a.jsx(\"div\",{className:\"mt-4 pt-4 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"div\",{className:\"grid grid-cols-3 gap-4 text-center\",children:[a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-2xl font-bold text-white\",children:t.filter(N=>{const A=Zn(N.completed_at||N.created_at)||new Date;return A.getMonth()===i&&A.getFullYear()===o}).length}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray\",children:\"Prints this month\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-2xl font-bold text-green-400\",children:t.filter(N=>{const A=Zn(N.completed_at||N.created_at)||new Date;return A.getMonth()===i&&A.getFullYear()===o&&N.status===\"completed\"}).length}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray\",children:\"Successful\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-2xl font-bold text-red-400\",children:t.filter(N=>{const A=Zn(N.completed_at||N.created_at)||new Date;return A.getMonth()===i&&A.getFullYear()===o&&N.status===\"failed\"}).length}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray\",children:\"Failed\"})]})]})})]}),a.jsx(\"div\",{className:\"lg:w-80 bg-bambu-dark rounded-xl p-4\",children:c?a.jsxs(a.Fragment,{children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-bambu-gray mb-3\",children:new Date(c+\"T12:00:00\").toLocaleDateString(\"en-US\",{weekday:\"long\",month:\"long\",day:\"numeric\",year:\"numeric\"})}),C.length>0?a.jsx(\"div\",{className:\"calendar-scroll space-y-2 max-h-96 overflow-y-auto\",children:C.map(N=>{const A=N.id===u||N.id===n;return a.jsxs(\"button\",{onClick:()=>{m(N.id),e?.(N)},className:`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${A?\"\":\"hover:bg-bambu-dark-tertiary\"}`,style:A?{outline:\"4px solid #facc15\",outlineOffset:\"2px\"}:void 0,children:[N.thumbnail_path?a.jsx(\"img\",{src:ue.getArchiveThumbnail(N.id),alt:\"\",className:\"w-12 h-12 rounded object-cover\"}):a.jsx(\"div\",{className:\"w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center\",children:a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"3MF\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white truncate\",children:N.print_name||N.filename}),a.jsxs(\"div\",{className:\"flex items-center gap-2 text-xs\",children:[a.jsx(\"span\",{className:N.status===\"failed\"?\"text-red-400\":\"text-green-400\",children:N.status===\"failed\"?\"Failed\":\"Completed\"}),N.filament_color&&a.jsx(\"div\",{className:\"flex gap-0.5\",children:N.filament_color.split(\",\").map((T,F)=>a.jsx(\"div\",{className:\"w-3 h-3 rounded-full border border-black/20\",style:{backgroundColor:T}},F))})]})]})]},N.id)})}):a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:\"No prints on this day\"})]}):a.jsx(\"div\",{className:\"text-center py-8\",children:a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:\"Select a day to see prints\"})})})]})}function oie({archiveId:t,archiveName:e,onClose:n}){const r=ue.getArchiveQRCodeUrl(t,300);w.useEffect(()=>{const s=o=>{o.key===\"Escape\"&&n()};return window.addEventListener(\"keydown\",s),()=>window.removeEventListener(\"keydown\",s)},[n]);const i=()=>{const s=document.createElement(\"a\");s.href=r,s.download=`${e}_qrcode.png`,s.click()};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:n,children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-sm\",onClick:s=>s.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:\"QR Code\"}),a.jsx(\"button\",{onClick:n,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-6 flex flex-col items-center\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-4 text-center truncate max-w-full\",children:e}),a.jsx(\"div\",{className:\"bg-white p-4 rounded-lg mb-4\",children:a.jsx(\"img\",{src:r,alt:\"QR Code\",className:\"w-64 h-64\"})}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-4 text-center\",children:\"Scan to open this archive\"}),a.jsxs(De,{onClick:i,className:\"w-full\",children:[a.jsx(ga,{className:\"w-4 h-4\"}),\"Download QR Code\"]})]})]})})}function lie({archiveId:t,archiveName:e,photos:n,onClose:r,onDelete:i}){const[s,o]=w.useState(0),[l,c]=w.useState(!1);if(w.useEffect(()=>{const f=y=>{y.key===\"Escape\"&&r(),y.key===\"ArrowLeft\"&&o(v=>Math.max(0,v-1)),y.key===\"ArrowRight\"&&o(v=>Math.min(n.length-1,v+1))};return window.addEventListener(\"keydown\",f),()=>window.removeEventListener(\"keydown\",f)},[r,n.length]),w.useEffect(()=>{s>=n.length&&o(Math.max(0,n.length-1))},[n.length,s]),n.length===0)return r(),null;const d=n[s],u=ue.getArchivePhotoUrl(t,d),m=()=>{const f=document.createElement(\"a\");f.href=u,f.download=`${e}_photo_${s+1}.jpg`,f.click()},p=()=>{i&&c(!0)};return a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/90 flex items-center justify-center z-50\",onClick:r,children:[a.jsxs(\"div\",{className:\"relative w-full h-full flex flex-col\",onClick:f=>f.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 bg-black/50\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:e}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[\"Photo \",s+1,\" of \",n.length]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:m,children:[a.jsx(ga,{className:\"w-4 h-4\"}),\"Download\"]}),i&&a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:p,className:\"text-red-400 hover:text-red-300\",children:a.jsx(en,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:r,className:\"p-2 text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-6 h-6\"})})]})]}),a.jsxs(\"div\",{className:\"flex-1 min-h-0 flex items-center justify-center p-4 relative overflow-hidden\",children:[s>0&&a.jsx(\"button\",{onClick:()=>o(f=>f-1),className:\"absolute left-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors\",children:a.jsx(xl,{className:\"w-8 h-8 text-white\"})}),a.jsx(\"img\",{src:u,alt:`Photo ${s+1}`,className:\"max-w-full max-h-full object-contain rounded-lg\",style:{maxHeight:\"calc(100vh - 200px)\"}}),s<n.length-1&&a.jsx(\"button\",{onClick:()=>o(f=>f+1),className:\"absolute right-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors\",children:a.jsx(ti,{className:\"w-8 h-8 text-white\"})})]}),n.length>1&&a.jsx(\"div\",{className:\"flex justify-center gap-2 p-4 bg-black/50\",children:n.map((f,y)=>a.jsx(\"button\",{onClick:()=>o(y),className:`w-16 h-16 rounded-lg overflow-hidden border-2 transition-colors ${y===s?\"border-bambu-green\":\"border-transparent hover:border-bambu-gray\"}`,children:a.jsx(\"img\",{src:ue.getArchivePhotoUrl(t,f),alt:`Thumbnail ${y+1}`,className:\"w-full h-full object-cover\"})},f))})]}),l&&a.jsx(yn,{title:\"Delete Photo\",message:\"Delete this photo? This cannot be undone.\",confirmText:\"Delete\",variant:\"danger\",onConfirm:()=>{i?.(d),c(!1)},onCancel:()=>c(!1)})]})}const{entries:cie,setPrototypeOf:fK,isFrozen:tGe,getPrototypeOf:nGe,getOwnPropertyDescriptor:rGe}=Object;let{freeze:po,seal:gc,create:y0}=Object,{apply:SL,construct:_L}=typeof Reflect<\"u\"&&Reflect;po||(po=function(e){return e});gc||(gc=function(e){return e});SL||(SL=function(e,n){for(var r=arguments.length,i=new Array(r>2?r-2:0),s=2;s<r;s++)i[s-2]=arguments[s];return e.apply(n,i)});_L||(_L=function(e){for(var n=arguments.length,r=new Array(n>1?n-1:0),i=1;i<n;i++)r[i-1]=arguments[i];return new e(...r)});const t0=fo(Array.prototype.forEach),aGe=fo(Array.prototype.lastIndexOf),gK=fo(Array.prototype.pop),n0=fo(Array.prototype.push),iGe=fo(Array.prototype.splice),nN=fo(String.prototype.toLowerCase),UD=fo(String.prototype.toString),BD=fo(String.prototype.match),sx=fo(String.prototype.replace),sGe=fo(String.prototype.indexOf),oGe=fo(String.prototype.trim),Ic=fo(Object.prototype.hasOwnProperty),Js=fo(RegExp.prototype.test),r0=lGe(TypeError);function fo(t){return function(e){e instanceof RegExp&&(e.lastIndex=0);for(var n=arguments.length,r=new Array(n>1?n-1:0),i=1;i<n;i++)r[i-1]=arguments[i];return SL(t,e,r)}}function lGe(t){return function(){for(var e=arguments.length,n=new Array(e),r=0;r<e;r++)n[r]=arguments[r];return _L(t,n)}}function ur(t,e){let n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:nN;fK&&fK(t,null);let r=e.length;for(;r--;){let i=e[r];if(typeof i==\"string\"){const s=n(i);s!==i&&(tGe(e)||(e[r]=s),i=s)}t[i]=!0}return t}function cGe(t){for(let e=0;e<t.length;e++)Ic(t,e)||(t[e]=null);return t}function vd(t){const e=y0(null);for(const[n,r]of cie(t))Ic(t,n)&&(Array.isArray(r)?e[n]=cGe(r):r&&typeof r==\"object\"&&r.constructor===Object?e[n]=vd(r):e[n]=r);return e}function a0(t,e){for(;t!==null;){const r=rGe(t,e);if(r){if(r.get)return fo(r.get);if(typeof r.value==\"function\")return fo(r.value)}t=nGe(t)}function n(){return null}return n}const bK=po([\"a\",\"abbr\",\"acronym\",\"address\",\"area\",\"article\",\"aside\",\"audio\",\"b\",\"bdi\",\"bdo\",\"big\",\"blink\",\"blockquote\",\"body\",\"br\",\"button\",\"canvas\",\"caption\",\"center\",\"cite\",\"code\",\"col\",\"colgroup\",\"content\",\"data\",\"datalist\",\"dd\",\"decorator\",\"del\",\"details\",\"dfn\",\"dialog\",\"dir\",\"div\",\"dl\",\"dt\",\"element\",\"em\",\"fieldset\",\"figcaption\",\"figure\",\"font\",\"footer\",\"form\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"head\",\"header\",\"hgroup\",\"hr\",\"html\",\"i\",\"img\",\"input\",\"ins\",\"kbd\",\"label\",\"legend\",\"li\",\"main\",\"map\",\"mark\",\"marquee\",\"menu\",\"menuitem\",\"meter\",\"nav\",\"nobr\",\"ol\",\"optgroup\",\"option\",\"output\",\"p\",\"picture\",\"pre\",\"progress\",\"q\",\"rp\",\"rt\",\"ruby\",\"s\",\"samp\",\"search\",\"section\",\"select\",\"shadow\",\"slot\",\"small\",\"source\",\"spacer\",\"span\",\"strike\",\"strong\",\"style\",\"sub\",\"summary\",\"sup\",\"table\",\"tbody\",\"td\",\"template\",\"textarea\",\"tfoot\",\"th\",\"thead\",\"time\",\"tr\",\"track\",\"tt\",\"u\",\"ul\",\"var\",\"video\",\"wbr\"]),HD=po([\"svg\",\"a\",\"altglyph\",\"altglyphdef\",\"altglyphitem\",\"animatecolor\",\"animatemotion\",\"animatetransform\",\"circle\",\"clippath\",\"defs\",\"desc\",\"ellipse\",\"enterkeyhint\",\"exportparts\",\"filter\",\"font\",\"g\",\"glyph\",\"glyphref\",\"hkern\",\"image\",\"inputmode\",\"line\",\"lineargradient\",\"marker\",\"mask\",\"metadata\",\"mpath\",\"part\",\"path\",\"pattern\",\"polygon\",\"polyline\",\"radialgradient\",\"rect\",\"stop\",\"style\",\"switch\",\"symbol\",\"text\",\"textpath\",\"title\",\"tref\",\"tspan\",\"view\",\"vkern\"]),qD=po([\"feBlend\",\"feColorMatrix\",\"feComponentTransfer\",\"feComposite\",\"feConvolveMatrix\",\"feDiffuseLighting\",\"feDisplacementMap\",\"feDistantLight\",\"feDropShadow\",\"feFlood\",\"feFuncA\",\"feFuncB\",\"feFuncG\",\"feFuncR\",\"feGaussianBlur\",\"feImage\",\"feMerge\",\"feMergeNode\",\"feMorphology\",\"feOffset\",\"fePointLight\",\"feSpecularLighting\",\"feSpotLight\",\"feTile\",\"feTurbulence\"]),dGe=po([\"animate\",\"color-profile\",\"cursor\",\"discard\",\"font-face\",\"font-face-format\",\"font-face-name\",\"font-face-src\",\"font-face-uri\",\"foreignobject\",\"hatch\",\"hatchpath\",\"mesh\",\"meshgradient\",\"meshpatch\",\"meshrow\",\"missing-glyph\",\"script\",\"set\",\"solidcolor\",\"unknown\",\"use\"]),$D=po([\"math\",\"menclose\",\"merror\",\"mfenced\",\"mfrac\",\"mglyph\",\"mi\",\"mlabeledtr\",\"mmultiscripts\",\"mn\",\"mo\",\"mover\",\"mpadded\",\"mphantom\",\"mroot\",\"mrow\",\"ms\",\"mspace\",\"msqrt\",\"mstyle\",\"msub\",\"msup\",\"msubsup\",\"mtable\",\"mtd\",\"mtext\",\"mtr\",\"munder\",\"munderover\",\"mprescripts\"]),uGe=po([\"maction\",\"maligngroup\",\"malignmark\",\"mlongdiv\",\"mscarries\",\"mscarry\",\"msgroup\",\"mstack\",\"msline\",\"msrow\",\"semantics\",\"annotation\",\"annotation-xml\",\"mprescripts\",\"none\"]),xK=po([\"#text\"]),yK=po([\"accept\",\"action\",\"align\",\"alt\",\"autocapitalize\",\"autocomplete\",\"autopictureinpicture\",\"autoplay\",\"background\",\"bgcolor\",\"border\",\"capture\",\"cellpadding\",\"cellspacing\",\"checked\",\"cite\",\"class\",\"clear\",\"color\",\"cols\",\"colspan\",\"controls\",\"controlslist\",\"coords\",\"crossorigin\",\"datetime\",\"decoding\",\"default\",\"dir\",\"disabled\",\"disablepictureinpicture\",\"disableremoteplayback\",\"download\",\"draggable\",\"enctype\",\"enterkeyhint\",\"exportparts\",\"face\",\"for\",\"headers\",\"height\",\"hidden\",\"high\",\"href\",\"hreflang\",\"id\",\"inert\",\"inputmode\",\"integrity\",\"ismap\",\"kind\",\"label\",\"lang\",\"list\",\"loading\",\"loop\",\"low\",\"max\",\"maxlength\",\"media\",\"method\",\"min\",\"minlength\",\"multiple\",\"muted\",\"name\",\"nonce\",\"noshade\",\"novalidate\",\"nowrap\",\"open\",\"optimum\",\"part\",\"pattern\",\"placeholder\",\"playsinline\",\"popover\",\"popovertarget\",\"popovertargetaction\",\"poster\",\"preload\",\"pubdate\",\"radiogroup\",\"readonly\",\"rel\",\"required\",\"rev\",\"reversed\",\"role\",\"rows\",\"rowspan\",\"spellcheck\",\"scope\",\"selected\",\"shape\",\"size\",\"sizes\",\"slot\",\"span\",\"srclang\",\"start\",\"src\",\"srcset\",\"step\",\"style\",\"summary\",\"tabindex\",\"title\",\"translate\",\"type\",\"usemap\",\"valign\",\"value\",\"width\",\"wrap\",\"xmlns\",\"slot\"]),VD=po([\"accent-height\",\"accumulate\",\"additive\",\"alignment-baseline\",\"amplitude\",\"ascent\",\"attributename\",\"attributetype\",\"azimuth\",\"basefrequency\",\"baseline-shift\",\"begin\",\"bias\",\"by\",\"class\",\"clip\",\"clippathunits\",\"clip-path\",\"clip-rule\",\"color\",\"color-interpolation\",\"color-interpolation-filters\",\"color-profile\",\"color-rendering\",\"cx\",\"cy\",\"d\",\"dx\",\"dy\",\"diffuseconstant\",\"direction\",\"display\",\"divisor\",\"dur\",\"edgemode\",\"elevation\",\"end\",\"exponent\",\"fill\",\"fill-opacity\",\"fill-rule\",\"filter\",\"filterunits\",\"flood-color\",\"flood-opacity\",\"font-family\",\"font-size\",\"font-size-adjust\",\"font-stretch\",\"font-style\",\"font-variant\",\"font-weight\",\"fx\",\"fy\",\"g1\",\"g2\",\"glyph-name\",\"glyphref\",\"gradientunits\",\"gradienttransform\",\"height\",\"href\",\"id\",\"image-rendering\",\"in\",\"in2\",\"intercept\",\"k\",\"k1\",\"k2\",\"k3\",\"k4\",\"kerning\",\"keypoints\",\"keysplines\",\"keytimes\",\"lang\",\"lengthadjust\",\"letter-spacing\",\"kernelmatrix\",\"kernelunitlength\",\"lighting-color\",\"local\",\"marker-end\",\"marker-mid\",\"marker-start\",\"markerheight\",\"markerunits\",\"markerwidth\",\"maskcontentunits\",\"maskunits\",\"max\",\"mask\",\"mask-type\",\"media\",\"method\",\"mode\",\"min\",\"name\",\"numoctaves\",\"offset\",\"operator\",\"opacity\",\"order\",\"orient\",\"orientation\",\"origin\",\"overflow\",\"paint-order\",\"path\",\"pathlength\",\"patterncontentunits\",\"patterntransform\",\"patternunits\",\"points\",\"preservealpha\",\"preserveaspectratio\",\"primitiveunits\",\"r\",\"rx\",\"ry\",\"radius\",\"refx\",\"refy\",\"repeatcount\",\"repeatdur\",\"restart\",\"result\",\"rotate\",\"scale\",\"seed\",\"shape-rendering\",\"slope\",\"specularconstant\",\"specularexponent\",\"spreadmethod\",\"startoffset\",\"stddeviation\",\"stitchtiles\",\"stop-color\",\"stop-opacity\",\"stroke-dasharray\",\"stroke-dashoffset\",\"stroke-linecap\",\"stroke-linejoin\",\"stroke-miterlimit\",\"stroke-opacity\",\"stroke\",\"stroke-width\",\"style\",\"surfacescale\",\"systemlanguage\",\"tabindex\",\"tablevalues\",\"targetx\",\"targety\",\"transform\",\"transform-origin\",\"text-anchor\",\"text-decoration\",\"text-rendering\",\"textlength\",\"type\",\"u1\",\"u2\",\"unicode\",\"values\",\"viewbox\",\"visibility\",\"version\",\"vert-adv-y\",\"vert-origin-x\",\"vert-origin-y\",\"width\",\"word-spacing\",\"wrap\",\"writing-mode\",\"xchannelselector\",\"ychannelselector\",\"x\",\"x1\",\"x2\",\"xmlns\",\"y\",\"y1\",\"y2\",\"z\",\"zoomandpan\"]),vK=po([\"accent\",\"accentunder\",\"align\",\"bevelled\",\"close\",\"columnalign\",\"columnlines\",\"columnspacing\",\"columnspan\",\"denomalign\",\"depth\",\"dir\",\"display\",\"displaystyle\",\"encoding\",\"fence\",\"frame\",\"height\",\"href\",\"id\",\"largeop\",\"length\",\"linethickness\",\"lquote\",\"lspace\",\"mathbackground\",\"mathcolor\",\"mathsize\",\"mathvariant\",\"maxsize\",\"minsize\",\"movablelimits\",\"notation\",\"numalign\",\"open\",\"rowalign\",\"rowlines\",\"rowspacing\",\"rowspan\",\"rspace\",\"rquote\",\"scriptlevel\",\"scriptminsize\",\"scriptsizemultiplier\",\"selection\",\"separator\",\"separators\",\"stretchy\",\"subscriptshift\",\"supscriptshift\",\"symmetric\",\"voffset\",\"width\",\"xmlns\"]),gk=po([\"xlink:href\",\"xml:id\",\"xlink:title\",\"xml:space\",\"xmlns:xlink\"]),mGe=gc(/\\{\\{[\\w\\W]*|[\\w\\W]*\\}\\}/gm),hGe=gc(/<%[\\w\\W]*|[\\w\\W]*%>/gm),pGe=gc(/\\$\\{[\\w\\W]*/gm),fGe=gc(/^data-[\\-\\w.\\u00B7-\\uFFFF]+$/),gGe=gc(/^aria-[\\-\\w]+$/),die=gc(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i),bGe=gc(/^(?:\\w+script|data):/i),xGe=gc(/[\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000]/g),uie=gc(/^html$/i),yGe=gc(/^[a-z][.\\w]*(-[.\\w]+)+$/i);var wK=Object.freeze({__proto__:null,ARIA_ATTR:gGe,ATTR_WHITESPACE:xGe,CUSTOM_ELEMENT:yGe,DATA_ATTR:fGe,DOCTYPE_NAME:uie,ERB_EXPR:hGe,IS_ALLOWED_URI:die,IS_SCRIPT_OR_DATA:bGe,MUSTACHE_EXPR:mGe,TMPLIT_EXPR:pGe});const i0={element:1,text:3,progressingInstruction:7,comment:8,document:9},vGe=function(){return typeof window>\"u\"?null:window},wGe=function(e,n){if(typeof e!=\"object\"||typeof e.createPolicy!=\"function\")return null;let r=null;const i=\"data-tt-policy-suffix\";n&&n.hasAttribute(i)&&(r=n.getAttribute(i));const s=\"dompurify\"+(r?\"#\"+r:\"\");try{return e.createPolicy(s,{createHTML(o){return o},createScriptURL(o){return o}})}catch{return console.warn(\"TrustedTypes policy \"+s+\" could not be created.\"),null}},SK=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function mie(){let t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:vGe();const e=nt=>mie(nt);if(e.version=\"3.4.0\",e.removed=[],!t||!t.document||t.document.nodeType!==i0.document||!t.Element)return e.isSupported=!1,e;let{document:n}=t;const r=n,i=r.currentScript,{DocumentFragment:s,HTMLTemplateElement:o,Node:l,Element:c,NodeFilter:d,NamedNodeMap:u=t.NamedNodeMap||t.MozNamedAttrMap,HTMLFormElement:m,DOMParser:p,trustedTypes:f}=t,y=c.prototype,v=a0(y,\"cloneNode\"),b=a0(y,\"remove\"),g=a0(y,\"nextSibling\"),_=a0(y,\"childNodes\"),C=a0(y,\"parentNode\");if(typeof o==\"function\"){const nt=n.createElement(\"template\");nt.content&&nt.content.ownerDocument&&(n=nt.content.ownerDocument)}let P,N=\"\";const{implementation:A,createNodeIterator:T,createDocumentFragment:F,getElementsByTagName:k}=n,{importNode:D}=r;let H=SK();e.isSupported=typeof cie==\"function\"&&typeof C==\"function\"&&A&&A.createHTMLDocument!==void 0;const{MUSTACHE_EXPR:z,ERB_EXPR:Q,TMPLIT_EXPR:L,DATA_ATTR:te,ARIA_ATTR:ie,IS_SCRIPT_OR_DATA:J,ATTR_WHITESPACE:oe,CUSTOM_ELEMENT:fe}=wK;let{IS_ALLOWED_URI:re}=wK,W=null;const ne=ur({},[...bK,...HD,...qD,...$D,...xK]);let me=null;const be=ur({},[...yK,...VD,...vK,...gk]);let Ce=Object.seal(y0(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),q=null,Y=null;const E=Object.seal(y0(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let j=!0,O=!0,K=!1,U=!0,de=!1,I=!0,G=!1,X=!1,V=!1,ee=!1,se=!1,ge=!1,he=!0,le=!1;const B=\"user-content-\";let R=!0,ae=!1,_e={},Se=null;const ve=ur({},[\"annotation-xml\",\"audio\",\"colgroup\",\"desc\",\"foreignobject\",\"head\",\"iframe\",\"math\",\"mi\",\"mn\",\"mo\",\"ms\",\"mtext\",\"noembed\",\"noframes\",\"noscript\",\"plaintext\",\"script\",\"style\",\"svg\",\"template\",\"thead\",\"title\",\"video\",\"xmp\"]);let Te=null;const ye=ur({},[\"audio\",\"video\",\"img\",\"source\",\"image\",\"track\"]);let je=null;const Le=ur({},[\"alt\",\"class\",\"for\",\"id\",\"label\",\"name\",\"pattern\",\"placeholder\",\"role\",\"summary\",\"title\",\"value\",\"style\",\"xmlns\"]),Me=\"http://www.w3.org/1998/Math/MathML\",Oe=\"http://www.w3.org/2000/svg\",Re=\"http://www.w3.org/1999/xhtml\";let $e=Re,Ye=!1,tt=null;const pe=ur({},[Me,Oe,Re],UD);let Fe=ur({},[\"mi\",\"mo\",\"mn\",\"ms\",\"mtext\"]),we=ur({},[\"annotation-xml\"]);const Ve=ur({},[\"title\",\"style\",\"font\",\"a\",\"script\"]);let Ae=null;const ce=[\"application/xhtml+xml\",\"text/html\"],xe=\"text/html\";let Be=null,Qe=null;const ht=n.createElement(\"form\"),xt=function(ze){return ze instanceof RegExp||ze instanceof Function},gt=function(){let ze=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(!(Qe&&Qe===ze)){if((!ze||typeof ze!=\"object\")&&(ze={}),ze=vd(ze),Ae=ce.indexOf(ze.PARSER_MEDIA_TYPE)===-1?xe:ze.PARSER_MEDIA_TYPE,Be=Ae===\"application/xhtml+xml\"?UD:nN,W=Ic(ze,\"ALLOWED_TAGS\")?ur({},ze.ALLOWED_TAGS,Be):ne,me=Ic(ze,\"ALLOWED_ATTR\")?ur({},ze.ALLOWED_ATTR,Be):be,tt=Ic(ze,\"ALLOWED_NAMESPACES\")?ur({},ze.ALLOWED_NAMESPACES,UD):pe,je=Ic(ze,\"ADD_URI_SAFE_ATTR\")?ur(vd(Le),ze.ADD_URI_SAFE_ATTR,Be):Le,Te=Ic(ze,\"ADD_DATA_URI_TAGS\")?ur(vd(ye),ze.ADD_DATA_URI_TAGS,Be):ye,Se=Ic(ze,\"FORBID_CONTENTS\")?ur({},ze.FORBID_CONTENTS,Be):ve,q=Ic(ze,\"FORBID_TAGS\")?ur({},ze.FORBID_TAGS,Be):vd({}),Y=Ic(ze,\"FORBID_ATTR\")?ur({},ze.FORBID_ATTR,Be):vd({}),_e=Ic(ze,\"USE_PROFILES\")?ze.USE_PROFILES:!1,j=ze.ALLOW_ARIA_ATTR!==!1,O=ze.ALLOW_DATA_ATTR!==!1,K=ze.ALLOW_UNKNOWN_PROTOCOLS||!1,U=ze.ALLOW_SELF_CLOSE_IN_ATTR!==!1,de=ze.SAFE_FOR_TEMPLATES||!1,I=ze.SAFE_FOR_XML!==!1,G=ze.WHOLE_DOCUMENT||!1,ee=ze.RETURN_DOM||!1,se=ze.RETURN_DOM_FRAGMENT||!1,ge=ze.RETURN_TRUSTED_TYPE||!1,V=ze.FORCE_BODY||!1,he=ze.SANITIZE_DOM!==!1,le=ze.SANITIZE_NAMED_PROPS||!1,R=ze.KEEP_CONTENT!==!1,ae=ze.IN_PLACE||!1,re=ze.ALLOWED_URI_REGEXP||die,$e=ze.NAMESPACE||Re,Fe=ze.MATHML_TEXT_INTEGRATION_POINTS||Fe,we=ze.HTML_INTEGRATION_POINTS||we,Ce=ze.CUSTOM_ELEMENT_HANDLING||y0(null),ze.CUSTOM_ELEMENT_HANDLING&&xt(ze.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Ce.tagNameCheck=ze.CUSTOM_ELEMENT_HANDLING.tagNameCheck),ze.CUSTOM_ELEMENT_HANDLING&&xt(ze.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Ce.attributeNameCheck=ze.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),ze.CUSTOM_ELEMENT_HANDLING&&typeof ze.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements==\"boolean\"&&(Ce.allowCustomizedBuiltInElements=ze.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),de&&(O=!1),se&&(ee=!0),_e&&(W=ur({},xK),me=y0(null),_e.html===!0&&(ur(W,bK),ur(me,yK)),_e.svg===!0&&(ur(W,HD),ur(me,VD),ur(me,gk)),_e.svgFilters===!0&&(ur(W,qD),ur(me,VD),ur(me,gk)),_e.mathMl===!0&&(ur(W,$D),ur(me,vK),ur(me,gk))),E.tagCheck=null,E.attributeCheck=null,ze.ADD_TAGS&&(typeof ze.ADD_TAGS==\"function\"?E.tagCheck=ze.ADD_TAGS:(W===ne&&(W=vd(W)),ur(W,ze.ADD_TAGS,Be))),ze.ADD_ATTR&&(typeof ze.ADD_ATTR==\"function\"?E.attributeCheck=ze.ADD_ATTR:(me===be&&(me=vd(me)),ur(me,ze.ADD_ATTR,Be))),ze.ADD_URI_SAFE_ATTR&&ur(je,ze.ADD_URI_SAFE_ATTR,Be),ze.FORBID_CONTENTS&&(Se===ve&&(Se=vd(Se)),ur(Se,ze.FORBID_CONTENTS,Be)),ze.ADD_FORBID_CONTENTS&&(Se===ve&&(Se=vd(Se)),ur(Se,ze.ADD_FORBID_CONTENTS,Be)),R&&(W[\"#text\"]=!0),G&&ur(W,[\"html\",\"head\",\"body\"]),W.table&&(ur(W,[\"tbody\"]),delete q.tbody),ze.TRUSTED_TYPES_POLICY){if(typeof ze.TRUSTED_TYPES_POLICY.createHTML!=\"function\")throw r0('TRUSTED_TYPES_POLICY configuration option must provide a \"createHTML\" hook.');if(typeof ze.TRUSTED_TYPES_POLICY.createScriptURL!=\"function\")throw r0('TRUSTED_TYPES_POLICY configuration option must provide a \"createScriptURL\" hook.');P=ze.TRUSTED_TYPES_POLICY,N=P.createHTML(\"\")}else P===void 0&&(P=wGe(f,i)),P!==null&&typeof N==\"string\"&&(N=P.createHTML(\"\"));po&&po(ze),Qe=ze}},Ut=ur({},[...HD,...qD,...dGe]),Wt=ur({},[...$D,...uGe]),Zt=function(ze){let ot=C(ze);(!ot||!ot.tagName)&&(ot={namespaceURI:$e,tagName:\"template\"});const Pe=nN(ze.tagName),Ge=nN(ot.tagName);return tt[ze.namespaceURI]?ze.namespaceURI===Oe?ot.namespaceURI===Re?Pe===\"svg\":ot.namespaceURI===Me?Pe===\"svg\"&&(Ge===\"annotation-xml\"||Fe[Ge]):!!Ut[Pe]:ze.namespaceURI===Me?ot.namespaceURI===Re?Pe===\"math\":ot.namespaceURI===Oe?Pe===\"math\"&&we[Ge]:!!Wt[Pe]:ze.namespaceURI===Re?ot.namespaceURI===Oe&&!we[Ge]||ot.namespaceURI===Me&&!Fe[Ge]?!1:!Wt[Pe]&&(Ve[Pe]||!Ut[Pe]):!!(Ae===\"application/xhtml+xml\"&&tt[ze.namespaceURI]):!1},Kt=function(ze){n0(e.removed,{element:ze});try{C(ze).removeChild(ze)}catch{b(ze)}},Xt=function(ze,ot){try{n0(e.removed,{attribute:ot.getAttributeNode(ze),from:ot})}catch{n0(e.removed,{attribute:null,from:ot})}if(ot.removeAttribute(ze),ze===\"is\")if(ee||se)try{Kt(ot)}catch{}else try{ot.setAttribute(ze,\"\")}catch{}},ln=function(ze){let ot=null,Pe=null;if(V)ze=\"<remove></remove>\"+ze;else{const Je=BD(ze,/^[\\r\\n\\t ]+/);Pe=Je&&Je[0]}Ae===\"application/xhtml+xml\"&&$e===Re&&(ze='<html xmlns=\"http://www.w3.org/1999/xhtml\"><head></head><body>'+ze+\"</body></html>\");const Ge=P?P.createHTML(ze):ze;if($e===Re)try{ot=new p().parseFromString(Ge,Ae)}catch{}if(!ot||!ot.documentElement){ot=A.createDocument($e,\"template\",null);try{ot.documentElement.innerHTML=Ye?N:Ge}catch{}}const Ze=ot.body||ot.documentElement;return ze&&Pe&&Ze.insertBefore(n.createTextNode(Pe),Ze.childNodes[0]||null),$e===Re?k.call(ot,G?\"html\":\"body\")[0]:G?ot.documentElement:Ze},vn=function(ze){return T.call(ze.ownerDocument||ze,ze,d.SHOW_ELEMENT|d.SHOW_COMMENT|d.SHOW_TEXT|d.SHOW_PROCESSING_INSTRUCTION|d.SHOW_CDATA_SECTION,null)},Ke=function(ze){return ze instanceof m&&(typeof ze.nodeName!=\"string\"||typeof ze.textContent!=\"string\"||typeof ze.removeChild!=\"function\"||!(ze.attributes instanceof u)||typeof ze.removeAttribute!=\"function\"||typeof ze.setAttribute!=\"function\"||typeof ze.namespaceURI!=\"string\"||typeof ze.insertBefore!=\"function\"||typeof ze.hasChildNodes!=\"function\")},at=function(ze){return typeof l==\"function\"&&ze instanceof l};function Lt(nt,ze,ot){t0(nt,Pe=>{Pe.call(e,ze,ot,Qe)})}const Et=function(ze){let ot=null;if(Lt(H.beforeSanitizeElements,ze,null),Ke(ze))return Kt(ze),!0;const Pe=Be(ze.nodeName);if(Lt(H.uponSanitizeElement,ze,{tagName:Pe,allowedTags:W}),I&&ze.hasChildNodes()&&!at(ze.firstElementChild)&&Js(/<[/\\w!]/g,ze.innerHTML)&&Js(/<[/\\w!]/g,ze.textContent)||I&&ze.namespaceURI===Re&&Pe===\"style\"&&at(ze.firstElementChild)||ze.nodeType===i0.progressingInstruction||I&&ze.nodeType===i0.comment&&Js(/<[/\\w]/g,ze.data))return Kt(ze),!0;if(q[Pe]||!(E.tagCheck instanceof Function&&E.tagCheck(Pe))&&!W[Pe]){if(!q[Pe]&&Ie(Pe)&&(Ce.tagNameCheck instanceof RegExp&&Js(Ce.tagNameCheck,Pe)||Ce.tagNameCheck instanceof Function&&Ce.tagNameCheck(Pe)))return!1;if(R&&!Se[Pe]){const Ge=C(ze)||ze.parentNode,Ze=_(ze)||ze.childNodes;if(Ze&&Ge){const Je=Ze.length;for(let We=Je-1;We>=0;--We){const Ue=v(Ze[We],!0);Ue.__removalCount=(ze.__removalCount||0)+1,Ge.insertBefore(Ue,g(ze))}}}return Kt(ze),!0}return ze instanceof c&&!Zt(ze)||(Pe===\"noscript\"||Pe===\"noembed\"||Pe===\"noframes\")&&Js(/<\\/no(script|embed|frames)/i,ze.innerHTML)?(Kt(ze),!0):(de&&ze.nodeType===i0.text&&(ot=ze.textContent,t0([z,Q,L],Ge=>{ot=sx(ot,Ge,\" \")}),ze.textContent!==ot&&(n0(e.removed,{element:ze.cloneNode()}),ze.textContent=ot)),Lt(H.afterSanitizeElements,ze,null),!1)},At=function(ze,ot,Pe){if(Y[ot]||he&&(ot===\"id\"||ot===\"name\")&&(Pe in n||Pe in ht))return!1;if(!(O&&!Y[ot]&&Js(te,ot))){if(!(j&&Js(ie,ot))){if(!(E.attributeCheck instanceof Function&&E.attributeCheck(ot,ze))){if(!me[ot]||Y[ot]){if(!(Ie(ze)&&(Ce.tagNameCheck instanceof RegExp&&Js(Ce.tagNameCheck,ze)||Ce.tagNameCheck instanceof Function&&Ce.tagNameCheck(ze))&&(Ce.attributeNameCheck instanceof RegExp&&Js(Ce.attributeNameCheck,ot)||Ce.attributeNameCheck instanceof Function&&Ce.attributeNameCheck(ot,ze))||ot===\"is\"&&Ce.allowCustomizedBuiltInElements&&(Ce.tagNameCheck instanceof RegExp&&Js(Ce.tagNameCheck,Pe)||Ce.tagNameCheck instanceof Function&&Ce.tagNameCheck(Pe))))return!1}else if(!je[ot]){if(!Js(re,sx(Pe,oe,\"\"))){if(!((ot===\"src\"||ot===\"xlink:href\"||ot===\"href\")&&ze!==\"script\"&&sGe(Pe,\"data:\")===0&&Te[ze])){if(!(K&&!Js(J,sx(Pe,oe,\"\")))){if(Pe)return!1}}}}}}}return!0},Ie=function(ze){return ze!==\"annotation-xml\"&&BD(ze,fe)},mt=function(ze){Lt(H.beforeSanitizeAttributes,ze,null);const{attributes:ot}=ze;if(!ot||Ke(ze))return;const Pe={attrName:\"\",attrValue:\"\",keepAttr:!0,allowedAttributes:me,forceKeepAttr:void 0};let Ge=ot.length;for(;Ge--;){const Ze=ot[Ge],{name:Je,namespaceURI:We,value:Ue}=Ze,et=Be(Je),jt=Ue;let yt=Je===\"value\"?jt:oGe(jt);if(Pe.attrName=et,Pe.attrValue=yt,Pe.keepAttr=!0,Pe.forceKeepAttr=void 0,Lt(H.uponSanitizeAttribute,ze,Pe),yt=Pe.attrValue,le&&(et===\"id\"||et===\"name\")&&(Xt(Je,ze),yt=B+yt),I&&Js(/((--!?|])>)|<\\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,yt)){Xt(Je,ze);continue}if(et===\"attributename\"&&BD(yt,\"href\")){Xt(Je,ze);continue}if(Pe.forceKeepAttr)continue;if(!Pe.keepAttr){Xt(Je,ze);continue}if(!U&&Js(/\\/>/i,yt)){Xt(Je,ze);continue}de&&t0([z,Q,L],St=>{yt=sx(yt,St,\" \")});const qe=Be(ze.nodeName);if(!At(qe,et,yt)){Xt(Je,ze);continue}if(P&&typeof f==\"object\"&&typeof f.getAttributeType==\"function\"&&!We)switch(f.getAttributeType(qe,et)){case\"TrustedHTML\":{yt=P.createHTML(yt);break}case\"TrustedScriptURL\":{yt=P.createScriptURL(yt);break}}if(yt!==jt)try{We?ze.setAttributeNS(We,Je,yt):ze.setAttribute(Je,yt),Ke(ze)?Kt(ze):gK(e.removed)}catch{Xt(Je,ze)}}Lt(H.afterSanitizeAttributes,ze,null)},pt=function(ze){let ot=null;const Pe=vn(ze);for(Lt(H.beforeSanitizeShadowDOM,ze,null);ot=Pe.nextNode();)Lt(H.uponSanitizeShadowNode,ot,null),Et(ot),mt(ot),ot.content instanceof s&&pt(ot.content);Lt(H.afterSanitizeShadowDOM,ze,null)};return e.sanitize=function(nt){let ze=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},ot=null,Pe=null,Ge=null,Ze=null;if(Ye=!nt,Ye&&(nt=\"<!-->\"),typeof nt!=\"string\"&&!at(nt))if(typeof nt.toString==\"function\"){if(nt=nt.toString(),typeof nt!=\"string\")throw r0(\"dirty is not a string, aborting\")}else throw r0(\"toString is not a function\");if(!e.isSupported)return nt;if(X||gt(ze),e.removed=[],typeof nt==\"string\"&&(ae=!1),ae){if(nt.nodeName){const Ue=Be(nt.nodeName);if(!W[Ue]||q[Ue])throw r0(\"root node is forbidden and cannot be sanitized in-place\")}}else if(nt instanceof l)ot=ln(\"<!---->\"),Pe=ot.ownerDocument.importNode(nt,!0),Pe.nodeType===i0.element&&Pe.nodeName===\"BODY\"||Pe.nodeName===\"HTML\"?ot=Pe:ot.appendChild(Pe);else{if(!ee&&!de&&!G&&nt.indexOf(\"<\")===-1)return P&&ge?P.createHTML(nt):nt;if(ot=ln(nt),!ot)return ee?null:ge?N:\"\"}ot&&V&&Kt(ot.firstChild);const Je=vn(ae?nt:ot);for(;Ge=Je.nextNode();)Et(Ge),mt(Ge),Ge.content instanceof s&&pt(Ge.content);if(ae)return nt;if(ee){if(de){ot.normalize();let Ue=ot.innerHTML;t0([z,Q,L],et=>{Ue=sx(Ue,et,\" \")}),ot.innerHTML=Ue}if(se)for(Ze=F.call(ot.ownerDocument);ot.firstChild;)Ze.appendChild(ot.firstChild);else Ze=ot;return(me.shadowroot||me.shadowrootmode)&&(Ze=D.call(r,Ze,!0)),Ze}let We=G?ot.outerHTML:ot.innerHTML;return G&&W[\"!doctype\"]&&ot.ownerDocument&&ot.ownerDocument.doctype&&ot.ownerDocument.doctype.name&&Js(uie,ot.ownerDocument.doctype.name)&&(We=\"<!DOCTYPE \"+ot.ownerDocument.doctype.name+`>\n`+We),de&&t0([z,Q,L],Ue=>{We=sx(We,Ue,\" \")}),P&&ge?P.createHTML(We):We},e.setConfig=function(){let nt=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};gt(nt),X=!0},e.clearConfig=function(){Qe=null,X=!1},e.isValidAttribute=function(nt,ze,ot){Qe||gt({});const Pe=Be(nt),Ge=Be(ze);return At(Pe,Ge,ot)},e.addHook=function(nt,ze){typeof ze==\"function\"&&n0(H[nt],ze)},e.removeHook=function(nt,ze){if(ze!==void 0){const ot=aGe(H[nt],ze);return ot===-1?void 0:iGe(H[nt],ot,1)[0]}return gK(H[nt])},e.removeHooks=function(nt){H[nt]=[]},e.removeAllHooks=function(){H=SK()},e}var hie=mie();function ps(t){this.content=t}ps.prototype={constructor:ps,find:function(t){for(var e=0;e<this.content.length;e+=2)if(this.content[e]===t)return e;return-1},get:function(t){var e=this.find(t);return e==-1?void 0:this.content[e+1]},update:function(t,e,n){var r=n&&n!=t?this.remove(n):this,i=r.find(t),s=r.content.slice();return i==-1?s.push(n||t,e):(s[i+1]=e,n&&(s[i]=n)),new ps(s)},remove:function(t){var e=this.find(t);if(e==-1)return this;var n=this.content.slice();return n.splice(e,2),new ps(n)},addToStart:function(t,e){return new ps([t,e].concat(this.remove(t).content))},addToEnd:function(t,e){var n=this.remove(t).content.slice();return n.push(t,e),new ps(n)},addBefore:function(t,e,n){var r=this.remove(e),i=r.content.slice(),s=r.find(t);return i.splice(s==-1?i.length:s,0,e,n),new ps(i)},forEach:function(t){for(var e=0;e<this.content.length;e+=2)t(this.content[e],this.content[e+1])},prepend:function(t){return t=ps.from(t),t.size?new ps(t.content.concat(this.subtract(t).content)):this},append:function(t){return t=ps.from(t),t.size?new ps(this.subtract(t).content.concat(t.content)):this},subtract:function(t){var e=this;t=ps.from(t);for(var n=0;n<t.content.length;n+=2)e=e.remove(t.content[n]);return e},toObject:function(){var t={};return this.forEach(function(e,n){t[e]=n}),t},get size(){return this.content.length>>1}};ps.from=function(t){if(t instanceof ps)return t;var e=[];if(t)for(var n in t)e.push(n,t[n]);return new ps(e)};function pie(t,e,n){for(let r=0;;r++){if(r==t.childCount||r==e.childCount)return t.childCount==e.childCount?null:n;let i=t.child(r),s=e.child(r);if(i==s){n+=i.nodeSize;continue}if(!i.sameMarkup(s))return n;if(i.isText&&i.text!=s.text){for(let o=0;i.text[o]==s.text[o];o++)n++;return n}if(i.content.size||s.content.size){let o=pie(i.content,s.content,n+1);if(o!=null)return o}n+=i.nodeSize}}function fie(t,e,n,r){for(let i=t.childCount,s=e.childCount;;){if(i==0||s==0)return i==s?null:{a:n,b:r};let o=t.child(--i),l=e.child(--s),c=o.nodeSize;if(o==l){n-=c,r-=c;continue}if(!o.sameMarkup(l))return{a:n,b:r};if(o.isText&&o.text!=l.text){let d=0,u=Math.min(o.text.length,l.text.length);for(;d<u&&o.text[o.text.length-d-1]==l.text[l.text.length-d-1];)d++,n--,r--;return{a:n,b:r}}if(o.content.size||l.content.size){let d=fie(o.content,l.content,n-1,r-1);if(d)return d}n-=c,r-=c}}class Vt{constructor(e,n){if(this.content=e,this.size=n||0,n==null)for(let r=0;r<e.length;r++)this.size+=e[r].nodeSize}nodesBetween(e,n,r,i=0,s){for(let o=0,l=0;l<n;o++){let c=this.content[o],d=l+c.nodeSize;if(d>e&&r(c,i+l,s||null,o)!==!1&&c.content.size){let u=l+1;c.nodesBetween(Math.max(0,e-u),Math.min(c.content.size,n-u),r,i+u)}l=d}}descendants(e){this.nodesBetween(0,this.size,e)}textBetween(e,n,r,i){let s=\"\",o=!0;return this.nodesBetween(e,n,(l,c)=>{let d=l.isText?l.text.slice(Math.max(e,c)-c,n-c):l.isLeaf?i?typeof i==\"function\"?i(l):i:l.type.spec.leafText?l.type.spec.leafText(l):\"\":\"\";l.isBlock&&(l.isLeaf&&d||l.isTextblock)&&r&&(o?o=!1:s+=r),s+=d},0),s}append(e){if(!e.size)return this;if(!this.size)return e;let n=this.lastChild,r=e.firstChild,i=this.content.slice(),s=0;for(n.isText&&n.sameMarkup(r)&&(i[i.length-1]=n.withText(n.text+r.text),s=1);s<e.content.length;s++)i.push(e.content[s]);return new Vt(i,this.size+e.size)}cut(e,n=this.size){if(e==0&&n==this.size)return this;let r=[],i=0;if(n>e)for(let s=0,o=0;o<n;s++){let l=this.content[s],c=o+l.nodeSize;c>e&&((o<e||c>n)&&(l.isText?l=l.cut(Math.max(0,e-o),Math.min(l.text.length,n-o)):l=l.cut(Math.max(0,e-o-1),Math.min(l.content.size,n-o-1))),r.push(l),i+=l.nodeSize),o=c}return new Vt(r,i)}cutByIndex(e,n){return e==n?Vt.empty:e==0&&n==this.content.length?this:new Vt(this.content.slice(e,n))}replaceChild(e,n){let r=this.content[e];if(r==n)return this;let i=this.content.slice(),s=this.size+n.nodeSize-r.nodeSize;return i[e]=n,new Vt(i,s)}addToStart(e){return new Vt([e].concat(this.content),this.size+e.nodeSize)}addToEnd(e){return new Vt(this.content.concat(e),this.size+e.nodeSize)}eq(e){if(this.content.length!=e.content.length)return!1;for(let n=0;n<this.content.length;n++)if(!this.content[n].eq(e.content[n]))return!1;return!0}get firstChild(){return this.content.length?this.content[0]:null}get lastChild(){return this.content.length?this.content[this.content.length-1]:null}get childCount(){return this.content.length}child(e){let n=this.content[e];if(!n)throw new RangeError(\"Index \"+e+\" out of range for \"+this);return n}maybeChild(e){return this.content[e]||null}forEach(e){for(let n=0,r=0;n<this.content.length;n++){let i=this.content[n];e(i,r,n),r+=i.nodeSize}}findDiffStart(e,n=0){return pie(this,e,n)}findDiffEnd(e,n=this.size,r=e.size){return fie(this,e,n,r)}findIndex(e){if(e==0)return bk(0,e);if(e==this.size)return bk(this.content.length,e);if(e>this.size||e<0)throw new RangeError(`Position ${e} outside of fragment (${this})`);for(let n=0,r=0;;n++){let i=this.child(n),s=r+i.nodeSize;if(s>=e)return s==e?bk(n+1,s):bk(n,r);r=s}}toString(){return\"<\"+this.toStringInner()+\">\"}toStringInner(){return this.content.join(\", \")}toJSON(){return this.content.length?this.content.map(e=>e.toJSON()):null}static fromJSON(e,n){if(!n)return Vt.empty;if(!Array.isArray(n))throw new RangeError(\"Invalid input for Fragment.fromJSON\");return new Vt(n.map(e.nodeFromJSON))}static fromArray(e){if(!e.length)return Vt.empty;let n,r=0;for(let i=0;i<e.length;i++){let s=e[i];r+=s.nodeSize,i&&s.isText&&e[i-1].sameMarkup(s)?(n||(n=e.slice(0,i)),n[n.length-1]=s.withText(n[n.length-1].text+s.text)):n&&n.push(s)}return new Vt(n||e,r)}static from(e){if(!e)return Vt.empty;if(e instanceof Vt)return e;if(Array.isArray(e))return this.fromArray(e);if(e.attrs)return new Vt([e],e.nodeSize);throw new RangeError(\"Can not convert \"+e+\" to a Fragment\"+(e.nodesBetween?\" (looks like multiple versions of prosemirror-model were loaded)\":\"\"))}}Vt.empty=new Vt([],0);const GD={index:0,offset:0};function bk(t,e){return GD.index=t,GD.offset=e,GD}function BC(t,e){if(t===e)return!0;if(!(t&&typeof t==\"object\")||!(e&&typeof e==\"object\"))return!1;let n=Array.isArray(t);if(Array.isArray(e)!=n)return!1;if(n){if(t.length!=e.length)return!1;for(let r=0;r<t.length;r++)if(!BC(t[r],e[r]))return!1}else{for(let r in t)if(!(r in e)||!BC(t[r],e[r]))return!1;for(let r in e)if(!(r in t))return!1}return!0}let sa=class kL{constructor(e,n){this.type=e,this.attrs=n}addToSet(e){let n,r=!1;for(let i=0;i<e.length;i++){let s=e[i];if(this.eq(s))return e;if(this.type.excludes(s.type))n||(n=e.slice(0,i));else{if(s.type.excludes(this.type))return e;!r&&s.type.rank>this.type.rank&&(n||(n=e.slice(0,i)),n.push(this),r=!0),n&&n.push(s)}}return n||(n=e.slice()),r||n.push(this),n}removeFromSet(e){for(let n=0;n<e.length;n++)if(this.eq(e[n]))return e.slice(0,n).concat(e.slice(n+1));return e}isInSet(e){for(let n=0;n<e.length;n++)if(this.eq(e[n]))return!0;return!1}eq(e){return this==e||this.type==e.type&&BC(this.attrs,e.attrs)}toJSON(){let e={type:this.type.name};for(let n in this.attrs){e.attrs=this.attrs;break}return e}static fromJSON(e,n){if(!n)throw new RangeError(\"Invalid input for Mark.fromJSON\");let r=e.marks[n.type];if(!r)throw new RangeError(`There is no mark type ${n.type} in this schema`);let i=r.create(n.attrs);return r.checkAttrs(i.attrs),i}static sameSet(e,n){if(e==n)return!0;if(e.length!=n.length)return!1;for(let r=0;r<e.length;r++)if(!e[r].eq(n[r]))return!1;return!0}static setFrom(e){if(!e||Array.isArray(e)&&e.length==0)return kL.none;if(e instanceof kL)return[e];let n=e.slice();return n.sort((r,i)=>r.type.rank-i.type.rank),n}};sa.none=[];class HC extends Error{}class an{constructor(e,n,r){this.content=e,this.openStart=n,this.openEnd=r}get size(){return this.content.size-this.openStart-this.openEnd}insertAt(e,n){let r=bie(this.content,e+this.openStart,n);return r&&new an(r,this.openStart,this.openEnd)}removeBetween(e,n){return new an(gie(this.content,e+this.openStart,n+this.openStart),this.openStart,this.openEnd)}eq(e){return this.content.eq(e.content)&&this.openStart==e.openStart&&this.openEnd==e.openEnd}toString(){return this.content+\"(\"+this.openStart+\",\"+this.openEnd+\")\"}toJSON(){if(!this.content.size)return null;let e={content:this.content.toJSON()};return this.openStart>0&&(e.openStart=this.openStart),this.openEnd>0&&(e.openEnd=this.openEnd),e}static fromJSON(e,n){if(!n)return an.empty;let r=n.openStart||0,i=n.openEnd||0;if(typeof r!=\"number\"||typeof i!=\"number\")throw new RangeError(\"Invalid input for Slice.fromJSON\");return new an(Vt.fromJSON(e,n.content),r,i)}static maxOpen(e,n=!0){let r=0,i=0;for(let s=e.firstChild;s&&!s.isLeaf&&(n||!s.type.spec.isolating);s=s.firstChild)r++;for(let s=e.lastChild;s&&!s.isLeaf&&(n||!s.type.spec.isolating);s=s.lastChild)i++;return new an(e,r,i)}}an.empty=new an(Vt.empty,0,0);function gie(t,e,n){let{index:r,offset:i}=t.findIndex(e),s=t.maybeChild(r),{index:o,offset:l}=t.findIndex(n);if(i==e||s.isText){if(l!=n&&!t.child(o).isText)throw new RangeError(\"Removing non-flat range\");return t.cut(0,e).append(t.cut(n))}if(r!=o)throw new RangeError(\"Removing non-flat range\");return t.replaceChild(r,s.copy(gie(s.content,e-i-1,n-i-1)))}function bie(t,e,n,r){let{index:i,offset:s}=t.findIndex(e),o=t.maybeChild(i);if(s==e||o.isText)return r&&!r.canReplace(i,i,n)?null:t.cut(0,e).append(n).append(t.cut(e));let l=bie(o.content,e-s-1,n,o);return l&&t.replaceChild(i,o.copy(l))}function SGe(t,e,n){if(n.openStart>t.depth)throw new HC(\"Inserted content deeper than insertion position\");if(t.depth-n.openStart!=e.depth-n.openEnd)throw new HC(\"Inconsistent open depths\");return xie(t,e,n,0)}function xie(t,e,n,r){let i=t.index(r),s=t.node(r);if(i==e.index(r)&&r<t.depth-n.openStart){let o=xie(t,e,n,r+1);return s.copy(s.content.replaceChild(i,o))}else if(n.content.size)if(!n.openStart&&!n.openEnd&&t.depth==r&&e.depth==r){let o=t.parent,l=o.content;return ng(o,l.cut(0,t.parentOffset).append(n.content).append(l.cut(e.parentOffset)))}else{let{start:o,end:l}=_Ge(n,t);return ng(s,vie(t,o,l,e,r))}else return ng(s,qC(t,e,r))}function yie(t,e){if(!e.type.compatibleContent(t.type))throw new HC(\"Cannot join \"+e.type.name+\" onto \"+t.type.name)}function NL(t,e,n){let r=t.node(n);return yie(r,e.node(n)),r}function tg(t,e){let n=e.length-1;n>=0&&t.isText&&t.sameMarkup(e[n])?e[n]=t.withText(e[n].text+t.text):e.push(t)}function I0(t,e,n,r){let i=(e||t).node(n),s=0,o=e?e.index(n):i.childCount;t&&(s=t.index(n),t.depth>n?s++:t.textOffset&&(tg(t.nodeAfter,r),s++));for(let l=s;l<o;l++)tg(i.child(l),r);e&&e.depth==n&&e.textOffset&&tg(e.nodeBefore,r)}function ng(t,e){return t.type.checkContent(e),t.copy(e)}function vie(t,e,n,r,i){let s=t.depth>i&&NL(t,e,i+1),o=r.depth>i&&NL(n,r,i+1),l=[];return I0(null,t,i,l),s&&o&&e.index(i)==n.index(i)?(yie(s,o),tg(ng(s,vie(t,e,n,r,i+1)),l)):(s&&tg(ng(s,qC(t,e,i+1)),l),I0(e,n,i,l),o&&tg(ng(o,qC(n,r,i+1)),l)),I0(r,null,i,l),new Vt(l)}function qC(t,e,n){let r=[];if(I0(null,t,n,r),t.depth>n){let i=NL(t,e,n+1);tg(ng(i,qC(t,e,n+1)),r)}return I0(e,null,n,r),new Vt(r)}function _Ge(t,e){let n=e.depth-t.openStart,i=e.node(n).copy(t.content);for(let s=n-1;s>=0;s--)i=e.node(s).copy(Vt.from(i));return{start:i.resolveNoCache(t.openStart+n),end:i.resolveNoCache(i.content.size-t.openEnd-n)}}class Fw{constructor(e,n,r){this.pos=e,this.path=n,this.parentOffset=r,this.depth=n.length/3-1}resolveDepth(e){return e==null?this.depth:e<0?this.depth+e:e}get parent(){return this.node(this.depth)}get doc(){return this.node(0)}node(e){return this.path[this.resolveDepth(e)*3]}index(e){return this.path[this.resolveDepth(e)*3+1]}indexAfter(e){return e=this.resolveDepth(e),this.index(e)+(e==this.depth&&!this.textOffset?0:1)}start(e){return e=this.resolveDepth(e),e==0?0:this.path[e*3-1]+1}end(e){return e=this.resolveDepth(e),this.start(e)+this.node(e).content.size}before(e){if(e=this.resolveDepth(e),!e)throw new RangeError(\"There is no position before the top-level node\");return e==this.depth+1?this.pos:this.path[e*3-1]}after(e){if(e=this.resolveDepth(e),!e)throw new RangeError(\"There is no position after the top-level node\");return e==this.depth+1?this.pos:this.path[e*3-1]+this.path[e*3].nodeSize}get textOffset(){return this.pos-this.path[this.path.length-1]}get nodeAfter(){let e=this.parent,n=this.index(this.depth);if(n==e.childCount)return null;let r=this.pos-this.path[this.path.length-1],i=e.child(n);return r?e.child(n).cut(r):i}get nodeBefore(){let e=this.index(this.depth),n=this.pos-this.path[this.path.length-1];return n?this.parent.child(e).cut(0,n):e==0?null:this.parent.child(e-1)}posAtIndex(e,n){n=this.resolveDepth(n);let r=this.path[n*3],i=n==0?0:this.path[n*3-1]+1;for(let s=0;s<e;s++)i+=r.child(s).nodeSize;return i}marks(){let e=this.parent,n=this.index();if(e.content.size==0)return sa.none;if(this.textOffset)return e.child(n).marks;let r=e.maybeChild(n-1),i=e.maybeChild(n);if(!r){let l=r;r=i,i=l}let s=r.marks;for(var o=0;o<s.length;o++)s[o].type.spec.inclusive===!1&&(!i||!s[o].isInSet(i.marks))&&(s=s[o--].removeFromSet(s));return s}marksAcross(e){let n=this.parent.maybeChild(this.index());if(!n||!n.isInline)return null;let r=n.marks,i=e.parent.maybeChild(e.index());for(var s=0;s<r.length;s++)r[s].type.spec.inclusive===!1&&(!i||!r[s].isInSet(i.marks))&&(r=r[s--].removeFromSet(r));return r}sharedDepth(e){for(let n=this.depth;n>0;n--)if(this.start(n)<=e&&this.end(n)>=e)return n;return 0}blockRange(e=this,n){if(e.pos<this.pos)return e.blockRange(this);for(let r=this.depth-(this.parent.inlineContent||this.pos==e.pos?1:0);r>=0;r--)if(e.pos<=this.end(r)&&(!n||n(this.node(r))))return new $C(this,e,r);return null}sameParent(e){return this.pos-this.parentOffset==e.pos-e.parentOffset}max(e){return e.pos>this.pos?e:this}min(e){return e.pos<this.pos?e:this}toString(){let e=\"\";for(let n=1;n<=this.depth;n++)e+=(e?\"/\":\"\")+this.node(n).type.name+\"_\"+this.index(n-1);return e+\":\"+this.parentOffset}static resolve(e,n){if(!(n>=0&&n<=e.content.size))throw new RangeError(\"Position \"+n+\" out of range\");let r=[],i=0,s=n;for(let o=e;;){let{index:l,offset:c}=o.content.findIndex(s),d=s-c;if(r.push(o,l,i+c),!d||(o=o.child(l),o.isText))break;s=d-1,i+=c+1}return new Fw(n,r,s)}static resolveCached(e,n){let r=_K.get(e);if(r)for(let s=0;s<r.elts.length;s++){let o=r.elts[s];if(o.pos==n)return o}else _K.set(e,r=new kGe);let i=r.elts[r.i]=Fw.resolve(e,n);return r.i=(r.i+1)%NGe,i}}class kGe{constructor(){this.elts=[],this.i=0}}const NGe=12,_K=new WeakMap;class $C{constructor(e,n,r){this.$from=e,this.$to=n,this.depth=r}get start(){return this.$from.before(this.depth+1)}get end(){return this.$to.after(this.depth+1)}get parent(){return this.$from.node(this.depth)}get startIndex(){return this.$from.index(this.depth)}get endIndex(){return this.$to.indexAfter(this.depth)}}const CGe=Object.create(null);class Yc{constructor(e,n,r,i=sa.none){this.type=e,this.attrs=n,this.marks=i,this.content=r||Vt.empty}get children(){return this.content.content}get nodeSize(){return this.isLeaf?1:2+this.content.size}get childCount(){return this.content.childCount}child(e){return this.content.child(e)}maybeChild(e){return this.content.maybeChild(e)}forEach(e){this.content.forEach(e)}nodesBetween(e,n,r,i=0){this.content.nodesBetween(e,n,r,i,this)}descendants(e){this.nodesBetween(0,this.content.size,e)}get textContent(){return this.isLeaf&&this.type.spec.leafText?this.type.spec.leafText(this):this.textBetween(0,this.content.size,\"\")}textBetween(e,n,r,i){return this.content.textBetween(e,n,r,i)}get firstChild(){return this.content.firstChild}get lastChild(){return this.content.lastChild}eq(e){return this==e||this.sameMarkup(e)&&this.content.eq(e.content)}sameMarkup(e){return this.hasMarkup(e.type,e.attrs,e.marks)}hasMarkup(e,n,r){return this.type==e&&BC(this.attrs,n||e.defaultAttrs||CGe)&&sa.sameSet(this.marks,r||sa.none)}copy(e=null){return e==this.content?this:new Yc(this.type,this.attrs,e,this.marks)}mark(e){return e==this.marks?this:new Yc(this.type,this.attrs,this.content,e)}cut(e,n=this.content.size){return e==0&&n==this.content.size?this:this.copy(this.content.cut(e,n))}slice(e,n=this.content.size,r=!1){if(e==n)return an.empty;let i=this.resolve(e),s=this.resolve(n),o=r?0:i.sharedDepth(n),l=i.start(o),d=i.node(o).content.cut(i.pos-l,s.pos-l);return new an(d,i.depth-o,s.depth-o)}replace(e,n,r){return SGe(this.resolve(e),this.resolve(n),r)}nodeAt(e){for(let n=this;;){let{index:r,offset:i}=n.content.findIndex(e);if(n=n.maybeChild(r),!n)return null;if(i==e||n.isText)return n;e-=i+1}}childAfter(e){let{index:n,offset:r}=this.content.findIndex(e);return{node:this.content.maybeChild(n),index:n,offset:r}}childBefore(e){if(e==0)return{node:null,index:0,offset:0};let{index:n,offset:r}=this.content.findIndex(e);if(r<e)return{node:this.content.child(n),index:n,offset:r};let i=this.content.child(n-1);return{node:i,index:n-1,offset:r-i.nodeSize}}resolve(e){return Fw.resolveCached(this,e)}resolveNoCache(e){return Fw.resolve(this,e)}rangeHasMark(e,n,r){let i=!1;return n>e&&this.nodesBetween(e,n,s=>(r.isInSet(s.marks)&&(i=!0),!i)),i}get isBlock(){return this.type.isBlock}get isTextblock(){return this.type.isTextblock}get inlineContent(){return this.type.inlineContent}get isInline(){return this.type.isInline}get isText(){return this.type.isText}get isLeaf(){return this.type.isLeaf}get isAtom(){return this.type.isAtom}toString(){if(this.type.spec.toDebugString)return this.type.spec.toDebugString(this);let e=this.type.name;return this.content.size&&(e+=\"(\"+this.content.toStringInner()+\")\"),wie(this.marks,e)}contentMatchAt(e){let n=this.type.contentMatch.matchFragment(this.content,0,e);if(!n)throw new Error(\"Called contentMatchAt on a node with invalid content\");return n}canReplace(e,n,r=Vt.empty,i=0,s=r.childCount){let o=this.contentMatchAt(e).matchFragment(r,i,s),l=o&&o.matchFragment(this.content,n);if(!l||!l.validEnd)return!1;for(let c=i;c<s;c++)if(!this.type.allowsMarks(r.child(c).marks))return!1;return!0}canReplaceWith(e,n,r,i){if(i&&!this.type.allowsMarks(i))return!1;let s=this.contentMatchAt(e).matchType(r),o=s&&s.matchFragment(this.content,n);return o?o.validEnd:!1}canAppend(e){return e.content.size?this.canReplace(this.childCount,this.childCount,e.content):this.type.compatibleContent(e.type)}check(){this.type.checkContent(this.content),this.type.checkAttrs(this.attrs);let e=sa.none;for(let n=0;n<this.marks.length;n++){let r=this.marks[n];r.type.checkAttrs(r.attrs),e=r.addToSet(e)}if(!sa.sameSet(e,this.marks))throw new RangeError(`Invalid collection of marks for node ${this.type.name}: ${this.marks.map(n=>n.type.name)}`);this.content.forEach(n=>n.check())}toJSON(){let e={type:this.type.name};for(let n in this.attrs){e.attrs=this.attrs;break}return this.content.size&&(e.content=this.content.toJSON()),this.marks.length&&(e.marks=this.marks.map(n=>n.toJSON())),e}static fromJSON(e,n){if(!n)throw new RangeError(\"Invalid input for Node.fromJSON\");let r;if(n.marks){if(!Array.isArray(n.marks))throw new RangeError(\"Invalid mark data for Node.fromJSON\");r=n.marks.map(e.markFromJSON)}if(n.type==\"text\"){if(typeof n.text!=\"string\")throw new RangeError(\"Invalid text node in JSON\");return e.text(n.text,r)}let i=Vt.fromJSON(e,n.content),s=e.nodeType(n.type).create(n.attrs,i,r);return s.type.checkAttrs(s.attrs),s}}Yc.prototype.text=void 0;class VC extends Yc{constructor(e,n,r,i){if(super(e,n,null,i),!r)throw new RangeError(\"Empty text nodes are not allowed\");this.text=r}toString(){return this.type.spec.toDebugString?this.type.spec.toDebugString(this):wie(this.marks,JSON.stringify(this.text))}get textContent(){return this.text}textBetween(e,n){return this.text.slice(e,n)}get nodeSize(){return this.text.length}mark(e){return e==this.marks?this:new VC(this.type,this.attrs,this.text,e)}withText(e){return e==this.text?this:new VC(this.type,this.attrs,e,this.marks)}cut(e=0,n=this.text.length){return e==0&&n==this.text.length?this:this.withText(this.text.slice(e,n))}eq(e){return this.sameMarkup(e)&&this.text==e.text}toJSON(){let e=super.toJSON();return e.text=this.text,e}}function wie(t,e){for(let n=t.length-1;n>=0;n--)e=t[n].type.name+\"(\"+e+\")\";return e}class jg{constructor(e){this.validEnd=e,this.next=[],this.wrapCache=[]}static parse(e,n){let r=new PGe(e,n);if(r.next==null)return jg.empty;let i=Sie(r);r.next&&r.err(\"Unexpected trailing text\");let s=FGe(DGe(i));return RGe(s,r),s}matchType(e){for(let n=0;n<this.next.length;n++)if(this.next[n].type==e)return this.next[n].next;return null}matchFragment(e,n=0,r=e.childCount){let i=this;for(let s=n;i&&s<r;s++)i=i.matchType(e.child(s).type);return i}get inlineContent(){return this.next.length!=0&&this.next[0].type.isInline}get defaultType(){for(let e=0;e<this.next.length;e++){let{type:n}=this.next[e];if(!(n.isText||n.hasRequiredAttrs()))return n}return null}compatible(e){for(let n=0;n<this.next.length;n++)for(let r=0;r<e.next.length;r++)if(this.next[n].type==e.next[r].type)return!0;return!1}fillBefore(e,n=!1,r=0){let i=[this];function s(o,l){let c=o.matchFragment(e,r);if(c&&(!n||c.validEnd))return Vt.from(l.map(d=>d.createAndFill()));for(let d=0;d<o.next.length;d++){let{type:u,next:m}=o.next[d];if(!(u.isText||u.hasRequiredAttrs())&&i.indexOf(m)==-1){i.push(m);let p=s(m,l.concat(u));if(p)return p}}return null}return s(this,[])}findWrapping(e){for(let r=0;r<this.wrapCache.length;r+=2)if(this.wrapCache[r]==e)return this.wrapCache[r+1];let n=this.computeWrapping(e);return this.wrapCache.push(e,n),n}computeWrapping(e){let n=Object.create(null),r=[{match:this,type:null,via:null}];for(;r.length;){let i=r.shift(),s=i.match;if(s.matchType(e)){let o=[];for(let l=i;l.type;l=l.via)o.push(l.type);return o.reverse()}for(let o=0;o<s.next.length;o++){let{type:l,next:c}=s.next[o];!l.isLeaf&&!l.hasRequiredAttrs()&&!(l.name in n)&&(!i.type||c.validEnd)&&(r.push({match:l.contentMatch,type:l,via:i}),n[l.name]=!0)}}return null}get edgeCount(){return this.next.length}edge(e){if(e>=this.next.length)throw new RangeError(`There's no ${e}th edge in this content match`);return this.next[e]}toString(){let e=[];function n(r){e.push(r);for(let i=0;i<r.next.length;i++)e.indexOf(r.next[i].next)==-1&&n(r.next[i].next)}return n(this),e.map((r,i)=>{let s=i+(r.validEnd?\"*\":\" \")+\" \";for(let o=0;o<r.next.length;o++)s+=(o?\", \":\"\")+r.next[o].type.name+\"->\"+e.indexOf(r.next[o].next);return s}).join(`\n`)}}jg.empty=new jg(!0);class PGe{constructor(e,n){this.string=e,this.nodeTypes=n,this.inline=null,this.pos=0,this.tokens=e.split(/\\s*(?=\\b|\\W|$)/),this.tokens[this.tokens.length-1]==\"\"&&this.tokens.pop(),this.tokens[0]==\"\"&&this.tokens.shift()}get next(){return this.tokens[this.pos]}eat(e){return this.next==e&&(this.pos++||!0)}err(e){throw new SyntaxError(e+\" (in content expression '\"+this.string+\"')\")}}function Sie(t){let e=[];do e.push(TGe(t));while(t.eat(\"|\"));return e.length==1?e[0]:{type:\"choice\",exprs:e}}function TGe(t){let e=[];do e.push(AGe(t));while(t.next&&t.next!=\")\"&&t.next!=\"|\");return e.length==1?e[0]:{type:\"seq\",exprs:e}}function AGe(t){let e=EGe(t);for(;;)if(t.eat(\"+\"))e={type:\"plus\",expr:e};else if(t.eat(\"*\"))e={type:\"star\",expr:e};else if(t.eat(\"?\"))e={type:\"opt\",expr:e};else if(t.eat(\"{\"))e=jGe(t,e);else break;return e}function kK(t){/\\D/.test(t.next)&&t.err(\"Expected number, got '\"+t.next+\"'\");let e=Number(t.next);return t.pos++,e}function jGe(t,e){let n=kK(t),r=n;return t.eat(\",\")&&(t.next!=\"}\"?r=kK(t):r=-1),t.eat(\"}\")||t.err(\"Unclosed braced range\"),{type:\"range\",min:n,max:r,expr:e}}function MGe(t,e){let n=t.nodeTypes,r=n[e];if(r)return[r];let i=[];for(let s in n){let o=n[s];o.isInGroup(e)&&i.push(o)}return i.length==0&&t.err(\"No node type or group '\"+e+\"' found\"),i}function EGe(t){if(t.eat(\"(\")){let e=Sie(t);return t.eat(\")\")||t.err(\"Missing closing paren\"),e}else if(/\\W/.test(t.next))t.err(\"Unexpected token '\"+t.next+\"'\");else{let e=MGe(t,t.next).map(n=>(t.inline==null?t.inline=n.isInline:t.inline!=n.isInline&&t.err(\"Mixing inline and block content\"),{type:\"name\",value:n}));return t.pos++,e.length==1?e[0]:{type:\"choice\",exprs:e}}}function DGe(t){let e=[[]];return i(s(t,0),n()),e;function n(){return e.push([])-1}function r(o,l,c){let d={term:c,to:l};return e[o].push(d),d}function i(o,l){o.forEach(c=>c.to=l)}function s(o,l){if(o.type==\"choice\")return o.exprs.reduce((c,d)=>c.concat(s(d,l)),[]);if(o.type==\"seq\")for(let c=0;;c++){let d=s(o.exprs[c],l);if(c==o.exprs.length-1)return d;i(d,l=n())}else if(o.type==\"star\"){let c=n();return r(l,c),i(s(o.expr,c),c),[r(c)]}else if(o.type==\"plus\"){let c=n();return i(s(o.expr,l),c),i(s(o.expr,c),c),[r(c)]}else{if(o.type==\"opt\")return[r(l)].concat(s(o.expr,l));if(o.type==\"range\"){let c=l;for(let d=0;d<o.min;d++){let u=n();i(s(o.expr,c),u),c=u}if(o.max==-1)i(s(o.expr,c),c);else for(let d=o.min;d<o.max;d++){let u=n();r(c,u),i(s(o.expr,c),u),c=u}return[r(c)]}else{if(o.type==\"name\")return[r(l,void 0,o.value)];throw new Error(\"Unknown expr type\")}}}}function _ie(t,e){return e-t}function NK(t,e){let n=[];return r(e),n.sort(_ie);function r(i){let s=t[i];if(s.length==1&&!s[0].term)return r(s[0].to);n.push(i);for(let o=0;o<s.length;o++){let{term:l,to:c}=s[o];!l&&n.indexOf(c)==-1&&r(c)}}}function FGe(t){let e=Object.create(null);return n(NK(t,0));function n(r){let i=[];r.forEach(o=>{t[o].forEach(({term:l,to:c})=>{if(!l)return;let d;for(let u=0;u<i.length;u++)i[u][0]==l&&(d=i[u][1]);NK(t,c).forEach(u=>{d||i.push([l,d=[]]),d.indexOf(u)==-1&&d.push(u)})})});let s=e[r.join(\",\")]=new jg(r.indexOf(t.length-1)>-1);for(let o=0;o<i.length;o++){let l=i[o][1].sort(_ie);s.next.push({type:i[o][0],next:e[l.join(\",\")]||n(l)})}return s}}function RGe(t,e){for(let n=0,r=[t];n<r.length;n++){let i=r[n],s=!i.validEnd,o=[];for(let l=0;l<i.next.length;l++){let{type:c,next:d}=i.next[l];o.push(c.name),s&&!(c.isText||c.hasRequiredAttrs())&&(s=!1),r.indexOf(d)==-1&&r.push(d)}s&&e.err(\"Only non-generatable nodes (\"+o.join(\", \")+\") in a required position (see https://prosemirror.net/docs/guide/#generatable)\")}}function kie(t){let e=Object.create(null);for(let n in t){let r=t[n];if(!r.hasDefault)return null;e[n]=r.default}return e}function Nie(t,e){let n=Object.create(null);for(let r in t){let i=e&&e[r];if(i===void 0){let s=t[r];if(s.hasDefault)i=s.default;else throw new RangeError(\"No value supplied for attribute \"+r)}n[r]=i}return n}function Cie(t,e,n,r){for(let i in e)if(!(i in t))throw new RangeError(`Unsupported attribute ${i} for ${n} of type ${i}`);for(let i in t){let s=t[i];s.validate&&s.validate(e[i])}}function Pie(t,e){let n=Object.create(null);if(e)for(let r in e)n[r]=new OGe(t,r,e[r]);return n}let CK=class Tie{constructor(e,n,r){this.name=e,this.schema=n,this.spec=r,this.markSet=null,this.groups=r.group?r.group.split(\" \"):[],this.attrs=Pie(e,r.attrs),this.defaultAttrs=kie(this.attrs),this.contentMatch=null,this.inlineContent=null,this.isBlock=!(r.inline||e==\"text\"),this.isText=e==\"text\"}get isInline(){return!this.isBlock}get isTextblock(){return this.isBlock&&this.inlineContent}get isLeaf(){return this.contentMatch==jg.empty}get isAtom(){return this.isLeaf||!!this.spec.atom}isInGroup(e){return this.groups.indexOf(e)>-1}get whitespace(){return this.spec.whitespace||(this.spec.code?\"pre\":\"normal\")}hasRequiredAttrs(){for(let e in this.attrs)if(this.attrs[e].isRequired)return!0;return!1}compatibleContent(e){return this==e||this.contentMatch.compatible(e.contentMatch)}computeAttrs(e){return!e&&this.defaultAttrs?this.defaultAttrs:Nie(this.attrs,e)}create(e=null,n,r){if(this.isText)throw new Error(\"NodeType.create can't construct text nodes\");return new Yc(this,this.computeAttrs(e),Vt.from(n),sa.setFrom(r))}createChecked(e=null,n,r){return n=Vt.from(n),this.checkContent(n),new Yc(this,this.computeAttrs(e),n,sa.setFrom(r))}createAndFill(e=null,n,r){if(e=this.computeAttrs(e),n=Vt.from(n),n.size){let o=this.contentMatch.fillBefore(n);if(!o)return null;n=o.append(n)}let i=this.contentMatch.matchFragment(n),s=i&&i.fillBefore(Vt.empty,!0);return s?new Yc(this,e,n.append(s),sa.setFrom(r)):null}validContent(e){let n=this.contentMatch.matchFragment(e);if(!n||!n.validEnd)return!1;for(let r=0;r<e.childCount;r++)if(!this.allowsMarks(e.child(r).marks))return!1;return!0}checkContent(e){if(!this.validContent(e))throw new RangeError(`Invalid content for node ${this.name}: ${e.toString().slice(0,50)}`)}checkAttrs(e){Cie(this.attrs,e,\"node\",this.name)}allowsMarkType(e){return this.markSet==null||this.markSet.indexOf(e)>-1}allowsMarks(e){if(this.markSet==null)return!0;for(let n=0;n<e.length;n++)if(!this.allowsMarkType(e[n].type))return!1;return!0}allowedMarks(e){if(this.markSet==null)return e;let n;for(let r=0;r<e.length;r++)this.allowsMarkType(e[r].type)?n&&n.push(e[r]):n||(n=e.slice(0,r));return n?n.length?n:sa.none:e}static compile(e,n){let r=Object.create(null);e.forEach((s,o)=>r[s]=new Tie(s,n,o));let i=n.spec.topNode||\"doc\";if(!r[i])throw new RangeError(\"Schema is missing its top node type ('\"+i+\"')\");if(!r.text)throw new RangeError(\"Every schema needs a 'text' type\");for(let s in r.text.attrs)throw new RangeError(\"The text node type should not have attributes\");return r}};function LGe(t,e,n){let r=n.split(\"|\");return i=>{let s=i===null?\"null\":typeof i;if(r.indexOf(s)<0)throw new RangeError(`Expected value of type ${r} for attribute ${e} on type ${t}, got ${s}`)}}class OGe{constructor(e,n,r){this.hasDefault=Object.prototype.hasOwnProperty.call(r,\"default\"),this.default=r.default,this.validate=typeof r.validate==\"string\"?LGe(e,n,r.validate):r.validate}get isRequired(){return!this.hasDefault}}class eA{constructor(e,n,r,i){this.name=e,this.rank=n,this.schema=r,this.spec=i,this.attrs=Pie(e,i.attrs),this.excluded=null;let s=kie(this.attrs);this.instance=s?new sa(this,s):null}create(e=null){return!e&&this.instance?this.instance:new sa(this,Nie(this.attrs,e))}static compile(e,n){let r=Object.create(null),i=0;return e.forEach((s,o)=>r[s]=new eA(s,i++,n,o)),r}removeFromSet(e){for(var n=0;n<e.length;n++)e[n].type==this&&(e=e.slice(0,n).concat(e.slice(n+1)),n--);return e}isInSet(e){for(let n=0;n<e.length;n++)if(e[n].type==this)return e[n]}checkAttrs(e){Cie(this.attrs,e,\"mark\",this.name)}excludes(e){return this.excluded.indexOf(e)>-1}}class Aie{constructor(e){this.linebreakReplacement=null,this.cached=Object.create(null);let n=this.spec={};for(let i in e)n[i]=e[i];n.nodes=ps.from(e.nodes),n.marks=ps.from(e.marks||{}),this.nodes=CK.compile(this.spec.nodes,this),this.marks=eA.compile(this.spec.marks,this);let r=Object.create(null);for(let i in this.nodes){if(i in this.marks)throw new RangeError(i+\" can not be both a node and a mark\");let s=this.nodes[i],o=s.spec.content||\"\",l=s.spec.marks;if(s.contentMatch=r[o]||(r[o]=jg.parse(o,this.nodes)),s.inlineContent=s.contentMatch.inlineContent,s.spec.linebreakReplacement){if(this.linebreakReplacement)throw new RangeError(\"Multiple linebreak nodes defined\");if(!s.isInline||!s.isLeaf)throw new RangeError(\"Linebreak replacement nodes must be inline leaf nodes\");this.linebreakReplacement=s}s.markSet=l==\"_\"?null:l?PK(this,l.split(\" \")):l==\"\"||!s.inlineContent?[]:null}for(let i in this.marks){let s=this.marks[i],o=s.spec.excludes;s.excluded=o==null?[s]:o==\"\"?[]:PK(this,o.split(\" \"))}this.nodeFromJSON=i=>Yc.fromJSON(this,i),this.markFromJSON=i=>sa.fromJSON(this,i),this.topNodeType=this.nodes[this.spec.topNode||\"doc\"],this.cached.wrappings=Object.create(null)}node(e,n=null,r,i){if(typeof e==\"string\")e=this.nodeType(e);else if(e instanceof CK){if(e.schema!=this)throw new RangeError(\"Node type from different schema used (\"+e.name+\")\")}else throw new RangeError(\"Invalid node type: \"+e);return e.createChecked(n,r,i)}text(e,n){let r=this.nodes.text;return new VC(r,r.defaultAttrs,e,sa.setFrom(n))}mark(e,n){return typeof e==\"string\"&&(e=this.marks[e]),e.create(n)}nodeType(e){let n=this.nodes[e];if(!n)throw new RangeError(\"Unknown node type: \"+e);return n}}function PK(t,e){let n=[];for(let r=0;r<e.length;r++){let i=e[r],s=t.marks[i],o=s;if(s)n.push(s);else for(let l in t.marks){let c=t.marks[l];(i==\"_\"||c.spec.group&&c.spec.group.split(\" \").indexOf(i)>-1)&&n.push(o=c)}if(!o)throw new SyntaxError(\"Unknown mark type: '\"+e[r]+\"'\")}return n}function IGe(t){return t.tag!=null}function zGe(t){return t.style!=null}let z0=class CL{constructor(e,n){this.schema=e,this.rules=n,this.tags=[],this.styles=[];let r=this.matchedStyles=[];n.forEach(i=>{if(IGe(i))this.tags.push(i);else if(zGe(i)){let s=/[^=]*/.exec(i.style)[0];r.indexOf(s)<0&&r.push(s),this.styles.push(i)}}),this.normalizeLists=!this.tags.some(i=>{if(!/^(ul|ol)\\b/.test(i.tag)||!i.node)return!1;let s=e.nodes[i.node];return s.contentMatch.matchType(s)})}parse(e,n={}){let r=new AK(this,n,!1);return r.addAll(e,sa.none,n.from,n.to),r.finish()}parseSlice(e,n={}){let r=new AK(this,n,!0);return r.addAll(e,sa.none,n.from,n.to),an.maxOpen(r.finish())}matchTag(e,n,r){for(let i=r?this.tags.indexOf(r)+1:0;i<this.tags.length;i++){let s=this.tags[i];if(HGe(e,s.tag)&&(s.namespace===void 0||e.namespaceURI==s.namespace)&&(!s.context||n.matchesContext(s.context))){if(s.getAttrs){let o=s.getAttrs(e);if(o===!1)continue;s.attrs=o||void 0}return s}}}matchStyle(e,n,r,i){for(let s=i?this.styles.indexOf(i)+1:0;s<this.styles.length;s++){let o=this.styles[s],l=o.style;if(!(l.indexOf(e)!=0||o.context&&!r.matchesContext(o.context)||l.length>e.length&&(l.charCodeAt(e.length)!=61||l.slice(e.length+1)!=n))){if(o.getAttrs){let c=o.getAttrs(n);if(c===!1)continue;o.attrs=c||void 0}return o}}}static schemaRules(e){let n=[];function r(i){let s=i.priority==null?50:i.priority,o=0;for(;o<n.length;o++){let l=n[o];if((l.priority==null?50:l.priority)<s)break}n.splice(o,0,i)}for(let i in e.marks){let s=e.marks[i].spec.parseDOM;s&&s.forEach(o=>{r(o=jK(o)),o.mark||o.ignore||o.clearMark||(o.mark=i)})}for(let i in e.nodes){let s=e.nodes[i].spec.parseDOM;s&&s.forEach(o=>{r(o=jK(o)),o.node||o.ignore||o.mark||(o.node=i)})}return n}static fromSchema(e){return e.cached.domParser||(e.cached.domParser=new CL(e,CL.schemaRules(e)))}};const jie={address:!0,article:!0,aside:!0,blockquote:!0,canvas:!0,dd:!0,div:!0,dl:!0,fieldset:!0,figcaption:!0,figure:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,li:!0,noscript:!0,ol:!0,output:!0,p:!0,pre:!0,section:!0,table:!0,tfoot:!0,ul:!0},UGe={head:!0,noscript:!0,object:!0,script:!0,style:!0,title:!0},Mie={ol:!0,ul:!0},Rw=1,PL=2,U0=4;function TK(t,e,n){return e!=null?(e?Rw:0)|(e===\"full\"?PL:0):t&&t.whitespace==\"pre\"?Rw|PL:n&~U0}class xk{constructor(e,n,r,i,s,o){this.type=e,this.attrs=n,this.marks=r,this.solid=i,this.options=o,this.content=[],this.activeMarks=sa.none,this.match=s||(o&U0?null:e.contentMatch)}findWrapping(e){if(!this.match){if(!this.type)return[];let n=this.type.contentMatch.fillBefore(Vt.from(e));if(n)this.match=this.type.contentMatch.matchFragment(n);else{let r=this.type.contentMatch,i;return(i=r.findWrapping(e.type))?(this.match=r,i):null}}return this.match.findWrapping(e.type)}finish(e){if(!(this.options&Rw)){let r=this.content[this.content.length-1],i;if(r&&r.isText&&(i=/[ \\t\\r\\n\\u000c]+$/.exec(r.text))){let s=r;r.text.length==i[0].length?this.content.pop():this.content[this.content.length-1]=s.withText(s.text.slice(0,s.text.length-i[0].length))}}let n=Vt.from(this.content);return!e&&this.match&&(n=n.append(this.match.fillBefore(Vt.empty,!0))),this.type?this.type.create(this.attrs,n,this.marks):n}inlineContext(e){return this.type?this.type.inlineContent:this.content.length?this.content[0].isInline:e.parentNode&&!jie.hasOwnProperty(e.parentNode.nodeName.toLowerCase())}}class AK{constructor(e,n,r){this.parser=e,this.options=n,this.isOpen=r,this.open=0,this.localPreserveWS=!1;let i=n.topNode,s,o=TK(null,n.preserveWhitespace,0)|(r?U0:0);i?s=new xk(i.type,i.attrs,sa.none,!0,n.topMatch||i.type.contentMatch,o):r?s=new xk(null,null,sa.none,!0,null,o):s=new xk(e.schema.topNodeType,null,sa.none,!0,null,o),this.nodes=[s],this.find=n.findPositions,this.needsBlock=!1}get top(){return this.nodes[this.open]}addDOM(e,n){e.nodeType==3?this.addTextNode(e,n):e.nodeType==1&&this.addElement(e,n)}addTextNode(e,n){let r=e.nodeValue,i=this.top,s=i.options&PL?\"full\":this.localPreserveWS||(i.options&Rw)>0,{schema:o}=this.parser;if(s===\"full\"||i.inlineContext(e)||/[^ \\t\\r\\n\\u000c]/.test(r)){if(s)if(s===\"full\")r=r.replace(/\\r\\n?/g,`\n`);else if(o.linebreakReplacement&&/[\\r\\n]/.test(r)&&this.top.findWrapping(o.linebreakReplacement.create())){let l=r.split(/\\r?\\n|\\r/);for(let c=0;c<l.length;c++)c&&this.insertNode(o.linebreakReplacement.create(),n,!0),l[c]&&this.insertNode(o.text(l[c]),n,!/\\S/.test(l[c]));r=\"\"}else r=r.replace(/\\r?\\n|\\r/g,\" \");else if(r=r.replace(/[ \\t\\r\\n\\u000c]+/g,\" \"),/^[ \\t\\r\\n\\u000c]/.test(r)&&this.open==this.nodes.length-1){let l=i.content[i.content.length-1],c=e.previousSibling;(!l||c&&c.nodeName==\"BR\"||l.isText&&/[ \\t\\r\\n\\u000c]$/.test(l.text))&&(r=r.slice(1))}r&&this.insertNode(o.text(r),n,!/\\S/.test(r)),this.findInText(e)}else this.findInside(e)}addElement(e,n,r){let i=this.localPreserveWS,s=this.top;(e.tagName==\"PRE\"||/pre/.test(e.style&&e.style.whiteSpace))&&(this.localPreserveWS=!0);let o=e.nodeName.toLowerCase(),l;Mie.hasOwnProperty(o)&&this.parser.normalizeLists&&BGe(e);let c=this.options.ruleFromNode&&this.options.ruleFromNode(e)||(l=this.parser.matchTag(e,this,r));e:if(c?c.ignore:UGe.hasOwnProperty(o))this.findInside(e),this.ignoreFallback(e,n);else if(!c||c.skip||c.closeParent){c&&c.closeParent?this.open=Math.max(0,this.open-1):c&&c.skip.nodeType&&(e=c.skip);let d,u=this.needsBlock;if(jie.hasOwnProperty(o))s.content.length&&s.content[0].isInline&&this.open&&(this.open--,s=this.top),d=!0,s.type||(this.needsBlock=!0);else if(!e.firstChild){this.leafFallback(e,n);break e}let m=c&&c.skip?n:this.readStyles(e,n);m&&this.addAll(e,m),d&&this.sync(s),this.needsBlock=u}else{let d=this.readStyles(e,n);d&&this.addElementByRule(e,c,d,c.consuming===!1?l:void 0)}this.localPreserveWS=i}leafFallback(e,n){e.nodeName==\"BR\"&&this.top.type&&this.top.type.inlineContent&&this.addTextNode(e.ownerDocument.createTextNode(`\n`),n)}ignoreFallback(e,n){e.nodeName==\"BR\"&&(!this.top.type||!this.top.type.inlineContent)&&this.findPlace(this.parser.schema.text(\"-\"),n,!0)}readStyles(e,n){let r=e.style;if(r&&r.length)for(let i=0;i<this.parser.matchedStyles.length;i++){let s=this.parser.matchedStyles[i],o=r.getPropertyValue(s);if(o)for(let l=void 0;;){let c=this.parser.matchStyle(s,o,this,l);if(!c)break;if(c.ignore)return null;if(c.clearMark?n=n.filter(d=>!c.clearMark(d)):n=n.concat(this.parser.schema.marks[c.mark].create(c.attrs)),c.consuming===!1)l=c;else break}}return n}addElementByRule(e,n,r,i){let s,o;if(n.node)if(o=this.parser.schema.nodes[n.node],o.isLeaf)this.insertNode(o.create(n.attrs),r,e.nodeName==\"BR\")||this.leafFallback(e,r);else{let c=this.enter(o,n.attrs||null,r,n.preserveWhitespace);c&&(s=!0,r=c)}else{let c=this.parser.schema.marks[n.mark];r=r.concat(c.create(n.attrs))}let l=this.top;if(o&&o.isLeaf)this.findInside(e);else if(i)this.addElement(e,r,i);else if(n.getContent)this.findInside(e),n.getContent(e,this.parser.schema).forEach(c=>this.insertNode(c,r,!1));else{let c=e;typeof n.contentElement==\"string\"?c=e.querySelector(n.contentElement):typeof n.contentElement==\"function\"?c=n.contentElement(e):n.contentElement&&(c=n.contentElement),this.findAround(e,c,!0),this.addAll(c,r),this.findAround(e,c,!1)}s&&this.sync(l)&&this.open--}addAll(e,n,r,i){let s=r||0;for(let o=r?e.childNodes[r]:e.firstChild,l=i==null?null:e.childNodes[i];o!=l;o=o.nextSibling,++s)this.findAtPoint(e,s),this.addDOM(o,n);this.findAtPoint(e,s)}findPlace(e,n,r){let i,s;for(let o=this.open,l=0;o>=0;o--){let c=this.nodes[o],d=c.findWrapping(e);if(d&&(!i||i.length>d.length+l)&&(i=d,s=c,!d.length))break;if(c.solid){if(r)break;l+=2}}if(!i)return null;this.sync(s);for(let o=0;o<i.length;o++)n=this.enterInner(i[o],null,n,!1);return n}insertNode(e,n,r){if(e.isInline&&this.needsBlock&&!this.top.type){let s=this.textblockFromContext();s&&(n=this.enterInner(s,null,n))}let i=this.findPlace(e,n,r);if(i){this.closeExtra();let s=this.top;s.match&&(s.match=s.match.matchType(e.type));let o=sa.none;for(let l of i.concat(e.marks))(s.type?s.type.allowsMarkType(l.type):MK(l.type,e.type))&&(o=l.addToSet(o));return s.content.push(e.mark(o)),!0}return!1}enter(e,n,r,i){let s=this.findPlace(e.create(n),r,!1);return s&&(s=this.enterInner(e,n,r,!0,i)),s}enterInner(e,n,r,i=!1,s){this.closeExtra();let o=this.top;o.match=o.match&&o.match.matchType(e);let l=TK(e,s,o.options);o.options&U0&&o.content.length==0&&(l|=U0);let c=sa.none;return r=r.filter(d=>(o.type?o.type.allowsMarkType(d.type):MK(d.type,e))?(c=d.addToSet(c),!1):!0),this.nodes.push(new xk(e,n,c,i,null,l)),this.open++,r}closeExtra(e=!1){let n=this.nodes.length-1;if(n>this.open){for(;n>this.open;n--)this.nodes[n-1].content.push(this.nodes[n].finish(e));this.nodes.length=this.open+1}}finish(){return this.open=0,this.closeExtra(this.isOpen),this.nodes[0].finish(!!(this.isOpen||this.options.topOpen))}sync(e){for(let n=this.open;n>=0;n--){if(this.nodes[n]==e)return this.open=n,!0;this.localPreserveWS&&(this.nodes[n].options|=Rw)}return!1}get currentPos(){this.closeExtra();let e=0;for(let n=this.open;n>=0;n--){let r=this.nodes[n].content;for(let i=r.length-1;i>=0;i--)e+=r[i].nodeSize;n&&e++}return e}findAtPoint(e,n){if(this.find)for(let r=0;r<this.find.length;r++)this.find[r].node==e&&this.find[r].offset==n&&(this.find[r].pos=this.currentPos)}findInside(e){if(this.find)for(let n=0;n<this.find.length;n++)this.find[n].pos==null&&e.nodeType==1&&e.contains(this.find[n].node)&&(this.find[n].pos=this.currentPos)}findAround(e,n,r){if(e!=n&&this.find)for(let i=0;i<this.find.length;i++)this.find[i].pos==null&&e.nodeType==1&&e.contains(this.find[i].node)&&n.compareDocumentPosition(this.find[i].node)&(r?2:4)&&(this.find[i].pos=this.currentPos)}findInText(e){if(this.find)for(let n=0;n<this.find.length;n++)this.find[n].node==e&&(this.find[n].pos=this.currentPos-(e.nodeValue.length-this.find[n].offset))}matchesContext(e){if(e.indexOf(\"|\")>-1)return e.split(/\\s*\\|\\s*/).some(this.matchesContext,this);let n=e.split(\"/\"),r=this.options.context,i=!this.isOpen&&(!r||r.parent.type==this.nodes[0].type),s=-(r?r.depth+1:0)+(i?0:1),o=(l,c)=>{for(;l>=0;l--){let d=n[l];if(d==\"\"){if(l==n.length-1||l==0)continue;for(;c>=s;c--)if(o(l-1,c))return!0;return!1}else{let u=c>0||c==0&&i?this.nodes[c].type:r&&c>=s?r.node(c-s).type:null;if(!u||u.name!=d&&!u.isInGroup(d))return!1;c--}}return!0};return o(n.length-1,this.open)}textblockFromContext(){let e=this.options.context;if(e)for(let n=e.depth;n>=0;n--){let r=e.node(n).contentMatchAt(e.indexAfter(n)).defaultType;if(r&&r.isTextblock&&r.defaultAttrs)return r}for(let n in this.parser.schema.nodes){let r=this.parser.schema.nodes[n];if(r.isTextblock&&r.defaultAttrs)return r}}}function BGe(t){for(let e=t.firstChild,n=null;e;e=e.nextSibling){let r=e.nodeType==1?e.nodeName.toLowerCase():null;r&&Mie.hasOwnProperty(r)&&n?(n.appendChild(e),e=n):r==\"li\"?n=e:r&&(n=null)}}function HGe(t,e){return(t.matches||t.msMatchesSelector||t.webkitMatchesSelector||t.mozMatchesSelector).call(t,e)}function jK(t){let e={};for(let n in t)e[n]=t[n];return e}function MK(t,e){let n=e.schema.nodes;for(let r in n){let i=n[r];if(!i.allowsMarkType(t))continue;let s=[],o=l=>{s.push(l);for(let c=0;c<l.edgeCount;c++){let{type:d,next:u}=l.edge(c);if(d==e||s.indexOf(u)<0&&o(u))return!0}};if(o(i.contentMatch))return!0}}class $g{constructor(e,n){this.nodes=e,this.marks=n}serializeFragment(e,n={},r){r||(r=WD(n).createDocumentFragment());let i=r,s=[];return e.forEach(o=>{if(s.length||o.marks.length){let l=0,c=0;for(;l<s.length&&c<o.marks.length;){let d=o.marks[c];if(!this.marks[d.type.name]){c++;continue}if(!d.eq(s[l][0])||d.type.spec.spanning===!1)break;l++,c++}for(;l<s.length;)i=s.pop()[1];for(;c<o.marks.length;){let d=o.marks[c++],u=this.serializeMark(d,o.isInline,n);u&&(s.push([d,i]),i.appendChild(u.dom),i=u.contentDOM||u.dom)}}i.appendChild(this.serializeNodeInner(o,n))}),r}serializeNodeInner(e,n){let{dom:r,contentDOM:i}=rN(WD(n),this.nodes[e.type.name](e),null,e.attrs);if(i){if(e.isLeaf)throw new RangeError(\"Content hole not allowed in a leaf node spec\");this.serializeFragment(e.content,n,i)}return r}serializeNode(e,n={}){let r=this.serializeNodeInner(e,n);for(let i=e.marks.length-1;i>=0;i--){let s=this.serializeMark(e.marks[i],e.isInline,n);s&&((s.contentDOM||s.dom).appendChild(r),r=s.dom)}return r}serializeMark(e,n,r={}){let i=this.marks[e.type.name];return i&&rN(WD(r),i(e,n),null,e.attrs)}static renderSpec(e,n,r=null,i){return rN(e,n,r,i)}static fromSchema(e){return e.cached.domSerializer||(e.cached.domSerializer=new $g(this.nodesFromSchema(e),this.marksFromSchema(e)))}static nodesFromSchema(e){let n=EK(e.nodes);return n.text||(n.text=r=>r.text),n}static marksFromSchema(e){return EK(e.marks)}}function EK(t){let e={};for(let n in t){let r=t[n].spec.toDOM;r&&(e[n]=r)}return e}function WD(t){return t.document||window.document}const DK=new WeakMap;function qGe(t){let e=DK.get(t);return e===void 0&&DK.set(t,e=$Ge(t)),e}function $Ge(t){let e=null;function n(r){if(r&&typeof r==\"object\")if(Array.isArray(r))if(typeof r[0]==\"string\")e||(e=[]),e.push(r);else for(let i=0;i<r.length;i++)n(r[i]);else for(let i in r)n(r[i])}return n(t),e}function rN(t,e,n,r){if(typeof e==\"string\")return{dom:t.createTextNode(e)};if(e.nodeType!=null)return{dom:e};if(e.dom&&e.dom.nodeType!=null)return e;let i=e[0],s;if(typeof i!=\"string\")throw new RangeError(\"Invalid array passed to renderSpec\");if(r&&(s=qGe(r))&&s.indexOf(e)>-1)throw new RangeError(\"Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.\");let o=i.indexOf(\" \");o>0&&(n=i.slice(0,o),i=i.slice(o+1));let l,c=n?t.createElementNS(n,i):t.createElement(i),d=e[1],u=1;if(d&&typeof d==\"object\"&&d.nodeType==null&&!Array.isArray(d)){u=2;for(let m in d)if(d[m]!=null){let p=m.indexOf(\" \");p>0?c.setAttributeNS(m.slice(0,p),m.slice(p+1),d[m]):m==\"style\"&&c.style?c.style.cssText=d[m]:c.setAttribute(m,d[m])}}for(let m=u;m<e.length;m++){let p=e[m];if(p===0){if(m<e.length-1||m>u)throw new RangeError(\"Content hole must be the only child of its parent node\");return{dom:c,contentDOM:c}}else{let{dom:f,contentDOM:y}=rN(t,p,n,r);if(c.appendChild(f),y){if(l)throw new RangeError(\"Multiple content holes\");l=y}}}return{dom:c,contentDOM:l}}const Eie=65535,Die=Math.pow(2,16);function VGe(t,e){return t+e*Die}function FK(t){return t&Eie}function GGe(t){return(t-(t&Eie))/Die}const Fie=1,Rie=2,aN=4,Lie=8;class TL{constructor(e,n,r){this.pos=e,this.delInfo=n,this.recover=r}get deleted(){return(this.delInfo&Lie)>0}get deletedBefore(){return(this.delInfo&(Fie|aN))>0}get deletedAfter(){return(this.delInfo&(Rie|aN))>0}get deletedAcross(){return(this.delInfo&aN)>0}}class dl{constructor(e,n=!1){if(this.ranges=e,this.inverted=n,!e.length&&dl.empty)return dl.empty}recover(e){let n=0,r=FK(e);if(!this.inverted)for(let i=0;i<r;i++)n+=this.ranges[i*3+2]-this.ranges[i*3+1];return this.ranges[r*3]+n+GGe(e)}mapResult(e,n=1){return this._map(e,n,!1)}map(e,n=1){return this._map(e,n,!0)}_map(e,n,r){let i=0,s=this.inverted?2:1,o=this.inverted?1:2;for(let l=0;l<this.ranges.length;l+=3){let c=this.ranges[l]-(this.inverted?i:0);if(c>e)break;let d=this.ranges[l+s],u=this.ranges[l+o],m=c+d;if(e<=m){let p=d?e==c?-1:e==m?1:n:n,f=c+i+(p<0?0:u);if(r)return f;let y=e==(n<0?c:m)?null:VGe(l/3,e-c),v=e==c?Rie:e==m?Fie:aN;return(n<0?e!=c:e!=m)&&(v|=Lie),new TL(f,v,y)}i+=u-d}return r?e+i:new TL(e+i,0,null)}touches(e,n){let r=0,i=FK(n),s=this.inverted?2:1,o=this.inverted?1:2;for(let l=0;l<this.ranges.length;l+=3){let c=this.ranges[l]-(this.inverted?r:0);if(c>e)break;let d=this.ranges[l+s],u=c+d;if(e<=u&&l==i*3)return!0;r+=this.ranges[l+o]-d}return!1}forEach(e){let n=this.inverted?2:1,r=this.inverted?1:2;for(let i=0,s=0;i<this.ranges.length;i+=3){let o=this.ranges[i],l=o-(this.inverted?s:0),c=o+(this.inverted?0:s),d=this.ranges[i+n],u=this.ranges[i+r];e(l,l+d,c,c+u),s+=u-d}}invert(){return new dl(this.ranges,!this.inverted)}toString(){return(this.inverted?\"-\":\"\")+JSON.stringify(this.ranges)}static offset(e){return e==0?dl.empty:new dl(e<0?[0,-e,0]:[0,0,e])}}dl.empty=new dl([]);class Lw{constructor(e,n,r=0,i=e?e.length:0){this.mirror=n,this.from=r,this.to=i,this._maps=e||[],this.ownData=!(e||n)}get maps(){return this._maps}slice(e=0,n=this.maps.length){return new Lw(this._maps,this.mirror,e,n)}appendMap(e,n){this.ownData||(this._maps=this._maps.slice(),this.mirror=this.mirror&&this.mirror.slice(),this.ownData=!0),this.to=this._maps.push(e),n!=null&&this.setMirror(this._maps.length-1,n)}appendMapping(e){for(let n=0,r=this._maps.length;n<e._maps.length;n++){let i=e.getMirror(n);this.appendMap(e._maps[n],i!=null&&i<n?r+i:void 0)}}getMirror(e){if(this.mirror){for(let n=0;n<this.mirror.length;n++)if(this.mirror[n]==e)return this.mirror[n+(n%2?-1:1)]}}setMirror(e,n){this.mirror||(this.mirror=[]),this.mirror.push(e,n)}appendMappingInverted(e){for(let n=e.maps.length-1,r=this._maps.length+e._maps.length;n>=0;n--){let i=e.getMirror(n);this.appendMap(e._maps[n].invert(),i!=null&&i>n?r-i-1:void 0)}}invert(){let e=new Lw;return e.appendMappingInverted(this),e}map(e,n=1){if(this.mirror)return this._map(e,n,!0);for(let r=this.from;r<this.to;r++)e=this._maps[r].map(e,n);return e}mapResult(e,n=1){return this._map(e,n,!1)}_map(e,n,r){let i=0;for(let s=this.from;s<this.to;s++){let o=this._maps[s],l=o.mapResult(e,n);if(l.recover!=null){let c=this.getMirror(s);if(c!=null&&c>s&&c<this.to){s=c,e=this._maps[c].recover(l.recover);continue}}i|=l.delInfo,e=l.pos}return r?e:new TL(e,i,null)}}const KD=Object.create(null);class Ws{getMap(){return dl.empty}merge(e){return null}static fromJSON(e,n){if(!n||!n.stepType)throw new RangeError(\"Invalid input for Step.fromJSON\");let r=KD[n.stepType];if(!r)throw new RangeError(`No step type ${n.stepType} defined`);return r.fromJSON(e,n)}static jsonID(e,n){if(e in KD)throw new RangeError(\"Duplicate use of step JSON ID \"+e);return KD[e]=n,n.prototype.jsonID=e,n}}class Si{constructor(e,n){this.doc=e,this.failed=n}static ok(e){return new Si(e,null)}static fail(e){return new Si(null,e)}static fromReplace(e,n,r,i){try{return Si.ok(e.replace(n,r,i))}catch(s){if(s instanceof HC)return Si.fail(s.message);throw s}}}function kz(t,e,n){let r=[];for(let i=0;i<t.childCount;i++){let s=t.child(i);s.content.size&&(s=s.copy(kz(s.content,e,s))),s.isInline&&(s=e(s,n,i)),r.push(s)}return Vt.fromArray(r)}class Kh extends Ws{constructor(e,n,r){super(),this.from=e,this.to=n,this.mark=r}apply(e){let n=e.slice(this.from,this.to),r=e.resolve(this.from),i=r.node(r.sharedDepth(this.to)),s=new an(kz(n.content,(o,l)=>!o.isAtom||!l.type.allowsMarkType(this.mark.type)?o:o.mark(this.mark.addToSet(o.marks)),i),n.openStart,n.openEnd);return Si.fromReplace(e,this.from,this.to,s)}invert(){return new Kc(this.from,this.to,this.mark)}map(e){let n=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return n.deleted&&r.deleted||n.pos>=r.pos?null:new Kh(n.pos,r.pos,this.mark)}merge(e){return e instanceof Kh&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new Kh(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:\"addMark\",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,n){if(typeof n.from!=\"number\"||typeof n.to!=\"number\")throw new RangeError(\"Invalid input for AddMarkStep.fromJSON\");return new Kh(n.from,n.to,e.markFromJSON(n.mark))}}Ws.jsonID(\"addMark\",Kh);class Kc extends Ws{constructor(e,n,r){super(),this.from=e,this.to=n,this.mark=r}apply(e){let n=e.slice(this.from,this.to),r=new an(kz(n.content,i=>i.mark(this.mark.removeFromSet(i.marks)),e),n.openStart,n.openEnd);return Si.fromReplace(e,this.from,this.to,r)}invert(){return new Kh(this.from,this.to,this.mark)}map(e){let n=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return n.deleted&&r.deleted||n.pos>=r.pos?null:new Kc(n.pos,r.pos,this.mark)}merge(e){return e instanceof Kc&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new Kc(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:\"removeMark\",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,n){if(typeof n.from!=\"number\"||typeof n.to!=\"number\")throw new RangeError(\"Invalid input for RemoveMarkStep.fromJSON\");return new Kc(n.from,n.to,e.markFromJSON(n.mark))}}Ws.jsonID(\"removeMark\",Kc);class Xh extends Ws{constructor(e,n){super(),this.pos=e,this.mark=n}apply(e){let n=e.nodeAt(this.pos);if(!n)return Si.fail(\"No node at mark step's position\");let r=n.type.create(n.attrs,null,this.mark.addToSet(n.marks));return Si.fromReplace(e,this.pos,this.pos+1,new an(Vt.from(r),0,n.isLeaf?0:1))}invert(e){let n=e.nodeAt(this.pos);if(n){let r=this.mark.addToSet(n.marks);if(r.length==n.marks.length){for(let i=0;i<n.marks.length;i++)if(!n.marks[i].isInSet(r))return new Xh(this.pos,n.marks[i]);return new Xh(this.pos,this.mark)}}return new Mg(this.pos,this.mark)}map(e){let n=e.mapResult(this.pos,1);return n.deletedAfter?null:new Xh(n.pos,this.mark)}toJSON(){return{stepType:\"addNodeMark\",pos:this.pos,mark:this.mark.toJSON()}}static fromJSON(e,n){if(typeof n.pos!=\"number\")throw new RangeError(\"Invalid input for AddNodeMarkStep.fromJSON\");return new Xh(n.pos,e.markFromJSON(n.mark))}}Ws.jsonID(\"addNodeMark\",Xh);class Mg extends Ws{constructor(e,n){super(),this.pos=e,this.mark=n}apply(e){let n=e.nodeAt(this.pos);if(!n)return Si.fail(\"No node at mark step's position\");let r=n.type.create(n.attrs,null,this.mark.removeFromSet(n.marks));return Si.fromReplace(e,this.pos,this.pos+1,new an(Vt.from(r),0,n.isLeaf?0:1))}invert(e){let n=e.nodeAt(this.pos);return!n||!this.mark.isInSet(n.marks)?this:new Xh(this.pos,this.mark)}map(e){let n=e.mapResult(this.pos,1);return n.deletedAfter?null:new Mg(n.pos,this.mark)}toJSON(){return{stepType:\"removeNodeMark\",pos:this.pos,mark:this.mark.toJSON()}}static fromJSON(e,n){if(typeof n.pos!=\"number\")throw new RangeError(\"Invalid input for RemoveNodeMarkStep.fromJSON\");return new Mg(n.pos,e.markFromJSON(n.mark))}}Ws.jsonID(\"removeNodeMark\",Mg);class Yi extends Ws{constructor(e,n,r,i=!1){super(),this.from=e,this.to=n,this.slice=r,this.structure=i}apply(e){return this.structure&&AL(e,this.from,this.to)?Si.fail(\"Structure replace would overwrite content\"):Si.fromReplace(e,this.from,this.to,this.slice)}getMap(){return new dl([this.from,this.to-this.from,this.slice.size])}invert(e){return new Yi(this.from,this.from+this.slice.size,e.slice(this.from,this.to))}map(e){let n=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return n.deletedAcross&&r.deletedAcross?null:new Yi(n.pos,Math.max(n.pos,r.pos),this.slice,this.structure)}merge(e){if(!(e instanceof Yi)||e.structure||this.structure)return null;if(this.from+this.slice.size==e.from&&!this.slice.openEnd&&!e.slice.openStart){let n=this.slice.size+e.slice.size==0?an.empty:new an(this.slice.content.append(e.slice.content),this.slice.openStart,e.slice.openEnd);return new Yi(this.from,this.to+(e.to-e.from),n,this.structure)}else if(e.to==this.from&&!this.slice.openStart&&!e.slice.openEnd){let n=this.slice.size+e.slice.size==0?an.empty:new an(e.slice.content.append(this.slice.content),e.slice.openStart,this.slice.openEnd);return new Yi(e.from,this.to,n,this.structure)}else return null}toJSON(){let e={stepType:\"replace\",from:this.from,to:this.to};return this.slice.size&&(e.slice=this.slice.toJSON()),this.structure&&(e.structure=!0),e}static fromJSON(e,n){if(typeof n.from!=\"number\"||typeof n.to!=\"number\")throw new RangeError(\"Invalid input for ReplaceStep.fromJSON\");return new Yi(n.from,n.to,an.fromJSON(e,n.slice),!!n.structure)}}Ws.jsonID(\"replace\",Yi);class Zi extends Ws{constructor(e,n,r,i,s,o,l=!1){super(),this.from=e,this.to=n,this.gapFrom=r,this.gapTo=i,this.slice=s,this.insert=o,this.structure=l}apply(e){if(this.structure&&(AL(e,this.from,this.gapFrom)||AL(e,this.gapTo,this.to)))return Si.fail(\"Structure gap-replace would overwrite content\");let n=e.slice(this.gapFrom,this.gapTo);if(n.openStart||n.openEnd)return Si.fail(\"Gap is not a flat range\");let r=this.slice.insertAt(this.insert,n.content);return r?Si.fromReplace(e,this.from,this.to,r):Si.fail(\"Content does not fit in gap\")}getMap(){return new dl([this.from,this.gapFrom-this.from,this.insert,this.gapTo,this.to-this.gapTo,this.slice.size-this.insert])}invert(e){let n=this.gapTo-this.gapFrom;return new Zi(this.from,this.from+this.slice.size+n,this.from+this.insert,this.from+this.insert+n,e.slice(this.from,this.to).removeBetween(this.gapFrom-this.from,this.gapTo-this.from),this.gapFrom-this.from,this.structure)}map(e){let n=e.mapResult(this.from,1),r=e.mapResult(this.to,-1),i=this.from==this.gapFrom?n.pos:e.map(this.gapFrom,-1),s=this.to==this.gapTo?r.pos:e.map(this.gapTo,1);return n.deletedAcross&&r.deletedAcross||i<n.pos||s>r.pos?null:new Zi(n.pos,r.pos,i,s,this.slice,this.insert,this.structure)}toJSON(){let e={stepType:\"replaceAround\",from:this.from,to:this.to,gapFrom:this.gapFrom,gapTo:this.gapTo,insert:this.insert};return this.slice.size&&(e.slice=this.slice.toJSON()),this.structure&&(e.structure=!0),e}static fromJSON(e,n){if(typeof n.from!=\"number\"||typeof n.to!=\"number\"||typeof n.gapFrom!=\"number\"||typeof n.gapTo!=\"number\"||typeof n.insert!=\"number\")throw new RangeError(\"Invalid input for ReplaceAroundStep.fromJSON\");return new Zi(n.from,n.to,n.gapFrom,n.gapTo,an.fromJSON(e,n.slice),n.insert,!!n.structure)}}Ws.jsonID(\"replaceAround\",Zi);function AL(t,e,n){let r=t.resolve(e),i=n-e,s=r.depth;for(;i>0&&s>0&&r.indexAfter(s)==r.node(s).childCount;)s--,i--;if(i>0){let o=r.node(s).maybeChild(r.indexAfter(s));for(;i>0;){if(!o||o.isLeaf)return!0;o=o.firstChild,i--}}return!1}function WGe(t,e,n,r){let i=[],s=[],o,l;t.doc.nodesBetween(e,n,(c,d,u)=>{if(!c.isInline)return;let m=c.marks;if(!r.isInSet(m)&&u.type.allowsMarkType(r.type)){let p=Math.max(d,e),f=Math.min(d+c.nodeSize,n),y=r.addToSet(m);for(let v=0;v<m.length;v++)m[v].isInSet(y)||(o&&o.to==p&&o.mark.eq(m[v])?o.to=f:i.push(o=new Kc(p,f,m[v])));l&&l.to==p?l.to=f:s.push(l=new Kh(p,f,r))}}),i.forEach(c=>t.step(c)),s.forEach(c=>t.step(c))}function KGe(t,e,n,r){let i=[],s=0;t.doc.nodesBetween(e,n,(o,l)=>{if(!o.isInline)return;s++;let c=null;if(r instanceof eA){let d=o.marks,u;for(;u=r.isInSet(d);)(c||(c=[])).push(u),d=u.removeFromSet(d)}else r?r.isInSet(o.marks)&&(c=[r]):c=o.marks;if(c&&c.length){let d=Math.min(l+o.nodeSize,n);for(let u=0;u<c.length;u++){let m=c[u],p;for(let f=0;f<i.length;f++){let y=i[f];y.step==s-1&&m.eq(i[f].style)&&(p=y)}p?(p.to=d,p.step=s):i.push({style:m,from:Math.max(l,e),to:d,step:s})}}}),i.forEach(o=>t.step(new Kc(o.from,o.to,o.style)))}function Nz(t,e,n,r=n.contentMatch,i=!0){let s=t.doc.nodeAt(e),o=[],l=e+1;for(let c=0;c<s.childCount;c++){let d=s.child(c),u=l+d.nodeSize,m=r.matchType(d.type);if(!m)o.push(new Yi(l,u,an.empty));else{r=m;for(let p=0;p<d.marks.length;p++)n.allowsMarkType(d.marks[p].type)||t.step(new Kc(l,u,d.marks[p]));if(i&&d.isText&&n.whitespace!=\"pre\"){let p,f=/\\r?\\n|\\r/g,y;for(;p=f.exec(d.text);)y||(y=new an(Vt.from(n.schema.text(\" \",n.allowedMarks(d.marks))),0,0)),o.push(new Yi(l+p.index,l+p.index+p[0].length,y))}}l=u}if(!r.validEnd){let c=r.fillBefore(Vt.empty,!0);t.replace(l,l,new an(c,0,0))}for(let c=o.length-1;c>=0;c--)t.step(o[c])}function XGe(t,e,n){return(e==0||t.canReplace(e,t.childCount))&&(n==t.childCount||t.canReplace(0,n))}function zy(t){let n=t.parent.content.cutByIndex(t.startIndex,t.endIndex);for(let r=t.depth,i=0,s=0;;--r){let o=t.$from.node(r),l=t.$from.index(r)+i,c=t.$to.indexAfter(r)-s;if(r<t.depth&&o.canReplace(l,c,n))return r;if(r==0||o.type.spec.isolating||!XGe(o,l,c))break;l&&(i=1),c<o.childCount&&(s=1)}return null}function YGe(t,e,n){let{$from:r,$to:i,depth:s}=e,o=r.before(s+1),l=i.after(s+1),c=o,d=l,u=Vt.empty,m=0;for(let y=s,v=!1;y>n;y--)v||r.index(y)>0?(v=!0,u=Vt.from(r.node(y).copy(u)),m++):c--;let p=Vt.empty,f=0;for(let y=s,v=!1;y>n;y--)v||i.after(y+1)<i.end(y)?(v=!0,p=Vt.from(i.node(y).copy(p)),f++):d++;t.step(new Zi(c,d,o,l,new an(u.append(p),m,f),u.size-m,!0))}function Cz(t,e,n=null,r=t){let i=QGe(t,e),s=i&&ZGe(r,e);return s?i.map(RK).concat({type:e,attrs:n}).concat(s.map(RK)):null}function RK(t){return{type:t,attrs:null}}function QGe(t,e){let{parent:n,startIndex:r,endIndex:i}=t,s=n.contentMatchAt(r).findWrapping(e);if(!s)return null;let o=s.length?s[0]:e;return n.canReplaceWith(r,i,o)?s:null}function ZGe(t,e){let{parent:n,startIndex:r,endIndex:i}=t,s=n.child(r),o=e.contentMatch.findWrapping(s.type);if(!o)return null;let c=(o.length?o[o.length-1]:e).contentMatch;for(let d=r;c&&d<i;d++)c=c.matchType(n.child(d).type);return!c||!c.validEnd?null:o}function JGe(t,e,n){let r=Vt.empty;for(let o=n.length-1;o>=0;o--){if(r.size){let l=n[o].type.contentMatch.matchFragment(r);if(!l||!l.validEnd)throw new RangeError(\"Wrapper type given to Transform.wrap does not form valid content of its parent wrapper\")}r=Vt.from(n[o].type.create(n[o].attrs,r))}let i=e.start,s=e.end;t.step(new Zi(i,s,i,s,new an(r,0,0),n.length,!0))}function e9e(t,e,n,r,i){if(!r.isTextblock)throw new RangeError(\"Type given to setBlockType should be a textblock\");let s=t.steps.length;t.doc.nodesBetween(e,n,(o,l)=>{let c=typeof i==\"function\"?i(o):i;if(o.isTextblock&&!o.hasMarkup(r,c)&&t9e(t.doc,t.mapping.slice(s).map(l),r)){let d=null;if(r.schema.linebreakReplacement){let f=r.whitespace==\"pre\",y=!!r.contentMatch.matchType(r.schema.linebreakReplacement);f&&!y?d=!1:!f&&y&&(d=!0)}d===!1&&Iie(t,o,l,s),Nz(t,t.mapping.slice(s).map(l,1),r,void 0,d===null);let u=t.mapping.slice(s),m=u.map(l,1),p=u.map(l+o.nodeSize,1);return t.step(new Zi(m,p,m+1,p-1,new an(Vt.from(r.create(c,null,o.marks)),0,0),1,!0)),d===!0&&Oie(t,o,l,s),!1}})}function Oie(t,e,n,r){e.forEach((i,s)=>{if(i.isText){let o,l=/\\r?\\n|\\r/g;for(;o=l.exec(i.text);){let c=t.mapping.slice(r).map(n+1+s+o.index);t.replaceWith(c,c+1,e.type.schema.linebreakReplacement.create())}}})}function Iie(t,e,n,r){e.forEach((i,s)=>{if(i.type==i.type.schema.linebreakReplacement){let o=t.mapping.slice(r).map(n+1+s);t.replaceWith(o,o+1,e.type.schema.text(`\n`))}})}function t9e(t,e,n){let r=t.resolve(e),i=r.index();return r.parent.canReplaceWith(i,i+1,n)}function n9e(t,e,n,r,i){let s=t.doc.nodeAt(e);if(!s)throw new RangeError(\"No node at given position\");n||(n=s.type);let o=n.create(r,null,i||s.marks);if(s.isLeaf)return t.replaceWith(e,e+s.nodeSize,o);if(!n.validContent(s.content))throw new RangeError(\"Invalid content for node type \"+n.name);t.step(new Zi(e,e+s.nodeSize,e+1,e+s.nodeSize-1,new an(Vt.from(o),0,0),1,!0))}function hm(t,e,n=1,r){let i=t.resolve(e),s=i.depth-n,o=r&&r[r.length-1]||i.parent;if(s<0||i.parent.type.spec.isolating||!i.parent.canReplace(i.index(),i.parent.childCount)||!o.type.validContent(i.parent.content.cutByIndex(i.index(),i.parent.childCount)))return!1;for(let d=i.depth-1,u=n-2;d>s;d--,u--){let m=i.node(d),p=i.index(d);if(m.type.spec.isolating)return!1;let f=m.content.cutByIndex(p,m.childCount),y=r&&r[u+1];y&&(f=f.replaceChild(0,y.type.create(y.attrs)));let v=r&&r[u]||m;if(!m.canReplace(p+1,m.childCount)||!v.type.validContent(f))return!1}let l=i.indexAfter(s),c=r&&r[0];return i.node(s).canReplaceWith(l,l,c?c.type:i.node(s+1).type)}function r9e(t,e,n=1,r){let i=t.doc.resolve(e),s=Vt.empty,o=Vt.empty;for(let l=i.depth,c=i.depth-n,d=n-1;l>c;l--,d--){s=Vt.from(i.node(l).copy(s));let u=r&&r[d];o=Vt.from(u?u.type.create(u.attrs,o):i.node(l).copy(o))}t.step(new Yi(e,e,new an(s.append(o),n,n),!0))}function Fp(t,e){let n=t.resolve(e),r=n.index();return zie(n.nodeBefore,n.nodeAfter)&&n.parent.canReplace(r,r+1)}function a9e(t,e){e.content.size||t.type.compatibleContent(e.type);let n=t.contentMatchAt(t.childCount),{linebreakReplacement:r}=t.type.schema;for(let i=0;i<e.childCount;i++){let s=e.child(i),o=s.type==r?t.type.schema.nodes.text:s.type;if(n=n.matchType(o),!n||!t.type.allowsMarks(s.marks))return!1}return n.validEnd}function zie(t,e){return!!(t&&e&&!t.isLeaf&&a9e(t,e))}function tA(t,e,n=-1){let r=t.resolve(e);for(let i=r.depth;;i--){let s,o,l=r.index(i);if(i==r.depth?(s=r.nodeBefore,o=r.nodeAfter):n>0?(s=r.node(i+1),l++,o=r.node(i).maybeChild(l)):(s=r.node(i).maybeChild(l-1),o=r.node(i+1)),s&&!s.isTextblock&&zie(s,o)&&r.node(i).canReplace(l,l+1))return e;if(i==0)break;e=n<0?r.before(i):r.after(i)}}function i9e(t,e,n){let r=null,{linebreakReplacement:i}=t.doc.type.schema,s=t.doc.resolve(e-n),o=s.node().type;if(i&&o.inlineContent){let u=o.whitespace==\"pre\",m=!!o.contentMatch.matchType(i);u&&!m?r=!1:!u&&m&&(r=!0)}let l=t.steps.length;if(r===!1){let u=t.doc.resolve(e+n);Iie(t,u.node(),u.before(),l)}o.inlineContent&&Nz(t,e+n-1,o,s.node().contentMatchAt(s.index()),r==null);let c=t.mapping.slice(l),d=c.map(e-n);if(t.step(new Yi(d,c.map(e+n,-1),an.empty,!0)),r===!0){let u=t.doc.resolve(d);Oie(t,u.node(),u.before(),t.steps.length)}return t}function s9e(t,e,n){let r=t.resolve(e);if(r.parent.canReplaceWith(r.index(),r.index(),n))return e;if(r.parentOffset==0)for(let i=r.depth-1;i>=0;i--){let s=r.index(i);if(r.node(i).canReplaceWith(s,s,n))return r.before(i+1);if(s>0)return null}if(r.parentOffset==r.parent.content.size)for(let i=r.depth-1;i>=0;i--){let s=r.indexAfter(i);if(r.node(i).canReplaceWith(s,s,n))return r.after(i+1);if(s<r.node(i).childCount)return null}return null}function Uie(t,e,n){let r=t.resolve(e);if(!n.content.size)return e;let i=n.content;for(let s=0;s<n.openStart;s++)i=i.firstChild.content;for(let s=1;s<=(n.openStart==0&&n.size?2:1);s++)for(let o=r.depth;o>=0;o--){let l=o==r.depth?0:r.pos<=(r.start(o+1)+r.end(o+1))/2?-1:1,c=r.index(o)+(l>0?1:0),d=r.node(o),u=!1;if(s==1)u=d.canReplace(c,c,i);else{let m=d.contentMatchAt(c).findWrapping(i.firstChild.type);u=m&&d.canReplaceWith(c,c,m[0])}if(u)return l==0?r.pos:l<0?r.before(o+1):r.after(o+1)}return null}function nA(t,e,n=e,r=an.empty){if(e==n&&!r.size)return null;let i=t.resolve(e),s=t.resolve(n);return Bie(i,s,r)?new Yi(e,n,r):new o9e(i,s,r).fit()}function Bie(t,e,n){return!n.openStart&&!n.openEnd&&t.start()==e.start()&&t.parent.canReplace(t.index(),e.index(),n.content)}class o9e{constructor(e,n,r){this.$from=e,this.$to=n,this.unplaced=r,this.frontier=[],this.placed=Vt.empty;for(let i=0;i<=e.depth;i++){let s=e.node(i);this.frontier.push({type:s.type,match:s.contentMatchAt(e.indexAfter(i))})}for(let i=e.depth;i>0;i--)this.placed=Vt.from(e.node(i).copy(this.placed))}get depth(){return this.frontier.length-1}fit(){for(;this.unplaced.size;){let d=this.findFittable();d?this.placeNodes(d):this.openMore()||this.dropNode()}let e=this.mustMoveInline(),n=this.placed.size-this.depth-this.$from.depth,r=this.$from,i=this.close(e<0?this.$to:r.doc.resolve(e));if(!i)return null;let s=this.placed,o=r.depth,l=i.depth;for(;o&&l&&s.childCount==1;)s=s.firstChild.content,o--,l--;let c=new an(s,o,l);return e>-1?new Zi(r.pos,e,this.$to.pos,this.$to.end(),c,n):c.size||r.pos!=this.$to.pos?new Yi(r.pos,i.pos,c):null}findFittable(){let e=this.unplaced.openStart;for(let n=this.unplaced.content,r=0,i=this.unplaced.openEnd;r<e;r++){let s=n.firstChild;if(n.childCount>1&&(i=0),s.type.spec.isolating&&i<=r){e=r;break}n=s.content}for(let n=1;n<=2;n++)for(let r=n==1?e:this.unplaced.openStart;r>=0;r--){let i,s=null;r?(s=XD(this.unplaced.content,r-1).firstChild,i=s.content):i=this.unplaced.content;let o=i.firstChild;for(let l=this.depth;l>=0;l--){let{type:c,match:d}=this.frontier[l],u,m=null;if(n==1&&(o?d.matchType(o.type)||(m=d.fillBefore(Vt.from(o),!1)):s&&c.compatibleContent(s.type)))return{sliceDepth:r,frontierDepth:l,parent:s,inject:m};if(n==2&&o&&(u=d.findWrapping(o.type)))return{sliceDepth:r,frontierDepth:l,parent:s,wrap:u};if(s&&d.matchType(s.type))break}}}openMore(){let{content:e,openStart:n,openEnd:r}=this.unplaced,i=XD(e,n);return!i.childCount||i.firstChild.isLeaf?!1:(this.unplaced=new an(e,n+1,Math.max(r,i.size+n>=e.size-r?n+1:0)),!0)}dropNode(){let{content:e,openStart:n,openEnd:r}=this.unplaced,i=XD(e,n);if(i.childCount<=1&&n>0){let s=e.size-n<=n+i.size;this.unplaced=new an(v0(e,n-1,1),n-1,s?n-1:r)}else this.unplaced=new an(v0(e,n,1),n,r)}placeNodes({sliceDepth:e,frontierDepth:n,parent:r,inject:i,wrap:s}){for(;this.depth>n;)this.closeFrontierNode();if(s)for(let v=0;v<s.length;v++)this.openFrontierNode(s[v]);let o=this.unplaced,l=r?r.content:o.content,c=o.openStart-e,d=0,u=[],{match:m,type:p}=this.frontier[n];if(i){for(let v=0;v<i.childCount;v++)u.push(i.child(v));m=m.matchFragment(i)}let f=l.size+e-(o.content.size-o.openEnd);for(;d<l.childCount;){let v=l.child(d),b=m.matchType(v.type);if(!b)break;d++,(d>1||c==0||v.content.size)&&(m=b,u.push(Hie(v.mark(p.allowedMarks(v.marks)),d==1?c:0,d==l.childCount?f:-1)))}let y=d==l.childCount;y||(f=-1),this.placed=w0(this.placed,n,Vt.from(u)),this.frontier[n].match=m,y&&f<0&&r&&r.type==this.frontier[this.depth].type&&this.frontier.length>1&&this.closeFrontierNode();for(let v=0,b=l;v<f;v++){let g=b.lastChild;this.frontier.push({type:g.type,match:g.contentMatchAt(g.childCount)}),b=g.content}this.unplaced=y?e==0?an.empty:new an(v0(o.content,e-1,1),e-1,f<0?o.openEnd:e-1):new an(v0(o.content,e,d),o.openStart,o.openEnd)}mustMoveInline(){if(!this.$to.parent.isTextblock)return-1;let e=this.frontier[this.depth],n;if(!e.type.isTextblock||!YD(this.$to,this.$to.depth,e.type,e.match,!1)||this.$to.depth==this.depth&&(n=this.findCloseLevel(this.$to))&&n.depth==this.depth)return-1;let{depth:r}=this.$to,i=this.$to.after(r);for(;r>1&&i==this.$to.end(--r);)++i;return i}findCloseLevel(e){e:for(let n=Math.min(this.depth,e.depth);n>=0;n--){let{match:r,type:i}=this.frontier[n],s=n<e.depth&&e.end(n+1)==e.pos+(e.depth-(n+1)),o=YD(e,n,i,r,s);if(o){for(let l=n-1;l>=0;l--){let{match:c,type:d}=this.frontier[l],u=YD(e,l,d,c,!0);if(!u||u.childCount)continue e}return{depth:n,fit:o,move:s?e.doc.resolve(e.after(n+1)):e}}}}close(e){let n=this.findCloseLevel(e);if(!n)return null;for(;this.depth>n.depth;)this.closeFrontierNode();n.fit.childCount&&(this.placed=w0(this.placed,n.depth,n.fit)),e=n.move;for(let r=n.depth+1;r<=e.depth;r++){let i=e.node(r),s=i.type.contentMatch.fillBefore(i.content,!0,e.index(r));this.openFrontierNode(i.type,i.attrs,s)}return e}openFrontierNode(e,n=null,r){let i=this.frontier[this.depth];i.match=i.match.matchType(e),this.placed=w0(this.placed,this.depth,Vt.from(e.create(n,r))),this.frontier.push({type:e,match:e.contentMatch})}closeFrontierNode(){let n=this.frontier.pop().match.fillBefore(Vt.empty,!0);n.childCount&&(this.placed=w0(this.placed,this.frontier.length,n))}}function v0(t,e,n){return e==0?t.cutByIndex(n,t.childCount):t.replaceChild(0,t.firstChild.copy(v0(t.firstChild.content,e-1,n)))}function w0(t,e,n){return e==0?t.append(n):t.replaceChild(t.childCount-1,t.lastChild.copy(w0(t.lastChild.content,e-1,n)))}function XD(t,e){for(let n=0;n<e;n++)t=t.firstChild.content;return t}function Hie(t,e,n){if(e<=0)return t;let r=t.content;return e>1&&(r=r.replaceChild(0,Hie(r.firstChild,e-1,r.childCount==1?n-1:0))),e>0&&(r=t.type.contentMatch.fillBefore(r).append(r),n<=0&&(r=r.append(t.type.contentMatch.matchFragment(r).fillBefore(Vt.empty,!0)))),t.copy(r)}function YD(t,e,n,r,i){let s=t.node(e),o=i?t.indexAfter(e):t.index(e);if(o==s.childCount&&!n.compatibleContent(s.type))return null;let l=r.fillBefore(s.content,!0,o);return l&&!l9e(n,s.content,o)?l:null}function l9e(t,e,n){for(let r=n;r<e.childCount;r++)if(!t.allowsMarks(e.child(r).marks))return!0;return!1}function c9e(t){return t.spec.defining||t.spec.definingForContent}function d9e(t,e,n,r){if(!r.size)return t.deleteRange(e,n);let i=t.doc.resolve(e),s=t.doc.resolve(n);if(Bie(i,s,r))return t.step(new Yi(e,n,r));let o=$ie(i,s);o[o.length-1]==0&&o.pop();let l=-(i.depth+1);o.unshift(l);for(let p=i.depth,f=i.pos-1;p>0;p--,f--){let y=i.node(p).type.spec;if(y.defining||y.definingAsContext||y.isolating)break;o.indexOf(p)>-1?l=p:i.before(p)==f&&o.splice(1,0,-p)}let c=o.indexOf(l),d=[],u=r.openStart;for(let p=r.content,f=0;;f++){let y=p.firstChild;if(d.push(y),f==r.openStart)break;p=y.content}for(let p=u-1;p>=0;p--){let f=d[p],y=c9e(f.type);if(y&&!f.sameMarkup(i.node(Math.abs(l)-1)))u=p;else if(y||!f.type.isTextblock)break}for(let p=r.openStart;p>=0;p--){let f=(p+u+1)%(r.openStart+1),y=d[f];if(y)for(let v=0;v<o.length;v++){let b=o[(v+c)%o.length],g=!0;b<0&&(g=!1,b=-b);let _=i.node(b-1),C=i.index(b-1);if(_.canReplaceWith(C,C,y.type,y.marks))return t.replace(i.before(b),g?s.after(b):n,new an(qie(r.content,0,r.openStart,f),f,r.openEnd))}}let m=t.steps.length;for(let p=o.length-1;p>=0&&(t.replace(e,n,r),!(t.steps.length>m));p--){let f=o[p];f<0||(e=i.before(f),n=s.after(f))}}function qie(t,e,n,r,i){if(e<n){let s=t.firstChild;t=t.replaceChild(0,s.copy(qie(s.content,e+1,n,r,s)))}if(e>r){let s=i.contentMatchAt(0),o=s.fillBefore(t).append(t);t=o.append(s.matchFragment(o).fillBefore(Vt.empty,!0))}return t}function u9e(t,e,n,r){if(!r.isInline&&e==n&&t.doc.resolve(e).parent.content.size){let i=s9e(t.doc,e,r.type);i!=null&&(e=n=i)}t.replaceRange(e,n,new an(Vt.from(r),0,0))}function m9e(t,e,n){let r=t.doc.resolve(e),i=t.doc.resolve(n),s=$ie(r,i);for(let o=0;o<s.length;o++){let l=s[o],c=o==s.length-1;if(c&&l==0||r.node(l).type.contentMatch.validEnd)return t.delete(r.start(l),i.end(l));if(l>0&&(c||r.node(l-1).canReplace(r.index(l-1),i.indexAfter(l-1))))return t.delete(r.before(l),i.after(l))}for(let o=1;o<=r.depth&&o<=i.depth;o++)if(e-r.start(o)==r.depth-o&&n>r.end(o)&&i.end(o)-n!=i.depth-o&&r.start(o-1)==i.start(o-1)&&r.node(o-1).canReplace(r.index(o-1),i.index(o-1)))return t.delete(r.before(o),n);t.delete(e,n)}function $ie(t,e){let n=[],r=Math.min(t.depth,e.depth);for(let i=r;i>=0;i--){let s=t.start(i);if(s<t.pos-(t.depth-i)||e.end(i)>e.pos+(e.depth-i)||t.node(i).type.spec.isolating||e.node(i).type.spec.isolating)break;(s==e.start(i)||i==t.depth&&i==e.depth&&t.parent.inlineContent&&e.parent.inlineContent&&i&&e.start(i-1)==s-1)&&n.push(i)}return n}class Ix extends Ws{constructor(e,n,r){super(),this.pos=e,this.attr=n,this.value=r}apply(e){let n=e.nodeAt(this.pos);if(!n)return Si.fail(\"No node at attribute step's position\");let r=Object.create(null);for(let s in n.attrs)r[s]=n.attrs[s];r[this.attr]=this.value;let i=n.type.create(r,null,n.marks);return Si.fromReplace(e,this.pos,this.pos+1,new an(Vt.from(i),0,n.isLeaf?0:1))}getMap(){return dl.empty}invert(e){return new Ix(this.pos,this.attr,e.nodeAt(this.pos).attrs[this.attr])}map(e){let n=e.mapResult(this.pos,1);return n.deletedAfter?null:new Ix(n.pos,this.attr,this.value)}toJSON(){return{stepType:\"attr\",pos:this.pos,attr:this.attr,value:this.value}}static fromJSON(e,n){if(typeof n.pos!=\"number\"||typeof n.attr!=\"string\")throw new RangeError(\"Invalid input for AttrStep.fromJSON\");return new Ix(n.pos,n.attr,n.value)}}Ws.jsonID(\"attr\",Ix);class Ow extends Ws{constructor(e,n){super(),this.attr=e,this.value=n}apply(e){let n=Object.create(null);for(let i in e.attrs)n[i]=e.attrs[i];n[this.attr]=this.value;let r=e.type.create(n,e.content,e.marks);return Si.ok(r)}getMap(){return dl.empty}invert(e){return new Ow(this.attr,e.attrs[this.attr])}map(e){return this}toJSON(){return{stepType:\"docAttr\",attr:this.attr,value:this.value}}static fromJSON(e,n){if(typeof n.attr!=\"string\")throw new RangeError(\"Invalid input for DocAttrStep.fromJSON\");return new Ow(n.attr,n.value)}}Ws.jsonID(\"docAttr\",Ow);let ly=class extends Error{};ly=function t(e){let n=Error.call(this,e);return n.__proto__=t.prototype,n};ly.prototype=Object.create(Error.prototype);ly.prototype.constructor=ly;ly.prototype.name=\"TransformError\";class Vie{constructor(e){this.doc=e,this.steps=[],this.docs=[],this.mapping=new Lw}get before(){return this.docs.length?this.docs[0]:this.doc}step(e){let n=this.maybeStep(e);if(n.failed)throw new ly(n.failed);return this}maybeStep(e){let n=e.apply(this.doc);return n.failed||this.addStep(e,n.doc),n}get docChanged(){return this.steps.length>0}changedRange(){let e=1e9,n=-1e9;for(let r=0;r<this.mapping.maps.length;r++){let i=this.mapping.maps[r];r&&(e=i.map(e,1),n=i.map(n,-1)),i.forEach((s,o,l,c)=>{e=Math.min(e,l),n=Math.max(n,c)})}return e==1e9?null:{from:e,to:n}}addStep(e,n){this.docs.push(this.doc),this.steps.push(e),this.mapping.appendMap(e.getMap()),this.doc=n}replace(e,n=e,r=an.empty){let i=nA(this.doc,e,n,r);return i&&this.step(i),this}replaceWith(e,n,r){return this.replace(e,n,new an(Vt.from(r),0,0))}delete(e,n){return this.replace(e,n,an.empty)}insert(e,n){return this.replaceWith(e,e,n)}replaceRange(e,n,r){return d9e(this,e,n,r),this}replaceRangeWith(e,n,r){return u9e(this,e,n,r),this}deleteRange(e,n){return m9e(this,e,n),this}lift(e,n){return YGe(this,e,n),this}join(e,n=1){return i9e(this,e,n),this}wrap(e,n){return JGe(this,e,n),this}setBlockType(e,n=e,r,i=null){return e9e(this,e,n,r,i),this}setNodeMarkup(e,n,r=null,i){return n9e(this,e,n,r,i),this}setNodeAttribute(e,n,r){return this.step(new Ix(e,n,r)),this}setDocAttribute(e,n){return this.step(new Ow(e,n)),this}addNodeMark(e,n){return this.step(new Xh(e,n)),this}removeNodeMark(e,n){let r=this.doc.nodeAt(e);if(!r)throw new RangeError(\"No node at position \"+e);if(n instanceof sa)n.isInSet(r.marks)&&this.step(new Mg(e,n));else{let i=r.marks,s,o=[];for(;s=n.isInSet(i);)o.push(new Mg(e,s)),i=s.removeFromSet(i);for(let l=o.length-1;l>=0;l--)this.step(o[l])}return this}split(e,n=1,r){return r9e(this,e,n,r),this}addMark(e,n,r){return WGe(this,e,n,r),this}removeMark(e,n,r){return KGe(this,e,n,r),this}clearIncompatible(e,n,r){return Nz(this,e,n,r),this}}const QD=Object.create(null);class rr{constructor(e,n,r){this.$anchor=e,this.$head=n,this.ranges=r||[new h9e(e.min(n),e.max(n))]}get anchor(){return this.$anchor.pos}get head(){return this.$head.pos}get from(){return this.$from.pos}get to(){return this.$to.pos}get $from(){return this.ranges[0].$from}get $to(){return this.ranges[0].$to}get empty(){let e=this.ranges;for(let n=0;n<e.length;n++)if(e[n].$from.pos!=e[n].$to.pos)return!1;return!0}content(){return this.$from.doc.slice(this.from,this.to,!0)}replace(e,n=an.empty){let r=n.content.lastChild,i=null;for(let l=0;l<n.openEnd;l++)i=r,r=r.lastChild;let s=e.steps.length,o=this.ranges;for(let l=0;l<o.length;l++){let{$from:c,$to:d}=o[l],u=e.mapping.slice(s);e.replaceRange(u.map(c.pos),u.map(d.pos),l?an.empty:n),l==0&&IK(e,s,(r?r.isInline:i&&i.isTextblock)?-1:1)}}replaceWith(e,n){let r=e.steps.length,i=this.ranges;for(let s=0;s<i.length;s++){let{$from:o,$to:l}=i[s],c=e.mapping.slice(r),d=c.map(o.pos),u=c.map(l.pos);s?e.deleteRange(d,u):(e.replaceRangeWith(d,u,n),IK(e,r,n.isInline?-1:1))}}static findFrom(e,n,r=!1){let i=e.parent.inlineContent?new $n(e):fx(e.node(0),e.parent,e.pos,e.index(),n,r);if(i)return i;for(let s=e.depth-1;s>=0;s--){let o=n<0?fx(e.node(0),e.node(s),e.before(s+1),e.index(s),n,r):fx(e.node(0),e.node(s),e.after(s+1),e.index(s)+1,n,r);if(o)return o}return null}static near(e,n=1){return this.findFrom(e,n)||this.findFrom(e,-n)||new bl(e.node(0))}static atStart(e){return fx(e,e,0,0,1)||new bl(e)}static atEnd(e){return fx(e,e,e.content.size,e.childCount,-1)||new bl(e)}static fromJSON(e,n){if(!n||!n.type)throw new RangeError(\"Invalid input for Selection.fromJSON\");let r=QD[n.type];if(!r)throw new RangeError(`No selection type ${n.type} defined`);return r.fromJSON(e,n)}static jsonID(e,n){if(e in QD)throw new RangeError(\"Duplicate use of selection JSON ID \"+e);return QD[e]=n,n.prototype.jsonID=e,n}getBookmark(){return $n.between(this.$anchor,this.$head).getBookmark()}}rr.prototype.visible=!0;class h9e{constructor(e,n){this.$from=e,this.$to=n}}let LK=!1;function OK(t){!LK&&!t.parent.inlineContent&&(LK=!0,console.warn(\"TextSelection endpoint not pointing into a node with inline content (\"+t.parent.type.name+\")\"))}class $n extends rr{constructor(e,n=e){OK(e),OK(n),super(e,n)}get $cursor(){return this.$anchor.pos==this.$head.pos?this.$head:null}map(e,n){let r=e.resolve(n.map(this.head));if(!r.parent.inlineContent)return rr.near(r);let i=e.resolve(n.map(this.anchor));return new $n(i.parent.inlineContent?i:r,r)}replace(e,n=an.empty){if(super.replace(e,n),n==an.empty){let r=this.$from.marksAcross(this.$to);r&&e.ensureMarks(r)}}eq(e){return e instanceof $n&&e.anchor==this.anchor&&e.head==this.head}getBookmark(){return new rA(this.anchor,this.head)}toJSON(){return{type:\"text\",anchor:this.anchor,head:this.head}}static fromJSON(e,n){if(typeof n.anchor!=\"number\"||typeof n.head!=\"number\")throw new RangeError(\"Invalid input for TextSelection.fromJSON\");return new $n(e.resolve(n.anchor),e.resolve(n.head))}static create(e,n,r=n){let i=e.resolve(n);return new this(i,r==n?i:e.resolve(r))}static between(e,n,r){let i=e.pos-n.pos;if((!r||i)&&(r=i>=0?1:-1),!n.parent.inlineContent){let s=rr.findFrom(n,r,!0)||rr.findFrom(n,-r,!0);if(s)n=s.$head;else return rr.near(n,r)}return e.parent.inlineContent||(i==0?e=n:(e=(rr.findFrom(e,-r,!0)||rr.findFrom(e,r,!0)).$anchor,e.pos<n.pos!=i<0&&(e=n))),new $n(e,n)}}rr.jsonID(\"text\",$n);class rA{constructor(e,n){this.anchor=e,this.head=n}map(e){return new rA(e.map(this.anchor),e.map(this.head))}resolve(e){return $n.between(e.resolve(this.anchor),e.resolve(this.head))}}class An extends rr{constructor(e){let n=e.nodeAfter,r=e.node(0).resolve(e.pos+n.nodeSize);super(e,r),this.node=n}map(e,n){let{deleted:r,pos:i}=n.mapResult(this.anchor),s=e.resolve(i);return r?rr.near(s):new An(s)}content(){return new an(Vt.from(this.node),0,0)}eq(e){return e instanceof An&&e.anchor==this.anchor}toJSON(){return{type:\"node\",anchor:this.anchor}}getBookmark(){return new Pz(this.anchor)}static fromJSON(e,n){if(typeof n.anchor!=\"number\")throw new RangeError(\"Invalid input for NodeSelection.fromJSON\");return new An(e.resolve(n.anchor))}static create(e,n){return new An(e.resolve(n))}static isSelectable(e){return!e.isText&&e.type.spec.selectable!==!1}}An.prototype.visible=!1;rr.jsonID(\"node\",An);class Pz{constructor(e){this.anchor=e}map(e){let{deleted:n,pos:r}=e.mapResult(this.anchor);return n?new rA(r,r):new Pz(r)}resolve(e){let n=e.resolve(this.anchor),r=n.nodeAfter;return r&&An.isSelectable(r)?new An(n):rr.near(n)}}class bl extends rr{constructor(e){super(e.resolve(0),e.resolve(e.content.size))}replace(e,n=an.empty){if(n==an.empty){e.delete(0,e.doc.content.size);let r=rr.atStart(e.doc);r.eq(e.selection)||e.setSelection(r)}else super.replace(e,n)}toJSON(){return{type:\"all\"}}static fromJSON(e){return new bl(e)}map(e){return new bl(e)}eq(e){return e instanceof bl}getBookmark(){return p9e}}rr.jsonID(\"all\",bl);const p9e={map(){return this},resolve(t){return new bl(t)}};function fx(t,e,n,r,i,s=!1){if(e.inlineContent)return $n.create(t,n);for(let o=r-(i>0?0:1);i>0?o<e.childCount:o>=0;o+=i){let l=e.child(o);if(l.isAtom){if(!s&&An.isSelectable(l))return An.create(t,n-(i<0?l.nodeSize:0))}else{let c=fx(t,l,n+i,i<0?l.childCount:0,i,s);if(c)return c}n+=l.nodeSize*i}return null}function IK(t,e,n){let r=t.steps.length-1;if(r<e)return;let i=t.steps[r];if(!(i instanceof Yi||i instanceof Zi))return;let s=t.mapping.maps[r],o;s.forEach((l,c,d,u)=>{o==null&&(o=u)}),t.setSelection(rr.near(t.doc.resolve(o),n))}const zK=1,yk=2,UK=4;class f9e extends Vie{constructor(e){super(e.doc),this.curSelectionFor=0,this.updated=0,this.meta=Object.create(null),this.time=Date.now(),this.curSelection=e.selection,this.storedMarks=e.storedMarks}get selection(){return this.curSelectionFor<this.steps.length&&(this.curSelection=this.curSelection.map(this.doc,this.mapping.slice(this.curSelectionFor)),this.curSelectionFor=this.steps.length),this.curSelection}setSelection(e){if(e.$from.doc!=this.doc)throw new RangeError(\"Selection passed to setSelection must point at the current document\");return this.curSelection=e,this.curSelectionFor=this.steps.length,this.updated=(this.updated|zK)&~yk,this.storedMarks=null,this}get selectionSet(){return(this.updated&zK)>0}setStoredMarks(e){return this.storedMarks=e,this.updated|=yk,this}ensureMarks(e){return sa.sameSet(this.storedMarks||this.selection.$from.marks(),e)||this.setStoredMarks(e),this}addStoredMark(e){return this.ensureMarks(e.addToSet(this.storedMarks||this.selection.$head.marks()))}removeStoredMark(e){return this.ensureMarks(e.removeFromSet(this.storedMarks||this.selection.$head.marks()))}get storedMarksSet(){return(this.updated&yk)>0}addStep(e,n){super.addStep(e,n),this.updated=this.updated&~yk,this.storedMarks=null}setTime(e){return this.time=e,this}replaceSelection(e){return this.selection.replace(this,e),this}replaceSelectionWith(e,n=!0){let r=this.selection;return n&&(e=e.mark(this.storedMarks||(r.empty?r.$from.marks():r.$from.marksAcross(r.$to)||sa.none))),r.replaceWith(this,e),this}deleteSelection(){return this.selection.replace(this),this}insertText(e,n,r){let i=this.doc.type.schema;if(n==null)return e?this.replaceSelectionWith(i.text(e),!0):this.deleteSelection();{if(r==null&&(r=n),!e)return this.deleteRange(n,r);let s=this.storedMarks;if(!s){let o=this.doc.resolve(n);s=r==n?o.marks():o.marksAcross(this.doc.resolve(r))}return this.replaceRangeWith(n,r,i.text(e,s)),!this.selection.empty&&this.selection.to==n+e.length&&this.setSelection(rr.near(this.selection.$to)),this}}setMeta(e,n){return this.meta[typeof e==\"string\"?e:e.key]=n,this}getMeta(e){return this.meta[typeof e==\"string\"?e:e.key]}get isGeneric(){for(let e in this.meta)return!1;return!0}scrollIntoView(){return this.updated|=UK,this}get scrolledIntoView(){return(this.updated&UK)>0}}function BK(t,e){return!e||!t?t:t.bind(e)}class S0{constructor(e,n,r){this.name=e,this.init=BK(n.init,r),this.apply=BK(n.apply,r)}}const g9e=[new S0(\"doc\",{init(t){return t.doc||t.schema.topNodeType.createAndFill()},apply(t){return t.doc}}),new S0(\"selection\",{init(t,e){return t.selection||rr.atStart(e.doc)},apply(t){return t.selection}}),new S0(\"storedMarks\",{init(t){return t.storedMarks||null},apply(t,e,n,r){return r.selection.$cursor?t.storedMarks:null}}),new S0(\"scrollToSelection\",{init(){return 0},apply(t,e){return t.scrolledIntoView?e+1:e}})];class ZD{constructor(e,n){this.schema=e,this.plugins=[],this.pluginsByKey=Object.create(null),this.fields=g9e.slice(),n&&n.forEach(r=>{if(this.pluginsByKey[r.key])throw new RangeError(\"Adding different instances of a keyed plugin (\"+r.key+\")\");this.plugins.push(r),this.pluginsByKey[r.key]=r,r.spec.state&&this.fields.push(new S0(r.key,r.spec.state,r))})}}class Nx{constructor(e){this.config=e}get schema(){return this.config.schema}get plugins(){return this.config.plugins}apply(e){return this.applyTransaction(e).state}filterTransaction(e,n=-1){for(let r=0;r<this.config.plugins.length;r++)if(r!=n){let i=this.config.plugins[r];if(i.spec.filterTransaction&&!i.spec.filterTransaction.call(i,e,this))return!1}return!0}applyTransaction(e){if(!this.filterTransaction(e))return{state:this,transactions:[]};let n=[e],r=this.applyInner(e),i=null;for(;;){let s=!1;for(let o=0;o<this.config.plugins.length;o++){let l=this.config.plugins[o];if(l.spec.appendTransaction){let c=i?i[o].n:0,d=i?i[o].state:this,u=c<n.length&&l.spec.appendTransaction.call(l,c?n.slice(c):n,d,r);if(u&&r.filterTransaction(u,o)){if(u.setMeta(\"appendedTransaction\",e),!i){i=[];for(let m=0;m<this.config.plugins.length;m++)i.push(m<o?{state:r,n:n.length}:{state:this,n:0})}n.push(u),r=r.applyInner(u),s=!0}i&&(i[o]={state:r,n:n.length})}}if(!s)return{state:r,transactions:n}}}applyInner(e){if(!e.before.eq(this.doc))throw new RangeError(\"Applying a mismatched transaction\");let n=new Nx(this.config),r=this.config.fields;for(let i=0;i<r.length;i++){let s=r[i];n[s.name]=s.apply(e,this[s.name],this,n)}return n}get tr(){return new f9e(this)}static create(e){let n=new ZD(e.doc?e.doc.type.schema:e.schema,e.plugins),r=new Nx(n);for(let i=0;i<n.fields.length;i++)r[n.fields[i].name]=n.fields[i].init(e,r);return r}reconfigure(e){let n=new ZD(this.schema,e.plugins),r=n.fields,i=new Nx(n);for(let s=0;s<r.length;s++){let o=r[s].name;i[o]=this.hasOwnProperty(o)?this[o]:r[s].init(e,i)}return i}toJSON(e){let n={doc:this.doc.toJSON(),selection:this.selection.toJSON()};if(this.storedMarks&&(n.storedMarks=this.storedMarks.map(r=>r.toJSON())),e&&typeof e==\"object\")for(let r in e){if(r==\"doc\"||r==\"selection\")throw new RangeError(\"The JSON fields `doc` and `selection` are reserved\");let i=e[r],s=i.spec.state;s&&s.toJSON&&(n[r]=s.toJSON.call(i,this[i.key]))}return n}static fromJSON(e,n,r){if(!n)throw new RangeError(\"Invalid input for EditorState.fromJSON\");if(!e.schema)throw new RangeError(\"Required config field 'schema' missing\");let i=new ZD(e.schema,e.plugins),s=new Nx(i);return i.fields.forEach(o=>{if(o.name==\"doc\")s.doc=Yc.fromJSON(e.schema,n.doc);else if(o.name==\"selection\")s.selection=rr.fromJSON(s.doc,n.selection);else if(o.name==\"storedMarks\")n.storedMarks&&(s.storedMarks=n.storedMarks.map(e.schema.markFromJSON));else{if(r)for(let l in r){let c=r[l],d=c.spec.state;if(c.key==o.name&&d&&d.fromJSON&&Object.prototype.hasOwnProperty.call(n,l)){s[o.name]=d.fromJSON.call(c,e,n[l],s);return}}s[o.name]=o.init(e,s)}}),s}}function Gie(t,e,n){for(let r in t){let i=t[r];i instanceof Function?i=i.bind(e):r==\"handleDOMEvents\"&&(i=Gie(i,e,{})),n[r]=i}return n}class Ua{constructor(e){this.spec=e,this.props={},e.props&&Gie(e.props,this,this.props),this.key=e.key?e.key.key:Wie(\"plugin\")}getState(e){return e[this.key]}}const JD=Object.create(null);function Wie(t){return t in JD?t+\"$\"+ ++JD[t]:(JD[t]=0,t+\"$\")}class zi{constructor(e=\"key\"){this.key=Wie(e)}get(e){return e.config.pluginsByKey[this.key]}getState(e){return e[this.key]}}const Tz=(t,e)=>t.selection.empty?!1:(e&&e(t.tr.deleteSelection().scrollIntoView()),!0);function Kie(t,e){let{$cursor:n}=t.selection;return!n||(e?!e.endOfTextblock(\"backward\",t):n.parentOffset>0)?null:n}const Xie=(t,e,n)=>{let r=Kie(t,n);if(!r)return!1;let i=Az(r);if(!i){let o=r.blockRange(),l=o&&zy(o);return l==null?!1:(e&&e(t.tr.lift(o,l).scrollIntoView()),!0)}let s=i.nodeBefore;if(ase(t,i,e,-1))return!0;if(r.parent.content.size==0&&(cy(s,\"end\")||An.isSelectable(s)))for(let o=r.depth;;o--){let l=nA(t.doc,r.before(o),r.after(o),an.empty);if(l&&l.slice.size<l.to-l.from){if(e){let c=t.tr.step(l);c.setSelection(cy(s,\"end\")?rr.findFrom(c.doc.resolve(c.mapping.map(i.pos,-1)),-1):An.create(c.doc,i.pos-s.nodeSize)),e(c.scrollIntoView())}return!0}if(o==1||r.node(o-1).childCount>1)break}return s.isAtom&&i.depth==r.depth-1?(e&&e(t.tr.delete(i.pos-s.nodeSize,i.pos).scrollIntoView()),!0):!1},b9e=(t,e,n)=>{let r=Kie(t,n);if(!r)return!1;let i=Az(r);return i?Yie(t,i,e):!1},x9e=(t,e,n)=>{let r=Zie(t,n);if(!r)return!1;let i=jz(r);return i?Yie(t,i,e):!1};function Yie(t,e,n){let r=e.nodeBefore,i=r,s=e.pos-1;for(;!i.isTextblock;s--){if(i.type.spec.isolating)return!1;let u=i.lastChild;if(!u)return!1;i=u}let o=e.nodeAfter,l=o,c=e.pos+1;for(;!l.isTextblock;c++){if(l.type.spec.isolating)return!1;let u=l.firstChild;if(!u)return!1;l=u}let d=nA(t.doc,s,c,an.empty);if(!d||d.from!=s||d instanceof Yi&&d.slice.size>=c-s)return!1;if(n){let u=t.tr.step(d);u.setSelection($n.create(u.doc,s)),n(u.scrollIntoView())}return!0}function cy(t,e,n=!1){for(let r=t;r;r=e==\"start\"?r.firstChild:r.lastChild){if(r.isTextblock)return!0;if(n&&r.childCount!=1)return!1}return!1}const Qie=(t,e,n)=>{let{$head:r,empty:i}=t.selection,s=r;if(!i)return!1;if(r.parent.isTextblock){if(n?!n.endOfTextblock(\"backward\",t):r.parentOffset>0)return!1;s=Az(r)}let o=s&&s.nodeBefore;return!o||!An.isSelectable(o)?!1:(e&&e(t.tr.setSelection(An.create(t.doc,s.pos-o.nodeSize)).scrollIntoView()),!0)};function Az(t){if(!t.parent.type.spec.isolating)for(let e=t.depth-1;e>=0;e--){if(t.index(e)>0)return t.doc.resolve(t.before(e+1));if(t.node(e).type.spec.isolating)break}return null}function Zie(t,e){let{$cursor:n}=t.selection;return!n||(e?!e.endOfTextblock(\"forward\",t):n.parentOffset<n.parent.content.size)?null:n}const Jie=(t,e,n)=>{let r=Zie(t,n);if(!r)return!1;let i=jz(r);if(!i)return!1;let s=i.nodeAfter;if(ase(t,i,e,1))return!0;if(r.parent.content.size==0&&(cy(s,\"start\")||An.isSelectable(s))){let o=nA(t.doc,r.before(),r.after(),an.empty);if(o&&o.slice.size<o.to-o.from){if(e){let l=t.tr.step(o);l.setSelection(cy(s,\"start\")?rr.findFrom(l.doc.resolve(l.mapping.map(i.pos)),1):An.create(l.doc,l.mapping.map(i.pos))),e(l.scrollIntoView())}return!0}}return s.isAtom&&i.depth==r.depth-1?(e&&e(t.tr.delete(i.pos,i.pos+s.nodeSize).scrollIntoView()),!0):!1},ese=(t,e,n)=>{let{$head:r,empty:i}=t.selection,s=r;if(!i)return!1;if(r.parent.isTextblock){if(n?!n.endOfTextblock(\"forward\",t):r.parentOffset<r.parent.content.size)return!1;s=jz(r)}let o=s&&s.nodeAfter;return!o||!An.isSelectable(o)?!1:(e&&e(t.tr.setSelection(An.create(t.doc,s.pos)).scrollIntoView()),!0)};function jz(t){if(!t.parent.type.spec.isolating)for(let e=t.depth-1;e>=0;e--){let n=t.node(e);if(t.index(e)+1<n.childCount)return t.doc.resolve(t.after(e+1));if(n.type.spec.isolating)break}return null}const y9e=(t,e)=>{let n=t.selection,r=n instanceof An,i;if(r){if(n.node.isTextblock||!Fp(t.doc,n.from))return!1;i=n.from}else if(i=tA(t.doc,n.from,-1),i==null)return!1;if(e){let s=t.tr.join(i);r&&s.setSelection(An.create(s.doc,i-t.doc.resolve(i).nodeBefore.nodeSize)),e(s.scrollIntoView())}return!0},v9e=(t,e)=>{let n=t.selection,r;if(n instanceof An){if(n.node.isTextblock||!Fp(t.doc,n.to))return!1;r=n.to}else if(r=tA(t.doc,n.to,1),r==null)return!1;return e&&e(t.tr.join(r).scrollIntoView()),!0},w9e=(t,e)=>{let{$from:n,$to:r}=t.selection,i=n.blockRange(r),s=i&&zy(i);return s==null?!1:(e&&e(t.tr.lift(i,s).scrollIntoView()),!0)},tse=(t,e)=>{let{$head:n,$anchor:r}=t.selection;return!n.parent.type.spec.code||!n.sameParent(r)?!1:(e&&e(t.tr.insertText(`\n`).scrollIntoView()),!0)};function Mz(t){for(let e=0;e<t.edgeCount;e++){let{type:n}=t.edge(e);if(n.isTextblock&&!n.hasRequiredAttrs())return n}return null}const S9e=(t,e)=>{let{$head:n,$anchor:r}=t.selection;if(!n.parent.type.spec.code||!n.sameParent(r))return!1;let i=n.node(-1),s=n.indexAfter(-1),o=Mz(i.contentMatchAt(s));if(!o||!i.canReplaceWith(s,s,o))return!1;if(e){let l=n.after(),c=t.tr.replaceWith(l,l,o.createAndFill());c.setSelection(rr.near(c.doc.resolve(l),1)),e(c.scrollIntoView())}return!0},nse=(t,e)=>{let n=t.selection,{$from:r,$to:i}=n;if(n instanceof bl||r.parent.inlineContent||i.parent.inlineContent)return!1;let s=Mz(i.parent.contentMatchAt(i.indexAfter()));if(!s||!s.isTextblock)return!1;if(e){let o=(!r.parentOffset&&i.index()<i.parent.childCount?r:i).pos,l=t.tr.insert(o,s.createAndFill());l.setSelection($n.create(l.doc,o+1)),e(l.scrollIntoView())}return!0},rse=(t,e)=>{let{$cursor:n}=t.selection;if(!n||n.parent.content.size)return!1;if(n.depth>1&&n.after()!=n.end(-1)){let s=n.before();if(hm(t.doc,s))return e&&e(t.tr.split(s).scrollIntoView()),!0}let r=n.blockRange(),i=r&&zy(r);return i==null?!1:(e&&e(t.tr.lift(r,i).scrollIntoView()),!0)};function _9e(t){return(e,n)=>{let{$from:r,$to:i}=e.selection;if(e.selection instanceof An&&e.selection.node.isBlock)return!r.parentOffset||!hm(e.doc,r.pos)?!1:(n&&n(e.tr.split(r.pos).scrollIntoView()),!0);if(!r.depth)return!1;let s=[],o,l,c=!1,d=!1;for(let f=r.depth;;f--)if(r.node(f).isBlock){c=r.end(f)==r.pos+(r.depth-f),d=r.start(f)==r.pos-(r.depth-f),l=Mz(r.node(f-1).contentMatchAt(r.indexAfter(f-1))),s.unshift(c&&l?{type:l}:null),o=f;break}else{if(f==1)return!1;s.unshift(null)}let u=e.tr;(e.selection instanceof $n||e.selection instanceof bl)&&u.deleteSelection();let m=u.mapping.map(r.pos),p=hm(u.doc,m,s.length,s);if(p||(s[0]=l?{type:l}:null,p=hm(u.doc,m,s.length,s)),!p)return!1;if(u.split(m,s.length,s),!c&&d&&r.node(o).type!=l){let f=u.mapping.map(r.before(o)),y=u.doc.resolve(f);l&&r.node(o-1).canReplaceWith(y.index(),y.index()+1,l)&&u.setNodeMarkup(u.mapping.map(r.before(o)),l)}return n&&n(u.scrollIntoView()),!0}}const k9e=_9e(),N9e=(t,e)=>{let{$from:n,to:r}=t.selection,i,s=n.sharedDepth(r);return s==0?!1:(i=n.before(s),e&&e(t.tr.setSelection(An.create(t.doc,i))),!0)};function C9e(t,e,n){let r=e.nodeBefore,i=e.nodeAfter,s=e.index();return!r||!i||!r.type.compatibleContent(i.type)?!1:!r.content.size&&e.parent.canReplace(s-1,s)?(n&&n(t.tr.delete(e.pos-r.nodeSize,e.pos).scrollIntoView()),!0):!e.parent.canReplace(s,s+1)||!(i.isTextblock||Fp(t.doc,e.pos))?!1:(n&&n(t.tr.join(e.pos).scrollIntoView()),!0)}function ase(t,e,n,r){let i=e.nodeBefore,s=e.nodeAfter,o,l,c=i.type.spec.isolating||s.type.spec.isolating;if(!c&&C9e(t,e,n))return!0;let d=!c&&e.parent.canReplace(e.index(),e.index()+1);if(d&&(o=(l=i.contentMatchAt(i.childCount)).findWrapping(s.type))&&l.matchType(o[0]||s.type).validEnd){if(n){let f=e.pos+s.nodeSize,y=Vt.empty;for(let g=o.length-1;g>=0;g--)y=Vt.from(o[g].create(null,y));y=Vt.from(i.copy(y));let v=t.tr.step(new Zi(e.pos-1,f,e.pos,f,new an(y,1,0),o.length,!0)),b=v.doc.resolve(f+2*o.length);b.nodeAfter&&b.nodeAfter.type==i.type&&Fp(v.doc,b.pos)&&v.join(b.pos),n(v.scrollIntoView())}return!0}let u=s.type.spec.isolating||r>0&&c?null:rr.findFrom(e,1),m=u&&u.$from.blockRange(u.$to),p=m&&zy(m);if(p!=null&&p>=e.depth)return n&&n(t.tr.lift(m,p).scrollIntoView()),!0;if(d&&cy(s,\"start\",!0)&&cy(i,\"end\")){let f=i,y=[];for(;y.push(f),!f.isTextblock;)f=f.lastChild;let v=s,b=1;for(;!v.isTextblock;v=v.firstChild)b++;if(f.canReplace(f.childCount,f.childCount,v.content)){if(n){let g=Vt.empty;for(let C=y.length-1;C>=0;C--)g=Vt.from(y[C].copy(g));let _=t.tr.step(new Zi(e.pos-y.length,e.pos+s.nodeSize,e.pos+b,e.pos+s.nodeSize-b,new an(g,y.length,0),0,!0));n(_.scrollIntoView())}return!0}}return!1}function ise(t){return function(e,n){let r=e.selection,i=t<0?r.$from:r.$to,s=i.depth;for(;i.node(s).isInline;){if(!s)return!1;s--}return i.node(s).isTextblock?(n&&n(e.tr.setSelection($n.create(e.doc,t<0?i.start(s):i.end(s)))),!0):!1}}const P9e=ise(-1),T9e=ise(1);function A9e(t,e=null){return function(n,r){let{$from:i,$to:s}=n.selection,o=i.blockRange(s),l=o&&Cz(o,t,e);return l?(r&&r(n.tr.wrap(o,l).scrollIntoView()),!0):!1}}function HK(t,e=null){return function(n,r){let i=!1;for(let s=0;s<n.selection.ranges.length&&!i;s++){let{$from:{pos:o},$to:{pos:l}}=n.selection.ranges[s];n.doc.nodesBetween(o,l,(c,d)=>{if(i)return!1;if(!(!c.isTextblock||c.hasMarkup(t,e)))if(c.type==t)i=!0;else{let u=n.doc.resolve(d),m=u.index();i=u.parent.canReplaceWith(m,m+1,t)}})}if(!i)return!1;if(r){let s=n.tr;for(let o=0;o<n.selection.ranges.length;o++){let{$from:{pos:l},$to:{pos:c}}=n.selection.ranges[o];s.setBlockType(l,c,t,e)}r(s.scrollIntoView())}return!0}}function Ez(...t){return function(e,n,r){for(let i=0;i<t.length;i++)if(t[i](e,n,r))return!0;return!1}}Ez(Tz,Xie,Qie);Ez(Tz,Jie,ese);Ez(tse,nse,rse,k9e);typeof navigator<\"u\"?/Mac|iP(hone|[oa]d)/.test(navigator.platform):typeof os<\"u\"&&os.platform&&os.platform()==\"darwin\";function j9e(t,e=null){return function(n,r){let{$from:i,$to:s}=n.selection,o=i.blockRange(s);if(!o)return!1;let l=r?n.tr:null;return M9e(l,o,t,e)?(r&&r(l.scrollIntoView()),!0):!1}}function M9e(t,e,n,r=null){let i=!1,s=e,o=e.$from.doc;if(e.depth>=2&&e.$from.node(e.depth-1).type.compatibleContent(n)&&e.startIndex==0){if(e.$from.index(e.depth-1)==0)return!1;let c=o.resolve(e.start-2);s=new $C(c,c,e.depth),e.endIndex<e.parent.childCount&&(e=new $C(e.$from,o.resolve(e.$to.end(e.depth)),e.depth)),i=!0}let l=Cz(s,n,r,e);return l?(t&&E9e(t,e,l,i,n),!0):!1}function E9e(t,e,n,r,i){let s=Vt.empty;for(let u=n.length-1;u>=0;u--)s=Vt.from(n[u].type.create(n[u].attrs,s));t.step(new Zi(e.start-(r?2:0),e.end,e.start,e.end,new an(s,0,0),n.length,!0));let o=0;for(let u=0;u<n.length;u++)n[u].type==i&&(o=u+1);let l=n.length-o,c=e.start+n.length-(r?2:0),d=e.parent;for(let u=e.startIndex,m=e.endIndex,p=!0;u<m;u++,p=!1)!p&&hm(t.doc,c,l)&&(t.split(c,l),c+=2*l),c+=d.child(u).nodeSize;return t}function D9e(t){return function(e,n){let{$from:r,$to:i}=e.selection,s=r.blockRange(i,o=>o.childCount>0&&o.firstChild.type==t);return s?n?r.node(s.depth-1).type==t?F9e(e,n,t,s):R9e(e,n,s):!0:!1}}function F9e(t,e,n,r){let i=t.tr,s=r.end,o=r.$to.end(r.depth);s<o&&(i.step(new Zi(s-1,o,s,o,new an(Vt.from(n.create(null,r.parent.copy())),1,0),1,!0)),r=new $C(i.doc.resolve(r.$from.pos),i.doc.resolve(o),r.depth));const l=zy(r);if(l==null)return!1;i.lift(r,l);let c=i.doc.resolve(i.mapping.map(s,-1)-1);return Fp(i.doc,c.pos)&&c.nodeBefore.type==c.nodeAfter.type&&i.join(c.pos),e(i.scrollIntoView()),!0}function R9e(t,e,n){let r=t.tr,i=n.parent;for(let f=n.end,y=n.endIndex-1,v=n.startIndex;y>v;y--)f-=i.child(y).nodeSize,r.delete(f-1,f+1);let s=r.doc.resolve(n.start),o=s.nodeAfter;if(r.mapping.map(n.end)!=n.start+s.nodeAfter.nodeSize)return!1;let l=n.startIndex==0,c=n.endIndex==i.childCount,d=s.node(-1),u=s.index(-1);if(!d.canReplace(u+(l?0:1),u+1,o.content.append(c?Vt.empty:Vt.from(i))))return!1;let m=s.pos,p=m+o.nodeSize;return r.step(new Zi(m-(l?1:0),p+(c?1:0),m+1,p-1,new an((l?Vt.empty:Vt.from(i.copy(Vt.empty))).append(c?Vt.empty:Vt.from(i.copy(Vt.empty))),l?0:1,c?0:1),l?0:1)),e(r.scrollIntoView()),!0}function L9e(t){return function(e,n){let{$from:r,$to:i}=e.selection,s=r.blockRange(i,d=>d.childCount>0&&d.firstChild.type==t);if(!s)return!1;let o=s.startIndex;if(o==0)return!1;let l=s.parent,c=l.child(o-1);if(c.type!=t)return!1;if(n){let d=c.lastChild&&c.lastChild.type==l.type,u=Vt.from(d?t.create():null),m=new an(Vt.from(t.create(null,Vt.from(l.type.create(null,u)))),d?3:1,0),p=s.start,f=s.end;n(e.tr.step(new Zi(p-(d?3:1),f,p,f,m,1,!0)).scrollIntoView())}return!0}}const ys=function(t){for(var e=0;;e++)if(t=t.previousSibling,!t)return e},dy=function(t){let e=t.assignedSlot||t.parentNode;return e&&e.nodeType==11?e.host:e};let jL=null;const qu=function(t,e,n){let r=jL||(jL=document.createRange());return r.setEnd(t,n??t.nodeValue.length),r.setStart(t,e||0),r},O9e=function(){jL=null},Eg=function(t,e,n,r){return n&&(qK(t,e,n,r,-1)||qK(t,e,n,r,1))},I9e=/^(img|br|input|textarea|hr)$/i;function qK(t,e,n,r,i){for(var s;;){if(t==n&&e==r)return!0;if(e==(i<0?0:Zl(t))){let o=t.parentNode;if(!o||o.nodeType!=1||OS(t)||I9e.test(t.nodeName)||t.contentEditable==\"false\")return!1;e=ys(t)+(i<0?0:1),t=o}else if(t.nodeType==1){let o=t.childNodes[e+(i<0?-1:0)];if(o.nodeType==1&&o.contentEditable==\"false\")if(!((s=o.pmViewDesc)===null||s===void 0)&&s.ignoreForSelection)e+=i;else return!1;else t=o,e=i<0?Zl(t):0}else return!1}}function Zl(t){return t.nodeType==3?t.nodeValue.length:t.childNodes.length}function z9e(t,e){for(;;){if(t.nodeType==3&&e)return t;if(t.nodeType==1&&e>0){if(t.contentEditable==\"false\")return null;t=t.childNodes[e-1],e=Zl(t)}else if(t.parentNode&&!OS(t))e=ys(t),t=t.parentNode;else return null}}function U9e(t,e){for(;;){if(t.nodeType==3&&e<t.nodeValue.length)return t;if(t.nodeType==1&&e<t.childNodes.length){if(t.contentEditable==\"false\")return null;t=t.childNodes[e],e=0}else if(t.parentNode&&!OS(t))e=ys(t)+1,t=t.parentNode;else return null}}function B9e(t,e,n){for(let r=e==0,i=e==Zl(t);r||i;){if(t==n)return!0;let s=ys(t);if(t=t.parentNode,!t)return!1;r=r&&s==0,i=i&&s==Zl(t)}}function OS(t){let e;for(let n=t;n&&!(e=n.pmViewDesc);n=n.parentNode);return e&&e.node&&e.node.isBlock&&(e.dom==t||e.contentDOM==t)}const aA=function(t){return t.focusNode&&Eg(t.focusNode,t.focusOffset,t.anchorNode,t.anchorOffset)};function Df(t,e){let n=document.createEvent(\"Event\");return n.initEvent(\"keydown\",!0,!0),n.keyCode=t,n.key=n.code=e,n}function H9e(t){let e=t.activeElement;for(;e&&e.shadowRoot;)e=e.shadowRoot.activeElement;return e}function q9e(t,e,n){if(t.caretPositionFromPoint)try{let r=t.caretPositionFromPoint(e,n);if(r)return{node:r.offsetNode,offset:Math.min(Zl(r.offsetNode),r.offset)}}catch{}if(t.caretRangeFromPoint){let r=t.caretRangeFromPoint(e,n);if(r)return{node:r.startContainer,offset:Math.min(Zl(r.startContainer),r.startOffset)}}}const Wd=typeof navigator<\"u\"?navigator:null,$K=typeof document<\"u\"?document:null,Rp=Wd&&Wd.userAgent||\"\",ML=/Edge\\/(\\d+)/.exec(Rp),sse=/MSIE \\d/.exec(Rp),EL=/Trident\\/(?:[7-9]|\\d{2,})\\..*rv:(\\d+)/.exec(Rp),Ho=!!(sse||EL||ML),mp=sse?document.documentMode:EL?+EL[1]:ML?+ML[1]:0,sc=!Ho&&/gecko\\/(\\d+)/i.test(Rp);sc&&+(/Firefox\\/(\\d+)/.exec(Rp)||[0,0])[1];const DL=!Ho&&/Chrome\\/(\\d+)/.exec(Rp),ks=!!DL,ose=DL?+DL[1]:0,$s=!Ho&&!!Wd&&/Apple Computer/.test(Wd.vendor),uy=$s&&(/Mobile\\/\\w+/.test(Rp)||!!Wd&&Wd.maxTouchPoints>2),Gl=uy||(Wd?/Mac/.test(Wd.platform):!1),lse=Wd?/Win/.test(Wd.platform):!1,am=/Android \\d/.test(Rp),IS=!!$K&&\"webkitFontSmoothing\"in $K.documentElement.style,$9e=IS?+(/\\bAppleWebKit\\/(\\d+)/.exec(navigator.userAgent)||[0,0])[1]:0;function V9e(t){let e=t.defaultView&&t.defaultView.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:t.documentElement.clientWidth,top:0,bottom:t.documentElement.clientHeight}}function Ru(t,e){return typeof t==\"number\"?t:t[e]}function G9e(t){let e=t.getBoundingClientRect(),n=e.width/t.offsetWidth||1,r=e.height/t.offsetHeight||1;return{left:e.left,right:e.left+t.clientWidth*n,top:e.top,bottom:e.top+t.clientHeight*r}}function VK(t,e,n){let r=t.someProp(\"scrollThreshold\")||0,i=t.someProp(\"scrollMargin\")||5,s=t.dom.ownerDocument;for(let o=n||t.dom;o;){if(o.nodeType!=1){o=dy(o);continue}let l=o,c=l==s.body,d=c?V9e(s):G9e(l),u=0,m=0;if(e.top<d.top+Ru(r,\"top\")?m=-(d.top-e.top+Ru(i,\"top\")):e.bottom>d.bottom-Ru(r,\"bottom\")&&(m=e.bottom-e.top>d.bottom-d.top?e.top+Ru(i,\"top\")-d.top:e.bottom-d.bottom+Ru(i,\"bottom\")),e.left<d.left+Ru(r,\"left\")?u=-(d.left-e.left+Ru(i,\"left\")):e.right>d.right-Ru(r,\"right\")&&(u=e.right-d.right+Ru(i,\"right\")),u||m)if(c)s.defaultView.scrollBy(u,m);else{let f=l.scrollLeft,y=l.scrollTop;m&&(l.scrollTop+=m),u&&(l.scrollLeft+=u);let v=l.scrollLeft-f,b=l.scrollTop-y;e={left:e.left-v,top:e.top-b,right:e.right-v,bottom:e.bottom-b}}let p=c?\"fixed\":getComputedStyle(o).position;if(/^(fixed|sticky)$/.test(p))break;o=p==\"absolute\"?o.offsetParent:dy(o)}}function W9e(t){let e=t.dom.getBoundingClientRect(),n=Math.max(0,e.top),r,i;for(let s=(e.left+e.right)/2,o=n+1;o<Math.min(innerHeight,e.bottom);o+=5){let l=t.root.elementFromPoint(s,o);if(!l||l==t.dom||!t.dom.contains(l))continue;let c=l.getBoundingClientRect();if(c.top>=n-20){r=l,i=c.top;break}}return{refDOM:r,refTop:i,stack:cse(t.dom)}}function cse(t){let e=[],n=t.ownerDocument;for(let r=t;r&&(e.push({dom:r,top:r.scrollTop,left:r.scrollLeft}),t!=n);r=dy(r));return e}function K9e({refDOM:t,refTop:e,stack:n}){let r=t?t.getBoundingClientRect().top:0;dse(n,r==0?0:r-e)}function dse(t,e){for(let n=0;n<t.length;n++){let{dom:r,top:i,left:s}=t[n];r.scrollTop!=i+e&&(r.scrollTop=i+e),r.scrollLeft!=s&&(r.scrollLeft=s)}}let ox=null;function X9e(t){if(t.setActive)return t.setActive();if(ox)return t.focus(ox);let e=cse(t);t.focus(ox==null?{get preventScroll(){return ox={preventScroll:!0},!0}}:void 0),ox||(ox=!1,dse(e,0))}function use(t,e){let n,r=2e8,i,s=0,o=e.top,l=e.top,c,d;for(let u=t.firstChild,m=0;u;u=u.nextSibling,m++){let p;if(u.nodeType==1)p=u.getClientRects();else if(u.nodeType==3)p=qu(u).getClientRects();else continue;for(let f=0;f<p.length;f++){let y=p[f];if(y.top<=o&&y.bottom>=l){o=Math.max(y.bottom,o),l=Math.min(y.top,l);let v=y.left>e.left?y.left-e.left:y.right<e.left?e.left-y.right:0;if(v<r){n=u,r=v,i=v&&n.nodeType==3?{left:y.right<e.left?y.right:y.left,top:e.top}:e,u.nodeType==1&&v&&(s=m+(e.left>=(y.left+y.right)/2?1:0));continue}}else y.top>e.top&&!c&&y.left<=e.left&&y.right>=e.left&&(c=u,d={left:Math.max(y.left,Math.min(y.right,e.left)),top:y.top});!n&&(e.left>=y.right&&e.top>=y.top||e.left>=y.left&&e.top>=y.bottom)&&(s=m+1)}}return!n&&c&&(n=c,i=d,r=0),n&&n.nodeType==3?Y9e(n,i):!n||r&&n.nodeType==1?{node:t,offset:s}:use(n,i)}function Y9e(t,e){let n=t.nodeValue.length,r=document.createRange(),i;for(let s=0;s<n;s++){r.setEnd(t,s+1),r.setStart(t,s);let o=Nh(r,1);if(o.top!=o.bottom&&Dz(e,o)){i={node:t,offset:s+(e.left>=(o.left+o.right)/2?1:0)};break}}return r.detach(),i||{node:t,offset:0}}function Dz(t,e){return t.left>=e.left-1&&t.left<=e.right+1&&t.top>=e.top-1&&t.top<=e.bottom+1}function Q9e(t,e){let n=t.parentNode;return n&&/^li$/i.test(n.nodeName)&&e.left<t.getBoundingClientRect().left?n:t}function Z9e(t,e,n){let{node:r,offset:i}=use(e,n),s=-1;if(r.nodeType==1&&!r.firstChild){let o=r.getBoundingClientRect();s=o.left!=o.right&&n.left>(o.left+o.right)/2?1:-1}return t.docView.posFromDOM(r,i,s)}function J9e(t,e,n,r){let i=-1;for(let s=e,o=!1;s!=t.dom;){let l=t.docView.nearestDesc(s,!0),c;if(!l)return null;if(l.dom.nodeType==1&&(l.node.isBlock&&l.parent||!l.contentDOM)&&((c=l.dom.getBoundingClientRect()).width||c.height)&&(l.node.isBlock&&l.parent&&!/^T(R|BODY|HEAD|FOOT)$/.test(l.dom.nodeName)&&(!o&&c.left>r.left||c.top>r.top?i=l.posBefore:(!o&&c.right<r.left||c.bottom<r.top)&&(i=l.posAfter),o=!0),!l.contentDOM&&i<0&&!l.node.isText))return(l.node.isBlock?r.top<(c.top+c.bottom)/2:r.left<(c.left+c.right)/2)?l.posBefore:l.posAfter;s=l.dom.parentNode}return i>-1?i:t.docView.posFromDOM(e,n,-1)}function mse(t,e,n){let r=t.childNodes.length;if(r&&n.top<n.bottom)for(let i=Math.max(0,Math.min(r-1,Math.floor(r*(e.top-n.top)/(n.bottom-n.top))-2)),s=i;;){let o=t.childNodes[s];if(o.nodeType==1){let l=o.getClientRects();for(let c=0;c<l.length;c++){let d=l[c];if(Dz(e,d))return mse(o,e,d)}}if((s=(s+1)%r)==i)break}return t}function eWe(t,e){let n=t.dom.ownerDocument,r,i=0,s=q9e(n,e.left,e.top);s&&({node:r,offset:i}=s);let o=(t.root.elementFromPoint?t.root:n).elementFromPoint(e.left,e.top),l;if(!o||!t.dom.contains(o.nodeType!=1?o.parentNode:o)){let d=t.dom.getBoundingClientRect();if(!Dz(e,d)||(o=mse(t.dom,e,d),!o))return null}if($s)for(let d=o;r&&d;d=dy(d))d.draggable&&(r=void 0);if(o=Q9e(o,e),r){if(sc&&r.nodeType==1&&(i=Math.min(i,r.childNodes.length),i<r.childNodes.length)){let u=r.childNodes[i],m;u.nodeName==\"IMG\"&&(m=u.getBoundingClientRect()).right<=e.left&&m.bottom>e.top&&i++}let d;IS&&i&&r.nodeType==1&&(d=r.childNodes[i-1]).nodeType==1&&d.contentEditable==\"false\"&&d.getBoundingClientRect().top>=e.top&&i--,r==t.dom&&i==r.childNodes.length-1&&r.lastChild.nodeType==1&&e.top>r.lastChild.getBoundingClientRect().bottom?l=t.state.doc.content.size:(i==0||r.nodeType!=1||r.childNodes[i-1].nodeName!=\"BR\")&&(l=J9e(t,r,i,e))}l==null&&(l=Z9e(t,o,e));let c=t.docView.nearestDesc(o,!0);return{pos:l,inside:c?c.posAtStart-c.border:-1}}function GK(t){return t.top<t.bottom||t.left<t.right}function Nh(t,e){let n=t.getClientRects();if(n.length){let r=n[e<0?0:n.length-1];if(GK(r))return r}return Array.prototype.find.call(n,GK)||t.getBoundingClientRect()}const tWe=/[\\u0590-\\u05f4\\u0600-\\u06ff\\u0700-\\u08ac]/;function hse(t,e,n){let{node:r,offset:i,atom:s}=t.docView.domFromPos(e,n<0?-1:1),o=IS||sc;if(r.nodeType==3)if(o&&(tWe.test(r.nodeValue)||(n<0?!i:i==r.nodeValue.length))){let c=Nh(qu(r,i,i),n);if(sc&&i&&/\\s/.test(r.nodeValue[i-1])&&i<r.nodeValue.length){let d=Nh(qu(r,i-1,i-1),-1);if(d.top==c.top){let u=Nh(qu(r,i,i+1),-1);if(u.top!=c.top)return s0(u,u.left<d.left)}}return c}else{let c=i,d=i,u=n<0?1:-1;return n<0&&!i?(d++,u=-1):n>=0&&i==r.nodeValue.length?(c--,u=1):n<0?c--:d++,s0(Nh(qu(r,c,d),u),u<0)}if(!t.state.doc.resolve(e-(s||0)).parent.inlineContent){if(s==null&&i&&(n<0||i==Zl(r))){let c=r.childNodes[i-1];if(c.nodeType==1)return e3(c.getBoundingClientRect(),!1)}if(s==null&&i<Zl(r)){let c=r.childNodes[i];if(c.nodeType==1)return e3(c.getBoundingClientRect(),!0)}return e3(r.getBoundingClientRect(),n>=0)}if(s==null&&i&&(n<0||i==Zl(r))){let c=r.childNodes[i-1],d=c.nodeType==3?qu(c,Zl(c)-(o?0:1)):c.nodeType==1&&(c.nodeName!=\"BR\"||!c.nextSibling)?c:null;if(d)return s0(Nh(d,1),!1)}if(s==null&&i<Zl(r)){let c=r.childNodes[i];for(;c.pmViewDesc&&c.pmViewDesc.ignoreForCoords;)c=c.nextSibling;let d=c?c.nodeType==3?qu(c,0,o?0:1):c.nodeType==1?c:null:null;if(d)return s0(Nh(d,-1),!0)}return s0(Nh(r.nodeType==3?qu(r):r,-n),n>=0)}function s0(t,e){if(t.width==0)return t;let n=e?t.left:t.right;return{top:t.top,bottom:t.bottom,left:n,right:n}}function e3(t,e){if(t.height==0)return t;let n=e?t.top:t.bottom;return{top:n,bottom:n,left:t.left,right:t.right}}function pse(t,e,n){let r=t.state,i=t.root.activeElement;r!=e&&t.updateState(e),i!=t.dom&&t.focus();try{return n()}finally{r!=e&&t.updateState(r),i!=t.dom&&i&&i.focus()}}function nWe(t,e,n){let r=e.selection,i=n==\"up\"?r.$from:r.$to;return pse(t,e,()=>{let{node:s}=t.docView.domFromPos(i.pos,n==\"up\"?-1:1);for(;;){let l=t.docView.nearestDesc(s,!0);if(!l)break;if(l.node.isBlock){s=l.contentDOM||l.dom;break}s=l.dom.parentNode}let o=hse(t,i.pos,1);for(let l=s.firstChild;l;l=l.nextSibling){let c;if(l.nodeType==1)c=l.getClientRects();else if(l.nodeType==3)c=qu(l,0,l.nodeValue.length).getClientRects();else continue;for(let d=0;d<c.length;d++){let u=c[d];if(u.bottom>u.top+1&&(n==\"up\"?o.top-u.top>(u.bottom-o.top)*2:u.bottom-o.bottom>(o.bottom-u.top)*2))return!1}}return!0})}const rWe=/[\\u0590-\\u08ac]/;function aWe(t,e,n){let{$head:r}=e.selection;if(!r.parent.isTextblock)return!1;let i=r.parentOffset,s=!i,o=i==r.parent.content.size,l=t.domSelection();return l?!rWe.test(r.parent.textContent)||!l.modify?n==\"left\"||n==\"backward\"?s:o:pse(t,e,()=>{let{focusNode:c,focusOffset:d,anchorNode:u,anchorOffset:m}=t.domSelectionRange(),p=l.caretBidiLevel;l.modify(\"move\",n,\"character\");let f=r.depth?t.docView.domAfterPos(r.before()):t.dom,{focusNode:y,focusOffset:v}=t.domSelectionRange(),b=y&&!f.contains(y.nodeType==1?y:y.parentNode)||c==y&&d==v;try{l.collapse(u,m),c&&(c!=u||d!=m)&&l.extend&&l.extend(c,d)}catch{}return p!=null&&(l.caretBidiLevel=p),b}):r.pos==r.start()||r.pos==r.end()}let WK=null,KK=null,XK=!1;function iWe(t,e,n){return WK==e&&KK==n?XK:(WK=e,KK=n,XK=n==\"up\"||n==\"down\"?nWe(t,e,n):aWe(t,e,n))}const oc=0,YK=1,$f=2,Kd=3;class zS{constructor(e,n,r,i){this.parent=e,this.children=n,this.dom=r,this.contentDOM=i,this.dirty=oc,r.pmViewDesc=this}matchesWidget(e){return!1}matchesMark(e){return!1}matchesNode(e,n,r){return!1}matchesHack(e){return!1}parseRule(){return null}stopEvent(e){return!1}get size(){let e=0;for(let n=0;n<this.children.length;n++)e+=this.children[n].size;return e}get border(){return 0}destroy(){this.parent=void 0,this.dom.pmViewDesc==this&&(this.dom.pmViewDesc=void 0);for(let e=0;e<this.children.length;e++)this.children[e].destroy()}posBeforeChild(e){for(let n=0,r=this.posAtStart;;n++){let i=this.children[n];if(i==e)return r;r+=i.size}}get posBefore(){return this.parent.posBeforeChild(this)}get posAtStart(){return this.parent?this.parent.posBeforeChild(this)+this.border:0}get posAfter(){return this.posBefore+this.size}get posAtEnd(){return this.posAtStart+this.size-2*this.border}localPosFromDOM(e,n,r){if(this.contentDOM&&this.contentDOM.contains(e.nodeType==1?e:e.parentNode))if(r<0){let s,o;if(e==this.contentDOM)s=e.childNodes[n-1];else{for(;e.parentNode!=this.contentDOM;)e=e.parentNode;s=e.previousSibling}for(;s&&!((o=s.pmViewDesc)&&o.parent==this);)s=s.previousSibling;return s?this.posBeforeChild(o)+o.size:this.posAtStart}else{let s,o;if(e==this.contentDOM)s=e.childNodes[n];else{for(;e.parentNode!=this.contentDOM;)e=e.parentNode;s=e.nextSibling}for(;s&&!((o=s.pmViewDesc)&&o.parent==this);)s=s.nextSibling;return s?this.posBeforeChild(o):this.posAtEnd}let i;if(e==this.dom&&this.contentDOM)i=n>ys(this.contentDOM);else if(this.contentDOM&&this.contentDOM!=this.dom&&this.dom.contains(this.contentDOM))i=e.compareDocumentPosition(this.contentDOM)&2;else if(this.dom.firstChild){if(n==0)for(let s=e;;s=s.parentNode){if(s==this.dom){i=!1;break}if(s.previousSibling)break}if(i==null&&n==e.childNodes.length)for(let s=e;;s=s.parentNode){if(s==this.dom){i=!0;break}if(s.nextSibling)break}}return i??r>0?this.posAtEnd:this.posAtStart}nearestDesc(e,n=!1){for(let r=!0,i=e;i;i=i.parentNode){let s=this.getDesc(i),o;if(s&&(!n||s.node))if(r&&(o=s.nodeDOM)&&!(o.nodeType==1?o.contains(e.nodeType==1?e:e.parentNode):o==e))r=!1;else return s}}getDesc(e){let n=e.pmViewDesc;for(let r=n;r;r=r.parent)if(r==this)return n}posFromDOM(e,n,r){for(let i=e;i;i=i.parentNode){let s=this.getDesc(i);if(s)return s.localPosFromDOM(e,n,r)}return-1}descAt(e){for(let n=0,r=0;n<this.children.length;n++){let i=this.children[n],s=r+i.size;if(r==e&&s!=r){for(;!i.border&&i.children.length;)for(let o=0;o<i.children.length;o++){let l=i.children[o];if(l.size){i=l;break}}return i}if(e<s)return i.descAt(e-r-i.border);r=s}}domFromPos(e,n){if(!this.contentDOM)return{node:this.dom,offset:0,atom:e+1};let r=0,i=0;for(let s=0;r<this.children.length;r++){let o=this.children[r],l=s+o.size;if(l>e||o instanceof gse){i=e-s;break}s=l}if(i)return this.children[r].domFromPos(i-this.children[r].border,n);for(let s;r&&!(s=this.children[r-1]).size&&s instanceof fse&&s.side>=0;r--);if(n<=0){let s,o=!0;for(;s=r?this.children[r-1]:null,!(!s||s.dom.parentNode==this.contentDOM);r--,o=!1);return s&&n&&o&&!s.border&&!s.domAtom?s.domFromPos(s.size,n):{node:this.contentDOM,offset:s?ys(s.dom)+1:0}}else{let s,o=!0;for(;s=r<this.children.length?this.children[r]:null,!(!s||s.dom.parentNode==this.contentDOM);r++,o=!1);return s&&o&&!s.border&&!s.domAtom?s.domFromPos(0,n):{node:this.contentDOM,offset:s?ys(s.dom):this.contentDOM.childNodes.length}}}parseRange(e,n,r=0){if(this.children.length==0)return{node:this.contentDOM,from:e,to:n,fromOffset:0,toOffset:this.contentDOM.childNodes.length};let i=-1,s=-1;for(let o=r,l=0;;l++){let c=this.children[l],d=o+c.size;if(i==-1&&e<=d){let u=o+c.border;if(e>=u&&n<=d-c.border&&c.node&&c.contentDOM&&this.contentDOM.contains(c.contentDOM))return c.parseRange(e,n,u);e=o;for(let m=l;m>0;m--){let p=this.children[m-1];if(p.size&&p.dom.parentNode==this.contentDOM&&!p.emptyChildAt(1)){i=ys(p.dom)+1;break}e-=p.size}i==-1&&(i=0)}if(i>-1&&(d>n||l==this.children.length-1)){n=d;for(let u=l+1;u<this.children.length;u++){let m=this.children[u];if(m.size&&m.dom.parentNode==this.contentDOM&&!m.emptyChildAt(-1)){s=ys(m.dom);break}n+=m.size}s==-1&&(s=this.contentDOM.childNodes.length);break}o=d}return{node:this.contentDOM,from:e,to:n,fromOffset:i,toOffset:s}}emptyChildAt(e){if(this.border||!this.contentDOM||!this.children.length)return!1;let n=this.children[e<0?0:this.children.length-1];return n.size==0||n.emptyChildAt(e)}domAfterPos(e){let{node:n,offset:r}=this.domFromPos(e,0);if(n.nodeType!=1||r==n.childNodes.length)throw new RangeError(\"No node after pos \"+e);return n.childNodes[r]}setSelection(e,n,r,i=!1){let s=Math.min(e,n),o=Math.max(e,n);for(let f=0,y=0;f<this.children.length;f++){let v=this.children[f],b=y+v.size;if(s>y&&o<b)return v.setSelection(e-y-v.border,n-y-v.border,r,i);y=b}let l=this.domFromPos(e,e?-1:1),c=n==e?l:this.domFromPos(n,n?-1:1),d=r.root.getSelection(),u=r.domSelectionRange(),m=!1;if((sc||$s)&&e==n){let{node:f,offset:y}=l;if(f.nodeType==3){if(m=!!(y&&f.nodeValue[y-1]==`\n`),m&&y==f.nodeValue.length)for(let v=f,b;v;v=v.parentNode){if(b=v.nextSibling){b.nodeName==\"BR\"&&(l=c={node:b.parentNode,offset:ys(b)+1});break}let g=v.pmViewDesc;if(g&&g.node&&g.node.isBlock)break}}else{let v=f.childNodes[y-1];m=v&&(v.nodeName==\"BR\"||v.contentEditable==\"false\")}}if(sc&&u.focusNode&&u.focusNode!=c.node&&u.focusNode.nodeType==1){let f=u.focusNode.childNodes[u.focusOffset];f&&f.contentEditable==\"false\"&&(i=!0)}if(!(i||m&&$s)&&Eg(l.node,l.offset,u.anchorNode,u.anchorOffset)&&Eg(c.node,c.offset,u.focusNode,u.focusOffset))return;let p=!1;if((d.extend||e==n)&&!(m&&sc)){d.collapse(l.node,l.offset);try{e!=n&&d.extend(c.node,c.offset),p=!0}catch{}}if(!p){if(e>n){let y=l;l=c,c=y}let f=document.createRange();f.setEnd(c.node,c.offset),f.setStart(l.node,l.offset),d.removeAllRanges(),d.addRange(f)}}ignoreMutation(e){return!this.contentDOM&&e.type!=\"selection\"}get contentLost(){return this.contentDOM&&this.contentDOM!=this.dom&&!this.dom.contains(this.contentDOM)}markDirty(e,n){for(let r=0,i=0;i<this.children.length;i++){let s=this.children[i],o=r+s.size;if(r==o?e<=o&&n>=r:e<o&&n>r){let l=r+s.border,c=o-s.border;if(e>=l&&n<=c){this.dirty=e==r||n==o?$f:YK,e==l&&n==c&&(s.contentLost||s.dom.parentNode!=this.contentDOM)?s.dirty=Kd:s.markDirty(e-l,n-l);return}else s.dirty=s.dom==s.contentDOM&&s.dom.parentNode==this.contentDOM&&!s.children.length?$f:Kd}r=o}this.dirty=$f}markParentsDirty(){let e=1;for(let n=this.parent;n;n=n.parent,e++){let r=e==1?$f:YK;n.dirty<r&&(n.dirty=r)}}get domAtom(){return!1}get ignoreForCoords(){return!1}get ignoreForSelection(){return!1}isText(e){return!1}}class fse extends zS{constructor(e,n,r,i){let s,o=n.type.toDOM;if(typeof o==\"function\"&&(o=o(r,()=>{if(!s)return i;if(s.parent)return s.parent.posBeforeChild(s)})),!n.type.spec.raw){if(o.nodeType!=1){let l=document.createElement(\"span\");l.appendChild(o),o=l}o.contentEditable=\"false\",o.classList.add(\"ProseMirror-widget\")}super(e,[],o,null),this.widget=n,this.widget=n,s=this}matchesWidget(e){return this.dirty==oc&&e.type.eq(this.widget.type)}parseRule(){return{ignore:!0}}stopEvent(e){let n=this.widget.spec.stopEvent;return n?n(e):!1}ignoreMutation(e){return e.type!=\"selection\"||this.widget.spec.ignoreSelection}destroy(){this.widget.type.destroy(this.dom),super.destroy()}get domAtom(){return!0}get ignoreForSelection(){return!!this.widget.type.spec.relaxedSide}get side(){return this.widget.type.side}}class sWe extends zS{constructor(e,n,r,i){super(e,[],n,null),this.textDOM=r,this.text=i}get size(){return this.text.length}localPosFromDOM(e,n){return e!=this.textDOM?this.posAtStart+(n?this.size:0):this.posAtStart+n}domFromPos(e){return{node:this.textDOM,offset:e}}ignoreMutation(e){return e.type===\"characterData\"&&e.target.nodeValue==e.oldValue}}class Dg extends zS{constructor(e,n,r,i,s){super(e,[],r,i),this.mark=n,this.spec=s}static create(e,n,r,i){let s=i.nodeViews[n.type.name],o=s&&s(n,i,r);return(!o||!o.dom)&&(o=$g.renderSpec(document,n.type.spec.toDOM(n,r),null,n.attrs)),new Dg(e,n,o.dom,o.contentDOM||o.dom,o)}parseRule(){return this.dirty&Kd||this.mark.type.spec.reparseInView?null:{mark:this.mark.type.name,attrs:this.mark.attrs,contentElement:this.contentDOM}}matchesMark(e){return this.dirty!=Kd&&this.mark.eq(e)}markDirty(e,n){if(super.markDirty(e,n),this.dirty!=oc){let r=this.parent;for(;!r.node;)r=r.parent;r.dirty<this.dirty&&(r.dirty=this.dirty),this.dirty=oc}}slice(e,n,r){let i=Dg.create(this.parent,this.mark,!0,r),s=this.children,o=this.size;n<o&&(s=RL(s,n,o,r)),e>0&&(s=RL(s,0,e,r));for(let l=0;l<s.length;l++)s[l].parent=i;return i.children=s,i}ignoreMutation(e){return this.spec.ignoreMutation?this.spec.ignoreMutation(e):super.ignoreMutation(e)}destroy(){this.spec.destroy&&this.spec.destroy(),super.destroy()}}class hp extends zS{constructor(e,n,r,i,s,o,l,c,d){super(e,[],s,o),this.node=n,this.outerDeco=r,this.innerDeco=i,this.nodeDOM=l}static create(e,n,r,i,s,o){let l=s.nodeViews[n.type.name],c,d=l&&l(n,s,()=>{if(!c)return o;if(c.parent)return c.parent.posBeforeChild(c)},r,i),u=d&&d.dom,m=d&&d.contentDOM;if(n.isText){if(!u)u=document.createTextNode(n.text);else if(u.nodeType!=3)throw new RangeError(\"Text must be rendered as a DOM text node\")}else u||({dom:u,contentDOM:m}=$g.renderSpec(document,n.type.spec.toDOM(n),null,n.attrs));!m&&!n.isText&&u.nodeName!=\"BR\"&&(u.hasAttribute(\"contenteditable\")||(u.contentEditable=\"false\"),n.type.spec.draggable&&(u.draggable=!0));let p=u;return u=yse(u,r,n),d?c=new oWe(e,n,r,i,u,m||null,p,d,s,o+1):n.isText?new iA(e,n,r,i,u,p,s):new hp(e,n,r,i,u,m||null,p,s,o+1)}parseRule(){if(this.node.type.spec.reparseInView)return null;let e={node:this.node.type.name,attrs:this.node.attrs};if(this.node.type.whitespace==\"pre\"&&(e.preserveWhitespace=\"full\"),!this.contentDOM)e.getContent=()=>this.node.content;else if(!this.contentLost)e.contentElement=this.contentDOM;else{for(let n=this.children.length-1;n>=0;n--){let r=this.children[n];if(this.dom.contains(r.dom.parentNode)){e.contentElement=r.dom.parentNode;break}}e.contentElement||(e.getContent=()=>Vt.empty)}return e}matchesNode(e,n,r){return this.dirty==oc&&e.eq(this.node)&&GC(n,this.outerDeco)&&r.eq(this.innerDeco)}get size(){return this.node.nodeSize}get border(){return this.node.isLeaf?0:1}updateChildren(e,n){let r=this.node.inlineContent,i=n,s=e.composing?this.localCompositionInfo(e,n):null,o=s&&s.pos>-1?s:null,l=s&&s.pos<0,c=new cWe(this,o&&o.node,e);mWe(this.node,this.innerDeco,(d,u,m)=>{d.spec.marks?c.syncToMarks(d.spec.marks,r,e,u):d.type.side>=0&&!m&&c.syncToMarks(u==this.node.childCount?sa.none:this.node.child(u).marks,r,e,u),c.placeWidget(d,e,i)},(d,u,m,p)=>{c.syncToMarks(d.marks,r,e,p);let f;c.findNodeMatch(d,u,m,p)||l&&e.state.selection.from>i&&e.state.selection.to<i+d.nodeSize&&(f=c.findIndexWithChild(s.node))>-1&&c.updateNodeAt(d,u,m,f,e)||c.updateNextNode(d,u,m,e,p,i)||c.addNode(d,u,m,e,i),i+=d.nodeSize}),c.syncToMarks([],r,e,0),this.node.isTextblock&&c.addTextblockHacks(),c.destroyRest(),(c.changed||this.dirty==$f)&&(o&&this.protectLocalComposition(e,o),bse(this.contentDOM,this.children,e),uy&&hWe(this.dom))}localCompositionInfo(e,n){let{from:r,to:i}=e.state.selection;if(!(e.state.selection instanceof $n)||r<n||i>n+this.node.content.size)return null;let s=e.input.compositionNode;if(!s||!this.dom.contains(s.parentNode))return null;if(this.node.inlineContent){let o=s.nodeValue,l=pWe(this.node.content,o,r-n,i-n);return l<0?null:{node:s,pos:l,text:o}}else return{node:s,pos:-1,text:\"\"}}protectLocalComposition(e,{node:n,pos:r,text:i}){if(this.getDesc(n))return;let s=n;for(;s.parentNode!=this.contentDOM;s=s.parentNode){for(;s.previousSibling;)s.parentNode.removeChild(s.previousSibling);for(;s.nextSibling;)s.parentNode.removeChild(s.nextSibling);s.pmViewDesc&&(s.pmViewDesc=void 0)}let o=new sWe(this,s,n,i);e.input.compositionNodes.push(o),this.children=RL(this.children,r,r+i.length,e,o)}update(e,n,r,i){return this.dirty==Kd||!e.sameMarkup(this.node)?!1:(this.updateInner(e,n,r,i),!0)}updateInner(e,n,r,i){this.updateOuterDeco(n),this.node=e,this.innerDeco=r,this.contentDOM&&this.updateChildren(i,this.posAtStart),this.dirty=oc}updateOuterDeco(e){if(GC(e,this.outerDeco))return;let n=this.nodeDOM.nodeType!=1,r=this.dom;this.dom=xse(this.dom,this.nodeDOM,FL(this.outerDeco,this.node,n),FL(e,this.node,n)),this.dom!=r&&(r.pmViewDesc=void 0,this.dom.pmViewDesc=this),this.outerDeco=e}selectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.add(\"ProseMirror-selectednode\"),(this.contentDOM||!this.node.type.spec.draggable)&&(this.nodeDOM.draggable=!0))}deselectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.remove(\"ProseMirror-selectednode\"),(this.contentDOM||!this.node.type.spec.draggable)&&this.nodeDOM.removeAttribute(\"draggable\"))}get domAtom(){return this.node.isAtom}}function QK(t,e,n,r,i){yse(r,e,t);let s=new hp(void 0,t,e,n,r,r,r,i,0);return s.contentDOM&&s.updateChildren(i,0),s}class iA extends hp{constructor(e,n,r,i,s,o,l){super(e,n,r,i,s,null,o,l,0)}parseRule(){let e=this.nodeDOM.parentNode;for(;e&&e!=this.dom&&!e.pmIsDeco;)e=e.parentNode;return{skip:e||!0}}update(e,n,r,i){return this.dirty==Kd||this.dirty!=oc&&!this.inParent()||!e.sameMarkup(this.node)?!1:(this.updateOuterDeco(n),(this.dirty!=oc||e.text!=this.node.text)&&e.text!=this.nodeDOM.nodeValue&&(this.nodeDOM.nodeValue=e.text,i.trackWrites==this.nodeDOM&&(i.trackWrites=null)),this.node=e,this.dirty=oc,!0)}inParent(){let e=this.parent.contentDOM;for(let n=this.nodeDOM;n;n=n.parentNode)if(n==e)return!0;return!1}domFromPos(e){return{node:this.nodeDOM,offset:e}}localPosFromDOM(e,n,r){return e==this.nodeDOM?this.posAtStart+Math.min(n,this.node.text.length):super.localPosFromDOM(e,n,r)}ignoreMutation(e){return e.type!=\"characterData\"&&e.type!=\"selection\"}slice(e,n,r){let i=this.node.cut(e,n),s=document.createTextNode(i.text);return new iA(this.parent,i,this.outerDeco,this.innerDeco,s,s,r)}markDirty(e,n){super.markDirty(e,n),this.dom!=this.nodeDOM&&(e==0||n==this.nodeDOM.nodeValue.length)&&(this.dirty=Kd)}get domAtom(){return!1}isText(e){return this.node.text==e}}class gse extends zS{parseRule(){return{ignore:!0}}matchesHack(e){return this.dirty==oc&&this.dom.nodeName==e}get domAtom(){return!0}get ignoreForCoords(){return this.dom.nodeName==\"IMG\"}}class oWe extends hp{constructor(e,n,r,i,s,o,l,c,d,u){super(e,n,r,i,s,o,l,d,u),this.spec=c}update(e,n,r,i){if(this.dirty==Kd)return!1;if(this.spec.update&&(this.node.type==e.type||this.spec.multiType)){let s=this.spec.update(e,n,r);return s&&this.updateInner(e,n,r,i),s}else return!this.contentDOM&&!e.isLeaf?!1:super.update(e,n,r,i)}selectNode(){this.spec.selectNode?this.spec.selectNode():super.selectNode()}deselectNode(){this.spec.deselectNode?this.spec.deselectNode():super.deselectNode()}setSelection(e,n,r,i){this.spec.setSelection?this.spec.setSelection(e,n,r.root):super.setSelection(e,n,r,i)}destroy(){this.spec.destroy&&this.spec.destroy(),super.destroy()}stopEvent(e){return this.spec.stopEvent?this.spec.stopEvent(e):!1}ignoreMutation(e){return this.spec.ignoreMutation?this.spec.ignoreMutation(e):super.ignoreMutation(e)}}function bse(t,e,n){let r=t.firstChild,i=!1;for(let s=0;s<e.length;s++){let o=e[s],l=o.dom;if(l.parentNode==t){for(;l!=r;)r=ZK(r),i=!0;r=r.nextSibling}else i=!0,t.insertBefore(l,r);if(o instanceof Dg){let c=r?r.previousSibling:t.lastChild;bse(o.contentDOM,o.children,n),r=c?c.nextSibling:t.firstChild}}for(;r;)r=ZK(r),i=!0;i&&n.trackWrites==t&&(n.trackWrites=null)}const B0=function(t){t&&(this.nodeName=t)};B0.prototype=Object.create(null);const Vf=[new B0];function FL(t,e,n){if(t.length==0)return Vf;let r=n?Vf[0]:new B0,i=[r];for(let s=0;s<t.length;s++){let o=t[s].type.attrs;if(o){o.nodeName&&i.push(r=new B0(o.nodeName));for(let l in o){let c=o[l];c!=null&&(n&&i.length==1&&i.push(r=new B0(e.isInline?\"span\":\"div\")),l==\"class\"?r.class=(r.class?r.class+\" \":\"\")+c:l==\"style\"?r.style=(r.style?r.style+\";\":\"\")+c:l!=\"nodeName\"&&(r[l]=c))}}}return i}function xse(t,e,n,r){if(n==Vf&&r==Vf)return e;let i=e;for(let s=0;s<r.length;s++){let o=r[s],l=n[s];if(s){let c;l&&l.nodeName==o.nodeName&&i!=t&&(c=i.parentNode)&&c.nodeName.toLowerCase()==o.nodeName||(c=document.createElement(o.nodeName),c.pmIsDeco=!0,c.appendChild(i),l=Vf[0]),i=c}lWe(i,l||Vf[0],o)}return i}function lWe(t,e,n){for(let r in e)r!=\"class\"&&r!=\"style\"&&r!=\"nodeName\"&&!(r in n)&&t.removeAttribute(r);for(let r in n)r!=\"class\"&&r!=\"style\"&&r!=\"nodeName\"&&n[r]!=e[r]&&t.setAttribute(r,n[r]);if(e.class!=n.class){let r=e.class?e.class.split(\" \").filter(Boolean):[],i=n.class?n.class.split(\" \").filter(Boolean):[];for(let s=0;s<r.length;s++)i.indexOf(r[s])==-1&&t.classList.remove(r[s]);for(let s=0;s<i.length;s++)r.indexOf(i[s])==-1&&t.classList.add(i[s]);t.classList.length==0&&t.removeAttribute(\"class\")}if(e.style!=n.style){if(e.style){let r=/\\s*([\\w\\-\\xa1-\\uffff]+)\\s*:(?:\"(?:\\\\.|[^\"])*\"|'(?:\\\\.|[^'])*'|\\(.*?\\)|[^;])*/g,i;for(;i=r.exec(e.style);)t.style.removeProperty(i[1])}n.style&&(t.style.cssText+=n.style)}}function yse(t,e,n){return xse(t,t,Vf,FL(e,n,t.nodeType!=1))}function GC(t,e){if(t.length!=e.length)return!1;for(let n=0;n<t.length;n++)if(!t[n].type.eq(e[n].type))return!1;return!0}function ZK(t){let e=t.nextSibling;return t.parentNode.removeChild(t),e}class cWe{constructor(e,n,r){this.lock=n,this.view=r,this.index=0,this.stack=[],this.changed=!1,this.top=e,this.preMatch=dWe(e.node.content,e)}destroyBetween(e,n){if(e!=n){for(let r=e;r<n;r++)this.top.children[r].destroy();this.top.children.splice(e,n-e),this.changed=!0}}destroyRest(){this.destroyBetween(this.index,this.top.children.length)}syncToMarks(e,n,r,i){let s=0,o=this.stack.length>>1,l=Math.min(o,e.length);for(;s<l&&(s==o-1?this.top:this.stack[s+1<<1]).matchesMark(e[s])&&e[s].type.spec.spanning!==!1;)s++;for(;s<o;)this.destroyRest(),this.top.dirty=oc,this.index=this.stack.pop(),this.top=this.stack.pop(),o--;for(;o<e.length;){this.stack.push(this.top,this.index+1);let c=-1,d=this.top.children.length;i<this.preMatch.index&&(d=Math.min(this.index+3,d));for(let u=this.index;u<d;u++){let m=this.top.children[u];if(m.matchesMark(e[o])&&!this.isLocked(m.dom)){c=u;break}}if(c>-1)c>this.index&&(this.changed=!0,this.destroyBetween(this.index,c)),this.top=this.top.children[this.index];else{let u=Dg.create(this.top,e[o],n,r);this.top.children.splice(this.index,0,u),this.top=u,this.changed=!0}this.index=0,o++}}findNodeMatch(e,n,r,i){let s=-1,o;if(i>=this.preMatch.index&&(o=this.preMatch.matches[i-this.preMatch.index]).parent==this.top&&o.matchesNode(e,n,r))s=this.top.children.indexOf(o,this.index);else for(let l=this.index,c=Math.min(this.top.children.length,l+5);l<c;l++){let d=this.top.children[l];if(d.matchesNode(e,n,r)&&!this.preMatch.matched.has(d)){s=l;break}}return s<0?!1:(this.destroyBetween(this.index,s),this.index++,!0)}updateNodeAt(e,n,r,i,s){let o=this.top.children[i];return o.dirty==Kd&&o.dom==o.contentDOM&&(o.dirty=$f),o.update(e,n,r,s)?(this.destroyBetween(this.index,i),this.index++,!0):!1}findIndexWithChild(e){for(;;){let n=e.parentNode;if(!n)return-1;if(n==this.top.contentDOM){let r=e.pmViewDesc;if(r){for(let i=this.index;i<this.top.children.length;i++)if(this.top.children[i]==r)return i}return-1}e=n}}updateNextNode(e,n,r,i,s,o){for(let l=this.index;l<this.top.children.length;l++){let c=this.top.children[l];if(c instanceof hp){let d=this.preMatch.matched.get(c);if(d!=null&&d!=s)return!1;let u=c.dom,m,p=this.isLocked(u)&&!(e.isText&&c.node&&c.node.isText&&c.nodeDOM.nodeValue==e.text&&c.dirty!=Kd&&GC(n,c.outerDeco));if(!p&&c.update(e,n,r,i))return this.destroyBetween(this.index,l),c.dom!=u&&(this.changed=!0),this.index++,!0;if(!p&&(m=this.recreateWrapper(c,e,n,r,i,o)))return this.destroyBetween(this.index,l),this.top.children[this.index]=m,m.contentDOM&&(m.dirty=$f,m.updateChildren(i,o+1),m.dirty=oc),this.changed=!0,this.index++,!0;break}}return!1}recreateWrapper(e,n,r,i,s,o){if(e.dirty||n.isAtom||!e.children.length||!e.node.content.eq(n.content)||!GC(r,e.outerDeco)||!i.eq(e.innerDeco))return null;let l=hp.create(this.top,n,r,i,s,o);if(l.contentDOM){l.children=e.children,e.children=[];for(let c of l.children)c.parent=l}return e.destroy(),l}addNode(e,n,r,i,s){let o=hp.create(this.top,e,n,r,i,s);o.contentDOM&&o.updateChildren(i,s+1),this.top.children.splice(this.index++,0,o),this.changed=!0}placeWidget(e,n,r){let i=this.index<this.top.children.length?this.top.children[this.index]:null;if(i&&i.matchesWidget(e)&&(e==i.widget||!i.widget.type.toDOM.parentNode))this.index++;else{let s=new fse(this.top,e,n,r);this.top.children.splice(this.index++,0,s),this.changed=!0}}addTextblockHacks(){let e=this.top.children[this.index-1],n=this.top;for(;e instanceof Dg;)n=e,e=n.children[n.children.length-1];(!e||!(e instanceof iA)||/\\n$/.test(e.node.text)||this.view.requiresGeckoHackNode&&/\\s$/.test(e.node.text))&&(($s||ks)&&e&&e.dom.contentEditable==\"false\"&&this.addHackNode(\"IMG\",n),this.addHackNode(\"BR\",this.top))}addHackNode(e,n){if(n==this.top&&this.index<n.children.length&&n.children[this.index].matchesHack(e))this.index++;else{let r=document.createElement(e);e==\"IMG\"&&(r.className=\"ProseMirror-separator\",r.alt=\"\"),e==\"BR\"&&(r.className=\"ProseMirror-trailingBreak\");let i=new gse(this.top,[],r,null);n!=this.top?n.children.push(i):n.children.splice(this.index++,0,i),this.changed=!0}}isLocked(e){return this.lock&&(e==this.lock||e.nodeType==1&&e.contains(this.lock.parentNode))}}function dWe(t,e){let n=e,r=n.children.length,i=t.childCount,s=new Map,o=[];e:for(;i>0;){let l;for(;;)if(r){let d=n.children[r-1];if(d instanceof Dg)n=d,r=d.children.length;else{l=d,r--;break}}else{if(n==e)break e;r=n.parent.children.indexOf(n),n=n.parent}let c=l.node;if(c){if(c!=t.child(i-1))break;--i,s.set(l,i),o.push(l)}}return{index:i,matched:s,matches:o.reverse()}}function uWe(t,e){return t.type.side-e.type.side}function mWe(t,e,n,r){let i=e.locals(t),s=0;if(i.length==0){for(let d=0;d<t.childCount;d++){let u=t.child(d);r(u,i,e.forChild(s,u),d),s+=u.nodeSize}return}let o=0,l=[],c=null;for(let d=0;;){let u,m;for(;o<i.length&&i[o].to==s;){let b=i[o++];b.widget&&(u?(m||(m=[u])).push(b):u=b)}if(u)if(m){m.sort(uWe);for(let b=0;b<m.length;b++)n(m[b],d,!!c)}else n(u,d,!!c);let p,f;if(c)f=-1,p=c,c=null;else if(d<t.childCount)f=d,p=t.child(d++);else break;for(let b=0;b<l.length;b++)l[b].to<=s&&l.splice(b--,1);for(;o<i.length&&i[o].from<=s&&i[o].to>s;)l.push(i[o++]);let y=s+p.nodeSize;if(p.isText){let b=y;o<i.length&&i[o].from<b&&(b=i[o].from);for(let g=0;g<l.length;g++)l[g].to<b&&(b=l[g].to);b<y&&(c=p.cut(b-s),p=p.cut(0,b-s),y=b,f=-1)}else for(;o<i.length&&i[o].to<y;)o++;let v=p.isInline&&!p.isLeaf?l.filter(b=>!b.inline):l.slice();r(p,v,e.forChild(s,p),f),s=y}}function hWe(t){if(t.nodeName==\"UL\"||t.nodeName==\"OL\"){let e=t.style.cssText;t.style.cssText=e+\"; list-style: square !important\",window.getComputedStyle(t).listStyle,t.style.cssText=e}}function pWe(t,e,n,r){for(let i=0,s=0;i<t.childCount&&s<=r;){let o=t.child(i++),l=s;if(s+=o.nodeSize,!o.isText)continue;let c=o.text;for(;i<t.childCount;){let d=t.child(i++);if(s+=d.nodeSize,!d.isText)break;c+=d.text}if(s>=n){if(s>=r&&c.slice(r-e.length-l,r-l)==e)return r-e.length;let d=l<r?c.lastIndexOf(e,r-l-1):-1;if(d>=0&&d+e.length+l>=n)return l+d;if(n==r&&c.length>=r+e.length-l&&c.slice(r-l,r-l+e.length)==e)return r}}return-1}function RL(t,e,n,r,i){let s=[];for(let o=0,l=0;o<t.length;o++){let c=t[o],d=l,u=l+=c.size;d>=n||u<=e?s.push(c):(d<e&&s.push(c.slice(0,e-d,r)),i&&(s.push(i),i=void 0),u>n&&s.push(c.slice(n-d,c.size,r)))}return s}function Fz(t,e=null){let n=t.domSelectionRange(),r=t.state.doc;if(!n.focusNode)return null;let i=t.docView.nearestDesc(n.focusNode),s=i&&i.size==0,o=t.docView.posFromDOM(n.focusNode,n.focusOffset,1);if(o<0)return null;let l=r.resolve(o),c,d;if(aA(n)){for(c=o;i&&!i.node;)i=i.parent;let m=i.node;if(i&&m.isAtom&&An.isSelectable(m)&&i.parent&&!(m.isInline&&B9e(n.focusNode,n.focusOffset,i.dom))){let p=i.posBefore;d=new An(o==p?l:r.resolve(p))}}else{if(n instanceof t.dom.ownerDocument.defaultView.Selection&&n.rangeCount>1){let m=o,p=o;for(let f=0;f<n.rangeCount;f++){let y=n.getRangeAt(f);m=Math.min(m,t.docView.posFromDOM(y.startContainer,y.startOffset,1)),p=Math.max(p,t.docView.posFromDOM(y.endContainer,y.endOffset,-1))}if(m<0)return null;[c,o]=p==t.state.selection.anchor?[p,m]:[m,p],l=r.resolve(o)}else c=t.docView.posFromDOM(n.anchorNode,n.anchorOffset,1);if(c<0)return null}let u=r.resolve(c);if(!d){let m=e==\"pointer\"||t.state.selection.head<l.pos&&!s?1:-1;d=Rz(t,u,l,m)}return d}function vse(t){return t.editable?t.hasFocus():Sse(t)&&document.activeElement&&document.activeElement.contains(t.dom)}function pm(t,e=!1){let n=t.state.selection;if(wse(t,n),!!vse(t)){if(!e&&t.input.mouseDown&&t.input.mouseDown.allowDefault&&ks){let r=t.domSelectionRange(),i=t.domObserver.currentSelection;if(r.anchorNode&&i.anchorNode&&Eg(r.anchorNode,r.anchorOffset,i.anchorNode,i.anchorOffset)){t.input.mouseDown.delayedSelectionSync=!0,t.domObserver.setCurSelection();return}}if(t.domObserver.disconnectSelection(),t.cursorWrapper)gWe(t);else{let{anchor:r,head:i}=n,s,o;JK&&!(n instanceof $n)&&(n.$from.parent.inlineContent||(s=eX(t,n.from)),!n.empty&&!n.$from.parent.inlineContent&&(o=eX(t,n.to))),t.docView.setSelection(r,i,t,e),JK&&(s&&tX(s),o&&tX(o)),n.visible?t.dom.classList.remove(\"ProseMirror-hideselection\"):(t.dom.classList.add(\"ProseMirror-hideselection\"),\"onselectionchange\"in document&&fWe(t))}t.domObserver.setCurSelection(),t.domObserver.connectSelection()}}const JK=$s||ks&&ose<63;function eX(t,e){let{node:n,offset:r}=t.docView.domFromPos(e,0),i=r<n.childNodes.length?n.childNodes[r]:null,s=r?n.childNodes[r-1]:null;if($s&&i&&i.contentEditable==\"false\")return t3(i);if((!i||i.contentEditable==\"false\")&&(!s||s.contentEditable==\"false\")){if(i)return t3(i);if(s)return t3(s)}}function t3(t){return t.contentEditable=\"true\",$s&&t.draggable&&(t.draggable=!1,t.wasDraggable=!0),t}function tX(t){t.contentEditable=\"false\",t.wasDraggable&&(t.draggable=!0,t.wasDraggable=null)}function fWe(t){let e=t.dom.ownerDocument;e.removeEventListener(\"selectionchange\",t.input.hideSelectionGuard);let n=t.domSelectionRange(),r=n.anchorNode,i=n.anchorOffset;e.addEventListener(\"selectionchange\",t.input.hideSelectionGuard=()=>{(n.anchorNode!=r||n.anchorOffset!=i)&&(e.removeEventListener(\"selectionchange\",t.input.hideSelectionGuard),setTimeout(()=>{(!vse(t)||t.state.selection.visible)&&t.dom.classList.remove(\"ProseMirror-hideselection\")},20))})}function gWe(t){let e=t.domSelection();if(!e)return;let n=t.cursorWrapper.dom,r=n.nodeName==\"IMG\";r?e.collapse(n.parentNode,ys(n)+1):e.collapse(n,0),!r&&!t.state.selection.visible&&Ho&&mp<=11&&(n.disabled=!0,n.disabled=!1)}function wse(t,e){if(e instanceof An){let n=t.docView.descAt(e.from);n!=t.lastSelectedViewDesc&&(nX(t),n&&n.selectNode(),t.lastSelectedViewDesc=n)}else nX(t)}function nX(t){t.lastSelectedViewDesc&&(t.lastSelectedViewDesc.parent&&t.lastSelectedViewDesc.deselectNode(),t.lastSelectedViewDesc=void 0)}function Rz(t,e,n,r){return t.someProp(\"createSelectionBetween\",i=>i(t,e,n))||$n.between(e,n,r)}function rX(t){return t.editable&&!t.hasFocus()?!1:Sse(t)}function Sse(t){let e=t.domSelectionRange();if(!e.anchorNode)return!1;try{return t.dom.contains(e.anchorNode.nodeType==3?e.anchorNode.parentNode:e.anchorNode)&&(t.editable||t.dom.contains(e.focusNode.nodeType==3?e.focusNode.parentNode:e.focusNode))}catch{return!1}}function bWe(t){let e=t.docView.domFromPos(t.state.selection.anchor,0),n=t.domSelectionRange();return Eg(e.node,e.offset,n.anchorNode,n.anchorOffset)}function LL(t,e){let{$anchor:n,$head:r}=t.selection,i=e>0?n.max(r):n.min(r),s=i.parent.inlineContent?i.depth?t.doc.resolve(e>0?i.after():i.before()):null:i;return s&&rr.findFrom(s,e)}function Mh(t,e){return t.dispatch(t.state.tr.setSelection(e).scrollIntoView()),!0}function aX(t,e,n){let r=t.state.selection;if(r instanceof $n)if(n.indexOf(\"s\")>-1){let{$head:i}=r,s=i.textOffset?null:e<0?i.nodeBefore:i.nodeAfter;if(!s||s.isText||!s.isLeaf)return!1;let o=t.state.doc.resolve(i.pos+s.nodeSize*(e<0?-1:1));return Mh(t,new $n(r.$anchor,o))}else if(r.empty){if(t.endOfTextblock(e>0?\"forward\":\"backward\")){let i=LL(t.state,e);return i&&i instanceof An?Mh(t,i):!1}else if(!(Gl&&n.indexOf(\"m\")>-1)){let i=r.$head,s=i.textOffset?null:e<0?i.nodeBefore:i.nodeAfter,o;if(!s||s.isText)return!1;let l=e<0?i.pos-s.nodeSize:i.pos;return s.isAtom||(o=t.docView.descAt(l))&&!o.contentDOM?An.isSelectable(s)?Mh(t,new An(e<0?t.state.doc.resolve(i.pos-s.nodeSize):i)):IS?Mh(t,new $n(t.state.doc.resolve(e<0?l:l+s.nodeSize))):!1:!1}}else return!1;else{if(r instanceof An&&r.node.isInline)return Mh(t,new $n(e>0?r.$to:r.$from));{let i=LL(t.state,e);return i?Mh(t,i):!1}}}function WC(t){return t.nodeType==3?t.nodeValue.length:t.childNodes.length}function H0(t,e){let n=t.pmViewDesc;return n&&n.size==0&&(e<0||t.nextSibling||t.nodeName!=\"BR\")}function lx(t,e){return e<0?xWe(t):yWe(t)}function xWe(t){let e=t.domSelectionRange(),n=e.focusNode,r=e.focusOffset;if(!n)return;let i,s,o=!1;for(sc&&n.nodeType==1&&r<WC(n)&&H0(n.childNodes[r],-1)&&(o=!0);;)if(r>0){if(n.nodeType!=1)break;{let l=n.childNodes[r-1];if(H0(l,-1))i=n,s=--r;else if(l.nodeType==3)n=l,r=n.nodeValue.length;else break}}else{if(_se(n))break;{let l=n.previousSibling;for(;l&&H0(l,-1);)i=n.parentNode,s=ys(l),l=l.previousSibling;if(l)n=l,r=WC(n);else{if(n=n.parentNode,n==t.dom)break;r=0}}}o?OL(t,n,r):i&&OL(t,i,s)}function yWe(t){let e=t.domSelectionRange(),n=e.focusNode,r=e.focusOffset;if(!n)return;let i=WC(n),s,o;for(;;)if(r<i){if(n.nodeType!=1)break;let l=n.childNodes[r];if(H0(l,1))s=n,o=++r;else break}else{if(_se(n))break;{let l=n.nextSibling;for(;l&&H0(l,1);)s=l.parentNode,o=ys(l)+1,l=l.nextSibling;if(l)n=l,r=0,i=WC(n);else{if(n=n.parentNode,n==t.dom)break;r=i=0}}}s&&OL(t,s,o)}function _se(t){let e=t.pmViewDesc;return e&&e.node&&e.node.isBlock}function vWe(t,e){for(;t&&e==t.childNodes.length&&!OS(t);)e=ys(t)+1,t=t.parentNode;for(;t&&e<t.childNodes.length;){let n=t.childNodes[e];if(n.nodeType==3)return n;if(n.nodeType==1&&n.contentEditable==\"false\")break;t=n,e=0}}function wWe(t,e){for(;t&&!e&&!OS(t);)e=ys(t),t=t.parentNode;for(;t&&e;){let n=t.childNodes[e-1];if(n.nodeType==3)return n;if(n.nodeType==1&&n.contentEditable==\"false\")break;t=n,e=t.childNodes.length}}function OL(t,e,n){if(e.nodeType!=3){let s,o;(o=vWe(e,n))?(e=o,n=0):(s=wWe(e,n))&&(e=s,n=s.nodeValue.length)}let r=t.domSelection();if(!r)return;if(aA(r)){let s=document.createRange();s.setEnd(e,n),s.setStart(e,n),r.removeAllRanges(),r.addRange(s)}else r.extend&&r.extend(e,n);t.domObserver.setCurSelection();let{state:i}=t;setTimeout(()=>{t.state==i&&pm(t)},50)}function iX(t,e){let n=t.state.doc.resolve(e);if(!(ks||lse)&&n.parent.inlineContent){let i=t.coordsAtPos(e);if(e>n.start()){let s=t.coordsAtPos(e-1),o=(s.top+s.bottom)/2;if(o>i.top&&o<i.bottom&&Math.abs(s.left-i.left)>1)return s.left<i.left?\"ltr\":\"rtl\"}if(e<n.end()){let s=t.coordsAtPos(e+1),o=(s.top+s.bottom)/2;if(o>i.top&&o<i.bottom&&Math.abs(s.left-i.left)>1)return s.left>i.left?\"ltr\":\"rtl\"}}return getComputedStyle(t.dom).direction==\"rtl\"?\"rtl\":\"ltr\"}function sX(t,e,n){let r=t.state.selection;if(r instanceof $n&&!r.empty||n.indexOf(\"s\")>-1||Gl&&n.indexOf(\"m\")>-1)return!1;let{$from:i,$to:s}=r;if(!i.parent.inlineContent||t.endOfTextblock(e<0?\"up\":\"down\")){let o=LL(t.state,e);if(o&&o instanceof An)return Mh(t,o)}if(!i.parent.inlineContent){let o=e<0?i:s,l=r instanceof bl?rr.near(o,e):rr.findFrom(o,e);return l?Mh(t,l):!1}return!1}function oX(t,e){if(!(t.state.selection instanceof $n))return!0;let{$head:n,$anchor:r,empty:i}=t.state.selection;if(!n.sameParent(r))return!0;if(!i)return!1;if(t.endOfTextblock(e>0?\"forward\":\"backward\"))return!0;let s=!n.textOffset&&(e<0?n.nodeBefore:n.nodeAfter);if(s&&!s.isText){let o=t.state.tr;return e<0?o.delete(n.pos-s.nodeSize,n.pos):o.delete(n.pos,n.pos+s.nodeSize),t.dispatch(o),!0}return!1}function lX(t,e,n){t.domObserver.stop(),e.contentEditable=n,t.domObserver.start()}function SWe(t){if(!$s||t.state.selection.$head.parentOffset>0)return!1;let{focusNode:e,focusOffset:n}=t.domSelectionRange();if(e&&e.nodeType==1&&n==0&&e.firstChild&&e.firstChild.contentEditable==\"false\"){let r=e.firstChild;lX(t,r,\"true\"),setTimeout(()=>lX(t,r,\"false\"),20)}return!1}function _We(t){let e=\"\";return t.ctrlKey&&(e+=\"c\"),t.metaKey&&(e+=\"m\"),t.altKey&&(e+=\"a\"),t.shiftKey&&(e+=\"s\"),e}function kWe(t,e){let n=e.keyCode,r=_We(e);if(n==8||Gl&&n==72&&r==\"c\")return oX(t,-1)||lx(t,-1);if(n==46&&!e.shiftKey||Gl&&n==68&&r==\"c\")return oX(t,1)||lx(t,1);if(n==13||n==27)return!0;if(n==37||Gl&&n==66&&r==\"c\"){let i=n==37?iX(t,t.state.selection.from)==\"ltr\"?-1:1:-1;return aX(t,i,r)||lx(t,i)}else if(n==39||Gl&&n==70&&r==\"c\"){let i=n==39?iX(t,t.state.selection.from)==\"ltr\"?1:-1:1;return aX(t,i,r)||lx(t,i)}else{if(n==38||Gl&&n==80&&r==\"c\")return sX(t,-1,r)||lx(t,-1);if(n==40||Gl&&n==78&&r==\"c\")return SWe(t)||sX(t,1,r)||lx(t,1);if(r==(Gl?\"m\":\"c\")&&(n==66||n==73||n==89||n==90))return!0}return!1}function Lz(t,e){t.someProp(\"transformCopied\",f=>{e=f(e,t)});let n=[],{content:r,openStart:i,openEnd:s}=e;for(;i>1&&s>1&&r.childCount==1&&r.firstChild.childCount==1;){i--,s--;let f=r.firstChild;n.push(f.type.name,f.attrs!=f.type.defaultAttrs?f.attrs:null),r=f.content}let o=t.someProp(\"clipboardSerializer\")||$g.fromSchema(t.state.schema),l=Ase(),c=l.createElement(\"div\");c.appendChild(o.serializeFragment(r,{document:l}));let d=c.firstChild,u,m=0;for(;d&&d.nodeType==1&&(u=Tse[d.nodeName.toLowerCase()]);){for(let f=u.length-1;f>=0;f--){let y=l.createElement(u[f]);for(;c.firstChild;)y.appendChild(c.firstChild);c.appendChild(y),m++}d=c.firstChild}d&&d.nodeType==1&&d.setAttribute(\"data-pm-slice\",`${i} ${s}${m?` -${m}`:\"\"} ${JSON.stringify(n)}`);let p=t.someProp(\"clipboardTextSerializer\",f=>f(e,t))||e.content.textBetween(0,e.content.size,`\n\n`);return{dom:c,text:p,slice:e}}function kse(t,e,n,r,i){let s=i.parent.type.spec.code,o,l;if(!n&&!e)return null;let c=!!e&&(r||s||!n);if(c){if(t.someProp(\"transformPastedText\",p=>{e=p(e,s||r,t)}),s)return l=new an(Vt.from(t.state.schema.text(e.replace(/\\r\\n?/g,`\n`))),0,0),t.someProp(\"transformPasted\",p=>{l=p(l,t,!0)}),l;let m=t.someProp(\"clipboardTextParser\",p=>p(e,i,r,t));if(m)l=m;else{let p=i.marks(),{schema:f}=t.state,y=$g.fromSchema(f);o=document.createElement(\"div\"),e.split(/(?:\\r\\n?|\\n)+/).forEach(v=>{let b=o.appendChild(document.createElement(\"p\"));v&&b.appendChild(y.serializeNode(f.text(v,p)))})}}else t.someProp(\"transformPastedHTML\",m=>{n=m(n,t)}),o=TWe(n),IS&&AWe(o);let d=o&&o.querySelector(\"[data-pm-slice]\"),u=d&&/^(\\d+) (\\d+)(?: -(\\d+))? (.*)/.exec(d.getAttribute(\"data-pm-slice\")||\"\");if(u&&u[3])for(let m=+u[3];m>0;m--){let p=o.firstChild;for(;p&&p.nodeType!=1;)p=p.nextSibling;if(!p)break;o=p}if(l||(l=(t.someProp(\"clipboardParser\")||t.someProp(\"domParser\")||z0.fromSchema(t.state.schema)).parseSlice(o,{preserveWhitespace:!!(c||u),context:i,ruleFromNode(p){return p.nodeName==\"BR\"&&!p.nextSibling&&p.parentNode&&!NWe.test(p.parentNode.nodeName)?{ignore:!0}:null}})),u)l=jWe(cX(l,+u[1],+u[2]),u[4]);else if(l=an.maxOpen(CWe(l.content,i),!0),l.openStart||l.openEnd){let m=0,p=0;for(let f=l.content.firstChild;m<l.openStart&&!f.type.spec.isolating;m++,f=f.firstChild);for(let f=l.content.lastChild;p<l.openEnd&&!f.type.spec.isolating;p++,f=f.lastChild);l=cX(l,m,p)}return t.someProp(\"transformPasted\",m=>{l=m(l,t,c)}),l}const NWe=/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i;function CWe(t,e){if(t.childCount<2)return t;for(let n=e.depth;n>=0;n--){let i=e.node(n).contentMatchAt(e.index(n)),s,o=[];if(t.forEach(l=>{if(!o)return;let c=i.findWrapping(l.type),d;if(!c)return o=null;if(d=o.length&&s.length&&Cse(c,s,l,o[o.length-1],0))o[o.length-1]=d;else{o.length&&(o[o.length-1]=Pse(o[o.length-1],s.length));let u=Nse(l,c);o.push(u),i=i.matchType(u.type),s=c}}),o)return Vt.from(o)}return t}function Nse(t,e,n=0){for(let r=e.length-1;r>=n;r--)t=e[r].create(null,Vt.from(t));return t}function Cse(t,e,n,r,i){if(i<t.length&&i<e.length&&t[i]==e[i]){let s=Cse(t,e,n,r.lastChild,i+1);if(s)return r.copy(r.content.replaceChild(r.childCount-1,s));if(r.contentMatchAt(r.childCount).matchType(i==t.length-1?n.type:t[i+1]))return r.copy(r.content.append(Vt.from(Nse(n,t,i+1))))}}function Pse(t,e){if(e==0)return t;let n=t.content.replaceChild(t.childCount-1,Pse(t.lastChild,e-1)),r=t.contentMatchAt(t.childCount).fillBefore(Vt.empty,!0);return t.copy(n.append(r))}function IL(t,e,n,r,i,s){let o=e<0?t.firstChild:t.lastChild,l=o.content;return t.childCount>1&&(s=0),i<r-1&&(l=IL(l,e,n,r,i+1,s)),i>=n&&(l=e<0?o.contentMatchAt(0).fillBefore(l,s<=i).append(l):l.append(o.contentMatchAt(o.childCount).fillBefore(Vt.empty,!0))),t.replaceChild(e<0?0:t.childCount-1,o.copy(l))}function cX(t,e,n){return e<t.openStart&&(t=new an(IL(t.content,-1,e,t.openStart,0,t.openEnd),e,t.openEnd)),n<t.openEnd&&(t=new an(IL(t.content,1,n,t.openEnd,0,0),t.openStart,n)),t}const Tse={thead:[\"table\"],tbody:[\"table\"],tfoot:[\"table\"],caption:[\"table\"],colgroup:[\"table\"],col:[\"table\",\"colgroup\"],tr:[\"table\",\"tbody\"],td:[\"table\",\"tbody\",\"tr\"],th:[\"table\",\"tbody\",\"tr\"]};let dX=null;function Ase(){return dX||(dX=document.implementation.createHTMLDocument(\"title\"))}let n3=null;function PWe(t){let e=window.trustedTypes;return e?(n3||(n3=e.defaultPolicy||e.createPolicy(\"ProseMirrorClipboard\",{createHTML:n=>n})),n3.createHTML(t)):t}function TWe(t){let e=/^(\\s*<meta [^>]*>)*/.exec(t);e&&(t=t.slice(e[0].length));let n=Ase().createElement(\"div\"),r=/<([a-z][^>\\s]+)/i.exec(t),i;if((i=r&&Tse[r[1].toLowerCase()])&&(t=i.map(s=>\"<\"+s+\">\").join(\"\")+t+i.map(s=>\"</\"+s+\">\").reverse().join(\"\")),n.innerHTML=PWe(t),i)for(let s=0;s<i.length;s++)n=n.querySelector(i[s])||n;return n}function AWe(t){let e=t.querySelectorAll(ks?\"span:not([class]):not([style])\":\"span.Apple-converted-space\");for(let n=0;n<e.length;n++){let r=e[n];r.childNodes.length==1&&r.textContent==\" \"&&r.parentNode&&r.parentNode.replaceChild(t.ownerDocument.createTextNode(\" \"),r)}}function jWe(t,e){if(!t.size)return t;let n=t.content.firstChild.type.schema,r;try{r=JSON.parse(e)}catch{return t}let{content:i,openStart:s,openEnd:o}=t;for(let l=r.length-2;l>=0;l-=2){let c=n.nodes[r[l]];if(!c||c.hasRequiredAttrs())break;i=Vt.from(c.create(r[l+1],i)),s++,o++}return new an(i,s,o)}const go={},bo={},MWe={touchstart:!0,touchmove:!0};class EWe{constructor(){this.shiftKey=!1,this.mouseDown=null,this.lastKeyCode=null,this.lastKeyCodeTime=0,this.lastClick={time:0,x:0,y:0,type:\"\",button:0},this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastIOSEnter=0,this.lastIOSEnterFallbackTimeout=-1,this.lastFocus=0,this.lastTouch=0,this.lastChromeDelete=0,this.composing=!1,this.compositionNode=null,this.composingTimeout=-1,this.compositionNodes=[],this.compositionEndedAt=-2e8,this.compositionID=1,this.badSafariComposition=!1,this.compositionPendingChanges=0,this.domChangeCount=0,this.eventHandlers=Object.create(null),this.hideSelectionGuard=null}}function DWe(t){for(let e in go){let n=go[e];t.dom.addEventListener(e,t.input.eventHandlers[e]=r=>{RWe(t,r)&&!Oz(t,r)&&(t.editable||!(r.type in bo))&&n(t,r)},MWe[e]?{passive:!0}:void 0)}$s&&t.dom.addEventListener(\"input\",()=>null),zL(t)}function Yh(t,e){t.input.lastSelectionOrigin=e,t.input.lastSelectionTime=Date.now()}function FWe(t){t.domObserver.stop();for(let e in t.input.eventHandlers)t.dom.removeEventListener(e,t.input.eventHandlers[e]);clearTimeout(t.input.composingTimeout),clearTimeout(t.input.lastIOSEnterFallbackTimeout)}function zL(t){t.someProp(\"handleDOMEvents\",e=>{for(let n in e)t.input.eventHandlers[n]||t.dom.addEventListener(n,t.input.eventHandlers[n]=r=>Oz(t,r))})}function Oz(t,e){return t.someProp(\"handleDOMEvents\",n=>{let r=n[e.type];return r?r(t,e)||e.defaultPrevented:!1})}function RWe(t,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let n=e.target;n!=t.dom;n=n.parentNode)if(!n||n.nodeType==11||n.pmViewDesc&&n.pmViewDesc.stopEvent(e))return!1;return!0}function LWe(t,e){!Oz(t,e)&&go[e.type]&&(t.editable||!(e.type in bo))&&go[e.type](t,e)}bo.keydown=(t,e)=>{let n=e;if(t.input.shiftKey=n.keyCode==16||n.shiftKey,!Mse(t,n)&&(t.input.lastKeyCode=n.keyCode,t.input.lastKeyCodeTime=Date.now(),!(am&&ks&&n.keyCode==13)))if(n.keyCode!=229&&t.domObserver.forceFlush(),uy&&n.keyCode==13&&!n.ctrlKey&&!n.altKey&&!n.metaKey){let r=Date.now();t.input.lastIOSEnter=r,t.input.lastIOSEnterFallbackTimeout=setTimeout(()=>{t.input.lastIOSEnter==r&&(t.someProp(\"handleKeyDown\",i=>i(t,Df(13,\"Enter\"))),t.input.lastIOSEnter=0)},200)}else t.someProp(\"handleKeyDown\",r=>r(t,n))||kWe(t,n)?n.preventDefault():Yh(t,\"key\")};bo.keyup=(t,e)=>{e.keyCode==16&&(t.input.shiftKey=!1)};bo.keypress=(t,e)=>{let n=e;if(Mse(t,n)||!n.charCode||n.ctrlKey&&!n.altKey||Gl&&n.metaKey)return;if(t.someProp(\"handleKeyPress\",i=>i(t,n))){n.preventDefault();return}let r=t.state.selection;if(!(r instanceof $n)||!r.$from.sameParent(r.$to)){let i=String.fromCharCode(n.charCode),s=()=>t.state.tr.insertText(i).scrollIntoView();!/[\\r\\n]/.test(i)&&!t.someProp(\"handleTextInput\",o=>o(t,r.$from.pos,r.$to.pos,i,s))&&t.dispatch(s()),n.preventDefault()}};function sA(t){return{left:t.clientX,top:t.clientY}}function OWe(t,e){let n=e.x-t.clientX,r=e.y-t.clientY;return n*n+r*r<100}function Iz(t,e,n,r,i){if(r==-1)return!1;let s=t.state.doc.resolve(r);for(let o=s.depth+1;o>0;o--)if(t.someProp(e,l=>o>s.depth?l(t,n,s.nodeAfter,s.before(o),i,!0):l(t,n,s.node(o),s.before(o),i,!1)))return!0;return!1}function zx(t,e,n){if(t.focused||t.focus(),t.state.selection.eq(e))return;let r=t.state.tr.setSelection(e);r.setMeta(\"pointer\",!0),t.dispatch(r)}function IWe(t,e){if(e==-1)return!1;let n=t.state.doc.resolve(e),r=n.nodeAfter;return r&&r.isAtom&&An.isSelectable(r)?(zx(t,new An(n)),!0):!1}function zWe(t,e){if(e==-1)return!1;let n=t.state.selection,r,i;n instanceof An&&(r=n.node);let s=t.state.doc.resolve(e);for(let o=s.depth+1;o>0;o--){let l=o>s.depth?s.nodeAfter:s.node(o);if(An.isSelectable(l)){r&&n.$from.depth>0&&o>=n.$from.depth&&s.before(n.$from.depth+1)==n.$from.pos?i=s.before(n.$from.depth):i=s.before(o);break}}return i!=null?(zx(t,An.create(t.state.doc,i)),!0):!1}function UWe(t,e,n,r,i){return Iz(t,\"handleClickOn\",e,n,r)||t.someProp(\"handleClick\",s=>s(t,e,r))||(i?zWe(t,n):IWe(t,n))}function BWe(t,e,n,r){return Iz(t,\"handleDoubleClickOn\",e,n,r)||t.someProp(\"handleDoubleClick\",i=>i(t,e,r))}function HWe(t,e,n,r){return Iz(t,\"handleTripleClickOn\",e,n,r)||t.someProp(\"handleTripleClick\",i=>i(t,e,r))||qWe(t,n,r)}function qWe(t,e,n){if(n.button!=0)return!1;let r=t.state.doc;if(e==-1)return r.inlineContent?(zx(t,$n.create(r,0,r.content.size)),!0):!1;let i=r.resolve(e);for(let s=i.depth+1;s>0;s--){let o=s>i.depth?i.nodeAfter:i.node(s),l=i.before(s);if(o.inlineContent)zx(t,$n.create(r,l+1,l+1+o.content.size));else if(An.isSelectable(o))zx(t,An.create(r,l));else continue;return!0}}function zz(t){return KC(t)}const jse=Gl?\"metaKey\":\"ctrlKey\";go.mousedown=(t,e)=>{let n=e;t.input.shiftKey=n.shiftKey;let r=zz(t),i=Date.now(),s=\"singleClick\";i-t.input.lastClick.time<500&&OWe(n,t.input.lastClick)&&!n[jse]&&t.input.lastClick.button==n.button&&(t.input.lastClick.type==\"singleClick\"?s=\"doubleClick\":t.input.lastClick.type==\"doubleClick\"&&(s=\"tripleClick\")),t.input.lastClick={time:i,x:n.clientX,y:n.clientY,type:s,button:n.button};let o=t.posAtCoords(sA(n));o&&(s==\"singleClick\"?(t.input.mouseDown&&t.input.mouseDown.done(),t.input.mouseDown=new $We(t,o,n,!!r)):(s==\"doubleClick\"?BWe:HWe)(t,o.pos,o.inside,n)?n.preventDefault():Yh(t,\"pointer\"))};class $We{constructor(e,n,r,i){this.view=e,this.pos=n,this.event=r,this.flushed=i,this.delayedSelectionSync=!1,this.mightDrag=null,this.startDoc=e.state.doc,this.selectNode=!!r[jse],this.allowDefault=r.shiftKey;let s,o;if(n.inside>-1)s=e.state.doc.nodeAt(n.inside),o=n.inside;else{let u=e.state.doc.resolve(n.pos);s=u.parent,o=u.depth?u.before():0}const l=i?null:r.target,c=l?e.docView.nearestDesc(l,!0):null;this.target=c&&c.nodeDOM.nodeType==1?c.nodeDOM:null;let{selection:d}=e.state;(r.button==0&&s.type.spec.draggable&&s.type.spec.selectable!==!1||d instanceof An&&d.from<=o&&d.to>o)&&(this.mightDrag={node:s,pos:o,addAttr:!!(this.target&&!this.target.draggable),setUneditable:!!(this.target&&sc&&!this.target.hasAttribute(\"contentEditable\"))}),this.target&&this.mightDrag&&(this.mightDrag.addAttr||this.mightDrag.setUneditable)&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&(this.target.draggable=!0),this.mightDrag.setUneditable&&setTimeout(()=>{this.view.input.mouseDown==this&&this.target.setAttribute(\"contentEditable\",\"false\")},20),this.view.domObserver.start()),e.root.addEventListener(\"mouseup\",this.up=this.up.bind(this)),e.root.addEventListener(\"mousemove\",this.move=this.move.bind(this)),Yh(e,\"pointer\")}done(){this.view.root.removeEventListener(\"mouseup\",this.up),this.view.root.removeEventListener(\"mousemove\",this.move),this.mightDrag&&this.target&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&this.target.removeAttribute(\"draggable\"),this.mightDrag.setUneditable&&this.target.removeAttribute(\"contentEditable\"),this.view.domObserver.start()),this.delayedSelectionSync&&setTimeout(()=>pm(this.view)),this.view.input.mouseDown=null}up(e){if(this.done(),!this.view.dom.contains(e.target))return;let n=this.pos;this.view.state.doc!=this.startDoc&&(n=this.view.posAtCoords(sA(e))),this.updateAllowDefault(e),this.allowDefault||!n?Yh(this.view,\"pointer\"):UWe(this.view,n.pos,n.inside,e,this.selectNode)?e.preventDefault():e.button==0&&(this.flushed||$s&&this.mightDrag&&!this.mightDrag.node.isAtom||ks&&!this.view.state.selection.visible&&Math.min(Math.abs(n.pos-this.view.state.selection.from),Math.abs(n.pos-this.view.state.selection.to))<=2)?(zx(this.view,rr.near(this.view.state.doc.resolve(n.pos))),e.preventDefault()):Yh(this.view,\"pointer\")}move(e){this.updateAllowDefault(e),Yh(this.view,\"pointer\"),e.buttons==0&&this.done()}updateAllowDefault(e){!this.allowDefault&&(Math.abs(this.event.x-e.clientX)>4||Math.abs(this.event.y-e.clientY)>4)&&(this.allowDefault=!0)}}go.touchstart=t=>{t.input.lastTouch=Date.now(),zz(t),Yh(t,\"pointer\")};go.touchmove=t=>{t.input.lastTouch=Date.now(),Yh(t,\"pointer\")};go.contextmenu=t=>zz(t);function Mse(t,e){return t.composing?!0:$s&&Math.abs(e.timeStamp-t.input.compositionEndedAt)<500?(t.input.compositionEndedAt=-2e8,!0):!1}const VWe=am?5e3:-1;bo.compositionstart=bo.compositionupdate=t=>{if(!t.composing){t.domObserver.flush();let{state:e}=t,n=e.selection.$to;if(e.selection instanceof $n&&(e.storedMarks||!n.textOffset&&n.parentOffset&&n.nodeBefore.marks.some(r=>r.type.spec.inclusive===!1)||ks&&lse&&GWe(t)))t.markCursor=t.state.storedMarks||n.marks(),KC(t,!0),t.markCursor=null;else if(KC(t,!e.selection.empty),sc&&e.selection.empty&&n.parentOffset&&!n.textOffset&&n.nodeBefore.marks.length){let r=t.domSelectionRange();for(let i=r.focusNode,s=r.focusOffset;i&&i.nodeType==1&&s!=0;){let o=s<0?i.lastChild:i.childNodes[s-1];if(!o)break;if(o.nodeType==3){let l=t.domSelection();l&&l.collapse(o,o.nodeValue.length);break}else i=o,s=-1}}t.input.composing=!0}Ese(t,VWe)};function GWe(t){let{focusNode:e,focusOffset:n}=t.domSelectionRange();if(!e||e.nodeType!=1||n>=e.childNodes.length)return!1;let r=e.childNodes[n];return r.nodeType==1&&r.contentEditable==\"false\"}bo.compositionend=(t,e)=>{t.composing&&(t.input.composing=!1,t.input.compositionEndedAt=e.timeStamp,t.input.compositionPendingChanges=t.domObserver.pendingRecords().length?t.input.compositionID:0,t.input.compositionNode=null,t.input.badSafariComposition?t.domObserver.forceFlush():t.input.compositionPendingChanges&&Promise.resolve().then(()=>t.domObserver.flush()),t.input.compositionID++,Ese(t,20))};function Ese(t,e){clearTimeout(t.input.composingTimeout),e>-1&&(t.input.composingTimeout=setTimeout(()=>KC(t),e))}function Dse(t){for(t.composing&&(t.input.composing=!1,t.input.compositionEndedAt=KWe());t.input.compositionNodes.length>0;)t.input.compositionNodes.pop().markParentsDirty()}function WWe(t){let e=t.domSelectionRange();if(!e.focusNode)return null;let n=z9e(e.focusNode,e.focusOffset),r=U9e(e.focusNode,e.focusOffset);if(n&&r&&n!=r){let i=r.pmViewDesc,s=t.domObserver.lastChangedTextNode;if(n==s||r==s)return s;if(!i||!i.isText(r.nodeValue))return r;if(t.input.compositionNode==r){let o=n.pmViewDesc;if(!(!o||!o.isText(n.nodeValue)))return r}}return n||r}function KWe(){let t=document.createEvent(\"Event\");return t.initEvent(\"event\",!0,!0),t.timeStamp}function KC(t,e=!1){if(!(am&&t.domObserver.flushingSoon>=0)){if(t.domObserver.forceFlush(),Dse(t),e||t.docView&&t.docView.dirty){let n=Fz(t),r=t.state.selection;return n&&!n.eq(r)?t.dispatch(t.state.tr.setSelection(n)):(t.markCursor||e)&&!r.$from.node(r.$from.sharedDepth(r.to)).inlineContent?t.dispatch(t.state.tr.deleteSelection()):t.updateState(t.state),!0}return!1}}function XWe(t,e){if(!t.dom.parentNode)return;let n=t.dom.parentNode.appendChild(document.createElement(\"div\"));n.appendChild(e),n.style.cssText=\"position: fixed; left: -10000px; top: 10px\";let r=getSelection(),i=document.createRange();i.selectNodeContents(e),t.dom.blur(),r.removeAllRanges(),r.addRange(i),setTimeout(()=>{n.parentNode&&n.parentNode.removeChild(n),t.focus()},50)}const Iw=Ho&&mp<15||uy&&$9e<604;go.copy=bo.cut=(t,e)=>{let n=e,r=t.state.selection,i=n.type==\"cut\";if(r.empty)return;let s=Iw?null:n.clipboardData,o=r.content(),{dom:l,text:c}=Lz(t,o);s?(n.preventDefault(),s.clearData(),s.setData(\"text/html\",l.innerHTML),s.setData(\"text/plain\",c)):XWe(t,l),i&&t.dispatch(t.state.tr.deleteSelection().scrollIntoView().setMeta(\"uiEvent\",\"cut\"))};function YWe(t){return t.openStart==0&&t.openEnd==0&&t.content.childCount==1?t.content.firstChild:null}function QWe(t,e){if(!t.dom.parentNode)return;let n=t.input.shiftKey||t.state.selection.$from.parent.type.spec.code,r=t.dom.parentNode.appendChild(document.createElement(n?\"textarea\":\"div\"));n||(r.contentEditable=\"true\"),r.style.cssText=\"position: fixed; left: -10000px; top: 10px\",r.focus();let i=t.input.shiftKey&&t.input.lastKeyCode!=45;setTimeout(()=>{t.focus(),r.parentNode&&r.parentNode.removeChild(r),n?zw(t,r.value,null,i,e):zw(t,r.textContent,r.innerHTML,i,e)},50)}function zw(t,e,n,r,i){let s=kse(t,e,n,r,t.state.selection.$from);if(t.someProp(\"handlePaste\",c=>c(t,i,s||an.empty)))return!0;if(!s)return!1;let o=YWe(s),l=o?t.state.tr.replaceSelectionWith(o,r):t.state.tr.replaceSelection(s);return t.dispatch(l.scrollIntoView().setMeta(\"paste\",!0).setMeta(\"uiEvent\",\"paste\")),!0}function Fse(t){let e=t.getData(\"text/plain\")||t.getData(\"Text\");if(e)return e;let n=t.getData(\"text/uri-list\");return n?n.replace(/\\r?\\n/g,\" \"):\"\"}bo.paste=(t,e)=>{let n=e;if(t.composing&&!am)return;let r=Iw?null:n.clipboardData,i=t.input.shiftKey&&t.input.lastKeyCode!=45;r&&zw(t,Fse(r),r.getData(\"text/html\"),i,n)?n.preventDefault():QWe(t,n)};class Rse{constructor(e,n,r){this.slice=e,this.move=n,this.node=r}}const ZWe=Gl?\"altKey\":\"ctrlKey\";function Lse(t,e){let n=t.someProp(\"dragCopies\",r=>!r(e));return n??!e[ZWe]}go.dragstart=(t,e)=>{let n=e,r=t.input.mouseDown;if(r&&r.done(),!n.dataTransfer)return;let i=t.state.selection,s=i.empty?null:t.posAtCoords(sA(n)),o;if(!(s&&s.pos>=i.from&&s.pos<=(i instanceof An?i.to-1:i.to))){if(r&&r.mightDrag)o=An.create(t.state.doc,r.mightDrag.pos);else if(n.target&&n.target.nodeType==1){let m=t.docView.nearestDesc(n.target,!0);m&&m.node.type.spec.draggable&&m!=t.docView&&(o=An.create(t.state.doc,m.posBefore))}}let l=(o||t.state.selection).content(),{dom:c,text:d,slice:u}=Lz(t,l);(!n.dataTransfer.files.length||!ks||ose>120)&&n.dataTransfer.clearData(),n.dataTransfer.setData(Iw?\"Text\":\"text/html\",c.innerHTML),n.dataTransfer.effectAllowed=\"copyMove\",Iw||n.dataTransfer.setData(\"text/plain\",d),t.dragging=new Rse(u,Lse(t,n),o)};go.dragend=t=>{let e=t.dragging;window.setTimeout(()=>{t.dragging==e&&(t.dragging=null)},50)};bo.dragover=bo.dragenter=(t,e)=>e.preventDefault();bo.drop=(t,e)=>{try{JWe(t,e,t.dragging)}finally{t.dragging=null}};function JWe(t,e,n){if(!e.dataTransfer)return;let r=t.posAtCoords(sA(e));if(!r)return;let i=t.state.doc.resolve(r.pos),s=n&&n.slice;s?t.someProp(\"transformPasted\",f=>{s=f(s,t,!1)}):s=kse(t,Fse(e.dataTransfer),Iw?null:e.dataTransfer.getData(\"text/html\"),!1,i);let o=!!(n&&Lse(t,e));if(t.someProp(\"handleDrop\",f=>f(t,e,s||an.empty,o))){e.preventDefault();return}if(!s)return;e.preventDefault();let l=s?Uie(t.state.doc,i.pos,s):i.pos;l==null&&(l=i.pos);let c=t.state.tr;if(o){let{node:f}=n;f?f.replace(c):c.deleteSelection()}let d=c.mapping.map(l),u=s.openStart==0&&s.openEnd==0&&s.content.childCount==1,m=c.doc;if(u?c.replaceRangeWith(d,d,s.content.firstChild):c.replaceRange(d,d,s),c.doc.eq(m))return;let p=c.doc.resolve(d);if(u&&An.isSelectable(s.content.firstChild)&&p.nodeAfter&&p.nodeAfter.sameMarkup(s.content.firstChild))c.setSelection(new An(p));else{let f=c.mapping.map(l);c.mapping.maps[c.mapping.maps.length-1].forEach((y,v,b,g)=>f=g),c.setSelection(Rz(t,p,c.doc.resolve(f)))}t.focus(),t.dispatch(c.setMeta(\"uiEvent\",\"drop\"))}go.focus=t=>{t.input.lastFocus=Date.now(),t.focused||(t.domObserver.stop(),t.dom.classList.add(\"ProseMirror-focused\"),t.domObserver.start(),t.focused=!0,setTimeout(()=>{t.docView&&t.hasFocus()&&!t.domObserver.currentSelection.eq(t.domSelectionRange())&&pm(t)},20))};go.blur=(t,e)=>{let n=e;t.focused&&(t.domObserver.stop(),t.dom.classList.remove(\"ProseMirror-focused\"),t.domObserver.start(),n.relatedTarget&&t.dom.contains(n.relatedTarget)&&t.domObserver.currentSelection.clear(),t.focused=!1)};go.beforeinput=(t,e)=>{if(ks&&am&&e.inputType==\"deleteContentBackward\"){t.domObserver.flushSoon();let{domChangeCount:r}=t.input;setTimeout(()=>{if(t.input.domChangeCount!=r||(t.dom.blur(),t.focus(),t.someProp(\"handleKeyDown\",s=>s(t,Df(8,\"Backspace\")))))return;let{$cursor:i}=t.state.selection;i&&i.pos>0&&t.dispatch(t.state.tr.delete(i.pos-1,i.pos).scrollIntoView())},50)}};for(let t in bo)go[t]=bo[t];function Uw(t,e){if(t==e)return!0;for(let n in t)if(t[n]!==e[n])return!1;for(let n in e)if(!(n in t))return!1;return!0}class XC{constructor(e,n){this.toDOM=e,this.spec=n||rg,this.side=this.spec.side||0}map(e,n,r,i){let{pos:s,deleted:o}=e.mapResult(n.from+i,this.side<0?-1:1);return o?null:new io(s-r,s-r,this)}valid(){return!0}eq(e){return this==e||e instanceof XC&&(this.spec.key&&this.spec.key==e.spec.key||this.toDOM==e.toDOM&&Uw(this.spec,e.spec))}destroy(e){this.spec.destroy&&this.spec.destroy(e)}}class pp{constructor(e,n){this.attrs=e,this.spec=n||rg}map(e,n,r,i){let s=e.map(n.from+i,this.spec.inclusiveStart?-1:1)-r,o=e.map(n.to+i,this.spec.inclusiveEnd?1:-1)-r;return s>=o?null:new io(s,o,this)}valid(e,n){return n.from<n.to}eq(e){return this==e||e instanceof pp&&Uw(this.attrs,e.attrs)&&Uw(this.spec,e.spec)}static is(e){return e.type instanceof pp}destroy(){}}class Uz{constructor(e,n){this.attrs=e,this.spec=n||rg}map(e,n,r,i){let s=e.mapResult(n.from+i,1);if(s.deleted)return null;let o=e.mapResult(n.to+i,-1);return o.deleted||o.pos<=s.pos?null:new io(s.pos-r,o.pos-r,this)}valid(e,n){let{index:r,offset:i}=e.content.findIndex(n.from),s;return i==n.from&&!(s=e.child(r)).isText&&i+s.nodeSize==n.to}eq(e){return this==e||e instanceof Uz&&Uw(this.attrs,e.attrs)&&Uw(this.spec,e.spec)}destroy(){}}class io{constructor(e,n,r){this.from=e,this.to=n,this.type=r}copy(e,n){return new io(e,n,this.type)}eq(e,n=0){return this.type.eq(e.type)&&this.from+n==e.from&&this.to+n==e.to}map(e,n,r){return this.type.map(e,this,n,r)}static widget(e,n,r){return new io(e,e,new XC(n,r))}static inline(e,n,r,i){return new io(e,n,new pp(r,i))}static node(e,n,r,i){return new io(e,n,new Uz(r,i))}get spec(){return this.type.spec}get inline(){return this.type instanceof pp}get widget(){return this.type instanceof XC}}const gx=[],rg={};class Fa{constructor(e,n){this.local=e.length?e:gx,this.children=n.length?n:gx}static create(e,n){return n.length?YC(n,e,0,rg):Os}find(e,n,r){let i=[];return this.findInner(e??0,n??1e9,i,0,r),i}findInner(e,n,r,i,s){for(let o=0;o<this.local.length;o++){let l=this.local[o];l.from<=n&&l.to>=e&&(!s||s(l.spec))&&r.push(l.copy(l.from+i,l.to+i))}for(let o=0;o<this.children.length;o+=3)if(this.children[o]<n&&this.children[o+1]>e){let l=this.children[o]+1;this.children[o+2].findInner(e-l,n-l,r,i+l,s)}}map(e,n,r){return this==Os||e.maps.length==0?this:this.mapInner(e,n,0,0,r||rg)}mapInner(e,n,r,i,s){let o;for(let l=0;l<this.local.length;l++){let c=this.local[l].map(e,r,i);c&&c.type.valid(n,c)?(o||(o=[])).push(c):s.onRemove&&s.onRemove(this.local[l].spec)}return this.children.length?eKe(this.children,o||[],e,n,r,i,s):o?new Fa(o.sort(ag),gx):Os}add(e,n){return n.length?this==Os?Fa.create(e,n):this.addInner(e,n,0):this}addInner(e,n,r){let i,s=0;e.forEach((l,c)=>{let d=c+r,u;if(u=Ise(n,l,d)){for(i||(i=this.children.slice());s<i.length&&i[s]<c;)s+=3;i[s]==c?i[s+2]=i[s+2].addInner(l,u,d+1):i.splice(s,0,c,c+l.nodeSize,YC(u,l,d+1,rg)),s+=3}});let o=Ose(s?zse(n):n,-r);for(let l=0;l<o.length;l++)o[l].type.valid(e,o[l])||o.splice(l--,1);return new Fa(o.length?this.local.concat(o).sort(ag):this.local,i||this.children)}remove(e){return e.length==0||this==Os?this:this.removeInner(e,0)}removeInner(e,n){let r=this.children,i=this.local;for(let s=0;s<r.length;s+=3){let o,l=r[s]+n,c=r[s+1]+n;for(let u=0,m;u<e.length;u++)(m=e[u])&&m.from>l&&m.to<c&&(e[u]=null,(o||(o=[])).push(m));if(!o)continue;r==this.children&&(r=this.children.slice());let d=r[s+2].removeInner(o,l+1);d!=Os?r[s+2]=d:(r.splice(s,3),s-=3)}if(i.length){for(let s=0,o;s<e.length;s++)if(o=e[s])for(let l=0;l<i.length;l++)i[l].eq(o,n)&&(i==this.local&&(i=this.local.slice()),i.splice(l--,1))}return r==this.children&&i==this.local?this:i.length||r.length?new Fa(i,r):Os}forChild(e,n){if(this==Os)return this;if(n.isLeaf)return Fa.empty;let r,i;for(let l=0;l<this.children.length;l+=3)if(this.children[l]>=e){this.children[l]==e&&(r=this.children[l+2]);break}let s=e+1,o=s+n.content.size;for(let l=0;l<this.local.length;l++){let c=this.local[l];if(c.from<o&&c.to>s&&c.type instanceof pp){let d=Math.max(s,c.from)-s,u=Math.min(o,c.to)-s;d<u&&(i||(i=[])).push(c.copy(d,u))}}if(i){let l=new Fa(i.sort(ag),gx);return r?new Oh([l,r]):l}return r||Os}eq(e){if(this==e)return!0;if(!(e instanceof Fa)||this.local.length!=e.local.length||this.children.length!=e.children.length)return!1;for(let n=0;n<this.local.length;n++)if(!this.local[n].eq(e.local[n]))return!1;for(let n=0;n<this.children.length;n+=3)if(this.children[n]!=e.children[n]||this.children[n+1]!=e.children[n+1]||!this.children[n+2].eq(e.children[n+2]))return!1;return!0}locals(e){return Bz(this.localsInner(e))}localsInner(e){if(this==Os)return gx;if(e.inlineContent||!this.local.some(pp.is))return this.local;let n=[];for(let r=0;r<this.local.length;r++)this.local[r].type instanceof pp||n.push(this.local[r]);return n}forEachSet(e){e(this)}}Fa.empty=new Fa([],[]);Fa.removeOverlap=Bz;const Os=Fa.empty;class Oh{constructor(e){this.members=e}map(e,n){const r=this.members.map(i=>i.map(e,n,rg));return Oh.from(r)}forChild(e,n){if(n.isLeaf)return Fa.empty;let r=[];for(let i=0;i<this.members.length;i++){let s=this.members[i].forChild(e,n);s!=Os&&(s instanceof Oh?r=r.concat(s.members):r.push(s))}return Oh.from(r)}eq(e){if(!(e instanceof Oh)||e.members.length!=this.members.length)return!1;for(let n=0;n<this.members.length;n++)if(!this.members[n].eq(e.members[n]))return!1;return!0}locals(e){let n,r=!0;for(let i=0;i<this.members.length;i++){let s=this.members[i].localsInner(e);if(s.length)if(!n)n=s;else{r&&(n=n.slice(),r=!1);for(let o=0;o<s.length;o++)n.push(s[o])}}return n?Bz(r?n:n.sort(ag)):gx}static from(e){switch(e.length){case 0:return Os;case 1:return e[0];default:return new Oh(e.every(n=>n instanceof Fa)?e:e.reduce((n,r)=>n.concat(r instanceof Fa?r:r.members),[]))}}forEachSet(e){for(let n=0;n<this.members.length;n++)this.members[n].forEachSet(e)}}function eKe(t,e,n,r,i,s,o){let l=t.slice();for(let d=0,u=s;d<n.maps.length;d++){let m=0;n.maps[d].forEach((p,f,y,v)=>{let b=v-y-(f-p);for(let g=0;g<l.length;g+=3){let _=l[g+1];if(_<0||p>_+u-m)continue;let C=l[g]+u-m;f>=C?l[g+1]=p<=C?-2:-1:p>=u&&b&&(l[g]+=b,l[g+1]+=b)}m+=b}),u=n.maps[d].map(u,-1)}let c=!1;for(let d=0;d<l.length;d+=3)if(l[d+1]<0){if(l[d+1]==-2){c=!0,l[d+1]=-1;continue}let u=n.map(t[d]+s),m=u-i;if(m<0||m>=r.content.size){c=!0;continue}let p=n.map(t[d+1]+s,-1),f=p-i,{index:y,offset:v}=r.content.findIndex(m),b=r.maybeChild(y);if(b&&v==m&&v+b.nodeSize==f){let g=l[d+2].mapInner(n,b,u+1,t[d]+s+1,o);g!=Os?(l[d]=m,l[d+1]=f,l[d+2]=g):(l[d+1]=-2,c=!0)}else c=!0}if(c){let d=tKe(l,t,e,n,i,s,o),u=YC(d,r,0,o);e=u.local;for(let m=0;m<l.length;m+=3)l[m+1]<0&&(l.splice(m,3),m-=3);for(let m=0,p=0;m<u.children.length;m+=3){let f=u.children[m];for(;p<l.length&&l[p]<f;)p+=3;l.splice(p,0,u.children[m],u.children[m+1],u.children[m+2])}}return new Fa(e.sort(ag),l)}function Ose(t,e){if(!e||!t.length)return t;let n=[];for(let r=0;r<t.length;r++){let i=t[r];n.push(new io(i.from+e,i.to+e,i.type))}return n}function tKe(t,e,n,r,i,s,o){function l(c,d){for(let u=0;u<c.local.length;u++){let m=c.local[u].map(r,i,d);m?n.push(m):o.onRemove&&o.onRemove(c.local[u].spec)}for(let u=0;u<c.children.length;u+=3)l(c.children[u+2],c.children[u]+d+1)}for(let c=0;c<t.length;c+=3)t[c+1]==-1&&l(t[c+2],e[c]+s+1);return n}function Ise(t,e,n){if(e.isLeaf)return null;let r=n+e.nodeSize,i=null;for(let s=0,o;s<t.length;s++)(o=t[s])&&o.from>n&&o.to<r&&((i||(i=[])).push(o),t[s]=null);return i}function zse(t){let e=[];for(let n=0;n<t.length;n++)t[n]!=null&&e.push(t[n]);return e}function YC(t,e,n,r){let i=[],s=!1;e.forEach((l,c)=>{let d=Ise(t,l,c+n);if(d){s=!0;let u=YC(d,l,n+c+1,r);u!=Os&&i.push(c,c+l.nodeSize,u)}});let o=Ose(s?zse(t):t,-n).sort(ag);for(let l=0;l<o.length;l++)o[l].type.valid(e,o[l])||(r.onRemove&&r.onRemove(o[l].spec),o.splice(l--,1));return o.length||i.length?new Fa(o,i):Os}function ag(t,e){return t.from-e.from||t.to-e.to}function Bz(t){let e=t;for(let n=0;n<e.length-1;n++){let r=e[n];if(r.from!=r.to)for(let i=n+1;i<e.length;i++){let s=e[i];if(s.from==r.from){s.to!=r.to&&(e==t&&(e=t.slice()),e[i]=s.copy(s.from,r.to),uX(e,i+1,s.copy(r.to,s.to)));continue}else{s.from<r.to&&(e==t&&(e=t.slice()),e[n]=r.copy(r.from,s.from),uX(e,i,r.copy(s.from,r.to)));break}}}return e}function uX(t,e,n){for(;e<t.length&&ag(n,t[e])>0;)e++;t.splice(e,0,n)}function r3(t){let e=[];return t.someProp(\"decorations\",n=>{let r=n(t.state);r&&r!=Os&&e.push(r)}),t.cursorWrapper&&e.push(Fa.create(t.state.doc,[t.cursorWrapper.deco])),Oh.from(e)}const nKe={childList:!0,characterData:!0,characterDataOldValue:!0,attributes:!0,attributeOldValue:!0,subtree:!0},rKe=Ho&&mp<=11;class aKe{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}set(e){this.anchorNode=e.anchorNode,this.anchorOffset=e.anchorOffset,this.focusNode=e.focusNode,this.focusOffset=e.focusOffset}clear(){this.anchorNode=this.focusNode=null}eq(e){return e.anchorNode==this.anchorNode&&e.anchorOffset==this.anchorOffset&&e.focusNode==this.focusNode&&e.focusOffset==this.focusOffset}}class iKe{constructor(e,n){this.view=e,this.handleDOMChange=n,this.queue=[],this.flushingSoon=-1,this.observer=null,this.currentSelection=new aKe,this.onCharData=null,this.suppressingSelectionUpdates=!1,this.lastChangedTextNode=null,this.observer=window.MutationObserver&&new window.MutationObserver(r=>{for(let i=0;i<r.length;i++)this.queue.push(r[i]);Ho&&mp<=11&&r.some(i=>i.type==\"childList\"&&i.removedNodes.length||i.type==\"characterData\"&&i.oldValue.length>i.target.nodeValue.length)?this.flushSoon():$s&&e.composing&&r.some(i=>i.type==\"childList\"&&i.target.nodeName==\"TR\")?(e.input.badSafariComposition=!0,this.flushSoon()):this.flush()}),rKe&&(this.onCharData=r=>{this.queue.push({target:r.target,type:\"characterData\",oldValue:r.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this)}flushSoon(){this.flushingSoon<0&&(this.flushingSoon=window.setTimeout(()=>{this.flushingSoon=-1,this.flush()},20))}forceFlush(){this.flushingSoon>-1&&(window.clearTimeout(this.flushingSoon),this.flushingSoon=-1,this.flush())}start(){this.observer&&(this.observer.takeRecords(),this.observer.observe(this.view.dom,nKe)),this.onCharData&&this.view.dom.addEventListener(\"DOMCharacterDataModified\",this.onCharData),this.connectSelection()}stop(){if(this.observer){let e=this.observer.takeRecords();if(e.length){for(let n=0;n<e.length;n++)this.queue.push(e[n]);window.setTimeout(()=>this.flush(),20)}this.observer.disconnect()}this.onCharData&&this.view.dom.removeEventListener(\"DOMCharacterDataModified\",this.onCharData),this.disconnectSelection()}connectSelection(){this.view.dom.ownerDocument.addEventListener(\"selectionchange\",this.onSelectionChange)}disconnectSelection(){this.view.dom.ownerDocument.removeEventListener(\"selectionchange\",this.onSelectionChange)}suppressSelectionUpdates(){this.suppressingSelectionUpdates=!0,setTimeout(()=>this.suppressingSelectionUpdates=!1,50)}onSelectionChange(){if(rX(this.view)){if(this.suppressingSelectionUpdates)return pm(this.view);if(Ho&&mp<=11&&!this.view.state.selection.empty){let e=this.view.domSelectionRange();if(e.focusNode&&Eg(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset))return this.flushSoon()}this.flush()}}setCurSelection(){this.currentSelection.set(this.view.domSelectionRange())}ignoreSelectionChange(e){if(!e.focusNode)return!0;let n=new Set,r;for(let s=e.focusNode;s;s=dy(s))n.add(s);for(let s=e.anchorNode;s;s=dy(s))if(n.has(s)){r=s;break}let i=r&&this.view.docView.nearestDesc(r);if(i&&i.ignoreMutation({type:\"selection\",target:r.nodeType==3?r.parentNode:r}))return this.setCurSelection(),!0}pendingRecords(){if(this.observer)for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}flush(){let{view:e}=this;if(!e.docView||this.flushingSoon>-1)return;let n=this.pendingRecords();n.length&&(this.queue=[]);let r=e.domSelectionRange(),i=!this.suppressingSelectionUpdates&&!this.currentSelection.eq(r)&&rX(e)&&!this.ignoreSelectionChange(r),s=-1,o=-1,l=!1,c=[];if(e.editable)for(let u=0;u<n.length;u++){let m=this.registerMutation(n[u],c);m&&(s=s<0?m.from:Math.min(m.from,s),o=o<0?m.to:Math.max(m.to,o),m.typeOver&&(l=!0))}if(c.some(u=>u.nodeName==\"BR\")&&(e.input.lastKeyCode==8||e.input.lastKeyCode==46)){for(let u of c)if(u.nodeName==\"BR\"&&u.parentNode){let m=u.nextSibling;m&&m.nodeType==1&&m.contentEditable==\"false\"&&u.parentNode.removeChild(u)}}else if(sc&&c.length){let u=c.filter(m=>m.nodeName==\"BR\");if(u.length==2){let[m,p]=u;m.parentNode&&m.parentNode.parentNode==p.parentNode?p.remove():m.remove()}else{let{focusNode:m}=this.currentSelection;for(let p of u){let f=p.parentNode;f&&f.nodeName==\"LI\"&&(!m||lKe(e,m)!=f)&&p.remove()}}}let d=null;s<0&&i&&e.input.lastFocus>Date.now()-200&&Math.max(e.input.lastTouch,e.input.lastClick.time)<Date.now()-300&&aA(r)&&(d=Fz(e))&&d.eq(rr.near(e.state.doc.resolve(0),1))?(e.input.lastFocus=0,pm(e),this.currentSelection.set(r),e.scrollToSelection()):(s>-1||i)&&(s>-1&&(e.docView.markDirty(s,o),sKe(e)),e.input.badSafariComposition&&(e.input.badSafariComposition=!1,cKe(e,c)),this.handleDOMChange(s,o,l,c),e.docView&&e.docView.dirty?e.updateState(e.state):this.currentSelection.eq(r)||pm(e),this.currentSelection.set(r))}registerMutation(e,n){if(n.indexOf(e.target)>-1)return null;let r=this.view.docView.nearestDesc(e.target);if(e.type==\"attributes\"&&(r==this.view.docView||e.attributeName==\"contenteditable\"||e.attributeName==\"style\"&&!e.oldValue&&!e.target.getAttribute(\"style\"))||!r||r.ignoreMutation(e))return null;if(e.type==\"childList\"){for(let u=0;u<e.addedNodes.length;u++){let m=e.addedNodes[u];n.push(m),m.nodeType==3&&(this.lastChangedTextNode=m)}if(r.contentDOM&&r.contentDOM!=r.dom&&!r.contentDOM.contains(e.target))return{from:r.posBefore,to:r.posAfter};let i=e.previousSibling,s=e.nextSibling;if(Ho&&mp<=11&&e.addedNodes.length)for(let u=0;u<e.addedNodes.length;u++){let{previousSibling:m,nextSibling:p}=e.addedNodes[u];(!m||Array.prototype.indexOf.call(e.addedNodes,m)<0)&&(i=m),(!p||Array.prototype.indexOf.call(e.addedNodes,p)<0)&&(s=p)}let o=i&&i.parentNode==e.target?ys(i)+1:0,l=r.localPosFromDOM(e.target,o,-1),c=s&&s.parentNode==e.target?ys(s):e.target.childNodes.length,d=r.localPosFromDOM(e.target,c,1);return{from:l,to:d}}else return e.type==\"attributes\"?{from:r.posAtStart-r.border,to:r.posAtEnd+r.border}:(this.lastChangedTextNode=e.target,{from:r.posAtStart,to:r.posAtEnd,typeOver:e.target.nodeValue==e.oldValue})}}let mX=new WeakMap,hX=!1;function sKe(t){if(!mX.has(t)&&(mX.set(t,null),[\"normal\",\"nowrap\",\"pre-line\"].indexOf(getComputedStyle(t.dom).whiteSpace)!==-1)){if(t.requiresGeckoHackNode=sc,hX)return;console.warn(\"ProseMirror expects the CSS white-space property to be set, preferably to 'pre-wrap'. It is recommended to load style/prosemirror.css from the prosemirror-view package.\"),hX=!0}}function pX(t,e){let n=e.startContainer,r=e.startOffset,i=e.endContainer,s=e.endOffset,o=t.domAtPos(t.state.selection.anchor);return Eg(o.node,o.offset,i,s)&&([n,r,i,s]=[i,s,n,r]),{anchorNode:n,anchorOffset:r,focusNode:i,focusOffset:s}}function oKe(t,e){if(e.getComposedRanges){let i=e.getComposedRanges(t.root)[0];if(i)return pX(t,i)}let n;function r(i){i.preventDefault(),i.stopImmediatePropagation(),n=i.getTargetRanges()[0]}return t.dom.addEventListener(\"beforeinput\",r,!0),document.execCommand(\"indent\"),t.dom.removeEventListener(\"beforeinput\",r,!0),n?pX(t,n):null}function lKe(t,e){for(let n=e.parentNode;n&&n!=t.dom;n=n.parentNode){let r=t.docView.nearestDesc(n,!0);if(r&&r.node.isBlock)return n}return null}function cKe(t,e){var n;let{focusNode:r,focusOffset:i}=t.domSelectionRange();for(let s of e)if(((n=s.parentNode)===null||n===void 0?void 0:n.nodeName)==\"TR\"){let o=s.nextSibling;for(;o&&o.nodeName!=\"TD\"&&o.nodeName!=\"TH\";)o=o.nextSibling;if(o){let l=o;for(;;){let c=l.firstChild;if(!c||c.nodeType!=1||c.contentEditable==\"false\"||/^(BR|IMG)$/.test(c.nodeName))break;l=c}l.insertBefore(s,l.firstChild),r==s&&t.domSelection().collapse(s,i)}else s.parentNode.removeChild(s)}}function dKe(t,e,n){let{node:r,fromOffset:i,toOffset:s,from:o,to:l}=t.docView.parseRange(e,n),c=t.domSelectionRange(),d,u=c.anchorNode;if(u&&t.dom.contains(u.nodeType==1?u:u.parentNode)&&(d=[{node:u,offset:c.anchorOffset}],aA(c)||d.push({node:c.focusNode,offset:c.focusOffset})),ks&&t.input.lastKeyCode===8)for(let b=s;b>i;b--){let g=r.childNodes[b-1],_=g.pmViewDesc;if(g.nodeName==\"BR\"&&!_){s=b;break}if(!_||_.size)break}let m=t.state.doc,p=t.someProp(\"domParser\")||z0.fromSchema(t.state.schema),f=m.resolve(o),y=null,v=p.parse(r,{topNode:f.parent,topMatch:f.parent.contentMatchAt(f.index()),topOpen:!0,from:i,to:s,preserveWhitespace:f.parent.type.whitespace==\"pre\"?\"full\":!0,findPositions:d,ruleFromNode:uKe,context:f});if(d&&d[0].pos!=null){let b=d[0].pos,g=d[1]&&d[1].pos;g==null&&(g=b),y={anchor:b+o,head:g+o}}return{doc:v,sel:y,from:o,to:l}}function uKe(t){let e=t.pmViewDesc;if(e)return e.parseRule();if(t.nodeName==\"BR\"&&t.parentNode){if($s&&/^(ul|ol)$/i.test(t.parentNode.nodeName)){let n=document.createElement(\"div\");return n.appendChild(document.createElement(\"li\")),{skip:n}}else if(t.parentNode.lastChild==t||$s&&/^(tr|table)$/i.test(t.parentNode.nodeName))return{ignore:!0}}else if(t.nodeName==\"IMG\"&&t.getAttribute(\"mark-placeholder\"))return{ignore:!0};return null}const mKe=/^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|img|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i;function hKe(t,e,n,r,i){let s=t.input.compositionPendingChanges||(t.composing?t.input.compositionID:0);if(t.input.compositionPendingChanges=0,e<0){let T=t.input.lastSelectionTime>Date.now()-50?t.input.lastSelectionOrigin:null,F=Fz(t,T);if(F&&!t.state.selection.eq(F)){if(ks&&am&&t.input.lastKeyCode===13&&Date.now()-100<t.input.lastKeyCodeTime&&t.someProp(\"handleKeyDown\",D=>D(t,Df(13,\"Enter\"))))return;let k=t.state.tr.setSelection(F);T==\"pointer\"?k.setMeta(\"pointer\",!0):T==\"key\"&&k.scrollIntoView(),s&&k.setMeta(\"composition\",s),t.dispatch(k)}return}let o=t.state.doc.resolve(e),l=o.sharedDepth(n);e=o.before(l+1),n=t.state.doc.resolve(n).after(l+1);let c=t.state.selection,d=dKe(t,e,n),u=t.state.doc,m=u.slice(d.from,d.to),p,f;t.input.lastKeyCode===8&&Date.now()-100<t.input.lastKeyCodeTime?(p=t.state.selection.to,f=\"end\"):(p=t.state.selection.from,f=\"start\"),t.input.lastKeyCode=null;let y=gKe(m.content,d.doc.content,d.from,p,f);if(y&&t.input.domChangeCount++,(uy&&t.input.lastIOSEnter>Date.now()-225||am)&&i.some(T=>T.nodeType==1&&!mKe.test(T.nodeName))&&(!y||y.endA>=y.endB)&&t.someProp(\"handleKeyDown\",T=>T(t,Df(13,\"Enter\")))){t.input.lastIOSEnter=0;return}if(!y)if(r&&c instanceof $n&&!c.empty&&c.$head.sameParent(c.$anchor)&&!t.composing&&!(d.sel&&d.sel.anchor!=d.sel.head))y={start:c.from,endA:c.to,endB:c.to};else{if(d.sel){let T=fX(t,t.state.doc,d.sel);if(T&&!T.eq(t.state.selection)){let F=t.state.tr.setSelection(T);s&&F.setMeta(\"composition\",s),t.dispatch(F)}}return}t.state.selection.from<t.state.selection.to&&y.start==y.endB&&t.state.selection instanceof $n&&(y.start>t.state.selection.from&&y.start<=t.state.selection.from+2&&t.state.selection.from>=d.from?y.start=t.state.selection.from:y.endA<t.state.selection.to&&y.endA>=t.state.selection.to-2&&t.state.selection.to<=d.to&&(y.endB+=t.state.selection.to-y.endA,y.endA=t.state.selection.to)),Ho&&mp<=11&&y.endB==y.start+1&&y.endA==y.start&&y.start>d.from&&d.doc.textBetween(y.start-d.from-1,y.start-d.from+1)==\"  \"&&(y.start--,y.endA--,y.endB--);let v=d.doc.resolveNoCache(y.start-d.from),b=d.doc.resolveNoCache(y.endB-d.from),g=u.resolve(y.start),_=v.sameParent(b)&&v.parent.inlineContent&&g.end()>=y.endA;if((uy&&t.input.lastIOSEnter>Date.now()-225&&(!_||i.some(T=>T.nodeName==\"DIV\"||T.nodeName==\"P\"))||!_&&v.pos<d.doc.content.size&&(!v.sameParent(b)||!v.parent.inlineContent)&&v.pos<b.pos&&!/\\S/.test(d.doc.textBetween(v.pos,b.pos,\"\",\"\")))&&t.someProp(\"handleKeyDown\",T=>T(t,Df(13,\"Enter\")))){t.input.lastIOSEnter=0;return}if(t.state.selection.anchor>y.start&&fKe(u,y.start,y.endA,v,b)&&t.someProp(\"handleKeyDown\",T=>T(t,Df(8,\"Backspace\")))){am&&ks&&t.domObserver.suppressSelectionUpdates();return}ks&&y.endB==y.start&&(t.input.lastChromeDelete=Date.now()),am&&!_&&v.start()!=b.start()&&b.parentOffset==0&&v.depth==b.depth&&d.sel&&d.sel.anchor==d.sel.head&&d.sel.head==y.endA&&(y.endB-=2,b=d.doc.resolveNoCache(y.endB-d.from),setTimeout(()=>{t.someProp(\"handleKeyDown\",function(T){return T(t,Df(13,\"Enter\"))})},20));let C=y.start,P=y.endA,N=T=>{let F=T||t.state.tr.replace(C,P,d.doc.slice(y.start-d.from,y.endB-d.from));if(d.sel){let k=fX(t,F.doc,d.sel);k&&!(ks&&t.composing&&k.empty&&(y.start!=y.endB||t.input.lastChromeDelete<Date.now()-100)&&(k.head==C||k.head==F.mapping.map(P)-1)||Ho&&k.empty&&k.head==C)&&F.setSelection(k)}return s&&F.setMeta(\"composition\",s),F.scrollIntoView()},A;if(_)if(v.pos==b.pos){Ho&&mp<=11&&v.parentOffset==0&&(t.domObserver.suppressSelectionUpdates(),setTimeout(()=>pm(t),20));let T=N(t.state.tr.delete(C,P)),F=u.resolve(y.start).marksAcross(u.resolve(y.endA));F&&T.ensureMarks(F),t.dispatch(T)}else if(y.endA==y.endB&&(A=pKe(v.parent.content.cut(v.parentOffset,b.parentOffset),g.parent.content.cut(g.parentOffset,y.endA-g.start())))){let T=N(t.state.tr);A.type==\"add\"?T.addMark(C,P,A.mark):T.removeMark(C,P,A.mark),t.dispatch(T)}else if(v.parent.child(v.index()).isText&&v.index()==b.index()-(b.textOffset?0:1)){let T=v.parent.textBetween(v.parentOffset,b.parentOffset),F=()=>N(t.state.tr.insertText(T,C,P));t.someProp(\"handleTextInput\",k=>k(t,C,P,T,F))||t.dispatch(F())}else t.dispatch(N());else t.dispatch(N())}function fX(t,e,n){return Math.max(n.anchor,n.head)>e.content.size?null:Rz(t,e.resolve(n.anchor),e.resolve(n.head))}function pKe(t,e){let n=t.firstChild.marks,r=e.firstChild.marks,i=n,s=r,o,l,c;for(let u=0;u<r.length;u++)i=r[u].removeFromSet(i);for(let u=0;u<n.length;u++)s=n[u].removeFromSet(s);if(i.length==1&&s.length==0)l=i[0],o=\"add\",c=u=>u.mark(l.addToSet(u.marks));else if(i.length==0&&s.length==1)l=s[0],o=\"remove\",c=u=>u.mark(l.removeFromSet(u.marks));else return null;let d=[];for(let u=0;u<e.childCount;u++)d.push(c(e.child(u)));if(Vt.from(d).eq(t))return{mark:l,type:o}}function fKe(t,e,n,r,i){if(n-e<=i.pos-r.pos||a3(r,!0,!1)<i.pos)return!1;let s=t.resolve(e);if(!r.parent.isTextblock){let l=s.nodeAfter;return l!=null&&n==e+l.nodeSize}if(s.parentOffset<s.parent.content.size||!s.parent.isTextblock)return!1;let o=t.resolve(a3(s,!0,!0));return!o.parent.isTextblock||o.pos>n||a3(o,!0,!1)<n?!1:r.parent.content.cut(r.parentOffset).eq(o.parent.content)}function a3(t,e,n){let r=t.depth,i=e?t.end():t.pos;for(;r>0&&(e||t.indexAfter(r)==t.node(r).childCount);)r--,i++,e=!1;if(n){let s=t.node(r).maybeChild(t.indexAfter(r));for(;s&&!s.isLeaf;)s=s.firstChild,i++}return i}function gKe(t,e,n,r,i){let s=t.findDiffStart(e,n);if(s==null)return null;let{a:o,b:l}=t.findDiffEnd(e,n+t.size,n+e.size);if(i==\"end\"){let c=Math.max(0,s-Math.min(o,l));r-=o+c-s}if(o<s&&t.size<e.size){let c=r<=s&&r>=o?s-r:0;s-=c,s&&s<e.size&&gX(e.textBetween(s-1,s+1))&&(s+=c?1:-1),l=s+(l-o),o=s}else if(l<s){let c=r<=s&&r>=l?s-r:0;s-=c,s&&s<t.size&&gX(t.textBetween(s-1,s+1))&&(s+=c?1:-1),o=s+(o-l),l=s}return{start:s,endA:o,endB:l}}function gX(t){if(t.length!=2)return!1;let e=t.charCodeAt(0),n=t.charCodeAt(1);return e>=56320&&e<=57343&&n>=55296&&n<=56319}class Use{constructor(e,n){this._root=null,this.focused=!1,this.trackWrites=null,this.mounted=!1,this.markCursor=null,this.cursorWrapper=null,this.lastSelectedViewDesc=void 0,this.input=new EWe,this.prevDirectPlugins=[],this.pluginViews=[],this.requiresGeckoHackNode=!1,this.dragging=null,this._props=n,this.state=n.state,this.directPlugins=n.plugins||[],this.directPlugins.forEach(wX),this.dispatch=this.dispatch.bind(this),this.dom=e&&e.mount||document.createElement(\"div\"),e&&(e.appendChild?e.appendChild(this.dom):typeof e==\"function\"?e(this.dom):e.mount&&(this.mounted=!0)),this.editable=yX(this),xX(this),this.nodeViews=vX(this),this.docView=QK(this.state.doc,bX(this),r3(this),this.dom,this),this.domObserver=new iKe(this,(r,i,s,o)=>hKe(this,r,i,s,o)),this.domObserver.start(),DWe(this),this.updatePluginViews()}get composing(){return this.input.composing}get props(){if(this._props.state!=this.state){let e=this._props;this._props={};for(let n in e)this._props[n]=e[n];this._props.state=this.state}return this._props}update(e){e.handleDOMEvents!=this._props.handleDOMEvents&&zL(this);let n=this._props;this._props=e,e.plugins&&(e.plugins.forEach(wX),this.directPlugins=e.plugins),this.updateStateInner(e.state,n)}setProps(e){let n={};for(let r in this._props)n[r]=this._props[r];n.state=this.state;for(let r in e)n[r]=e[r];this.update(n)}updateState(e){this.updateStateInner(e,this._props)}updateStateInner(e,n){var r;let i=this.state,s=!1,o=!1;e.storedMarks&&this.composing&&(Dse(this),o=!0),this.state=e;let l=i.plugins!=e.plugins||this._props.plugins!=n.plugins;if(l||this._props.plugins!=n.plugins||this._props.nodeViews!=n.nodeViews){let f=vX(this);xKe(f,this.nodeViews)&&(this.nodeViews=f,s=!0)}(l||n.handleDOMEvents!=this._props.handleDOMEvents)&&zL(this),this.editable=yX(this),xX(this);let c=r3(this),d=bX(this),u=i.plugins!=e.plugins&&!i.doc.eq(e.doc)?\"reset\":e.scrollToSelection>i.scrollToSelection?\"to selection\":\"preserve\",m=s||!this.docView.matchesNode(e.doc,d,c);(m||!e.selection.eq(i.selection))&&(o=!0);let p=u==\"preserve\"&&o&&this.dom.style.overflowAnchor==null&&W9e(this);if(o){this.domObserver.stop();let f=m&&(Ho||ks)&&!this.composing&&!i.selection.empty&&!e.selection.empty&&bKe(i.selection,e.selection);if(m){let y=ks?this.trackWrites=this.domSelectionRange().focusNode:null;this.composing&&(this.input.compositionNode=WWe(this)),(s||!this.docView.update(e.doc,d,c,this))&&(this.docView.updateOuterDeco(d),this.docView.destroy(),this.docView=QK(e.doc,d,c,this.dom,this)),y&&(!this.trackWrites||!this.dom.contains(this.trackWrites))&&(f=!0)}f||!(this.input.mouseDown&&this.domObserver.currentSelection.eq(this.domSelectionRange())&&bWe(this))?pm(this,f):(wse(this,e.selection),this.domObserver.setCurSelection()),this.domObserver.start()}this.updatePluginViews(i),!((r=this.dragging)===null||r===void 0)&&r.node&&!i.doc.eq(e.doc)&&this.updateDraggedNode(this.dragging,i),u==\"reset\"?this.dom.scrollTop=0:u==\"to selection\"?this.scrollToSelection():p&&K9e(p)}scrollToSelection(){let e=this.domSelectionRange().focusNode;if(!(!e||!this.dom.contains(e.nodeType==1?e:e.parentNode))){if(!this.someProp(\"handleScrollToSelection\",n=>n(this)))if(this.state.selection instanceof An){let n=this.docView.domAfterPos(this.state.selection.from);n.nodeType==1&&VK(this,n.getBoundingClientRect(),e)}else VK(this,this.coordsAtPos(this.state.selection.head,1),e)}}destroyPluginViews(){let e;for(;e=this.pluginViews.pop();)e.destroy&&e.destroy()}updatePluginViews(e){if(!e||e.plugins!=this.state.plugins||this.directPlugins!=this.prevDirectPlugins){this.prevDirectPlugins=this.directPlugins,this.destroyPluginViews();for(let n=0;n<this.directPlugins.length;n++){let r=this.directPlugins[n];r.spec.view&&this.pluginViews.push(r.spec.view(this))}for(let n=0;n<this.state.plugins.length;n++){let r=this.state.plugins[n];r.spec.view&&this.pluginViews.push(r.spec.view(this))}}else for(let n=0;n<this.pluginViews.length;n++){let r=this.pluginViews[n];r.update&&r.update(this,e)}}updateDraggedNode(e,n){let r=e.node,i=-1;if(this.state.doc.nodeAt(r.from)==r.node)i=r.from;else{let s=r.from+(this.state.doc.content.size-n.doc.content.size);(s>0&&this.state.doc.nodeAt(s))==r.node&&(i=s)}this.dragging=new Rse(e.slice,e.move,i<0?void 0:An.create(this.state.doc,i))}someProp(e,n){let r=this._props&&this._props[e],i;if(r!=null&&(i=n?n(r):r))return i;for(let o=0;o<this.directPlugins.length;o++){let l=this.directPlugins[o].props[e];if(l!=null&&(i=n?n(l):l))return i}let s=this.state.plugins;if(s)for(let o=0;o<s.length;o++){let l=s[o].props[e];if(l!=null&&(i=n?n(l):l))return i}}hasFocus(){if(Ho){let e=this.root.activeElement;if(e==this.dom)return!0;if(!e||!this.dom.contains(e))return!1;for(;e&&this.dom!=e&&this.dom.contains(e);){if(e.contentEditable==\"false\")return!1;e=e.parentElement}return!0}return this.root.activeElement==this.dom}focus(){this.domObserver.stop(),this.editable&&X9e(this.dom),pm(this),this.domObserver.start()}get root(){let e=this._root;if(e==null){for(let n=this.dom.parentNode;n;n=n.parentNode)if(n.nodeType==9||n.nodeType==11&&n.host)return n.getSelection||(Object.getPrototypeOf(n).getSelection=()=>n.ownerDocument.getSelection()),this._root=n}return e||document}updateRoot(){this._root=null}posAtCoords(e){return eWe(this,e)}coordsAtPos(e,n=1){return hse(this,e,n)}domAtPos(e,n=0){return this.docView.domFromPos(e,n)}nodeDOM(e){let n=this.docView.descAt(e);return n?n.nodeDOM:null}posAtDOM(e,n,r=-1){let i=this.docView.posFromDOM(e,n,r);if(i==null)throw new RangeError(\"DOM position not inside the editor\");return i}endOfTextblock(e,n){return iWe(this,n||this.state,e)}pasteHTML(e,n){return zw(this,\"\",e,!1,n||new ClipboardEvent(\"paste\"))}pasteText(e,n){return zw(this,e,null,!0,n||new ClipboardEvent(\"paste\"))}serializeForClipboard(e){return Lz(this,e)}destroy(){this.docView&&(FWe(this),this.destroyPluginViews(),this.mounted?(this.docView.update(this.state.doc,[],r3(this),this),this.dom.textContent=\"\"):this.dom.parentNode&&this.dom.parentNode.removeChild(this.dom),this.docView.destroy(),this.docView=null,O9e())}get isDestroyed(){return this.docView==null}dispatchEvent(e){return LWe(this,e)}domSelectionRange(){let e=this.domSelection();return e?$s&&this.root.nodeType===11&&H9e(this.dom.ownerDocument)==this.dom&&oKe(this,e)||e:{focusNode:null,focusOffset:0,anchorNode:null,anchorOffset:0}}domSelection(){return this.root.getSelection()}}Use.prototype.dispatch=function(t){let e=this._props.dispatchTransaction;e?e.call(this,t):this.updateState(this.state.apply(t))};function bX(t){let e=Object.create(null);return e.class=\"ProseMirror\",e.contenteditable=String(t.editable),t.someProp(\"attributes\",n=>{if(typeof n==\"function\"&&(n=n(t.state)),n)for(let r in n)r==\"class\"?e.class+=\" \"+n[r]:r==\"style\"?e.style=(e.style?e.style+\";\":\"\")+n[r]:!e[r]&&r!=\"contenteditable\"&&r!=\"nodeName\"&&(e[r]=String(n[r]))}),e.translate||(e.translate=\"no\"),[io.node(0,t.state.doc.content.size,e)]}function xX(t){if(t.markCursor){let e=document.createElement(\"img\");e.className=\"ProseMirror-separator\",e.setAttribute(\"mark-placeholder\",\"true\"),e.setAttribute(\"alt\",\"\"),t.cursorWrapper={dom:e,deco:io.widget(t.state.selection.from,e,{raw:!0,marks:t.markCursor})}}else t.cursorWrapper=null}function yX(t){return!t.someProp(\"editable\",e=>e(t.state)===!1)}function bKe(t,e){let n=Math.min(t.$anchor.sharedDepth(t.head),e.$anchor.sharedDepth(e.head));return t.$anchor.start(n)!=e.$anchor.start(n)}function vX(t){let e=Object.create(null);function n(r){for(let i in r)Object.prototype.hasOwnProperty.call(e,i)||(e[i]=r[i])}return t.someProp(\"nodeViews\",n),t.someProp(\"markViews\",n),e}function xKe(t,e){let n=0,r=0;for(let i in t){if(t[i]!=e[i])return!0;n++}for(let i in e)r++;return n!=r}function wX(t){if(t.spec.state||t.spec.filterTransaction||t.spec.appendTransaction)throw new RangeError(\"Plugins passed directly to the view must not have a state component\")}var kp={8:\"Backspace\",9:\"Tab\",10:\"Enter\",12:\"NumLock\",13:\"Enter\",16:\"Shift\",17:\"Control\",18:\"Alt\",20:\"CapsLock\",27:\"Escape\",32:\" \",33:\"PageUp\",34:\"PageDown\",35:\"End\",36:\"Home\",37:\"ArrowLeft\",38:\"ArrowUp\",39:\"ArrowRight\",40:\"ArrowDown\",44:\"PrintScreen\",45:\"Insert\",46:\"Delete\",59:\";\",61:\"=\",91:\"Meta\",92:\"Meta\",106:\"*\",107:\"+\",108:\",\",109:\"-\",110:\".\",111:\"/\",144:\"NumLock\",145:\"ScrollLock\",160:\"Shift\",161:\"Shift\",162:\"Control\",163:\"Control\",164:\"Alt\",165:\"Alt\",173:\"-\",186:\";\",187:\"=\",188:\",\",189:\"-\",190:\".\",191:\"/\",192:\"`\",219:\"[\",220:\"\\\\\",221:\"]\",222:\"'\"},QC={48:\")\",49:\"!\",50:\"@\",51:\"#\",52:\"$\",53:\"%\",54:\"^\",55:\"&\",56:\"*\",57:\"(\",59:\":\",61:\"+\",173:\"_\",186:\":\",187:\"+\",188:\"<\",189:\"_\",190:\">\",191:\"?\",192:\"~\",219:\"{\",220:\"|\",221:\"}\",222:'\"'},yKe=typeof navigator<\"u\"&&/Mac/.test(navigator.platform),vKe=typeof navigator<\"u\"&&/MSIE \\d|Trident\\/(?:[7-9]|\\d{2,})\\..*rv:(\\d+)/.exec(navigator.userAgent);for(var vs=0;vs<10;vs++)kp[48+vs]=kp[96+vs]=String(vs);for(var vs=1;vs<=24;vs++)kp[vs+111]=\"F\"+vs;for(var vs=65;vs<=90;vs++)kp[vs]=String.fromCharCode(vs+32),QC[vs]=String.fromCharCode(vs);for(var i3 in kp)QC.hasOwnProperty(i3)||(QC[i3]=kp[i3]);function wKe(t){var e=yKe&&t.metaKey&&t.shiftKey&&!t.ctrlKey&&!t.altKey||vKe&&t.shiftKey&&t.key&&t.key.length==1||t.key==\"Unidentified\",n=!e&&t.key||(t.shiftKey?QC:kp)[t.keyCode]||t.key||\"Unidentified\";return n==\"Esc\"&&(n=\"Escape\"),n==\"Del\"&&(n=\"Delete\"),n==\"Left\"&&(n=\"ArrowLeft\"),n==\"Up\"&&(n=\"ArrowUp\"),n==\"Right\"&&(n=\"ArrowRight\"),n==\"Down\"&&(n=\"ArrowDown\"),n}const SKe=typeof navigator<\"u\"&&/Mac|iP(hone|[oa]d)/.test(navigator.platform),_Ke=typeof navigator<\"u\"&&/Win/.test(navigator.platform);function kKe(t){let e=t.split(/-(?!$)/),n=e[e.length-1];n==\"Space\"&&(n=\" \");let r,i,s,o;for(let l=0;l<e.length-1;l++){let c=e[l];if(/^(cmd|meta|m)$/i.test(c))o=!0;else if(/^a(lt)?$/i.test(c))r=!0;else if(/^(c|ctrl|control)$/i.test(c))i=!0;else if(/^s(hift)?$/i.test(c))s=!0;else if(/^mod$/i.test(c))SKe?o=!0:i=!0;else throw new Error(\"Unrecognized modifier name: \"+c)}return r&&(n=\"Alt-\"+n),i&&(n=\"Ctrl-\"+n),o&&(n=\"Meta-\"+n),s&&(n=\"Shift-\"+n),n}function NKe(t){let e=Object.create(null);for(let n in t)e[kKe(n)]=t[n];return e}function s3(t,e,n=!0){return e.altKey&&(t=\"Alt-\"+t),e.ctrlKey&&(t=\"Ctrl-\"+t),e.metaKey&&(t=\"Meta-\"+t),n&&e.shiftKey&&(t=\"Shift-\"+t),t}function CKe(t){return new Ua({props:{handleKeyDown:Bse(t)}})}function Bse(t){let e=NKe(t);return function(n,r){let i=wKe(r),s,o=e[s3(i,r)];if(o&&o(n.state,n.dispatch,n))return!0;if(i.length==1&&i!=\" \"){if(r.shiftKey){let l=e[s3(i,r,!1)];if(l&&l(n.state,n.dispatch,n))return!0}if((r.altKey||r.metaKey||r.ctrlKey)&&!(_Ke&&r.ctrlKey&&r.altKey)&&(s=kp[r.keyCode])&&s!=i){let l=e[s3(s,r)];if(l&&l(n.state,n.dispatch,n))return!0}}return!1}}var PKe=Object.defineProperty,Hz=(t,e)=>{for(var n in e)PKe(t,n,{get:e[n],enumerable:!0})};function oA(t){const{state:e,transaction:n}=t;let{selection:r}=n,{doc:i}=n,{storedMarks:s}=n;return{...e,apply:e.apply.bind(e),applyTransaction:e.applyTransaction.bind(e),plugins:e.plugins,schema:e.schema,reconfigure:e.reconfigure.bind(e),toJSON:e.toJSON.bind(e),get storedMarks(){return s},get selection(){return r},get doc(){return i},get tr(){return r=n.selection,i=n.doc,s=n.storedMarks,n}}}var lA=class{constructor(t){this.editor=t.editor,this.rawCommands=this.editor.extensionManager.commands,this.customState=t.state}get hasCustomState(){return!!this.customState}get state(){return this.customState||this.editor.state}get commands(){const{rawCommands:t,editor:e,state:n}=this,{view:r}=e,{tr:i}=n,s=this.buildProps(i);return Object.fromEntries(Object.entries(t).map(([o,l])=>[o,(...d)=>{const u=l(...d)(s);return!i.getMeta(\"preventDispatch\")&&!this.hasCustomState&&r.dispatch(i),u}]))}get chain(){return()=>this.createChain()}get can(){return()=>this.createCan()}createChain(t,e=!0){const{rawCommands:n,editor:r,state:i}=this,{view:s}=r,o=[],l=!!t,c=t||i.tr,d=()=>(!l&&e&&!c.getMeta(\"preventDispatch\")&&!this.hasCustomState&&s.dispatch(c),o.every(m=>m===!0)),u={...Object.fromEntries(Object.entries(n).map(([m,p])=>[m,(...y)=>{const v=this.buildProps(c,e),b=p(...y)(v);return o.push(b),u}])),run:d};return u}createCan(t){const{rawCommands:e,state:n}=this,r=!1,i=t||n.tr,s=this.buildProps(i,r);return{...Object.fromEntries(Object.entries(e).map(([l,c])=>[l,(...d)=>c(...d)({...s,dispatch:void 0})])),chain:()=>this.createChain(i,r)}}buildProps(t,e=!0){const{rawCommands:n,editor:r,state:i}=this,{view:s}=r,o={tr:t,editor:r,view:s,state:oA({state:i,transaction:t}),dispatch:e?()=>{}:void 0,chain:()=>this.createChain(t,e),can:()=>this.createCan(t),get commands(){return Object.fromEntries(Object.entries(n).map(([l,c])=>[l,(...d)=>c(...d)(o)]))}};return o}},Hse={};Hz(Hse,{blur:()=>TKe,clearContent:()=>AKe,clearNodes:()=>jKe,command:()=>MKe,createParagraphNear:()=>EKe,cut:()=>DKe,deleteCurrentNode:()=>FKe,deleteNode:()=>RKe,deleteRange:()=>LKe,deleteSelection:()=>OKe,enter:()=>IKe,exitCode:()=>zKe,extendMarkRange:()=>UKe,first:()=>BKe,focus:()=>qKe,forEach:()=>$Ke,insertContent:()=>VKe,insertContentAt:()=>KKe,joinBackward:()=>QKe,joinDown:()=>YKe,joinForward:()=>ZKe,joinItemBackward:()=>JKe,joinItemForward:()=>eXe,joinTextblockBackward:()=>tXe,joinTextblockForward:()=>nXe,joinUp:()=>XKe,keyboardShortcut:()=>aXe,lift:()=>iXe,liftEmptyBlock:()=>sXe,liftListItem:()=>oXe,newlineInCode:()=>lXe,resetAttributes:()=>cXe,scrollIntoView:()=>dXe,selectAll:()=>uXe,selectNodeBackward:()=>mXe,selectNodeForward:()=>hXe,selectParentNode:()=>pXe,selectTextblockEnd:()=>fXe,selectTextblockStart:()=>gXe,setContent:()=>bXe,setMark:()=>OXe,setMeta:()=>IXe,setNode:()=>zXe,setNodeSelection:()=>UXe,setTextDirection:()=>BXe,setTextSelection:()=>HXe,sinkListItem:()=>qXe,splitBlock:()=>$Xe,splitListItem:()=>VXe,toggleList:()=>GXe,toggleMark:()=>WXe,toggleNode:()=>KXe,toggleWrap:()=>XXe,undoInputRule:()=>YXe,unsetAllMarks:()=>QXe,unsetMark:()=>ZXe,unsetTextDirection:()=>JXe,updateAttributes:()=>eYe,wrapIn:()=>tYe,wrapInList:()=>nYe});var TKe=()=>({editor:t,view:e})=>(requestAnimationFrame(()=>{var n;t.isDestroyed||(e.dom.blur(),(n=window?.getSelection())==null||n.removeAllRanges())}),!0),AKe=(t=!0)=>({commands:e})=>e.setContent(\"\",{emitUpdate:t}),jKe=()=>({state:t,tr:e,dispatch:n})=>{const{selection:r}=e,{ranges:i}=r;return n&&i.forEach(({$from:s,$to:o})=>{t.doc.nodesBetween(s.pos,o.pos,(l,c)=>{if(l.type.isText)return;const{doc:d,mapping:u}=e,m=d.resolve(u.map(c)),p=d.resolve(u.map(c+l.nodeSize)),f=m.blockRange(p);if(!f)return;const y=zy(f);if(l.type.isTextblock){const{defaultType:v}=m.parent.contentMatchAt(m.index());e.setNodeMarkup(f.start,v)}(y||y===0)&&e.lift(f,y)})}),!0},MKe=t=>e=>t(e),EKe=()=>({state:t,dispatch:e})=>nse(t,e),DKe=(t,e)=>({editor:n,tr:r})=>{const{state:i}=n,s=i.doc.slice(t.from,t.to);r.deleteRange(t.from,t.to);const o=r.mapping.map(e);return r.insert(o,s.content),r.setSelection(new $n(r.doc.resolve(Math.max(o-1,0)))),!0},FKe=()=>({tr:t,dispatch:e})=>{const{selection:n}=t,r=n.$anchor.node();if(r.content.size>0)return!1;const i=t.selection.$anchor;for(let s=i.depth;s>0;s-=1)if(i.node(s).type===r.type){if(e){const l=i.before(s),c=i.after(s);t.delete(l,c).scrollIntoView()}return!0}return!1};function Ni(t,e){if(typeof t==\"string\"){if(!e.nodes[t])throw Error(`There is no node type named '${t}'. Maybe you forgot to add the extension?`);return e.nodes[t]}return t}var RKe=t=>({tr:e,state:n,dispatch:r})=>{const i=Ni(t,n.schema),s=e.selection.$anchor;for(let o=s.depth;o>0;o-=1)if(s.node(o).type===i){if(r){const c=s.before(o),d=s.after(o);e.delete(c,d).scrollIntoView()}return!0}return!1},LKe=t=>({tr:e,dispatch:n})=>{const{from:r,to:i}=t;return n&&e.delete(r,i),!0},OKe=()=>({state:t,dispatch:e})=>Tz(t,e),IKe=()=>({commands:t})=>t.keyboardShortcut(\"Enter\"),zKe=()=>({state:t,dispatch:e})=>S9e(t,e);function qz(t){return Object.prototype.toString.call(t)===\"[object RegExp]\"}function ZC(t,e,n={strict:!0}){const r=Object.keys(e);return r.length?r.every(i=>n.strict?e[i]===t[i]:qz(e[i])?e[i].test(t[i]):e[i]===t[i]):!0}function qse(t,e,n={}){return t.find(r=>r.type===e&&ZC(Object.fromEntries(Object.keys(n).map(i=>[i,r.attrs[i]])),n))}function SX(t,e,n={}){return!!qse(t,e,n)}function $z(t,e,n){var r;if(!t||!e)return;let i=t.parent.childAfter(t.parentOffset);if((!i.node||!i.node.marks.some(u=>u.type===e))&&(i=t.parent.childBefore(t.parentOffset)),!i.node||!i.node.marks.some(u=>u.type===e)||(n=n||((r=i.node.marks[0])==null?void 0:r.attrs),!qse([...i.node.marks],e,n)))return;let o=i.index,l=t.start()+i.offset,c=o+1,d=l+i.node.nodeSize;for(;o>0&&SX([...t.parent.child(o-1).marks],e,n);)o-=1,l-=t.parent.child(o).nodeSize;for(;c<t.parent.childCount&&SX([...t.parent.child(c).marks],e,n);)d+=t.parent.child(c).nodeSize,c+=1;return{from:l,to:d}}function Rm(t,e){if(typeof t==\"string\"){if(!e.marks[t])throw Error(`There is no mark type named '${t}'. Maybe you forgot to add the extension?`);return e.marks[t]}return t}var UKe=(t,e={})=>({tr:n,state:r,dispatch:i})=>{const s=Rm(t,r.schema),{doc:o,selection:l}=n,{$from:c,from:d,to:u}=l;if(i){const m=$z(c,s,e);if(m&&m.from<=d&&m.to>=u){const p=$n.create(o,m.from,m.to);n.setSelection(p)}}return!0},BKe=t=>e=>{const n=typeof t==\"function\"?t(e):t;for(let r=0;r<n.length;r+=1)if(n[r](e))return!0;return!1};function $se(t){return t instanceof $n}function Gf(t=0,e=0,n=0){return Math.min(Math.max(t,e),n)}function Vse(t,e=null){if(!e)return null;const n=rr.atStart(t),r=rr.atEnd(t);if(e===\"start\"||e===!0)return n;if(e===\"end\")return r;const i=n.from,s=r.to;return e===\"all\"?$n.create(t,Gf(0,i,s),Gf(t.content.size,i,s)):$n.create(t,Gf(e,i,s),Gf(e,i,s))}function _X(){return navigator.platform===\"Android\"||/android/i.test(navigator.userAgent)}function JC(){return[\"iPad Simulator\",\"iPhone Simulator\",\"iPod Simulator\",\"iPad\",\"iPhone\",\"iPod\"].includes(navigator.platform)||navigator.userAgent.includes(\"Mac\")&&\"ontouchend\"in document}function HKe(){return typeof navigator<\"u\"?/^((?!chrome|android).)*safari/i.test(navigator.userAgent):!1}var qKe=(t=null,e={})=>({editor:n,view:r,tr:i,dispatch:s})=>{e={scrollIntoView:!0,...e};const o=()=>{(JC()||_X())&&r.dom.focus(),HKe()&&!JC()&&!_X()&&r.dom.focus({preventScroll:!0}),requestAnimationFrame(()=>{n.isDestroyed||(r.focus(),e?.scrollIntoView&&n.commands.scrollIntoView())})};try{if(r.hasFocus()&&t===null||t===!1)return!0}catch{return!1}if(s&&t===null&&!$se(n.state.selection))return o(),!0;const l=Vse(i.doc,t)||n.state.selection,c=n.state.selection.eq(l);return s&&(c||i.setSelection(l),c&&i.storedMarks&&i.setStoredMarks(i.storedMarks),o()),!0},$Ke=(t,e)=>n=>t.every((r,i)=>e(r,{...n,index:i})),VKe=(t,e)=>({tr:n,commands:r})=>r.insertContentAt({from:n.selection.from,to:n.selection.to},t,e),Gse=t=>{const e=t.childNodes;for(let n=e.length-1;n>=0;n-=1){const r=e[n];r.nodeType===3&&r.nodeValue&&/^(\\n\\s\\s|\\n)$/.test(r.nodeValue)?t.removeChild(r):r.nodeType===1&&Gse(r)}return t};function vk(t){if(typeof window>\"u\")throw new Error(\"[tiptap error]: there is no window object available, so this function cannot be used\");const e=`<body>${t}</body>`,n=new window.DOMParser().parseFromString(e,\"text/html\").body;return Gse(n)}function Bw(t,e,n){if(t instanceof Yc||t instanceof Vt)return t;n={slice:!0,parseOptions:{},...n};const r=typeof t==\"object\"&&t!==null,i=typeof t==\"string\";if(r)try{if(Array.isArray(t)&&t.length>0)return Vt.fromArray(t.map(l=>e.nodeFromJSON(l)));const o=e.nodeFromJSON(t);return n.errorOnInvalidContent&&o.check(),o}catch(s){if(n.errorOnInvalidContent)throw new Error(\"[tiptap error]: Invalid JSON content\",{cause:s});return console.warn(\"[tiptap warn]: Invalid content.\",\"Passed value:\",t,\"Error:\",s),Bw(\"\",e,n)}if(i){if(n.errorOnInvalidContent){let o=!1,l=\"\";const c=new Aie({topNode:e.spec.topNode,marks:e.spec.marks,nodes:e.spec.nodes.append({__tiptap__private__unknown__catch__all__node:{content:\"inline*\",group:\"block\",parseDOM:[{tag:\"*\",getAttrs:d=>(o=!0,l=typeof d==\"string\"?d:d.outerHTML,null)}]}})});if(n.slice?z0.fromSchema(c).parseSlice(vk(t),n.parseOptions):z0.fromSchema(c).parse(vk(t),n.parseOptions),n.errorOnInvalidContent&&o)throw new Error(\"[tiptap error]: Invalid HTML content\",{cause:new Error(`Invalid element found: ${l}`)})}const s=z0.fromSchema(e);return n.slice?s.parseSlice(vk(t),n.parseOptions).content:s.parse(vk(t),n.parseOptions)}return Bw(\"\",e,n)}function GKe(t,e,n){const r=t.steps.length-1;if(r<e)return;const i=t.steps[r];if(!(i instanceof Yi||i instanceof Zi))return;const s=t.mapping.maps[r];let o=0;s.forEach((l,c,d,u)=>{o===0&&(o=u)}),t.setSelection(rr.near(t.doc.resolve(o),n))}var WKe=t=>!(\"type\"in t),KKe=(t,e,n)=>({tr:r,dispatch:i,editor:s})=>{var o;if(i){n={parseOptions:s.options.parseOptions,updateSelection:!0,applyInputRules:!1,applyPasteRules:!1,...n};let l;const c=b=>{s.emit(\"contentError\",{editor:s,error:b,disableCollaboration:()=>{\"collaboration\"in s.storage&&typeof s.storage.collaboration==\"object\"&&s.storage.collaboration&&(s.storage.collaboration.isDisabled=!0)}})},d={preserveWhitespace:\"full\",...n.parseOptions};if(!n.errorOnInvalidContent&&!s.options.enableContentCheck&&s.options.emitContentError)try{Bw(e,s.schema,{parseOptions:d,errorOnInvalidContent:!0})}catch(b){c(b)}try{l=Bw(e,s.schema,{parseOptions:d,errorOnInvalidContent:(o=n.errorOnInvalidContent)!=null?o:s.options.enableContentCheck})}catch(b){return c(b),!1}let{from:u,to:m}=typeof t==\"number\"?{from:t,to:t}:{from:t.from,to:t.to},p=!0,f=!0;if((WKe(l)?l:[l]).forEach(b=>{b.check(),p=p?b.isText&&b.marks.length===0:!1,f=f?b.isBlock:!1}),u===m&&f){const{parent:b}=r.doc.resolve(u);b.isTextblock&&!b.type.spec.code&&!b.childCount&&(u-=1,m+=1)}let v;if(p){if(Array.isArray(e))v=e.map(b=>b.text||\"\").join(\"\");else if(e instanceof Vt){let b=\"\";e.forEach(g=>{g.text&&(b+=g.text)}),v=b}else typeof e==\"object\"&&e&&e.text?v=e.text:v=e;r.insertText(v,u,m)}else{v=l;const b=r.doc.resolve(u),g=b.node(),_=b.parentOffset===0,C=g.isText||g.isTextblock,P=g.content.size>0;_&&C&&P&&(u=Math.max(0,u-1)),r.replaceWith(u,m,v)}n.updateSelection&&GKe(r,r.steps.length-1,-1),n.applyInputRules&&r.setMeta(\"applyInputRules\",{from:u,text:v}),n.applyPasteRules&&r.setMeta(\"applyPasteRules\",{from:u,text:v})}return!0},XKe=()=>({state:t,dispatch:e})=>y9e(t,e),YKe=()=>({state:t,dispatch:e})=>v9e(t,e),QKe=()=>({state:t,dispatch:e})=>Xie(t,e),ZKe=()=>({state:t,dispatch:e})=>Jie(t,e),JKe=()=>({state:t,dispatch:e,tr:n})=>{try{const r=tA(t.doc,t.selection.$from.pos,-1);return r==null?!1:(n.join(r,2),e&&e(n),!0)}catch{return!1}},eXe=()=>({state:t,dispatch:e,tr:n})=>{try{const r=tA(t.doc,t.selection.$from.pos,1);return r==null?!1:(n.join(r,2),e&&e(n),!0)}catch{return!1}},tXe=()=>({state:t,dispatch:e})=>b9e(t,e),nXe=()=>({state:t,dispatch:e})=>x9e(t,e);function Wse(){return typeof navigator<\"u\"?/Mac/.test(navigator.platform):!1}function rXe(t){const e=t.split(/-(?!$)/);let n=e[e.length-1];n===\"Space\"&&(n=\" \");let r,i,s,o;for(let l=0;l<e.length-1;l+=1){const c=e[l];if(/^(cmd|meta|m)$/i.test(c))o=!0;else if(/^a(lt)?$/i.test(c))r=!0;else if(/^(c|ctrl|control)$/i.test(c))i=!0;else if(/^s(hift)?$/i.test(c))s=!0;else if(/^mod$/i.test(c))JC()||Wse()?o=!0:i=!0;else throw new Error(`Unrecognized modifier name: ${c}`)}return r&&(n=`Alt-${n}`),i&&(n=`Ctrl-${n}`),o&&(n=`Meta-${n}`),s&&(n=`Shift-${n}`),n}var aXe=t=>({editor:e,view:n,tr:r,dispatch:i})=>{const s=rXe(t).split(/-(?!$)/),o=s.find(d=>![\"Alt\",\"Ctrl\",\"Meta\",\"Shift\"].includes(d)),l=new KeyboardEvent(\"keydown\",{key:o===\"Space\"?\" \":o,altKey:s.includes(\"Alt\"),ctrlKey:s.includes(\"Ctrl\"),metaKey:s.includes(\"Meta\"),shiftKey:s.includes(\"Shift\"),bubbles:!0,cancelable:!0}),c=e.captureTransaction(()=>{n.someProp(\"handleKeyDown\",d=>d(n,l))});return c?.steps.forEach(d=>{const u=d.map(r.mapping);u&&i&&r.maybeStep(u)}),!0};function Np(t,e,n={}){const{from:r,to:i,empty:s}=t.selection,o=e?Ni(e,t.schema):null,l=[];t.doc.nodesBetween(r,i,(m,p)=>{if(m.isText)return;const f=Math.max(r,p),y=Math.min(i,p+m.nodeSize);l.push({node:m,from:f,to:y})});const c=i-r,d=l.filter(m=>o?o.name===m.node.type.name:!0).filter(m=>ZC(m.node.attrs,n,{strict:!1}));return s?!!d.length:d.reduce((m,p)=>m+p.to-p.from,0)>=c}var iXe=(t,e={})=>({state:n,dispatch:r})=>{const i=Ni(t,n.schema);return Np(n,i,e)?w9e(n,r):!1},sXe=()=>({state:t,dispatch:e})=>rse(t,e),oXe=t=>({state:e,dispatch:n})=>{const r=Ni(t,e.schema);return D9e(r)(e,n)},lXe=()=>({state:t,dispatch:e})=>tse(t,e);function cA(t,e){return e.nodes[t]?\"node\":e.marks[t]?\"mark\":null}function kX(t,e){const n=typeof e==\"string\"?[e]:e;return Object.keys(t).reduce((r,i)=>(n.includes(i)||(r[i]=t[i]),r),{})}var cXe=(t,e)=>({tr:n,state:r,dispatch:i})=>{let s=null,o=null;const l=cA(typeof t==\"string\"?t:t.name,r.schema);if(!l)return!1;l===\"node\"&&(s=Ni(t,r.schema)),l===\"mark\"&&(o=Rm(t,r.schema));let c=!1;return n.selection.ranges.forEach(d=>{r.doc.nodesBetween(d.$from.pos,d.$to.pos,(u,m)=>{s&&s===u.type&&(c=!0,i&&n.setNodeMarkup(m,void 0,kX(u.attrs,e))),o&&u.marks.length&&u.marks.forEach(p=>{o===p.type&&(c=!0,i&&n.addMark(m,m+u.nodeSize,o.create(kX(p.attrs,e))))})})}),c},dXe=()=>({tr:t,dispatch:e})=>(e&&t.scrollIntoView(),!0),uXe=()=>({tr:t,dispatch:e})=>{if(e){const n=new bl(t.doc);t.setSelection(n)}return!0},mXe=()=>({state:t,dispatch:e})=>Qie(t,e),hXe=()=>({state:t,dispatch:e})=>ese(t,e),pXe=()=>({state:t,dispatch:e})=>N9e(t,e),fXe=()=>({state:t,dispatch:e})=>T9e(t,e),gXe=()=>({state:t,dispatch:e})=>P9e(t,e);function UL(t,e,n={},r={}){return Bw(t,e,{slice:!1,parseOptions:n,errorOnInvalidContent:r.errorOnInvalidContent})}var bXe=(t,{errorOnInvalidContent:e,emitUpdate:n=!0,parseOptions:r={}}={})=>({editor:i,tr:s,dispatch:o,commands:l})=>{const{doc:c}=s;if(r.preserveWhitespace!==\"full\"){const d=UL(t,i.schema,r,{errorOnInvalidContent:e??i.options.enableContentCheck});return o&&s.replaceWith(0,c.content.size,d).setMeta(\"preventUpdate\",!n),!0}return o&&s.setMeta(\"preventUpdate\",!n),l.insertContentAt({from:0,to:c.content.size},t,{parseOptions:r,errorOnInvalidContent:e??i.options.enableContentCheck})};function Kse(t,e){const n=Rm(e,t.schema),{from:r,to:i,empty:s}=t.selection,o=[];s?(t.storedMarks&&o.push(...t.storedMarks),o.push(...t.selection.$head.marks())):t.doc.nodesBetween(r,i,c=>{o.push(...c.marks)});const l=o.find(c=>c.type.name===n.name);return l?{...l.attrs}:{}}function Xse(t,e){const n=new Vie(t);return e.forEach(r=>{r.steps.forEach(i=>{n.step(i)})}),n}function xXe(t){for(let e=0;e<t.edgeCount;e+=1){const{type:n}=t.edge(e);if(n.isTextblock&&!n.hasRequiredAttrs())return n}return null}function yXe(t,e,n){const r=[];return t.nodesBetween(e.from,e.to,(i,s)=>{n(i)&&r.push({node:i,pos:s})}),r}function vXe(t,e){for(let n=t.depth;n>0;n-=1){const r=t.node(n);if(e(r))return{pos:n>0?t.before(n):0,start:t.start(n),depth:n,node:r}}}function dA(t){return e=>vXe(e.$from,t)}function Pn(t,e,n){return t.config[e]===void 0&&t.parent?Pn(t.parent,e,n):typeof t.config[e]==\"function\"?t.config[e].bind({...n,parent:t.parent?Pn(t.parent,e,n):null}):t.config[e]}function Vz(t){return t.map(e=>{const n={name:e.name,options:e.options,storage:e.storage},r=Pn(e,\"addExtensions\",n);return r?[e,...Vz(r())]:e}).flat(10)}function Gz(t,e){const n=$g.fromSchema(e).serializeFragment(t),i=document.implementation.createHTMLDocument().createElement(\"div\");return i.appendChild(n),i.innerHTML}function Yse(t){return typeof t==\"function\"}function $r(t,e=void 0,...n){return Yse(t)?e?t.bind(e)(...n):t(...n):t}function wXe(t={}){return Object.keys(t).length===0&&t.constructor===Object}function my(t){const e=t.filter(i=>i.type===\"extension\"),n=t.filter(i=>i.type===\"node\"),r=t.filter(i=>i.type===\"mark\");return{baseExtensions:e,nodeExtensions:n,markExtensions:r}}function Qse(t){const e=[],{nodeExtensions:n,markExtensions:r}=my(t),i=[...n,...r],s={default:null,validate:void 0,rendered:!0,renderHTML:null,parseHTML:null,keepOnSplit:!0,isRequired:!1};return t.forEach(o=>{const l={name:o.name,options:o.options,storage:o.storage,extensions:i},c=Pn(o,\"addGlobalAttributes\",l);if(!c)return;c().forEach(u=>{u.types.forEach(m=>{Object.entries(u.attributes).forEach(([p,f])=>{e.push({type:m,name:p,attribute:{...s,...f}})})})})}),i.forEach(o=>{const l={name:o.name,options:o.options,storage:o.storage},c=Pn(o,\"addAttributes\",l);if(!c)return;const d=c();Object.entries(d).forEach(([u,m])=>{const p={...s,...m};typeof p?.default==\"function\"&&(p.default=p.default()),p?.isRequired&&p?.default===void 0&&delete p.default,e.push({type:o.name,name:u,attribute:p})})}),e}function ni(...t){return t.filter(e=>!!e).reduce((e,n)=>{const r={...e};return Object.entries(n).forEach(([i,s])=>{if(!r[i]){r[i]=s;return}if(i===\"class\"){const l=s?String(s).split(\" \"):[],c=r[i]?r[i].split(\" \"):[],d=l.filter(u=>!c.includes(u));r[i]=[...c,...d].join(\" \")}else if(i===\"style\"){const l=s?s.split(\";\").map(u=>u.trim()).filter(Boolean):[],c=r[i]?r[i].split(\";\").map(u=>u.trim()).filter(Boolean):[],d=new Map;c.forEach(u=>{const[m,p]=u.split(\":\").map(f=>f.trim());d.set(m,p)}),l.forEach(u=>{const[m,p]=u.split(\":\").map(f=>f.trim());d.set(m,p)}),r[i]=Array.from(d.entries()).map(([u,m])=>`${u}: ${m}`).join(\"; \")}else r[i]=s}),r},{})}function Hw(t,e){return e.filter(n=>n.type===t.type.name).filter(n=>n.attribute.rendered).map(n=>n.attribute.renderHTML?n.attribute.renderHTML(t.attrs)||{}:{[n.name]:t.attrs[n.name]}).reduce((n,r)=>ni(n,r),{})}function SXe(t){return typeof t!=\"string\"?t:t.match(/^[+-]?(?:\\d*\\.)?\\d+$/)?Number(t):t===\"true\"?!0:t===\"false\"?!1:t}function NX(t,e){return\"style\"in t?t:{...t,getAttrs:n=>{const r=t.getAttrs?t.getAttrs(n):t.attrs;if(r===!1)return!1;const i=e.reduce((s,o)=>{const l=o.attribute.parseHTML?o.attribute.parseHTML(n):SXe(n.getAttribute(o.name));return l==null?s:{...s,[o.name]:l}},{});return{...r,...i}}}}function CX(t){return Object.fromEntries(Object.entries(t).filter(([e,n])=>e===\"attrs\"&&wXe(n)?!1:n!=null))}function PX(t){var e,n;const r={};return!((e=t?.attribute)!=null&&e.isRequired)&&\"default\"in(t?.attribute||{})&&(r.default=t.attribute.default),((n=t?.attribute)==null?void 0:n.validate)!==void 0&&(r.validate=t.attribute.validate),[t.name,r]}function _Xe(t,e){var n;const r=Qse(t),{nodeExtensions:i,markExtensions:s}=my(t),o=(n=i.find(d=>Pn(d,\"topNode\")))==null?void 0:n.name,l=Object.fromEntries(i.map(d=>{const u=r.filter(g=>g.type===d.name),m={name:d.name,options:d.options,storage:d.storage,editor:e},p=t.reduce((g,_)=>{const C=Pn(_,\"extendNodeSchema\",m);return{...g,...C?C(d):{}}},{}),f=CX({...p,content:$r(Pn(d,\"content\",m)),marks:$r(Pn(d,\"marks\",m)),group:$r(Pn(d,\"group\",m)),inline:$r(Pn(d,\"inline\",m)),atom:$r(Pn(d,\"atom\",m)),selectable:$r(Pn(d,\"selectable\",m)),draggable:$r(Pn(d,\"draggable\",m)),code:$r(Pn(d,\"code\",m)),whitespace:$r(Pn(d,\"whitespace\",m)),linebreakReplacement:$r(Pn(d,\"linebreakReplacement\",m)),defining:$r(Pn(d,\"defining\",m)),isolating:$r(Pn(d,\"isolating\",m)),attrs:Object.fromEntries(u.map(PX))}),y=$r(Pn(d,\"parseHTML\",m));y&&(f.parseDOM=y.map(g=>NX(g,u)));const v=Pn(d,\"renderHTML\",m);v&&(f.toDOM=g=>v({node:g,HTMLAttributes:Hw(g,u)}));const b=Pn(d,\"renderText\",m);return b&&(f.toText=b),[d.name,f]})),c=Object.fromEntries(s.map(d=>{const u=r.filter(b=>b.type===d.name),m={name:d.name,options:d.options,storage:d.storage,editor:e},p=t.reduce((b,g)=>{const _=Pn(g,\"extendMarkSchema\",m);return{...b,..._?_(d):{}}},{}),f=CX({...p,inclusive:$r(Pn(d,\"inclusive\",m)),excludes:$r(Pn(d,\"excludes\",m)),group:$r(Pn(d,\"group\",m)),spanning:$r(Pn(d,\"spanning\",m)),code:$r(Pn(d,\"code\",m)),attrs:Object.fromEntries(u.map(PX))}),y=$r(Pn(d,\"parseHTML\",m));y&&(f.parseDOM=y.map(b=>NX(b,u)));const v=Pn(d,\"renderHTML\",m);return v&&(f.toDOM=b=>v({mark:b,HTMLAttributes:Hw(b,u)})),[d.name,f]}));return new Aie({topNode:o,nodes:l,marks:c})}function kXe(t){const e=t.filter((n,r)=>t.indexOf(n)!==r);return Array.from(new Set(e))}function eP(t){return t.sort((n,r)=>{const i=Pn(n,\"priority\")||100,s=Pn(r,\"priority\")||100;return i>s?-1:i<s?1:0})}function Zse(t){const e=eP(Vz(t)),n=kXe(e.map(r=>r.name));return n.length&&console.warn(`[tiptap warn]: Duplicate extension names found: [${n.map(r=>`'${r}'`).join(\", \")}]. This can lead to issues.`),e}function Jse(t,e,n){const{from:r,to:i}=e,{blockSeparator:s=`\n\n`,textSerializers:o={}}=n||{};let l=\"\";return t.nodesBetween(r,i,(c,d,u,m)=>{var p;c.isBlock&&d>r&&(l+=s);const f=o?.[c.type.name];if(f)return u&&(l+=f({node:c,pos:d,parent:u,index:m,range:e})),!1;c.isText&&(l+=(p=c?.text)==null?void 0:p.slice(Math.max(r,d)-d,i-d))}),l}function NXe(t,e){const n={from:0,to:t.content.size};return Jse(t,n,e)}function eoe(t){return Object.fromEntries(Object.entries(t.nodes).filter(([,e])=>e.spec.toText).map(([e,n])=>[e,n.spec.toText]))}function CXe(t,e){const n=Ni(e,t.schema),{from:r,to:i}=t.selection,s=[];t.doc.nodesBetween(r,i,l=>{s.push(l)});const o=s.reverse().find(l=>l.type.name===n.name);return o?{...o.attrs}:{}}function toe(t,e){const n=cA(typeof e==\"string\"?e:e.name,t.schema);return n===\"node\"?CXe(t,e):n===\"mark\"?Kse(t,e):{}}function PXe(t,e=JSON.stringify){const n={};return t.filter(r=>{const i=e(r);return Object.prototype.hasOwnProperty.call(n,i)?!1:n[i]=!0})}function TXe(t){const e=PXe(t);return e.length===1?e:e.filter((n,r)=>!e.filter((s,o)=>o!==r).some(s=>n.oldRange.from>=s.oldRange.from&&n.oldRange.to<=s.oldRange.to&&n.newRange.from>=s.newRange.from&&n.newRange.to<=s.newRange.to))}function noe(t){const{mapping:e,steps:n}=t,r=[];return e.maps.forEach((i,s)=>{const o=[];if(i.ranges.length)i.forEach((l,c)=>{o.push({from:l,to:c})});else{const{from:l,to:c}=n[s];if(l===void 0||c===void 0)return;o.push({from:l,to:c})}o.forEach(({from:l,to:c})=>{const d=e.slice(s).map(l,-1),u=e.slice(s).map(c),m=e.invert().map(d,-1),p=e.invert().map(u);r.push({oldRange:{from:m,to:p},newRange:{from:d,to:u}})})}),TXe(r)}function Wz(t,e,n){const r=[];return t===e?n.resolve(t).marks().forEach(i=>{const s=n.resolve(t),o=$z(s,i.type);o&&r.push({mark:i,...o})}):n.nodesBetween(t,e,(i,s)=>{!i||i?.nodeSize===void 0||r.push(...i.marks.map(o=>({from:s,to:s+i.nodeSize,mark:o})))}),r}var AXe=(t,e,n,r=20)=>{const i=t.doc.resolve(n);let s=r,o=null;for(;s>0&&o===null;){const l=i.node(s);l?.type.name===e?o=l:s-=1}return[o,s]};function wk(t,e){return e.nodes[t]||e.marks[t]||null}function iN(t,e,n){return Object.fromEntries(Object.entries(n).filter(([r])=>{const i=t.find(s=>s.type===e&&s.name===r);return i?i.attribute.keepOnSplit:!1}))}var jXe=(t,e=500)=>{let n=\"\";const r=t.parentOffset;return t.parent.nodesBetween(Math.max(0,r-e),r,(i,s,o,l)=>{var c,d;const u=((d=(c=i.type.spec).toText)==null?void 0:d.call(c,{node:i,pos:s,parent:o,index:l}))||i.textContent||\"%leaf%\";n+=i.isAtom&&!i.isText?u:u.slice(0,Math.max(0,r-s))}),n};function BL(t,e,n={}){const{empty:r,ranges:i}=t.selection,s=e?Rm(e,t.schema):null;if(r)return!!(t.storedMarks||t.selection.$from.marks()).filter(m=>s?s.name===m.type.name:!0).find(m=>ZC(m.attrs,n,{strict:!1}));let o=0;const l=[];if(i.forEach(({$from:m,$to:p})=>{const f=m.pos,y=p.pos;t.doc.nodesBetween(f,y,(v,b)=>{if(!v.isText&&!v.marks.length)return;const g=Math.max(f,b),_=Math.min(y,b+v.nodeSize),C=_-g;o+=C,l.push(...v.marks.map(P=>({mark:P,from:g,to:_})))})}),o===0)return!1;const c=l.filter(m=>s?s.name===m.mark.type.name:!0).filter(m=>ZC(m.mark.attrs,n,{strict:!1})).reduce((m,p)=>m+p.to-p.from,0),d=l.filter(m=>s?m.mark.type!==s&&m.mark.type.excludes(s):!0).reduce((m,p)=>m+p.to-p.from,0);return(c>0?c+d:c)>=o}function MXe(t,e,n={}){if(!e)return Np(t,null,n)||BL(t,null,n);const r=cA(e,t.schema);return r===\"node\"?Np(t,e,n):r===\"mark\"?BL(t,e,n):!1}var EXe=(t,e)=>{const{$from:n,$to:r,$anchor:i}=t.selection;if(e){const s=dA(l=>l.type.name===e)(t.selection);if(!s)return!1;const o=t.doc.resolve(s.pos+1);return i.pos+1===o.end()}return!(r.parentOffset<r.parent.nodeSize-2||n.pos!==r.pos)},DXe=t=>{const{$from:e,$to:n}=t.selection;return!(e.parentOffset>0||e.pos!==n.pos)};function TX(t,e){return Array.isArray(e)?e.some(n=>(typeof n==\"string\"?n:n.name)===t.name):e}function AX(t,e){const{nodeExtensions:n}=my(e),r=n.find(o=>o.name===t);if(!r)return!1;const i={name:r.name,options:r.options,storage:r.storage},s=$r(Pn(r,\"group\",i));return typeof s!=\"string\"?!1:s.split(\" \").includes(\"list\")}function uA(t,{checkChildren:e=!0,ignoreWhitespace:n=!1}={}){var r;if(n){if(t.type.name===\"hardBreak\")return!0;if(t.isText)return/^\\s*$/m.test((r=t.text)!=null?r:\"\")}if(t.isText)return!t.text;if(t.isAtom||t.isLeaf)return!1;if(t.content.childCount===0)return!0;if(e){let i=!0;return t.content.forEach(s=>{i!==!1&&(uA(s,{ignoreWhitespace:n,checkChildren:e})||(i=!1))}),i}return!1}function roe(t){return t instanceof An}var aoe=class ioe{constructor(e){this.position=e}static fromJSON(e){return new ioe(e.position)}toJSON(){return{position:this.position}}};function FXe(t,e){const n=e.mapping.mapResult(t.position);return{position:new aoe(n.pos),mapResult:n}}function RXe(t){return new aoe(t)}function LXe(t,e,n){var r;const{selection:i}=e;let s=null;if($se(i)&&(s=i.$cursor),s){const l=(r=t.storedMarks)!=null?r:s.marks();return s.parent.type.allowsMarkType(n)&&(!!n.isInSet(l)||!l.some(d=>d.type.excludes(n)))}const{ranges:o}=i;return o.some(({$from:l,$to:c})=>{let d=l.depth===0?t.doc.inlineContent&&t.doc.type.allowsMarkType(n):!1;return t.doc.nodesBetween(l.pos,c.pos,(u,m,p)=>{if(d)return!1;if(u.isInline){const f=!p||p.type.allowsMarkType(n),y=!!n.isInSet(u.marks)||!u.marks.some(v=>v.type.excludes(n));d=f&&y}return!d}),d})}var OXe=(t,e={})=>({tr:n,state:r,dispatch:i})=>{const{selection:s}=n,{empty:o,ranges:l}=s,c=Rm(t,r.schema);if(i)if(o){const d=Kse(r,c);n.addStoredMark(c.create({...d,...e}))}else l.forEach(d=>{const u=d.$from.pos,m=d.$to.pos;r.doc.nodesBetween(u,m,(p,f)=>{const y=Math.max(f,u),v=Math.min(f+p.nodeSize,m);p.marks.find(g=>g.type===c)?p.marks.forEach(g=>{c===g.type&&n.addMark(y,v,c.create({...g.attrs,...e}))}):n.addMark(y,v,c.create(e))})});return LXe(r,n,c)},IXe=(t,e)=>({tr:n})=>(n.setMeta(t,e),!0),zXe=(t,e={})=>({state:n,dispatch:r,chain:i})=>{const s=Ni(t,n.schema);let o;return n.selection.$anchor.sameParent(n.selection.$head)&&(o=n.selection.$anchor.parent.attrs),s.isTextblock?i().command(({commands:l})=>HK(s,{...o,...e})(n)?!0:l.clearNodes()).command(({state:l})=>HK(s,{...o,...e})(l,r)).run():(console.warn('[tiptap warn]: Currently \"setNode()\" only supports text block nodes.'),!1)},UXe=t=>({tr:e,dispatch:n})=>{if(n){const{doc:r}=e,i=Gf(t,0,r.content.size),s=An.create(r,i);e.setSelection(s)}return!0},BXe=(t,e)=>({tr:n,state:r,dispatch:i})=>{const{selection:s}=r;let o,l;return typeof e==\"number\"?(o=e,l=e):e&&\"from\"in e&&\"to\"in e?(o=e.from,l=e.to):(o=s.from,l=s.to),i&&n.doc.nodesBetween(o,l,(c,d)=>{c.isText||n.setNodeMarkup(d,void 0,{...c.attrs,dir:t})}),!0},HXe=t=>({tr:e,dispatch:n})=>{if(n){const{doc:r}=e,{from:i,to:s}=typeof t==\"number\"?{from:t,to:t}:t,o=$n.atStart(r).from,l=$n.atEnd(r).to,c=Gf(i,o,l),d=Gf(s,o,l),u=$n.create(r,c,d);e.setSelection(u)}return!0},qXe=t=>({state:e,dispatch:n})=>{const r=Ni(t,e.schema);return L9e(r)(e,n)};function jX(t,e){const n=t.storedMarks||t.selection.$to.parentOffset&&t.selection.$from.marks();if(n){const r=n.filter(i=>e?.includes(i.type.name));t.tr.ensureMarks(r)}}var $Xe=({keepMarks:t=!0}={})=>({tr:e,state:n,dispatch:r,editor:i})=>{const{selection:s,doc:o}=e,{$from:l,$to:c}=s,d=i.extensionManager.attributes,u=iN(d,l.node().type.name,l.node().attrs);if(s instanceof An&&s.node.isBlock)return!l.parentOffset||!hm(o,l.pos)?!1:(r&&(t&&jX(n,i.extensionManager.splittableMarks),e.split(l.pos).scrollIntoView()),!0);if(!l.parent.isBlock)return!1;const m=c.parentOffset===c.parent.content.size,p=l.depth===0?void 0:xXe(l.node(-1).contentMatchAt(l.indexAfter(-1)));let f=m&&p?[{type:p,attrs:u}]:void 0,y=hm(e.doc,e.mapping.map(l.pos),1,f);if(!f&&!y&&hm(e.doc,e.mapping.map(l.pos),1,p?[{type:p}]:void 0)&&(y=!0,f=p?[{type:p,attrs:u}]:void 0),r){if(y&&(s instanceof $n&&e.deleteSelection(),e.split(e.mapping.map(l.pos),1,f),p&&!m&&!l.parentOffset&&l.parent.type!==p)){const v=e.mapping.map(l.before()),b=e.doc.resolve(v);l.node(-1).canReplaceWith(b.index(),b.index()+1,p)&&e.setNodeMarkup(e.mapping.map(l.before()),p)}t&&jX(n,i.extensionManager.splittableMarks),e.scrollIntoView()}return y},VXe=(t,e={})=>({tr:n,state:r,dispatch:i,editor:s})=>{var o;const l=Ni(t,r.schema),{$from:c,$to:d}=r.selection,u=r.selection.node;if(u&&u.isBlock||c.depth<2||!c.sameParent(d))return!1;const m=c.node(-1);if(m.type!==l)return!1;const p=s.extensionManager.attributes;if(c.parent.content.size===0&&c.node(-1).childCount===c.indexAfter(-1)){if(c.depth===2||c.node(-3).type!==l||c.index(-2)!==c.node(-2).childCount-1)return!1;if(i){let g=Vt.empty;const _=c.index(-1)?1:c.index(-2)?2:3;for(let F=c.depth-_;F>=c.depth-3;F-=1)g=Vt.from(c.node(F).copy(g));const C=c.indexAfter(-1)<c.node(-2).childCount?1:c.indexAfter(-2)<c.node(-3).childCount?2:3,P={...iN(p,c.node().type.name,c.node().attrs),...e},N=((o=l.contentMatch.defaultType)==null?void 0:o.createAndFill(P))||void 0;g=g.append(Vt.from(l.createAndFill(null,N)||void 0));const A=c.before(c.depth-(_-1));n.replace(A,c.after(-C),new an(g,4-_,0));let T=-1;n.doc.nodesBetween(A,n.doc.content.size,(F,k)=>{if(T>-1)return!1;F.isTextblock&&F.content.size===0&&(T=k+1)}),T>-1&&n.setSelection($n.near(n.doc.resolve(T))),n.scrollIntoView()}return!0}const f=d.pos===c.end()?m.contentMatchAt(0).defaultType:null,y={...iN(p,m.type.name,m.attrs),...e},v={...iN(p,c.node().type.name,c.node().attrs),...e};n.delete(c.pos,d.pos);const b=f?[{type:l,attrs:y},{type:f,attrs:v}]:[{type:l,attrs:y}];if(!hm(n.doc,c.pos,2))return!1;if(i){const{selection:g,storedMarks:_}=r,{splittableMarks:C}=s.extensionManager,P=_||g.$to.parentOffset&&g.$from.marks();if(n.split(c.pos,2,b).scrollIntoView(),!P||!i)return!0;const N=P.filter(A=>C.includes(A.type.name));n.ensureMarks(N)}return!0},o3=(t,e)=>{const n=dA(o=>o.type===e)(t.selection);if(!n)return!0;const r=t.doc.resolve(Math.max(0,n.pos-1)).before(n.depth);if(r===void 0)return!0;const i=t.doc.nodeAt(r);return n.node.type===i?.type&&Fp(t.doc,n.pos)&&t.join(n.pos),!0},l3=(t,e)=>{const n=dA(o=>o.type===e)(t.selection);if(!n)return!0;const r=t.doc.resolve(n.start).after(n.depth);if(r===void 0)return!0;const i=t.doc.nodeAt(r);return n.node.type===i?.type&&Fp(t.doc,r)&&t.join(r),!0},GXe=(t,e,n,r={})=>({editor:i,tr:s,state:o,dispatch:l,chain:c,commands:d,can:u})=>{const{extensions:m,splittableMarks:p}=i.extensionManager,f=Ni(t,o.schema),y=Ni(e,o.schema),{selection:v,storedMarks:b}=o,{$from:g,$to:_}=v,C=g.blockRange(_),P=b||v.$to.parentOffset&&v.$from.marks();if(!C)return!1;const N=dA(A=>AX(A.type.name,m))(v);if(C.depth>=1&&N&&C.depth-N.depth<=1){if(N.node.type===f)return d.liftListItem(y);if(AX(N.node.type.name,m)&&f.validContent(N.node.content)&&l)return c().command(()=>(s.setNodeMarkup(N.pos,f),!0)).command(()=>o3(s,f)).command(()=>l3(s,f)).run()}return!n||!P||!l?c().command(()=>u().wrapInList(f,r)?!0:d.clearNodes()).wrapInList(f,r).command(()=>o3(s,f)).command(()=>l3(s,f)).run():c().command(()=>{const A=u().wrapInList(f,r),T=P.filter(F=>p.includes(F.type.name));return s.ensureMarks(T),A?!0:d.clearNodes()}).wrapInList(f,r).command(()=>o3(s,f)).command(()=>l3(s,f)).run()},WXe=(t,e={},n={})=>({state:r,commands:i})=>{const{extendEmptyMarkRange:s=!1}=n,o=Rm(t,r.schema);return BL(r,o,e)?i.unsetMark(o,{extendEmptyMarkRange:s}):i.setMark(o,e)},KXe=(t,e,n={})=>({state:r,commands:i})=>{const s=Ni(t,r.schema),o=Ni(e,r.schema),l=Np(r,s,n);let c;return r.selection.$anchor.sameParent(r.selection.$head)&&(c=r.selection.$anchor.parent.attrs),l?i.setNode(o,c):i.setNode(s,{...c,...n})},XXe=(t,e={})=>({state:n,commands:r})=>{const i=Ni(t,n.schema);return Np(n,i,e)?r.lift(i):r.wrapIn(i,e)},YXe=()=>({state:t,dispatch:e})=>{const n=t.plugins;for(let r=0;r<n.length;r+=1){const i=n[r];let s;if(i.spec.isInputRules&&(s=i.getState(t))){if(e){const o=t.tr,l=s.transform;for(let c=l.steps.length-1;c>=0;c-=1)o.step(l.steps[c].invert(l.docs[c]));if(s.text){const c=o.doc.resolve(s.from).marks();o.replaceWith(s.from,s.to,t.schema.text(s.text,c))}else o.delete(s.from,s.to)}return!0}}return!1},QXe=()=>({tr:t,dispatch:e})=>{const{selection:n}=t,{empty:r,ranges:i}=n;return r||e&&i.forEach(s=>{t.removeMark(s.$from.pos,s.$to.pos)}),!0},ZXe=(t,e={})=>({tr:n,state:r,dispatch:i})=>{var s;const{extendEmptyMarkRange:o=!1}=e,{selection:l}=n,c=Rm(t,r.schema),{$from:d,empty:u,ranges:m}=l;if(!i)return!0;if(u&&o){let{from:p,to:f}=l;const y=(s=d.marks().find(b=>b.type===c))==null?void 0:s.attrs,v=$z(d,c,y);v&&(p=v.from,f=v.to),n.removeMark(p,f,c)}else m.forEach(p=>{n.removeMark(p.$from.pos,p.$to.pos,c)});return n.removeStoredMark(c),!0},JXe=t=>({tr:e,state:n,dispatch:r})=>{const{selection:i}=n;let s,o;return typeof t==\"number\"?(s=t,o=t):t&&\"from\"in t&&\"to\"in t?(s=t.from,o=t.to):(s=i.from,o=i.to),r&&e.doc.nodesBetween(s,o,(l,c)=>{if(l.isText)return;const d={...l.attrs};delete d.dir,e.setNodeMarkup(c,void 0,d)}),!0},eYe=(t,e={})=>({tr:n,state:r,dispatch:i})=>{let s=null,o=null;const l=cA(typeof t==\"string\"?t:t.name,r.schema);if(!l)return!1;l===\"node\"&&(s=Ni(t,r.schema)),l===\"mark\"&&(o=Rm(t,r.schema));let c=!1;return n.selection.ranges.forEach(d=>{const u=d.$from.pos,m=d.$to.pos;let p,f,y,v;n.selection.empty?r.doc.nodesBetween(u,m,(b,g)=>{s&&s===b.type&&(c=!0,y=Math.max(g,u),v=Math.min(g+b.nodeSize,m),p=g,f=b)}):r.doc.nodesBetween(u,m,(b,g)=>{g<u&&s&&s===b.type&&(c=!0,y=Math.max(g,u),v=Math.min(g+b.nodeSize,m),p=g,f=b),g>=u&&g<=m&&(s&&s===b.type&&(c=!0,i&&n.setNodeMarkup(g,void 0,{...b.attrs,...e})),o&&b.marks.length&&b.marks.forEach(_=>{if(o===_.type&&(c=!0,i)){const C=Math.max(g,u),P=Math.min(g+b.nodeSize,m);n.addMark(C,P,o.create({..._.attrs,...e}))}}))}),f&&(p!==void 0&&i&&n.setNodeMarkup(p,void 0,{...f.attrs,...e}),o&&f.marks.length&&f.marks.forEach(b=>{o===b.type&&i&&n.addMark(y,v,o.create({...b.attrs,...e}))}))}),c},tYe=(t,e={})=>({state:n,dispatch:r})=>{const i=Ni(t,n.schema);return A9e(i,e)(n,r)},nYe=(t,e={})=>({state:n,dispatch:r})=>{const i=Ni(t,n.schema);return j9e(i,e)(n,r)},rYe=class{constructor(){this.callbacks={}}on(t,e){return this.callbacks[t]||(this.callbacks[t]=[]),this.callbacks[t].push(e),this}emit(t,...e){const n=this.callbacks[t];return n&&n.forEach(r=>r.apply(this,e)),this}off(t,e){const n=this.callbacks[t];return n&&(e?this.callbacks[t]=n.filter(r=>r!==e):delete this.callbacks[t]),this}once(t,e){const n=(...r)=>{this.off(t,n),e.apply(this,r)};return this.on(t,n)}removeAllListeners(){this.callbacks={}}},mA=class{constructor(t){var e;this.find=t.find,this.handler=t.handler,this.undoable=(e=t.undoable)!=null?e:!0}},aYe=(t,e)=>{if(qz(e))return e.exec(t);const n=e(t);if(!n)return null;const r=[n.text];return r.index=n.index,r.input=t,r.data=n.data,n.replaceWith&&(n.text.includes(n.replaceWith)||console.warn('[tiptap warn]: \"inputRuleMatch.replaceWith\" must be part of \"inputRuleMatch.text\".'),r.push(n.replaceWith)),r};function Sk(t){var e;const{editor:n,from:r,to:i,text:s,rules:o,plugin:l}=t,{view:c}=n;if(c.composing)return!1;const d=c.state.doc.resolve(r);if(d.parent.type.spec.code||(e=d.nodeBefore||d.nodeAfter)!=null&&e.marks.find(p=>p.type.spec.code))return!1;let u=!1;const m=jXe(d)+s;return o.forEach(p=>{if(u)return;const f=aYe(m,p.find);if(!f)return;const y=c.state.tr,v=oA({state:c.state,transaction:y}),b={from:r-(f[0].length-s.length),to:i},{commands:g,chain:_,can:C}=new lA({editor:n,state:v});p.handler({state:v,range:b,match:f,commands:g,chain:_,can:C})===null||!y.steps.length||(p.undoable&&y.setMeta(l,{transform:y,from:r,to:i,text:s}),c.dispatch(y),u=!0)}),u}function iYe(t){const{editor:e,rules:n}=t,r=new Ua({state:{init(){return null},apply(i,s,o){const l=i.getMeta(r);if(l)return l;const c=i.getMeta(\"applyInputRules\");return c&&setTimeout(()=>{let{text:u}=c;typeof u==\"string\"?u=u:u=Gz(Vt.from(u),o.schema);const{from:m}=c,p=m+u.length;Sk({editor:e,from:m,to:p,text:u,rules:n,plugin:r})}),i.selectionSet||i.docChanged?null:s}},props:{handleTextInput(i,s,o,l){return Sk({editor:e,from:s,to:o,text:l,rules:n,plugin:r})},handleDOMEvents:{compositionend:i=>(setTimeout(()=>{const{$cursor:s}=i.state.selection;s&&Sk({editor:e,from:s.pos,to:s.pos,text:\"\",rules:n,plugin:r})}),!1)},handleKeyDown(i,s){if(s.key!==\"Enter\")return!1;const{$cursor:o}=i.state.selection;return o?Sk({editor:e,from:o.pos,to:o.pos,text:`\n`,rules:n,plugin:r}):!1}},isInputRules:!0});return r}function sYe(t){return Object.prototype.toString.call(t).slice(8,-1)}function _k(t){return sYe(t)!==\"Object\"?!1:t.constructor===Object&&Object.getPrototypeOf(t)===Object.prototype}function soe(t,e){const n={...t};return _k(t)&&_k(e)&&Object.keys(e).forEach(r=>{_k(e[r])&&_k(t[r])?n[r]=soe(t[r],e[r]):n[r]=e[r]}),n}var Kz=class{constructor(t={}){this.type=\"extendable\",this.parent=null,this.child=null,this.name=\"\",this.config={name:this.name},this.config={...this.config,...t},this.name=this.config.name}get options(){return{...$r(Pn(this,\"addOptions\",{name:this.name}))||{}}}get storage(){return{...$r(Pn(this,\"addStorage\",{name:this.name,options:this.options}))||{}}}configure(t={}){const e=this.extend({...this.config,addOptions:()=>soe(this.options,t)});return e.name=this.name,e.parent=this.parent,e}extend(t={}){const e=new this.constructor({...this.config,...t});return e.parent=this,this.child=e,e.name=\"name\"in t?t.name:e.parent.name,e}},Lp=class ooe extends Kz{constructor(){super(...arguments),this.type=\"mark\"}static create(e={}){const n=typeof e==\"function\"?e():e;return new ooe(n)}static handleExit({editor:e,mark:n}){const{tr:r}=e.state,i=e.state.selection.$from;if(i.pos===i.end()){const o=i.marks();if(!!!o.find(d=>d?.type.name===n.name))return!1;const c=o.find(d=>d?.type.name===n.name);return c&&r.removeStoredMark(c),r.insertText(\" \",i.pos),e.view.dispatch(r),!0}return!1}configure(e){return super.configure(e)}extend(e){const n=typeof e==\"function\"?e():e;return super.extend(n)}};function oYe(t){return typeof t==\"number\"}var lYe=class{constructor(t){this.find=t.find,this.handler=t.handler}},cYe=(t,e,n)=>{if(qz(e))return[...t.matchAll(e)];const r=e(t,n);return r?r.map(i=>{const s=[i.text];return s.index=i.index,s.input=t,s.data=i.data,i.replaceWith&&(i.text.includes(i.replaceWith)||console.warn('[tiptap warn]: \"pasteRuleMatch.replaceWith\" must be part of \"pasteRuleMatch.text\".'),s.push(i.replaceWith)),s}):[]};function dYe(t){const{editor:e,state:n,from:r,to:i,rule:s,pasteEvent:o,dropEvent:l}=t,{commands:c,chain:d,can:u}=new lA({editor:e,state:n}),m=[];return n.doc.nodesBetween(r,i,(f,y)=>{var v,b,g,_,C;if((b=(v=f.type)==null?void 0:v.spec)!=null&&b.code||!(f.isText||f.isTextblock||f.isInline))return;const P=(C=(_=(g=f.content)==null?void 0:g.size)!=null?_:f.nodeSize)!=null?C:0,N=Math.max(r,y),A=Math.min(i,y+P);if(N>=A)return;const T=f.isText?f.text||\"\":f.textBetween(N-y,A-y,void 0,\"￼\");cYe(T,s.find,o).forEach(k=>{if(k.index===void 0)return;const D=N+k.index+1,H=D+k[0].length,z={from:n.tr.mapping.map(D),to:n.tr.mapping.map(H)},Q=s.handler({state:n,range:z,match:k,commands:c,chain:d,can:u,pasteEvent:o,dropEvent:l});m.push(Q)})}),m.every(f=>f!==null)}var kk=null,uYe=t=>{var e;const n=new ClipboardEvent(\"paste\",{clipboardData:new DataTransfer});return(e=n.clipboardData)==null||e.setData(\"text/html\",t),n};function mYe(t){const{editor:e,rules:n}=t;let r=null,i=!1,s=!1,o=typeof ClipboardEvent<\"u\"?new ClipboardEvent(\"paste\"):null,l;try{l=typeof DragEvent<\"u\"?new DragEvent(\"drop\"):null}catch{l=null}const c=({state:u,from:m,to:p,rule:f,pasteEvt:y})=>{const v=u.tr,b=oA({state:u,transaction:v});if(!(!dYe({editor:e,state:b,from:Math.max(m-1,0),to:p.b-1,rule:f,pasteEvent:y,dropEvent:l})||!v.steps.length)){try{l=typeof DragEvent<\"u\"?new DragEvent(\"drop\"):null}catch{l=null}return o=typeof ClipboardEvent<\"u\"?new ClipboardEvent(\"paste\"):null,v}};return n.map(u=>new Ua({view(m){const p=y=>{var v;r=(v=m.dom.parentElement)!=null&&v.contains(y.target)?m.dom.parentElement:null,r&&(kk=e)},f=()=>{kk&&(kk=null)};return window.addEventListener(\"dragstart\",p),window.addEventListener(\"dragend\",f),{destroy(){window.removeEventListener(\"dragstart\",p),window.removeEventListener(\"dragend\",f)}}},props:{handleDOMEvents:{drop:(m,p)=>{if(s=r===m.dom.parentElement,l=p,!s){const f=kk;f?.isEditable&&setTimeout(()=>{const y=f.state.selection;y&&f.commands.deleteRange({from:y.from,to:y.to})},10)}return!1},paste:(m,p)=>{var f;const y=(f=p.clipboardData)==null?void 0:f.getData(\"text/html\");return o=p,i=!!y?.includes(\"data-pm-slice\"),!1}}},appendTransaction:(m,p,f)=>{const y=m[0],v=y.getMeta(\"uiEvent\")===\"paste\"&&!i,b=y.getMeta(\"uiEvent\")===\"drop\"&&!s,g=y.getMeta(\"applyPasteRules\"),_=!!g;if(!v&&!b&&!_)return;if(_){let{text:N}=g;typeof N==\"string\"?N=N:N=Gz(Vt.from(N),f.schema);const{from:A}=g,T=A+N.length,F=uYe(N);return c({rule:u,state:f,from:A,to:{b:T},pasteEvt:F})}const C=p.doc.content.findDiffStart(f.doc.content),P=p.doc.content.findDiffEnd(f.doc.content);if(!(!oYe(C)||!P||C===P.b))return c({rule:u,state:f,from:C,to:P,pasteEvt:o})}}))}var hA=class{constructor(t,e){this.splittableMarks=[],this.editor=e,this.baseExtensions=t,this.extensions=Zse(t),this.schema=_Xe(this.extensions,e),this.setupExtensions()}get commands(){return this.extensions.reduce((t,e)=>{const n={name:e.name,options:e.options,storage:this.editor.extensionStorage[e.name],editor:this.editor,type:wk(e.name,this.schema)},r=Pn(e,\"addCommands\",n);return r?{...t,...r()}:t},{})}get plugins(){const{editor:t}=this;return eP([...this.extensions].reverse()).flatMap(r=>{const i={name:r.name,options:r.options,storage:this.editor.extensionStorage[r.name],editor:t,type:wk(r.name,this.schema)},s=[],o=Pn(r,\"addKeyboardShortcuts\",i);let l={};if(r.type===\"mark\"&&Pn(r,\"exitable\",i)&&(l.ArrowRight=()=>Lp.handleExit({editor:t,mark:r})),o){const p=Object.fromEntries(Object.entries(o()).map(([f,y])=>[f,()=>y({editor:t})]));l={...l,...p}}const c=CKe(l);s.push(c);const d=Pn(r,\"addInputRules\",i);if(TX(r,t.options.enableInputRules)&&d){const p=d();if(p&&p.length){const f=iYe({editor:t,rules:p}),y=Array.isArray(f)?f:[f];s.push(...y)}}const u=Pn(r,\"addPasteRules\",i);if(TX(r,t.options.enablePasteRules)&&u){const p=u();if(p&&p.length){const f=mYe({editor:t,rules:p});s.push(...f)}}const m=Pn(r,\"addProseMirrorPlugins\",i);if(m){const p=m();s.push(...p)}return s})}get attributes(){return Qse(this.extensions)}get nodeViews(){const{editor:t}=this,{nodeExtensions:e}=my(this.extensions);return Object.fromEntries(e.filter(n=>!!Pn(n,\"addNodeView\")).map(n=>{const r=this.attributes.filter(c=>c.type===n.name),i={name:n.name,options:n.options,storage:this.editor.extensionStorage[n.name],editor:t,type:Ni(n.name,this.schema)},s=Pn(n,\"addNodeView\",i);if(!s)return[];const o=s();if(!o)return[];const l=(c,d,u,m,p)=>{const f=Hw(c,r);return o({node:c,view:d,getPos:u,decorations:m,innerDecorations:p,editor:t,extension:n,HTMLAttributes:f})};return[n.name,l]}))}dispatchTransaction(t){const{editor:e}=this;return eP([...this.extensions].reverse()).reduceRight((r,i)=>{const s={name:i.name,options:i.options,storage:this.editor.extensionStorage[i.name],editor:e,type:wk(i.name,this.schema)},o=Pn(i,\"dispatchTransaction\",s);return o?l=>{o.call(s,{transaction:l,next:r})}:r},t)}get markViews(){const{editor:t}=this,{markExtensions:e}=my(this.extensions);return Object.fromEntries(e.filter(n=>!!Pn(n,\"addMarkView\")).map(n=>{const r=this.attributes.filter(l=>l.type===n.name),i={name:n.name,options:n.options,storage:this.editor.extensionStorage[n.name],editor:t,type:Rm(n.name,this.schema)},s=Pn(n,\"addMarkView\",i);if(!s)return[];const o=(l,c,d)=>{const u=Hw(l,r);return s()({mark:l,view:c,inline:d,editor:t,extension:n,HTMLAttributes:u,updateAttributes:m=>{PYe(l,t,m)}})};return[n.name,o]}))}setupExtensions(){const t=this.extensions;this.editor.extensionStorage=Object.fromEntries(t.map(e=>[e.name,e.storage])),t.forEach(e=>{var n;const r={name:e.name,options:e.options,storage:this.editor.extensionStorage[e.name],editor:this.editor,type:wk(e.name,this.schema)};e.type===\"mark\"&&((n=$r(Pn(e,\"keepOnSplit\",r)))==null||n)&&this.splittableMarks.push(e.name);const i=Pn(e,\"onBeforeCreate\",r),s=Pn(e,\"onCreate\",r),o=Pn(e,\"onUpdate\",r),l=Pn(e,\"onSelectionUpdate\",r),c=Pn(e,\"onTransaction\",r),d=Pn(e,\"onFocus\",r),u=Pn(e,\"onBlur\",r),m=Pn(e,\"onDestroy\",r);i&&this.editor.on(\"beforeCreate\",i),s&&this.editor.on(\"create\",s),o&&this.editor.on(\"update\",o),l&&this.editor.on(\"selectionUpdate\",l),c&&this.editor.on(\"transaction\",c),d&&this.editor.on(\"focus\",d),u&&this.editor.on(\"blur\",u),m&&this.editor.on(\"destroy\",m)})}};hA.resolve=Zse;hA.sort=eP;hA.flatten=Vz;var hYe={};Hz(hYe,{ClipboardTextSerializer:()=>coe,Commands:()=>doe,Delete:()=>uoe,Drop:()=>moe,Editable:()=>hoe,FocusEvents:()=>foe,Keymap:()=>goe,Paste:()=>boe,Tabindex:()=>xoe,TextDirection:()=>yoe,focusEventsPluginKey:()=>poe});var na=class loe extends Kz{constructor(){super(...arguments),this.type=\"extension\"}static create(e={}){const n=typeof e==\"function\"?e():e;return new loe(n)}configure(e){return super.configure(e)}extend(e){const n=typeof e==\"function\"?e():e;return super.extend(n)}},coe=na.create({name:\"clipboardTextSerializer\",addOptions(){return{blockSeparator:void 0}},addProseMirrorPlugins(){return[new Ua({key:new zi(\"clipboardTextSerializer\"),props:{clipboardTextSerializer:()=>{const{editor:t}=this,{state:e,schema:n}=t,{doc:r,selection:i}=e,{ranges:s}=i,o=Math.min(...s.map(u=>u.$from.pos)),l=Math.max(...s.map(u=>u.$to.pos)),c=eoe(n);return Jse(r,{from:o,to:l},{...this.options.blockSeparator!==void 0?{blockSeparator:this.options.blockSeparator}:{},textSerializers:c})}}})]}}),doe=na.create({name:\"commands\",addCommands(){return{...Hse}}}),uoe=na.create({name:\"delete\",onUpdate({transaction:t,appendedTransactions:e}){var n,r,i;const s=()=>{var o,l,c,d;if((d=(c=(l=(o=this.editor.options.coreExtensionOptions)==null?void 0:o.delete)==null?void 0:l.filterTransaction)==null?void 0:c.call(l,t))!=null?d:t.getMeta(\"y-sync$\"))return;const u=Xse(t.before,[t,...e]);noe(u).forEach(f=>{u.mapping.mapResult(f.oldRange.from).deletedAfter&&u.mapping.mapResult(f.oldRange.to).deletedBefore&&u.before.nodesBetween(f.oldRange.from,f.oldRange.to,(y,v)=>{const b=v+y.nodeSize-2,g=f.oldRange.from<=v&&b<=f.oldRange.to;this.editor.emit(\"delete\",{type:\"node\",node:y,from:v,to:b,newFrom:u.mapping.map(v),newTo:u.mapping.map(b),deletedRange:f.oldRange,newRange:f.newRange,partial:!g,editor:this.editor,transaction:t,combinedTransform:u})})});const p=u.mapping;u.steps.forEach((f,y)=>{var v,b;if(f instanceof Kc){const g=p.slice(y).map(f.from,-1),_=p.slice(y).map(f.to),C=p.invert().map(g,-1),P=p.invert().map(_),N=(v=u.doc.nodeAt(g-1))==null?void 0:v.marks.some(T=>T.eq(f.mark)),A=(b=u.doc.nodeAt(_))==null?void 0:b.marks.some(T=>T.eq(f.mark));this.editor.emit(\"delete\",{type:\"mark\",mark:f.mark,from:f.from,to:f.to,deletedRange:{from:C,to:P},newRange:{from:g,to:_},partial:!!(A||N),editor:this.editor,transaction:t,combinedTransform:u})}})};(i=(r=(n=this.editor.options.coreExtensionOptions)==null?void 0:n.delete)==null?void 0:r.async)==null||i?setTimeout(s,0):s()}}),moe=na.create({name:\"drop\",addProseMirrorPlugins(){return[new Ua({key:new zi(\"tiptapDrop\"),props:{handleDrop:(t,e,n,r)=>{this.editor.emit(\"drop\",{editor:this.editor,event:e,slice:n,moved:r})}}})]}}),hoe=na.create({name:\"editable\",addProseMirrorPlugins(){return[new Ua({key:new zi(\"editable\"),props:{editable:()=>this.editor.options.editable}})]}}),poe=new zi(\"focusEvents\"),foe=na.create({name:\"focusEvents\",addProseMirrorPlugins(){const{editor:t}=this;return[new Ua({key:poe,props:{handleDOMEvents:{focus:(e,n)=>{t.isFocused=!0;const r=t.state.tr.setMeta(\"focus\",{event:n}).setMeta(\"addToHistory\",!1);return e.dispatch(r),!1},blur:(e,n)=>{t.isFocused=!1;const r=t.state.tr.setMeta(\"blur\",{event:n}).setMeta(\"addToHistory\",!1);return e.dispatch(r),!1}}}})]}}),goe=na.create({name:\"keymap\",addKeyboardShortcuts(){const t=()=>this.editor.commands.first(({commands:o})=>[()=>o.undoInputRule(),()=>o.command(({tr:l})=>{const{selection:c,doc:d}=l,{empty:u,$anchor:m}=c,{pos:p,parent:f}=m,y=m.parent.isTextblock&&p>0?l.doc.resolve(p-1):m,v=y.parent.type.spec.isolating,b=m.pos-m.parentOffset,g=v&&y.parent.childCount===1?b===m.pos:rr.atStart(d).from===p;return!u||!f.type.isTextblock||f.textContent.length||!g||g&&m.parent.type.name===\"paragraph\"?!1:o.clearNodes()}),()=>o.deleteSelection(),()=>o.joinBackward(),()=>o.selectNodeBackward()]),e=()=>this.editor.commands.first(({commands:o})=>[()=>o.deleteSelection(),()=>o.deleteCurrentNode(),()=>o.joinForward(),()=>o.selectNodeForward()]),r={Enter:()=>this.editor.commands.first(({commands:o})=>[()=>o.newlineInCode(),()=>o.createParagraphNear(),()=>o.liftEmptyBlock(),()=>o.splitBlock()]),\"Mod-Enter\":()=>this.editor.commands.exitCode(),Backspace:t,\"Mod-Backspace\":t,\"Shift-Backspace\":t,Delete:e,\"Mod-Delete\":e,\"Mod-a\":()=>this.editor.commands.selectAll()},i={...r},s={...r,\"Ctrl-h\":t,\"Alt-Backspace\":t,\"Ctrl-d\":e,\"Ctrl-Alt-Backspace\":e,\"Alt-Delete\":e,\"Alt-d\":e,\"Ctrl-a\":()=>this.editor.commands.selectTextblockStart(),\"Ctrl-e\":()=>this.editor.commands.selectTextblockEnd()};return JC()||Wse()?s:i},addProseMirrorPlugins(){return[new Ua({key:new zi(\"clearDocument\"),appendTransaction:(t,e,n)=>{if(t.some(v=>v.getMeta(\"composition\")))return;const r=t.some(v=>v.docChanged)&&!e.doc.eq(n.doc),i=t.some(v=>v.getMeta(\"preventClearDocument\"));if(!r||i)return;const{empty:s,from:o,to:l}=e.selection,c=rr.atStart(e.doc).from,d=rr.atEnd(e.doc).to;if(s||!(o===c&&l===d)||!uA(n.doc))return;const p=n.tr,f=oA({state:n,transaction:p}),{commands:y}=new lA({editor:this.editor,state:f});if(y.clearNodes(),!!p.steps.length)return p}})]}}),boe=na.create({name:\"paste\",addProseMirrorPlugins(){return[new Ua({key:new zi(\"tiptapPaste\"),props:{handlePaste:(t,e,n)=>{this.editor.emit(\"paste\",{editor:this.editor,event:e,slice:n})}}})]}}),xoe=na.create({name:\"tabindex\",addProseMirrorPlugins(){return[new Ua({key:new zi(\"tabindex\"),props:{attributes:()=>this.editor.isEditable?{tabindex:\"0\"}:{}}})]}}),yoe=na.create({name:\"textDirection\",addOptions(){return{direction:void 0}},addGlobalAttributes(){if(!this.options.direction)return[];const{nodeExtensions:t}=my(this.extensions);return[{types:t.filter(e=>e.name!==\"text\").map(e=>e.name),attributes:{dir:{default:this.options.direction,parseHTML:e=>{const n=e.getAttribute(\"dir\");return n&&(n===\"ltr\"||n===\"rtl\"||n===\"auto\")?n:this.options.direction},renderHTML:e=>e.dir?{dir:e.dir}:{}}}}]},addProseMirrorPlugins(){return[new Ua({key:new zi(\"textDirection\"),props:{attributes:()=>{const t=this.options.direction;return t?{dir:t}:{}}}})]}}),pYe=class _0{constructor(e,n,r=!1,i=null){this.currentNode=null,this.actualDepth=null,this.isBlock=r,this.resolvedPos=e,this.editor=n,this.currentNode=i}get name(){return this.node.type.name}get node(){return this.currentNode||this.resolvedPos.node()}get element(){return this.editor.view.domAtPos(this.pos).node}get depth(){var e;return(e=this.actualDepth)!=null?e:this.resolvedPos.depth}get pos(){return this.resolvedPos.pos}get content(){return this.node.content}set content(e){let n=this.from,r=this.to;if(this.isBlock){if(this.content.size===0){console.error(`You can’t set content on a block node. Tried to set content on ${this.name} at ${this.pos}`);return}n=this.from+1,r=this.to-1}this.editor.commands.insertContentAt({from:n,to:r},e)}get attributes(){return this.node.attrs}get textContent(){return this.node.textContent}get size(){return this.node.nodeSize}get from(){return this.isBlock?this.pos:this.resolvedPos.start(this.resolvedPos.depth)}get range(){return{from:this.from,to:this.to}}get to(){return this.isBlock?this.pos+this.size:this.resolvedPos.end(this.resolvedPos.depth)+(this.node.isText?0:1)}get parent(){if(this.depth===0)return null;const e=this.resolvedPos.start(this.resolvedPos.depth-1),n=this.resolvedPos.doc.resolve(e);return new _0(n,this.editor)}get before(){let e=this.resolvedPos.doc.resolve(this.from-(this.isBlock?1:2));return e.depth!==this.depth&&(e=this.resolvedPos.doc.resolve(this.from-3)),new _0(e,this.editor)}get after(){let e=this.resolvedPos.doc.resolve(this.to+(this.isBlock?2:1));return e.depth!==this.depth&&(e=this.resolvedPos.doc.resolve(this.to+3)),new _0(e,this.editor)}get children(){const e=[];return this.node.content.forEach((n,r)=>{const i=n.isBlock&&!n.isTextblock,s=n.isAtom&&!n.isText,o=n.isInline,l=this.pos+r+(s?0:1);if(l<0||l>this.resolvedPos.doc.nodeSize-2)return;const c=this.resolvedPos.doc.resolve(l);if(!i&&!o&&c.depth<=this.depth)return;const d=new _0(c,this.editor,i,i||o?n:null);i&&(d.actualDepth=this.depth+1),e.push(d)}),e}get firstChild(){return this.children[0]||null}get lastChild(){const e=this.children;return e[e.length-1]||null}closest(e,n={}){let r=null,i=this.parent;for(;i&&!r;){if(i.node.type.name===e)if(Object.keys(n).length>0){const s=i.node.attrs,o=Object.keys(n);for(let l=0;l<o.length;l+=1){const c=o[l];if(s[c]!==n[c])break}}else r=i;i=i.parent}return r}querySelector(e,n={}){return this.querySelectorAll(e,n,!0)[0]||null}querySelectorAll(e,n={},r=!1){let i=[];if(!this.children||this.children.length===0)return i;const s=Object.keys(n);return this.children.forEach(o=>{r&&i.length>0||(o.node.type.name===e&&s.every(c=>n[c]===o.node.attrs[c])&&i.push(o),!(r&&i.length>0)&&(i=i.concat(o.querySelectorAll(e,n,r))))}),i}setAttribute(e){const{tr:n}=this.editor.state;n.setNodeMarkup(this.from,void 0,{...this.node.attrs,...e}),this.editor.view.dispatch(n)}},fYe=`.ProseMirror {\n  position: relative;\n}\n\n.ProseMirror {\n  word-wrap: break-word;\n  white-space: pre-wrap;\n  white-space: break-spaces;\n  -webkit-font-variant-ligatures: none;\n  font-variant-ligatures: none;\n  font-feature-settings: \"liga\" 0; /* the above doesn't seem to work in Edge */\n}\n\n.ProseMirror [contenteditable=\"false\"] {\n  white-space: normal;\n}\n\n.ProseMirror [contenteditable=\"false\"] [contenteditable=\"true\"] {\n  white-space: pre-wrap;\n}\n\n.ProseMirror pre {\n  white-space: pre-wrap;\n}\n\nimg.ProseMirror-separator {\n  display: inline !important;\n  border: none !important;\n  margin: 0 !important;\n  width: 0 !important;\n  height: 0 !important;\n}\n\n.ProseMirror-gapcursor {\n  display: none;\n  pointer-events: none;\n  position: absolute;\n  margin: 0;\n}\n\n.ProseMirror-gapcursor:after {\n  content: \"\";\n  display: block;\n  position: absolute;\n  top: -2px;\n  width: 20px;\n  border-top: 1px solid black;\n  animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;\n}\n\n@keyframes ProseMirror-cursor-blink {\n  to {\n    visibility: hidden;\n  }\n}\n\n.ProseMirror-hideselection *::selection {\n  background: transparent;\n}\n\n.ProseMirror-hideselection *::-moz-selection {\n  background: transparent;\n}\n\n.ProseMirror-hideselection * {\n  caret-color: transparent;\n}\n\n.ProseMirror-focused .ProseMirror-gapcursor {\n  display: block;\n}`;function gYe(t,e,n){const r=document.querySelector(\"style[data-tiptap-style]\");if(r!==null)return r;const i=document.createElement(\"style\");return e&&i.setAttribute(\"nonce\",e),i.setAttribute(\"data-tiptap-style\",\"\"),i.innerHTML=t,document.getElementsByTagName(\"head\")[0].appendChild(i),i}var bYe=class extends rYe{constructor(t={}){super(),this.css=null,this.className=\"tiptap\",this.editorView=null,this.isFocused=!1,this.isInitialized=!1,this.extensionStorage={},this.instanceId=Math.random().toString(36).slice(2,9),this.options={element:typeof document<\"u\"?document.createElement(\"div\"):null,content:\"\",injectCSS:!0,injectNonce:void 0,extensions:[],autofocus:!1,editable:!0,textDirection:void 0,editorProps:{},parseOptions:{},coreExtensionOptions:{},enableInputRules:!0,enablePasteRules:!0,enableCoreExtensions:!0,enableContentCheck:!1,emitContentError:!1,onBeforeCreate:()=>null,onCreate:()=>null,onMount:()=>null,onUnmount:()=>null,onUpdate:()=>null,onSelectionUpdate:()=>null,onTransaction:()=>null,onFocus:()=>null,onBlur:()=>null,onDestroy:()=>null,onContentError:({error:r})=>{throw r},onPaste:()=>null,onDrop:()=>null,onDelete:()=>null,enableExtensionDispatchTransaction:!0},this.isCapturingTransaction=!1,this.capturedTransaction=null,this.utils={getUpdatedPosition:FXe,createMappablePosition:RXe},this.setOptions(t),this.createExtensionManager(),this.createCommandManager(),this.createSchema(),this.on(\"beforeCreate\",this.options.onBeforeCreate),this.emit(\"beforeCreate\",{editor:this}),this.on(\"mount\",this.options.onMount),this.on(\"unmount\",this.options.onUnmount),this.on(\"contentError\",this.options.onContentError),this.on(\"create\",this.options.onCreate),this.on(\"update\",this.options.onUpdate),this.on(\"selectionUpdate\",this.options.onSelectionUpdate),this.on(\"transaction\",this.options.onTransaction),this.on(\"focus\",this.options.onFocus),this.on(\"blur\",this.options.onBlur),this.on(\"destroy\",this.options.onDestroy),this.on(\"drop\",({event:r,slice:i,moved:s})=>this.options.onDrop(r,i,s)),this.on(\"paste\",({event:r,slice:i})=>this.options.onPaste(r,i)),this.on(\"delete\",this.options.onDelete);const e=this.createDoc(),n=Vse(e,this.options.autofocus);this.editorState=Nx.create({doc:e,schema:this.schema,selection:n||void 0}),this.options.element&&this.mount(this.options.element)}mount(t){if(typeof document>\"u\")throw new Error(\"[tiptap error]: The editor cannot be mounted because there is no 'document' defined in this environment.\");this.createView(t),this.emit(\"mount\",{editor:this}),this.css&&!document.head.contains(this.css)&&document.head.appendChild(this.css),window.setTimeout(()=>{this.isDestroyed||(this.options.autofocus!==!1&&this.options.autofocus!==null&&this.commands.focus(this.options.autofocus),this.emit(\"create\",{editor:this}),this.isInitialized=!0)},0)}unmount(){if(this.editorView){const t=this.editorView.dom;t?.editor&&delete t.editor,this.editorView.destroy()}if(this.editorView=null,this.isInitialized=!1,this.css&&!document.querySelectorAll(`.${this.className}`).length)try{typeof this.css.remove==\"function\"?this.css.remove():this.css.parentNode&&this.css.parentNode.removeChild(this.css)}catch(t){console.warn(\"Failed to remove CSS element:\",t)}this.css=null,this.emit(\"unmount\",{editor:this})}get storage(){return this.extensionStorage}get commands(){return this.commandManager.commands}chain(){return this.commandManager.chain()}can(){return this.commandManager.can()}injectCSS(){this.options.injectCSS&&typeof document<\"u\"&&(this.css=gYe(fYe,this.options.injectNonce))}setOptions(t={}){this.options={...this.options,...t},!(!this.editorView||!this.state||this.isDestroyed)&&(this.options.editorProps&&this.view.setProps(this.options.editorProps),this.view.updateState(this.state))}setEditable(t,e=!0){this.setOptions({editable:t}),e&&this.emit(\"update\",{editor:this,transaction:this.state.tr,appendedTransactions:[]})}get isEditable(){return this.options.editable&&this.view&&this.view.editable}get view(){return this.editorView?this.editorView:new Proxy({state:this.editorState,updateState:t=>{this.editorState=t},dispatch:t=>{this.dispatchTransaction(t)},composing:!1,dragging:null,editable:!0,isDestroyed:!1},{get:(t,e)=>{if(this.editorView)return this.editorView[e];if(e===\"state\")return this.editorState;if(e in t)return Reflect.get(t,e);throw new Error(`[tiptap error]: The editor view is not available. Cannot access view['${e}']. The editor may not be mounted yet.`)}})}get state(){return this.editorView&&(this.editorState=this.view.state),this.editorState}registerPlugin(t,e){const n=Yse(e)?e(t,[...this.state.plugins]):[...this.state.plugins,t],r=this.state.reconfigure({plugins:n});return this.view.updateState(r),r}unregisterPlugin(t){if(this.isDestroyed)return;const e=this.state.plugins;let n=e;if([].concat(t).forEach(i=>{const s=typeof i==\"string\"?`${i}$`:i.key;n=n.filter(o=>!o.key.startsWith(s))}),e.length===n.length)return;const r=this.state.reconfigure({plugins:n});return this.view.updateState(r),r}createExtensionManager(){var t,e;const r=[...this.options.enableCoreExtensions?[hoe,coe.configure({blockSeparator:(e=(t=this.options.coreExtensionOptions)==null?void 0:t.clipboardTextSerializer)==null?void 0:e.blockSeparator}),doe,foe,goe,xoe,moe,boe,uoe,yoe.configure({direction:this.options.textDirection})].filter(i=>typeof this.options.enableCoreExtensions==\"object\"?this.options.enableCoreExtensions[i.name]!==!1:!0):[],...this.options.extensions].filter(i=>[\"extension\",\"node\",\"mark\"].includes(i?.type));this.extensionManager=new hA(r,this)}createCommandManager(){this.commandManager=new lA({editor:this})}createSchema(){this.schema=this.extensionManager.schema}createDoc(){let t;try{t=UL(this.options.content,this.schema,this.options.parseOptions,{errorOnInvalidContent:this.options.enableContentCheck})}catch(e){if(!(e instanceof Error)||![\"[tiptap error]: Invalid JSON content\",\"[tiptap error]: Invalid HTML content\"].includes(e.message))throw e;this.emit(\"contentError\",{editor:this,error:e,disableCollaboration:()=>{\"collaboration\"in this.storage&&typeof this.storage.collaboration==\"object\"&&this.storage.collaboration&&(this.storage.collaboration.isDisabled=!0),this.options.extensions=this.options.extensions.filter(n=>n.name!==\"collaboration\"),this.createExtensionManager()}}),t=UL(this.options.content,this.schema,this.options.parseOptions,{errorOnInvalidContent:!1})}return t}createView(t){const{editorProps:e,enableExtensionDispatchTransaction:n}=this.options,r=e.dispatchTransaction||this.dispatchTransaction.bind(this),i=n?this.extensionManager.dispatchTransaction(r):r;this.editorView=new Use(t,{...e,attributes:{role:\"textbox\",...e?.attributes},dispatchTransaction:i,state:this.editorState,markViews:this.extensionManager.markViews,nodeViews:this.extensionManager.nodeViews});const s=this.state.reconfigure({plugins:this.extensionManager.plugins});this.view.updateState(s),this.prependClass(),this.injectCSS();const o=this.view.dom;o.editor=this}createNodeViews(){this.view.isDestroyed||this.view.setProps({markViews:this.extensionManager.markViews,nodeViews:this.extensionManager.nodeViews})}prependClass(){this.view.dom.className=`${this.className} ${this.view.dom.className}`}captureTransaction(t){this.isCapturingTransaction=!0,t(),this.isCapturingTransaction=!1;const e=this.capturedTransaction;return this.capturedTransaction=null,e}dispatchTransaction(t){if(this.view.isDestroyed)return;if(this.isCapturingTransaction){if(!this.capturedTransaction){this.capturedTransaction=t;return}t.steps.forEach(d=>{var u;return(u=this.capturedTransaction)==null?void 0:u.step(d)});return}const{state:e,transactions:n}=this.state.applyTransaction(t),r=!this.state.selection.eq(e.selection),i=n.includes(t),s=this.state;if(this.emit(\"beforeTransaction\",{editor:this,transaction:t,nextState:e}),!i)return;this.view.updateState(e),this.emit(\"transaction\",{editor:this,transaction:t,appendedTransactions:n.slice(1)}),r&&this.emit(\"selectionUpdate\",{editor:this,transaction:t});const o=n.findLast(d=>d.getMeta(\"focus\")||d.getMeta(\"blur\")),l=o?.getMeta(\"focus\"),c=o?.getMeta(\"blur\");l&&this.emit(\"focus\",{editor:this,event:l.event,transaction:o}),c&&this.emit(\"blur\",{editor:this,event:c.event,transaction:o}),!(t.getMeta(\"preventUpdate\")||!n.some(d=>d.docChanged)||s.doc.eq(e.doc))&&this.emit(\"update\",{editor:this,transaction:t,appendedTransactions:n.slice(1)})}getAttributes(t){return toe(this.state,t)}isActive(t,e){const n=typeof t==\"string\"?t:null,r=typeof t==\"string\"?e:t;return MXe(this.state,n,r)}getJSON(){return this.state.doc.toJSON()}getHTML(){return Gz(this.state.doc.content,this.schema)}getText(t){const{blockSeparator:e=`\n\n`,textSerializers:n={}}=t||{};return NXe(this.state.doc,{blockSeparator:e,textSerializers:{...eoe(this.schema),...n}})}get isEmpty(){return uA(this.state.doc)}destroy(){this.emit(\"destroy\"),this.unmount(),this.removeAllListeners()}get isDestroyed(){var t,e;return(e=(t=this.editorView)==null?void 0:t.isDestroyed)!=null?e:!0}$node(t,e){var n;return((n=this.$doc)==null?void 0:n.querySelector(t,e))||null}$nodes(t,e){var n;return((n=this.$doc)==null?void 0:n.querySelectorAll(t,e))||null}$pos(t){const e=this.state.doc.resolve(t);return new pYe(e,this)}get $doc(){return this.$pos(0)}};function hy(t){return new mA({find:t.find,handler:({state:e,range:n,match:r})=>{const i=$r(t.getAttributes,void 0,r);if(i===!1||i===null)return null;const{tr:s}=e,o=r[r.length-1],l=r[0];if(o){const c=l.search(/\\S/),d=n.from+l.indexOf(o),u=d+o.length;if(Wz(n.from,n.to,e.doc).filter(f=>f.mark.type.excluded.find(v=>v===t.type&&v!==f.mark.type)).filter(f=>f.to>d).length)return null;u<n.to&&s.delete(u,n.to),d>n.from&&s.delete(n.from+c,d);const p=n.from+c+o.length;s.addMark(n.from+c,p,t.type.create(i||{})),s.removeStoredMark(t.type)}},undoable:t.undoable})}function voe(t){return new mA({find:t.find,handler:({state:e,range:n,match:r})=>{const i=$r(t.getAttributes,void 0,r)||{},{tr:s}=e,o=n.from;let l=n.to;const c=t.type.create(i);if(r[1]){const d=r[0].lastIndexOf(r[1]);let u=o+d;u>l?u=l:l=u+r[1].length;const m=r[0][r[0].length-1];s.insertText(m,o+r[0].length-1),s.replaceWith(u,l,c)}else if(r[0]){const d=t.type.isInline?o:o-1;s.insert(d,t.type.create(i)).delete(s.mapping.map(o),s.mapping.map(l))}s.scrollIntoView()},undoable:t.undoable})}function HL(t){return new mA({find:t.find,handler:({state:e,range:n,match:r})=>{const i=e.doc.resolve(n.from),s=$r(t.getAttributes,void 0,r)||{};if(!i.node(-1).canReplaceWith(i.index(-1),i.indexAfter(-1),t.type))return null;e.tr.delete(n.from,n.to).setBlockType(n.from,n.from,t.type,s)},undoable:t.undoable})}function py(t){return new mA({find:t.find,handler:({state:e,range:n,match:r,chain:i})=>{const s=$r(t.getAttributes,void 0,r)||{},o=e.tr.delete(n.from,n.to),c=o.doc.resolve(n.from).blockRange(),d=c&&Cz(c,t.type,s);if(!d)return null;if(o.wrap(c,d),t.keepMarks&&t.editor){const{selection:m,storedMarks:p}=e,{splittableMarks:f}=t.editor.extensionManager,y=p||m.$to.parentOffset&&m.$from.marks();if(y){const v=y.filter(b=>f.includes(b.type.name));o.ensureMarks(v)}}if(t.keepAttributes){const m=t.type.name===\"bulletList\"||t.type.name===\"orderedList\"?\"listItem\":\"taskList\";i().updateAttributes(m,s).run()}const u=o.doc.resolve(n.from-1).nodeBefore;u&&u.type===t.type&&Fp(o.doc,n.from-1)&&(!t.joinPredicate||t.joinPredicate(r,u))&&o.join(n.from-1)},undoable:t.undoable})}var xYe=t=>\"touches\"in t,yYe=class{constructor(t){this.directions=[\"bottom-left\",\"bottom-right\",\"top-left\",\"top-right\"],this.minSize={height:8,width:8},this.preserveAspectRatio=!1,this.classNames={container:\"\",wrapper:\"\",handle:\"\",resizing:\"\"},this.initialWidth=0,this.initialHeight=0,this.aspectRatio=1,this.isResizing=!1,this.activeHandle=null,this.startX=0,this.startY=0,this.startWidth=0,this.startHeight=0,this.isShiftKeyPressed=!1,this.lastEditableState=void 0,this.handleMap=new Map,this.handleMouseMove=l=>{if(!this.isResizing||!this.activeHandle)return;const c=l.clientX-this.startX,d=l.clientY-this.startY;this.handleResize(c,d)},this.handleTouchMove=l=>{if(!this.isResizing||!this.activeHandle)return;const c=l.touches[0];if(!c)return;const d=c.clientX-this.startX,u=c.clientY-this.startY;this.handleResize(d,u)},this.handleMouseUp=()=>{if(!this.isResizing)return;const l=this.element.offsetWidth,c=this.element.offsetHeight;this.onCommit(l,c),this.isResizing=!1,this.activeHandle=null,this.container.dataset.resizeState=\"false\",this.classNames.resizing&&this.container.classList.remove(this.classNames.resizing),document.removeEventListener(\"mousemove\",this.handleMouseMove),document.removeEventListener(\"mouseup\",this.handleMouseUp),document.removeEventListener(\"keydown\",this.handleKeyDown),document.removeEventListener(\"keyup\",this.handleKeyUp)},this.handleKeyDown=l=>{l.key===\"Shift\"&&(this.isShiftKeyPressed=!0)},this.handleKeyUp=l=>{l.key===\"Shift\"&&(this.isShiftKeyPressed=!1)};var e,n,r,i,s,o;this.node=t.node,this.editor=t.editor,this.element=t.element,this.contentElement=t.contentElement,this.getPos=t.getPos,this.onResize=t.onResize,this.onCommit=t.onCommit,this.onUpdate=t.onUpdate,(e=t.options)!=null&&e.min&&(this.minSize={...this.minSize,...t.options.min}),(n=t.options)!=null&&n.max&&(this.maxSize=t.options.max),(r=t?.options)!=null&&r.directions&&(this.directions=t.options.directions),(i=t.options)!=null&&i.preserveAspectRatio&&(this.preserveAspectRatio=t.options.preserveAspectRatio),(s=t.options)!=null&&s.className&&(this.classNames={container:t.options.className.container||\"\",wrapper:t.options.className.wrapper||\"\",handle:t.options.className.handle||\"\",resizing:t.options.className.resizing||\"\"}),(o=t.options)!=null&&o.createCustomHandle&&(this.createCustomHandle=t.options.createCustomHandle),this.wrapper=this.createWrapper(),this.container=this.createContainer(),this.applyInitialSize(),this.attachHandles(),this.editor.on(\"update\",this.handleEditorUpdate.bind(this))}get dom(){return this.container}get contentDOM(){var t;return(t=this.contentElement)!=null?t:null}handleEditorUpdate(){const t=this.editor.isEditable;t!==this.lastEditableState&&(this.lastEditableState=t,t?t&&this.handleMap.size===0&&this.attachHandles():this.removeHandles())}update(t,e,n){return t.type!==this.node.type?!1:(this.node=t,this.onUpdate?this.onUpdate(t,e,n):!0)}destroy(){this.isResizing&&(this.container.dataset.resizeState=\"false\",this.classNames.resizing&&this.container.classList.remove(this.classNames.resizing),document.removeEventListener(\"mousemove\",this.handleMouseMove),document.removeEventListener(\"mouseup\",this.handleMouseUp),document.removeEventListener(\"keydown\",this.handleKeyDown),document.removeEventListener(\"keyup\",this.handleKeyUp),this.isResizing=!1,this.activeHandle=null),this.editor.off(\"update\",this.handleEditorUpdate.bind(this)),this.container.remove()}createContainer(){const t=document.createElement(\"div\");return t.dataset.resizeContainer=\"\",t.dataset.node=this.node.type.name,t.style.display=\"flex\",this.classNames.container&&(t.className=this.classNames.container),t.appendChild(this.wrapper),t}createWrapper(){const t=document.createElement(\"div\");return t.style.position=\"relative\",t.style.display=\"block\",t.dataset.resizeWrapper=\"\",this.classNames.wrapper&&(t.className=this.classNames.wrapper),t.appendChild(this.element),t}createHandle(t){const e=document.createElement(\"div\");return e.dataset.resizeHandle=t,e.style.position=\"absolute\",this.classNames.handle&&(e.className=this.classNames.handle),e}positionHandle(t,e){const n=e.includes(\"top\"),r=e.includes(\"bottom\"),i=e.includes(\"left\"),s=e.includes(\"right\");n&&(t.style.top=\"0\"),r&&(t.style.bottom=\"0\"),i&&(t.style.left=\"0\"),s&&(t.style.right=\"0\"),(e===\"top\"||e===\"bottom\")&&(t.style.left=\"0\",t.style.right=\"0\"),(e===\"left\"||e===\"right\")&&(t.style.top=\"0\",t.style.bottom=\"0\")}attachHandles(){this.directions.forEach(t=>{let e;this.createCustomHandle?e=this.createCustomHandle(t):e=this.createHandle(t),e instanceof HTMLElement||(console.warn(`[ResizableNodeView] createCustomHandle(\"${t}\") did not return an HTMLElement. Falling back to default handle.`),e=this.createHandle(t)),this.createCustomHandle||this.positionHandle(e,t),e.addEventListener(\"mousedown\",n=>this.handleResizeStart(n,t)),e.addEventListener(\"touchstart\",n=>this.handleResizeStart(n,t)),this.handleMap.set(t,e),this.wrapper.appendChild(e)})}removeHandles(){this.handleMap.forEach(t=>t.remove()),this.handleMap.clear()}applyInitialSize(){const t=this.node.attrs.width,e=this.node.attrs.height;t?(this.element.style.width=`${t}px`,this.initialWidth=t):this.initialWidth=this.element.offsetWidth,e?(this.element.style.height=`${e}px`,this.initialHeight=e):this.initialHeight=this.element.offsetHeight,this.initialWidth>0&&this.initialHeight>0&&(this.aspectRatio=this.initialWidth/this.initialHeight)}handleResizeStart(t,e){t.preventDefault(),t.stopPropagation(),this.isResizing=!0,this.activeHandle=e,xYe(t)?(this.startX=t.touches[0].clientX,this.startY=t.touches[0].clientY):(this.startX=t.clientX,this.startY=t.clientY),this.startWidth=this.element.offsetWidth,this.startHeight=this.element.offsetHeight,this.startWidth>0&&this.startHeight>0&&(this.aspectRatio=this.startWidth/this.startHeight),this.getPos(),this.container.dataset.resizeState=\"true\",this.classNames.resizing&&this.container.classList.add(this.classNames.resizing),document.addEventListener(\"mousemove\",this.handleMouseMove),document.addEventListener(\"touchmove\",this.handleTouchMove),document.addEventListener(\"mouseup\",this.handleMouseUp),document.addEventListener(\"keydown\",this.handleKeyDown),document.addEventListener(\"keyup\",this.handleKeyUp)}handleResize(t,e){if(!this.activeHandle)return;const n=this.preserveAspectRatio||this.isShiftKeyPressed,{width:r,height:i}=this.calculateNewDimensions(this.activeHandle,t,e),s=this.applyConstraints(r,i,n);this.element.style.width=`${s.width}px`,this.element.style.height=`${s.height}px`,this.onResize&&this.onResize(s.width,s.height)}calculateNewDimensions(t,e,n){let r=this.startWidth,i=this.startHeight;const s=t.includes(\"right\"),o=t.includes(\"left\"),l=t.includes(\"bottom\"),c=t.includes(\"top\");return s?r=this.startWidth+e:o&&(r=this.startWidth-e),l?i=this.startHeight+n:c&&(i=this.startHeight-n),(t===\"right\"||t===\"left\")&&(r=this.startWidth+(s?e:-e)),(t===\"top\"||t===\"bottom\")&&(i=this.startHeight+(l?n:-n)),this.preserveAspectRatio||this.isShiftKeyPressed?this.applyAspectRatio(r,i,t):{width:r,height:i}}applyConstraints(t,e,n){var r,i,s,o;if(!n){let d=Math.max(this.minSize.width,t),u=Math.max(this.minSize.height,e);return(r=this.maxSize)!=null&&r.width&&(d=Math.min(this.maxSize.width,d)),(i=this.maxSize)!=null&&i.height&&(u=Math.min(this.maxSize.height,u)),{width:d,height:u}}let l=t,c=e;return l<this.minSize.width&&(l=this.minSize.width,c=l/this.aspectRatio),c<this.minSize.height&&(c=this.minSize.height,l=c*this.aspectRatio),(s=this.maxSize)!=null&&s.width&&l>this.maxSize.width&&(l=this.maxSize.width,c=l/this.aspectRatio),(o=this.maxSize)!=null&&o.height&&c>this.maxSize.height&&(c=this.maxSize.height,l=c*this.aspectRatio),{width:l,height:c}}applyAspectRatio(t,e,n){const r=n===\"left\"||n===\"right\",i=n===\"top\"||n===\"bottom\";return r?{width:t,height:t/this.aspectRatio}:i?{width:e*this.aspectRatio,height:e}:{width:t,height:t/this.aspectRatio}}};function vYe(t,e){const{selection:n}=t,{$from:r}=n;if(n instanceof An){const s=r.index();return r.parent.canReplaceWith(s,s+1,e)}let i=r.depth;for(;i>=0;){const s=r.index(i);if(r.node(i).contentMatchAt(s).matchType(e))return!0;i-=1}return!1}var wYe={};Hz(wYe,{createAtomBlockMarkdownSpec:()=>SYe,createBlockMarkdownSpec:()=>_Ye,createInlineMarkdownSpec:()=>CYe,parseAttributes:()=>Xz,parseIndentedBlocks:()=>qL,renderNestedMarkdownContent:()=>Qz,serializeAttributes:()=>Yz});function Xz(t){if(!t?.trim())return{};const e={},n=[],r=t.replace(/[\"']([^\"']*)[\"']/g,d=>(n.push(d),`__QUOTED_${n.length-1}__`)),i=r.match(/(?:^|\\s)\\.([a-zA-Z][\\w-]*)/g);if(i){const d=i.map(u=>u.trim().slice(1));e.class=d.join(\" \")}const s=r.match(/(?:^|\\s)#([a-zA-Z][\\w-]*)/);s&&(e.id=s[1]);const o=/([a-zA-Z][\\w-]*)\\s*=\\s*(__QUOTED_\\d+__)/g;Array.from(r.matchAll(o)).forEach(([,d,u])=>{var m;const p=parseInt(((m=u.match(/__QUOTED_(\\d+)__/))==null?void 0:m[1])||\"0\",10),f=n[p];f&&(e[d]=f.slice(1,-1))});const c=r.replace(/(?:^|\\s)\\.([a-zA-Z][\\w-]*)/g,\"\").replace(/(?:^|\\s)#([a-zA-Z][\\w-]*)/g,\"\").replace(/([a-zA-Z][\\w-]*)\\s*=\\s*__QUOTED_\\d+__/g,\"\").trim();return c&&c.split(/\\s+/).filter(Boolean).forEach(u=>{u.match(/^[a-zA-Z][\\w-]*$/)&&(e[u]=!0)}),e}function Yz(t){if(!t||Object.keys(t).length===0)return\"\";const e=[];return t.class&&String(t.class).split(/\\s+/).filter(Boolean).forEach(r=>e.push(`.${r}`)),t.id&&e.push(`#${t.id}`),Object.entries(t).forEach(([n,r])=>{n===\"class\"||n===\"id\"||(r===!0?e.push(n):r!==!1&&r!=null&&e.push(`${n}=\"${String(r)}\"`))}),e.join(\" \")}function SYe(t){const{nodeName:e,name:n,parseAttributes:r=Xz,serializeAttributes:i=Yz,defaultAttributes:s={},requiredAttributes:o=[],allowedAttributes:l}=t,c=n||e,d=u=>{if(!l)return u;const m={};return l.forEach(p=>{p in u&&(m[p]=u[p])}),m};return{parseMarkdown:(u,m)=>{const p={...s,...u.attributes};return m.createNode(e,p,[])},markdownTokenizer:{name:e,level:\"block\",start(u){var m;const p=new RegExp(`^:::${c}(?:\\\\s|$)`,\"m\"),f=(m=u.match(p))==null?void 0:m.index;return f!==void 0?f:-1},tokenize(u,m,p){const f=new RegExp(`^:::${c}(?:\\\\s+\\\\{([^}]*)\\\\})?\\\\s*:::(?:\\\\n|$)`),y=u.match(f);if(!y)return;const v=y[1]||\"\",b=r(v);if(!o.find(_=>!(_ in b)))return{type:e,raw:y[0],attributes:b}}},renderMarkdown:u=>{const m=d(u.attrs||{}),p=i(m),f=p?` {${p}}`:\"\";return`:::${c}${f} :::`}}}function _Ye(t){const{nodeName:e,name:n,getContent:r,parseAttributes:i=Xz,serializeAttributes:s=Yz,defaultAttributes:o={},content:l=\"block\",allowedAttributes:c}=t,d=n||e,u=m=>{if(!c)return m;const p={};return c.forEach(f=>{f in m&&(p[f]=m[f])}),p};return{parseMarkdown:(m,p)=>{let f;if(r){const v=r(m);f=typeof v==\"string\"?[{type:\"text\",text:v}]:v}else l===\"block\"?f=p.parseChildren(m.tokens||[]):f=p.parseInline(m.tokens||[]);const y={...o,...m.attributes};return p.createNode(e,y,f)},markdownTokenizer:{name:e,level:\"block\",start(m){var p;const f=new RegExp(`^:::${d}`,\"m\"),y=(p=m.match(f))==null?void 0:p.index;return y!==void 0?y:-1},tokenize(m,p,f){var y;const v=new RegExp(`^:::${d}(?:\\\\s+\\\\{([^}]*)\\\\})?\\\\s*\\\\n`),b=m.match(v);if(!b)return;const[g,_=\"\"]=b,C=i(_);let P=1;const N=g.length;let A=\"\";const T=/^:::([\\w-]*)(\\s.*)?/gm,F=m.slice(N);for(T.lastIndex=0;;){const k=T.exec(F);if(k===null)break;const D=k.index,H=k[1];if(!((y=k[2])!=null&&y.endsWith(\":::\"))){if(H)P+=1;else if(P-=1,P===0){const z=F.slice(0,D);A=z.trim();const Q=m.slice(0,N+D+k[0].length);let L=[];if(A)if(l===\"block\")for(L=f.blockTokens(z),L.forEach(te=>{te.text&&(!te.tokens||te.tokens.length===0)&&(te.tokens=f.inlineTokens(te.text))});L.length>0;){const te=L[L.length-1];if(te.type===\"paragraph\"&&(!te.text||te.text.trim()===\"\"))L.pop();else break}else L=f.inlineTokens(A);return{type:e,raw:Q,attributes:C,content:A,tokens:L}}}}}},renderMarkdown:(m,p)=>{const f=u(m.attrs||{}),y=s(f),v=y?` {${y}}`:\"\",b=p.renderChildren(m.content||[],`\n\n`);return`:::${d}${v}\n\n${b}\n\n:::`}}}function kYe(t){if(!t.trim())return{};const e={},n=/(\\w+)=(?:\"([^\"]*)\"|'([^']*)')/g;let r=n.exec(t);for(;r!==null;){const[,i,s,o]=r;e[i]=s||o,r=n.exec(t)}return e}function NYe(t){return Object.entries(t).filter(([,e])=>e!=null).map(([e,n])=>`${e}=\"${n}\"`).join(\" \")}function CYe(t){const{nodeName:e,name:n,getContent:r,parseAttributes:i=kYe,serializeAttributes:s=NYe,defaultAttributes:o={},selfClosing:l=!1,allowedAttributes:c}=t,d=n||e,u=p=>{if(!c)return p;const f={};return c.forEach(y=>{const v=typeof y==\"string\"?y:y.name,b=typeof y==\"string\"?void 0:y.skipIfDefault;if(v in p){const g=p[v];if(b!==void 0&&g===b)return;f[v]=g}}),f},m=d.replace(/[.*+?^${}()|[\\]\\\\]/g,\"\\\\$&\");return{parseMarkdown:(p,f)=>{const y={...o,...p.attributes};if(l)return f.createNode(e,y);const v=r?r(p):p.content||\"\";return v?f.createNode(e,y,[f.createTextNode(v)]):f.createNode(e,y,[])},markdownTokenizer:{name:e,level:\"inline\",start(p){const f=l?new RegExp(`\\\\[${m}\\\\s*[^\\\\]]*\\\\]`):new RegExp(`\\\\[${m}\\\\s*[^\\\\]]*\\\\][\\\\s\\\\S]*?\\\\[\\\\/${m}\\\\]`),y=p.match(f),v=y?.index;return v!==void 0?v:-1},tokenize(p,f,y){const v=l?new RegExp(`^\\\\[${m}\\\\s*([^\\\\]]*)\\\\]`):new RegExp(`^\\\\[${m}\\\\s*([^\\\\]]*)\\\\]([\\\\s\\\\S]*?)\\\\[\\\\/${m}\\\\]`),b=p.match(v);if(!b)return;let g=\"\",_=\"\";if(l){const[,P]=b;_=P}else{const[,P,N]=b;_=P,g=N||\"\"}const C=i(_.trim());return{type:e,raw:b[0],content:g.trim(),attributes:C}}},renderMarkdown:p=>{let f=\"\";r?f=r(p):p.content&&p.content.length>0&&(f=p.content.filter(g=>g.type===\"text\").map(g=>g.text).join(\"\"));const y=u(p.attrs||{}),v=s(y),b=v?` ${v}`:\"\";return l?`[${d}${b}]`:`[${d}${b}]${f}[/${d}]`}}}function qL(t,e,n){var r,i,s,o;const l=t.split(`\n`),c=[];let d=\"\",u=0;const m=e.baseIndentSize||2;for(;u<l.length;){const p=l[u],f=p.match(e.itemPattern);if(!f){if(c.length>0)break;if(p.trim()===\"\"){u+=1,d=`${d}${p}\n`;continue}else return}const y=e.extractItemData(f),{indentLevel:v,mainContent:b}=y;d=`${d}${p}\n`;const g=[b];for(u+=1;u<l.length;){const N=l[u];if(N.trim()===\"\"){const T=l.slice(u+1).findIndex(D=>D.trim()!==\"\");if(T===-1)break;if((((i=(r=l[u+1+T].match(/^(\\s*)/))==null?void 0:r[1])==null?void 0:i.length)||0)>v){g.push(N),d=`${d}${N}\n`,u+=1;continue}else break}if((((o=(s=N.match(/^(\\s*)/))==null?void 0:s[1])==null?void 0:o.length)||0)>v)g.push(N),d=`${d}${N}\n`,u+=1;else break}let _;const C=g.slice(1);if(C.length>0){const N=C.map(A=>A.slice(v+m)).join(`\n`);N.trim()&&(e.customNestedParser?_=e.customNestedParser(N):_=n.blockTokens(N))}const P=e.createToken(y,_);c.push(P)}if(c.length!==0)return{items:c,raw:d}}function Qz(t,e,n,r){if(!t||!Array.isArray(t.content))return\"\";const i=typeof n==\"function\"?n(r):n,[s,...o]=t.content,l=e.renderChildren([s]),c=[`${i}${l}`];return o&&o.length>0&&o.forEach(d=>{const u=e.renderChildren([d]);if(u){const m=u.split(`\n`).map(p=>p?e.indent(p):\"\").join(`\n`);c.push(m)}}),c.join(`\n`)}function PYe(t,e,n={}){const{state:r}=e,{doc:i,tr:s}=r,o=t;i.descendants((l,c)=>{const d=s.mapping.map(c),u=s.mapping.map(c)+l.nodeSize;let m=null;if(l.marks.forEach(f=>{if(f!==o)return!1;m=f}),!m)return;let p=!1;if(Object.keys(n).forEach(f=>{n[f]!==m.attrs[f]&&(p=!0)}),p){const f=t.type.create({...t.attrs,...n});s.removeMark(d,u,t.type),s.addMark(d,u,f)}}),s.docChanged&&e.view.dispatch(s)}var Vo=class woe extends Kz{constructor(){super(...arguments),this.type=\"node\"}static create(e={}){const n=typeof e==\"function\"?e():e;return new woe(n)}configure(e){return super.configure(e)}extend(e){const n=typeof e==\"function\"?e():e;return super.extend(n)}};function Fg(t){return new lYe({find:t.find,handler:({state:e,range:n,match:r,pasteEvent:i})=>{const s=$r(t.getAttributes,void 0,r,i);if(s===!1||s===null)return null;const{tr:o}=e,l=r[r.length-1],c=r[0];let d=n.to;if(l){const u=c.search(/\\S/),m=n.from+c.indexOf(l),p=m+l.length;if(Wz(n.from,n.to,e.doc).filter(y=>y.mark.type.excluded.find(b=>b===t.type&&b!==y.mark.type)).filter(y=>y.to>m).length)return null;p<n.to&&o.delete(p,n.to),m>n.from&&o.delete(n.from+u,m),d=n.from+u+l.length,o.addMark(n.from+u,d,t.type.create(s||{})),o.removeStoredMark(t.type)}}})}const{getOwnPropertyNames:TYe,getOwnPropertySymbols:AYe}=Object,{hasOwnProperty:jYe}=Object.prototype;function c3(t,e){return function(r,i,s){return t(r,i,s)&&e(r,i,s)}}function Nk(t){return function(n,r,i){if(!n||!r||typeof n!=\"object\"||typeof r!=\"object\")return t(n,r,i);const{cache:s}=i,o=s.get(n),l=s.get(r);if(o&&l)return o===r&&l===n;s.set(n,r),s.set(r,n);const c=t(n,r,i);return s.delete(n),s.delete(r),c}}function MYe(t){return t?.[Symbol.toStringTag]}function MX(t){return TYe(t).concat(AYe(t))}const EYe=Object.hasOwn||((t,e)=>jYe.call(t,e));function Vg(t,e){return t===e||!t&&!e&&t!==t&&e!==e}const DYe=\"__v\",FYe=\"__o\",RYe=\"_owner\",{getOwnPropertyDescriptor:EX,keys:DX}=Object;function LYe(t,e){return t.byteLength===e.byteLength&&tP(new Uint8Array(t),new Uint8Array(e))}function OYe(t,e,n){let r=t.length;if(e.length!==r)return!1;for(;r-- >0;)if(!n.equals(t[r],e[r],r,r,t,e,n))return!1;return!0}function IYe(t,e){return t.byteLength===e.byteLength&&tP(new Uint8Array(t.buffer,t.byteOffset,t.byteLength),new Uint8Array(e.buffer,e.byteOffset,e.byteLength))}function zYe(t,e){return Vg(t.getTime(),e.getTime())}function UYe(t,e){return t.name===e.name&&t.message===e.message&&t.cause===e.cause&&t.stack===e.stack}function BYe(t,e){return t===e}function FX(t,e,n){const r=t.size;if(r!==e.size)return!1;if(!r)return!0;const i=new Array(r),s=t.entries();let o,l,c=0;for(;(o=s.next())&&!o.done;){const d=e.entries();let u=!1,m=0;for(;(l=d.next())&&!l.done;){if(i[m]){m++;continue}const p=o.value,f=l.value;if(n.equals(p[0],f[0],c,m,t,e,n)&&n.equals(p[1],f[1],p[0],f[0],t,e,n)){u=i[m]=!0;break}m++}if(!u)return!1;c++}return!0}const HYe=Vg;function qYe(t,e,n){const r=DX(t);let i=r.length;if(DX(e).length!==i)return!1;for(;i-- >0;)if(!Soe(t,e,n,r[i]))return!1;return!0}function o0(t,e,n){const r=MX(t);let i=r.length;if(MX(e).length!==i)return!1;let s,o,l;for(;i-- >0;)if(s=r[i],!Soe(t,e,n,s)||(o=EX(t,s),l=EX(e,s),(o||l)&&(!o||!l||o.configurable!==l.configurable||o.enumerable!==l.enumerable||o.writable!==l.writable)))return!1;return!0}function $Ye(t,e){return Vg(t.valueOf(),e.valueOf())}function VYe(t,e){return t.source===e.source&&t.flags===e.flags}function RX(t,e,n){const r=t.size;if(r!==e.size)return!1;if(!r)return!0;const i=new Array(r),s=t.values();let o,l;for(;(o=s.next())&&!o.done;){const c=e.values();let d=!1,u=0;for(;(l=c.next())&&!l.done;){if(!i[u]&&n.equals(o.value,l.value,o.value,l.value,t,e,n)){d=i[u]=!0;break}u++}if(!d)return!1}return!0}function tP(t,e){let n=t.byteLength;if(e.byteLength!==n||t.byteOffset!==e.byteOffset)return!1;for(;n-- >0;)if(t[n]!==e[n])return!1;return!0}function GYe(t,e){return t.hostname===e.hostname&&t.pathname===e.pathname&&t.protocol===e.protocol&&t.port===e.port&&t.hash===e.hash&&t.username===e.username&&t.password===e.password}function Soe(t,e,n,r){return(r===RYe||r===FYe||r===DYe)&&(t.$$typeof||e.$$typeof)?!0:EYe(e,r)&&n.equals(t[r],e[r],r,r,t,e,n)}const WYe=\"[object ArrayBuffer]\",KYe=\"[object Arguments]\",XYe=\"[object Boolean]\",YYe=\"[object DataView]\",QYe=\"[object Date]\",ZYe=\"[object Error]\",JYe=\"[object Map]\",eQe=\"[object Number]\",tQe=\"[object Object]\",nQe=\"[object RegExp]\",rQe=\"[object Set]\",aQe=\"[object String]\",iQe={\"[object Int8Array]\":!0,\"[object Uint8Array]\":!0,\"[object Uint8ClampedArray]\":!0,\"[object Int16Array]\":!0,\"[object Uint16Array]\":!0,\"[object Int32Array]\":!0,\"[object Uint32Array]\":!0,\"[object Float16Array]\":!0,\"[object Float32Array]\":!0,\"[object Float64Array]\":!0,\"[object BigInt64Array]\":!0,\"[object BigUint64Array]\":!0},sQe=\"[object URL]\",oQe=Object.prototype.toString;function lQe({areArrayBuffersEqual:t,areArraysEqual:e,areDataViewsEqual:n,areDatesEqual:r,areErrorsEqual:i,areFunctionsEqual:s,areMapsEqual:o,areNumbersEqual:l,areObjectsEqual:c,arePrimitiveWrappersEqual:d,areRegExpsEqual:u,areSetsEqual:m,areTypedArraysEqual:p,areUrlsEqual:f,unknownTagComparators:y}){return function(b,g,_){if(b===g)return!0;if(b==null||g==null)return!1;const C=typeof b;if(C!==typeof g)return!1;if(C!==\"object\")return C===\"number\"?l(b,g,_):C===\"function\"?s(b,g,_):!1;const P=b.constructor;if(P!==g.constructor)return!1;if(P===Object)return c(b,g,_);if(Array.isArray(b))return e(b,g,_);if(P===Date)return r(b,g,_);if(P===RegExp)return u(b,g,_);if(P===Map)return o(b,g,_);if(P===Set)return m(b,g,_);const N=oQe.call(b);if(N===QYe)return r(b,g,_);if(N===nQe)return u(b,g,_);if(N===JYe)return o(b,g,_);if(N===rQe)return m(b,g,_);if(N===tQe)return typeof b.then!=\"function\"&&typeof g.then!=\"function\"&&c(b,g,_);if(N===sQe)return f(b,g,_);if(N===ZYe)return i(b,g,_);if(N===KYe)return c(b,g,_);if(iQe[N])return p(b,g,_);if(N===WYe)return t(b,g,_);if(N===YYe)return n(b,g,_);if(N===XYe||N===eQe||N===aQe)return d(b,g,_);if(y){let A=y[N];if(!A){const T=MYe(b);T&&(A=y[T])}if(A)return A(b,g,_)}return!1}}function cQe({circular:t,createCustomConfig:e,strict:n}){let r={areArrayBuffersEqual:LYe,areArraysEqual:n?o0:OYe,areDataViewsEqual:IYe,areDatesEqual:zYe,areErrorsEqual:UYe,areFunctionsEqual:BYe,areMapsEqual:n?c3(FX,o0):FX,areNumbersEqual:HYe,areObjectsEqual:n?o0:qYe,arePrimitiveWrappersEqual:$Ye,areRegExpsEqual:VYe,areSetsEqual:n?c3(RX,o0):RX,areTypedArraysEqual:n?c3(tP,o0):tP,areUrlsEqual:GYe,unknownTagComparators:void 0};if(e&&(r=Object.assign({},r,e(r))),t){const i=Nk(r.areArraysEqual),s=Nk(r.areMapsEqual),o=Nk(r.areObjectsEqual),l=Nk(r.areSetsEqual);r=Object.assign({},r,{areArraysEqual:i,areMapsEqual:s,areObjectsEqual:o,areSetsEqual:l})}return r}function dQe(t){return function(e,n,r,i,s,o,l){return t(e,n,l)}}function uQe({circular:t,comparator:e,createState:n,equals:r,strict:i}){if(n)return function(l,c){const{cache:d=t?new WeakMap:void 0,meta:u}=n();return e(l,c,{cache:d,equals:r,meta:u,strict:i})};if(t)return function(l,c){return e(l,c,{cache:new WeakMap,equals:r,meta:void 0,strict:i})};const s={cache:void 0,equals:r,meta:void 0,strict:i};return function(l,c){return e(l,c,s)}}const mQe=Op();Op({strict:!0});Op({circular:!0});Op({circular:!0,strict:!0});Op({createInternalComparator:()=>Vg});Op({strict:!0,createInternalComparator:()=>Vg});Op({circular:!0,createInternalComparator:()=>Vg});Op({circular:!0,createInternalComparator:()=>Vg,strict:!0});function Op(t={}){const{circular:e=!1,createInternalComparator:n,createState:r,strict:i=!1}=t,s=cQe(t),o=lQe(s),l=n?n(o):dQe(o);return uQe({circular:e,comparator:o,createState:r,equals:l,strict:i})}var hQe=(...t)=>e=>{t.forEach(n=>{typeof n==\"function\"?n(e):n&&(n.current=e)})},pQe=({contentComponent:t})=>{const e=hO.useSyncExternalStore(t.subscribe,t.getSnapshot,t.getServerSnapshot);return a.jsx(a.Fragment,{children:Object.values(e)})};function fQe(){const t=new Set;let e={};return{subscribe(n){return t.add(n),()=>{t.delete(n)}},getSnapshot(){return e},getServerSnapshot(){return e},setRenderer(n,r){e={...e,[n]:jhe.createPortal(r.reactElement,r.element,n)},t.forEach(i=>i())},removeRenderer(n){const r={...e};delete r[n],e=r,t.forEach(i=>i())}}}var gQe=class extends ia.Component{constructor(t){var e;super(t),this.editorContentRef=ia.createRef(),this.initialized=!1,this.state={hasContentComponentInitialized:!!((e=t.editor)!=null&&e.contentComponent)}}componentDidMount(){this.init()}componentDidUpdate(){this.init()}init(){var t;const e=this.props.editor;if(e&&!e.isDestroyed&&((t=e.view.dom)!=null&&t.parentNode)){if(e.contentComponent)return;const n=this.editorContentRef.current;n.append(...e.view.dom.parentNode.childNodes),e.setOptions({element:n}),e.contentComponent=fQe(),this.state.hasContentComponentInitialized||(this.unsubscribeToContentComponent=e.contentComponent.subscribe(()=>{this.setState(r=>r.hasContentComponentInitialized?r:{hasContentComponentInitialized:!0}),this.unsubscribeToContentComponent&&this.unsubscribeToContentComponent()})),e.createNodeViews(),this.initialized=!0}}componentWillUnmount(){var t;const e=this.props.editor;if(e){this.initialized=!1,e.isDestroyed||e.view.setProps({nodeViews:{}}),this.unsubscribeToContentComponent&&this.unsubscribeToContentComponent(),e.contentComponent=null;try{if(!((t=e.view.dom)!=null&&t.parentNode))return;const n=document.createElement(\"div\");n.append(...e.view.dom.parentNode.childNodes),e.setOptions({element:n})}catch{}}}render(){const{editor:t,innerRef:e,...n}=this.props;return a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{ref:hQe(e,this.editorContentRef),...n}),t?.contentComponent&&a.jsx(pQe,{contentComponent:t.contentComponent})]})}},bQe=w.forwardRef((t,e)=>{const n=ia.useMemo(()=>Math.floor(Math.random()*4294967295).toString(),[t.editor]);return ia.createElement(gQe,{key:n,innerRef:e,...t})}),_oe=ia.memo(bQe),xQe=typeof window<\"u\"?w.useLayoutEffect:w.useEffect,yQe=class{constructor(t){this.transactionNumber=0,this.lastTransactionNumber=0,this.subscribers=new Set,this.editor=t,this.lastSnapshot={editor:t,transactionNumber:0},this.getSnapshot=this.getSnapshot.bind(this),this.getServerSnapshot=this.getServerSnapshot.bind(this),this.watch=this.watch.bind(this),this.subscribe=this.subscribe.bind(this)}getSnapshot(){return this.transactionNumber===this.lastTransactionNumber?this.lastSnapshot:(this.lastTransactionNumber=this.transactionNumber,this.lastSnapshot={editor:this.editor,transactionNumber:this.transactionNumber},this.lastSnapshot)}getServerSnapshot(){return{editor:null,transactionNumber:0}}subscribe(t){return this.subscribers.add(t),()=>{this.subscribers.delete(t)}}watch(t){if(this.editor=t,this.editor){const e=()=>{this.transactionNumber+=1,this.subscribers.forEach(r=>r())},n=this.editor;return n.on(\"transaction\",e),()=>{n.off(\"transaction\",e)}}}};function vQe(t){var e;const[n]=w.useState(()=>new yQe(t.editor)),r=pee.useSyncExternalStoreWithSelector(n.subscribe,n.getSnapshot,n.getServerSnapshot,t.selector,(e=t.equalityFn)!=null?e:mQe);return xQe(()=>n.watch(t.editor),[t.editor,n]),w.useDebugValue(r),r}var wQe=!1,$L=typeof window>\"u\",SQe=$L||!!(typeof window<\"u\"&&window.next),_Qe=class koe{constructor(e){this.editor=null,this.subscriptions=new Set,this.isComponentMounted=!1,this.previousDeps=null,this.instanceId=\"\",this.options=e,this.subscriptions=new Set,this.setEditor(this.getInitialEditor()),this.scheduleDestroy(),this.getEditor=this.getEditor.bind(this),this.getServerSnapshot=this.getServerSnapshot.bind(this),this.subscribe=this.subscribe.bind(this),this.refreshEditorInstance=this.refreshEditorInstance.bind(this),this.scheduleDestroy=this.scheduleDestroy.bind(this),this.onRender=this.onRender.bind(this),this.createEditor=this.createEditor.bind(this)}setEditor(e){this.editor=e,this.instanceId=Math.random().toString(36).slice(2,9),this.subscriptions.forEach(n=>n())}getInitialEditor(){return this.options.current.immediatelyRender===void 0?$L||SQe?null:this.createEditor():(this.options.current.immediatelyRender,this.options.current.immediatelyRender?this.createEditor():null)}createEditor(){const e={...this.options.current,onBeforeCreate:(...r)=>{var i,s;return(s=(i=this.options.current).onBeforeCreate)==null?void 0:s.call(i,...r)},onBlur:(...r)=>{var i,s;return(s=(i=this.options.current).onBlur)==null?void 0:s.call(i,...r)},onCreate:(...r)=>{var i,s;return(s=(i=this.options.current).onCreate)==null?void 0:s.call(i,...r)},onDestroy:(...r)=>{var i,s;return(s=(i=this.options.current).onDestroy)==null?void 0:s.call(i,...r)},onFocus:(...r)=>{var i,s;return(s=(i=this.options.current).onFocus)==null?void 0:s.call(i,...r)},onSelectionUpdate:(...r)=>{var i,s;return(s=(i=this.options.current).onSelectionUpdate)==null?void 0:s.call(i,...r)},onTransaction:(...r)=>{var i,s;return(s=(i=this.options.current).onTransaction)==null?void 0:s.call(i,...r)},onUpdate:(...r)=>{var i,s;return(s=(i=this.options.current).onUpdate)==null?void 0:s.call(i,...r)},onContentError:(...r)=>{var i,s;return(s=(i=this.options.current).onContentError)==null?void 0:s.call(i,...r)},onDrop:(...r)=>{var i,s;return(s=(i=this.options.current).onDrop)==null?void 0:s.call(i,...r)},onPaste:(...r)=>{var i,s;return(s=(i=this.options.current).onPaste)==null?void 0:s.call(i,...r)},onDelete:(...r)=>{var i,s;return(s=(i=this.options.current).onDelete)==null?void 0:s.call(i,...r)}};return new bYe(e)}getEditor(){return this.editor}getServerSnapshot(){return null}subscribe(e){return this.subscriptions.add(e),()=>{this.subscriptions.delete(e)}}static compareOptions(e,n){return Object.keys(e).every(r=>[\"onCreate\",\"onBeforeCreate\",\"onDestroy\",\"onUpdate\",\"onTransaction\",\"onFocus\",\"onBlur\",\"onSelectionUpdate\",\"onContentError\",\"onDrop\",\"onPaste\"].includes(r)?!0:r===\"extensions\"&&e.extensions&&n.extensions?e.extensions.length!==n.extensions.length?!1:e.extensions.every((i,s)=>{var o;return i===((o=n.extensions)==null?void 0:o[s])}):e[r]===n[r])}onRender(e){return()=>(this.isComponentMounted=!0,clearTimeout(this.scheduledDestructionTimeout),this.editor&&!this.editor.isDestroyed&&e.length===0?koe.compareOptions(this.options.current,this.editor.options)||this.editor.setOptions({...this.options.current,editable:this.editor.isEditable}):this.refreshEditorInstance(e),()=>{this.isComponentMounted=!1,this.scheduleDestroy()})}refreshEditorInstance(e){if(this.editor&&!this.editor.isDestroyed){if(this.previousDeps===null){this.previousDeps=e;return}if(this.previousDeps.length===e.length&&this.previousDeps.every((r,i)=>r===e[i]))return}this.editor&&!this.editor.isDestroyed&&this.editor.destroy(),this.setEditor(this.createEditor()),this.previousDeps=e}scheduleDestroy(){const e=this.instanceId,n=this.editor;this.scheduledDestructionTimeout=setTimeout(()=>{if(this.isComponentMounted&&this.instanceId===e){n&&n.setOptions(this.options.current);return}n&&!n.isDestroyed&&(n.destroy(),this.instanceId===e&&this.setEditor(null))},1)}};function kQe(t={},e=[]){const n=w.useRef(t);n.current=t;const[r]=w.useState(()=>new _Qe(n)),i=hO.useSyncExternalStore(r.subscribe,r.getEditor,r.getServerSnapshot);return w.useDebugValue(i),w.useEffect(r.onRender(e)),vQe({editor:i,selector:({transactionNumber:s})=>t.shouldRerenderOnTransaction===!1||t.shouldRerenderOnTransaction===void 0?null:t.immediatelyRender&&s===0?0:s+1}),i}var Noe=w.createContext({editor:null});Noe.Consumer;var NQe=w.createContext({onDragStart:()=>{},nodeViewContentChildren:void 0,nodeViewContentRef:()=>{}}),CQe=()=>w.useContext(NQe);ia.forwardRef((t,e)=>{const{onDragStart:n}=CQe(),r=t.as||\"div\";return a.jsx(r,{...t,ref:e,\"data-node-view-wrapper\":\"\",onDragStart:n,style:{whiteSpace:\"normal\",...t.style}})});ia.createContext({markViewContentRef:()=>{}});var Zz=w.createContext({get editor(){throw new Error(\"useTiptap must be used within a <Tiptap> provider\")}});Zz.displayName=\"TiptapContext\";var PQe=()=>w.useContext(Zz);function Coe({editor:t,instance:e,children:n}){const r=t??e;if(!r)throw new Error(\"Tiptap: An editor instance is required. Pass a non-null `editor` prop.\");const i=w.useMemo(()=>({editor:r}),[r]),s=w.useMemo(()=>({editor:r}),[r]);return a.jsx(Noe.Provider,{value:s,children:a.jsx(Zz.Provider,{value:i,children:n})})}Coe.displayName=\"Tiptap\";function Poe({...t}){const{editor:e}=PQe();return a.jsx(_oe,{editor:e,...t})}Poe.displayName=\"Tiptap.Content\";Object.assign(Coe,{Content:Poe});var nP=(t,e)=>{if(t===\"slot\")return 0;if(t instanceof Function)return t(e);const{children:n,...r}=e??{};if(t===\"svg\")throw new Error(\"SVG elements are not supported in the JSX syntax, use the array syntax instead\");return[t,r,n]},TQe=/^\\s*>\\s$/,AQe=Vo.create({name:\"blockquote\",addOptions(){return{HTMLAttributes:{}}},content:\"block+\",group:\"block\",defining:!0,parseHTML(){return[{tag:\"blockquote\"}]},renderHTML({HTMLAttributes:t}){return nP(\"blockquote\",{...ni(this.options.HTMLAttributes,t),children:nP(\"slot\",{})})},parseMarkdown:(t,e)=>e.createNode(\"blockquote\",void 0,e.parseChildren(t.tokens||[])),renderMarkdown:(t,e)=>{if(!t.content)return\"\";const n=\">\",r=[];return t.content.forEach(i=>{const l=e.renderChildren([i]).split(`\n`).map(c=>c.trim()===\"\"?n:`${n} ${c}`);r.push(l.join(`\n`))}),r.join(`\n${n}\n`)},addCommands(){return{setBlockquote:()=>({commands:t})=>t.wrapIn(this.name),toggleBlockquote:()=>({commands:t})=>t.toggleWrap(this.name),unsetBlockquote:()=>({commands:t})=>t.lift(this.name)}},addKeyboardShortcuts(){return{\"Mod-Shift-b\":()=>this.editor.commands.toggleBlockquote()}},addInputRules(){return[py({find:TQe,type:this.type})]}}),jQe=/(?:^|\\s)(\\*\\*(?!\\s+\\*\\*)((?:[^*]+))\\*\\*(?!\\s+\\*\\*))$/,MQe=/(?:^|\\s)(\\*\\*(?!\\s+\\*\\*)((?:[^*]+))\\*\\*(?!\\s+\\*\\*))/g,EQe=/(?:^|\\s)(__(?!\\s+__)((?:[^_]+))__(?!\\s+__))$/,DQe=/(?:^|\\s)(__(?!\\s+__)((?:[^_]+))__(?!\\s+__))/g,FQe=Lp.create({name:\"bold\",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:\"strong\"},{tag:\"b\",getAttrs:t=>t.style.fontWeight!==\"normal\"&&null},{style:\"font-weight=400\",clearMark:t=>t.type.name===this.name},{style:\"font-weight\",getAttrs:t=>/^(bold(er)?|[5-9]\\d{2,})$/.test(t)&&null}]},renderHTML({HTMLAttributes:t}){return nP(\"strong\",{...ni(this.options.HTMLAttributes,t),children:nP(\"slot\",{})})},markdownTokenName:\"strong\",parseMarkdown:(t,e)=>e.applyMark(\"bold\",e.parseInline(t.tokens||[])),renderMarkdown:(t,e)=>`**${e.renderChildren(t)}**`,addCommands(){return{setBold:()=>({commands:t})=>t.setMark(this.name),toggleBold:()=>({commands:t})=>t.toggleMark(this.name),unsetBold:()=>({commands:t})=>t.unsetMark(this.name)}},addKeyboardShortcuts(){return{\"Mod-b\":()=>this.editor.commands.toggleBold(),\"Mod-B\":()=>this.editor.commands.toggleBold()}},addInputRules(){return[hy({find:jQe,type:this.type}),hy({find:EQe,type:this.type})]},addPasteRules(){return[Fg({find:MQe,type:this.type}),Fg({find:DQe,type:this.type})]}}),RQe=/(^|[^`])`([^`]+)`(?!`)$/,LQe=/(^|[^`])`([^`]+)`(?!`)/g,OQe=Lp.create({name:\"code\",addOptions(){return{HTMLAttributes:{}}},excludes:\"_\",code:!0,exitable:!0,parseHTML(){return[{tag:\"code\"}]},renderHTML({HTMLAttributes:t}){return[\"code\",ni(this.options.HTMLAttributes,t),0]},markdownTokenName:\"codespan\",parseMarkdown:(t,e)=>e.applyMark(\"code\",[{type:\"text\",text:t.text||\"\"}]),renderMarkdown:(t,e)=>t.content?`\\`${e.renderChildren(t.content)}\\``:\"\",addCommands(){return{setCode:()=>({commands:t})=>t.setMark(this.name),toggleCode:()=>({commands:t})=>t.toggleMark(this.name),unsetCode:()=>({commands:t})=>t.unsetMark(this.name)}},addKeyboardShortcuts(){return{\"Mod-e\":()=>this.editor.commands.toggleCode()}},addInputRules(){return[hy({find:RQe,type:this.type})]},addPasteRules(){return[Fg({find:LQe,type:this.type})]}}),d3=4,IQe=/^```([a-z]+)?[\\s\\n]$/,zQe=/^~~~([a-z]+)?[\\s\\n]$/,UQe=Vo.create({name:\"codeBlock\",addOptions(){return{languageClassPrefix:\"language-\",exitOnTripleEnter:!0,exitOnArrowDown:!0,defaultLanguage:null,enableTabIndentation:!1,tabSize:d3,HTMLAttributes:{}}},content:\"text*\",marks:\"\",group:\"block\",code:!0,defining:!0,addAttributes(){return{language:{default:this.options.defaultLanguage,parseHTML:t=>{var e;const{languageClassPrefix:n}=this.options;if(!n)return null;const s=[...((e=t.firstElementChild)==null?void 0:e.classList)||[]].filter(o=>o.startsWith(n)).map(o=>o.replace(n,\"\"))[0];return s||null},rendered:!1}}},parseHTML(){return[{tag:\"pre\",preserveWhitespace:\"full\"}]},renderHTML({node:t,HTMLAttributes:e}){return[\"pre\",ni(this.options.HTMLAttributes,e),[\"code\",{class:t.attrs.language?this.options.languageClassPrefix+t.attrs.language:null},0]]},markdownTokenName:\"code\",parseMarkdown:(t,e)=>{var n;return((n=t.raw)==null?void 0:n.startsWith(\"```\"))===!1&&t.codeBlockStyle!==\"indented\"?[]:e.createNode(\"codeBlock\",{language:t.lang||null},t.text?[e.createTextNode(t.text)]:[])},renderMarkdown:(t,e)=>{var n;let r=\"\";const i=((n=t.attrs)==null?void 0:n.language)||\"\";return t.content?r=[`\\`\\`\\`${i}`,e.renderChildren(t.content),\"```\"].join(`\n`):r=`\\`\\`\\`${i}\n\n\\`\\`\\``,r},addCommands(){return{setCodeBlock:t=>({commands:e})=>e.setNode(this.name,t),toggleCodeBlock:t=>({commands:e})=>e.toggleNode(this.name,\"paragraph\",t)}},addKeyboardShortcuts(){return{\"Mod-Alt-c\":()=>this.editor.commands.toggleCodeBlock(),Backspace:()=>{const{empty:t,$anchor:e}=this.editor.state.selection,n=e.pos===1;return!t||e.parent.type.name!==this.name?!1:n||!e.parent.textContent.length?this.editor.commands.clearNodes():!1},Tab:({editor:t})=>{var e;if(!this.options.enableTabIndentation)return!1;const n=(e=this.options.tabSize)!=null?e:d3,{state:r}=t,{selection:i}=r,{$from:s,empty:o}=i;if(s.parent.type!==this.type)return!1;const l=\" \".repeat(n);return o?t.commands.insertContent(l):t.commands.command(({tr:c})=>{const{from:d,to:u}=i,f=r.doc.textBetween(d,u,`\n`,`\n`).split(`\n`).map(y=>l+y).join(`\n`);return c.replaceWith(d,u,r.schema.text(f)),!0})},\"Shift-Tab\":({editor:t})=>{var e;if(!this.options.enableTabIndentation)return!1;const n=(e=this.options.tabSize)!=null?e:d3,{state:r}=t,{selection:i}=r,{$from:s,empty:o}=i;return s.parent.type!==this.type?!1:o?t.commands.command(({tr:l})=>{var c;const{pos:d}=s,u=s.start(),m=s.end(),f=r.doc.textBetween(u,m,`\n`,`\n`).split(`\n`);let y=0,v=0;const b=d-u;for(let A=0;A<f.length;A+=1){if(v+f[A].length>=b){y=A;break}v+=f[A].length+1}const _=((c=f[y].match(/^ */))==null?void 0:c[0])||\"\",C=Math.min(_.length,n);if(C===0)return!0;let P=u;for(let A=0;A<y;A+=1)P+=f[A].length+1;return l.delete(P,P+C),d-P<=C&&l.setSelection($n.create(l.doc,P)),!0}):t.commands.command(({tr:l})=>{const{from:c,to:d}=i,p=r.doc.textBetween(c,d,`\n`,`\n`).split(`\n`).map(f=>{var y;const v=((y=f.match(/^ */))==null?void 0:y[0])||\"\",b=Math.min(v.length,n);return f.slice(b)}).join(`\n`);return l.replaceWith(c,d,r.schema.text(p)),!0})},Enter:({editor:t})=>{if(!this.options.exitOnTripleEnter)return!1;const{state:e}=t,{selection:n}=e,{$from:r,empty:i}=n;if(!i||r.parent.type!==this.type)return!1;const s=r.parentOffset===r.parent.nodeSize-2,o=r.parent.textContent.endsWith(`\n\n`);return!s||!o?!1:t.chain().command(({tr:l})=>(l.delete(r.pos-2,r.pos),!0)).exitCode().run()},ArrowDown:({editor:t})=>{if(!this.options.exitOnArrowDown)return!1;const{state:e}=t,{selection:n,doc:r}=e,{$from:i,empty:s}=n;if(!s||i.parent.type!==this.type||!(i.parentOffset===i.parent.nodeSize-2))return!1;const l=i.after();return l===void 0?!1:r.nodeAt(l)?t.commands.command(({tr:d})=>(d.setSelection(rr.near(r.resolve(l))),!0)):t.commands.exitCode()}}},addInputRules(){return[HL({find:IQe,type:this.type,getAttributes:t=>({language:t[1]})}),HL({find:zQe,type:this.type,getAttributes:t=>({language:t[1]})})]},addProseMirrorPlugins(){return[new Ua({key:new zi(\"codeBlockVSCodeHandler\"),props:{handlePaste:(t,e)=>{if(!e.clipboardData||this.editor.isActive(this.type.name))return!1;const n=e.clipboardData.getData(\"text/plain\"),r=e.clipboardData.getData(\"vscode-editor-data\"),i=r?JSON.parse(r):void 0,s=i?.mode;if(!n||!s)return!1;const{tr:o,schema:l}=t.state,c=l.text(n.replace(/\\r\\n?/g,`\n`));return o.replaceSelectionWith(this.type.create({language:s},c)),o.selection.$from.parent.type!==this.type&&o.setSelection($n.near(o.doc.resolve(Math.max(0,o.selection.from-2)))),o.setMeta(\"paste\",!0),t.dispatch(o),!0}}})]}}),BQe=Vo.create({name:\"doc\",topNode:!0,content:\"block+\",renderMarkdown:(t,e)=>t.content?e.renderChildren(t.content,`\n\n`):\"\"}),HQe=Vo.create({name:\"hardBreak\",markdownTokenName:\"br\",addOptions(){return{keepMarks:!0,HTMLAttributes:{}}},inline:!0,group:\"inline\",selectable:!1,linebreakReplacement:!0,parseHTML(){return[{tag:\"br\"}]},renderHTML({HTMLAttributes:t}){return[\"br\",ni(this.options.HTMLAttributes,t)]},renderText(){return`\n`},renderMarkdown:()=>`  \n`,parseMarkdown:()=>({type:\"hardBreak\"}),addCommands(){return{setHardBreak:()=>({commands:t,chain:e,state:n,editor:r})=>t.first([()=>t.exitCode(),()=>t.command(()=>{const{selection:i,storedMarks:s}=n;if(i.$from.parent.type.spec.isolating)return!1;const{keepMarks:o}=this.options,{splittableMarks:l}=r.extensionManager,c=s||i.$to.parentOffset&&i.$from.marks();return e().insertContent({type:this.name}).command(({tr:d,dispatch:u})=>{if(u&&c&&o){const m=c.filter(p=>l.includes(p.type.name));d.ensureMarks(m)}return!0}).run()})])}},addKeyboardShortcuts(){return{\"Mod-Enter\":()=>this.editor.commands.setHardBreak(),\"Shift-Enter\":()=>this.editor.commands.setHardBreak()}}}),qQe=Vo.create({name:\"heading\",addOptions(){return{levels:[1,2,3,4,5,6],HTMLAttributes:{}}},content:\"inline*\",group:\"block\",defining:!0,addAttributes(){return{level:{default:1,rendered:!1}}},parseHTML(){return this.options.levels.map(t=>({tag:`h${t}`,attrs:{level:t}}))},renderHTML({node:t,HTMLAttributes:e}){return[`h${this.options.levels.includes(t.attrs.level)?t.attrs.level:this.options.levels[0]}`,ni(this.options.HTMLAttributes,e),0]},parseMarkdown:(t,e)=>e.createNode(\"heading\",{level:t.depth||1},e.parseInline(t.tokens||[])),renderMarkdown:(t,e)=>{var n;const r=(n=t.attrs)!=null&&n.level?parseInt(t.attrs.level,10):1,i=\"#\".repeat(r);return t.content?`${i} ${e.renderChildren(t.content)}`:\"\"},addCommands(){return{setHeading:t=>({commands:e})=>this.options.levels.includes(t.level)?e.setNode(this.name,t):!1,toggleHeading:t=>({commands:e})=>this.options.levels.includes(t.level)?e.toggleNode(this.name,\"paragraph\",t):!1}},addKeyboardShortcuts(){return this.options.levels.reduce((t,e)=>({...t,[`Mod-Alt-${e}`]:()=>this.editor.commands.toggleHeading({level:e})}),{})},addInputRules(){return this.options.levels.map(t=>HL({find:new RegExp(`^(#{${Math.min(...this.options.levels)},${t}})\\\\s$`),type:this.type,getAttributes:{level:t}}))}}),$Qe=Vo.create({name:\"horizontalRule\",addOptions(){return{HTMLAttributes:{},nextNodeType:\"paragraph\"}},group:\"block\",parseHTML(){return[{tag:\"hr\"}]},renderHTML({HTMLAttributes:t}){return[\"hr\",ni(this.options.HTMLAttributes,t)]},markdownTokenName:\"hr\",parseMarkdown:(t,e)=>e.createNode(\"horizontalRule\"),renderMarkdown:()=>\"---\",addCommands(){return{setHorizontalRule:()=>({chain:t,state:e})=>{if(!vYe(e,e.schema.nodes[this.name]))return!1;const{selection:n}=e,{$to:r}=n,i=t();return roe(n)?i.insertContentAt(r.pos,{type:this.name}):i.insertContent({type:this.name}),i.command(({state:s,tr:o,dispatch:l})=>{if(l){const{$to:c}=o.selection,d=c.end();if(c.nodeAfter)c.nodeAfter.isTextblock?o.setSelection($n.create(o.doc,c.pos+1)):c.nodeAfter.isBlock?o.setSelection(An.create(o.doc,c.pos)):o.setSelection($n.create(o.doc,c.pos));else{const u=s.schema.nodes[this.options.nextNodeType]||c.parent.type.contentMatch.defaultType,m=u?.create();m&&(o.insert(d,m),o.setSelection($n.create(o.doc,d+1)))}o.scrollIntoView()}return!0}).run()}}},addInputRules(){return[voe({find:/^(?:---|—-|___\\s|\\*\\*\\*\\s)$/,type:this.type})]}}),VQe=/(?:^|\\s)(\\*(?!\\s+\\*)((?:[^*]+))\\*(?!\\s+\\*))$/,GQe=/(?:^|\\s)(\\*(?!\\s+\\*)((?:[^*]+))\\*(?!\\s+\\*))/g,WQe=/(?:^|\\s)(_(?!\\s+_)((?:[^_]+))_(?!\\s+_))$/,KQe=/(?:^|\\s)(_(?!\\s+_)((?:[^_]+))_(?!\\s+_))/g,XQe=Lp.create({name:\"italic\",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:\"em\"},{tag:\"i\",getAttrs:t=>t.style.fontStyle!==\"normal\"&&null},{style:\"font-style=normal\",clearMark:t=>t.type.name===this.name},{style:\"font-style=italic\"}]},renderHTML({HTMLAttributes:t}){return[\"em\",ni(this.options.HTMLAttributes,t),0]},addCommands(){return{setItalic:()=>({commands:t})=>t.setMark(this.name),toggleItalic:()=>({commands:t})=>t.toggleMark(this.name),unsetItalic:()=>({commands:t})=>t.unsetMark(this.name)}},markdownTokenName:\"em\",parseMarkdown:(t,e)=>e.applyMark(\"italic\",e.parseInline(t.tokens||[])),renderMarkdown:(t,e)=>`*${e.renderChildren(t)}*`,addKeyboardShortcuts(){return{\"Mod-i\":()=>this.editor.commands.toggleItalic(),\"Mod-I\":()=>this.editor.commands.toggleItalic()}},addInputRules(){return[hy({find:VQe,type:this.type}),hy({find:WQe,type:this.type})]},addPasteRules(){return[Fg({find:GQe,type:this.type}),Fg({find:KQe,type:this.type})]}});const YQe=\"aaa1rp3bb0ott3vie4c1le2ogado5udhabi7c0ademy5centure6ountant0s9o1tor4d0s1ult4e0g1ro2tna4f0l1rica5g0akhan5ency5i0g1rbus3force5tel5kdn3l0ibaba4pay4lfinanz6state5y2sace3tom5m0azon4ericanexpress7family11x2fam3ica3sterdam8nalytics7droid5quan4z2o0l2partments8p0le4q0uarelle8r0ab1mco4chi3my2pa2t0e3s0da2ia2sociates9t0hleta5torney7u0ction5di0ble3o3spost5thor3o0s4w0s2x0a2z0ure5ba0by2idu3namex4d1k2r0celona5laycard4s5efoot5gains6seball5ketball8uhaus5yern5b0c1t1va3cg1n2d1e0ats2uty4er2rlin4st0buy5t2f1g1h0arti5i0ble3d1ke2ng0o3o1z2j1lack0friday9ockbuster8g1omberg7ue3m0s1w2n0pparibas9o0ats3ehringer8fa2m1nd2o0k0ing5sch2tik2on4t1utique6x2r0adesco6idgestone9oadway5ker3ther5ussels7s1t1uild0ers6siness6y1zz3v1w1y1z0h3ca0b1fe2l0l1vinklein9m0era3p2non3petown5ital0one8r0avan4ds2e0er0s4s2sa1e1h1ino4t0ering5holic7ba1n1re3c1d1enter4o1rn3f0a1d2g1h0anel2nel4rity4se2t2eap3intai5ristmas6ome4urch5i0priani6rcle4sco3tadel4i0c2y3k1l0aims4eaning6ick2nic1que6othing5ud3ub0med6m1n1o0ach3des3ffee4llege4ogne5m0mbank4unity6pany2re3uter5sec4ndos3struction8ulting7tact3ractors9oking4l1p2rsica5untry4pon0s4rses6pa2r0edit0card4union9icket5own3s1uise0s6u0isinella9v1w1x1y0mru3ou3z2dad1nce3ta1e1ing3sun4y2clk3ds2e0al0er2s3gree4livery5l1oitte5ta3mocrat6ntal2ist5si0gn4v2hl2iamonds6et2gital5rect0ory7scount3ver5h2y2j1k1m1np2o0cs1tor4g1mains5t1wnload7rive4tv2ubai3nlop4pont4rban5vag2r2z2earth3t2c0o2deka3u0cation8e1g1mail3erck5nergy4gineer0ing9terprises10pson4quipment8r0icsson6ni3s0q1tate5t1u0rovision8s2vents5xchange6pert3osed4ress5traspace10fage2il1rwinds6th3mily4n0s2rm0ers5shion4t3edex3edback6rrari3ero6i0delity5o2lm2nal1nce1ial7re0stone6mdale6sh0ing5t0ness6j1k1lickr3ghts4r2orist4wers5y2m1o0o0d1tball6rd1ex2sale4um3undation8x2r0ee1senius7l1ogans4ntier7tr2ujitsu5n0d2rniture7tbol5yi3ga0l0lery3o1up4me0s3p1rden4y2b0iz3d0n2e0a1nt0ing5orge5f1g0ee3h1i0ft0s3ves2ing5l0ass3e1obal2o4m0ail3bh2o1x2n1odaddy5ld0point6f2o0dyear5g0le4p1t1v2p1q1r0ainger5phics5tis4een3ipe3ocery4up4s1t1u0cci3ge2ide2tars5ru3w1y2hair2mburg5ngout5us3bo2dfc0bank7ealth0care8lp1sinki6re1mes5iphop4samitsu7tachi5v2k0t2m1n1ockey4ldings5iday5medepot5goods5s0ense7nda3rse3spital5t0ing5t0els3mail5use3w2r1sbc3t1u0ghes5yatt3undai7ibm2cbc2e1u2d1e0ee3fm2kano4l1m0amat4db2mo0bilien9n0c1dustries8finiti5o2g1k1stitute6urance4e4t0ernational10uit4vestments10o1piranga7q1r0ish4s0maili5t0anbul7t0au2v3jaguar4va3cb2e0ep2tzt3welry6io2ll2m0p2nj2o0bs1urg4t1y2p0morgan6rs3uegos4niper7kaufen5ddi3e0rryhotels6properties14fh2g1h1i0a1ds2m1ndle4tchen5wi3m1n1oeln3matsu5sher5p0mg2n2r0d1ed3uokgroup8w1y0oto4z2la0caixa5mborghini8er3nd0rover6xess5salle5t0ino3robe5w0yer5b1c1ds2ease3clerc5frak4gal2o2xus4gbt3i0dl2fe0insurance9style7ghting6ke2lly3mited4o2ncoln4k2ve1ing5k1lc1p2oan0s3cker3us3l1ndon4tte1o3ve3pl0financial11r1s1t0d0a3u0ndbeck6xe1ury5v1y2ma0drid4if1son4keup4n0agement7go3p1rket0ing3s4riott5shalls7ttel5ba2c0kinsey7d1e0d0ia3et2lbourne7me1orial6n0u2rckmsd7g1h1iami3crosoft7l1ni1t2t0subishi9k1l0b1s2m0a2n1o0bi0le4da2e1i1m1nash3ey2ster5rmon3tgage6scow4to0rcycles9v0ie4p1q1r1s0d2t0n1r2u0seum3ic4v1w1x1y1z2na0b1goya4me2vy3ba2c1e0c1t0bank4flix4work5ustar5w0s2xt0direct7us4f0l2g0o2hk2i0co2ke1on3nja3ssan1y5l1o0kia3rton4w0ruz3tv4p1r0a1w2tt2u1yc2z2obi1server7ffice5kinawa6layan0group9lo3m0ega4ne1g1l0ine5oo2pen3racle3nge4g0anic5igins6saka4tsuka4t2vh3pa0ge2nasonic7ris2s1tners4s1y3y2ccw3e0t2f0izer5g1h0armacy6d1ilips5one2to0graphy6s4ysio5ics1tet2ures6d1n0g1k2oneer5zza4k1l0ace2y0station9umbing5s3m1n0c2ohl2ker3litie5rn2st3r0axi3ess3ime3o0d0uctions8f1gressive8mo2perties3y5tection8u0dential9s1t1ub2w0c2y2qa1pon3uebec3st5racing4dio4e0ad1lestate6tor2y4cipes5d0stone5umbrella9hab3ise0n3t2liance6n0t0als5pair3ort3ublican8st0aurant8view0s5xroth6ich0ardli6oh3l1o1p2o0cks3deo3gers4om3s0vp3u0gby3hr2n2w0e2yukyu6sa0arland6fe0ty4kura4le1on3msclub4ung5ndvik0coromant12ofi4p1rl2s1ve2xo3b0i1s2c0b1haeffler7midt4olarships8ol3ule3warz5ience5ot3d1e0arch3t2cure1ity6ek2lect4ner3rvices6ven3w1x0y3fr2g1h0angrila6rp3ell3ia1ksha5oes2p0ping5uji3w3i0lk2na1gles5te3j1k0i0n2y0pe4l0ing4m0art3ile4n0cf3o0ccer3ial4ftbank4ware6hu2lar2utions7ng1y2y2pa0ce3ort2t3r0l2s1t0ada2ples4r1tebank4farm7c0group6ockholm6rage3e3ream4udio2y3yle4u0cks3pplies3y2ort5rf1gery5zuki5v1watch4iss4x1y0dney4stems6z2tab1ipei4lk2obao4rget4tamotors6r2too4x0i3c0i2d0k2eam2ch0nology8l1masek5nnis4va3f1g1h0d1eater2re6iaa2ckets5enda4ps2res2ol4j0maxx4x2k0maxx5l1m0all4n1o0day3kyo3ols3p1ray3shiba5tal3urs3wn2yota3s3r0ade1ing4ining5vel0ers0insurance16ust3v2t1ube2i1nes3shu4v0s2w1z2ua1bank3s2g1k1nicom3versity8o2ol2ps2s1y1z2va0cations7na1guard7c1e0gas3ntures6risign5mögensberater2ung14sicherung10t2g1i0ajes4deo3g1king4llas4n1p1rgin4sa1ion4va1o3laanderen9n1odka3lvo3te1ing3o2yage5u2wales2mart4ter4ng0gou5tch0es6eather0channel12bcam3er2site5d0ding5ibo2r3f1hoswho6ien2ki2lliamhill9n0dows4e1ners6me2olterskluwer11odside6rk0s2ld3w2s1tc1f3xbox3erox4ihuan4n2xx2yz3yachts4hoo3maxun5ndex5e1odobashi7ga2kohama6u0tube6t1un3za0ppos4ra3ero3ip2m1one3uerich6w2\",QQe=\"ελ1υ2бг1ел3дети4ею2католик6ом3мкд2он1сква6онлайн5рг3рус2ф2сайт3рб3укр3қаз3հայ3ישראל5קום3ابوظبي5رامكو5لاردن4بحرين5جزائر5سعودية6عليان5مغرب5مارات5یران5بارت2زار4يتك3ھارت5تونس4سودان3رية5شبكة4عراق2ب2مان4فلسطين6قطر3كاثوليك6وم3مصر2ليسيا5وريتانيا7قع4همراه5پاکستان7ڀارت4कॉम3नेट3भारत0म्3ोत5संगठन5বাংলা5ভারত2ৰত4ਭਾਰਤ4ભારત4ଭାରତ4இந்தியா6லங்கை6சிங்கப்பூர்11భారత్5ಭಾರತ4ഭാരതം5ලංකා4คอม3ไทย3ລາວ3გე2みんな3アマゾン4クラウド4グーグル4コム2ストア3セール3ファッション6ポイント4世界2中信1国1國1文网3亚马逊3企业2佛山2信息2健康2八卦2公司1益2台湾1灣2商城1店1标2嘉里0大酒店5在线2大拿2天主教3娱乐2家電2广东2微博2慈善2我爱你3手机2招聘2政务1府2新加坡2闻2时尚2書籍2机构2淡马锡3游戏2澳門2点看2移动2组织机构4网址1店1站1络2联通2谷歌2购物2通販2集团2電訊盈科4飞利浦3食品2餐厅2香格里拉3港2닷넷1컴2삼성2한국2\",VL=\"numeric\",GL=\"ascii\",WL=\"alpha\",q0=\"asciinumeric\",k0=\"alphanumeric\",KL=\"domain\",Toe=\"emoji\",ZQe=\"scheme\",JQe=\"slashscheme\",u3=\"whitespace\";function eZe(t,e){return t in e||(e[t]=[]),e[t]}function Wf(t,e,n){e[VL]&&(e[q0]=!0,e[k0]=!0),e[GL]&&(e[q0]=!0,e[WL]=!0),e[q0]&&(e[k0]=!0),e[WL]&&(e[k0]=!0),e[k0]&&(e[KL]=!0),e[Toe]&&(e[KL]=!0);for(const r in e){const i=eZe(r,n);i.indexOf(t)<0&&i.push(t)}}function tZe(t,e){const n={};for(const r in e)e[r].indexOf(t)>=0&&(n[r]=!0);return n}function Ro(t=null){this.j={},this.jr=[],this.jd=null,this.t=t}Ro.groups={};Ro.prototype={accepts(){return!!this.t},go(t){const e=this,n=e.j[t];if(n)return n;for(let r=0;r<e.jr.length;r++){const i=e.jr[r][0],s=e.jr[r][1];if(s&&i.test(t))return s}return e.jd},has(t,e=!1){return e?t in this.j:!!this.go(t)},ta(t,e,n,r){for(let i=0;i<t.length;i++)this.tt(t[i],e,n,r)},tr(t,e,n,r){r=r||Ro.groups;let i;return e&&e.j?i=e:(i=new Ro(e),n&&r&&Wf(e,n,r)),this.jr.push([t,i]),i},ts(t,e,n,r){let i=this;const s=t.length;if(!s)return i;for(let o=0;o<s-1;o++)i=i.tt(t[o]);return i.tt(t[s-1],e,n,r)},tt(t,e,n,r){r=r||Ro.groups;const i=this;if(e&&e.j)return i.j[t]=e,e;const s=e;let o,l=i.go(t);if(l?(o=new Ro,Object.assign(o.j,l.j),o.jr.push.apply(o.jr,l.jr),o.jd=l.jd,o.t=l.t):o=new Ro,s){if(r)if(o.t&&typeof o.t==\"string\"){const c=Object.assign(tZe(o.t,r),n);Wf(s,c,r)}else n&&Wf(s,n,r);o.t=s}return i.j[t]=o,o}};const Jn=(t,e,n,r,i)=>t.ta(e,n,r,i),Va=(t,e,n,r,i)=>t.tr(e,n,r,i),LX=(t,e,n,r,i)=>t.ts(e,n,r,i),Gt=(t,e,n,r,i)=>t.tt(e,n,r,i),Bu=\"WORD\",XL=\"UWORD\",Aoe=\"ASCIINUMERICAL\",joe=\"ALPHANUMERICAL\",qw=\"LOCALHOST\",YL=\"TLD\",QL=\"UTLD\",sN=\"SCHEME\",bx=\"SLASH_SCHEME\",Jz=\"NUM\",ZL=\"WS\",e5=\"NL\",$0=\"OPENBRACE\",V0=\"CLOSEBRACE\",rP=\"OPENBRACKET\",aP=\"CLOSEBRACKET\",iP=\"OPENPAREN\",sP=\"CLOSEPAREN\",oP=\"OPENANGLEBRACKET\",lP=\"CLOSEANGLEBRACKET\",cP=\"FULLWIDTHLEFTPAREN\",dP=\"FULLWIDTHRIGHTPAREN\",uP=\"LEFTCORNERBRACKET\",mP=\"RIGHTCORNERBRACKET\",hP=\"LEFTWHITECORNERBRACKET\",pP=\"RIGHTWHITECORNERBRACKET\",fP=\"FULLWIDTHLESSTHAN\",gP=\"FULLWIDTHGREATERTHAN\",bP=\"AMPERSAND\",xP=\"APOSTROPHE\",yP=\"ASTERISK\",Eh=\"AT\",vP=\"BACKSLASH\",wP=\"BACKTICK\",SP=\"CARET\",Ih=\"COLON\",t5=\"COMMA\",_P=\"DOLLAR\",wd=\"DOT\",kP=\"EQUALS\",n5=\"EXCLAMATION\",$l=\"HYPHEN\",G0=\"PERCENT\",NP=\"PIPE\",CP=\"PLUS\",PP=\"POUND\",W0=\"QUERY\",r5=\"QUOTE\",Moe=\"FULLWIDTHMIDDLEDOT\",a5=\"SEMI\",Sd=\"SLASH\",K0=\"TILDE\",TP=\"UNDERSCORE\",Eoe=\"EMOJI\",AP=\"SYM\";var Doe=Object.freeze({__proto__:null,ALPHANUMERICAL:joe,AMPERSAND:bP,APOSTROPHE:xP,ASCIINUMERICAL:Aoe,ASTERISK:yP,AT:Eh,BACKSLASH:vP,BACKTICK:wP,CARET:SP,CLOSEANGLEBRACKET:lP,CLOSEBRACE:V0,CLOSEBRACKET:aP,CLOSEPAREN:sP,COLON:Ih,COMMA:t5,DOLLAR:_P,DOT:wd,EMOJI:Eoe,EQUALS:kP,EXCLAMATION:n5,FULLWIDTHGREATERTHAN:gP,FULLWIDTHLEFTPAREN:cP,FULLWIDTHLESSTHAN:fP,FULLWIDTHMIDDLEDOT:Moe,FULLWIDTHRIGHTPAREN:dP,HYPHEN:$l,LEFTCORNERBRACKET:uP,LEFTWHITECORNERBRACKET:hP,LOCALHOST:qw,NL:e5,NUM:Jz,OPENANGLEBRACKET:oP,OPENBRACE:$0,OPENBRACKET:rP,OPENPAREN:iP,PERCENT:G0,PIPE:NP,PLUS:CP,POUND:PP,QUERY:W0,QUOTE:r5,RIGHTCORNERBRACKET:mP,RIGHTWHITECORNERBRACKET:pP,SCHEME:sN,SEMI:a5,SLASH:Sd,SLASH_SCHEME:bx,SYM:AP,TILDE:K0,TLD:YL,UNDERSCORE:TP,UTLD:QL,UWORD:XL,WORD:Bu,WS:ZL});const Lu=/[a-z]/,l0=new RegExp(\"\\\\p{L}\",\"u\"),m3=new RegExp(\"\\\\p{Emoji}\",\"u\"),Ou=/\\d/,h3=/\\s/,OX=\"\\r\",p3=`\n`,nZe=\"️\",rZe=\"‍\",f3=\"￼\";let Ck=null,Pk=null;function aZe(t=[]){const e={};Ro.groups=e;const n=new Ro;Ck==null&&(Ck=IX(YQe)),Pk==null&&(Pk=IX(QQe)),Gt(n,\"'\",xP),Gt(n,\"{\",$0),Gt(n,\"}\",V0),Gt(n,\"[\",rP),Gt(n,\"]\",aP),Gt(n,\"(\",iP),Gt(n,\")\",sP),Gt(n,\"<\",oP),Gt(n,\">\",lP),Gt(n,\"（\",cP),Gt(n,\"）\",dP),Gt(n,\"「\",uP),Gt(n,\"」\",mP),Gt(n,\"『\",hP),Gt(n,\"』\",pP),Gt(n,\"＜\",fP),Gt(n,\"＞\",gP),Gt(n,\"&\",bP),Gt(n,\"*\",yP),Gt(n,\"@\",Eh),Gt(n,\"`\",wP),Gt(n,\"^\",SP),Gt(n,\":\",Ih),Gt(n,\",\",t5),Gt(n,\"$\",_P),Gt(n,\".\",wd),Gt(n,\"=\",kP),Gt(n,\"!\",n5),Gt(n,\"-\",$l),Gt(n,\"%\",G0),Gt(n,\"|\",NP),Gt(n,\"+\",CP),Gt(n,\"#\",PP),Gt(n,\"?\",W0),Gt(n,'\"',r5),Gt(n,\"/\",Sd),Gt(n,\";\",a5),Gt(n,\"~\",K0),Gt(n,\"_\",TP),Gt(n,\"\\\\\",vP),Gt(n,\"・\",Moe);const r=Va(n,Ou,Jz,{[VL]:!0});Va(r,Ou,r);const i=Va(r,Lu,Aoe,{[q0]:!0}),s=Va(r,l0,joe,{[k0]:!0}),o=Va(n,Lu,Bu,{[GL]:!0});Va(o,Ou,i),Va(o,Lu,o),Va(i,Ou,i),Va(i,Lu,i);const l=Va(n,l0,XL,{[WL]:!0});Va(l,Lu),Va(l,Ou,s),Va(l,l0,l),Va(s,Ou,s),Va(s,Lu),Va(s,l0,s);const c=Gt(n,p3,e5,{[u3]:!0}),d=Gt(n,OX,ZL,{[u3]:!0}),u=Va(n,h3,ZL,{[u3]:!0});Gt(n,f3,u),Gt(d,p3,c),Gt(d,f3,u),Va(d,h3,u),Gt(u,OX),Gt(u,p3),Va(u,h3,u),Gt(u,f3,u);const m=Va(n,m3,Eoe,{[Toe]:!0});Gt(m,\"#\"),Va(m,m3,m),Gt(m,nZe,m);const p=Gt(m,rZe);Gt(p,\"#\"),Va(p,m3,m);const f=[[Lu,o],[Ou,i]],y=[[Lu,null],[l0,l],[Ou,s]];for(let v=0;v<Ck.length;v++)Sh(n,Ck[v],YL,Bu,f);for(let v=0;v<Pk.length;v++)Sh(n,Pk[v],QL,XL,y);Wf(YL,{tld:!0,ascii:!0},e),Wf(QL,{utld:!0,alpha:!0},e),Sh(n,\"file\",sN,Bu,f),Sh(n,\"mailto\",sN,Bu,f),Sh(n,\"http\",bx,Bu,f),Sh(n,\"https\",bx,Bu,f),Sh(n,\"ftp\",bx,Bu,f),Sh(n,\"ftps\",bx,Bu,f),Wf(sN,{scheme:!0,ascii:!0},e),Wf(bx,{slashscheme:!0,ascii:!0},e),t=t.sort((v,b)=>v[0]>b[0]?1:-1);for(let v=0;v<t.length;v++){const b=t[v][0],_=t[v][1]?{[ZQe]:!0}:{[JQe]:!0};b.indexOf(\"-\")>=0?_[KL]=!0:Lu.test(b)?Ou.test(b)?_[q0]=!0:_[GL]=!0:_[VL]=!0,LX(n,b,b,_)}return LX(n,\"localhost\",qw,{ascii:!0}),n.jd=new Ro(AP),{start:n,tokens:Object.assign({groups:e},Doe)}}function Foe(t,e){const n=iZe(e.replace(/[A-Z]/g,l=>l.toLowerCase())),r=n.length,i=[];let s=0,o=0;for(;o<r;){let l=t,c=null,d=0,u=null,m=-1,p=-1;for(;o<r&&(c=l.go(n[o]));)l=c,l.accepts()?(m=0,p=0,u=l):m>=0&&(m+=n[o].length,p++),d+=n[o].length,s+=n[o].length,o++;s-=m,o-=p,d-=m,i.push({t:u.t,v:e.slice(s-d,s),s:s-d,e:s})}return i}function iZe(t){const e=[],n=t.length;let r=0;for(;r<n;){let i=t.charCodeAt(r),s,o=i<55296||i>56319||r+1===n||(s=t.charCodeAt(r+1))<56320||s>57343?t[r]:t.slice(r,r+2);e.push(o),r+=o.length}return e}function Sh(t,e,n,r,i){let s;const o=e.length;for(let l=0;l<o-1;l++){const c=e[l];t.j[c]?s=t.j[c]:(s=new Ro(r),s.jr=i.slice(),t.j[c]=s),t=s}return s=new Ro(n),s.jr=i.slice(),t.j[e[o-1]]=s,s}function IX(t){const e=[],n=[];let r=0,i=\"0123456789\";for(;r<t.length;){let s=0;for(;i.indexOf(t[r+s])>=0;)s++;if(s>0){e.push(n.join(\"\"));for(let o=parseInt(t.substring(r,r+s),10);o>0;o--)n.pop();r+=s}else n.push(t[r]),r++}return e}const $w={defaultProtocol:\"http\",events:null,format:zX,formatHref:zX,nl2br:!1,tagName:\"a\",target:null,rel:null,validate:!0,truncate:1/0,className:null,attributes:null,ignoreTags:[],render:null};function i5(t,e=null){let n=Object.assign({},$w);t&&(n=Object.assign(n,t instanceof i5?t.o:t));const r=n.ignoreTags,i=[];for(let s=0;s<r.length;s++)i.push(r[s].toUpperCase());this.o=n,e&&(this.defaultRender=e),this.ignoreTags=i}i5.prototype={o:$w,ignoreTags:[],defaultRender(t){return t},check(t){return this.get(\"validate\",t.toString(),t)},get(t,e,n){const r=e!=null;let i=this.o[t];return i&&(typeof i==\"object\"?(i=n.t in i?i[n.t]:$w[t],typeof i==\"function\"&&r&&(i=i(e,n))):typeof i==\"function\"&&r&&(i=i(e,n.t,n)),i)},getObj(t,e,n){let r=this.o[t];return typeof r==\"function\"&&e!=null&&(r=r(e,n.t,n)),r},render(t){const e=t.render(this);return(this.get(\"render\",null,t)||this.defaultRender)(e,t.t,t)}};function zX(t){return t}function Roe(t,e){this.t=\"token\",this.v=t,this.tk=e}Roe.prototype={isLink:!1,toString(){return this.v},toHref(t){return this.toString()},toFormattedString(t){const e=this.toString(),n=t.get(\"truncate\",e,this),r=t.get(\"format\",e,this);return n&&r.length>n?r.substring(0,n)+\"…\":r},toFormattedHref(t){return t.get(\"formatHref\",this.toHref(t.get(\"defaultProtocol\")),this)},startIndex(){return this.tk[0].s},endIndex(){return this.tk[this.tk.length-1].e},toObject(t=$w.defaultProtocol){return{type:this.t,value:this.toString(),isLink:this.isLink,href:this.toHref(t),start:this.startIndex(),end:this.endIndex()}},toFormattedObject(t){return{type:this.t,value:this.toFormattedString(t),isLink:this.isLink,href:this.toFormattedHref(t),start:this.startIndex(),end:this.endIndex()}},validate(t){return t.get(\"validate\",this.toString(),this)},render(t){const e=this,n=this.toHref(t.get(\"defaultProtocol\")),r=t.get(\"formatHref\",n,this),i=t.get(\"tagName\",n,e),s=this.toFormattedString(t),o={},l=t.get(\"className\",n,e),c=t.get(\"target\",n,e),d=t.get(\"rel\",n,e),u=t.getObj(\"attributes\",n,e),m=t.getObj(\"events\",n,e);return o.href=r,l&&(o.class=l),c&&(o.target=c),d&&(o.rel=d),u&&Object.assign(o,u),{tagName:i,attributes:o,content:s,eventListeners:m}}};function pA(t,e){class n extends Roe{constructor(i,s){super(i,s),this.t=t}}for(const r in e)n.prototype[r]=e[r];return n.t=t,n}const UX=pA(\"email\",{isLink:!0,toHref(){return\"mailto:\"+this.toString()}}),BX=pA(\"text\"),sZe=pA(\"nl\"),Tk=pA(\"url\",{isLink:!0,toHref(t=$w.defaultProtocol){return this.hasProtocol()?this.v:`${t}://${this.v}`},hasProtocol(){const t=this.tk;return t.length>=2&&t[0].t!==qw&&t[1].t===Ih}}),Bl=t=>new Ro(t);function oZe({groups:t}){const e=t.domain.concat([bP,yP,Eh,vP,wP,SP,_P,kP,$l,Jz,G0,NP,CP,PP,Sd,AP,K0,TP]),n=[xP,Ih,t5,wd,n5,G0,W0,r5,a5,oP,lP,$0,V0,aP,rP,iP,sP,cP,dP,uP,mP,hP,pP,fP,gP],r=[bP,xP,yP,vP,wP,SP,_P,kP,$l,$0,V0,G0,NP,CP,PP,W0,Sd,AP,K0,TP],i=Bl(),s=Gt(i,K0);Jn(s,r,s),Jn(s,t.domain,s);const o=Bl(),l=Bl(),c=Bl();Jn(i,t.domain,o),Jn(i,t.scheme,l),Jn(i,t.slashscheme,c),Jn(o,r,s),Jn(o,t.domain,o);const d=Gt(o,Eh);Gt(s,Eh,d),Gt(l,Eh,d),Gt(c,Eh,d);const u=Gt(s,wd);Jn(u,r,s),Jn(u,t.domain,s);const m=Bl();Jn(d,t.domain,m),Jn(m,t.domain,m);const p=Gt(m,wd);Jn(p,t.domain,m);const f=Bl(UX);Jn(p,t.tld,f),Jn(p,t.utld,f),Gt(d,qw,f);const y=Gt(m,$l);Gt(y,$l,y),Jn(y,t.domain,m),Jn(f,t.domain,m),Gt(f,wd,p),Gt(f,$l,y);const v=Gt(f,Ih);Jn(v,t.numeric,UX);const b=Gt(o,$l),g=Gt(o,wd);Gt(b,$l,b),Jn(b,t.domain,o),Jn(g,r,s),Jn(g,t.domain,o);const _=Bl(Tk);Jn(g,t.tld,_),Jn(g,t.utld,_),Jn(_,t.domain,o),Jn(_,r,s),Gt(_,wd,g),Gt(_,$l,b),Gt(_,Eh,d);const C=Gt(_,Ih),P=Bl(Tk);Jn(C,t.numeric,P);const N=Bl(Tk),A=Bl();Jn(N,e,N),Jn(N,n,A),Jn(A,e,N),Jn(A,n,A),Gt(_,Sd,N),Gt(P,Sd,N);const T=Gt(l,Ih),F=Gt(c,Ih),k=Gt(F,Sd),D=Gt(k,Sd);Jn(l,t.domain,o),Gt(l,wd,g),Gt(l,$l,b),Jn(c,t.domain,o),Gt(c,wd,g),Gt(c,$l,b),Jn(T,t.domain,N),Gt(T,Sd,N),Gt(T,W0,N),Jn(D,t.domain,N),Jn(D,e,N),Gt(D,Sd,N);const H=[[$0,V0],[rP,aP],[iP,sP],[oP,lP],[cP,dP],[uP,mP],[hP,pP],[fP,gP]];for(let z=0;z<H.length;z++){const[Q,L]=H[z],te=Gt(N,Q);Gt(A,Q,te),Gt(te,L,N);const ie=Bl(Tk);Jn(te,e,ie);const J=Bl();Jn(te,n),Jn(ie,e,ie),Jn(ie,n,J),Jn(J,e,ie),Jn(J,n,J),Gt(ie,L,N),Gt(J,L,N)}return Gt(i,qw,_),Gt(i,e5,sZe),{start:i,tokens:Doe}}function lZe(t,e,n){let r=n.length,i=0,s=[],o=[];for(;i<r;){let l=t,c=null,d=null,u=0,m=null,p=-1;for(;i<r&&!(c=l.go(n[i].t));)o.push(n[i++]);for(;i<r&&(d=c||l.go(n[i].t));)c=null,l=d,l.accepts()?(p=0,m=l):p>=0&&p++,i++,u++;if(p<0)i-=u,i<r&&(o.push(n[i]),i++);else{o.length>0&&(s.push(g3(BX,e,o)),o=[]),i-=p,u-=p;const f=m.t,y=n.slice(i-u,i);s.push(g3(f,e,y))}}return o.length>0&&s.push(g3(BX,e,o)),s}function g3(t,e,n){const r=n[0].s,i=n[n.length-1].e,s=e.slice(r,i);return new t(s,n)}const cZe=typeof console<\"u\"&&console&&console.warn||(()=>{}),dZe=\"until manual call of linkify.init(). Register all schemes and plugins before invoking linkify the first time.\",_a={scanner:null,parser:null,tokenQueue:[],pluginQueue:[],customSchemes:[],initialized:!1};function uZe(){return Ro.groups={},_a.scanner=null,_a.parser=null,_a.tokenQueue=[],_a.pluginQueue=[],_a.customSchemes=[],_a.initialized=!1,_a}function HX(t,e=!1){if(_a.initialized&&cZe(`linkifyjs: already initialized - will not register custom scheme \"${t}\" ${dZe}`),!/^[0-9a-z]+(-[0-9a-z]+)*$/.test(t))throw new Error(`linkifyjs: incorrect scheme format.\n1. Must only contain digits, lowercase ASCII letters or \"-\"\n2. Cannot start or end with \"-\"\n3. \"-\" cannot repeat`);_a.customSchemes.push([t,e])}function mZe(){_a.scanner=aZe(_a.customSchemes);for(let t=0;t<_a.tokenQueue.length;t++)_a.tokenQueue[t][1]({scanner:_a.scanner});_a.parser=oZe(_a.scanner.tokens);for(let t=0;t<_a.pluginQueue.length;t++)_a.pluginQueue[t][1]({scanner:_a.scanner,parser:_a.parser});return _a.initialized=!0,_a}function s5(t){return _a.initialized||mZe(),lZe(_a.parser.start,t,Foe(_a.scanner.start,t))}s5.scan=Foe;function Loe(t,e=null,n=null){if(e&&typeof e==\"object\"){if(n)throw Error(`linkifyjs: Invalid link type ${e}; must be a string`);n=e,e=null}const r=new i5(n),i=s5(t),s=[];for(let o=0;o<i.length;o++){const l=i[o];l.isLink&&(!e||l.t===e)&&r.check(l)&&s.push(l.toFormattedObject(r))}return s}var o5=\"[\\0-   ᠎ -\\u2029 　]\",hZe=new RegExp(o5),pZe=new RegExp(`${o5}$`),fZe=new RegExp(o5,\"g\");function gZe(t){return t.length===1?t[0].isLink:t.length===3&&t[1].isLink?[\"()\",\"[]\"].includes(t[0].value+t[2].value):!1}function bZe(t){return new Ua({key:new zi(\"autolink\"),appendTransaction:(e,n,r)=>{const i=e.some(d=>d.docChanged)&&!n.doc.eq(r.doc),s=e.some(d=>d.getMeta(\"preventAutolink\"));if(!i||s)return;const{tr:o}=r,l=Xse(n.doc,[...e]);if(noe(l).forEach(({newRange:d})=>{const u=yXe(r.doc,d,f=>f.isTextblock);let m,p;if(u.length>1)m=u[0],p=r.doc.textBetween(m.pos,m.pos+m.node.nodeSize,void 0,\" \");else if(u.length){const f=r.doc.textBetween(d.from,d.to,\" \",\" \");if(!pZe.test(f))return;m=u[0],p=r.doc.textBetween(m.pos,d.to,void 0,\" \")}if(m&&p){const f=p.split(hZe).filter(Boolean);if(f.length<=0)return!1;const y=f[f.length-1],v=m.pos+p.lastIndexOf(y);if(!y)return!1;const b=s5(y).map(g=>g.toObject(t.defaultProtocol));if(!gZe(b))return!1;b.filter(g=>g.isLink).map(g=>({...g,from:v+g.start+1,to:v+g.end+1})).filter(g=>r.schema.marks.code?!r.doc.rangeHasMark(g.from,g.to,r.schema.marks.code):!0).filter(g=>t.validate(g.value)).filter(g=>t.shouldAutoLink(g.value)).forEach(g=>{Wz(g.from,g.to,r.doc).some(_=>_.mark.type===t.type)||o.addMark(g.from,g.to,t.type.create({href:g.href}))})}}),!!o.steps.length)return o}})}function xZe(t){return new Ua({key:new zi(\"handleClickLink\"),props:{handleClick:(e,n,r)=>{var i,s;if(r.button!==0||!e.editable)return!1;let o=null;if(r.target instanceof HTMLAnchorElement)o=r.target;else{const c=r.target;if(!c)return!1;const d=t.editor.view.dom;o=c.closest(\"a\"),o&&!d.contains(o)&&(o=null)}if(!o)return!1;let l=!1;if(t.enableClickSelection&&(l=t.editor.commands.extendMarkRange(t.type.name)),t.openOnClick){const c=toe(e.state,t.type.name),d=(i=o.href)!=null?i:c.href,u=(s=o.target)!=null?s:c.target;d&&(window.open(d,u),l=!0)}return l}}})}function yZe(t){return new Ua({key:new zi(\"handlePasteLink\"),props:{handlePaste:(e,n,r)=>{const{shouldAutoLink:i}=t,{state:s}=e,{selection:o}=s,{empty:l}=o;if(l)return!1;let c=\"\";r.content.forEach(u=>{c+=u.textContent});const d=Loe(c,{defaultProtocol:t.defaultProtocol}).find(u=>u.isLink&&u.value===c);return!c||!d||i!==void 0&&!i(d.value)?!1:t.editor.commands.setMark(t.type,{href:d.href})}}})}function vf(t,e){const n=[\"http\",\"https\",\"ftp\",\"ftps\",\"mailto\",\"tel\",\"callto\",\"sms\",\"cid\",\"xmpp\"];return e&&e.forEach(r=>{const i=typeof r==\"string\"?r:r.scheme;i&&n.push(i)}),!t||t.replace(fZe,\"\").match(new RegExp(`^(?:(?:${n.join(\"|\")}):|[^a-z]|[a-z0-9+.-]+(?:[^a-z+.-:]|$))`,\"i\"))}var Ooe=Lp.create({name:\"link\",priority:1e3,keepOnSplit:!1,exitable:!0,onCreate(){this.options.validate&&!this.options.shouldAutoLink&&(this.options.shouldAutoLink=this.options.validate,console.warn(\"The `validate` option is deprecated. Rename to the `shouldAutoLink` option instead.\")),this.options.protocols.forEach(t=>{if(typeof t==\"string\"){HX(t);return}HX(t.scheme,t.optionalSlashes)})},onDestroy(){uZe()},inclusive(){return this.options.autolink},addOptions(){return{openOnClick:!0,enableClickSelection:!1,linkOnPaste:!0,autolink:!0,protocols:[],defaultProtocol:\"http\",HTMLAttributes:{target:\"_blank\",rel:\"noopener noreferrer nofollow\",class:null},isAllowedUri:(t,e)=>!!vf(t,e.protocols),validate:t=>!!t,shouldAutoLink:t=>{const e=/^[a-z][a-z0-9+.-]*:\\/\\//i.test(t),n=/^[a-z][a-z0-9+.-]*:/i.test(t);if(e||n&&!t.includes(\"@\"))return!0;const i=(t.includes(\"@\")?t.split(\"@\").pop():t).split(/[/?#:]/)[0];return!(/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(i)||!/\\./.test(i))}}},addAttributes(){return{href:{default:null,parseHTML(t){return t.getAttribute(\"href\")}},target:{default:this.options.HTMLAttributes.target},rel:{default:this.options.HTMLAttributes.rel},class:{default:this.options.HTMLAttributes.class},title:{default:null}}},parseHTML(){return[{tag:\"a[href]\",getAttrs:t=>{const e=t.getAttribute(\"href\");return!e||!this.options.isAllowedUri(e,{defaultValidate:n=>!!vf(n,this.options.protocols),protocols:this.options.protocols,defaultProtocol:this.options.defaultProtocol})?!1:null}}]},renderHTML({HTMLAttributes:t}){return this.options.isAllowedUri(t.href,{defaultValidate:e=>!!vf(e,this.options.protocols),protocols:this.options.protocols,defaultProtocol:this.options.defaultProtocol})?[\"a\",ni(this.options.HTMLAttributes,t),0]:[\"a\",ni(this.options.HTMLAttributes,{...t,href:\"\"}),0]},markdownTokenName:\"link\",parseMarkdown:(t,e)=>e.applyMark(\"link\",e.parseInline(t.tokens||[]),{href:t.href,title:t.title||null}),renderMarkdown:(t,e)=>{var n,r,i,s;const o=(r=(n=t.attrs)==null?void 0:n.href)!=null?r:\"\",l=(s=(i=t.attrs)==null?void 0:i.title)!=null?s:\"\",c=e.renderChildren(t);return l?`[${c}](${o} \"${l}\")`:`[${c}](${o})`},addCommands(){return{setLink:t=>({chain:e})=>{const{href:n}=t;return this.options.isAllowedUri(n,{defaultValidate:r=>!!vf(r,this.options.protocols),protocols:this.options.protocols,defaultProtocol:this.options.defaultProtocol})?e().setMark(this.name,t).setMeta(\"preventAutolink\",!0).run():!1},toggleLink:t=>({chain:e})=>{const{href:n}=t||{};return n&&!this.options.isAllowedUri(n,{defaultValidate:r=>!!vf(r,this.options.protocols),protocols:this.options.protocols,defaultProtocol:this.options.defaultProtocol})?!1:e().toggleMark(this.name,t,{extendEmptyMarkRange:!0}).setMeta(\"preventAutolink\",!0).run()},unsetLink:()=>({chain:t})=>t().unsetMark(this.name,{extendEmptyMarkRange:!0}).setMeta(\"preventAutolink\",!0).run()}},addPasteRules(){return[Fg({find:t=>{const e=[];if(t){const{protocols:n,defaultProtocol:r}=this.options,i=Loe(t).filter(s=>s.isLink&&this.options.isAllowedUri(s.value,{defaultValidate:o=>!!vf(o,n),protocols:n,defaultProtocol:r}));i.length&&i.forEach(s=>{this.options.shouldAutoLink(s.value)&&e.push({text:s.value,data:{href:s.href},index:s.start})})}return e},type:this.type,getAttributes:t=>{var e;return{href:(e=t.data)==null?void 0:e.href}}})]},addProseMirrorPlugins(){const t=[],{protocols:e,defaultProtocol:n}=this.options;return this.options.autolink&&t.push(bZe({type:this.type,defaultProtocol:this.options.defaultProtocol,validate:r=>this.options.isAllowedUri(r,{defaultValidate:i=>!!vf(i,e),protocols:e,defaultProtocol:n}),shouldAutoLink:this.options.shouldAutoLink})),t.push(xZe({type:this.type,editor:this.editor,openOnClick:this.options.openOnClick===\"whenNotEditable\"?!0:this.options.openOnClick,enableClickSelection:this.options.enableClickSelection})),this.options.linkOnPaste&&t.push(yZe({editor:this.editor,defaultProtocol:this.options.defaultProtocol,type:this.type,shouldAutoLink:this.options.shouldAutoLink})),t}}),vZe=Ooe,wZe=Object.defineProperty,SZe=(t,e)=>{for(var n in e)wZe(t,n,{get:e[n],enumerable:!0})},_Ze=\"listItem\",qX=\"textStyle\",$X=/^\\s*([-+*])\\s$/,Ioe=Vo.create({name:\"bulletList\",addOptions(){return{itemTypeName:\"listItem\",HTMLAttributes:{},keepMarks:!1,keepAttributes:!1}},group:\"block list\",content(){return`${this.options.itemTypeName}+`},parseHTML(){return[{tag:\"ul\"}]},renderHTML({HTMLAttributes:t}){return[\"ul\",ni(this.options.HTMLAttributes,t),0]},markdownTokenName:\"list\",parseMarkdown:(t,e)=>t.type!==\"list\"||t.ordered?[]:{type:\"bulletList\",content:t.items?e.parseChildren(t.items):[]},renderMarkdown:(t,e)=>t.content?e.renderChildren(t.content,`\n`):\"\",markdownOptions:{indentsContent:!0},addCommands(){return{toggleBulletList:()=>({commands:t,chain:e})=>this.options.keepAttributes?e().toggleList(this.name,this.options.itemTypeName,this.options.keepMarks).updateAttributes(_Ze,this.editor.getAttributes(qX)).run():t.toggleList(this.name,this.options.itemTypeName,this.options.keepMarks)}},addKeyboardShortcuts(){return{\"Mod-Shift-8\":()=>this.editor.commands.toggleBulletList()}},addInputRules(){let t=py({find:$X,type:this.type});return(this.options.keepMarks||this.options.keepAttributes)&&(t=py({find:$X,type:this.type,keepMarks:this.options.keepMarks,keepAttributes:this.options.keepAttributes,getAttributes:()=>this.editor.getAttributes(qX),editor:this.editor})),[t]}}),zoe=Vo.create({name:\"listItem\",addOptions(){return{HTMLAttributes:{},bulletListTypeName:\"bulletList\",orderedListTypeName:\"orderedList\"}},content:\"paragraph block*\",defining:!0,parseHTML(){return[{tag:\"li\"}]},renderHTML({HTMLAttributes:t}){return[\"li\",ni(this.options.HTMLAttributes,t),0]},markdownTokenName:\"list_item\",parseMarkdown:(t,e)=>{if(t.type!==\"list_item\")return[];let n=[];if(t.tokens&&t.tokens.length>0)if(t.tokens.some(i=>i.type===\"paragraph\"))n=e.parseChildren(t.tokens);else{const i=t.tokens[0];if(i&&i.type===\"text\"&&i.tokens&&i.tokens.length>0){if(n=[{type:\"paragraph\",content:e.parseInline(i.tokens)}],t.tokens.length>1){const o=t.tokens.slice(1),l=e.parseChildren(o);n.push(...l)}}else n=e.parseChildren(t.tokens)}return n.length===0&&(n=[{type:\"paragraph\",content:[]}]),{type:\"listItem\",content:n}},renderMarkdown:(t,e,n)=>Qz(t,e,r=>{var i,s;return r.parentType===\"bulletList\"?\"- \":r.parentType===\"orderedList\"?`${(((s=(i=r.meta)==null?void 0:i.parentAttrs)==null?void 0:s.start)||1)+r.index}. `:\"- \"},n),addKeyboardShortcuts(){return{Enter:()=>this.editor.commands.splitListItem(this.name),Tab:()=>this.editor.commands.sinkListItem(this.name),\"Shift-Tab\":()=>this.editor.commands.liftListItem(this.name)}}}),kZe={};SZe(kZe,{findListItemPos:()=>US,getNextListDepth:()=>l5,handleBackspace:()=>JL,handleDelete:()=>eO,hasListBefore:()=>Uoe,hasListItemAfter:()=>NZe,hasListItemBefore:()=>Boe,listItemHasSubList:()=>Hoe,nextListIsDeeper:()=>qoe,nextListIsHigher:()=>$oe});var US=(t,e)=>{const{$from:n}=e.selection,r=Ni(t,e.schema);let i=null,s=n.depth,o=n.pos,l=null;for(;s>0&&l===null;)i=n.node(s),i.type===r?l=s:(s-=1,o-=1);return l===null?null:{$pos:e.doc.resolve(o),depth:l}},l5=(t,e)=>{const n=US(t,e);if(!n)return!1;const[,r]=AXe(e,t,n.$pos.pos+4);return r},Uoe=(t,e,n)=>{const{$anchor:r}=t.selection,i=Math.max(0,r.pos-2),s=t.doc.resolve(i).node();return!(!s||!n.includes(s.type.name))},Boe=(t,e)=>{var n;const{$anchor:r}=e.selection,i=e.doc.resolve(r.pos-2);return!(i.index()===0||((n=i.nodeBefore)==null?void 0:n.type.name)!==t)},Hoe=(t,e,n)=>{if(!n)return!1;const r=Ni(t,e.schema);let i=!1;return n.descendants(s=>{s.type===r&&(i=!0)}),i},JL=(t,e,n)=>{if(t.commands.undoInputRule())return!0;if(t.state.selection.from!==t.state.selection.to)return!1;if(!Np(t.state,e)&&Uoe(t.state,e,n)){const{$anchor:l}=t.state.selection,c=t.state.doc.resolve(l.before()-1),d=[];c.node().descendants((p,f)=>{p.type.name===e&&d.push({node:p,pos:f})});const u=d.at(-1);if(!u)return!1;const m=t.state.doc.resolve(c.start()+u.pos+1);return t.chain().cut({from:l.start()-1,to:l.end()+1},m.end()).joinForward().run()}if(!Np(t.state,e)||!DXe(t.state))return!1;const r=US(e,t.state);if(!r)return!1;const s=t.state.doc.resolve(r.$pos.pos-2).node(r.depth),o=Hoe(e,t.state,s);return Boe(e,t.state)&&!o?t.commands.joinItemBackward():t.chain().liftListItem(e).run()},qoe=(t,e)=>{const n=l5(t,e),r=US(t,e);return!r||!n?!1:n>r.depth},$oe=(t,e)=>{const n=l5(t,e),r=US(t,e);return!r||!n?!1:n<r.depth},eO=(t,e)=>{if(!Np(t.state,e)||!EXe(t.state,e))return!1;const{selection:n}=t.state,{$from:r,$to:i}=n;return!n.empty&&r.sameParent(i)?!1:qoe(e,t.state)?t.chain().focus(t.state.selection.from+4).lift(e).joinBackward().run():$oe(e,t.state)?t.chain().joinForward().joinBackward().run():t.commands.joinItemForward()},NZe=(t,e)=>{var n;const{$anchor:r}=e.selection,i=e.doc.resolve(r.pos-r.parentOffset-2);return!(i.index()===i.parent.childCount-1||((n=i.nodeAfter)==null?void 0:n.type.name)!==t)},Voe=na.create({name:\"listKeymap\",addOptions(){return{listTypes:[{itemName:\"listItem\",wrapperNames:[\"bulletList\",\"orderedList\"]},{itemName:\"taskItem\",wrapperNames:[\"taskList\"]}]}},addKeyboardShortcuts(){return{Delete:({editor:t})=>{let e=!1;return this.options.listTypes.forEach(({itemName:n})=>{t.state.schema.nodes[n]!==void 0&&eO(t,n)&&(e=!0)}),e},\"Mod-Delete\":({editor:t})=>{let e=!1;return this.options.listTypes.forEach(({itemName:n})=>{t.state.schema.nodes[n]!==void 0&&eO(t,n)&&(e=!0)}),e},Backspace:({editor:t})=>{let e=!1;return this.options.listTypes.forEach(({itemName:n,wrapperNames:r})=>{t.state.schema.nodes[n]!==void 0&&JL(t,n,r)&&(e=!0)}),e},\"Mod-Backspace\":({editor:t})=>{let e=!1;return this.options.listTypes.forEach(({itemName:n,wrapperNames:r})=>{t.state.schema.nodes[n]!==void 0&&JL(t,n,r)&&(e=!0)}),e}}}}),VX=/^(\\s*)(\\d+)\\.\\s+(.*)$/,CZe=/^\\s/;function PZe(t){const e=[];let n=0,r=0;for(;n<t.length;){const i=t[n],s=i.match(VX);if(!s)break;const[,o,l,c]=s,d=o.length;let u=c,m=n+1;const p=[i];for(;m<t.length;){const f=t[m];if(f.match(VX))break;if(f.trim()===\"\")p.push(f),u+=`\n`,m+=1;else if(f.match(CZe))p.push(f),u+=`\n${f.slice(d+2)}`,m+=1;else break}e.push({indent:d,number:parseInt(l,10),content:u.trim(),raw:p.join(`\n`)}),r=m,n=m}return[e,r]}function Goe(t,e,n){var r;const i=[];let s=0;for(;s<t.length;){const o=t[s];if(o.indent===e){const l=o.content.split(`\n`),c=((r=l[0])==null?void 0:r.trim())||\"\",d=[];c&&d.push({type:\"paragraph\",raw:c,tokens:n.inlineTokens(c)});const u=l.slice(1).join(`\n`).trim();if(u){const f=n.blockTokens(u);d.push(...f)}let m=s+1;const p=[];for(;m<t.length&&t[m].indent>e;)p.push(t[m]),m+=1;if(p.length>0){const f=Math.min(...p.map(v=>v.indent)),y=Goe(p,f,n);d.push({type:\"list\",ordered:!0,start:p[0].number,items:y,raw:p.map(v=>v.raw).join(`\n`)})}i.push({type:\"list_item\",raw:o.raw,tokens:d}),s=m}else s+=1}return i}function TZe(t,e){return t.map(n=>{if(n.type!==\"list_item\")return e.parseChildren([n])[0];const r=[];return n.tokens&&n.tokens.length>0&&n.tokens.forEach(i=>{if(i.type===\"paragraph\"||i.type===\"list\"||i.type===\"blockquote\"||i.type===\"code\")r.push(...e.parseChildren([i]));else if(i.type===\"text\"&&i.tokens){const s=e.parseChildren([i]);r.push({type:\"paragraph\",content:s})}else{const s=e.parseChildren([i]);s.length>0&&r.push(...s)}}),{type:\"listItem\",content:r}})}var AZe=\"listItem\",GX=\"textStyle\",WX=/^(\\d+)\\.\\s$/,Woe=Vo.create({name:\"orderedList\",addOptions(){return{itemTypeName:\"listItem\",HTMLAttributes:{},keepMarks:!1,keepAttributes:!1}},group:\"block list\",content(){return`${this.options.itemTypeName}+`},addAttributes(){return{start:{default:1,parseHTML:t=>t.hasAttribute(\"start\")?parseInt(t.getAttribute(\"start\")||\"\",10):1},type:{default:null,parseHTML:t=>t.getAttribute(\"type\")}}},parseHTML(){return[{tag:\"ol\"}]},renderHTML({HTMLAttributes:t}){const{start:e,...n}=t;return e===1?[\"ol\",ni(this.options.HTMLAttributes,n),0]:[\"ol\",ni(this.options.HTMLAttributes,t),0]},markdownTokenName:\"list\",parseMarkdown:(t,e)=>{if(t.type!==\"list\"||!t.ordered)return[];const n=t.start||1,r=t.items?TZe(t.items,e):[];return n!==1?{type:\"orderedList\",attrs:{start:n},content:r}:{type:\"orderedList\",content:r}},renderMarkdown:(t,e)=>t.content?e.renderChildren(t.content,`\n`):\"\",markdownTokenizer:{name:\"orderedList\",level:\"block\",start:t=>{const e=t.match(/^(\\s*)(\\d+)\\.\\s+/),n=e?.index;return n!==void 0?n:-1},tokenize:(t,e,n)=>{var r;const i=t.split(`\n`),[s,o]=PZe(i);if(s.length===0)return;const l=Goe(s,0,n);return l.length===0?void 0:{type:\"list\",ordered:!0,start:((r=s[0])==null?void 0:r.number)||1,items:l,raw:i.slice(0,o).join(`\n`)}}},markdownOptions:{indentsContent:!0},addCommands(){return{toggleOrderedList:()=>({commands:t,chain:e})=>this.options.keepAttributes?e().toggleList(this.name,this.options.itemTypeName,this.options.keepMarks).updateAttributes(AZe,this.editor.getAttributes(GX)).run():t.toggleList(this.name,this.options.itemTypeName,this.options.keepMarks)}},addKeyboardShortcuts(){return{\"Mod-Shift-7\":()=>this.editor.commands.toggleOrderedList()}},addInputRules(){let t=py({find:WX,type:this.type,getAttributes:e=>({start:+e[1]}),joinPredicate:(e,n)=>n.childCount+n.attrs.start===+e[1]});return(this.options.keepMarks||this.options.keepAttributes)&&(t=py({find:WX,type:this.type,keepMarks:this.options.keepMarks,keepAttributes:this.options.keepAttributes,getAttributes:e=>({start:+e[1],...this.editor.getAttributes(GX)}),joinPredicate:(e,n)=>n.childCount+n.attrs.start===+e[1],editor:this.editor})),[t]}}),jZe=/^\\s*(\\[([( |x])?\\])\\s$/,MZe=Vo.create({name:\"taskItem\",addOptions(){return{nested:!1,HTMLAttributes:{},taskListTypeName:\"taskList\",a11y:void 0}},content(){return this.options.nested?\"paragraph block*\":\"paragraph+\"},defining:!0,addAttributes(){return{checked:{default:!1,keepOnSplit:!1,parseHTML:t=>{const e=t.getAttribute(\"data-checked\");return e===\"\"||e===\"true\"},renderHTML:t=>({\"data-checked\":t.checked})}}},parseHTML(){return[{tag:`li[data-type=\"${this.name}\"]`,priority:51}]},renderHTML({node:t,HTMLAttributes:e}){return[\"li\",ni(this.options.HTMLAttributes,e,{\"data-type\":this.name}),[\"label\",[\"input\",{type:\"checkbox\",checked:t.attrs.checked?\"checked\":null}],[\"span\"]],[\"div\",0]]},parseMarkdown:(t,e)=>{const n=[];if(t.tokens&&t.tokens.length>0?n.push(e.createNode(\"paragraph\",{},e.parseInline(t.tokens))):t.text?n.push(e.createNode(\"paragraph\",{},[e.createNode(\"text\",{text:t.text})])):n.push(e.createNode(\"paragraph\",{},[])),t.nestedTokens&&t.nestedTokens.length>0){const r=e.parseChildren(t.nestedTokens);n.push(...r)}return e.createNode(\"taskItem\",{checked:t.checked||!1},n)},renderMarkdown:(t,e)=>{var n;const i=`- [${(n=t.attrs)!=null&&n.checked?\"x\":\" \"}] `;return Qz(t,e,i)},addKeyboardShortcuts(){const t={Enter:()=>this.editor.commands.splitListItem(this.name),\"Shift-Tab\":()=>this.editor.commands.liftListItem(this.name)};return this.options.nested?{...t,Tab:()=>this.editor.commands.sinkListItem(this.name)}:t},addNodeView(){return({node:t,HTMLAttributes:e,getPos:n,editor:r})=>{const i=document.createElement(\"li\"),s=document.createElement(\"label\"),o=document.createElement(\"span\"),l=document.createElement(\"input\"),c=document.createElement(\"div\"),d=m=>{var p,f;l.ariaLabel=((f=(p=this.options.a11y)==null?void 0:p.checkboxLabel)==null?void 0:f.call(p,m,l.checked))||`Task item checkbox for ${m.textContent||\"empty task item\"}`};d(t),s.contentEditable=\"false\",l.type=\"checkbox\",l.addEventListener(\"mousedown\",m=>m.preventDefault()),l.addEventListener(\"change\",m=>{if(!r.isEditable&&!this.options.onReadOnlyChecked){l.checked=!l.checked;return}const{checked:p}=m.target;r.isEditable&&typeof n==\"function\"&&r.chain().focus(void 0,{scrollIntoView:!1}).command(({tr:f})=>{const y=n();if(typeof y!=\"number\")return!1;const v=f.doc.nodeAt(y);return f.setNodeMarkup(y,void 0,{...v?.attrs,checked:p}),!0}).run(),!r.isEditable&&this.options.onReadOnlyChecked&&(this.options.onReadOnlyChecked(t,p)||(l.checked=!l.checked))}),Object.entries(this.options.HTMLAttributes).forEach(([m,p])=>{i.setAttribute(m,p)}),i.dataset.checked=t.attrs.checked,l.checked=t.attrs.checked,s.append(l,o),i.append(s,c),Object.entries(e).forEach(([m,p])=>{i.setAttribute(m,p)});let u=new Set(Object.keys(e));return{dom:i,contentDOM:c,update:m=>{if(m.type!==this.type)return!1;i.dataset.checked=m.attrs.checked,l.checked=m.attrs.checked,d(m);const p=r.extensionManager.attributes,f=Hw(m,p),y=new Set(Object.keys(f)),v=this.options.HTMLAttributes;return u.forEach(b=>{y.has(b)||(b in v?i.setAttribute(b,v[b]):i.removeAttribute(b))}),Object.entries(f).forEach(([b,g])=>{g==null?b in v?i.setAttribute(b,v[b]):i.removeAttribute(b):i.setAttribute(b,g)}),u=y,!0}}}},addInputRules(){return[py({find:jZe,type:this.type,getAttributes:t=>({checked:t[t.length-1]===\"x\"})})]}}),EZe=Vo.create({name:\"taskList\",addOptions(){return{itemTypeName:\"taskItem\",HTMLAttributes:{}}},group:\"block list\",content(){return`${this.options.itemTypeName}+`},parseHTML(){return[{tag:`ul[data-type=\"${this.name}\"]`,priority:51}]},renderHTML({HTMLAttributes:t}){return[\"ul\",ni(this.options.HTMLAttributes,t,{\"data-type\":this.name}),0]},parseMarkdown:(t,e)=>e.createNode(\"taskList\",{},e.parseChildren(t.items||[])),renderMarkdown:(t,e)=>t.content?e.renderChildren(t.content,`\n`):\"\",markdownTokenizer:{name:\"taskList\",level:\"block\",start(t){var e;const n=(e=t.match(/^\\s*[-+*]\\s+\\[([ xX])\\]\\s+/))==null?void 0:e.index;return n!==void 0?n:-1},tokenize(t,e,n){const r=s=>{const o=qL(s,{itemPattern:/^(\\s*)([-+*])\\s+\\[([ xX])\\]\\s+(.*)$/,extractItemData:l=>({indentLevel:l[1].length,mainContent:l[4],checked:l[3].toLowerCase()===\"x\"}),createToken:(l,c)=>({type:\"taskItem\",raw:\"\",mainContent:l.mainContent,indentLevel:l.indentLevel,checked:l.checked,text:l.mainContent,tokens:n.inlineTokens(l.mainContent),nestedTokens:c}),customNestedParser:r},n);return o?[{type:\"taskList\",raw:o.raw,items:o.items}]:n.blockTokens(s)},i=qL(t,{itemPattern:/^(\\s*)([-+*])\\s+\\[([ xX])\\]\\s+(.*)$/,extractItemData:s=>({indentLevel:s[1].length,mainContent:s[4],checked:s[3].toLowerCase()===\"x\"}),createToken:(s,o)=>({type:\"taskItem\",raw:\"\",mainContent:s.mainContent,indentLevel:s.indentLevel,checked:s.checked,text:s.mainContent,tokens:n.inlineTokens(s.mainContent),nestedTokens:o}),customNestedParser:r},n);if(i)return{type:\"taskList\",raw:i.raw,items:i.items}}},markdownOptions:{indentsContent:!0},addCommands(){return{toggleTaskList:()=>({commands:t})=>t.toggleList(this.name,this.options.itemTypeName)}},addKeyboardShortcuts(){return{\"Mod-Shift-9\":()=>this.editor.commands.toggleTaskList()}}});na.create({name:\"listKit\",addExtensions(){const t=[];return this.options.bulletList!==!1&&t.push(Ioe.configure(this.options.bulletList)),this.options.listItem!==!1&&t.push(zoe.configure(this.options.listItem)),this.options.listKeymap!==!1&&t.push(Voe.configure(this.options.listKeymap)),this.options.orderedList!==!1&&t.push(Woe.configure(this.options.orderedList)),this.options.taskItem!==!1&&t.push(MZe.configure(this.options.taskItem)),this.options.taskList!==!1&&t.push(EZe.configure(this.options.taskList)),t}});var KX=\"&nbsp;\",DZe=\" \",FZe=Vo.create({name:\"paragraph\",priority:1e3,addOptions(){return{HTMLAttributes:{}}},group:\"block\",content:\"inline*\",parseHTML(){return[{tag:\"p\"}]},renderHTML({HTMLAttributes:t}){return[\"p\",ni(this.options.HTMLAttributes,t),0]},parseMarkdown:(t,e)=>{const n=t.tokens||[];if(n.length===1&&n[0].type===\"image\")return e.parseChildren([n[0]]);const r=e.parseInline(n);return r.length===1&&r[0].type===\"text\"&&(r[0].text===KX||r[0].text===DZe)?e.createNode(\"paragraph\",void 0,[]):e.createNode(\"paragraph\",void 0,r)},renderMarkdown:(t,e)=>{if(!t)return\"\";const n=Array.isArray(t.content)?t.content:[];return n.length===0?KX:e.renderChildren(n)},addCommands(){return{setParagraph:()=>({commands:t})=>t.setNode(this.name)}},addKeyboardShortcuts(){return{\"Mod-Alt-0\":()=>this.editor.commands.setParagraph()}}}),RZe=/(?:^|\\s)(~~(?!\\s+~~)((?:[^~]+))~~(?!\\s+~~))$/,LZe=/(?:^|\\s)(~~(?!\\s+~~)((?:[^~]+))~~(?!\\s+~~))/g,OZe=Lp.create({name:\"strike\",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:\"s\"},{tag:\"del\"},{tag:\"strike\"},{style:\"text-decoration\",consuming:!1,getAttrs:t=>t.includes(\"line-through\")?{}:!1}]},renderHTML({HTMLAttributes:t}){return[\"s\",ni(this.options.HTMLAttributes,t),0]},markdownTokenName:\"del\",parseMarkdown:(t,e)=>e.applyMark(\"strike\",e.parseInline(t.tokens||[])),renderMarkdown:(t,e)=>`~~${e.renderChildren(t)}~~`,addCommands(){return{setStrike:()=>({commands:t})=>t.setMark(this.name),toggleStrike:()=>({commands:t})=>t.toggleMark(this.name),unsetStrike:()=>({commands:t})=>t.unsetMark(this.name)}},addKeyboardShortcuts(){return{\"Mod-Shift-s\":()=>this.editor.commands.toggleStrike()}},addInputRules(){return[hy({find:RZe,type:this.type})]},addPasteRules(){return[Fg({find:LZe,type:this.type})]}}),IZe=Vo.create({name:\"text\",group:\"inline\",parseMarkdown:t=>({type:\"text\",text:t.text||\"\"}),renderMarkdown:t=>t.text||\"\"}),Koe=Lp.create({name:\"underline\",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:\"u\"},{style:\"text-decoration\",consuming:!1,getAttrs:t=>t.includes(\"underline\")?{}:!1}]},renderHTML({HTMLAttributes:t}){return[\"u\",ni(this.options.HTMLAttributes,t),0]},parseMarkdown(t,e){return e.applyMark(this.name||\"underline\",e.parseInline(t.tokens||[]))},renderMarkdown(t,e){return`++${e.renderChildren(t)}++`},markdownTokenizer:{name:\"underline\",level:\"inline\",start(t){return t.indexOf(\"++\")},tokenize(t,e,n){const i=/^(\\+\\+)([\\s\\S]+?)(\\+\\+)/.exec(t);if(!i)return;const s=i[2].trim();return{type:\"underline\",raw:i[0],text:s,tokens:n.inlineTokens(s)}}},addCommands(){return{setUnderline:()=>({commands:t})=>t.setMark(this.name),toggleUnderline:()=>({commands:t})=>t.toggleMark(this.name),unsetUnderline:()=>({commands:t})=>t.unsetMark(this.name)}},addKeyboardShortcuts(){return{\"Mod-u\":()=>this.editor.commands.toggleUnderline(),\"Mod-U\":()=>this.editor.commands.toggleUnderline()}}}),zZe=Koe;function UZe(t={}){return new Ua({view(e){return new BZe(e,t)}})}class BZe{constructor(e,n){var r;this.editorView=e,this.cursorPos=null,this.element=null,this.timeout=-1,this.width=(r=n.width)!==null&&r!==void 0?r:1,this.color=n.color===!1?void 0:n.color||\"black\",this.class=n.class,this.handlers=[\"dragover\",\"dragend\",\"drop\",\"dragleave\"].map(i=>{let s=o=>{this[i](o)};return e.dom.addEventListener(i,s),{name:i,handler:s}})}destroy(){this.handlers.forEach(({name:e,handler:n})=>this.editorView.dom.removeEventListener(e,n))}update(e,n){this.cursorPos!=null&&n.doc!=e.state.doc&&(this.cursorPos>e.state.doc.content.size?this.setCursor(null):this.updateOverlay())}setCursor(e){e!=this.cursorPos&&(this.cursorPos=e,e==null?(this.element.parentNode.removeChild(this.element),this.element=null):this.updateOverlay())}updateOverlay(){let e=this.editorView.state.doc.resolve(this.cursorPos),n=!e.parent.inlineContent,r,i=this.editorView.dom,s=i.getBoundingClientRect(),o=s.width/i.offsetWidth,l=s.height/i.offsetHeight;if(n){let m=e.nodeBefore,p=e.nodeAfter;if(m||p){let f=this.editorView.nodeDOM(this.cursorPos-(m?m.nodeSize:0));if(f){let y=f.getBoundingClientRect(),v=m?y.bottom:y.top;m&&p&&(v=(v+this.editorView.nodeDOM(this.cursorPos).getBoundingClientRect().top)/2);let b=this.width/2*l;r={left:y.left,right:y.right,top:v-b,bottom:v+b}}}}if(!r){let m=this.editorView.coordsAtPos(this.cursorPos),p=this.width/2*o;r={left:m.left-p,right:m.left+p,top:m.top,bottom:m.bottom}}let c=this.editorView.dom.offsetParent;this.element||(this.element=c.appendChild(document.createElement(\"div\")),this.class&&(this.element.className=this.class),this.element.style.cssText=\"position: absolute; z-index: 50; pointer-events: none;\",this.color&&(this.element.style.backgroundColor=this.color)),this.element.classList.toggle(\"prosemirror-dropcursor-block\",n),this.element.classList.toggle(\"prosemirror-dropcursor-inline\",!n);let d,u;if(!c||c==document.body&&getComputedStyle(c).position==\"static\")d=-pageXOffset,u=-pageYOffset;else{let m=c.getBoundingClientRect(),p=m.width/c.offsetWidth,f=m.height/c.offsetHeight;d=m.left-c.scrollLeft*p,u=m.top-c.scrollTop*f}this.element.style.left=(r.left-d)/o+\"px\",this.element.style.top=(r.top-u)/l+\"px\",this.element.style.width=(r.right-r.left)/o+\"px\",this.element.style.height=(r.bottom-r.top)/l+\"px\"}scheduleRemoval(e){clearTimeout(this.timeout),this.timeout=setTimeout(()=>this.setCursor(null),e)}dragover(e){if(!this.editorView.editable)return;let n=this.editorView.posAtCoords({left:e.clientX,top:e.clientY}),r=n&&n.inside>=0&&this.editorView.state.doc.nodeAt(n.inside),i=r&&r.type.spec.disableDropCursor,s=typeof i==\"function\"?i(this.editorView,n,e):i;if(n&&!s){let o=n.pos;if(this.editorView.dragging&&this.editorView.dragging.slice){let l=Uie(this.editorView.state.doc,o,this.editorView.dragging.slice);l!=null&&(o=l)}this.setCursor(o),this.scheduleRemoval(5e3)}}dragend(){this.scheduleRemoval(20)}drop(){this.scheduleRemoval(20)}dragleave(e){this.editorView.dom.contains(e.relatedTarget)||this.setCursor(null)}}class Ya extends rr{constructor(e){super(e,e)}map(e,n){let r=e.resolve(n.map(this.head));return Ya.valid(r)?new Ya(r):rr.near(r)}content(){return an.empty}eq(e){return e instanceof Ya&&e.head==this.head}toJSON(){return{type:\"gapcursor\",pos:this.head}}static fromJSON(e,n){if(typeof n.pos!=\"number\")throw new RangeError(\"Invalid input for GapCursor.fromJSON\");return new Ya(e.resolve(n.pos))}getBookmark(){return new c5(this.anchor)}static valid(e){let n=e.parent;if(n.isTextblock||!HZe(e)||!qZe(e))return!1;let r=n.type.spec.allowGapCursor;if(r!=null)return r;let i=n.contentMatchAt(e.index()).defaultType;return i&&i.isTextblock}static findGapCursorFrom(e,n,r=!1){e:for(;;){if(!r&&Ya.valid(e))return e;let i=e.pos,s=null;for(let o=e.depth;;o--){let l=e.node(o);if(n>0?e.indexAfter(o)<l.childCount:e.index(o)>0){s=l.child(n>0?e.indexAfter(o):e.index(o)-1);break}else if(o==0)return null;i+=n;let c=e.doc.resolve(i);if(Ya.valid(c))return c}for(;;){let o=n>0?s.firstChild:s.lastChild;if(!o){if(s.isAtom&&!s.isText&&!An.isSelectable(s)){e=e.doc.resolve(i+s.nodeSize*n),r=!1;continue e}break}s=o,i+=n;let l=e.doc.resolve(i);if(Ya.valid(l))return l}return null}}}Ya.prototype.visible=!1;Ya.findFrom=Ya.findGapCursorFrom;rr.jsonID(\"gapcursor\",Ya);class c5{constructor(e){this.pos=e}map(e){return new c5(e.map(this.pos))}resolve(e){let n=e.resolve(this.pos);return Ya.valid(n)?new Ya(n):rr.near(n)}}function Xoe(t){return t.isAtom||t.spec.isolating||t.spec.createGapCursor}function HZe(t){for(let e=t.depth;e>=0;e--){let n=t.index(e),r=t.node(e);if(n==0){if(r.type.spec.isolating)return!0;continue}for(let i=r.child(n-1);;i=i.lastChild){if(i.childCount==0&&!i.inlineContent||Xoe(i.type))return!0;if(i.inlineContent)return!1}}return!0}function qZe(t){for(let e=t.depth;e>=0;e--){let n=t.indexAfter(e),r=t.node(e);if(n==r.childCount){if(r.type.spec.isolating)return!0;continue}for(let i=r.child(n);;i=i.firstChild){if(i.childCount==0&&!i.inlineContent||Xoe(i.type))return!0;if(i.inlineContent)return!1}}return!0}function $Ze(){return new Ua({props:{decorations:KZe,createSelectionBetween(t,e,n){return e.pos==n.pos&&Ya.valid(n)?new Ya(n):null},handleClick:GZe,handleKeyDown:VZe,handleDOMEvents:{beforeinput:WZe}}})}const VZe=Bse({ArrowLeft:Ak(\"horiz\",-1),ArrowRight:Ak(\"horiz\",1),ArrowUp:Ak(\"vert\",-1),ArrowDown:Ak(\"vert\",1)});function Ak(t,e){const n=t==\"vert\"?e>0?\"down\":\"up\":e>0?\"right\":\"left\";return function(r,i,s){let o=r.selection,l=e>0?o.$to:o.$from,c=o.empty;if(o instanceof $n){if(!s.endOfTextblock(n)||l.depth==0)return!1;c=!1,l=r.doc.resolve(e>0?l.after():l.before())}let d=Ya.findGapCursorFrom(l,e,c);return d?(i&&i(r.tr.setSelection(new Ya(d))),!0):!1}}function GZe(t,e,n){if(!t||!t.editable)return!1;let r=t.state.doc.resolve(e);if(!Ya.valid(r))return!1;let i=t.posAtCoords({left:n.clientX,top:n.clientY});return i&&i.inside>-1&&An.isSelectable(t.state.doc.nodeAt(i.inside))?!1:(t.dispatch(t.state.tr.setSelection(new Ya(r))),!0)}function WZe(t,e){if(e.inputType!=\"insertCompositionText\"||!(t.state.selection instanceof Ya))return!1;let{$from:n}=t.state.selection,r=n.parent.contentMatchAt(n.index()).findWrapping(t.state.schema.nodes.text);if(!r)return!1;let i=Vt.empty;for(let o=r.length-1;o>=0;o--)i=Vt.from(r[o].createAndFill(null,i));let s=t.state.tr.replace(n.pos,n.pos,new an(i,0,0));return s.setSelection($n.near(s.doc.resolve(n.pos+1))),t.dispatch(s),!1}function KZe(t){if(!(t.selection instanceof Ya))return null;let e=document.createElement(\"div\");return e.className=\"ProseMirror-gapcursor\",Fa.create(t.doc,[io.widget(t.selection.head,e,{key:\"gapcursor\"})])}var jP=200,Ji=function(){};Ji.prototype.append=function(e){return e.length?(e=Ji.from(e),!this.length&&e||e.length<jP&&this.leafAppend(e)||this.length<jP&&e.leafPrepend(this)||this.appendInner(e)):this};Ji.prototype.prepend=function(e){return e.length?Ji.from(e).append(this):this};Ji.prototype.appendInner=function(e){return new XZe(this,e)};Ji.prototype.slice=function(e,n){return e===void 0&&(e=0),n===void 0&&(n=this.length),e>=n?Ji.empty:this.sliceInner(Math.max(0,e),Math.min(this.length,n))};Ji.prototype.get=function(e){if(!(e<0||e>=this.length))return this.getInner(e)};Ji.prototype.forEach=function(e,n,r){n===void 0&&(n=0),r===void 0&&(r=this.length),n<=r?this.forEachInner(e,n,r,0):this.forEachInvertedInner(e,n,r,0)};Ji.prototype.map=function(e,n,r){n===void 0&&(n=0),r===void 0&&(r=this.length);var i=[];return this.forEach(function(s,o){return i.push(e(s,o))},n,r),i};Ji.from=function(e){return e instanceof Ji?e:e&&e.length?new Yoe(e):Ji.empty};var Yoe=(function(t){function e(r){t.call(this),this.values=r}t&&(e.__proto__=t),e.prototype=Object.create(t&&t.prototype),e.prototype.constructor=e;var n={length:{configurable:!0},depth:{configurable:!0}};return e.prototype.flatten=function(){return this.values},e.prototype.sliceInner=function(i,s){return i==0&&s==this.length?this:new e(this.values.slice(i,s))},e.prototype.getInner=function(i){return this.values[i]},e.prototype.forEachInner=function(i,s,o,l){for(var c=s;c<o;c++)if(i(this.values[c],l+c)===!1)return!1},e.prototype.forEachInvertedInner=function(i,s,o,l){for(var c=s-1;c>=o;c--)if(i(this.values[c],l+c)===!1)return!1},e.prototype.leafAppend=function(i){if(this.length+i.length<=jP)return new e(this.values.concat(i.flatten()))},e.prototype.leafPrepend=function(i){if(this.length+i.length<=jP)return new e(i.flatten().concat(this.values))},n.length.get=function(){return this.values.length},n.depth.get=function(){return 0},Object.defineProperties(e.prototype,n),e})(Ji);Ji.empty=new Yoe([]);var XZe=(function(t){function e(n,r){t.call(this),this.left=n,this.right=r,this.length=n.length+r.length,this.depth=Math.max(n.depth,r.depth)+1}return t&&(e.__proto__=t),e.prototype=Object.create(t&&t.prototype),e.prototype.constructor=e,e.prototype.flatten=function(){return this.left.flatten().concat(this.right.flatten())},e.prototype.getInner=function(r){return r<this.left.length?this.left.get(r):this.right.get(r-this.left.length)},e.prototype.forEachInner=function(r,i,s,o){var l=this.left.length;if(i<l&&this.left.forEachInner(r,i,Math.min(s,l),o)===!1||s>l&&this.right.forEachInner(r,Math.max(i-l,0),Math.min(this.length,s)-l,o+l)===!1)return!1},e.prototype.forEachInvertedInner=function(r,i,s,o){var l=this.left.length;if(i>l&&this.right.forEachInvertedInner(r,i-l,Math.max(s,l)-l,o+l)===!1||s<l&&this.left.forEachInvertedInner(r,Math.min(i,l),s,o)===!1)return!1},e.prototype.sliceInner=function(r,i){if(r==0&&i==this.length)return this;var s=this.left.length;return i<=s?this.left.slice(r,i):r>=s?this.right.slice(r-s,i-s):this.left.slice(r,s).append(this.right.slice(0,i-s))},e.prototype.leafAppend=function(r){var i=this.right.leafAppend(r);if(i)return new e(this.left,i)},e.prototype.leafPrepend=function(r){var i=this.left.leafPrepend(r);if(i)return new e(i,this.right)},e.prototype.appendInner=function(r){return this.left.depth>=Math.max(this.right.depth,r.depth)+1?new e(this.left,new e(this.right,r)):new e(this,r)},e})(Ji);const YZe=500;class $c{constructor(e,n){this.items=e,this.eventCount=n}popEvent(e,n){if(this.eventCount==0)return null;let r=this.items.length;for(;;r--)if(this.items.get(r-1).selection){--r;break}let i,s;n&&(i=this.remapping(r,this.items.length),s=i.maps.length);let o=e.tr,l,c,d=[],u=[];return this.items.forEach((m,p)=>{if(!m.step){i||(i=this.remapping(r,p+1),s=i.maps.length),s--,u.push(m);return}if(i){u.push(new _d(m.map));let f=m.step.map(i.slice(s)),y;f&&o.maybeStep(f).doc&&(y=o.mapping.maps[o.mapping.maps.length-1],d.push(new _d(y,void 0,void 0,d.length+u.length))),s--,y&&i.appendMap(y,s)}else o.maybeStep(m.step);if(m.selection)return l=i?m.selection.map(i.slice(s)):m.selection,c=new $c(this.items.slice(0,r).append(u.reverse().concat(d)),this.eventCount-1),!1},this.items.length,0),{remaining:c,transform:o,selection:l}}addTransform(e,n,r,i){let s=[],o=this.eventCount,l=this.items,c=!i&&l.length?l.get(l.length-1):null;for(let u=0;u<e.steps.length;u++){let m=e.steps[u].invert(e.docs[u]),p=new _d(e.mapping.maps[u],m,n),f;(f=c&&c.merge(p))&&(p=f,u?s.pop():l=l.slice(0,l.length-1)),s.push(p),n&&(o++,n=void 0),i||(c=p)}let d=o-r.depth;return d>ZZe&&(l=QZe(l,d),o-=d),new $c(l.append(s),o)}remapping(e,n){let r=new Lw;return this.items.forEach((i,s)=>{let o=i.mirrorOffset!=null&&s-i.mirrorOffset>=e?r.maps.length-i.mirrorOffset:void 0;r.appendMap(i.map,o)},e,n),r}addMaps(e){return this.eventCount==0?this:new $c(this.items.append(e.map(n=>new _d(n))),this.eventCount)}rebased(e,n){if(!this.eventCount)return this;let r=[],i=Math.max(0,this.items.length-n),s=e.mapping,o=e.steps.length,l=this.eventCount;this.items.forEach(p=>{p.selection&&l--},i);let c=n;this.items.forEach(p=>{let f=s.getMirror(--c);if(f==null)return;o=Math.min(o,f);let y=s.maps[f];if(p.step){let v=e.steps[f].invert(e.docs[f]),b=p.selection&&p.selection.map(s.slice(c+1,f));b&&l++,r.push(new _d(y,v,b))}else r.push(new _d(y))},i);let d=[];for(let p=n;p<o;p++)d.push(new _d(s.maps[p]));let u=this.items.slice(0,i).append(d).append(r),m=new $c(u,l);return m.emptyItemCount()>YZe&&(m=m.compress(this.items.length-r.length)),m}emptyItemCount(){let e=0;return this.items.forEach(n=>{n.step||e++}),e}compress(e=this.items.length){let n=this.remapping(0,e),r=n.maps.length,i=[],s=0;return this.items.forEach((o,l)=>{if(l>=e)i.push(o),o.selection&&s++;else if(o.step){let c=o.step.map(n.slice(r)),d=c&&c.getMap();if(r--,d&&n.appendMap(d,r),c){let u=o.selection&&o.selection.map(n.slice(r));u&&s++;let m=new _d(d.invert(),c,u),p,f=i.length-1;(p=i.length&&i[f].merge(m))?i[f]=p:i.push(m)}}else o.map&&r--},this.items.length,0),new $c(Ji.from(i.reverse()),s)}}$c.empty=new $c(Ji.empty,0);function QZe(t,e){let n;return t.forEach((r,i)=>{if(r.selection&&e--==0)return n=i,!1}),t.slice(n)}class _d{constructor(e,n,r,i){this.map=e,this.step=n,this.selection=r,this.mirrorOffset=i}merge(e){if(this.step&&e.step&&!e.selection){let n=e.step.merge(this.step);if(n)return new _d(n.getMap().invert(),n,this.selection)}}}class Dh{constructor(e,n,r,i,s){this.done=e,this.undone=n,this.prevRanges=r,this.prevTime=i,this.prevComposition=s}}const ZZe=20;function JZe(t,e,n,r){let i=n.getMeta(ig),s;if(i)return i.historyState;n.getMeta(nJe)&&(t=new Dh(t.done,t.undone,null,0,-1));let o=n.getMeta(\"appendedTransaction\");if(n.steps.length==0)return t;if(o&&o.getMeta(ig))return o.getMeta(ig).redo?new Dh(t.done.addTransform(n,void 0,r,oN(e)),t.undone,XX(n.mapping.maps),t.prevTime,t.prevComposition):new Dh(t.done,t.undone.addTransform(n,void 0,r,oN(e)),null,t.prevTime,t.prevComposition);if(n.getMeta(\"addToHistory\")!==!1&&!(o&&o.getMeta(\"addToHistory\")===!1)){let l=n.getMeta(\"composition\"),c=t.prevTime==0||!o&&t.prevComposition!=l&&(t.prevTime<(n.time||0)-r.newGroupDelay||!eJe(n,t.prevRanges)),d=o?b3(t.prevRanges,n.mapping):XX(n.mapping.maps);return new Dh(t.done.addTransform(n,c?e.selection.getBookmark():void 0,r,oN(e)),$c.empty,d,n.time,l??t.prevComposition)}else return(s=n.getMeta(\"rebased\"))?new Dh(t.done.rebased(n,s),t.undone.rebased(n,s),b3(t.prevRanges,n.mapping),t.prevTime,t.prevComposition):new Dh(t.done.addMaps(n.mapping.maps),t.undone.addMaps(n.mapping.maps),b3(t.prevRanges,n.mapping),t.prevTime,t.prevComposition)}function eJe(t,e){if(!e)return!1;if(!t.docChanged)return!0;let n=!1;return t.mapping.maps[0].forEach((r,i)=>{for(let s=0;s<e.length;s+=2)r<=e[s+1]&&i>=e[s]&&(n=!0)}),n}function XX(t){let e=[];for(let n=t.length-1;n>=0&&e.length==0;n--)t[n].forEach((r,i,s,o)=>e.push(s,o));return e}function b3(t,e){if(!t)return null;let n=[];for(let r=0;r<t.length;r+=2){let i=e.map(t[r],1),s=e.map(t[r+1],-1);i<=s&&n.push(i,s)}return n}function tJe(t,e,n){let r=oN(e),i=ig.get(e).spec.config,s=(n?t.undone:t.done).popEvent(e,r);if(!s)return null;let o=s.selection.resolve(s.transform.doc),l=(n?t.done:t.undone).addTransform(s.transform,e.selection.getBookmark(),i,r),c=new Dh(n?l:s.remaining,n?s.remaining:l,null,0,-1);return s.transform.setSelection(o).setMeta(ig,{redo:n,historyState:c})}let x3=!1,YX=null;function oN(t){let e=t.plugins;if(YX!=e){x3=!1,YX=e;for(let n=0;n<e.length;n++)if(e[n].spec.historyPreserveItems){x3=!0;break}}return x3}const ig=new zi(\"history\"),nJe=new zi(\"closeHistory\");function rJe(t={}){return t={depth:t.depth||100,newGroupDelay:t.newGroupDelay||500},new Ua({key:ig,state:{init(){return new Dh($c.empty,$c.empty,null,0,-1)},apply(e,n,r){return JZe(n,r,e,t)}},config:t,props:{handleDOMEvents:{beforeinput(e,n){let r=n.inputType,i=r==\"historyUndo\"?Zoe:r==\"historyRedo\"?Joe:null;return!i||!e.editable?!1:(n.preventDefault(),i(e.state,e.dispatch))}}}})}function Qoe(t,e){return(n,r)=>{let i=ig.getState(n);if(!i||(t?i.undone:i.done).eventCount==0)return!1;if(r){let s=tJe(i,n,t);s&&r(e?s.scrollIntoView():s)}return!0}}const Zoe=Qoe(!1,!0),Joe=Qoe(!0,!0);na.create({name:\"characterCount\",addOptions(){return{limit:null,mode:\"textSize\",textCounter:t=>t.length,wordCounter:t=>t.split(\" \").filter(e=>e!==\"\").length}},addStorage(){return{characters:()=>0,words:()=>0}},onBeforeCreate(){this.storage.characters=t=>{const e=t?.node||this.editor.state.doc;if((t?.mode||this.options.mode)===\"textSize\"){const r=e.textBetween(0,e.content.size,void 0,\" \");return this.options.textCounter(r)}return e.nodeSize},this.storage.words=t=>{const e=t?.node||this.editor.state.doc,n=e.textBetween(0,e.content.size,\" \",\" \");return this.options.wordCounter(n)}},addProseMirrorPlugins(){let t=!1;return[new Ua({key:new zi(\"characterCount\"),appendTransaction:(e,n,r)=>{if(t)return;const i=this.options.limit;if(i==null||i===0){t=!0;return}const s=this.storage.characters({node:r.doc});if(s>i){const o=s-i,l=0,c=o;console.warn(`[CharacterCount] Initial content exceeded limit of ${i} characters. Content was automatically trimmed.`);const d=r.tr.deleteRange(l,c);return t=!0,d}t=!0},filterTransaction:(e,n)=>{const r=this.options.limit;if(!e.docChanged||r===0||r===null||r===void 0)return!0;const i=this.storage.characters({node:n.doc}),s=this.storage.characters({node:e.doc});if(s<=r||i>r&&s>r&&s<=i)return!0;if(i>r&&s>r&&s>i||!e.getMeta(\"paste\"))return!1;const l=e.selection.$head.pos,c=s-r,d=l-c,u=l;return e.deleteRange(d,u),!(this.storage.characters({node:e.doc})>r)}})]}});var aJe=na.create({name:\"dropCursor\",addOptions(){return{color:\"currentColor\",width:1,class:void 0}},addProseMirrorPlugins(){return[UZe(this.options)]}});na.create({name:\"focus\",addOptions(){return{className:\"has-focus\",mode:\"all\"}},addProseMirrorPlugins(){return[new Ua({key:new zi(\"focus\"),props:{decorations:({doc:t,selection:e})=>{const{isEditable:n,isFocused:r}=this.editor,{anchor:i}=e,s=[];if(!n||!r)return Fa.create(t,[]);let o=0;this.options.mode===\"deepest\"&&t.descendants((c,d)=>{if(c.isText)return;if(!(i>=d&&i<=d+c.nodeSize-1))return!1;o+=1});let l=0;return t.descendants((c,d)=>{if(c.isText||!(i>=d&&i<=d+c.nodeSize-1))return!1;if(l+=1,this.options.mode===\"deepest\"&&o-l>0||this.options.mode===\"shallowest\"&&l>1)return this.options.mode===\"deepest\";s.push(io.node(d,d+c.nodeSize,{class:this.options.className}))}),Fa.create(t,s)}}})]}});var iJe=na.create({name:\"gapCursor\",addProseMirrorPlugins(){return[$Ze()]},extendNodeSchema(t){var e;const n={name:t.name,options:t.options,storage:t.storage};return{allowGapCursor:(e=$r(Pn(t,\"allowGapCursor\",n)))!=null?e:null}}}),QX=\"placeholder\";function sJe(t){return t.replace(/\\s+/g,\"-\").replace(/[^a-zA-Z0-9-]/g,\"\").replace(/^[0-9-]+/,\"\").replace(/^-+/,\"\").toLowerCase()}na.create({name:\"placeholder\",addOptions(){return{emptyEditorClass:\"is-editor-empty\",emptyNodeClass:\"is-empty\",dataAttribute:QX,placeholder:\"Write something …\",showOnlyWhenEditable:!0,showOnlyCurrent:!0,includeChildren:!1}},addProseMirrorPlugins(){const t=this.options.dataAttribute?`data-${sJe(this.options.dataAttribute)}`:`data-${QX}`;return[new Ua({key:new zi(\"placeholder\"),props:{decorations:({doc:e,selection:n})=>{const r=this.editor.isEditable||!this.options.showOnlyWhenEditable,{anchor:i}=n,s=[];if(!r)return null;const o=this.editor.isEmpty;return e.descendants((l,c)=>{const d=i>=c&&i<=c+l.nodeSize,u=!l.isLeaf&&uA(l);if((d||!this.options.showOnlyCurrent)&&u){const m=[this.options.emptyNodeClass];o&&m.push(this.options.emptyEditorClass);const p=io.node(c,c+l.nodeSize,{class:m.join(\" \"),[t]:typeof this.options.placeholder==\"function\"?this.options.placeholder({editor:this.editor,node:l,pos:c,hasAnchor:d}):this.options.placeholder});s.push(p)}return this.options.includeChildren}),Fa.create(e,s)}}})]}});na.create({name:\"selection\",addOptions(){return{className:\"selection\"}},addProseMirrorPlugins(){const{editor:t,options:e}=this;return[new Ua({key:new zi(\"selection\"),props:{decorations(n){return n.selection.empty||t.isFocused||!t.isEditable||roe(n.selection)||t.view.dragging?null:Fa.create(n.doc,[io.inline(n.selection.from,n.selection.to,{class:e.className})])}}})]}});function ZX({types:t,node:e}){return e&&Array.isArray(t)&&t.includes(e.type)||e?.type===t}var oJe=na.create({name:\"trailingNode\",addOptions(){return{node:void 0,notAfter:[]}},addProseMirrorPlugins(){var t;const e=new zi(this.name),n=this.options.node||((t=this.editor.schema.topNodeType.contentMatch.defaultType)==null?void 0:t.name)||\"paragraph\",r=Object.entries(this.editor.schema.nodes).map(([,i])=>i).filter(i=>(this.options.notAfter||[]).concat(n).includes(i.name));return[new Ua({key:e,appendTransaction:(i,s,o)=>{const{doc:l,tr:c,schema:d}=o,u=e.getState(o),m=l.content.size,p=d.nodes[n];if(u)return c.insert(m,p.create())},state:{init:(i,s)=>{const o=s.tr.doc.lastChild;return!ZX({node:o,types:r})},apply:(i,s)=>{if(!i.docChanged||i.getMeta(\"__uniqueIDTransaction\"))return s;const o=i.doc.lastChild;return!ZX({node:o,types:r})}}})]}}),lJe=na.create({name:\"undoRedo\",addOptions(){return{depth:100,newGroupDelay:500}},addCommands(){return{undo:()=>({state:t,dispatch:e})=>Zoe(t,e),redo:()=>({state:t,dispatch:e})=>Joe(t,e)}},addProseMirrorPlugins(){return[rJe(this.options)]},addKeyboardShortcuts(){return{\"Mod-z\":()=>this.editor.commands.undo(),\"Shift-Mod-z\":()=>this.editor.commands.redo(),\"Mod-y\":()=>this.editor.commands.redo(),\"Mod-я\":()=>this.editor.commands.undo(),\"Shift-Mod-я\":()=>this.editor.commands.redo()}}}),cJe=na.create({name:\"starterKit\",addExtensions(){var t,e,n,r;const i=[];return this.options.bold!==!1&&i.push(FQe.configure(this.options.bold)),this.options.blockquote!==!1&&i.push(AQe.configure(this.options.blockquote)),this.options.bulletList!==!1&&i.push(Ioe.configure(this.options.bulletList)),this.options.code!==!1&&i.push(OQe.configure(this.options.code)),this.options.codeBlock!==!1&&i.push(UQe.configure(this.options.codeBlock)),this.options.document!==!1&&i.push(BQe.configure(this.options.document)),this.options.dropcursor!==!1&&i.push(aJe.configure(this.options.dropcursor)),this.options.gapcursor!==!1&&i.push(iJe.configure(this.options.gapcursor)),this.options.hardBreak!==!1&&i.push(HQe.configure(this.options.hardBreak)),this.options.heading!==!1&&i.push(qQe.configure(this.options.heading)),this.options.undoRedo!==!1&&i.push(lJe.configure(this.options.undoRedo)),this.options.horizontalRule!==!1&&i.push($Qe.configure(this.options.horizontalRule)),this.options.italic!==!1&&i.push(XQe.configure(this.options.italic)),this.options.listItem!==!1&&i.push(zoe.configure(this.options.listItem)),this.options.listKeymap!==!1&&i.push(Voe.configure((t=this.options)==null?void 0:t.listKeymap)),this.options.link!==!1&&i.push(Ooe.configure((e=this.options)==null?void 0:e.link)),this.options.orderedList!==!1&&i.push(Woe.configure(this.options.orderedList)),this.options.paragraph!==!1&&i.push(FZe.configure(this.options.paragraph)),this.options.strike!==!1&&i.push(OZe.configure(this.options.strike)),this.options.text!==!1&&i.push(IZe.configure(this.options.text)),this.options.underline!==!1&&i.push(Koe.configure((n=this.options)==null?void 0:n.underline)),this.options.trailingNode!==!1&&i.push(oJe.configure((r=this.options)==null?void 0:r.trailingNode)),i}}),dJe=cJe,uJe=na.create({name:\"textAlign\",addOptions(){return{types:[],alignments:[\"left\",\"center\",\"right\",\"justify\"],defaultAlignment:null}},addGlobalAttributes(){return[{types:this.options.types,attributes:{textAlign:{default:this.options.defaultAlignment,parseHTML:t=>{const e=t.style.textAlign;return this.options.alignments.includes(e)?e:this.options.defaultAlignment},renderHTML:t=>t.textAlign?{style:`text-align: ${t.textAlign}`}:{}}}}]},addCommands(){return{setTextAlign:t=>({commands:e})=>this.options.alignments.includes(t)?this.options.types.map(n=>e.updateAttributes(n,{textAlign:t})).some(n=>n):!1,unsetTextAlign:()=>({commands:t})=>this.options.types.map(e=>t.resetAttributes(e,\"textAlign\")).some(e=>e),toggleTextAlign:t=>({editor:e,commands:n})=>this.options.alignments.includes(t)?e.isActive({textAlign:t})?n.unsetTextAlign():n.setTextAlign(t):!1}},addKeyboardShortcuts(){return{\"Mod-Shift-l\":()=>this.editor.commands.setTextAlign(\"left\"),\"Mod-Shift-e\":()=>this.editor.commands.setTextAlign(\"center\"),\"Mod-Shift-r\":()=>this.editor.commands.setTextAlign(\"right\"),\"Mod-Shift-j\":()=>this.editor.commands.setTextAlign(\"justify\")}}}),mJe=uJe,hJe=20,ele=(t,e=0)=>{const n=[];return!t.children.length||e>hJe||Array.from(t.children).forEach(r=>{r.tagName===\"SPAN\"?n.push(r):r.children.length&&n.push(...ele(r,e+1))}),n},pJe=t=>{if(!t.children.length)return;const e=ele(t);e&&e.forEach(n=>{var r,i;const s=n.getAttribute(\"style\"),o=(i=(r=n.parentElement)==null?void 0:r.closest(\"span\"))==null?void 0:i.getAttribute(\"style\");n.setAttribute(\"style\",`${o};${s}`)})},tle=Lp.create({name:\"textStyle\",priority:101,addOptions(){return{HTMLAttributes:{},mergeNestedSpanStyles:!0}},parseHTML(){return[{tag:\"span\",consuming:!1,getAttrs:t=>t.hasAttribute(\"style\")?(this.options.mergeNestedSpanStyles&&pJe(t),{}):!1}]},renderHTML({HTMLAttributes:t}){return[\"span\",ni(this.options.HTMLAttributes,t),0]},addCommands(){return{toggleTextStyle:t=>({commands:e})=>e.toggleMark(this.name,t),removeEmptyTextStyle:()=>({tr:t})=>{const{selection:e}=t;return t.doc.nodesBetween(e.from,e.to,(n,r)=>{if(n.isTextblock)return!0;n.marks.filter(i=>i.type===this.type).some(i=>Object.values(i.attrs).some(s=>!!s))||t.removeMark(r,r+n.nodeSize,this.type)}),!0}}}}),fJe=na.create({name:\"backgroundColor\",addOptions(){return{types:[\"textStyle\"]}},addGlobalAttributes(){return[{types:this.options.types,attributes:{backgroundColor:{default:null,parseHTML:t=>{var e;const n=t.getAttribute(\"style\");if(n){const r=n.split(\";\").map(i=>i.trim()).filter(Boolean);for(let i=r.length-1;i>=0;i-=1){const s=r[i].split(\":\");if(s.length>=2){const o=s[0].trim().toLowerCase(),l=s.slice(1).join(\":\").trim();if(o===\"background-color\")return l.replace(/['\"]+/g,\"\")}}}return(e=t.style.backgroundColor)==null?void 0:e.replace(/['\"]+/g,\"\")},renderHTML:t=>t.backgroundColor?{style:`background-color: ${t.backgroundColor}`}:{}}}}]},addCommands(){return{setBackgroundColor:t=>({chain:e})=>e().setMark(\"textStyle\",{backgroundColor:t}).run(),unsetBackgroundColor:()=>({chain:t})=>t().setMark(\"textStyle\",{backgroundColor:null}).removeEmptyTextStyle().run()}}}),nle=na.create({name:\"color\",addOptions(){return{types:[\"textStyle\"]}},addGlobalAttributes(){return[{types:this.options.types,attributes:{color:{default:null,parseHTML:t=>{var e;const n=t.getAttribute(\"style\");if(n){const r=n.split(\";\").map(i=>i.trim()).filter(Boolean);for(let i=r.length-1;i>=0;i-=1){const s=r[i].split(\":\");if(s.length>=2){const o=s[0].trim().toLowerCase(),l=s.slice(1).join(\":\").trim();if(o===\"color\")return l.replace(/['\"]+/g,\"\")}}}return(e=t.style.color)==null?void 0:e.replace(/['\"]+/g,\"\")},renderHTML:t=>t.color?{style:`color: ${t.color}`}:{}}}}]},addCommands(){return{setColor:t=>({chain:e})=>e().setMark(\"textStyle\",{color:t}).run(),unsetColor:()=>({chain:t})=>t().setMark(\"textStyle\",{color:null}).removeEmptyTextStyle().run()}}}),gJe=na.create({name:\"fontFamily\",addOptions(){return{types:[\"textStyle\"]}},addGlobalAttributes(){return[{types:this.options.types,attributes:{fontFamily:{default:null,parseHTML:t=>t.style.fontFamily,renderHTML:t=>t.fontFamily?{style:`font-family: ${t.fontFamily}`}:{}}}}]},addCommands(){return{setFontFamily:t=>({chain:e})=>e().setMark(\"textStyle\",{fontFamily:t}).run(),unsetFontFamily:()=>({chain:t})=>t().setMark(\"textStyle\",{fontFamily:null}).removeEmptyTextStyle().run()}}}),bJe=na.create({name:\"fontSize\",addOptions(){return{types:[\"textStyle\"]}},addGlobalAttributes(){return[{types:this.options.types,attributes:{fontSize:{default:null,parseHTML:t=>t.style.fontSize,renderHTML:t=>t.fontSize?{style:`font-size: ${t.fontSize}`}:{}}}}]},addCommands(){return{setFontSize:t=>({chain:e})=>e().setMark(\"textStyle\",{fontSize:t}).run(),unsetFontSize:()=>({chain:t})=>t().setMark(\"textStyle\",{fontSize:null}).removeEmptyTextStyle().run()}}}),xJe=na.create({name:\"lineHeight\",addOptions(){return{types:[\"textStyle\"]}},addGlobalAttributes(){return[{types:this.options.types,attributes:{lineHeight:{default:null,parseHTML:t=>t.style.lineHeight,renderHTML:t=>t.lineHeight?{style:`line-height: ${t.lineHeight}`}:{}}}}]},addCommands(){return{setLineHeight:t=>({chain:e})=>e().setMark(\"textStyle\",{lineHeight:t}).run(),unsetLineHeight:()=>({chain:t})=>t().setMark(\"textStyle\",{lineHeight:null}).removeEmptyTextStyle().run()}}});na.create({name:\"textStyleKit\",addExtensions(){const t=[];return this.options.backgroundColor!==!1&&t.push(fJe.configure(this.options.backgroundColor)),this.options.color!==!1&&t.push(nle.configure(this.options.color)),this.options.fontFamily!==!1&&t.push(gJe.configure(this.options.fontFamily)),this.options.fontSize!==!1&&t.push(bJe.configure(this.options.fontSize)),this.options.lineHeight!==!1&&t.push(xJe.configure(this.options.lineHeight)),this.options.textStyle!==!1&&t.push(tle.configure(this.options.textStyle)),t}});var yJe=nle,vJe=/(?:^|\\s)(!\\[(.+|:?)]\\((\\S+)(?:(?:\\s+)[\"'](\\S+)[\"'])?\\))$/,wJe=Vo.create({name:\"image\",addOptions(){return{inline:!1,allowBase64:!1,HTMLAttributes:{},resize:!1}},inline(){return this.options.inline},group(){return this.options.inline?\"inline\":\"block\"},draggable:!0,addAttributes(){return{src:{default:null},alt:{default:null},title:{default:null},width:{default:null},height:{default:null}}},parseHTML(){return[{tag:this.options.allowBase64?\"img[src]\":'img[src]:not([src^=\"data:\"])'}]},renderHTML({HTMLAttributes:t}){return[\"img\",ni(this.options.HTMLAttributes,t)]},parseMarkdown:(t,e)=>e.createNode(\"image\",{src:t.href,title:t.title,alt:t.text}),renderMarkdown:t=>{var e,n,r,i,s,o;const l=(n=(e=t.attrs)==null?void 0:e.src)!=null?n:\"\",c=(i=(r=t.attrs)==null?void 0:r.alt)!=null?i:\"\",d=(o=(s=t.attrs)==null?void 0:s.title)!=null?o:\"\";return d?`![${c}](${l} \"${d}\")`:`![${c}](${l})`},addNodeView(){if(!this.options.resize||!this.options.resize.enabled||typeof document>\"u\")return null;const{directions:t,minWidth:e,minHeight:n,alwaysPreserveAspectRatio:r}=this.options.resize;return({node:i,getPos:s,HTMLAttributes:o,editor:l})=>{const c=document.createElement(\"img\");Object.entries(o).forEach(([m,p])=>{if(p!=null)switch(m){case\"width\":case\"height\":break;default:c.setAttribute(m,p);break}}),c.src=o.src;const d=new yYe({element:c,editor:l,node:i,getPos:s,onResize:(m,p)=>{c.style.width=`${m}px`,c.style.height=`${p}px`},onCommit:(m,p)=>{const f=s();f!==void 0&&this.editor.chain().setNodeSelection(f).updateAttributes(this.name,{width:m,height:p}).run()},onUpdate:(m,p,f)=>m.type===i.type,options:{directions:t,min:{width:e,height:n},preserveAspectRatio:r===!0}}),u=d.dom;return u.style.visibility=\"hidden\",u.style.pointerEvents=\"none\",c.onload=()=>{u.style.visibility=\"\",u.style.pointerEvents=\"\"},d}},addCommands(){return{setImage:t=>({commands:e})=>e.insertContent({type:this.name,attrs:t})}},addInputRules(){return[voe({find:vJe,type:this.type,getAttributes:t=>{const[,,e,n,r]=t;return{src:n,alt:e,title:r}}})]}}),SJe=wJe;function tO({content:t,onChange:e,placeholder:n}){const{t:r}=Ft(),i=kQe({extensions:[dJe.configure({heading:!1,codeBlock:!1,code:!1}),zZe,vZe.configure({openOnClick:!1,HTMLAttributes:{target:\"_blank\",rel:\"noopener noreferrer\"}}),mJe.configure({types:[\"paragraph\"]}),tle,yJe,SJe.configure({HTMLAttributes:{style:\"max-width: 100%; height: auto;\"}})],content:t,onUpdate:({editor:l})=>{e(l.getHTML())},editorProps:{attributes:{class:\"prose prose-invert prose-sm max-w-none focus:outline-none min-h-[120px] px-3 py-2\",placeholder:n||\"\"}}});if(!i)return null;const s=({onClick:l,isActive:c=!1,children:d,title:u})=>a.jsx(\"button\",{type:\"button\",onClick:l,title:u,className:`p-1.5 rounded hover:bg-bambu-dark-tertiary transition-colors ${c?\"bg-bambu-dark-tertiary text-bambu-green\":\"text-bambu-gray\"}`,children:d}),o=()=>{const l=window.prompt(\"Enter URL:\");l&&i.chain().focus().setLink({href:l}).run()};return a.jsxs(\"div\",{className:\"border border-bambu-dark-tertiary rounded-lg overflow-hidden bg-bambu-dark\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-0.5 p-1.5 border-b border-bambu-dark-tertiary bg-bambu-dark-secondary\",children:[a.jsx(s,{onClick:()=>i.chain().focus().toggleBold().run(),isActive:i.isActive(\"bold\"),title:r(\"richTextEditor.bold\"),children:a.jsx(jpe,{className:\"w-4 h-4\"})}),a.jsx(s,{onClick:()=>i.chain().focus().toggleItalic().run(),isActive:i.isActive(\"italic\"),title:r(\"richTextEditor.italic\"),children:a.jsx(Wge,{className:\"w-4 h-4\"})}),a.jsx(s,{onClick:()=>i.chain().focus().toggleUnderline().run(),isActive:i.isActive(\"underline\"),title:r(\"richTextEditor.underline\"),children:a.jsx(Xxe,{className:\"w-4 h-4\"})}),a.jsx(\"div\",{className:\"w-px h-5 bg-bambu-dark-tertiary mx-1\"}),a.jsx(s,{onClick:()=>i.chain().focus().toggleBulletList().run(),isActive:i.isActive(\"bulletList\"),title:r(\"richTextEditor.bulletList\"),children:a.jsx(tS,{className:\"w-4 h-4\"})}),a.jsx(s,{onClick:()=>i.chain().focus().toggleOrderedList().run(),isActive:i.isActive(\"orderedList\"),title:r(\"richTextEditor.numberedList\"),children:a.jsx(SN,{className:\"w-4 h-4\"})}),a.jsx(\"div\",{className:\"w-px h-5 bg-bambu-dark-tertiary mx-1\"}),a.jsx(s,{onClick:()=>i.chain().focus().setTextAlign(\"left\").run(),isActive:i.isActive({textAlign:\"left\"}),title:r(\"richTextEditor.alignLeft\"),children:a.jsx(Bxe,{className:\"w-4 h-4\"})}),a.jsx(s,{onClick:()=>i.chain().focus().setTextAlign(\"center\").run(),isActive:i.isActive({textAlign:\"center\"}),title:r(\"richTextEditor.alignCenter\"),children:a.jsx(Oxe,{className:\"w-4 h-4\"})}),a.jsx(s,{onClick:()=>i.chain().focus().setTextAlign(\"right\").run(),isActive:i.isActive({textAlign:\"right\"}),title:r(\"richTextEditor.alignRight\"),children:a.jsx(zxe,{className:\"w-4 h-4\"})}),a.jsx(\"div\",{className:\"w-px h-5 bg-bambu-dark-tertiary mx-1\"}),a.jsx(s,{onClick:o,isActive:i.isActive(\"link\"),title:r(\"richTextEditor.addLink\"),children:a.jsx(Sy,{className:\"w-4 h-4\"})}),i.isActive(\"link\")&&a.jsx(s,{onClick:()=>i.chain().focus().unsetLink().run(),title:r(\"richTextEditor.removeLink\"),children:a.jsx(gp,{className:\"w-4 h-4\"})})]}),a.jsx(_oe,{editor:i})]})}function rle({archiveId:t,archiveName:e,onClose:n}){const r=nn(),[i,s]=w.useState(!1),[o,l]=w.useState(null),[c,d]=w.useState({}),{data:u,isLoading:m,error:p}=Xe({queryKey:[\"archive-project-page\",t],queryFn:()=>ue.getArchiveProjectPage(t)}),f=it({mutationFn:N=>ue.updateArchiveProjectPage(t,N),onSuccess:()=>{r.invalidateQueries({queryKey:[\"archive-project-page\",t]}),s(!1),d({})}});w.useEffect(()=>{const N=A=>{A.key===\"Escape\"&&(o!==null?l(null):i?g():n())};return document.addEventListener(\"keydown\",N),()=>document.removeEventListener(\"keydown\",N)},[o,i,n]);const y=[...u?.model_pictures||[],...u?.profile_pictures||[]],v=()=>{d({title:u?.title||\"\",description:u?.description||\"\",designer:u?.designer||\"\",license:u?.license||\"\",profile_title:u?.profile_title||\"\",profile_description:u?.profile_description||\"\"}),s(!0)},b=()=>{f.mutate(c)},g=()=>{s(!1),d({})},_=N=>hie.sanitize(N,{ALLOWED_TAGS:[\"p\",\"br\",\"b\",\"strong\",\"i\",\"em\",\"u\",\"a\",\"ul\",\"ol\",\"li\",\"figure\",\"img\"],ALLOWED_ATTR:[\"href\",\"src\",\"target\",\"rel\",\"style\"],ADD_ATTR:[\"target\"]}),C=u&&(u.title||u.description||u.designer||u.profile_title||y.length>0),P=N=>{N.target===N.currentTarget&&n()};return a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:P,children:[a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(Us,{className:\"w-5 h-5 text-bambu-green\"}),a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white\",children:[\"Project Page\",e&&a.jsxs(\"span\",{className:\"text-bambu-gray ml-2\",children:[\"- \",e]})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[!i&&C&&a.jsxs(De,{variant:\"ghost\",size:\"sm\",onClick:v,children:[a.jsx(dg,{className:\"w-4 h-4 mr-1\"}),\"Edit\"]}),i&&a.jsxs(a.Fragment,{children:[a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:g,children:\"Cancel\"}),a.jsxs(De,{variant:\"primary\",size:\"sm\",onClick:b,disabled:f.isPending,children:[a.jsx(_s,{className:\"w-4 h-4 mr-1\"}),\"Save\"]})]}),a.jsx(\"button\",{onClick:n,className:\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]})]}),a.jsxs(\"div\",{className:\"flex-1 overflow-y-auto p-6\",children:[m&&a.jsx(\"div\",{className:\"flex items-center justify-center py-12\",children:a.jsx(\"div\",{className:\"animate-spin rounded-full h-8 w-8 border-2 border-bambu-green border-t-transparent\"})}),p&&a.jsx(\"div\",{className:\"text-red-400 text-center py-12\",children:\"Failed to load project page data\"}),u&&!C&&a.jsxs(\"div\",{className:\"text-bambu-gray text-center py-12\",children:[a.jsx(Us,{className:\"w-12 h-12 mx-auto mb-4 opacity-50\"}),a.jsx(\"p\",{children:\"No project page data found in this 3MF file.\"}),a.jsx(\"p\",{className:\"text-sm mt-2\",children:\"Project pages are typically included in files downloaded from MakerWorld.\"})]}),u&&C&&a.jsxs(\"div\",{className:\"space-y-6\",children:[a.jsxs(\"div\",{className:\"space-y-4\",children:[i?a.jsx(\"input\",{type:\"text\",value:c.title||\"\",onChange:N=>d({...c,title:N.target.value}),placeholder:\"Title\",className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-4 py-2 text-white text-xl font-semibold\"}):u.title&&a.jsx(\"h3\",{className:\"text-xl font-semibold text-white\",children:u.title}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-4 text-sm\",children:[i?a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(ym,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:c.designer||\"\",onChange:N=>d({...c,designer:N.target.value}),placeholder:\"Designer\",className:\"bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white\"})]}):u.designer&&a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray\",children:[a.jsx(ym,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:u.designer}),u.designer_user_id&&a.jsx(\"a\",{href:`https://makerworld.com/en/@${u.designer_user_id}`,target:\"_blank\",rel:\"noopener noreferrer\",className:\"text-bambu-green hover:underline\",children:a.jsx(la,{className:\"w-3 h-3\"})})]}),u.creation_date&&a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray\",children:[a.jsx(oa,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:u.creation_date})]}),i?a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Us,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:c.license||\"\",onChange:N=>d({...c,license:N.target.value}),placeholder:\"License\",className:\"bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white\"})]}):u.license&&a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray\",children:[a.jsx(Us,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:u.license})]}),u.origin&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-bambu-dark rounded text-bambu-gray\",children:u.origin})]})]}),(u.description||i)&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray uppercase tracking-wide\",children:\"Description\"}),i?a.jsx(tO,{content:c.description||\"\",onChange:N=>d({...c,description:N}),placeholder:\"Enter description...\"}):a.jsx(\"div\",{className:\"prose prose-invert prose-sm max-w-none text-bambu-gray-light\",dangerouslySetInnerHTML:{__html:_(u.description||\"\")}})]}),(u.profile_title||u.profile_description||i)&&a.jsxs(\"div\",{className:\"space-y-2 p-4 bg-bambu-dark rounded-lg\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray uppercase tracking-wide\",children:\"Print Profile\"}),i?a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"input\",{type:\"text\",value:c.profile_title||\"\",onChange:N=>d({...c,profile_title:N.target.value}),placeholder:\"Profile Title\",className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-white\"}),a.jsx(tO,{content:c.profile_description||\"\",onChange:N=>d({...c,profile_description:N}),placeholder:\"Profile description...\"})]}):a.jsxs(a.Fragment,{children:[u.profile_title&&a.jsx(\"p\",{className:\"text-white font-medium\",children:u.profile_title}),u.profile_description&&a.jsx(\"div\",{className:\"prose prose-invert prose-sm max-w-none text-bambu-gray-light\",dangerouslySetInnerHTML:{__html:_(u.profile_description)}}),u.profile_user_name&&a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[\"by \",u.profile_user_name]})]})]}),y.length>0&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"h4\",{className:\"text-sm font-medium text-bambu-gray uppercase tracking-wide flex items-center gap-2\",children:[a.jsx(gm,{className:\"w-4 h-4\"}),\"Images (\",y.length,\")\"]}),a.jsx(\"div\",{className:\"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2\",children:y.map((N,A)=>a.jsx(\"button\",{onClick:()=>l(A),className:\"aspect-square rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors\",children:a.jsx(\"img\",{src:N.url,alt:N.name,className:\"w-full h-full object-cover\"})},N.path))})]}),u.design_model_id&&a.jsx(\"div\",{className:\"pt-4 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"a\",{href:`https://makerworld.com/en/models/${u.design_model_id}`,target:\"_blank\",rel:\"noopener noreferrer\",className:\"inline-flex items-center gap-2 text-bambu-green hover:underline\",children:[a.jsx(la,{className:\"w-4 h-4\"}),\"View on MakerWorld\"]})})]})]})]}),o!==null&&y[o]&&a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/90 flex items-center justify-center z-60\",onClick:()=>l(null),children:[a.jsx(\"button\",{onClick:N=>{N.stopPropagation(),l(Math.max(0,o-1))},disabled:o===0,className:\"absolute left-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30\",children:a.jsx(xl,{className:\"w-6 h-6 text-white\"})}),a.jsx(\"img\",{src:y[o].url,alt:y[o].name,className:\"max-w-[90vw] max-h-[90vh] object-contain\",onClick:N=>N.stopPropagation()}),a.jsx(\"button\",{onClick:N=>{N.stopPropagation(),l(Math.min(y.length-1,o+1))},disabled:o===y.length-1,className:\"absolute right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30\",children:a.jsx(ti,{className:\"w-6 h-6 text-white\"})}),a.jsx(\"button\",{onClick:()=>l(null),className:\"absolute top-4 right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary\",children:a.jsx(Ht,{className:\"w-6 h-6 text-white\"})}),a.jsxs(\"div\",{className:\"absolute bottom-4 text-white text-sm\",children:[o+1,\" / \",y.length]})]})]})}const _Je=[.25,.5,.75,1,1.5,2,3,4];function kJe({archiveId:t,timelapseSrc:e,onClose:n,onSave:r}){const{showToast:i}=hn(),s=w.useRef(null),o=w.useRef(null),[l,c]=w.useState(!1),[d,u]=w.useState(0),[m,p]=w.useState(0),[f,y]=w.useState(0),[v,b]=w.useState(0),[g,_]=w.useState(1),[C,P]=w.useState(null),[N,A]=w.useState(null),[T,F]=w.useState(.8),[k,D]=w.useState(!1),{data:H,isLoading:z}=Xe({queryKey:[\"timelapse-info\",t],queryFn:()=>ue.getTimelapseInfo(t)}),{data:Q}=Xe({queryKey:[\"timelapse-thumbnails\",t],queryFn:()=>ue.getTimelapseThumbnails(t,15)}),L=it({mutationFn:()=>ue.processTimelapse(t,{trimStart:f,trimEnd:v,speed:g,saveMode:\"replace\"},C||void 0),onSuccess:W=>{i(W.message,\"success\"),r?.(),n()},onError:W=>{i(W.message||\"Processing failed\",\"error\")}});w.useEffect(()=>{H?.duration&&v===0&&b(H.duration)},[H?.duration,v]),w.useEffect(()=>{const W=ne=>{ne.key===\"Escape\"&&n()};return window.addEventListener(\"keydown\",W),()=>window.removeEventListener(\"keydown\",W)},[n]),w.useEffect(()=>{const W=s.current;if(!W)return;const ne=()=>{const q=W.currentTime;u(q),q>=v&&(W.currentTime=f)},me=()=>{p(W.duration),v===0&&b(W.duration)},be=()=>c(!0),Ce=()=>c(!1);return W.addEventListener(\"timeupdate\",ne),W.addEventListener(\"durationchange\",me),W.addEventListener(\"play\",be),W.addEventListener(\"pause\",Ce),()=>{W.removeEventListener(\"timeupdate\",ne),W.removeEventListener(\"durationchange\",me),W.removeEventListener(\"play\",be),W.removeEventListener(\"pause\",Ce)}},[f,v]),w.useEffect(()=>{const W=o.current,ne=s.current;!W||!ne||!N||(W.currentTime=ne.currentTime,W.playbackRate=ne.playbackRate,l&&!k?W.play().catch(()=>{}):W.pause())},[l,N,k]),w.useEffect(()=>{o.current&&(o.current.volume=k?0:T)},[T,k]),w.useEffect(()=>{s.current&&(s.current.playbackRate=g),o.current&&(o.current.playbackRate=g)},[g]);const te=w.useCallback(()=>{const W=s.current;W&&(l?W.pause():(W.currentTime<f&&(W.currentTime=f),W.play()))},[l,f]),ie=W=>{const ne=s.current;ne&&(ne.currentTime=Math.max(f,Math.min(v,W)))},J=W=>{const ne=W.target.files?.[0];ne&&(N&&URL.revokeObjectURL(N),P(ne),A(URL.createObjectURL(ne)))},oe=()=>{N&&URL.revokeObjectURL(N),P(null),A(null)};w.useEffect(()=>()=>{N&&URL.revokeObjectURL(N)},[N]);const fe=v-f,re=fe/g;return z?a.jsx(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center bg-black/80\",children:a.jsxs(\"div\",{className:\"flex items-center gap-3 text-white\",children:[a.jsx(ft,{className:\"w-6 h-6 animate-spin\"}),\"Loading video info...\"]})}):a.jsx(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center bg-black/80\",children:a.jsxs(\"div\",{className:\"relative bg-bambu-dark-secondary rounded-xl max-w-5xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0\",children:[a.jsxs(\"h3\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(tp,{className:\"w-5 h-5 text-bambu-green\"}),\"Edit Timelapse\"]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(De,{variant:\"primary\",size:\"sm\",onClick:()=>L.mutate(),disabled:L.isPending,children:L.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),\"Processing...\"]}):a.jsxs(a.Fragment,{children:[a.jsx(_s,{className:\"w-4 h-4\"}),\"Save\"]})}),a.jsx(\"button\",{onClick:n,className:\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]})]}),a.jsxs(\"div\",{className:\"flex-1 overflow-y-auto p-4 space-y-4\",children:[a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"video\",{ref:s,src:e,className:\"w-full rounded-lg bg-black\",onClick:te,muted:!!N}),!l&&a.jsx(\"button\",{onClick:te,className:\"absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition-colors\",children:a.jsx(\"div\",{className:\"p-4 bg-bambu-green rounded-full\",children:a.jsx(es,{className:\"w-8 h-8 text-white\"})})}),N&&a.jsx(\"audio\",{ref:o,src:N,loop:!0})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray\",children:[a.jsx(nxe,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:\"Trim\"}),a.jsxs(\"span\",{className:\"ml-auto\",children:[Ch(f),\" - \",Ch(v),\" (\",Ch(fe),\")\"]})]}),a.jsxs(\"div\",{className:\"relative h-16 bg-bambu-dark rounded-lg overflow-hidden\",children:[a.jsx(\"div\",{className:\"absolute inset-0 flex\",children:Q?.thumbnails.map((W,ne)=>a.jsx(\"div\",{className:\"flex-1 bg-cover bg-center\",style:{backgroundImage:`url(data:image/jpeg;base64,${W})`}},ne))}),a.jsx(\"div\",{className:\"absolute inset-y-0 left-0 bg-black/60\",style:{width:`${f/m*100}%`}}),a.jsx(\"div\",{className:\"absolute inset-y-0 right-0 bg-black/60\",style:{width:`${(m-v)/m*100}%`}}),a.jsx(\"div\",{className:\"absolute inset-y-0 border-2 border-bambu-green\",style:{left:`${f/m*100}%`,right:`${(m-v)/m*100}%`}}),a.jsx(\"div\",{className:\"absolute top-0 bottom-0 w-0.5 bg-white shadow-lg\",style:{left:`${d/m*100}%`}}),a.jsx(\"input\",{type:\"range\",min:0,max:m,step:.1,value:f,onChange:W=>{const ne=parseFloat(W.target.value);ne<v-1&&(y(ne),s.current&&s.current.currentTime<ne&&(s.current.currentTime=ne))},className:\"absolute inset-0 w-full opacity-0 cursor-ew-resize\",style:{clipPath:\"inset(0 50% 0 0)\"}}),a.jsx(\"input\",{type:\"range\",min:0,max:m,step:.1,value:v,onChange:W=>{const ne=parseFloat(W.target.value);ne>f+1&&b(ne)},className:\"absolute inset-0 w-full opacity-0 cursor-ew-resize\",style:{clipPath:\"inset(0 0 0 50%)\"}})]}),a.jsx(\"input\",{type:\"range\",min:0,max:m,step:.1,value:d,onChange:W=>ie(parseFloat(W.target.value)),className:`w-full h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer\n                [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3\n                [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full\n                [&::-webkit-slider-thumb]:cursor-pointer`}),a.jsx(\"div\",{className:\"flex items-center justify-center gap-2\",children:a.jsx(\"button\",{onClick:te,className:\"p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors\",children:l?a.jsx(rS,{className:\"w-5 h-5 text-white\"}):a.jsx(es,{className:\"w-5 h-5 text-white\"})})})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray\",children:[a.jsx(Zw,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:\"Speed\"}),a.jsxs(\"span\",{className:\"ml-auto\",children:[g,\"x (output: \",Ch(re),\")\"]})]}),a.jsx(\"div\",{className:\"flex gap-1\",children:_Je.map(W=>a.jsxs(\"button\",{onClick:()=>_(W),className:`flex-1 px-2 py-2 text-sm rounded transition-colors ${g===W?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:[W,\"x\"]},W))})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray\",children:[a.jsx(lF,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:\"Music Overlay\"})]}),C?a.jsxs(\"div\",{className:\"flex items-center gap-3 p-3 bg-bambu-dark rounded-lg\",children:[a.jsx(lF,{className:\"w-5 h-5 text-bambu-green\"}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white truncate\",children:C.name}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[(C.size/1024/1024).toFixed(1),\" MB\"]})]}),a.jsx(\"button\",{onClick:()=>D(!k),className:\"p-2 hover:bg-bambu-dark-tertiary rounded transition-colors\",children:k?a.jsx(aye,{className:\"w-4 h-4 text-bambu-gray\"}):a.jsx(nye,{className:\"w-4 h-4 text-bambu-green\"})}),a.jsx(\"input\",{type:\"range\",min:0,max:1,step:.1,value:T,onChange:W=>F(parseFloat(W.target.value)),className:`w-20 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer\n                    [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3\n                    [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full`}),a.jsx(\"button\",{onClick:oe,className:\"p-2 hover:bg-red-500/20 rounded transition-colors\",children:a.jsx(en,{className:\"w-4 h-4 text-red-400\"})})]}):a.jsxs(\"label\",{className:\"flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green/50 transition-colors\",children:[a.jsx(La,{className:\"w-8 h-8 text-bambu-gray\"}),a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:\"Drop audio file or click to upload\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray/60\",children:\"MP3, WAV, M4A, AAC, OGG\"}),a.jsx(\"input\",{type:\"file\",accept:\".mp3,.wav,.m4a,.aac,.ogg,audio/*\",onChange:J,className:\"hidden\"})]})]}),a.jsxs(\"div\",{className:\"p-3 bg-bambu-dark rounded-lg text-sm space-y-1\",children:[a.jsxs(\"p\",{className:\"text-bambu-gray\",children:[a.jsx(\"span\",{className:\"text-white\",children:\"Original:\"}),\" \",Ch(m),\" @ \",H?.width,\"x\",H?.height]}),a.jsxs(\"p\",{className:\"text-bambu-gray\",children:[a.jsx(\"span\",{className:\"text-white\",children:\"Output:\"}),\" \",Ch(re),\" @ \",g,\"x speed\",C&&\" + music overlay\"]})]})]}),L.isPending&&a.jsxs(\"div\",{className:\"absolute inset-0 bg-black/80 flex flex-col items-center justify-center gap-4\",children:[a.jsx(ft,{className:\"w-12 h-12 text-bambu-green animate-spin\"}),a.jsx(\"p\",{className:\"text-white text-lg\",children:\"Processing timelapse...\"}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:\"This may take a few moments\"})]})]})})}const NJe=[.25,.5,.75,1,1.5,2,3,4];function ale({src:t,title:e,downloadFilename:n,archiveId:r,onClose:i,onEdit:s}){const o=w.useRef(null),[l,c]=w.useState(!0),[d,u]=w.useState(1),[m,p]=w.useState(0),[f,y]=w.useState(0),[v,b]=w.useState(!1);w.useEffect(()=>{const A=o.current;A&&(A.playbackRate=d)},[d]),w.useEffect(()=>{const A=T=>{T.key===\"Escape\"&&i()};return window.addEventListener(\"keydown\",A),()=>window.removeEventListener(\"keydown\",A)},[i]),w.useEffect(()=>{const A=o.current;if(!A)return;const T=()=>p(A.currentTime),F=()=>y(A.duration),k=()=>c(!0),D=()=>c(!1);return A.addEventListener(\"timeupdate\",T),A.addEventListener(\"durationchange\",F),A.addEventListener(\"play\",k),A.addEventListener(\"pause\",D),()=>{A.removeEventListener(\"timeupdate\",T),A.removeEventListener(\"durationchange\",F),A.removeEventListener(\"play\",k),A.removeEventListener(\"pause\",D)}},[]);const g=()=>{const A=o.current;A&&(l?A.pause():A.play())},_=A=>{const T=o.current;T&&(T.currentTime=parseFloat(A.target.value))},C=()=>{const A=o.current;A&&(A.currentTime=Math.max(0,A.currentTime-5))},P=()=>{const A=o.current;A&&(A.currentTime=Math.min(f,A.currentTime+5))},N=()=>{const A=document.createElement(\"a\");A.href=t,A.download=n,A.click()};return a.jsxs(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center bg-black/80\",children:[a.jsxs(\"div\",{className:\"relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"h3\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(tp,{className:\"w-5 h-5 text-bambu-green\"}),e]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[r&&a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>b(!0),children:[a.jsx(ci,{className:\"w-4 h-4\"}),\"Edit\"]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:N,children:[a.jsx(ga,{className:\"w-4 h-4\"}),\"Download\"]}),a.jsx(\"button\",{onClick:i,className:\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]})]}),a.jsxs(\"div\",{className:\"p-4\",children:[a.jsx(\"video\",{ref:o,src:t,autoPlay:!0,className:\"w-full rounded-lg\",onClick:g}),a.jsxs(\"div\",{className:\"mt-4 space-y-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray w-12 text-right\",children:Ch(m)}),a.jsx(\"input\",{type:\"range\",min:0,max:f||100,value:m,onChange:_,className:`flex-1 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer\n                  [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3\n                  [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full\n                  [&::-webkit-slider-thumb]:cursor-pointer`}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray w-12\",children:Ch(f)})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"button\",{onClick:C,className:\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",title:\"Skip back 5s\",children:a.jsx(gxe,{className:\"w-5 h-5 text-bambu-gray\"})}),a.jsx(\"button\",{onClick:g,className:\"p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors\",children:l?a.jsx(rS,{className:\"w-5 h-5 text-white\"}):a.jsx(es,{className:\"w-5 h-5 text-white\"})}),a.jsx(\"button\",{onClick:P,className:\"p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",title:\"Skip forward 5s\",children:a.jsx(GP,{className:\"w-5 h-5 text-bambu-gray\"})})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:\"Speed:\"}),a.jsx(\"div\",{className:\"flex gap-1\",children:NJe.map(A=>a.jsxs(\"button\",{onClick:()=>u(A),className:`px-2 py-1 text-xs rounded transition-colors ${d===A?\"bg-bambu-green text-white\":\"bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark-tertiary/80\"}`,children:[A,\"x\"]},A))})]})]})]})]})]}),v&&r&&a.jsx(kJe,{archiveId:r,timelapseSrc:t,onClose:()=>b(!1),onSave:s})]})}function CJe({archiveIds:t,onClose:e}){w.useEffect(()=>{const s=o=>{o.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",s),()=>window.removeEventListener(\"keydown\",s)},[e]);const{data:n,isLoading:r,error:i}=Xe({queryKey:[\"archive-comparison\",t],queryFn:()=>ue.compareArchives(t)});return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\",onClick:e,children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[90vh] flex flex-col border border-bambu-dark-tertiary\",onClick:s=>s.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"h3\",{className:\"text-lg font-semibold text-white\",children:[\"Compare Archives (\",t.length,\")\"]}),a.jsx(\"button\",{onClick:e,className:\"text-bambu-gray hover:text-white p-1\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsx(\"div\",{className:\"flex-1 overflow-auto p-4 bg-bambu-dark-secondary\",children:r?a.jsx(\"div\",{className:\"flex items-center justify-center py-12\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):i?a.jsxs(\"div\",{className:\"text-center py-12 text-red-400\",children:[a.jsx(Dn,{className:\"w-12 h-12 mx-auto mb-4 opacity-50\"}),a.jsx(\"p\",{children:\"Failed to load comparison\"}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-2\",children:i instanceof Error?i.message:\"Unknown error\"})]}):n?a.jsx(PJe,{comparison:n}):null}),a.jsx(\"div\",{className:\"p-4 border-t border-bambu-dark-tertiary\",children:a.jsx(De,{variant:\"secondary\",onClick:e,className:\"w-full\",children:\"Close\"})})]})})}function PJe({comparison:t}){return a.jsxs(\"div\",{className:\"space-y-6\",children:[a.jsx(\"div\",{className:\"overflow-x-auto\",children:a.jsxs(\"table\",{className:\"w-full\",children:[a.jsx(\"thead\",{children:a.jsxs(\"tr\",{children:[a.jsx(\"th\",{className:\"text-left text-sm text-bambu-gray font-medium pb-2 pr-4 min-w-[150px]\",children:\"Setting\"}),t.archives.map(e=>a.jsxs(\"th\",{className:\"text-left text-sm font-medium pb-2 px-2 min-w-[120px]\",children:[a.jsx(\"div\",{className:\"text-white truncate max-w-[150px]\",title:e.print_name,children:e.print_name}),a.jsx(\"div\",{className:`text-xs ${e.status===\"completed\"?\"text-status-ok\":e.status===\"failed\"?\"text-status-error\":\"text-bambu-gray\"}`,children:e.status})]},e.id))]})}),a.jsx(\"tbody\",{className:\"divide-y divide-bambu-gray/20\",children:t.comparison.map(e=>a.jsxs(\"tr\",{className:e.has_difference?\"bg-yellow-500/5\":\"\",children:[a.jsx(\"td\",{className:\"py-2 pr-4 text-sm\",children:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[e.has_difference&&a.jsx(Dn,{className:\"w-3 h-3 text-yellow-400 flex-shrink-0\"}),a.jsx(\"span\",{className:e.has_difference?\"text-yellow-400\":\"text-bambu-gray\",children:e.label})]})}),e.values.map((n,r)=>a.jsxs(\"td\",{className:\"py-2 px-2 text-sm text-white\",children:[n??a.jsx(\"span\",{className:\"text-bambu-gray/50\",children:\"-\"}),e.unit&&n!==null&&a.jsx(\"span\",{className:\"text-bambu-gray ml-1\",children:e.unit})]},r))]},e.field))})]})}),t.differences.length>0&&a.jsxs(\"div\",{className:\"p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg\",children:[a.jsxs(\"h4\",{className:\"text-sm font-medium text-yellow-400 mb-2 flex items-center gap-2\",children:[a.jsx(Dn,{className:\"w-4 h-4\"}),t.differences.length,\" Difference\",t.differences.length>1?\"s\":\"\",\" Found\"]}),a.jsxs(\"ul\",{className:\"text-sm text-white/80 space-y-1\",children:[t.differences.slice(0,5).map(e=>a.jsxs(\"li\",{children:[a.jsx(\"span\",{className:\"text-yellow-400\",children:e.label}),\": \",e.values.join(\" vs \"),\" \",e.unit||\"\"]},e.field)),t.differences.length>5&&a.jsxs(\"li\",{className:\"text-bambu-gray\",children:[\"...and \",t.differences.length-5,\" more\"]})]})]}),t.success_correlation.has_both_outcomes?a.jsxs(\"div\",{className:\"p-4 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"h4\",{className:\"text-sm font-medium text-white mb-3 flex items-center gap-2\",children:[a.jsx(Ur,{className:\"w-4 h-4 text-bambu-green\"}),\"Success/Failure Analysis\"]}),a.jsxs(\"div\",{className:\"flex items-center gap-4 text-sm mb-3\",children:[a.jsxs(\"span\",{className:\"text-bambu-green\",children:[t.success_correlation.successful_count,\" successful\"]}),a.jsxs(\"span\",{className:\"text-red-400\",children:[t.success_correlation.failed_count,\" failed\"]})]}),t.success_correlation.insights&&t.success_correlation.insights.length>0?a.jsx(\"div\",{className:\"space-y-2\",children:t.success_correlation.insights.map(e=>a.jsxs(\"div\",{className:\"text-sm p-2 bg-bambu-dark-secondary rounded\",children:[a.jsxs(\"span\",{className:\"text-white font-medium\",children:[e.label,\":\"]}),\" \",a.jsx(\"span\",{className:\"text-white/80\",children:e.insight})]},e.field))}):a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:\"No clear correlations found between settings and outcomes.\"})]}):a.jsx(\"div\",{className:\"p-4 bg-bambu-dark rounded-lg text-sm text-bambu-gray\",children:a.jsx(\"p\",{children:t.success_correlation.message||\"Need both successful and failed prints for correlation analysis.\"})})]})}function TJe(t){const e=new Date(t),r=new Date().getTime()-e.getTime(),i=Math.floor(r/6e4);if(i<1)return\"Just now\";if(i<60)return`${i}m ago`;const s=Math.floor(i/60);return s<24?`${s}h ago`:`${Math.floor(s/24)}d ago`}function AJe({upload:t,projects:e,onArchive:n,onDiscard:r,isArchiving:i,isDiscarding:s}){const[o,l]=w.useState(!1),[c,d]=w.useState(t.tags||\"\"),[u,m]=w.useState(t.notes||\"\"),[p,f]=w.useState(t.project_id),[y,v]=w.useState(!1);return a.jsx(Tt,{children:a.jsxs(Mt,{className:\"py-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(ep,{className:\"w-8 h-8 text-bambu-green flex-shrink-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t.filename}),a.jsxs(\"div\",{className:\"flex items-center gap-2 text-xs text-bambu-gray\",children:[a.jsx(\"span\",{children:Lo(t.file_size)}),a.jsx(\"span\",{children:\"·\"}),a.jsxs(\"span\",{className:\"flex items-center gap-1\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),TJe(t.uploaded_at)]}),t.source_ip&&a.jsxs(a.Fragment,{children:[a.jsx(\"span\",{children:\"·\"}),a.jsxs(\"span\",{children:[\"from \",t.source_ip]})]})]})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"button\",{onClick:()=>l(!o),className:\"p-1 text-bambu-gray hover:text-white transition-colors\",children:o?a.jsx(cc,{className:\"w-5 h-5\"}):a.jsx(On,{className:\"w-5 h-5\"})}),a.jsx(De,{variant:\"primary\",size:\"sm\",onClick:()=>n(t.id,{tags:c,notes:u,project_id:p||void 0}),disabled:i,children:i?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsxs(a.Fragment,{children:[a.jsx(so,{className:\"w-4 h-4\"}),\"Archive\"]})}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>v(!0),disabled:s,children:s?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4 text-red-400\"})})]})]}),y&&a.jsx(yn,{title:\"Discard Upload\",message:`Are you sure you want to discard \"${t.filename}\"? This cannot be undone.`,confirmText:\"Discard\",variant:\"danger\",onConfirm:()=>{r(t.id),v(!1)},onCancel:()=>v(!1)}),o&&a.jsxs(\"div\",{className:\"mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:\"Tags\"}),a.jsx(\"input\",{type:\"text\",value:c,onChange:b=>d(b.target.value),placeholder:\"e.g., functional, prototype, gift\",className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:\"Notes\"}),a.jsx(\"textarea\",{value:u,onChange:b=>m(b.target.value),placeholder:\"Add notes about this print...\",rows:2,className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm resize-none\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:\"Project\"}),a.jsxs(\"select\",{value:p||\"\",onChange:b=>f(b.target.value?Number(b.target.value):null),className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm\",children:[a.jsx(\"option\",{value:\"\",children:\"No project\"}),e.map(b=>a.jsx(\"option\",{value:b.id,children:b.name},b.id))]})]})]})]})})}function jJe(){const t=nn(),{showToast:e}=hn(),[n,r]=w.useState(!1),[i,s]=w.useState(!1),[o,l]=w.useState(new Set),[c,d]=w.useState(new Set),{data:u,isLoading:m}=Xe({queryKey:[\"pending-uploads\"],queryFn:ux.list,refetchInterval:1e4}),{data:p}=Xe({queryKey:[\"projects\"],queryFn:()=>ue.getProjects()}),f=it({mutationFn:({id:g,data:_})=>ux.archive(g,_),onMutate:({id:g})=>{l(_=>new Set(_).add(g))},onSettled:(g,_,{id:C})=>{l(P=>{const N=new Set(P);return N.delete(C),N})},onSuccess:g=>{t.invalidateQueries({queryKey:[\"pending-uploads\"]}),t.invalidateQueries({queryKey:[\"archives\"]}),e(`Archived: ${g.print_name}`)},onError:g=>{e(g.message||\"Failed to archive\",\"error\")}}),y=it({mutationFn:g=>ux.discard(g),onMutate:g=>{d(_=>new Set(_).add(g))},onSettled:(g,_,C)=>{d(P=>{const N=new Set(P);return N.delete(C),N})},onSuccess:()=>{t.invalidateQueries({queryKey:[\"pending-uploads\"]}),e(\"Upload discarded\")},onError:g=>{e(g.message||\"Failed to discard\",\"error\")}}),v=it({mutationFn:ux.archiveAll,onSuccess:g=>{t.invalidateQueries({queryKey:[\"pending-uploads\"]}),t.invalidateQueries({queryKey:[\"archives\"]}),e(`Archived ${g.archived} files${g.failed>0?`, ${g.failed} failed`:\"\"}`)},onError:g=>{e(g.message||\"Failed to archive all\",\"error\")}}),b=it({mutationFn:ux.discardAll,onSuccess:g=>{t.invalidateQueries({queryKey:[\"pending-uploads\"]}),e(`Discarded ${g.discarded} files`)},onError:g=>{e(g.message||\"Failed to discard all\",\"error\")}});return m?a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-8 flex justify-center\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-green\"})})}):!u||u.length===0?null:a.jsxs(\"div\",{className:\"mb-6\",children:[a.jsxs(Tt,{className:\"border-l-4 border-l-yellow-500\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(La,{className:\"w-5 h-5 text-yellow-500\"}),a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white\",children:[\"Pending Uploads (\",u.length,\")\"]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(De,{variant:\"primary\",size:\"sm\",onClick:()=>r(!0),disabled:v.isPending,children:v.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsxs(a.Fragment,{children:[a.jsx(so,{className:\"w-4 h-4\"}),\"Archive All\"]})}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>s(!0),disabled:b.isPending,children:b.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsxs(a.Fragment,{children:[a.jsx(en,{className:\"w-4 h-4\"}),\"Discard All\"]})})]})]})}),a.jsxs(Mt,{children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-4\",children:\"These files were uploaded via the virtual printer. Review and archive them to add to your collection.\"}),a.jsx(\"div\",{className:\"space-y-3\",children:u.map(g=>a.jsx(AJe,{upload:g,projects:p||[],onArchive:(_,C)=>f.mutate({id:_,data:C}),onDiscard:_=>y.mutate(_),isArchiving:o.has(g.id),isDiscarding:c.has(g.id)},g.id))})]})]}),n&&a.jsx(yn,{title:\"Archive All Uploads\",message:`Are you sure you want to archive all ${u.length} pending uploads?`,confirmText:\"Archive All\",onConfirm:()=>{v.mutate(),r(!1)},onCancel:()=>r(!1)}),i&&a.jsx(yn,{title:\"Discard All Uploads\",message:`Are you sure you want to discard all ${u.length} pending uploads? This cannot be undone.`,confirmText:\"Discard All\",variant:\"danger\",onConfirm:()=>{b.mutate(),s(!1)},onCancel:()=>s(!1)})]})}function MJe({onClose:t}){const e=nn(),{showToast:n}=hn(),[r,i]=w.useState(\"\"),[s,o]=w.useState(null),[l,c]=w.useState(\"\"),[d,u]=w.useState(null),[m,p]=w.useState(\"count\");w.useEffect(()=>{const k=D=>{D.key===\"Escape\"&&(s?o(null):d?u(null):t())};return window.addEventListener(\"keydown\",k),()=>window.removeEventListener(\"keydown\",k)},[t,s,d]);const{data:f,isLoading:y}=Xe({queryKey:[\"tags\"],queryFn:ue.getTags}),v=it({mutationFn:({oldName:k,newName:D})=>ue.renameTag(k,D),onSuccess:(k,{oldName:D,newName:H})=>{e.invalidateQueries({queryKey:[\"tags\"]}),e.invalidateQueries({queryKey:[\"archives\"]}),n(`Renamed \"${D}\" to \"${H}\" in ${k.affected} archive${k.affected!==1?\"s\":\"\"}`),o(null)},onError:k=>{n(k.message||\"Failed to rename tag\",\"error\")}}),b=it({mutationFn:k=>ue.deleteTag(k),onSuccess:(k,D)=>{e.invalidateQueries({queryKey:[\"tags\"]}),e.invalidateQueries({queryKey:[\"archives\"]}),n(`Deleted \"${D}\" from ${k.affected} archive${k.affected!==1?\"s\":\"\"}`),u(null)},onError:k=>{n(k.message||\"Failed to delete tag\",\"error\")}}),g=k=>{o(k.name),c(k.name),u(null)},_=()=>{o(null),c(\"\")},C=()=>{if(!s||!l.trim())return;const k=l.trim();if(k===s){_();return}v.mutate({oldName:s,newName:k})},P=k=>{k.key===\"Enter\"?(k.preventDefault(),C()):k.key===\"Escape\"&&(k.preventDefault(),_())},N=k=>{u(k),o(null)},A=()=>{d&&b.mutate(d)},T=f?.filter(k=>k.name.toLowerCase().includes(r.toLowerCase())).sort((k,D)=>m===\"count\"&&D.count-k.count||k.name.localeCompare(D.name)),F=f?.reduce((k,D)=>k+D.count,0)||0;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:a.jsx(Tt,{className:\"w-full max-w-lg max-h-[80vh] flex flex-col\",children:a.jsxs(Mt,{className:\"p-0 flex flex-col min-h-0\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(xm,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:\"Manage Tags\"})]}),a.jsx(\"button\",{onClick:t,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary flex-shrink-0\",children:[a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",placeholder:\"Search tags...\",className:\"w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",value:r,onChange:k=>i(k.target.value)})]}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",value:m,onChange:k=>p(k.target.value),children:[a.jsx(\"option\",{value:\"count\",children:\"Sort by Count\"}),a.jsx(\"option\",{value:\"name\",children:\"Sort by Name\"})]})]}),f&&a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mt-2\",children:[f.length,\" tag\",f.length!==1?\"s\":\"\",\" across \",F,\" usage\",F!==1?\"s\":\"\"]})]}),a.jsx(\"div\",{className:\"flex-1 overflow-y-auto min-h-0 p-4\",children:y?a.jsx(\"div\",{className:\"flex items-center justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-gray\"})}):T?.length?a.jsx(\"div\",{className:\"space-y-2\",children:T.map(k=>a.jsx(\"div\",{className:\"flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group\",children:s===k.name?a.jsxs(\"div\",{className:\"flex-1 flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"text\",className:\"flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none\",value:l,onChange:D=>c(D.target.value),onKeyDown:P,autoFocus:!0}),a.jsx(De,{size:\"sm\",variant:\"primary\",onClick:C,disabled:!l.trim()||v.isPending,className:\"p-1.5\",children:v.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Ur,{className:\"w-4 h-4\"})}),a.jsx(De,{size:\"sm\",variant:\"ghost\",onClick:_,className:\"p-1.5\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}):d===k.name?a.jsxs(\"div\",{className:\"flex-1 flex items-center gap-2\",children:[a.jsx(Dn,{className:\"w-4 h-4 text-yellow-400 flex-shrink-0\"}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray-light flex-1\",children:['Delete \"',k.name,'\" from ',k.count,\" archive\",k.count!==1?\"s\":\"\",\"?\"]}),a.jsx(De,{size:\"sm\",variant:\"danger\",onClick:A,disabled:b.isPending,className:\"p-1.5\",children:b.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"})}),a.jsx(De,{size:\"sm\",variant:\"ghost\",onClick:()=>u(null),className:\"p-1.5\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}):a.jsxs(a.Fragment,{children:[a.jsx(xm,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"}),a.jsx(\"span\",{className:\"text-white flex-1 truncate\",children:k.name}),a.jsx(\"span\",{className:\"px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs\",children:k.count}),a.jsxs(\"div\",{className:\"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity\",children:[a.jsx(\"button\",{onClick:()=>g(k),className:\"p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors\",title:\"Rename tag\",children:a.jsx(ci,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>N(k.name),className:\"p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors\",title:\"Delete tag\",children:a.jsx(en,{className:\"w-4 h-4\"})})]})]})},k.name))}):a.jsx(\"div\",{className:\"text-center py-8 text-bambu-gray\",children:r?\"No tags match your search\":\"No tags found\"})}),a.jsx(\"div\",{className:\"flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0\",children:a.jsx(De,{variant:\"secondary\",onClick:t,className:\"flex-1\",children:\"Close\"})})]})})})}function zh(t){const e=t.filename;if(e){const n=e.toLowerCase();if(n.endsWith(\".gcode\")||n.includes(\".gcode.\"))return!0}return!!(t.total_layers||t.print_time_seconds)}function ile(t){if(!t)return;const e=t.toLowerCase();return e.endsWith(\".3mf\")?\"3mf\":e.endsWith(\".stl\")?\"stl\":e.endsWith(\".gcode\")||e.includes(\".gcode.\")?\"gcode\":e.split(\".\").pop()}async function Uh(t,e,n,r){try{if(n===\"source\"){const{token:i}=await ue.createSourceSlicerToken(t),s=ue.getSourceSlicerDownloadUrl(t,i,e);qf(`${window.location.origin}${s}`,r)}else{const{token:i}=await ue.createArchiveSlicerToken(t),s=ue.getArchiveSlicerDownloadUrl(t,i,e);qf(`${window.location.origin}${s}`,r)}}catch{const i=n===\"source\"?ue.getSource3mfForSlicer(t,e):ue.getArchiveForSlicer(t,e);qf(`${window.location.origin}${i}`,r)}}function EJe({archive:t,printerName:e,isSelected:n,onSelect:r,selectionMode:i,projects:s,isHighlighted:o,timeFormat:l=\"system\",preferredSlicer:c=\"bambu_studio\",currency:d,t:u,onNavigateToArchive:m}){o&&console.log(\"ArchiveCard isHighlighted=true for archive:\",t.id);const p=nn(),{showToast:f}=hn(),{hasPermission:y,canModify:v}=kr(),b=aie(),[g,_]=w.useState(!1),[C,P]=w.useState(!1),[N,A]=w.useState(!1),[T,F]=w.useState(!1),[k,D]=w.useState(!1),[H,z]=w.useState(!1),[Q,L]=w.useState([]),[te,ie]=w.useState(!1),[J,oe]=w.useState(!1),[fe,re]=w.useState(!1),[W,ne]=w.useState(!1),[me,be]=w.useState(!1),[Ce,q]=w.useState(!1),[Y,E]=w.useState(!1),[j,O]=w.useState(null),[K,U]=w.useState(null),[de,I]=w.useState(!1),G=w.useRef(null),X=w.useRef(null),V=w.useRef(null),{data:ee}=Xe({queryKey:[\"archive-plates\",t.id],queryFn:()=>ue.getArchivePlates(t.id),enabled:de,staleTime:300*1e3}),se=t.duplicate_sequence??0,ge=t.original_archive_id??null,he=ee?.plates??[],le=ee?.is_multi_plate??!1,B=K??0,R=it({mutationFn:()=>ue.deleteArchiveTimelapse(t.id),onSuccess:()=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.timelapseRemoved\"))},onError:pe=>{f(pe.message||u(\"archives.toast.failedRemoveTimelapse\"),\"error\")}}),ae=it({mutationFn:pe=>ue.uploadArchiveTimelapse(t.id,pe),onSuccess:pe=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.timelapseUploaded\",{filename:pe.filename}))},onError:pe=>{f(pe.message||u(\"archives.toast.failedUploadTimelapse\"),\"error\")}}),_e=it({mutationFn:pe=>ue.uploadSource3mf(t.id,pe),onSuccess:pe=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.source3mfAttached\",{filename:pe.filename}))},onError:pe=>{f(pe.message||u(\"archives.toast.failedUploadSource3mf\"),\"error\")}}),Se=it({mutationFn:()=>ue.deleteSource3mf(t.id),onSuccess:()=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.source3mfRemoved\"))},onError:pe=>{f(pe.message||u(\"archives.toast.failedRemoveSource3mf\"),\"error\")}}),ve=it({mutationFn:pe=>ue.uploadF3d(t.id,pe),onSuccess:pe=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.f3dAttached\",{filename:pe.filename}))},onError:pe=>{f(pe.message||u(\"archives.toast.failedUploadF3d\"),\"error\")}}),Te=it({mutationFn:()=>ue.deleteF3d(t.id),onSuccess:()=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.f3dRemoved\"))},onError:pe=>{f(pe.message||u(\"archives.toast.failedRemoveF3d\"),\"error\")}}),ye=it({mutationFn:()=>ue.scanArchiveTimelapse(t.id),onSuccess:pe=>{pe.status===\"attached\"?(p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.timelapseAttached\",{filename:pe.filename}))):pe.status===\"exists\"?f(u(\"archives.toast.timelapseAlreadyAttached\")):pe.status===\"not_found\"&&pe.available_files&&pe.available_files.length>0?(L(pe.available_files),z(!0)):f(pe.message||u(\"archives.toast.noMatchingTimelapse\"),\"warning\")},onError:pe=>{f(pe.message||u(\"archives.toast.failedScanTimelapse\"),\"error\")}}),je=it({mutationFn:pe=>ue.selectArchiveTimelapse(t.id,pe),onSuccess:pe=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.timelapseAttached\",{filename:pe.filename})),z(!1),L([])},onError:pe=>{f(pe.message||u(\"archives.toast.failedAttachTimelapse\"),\"error\")}}),Le=it({mutationFn:()=>ue.deleteArchive(t.id),onSuccess:()=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.archiveDeleted\"))},onError:()=>{f(u(\"archives.toast.failedDeleteArchive\"),\"error\")}}),Me=it({mutationFn:()=>ue.toggleFavorite(t.id),onSuccess:pe=>{p.invalidateQueries({queryKey:[\"archives\"]}),f(pe.is_favorite?u(\"archives.toast.addedToFavorites\"):u(\"archives.toast.removedFromFavorites\"))}}),{data:Oe}=Xe({queryKey:[\"archive-folders\",t.id],queryFn:()=>ue.getLibraryFoldersByArchive(t.id)}),Re=it({mutationFn:pe=>ue.updateArchive(t.id,{project_id:pe}),onSuccess:()=>{p.invalidateQueries({queryKey:[\"archives\"]}),p.invalidateQueries({queryKey:[\"projects\"]}),f(u(\"archives.toast.projectUpdated\"))},onError:()=>{f(u(\"archives.toast.failedUpdateProject\"),\"error\")}}),$e=pe=>{pe.preventDefault(),O({x:pe.clientX,y:pe.clientY})},tt=[...zh(t)?[{label:u(\"archives.menu.print\"),icon:a.jsx(Er,{className:\"w-4 h-4\"}),onClick:()=>P(!0),disabled:!t.file_path||!v(\"archives\",\"reprint\",t.created_by_id),title:t.file_path?v(\"archives\",\"reprint\",t.created_by_id)?void 0:u(\"archives.permission.noReprint\"):u(\"archives.card.noFileForReprint\")},{label:u(\"archives.menu.schedule\"),icon:a.jsx(oa,{className:\"w-4 h-4\"}),onClick:()=>ne(!0),disabled:!t.file_path||!y(\"queue:create\"),title:t.file_path?y(\"queue:create\")?void 0:u(\"archives.permission.noAddToQueue\"):u(\"archives.card.noFileForReprint\")},{label:u(\"archives.menu.openInBambuStudio\"),icon:a.jsx(la,{className:\"w-4 h-4\"}),onClick:()=>{const pe=t.print_name||t.filename||\"model\";Uh(t.id,pe,\"file\",c)},disabled:!t.file_path,title:t.file_path?void 0:u(\"archives.card.noFileForReprint\")}]:[{label:u(\"archives.menu.slice\"),icon:a.jsx(la,{className:\"w-4 h-4\"}),onClick:()=>{const pe=t.print_name||t.filename||\"model\";Uh(t.id,pe,\"file\",c)}}],{label:t.external_url?u(\"archives.menu.externalLink\"):u(\"archives.menu.viewOnMakerWorld\"),icon:a.jsx(ul,{className:\"w-4 h-4\"}),onClick:()=>{const pe=t.external_url||t.makerworld_url;pe&&window.open(pe,\"_blank\")},disabled:!t.external_url&&!t.makerworld_url},{label:\"\",divider:!0,onClick:()=>{}},{label:u(\"archives.menu.preview3d\"),icon:a.jsx(vi,{className:\"w-4 h-4\"}),onClick:()=>_(!0)},{label:u(\"archives.menu.viewTimelapse\"),icon:a.jsx(tp,{className:\"w-4 h-4\"}),onClick:()=>D(!0),disabled:!t.timelapse_path},{label:u(\"archives.menu.scanForTimelapse\"),icon:a.jsx(NN,{className:\"w-4 h-4\"}),onClick:()=>ye.mutate(),disabled:!t.printer_id||!!t.timelapse_path||ye.isPending||!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")},{label:u(\"archives.menu.uploadTimelapse\"),icon:a.jsx(La,{className:\"w-4 h-4\"}),onClick:()=>V.current?.click(),disabled:!!t.timelapse_path||!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")},...t.timelapse_path?[{label:u(\"archives.menu.removeTimelapse\"),icon:a.jsx(en,{className:\"w-4 h-4\"}),onClick:()=>E(!0),danger:!0,disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")}]:[],{label:\"\",divider:!0,onClick:()=>{}},{label:t.source_3mf_path?u(\"archives.menu.downloadSource3mf\"):u(\"archives.menu.uploadSource3mf\"),icon:a.jsx(yN,{className:\"w-4 h-4\"}),onClick:()=>{t.source_3mf_path?ue.downloadSource3mf(t.id).catch(pe=>{console.error(\"Source 3MF download failed:\",pe)}):G.current?.click()},disabled:!t.source_3mf_path&&!v(\"archives\",\"update\",t.created_by_id),title:!t.source_3mf_path&&!v(\"archives\",\"update\",t.created_by_id)?u(\"archives.permission.noUploadFiles\"):void 0},...t.source_3mf_path?[{label:u(\"archives.menu.replaceSource3mf\"),icon:a.jsx(La,{className:\"w-4 h-4\"}),onClick:()=>G.current?.click(),disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")},{label:u(\"archives.menu.removeSource3mf\"),icon:a.jsx(en,{className:\"w-4 h-4\"}),onClick:()=>be(!0),danger:!0,disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")}]:[],{label:t.f3d_path?u(\"archives.menu.replaceF3d\"):u(\"archives.menu.uploadF3d\"),icon:a.jsx(vi,{className:\"w-4 h-4\"}),onClick:()=>X.current?.click(),disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")},...t.f3d_path?[{label:u(\"archives.menu.downloadF3d\"),icon:a.jsx(ga,{className:\"w-4 h-4\"}),onClick:()=>{ue.downloadF3d(t.id).catch(pe=>{console.error(\"F3D download failed:\",pe)})}},{label:u(\"archives.menu.removeF3d\"),icon:a.jsx(en,{className:\"w-4 h-4\"}),onClick:()=>q(!0),danger:!0,disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")}]:[],{label:\"\",divider:!0,onClick:()=>{}},{label:u(\"archives.menu.download\"),icon:a.jsx(ga,{className:\"w-4 h-4\"}),onClick:()=>{ue.downloadArchive(t.id,`${t.print_name||t.filename}.3mf`).catch(pe=>{console.error(\"Archive download failed:\",pe)})},disabled:!y(\"archives:read\"),title:y(\"archives:read\")?void 0:u(\"archives.permission.noDownload\")},{label:u(\"archives.menu.copyDownloadLink\"),icon:a.jsx(qs,{className:\"w-4 h-4\"}),onClick:()=>{const pe=`${window.location.origin}${ue.getArchiveDownload(t.id)}`;navigator.clipboard.writeText(pe).then(()=>{f(u(\"archives.toast.linkCopied\"))}).catch(()=>{f(u(\"archives.toast.failedCopyLink\"),\"error\")})},disabled:!y(\"archives:read\"),title:y(\"archives:read\")?void 0:u(\"archives.permission.noCopyLink\")},{label:u(\"archives.menu.qrCode\"),icon:a.jsx(UQ,{className:\"w-4 h-4\"}),onClick:()=>ie(!0)},{label:t.photos?.length?u(\"archives.menu.viewPhotosCount\",{count:t.photos.length}):u(\"archives.menu.viewPhotos\"),icon:a.jsx(qx,{className:\"w-4 h-4\"}),onClick:()=>oe(!0),disabled:!t.photos?.length},{label:u(\"archives.menu.projectPage\"),icon:a.jsx(Us,{className:\"w-4 h-4\"}),onClick:()=>re(!0)},{label:\"\",divider:!0,onClick:()=>{}},{label:t.is_favorite?u(\"archives.menu.removeFromFavorites\"):u(\"archives.menu.addToFavorites\"),icon:a.jsx(ug,{className:`w-4 h-4 ${t.is_favorite?\"fill-yellow-400 text-yellow-400\":\"\"}`}),onClick:()=>Me.mutate(),disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")},{label:u(\"archives.menu.edit\"),icon:a.jsx(ci,{className:\"w-4 h-4\"}),onClick:()=>F(!0),disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\")},...t.project_id&&t.project_name?[{label:u(\"archives.menu.goToProject\",{name:t.project_name}),icon:a.jsx(Bs,{className:\"w-4 h-4 text-bambu-green\"}),onClick:()=>window.location.href=\"/projects\"}]:[],{label:u(\"archives.menu.addToProject\"),icon:a.jsx(Bs,{className:\"w-4 h-4\"}),onClick:()=>{},disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?void 0:u(\"archives.permission.noUpdateArchives\"),submenu:(()=>{const pe=[];if(t.project_id&&pe.push({label:u(\"archives.menu.removeFromProject\"),icon:a.jsx(Ht,{className:\"w-4 h-4\"}),onClick:()=>Re.mutate(null),disabled:!v(\"archives\",\"update\",t.created_by_id)}),!s)pe.push({label:u(\"archives.menu.loading\"),icon:a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),onClick:()=>{},disabled:!0});else{const Fe=s.filter(we=>we.status===\"active\");Fe.length===0?pe.push({label:u(\"archives.menu.noProjectsAvailable\"),icon:a.jsx(Bs,{className:\"w-4 h-4 opacity-50\"}),onClick:()=>{},disabled:!0}):Fe.forEach(we=>{pe.push({label:we.name,icon:a.jsx(\"div\",{className:\"w-3 h-3 rounded-full flex-shrink-0\",style:{backgroundColor:we.color||\"#888\"}}),onClick:()=>Re.mutate(we.id),disabled:t.project_id===we.id||!v(\"archives\",\"update\",t.created_by_id)})})}return pe})()},{label:u(n?\"archives.menu.deselect\":\"archives.menu.select\"),icon:n?a.jsx(Ns,{className:\"w-4 h-4\"}):a.jsx(uo,{className:\"w-4 h-4\"}),onClick:()=>r(t.id)},{label:\"\",divider:!0,onClick:()=>{}},{label:u(\"archives.menu.delete\"),icon:a.jsx(en,{className:\"w-4 h-4\"}),onClick:()=>A(!0),danger:!0,disabled:!v(\"archives\",\"delete\",t.created_by_id),title:v(\"archives\",\"delete\",t.created_by_id)?void 0:u(\"archives.permission.noDelete\")}];return a.jsxs(Tt,{\"data-archive-id\":t.id,className:`relative flex flex-col group ${n?\"ring-2 ring-bambu-green\":\"\"} ${i?\"cursor-pointer\":\"\"}`,style:o?{outline:\"4px solid #facc15\",outlineOffset:\"2px\"}:void 0,onContextMenu:$e,onClick:i?()=>r(t.id):void 0,children:[i&&a.jsx(\"button\",{className:\"absolute top-2 left-2 z-10 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors\",onClick:pe=>{pe.stopPropagation(),r(t.id)},children:n?a.jsx(Ns,{className:\"w-5 h-5 text-bambu-green\"}):a.jsx(uo,{className:\"w-5 h-5 text-white\"})}),a.jsxs(\"div\",{className:\"aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl\",onMouseEnter:()=>I(!0),onMouseLeave:()=>I(!1),children:[t.thumbnail_path?a.jsx(\"img\",{src:K!==null&&he.length>0?ue.getArchivePlateThumbnail(t.id,he[B]?.index??0):ue.getArchiveThumbnail(t.id),alt:t.print_name||t.filename,className:\"w-full h-full object-cover\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center\",children:a.jsx(gm,{className:\"w-12 h-12 text-bambu-dark-tertiary\"})}),le&&he.length>1&&a.jsxs(a.Fragment,{children:[a.jsx(\"button\",{className:`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${b?\"opacity-100\":\"opacity-0 group-hover:opacity-100\"}`,onClick:pe=>{pe.stopPropagation(),U(Fe=>{const we=Fe??0;return we>0?we-1:he.length-1})},title:u(\"archives.card.previousPlate\"),children:a.jsx(xl,{className:\"w-4 h-4 text-white\"})}),a.jsx(\"button\",{className:`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${b?\"opacity-100\":\"opacity-0 group-hover:opacity-100\"}`,onClick:pe=>{pe.stopPropagation(),U(Fe=>{const we=Fe??0;return we<he.length-1?we+1:0})},title:u(\"archives.card.nextPlate\"),children:a.jsx(ti,{className:\"w-4 h-4 text-white\"})}),a.jsx(\"div\",{className:`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${b?\"opacity-100\":\"opacity-0 group-hover:opacity-100\"}`,children:he.map((pe,Fe)=>a.jsx(\"button\",{className:`w-2 h-2 rounded-full transition-colors ${Fe===B?\"bg-bambu-green\":\"bg-white/50 hover:bg-white/80\"}`,onClick:we=>{we.stopPropagation(),U(Fe)},title:pe.name||u(\"archives.card.plateNumber\",{index:pe.index})},pe.index))})]}),a.jsx(\"button\",{className:`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${b?\"opacity-100\":\"opacity-0 group-hover:opacity-100\"} ${i?\"left-10\":\"\"}`,onClick:pe=>{pe.stopPropagation();const Fe=pe.currentTarget.getBoundingClientRect();O({x:Fe.left,y:Fe.bottom+4})},title:u(\"archives.card.moreOptions\"),children:a.jsx(Jh,{className:\"w-5 h-5 text-white\"})}),a.jsx(\"button\",{className:`absolute top-2 right-2 p-1 rounded transition-colors ${v(\"archives\",\"update\",t.created_by_id)?\"bg-black/50 hover:bg-black/70\":\"bg-black/30 cursor-not-allowed\"}`,onClick:pe=>{pe.stopPropagation(),v(\"archives\",\"update\",t.created_by_id)&&Me.mutate()},disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?t.is_favorite?u(\"archives.card.removeFromFavorites\"):u(\"archives.card.addToFavorites\"):u(\"archives.permission.noUpdateArchives\"),children:a.jsx(ug,{className:`w-5 h-5 ${t.is_favorite?\"text-yellow-400 fill-yellow-400\":\"text-white\"} ${v(\"archives\",\"update\",t.created_by_id)?\"\":\"opacity-50\"}`})}),(t.status===\"failed\"||t.status===\"aborted\")&&a.jsx(\"div\",{className:\"absolute top-2 left-12 px-2 py-1 rounded text-xs bg-status-error/80 text-white\",children:t.status===\"aborted\"?u(\"archives.card.cancelled\"):u(\"archives.card.failed\")}),t.duplicate_count>0&&se>0&&ge&&a.jsxs(\"button\",{onClick:pe=>{pe.stopPropagation(),m?.(ge)},className:\"absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 hover:bg-purple-600/90 text-white flex items-center gap-1 transition-colors cursor-pointer\",title:u(\"archives.viewOriginalPrint\",{id:ge}),children:[a.jsx(qs,{className:\"w-3 h-3\"}),\"#\",se]}),t.duplicate_count>0&&se===0&&a.jsxs(\"span\",{className:\"absolute top-2 right-12 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1\",title:`${t.duplicate_count} reprint${t.duplicate_count===1?\"\":\"s\"}`,children:[a.jsx(OQ,{className:\"w-3 h-3\"}),\"+\",t.duplicate_count]}),t.source_3mf_path&&a.jsx(\"button\",{className:\"absolute bottom-2 left-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors\",onClick:pe=>{pe.stopPropagation();const Fe=(t.print_name||t.filename||\"source\").replace(/\\.gcode\\.3mf$/i,\"\")+\"_source\";Uh(t.id,Fe,\"source\",c)},title:u(\"archives.card.openSource3mf\"),children:a.jsx(yN,{className:\"w-4 h-4 text-orange-400\"})}),t.f3d_path&&a.jsx(\"button\",{className:`absolute bottom-2 ${t.source_3mf_path?\"left-12\":\"left-2\"} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`,onClick:pe=>{pe.stopPropagation(),ue.downloadF3d(t.id).catch(Fe=>{console.error(\"F3D download failed:\",Fe)})},title:u(\"archives.card.downloadF3d\"),children:a.jsx(vi,{className:\"w-4 h-4 text-cyan-400\"})}),a.jsx(\"button\",{className:\"absolute bottom-2 right-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors\",onClick:pe=>{pe.stopPropagation(),_(!0)},title:u(\"archives.card.preview3d\"),children:a.jsx(da,{className:\"w-4 h-4 text-white\"})}),t.timelapse_path&&a.jsx(\"button\",{className:\"absolute bottom-2 right-12 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors\",onClick:pe=>{pe.stopPropagation(),D(!0)},title:u(\"archives.card.viewTimelapse\"),children:a.jsx(tp,{className:\"w-4 h-4 text-bambu-green\"})}),t.photos&&t.photos.length>0&&a.jsxs(\"button\",{className:`absolute bottom-2 ${t.timelapse_path?\"right-[5.5rem]\":\"right-12\"} p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors`,onClick:pe=>{pe.stopPropagation(),oe(!0)},title:t.photos.length===1?u(\"archives.card.viewPhoto\"):u(\"archives.card.viewPhotos\",{count:t.photos.length}),children:[a.jsx(qx,{className:\"w-4 h-4 text-blue-400\"}),t.photos.length>1&&a.jsx(\"span\",{className:\"absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full text-[10px] text-white flex items-center justify-center\",children:t.photos.length})]}),Oe&&Oe.length>0&&a.jsx(Do,{to:`/files?folder=${Oe[0].id}`,className:\"absolute bottom-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors\",onClick:pe=>pe.stopPropagation(),title:u(\"archives.card.openFolder\",{name:Oe[0].name}),style:{left:t.source_3mf_path?t.f3d_path?\"5.5rem\":\"3rem\":t.f3d_path?\"3rem\":\"0.5rem\"},children:a.jsx(Qc,{className:\"w-4 h-4 text-yellow-400\"})})]}),a.jsxs(Mt,{className:\"p-4 flex-1 flex flex-col\",children:[a.jsxs(\"p\",{className:\"text-[10px] text-bambu-gray/70 mb-1\",children:[\"#\",t.id]}),a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2 mb-1\",children:[a.jsx(\"h3\",{className:\"min-w-0 font-medium text-white truncate\",children:t.print_name||t.filename}),a.jsx(De,{variant:\"ghost\",size:\"sm\",className:\"p-1 sm:p-1.5 shrink-0\",onClick:()=>F(!0),disabled:!v(\"archives\",\"update\",t.created_by_id),title:v(\"archives\",\"update\",t.created_by_id)?u(\"archives.card.edit\"):u(\"archives.card.noPermissionEdit\"),children:a.jsx(ci,{className:\"w-3 h-3 sm:w-4 sm:h-4\"})})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3 flex-wrap\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:e}),a.jsx(\"span\",{className:`text-[10px] px-1.5 py-0.5 rounded font-medium ${zh(t)?\"bg-bambu-green/20 text-bambu-green\":\"bg-orange-500/20 text-orange-400\"}`,title:zh(t)?u(\"archives.card.slicedFile\"):u(\"archives.card.sourceFile\"),children:zh(t)?u(\"archives.card.gcode\"):u(\"archives.card.source\")}),t.content_hash&&a.jsx(\"span\",{className:\"text-[10px] px-1.5 py-0.5 rounded font-mono bg-bambu-dark-tertiary/50 text-bambu-gray-light opacity-0 transition-opacity duration-150 group-hover:opacity-100\",title:`SHA256: ${t.content_hash}`,children:t.content_hash.slice(0,8).toUpperCase()}),t.project_name&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]\",style:{backgroundColor:`${s?.find(pe=>pe.id===t.project_id)?.color||\"#6b7280\"}20`,color:s?.find(pe=>pe.id===t.project_id)?.color||\"#6b7280\"},title:u(\"archives.card.project\",{name:t.project_name}),children:t.project_name})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]\",children:[(t.print_time_seconds||t.actual_time_seconds)&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-bambu-gray\",title:t.time_accuracy?`Estimated: ${ws(t.print_time_seconds||0)}\nActual: ${ws(t.actual_time_seconds||0)}\nAccuracy: ${t.time_accuracy.toFixed(0)}%`:t.actual_time_seconds?`Actual: ${ws(t.actual_time_seconds)}`:`Estimated: ${ws(t.print_time_seconds||0)}`,children:[a.jsx(Yn,{className:\"w-3 h-3\"}),ws(t.actual_time_seconds||t.print_time_seconds||0),t.time_accuracy&&a.jsxs(\"span\",{className:`text-[10px] px-1 rounded ${t.time_accuracy>=95&&t.time_accuracy<=105?\"bg-bambu-green/20 text-bambu-green\":t.time_accuracy>105?\"bg-blue-500/20 text-blue-400\":\"bg-orange-500/20 text-orange-400\"}`,children:[t.time_accuracy>100?\"+\":\"\",(t.time_accuracy-100).toFixed(0),\"%\"]})]}),t.filament_used_grams&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-bambu-gray\",children:[a.jsx(Ra,{className:\"w-3 h-3\"}),t.filament_used_grams.toFixed(1),\"g\"]}),(t.cost!=null||t.energy_cost!=null)&&a.jsxs(\"div\",{className:\"flex items-center gap-3 text-bambu-gray\",children:[t.cost!=null&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(Cfe,{className:\"w-3 h-3\"}),d,t.cost.toFixed(2)]}),t.energy_cost!=null&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",title:`${u(\"stats.energyUsed\")}: ${t.energy_kwh?.toFixed(3)||\"N/A\"} kWh`,children:[a.jsx(dc,{className:\"w-3 h-3\"}),d,t.energy_cost.toFixed(2)]})]}),(t.layer_height||t.total_layers)&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-bambu-gray\",children:[a.jsx(da,{className:\"w-3 h-3\"}),t.total_layers&&a.jsx(\"span\",{children:t.total_layers===1?u(\"archives.card.layer\",{count:t.total_layers}):u(\"archives.card.layers\",{count:t.total_layers})}),t.total_layers&&t.layer_height&&a.jsx(\"span\",{className:\"text-bambu-gray/50\",children:\"·\"}),t.layer_height&&a.jsxs(\"span\",{children:[t.layer_height,\"mm\"]})]}),t.object_count!=null&&t.object_count>0&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-bambu-gray\",title:t.object_count===1?u(\"archives.card.object\",{count:t.object_count}):u(\"archives.card.objects\",{count:t.object_count}),children:[a.jsx(vi,{className:\"w-3 h-3\"}),t.object_count===1?u(\"archives.card.object\",{count:t.object_count}):u(\"archives.card.objects\",{count:t.object_count})]}),t.sliced_for_model&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-bambu-gray\",title:u(\"archives.card.slicedFor\",{model:t.sliced_for_model}),children:[a.jsx(Er,{className:\"w-3 h-3\"}),t.sliced_for_model]}),t.filament_type&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 col-span-2\",children:[a.jsx(\"span\",{className:\"text-bambu-gray text-xs\",children:t.filament_type}),t.filament_color&&a.jsx(\"div\",{className:\"flex items-center gap-0.5 flex-wrap\",children:t.filament_color.split(\",\").map((pe,Fe)=>a.jsx(\"div\",{className:\"w-3 h-3 rounded-full border border-black/20\",style:{backgroundColor:pe},title:pe},Fe))})]})]}),(t.tags||t.notes)&&a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-1.5 mb-3\",children:[t.notes&&a.jsx(\"div\",{className:\"flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded text-xs\",title:t.notes,children:a.jsx(VQ,{className:\"w-3 h-3\"})}),t.tags?.split(\",\").map((pe,Fe)=>a.jsx(\"span\",{className:\"px-1.5 py-0.5 bg-bambu-dark-tertiary text-bambu-gray-light rounded text-xs\",children:pe.trim()},Fe))]}),a.jsx(\"div\",{className:\"flex-1\"}),a.jsxs(\"div\",{className:\"flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3\",children:[a.jsx(\"span\",{children:pg(t.created_at,l)}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[t.created_by_username&&a.jsxs(\"span\",{className:\"flex items-center gap-1\",title:u(\"archives.card.uploadedBy\",{name:t.created_by_username}),children:[a.jsx(ym,{className:\"w-3 h-3\"}),t.created_by_username]}),a.jsx(\"span\",{children:Lo(t.file_size)})]})]}),a.jsxs(\"div\",{className:\"flex gap-1 mt-3\",children:[zh(t)?a.jsxs(a.Fragment,{children:[a.jsxs(De,{variant:\"primary\",size:\"sm\",className:\"flex-1 min-w-0 overflow-hidden\",onClick:()=>P(!0),disabled:!t.file_path||!v(\"archives\",\"reprint\",t.created_by_id),title:t.file_path?v(\"archives\",\"reprint\",t.created_by_id)?void 0:u(\"archives.card.noPermissionReprint\"):u(\"archives.card.noFileForReprint\"),children:[a.jsx(Er,{className:\"w-3 h-3 flex-shrink-0\"}),a.jsx(\"span\",{className:\"hidden sm:inline truncate\",children:u(\"archives.card.reprint\")})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",className:\"flex-1 min-w-0 overflow-hidden\",onClick:()=>ne(!0),disabled:!t.file_path||!y(\"queue:create\"),title:t.file_path?y(\"queue:create\")?u(\"archives.card.schedulePrint\"):u(\"archives.permission.noAddToQueue\"):u(\"archives.card.noFileForReprint\"),children:[a.jsx(oa,{className:\"w-3 h-3 flex-shrink-0\"}),a.jsx(\"span\",{className:\"hidden sm:inline truncate\",children:u(\"archives.card.schedule\")})]}),a.jsx(De,{variant:\"secondary\",size:\"sm\",className:\"min-w-0 p-1 sm:p-1.5\",onClick:()=>{const pe=t.print_name||t.filename||\"model\";Uh(t.id,pe,\"file\",c)},title:u(\"archives.card.openInBambuStudio\"),children:a.jsx(la,{className:\"w-3 h-3 sm:w-4 sm:h-4\"})})]}):a.jsxs(De,{variant:\"primary\",size:\"sm\",className:\"flex-1 min-w-0 overflow-hidden\",onClick:()=>{const pe=t.print_name||t.filename||\"model\";Uh(t.id,pe,\"file\",c)},title:u(\"archives.card.openInBambuStudioToSlice\"),children:[a.jsx(la,{className:\"w-3 h-3 flex-shrink-0\"}),a.jsx(\"span\",{className:\"hidden sm:inline truncate\",children:u(\"archives.card.slice\")})]}),a.jsx(De,{variant:\"secondary\",size:\"sm\",className:\"min-w-0 p-1 sm:p-1.5\",onClick:()=>{const pe=t.external_url||t.makerworld_url;pe&&window.open(pe,\"_blank\")},disabled:!t.external_url&&!t.makerworld_url,title:t.external_url?u(\"archives.card.externalLink\"):t.makerworld_url?u(\"archives.card.makerWorld\",{designer:t.designer||u(\"archives.card.viewProject\")}):u(\"archives.card.noExternalLink\"),children:a.jsx(ul,{className:`w-3 h-3 sm:w-4 sm:h-4 ${!t.external_url&&!t.makerworld_url?\"opacity-20\":\"\"}`})}),a.jsx(De,{variant:\"secondary\",size:\"sm\",className:\"min-w-0 p-1 sm:p-1.5\",onClick:()=>{ue.downloadArchive(t.id,`${t.print_name||t.filename}.3mf`).catch(pe=>{console.error(\"Archive download failed:\",pe)})},title:u(\"archives.card.download\"),children:a.jsx(ga,{className:\"w-3 h-3 sm:w-4 sm:h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",className:\"min-w-0 p-1 sm:p-1.5\",onClick:()=>A(!0),disabled:!v(\"archives\",\"delete\",t.created_by_id),title:v(\"archives\",\"delete\",t.created_by_id)?u(\"archives.card.delete\"):u(\"archives.card.noPermissionDelete\"),children:a.jsx(en,{className:\"w-3 h-3 sm:w-4 sm:h-4 text-red-400\"})})]})]}),T&&a.jsx(iie,{archive:t,onClose:()=>F(!1)}),g&&a.jsx(_z,{archiveId:t.id,title:t.print_name||t.filename,fileType:ile(t.filename),onClose:()=>_(!1)}),C&&a.jsx(ic,{mode:\"reprint\",archiveId:t.id,archiveName:t.print_name||t.filename,onClose:()=>P(!1)}),N&&a.jsx(yn,{title:u(\"archives.modal.deleteArchive\"),message:u(\"archives.modal.deleteConfirm\",{name:t.print_name||t.filename}),confirmText:u(\"archives.modal.deleteButton\"),variant:\"danger\",onConfirm:()=>{Le.mutate(),A(!1)},onCancel:()=>A(!1)}),me&&a.jsx(yn,{title:u(\"archives.modal.removeSource3mf\"),message:u(\"archives.modal.removeSource3mfConfirm\",{name:t.print_name||t.filename}),confirmText:u(\"archives.modal.removeButton\"),variant:\"danger\",onConfirm:()=>{Se.mutate(),be(!1)},onCancel:()=>be(!1)}),Ce&&a.jsx(yn,{title:u(\"archives.modal.removeF3d\"),message:u(\"archives.modal.removeF3dConfirm\",{name:t.print_name||t.filename}),confirmText:u(\"archives.modal.removeButton\"),variant:\"danger\",onConfirm:()=>{Te.mutate(),q(!1)},onCancel:()=>q(!1)}),Y&&a.jsx(yn,{title:u(\"archives.modal.removeTimelapse\"),message:u(\"archives.modal.removeTimelapseConfirm\",{name:t.print_name||t.filename}),confirmText:u(\"archives.modal.removeButton\"),variant:\"danger\",onConfirm:()=>{R.mutate(),E(!1)},onCancel:()=>E(!1)}),j&&a.jsx(sie,{x:j.x,y:j.y,items:tt,onClose:()=>O(null)}),k&&t.timelapse_path&&a.jsx(ale,{src:ue.getArchiveTimelapse(t.id),title:u(\"archives.modal.timelapse\",{name:t.print_name||t.filename}),downloadFilename:`${t.print_name||t.filename}_timelapse.mp4`,archiveId:t.id,onClose:()=>D(!1),onEdit:()=>{p.invalidateQueries({queryKey:[\"archives\"]}),D(!1)}}),H&&Q.length>0&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-gray-700\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white\",children:u(\"archives.modal.selectTimelapse\")}),a.jsx(\"p\",{className:\"text-sm text-gray-400 mt-1\",children:u(\"archives.modal.selectTimelapseDesc\")})]}),a.jsx(\"button\",{onClick:()=>{z(!1),L([])},className:\"text-gray-400 hover:text-white p-1\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsx(\"div\",{className:\"overflow-y-auto flex-1 p-2\",children:Q.map(pe=>a.jsxs(\"button\",{onClick:()=>je.mutate(pe.name),disabled:je.isPending,className:\"w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-3 disabled:opacity-50\",children:[a.jsx(tp,{className:\"w-8 h-8 text-bambu-green flex-shrink-0\"}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-white font-medium truncate\",children:pe.name}),a.jsxs(\"p\",{className:\"text-sm text-gray-400\",children:[Lo(pe.size),pe.mtime&&` • ${pg(pe.mtime,l)}`]})]})]},pe.name))}),a.jsx(\"div\",{className:\"p-4 border-t border-gray-700\",children:a.jsx(De,{variant:\"secondary\",onClick:()=>{z(!1),L([])},className:\"w-full\",children:\"Cancel\"})})]})}),te&&a.jsx(oie,{archiveId:t.id,archiveName:t.print_name||t.filename,onClose:()=>ie(!1)}),J&&t.photos&&t.photos.length>0&&a.jsx(lie,{archiveId:t.id,archiveName:t.print_name||t.filename,photos:t.photos,onClose:()=>oe(!1),onDelete:async pe=>{try{await ue.deleteArchivePhoto(t.id,pe),p.invalidateQueries({queryKey:[\"archives\"]}),f(u(\"archives.toast.photoDeleted\"))}catch{f(u(\"archives.toast.failedDeletePhoto\"),\"error\")}}}),fe&&a.jsx(rle,{archiveId:t.id,archiveName:t.print_name||t.filename,onClose:()=>re(!1)}),W&&a.jsx(ic,{mode:\"add-to-queue\",archiveId:t.id,archiveName:t.print_name||t.filename,onClose:()=>ne(!1)}),a.jsx(\"input\",{ref:G,type:\"file\",accept:\".3mf\",className:\"hidden\",onChange:pe=>{const Fe=pe.target.files?.[0];Fe&&_e.mutate(Fe),pe.target.value=\"\"}}),a.jsx(\"input\",{ref:X,type:\"file\",accept:\".f3d\",className:\"hidden\",onChange:pe=>{const Fe=pe.target.files?.[0];Fe&&ve.mutate(Fe),pe.target.value=\"\"}}),a.jsx(\"input\",{ref:V,type:\"file\",accept:\".mp4,.avi,.mkv\",className:\"hidden\",onChange:pe=>{const Fe=pe.target.files?.[0];Fe&&ae.mutate(Fe),pe.target.value=\"\"}})]})}function DJe({archive:t,printerName:e,isSelected:n,onSelect:r,selectionMode:i,projects:s,isHighlighted:o,preferredSlicer:l=\"bambu_studio\",t:c,onNavigateToArchive:d}){const u=nn(),{showToast:m}=hn(),{hasPermission:p,canModify:f}=kr(),[y,v]=w.useState(!1),[b,g]=w.useState(!1),[_,C]=w.useState(!1),[P,N]=w.useState(!1),[A,T]=w.useState(!1),[F,k]=w.useState(!1),[D,H]=w.useState(!1),[z,Q]=w.useState([]),[L,te]=w.useState(!1),[ie,J]=w.useState(!1),[oe,fe]=w.useState(!1),[re,W]=w.useState(!1),[ne,me]=w.useState(!1),[be,Ce]=w.useState(!1),[q,Y]=w.useState(null),E=w.useRef(null),j=w.useRef(null),O=w.useRef(null),K=t.duplicate_sequence??0,U=t.original_archive_id??null,de=it({mutationFn:()=>ue.deleteArchiveTimelapse(t.id),onSuccess:()=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.timelapseRemoved\"))},onError:ve=>{m(ve.message||c(\"archives.toast.failedRemoveTimelapse\"),\"error\")}}),I=it({mutationFn:ve=>ue.uploadArchiveTimelapse(t.id,ve),onSuccess:ve=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.timelapseUploaded\",{filename:ve.filename}))},onError:ve=>{m(ve.message||c(\"archives.toast.failedUploadTimelapse\"),\"error\")}}),G=it({mutationFn:ve=>ue.uploadSource3mf(t.id,ve),onSuccess:ve=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.source3mfAttached\",{filename:ve.filename}))},onError:ve=>{m(ve.message||c(\"archives.toast.failedUploadSource3mf\"),\"error\")}}),X=it({mutationFn:()=>ue.deleteSource3mf(t.id),onSuccess:()=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.source3mfRemoved\"))},onError:ve=>{m(ve.message||c(\"archives.toast.failedRemoveSource3mf\"),\"error\")}}),V=it({mutationFn:ve=>ue.uploadF3d(t.id,ve),onSuccess:ve=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.f3dAttached\",{filename:ve.filename}))},onError:ve=>{m(ve.message||c(\"archives.toast.failedUploadF3d\"),\"error\")}}),ee=it({mutationFn:()=>ue.deleteF3d(t.id),onSuccess:()=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.f3dRemoved\"))},onError:ve=>{m(ve.message||c(\"archives.toast.failedRemoveF3d\"),\"error\")}}),se=it({mutationFn:()=>ue.scanArchiveTimelapse(t.id),onSuccess:ve=>{ve.status===\"attached\"?(u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.timelapseAttached\",{filename:ve.filename}))):ve.status===\"exists\"?m(c(\"archives.toast.timelapseAlreadyAttached\")):ve.status===\"not_found\"&&ve.available_files&&ve.available_files.length>0?(Q(ve.available_files),H(!0)):m(ve.message||c(\"archives.toast.noMatchingTimelapse\"),\"warning\")},onError:ve=>{m(ve.message||c(\"archives.toast.failedScanTimelapse\"),\"error\")}}),ge=it({mutationFn:ve=>ue.selectArchiveTimelapse(t.id,ve),onSuccess:ve=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.timelapseAttached\",{filename:ve.filename})),H(!1),Q([])},onError:ve=>{m(ve.message||c(\"archives.toast.failedAttachTimelapse\"),\"error\")}}),he=it({mutationFn:()=>ue.deleteArchive(t.id),onSuccess:()=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.archiveDeleted\"))},onError:()=>{m(c(\"archives.toast.failedDeleteArchive\"),\"error\")}}),le=it({mutationFn:()=>ue.toggleFavorite(t.id),onSuccess:ve=>{u.invalidateQueries({queryKey:[\"archives\"]}),m(ve.is_favorite?c(\"archives.toast.addedToFavorites\"):c(\"archives.toast.removedFromFavorites\"))}}),{data:B}=Xe({queryKey:[\"archive-folders\",t.id],queryFn:()=>ue.getLibraryFoldersByArchive(t.id)}),R=it({mutationFn:ve=>ue.updateArchive(t.id,{project_id:ve}),onSuccess:()=>{u.invalidateQueries({queryKey:[\"archives\"]}),u.invalidateQueries({queryKey:[\"projects\"]}),m(c(\"archives.toast.projectUpdated\"))},onError:()=>{m(c(\"archives.toast.failedUpdateProject\"),\"error\")}}),ae=ve=>{ve.preventDefault(),Y({x:ve.clientX,y:ve.clientY})},Se=[...zh(t)?[{label:c(\"archives.menu.print\"),icon:a.jsx(Er,{className:\"w-4 h-4\"}),onClick:()=>C(!0),disabled:!t.file_path||!f(\"archives\",\"reprint\",t.created_by_id),title:t.file_path?f(\"archives\",\"reprint\",t.created_by_id)?void 0:c(\"archives.permission.noReprint\"):c(\"archives.card.noFileForReprint\")},{label:c(\"archives.menu.schedule\"),icon:a.jsx(oa,{className:\"w-4 h-4\"}),onClick:()=>N(!0),disabled:!t.file_path||!p(\"queue:create\"),title:t.file_path?p(\"queue:create\")?void 0:c(\"archives.permission.noAddToQueue\"):c(\"archives.card.noFileForReprint\")},{label:c(\"archives.menu.openInBambuStudio\"),icon:a.jsx(la,{className:\"w-4 h-4\"}),onClick:()=>{const ve=t.print_name||t.filename||\"model\";Uh(t.id,ve,\"file\",l)},disabled:!t.file_path,title:t.file_path?void 0:c(\"archives.card.noFileForReprint\")}]:[{label:c(\"archives.menu.slice\"),icon:a.jsx(la,{className:\"w-4 h-4\"}),onClick:()=>{const ve=t.print_name||t.filename||\"model\";Uh(t.id,ve,\"file\",l)}}],{label:t.external_url?c(\"archives.menu.externalLink\"):c(\"archives.menu.viewOnMakerWorld\"),icon:a.jsx(ul,{className:\"w-4 h-4\"}),onClick:()=>{const ve=t.external_url||t.makerworld_url;ve&&window.open(ve,\"_blank\")},disabled:!t.external_url&&!t.makerworld_url},{label:\"\",divider:!0,onClick:()=>{}},{label:c(\"archives.menu.preview3d\"),icon:a.jsx(vi,{className:\"w-4 h-4\"}),onClick:()=>T(!0)},{label:c(\"archives.menu.viewTimelapse\"),icon:a.jsx(tp,{className:\"w-4 h-4\"}),onClick:()=>k(!0),disabled:!t.timelapse_path},{label:c(\"archives.menu.scanForTimelapse\"),icon:a.jsx(NN,{className:\"w-4 h-4\"}),onClick:()=>se.mutate(),disabled:!t.printer_id||!!t.timelapse_path||se.isPending||!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")},{label:c(\"archives.menu.uploadTimelapse\"),icon:a.jsx(La,{className:\"w-4 h-4\"}),onClick:()=>O.current?.click(),disabled:!!t.timelapse_path||!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")},...t.timelapse_path?[{label:c(\"archives.menu.removeTimelapse\"),icon:a.jsx(en,{className:\"w-4 h-4\"}),onClick:()=>Ce(!0),danger:!0,disabled:!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")}]:[],{label:\"\",divider:!0,onClick:()=>{}},{label:t.source_3mf_path?c(\"archives.menu.downloadSource3mf\"):c(\"archives.menu.uploadSource3mf\"),icon:a.jsx(yN,{className:\"w-4 h-4\"}),onClick:()=>{t.source_3mf_path?ue.downloadSource3mf(t.id).catch(ve=>{console.error(\"Source 3MF download failed:\",ve)}):E.current?.click()},disabled:!t.source_3mf_path&&!f(\"archives\",\"update\",t.created_by_id),title:!t.source_3mf_path&&!f(\"archives\",\"update\",t.created_by_id)?c(\"archives.permission.noUploadFiles\"):void 0},...t.source_3mf_path?[{label:c(\"archives.menu.replaceSource3mf\"),icon:a.jsx(La,{className:\"w-4 h-4\"}),onClick:()=>E.current?.click(),disabled:!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")},{label:c(\"archives.menu.removeSource3mf\"),icon:a.jsx(en,{className:\"w-4 h-4\"}),onClick:()=>W(!0),danger:!0,disabled:!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")}]:[],{label:t.f3d_path?c(\"archives.menu.replaceF3d\"):c(\"archives.menu.uploadF3d\"),icon:a.jsx(vi,{className:\"w-4 h-4\"}),onClick:()=>j.current?.click(),disabled:!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")},...t.f3d_path?[{label:c(\"archives.menu.downloadF3d\"),icon:a.jsx(ga,{className:\"w-4 h-4\"}),onClick:()=>{ue.downloadF3d(t.id).catch(ve=>{console.error(\"F3D download failed:\",ve)})}},{label:c(\"archives.menu.removeF3d\"),icon:a.jsx(en,{className:\"w-4 h-4\"}),onClick:()=>me(!0),danger:!0,disabled:!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")}]:[],{label:\"\",divider:!0,onClick:()=>{}},{label:c(\"archives.menu.download\"),icon:a.jsx(ga,{className:\"w-4 h-4\"}),onClick:()=>{ue.downloadArchive(t.id,`${t.print_name||t.filename}.3mf`).catch(ve=>{console.error(\"Archive download failed:\",ve)})},disabled:!p(\"archives:read\"),title:p(\"archives:read\")?void 0:c(\"archives.permission.noDownload\")},{label:c(\"archives.menu.copyDownloadLink\"),icon:a.jsx(qs,{className:\"w-4 h-4\"}),onClick:()=>{const ve=`${window.location.origin}${ue.getArchiveDownload(t.id)}`;navigator.clipboard.writeText(ve).then(()=>{m(c(\"archives.toast.linkCopied\"))}).catch(()=>{m(c(\"archives.toast.failedCopyLink\"),\"error\")})},disabled:!p(\"archives:read\"),title:p(\"archives:read\")?void 0:c(\"archives.permission.noCopyLink\")},{label:c(\"archives.menu.qrCode\"),icon:a.jsx(UQ,{className:\"w-4 h-4\"}),onClick:()=>te(!0)},{label:t.photos?.length?c(\"archives.menu.viewPhotosCount\",{count:t.photos.length}):c(\"archives.menu.viewPhotos\"),icon:a.jsx(qx,{className:\"w-4 h-4\"}),onClick:()=>J(!0),disabled:!t.photos?.length},{label:c(\"archives.menu.projectPage\"),icon:a.jsx(Us,{className:\"w-4 h-4\"}),onClick:()=>fe(!0)},{label:\"\",divider:!0,onClick:()=>{}},{label:t.is_favorite?c(\"archives.menu.removeFromFavorites\"):c(\"archives.menu.addToFavorites\"),icon:a.jsx(ug,{className:`w-4 h-4 ${t.is_favorite?\"fill-yellow-400 text-yellow-400\":\"\"}`}),onClick:()=>le.mutate(),disabled:!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")},{label:c(\"archives.menu.edit\"),icon:a.jsx(ci,{className:\"w-4 h-4\"}),onClick:()=>v(!0),disabled:!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?void 0:c(\"archives.permission.noUpdateArchives\")},...t.project_id&&t.project_name?[{label:c(\"archives.menu.goToProject\",{name:t.project_name}),icon:a.jsx(Bs,{className:\"w-4 h-4 text-bambu-green\"}),onClick:()=>window.location.href=\"/projects\"}]:[],{label:c(\"archives.menu.addToProject\"),icon:a.jsx(Bs,{className:\"w-4 h-4\"}),onClick:()=>{},submenu:(()=>{const ve=[];if(t.project_id&&ve.push({label:c(\"archives.menu.removeFromProject\"),icon:a.jsx(Ht,{className:\"w-4 h-4\"}),onClick:()=>R.mutate(null)}),!s)ve.push({label:c(\"archives.menu.loading\"),icon:a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),onClick:()=>{},disabled:!0});else{const Te=s.filter(ye=>ye.status===\"active\");Te.length===0?ve.push({label:c(\"archives.menu.noProjectsAvailable\"),icon:a.jsx(Bs,{className:\"w-4 h-4 opacity-50\"}),onClick:()=>{},disabled:!0}):Te.forEach(ye=>{ve.push({label:ye.name,icon:a.jsx(\"div\",{className:\"w-3 h-3 rounded-full flex-shrink-0\",style:{backgroundColor:ye.color||\"#888\"}}),onClick:()=>R.mutate(ye.id),disabled:t.project_id===ye.id})})}return ve})()},{label:c(n?\"archives.menu.deselect\":\"archives.menu.select\"),icon:n?a.jsx(Ns,{className:\"w-4 h-4\"}):a.jsx(uo,{className:\"w-4 h-4\"}),onClick:()=>r(t.id)},{label:\"\",divider:!0,onClick:()=>{}},{label:c(\"archives.menu.delete\"),icon:a.jsx(en,{className:\"w-4 h-4\"}),onClick:()=>g(!0),danger:!0,disabled:!f(\"archives\",\"delete\",t.created_by_id),title:f(\"archives\",\"delete\",t.created_by_id)?void 0:c(\"archives.permission.noDelete\")}];return a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{\"data-archive-id\":t.id,className:`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${n?\"bg-bambu-green/10\":\"\"}`,style:o?{outline:\"4px solid #facc15\",outlineOffset:\"-4px\"}:void 0,onContextMenu:ae,children:[a.jsxs(\"div\",{className:\"col-span-1 flex items-center gap-2\",children:[i&&a.jsx(\"button\",{onClick:()=>r(t.id),children:n?a.jsx(Ns,{className:\"w-4 h-4 text-bambu-green\"}):a.jsx(uo,{className:\"w-4 h-4 text-bambu-gray\"})}),t.thumbnail_path?a.jsx(\"img\",{src:ue.getArchiveThumbnail(t.id),alt:\"\",className:\"w-10 h-10 object-cover rounded\"}):a.jsx(\"div\",{className:\"w-10 h-10 bg-bambu-dark rounded flex items-center justify-center\",children:a.jsx(gm,{className:\"w-5 h-5 text-bambu-dark-tertiary\"})})]}),a.jsxs(\"div\",{className:\"col-span-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"p\",{className:\"text-white text-sm truncate\",children:t.print_name||t.filename}),(t.status===\"failed\"||t.status===\"aborted\")&&a.jsx(\"span\",{className:\"px-1.5 py-0.5 rounded text-[10px] leading-tight bg-status-error/80 text-white flex-shrink-0\",children:t.status===\"aborted\"?c(\"archives.card.cancelled\"):c(\"archives.card.failed\")}),t.duplicate_count>0&&K>0&&U&&a.jsxs(\"button\",{onClick:ve=>{ve.stopPropagation(),d?.(U)},className:\"px-1.5 py-0.5 rounded text-[10px] leading-tight bg-purple-500/80 hover:bg-purple-600/90 text-white flex-shrink-0 transition-colors flex items-center gap-1\",title:c(\"archives.viewOriginalPrint\",{id:U}),children:[a.jsx(qs,{className:\"w-3 h-3\"}),\"#\",K]}),t.duplicate_count>0&&K===0&&a.jsxs(\"span\",{className:\"px-1.5 py-0.5 rounded text-[10px] leading-tight bg-purple-500/80 text-white flex-shrink-0 flex items-center gap-1\",title:`${t.duplicate_count} reprint${t.duplicate_count===1?\"\":\"s\"}`,children:[a.jsx(OQ,{className:\"w-3 h-3\"}),\"+\",t.duplicate_count]}),t.timelapse_path&&a.jsx(\"span\",{title:c(\"archives.list.hasTimelapse\"),children:a.jsx(tp,{className:\"w-3.5 h-3.5 text-bambu-green flex-shrink-0\"})}),B&&B.length>0&&a.jsx(Do,{to:`/files?folder=${B[0].id}`,className:\"flex-shrink-0\",title:c(\"archives.card.openFolder\",{name:B[0].name}),onClick:ve=>ve.stopPropagation(),children:a.jsx(Qc,{className:\"w-3.5 h-3.5 text-yellow-400\"})})]}),(t.filament_type||t.sliced_for_model)&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 mt-0.5\",children:[t.sliced_for_model&&a.jsxs(\"span\",{className:\"text-xs text-bambu-gray flex items-center gap-1\",title:c(\"archives.card.slicedFor\",{model:t.sliced_for_model}),children:[a.jsx(Er,{className:\"w-2.5 h-2.5\"}),t.sliced_for_model]}),t.sliced_for_model&&t.filament_type&&a.jsx(\"span\",{className:\"text-bambu-gray/50\",children:\"·\"}),t.filament_type&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:t.filament_type}),t.filament_color&&a.jsx(\"div\",{className:\"flex items-center gap-0.5 flex-wrap\",children:t.filament_color.split(\",\").map((ve,Te)=>a.jsx(\"div\",{className:\"w-2.5 h-2.5 rounded-full border border-black/20\",style:{backgroundColor:ve},title:ve},Te))})]})]}),a.jsx(\"div\",{className:\"col-span-2 text-sm text-bambu-gray truncate\",children:e}),a.jsxs(\"div\",{className:\"col-span-2 text-sm text-bambu-gray\",children:[a.jsx(\"div\",{children:hg(t.created_at)}),t.created_by_username&&a.jsxs(\"div\",{className:\"flex items-center gap-1 text-xs opacity-75\",title:c(\"archives.card.uploadedBy\",{name:t.created_by_username}),children:[a.jsx(ym,{className:\"w-3 h-3\"}),t.created_by_username]})]}),a.jsx(\"div\",{className:\"col-span-1 text-sm text-bambu-gray\",children:Lo(t.file_size)}),a.jsxs(\"div\",{className:\"col-span-2 flex justify-end gap-1\",children:[zh(t)&&a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>C(!0),disabled:!f(\"archives\",\"reprint\",t.created_by_id),title:f(\"archives\",\"reprint\",t.created_by_id)?c(\"archives.card.reprint\"):c(\"archives.card.noPermissionReprint\"),className:\"text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10\",children:a.jsx(es,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>{const ve=t.print_name||t.filename||\"model\";Uh(t.id,ve,\"file\",l)},title:c(\"archives.card.openInBambuStudio\"),children:a.jsx(la,{className:\"w-4 h-4\"})}),(t.external_url||t.makerworld_url)&&a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>window.open(t.external_url||t.makerworld_url,\"_blank\"),title:t.external_url?c(\"archives.card.externalLink\"):\"MakerWorld\",children:a.jsx(ul,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>{ue.downloadArchive(t.id,`${t.print_name||t.filename}.3mf`).catch(ve=>{console.error(\"Archive download failed:\",ve)})},title:c(\"archives.card.download\"),children:a.jsx(ga,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>v(!0),disabled:!f(\"archives\",\"update\",t.created_by_id),title:f(\"archives\",\"update\",t.created_by_id)?c(\"archives.card.edit\"):c(\"archives.card.noPermissionEdit\"),children:a.jsx(ci,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>g(!0),disabled:!f(\"archives\",\"delete\",t.created_by_id),title:f(\"archives\",\"delete\",t.created_by_id)?c(\"archives.card.delete\"):c(\"archives.card.noPermissionDelete\"),children:a.jsx(en,{className:\"w-4 h-4 text-red-400\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:ve=>{const Te=ve.currentTarget.getBoundingClientRect();Y({x:Te.left,y:Te.bottom+4})},title:c(\"archives.card.moreOptions\"),children:a.jsx(Jh,{className:\"w-4 h-4\"})})]})]}),y&&a.jsx(iie,{archive:t,onClose:()=>v(!1)}),A&&a.jsx(_z,{archiveId:t.id,title:t.print_name||t.filename,fileType:ile(t.filename),onClose:()=>T(!1)}),_&&a.jsx(ic,{mode:\"reprint\",archiveId:t.id,archiveName:t.print_name||t.filename,onClose:()=>C(!1)}),b&&a.jsx(yn,{title:c(\"archives.modal.deleteArchive\"),message:c(\"archives.modal.deleteConfirm\",{name:t.print_name||t.filename}),confirmText:c(\"archives.modal.deleteButton\"),variant:\"danger\",onConfirm:()=>{he.mutate(),g(!1)},onCancel:()=>g(!1)}),re&&a.jsx(yn,{title:c(\"archives.modal.removeSource3mf\"),message:c(\"archives.modal.removeSource3mfConfirm\",{name:t.print_name||t.filename}),confirmText:c(\"archives.modal.removeButton\"),variant:\"danger\",onConfirm:()=>{X.mutate(),W(!1)},onCancel:()=>W(!1)}),ne&&a.jsx(yn,{title:c(\"archives.modal.removeF3d\"),message:c(\"archives.modal.removeF3dConfirm\",{name:t.print_name||t.filename}),confirmText:c(\"archives.modal.removeButton\"),variant:\"danger\",onConfirm:()=>{ee.mutate(),me(!1)},onCancel:()=>me(!1)}),be&&a.jsx(yn,{title:c(\"archives.modal.removeTimelapse\"),message:c(\"archives.modal.removeTimelapseConfirm\",{name:t.print_name||t.filename}),confirmText:c(\"archives.modal.removeButton\"),variant:\"danger\",onConfirm:()=>{de.mutate(),Ce(!1)},onCancel:()=>Ce(!1)}),q&&a.jsx(sie,{x:q.x,y:q.y,items:Se,onClose:()=>Y(null)}),F&&t.timelapse_path&&a.jsx(ale,{src:ue.getArchiveTimelapse(t.id),title:c(\"archives.modal.timelapse\",{name:t.print_name||t.filename}),downloadFilename:`${t.print_name||t.filename}_timelapse.mp4`,archiveId:t.id,onClose:()=>k(!1),onEdit:()=>{u.invalidateQueries({queryKey:[\"archives\"]}),k(!1)}}),D&&z.length>0&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-gray-700\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white\",children:c(\"archives.modal.selectTimelapse\")}),a.jsx(\"p\",{className:\"text-sm text-gray-400 mt-1\",children:c(\"archives.modal.selectTimelapseDesc\")})]}),a.jsx(\"button\",{onClick:()=>{H(!1),Q([])},className:\"text-gray-400 hover:text-white p-1\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsx(\"div\",{className:\"overflow-y-auto flex-1 p-2\",children:z.map(ve=>a.jsxs(\"button\",{onClick:()=>ge.mutate(ve.name),disabled:ge.isPending,className:\"w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors mb-1\",children:[a.jsx(\"div\",{className:\"font-medium text-white\",children:ve.name}),a.jsxs(\"div\",{className:\"text-sm text-gray-400 flex gap-3\",children:[a.jsx(\"span\",{children:Lo(ve.size)}),ve.mtime&&a.jsx(\"span\",{children:hg(ve.mtime)})]})]},ve.name))})]})}),L&&a.jsx(oie,{archiveId:t.id,archiveName:t.print_name||t.filename,onClose:()=>te(!1)}),ie&&t.photos&&a.jsx(lie,{archiveId:t.id,archiveName:t.print_name||t.filename,photos:t.photos,onClose:()=>J(!1),onDelete:async ve=>{try{await ue.deleteArchivePhoto(t.id,ve),u.invalidateQueries({queryKey:[\"archives\"]}),m(c(\"archives.toast.photoDeleted\"))}catch{m(c(\"archives.toast.failedDeletePhoto\"),\"error\")}}}),oe&&a.jsx(rle,{archiveId:t.id,archiveName:t.print_name||t.filename,onClose:()=>fe(!1)}),P&&a.jsx(ic,{mode:\"add-to-queue\",archiveId:t.id,archiveName:t.print_name||t.filename,onClose:()=>N(!1)}),a.jsx(\"input\",{ref:E,type:\"file\",accept:\".3mf\",className:\"hidden\",onChange:ve=>{const Te=ve.target.files?.[0];Te&&G.mutate(Te),ve.target.value=\"\"}}),a.jsx(\"input\",{ref:j,type:\"file\",accept:\".f3d\",className:\"hidden\",onChange:ve=>{const Te=ve.target.files?.[0];Te&&V.mutate(Te),ve.target.value=\"\"}}),a.jsx(\"input\",{ref:O,type:\"file\",accept:\".mp4,.avi,.mkv\",className:\"hidden\",onChange:ve=>{const Te=ve.target.files?.[0];Te&&I.mutate(Te),ve.target.value=\"\"}})]})}const FJe=[{id:\"all\",label:\"All Archives\",icon:a.jsx(Qc,{className:\"w-4 h-4\"})},{id:\"recent\",label:\"Last 24 Hours\",icon:a.jsx(Yn,{className:\"w-4 h-4\"})},{id:\"this-week\",label:\"This Week\",icon:a.jsx(oa,{className:\"w-4 h-4\"})},{id:\"this-month\",label:\"This Month\",icon:a.jsx(oa,{className:\"w-4 h-4\"})},{id:\"favorites\",label:\"Favorites\",icon:a.jsx(ug,{className:\"w-4 h-4\"})},{id:\"failed\",label:\"Failed Prints\",icon:a.jsx(ei,{className:\"w-4 h-4\"})},{id:\"duplicates\",label:\"Duplicates\",icon:a.jsx(qs,{className:\"w-4 h-4\"})}];function RJe(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),{hasPermission:r,hasAnyPermission:i}=kr(),s=w.useRef(null),[o,l]=w.useState(\"\"),[c,d]=w.useState(()=>{const qe=localStorage.getItem(\"archiveFilterPrinter\");return qe?Number(qe):null}),[u,m]=w.useState(()=>localStorage.getItem(\"archiveFilterMaterial\")),[p,f]=w.useState(()=>{const qe=localStorage.getItem(\"archiveFilterColors\");return qe?new Set(JSON.parse(qe)):new Set}),[y,v]=w.useState(()=>localStorage.getItem(\"archiveColorFilterMode\")||\"or\"),[b,g]=w.useState(()=>localStorage.getItem(\"archiveFilterFavorites\")===\"true\"),[_,C]=w.useState(()=>localStorage.getItem(\"archiveHideFailed\")===\"true\"),[P,N]=w.useState(()=>localStorage.getItem(\"archiveHideDuplicates\")===\"true\"),[A,T]=w.useState(()=>localStorage.getItem(\"archiveFilterTag\")),[F,k]=w.useState(()=>localStorage.getItem(\"archiveFilterFileType\")||\"all\"),[D,H]=w.useState(!1),[z,Q]=w.useState([]),[L,te]=w.useState(!1),[ie,J]=w.useState(new Set),[oe,fe]=w.useState(!1),[re,W]=w.useState(!1),[ne,me]=w.useState(!1),[be,Ce]=w.useState(!1),[q,Y]=w.useState(()=>localStorage.getItem(\"archiveViewMode\")||\"grid\"),[E,j]=w.useState(()=>localStorage.getItem(\"archiveSortBy\")||\"date-desc\"),[O,K]=w.useState(()=>localStorage.getItem(\"archiveCollection\")||\"all\"),[U,de]=w.useState(0),[I,G]=w.useState(()=>{try{const qe=localStorage.getItem(\"archivePageSize\");return qe?Number(qe):50}catch{return 50}}),[X,V]=w.useState(!1),[ee,se]=w.useState(!1),[ge,he]=w.useState(!1),[le,B]=w.useState(!1),[R,ae]=w.useState(null),[_e,Se]=w.useState(null),[ve,Te]=w.useState(()=>localStorage.getItem(\"logFilterUser\")||null),[ye,je]=w.useState(()=>localStorage.getItem(\"logFilterStatus\")),[Le,Me]=w.useState(()=>localStorage.getItem(\"logFilterDateFrom\")||\"\"),[Oe,Re]=w.useState(()=>localStorage.getItem(\"logFilterDateTo\")||\"\"),[$e,Ye]=w.useState(()=>{const qe=localStorage.getItem(\"logOffset\");return qe?Number(qe):0}),[tt,pe]=w.useState(!1),[Fe,we]=w.useState(()=>{const qe=localStorage.getItem(\"logPageSize\");return qe?Number(qe):25}),Ve=w.useCallback(qe=>{Se(qe),ae(qe)},[]);w.useEffect(()=>{if(R){const qe=setTimeout(()=>{const Pt=document.querySelector(`[data-archive-id=\"${R}\"]`);Pt?Pt.scrollIntoView({behavior:\"smooth\",block:\"center\"}):_e===R&&n(t(\"archives.originalPrintNotVisible\"),\"warning\"),_e===R&&Se(null)},100),St=setTimeout(()=>ae(null),5e3);return()=>{clearTimeout(qe),clearTimeout(St)}}},[R,_e,n,t]);const{data:Ae,isLoading:ce}=Xe({queryKey:[\"archives\",c],queryFn:()=>ue.getArchives(c||void 0)}),{data:xe}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:Be}=Xe({queryKey:[\"projects\"],queryFn:()=>ue.getProjects()}),{data:Qe}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),{data:ht}=Xe({queryKey:[\"users\"],queryFn:ue.getUsers,enabled:q===\"log\"}),{data:xt,isLoading:gt}=Xe({queryKey:[\"print-log\",c,ve,ye,Le,Oe,o,$e,Fe],queryFn:()=>ue.getPrintLog({search:o||void 0,printerId:c||void 0,username:ve||void 0,status:ye||void 0,dateFrom:Le||void 0,dateTo:Oe||void 0,limit:Fe,offset:$e}),enabled:q===\"log\"}),Ut=Qe?.time_format||\"system\",Wt=Qe?.preferred_slicer||\"bambu_studio\",Zt=Eo(Qe?.currency||\"USD\"),Kt=it({mutationFn:async qe=>(await Promise.all(qe.map(St=>ue.deleteArchive(St))),qe.length),onSuccess:qe=>{e.invalidateQueries({queryKey:[\"archives\"]}),J(new Set),n(`${qe} archive${qe!==1?\"s\":\"\"} deleted`)},onError:()=>{n(t(\"archives.toast.failedDeleteArchives\"),\"error\")}}),Xt=it({mutationFn:()=>ue.clearPrintLog(),onSuccess:qe=>{e.invalidateQueries({queryKey:[\"print-log\"]}),Ye(0),n(t(\"archives.log.cleared\",{count:qe.deleted}))},onError:()=>{n(t(\"archives.log.clearFailed\"),\"error\")}});w.useEffect(()=>{c!==null?localStorage.setItem(\"archiveFilterPrinter\",c.toString()):localStorage.removeItem(\"archiveFilterPrinter\")},[c]),w.useEffect(()=>{u?localStorage.setItem(\"archiveFilterMaterial\",u):localStorage.removeItem(\"archiveFilterMaterial\")},[u]),w.useEffect(()=>{localStorage.setItem(\"archiveFilterColors\",JSON.stringify([...p]))},[p]),w.useEffect(()=>{localStorage.setItem(\"archiveColorFilterMode\",y)},[y]),w.useEffect(()=>{localStorage.setItem(\"archiveFilterFavorites\",b.toString())},[b]),w.useEffect(()=>{localStorage.setItem(\"archiveHideFailed\",_.toString())},[_]),w.useEffect(()=>{localStorage.setItem(\"archiveHideDuplicates\",P.toString())},[P]),w.useEffect(()=>{A?localStorage.setItem(\"archiveFilterTag\",A):localStorage.removeItem(\"archiveFilterTag\")},[A]),w.useEffect(()=>{localStorage.setItem(\"archiveFilterFileType\",F)},[F]),w.useEffect(()=>{de(0)},[o,c,u,p,y,b,_,P,A,F,E,O]),w.useEffect(()=>{try{localStorage.setItem(\"archivePageSize\",String(I))}catch{}},[I]),w.useEffect(()=>{localStorage.setItem(\"archiveViewMode\",q)},[q]),w.useEffect(()=>{localStorage.setItem(\"archiveSortBy\",E)},[E]),w.useEffect(()=>{localStorage.setItem(\"archiveCollection\",O)},[O]),w.useEffect(()=>{ve?localStorage.setItem(\"logFilterUser\",ve):localStorage.removeItem(\"logFilterUser\")},[ve]),w.useEffect(()=>{ye?localStorage.setItem(\"logFilterStatus\",ye):localStorage.removeItem(\"logFilterStatus\")},[ye]),w.useEffect(()=>{Le?localStorage.setItem(\"logFilterDateFrom\",Le):localStorage.removeItem(\"logFilterDateFrom\")},[Le]),w.useEffect(()=>{Oe?localStorage.setItem(\"logFilterDateTo\",Oe):localStorage.removeItem(\"logFilterDateTo\")},[Oe]),w.useEffect(()=>{localStorage.setItem(\"logOffset\",$e.toString())},[$e]),w.useEffect(()=>{localStorage.setItem(\"logPageSize\",Fe.toString())},[Fe]);const ln=new Map(xe?.map(qe=>[qe.id,qe.name])||[]),vn=[...new Set(Ae?.flatMap(qe=>qe.filament_type?.split(\", \")||[]).filter(Boolean)||[])].sort(),Ke=[...new Set(Ae?.flatMap(qe=>qe.filament_color?.split(\",\")||[]).filter(Boolean)||[])],at=[...new Set(Ae?.flatMap(qe=>qe.tags?.split(\",\").map(St=>St.trim())||[]).filter(Boolean)||[])].sort(),Lt=Ae?.filter(qe=>{const St=new Date,Pt=Zn(qe.created_at)||new Date(0);let qt=!0;switch(O){case\"recent\":qt=St.getTime()-Pt.getTime()<1440*60*1e3;break;case\"this-week\":qt=St.getTime()-Pt.getTime()<10080*60*1e3;break;case\"this-month\":qt=Pt.getMonth()===St.getMonth()&&Pt.getFullYear()===St.getFullYear();break;case\"favorites\":qt=qe.is_favorite===!0;break;case\"failed\":qt=qe.status===\"failed\"||qe.status===\"aborted\";break;case\"duplicates\":qt=qe.duplicate_count>0;break}const on=(qe.print_name||qe.filename).toLowerCase().includes(o.toLowerCase()),dn=!u||qe.filament_type?.split(\", \").includes(u),Nn=qe.filament_color?.split(\",\")||[],bn=p.size===0||(y===\"or\"?Nn.some(In=>p.has(In)):[...p].every(In=>Nn.includes(In))),un=O===\"favorites\"||!b||qe.is_favorite,wn=O===\"failed\"||!_||qe.status!==\"failed\"&&qe.status!==\"aborted\",pn=O===\"duplicates\"||!P||qe.duplicate_count===0||qe.duplicate_sequence===0,gr=qe.tags?.split(\",\").map(In=>In.trim())||[],Nr=!A||gr.includes(A),Fn=zh(qe);return qt&&on&&dn&&bn&&un&&wn&&pn&&Nr&&(F===\"all\"||F===\"gcode\"&&Fn||F===\"source\"&&!Fn)}).sort((qe,St)=>{switch(E){case\"date-desc\":return(Zn(St.created_at)?.getTime()||0)-(Zn(qe.created_at)?.getTime()||0);case\"date-asc\":return(Zn(qe.created_at)?.getTime()||0)-(Zn(St.created_at)?.getTime()||0);case\"name-asc\":return(qe.print_name||qe.filename).localeCompare(St.print_name||St.filename);case\"name-desc\":return(St.print_name||St.filename).localeCompare(qe.print_name||qe.filename);case\"size-desc\":return St.file_size-qe.file_size;case\"size-asc\":return qe.file_size-St.file_size;default:return 0}}),Et=Lt?.length||0,At=I===-1,Ie=At?Et||1:I,mt=Math.max(1,Math.ceil(Et/Ie)),pt=At?Lt:Lt?.slice(U*Ie,(U+1)*Ie);w.useEffect(()=>{if(R&&Lt&&!At){const qe=Lt.findIndex(St=>St.id===R);if(qe>=0){const St=Math.floor(qe/Ie);St!==U&&de(St)}}},[R]);const nt=oe||ie.size>0,ze=qe=>{J(St=>{const Pt=new Set(St);return Pt.has(qe)?Pt.delete(qe):Pt.add(qe),Pt})},ot=()=>{Lt&&J(new Set(Lt.map(qe=>qe.id)))},Pe=()=>{J(new Set),fe(!1)},Ge=qe=>{f(St=>{const Pt=new Set(St);return Pt.has(qe)?Pt.delete(qe):Pt.add(qe),Pt})},Ze=()=>{f(new Set)},Je=()=>{l(\"\"),d(null),m(null),g(!1),C(!1),N(!1),T(null),k(\"all\")},We=o||c||u||b||_||P||A||F!==\"all\",Ue=w.useCallback(qe=>{qe.preventDefault(),qe.dataTransfer.types.includes(\"Files\")&&te(!0)},[]),et=w.useCallback(qe=>{qe.preventDefault(),qe.currentTarget===qe.target&&te(!1)},[]),jt=w.useCallback(qe=>{qe.preventDefault(),te(!1);const St=Array.from(qe.dataTransfer.files).filter(Pt=>Pt.name.endsWith(\".3mf\"));St.length>0?(Q(St),H(!0)):qe.dataTransfer.files.length>0&&n(t(\"archives.page.only3mfSupported\"),\"warning\")},[n,t]),yt=w.useCallback(qe=>{const St=qe.target;if(St.tagName===\"INPUT\"||St.tagName===\"TEXTAREA\"||St.isContentEditable){qe.key===\"Escape\"&&St.blur();return}switch(qe.key){case\"/\":qe.preventDefault(),s.current?.focus();break;case\"u\":case\"U\":!qe.metaKey&&!qe.ctrlKey&&(qe.preventDefault(),H(!0));break;case\"Escape\":nt&&Pe();break}},[nt]);return w.useEffect(()=>(document.addEventListener(\"keydown\",yt),()=>document.removeEventListener(\"keydown\",yt)),[yt]),a.jsxs(\"div\",{className:\"p-4 md:p-8 relative\",onDragOver:Ue,onDragLeave:et,onDrop:jt,children:[L&&a.jsx(\"div\",{className:\"fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none\",children:a.jsxs(\"div\",{className:\"border-4 border-dashed border-bambu-green rounded-xl p-12 text-center\",children:[a.jsx(La,{className:\"w-16 h-16 mx-auto mb-4 text-bambu-green\"}),a.jsx(\"p\",{className:\"text-2xl font-semibold text-white mb-2\",children:\"Drop .3mf files here\"}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"archives.releaseToUpload\")})]})}),nt&&a.jsxs(\"div\",{className:\"fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-4\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:Pe,children:[a.jsx(Ht,{className:\"w-4 h-4\"}),\"Close\"]}),a.jsx(\"div\",{className:\"w-px h-6 bg-bambu-dark-tertiary\"}),a.jsxs(\"span\",{className:\"text-white font-medium\",children:[ie.size,\" selected\"]}),a.jsx(\"div\",{className:\"w-px h-6 bg-bambu-dark-tertiary\"}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:ot,children:\"Select All\"}),a.jsx(\"div\",{className:\"w-px h-6 bg-bambu-dark-tertiary\"}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>me(!0),disabled:!i(\"archives:update_own\",\"archives:update_all\"),title:i(\"archives:update_own\",\"archives:update_all\")?void 0:t(\"archives.permission.noUpdateArchives\"),children:[a.jsx(xm,{className:\"w-4 h-4\"}),\"Tags\"]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>Ce(!0),disabled:!i(\"archives:update_own\",\"archives:update_all\"),title:i(\"archives:update_own\",\"archives:update_all\")?void 0:t(\"archives.permission.noUpdateArchives\"),children:[a.jsx(Bs,{className:\"w-4 h-4\"}),\"Project\"]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",disabled:!i(\"archives:update_own\",\"archives:update_all\"),title:i(\"archives:update_own\",\"archives:update_all\")?void 0:t(\"archives.permission.noUpdateArchives\"),onClick:()=>{const qe=Array.from(ie);Promise.all(qe.map(St=>ue.toggleFavorite(St))).then(()=>{e.invalidateQueries({queryKey:[\"archives\"]}),n(`Toggled favorites for ${qe.length} archive${qe.length!==1?\"s\":\"\"}`)}).catch(()=>{n(t(\"archives.toast.failedUpdateFavorites\"),\"error\")})},children:[a.jsx(ug,{className:\"w-4 h-4\"}),\"Favorite\"]}),a.jsxs(De,{size:\"sm\",className:\"bg-red-500 hover:bg-red-600\",onClick:()=>W(!0),disabled:!i(\"archives:delete_own\",\"archives:delete_all\"),title:i(\"archives:delete_own\",\"archives:delete_all\")?void 0:t(\"archives.permission.noDelete\"),children:[a.jsx(en,{className:\"w-4 h-4\"}),\"Delete\"]})]}),a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:\"Archives\"}),a.jsx(\"select\",{className:\"px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray-light text-sm focus:border-bambu-green focus:outline-none\",value:O,onChange:qe=>K(qe.target.value),children:FJe.map(qe=>a.jsx(\"option\",{value:qe.id,children:qe.label},qe.id))})]}),a.jsxs(\"p\",{className:\"text-bambu-gray\",children:[Lt?.length||0,\" of \",Ae?.length||0,\" prints\"]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 sm:gap-3 flex-wrap\",children:[a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(De,{variant:\"secondary\",onClick:()=>V(!X),disabled:ee,children:[ee?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(vN,{className:\"w-4 h-4\"}),\"Export\"]}),X&&a.jsxs(\"div\",{className:\"absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20\",children:[a.jsxs(\"button\",{className:\"w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-t-lg\",onClick:async()=>{V(!1),se(!0);try{const{blob:qe,filename:St}=await ue.exportArchives({format:\"csv\",printerId:c||void 0,status:O===\"failed\"?\"failed\":void 0,search:o||void 0}),Pt=URL.createObjectURL(qe),qt=document.createElement(\"a\");qt.href=Pt,qt.download=St,qt.click(),URL.revokeObjectURL(Pt),n(t(\"archives.toast.exportDownloaded\"))}catch{n(t(\"archives.toast.exportFailed\"),\"error\")}finally{se(!1)}},children:[a.jsx(Us,{className:\"w-4 h-4\"}),\"Export as CSV\"]}),a.jsxs(\"button\",{className:\"w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg\",onClick:async()=>{V(!1),se(!0);try{const{blob:qe,filename:St}=await ue.exportArchives({format:\"xlsx\",printerId:c||void 0,status:O===\"failed\"?\"failed\":void 0,search:o||void 0}),Pt=URL.createObjectURL(qe),qt=document.createElement(\"a\");qt.href=Pt,qt.download=St,qt.click(),URL.revokeObjectURL(Pt),n(t(\"archives.toast.exportDownloaded\"))}catch{n(t(\"archives.toast.exportFailed\"),\"error\")}finally{se(!1)}},children:[a.jsx(vN,{className:\"w-4 h-4\"}),\"Export as Excel\"]})]})]}),ie.size>=2&&ie.size<=5&&a.jsxs(De,{variant:\"secondary\",onClick:()=>he(!0),children:[a.jsx(Cx,{className:\"w-4 h-4\"}),\"Compare (\",ie.size,\")\"]}),!nt&&a.jsxs(De,{variant:\"secondary\",onClick:()=>fe(!0),children:[a.jsx(Ns,{className:\"w-4 h-4\"}),\"Select\"]}),a.jsxs(De,{onClick:()=>H(!0),disabled:!r(\"archives:create\"),title:r(\"archives:create\")?void 0:t(\"archives.permission.noCreate\"),children:[a.jsx(La,{className:\"w-4 h-4\"}),\"Upload 3MF\"]})]})]}),a.jsxs(\"div\",{className:\"flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0 w-fit mb-4\",children:[a.jsx(\"button\",{className:`p-2 ${q===\"grid\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,onClick:()=>Y(\"grid\"),title:t(\"archives.gridView\"),children:a.jsx(eS,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{className:`p-2 ${q===\"list\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,onClick:()=>Y(\"list\"),title:t(\"archives.listView\"),children:a.jsx(tS,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{className:`p-2 ${q===\"calendar\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,onClick:()=>Y(\"calendar\"),title:t(\"archives.calendarView\"),children:a.jsx(Hpe,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{className:`p-2 ${q===\"log\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,onClick:()=>Y(\"log\"),title:t(\"archives.logView\"),children:a.jsx(ffe,{className:\"w-4 h-4\"})})]}),q!==\"log\"&&a.jsx(Tt,{className:\"mb-6\",children:a.jsxs(Mt,{className:\"py-4\",children:[a.jsxs(\"div\",{className:\"flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap\",children:[a.jsxs(\"div\",{className:\"w-full md:flex-1 relative md:min-w-[200px]\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{ref:s,type:\"text\",placeholder:t(\"archives.searchPlaceholder\"),className:\"w-full pl-10 pr-4 py-3 md:py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:o,onChange:qe=>l(qe.target.value)})]}),a.jsxs(\"div\",{className:\"flex gap-2 md:gap-4 overflow-x-auto pb-1 md:pb-0 -mx-4 px-4 md:mx-0 md:px-0 md:flex-wrap scrollbar-hide\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0\",children:[a.jsx($P,{className:\"w-4 h-4 text-bambu-gray hidden md:block\"}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:c||\"\",onChange:qe=>d(qe.target.value?Number(qe.target.value):null),children:[a.jsx(\"option\",{value:\"\",children:\"All Printers\"}),xe?.map(qe=>a.jsx(\"option\",{value:qe.id,children:qe.name},qe.id))]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0\",children:[a.jsx(Ra,{className:\"w-4 h-4 text-bambu-gray hidden md:block\"}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:u||\"\",onChange:qe=>m(qe.target.value||null),children:[a.jsx(\"option\",{value:\"\",children:\"All Materials\"}),vn.map(qe=>a.jsx(\"option\",{value:qe,children:qe},qe))]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0\",children:[a.jsx(yN,{className:\"w-4 h-4 text-bambu-gray hidden md:block\"}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:F,onChange:qe=>k(qe.target.value),children:[a.jsx(\"option\",{value:\"all\",children:\"All Files\"}),a.jsx(\"option\",{value:\"gcode\",children:\"Sliced (GCODE)\"}),a.jsx(\"option\",{value:\"source\",children:\"Source Only\"})]})]}),a.jsxs(\"button\",{onClick:()=>g(!b),className:`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${b?\"bg-yellow-500/20 border-yellow-500 text-yellow-400\":\"bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,title:t(b?\"archives.showAll\":\"archives.showFavoritesOnly\"),children:[a.jsx(ug,{className:`w-4 h-4 ${b?\"fill-yellow-400\":\"\"}`}),a.jsx(\"span\",{className:\"text-sm hidden md:inline\",children:\"Favorites\"})]}),a.jsxs(\"button\",{onClick:()=>C(!_),className:`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${_?\"bg-red-500/20 border-red-500 text-red-400\":\"bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,title:t(_?\"archives.showFailedPrints\":\"archives.hideFailedPrints\"),children:[a.jsx(ei,{className:\"w-4 h-4 \"}),a.jsx(\"span\",{className:\"text-sm hidden md:inline\",children:\"Hide Failed\"})]}),a.jsxs(\"button\",{onClick:()=>N(!P),className:`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors flex-shrink-0 ${P?\"bg-purple-500/20 border-purple-500 text-purple-400\":\"bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,title:t(\"archives.hideDuplicates\"),children:[a.jsx(qs,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"text-sm hidden md:inline\",children:t(\"archives.hideDuplicates\")})]}),at.length>0&&a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0\",children:[a.jsx(xm,{className:\"w-4 h-4 text-bambu-gray hidden md:block\"}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:A||\"\",onChange:qe=>T(qe.target.value||null),children:[a.jsx(\"option\",{value:\"\",children:\"All Tags\"}),at.map(qe=>a.jsx(\"option\",{value:qe,children:qe},qe))]}),a.jsx(\"button\",{onClick:()=>B(!0),className:\"p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors\",title:t(\"archives.manageTags\"),children:a.jsx(sS,{className:\"w-4 h-4\"})})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0\",children:[a.jsx(TO,{className:\"w-4 h-4 text-bambu-gray hidden md:block\"}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:E,onChange:qe=>j(qe.target.value),children:[a.jsx(\"option\",{value:\"date-desc\",children:t(\"archives.sortNewest\")}),a.jsx(\"option\",{value:\"date-asc\",children:t(\"archives.sortOldest\")}),a.jsxs(\"option\",{value:\"name-asc\",children:[t(\"archives.sortName\"),\" A-Z\"]}),a.jsxs(\"option\",{value:\"name-desc\",children:[t(\"archives.sortName\"),\" Z-A\"]}),a.jsx(\"option\",{value:\"size-desc\",children:t(\"archives.sortLargest\")}),a.jsx(\"option\",{value:\"size-asc\",children:t(\"archives.sortSmallest\")})]})]})]}),We&&a.jsxs(De,{variant:\"ghost\",size:\"sm\",onClick:Je,className:\"text-bambu-gray hover:text-white\",children:[a.jsx(Ht,{className:\"w-4 h-4\"}),\"Reset\"]})]}),Ke.length>0&&a.jsxs(\"div\",{className:\"flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"Colors:\"}),p.size>1&&a.jsx(\"button\",{onClick:()=>v(qe=>qe===\"or\"?\"and\":\"or\"),className:`px-2 py-0.5 text-xs rounded transition-colors ${y===\"and\"?\"bg-bambu-green text-white\":\"bg-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,title:y===\"or\"?\"Match ANY selected color\":\"Match ALL selected colors\",children:y.toUpperCase()}),a.jsx(\"div\",{className:\"flex items-center gap-1.5 flex-wrap\",children:Ke.map(qe=>a.jsx(\"button\",{onClick:()=>Ge(qe),className:`w-6 h-6 rounded-full border-2 transition-all ${p.has(qe)?\"border-bambu-green scale-110\":\"border-white/20 hover:border-white/40\"}`,style:{backgroundColor:qe},title:qe},qe))}),p.size>0&&a.jsxs(\"button\",{onClick:Ze,className:\"text-xs text-bambu-gray hover:text-white flex items-center gap-1\",children:[a.jsx(Ht,{className:\"w-3 h-3\"}),\"Clear\"]})]})]})}),a.jsx(jJe,{}),ce?a.jsx(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:t(\"archives.loadingArchives\")}):Lt?.length===0?a.jsx(Tt,{children:a.jsxs(Mt,{className:\"text-center py-12\",children:[a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(o?\"archives.noArchivesSearch\":\"archives.noArchivesYet\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-2\",children:\"Archives are created automatically when prints complete\"})]})}):q===\"calendar\"?a.jsx(Tt,{className:\"p-6\",children:a.jsx(eGe,{archives:Lt||[],onArchiveClick:qe=>{l(\"\"),Y(\"grid\"),ae(qe.id)},highlightedArchiveId:R})}):q===\"grid\"?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6\",children:pt?.map(qe=>a.jsx(EJe,{archive:qe,printerName:qe.printer_id?ln.get(qe.printer_id)||\"Unknown\":qe.sliced_for_model?`Sliced for ${qe.sliced_for_model}`:\"No Printer\",isSelected:ie.has(qe.id),onSelect:ze,selectionMode:nt,projects:Be,isHighlighted:qe.id===R,timeFormat:Ut,preferredSlicer:Wt,currency:Zt,t,onNavigateToArchive:Ve},qe.id))}),a.jsx(JX,{pageIndex:U,pageSize:I,totalRows:Et,totalPages:mt,onPageChange:de,onPageSizeChange:qe=>{G(qe),de(0)},t})]}):q===\"list\"?a.jsxs(a.Fragment,{children:[a.jsx(Tt,{children:a.jsxs(\"div\",{className:\"divide-y divide-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-12 gap-4 px-4 py-3 text-xs text-bambu-gray font-medium\",children:[a.jsx(\"div\",{className:\"col-span-1\"}),a.jsx(\"div\",{className:\"col-span-4\",children:\"Name\"}),a.jsx(\"div\",{className:\"col-span-2\",children:\"Printer\"}),a.jsx(\"div\",{className:\"col-span-2\",children:\"Date\"}),a.jsx(\"div\",{className:\"col-span-1\",children:\"Size\"}),a.jsx(\"div\",{className:\"col-span-2 text-right\",children:\"Actions\"})]}),pt?.map(qe=>a.jsx(DJe,{archive:qe,printerName:qe.printer_id?ln.get(qe.printer_id)||\"Unknown\":qe.sliced_for_model?`Sliced for ${qe.sliced_for_model}`:\"No Printer\",isSelected:ie.has(qe.id),onSelect:ze,selectionMode:nt,projects:Be,isHighlighted:qe.id===R,preferredSlicer:Wt,t,onNavigateToArchive:Ve},qe.id))]})}),a.jsx(JX,{pageIndex:U,pageSize:I,totalRows:Et,totalPages:mt,onPageChange:de,onPageSizeChange:qe=>{G(qe),de(0)},t})]}):q===\"log\"?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-3\",children:a.jsxs(\"div\",{className:\"flex flex-col md:flex-row gap-3 md:items-center md:flex-wrap\",children:[a.jsxs(\"div\",{className:\"flex-1 relative md:min-w-[200px]\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",placeholder:t(\"archives.searchPlaceholder\"),className:\"w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\",value:o,onChange:qe=>{l(qe.target.value),Ye(0)}})]}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\",value:c||\"\",onChange:qe=>{d(qe.target.value?Number(qe.target.value):null),Ye(0)},children:[a.jsx(\"option\",{value:\"\",children:t(\"archives.log.allPrinters\")}),xe?.map(qe=>a.jsx(\"option\",{value:qe.id,children:qe.name},qe.id))]}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\",value:ve||\"\",onChange:qe=>{Te(qe.target.value||null),Ye(0)},children:[a.jsx(\"option\",{value:\"\",children:t(\"archives.log.allUsers\")}),ht?.map(qe=>a.jsx(\"option\",{value:qe.username,children:qe.username},qe.id))]}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\",value:ye||\"\",onChange:qe=>{je(qe.target.value||null),Ye(0)},children:[a.jsx(\"option\",{value:\"\",children:t(\"archives.log.allStatuses\")}),a.jsx(\"option\",{value:\"completed\",children:t(\"archives.status.completed\")}),a.jsx(\"option\",{value:\"failed\",children:t(\"archives.status.failed\")}),a.jsx(\"option\",{value:\"stopped\",children:t(\"archives.status.stopped\")}),a.jsx(\"option\",{value:\"cancelled\",children:t(\"archives.log.cancelled\")}),a.jsx(\"option\",{value:\"skipped\",children:t(\"archives.log.skipped\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"label\",{className:\"text-sm text-bambu-gray\",children:t(\"archives.log.dateFrom\")}),a.jsx(\"input\",{type:\"date\",className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\",value:Le,onChange:qe=>{Me(qe.target.value),Ye(0)}})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"label\",{className:\"text-sm text-bambu-gray\",children:t(\"archives.log.dateTo\")}),a.jsx(\"input\",{type:\"date\",className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm\",value:Oe,onChange:qe=>{Re(qe.target.value),Ye(0)}})]}),a.jsx(\"div\",{className:\"ml-auto\",children:a.jsxs(De,{variant:\"danger\",size:\"sm\",onClick:()=>pe(!0),disabled:!r(\"archives:delete_all\")||Xt.isPending,children:[a.jsx(en,{className:\"w-4 h-4\"}),t(\"archives.log.clearLog\")]})})]})})}),a.jsx(Tt,{children:gt?a.jsx(\"div\",{className:\"flex items-center justify-center py-12\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-green\"})}):xt?.items.length?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"overflow-x-auto\",children:a.jsxs(\"table\",{className:\"w-full text-sm\",children:[a.jsx(\"thead\",{children:a.jsxs(\"tr\",{className:\"border-b border-bambu-dark-tertiary text-bambu-gray text-left\",children:[a.jsx(\"th\",{className:\"px-4 py-3 font-medium\",children:t(\"archives.log.date\")}),a.jsx(\"th\",{className:\"px-4 py-3 font-medium\",children:t(\"archives.log.printName\")}),a.jsx(\"th\",{className:\"px-4 py-3 font-medium\",children:t(\"archives.log.printer\")}),a.jsx(\"th\",{className:\"px-4 py-3 font-medium\",children:t(\"archives.log.user\")}),a.jsx(\"th\",{className:\"px-4 py-3 font-medium\",children:t(\"archives.log.status\")}),a.jsx(\"th\",{className:\"px-4 py-3 font-medium\",children:t(\"archives.log.duration\")}),a.jsx(\"th\",{className:\"px-4 py-3 font-medium\",children:t(\"archives.log.filament\")})]})}),a.jsx(\"tbody\",{className:\"divide-y divide-bambu-dark-tertiary\",children:xt.items.map(qe=>a.jsxs(\"tr\",{className:\"hover:bg-bambu-dark-secondary/50\",children:[a.jsx(\"td\",{className:\"px-4 py-3 text-white whitespace-nowrap\",children:pg(qe.started_at||qe.created_at,Ut)}),a.jsx(\"td\",{className:\"px-4 py-3\",children:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[qe.thumbnail_path&&a.jsx(\"img\",{src:ue.getPrintLogThumbnail(qe.id),alt:\"\",className:\"w-8 h-8 rounded object-cover flex-shrink-0\",onError:St=>{St.target.style.display=\"none\"}}),a.jsx(\"span\",{className:\"text-white truncate max-w-[200px]\",children:qe.print_name||\"—\"})]})}),a.jsx(\"td\",{className:\"px-4 py-3 text-bambu-gray-light\",children:qe.printer_name||\"—\"}),a.jsx(\"td\",{className:\"px-4 py-3 text-bambu-gray-light\",children:qe.created_by_username||\"—\"}),a.jsx(\"td\",{className:\"px-4 py-3\",children:a.jsx(\"span\",{className:`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${qe.status===\"completed\"?\"bg-green-500/20 text-green-400\":qe.status===\"failed\"?\"bg-red-500/20 text-red-400\":qe.status===\"stopped\"?\"bg-yellow-500/20 text-yellow-400\":qe.status===\"cancelled\"?\"bg-orange-500/20 text-orange-400\":qe.status===\"skipped\"?\"bg-blue-500/20 text-blue-400\":\"bg-gray-500/20 text-gray-400\"}`,children:qe.status})}),a.jsx(\"td\",{className:\"px-4 py-3 text-bambu-gray-light whitespace-nowrap\",children:qe.duration_seconds?ws(qe.duration_seconds):\"—\"}),a.jsx(\"td\",{className:\"px-4 py-3\",children:a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[qe.filament_color&&a.jsx(\"span\",{className:\"w-3 h-3 rounded-full border border-black/20 flex-shrink-0\",style:{backgroundColor:qe.filament_color.startsWith(\"#\")?qe.filament_color:void 0}}),a.jsx(\"span\",{className:\"text-bambu-gray-light text-xs\",children:qe.filament_type||\"—\"})]})})]},qe.id))})]})}),a.jsxs(\"div\",{className:\"flex items-center justify-between px-4 py-3 border-t border-bambu-dark-tertiary flex-wrap gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t(\"archives.log.showing\",{count:Math.min($e+Fe,xt.total),total:xt.total})}),a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray\",children:t(\"archives.log.rowsPerPage\")}),a.jsxs(\"select\",{className:\"px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none\",value:Fe,onChange:qe=>{we(Number(qe.target.value)),Ye(0)},children:[a.jsx(\"option\",{value:10,children:\"10\"}),a.jsx(\"option\",{value:25,children:\"25\"}),a.jsx(\"option\",{value:50,children:\"50\"}),a.jsx(\"option\",{value:100,children:\"100\"})]})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[t(\"archives.log.page\"),\" \",Math.floor($e/Fe)+1,\" / \",Math.max(1,Math.ceil(xt.total/Fe))]}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>Ye(Math.max(0,$e-Fe)),disabled:$e===0,children:a.jsx(xl,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>Ye($e+Fe),disabled:$e+Fe>=xt.total,children:a.jsx(ti,{className:\"w-4 h-4\"})})]})]})]}):a.jsx(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:t(\"archives.log.noEntries\")})})]}):null,D&&a.jsx(V7e,{onClose:()=>{H(!1),Q([])},initialFiles:z}),re&&a.jsx(yn,{title:t(\"archives.modal.deleteArchives\"),message:t(\"archives.modal.deleteArchivesConfirm\",{count:ie.size}),confirmText:t(\"archives.modal.deleteCount\",{count:ie.size}),variant:\"danger\",onConfirm:()=>{Kt.mutate(Array.from(ie)),W(!1)},onCancel:()=>W(!1)}),ne&&a.jsx(K7e,{selectedIds:Array.from(ie),existingTags:at,onClose:()=>me(!1)}),be&&a.jsx(X7e,{selectedIds:Array.from(ie),onClose:()=>Ce(!1)}),ge&&ie.size>=2&&ie.size<=5&&a.jsx(CJe,{archiveIds:Array.from(ie),onClose:()=>{he(!1),J(new Set),fe(!1)}}),le&&a.jsx(MJe,{onClose:()=>B(!1)}),tt&&a.jsx(yn,{title:t(\"archives.log.clearLogTitle\"),message:t(\"archives.log.clearLogConfirm\"),confirmText:t(\"archives.log.clearLogButton\"),variant:\"danger\",onConfirm:()=>{Xt.mutate(),pe(!1)},onCancel:()=>pe(!1)})]})}function JX({pageIndex:t,pageSize:e,totalRows:n,totalPages:r,onPageChange:i,onPageSizeChange:s,t:o}){const l=e===-1;if(r<=1&&!l)return null;const c=l?n||1:e;return a.jsxs(\"div\",{className:\"flex items-center justify-between pt-2 text-sm\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:l?`${n} ${o(\"archives.prints\")}`:a.jsxs(a.Fragment,{children:[o(\"archives.pagination.showing\"),\" \",t*c+1,\" \",o(\"archives.pagination.to\"),\" \",Math.min((t+1)*c,n),\" \",o(\"archives.pagination.of\"),\" \",n,\" \",o(\"archives.prints\")]})}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:o(\"archives.pagination.show\")}),a.jsxs(\"select\",{value:e,onChange:d=>s(Number(d.target.value)),className:\"px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green\",children:[[25,50,100,200].map(d=>a.jsx(\"option\",{value:d,children:d},d)),a.jsx(\"option\",{value:-1,children:o(\"archives.pagination.all\")})]}),!l&&a.jsxs(a.Fragment,{children:[a.jsx(\"button\",{onClick:()=>i(0),disabled:t===0,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(jO,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>i(Math.max(0,t-1)),disabled:t===0,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(xl,{className:\"w-4 h-4\"})}),a.jsxs(\"span\",{className:\"text-bambu-gray px-2 whitespace-nowrap\",children:[o(\"archives.pagination.page\"),\" \",t+1,\" \",o(\"archives.pagination.of\"),\" \",r]}),a.jsx(\"button\",{onClick:()=>i(Math.min(r-1,t+1)),disabled:t>=r-1,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(ti,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>i(r-1),disabled:t>=r-1,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(MO,{className:\"w-4 h-4\"})})]})]})]})}function LJe(){for(var t=arguments.length,e=new Array(t),n=0;n<t;n++)e[n]=arguments[n];return w.useMemo(()=>r=>{e.forEach(i=>i(r))},e)}const fA=typeof window<\"u\"&&typeof window.document<\"u\"&&typeof window.document.createElement<\"u\";function Uy(t){const e=Object.prototype.toString.call(t);return e===\"[object Window]\"||e===\"[object global]\"}function d5(t){return\"nodeType\"in t}function Go(t){var e,n;return t?Uy(t)?t:d5(t)&&(e=(n=t.ownerDocument)==null?void 0:n.defaultView)!=null?e:window:window}function u5(t){const{Document:e}=Go(t);return t instanceof e}function BS(t){return Uy(t)?!1:t instanceof Go(t).HTMLElement}function sle(t){return t instanceof Go(t).SVGElement}function By(t){return t?Uy(t)?t.document:d5(t)?u5(t)?t:BS(t)||sle(t)?t.ownerDocument:document:document:document}const Xd=fA?w.useLayoutEffect:w.useEffect;function m5(t){const e=w.useRef(t);return Xd(()=>{e.current=t}),w.useCallback(function(){for(var n=arguments.length,r=new Array(n),i=0;i<n;i++)r[i]=arguments[i];return e.current==null?void 0:e.current(...r)},[])}function OJe(){const t=w.useRef(null),e=w.useCallback((r,i)=>{t.current=setInterval(r,i)},[]),n=w.useCallback(()=>{t.current!==null&&(clearInterval(t.current),t.current=null)},[]);return[e,n]}function Vw(t,e){e===void 0&&(e=[t]);const n=w.useRef(t);return Xd(()=>{n.current!==t&&(n.current=t)},e),n}function HS(t,e){const n=w.useRef();return w.useMemo(()=>{const r=t(n.current);return n.current=r,r},[...e])}function MP(t){const e=m5(t),n=w.useRef(null),r=w.useCallback(i=>{i!==n.current&&e?.(i,n.current),n.current=i},[]);return[n,r]}function nO(t){const e=w.useRef();return w.useEffect(()=>{e.current=t},[t]),e.current}let y3={};function qS(t,e){return w.useMemo(()=>{if(e)return e;const n=y3[t]==null?0:y3[t]+1;return y3[t]=n,t+\"-\"+n},[t,e])}function ole(t){return function(e){for(var n=arguments.length,r=new Array(n>1?n-1:0),i=1;i<n;i++)r[i-1]=arguments[i];return r.reduce((s,o)=>{const l=Object.entries(o);for(const[c,d]of l){const u=s[c];u!=null&&(s[c]=u+t*d)}return s},{...e})}}const Ux=ole(1),Gw=ole(-1);function IJe(t){return\"clientX\"in t&&\"clientY\"in t}function h5(t){if(!t)return!1;const{KeyboardEvent:e}=Go(t.target);return e&&t instanceof e}function zJe(t){if(!t)return!1;const{TouchEvent:e}=Go(t.target);return e&&t instanceof e}function rO(t){if(zJe(t)){if(t.touches&&t.touches.length){const{clientX:e,clientY:n}=t.touches[0];return{x:e,y:n}}else if(t.changedTouches&&t.changedTouches.length){const{clientX:e,clientY:n}=t.changedTouches[0];return{x:e,y:n}}}return IJe(t)?{x:t.clientX,y:t.clientY}:null}const fy=Object.freeze({Translate:{toString(t){if(!t)return;const{x:e,y:n}=t;return\"translate3d(\"+(e?Math.round(e):0)+\"px, \"+(n?Math.round(n):0)+\"px, 0)\"}},Scale:{toString(t){if(!t)return;const{scaleX:e,scaleY:n}=t;return\"scaleX(\"+e+\") scaleY(\"+n+\")\"}},Transform:{toString(t){if(t)return[fy.Translate.toString(t),fy.Scale.toString(t)].join(\" \")}},Transition:{toString(t){let{property:e,duration:n,easing:r}=t;return e+\" \"+n+\"ms \"+r}}}),eY=\"a,frame,iframe,input:not([type=hidden]):not(:disabled),select:not(:disabled),textarea:not(:disabled),button:not(:disabled),*[tabindex]\";function UJe(t){return t.matches(eY)?t:t.querySelector(eY)}const BJe={display:\"none\"};function HJe(t){let{id:e,value:n}=t;return ia.createElement(\"div\",{id:e,style:BJe},n)}function qJe(t){let{id:e,announcement:n,ariaLiveType:r=\"assertive\"}=t;const i={position:\"fixed\",top:0,left:0,width:1,height:1,margin:-1,border:0,padding:0,overflow:\"hidden\",clip:\"rect(0 0 0 0)\",clipPath:\"inset(100%)\",whiteSpace:\"nowrap\"};return ia.createElement(\"div\",{id:e,style:i,role:\"status\",\"aria-live\":r,\"aria-atomic\":!0},n)}function $Je(){const[t,e]=w.useState(\"\");return{announce:w.useCallback(r=>{r!=null&&e(r)},[]),announcement:t}}const lle=w.createContext(null);function VJe(t){const e=w.useContext(lle);w.useEffect(()=>{if(!e)throw new Error(\"useDndMonitor must be used within a children of <DndContext>\");return e(t)},[t,e])}function GJe(){const[t]=w.useState(()=>new Set),e=w.useCallback(r=>(t.add(r),()=>t.delete(r)),[t]);return[w.useCallback(r=>{let{type:i,event:s}=r;t.forEach(o=>{var l;return(l=o[i])==null?void 0:l.call(o,s)})},[t]),e]}const WJe={draggable:`\n    To pick up a draggable item, press the space bar.\n    While dragging, use the arrow keys to move the item.\n    Press space again to drop the item in its new position, or press escape to cancel.\n  `},KJe={onDragStart(t){let{active:e}=t;return\"Picked up draggable item \"+e.id+\".\"},onDragOver(t){let{active:e,over:n}=t;return n?\"Draggable item \"+e.id+\" was moved over droppable area \"+n.id+\".\":\"Draggable item \"+e.id+\" is no longer over a droppable area.\"},onDragEnd(t){let{active:e,over:n}=t;return n?\"Draggable item \"+e.id+\" was dropped over droppable area \"+n.id:\"Draggable item \"+e.id+\" was dropped.\"},onDragCancel(t){let{active:e}=t;return\"Dragging was cancelled. Draggable item \"+e.id+\" was dropped.\"}};function XJe(t){let{announcements:e=KJe,container:n,hiddenTextDescribedById:r,screenReaderInstructions:i=WJe}=t;const{announce:s,announcement:o}=$Je(),l=qS(\"DndLiveRegion\"),[c,d]=w.useState(!1);if(w.useEffect(()=>{d(!0)},[]),VJe(w.useMemo(()=>({onDragStart(m){let{active:p}=m;s(e.onDragStart({active:p}))},onDragMove(m){let{active:p,over:f}=m;e.onDragMove&&s(e.onDragMove({active:p,over:f}))},onDragOver(m){let{active:p,over:f}=m;s(e.onDragOver({active:p,over:f}))},onDragEnd(m){let{active:p,over:f}=m;s(e.onDragEnd({active:p,over:f}))},onDragCancel(m){let{active:p,over:f}=m;s(e.onDragCancel({active:p,over:f}))}}),[s,e])),!c)return null;const u=ia.createElement(ia.Fragment,null,ia.createElement(HJe,{id:r,value:i.draggable}),ia.createElement(qJe,{id:l,announcement:o}));return n?Yu.createPortal(u,n):u}var ji;(function(t){t.DragStart=\"dragStart\",t.DragMove=\"dragMove\",t.DragEnd=\"dragEnd\",t.DragCancel=\"dragCancel\",t.DragOver=\"dragOver\",t.RegisterDroppable=\"registerDroppable\",t.SetDroppableDisabled=\"setDroppableDisabled\",t.UnregisterDroppable=\"unregisterDroppable\"})(ji||(ji={}));function EP(){}function DP(t,e){return w.useMemo(()=>({sensor:t,options:e??{}}),[t,e])}function cle(){for(var t=arguments.length,e=new Array(t),n=0;n<t;n++)e[n]=arguments[n];return w.useMemo(()=>[...e].filter(r=>r!=null),[...e])}const ed=Object.freeze({x:0,y:0});function dle(t,e){return Math.sqrt(Math.pow(t.x-e.x,2)+Math.pow(t.y-e.y,2))}function ule(t,e){let{data:{value:n}}=t,{data:{value:r}}=e;return n-r}function YJe(t,e){let{data:{value:n}}=t,{data:{value:r}}=e;return r-n}function tY(t){let{left:e,top:n,height:r,width:i}=t;return[{x:e,y:n},{x:e+i,y:n},{x:e,y:n+r},{x:e+i,y:n+r}]}function mle(t,e){if(!t||t.length===0)return null;const[n]=t;return n[e]}function nY(t,e,n){return e===void 0&&(e=t.left),n===void 0&&(n=t.top),{x:e+t.width*.5,y:n+t.height*.5}}const hle=t=>{let{collisionRect:e,droppableRects:n,droppableContainers:r}=t;const i=nY(e,e.left,e.top),s=[];for(const o of r){const{id:l}=o,c=n.get(l);if(c){const d=dle(nY(c),i);s.push({id:l,data:{droppableContainer:o,value:d}})}}return s.sort(ule)},QJe=t=>{let{collisionRect:e,droppableRects:n,droppableContainers:r}=t;const i=tY(e),s=[];for(const o of r){const{id:l}=o,c=n.get(l);if(c){const d=tY(c),u=i.reduce((p,f,y)=>p+dle(d[y],f),0),m=Number((u/4).toFixed(4));s.push({id:l,data:{droppableContainer:o,value:m}})}}return s.sort(ule)};function ZJe(t,e){const n=Math.max(e.top,t.top),r=Math.max(e.left,t.left),i=Math.min(e.left+e.width,t.left+t.width),s=Math.min(e.top+e.height,t.top+t.height),o=i-r,l=s-n;if(r<i&&n<s){const c=e.width*e.height,d=t.width*t.height,u=o*l,m=u/(c+d-u);return Number(m.toFixed(4))}return 0}const JJe=t=>{let{collisionRect:e,droppableRects:n,droppableContainers:r}=t;const i=[];for(const s of r){const{id:o}=s,l=n.get(o);if(l){const c=ZJe(l,e);c>0&&i.push({id:o,data:{droppableContainer:s,value:c}})}}return i.sort(YJe)};function eet(t,e,n){return{...t,scaleX:e&&n?e.width/n.width:1,scaleY:e&&n?e.height/n.height:1}}function ple(t,e){return t&&e?{x:t.left-e.left,y:t.top-e.top}:ed}function tet(t){return function(n){for(var r=arguments.length,i=new Array(r>1?r-1:0),s=1;s<r;s++)i[s-1]=arguments[s];return i.reduce((o,l)=>({...o,top:o.top+t*l.y,bottom:o.bottom+t*l.y,left:o.left+t*l.x,right:o.right+t*l.x}),{...n})}}const net=tet(1);function ret(t){if(t.startsWith(\"matrix3d(\")){const e=t.slice(9,-1).split(/, /);return{x:+e[12],y:+e[13],scaleX:+e[0],scaleY:+e[5]}}else if(t.startsWith(\"matrix(\")){const e=t.slice(7,-1).split(/, /);return{x:+e[4],y:+e[5],scaleX:+e[0],scaleY:+e[3]}}return null}function aet(t,e,n){const r=ret(e);if(!r)return t;const{scaleX:i,scaleY:s,x:o,y:l}=r,c=t.left-o-(1-i)*parseFloat(n),d=t.top-l-(1-s)*parseFloat(n.slice(n.indexOf(\" \")+1)),u=i?t.width/i:t.width,m=s?t.height/s:t.height;return{width:u,height:m,top:d,right:c+u,bottom:d+m,left:c}}const iet={ignoreTransform:!1};function Hy(t,e){e===void 0&&(e=iet);let n=t.getBoundingClientRect();if(e.ignoreTransform){const{transform:d,transformOrigin:u}=Go(t).getComputedStyle(t);d&&(n=aet(n,d,u))}const{top:r,left:i,width:s,height:o,bottom:l,right:c}=n;return{top:r,left:i,width:s,height:o,bottom:l,right:c}}function rY(t){return Hy(t,{ignoreTransform:!0})}function set(t){const e=t.innerWidth,n=t.innerHeight;return{top:0,left:0,right:e,bottom:n,width:e,height:n}}function oet(t,e){return e===void 0&&(e=Go(t).getComputedStyle(t)),e.position===\"fixed\"}function cet(t,e){e===void 0&&(e=Go(t).getComputedStyle(t));const n=/(auto|scroll|overlay)/;return[\"overflow\",\"overflowX\",\"overflowY\"].some(i=>{const s=e[i];return typeof s==\"string\"?n.test(s):!1})}function gA(t,e){const n=[];function r(i){if(e!=null&&n.length>=e||!i)return n;if(u5(i)&&i.scrollingElement!=null&&!n.includes(i.scrollingElement))return n.push(i.scrollingElement),n;if(!BS(i)||sle(i)||n.includes(i))return n;const s=Go(t).getComputedStyle(i);return i!==t&&cet(i,s)&&n.push(i),oet(i,s)?n:r(i.parentNode)}return t?r(t):n}function fle(t){const[e]=gA(t,1);return e??null}function v3(t){return!fA||!t?null:Uy(t)?t:d5(t)?u5(t)||t===By(t).scrollingElement?window:BS(t)?t:null:null}function gle(t){return Uy(t)?t.scrollX:t.scrollLeft}function ble(t){return Uy(t)?t.scrollY:t.scrollTop}function aO(t){return{x:gle(t),y:ble(t)}}var Xi;(function(t){t[t.Forward=1]=\"Forward\",t[t.Backward=-1]=\"Backward\"})(Xi||(Xi={}));function xle(t){return!fA||!t?!1:t===document.scrollingElement}function yle(t){const e={x:0,y:0},n=xle(t)?{height:window.innerHeight,width:window.innerWidth}:{height:t.clientHeight,width:t.clientWidth},r={x:t.scrollWidth-n.width,y:t.scrollHeight-n.height},i=t.scrollTop<=e.y,s=t.scrollLeft<=e.x,o=t.scrollTop>=r.y,l=t.scrollLeft>=r.x;return{isTop:i,isLeft:s,isBottom:o,isRight:l,maxScroll:r,minScroll:e}}const det={x:.2,y:.2};function uet(t,e,n,r,i){let{top:s,left:o,right:l,bottom:c}=n;r===void 0&&(r=10),i===void 0&&(i=det);const{isTop:d,isBottom:u,isLeft:m,isRight:p}=yle(t),f={x:0,y:0},y={x:0,y:0},v={height:e.height*i.y,width:e.width*i.x};return!d&&s<=e.top+v.height?(f.y=Xi.Backward,y.y=r*Math.abs((e.top+v.height-s)/v.height)):!u&&c>=e.bottom-v.height&&(f.y=Xi.Forward,y.y=r*Math.abs((e.bottom-v.height-c)/v.height)),!p&&l>=e.right-v.width?(f.x=Xi.Forward,y.x=r*Math.abs((e.right-v.width-l)/v.width)):!m&&o<=e.left+v.width&&(f.x=Xi.Backward,y.x=r*Math.abs((e.left+v.width-o)/v.width)),{direction:f,speed:y}}function met(t){if(t===document.scrollingElement){const{innerWidth:s,innerHeight:o}=window;return{top:0,left:0,right:s,bottom:o,width:s,height:o}}const{top:e,left:n,right:r,bottom:i}=t.getBoundingClientRect();return{top:e,left:n,right:r,bottom:i,width:t.clientWidth,height:t.clientHeight}}function vle(t){return t.reduce((e,n)=>Ux(e,aO(n)),ed)}function het(t){return t.reduce((e,n)=>e+gle(n),0)}function pet(t){return t.reduce((e,n)=>e+ble(n),0)}function fet(t,e){if(e===void 0&&(e=Hy),!t)return;const{top:n,left:r,bottom:i,right:s}=e(t);fle(t)&&(i<=0||s<=0||n>=window.innerHeight||r>=window.innerWidth)&&t.scrollIntoView({block:\"center\",inline:\"center\"})}const get=[[\"x\",[\"left\",\"right\"],het],[\"y\",[\"top\",\"bottom\"],pet]];class p5{constructor(e,n){this.rect=void 0,this.width=void 0,this.height=void 0,this.top=void 0,this.bottom=void 0,this.right=void 0,this.left=void 0;const r=gA(n),i=vle(r);this.rect={...e},this.width=e.width,this.height=e.height;for(const[s,o,l]of get)for(const c of o)Object.defineProperty(this,c,{get:()=>{const d=l(r),u=i[s]-d;return this.rect[c]+u},enumerable:!0});Object.defineProperty(this,\"rect\",{enumerable:!1})}}class X0{constructor(e){this.target=void 0,this.listeners=[],this.removeAll=()=>{this.listeners.forEach(n=>{var r;return(r=this.target)==null?void 0:r.removeEventListener(...n)})},this.target=e}add(e,n,r){var i;(i=this.target)==null||i.addEventListener(e,n,r),this.listeners.push([e,n,r])}}function bet(t){const{EventTarget:e}=Go(t);return t instanceof e?t:By(t)}function w3(t,e){const n=Math.abs(t.x),r=Math.abs(t.y);return typeof e==\"number\"?Math.sqrt(n**2+r**2)>e:\"x\"in e&&\"y\"in e?n>e.x&&r>e.y:\"x\"in e?n>e.x:\"y\"in e?r>e.y:!1}var Wl;(function(t){t.Click=\"click\",t.DragStart=\"dragstart\",t.Keydown=\"keydown\",t.ContextMenu=\"contextmenu\",t.Resize=\"resize\",t.SelectionChange=\"selectionchange\",t.VisibilityChange=\"visibilitychange\"})(Wl||(Wl={}));function aY(t){t.preventDefault()}function xet(t){t.stopPropagation()}var wr;(function(t){t.Space=\"Space\",t.Down=\"ArrowDown\",t.Right=\"ArrowRight\",t.Left=\"ArrowLeft\",t.Up=\"ArrowUp\",t.Esc=\"Escape\",t.Enter=\"Enter\",t.Tab=\"Tab\"})(wr||(wr={}));const wle={start:[wr.Space,wr.Enter],cancel:[wr.Esc],end:[wr.Space,wr.Enter,wr.Tab]},yet=(t,e)=>{let{currentCoordinates:n}=e;switch(t.code){case wr.Right:return{...n,x:n.x+25};case wr.Left:return{...n,x:n.x-25};case wr.Down:return{...n,y:n.y+25};case wr.Up:return{...n,y:n.y-25}}};class bA{constructor(e){this.props=void 0,this.autoScrollEnabled=!1,this.referenceCoordinates=void 0,this.listeners=void 0,this.windowListeners=void 0,this.props=e;const{event:{target:n}}=e;this.props=e,this.listeners=new X0(By(n)),this.windowListeners=new X0(Go(n)),this.handleKeyDown=this.handleKeyDown.bind(this),this.handleCancel=this.handleCancel.bind(this),this.attach()}attach(){this.handleStart(),this.windowListeners.add(Wl.Resize,this.handleCancel),this.windowListeners.add(Wl.VisibilityChange,this.handleCancel),setTimeout(()=>this.listeners.add(Wl.Keydown,this.handleKeyDown))}handleStart(){const{activeNode:e,onStart:n}=this.props,r=e.node.current;r&&fet(r),n(ed)}handleKeyDown(e){if(h5(e)){const{active:n,context:r,options:i}=this.props,{keyboardCodes:s=wle,coordinateGetter:o=yet,scrollBehavior:l=\"smooth\"}=i,{code:c}=e;if(s.end.includes(c)){this.handleEnd(e);return}if(s.cancel.includes(c)){this.handleCancel(e);return}const{collisionRect:d}=r.current,u=d?{x:d.left,y:d.top}:ed;this.referenceCoordinates||(this.referenceCoordinates=u);const m=o(e,{active:n,context:r.current,currentCoordinates:u});if(m){const p=Gw(m,u),f={x:0,y:0},{scrollableAncestors:y}=r.current;for(const v of y){const b=e.code,{isTop:g,isRight:_,isLeft:C,isBottom:P,maxScroll:N,minScroll:A}=yle(v),T=met(v),F={x:Math.min(b===wr.Right?T.right-T.width/2:T.right,Math.max(b===wr.Right?T.left:T.left+T.width/2,m.x)),y:Math.min(b===wr.Down?T.bottom-T.height/2:T.bottom,Math.max(b===wr.Down?T.top:T.top+T.height/2,m.y))},k=b===wr.Right&&!_||b===wr.Left&&!C,D=b===wr.Down&&!P||b===wr.Up&&!g;if(k&&F.x!==m.x){const H=v.scrollLeft+p.x,z=b===wr.Right&&H<=N.x||b===wr.Left&&H>=A.x;if(z&&!p.y){v.scrollTo({left:H,behavior:l});return}z?f.x=v.scrollLeft-H:f.x=b===wr.Right?v.scrollLeft-N.x:v.scrollLeft-A.x,f.x&&v.scrollBy({left:-f.x,behavior:l});break}else if(D&&F.y!==m.y){const H=v.scrollTop+p.y,z=b===wr.Down&&H<=N.y||b===wr.Up&&H>=A.y;if(z&&!p.x){v.scrollTo({top:H,behavior:l});return}z?f.y=v.scrollTop-H:f.y=b===wr.Down?v.scrollTop-N.y:v.scrollTop-A.y,f.y&&v.scrollBy({top:-f.y,behavior:l});break}}this.handleMove(e,Ux(Gw(m,this.referenceCoordinates),f))}}}handleMove(e,n){const{onMove:r}=this.props;e.preventDefault(),r(n)}handleEnd(e){const{onEnd:n}=this.props;e.preventDefault(),this.detach(),n()}handleCancel(e){const{onCancel:n}=this.props;e.preventDefault(),this.detach(),n()}detach(){this.listeners.removeAll(),this.windowListeners.removeAll()}}bA.activators=[{eventName:\"onKeyDown\",handler:(t,e,n)=>{let{keyboardCodes:r=wle,onActivation:i}=e,{active:s}=n;const{code:o}=t.nativeEvent;if(r.start.includes(o)){const l=s.activatorNode.current;return l&&t.target!==l?!1:(t.preventDefault(),i?.({event:t.nativeEvent}),!0)}return!1}}];function iY(t){return!!(t&&\"distance\"in t)}function sY(t){return!!(t&&\"delay\"in t)}class f5{constructor(e,n,r){var i;r===void 0&&(r=bet(e.event.target)),this.props=void 0,this.events=void 0,this.autoScrollEnabled=!0,this.document=void 0,this.activated=!1,this.initialCoordinates=void 0,this.timeoutId=null,this.listeners=void 0,this.documentListeners=void 0,this.windowListeners=void 0,this.props=e,this.events=n;const{event:s}=e,{target:o}=s;this.props=e,this.events=n,this.document=By(o),this.documentListeners=new X0(this.document),this.listeners=new X0(r),this.windowListeners=new X0(Go(o)),this.initialCoordinates=(i=rO(s))!=null?i:ed,this.handleStart=this.handleStart.bind(this),this.handleMove=this.handleMove.bind(this),this.handleEnd=this.handleEnd.bind(this),this.handleCancel=this.handleCancel.bind(this),this.handleKeydown=this.handleKeydown.bind(this),this.removeTextSelection=this.removeTextSelection.bind(this),this.attach()}attach(){const{events:e,props:{options:{activationConstraint:n,bypassActivationConstraint:r}}}=this;if(this.listeners.add(e.move.name,this.handleMove,{passive:!1}),this.listeners.add(e.end.name,this.handleEnd),e.cancel&&this.listeners.add(e.cancel.name,this.handleCancel),this.windowListeners.add(Wl.Resize,this.handleCancel),this.windowListeners.add(Wl.DragStart,aY),this.windowListeners.add(Wl.VisibilityChange,this.handleCancel),this.windowListeners.add(Wl.ContextMenu,aY),this.documentListeners.add(Wl.Keydown,this.handleKeydown),n){if(r!=null&&r({event:this.props.event,activeNode:this.props.activeNode,options:this.props.options}))return this.handleStart();if(sY(n)){this.timeoutId=setTimeout(this.handleStart,n.delay),this.handlePending(n);return}if(iY(n)){this.handlePending(n);return}}this.handleStart()}detach(){this.listeners.removeAll(),this.windowListeners.removeAll(),setTimeout(this.documentListeners.removeAll,50),this.timeoutId!==null&&(clearTimeout(this.timeoutId),this.timeoutId=null)}handlePending(e,n){const{active:r,onPending:i}=this.props;i(r,e,this.initialCoordinates,n)}handleStart(){const{initialCoordinates:e}=this,{onStart:n}=this.props;e&&(this.activated=!0,this.documentListeners.add(Wl.Click,xet,{capture:!0}),this.removeTextSelection(),this.documentListeners.add(Wl.SelectionChange,this.removeTextSelection),n(e))}handleMove(e){var n;const{activated:r,initialCoordinates:i,props:s}=this,{onMove:o,options:{activationConstraint:l}}=s;if(!i)return;const c=(n=rO(e))!=null?n:ed,d=Gw(i,c);if(!r&&l){if(iY(l)){if(l.tolerance!=null&&w3(d,l.tolerance))return this.handleCancel();if(w3(d,l.distance))return this.handleStart()}if(sY(l)&&w3(d,l.tolerance))return this.handleCancel();this.handlePending(l,d);return}e.cancelable&&e.preventDefault(),o(c)}handleEnd(){const{onAbort:e,onEnd:n}=this.props;this.detach(),this.activated||e(this.props.active),n()}handleCancel(){const{onAbort:e,onCancel:n}=this.props;this.detach(),this.activated||e(this.props.active),n()}handleKeydown(e){e.code===wr.Esc&&this.handleCancel()}removeTextSelection(){var e;(e=this.document.getSelection())==null||e.removeAllRanges()}}const vet={cancel:{name:\"pointercancel\"},move:{name:\"pointermove\"},end:{name:\"pointerup\"}};class xA extends f5{constructor(e){const{event:n}=e,r=By(n.target);super(e,vet,r)}}xA.activators=[{eventName:\"onPointerDown\",handler:(t,e)=>{let{nativeEvent:n}=t,{onActivation:r}=e;return!n.isPrimary||n.button!==0?!1:(r?.({event:n}),!0)}}];const wet={move:{name:\"mousemove\"},end:{name:\"mouseup\"}};var iO;(function(t){t[t.RightClick=2]=\"RightClick\"})(iO||(iO={}));class _et extends f5{constructor(e){super(e,wet,By(e.event.target))}}_et.activators=[{eventName:\"onMouseDown\",handler:(t,e)=>{let{nativeEvent:n}=t,{onActivation:r}=e;return n.button===iO.RightClick?!1:(r?.({event:n}),!0)}}];const S3={cancel:{name:\"touchcancel\"},move:{name:\"touchmove\"},end:{name:\"touchend\"}};class ket extends f5{constructor(e){super(e,S3)}static setup(){return window.addEventListener(S3.move.name,e,{capture:!1,passive:!1}),function(){window.removeEventListener(S3.move.name,e)};function e(){}}}ket.activators=[{eventName:\"onTouchStart\",handler:(t,e)=>{let{nativeEvent:n}=t,{onActivation:r}=e;const{touches:i}=n;return i.length>1?!1:(r?.({event:n}),!0)}}];var Y0;(function(t){t[t.Pointer=0]=\"Pointer\",t[t.DraggableRect=1]=\"DraggableRect\"})(Y0||(Y0={}));var FP;(function(t){t[t.TreeOrder=0]=\"TreeOrder\",t[t.ReversedTreeOrder=1]=\"ReversedTreeOrder\"})(FP||(FP={}));function Net(t){let{acceleration:e,activator:n=Y0.Pointer,canScroll:r,draggingRect:i,enabled:s,interval:o=5,order:l=FP.TreeOrder,pointerCoordinates:c,scrollableAncestors:d,scrollableAncestorRects:u,delta:m,threshold:p}=t;const f=Pet({delta:m,disabled:!s}),[y,v]=OJe(),b=w.useRef({x:0,y:0}),g=w.useRef({x:0,y:0}),_=w.useMemo(()=>{switch(n){case Y0.Pointer:return c?{top:c.y,bottom:c.y,left:c.x,right:c.x}:null;case Y0.DraggableRect:return i}},[n,i,c]),C=w.useRef(null),P=w.useCallback(()=>{const A=C.current;if(!A)return;const T=b.current.x*g.current.x,F=b.current.y*g.current.y;A.scrollBy(T,F)},[]),N=w.useMemo(()=>l===FP.TreeOrder?[...d].reverse():d,[l,d]);w.useEffect(()=>{if(!s||!d.length||!_){v();return}for(const A of N){if(r?.(A)===!1)continue;const T=d.indexOf(A),F=u[T];if(!F)continue;const{direction:k,speed:D}=uet(A,F,_,e,p);for(const H of[\"x\",\"y\"])f[H][k[H]]||(D[H]=0,k[H]=0);if(D.x>0||D.y>0){v(),C.current=A,y(P,o),b.current=D,g.current=k;return}}b.current={x:0,y:0},g.current={x:0,y:0},v()},[e,P,r,v,s,o,JSON.stringify(_),JSON.stringify(f),y,d,N,u,JSON.stringify(p)])}const Cet={x:{[Xi.Backward]:!1,[Xi.Forward]:!1},y:{[Xi.Backward]:!1,[Xi.Forward]:!1}};function Pet(t){let{delta:e,disabled:n}=t;const r=nO(e);return HS(i=>{if(n||!r||!i)return Cet;const s={x:Math.sign(e.x-r.x),y:Math.sign(e.y-r.y)};return{x:{[Xi.Backward]:i.x[Xi.Backward]||s.x===-1,[Xi.Forward]:i.x[Xi.Forward]||s.x===1},y:{[Xi.Backward]:i.y[Xi.Backward]||s.y===-1,[Xi.Forward]:i.y[Xi.Forward]||s.y===1}}},[n,e,r])}function Tet(t,e){const n=e!=null?t.get(e):void 0,r=n?n.node.current:null;return HS(i=>{var s;return e==null?null:(s=r??i)!=null?s:null},[r,e])}function Aet(t,e){return w.useMemo(()=>t.reduce((n,r)=>{const{sensor:i}=r,s=i.activators.map(o=>({eventName:o.eventName,handler:e(o.handler,r)}));return[...n,...s]},[]),[t,e])}var Ww;(function(t){t[t.Always=0]=\"Always\",t[t.BeforeDragging=1]=\"BeforeDragging\",t[t.WhileDragging=2]=\"WhileDragging\"})(Ww||(Ww={}));var sO;(function(t){t.Optimized=\"optimized\"})(sO||(sO={}));const oY=new Map;function jet(t,e){let{dragging:n,dependencies:r,config:i}=e;const[s,o]=w.useState(null),{frequency:l,measure:c,strategy:d}=i,u=w.useRef(t),m=b(),p=Vw(m),f=w.useCallback(function(g){g===void 0&&(g=[]),!p.current&&o(_=>_===null?g:_.concat(g.filter(C=>!_.includes(C))))},[p]),y=w.useRef(null),v=HS(g=>{if(m&&!n)return oY;if(!g||g===oY||u.current!==t||s!=null){const _=new Map;for(let C of t){if(!C)continue;if(s&&s.length>0&&!s.includes(C.id)&&C.rect.current){_.set(C.id,C.rect.current);continue}const P=C.node.current,N=P?new p5(c(P),P):null;C.rect.current=N,N&&_.set(C.id,N)}return _}return g},[t,s,n,m,c]);return w.useEffect(()=>{u.current=t},[t]),w.useEffect(()=>{m||f()},[n,m]),w.useEffect(()=>{s&&s.length>0&&o(null)},[JSON.stringify(s)]),w.useEffect(()=>{m||typeof l!=\"number\"||y.current!==null||(y.current=setTimeout(()=>{f(),y.current=null},l))},[l,m,f,...r]),{droppableRects:v,measureDroppableContainers:f,measuringScheduled:s!=null};function b(){switch(d){case Ww.Always:return!1;case Ww.BeforeDragging:return n;default:return!n}}}function Sle(t,e){return HS(n=>t?n||(typeof e==\"function\"?e(t):t):null,[e,t])}function Met(t,e){return Sle(t,e)}function Eet(t){let{callback:e,disabled:n}=t;const r=m5(e),i=w.useMemo(()=>{if(n||typeof window>\"u\"||typeof window.MutationObserver>\"u\")return;const{MutationObserver:s}=window;return new s(r)},[r,n]);return w.useEffect(()=>()=>i?.disconnect(),[i]),i}function yA(t){let{callback:e,disabled:n}=t;const r=m5(e),i=w.useMemo(()=>{if(n||typeof window>\"u\"||typeof window.ResizeObserver>\"u\")return;const{ResizeObserver:s}=window;return new s(r)},[n]);return w.useEffect(()=>()=>i?.disconnect(),[i]),i}function Det(t){return new p5(Hy(t),t)}function lY(t,e,n){e===void 0&&(e=Det);const[r,i]=w.useState(null);function s(){i(c=>{if(!t)return null;if(t.isConnected===!1){var d;return(d=c??n)!=null?d:null}const u=e(t);return JSON.stringify(c)===JSON.stringify(u)?c:u})}const o=Eet({callback(c){if(t)for(const d of c){const{type:u,target:m}=d;if(u===\"childList\"&&m instanceof HTMLElement&&m.contains(t)){s();break}}}}),l=yA({callback:s});return Xd(()=>{s(),t?(l?.observe(t),o?.observe(document.body,{childList:!0,subtree:!0})):(l?.disconnect(),o?.disconnect())},[t]),r}function Fet(t){const e=Sle(t);return ple(t,e)}const cY=[];function Ret(t){const e=w.useRef(t),n=HS(r=>t?r&&r!==cY&&t&&e.current&&t.parentNode===e.current.parentNode?r:gA(t):cY,[t]);return w.useEffect(()=>{e.current=t},[t]),n}function Let(t){const[e,n]=w.useState(null),r=w.useRef(t),i=w.useCallback(s=>{const o=v3(s.target);o&&n(l=>l?(l.set(o,aO(o)),new Map(l)):null)},[]);return w.useEffect(()=>{const s=r.current;if(t!==s){o(s);const l=t.map(c=>{const d=v3(c);return d?(d.addEventListener(\"scroll\",i,{passive:!0}),[d,aO(d)]):null}).filter(c=>c!=null);n(l.length?new Map(l):null),r.current=t}return()=>{o(t),o(s)};function o(l){l.forEach(c=>{const d=v3(c);d?.removeEventListener(\"scroll\",i)})}},[i,t]),w.useMemo(()=>t.length?e?Array.from(e.values()).reduce((s,o)=>Ux(s,o),ed):vle(t):ed,[t,e])}function dY(t,e){e===void 0&&(e=[]);const n=w.useRef(null);return w.useEffect(()=>{n.current=null},e),w.useEffect(()=>{const r=t!==ed;r&&!n.current&&(n.current=t),!r&&n.current&&(n.current=null)},[t]),n.current?Gw(t,n.current):ed}function Oet(t){w.useEffect(()=>{if(!fA)return;const e=t.map(n=>{let{sensor:r}=n;return r.setup==null?void 0:r.setup()});return()=>{for(const n of e)n?.()}},t.map(e=>{let{sensor:n}=e;return n}))}function Iet(t,e){return w.useMemo(()=>t.reduce((n,r)=>{let{eventName:i,handler:s}=r;return n[i]=o=>{s(o,e)},n},{}),[t,e])}function _le(t){return w.useMemo(()=>t?set(t):null,[t])}const uY=[];function zet(t,e){e===void 0&&(e=Hy);const[n]=t,r=_le(n?Go(n):null),[i,s]=w.useState(uY);function o(){s(()=>t.length?t.map(c=>xle(c)?r:new p5(e(c),c)):uY)}const l=yA({callback:o});return Xd(()=>{l?.disconnect(),o(),t.forEach(c=>l?.observe(c))},[t]),i}function Uet(t){if(!t)return null;if(t.children.length>1)return t;const e=t.children[0];return BS(e)?e:t}function Bet(t){let{measure:e}=t;const[n,r]=w.useState(null),i=w.useCallback(d=>{for(const{target:u}of d)if(BS(u)){r(m=>{const p=e(u);return m?{...m,width:p.width,height:p.height}:p});break}},[e]),s=yA({callback:i}),o=w.useCallback(d=>{const u=Uet(d);s?.disconnect(),u&&s?.observe(u),r(u?e(u):null)},[e,s]),[l,c]=MP(o);return w.useMemo(()=>({nodeRef:l,rect:n,setRef:c}),[n,l,c])}const Het=[{sensor:xA,options:{}},{sensor:bA,options:{}}],qet={current:{}},lN={draggable:{measure:rY},droppable:{measure:rY,strategy:Ww.WhileDragging,frequency:sO.Optimized},dragOverlay:{measure:Hy}};class Q0 extends Map{get(e){var n;return e!=null&&(n=super.get(e))!=null?n:void 0}toArray(){return Array.from(this.values())}getEnabled(){return this.toArray().filter(e=>{let{disabled:n}=e;return!n})}getNodeFor(e){var n,r;return(n=(r=this.get(e))==null?void 0:r.node.current)!=null?n:void 0}}const $et={activatorEvent:null,active:null,activeNode:null,activeNodeRect:null,collisions:null,containerNodeRect:null,draggableNodes:new Map,droppableRects:new Map,droppableContainers:new Q0,over:null,dragOverlay:{nodeRef:{current:null},rect:null,setRef:EP},scrollableAncestors:[],scrollableAncestorRects:[],measuringConfiguration:lN,measureDroppableContainers:EP,windowRect:null,measuringScheduled:!1},Vet={activatorEvent:null,activators:[],active:null,activeNodeRect:null,ariaDescribedById:{draggable:\"\"},dispatch:EP,draggableNodes:new Map,over:null,measureDroppableContainers:EP},vA=w.createContext(Vet),kle=w.createContext($et);function Get(){return{draggable:{active:null,initialCoordinates:{x:0,y:0},nodes:new Map,translate:{x:0,y:0}},droppable:{containers:new Q0}}}function Wet(t,e){switch(e.type){case ji.DragStart:return{...t,draggable:{...t.draggable,initialCoordinates:e.initialCoordinates,active:e.active}};case ji.DragMove:return t.draggable.active==null?t:{...t,draggable:{...t.draggable,translate:{x:e.coordinates.x-t.draggable.initialCoordinates.x,y:e.coordinates.y-t.draggable.initialCoordinates.y}}};case ji.DragEnd:case ji.DragCancel:return{...t,draggable:{...t.draggable,active:null,initialCoordinates:{x:0,y:0},translate:{x:0,y:0}}};case ji.RegisterDroppable:{const{element:n}=e,{id:r}=n,i=new Q0(t.droppable.containers);return i.set(r,n),{...t,droppable:{...t.droppable,containers:i}}}case ji.SetDroppableDisabled:{const{id:n,key:r,disabled:i}=e,s=t.droppable.containers.get(n);if(!s||r!==s.key)return t;const o=new Q0(t.droppable.containers);return o.set(n,{...s,disabled:i}),{...t,droppable:{...t.droppable,containers:o}}}case ji.UnregisterDroppable:{const{id:n,key:r}=e,i=t.droppable.containers.get(n);if(!i||r!==i.key)return t;const s=new Q0(t.droppable.containers);return s.delete(n),{...t,droppable:{...t.droppable,containers:s}}}default:return t}}function Ket(t){let{disabled:e}=t;const{active:n,activatorEvent:r,draggableNodes:i}=w.useContext(vA),s=nO(r),o=nO(n?.id);return w.useEffect(()=>{if(!e&&!r&&s&&o!=null){if(!h5(s)||document.activeElement===s.target)return;const l=i.get(o);if(!l)return;const{activatorNode:c,node:d}=l;if(!c.current&&!d.current)return;requestAnimationFrame(()=>{for(const u of[c.current,d.current]){if(!u)continue;const m=UJe(u);if(m){m.focus();break}}})}},[r,e,i,o,s]),null}function Xet(t,e){let{transform:n,...r}=e;return t!=null&&t.length?t.reduce((i,s)=>s({transform:i,...r}),n):n}function Yet(t){return w.useMemo(()=>({draggable:{...lN.draggable,...t?.draggable},droppable:{...lN.droppable,...t?.droppable},dragOverlay:{...lN.dragOverlay,...t?.dragOverlay}}),[t?.draggable,t?.droppable,t?.dragOverlay])}function Qet(t){let{activeNode:e,measure:n,initialRect:r,config:i=!0}=t;const s=w.useRef(!1),{x:o,y:l}=typeof i==\"boolean\"?{x:i,y:i}:i;Xd(()=>{if(!o&&!l||!e){s.current=!1;return}if(s.current||!r)return;const d=e?.node.current;if(!d||d.isConnected===!1)return;const u=n(d),m=ple(u,r);if(o||(m.x=0),l||(m.y=0),s.current=!0,Math.abs(m.x)>0||Math.abs(m.y)>0){const p=fle(d);p&&p.scrollBy({top:m.y,left:m.x})}},[e,o,l,r,n])}const Nle=w.createContext({...ed,scaleX:1,scaleY:1});var Fh;(function(t){t[t.Uninitialized=0]=\"Uninitialized\",t[t.Initializing=1]=\"Initializing\",t[t.Initialized=2]=\"Initialized\"})(Fh||(Fh={}));const Cle=w.memo(function(e){var n,r,i,s;let{id:o,accessibility:l,autoScroll:c=!0,children:d,sensors:u=Het,collisionDetection:m=JJe,measuring:p,modifiers:f,...y}=e;const v=w.useReducer(Wet,void 0,Get),[b,g]=v,[_,C]=GJe(),[P,N]=w.useState(Fh.Uninitialized),A=P===Fh.Initialized,{draggable:{active:T,nodes:F,translate:k},droppable:{containers:D}}=b,H=T!=null?F.get(T):null,z=w.useRef({initial:null,translated:null}),Q=w.useMemo(()=>{var Ve;return T!=null?{id:T,data:(Ve=H?.data)!=null?Ve:qet,rect:z}:null},[T,H]),L=w.useRef(null),[te,ie]=w.useState(null),[J,oe]=w.useState(null),fe=Vw(y,Object.values(y)),re=qS(\"DndDescribedBy\",o),W=w.useMemo(()=>D.getEnabled(),[D]),ne=Yet(p),{droppableRects:me,measureDroppableContainers:be,measuringScheduled:Ce}=jet(W,{dragging:A,dependencies:[k.x,k.y],config:ne.droppable}),q=Tet(F,T),Y=w.useMemo(()=>J?rO(J):null,[J]),E=we(),j=Met(q,ne.draggable.measure);Qet({activeNode:T!=null?F.get(T):null,config:E.layoutShiftCompensation,initialRect:j,measure:ne.draggable.measure});const O=lY(q,ne.draggable.measure,j),K=lY(q?q.parentElement:null),U=w.useRef({activatorEvent:null,active:null,activeNode:q,collisionRect:null,collisions:null,droppableRects:me,draggableNodes:F,draggingNode:null,draggingNodeRect:null,droppableContainers:D,over:null,scrollableAncestors:[],scrollAdjustedTranslate:null}),de=D.getNodeFor((n=U.current.over)==null?void 0:n.id),I=Bet({measure:ne.dragOverlay.measure}),G=(r=I.nodeRef.current)!=null?r:q,X=A?(i=I.rect)!=null?i:O:null,V=!!(I.nodeRef.current&&I.rect),ee=Fet(V?null:O),se=_le(G?Go(G):null),ge=Ret(A?de??q:null),he=zet(ge),le=Xet(f,{transform:{x:k.x-ee.x,y:k.y-ee.y,scaleX:1,scaleY:1},activatorEvent:J,active:Q,activeNodeRect:O,containerNodeRect:K,draggingNodeRect:X,over:U.current.over,overlayNodeRect:I.rect,scrollableAncestors:ge,scrollableAncestorRects:he,windowRect:se}),B=Y?Ux(Y,k):null,R=Let(ge),ae=dY(R),_e=dY(R,[O]),Se=Ux(le,ae),ve=X?net(X,le):null,Te=Q&&ve?m({active:Q,collisionRect:ve,droppableRects:me,droppableContainers:W,pointerCoordinates:B}):null,ye=mle(Te,\"id\"),[je,Le]=w.useState(null),Me=V?le:Ux(le,_e),Oe=eet(Me,(s=je?.rect)!=null?s:null,O),Re=w.useRef(null),$e=w.useCallback((Ve,Ae)=>{let{sensor:ce,options:xe}=Ae;if(L.current==null)return;const Be=F.get(L.current);if(!Be)return;const Qe=Ve.nativeEvent,ht=new ce({active:L.current,activeNode:Be,event:Qe,options:xe,context:U,onAbort(gt){if(!F.get(gt))return;const{onDragAbort:Wt}=fe.current,Zt={id:gt};Wt?.(Zt),_({type:\"onDragAbort\",event:Zt})},onPending(gt,Ut,Wt,Zt){if(!F.get(gt))return;const{onDragPending:Xt}=fe.current,ln={id:gt,constraint:Ut,initialCoordinates:Wt,offset:Zt};Xt?.(ln),_({type:\"onDragPending\",event:ln})},onStart(gt){const Ut=L.current;if(Ut==null)return;const Wt=F.get(Ut);if(!Wt)return;const{onDragStart:Zt}=fe.current,Kt={activatorEvent:Qe,active:{id:Ut,data:Wt.data,rect:z}};Yu.unstable_batchedUpdates(()=>{Zt?.(Kt),N(Fh.Initializing),g({type:ji.DragStart,initialCoordinates:gt,active:Ut}),_({type:\"onDragStart\",event:Kt}),ie(Re.current),oe(Qe)})},onMove(gt){g({type:ji.DragMove,coordinates:gt})},onEnd:xt(ji.DragEnd),onCancel:xt(ji.DragCancel)});Re.current=ht;function xt(gt){return async function(){const{active:Wt,collisions:Zt,over:Kt,scrollAdjustedTranslate:Xt}=U.current;let ln=null;if(Wt&&Xt){const{cancelDrop:vn}=fe.current;ln={activatorEvent:Qe,active:Wt,collisions:Zt,delta:Xt,over:Kt},gt===ji.DragEnd&&typeof vn==\"function\"&&await Promise.resolve(vn(ln))&&(gt=ji.DragCancel)}L.current=null,Yu.unstable_batchedUpdates(()=>{g({type:gt}),N(Fh.Uninitialized),Le(null),ie(null),oe(null),Re.current=null;const vn=gt===ji.DragEnd?\"onDragEnd\":\"onDragCancel\";if(ln){const Ke=fe.current[vn];Ke?.(ln),_({type:vn,event:ln})}})}}},[F]),Ye=w.useCallback((Ve,Ae)=>(ce,xe)=>{const Be=ce.nativeEvent,Qe=F.get(xe);if(L.current!==null||!Qe||Be.dndKit||Be.defaultPrevented)return;const ht={active:Qe};Ve(ce,Ae.options,ht)===!0&&(Be.dndKit={capturedBy:Ae.sensor},L.current=xe,$e(ce,Ae))},[F,$e]),tt=Aet(u,Ye);Oet(u),Xd(()=>{O&&P===Fh.Initializing&&N(Fh.Initialized)},[O,P]),w.useEffect(()=>{const{onDragMove:Ve}=fe.current,{active:Ae,activatorEvent:ce,collisions:xe,over:Be}=U.current;if(!Ae||!ce)return;const Qe={active:Ae,activatorEvent:ce,collisions:xe,delta:{x:Se.x,y:Se.y},over:Be};Yu.unstable_batchedUpdates(()=>{Ve?.(Qe),_({type:\"onDragMove\",event:Qe})})},[Se.x,Se.y]),w.useEffect(()=>{const{active:Ve,activatorEvent:Ae,collisions:ce,droppableContainers:xe,scrollAdjustedTranslate:Be}=U.current;if(!Ve||L.current==null||!Ae||!Be)return;const{onDragOver:Qe}=fe.current,ht=xe.get(ye),xt=ht&&ht.rect.current?{id:ht.id,rect:ht.rect.current,data:ht.data,disabled:ht.disabled}:null,gt={active:Ve,activatorEvent:Ae,collisions:ce,delta:{x:Be.x,y:Be.y},over:xt};Yu.unstable_batchedUpdates(()=>{Le(xt),Qe?.(gt),_({type:\"onDragOver\",event:gt})})},[ye]),Xd(()=>{U.current={activatorEvent:J,active:Q,activeNode:q,collisionRect:ve,collisions:Te,droppableRects:me,draggableNodes:F,draggingNode:G,draggingNodeRect:X,droppableContainers:D,over:je,scrollableAncestors:ge,scrollAdjustedTranslate:Se},z.current={initial:X,translated:ve}},[Q,q,Te,ve,F,G,X,me,D,je,ge,Se]),Net({...E,delta:k,draggingRect:ve,pointerCoordinates:B,scrollableAncestors:ge,scrollableAncestorRects:he});const pe=w.useMemo(()=>({active:Q,activeNode:q,activeNodeRect:O,activatorEvent:J,collisions:Te,containerNodeRect:K,dragOverlay:I,draggableNodes:F,droppableContainers:D,droppableRects:me,over:je,measureDroppableContainers:be,scrollableAncestors:ge,scrollableAncestorRects:he,measuringConfiguration:ne,measuringScheduled:Ce,windowRect:se}),[Q,q,O,J,Te,K,I,F,D,me,je,be,ge,he,ne,Ce,se]),Fe=w.useMemo(()=>({activatorEvent:J,activators:tt,active:Q,activeNodeRect:O,ariaDescribedById:{draggable:re},dispatch:g,draggableNodes:F,over:je,measureDroppableContainers:be}),[J,tt,Q,O,g,re,F,je,be]);return ia.createElement(lle.Provider,{value:C},ia.createElement(vA.Provider,{value:Fe},ia.createElement(kle.Provider,{value:pe},ia.createElement(Nle.Provider,{value:Oe},d)),ia.createElement(Ket,{disabled:l?.restoreFocus===!1})),ia.createElement(XJe,{...l,hiddenTextDescribedById:re}));function we(){const Ve=te?.autoScrollEnabled===!1,Ae=typeof c==\"object\"?c.enabled===!1:c===!1,ce=A&&!Ve&&!Ae;return typeof c==\"object\"?{...c,enabled:ce}:{enabled:ce}}}),Zet=w.createContext(null),mY=\"button\",Jet=\"Draggable\";function ett(t){let{id:e,data:n,disabled:r=!1,attributes:i}=t;const s=qS(Jet),{activators:o,activatorEvent:l,active:c,activeNodeRect:d,ariaDescribedById:u,draggableNodes:m,over:p}=w.useContext(vA),{role:f=mY,roleDescription:y=\"draggable\",tabIndex:v=0}=i??{},b=c?.id===e,g=w.useContext(b?Nle:Zet),[_,C]=MP(),[P,N]=MP(),A=Iet(o,e),T=Vw(n);Xd(()=>(m.set(e,{id:e,key:s,node:_,activatorNode:P,data:T}),()=>{const k=m.get(e);k&&k.key===s&&m.delete(e)}),[m,e]);const F=w.useMemo(()=>({role:f,tabIndex:v,\"aria-disabled\":r,\"aria-pressed\":b&&f===mY?!0:void 0,\"aria-roledescription\":y,\"aria-describedby\":u.draggable}),[r,f,v,b,y,u.draggable]);return{active:c,activatorEvent:l,activeNodeRect:d,attributes:F,isDragging:b,listeners:r?void 0:A,node:_,over:p,setNodeRef:C,setActivatorNodeRef:N,transform:g}}function ttt(){return w.useContext(kle)}const ntt=\"Droppable\",rtt={timeout:25};function att(t){let{data:e,disabled:n=!1,id:r,resizeObserverConfig:i}=t;const s=qS(ntt),{active:o,dispatch:l,over:c,measureDroppableContainers:d}=w.useContext(vA),u=w.useRef({disabled:n}),m=w.useRef(!1),p=w.useRef(null),f=w.useRef(null),{disabled:y,updateMeasurementsFor:v,timeout:b}={...rtt,...i},g=Vw(v??r),_=w.useCallback(()=>{if(!m.current){m.current=!0;return}f.current!=null&&clearTimeout(f.current),f.current=setTimeout(()=>{d(Array.isArray(g.current)?g.current:[g.current]),f.current=null},b)},[b]),C=yA({callback:_,disabled:y||!o}),P=w.useCallback((F,k)=>{C&&(k&&(C.unobserve(k),m.current=!1),F&&C.observe(F))},[C]),[N,A]=MP(P),T=Vw(e);return w.useEffect(()=>{!C||!N.current||(C.disconnect(),m.current=!1,C.observe(N.current))},[N,C]),w.useEffect(()=>(l({type:ji.RegisterDroppable,element:{id:r,key:s,disabled:n,node:N,rect:p,data:T}}),()=>l({type:ji.UnregisterDroppable,key:s,id:r})),[r]),w.useEffect(()=>{n!==u.current.disabled&&(l({type:ji.SetDroppableDisabled,id:r,key:s,disabled:n}),u.current.disabled=n)},[r,s,n,l]),{active:o,rect:p,isOver:c?.id===r,node:N,over:c,setNodeRef:A}}function wA(t,e,n){const r=t.slice();return r.splice(n<0?r.length+n:n,0,r.splice(e,1)[0]),r}function itt(t,e){return t.reduce((n,r,i)=>{const s=e.get(r);return s&&(n[i]=s),n},Array(t.length))}function jk(t){return t!==null&&t>=0}function stt(t,e){if(t===e)return!0;if(t.length!==e.length)return!1;for(let n=0;n<t.length;n++)if(t[n]!==e[n])return!1;return!0}function ott(t){return typeof t==\"boolean\"?{draggable:t,droppable:t}:t}const g5=t=>{let{rects:e,activeIndex:n,overIndex:r,index:i}=t;const s=wA(e,r,n),o=e[i],l=s[i];return!l||!o?null:{x:l.left-o.left,y:l.top-o.top,scaleX:l.width/o.width,scaleY:l.height/o.height}},Mk={scaleX:1,scaleY:1},ltt=t=>{var e;let{activeIndex:n,activeNodeRect:r,index:i,rects:s,overIndex:o}=t;const l=(e=s[n])!=null?e:r;if(!l)return null;if(i===n){const d=s[o];return d?{x:0,y:n<o?d.top+d.height-(l.top+l.height):d.top-l.top,...Mk}:null}const c=ctt(s,i,n);return i>n&&i<=o?{x:0,y:-l.height-c,...Mk}:i<n&&i>=o?{x:0,y:l.height+c,...Mk}:{x:0,y:0,...Mk}};function ctt(t,e,n){const r=t[e],i=t[e-1],s=t[e+1];return r?n<e?i?r.top-(i.top+i.height):s?s.top-(r.top+r.height):0:s?s.top-(r.top+r.height):i?r.top-(i.top+i.height):0:0}const Ple=\"Sortable\",Tle=ia.createContext({activeIndex:-1,containerId:Ple,disableTransforms:!1,items:[],overIndex:-1,useDragOverlay:!1,sortedRects:[],strategy:g5,disabled:{draggable:!1,droppable:!1}});function Ale(t){let{children:e,id:n,items:r,strategy:i=g5,disabled:s=!1}=t;const{active:o,dragOverlay:l,droppableRects:c,over:d,measureDroppableContainers:u}=ttt(),m=qS(Ple,n),p=l.rect!==null,f=w.useMemo(()=>r.map(A=>typeof A==\"object\"&&\"id\"in A?A.id:A),[r]),y=o!=null,v=o?f.indexOf(o.id):-1,b=d?f.indexOf(d.id):-1,g=w.useRef(f),_=!stt(f,g.current),C=b!==-1&&v===-1||_,P=ott(s);Xd(()=>{_&&y&&u(f)},[_,f,y,u]),w.useEffect(()=>{g.current=f},[f]);const N=w.useMemo(()=>({activeIndex:v,containerId:m,disabled:P,disableTransforms:C,items:f,overIndex:b,useDragOverlay:p,sortedRects:itt(f,c),strategy:i}),[v,m,P.draggable,P.droppable,C,f,b,c,p,i]);return ia.createElement(Tle.Provider,{value:N},e)}const dtt=t=>{let{id:e,items:n,activeIndex:r,overIndex:i}=t;return wA(n,r,i).indexOf(e)},utt=t=>{let{containerId:e,isSorting:n,wasDragging:r,index:i,items:s,newIndex:o,previousItems:l,previousContainerId:c,transition:d}=t;return!d||!r||l!==s&&i===o?!1:n?!0:o!==i&&e===c},mtt={duration:200,easing:\"ease\"},jle=\"transform\",htt=fy.Transition.toString({property:jle,duration:0,easing:\"linear\"}),ptt={roleDescription:\"sortable\"};function ftt(t){let{disabled:e,index:n,node:r,rect:i}=t;const[s,o]=w.useState(null),l=w.useRef(n);return Xd(()=>{if(!e&&n!==l.current&&r.current){const c=i.current;if(c){const d=Hy(r.current,{ignoreTransform:!0}),u={x:c.left-d.left,y:c.top-d.top,scaleX:c.width/d.width,scaleY:c.height/d.height};(u.x||u.y)&&o(u)}}n!==l.current&&(l.current=n)},[e,n,r,i]),w.useEffect(()=>{s&&o(null)},[s]),s}function Mle(t){let{animateLayoutChanges:e=utt,attributes:n,disabled:r,data:i,getNewIndex:s=dtt,id:o,strategy:l,resizeObserverConfig:c,transition:d=mtt}=t;const{items:u,containerId:m,activeIndex:p,disabled:f,disableTransforms:y,sortedRects:v,overIndex:b,useDragOverlay:g,strategy:_}=w.useContext(Tle),C=gtt(r,f),P=u.indexOf(o),N=w.useMemo(()=>({sortable:{containerId:m,index:P,items:u},...i}),[m,i,P,u]),A=w.useMemo(()=>u.slice(u.indexOf(o)),[u,o]),{rect:T,node:F,isOver:k,setNodeRef:D}=att({id:o,data:N,disabled:C.droppable,resizeObserverConfig:{updateMeasurementsFor:A,...c}}),{active:H,activatorEvent:z,activeNodeRect:Q,attributes:L,setNodeRef:te,listeners:ie,isDragging:J,over:oe,setActivatorNodeRef:fe,transform:re}=ett({id:o,data:N,attributes:{...ptt,...n},disabled:C.draggable}),W=LJe(D,te),ne=!!H,me=ne&&!y&&jk(p)&&jk(b),be=!g&&J,Ce=be&&me?re:null,Y=me?Ce??(l??_)({rects:v,activeNodeRect:Q,activeIndex:p,overIndex:b,index:P}):null,E=jk(p)&&jk(b)?s({id:o,items:u,activeIndex:p,overIndex:b}):P,j=H?.id,O=w.useRef({activeId:j,items:u,newIndex:E,containerId:m}),K=u!==O.current.items,U=e({active:H,containerId:m,isDragging:J,isSorting:ne,id:o,index:P,items:u,newIndex:O.current.newIndex,previousItems:O.current.items,previousContainerId:O.current.containerId,transition:d,wasDragging:O.current.activeId!=null}),de=ftt({disabled:!U,index:P,node:F,rect:T});return w.useEffect(()=>{ne&&O.current.newIndex!==E&&(O.current.newIndex=E),m!==O.current.containerId&&(O.current.containerId=m),u!==O.current.items&&(O.current.items=u)},[ne,E,m,u]),w.useEffect(()=>{if(j===O.current.activeId)return;if(j!=null&&O.current.activeId==null){O.current.activeId=j;return}const G=setTimeout(()=>{O.current.activeId=j},50);return()=>clearTimeout(G)},[j]),{active:H,activeIndex:p,attributes:L,data:N,rect:T,index:P,newIndex:E,items:u,isOver:k,isSorting:ne,isDragging:J,listeners:ie,node:F,overIndex:b,over:oe,setNodeRef:W,setActivatorNodeRef:fe,setDroppableNodeRef:D,setDraggableNodeRef:te,transform:de??Y,transition:I()};function I(){if(de||K&&O.current.newIndex===P)return htt;if(!(be&&!h5(z)||!d)&&(ne||U))return fy.Transition.toString({...d,property:jle})}}function gtt(t,e){var n,r;return typeof t==\"boolean\"?{draggable:t,droppable:!1}:{draggable:(n=t?.draggable)!=null?n:e.draggable,droppable:(r=t?.droppable)!=null?r:e.droppable}}function RP(t){if(!t)return!1;const e=t.data.current;return!!(e&&\"sortable\"in e&&typeof e.sortable==\"object\"&&\"containerId\"in e.sortable&&\"items\"in e.sortable&&\"index\"in e.sortable)}const btt=[wr.Down,wr.Right,wr.Up,wr.Left],Ele=(t,e)=>{let{context:{active:n,collisionRect:r,droppableRects:i,droppableContainers:s,over:o,scrollableAncestors:l}}=e;if(btt.includes(t.code)){if(t.preventDefault(),!n||!r)return;const c=[];s.getEnabled().forEach(m=>{if(!m||m!=null&&m.disabled)return;const p=i.get(m.id);if(p)switch(t.code){case wr.Down:r.top<p.top&&c.push(m);break;case wr.Up:r.top>p.top&&c.push(m);break;case wr.Left:r.left>p.left&&c.push(m);break;case wr.Right:r.left<p.left&&c.push(m);break}});const d=QJe({collisionRect:r,droppableRects:i,droppableContainers:c});let u=mle(d,\"id\");if(u===o?.id&&d.length>1&&(u=d[1].id),u!=null){const m=s.get(n.id),p=s.get(u),f=p?i.get(p.id):null,y=p?.node.current;if(y&&f&&m&&p){const b=gA(y).some((A,T)=>l[T]!==A),g=Dle(m,p),_=xtt(m,p),C=b||!g?{x:0,y:0}:{x:_?r.width-f.width:0,y:_?r.height-f.height:0},P={x:f.left,y:f.top};return C.x&&C.y?P:Gw(P,C)}}}};function Dle(t,e){return!RP(t)||!RP(e)?!1:t.data.current.sortable.containerId===e.data.current.sortable.containerId}function xtt(t,e){return!RP(t)||!RP(e)||!Dle(t,e)?!1:t.data.current.sortable.index<e.data.current.sortable.index}function ytt(t){return t>=1e3?`${(t/1e3).toFixed(1)}kg`:`${Math.round(t)}g`}function vtt({activeCount:t,pendingCount:e,totalTime:n,totalWeight:r,historyCount:i,t:s}){const o=[{icon:es,value:t,label:s(\"queue.summary.printing\"),color:\"text-blue-400\"},{icon:Yn,value:e,label:s(\"queue.summary.queued\"),color:\"text-yellow-400\"},{icon:jd,value:ws(n),label:s(\"queue.summary.totalTime\"),color:\"text-bambu-green\"},{icon:XQ,value:ytt(r),label:s(\"queue.summary.totalWeight\"),color:\"text-purple-400\"},{icon:yr,value:i,label:s(\"queue.summary.history\"),color:\"text-bambu-gray\"}];return a.jsx(\"div\",{className:\"flex items-center gap-3 sm:gap-5 flex-wrap px-4 py-3 bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary mb-6\",children:o.map((l,c)=>a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[c>0&&a.jsx(\"span\",{className:\"hidden sm:block text-bambu-dark-tertiary\",children:\"|\"}),a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(l.icon,{className:`w-4 h-4 ${l.color}`}),a.jsx(\"span\",{className:\"text-sm font-semibold text-white\",children:l.value}),a.jsx(\"span\",{className:\"text-xs sm:text-sm text-bambu-gray\",children:l.label})]})]},c))})}const hY={completed:{icon:yr,color:\"text-emerald-400\",border:\"border-l-emerald-500\"},failed:{icon:Ma,color:\"text-red-400\",border:\"border-l-red-500\"},skipped:{icon:GP,color:\"text-orange-400\",border:\"border-l-gray-500\"},cancelled:{icon:Ht,color:\"text-gray-400\",border:\"border-l-gray-500\"}};function wtt({item:t,onRequeue:e,onRemove:n,timeFormat:r=\"system\",hasPermission:i,canModify:s,t:o}){const l=hY[t.status]||hY.cancelled,c=l.icon,d=t.archive_name||t.library_file_name||`File #${t.archive_id||t.library_file_id}`,u=t.archive_thumbnail?ue.getArchiveThumbnail(t.archive_id):t.library_file_thumbnail?ue.getLibraryFileThumbnailUrl(t.library_file_id):null,m=t.completed_at||t.created_at;return a.jsxs(\"div\",{className:`flex items-center gap-2 sm:gap-3 px-3 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary border-l-[3px] ${l.border}`,children:[a.jsx(c,{className:`w-4 h-4 shrink-0 ${l.color}`}),a.jsx(\"div\",{className:\"w-8 h-8 shrink-0 bg-bambu-dark rounded overflow-hidden\",children:u?a.jsx(\"img\",{src:u,alt:\"\",className:\"w-full h-full object-cover\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center text-bambu-gray\",children:a.jsx(da,{className:\"w-4 h-4\"})})}),a.jsx(\"span\",{className:\"text-sm text-white font-medium truncate min-w-0 flex-1\",children:d}),t.printer_name&&a.jsxs(\"span\",{className:\"hidden sm:flex items-center gap-1 text-xs text-bambu-gray shrink-0\",children:[a.jsx(Er,{className:\"w-3 h-3\"}),a.jsx(\"span\",{className:\"truncate max-w-[100px]\",children:t.printer_name})]}),t.print_time_seconds&&a.jsxs(\"span\",{className:\"hidden sm:flex items-center gap-1 text-xs text-bambu-gray shrink-0\",children:[a.jsx(jd,{className:\"w-3 h-3\"}),ws(t.print_time_seconds)]}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray shrink-0\",children:ap(m,r,o)}),a.jsxs(\"div\",{className:\"flex items-center gap-0.5 shrink-0\",children:[a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:e,disabled:!i(\"queue:create\"),title:i(\"queue:create\")?o(\"queue.actions.requeue\"):o(\"queue.permissions.noRequeue\"),className:\"text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5\",children:a.jsx(lr,{className:\"w-3.5 h-3.5\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:n,disabled:!s(\"queue\",\"delete\",t.created_by_id),title:s(\"queue\",\"delete\",t.created_by_id)?o(\"common.remove\"):o(\"queue.permissions.noRemove\"),className:\"p-1.5\",children:a.jsx(en,{className:\"w-3.5 h-3.5\"})})]})]})}function c0(t){const e=new Date(t);return e.setHours(0,0,0,0),e}function Stt(t){return t.toLocaleDateString(void 0,{weekday:\"short\",month:\"short\",day:\"numeric\"})}function _tt(t){return t.toLocaleTimeString(void 0,{hour:\"2-digit\",minute:\"2-digit\"})}function ktt(t,e){if(t<=0)return e(\"queue.timeline.time.anyMoment\");const n=Math.round(t/6e4);if(n<60)return e(\"queue.timeline.time.minutesLeft\",{minutes:n});const r=Math.floor(n/60),i=n%60;return i===0?e(\"queue.timeline.time.hoursLeft\",{hours:r}):e(\"queue.timeline.time.hoursMinutesLeft\",{hours:r,minutes:i})}function Ntt(t){const e=new Date;return e.setHours(t,0,0,0),e.toLocaleTimeString(void 0,{hour:\"2-digit\",minute:\"2-digit\"})}function Ctt({event:t,now:e,onItemClick:n,t:r}){const i=t.item,s=i.archive_name||i.library_file_name||r(\"common.unknown\"),o=i.printer_name||(i.target_model?`${r(\"queue.filter.any\")} ${i.target_model}`:r(\"queue.timeline.unassigned\")),l=t.type===\"printing\",c=t.estimatedEnd.getTime()-e.getTime(),d=i.archive_thumbnail?ue.getArchiveThumbnail(i.archive_id):i.library_file_thumbnail?ue.getLibraryFileThumbnailUrl(i.library_file_id):null;return a.jsxs(\"div\",{className:`flex items-center gap-3 px-3 sm:px-4 py-3 bg-bambu-dark-secondary rounded-xl border cursor-pointer transition-all hover:border-bambu-green/40\n        ${l?\"border-blue-500/30\":\"border-bambu-dark-tertiary\"}`,onClick:()=>n(i),children:[a.jsx(\"div\",{className:`w-1 self-stretch rounded-full shrink-0 ${l?\"bg-blue-500\":\"bg-bambu-green/40\"}`}),a.jsx(\"div\",{className:\"w-10 h-10 shrink-0 bg-bambu-dark rounded-lg overflow-hidden\",children:d?a.jsx(\"img\",{src:d,alt:\"\",className:\"w-full h-full object-cover\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center text-bambu-gray\",children:a.jsx(da,{className:\"w-5 h-5\"})})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white font-medium truncate\",children:s}),a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-0.5\",children:[a.jsxs(\"span\",{className:\"flex items-center gap-1 text-xs text-bambu-gray\",children:[a.jsx(Er,{className:\"w-3 h-3\"}),a.jsx(\"span\",{className:\"truncate max-w-[120px] sm:max-w-none\",children:o})]}),i.print_time_seconds&&a.jsx(\"span\",{className:\"hidden sm:inline text-xs text-bambu-gray\",children:ws(i.print_time_seconds)})]}),l&&t.progress!=null&&a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-1.5\",children:[a.jsx(\"div\",{className:\"flex-1 bg-bambu-dark-tertiary rounded-full h-1.5\",children:a.jsx(\"div\",{className:\"bg-blue-500 h-1.5 rounded-full transition-all\",style:{width:`${t.progress}%`}})}),a.jsxs(\"span\",{className:\"text-xs text-blue-400 shrink-0\",children:[Math.round(t.progress),\"%\"]})]})]}),a.jsxs(\"div\",{className:\"text-right shrink-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white font-medium\",children:_tt(t.estimatedEnd)}),a.jsx(\"p\",{className:`text-xs mt-0.5 ${l?\"text-blue-400\":\"text-bambu-gray\"}`,children:ktt(c,r)})]})]})}function Ptt({queueItems:t,printerStatuses:e,onItemClick:n,t:r}){const[i,s]=w.useState(()=>c0(new Date)),[o,l]=w.useState(()=>new Date),[c,d]=w.useState(\"all\");w.useEffect(()=>{const F=setInterval(()=>l(new Date),6e4);return()=>clearInterval(F)},[]);const u=o.getTime(),m=c0(new Date).getTime()===c0(i).getTime(),p=w.useMemo(()=>{const F=[],k=new Map;for(const D of t)if(D.status===\"printing\"){const H=D.printer_id!=null?e[D.printer_id]:void 0,z=Zn(D.started_at)||new Date;let Q;if(H?.remaining_time!=null&&H.remaining_time>0)Q=new Date(u+H.remaining_time*60*1e3);else if(D.print_time_seconds){const L=H?.progress||0,te=Math.max(0,1-L/100);Q=new Date(u+D.print_time_seconds*te*1e3)}else Q=new Date(u+36e5);F.push({item:D,estimatedStart:z,estimatedEnd:Q,progress:H?.progress??void 0,type:\"printing\"})}else if(D.status===\"pending\"){const H=D.printer_id;k.has(H)||k.set(H,[]),k.get(H).push(D)}for(const[D,H]of k){H.sort((Q,L)=>Q.position-L.position);let z=u;for(const Q of F)Q.item.printer_id===D&&Q.type===\"printing\"&&(z=Math.max(z,Q.estimatedEnd.getTime()));for(const Q of H){const L=Zn(Q.scheduled_time);if(L){const oe=Date.now()+15552e6;L.getTime()<=oe&&(z=Math.max(z,L.getTime()))}const te=(Q.print_time_seconds||3600)*1e3,ie=new Date(z),J=new Date(z+te);F.push({item:Q,estimatedStart:ie,estimatedEnd:J,type:\"queued\"}),z=J.getTime()}}return F.sort((D,H)=>D.estimatedEnd.getTime()-H.estimatedEnd.getTime()),F},[t,e,u]),f=c0(i).getTime(),y=f+1440*60*1e3-1,v=w.useMemo(()=>p.filter(F=>{const k=F.estimatedEnd.getTime();return k<f||k>y?!1:c===\"printing\"?F.type===\"printing\":c===\"queued\"?F.type===\"queued\":!0}),[p,f,y,c]),b=w.useMemo(()=>{const F=new Map;for(const k of v){const D=k.estimatedEnd.getHours();F.has(D)||F.set(D,[]),F.get(D).push(k)}return Array.from(F.entries()).sort(([k],[D])=>k-D)},[v]),g=p.filter(F=>F.type===\"printing\"&&F.estimatedEnd.getTime()>=f&&F.estimatedEnd.getTime()<=y).length,_=p.filter(F=>F.type===\"queued\"&&F.estimatedEnd.getTime()>=f&&F.estimatedEnd.getTime()<=y).length,C=w.useMemo(()=>{let F=0;for(const k of p)F=Math.max(F,k.estimatedEnd.getTime());return F>0?new Date(F):null},[p]),P=()=>s(c0(new Date)),N=()=>{const F=new Date(i);F.setDate(F.getDate()-1),s(F)},A=()=>{const F=new Date(i);F.setDate(F.getDate()+1),s(F)},T=[{key:\"all\",label:r(\"queue.timeline.filterAll\"),count:g+_},{key:\"printing\",label:r(\"queue.timeline.filterPrinting\"),count:g},{key:\"queued\",label:r(\"queue.timeline.filterQueued\"),count:_}];return a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:N,className:\"p-1.5\",children:a.jsx(xl,{className:\"w-4 h-4\"})}),a.jsx(\"span\",{className:\"text-sm font-medium text-white min-w-[140px] text-center\",children:Stt(i)}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:A,className:\"p-1.5\",children:a.jsx(ti,{className:\"w-4 h-4\"})}),!m&&a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:P,className:\"text-xs text-bambu-green\",children:r(\"queue.timeline.day.today\")})]}),C&&a.jsxs(\"span\",{className:\"text-xs text-bambu-gray flex items-center gap-1.5\",children:[a.jsx(Yn,{className:\"w-3.5 h-3.5\"}),r(\"queue.timeline.allDoneBy\",{time:C.toLocaleTimeString(void 0,{hour:\"2-digit\",minute:\"2-digit\"})})]})]}),a.jsx(\"div\",{className:\"flex gap-2 mb-5\",children:T.map(F=>a.jsxs(\"button\",{onClick:()=>d(F.key),className:`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${c===F.key?\"bg-bambu-green text-white\":\"bg-bambu-dark-secondary border border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,children:[F.label,F.count>0&&a.jsx(\"span\",{className:`ml-1.5 text-xs ${c===F.key?\"text-white/70\":\"text-bambu-gray\"}`,children:F.count})]},F.key))}),b.length>0?a.jsx(\"div\",{className:\"space-y-6\",children:b.map(([F,k])=>a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-3\",children:[a.jsx(\"span\",{className:\"text-xs font-medium text-bambu-gray w-14 shrink-0\",children:Ntt(F)}),a.jsx(\"div\",{className:\"flex-1 h-px bg-bambu-dark-tertiary\"})]}),a.jsx(\"div\",{className:\"space-y-2 sm:ml-[68px]\",children:k.map(D=>a.jsx(Ctt,{event:D,now:o,onItemClick:n,t:r},D.item.id))})]},F))}):a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center py-16 text-bambu-gray\",children:[a.jsx(da,{className:\"w-12 h-12 mb-3 opacity-30\"}),a.jsx(\"p\",{className:\"text-sm\",children:r(\"queue.timeline.noData\")})]})]})}function Ttt(t,e=!1){return e&&t>=1e3?`${(t/1e3).toFixed(1)}kg`:`${Math.round(t)}g`}function Att({status:t,waitingReason:e,printerState:n,t:r}){if(t===\"pending\"&&e)return a.jsxs(\"span\",{className:\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20\",children:[a.jsx(Yn,{className:\"w-3.5 h-3.5\"}),r(\"queue.status.waiting\")]});if(t===\"printing\"&&n===\"PAUSE\")return a.jsxs(\"span\",{className:\"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-yellow-400 bg-yellow-400/10 border-yellow-400/20\",children:[a.jsx(rS,{className:\"w-3.5 h-3.5\"}),r(\"queue.status.paused\")]});const i={pending:{icon:Yn,color:\"text-status-warning bg-status-warning/10 border-status-warning/20\",label:r(\"queue.status.pending\")},printing:{icon:es,color:\"text-blue-400 bg-blue-400/10 border-blue-400/20\",label:r(\"queue.status.printing\")},completed:{icon:yr,color:\"text-status-ok bg-status-ok/10 border-status-ok/20\",label:r(\"queue.status.completed\")},failed:{icon:Ma,color:\"text-status-error bg-status-error/10 border-status-error/20\",label:r(\"queue.status.failed\")},skipped:{icon:GP,color:\"text-orange-400 bg-orange-400/10 border-orange-400/20\",label:r(\"queue.status.skipped\")},cancelled:{icon:Ht,color:\"text-gray-400 bg-gray-400/10 border-gray-400/20\",label:r(\"queue.status.cancelled\")}},{icon:s,color:o,label:l}=i[t];return a.jsxs(\"span\",{className:`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${o}`,children:[a.jsx(s,{className:\"w-3.5 h-3.5\"}),l]})}function jtt({selectedCount:t,printers:e,onSave:n,onClose:r,isSaving:i,canControlPrinter:s,t:o}){const[l,c]=w.useState(\"unchanged\"),[d,u]=w.useState(\"unchanged\"),[m,p]=w.useState(\"unchanged\"),[f,y]=w.useState(\"unchanged\"),[v,b]=w.useState(\"unchanged\"),[g,_]=w.useState(\"unchanged\"),[C,P]=w.useState(\"unchanged\"),[N,A]=w.useState(\"unchanged\"),[T,F]=w.useState(\"unchanged\"),[k,D]=w.useState(\"unchanged\"),H=()=>{const Q={};l!==\"unchanged\"&&(Q.printer_id=l),d!==\"unchanged\"&&(Q.manual_start=d),m!==\"unchanged\"&&(Q.auto_off_after=m),f!==\"unchanged\"&&(Q.require_previous_success=f),v!==\"unchanged\"&&(Q.bed_levelling=v),g!==\"unchanged\"&&(Q.flow_cali=g),C!==\"unchanged\"&&(Q.vibration_cali=C),N!==\"unchanged\"&&(Q.layer_inspect=N),T!==\"unchanged\"&&(Q.timelapse=T),k!==\"unchanged\"&&(Q.use_ams=k),n(Q)},z=l!==\"unchanged\"||d!==\"unchanged\"||m!==\"unchanged\"||f!==\"unchanged\"||v!==\"unchanged\"||g!==\"unchanged\"||C!==\"unchanged\"||N!==\"unchanged\"||T!==\"unchanged\"||k!==\"unchanged\";return a.jsx(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:o(\"queue.bulkEdit.title\",{count:t})}),a.jsx(\"button\",{onClick:r,className:\"p-1 hover:bg-bambu-dark rounded\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:o(\"queue.bulkEdit.description\")}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:o(\"queue.bulkEdit.printer\")}),a.jsxs(\"select\",{value:l===null?\"null\":l===\"unchanged\"?\"unchanged\":String(l),onChange:Q=>{const L=Q.target.value;c(L===\"unchanged\"?\"unchanged\":L===\"null\"?null:Number(L))},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\",children:[a.jsx(\"option\",{value:\"unchanged\",children:o(\"queue.bulkEdit.noChange\")}),a.jsx(\"option\",{value:\"null\",children:o(\"queue.filter.unassigned\")}),e.map(Q=>a.jsx(\"option\",{value:Q.id,children:Q.name},Q.id))]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:o(\"queue.bulkEdit.queueOptions\")}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(Iu,{label:o(\"queue.bulkEdit.staged\"),value:d,onChange:u,t:o}),a.jsx(Iu,{label:o(\"queue.bulkEdit.autoPowerOff\"),value:m,onChange:p,disabled:!s,t:o}),a.jsx(Iu,{label:o(\"queue.bulkEdit.requirePrevious\"),value:f,onChange:y,t:o})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:o(\"queue.bulkEdit.printOptions\")}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(Iu,{label:o(\"queue.bulkEdit.bedLevelling\"),value:v,onChange:b,t:o}),a.jsx(Iu,{label:o(\"queue.bulkEdit.flowCalibration\"),value:g,onChange:_,t:o}),a.jsx(Iu,{label:o(\"queue.bulkEdit.vibrationCalibration\"),value:C,onChange:P,t:o}),a.jsx(Iu,{label:o(\"queue.bulkEdit.layerInspection\"),value:N,onChange:A,t:o}),a.jsx(Iu,{label:o(\"queue.bulkEdit.timelapse\"),value:T,onChange:F,t:o}),a.jsx(Iu,{label:o(\"queue.bulkEdit.useAms\"),value:k,onChange:D,t:o})]})]})]}),a.jsxs(\"div\",{className:\"flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary\",children:[a.jsx(De,{variant:\"secondary\",onClick:r,children:o(\"common.cancel\")}),a.jsx(De,{onClick:H,disabled:!z||i,children:o(i?\"common.saving\":\"queue.bulkEdit.applyChanges\")})]})]})})}function Iu({label:t,value:e,onChange:n,disabled:r,t:i}){return a.jsxs(\"div\",{className:`flex items-center justify-between py-1 ${r?\"opacity-50\":\"\"}`,children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t}),a.jsxs(\"div\",{className:\"flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5\",children:[a.jsx(\"button\",{onClick:()=>n(\"unchanged\"),disabled:r,className:`px-2 py-1 text-xs rounded ${e===\"unchanged\"?\"bg-bambu-dark-tertiary text-white\":\"text-bambu-gray hover:text-white\"} disabled:cursor-not-allowed`,children:\"—\"}),a.jsx(\"button\",{onClick:()=>n(!1),disabled:r,className:`px-2 py-1 text-xs rounded ${e===!1?\"bg-red-500/20 text-red-400\":\"text-bambu-gray hover:text-white\"} disabled:cursor-not-allowed`,children:i(\"common.off\")}),a.jsx(\"button\",{onClick:()=>n(!0),disabled:r,className:`px-2 py-1 text-xs rounded ${e===!0?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:text-white\"} disabled:cursor-not-allowed`,children:i(\"common.on\")})]})]})}function pY({item:t,position:e,onEdit:n,onCancel:r,onRemove:i,onStop:s,onRequeue:o,onStart:l,timeFormat:c=\"system\",isSelected:d=!1,onToggleSelect:u,hasPermission:m,canModify:p,printerState:f,t:y}){const{data:v}=Xe({queryKey:[\"printerStatus\",t.printer_id],queryFn:()=>ue.getPrinterStatus(t.printer_id),refetchInterval:3e4,enabled:t.printer_id!=null&&f===\"printing\"}),b=!!t.library_file_id&&!t.archive_id,{data:g}=Xe({queryKey:[\"archive-plates\",t.archive_id],queryFn:()=>ue.getArchivePlates(t.archive_id),enabled:!!t.archive_id&&!b}),{data:_}=Xe({queryKey:[\"library-file-plates\",t.library_file_id],queryFn:()=>ue.getLibraryFilePlates(t.library_file_id),enabled:b&&!!t.library_file_id}),C=b?_:g,P=C?.plates??[],N=m(\"queue:reorder\"),{attributes:A,listeners:T,setNodeRef:F,transform:k,transition:D,isDragging:H}=Mle({id:t.id,disabled:t.status!==\"pending\"||!N}),z={transform:fy.Transform.toString(k),transition:D},Q=t.status===\"printing\",L=t.status===\"pending\",te=[\"completed\",\"failed\",\"skipped\",\"cancelled\"].includes(t.status),ie=L&&u;return a.jsxs(\"div\",{ref:F,style:z,className:`\n        group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200\n        border-l-[3px] ${Q?\"border-l-blue-500\":L?\"border-l-yellow-500\":t.status===\"completed\"?\"border-l-emerald-500\":t.status===\"failed\"?\"border-l-red-500\":\"border-l-gray-500\"}\n        ${H?\"opacity-50 scale-[1.02] shadow-xl z-50\":\"\"}\n        ${Q?\"border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent\":\"\"}\n        ${d&&ie?\"sm:border-bambu-dark-tertiary border-bambu-green/40\":\"\"}\n        ${!d&&!Q?\"border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80\":\"\"}\n        ${ie?\"sm:cursor-default\":\"\"}\n      `,onClick:ie?()=>{window.innerWidth<640&&u()}:void 0,children:[ie&&d&&a.jsx(\"div\",{className:\"sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green\"}),a.jsxs(\"div\",{className:\"flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4\",children:[L&&u&&a.jsx(\"button\",{onClick:J=>{J.stopPropagation(),u()},className:`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${d?\"bg-bambu-green border-bambu-green text-white\":\"border-white/30 bg-black/30 hover:border-bambu-green/50\"}`,children:d&&a.jsx(Ur,{className:\"w-4 h-4\"})}),L?a.jsx(\"div\",{...A,...T,className:\"hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0\",children:a.jsx(np,{className:\"w-4 h-4 text-bambu-gray\"})}):e!==void 0?a.jsxs(\"div\",{className:\"hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium shrink-0\",children:[\"#\",e]}):a.jsx(\"div\",{className:\"hidden sm:block w-8 shrink-0\"}),a.jsx(\"div\",{className:\"w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden\",children:t.archive_thumbnail?a.jsx(\"img\",{src:t.plate_id!=null?ue.getArchivePlateThumbnail(t.archive_id,t.plate_id):ue.getArchiveThumbnail(t.archive_id),alt:\"\",className:\"w-full h-full object-cover\"}):t.library_file_thumbnail?a.jsx(\"img\",{src:t.plate_id!=null?ue.getLibraryFilePlateThumbnail(t.library_file_id,t.plate_id):ue.getLibraryFileThumbnailUrl(t.library_file_id),alt:\"\",className:\"w-full h-full object-cover\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center text-bambu-gray\",children:a.jsx(da,{className:\"w-5 h-5 sm:w-6 sm:h-6\"})})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsxs(\"p\",{className:\"text-sm sm:text-base text-white font-medium truncate\",children:[t.archive_name||t.library_file_name||`File #${t.archive_id||t.library_file_id}`,(C?.is_multi_plate??!1)&&t.plate_id!==void 0&&t.plate_id!==null&&` • ${P.find(J=>J.index===t.plate_id)?.name||y(\"queue.plateNumber\",{index:t.plate_id})}`]}),t.archive_id?a.jsx(Do,{to:`/archives?highlight=${t.archive_id}`,className:\"text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0\",title:y(\"queue.viewArchive\"),children:a.jsx(la,{className:\"w-3.5 h-3.5\"})}):t.library_file_id?a.jsx(Do,{to:`/library?highlight=${t.library_file_id}`,className:\"text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0\",title:y(\"queue.viewInFileManager\"),children:a.jsx(la,{className:\"w-3.5 h-3.5\"})}):null,t.batch_name&&a.jsx(\"span\",{className:\"flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-purple-500/20 text-purple-300 rounded border border-purple-500/30\",children:t.batch_name})]}),a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray\",children:[a.jsxs(\"span\",{className:`flex items-center gap-1 sm:gap-1.5 ${t.printer_id===null&&!t.target_model?\"text-orange-400\":\"\"} ${t.target_model&&!t.printer_id?\"text-blue-400\":\"\"}`,children:[a.jsx(Er,{className:\"w-3 h-3 sm:w-3.5 sm:h-3.5\"}),a.jsx(\"span\",{className:\"truncate max-w-[120px] sm:max-w-none\",children:t.target_model&&!t.printer_id?`${y(\"queue.filter.any\")} ${t.target_model}${t.target_location?` @ ${t.target_location}`:\"\"}${t.required_filament_types?.length?` (${t.required_filament_types.join(\", \")})`:\"\"}`:t.printer_id===null?y(\"queue.filter.unassigned\"):t.printer_name||`${y(\"common.printer\")} #${t.printer_id}`})]}),t.print_time_seconds&&a.jsxs(\"span\",{className:\"flex items-center gap-1 sm:gap-1.5\",children:[a.jsx(jd,{className:\"w-3 h-3 sm:w-3.5 sm:h-3.5\"}),ws(t.print_time_seconds)]}),t.filament_used_grams&&a.jsxs(\"span\",{className:\"flex items-center gap-1 sm:gap-1.5\",children:[a.jsx(XQ,{className:\"w-3 h-3 sm:w-3.5 sm:h-3.5\"}),Ttt(t.filament_used_grams)]}),t.created_by_username&&a.jsxs(\"span\",{className:\"hidden sm:flex items-center gap-1.5\",title:y(\"queue.addedBy\",{name:t.created_by_username}),children:[a.jsx(ym,{className:\"w-3.5 h-3.5\"}),t.created_by_username]}),L&&!t.manual_start&&a.jsxs(\"span\",{className:\"flex items-center gap-1.5\",children:[a.jsx(Yn,{className:\"w-3.5 h-3.5\"}),t.scheduled_time?(Zn(t.scheduled_time)?.getTime()??0)-Date.now()<-6e4?y?.(\"queue.time.overdue\")??\"Overdue\":ap(t.scheduled_time,c,y):y?.(\"queue.time.asap\")??\"ASAP\"]})]}),a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2\",children:[t.manual_start&&a.jsxs(\"span\",{className:\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1\",children:[a.jsx(IQ,{className:\"w-2.5 h-2.5 sm:w-3 sm:h-3\"}),y(\"queue.badges.staged\")]}),t.require_previous_success&&a.jsx(\"span\",{className:\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20\",children:y(\"queue.badges.requiresPrevious\")}),t.auto_off_after&&a.jsxs(\"span\",{className:\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1\",children:[a.jsx(Yd,{className:\"w-2.5 h-2.5 sm:w-3 sm:h-3\"}),y(\"queue.badges.autoPowerOff\")]}),t.gcode_injection&&a.jsxs(\"span\",{className:\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-emerald-500/10 text-emerald-400 rounded-full border border-emerald-500/20 flex items-center gap-1\",children:[a.jsx(wy,{className:\"w-2.5 h-2.5 sm:w-3 sm:h-3\"}),y(\"queue.badges.gcodeInjection\")]})]}),Q&&v&&(()=>{const J=v.state===\"RUNNING\"||v.state===\"PAUSE\",oe=J&&v.progress||0,fe=J?v.remaining_time:null,re=J?v.layer_num:null,W=J?v.total_layers:null;return a.jsxs(\"div\",{className:\"mt-2 sm:mt-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between text-xs sm:text-sm\",children:[a.jsx(\"div\",{className:\"flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3\",children:a.jsx(\"div\",{className:\"bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all\",style:{width:`${oe}%`}})}),a.jsxs(\"span\",{className:\"text-white\",children:[Math.round(oe),\"%\"]})]}),a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-2 sm:gap-3 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-bambu-gray\",children:[fe!=null&&fe>0&&a.jsxs(a.Fragment,{children:[a.jsxs(\"span\",{className:\"flex items-center gap-1\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),ws(fe*60)]}),a.jsxs(\"span\",{className:\"text-bambu-green font-medium\",title:y(\"printers.estimatedCompletion\"),children:[\"ETA \",qO(fe,c,y)]})]}),re!=null&&W!=null&&W>0&&a.jsxs(\"span\",{className:\"flex items-center gap-1\",children:[a.jsx(da,{className:\"w-3 h-3\"}),re,\"/\",W]})]})]})})(),t.waiting_reason&&t.status===\"pending\"&&a.jsxs(\"p\",{className:\"text-[10px] sm:text-xs text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1\",children:[a.jsx(ei,{className:\"w-3 h-3 mt-0.5 flex-shrink-0\"}),a.jsx(\"span\",{children:t.waiting_reason})]}),t.error_message&&a.jsxs(\"p\",{className:\"text-[10px] sm:text-xs text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1\",children:[a.jsx(ei,{className:\"w-3 h-3\"}),t.error_message]})]}),a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0\",onClick:J=>J.stopPropagation(),children:[a.jsx(Att,{status:t.status,waitingReason:t.waiting_reason,printerState:f,t:y}),a.jsxs(\"div\",{className:\"flex items-center gap-0.5 sm:gap-1\",children:[Q&&a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:s,disabled:!m(\"printers:control\"),title:m(\"printers:control\")?y(\"queue.actions.stopPrint\"):y(\"queue.permissions.noStopPrint\"),className:\"text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2\",children:a.jsx(ufe,{className:\"w-4 h-4\"})}),L&&a.jsxs(a.Fragment,{children:[t.manual_start&&a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:l,disabled:!m(\"printers:control\"),title:m(\"printers:control\")?y(\"queue.actions.startPrint\"):y(\"queue.permissions.noStartPrint\"),className:\"text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2\",children:a.jsx(es,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:n,disabled:!p(\"queue\",\"update\",t.created_by_id),title:p(\"queue\",\"update\",t.created_by_id)?y(\"common.edit\"):y(\"queue.permissions.noEdit\"),className:\"p-1.5 sm:p-2\",children:a.jsx(ci,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:r,disabled:!p(\"queue\",\"delete\",t.created_by_id),title:p(\"queue\",\"delete\",t.created_by_id)?y(\"common.cancel\"):y(\"queue.permissions.noCancel\"),className:\"text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),te&&a.jsxs(a.Fragment,{children:[a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:o,disabled:!m(\"queue:create\"),title:m(\"queue:create\")?y(\"queue.actions.requeue\"):y(\"queue.permissions.noRequeue\"),className:\"text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2\",children:a.jsx(lr,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:i,disabled:!p(\"queue\",\"delete\",t.created_by_id),title:p(\"queue\",\"delete\",t.created_by_id)?y(\"common.remove\"):y(\"queue.permissions.noRemove\"),className:\"p-1.5 sm:p-2\",children:a.jsx(en,{className:\"w-4 h-4\"})})]})]})]})]})]})}function Mtt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),{hasPermission:r,hasAnyPermission:i,canModify:s}=kr(),[o,l]=w.useState(null),[c,d]=w.useState(\"\"),[u,m]=w.useState(\"\"),[p,f]=w.useState(!1),[y,v]=w.useState(null),[b,g]=w.useState(null),[_,C]=w.useState(null),[P,N]=w.useState([]),[A,T]=w.useState(!1),[F,k]=w.useState(()=>localStorage.getItem(\"queue.historySortBy\")||\"date\"),[D,H]=w.useState(()=>{const Te=localStorage.getItem(\"queue.historySortAsc\");return Te!==null?Te===\"true\":!1}),[z,Q]=w.useState(()=>localStorage.getItem(\"queue.pendingSortBy\")||\"position\"),[L,te]=w.useState(()=>{const Te=localStorage.getItem(\"queue.pendingSortAsc\");return Te!==null?Te===\"true\":!0}),[ie,J]=w.useState(()=>localStorage.getItem(\"queue.historyCollapsed\")!==\"false\"),[oe,fe]=w.useState(()=>localStorage.getItem(\"queue.viewMode\")||\"list\");w.useEffect(()=>{localStorage.setItem(\"queue.historySortBy\",F)},[F]),w.useEffect(()=>{localStorage.setItem(\"queue.historySortAsc\",String(D))},[D]),w.useEffect(()=>{localStorage.setItem(\"queue.pendingSortBy\",z)},[z]),w.useEffect(()=>{localStorage.setItem(\"queue.pendingSortAsc\",String(L))},[L]),w.useEffect(()=>{localStorage.setItem(\"queue.historyCollapsed\",String(ie))},[ie]),w.useEffect(()=>{localStorage.setItem(\"queue.viewMode\",oe)},[oe]);const re=cle(DP(xA,{activationConstraint:{distance:8}}),DP(bA,{coordinateGetter:Ele})),{data:W}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),ne=W?.time_format||\"system\",{data:me,isLoading:be}=Xe({queryKey:[\"queue\",o,c],queryFn:()=>ue.getQueue(o||void 0,c||void 0),refetchInterval:5e3}),{data:Ce}=Xe({queryKey:[\"printers\"],queryFn:()=>ue.getPrinters()}),q=it({mutationFn:Te=>ue.updateSettings({queue_shortest_first:Te}),onSuccess:()=>{e.invalidateQueries({queryKey:[\"settings\"]})}}),Y=it({mutationFn:Te=>ue.cancelQueueItem(Te),onSuccess:()=>{e.invalidateQueries({queryKey:[\"queue\"]}),n(t(\"queue.toast.cancelled\"))},onError:()=>n(t(\"queue.toast.cancelFailed\"),\"error\")}),E=it({mutationFn:Te=>ue.removeFromQueue(Te),onSuccess:()=>{e.invalidateQueries({queryKey:[\"queue\"]}),n(t(\"queue.toast.removed\"))},onError:()=>n(t(\"queue.toast.removeFailed\"),\"error\")}),j=it({mutationFn:Te=>ue.stopQueueItem(Te),onSuccess:()=>{e.invalidateQueries({queryKey:[\"queue\"]}),n(t(\"queue.toast.stopped\"))},onError:()=>n(t(\"queue.toast.stopFailed\"),\"error\")}),O=it({mutationFn:Te=>ue.startQueueItem(Te),onSuccess:()=>{e.invalidateQueries({queryKey:[\"queue\"]}),n(t(\"queue.toast.released\"))},onError:()=>n(t(\"queue.toast.startFailed\"),\"error\")}),K=it({mutationFn:Te=>ue.reorderQueue(Te),onSuccess:()=>{e.invalidateQueries({queryKey:[\"queue\"]})},onError:()=>n(t(\"queue.toast.reorderFailed\"),\"error\")}),U=it({mutationFn:async()=>{const Te=me?.filter(ye=>[\"completed\",\"failed\",\"skipped\",\"cancelled\"].includes(ye.status))||[];for(const ye of Te)await ue.removeFromQueue(ye.id);return Te.length},onSuccess:Te=>{e.invalidateQueries({queryKey:[\"queue\"]}),n(t(\"queue.toast.historyCleared\",{count:Te}))},onError:()=>n(t(\"queue.toast.clearHistoryFailed\"),\"error\")}),de=it({mutationFn:Te=>ue.bulkUpdateQueue(Te),onSuccess:Te=>{e.invalidateQueries({queryKey:[\"queue\"]}),N([]),T(!1),n(Te.message)},onError:()=>n(t(\"queue.toast.updateFailed\"),\"error\")}),I=it({mutationFn:async Te=>{for(const ye of Te)await ue.cancelQueueItem(ye);return Te.length},onSuccess:Te=>{e.invalidateQueries({queryKey:[\"queue\"]}),N([]),n(t(\"queue.toast.bulkCancelled\",{count:Te}))},onError:()=>n(t(\"queue.toast.bulkCancelFailed\"),\"error\")}),G=Te=>{N(ye=>ye.includes(Te)?ye.filter(je=>je!==Te):[...ye,Te])},X=w.useMemo(()=>{const Te=new Set;return Ce?.forEach(ye=>{ye.location&&Te.add(ye.location)}),me?.forEach(ye=>{ye.target_location&&Te.add(ye.target_location)}),Array.from(Te).sort()},[Ce,me]),V=w.useCallback(Te=>u?Te.target_location?Te.target_location===u:Te.printer_id?Ce?.find(je=>je.id===Te.printer_id)?.location===u:!1:!0,[u,Ce]),ee=w.useMemo(()=>{let Te=me?.filter(je=>je.status===\"pending\")||[];u&&(Te=Te.filter(V));const ye=je=>{if(!je.scheduled_time)return 0;const Le=Zn(je.scheduled_time)?.getTime()??0,Me=Date.now()+4320*60*60*1e3;return Le>Me?0:Le};return W?.queue_shortest_first?[...Te].sort((je,Le)=>{const Me=je.printer_id??-(je.target_model?.charCodeAt(0)??0),Oe=Le.printer_id??-(Le.target_model?.charCodeAt(0)??0);if(Me!==Oe)return Me-Oe;const Re=je.been_jumped?1:0,$e=Le.been_jumped?1:0;if(Re!==$e)return $e-Re;const Ye=je.print_time_seconds??1/0,tt=Le.print_time_seconds??1/0;return Ye!==tt?Ye-tt:je.position-Le.position}):[...Te].sort((je,Le)=>{let Me;if(z===\"name\"){const Oe=je.archive_name||je.library_file_name||\"\",Re=Le.archive_name||Le.library_file_name||\"\";Me=Oe.localeCompare(Re)}else z===\"printer\"?Me=(je.printer_name||\"\").localeCompare(Le.printer_name||\"\"):z===\"time\"?Me=ye(je)-ye(Le):Me=je.position-Le.position;return L?Me:-Me})},[me,z,L,V,u,W?.queue_shortest_first]),se=()=>{const Te=ee.map(ye=>ye.id);P.length===Te.length?N([]):N(Te)},ge=w.useMemo(()=>{let Te=me?.filter(ye=>ye.status===\"printing\")||[];return u&&(Te=Te.filter(V)),Te},[me,u,V]),he=w.useMemo(()=>{const Te=new Set;return ge.forEach(ye=>{ye.printer_id&&Te.add(ye.printer_id)}),Array.from(Te)},[ge]),le=Pp({queries:he.map(Te=>({queryKey:[\"printerStatus\",Te],queryFn:()=>ue.getPrinterStatus(Te),refetchInterval:5e3}))}),B=w.useMemo(()=>{const Te={};return he.forEach((ye,je)=>{const Le=le[je];Le?.data?.state&&(Te[ye]=Le.data.state)}),Te},[he,le]),R=w.useMemo(()=>{const Te={};return he.forEach((ye,je)=>{const Le=le[je];Le?.data&&(Te[ye]={progress:Le.data.progress??void 0,remaining_time:Le.data.remaining_time??void 0,state:Le.data.state??void 0})}),Te},[he,le]),ae=w.useMemo(()=>{let Te=me?.filter(ye=>[\"completed\",\"failed\",\"skipped\",\"cancelled\"].includes(ye.status))||[];return u&&(Te=Te.filter(V)),[...Te].sort((ye,je)=>{let Le;if(F===\"name\"){const Me=ye.archive_name||ye.library_file_name||\"\",Oe=je.archive_name||je.library_file_name||\"\";Le=Me.localeCompare(Oe)}else F===\"printer\"?Le=(ye.printer_name||\"\").localeCompare(je.printer_name||\"\"):Le=(Zn(je.completed_at||je.created_at)?.getTime()??0)-(Zn(ye.completed_at||ye.created_at)?.getTime()??0);return D?-Le:Le})},[me,F,D,V,u]),_e=w.useMemo(()=>ee.reduce((Te,ye)=>Te+(ye.print_time_seconds||0),0),[ee]),Se=w.useMemo(()=>ee.reduce((Te,ye)=>Te+(ye.filament_used_grams||0),0),[ee]),ve=Te=>{const{active:ye,over:je}=Te;if(!je||ye.id===je.id)return;const Le=ee.findIndex(Oe=>Oe.id===ye.id),Me=ee.findIndex(Oe=>Oe.id===je.id);if(Le!==-1&&Me!==-1){const Re=wA(ee,Le,Me).map(($e,Ye)=>({id:$e.id,position:Ye+1}));K.mutate(Re)}};return a.jsxs(\"div\",{className:\"p-4 md:p-8\",children:[a.jsx(\"div\",{className:\"flex items-center justify-between mb-8\",children:a.jsxs(\"div\",{children:[a.jsxs(\"h1\",{className:\"text-2xl font-bold text-white flex items-center gap-3\",children:[a.jsx(SN,{className:\"w-7 h-7 text-bambu-green\"}),t(\"queue.title\")]}),a.jsx(\"p\",{className:\"text-bambu-gray mt-1\",children:t(\"queue.subtitle\")})]})}),a.jsx(vtt,{activeCount:ge.length,pendingCount:ee.length,totalTime:_e,totalWeight:Se,historyCount:ae.length,t}),a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-2 sm:gap-4 mb-6\",children:[a.jsxs(\"select\",{className:\"px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none\",value:o===-1?\"unassigned\":o||\"\",onChange:Te=>{const ye=Te.target.value;l(ye===\"unassigned\"?-1:ye===\"\"?null:Number(ye))},children:[a.jsx(\"option\",{value:\"\",children:t(\"queue.filter.allPrinters\")}),a.jsx(\"option\",{value:\"unassigned\",children:t(\"queue.filter.unassigned\")}),Ce?.map(Te=>a.jsx(\"option\",{value:Te.id,children:Te.name},Te.id))]}),a.jsxs(\"select\",{className:\"px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none\",value:c,onChange:Te=>d(Te.target.value),children:[a.jsx(\"option\",{value:\"\",children:t(\"queue.filter.allStatus\")}),a.jsx(\"option\",{value:\"pending\",children:t(\"queue.status.pending\")}),a.jsx(\"option\",{value:\"printing\",children:t(\"queue.status.printing\")}),a.jsx(\"option\",{value:\"completed\",children:t(\"queue.status.completed\")}),a.jsx(\"option\",{value:\"failed\",children:t(\"queue.status.failed\")}),a.jsx(\"option\",{value:\"skipped\",children:t(\"queue.status.skipped\")}),a.jsx(\"option\",{value:\"cancelled\",children:t(\"queue.status.cancelled\")})]}),X.length>0&&a.jsxs(\"select\",{className:\"px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none\",value:u,onChange:Te=>m(Te.target.value),children:[a.jsx(\"option\",{value:\"\",children:t(\"queue.filter.allLocations\")}),X.map(Te=>a.jsx(\"option\",{value:Te,children:Te},Te))]}),a.jsx(\"div\",{className:\"hidden sm:block flex-1\"}),ae.length>0&&a.jsxs(De,{className:\"w-full sm:w-auto\",variant:\"secondary\",size:\"sm\",onClick:()=>f(!0),disabled:!r(\"queue:delete_all\"),title:r(\"queue:delete_all\")?void 0:t(\"queue.permissions.noClearHistory\"),children:[a.jsx(en,{className:\"w-4 h-4\"}),t(\"queue.clearHistory\")]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-6\",children:[a.jsxs(\"div\",{className:\"hidden sm:flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden\",children:[a.jsx(\"button\",{className:`p-2 transition-colors ${oe===\"list\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,onClick:()=>fe(\"list\"),title:t(\"queue.timeline.listView\"),children:a.jsx(tS,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{className:`p-2 transition-colors ${oe===\"timeline\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,onClick:()=>fe(\"timeline\"),title:t(\"queue.timeline.timelineView\"),children:a.jsx(Ype,{className:\"w-4 h-4\"})})]}),a.jsxs(\"button\",{onClick:()=>{const Te=!(W?.queue_shortest_first??!1);q.mutate(Te)},className:`flex items-center gap-1 px-2 py-1.5 text-xs rounded-lg border transition-colors ${W?.queue_shortest_first?\"bg-bambu-green/20 border-bambu-green text-bambu-green\":\"bg-bambu-dark-secondary border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray\"}`,title:t(\"queue.sjf.tooltip\",\"Shortest Job First — scheduler prioritizes shorter prints\"),children:[a.jsx(wxe,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"queue.sjf.label\",\"SJF\")}),a.jsx(\"span\",{className:`w-1.5 h-1.5 rounded-full ${W?.queue_shortest_first?\"bg-bambu-green\":\"bg-bambu-gray\"}`})]})]}),be?a.jsx(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:t(\"common.loading\")}):me?.length===0?a.jsxs(Tt,{className:\"p-12 text-center border-dashed\",children:[a.jsx(oa,{className:\"w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50\"}),a.jsx(\"h3\",{className:\"text-xl font-medium text-white mb-2\",children:t(\"queue.empty.title\")}),a.jsx(\"p\",{className:\"text-bambu-gray max-w-md mx-auto\",children:t(\"queue.empty.description\")})]}):oe===\"timeline\"?a.jsx(Ptt,{queueItems:me||[],printerStatuses:R,onItemClick:Te=>{[\"completed\",\"failed\",\"skipped\",\"cancelled\"].includes(Te.status)?g(Te):Te.status===\"pending\"?v(Te):Te.status===\"printing\"&&C({type:\"stop\",item:Te})},t}):a.jsxs(\"div\",{className:\"space-y-6 sm:space-y-8\",children:[ge.length>0&&a.jsxs(\"div\",{children:[a.jsxs(\"h2\",{className:\"text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2\",children:[a.jsx(\"div\",{className:\"w-2 h-2 rounded-full bg-blue-400 animate-pulse\"}),t(\"queue.sections.currentlyPrinting\")]}),a.jsx(\"div\",{className:\"space-y-2 sm:space-y-3\",children:ge.map(Te=>a.jsx(pY,{item:Te,onEdit:()=>{},onCancel:()=>{},onRemove:()=>{},onStop:()=>C({type:\"stop\",item:Te}),onRequeue:()=>{},onStart:()=>{},timeFormat:ne,hasPermission:r,canModify:s,printerState:Te.printer_id?B[Te.printer_id]:null,t},Te.id))})]}),ee.length>0&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4\",children:[a.jsxs(\"h2\",{className:\"text-base sm:text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(Yn,{className:\"w-4 h-4 sm:w-5 sm:h-5 text-yellow-400\"}),t(\"queue.sections.queued\"),a.jsxs(\"span\",{className:\"text-xs sm:text-sm font-normal text-bambu-gray\",children:[\"(\",t(\"queue.itemCount\",{count:ee.length}),\")\"]}),a.jsx(\"span\",{className:\"hidden sm:inline text-xs text-bambu-gray ml-2\",title:t(\"queue.reorderHint\"),children:t(\"queue.dragToReorder\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"select\",{className:\"px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:z,onChange:Te=>Q(Te.target.value),children:[a.jsx(\"option\",{value:\"position\",children:t(\"queue.sort.byPosition\")}),a.jsx(\"option\",{value:\"name\",children:t(\"queue.sort.byName\")}),a.jsx(\"option\",{value:\"printer\",children:t(\"queue.sort.byPrinter\")}),a.jsx(\"option\",{value:\"time\",children:t(\"queue.sort.bySchedule\")})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>te(!L),title:t(L?\"common.ascending\":\"common.descending\"),className:\"px-2\",children:L?a.jsx(fp,{className:\"w-4 h-4\"}):a.jsx(cg,{className:\"w-4 h-4\"})})]})]}),a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-2 sm:gap-3 mb-3 sm:mb-4 p-2 sm:p-3 bg-bambu-dark rounded-lg\",children:[a.jsxs(De,{variant:\"ghost\",size:\"sm\",onClick:se,className:\"flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm\",children:[P.length===ee.length&&ee.length>0?a.jsx(Ns,{className:\"w-4 h-4 text-bambu-green\"}):a.jsx(uo,{className:\"w-4 h-4\"}),P.length===ee.length&&ee.length>0?t(\"queue.bulkEdit.deselectAll\"):t(\"queue.bulkEdit.selectAll\")]}),P.length>0&&a.jsxs(a.Fragment,{children:[a.jsx(\"span\",{className:\"text-xs sm:text-sm text-bambu-gray\",children:t(\"queue.bulkEdit.selected\",{count:P.length})}),a.jsx(\"div\",{className:\"hidden sm:block h-4 w-px bg-bambu-dark-tertiary\"}),a.jsxs(De,{variant:\"ghost\",size:\"sm\",onClick:()=>T(!0),className:\"flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light\",disabled:!i(\"queue:update_own\",\"queue:update_all\"),title:i(\"queue:update_own\",\"queue:update_all\")?t(\"queue.bulkEdit.editSelected\"):t(\"queue.permissions.noEditItems\"),children:[a.jsx(ci,{className:\"w-3.5 h-3.5 sm:w-4 sm:h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"queue.bulkEdit.editSelected\")})]}),a.jsxs(De,{variant:\"ghost\",size:\"sm\",onClick:()=>I.mutate(P),className:\"flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-400 hover:text-red-300\",disabled:I.isPending||!i(\"queue:delete_own\",\"queue:delete_all\"),title:i(\"queue:delete_own\",\"queue:delete_all\")?t(\"queue.bulkEdit.cancelSelected\"):t(\"queue.permissions.noCancelItems\"),children:[a.jsx(Ht,{className:\"w-3.5 h-3.5 sm:w-4 sm:h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"queue.bulkEdit.cancelSelected\")})]})]})]}),a.jsx(Cle,{sensors:re,collisionDetection:hle,onDragEnd:ve,children:a.jsx(Ale,{items:ee.map(Te=>Te.id),strategy:ltt,children:a.jsx(\"div\",{className:\"space-y-2 sm:space-y-3\",children:ee.map((Te,ye)=>a.jsx(pY,{item:Te,position:ye+1,onEdit:()=>v(Te),onCancel:()=>C({type:\"cancel\",item:Te}),onRemove:()=>{},onStop:()=>{},onRequeue:()=>{},onStart:()=>O.mutate(Te.id),timeFormat:ne,isSelected:P.includes(Te.id),onToggleSelect:()=>G(Te.id),hasPermission:r,canModify:s,t},Te.id))})})})]}),ae.length>0&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4\",children:[a.jsxs(\"button\",{onClick:()=>J(!ie),className:\"text-base sm:text-lg font-semibold text-white flex items-center gap-2 hover:text-bambu-green transition-colors\",children:[ie?a.jsx(ti,{className:\"w-4 h-4 sm:w-5 sm:h-5\"}):a.jsx(On,{className:\"w-4 h-4 sm:w-5 sm:h-5\"}),t(\"queue.sections.history\"),a.jsxs(\"span\",{className:\"text-xs sm:text-sm font-normal text-bambu-gray\",children:[\"(\",t(\"queue.itemCount\",{count:ae.length}),\")\"]})]}),!ie&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"select\",{className:\"px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:F,onChange:Te=>k(Te.target.value),children:[a.jsx(\"option\",{value:\"date\",children:t(\"queue.sort.byDate\")}),a.jsx(\"option\",{value:\"name\",children:t(\"queue.sort.byName\")}),a.jsx(\"option\",{value:\"printer\",children:t(\"queue.sort.byPrinter\")})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>H(!D),title:t(D?\"queue.sort.ascendingOldest\":\"queue.sort.descendingNewest\"),className:\"px-2\",children:D?a.jsx(fp,{className:\"w-4 h-4\"}):a.jsx(cg,{className:\"w-4 h-4\"})})]})]}),!ie&&a.jsx(\"div\",{className:\"space-y-1.5 sm:space-y-2\",children:ae.slice(0,50).map(Te=>a.jsx(wtt,{item:Te,onRemove:()=>C({type:\"remove\",item:Te}),onRequeue:()=>g(Te),timeFormat:ne,hasPermission:r,canModify:s,t},Te.id))})]})]}),y&&a.jsx(ic,{mode:\"edit-queue-item\",archiveId:y.archive_id??void 0,libraryFileId:y.library_file_id??void 0,archiveName:y.archive_name||y.library_file_name||`File #${y.archive_id||y.library_file_id}`,queueItem:y,onClose:()=>v(null)}),b&&a.jsx(ic,{mode:\"add-to-queue\",archiveId:b.archive_id??void 0,libraryFileId:b.library_file_id??void 0,archiveName:b.archive_name||b.library_file_name||`File #${b.archive_id||b.library_file_id}`,onClose:()=>g(null)}),_&&a.jsx(yn,{title:_.type===\"cancel\"?t(\"queue.confirm.cancelTitle\"):_.type===\"stop\"?t(\"queue.confirm.stopTitle\"):t(\"queue.confirm.removeTitle\"),message:_.type===\"cancel\"?t(\"queue.confirm.cancelMessage\",{name:_.item.archive_name||_.item.library_file_name||t(\"queue.confirm.thisPrint\")}):_.type===\"stop\"?t(\"queue.confirm.stopMessage\",{name:_.item.archive_name||_.item.library_file_name||t(\"queue.confirm.thisPrint\")}):t(\"queue.confirm.removeMessage\",{name:_.item.archive_name||_.item.library_file_name||t(\"queue.confirm.thisItem\")}),confirmText:_.type===\"cancel\"?t(\"queue.confirm.cancelButton\"):_.type===\"stop\"?t(\"queue.confirm.stopButton\"):t(\"common.remove\"),variant:\"danger\",onConfirm:()=>{_.type===\"cancel\"?Y.mutate(_.item.id):_.type===\"stop\"?j.mutate(_.item.id):E.mutate(_.item.id),C(null)},onCancel:()=>C(null)}),p&&a.jsx(yn,{title:t(\"queue.confirm.clearHistoryTitle\"),message:t(\"queue.confirm.clearHistoryMessage\",{count:ae.length}),confirmText:t(\"queue.clearHistory\"),variant:\"danger\",onConfirm:()=>{U.mutate(),f(!1)},onCancel:()=>f(!1)}),A&&a.jsx(jtt,{selectedCount:P.length,printers:Ce?.map(Te=>({id:Te.id,name:Te.name}))||[],onSave:Te=>{Object.keys(Te).length>0&&de.mutate({item_ids:P,...Te})},onClose:()=>T(!1),isSaving:de.isPending,canControlPrinter:r(\"printers:control\"),t})]})}function Ett({printDates:t,months:e=3}){const n=w.useRef(null),[r,i]=w.useState(0);w.useEffect(()=>{const _=n.current;if(!_)return;const C=new ResizeObserver(P=>{const N=P[0]?.contentRect.width||0;i(N)});return C.observe(_),()=>C.disconnect()},[]);const{weeks:s,monthLabels:o,printCounts:l}=w.useMemo(()=>{const _={};t.forEach(H=>{const z=H.split(\"T\")[0];_[z]=(_[z]||0)+1});const C=new Date,P=new Date(C);P.setMonth(P.getMonth()-e),P.setDate(P.getDate()-P.getDay());const N=[],A=[];let T=[],F=-1;const k=new Date(P);let D=0;for(;k<=C;)k.getDay()===0&&T.length>0&&(N.push(T),T=[],D++),k.getMonth()!==F&&(A.push({month:k.toLocaleDateString(\"en-US\",{month:\"short\"}),weekIndex:D}),F=k.getMonth()),T.push(new Date(k)),k.setDate(k.getDate()+1);return T.length>0&&N.push(T),{weeks:N,monthLabels:A,printCounts:_}},[t,e]),c=Math.max(1,...Object.values(l)),d=_=>{if(_===0)return\"bg-bambu-dark\";const C=_/c;return C<=.25?\"bg-bambu-green/30\":C<=.5?\"bg-bambu-green/50\":C<=.75?\"bg-bambu-green/75\":\"bg-bambu-green\"},u=[\"Sun\",\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\"],m=s.length,p=32,f=2,y=r-p-16,v=m>0?Math.floor((y-(m-1)*f)/m):12,b=Math.max(8,Math.min(20,v)),g=b<=10?10:12;return a.jsx(\"div\",{ref:n,className:\"w-full flex justify-center\",children:r>0&&a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"flex mb-1\",style:{marginLeft:p+4},children:o.map(({month:_,weekIndex:C},P)=>a.jsx(\"div\",{className:\"text-bambu-gray\",style:{fontSize:g,marginLeft:P===0?0:`${(C-(o[P-1]?.weekIndex||0))*(b+f)-24}px`},children:_},P))}),a.jsxs(\"div\",{className:\"flex\",style:{gap:f},children:[a.jsx(\"div\",{className:\"flex flex-col\",style:{gap:f,marginRight:4,width:p},children:u.map((_,C)=>a.jsx(\"div\",{className:\"text-bambu-gray flex items-center\",style:{width:p,height:b,fontSize:g,visibility:C%2===1?\"visible\":\"hidden\"},children:_},_))}),s.map((_,C)=>a.jsx(\"div\",{className:\"flex flex-col\",style:{gap:f},children:[0,1,2,3,4,5,6].map(P=>{const N=_.find(k=>k.getDay()===P);if(!N)return a.jsx(\"div\",{style:{width:b,height:b}},P);const A=N.toISOString().split(\"T\")[0],T=l[A]||0,F=A===new Date().toISOString().split(\"T\")[0];return a.jsx(\"div\",{className:`rounded-sm ${d(T)} ${F?\"ring-1 ring-white\":\"\"}`,style:{width:b,height:b},title:`${N.toLocaleDateString()}: ${T} print${T!==1?\"s\":\"\"}`},P)})},C))]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-3 text-bambu-gray\",style:{fontSize:g},children:[a.jsx(\"span\",{children:\"Less\"}),a.jsxs(\"div\",{className:\"flex\",style:{gap:f},children:[a.jsx(\"div\",{className:\"rounded-sm bg-bambu-dark\",style:{width:b,height:b}}),a.jsx(\"div\",{className:\"rounded-sm bg-bambu-green/30\",style:{width:b,height:b}}),a.jsx(\"div\",{className:\"rounded-sm bg-bambu-green/50\",style:{width:b,height:b}}),a.jsx(\"div\",{className:\"rounded-sm bg-bambu-green/75\",style:{width:b,height:b}}),a.jsx(\"div\",{className:\"rounded-sm bg-bambu-green\",style:{width:b,height:b}})]}),a.jsx(\"span\",{children:\"More\"})]})]})})}const fY=[\"weight\",\"prints\",\"time\"];function LP({value:t,onChange:e,exclude:n}){const{t:r}=Ft(),i={weight:r(\"stats.filamentByWeight\"),prints:r(\"stats.filamentByPrints\"),time:r(\"stats.filamentByTime\")},s=n?fY.filter(o=>!n.includes(o)):fY;return a.jsx(\"div\",{className:\"flex gap-0.5 bg-bambu-dark rounded-lg p-0.5\",children:s.map(o=>a.jsx(\"button\",{onClick:()=>e(o),className:`px-2 py-0.5 text-xs rounded-md transition-colors ${t===o?\"bg-bambu-green text-white\":\"text-bambu-gray hover:text-white\"}`,children:i[o]},o))})}function Bh(t){if(t>=1e6){const e=t/1e6;return`${e%1===0?e.toFixed(0):e.toFixed(1)}t`}if(t>=1e3){const e=t/1e3;return`${e%1===0?e.toFixed(0):e.toFixed(1)}kg`}return`${Math.round(t)}g`}const Ek=[\"#00ae42\",\"#3b82f6\",\"#f59e0b\",\"#ef4444\",\"#8b5cf6\",\"#ec4899\",\"#14b8a6\",\"#f97316\"],Dtt=[\"Sun\",\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\"],gY=[\"12am\",\"1am\",\"2am\",\"3am\",\"4am\",\"5am\",\"6am\",\"7am\",\"8am\",\"9am\",\"10am\",\"11am\",\"12pm\",\"1pm\",\"2pm\",\"3pm\",\"4pm\",\"5pm\",\"6pm\",\"7pm\",\"8pm\",\"9pm\",\"10pm\",\"11pm\"];function Ftt({archives:t,currency:e=\"$\",dateFrom:n,dateTo:r}){const{t:i}=Ft(),[s,o]=w.useState(\"weight\"),[l,c]=w.useState(\"weight\"),d=w.useMemo(()=>{const F=new Map;return t.forEach(k=>{const D=Zn(k.completed_at||k.created_at)||new Date,H=`${D.getFullYear()}-${String(D.getMonth()+1).padStart(2,\"0\")}-${String(D.getDate()).padStart(2,\"0\")}`,z=F.get(H)||{date:H,filament:0,cost:0,prints:0};z.filament+=k.filament_used_grams||0,z.cost+=k.cost||0,z.prints+=k.quantity||1,F.set(H,z)}),Array.from(F.values()).sort((k,D)=>k.date.localeCompare(D.date)).map(k=>({...k,dateLabel:new Date(k.date).toLocaleDateString(\"en-US\",{month:\"short\",day:\"numeric\"})}))},[t]),u=w.useMemo(()=>{if(n&&r)return Math.max((new Date(r).getTime()-new Date(n).getTime())/864e5,0)+1;if(n)return Math.max((Date.now()-new Date(n).getTime())/864e5,0)+1;if(t.length<2)return 0;const F=t.map(k=>new Date(k.completed_at||k.created_at).getTime());return(Math.max(...F)-Math.min(...F))/864e5},[t,n,r]),m=w.useMemo(()=>{if(u>7)return[];const F=new Map,k=u>1;return t.forEach(D=>{const H=Zn(D.completed_at||D.created_at)||new Date,z=H.getHours(),Q=`${H.getFullYear()}-${String(H.getMonth()+1).padStart(2,\"0\")}-${String(H.getDate()).padStart(2,\"0\")}T${String(z).padStart(2,\"0\")}`,L=F.get(Q)||{date:Q,filament:0,cost:0,prints:0};L.filament+=D.filament_used_grams||0,L.cost+=D.cost||0,L.prints+=D.quantity||1,F.set(Q,L)}),Array.from(F.values()).sort((D,H)=>D.date.localeCompare(H.date)).map(D=>{const[H,z]=D.date.split(\"T\"),Q=new Date(H),L=parseInt(z,10),te=k?`${Dtt[Q.getDay()]} ${gY[L]}`:gY[L];return{...D,dateLabel:te}})},[t,u]),p=w.useMemo(()=>{if(d.length<=60)return d;const F=new Map;return d.forEach(k=>{const D=new Date(k.date),H=new Date(D);H.setDate(D.getDate()-D.getDay());const z=`${H.getFullYear()}-${String(H.getMonth()+1).padStart(2,\"0\")}-${String(H.getDate()).padStart(2,\"0\")}`,Q=F.get(z)||{week:z,filament:0,cost:0,prints:0};Q.filament+=k.filament,Q.cost+=k.cost,Q.prints+=k.prints,F.set(z,Q)}),Array.from(F.values()).sort((k,D)=>k.week.localeCompare(D.week)).map(k=>({date:k.week,dateLabel:`Week of ${new Date(k.week).toLocaleDateString(\"en-US\",{month:\"short\",day:\"numeric\"})}`,...k}))},[d]),f=w.useMemo(()=>{const F=new Map;return t.forEach(k=>{const H=(k.filament_type||\"Unknown\").split(\", \");H.forEach(z=>{const Q=(k.filament_used_grams||0)/H.length;F.set(z,(F.get(z)||0)+Q)})}),Array.from(F.entries()).map(([k,D])=>({name:k,value:Math.round(D)})).sort((k,D)=>D.value-k.value)},[t]),y=w.useMemo(()=>{const F=new Map;return t.forEach(k=>{(k.filament_type||\"Unknown\").split(\", \").forEach(z=>{F.set(z,(F.get(z)||0)+1)})}),Array.from(F.entries()).map(([k,D])=>({name:k,value:D})).sort((k,D)=>D.value-k.value)},[t]),v=w.useMemo(()=>{const F=new Map;return t.forEach(k=>{const H=(k.filament_type||\"Unknown\").split(\", \"),z=(k.actual_time_seconds||k.print_time_seconds||0)/H.length;H.forEach(Q=>{F.set(Q,(F.get(Q)||0)+z)})}),Array.from(F.entries()).map(([k,D])=>({name:k,value:Math.round(D/3600*10)/10})).sort((k,D)=>D.value-k.value)},[t]),b=w.useMemo(()=>{const F=new Map;return t.forEach(k=>{if(k.status!==\"completed\"&&k.status!==\"failed\")return;(k.filament_type||\"Unknown\").split(\", \").forEach(H=>{const z=F.get(H)||{completed:0,failed:0};k.status===\"completed\"?z.completed++:z.failed++,F.set(H,z)})}),Array.from(F.entries()).filter(([,k])=>k.completed+k.failed>=2).map(([k,D])=>{const H=D.completed+D.failed,z=Math.round(D.completed/H*100);return{name:k,rate:z,total:H}}).sort((k,D)=>D.rate-k.rate)},[t]),g=w.useMemo(()=>{const F=new Map;return t.forEach(k=>{if(!k.filament_color)return;const D=k.filament_color.split(\",\").map(z=>z.trim()),H=(k.filament_used_grams||0)/D.length;D.forEach(z=>{const Q=F.get(z)||{count:0,weight:0};Q.count++,Q.weight+=H,F.set(z,Q)})}),Array.from(F.entries()).map(([k,D])=>({hex:k,value:l===\"prints\"?D.count:Math.round(D.weight)})).sort((k,D)=>D.value-k.value)},[t,l]),_=s===\"weight\"?f:s===\"prints\"?y:v,C=u<=7&&m.length>0?m:p,P=t.reduce((F,k)=>F+(k.filament_used_grams||0),0),N=t.reduce((F,k)=>F+(k.cost||0),0),A=t.reduce((F,k)=>F+(k.quantity||1),0),T=new Set(t.map(F=>F.printer_id).filter(Boolean)).size;return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-3 gap-2 max-[640px]:grid-cols-1\",children:[a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray leading-none\",children:i(\"stats.periodFilament\")}),a.jsx(\"p\",{className:\"text-2xl font-bold text-white leading-none\",children:Bh(P)})]}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[T,\" \",i(\"nav.printers\").toLowerCase()]})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray leading-none\",children:i(\"stats.periodCost\")}),a.jsxs(\"p\",{className:\"text-2xl font-bold text-white leading-none\",children:[e,N.toFixed(2)]})]}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[A,\" \",i(\"common.prints\")]})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray leading-none\",children:i(\"stats.avgPerPrint\")}),a.jsxs(\"p\",{className:\"text-2xl font-bold text-white leading-none\",children:[A>0?(P/A).toFixed(0):0,\"g\"]})]}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[e,A>0?(N/A).toFixed(2):\"0.00\",\" avg\"]})]})]}),C.length>0?a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray mb-4\",children:i(\"stats.usageOverTime\")}),a.jsx(Gh,{width:\"100%\",height:250,children:a.jsxs(a7e,{data:C,children:[a.jsx(\"defs\",{children:a.jsxs(\"linearGradient\",{id:\"colorFilament\",x1:\"0\",y1:\"0\",x2:\"0\",y2:\"1\",children:[a.jsx(\"stop\",{offset:\"5%\",stopColor:\"#00ae42\",stopOpacity:.3}),a.jsx(\"stop\",{offset:\"95%\",stopColor:\"#00ae42\",stopOpacity:0})]})}),a.jsx(Uf,{strokeDasharray:\"3 3\",stroke:\"#3d3d3d\"}),a.jsx(Bf,{dataKey:\"dateLabel\",stroke:\"#9ca3af\",tick:{fontSize:12},interval:\"preserveStartEnd\"}),a.jsx(Hf,{stroke:\"#9ca3af\",tick:{fontSize:12},tickFormatter:F=>`${F}g`}),a.jsx(Wh,{contentStyle:{backgroundColor:\"#2d2d2d\",border:\"1px solid #3d3d3d\",borderRadius:\"8px\"},labelStyle:{color:\"#fff\"},formatter:F=>[`${Number(F??0).toFixed(0)}g`,\"Filament\"]}),a.jsx(Cae,{type:\"monotone\",dataKey:\"filament\",stroke:\"#00ae42\",strokeWidth:2,fillOpacity:1,fill:\"url(#colorFilament)\"})]})})]}):a.jsx(\"div\",{className:\"bg-bambu-dark rounded-lg p-8 text-center text-bambu-gray\",children:i(\"stats.noPrintDataInRange\")}),a.jsxs(\"div\",{className:\"grid grid-cols-1 lg:grid-cols-3 gap-4\",children:[a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-4\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray\",children:i(\"stats.byMaterial\")}),a.jsx(LP,{value:s,onChange:o})]}),_.length>0?a.jsxs(\"div\",{className:\"flex items-center gap-4\",children:[a.jsx(Gh,{width:160,height:160,children:a.jsxs(GW,{children:[a.jsx(dL,{data:_,cx:\"50%\",cy:\"50%\",innerRadius:40,outerRadius:70,paddingAngle:2,dataKey:\"value\",children:_.map((F,k)=>a.jsx(oy,{fill:Ek[k%Ek.length]},`cell-${k}`))}),a.jsx(Wh,{contentStyle:{backgroundColor:\"#2d2d2d\",border:\"1px solid #3d3d3d\",borderRadius:\"8px\"},formatter:F=>[s===\"weight\"?Bh(Number(F??0)):s===\"time\"?`${Number(F??0)}h`:`${F??0}`,s===\"weight\"?\"Usage\":s===\"time\"?\"Time\":\"Prints\"]})]})}),a.jsx(\"div\",{className:\"flex-1 space-y-2 overflow-hidden\",children:_.map((F,k)=>{const D=_.reduce((z,Q)=>z+Q.value,0),H=D>0?(F.value/D*100).toFixed(0):0;return a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(\"div\",{className:\"w-3 h-3 rounded-sm flex-shrink-0\",style:{backgroundColor:Ek[k%Ek.length]}}),a.jsx(\"span\",{className:\"text-white truncate flex-1\",children:F.name}),a.jsxs(\"span\",{className:\"text-bambu-gray flex-shrink-0\",children:[s===\"weight\"?Bh(F.value):s===\"time\"?`${F.value}h`:F.value,\" · \",H,\"%\"]})]},F.name)})})]}):a.jsx(\"div\",{className:\"h-[160px] flex items-center justify-center text-bambu-gray\",children:i(\"stats.noFilamentData\")})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray mb-4\",children:i(\"stats.filamentSuccess\")}),b.length>0?a.jsx(\"div\",{className:\"space-y-1.5\",children:b.map(F=>a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(\"span\",{className:\"text-white truncate w-20 flex-shrink-0\",children:F.name}),a.jsx(\"div\",{className:\"flex-1 h-1.5 bg-bambu-dark-secondary rounded-full\",children:a.jsx(\"div\",{className:`h-full rounded-full transition-all ${F.rate>=90?\"bg-status-ok\":F.rate>=70?\"bg-status-warning\":\"bg-status-error\"}`,style:{width:`${F.rate}%`}})}),a.jsxs(\"span\",{className:`font-medium flex-shrink-0 tabular-nums ${F.rate>=90?\"text-status-ok\":F.rate>=70?\"text-status-warning\":\"text-status-error\"}`,children:[F.rate,\"%\"]}),a.jsxs(\"span\",{className:\"text-bambu-gray flex-shrink-0 text-xs\",children:[\"(\",F.total,\")\"]})]},F.name))}):a.jsx(\"div\",{className:\"h-[160px] flex items-center justify-center text-bambu-gray\",children:i(\"stats.noArchiveData\")})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-4\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray\",children:i(\"stats.colorDistribution\")}),a.jsx(LP,{value:l,onChange:c,exclude:[\"time\"]})]}),g.length>0?(()=>{const F=g.reduce((k,D)=>k+D.value,0);return a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"relative mx-auto\",style:{width:160,height:160},children:[a.jsx(Gh,{width:\"100%\",height:\"100%\",children:a.jsxs(GW,{children:[a.jsx(dL,{data:g,cx:\"50%\",cy:\"50%\",innerRadius:45,outerRadius:70,paddingAngle:2,dataKey:\"value\",children:g.map((k,D)=>a.jsx(oy,{fill:k.hex,stroke:\"#1a1a1a\",strokeWidth:1},`color-${D}`))}),a.jsx(Wh,{contentStyle:{backgroundColor:\"#2d2d2d\",border:\"1px solid #3d3d3d\",borderRadius:\"8px\"},formatter:k=>[l===\"weight\"?Bh(Number(k??0)):`${k??0}`,i(l===\"weight\"?\"stats.filamentByWeight\":\"stats.filamentByPrints\")]})]})}),a.jsxs(\"div\",{className:\"absolute inset-0 flex flex-col items-center justify-center\",children:[a.jsx(\"span\",{className:\"text-lg font-bold text-white\",children:l===\"weight\"?Bh(F):F}),a.jsxs(\"span\",{className:\"text-[10px] text-bambu-gray\",children:[g.length,\" \",g.length===1?\"color\":\"colors\"]})]})]}),a.jsx(\"div\",{className:\"grid grid-cols-2 gap-x-3 gap-y-1 mt-2\",children:g.slice(0,8).map(k=>{const D=F>0?(k.value/F*100).toFixed(0):0;return a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-xs min-w-0\",children:[a.jsx(\"div\",{className:\"w-2.5 h-2.5 rounded-full flex-shrink-0 border border-black/20\",style:{backgroundColor:k.hex}}),a.jsxs(\"span\",{className:\"text-bambu-gray truncate\",children:[D,\"%\"]})]},k.hex)})}),g.length>8&&a.jsxs(\"p\",{className:\"text-[10px] text-bambu-gray mt-1 text-center\",children:[\"+\",g.length-8,\" more\"]})]})})():a.jsx(\"div\",{className:\"h-[160px] flex items-center justify-center text-bambu-gray\",children:i(\"stats.noColorData\")})]})]})]})}function Rtt({id:t,title:e,component:n,isHidden:r,size:i,columnSpan:s,onToggleVisibility:o,onToggleSize:l}){const{attributes:c,listeners:d,setNodeRef:u,transform:m,transition:p,isDragging:f}=Mle({id:t}),y={transform:fy.Transform.toString(m),transition:p,opacity:f?.5:1};return r?null:a.jsxs(\"div\",{ref:u,style:{...y,gridColumn:`span ${s}`},className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden ${f?\"ring-2 ring-bambu-green shadow-lg\":\"\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/30\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"button\",{...c,...d,className:\"cursor-grab active:cursor-grabbing p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\",title:\"Drag to reorder\",children:a.jsx(np,{className:\"w-6 h-6 md:w-4 md:h-4 text-bambu-gray\"})}),a.jsx(\"h3\",{className:\"text-sm font-medium text-white\",children:e})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"button\",{onClick:l,className:\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\",title:`Size: ${i===1?\"1/4\":i===2?\"1/2\":\"Full\"} - Click to cycle`,children:i===4?a.jsx(DO,{className:\"w-4 h-4 text-bambu-gray hover:text-white\"}):a.jsx(VP,{className:\"w-4 h-4 text-bambu-gray hover:text-white\"})}),a.jsx(\"button\",{onClick:o,className:\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\",title:\"Hide widget\",children:a.jsx(Og,{className:\"w-4 h-4 text-bambu-gray hover:text-white\"})})]})]}),a.jsx(\"div\",{className:\"p-4\",children:typeof n==\"function\"?n(i):n})]})}function Ltt({widgets:t,storageKey:e,columns:n=4,stackBelow:r,hideControls:i=!1,onResetLayout:s,renderControls:o}){const l=()=>{const F={};return t.forEach(k=>{F[k.id]=k.defaultSize||4}),F},[c,d]=w.useState(()=>{const F=localStorage.getItem(e);if(F)try{const k=JSON.parse(F);if(!k.sizes)k.sizes=l();else{const D=l();for(const H in D)H in k.sizes||(k.sizes[H]=D[H])}return k}catch{}return{order:t.map(k=>k.id),hidden:t.filter(k=>k.defaultVisible===!1).map(k=>k.id),sizes:l()}}),[u,m]=w.useState(!1),[p,f]=w.useState(!1);w.useEffect(()=>{if(!r)return;const F=window.matchMedia(`(max-width: ${r}px)`),k=H=>{f(H.matches)};k(F);const D=H=>k(H);return F.addEventListener?F.addEventListener(\"change\",D):F.addListener(D),()=>{F.removeEventListener?F.removeEventListener(\"change\",D):F.removeListener(D)}},[r]);const y=r&&p?1:n;w.useEffect(()=>{const F=()=>m(k=>!k);return window.addEventListener(\"toggle-hidden-panel\",F),()=>window.removeEventListener(\"toggle-hidden-panel\",F)},[]),w.useEffect(()=>{localStorage.setItem(e,JSON.stringify(c))},[c,e]),w.useEffect(()=>{const k=t.map(D=>D.id).filter(D=>!c.order.includes(D));k.length>0&&d(D=>({...D,order:[...D.order,...k]}))},[t,c.order]);const v=cle(DP(xA,{activationConstraint:{distance:8}}),DP(bA,{coordinateGetter:Ele})),b=F=>{const{active:k,over:D}=F;D&&k.id!==D.id&&d(H=>{const z=H.order.indexOf(k.id),Q=H.order.indexOf(D.id);return{...H,order:wA(H.order,z,Q)}})},g=F=>{d(k=>({...k,hidden:k.hidden.includes(F)?k.hidden.filter(D=>D!==F):[...k.hidden,F]}))},_=F=>{d(k=>{const D=k.sizes[F]||4,H=D===1?2:D===2?4:1;return{...k,sizes:{...k.sizes,[F]:H}}})},C=()=>{const F={order:t.map(k=>k.id),hidden:t.filter(k=>k.defaultVisible===!1).map(k=>k.id),sizes:l()};d(F),s?.()},P=c.order.map(F=>t.find(k=>k.id===F)).filter(Boolean),N=P.filter(F=>!c.hidden.includes(F.id)),A=P.filter(F=>c.hidden.includes(F.id)),T=o?.({hiddenCount:A.length,showHiddenPanel:u,setShowHiddenPanel:m,resetLayout:C});return a.jsxs(\"div\",{className:\"space-y-4\",children:[T,!i&&!o&&a.jsxs(\"div\",{className:\"flex items-center justify-end gap-2\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:C,children:[a.jsx(co,{className:\"w-4 h-4\"}),\"Reset Layout\"]}),A.length>0&&a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>m(!u),children:[a.jsx(yl,{className:\"w-4 h-4\"}),A.length,\" Hidden\"]})]}),u&&A.length>0&&a.jsxs(\"div\",{className:\"p-4 bg-bambu-dark rounded-xl border border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-3\",children:\"Hidden widgets (click to show):\"}),a.jsx(\"div\",{className:\"flex flex-wrap gap-2\",children:A.map(F=>a.jsxs(\"button\",{onClick:()=>g(F.id),className:\"px-3 py-1.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded-lg text-sm text-white transition-colors flex items-center gap-2\",children:[a.jsx(yl,{className:\"w-3 h-3\"}),F.title]},F.id))})]}),a.jsx(Cle,{sensors:v,collisionDetection:hle,onDragEnd:b,children:a.jsx(Ale,{items:N.map(F=>F.id),strategy:g5,children:a.jsx(\"div\",{className:\"grid gap-6\",style:{gridTemplateColumns:`repeat(${y}, minmax(0, 1fr))`},children:N.map(F=>{const k=c.sizes[F.id]||2,D=Math.min(k,y);return a.jsx(Rtt,{id:F.id,title:F.title,component:F.component,isHidden:c.hidden.includes(F.id),size:k,columnSpan:D,onToggleVisibility:()=>g(F.id),onToggleSize:()=>_(F.id)},F.id)})})})}),N.length===0&&a.jsxs(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:[a.jsx(\"p\",{children:\"All widgets are hidden.\"}),a.jsx(De,{className:\"mt-4\",onClick:C,children:\"Reset Layout\"})]})]})}function Ott(t){const e=new Date,n=e.getUTCFullYear(),r=e.getUTCMonth(),i=e.getUTCDate(),s=l=>l.toISOString().split(\"T\")[0],o=s(e);switch(t){case\"today\":return{dateFrom:o,dateTo:o};case\"this-week\":{const l=e.getUTCDay(),c=new Date(Date.UTC(n,r,i-(l===0?6:l-1)));return{dateFrom:s(c),dateTo:o}}case\"this-month\":return{dateFrom:s(new Date(Date.UTC(n,r,1))),dateTo:o};case\"last-7\":return{dateFrom:s(new Date(Date.UTC(n,r,i-6))),dateTo:o};case\"last-30\":return{dateFrom:s(new Date(Date.UTC(n,r,i-29))),dateTo:o};case\"last-90\":return{dateFrom:s(new Date(Date.UTC(n,r,i-89))),dateTo:o};case\"this-year\":return{dateFrom:s(new Date(Date.UTC(n,0,1))),dateTo:o};case\"all-time\":return{dateFrom:void 0,dateTo:void 0};case\"custom\":return{}}}const Itt=[\"today\",\"this-week\",\"this-month\",\"last-7\",\"last-30\",\"last-90\",\"this-year\",\"all-time\"],ztt=[\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\",\"Sun\"],oO=[\"12am\",\"1am\",\"2am\",\"3am\",\"4am\",\"5am\",\"6am\",\"7am\",\"8am\",\"9am\",\"10am\",\"11am\",\"12pm\",\"1pm\",\"2pm\",\"3pm\",\"4pm\",\"5pm\",\"6pm\",\"7pm\",\"8pm\",\"9pm\",\"10pm\",\"11pm\"],_3=[{key:\"<30m\",max:1800},{key:\"30m-1h\",max:3600},{key:\"1-2h\",max:7200},{key:\"2-4h\",max:14400},{key:\"4-8h\",max:28800},{key:\"8-12h\",max:43200},{key:\"12-24h\",max:86400},{key:\"24h+\",max:1/0}],Dk={backgroundColor:\"#2d2d2d\",border:\"1px solid #3d3d3d\",borderRadius:\"8px\"};function Utt({stats:t,currency:e}){const{t:n}=Ft(),r=t?.energy_data_warming_up===!0,i=r?n(\"stats.energyWarmingUpTooltip\"):void 0,s=[{icon:Ra,color:\"text-bambu-green\",label:n(\"stats.totalPrints\"),value:`${t?.total_prints||0}`},{icon:Yn,color:\"text-blue-400\",label:n(\"stats.printTime\"),value:`${t?.total_print_time_hours?.toFixed(1)??\"0\"}h`},{icon:Ra,color:\"text-orange-400\",label:n(\"stats.filamentUsed\"),value:Bh(t?.total_filament_grams||0)},{icon:xN,color:\"text-green-400\",label:n(\"stats.filamentCost\"),value:`${e} ${t?.total_cost?.toFixed(2)??\"0.00\"}`},{icon:dc,color:\"text-yellow-400\",label:n(\"stats.energyUsed\"),value:`${t?.total_energy_kwh?.toFixed(3)??\"0.000\"} kWh`,warning:r,tooltip:i},{icon:xN,color:\"text-yellow-500\",label:n(\"stats.energyCost\"),value:`${e} ${t?.total_energy_cost?.toFixed(2)??\"0.00\"}`,warning:r,tooltip:i}];return a.jsx(\"div\",{className:\"grid grid-cols-2 sm:grid-cols-3 gap-4\",children:s.map(o=>a.jsxs(\"div\",{className:\"flex items-start gap-3\",title:o.tooltip,children:[a.jsx(\"div\",{className:`p-2 rounded-lg bg-bambu-dark ${o.color}`,children:a.jsx(o.icon,{className:\"w-5 h-5\"})}),a.jsxs(\"div\",{children:[a.jsxs(\"p\",{className:\"text-xs text-bambu-gray flex items-center gap-1\",children:[o.label,o.warning&&a.jsx(Dn,{className:\"w-3 h-3 text-yellow-400\",\"aria-label\":o.tooltip})]}),a.jsx(\"p\",{className:\"text-xl font-bold text-white\",children:o.value})]})]},o.label))})}function Btt({stats:t,printerMap:e,size:n=1}){const{t:r}=Ft(),i=(t?.successful_prints||0)+(t?.failed_prints||0),s=i?Math.round(t.successful_prints/i*100):0,o=n===1?112:n===2?128:144,l=o/2-8,c=l*2*Math.PI;return a.jsxs(\"div\",{className:\"flex items-center gap-6\",children:[a.jsxs(\"div\",{className:\"relative flex-shrink-0\",style:{width:o,height:o},children:[a.jsxs(\"svg\",{className:\"w-full h-full -rotate-90\",children:[a.jsx(\"circle\",{cx:o/2,cy:o/2,r:l,fill:\"none\",stroke:\"#3d3d3d\",strokeWidth:\"10\"}),a.jsx(\"circle\",{cx:o/2,cy:o/2,r:l,fill:\"none\",stroke:\"#00ae42\",strokeWidth:\"10\",strokeLinecap:\"round\",strokeDasharray:`${s/100*c} ${c}`})]}),a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center\",children:a.jsxs(\"span\",{className:`font-bold text-white ${n>=2?\"text-2xl\":\"text-xl\"}`,children:[s,\"%\"]})})]}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(yr,{className:\"w-4 h-4 text-status-ok flex-shrink-0\"}),a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:r(\"stats.successful\")}),a.jsx(\"span\",{className:\"text-sm text-white font-medium\",children:t?.successful_prints||0})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Ma,{className:\"w-4 h-4 text-status-error flex-shrink-0\"}),a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:r(\"stats.failed\")}),a.jsx(\"span\",{className:\"text-sm text-white font-medium\",children:t?.failed_prints||0})]})]}),n>=2&&t?.prints_by_printer&&Object.keys(t.prints_by_printer).length>0&&a.jsxs(\"div\",{className:\"mt-4 pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray font-medium mb-2\",children:r(\"stats.printsByPrinter\")}),a.jsx(\"div\",{className:`grid gap-x-6 gap-y-1 ${n===4?\"grid-cols-3\":\"grid-cols-2\"}`,style:{width:\"fit-content\"},children:Object.entries(t.prints_by_printer).map(([d,u])=>a.jsxs(\"div\",{className:\"flex items-center gap-3 text-sm\",children:[a.jsx(\"span\",{className:\"text-bambu-gray truncate max-w-[120px]\",children:e.get(d)||`${r(\"common.printer\")} ${d}`}),a.jsx(\"span\",{className:\"text-white font-medium\",children:u})]},d))})]})]})]})}function Htt({stats:t,printerMap:e,size:n=1}){const{t:r}=Ft(),i=t?.average_time_accuracy;if(i==null)return a.jsx(\"div\",{className:\"flex items-center justify-center h-full\",children:a.jsx(\"p\",{className:\"text-bambu-gray text-center py-4\",children:r(\"stats.noTimeAccuracyData\")})});const o=(Math.min(150,Math.max(50,i))-50)/100*100,c=(v=>v>=95&&v<=105?\"#00ae42\":v>105?\"#3b82f6\":\"#f97316\")(i),d=i-100,u=n===1?112:n===2?128:144,m=u/2-8,p=m*2*Math.PI,f=n===1?3:n===2?6:999,y=t?.time_accuracy_by_printer?Object.entries(t.time_accuracy_by_printer).slice(0,f):[];return a.jsxs(\"div\",{className:\"flex items-center gap-6\",children:[a.jsxs(\"div\",{className:\"relative flex-shrink-0\",style:{width:u,height:u},children:[a.jsxs(\"svg\",{className:\"w-full h-full -rotate-90\",children:[a.jsx(\"circle\",{cx:u/2,cy:u/2,r:m,fill:\"none\",stroke:\"#3d3d3d\",strokeWidth:\"10\"}),a.jsx(\"circle\",{cx:u/2,cy:u/2,r:m,fill:\"none\",stroke:c,strokeWidth:\"10\",strokeLinecap:\"round\",strokeDasharray:`${o/100*p} ${p}`})]}),a.jsxs(\"div\",{className:\"absolute inset-0 flex flex-col items-center justify-center\",children:[a.jsxs(\"span\",{className:`font-bold text-white ${n>=2?\"text-2xl\":\"text-xl\"}`,children:[i.toFixed(0),\"%\"]}),a.jsxs(\"span\",{className:`text-xs ${d>=0?\"text-blue-400\":\"text-orange-400\"}`,children:[d>=0?\"+\":\"\",d.toFixed(0),\"%\"]})]})]}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-xs text-bambu-gray\",children:[a.jsx(GQ,{className:\"w-3 h-3 flex-shrink-0\"}),a.jsx(\"span\",{children:r(\"stats.perfectEstimate\")})]}),y.length>0&&a.jsx(\"div\",{className:`mt-2 ${n===4?\"grid grid-cols-3 gap-x-6 gap-y-1\":n===2?\"grid grid-cols-2 gap-x-6 gap-y-1\":\"space-y-1\"}`,style:{width:\"fit-content\"},children:y.map(([v,b])=>a.jsxs(\"div\",{className:\"flex items-center gap-2 text-xs\",children:[a.jsx(\"span\",{className:\"text-bambu-gray truncate max-w-[100px]\",children:e.get(v)||`${r(\"common.printer\")} ${v}`}),a.jsxs(\"span\",{className:`font-medium ${b>=95&&b<=105?\"text-status-ok\":b>105?\"text-blue-400\":\"text-status-warning\"}`,children:[b.toFixed(0),\"%\"]})]},v))})]})]})}function qtt({printDates:t,dateFrom:e,dateTo:n}){const{days:r,hourlyCounts:i,maxCount:s}=w.useMemo(()=>{const u=new Date(e+\"T00:00:00\"),m=new Date(n+\"T00:00:00\"),p=[],f=g=>`${g.getFullYear()}-${String(g.getMonth()+1).padStart(2,\"0\")}-${String(g.getDate()).padStart(2,\"0\")}`,y=new Date(u);for(;y<=m;)p.push({key:f(y),label:y.toLocaleDateString(void 0,{weekday:\"short\",month:\"short\",day:\"numeric\"})}),y.setDate(y.getDate()+1);const v={};let b=0;return t.forEach(g=>{const _=Zn(g);if(!_)return;const P=`${`${_.getFullYear()}-${String(_.getMonth()+1).padStart(2,\"0\")}-${String(_.getDate()).padStart(2,\"0\")}`}-${_.getHours()}`;v[P]=(v[P]||0)+1,v[P]>b&&(b=v[P])}),{days:p,hourlyCounts:v,maxCount:Math.max(1,b)}},[t,e,n]),o=u=>{if(u===0)return\"bg-bambu-dark\";const m=u/s;return m<=.25?\"bg-bambu-green/30\":m<=.5?\"bg-bambu-green/50\":m<=.75?\"bg-bambu-green/75\":\"bg-bambu-green\"},l=20,c=2,d=80;return a.jsxs(\"div\",{className:\"w-full overflow-x-auto\",children:[a.jsxs(\"div\",{className:\"inline-flex flex-col\",style:{gap:c},children:[a.jsx(\"div\",{className:\"flex\",style:{gap:c,marginLeft:d+4},children:oO.map((u,m)=>a.jsx(\"div\",{className:\"text-bambu-gray text-[10px] text-center\",style:{width:l,visibility:m%2===0?\"visible\":\"hidden\"},children:u},m))}),r.map(u=>a.jsxs(\"div\",{className:\"flex items-center\",style:{gap:c},children:[a.jsx(\"div\",{className:\"text-bambu-gray text-[10px] flex-shrink-0 truncate\",style:{width:d},children:u.label}),Array.from({length:24},(m,p)=>{const f=i[`${u.key}-${p}`]||0;return a.jsx(\"div\",{className:`rounded-sm ${o(f)}`,style:{width:l,height:l},title:`${u.label} ${oO[p]}: ${f} print${f!==1?\"s\":\"\"}`},p)})]},u.key))]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-3 text-bambu-gray text-xs\",children:[a.jsx(\"span\",{children:\"Less\"}),a.jsxs(\"div\",{className:\"flex\",style:{gap:c},children:[a.jsx(\"div\",{className:\"rounded-sm bg-bambu-dark\",style:{width:l,height:l}}),a.jsx(\"div\",{className:\"rounded-sm bg-bambu-green/30\",style:{width:l,height:l}}),a.jsx(\"div\",{className:\"rounded-sm bg-bambu-green/50\",style:{width:l,height:l}}),a.jsx(\"div\",{className:\"rounded-sm bg-bambu-green/75\",style:{width:l,height:l}}),a.jsx(\"div\",{className:\"rounded-sm bg-bambu-green\",style:{width:l,height:l}})]}),a.jsx(\"span\",{children:\"More\"})]})]})}function $tt({printDates:t,size:e=2,dateFrom:n,dateTo:r}){const i=w.useMemo(()=>n&&r?Math.max((new Date(r).getTime()-new Date(n).getTime())/864e5,0)+1:n?Math.max((Date.now()-new Date(n).getTime())/864e5,0)+1:1/0,[n,r]);if(i<=7&&n&&r)return a.jsx(qtt,{printDates:t,dateFrom:n,dateTo:r});const o=i===1/0?e===1?3:e===2?6:12:Math.max(1,Math.ceil(i/30));return a.jsx(Ett,{printDates:t,months:o})}function Vtt({stats:t,archives:e,printerMap:n}){const{t:r}=Ft(),[i,s]=w.useState(\"weight\"),[o,l]=w.useState(\"weight\"),c=w.useMemo(()=>{const g=new Map;return t?.prints_by_printer&&Object.entries(t.prints_by_printer).forEach(([_,C])=>{const P=g.get(_)||{prints:0,weight:0,time:0};P.prints=C,g.set(_,P)}),e.forEach(_=>{if(!_.printer_id)return;const C=String(_.printer_id),P=g.get(C)||{prints:0,weight:0,time:0};P.weight+=_.filament_used_grams||0,P.time+=_.actual_time_seconds||_.print_time_seconds||0,t?.prints_by_printer||P.prints++,g.set(C,P)}),Array.from(g.entries()).map(([_,C])=>({name:n.get(_)||`${r(\"common.printer\")} ${_}`,value:i===\"prints\"?C.prints:i===\"weight\"?Math.round(C.weight):Math.round(C.time/3600*10)/10})).sort((_,C)=>C.value-_.value)},[t,e,n,i,r]),d=w.useMemo(()=>{const g=Array.from({length:24},(_,C)=>({hour:C,label:oO[C],total:0,failures:0}));return e.forEach(_=>{if(!_.started_at)return;const C=Zn(_.started_at);if(!C)return;const P=C.getHours();g[P].total++,_.status===\"failed\"&&g[P].failures++}),g},[e]),u=w.useMemo(()=>{const g=_3.map(_=>({name:_.key,count:0}));return e.forEach(_=>{const C=_.actual_time_seconds||_.print_time_seconds;if(!(!C||C<=0)){for(let P=0;P<_3.length;P++)if(C<=_3[P].max){g[P].count++;break}}}),g},[e]),m=w.useMemo(()=>{const g=[0,0,0,0,0,0,0],_=new Set;e.forEach(P=>{const N=Zn(P.created_at)||new Date(P.created_at);let A=N.getDay()-1;A<0&&(A=6),o===\"prints\"?g[A]++:o===\"weight\"?g[A]+=P.filament_used_grams||0:g[A]+=(P.actual_time_seconds||P.print_time_seconds||0)/3600;const T=new Date(N);T.setDate(N.getDate()-(N.getDay()+6)%7),_.add(`${T.getFullYear()}-${String(T.getMonth()+1).padStart(2,\"0\")}-${String(T.getDate()).padStart(2,\"0\")}`)});const C=Math.max(_.size,1);return ztt.map((P,N)=>({name:P,avg:Math.round(g[N]/C*10)/10}))},[e,o]),p=g=>({unit:g===\"weight\"?\"g\":g===\"time\"?\"h\":\"\",color:g===\"weight\"?\"#00ae42\":g===\"time\"?\"#3b82f6\":\"#f59e0b\"}),f=p(i),y=r(i===\"weight\"?\"stats.filamentByWeight\":i===\"time\"?\"stats.hours\":\"common.prints\"),v=p(o),b=r(o===\"weight\"?\"stats.avgWeight\":o===\"time\"?\"stats.avgTime\":\"stats.avgPrints\");return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray\",children:r(\"stats.printsByPrinter\")}),a.jsx(LP,{value:i,onChange:s})]}),c.length>0?a.jsx(Gh,{width:\"100%\",height:Math.max(140,c.length*40),children:a.jsxs(fk,{data:c,layout:\"vertical\",margin:{left:10},children:[a.jsx(Uf,{strokeDasharray:\"3 3\",stroke:\"#3d3d3d\"}),a.jsx(Bf,{type:\"number\",stroke:\"#9ca3af\",tick:{fontSize:11},unit:f.unit}),a.jsx(Hf,{type:\"category\",dataKey:\"name\",stroke:\"#9ca3af\",tick:{fontSize:11},width:100}),a.jsx(Wh,{contentStyle:Dk,formatter:g=>[i===\"weight\"?Bh(Number(g??0)):`${g??0}${f.unit}`,y]}),a.jsx(px,{dataKey:\"value\",fill:f.color,radius:[0,4,4,0]})]})}):a.jsx(\"p\",{className:\"text-bambu-gray text-center py-4\",children:r(\"stats.noPrinterData\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 lg:grid-cols-3 gap-4\",children:[a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray mb-3\",children:r(\"stats.printDuration\")}),e.length>0?a.jsx(Gh,{width:\"100%\",height:160,children:a.jsxs(fk,{data:u,children:[a.jsx(Uf,{strokeDasharray:\"3 3\",stroke:\"#3d3d3d\"}),a.jsx(Bf,{dataKey:\"name\",stroke:\"#9ca3af\",tick:{fontSize:11}}),a.jsx(Hf,{stroke:\"#9ca3af\",tick:{fontSize:11},allowDecimals:!1}),a.jsx(Wh,{contentStyle:Dk}),a.jsx(px,{dataKey:\"count\",name:r(\"common.prints\"),fill:\"#00ae42\",radius:[4,4,0,0]})]})}):a.jsx(\"p\",{className:\"text-bambu-gray text-center py-4\",children:r(\"stats.noArchiveData\")})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray\",children:r(\"stats.printHabits\")}),a.jsx(LP,{value:o,onChange:l})]}),e.length>0?a.jsx(Gh,{width:\"100%\",height:160,children:a.jsxs(fk,{data:m,children:[a.jsx(Uf,{strokeDasharray:\"3 3\",stroke:\"#3d3d3d\"}),a.jsx(Bf,{dataKey:\"name\",stroke:\"#9ca3af\",tick:{fontSize:11}}),a.jsx(Hf,{stroke:\"#9ca3af\",tick:{fontSize:11},unit:v.unit}),a.jsx(Wh,{contentStyle:Dk,formatter:g=>[`${g??0}${v.unit}`,b]}),a.jsx(px,{dataKey:\"avg\",fill:v.color,radius:[4,4,0,0]})]})}):a.jsx(\"p\",{className:\"text-bambu-gray text-center py-4\",children:r(\"stats.noArchiveData\")})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-bambu-gray mb-3\",children:r(\"stats.printTimeOfDay\")}),e.length>0?a.jsx(Gh,{width:\"100%\",height:160,children:a.jsxs(fk,{data:d,children:[a.jsx(Uf,{strokeDasharray:\"3 3\",stroke:\"#3d3d3d\"}),a.jsx(Bf,{dataKey:\"label\",stroke:\"#9ca3af\",tick:{fontSize:10},interval:5}),a.jsx(Hf,{stroke:\"#9ca3af\",tick:{fontSize:11},allowDecimals:!1}),a.jsx(Wh,{contentStyle:Dk}),a.jsx(px,{dataKey:\"total\",name:r(\"stats.totalPrints\"),fill:\"#00ae42\",radius:[2,2,0,0]}),a.jsx(px,{dataKey:\"failures\",name:r(\"stats.failed\"),fill:\"#ef4444\",radius:[2,2,0,0]})]})}):a.jsx(\"p\",{className:\"text-bambu-gray text-center py-4\",children:r(\"stats.noArchiveData\")})]})]})]})}function Gtt({archives:t,currency:e,dateFrom:n,dateTo:r}){const{t:i}=Ft();return!t||t.length===0?a.jsx(\"p\",{className:\"text-bambu-gray text-center py-4\",children:i(\"stats.noPrintData\")}):a.jsx(Ftt,{archives:t,currency:e,dateFrom:n,dateTo:r})}function Wtt({size:t=1,dateFrom:e,dateTo:n,createdById:r}){const{t:i}=Ft(),s=!!(e||n),{data:o,isLoading:l}=Xe({queryKey:[\"failureAnalysis\",e,n,r??\"all\"],queryFn:()=>ue.getFailureAnalysis({...s?{dateFrom:e,dateTo:n}:{days:30},createdById:r})});if(l)return a.jsx(\"div\",{className:\"flex justify-center py-4\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})});if(!o||o.total_prints===0)return a.jsx(\"p\",{className:\"text-bambu-gray text-center py-4\",children:i(s?\"stats.noPrintDataInRange\":\"stats.noPrintDataLast30Days\")});const c=t===1?5:t===2?8:999,d=Object.entries(o.failures_by_reason).sort(([,p],[,f])=>f-p),u=d.slice(0,c),m=d.length>c;return a.jsxs(\"div\",{className:`${t>=2?\"flex gap-8\":\"space-y-4\"}`,children:[a.jsxs(\"div\",{className:t>=2?\"flex-shrink-0\":\"\",children:[a.jsx(\"div\",{className:\"flex items-center gap-4\",children:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Dn,{className:`w-5 h-5 ${o.failure_rate>20?\"text-status-error\":o.failure_rate>10?\"text-status-warning\":\"text-status-ok\"}`}),a.jsxs(\"span\",{className:`font-bold text-white ${t>=2?\"text-3xl\":\"text-2xl\"}`,children:[o.failure_rate.toFixed(1),\"%\"]})]})}),a.jsx(\"div\",{className:\"text-sm text-bambu-gray mt-1\",children:i(\"stats.failedPrintsCount\",{failed:o.failed_prints,total:o.total_prints})}),o.trend&&o.trend.length>=2&&a.jsx(\"div\",{className:`${t>=2?\"mt-4\":\"mt-2 pt-2 border-t border-bambu-dark-tertiary\"}`,children:a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(LO,{className:`w-4 h-4 ${o.trend[o.trend.length-1].failure_rate<o.trend[o.trend.length-2].failure_rate?\"text-status-ok\":\"text-status-error\"}`}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"stats.lastWeekRate\",{rate:o.trend[o.trend.length-1].failure_rate.toFixed(1)})})]})})]}),u.length>0&&a.jsxs(\"div\",{className:`flex-1 ${t>=2?\"border-l border-bambu-dark-tertiary pl-8\":\"pt-2\"}`,children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray font-medium mb-2\",children:t>=2?i(\"stats.failureReasons\"):i(\"stats.topFailureReasons\")}),a.jsx(\"div\",{className:`${t===4?\"grid grid-cols-2 gap-x-6 gap-y-1\":\"space-y-1\"}`,children:u.map(([p,f])=>a.jsxs(\"div\",{className:\"flex items-center justify-between text-sm\",children:[a.jsx(\"span\",{className:`text-white truncate ${t===4?\"max-w-[200px]\":\"max-w-[160px]\"}`,children:p||i(\"common.unknown\")}),a.jsx(\"span\",{className:\"text-bambu-gray ml-2\",children:f})]},p))}),m&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-2\",children:i(\"common.more\",{count:d.length-c})})]})]})}function Ktt({archives:t,currency:e}){const{t:n}=Ft(),r=w.useMemo(()=>{const i=[];if(t.length===0)return i;const s=y=>{let v=null,b=0;return t.forEach(g=>{const _=y(g);_&&_>b&&(b=_,v=g)}),{archive:v,value:b}},o=s(y=>y.actual_time_seconds);o.archive&&i.push({icon:Yn,iconColor:\"text-blue-400\",label:n(\"stats.longestPrint\"),value:ws(o.value),detail:o.archive.print_name||null});const l=s(y=>y.filament_used_grams);l.archive&&i.push({icon:Ra,iconColor:\"text-orange-400\",label:n(\"stats.heaviestPrint\"),value:Bh(l.value),detail:l.archive.print_name||null});const c=s(y=>y.cost);c.archive&&i.push({icon:xN,iconColor:\"text-green-400\",label:n(\"stats.mostExpensivePrint\"),value:`${e}${c.value.toFixed(2)}`,detail:c.archive.print_name||null});const d=new Map;t.forEach(y=>{const v=Zn(y.created_at)||new Date(y.created_at),b=`${v.getFullYear()}-${String(v.getMonth()+1).padStart(2,\"0\")}-${String(v.getDate()).padStart(2,\"0\")}`;d.set(b,(d.get(b)||0)+1)});let u=\"\",m=0;d.forEach((y,v)=>{y>m&&(m=y,u=v)}),m>1&&i.push({icon:oa,iconColor:\"text-purple-400\",label:n(\"stats.busiestDay\"),value:`${m} ${n(\"common.prints\")}`,detail:(()=>{const[y,v,b]=u.split(\"-\").map(Number);return new Date(y,v-1,b).toLocaleDateString(void 0,{month:\"short\",day:\"numeric\",year:\"numeric\"})})()});const p=[...t].filter(y=>y.status===\"completed\"||y.status===\"failed\").sort((y,v)=>new Date(v.completed_at||v.created_at).getTime()-new Date(y.completed_at||y.created_at).getTime());let f=0;for(const y of p)if(y.status===\"completed\")f++;else break;return f>0&&i.push({icon:dc,iconColor:\"text-yellow-400\",label:n(\"stats.successStreak\"),value:`${f}`,detail:f===1?n(\"stats.streakPrint\"):n(\"stats.streakPrints\",{count:f})}),i},[t,e,n]);return r.length===0?a.jsx(\"p\",{className:\"text-bambu-gray text-center py-4\",children:n(\"stats.noArchiveData\")}):a.jsx(\"div\",{className:\"space-y-3\",children:r.map((i,s)=>a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`p-1.5 rounded-lg bg-bambu-dark ${i.iconColor}`,children:a.jsx(i.icon,{className:\"w-4 h-4\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i.label}),a.jsxs(\"div\",{className:\"flex items-baseline gap-2\",children:[a.jsx(\"span\",{className:\"text-sm font-bold text-white\",children:i.value}),i.detail&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray truncate\",children:i.detail})]})]})]},s))})}function Xtt(){const{t}=Ft(),{showToast:e}=hn(),{hasPermission:n,authEnabled:r}=kr(),[i,s]=w.useState(!1),[o,l]=w.useState(!1),[c,d]=w.useState(0),[u,m]=w.useState(0),[p,f]=w.useState(!1),[y,v]=w.useState(null),[b,g]=w.useState(!1),_=r&&n(\"stats:filter_by_user\"),[C,P]=w.useState(()=>{try{const Y=localStorage.getItem(\"bambusy-stats-timeframe\");if(Y){const E=JSON.parse(Y);if(E.preset)return E}}catch{}return{preset:\"all-time\",dateFrom:void 0,dateTo:void 0}}),[N,A]=w.useState(!1);w.useEffect(()=>{localStorage.setItem(\"bambusy-stats-timeframe\",JSON.stringify(C))},[C]);const T=w.useMemo(()=>C.preset===\"custom\"?{dateFrom:C.dateFrom,dateTo:C.dateTo}:Ott(C.preset),[C]);w.useEffect(()=>{const Y=()=>{try{const j=localStorage.getItem(\"bambusy-dashboard-layout-v2\");if(j){const O=JSON.parse(j);m(O.hidden?.length||0)}}catch{m(0)}};Y(),window.addEventListener(\"storage\",Y);const E=setInterval(Y,2e3);return()=>{window.removeEventListener(\"storage\",Y),clearInterval(E)}},[c]);const F=y!==null?y:void 0,{data:k,isLoading:D,isFetching:H,refetch:z}=Xe({queryKey:[\"archiveStats\",T.dateFrom,T.dateTo,F??\"all\"],queryFn:()=>ue.getArchiveStats({dateFrom:T.dateFrom,dateTo:T.dateTo,createdById:F})}),{data:Q}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:L,isFetching:te,refetch:ie}=Xe({queryKey:[\"archivesSlim\",T.dateFrom,T.dateTo,F??\"all\"],queryFn:()=>ue.getArchivesSlim(T.dateFrom,T.dateTo,F)}),{data:J}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),{data:oe}=Xe({queryKey:[\"users\"],queryFn:ue.getUsers,enabled:_}),fe=w.useMemo(()=>y===null?t(\"stats.allUsers\",\"All Users\"):y===-1?t(\"stats.noUser\",\"No User (System)\"):oe?.find(Y=>Y.id===y)?.username??\"?\",[y,oe,t]),re=async Y=>{l(!1),s(!0);try{const{blob:E,filename:j}=await ue.exportStats({format:Y,days:90,createdById:F}),O=URL.createObjectURL(E),K=document.createElement(\"a\");K.href=O,K.download=j,K.click(),URL.revokeObjectURL(O),e(t(\"stats.exportDownloaded\"))}catch{e(t(\"stats.exportFailed\"),\"error\")}finally{s(!1)}},W=async()=>{f(!0);try{const Y=await ue.recalculateCosts();await Promise.all([z(),ie()]),e(t(\"stats.recalculatedCosts\",{count:Y.updated}))}catch{e(t(\"stats.recalculateFailed\"),\"error\")}finally{f(!1)}},ne=(H||te)&&!D,me=Eo(J?.currency||\"USD\"),be=new Map(Q?.map(Y=>[String(Y.id),Y.name])||[]),Ce=w.useMemo(()=>L?.map(Y=>Y.created_at)||[],[L]);if(D)return a.jsx(\"div\",{className:\"p-4 md:p-8\",children:a.jsx(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:t(\"stats.loadingStats\")})});const q=[{id:\"quick-stats\",title:t(\"stats.quickStats\"),component:a.jsx(Utt,{stats:k,currency:me}),defaultSize:2},{id:\"success-rate\",title:t(\"stats.successRate\"),component:Y=>a.jsx(Btt,{stats:k,printerMap:be,size:Y}),defaultSize:1},{id:\"time-accuracy\",title:t(\"stats.timeAccuracy\"),component:Y=>a.jsx(Htt,{stats:k,printerMap:be,size:Y}),defaultSize:1},{id:\"failure-analysis\",title:t(\"stats.failureAnalysis\"),component:Y=>a.jsx(Wtt,{size:Y,dateFrom:T.dateFrom,dateTo:T.dateTo,createdById:F}),defaultSize:1},{id:\"print-activity\",title:t(\"stats.printActivity\"),component:Y=>a.jsx($tt,{printDates:Ce,size:Y,dateFrom:T.dateFrom,dateTo:T.dateTo}),defaultSize:2},{id:\"records\",title:t(\"stats.records\"),component:a.jsx(Ktt,{archives:L||[],currency:me}),defaultSize:1},{id:\"printer-stats\",title:t(\"stats.printerStats\"),component:a.jsx(Vtt,{stats:k,archives:L||[],printerMap:be}),defaultSize:4},{id:\"filament-trends\",title:t(\"stats.filamentTrends\"),component:a.jsx(Gtt,{archives:L||[],currency:me,dateFrom:T.dateFrom,dateTo:T.dateTo}),defaultSize:4}];return a.jsxs(\"div\",{className:\"p-4 md:p-8\",children:[a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:t(\"stats.title\")}),ne&&a.jsx(ft,{className:\"w-5 h-5 text-bambu-green animate-spin\"})]}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"stats.subtitle\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-wrap\",children:[u>0&&a.jsxs(De,{variant:\"secondary\",onClick:()=>{window.dispatchEvent(new CustomEvent(\"toggle-hidden-panel\"))},children:[a.jsx(yl,{className:\"w-4 h-4\"}),t(\"stats.hiddenCount\",{count:u})]}),a.jsxs(De,{variant:\"secondary\",onClick:()=>{localStorage.removeItem(\"bambusy-dashboard-layout-v2\"),d(Y=>Y+1),e(t(\"stats.layoutReset\"))},disabled:!n(\"settings:update\"),title:n(\"settings:update\")?void 0:t(\"stats.noPermissionResetLayout\"),children:[a.jsx(co,{className:\"w-4 h-4\"}),t(\"stats.resetLayout\")]}),a.jsxs(De,{variant:\"secondary\",onClick:W,disabled:p||!n(\"archives:update_all\"),title:n(\"archives:update_all\")?t(\"stats.recalculateCostsHint\"):t(\"stats.noPermissionRecalculate\"),children:[p?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Upe,{className:\"w-4 h-4\"}),t(\"stats.recalculateCosts\")]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(De,{variant:\"secondary\",onClick:()=>l(!o),disabled:i,children:[i?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(vN,{className:\"w-4 h-4\"}),t(\"stats.exportStats\")]}),o&&a.jsxs(\"div\",{className:\"absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20\",children:[a.jsxs(\"button\",{className:\"w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-t-lg\",onClick:()=>re(\"csv\"),children:[a.jsx(Us,{className:\"w-4 h-4\"}),t(\"stats.exportAsCsv\")]}),a.jsxs(\"button\",{className:\"w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg\",onClick:()=>re(\"xlsx\"),children:[a.jsx(vN,{className:\"w-4 h-4\"}),t(\"stats.exportAsExcel\")]})]})]}),_&&oe&&oe.length>0&&a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(De,{variant:\"secondary\",onClick:()=>g(!b),children:[a.jsx(Wu,{className:\"w-4 h-4\"}),fe,a.jsx(On,{className:\"w-3 h-3\"})]}),b&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>g(!1)}),a.jsxs(\"div\",{className:\"absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 p-2 max-h-64 overflow-y-auto\",children:[a.jsx(\"button\",{className:`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${y===null?\"bg-bambu-green text-white\":\"text-white hover:bg-bambu-dark-tertiary\"}`,onClick:()=>{v(null),g(!1)},children:t(\"stats.allUsers\",\"All Users\")}),a.jsx(\"button\",{className:`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${y===-1?\"bg-bambu-green text-white\":\"text-white hover:bg-bambu-dark-tertiary\"}`,onClick:()=>{v(-1),g(!1)},children:t(\"stats.noUser\",\"No User (System)\")}),a.jsx(\"div\",{className:\"border-t border-bambu-dark-tertiary my-1\"}),oe.map(Y=>a.jsx(\"button\",{className:`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${y===Y.id?\"bg-bambu-green text-white\":\"text-white hover:bg-bambu-dark-tertiary\"}`,onClick:()=>{v(Y.id),g(!1)},children:Y.username},Y.id))]})]})]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(De,{variant:\"secondary\",onClick:()=>A(!N),children:[a.jsx(oa,{className:\"w-4 h-4\"}),t(`stats.timeframe.${C.preset}`),a.jsx(On,{className:\"w-3 h-3\"})]}),N&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>A(!1)}),a.jsxs(\"div\",{className:\"absolute right-0 top-full mt-1 w-64 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 p-2\",children:[Itt.map(Y=>a.jsx(\"button\",{className:`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${C.preset===Y?\"bg-bambu-green text-white\":\"text-white hover:bg-bambu-dark-tertiary\"}`,onClick:()=>{P({preset:Y,dateFrom:void 0,dateTo:void 0}),A(!1)},children:t(`stats.timeframe.${Y}`)},Y)),a.jsx(\"div\",{className:\"border-t border-bambu-dark-tertiary my-2\"}),a.jsx(\"button\",{className:`w-full px-3 py-2 text-left text-sm rounded-md transition-colors ${C.preset===\"custom\"?\"bg-bambu-green text-white\":\"text-white hover:bg-bambu-dark-tertiary\"}`,onClick:()=>P(Y=>({...Y,preset:\"custom\"})),children:t(\"stats.timeframe.custom\")}),C.preset===\"custom\"&&a.jsxs(\"div\",{className:\"mt-2 px-1 pb-1 space-y-2\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray block mb-1\",children:t(\"stats.timeframe.from\")}),a.jsx(\"input\",{type:\"date\",value:C.dateFrom||\"\",max:C.dateTo||new Date().toISOString().split(\"T\")[0],onChange:Y=>P(E=>({...E,dateFrom:Y.target.value||void 0})),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray block mb-1\",children:t(\"stats.timeframe.to\")}),a.jsx(\"input\",{type:\"date\",value:C.dateTo||\"\",min:C.dateFrom,max:new Date().toISOString().split(\"T\")[0],onChange:Y=>P(E=>({...E,dateTo:Y.target.value||void 0})),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white [color-scheme:dark]\"})]}),a.jsx(De,{variant:\"primary\",onClick:()=>A(!1),className:\"w-full\",children:t(\"common.apply\")})]})]})]})]})]})]}),a.jsx(Ltt,{widgets:q,storageKey:\"bambusy-dashboard-layout-v2\",stackBelow:640,hideControls:!0},c)]})}function Ytt({plug:t,onEdit:e}){const{t:n}=Ft(),r=nn(),{showToast:i}=hn(),[s,o]=w.useState(!1),[l,c]=w.useState(!1),[d,u]=w.useState(!1),[m,p]=w.useState(!1),{data:f,isLoading:y}=Xe({queryKey:[\"smart-plug-status\",t.id],queryFn:()=>ue.getSmartPlugStatus(t.id),refetchInterval:3e4}),{data:v}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),b=v?.find(D=>D.id===t.printer_id),g=it({mutationFn:D=>ue.controlSmartPlug(t.id,D),onMutate:async D=>{await r.cancelQueries({queryKey:[\"smart-plug-status\",t.id]});const H=r.getQueryData([\"smart-plug-status\",t.id]),z=D===\"on\"?\"ON\":D===\"off\"||f?.state===\"ON\"?\"OFF\":\"ON\";return r.setQueryData([\"smart-plug-status\",t.id],Q=>({...Q,state:z})),{previousStatus:H}},onError:(D,H,z)=>{z?.previousStatus&&r.setQueryData([\"smart-plug-status\",t.id],z.previousStatus),i(n(\"smartPlugs.failedToTurn\",{action:H,name:t.name}),\"error\")},onSettled:()=>{setTimeout(()=>{r.invalidateQueries({queryKey:[\"smart-plug-status\",t.id]}),r.invalidateQueries({queryKey:[\"smart-plugs\"]})},1e3)}}),_=it({mutationFn:D=>ue.updateSmartPlug(t.id,D),onSuccess:()=>{r.invalidateQueries({queryKey:[\"smart-plugs\"]}),t.printer_id&&(r.invalidateQueries({queryKey:[\"smartPlugByPrinter\",t.printer_id]}),r.invalidateQueries({queryKey:[\"scriptPlugsByPrinter\",t.printer_id]}))}}),C=it({mutationFn:()=>ue.deleteSmartPlug(t.id),onSuccess:()=>{r.invalidateQueries({queryKey:[\"smart-plugs\"]}),t.printer_id&&r.invalidateQueries({queryKey:[\"scriptPlugsByPrinter\",t.printer_id]})}}),P=f?.state===\"ON\",N=t.plug_type===\"mqtt\"&&f?.energy?.power!==null&&f?.energy?.power!==void 0,A=(f?.reachable??!1)||N,T=g.isPending,k=(()=>{if(t.plug_type!==\"tasmota\"||!t.ip_address)return null;const D=t.ip_address;return t.username&&t.password?`http://${encodeURIComponent(t.username)}:${encodeURIComponent(t.password)}@${D}/`:`http://${D}/`})();return a.jsxs(a.Fragment,{children:[a.jsx(Tt,{className:\"relative\",children:a.jsxs(Mt,{className:\"p-4\",children:[a.jsxs(\"div\",{className:\"flex items-start justify-between gap-2 mb-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 min-w-0 flex-1\",children:[a.jsx(\"div\",{className:`p-2 rounded-lg flex-shrink-0 ${t.plug_type===\"mqtt\"?A?\"bg-teal-500/20\":\"bg-red-500/20\":A?P?\"bg-bambu-green/20\":\"bg-bambu-dark\":\"bg-red-500/20\"}`,children:t.plug_type===\"mqtt\"?a.jsx(RO,{className:`w-5 h-5 ${A?\"text-teal-400\":\"text-red-400\"}`}):t.plug_type===\"homeassistant\"?a.jsx(Jw,{className:`w-5 h-5 ${A?P?\"text-bambu-green\":\"text-bambu-gray\":\"text-red-400\"}`}):t.plug_type===\"rest\"?a.jsx(ul,{className:`w-5 h-5 ${A?P?\"text-bambu-green\":\"text-bambu-gray\":\"text-red-400\"}`}):a.jsx(nc,{className:`w-5 h-5 ${A?P?\"text-bambu-green\":\"text-bambu-gray\":\"text-red-400\"}`})}),a.jsxs(\"div\",{className:\"min-w-0\",children:[a.jsx(\"h3\",{className:\"font-medium text-white truncate\",children:t.name}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray truncate\",title:t.plug_type===\"mqtt\"?t.mqtt_topic??void 0:t.plug_type===\"homeassistant\"?t.ha_entity_id??void 0:t.plug_type===\"rest\"?t.rest_on_url??t.rest_off_url??void 0:t.ip_address??void 0,children:t.plug_type===\"mqtt\"?t.mqtt_topic:t.plug_type===\"homeassistant\"?t.ha_entity_id:t.plug_type===\"rest\"?t.rest_on_url||t.rest_off_url:t.ip_address})]})]}),a.jsxs(\"div\",{className:\"flex flex-col items-end gap-1 flex-shrink-0\",children:[y?a.jsx(ft,{className:\"w-4 h-4 text-bambu-gray animate-spin\"}):t.plug_type===\"mqtt\"?a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-sm whitespace-nowrap\",children:[a.jsx(\"span\",{className:\"px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0\",children:\"MQTT\"}),A&&a.jsx(\"span\",{className:\"text-status-ok\",children:\"✓\"})]}):t.plug_type===\"homeassistant\"?a.jsxs(\"div\",{className:\"flex items-center gap-1 text-sm\",children:[a.jsx(\"span\",{className:\"px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded\",children:\"HA\"}),a.jsx(\"span\",{className:A?P?\"text-status-ok\":\"text-bambu-gray\":\"text-status-error\",children:A?f?.state||\"?\":n(\"smartPlugs.offline\")})]}):t.plug_type===\"rest\"?a.jsxs(\"div\",{className:\"flex items-center gap-1 text-sm\",children:[a.jsx(\"span\",{className:\"px-1 py-0.5 bg-purple-500/20 text-purple-400 text-[10px] font-medium rounded\",children:\"REST\"}),a.jsx(\"span\",{className:A?P?\"text-status-ok\":\"text-bambu-gray\":\"text-status-error\",children:A?f?.state||\"?\":n(\"smartPlugs.offline\")})]}):A?a.jsxs(\"div\",{className:\"flex items-center gap-1 text-sm\",children:[a.jsx(rp,{className:\"w-4 h-4 text-status-ok\"}),a.jsx(\"span\",{className:P?\"text-status-ok\":\"text-bambu-gray\",children:f?.state||n(\"smartPlugs.unknown\")})]}):a.jsxs(\"div\",{className:\"flex items-center gap-1 text-sm text-status-error\",children:[a.jsx(Bd,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:n(\"smartPlugs.offline\")})]}),k&&a.jsxs(\"a\",{href:k,target:\"_blank\",rel:\"noopener noreferrer\",className:\"flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors\",title:n(\"smartPlugs.openPlugAdminPage\"),children:[a.jsx(la,{className:\"w-3 h-3\"}),n(\"smartPlugs.admin\")]})]})]}),b&&a.jsxs(\"div\",{className:\"mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[n(\"smartPlugs.linkedTo\"),\" \"]}),a.jsx(\"span\",{className:\"text-sm text-white\",children:b.name})]}),(t.power_alert_enabled||t.schedule_enabled||t.plug_type===\"mqtt\")&&a.jsxs(\"div\",{className:\"flex flex-wrap gap-1.5 mb-3\",children:[t.plug_type===\"mqtt\"&&a.jsxs(\"span\",{className:\"flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full\",children:[a.jsx(yl,{className:\"w-3 h-3\"}),n(\"smartPlugs.monitorOnly\")]}),t.power_alert_enabled&&a.jsxs(\"span\",{className:\"flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full\",children:[a.jsx(Zh,{className:\"w-3 h-3\"}),n(\"smartPlugs.alerts\")]}),t.schedule_enabled&&a.jsxs(\"span\",{className:\"flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full\",children:[a.jsx(oa,{className:\"w-3 h-3\"}),t.schedule_on_time&&t.schedule_off_time?`${t.schedule_on_time} - ${t.schedule_off_time}`:t.schedule_on_time?n(\"smartPlugs.scheduleOn\",{time:t.schedule_on_time}):n(\"smartPlugs.scheduleOff\",{time:t.schedule_off_time})]})]}),t.plug_type!==\"mqtt\"&&a.jsxs(\"div\",{className:\"flex gap-2 mb-3\",children:[a.jsxs(De,{size:\"sm\",variant:P?\"primary\":\"secondary\",disabled:!A||T,onClick:()=>c(!0),className:\"flex-1\",children:[T?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Yd,{className:\"w-4 h-4\"}),n(\"smartPlugs.on\")]}),a.jsxs(De,{size:\"sm\",variant:P?\"secondary\":\"primary\",disabled:!A||T,onClick:()=>u(!0),className:\"flex-1\",children:[T?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(aS,{className:\"w-4 h-4\"}),n(\"smartPlugs.off\")]})]}),t.plug_type===\"mqtt\"&&f?.energy&&a.jsxs(\"div\",{className:\"flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg\",children:[f.energy.power!==null&&f.energy.power!==void 0&&a.jsxs(\"div\",{className:\"flex-1 text-center\",children:[a.jsxs(\"p\",{className:\"text-lg font-semibold text-white\",children:[Math.round(f.energy.power),\"W\"]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.power\")})]}),f.energy.today!==null&&f.energy.today!==void 0&&a.jsxs(\"div\",{className:\"flex-1 text-center border-l border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-lg font-semibold text-white\",children:f.energy.today.toFixed(3)}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.kwhToday\")})]})]}),a.jsxs(\"button\",{onClick:()=>p(!m),className:\"w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors\",children:[a.jsxs(\"span\",{className:\"flex items-center gap-2\",children:[a.jsx(Ud,{className:\"w-4 h-4\"}),t.plug_type===\"mqtt\"?n(\"smartPlugs.settings\"):n(\"smartPlugs.automationSettings\")]}),a.jsx(\"span\",{children:m?\"-\":\"+\"})]}),m&&a.jsxs(\"div\",{className:\"pt-3 border-t border-bambu-dark-tertiary space-y-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(eS,{className:\"w-4 h-4 text-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"smartPlugs.showInSwitchbar\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.quickAccessSidebar\")})]})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:t.show_in_switchbar,onChange:D=>_.mutate({show_in_switchbar:D.target.checked}),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"})]})]}),t.plug_type!==\"mqtt\"&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"smartPlugs.enabled\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.enableAutomation\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:t.enabled,onChange:D=>_.mutate({enabled:D.target.checked}),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"smartPlugs.autoOn\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.autoOnDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:t.auto_on,onChange:D=>_.mutate({auto_on:D.target.checked}),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"smartPlugs.autoOff\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.autoOffDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:t.auto_off,onChange:D=>_.mutate({auto_off:D.target.checked}),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"})]})]}),t.auto_off&&a.jsxs(\"div\",{className:\"flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"smartPlugs.autoOffPersistent\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.autoOffPersistentDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:t.auto_off_persistent,onChange:D=>_.mutate({auto_off_persistent:D.target.checked}),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"})]})]}),t.auto_off&&a.jsxs(\"div\",{className:\"space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white mb-2\",children:n(\"smartPlugs.turnOffDelayMode\")}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"button\",{onClick:()=>_.mutate({off_delay_mode:\"time\"}),className:`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${t.off_delay_mode===\"time\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,children:[a.jsx(Yn,{className:\"w-4 h-4\"}),n(\"smartPlugs.time\")]}),a.jsxs(\"button\",{onClick:()=>_.mutate({off_delay_mode:\"temperature\"}),className:`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${t.off_delay_mode===\"temperature\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,children:[a.jsx(oS,{className:\"w-4 h-4\"}),n(\"smartPlugs.temp\")]})]})]}),t.off_delay_mode===\"time\"?a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:n(\"smartPlugs.delayMinutes\")}),a.jsx(\"input\",{type:\"number\",min:\"1\",max:\"60\",value:t.off_delay_minutes,onChange:D=>_.mutate({off_delay_minutes:parseInt(D.target.value)||5}),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"})]}):a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:n(\"smartPlugs.tempThreshold\")}),a.jsx(\"input\",{type:\"number\",min:\"30\",max:\"100\",value:t.off_temp_threshold,onChange:D=>_.mutate({off_temp_threshold:parseInt(D.target.value)||70}),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(\"smartPlugs.tempThresholdDescription\")})]})]})]}),a.jsxs(\"div\",{className:\"flex gap-2 pt-2\",children:[a.jsxs(De,{size:\"sm\",variant:\"secondary\",onClick:()=>e(t),className:\"flex-1\",children:[a.jsx(Qu,{className:\"w-4 h-4\"}),n(\"smartPlugs.edit\")]}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:()=>o(!0),className:\"text-red-400 hover:text-red-300\",children:a.jsx(en,{className:\"w-4 h-4\"})})]})]})]})}),s&&a.jsx(yn,{title:n(\"smartPlugs.deleteSmartPlug\"),message:n(\"smartPlugs.deleteConfirm\",{name:t.name}),confirmText:n(\"smartPlugs.delete\"),variant:\"danger\",onConfirm:()=>{C.mutate(),o(!1)},onCancel:()=>o(!1)}),l&&a.jsx(yn,{title:n(\"smartPlugs.turnOnSmartPlug\"),message:n(\"smartPlugs.turnOnConfirm\",{name:t.name}),confirmText:n(\"smartPlugs.turnOn\"),variant:\"default\",onConfirm:()=>{g.mutate(\"on\"),c(!1)},onCancel:()=>c(!1)}),d&&a.jsx(yn,{title:n(\"smartPlugs.turnOffSmartPlug\"),message:n(\"smartPlugs.turnOffConfirm\",{name:t.name}),confirmText:n(\"smartPlugs.turnOff\"),variant:\"danger\",onConfirm:()=>{g.mutate(\"off\"),u(!1)},onCancel:()=>u(!1)})]})}function Qtt({plug:t,onClose:e}){const{t:n}=Ft(),r=nn(),i=!!t,[s,o]=w.useState(t?.plug_type||\"tasmota\"),[l,c]=w.useState(t?.name||\"\"),[d,u]=w.useState(t?.ip_address||\"\"),[m,p]=w.useState(t?.username||\"\"),[f,y]=w.useState(t?.password||\"\"),[v,b]=w.useState(t?.ha_entity_id||\"\"),[g,_]=w.useState(t?.mqtt_power_topic||t?.mqtt_topic||\"\"),[C,P]=w.useState(t?.mqtt_power_path||\"\"),[N,A]=w.useState((t?.mqtt_power_multiplier??t?.mqtt_multiplier??1).toString()),[T,F]=w.useState(t?.mqtt_energy_topic||\"\"),[k,D]=w.useState(t?.mqtt_energy_path||\"\"),[H,z]=w.useState((t?.mqtt_energy_multiplier??1).toString()),[Q,L]=w.useState(t?.mqtt_state_topic||\"\"),[te,ie]=w.useState(t?.mqtt_state_path||\"\"),[J,oe]=w.useState(t?.mqtt_state_on_value||\"\"),[fe,re]=w.useState(t?.rest_on_url||\"\"),[W,ne]=w.useState(t?.rest_on_body||\"\"),[me,be]=w.useState(t?.rest_off_url||\"\"),[Ce,q]=w.useState(t?.rest_off_body||\"\"),[Y,E]=w.useState(t?.rest_method||\"POST\"),[j,O]=w.useState(t?.rest_headers||\"\"),[K,U]=w.useState(t?.rest_status_url||\"\"),[de,I]=w.useState(t?.rest_status_path||\"\"),[G,X]=w.useState(t?.rest_status_on_value||\"\"),[V,ee]=w.useState(t?.rest_power_url||\"\"),[se,ge]=w.useState(t?.rest_power_path||\"\"),[he,le]=w.useState((t?.rest_power_multiplier??1).toString()),[B,R]=w.useState(t?.rest_energy_url||\"\"),[ae,_e]=w.useState(t?.rest_energy_path||\"\"),[Se,ve]=w.useState((t?.rest_energy_multiplier??1).toString()),[Te,ye]=w.useState(t?.ha_power_entity||\"\"),[je,Le]=w.useState(t?.ha_energy_today_entity||\"\"),[Me,Oe]=w.useState(t?.ha_energy_total_entity||\"\"),[Re,$e]=w.useState(\"\"),[Ye,tt]=w.useState(\"\"),[pe,Fe]=w.useState(!1),we=w.useRef(null),[Ve,Ae]=w.useState(\"\"),[ce,xe]=w.useState(!1),Be=w.useRef(null),[Qe,ht]=w.useState(\"\"),[xt,gt]=w.useState(!1),Ut=w.useRef(null),[Wt,Zt]=w.useState(\"\"),[Kt,Xt]=w.useState(!1),ln=w.useRef(null),[vn,Ke]=w.useState(t?.printer_id||null),[at,Lt]=w.useState(null),[Et,At]=w.useState(null),[Ie,mt]=w.useState(t?.power_alert_enabled||!1),[pt,nt]=w.useState(t?.power_alert_high?.toString()||\"\"),[ze,ot]=w.useState(t?.power_alert_low?.toString()||\"\"),[Pe,Ge]=w.useState(t?.schedule_enabled||!1),[Ze,Je]=w.useState(t?.schedule_on_time||\"\"),[We,Ue]=w.useState(t?.schedule_off_time||\"\"),[et,jt]=w.useState(t?.show_in_switchbar||!1),[yt,qe]=w.useState(t?.show_on_printer_card??!0),[St,Pt]=w.useState(!1),[qt,on]=w.useState({scanned:0,total:0}),[dn,Nn]=w.useState([]),bn=w.useRef(null),{data:un}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:wn}=Xe({queryKey:[\"smart-plugs\"],queryFn:ue.getSmartPlugs}),{data:pn}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),gr=!!(pn?.ha_enabled&&pn?.ha_url&&pn?.ha_token);w.useEffect(()=>{const vt=setTimeout(()=>{tt(Re)},300);return()=>clearTimeout(vt)},[Re]),w.useEffect(()=>{const vt=Kn=>{we.current&&!we.current.contains(Kn.target)&&Fe(!1),Be.current&&!Be.current.contains(Kn.target)&&xe(!1),Ut.current&&!Ut.current.contains(Kn.target)&&gt(!1),ln.current&&!ln.current.contains(Kn.target)&&Xt(!1)};return document.addEventListener(\"mousedown\",vt),()=>document.removeEventListener(\"mousedown\",vt)},[]);const{data:Nr,isLoading:Fn,error:Ba}=Xe({queryKey:[\"ha-entities\",Ye],queryFn:()=>ue.getHAEntities(Ye||void 0),enabled:s===\"homeassistant\"&&gr,retry:!1,staleTime:0}),{data:In}=Xe({queryKey:[\"ha-sensor-entities\"],queryFn:ue.getHASensorEntities,enabled:s===\"homeassistant\"&&gr,retry:!1,staleTime:0});w.useEffect(()=>{const vt=Kn=>{Kn.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",vt),()=>{window.removeEventListener(\"keydown\",vt),bn.current&&clearInterval(bn.current)}},[e]);const ma=async()=>{Pt(!0),Nn([]),on({scanned:0,total:0}),At(null);try{await ue.startTasmotaScan();const vt=async()=>{try{const Kn=await ue.getTasmotaScanStatus();on({scanned:Kn.scanned,total:Kn.total});const dr=await ue.getDiscoveredTasmotaDevices();Nn(dr),Kn.running||(Pt(!1),bn.current&&(clearInterval(bn.current),bn.current=null))}catch(Kn){console.error(\"Polling error:\",Kn)}};await vt(),bn.current=setInterval(vt,500)}catch(vt){Pt(!1);const Kn=vt instanceof Error?vt.message:typeof vt==\"string\"?vt:JSON.stringify(vt);At(Kn||n(\"smartPlugs.failedToStartScan\"))}},ra=async()=>{try{await ue.stopTasmotaScan()}catch{}Pt(!1),bn.current&&(clearInterval(bn.current),bn.current=null)},Fr=vt=>{u(vt.ip_address),c(vt.name),Lt(null)},Br=it({mutationFn:()=>ue.testSmartPlugConnection(d,m||null,f||null),onSuccess:vt=>{Lt(vt),At(null),!l&&vt.device_name&&c(vt.device_name)},onError:vt=>{Lt(null),At(vt.message)}}),cr=it({mutationFn:vt=>ue.createSmartPlug(vt),onSuccess:()=>{r.invalidateQueries({queryKey:[\"smart-plugs\"]}),r.invalidateQueries({queryKey:[\"scriptPlugsByPrinter\"]}),e()},onError:vt=>{At(vt.message)}}),Ci=it({mutationFn:vt=>ue.updateSmartPlug(t.id,vt),onSuccess:()=>{r.invalidateQueries({queryKey:[\"smart-plugs\"]}),r.invalidateQueries({queryKey:[\"scriptPlugsByPrinter\"]}),e()},onError:vt=>{At(vt.message)}}),Ui=un?.filter(vt=>s===\"tasmota\"?!wn?.some(dr=>dr.printer_id===vt.id&&dr.id!==t?.id&&dr.plug_type===\"tasmota\"):!0),vc=vt=>{if(vt.preventDefault(),At(null),!l.trim()){At(n(\"smartPlugs.nameRequired\"));return}if(s===\"tasmota\"&&!d.trim()){At(\"IP address is required for Tasmota plugs\");return}if(s===\"homeassistant\"&&!v){At(n(\"smartPlugs.entityRequired\"));return}if(s===\"mqtt\"){const dr=g.trim(),$t=T.trim(),Ks=Q.trim();if(!dr&&!$t&&!Ks){At(n(\"smartPlugs.mqttTopicRequired\"));return}}if(s===\"rest\"&&!fe.trim()&&!me.trim()){At(n(\"smartPlugs.restUrlRequired\"));return}const Kn={name:l.trim(),plug_type:s,ip_address:s===\"tasmota\"?d.trim():null,ha_entity_id:s===\"homeassistant\"?v:null,ha_power_entity:s===\"homeassistant\"&&Te||null,ha_energy_today_entity:s===\"homeassistant\"&&je||null,ha_energy_total_entity:s===\"homeassistant\"&&Me||null,mqtt_power_topic:s===\"mqtt\"&&g.trim()||null,mqtt_power_path:s===\"mqtt\"&&C.trim()||null,mqtt_power_multiplier:s===\"mqtt\"&&parseFloat(N)||1,mqtt_energy_topic:s===\"mqtt\"&&T.trim()||null,mqtt_energy_path:s===\"mqtt\"&&k.trim()||null,mqtt_energy_multiplier:s===\"mqtt\"&&parseFloat(H)||1,mqtt_state_topic:s===\"mqtt\"&&Q.trim()||null,mqtt_state_path:s===\"mqtt\"&&te.trim()||null,mqtt_state_on_value:s===\"mqtt\"&&J.trim()||null,rest_on_url:s===\"rest\"&&fe.trim()||null,rest_on_body:s===\"rest\"&&W.trim()||null,rest_off_url:s===\"rest\"&&me.trim()||null,rest_off_body:s===\"rest\"&&Ce.trim()||null,rest_method:s===\"rest\"?Y:null,rest_headers:s===\"rest\"&&j.trim()||null,rest_status_url:s===\"rest\"&&K.trim()||null,rest_status_path:s===\"rest\"&&de.trim()||null,rest_status_on_value:s===\"rest\"&&G.trim()||null,rest_power_url:s===\"rest\"&&V.trim()||null,rest_power_path:s===\"rest\"&&se.trim()||null,rest_power_multiplier:s===\"rest\"&&parseFloat(he)||1,rest_energy_url:s===\"rest\"&&B.trim()||null,rest_energy_path:s===\"rest\"&&ae.trim()||null,rest_energy_multiplier:s===\"rest\"&&parseFloat(Se)||1,username:s===\"tasmota\"&&m.trim()||null,password:s===\"tasmota\"&&f.trim()||null,printer_id:vn,power_alert_enabled:Ie,power_alert_high:pt?parseFloat(pt):null,power_alert_low:ze?parseFloat(ze):null,schedule_enabled:Pe,schedule_on_time:Ze||null,schedule_off_time:We||null,show_in_switchbar:et,show_on_printer_card:yt};i?Ci.mutate(Kn):cr.mutate(Kn)},_l=cr.isPending||Ci.isPending;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:e,children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col\",onClick:vt=>vt.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary flex-shrink-0\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:n(i?\"smartPlugs.editTitle\":\"smartPlugs.addTitle\")}),a.jsx(\"button\",{onClick:e,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"form\",{onSubmit:vc,className:\"p-6 space-y-4 overflow-y-auto\",children:[Et&&a.jsx(\"div\",{className:\"p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\",children:Et}),!i&&a.jsxs(\"div\",{className:\"flex gap-2 mb-2\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>{o(\"tasmota\"),Lt(null),At(null)},className:`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${s===\"tasmota\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary\"}`,children:[a.jsx(nc,{className:\"w-4 h-4\"}),\"Tasmota\"]}),a.jsxs(\"button\",{type:\"button\",onClick:()=>{o(\"homeassistant\"),Lt(null),At(null)},className:`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${s===\"homeassistant\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary\"}`,children:[a.jsx(Jw,{className:\"w-4 h-4\"}),\"HA\"]}),a.jsxs(\"button\",{type:\"button\",onClick:()=>{o(\"mqtt\"),Lt(null),At(null)},className:`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${s===\"mqtt\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary\"}`,children:[a.jsx(RO,{className:\"w-4 h-4\"}),\"MQTT\"]}),a.jsxs(\"button\",{type:\"button\",onClick:()=>{o(\"rest\"),Lt(null),At(null)},className:`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${s===\"rest\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary\"}`,children:[a.jsx(ul,{className:\"w-4 h-4\"}),\"REST\"]})]}),!i&&s===\"tasmota\"&&a.jsxs(\"div\",{className:\"space-y-3\",children:[St?a.jsxs(De,{type:\"button\",variant:\"secondary\",onClick:ra,className:\"w-full\",children:[a.jsx(Ht,{className:\"w-4 h-4\"}),n(\"smartPlugs.stopScanning\")]}):a.jsxs(De,{type:\"button\",variant:\"primary\",onClick:ma,className:\"w-full\",children:[a.jsx(Pr,{className:\"w-4 h-4\"}),n(\"smartPlugs.discoverTasmota\")]}),St&&qt.total>0&&a.jsxs(\"div\",{className:\"space-y-1\",children:[a.jsxs(\"div\",{className:\"flex justify-between text-xs text-bambu-gray\",children:[a.jsx(\"span\",{children:n(\"smartPlugs.addSmartPlug.scanningNetwork\")}),a.jsxs(\"span\",{children:[qt.scanned,\" / \",qt.total]})]}),a.jsx(\"div\",{className:\"w-full bg-bambu-dark-tertiary rounded-full h-2\",children:a.jsx(\"div\",{className:\"bg-bambu-green h-2 rounded-full transition-all duration-300\",style:{width:`${qt.scanned/qt.total*100}%`}})})]}),dn.length>0&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.foundDevices\",{count:dn.length})}),a.jsx(\"div\",{className:\"max-h-40 overflow-y-auto space-y-1\",children:dn.map(vt=>a.jsxs(\"button\",{type:\"button\",onClick:()=>Fr(vt),className:\"w-full flex items-center justify-between p-2 bg-bambu-dark hover:bg-bambu-dark-tertiary rounded-lg transition-colors text-left border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(nc,{className:\"w-4 h-4 text-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:vt.name}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:vt.ip_address})]})]}),vt.state&&a.jsxs(\"span\",{className:`flex items-center gap-1 text-xs ${vt.state===\"ON\"?\"text-bambu-green\":\"text-bambu-gray\"}`,children:[a.jsx(Yd,{className:\"w-3 h-3\"}),vt.state]})]},vt.ip_address))})]}),!St&&dn.length===0&&qt.total>0&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray text-center py-2\",children:n(\"smartPlugs.noDevicesFound\")})]}),s===\"homeassistant\"&&a.jsxs(\"div\",{className:\"space-y-3\",children:[!gr&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400\",children:[n(\"smartPlugs.haNotConfigured\"),\" \",a.jsx(\"span\",{className:\"font-medium\",children:n(\"smartPlugs.haSettingsPath\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1 opacity-50\",children:n(\"smartPlugs.selectEntity\")}),a.jsx(\"select\",{disabled:!0,className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed opacity-50\",children:a.jsx(\"option\",{children:n(\"smartPlugs.addSmartPlug.chooseEntity\")})})]})]}),gr&&a.jsxs(a.Fragment,{children:[Fn&&a.jsxs(\"div\",{className:\"flex items-center justify-center py-4 text-bambu-gray\",children:[a.jsx(ft,{className:\"w-5 h-5 animate-spin mr-2\"}),n(\"smartPlugs.loadingEntities\")]}),Ba&&a.jsx(\"div\",{className:\"p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\",children:n(\"smartPlugs.failedToLoadEntities\",{error:Ba.message})}),(()=>{const vt=wn?.filter($t=>$t.ha_entity_id&&$t.id!==t?.id).map($t=>$t.ha_entity_id)||[],Kn=(Nr||[]).filter($t=>!vt.includes($t.entity_id)),dr=Nr?.find($t=>$t.entity_id===v);return a.jsxs(\"div\",{ref:we,className:\"relative\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.selectEntity\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"}),a.jsx(\"input\",{type:\"text\",value:pe?Re:dr?`${dr.friendly_name} (${dr.entity_id})`:\"\",onChange:$t=>{$e($t.target.value),pe||Fe(!0)},onFocus:()=>{Fe(!0),$e(\"\")},placeholder:n(\"smartPlugs.addSmartPlug.placeholders.searchEntities\"),className:\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"}),v&&!pe&&a.jsx(\"button\",{type:\"button\",onClick:()=>{b(\"\"),$e(\"\")},className:\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded\",children:a.jsx(Ht,{className:\"w-4 h-4 text-bambu-gray hover:text-white\"})}),Fn&&a.jsx(ft,{className:\"absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray animate-spin\"})]}),pe&&a.jsxs(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-60 overflow-y-auto\",children:[Fn&&a.jsxs(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray flex items-center gap-2\",children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),n(\"smartPlugs.loading\")]}),!Fn&&Kn.length===0&&a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:Ye?n(\"smartPlugs.noEntitiesMatching\",{search:Ye}):n(\"smartPlugs.noEntitiesAvailable\")}),!Fn&&Kn.map($t=>a.jsxs(\"button\",{type:\"button\",onClick:()=>{b($t.entity_id),Fe(!1),$e(\"\"),l||c($t.friendly_name)},className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary transition-colors ${$t.entity_id===v?\"bg-bambu-green/20 text-bambu-green\":\"text-white\"}`,children:[a.jsx(\"div\",{className:\"font-medium\",children:$t.friendly_name}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray flex items-center justify-between\",children:[a.jsx(\"span\",{children:$t.entity_id}),a.jsx(\"span\",{className:$t.state===\"on\"?\"text-bambu-green\":\"\",children:$t.state})]})]},$t.entity_id))]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:Ye?n(\"smartPlugs.searchingEntities\",{count:Kn.length}):n(\"smartPlugs.showingEntities\",{count:Kn.length})})]})})(),v&&In&&In.length>0&&a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium mb-1\",children:n(\"smartPlugs.energyMonitoringOptional\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-3\",children:n(\"smartPlugs.energyMonitoringHint\")})]}),(()=>{const vt=In.filter($t=>$t.unit_of_measurement===\"W\"||$t.unit_of_measurement===\"kW\"||$t.unit_of_measurement===\"mW\"),Kn=Ve?vt.filter($t=>$t.entity_id.toLowerCase().includes(Ve.toLowerCase())||$t.friendly_name.toLowerCase().includes(Ve.toLowerCase())):vt,dr=In.find($t=>$t.entity_id===Te);return a.jsxs(\"div\",{ref:Be,className:\"relative\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.powerSensorW\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"}),a.jsx(\"input\",{type:\"text\",value:ce?Ve:dr?`${dr.friendly_name} (${dr.state} ${dr.unit_of_measurement})`:\"\",onChange:$t=>{Ae($t.target.value),ce||xe(!0)},onFocus:()=>{xe(!0),Ae(\"\")},placeholder:n(\"smartPlugs.addSmartPlug.placeholders.searchPowerSensors\"),className:\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"}),Te&&!ce&&a.jsx(\"button\",{type:\"button\",onClick:()=>{ye(\"\"),Ae(\"\")},className:\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded\",children:a.jsx(Ht,{className:\"w-4 h-4 text-bambu-gray hover:text-white\"})})]}),ce&&a.jsxs(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\",children:[a.jsx(\"button\",{type:\"button\",onClick:()=>{ye(\"\"),xe(!1),Ae(\"\")},className:\"w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary\",children:n(\"smartPlugs.none\")}),Kn.map($t=>a.jsxs(\"button\",{type:\"button\",onClick:()=>{ye($t.entity_id),xe(!1),Ae(\"\")},className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${$t.entity_id===Te?\"bg-bambu-green/20 text-bambu-green\":\"text-white\"}`,children:[a.jsx(\"div\",{className:\"font-medium\",children:$t.friendly_name}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray\",children:[$t.entity_id,\" • \",$t.state,\" \",$t.unit_of_measurement]})]},$t.entity_id)),Kn.length===0&&a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:n(\"smartPlugs.noMatchingSensors\")})]})]})})(),(()=>{const vt=In.filter($t=>$t.unit_of_measurement===\"kWh\"||$t.unit_of_measurement===\"Wh\"||$t.unit_of_measurement===\"MWh\"),Kn=Qe?vt.filter($t=>$t.entity_id.toLowerCase().includes(Qe.toLowerCase())||$t.friendly_name.toLowerCase().includes(Qe.toLowerCase())):vt,dr=In.find($t=>$t.entity_id===je);return a.jsxs(\"div\",{ref:Ut,className:\"relative\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.energyTodayKwh\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"}),a.jsx(\"input\",{type:\"text\",value:xt?Qe:dr?`${dr.friendly_name} (${dr.state} ${dr.unit_of_measurement})`:\"\",onChange:$t=>{ht($t.target.value),xt||gt(!0)},onFocus:()=>{gt(!0),ht(\"\")},placeholder:n(\"smartPlugs.addSmartPlug.placeholders.searchEnergySensors\"),className:\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"}),je&&!xt&&a.jsx(\"button\",{type:\"button\",onClick:()=>{Le(\"\"),ht(\"\")},className:\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded\",children:a.jsx(Ht,{className:\"w-4 h-4 text-bambu-gray hover:text-white\"})})]}),xt&&a.jsxs(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\",children:[a.jsx(\"button\",{type:\"button\",onClick:()=>{Le(\"\"),gt(!1),ht(\"\")},className:\"w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary\",children:n(\"smartPlugs.none\")}),Kn.map($t=>a.jsxs(\"button\",{type:\"button\",onClick:()=>{Le($t.entity_id),gt(!1),ht(\"\")},className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${$t.entity_id===je?\"bg-bambu-green/20 text-bambu-green\":\"text-white\"}`,children:[a.jsx(\"div\",{className:\"font-medium\",children:$t.friendly_name}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray\",children:[$t.entity_id,\" • \",$t.state,\" \",$t.unit_of_measurement]})]},$t.entity_id)),Kn.length===0&&a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:n(\"smartPlugs.noMatchingSensors\")})]})]})})(),(()=>{const vt=In.filter($t=>$t.unit_of_measurement===\"kWh\"||$t.unit_of_measurement===\"Wh\"||$t.unit_of_measurement===\"MWh\"),Kn=Wt?vt.filter($t=>$t.entity_id.toLowerCase().includes(Wt.toLowerCase())||$t.friendly_name.toLowerCase().includes(Wt.toLowerCase())):vt,dr=In.find($t=>$t.entity_id===Me);return a.jsxs(\"div\",{ref:ln,className:\"relative\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.totalEnergyKwh\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"}),a.jsx(\"input\",{type:\"text\",value:Kt?Wt:dr?`${dr.friendly_name} (${dr.state} ${dr.unit_of_measurement})`:\"\",onChange:$t=>{Zt($t.target.value),Kt||Xt(!0)},onFocus:()=>{Xt(!0),Zt(\"\")},placeholder:n(\"smartPlugs.addSmartPlug.placeholders.searchEnergySensors\"),className:\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"}),Me&&!Kt&&a.jsx(\"button\",{type:\"button\",onClick:()=>{Oe(\"\"),Zt(\"\")},className:\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded\",children:a.jsx(Ht,{className:\"w-4 h-4 text-bambu-gray hover:text-white\"})})]}),Kt&&a.jsxs(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\",children:[a.jsx(\"button\",{type:\"button\",onClick:()=>{Oe(\"\"),Xt(!1),Zt(\"\")},className:\"w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary\",children:n(\"smartPlugs.none\")}),Kn.map($t=>a.jsxs(\"button\",{type:\"button\",onClick:()=>{Oe($t.entity_id),Xt(!1),Zt(\"\")},className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${$t.entity_id===Me?\"bg-bambu-green/20 text-bambu-green\":\"text-white\"}`,children:[a.jsx(\"div\",{className:\"font-medium\",children:$t.friendly_name}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray\",children:[$t.entity_id,\" • \",$t.state,\" \",$t.unit_of_measurement]})]},$t.entity_id)),Kn.length===0&&a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:n(\"smartPlugs.noMatchingSensors\")})]})]})})()]})]})]}),s===\"mqtt\"&&a.jsxs(\"div\",{className:\"space-y-3\",children:[!pn?.mqtt_broker&&a.jsxs(\"div\",{className:\"p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400\",children:[n(\"smartPlugs.mqttNotConfigured\"),\" \",a.jsx(\"span\",{className:\"font-medium\",children:n(\"smartPlugs.mqttSettingsPath\")}),\" \",n(\"smartPlugs.mqttNotConfiguredSuffix\")]}),pn?.mqtt_broker&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-300\",children:[a.jsx(\"p\",{className:\"font-medium mb-1\",children:n(\"smartPlugs.monitorOnly\")}),a.jsx(\"p\",{className:\"text-xs opacity-80\",children:n(\"smartPlugs.mqttMonitorOnlyDescription\")})]}),a.jsxs(\"div\",{className:\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-white font-medium text-sm\",children:n(\"smartPlugs.powerMonitoring\")}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.topic\")}),a.jsx(\"input\",{type:\"text\",value:g,onChange:vt=>_(vt.target.value),placeholder:\"zigbee2mqtt/shelly-working-room\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.jsonPath\")}),a.jsx(\"input\",{type:\"text\",value:C,onChange:vt=>P(vt.target.value),placeholder:\"power_l1\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.multiplier\")}),a.jsx(\"input\",{type:\"text\",value:N,onChange:vt=>A(vt.target.value),placeholder:\"1\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",style:{whiteSpace:\"pre-line\"},children:n(\"smartPlugs.mqttPowerHint\")})]}),a.jsxs(\"div\",{className:\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"p\",{className:\"text-white font-medium text-sm\",children:[n(\"smartPlugs.energyMonitoring\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.topic\")}),a.jsx(\"input\",{type:\"text\",value:T,onChange:vt=>F(vt.target.value),placeholder:\"Same as power topic, or different\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.jsonPath\")}),a.jsx(\"input\",{type:\"text\",value:k,onChange:vt=>D(vt.target.value),placeholder:\"energy_l1\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.multiplier\")}),a.jsx(\"input\",{type:\"text\",value:H,onChange:vt=>z(vt.target.value),placeholder:\"1\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",style:{whiteSpace:\"pre-line\"},children:n(\"smartPlugs.mqttEnergyHint\")})]}),a.jsxs(\"div\",{className:\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"p\",{className:\"text-white font-medium text-sm\",children:[n(\"smartPlugs.stateMonitoring\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.topic\")}),a.jsx(\"input\",{type:\"text\",value:Q,onChange:vt=>L(vt.target.value),placeholder:\"Same as power topic, or different\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.jsonPath\")}),a.jsx(\"input\",{type:\"text\",value:te,onChange:vt=>ie(vt.target.value),placeholder:\"state_l1\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.onValue\")}),a.jsx(\"input\",{type:\"text\",value:J,onChange:vt=>oe(vt.target.value),placeholder:n(\"smartPlugs.addSmartPlug.placeholders.mqttStateOnValue\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",style:{whiteSpace:\"pre-line\"},children:n(\"smartPlugs.mqttStateHint\")})]})]})]}),s===\"rest\"&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-white font-medium text-sm\",children:n(\"smartPlugs.restControl\")}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restMethod\")}),a.jsxs(\"select\",{value:Y,onChange:vt=>E(vt.target.value),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"GET\",children:\"GET\"}),a.jsx(\"option\",{value:\"POST\",children:\"POST\"}),a.jsx(\"option\",{value:\"PUT\",children:\"PUT\"}),a.jsx(\"option\",{value:\"PATCH\",children:\"PATCH\"})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restOnUrl\")}),a.jsx(\"input\",{type:\"text\",value:fe,onChange:vt=>{re(vt.target.value),Lt(null)},placeholder:\"http://openhab:8080/rest/items/MyPlug\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[n(\"smartPlugs.restOnBody\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsx(\"input\",{type:\"text\",value:W,onChange:vt=>ne(vt.target.value),placeholder:n(\"smartPlugs.restBodyHint\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restOffUrl\")}),a.jsx(\"input\",{type:\"text\",value:me,onChange:vt=>{be(vt.target.value),Lt(null)},placeholder:\"http://openhab:8080/rest/items/MyPlug\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[n(\"smartPlugs.restOffBody\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsx(\"input\",{type:\"text\",value:Ce,onChange:vt=>q(vt.target.value),placeholder:n(\"smartPlugs.restBodyHint\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]})]}),a.jsxs(\"div\",{className:\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"p\",{className:\"text-white font-medium text-sm\",children:[n(\"smartPlugs.restHeaders\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsx(\"div\",{children:a.jsx(\"textarea\",{value:j,onChange:vt=>O(vt.target.value),placeholder:n(\"smartPlugs.restHeadersHint\"),rows:2,className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none font-mono text-sm\"})})]}),a.jsxs(\"div\",{className:\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"p\",{className:\"text-white font-medium text-sm\",children:[n(\"smartPlugs.stateMonitoring\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restStatusUrl\")}),a.jsx(\"input\",{type:\"text\",value:K,onChange:vt=>U(vt.target.value),placeholder:n(\"smartPlugs.restStatusHint\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restStatusPath\")}),a.jsx(\"input\",{type:\"text\",value:de,onChange:vt=>I(vt.target.value),placeholder:n(\"smartPlugs.restPathHint\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restStatusOnValue\")}),a.jsx(\"input\",{type:\"text\",value:G,onChange:vt=>X(vt.target.value),placeholder:\"ON\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]})]})]}),a.jsxs(\"div\",{className:\"space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"p\",{className:\"text-white font-medium text-sm\",children:[n(\"smartPlugs.energyMonitoring\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[n(\"smartPlugs.restPowerUrl\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsx(\"input\",{type:\"text\",value:V,onChange:vt=>ee(vt.target.value),placeholder:n(\"smartPlugs.restPowerUrlHint\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restPowerPath\")}),a.jsx(\"input\",{type:\"text\",value:se,onChange:vt=>ge(vt.target.value),placeholder:n(\"smartPlugs.restPathHint\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restPowerMultiplier\")}),a.jsx(\"input\",{type:\"text\",value:he,onChange:vt=>le(vt.target.value),placeholder:\"1\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[n(\"smartPlugs.restEnergyUrl\"),\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",n(\"smartPlugs.optional\"),\")\"]})]}),a.jsx(\"input\",{type:\"text\",value:B,onChange:vt=>R(vt.target.value),placeholder:n(\"smartPlugs.restEnergyUrlHint\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restEnergyPath\")}),a.jsx(\"input\",{type:\"text\",value:ae,onChange:vt=>_e(vt.target.value),placeholder:n(\"smartPlugs.restPathHint\"),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.restEnergyMultiplier\")}),a.jsx(\"input\",{type:\"text\",value:Se,onChange:vt=>ve(vt.target.value),placeholder:\"1\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.restEnergyHint\")})]}),(fe.trim()||me.trim())&&a.jsx(\"div\",{className:\"flex gap-2\",children:a.jsxs(De,{type:\"button\",variant:\"secondary\",onClick:async()=>{Lt(null);try{const vt=fe.trim()||me.trim(),Kn=await ue.testRESTConnection(vt,Y,j.trim()||null);Lt({success:Kn.success}),Kn.success||At(Kn.error||n(\"smartPlugs.addSmartPlug.connectionFailed\"))}catch{Lt({success:!1}),At(n(\"smartPlugs.addSmartPlug.connectionFailed\"))}},className:\"w-full\",children:[a.jsx(rp,{className:\"w-4 h-4\"}),n(\"smartPlugs.testConnection\")]})}),at&&a.jsxs(\"div\",{className:`flex items-center gap-2 text-sm ${at.success?\"text-green-400\":\"text-red-400\"}`,children:[at.success?a.jsx(yr,{className:\"w-4 h-4\"}):a.jsx(Bd,{className:\"w-4 h-4\"}),at.success?n(\"smartPlugs.connectionSuccess\"):n(\"smartPlugs.addSmartPlug.connectionFailed\")]})]}),s===\"tasmota\"&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.ipAddress\")}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"input\",{type:\"text\",value:d,onChange:vt=>{u(vt.target.value),Lt(null)},placeholder:\"192.168.1.100\",className:\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"}),a.jsxs(De,{type:\"button\",variant:\"secondary\",onClick:()=>Br.mutate(),disabled:!d.trim()||Br.isPending,children:[Br.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(rp,{className:\"w-4 h-4\"}),n(\"smartPlugs.test\")]})]})]}),s===\"tasmota\"&&at&&a.jsx(\"div\",{className:`p-3 rounded-lg flex items-center gap-2 ${at.success?\"bg-bambu-green/20 border border-bambu-green/50 text-bambu-green\":\"bg-red-500/20 border border-red-500/50 text-red-400\"}`,children:at.success?a.jsxs(a.Fragment,{children:[a.jsx(yr,{className:\"w-5 h-5\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"font-medium\",children:n(\"smartPlugs.connectedResult\")}),a.jsxs(\"p\",{className:\"text-sm opacity-80\",children:[at.device_name&&n(\"smartPlugs.deviceLabel\",{name:at.device_name}),n(\"smartPlugs.stateLabel\",{state:at.state})]})]})]}):a.jsxs(a.Fragment,{children:[a.jsx(Bd,{className:\"w-5 h-5\"}),a.jsx(\"span\",{children:n(\"smartPlugs.addSmartPlug.connectionFailed\")})]})}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.nameLabel\")}),a.jsx(\"input\",{type:\"text\",value:l,onChange:vt=>c(vt.target.value),placeholder:n(\"smartPlugs.addSmartPlug.placeholders.plugName\"),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\"})]}),s===\"tasmota\"&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.username\")}),a.jsx(\"input\",{type:\"text\",value:m,onChange:vt=>p(vt.target.value),placeholder:\"admin\",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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.password\")}),a.jsx(\"input\",{type:\"password\",value:f,onChange:vt=>y(vt.target.value),placeholder:\"********\",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\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray -mt-2\",children:n(\"smartPlugs.authHint\")})]}),s!==\"mqtt\"&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.linkToPrinter\")}),a.jsxs(\"select\",{value:vn??\"\",onChange:vt=>Ke(vt.target.value?Number(vt.target.value):null),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\",children:[a.jsx(\"option\",{value:\"\",children:n(\"smartPlugs.noPrinter\")}),Ui?.map(vt=>a.jsx(\"option\",{value:vt.id,children:vt.name},vt.id))]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(\"smartPlugs.linkingDescription\")})]}),a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Zh,{className:\"w-4 h-4 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-white font-medium\",children:n(\"smartPlugs.powerAlerts\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:Ie,onChange:vt=>mt(vt.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),Ie&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.alertAbove\")}),a.jsx(\"input\",{type:\"number\",value:pt,onChange:vt=>nt(vt.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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.alertBelow\")}),a.jsx(\"input\",{type:\"number\",value:ze,onChange:vt=>ot(vt.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\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.alertDescription\")})]})]}),s!==\"mqtt\"&&a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Yn,{className:\"w-4 h-4 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-white font-medium\",children:n(\"smartPlugs.dailySchedule\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:Pe,onChange:vt=>Ge(vt.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),Pe&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.turnOnAt\")}),a.jsx(\"input\",{type:\"time\",value:Ze,onChange:vt=>Je(vt.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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"smartPlugs.turnOffAt\")}),a.jsx(\"input\",{type:\"time\",value:We,onChange:vt=>Ue(vt.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\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.scheduleDescription\")})]})]}),a.jsx(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(eS,{className:\"w-4 h-4 text-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-white font-medium\",children:n(\"smartPlugs.showInSwitchbar\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.quickAccessSidebar\")})]})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:et,onChange:vt=>jt(vt.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]})}),s===\"homeassistant\"&&a.jsx(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(yl,{className:\"w-4 h-4 text-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-white font-medium\",children:n(\"smartPlugs.showOnPrinterCard\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"smartPlugs.displayOnPrinterCard\")})]})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:yt,onChange:vt=>qe(vt.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]})}),a.jsxs(\"div\",{className:\"flex gap-3 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:e,className:\"flex-1\",children:n(\"smartPlugs.cancel\")}),a.jsxs(De,{type:\"submit\",disabled:_l,className:\"flex-1\",children:[_l?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(_s,{className:\"w-4 h-4\"}),n(i?\"smartPlugs.save\":\"smartPlugs.add\")]})]})]})]})})}function Ln({checked:t,onChange:e,disabled:n}){const r=i=>{i.preventDefault(),i.stopPropagation(),n||e(!t)};return a.jsx(\"button\",{type:\"button\",role:\"switch\",\"aria-checked\":t,disabled:n,onClick:r,className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${n?\"bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50\":t?\"bg-bambu-green cursor-pointer\":\"bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80\"}`,children:a.jsx(\"span\",{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${t?\"translate-x-4\":\"translate-x-0\"}`})})}function Ztt({provider:t,onEdit:e}){const{t:n}=Ft(),r=nn(),[i,s]=w.useState(!1),[o,l]=w.useState(!1),[c,d]=w.useState(null),{data:u}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),m=u?.find(b=>b.id===t.printer_id),p=it({mutationFn:b=>ue.updateNotificationProvider(t.id,b),onSuccess:()=>{r.invalidateQueries({queryKey:[\"notification-providers\"]})}}),f=it({mutationFn:()=>ue.deleteNotificationProvider(t.id),onSuccess:()=>{r.invalidateQueries({queryKey:[\"notification-providers\"]})}}),y=it({mutationFn:()=>ue.testNotificationProvider(t.id),onSuccess:b=>{d(b),r.invalidateQueries({queryKey:[\"notification-providers\"]})},onError:b=>{d({success:!1,message:b.message})}}),v=b=>b||\"\";return a.jsxs(a.Fragment,{children:[a.jsx(Tt,{className:\"relative\",children:a.jsxs(Mt,{className:\"p-3\",children:[a.jsxs(\"div\",{className:`flex items-start justify-between ${t.enabled?\"mb-3\":\"mb-0\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`p-2 rounded-lg ${t.enabled?\"bg-bambu-green/20\":\"bg-bambu-dark\"}`,children:a.jsx(Zh,{className:`w-5 h-5 ${t.enabled?\"text-bambu-green\":\"text-bambu-gray\"}`})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"font-medium text-white\",children:t.name}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(`notifications.providerTypes.${t.provider_type}`,t.provider_type)})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[t.last_success&&a.jsx(\"span\",{className:\"text-xs text-status-ok hidden sm:inline\",children:n(\"notifications.lastSuccess\",{date:hg(t.last_success)})}),t.last_error&&t.last_error_at&&(!t.last_success||(Zn(t.last_error_at)?.getTime()||0)>(Zn(t.last_success)?.getTime()||0))&&a.jsx(\"span\",{className:\"text-xs text-status-error\",title:t.last_error,children:n(\"notifications.error\")}),a.jsx(Ln,{checked:t.enabled,onChange:b=>p.mutate({enabled:b})})]})]}),t.enabled&&a.jsxs(a.Fragment,{children:[m&&a.jsxs(\"div\",{className:\"mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[n(\"notifications.printer\"),\" \"]}),a.jsx(\"span\",{className:\"text-sm text-white\",children:m.name})]}),!m&&!t.printer_id&&a.jsx(\"div\",{className:\"mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg\",children:a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.allPrinters\")})}),a.jsxs(\"div\",{className:\"mb-3 flex flex-wrap gap-1\",children:[t.on_print_start&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded\",children:n(\"notifications.start\")}),t.on_plate_not_empty&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-rose-600/20 text-rose-300 text-xs rounded\",children:n(\"notifications.plateCheck\")}),t.on_print_complete&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded\",children:n(\"notifications.complete\")}),t.on_print_failed&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded\",children:n(\"notifications.failed\")}),t.on_print_stopped&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded\",children:n(\"notifications.stopped\")}),t.on_print_progress&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded\",children:n(\"notifications.progress\")}),t.on_printer_offline&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded\",children:n(\"notifications.offline\")}),t.on_printer_error&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded\",children:n(\"notifications.error\")}),t.on_filament_low&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded\",children:n(\"notifications.lowFilament\")}),t.on_maintenance_due&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded\",children:n(\"notifications.maintenance\")}),t.on_ams_humidity_high&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-blue-600/20 text-blue-300 text-xs rounded\",children:n(\"notifications.amsHumidity\")}),t.on_ams_temperature_high&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-orange-600/20 text-orange-300 text-xs rounded\",children:n(\"notifications.amsTemp\")}),t.on_ams_ht_humidity_high&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-cyan-600/20 text-cyan-300 text-xs rounded\",children:n(\"notifications.amsHtHumidity\")}),t.on_ams_ht_temperature_high&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-amber-600/20 text-amber-300 text-xs rounded\",children:n(\"notifications.amsHtTemp\")}),t.on_bed_cooled&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded\",children:n(\"notifications.bedCooled\")}),t.on_first_layer_complete&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded\",children:n(\"notifications.firstLayer\")}),t.on_print_missing_spool_assignment&&a.jsx(\"span\",{className:\"px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded\",children:n(\"notifications.missingSpoolAssignmentLabel\")}),t.quiet_hours_enabled&&a.jsxs(\"span\",{className:\"px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1\",children:[a.jsx(kN,{className:\"w-3 h-3\"}),n(\"notifications.quiet\")]}),t.daily_digest_enabled&&a.jsxs(\"span\",{className:\"px-2 py-0.5 bg-emerald-500/20 text-emerald-400 text-xs rounded flex items-center gap-1\",children:[a.jsx(oa,{className:\"w-3 h-3\"}),n(\"notifications.digest\",{time:t.daily_digest_time})]})]}),a.jsx(\"div\",{className:\"mb-3\",children:a.jsxs(De,{size:\"sm\",variant:\"secondary\",disabled:y.isPending,onClick:()=>{d(null),y.mutate()},className:\"w-full\",children:[y.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(iS,{className:\"w-4 h-4\"}),n(\"notifications.sendTestNotification\")]})}),c&&a.jsxs(\"div\",{className:`mb-3 p-2 rounded-lg flex items-center gap-2 text-sm ${c.success?\"bg-bambu-green/20 text-bambu-green\":\"bg-red-500/20 text-red-400\"}`,children:[c.success?a.jsx(yr,{className:\"w-4 h-4\"}):a.jsx(Ma,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:c.message})]})]}),a.jsxs(\"button\",{onClick:()=>l(!o),className:`w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors ${t.enabled?\"border-t border-bambu-dark-tertiary\":\"mt-2 border-t border-bambu-dark-tertiary\"}`,children:[a.jsxs(\"span\",{className:\"flex items-center gap-2\",children:[a.jsx(Ud,{className:\"w-4 h-4\"}),n(\"notifications.eventSettings\")]}),o?a.jsx(cc,{className:\"w-4 h-4\"}):a.jsx(On,{className:\"w-4 h-4\"})]}),o&&a.jsxs(\"div\",{className:\"pt-3 border-t border-bambu-dark-tertiary space-y-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.enabled\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.sendFromProvider\")})]}),a.jsx(Ln,{checked:t.enabled,onChange:b=>p.mutate({enabled:b})})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase tracking-wide\",children:n(\"notifications.printEvents\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.printStarted\")}),a.jsx(Ln,{checked:t.on_print_start,onChange:b=>p.mutate({on_print_start:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.plateNotEmpty\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.plateNotEmptyDescription\")})]}),a.jsx(Ln,{checked:t.on_plate_not_empty??!0,onChange:b=>p.mutate({on_plate_not_empty:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.printCompleted\")}),a.jsx(Ln,{checked:t.on_print_complete,onChange:b=>p.mutate({on_print_complete:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.bedCooledLabel\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.bedCooledDescription\")})]}),a.jsx(Ln,{checked:t.on_bed_cooled??!1,onChange:b=>p.mutate({on_bed_cooled:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.firstLayerCompleteLabel\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.firstLayerCompleteDescription\")})]}),a.jsx(Ln,{checked:t.on_first_layer_complete??!1,onChange:b=>p.mutate({on_first_layer_complete:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.missingSpoolAssignmentLabel\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.missingSpoolAssignmentDescription\")})]}),a.jsx(Ln,{checked:t.on_print_missing_spool_assignment??!1,onChange:b=>p.mutate({on_print_missing_spool_assignment:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.printFailed\")}),a.jsx(Ln,{checked:t.on_print_failed,onChange:b=>p.mutate({on_print_failed:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.printStopped\")}),a.jsx(Ln,{checked:t.on_print_stopped,onChange:b=>p.mutate({on_print_stopped:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.progressMilestones\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.progressMilestonesDescription\")})]}),a.jsx(Ln,{checked:t.on_print_progress,onChange:b=>p.mutate({on_print_progress:b})})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase tracking-wide\",children:n(\"notifications.printerStatus\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.printerOffline\")}),a.jsx(Ln,{checked:t.on_printer_offline,onChange:b=>p.mutate({on_printer_offline:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.printerError\")}),a.jsx(Ln,{checked:t.on_printer_error,onChange:b=>p.mutate({on_printer_error:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.lowFilamentLabel\")}),a.jsx(Ln,{checked:t.on_filament_low,onChange:b=>p.mutate({on_filament_low:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.maintenanceDue\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.maintenanceDueDescription\")})]}),a.jsx(Ln,{checked:t.on_maintenance_due??!1,onChange:b=>p.mutate({on_maintenance_due:b})})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase tracking-wide\",children:n(\"notifications.amsAlarms\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.amsHumidityHigh\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.amsHumidityHighDescription\")})]}),a.jsx(Ln,{checked:t.on_ams_humidity_high??!1,onChange:b=>p.mutate({on_ams_humidity_high:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.amsTemperatureHigh\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.amsTemperatureHighDescription\")})]}),a.jsx(Ln,{checked:t.on_ams_temperature_high??!1,onChange:b=>p.mutate({on_ams_temperature_high:b})})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase tracking-wide\",children:n(\"notifications.amsHtAlarms\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.amsHtHumidityHigh\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.amsHtHumidityHighDescription\")})]}),a.jsx(Ln,{checked:t.on_ams_ht_humidity_high??!1,onChange:b=>p.mutate({on_ams_ht_humidity_high:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.amsHtTemperatureHigh\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.amsHtTemperatureHighDescription\")})]}),a.jsx(Ln,{checked:t.on_ams_ht_temperature_high??!1,onChange:b=>p.mutate({on_ams_ht_temperature_high:b})})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase tracking-wide\",children:n(\"notifications.printQueue\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.jobAdded\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.jobAddedDescription\")})]}),a.jsx(Ln,{checked:t.on_queue_job_added??!1,onChange:b=>p.mutate({on_queue_job_added:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.jobAssigned\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.jobAssignedDescription\")})]}),a.jsx(Ln,{checked:t.on_queue_job_assigned??!1,onChange:b=>p.mutate({on_queue_job_assigned:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.jobStarted\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.jobStartedDescription\")})]}),a.jsx(Ln,{checked:t.on_queue_job_started??!1,onChange:b=>p.mutate({on_queue_job_started:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.jobWaiting\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.jobWaitingDescription\")})]}),a.jsx(Ln,{checked:t.on_queue_job_waiting??!0,onChange:b=>p.mutate({on_queue_job_waiting:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.jobSkipped\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.jobSkippedDescription\")})]}),a.jsx(Ln,{checked:t.on_queue_job_skipped??!0,onChange:b=>p.mutate({on_queue_job_skipped:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.jobFailed\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.jobFailedDescription\")})]}),a.jsx(Ln,{checked:t.on_queue_job_failed??!0,onChange:b=>p.mutate({on_queue_job_failed:b})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.queueComplete\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.queueCompleteDescription\")})]}),a.jsx(Ln,{checked:t.on_queue_completed??!1,onChange:b=>p.mutate({on_queue_completed:b})})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(kN,{className:\"w-4 h-4 text-purple-400\"}),a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.quietHours\")})]}),a.jsx(Ln,{checked:t.quiet_hours_enabled,onChange:b=>p.mutate({quiet_hours_enabled:b})})]}),t.quiet_hours_enabled&&a.jsxs(\"div\",{className:\"pl-4 border-l-2 border-bambu-dark-tertiary space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.noNotificationsDuring\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Yn,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsxs(\"span\",{className:\"text-sm text-white\",children:[v(t.quiet_hours_start)||\"22:00\",\" - \",v(t.quiet_hours_end)||\"07:00\"]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.editProviderToChangeQuietHours\")})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(oa,{className:\"w-4 h-4 text-emerald-400\"}),a.jsx(\"p\",{className:\"text-sm text-white\",children:n(\"notifications.dailyDigest\")})]}),a.jsx(Ln,{checked:t.daily_digest_enabled,onChange:b=>p.mutate({daily_digest_enabled:b})})]}),t.daily_digest_enabled&&a.jsxs(\"div\",{className:\"pl-4 border-l-2 border-bambu-dark-tertiary space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.batchNotifications\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Yn,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.sendAt\",{time:v(t.daily_digest_time)||\"08:00\"})})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.editProviderToChangeDigestTime\")})]})]}),a.jsxs(\"div\",{className:\"flex gap-2 pt-2\",children:[a.jsxs(De,{size:\"sm\",variant:\"secondary\",onClick:()=>e(t),className:\"flex-1\",children:[a.jsx(Qu,{className:\"w-4 h-4\"}),n(\"notifications.edit\")]}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:()=>s(!0),className:\"text-red-400 hover:text-red-300\",children:a.jsx(en,{className:\"w-4 h-4\"})})]})]})]})}),i&&a.jsx(yn,{title:n(\"notifications.deleteProvider\"),message:n(\"notifications.deleteConfirm\",{name:t.name}),confirmText:n(\"notifications.delete\"),variant:\"danger\",onConfirm:()=>{f.mutate(),s(!1)},onCancel:()=>s(!1)})]})}const Jtt=[\"email\",\"telegram\",\"discord\",\"ntfy\",\"pushover\",\"callmebot\",\"webhook\",\"homeassistant\"];function ent({provider:t,onClose:e}){const{t:n}=Ft(),r=nn(),i=!!t,[s,o]=w.useState(t?.name||\"\"),[l,c]=w.useState(t?.provider_type||\"email\"),[d,u]=w.useState(t?.printer_id||null),[m,p]=w.useState(t?.quiet_hours_enabled||!1),[f,y]=w.useState(t?.quiet_hours_start||\"22:00\"),[v,b]=w.useState(t?.quiet_hours_end||\"07:00\"),[g,_]=w.useState(t?.daily_digest_enabled||!1),[C,P]=w.useState(t?.daily_digest_time||\"08:00\"),[N,A]=w.useState(t?.on_print_start??!1),[T,F]=w.useState(t?.on_print_complete??!0),[k,D]=w.useState(t?.on_print_failed??!0),[H,z]=w.useState(t?.on_print_stopped??!0),[Q,L]=w.useState(t?.on_print_progress??!1),[te,ie]=w.useState(t?.on_printer_offline??!1),[J,oe]=w.useState(t?.on_printer_error??!1),[fe,re]=w.useState(t?.on_filament_low??!1),[W,ne]=w.useState(t?.on_maintenance_due??!1),[me,be]=w.useState(t?.on_bed_cooled??!1),[Ce,q]=w.useState(t?.on_first_layer_complete??!1),[Y,E]=w.useState(t?.config?Object.fromEntries(Object.entries(t.config).map(([le,B])=>[le,String(B)])):{}),[j,O]=w.useState(null),[K,U]=w.useState(null),{data:de}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters});w.useEffect(()=>{const le=B=>{B.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",le),()=>window.removeEventListener(\"keydown\",le)},[e]);const I=it({mutationFn:()=>ue.testNotificationConfig({provider_type:l,config:Y}),onSuccess:le=>{O(le),U(null)},onError:le=>{O({success:!1,message:le.message})}}),G=it({mutationFn:le=>ue.createNotificationProvider(le),onSuccess:()=>{r.invalidateQueries({queryKey:[\"notification-providers\"]}),e()},onError:le=>{U(le.message)}}),X=it({mutationFn:le=>ue.updateNotificationProvider(t.id,le),onSuccess:()=>{r.invalidateQueries({queryKey:[\"notification-providers\"]}),e()},onError:le=>{U(le.message)}}),V=le=>{if(le.preventDefault(),U(null),!s.trim()){U(n(\"notifications.nameRequired\"));return}const B=ge(l);for(const ae of B)if(!Y[ae.key]?.trim()){U(n(\"notifications.fieldRequired\",{field:ae.label}));return}const R={name:s.trim(),provider_type:l,config:Y,printer_id:d,quiet_hours_enabled:m,quiet_hours_start:m?f:null,quiet_hours_end:m?v:null,daily_digest_enabled:g,daily_digest_time:g?C:null,on_print_start:N,on_print_complete:T,on_print_failed:k,on_print_stopped:H,on_print_progress:Q,on_printer_offline:te,on_printer_error:J,on_filament_low:fe,on_maintenance_due:W,on_bed_cooled:me,on_first_layer_complete:Ce};i?X.mutate(R):G.mutate(R)},ee=G.isPending||X.isPending,se=le=>{switch(le){case\"callmebot\":return[{key:\"phone\",label:\"Phone Number\",placeholder:\"+1234567890\",type:\"text\",required:!0},{key:\"apikey\",label:\"API Key\",placeholder:\"Your CallMeBot API key\",type:\"text\",required:!0}];case\"ntfy\":return[{key:\"server\",label:\"Server URL\",placeholder:\"https://ntfy.sh\",type:\"text\",required:!1},{key:\"topic\",label:\"Topic\",placeholder:\"my-bambuddy\",type:\"text\",required:!0},{key:\"auth_token\",label:\"Auth Token\",placeholder:\"Optional authentication\",type:\"password\",required:!1}];case\"pushover\":return[{key:\"user_key\",label:\"User Key\",placeholder:\"Your Pushover user key\",type:\"text\",required:!0},{key:\"app_token\",label:\"App Token\",placeholder:\"Your Pushover app token\",type:\"text\",required:!0},{key:\"priority\",label:\"Priority\",placeholder:\"0 (normal)\",type:\"number\",required:!1}];case\"telegram\":return[{key:\"bot_token\",label:\"Bot Token\",placeholder:\"Bot token from @BotFather\",type:\"password\",required:!0},{key:\"chat_id\",label:\"Chat ID\",placeholder:\"Your chat or group ID\",type:\"text\",required:!0}];case\"email\":return[{key:\"smtp_server\",label:\"SMTP Server\",placeholder:\"smtp.gmail.com\",type:\"text\",required:!0},{key:\"smtp_port\",label:\"SMTP Port\",placeholder:\"587\",type:\"number\",required:!1},{key:\"security\",label:\"Security\",type:\"select\",required:!1,options:[{value:\"starttls\",label:\"STARTTLS (Port 587)\"},{value:\"ssl\",label:\"SSL/TLS (Port 465)\"},{value:\"none\",label:\"None (Port 25)\"}]},{key:\"auth_enabled\",label:\"Authentication\",type:\"select\",required:!1,options:[{value:\"true\",label:\"Enabled\"},{value:\"false\",label:\"Disabled\"}]},{key:\"username\",label:\"Username\",placeholder:\"your@email.com\",type:\"text\",required:!1},{key:\"password\",label:\"Password\",placeholder:\"App password\",type:\"password\",required:!1},{key:\"from_email\",label:\"From Email\",placeholder:\"your@email.com\",type:\"text\",required:!0},{key:\"to_email\",label:\"To Email\",placeholder:\"recipient@email.com\",type:\"text\",required:!0}];case\"discord\":return[{key:\"webhook_url\",label:\"Webhook URL\",placeholder:\"https://discord.com/api/webhooks/...\",type:\"text\",required:!0}];case\"webhook\":return[{key:\"webhook_url\",label:\"Webhook URL\",placeholder:\"https://example.com/webhook\",type:\"text\",required:!0},{key:\"payload_format\",label:\"Payload Format\",type:\"select\",required:!1,options:[{value:\"generic\",label:\"Generic JSON\"},{value:\"slack\",label:\"Slack / Mattermost\"}]},{key:\"auth_header\",label:\"Authorization\",placeholder:\"Bearer token (optional)\",type:\"password\",required:!1},{key:\"field_title\",label:\"Title Field Name\",placeholder:\"title\",type:\"text\",required:!1,showIf:B=>B.payload_format!==\"slack\"},{key:\"field_message\",label:\"Message Field Name\",placeholder:\"message\",type:\"text\",required:!1,showIf:B=>B.payload_format!==\"slack\"}];case\"homeassistant\":return[{key:\"service\",label:\"Home Assistant Service\",placeholder:\"notify.mobile_app_myphone\",type:\"text\",required:!1}];default:return[]}},ge=le=>se(le).filter(B=>B.required),he=se(l);return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto\",onClick:e,children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8 max-h-[90vh] overflow-y-auto\",onClick:le=>le.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:n(i?\"notifications.editTitle\":\"notifications.addTitle\")}),a.jsx(\"button\",{onClick:e,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"form\",{onSubmit:V,className:\"p-6 space-y-4\",children:[K&&a.jsx(\"div\",{className:\"p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\",children:K}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"notifications.nameLabel\")}),a.jsx(\"input\",{type:\"text\",value:s,onChange:le=>o(le.target.value),placeholder:n(\"notifications.namePlaceholder\"),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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"notifications.providerTypeLabel\")}),a.jsx(\"select\",{value:l,onChange:le=>{c(le.target.value),E({}),O(null)},disabled:i,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 disabled:opacity-50\",children:Jtt.map(le=>a.jsx(\"option\",{value:le,children:n(`notifications.providerTypes.${le}`,le)},le))}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(`notifications.providerDescriptions.${l}`,\"\")})]}),a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"notifications.configuration\")}),he.filter(le=>!(\"showIf\"in le)||le.showIf?.(Y)!==!1).map(le=>a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[le.label,\" \",le.required&&\"*\"]}),le.type===\"select\"&&\"options\"in le&&le.options?a.jsx(\"select\",{value:Y[le.key]||le.options[0]?.value||\"\",onChange:B=>{E({...Y,[le.key]:B.target.value}),O(null)},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\",children:le.options.map(B=>a.jsx(\"option\",{value:B.value,children:B.label},B.value))}):a.jsx(\"input\",{type:le.type,value:Y[le.key]||\"\",onChange:B=>{E({...Y,[le.key]:B.target.value}),O(null)},placeholder:le.placeholder,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\"})]},le.key))]}),a.jsx(\"div\",{className:\"flex gap-2\",children:a.jsxs(De,{type:\"button\",variant:\"secondary\",onClick:()=>{O(null),I.mutate()},disabled:I.isPending||ge(l).length>0&&!Y[ge(l)[0]?.key],className:\"flex-1\",children:[I.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(iS,{className:\"w-4 h-4\"}),n(\"notifications.testConfiguration\")]})}),j&&a.jsx(\"div\",{className:`p-3 rounded-lg flex items-center gap-2 ${j.success?\"bg-bambu-green/20 border border-bambu-green/50 text-bambu-green\":\"bg-red-500/20 border border-red-500/50 text-red-400\"}`,children:j.success?a.jsxs(a.Fragment,{children:[a.jsx(yr,{className:\"w-5 h-5\"}),a.jsx(\"span\",{children:j.message})]}):a.jsxs(a.Fragment,{children:[a.jsx(Ma,{className:\"w-5 h-5\"}),a.jsx(\"span\",{children:j.message})]})}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"notifications.printerFilter\")}),a.jsxs(\"select\",{value:d??\"\",onChange:le=>u(le.target.value?Number(le.target.value):null),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\",children:[a.jsx(\"option\",{value:\"\",children:n(\"notifications.allPrinters\")}),de?.map(le=>a.jsx(\"option\",{value:le.id,children:le.name},le.id))]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(\"notifications.onlyFromPrinter\")})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"label\",{className:\"text-sm text-white\",children:n(\"notifications.quietHoursDnd\")}),a.jsx(Ln,{checked:m,onChange:p})]}),m&&a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:n(\"notifications.quietStart\")}),a.jsx(\"input\",{type:\"time\",value:f,onChange:le=>y(le.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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:n(\"notifications.quietEnd\")}),a.jsx(\"input\",{type:\"time\",value:v,onChange:le=>b(le.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\"})]})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-sm text-white\",children:n(\"notifications.dailyDigestLabel\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.batchNotifications\")})]}),a.jsx(Ln,{checked:g,onChange:_})]}),g&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:n(\"notifications.sendDigestAt\")}),a.jsx(\"input\",{type:\"time\",value:C,onChange:le=>P(le.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\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(\"notifications.digestCollected\")})]})]}),a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"notifications.notificationEvents\")}),a.jsxs(\"div\",{className:\"space-y-2 p-3 bg-bambu-dark rounded-lg\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase tracking-wide mb-2\",children:n(\"notifications.printEvents\")}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.start\")}),a.jsx(Ln,{checked:N,onChange:A})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.complete\")}),a.jsx(Ln,{checked:T,onChange:F})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.failed\")}),a.jsx(Ln,{checked:k,onChange:D})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.stopped\")}),a.jsx(Ln,{checked:H,onChange:z})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between col-span-2\",children:[a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.progress\")}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray ml-1\",children:n(\"notifications.progressPercent\")})]}),a.jsx(Ln,{checked:Q,onChange:L})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between col-span-2\",children:[a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.bedCooled\")}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray ml-1\",children:n(\"notifications.bedCooledAfterPrint\")})]}),a.jsx(Ln,{checked:me,onChange:be})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between col-span-2\",children:[a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.firstLayerCompleteLabel\")}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray ml-1\",children:n(\"notifications.firstLayerCompleteDescription\")})]}),a.jsx(Ln,{checked:Ce,onChange:q})]})]})]}),a.jsxs(\"div\",{className:\"space-y-2 p-3 bg-bambu-dark rounded-lg\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase tracking-wide mb-2\",children:n(\"notifications.printerStatus\")}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.offline\")}),a.jsx(Ln,{checked:te,onChange:ie})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.error\")}),a.jsx(Ln,{checked:J,onChange:oe})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.lowFilament\")}),a.jsx(Ln,{checked:fe,onChange:re})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:n(\"notifications.maintenance\")}),a.jsx(Ln,{checked:W,onChange:ne})]})]})]})]}),a.jsxs(\"div\",{className:\"flex gap-3 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:e,className:\"flex-1\",children:n(\"notifications.cancel\")}),a.jsxs(De,{type:\"submit\",disabled:ee,className:\"flex-1\",children:[ee?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(_s,{className:\"w-4 h-4\"}),n(i?\"notifications.save\":\"notifications.add\")]})]})]})]})})}function tnt({template:t,onClose:e}){const{t:n}=Ft(),r=nn(),i=w.useRef(null),[s,o]=w.useState(t.title_template),[l,c]=w.useState(t.body_template),[d,u]=w.useState(null),[m,p]=w.useState(!0),{data:f}=Xe({queryKey:[\"template-variables\"],queryFn:ue.getTemplateVariables}),y=f?.find(A=>A.event_type===t.event_type),{data:v,isLoading:b}=Xe({queryKey:[\"template-preview\",t.event_type,s,l],queryFn:()=>ue.previewTemplate({event_type:t.event_type,title_template:s,body_template:l}),enabled:m&&s.length>0&&l.length>0});w.useEffect(()=>{const A=T=>{T.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",A),()=>window.removeEventListener(\"keydown\",A)},[e]);const g=it({mutationFn:A=>ue.updateNotificationTemplate(t.id,A),onSuccess:()=>{r.invalidateQueries({queryKey:[\"notification-templates\"]}),e()},onError:A=>{u(A.message)}}),_=it({mutationFn:()=>ue.resetNotificationTemplate(t.id),onSuccess:A=>{o(A.title_template),c(A.body_template),r.invalidateQueries({queryKey:[\"notification-templates\"]})},onError:A=>{u(A.message)}}),C=A=>{if(A.preventDefault(),u(null),!s.trim()){u(n(\"notifications.titleRequired\"));return}if(!l.trim()){u(n(\"notifications.bodyRequired\"));return}g.mutate({title_template:s,body_template:l})},P=A=>{const T=i.current;if(!T)return;const F=T.selectionStart,k=T.selectionEnd,D=l,H=D.substring(0,F),z=D.substring(k),Q=H+`{${A}}`+z;c(Q),setTimeout(()=>{T.focus();const L=F+A.length+2;T.setSelectionRange(L,L)},0)},N=s!==t.title_template||l!==t.body_template;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:n(\"notifications.editTemplate\",{name:t.name})}),a.jsx(\"button\",{onClick:e,className:\"p-1 hover:bg-bambu-dark-tertiary rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]}),a.jsxs(\"form\",{onSubmit:C,className:\"flex-1 overflow-y-auto p-4 space-y-4\",children:[d&&a.jsx(\"div\",{className:\"p-3 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-sm\",children:d}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:n(\"notifications.titleLabel\")}),a.jsx(\"input\",{type:\"text\",value:s,onChange:A=>o(A.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green\",placeholder:n(\"notifications.titlePlaceholder\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:n(\"notifications.bodyLabel\")}),a.jsx(\"textarea\",{ref:i,value:l,onChange:A=>c(A.target.value),rows:4,className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green font-mono text-sm resize-none\",placeholder:n(\"notifications.bodyPlaceholder\")})]}),y&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-2\",children:n(\"notifications.availableVariables\")}),a.jsx(\"div\",{className:\"flex flex-wrap gap-2\",children:y.variables.map(A=>a.jsxs(\"button\",{type:\"button\",onClick:()=>P(A),className:\"inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs text-bambu-gray hover:text-white transition-colors\",children:[a.jsx(sr,{className:\"w-3 h-3\"}),A]},A))}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray/60 mt-1\",children:n(\"notifications.clickToInsert\")})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsxs(\"label\",{className:\"text-sm font-medium text-bambu-gray flex items-center gap-2\",children:[a.jsx(yl,{className:\"w-4 h-4\"}),n(\"notifications.livePreview\")]}),a.jsx(\"button\",{type:\"button\",onClick:()=>p(!m),className:\"text-xs text-bambu-green hover:text-bambu-green-light\",children:n(m?\"notifications.hide\":\"notifications.show\")})]}),m&&a.jsx(\"div\",{className:\"bg-bambu-dark border border-bambu-dark-tertiary rounded p-3 space-y-2\",children:b?a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray text-sm\",children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),n(\"notifications.loadingPreview\")]}):v?a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.titlePreview\")}),a.jsx(\"div\",{className:\"text-white font-medium\",children:v.title})]}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:n(\"notifications.bodyPreview\")}),a.jsx(\"div\",{className:\"text-white whitespace-pre-wrap text-sm\",children:v.body})]})]}):a.jsx(\"div\",{className:\"text-bambu-gray text-sm\",children:n(\"notifications.enterTemplateContent\")})})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-t border-bambu-dark-tertiary shrink-0\",children:[a.jsxs(De,{type:\"button\",variant:\"ghost\",onClick:()=>_.mutate(),disabled:_.isPending,className:\"text-orange-400 hover:text-orange-300\",children:[_.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin mr-2\"}):a.jsx(co,{className:\"w-4 h-4 mr-2\"}),n(\"notifications.resetToDefault\")]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:e,children:n(\"notifications.cancel\")}),a.jsxs(De,{onClick:C,disabled:g.isPending||!N,children:[g.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin mr-2\"}):a.jsx(_s,{className:\"w-4 h-4 mr-2\"}),n(\"notifications.save\")]})]})]})]})})}const nnt={print_start:\"text-blue-400\",print_complete:\"text-bambu-green\",print_failed:\"text-red-400\",print_stopped:\"text-orange-400\",print_progress:\"text-yellow-400\",printer_offline:\"text-gray-400\",printer_error:\"text-rose-400\",filament_low:\"text-cyan-400\",maintenance_due:\"text-purple-400\",test:\"text-bambu-gray\"};function rnt({onClose:t}){const{t:e}=Ft(),n=nn(),{showToast:r}=hn(),[i,s]=w.useState(7),[o,l]=w.useState(null),[c,d]=w.useState(!1),{data:u}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),m=u?.time_format||\"system\",{data:p,isLoading:f,refetch:y,isRefetching:v}=Xe({queryKey:[\"notification-logs\",i,c],queryFn:()=>ue.getNotificationLogs({days:i,limit:100,success:c?!1:void 0})}),{data:b}=Xe({queryKey:[\"notification-log-stats\",i],queryFn:()=>ue.getNotificationLogStats(i)}),g=it({mutationFn:()=>ue.clearNotificationLogs(30),onSuccess:C=>{r(C.message,\"success\"),n.invalidateQueries({queryKey:[\"notification-logs\"]}),n.invalidateQueries({queryKey:[\"notification-log-stats\"]})},onError:C=>{r(`Failed to clear logs: ${C.message}`,\"error\")}}),_=C=>{const P=Zn(C);if(!P)return\"\";const A=new Date().getTime()-P.getTime();return A<6e4?e(\"notifications.justNow\"):A<36e5?`${Math.floor(A/6e4)}m ago`:A<864e5?`${Math.floor(A/36e5)}h ago`:P.toLocaleDateString()+\" \"+sZ(P,m)};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg w-full max-w-3xl max-h-[85vh] flex flex-col\",children:[a.jsxs(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(iw,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:e(\"notifications.notificationLog\")})]}),a.jsx(\"button\",{onClick:t,className:\"text-bambu-gray hover:text-white transition-colors\",children:\"×\"})]}),b&&a.jsx(\"div\",{className:\"px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50\",children:a.jsxs(\"div\",{className:\"flex items-center gap-6 text-sm\",children:[a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[e(\"notifications.statsSummary\",{days:i}),\" \",a.jsx(\"span\",{className:\"text-white font-medium\",children:b.total}),\" \",e(\"notifications.statsNotifications\")]}),a.jsxs(\"span\",{className:\"flex items-center gap-1 text-bambu-green\",children:[a.jsx(yr,{className:\"w-4 h-4\"}),e(\"notifications.statsSent\",{count:b.success_count})]}),b.failure_count>0&&a.jsxs(\"span\",{className:\"flex items-center gap-1 text-red-400\",children:[a.jsx(Ma,{className:\"w-4 h-4\"}),e(\"notifications.statsFailed\",{count:b.failure_count})]})]})}),a.jsxs(\"div\",{className:\"px-4 py-3 border-b border-bambu-dark-tertiary flex items-center gap-4\",children:[a.jsxs(\"select\",{value:i,onChange:C=>s(Number(C.target.value)),className:\"px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green\",children:[a.jsx(\"option\",{value:1,children:e(\"notifications.last24Hours\")}),a.jsx(\"option\",{value:7,children:e(\"notifications.last7Days\")}),a.jsx(\"option\",{value:30,children:e(\"notifications.last30Days\")}),a.jsx(\"option\",{value:90,children:e(\"notifications.last90Days\")})]}),a.jsxs(\"label\",{className:\"flex items-center gap-2 text-sm text-bambu-gray cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:c,onChange:C=>d(C.target.checked),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),e(\"notifications.showFailedOnly\")]}),a.jsx(\"div\",{className:\"flex-1\"}),a.jsxs(De,{size:\"sm\",variant:\"secondary\",onClick:()=>y(),disabled:v,children:[v?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(lr,{className:\"w-4 h-4\"}),e(\"notifications.refresh\")]}),a.jsxs(De,{size:\"sm\",variant:\"secondary\",onClick:()=>g.mutate(),disabled:g.isPending,className:\"text-red-400 hover:text-red-300\",children:[g.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"}),e(\"notifications.clearOld\")]})]}),a.jsx(\"div\",{className:\"flex-1 overflow-y-auto p-4\",children:f?a.jsx(\"div\",{className:\"flex justify-center py-12\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})}):p&&p.length>0?a.jsx(\"div\",{className:\"space-y-2\",children:p.map(C=>a.jsx(ant,{log:C,isExpanded:o===C.id,onToggle:()=>l(o===C.id?null:C.id),formatDate:_,formatFullDate:P=>pg(P,m)},C.id))}):a.jsxs(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:[a.jsx(iw,{className:\"w-12 h-12 mx-auto mb-3 opacity-30\"}),a.jsx(\"p\",{className:\"text-sm\",children:e(c?\"notifications.noFailedNotifications\":\"notifications.noNotificationsLogged\")})]})})]})})}function ant({log:t,isExpanded:e,onToggle:n,formatDate:r,formatFullDate:i}){const{t:s}=Ft();return a.jsxs(\"div\",{className:`border rounded-lg overflow-hidden transition-colors ${t.success?\"border-bambu-dark-tertiary bg-bambu-dark/30\":\"border-red-500/30 bg-red-500/5\"}`,children:[a.jsxs(\"button\",{className:\"w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-bambu-dark/50 transition-colors\",onClick:n,children:[t.success?a.jsx(yr,{className:\"w-4 h-4 text-bambu-green shrink-0\"}):a.jsx(Ma,{className:\"w-4 h-4 text-red-400 shrink-0\"}),a.jsx(\"span\",{className:`text-xs font-medium ${nnt[t.event_type]||\"text-bambu-gray\"}`,children:s(`notifications.eventTypes.${t.event_type}`,t.event_type)}),a.jsx(\"span\",{className:\"text-sm text-white truncate flex-1\",children:t.provider_name||s(\"notifications.unknownProvider\")}),t.printer_name&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:t.printer_name}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray shrink-0\",children:r(t.created_at)}),e?a.jsx(cc,{className:\"w-4 h-4 text-bambu-gray shrink-0\"}):a.jsx(On,{className:\"w-4 h-4 text-bambu-gray shrink-0\"})]}),e&&a.jsxs(\"div\",{className:\"px-3 py-2 border-t border-bambu-dark-tertiary bg-bambu-dark/20 space-y-2\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-1\",children:s(\"notifications.logTitle\")}),a.jsx(\"p\",{className:\"text-sm text-white\",children:t.title})]}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-1\",children:s(\"notifications.logMessage\")}),a.jsx(\"p\",{className:\"text-sm text-white whitespace-pre-wrap\",children:t.message})]}),!t.success&&t.error_message&&a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-red-400 mb-1\",children:s(\"notifications.logError\")}),a.jsx(\"p\",{className:\"text-sm text-red-300\",children:t.error_message})]}),a.jsxs(\"div\",{className:\"flex gap-4 text-xs text-bambu-gray pt-1\",children:[a.jsx(\"span\",{children:s(\"notifications.logProvider\",{type:t.provider_type})}),a.jsx(\"span\",{children:s(\"notifications.logTime\",{time:i(t.created_at)})})]})]})]})}function int({formData:t,setFormData:e,groups:n,onClose:r,onCreate:i,isCreating:s,isCreateButtonDisabled:o}){const{t:l}=Ft();w.useEffect(()=>{const d=u=>{u.key===\"Escape\"&&r()};return window.addEventListener(\"keydown\",d),()=>window.removeEventListener(\"keydown\",d)},[r]);const c=d=>{e({...t,group_ids:t.group_ids.includes(d)?t.group_ids.filter(u=>u!==d):[...t.group_ids,d]})};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:r,children:a.jsxs(Tt,{className:\"w-full max-w-md\",onClick:d=>d.stopPropagation(),children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex flex-col gap-1\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Wu,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:l(\"users.modal.createUser\")})]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray ml-7\",children:l(\"users.modal.advancedAuthSubtitle\")||\"with Advanced Authentication\"})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:r,children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})}),a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:[l(\"users.form.username\"),\" \",a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"})]}),a.jsx(\"input\",{type:\"text\",value:t.username,onChange:d=>e({...t,username:d.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:l(\"users.form.usernamePlaceholder\"),autoComplete:\"username\",required:!0})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:[l(\"users.form.email\")||\"Email\",\" \",a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"})]}),a.jsx(\"input\",{type:\"email\",value:t.email,onChange:d=>e({...t,email:d.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:l(\"users.form.emailPlaceholder\")||\"user@example.com\",required:!0})]}),a.jsx(\"div\",{className:\"bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3\",children:a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:l(\"users.form.autoGeneratedPassword\")||\"A secure password will be automatically generated and emailed to the user.\"})}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:l(\"users.form.groups\")}),a.jsxs(\"div\",{className:\"space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\",children:[n.map(d=>a.jsxs(\"label\",{className:\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:t.group_ids.includes(d.id),onChange:()=>c(d.id),className:\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:d.name}),d.is_system&&a.jsxs(\"span\",{className:\"text-xs text-yellow-400\",children:[\"(\",l(\"users.system\"),\")\"]})]},d.id)),n.length===0&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:l(\"users.noGroupsAvailable\")})]})]})]}),a.jsxs(\"div\",{className:\"mt-6 flex justify-end gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:r,children:l(\"users.modal.cancel\")}),a.jsx(De,{onClick:i,disabled:o,children:s?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),l(\"users.modal.creating\")]}):a.jsxs(a.Fragment,{children:[a.jsx(sr,{className:\"w-4 h-4\"}),l(\"users.modal.createUser\")]})})]})]})]})})}function snt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),[r,i]=w.useState(!1),[s,o]=w.useState(\"\"),[l,c]=w.useState(\"auto\"),[d,u]=w.useState(!1),[m,p]=w.useState(!0),[f,y]=w.useState(\"all\"),[v,b]=w.useState(!1),[g,_]=w.useState(!1),[C,P]=w.useState(!1),{data:N,isLoading:A}=Xe({queryKey:[\"spoolman-settings\"],queryFn:ue.getSpoolmanSettings}),{data:T,isLoading:F,refetch:k}=Xe({queryKey:[\"spoolman-status\"],queryFn:ue.getSpoolmanStatus,refetchInterval:3e4}),{data:D}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters});w.useEffect(()=>{N&&(i(N.spoolman_enabled===\"true\"),o(N.spoolman_url||\"\"),c(N.spoolman_sync_mode||\"auto\"),u(N.spoolman_disable_weight_sync===\"true\"),p(N.spoolman_report_partial_usage!==\"false\"),b(!0))},[N]),w.useEffect(()=>{if(!v||!N)return;if(N.spoolman_enabled===\"true\"!==r||(N.spoolman_url||\"\")!==s||(N.spoolman_sync_mode||\"auto\")!==l||N.spoolman_disable_weight_sync===\"true\"!==d||N.spoolman_report_partial_usage!==\"false\"!==m){const ne=setTimeout(()=>{H.mutate()},500);return()=>clearTimeout(ne)}},[r,s,l,d,m,v]);const H=it({mutationFn:()=>ue.updateSpoolmanSettings({spoolman_enabled:r?\"true\":\"false\",spoolman_url:s,spoolman_sync_mode:l,spoolman_disable_weight_sync:d?\"true\":\"false\",spoolman_report_partial_usage:m?\"true\":\"false\"}),onSuccess:()=>{e.invalidateQueries({queryKey:[\"spoolman-settings\"]}),e.invalidateQueries({queryKey:[\"spoolman-status\"]}),e.invalidateQueries({queryKey:[\"spool-assignments\"]}),e.invalidateQueries({queryKey:[\"settings\"]}),n(t(\"settings.toast.settingsSaved\"))}}),z=it({mutationFn:ue.connectSpoolman,onSuccess:()=>{k()}}),Q=it({mutationFn:ue.disconnectSpoolman,onSuccess:()=>{k()}}),L=it({mutationFn:ue.syncAllPrintersAms,onSuccess:W=>{W.success}}),te=it({mutationFn:W=>ue.syncPrinterAms(W),onSuccess:W=>{W.success}}),ie=()=>{f===\"all\"?L.mutate():te.mutate(f)},J=it({mutationFn:ue.syncWeightsFromAms,onSuccess:W=>{e.invalidateQueries({queryKey:[\"spools\"]}),e.invalidateQueries({queryKey:[\"inventory-spools\"]}),n(t(\"settings.amsSyncSuccess\",{synced:W.synced,skipped:W.skipped}),\"success\"),P(!1)},onError:()=>{n(t(\"settings.amsSyncError\"),\"error\"),P(!1)}}),oe=L.isPending||te.isPending,fe=f===\"all\"?L.data:te.data,re=f===\"all\"?L.isSuccess:te.isSuccess;return A?a.jsxs(Tt,{id:\"card-spoolman\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Jl,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.filamentTracking\")})]})}),a.jsx(Mt,{children:a.jsx(\"div\",{className:\"flex justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})})})]}):a.jsxs(Tt,{id:\"card-spoolman\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Jl,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.filamentTracking\")})]}),H.isPending&&a.jsx(ft,{className:\"w-4 h-4 text-bambu-green animate-spin\"})]})}),a.jsxs(Mt,{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"settings.filamentTrackingDesc\")}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>i(!1),className:`p-3 rounded-lg border-2 text-left transition-colors ${r?\"border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50\":\"border-bambu-green bg-bambu-green/10\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1.5\",children:[a.jsx(Ra,{className:`w-4 h-4 ${r?\"text-bambu-gray\":\"text-bambu-green\"}`}),a.jsx(\"span\",{className:`text-sm font-medium ${r?\"text-bambu-gray\":\"text-white\"}`,children:t(\"settings.trackingModeBuiltIn\")})]}),a.jsx(\"p\",{className:`text-xs ${r?\"text-bambu-gray/60\":\"text-bambu-gray\"}`,children:t(\"settings.trackingModeBuiltInDesc\")}),!r&&a.jsxs(\"div\",{className:\"flex items-center gap-1 mt-2\",children:[a.jsx(Ur,{className:\"w-3 h-3 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-green\",children:t(\"common.enabled\")})]})]}),a.jsxs(\"button\",{type:\"button\",onClick:()=>i(!0),className:`p-3 rounded-lg border-2 text-left transition-colors ${r?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1.5\",children:[a.jsx(la,{className:`w-4 h-4 ${r?\"text-bambu-green\":\"text-bambu-gray\"}`}),a.jsx(\"span\",{className:`text-sm font-medium ${r?\"text-white\":\"text-bambu-gray\"}`,children:\"Spoolman\"})]}),a.jsx(\"p\",{className:`text-xs ${r?\"text-bambu-gray\":\"text-bambu-gray/60\"}`,children:t(\"settings.trackingModeSpoolmanDesc\")}),r&&a.jsxs(\"div\",{className:\"flex items-center gap-1 mt-2\",children:[a.jsx(Ur,{className:\"w-3 h-3 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-green\",children:t(\"common.enabled\")})]})]})]}),!r&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsx(\"div\",{className:\"p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg\",children:a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(Ss,{className:\"w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5\"}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray\",children:a.jsxs(\"ul\",{className:\"list-disc list-inside space-y-0.5\",children:[a.jsx(\"li\",{children:t(\"settings.builtInFeatureRfid\")}),a.jsx(\"li\",{children:t(\"settings.builtInFeatureUsage\")}),a.jsx(\"li\",{children:t(\"settings.builtInFeatureCatalog\")}),a.jsx(\"li\",{children:t(\"settings.builtInFeatureThirdParty\")})]})})]})}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>P(!0),disabled:J.isPending,children:[J.isPending?a.jsx(ft,{className:\"w-4 h-4 mr-2 animate-spin\"}):a.jsx(lr,{className:\"w-4 h-4 mr-2\"}),t(\"settings.amsSyncButton\")]})]}),r&&a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"div\",{className:\"p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg\",children:a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(Ss,{className:\"w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5\"}),a.jsxs(\"div\",{className:\"text-xs text-blue-300\",children:[a.jsx(\"p\",{className:\"font-medium mb-1\",children:t(\"settings.howSyncWorks\")}),a.jsxs(\"ul\",{className:\"list-disc list-inside space-y-0.5 text-blue-300/80\",children:[a.jsx(\"li\",{children:t(\"settings.syncInfoRfidOnly\")}),a.jsx(\"li\",{children:t(\"settings.syncInfoAutoCreate\")}),a.jsx(\"li\",{children:t(\"settings.syncInfoThirdPartySkipped\")})]}),a.jsx(\"p\",{className:\"font-medium mt-2 mb-1\",children:t(\"settings.linkingExistingSpools\")}),a.jsx(\"p\",{className:\"text-blue-300/80\",children:t(\"settings.linkingExistingSpoolsDesc\")})]})]})}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:t(\"settings.spoolmanUrl\")}),a.jsx(\"input\",{type:\"text\",placeholder:\"http://192.168.1.100:7912\",value:s,onChange:W=>o(W.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"settings.spoolmanUrlHint\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:t(\"settings.syncMode\")}),a.jsxs(\"select\",{value:l,onChange:W=>c(W.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\",children:[a.jsx(\"option\",{value:\"auto\",children:t(\"settings.syncModeAuto\")}),a.jsx(\"option\",{value:\"manual\",children:t(\"settings.syncModeManual\")})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(l===\"auto\"?\"settings.syncModeAutoDesc\":\"settings.syncModeManualDesc\")})]}),l===\"auto\"&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:t(\"spoolman.disableWeightSync\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"spoolman.disableWeightSyncDesc\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:d,onChange:W=>u(W.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),d&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:t(\"spoolman.reportPartialUsage\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"spoolman.reportPartialUsageDesc\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:m,onChange:W=>p(W.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[t(\"settings.status\"),\":\"]}),F?a.jsx(ft,{className:\"w-4 h-4 text-bambu-gray animate-spin\"}):T?.connected?a.jsxs(\"span\",{className:\"flex items-center gap-1 text-sm text-green-500\",children:[a.jsx(Ur,{className:\"w-4 h-4\"}),t(\"settings.spoolmanConnected\")]}):a.jsxs(\"span\",{className:\"flex items-center gap-1 text-sm text-red-500\",children:[a.jsx(Ht,{className:\"w-4 h-4\"}),t(\"settings.spoolmanDisconnected\")]})]}),a.jsx(\"div\",{className:\"flex gap-2\",children:T?.connected?a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>Q.mutate(),disabled:Q.isPending,children:[Q.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Jge,{className:\"w-4 h-4\"}),t(\"settings.disconnect\")]}):a.jsxs(De,{size:\"sm\",onClick:()=>z.mutate(),disabled:z.isPending||!s,children:[z.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(sm,{className:\"w-4 h-4\"}),t(\"settings.connect\")]})})]}),z.isError&&a.jsx(\"div\",{className:\"mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400\",children:z.error.message}),T?.connected&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:t(\"settings.syncAmsData\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:t(\"settings.syncAmsDataDesc\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsxs(\"select\",{value:f,onChange:W=>y(W.target.value===\"all\"?\"all\":Number(W.target.value)),className:\"w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:[a.jsx(\"option\",{value:\"all\",children:t(\"settings.allPrinters\")}),D?.map(W=>a.jsx(\"option\",{value:W.id,children:W.name},W.id))]}),a.jsx(On,{className:\"absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:ie,disabled:oe,children:[oe?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(lr,{className:\"w-4 h-4\"}),t(\"spoolman.sync\")]})]})]}),re&&fe&&a.jsxs(\"div\",{className:\"mt-3 space-y-2\",children:[a.jsx(\"div\",{className:`p-2 rounded text-sm ${fe.success?\"bg-green-500/20 border border-green-500/50 text-green-400\":\"bg-yellow-500/20 border border-yellow-500/50 text-yellow-400\"}`,children:fe.success?`Synced ${fe.synced_count} spool${fe.synced_count!==1?\"s\":\"\"} successfully`:`Synced ${fe.synced_count} spool${fe.synced_count!==1?\"s\":\"\"} with ${fe.errors.length} error${fe.errors.length!==1?\"s\":\"\"}`}),fe.skipped_count>0&&a.jsxs(\"div\",{className:\"p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between text-amber-400 mb-1\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(Dn,{className:\"w-3.5 h-3.5\"}),a.jsxs(\"span\",{className:\"font-medium\",children:[fe.skipped_count,\" spool\",fe.skipped_count!==1?\"s\":\"\",\" skipped\"]})]}),fe.skipped_count>5&&a.jsx(\"button\",{onClick:()=>_(!g),className:\"text-xs text-amber-400 hover:text-amber-300 underline\",children:g?\"Show less\":\"Show all\"})]}),a.jsxs(\"ul\",{className:\"text-xs text-amber-300/80 space-y-0.5\",children:[(g?fe.skipped:fe.skipped.slice(0,5)).map((W,ne)=>a.jsxs(\"li\",{className:\"flex items-center gap-2\",children:[W.color&&a.jsx(\"span\",{className:\"w-3 h-3 rounded-full border border-black/20\",style:{backgroundColor:`#${W.color}`}}),a.jsx(\"span\",{children:W.location}),a.jsxs(\"span\",{className:\"text-amber-300/60\",children:[\"- \",W.reason]})]},ne)),!g&&fe.skipped_count>5&&a.jsxs(\"li\",{className:\"text-amber-300/60 italic\",children:[\"...and \",fe.skipped_count-5,\" more\"]})]})]}),fe.errors.length>0&&a.jsxs(\"div\",{className:\"p-2 bg-red-500/10 border border-red-500/30 rounded text-sm\",children:[a.jsx(\"div\",{className:\"text-red-400 font-medium mb-1\",children:\"Errors:\"}),a.jsx(\"ul\",{className:\"text-xs text-red-300/80 space-y-0.5\",children:fe.errors.map((W,ne)=>a.jsx(\"li\",{children:W},ne))})]})]})]})]})]}),C&&a.jsx(yn,{title:t(\"settings.amsSyncTitle\"),message:t(\"settings.amsSyncMessage\"),confirmText:t(\"settings.amsSyncButton\"),variant:\"warning\",isLoading:J.isPending,loadingText:t(\"settings.amsSyncing\"),onConfirm:()=>J.mutate(),onCancel:()=>P(!1)})]})}function ont(){const{t}=Ft(),{showToast:e}=hn(),[n,r]=w.useState([]),[i,s]=w.useState(!0),[o,l]=w.useState(\"\"),c=w.useRef(null),[d,u]=w.useState(!1),[m,p]=w.useState(null),[f,y]=w.useState(\"\"),[v,b]=w.useState(\"\"),[g,_]=w.useState(!1),[C,P]=w.useState(new Set),[N,A]=w.useState(!1),[T,F]=w.useState(null),[k,D]=w.useState(!1),H=w.useCallback(async()=>{try{const be=await ue.getSpoolCatalog();r(be)}catch{e(t(\"settings.catalog.loadFailed\"),\"error\")}finally{s(!1)}},[e,t]);w.useEffect(()=>{H()},[H]);const z=n.filter(be=>be.name.toLowerCase().includes(o.toLowerCase())),Q=async()=>{if(!f.trim()||!v){e(t(\"settings.catalog.nameWeightRequired\"),\"error\");return}_(!0);try{const be=await ue.addCatalogEntry({name:f.trim(),weight:parseInt(v)});r(Ce=>[...Ce,be].sort((q,Y)=>q.name.localeCompare(Y.name))),u(!1),y(\"\"),b(\"\"),e(t(\"settings.catalog.entryAdded\"),\"success\")}catch{e(t(\"settings.catalog.addFailed\"),\"error\")}finally{_(!1)}},L=be=>{p(be.id),y(be.name),b(be.weight.toString())},te=()=>{p(null),y(\"\"),b(\"\")},ie=async be=>{if(!f.trim()||!v){e(t(\"settings.catalog.nameWeightRequired\"),\"error\");return}_(!0);try{const Ce=await ue.updateCatalogEntry(be,{name:f.trim(),weight:parseInt(v)});r(q=>q.map(Y=>Y.id===be?Ce:Y).sort((Y,E)=>Y.name.localeCompare(E.name))),p(null),y(\"\"),b(\"\"),e(t(\"settings.catalog.entryUpdated\"),\"success\")}catch{e(t(\"settings.catalog.updateFailed\"),\"error\")}finally{_(!1)}},J=async()=>{if(T)try{await ue.deleteCatalogEntry(T.id),r(be=>be.filter(Ce=>Ce.id!==T.id)),e(t(\"settings.catalog.entryDeleted\"),\"success\")}catch{e(t(\"settings.catalog.deleteFailed\"),\"error\")}finally{F(null)}},oe=async()=>{D(!1),s(!0);try{await ue.resetSpoolCatalog(),await H(),e(t(\"settings.catalog.resetSuccess\"),\"success\")}catch{e(t(\"settings.catalog.resetFailed\"),\"error\"),s(!1)}},fe=be=>{P(Ce=>{const q=new Set(Ce);return q.has(be)?q.delete(be):q.add(be),q})},re=()=>{C.size===z.length?P(new Set):P(new Set(z.map(be=>be.id)))},W=async()=>{if(A(!1),C.size!==0)try{const be=await ue.bulkDeleteCatalogEntries([...C]);r(Ce=>Ce.filter(q=>!C.has(q.id))),P(new Set),e(t(\"settings.catalog.bulkDeleted\",{count:be.deleted}),\"success\")}catch{e(t(\"settings.catalog.bulkDeleteFailed\"),\"error\")}},ne=()=>{const be=n.map(({name:E,weight:j})=>({name:E,weight:j})),Ce=new Blob([JSON.stringify(be,null,2)],{type:\"application/json\"}),q=URL.createObjectURL(Ce),Y=document.createElement(\"a\");Y.href=q,Y.download=\"spool-catalog.json\",document.body.appendChild(Y),Y.click(),document.body.removeChild(Y),URL.revokeObjectURL(q),e(t(\"settings.catalog.exported\",{count:n.length}),\"success\")},me=async be=>{const Ce=be.target.files?.[0];if(Ce){try{const q=await Ce.text(),Y=JSON.parse(q);if(!Array.isArray(Y))throw new Error(\"Invalid format\");let E=0,j=0;for(const O of Y){if(!O.name||typeof O.weight!=\"number\"){j++;continue}if(n.some(U=>U.name.toLowerCase()===O.name.toLowerCase())){j++;continue}try{const U=await ue.addCatalogEntry({name:O.name,weight:O.weight});r(de=>[...de,U].sort((I,G)=>I.name.localeCompare(G.name))),E++}catch{j++}}e(t(\"settings.catalog.imported\",{added:E,skipped:j}),\"success\")}catch{e(t(\"settings.catalog.importFailed\"),\"error\")}c.current&&(c.current.value=\"\")}};return a.jsxs(Tt,{id:\"card-spool-catalog\",children:[a.jsxs(gn,{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3\",children:[a.jsx(Jl,{className:\"w-5 h-5 text-bambu-gray\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.catalog.spoolCatalog\")}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[\"(\",n.length,\")\"]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-wrap\",children:[a.jsxs(\"button\",{onClick:ne,className:\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\",title:t(\"settings.catalog.exportTooltip\"),children:[a.jsx(ga,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.export\")})]}),a.jsxs(\"button\",{onClick:()=>c.current?.click(),className:\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\",title:t(\"settings.catalog.importTooltip\"),children:[a.jsx(La,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.import\")})]}),a.jsx(\"input\",{ref:c,type:\"file\",accept:\".json\",className:\"hidden\",onChange:me}),a.jsxs(\"button\",{onClick:()=>D(!0),className:\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\",title:t(\"settings.catalog.resetTooltip\"),children:[a.jsx(co,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.reset\")})]}),a.jsxs(\"button\",{onClick:()=>u(!0),className:\"px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5\",children:[a.jsx(sr,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.add\")})]})]}),C.size>0&&a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg\",children:[a.jsx(\"span\",{className:\"text-sm text-red-400\",children:t(\"settings.catalog.selectedCount\",{count:C.size})}),a.jsxs(\"button\",{onClick:()=>A(!0),className:\"ml-auto px-3 py-1.5 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5\",children:[a.jsx(en,{className:\"w-4 h-4\"}),t(\"settings.catalog.deleteSelected\")]}),a.jsx(\"button\",{onClick:()=>P(new Set),className:\"px-3 py-1.5 text-sm text-bambu-gray hover:text-white transition-colors\",children:t(\"common.cancel\")})]})]}),a.jsxs(Mt,{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"settings.catalog.spoolCatalogDescription\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",className:\"w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\",placeholder:t(\"settings.catalog.searchCatalog\"),value:o,onChange:be=>l(be.target.value)})]}),d&&a.jsxs(\"div\",{className:\"p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white mb-3\",children:t(\"settings.catalog.addNewEntry\")}),a.jsxs(\"div\",{className:\"flex gap-2 items-center\",children:[a.jsx(\"div\",{className:\"flex-1 min-w-0\",children:a.jsx(\"input\",{type:\"text\",className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\",placeholder:t(\"settings.catalog.namePlaceholder\"),value:f,onChange:be=>y(be.target.value)})}),a.jsx(\"input\",{type:\"number\",className:\"w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none\",placeholder:\"g\",value:v,onChange:be=>b(be.target.value)}),a.jsx(\"span\",{className:\"text-bambu-gray shrink-0\",children:\"g\"}),a.jsxs(\"button\",{onClick:Q,disabled:g,className:\"px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0\",children:[g?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Ur,{className:\"w-4 h-4\"}),t(\"common.add\")]}),a.jsx(\"button\",{onClick:()=>{u(!1),y(\"\"),b(\"\")},className:\"p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]})]}),i?a.jsxs(\"div\",{className:\"flex items-center justify-center py-8 text-bambu-gray\",children:[a.jsx(ft,{className:\"w-5 h-5 animate-spin mr-2\"}),t(\"common.loading\")]}):a.jsx(\"div\",{className:\"max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg\",children:a.jsxs(\"table\",{className:\"w-full text-sm\",children:[a.jsx(\"thead\",{className:\"bg-bambu-dark sticky top-0\",children:a.jsxs(\"tr\",{children:[a.jsx(\"th\",{className:\"px-2 py-2 w-10\",children:a.jsx(\"input\",{type:\"checkbox\",checked:z.length>0&&C.size===z.length,onChange:re,className:\"w-4 h-4 accent-bambu-green cursor-pointer\"})}),a.jsx(\"th\",{className:\"px-4 py-2 text-left text-bambu-gray font-medium\",children:t(\"common.name\")}),a.jsx(\"th\",{className:\"px-4 py-2 text-right text-bambu-gray font-medium w-24\",children:t(\"settings.catalog.weight\")}),a.jsx(\"th\",{className:\"px-4 py-2 text-center text-bambu-gray font-medium w-20\",children:t(\"settings.catalog.type\")}),a.jsx(\"th\",{className:\"px-4 py-2 w-24\"})]})}),a.jsx(\"tbody\",{children:z.length===0?a.jsx(\"tr\",{children:a.jsx(\"td\",{colSpan:5,className:\"px-4 py-8 text-center text-bambu-gray\",children:t(o?\"settings.catalog.noMatch\":\"settings.catalog.empty\")})}):z.map(be=>a.jsx(\"tr\",{className:`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${C.has(be.id)?\"bg-bambu-dark\":\"\"}`,children:m===be.id?a.jsxs(a.Fragment,{children:[a.jsx(\"td\",{className:\"px-2 py-2\",children:a.jsx(\"input\",{type:\"checkbox\",checked:C.has(be.id),onChange:()=>fe(be.id),className:\"w-4 h-4 accent-bambu-green cursor-pointer\"})}),a.jsx(\"td\",{className:\"px-4 py-2\",children:a.jsx(\"input\",{type:\"text\",className:\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none\",value:f,onChange:Ce=>y(Ce.target.value)})}),a.jsx(\"td\",{className:\"px-4 py-2\",children:a.jsx(\"input\",{type:\"number\",className:\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none\",value:v,onChange:Ce=>b(Ce.target.value)})}),a.jsx(\"td\",{className:\"px-4 py-2 text-center\",children:a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"-\"})}),a.jsx(\"td\",{className:\"px-4 py-2\",children:a.jsxs(\"div\",{className:\"flex justify-end gap-1\",children:[a.jsx(\"button\",{onClick:()=>ie(be.id),disabled:g,className:\"p-1.5 rounded hover:bg-green-500/20 text-green-500\",children:g?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Ur,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:te,className:\"p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]})})]}):a.jsxs(a.Fragment,{children:[a.jsx(\"td\",{className:\"px-2 py-2\",children:a.jsx(\"input\",{type:\"checkbox\",checked:C.has(be.id),onChange:()=>fe(be.id),className:\"w-4 h-4 accent-bambu-green cursor-pointer\"})}),a.jsx(\"td\",{className:\"px-4 py-2 text-white\",children:be.name}),a.jsxs(\"td\",{className:\"px-4 py-2 text-right font-mono text-white\",children:[be.weight,\"g\"]}),a.jsx(\"td\",{className:\"px-4 py-2 text-center\",children:be.is_default?a.jsx(\"span\",{className:\"text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray\",children:t(\"settings.catalog.default\")}):a.jsx(\"span\",{className:\"text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green\",children:t(\"settings.catalog.custom\")})}),a.jsx(\"td\",{className:\"px-4 py-2\",children:a.jsxs(\"div\",{className:\"flex justify-end gap-1\",children:[a.jsx(\"button\",{onClick:()=>L(be),className:\"p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\",children:a.jsx(ci,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>F(be),className:\"p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500\",children:a.jsx(en,{className:\"w-4 h-4\"})})]})})]})},be.id))})]})})]}),T&&a.jsx(yn,{title:t(\"settings.catalog.deleteEntry\"),message:t(\"settings.catalog.deleteConfirm\",{name:T.name}),confirmText:t(\"common.delete\"),variant:\"danger\",onConfirm:J,onCancel:()=>F(null)}),N&&a.jsx(yn,{title:t(\"settings.catalog.deleteSelected\"),message:t(\"settings.catalog.bulkDeleteConfirm\",{count:C.size}),confirmText:t(\"common.delete\"),variant:\"danger\",onConfirm:W,onCancel:()=>A(!1)}),k&&a.jsx(yn,{title:t(\"settings.catalog.resetCatalog\"),message:t(\"settings.catalog.resetConfirm\"),confirmText:t(\"common.reset\"),variant:\"danger\",onConfirm:oe,onCancel:()=>D(!1)})]})}function lnt(){const{t}=Ft(),{showToast:e}=hn(),[n,r]=w.useState([]),[i,s]=w.useState(!0),[o,l]=w.useState(\"\"),[c,d]=w.useState(\"\"),u=w.useRef(null),[m,p]=w.useState(!1),[f,y]=w.useState(null),[v,b]=w.useState(\"\"),[g,_]=w.useState(\"\"),[C,P]=w.useState(\"#FFFFFF\"),[N,A]=w.useState(\"\"),[T,F]=w.useState(!1),[k,D]=w.useState(new Set),[H,z]=w.useState(!1),[Q,L]=w.useState(!1),[te,ie]=w.useState(null),[J,oe]=w.useState(null),[fe,re]=w.useState(!1),W=w.useCallback(async()=>{try{const V=await ue.getColorCatalog();r(V)}catch{e(t(\"settings.colorCatalog.loadFailed\"),\"error\")}finally{s(!1)}},[e,t]);w.useEffect(()=>{W()},[W]);const ne=[...new Set(n.map(V=>V.manufacturer))].sort(),me=n.filter(V=>{const ee=o===\"\"||V.manufacturer.toLowerCase().includes(o.toLowerCase())||V.color_name.toLowerCase().includes(o.toLowerCase())||(V.material?.toLowerCase().includes(o.toLowerCase())??!1),se=c===\"\"||V.manufacturer===c;return ee&&se}),be=()=>{b(\"\"),_(\"\"),P(\"#FFFFFF\"),A(\"\")},Ce=async()=>{if(!v.trim()||!g.trim()||!C){e(t(\"settings.colorCatalog.fieldsRequired\"),\"error\");return}F(!0);try{const V=await ue.addColorEntry({manufacturer:v.trim(),color_name:g.trim(),hex_color:C,material:N.trim()||null});r(ee=>[...ee,V].sort((se,ge)=>se.manufacturer.localeCompare(ge.manufacturer)||(se.material||\"\").localeCompare(ge.material||\"\")||se.color_name.localeCompare(ge.color_name))),p(!1),be(),e(t(\"settings.colorCatalog.colorAdded\"),\"success\")}catch{e(t(\"settings.colorCatalog.addFailed\"),\"error\")}finally{F(!1)}},q=V=>{y(V.id),b(V.manufacturer),_(V.color_name),P(V.hex_color),A(V.material||\"\")},Y=()=>{y(null),be()},E=async V=>{if(!v.trim()||!g.trim()||!C){e(t(\"settings.colorCatalog.fieldsRequired\"),\"error\");return}F(!0);try{const ee=await ue.updateColorEntry(V,{manufacturer:v.trim(),color_name:g.trim(),hex_color:C,material:N.trim()||null});r(se=>se.map(ge=>ge.id===V?ee:ge).sort((ge,he)=>ge.manufacturer.localeCompare(he.manufacturer)||(ge.material||\"\").localeCompare(he.material||\"\")||ge.color_name.localeCompare(he.color_name))),y(null),be(),e(t(\"settings.colorCatalog.colorUpdated\"),\"success\")}catch{e(t(\"settings.colorCatalog.updateFailed\"),\"error\")}finally{F(!1)}},j=async()=>{if(J)try{await ue.deleteColorEntry(J.id),r(V=>V.filter(ee=>ee.id!==J.id)),e(t(\"settings.colorCatalog.colorDeleted\"),\"success\")}catch{e(t(\"settings.colorCatalog.deleteFailed\"),\"error\")}finally{oe(null)}},O=async()=>{re(!1),s(!0);try{await ue.resetColorCatalog(),await W(),e(t(\"settings.colorCatalog.resetSuccess\"),\"success\")}catch{e(t(\"settings.colorCatalog.resetFailed\"),\"error\"),s(!1)}},K=async()=>{L(!0),ie(null);try{const V={},ee=vm();ee&&(V.Authorization=`Bearer ${ee}`);const se=await fetch(\"/api/v1/inventory/colors/sync\",{method:\"POST\",headers:V});if(!se.ok)throw new Error(\"Failed to start sync\");const ge=se.body?.getReader();if(!ge)throw new Error(\"No response body\");const he=new TextDecoder;let le=\"\";for(;;){const{done:B,value:R}=await ge.read();if(B)break;le+=he.decode(R,{stream:!0});const ae=le.split(`\n`);le=ae.pop()||\"\";for(const _e of ae)if(_e.startsWith(\"data: \"))try{const Se=JSON.parse(_e.slice(6));Se.type===\"progress\"?ie({fetched:Se.total_fetched,total:Se.total_available}):Se.type===\"complete\"?Se.added===0?e(t(\"settings.colorCatalog.syncUpToDate\",{count:Se.total_fetched}),\"success\"):e(t(\"settings.colorCatalog.syncComplete\",{added:Se.added,skipped:Se.skipped}),\"success\"):Se.type===\"error\"&&e(`${t(\"settings.colorCatalog.syncError\")}: ${Se.error}`,\"error\")}catch{}}await W()}catch{e(t(\"settings.colorCatalog.syncFailed\"),\"error\")}finally{L(!1),ie(null)}},U=V=>{D(ee=>{const se=new Set(ee);return se.has(V)?se.delete(V):se.add(V),se})},de=()=>{k.size===me.length?D(new Set):D(new Set(me.map(V=>V.id)))},I=async()=>{if(z(!1),k.size!==0)try{const V=await ue.bulkDeleteColorEntries([...k]);r(ee=>ee.filter(se=>!k.has(se.id))),D(new Set),e(t(\"settings.colorCatalog.bulkDeleted\",{count:V.deleted}),\"success\")}catch{e(t(\"settings.colorCatalog.bulkDeleteFailed\"),\"error\")}},G=()=>{const V=n.map(({manufacturer:he,color_name:le,hex_color:B,material:R})=>({manufacturer:he,color_name:le,hex_color:B,material:R})),ee=new Blob([JSON.stringify(V,null,2)],{type:\"application/json\"}),se=URL.createObjectURL(ee),ge=document.createElement(\"a\");ge.href=se,ge.download=\"color-catalog.json\",document.body.appendChild(ge),ge.click(),document.body.removeChild(ge),URL.revokeObjectURL(se),e(t(\"settings.colorCatalog.exported\",{count:n.length}),\"success\")},X=async V=>{const ee=V.target.files?.[0];if(ee){try{const se=await ee.text(),ge=JSON.parse(se);if(!Array.isArray(ge))throw new Error(\"Invalid format\");let he=0,le=0;for(const B of ge){if(!B.manufacturer||!B.color_name||!B.hex_color){le++;continue}if(n.some(ae=>ae.manufacturer.toLowerCase()===B.manufacturer.toLowerCase()&&ae.color_name.toLowerCase()===B.color_name.toLowerCase()&&(ae.material||\"\").toLowerCase()===(B.material||\"\").toLowerCase())){le++;continue}try{const ae=await ue.addColorEntry({manufacturer:B.manufacturer,color_name:B.color_name,hex_color:B.hex_color,material:B.material||null});r(_e=>[..._e,ae].sort((Se,ve)=>Se.manufacturer.localeCompare(ve.manufacturer)||(Se.material||\"\").localeCompare(ve.material||\"\")||Se.color_name.localeCompare(ve.color_name))),he++}catch{le++}}e(t(\"settings.colorCatalog.imported\",{added:he,skipped:le}),\"success\")}catch{e(t(\"settings.colorCatalog.importFailed\"),\"error\")}u.current&&(u.current.value=\"\")}};return a.jsxs(Tt,{id:\"card-color-catalog\",children:[a.jsxs(gn,{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3\",children:[a.jsx(nS,{className:\"w-5 h-5 text-bambu-gray\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.colorCatalog.title\")}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[\"(\",n.length,\")\"]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-wrap\",children:[a.jsxs(\"button\",{onClick:G,className:\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\",children:[a.jsx(ga,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.export\")})]}),a.jsxs(\"button\",{onClick:()=>u.current?.click(),className:\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\",children:[a.jsx(La,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.import\")})]}),a.jsx(\"input\",{ref:u,type:\"file\",accept:\".json\",className:\"hidden\",onChange:X}),a.jsxs(\"button\",{onClick:K,disabled:Q,className:\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\",title:t(\"settings.colorCatalog.syncTooltip\"),children:[Q?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(vy,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:Q?te?`${Math.min(te.fetched,te.total)} / ${te.total}`:t(\"settings.colorCatalog.starting\"):t(\"settings.colorCatalog.sync\")})]}),a.jsxs(\"button\",{onClick:()=>re(!0),className:\"px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5\",children:[a.jsx(co,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.reset\")})]}),a.jsxs(\"button\",{onClick:()=>p(!0),className:\"px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5\",children:[a.jsx(sr,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.add\")})]})]}),k.size>0&&a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg\",children:[a.jsx(\"span\",{className:\"text-sm text-red-400\",children:t(\"settings.colorCatalog.selectedCount\",{count:k.size})}),a.jsxs(\"button\",{onClick:()=>z(!0),className:\"ml-auto px-3 py-1.5 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors flex items-center gap-1.5\",children:[a.jsx(en,{className:\"w-4 h-4\"}),t(\"settings.colorCatalog.deleteSelected\")]}),a.jsx(\"button\",{onClick:()=>D(new Set),className:\"px-3 py-1.5 text-sm text-bambu-gray hover:text-white transition-colors\",children:t(\"common.cancel\")})]})]}),a.jsxs(Mt,{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"settings.colorCatalog.description\")}),a.jsxs(\"div\",{className:\"flex gap-2 flex-wrap\",children:[a.jsxs(\"div\",{className:\"relative flex-1 min-w-[200px]\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",className:\"w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\",placeholder:t(\"settings.colorCatalog.searchColors\"),value:o,onChange:V=>l(V.target.value)})]}),a.jsxs(\"select\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",value:c,onChange:V=>d(V.target.value),children:[a.jsx(\"option\",{value:\"\",children:t(\"settings.colorCatalog.allManufacturers\")}),ne.map(V=>a.jsx(\"option\",{value:V,children:V},V))]})]}),m&&a.jsxs(\"div\",{className:\"p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white mb-3\",children:t(\"settings.colorCatalog.addNewColor\")}),a.jsxs(\"div\",{className:\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-2 items-end\",children:[a.jsx(\"input\",{type:\"text\",className:\"px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\",placeholder:t(\"settings.colorCatalog.manufacturer\"),value:v,onChange:V=>b(V.target.value)}),a.jsx(\"input\",{type:\"text\",className:\"px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\",placeholder:t(\"settings.colorCatalog.colorName\"),value:g,onChange:V=>_(V.target.value)}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"color\",className:\"w-20 h-10 rounded cursor-pointer border border-bambu-dark-tertiary\",value:C,onChange:V=>P(V.target.value)}),a.jsx(\"input\",{type:\"text\",className:\"w-32 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\",placeholder:\"#FFFFFF\",value:C,onChange:V=>P(V.target.value)})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"input\",{type:\"text\",className:\"flex-1 max-w-lg min-w-[200px] px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\",placeholder:t(\"settings.colorCatalog.materialOptional\"),value:N,onChange:V=>A(V.target.value)}),a.jsxs(\"button\",{onClick:Ce,disabled:T,className:\"w-24 px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center justify-center gap-3\",children:[T?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Ur,{className:\"w-4 h-4\"}),t(\"common.add\")]}),a.jsx(\"button\",{onClick:()=>{p(!1),be()},className:\"p-2 ml-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]})]})]}),(o||c)&&a.jsx(\"div\",{className:\"text-xs text-bambu-gray\",children:t(\"settings.colorCatalog.showing\",{filtered:me.length,total:n.length})}),i?a.jsxs(\"div\",{className:\"flex items-center justify-center py-8 text-bambu-gray\",children:[a.jsx(ft,{className:\"w-5 h-5 animate-spin mr-2\"}),t(\"common.loading\")]}):a.jsx(\"div\",{className:\"max-h-[600px] overflow-auto border border-bambu-dark-tertiary rounded-lg\",children:a.jsxs(\"table\",{className:\"w-full text-sm\",children:[a.jsx(\"thead\",{className:\"bg-bambu-dark sticky top-0\",children:a.jsxs(\"tr\",{children:[a.jsx(\"th\",{className:\"px-2 py-2 w-10\",children:a.jsx(\"input\",{type:\"checkbox\",checked:me.length>0&&k.size===me.length,onChange:de,className:\"w-4 h-4 accent-bambu-green cursor-pointer\"})}),a.jsx(\"th\",{className:\"px-3 py-2 text-left text-bambu-gray font-medium w-12\"}),a.jsx(\"th\",{className:\"px-3 py-2 text-left text-bambu-gray font-medium\",children:t(\"settings.colorCatalog.manufacturer\")}),a.jsx(\"th\",{className:\"px-3 py-2 text-left text-bambu-gray font-medium\",children:t(\"inventory.material\")}),a.jsx(\"th\",{className:\"px-3 py-2 text-left text-bambu-gray font-medium\",children:t(\"settings.colorCatalog.colorName\")}),a.jsx(\"th\",{className:\"px-3 py-2 text-left text-bambu-gray font-medium w-24\",children:t(\"settings.colorCatalog.hex\")}),a.jsx(\"th\",{className:\"px-3 py-2 w-16\"})]})}),a.jsx(\"tbody\",{children:me.length===0?a.jsx(\"tr\",{children:a.jsx(\"td\",{colSpan:7,className:\"px-3 py-8 text-center text-bambu-gray\",children:t(o||c?\"settings.colorCatalog.noMatch\":\"settings.colorCatalog.empty\")})}):me.map(V=>a.jsx(\"tr\",{className:`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${k.has(V.id)?\"bg-bambu-dark\":\"\"}`,children:f===V.id?a.jsxs(a.Fragment,{children:[a.jsx(\"td\",{className:\"px-2 py-2\",children:a.jsx(\"input\",{type:\"checkbox\",checked:k.has(V.id),onChange:()=>U(V.id),className:\"w-4 h-4 accent-bambu-green cursor-pointer\"})}),a.jsx(\"td\",{className:\"px-3 py-2\",children:a.jsx(\"input\",{type:\"color\",className:\"w-8 h-8 rounded cursor-pointer border border-bambu-dark-tertiary\",value:C,onChange:ee=>P(ee.target.value)})}),a.jsx(\"td\",{className:\"px-3 py-2\",children:a.jsx(\"input\",{type:\"text\",className:\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\",value:v,onChange:ee=>b(ee.target.value)})}),a.jsx(\"td\",{className:\"px-3 py-2\",children:a.jsx(\"input\",{type:\"text\",className:\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\",value:N,onChange:ee=>A(ee.target.value)})}),a.jsx(\"td\",{className:\"px-3 py-2\",children:a.jsx(\"input\",{type:\"text\",className:\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\",value:g,onChange:ee=>_(ee.target.value)})}),a.jsx(\"td\",{className:\"px-3 py-2\",children:a.jsx(\"input\",{type:\"text\",className:\"w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\",value:C,onChange:ee=>P(ee.target.value)})}),a.jsx(\"td\",{className:\"px-3 py-2\",children:a.jsxs(\"div\",{className:\"flex justify-end gap-1\",children:[a.jsx(\"button\",{onClick:()=>E(V.id),disabled:T,className:\"p-1.5 rounded hover:bg-green-500/20 text-green-500\",children:T?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Ur,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:Y,className:\"p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]})})]}):a.jsxs(a.Fragment,{children:[a.jsx(\"td\",{className:\"px-2 py-2\",children:a.jsx(\"input\",{type:\"checkbox\",checked:k.has(V.id),onChange:()=>U(V.id),className:\"w-4 h-4 accent-bambu-green cursor-pointer\"})}),a.jsx(\"td\",{className:\"px-3 py-2\",children:a.jsx(\"div\",{className:\"w-8 h-8 rounded border border-bambu-dark-tertiary\",style:{backgroundColor:V.hex_color},title:V.hex_color})}),a.jsx(\"td\",{className:\"px-3 py-2 text-white\",children:V.manufacturer}),a.jsx(\"td\",{className:\"px-3 py-2 text-bambu-gray\",children:V.material||\"-\"}),a.jsx(\"td\",{className:\"px-3 py-2 text-white\",children:V.color_name}),a.jsx(\"td\",{className:\"px-3 py-2 font-mono text-xs text-bambu-gray\",children:V.hex_color}),a.jsx(\"td\",{className:\"px-3 py-2\",children:a.jsxs(\"div\",{className:\"flex justify-end gap-1\",children:[a.jsx(\"button\",{onClick:()=>q(V),className:\"p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\",children:a.jsx(ci,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>oe(V),className:\"p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500\",children:a.jsx(en,{className:\"w-4 h-4\"})})]})})]})},V.id))})]})})]}),J&&a.jsx(yn,{title:t(\"settings.colorCatalog.deleteColor\"),message:t(\"settings.colorCatalog.deleteConfirm\",{name:`${J.manufacturer} - ${J.color_name}`}),confirmText:t(\"common.delete\"),variant:\"danger\",onConfirm:j,onCancel:()=>oe(null)}),H&&a.jsx(yn,{title:t(\"settings.colorCatalog.deleteSelected\"),message:t(\"settings.colorCatalog.bulkDeleteConfirm\",{count:k.size}),confirmText:t(\"common.delete\"),variant:\"danger\",onConfirm:I,onCancel:()=>z(!1)}),fe&&a.jsx(yn,{title:t(\"settings.colorCatalog.resetCatalog\"),message:t(\"settings.colorCatalog.resetConfirm\"),confirmText:t(\"common.reset\"),variant:\"danger\",onConfirm:O,onCancel:()=>re(!1)})]})}function cnt({link:t,onClose:e}){const{t:n}=Ft(),r=nn(),i=!!t,s=w.useRef(null),[o,l]=w.useState(t?.name||\"\"),[c,d]=w.useState(t?.url||\"\"),[u,m]=w.useState(t?.icon||\"link\"),[p,f]=w.useState(t?.open_in_new_tab||!1),[y,v]=w.useState(!!t?.custom_icon),[b,g]=w.useState(t?.custom_icon?ue.getExternalLinkIconUrl(t.id):null),[_,C]=w.useState(null),[P,N]=w.useState(null);w.useEffect(()=>{const Q=L=>{L.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",Q),()=>window.removeEventListener(\"keydown\",Q)},[e]);const A=it({mutationFn:async Q=>{const L=await ue.createExternalLink(Q);return _?await ue.uploadExternalLinkIcon(L.id,_):L},onSuccess:()=>{r.invalidateQueries({queryKey:[\"external-links\"]}),e()},onError:Q=>{N(Q.message)}}),T=it({mutationFn:async Q=>{let L=await ue.updateExternalLink(t.id,Q);return _?L=await ue.uploadExternalLinkIcon(t.id,_):!y&&t?.custom_icon&&(L=await ue.deleteExternalLinkIcon(t.id)),L},onSuccess:()=>{r.invalidateQueries({queryKey:[\"external-links\"]}),e()},onError:Q=>{N(Q.message)}}),F=Q=>{const L=Q.target.files?.[0];if(L){if(![\"image/png\",\"image/jpeg\",\"image/gif\",\"image/svg+xml\",\"image/webp\",\"image/x-icon\"].includes(L.type)){N(\"Please select a valid image file (PNG, JPG, GIF, SVG, WebP, or ICO)\");return}if(L.size>1024*1024){N(\"Image file must be less than 1MB\");return}C(L),v(!0);const ie=new FileReader;ie.onload=J=>{g(J.target?.result)},ie.readAsDataURL(L)}},k=()=>{C(null),g(null),v(!1),s.current&&(s.current.value=\"\")},D=Q=>{if(Q.preventDefault(),N(null),!o.trim()){N(\"Name is required\");return}if(!c.trim()){N(\"URL is required\");return}if(!c.startsWith(\"http://\")&&!c.startsWith(\"https://\")){N(\"URL must start with http:// or https://\");return}const L={name:o.trim(),url:c.trim(),icon:u,open_in_new_tab:p};i?T.mutate(L):A.mutate(L)},H=A.isPending||T.isPending,z=WP(u);return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:e,children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md\",onClick:Q=>Q.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"p-2 rounded-full bg-bambu-green/20 text-bambu-green\",children:y&&b?a.jsx(\"img\",{src:b,alt:\"\",className:\"w-5 h-5 rounded\"}):a.jsx(z,{className:\"w-5 h-5\"})}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i?\"Edit Link\":\"Add External Link\"})]}),a.jsx(\"button\",{onClick:e,className:\"text-bambu-gray hover:text-white transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"form\",{onSubmit:D,className:\"p-6 space-y-4\",children:[P&&a.jsx(\"div\",{className:\"p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400\",children:P}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:\"Name *\"}),a.jsx(\"input\",{type:\"text\",value:o,onChange:Q=>l(Q.target.value),placeholder:\"My Link\",maxLength:50,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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:\"URL *\"}),a.jsx(\"input\",{type:\"text\",value:c,onChange:Q=>d(Q.target.value),placeholder:\"https://example.com\",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\"})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"label\",{className:\"text-sm text-bambu-gray\",children:n(\"externalLinks.openInNewTab\")}),a.jsx(\"button\",{type:\"button\",onClick:()=>f(!p),className:`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${p?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(\"span\",{className:`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${p?\"translate-x-6\":\"translate-x-1\"}`})})]}),a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray\",children:\"Icon\"}),a.jsxs(\"div\",{className:\"p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"span\",{className:\"text-sm text-white\",children:\"Custom Icon\"}),a.jsx(\"input\",{ref:s,type:\"file\",accept:\"image/png,image/jpeg,image/gif,image/svg+xml,image/webp,image/x-icon\",className:\"hidden\",onChange:F}),y&&b?a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"img\",{src:b,alt:\"Custom icon\",className:\"w-8 h-8 rounded border border-bambu-dark-tertiary\"}),a.jsx(\"button\",{type:\"button\",onClick:k,className:\"p-1 text-red-400 hover:text-red-300 transition-colors\",title:\"Remove custom icon\",children:a.jsx(en,{className:\"w-4 h-4\"})})]}):a.jsxs(De,{type:\"button\",variant:\"secondary\",size:\"sm\",onClick:()=>s.current?.click(),children:[a.jsx(La,{className:\"w-4 h-4\"}),\"Upload\"]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:\"PNG, JPG, GIF, SVG, WebP, or ICO. Max 1MB.\"})]}),!y&&a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray block mb-2\",children:\"Or choose a preset icon\"}),a.jsx(wye,{value:u,onChange:m})]})]}),a.jsxs(\"div\",{className:\"flex gap-3 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:e,className:\"flex-1\",children:\"Cancel\"}),a.jsxs(De,{type:\"submit\",disabled:H,className:\"flex-1\",children:[H?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(_s,{className:\"w-4 h-4\"}),i?\"Save\":\"Add\"]})]})]})]})})}function dnt(){const{t}=Ft(),e=nn(),[n,r]=w.useState(!1),[i,s]=w.useState(null),[o,l]=w.useState(null),[c,d]=w.useState(null),{data:u,isLoading:m}=Xe({queryKey:[\"external-links\"],queryFn:ue.getExternalLinks}),p=it({mutationFn:C=>ue.deleteExternalLink(C),onSuccess:()=>{e.invalidateQueries({queryKey:[\"external-links\"]})}}),f=it({mutationFn:C=>ue.reorderExternalLinks(C),onSuccess:()=>{e.invalidateQueries({queryKey:[\"external-links\"]})}}),y=(C,P)=>{d(P),C.dataTransfer.effectAllowed=\"move\"},v=C=>{C.preventDefault(),C.dataTransfer.dropEffect=\"move\"},b=(C,P)=>{if(C.preventDefault(),c===null||c===P||!u)return;const N=u.map(k=>k.id),A=N.indexOf(c),T=N.indexOf(P);if(A===-1||T===-1)return;const F=[...N];F.splice(A,1),F.splice(T,0,c),f.mutate(F),d(null)},g=C=>{l(C)},_=()=>{o&&(p.mutate(o.id),l(null))};return a.jsxs(a.Fragment,{children:[a.jsxs(Tt,{id:\"card-sidebar-links\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(sm,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:\"Sidebar Links\"})]}),a.jsxs(De,{size:\"sm\",onClick:()=>r(!0),children:[a.jsx(sr,{className:\"w-4 h-4\"}),\"Add Link\"]})]})}),a.jsxs(Mt,{children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-4\",children:\"Add external links to the sidebar navigation. Drag to reorder.\"}),m?a.jsx(\"div\",{className:\"flex justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})}):u&&u.length>0?a.jsx(\"div\",{className:\"space-y-2\",children:u.map(C=>{const P=WP(C.icon);return a.jsxs(\"div\",{draggable:!0,onDragStart:N=>y(N,C.id),onDragOver:v,onDrop:N=>b(N,C.id),className:`flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${c===C.id?\"opacity-50\":\"\"}`,children:[a.jsx(np,{className:\"w-6 h-6 md:w-4 md:h-4 text-bambu-gray cursor-grab flex-shrink-0\"}),a.jsx(\"div\",{className:\"p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray\",children:a.jsx(P,{className:\"w-4 h-4\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-white font-medium truncate\",children:C.name}),a.jsx(la,{className:\"w-3 h-3 text-bambu-gray flex-shrink-0\"})]}),a.jsx(\"span\",{className:\"text-sm text-bambu-gray truncate block\",children:C.url})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 flex-shrink-0\",children:[a.jsx(\"button\",{onClick:()=>s(C),className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors\",title:t(\"common.edit\"),children:a.jsx(ci,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>g(C),disabled:p.isPending,className:\"p-2 rounded-lg hover:bg-red-500/20 text-bambu-gray hover:text-red-400 transition-colors disabled:opacity-50\",title:t(\"externalLinks.deleteLink\"),children:a.jsx(en,{className:\"w-4 h-4\"})})]})]},C.id)})}):a.jsxs(\"div\",{className:\"text-center py-8 text-bambu-gray\",children:[a.jsx(sm,{className:\"w-8 h-8 mx-auto mb-2 opacity-50\"}),a.jsx(\"p\",{children:t(\"externalLinks.noLinksConfigured\")}),a.jsx(\"p\",{className:\"text-sm\",children:'Click \"Add Link\" to add one'})]})]})]}),(n||i)&&a.jsx(cnt,{link:i,onClose:()=>{r(!1),s(null)}}),o&&a.jsx(yn,{title:\"Delete Link\",message:`Are you sure you want to delete \"${o.name}\"? This action cannot be undone.`,confirmText:\"Delete\",cancelText:\"Cancel\",variant:\"danger\",onConfirm:_,onCancel:()=>l(null)})]})}const k3={immediate:\"archive\",review:\"review\",print_queue:\"queue\",proxy:\"proxy\"};function unt({printer:t,models:e}){const{t:n}=Ft(),r=nn(),{showToast:i}=hn(),[s,o]=w.useState(!0),[l,c]=w.useState(t.enabled),[d,u]=w.useState(t.name),[m,p]=w.useState(\"\"),[f,y]=w.useState(t.mode===\"queue\"?\"review\":t.mode),[v,b]=w.useState(t.target_printer_id),[g,_]=w.useState(t.bind_ip||\"\"),[C,P]=w.useState(t.remote_interface_ip||\"\"),[N,A]=w.useState(t.model||\"\"),[T,F]=w.useState(t.auto_dispatch??!0),[k,D]=w.useState(!1),[H,z]=w.useState(null),[Q,L]=w.useState(!1);w.useEffect(()=>{H||(c(t.enabled),y(t.mode===\"queue\"?\"review\":t.mode),u(t.name),b(t.target_printer_id),_(t.bind_ip||\"\"),P(t.remote_interface_ip||\"\"),A(t.model||\"\"),F(t.auto_dispatch??!0))},[t,H]);const{data:te}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:ie}=Xe({queryKey:[\"network-interfaces\"],queryFn:()=>ue.getNetworkInterfaces().then(j=>j.interfaces)}),J=it({mutationFn:j=>PN.update(t.id,j),onSuccess:()=>{r.invalidateQueries({queryKey:[\"virtual-printers\"]}),i(n(\"virtualPrinter.toast.updated\")),z(null)},onError:j=>{i(j.message||n(\"virtualPrinter.toast.failedToUpdate\"),\"error\"),c(t.enabled),y(t.mode===\"queue\"?\"review\":t.mode),b(t.target_printer_id),_(t.bind_ip||\"\"),z(null)}}),oe=it({mutationFn:()=>PN.remove(t.id),onSuccess:()=>{r.invalidateQueries({queryKey:[\"virtual-printers\"]}),i(n(\"virtualPrinter.toast.deleted\")),L(!1)},onError:j=>{i(j.message||n(\"virtualPrinter.toast.failedToDelete\"),\"error\"),L(!1)}}),fe=j=>{j.stopPropagation();const O=!l;if(O){if(!g){i(n(\"virtualPrinter.toast.bindIpRequired\"),\"error\");return}if(f===\"proxy\"){if(!v){i(n(\"virtualPrinter.toast.targetPrinterRequired\"),\"error\");return}}else if(!m&&!t.access_code_set){i(n(\"virtualPrinter.toast.accessCodeRequired\"),\"error\");return}}c(O),z(\"toggle\"),J.mutate({enabled:O})},re=()=>{d.trim()&&(z(\"name\"),J.mutate({name:d.trim()}))},W=()=>{if(!m){i(n(\"virtualPrinter.toast.accessCodeEmpty\"),\"error\");return}if(m.length!==8){i(n(\"virtualPrinter.toast.accessCodeLength\"),\"error\");return}z(\"accessCode\"),J.mutate({access_code:m}),p(\"\")},ne=j=>{y(j),z(\"mode\"),J.mutate({mode:j})},me=j=>{A(j),z(\"model\"),J.mutate({model:j})},be=j=>{b(j),z(\"targetPrinter\"),J.mutate({target_printer_id:j})},Ce=j=>{P(j),z(\"remoteInterface\"),J.mutate({remote_interface_ip:j})},q=t.status?.running||!1,Y=n(`virtualPrinter.mode.${k3[f]||\"archive\"}`),E=te?.find(j=>j.id===v)?.name;return a.jsxs(a.Fragment,{children:[a.jsxs(Tt,{children:[a.jsxs(\"div\",{className:\"px-4 py-3 flex items-center gap-3 cursor-pointer select-none\",onClick:()=>o(!s),children:[a.jsx(\"button\",{className:\"text-bambu-gray flex-shrink-0\",children:s?a.jsx(On,{className:\"w-4 h-4\"}):a.jsx(ti,{className:\"w-4 h-4\"})}),a.jsx(\"span\",{className:`w-2 h-2 rounded-full flex-shrink-0 ${q?\"bg-green-400 animate-pulse\":\"bg-gray-500\"}`}),a.jsx(\"span\",{className:\"text-white font-medium truncate\",children:t.name}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray flex-shrink-0\",children:Y}),t.model_name&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray flex-shrink-0\",children:t.model_name}),E&&a.jsxs(\"span\",{className:\"text-xs text-bambu-gray flex-shrink-0 truncate\",children:[f===\"proxy\"&&a.jsx(nF,{className:\"w-3 h-3 inline mr-1\"}),E]}),g&&a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray flex-shrink-0 font-mono\",children:g}),C&&a.jsx(\"span\",{className:\"text-[10px] text-bambu-gray flex-shrink-0 font-mono\",children:C}),a.jsx(\"div\",{className:\"ml-auto flex items-center gap-2 flex-shrink-0\",onClick:j=>j.stopPropagation(),children:a.jsx(\"button\",{onClick:fe,disabled:H===\"toggle\",className:`relative w-10 h-5 rounded-full transition-colors ${l?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"} ${H===\"toggle\"?\"opacity-50\":\"\"}`,children:a.jsx(\"span\",{className:`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${l?\"translate-x-5\":\"\"}`})})})]}),s&&a.jsxs(Mt,{className:\"pt-0 space-y-4\",children:[a.jsx(\"div\",{className:\"border-t border-bambu-dark-tertiary\"}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"text\",value:d,onChange:j=>u(j.target.value),onBlur:re,onKeyDown:j=>j.key===\"Enter\"&&re(),className:\"flex-1 text-sm text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 focus:border-bambu-green focus:outline-none\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray font-mono\",children:t.serial}),a.jsx(\"button\",{onClick:()=>L(!0),className:\"p-1.5 text-bambu-gray hover:text-red-400 transition-colors\",title:n(\"common.delete\"),children:a.jsx(en,{className:\"w-4 h-4\"})})]}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-white text-sm font-medium mb-2\",children:n(\"virtualPrinter.mode.title\")}),a.jsx(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[\"immediate\",\"review\",\"print_queue\",\"proxy\"].map(j=>a.jsxs(\"button\",{onClick:()=>ne(j),disabled:H===\"mode\",className:`p-2 rounded-lg border text-left transition-colors ${f===j?j===\"proxy\"?\"border-blue-500 bg-blue-500/10\":\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary hover:border-bambu-gray\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-white text-xs font-medium\",children:[j===\"proxy\"&&a.jsx(nF,{className:\"w-3 h-3\"}),n(`virtualPrinter.mode.${k3[j]}`)]}),a.jsx(\"div\",{className:\"text-[10px] text-bambu-gray\",children:n(`virtualPrinter.mode.${k3[j]}Desc`)})]},j))})]}),f===\"print_queue\"&&a.jsx(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-white text-sm font-medium\",children:n(\"virtualPrinter.autoDispatch.title\")}),a.jsx(\"div\",{className:\"text-[10px] text-bambu-gray\",children:n(\"virtualPrinter.autoDispatch.description\")})]}),a.jsx(\"button\",{onClick:()=>{const j=!T;F(j),z(\"autoDispatch\"),J.mutate({auto_dispatch:j})},disabled:H===\"autoDispatch\",className:`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${T?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"} ${H===\"autoDispatch\"?\"opacity-50\":\"\"}`,children:a.jsx(\"span\",{className:`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${T?\"translate-x-5\":\"\"}`})})]})}),f!==\"proxy\"&&a.jsxs(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"text-white text-sm font-medium mb-1\",children:n(\"virtualPrinter.model.title\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:n(\"virtualPrinter.model.description\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"select\",{value:N,onChange:j=>me(j.target.value),disabled:H===\"model\",className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10\",children:Object.entries(e).map(([j,O])=>a.jsxs(\"option\",{value:j,children:[O,\" (\",j,\")\"]},j))}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]})]}),f===\"proxy\"&&a.jsx(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"div\",{className:\"flex items-start gap-2 p-2 rounded bg-blue-500/10 border border-blue-500/30\",children:[a.jsx(Ss,{className:\"w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"virtualPrinter.proxy.accessCodeHint\")})]})}),f!==\"proxy\"&&a.jsxs(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-2\",children:[a.jsx(\"div\",{className:\"text-white text-sm font-medium\",children:n(\"virtualPrinter.accessCode.title\")}),t.access_code_set?a.jsxs(\"span\",{className:\"flex items-center gap-1 text-xs text-green-400\",children:[a.jsx(Ur,{className:\"w-3 h-3\"}),n(\"virtualPrinter.accessCode.isSet\")]}):a.jsxs(\"span\",{className:\"flex items-center gap-1 text-xs text-yellow-400\",children:[a.jsx(Dn,{className:\"w-3 h-3\"}),n(\"virtualPrinter.accessCode.notSet\")]})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(\"input\",{type:k?\"text\":\"password\",value:m,onChange:j=>p(j.target.value),placeholder:t.access_code_set?n(\"virtualPrinter.accessCode.placeholderChange\"):n(\"virtualPrinter.accessCode.placeholder\"),maxLength:8,className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm placeholder-bambu-gray pr-10 font-mono\"}),a.jsx(\"button\",{onClick:()=>D(!k),className:\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\",children:k?a.jsx(Og,{className:\"w-4 h-4\"}):a.jsx(yl,{className:\"w-4 h-4\"})})]}),a.jsx(De,{onClick:W,disabled:!m||H===\"accessCode\",variant:\"primary\",children:H===\"accessCode\"?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):n(\"common.save\")})]}),m&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:a.jsx(\"span\",{className:m.length===8?\"text-green-400\":\"text-yellow-400\",children:n(\"virtualPrinter.accessCode.charCount\",{count:m.length})})})]}),a.jsxs(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"text-white text-sm font-medium mb-2\",children:n(\"virtualPrinter.targetPrinter.title\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:v??\"\",onChange:j=>{const O=parseInt(j.target.value,10);isNaN(O)||be(O)},disabled:H===\"targetPrinter\",className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10\",children:[a.jsx(\"option\",{value:\"\",children:n(\"virtualPrinter.targetPrinter.placeholder\")}),te?.map(j=>a.jsxs(\"option\",{value:j.id,children:[j.name,\" (\",j.ip_address,\")\"]},j.id))]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]})]}),a.jsxs(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"text-white text-sm font-medium mb-1\",children:n(\"virtualPrinter.bindIp.title\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:g,onChange:j=>{_(j.target.value),z(\"bindIp\"),J.mutate({bind_ip:j.target.value})},disabled:H===\"bindIp\",className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10\",children:[a.jsx(\"option\",{value:\"\",children:n(\"virtualPrinter.bindIp.placeholder\")}),ie?.map(j=>a.jsxs(\"option\",{value:j.ip,children:[j.name,\" (\",j.ip,\")\",j.is_alias?\" [alias]\":\"\",\" - \",j.subnet]},j.ip))]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(\"virtualPrinter.bindIp.hint\")})]}),a.jsxs(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsx(\"div\",{className:\"text-white text-sm font-medium\",children:n(\"virtualPrinter.remoteInterface.title\")}),C?a.jsx(\"span\",{className:\"flex items-center gap-1 text-xs text-green-400\",children:a.jsx(Ur,{className:\"w-3 h-3\"})}):a.jsx(\"span\",{className:\"flex items-center gap-1 text-xs text-bambu-gray\",title:n(\"virtualPrinter.remoteInterface.optional\"),children:a.jsx(Ss,{className:\"w-3 h-3\"})})]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:C,onChange:j=>Ce(j.target.value),disabled:H===\"remoteInterface\",className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10\",children:[a.jsx(\"option\",{value:\"\",children:n(\"virtualPrinter.remoteInterface.placeholder\")}),ie?.map(j=>a.jsxs(\"option\",{value:j.ip,children:[j.name,\" (\",j.ip,\") - \",j.subnet]},j.ip))]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]})]})]})]}),Q&&a.jsx(yn,{title:n(\"virtualPrinter.deleteConfirm.title\"),message:n(\"virtualPrinter.deleteConfirm.message\",{name:t.name}),variant:\"danger\",confirmText:n(\"common.delete\"),isLoading:oe.isPending,onConfirm:()=>oe.mutate(),onCancel:()=>L(!1)})]})}const bY={immediate:\"archive\",review:\"review\",print_queue:\"queue\",proxy:\"proxy\"};function mnt({onClose:t}){const{t:e}=Ft(),n=nn(),{showToast:r}=hn(),[i,s]=w.useState(\"\"),[o,l]=w.useState(\"immediate\"),[c,d]=w.useState(null),{data:u}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),m=it({mutationFn:()=>PN.create({name:i.trim()||\"Bambuddy\",mode:o,target_printer_id:o===\"proxy\"?c??void 0:void 0}),onSuccess:()=>{n.invalidateQueries({queryKey:[\"virtual-printers\"]}),r(e(\"virtualPrinter.toast.created\")),t()},onError:p=>{r(p.message||e(\"virtualPrinter.toast.failedToCreate\"),\"error\")}});return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",onClick:t,children:a.jsx(Tt,{className:\"w-full max-w-md\",onClick:p=>p.stopPropagation(),children:a.jsxs(Mt,{className:\"p-6 space-y-4\",children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white\",children:e(\"virtualPrinter.addDialog.title\")}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-sm text-white font-medium block mb-1\",children:e(\"virtualPrinter.addDialog.name\")}),a.jsx(\"input\",{type:\"text\",value:i,onChange:p=>s(p.target.value),placeholder:\"Bambuddy\",className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm placeholder-bambu-gray\",autoFocus:!0})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-sm text-white font-medium block mb-1\",children:e(\"virtualPrinter.mode.title\")}),a.jsx(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[\"immediate\",\"review\",\"print_queue\",\"proxy\"].map(p=>a.jsxs(\"button\",{onClick:()=>l(p),className:`p-2 rounded-lg border text-left transition-colors ${o===p?p===\"proxy\"?\"border-blue-500 bg-blue-500/10\":\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary hover:border-bambu-gray\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-white text-xs font-medium\",children:[p===\"proxy\"&&a.jsx(nF,{className:\"w-3 h-3\"}),e(`virtualPrinter.mode.${bY[p]}`)]}),a.jsx(\"div\",{className:\"text-[10px] text-bambu-gray\",children:e(`virtualPrinter.mode.${bY[p]}Desc`)})]},p))})]}),o===\"proxy\"&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"text-sm text-white font-medium block mb-1\",children:e(\"virtualPrinter.targetPrinter.title\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:c??\"\",onChange:p=>{const f=parseInt(p.target.value,10);d(isNaN(f)?null:f)},className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm appearance-none cursor-pointer pr-10\",children:[a.jsx(\"option\",{value:\"\",children:e(\"virtualPrinter.targetPrinter.placeholder\")}),u?.map(p=>a.jsxs(\"option\",{value:p.id,children:[p.name,\" (\",p.ip_address,\")\"]},p.id))]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:e(\"virtualPrinter.addDialog.hint\")}),a.jsxs(\"div\",{className:\"flex gap-3 pt-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:t,className:\"flex-1\",disabled:m.isPending,children:e(\"common.cancel\")}),a.jsx(De,{variant:\"primary\",onClick:()=>m.mutate(),className:\"flex-1\",disabled:m.isPending,children:m.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):e(\"virtualPrinter.addDialog.create\")})]})]})})})}function hnt(){const{t}=Ft(),[e,n]=w.useState(!1),{data:r,isLoading:i}=Xe({queryKey:[\"virtual-printers\"],queryFn:PN.list,refetchInterval:1e4});if(i)return a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-8 flex justify-center\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-green\"})})});const s=r?.printers||[],o=r?.models||{};return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-1 lg:grid-cols-4 gap-4 items-stretch\",children:[a.jsx(Tt,{className:\"border-l-4 border-l-yellow-500\",children:a.jsx(Mt,{className:\"py-3 px-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-2\",children:[a.jsx(Dn,{className:\"w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5\"}),a.jsxs(\"div\",{className:\"text-xs\",children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"virtualPrinter.setupRequired.title\")}),a.jsx(\"p\",{className:\"text-bambu-gray mt-1\",children:t(\"virtualPrinter.setupRequired.description\")}),a.jsxs(\"a\",{href:\"https://wiki.bambuddy.cool/features/virtual-printer/\",target:\"_blank\",rel:\"noopener noreferrer\",className:\"inline-flex items-center gap-1.5 mt-2 px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/50 rounded text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs\",children:[a.jsx(la,{className:\"w-3 h-3\"}),t(\"virtualPrinter.setupRequired.readGuide\")]})]})]})})}),a.jsx(Tt,{className:\"lg:col-span-3\",children:a.jsx(Mt,{className:\"py-3 px-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-2\",children:[a.jsx(Ss,{className:\"w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5\"}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray\",children:[a.jsx(\"p\",{className:\"text-white font-medium mb-1\",children:t(\"virtualPrinter.howItWorks.title\")}),a.jsxs(\"ul\",{className:\"space-y-1 list-disc list-inside\",children:[a.jsx(\"li\",{children:t(\"virtualPrinter.howItWorks.step1\")}),a.jsx(\"li\",{children:t(\"virtualPrinter.howItWorks.step2\")}),a.jsx(\"li\",{children:t(\"virtualPrinter.howItWorks.step3\")})]})]})]})})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Er,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"virtualPrinter.list.title\")}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[\"(\",s.length,\")\"]})]}),a.jsxs(De,{variant:\"primary\",onClick:()=>n(!0),children:[a.jsx(sr,{className:\"w-4 h-4 mr-1\"}),t(\"virtualPrinter.list.add\")]})]}),s.length===0?a.jsx(Tt,{children:a.jsxs(Mt,{className:\"py-8 text-center\",children:[a.jsx(Er,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-3\"}),a.jsx(\"p\",{className:\"text-bambu-gray mb-4\",children:t(\"virtualPrinter.list.empty\")}),a.jsxs(De,{variant:\"primary\",onClick:()=>n(!0),children:[a.jsx(sr,{className:\"w-4 h-4 mr-1\"}),t(\"virtualPrinter.list.addFirst\")]})]})}):a.jsx(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 items-start\",children:s.map(l=>a.jsx(unt,{printer:l,models:o},l.id))}),e&&a.jsx(mnt,{onClose:()=>n(!1)})]})}function xY(t){if(t<60)return`${t}s`;const e=Math.floor(t/60);if(e<60)return`${e}m`;const n=Math.floor(e/60),r=e%60;if(n<24)return r?`${n}h ${r}m`:`${n}h`;const i=Math.floor(n/24),s=n%24;return s?`${i}d ${s}h`:`${i}d`}function yY(t){return t==null?\"—\":t>=1024?`${(t/1024).toFixed(1)} GB`:`${Math.round(t)} MB`}function pnt({device:t,onUnregister:e,isDeleting:n}){const{t:r}=Ft(),{showToast:i}=hn(),s=t.system_stats,o=s?.memory,l=s?.disk,c=t.online,[d,u]=w.useState(null),[m,p]=w.useState(null),f=async g=>{p(g);try{g===\"update\"?await Gr.triggerUpdate(t.device_id):await Gr.systemCommand(t.device_id,g),i(r(\"settings.spoolbuddy.commandQueued\"),\"success\")}catch(_){const C=_ instanceof Error?_.message:r(\"settings.spoolbuddy.commandError\");i(C,\"error\")}finally{p(null),u(null)}},y=[{key:\"update\",label:r(\"settings.spoolbuddy.update\"),icon:ga},{key:\"restart_browser\",label:r(\"settings.spoolbuddy.restartBrowser\"),icon:FO},{key:\"restart_daemon\",label:r(\"settings.spoolbuddy.restartDaemon\"),icon:lr},{key:\"reboot\",label:r(\"settings.spoolbuddy.reboot\"),icon:sw},{key:\"shutdown\",label:r(\"settings.spoolbuddy.shutdown\"),icon:Yd,variant:\"danger\"}],v={update:r(\"settings.spoolbuddy.updateConfirmTitle\"),restart_browser:r(\"settings.spoolbuddy.restartBrowserConfirmTitle\"),restart_daemon:r(\"settings.spoolbuddy.restartDaemonConfirmTitle\"),reboot:r(\"settings.spoolbuddy.rebootConfirmTitle\"),shutdown:r(\"settings.spoolbuddy.shutdownConfirmTitle\")},b={update:r(\"settings.spoolbuddy.updateConfirmBody\",{hostname:t.hostname}),restart_browser:r(\"settings.spoolbuddy.restartBrowserConfirmBody\",{hostname:t.hostname}),restart_daemon:r(\"settings.spoolbuddy.restartDaemonConfirmBody\",{hostname:t.hostname}),reboot:r(\"settings.spoolbuddy.rebootConfirmBody\",{hostname:t.hostname}),shutdown:r(\"settings.spoolbuddy.shutdownConfirmBody\",{hostname:t.hostname})};return a.jsxs(Tt,{children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-start justify-between gap-3 flex-wrap\",children:[a.jsxs(\"div\",{className:\"min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"h3\",{className:\"text-base font-semibold text-white truncate\",children:t.hostname}),a.jsxs(\"span\",{className:`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${c?\"bg-green-500/15 text-green-400 border border-green-500/40\":\"bg-gray-500/15 text-gray-400 border border-gray-500/40\"}`,children:[c?a.jsx(rp,{className:\"w-3 h-3\"}):a.jsx(Bd,{className:\"w-3 h-3\"}),r(c?\"settings.spoolbuddy.online\":\"settings.spoolbuddy.offline\")]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray font-mono mt-1 truncate\",children:t.device_id})]}),a.jsxs(De,{variant:\"danger\",size:\"sm\",onClick:()=>e(t),disabled:n,\"aria-label\":r(\"settings.spoolbuddy.unregister\"),children:[a.jsx(en,{className:\"w-3.5 h-3.5\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:r(\"settings.spoolbuddy.unregister\")})]})]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs\",children:[a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-bambu-gray\",children:r(\"settings.spoolbuddy.ipAddress\")}),a.jsx(\"div\",{className:\"text-white font-mono\",children:t.ip_address})]}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-bambu-gray\",children:r(\"settings.spoolbuddy.firmware\")}),a.jsx(\"div\",{className:\"text-white\",children:t.firmware_version??\"—\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"text-bambu-gray flex items-center gap-1\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),r(\"settings.spoolbuddy.lastSeen\")]}),a.jsx(\"div\",{className:\"text-white\",children:t.last_seen?ap(t.last_seen):r(\"settings.spoolbuddy.never\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-bambu-gray\",children:r(\"settings.spoolbuddy.daemonUptime\")}),a.jsx(\"div\",{className:\"text-white\",children:xY(t.uptime_s)})]})]}),a.jsx(\"div\",{className:\"flex flex-wrap gap-2\",children:y.map(({key:g,label:_,icon:C,variant:P})=>a.jsxs(De,{variant:P??\"secondary\",size:\"sm\",onClick:()=>u(g),disabled:!c||m!==null,\"aria-label\":_,children:[m===g?a.jsx(ft,{className:\"w-3.5 h-3.5 animate-spin\"}):a.jsx(C,{className:\"w-3.5 h-3.5\"}),a.jsx(\"span\",{children:_})]},g))}),a.jsxs(\"div\",{className:\"flex items-center gap-3 text-xs flex-wrap\",children:[a.jsxs(\"span\",{className:\"flex items-center gap-1 text-bambu-gray\",children:[t.nfc_ok?a.jsx(Vc,{className:\"w-3.5 h-3.5 text-green-400\"}):a.jsx(Ma,{className:\"w-3.5 h-3.5 text-red-400\"}),r(\"settings.spoolbuddy.nfc\"),t.nfc_reader_type&&a.jsxs(\"span\",{className:\"text-bambu-gray/70\",children:[\"(\",t.nfc_reader_type,\")\"]})]}),a.jsxs(\"span\",{className:\"flex items-center gap-1 text-bambu-gray\",children:[t.scale_ok?a.jsx(Vc,{className:\"w-3.5 h-3.5 text-green-400\"}):a.jsx(Ma,{className:\"w-3.5 h-3.5 text-red-400\"}),r(\"settings.spoolbuddy.scale\")]})]}),s&&a.jsxs(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-xs\",children:[s.cpu_temp_c!==void 0&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(oS,{className:\"w-3.5 h-3.5 text-bambu-gray\"}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-bambu-gray\",children:r(\"settings.spoolbuddy.cpuTemp\")}),a.jsxs(\"div\",{className:\"text-white\",children:[s.cpu_temp_c.toFixed(1),\"°C\"]})]})]}),o&&o.percent!==void 0&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(u0,{className:\"w-3.5 h-3.5 text-bambu-gray\"}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-bambu-gray\",children:r(\"settings.spoolbuddy.memory\")}),a.jsxs(\"div\",{className:\"text-white\",children:[o.percent.toFixed(0),\"% (\",yY(o.used_mb),\" / \",yY(o.total_mb),\")\"]})]})]}),l&&l.percent!==void 0&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Ig,{className:\"w-3.5 h-3.5 text-bambu-gray\"}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-bambu-gray\",children:r(\"settings.spoolbuddy.disk\")}),a.jsxs(\"div\",{className:\"text-white\",children:[l.percent.toFixed(0),\"% (\",l.used_gb?.toFixed(1),\" / \",l.total_gb?.toFixed(1),\" GB)\"]})]})]}),s.system_uptime_s!==void 0&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Yn,{className:\"w-3.5 h-3.5 text-bambu-gray\"}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-bambu-gray\",children:r(\"settings.spoolbuddy.systemUptime\")}),a.jsx(\"div\",{className:\"text-white\",children:xY(s.system_uptime_s)})]})]})]}),s.os&&a.jsx(\"div\",{className:\"mt-3 text-xs text-bambu-gray font-mono truncate\",children:[s.os.os,s.os.kernel,s.os.arch,s.os.python&&`Python ${s.os.python}`].filter(Boolean).join(\" · \")})]})]}),d&&a.jsx(yn,{variant:d===\"shutdown\"||d===\"reboot\"?\"danger\":\"default\",title:v[d],message:b[d],confirmText:r(\"settings.spoolbuddy.commandConfirm\"),isLoading:m!==null,onConfirm:()=>f(d),onCancel:()=>u(null)})]})}function fnt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),[r,i]=w.useState(null),{data:s=[],isLoading:o}=Xe({queryKey:[\"spoolbuddy-devices\"],queryFn:()=>Gr.getDevices(),refetchInterval:15e3}),l=it({mutationFn:d=>Gr.deleteDevice(d),onSuccess:()=>{e.invalidateQueries({queryKey:[\"spoolbuddy-devices\"]}),n(t(\"settings.spoolbuddy.unregisterSuccess\"),\"success\"),i(null)},onError:d=>{n(d.message||t(\"settings.spoolbuddy.unregisterError\"),\"error\")}});if(o)return a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-8 flex justify-center\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-green\"})})});const c=s.length>1;return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-3 px-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-2 text-xs\",children:[a.jsx(Ss,{className:\"w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5\"}),a.jsxs(\"div\",{className:\"text-bambu-gray\",children:[a.jsx(\"p\",{className:\"text-white font-medium mb-1\",children:t(\"settings.spoolbuddy.infoTitle\")}),a.jsx(\"p\",{children:t(\"settings.spoolbuddy.infoBody\")})]})]})})}),c&&a.jsx(Tt,{className:\"border-l-4 border-l-yellow-500\",children:a.jsx(Mt,{className:\"py-3 px-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-2 text-xs\",children:[a.jsx(Dn,{className:\"w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5\"}),a.jsxs(\"div\",{className:\"text-bambu-gray\",children:[a.jsx(\"p\",{className:\"text-white font-medium mb-1\",children:t(\"settings.spoolbuddy.duplicatesTitle\",{count:s.length})}),a.jsx(\"p\",{children:t(\"settings.spoolbuddy.duplicatesBody\")})]})]})})}),s.length===0?a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-8 text-center text-bambu-gray text-sm\",children:t(\"settings.spoolbuddy.empty\")})}):a.jsx(\"div\",{className:\"space-y-3\",children:s.map(d=>a.jsx(pnt,{device:d,onUnregister:i,isDeleting:l.isPending&&l.variables===d.device_id},d.id))}),r&&a.jsx(yn,{variant:\"danger\",title:t(\"settings.spoolbuddy.confirmTitle\"),message:t(\"settings.spoolbuddy.confirmBody\",{hostname:r.hostname,deviceId:r.device_id}),confirmText:t(\"settings.spoolbuddy.unregister\"),isLoading:l.isPending,onConfirm:()=>l.mutate(r.device_id),onCancel:()=>i(null)})]})}function N3(t){if(!t)return\"-\";const e=Zn(t);return e?e.toLocaleString():\"-\"}function C3({status:t}){if(!t)return null;const e={success:\"bg-green-500/20 text-green-400\",failed:\"bg-red-500/20 text-red-400\",skipped:\"bg-yellow-500/20 text-yellow-400\",running:\"bg-blue-500/20 text-blue-400\"},n={success:a.jsx(yr,{className:\"w-3 h-3\"}),failed:a.jsx(Ma,{className:\"w-3 h-3\"}),skipped:a.jsx(GP,{className:\"w-3 h-3\"}),running:a.jsx(ft,{className:\"w-3 h-3 animate-spin\"})};return a.jsxs(\"span\",{className:`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${e[t]||\"bg-gray-500/20 text-gray-400\"}`,children:[n[t],t.charAt(0).toUpperCase()+t.slice(1)]})}function gnt(){const t=nn(),{showToast:e}=hn(),{t:n}=Ft(),[r,i]=w.useState(\"\"),[s,o]=w.useState(\"\"),[l,c]=w.useState(\"main\"),[d,u]=w.useState(!1),[m,p]=w.useState(\"daily\"),[f,y]=w.useState(!0),[v,b]=w.useState(!0),[g,_]=w.useState(!1),[C,P]=w.useState(!1),[N,A]=w.useState(!1),[T,F]=w.useState(!0),[k,D]=w.useState(!1),[H,z]=w.useState(!1),[Q,L]=w.useState(\"\"),[te,ie]=w.useState(!1),[J,oe]=w.useState(null),[fe,re]=w.useState(null),W=w.useRef(null),[ne,me]=w.useState(null),[be,Ce]=w.useState(null),[q,Y]=w.useState(\"\"),{data:E,refetch:j}=Xe({queryKey:[\"local-backup-status\"],queryFn:ue.getLocalBackupStatus,refetchInterval:pe=>pe.state.data?.is_running?1e3:1e4}),{data:O,refetch:K}=Xe({queryKey:[\"local-backup-files\"],queryFn:ue.getLocalBackups,refetchInterval:3e4});w.useEffect(()=>{E?.path!==void 0&&Y(E.path)},[E?.path]);const U=it({mutationFn:ue.triggerLocalBackup,onSuccess:pe=>{pe.success?e(n(\"backup.scheduledBackupComplete\")):e(pe.message,\"error\"),j(),K()},onError:()=>e(n(\"backup.scheduledBackupFailed\"),\"error\")}),de=it({mutationFn:pe=>ue.deleteLocalBackup(pe),onSuccess:()=>{K(),me(null)}}),I=it({mutationFn:async pe=>(Ce(null),z(!0),re(null),L(n(\"backup.restoring\")),ue.restoreLocalBackup(pe)),onSuccess:pe=>{z(!1),L(\"\"),pe.success?(re({success:!0,message:pe.message}),e(n(\"backup.backupRestoredRestart\"),\"success\")):(re({success:!1,message:pe.message}),e(pe.message,\"error\"))},onError:pe=>{z(!1),L(\"\");const Fe=pe instanceof Error?pe.message:n(\"backup.failedToRestore\");re({success:!1,message:Fe}),e(Fe,\"error\")}});w.useEffect(()=>{if(k||H){const Fe=we=>(we.preventDefault(),we.returnValue=\"A backup operation is in progress. Are you sure you want to leave?\",we.returnValue);return window.addEventListener(\"beforeunload\",Fe),()=>window.removeEventListener(\"beforeunload\",Fe)}},[k,H]);const[G,X]=w.useState(!1),[V,ee]=w.useState(null),se=w.useRef(null),ge=w.useRef(!1),{data:he,isLoading:le}=Xe({queryKey:[\"github-backup-config\"],queryFn:ue.getGitHubBackupConfig}),{data:B}=Xe({queryKey:[\"github-backup-status\"],queryFn:ue.getGitHubBackupStatus,refetchInterval:pe=>pe.state.data?.is_running?500:1e4}),{data:R}=Xe({queryKey:[\"github-backup-logs\"],queryFn:()=>ue.getGitHubBackupLogs(20)}),{data:ae}=Xe({queryKey:[\"cloud-status\"],queryFn:ue.getCloudStatus}),{data:_e}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),Se=Pp({queries:(_e??[]).map(pe=>({queryKey:[\"printerStatus\",pe.id],queryFn:()=>ue.getPrinterStatus(pe.id),staleTime:1e4,refetchInterval:3e4}))}),ve=(_e??[]).map((pe,Fe)=>({printer:pe,connected:Se[Fe]?.data?.connected??!1})),Te=ve.length,ye=ve.filter(pe=>pe.connected).length,je=Te>0&&ye===0,Le=ye>0&&ye<Te;w.useEffect(()=>{he&&(i(he.repository_url),c(he.branch),u(he.schedule_enabled),p(he.schedule_type),y(he.backup_kprofiles),b(he.backup_cloud_profiles),_(he.backup_settings),P(he.backup_spools),A(he.backup_archives),F(he.enabled),o(\"\"),setTimeout(()=>{ge.current=!0},100))},[he]);const Me=w.useCallback(async(pe=!1)=>{if(he?.has_token)try{pe&&s?(await ue.saveGitHubBackupConfig({repository_url:r,access_token:s,branch:l,schedule_enabled:d,schedule_type:m,backup_kprofiles:f,backup_cloud_profiles:v,backup_settings:g,backup_spools:C,backup_archives:N,enabled:T}),o(\"\"),e(n(\"backup.tokenUpdated\"))):(await ue.updateGitHubBackupConfig({repository_url:r,branch:l,schedule_enabled:d,schedule_type:m,backup_kprofiles:f,backup_cloud_profiles:v,backup_settings:g,backup_spools:C,backup_archives:N,enabled:T}),e(n(\"backup.settingsSaved\"))),t.invalidateQueries({queryKey:[\"github-backup-config\"]}),t.invalidateQueries({queryKey:[\"github-backup-status\"]})}catch(Fe){e(n(\"backup.failedToSave\",{message:Fe.message}),\"error\")}},[he?.has_token,r,s,l,d,m,f,v,g,C,N,T,t,e,n]);w.useEffect(()=>{if(!(!ge.current||!he?.has_token))return se.current&&clearTimeout(se.current),se.current=setTimeout(()=>{Me(!1)},500),()=>{se.current&&clearTimeout(se.current)}},[r,l,d,m,f,v,g,C,N,T,Me,he?.has_token]),w.useEffect(()=>{if(!(!ge.current||!he?.has_token||!s))return se.current&&clearTimeout(se.current),se.current=setTimeout(()=>{Me(!0)},1e3),()=>{se.current&&clearTimeout(se.current)}},[s,Me,he?.has_token]);const Oe=it({mutationFn:pe=>ue.saveGitHubBackupConfig(pe),onSuccess:()=>{t.invalidateQueries({queryKey:[\"github-backup-config\"]}),t.invalidateQueries({queryKey:[\"github-backup-status\"]}),e(n(\"backup.githubBackupEnabled\")),o(\"\"),ge.current=!0},onError:pe=>{e(n(\"backup.failedToSave\",{message:pe.message}),\"error\")}}),Re=it({mutationFn:ue.triggerGitHubBackup,onSuccess:pe=>{t.invalidateQueries({queryKey:[\"github-backup-status\"]}),t.invalidateQueries({queryKey:[\"github-backup-logs\"]}),pe.success?pe.files_changed>0?e(n(\"backup.backupCompleteFiles\",{count:pe.files_changed})):e(n(\"backup.backupSkippedNoChanges\")):e(n(\"backup.backupFailed2\",{message:pe.message}),\"error\")},onError:pe=>{e(n(\"backup.backupFailed2\",{message:pe.message}),\"error\")}}),$e=it({mutationFn:()=>ue.clearGitHubBackupLogs(0),onSuccess:pe=>{t.invalidateQueries({queryKey:[\"github-backup-logs\"]}),e(n(\"backup.clearedLogs\",{count:pe.deleted}))},onError:pe=>{e(n(\"backup.failedToClearLogs\",{message:pe.message}),\"error\")}}),Ye=async()=>{X(!0),ee(null);try{let pe;if(s){if(!r){e(n(\"backup.enterRepoUrl\"),\"error\"),X(!1);return}pe=await ue.testGitHubConnection(r,s)}else if(he?.has_token)pe=await ue.testGitHubStoredConnection();else{e(n(\"backup.enterRepoAndToken\"),\"error\"),X(!1);return}ee({success:pe.success,message:pe.message})}catch(pe){ee({success:!1,message:pe.message})}finally{X(!1)}},tt=()=>{if(!r){e(n(\"backup.repoRequired\"),\"error\");return}if(!s){e(n(\"backup.tokenRequired\"),\"error\");return}Oe.mutate({repository_url:r,access_token:s,branch:l,schedule_enabled:d,schedule_type:m,backup_kprofiles:f,backup_cloud_profiles:v,backup_settings:g,backup_spools:C,backup_archives:N,enabled:T})};return le?a.jsx(\"div\",{className:\"flex items-center justify-center py-12\",children:a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green\"})}):a.jsxs(\"div\",{className:\"grid grid-cols-1 lg:grid-cols-2 gap-6\",children:[a.jsxs(\"div\",{className:\"space-y-6\",children:[a.jsxs(Tt,{id:\"card-backup-github\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(aF,{className:\"w-5 h-5 text-gray-400\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:n(\"backup.githubBackup\")})]}),he&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:n(\"backup.enabled\")}),a.jsx(Ln,{checked:T,onChange:F})]})]})}),a.jsxs(Mt,{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"backup.githubDescription\")}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"backup.repositoryUrl\")}),a.jsx(\"input\",{type:\"text\",value:r,onChange:pe=>{i(pe.target.value),ee(null)},placeholder:\"https://github.com/username/bambuddy-backup\",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\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[n(\"backup.personalAccessToken\"),\" \",he?.has_token&&a.jsx(\"span\",{className:\"text-green-400\",children:n(\"backup.tokenSaved\")})]}),a.jsx(\"input\",{type:\"password\",value:s,onChange:pe=>{o(pe.target.value),ee(null)},placeholder:he?.has_token?n(\"backup.enterNewToken\"):\"ghp_xxxxxxxxxxxx\",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\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:n(\"backup.tokenHint\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"backup.branch\")}),a.jsx(\"input\",{type:\"text\",value:l,onChange:pe=>c(pe.target.value),placeholder:\"main\",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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"backup.autoBackup\")}),a.jsxs(\"select\",{value:d?m:\"disabled\",onChange:pe=>{pe.target.value===\"disabled\"?u(!1):(u(!0),p(pe.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\",children:[a.jsx(\"option\",{value:\"disabled\",children:n(\"backup.manualOnly\")}),a.jsx(\"option\",{value:\"hourly\",children:n(\"backup.hourly\")}),a.jsx(\"option\",{value:\"daily\",children:n(\"backup.daily\")}),a.jsx(\"option\",{value:\"weekly\",children:n(\"backup.weekly\")})]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:n(\"backup.includeInBackup\")}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"label\",{className:`flex items-start gap-2 ${je?\"cursor-not-allowed opacity-60\":\"cursor-pointer\"}`,children:[a.jsx(\"input\",{type:\"checkbox\",checked:f,onChange:pe=>y(pe.target.checked),className:\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\",disabled:je}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:`text-sm ${je?\"text-bambu-gray\":\"text-white\"}`,children:n(\"backup.kProfiles\")}),je&&a.jsxs(\"span\",{className:\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400\",children:[a.jsx(Dn,{className:\"w-3 h-3\"}),n(\"backup.noPrintersConnected\")]}),Le&&a.jsxs(\"span\",{className:\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400\",children:[a.jsx(Dn,{className:\"w-3 h-3\"}),n(\"backup.printersConnected\",{connected:ye,total:Te})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"backup.kProfilesDescription\")})]})]}),a.jsxs(\"label\",{className:`flex items-start gap-2 ${ae?.is_authenticated?\"cursor-pointer\":\"cursor-not-allowed opacity-60\"}`,children:[a.jsx(\"input\",{type:\"checkbox\",checked:v,onChange:pe=>b(pe.target.checked),className:\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\",disabled:!ae?.is_authenticated}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:`text-sm ${ae?.is_authenticated?\"text-white\":\"text-bambu-gray\"}`,children:n(\"backup.cloudProfiles\")}),!ae?.is_authenticated&&a.jsxs(\"span\",{className:\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400\",children:[a.jsx(Dn,{className:\"w-3 h-3\"}),n(\"backup.cloudLoginRequiredShort\")]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"backup.cloudProfilesDescription\")})]})]}),a.jsxs(\"label\",{className:\"flex items-start gap-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:g,onChange:pe=>_(pe.target.checked),className:\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-white text-sm\",children:n(\"backup.appSettings\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"backup.appSettingsDescription\")})]})]}),a.jsxs(\"label\",{className:\"flex items-start gap-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:C,onChange:pe=>P(pe.target.checked),className:\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-white text-sm\",children:n(\"backup.spoolInventory\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"backup.spoolInventoryDescription\")})]})]}),a.jsxs(\"label\",{className:\"flex items-start gap-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:N,onChange:pe=>A(pe.target.checked),className:\"w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-white text-sm\",children:n(\"backup.printArchives\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"backup.printArchivesDescription\")})]})]})]})]}),a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4 space-y-3\",children:[B?.configured&&a.jsxs(\"div\",{className:\"flex items-center justify-between text-sm\",children:[a.jsx(\"div\",{className:\"flex items-center gap-2 text-bambu-gray\",children:B.last_backup_at?a.jsxs(a.Fragment,{children:[a.jsxs(\"span\",{children:[n(\"backup.lastBackupAt\"),\" \",ap(B.last_backup_at,\"system\",n)]}),a.jsx(C3,{status:B.last_backup_status})]}):a.jsx(\"span\",{children:n(\"backup.noBackupsYet\")})}),B.next_scheduled_run&&a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[a.jsx(Yn,{className:\"w-3 h-3 inline mr-1\"}),n(\"backup.next\"),\" \",ap(B.next_scheduled_run,\"system\",n)]})]}),V&&a.jsxs(\"div\",{className:`text-sm flex items-center gap-1 ${V.success?\"text-green-400\":\"text-red-400\"}`,children:[V.success?a.jsx(yr,{className:\"w-4 h-4\"}):a.jsx(Ma,{className:\"w-4 h-4\"}),V.message]}),a.jsx(\"div\",{className:\"flex flex-wrap items-center gap-2\",children:B?.configured?a.jsx(a.Fragment,{children:Re.isPending||B.is_running?a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-green\",children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),a.jsx(\"span\",{className:\"text-sm\",children:B.progress||n(\"backup.startingBackup\")})]}):a.jsxs(a.Fragment,{children:[a.jsxs(De,{variant:\"primary\",size:\"sm\",onClick:()=>Re.mutate(),disabled:!he?.enabled,children:[a.jsx(es,{className:\"w-4 h-4\"}),n(\"backup.backupNow\")]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:Ye,disabled:G,children:[G?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(lr,{className:\"w-4 h-4\"}),n(\"backup.test\")]})]})}):a.jsxs(a.Fragment,{children:[a.jsxs(De,{variant:\"primary\",size:\"sm\",onClick:tt,disabled:Oe.isPending||!r||!s,children:[Oe.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(yr,{className:\"w-4 h-4\"}),n(\"backup.enableBackup\")]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:Ye,disabled:G||!r||!s,children:[G?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(lr,{className:\"w-4 h-4\"}),n(\"backup.testConnection\")]})]})})]})]})]}),R&&R.length>0&&a.jsxs(Tt,{id:\"card-backup-history\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(iw,{className:\"w-5 h-5 text-gray-400\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:n(\"backup.history\")})]}),a.jsxs(De,{variant:\"ghost\",size:\"sm\",onClick:()=>$e.mutate(),disabled:$e.isPending,children:[a.jsx(en,{className:\"w-4 h-4\"}),n(\"backup.clear\")]})]})}),a.jsx(Mt,{children:a.jsx(\"div\",{className:\"overflow-x-auto\",children:a.jsxs(\"table\",{className:\"w-full text-sm\",children:[a.jsx(\"thead\",{children:a.jsxs(\"tr\",{className:\"text-bambu-gray border-b border-bambu-dark-tertiary\",children:[a.jsx(\"th\",{className:\"text-left py-2 px-2\",children:n(\"backup.date\")}),a.jsx(\"th\",{className:\"text-left py-2 px-2\",children:n(\"backup.status\")}),a.jsx(\"th\",{className:\"text-left py-2 px-2\",children:n(\"backup.commit\")})]})}),a.jsx(\"tbody\",{children:R.slice(0,10).map(pe=>a.jsxs(\"tr\",{className:\"border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary\",children:[a.jsx(\"td\",{className:\"py-2 px-2 text-white\",children:N3(pe.started_at)}),a.jsx(\"td\",{className:\"py-2 px-2\",children:a.jsx(C3,{status:pe.status})}),a.jsx(\"td\",{className:\"py-2 px-2\",children:pe.commit_sha?a.jsxs(\"a\",{href:`${he?.repository_url}/commit/${pe.commit_sha}`,target:\"_blank\",rel:\"noopener noreferrer\",className:\"text-bambu-green hover:underline inline-flex items-center gap-1\",children:[pe.commit_sha.substring(0,7),a.jsx(la,{className:\"w-3 h-3\"})]}):a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"-\"})})]},pe.id))})]})})})]})]}),a.jsxs(\"div\",{className:\"space-y-6\",children:[a.jsxs(Tt,{id:\"card-backup-local\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Jl,{className:\"w-5 h-5 text-gray-400\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:n(\"backup.localBackup\")})]})}),a.jsxs(Mt,{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"backup.localBackupDescription\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between py-3 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:n(\"backup.downloadBackupLabel\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"backup.completeBackupZip\")})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",disabled:k||H,onClick:async()=>{D(!0),L(n(\"backup.preparingBackup\"));try{L(n(\"backup.creatingArchive\"));const{blob:pe,filename:Fe}=await ue.exportBackup();L(n(\"backup.downloadingFile\"));const we=URL.createObjectURL(pe),Ve=document.createElement(\"a\");Ve.href=we,Ve.download=Fe,Ve.click(),URL.revokeObjectURL(we),e(n(\"backup.backupDownloaded\"))}catch(pe){e(n(\"backup.failedToCreateBackup\",{message:pe instanceof Error?pe.message:\"Unknown error\"}),\"error\")}finally{D(!1),L(\"\")}},children:[a.jsx(ga,{className:\"w-4 h-4\"}),n(\"backup.download\")]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between py-3 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:n(\"backup.restoreBackup\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"backup.restoreDescription\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray-light mt-1\",children:n(\"backup.restoreNote\")})]}),a.jsx(\"input\",{ref:W,type:\"file\",accept:\".zip\",className:\"hidden\",onChange:pe=>{const Fe=pe.target.files?.[0];Fe&&(oe(Fe),ie(!0)),pe.target.value=\"\"}}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",disabled:H||k,onClick:()=>W.current?.click(),children:[a.jsx(La,{className:\"w-4 h-4\"}),n(\"backup.restore\")]})]}),fe&&a.jsx(\"div\",{className:`p-3 rounded-lg ${fe.success?\"bg-green-500/10 border border-green-500/30\":\"bg-red-500/10 border border-red-500/30\"}`,children:a.jsxs(\"div\",{className:\"flex items-start gap-2 text-sm\",children:[fe.success?a.jsx(yr,{className:\"w-4 h-4 text-green-400 mt-0.5 flex-shrink-0\"}):a.jsx(Ma,{className:\"w-4 h-4 text-red-400 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:fe.success?\"text-green-200\":\"text-red-200\",children:[fe.message,fe.success&&a.jsx(\"div\",{className:\"mt-2\",children:a.jsxs(De,{size:\"sm\",onClick:()=>window.location.reload(),children:[a.jsx(co,{className:\"w-3 h-3\"}),n(\"backup.reloadNow\")]})})]})]})}),a.jsx(\"div\",{className:\"p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30\",children:a.jsxs(\"div\",{className:\"flex items-start gap-2 text-sm\",children:[a.jsx(Dn,{className:\"w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"text-yellow-200\",children:[a.jsx(\"span\",{className:\"font-medium\",children:n(\"backup.restoreReplacesAll\")}),\" \",a.jsx(\"span\",{className:\"text-yellow-200/70\",children:n(\"backup.restoreReplacesAllDetail\")})]})]})})]})]}),a.jsxs(Tt,{id:\"card-backup-scheduled\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(mge,{className:\"w-5 h-5 text-gray-400\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:n(\"backup.scheduledBackup\")})]}),a.jsx(Ln,{checked:E?.enabled??!1,onChange:async pe=>{try{await ue.updateSettings({local_backup_enabled:pe}),e(n(\"backup.settingsSaved\"))}catch(Fe){e(n(\"backup.failedToSave\",{message:Fe instanceof Error?Fe.message:\"Unknown error\"}),\"error\")}j()}})]})}),a.jsxs(Mt,{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"backup.scheduledBackupDescription\")}),E?.enabled&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"grid grid-cols-3 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"backup.frequency\")}),a.jsxs(\"select\",{value:E?.schedule??\"daily\",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\",onChange:async pe=>{try{await ue.updateSettings({local_backup_schedule:pe.target.value}),e(n(\"backup.settingsSaved\"))}catch(Fe){e(n(\"backup.failedToSave\",{message:Fe instanceof Error?Fe.message:\"Unknown error\"}),\"error\")}j()},children:[a.jsx(\"option\",{value:\"hourly\",children:n(\"backup.hourly\")}),a.jsx(\"option\",{value:\"daily\",children:n(\"backup.daily\")}),a.jsx(\"option\",{value:\"weekly\",children:n(\"backup.weekly\")})]})]}),(E?.schedule??\"daily\")!==\"hourly\"&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"backup.backupTime\")}),a.jsx(\"input\",{type:\"time\",value:E?.time??\"03:00\",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 [color-scheme:dark]\",onChange:async pe=>{try{await ue.updateSettings({local_backup_time:pe.target.value}),e(n(\"backup.settingsSaved\"))}catch(Fe){e(n(\"backup.failedToSave\",{message:Fe instanceof Error?Fe.message:\"Unknown error\"}),\"error\")}j()}}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray-light mt-1\",children:n(\"backup.utc\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"backup.retention\")}),a.jsx(\"input\",{type:\"number\",min:1,max:100,value:E?.retention??5,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\",onChange:async pe=>{const Fe=Math.max(1,Math.min(100,parseInt(pe.target.value)||5));try{await ue.updateSettings({local_backup_retention:Fe}),e(n(\"backup.settingsSaved\"))}catch(we){e(n(\"backup.failedToSave\",{message:we instanceof Error?we.message:\"Unknown error\"}),\"error\")}j()}}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray-light mt-1\",children:n(\"backup.retentionDescription\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:n(\"backup.outputPath\")}),a.jsx(\"input\",{type:\"text\",value:q,onChange:pe=>Y(pe.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\",onBlur:async()=>{try{await ue.updateSettings({local_backup_path:q}),e(n(\"backup.settingsSaved\"))}catch(pe){e(n(\"backup.failedToSave\",{message:pe instanceof Error?pe.message:\"Unknown error\"}),\"error\")}j(),K()},onKeyDown:pe=>{pe.key===\"Enter\"&&pe.target.blur()}}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray-light mt-1\",children:q?n(\"backup.outputPathDescription\"):a.jsxs(a.Fragment,{children:[n(\"backup.defaultPathLabel\"),\" \",a.jsx(\"code\",{className:\"text-bambu-gray\",children:E?.default_path||\"...\"})]})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between py-3 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"text-sm\",children:[E?.last_backup_at&&a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray\",children:[a.jsxs(\"span\",{children:[n(\"backup.lastBackup\"),\":\"]}),a.jsx(C3,{status:E.last_status}),a.jsx(\"span\",{children:ap(E.last_backup_at)})]}),E?.next_run&&a.jsxs(\"div\",{className:\"text-bambu-gray mt-1\",children:[a.jsxs(\"span\",{children:[n(\"backup.nextBackup\"),\": \"]}),a.jsx(\"span\",{children:N3(E.next_run)})]})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",disabled:E?.is_running||U.isPending,onClick:()=>U.mutate(),children:[E?.is_running||U.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(es,{className:\"w-4 h-4\"}),E?.is_running?n(\"backup.backupRunning\"):n(\"backup.runNow\")]})]}),O&&O.length>0&&a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white mb-2\",children:n(\"backup.backupFiles\")}),a.jsx(\"div\",{className:\"space-y-1\",children:O.map(pe=>a.jsxs(\"div\",{className:\"flex items-center justify-between py-1.5 px-2 rounded hover:bg-bambu-dark-tertiary/50 text-sm\",children:[a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"span\",{className:\"text-white truncate block\",children:pe.filename}),a.jsxs(\"span\",{className:\"text-bambu-gray text-xs\",children:[(pe.size/1024/1024).toFixed(1),\" MB · \",N3(pe.created_at)]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 flex-shrink-0\",children:[a.jsx(\"button\",{className:\"text-bambu-gray hover:text-bambu-green p-1\",title:n(\"backup.download\"),onClick:async()=>{try{const{blob:Fe,filename:we}=await ue.downloadLocalBackup(pe.filename),Ve=URL.createObjectURL(Fe),Ae=document.createElement(\"a\");Ae.href=Ve,Ae.download=we,Ae.click(),URL.revokeObjectURL(Ve)}catch{e(n(\"backup.scheduledBackupFailed\"),\"error\")}},children:a.jsx(ga,{className:\"w-3.5 h-3.5\"})}),a.jsx(\"button\",{className:\"text-bambu-gray hover:text-yellow-400 p-1\",title:n(\"backup.restore\"),onClick:()=>Ce(pe.filename),children:a.jsx(co,{className:\"w-3.5 h-3.5\"})}),a.jsx(\"button\",{className:\"text-bambu-gray hover:text-red-400 p-1\",onClick:()=>me(pe.filename),title:n(\"backup.deleteBackup\"),children:a.jsx(en,{className:\"w-3.5 h-3.5\"})})]})]},pe.filename))})]}),O&&O.length===0&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray text-center py-3 border-t border-bambu-dark-tertiary\",children:n(\"backup.noScheduledBackups\")})]})]})]})]}),ne&&a.jsx(yn,{title:n(\"backup.deleteBackup\"),message:n(\"backup.deleteBackupConfirm\"),confirmText:n(\"backup.deleteBackup\"),variant:\"danger\",onConfirm:()=>de.mutate(ne),onCancel:()=>me(null)}),be&&a.jsx(yn,{title:n(\"backup.restoreConfirmTitle\"),message:n(\"backup.restoreConfirmMessage\",{filename:be}),confirmText:n(\"backup.restoreConfirmButton\"),variant:\"danger\",onConfirm:()=>I.mutate(be),onCancel:()=>Ce(null)}),te&&J&&a.jsx(yn,{title:n(\"backup.restoreConfirmTitle\"),message:n(\"backup.restoreConfirmMessage\",{filename:J.name}),confirmText:n(\"backup.restoreConfirmButton\"),variant:\"danger\",onConfirm:async()=>{ie(!1),z(!0),re(null);try{L(n(\"backup.uploadingFile\"));const pe=await ue.importBackup(J);re(pe),pe.success?e(n(\"backup.backupRestoredRestart\"),\"success\"):e(pe.message,\"error\")}catch(pe){const Fe=pe instanceof Error?pe.message:n(\"backup.failedToRestore\");re({success:!1,message:Fe}),e(Fe,\"error\")}finally{z(!1),L(\"\"),oe(null)}},onCancel:()=>{ie(!1),oe(null)}}),(k||H)&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/80 flex items-center justify-center z-[100]\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center\",children:[a.jsx(\"div\",{className:\"flex justify-center mb-4\",children:a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"div\",{className:\"w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full\"}),a.jsx(\"div\",{className:\"w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin\"})]})}),a.jsx(\"h3\",{className:\"text-xl font-semibold text-white mb-2\",children:n(k?\"backup.creatingBackup\":\"backup.restoringBackup\")}),a.jsx(\"p\",{className:\"text-bambu-gray mb-4\",children:Q||n(k?\"backup.preparing\":\"backup.processing\")}),a.jsx(\"div\",{className:\"p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30\",children:a.jsxs(\"div\",{className:\"flex items-start gap-2 text-sm\",children:[a.jsx(Dn,{className:\"w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0\"}),a.jsx(\"p\",{className:\"text-yellow-200 text-left\",children:n(\"backup.doNotClosePage\")})]})})]})})]})}function bnt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),[r,i]=w.useState(!1),[s,o]=w.useState(\"\"),[l,c]=w.useState(\"medium\"),[d,u]=w.useState(\"notify\"),[m,p]=w.useState(10),[f,y]=w.useState(null),[v,b]=w.useState(null),[g,_]=w.useState(!1),{data:C}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),{data:P,refetch:N}=Xe({queryKey:[\"obico-status\"],queryFn:ue.getObicoStatus,refetchInterval:1e4}),{data:A}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters});w.useEffect(()=>{if(C){i(C.obico_enabled??!1),o(C.obico_ml_url??\"\"),c(C.obico_sensitivity??\"medium\"),u(C.obico_action??\"notify\"),p(C.obico_poll_interval??10);try{const D=C.obico_enabled_printers?JSON.parse(C.obico_enabled_printers):null;y(Array.isArray(D)?D:null)}catch{y(null)}_(!0)}},[C]);const T=it({mutationFn:()=>ue.updateSettings({obico_enabled:r,obico_ml_url:s,obico_sensitivity:l,obico_action:d,obico_poll_interval:m,obico_enabled_printers:f===null?\"\":JSON.stringify(f)}),onSuccess:()=>{e.invalidateQueries({queryKey:[\"settings\"]}),e.invalidateQueries({queryKey:[\"obico-status\"]}),n(t(\"settings.toast.settingsSaved\"))}});w.useEffect(()=>{if(!g||!C||!(C.obico_enabled!==r||C.obico_ml_url!==s||C.obico_sensitivity!==l||C.obico_action!==d||C.obico_poll_interval!==m||C.obico_enabled_printers!==(f===null?\"\":JSON.stringify(f))))return;const H=setTimeout(()=>T.mutate(),500);return()=>clearTimeout(H)},[r,s,l,d,m,f,g]);const F=async()=>{b(null);try{const D=await ue.testObicoConnection(s);D.ok?b({ok:!0,message:t(\"failureDetection.testSuccess\")}):b({ok:!1,message:D.error||`HTTP ${D.status_code??\"?\"} — ${D.body??t(\"failureDetection.testFailed\")}`})}catch(D){b({ok:!1,message:D instanceof Error?D.message:String(D)})}},k=(D,H)=>{if(f===null){const z=A?.map(L=>L.id)??[],Q=H?z:z.filter(L=>L!==D);y(Q);return}y(H?[...f,D]:f.filter(z=>z!==D))};return a.jsxs(\"div\",{className:\"flex flex-col lg:flex-row gap-4 lg:gap-6\",children:[a.jsxs(\"div\",{className:\"space-y-3 flex-1 lg:max-w-xl\",children:[a.jsxs(Tt,{id:\"card-fd-ml\",children:[a.jsxs(gn,{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(HQ,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"failureDetection.title\")})]}),a.jsx(Ln,{checked:r,onChange:i})]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-2\",children:t(\"failureDetection.description\")})]}),a.jsxs(Mt,{className:\"space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:t(\"failureDetection.mlUrl\")}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"input\",{type:\"text\",value:s,onChange:D=>o(D.target.value),placeholder:\"http://192.168.1.10:3333\",className:\"flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm\",disabled:!r}),a.jsx(De,{onClick:F,disabled:!s||T.isPending,variant:\"secondary\",children:t(\"failureDetection.test\")})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"failureDetection.mlUrlHint\")}),v&&a.jsxs(\"div\",{className:`flex items-start gap-2 mt-2 text-sm ${v.ok?\"text-green-400\":\"text-red-400\"}`,children:[v.ok?a.jsx(Ur,{className:\"w-4 h-4 mt-0.5\"}):a.jsx(Ht,{className:\"w-4 h-4 mt-0.5\"}),a.jsx(\"span\",{children:v.message})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:t(\"failureDetection.sensitivity\")}),a.jsxs(\"select\",{value:l,onChange:D=>c(D.target.value),disabled:!r,className:\"w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm\",children:[a.jsx(\"option\",{value:\"low\",children:t(\"failureDetection.sensitivityLow\")}),a.jsx(\"option\",{value:\"medium\",children:t(\"failureDetection.sensitivityMedium\")}),a.jsx(\"option\",{value:\"high\",children:t(\"failureDetection.sensitivityHigh\")})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"failureDetection.sensitivityHint\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:t(\"failureDetection.action\")}),a.jsxs(\"select\",{value:d,onChange:D=>u(D.target.value),disabled:!r,className:\"w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm\",children:[a.jsx(\"option\",{value:\"notify\",children:t(\"failureDetection.actionNotify\")}),a.jsx(\"option\",{value:\"pause\",children:t(\"failureDetection.actionPause\")}),a.jsx(\"option\",{value:\"pause_and_off\",children:t(\"failureDetection.actionPauseOff\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:t(\"failureDetection.pollInterval\")}),a.jsx(\"input\",{type:\"number\",value:m,onChange:D=>p(Math.max(5,Math.min(120,Number(D.target.value)||10))),min:5,max:120,disabled:!r,className:\"w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"failureDetection.pollIntervalHint\")})]}),P&&!P.external_url_configured&&r&&a.jsxs(\"div\",{className:\"flex items-start gap-2 p-3 bg-amber-900/30 border border-amber-700 rounded text-sm text-amber-200\",children:[a.jsx(Dn,{className:\"w-4 h-4 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"font-medium\",children:t(\"failureDetection.externalUrlMissing\")}),a.jsx(\"div\",{className:\"text-xs mt-1\",children:t(\"failureDetection.externalUrlHint\")})]})]})]})]}),a.jsxs(Tt,{id:\"card-fd-perprinter\",children:[a.jsxs(gn,{children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"failureDetection.perPrinterTitle\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:t(\"failureDetection.perPrinterHint\")})]}),a.jsxs(Mt,{className:\"space-y-2\",children:[a.jsxs(\"label\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:f===null,onChange:D=>y(D.target.checked?null:A?.map(H=>H.id)??[]),disabled:!r}),a.jsx(\"span\",{className:\"text-white\",children:t(\"failureDetection.monitorAll\")})]}),f!==null&&A&&a.jsx(\"div\",{className:\"pl-5 space-y-1 border-l border-gray-700\",children:A.map(D=>a.jsxs(\"label\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:f.includes(D.id),onChange:H=>k(D.id,H.target.checked),disabled:!r}),a.jsx(\"span\",{className:\"text-white\",children:D.name})]},D.id))})]})]})]}),a.jsxs(\"div\",{className:\"space-y-3 flex-1 lg:max-w-xl\",children:[a.jsxs(Tt,{id:\"card-fd-status\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"failureDetection.statusTitle\")})}),a.jsx(Mt,{children:P?a.jsxs(\"div\",{className:\"space-y-3 text-sm\",children:[a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:t(\"failureDetection.serviceRunning\")}),a.jsx(\"span\",{className:P.is_running?\"text-green-400\":\"text-red-400\",children:P.is_running?t(\"common.yes\"):t(\"common.no\")})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:t(\"failureDetection.thresholds\")}),a.jsxs(\"span\",{className:\"text-white font-mono\",children:[P.thresholds.low.toFixed(2),\" / \",P.thresholds.high.toFixed(2)]})]}),P.last_error&&a.jsxs(\"div\",{className:\"flex items-start gap-2 text-red-400\",children:[a.jsx(Ht,{className:\"w-4 h-4 mt-0.5 flex-shrink-0\"}),a.jsx(\"span\",{className:\"break-words\",children:P.last_error})]}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"text-bambu-gray mb-1\",children:t(\"failureDetection.activePrinters\")}),Object.keys(P.per_printer).length===0?a.jsx(\"div\",{className:\"text-bambu-gray italic text-xs\",children:t(\"failureDetection.noActivePrints\")}):a.jsx(\"div\",{className:\"space-y-1\",children:Object.entries(P.per_printer).map(([D,H])=>{const z=A?.find(L=>String(L.id)===D),Q=H.class===\"failure\"?\"text-red-400\":H.class===\"warning\"?\"text-amber-400\":\"text-green-400\";return a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-white\",children:z?.name??`Printer ${D}`}),a.jsxs(\"span\",{className:`font-mono ${Q}`,children:[H.class,\" (\",H.score.toFixed(3),\", \",H.frame_count,\"f)\"]})]},D)})})]})]}):a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray\",children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),a.jsx(\"span\",{children:t(\"common.loading\")})]})})]}),a.jsxs(Tt,{id:\"card-fd-history\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"failureDetection.historyTitle\")}),a.jsx(\"button\",{onClick:()=>N(),className:\"text-xs text-bambu-gray hover:text-white\",children:t(\"common.refresh\")})]})}),a.jsx(Mt,{children:!P||P.history.length===0?a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray text-sm\",children:[a.jsx(Ss,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:t(\"failureDetection.noHistory\")})]}):a.jsx(\"div\",{className:\"space-y-1 max-h-96 overflow-y-auto text-xs font-mono\",children:P.history.map((D,H)=>{const z=A?.find(L=>L.id===D.printer_id),Q=D.class===\"failure\"?\"text-red-400\":D.class===\"warning\"?\"text-amber-400\":\"text-bambu-gray\";return a.jsxs(\"div\",{className:\"flex justify-between gap-2 py-1 border-b border-gray-800\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:new Date(D.timestamp).toLocaleTimeString()}),a.jsx(\"span\",{className:\"text-white truncate\",children:z?.name??`#${D.printer_id}`}),a.jsxs(\"span\",{className:Q,children:[D.class,\" \",D.score.toFixed(3)]})]},H)})})})]})]})]})}const xnt={starttls:587,ssl:465,none:25},ynt={587:\"starttls\",465:\"ssl\",25:\"none\"};function vnt(){const{t}=Ft(),{showToast:e}=hn(),n=nn(),[r,i]=w.useState({smtp_host:\"\",smtp_port:587,smtp_username:\"\",smtp_password:\"\",smtp_security:\"starttls\",smtp_auth_enabled:!0,smtp_from_email:\"\",smtp_from_name:\"BamBuddy\"}),[s,o]=w.useState(\"\"),{data:l,isLoading:c}=Xe({queryKey:[\"smtpSettings\"],queryFn:()=>ue.getSMTPSettings()}),{authEnabled:d}=kr(),{data:u}=Xe({queryKey:[\"advancedAuthStatus\"],queryFn:()=>ue.getAdvancedAuthStatus()});w.useEffect(()=>{l&&i({...l,smtp_password:\"\"})},[l]);const m=T=>{i({...r,smtp_security:T,smtp_port:xnt[T]})},p=T=>{const F=ynt[T];i({...r,smtp_port:T,...F?{smtp_security:F}:{}})},f=T=>{i({...r,smtp_auth_enabled:T,...T?{}:{smtp_username:\"\",smtp_password:\"\"}})},y=it({mutationFn:T=>ue.saveSMTPSettings(T),onSuccess:()=>{n.invalidateQueries({queryKey:[\"smtpSettings\"]}),n.invalidateQueries({queryKey:[\"advancedAuthStatus\"]}),e(t(\"settings.email.success.settingsSaved\"),\"success\")},onError:T=>{e(T.message,\"error\")}}),v=it({mutationFn:T=>ue.testSMTP(T),onSuccess:T=>{e(T.message,T.success?\"success\":\"error\")},onError:T=>{e(T.message,\"error\")}}),b=it({mutationFn:T=>T?ue.enableAdvancedAuth():ue.disableAdvancedAuth(),onSuccess:T=>{n.invalidateQueries({queryKey:[\"advancedAuthStatus\"]}),e(T.message,\"success\")},onError:T=>{e(T.message,\"error\")}}),g=()=>{if(!r.smtp_host||!r.smtp_from_email){e(t(\"settings.email.errors.requiredFields\"),\"error\");return}if(r.smtp_auth_enabled&&!r.smtp_username){e(t(\"settings.email.errors.usernameRequired\"),\"error\");return}y.mutate(r)},_=()=>{if(!s){e(t(\"settings.email.errors.enterTestEmail\"),\"error\");return}v.mutate({test_recipient:s})},C=()=>{if(!d){e(t(\"settings.email.errors.enableAuthFirst\"),\"error\");return}if(!u?.advanced_auth_enabled&&!u?.smtp_configured){e(t(\"settings.email.errors.configureSmtpFirst\"),\"error\");return}b.mutate(!u?.advanced_auth_enabled)};if(c)return a.jsx(\"div\",{className:\"flex items-center justify-center p-12\",children:a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green\"})});const P=u?.advanced_auth_enabled??!1,N=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",A=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white/40 placeholder-bambu-gray/40 cursor-not-allowed\";return a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(Tt,{id:\"card-email-advanced-auth\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(bm,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.email.advancedAuth\")||\"Advanced Authentication\"})]}),a.jsx(De,{onClick:C,disabled:b.isPending,variant:P?\"danger\":\"primary\",children:P?a.jsxs(a.Fragment,{children:[a.jsx(A0,{className:\"w-4 h-4\"}),t(\"settings.email.disable\")||\"Disable\"]}):a.jsxs(a.Fragment,{children:[a.jsx(kd,{className:\"w-4 h-4\"}),t(\"settings.email.enable\")||\"Enable\"]})})]})}),a.jsx(Mt,{children:a.jsx(\"div\",{className:\"space-y-3\",children:P?a.jsx(\"div\",{className:\"bg-green-500/10 border border-green-500/30 rounded-lg p-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(yr,{className:\"w-5 h-5 text-green-400 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"settings.email.advancedAuthEnabled\")||\"Advanced Authentication is enabled\"}),a.jsxs(\"ul\",{className:\"text-sm text-green-300 space-y-1 list-disc list-inside\",children:[a.jsx(\"li\",{children:t(\"settings.email.feature1\")||\"Passwords are auto-generated and emailed to new users\"}),a.jsx(\"li\",{children:t(\"settings.email.feature2\")||\"Users can login with username or email\"}),a.jsx(\"li\",{children:t(\"settings.email.feature3\")||\"Forgot password feature is available\"}),a.jsx(\"li\",{children:t(\"settings.email.feature4\")||\"Admins can reset user passwords via email\"})]})]})]})}):a.jsx(\"div\",{className:\"bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(Dn,{className:\"w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"settings.email.advancedAuthDisabled\")||\"Advanced Authentication is disabled\"}),a.jsx(\"p\",{className:\"text-sm text-yellow-300\",children:t(\"settings.email.advancedAuthDisabledDesc\")||\"Enable advanced authentication to activate email-based features for user management.\"})]})]})})})})]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 lg:grid-cols-3 gap-4\",children:[a.jsx(\"div\",{className:\"lg:col-span-2\",children:a.jsxs(Tt,{id:\"card-smtp-config\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.email.smtpSettings\")||\"SMTP Configuration\"})}),a.jsx(Mt,{children:a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:t(\"settings.email.authentication\")||\"Authentication\"}),a.jsxs(\"select\",{value:r.smtp_auth_enabled?\"true\":\"false\",onChange:T=>f(T.target.value===\"true\"),className:N,children:[a.jsx(\"option\",{value:\"true\",children:t(\"settings.email.authOptions.enabled\")}),a.jsx(\"option\",{value:\"false\",children:t(\"settings.email.authOptions.disabled\")})]})]}),a.jsxs(\"div\",{className:`grid grid-cols-1 md:grid-cols-2 gap-4 transition-opacity ${r.smtp_auth_enabled?\"\":\"opacity-40 pointer-events-none\"}`,children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:t(\"settings.email.username\")||\"Username\"}),a.jsx(\"input\",{type:\"text\",value:r.smtp_username||\"\",onChange:T=>i({...r,smtp_username:T.target.value}),placeholder:\"your.email@gmail.com\",disabled:!r.smtp_auth_enabled,className:r.smtp_auth_enabled?N:A})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:t(\"settings.email.password\")||\"Password\"}),a.jsx(\"input\",{type:\"password\",value:r.smtp_password||\"\",onChange:T=>i({...r,smtp_password:T.target.value}),placeholder:l?\"••••••••\":\"App password\",disabled:!r.smtp_auth_enabled,className:r.smtp_auth_enabled?N:A})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:[t(\"settings.email.smtpHost\")||\"SMTP Server\",\" *\"]}),a.jsx(\"input\",{type:\"text\",value:r.smtp_host,onChange:T=>i({...r,smtp_host:T.target.value}),placeholder:\"smtp.gmail.com\",className:N})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:t(\"settings.email.smtpPort\")||\"SMTP Port\"}),a.jsx(\"input\",{type:\"number\",value:r.smtp_port,onChange:T=>p(parseInt(T.target.value)||587),placeholder:\"587\",className:N})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:t(\"settings.email.security\")||\"Security\"}),a.jsxs(\"select\",{value:r.smtp_security,onChange:T=>m(T.target.value),className:N,children:[a.jsx(\"option\",{value:\"starttls\",children:t(\"settings.email.securityOptions.starttls\")}),a.jsx(\"option\",{value:\"ssl\",children:t(\"settings.email.securityOptions.ssl\")}),a.jsx(\"option\",{value:\"none\",children:t(\"settings.email.securityOptions.none\")})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:[t(\"settings.email.fromEmail\")||\"From Email\",\" *\"]}),a.jsx(\"input\",{type:\"email\",value:r.smtp_from_email,onChange:T=>i({...r,smtp_from_email:T.target.value}),placeholder:\"your@email.com\",className:N})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:t(\"settings.email.fromName\")||\"From Name\"}),a.jsx(\"input\",{type:\"text\",value:r.smtp_from_name,onChange:T=>i({...r,smtp_from_name:T.target.value}),placeholder:\"BamBuddy\",className:N})]})]}),a.jsx(\"div\",{className:\"flex gap-2\",children:a.jsx(De,{onClick:g,disabled:y.isPending,className:\"flex-1\",children:y.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),t(\"settings.email.saving\")||\"Saving...\"]}):t(\"settings.email.save\")||\"Save Settings\"})})]})})]})}),a.jsx(\"div\",{children:a.jsxs(Tt,{id:\"card-email-test\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.email.testConnection\")||\"Test SMTP Connection\"})}),a.jsx(Mt,{children:a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:t(\"settings.email.testRecipient\")||\"Test Recipient Email\"}),a.jsx(\"input\",{type:\"email\",value:s,onChange:T=>o(T.target.value),placeholder:\"test@example.com\",className:N})]}),a.jsx(De,{onClick:_,disabled:v.isPending,variant:\"secondary\",children:v.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),t(\"settings.email.sending\")||\"Sending...\"]}):a.jsxs(a.Fragment,{children:[a.jsx(iS,{className:\"w-4 h-4\"}),t(\"settings.email.sendTest\")||\"Send Test Email\"]})})]})})]})})]})]})}const wnt={starttls:\"389\",ldaps:\"636\"};function Snt(){const{t}=Ft(),{showToast:e}=hn(),n=nn(),{authEnabled:r}=kr(),[i,s]=w.useState({ldap_server_url:\"\",ldap_bind_dn:\"\",ldap_bind_password:\"\",ldap_search_base:\"\",ldap_user_filter:\"(sAMAccountName={username})\",ldap_security:\"starttls\",ldap_group_mapping:\"\",ldap_auto_provision:!1,ldap_default_group:\"\"}),{data:o,isLoading:l}=Xe({queryKey:[\"settings\"],queryFn:()=>ue.getSettings()}),{data:c}=Xe({queryKey:[\"ldapStatus\"],queryFn:()=>ue.getLDAPStatus()}),{data:d=[]}=Xe({queryKey:[\"groups\"],queryFn:()=>ue.getGroups()});w.useEffect(()=>{o&&s({ldap_server_url:o.ldap_server_url||\"\",ldap_bind_dn:o.ldap_bind_dn||\"\",ldap_bind_password:\"\",ldap_search_base:o.ldap_search_base||\"\",ldap_user_filter:o.ldap_user_filter||\"(sAMAccountName={username})\",ldap_security:o.ldap_security||\"starttls\",ldap_group_mapping:o.ldap_group_mapping||\"\",ldap_auto_provision:o.ldap_auto_provision??!1,ldap_default_group:o.ldap_default_group||\"\"})},[o]);const u=it({mutationFn:g=>ue.updateSettings(g),onSuccess:()=>{n.invalidateQueries({queryKey:[\"settings\"]}),n.invalidateQueries({queryKey:[\"ldapStatus\"]}),e(t(\"settings.ldap.settingsSaved\")||\"LDAP settings saved\",\"success\")},onError:g=>{e(g.message,\"error\")}}),m=it({mutationFn:g=>ue.updateSettings({ldap_enabled:g}),onSuccess:()=>{n.invalidateQueries({queryKey:[\"settings\"]}),n.invalidateQueries({queryKey:[\"ldapStatus\"]}),e(c?.ldap_enabled?t(\"settings.ldap.disabled\")||\"LDAP authentication disabled\":t(\"settings.ldap.enabled\")||\"LDAP authentication enabled\",\"success\")},onError:g=>{e(g.message,\"error\")}}),p=it({mutationFn:()=>ue.testLDAP(),onSuccess:g=>{e(g.message,g.success?\"success\":\"error\")},onError:g=>{e(g.message,\"error\")}}),f=()=>{if(!i.ldap_server_url){e(t(\"settings.ldap.errors.serverRequired\")||\"LDAP server URL is required\",\"error\");return}if(!i.ldap_search_base){e(t(\"settings.ldap.errors.searchBaseRequired\")||\"Search base DN is required\",\"error\");return}const g={ldap_server_url:i.ldap_server_url,ldap_bind_dn:i.ldap_bind_dn,ldap_search_base:i.ldap_search_base,ldap_user_filter:i.ldap_user_filter,ldap_security:i.ldap_security,ldap_group_mapping:i.ldap_group_mapping,ldap_auto_provision:i.ldap_auto_provision,ldap_default_group:i.ldap_default_group};i.ldap_bind_password&&(g.ldap_bind_password=i.ldap_bind_password),u.mutate(g)},y=()=>{if(!r){e(t(\"settings.ldap.errors.enableAuthFirst\")||\"Enable authentication first\",\"error\");return}if(!c?.ldap_enabled&&!c?.ldap_configured){e(t(\"settings.ldap.errors.configureLdapFirst\")||\"Save LDAP settings first\",\"error\");return}m.mutate(!c?.ldap_enabled)};if(l)return a.jsx(\"div\",{className:\"flex items-center justify-center p-12\",children:a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green\"})});const v=c?.ldap_enabled??!1,b=\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\";return a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(Tt,{id:\"card-ldap-toggle\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Gu,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.ldap.title\")||\"LDAP Authentication\"})]}),a.jsx(De,{onClick:y,disabled:m.isPending,variant:v?\"danger\":\"primary\",children:v?a.jsxs(a.Fragment,{children:[a.jsx(A0,{className:\"w-4 h-4\"}),t(\"common.disable\")||\"Disable\"]}):a.jsxs(a.Fragment,{children:[a.jsx(kd,{className:\"w-4 h-4\"}),t(\"common.enable\")||\"Enable\"]})})]})}),a.jsx(Mt,{children:v?a.jsx(\"div\",{className:\"bg-green-500/10 border border-green-500/30 rounded-lg p-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(yr,{className:\"w-5 h-5 text-green-400 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"settings.ldap.enabledDesc\")||\"LDAP authentication is enabled\"}),a.jsxs(\"ul\",{className:\"text-sm text-green-300 space-y-1 list-disc list-inside\",children:[a.jsx(\"li\",{children:t(\"settings.ldap.feature1\")||\"Users can login with LDAP credentials\"}),a.jsx(\"li\",{children:t(\"settings.ldap.feature2\")||\"Local admin account remains as fallback\"}),a.jsx(\"li\",{children:t(\"settings.ldap.feature3\")||\"LDAP groups are mapped to BamBuddy groups on login\"})]})]})]})}):a.jsx(\"div\",{className:\"bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(Dn,{className:\"w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"settings.ldap.disabledDesc\")||\"LDAP authentication is disabled\"}),a.jsx(\"p\",{className:\"text-sm text-yellow-300 mt-1\",children:t(\"settings.ldap.disabledHint\")||\"Configure and save LDAP settings below, then enable.\"})]})]})})})]}),a.jsxs(Tt,{id:\"card-ldap-server\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"settings.ldap.serverConfig\")||\"LDAP Server Configuration\"})}),a.jsx(Mt,{children:a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-3 gap-3\",children:[a.jsxs(\"div\",{className:\"md:col-span-2\",children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:t(\"settings.ldap.serverUrl\")||\"Server URL\"}),a.jsx(\"input\",{type:\"text\",className:b,placeholder:\"ldaps://ldap.example.com:636\",value:i.ldap_server_url,onChange:g=>s({...i,ldap_server_url:g.target.value})}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"settings.ldap.serverUrlHint\")||\"Use ldaps:// for SSL or ldap:// with StartTLS\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:t(\"settings.ldap.security\")||\"Security\"}),a.jsx(\"div\",{className:\"flex gap-2\",children:[\"starttls\",\"ldaps\"].map(g=>a.jsx(\"button\",{onClick:()=>s({...i,ldap_security:g}),className:`flex-1 px-2 py-2 rounded-lg text-sm font-medium transition-colors ${i.ldap_security===g?\"bg-bambu-green text-black\":\"bg-bambu-dark-secondary text-bambu-gray hover:text-white border border-bambu-dark-tertiary\"}`,children:g===\"starttls\"?\"StartTLS\":\"LDAPS\"},g))}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"settings.ldap.securityHint\")||`Default port: ${wnt[i.ldap_security]}`})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:t(\"settings.ldap.bindDn\")||\"Bind DN (Service Account)\"}),a.jsx(\"input\",{type:\"text\",className:b,placeholder:\"cn=service-account,ou=service,dc=example,dc=com\",value:i.ldap_bind_dn,onChange:g=>s({...i,ldap_bind_dn:g.target.value})})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:t(\"settings.ldap.bindPassword\")||\"Bind Password\"}),a.jsx(\"input\",{type:\"password\",className:b,placeholder:o?.ldap_bind_dn?\"••••••••\":\"\",value:i.ldap_bind_password,onChange:g=>s({...i,ldap_bind_password:g.target.value})})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:t(\"settings.ldap.searchBase\")||\"Search Base DN\"}),a.jsx(\"input\",{type:\"text\",className:b,placeholder:\"ou=users,dc=example,dc=com\",value:i.ldap_search_base,onChange:g=>s({...i,ldap_search_base:g.target.value})})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:t(\"settings.ldap.userFilter\")||\"User Search Filter\"}),a.jsx(\"input\",{type:\"text\",className:b,placeholder:\"(sAMAccountName={username})\",value:i.ldap_user_filter,onChange:g=>s({...i,ldap_user_filter:g.target.value})})]})]}),a.jsx(Sz,{summary:a.jsx(\"span\",{className:\"text-sm font-medium text-bambu-gray\",children:t(\"settings.ldap.advanced\")||\"Advanced\"}),className:\"border-t border-bambu-dark-tertiary pt-3\",summaryClassName:\"py-1\",children:a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white\",children:t(\"settings.ldap.autoProvision\")||\"Auto-provision users\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-0.5\",children:t(\"settings.ldap.autoProvisionHint\")||\"Automatically create a BamBuddy account on first LDAP login\"})]}),a.jsx(\"button\",{onClick:()=>s({...i,ldap_auto_provision:!i.ldap_auto_provision}),className:`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${i.ldap_auto_provision?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(\"span\",{className:`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${i.ldap_auto_provision?\"translate-x-6\":\"translate-x-1\"}`})})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:t(\"settings.ldap.defaultGroup\")||\"Default group\"}),a.jsxs(\"select\",{className:b,value:i.ldap_default_group,onChange:g=>s({...i,ldap_default_group:g.target.value}),children:[a.jsx(\"option\",{value:\"\",children:t(\"settings.ldap.defaultGroupNone\")||\"— None (reject login) —\"}),d.map(g=>a.jsx(\"option\",{value:g.name,children:g.name},g.id))]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"settings.ldap.defaultGroupHint\")||\"Fallback group assigned when an LDAP user authenticates but is not listed in any mapped group. Leave empty to leave unmapped users without permissions.\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:t(\"settings.ldap.groupMapping\")||\"Group Mapping (JSON)\"}),a.jsx(\"textarea\",{className:`${b} font-mono text-sm`,rows:4,placeholder:`{\n  \"CN=PrintFarm_Admins,OU=Groups,DC=example,DC=com\": \"Administrators\",\n  \"CN=PrintFarm_Users,OU=Groups,DC=example,DC=com\": \"Operators\"\n}`,value:i.ldap_group_mapping,onChange:g=>s({...i,ldap_group_mapping:g.target.value})}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:[t(\"settings.ldap.groupMappingHint\")||\"Map LDAP group DNs to BamBuddy groups. Available groups: \",d.map(g=>g.name).join(\", \")]})]})]})}),a.jsxs(\"div\",{className:\"flex gap-3 pt-2\",children:[a.jsxs(De,{onClick:f,disabled:u.isPending,children:[u.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(yr,{className:\"w-4 h-4\"}),t(\"common.save\")||\"Save\"]}),a.jsxs(De,{variant:\"secondary\",onClick:()=>p.mutate(),disabled:p.isPending,children:[p.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(iS,{className:\"w-4 h-4\"}),t(\"settings.ldap.testConnection\")||\"Test Connection\"]})]})]})})]})]})}function cN({value:t,onChange:e,placeholder:n,maxLength:r=6}){return a.jsx(\"input\",{type:\"text\",value:t,onChange:i=>e(i.target.value.toUpperCase().replace(/\\s/g,\"\")),maxLength:r,className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors font-mono tracking-widest text-center\",placeholder:n,autoComplete:\"one-time-code\"})}function Fle({codes:t,onDone:e}){const{t:n}=Ft(),[r,i]=w.useState(!1),s=()=>{navigator.clipboard.writeText(t.join(`\n`)),i(!0),setTimeout(()=>i(!1),2e3)};return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"div\",{className:\"bg-amber-500/10 border border-amber-500/30 rounded-lg p-4\",children:a.jsx(\"p\",{className:\"text-amber-400 text-sm font-medium\",children:n(\"settings.twoFa.backupCodesWarning\")})}),a.jsx(\"div\",{className:\"grid grid-cols-2 gap-2\",children:t.map((o,l)=>a.jsx(\"code\",{className:\"bg-bambu-dark-secondary rounded px-3 py-2 text-center font-mono text-sm text-white tracking-widest\",children:o},l))}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:s,className:\"flex items-center gap-2\",children:[a.jsx(qs,{className:\"w-4 h-4\"}),n(r?\"common.copied\":\"common.copy\")]}),a.jsx(De,{variant:\"primary\",size:\"sm\",onClick:e,className:\"flex-1\",children:n(\"settings.twoFa.savedCodes\")})]})]})}function _nt({onDone:t}){const{t:e}=Ft(),n=nn(),{showToast:r}=hn(),[i,s]=w.useState(\"qr\"),[o,l]=w.useState(\"\"),[c,d]=w.useState([]),[u,m]=w.useState(!1),{data:p,isLoading:f}=Xe({queryKey:[\"totp-setup\"],queryFn:()=>ue.setupTOTP(),staleTime:1/0}),y=it({mutationFn:v=>ue.enableTOTP(v),onSuccess:v=>{d(v.backup_codes),s(\"backup\"),n.invalidateQueries({queryKey:[\"2fa-status\"]})},onError:()=>r(e(\"settings.twoFa.invalidCode\"),\"error\")});return f||!p?a.jsx(\"div\",{className:\"flex items-center justify-center py-8\",children:a.jsx(lr,{className:\"w-6 h-6 animate-spin text-bambu-green\"})}):i===\"qr\"?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-bambu-gray-light text-sm\",children:e(\"settings.twoFa.setupInstructions\")}),a.jsx(\"div\",{className:\"flex justify-center\",children:a.jsx(\"img\",{src:`data:image/png;base64,${p.qr_code_b64}`,alt:\"TOTP QR Code\",className:\"w-48 h-48 rounded-lg\"})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-1\",children:e(\"settings.twoFa.manualEntry\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2 bg-bambu-dark-secondary rounded-lg px-3 py-2\",children:[a.jsx(\"code\",{className:\"text-white text-xs font-mono flex-1 break-all\",children:u?p.secret:\"••••••••••••••••\"}),a.jsx(\"button\",{onClick:()=>m(!u),className:\"text-bambu-gray hover:text-white\",children:u?a.jsx(Og,{className:\"w-4 h-4\"}):a.jsx(yl,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>{navigator.clipboard.writeText(p.secret)},className:\"text-bambu-gray hover:text-white\",children:a.jsx(qs,{className:\"w-4 h-4\"})})]})]}),a.jsx(De,{variant:\"primary\",className:\"w-full\",onClick:()=>s(\"confirm\"),children:e(\"settings.twoFa.scannedContinue\")})]}):i===\"confirm\"?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-bambu-gray-light text-sm\",children:e(\"settings.twoFa.enterCodeToConfirm\")}),a.jsx(cN,{value:o,onChange:l,placeholder:\"000000\"}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>s(\"qr\"),className:\"flex-1\",children:e(\"common.back\")}),a.jsx(De,{variant:\"primary\",className:\"flex-1\",disabled:o.length!==6||y.isPending,onClick:()=>y.mutate(o),children:y.isPending?e(\"common.saving\"):e(\"settings.twoFa.activate\")})]})]}):a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"h3\",{className:\"text-white font-medium\",children:e(\"settings.twoFa.backupCodesTitle\")}),a.jsx(Fle,{codes:c,onDone:t})]})}function knt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),{user:r}=kr(),[i,s]=w.useState(!1),[o,l]=w.useState(!1),[c,d]=w.useState(!1),[u,m]=w.useState(\"\"),[p,f]=w.useState(\"\"),[y,v]=w.useState(null),[b,g]=w.useState(null),[_,C]=w.useState(\"\"),[P,N]=w.useState(!1),[A,T]=w.useState(\"\"),[F,k]=w.useState(!1),{data:D,isLoading:H}=Xe({queryKey:[\"2fa-status\"],queryFn:()=>ue.get2FAStatus()}),{data:z}=Xe({queryKey:[\"oidc-links\"],queryFn:()=>ue.getOIDCLinks()}),Q=it({mutationFn:()=>ue.enableEmailOTP(),onSuccess:re=>{g(re.setup_token),n(re.message,\"success\")},onError:re=>{const W=re.message??\"\";W.toLowerCase().includes(\"smtp\")?n(t(\"settings.twoFa.smtpRequired\"),\"error\"):n(W,\"error\")}}),L=it({mutationFn:()=>ue.confirmEnableEmailOTP(b,_),onSuccess:()=>{e.invalidateQueries({queryKey:[\"2fa-status\"]}),g(null),C(\"\"),n(t(\"settings.twoFa.emailOtpEnabled\"),\"success\")},onError:re=>n(re.message,\"error\")}),te=it({mutationFn:re=>ue.disableEmailOTP(re),onSuccess:()=>{e.invalidateQueries({queryKey:[\"2fa-status\"]}),N(!1),T(\"\"),n(t(\"settings.twoFa.emailOtpDisabled\"),\"success\")},onError:re=>n(re.message,\"error\")}),ie=it({mutationFn:re=>ue.disableTOTP(re),onSuccess:()=>{e.invalidateQueries({queryKey:[\"2fa-status\"]}),l(!1),m(\"\"),n(t(\"settings.twoFa.totpDisabled\"),\"success\")},onError:()=>n(t(\"settings.twoFa.invalidCode\"),\"error\")}),J=it({mutationFn:re=>ue.regenerateBackupCodes(re),onSuccess:re=>{e.invalidateQueries({queryKey:[\"2fa-status\"]}),d(!1),f(\"\"),v(re.backup_codes)},onError:()=>n(t(\"settings.twoFa.invalidCode\"),\"error\")}),oe=it({mutationFn:re=>ue.deleteOIDCLink(re),onSuccess:()=>{e.invalidateQueries({queryKey:[\"oidc-links\"]}),n(t(\"settings.twoFa.oidcUnlinked\"),\"success\")},onError:re=>n(re.message,\"error\")});if(H)return a.jsx(\"div\",{className:\"flex items-center justify-center py-12\",children:a.jsx(lr,{className:\"w-6 h-6 animate-spin text-bambu-green\"})});const fe=!!r?.email;return a.jsxs(\"div\",{className:\"space-y-6\",children:[a.jsxs(Tt,{id:\"card-2fa-totp\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`w-10 h-10 rounded-full flex items-center justify-center ${D?.totp_enabled?\"bg-green-500/20\":\"bg-gray-500/20\"}`,children:a.jsx(cF,{className:`w-5 h-5 ${D?.totp_enabled?\"text-green-400\":\"text-gray-400\"}`})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-white font-semibold\",children:t(\"settings.twoFa.totpTitle\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:t(\"settings.twoFa.totpDesc\")})]}),a.jsx(\"div\",{className:\"ml-auto\",children:D?.totp_enabled?a.jsxs(\"span\",{className:\"flex items-center gap-1 text-green-400 text-sm font-medium\",children:[a.jsx(NH,{className:\"w-4 h-4\"}),\" \",t(\"common.enabled\")]}):a.jsxs(\"span\",{className:\"flex items-center gap-1 text-bambu-gray text-sm\",children:[a.jsx(CH,{className:\"w-4 h-4\"}),\" \",t(\"common.disabled\")]})})]})}),a.jsx(Mt,{children:i?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"h4\",{className:\"text-white font-medium\",children:t(\"settings.twoFa.setupAuthApp\")}),a.jsx(\"button\",{onClick:()=>{s(!1),e.removeQueries({queryKey:[\"totp-setup\"]})},className:\"text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsx(_nt,{onDone:()=>{s(!1),e.removeQueries({queryKey:[\"totp-setup\"]})}})]}):o?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-bambu-gray-light text-sm\",children:t(\"settings.twoFa.disableConfirmHint\")}),a.jsx(cN,{value:u,onChange:m,placeholder:\"000000 or XXXXXXXX\",maxLength:8}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{l(!1),m(\"\")},className:\"flex-1\",children:t(\"common.cancel\")}),a.jsx(De,{variant:\"danger\",className:\"flex-1\",disabled:u.length<6||ie.isPending,onClick:()=>ie.mutate(u),children:ie.isPending?t(\"common.saving\"):t(\"settings.twoFa.disableTotp\")})]})]}):c?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-bambu-gray-light text-sm\",children:t(\"settings.twoFa.regenBackupHint\")}),a.jsx(cN,{value:p,onChange:f,placeholder:\"000000 or XXXXXXXX\",maxLength:8}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{d(!1),f(\"\")},className:\"flex-1\",children:t(\"common.cancel\")}),a.jsx(De,{variant:\"primary\",className:\"flex-1\",disabled:p.length<6||J.isPending,onClick:()=>J.mutate(p),children:J.isPending?t(\"common.saving\"):t(\"settings.twoFa.regenBackup\")})]})]}):y?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"h4\",{className:\"text-white font-medium\",children:t(\"settings.twoFa.newBackupCodes\")}),a.jsx(Fle,{codes:y,onDone:()=>v(null)})]}):a.jsx(\"div\",{className:\"space-y-3\",children:D?.totp_enabled?a.jsxs(\"div\",{className:\"flex flex-wrap gap-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray-light\",children:[a.jsx(no,{className:\"w-4 h-4\"}),t(\"settings.twoFa.backupCodesRemaining\",{count:D.backup_codes_remaining})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>d(!0),className:\"flex items-center gap-2\",children:[a.jsx(lr,{className:\"w-4 h-4\"}),t(\"settings.twoFa.regenBackup\")]}),a.jsxs(De,{variant:\"danger\",size:\"sm\",onClick:()=>l(!0),className:\"flex items-center gap-2\",children:[a.jsx(en,{className:\"w-4 h-4\"}),t(\"settings.twoFa.disableTotp\")]})]}):a.jsxs(De,{variant:\"primary\",onClick:()=>s(!0),className:\"flex items-center gap-2\",children:[a.jsx(cF,{className:\"w-4 h-4\"}),t(\"settings.twoFa.setupTotp\")]})})})]}),a.jsxs(Tt,{id:\"card-2fa-emailotp\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`w-10 h-10 rounded-full flex items-center justify-center ${D?.email_otp_enabled?\"bg-green-500/20\":\"bg-gray-500/20\"}`,children:a.jsx(bm,{className:`w-5 h-5 ${D?.email_otp_enabled?\"text-green-400\":\"text-gray-400\"}`})}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"h3\",{className:\"text-white font-semibold\",children:t(\"settings.twoFa.emailOtpTitle\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:fe?t(\"settings.twoFa.emailOtpDesc\",{email:r?.email}):t(\"settings.twoFa.emailOtpNoEmail\")})]}),a.jsx(\"div\",{className:\"ml-auto\",children:D?.email_otp_enabled?a.jsxs(\"span\",{className:\"flex items-center gap-1 text-green-400 text-sm font-medium\",children:[a.jsx(NH,{className:\"w-4 h-4\"}),\" \",t(\"common.enabled\")]}):a.jsxs(\"span\",{className:\"flex items-center gap-1 text-bambu-gray text-sm\",children:[a.jsx(CH,{className:\"w-4 h-4\"}),\" \",t(\"common.disabled\")]})})]})}),a.jsx(Mt,{children:fe?b?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-bambu-gray-light text-sm\",children:t(\"settings.twoFa.emailSetupEnterCode\")}),a.jsx(cN,{value:_,onChange:C,placeholder:\"000000\"}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{g(null),C(\"\")},className:\"flex-1\",children:t(\"common.cancel\")}),a.jsx(De,{variant:\"primary\",className:\"flex-1\",disabled:_.length!==6||L.isPending,onClick:()=>L.mutate(),children:L.isPending?t(\"common.saving\"):t(\"settings.twoFa.verifyAndEnable\")})]})]}):P?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-bambu-gray-light text-sm\",children:t(\"settings.twoFa.emailDisablePasswordHint\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"input\",{type:F?\"text\":\"password\",value:A,onChange:re=>T(re.target.value),placeholder:t(\"settings.twoFa.passwordPlaceholder\"),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\"}),a.jsx(\"button\",{type:\"button\",onClick:()=>k(!F),className:\"absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\",children:F?a.jsx(Og,{className:\"w-5 h-5\"}):a.jsx(yl,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{N(!1),T(\"\")},className:\"flex-1\",children:t(\"common.cancel\")}),a.jsx(De,{variant:\"danger\",className:\"flex-1\",disabled:!A||te.isPending,onClick:()=>te.mutate(A),children:te.isPending?t(\"common.saving\"):t(\"settings.twoFa.disableEmailOtp\")})]})]}):a.jsx(\"div\",{className:\"flex gap-3\",children:D?.email_otp_enabled?a.jsxs(De,{variant:\"danger\",size:\"sm\",onClick:()=>N(!0),className:\"flex items-center gap-2\",children:[a.jsx(en,{className:\"w-4 h-4\"}),t(\"settings.twoFa.disableEmailOtp\")]}):a.jsxs(De,{variant:\"primary\",disabled:!fe||Q.isPending,onClick:()=>Q.mutate(),className:\"flex items-center gap-2\",children:[a.jsx(bm,{className:\"w-4 h-4\"}),Q.isPending?t(\"common.saving\"):t(\"settings.twoFa.enableEmailOtp\")]})}):a.jsx(\"p\",{className:\"text-amber-400 text-sm\",children:t(\"settings.twoFa.addEmailFirst\")})})]}),z&&z.length>0&&a.jsxs(Tt,{id:\"card-2fa-linked\",children:[a.jsxs(gn,{children:[a.jsx(\"h3\",{className:\"text-white font-semibold\",children:t(\"settings.twoFa.linkedAccounts\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:t(\"settings.twoFa.linkedAccountsDesc\")})]}),a.jsx(Mt,{children:a.jsx(\"div\",{className:\"space-y-3\",children:z.map(re=>a.jsxs(\"div\",{className:\"flex items-center justify-between py-2 border-b border-bambu-dark-tertiary last:border-0\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white text-sm font-medium\",children:re.provider_name}),re.provider_email&&a.jsx(\"p\",{className:\"text-bambu-gray text-xs\",children:re.provider_email})]}),a.jsx(De,{variant:\"danger\",size:\"sm\",onClick:()=>oe.mutate(re.provider_id),disabled:oe.isPending,children:a.jsx(en,{className:\"w-4 h-4\"})})]},re.id))})})]})]})}const Nnt={name:\"\",issuer_url:\"\",client_id:\"\",client_secret:\"\",scopes:\"openid email profile\",is_enabled:!0,auto_create_users:!1,auto_link_existing_accounts:!1,icon_url:void 0};function vY({initial:t,isEdit:e=!1,onSave:n,onCancel:r,isPending:i}){const{t:s}=Ft(),[o,l]=w.useState(t),[c,d]=w.useState(!1),u=(y,v)=>l(b=>({...b,[y]:v})),m=\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors text-sm\",p=\"block text-sm font-medium text-white mb-1\",f=()=>{const y={...o};e&&!c&&delete y.client_secret,n(y)};return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-1 sm:grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:p,children:[s(\"settings.oidc.form.name\"),\" \",a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"})]}),a.jsx(\"input\",{className:m,value:o.name,onChange:y=>u(\"name\",y.target.value),placeholder:\"Google\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:p,children:[s(\"settings.oidc.form.issuerUrl\"),\" \",a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"})]}),a.jsx(\"input\",{className:m,value:o.issuer_url,onChange:y=>u(\"issuer_url\",y.target.value),placeholder:\"https://accounts.google.com\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:p,children:[s(\"settings.oidc.form.clientId\"),\" \",a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"})]}),a.jsx(\"input\",{className:m,value:o.client_id,onChange:y=>u(\"client_id\",y.target.value),placeholder:\"your-client-id\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:p,children:[s(\"settings.oidc.form.clientSecret\"),!e&&a.jsx(\"span\",{className:\"text-red-400\",children:\" *\"}),e&&a.jsxs(\"span\",{className:\"text-bambu-gray text-xs ml-1\",children:[\"(\",s(\"settings.oidc.form.secretHint\"),\")\"]})]}),a.jsx(\"input\",{className:m,type:\"password\",value:c?o.client_secret:\"\",placeholder:e&&!c?\"••••••••\":s(\"settings.oidc.form.secretPlaceholder\"),onChange:y=>{d(!0),u(\"client_secret\",y.target.value)}})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:p,children:s(\"settings.oidc.form.scopes\")}),a.jsx(\"input\",{className:m,value:o.scopes,onChange:y=>u(\"scopes\",y.target.value),placeholder:\"openid email profile\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:p,children:s(\"settings.oidc.form.iconUrl\")}),a.jsx(\"input\",{className:m,value:o.icon_url??\"\",onChange:y=>u(\"icon_url\",y.target.value||void 0),placeholder:\"https://...\"})]})]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-6 pt-2\",children:[a.jsxs(\"label\",{className:\"flex items-center gap-3 cursor-pointer\",children:[a.jsx(Ln,{checked:o.is_enabled??!0,onChange:y=>u(\"is_enabled\",y)}),a.jsx(\"span\",{className:\"text-white text-sm\",children:s(\"settings.oidc.form.enabled\")})]}),a.jsxs(\"label\",{className:\"flex items-center gap-3 cursor-pointer\",children:[a.jsx(Ln,{checked:o.auto_create_users??!1,onChange:y=>u(\"auto_create_users\",y)}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white text-sm\",children:s(\"settings.oidc.form.autoCreate\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-xs\",children:s(\"settings.oidc.form.autoCreateDesc\")})]})]}),a.jsxs(\"label\",{className:\"flex items-center gap-3 cursor-pointer\",children:[a.jsx(Ln,{checked:o.auto_link_existing_accounts??!1,onChange:y=>u(\"auto_link_existing_accounts\",y)}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white text-sm\",children:s(\"settings.oidc.form.autoLink\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-xs\",children:s(\"settings.oidc.form.autoLinkDesc\")})]})]})]}),a.jsxs(\"div\",{className:\"flex gap-3 pt-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:r,className:\"flex-1\",children:s(\"common.cancel\")}),a.jsx(De,{variant:\"primary\",className:\"flex-1\",disabled:!o.name||!o.issuer_url||!o.client_id||!e&&!o.client_secret||e&&c&&!o.client_secret||i,onClick:f,children:s(i?\"common.saving\":\"common.save\")})]})]})}function Cnt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),[r,i]=w.useState(!1),[s,o]=w.useState(null),[l,c]=w.useState(null),{data:d,isLoading:u}=Xe({queryKey:[\"oidc-providers-all\"],queryFn:()=>ue.getOIDCProvidersAll()}),m=it({mutationFn:v=>ue.createOIDCProvider(v),onSuccess:()=>{e.invalidateQueries({queryKey:[\"oidc-providers-all\"]}),i(!1),n(t(\"settings.oidc.created\"),\"success\")},onError:v=>n(v.message,\"error\")}),p=it({mutationFn:({id:v,data:b})=>ue.updateOIDCProvider(v,b),onSuccess:()=>{e.invalidateQueries({queryKey:[\"oidc-providers-all\"]}),o(null),n(t(\"settings.oidc.updated\"),\"success\")},onError:v=>n(v.message,\"error\")}),f=it({mutationFn:v=>ue.deleteOIDCProvider(v),onSuccess:()=>{e.invalidateQueries({queryKey:[\"oidc-providers-all\"]}),c(null),n(t(\"settings.oidc.deleted\"),\"success\")},onError:v=>n(v.message,\"error\")}),y=v=>p.mutate({id:v.id,data:{is_enabled:!v.is_enabled}});return u?a.jsx(\"div\",{className:\"flex items-center justify-center py-12\",children:a.jsx(lr,{className:\"w-6 h-6 animate-spin text-bambu-green\"})}):a.jsxs(\"div\",{className:\"space-y-6\",children:[a.jsxs(Tt,{id:\"card-oidc\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-white font-semibold\",children:t(\"settings.oidc.title\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:t(\"settings.oidc.desc\")})]}),!r&&a.jsxs(De,{variant:\"primary\",size:\"sm\",onClick:()=>i(!0),className:\"flex items-center gap-2\",children:[a.jsx(sr,{className:\"w-4 h-4\"}),t(\"settings.oidc.addProvider\")]})]})}),r&&a.jsx(Mt,{children:a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4\",children:[a.jsx(\"h4\",{className:\"text-white font-medium mb-4\",children:t(\"settings.oidc.newProvider\")}),a.jsx(vY,{initial:Nnt,onSave:v=>m.mutate(v),onCancel:()=>i(!1),isPending:m.isPending})]})})]}),d&&d.length===0&&!r&&a.jsx(Tt,{id:\"card-oidc-empty\",children:a.jsx(Mt,{children:a.jsxs(\"div\",{className:\"text-center py-8 space-y-3\",children:[a.jsx(ul,{className:\"w-12 h-12 text-bambu-gray mx-auto\"}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"settings.oidc.empty\")}),a.jsxs(De,{variant:\"primary\",size:\"sm\",onClick:()=>i(!0),className:\"inline-flex items-center gap-2\",children:[a.jsx(sr,{className:\"w-4 h-4\"}),t(\"settings.oidc.addProvider\")]})]})})}),d?.map(v=>a.jsxs(Tt,{children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[v.icon_url?a.jsx(\"img\",{src:v.icon_url,alt:v.name,className:\"w-8 h-8 rounded object-contain\",onError:b=>{b.target.style.display=\"none\"}}):a.jsx(\"div\",{className:\"w-8 h-8 rounded-full bg-bambu-dark-tertiary flex items-center justify-center\",children:a.jsx(ul,{className:\"w-4 h-4 text-bambu-gray\"})}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"h4\",{className:\"text-white font-medium\",children:v.name}),v.is_enabled?a.jsxs(\"span\",{className:\"flex items-center gap-1 text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full\",children:[a.jsx(Ur,{className:\"w-3 h-3\"}),\" \",t(\"common.enabled\")]}):a.jsxs(\"span\",{className:\"flex items-center gap-1 text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-0.5 rounded-full\",children:[a.jsx(Ht,{className:\"w-3 h-3\"}),\" \",t(\"common.disabled\")]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 text-bambu-gray text-xs mt-0.5\",children:[a.jsx(la,{className:\"w-3 h-3\"}),a.jsx(\"span\",{children:v.issuer_url})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Ln,{checked:v.is_enabled,onChange:()=>y(v),disabled:p.isPending}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>o(s===v.id?null:v.id),children:a.jsx(Qu,{className:\"w-4 h-4\"})}),a.jsx(De,{variant:\"danger\",size:\"sm\",onClick:()=>c(v),children:a.jsx(en,{className:\"w-4 h-4\"})})]})]})}),s===v.id&&a.jsx(Mt,{children:a.jsx(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4\",children:a.jsx(vY,{isEdit:!0,initial:{name:v.name,issuer_url:v.issuer_url,client_id:v.client_id,client_secret:\"\",scopes:v.scopes,is_enabled:v.is_enabled,auto_create_users:v.auto_create_users,auto_link_existing_accounts:v.auto_link_existing_accounts,icon_url:v.icon_url??void 0},onSave:b=>p.mutate({id:v.id,data:b}),onCancel:()=>o(null),isPending:p.isPending})})}),s!==v.id&&a.jsx(Mt,{children:a.jsxs(\"dl\",{className:\"grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm\",children:[a.jsxs(\"div\",{children:[a.jsx(\"dt\",{className:\"text-bambu-gray\",children:t(\"settings.oidc.form.clientId\")}),a.jsx(\"dd\",{className:\"text-white font-mono truncate\",children:v.client_id})]}),a.jsxs(\"div\",{children:[a.jsx(\"dt\",{className:\"text-bambu-gray\",children:t(\"settings.oidc.form.scopes\")}),a.jsx(\"dd\",{className:\"text-white\",children:v.scopes})]}),a.jsxs(\"div\",{children:[a.jsx(\"dt\",{className:\"text-bambu-gray\",children:t(\"settings.oidc.form.autoCreate\")}),a.jsx(\"dd\",{className:v.auto_create_users?\"text-green-400\":\"text-bambu-gray\",children:v.auto_create_users?t(\"common.yes\"):t(\"common.no\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"dt\",{className:\"text-bambu-gray\",children:t(\"settings.oidc.form.autoLink\")}),a.jsx(\"dd\",{className:v.auto_link_existing_accounts?\"text-green-400\":\"text-bambu-gray\",children:v.auto_link_existing_accounts?t(\"common.yes\"):t(\"common.no\")})]})]})})]},v.id)),l&&a.jsx(yn,{title:t(\"settings.oidc.deleteTitle\"),message:t(\"settings.oidc.deleteMessage\",{name:l.name}),confirmText:t(\"common.delete\"),variant:\"danger\",onConfirm:()=>f.mutate(l.id),onCancel:()=>c(null)})]})}const Pnt={get:\"bg-blue-500/20 text-blue-400 border-blue-500/30\",post:\"bg-green-500/20 text-green-400 border-green-500/30\",put:\"bg-yellow-500/20 text-yellow-400 border-yellow-500/30\",patch:\"bg-orange-500/20 text-orange-400 border-orange-500/30\",delete:\"bg-red-500/20 text-red-400 border-red-500/30\"};function Tnt(t,e){const n=e.replace(\"#/\",\"\").split(\"/\");let r=t;for(const i of n)r=r[i];return r}function N0(t,e,n=0){if(n>5)return\"...\";if(e.$ref)return N0(t,Tnt(t,e.$ref),n+1);if(e.allOf){const r={};for(const i of e.allOf){const s=N0(t,i,n+1);typeof s==\"object\"&&s!==null&&Object.assign(r,s)}return r}if(e.example!==void 0)return e.example;if(e.default!==void 0)return e.default;switch(e.type){case\"string\":return e.enum?e.enum[0]:\"string\";case\"integer\":case\"number\":return 0;case\"boolean\":return!1;case\"array\":return e.items?[N0(t,e.items,n+1)]:[];case\"object\":if(e.properties){const r={};for(const[i,s]of Object.entries(e.properties))r[i]=N0(t,s,n+1);return r}return{};default:return null}}function Ant({path:t,method:e,spec:n,schema:r,apiKey:i}){const[s,o]=w.useState(!1),[l,c]=w.useState({}),[d,u]=w.useState(\"\"),[m,p]=w.useState(null),[f,y]=w.useState(!1),[v,b]=w.useState(!1);w.useEffect(()=>{if(s&&n.parameters){const F={};for(const k of n.parameters)k.schema?.default!==void 0&&(F[k.name]=String(k.schema.default));c(k=>({...F,...k}))}},[s,n.parameters]),w.useEffect(()=>{if(s&&n.requestBody?.content?.[\"application/json\"]?.schema&&!d){const F=n.requestBody.content[\"application/json\"].schema,k=N0(r,F);u(JSON.stringify(k,null,2))}},[s,n.requestBody,r,d]);const _=(()=>{const F=[];for(const k of n.parameters||[])if(k.in===\"path\"||k.required){const D=l[k.name];(D===void 0||D===\"\")&&F.push(k.name)}return F})(),C=async()=>{if(_.length>0){p({status:0,statusText:\"Validation Error\",headers:{},body:`Missing required parameters: ${_.join(\", \")}`,duration:0});return}y(!0),p(null);try{let F=t;const k=new URLSearchParams;for(const re of n.parameters||[]){const W=l[re.name];W!==void 0&&W!==\"\"&&(re.in===\"path\"?F=F.replace(`{${re.name}}`,encodeURIComponent(W)):re.in===\"query\"&&k.append(re.name,W))}const D=k.toString(),H=`${F}${D?`?${D}`:\"\"}`,z={\"Content-Type\":\"application/json\"};i&&(z[\"X-API-Key\"]=i);const Q={method:e.toUpperCase(),headers:z};[\"post\",\"put\",\"patch\"].includes(e)&&d&&(Q.body=d);const L=performance.now(),te=await fetch(H,Q),ie=Math.round(performance.now()-L),J={};te.headers.forEach((re,W)=>{J[W]=re});let oe;te.headers.get(\"content-type\")?.includes(\"application/json\")?oe=await te.json():oe=await te.text(),p({status:te.status,statusText:te.statusText,headers:J,body:oe,duration:ie})}catch(F){p({status:0,statusText:\"Network Error\",headers:{},body:F instanceof Error?F.message:\"Unknown error\",duration:0})}finally{y(!1)}},P=async()=>{if(m){const F=typeof m.body==\"string\"?m.body:JSON.stringify(m.body,null,2);try{await navigator.clipboard.writeText(F),b(!0),setTimeout(()=>b(!1),2e3)}catch{const k=document.createElement(\"textarea\");k.value=F,k.style.position=\"fixed\",k.style.left=\"-999999px\",document.body.appendChild(k),k.select(),document.execCommand(\"copy\"),document.body.removeChild(k),b(!0),setTimeout(()=>b(!1),2e3)}}},N=(n.parameters||[]).filter(F=>F.in===\"path\"),A=(n.parameters||[]).filter(F=>F.in===\"query\"),T=[\"post\",\"put\",\"patch\"].includes(e)&&n.requestBody;return a.jsxs(\"div\",{className:\"border border-bambu-dark-tertiary rounded-lg overflow-hidden\",children:[a.jsxs(\"button\",{onClick:()=>o(!s),className:\"w-full flex items-center gap-3 p-3 hover:bg-bambu-dark-tertiary/50 transition-colors text-left\",children:[s?a.jsx(On,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"}):a.jsx(ti,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"}),a.jsx(\"span\",{className:`px-2 py-0.5 text-xs font-mono font-semibold uppercase rounded border ${Pnt[e]||\"bg-gray-500/20 text-gray-400\"}`,children:e}),a.jsx(\"code\",{className:\"text-sm text-white font-mono flex-1 truncate\",children:t}),n.summary&&a.jsx(\"span\",{className:\"text-sm text-bambu-gray truncate max-w-[40%]\",children:n.summary})]}),s&&a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary p-4 space-y-4 bg-bambu-dark/50\",children:[n.description&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n.description}),N.length>0&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-white\",children:\"Path Parameters\"}),a.jsx(\"div\",{className:\"space-y-2\",children:N.map(F=>a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"label\",{className:\"text-sm text-bambu-gray w-32 flex-shrink-0\",children:[F.name,F.required&&a.jsx(\"span\",{className:\"text-red-400 ml-1\",children:\"*\"})]}),a.jsx(\"input\",{type:\"text\",value:l[F.name]||\"\",onChange:k=>c(D=>({...D,[F.name]:k.target.value})),placeholder:F.description||F.schema?.type||\"value\",className:\"flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none\"})]},F.name))})]}),A.length>0&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-white\",children:\"Query Parameters\"}),a.jsx(\"div\",{className:\"space-y-2\",children:A.map(F=>a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"label\",{className:\"text-sm text-bambu-gray w-32 flex-shrink-0\",children:[F.name,F.required&&a.jsx(\"span\",{className:\"text-red-400 ml-1\",children:\"*\"})]}),F.schema?.enum?a.jsxs(\"select\",{value:l[F.name]||\"\",onChange:k=>c(D=>({...D,[F.name]:k.target.value})),className:\"flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"\",children:\"-- Select --\"}),F.schema.enum.map(k=>a.jsx(\"option\",{value:k,children:k},k))]}):a.jsx(\"input\",{type:\"text\",value:l[F.name]||\"\",onChange:k=>c(D=>({...D,[F.name]:k.target.value})),placeholder:F.description||F.schema?.type||\"value\",className:\"flex-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none\"})]},F.name))})]}),T&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-white\",children:\"Request Body\"}),a.jsx(\"textarea\",{value:d,onChange:F=>u(F.target.value),rows:8,className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono focus:border-bambu-green focus:outline-none resize-y\",placeholder:\"JSON request body...\"})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(De,{onClick:C,disabled:f,children:[f?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(es,{className:\"w-4 h-4\"}),\"Execute\"]}),_.length>0&&a.jsxs(\"span\",{className:\"text-xs text-yellow-400 flex items-center gap-1\",children:[a.jsx(ei,{className:\"w-3 h-3\"}),\"Fill in: \",_.join(\", \")]})]}),m&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"h4\",{className:\"text-sm font-medium text-white flex items-center gap-2\",children:[\"Response\",a.jsxs(\"span\",{className:`px-2 py-0.5 text-xs rounded ${m.status>=200&&m.status<300?\"bg-green-500/20 text-green-400\":m.status>=400?\"bg-red-500/20 text-red-400\":\"bg-yellow-500/20 text-yellow-400\"}`,children:[m.status,\" \",m.statusText]}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[m.duration,\"ms\"]})]}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:P,children:v?a.jsx(yr,{className:\"w-3 h-3 text-green-400\"}):a.jsx(qs,{className:\"w-3 h-3\"})})]}),a.jsx(\"pre\",{className:\"p-3 bg-bambu-dark rounded-lg text-sm font-mono text-white overflow-auto max-h-96 border border-bambu-dark-tertiary\",children:typeof m.body==\"string\"?m.body:JSON.stringify(m.body,null,2)})]})]})]})}function jnt({apiKey:t=\"\"}){const[e,n]=w.useState(null),[r,i]=w.useState(!0),[s,o]=w.useState(null),[l,c]=w.useState(new Set),[d,u]=w.useState(\"\");if(w.useEffect(()=>{async function b(){try{const g=await fetch(\"/openapi.json\");if(!g.ok)throw new Error(\"Failed to fetch OpenAPI schema\");const _=await g.json();n(_)}catch(g){o(g instanceof Error?g.message:\"Unknown error\")}finally{i(!1)}}b()},[]),r)return a.jsx(\"div\",{className:\"flex justify-center py-12\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})});if(s||!e)return a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-8\",children:a.jsxs(\"div\",{className:\"text-center text-red-400\",children:[a.jsx(ei,{className:\"w-12 h-12 mx-auto mb-3 opacity-50\"}),a.jsx(\"p\",{children:\"Failed to load API schema\"}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:s})]})})});const m={};for(const[b,g]of Object.entries(e.paths))for(const[_,C]of Object.entries(g)){if(_===\"parameters\")continue;const P=C.tags||[\"Other\"];for(const N of P)m[N]||(m[N]=[]),m[N].push({path:b,method:_,spec:C})}const p=Object.entries(m).map(([b,g])=>{if(!d)return{tag:b,endpoints:g};const _=g.filter(({path:C,method:P,spec:N})=>{const A=d.toLowerCase();return C.toLowerCase().includes(A)||P.toLowerCase().includes(A)||(N.summary?.toLowerCase()||\"\").includes(A)||(N.description?.toLowerCase()||\"\").includes(A)});return{tag:b,endpoints:_}}).filter(({endpoints:b})=>b.length>0).sort((b,g)=>b.tag.localeCompare(g.tag)),f=b=>{c(g=>{const _=new Set(g);return _.has(b)?_.delete(b):_.add(b),_})},y=()=>{c(new Set(p.map(b=>b.tag)))},v=()=>{c(new Set)};return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between gap-4\",children:[a.jsx(\"div\",{className:\"flex-1\",children:a.jsx(\"input\",{type:\"text\",value:d,onChange:b=>u(b.target.value),placeholder:\"Search endpoints...\",className:\"w-full max-w-md px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"})}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:y,children:\"Expand All\"}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:v,children:\"Collapse All\"}),a.jsxs(\"a\",{href:\"/docs\",target:\"_blank\",rel:\"noopener noreferrer\",className:\"flex items-center gap-1 text-sm text-bambu-green hover:underline\",children:[a.jsx(la,{className:\"w-4 h-4\"}),\"Swagger UI\"]})]})]}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[p.reduce((b,g)=>b+g.endpoints.length,0),\" endpoints in \",p.length,\" categories\"]}),a.jsx(\"div\",{className:\"space-y-3\",children:p.map(({tag:b,endpoints:g})=>a.jsxs(Tt,{children:[a.jsx(\"button\",{onClick:()=>f(b),className:\"w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/30 transition-colors text-left\",children:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[l.has(b)?a.jsx(On,{className:\"w-5 h-5 text-bambu-gray\"}):a.jsx(ti,{className:\"w-5 h-5 text-bambu-gray\"}),a.jsx(\"h3\",{className:\"text-base font-semibold text-white capitalize\",children:b.replace(/-/g,\" \")}),a.jsx(\"span\",{className:\"text-xs bg-bambu-dark-tertiary px-2 py-0.5 rounded-full text-bambu-gray\",children:g.length})]})}),l.has(b)&&a.jsx(Mt,{className:\"pt-0 space-y-2\",children:g.map(({path:_,method:C,spec:P})=>a.jsx(Ant,{path:_,method:C,spec:P,schema:e,apiKey:t},`${C}-${_}`))})]},b))})]})}const Rle=new Map;function jn(t){Rle.set(t.anchor,t)}function Mnt(){return Array.from(Rle.values())}const Ent=[\"general\",\"plugs\",\"notifications\",\"queue\",\"filament\",\"network\",\"apikeys\",\"virtual-printer\",\"spoolbuddy\",\"failure-detection\",\"users\",\"backup\"];jn({labelKey:\"settings.general\",tab:\"general\",keywords:\"language date time format printer model printers cards\",anchor:\"card-general\"});jn({labelKey:\"settings.appearance\",tab:\"general\",keywords:\"theme dark light mode colors\",anchor:\"card-appearance\"});jn({labelKey:\"settings.archiveSettings\",tab:\"general\",keywords:\"archive auto save thumbnails captures\",anchor:\"card-archive\"});jn({labelKey:\"settings.camera\",tab:\"general\",keywords:\"camera external video stream\",anchor:\"card-camera\"});jn({labelKey:\"settings.costTracking\",tab:\"general\",keywords:\"currency filament cost energy kwh price\",anchor:\"card-cost\"});jn({labelKey:\"settings.fileManager\",tab:\"general\",keywords:\"file manager archive mode disk warning storage\",anchor:\"card-filemanager\"});jn({labelKey:\"settings.updates\",tab:\"general\",keywords:\"updates version firmware beta check\",anchor:\"card-updates\"});jn({labelKey:\"settings.dataManagement\",tab:\"general\",keywords:\"data reset clear logs notifications preferences\",anchor:\"card-data\"});jn({labelKey:\"settings.smartPlugs\",tab:\"plugs\",keywords:\"smart plug energy power automation tapo kasa tplink shelly\",anchor:\"card-plugs\"});jn({labelKey:\"settings.providers\",tab:\"notifications\",keywords:\"telegram discord email notification providers webhook\",anchor:\"card-providers\"});jn({labelKey:\"settings.messageTemplates\",tab:\"notifications\",keywords:\"message templates notification text edit\",anchor:\"card-templates\"});jn({labelKey:\"settings.defaultPrintOptions\",labelFallback:\"Default Print Options\",tab:\"queue\",keywords:\"print bed leveling flow calibration vibration first layer timelapse\",anchor:\"card-print-options\"});jn({labelKey:\"settings.staggeredStart\",labelFallback:\"Staggered Start\",tab:\"queue\",keywords:\"staggered batch delay start queue group\",anchor:\"card-staggered\"});jn({labelKey:\"settings.plateClear\",labelFallback:\"Plate-Clear Confirmation\",tab:\"queue\",keywords:\"plate clear confirm auto queue\",anchor:\"card-plate\"});jn({labelKey:\"settings.gcodeInjection\",labelFallback:\"G-code Injection\",tab:\"queue\",keywords:\"gcode injection start end autoprint farmloop swapmod autoclear printflow\",anchor:\"card-gcode\"});jn({labelKey:\"settings.queueDrying\",tab:\"queue\",keywords:\"drying presets temperature time humidity ams\",anchor:\"card-drying\"});jn({labelKey:\"settings.filamentChecks\",tab:\"filament\",keywords:\"filament check warning runout remaining\",anchor:\"card-filamentchecks\"});jn({labelKey:\"settings.printModal\",tab:\"filament\",keywords:\"print modal custom mapping\",anchor:\"card-printmodal\"});jn({labelKey:\"settings.amsDisplayThresholds\",tab:\"filament\",keywords:\"ams humidity temperature threshold history retention\",anchor:\"card-amsthresholds\"});jn({labelKey:\"settings.externalUrl\",tab:\"network\",keywords:\"external url reverse proxy public notification link\",anchor:\"card-externalurl\"});jn({labelKey:\"settings.ftpRetry\",tab:\"network\",keywords:\"ftp retry upload retries backoff\",anchor:\"card-ftpretry\"});jn({labelKey:\"settings.homeAssistant\",tab:\"network\",keywords:\"home assistant ha hass mqtt integration\",anchor:\"card-ha\"});jn({labelKey:\"settings.mqttPublishing\",tab:\"network\",keywords:\"mqtt publish broker topic\",anchor:\"card-mqtt\"});jn({labelKey:\"settings.prometheusMetrics\",tab:\"network\",keywords:\"prometheus metrics grafana monitoring bearer token\",anchor:\"card-prometheus\"});jn({labelKey:\"settings.createNewApiKey\",tab:\"apikeys\",keywords:\"api key create permission scope\",anchor:\"card-createapi\"});jn({labelKey:\"settings.webhookEndpoints\",tab:\"apikeys\",keywords:\"webhook endpoint post http\",anchor:\"card-webhooks\"});jn({labelKey:\"settings.apiBrowser\",tab:\"apikeys\",keywords:\"api browser endpoint documentation test\",anchor:\"card-apibrowser\"});jn({labelKey:\"settings.tabs.virtualPrinter\",tab:\"virtual-printer\",keywords:\"virtual printer proxy archive slicer bambustudio orcaslicer ip bind\",anchor:\"card-vp\"});jn({labelKey:\"settings.tabs.spoolbuddy\",tab:\"spoolbuddy\",keywords:\"spoolbuddy device scale nfc rfid kiosk unregister\",anchor:\"card-spoolbuddy\"});jn({labelKey:\"settings.currentUser\",tab:\"users\",subTab:\"users\",keywords:\"current user profile password change\",anchor:\"card-currentuser\"});jn({labelKey:\"settings.users\",tab:\"users\",subTab:\"users\",keywords:\"users accounts list\",anchor:\"card-users\"});jn({labelKey:\"settings.groups\",tab:\"users\",subTab:\"users\",keywords:\"groups roles permissions administrators operators viewers\",anchor:\"card-groups\"});jn({labelKey:\"settings.email.smtpSettings\",labelFallback:\"SMTP Configuration\",tab:\"users\",subTab:\"email\",keywords:\"smtp email send server port password auth starttls ssl\",anchor:\"card-smtp\"});jn({labelKey:\"settings.ldap.title\",labelFallback:\"LDAP Authentication\",tab:\"users\",subTab:\"ldap\",keywords:\"ldap active directory ad authentication bind dn search base group mapping\",anchor:\"card-ldap\"});jn({labelKey:\"settings.tabs.backup\",tab:\"backup\",keywords:\"backup github restore download cloud sync profiles archives\",anchor:\"card-backup\"});jn({labelKey:\"externalLinks.title\",labelFallback:\"Sidebar Links\",tab:\"general\",keywords:\"sidebar links external custom navigation url add\",anchor:\"card-sidebar-links\"});jn({labelKey:\"settings.filamentTracking\",tab:\"filament\",keywords:\"spoolman filament tracking inventory sync remote integration\",anchor:\"card-spoolman\"});jn({labelKey:\"settings.catalog.spoolCatalog\",labelFallback:\"Spool Catalog\",tab:\"filament\",keywords:\"spool catalog entries brand material reset import export\",anchor:\"card-spool-catalog\"});jn({labelKey:\"settings.colorCatalog.title\",labelFallback:\"Color Catalog\",tab:\"filament\",keywords:\"color catalog hex swatch palette sync reset\",anchor:\"card-color-catalog\"});jn({labelKey:\"settings.tabs.failureDetection\",labelFallback:\"Failure Detection\",tab:\"failure-detection\",keywords:\"failure detection ai ml obico spaghetti detect monitoring\",anchor:\"card-fd-ml\"});jn({labelKey:\"failureDetection.perPrinterTitle\",labelFallback:\"Per-Printer Settings\",tab:\"failure-detection\",keywords:\"failure detection per printer enable per-printer sensitivity\",anchor:\"card-fd-perprinter\"});jn({labelKey:\"failureDetection.statusTitle\",labelFallback:\"Detection Status\",tab:\"failure-detection\",keywords:\"failure detection status running connection\",anchor:\"card-fd-status\"});jn({labelKey:\"failureDetection.historyTitle\",labelFallback:\"Detection History\",tab:\"failure-detection\",keywords:\"failure detection history log events\",anchor:\"card-fd-history\"});jn({labelKey:\"settings.email.advancedAuth\",labelFallback:\"Advanced Email Authentication\",tab:\"users\",subTab:\"email\",keywords:\"email authentication advanced password reset self-service forgot\",anchor:\"card-email-advanced-auth\"});jn({labelKey:\"settings.email.testConnection\",labelFallback:\"Test SMTP Connection\",tab:\"users\",subTab:\"email\",keywords:\"email smtp test connection send check\",anchor:\"card-email-test\"});jn({labelKey:\"settings.twoFa.totpTitle\",labelFallback:\"Authenticator App (TOTP)\",tab:\"users\",subTab:\"twofa\",keywords:\"two factor 2fa totp authenticator app google authy otp\",anchor:\"card-2fa-totp\"});jn({labelKey:\"settings.twoFa.emailOtpTitle\",labelFallback:\"Email One-Time Codes\",tab:\"users\",subTab:\"twofa\",keywords:\"two factor 2fa email otp one time code\",anchor:\"card-2fa-emailotp\"});jn({labelKey:\"settings.twoFa.linkedAccounts\",labelFallback:\"Linked Accounts\",tab:\"users\",subTab:\"twofa\",keywords:\"two factor 2fa linked accounts sso oidc provider google github\",anchor:\"card-2fa-linked\"});jn({labelKey:\"settings.oidc.title\",labelFallback:\"Single Sign-On (OIDC)\",tab:\"users\",subTab:\"oidc\",keywords:\"sso oidc openid single sign-on pocketid authentik keycloak google okta azure provider\",anchor:\"card-oidc\"});jn({labelKey:\"settings.ldap.serverConfig\",labelFallback:\"LDAP Server Configuration\",tab:\"users\",subTab:\"ldap\",keywords:\"ldap server url bind dn user search base group filter tls\",anchor:\"card-ldap-server\"});jn({labelKey:\"backup.githubBackup\",labelFallback:\"GitHub Backup\",tab:\"backup\",keywords:\"github backup cloud remote sync profiles token\",anchor:\"card-backup-github\"});jn({labelKey:\"backup.history\",labelFallback:\"Backup History\",tab:\"backup\",keywords:\"backup history log runs github commits\",anchor:\"card-backup-history\"});jn({labelKey:\"backup.localBackup\",labelFallback:\"Local Backup\",tab:\"backup\",keywords:\"local backup download zip manual export\",anchor:\"card-backup-local\"});jn({labelKey:\"backup.scheduledBackup\",labelFallback:\"Scheduled Backups\",tab:\"backup\",keywords:\"scheduled backup automatic hourly daily weekly retention local path\",anchor:\"card-backup-scheduled\"});const Dnt={database:\"bg-blue-600\",library_files:\"bg-green-500\",library_thumbnails:\"bg-teal-500\",library_other:\"bg-emerald-700\",archive_timelapses:\"bg-red-500\",archive_thumbnails:\"bg-amber-500\",archive_files:\"bg-sky-500\",virtual_printer_uploads:\"bg-purple-500\",virtual_printer_upload_cache:\"bg-fuchsia-500\",virtual_printer_certs:\"bg-violet-500\",virtual_printer_other:\"bg-purple-700\",downloads:\"bg-cyan-500\",plate_calibration:\"bg-lime-500\",logs:\"bg-orange-500\",other_data:\"bg-yellow-500\"},wY=[\"bg-blue-500\",\"bg-green-500\",\"bg-yellow-500\",\"bg-red-500\",\"bg-orange-500\",\"bg-teal-500\",\"bg-cyan-500\",\"bg-purple-500\"],SY=(t,e)=>Dnt[t]||wY[e%wY.length];function Fnt(){const t=nn(),e=qo(),[n,r]=BP(),{t:i,i18n:s}=Ft(),{showToast:o}=hn(),{authEnabled:l,user:c,isAdmin:d,refreshAuth:u,hasPermission:m}=kr(),{mode:p,darkStyle:f,darkBackground:y,darkAccent:v,lightStyle:b,lightBackground:g,lightAccent:_,setDarkStyle:C,setDarkBackground:P,setDarkAccent:N,setLightStyle:A,setLightBackground:T,setLightAccent:F}=zg(),[k,D]=w.useState(null),[H,z]=w.useState(!1),[Q,L]=w.useState(null),[te,ie]=w.useState(!1),[J,oe]=w.useState(null),[fe,re]=w.useState(null),[W,ne]=w.useState(\"\"),[me,be]=w.useState(\"\"),[Ce,q]=w.useState(!1),[Y,E]=w.useState(oZ()),j=n.get(\"tab\"),O=j===\"email\",K=O?\"users\":j&&Ent.includes(j)?j:\"general\",[U,de]=w.useState(K),[I,G]=w.useState(O?\"email\":\"users\"),X=ke=>{de(ke),ke===\"users\"&&G(\"users\"),ke===\"general\"?n.delete(\"tab\"):n.set(\"tab\",ke),r(n,{replace:!0})},[V,ee]=w.useState(!1),[se,ge]=w.useState(\"\"),[he,le]=w.useState({can_queue:!0,can_control_printer:!1,can_read_status:!0}),[B,R]=w.useState(null),[ae,_e]=w.useState(null),[Se,ve]=w.useState(\"\"),[Te,ye]=w.useState(!1),[je,Le]=w.useState(!1),[Me,Oe]=w.useState(null),[Re,$e]=w.useState(!1),[Ye,tt]=w.useState(!1),[pe,Fe]=w.useState(!1),[we,Ve]=w.useState({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"}),[Ae,ce]=w.useState(!1),[xe,Be]=w.useState(!1),[Qe,ht]=w.useState(!1),[xt,gt]=w.useState(!1),[Ut,Wt]=w.useState(null),[Zt,Kt]=w.useState(null),[Xt,ln]=w.useState(null),[vn,Ke]=w.useState(!1),[at,Lt]=w.useState({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]}),[Et,At]=w.useState(null),[Ie,mt]=w.useState(null),[pt,nt]=w.useState(!1),[ze,ot]=w.useState({}),[Pe,Ge]=w.useState({}),Ze=ke=>{E(ke),Uye(ke),o(i(\"settings.toast.settingsSaved\"),\"success\")},Je=()=>{localStorage.removeItem(\"sidebarOrder\"),window.location.reload()},We=!!k?.default_sidebar_order,Ue=async ke=>{try{if(ke){let Rt;const Sn=localStorage.getItem(\"sidebarOrder\");try{Rt=Sn?JSON.parse(Sn):Zu.map(Bn=>Bn.id)}catch{Rt=Zu.map(Bn=>Bn.id)}(!Array.isArray(Rt)||Rt.length===0)&&(Rt=Zu.map(Bn=>Bn.id));const _n=JSON.stringify({order:Rt});await ue.updateSettings({default_sidebar_order:_n}),D(Bn=>Bn&&{...Bn,default_sidebar_order:_n}),o(i(\"settings.sidebarDefaultSet\"),\"success\")}else await ue.updateSettings({default_sidebar_order:\"\"}),D(Rt=>Rt&&{...Rt,default_sidebar_order:\"\"}),o(i(\"settings.sidebarDefaultCleared\"),\"success\");t.invalidateQueries({queryKey:[\"settings\"]}),t.invalidateQueries({queryKey:[\"default-sidebar-order\"]})}catch{o(i(\"settings.sidebarDefaultFailed\"),\"error\")}},{data:et,isLoading:jt}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),{data:yt,isLoading:qe,isFetching:St}=Xe({queryKey:[\"storage-usage\"],queryFn:()=>ue.getStorageUsage(),enabled:U===\"general\",staleTime:1/0,refetchInterval:!1,refetchOnWindowFocus:!1,refetchOnReconnect:!1}),Pt=async()=>{Be(!0);try{const ke=await ue.getStorageUsage({refresh:!0});t.setQueryData([\"storage-usage\"],ke)}catch(ke){const Rt=ke instanceof Error?ke.message:\"Failed to refresh storage usage\";o(Rt,\"error\")}finally{Be(!1)}},{data:qt,isLoading:on}=Xe({queryKey:[\"smart-plugs\"],queryFn:ue.getSmartPlugs}),{data:dn,isLoading:Nn}=Xe({queryKey:[\"smart-plugs-energy\",qt?.map(ke=>ke.id)],queryFn:async()=>{if(!qt||qt.length===0)return null;const ke=await Promise.all(qt.filter(ui=>ui.enabled).map(async ui=>{try{const Cr=await ue.getSmartPlugStatus(ui.id);return{plug:ui,status:Cr}}catch{return{plug:ui,status:null}}}));let Rt=0,Sn=0,_n=0,Bn=0,xa=0;for(const{plug:ui,status:Cr}of ke){const zm=ui.plug_type===\"mqtt\"&&Cr?.energy?.power!=null;(Cr?.reachable||zm)&&Cr?.energy&&(xa++,Cr.energy?.power!=null&&(Rt+=Cr.energy.power),Cr.energy?.today!=null&&(Sn+=Cr.energy.today),Cr.energy?.yesterday!=null&&(_n+=Cr.energy.yesterday),Cr.energy?.total!=null&&(Bn+=Cr.energy.total))}return{totalPower:Rt,totalToday:Sn,totalYesterday:_n,totalLifetime:Bn,reachableCount:xa,totalPlugs:qt.filter(ui=>ui.enabled).length}},enabled:U===\"plugs\"&&!!qt&&qt.length>0,refetchInterval:U===\"plugs\"?1e4:!1}),{data:bn,isLoading:un}=Xe({queryKey:[\"notification-providers\"],queryFn:ue.getNotificationProviders}),{data:wn,isLoading:pn}=Xe({queryKey:[\"api-keys\"],queryFn:ue.getAPIKeys}),gr=it({mutationFn:ke=>ue.createAPIKey(ke),onSuccess:ke=>{R(ke.key||null),ee(!1),ge(\"\"),t.invalidateQueries({queryKey:[\"api-keys\"]}),o(i(\"settings.toast.apiKeyCreated\"))},onError:ke=>{o(`Failed to create API key: ${ke.message}`,\"error\")}}),Nr=it({mutationFn:ke=>ue.deleteAPIKey(ke),onSuccess:()=>{t.invalidateQueries({queryKey:[\"api-keys\"]}),o(i(\"settings.toast.apiKeyDeleted\"))},onError:ke=>{o(`Failed to delete API key: ${ke.message}`,\"error\")}}),{data:Fn}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:Ba,isLoading:In}=Xe({queryKey:[\"notification-templates\"],queryFn:ue.getNotificationTemplates}),{data:ma}=Xe({queryKey:[\"virtual-printer-settings\"],queryFn:fye.getSettings,refetchInterval:1e4}),ra=ma?.status?.running??!1,{data:Fr}=Xe({queryKey:[\"spoolbuddy-devices\"],queryFn:()=>Gr.getDevices(),refetchInterval:15e3}),Br=Fr?.length??0,cr=Fr?.some(ke=>ke.online)??!1,{data:Ci}=Xe({queryKey:[\"obico-status\"],queryFn:ue.getObicoStatus,refetchInterval:15e3}),Ui=!!(Ci?.is_running&&Ci?.enabled),{data:vc}=Xe({queryKey:[\"ffmpeg-status\"],queryFn:ue.checkFfmpeg}),{data:_l}=Xe({queryKey:[\"version\"],queryFn:ue.getVersion}),{data:vt,refetch:Kn,isRefetching:dr}=Xe({queryKey:[\"updateCheck\"],queryFn:ue.checkForUpdates,enabled:et?.check_updates!==!1,staleTime:300*1e3}),{data:$t,refetch:Ks}=Xe({queryKey:[\"updateStatus\"],queryFn:ue.getUpdateStatus,refetchInterval:ke=>{const Rt=ke.state.data;return Rt?.status===\"downloading\"||Rt?.status===\"installing\"?1e3:!1}}),{data:rs}=Xe({queryKey:[\"mqtt-status\"],queryFn:ue.getMQTTStatus,refetchInterval:U===\"network\"?5e3:!1}),{data:wc}=Xe({queryKey:[\"github-backup-status\"],queryFn:ue.getGitHubBackupStatus}),{data:Sc}=Xe({queryKey:[\"cloud-status\"],queryFn:ue.getCloudStatus}),{data:Ha={advanced_auth_enabled:!1,smtp_configured:!1}}=Xe({queryKey:[\"advancedAuthStatus\"],queryFn:()=>ue.getAdvancedAuthStatus()}),{data:id}=Xe({queryKey:[\"ldapStatus\"],queryFn:()=>ue.getLDAPStatus()}),{data:Ip}=Xe({queryKey:[\"twoFAStatus\"],queryFn:()=>ue.get2FAStatus()}),{data:tu=[]}=Xe({queryKey:[\"oidcProvidersAll\"],queryFn:()=>ue.getOIDCProvidersAll(),enabled:d}),{data:yo=[],isLoading:nu}=Xe({queryKey:[\"users\"],queryFn:()=>ue.getUsers(),enabled:l&&m(\"users:read\")}),{data:Ps=[],isLoading:vo}=Xe({queryKey:[\"groups\"],queryFn:()=>ue.getGroups(),enabled:l&&m(\"groups:read\")}),Ts=it({mutationFn:ke=>ue.createUser(ke),onSuccess:()=>{t.invalidateQueries({queryKey:[\"users\"]}),t.invalidateQueries({queryKey:[\"groups\"]}),ht(!1),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]}),o(i(\"settings.toast.userCreated\"))},onError:ke=>{o(ke.message,\"error\")}}),Wo=it({mutationFn:({id:ke,data:Rt})=>ue.updateUser(ke,Rt),onSuccess:()=>{t.invalidateQueries({queryKey:[\"users\"]}),t.invalidateQueries({queryKey:[\"groups\"]}),gt(!1),Wt(null),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]}),o(i(\"settings.toast.userUpdated\"))},onError:ke=>{o(ke.message,\"error\")}}),wo=it({mutationFn:({id:ke,deleteItems:Rt})=>ue.deleteUser(ke,Rt),onSuccess:()=>{t.invalidateQueries({queryKey:[\"users\"]}),o(i(\"settings.toast.userDeleted\")),Kt(null),ln(null)},onError:ke=>{o(ke.message,\"error\")}}),_c=it({mutationFn:ke=>ue.resetUserPassword({user_id:ke}),onSuccess:ke=>{o(ke.message,\"success\")},onError:ke=>{o(ke.message,\"error\")}}),ru=async ke=>{Kt(ke),Ke(!0);try{const Rt=await ue.getUserItemsCount(ke);ln(Rt)}catch{ln({archives:0,queue_items:0,library_files:0})}finally{Ke(!1)}},zp=it({mutationFn:ke=>ue.deleteGroup(ke),onSuccess:()=>{t.invalidateQueries({queryKey:[\"groups\"]}),o(i(\"settings.toast.groupDeleted\"))},onError:ke=>{o(ke.message,\"error\")}}),di=()=>{const ke=Ha?.advanced_auth_enabled||!1;if(!at.username){o(i(\"settings.toast.fillRequiredFields\"),\"error\");return}if(ke&&!at.email){o(\"Email is required when advanced authentication is enabled\",\"error\");return}if(!ke){if(!at.password){o(i(\"settings.toast.fillRequiredFields\"),\"error\");return}if(at.password!==at.confirmPassword){o(i(\"settings.toast.passwordsDoNotMatch\"),\"error\");return}if(at.password.length<6){o(i(\"settings.toast.passwordTooShort\"),\"error\");return}}Ts.mutate({username:at.username,password:ke?void 0:at.password,email:at.email||void 0,role:at.role,group_ids:at.group_ids.length>0?at.group_ids:void 0})},As=ke=>{if(at.password){if(at.password!==at.confirmPassword){o(i(\"settings.toast.passwordsDoNotMatch\"),\"error\");return}if(at.password.length<6){o(i(\"settings.toast.passwordTooShort\"),\"error\");return}}const Rt={username:at.username||void 0,password:at.password||void 0,email:at.email||void 0,role:at.role,group_ids:at.group_ids};Rt.password||delete Rt.password,Wo.mutate({id:ke,data:Rt})},au=ke=>{Wt(ke.id),Lt({username:ke.username,password:\"\",email:ke.email||\"\",confirmPassword:\"\",role:ke.role,group_ids:ke.groups?.map(Rt=>Rt.id)||[]}),gt(!0)},sd=ke=>{Lt(Rt=>({...Rt,group_ids:Rt.group_ids.includes(ke)?Rt.group_ids.filter(Sn=>Sn!==ke):[...Rt.group_ids,ke]}))},Up=it({mutationFn:ue.applyUpdate,onSuccess:ke=>{ke.is_docker?o(ke.message,\"error\"):Ks()}}),[Ko,iu]=w.useState(null),So=it({mutationFn:ue.testAllNotificationProviders,onSuccess:ke=>{iu(ke),t.invalidateQueries({queryKey:[\"notification-providers\"]}),ke.failed===0?o(`All ${ke.tested} providers tested successfully!`,\"success\"):o(`${ke.success}/${ke.tested} providers succeeded`,ke.failed>0?\"error\":\"success\")},onError:ke=>{o(`Failed to test providers: ${ke.message}`,\"error\")}}),Xo=it({mutationFn:async ke=>{if(!qt)return{success:0,failed:0};const Rt=qt.filter(_n=>_n.enabled),Sn=await Promise.all(Rt.map(async _n=>{try{return await ue.controlSmartPlug(_n.id,ke),{success:!0}}catch{return{success:!1}}}));return{success:Sn.filter(_n=>_n.success).length,failed:Sn.filter(_n=>!_n.success).length}},onSuccess:(ke,Rt)=>{t.invalidateQueries({queryKey:[\"smart-plugs\"]}),t.invalidateQueries({queryKey:[\"smart-plugs-energy\"]}),ke.failed===0?o(`All ${ke.success} plugs turned ${Rt}`,\"success\"):o(`${ke.success} plugs turned ${Rt}, ${ke.failed} failed`,\"error\")},onError:ke=>{o(`Failed: ${ke.message}`,\"error\")}}),kl=w.useRef(null),Yo=w.useRef(null),Nl=w.useRef(!1),Lm=w.useRef(!0);w.useEffect(()=>{if(et&&!k){const ke={...et,external_url:et.external_url||window.location.origin};D(ke),setTimeout(()=>{Lm.current=!1},100)}},[et,k]);const as=it({mutationFn:ue.updateSettings,onSuccess:ke=>{t.setQueryData([\"settings\"],ke),t.invalidateQueries({queryKey:[\"archiveStats\"]}),o(i(\"settings.toast.settingsSaved\"),\"success\")},onError:ke=>{o(`Failed to save: ${ke.message}`,\"error\")},onSettled:()=>{Nl.current=!1}}),Om=it({mutationFn:({id:ke,data:Rt})=>ue.updatePrinter(ke,Rt),onSuccess:()=>{t.invalidateQueries({queryKey:[\"printers\"]}),o(i(\"settings.toast.cameraSettingsSaved\"),\"success\")},onError:ke=>{o(`Failed to update printer: ${ke.message}`,\"error\")}});w.useEffect(()=>{if(!(Lm.current||!k||!et||!(et.auto_archive!==k.auto_archive||et.save_thumbnails!==k.save_thumbnails||et.capture_finish_photo!==k.capture_finish_photo||et.default_filament_cost!==k.default_filament_cost||et.currency!==k.currency||et.energy_cost_per_kwh!==k.energy_cost_per_kwh||et.energy_tracking_mode!==k.energy_tracking_mode||et.check_updates!==k.check_updates||(et.check_printer_firmware??!0)!==(k.check_printer_firmware??!0)||(et.include_beta_updates??!1)!==(k.include_beta_updates??!1)||et.notification_language!==k.notification_language||(et.bed_cooled_threshold??35)!==(k.bed_cooled_threshold??35)||et.ams_humidity_good!==k.ams_humidity_good||et.ams_humidity_fair!==k.ams_humidity_fair||et.ams_temp_good!==k.ams_temp_good||et.ams_temp_fair!==k.ams_temp_fair||et.ams_history_retention_days!==k.ams_history_retention_days||et.disable_filament_warnings!==k.disable_filament_warnings||et.prefer_lowest_filament!==k.prefer_lowest_filament||(et.queue_drying_enabled??!1)!==(k.queue_drying_enabled??!1)||(et.queue_drying_block??!1)!==(k.queue_drying_block??!1)||(et.ambient_drying_enabled??!1)!==(k.ambient_drying_enabled??!1)||(et.drying_presets??\"\")!==(k.drying_presets??\"\")||et.per_printer_mapping_expanded!==k.per_printer_mapping_expanded||et.date_format!==k.date_format||et.time_format!==k.time_format||et.default_printer_id!==k.default_printer_id||et.ftp_retry_enabled!==k.ftp_retry_enabled||et.ftp_retry_count!==k.ftp_retry_count||et.ftp_retry_delay!==k.ftp_retry_delay||et.ftp_timeout!==k.ftp_timeout||et.mqtt_enabled!==k.mqtt_enabled||et.mqtt_broker!==k.mqtt_broker||et.mqtt_port!==k.mqtt_port||et.mqtt_username!==k.mqtt_username||et.mqtt_password!==k.mqtt_password||et.mqtt_topic_prefix!==k.mqtt_topic_prefix||et.mqtt_use_tls!==k.mqtt_use_tls||et.external_url!==k.external_url||et.ha_enabled!==k.ha_enabled||et.ha_url!==k.ha_url||et.ha_token!==k.ha_token||(et.library_archive_mode??\"ask\")!==(k.library_archive_mode??\"ask\")||Number(et.library_disk_warning_gb??5)!==Number(k.library_disk_warning_gb??5)||(et.camera_view_mode??\"window\")!==(k.camera_view_mode??\"window\")||(et.preferred_slicer??\"bambu_studio\")!==(k.preferred_slicer??\"bambu_studio\")||et.prometheus_enabled!==k.prometheus_enabled||et.prometheus_token!==k.prometheus_token||(et.user_notifications_enabled??!0)!==(k.user_notifications_enabled??!0)||(et.default_bed_levelling??!0)!==(k.default_bed_levelling??!0)||(et.default_flow_cali??!1)!==(k.default_flow_cali??!1)||(et.default_vibration_cali??!0)!==(k.default_vibration_cali??!0)||(et.default_layer_inspect??!1)!==(k.default_layer_inspect??!1)||(et.default_timelapse??!1)!==(k.default_timelapse??!1)||(et.stagger_group_size??2)!==(k.stagger_group_size??2)||(et.stagger_interval_minutes??5)!==(k.stagger_interval_minutes??5)||(et.require_plate_clear??!1)!==(k.require_plate_clear??!1)))&&!Nl.current)return kl.current&&clearTimeout(kl.current),kl.current=setTimeout(()=>{if(Nl.current)return;Nl.current=!0;const Rt={auto_archive:k.auto_archive,save_thumbnails:k.save_thumbnails,capture_finish_photo:k.capture_finish_photo,default_filament_cost:k.default_filament_cost,currency:k.currency,energy_cost_per_kwh:k.energy_cost_per_kwh,energy_tracking_mode:k.energy_tracking_mode,check_updates:k.check_updates,check_printer_firmware:k.check_printer_firmware,include_beta_updates:k.include_beta_updates,notification_language:k.notification_language,bed_cooled_threshold:k.bed_cooled_threshold,ams_humidity_good:k.ams_humidity_good,ams_humidity_fair:k.ams_humidity_fair,ams_temp_good:k.ams_temp_good,ams_temp_fair:k.ams_temp_fair,ams_history_retention_days:k.ams_history_retention_days,disable_filament_warnings:k.disable_filament_warnings,prefer_lowest_filament:k.prefer_lowest_filament,queue_drying_enabled:k.queue_drying_enabled,queue_drying_block:k.queue_drying_block,ambient_drying_enabled:k.ambient_drying_enabled,drying_presets:k.drying_presets,per_printer_mapping_expanded:k.per_printer_mapping_expanded,date_format:k.date_format,time_format:k.time_format,default_printer_id:k.default_printer_id,ftp_retry_enabled:k.ftp_retry_enabled,ftp_retry_count:k.ftp_retry_count,ftp_retry_delay:k.ftp_retry_delay,ftp_timeout:k.ftp_timeout,mqtt_enabled:k.mqtt_enabled,mqtt_broker:k.mqtt_broker,mqtt_port:k.mqtt_port,mqtt_username:k.mqtt_username,mqtt_password:k.mqtt_password,mqtt_topic_prefix:k.mqtt_topic_prefix,mqtt_use_tls:k.mqtt_use_tls,external_url:k.external_url,ha_enabled:k.ha_enabled,ha_url:k.ha_url,ha_token:k.ha_token,library_archive_mode:k.library_archive_mode,library_disk_warning_gb:k.library_disk_warning_gb,camera_view_mode:k.camera_view_mode,preferred_slicer:k.preferred_slicer,prometheus_enabled:k.prometheus_enabled,prometheus_token:k.prometheus_token,user_notifications_enabled:k.user_notifications_enabled,default_bed_levelling:k.default_bed_levelling,default_flow_cali:k.default_flow_cali,default_vibration_cali:k.default_vibration_cali,default_layer_inspect:k.default_layer_inspect,default_timelapse:k.default_timelapse,stagger_group_size:k.stagger_group_size,stagger_interval_minutes:k.stagger_interval_minutes,require_plate_clear:k.require_plate_clear};as.mutate(Rt)},500),()=>{kl.current&&clearTimeout(kl.current)}},[k,et,as]);const xn=w.useCallback((ke,Rt)=>{D(Sn=>Sn?{...Sn,[ke]:Rt}:null)},[]),kc=async(ke,Rt,Sn)=>{if(!Rt){o(i(\"settings.toast.enterCameraUrl\"),\"error\");return}Ge(_n=>({..._n,[ke]:!0})),ot(_n=>({..._n,[ke]:null}));try{const _n=await ue.testExternalCamera(ke,Rt,Sn);ot(Bn=>({...Bn,[ke]:_n})),_n.success?o(i(\"settings.toast.cameraConnected\",{resolution:_n.resolution||\"\"}),\"success\"):o(_n.error||i(\"settings.toast.connectionFailed\"),\"error\")}catch(_n){const Bn=_n instanceof Error?_n.message:i(\"settings.toast.testFailed\");ot(xa=>({...xa,[ke]:{success:!1,error:Bn}})),o(Bn,\"error\")}finally{Ge(_n=>({..._n,[ke]:!1}))}},[Bp,od]=w.useState({}),Nc=w.useRef({}),su=w.useRef(new Set);w.useEffect(()=>{if(Fn){const ke={};Fn.forEach(Rt=>{Rt.external_camera_url&&!su.current.has(Rt.id)&&(ke[Rt.id]=Rt.external_camera_url,su.current.add(Rt.id))}),Object.keys(ke).length>0&&od(Rt=>({...Rt,...ke}))}},[Fn]);const qy=(ke,Rt)=>{od(Sn=>({...Sn,[ke]:Rt})),Nc.current[ke]&&clearTimeout(Nc.current[ke]),Nc.current[ke]=setTimeout(()=>{Om.mutate({id:ke,data:{external_camera_url:Rt||null}})},800)},Hp=(ke,Rt)=>{const Sn={};Rt.type!==void 0&&(Sn.external_camera_type=Rt.type||null),Rt.enabled!==void 0&&(Sn.external_camera_enabled=Rt.enabled),Rt.rotation!==void 0&&(Sn.camera_rotation=Rt.rotation),Om.mutate({id:ke,data:Sn})};if(jt||!k)return a.jsx(\"div\",{className:\"p-4 md:p-8 flex justify-center\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})});const ld=Mnt().map(ke=>({...ke,label:i(ke.labelKey,ke.labelFallback??ke.labelKey)})),Cc=me.trim().toLowerCase(),Im=Cc?ld.filter(ke=>ke.label.toLowerCase().includes(Cc)||ke.keywords.toLowerCase().includes(Cc)).slice(0,8):[],$y=ke=>{X(ke.tab),ke.subTab&&G(ke.subTab),be(\"\"),setTimeout(()=>{const Rt=document.getElementById(ke.anchor);Rt&&(Rt.scrollIntoView({behavior:\"smooth\",block:\"start\"}),Rt.classList.add(\"ring-2\",\"ring-bambu-green\"),setTimeout(()=>Rt.classList.remove(\"ring-2\",\"ring-bambu-green\"),1500))},50)};return a.jsx(bye,{density:\"dense\",children:a.jsxs(\"div\",{className:\"p-4 md:p-6\",children:[a.jsxs(\"div\",{className:\"mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3\",children:[a.jsxs(\"div\",{className:\"flex items-baseline gap-3\",children:[a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:i(\"settings.title\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray hidden md:block\",children:i(\"settings.configureBambuddy\")})]}),a.jsxs(\"div\",{className:\"relative sm:w-72\",children:[a.jsx(Pr,{className:\"w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\"}),a.jsx(\"input\",{type:\"text\",value:me,onChange:ke=>be(ke.target.value),placeholder:i(\"settings.searchPlaceholder\",\"Search settings…\"),className:\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"}),me&&a.jsx(\"button\",{onClick:()=>be(\"\"),className:\"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-bambu-gray hover:text-white\",\"aria-label\":\"Clear\",children:a.jsx(Ht,{className:\"w-3.5 h-3.5\"})}),Im.length>0&&a.jsx(\"div\",{className:\"absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-30 overflow-hidden\",children:Im.map(ke=>a.jsxs(\"button\",{onClick:()=>$y(ke),className:\"w-full px-3 py-2 text-left hover:bg-bambu-dark-tertiary transition-colors border-b border-bambu-dark-tertiary last:border-b-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:ke.label}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[i(`settings.tabs.${ke.tab===\"virtual-printer\"?\"virtualPrinter\":ke.tab===\"failure-detection\"?\"failureDetection\":ke.tab}`),ke.subTab?` › ${i(`settings.tabs.${ke.subTab}`,ke.subTab)}`:\"\"]})]},ke.anchor))}),Cc&&Im.length===0&&a.jsx(\"div\",{className:\"absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-30 p-3\",children:a.jsx(\"p\",{className:\"text-xs text-bambu-gray italic\",children:i(\"settings.noSearchResults\",\"No matching settings.\")})})]})]}),a.jsxs(\"div\",{className:\"flex flex-col lg:flex-row gap-4 lg:gap-6\",children:[a.jsxs(\"nav\",{className:\"flex flex-wrap gap-1 border-b border-bambu-dark-tertiary lg:flex-col lg:flex-nowrap lg:gap-0 lg:border-b-0 lg:border-r lg:w-48 lg:flex-shrink-0 lg:self-start lg:sticky lg:top-4\",children:[a.jsxs(\"button\",{onClick:()=>X(\"general\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"general\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(sS,{className:\"w-4 h-4\"}),i(\"settings.tabs.general\")]}),a.jsxs(\"button\",{onClick:()=>X(\"plugs\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"plugs\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(nc,{className:\"w-4 h-4\"}),i(\"settings.tabs.smartPlugs\"),qt&&qt.length>0&&a.jsx(\"span\",{className:\"text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full\",children:qt.length})]}),a.jsxs(\"button\",{onClick:()=>X(\"notifications\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"notifications\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(Zh,{className:\"w-4 h-4\"}),i(\"settings.tabs.notifications\"),bn&&bn.length>0&&a.jsx(\"span\",{className:\"text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full\",children:bn.length})]}),a.jsxs(\"button\",{onClick:()=>X(\"queue\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"queue\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(SN,{className:\"w-4 h-4\"}),i(\"settings.tabs.queue\",\"Workflow\")]}),a.jsxs(\"button\",{onClick:()=>X(\"filament\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"filament\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(Lfe,{className:\"w-4 h-4\"}),i(\"settings.tabs.filament\")]}),a.jsxs(\"button\",{onClick:()=>X(\"network\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"network\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(rp,{className:\"w-4 h-4\"}),i(\"settings.tabs.network\"),a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${rs?.enabled?\"bg-green-400\":\"bg-gray-500\"}`})]}),a.jsxs(\"button\",{onClick:()=>X(\"apikeys\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"apikeys\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(no,{className:\"w-4 h-4\"}),i(\"settings.tabs.apiKeys\"),wn&&wn.length>0&&a.jsx(\"span\",{className:\"text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full\",children:wn.length})]}),a.jsxs(\"button\",{onClick:()=>X(\"virtual-printer\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"virtual-printer\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(Er,{className:\"w-4 h-4\"}),i(\"settings.tabs.virtualPrinter\"),a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${ra?\"bg-green-400\":\"bg-gray-500\"}`})]}),a.jsxs(\"button\",{onClick:()=>X(\"spoolbuddy\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"spoolbuddy\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(BQ,{className:\"w-4 h-4\"}),i(\"settings.tabs.spoolbuddy\"),Br>0&&a.jsx(\"span\",{className:\"text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full\",children:Br}),a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${cr?\"bg-green-400\":\"bg-gray-500\"}`})]}),a.jsxs(\"button\",{onClick:()=>X(\"failure-detection\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"failure-detection\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(HQ,{className:\"w-4 h-4\"}),i(\"settings.tabs.failureDetection\"),a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${Ui?\"bg-green-400\":\"bg-gray-500\"}`})]}),a.jsxs(\"button\",{onClick:()=>X(\"users\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"users\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(Wu,{className:\"w-4 h-4\"}),i(\"settings.tabs.users\"),l&&a.jsx(\"span\",{className:\"w-2 h-2 rounded-full bg-green-400\"})]}),a.jsxs(\"button\",{onClick:()=>X(\"backup\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${U===\"backup\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(Jl,{className:\"w-4 h-4\"}),i(\"settings.tabs.backup\"),a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${Sc?.is_authenticated&&wc?.configured&&wc?.enabled?\"bg-green-400\":\"bg-gray-500\"}`})]})]}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[U===\"general\"&&a.jsxs(\"div\",{className:\"flex flex-col lg:flex-row gap-4 lg:gap-6\",children:[a.jsxs(\"div\",{className:\"space-y-3 flex-1 lg:max-w-xl\",children:[a.jsxs(Tt,{id:\"card-general\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.general\")})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[a.jsx(ul,{className:\"w-4 h-4 inline mr-1\"}),i(\"settings.language\")]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"select\",{value:s.language,onChange:ke=>{s.changeLanguage(ke.target.value),ue.updateSettings({language:ke.target.value}),o(i(\"settings.toast.settingsSaved\"),\"success\")},className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:iH.map(ke=>a.jsxs(\"option\",{value:ke.code,children:[ke.nativeName,\" (\",ke.name,\")\"]},ke.code))}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.languageDescription\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.defaultView\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"select\",{value:Y,onChange:ke=>Ze(ke.target.value),className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:Zu.map(ke=>a.jsx(\"option\",{value:ke.to,children:i(ke.labelKey)},ke.id))}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.defaultViewDescription\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.dateFormat\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:k.date_format||\"system\",onChange:ke=>xn(\"date_format\",ke.target.value),className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:[a.jsx(\"option\",{value:\"system\",children:i(\"settings.systemDefault\")}),a.jsx(\"option\",{value:\"us\",children:i(\"settings.dateFormatUs\")}),a.jsx(\"option\",{value:\"eu\",children:i(\"settings.dateFormatEu\")}),a.jsx(\"option\",{value:\"iso\",children:i(\"settings.dateFormatIso\")})]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.timeFormat\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:k.time_format||\"system\",onChange:ke=>xn(\"time_format\",ke.target.value),className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:[a.jsx(\"option\",{value:\"system\",children:i(\"settings.systemDefault\")}),a.jsx(\"option\",{value:\"12h\",children:i(\"settings.timeFormat12\")}),a.jsx(\"option\",{value:\"24h\",children:i(\"settings.timeFormat24\")})]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.defaultPrinter\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:k.default_printer_id??\"\",onChange:ke=>xn(\"default_printer_id\",ke.target.value?Number(ke.target.value):null),className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:[a.jsx(\"option\",{value:\"\",children:i(\"settings.noDefaultPrinter\")}),Fn?.map(ke=>a.jsx(\"option\",{value:ke.id,children:ke.name},ke.id))]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.defaultPrinterDescription\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.preferredSlicer\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"select\",{value:k.preferred_slicer??\"bambu_studio\",onChange:ke=>xn(\"preferred_slicer\",ke.target.value),className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:[a.jsx(\"option\",{value:\"bambu_studio\",children:i(\"settings.slicerBambuStudio\")}),a.jsx(\"option\",{value:\"orcaslicer\",children:i(\"settings.slicerOrcaSlicer\")})]}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.preferredSlicerDescription\")})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.sidebarOrder\")}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[i(\"settings.sidebarOrderDescription\"),l&&m(\"settings:update\")&&` ${i(\"settings.sidebarOrderSetDefaultHint\")}`]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 shrink-0\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:Je,children:[a.jsx(co,{className:\"w-4 h-4\"}),i(\"settings.reset\")]}),l&&m(\"settings:update\")&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray whitespace-nowrap\",children:i(\"settings.setDefault\")}),a.jsx(Ln,{checked:We,onChange:Ue,disabled:jt})]})]})]})]})]}),a.jsxs(Tt,{id:\"card-appearance\",children:[a.jsx(gn,{children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(nS,{className:\"w-5 h-5\"}),i(\"settings.appearance\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:`space-y-3 p-4 rounded-lg border ${p===\"dark\"?\"border-bambu-green bg-bambu-green/5\":\"border-bambu-dark-tertiary\"}`,children:[a.jsxs(\"h3\",{className:\"text-sm font-medium text-white flex items-center gap-2\",children:[i(\"settings.darkMode\"),p===\"dark\"&&a.jsx(\"span\",{className:\"text-xs text-bambu-green\",children:i(\"settings.active\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-3 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.background\")}),a.jsxs(\"select\",{value:y,onChange:ke=>{P(ke.target.value),o(i(\"settings.toast.settingsSaved\"),\"success\")},className:\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"neutral\",children:i(\"settings.bgNeutral\")}),a.jsx(\"option\",{value:\"warm\",children:i(\"settings.bgWarm\")}),a.jsx(\"option\",{value:\"cool\",children:i(\"settings.bgCool\")}),a.jsx(\"option\",{value:\"oled\",children:i(\"settings.bgOled\")}),a.jsx(\"option\",{value:\"slate\",children:i(\"settings.bgSlate\")}),a.jsx(\"option\",{value:\"forest\",children:i(\"settings.bgForest\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.accent\")}),a.jsxs(\"select\",{value:v,onChange:ke=>{N(ke.target.value),o(i(\"settings.toast.settingsSaved\"),\"success\")},className:\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"green\",children:i(\"settings.accentGreen\")}),a.jsx(\"option\",{value:\"teal\",children:i(\"settings.accentTeal\")}),a.jsx(\"option\",{value:\"blue\",children:i(\"settings.accentBlue\")}),a.jsx(\"option\",{value:\"orange\",children:i(\"settings.accentOrange\")}),a.jsx(\"option\",{value:\"purple\",children:i(\"settings.accentPurple\")}),a.jsx(\"option\",{value:\"red\",children:i(\"settings.accentRed\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.style\")}),a.jsxs(\"select\",{value:f,onChange:ke=>{C(ke.target.value),o(i(\"settings.toast.settingsSaved\"),\"success\")},className:\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"classic\",children:i(\"settings.styleClassic\")}),a.jsx(\"option\",{value:\"glow\",children:i(\"settings.styleGlow\")}),a.jsx(\"option\",{value:\"vibrant\",children:i(\"settings.styleVibrant\")})]})]})]})]}),a.jsxs(\"div\",{className:`space-y-3 p-4 rounded-lg border ${p===\"light\"?\"border-bambu-green bg-bambu-green/5\":\"border-bambu-dark-tertiary\"}`,children:[a.jsxs(\"h3\",{className:\"text-sm font-medium text-white flex items-center gap-2\",children:[i(\"settings.lightMode\"),p===\"light\"&&a.jsx(\"span\",{className:\"text-xs text-bambu-green\",children:i(\"settings.active\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-3 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.background\")}),a.jsxs(\"select\",{value:g,onChange:ke=>{T(ke.target.value),o(i(\"settings.toast.settingsSaved\"),\"success\")},className:\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"neutral\",children:i(\"settings.bgNeutral\")}),a.jsx(\"option\",{value:\"warm\",children:i(\"settings.bgWarm\")}),a.jsx(\"option\",{value:\"cool\",children:i(\"settings.bgCool\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.accent\")}),a.jsxs(\"select\",{value:_,onChange:ke=>{F(ke.target.value),o(i(\"settings.toast.settingsSaved\"),\"success\")},className:\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"green\",children:i(\"settings.accentGreen\")}),a.jsx(\"option\",{value:\"teal\",children:i(\"settings.accentTeal\")}),a.jsx(\"option\",{value:\"blue\",children:i(\"settings.accentBlue\")}),a.jsx(\"option\",{value:\"orange\",children:i(\"settings.accentOrange\")}),a.jsx(\"option\",{value:\"purple\",children:i(\"settings.accentPurple\")}),a.jsx(\"option\",{value:\"red\",children:i(\"settings.accentRed\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.style\")}),a.jsxs(\"select\",{value:b,onChange:ke=>{A(ke.target.value),o(i(\"settings.toast.settingsSaved\"),\"success\")},className:\"w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"classic\",children:i(\"settings.styleClassic\")}),a.jsx(\"option\",{value:\"glow\",children:i(\"settings.styleGlow\")}),a.jsx(\"option\",{value:\"vibrant\",children:i(\"settings.styleVibrant\")})]})]})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.themeToggleHint\")})]})]}),a.jsxs(Tt,{id:\"card-archive\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.archiveSettings\")})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.autoArchivePrints\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.autoArchiveDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.auto_archive,onChange:ke=>xn(\"auto_archive\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.saveThumbnails\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.saveThumbnailsDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.save_thumbnails,onChange:ke=>xn(\"save_thumbnails\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.captureFinishPhoto\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.captureFinishPhotoDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.capture_finish_photo,onChange:ke=>xn(\"capture_finish_photo\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),k.capture_finish_photo&&vc&&!vc.installed&&a.jsxs(\"div\",{className:\"flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg\",children:[a.jsx(Dn,{className:\"w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5\"}),a.jsxs(\"div\",{className:\"text-sm\",children:[a.jsx(\"p\",{className:\"text-yellow-500 font-medium\",children:i(\"settings.ffmpegNotInstalled\")}),a.jsx(\"p\",{className:\"text-bambu-gray mt-1\",children:i(\"settings.ffmpegRequired\")})]})]})]})]})]}),a.jsxs(\"div\",{className:\"space-y-3 flex-1 lg:max-w-md\",children:[a.jsxs(Tt,{id:\"card-camera\",children:[a.jsx(gn,{children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(OO,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.camera\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.cameraViewMode\")}),a.jsxs(\"select\",{value:k.camera_view_mode??\"window\",onChange:ke=>xn(\"camera_view_mode\",ke.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\",children:[a.jsx(\"option\",{value:\"window\",children:i(\"settings.newWindow\")}),a.jsx(\"option\",{value:\"embedded\",children:i(\"settings.embeddedOverlay\")})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:k.camera_view_mode===\"embedded\"?i(\"settings.cameraOverlayDescription\"):i(\"settings.cameraWindowDescription\")})]}),a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4 mt-4\",children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white mb-2\",children:i(\"settings.externalCameras\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-3\",children:i(\"settings.externalCamerasDescription\")}),Fn&&Fn.length>0?a.jsx(\"div\",{className:\"space-y-3\",children:Fn.map(ke=>a.jsxs(\"div\",{className:\"p-3 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"span\",{className:\"text-white font-medium text-sm\",children:ke.name}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:ke.external_camera_enabled,onChange:Rt=>Hp(ke.id,{enabled:Rt.target.checked}),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green\"})]})]}),ke.external_camera_enabled&&a.jsxs(\"div\",{className:\"space-y-2 mt-2\",children:[a.jsx(\"input\",{type:\"text\",placeholder:ke.external_camera_type===\"usb\"?i(\"settings.cameraPlaceholderUsb\"):i(\"settings.cameraPlaceholderUrl\"),value:Bp[ke.id]??ke.external_camera_url??\"\",onChange:Rt=>qy(ke.id,Rt.target.value),className:\"w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"select\",{value:ke.external_camera_type||\"mjpeg\",onChange:Rt=>Hp(ke.id,{type:Rt.target.value}),className:\"flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"mjpeg\",children:i(\"settings.cameraTypeMjpeg\")}),a.jsx(\"option\",{value:\"rtsp\",children:i(\"settings.cameraTypeRtsp\")}),a.jsx(\"option\",{value:\"snapshot\",children:i(\"settings.cameraTypeSnapshot\")}),a.jsx(\"option\",{value:\"usb\",children:i(\"settings.cameraTypeUsb\")})]}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:()=>kc(ke.id,Bp[ke.id]??ke.external_camera_url??\"\",ke.external_camera_type||\"mjpeg\"),disabled:Pe[ke.id]||!(Bp[ke.id]??ke.external_camera_url),children:Pe[ke.id]?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):i(\"settings.test\")})]}),ze[ke.id]&&a.jsx(\"div\",{className:`text-xs flex items-center gap-1 ${ze[ke.id]?.success?\"text-green-500\":\"text-red-500\"}`,children:ze[ke.id]?.success?a.jsxs(a.Fragment,{children:[a.jsx(yr,{className:\"w-3 h-3\"}),i(\"settings.connected\"),ze[ke.id]?.resolution&&` (${ze[ke.id]?.resolution})`]}):a.jsxs(a.Fragment,{children:[a.jsx(Ma,{className:\"w-3 h-3\"}),ze[ke.id]?.error||i(\"settings.toast.connectionFailed\")]})}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.cameraRotation\")}),a.jsxs(\"select\",{value:ke.camera_rotation||0,onChange:Rt=>Hp(ke.id,{rotation:parseInt(Rt.target.value)}),className:\"px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:0,children:\"0°\"}),a.jsx(\"option\",{value:90,children:\"90°\"}),a.jsx(\"option\",{value:180,children:\"180°\"}),a.jsx(\"option\",{value:270,children:\"270°\"})]})]})]})]},ke.id))}):a.jsx(\"p\",{className:\"text-xs text-bambu-gray italic\",children:i(\"settings.noPrintersConfigured\")})]})]})]}),a.jsxs(Tt,{id:\"card-cost\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.costTracking\")})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.currency\")}),a.jsx(\"select\",{value:k.currency,onChange:ke=>xn(\"currency\",ke.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\",children:g7e.map(ke=>a.jsx(\"option\",{value:ke.code,children:ke.label},ke.code))})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.defaultFilamentCost\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"span\",{className:\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none\",children:Eo(k.currency)}),a.jsx(\"input\",{type:\"number\",step:\"0.01\",min:\"0\",value:k.default_filament_cost,onChange:ke=>xn(\"default_filament_cost\",parseFloat(ke.target.value)||0),style:{paddingLeft:`${Math.max(2,Eo(k.currency).length*.6+1)}rem`},className:\"w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.electricityCost\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"span\",{className:\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none\",children:Eo(k.currency)}),a.jsx(\"input\",{type:\"number\",step:\"0.001\",min:\"0\",value:k.energy_cost_per_kwh,onChange:ke=>xn(\"energy_cost_per_kwh\",parseFloat(ke.target.value)||0),style:{paddingLeft:`${Math.max(2,Eo(k.currency).length*.6+1)}rem`},className:\"w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.energyDisplayMode\")}),a.jsxs(\"select\",{value:k.energy_tracking_mode||\"total\",onChange:ke=>xn(\"energy_tracking_mode\",ke.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\",children:[a.jsx(\"option\",{value:\"print\",children:i(\"settings.printsOnly\")}),a.jsx(\"option\",{value:\"total\",children:i(\"settings.totalConsumption\")})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:k.energy_tracking_mode===\"print\"?i(\"settings.energyModePrintDescription\"):i(\"settings.energyModeTotalDescription\")})]})]})]}),a.jsxs(Tt,{id:\"card-filemanager\",children:[a.jsx(gn,{children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(Us,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.fileManager\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.createArchiveEntry\")}),a.jsxs(\"select\",{value:k.library_archive_mode??\"ask\",onChange:ke=>xn(\"library_archive_mode\",ke.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\",children:[a.jsx(\"option\",{value:\"always\",children:i(\"settings.archiveMode.always\")}),a.jsx(\"option\",{value:\"never\",children:i(\"settings.archiveMode.never\")}),a.jsx(\"option\",{value:\"ask\",children:i(\"settings.archiveMode.ask\")})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.createArchiveEntryDescription\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.lowDiskSpaceWarning\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"number\",min:\"0.5\",max:\"100\",step:\"0.5\",value:k.library_disk_warning_gb??5,onChange:ke=>xn(\"library_disk_warning_gb\",parseFloat(ke.target.value)||5),className:\"w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"GB\"})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.lowDiskSpaceDescription\")})]})]})]})]}),a.jsxs(\"div\",{className:\"space-y-3 flex-1 lg:max-w-sm\",children:[a.jsx(dnt,{}),a.jsxs(Tt,{id:\"card-updates\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.updates\")})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-xs font-medium text-bambu-gray uppercase tracking-wider\",children:i(\"settings.printerFirmware\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.checkPrinterFirmware\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.checkFirmwareDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.check_printer_firmware??!0,onChange:ke=>xn(\"check_printer_firmware\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsx(\"div\",{className:\"border-t border-bambu-dark-tertiary pt-4\",children:a.jsx(\"p\",{className:\"text-xs font-medium text-bambu-gray uppercase tracking-wider mb-4\",children:i(\"settings.bambuddySoftware\")})}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.checkForUpdatesLabel\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.autoCheckDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.check_updates,onChange:ke=>xn(\"check_updates\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{className:`flex items-center justify-between ${k.check_updates?\"\":\"opacity-50\"}`,children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.includeBetaUpdates\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.includeBetaUpdatesDesc\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.include_beta_updates??!1,onChange:ke=>xn(\"include_beta_updates\",ke.target.checked),disabled:!k.check_updates,className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.currentVersion\")}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[\"v\",_l?.version||\"...\"]})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>Kn(),disabled:dr,children:[dr?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(lr,{className:\"w-4 h-4\"}),i(\"settings.checkNow\")]})]}),vt?.update_available?a.jsxs(\"div\",{className:\"mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-start justify-between\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"p\",{className:\"text-bambu-green font-medium\",children:[\"Update available: v\",vt.latest_version]}),vt.release_name&&vt.release_name!==vt.latest_version&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:vt.release_name})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[vt.release_notes&&a.jsx(\"button\",{onClick:()=>$e(!0),className:\"text-bambu-gray hover:text-white transition-colors text-sm underline\",children:i(\"settings.releaseNotes\")}),vt.release_url&&a.jsx(\"a\",{href:vt.release_url,target:\"_blank\",rel:\"noopener noreferrer\",className:\"text-bambu-gray hover:text-white transition-colors\",title:i(\"settings.viewReleaseOnGitHub\"),children:a.jsx(la,{className:\"w-4 h-4\"})})]})]}),$t?.status===\"downloading\"||$t?.status===\"installing\"?a.jsxs(\"div\",{className:\"mt-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray\",children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),a.jsx(\"span\",{children:$t.message})]}),a.jsx(\"div\",{className:\"mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2\",children:a.jsx(\"div\",{className:\"bg-bambu-green h-2 rounded-full transition-all duration-300\",style:{width:`${$t.progress}%`}})})]}):$t?.status===\"complete\"?a.jsx(\"div\",{className:\"mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green\",children:$t.message}):$t?.status===\"error\"?a.jsx(\"div\",{className:\"mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400\",children:$t.error||$t.message}):vt?.is_docker?a.jsxs(\"div\",{className:\"mt-3 p-3 bg-bambu-dark-tertiary rounded-lg\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-2\",children:i(\"settings.updateViaDocker\")}),a.jsx(\"code\",{className:\"block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono\",children:\"docker compose pull && docker compose up -d\"})]}):a.jsxs(De,{className:\"mt-3\",onClick:()=>Up.mutate(),disabled:Up.isPending,children:[Up.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(ga,{className:\"w-4 h-4\"}),i(\"settings.installUpdate\")]})]}):vt?.error?a.jsx(\"div\",{className:\"mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-400\",children:i(\"settings.failedToCheckUpdates\",{error:vt.error})}):vt&&!vt.update_available?a.jsx(\"p\",{className:\"mt-2 text-sm text-bambu-gray\",children:i(\"settings.latestVersionRunning\")}):null]})]})]}),a.jsxs(Tt,{id:\"card-data\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.dataManagement\")})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.clearNotificationLogs\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.clearNotificationLogsDescription\")})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>ye(!0),children:[a.jsx(en,{className:\"w-4 h-4\"}),i(\"common.clear\")]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.resetUiPreferences\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.resetUiPreferencesDescription\")})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>Le(!0),children:[a.jsx(en,{className:\"w-4 h-4\"}),i(\"settings.reset\")]})]}),a.jsxs(\"div\",{className:\"pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.storageUsage\",\"Storage Usage\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.storageUsageDescription\",\"Breakdown of data usage by category\")})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:Pt,disabled:St||xe,children:[a.jsx(lr,{className:`w-4 h-4 ${St||xe?\"animate-spin\":\"\"}`}),i(\"common.refresh\",\"Refresh\")]})]}),a.jsx(\"div\",{className:\"mt-3\",children:qe?a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray\",children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),i(\"common.loading\",\"Loading\")]}):yt?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex\",children:yt.categories.filter(ke=>ke.bytes>0).map((ke,Rt)=>a.jsx(\"div\",{className:`${SY(ke.key,Rt)} h-full`,style:{width:`${ke.percent_of_total}%`},title:`${ke.label}: ${ke.formatted}`},ke.key))}),a.jsx(\"div\",{className:\"mt-3 flex flex-wrap gap-3\",children:yt.categories.filter(ke=>ke.bytes>0).map((ke,Rt)=>a.jsxs(\"div\",{className:\"flex items-center gap-2 text-xs\",children:[a.jsx(\"span\",{className:`w-3 h-3 rounded-full ${SY(ke.key,Rt)}`}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:ke.label}),a.jsx(\"span\",{className:\"text-white\",children:ke.formatted}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"(\",ke.percent_of_total.toFixed(1),\"%)\"]})]},ke.key))}),a.jsxs(\"div\",{className:\"mt-2 text-xs text-bambu-gray\",children:[i(\"settings.storageUsageTotal\",\"Total\"),\": \",a.jsx(\"span\",{className:\"text-white\",children:yt.total_formatted}),yt.scan_errors>0&&a.jsxs(\"span\",{className:\"ml-2 text-amber-400\",children:[i(\"settings.storageUsageErrors\",\"Scan errors\"),\": \",yt.scan_errors]})]}),yt.other_breakdown?.length>0&&a.jsxs(\"div\",{className:\"mt-4\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:i(\"settings.storageUsageOtherBreakdown\",\"Other breakdown\")}),a.jsx(\"div\",{className:\"space-y-2\",children:yt.other_breakdown.map(ke=>a.jsxs(\"div\",{className:\"flex items-center justify-between text-xs\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-white\",children:ke.label}),a.jsx(\"span\",{className:`px-2 py-0.5 rounded-full border ${ke.kind===\"system\"?\"border-slate-500 text-slate-300\":\"border-bambu-green text-bambu-green\"}`,children:ke.kind===\"system\"?i(\"settings.storageUsageSystem\",\"System\"):i(\"settings.storageUsageData\",\"Data\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray\",children:[a.jsx(\"span\",{className:\"text-white\",children:ke.formatted}),a.jsxs(\"span\",{children:[\"(\",ke.percent_of_total.toFixed(1),\"%)\"]})]})]},`${ke.bucket}-${ke.kind}`))})]})]}):a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.storageUsageUnavailable\",\"Storage usage data is unavailable\")})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.backupRestore\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.backupRestoreDescription\")})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>X(\"backup\"),children:[a.jsx(Jl,{className:\"w-4 h-4\"}),i(\"settings.goToBackup\")]})]})]})]})]})]}),U===\"network\"&&k&&a.jsxs(\"div\",{className:\"flex flex-col lg:flex-row gap-6\",children:[a.jsxs(\"div\",{className:\"flex-1 lg:max-w-xl space-y-3\",children:[a.jsxs(Tt,{id:\"card-externalurl\",children:[a.jsx(gn,{children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(ul,{className:\"w-5 h-5 text-blue-400\"}),i(\"settings.externalUrl\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.externalUrlDescription\")}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.bambuddyUrl\")}),a.jsx(\"input\",{type:\"text\",value:k.external_url??\"\",onChange:ke=>xn(\"external_url\",ke.target.value),placeholder:\"http://192.168.1.100:8000\",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\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.externalUrlHint\")})]})]})]}),a.jsxs(Tt,{id:\"card-ftpretry\",children:[a.jsx(gn,{children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(lr,{className:\"w-5 h-5 text-blue-400\"}),i(\"settings.ftpRetry\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.ftpRetryDescription\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.enableRetry\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.autoRetryDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.ftp_retry_enabled??!0,onChange:ke=>xn(\"ftp_retry_enabled\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),k.ftp_retry_enabled&&a.jsxs(\"div\",{className:\"space-y-3 pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.retryAttempts\")}),a.jsxs(\"div\",{className:\"relative w-44\",children:[a.jsx(\"select\",{value:k.ftp_retry_count??3,onChange:ke=>xn(\"ftp_retry_count\",parseInt(ke.target.value)),className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:[1,2,3,4,5,6,7,8,9,10].map(ke=>a.jsx(\"option\",{value:ke,children:i(\"settings.time\",{count:ke})},ke))}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.retryDelay\")}),a.jsxs(\"div\",{className:\"relative w-44\",children:[a.jsx(\"select\",{value:k.ftp_retry_delay??2,onChange:ke=>xn(\"ftp_retry_delay\",parseInt(ke.target.value)),className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:[1,2,3,5,10,15,20,30].map(ke=>a.jsx(\"option\",{value:ke,children:i(\"settings.second\",{count:ke})},ke))}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.connectionTimeout\")}),a.jsxs(\"div\",{className:\"relative w-44\",children:[a.jsx(\"select\",{value:k.ftp_timeout??30,onChange:ke=>xn(\"ftp_timeout\",parseInt(ke.target.value)),className:\"w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer\",children:[10,15,20,30,45,60,90,120,180,300].map(ke=>a.jsx(\"option\",{value:ke,children:i(\"settings.nSeconds\",{count:ke})},ke))}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none\"})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.increaseForWeakWifi\")})]})]})]})]})]}),a.jsxs(\"div\",{className:\"flex-1 lg:max-w-xl space-y-3\",children:[a.jsxs(Tt,{id:\"card-ha\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(Jw,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.homeAssistant\")]}),k.ha_enabled&&Ie&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:`w-2.5 h-2.5 rounded-full ${Ie.success?\"bg-green-400\":\"bg-red-400\"}`}),a.jsx(\"span\",{className:`text-sm ${Ie.success?\"text-green-400\":\"text-red-400\"}`,children:Ie.success?i(\"settings.connected\"):i(\"settings.disconnected\")})]})]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.homeAssistantFullDescription\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.enableHomeAssistant\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.homeAssistantDescription\")}),k.ha_env_managed&&a.jsxs(\"div\",{className:\"flex items-center gap-1 mt-1\",children:[a.jsx(kd,{className:\"w-3 h-3 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-green\",children:i(\"settings.autoEnabledViaEnv\")})]})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.ha_enabled??!1,onChange:ke=>xn(\"ha_enabled\",ke.target.checked),disabled:k.ha_env_managed,className:\"sr-only peer\"}),a.jsx(\"div\",{className:`w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green ${k.ha_env_managed?\"opacity-60 cursor-not-allowed\":\"\"}`})]})]}),k.ha_enabled&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[i(\"settings.homeAssistantUrl\"),k.ha_url_from_env&&a.jsx(\"span\",{className:\"ml-2 text-xs text-bambu-green\",children:i(\"settings.environmentManagedLabel\")})]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"input\",{type:\"text\",value:k.ha_url??\"\",onChange:ke=>xn(\"ha_url\",ke.target.value),placeholder:\"http://192.168.1.100:8123\",disabled:k.ha_url_from_env,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 ${k.ha_url_from_env?\"opacity-60 cursor-not-allowed\":\"\"}`}),k.ha_url_from_env&&a.jsx(kd,{className:\"absolute right-3 top-2.5 w-4 h-4 text-bambu-gray\"})]}),k.ha_url_from_env&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.urlFromEnvReadOnly\")})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[i(\"settings.longLivedAccessToken\"),k.ha_token_from_env&&a.jsx(\"span\",{className:\"ml-2 text-xs text-bambu-green\",children:i(\"settings.environmentManagedLabel\")})]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"input\",{type:\"password\",value:k.ha_token??\"\",onChange:ke=>xn(\"ha_token\",ke.target.value),placeholder:\"eyJ0eXAiOiJKV1QiLC...\",disabled:k.ha_token_from_env,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 ${k.ha_token_from_env?\"opacity-60 cursor-not-allowed\":\"\"}`}),k.ha_token_from_env&&a.jsx(kd,{className:\"absolute right-3 top-2.5 w-4 h-4 text-bambu-gray\"})]}),k.ha_token_from_env?a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.tokenFromEnvReadOnly\")}):a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.haTokenHint\")})]}),k.ha_url&&k.ha_token&&a.jsx(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:a.jsxs(De,{variant:\"secondary\",size:\"sm\",disabled:pt,onClick:async()=>{nt(!0),mt(null);try{const ke=await ue.testHAConnection(k.ha_url,k.ha_token);mt(ke)}catch(ke){mt({success:!1,message:null,error:ke instanceof Error?ke.message:i(\"common.unknownError\")})}finally{nt(!1)}},children:[pt?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(rp,{className:\"w-4 h-4\"}),i(\"settings.testConnection\")]})})]})]})]}),a.jsxs(Tt,{id:\"card-mqtt\",children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(rp,{className:\"w-5 h-5 text-blue-400\"}),i(\"settings.mqttPublishing\")]}),rs?.enabled&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:`w-2.5 h-2.5 rounded-full ${rs.connected?\"bg-green-400\":\"bg-red-400\"}`}),a.jsx(\"span\",{className:`text-sm ${rs.connected?\"text-green-400\":\"text-red-400\"}`,children:rs.connected?i(\"settings.connected\"):i(\"settings.disconnected\")})]})]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.mqttDescription\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.enableMqtt\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.mqttEnableDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.mqtt_enabled??!1,onChange:ke=>xn(\"mqtt_enabled\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),k.mqtt_enabled&&a.jsxs(\"div\",{className:\"space-y-3 pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.brokerHostname\")}),a.jsx(\"input\",{type:\"text\",value:k.mqtt_broker??\"\",onChange:ke=>xn(\"mqtt_broker\",ke.target.value),placeholder:\"mqtt.example.com or 192.168.1.100\",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\"})]}),a.jsxs(\"div\",{className:\"flex items-end gap-4\",children:[a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.port\")}),a.jsx(\"input\",{type:\"number\",min:\"1\",max:\"65535\",value:k.mqtt_port??1883,onChange:ke=>xn(\"mqtt_port\",Math.min(65535,Math.max(1,parseInt(ke.target.value)||1883))),className:\"w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3 pb-2\",children:[a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.mqtt_use_tls??!1,onChange:ke=>{const Rt=ke.target.checked;xn(\"mqtt_use_tls\",Rt);const Sn=k.mqtt_port??1883;Rt&&Sn===1883?xn(\"mqtt_port\",8883):!Rt&&Sn===8883&&xn(\"mqtt_port\",1883)},className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]}),a.jsx(\"span\",{className:\"text-white text-sm\",children:i(\"settings.useTls\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.usernameOptional\")}),a.jsx(\"input\",{type:\"text\",value:k.mqtt_username??\"\",onChange:ke=>xn(\"mqtt_username\",ke.target.value),placeholder:i(\"settings.leaveEmptyForAnonymous\"),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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.passwordOptional\")}),a.jsx(\"input\",{type:\"password\",value:k.mqtt_password??\"\",onChange:ke=>xn(\"mqtt_password\",ke.target.value),placeholder:i(\"settings.leaveEmptyForAnonymous\"),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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.topicPrefix\")}),a.jsx(\"input\",{type:\"text\",value:k.mqtt_topic_prefix??\"bambuddy\",onChange:ke=>xn(\"mqtt_topic_prefix\",ke.target.value),placeholder:\"bambuddy\",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\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.topicPrefixHint\",{prefix:k.mqtt_topic_prefix||\"bambuddy\"})})]}),rs&&a.jsx(\"div\",{className:\"pt-3 mt-3 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${rs.connected?\"bg-green-400\":\"bg-red-400\"}`}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:rs.connected?a.jsxs(a.Fragment,{children:[i(\"settings.mqttConnectedTo\"),\" \",a.jsxs(\"span\",{className:\"text-white\",children:[rs.broker,\":\",rs.port]})]}):i(\"settings.spoolmanDisconnected\")})]})})]})]})]})]}),a.jsx(\"div\",{className:\"flex-1 lg:max-w-md space-y-3\",children:a.jsxs(Tt,{id:\"card-prometheus\",children:[a.jsx(gn,{children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(dF,{className:\"w-5 h-5 text-orange-400\"}),i(\"settings.prometheusMetrics\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.prometheusEndpointDescription\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.enableMetricsEndpoint\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.prometheusDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.prometheus_enabled??!1,onChange:ke=>xn(\"prometheus_enabled\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),k.prometheus_enabled&&a.jsxs(\"div\",{className:\"space-y-3 pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.bearerTokenOptional\")}),a.jsx(\"input\",{type:\"password\",value:k.prometheus_token??\"\",onChange:ke=>xn(\"prometheus_token\",ke.target.value),placeholder:i(\"settings.leaveEmptyForNoAuth\"),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\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.bearerTokenHint\")})]}),a.jsxs(\"div\",{className:\"pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-sm text-white mb-2\",children:i(\"settings.availableMetrics\")}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray space-y-1\",children:[a.jsxs(\"p\",{children:[a.jsx(\"code\",{className:\"text-orange-400\",children:\"bambuddy_printer_connected\"}),\" - \",i(\"settings.metricsConnectionStatus\")]}),a.jsxs(\"p\",{children:[a.jsx(\"code\",{className:\"text-orange-400\",children:\"bambuddy_printer_state\"}),\" - \",i(\"settings.metricsPrinterState\")]}),a.jsxs(\"p\",{children:[a.jsx(\"code\",{className:\"text-orange-400\",children:\"bambuddy_print_progress\"}),\" - \",i(\"settings.metricsPrintProgress\")]}),a.jsxs(\"p\",{children:[a.jsx(\"code\",{className:\"text-orange-400\",children:\"bambuddy_bed_temp_celsius\"}),\" - \",i(\"settings.metricsBedTemp\")]}),a.jsxs(\"p\",{children:[a.jsx(\"code\",{className:\"text-orange-400\",children:\"bambuddy_nozzle_temp_celsius\"}),\" - \",i(\"settings.metricsNozzleTemp\")]}),a.jsxs(\"p\",{children:[a.jsx(\"code\",{className:\"text-orange-400\",children:\"bambuddy_prints_total\"}),\" - \",i(\"settings.metricsPrintsTotal\")]}),a.jsx(\"p\",{className:\"text-bambu-gray/70 italic\",children:i(\"settings.metricsMore\")})]})]})]})]})]})})]}),Ie&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full mx-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-4\",children:[Ie.success?a.jsx(yr,{className:\"w-8 h-8 text-green-400\"}):a.jsx(Ma,{className:\"w-8 h-8 text-red-400\"}),a.jsx(\"h3\",{className:\"text-lg font-medium text-white\",children:Ie.success?i(\"settings.connectionSuccessful\"):i(\"settings.connectionFailed\")})]}),a.jsx(\"p\",{className:\"text-bambu-gray mb-6\",children:Ie.success?Ie.message||i(\"settings.haConnectionSuccess\"):Ie.error||i(\"settings.haConnectionFailed\")}),a.jsx(\"div\",{className:\"flex justify-end\",children:a.jsx(De,{variant:\"primary\",onClick:()=>mt(null),children:i(\"settings.ok\")})})]})}),U===\"plugs\"&&a.jsxs(\"div\",{id:\"card-plugs\",children:[a.jsxs(\"div\",{className:\"flex items-start justify-between mb-6\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(nc,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.smartPlugs\")]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:i(\"settings.smartPlugsDescription\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 pt-1 shrink-0\",children:[qt&&qt.filter(ke=>ke.enabled).length>1&&a.jsxs(a.Fragment,{children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",className:\"whitespace-nowrap\",onClick:()=>Oe(\"on\"),disabled:Xo.isPending,title:i(\"settings.turnAllPlugsOn\"),children:[Xo.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Yd,{className:\"w-4 h-4 text-bambu-green\"}),i(\"settings.allOn\")]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",className:\"whitespace-nowrap\",onClick:()=>Oe(\"off\"),disabled:Xo.isPending,title:i(\"settings.turnAllPlugsOff\"),children:[Xo.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(aS,{className:\"w-4 h-4 text-red-400\"}),i(\"settings.allOff\")]})]}),a.jsxs(De,{className:\"whitespace-nowrap\",onClick:()=>{L(null),z(!0)},children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.addSmartPlug\")]})]})]}),qt&&qt.length>0&&a.jsxs(Tt,{className:\"mb-6\",children:[a.jsx(gn,{children:a.jsxs(\"h3\",{className:\"text-base font-semibold text-white flex items-center gap-2\",children:[a.jsx(dc,{className:\"w-4 h-4 text-yellow-400\"}),i(\"settings.energySummary\"),Nn&&a.jsx(ft,{className:\"w-4 h-4 animate-spin text-bambu-gray ml-2\"})]})}),a.jsx(Mt,{children:dn?a.jsxs(\"div\",{className:\"grid grid-cols-2 md:grid-cols-4 gap-4\",children:[a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray text-xs mb-1\",children:[a.jsx(dc,{className:\"w-3 h-3\"}),i(\"settings.currentPower\")]}),a.jsxs(\"div\",{className:\"text-xl font-bold text-white\",children:[dn.totalPower.toFixed(1),a.jsx(\"span\",{className:\"text-sm font-normal text-bambu-gray ml-1\",children:\"W\"})]}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.plugsOnline\",{reachable:dn.reachableCount,total:dn.totalPlugs})})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray text-xs mb-1\",children:[a.jsx(oa,{className:\"w-3 h-3\"}),i(\"settings.today\")]}),a.jsxs(\"div\",{className:\"text-xl font-bold text-white\",children:[dn.totalToday.toFixed(3),a.jsx(\"span\",{className:\"text-sm font-normal text-bambu-gray ml-1\",children:\"kWh\"})]}),(k?.energy_cost_per_kwh??0)>0&&a.jsxs(\"div\",{className:\"text-xs text-bambu-gray mt-1\",children:[\"~\",(dn.totalToday*(k?.energy_cost_per_kwh??0)).toFixed(2),\" \",Eo(k?.currency||\"USD\")]})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray text-xs mb-1\",children:[a.jsx(dF,{className:\"w-3 h-3\"}),i(\"settings.yesterday\")]}),a.jsxs(\"div\",{className:\"text-xl font-bold text-white\",children:[dn.totalYesterday.toFixed(3),a.jsx(\"span\",{className:\"text-sm font-normal text-bambu-gray ml-1\",children:\"kWh\"})]}),(k?.energy_cost_per_kwh??0)>0&&a.jsxs(\"div\",{className:\"text-xs text-bambu-gray mt-1\",children:[\"~\",(dn.totalYesterday*(k?.energy_cost_per_kwh??0)).toFixed(2),\" \",Eo(k?.currency||\"USD\")]})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-bambu-gray text-xs mb-1\",children:[a.jsx(xN,{className:\"w-3 h-3\"}),i(\"settings.total\")]}),a.jsxs(\"div\",{className:\"text-xl font-bold text-white\",children:[dn.totalLifetime.toFixed(1),a.jsx(\"span\",{className:\"text-sm font-normal text-bambu-gray ml-1\",children:\"kWh\"})]}),(k?.energy_cost_per_kwh??0)>0&&a.jsxs(\"div\",{className:\"text-xs text-bambu-gray mt-1\",children:[\"~\",(dn.totalLifetime*(k?.energy_cost_per_kwh??0)).toFixed(2),\" \",Eo(k?.currency||\"USD\")]})]})]}):Nn?null:a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.enablePlugsForSummary\")})})]}),on?a.jsx(\"div\",{className:\"flex justify-center py-12\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):qt&&qt.length>0?a.jsx(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\",children:qt.map(ke=>a.jsx(Ytt,{plug:ke,onEdit:Rt=>{L(Rt),z(!0)}},ke.id))}):a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-12\",children:a.jsxs(\"div\",{className:\"text-center text-bambu-gray\",children:[a.jsx(nc,{className:\"w-16 h-16 mx-auto mb-4 opacity-30\"}),a.jsx(\"p\",{className:\"text-lg font-medium text-white mb-2\",children:i(\"settings.noSmartPlugsTitle\")}),a.jsx(\"p\",{className:\"text-sm mb-4\",children:i(\"settings.noSmartPlugsDescription\")}),a.jsxs(De,{onClick:()=>{L(null),z(!0)},children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.addFirstSmartPlug\")]})]})})})]}),U===\"notifications\"&&a.jsxs(\"div\",{className:\"grid grid-cols-1 lg:grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-4\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",id:\"card-providers\",children:[a.jsx(Zh,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.providers\")]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(De,{size:\"sm\",variant:\"secondary\",onClick:()=>q(!0),children:[a.jsx(iw,{className:\"w-4 h-4\"}),i(\"settings.log\")]}),bn&&bn.length>0&&a.jsxs(De,{size:\"sm\",variant:\"secondary\",onClick:()=>{iu(null),So.mutate()},disabled:So.isPending,children:[So.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(iS,{className:\"w-4 h-4\"}),i(\"settings.testAll\")]}),a.jsxs(De,{size:\"sm\",onClick:()=>{oe(null),ie(!0)},children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.addNotificationProvider\")]})]})]}),a.jsx(Tt,{className:\"mb-4\",children:a.jsx(Mt,{className:\"py-3\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white text-sm font-medium\",children:i(\"settings.notificationLanguage\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.notificationLanguageDescription\")})]}),a.jsx(\"select\",{value:k.notification_language||\"en\",onChange:ke=>xn(\"notification_language\",ke.target.value),className:\"px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green\",children:iH.map(ke=>a.jsx(\"option\",{value:ke.code,children:ke.nativeName},ke.code))})]})})}),a.jsx(Tt,{className:\"mb-4\",children:a.jsx(Mt,{className:\"py-3\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white text-sm font-medium\",children:i(\"settings.bedCooledThreshold\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.bedCooledThresholdDescription\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"input\",{type:\"number\",min:20,max:80,step:1,value:k.bed_cooled_threshold??35,onChange:ke=>xn(\"bed_cooled_threshold\",Number(ke.target.value)),className:\"w-16 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-bambu-green\"}),a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:\"°C\"})]})]})})}),a.jsx(Tt,{className:\"mb-4\",children:a.jsx(Mt,{className:\"py-3\",children:a.jsxs(\"div\",{className:`flex items-center justify-between ${Ha?.advanced_auth_enabled?\"\":\"opacity-50\"}`,children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white text-sm font-medium\",children:i(\"settings.userNotificationsEnabled\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:Ha?.advanced_auth_enabled?i(\"settings.userNotificationsEnabledDescription\"):i(\"settings.userNotificationsDisabledHint\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",className:\"sr-only peer\",checked:k.user_notifications_enabled??!0,disabled:!Ha?.advanced_auth_enabled,onChange:ke=>xn(\"user_notifications_enabled\",ke.target.checked)}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green peer-disabled:cursor-not-allowed\"})]})]})})}),Ko&&a.jsx(Tt,{className:\"mb-4\",children:a.jsxs(Mt,{className:\"py-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"span\",{className:\"text-sm font-medium text-white\",children:i(\"settings.testResults\")}),a.jsx(\"button\",{onClick:()=>iu(null),className:\"text-bambu-gray hover:text-white text-xs\",children:i(\"common.dismiss\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-4 text-sm mb-2\",children:[a.jsxs(\"span\",{className:\"flex items-center gap-1 text-bambu-green\",children:[a.jsx(yr,{className:\"w-4 h-4\"}),i(\"settings.testPassedCount\",{count:Ko.success})]}),Ko.failed>0&&a.jsxs(\"span\",{className:\"flex items-center gap-1 text-red-400\",children:[a.jsx(Ma,{className:\"w-4 h-4\"}),i(\"settings.testFailedCount\",{count:Ko.failed})]})]}),Ko.results.filter(ke=>!ke.success).length>0&&a.jsx(\"div\",{className:\"space-y-1 mt-2 pt-2 border-t border-bambu-dark-tertiary\",children:Ko.results.filter(ke=>!ke.success).map(ke=>a.jsxs(\"div\",{className:\"text-xs text-red-400\",children:[a.jsxs(\"span\",{className:\"font-medium\",children:[ke.provider_name,\":\"]}),\" \",ke.message]},ke.provider_id))})]})}),un?a.jsx(\"div\",{className:\"flex justify-center py-12\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})}):bn&&bn.length>0?a.jsx(\"div\",{className:\"space-y-3\",children:bn.map(ke=>a.jsx(Ztt,{provider:ke,onEdit:Rt=>{oe(Rt),ie(!0)}},ke.id))}):a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-8\",children:a.jsxs(\"div\",{className:\"text-center text-bambu-gray\",children:[a.jsx(Zh,{className:\"w-12 h-12 mx-auto mb-3 opacity-30\"}),a.jsx(\"p\",{className:\"text-sm font-medium text-white mb-2\",children:i(\"settings.noProvidersTitle\")}),a.jsx(\"p\",{className:\"text-xs mb-3\",children:i(\"settings.noProvidersDescription\")}),a.jsxs(De,{size:\"sm\",onClick:()=>{oe(null),ie(!0)},children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.addProvider\")]})]})})})]}),a.jsxs(\"div\",{children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2 mb-2\",id:\"card-templates\",children:[a.jsx(Us,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.messageTemplates\")]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-3\",children:i(\"settings.messageTemplatesDescription\")}),a.jsxs(\"div\",{className:\"relative mb-3\",children:[a.jsx(Pr,{className:\"w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none\"}),a.jsx(\"input\",{type:\"text\",value:W,onChange:ke=>ne(ke.target.value),placeholder:i(\"settings.filterTemplates\",\"Filter templates…\"),className:\"w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"}),W&&a.jsx(\"button\",{onClick:()=>ne(\"\"),className:\"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-bambu-gray hover:text-white\",\"aria-label\":\"Clear filter\",children:a.jsx(Ht,{className:\"w-3.5 h-3.5\"})})]}),In?a.jsx(\"div\",{className:\"flex justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})}):Ba&&Ba.length>0?(()=>{const ke=W.trim().toLowerCase(),Rt=[...Ba].sort((Sn,_n)=>Sn.name.localeCompare(_n.name)).filter(Sn=>!ke||Sn.name.toLowerCase().includes(ke)||(Sn.title_template||\"\").toLowerCase().includes(ke));return Rt.length===0?a.jsx(\"p\",{className:\"text-sm text-bambu-gray italic text-center py-6\",children:i(\"settings.noTemplatesMatch\",\"No templates match your filter.\")}):a.jsx(\"div\",{className:\"space-y-2\",children:Rt.map(Sn=>a.jsx(Tt,{className:\"cursor-pointer hover:border-bambu-green/50 transition-colors\",onClick:()=>re(Sn),children:a.jsx(Mt,{className:\"py-2.5 px-3\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"text-white font-medium text-sm truncate\",children:Sn.name}),a.jsx(\"p\",{className:\"text-bambu-gray text-xs truncate mt-0.5\",children:Sn.title_template})]}),a.jsx(\"button\",{className:\"p-1.5 hover:bg-bambu-dark-tertiary rounded transition-colors shrink-0 ml-2\",onClick:_n=>{_n.stopPropagation(),re(Sn)},children:a.jsx(Qu,{className:\"w-4 h-4 text-bambu-gray\"})})]})})},Sn.id))})})():a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-8\",children:a.jsxs(\"div\",{className:\"text-center text-bambu-gray\",children:[a.jsx(Us,{className:\"w-12 h-12 mx-auto mb-3 opacity-30\"}),a.jsx(\"p\",{className:\"text-sm\",children:i(\"settings.noTemplatesAvailable\")})]})})})]})]}),U===\"apikeys\"&&a.jsxs(\"div\",{className:\"grid grid-cols-1 xl:grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-start justify-between gap-4 mb-6\",children:[a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",id:\"card-createapi\",children:[a.jsx(no,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.apiKeys\")]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:i(\"settings.apiKeysDescription\")})]}),a.jsxs(De,{size:\"sm\",onClick:()=>ee(!0),className:\"flex-shrink-0\",children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.createKey\")]})]}),B&&a.jsx(Tt,{className:\"mb-6 border-bambu-green\",children:a.jsx(Mt,{className:\"py-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(yr,{className:\"w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5\"}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"p\",{className:\"text-white font-medium mb-1\",children:i(\"settings.apiKeyCreated\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-2\",children:i(\"settings.apiKeyCopyWarning\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2 bg-bambu-dark rounded-lg p-2\",children:[a.jsx(\"code\",{className:\"flex-1 text-sm text-bambu-green font-mono break-all\",children:B}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:async()=>{try{if(navigator.clipboard&&navigator.clipboard.writeText)await navigator.clipboard.writeText(B);else{const ke=document.createElement(\"textarea\");ke.value=B,ke.style.position=\"fixed\",ke.style.left=\"-999999px\",document.body.appendChild(ke),ke.select(),document.execCommand(\"copy\"),document.body.removeChild(ke)}o(i(\"settings.toast.keyCopied\"))}catch{o(i(\"settings.toast.copyFailed\"),\"error\")}},children:a.jsx(qs,{className:\"w-4 h-4\"})})]}),a.jsxs(\"div\",{className:\"flex gap-2 mt-3\",children:[a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>{ve(B),o(i(\"settings.toast.keyAddedToBrowser\"))},children:i(\"settings.useInApiBrowser\")}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>R(null),children:i(\"common.dismiss\")})]})]})]})})}),V&&a.jsxs(Tt,{className:\"mb-6\",children:[a.jsx(gn,{children:a.jsx(\"h3\",{className:\"text-base font-semibold text-white\",children:i(\"settings.createNewApiKey\")})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.keyName\")}),a.jsx(\"input\",{type:\"text\",value:se,onChange:ke=>ge(ke.target.value),placeholder:i(\"settings.keyNamePlaceholder\"),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\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:i(\"common.permissions\")}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"label\",{className:\"flex items-center gap-3 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:he.can_read_status,onChange:ke=>le(Rt=>({...Rt,can_read_status:ke.target.checked})),className:\"w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-white\",children:i(\"settings.readStatus\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.readStatusDescription\")})]})]}),a.jsxs(\"label\",{className:\"flex items-center gap-3 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:he.can_queue,onChange:ke=>le(Rt=>({...Rt,can_queue:ke.target.checked})),className:\"w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-white\",children:i(\"settings.manageQueue\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.manageQueueDescription\")})]})]}),a.jsxs(\"label\",{className:\"flex items-center gap-3 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:he.can_control_printer,onChange:ke=>le(Rt=>({...Rt,can_control_printer:ke.target.checked})),className:\"w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green\"}),a.jsxs(\"div\",{children:[a.jsx(\"span\",{className:\"text-white\",children:i(\"settings.controlPrinter\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.controlPrinterDescription\")})]})]})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 pt-2\",children:[a.jsxs(De,{onClick:()=>gr.mutate({name:se||i(\"settings.unnamedKey\"),...he}),disabled:gr.isPending,children:[gr.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.createKey\")]}),a.jsx(De,{variant:\"secondary\",onClick:()=>ee(!1),children:i(\"common.cancel\")})]})]})]}),pn?a.jsx(\"div\",{className:\"flex justify-center py-12\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):wn&&wn.length>0?a.jsx(\"div\",{className:\"space-y-3\",children:wn.map(ke=>a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-3\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(no,{className:`w-5 h-5 ${ke.enabled?\"text-bambu-green\":\"text-bambu-gray\"}`}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:ke.name}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[ke.key_prefix,\"••••••••\",ke.last_used&&` · ${i(\"settings.lastUsed\")}: ${hg(ke.last_used)}`]})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"flex gap-1 text-xs\",children:[ke.can_read_status&&a.jsx(\"span\",{className:\"px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded\",children:i(\"settings.read\")}),ke.can_queue&&a.jsx(\"span\",{className:\"px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded\",children:i(\"queue.title\")}),ke.can_control_printer&&a.jsx(\"span\",{className:\"px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded\",children:i(\"settings.control\")})]}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>_e(ke.id),children:a.jsx(en,{className:\"w-4 h-4 text-red-400\"})})]})]})})},ke.id))}):a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-12\",children:a.jsxs(\"div\",{className:\"text-center text-bambu-gray\",children:[a.jsx(no,{className:\"w-16 h-16 mx-auto mb-4 opacity-30\"}),a.jsx(\"p\",{className:\"text-lg font-medium text-white mb-2\",children:i(\"settings.apiKeysEmptyTitle\")}),a.jsx(\"p\",{className:\"text-sm mb-4\",children:i(\"settings.apiKeysEmptyDescription\")}),a.jsxs(De,{onClick:()=>ee(!0),children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.createFirstKey\")]})]})})}),a.jsxs(Tt,{className:\"mt-6\",children:[a.jsx(gn,{children:a.jsx(\"h3\",{className:\"text-base font-semibold text-white\",id:\"card-webhooks\",children:i(\"settings.webhookEndpoints\")})}),a.jsxs(Mt,{className:\"space-y-3 text-sm\",children:[a.jsx(\"p\",{className:\"text-bambu-gray\",children:i(\"settings.webhookApiKeyHint\")}),a.jsxs(\"div\",{className:\"space-y-2 font-mono text-xs\",children:[a.jsxs(\"div\",{className:\"p-2 bg-bambu-dark rounded\",children:[a.jsx(\"span\",{className:\"text-blue-400\",children:\"GET\"}),\" \",a.jsx(\"span\",{className:\"text-white\",children:\"/api/v1/webhook/status\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\" - \",i(\"settings.webhook.getAllStatus\")]})]}),a.jsxs(\"div\",{className:\"p-2 bg-bambu-dark rounded\",children:[a.jsx(\"span\",{className:\"text-blue-400\",children:\"GET\"}),\" \",a.jsx(\"span\",{className:\"text-white\",children:\"/api/v1/webhook/status/:id\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\" - \",i(\"settings.webhook.getSpecificStatus\")]})]}),a.jsxs(\"div\",{className:\"p-2 bg-bambu-dark rounded\",children:[a.jsx(\"span\",{className:\"text-green-400\",children:\"POST\"}),\" \",a.jsx(\"span\",{className:\"text-white\",children:\"/api/v1/webhook/queue\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\" - \",i(\"settings.webhook.addToQueue\")]})]}),a.jsxs(\"div\",{className:\"p-2 bg-bambu-dark rounded\",children:[a.jsx(\"span\",{className:\"text-orange-400\",children:\"POST\"}),\" \",a.jsx(\"span\",{className:\"text-white\",children:\"/api/v1/webhook/printer/:id/pause\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\" - \",i(\"settings.webhook.pausePrint\")]})]}),a.jsxs(\"div\",{className:\"p-2 bg-bambu-dark rounded\",children:[a.jsx(\"span\",{className:\"text-orange-400\",children:\"POST\"}),\" \",a.jsx(\"span\",{className:\"text-white\",children:\"/api/v1/webhook/printer/:id/resume\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\" - \",i(\"settings.webhook.resumePrint\")]})]}),a.jsxs(\"div\",{className:\"p-2 bg-bambu-dark rounded\",children:[a.jsx(\"span\",{className:\"text-red-400\",children:\"POST\"}),\" \",a.jsx(\"span\",{className:\"text-white\",children:\"/api/v1/webhook/printer/:id/stop\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\" - \",i(\"settings.webhook.stopPrint\")]})]})]})]})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"mb-6\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",id:\"card-apibrowser\",children:[a.jsx(ul,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.apiBrowser\")]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:i(\"settings.apiBrowserDescription\")})]}),a.jsx(Tt,{className:\"mb-4\",children:a.jsxs(Mt,{className:\"py-3\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-2\",children:i(\"settings.apiKeyForTesting\")}),a.jsx(\"input\",{type:\"text\",value:Se,onChange:ke=>ve(ke.target.value),placeholder:i(\"settings.apiKeyPlaceholder\"),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm focus:border-bambu-green focus:outline-none\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-2\",children:i(\"settings.apiKeyHint\")})]})}),a.jsx(jnt,{apiKey:Se})]})]}),U===\"virtual-printer\"&&a.jsx(\"div\",{id:\"card-vp\",children:a.jsx(hnt,{})}),U===\"spoolbuddy\"&&a.jsx(\"div\",{id:\"card-spoolbuddy\",children:a.jsx(fnt,{})}),U===\"queue\"&&k&&a.jsxs(\"div\",{className:\"flex flex-col lg:flex-row gap-4 lg:gap-6\",children:[a.jsxs(\"div\",{className:\"lg:w-1/2 space-y-3\",children:[a.jsxs(Tt,{id:\"card-print-options\",children:[a.jsx(gn,{children:a.jsxs(\"h3\",{className:\"text-base font-semibold text-white flex items-center gap-2\",children:[a.jsx(SN,{className:\"w-4 h-4 text-bambu-green\"}),i(\"settings.defaultPrintOptions\",\"Default Print Options\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.defaultPrintOptionsDescription\",\"Set default values for print options when starting new prints. These can be overridden per print in the print dialog.\")}),[{key:\"default_bed_levelling\",label:i(\"settings.defaultBedLevelling\",\"Bed Levelling\"),desc:i(\"settings.defaultBedLevellingDesc\",\"Auto-level bed before print\"),fallback:!0},{key:\"default_flow_cali\",label:i(\"settings.defaultFlowCali\",\"Flow Calibration\"),desc:i(\"settings.defaultFlowCaliDesc\",\"Calibrate extrusion flow\"),fallback:!1},{key:\"default_vibration_cali\",label:i(\"settings.defaultVibrationCali\",\"Vibration Calibration\"),desc:i(\"settings.defaultVibrationCaliDesc\",\"Reduce ringing artifacts\"),fallback:!0},{key:\"default_layer_inspect\",label:i(\"settings.defaultLayerInspect\",\"First Layer Inspection\"),desc:i(\"settings.defaultLayerInspectDesc\",\"AI inspection of first layer\"),fallback:!1},{key:\"default_timelapse\",label:i(\"settings.defaultTimelapse\",\"Timelapse\"),desc:i(\"settings.defaultTimelapseDesc\",\"Record timelapse video\"),fallback:!1}].map(({key:ke,label:Rt,desc:Sn,fallback:_n})=>a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex-1 mr-4\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:Rt}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-0.5\",children:Sn})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k[ke]??_n,onChange:Bn=>xn(ke,Bn.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]},ke))]})]}),a.jsxs(Tt,{id:\"card-staggered\",children:[a.jsx(gn,{children:a.jsxs(\"h3\",{className:\"text-base font-semibold text-white flex items-center gap-2\",children:[a.jsx(da,{className:\"w-4 h-4 text-bambu-green\"}),i(\"settings.staggeredStart\",\"Staggered Start\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.staggeredStartDescription\",\"Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.\")}),a.jsxs(\"div\",{className:\"flex gap-4\",children:[a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.staggerGroupSize\",\"Group size\")}),a.jsx(\"input\",{type:\"number\",min:1,max:50,value:k.stagger_group_size??2,onChange:ke=>xn(\"stagger_group_size\",Math.max(1,Math.min(50,parseInt(ke.target.value)||1))),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.staggerGroupSizeHelp\",\"Printers to start simultaneously per group\")})]}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.staggerInterval\",\"Interval (minutes)\")}),a.jsx(\"input\",{type:\"number\",min:1,max:60,value:k.stagger_interval_minutes??5,onChange:ke=>xn(\"stagger_interval_minutes\",Math.max(1,Math.min(60,parseInt(ke.target.value)||1))),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.staggerIntervalHelp\",\"Delay between each group starting\")})]})]})]})]}),a.jsxs(Tt,{id:\"card-plate\",children:[a.jsx(gn,{children:a.jsxs(\"h3\",{className:\"text-base font-semibold text-white flex items-center gap-2\",children:[a.jsx(Gu,{className:\"w-4 h-4 text-bambu-green\"}),i(\"settings.plateClear\",\"Plate-Clear Confirmation\")]})}),a.jsx(Mt,{className:\"space-y-3\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex-1 mr-4\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:i(\"settings.requirePlateClear\",\"Require plate-clear confirmation\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:i(\"settings.requirePlateClearDescription\",'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the \"Mark plate as cleared\" button on printer cards.')})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.require_plate_clear??!1,onChange:ke=>xn(\"require_plate_clear\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]})})]}),a.jsxs(Tt,{id:\"card-gcode\",children:[a.jsx(gn,{children:a.jsxs(\"h3\",{className:\"text-base font-semibold text-white flex items-center gap-2\",children:[a.jsx(wy,{className:\"w-4 h-4 text-bambu-green\"}),i(\"settings.gcodeInjection\",\"G-code Injection\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.gcodeInjectionDescription\",'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when \"Inject G-code\" is enabled on a queue item.')}),(()=>{const ke=(()=>{try{return k.gcode_snippets?JSON.parse(k.gcode_snippets):{}}catch{return{}}})(),Rt=[...new Set((Fn||[]).filter(Bn=>Bn.model).map(Bn=>Bn.model))].sort(),Sn=(Bn,xa,ui)=>{const Cr={...ke};Cr[Bn]||(Cr[Bn]={start_gcode:\"\",end_gcode:\"\"}),Cr[Bn][xa]=ui,!Cr[Bn].start_gcode&&!Cr[Bn].end_gcode&&delete Cr[Bn];const zm=Object.keys(Cr).length>0?JSON.stringify(Cr):\"\";D(qp=>qp?{...qp,gcode_snippets:zm}:null),Yo.current=zm},_n=()=>{Yo.current!==null&&(as.mutate({gcode_snippets:Yo.current}),Yo.current=null)};return Rt.length===0?a.jsx(\"p\",{className:\"text-sm text-bambu-gray italic\",children:i(\"settings.gcodeInjectionNoPrinters\",\"No printers found. Add printers to configure G-code snippets.\")}):Rt.map(Bn=>{const xa=ke[Bn]||{start_gcode:\"\",end_gcode:\"\"},ui=!!(xa.start_gcode||xa.end_gcode);return a.jsx(Sz,{defaultOpen:ui,className:\"border border-bambu-dark-tertiary rounded-lg px-3 py-2\",summary:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-white\",children:Bn}),ui&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green\",children:i(\"settings.gcodeConfigured\",\"Configured\")})]}),children:a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.gcodeStartLabel\",\"Start G-code\")}),a.jsx(\"textarea\",{value:xa.start_gcode,onChange:Cr=>Sn(Bn,\"start_gcode\",Cr.target.value),onBlur:_n,placeholder:i(\"settings.gcodeStartPlaceholder\",\"G-code prepended before the print starts...\"),rows:3,className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:outline-none focus:border-bambu-green resize-y\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1\",children:i(\"settings.gcodeEndLabel\",\"End G-code\")}),a.jsx(\"textarea\",{value:xa.end_gcode,onChange:Cr=>Sn(Bn,\"end_gcode\",Cr.target.value),onBlur:_n,placeholder:i(\"settings.gcodeEndPlaceholder\",\"G-code appended after the print ends...\"),rows:3,className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:outline-none focus:border-bambu-green resize-y\"})]})]})},Bn)})})()]})]})]}),a.jsx(\"div\",{className:\"lg:w-1/2 space-y-3\",children:a.jsxs(Tt,{children:[a.jsx(gn,{children:a.jsxs(\"h3\",{className:\"text-base font-semibold text-white flex items-center gap-2\",id:\"card-drying\",children:[a.jsx(Hu,{className:\"w-4 h-4 text-amber-400\"}),i(\"settings.queueDrying\")]})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.queueDryingDescription\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-white\",children:i(\"settings.queueDryingEnabled\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-0.5\",children:i(\"settings.queueDryingEnabledDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.queue_drying_enabled??!1,onChange:ke=>xn(\"queue_drying_enabled\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),k.queue_drying_enabled&&a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-white\",children:i(\"settings.queueDryingBlock\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-0.5\",children:i(\"settings.queueDryingBlockDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.queue_drying_block??!1,onChange:ke=>xn(\"queue_drying_block\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500\"})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-white\",children:i(\"settings.ambientDryingEnabled\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-0.5\",children:i(\"settings.ambientDryingEnabledDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.ambient_drying_enabled??!1,onChange:ke=>xn(\"ambient_drying_enabled\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-sm text-white font-medium\",children:i(\"settings.dryingPresets\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.dryingPresetsDescription\")}),a.jsx(\"div\",{className:\"overflow-x-auto\",children:a.jsxs(\"table\",{className:\"w-full text-xs\",children:[a.jsx(\"thead\",{children:a.jsxs(\"tr\",{className:\"text-bambu-gray border-b border-bambu-dark-tertiary\",children:[a.jsx(\"th\",{className:\"text-left py-1.5\",children:i(\"settings.dryingFilament\")}),a.jsx(\"th\",{className:\"text-center py-1.5\",colSpan:2,children:\"AMS 2 Pro\"}),a.jsx(\"th\",{className:\"text-center py-1.5\",colSpan:2,children:\"AMS-HT\"})]})}),a.jsx(\"tbody\",{children:(()=>{const ke={PLA:{n3f:45,n3s:45,n3f_hours:12,n3s_hours:12},PETG:{n3f:65,n3s:65,n3f_hours:12,n3s_hours:12},TPU:{n3f:65,n3s:75,n3f_hours:12,n3s_hours:18},ABS:{n3f:65,n3s:80,n3f_hours:12,n3s_hours:8},ASA:{n3f:65,n3s:80,n3f_hours:12,n3s_hours:8},PA:{n3f:65,n3s:85,n3f_hours:12,n3s_hours:12},PC:{n3f:65,n3s:80,n3f_hours:12,n3s_hours:8},PVA:{n3f:65,n3s:85,n3f_hours:12,n3s_hours:18}};let Rt={...ke};try{if(k.drying_presets){const _n=JSON.parse(k.drying_presets);typeof _n==\"object\"&&_n!==null&&(Rt={...ke,..._n})}}catch{}const Sn=(_n,Bn,xa)=>{const ui={...Rt,[_n]:{...Rt[_n],[Bn]:xa}};xn(\"drying_presets\",JSON.stringify(ui))};return Object.entries(Rt).map(([_n,Bn])=>a.jsxs(\"tr\",{className:\"border-b border-bambu-dark-tertiary/50\",children:[a.jsx(\"td\",{className:\"py-1.5 pr-2 text-white font-medium\",children:_n}),a.jsx(\"td\",{className:\"py-1 px-1\",children:a.jsxs(\"div\",{className:\"flex items-center justify-end gap-1\",children:[a.jsx(\"input\",{type:\"number\",min:30,max:65,value:Bn.n3f,onChange:xa=>Sn(_n,\"n3f\",Math.max(1,parseInt(xa.target.value)||0)),className:\"w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"°C\"})]})}),a.jsx(\"td\",{className:\"py-1 px-1\",children:a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"input\",{type:\"number\",min:1,max:24,value:Bn.n3f_hours,onChange:xa=>Sn(_n,\"n3f_hours\",Math.max(1,parseInt(xa.target.value)||0)),className:\"w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"h\"})]})}),a.jsx(\"td\",{className:\"py-1 px-1\",children:a.jsxs(\"div\",{className:\"flex items-center justify-end gap-1\",children:[a.jsx(\"input\",{type:\"number\",min:30,max:85,value:Bn.n3s,onChange:xa=>Sn(_n,\"n3s\",Math.max(1,parseInt(xa.target.value)||0)),className:\"w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"°C\"})]})}),a.jsx(\"td\",{className:\"py-1 px-1\",children:a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"input\",{type:\"number\",min:1,max:24,value:Bn.n3s_hours,onChange:xa=>Sn(_n,\"n3s_hours\",Math.max(1,parseInt(xa.target.value)||0)),className:\"w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"h\"})]})})]},_n))})()})]})})]})]})]})})]}),U===\"filament\"&&k&&a.jsx(a.Fragment,{children:a.jsxs(\"div\",{className:\"flex flex-col lg:flex-row gap-4 lg:gap-6\",children:[a.jsxs(\"div\",{className:\"lg:w-1/3 space-y-3\",children:[a.jsx(snt,{}),a.jsxs(Tt,{id:\"card-filamentchecks\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.filamentChecks\")})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.disableFilamentWarnings\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.disableFilamentWarningsDesc\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.disable_filament_warnings,onChange:ke=>xn(\"disable_filament_warnings\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.preferLowestFilament\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.preferLowestFilamentDesc\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.prefer_lowest_filament,onChange:ke=>xn(\"prefer_lowest_filament\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]})]})]}),a.jsxs(Tt,{id:\"card-printmodal\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.printModal\")})}),a.jsx(Mt,{className:\"space-y-3\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.expandCustomMapping\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.expandCustomMappingDescription\")})]}),a.jsxs(\"label\",{className:\"relative inline-flex items-center cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:k.per_printer_mapping_expanded??!1,onChange:ke=>xn(\"per_printer_mapping_expanded\",ke.target.checked),className:\"sr-only peer\"}),a.jsx(\"div\",{className:\"w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green\"})]})]})})]}),a.jsxs(Tt,{id:\"card-amsthresholds\",children:[a.jsx(gn,{children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.amsDisplayThresholds\")})}),a.jsxs(Mt,{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.amsThresholdsDescription\")}),a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-white\",children:[a.jsx(EO,{className:\"w-4 h-4 text-blue-400\"}),a.jsx(\"span\",{className:\"font-medium\",children:i(\"settings.humidity\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[i(\"settings.goodGreen\"),\" ≤\"]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"number\",min:\"0\",max:\"100\",value:k.ams_humidity_good??40,onChange:ke=>xn(\"ams_humidity_good\",parseInt(ke.target.value)||40),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\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"%\"})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[i(\"settings.fairOrange\"),\" ≤\"]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"number\",min:\"0\",max:\"100\",value:k.ams_humidity_fair??60,onChange:ke=>xn(\"ams_humidity_fair\",parseInt(ke.target.value)||60),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\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"%\"})]})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.aboveFairBad\")}),a.jsx(\"p\",{className:\"text-xs text-amber-400/70\",children:i(\"settings.fairAlsoDryingThreshold\")})]}),a.jsxs(\"div\",{className:\"space-y-3 pt-2 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-white\",children:[a.jsx(oS,{className:\"w-4 h-4 text-orange-400\"}),a.jsx(\"span\",{className:\"font-medium\",children:i(\"settings.temperature\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[i(\"settings.goodBlue\"),\" ≤\"]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"number\",step:\"0.5\",min:\"0\",max:\"60\",value:k.ams_temp_good??28,onChange:ke=>xn(\"ams_temp_good\",parseFloat(ke.target.value)||28),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\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"°C\"})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:[i(\"settings.fairOrange\"),\" ≤\"]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"number\",step:\"0.5\",min:\"0\",max:\"60\",value:k.ams_temp_fair??35,onChange:ke=>xn(\"ams_temp_fair\",parseFloat(ke.target.value)||35),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\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:\"°C\"})]})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.aboveFairHot\")})]}),a.jsxs(\"div\",{className:\"space-y-3 pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-white\",children:[a.jsx(Jl,{className:\"w-4 h-4 text-purple-400\"}),a.jsx(\"span\",{className:\"font-medium\",children:i(\"settings.historyRetention\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:i(\"settings.keepSensorHistory\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"number\",min:\"1\",max:\"365\",value:k.ams_history_retention_days??30,onChange:ke=>xn(\"ams_history_retention_days\",parseInt(ke.target.value)||30),className:\"w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"common.days\")})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:i(\"settings.historyRetentionDescription\")})]})]})]})]}),a.jsxs(\"div\",{className:\"lg:w-2/3 space-y-3\",children:[a.jsx(ont,{}),a.jsx(lnt,{})]})]})}),ae!==null&&a.jsx(yn,{title:i(\"settings.deleteApiKeyTitle\"),message:i(\"settings.deleteApiKeyMessage\"),confirmText:i(\"settings.deleteKey\"),variant:\"danger\",onConfirm:()=>{Nr.mutate(ae),_e(null)},onCancel:()=>_e(null)}),H&&a.jsx(Qtt,{plug:Q,onClose:()=>{z(!1),L(null)}}),te&&a.jsx(ent,{provider:J,onClose:()=>{ie(!1),oe(null)}}),fe&&a.jsx(tnt,{template:fe,onClose:()=>re(null)}),Ce&&a.jsx(rnt,{onClose:()=>q(!1)}),Te&&a.jsx(yn,{title:i(\"settings.clearNotificationLogs\"),message:i(\"settings.clearLogsMessage\"),confirmText:i(\"settings.clearLogs\"),variant:\"warning\",onConfirm:async()=>{ye(!1);try{const ke=await ue.clearNotificationLogs(30);o(ke.message,\"success\")}catch{o(i(\"settings.toast.clearLogsFailed\"),\"error\")}},onCancel:()=>ye(!1)}),je&&a.jsx(yn,{title:i(\"settings.resetUiPreferences\"),message:i(\"settings.resetUiPreferencesMessage\"),confirmText:i(\"settings.resetPreferences\"),variant:\"default\",onConfirm:()=>{Le(!1),localStorage.clear(),o(i(\"settings.toast.uiPreferencesReset\"),\"success\"),setTimeout(()=>window.location.reload(),1e3)},onCancel:()=>Le(!1)}),Me&&a.jsx(yn,{title:`Turn All Plugs ${Me===\"on\"?\"On\":\"Off\"}`,message:`This will turn ${Me===\"on\"?\"ON\":\"OFF\"} all ${qt?.filter(ke=>ke.enabled).length||0} enabled smart plugs. ${Me===\"off\"?\"Any running printers may be affected!\":\"\"}`,confirmText:`Turn All ${Me===\"on\"?\"On\":\"Off\"}`,variant:Me===\"off\"?\"danger\":\"warning\",onConfirm:()=>{const ke=Me;Oe(null),Xo.mutate(ke)},onCancel:()=>Oe(null)}),Re&&vt?.release_notes&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",onClick:()=>$e(!1),children:a.jsxs(Tt,{className:\"w-full max-w-2xl max-h-[80vh] flex flex-col\",onClick:ke=>ke.stopPropagation(),children:[a.jsxs(gn,{className:\"flex flex-row items-center justify-between shrink-0\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white\",children:[\"Release Notes - v\",vt.latest_version]}),vt.release_name&&vt.release_name!==vt.latest_version&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:vt.release_name})]}),a.jsx(\"button\",{onClick:()=>$e(!1),className:\"p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsx(Mt,{className:\"overflow-y-auto flex-1\",children:a.jsx(\"pre\",{className:\"text-sm text-bambu-gray whitespace-pre-wrap font-sans\",children:vt.release_notes})}),a.jsxs(\"div\",{className:\"p-4 border-t border-bambu-dark-tertiary shrink-0 flex gap-2\",children:[vt.release_url&&a.jsx(\"a\",{href:vt.release_url,target:\"_blank\",rel:\"noopener noreferrer\",className:\"flex-1\",children:a.jsxs(De,{variant:\"secondary\",className:\"w-full\",children:[a.jsx(la,{className:\"w-4 h-4\"}),\"View on GitHub\"]})}),a.jsx(De,{onClick:()=>$e(!1),className:\"flex-1\",children:\"Close\"})]})]})}),U===\"users\"&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"flex gap-1 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"button\",{onClick:()=>G(\"users\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${I===\"users\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(Wu,{className:\"w-4 h-4\"}),i(\"settings.tabs.users\")]}),a.jsxs(\"button\",{onClick:()=>G(\"email\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${I===\"email\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(bm,{className:\"w-4 h-4\"}),i(\"settings.tabs.emailAuth\")||\"Email Authentication\",Ha?.advanced_auth_enabled&&a.jsx(\"span\",{className:\"w-2 h-2 rounded-full bg-green-400\"})]}),a.jsxs(\"button\",{onClick:()=>G(\"ldap\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${I===\"ldap\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(Gu,{className:\"w-4 h-4\"}),i(\"settings.tabs.ldap\")||\"LDAP\",id?.ldap_enabled&&a.jsx(\"span\",{className:\"w-2 h-2 rounded-full bg-green-400\"})]}),a.jsxs(\"button\",{onClick:()=>G(\"twofa\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${I===\"twofa\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(Gu,{className:\"w-4 h-4\"}),i(\"settings.tabs.twoFa\"),a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${Ip?.totp_enabled||Ip?.email_otp_enabled?\"bg-green-400\":\"bg-bambu-gray/40\"}`})]}),d&&a.jsxs(\"button\",{onClick:()=>G(\"oidc\"),className:`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${I===\"oidc\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent\"}`,children:[a.jsx(ul,{className:\"w-4 h-4\"}),i(\"settings.tabs.oidc\"),a.jsx(\"span\",{className:`w-2 h-2 rounded-full ${tu.some(ke=>ke.is_enabled)?\"bg-green-400\":\"bg-bambu-gray/40\"}`})]})]}),I===\"users\"&&a.jsxs(a.Fragment,{children:[a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-4\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`w-10 h-10 rounded-full flex items-center justify-center ${l?\"bg-green-500/20\":\"bg-gray-500/20\"}`,children:l?a.jsx(kd,{className:\"w-5 h-5 text-green-400\"}):a.jsx(A0,{className:\"w-5 h-5 text-gray-400\"})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-white font-medium\",children:i(\"settings.authentication\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(l?\"settings.authEnabledDescription\":\"settings.authDisabledDescription\")})]})]}),l?c?.is_admin&&a.jsxs(De,{variant:\"secondary\",onClick:()=>tt(!0),children:[a.jsx(A0,{className:\"w-4 h-4\"}),i(\"common.disable\")]}):a.jsxs(De,{onClick:()=>e(\"/setup\"),children:[a.jsx(kd,{className:\"w-4 h-4\"}),i(\"common.enable\")]})]})})}),Ha?.advanced_auth_enabled&&a.jsx(Tt,{className:\"border-blue-500/30 bg-blue-500/5\",children:a.jsx(Mt,{className:\"py-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(\"div\",{className:\"w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0\",children:a.jsx(bm,{className:\"w-5 h-5 text-blue-400\"})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-white font-medium\",children:i(\"settings.email.advancedAuthEnabled\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:i(\"settings.email.advancedAuthEnabledDesc\")})]})]})})}),l&&a.jsxs(\"div\",{className:\"grid grid-cols-1 xl:grid-cols-2 gap-6\",children:[a.jsxs(\"div\",{className:\"space-y-3\",children:[c&&a.jsxs(Tt,{children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"h3\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",id:\"card-currentuser\",children:[a.jsx(Wu,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.currentUser\")]}),c.auth_source!==\"ldap\"&&a.jsxs(De,{size:\"sm\",variant:\"ghost\",onClick:()=>Fe(!0),children:[a.jsx(no,{className:\"w-4 h-4\"}),i(\"settings.changePassword\")]})]})}),a.jsx(Mt,{children:a.jsx(\"div\",{className:\"flex items-center justify-between\",children:a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium text-lg\",children:c.username}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-1 mt-2\",children:[c.is_admin&&a.jsx(\"span\",{className:\"px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300\",children:i(\"settings.admin\")}),c.groups?.map(ke=>a.jsx(\"span\",{className:`px-2 py-0.5 rounded-full text-xs font-medium ${ke.name===\"Administrators\"?\"bg-purple-500/20 text-purple-300\":ke.name===\"Operators\"?\"bg-blue-500/20 text-blue-300\":ke.name===\"Viewers\"?\"bg-green-500/20 text-green-300\":\"bg-gray-500/20 text-gray-300\"}`,children:ke.name},ke.id))]})]})})})]}),a.jsxs(Tt,{children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"h3\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",id:\"card-users\",children:[a.jsx(Wu,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.users\")]}),m(\"users:create\")&&a.jsxs(De,{size:\"sm\",onClick:()=>{ht(!0),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]})},children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.addUser\")]})]})}),a.jsx(Mt,{children:nu?a.jsx(\"div\",{className:\"flex items-center justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})}):yo.length===0?a.jsx(\"p\",{className:\"text-center text-bambu-gray py-8\",children:i(\"settings.noUsersFound\")}):a.jsx(\"div\",{className:\"divide-y divide-bambu-dark-tertiary\",children:yo.map(ke=>a.jsxs(\"div\",{className:\"py-3 flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-white font-medium truncate\",children:ke.username}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-1 mt-1\",children:[ke.auth_source===\"ldap\"&&a.jsx(\"span\",{className:\"px-2 py-0.5 rounded-full text-xs font-medium bg-cyan-500/20 text-cyan-300\",children:\"LDAP\"}),ke.is_admin&&a.jsx(\"span\",{className:\"px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300\",children:i(\"settings.admin\")}),ke.groups?.map(Rt=>a.jsx(\"span\",{className:`px-2 py-0.5 rounded-full text-xs font-medium ${Rt.name===\"Administrators\"?\"bg-purple-500/20 text-purple-300\":Rt.name===\"Operators\"?\"bg-blue-500/20 text-blue-300\":Rt.name===\"Viewers\"?\"bg-green-500/20 text-green-300\":\"bg-gray-500/20 text-gray-300\"}`,children:Rt.name},Rt.id))]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 ml-4\",children:[m(\"users:update\")&&a.jsx(De,{size:\"sm\",variant:\"ghost\",onClick:()=>au(ke),children:a.jsx(Qu,{className:\"w-4 h-4\"})}),m(\"users:delete\")&&ke.id!==c?.id&&a.jsx(De,{size:\"sm\",variant:\"ghost\",onClick:()=>ru(ke.id),children:a.jsx(en,{className:\"w-4 h-4\"})})]})]},ke.id))})})]})]}),a.jsx(\"div\",{children:a.jsxs(Tt,{children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"h3\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",id:\"card-groups\",children:[a.jsx(Gu,{className:\"w-5 h-5 text-bambu-green\"}),i(\"settings.groups\")]}),m(\"groups:create\")&&a.jsxs(De,{size:\"sm\",onClick:()=>e(\"/groups/new\"),children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.addGroup\")]})]})}),a.jsx(Mt,{children:vo?a.jsx(\"div\",{className:\"flex items-center justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 text-bambu-green animate-spin\"})}):Ps.length===0?a.jsx(\"p\",{className:\"text-center text-bambu-gray py-8\",children:i(\"settings.noGroupsFound\")}):a.jsx(\"div\",{className:\"divide-y divide-bambu-dark-tertiary\",children:Ps.map(ke=>a.jsxs(\"div\",{className:\"py-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Gu,{className:`w-4 h-4 ${ke.name===\"Administrators\"?\"text-purple-400\":ke.name===\"Operators\"?\"text-blue-400\":ke.name===\"Viewers\"?\"text-green-400\":\"text-bambu-gray\"}`}),a.jsx(\"span\",{className:\"text-white font-medium\",children:ke.name}),ke.is_system&&a.jsx(\"span\",{className:\"px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400\",children:i(\"settings.system\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[m(\"groups:update\")&&a.jsx(De,{size:\"sm\",variant:\"ghost\",onClick:()=>e(`/groups/${ke.id}/edit`),children:a.jsx(Qu,{className:\"w-4 h-4\"})}),m(\"groups:delete\")&&!ke.is_system&&a.jsx(De,{size:\"sm\",variant:\"ghost\",onClick:()=>At(ke.id),children:a.jsx(en,{className:\"w-4 h-4\"})})]})]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1 ml-6\",children:ke.description||i(\"settings.noDescription\")}),a.jsxs(\"div\",{className:\"flex items-center gap-4 mt-2 ml-6 text-xs text-bambu-gray\",children:[a.jsx(\"span\",{children:i(\"settings.userCount\",{count:ke.user_count})}),a.jsx(\"span\",{children:i(\"settings.permissionCount\",{count:ke.permissions.length})})]})]},ke.id))})})]})})]}),!l&&a.jsx(Tt,{children:a.jsx(Mt,{className:\"py-6\",children:a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(A0,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-4\"}),a.jsx(\"h3\",{className:\"text-lg font-medium text-white mb-2\",children:i(\"settings.authDisabledTitle\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-4 max-w-md mx-auto\",children:i(\"settings.authDisabledMessage\")}),a.jsxs(\"ul\",{className:\"space-y-2 text-sm text-bambu-gray mb-6 text-left max-w-xs mx-auto\",children:[a.jsxs(\"li\",{className:\"flex items-start gap-2\",children:[a.jsx(yr,{className:\"w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0\"}),a.jsx(\"span\",{children:i(\"settings.authDisabledFeature1\")})]}),a.jsxs(\"li\",{className:\"flex items-start gap-2\",children:[a.jsx(yr,{className:\"w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0\"}),a.jsx(\"span\",{children:i(\"settings.authDisabledFeature2\")})]}),a.jsxs(\"li\",{className:\"flex items-start gap-2\",children:[a.jsx(yr,{className:\"w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0\"}),a.jsx(\"span\",{children:i(\"settings.authDisabledFeature3\")})]})]}),a.jsxs(De,{onClick:()=>e(\"/setup\"),children:[a.jsx(kd,{className:\"w-4 h-4\"}),i(\"settings.enableAuthentication\")]})]})})})]}),I===\"email\"&&a.jsx(\"div\",{className:\"max-w-5xl\",id:\"card-smtp\",children:a.jsx(vnt,{})}),I===\"ldap\"&&a.jsx(\"div\",{className:\"max-w-5xl\",id:\"card-ldap\",children:a.jsx(Snt,{})}),I===\"twofa\"&&a.jsx(\"div\",{className:\"max-w-2xl\",children:a.jsx(knt,{})}),I===\"oidc\"&&d&&a.jsx(\"div\",{className:\"max-w-3xl\",children:a.jsx(Cnt,{})})]}),Qe&&!Ha?.advanced_auth_enabled&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black flex items-center justify-center z-50 p-4\",onClick:()=>{ht(!1),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]})},children:a.jsxs(Tt,{className:\"w-full max-w-md\",onClick:ke=>ke.stopPropagation(),children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Wu,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.createUser\")})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>{ht(!1),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]})},children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})}),a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"settings.username\")}),a.jsx(\"input\",{type:\"text\",value:at.username,onChange:ke=>Lt({...at,username:ke.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"settings.enterUsername\"),autoComplete:\"username\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"settings.password\")}),a.jsx(\"input\",{type:\"password\",value:at.password,onChange:ke=>Lt({...at,password:ke.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"settings.enterPassword\"),autoComplete:\"new-password\",minLength:6})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"settings.confirmPassword\")}),a.jsx(\"input\",{type:\"password\",value:at.confirmPassword,onChange:ke=>Lt({...at,confirmPassword:ke.target.value}),className:`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${at.confirmPassword&&at.password!==at.confirmPassword?\"border-red-500\":\"border-bambu-dark-tertiary\"}`,placeholder:i(\"settings.confirmPasswordPlaceholder\"),autoComplete:\"new-password\",minLength:6}),at.confirmPassword&&at.password!==at.confirmPassword&&a.jsx(\"p\",{className:\"text-red-400 text-xs mt-1\",children:i(\"settings.passwordsDoNotMatch\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"settings.groups\")}),a.jsxs(\"div\",{className:\"space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\",children:[Ps.map(ke=>a.jsxs(\"label\",{className:\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:at.group_ids.includes(ke.id),onChange:()=>sd(ke.id),className:\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:ke.name}),ke.is_system&&a.jsx(\"span\",{className:\"text-xs text-yellow-400\",children:i(\"settings.systemBadge\")})]},ke.id)),Ps.length===0&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"settings.noGroupsAvailable\")})]})]})]}),a.jsxs(\"div\",{className:\"mt-6 flex justify-end gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{ht(!1),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]})},children:i(\"common.cancel\")}),a.jsx(De,{onClick:di,disabled:Ts.isPending||!at.username||!at.password||at.password!==at.confirmPassword||at.password.length<6,children:Ts.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),i(\"settings.creating\")]}):a.jsxs(a.Fragment,{children:[a.jsx(sr,{className:\"w-4 h-4\"}),i(\"settings.createUser\")]})})]})]})]})}),Qe&&Ha?.advanced_auth_enabled&&a.jsx(int,{formData:at,setFormData:Lt,groups:Ps,onClose:()=>{ht(!1),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]})},onCreate:di,isCreating:Ts.isPending,isCreateButtonDisabled:Ts.isPending||!at.username||!at.email}),xt&&Ut!==null&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black flex items-center justify-center z-50 p-4\",onClick:()=>{gt(!1),Wt(null),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]})},children:a.jsxs(Tt,{className:\"w-full max-w-md\",onClick:ke=>ke.stopPropagation(),children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(Qu,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.editUser\")})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>{gt(!1),Wt(null),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]})},children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})}),a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:[i(\"settings.username\"),\" \",Ha?.advanced_auth_enabled&&a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"})]}),a.jsx(\"input\",{type:\"text\",value:at.username,onChange:ke=>Lt({...at,username:ke.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"settings.enterUsername\"),autoComplete:\"username\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:[i(\"users.form.email\")||\"Email\",\" \",Ha?.advanced_auth_enabled?a.jsx(\"span\",{className:\"text-red-400\",children:\"*\"}):a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",i(\"users.form.optional\")||\"optional\",\")\"]})]}),a.jsx(\"input\",{type:\"email\",value:at.email,onChange:ke=>Lt({...at,email:ke.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"users.form.emailPlaceholder\")||\"user@example.com\",required:Ha?.advanced_auth_enabled})]}),!Ha?.advanced_auth_enabled&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:[i(\"users.form.password\")||\"Password\",\" \",a.jsxs(\"span\",{className:\"text-bambu-gray font-normal\",children:[\"(\",i(\"users.form.leaveBlankToKeep\")||\"leave blank to keep current\",\")\"]})]}),a.jsx(\"input\",{type:\"password\",value:at.password,onChange:ke=>Lt({...at,password:ke.target.value,confirmPassword:\"\"}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"settings.enterNewPassword\"),autoComplete:\"new-password\",minLength:6})]}),at.password&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"settings.confirmPassword\")}),a.jsx(\"input\",{type:\"password\",value:at.confirmPassword,onChange:ke=>Lt({...at,confirmPassword:ke.target.value}),className:`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${at.confirmPassword&&at.password!==at.confirmPassword?\"border-red-500\":\"border-bambu-dark-tertiary\"}`,placeholder:i(\"settings.confirmNewPassword\"),autoComplete:\"new-password\",minLength:6}),at.confirmPassword&&at.password!==at.confirmPassword&&a.jsx(\"p\",{className:\"text-red-400 text-xs mt-1\",children:i(\"settings.passwordsDoNotMatch\")})]})]}),Ha?.advanced_auth_enabled&&a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3 space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"users.form.passwordManagedByAdvancedAuth\")||'Password is managed by Advanced Authentication. Use \"Reset Password\" to send a new password to the user via email.'}),a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>Ut&&_c.mutate(Ut),disabled:_c.isPending||!at.email,className:\"w-full\",children:_c.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),i(\"users.form.resettingPassword\")||\"Resetting Password...\"]}):a.jsxs(a.Fragment,{children:[a.jsx(co,{className:\"w-4 h-4\"}),i(\"users.form.resetPassword\")||\"Reset Password\"]})})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:i(\"users.form.groups\")||\"Groups\"}),a.jsx(\"div\",{className:\"space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\",children:Ps.map(ke=>a.jsxs(\"label\",{className:\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:at.group_ids.includes(ke.id),onChange:()=>sd(ke.id),className:\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:ke.name}),ke.is_system&&a.jsxs(\"span\",{className:\"text-xs text-yellow-400\",children:[\"(\",i(\"users.system\")||\"System\",\")\"]})]},ke.id))})]})]}),a.jsxs(\"div\",{className:\"mt-6 flex justify-end gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{gt(!1),Wt(null),Lt({username:\"\",password:\"\",email:\"\",confirmPassword:\"\",role:\"user\",group_ids:[]})},children:i(\"users.modal.cancel\")||\"Cancel\"}),a.jsx(De,{onClick:()=>As(Ut),disabled:Wo.isPending||!at.username||Ha?.advanced_auth_enabled&&!at.email||!!(!Ha?.advanced_auth_enabled&&at.password&&(at.password!==at.confirmPassword||at.password.length<6)),children:Wo.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),i(\"users.modal.saving\")||\"Saving...\"]}):a.jsxs(a.Fragment,{children:[a.jsx(_s,{className:\"w-4 h-4\"}),i(\"users.modal.saveChanges\")||\"Save Changes\"]})})]})]})]})}),Zt!==null&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4\",onClick:()=>{Kt(null),ln(null)},children:a.jsxs(Tt,{className:\"w-full max-w-md\",onClick:ke=>ke.stopPropagation(),children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center gap-2 text-red-400\",children:[a.jsx(en,{className:\"w-5 h-5\"}),a.jsx(\"h3\",{className:\"text-lg font-semibold\",children:i(\"settings.deleteUserTitle\")})]})}),a.jsx(Mt,{className:\"space-y-3\",children:vn?a.jsx(\"div\",{className:\"flex items-center justify-center py-4\",children:a.jsx(\"div\",{className:\"animate-spin rounded-full h-6 w-6 border-2 border-bambu-green border-t-transparent\"})}):Xt&&Xt.archives+Xt.queue_items+Xt.library_files>0?a.jsxs(a.Fragment,{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.userHasCreated\")}),a.jsxs(\"ul\",{className:\"list-disc list-inside text-bambu-gray space-y-1\",children:[Xt.archives>0&&a.jsxs(\"li\",{children:[Xt.archives,\" archive\",Xt.archives!==1?\"s\":\"\"]}),Xt.queue_items>0&&a.jsxs(\"li\",{children:[Xt.queue_items,\" queue item\",Xt.queue_items!==1?\"s\":\"\"]}),Xt.library_files>0&&a.jsxs(\"li\",{children:[Xt.library_files,\" library file\",Xt.library_files!==1?\"s\":\"\"]})]}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:i(\"settings.userItemsQuestion\")}),a.jsxs(\"div\",{className:\"flex flex-col gap-2\",children:[a.jsx(De,{variant:\"danger\",onClick:()=>wo.mutate({id:Zt,deleteItems:!0}),disabled:wo.isPending,className:\"justify-center\",children:i(\"settings.deleteUserAndItems\")}),a.jsx(De,{variant:\"secondary\",onClick:()=>wo.mutate({id:Zt,deleteItems:!1}),disabled:wo.isPending,className:\"justify-center\",children:i(\"settings.deleteUserKeepItems\")}),a.jsx(De,{variant:\"ghost\",onClick:()=>{Kt(null),ln(null)},disabled:wo.isPending,className:\"justify-center\",children:i(\"common.cancel\")})]})]}):a.jsxs(a.Fragment,{children:[a.jsx(\"p\",{className:\"text-white\",children:i(\"settings.deleteUserConfirm\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:i(\"settings.actionCannotBeUndone\")}),a.jsxs(\"div\",{className:\"flex gap-2 justify-end\",children:[a.jsx(De,{variant:\"ghost\",onClick:()=>{Kt(null),ln(null)},disabled:wo.isPending,children:i(\"common.cancel\")}),a.jsx(De,{variant:\"danger\",onClick:()=>wo.mutate({id:Zt,deleteItems:!1}),disabled:wo.isPending,children:i(\"settings.deleteUserTitle\")})]})]})})]})}),Et!==null&&a.jsx(yn,{title:i(\"settings.deleteGroupTitle\"),message:i(\"settings.deleteGroupMessage\"),confirmText:i(\"settings.deleteGroup\"),variant:\"danger\",onConfirm:()=>{zp.mutate(Et),At(null)},onCancel:()=>At(null)}),U===\"failure-detection\"&&a.jsx(\"div\",{id:\"card-failure-detection\",children:a.jsx(bnt,{})}),U===\"backup\"&&a.jsx(\"div\",{id:\"card-backup\",children:a.jsx(gnt,{})}),Ye&&a.jsx(yn,{title:i(\"settings.disableAuthenticationTitle\"),message:i(\"settings.disableAuthenticationMessage\"),confirmText:i(\"settings.disableAuthentication\"),variant:\"danger\",onConfirm:async()=>{try{await ue.disableAuth(),o(i(\"settings.toast.authDisabled\"),\"success\"),await u(),tt(!1),window.location.href=\"/\"}catch(ke){const Rt=ke instanceof Error?ke.message:i(\"settings.toast.authDisableFailed\");o(Rt,\"error\")}},onCancel:()=>tt(!1)}),pe&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:()=>{Fe(!1),Ve({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"})},children:a.jsxs(Tt,{className:\"w-full max-w-md\",onClick:ke=>ke.stopPropagation(),children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(no,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"settings.changePassword\")})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>{Fe(!1),Ve({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"})},children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})}),a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:\"Current Password\"}),a.jsx(\"input\",{type:\"password\",value:we.currentPassword,onChange:ke=>Ve({...we,currentPassword:ke.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"settings.enterCurrentPassword\"),autoComplete:\"current-password\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:\"New Password\"}),a.jsx(\"input\",{type:\"password\",value:we.newPassword,onChange:ke=>Ve({...we,newPassword:ke.target.value}),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:i(\"settings.enterNewPasswordMin6\"),autoComplete:\"new-password\",minLength:6})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:\"Confirm New Password\"}),a.jsx(\"input\",{type:\"password\",value:we.confirmPassword,onChange:ke=>Ve({...we,confirmPassword:ke.target.value}),className:`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${we.confirmPassword&&we.newPassword!==we.confirmPassword?\"border-red-500\":\"border-bambu-dark-tertiary\"}`,placeholder:i(\"settings.confirmNewPassword\"),autoComplete:\"new-password\",minLength:6}),we.confirmPassword&&we.newPassword!==we.confirmPassword&&a.jsx(\"p\",{className:\"text-red-400 text-xs mt-1\",children:i(\"settings.passwordsDoNotMatch\")})]})]}),a.jsxs(\"div\",{className:\"mt-6 flex justify-end gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>{Fe(!1),Ve({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"})},children:i(\"common.cancel\")}),a.jsx(De,{onClick:async()=>{if(we.newPassword!==we.confirmPassword){o(i(\"settings.toast.passwordsDoNotMatch\"),\"error\");return}if(we.newPassword.length<6){o(i(\"settings.toast.passwordTooShort\"),\"error\");return}ce(!0);try{await ue.changePassword(we.currentPassword,we.newPassword),o(i(\"settings.toast.passwordChanged\"),\"success\"),Fe(!1),Ve({currentPassword:\"\",newPassword:\"\",confirmPassword:\"\"})}catch(ke){const Rt=ke instanceof Error?ke.message:\"Failed to change password\";o(Rt,\"error\")}finally{ce(!1)}},disabled:Ae||!we.currentPassword||!we.newPassword||we.newPassword!==we.confirmPassword||we.newPassword.length<6,children:Ae?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),i(\"settings.changing\")]}):a.jsxs(a.Fragment,{children:[a.jsx(no,{className:\"w-4 h-4\"}),i(\"settings.changePassword\")]})})]})]})]})})]})]})]})})}const Lle=t=>{const e=parseFloat(t);return(Math.trunc(e*1e3)/1e3).toFixed(3)},Rnt=t=>t.startsWith(\"HH\")?\"HF\":\"S\",Lnt=t=>{const e=t.match(/^([A-Z]{2}\\d{2})/);return e?e[1]:\"HH00\"},Ole=t=>{const e=[\"High Flow_\",\"High Flow \",\"Standard_\",\"Standard \",\"HF_\",\"HF \",\"S_\",\"S \"];for(const r of e)if(t.startsWith(r))return t.slice(r.length);const n=t.indexOf(\"_\");return n>0?t.slice(n+1):t};function P3({profile:t,onEdit:e,onCopy:n,selectionMode:r,isSelected:i,onToggleSelect:s,note:o}){const l=Rnt(t.nozzle_id),c=t.nozzle_diameter,d=()=>{r&&s?s():e()};return a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[r&&a.jsx(\"button\",{onClick:s,className:\"text-bambu-gray hover:text-white transition-colors p-1\",children:i?a.jsx(Ns,{className:\"w-4 h-4 text-bambu-green\"}):a.jsx(uo,{className:\"w-4 h-4\"})}),a.jsxs(\"button\",{onClick:d,className:`flex-1 text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors ${i?\"ring-1 ring-bambu-green\":\"\"}`,children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-bambu-green font-mono text-sm font-bold whitespace-nowrap\",children:Lle(t.k_value)}),a.jsx(\"span\",{className:\"text-white text-sm truncate flex-1\",title:t.name,children:t.name||\"Unnamed\"}),o&&a.jsx(\"span\",{title:\"Has note\",children:a.jsx(VQ,{className:\"w-3 h-3 text-yellow-500\"})}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray whitespace-nowrap\",children:[l,\" \",c]})]}),o&&a.jsxs(\"div\",{className:\"text-xs mt-0.5 truncate text-yellow-500/70\",title:o,children:[\"Note: \",o.length>50?o.substring(0,50)+\"...\":o]})]}),!r&&n&&a.jsx(\"button\",{onClick:u=>{u.stopPropagation(),n()},className:\"text-bambu-gray hover:text-white transition-colors p-1\",title:\"Copy profile\",children:a.jsx(qs,{className:\"w-4 h-4\"})})]})}function T3({profile:t,printerId:e,nozzleDiameter:n,existingProfiles:r=[],builtinFilaments:i=[],isDualNozzle:s=!1,initialNote:o=\"\",initialNoteKey:l=null,onClose:c,onSave:d,onSaveNote:u,hasPermission:m}){const{t:p}=Ft(),{showToast:f}=hn(),[y,v]=w.useState(t?.name||\"\"),[b,g]=w.useState(t?.k_value?Lle(t.k_value):\"0.020\"),[_,C]=w.useState(t?.filament_id||\"\"),[P,N]=w.useState(t?.nozzle_id?Lnt(t.nozzle_id):\"HH00\"),[A,T]=w.useState(t?.nozzle_diameter||n),[F,k]=w.useState(t?[t.extruder_id]:s?[0,1]:[0]),[D,H]=w.useState(!1),[z,Q]=w.useState({current:0,total:0}),[L,te]=w.useState(o),ie=ia.useMemo(()=>{const me=new Map;for(const Ce of i)me.set(Ce.filament_id,Ce.name);const be=new Map;for(const Ce of r)if(Ce.filament_id&&!be.has(Ce.filament_id)){const Y=me.get(Ce.filament_id)||Ole(Ce.name||\"\");be.set(Ce.filament_id,{id:Ce.filament_id,name:Y||Ce.filament_id})}return Array.from(be.values()).sort((Ce,q)=>Ce.name.localeCompare(q.name))},[r,i]),J=it({mutationFn:me=>(console.log(\"[KProfile] Calling API...\"),ue.setKProfile(e,me)),onSuccess:me=>{if(console.log(\"[KProfile] Save success:\",me),f(p(\"kProfiles.toast.profileSaved\")),u&&L!==o){let be;L===\"\"&&l?be=l:t&&t.slot_id>0?be=t.setting_id||`slot_${t.slot_id}_${t.filament_id}_${t.extruder_id}`:be=`name_${y}_${_}`,u(be,L)}H(!0),setTimeout(()=>{H(!1),d()},2500)},onError:me=>{console.error(\"[KProfile] Save error:\",me),f(me.message,\"error\"),H(!1)}}),oe=it({mutationFn:me=>(console.log(\"[KProfile] Deleting profile...\"),ue.deleteKProfile(e,me)),onSuccess:me=>{console.log(\"[KProfile] Delete success:\",me),f(p(\"kProfiles.toast.profileDeleted\")),H(!0),setTimeout(()=>{H(!1),c()},4e3)},onError:me=>{console.error(\"[KProfile] Delete error:\",me),f(me.message,\"error\"),H(!1)}}),[fe,re]=w.useState(!1),W=()=>{t&&oe.mutate({slot_id:t.slot_id,extruder_id:t.extruder_id,nozzle_id:t.nozzle_id,nozzle_diameter:t.nozzle_diameter,filament_id:t.filament_id,setting_id:t.setting_id})},ne=async me=>{if(me.preventDefault(),s&&!t&&F.length===0){f(p(\"kProfiles.toast.selectAtLeastOneExtruder\"),\"error\");return}const be=parseFloat(b).toFixed(6),Ce=`${P}-${A}`;if(t||F.length===1){const Y={name:y,k_value:be,filament_id:_,nozzle_id:Ce,nozzle_diameter:A,extruder_id:t?t.extruder_id:F[0],setting_id:t?.setting_id,slot_id:t?.slot_id??0};console.log(\"[KProfile] Saving profile:\",Y),J.mutate(Y);return}H(!0),Q({current:1,total:F.length});const q=F.map(Y=>({name:y,k_value:be,filament_id:_,nozzle_id:Ce,nozzle_diameter:A,extruder_id:Y,setting_id:void 0,slot_id:0}));console.log(`[KProfile] Saving ${q.length} profiles in batch:`,q);try{if(await ue.setKProfilesBatch(e,q),f(p(\"kProfiles.toast.profilesSaved\",{count:F.length})),u&&L){const Y=`name_${y}_${_}`;u(Y,L)}}catch(Y){console.error(\"[KProfile] Failed to save batch:\",Y),f(p(\"kProfiles.toast.failedToSaveBatch\"),\"error\"),H(!1),Q({current:0,total:0});return}Q({current:F.length,total:F.length}),setTimeout(()=>{H(!1),Q({current:0,total:0}),d()},3e3)};return a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:[a.jsxs(Tt,{className:\"w-full max-w-md relative\",children:[D&&a.jsxs(\"div\",{className:\"absolute inset-0 bg-bambu-dark-secondary/90 flex flex-col items-center justify-center z-10 rounded-lg\",children:[a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin mb-3\"}),a.jsx(\"p\",{className:\"text-white font-medium\",children:z.total>1?p(\"kProfiles.modal.savingExtruder\",{current:z.current,total:z.total}):p(\"kProfiles.modal.syncing\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm mt-1\",children:p(\"kProfiles.modal.pleaseWait\")})]}),a.jsxs(Mt,{className:\"p-0\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:p(t?\"kProfiles.modal.editTitle\":\"kProfiles.modal.addTitle\")}),a.jsx(\"button\",{onClick:c,className:\"text-bambu-gray hover:text-white transition-colors\",disabled:D,children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"form\",{onSubmit:ne,className:\"p-4 space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:p(\"kProfiles.modal.profileName\")}),a.jsx(\"input\",{type:\"text\",value:y,onChange:me=>v(me.target.value),disabled:!!t,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 ${t?\"opacity-60 cursor-not-allowed\":\"\"}`,placeholder:p(\"kProfiles.modal.profileNamePlaceholder\"),required:!t})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:p(\"kProfiles.modal.kValue\")}),a.jsx(\"input\",{type:\"text\",inputMode:\"decimal\",value:b,onChange:me=>{const be=me.target.value;(be===\"\"||/^\\d*\\.?\\d*$/.test(be))&&g(be)},onBlur:me=>{const be=parseFloat(me.target.value);isNaN(be)||g((Math.trunc(be*1e3)/1e3).toFixed(3))},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 font-mono\",placeholder:p(\"kProfiles.modal.kValuePlaceholder\"),required:!0}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:p(\"kProfiles.modal.kValueHelp\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:p(\"kProfiles.modal.filament\")}),a.jsxs(\"select\",{value:_,onChange:me=>{const be=me.target.value;if(C(be),!t&&be&&!y){const Ce=ie.find(q=>q.id===be);Ce&&v(`${P===\"HH00\"?\"HF\":\"S\"} ${Ce.name}`)}},disabled:!!t,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 ${t?\"opacity-60 cursor-not-allowed\":\"\"}`,required:!t,children:[a.jsx(\"option\",{value:\"\",children:p(\"kProfiles.modal.selectFilament\")}),t?.filament_id&&a.jsx(\"option\",{value:t.filament_id,children:ie.find(me=>me.id===t.filament_id)?.name||t.filament_id},t.filament_id),!t&&ie.map(me=>a.jsx(\"option\",{value:me.id,children:me.name},me.id))]}),!t&&ie.length===0&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:p(\"kProfiles.modal.noFilamentsHelp\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:p(\"kProfiles.modal.flowType\")}),a.jsxs(\"select\",{value:P,onChange:me=>{const be=me.target.value;if(N(be),!t&&_&&!y){const Ce=ie.find(q=>q.id===_);Ce&&v(`${be===\"HS00\"?\"HF\":\"S\"} ${Ce.name}`)}},disabled:!!t,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 ${t?\"opacity-60 cursor-not-allowed\":\"\"}`,children:[a.jsx(\"option\",{value:\"HH00\",children:p(\"kProfiles.modal.highFlow\")}),a.jsx(\"option\",{value:\"HS00\",children:p(\"kProfiles.modal.standard\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:p(\"kProfiles.modal.nozzleSize\")}),a.jsxs(\"select\",{value:A,onChange:me=>T(me.target.value),disabled:!!t,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 ${t?\"opacity-60 cursor-not-allowed\":\"\"}`,children:[a.jsx(\"option\",{value:\"0.2\",children:\"0.2mm\"}),a.jsx(\"option\",{value:\"0.4\",children:\"0.4mm\"}),a.jsx(\"option\",{value:\"0.6\",children:\"0.6mm\"}),a.jsx(\"option\",{value:\"0.8\",children:\"0.8mm\"})]})]})]}),s&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:p(t?\"kProfiles.modal.extruder\":\"kProfiles.modal.extruders\")}),t?a.jsx(\"div\",{className:\"px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60\",children:t.extruder_id===1?p(\"kProfiles.modal.left\"):p(\"kProfiles.modal.right\")}):a.jsxs(\"div\",{className:\"flex gap-4\",children:[a.jsxs(\"label\",{className:\"flex items-center gap-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:F.includes(1),onChange:me=>{me.target.checked?k([...F,1]):k(F.filter(be=>be!==1))},className:\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green\"}),a.jsx(\"span\",{className:\"text-white\",children:p(\"kProfiles.modal.left\")})]}),a.jsxs(\"label\",{className:\"flex items-center gap-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:F.includes(0),onChange:me=>{me.target.checked?k([...F,0]):k(F.filter(be=>be!==0))},className:\"w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green\"}),a.jsx(\"span\",{className:\"text-white\",children:p(\"kProfiles.modal.right\")})]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:p(\"kProfiles.modal.notes\")}),a.jsx(\"textarea\",{value:L,onChange:me=>te(me.target.value),placeholder:p(\"kProfiles.modal.notesPlaceholder\"),rows:2,className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:p(\"kProfiles.modal.notesHelp\")})]}),a.jsxs(\"div\",{className:\"flex gap-2 pt-4\",children:[t&&a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:()=>re(!0),disabled:oe.isPending||D||!m(\"kprofiles:delete\"),title:m(\"kprofiles:delete\")?void 0:p(\"kProfiles.permission.noDelete\"),className:\"text-red-500 hover:bg-red-500/10\",children:oe.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"})}),a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:c,disabled:D,className:\"flex-1\",children:p(\"common.cancel\")}),a.jsxs(De,{type:\"submit\",disabled:J.isPending||D||!m(t?\"kprofiles:update\":\"kprofiles:create\"),title:m(t?\"kprofiles:update\":\"kprofiles:create\")?void 0:p(t?\"kProfiles.permission.noUpdate\":\"kProfiles.permission.noCreate\"),className:\"flex-1\",children:[J.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(Zw,{className:\"w-4 h-4\"}),p(\"common.save\")]})]})]})]})]}),fe&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-[60]\",children:a.jsx(Tt,{className:\"w-full max-w-sm\",children:a.jsxs(Mt,{className:\"p-6\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-4\",children:[a.jsx(\"div\",{className:\"w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center\",children:a.jsx(en,{className:\"w-5 h-5 text-red-500\"})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white\",children:p(\"kProfiles.deleteConfirm.title\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:p(\"kProfiles.deleteConfirm.cannotUndo\")})]})]}),a.jsx(\"p\",{className:\"text-bambu-gray mb-6\",children:p(\"kProfiles.deleteConfirm.message\",{name:t?.name})}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>re(!1),className:\"flex-1\",children:p(\"common.cancel\")}),a.jsxs(De,{onClick:()=>{re(!1),W()},disabled:oe.isPending,className:\"flex-1 bg-red-500 hover:bg-red-600 text-white\",children:[oe.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"}),p(\"common.delete\")]})]})]})})})]})}const Fk={NOZZLE_DIAMETER:\"bambusy_kprofiles_nozzle\",SORT_OPTION:\"bambusy_kprofiles_sort\"};function Ont(){const{t}=Ft(),{showToast:e}=hn(),{hasPermission:n}=kr(),[r,i]=w.useState(null),[s,o]=w.useState(()=>localStorage.getItem(Fk.NOZZLE_DIAMETER)||\"0.4\"),[l,c]=w.useState(null),[d,u]=w.useState(!1),[m,p]=w.useState(null),[f,y]=w.useState(\"\"),[v,b]=w.useState(\"all\"),[g,_]=w.useState(\"all\"),[C,P]=w.useState(()=>localStorage.getItem(Fk.SORT_OPTION)||\"name\"),[N,A]=w.useState(!1),[T,F]=w.useState(new Set),[k,D]=w.useState(!1),[H,z]=w.useState(!1),Q=w.useCallback(R=>`${R.slot_id}_${R.extruder_id}`,[]);w.useEffect(()=>{localStorage.setItem(Fk.NOZZLE_DIAMETER,s)},[s]),w.useEffect(()=>{localStorage.setItem(Fk.SORT_OPTION,C)},[C]);const{data:L,isLoading:te}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:ie,isLoading:J,isFetching:oe,error:fe,refetch:re}=Xe({queryKey:[\"kprofiles\",r,s],queryFn:async()=>{console.log(\"[KProfiles] Fetching profiles for printer\",r,\"nozzle\",s);const R=await ue.getKProfiles(r,s);return console.log(\"[KProfiles] Received profiles:\",R?.profiles?.length||0,\"profiles\"),R},enabled:!!r,retry:!1,staleTime:0,gcTime:0,refetchOnMount:\"always\"}),{data:W}=Xe({queryKey:[\"kprofiles\",r,\"0.4\"],queryFn:()=>ue.getKProfiles(r,\"0.4\"),enabled:!!r,staleTime:6e4}),{data:ne}=Xe({queryKey:[\"builtinFilaments\"],queryFn:()=>ue.getBuiltinFilaments(),staleTime:3e5}),{data:me}=Xe({queryKey:[\"filamentIdMap\"],queryFn:()=>ue.getFilamentIdMap(),staleTime:3e5}),{data:be,refetch:Ce}=Xe({queryKey:[\"kprofile-notes\",r],queryFn:()=>ue.getKProfileNotes(r),enabled:!!r,staleTime:3e4}),q=fe?.message?.includes(\"not connected\");w.useEffect(()=>{if(!r&&L&&L.length>0){const R=L.find(ae=>ae.is_active);R&&i(R.id)}},[r,L]),w.useEffect(()=>{if(r){const R=setTimeout(()=>{re()},150);return()=>clearTimeout(R)}},[r,s]);const Y=L?.filter(R=>R.is_active)||[],E=ia.useMemo(()=>{const R=new Map;if(ne)for(const ae of ne)R.set(ae.filament_id,ae.name);if(me)for(const[ae,_e]of Object.entries(me))R.has(ae)||R.set(ae,_e);return R},[ne,me]),j=ia.useMemo(()=>Array.from(E.entries()).map(([R,ae])=>({filament_id:R,name:ae})),[E]),O=ia.useCallback(R=>E.get(R.filament_id)||Ole(R.name),[E]),K=ia.useMemo(()=>ie?.profiles?ie.profiles.filter(ae=>{const _e=f.toLowerCase(),Se=!_e||ae.name.toLowerCase().includes(_e)||ae.filament_id.toLowerCase().includes(_e),ve=v===\"all\"||v===\"left\"&&ae.extruder_id===1||v===\"right\"&&ae.extruder_id===0,Te=g===\"all\"||g===\"hf\"&&ae.nozzle_id.startsWith(\"HH\")||g===\"s\"&&ae.nozzle_id.startsWith(\"HS\");return Se&&ve&&Te}).sort((ae,_e)=>{switch(C){case\"k_value\":return parseFloat(ae.k_value)-parseFloat(_e.k_value);case\"filament\":return O(ae).localeCompare(O(_e));default:return ae.name.localeCompare(_e.name)}}):[],[ie?.profiles,f,v,g,C,O]),U=L?.find(R=>R.id===r),de=U?.nozzle_count===2;w.useEffect(()=>{const R=ae=>{ae.target instanceof HTMLInputElement||ae.target instanceof HTMLTextAreaElement||ae.target instanceof HTMLSelectElement||l||d||m||(ae.key===\"r\"||ae.key===\"R\"?(ae.preventDefault(),re()):ae.key===\"n\"||ae.key===\"N\"?(ae.preventDefault(),u(!0)):ae.key===\"Escape\"&&N&&(ae.preventDefault(),A(!1),F(new Set)))};return window.addEventListener(\"keydown\",R),()=>window.removeEventListener(\"keydown\",R)},[l,d,m,N,re]);const I=w.useCallback(()=>{if(!ie?.profiles||ie.profiles.length===0){e(t(\"kProfiles.toast.noProfilesToExport\"),\"error\");return}const R={version:1,exported_at:new Date().toISOString(),printer:U?.name||\"Unknown\",nozzle_diameter:s,profiles:ie.profiles.map(ve=>({name:ve.name,k_value:ve.k_value,filament_id:ve.filament_id,nozzle_id:ve.nozzle_id,nozzle_diameter:ve.nozzle_diameter,extruder_id:ve.extruder_id}))},ae=new Blob([JSON.stringify(R,null,2)],{type:\"application/json\"}),_e=URL.createObjectURL(ae),Se=document.createElement(\"a\");Se.href=_e,Se.download=`kprofiles_${U?.name||\"printer\"}_${s}mm_${new Date().toISOString().split(\"T\")[0]}.json`,document.body.appendChild(Se),Se.click(),document.body.removeChild(Se),URL.revokeObjectURL(_e),e(t(\"kProfiles.toast.exportedProfiles\",{count:ie.profiles.length}))},[ie?.profiles,U,s,e,t]),G=w.useCallback(()=>{const R=document.createElement(\"input\");R.type=\"file\",R.accept=\".json\",R.onchange=async ae=>{const _e=ae.target.files?.[0];if(_e)try{const Se=await _e.text(),ve=JSON.parse(Se);if(!ve.profiles||!Array.isArray(ve.profiles)){e(t(\"kProfiles.toast.invalidFileFormat\"),\"error\");return}let Te=0;for(const ye of ve.profiles)if(!(!ye.name||!ye.k_value||!ye.filament_id))try{await ue.setKProfile(r,{name:ye.name,k_value:parseFloat(ye.k_value).toFixed(6),filament_id:ye.filament_id,nozzle_id:ye.nozzle_id||`HH00-${s}`,nozzle_diameter:ye.nozzle_diameter||s,extruder_id:ye.extruder_id??0,slot_id:0}),Te++,await new Promise(je=>setTimeout(je,500))}catch(je){console.error(\"Failed to import profile:\",ye.name,je)}e(t(\"kProfiles.toast.importedProfiles\",{count:Te,total:ve.profiles.length})),re()}catch(Se){console.error(\"Import error:\",Se),e(t(\"kProfiles.toast.failedToParseImport\"),\"error\")}},R.click()},[r,s,e,re,t]),X=w.useCallback(R=>{F(ae=>{const _e=new Set(ae);return _e.has(R)?_e.delete(R):_e.add(R),_e})},[]),V=w.useCallback(()=>{F(new Set(K.map(R=>Q(R))))},[K,Q]),ee=w.useCallback(()=>{T.size!==0&&D(!0)},[T.size]),se=w.useCallback(async()=>{const R=K.filter(_e=>T.has(Q(_e)));z(!0);let ae=0;for(const _e of R)try{await ue.deleteKProfile(r,{slot_id:_e.slot_id,extruder_id:_e.extruder_id,nozzle_id:_e.nozzle_id,nozzle_diameter:_e.nozzle_diameter,filament_id:_e.filament_id,setting_id:_e.setting_id}),ae++,await new Promise(Se=>setTimeout(Se,300))}catch(Se){console.error(\"Failed to delete profile:\",_e.name,Se)}e(t(\"kProfiles.toast.profilesDeleted\",{count:ae})),z(!1),D(!1),A(!1),F(new Set),re()},[r,T,K,e,re,Q,t]),ge=w.useCallback(R=>{const ae=[];return R.setting_id&&ae.push(R.setting_id),ae.push(`slot_${R.slot_id}_${R.filament_id}_${R.extruder_id}`),ae.push(`name_${R.name}_${R.filament_id}`),ae},[]),he=w.useCallback(async(R,ae)=>{if(r)try{await ue.setKProfileNote(r,R,ae),Ce()}catch(_e){console.error(\"Failed to save note:\",_e),e(t(\"kProfiles.toast.failedToSaveNote\"),\"error\")}},[r,Ce,e,t]),le=w.useCallback(R=>{if(!be?.notes)return{note:\"\",key:null};const ae=ge(R);for(const _e of ae)if(be.notes[_e])return{note:be.notes[_e],key:_e};return{note:\"\",key:null}},[be,ge]),B=w.useCallback(R=>le(R).note,[le]);return te?a.jsx(\"div\",{className:\"flex justify-center py-12\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):!L||L.length===0?a.jsx(Tt,{children:a.jsxs(Mt,{className:\"py-12 text-center\",children:[a.jsx(ei,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-4\"}),a.jsx(\"h3\",{className:\"text-lg font-semibold text-white mb-2\",children:t(\"kProfiles.noPrintersConfigured\")}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"kProfiles.addPrinterInSettings\")})]})}):Y.length===0?a.jsx(Tt,{children:a.jsxs(Mt,{className:\"py-12 text-center\",children:[a.jsx(Er,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-4\"}),a.jsx(\"h3\",{className:\"text-lg font-semibold text-white mb-2\",children:t(\"kProfiles.noActivePrinters\")}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"kProfiles.enablePrinterConnection\")})]})}):a.jsxs(a.Fragment,{children:[oe&&!J&&a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40\",children:[a.jsx(ft,{className:\"w-10 h-10 text-bambu-green animate-spin mb-3\"}),a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"kProfiles.loadingProfiles\")})]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-4 mb-6\",children:[a.jsxs(\"div\",{className:\"flex-1 min-w-48\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:t(\"kProfiles.printer\")}),a.jsx(\"select\",{value:r||\"\",onChange:R=>i(parseInt(R.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\",children:Y.map(R=>a.jsx(\"option\",{value:R.id,children:R.name},R.id))})]}),a.jsxs(\"div\",{className:\"w-32\",children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:t(\"kProfiles.nozzle\")}),a.jsxs(\"select\",{value:s,onChange:R=>o(R.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\",children:[a.jsx(\"option\",{value:\"0.2\",children:\"0.2mm\"}),a.jsx(\"option\",{value:\"0.4\",children:\"0.4mm\"}),a.jsx(\"option\",{value:\"0.6\",children:\"0.6mm\"}),a.jsx(\"option\",{value:\"0.8\",children:\"0.8mm\"})]})]}),a.jsxs(\"div\",{className:\"flex items-end gap-2\",children:[a.jsxs(De,{variant:\"secondary\",onClick:()=>re(),disabled:oe||!n(\"kprofiles:read\"),title:n(\"kprofiles:read\")?void 0:t(\"kProfiles.permission.noRead\"),children:[a.jsx(lr,{className:`w-4 h-4 ${oe?\"animate-spin\":\"\"}`}),t(\"kProfiles.refresh\")]}),a.jsxs(De,{onClick:()=>u(!0),disabled:!n(\"kprofiles:create\"),title:n(\"kprofiles:create\")?void 0:t(\"kProfiles.permission.noCreate\"),children:[a.jsx(sr,{className:\"w-4 h-4\"}),t(\"kProfiles.addProfile\")]})]})]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-4 mb-4\",children:[a.jsxs(\"div\",{className:\"flex-1 min-w-48 relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:f,onChange:R=>y(R.target.value),placeholder:t(\"kProfiles.searchPlaceholder\"),className:\"w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"})]}),de&&a.jsx(\"div\",{className:\"w-36\",children:a.jsxs(\"select\",{value:v,onChange:R=>b(R.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\",children:[a.jsx(\"option\",{value:\"all\",children:t(\"kProfiles.allExtruders\")}),a.jsx(\"option\",{value:\"left\",children:t(\"kProfiles.leftOnly\")}),a.jsx(\"option\",{value:\"right\",children:t(\"kProfiles.rightOnly\")})]})}),a.jsx(\"div\",{className:\"w-32\",children:a.jsxs(\"select\",{value:g,onChange:R=>_(R.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\",children:[a.jsx(\"option\",{value:\"all\",children:t(\"kProfiles.allFlow\")}),a.jsx(\"option\",{value:\"hf\",children:t(\"kProfiles.hfOnly\")}),a.jsx(\"option\",{value:\"s\",children:t(\"kProfiles.sOnly\")})]})}),a.jsx(\"div\",{className:\"w-32\",children:a.jsxs(\"select\",{value:C,onChange:R=>P(R.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\",children:[a.jsx(\"option\",{value:\"name\",children:t(\"kProfiles.sortName\")}),a.jsx(\"option\",{value:\"k_value\",children:t(\"kProfiles.sortKValue\")}),a.jsx(\"option\",{value:\"filament\",children:t(\"kProfiles.sortFilament\")})]})})]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-2 mb-6\",children:[a.jsxs(De,{variant:\"secondary\",onClick:I,disabled:!ie?.profiles?.length||!n(\"kprofiles:read\"),title:n(\"kprofiles:read\")?void 0:t(\"kProfiles.permission.noExport\"),children:[a.jsx(ga,{className:\"w-4 h-4\"}),t(\"kProfiles.export\")]}),a.jsxs(De,{variant:\"secondary\",onClick:G,disabled:!n(\"kprofiles:create\"),title:n(\"kprofiles:create\")?void 0:t(\"kProfiles.permission.noImport\"),children:[a.jsx(La,{className:\"w-4 h-4\"}),t(\"kProfiles.import\")]}),a.jsx(\"div\",{className:\"flex-1\"}),N?a.jsxs(a.Fragment,{children:[a.jsxs(De,{variant:\"secondary\",onClick:V,children:[a.jsx(Ns,{className:\"w-4 h-4\"}),t(\"kProfiles.selectAll\")]}),a.jsxs(De,{variant:\"secondary\",onClick:ee,disabled:T.size===0||!n(\"kprofiles:delete\"),className:\"text-red-500 hover:bg-red-500/10\",title:n(\"kprofiles:delete\")?void 0:t(\"kProfiles.permission.noDelete\"),children:[a.jsx(en,{className:\"w-4 h-4\"}),t(\"kProfiles.delete\"),\" (\",T.size,\")\"]}),a.jsxs(De,{variant:\"secondary\",onClick:()=>{A(!1),F(new Set)},children:[a.jsx(Ht,{className:\"w-4 h-4\"}),t(\"common.cancel\")]})]}):a.jsxs(De,{variant:\"secondary\",onClick:()=>A(!0),disabled:!K.length||!n(\"kprofiles:delete\"),title:n(\"kprofiles:delete\")?void 0:t(\"kProfiles.permission.noDelete\"),children:[a.jsx(Ns,{className:\"w-4 h-4\"}),t(\"kProfiles.select\")]})]}),J?a.jsx(\"div\",{className:\"flex justify-center py-12\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):q?a.jsx(Tt,{children:a.jsxs(Mt,{className:\"py-12 text-center\",children:[a.jsx(Bd,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-4\"}),a.jsx(\"h3\",{className:\"text-lg font-semibold text-white mb-2\",children:t(\"kProfiles.printerOffline\")}),a.jsx(\"p\",{className:\"text-bambu-gray mb-4\",children:t(\"kProfiles.printerOfflineDesc\")}),a.jsxs(De,{variant:\"secondary\",onClick:()=>re(),children:[a.jsx(lr,{className:\"w-4 h-4\"}),t(\"common.refresh\")]})]})}):K.length>0?de?a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-bambu-gray mb-2 px-1\",children:t(\"kProfiles.leftExtruder\")}),a.jsx(\"div\",{className:\"space-y-1\",children:K.filter(R=>R.extruder_id===1).map(R=>a.jsx(P3,{profile:R,onEdit:()=>c(R),onCopy:()=>p(R),selectionMode:N,isSelected:T.has(Q(R)),onToggleSelect:()=>X(Q(R)),note:B(R)},Q(R)))})]}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-bambu-gray mb-2 px-1\",children:t(\"kProfiles.rightExtruder\")}),a.jsx(\"div\",{className:\"space-y-1\",children:K.filter(R=>R.extruder_id===0).map(R=>a.jsx(P3,{profile:R,onEdit:()=>c(R),onCopy:()=>p(R),selectionMode:N,isSelected:T.has(Q(R)),onToggleSelect:()=>X(Q(R)),note:B(R)},Q(R)))})]})]}):a.jsx(\"div\",{className:\"space-y-1\",children:K.map(R=>a.jsx(P3,{profile:R,onEdit:()=>c(R),onCopy:()=>p(R),selectionMode:N,isSelected:T.has(Q(R)),onToggleSelect:()=>X(Q(R)),note:B(R)},Q(R)))}):f||v!==\"all\"||g!==\"all\"?a.jsx(Tt,{children:a.jsxs(Mt,{className:\"py-12 text-center\",children:[a.jsx(Pr,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-4\"}),a.jsx(\"h3\",{className:\"text-lg font-semibold text-white mb-2\",children:t(\"kProfiles.noMatchingProfiles\")}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"kProfiles.noMatchingProfilesDesc\")})]})}):a.jsx(Tt,{children:a.jsxs(Mt,{className:\"py-12 text-center\",children:[a.jsx(Zw,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-4\"}),a.jsx(\"h3\",{className:\"text-lg font-semibold text-white mb-2\",children:t(\"kProfiles.noKProfiles\")}),a.jsx(\"p\",{className:\"text-bambu-gray mb-4\",children:t(\"kProfiles.noKProfilesDesc\",{diameter:s})}),a.jsxs(De,{onClick:()=>u(!0),children:[a.jsx(sr,{className:\"w-4 h-4\"}),t(\"kProfiles.createFirstProfile\")]})]})}),l&&r&&(()=>{const{note:R,key:ae}=le(l);return a.jsx(T3,{profile:l,printerId:r,nozzleDiameter:s,existingProfiles:W?.profiles||ie?.profiles,builtinFilaments:j,isDualNozzle:de,initialNote:R,initialNoteKey:ae,onSaveNote:he,hasPermission:n,onClose:()=>{console.log(\"[KProfiles] Edit modal onClose - refetching profiles...\"),c(null),re()},onSave:()=>{c(null),re()}})})(),d&&r&&a.jsx(T3,{printerId:r,nozzleDiameter:s,existingProfiles:W?.profiles||ie?.profiles,builtinFilaments:j,isDualNozzle:de,onSaveNote:he,hasPermission:n,onClose:()=>{u(!1),re()},onSave:()=>{u(!1),re()}}),m&&r&&a.jsx(T3,{printerId:r,nozzleDiameter:s,existingProfiles:W?.profiles||ie?.profiles,builtinFilaments:j,isDualNozzle:de,onSaveNote:he,hasPermission:n,profile:{...m,slot_id:0,name:`${m.name} (Copy)`},onClose:()=>{p(null),re()},onSave:()=>{p(null),re()}}),k&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:a.jsx(Tt,{className:\"w-full max-w-sm\",children:a.jsxs(Mt,{className:\"p-6\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-4\",children:[a.jsx(\"div\",{className:\"w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center\",children:a.jsx(en,{className:\"w-5 h-5 text-red-500\"})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white\",children:t(\"kProfiles.bulkDelete.title\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"kProfiles.bulkDelete.cannotUndo\")})]})]}),a.jsx(\"p\",{className:\"text-bambu-gray mb-6\",children:t(\"kProfiles.bulkDelete.message\",{count:T.size})}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>D(!1),disabled:H,className:\"flex-1\",children:t(\"common.cancel\")}),a.jsxs(De,{onClick:se,disabled:H,className:\"flex-1 bg-red-500 hover:bg-red-600 text-white\",children:[H?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"}),t(\"common.delete\")]})]})]})})})]})}const Ile=[\"PLA\",\"PETG\",\"PCTG\",\"ABS\",\"ASA\",\"TPU\",\"PC\",\"PA\",\"PVA\",\"HIPS\",\"PP\",\"PET\",\"NYLON\"],Int={PLA:\"E8E8E8\",PETG:\"4A90D9\",ABS:\"E67E22\",ASA:\"D35400\",TPU:\"9B59B6\",PC:\"BDC3C7\",PA:\"2ECC71\",NYLON:\"2ECC71\",PVA:\"F1C40F\",HIPS:\"95A5A6\",PP:\"ECF0F1\",PET:\"3498DB\"};function znt(t){const e=t.toUpperCase();for(const n of Ile)if(new RegExp(`\\\\b${n}\\\\b`).test(e))return n;return null}function Unt(t){const e=t.replace(/@.+$/,\"\").trim(),n=e.toUpperCase();for(const r of Ile){const i=n.indexOf(r);if(i>0){const s=e.slice(0,i).trim();if(s&&s.length>1)return s}}return null}function A3({preset:t,onDelete:e,onExpand:n,isExpanded:r}){const{t:i}=Ft(),{hasPermission:s}=kr(),o=t.filament_type||znt(t.name),l=t.filament_vendor||Unt(t.name);let c=null,d=!1;if(t.default_filament_colour)try{const u=JSON.parse(t.default_filament_colour),m=Array.isArray(u)?u[0]:u;typeof m==\"string\"&&/^#?[0-9a-fA-F]{6,8}$/.test(m.replace(\"#\",\"\"))&&(c=m.replace(\"#\",\"\").slice(0,6),d=!0)}catch{const u=t.default_filament_colour;/^#?[0-9a-fA-F]{6,8}$/.test(u.replace(\"#\",\"\"))&&(c=u.replace(\"#\",\"\").slice(0,6),d=!0)}return!c&&o&&(c=Int[o.toUpperCase()]||null),a.jsx(Tt,{className:\"bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80 transition-colors\",children:a.jsxs(Mt,{className:\"p-3\",children:[a.jsxs(\"div\",{className:\"flex items-start justify-between gap-2\",children:[a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[t.preset_type===\"filament\"&&a.jsx(\"div\",{className:`w-4 h-4 rounded-full border border-black/20 flex-shrink-0 ${!d&&!c?\"opacity-25\":d?\"\":\"opacity-50\"}`,style:{backgroundColor:c?`#${c}`:\"#666\"}}),a.jsx(\"span\",{className:\"text-sm font-medium text-white truncate\",children:t.name})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-wrap\",children:[o&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green\",children:o}),l&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:l}),a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400\",children:i(\"profiles.localProfiles.badge\")})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 flex-shrink-0\",children:[s(\"settings:update\")&&a.jsx(\"button\",{onClick:()=>e(t.id),className:\"p-1 text-bambu-gray hover:text-red-400 transition-colors\",title:i(\"profiles.localProfiles.delete\"),children:a.jsx(en,{className:\"w-3.5 h-3.5\"})}),a.jsx(\"button\",{onClick:()=>n(r?null:t.id),className:\"p-1 text-bambu-gray hover:text-white transition-colors\",children:r?a.jsx(cc,{className:\"w-3.5 h-3.5\"}):a.jsx(On,{className:\"w-3.5 h-3.5\"})})]})]}),r&&a.jsxs(\"div\",{className:\"mt-3 pt-3 border-t border-bambu-dark-tertiary text-xs space-y-1.5\",children:[o&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.filamentType\")}),a.jsx(\"span\",{className:\"text-white\",children:o})]}),l&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.vendor\")}),a.jsx(\"span\",{className:\"text-white\",children:l})]}),t.nozzle_temp_min!=null&&t.nozzle_temp_max!=null&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.nozzleTemp\")}),a.jsxs(\"span\",{className:\"text-white\",children:[t.nozzle_temp_min,\"–\",t.nozzle_temp_max,\"°C\"]})]}),t.filament_cost&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.cost\")}),a.jsx(\"span\",{className:\"text-white\",children:t.filament_cost})]}),t.filament_density&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.density\")}),a.jsxs(\"span\",{className:\"text-white\",children:[t.filament_density,\" g/cm³\"]})]}),t.pressure_advance&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.pressureAdvance\")}),a.jsx(\"span\",{className:\"text-white\",children:t.pressure_advance})]}),t.compatible_printers&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.compatiblePrinters\")}),a.jsx(\"span\",{className:\"text-white truncate ml-2\",children:(()=>{try{return JSON.parse(t.compatible_printers).join(\", \")}catch{return t.compatible_printers}})()})]}),t.inherits&&t.inherits!==t.name&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.inheritsFrom\")}),a.jsx(\"span\",{className:\"text-white truncate ml-2\",children:t.inherits})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:i(\"profiles.localProfiles.source\")}),a.jsx(\"span\",{className:\"text-white capitalize\",children:t.source})]})]})]})})}function Bnt(){const{t}=Ft(),{hasPermission:e}=kr(),n=nn(),{showToast:r}=hn(),[i,s]=w.useState(\"\"),[o,l]=w.useState(null),[c,d]=w.useState(!1),[u,m]=w.useState(null),{data:p,isLoading:f}=Xe({queryKey:[\"localPresets\"],queryFn:()=>ue.getLocalPresets()}),y=it({mutationFn:async T=>{const F=[];for(const k of Array.from(T)){const D=new FormData;D.append(\"file\",k),F.push(await ue.importLocalPresets(D))}return F},onSuccess:T=>{n.invalidateQueries({queryKey:[\"localPresets\"]});let F=0,k=0,D=0;for(const H of T)F+=H.imported,k+=H.skipped,D+=H.errors.length;F>0&&r(t(\"profiles.localProfiles.toast.importSuccess\",{count:F})),k>0&&r(t(\"profiles.localProfiles.toast.importSkipped\",{count:k}),\"warning\"),D>0&&r(t(\"profiles.localProfiles.toast.importError\",{count:D}),\"error\")},onError:T=>{r(T.message,\"error\")}}),v=it({mutationFn:T=>ue.deleteLocalPreset(T),onSuccess:()=>{n.invalidateQueries({queryKey:[\"localPresets\"]}),m(null),r(t(\"profiles.localProfiles.toast.deleted\"))}}),b=w.useCallback(T=>{!T||T.length===0||y.mutate(T)},[y]),g=w.useCallback(T=>{T.preventDefault(),d(!1),b(T.dataTransfer.files)},[b]),_=w.useCallback(T=>{if(!i)return T;const F=i.toLowerCase();return T.filter(k=>k.name.toLowerCase().includes(F)||k.filament_type?.toLowerCase().includes(F)||k.filament_vendor?.toLowerCase().includes(F))},[i]),C=w.useMemo(()=>_(p?.filament||[]),[p?.filament,_]),P=w.useMemo(()=>_(p?.printer||[]),[p?.printer,_]),N=w.useMemo(()=>_(p?.process||[]),[p?.process,_]),A=C.length+P.length+N.length;return f?a.jsx(\"div\",{className:\"flex items-center justify-center py-16\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):a.jsxs(\"div\",{className:\"space-y-6\",children:[e(\"settings:update\")&&a.jsxs(\"div\",{onDragOver:T=>{T.preventDefault(),d(!0)},onDragLeave:()=>d(!1),onDrop:g,className:`relative border-2 border-dashed rounded-lg p-6 text-center transition-colors ${c?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary hover:border-bambu-gray\"}`,children:[a.jsx(\"input\",{type:\"file\",accept:\".json,.zip,.orca_filament,.bbscfg,.bbsflmt\",multiple:!0,className:\"absolute inset-0 w-full h-full opacity-0 cursor-pointer\",onChange:T=>b(T.target.files)}),y.isPending?a.jsxs(\"div\",{className:\"flex items-center justify-center gap-2\",children:[a.jsx(ft,{className:\"w-5 h-5 text-bambu-green animate-spin\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:t(\"profiles.localProfiles.importing\")})]}):a.jsxs(a.Fragment,{children:[a.jsx(La,{className:\"w-8 h-8 text-bambu-gray mx-auto mb-2\"}),a.jsx(\"p\",{className:\"text-sm text-white font-medium\",children:t(\"profiles.localProfiles.import\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"profiles.localProfiles.importDesc\")})]})]}),A>0&&a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:i,onChange:T=>s(T.target.value),placeholder:t(\"profiles.localProfiles.search\"),className:\"w-full pl-9 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"})]}),A===0&&!f&&a.jsxs(\"div\",{className:\"text-center py-12\",children:[a.jsx(Ig,{className:\"w-12 h-12 text-bambu-gray mx-auto mb-3 opacity-50\"}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"profiles.localProfiles.noPresets\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray/60 mt-1\",children:t(\"profiles.localProfiles.importDesc\")})]}),A>0&&a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\",children:[C.length>0&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3\",children:[a.jsx(HP,{className:\"w-4 h-4 text-bambu-green\"}),a.jsx(\"h3\",{className:\"text-sm font-medium text-white\",children:t(\"profiles.localProfiles.filament\")}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[\"(\",C.length,\")\"]})]}),a.jsx(\"div\",{className:\"space-y-2\",children:C.map(T=>a.jsx(A3,{preset:T,onDelete:F=>m(F),onExpand:l,isExpanded:o===T.id},T.id))})]}),N.length>0&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3\",children:[a.jsx(da,{className:\"w-4 h-4 text-blue-400\"}),a.jsx(\"h3\",{className:\"text-sm font-medium text-white\",children:t(\"profiles.localProfiles.process\")}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[\"(\",N.length,\")\"]})]}),a.jsx(\"div\",{className:\"space-y-2\",children:N.map(T=>a.jsx(A3,{preset:T,onDelete:F=>m(F),onExpand:l,isExpanded:o===T.id},T.id))})]}),P.length>0&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3\",children:[a.jsx(Ud,{className:\"w-4 h-4 text-orange-400\"}),a.jsx(\"h3\",{className:\"text-sm font-medium text-white\",children:t(\"profiles.localProfiles.printer\")}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[\"(\",P.length,\")\"]})]}),a.jsx(\"div\",{className:\"space-y-2\",children:P.map(T=>a.jsx(A3,{preset:T,onDelete:F=>m(F),onExpand:l,isExpanded:o===T.id},T.id))})]})]}),u!==null&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-6 max-w-sm mx-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3\",children:[a.jsx(ei,{className:\"w-5 h-5 text-red-400\"}),a.jsx(\"h3\",{className:\"text-white font-medium\",children:t(\"profiles.localProfiles.deleteConfirmTitle\")})]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-4\",children:t(\"profiles.localProfiles.deleteConfirm\")}),a.jsxs(\"div\",{className:\"flex justify-end gap-2\",children:[a.jsx(De,{variant:\"secondary\",size:\"sm\",onClick:()=>m(null),children:t(\"profiles.localProfiles.cancel\")}),a.jsxs(De,{variant:\"danger\",size:\"sm\",onClick:()=>v.mutate(u),disabled:v.isPending,children:[v.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"}),t(\"profiles.localProfiles.delete\")]})]})]})})]})}function b5(t,e){const n=`${t} ${e||\"\"}`,r=n.match(/@?\\s*(?:BBL\\s+)?(?:Bambu\\s+Lab\\s+)?([XPAH][1-9][A-Z]?(?:\\s*(?:Carbon|mini))?|H2D)/i),i=r?r[1].trim():null,s=n.match(/(\\d+\\.?\\d*)\\s*(?:mm\\s*)?nozzle|nozzle\\s*(\\d+\\.?\\d*)/i),o=s?(s[1]||s[2])+\"mm\":null,l=n.match(/(\\d+\\.?\\d*)mm\\s*(?:Standard|Fine|Extra Fine|Draft|Quality)?/i),c=l?l[1]+\"mm\":null,d=n.match(/\\b(PLA|PETG|ABS|ASA|TPU|PC|PA|PVA|HIPS|PP|PET(?:-?CF)?|PA(?:-?CF)?|PLA(?:-?CF)?)\\b/i),u=d?d[1].toUpperCase():null;return{printer:i,nozzle:o,layerHeight:c,filamentType:u}}function Kw(t){return/^(P[FPM]US|PF\\d|PP\\d)/.test(t)}function Hnt({onSuccess:t,t:e}){const{showToast:n}=hn(),[r,i]=w.useState(\"email\"),[s,o]=w.useState(\"\"),[l,c]=w.useState(\"\"),[d,u]=w.useState(\"\"),[m,p]=w.useState(\"\"),[f,y]=w.useState(\"global\"),[v,b]=w.useState(null),[g,_]=w.useState(null),C=it({mutationFn:()=>ue.cloudLogin(s,l,f),onSuccess:F=>{F.success?(n(e(\"profiles.login.toast.loggedIn\")),t()):F.needs_verification?(b(F.verification_type||\"email\"),_(F.tfa_key||null),F.verification_type===\"totp\"?n(e(\"profiles.login.toast.enterTotp\")):n(e(\"profiles.login.toast.codeSent\")),i(\"code\")):n(F.message,\"error\")},onError:F=>n(F.message,\"error\")}),P=it({mutationFn:()=>ue.cloudVerify(s,d,g||void 0,f),onSuccess:F=>{F.success?(n(e(\"profiles.login.toast.loggedIn\")),t()):n(F.message,\"error\")},onError:F=>n(F.message,\"error\")}),N=it({mutationFn:()=>ue.cloudSetToken(m,f),onSuccess:()=>{n(e(\"profiles.login.toast.tokenSet\")),t()},onError:F=>n(F.message,\"error\")}),A=F=>{F.preventDefault(),r===\"email\"?C.mutate():r===\"code\"?P.mutate():r===\"token\"&&N.mutate()},T=C.isPending||P.isPending||N.isPending;return a.jsx(Tt,{className:\"max-w-md mx-auto\",children:a.jsxs(Mt,{children:[a.jsxs(\"div\",{className:\"text-center mb-6\",children:[a.jsx(\"div\",{className:\"inline-flex items-center justify-center w-12 h-12 rounded-xl bg-bambu-green/20 mb-3\",children:a.jsx(vy,{className:\"w-6 h-6 text-bambu-green\"})}),a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:e(\"profiles.login.title\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:e(\"profiles.login.subtitle\")})]}),a.jsxs(\"form\",{onSubmit:A,className:\"space-y-4\",children:[r===\"email\"&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:e(\"profiles.login.email\")}),a.jsx(\"input\",{type:\"email\",value:s,onChange:F=>o(F.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\",placeholder:\"your@email.com\",required:!0})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:e(\"profiles.login.password\")}),a.jsx(\"input\",{type:\"password\",value:l,onChange:F=>c(F.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\",placeholder:\"••••••••\",required:!0})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:e(\"profiles.login.region\")}),a.jsxs(\"select\",{value:f,onChange:F=>y(F.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\",children:[a.jsx(\"option\",{value:\"global\",children:e(\"profiles.login.regionGlobal\")}),a.jsx(\"option\",{value:\"china\",children:e(\"profiles.login.regionChina\")})]})]})]}),r===\"code\"&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:e(v===\"totp\"?\"profiles.login.totpCode\":\"profiles.login.verificationCode\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:v===\"totp\"?e(\"profiles.login.enterTotpHint\"):e(\"profiles.login.checkEmail\",{email:s})}),a.jsx(\"input\",{type:\"text\",value:d,onChange:F=>u(F.target.value.replace(/\\D/g,\"\").slice(0,6)),className:\"w-full px-3 py-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-center text-2xl tracking-widest font-mono focus:border-bambu-green focus:outline-none\",placeholder:\"000000\",maxLength:6,required:!0})]}),r===\"token\"&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:e(\"profiles.login.accessToken\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:e(\"profiles.login.accessTokenHint\")}),a.jsx(\"textarea\",{value:m,onChange:F=>p(F.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none resize-none\",placeholder:\"eyJ...\",rows:4,required:!0})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:e(\"profiles.login.region\")}),a.jsxs(\"select\",{value:f,onChange:F=>y(F.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\",children:[a.jsx(\"option\",{value:\"global\",children:e(\"profiles.login.regionGlobal\")}),a.jsx(\"option\",{value:\"china\",children:e(\"profiles.login.regionChina\")})]})]})]}),a.jsxs(\"div\",{className:\"flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center\",children:[r===\"code\"&&a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:()=>i(\"email\"),className:\"flex-1\",children:e(\"profiles.login.back\")}),a.jsxs(De,{type:\"submit\",disabled:T,className:\"flex-1\",children:[T?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(kH,{className:\"w-4 h-4\"}),e(r===\"email\"?\"profiles.login.loginButton\":r===\"code\"?\"profiles.login.verifyButton\":\"profiles.login.setTokenButton\")]})]}),r===\"email\"&&a.jsx(\"div\",{className:\"pt-4 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"button\",{type:\"button\",onClick:()=>i(\"token\"),className:\"text-sm text-bambu-gray hover:text-white flex items-center gap-2 transition-colors\",children:[a.jsx(no,{className:\"w-4 h-4\"}),e(\"profiles.login.useToken\")]})}),r===\"token\"&&a.jsx(\"div\",{className:\"pt-4 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"button\",{type:\"button\",onClick:()=>i(\"email\"),className:\"text-sm text-bambu-gray hover:text-white flex items-center gap-2 transition-colors\",children:[a.jsx(kH,{className:\"w-4 h-4\"}),e(\"profiles.login.useEmail\")]})})]})]})})}function cx({label:t,value:e,options:n,onChange:r}){const[i,s]=w.useState(!1),o=n.find(l=>l.value===e);return a.jsxs(\"div\",{className:\"relative\",children:[a.jsxs(\"button\",{onClick:()=>s(!i),className:\"flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white hover:border-bambu-gray-dark transition-colors\",children:[a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[t,\":\"]}),a.jsx(\"span\",{children:o?.label||\"All\"}),a.jsx(On,{className:`w-4 h-4 text-bambu-gray transition-transform ${i?\"rotate-180\":\"\"}`})]}),i&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>s(!1)}),a.jsx(\"div\",{className:\"absolute top-full left-0 mt-1 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 py-1 max-h-60 overflow-y-auto\",children:n.map(l=>a.jsxs(\"button\",{onClick:()=>{r(l.value),s(!1)},className:`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-bambu-dark-tertiary transition-colors ${e===l.value?\"text-bambu-green\":\"text-white\"}`,children:[a.jsx(\"span\",{children:l.label}),l.count!==void 0&&a.jsx(\"span\",{className:\"text-bambu-gray text-xs\",children:l.count})]},l.value))})]})]})}function qnt(){const[t,e]=w.useState(!1);w.useEffect(()=>{const r=()=>{e(window.scrollY>300)};return window.addEventListener(\"scroll\",r),()=>window.removeEventListener(\"scroll\",r)},[]);const n=()=>{window.scrollTo({top:0,behavior:\"smooth\"})};return t?a.jsx(\"button\",{onClick:n,className:\"fixed bottom-6 right-6 p-3 bg-bambu-green hover:bg-bambu-green-light text-white rounded-full shadow-lg shadow-bambu-green/25 transition-all z-40\",\"aria-label\":\"Scroll to top\",children:a.jsx(fp,{className:\"w-5 h-5\"})}):null}function j3({setting:t,onClick:e,onDuplicate:n,compareMode:r,isCompareSelected:i,compareIndex:s,compareDisabled:o,t:l}){const c=b5(t.name),d=Kw(t.setting_id);return a.jsxs(\"div\",{className:\"flex items-center gap-2 group\",children:[a.jsx(\"button\",{onClick:e,disabled:o,className:`flex-1 text-left px-3 py-2 rounded transition-colors ${i?\"bg-blue-500/20 border border-blue-500/50\":o?\"bg-bambu-dark/50 opacity-40 cursor-not-allowed\":\"bg-bambu-dark hover:bg-bambu-dark-tertiary\"} ${r&&!o?\"cursor-pointer\":\"\"}`,children:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[i&&s!==void 0&&a.jsx(\"span\",{className:\"flex-shrink-0 w-5 h-5 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium\",children:s+1}),!i&&d&&a.jsx(\"span\",{className:\"flex-shrink-0 w-1.5 h-1.5 rounded-full bg-bambu-green\",title:l(\"profiles.presets.myPreset\")}),a.jsx(\"span\",{className:\"text-white text-sm truncate flex-1\",title:t.name,children:t.name}),c.filamentType&&t.type===\"filament\"&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray whitespace-nowrap\",children:c.filamentType}),c.layerHeight&&t.type===\"process\"&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray whitespace-nowrap\",children:c.layerHeight}),c.printer&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray whitespace-nowrap\",children:c.printer})]})}),a.jsx(\"button\",{onClick:u=>{u.stopPropagation(),n()},className:\"opacity-0 group-hover:opacity-100 text-bambu-gray hover:text-white transition-all p-1\",title:l(\"profiles.presets.duplicate\"),children:a.jsx(qs,{className:\"w-4 h-4\"})})]})}function lO(t,e=0){const n=\"  \".repeat(e);if(t===null)return\"null\";if(t===void 0)return\"undefined\";if(typeof t==\"string\"){if(t.includes(\"\\\\n\")||t.includes(`\n`)){const i=t.replace(/\\\\n/g,`\n`).replace(/\\\\\"/g,'\"').replace(/\\\\t/g,\"\t\").split(`\n`);if(i.length>1)return`\"\"\"\n`+i.map(s=>n+\"  \"+s).join(`\n`)+`\n`+n+'\"\"\"'}return JSON.stringify(t)}if(typeof t==\"number\"||typeof t==\"boolean\")return String(t);if(Array.isArray(t))return t.length===0?\"[]\":`[\n`+t.map(i=>n+\"  \"+lO(i,e+1)).join(`,\n`)+`\n`+n+\"]\";if(typeof t==\"object\"){const r=Object.entries(t);return r.length===0?\"{}\":`{\n`+r.map(([s,o])=>n+\"  \"+JSON.stringify(s)+\": \"+lO(o,e+1)).join(`,\n`)+`\n`+n+\"}\"}return String(t)}function $nt({setting:t,onClose:e,onDeleted:n,onDuplicate:r,onEdit:i,hasPermission:s,t:o}){const{showToast:l}=hn(),c=nn(),[d,u]=w.useState(!1),{data:m,isLoading:p}=Xe({queryKey:[\"cloudSettingDetail\",t.setting_id],queryFn:()=>ue.getCloudSettingDetail(t.setting_id)}),f=it({mutationFn:()=>ue.deleteCloudSetting(t.setting_id),onSuccess:()=>{l(o(\"profiles.presets.toast.deleted\")),c.invalidateQueries({queryKey:[\"cloudSettings\"]}),n()},onError:b=>l(b.message,\"error\")}),y=Kw(t.setting_id),v=b5(t.name,m?.setting?.inherits);return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",children:a.jsx(Tt,{className:\"w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden\",children:a.jsxs(Mt,{className:\"p-0 flex flex-col min-h-0 flex-1\",children:[a.jsxs(\"div\",{className:\"flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"h2\",{className:\"text-xl font-semibold text-white truncate\",children:t.name}),y&&a.jsx(\"span\",{className:\"px-2 py-0.5 text-xs font-medium bg-bambu-green/20 text-bambu-green rounded-full\",children:o(\"profiles.presets.editable\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-1 text-sm text-bambu-gray\",children:[a.jsx(\"span\",{className:\"capitalize\",children:o(`profiles.presets.types.${t.type}`)}),v.printer&&a.jsxs(a.Fragment,{children:[a.jsx(\"span\",{children:\"•\"}),a.jsx(\"span\",{children:v.printer})]})]})]}),a.jsx(\"button\",{onClick:e,className:\"p-2 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsx(\"div\",{className:\"flex-1 min-h-0 overflow-y-auto p-4\",children:p?a.jsx(\"div\",{className:\"flex items-center justify-center py-16\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):m?a.jsx(\"pre\",{className:\"text-xs text-bambu-gray font-mono whitespace-pre-wrap break-all bg-bambu-dark p-4 rounded-lg border border-bambu-dark-tertiary overflow-x-auto max-w-full\",children:lO(m)}):a.jsx(\"div\",{className:\"text-center py-16 text-bambu-gray\",children:o(\"profiles.presets.failedToLoadDetails\")})}),d?a.jsxs(\"div\",{className:\"flex-shrink-0 p-4 border-t border-bambu-dark-tertiary bg-red-500/5\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3 text-red-400\",children:[a.jsx(Dn,{className:\"w-5 h-5\"}),a.jsx(\"span\",{className:\"font-medium\",children:o(\"profiles.presets.deleteConfirm\")})]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-4\",children:o(\"profiles.presets.deleteWarning\",{name:t.name})}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>u(!1),disabled:f.isPending,className:\"flex-1\",children:o(\"common.cancel\")}),a.jsxs(De,{variant:\"danger\",onClick:()=>f.mutate(),disabled:f.isPending,className:\"flex-1\",children:[f.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(en,{className:\"w-4 h-4\"}),o(\"common.delete\")]})]})]}):a.jsx(\"div\",{className:\"flex-shrink-0 p-4 border-t border-bambu-dark-tertiary\",children:a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:e,className:\"flex-1\",children:o(\"common.close\")}),a.jsxs(De,{variant:\"secondary\",onClick:r,disabled:!s(\"cloud:auth\"),title:s(\"cloud:auth\")?void 0:o(\"profiles.presets.noDuplicatePermission\"),children:[a.jsx(qs,{className:\"w-4 h-4\"}),o(\"profiles.presets.duplicate\")]}),y&&a.jsxs(a.Fragment,{children:[a.jsxs(De,{variant:\"secondary\",onClick:i,disabled:p||!m||!s(\"cloud:auth\"),title:s(\"cloud:auth\")?void 0:o(\"profiles.presets.noEditPermission\"),children:[a.jsx(ci,{className:\"w-4 h-4\"}),o(\"common.edit\")]}),a.jsx(De,{variant:\"danger\",onClick:()=>u(!0),disabled:!s(\"cloud:auth\"),title:s(\"cloud:auth\")?void 0:o(\"profiles.presets.noDeletePermission\"),children:a.jsx(en,{className:\"w-4 h-4\"})})]})]})})]})})})}function zle(){try{const t=localStorage.getItem(\"bambusy_preset_templates\");return t?JSON.parse(t):[]}catch{return[]}}function Ule(t){localStorage.setItem(\"bambusy_preset_templates\",JSON.stringify(t))}function Vnt({onClose:t,onApply:e,t:n}){const{showToast:r}=hn(),[i,s]=w.useState(zle),[o,l]=w.useState(\"all\"),[c,d]=w.useState(null),[u,m]=w.useState(\"\"),[p,f]=w.useState(\"\"),[y,v]=w.useState(\"{}\"),[b,g]=w.useState(null),[_,C]=w.useState(null),P=o===\"all\"?i:i.filter(Q=>Q.type===o),N=Q=>{s(Q),Ule(Q)},A=Q=>{const L=i.filter(te=>te.id!==Q);N(L),C(null),r(n(\"profiles.templates.toast.deleted\"))},T=Q=>{d(Q.id),m(Q.name),f(Q.description),v(JSON.stringify(Q.settings,null,2)),g(null)},F=()=>{if(!(!c||!u.trim()))try{const Q=JSON.parse(y),L=i.map(te=>te.id===c?{...te,name:u.trim(),description:p.trim(),settings:Q}:te);N(L),d(null),r(n(\"profiles.templates.toast.updated\"))}catch(Q){g(Q.message)}},k=()=>{d(null),m(\"\"),f(\"\"),v(\"{}\"),g(null)},D=Q=>{const L=i.map(te=>te.id===Q?{...te,showInModal:!te.showInModal}:te);N(L)},H={filament:{label:n(\"profiles.presets.types.filament\"),icon:HP,color:\"text-amber-400\"},print:{label:n(\"profiles.presets.types.process\"),icon:Ud,color:\"text-blue-400\"},printer:{label:n(\"profiles.presets.types.printer\"),icon:Er,color:\"text-purple-400\"}},z=_?i.find(Q=>Q.id===_):null;return w.useEffect(()=>{const Q=L=>{L.key===\"Escape\"&&(_?C(null):c?k():t())};return window.addEventListener(\"keydown\",Q),()=>window.removeEventListener(\"keydown\",Q)},[_,c,t]),a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:[z&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-60\",children:a.jsx(Tt,{className:\"w-full max-w-md\",children:a.jsxs(Mt,{className:\"p-6\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-4\",children:[a.jsx(\"div\",{className:\"p-2 bg-red-500/20 rounded-lg\",children:a.jsx(Dn,{className:\"w-6 h-6 text-red-400\"})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white\",children:n(\"profiles.templates.deleteTitle\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"profiles.templates.deleteWarning\")})]})]}),a.jsx(\"p\",{className:\"text-white mb-6\",children:n(\"profiles.templates.deleteConfirm\",{name:z.name})}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>C(null),className:\"flex-1\",children:n(\"common.cancel\")}),a.jsxs(De,{onClick:()=>A(_),className:\"flex-1 bg-red-500 hover:bg-red-600\",children:[a.jsx(en,{className:\"w-4 h-4\"}),n(\"common.delete\")]})]})]})})}),a.jsx(Tt,{className:\"w-full max-w-2xl max-h-[80vh] flex flex-col\",children:a.jsxs(Mt,{className:\"p-0 flex flex-col h-full\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx($x,{className:\"w-5 h-5 text-amber-400\"}),n(\"profiles.templates.title\")]}),a.jsx(\"button\",{onClick:t,className:\"text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 p-4 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:n(\"profiles.templates.typeFilter\")}),[\"all\",\"filament\",\"print\",\"printer\"].map(Q=>a.jsx(\"button\",{onClick:()=>l(Q),className:`px-3 py-1 text-sm rounded-lg transition-colors ${o===Q?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,children:Q===\"all\"?n(\"common.all\"):H[Q].label},Q))]}),a.jsx(\"div\",{className:\"flex-1 overflow-y-auto p-4\",children:P.length===0?a.jsxs(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:[a.jsx($x,{className:\"w-12 h-12 mx-auto mb-4 opacity-30\"}),a.jsx(\"p\",{children:n(\"profiles.templates.noTemplates\")}),a.jsx(\"p\",{className:\"text-sm mt-1\",children:n(\"profiles.templates.createFirst\")})]}):a.jsx(\"div\",{className:\"space-y-2\",children:P.map(Q=>{const L=H[Q.type],te=L.icon;return c===Q.id?a.jsxs(\"div\",{className:\"p-4 bg-bambu-dark rounded-lg border border-bambu-green\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3 mb-3\",children:[a.jsx(\"input\",{type:\"text\",value:u,onChange:ie=>m(ie.target.value),placeholder:n(\"profiles.templates.namePlaceholder\"),className:\"px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\",autoFocus:!0}),a.jsx(\"input\",{type:\"text\",value:p,onChange:ie=>f(ie.target.value),placeholder:n(\"profiles.templates.descriptionPlaceholder\"),className:\"px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"mb-3\",children:[a.jsx(\"label\",{className:\"text-xs text-bambu-gray mb-1 block\",children:n(\"profiles.templates.settingsJson\")}),a.jsx(\"textarea\",{value:y,onChange:ie=>{v(ie.target.value),g(null)},rows:6,className:`w-full px-3 py-2 bg-bambu-dark-secondary border rounded text-white text-sm font-mono focus:outline-none ${b?\"border-red-500\":\"border-bambu-dark-tertiary focus:border-bambu-green\"}`}),b&&a.jsx(\"p\",{className:\"text-xs text-red-400 mt-1\",children:b})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(De,{size:\"sm\",onClick:F,disabled:!u.trim(),children:[a.jsx(_s,{className:\"w-4 h-4\"}),\"Save\"]}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:k,children:\"Cancel\"})]})]},Q.id):a.jsxs(\"div\",{className:\"flex items-center gap-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary hover:border-bambu-gray-dark transition-colors\",children:[a.jsx(te,{className:`w-5 h-5 ${L.color} flex-shrink-0`}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm font-medium text-white\",children:Q.name}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray truncate\",children:Q.description})]}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray-dark px-2 py-1 bg-bambu-dark-secondary rounded\",children:n(\"profiles.templates.fieldsCount\",{count:Object.keys(Q.settings).length})}),a.jsx(\"button\",{onClick:()=>D(Q.id),className:`p-1 transition-colors ${Q.showInModal?\"text-bambu-green hover:text-bambu-green/70\":\"text-bambu-gray hover:text-white\"}`,title:Q.showInModal?n(\"profiles.templates.shownInModals\"):n(\"profiles.templates.hiddenInModals\"),children:Q.showInModal?a.jsx(yl,{className:\"w-4 h-4\"}):a.jsx(Og,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>e(Q),className:\"px-3 py-1 text-xs bg-bambu-green/20 text-bambu-green rounded hover:bg-bambu-green/30 transition-colors\",children:n(\"profiles.templates.apply\")}),a.jsx(\"button\",{onClick:()=>T(Q),className:\"p-1 text-bambu-gray hover:text-white\",title:n(\"common.edit\"),children:a.jsx(ci,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>C(Q.id),className:\"p-1 text-bambu-gray hover:text-red-400\",title:n(\"common.delete\"),children:a.jsx(en,{className:\"w-4 h-4\"})})]},Q.id)})})})]})})]})}function Ble({onClose:t,leftPreset:e,rightPreset:n,leftLabel:r,rightLabel:i,t:s}){const[o,l]=w.useState(\"changes\"),[c,d]=w.useState(\"\"),u=w.useMemo(()=>{const y=new Set([...Object.keys(e),...Object.keys(n)]),v=[];for(const b of y){if(b===\"inherits\"||b===\"version\")continue;const g=e[b],_=n[b],C=b in e,P=b in n;let N;!C&&P?N=\"added\":C&&!P?N=\"removed\":JSON.stringify(g)!==JSON.stringify(_)?N=\"changed\":N=\"same\",v.push({key:b,left:g,right:_,status:N})}return v.sort((b,g)=>{const _={changed:0,added:1,removed:2,same:3};return _[b.status]!==_[g.status]?_[b.status]-_[g.status]:b.key.localeCompare(g.key)})},[e,n]),m=w.useMemo(()=>{let y=[...u];if(o===\"changes\"&&(y=y.filter(v=>v.status!==\"same\")),c){const v=c.toLowerCase();y=y.filter(b=>b.key.toLowerCase().includes(v)||String(b.left).toLowerCase().includes(v)||String(b.right).toLowerCase().includes(v))}return y},[u,o,c]),p=w.useMemo(()=>({added:u.filter(y=>y.status===\"added\").length,removed:u.filter(y=>y.status===\"removed\").length,changed:u.filter(y=>y.status===\"changed\").length,same:u.filter(y=>y.status===\"same\").length}),[u]),f=y=>{if(y===void 0)return\"—\";if(y===null)return\"null\";if(Array.isArray(y))return y.length===0?\"[]\":y.length===1?String(y[0]):y.join(\", \");if(typeof y==\"object\")return JSON.stringify(y);const v=String(y);if(v.includes(\"\\\\n\")||v.length>100){const b=v.split(\"\\\\n\").length;if(b>1)return`[${b} lines of G-code/script]`;if(v.length>100)return v.substring(0,100)+\"…\"}return v};return w.useEffect(()=>{const y=v=>{v.key===\"Escape\"&&t()};return window.addEventListener(\"keydown\",y),()=>window.removeEventListener(\"keydown\",y)},[t]),a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsx(Tt,{className:\"w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden\",children:a.jsxs(Mt,{className:\"p-0 flex flex-col min-h-0 flex-1\",children:[a.jsxs(\"div\",{className:\"flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(Cx,{className:\"w-5 h-5 text-blue-400\"}),s(\"profiles.diff.title\")]}),a.jsx(\"button\",{onClick:t,className:\"text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"flex-shrink-0 grid grid-cols-2 gap-4 p-4 border-b border-bambu-dark-tertiary bg-bambu-dark\",children:[a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:s(\"profiles.diff.left\")}),a.jsx(\"p\",{className:\"text-white font-medium truncate\",children:r})]}),a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:s(\"profiles.diff.right\")}),a.jsx(\"p\",{className:\"text-white font-medium truncate\",children:i})]})]}),a.jsxs(\"div\",{className:\"flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-4 text-sm\",children:[a.jsxs(\"span\",{className:\"flex items-center gap-1 text-green-400\",children:[a.jsx(sr,{className:\"w-3.5 h-3.5\"}),p.added,\" \",s(\"profiles.diff.added\")]}),a.jsxs(\"span\",{className:\"flex items-center gap-1 text-red-400\",children:[a.jsx(_N,{className:\"w-3.5 h-3.5\"}),p.removed,\" \",s(\"profiles.diff.removed\")]}),a.jsxs(\"span\",{className:\"flex items-center gap-1 text-amber-400\",children:[a.jsx(rF,{className:\"w-3.5 h-3.5\"}),p.changed,\" \",s(\"profiles.diff.changed\")]}),a.jsxs(\"span\",{className:\"flex items-center gap-1 text-bambu-gray\",children:[a.jsx(AM,{className:\"w-3.5 h-3.5\"}),p.same,\" \",s(\"profiles.diff.same\")]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:c,onChange:y=>d(y.target.value),placeholder:s(\"profiles.diff.searchFields\"),className:\"pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none w-48\"})]}),p.same>0&&a.jsxs(\"div\",{className:\"flex rounded overflow-hidden border border-bambu-dark-tertiary\",children:[a.jsx(\"button\",{onClick:()=>l(\"changes\"),className:`px-3 py-1.5 text-sm transition-colors ${o===\"changes\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,children:s(\"profiles.diff.changes\")}),a.jsx(\"button\",{onClick:()=>l(\"all\"),className:`px-3 py-1.5 text-sm transition-colors ${o===\"all\"?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,children:s(\"common.all\")})]})]})]}),a.jsx(\"div\",{className:\"flex-1 min-h-0 overflow-y-auto\",children:m.length===0?a.jsxs(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:[a.jsx(AM,{className:\"w-12 h-12 mx-auto mb-4 opacity-30\"}),a.jsx(\"p\",{children:s(o===\"changes\"?\"profiles.diff.noDifferences\":\"profiles.diff.noFieldsMatch\")})]}):a.jsxs(\"table\",{className:\"w-full\",children:[a.jsx(\"thead\",{className:\"sticky top-0 bg-bambu-dark-secondary\",children:a.jsxs(\"tr\",{className:\"text-sm text-bambu-gray border-b border-bambu-dark-tertiary\",children:[a.jsx(\"th\",{className:\"text-left p-3 w-1/3\",children:s(\"profiles.diff.field\")}),a.jsx(\"th\",{className:\"text-left p-3 w-1/3\",children:r}),a.jsx(\"th\",{className:\"text-left p-3 w-1/3\",children:i})]})}),a.jsx(\"tbody\",{children:m.map(y=>{const v={added:\"bg-green-500/10\",removed:\"bg-red-500/10\",changed:\"bg-amber-500/10\",same:\"\"}[y.status],b={added:a.jsx(sr,{className:\"w-3.5 h-3.5 text-green-400\"}),removed:a.jsx(_N,{className:\"w-3.5 h-3.5 text-red-400\"}),changed:a.jsx(rF,{className:\"w-3.5 h-3.5 text-amber-400\"}),same:a.jsx(AM,{className:\"w-3.5 h-3.5 text-bambu-gray-dark\"})}[y.status];return a.jsxs(\"tr\",{className:`border-b border-bambu-dark-tertiary ${v}`,children:[a.jsx(\"td\",{className:\"p-3\",children:a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[b,a.jsx(\"span\",{className:\"text-sm text-white font-mono\",children:y.key})]})}),a.jsx(\"td\",{className:\"p-3\",children:a.jsx(\"span\",{className:`text-sm font-mono break-all ${y.status===\"removed\"?\"text-red-300\":y.status===\"changed\"?\"text-white\":\"text-bambu-gray\"}`,children:f(y.left)})}),a.jsx(\"td\",{className:\"p-3\",children:a.jsx(\"span\",{className:`text-sm font-mono break-all ${y.status===\"added\"?\"text-green-300\":y.status===\"changed\"?\"text-white\":\"text-bambu-gray\"}`,children:f(y.right)})})]},y.key)})})]})})]})})})}function Gnt({onClose:t,initialData:e,allPresets:n,t:r}){const{showToast:i}=hn(),s=nn(),o=!!e?.setting_id,[l,c]=w.useState(\"common\"),[d,u]=w.useState(e?.type||\"filament\"),[m,p]=w.useState(e?.name?o?e.name:`${e.name} (Copy)`:\"\"),[f,y]=w.useState(e?.base_id||\"\"),[v,b]=w.useState(\"\"),[g,_]=w.useState(e?.setting||{inherits:\"\"}),[C,P]=w.useState(JSON.stringify(e?.setting||{inherits:\"\"},null,2)),[N,A]=w.useState(null),[T,F]=w.useState(\"\"),[k,D]=w.useState(!1),[H,z]=w.useState(\"\"),[Q,L]=w.useState(!1),[te,ie]=w.useState(zle),[J,oe]=w.useState(!1),[fe,re]=w.useState(\"\"),[W,ne]=w.useState(\"\"),[me,be]=w.useState(!0),[Ce,q]=w.useState(null),[Y,E]=w.useState(!1),j=w.useMemo(()=>({filament:n.filament,print:n.process,printer:n.printer})[d]||[],[n,d]),O=w.useMemo(()=>j.filter(Re=>Kw(Re.setting_id)),[j]),{data:K}=Xe({queryKey:[\"cloudFields\",d],queryFn:()=>ue.getCloudFields(d===\"print\"?\"process\":d),staleTime:1e3*60*60}),{data:U}=Xe({queryKey:[\"allPresetDetails\",d,O.map(Re=>Re.setting_id).join(\",\")],queryFn:async()=>{const Re={};for(let Ye=0;Ye<O.length;Ye+=5){const tt=O.slice(Ye,Ye+5);(await Promise.all(tt.map(async Fe=>{try{const we=await ue.getCloudSettingDetail(Fe.setting_id);return{id:Fe.setting_id,detail:we}}catch{return null}}))).forEach(Fe=>{Fe&&(Re[Fe.id]=Fe.detail)})}return Re},enabled:O.length>0,staleTime:1e3*60*10}),{data:de,isLoading:I}=Xe({queryKey:[\"cloudSettingDetail\",f],queryFn:()=>ue.getCloudSettingDetail(f),enabled:!!f});w.useEffect(()=>{l!==\"json\"&&P(JSON.stringify(g,null,2))},[g,l]);const G=w.useMemo(()=>({filament:n.filament,print:n.process,printer:n.printer}[d]||[]).filter($e=>!Kw($e.setting_id)).sort(($e,Ye)=>$e.name.localeCompare(Ye.name)),[n,d]);w.useEffect(()=>{if(!f)return;const Re=G.find($e=>$e.setting_id===f);Re&&(b(Re.name),o||(_({inherits:Re.name}),P(JSON.stringify({inherits:Re.name},null,2))))},[f,G,o]);const X=w.useMemo(()=>{const Re=K?.fields||[],$e=new Set(Re.map(Fe=>Fe.key)),Ye=new Set,tt=new Set([\"inherits\",\"updated_time\",\"compatible_printers\",\"compatible_prints\"]);U&&Object.values(U).forEach(Fe=>{Fe?.setting&&Object.keys(Fe.setting).forEach(we=>{!$e.has(we)&&!tt.has(we)&&Ye.add(we)})}),Object.keys(g).forEach(Fe=>{!$e.has(Fe)&&!tt.has(Fe)&&Ye.add(Fe)});const pe=Array.from(Ye).sort().map(Fe=>({key:Fe,label:Fe.replace(/_/g,\" \").replace(/\\b\\w/g,we=>we.toUpperCase()),type:\"text\",category:\"discovered\",description:r(\"profiles.presets.discoveredFromPresets\")}));return[...Re,...pe]},[K,U,g,r]),V=X.filter(Re=>Re.label.toLowerCase().includes(T.toLowerCase())||Re.key.toLowerCase().includes(T.toLowerCase())),ee=()=>{if(H.trim()){const Re=H.trim().toLowerCase().replace(/\\s+/g,\"_\");se(Re,\"\"),z(\"\"),L(!1),i(r(\"profiles.presets.toast.fieldAdded\",{key:Re}))}},se=(Re,$e)=>{_(Ye=>{const tt={...Ye};return $e===\"\"||$e===void 0?delete tt[Re]:tt[Re]=$e,tt})},ge=Re=>{_($e=>({...$e,...Re.settings})),q(Re.name),i(r(\"profiles.templates.toast.applied\"))},he=()=>{if(!fe.trim())return;const Re={...g};if(delete Re.inherits,Object.keys(Re).length===0){i(r(\"profiles.presets.noOverridesToSave\"),\"error\");return}const $e={id:Date.now().toString(),name:fe.trim(),description:W.trim()||r(\"profiles.presets.customTemplate\"),type:d,settings:Re,showInModal:me},Ye=[...te,$e];ie(Ye),Ule(Ye),oe(!1),re(\"\"),ne(\"\"),be(!0),i(r(\"profiles.templates.toast.created\"))},le=w.useMemo(()=>te.filter(Re=>Re.type===d&&Re.showInModal),[d,te]),B=Re=>{P(Re);try{const $e=JSON.parse(Re);_($e),A(null)}catch($e){A($e.message)}},R=Re=>{Re.preventDefault(),D(!1);const $e=Re.dataTransfer.files[0];if($e&&$e.name.endsWith(\".json\")){const Ye=new FileReader;Ye.onload=tt=>{try{const pe=tt.target?.result,Fe=JSON.parse(pe),we=Fe.setting||Fe;_(Ve=>({...Ve,...we})),P(JSON.stringify({...g,...we},null,2)),i(r(\"profiles.presets.fileImported\"))}catch{i(r(\"profiles.presets.invalidJsonFile\"),\"error\")}},Ye.readAsText($e)}},ae=it({mutationFn:()=>{const Re={...g},$e=d===\"filament\"?\"filament_settings_id\":d===\"print\"?\"print_settings_id\":\"printer_settings_id\";Re[$e]=`\"${m}\"`;const Ye={type:d,name:m,base_id:f,setting:Re};return ue.createCloudSetting(Ye)},onSuccess:async()=>{i(r(\"profiles.presets.toast.created\")),await s.refetchQueries({queryKey:[\"cloudSettings\"]}),t()},onError:Re=>i(Re.message,\"error\")}),_e=it({mutationFn:()=>{if(!e?.setting_id)throw new Error(r(\"profiles.presets.noSettingId\"));return ue.updateCloudSetting(e.setting_id,{name:m,setting:g})},onSuccess:async()=>{i(r(\"profiles.presets.toast.updated\")),s.removeQueries({queryKey:[\"cloudSettingDetail\"]}),await s.refetchQueries({queryKey:[\"cloudSettings\"]}),t()},onError:Re=>i(Re.message,\"error\")}),Se=o?_e:ae,ve=de?.setting?.inherits,Te=ve?G.find(Re=>Re.name===ve):void 0,{data:ye}=Xe({queryKey:[\"cloudSettingDetail\",Te?.setting_id],queryFn:()=>ue.getCloudSettingDetail(Te.setting_id),enabled:!!Te?.setting_id}),je=w.useMemo(()=>{const Re=ye?.setting||{},$e=de?.setting||{},Ye=f&&U?.[f]?.setting?U[f].setting:{};return{...Re,...$e,...Ye}},[f,de,ye,U]),Le=Re=>{if(Re==null)return\"\";if(Array.isArray(Re)){const $e=[...new Set(Re.map(Ye=>String(Ye)))];return $e.length===1?$e[0]:Re.join(\", \")}return String(Re)},Me=Re=>{const $e=g[Re.key],Ye=je[Re.key],tt=Le(Ye),pe=I?r(\"common.loading\"):tt||\"\",Fe=\"w-full px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\";if(Re.type===\"boolean\"){const we=$e===\"1\"||$e===void 0&&Ye===\"1\";return a.jsx(\"button\",{type:\"button\",onClick:()=>se(Re.key,$e===\"1\"?\"0\":\"1\"),className:`w-8 h-5 rounded-full transition-colors ${we?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(\"div\",{className:`w-4 h-4 rounded-full bg-white shadow transition-transform ${we?\"translate-x-3.5\":\"translate-x-0.5\"}`})})}return Re.type===\"select\"?a.jsxs(\"select\",{value:$e||\"\",onChange:we=>se(Re.key,we.target.value),className:Fe,children:[a.jsx(\"option\",{value:\"\",children:pe}),Re.options?.map(we=>a.jsx(\"option\",{value:we.value,children:we.label},we.value))]}):a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:Re.type===\"number\"?\"number\":\"text\",value:$e!==void 0?String($e):\"\",onChange:we=>se(Re.key,we.target.value),step:Re.step,min:Re.min,max:Re.max,placeholder:pe,className:Fe}),Re.unit&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray whitespace-nowrap\",children:Re.unit})]})},Oe=w.useMemo(()=>de?.setting?de.setting:{},[de]);return a.jsxs(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",onDragOver:Re=>{Re.preventDefault(),D(!0)},onDragLeave:()=>D(!1),onDrop:R,children:[Y&&f&&a.jsx(Ble,{onClose:()=>E(!1),leftPreset:Oe,rightPreset:g,leftLabel:r(\"profiles.presets.baseLabel\",{name:v||f}),rightLabel:r(\"profiles.presets.currentLabel\",{name:m||r(\"profiles.presets.newPreset\")}),t:r}),a.jsx(Tt,{className:\"w-full max-w-6xl max-h-[90vh] flex flex-col overflow-y-auto\",children:a.jsxs(Mt,{className:\"p-0 flex flex-col h-full\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:r(o?\"profiles.presets.editPreset\":e?\"profiles.presets.duplicatePreset\":\"profiles.presets.createNewPreset\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:r(\"profiles.presets.customizeSettings\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[f&&a.jsxs(\"button\",{onClick:()=>E(!0),className:\"flex items-center gap-2 px-3 py-2 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",title:r(\"profiles.presets.compareWithBase\"),children:[a.jsx(Cx,{className:\"w-4 h-4\"}),r(\"profiles.presets.compare\")]}),a.jsx(\"button\",{onClick:t,className:\"p-2 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})]}),k&&a.jsx(\"div\",{className:\"absolute inset-0 bg-bambu-green/10 border-2 border-dashed border-bambu-green rounded-lg flex items-center justify-center z-10\",children:a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(La,{className:\"w-12 h-12 text-bambu-green mx-auto mb-2\"}),a.jsx(\"p\",{className:\"text-bambu-green font-medium\",children:r(\"profiles.presets.dropJsonToImport\")})]})}),a.jsxs(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary space-y-3\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-3 gap-4 max-[640px]:grid-cols-1\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"common.type\")}),a.jsxs(\"select\",{value:d,onChange:Re=>{u(Re.target.value),y(\"\")},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\",children:[a.jsx(\"option\",{value:\"filament\",children:r(\"profiles.presets.types.filament\")}),a.jsx(\"option\",{value:\"print\",children:r(\"profiles.presets.types.process\")}),a.jsx(\"option\",{value:\"printer\",children:r(\"profiles.presets.types.printer\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"profiles.presets.basePreset\")}),a.jsxs(\"select\",{value:f,onChange:Re=>y(Re.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"\",children:r(\"profiles.presets.selectBasePreset\")}),G.map(Re=>a.jsx(\"option\",{value:Re.setting_id,children:Re.name},Re.setting_id))]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm text-bambu-gray mb-1\",children:r(\"profiles.presets.presetName\")}),a.jsx(\"input\",{type:\"text\",value:m,onChange:Re=>p(Re.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\",placeholder:r(\"profiles.presets.myCustomPreset\")})]})]}),v&&a.jsx(\"div\",{className:\"text-xs text-bambu-gray\",children:a.jsxs(\"p\",{className:\"flex items-center gap-1\",children:[a.jsx(Ur,{className:\"w-3 h-3 text-bambu-green\"}),r(\"profiles.presets.inheritsFrom\"),\" \",a.jsx(\"span\",{className:\"text-white\",children:v}),I&&a.jsx(ft,{className:\"w-3 h-3 animate-spin ml-1\"})]})})]}),a.jsxs(\"div\",{className:\"flex border-b border-bambu-dark-tertiary max-[640px]:flex-wrap max-[640px]:items-center\",children:[a.jsxs(\"button\",{onClick:()=>c(\"common\"),className:`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${l===\"common\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-white border-transparent\"}`,children:[a.jsx(PH,{className:\"w-4 h-4\"}),r(\"profiles.presets.tabs.common\")]}),a.jsxs(\"button\",{onClick:()=>c(\"fields\"),className:`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${l===\"fields\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-white border-transparent\"}`,children:[a.jsx(tS,{className:\"w-4 h-4\"}),r(\"profiles.presets.tabs.allFields\")]}),a.jsxs(\"button\",{onClick:()=>c(\"json\"),className:`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${l===\"json\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-white border-transparent\"}`,children:[a.jsx(wy,{className:\"w-4 h-4\"}),\"JSON\",N&&a.jsx(ei,{className:\"w-3 h-3 text-red-400\"})]}),a.jsx(\"div\",{className:\"flex-1 max-[640px]:hidden\"}),a.jsxs(\"button\",{onClick:()=>{const Re={name:m,type:d,base_id:f,setting:g},$e=new Blob([JSON.stringify(Re,null,2)],{type:\"application/json\"}),Ye=URL.createObjectURL($e),tt=document.createElement(\"a\");tt.href=Ye,tt.download=`${m||\"preset\"}.json`,document.body.appendChild(tt),tt.click(),document.body.removeChild(tt),URL.revokeObjectURL(Ye),i(r(\"profiles.presets.toast.exported\"))},className:\"flex items-center gap-2 px-4 py-3 text-sm text-bambu-gray hover:text-white transition-colors\",title:r(\"profiles.presets.exportToJson\"),children:[a.jsx(ga,{className:\"w-4 h-4\"}),r(\"common.download\")]}),a.jsxs(\"button\",{onClick:()=>document.getElementById(\"file-import\")?.click(),className:\"flex items-center gap-2 px-4 py-3 text-sm text-bambu-gray hover:text-white transition-colors\",title:r(\"profiles.presets.importFromJson\"),children:[a.jsx(La,{className:\"w-4 h-4\"}),r(\"common.upload\")]}),a.jsx(\"input\",{id:\"file-import\",type:\"file\",accept:\".json\",className:\"hidden\",onChange:Re=>{const $e=Re.target.files?.[0];if($e){const Ye=new FileReader;Ye.onload=tt=>{try{const pe=JSON.parse(tt.target?.result),Fe=pe.setting||pe;_(we=>({...we,...Fe})),i(r(\"profiles.presets.fileImported\"))}catch{i(r(\"profiles.presets.invalidJson\"),\"error\")}},Ye.readAsText($e)}}})]}),a.jsxs(\"div\",{className:\"flex-1 p-4\",children:[l===\"common\"&&a.jsxs(\"div\",{className:\"space-y-6\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsxs(\"h3\",{className:\"text-sm font-medium text-white flex items-center gap-2\",children:[a.jsx($x,{className:\"w-4 h-4 text-amber-400\"}),r(\"profiles.templates.title\")]}),Object.keys(g).filter(Re=>Re!==\"inherits\").length>0&&a.jsxs(\"button\",{onClick:()=>oe(!J),className:\"text-xs text-bambu-gray hover:text-white flex items-center gap-1 transition-colors\",children:[a.jsx(_s,{className:\"w-3 h-3\"}),r(\"profiles.presets.saveAsTemplate\")]})]}),J&&a.jsxs(\"div\",{className:\"mb-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2 mb-2\",children:[a.jsx(\"input\",{type:\"text\",value:fe,onChange:Re=>re(Re.target.value),placeholder:r(\"profiles.templates.namePlaceholder\"),className:\"px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\",autoFocus:!0}),a.jsx(\"input\",{type:\"text\",value:W,onChange:Re=>ne(Re.target.value),placeholder:r(\"profiles.templates.descriptionPlaceholder\"),className:\"px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(De,{size:\"sm\",onClick:he,disabled:!fe.trim(),children:[a.jsx(_s,{className:\"w-3 h-3\"}),r(\"common.save\")]}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:()=>oe(!1),children:r(\"common.cancel\")})]}),a.jsxs(\"button\",{onClick:()=>be(!me),className:`flex items-center gap-1.5 text-xs transition-colors ${me?\"text-bambu-green\":\"text-bambu-gray hover:text-white\"}`,children:[me?a.jsx(yl,{className:\"w-3.5 h-3.5\"}):a.jsx(Og,{className:\"w-3.5 h-3.5\"}),r(me?\"profiles.templates.shownInModals\":\"profiles.templates.hiddenInModals\")]})]})]}),Ce&&a.jsxs(\"div\",{className:\"mb-3 px-3 py-2 bg-bambu-green/10 border border-bambu-green/30 rounded-lg flex items-center gap-2\",children:[a.jsx(Ur,{className:\"w-4 h-4 text-bambu-green\"}),a.jsxs(\"span\",{className:\"text-sm text-bambu-green\",children:[r(\"profiles.presets.templateApplied\"),\" \",a.jsx(\"span\",{className:\"font-medium\",children:Ce})]}),a.jsx(\"button\",{onClick:()=>q(null),className:\"ml-auto text-bambu-green/70 hover:text-bambu-green\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),a.jsxs(\"div\",{className:\"grid grid-cols-3 gap-2\",children:[le.map(Re=>a.jsxs(\"button\",{onClick:()=>ge(Re),className:\"p-3 text-left bg-bambu-dark border border-bambu-dark-tertiary rounded-lg hover:border-bambu-gray-dark transition-colors\",children:[a.jsx(\"p\",{className:\"text-sm font-medium text-white\",children:Re.name}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:Re.description})]},Re.id)),le.length===0&&a.jsx(\"p\",{className:\"col-span-3 text-center text-bambu-gray text-sm py-4\",children:r(\"profiles.presets.noTemplatesSelected\")})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray-dark mt-2 text-center\",children:r(\"profiles.presets.manageTemplatesHint\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white mb-3\",children:r(\"profiles.presets.commonSettings\")}),a.jsx(\"div\",{className:\"grid grid-cols-2 gap-x-6 gap-y-3\",children:X.slice(0,10).map(Re=>a.jsxs(\"div\",{className:\"flex items-center justify-between gap-4 max-[640px]:flex-col max-[640px]:items-start\",children:[a.jsx(\"label\",{className:\"text-sm text-bambu-gray flex-shrink-0\",children:Re.label}),a.jsx(\"div\",{className:\"w-48 max-[640px]:w-full\",children:Me(Re)})]},Re.key))})]}),Object.keys(g).length>1&&a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white mb-3\",children:r(\"profiles.presets.currentOverrides\")}),a.jsx(\"div\",{className:\"flex flex-wrap gap-2\",children:Object.entries(g).filter(([Re])=>Re!==\"inherits\").map(([Re,$e])=>a.jsxs(\"span\",{className:\"inline-flex items-center gap-1 px-2 py-1 bg-bambu-green/10 text-bambu-green text-xs rounded\",children:[Re,\": \",String($e).slice(0,20),a.jsx(\"button\",{onClick:()=>se(Re,void 0),className:\"hover:text-white\",children:a.jsx(Ht,{className:\"w-3 h-3\"})})]},Re))})]})]}),l===\"fields\"&&a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-6\",style:{height:\"400px\"},children:[a.jsxs(\"div\",{className:\"flex flex-col h-full overflow-hidden\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3 flex-shrink-0\",children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white\",children:r(\"profiles.presets.availableFields\")}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:U?r(\"profiles.templates.fieldsCount\",{count:X.length}):r(\"common.loading\")})]}),a.jsxs(\"div\",{className:\"relative mb-3 flex-shrink-0\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:T,onChange:Re=>F(Re.target.value),placeholder:r(\"profiles.presets.searchFieldsPlaceholder\"),className:\"w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"flex-1 overflow-y-auto space-y-1 pr-2 min-h-0\",children:[V.filter(Re=>!(Re.key in g)).map(Re=>{const $e=je[Re.key],Ye=Le($e);return a.jsxs(\"div\",{onClick:()=>{_(tt=>({...tt,[Re.key]:Ye||\"\"}))},className:\"flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors cursor-pointer group\",children:[a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsx(\"p\",{className:\"text-sm text-white truncate\",children:Re.label}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray-dark truncate\",children:Re.key})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0\",children:[Ye&&a.jsxs(\"span\",{className:\"text-xs text-bambu-gray bg-bambu-dark px-2 py-0.5 rounded max-w-32 truncate\",title:Ye,children:[Ye.slice(0,20),Ye.length>20?\"...\":\"\"]}),a.jsx(\"div\",{className:\"w-6 h-6 flex items-center justify-center rounded bg-bambu-dark-tertiary group-hover:bg-bambu-green/20 transition-colors\",children:a.jsx(sr,{className:\"w-4 h-4 text-bambu-gray group-hover:text-bambu-green transition-colors\"})})]})]},Re.key)}),V.filter(Re=>!(Re.key in g)).length===0&&a.jsx(\"p\",{className:\"text-center text-bambu-gray py-4 text-sm\",children:r(T?\"profiles.presets.noMatchingFields\":\"profiles.presets.allFieldsAdded\")})]}),a.jsx(\"div\",{className:\"pt-3 mt-3 border-t border-bambu-dark-tertiary flex-shrink-0\",children:Q?a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"input\",{type:\"text\",value:H,onChange:Re=>z(Re.target.value),onKeyDown:Re=>Re.key===\"Enter\"&&ee(),placeholder:\"custom_field_name\",className:\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\",autoFocus:!0}),a.jsx(De,{size:\"sm\",onClick:ee,disabled:!H.trim(),children:a.jsx(sr,{className:\"w-4 h-4\"})}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:()=>{L(!1),z(\"\")},children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}):a.jsxs(\"button\",{onClick:()=>L(!0),className:\"w-full flex items-center justify-center gap-2 p-2 text-sm text-bambu-gray hover:text-white border border-dashed border-bambu-dark-tertiary hover:border-bambu-gray-dark rounded-lg transition-colors\",children:[a.jsx(sr,{className:\"w-4 h-4\"}),r(\"profiles.presets.addCustomField\")]})})]}),a.jsxs(\"div\",{className:\"flex flex-col h-full overflow-hidden\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3 flex-shrink-0\",children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white\",children:r(\"profiles.presets.yourOverrides\")}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:r(\"profiles.templates.fieldsCount\",{count:Object.keys(g).filter(Re=>Re!==\"inherits\").length})})]}),a.jsxs(\"div\",{className:\"flex-1 overflow-y-auto space-y-2 pr-2 min-h-0\",children:[Object.entries(g).filter(([Re])=>Re!==\"inherits\").map(([Re,$e])=>{const Ye=X.find(tt=>tt.key===Re);return a.jsxs(\"div\",{className:\"p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm font-medium text-white\",children:Ye?.label||Re}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray-dark\",children:Re})]}),a.jsx(\"button\",{onClick:()=>se(Re,void 0),className:\"p-1 text-bambu-gray hover:text-red-400 transition-colors\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),Ye?Me(Ye):a.jsx(\"input\",{type:\"text\",value:String($e),onChange:tt=>se(Re,tt.target.value),className:\"w-full px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none\"})]},Re)}),Object.keys(g).filter(Re=>Re!==\"inherits\").length===0&&a.jsxs(\"div\",{className:\"text-center py-8 text-bambu-gray\",children:[a.jsx(PH,{className:\"w-8 h-8 mx-auto mb-2 opacity-50\"}),a.jsx(\"p\",{className:\"text-sm\",children:r(\"profiles.presets.noOverridesYet\")}),a.jsx(\"p\",{className:\"text-xs mt-1\",children:r(\"profiles.presets.clickFieldsToAdd\")})]})]}),Object.keys(g).filter(Re=>Re!==\"inherits\").length>0&&a.jsx(\"div\",{className:\"pt-3 mt-3 border-t border-bambu-dark-tertiary flex-shrink-0\",children:a.jsxs(\"button\",{onClick:()=>{oe(!0),c(\"common\")},className:\"w-full flex items-center justify-center gap-2 p-2 text-sm text-bambu-gray hover:text-white border border-dashed border-bambu-dark-tertiary hover:border-bambu-gray-dark rounded-lg transition-colors\",children:[a.jsx(_s,{className:\"w-4 h-4\"}),r(\"profiles.presets.saveAsTemplate\")]})})]})]}),l===\"json\"&&a.jsxs(\"div\",{className:\"space-y-2\",children:[N&&a.jsxs(\"div\",{className:\"flex items-center gap-2 text-red-400 text-sm\",children:[a.jsx(ei,{className:\"w-4 h-4\"}),N]}),a.jsx(\"textarea\",{value:C,onChange:Re=>B(Re.target.value),className:`w-full h-80 px-3 py-2 bg-bambu-dark border rounded-lg text-white text-xs font-mono focus:outline-none resize-none ${N?\"border-red-500 focus:border-red-500\":\"border-bambu-dark-tertiary focus:border-bambu-green\"}`,spellCheck:!1}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:r(\"profiles.presets.jsonTip\")})]})]}),a.jsxs(\"div\",{className:\"p-4 border-t border-bambu-dark-tertiary flex gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:t,className:\"flex-1\",children:r(\"common.cancel\")}),a.jsxs(De,{onClick:()=>Se.mutate(),disabled:Se.isPending||!m.trim()||!o&&!f||!!N,className:\"flex-1\",children:[Se.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):o?a.jsx(_s,{className:\"w-4 h-4\"}):a.jsx(sr,{className:\"w-4 h-4\"}),r(o?\"common.save\":e?\"common.duplicate\":\"common.create\")]})]})]})})]})}function Wnt({settings:t,lastSyncTime:e,onRefresh:n,isRefreshing:r,printers:i,hasPermission:s,t:o}){const[l,c]=w.useState(\"\"),[d,u]=w.useState(\"all\"),[m,p]=w.useState(\"all\"),[f,y]=w.useState(\"all\"),[v,b]=w.useState(\"all\"),[g,_]=w.useState(\"all\"),[C,P]=w.useState(\"all\"),[N,A]=w.useState(null),[T,F]=w.useState(!1),[k,D]=w.useState(!1),[H,z]=w.useState(null),[Q,L]=w.useState(null),[te,ie]=w.useState(null),[J,oe]=w.useState(!1),[fe,re]=w.useState([null,null]),[W,ne]=w.useState(!1),[me,be]=w.useState(null),Ce=nn(),q=w.useMemo(()=>[...t.filament.map(ee=>({...ee,type:\"filament\"})),...t.printer.map(ee=>({...ee,type:\"printer\"})),...t.process.map(ee=>({...ee,type:\"process\"}))].map(ee=>({...ee,meta:b5(ee.name)})),[t]),Y=w.useMemo(()=>{const V=new Set,ee=new Set,se=new Set;return q.forEach(ge=>{ge.meta.nozzle&&V.add(ge.meta.nozzle),ge.meta.filamentType&&ee.add(ge.meta.filamentType),ge.meta.layerHeight&&se.add(ge.meta.layerHeight)}),{printers:i.map(ge=>({id:ge.id.toString(),name:ge.name})),nozzles:Array.from(V).sort((ge,he)=>parseFloat(ge)-parseFloat(he)),filaments:Array.from(ee).sort(),layerHeights:Array.from(se).sort((ge,he)=>parseFloat(ge)-parseFloat(he))}},[q,i]),E=w.useMemo(()=>f===\"all\"?null:i.find(ee=>ee.id.toString()===f)?.model||null,[f,i]),j=w.useMemo(()=>q.filter(V=>d===\"all\"||V.type===d).filter(V=>{if(m===\"all\")return!0;const ee=Kw(V.setting_id);return m===\"custom\"?ee:!ee}).filter(V=>{if(f===\"all\"||!E)return!0;const ee=V.meta.printer?.toLowerCase()||\"\",se=E.toLowerCase();return ee.includes(se)||se.includes(ee)}).filter(V=>v===\"all\"||V.meta.nozzle===v).filter(V=>g===\"all\"||V.meta.filamentType===g).filter(V=>C===\"all\"||V.meta.layerHeight===C).filter(V=>l===\"\"||V.name.toLowerCase().includes(l.toLowerCase())).sort((V,ee)=>V.name.localeCompare(ee.name)),[q,d,m,f,E,v,g,C,l]),O=V=>{if(J){const ee=fe[0]?.setting_id===V.setting_id,se=fe[1]?.setting_id===V.setting_id;if(ee)re([fe[1],null]);else if(se)re([fe[0],null]);else if(!fe[0])re([V,null]);else if(fe[1]){if(fe[0].type!==V.type)return;re([fe[0],V])}else{if(fe[0].type!==V.type)return;re([fe[0],V])}}else A(V)},K=V=>{if(fe[0]?.setting_id===V.setting_id)return 0;if(fe[1]?.setting_id===V.setting_id)return 1},U=async V=>{try{const ee=await ue.getCloudSettingDetail(V.setting_id),se=V.type===\"process\"?\"print\":V.type;z({type:se,name:V.name,base_id:ee.base_id||\"GFSA00\",setting:ee.setting||{}}),A(null)}catch(ee){console.error(\"Failed to fetch preset details for duplication:\",ee)}},de=async V=>{try{Ce.removeQueries({queryKey:[\"cloudSettingDetail\",V.setting_id]});const ee=await ue.getCloudSettingDetail(V.setting_id),se=V.type===\"process\"?\"print\":V.type;L({type:se,name:V.name,base_id:ee.base_id||\"GFSA00\",setting:ee.setting||{},setting_id:V.setting_id}),A(null)}catch(ee){console.error(\"Failed to fetch preset details for editing:\",ee)}},I=()=>{u(\"all\"),p(\"all\"),y(\"all\"),b(\"all\"),_(\"all\"),P(\"all\"),c(\"\")},G=d!==\"all\"||m!==\"all\"||f!==\"all\"||v!==\"all\"||g!==\"all\"||C!==\"all\"||l!==\"\",X=t.filament.length+t.printer.length+t.process.length;return a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"space-y-4 mb-6\",children:[a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row gap-3\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:l,onChange:V=>c(V.target.value),placeholder:o(\"profiles.cloudView.searchPlaceholder\"),className:\"w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none\"})]}),a.jsxs(\"div\",{className:\"flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center\",children:[a.jsxs(De,{variant:J?\"primary\":\"secondary\",onClick:()=>{J?(oe(!1),re([null,null])):oe(!0)},children:[a.jsx(Cx,{className:\"w-4 h-4\"}),o(J?\"common.cancel\":\"profiles.presets.compare\")]}),a.jsxs(De,{variant:\"secondary\",onClick:()=>D(!0),disabled:!s(\"cloud:auth\"),title:s(\"cloud:auth\")?void 0:o(\"profiles.cloudView.noTemplatesPermission\"),children:[a.jsx($x,{className:\"w-4 h-4\"}),o(\"profiles.cloudView.templates\")]}),a.jsxs(De,{variant:\"secondary\",onClick:n,disabled:r||!s(\"cloud:auth\"),title:s(\"cloud:auth\")?void 0:o(\"profiles.cloudView.noRefreshPermission\"),children:[a.jsx(lr,{className:`w-4 h-4 ${r?\"animate-spin\":\"\"}`}),o(\"profiles.cloudView.refresh\")]}),a.jsxs(De,{onClick:()=>F(!0),disabled:!s(\"cloud:auth\"),title:s(\"cloud:auth\")?void 0:o(\"profiles.cloudView.noCreatePermission\"),children:[a.jsx(sr,{className:\"w-4 h-4\"}),o(\"profiles.cloudView.newPreset\")]})]})]}),a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-2\",children:[a.jsx($P,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(cx,{label:o(\"profiles.cloudView.filters.type\"),value:d,options:[{value:\"all\",label:o(\"profiles.cloudView.filters.all\"),count:X},{value:\"filament\",label:o(\"profiles.cloudView.filters.filament\"),count:t.filament.length},{value:\"printer\",label:o(\"profiles.cloudView.filters.printer\"),count:t.printer.length},{value:\"process\",label:o(\"profiles.cloudView.filters.process\"),count:t.process.length}],onChange:V=>u(V)}),a.jsx(cx,{label:o(\"profiles.cloudView.filters.owner\"),value:m,options:[{value:\"all\",label:o(\"profiles.cloudView.filters.all\")},{value:\"custom\",label:o(\"profiles.cloudView.filters.myPresets\")},{value:\"builtin\",label:o(\"profiles.cloudView.filters.builtIn\")}],onChange:V=>p(V)}),Y.printers.length>0&&a.jsx(cx,{label:o(\"profiles.cloudView.filters.printer\"),value:f,options:[{value:\"all\",label:o(\"profiles.cloudView.filters.all\")},...Y.printers.map(V=>({value:V.id,label:V.name}))],onChange:y}),Y.nozzles.length>0&&a.jsx(cx,{label:o(\"profiles.cloudView.filters.nozzle\"),value:v,options:[{value:\"all\",label:o(\"profiles.cloudView.filters.all\")},...Y.nozzles.map(V=>({value:V,label:V}))],onChange:b}),Y.filaments.length>0&&(d===\"all\"||d===\"filament\")&&a.jsx(cx,{label:o(\"profiles.cloudView.filters.filament\"),value:g,options:[{value:\"all\",label:o(\"profiles.cloudView.filters.all\")},...Y.filaments.map(V=>({value:V,label:V}))],onChange:_}),Y.layerHeights.length>0&&(d===\"all\"||d===\"process\")&&a.jsx(cx,{label:o(\"profiles.cloudView.filters.layer\"),value:C,options:[{value:\"all\",label:o(\"profiles.cloudView.filters.all\")},...Y.layerHeights.map(V=>({value:V,label:V}))],onChange:P}),G&&a.jsx(\"button\",{onClick:I,className:\"px-3 py-2 text-sm text-bambu-gray hover:text-white transition-colors\",children:o(\"profiles.cloudView.clearFilters\")})]})]}),J&&a.jsx(\"div\",{className:\"mb-4 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg\",children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-4\",children:[a.jsx(Cx,{className:\"w-5 h-5 text-blue-400\"}),a.jsx(\"span\",{className:\"text-white font-medium\",children:o(\"profiles.cloudView.compareMode\")}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:fe[0]?o(\"profiles.cloudView.selectAnotherPreset\",{type:fe[0].type}):o(\"profiles.cloudView.clickTwoPresets\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:`px-2 py-1 text-sm rounded truncate max-w-[200px] ${fe[0]?\"bg-blue-500/30 text-blue-700 dark:text-blue-300\":\"bg-bambu-dark text-bambu-gray\"}`,children:fe[0]?fe[0].name:o(\"profiles.cloudView.selectFirst\")}),a.jsx(rF,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"span\",{className:`px-2 py-1 text-sm rounded truncate max-w-[200px] ${fe[1]?\"bg-blue-500/30 text-blue-700 dark:text-blue-300\":\"bg-bambu-dark text-bambu-gray\"}`,children:fe[1]?fe[1].name:o(\"profiles.cloudView.selectSecond\")})]}),fe[0]&&fe[1]&&a.jsxs(De,{size:\"sm\",onClick:async()=>{try{const[V,ee]=await Promise.all([ue.getCloudSettingDetail(fe[0].setting_id),ue.getCloudSettingDetail(fe[1].setting_id)]);be([V.setting||{},ee.setting||{}]),ne(!0)}catch{}},children:[a.jsx(Cx,{className:\"w-4 h-4\"}),o(\"profiles.cloudView.compareNow\")]})]})]})}),a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-4 mb-4 text-sm text-bambu-gray\",children:[e&&a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),o(\"profiles.cloudView.lastSynced\"),\" \",ap(e.toISOString(),\"system\",o)]}),a.jsx(\"span\",{children:o(\"profiles.cloudView.showingCount\",{showing:j.length,total:X})}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"span\",{className:\"w-1.5 h-1.5 rounded-full bg-bambu-green\"}),a.jsxs(\"span\",{children:[\"= \",o(\"profiles.presets.myPreset\")]})]})]}),j.length===0?a.jsxs(\"div\",{className:\"text-center py-16\",children:[a.jsx(da,{className:\"w-12 h-12 text-bambu-gray-dark mx-auto mb-4\"}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:o(\"profiles.cloudView.noPresetsFound\")}),G&&a.jsx(\"button\",{onClick:I,className:\"mt-2 text-sm text-bambu-green hover:text-bambu-green-light\",children:o(\"profiles.cloudView.clearFilters\")})]}):a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-3 gap-6\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3 px-1\",children:[a.jsx(HP,{className:\"w-4 h-4 text-amber-400\"}),a.jsx(\"h3\",{className:\"text-sm font-medium text-bambu-gray\",children:o(\"profiles.cloudView.columns.filament\")}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray-dark\",children:[\"(\",j.filter(V=>V.type===\"filament\").length,\")\"]})]}),a.jsxs(\"div\",{className:\"space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1\",children:[j.filter(V=>V.type===\"filament\").map(V=>a.jsx(j3,{setting:V,onClick:()=>O(V),onDuplicate:()=>U(V),compareMode:J,isCompareSelected:K(V)!==void 0,compareIndex:K(V),compareDisabled:J&&!!fe[0]&&fe[0].type!==V.type,t:o},V.setting_id)),j.filter(V=>V.type===\"filament\").length===0&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray-dark px-3 py-2\",children:o(\"profiles.cloudView.noFilamentPresets\")})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3 px-1\",children:[a.jsx(Ud,{className:\"w-4 h-4 text-blue-400\"}),a.jsx(\"h3\",{className:\"text-sm font-medium text-bambu-gray\",children:o(\"profiles.cloudView.columns.process\")}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray-dark\",children:[\"(\",j.filter(V=>V.type===\"process\").length,\")\"]})]}),a.jsxs(\"div\",{className:\"space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1\",children:[j.filter(V=>V.type===\"process\").map(V=>a.jsx(j3,{setting:V,onClick:()=>O(V),onDuplicate:()=>U(V),compareMode:J,isCompareSelected:K(V)!==void 0,compareIndex:K(V),compareDisabled:J&&!!fe[0]&&fe[0].type!==V.type,t:o},V.setting_id)),j.filter(V=>V.type===\"process\").length===0&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray-dark px-3 py-2\",children:o(\"profiles.cloudView.noProcessPresets\")})]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-3 px-1\",children:[a.jsx(Er,{className:\"w-4 h-4 text-purple-400\"}),a.jsx(\"h3\",{className:\"text-sm font-medium text-bambu-gray\",children:o(\"profiles.cloudView.columns.printer\")}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray-dark\",children:[\"(\",j.filter(V=>V.type===\"printer\").length,\")\"]})]}),a.jsxs(\"div\",{className:\"space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1\",children:[j.filter(V=>V.type===\"printer\").map(V=>a.jsx(j3,{setting:V,onClick:()=>O(V),onDuplicate:()=>U(V),compareMode:J,isCompareSelected:K(V)!==void 0,compareIndex:K(V),compareDisabled:J&&!!fe[0]&&fe[0].type!==V.type,t:o},V.setting_id)),j.filter(V=>V.type===\"printer\").length===0&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray-dark px-3 py-2\",children:o(\"profiles.cloudView.noPrinterPresets\")})]})]})]}),N&&a.jsx($nt,{setting:N,onClose:()=>A(null),onDeleted:()=>A(null),onDuplicate:()=>U(N),onEdit:()=>de(N),hasPermission:s,t:o}),(T||H||Q||te)&&a.jsx(Gnt,{onClose:()=>{F(!1),z(null),L(null),ie(null)},initialData:Q||H||(te?{type:te.type,name:\"\",base_id:\"\",setting:te.setting}:void 0),allPresets:t,t:o}),k&&a.jsx(Vnt,{onClose:()=>D(!1),onApply:V=>{ie({type:V.type,setting:V.settings}),D(!1)},t:o}),W&&me&&fe[0]&&fe[1]&&a.jsx(Ble,{onClose:()=>{ne(!1),be(null)},leftPreset:me[0],rightPreset:me[1],leftLabel:fe[0].name,rightLabel:fe[1].name,t:o})]})}function Knt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),{hasPermission:r}=kr(),[i,s]=w.useState(\"cloud\"),[o,l]=w.useState(),{data:c,isLoading:d}=Xe({queryKey:[\"cloudStatus\"],queryFn:ue.getCloudStatus}),{data:u=[]}=Xe({queryKey:[\"printers\"],queryFn:ue.getPrinters}),{data:m,isLoading:p,refetch:f,dataUpdatedAt:y}=Xe({queryKey:[\"cloudSettings\"],queryFn:()=>ue.getCloudSettings(),enabled:!!c?.is_authenticated,retry:!1,staleTime:1e3*60*5});w.useEffect(()=>{y&&l(new Date(y))},[y]);const v=it({mutationFn:ue.cloudLogout,onSuccess:()=>{e.invalidateQueries({queryKey:[\"cloudStatus\"]}),e.removeQueries({queryKey:[\"cloudSettings\"]}),n(t(\"profiles.toast.loggedOut\"))}}),b=()=>{e.invalidateQueries({queryKey:[\"cloudStatus\"]})};return d?a.jsx(\"div\",{className:\"p-4 md:p-8 flex items-center justify-center min-h-[400px]\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):a.jsxs(\"div\",{className:\"p-6 lg:p-8\",children:[a.jsxs(\"div\",{className:\"mb-6\",children:[a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:t(\"profiles.title\")}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"profiles.subtitle\")})]}),a.jsxs(\"div\",{className:\"flex border-b border-bambu-dark-tertiary mb-6\",children:[a.jsxs(\"button\",{onClick:()=>s(\"cloud\"),className:`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${i===\"cloud\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-white border-transparent\"}`,children:[a.jsx(vy,{className:\"w-4 h-4\"}),t(\"profiles.tabs.cloud\")]}),a.jsxs(\"button\",{onClick:()=>s(\"local\"),className:`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${i===\"local\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-white border-transparent\"}`,children:[a.jsx(Ig,{className:\"w-4 h-4\"}),t(\"profiles.tabs.local\")]}),a.jsxs(\"button\",{onClick:()=>s(\"kprofiles\"),className:`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${i===\"kprofiles\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray hover:text-white border-transparent\"}`,children:[a.jsx(Zw,{className:\"w-4 h-4\"}),t(\"profiles.tabs.kprofiles\")]})]}),i===\"cloud\"&&a.jsxs(a.Fragment,{children:[c?.is_authenticated&&a.jsxs(\"div\",{className:\"flex items-center justify-between p-3 mb-6 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"w-2 h-2 rounded-full bg-bambu-green animate-pulse\"}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[t(\"profiles.connectedAs\"),\" \",a.jsx(\"span\",{className:\"text-white\",children:c.email}),c.region&&a.jsx(\"span\",{className:\"ml-2 px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray\",children:c.region===\"china\"?t(\"profiles.login.regionChina\"):t(\"profiles.login.regionGlobal\")})]})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>v.mutate(),disabled:v.isPending||!r(\"cloud:auth\"),title:r(\"cloud:auth\")?void 0:t(\"profiles.noLogoutPermission\"),children:[a.jsx(oF,{className:\"w-4 h-4\"}),t(\"profiles.logout\")]})]}),c?.is_authenticated?p?a.jsx(\"div\",{className:\"flex items-center justify-center py-16\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):m?a.jsx(Wnt,{settings:m,lastSyncTime:o,onRefresh:()=>f(),isRefreshing:p,printers:u,hasPermission:r,t}):a.jsxs(\"div\",{className:\"text-center py-16\",children:[a.jsx(\"p\",{className:\"text-bambu-gray mb-4\",children:t(\"profiles.failedToLoad\")}),a.jsx(De,{onClick:()=>f(),children:t(\"profiles.retry\")})]}):a.jsx(Hnt,{onSuccess:b,t})]}),i===\"local\"&&a.jsx(Bnt,{}),i===\"kprofiles\"&&a.jsx(Ont,{}),a.jsx(qnt,{})]})}function Xnt(t,e){const n=(e||\"\").toUpperCase().replace(/[- ]/g,\"\"),r=n.includes(\"X1\"),i=n.includes(\"P1\"),s=n.includes(\"A1MINI\"),o=n.includes(\"A1\")&&!s,l=n.includes(\"H2D\"),c=n.includes(\"H2C\"),d=n.includes(\"H2S\"),u=l||c||d,m=n.includes(\"P2S\"),p=n.includes(\"X2D\"),f=m||p;switch(t){case\"Lubricate Steel Rods\":return f?\"https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis\":null;case\"Clean Steel Rods\":return f?\"https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis\":null;case\"Lubricate Linear Rails\":return s?\"https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis\":o?\"https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis\":u?\"https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication\":null;case\"Clean Nozzle/Hotend\":return r||i?\"https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog\":s||o?\"https://wiki.bambulab.com/en/a1-mini/troubleshooting/nozzle-clog\":u?\"https://wiki.bambulab.com/en/h2/maintenance/nozzl-cold-pull-maintenance-and-cleaning\":f?\"https://wiki.bambulab.com/en/p2s/maintenance/cold-pull-maintenance-hotend\":\"https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog\";case\"Check Belt Tension\":return r?\"https://wiki.bambulab.com/en/x1/maintenance/belt-tension\":i?\"https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance\":s?\"https://wiki.bambulab.com/en/a1-mini/maintenance/belt_tension\":o?\"https://wiki.bambulab.com/en/a1/maintenance/belt_tension\":l?\"https://wiki.bambulab.com/en/h2/maintenance/belt-tension\":c?\"https://wiki.bambulab.com/en/h2c/maintenance/belt-tension\":d?\"https://wiki.bambulab.com/en/h2s/maintenance/belt-tension\":f?\"https://wiki.bambulab.com/en/p2s/maintenance/belt-tension\":\"https://wiki.bambulab.com/en/x1/maintenance/belt-tension\";case\"Clean Carbon Rods\":return r||i?\"https://wiki.bambulab.com/en/general/carbon-rods-clearance\":null;case\"Clean Linear Rails\":return s?\"https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis\":o?\"https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis\":u?\"https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication\":null;case\"Clean Build Plate\":return\"https://wiki.bambulab.com/en/filament-acc/acc/pei-plate-clean-guide\";case\"Check PTFE Tube\":return r||i?\"https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube\":s||o?\"https://wiki.bambulab.com/en/a1-mini/maintenance/ptfe-tube\":l?\"https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer\":d?\"https://wiki.bambulab.com/en/h2s/maintenance/replace-ptfe-tube-on-h2s-printer\":c?\"https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer\":\"https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube\";case\"Replace HEPA Filter\":case\"HEPA Filter\":case\"Replace Carbon Filter\":case\"Carbon Filter\":return u?\"https://wiki.bambulab.com/en/h2/maintenance/replace-smoke-purifier-air-filte\":\"https://wiki.bambulab.com/en/x1/maintenance/replace-carbon-filter\";case\"Lubricate Left Nozzle Rail\":case\"Left Nozzle Rail\":return u?\"https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication\":null;default:return null}}const C0={Droplet:HP,Flame:Hu,Ruler:Ybe,Sparkles:$x,Square:uo,Cable:AO,Wrench:mg,Calendar:oa,Timer:jd,Cog:Tfe,Fan:RQ,Zap:dc,Wind:YQ,Thermometer:oS,Layers:da,Box:vi,Target:GQ,RefreshCw:lr,Settings:sS,Filter:$P,CircleDot:cfe};function Z0(t){return t&&C0[t]||mg}function dx(t,e,n){if(e===\"days\"){if(t<1)return n?n(\"common.today\"):\"Today\";if(t===1)return n?n(\"maintenance.day\"):\"1 day\";if(t<7){const i=Math.round(t);return n?n(\"maintenance.days\",{count:i}):`${i} days`}if(t<180){const i=Math.round(t/7);return i===1?n?n(\"maintenance.week\"):\"1 week\":n?n(\"maintenance.weeks\",{count:i}):`${i} weeks`}const r=Math.round(t/30);return r===1?n?n(\"maintenance.month\"):\"1 month\":n?n(\"maintenance.months\",{count:r}):`${r} months`}else{if(t<1)return`${Math.round(t*60)}m`;if(t<24)return`${t<10?t.toFixed(1):Math.round(t)}h`;const r=t/24;if(r<7)return`${r<2?r.toFixed(1):Math.round(r)}d`;const i=r/7;return i<12?`${i<2?i.toFixed(1):Math.round(i)}w`:`${Math.round(i/4)}mo`}}function M3(t,e,n){return e===\"days\"?t===1?n?n(\"maintenance.day\"):\"1 day\":t===7?n?n(\"maintenance.week\"):\"1 week\":t===14?n?n(\"maintenance.weeks\",{count:2}):\"2 weeks\":t===30?n?n(\"maintenance.month\"):\"1 month\":t===60?n?n(\"maintenance.months\",{count:2}):\"2 months\":t===90?n?n(\"maintenance.months\",{count:3}):\"3 months\":t===180?n?n(\"maintenance.months\",{count:6}):\"6 months\":t===365?n?n(\"maintenance.year\"):\"1 year\":n?n(\"maintenance.days\",{count:t}):`${t} days`:`${t}h`}function Ynt({item:t,onPerform:e,onToggle:n,hasPermission:r,t:i}){const s=Z0(t.maintenance_type_icon),o=t.interval_type||\"hours\",c=(()=>{if(o===\"days\"){const f=t.days_since_maintenance??0;return Math.max(0,Math.min(100,f/t.interval_hours*100))}return Math.max(0,Math.min(100,(t.interval_hours-t.hours_until_due)/t.interval_hours*100))})(),d=()=>t.enabled?t.is_due?\"text-red-400\":t.is_warning?\"text-amber-400\":\"text-bambu-green\":\"text-bambu-gray\",u=()=>t.enabled?t.is_due?\"bg-red-500\":t.is_warning?\"bg-amber-500\":\"bg-bambu-green\":\"bg-bambu-gray/30\",m=()=>t.enabled?t.is_due?\"bg-red-500/5 border-red-500/20\":t.is_warning?\"bg-amber-500/5 border-amber-500/20\":\"bg-bambu-dark-secondary border-bambu-dark-tertiary\":\"bg-bambu-dark-secondary/50\",p=()=>{if(!t.enabled)return i(\"common.disabled\");if(o===\"days\"){const f=t.days_until_due??0;return t.is_due?i(\"maintenance.overdueBy\",{duration:dx(Math.abs(f),\"days\",i)}):t.is_warning?i(\"maintenance.dueIn\",{duration:dx(f,\"days\",i)}):i(\"maintenance.timeLeft\",{duration:dx(f,\"days\",i)})}else return t.is_due?i(\"maintenance.overdueBy\",{duration:dx(Math.abs(t.hours_until_due),\"hours\",i)}):t.is_warning?i(\"maintenance.dueIn\",{duration:dx(t.hours_until_due,\"hours\",i)}):i(\"maintenance.timeLeft\",{duration:dx(t.hours_until_due,\"hours\",i)})};return a.jsx(\"div\",{className:`rounded-xl border p-4 transition-all ${m()}`,children:a.jsxs(\"div\",{className:\"flex items-start gap-3 max-[550px]:flex-wrap\",children:[a.jsxs(\"div\",{className:`relative p-2.5 rounded-lg shrink-0 ${t.is_due?\"bg-red-500/20\":t.is_warning?\"bg-amber-500/20\":t.enabled?\"bg-bambu-dark\":\"bg-bambu-dark/50\"}`,children:[a.jsx(s,{className:`w-5 h-5 ${d()}`}),t.enabled&&(t.is_due||t.is_warning)&&a.jsx(\"span\",{className:`absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full ${t.is_due?\"bg-red-500\":\"bg-amber-500\"} animate-pulse`})]}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"h3\",{className:`font-medium truncate ${t.enabled?\"text-white\":\"text-bambu-gray\"}`,children:t.maintenance_type_name}),o===\"days\"&&a.jsx(\"span\",{title:i(\"maintenance.timeBasedInterval\"),children:a.jsx(oa,{className:\"w-3.5 h-3.5 text-bambu-gray shrink-0\"})}),(()=>{const f=t.maintenance_type_wiki_url||Xnt(t.maintenance_type_name,t.printer_model);return f?a.jsx(\"a\",{href:f,target:\"_blank\",rel:\"noopener noreferrer\",className:\"text-bambu-gray hover:text-bambu-green transition-colors shrink-0\",title:i(\"maintenance.viewDocumentation\"),onClick:y=>y.stopPropagation(),children:a.jsx(la,{className:\"w-3.5 h-3.5\"})}):null})()]}),a.jsx(\"div\",{className:\"mt-2 mb-1.5\",children:a.jsx(\"div\",{className:\"w-full h-1.5 bg-bambu-dark rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:`h-full rounded-full transition-all duration-500 ${u()}`,style:{width:`${c}%`}})})}),a.jsxs(\"div\",{className:`text-xs flex items-center gap-1 ${d()}`,children:[t.is_due&&a.jsx(Dn,{className:\"w-3 h-3\"}),t.is_warning&&!t.is_due&&a.jsx(Yn,{className:\"w-3 h-3\"}),!t.is_due&&!t.is_warning&&t.enabled&&a.jsx(Ur,{className:\"w-3 h-3\"}),p()]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 shrink-0 max-[550px]:w-full max-[550px]:justify-end max-[550px]:mt-1\",children:[a.jsx(\"span\",{title:r(\"maintenance:update\")?void 0:i(\"maintenance.noPermissionUpdate\"),children:a.jsx(Ln,{checked:t.enabled,onChange:f=>n(t.id,f),disabled:!r(\"maintenance:update\")})}),a.jsxs(De,{size:\"sm\",variant:t.is_due?\"primary\":\"secondary\",onClick:()=>e(t.id),disabled:!t.enabled||!r(\"maintenance:update\"),title:r(\"maintenance:update\")?void 0:i(\"maintenance.noPermissionPerform\"),className:\"!px-3\",children:[a.jsx(co,{className:\"w-3.5 h-3.5\"}),i(\"common.reset\")]})]})]})})}function Qnt({overview:t,onPerform:e,onToggle:n,onSetHours:r,hasPermission:i,t:s}){const[o,l]=w.useState(!1),[c,d]=w.useState(!1),[u,m]=w.useState(t.total_print_hours.toFixed(1)),p=[...t.maintenance_items].sort((v,b)=>v.is_due&&!b.is_due?-1:!v.is_due&&b.is_due?1:v.is_warning&&!b.is_warning?-1:!v.is_warning&&b.is_warning?1:v.maintenance_type_id-b.maintenance_type_id),f=p.find(v=>v.enabled&&(v.is_due||v.is_warning)),y=()=>{const v=parseFloat(u);!isNaN(v)&&v>=0&&(r(t.printer_id,v),d(!1))};return a.jsxs(Tt,{className:\"overflow-hidden\",children:[a.jsxs(\"div\",{className:\"p-5\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-4\",children:[a.jsx(\"h2\",{className:\"text-xl font-semibold text-white\",children:t.printer_name}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[t.due_count>0&&a.jsxs(\"span\",{className:\"px-2.5 py-1 bg-red-500/20 text-red-400 text-xs font-medium rounded-full flex items-center gap-1.5\",children:[a.jsx(Dn,{className:\"w-3 h-3\"}),s(\"maintenance.overdueCount\",{count:t.due_count})]}),t.warning_count>0&&a.jsxs(\"span\",{className:\"px-2.5 py-1 bg-amber-500/20 text-amber-400 text-xs font-medium rounded-full flex items-center gap-1.5\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),s(\"maintenance.dueSoonCount\",{count:t.warning_count})]}),t.due_count===0&&t.warning_count===0&&a.jsxs(\"span\",{className:\"px-2.5 py-1 bg-bambu-green/20 text-bambu-green text-xs font-medium rounded-full flex items-center gap-1.5\",children:[a.jsx(Ur,{className:\"w-3 h-3\"}),s(\"maintenance.allGood\")]})]})]}),a.jsxs(\"button\",{onClick:()=>l(!o),className:\"flex items-center gap-1.5 px-3 py-1.5 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark rounded-lg transition-colors\",children:[o?a.jsx(cc,{className:\"w-4 h-4\"}):a.jsx(On,{className:\"w-4 h-4\"}),s(o?\"common.collapse\":\"common.expand\")]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-6 mt-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"p-2 bg-bambu-dark/50 rounded-lg\",children:a.jsx(jd,{className:\"w-4 h-4 text-bambu-gray\"})}),c?a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"input\",{type:\"number\",value:u,onChange:v=>m(v.target.value),onKeyDown:v=>{v.key===\"Enter\"&&y(),v.key===\"Escape\"&&d(!1)},className:\"w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm\",min:\"0\",step:\"1\",autoFocus:!0}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:s(\"common.hours\")}),a.jsx(De,{size:\"sm\",onClick:y,children:s(\"common.save\")}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:()=>d(!1),children:s(\"common.cancel\")})]}):a.jsxs(\"button\",{onClick:()=>{i(\"maintenance:update\")&&(m(Math.round(t.total_print_hours).toString()),d(!0))},className:`group ${i(\"maintenance:update\")?\"\":\"cursor-not-allowed opacity-60\"}`,title:i(\"maintenance:update\")?void 0:s(\"maintenance.noPermissionEditHours\"),children:[a.jsxs(\"div\",{className:`text-sm font-medium text-white ${i(\"maintenance:update\")?\"group-hover:text-bambu-green\":\"\"} transition-colors flex items-center gap-1`,children:[Math.round(t.total_print_hours),\" \",s(\"common.hours\"),a.jsx(dg,{className:`w-3 h-3 text-bambu-gray ${i(\"maintenance:update\")?\"group-hover:text-bambu-green\":\"\"}`})]}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray\",children:s(\"maintenance.totalPrintTime\")})]})]}),a.jsx(\"div\",{className:\"w-px h-10 bg-bambu-dark-tertiary\"}),f&&a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`p-2 rounded-lg ${f.is_due?\"bg-red-500/20\":\"bg-amber-500/20\"}`,children:(()=>{const v=Z0(f.maintenance_type_icon);return a.jsx(v,{className:`w-4 h-4 ${f.is_due?\"text-red-400\":\"text-amber-400\"}`})})()}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:`text-sm font-medium ${f.is_due?\"text-red-400\":\"text-amber-400\"}`,children:f.maintenance_type_name}),a.jsx(\"div\",{className:`text-xs ${f.is_due?\"text-red-400/70\":\"text-amber-400/70\"}`,children:f.is_due?s(\"common.overdue\"):s(\"maintenance.dueSoon\")})]})]})]})]}),o&&a.jsx(Mt,{className:\"pt-0 border-t border-bambu-dark-tertiary\",children:a.jsx(\"div\",{className:\"grid grid-cols-1 lg:grid-cols-2 gap-3 pt-4\",children:p.map(v=>a.jsx(Ynt,{item:v,onPerform:e,onToggle:n,hasPermission:i,t:s},v.id))})})]})}function Znt({overview:t,types:e,onUpdateInterval:n,onAddType:r,onUpdateType:i,onDeleteType:s,onRestoreDefaults:o,isRestoringDefaults:l,onAssignType:c,onRemoveItem:d,hasPermission:u,t:m}){const[p,f]=w.useState(null),[y,v]=w.useState(\"\"),[b,g]=w.useState(\"hours\"),[_,C]=w.useState(!1),[P,N]=w.useState(\"\"),[A,T]=w.useState(\"100\"),[F,k]=w.useState(\"hours\"),[D,H]=w.useState(\"Wrench\"),[z,Q]=w.useState(\"\"),[L,te]=w.useState(new Set),[ie,J]=w.useState(null),[oe,fe]=w.useState(null),re=w.useMemo(()=>t?t.map(B=>({id:B.printer_id,name:B.printer_name})):[],[t]),W=B=>t?t.filter(R=>R.maintenance_items.some(ae=>ae.maintenance_type_id===B)).map(R=>({printerId:R.printer_id,printerName:R.printer_name,itemId:R.maintenance_items.find(ae=>ae.maintenance_type_id===B)?.id})):[],ne=B=>{if(!t)return[];const R=new Set(W(B).map(ae=>ae.printerId));return re.filter(ae=>!R.has(ae.id))},[me,be]=w.useState(null),[Ce,q]=w.useState(\"\"),[Y,E]=w.useState(\"\"),[j,O]=w.useState(\"hours\"),[K,U]=w.useState(\"Wrench\"),[de,I]=w.useState(\"\"),G=B=>{be(B),q(B.name),E(B.default_interval_hours.toString()),O(B.interval_type||\"hours\"),U(B.icon||\"Wrench\"),I(B.wiki_url||\"\")},X=()=>{me&&Ce.trim()&&parseFloat(Y)>0&&(i(me.id,{name:Ce.trim(),default_interval_hours:parseFloat(Y),interval_type:j,icon:K,wiki_url:de.trim()||null}),be(null))},V=(B,R,ae)=>{const _e=parseFloat(y);if(!isNaN(_e)&&_e>0){const Se=Math.abs(_e-R)<.01?null:_e;n(B,{custom_interval_hours:Se,custom_interval_type:b!==ae?b:null})}f(null)},ee=B=>{B.preventDefault(),P.trim()&&parseFloat(A)>0&&L.size>0&&(r({name:P.trim(),default_interval_hours:parseFloat(A),interval_type:F,icon:D,wiki_url:z.trim()||null},Array.from(L)),N(\"\"),T(\"100\"),k(\"hours\"),Q(\"\"),te(new Set),C(!1))},se=B=>{te(R=>{const ae=new Set(R);return ae.has(B)?ae.delete(B):ae.add(B),ae})},ge=t?.map(B=>({printerId:B.printer_id,printerName:B.printer_name,items:B.maintenance_items.sort((R,ae)=>R.maintenance_type_id-ae.maintenance_type_id)})).sort((B,R)=>B.printerName.localeCompare(R.printerName))||[],he=e.filter(B=>B.is_system),le=e.filter(B=>!B.is_system);return a.jsxs(\"div\",{className:\"space-y-8\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:m(\"maintenance.maintenanceTypes\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:m(\"maintenance.maintenanceTypesDescription\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(De,{variant:\"secondary\",onClick:o,disabled:!u(\"maintenance:delete\")||l,title:u(\"maintenance:delete\")?void 0:m(\"maintenance.noPermissionDeleteTypes\"),children:m(\"maintenance.restoreDefaults\")}),a.jsxs(De,{onClick:()=>C(!_),disabled:!u(\"maintenance:create\"),title:u(\"maintenance:create\")?void 0:m(\"maintenance.noPermissionEditTypes\"),children:[a.jsx(sr,{className:\"w-4 h-4\"}),m(\"maintenance.addCustomType\")]})]})]}),_&&a.jsx(Tt,{className:\"mb-6\",children:a.jsx(Mt,{className:\"py-4\",children:a.jsxs(\"form\",{onSubmit:ee,children:[a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4\",children:[a.jsxs(\"div\",{className:\"lg:col-span-2\",children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1.5\",children:m(\"common.name\")}),a.jsx(\"input\",{type:\"text\",value:P,onChange:B=>N(B.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",placeholder:m(\"maintenance.exampleName\"),autoFocus:!0})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1.5\",children:m(\"maintenance.intervalType\")}),a.jsxs(\"select\",{value:F,onChange:B=>{k(B.target.value),B.target.value===\"days\"?T(\"30\"):T(\"100\")},className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"hours\",children:m(\"maintenance.printHours\")}),a.jsx(\"option\",{value:\"days\",children:m(\"maintenance.calendarDays\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1.5\",children:m(\"maintenance.intervalValue\",{type:F===\"days\"?m(\"maintenance.calendarDays\").toLowerCase():m(\"common.hours\")})}),a.jsx(\"input\",{type:\"number\",value:A,onChange:B=>T(B.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",min:\"1\"})]})]}),a.jsx(\"div\",{className:\"mt-4 flex items-end justify-between\",children:a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1.5\",children:m(\"maintenance.icon\")}),a.jsx(\"div\",{className:\"flex gap-1\",children:Object.keys(C0).map(B=>{const R=C0[B];return a.jsx(\"button\",{type:\"button\",onClick:()=>H(B),className:`p-2 rounded-lg transition-colors ${D===B?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\"}`,children:a.jsx(R,{className:\"w-4 h-4\"})},B)})})]})}),a.jsxs(\"div\",{className:\"mt-4\",children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1.5\",children:m(\"maintenance.documentationLink\")}),a.jsx(\"input\",{type:\"url\",value:z,onChange:B=>Q(B.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",placeholder:\"https://wiki.bambulab.com/...\"})]}),a.jsxs(\"div\",{className:\"mt-4\",children:[a.jsx(\"label\",{className:\"block text-xs text-bambu-gray mb-1.5\",children:m(\"maintenance.assignToPrinters\")}),a.jsx(\"div\",{className:\"flex flex-wrap gap-2\",children:re.map(B=>a.jsx(\"button\",{type:\"button\",onClick:()=>se(B.id),className:`px-3 py-1.5 rounded-lg text-sm transition-colors ${L.has(B.id)?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary\"}`,children:B.name},B.id))}),L.size===0&&a.jsx(\"p\",{className:\"text-xs text-orange-400 mt-1\",children:m(\"maintenance.selectAtLeastOnePrinter\")})]}),a.jsxs(\"div\",{className:\"mt-4 flex justify-end gap-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:()=>{C(!1),te(new Set)},children:m(\"common.cancel\")}),a.jsx(De,{type:\"submit\",disabled:!P.trim()||L.size===0,children:m(\"maintenance.addType\")})]})]})})}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3\",children:[he.map(B=>{const R=Z0(B.icon),ae=B.interval_type||\"hours\";return a.jsx(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-dark-tertiary\",children:a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"p-2.5 bg-bambu-dark rounded-lg\",children:a.jsx(R,{className:\"w-5 h-5 text-bambu-gray\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"div\",{className:\"text-sm font-medium text-white truncate\",children:B.name}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray mt-0.5 flex items-center gap-1\",children:[ae===\"days\"?a.jsx(oa,{className:\"w-3 h-3\"}):a.jsx(jd,{className:\"w-3 h-3\"}),M3(B.default_interval_hours,ae,m)]})]}),a.jsx(\"button\",{onClick:()=>{u(\"maintenance:delete\")&&fe(B)},disabled:!u(\"maintenance:delete\"),title:u(\"maintenance:delete\")?void 0:m(\"maintenance.noPermissionDeleteTypes\"),className:`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${u(\"maintenance:delete\")?\"\":\"opacity-50 cursor-not-allowed\"}`,children:a.jsx(en,{className:\"w-4 h-4\"})})]})},B.id)}),le.map(B=>{const R=Z0(B.icon),ae=B.interval_type||\"hours\";if(me?.id===B.id)return a.jsx(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green\",children:a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsx(\"input\",{type:\"text\",value:Ce,onChange:ye=>q(ye.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",placeholder:m(\"common.name\"),autoFocus:!0}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"select\",{value:j,onChange:ye=>O(ye.target.value),className:\"flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",children:[a.jsx(\"option\",{value:\"hours\",children:m(\"maintenance.printHours\")}),a.jsx(\"option\",{value:\"days\",children:m(\"maintenance.calendarDays\")})]}),a.jsx(\"input\",{type:\"number\",value:Y,onChange:ye=>E(ye.target.value),className:\"w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",min:\"1\"})]}),a.jsx(\"div\",{className:\"flex flex-wrap gap-1\",children:Object.keys(C0).map(ye=>{const je=C0[ye];return a.jsx(\"button\",{type:\"button\",onClick:()=>U(ye),className:`p-1.5 rounded transition-colors ${K===ye?\"bg-bambu-green text-white\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,children:a.jsx(je,{className:\"w-3.5 h-3.5\"})},ye)})}),a.jsx(\"input\",{type:\"url\",value:de,onChange:ye=>I(ye.target.value),className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none\",placeholder:m(\"maintenance.documentationLink\")}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{size:\"sm\",onClick:X,disabled:!Ce.trim(),children:m(\"common.save\")}),a.jsx(De,{size:\"sm\",variant:\"secondary\",onClick:()=>be(null),children:m(\"common.cancel\")})]})]})},B.id);const Se=W(B.id),ve=ne(B.id),Te=ie===B.id;return a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green/30\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"p-2.5 bg-bambu-green/20 rounded-lg\",children:a.jsx(R,{className:\"w-5 h-5 text-bambu-green\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-sm font-medium text-white truncate\",children:B.name}),a.jsx(\"span\",{className:\"px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded\",children:m(\"maintenance.custom\")})]}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray mt-0.5 flex items-center gap-1\",children:[ae===\"days\"?a.jsx(oa,{className:\"w-3 h-3\"}):a.jsx(jd,{className:\"w-3 h-3\"}),M3(B.default_interval_hours,ae,m)]})]}),a.jsxs(\"button\",{onClick:()=>J(Te?null:B.id),className:`px-2 py-1 rounded-lg border transition-colors flex items-center gap-1 ${Se.length>0?\"border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20\":\"border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20\"}`,title:m(\"maintenance.printersAssignedClick\",{count:Se.length}),children:[a.jsx(Er,{className:\"w-3 h-3\"}),a.jsx(\"span\",{className:\"text-xs font-medium\",children:Se.length}),a.jsx(On,{className:`w-3 h-3 transition-transform ${Te?\"rotate-180\":\"\"}`})]}),a.jsx(\"button\",{onClick:()=>G(B),disabled:!u(\"maintenance:update\"),title:u(\"maintenance:update\")?void 0:m(\"maintenance.noPermissionEditTypes\"),className:`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors ${u(\"maintenance:update\")?\"\":\"opacity-50 cursor-not-allowed\"}`,children:a.jsx(dg,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>{confirm(m(\"maintenance.deleteTypeConfirm\",{name:B.name}))&&s(B.id)},disabled:!u(\"maintenance:delete\"),title:u(\"maintenance:delete\")?void 0:m(\"maintenance.noPermissionDeleteTypes\"),className:`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${u(\"maintenance:delete\")?\"\":\"opacity-50 cursor-not-allowed\"}`,children:a.jsx(en,{className:\"w-4 h-4\"})})]}),Te&&a.jsxs(\"div\",{className:\"mt-3 pt-3 border-t border-bambu-dark-tertiary\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-2\",children:m(\"maintenance.assignedToPrinters\")}),Se.length===0?a.jsx(\"p\",{className:\"text-xs text-orange-400\",children:m(\"maintenance.noPrintersAssigned\")}):a.jsx(\"div\",{className:\"flex flex-wrap gap-1 mb-2\",children:Se.map(ye=>a.jsxs(\"span\",{className:\"inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark rounded text-xs text-white\",children:[ye.printerName,a.jsx(\"button\",{onClick:()=>ye.itemId&&d(ye.itemId),disabled:!u(\"maintenance:delete\"),title:u(\"maintenance:delete\")?m(\"maintenance.removeFromPrinter\"):m(\"maintenance.noPermissionRemovePrinter\"),className:`ml-1 ${u(\"maintenance:delete\")?\"hover:text-red-400\":\"opacity-50 cursor-not-allowed\"}`,children:\"×\"})]},ye.printerId))}),ve.length>0&&a.jsxs(\"div\",{className:\"flex flex-wrap gap-1\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray mr-1\",children:m(\"maintenance.addPrinterShort\")}),ve.map(ye=>a.jsxs(\"button\",{onClick:()=>c(ye.id,B.id),disabled:!u(\"maintenance:create\"),title:u(\"maintenance:create\")?void 0:m(\"maintenance.noPermissionAssignPrinter\"),className:`px-2 py-1 bg-bambu-dark rounded text-xs transition-colors ${u(\"maintenance:create\")?\"hover:bg-bambu-green/20 text-bambu-gray hover:text-bambu-green\":\"opacity-50 cursor-not-allowed text-bambu-gray\"}`,children:[\"+ \",ye.name]},ye.id))]})]})]},B.id)})]})]}),ge.length>0&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:m(\"maintenance.intervalOverrides\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:m(\"maintenance.intervalOverridesDescription\")})]}),a.jsx(\"div\",{className:\"space-y-4\",children:ge.map(B=>a.jsx(Tt,{children:a.jsxs(Mt,{className:\"py-4\",children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white mb-3\",children:B.printerName}),a.jsx(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2\",children:B.items.map(R=>{const ae=Z0(R.maintenance_type_icon),_e=e.find(je=>je.id===R.maintenance_type_id),Se=_e?.default_interval_hours||R.interval_hours,ve=_e?.interval_type||\"hours\",Te=R.interval_type||\"hours\",ye=p===R.id;return a.jsxs(\"div\",{className:\"flex items-center gap-2 p-2.5 bg-bambu-dark rounded-lg\",children:[a.jsx(ae,{className:\"w-4 h-4 text-bambu-gray shrink-0\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray flex-1 truncate\",children:R.maintenance_type_name}),ye?a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[b===\"days\"?a.jsx(oa,{className:\"w-3.5 h-3.5 text-bambu-gray shrink-0\"}):a.jsx(jd,{className:\"w-3.5 h-3.5 text-bambu-gray shrink-0\"}),a.jsxs(\"select\",{value:b,onChange:je=>g(je.target.value),className:\"px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs\",children:[a.jsx(\"option\",{value:\"hours\",children:m(\"maintenance.printHours\")}),a.jsx(\"option\",{value:\"days\",children:m(\"maintenance.calendarDays\")})]}),a.jsx(\"input\",{type:\"number\",value:y,onChange:je=>v(je.target.value),onKeyDown:je=>{je.key===\"Enter\"&&V(R.id,Se,ve),je.key===\"Escape\"&&f(null)},className:\"w-16 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs\",min:\"1\"}),a.jsx(De,{size:\"sm\",onClick:()=>V(R.id,Se,ve),children:\"OK\"})]}):a.jsxs(\"button\",{onClick:()=>{u(\"maintenance:update\")&&(f(R.id),v(R.interval_hours.toString()),g(Te))},disabled:!u(\"maintenance:update\"),title:u(\"maintenance:update\")?void 0:m(\"maintenance.noPermissionEditIntervals\"),className:`px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs font-medium text-white transition-colors flex items-center gap-1 ${u(\"maintenance:update\")?\"hover:bg-bambu-dark-secondary hover:border-bambu-green\":\"opacity-50 cursor-not-allowed\"}`,children:[Te===\"days\"?a.jsx(oa,{className:\"w-3 h-3\"}):a.jsx(jd,{className:\"w-3 h-3\"}),M3(R.interval_hours,Te,m),a.jsx(dg,{className:\"w-3 h-3 text-bambu-gray\"})]})]},R.id)})})]})},B.printerId))})]}),ge.length===0&&a.jsx(Tt,{children:a.jsxs(Mt,{className:\"text-center py-12\",children:[a.jsx(Yn,{className:\"w-12 h-12 mx-auto mb-4 text-bambu-gray/30\"}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:m(\"common.noPrinters\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray/70 mt-1\",children:m(\"maintenance.intervalOverridesDescription\")})]})}),oe&&a.jsx(yn,{title:m(\"maintenance.deleteSystemTypeTitle\"),message:m(\"maintenance.deleteSystemTypeMessage\",{name:oe.name}),confirmText:m(\"common.delete\"),cancelText:m(\"common.cancel\"),variant:\"danger\",cancelVariant:\"primary\",cardClassName:\"bg-red-950/70 border border-red-800/70\",onConfirm:()=>{s(oe.id),fe(null)},onCancel:()=>fe(null)})]})}function Jnt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),{hasPermission:r}=kr(),[i,s]=w.useState(\"status\"),{data:o,isLoading:l}=Xe({queryKey:[\"maintenanceOverview\"],queryFn:ue.getMaintenanceOverview}),{data:c}=Xe({queryKey:[\"maintenanceTypes\"],queryFn:ue.getMaintenanceTypes}),d=it({mutationFn:({id:A,notes:T})=>ue.performMaintenance(A,T),onSuccess:()=>{e.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),e.invalidateQueries({queryKey:[\"maintenanceSummary\"]}),n(t(\"maintenance.maintenanceComplete\"))},onError:A=>{n(A.message,\"error\")}}),u=it({mutationFn:({id:A,data:T})=>ue.updateMaintenanceItem(A,T),onSuccess:()=>{e.invalidateQueries({queryKey:[\"maintenanceOverview\"]})},onError:A=>{n(A.message,\"error\")}}),m=it({mutationFn:({id:A,data:T})=>ue.updateMaintenanceType(A,T),onSuccess:()=>{e.invalidateQueries({queryKey:[\"maintenanceTypes\"]}),e.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),n(t(\"maintenance.typeUpdated\"))},onError:A=>{n(A.message,\"error\")}}),p=it({mutationFn:ue.deleteMaintenanceType,onSuccess:()=>{e.invalidateQueries({queryKey:[\"maintenanceTypes\"]}),e.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),n(t(\"maintenance.typeDeleted\"))},onError:A=>{n(A.message,\"error\")}}),f=it({mutationFn:ue.restoreDefaultMaintenanceTypes,onSuccess:A=>{e.invalidateQueries({queryKey:[\"maintenanceTypes\"]}),e.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),n(t(\"maintenance.defaultsRestored\",{count:A.restored}))},onError:A=>{n(A.message,\"error\")}}),y=it({mutationFn:({printerId:A,hours:T})=>ue.setPrinterHours(A,T),onSuccess:()=>{e.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),e.invalidateQueries({queryKey:[\"maintenanceSummary\"]}),n(t(\"maintenance.printHoursUpdated\"))},onError:A=>{n(A.message,\"error\")}}),v=it({mutationFn:({printerId:A,typeId:T})=>ue.assignMaintenanceType(A,T),onSuccess:()=>{e.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),n(t(\"maintenance.printerAssigned\"))},onError:A=>{n(A.message,\"error\")}}),b=it({mutationFn:ue.removeMaintenanceItem,onSuccess:()=>{e.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),n(t(\"maintenance.printerRemoved\"))},onError:A=>{n(A.message,\"error\")}}),g=A=>{d.mutate({id:A})},_=(A,T)=>{u.mutate({id:A,data:{enabled:T}})},C=(A,T)=>{y.mutate({printerId:A,hours:T})};if(l)return a.jsx(\"div\",{className:\"p-4 md:p-8 flex justify-center\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})});const P=o?.reduce((A,T)=>A+T.due_count,0)||0,N=o?.reduce((A,T)=>A+T.warning_count,0)||0;return a.jsxs(\"div\",{className:\"p-4 md:p-8\",children:[a.jsxs(\"div\",{className:\"mb-6\",children:[a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:t(\"maintenance.title\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-sm mt-1\",children:i===\"status\"?a.jsxs(a.Fragment,{children:[P>0&&a.jsx(\"span\",{className:\"text-red-400\",children:t(\"maintenance.dueCount\",{count:P})}),P>0&&N>0&&\" · \",N>0&&a.jsx(\"span\",{className:\"text-amber-400\",children:t(\"maintenance.warningCount\",{count:N})}),P===0&&N===0&&a.jsx(\"span\",{className:\"text-bambu-green\",children:t(\"maintenance.allOk\")})]}):t(\"maintenance.configureSettings\")})]}),a.jsxs(\"div\",{className:\"flex gap-1 mb-6 border-b border-bambu-dark-tertiary\",children:[a.jsx(\"button\",{onClick:()=>s(\"status\"),className:`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${i===\"status\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray border-transparent hover:text-white\"}`,children:t(\"maintenance.statusTab\")}),a.jsx(\"button\",{onClick:()=>s(\"settings\"),className:`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${i===\"settings\"?\"text-bambu-green border-bambu-green\":\"text-bambu-gray border-transparent hover:text-white\"}`,children:t(\"maintenance.settingsTab\")})]}),i===\"status\"?a.jsx(\"div\",{className:\"space-y-6\",children:o&&o.length>0?[...o].sort((A,T)=>{const F=A.due_count*10+A.warning_count,k=T.due_count*10+T.warning_count;return F!==k?k-F:A.printer_name.localeCompare(T.printer_name)}).map(A=>a.jsx(Qnt,{overview:A,onPerform:g,onToggle:_,onSetHours:C,hasPermission:r,t},A.printer_id)):a.jsx(Tt,{children:a.jsxs(Mt,{className:\"text-center py-16\",children:[a.jsx(mg,{className:\"w-16 h-16 mx-auto mb-4 text-bambu-gray/30\"}),a.jsx(\"p\",{className:\"text-lg font-medium text-white mb-2\",children:t(\"common.noPrinters\")}),a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"maintenance.configureSettings\")})]})})}):a.jsx(Znt,{overview:o,types:c||[],onUpdateInterval:(A,T)=>u.mutate({id:A,data:T}),onAddType:async(A,T)=>{const F=await ue.createMaintenanceType(A);for(const k of T)await ue.assignMaintenanceType(k,F.id);e.invalidateQueries({queryKey:[\"maintenanceTypes\"]}),e.invalidateQueries({queryKey:[\"maintenanceOverview\"]}),n(t(\"maintenance.typeUpdated\"))},onUpdateType:(A,T)=>m.mutate({id:A,data:T}),onDeleteType:A=>p.mutate(A),onRestoreDefaults:()=>f.mutate(),isRestoringDefaults:f.isPending,onAssignType:(A,T)=>v.mutate({printerId:A,typeId:T}),onRemoveItem:A=>b.mutate(A),hasPermission:r,t})]})}const _Y=[\"#ef4444\",\"#f97316\",\"#eab308\",\"#22c55e\",\"#06b6d4\",\"#3b82f6\",\"#8b5cf6\",\"#ec4899\",\"#6b7280\"];function Hle({project:t,onClose:e,onSave:n,isLoading:r,currencySymbol:i,t:s}){const[o,l]=w.useState(t?.name||\"\"),[c,d]=w.useState(t?.description||\"\"),[u,m]=w.useState(t?.color||_Y[0]),[p,f]=w.useState(t?.target_count?.toString()||\"\"),[y,v]=w.useState(t?.target_parts_count?.toString()||\"\"),[b,g]=w.useState(t?.status||\"active\"),[_,C]=w.useState(t?.tags||\"\"),[P,N]=w.useState(t?.due_date?.split(\"T\")[0]||\"\"),[A,T]=w.useState(t?.priority||\"normal\"),[F,k]=w.useState(t?.budget?.toString()||\"\"),D=H=>{H.preventDefault(),n({name:o.trim(),description:c.trim()||void 0,color:u,target_count:p?parseInt(p,10):void 0,target_parts_count:y?parseInt(y,10):void 0,tags:_.trim()||void 0,due_date:P||void 0,priority:A,budget:F.trim()?parseFloat(F):null,...t&&{status:b}})};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary\",children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:s(t?\"projects.editProject\":\"projects.newProject\")})}),a.jsxs(\"form\",{onSubmit:D,className:\"p-4 space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"common.name\")}),a.jsx(\"input\",{type:\"text\",value:o,onChange:H=>l(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:s(\"projects.namePlaceholder\"),required:!0})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"common.description\")}),a.jsx(\"textarea\",{value:c,onChange:H=>d(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none\",placeholder:s(\"projects.descriptionPlaceholder\"),rows:2})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"projects.color\")}),a.jsx(\"div\",{className:\"flex gap-2 flex-wrap\",children:_Y.map(H=>a.jsx(\"button\",{type:\"button\",onClick:()=>m(H),className:`w-8 h-8 rounded-full transition-transform ${u===H?\"ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110\":\"\"}`,style:{backgroundColor:H}},H))})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"projects.targetPlates\")}),a.jsx(\"input\",{type:\"number\",value:p,onChange:H=>f(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:s(\"projects.targetPlatesPlaceholder\"),min:\"1\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:s(\"projects.targetPlatesHelp\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"projects.targetParts\")}),a.jsx(\"input\",{type:\"number\",value:y,onChange:H=>v(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:s(\"projects.targetPartsPlaceholder\"),min:\"1\"}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:s(\"projects.targetPartsHelp\")})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"projects.tagsLabel\")}),a.jsx(\"input\",{type:\"text\",value:_,onChange:H=>C(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:s(\"projects.tagsPlaceholder\")})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"projects.dueDate\")}),a.jsx(\"input\",{type:\"date\",value:P,onChange:H=>N(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"projects.priority\")}),a.jsxs(\"select\",{value:A,onChange:H=>T(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green\",children:[a.jsx(\"option\",{value:\"low\",children:s(\"projects.priorityLow\")}),a.jsx(\"option\",{value:\"normal\",children:s(\"projects.priorityNormal\")}),a.jsx(\"option\",{value:\"high\",children:s(\"projects.priorityHigh\")}),a.jsx(\"option\",{value:\"urgent\",children:s(\"projects.priorityUrgent\")})]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"projectDetail.cost.budget\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"span\",{className:\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray pointer-events-none\",children:i}),a.jsx(\"input\",{type:\"number\",step:\"0.01\",min:\"0\",value:F,onChange:H=>k(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded pl-8 pr-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:\"0.00\"})]})]}),t&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"common.status\")}),a.jsxs(\"select\",{value:b,onChange:H=>g(H.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green\",children:[a.jsx(\"option\",{value:\"active\",children:s(\"projects.statusActive\")}),a.jsx(\"option\",{value:\"completed\",children:s(\"projects.statusCompleted\")}),a.jsx(\"option\",{value:\"archived\",children:s(\"projects.statusArchived\")})]})]}),a.jsxs(\"div\",{className:\"flex justify-end gap-2 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:e,children:s(\"common.cancel\")}),a.jsx(De,{type:\"submit\",disabled:!o.trim()||r,children:r?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):s(t?\"common.save\":\"projects.create\")})]})]})]})})}function ert({project:t,onClick:e,onEdit:n,onDelete:r,hasPermission:i,t:s}){const o=t.target_count?Math.round(t.archive_count/t.target_count*100):0,l=t.target_parts_count?Math.round(t.completed_count/t.target_parts_count*100):0,c=t.status===\"completed\",d=t.status===\"archived\",[u,m]=w.useState(!1),f=c?{icon:Vc,color:\"text-bambu-green\",bg:\"bg-bambu-green/10\"}:d?{icon:so,color:\"text-bambu-gray\",bg:\"bg-bambu-gray/10\"}:t.queue_count>0?{icon:Yn,color:\"text-blue-400\",bg:\"bg-blue-400/10\"}:{icon:Bs,color:\"text-bambu-gray\",bg:\"bg-bambu-gray/10\"};return a.jsxs(\"div\",{className:\"group relative bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary hover:border-bambu-green/50 hover:shadow-lg hover:shadow-bambu-green/5 transition-all duration-300 cursor-pointer overflow-hidden\",onClick:e,children:[a.jsx(\"div\",{className:\"absolute top-0 left-0 w-1.5 h-full\",style:{backgroundColor:t.color||\"#6b7280\",boxShadow:`0 0 12px ${t.color||\"#6b7280\"}40`}}),a.jsxs(\"div\",{className:\"p-5 pl-6\",children:[a.jsxs(\"div\",{className:\"flex items-start justify-between mb-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 min-w-0 flex-1\",children:[a.jsx(\"div\",{className:`p-2 rounded-lg ${f.bg} flex-shrink-0`,children:a.jsx(f.icon,{className:`w-5 h-5 ${f.color}`})}),a.jsxs(\"div\",{className:\"min-w-0 flex-1\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-wrap\",children:[a.jsx(\"h3\",{className:\"font-semibold text-white truncate\",children:t.name}),t.target_parts_count?a.jsxs(\"span\",{className:`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${l>=100?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark text-bambu-gray\"}`,children:[t.completed_count,\"/\",t.target_parts_count,\" \",s(\"projects.parts\")]}):t.target_count?a.jsxs(\"span\",{className:`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${o>=100?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark text-bambu-gray\"}`,children:[t.archive_count,\"/\",t.target_count,\" \",s(\"projects.plates\")]}):t.completed_count>0?a.jsxs(\"span\",{className:\"text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray\",children:[t.completed_count,\" \",s(\"projects.parts\")]}):null,c&&a.jsx(\"span\",{className:\"text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap\",children:s(\"projects.done\")}),d&&a.jsx(\"span\",{className:\"text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap\",children:s(\"projects.statusArchived\")})]}),t.description&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray/70 mt-1 line-clamp-1\",children:t.description}),t.archives&&t.archives.length>0&&(()=>{const y=t.archives.map(_=>_.filament_type).filter(Boolean).flatMap(_=>_.split(\",\").map(C=>C.trim())).filter(Boolean),v=[...new Set(y)],b=t.archives.map(_=>_.filament_color).filter(Boolean).flatMap(_=>_.split(\",\").map(C=>C.trim())).filter(_=>_.startsWith(\"#\")||/^[0-9A-Fa-f]{6}$/.test(_)),g=[...new Set(b)];return v.length===0&&g.length===0?null:a.jsxs(\"div\",{className:\"flex items-center gap-2 mt-1.5\",children:[v.slice(0,3).map(_=>a.jsx(\"span\",{className:\"text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded\",children:_},_)),g.length>0&&a.jsxs(\"div\",{className:\"flex items-center gap-0.5\",children:[g.slice(0,5).map(_=>a.jsx(\"div\",{className:\"w-3 h-3 rounded-full border border-black/20\",style:{backgroundColor:_.startsWith(\"#\")?_:`#${_}`},title:_},_)),g.length>5&&a.jsxs(\"span\",{className:\"text-[10px] text-bambu-gray ml-0.5\",children:[\"+\",g.length-5]})]})]})})()]})]}),a.jsxs(\"div\",{className:\"relative\",onClick:y=>y.stopPropagation(),children:[a.jsx(\"button\",{className:\"p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100\",onClick:()=>m(!u),children:a.jsx(Jh,{className:\"w-4 h-4\"})}),u&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>m(!1)}),a.jsxs(\"div\",{className:\"absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]\",children:[a.jsxs(\"button\",{className:`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${i(\"projects:update\")?\"text-white hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{i(\"projects:update\")&&(n(),m(!1))},disabled:!i(\"projects:update\"),title:i(\"projects:update\")?void 0:s(\"projects.noEditPermission\"),children:[a.jsx(dg,{className:\"w-4 h-4\"}),s(\"common.edit\")]}),a.jsxs(\"button\",{className:`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${i(\"projects:delete\")?\"text-red-400 hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{i(\"projects:delete\")&&(r(),m(!1))},disabled:!i(\"projects:delete\"),title:i(\"projects:delete\")?void 0:s(\"projects.noDeletePermission\"),children:[a.jsx(en,{className:\"w-4 h-4\"}),s(\"common.delete\")]})]})]})]})]}),a.jsx(\"div\",{className:\"mb-4\",children:t.target_count||t.target_parts_count?a.jsxs(\"div\",{className:\"space-y-3\",children:[t.target_count&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between text-xs mb-1\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:s(\"projects.plates\")}),a.jsxs(\"span\",{className:o>=100?\"text-bambu-green font-medium\":\"text-white\",children:[t.archive_count,\" / \",t.target_count]})]}),a.jsx(\"div\",{className:\"h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm\",children:a.jsx(\"div\",{className:\"h-full transition-all duration-500 ease-out rounded-full relative\",style:{width:`${Math.min(o,100)}%`,background:o>=100?\"linear-gradient(90deg, #22c55e, #4ade80)\":`linear-gradient(90deg, ${t.color||\"#6b7280\"}, ${t.color||\"#6b7280\"}cc)`,boxShadow:`0 0 8px ${o>=100?\"#22c55e\":t.color||\"#6b7280\"}60`}})})]}),t.target_parts_count&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between text-xs mb-1\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:s(\"projects.parts\")}),a.jsxs(\"span\",{className:l>=100?\"text-bambu-green font-medium\":\"text-white\",children:[t.completed_count,\" / \",t.target_parts_count]})]}),a.jsx(\"div\",{className:\"h-2 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm\",children:a.jsx(\"div\",{className:\"h-full transition-all duration-500 ease-out rounded-full relative\",style:{width:`${Math.min(l,100)}%`,background:l>=100?\"linear-gradient(90deg, #22c55e, #4ade80)\":`linear-gradient(90deg, ${t.color||\"#6b7280\"}, ${t.color||\"#6b7280\"}cc)`,boxShadow:`0 0 8px ${l>=100?\"#22c55e\":t.color||\"#6b7280\"}60`}})})]}),t.failed_count>0&&a.jsxs(\"div\",{className:\"text-xs text-red-400\",children:[t.failed_count,\" \",s(\"projects.failed\")]})]}):t.completed_count>0||t.failed_count>0?a.jsxs(\"div\",{className:\"flex items-center gap-4 text-xs\",children:[t.completed_count>0&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-bambu-gray\",children:[a.jsx(so,{className:\"w-3.5 h-3.5\"}),a.jsxs(\"span\",{children:[t.completed_count,\" \",s(\"projects.completed\")]})]}),t.failed_count>0&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-red-400\",children:[a.jsx(Dn,{className:\"w-3.5 h-3.5\"}),a.jsxs(\"span\",{children:[t.failed_count,\" \",s(\"projects.failed\")]})]}),t.queue_count>0&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-blue-400\",children:[a.jsx(Yn,{className:\"w-3.5 h-3.5\"}),a.jsxs(\"span\",{children:[t.queue_count,\" \",s(\"projects.inQueue\")]})]})]}):a.jsx(\"div\",{className:\"text-xs text-bambu-gray/60 italic\",children:s(\"projects.noPrintsYet\")})}),t.archives&&t.archives.length>0&&a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsx(\"div\",{className:\"grid grid-cols-4 gap-1.5\",children:t.archives.slice(0,4).map(y=>a.jsxs(\"div\",{className:\"relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary\",title:y.print_name||\"Unknown\",children:[y.thumbnail_path?a.jsx(\"img\",{src:ue.getArchiveThumbnail(y.id),alt:y.print_name||\"\",className:\"w-full h-full object-cover\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center text-bambu-gray/50\",children:a.jsx(Ra,{className:\"w-6 h-6\"})}),y.status===\"failed\"&&a.jsx(\"div\",{className:\"absolute inset-0 bg-red-500/40 flex items-center justify-center\",children:a.jsx(Dn,{className:\"w-4 h-4 text-white\"})})]},y.id))}),t.archive_count>4&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1.5 text-center\",children:s(\"common.more\",{count:t.archive_count-4})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-4 text-xs text-bambu-gray\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",title:s(\"projects.printJobs\"),children:[a.jsx(da,{className:\"w-3.5 h-3.5 text-blue-400\"}),a.jsxs(\"span\",{children:[t.archive_count,\" \",s(\"projects.plates\")]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",title:s(\"projects.partsPrinted\"),children:[a.jsx(Ra,{className:\"w-3.5 h-3.5 text-bambu-green\"}),a.jsxs(\"span\",{children:[t.completed_count,\" \",s(\"projects.parts\")]})]}),t.failed_count>0&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-red-400\",title:s(\"projects.failedParts\"),children:[a.jsx(Dn,{className:\"w-3.5 h-3.5\"}),a.jsx(\"span\",{children:t.failed_count})]}),t.queue_count>0&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-yellow-400\",title:s(\"projects.inQueue\"),children:[a.jsx(sF,{className:\"w-3.5 h-3.5\"}),a.jsx(\"span\",{children:t.queue_count})]})]}),a.jsx(ti,{className:\"w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors\"})]})]})]})}function trt(){const{t}=Ft(),e=qo(),n=nn(),{showToast:r}=hn(),{hasPermission:i}=kr(),[s,o]=w.useState(!1),[l,c]=w.useState(),[d,u]=w.useState(\"active\"),[m,p]=w.useState(null),{data:f}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),y=Eo(f?.currency||\"USD\"),{data:v,isLoading:b}=Xe({queryKey:[\"projects\",d===\"all\"?void 0:d],queryFn:()=>ue.getProjects(d===\"all\"?void 0:d)}),g=it({mutationFn:te=>ue.createProject(te),onSuccess:()=>{n.invalidateQueries({queryKey:[\"projects\"]}),o(!1),r(t(\"projects.toast.created\"),\"success\")},onError:te=>{r(te.message,\"error\")}}),_=it({mutationFn:({id:te,data:ie})=>ue.updateProject(te,ie),onSuccess:()=>{n.invalidateQueries({queryKey:[\"projects\"]}),o(!1),c(void 0),r(t(\"projects.toast.updated\"),\"success\")},onError:te=>{r(te.message,\"error\")}}),C=it({mutationFn:te=>ue.deleteProject(te),onSuccess:()=>{p(null),r(t(\"projects.toast.deleted\"),\"success\"),setTimeout(()=>window.location.reload(),100)},onError:te=>{p(null),r(te.message,\"error\")}}),P=it({mutationFn:te=>ue.importProject(te),onSuccess:()=>{n.invalidateQueries({queryKey:[\"projects\"]}),r(t(\"projects.toast.imported\"),\"success\")},onError:te=>{r(te.message,\"error\")}}),N=w.useRef(null),A=async()=>{try{const te=await ue.getProjects(),ie=await Promise.all(te.map(async re=>await ue.exportProjectJson(re.id))),J=new Blob([JSON.stringify(ie,null,2)],{type:\"application/json\"}),oe=URL.createObjectURL(J),fe=document.createElement(\"a\");fe.href=oe,fe.download=`bambuddy_projects_${new Date().toISOString().split(\"T\")[0]}.json`,fe.click(),URL.revokeObjectURL(oe),r(t(\"projects.toast.exported\"),\"success\")}catch(te){r(te.message,\"error\")}},T=()=>{N.current?.click()},F=async te=>{const ie=te.target.files?.[0];if(ie){try{if(ie.name.toLowerCase().endsWith(\".zip\"))await ue.importProjectFile(ie),n.invalidateQueries({queryKey:[\"projects\"]}),r(t(\"projects.toast.imported\"),\"success\");else{const oe=await ie.text(),fe=JSON.parse(oe),re=Array.isArray(fe)?fe:[fe];for(const W of re)await P.mutateAsync(W);re.length>1&&r(t(\"projects.toast.multipleImported\",{count:re.length}),\"success\")}}catch(J){r(`${t(\"projects.toast.importFailed\")}: ${J.message}`,\"error\")}te.target.value=\"\"}},k=te=>{l?_.mutate({id:l.id,data:te}):g.mutate(te)},D=te=>{c(te),o(!0)},H=te=>{e(`/projects/${te.id}`)},z=te=>{p(te)},Q=()=>{m!==null&&C.mutate(m)},L=v?.reduce((te,ie)=>(te[ie.status]=(te[ie.status]||0)+1,te.all=(te.all||0)+1,te),{})||{};return a.jsxs(\"div\",{className:\"p-4 md:p-8 space-y-8\",children:[a.jsx(\"input\",{ref:N,type:\"file\",accept:\".json,.zip\",onChange:F,className:\"hidden\"}),a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row sm:items-center justify-between gap-4\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"h1\",{className:\"text-2xl font-bold text-white flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"p-2.5 bg-bambu-green/10 rounded-xl\",children:a.jsx(Bs,{className:\"w-6 h-6 text-bambu-green\"})}),t(\"projects.title\")]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-2 ml-14\",children:t(\"projects.subtitle\")})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(De,{variant:\"secondary\",onClick:T,disabled:!i(\"projects:create\"),title:i(\"projects:create\")?t(\"projects.importProject\"):t(\"projects.noImportPermission\"),children:[a.jsx(La,{className:\"w-4 h-4 mr-2\"}),t(\"projects.import\")]}),a.jsxs(De,{variant:\"secondary\",onClick:A,disabled:!i(\"projects:read\"),title:i(\"projects:read\")?t(\"projects.exportAll\"):t(\"projects.noExportPermission\"),children:[a.jsx(ga,{className:\"w-4 h-4 mr-2\"}),t(\"projects.export\")]}),a.jsxs(De,{onClick:()=>o(!0),className:\"sm:w-auto w-full\",disabled:!i(\"projects:create\"),title:i(\"projects:create\")?void 0:t(\"projects.noCreatePermission\"),children:[a.jsx(sr,{className:\"w-4 h-4 mr-2\"}),t(\"projects.newProject\")]})]})]}),a.jsx(\"div\",{className:\"flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit\",children:[{key:\"active\",label:t(\"projects.statusActive\"),icon:Yn},{key:\"completed\",label:t(\"projects.statusCompleted\"),icon:Vc},{key:\"archived\",label:t(\"projects.statusArchived\"),icon:so},{key:\"all\",label:t(\"common.all\"),icon:Bs}].map(({key:te,label:ie,icon:J})=>a.jsxs(\"button\",{onClick:()=>u(te),className:`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${d===te?\"bg-bambu-card text-white shadow-sm\":\"text-bambu-gray hover:text-white\"}`,children:[a.jsx(J,{className:\"w-4 h-4\"}),a.jsx(\"span\",{children:ie}),L[te]>0&&a.jsx(\"span\",{className:`text-xs px-1.5 py-0.5 rounded-full ${d===te?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark-tertiary\"}`,children:L[te]})]},te))}),b?a.jsx(\"div\",{className:\"flex items-center justify-center py-20\",children:a.jsxs(\"div\",{className:\"flex flex-col items-center gap-3\",children:[a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green\"}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"projects.loading\")})]})}):v?.length===0?a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center py-20 px-4\",children:[a.jsx(\"div\",{className:\"p-4 bg-bambu-dark rounded-2xl mb-4\",children:a.jsx(Bs,{className:\"w-12 h-12 text-bambu-gray/50\"})}),a.jsx(\"h3\",{className:\"text-lg font-medium text-white mb-2\",children:d===\"all\"?t(\"projects.noProjects\"):t(\"projects.noProjectsFiltered\",{status:d})}),a.jsx(\"p\",{className:\"text-bambu-gray text-center max-w-md mb-6\",children:d===\"all\"?t(\"projects.createFirst\"):t(\"projects.noProjectsFilteredHelp\",{status:d})}),d===\"all\"&&a.jsxs(De,{onClick:()=>o(!0),disabled:!i(\"projects:create\"),title:i(\"projects:create\")?void 0:t(\"projects.noCreatePermission\"),children:[a.jsx(sr,{className:\"w-4 h-4 mr-2\"}),t(\"projects.createFirstButton\")]})]}):a.jsx(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8\",children:v?.map(te=>a.jsx(ert,{project:te,onClick:()=>H(te),onEdit:()=>D(te),onDelete:()=>z(te.id),hasPermission:i,t},te.id))}),m!==null&&a.jsx(yn,{title:t(\"projects.deleteProject\"),message:t(\"projects.deleteConfirm\"),confirmText:t(\"projects.deleteProject\"),variant:\"danger\",onConfirm:Q,onCancel:()=>p(null)}),s&&a.jsx(Hle,{project:l,onClose:()=>{o(!1),c(void 0)},onSave:k,isLoading:g.isPending||_.isPending,currencySymbol:y,t})]})}function nrt(t){const e=t.toLowerCase();return e.endsWith(\".gcode\")||e.endsWith(\".gcode.3mf\")}function rrt(t){return t>=1e3?`${(t/1e3).toFixed(2)}kg`:`${Math.round(t)}g`}function art({status:t,t:e}){const n={active:\"bg-bambu-green/20 text-bambu-green\",completed:\"bg-blue-500/20 text-blue-400\",archived:\"bg-bambu-gray/20 text-bambu-gray\"},r=n[t]||n.active,i={active:e(\"projectDetail.status.active\"),completed:e(\"projectDetail.status.completed\"),archived:e(\"projectDetail.status.archived\")};return a.jsx(\"span\",{className:`px-2 py-1 rounded text-sm font-medium ${r}`,children:i[t]||t.charAt(0).toUpperCase()+t.slice(1)})}function kY({icon:t,label:e,value:n,subValue:r,hint:i,color:s=\"text-bambu-gray\"}){return a.jsx(Tt,{children:a.jsx(Mt,{className:\"p-4\",children:a.jsxs(\"div\",{className:\"flex items-center gap-3\",title:i,children:[a.jsx(\"div\",{className:`p-2 rounded-lg bg-bambu-dark ${s}`,children:a.jsx(t,{className:\"w-5 h-5\"})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:e}),a.jsx(\"p\",{className:\"text-xl font-semibold text-white\",children:n}),r&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray/70\",children:r})]})]})})})}function irt({archives:t,t:e}){return t.length===0?a.jsxs(\"div\",{className:\"text-center py-8 text-bambu-gray\",children:[a.jsx(Ra,{className:\"w-12 h-12 mx-auto mb-2 opacity-50\"}),a.jsx(\"p\",{children:e(\"projectDetail.noPrints\")})]}):a.jsx(\"div\",{className:\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3\",children:t.map(n=>a.jsxs(Do,{to:`/archives?search=${encodeURIComponent(n.print_name||\"\")}`,className:\"group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors\",children:[n.thumbnail_path?a.jsx(\"img\",{src:ue.getArchiveThumbnail(n.id),alt:n.print_name||\"Print\",className:\"w-full h-full object-cover\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center text-bambu-gray\",children:a.jsx(Ra,{className:\"w-8 h-8\"})}),n.status===\"failed\"&&a.jsx(\"div\",{className:\"absolute inset-0 bg-red-500/30 flex items-center justify-center\",children:a.jsx(Ma,{className:\"w-8 h-8 text-white\"})}),n.status===\"completed\"&&a.jsx(\"div\",{className:\"absolute top-1 right-1\",children:a.jsx(yr,{className:\"w-4 h-4 text-bambu-green\"})}),a.jsx(\"div\",{className:\"absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity\",children:a.jsx(\"p\",{className:\"text-xs text-white truncate\",children:n.print_name||\"Unknown\"})})]},n.id))})}function srt({priority:t,t:e}){const n={low:{color:\"bg-gray-500/20 text-gray-400\",label:e(\"projectDetail.priority.low\")},normal:{color:\"bg-blue-500/20 text-blue-400\",label:e(\"projectDetail.priority.normal\")},high:{color:\"bg-orange-500/20 text-orange-400\",label:e(\"projectDetail.priority.high\")},urgent:{color:\"bg-red-500/20 text-red-400\",label:e(\"projectDetail.priority.urgent\")}},{color:r,label:i}=n[t]||n.normal;return a.jsxs(\"span\",{className:`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 ${r}`,children:[t===\"urgent\"&&a.jsx(Dn,{className:\"w-3 h-3\"}),i]})}function E3(t,e){if(!t)return null;const n=Zn(t);if(!n)return null;const r=new Date,i=Math.ceil((n.getTime()-r.getTime())/(1e3*60*60*24));return i<0?{color:\"text-red-400\",label:e(\"projectDetail.dueDate.overdue\")}:i===0?{color:\"text-orange-400\",label:e(\"projectDetail.dueDate.today\")}:i<=3?{color:\"text-yellow-400\",label:e(\"projectDetail.dueDate.daysLeft\",{count:i})}:{color:\"text-bambu-gray\",label:e(\"projectDetail.dueDate.daysLeft\",{count:i})}}function ort(){const{id:t}=Yw(),e=qo(),{t:n}=Ft(),r=nn(),{showToast:i}=hn(),{hasPermission:s}=kr(),[o,l]=w.useState(!1),[c,d]=w.useState(!1),[u,m]=w.useState(\"\"),[p,f]=w.useState(null),[y,v]=w.useState(null),b=parseInt(t||\"0\",10),{data:g,isLoading:_,error:C}=Xe({queryKey:[\"project\",b],queryFn:()=>ue.getProject(b),enabled:b>0}),{data:P,isLoading:N}=Xe({queryKey:[\"project-archives\",b],queryFn:()=>ue.getProjectArchives(b),enabled:b>0}),{data:A,isLoading:T}=Xe({queryKey:[\"project-bom\",b],queryFn:()=>ue.getProjectBOM(b),enabled:b>0}),{data:F,isLoading:k}=Xe({queryKey:[\"project-timeline\",b],queryFn:()=>ue.getProjectTimeline(b,20),enabled:b>0}),{data:D}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),{data:H}=Xe({queryKey:[\"project-folders\",b],queryFn:()=>ue.getLibraryFoldersByProject(b),enabled:b>0}),{data:z,isLoading:Q}=Xe({queryKey:[\"project-files\",b],queryFn:()=>ue.getLibraryFiles(null,!1,b),enabled:b>0}),L=w.useMemo(()=>{const ce=new Map;for(const xe of z??[])if(xe.folder_id!=null){const Be=ce.get(xe.folder_id)??[];Be.push(xe),ce.set(xe.folder_id,Be)}return ce},[z]),te=Eo(D?.currency||\"USD\"),ie=D?.time_format||\"system\",J=it({mutationFn:ce=>ue.updateProject(b,ce),onSuccess:()=>{r.invalidateQueries({queryKey:[\"project\",b]}),r.invalidateQueries({queryKey:[\"projects\"]}),l(!1),d(!1),i(n(\"projectDetail.toast.projectUpdated\"),\"success\")},onError:ce=>{i(ce.message,\"error\")}}),oe=()=>{m(g?.notes||\"\"),d(!0)},fe=()=>{J.mutate({notes:u})},re=()=>{d(!1),m(\"\")},[W,ne]=w.useState(\"\"),[me,be]=w.useState(1),[Ce,q]=w.useState(\"\"),[Y,E]=w.useState(\"\"),[j,O]=w.useState(\"\"),[K,U]=w.useState(!1),[de,I]=w.useState(!1),[G,X]=w.useState(null),[V,ee]=w.useState(\"\"),[se,ge]=w.useState(1),[he,le]=w.useState(\"\"),[B,R]=w.useState(\"\"),[ae,_e]=w.useState(\"\"),[Se,ve]=w.useState({isOpen:!1,title:\"\",message:\"\",onConfirm:()=>{}}),Te=it({mutationFn:ce=>ue.createBOMItem(b,ce),onSuccess:()=>{r.invalidateQueries({queryKey:[\"project-bom\",b]}),r.invalidateQueries({queryKey:[\"project\",b]}),ne(\"\"),be(1),q(\"\"),E(\"\"),O(\"\"),U(!1),i(n(\"projectDetail.toast.partAdded\"),\"success\")},onError:ce=>i(ce.message,\"error\")}),ye=it({mutationFn:({itemId:ce,data:xe})=>ue.updateBOMItem(b,ce,xe),onSuccess:()=>{r.invalidateQueries({queryKey:[\"project-bom\",b]}),r.invalidateQueries({queryKey:[\"project\",b]}),X(null)},onError:ce=>i(ce.message,\"error\")}),je=it({mutationFn:ce=>ue.deleteBOMItem(b,ce),onSuccess:()=>{r.invalidateQueries({queryKey:[\"project-bom\",b]}),r.invalidateQueries({queryKey:[\"project\",b]}),i(n(\"projectDetail.toast.partRemoved\"),\"success\")},onError:ce=>i(ce.message,\"error\")}),Le=ce=>{ce.preventDefault(),W.trim()&&Te.mutate({name:W.trim(),quantity_needed:me,unit_price:Ce?parseFloat(Ce):void 0,sourcing_url:Y.trim()||void 0,remarks:j.trim()||void 0})},Me=ce=>{const xe=ce.is_complete?0:ce.quantity_needed;ye.mutate({itemId:ce.id,data:{quantity_acquired:xe}})},Oe=(ce,xe)=>{ve({isOpen:!0,title:n(\"projectDetail.bom.deletePart\"),message:n(\"projectDetail.bom.deleteConfirm\",{name:xe}),onConfirm:()=>{ve(Be=>({...Be,isOpen:!1})),je.mutate(ce)}})},Re=ce=>{X(ce),ee(ce.name),ge(ce.quantity_needed),le(ce.unit_price?.toString()||\"\"),R(ce.sourcing_url||\"\"),_e(ce.remarks||\"\")},$e=ce=>{ce.preventDefault(),!(!G||!V.trim())&&ye.mutate({itemId:G.id,data:{name:V.trim(),quantity_needed:se,unit_price:he?parseFloat(he):void 0,sourcing_url:B.trim()||void 0,remarks:ae.trim()||void 0}})},Ye=()=>{X(null)},tt=async()=>{try{const{blob:ce,filename:xe}=await ue.exportProjectZip(Number(b)),Be=URL.createObjectURL(ce),Qe=document.createElement(\"a\");Qe.href=Be,Qe.download=xe||`${g?.name||\"project\"}_${new Date().toISOString().split(\"T\")[0]}.zip`,Qe.click(),URL.revokeObjectURL(Be),i(n(\"projectDetail.toast.projectExported\"),\"success\")}catch(ce){i(ce.message,\"error\")}},pe=it({mutationFn:()=>ue.createTemplateFromProject(b),onSuccess:()=>{r.invalidateQueries({queryKey:[\"projects\"]}),i(n(\"projectDetail.toast.templateCreated\"),\"success\")},onError:ce=>i(ce.message,\"error\")}),Fe=ce=>pg(ce,ie,{month:\"short\",day:\"numeric\",hour:\"2-digit\",minute:\"2-digit\"});if(_)return a.jsx(\"div\",{className:\"flex items-center justify-center py-24\",children:a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green\"})});if(C||!g)return a.jsxs(\"div\",{className:\"text-center py-24\",children:[a.jsx(\"p\",{className:\"text-bambu-gray\",children:C?`${n(\"common.error\")}: ${C.message}`:n(\"projectDetail.notFound\")}),a.jsx(De,{variant:\"secondary\",className:\"mt-4\",onClick:()=>e(\"/projects\"),children:n(\"projectDetail.backToProjects\")})]});const we=g.stats,Ve=we?.progress_percent??0,Ae=we?.parts_progress_percent??0;return a.jsxs(\"div\",{className:\"p-4 md:p-8 space-y-8\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm text-bambu-gray\",children:[a.jsx(Do,{to:\"/projects\",className:\"hover:text-white transition-colors\",children:n(\"nav.projects\")}),a.jsx(ti,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"text-white\",children:g.name})]}),a.jsxs(\"div\",{className:\"flex items-start justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-4\",children:[a.jsx(\"button\",{onClick:()=>e(\"/projects\"),className:\"p-2 rounded-lg bg-bambu-card hover:bg-bambu-dark-tertiary transition-colors\",children:a.jsx(DQ,{className:\"w-5 h-5 text-bambu-gray\"})}),a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"w-4 h-4 rounded-full shrink-0\",style:{backgroundColor:g.color||\"#6b7280\"}}),a.jsxs(\"div\",{children:[a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:g.name}),g.description&&a.jsx(\"p\",{className:\"text-bambu-gray mt-1\",children:g.description})]})]}),a.jsx(art,{status:g.status,t:n})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(De,{variant:\"secondary\",onClick:tt,disabled:!s(\"projects:read\"),title:s(\"projects:read\")?n(\"projectDetail.exportProject\"):n(\"projectDetail.noExportPermission\"),children:[a.jsx(ga,{className:\"w-4 h-4 mr-2\"}),n(\"projectDetail.export\")]}),a.jsxs(De,{onClick:()=>l(!0),disabled:!s(\"projects:update\"),title:s(\"projects:update\")?void 0:n(\"projectDetail.noEditPermission\"),children:[a.jsx(dg,{className:\"w-4 h-4 mr-2\"}),n(\"common.edit\")]})]})]}),(g.target_count||g.target_parts_count)&&a.jsx(Tt,{children:a.jsxs(Mt,{className:\"p-4 space-y-4\",children:[g.target_count&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:n(\"projectDetail.progress.platesProgress\")}),a.jsxs(\"span\",{className:\"text-sm font-medium text-white\",children:[we?.total_archives||0,\" / \",g.target_count,\" \",n(\"projectDetail.progress.printJobs\")]})]}),a.jsx(\"div\",{className:\"h-3 bg-bambu-dark rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:\"h-full transition-all duration-500\",style:{width:`${Math.min(Ve,100)}%`,backgroundColor:Ve>=100?\"#22c55e\":g.color||\"#6b7280\"}})}),a.jsxs(\"div\",{className:\"flex justify-between mt-1\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray/70\",children:n(\"projectDetail.progress.percentComplete\",{percent:Ve.toFixed(0)})}),we?.remaining_prints!=null&&we.remaining_prints>0&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray/70\",children:n(\"projectDetail.progress.remaining\",{count:we.remaining_prints})})]})]}),g.target_parts_count&&a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:n(\"projectDetail.progress.partsProgress\")}),a.jsxs(\"span\",{className:\"text-sm font-medium text-white\",children:[we?.completed_prints||0,\" / \",g.target_parts_count,\" \",n(\"projectDetail.progress.parts\")]})]}),a.jsx(\"div\",{className:\"h-3 bg-bambu-dark rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:\"h-full transition-all duration-500\",style:{width:`${Math.min(Ae,100)}%`,backgroundColor:Ae>=100?\"#22c55e\":g.color||\"#6b7280\"}})}),a.jsxs(\"div\",{className:\"flex justify-between mt-1\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray/70\",children:n(\"projectDetail.progress.percentComplete\",{percent:Ae.toFixed(0)})}),we?.remaining_parts!=null&&we.remaining_parts>0&&a.jsx(\"span\",{className:\"text-xs text-bambu-gray/70\",children:n(\"projectDetail.progress.remaining\",{count:we.remaining_parts})})]})]})]})}),we&&a.jsxs(\"div\",{className:\"grid grid-cols-2 md:grid-cols-4 gap-4\",children:[a.jsx(Tt,{children:a.jsx(Mt,{className:\"p-4\",children:a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"p-2 rounded-lg bg-bambu-dark text-bambu-green\",children:a.jsx(Ra,{className:\"w-5 h-5\"})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"projectDetail.stats.printJobs\")}),a.jsxs(\"p\",{className:\"text-xl font-semibold text-white\",children:[we.total_archives,\" \",a.jsx(\"span\",{className:\"text-sm font-normal text-bambu-gray\",children:n(\"projectDetail.stats.total\")})]}),we.failed_prints>0&&a.jsx(\"p\",{className:\"text-sm text-status-error\",children:n(\"projectDetail.stats.failed\",{count:we.failed_prints})}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"projectDetail.stats.partsPrinted\",{count:we.completed_prints})})]})]})})}),a.jsx(kY,{icon:Yn,label:n(\"projectDetail.stats.printTime\"),value:Dye(we.total_print_time_hours),color:\"text-yellow-400\"}),a.jsx(kY,{icon:Er,label:n(\"projectDetail.stats.filamentUsed\"),value:rrt(we.total_filament_grams),color:\"text-purple-400\"})]}),we&&(()=>{const ce=we.estimated_cost+we.total_energy_cost+we.bom_cost;return we.estimated_cost>0||ce>0||g.budget!==null})()&&a.jsx(Tt,{children:a.jsxs(Mt,{className:\"p-4\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white mb-3\",children:n(\"projectDetail.cost.title\")}),a.jsxs(\"div\",{className:\"grid grid-cols-2 md:grid-cols-4 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase\",children:n(\"projectDetail.cost.filamentCost\")}),a.jsxs(\"p\",{className:\"text-lg font-semibold text-white\",children:[te,we.estimated_cost.toFixed(2)]})]}),we.total_energy_kwh>0&&a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase\",children:n(\"projectDetail.cost.energy\")}),a.jsxs(\"p\",{className:\"text-lg font-semibold text-white\",children:[we.total_energy_kwh.toFixed(3),\" kWh\",we.total_energy_cost>0&&a.jsxs(\"span\",{className:\"text-sm text-bambu-gray ml-1\",children:[\"(\",te,we.total_energy_cost.toFixed(2),\")\"]})]})]}),(()=>{const ce=we.estimated_cost+we.total_energy_cost+we.bom_cost;return ce<=0?null:a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase\",children:n(\"projectDetail.cost.totalCost\")}),a.jsxs(\"p\",{className:\"text-lg font-semibold text-bambu-green\",children:[te,ce.toFixed(2)]}),we.bom_cost>0&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray/70\",children:n(\"projectDetail.cost.includesBom\")})]})})(),g.budget!==null&&(()=>{const ce=we.estimated_cost+we.total_energy_cost+we.bom_cost,xe=g.budget-ce;return a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray uppercase\",children:n(\"projectDetail.cost.budget\")}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[n(\"projectDetail.cost.total\"),\": \",a.jsxs(\"span\",{className:\"text-white font-semibold\",children:[te,g.budget.toFixed(2)]})]}),a.jsxs(\"p\",{className:`text-sm ${xe>=0?\"text-bambu-green\":\"text-red-400\"}`,children:[n(\"projectDetail.cost.remaining\"),\": \",a.jsxs(\"span\",{className:\"font-semibold\",children:[te,xe.toFixed(2)]})]})]})})()]})]})}),g.children&&g.children.length>0&&a.jsx(Tt,{children:a.jsxs(Mt,{className:\"p-4\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2 mb-3\",children:[a.jsx(yge,{className:\"w-5 h-5\"}),n(\"projectDetail.subProjects.title\",{count:g.children.length})]}),a.jsx(\"div\",{className:\"space-y-2\",children:g.children.map(ce=>a.jsxs(Do,{to:`/projects/${ce.id}`,className:\"flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"w-3 h-3 rounded-full\",style:{backgroundColor:ce.color||\"#6b7280\"}}),a.jsx(\"span\",{className:\"text-white\",children:ce.name}),a.jsx(\"span\",{className:`text-xs px-2 py-0.5 rounded ${ce.status===\"completed\"?\"bg-status-ok/20 text-status-ok\":ce.status===\"archived\"?\"bg-bambu-gray/20 text-bambu-gray\":\"bg-blue-500/20 text-blue-400\"}`,children:ce.status})]}),ce.progress_percent!==null&&a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[ce.progress_percent.toFixed(0),\"%\"]})]},ce.id))})]})}),g.parent_id&&g.parent_name&&a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(da,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:n(\"projectDetail.partOf\")}),a.jsx(Do,{to:`/projects/${g.parent_id}`,className:\"text-bambu-green hover:underline\",children:g.parent_name})]}),(g.tags||g.due_date||g.priority!==\"normal\")&&a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-4\",children:[g.priority&&g.priority!==\"normal\"&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray uppercase\",children:n(\"projectDetail.priorityLabel\")}),a.jsx(srt,{priority:g.priority,t:n})]}),g.due_date&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(oa,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:hg(g.due_date,{year:\"numeric\",month:\"short\",day:\"numeric\"})}),E3(g.due_date,n)&&a.jsxs(\"span\",{className:`text-xs ${E3(g.due_date,n).color}`,children:[\"(\",E3(g.due_date,n).label,\")\"]})]}),g.tags&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(xm,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"div\",{className:\"flex flex-wrap gap-1\",children:g.tags.split(\",\").map((ce,xe)=>a.jsx(\"span\",{className:\"px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded\",children:ce.trim()},xe))})]})]}),a.jsx(Tt,{children:a.jsxs(Mt,{className:\"p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(Us,{className:\"w-5 h-5\"}),n(\"projectDetail.notes.title\")]}),c?a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:re,disabled:J.isPending,children:[a.jsx(Ht,{className:\"w-4 h-4 mr-1\"}),n(\"common.cancel\")]}),a.jsxs(De,{size:\"sm\",onClick:fe,disabled:J.isPending,children:[J.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin mr-1\"}):a.jsx(_s,{className:\"w-4 h-4 mr-1\"}),n(\"common.save\")]})]}):a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:oe,disabled:!s(\"projects:update\"),title:s(\"projects:update\")?void 0:n(\"projectDetail.notes.noEditPermission\"),children:[a.jsx(dg,{className:\"w-4 h-4 mr-1\"}),n(\"common.edit\")]})]}),c?a.jsx(tO,{content:u,onChange:m,placeholder:n(\"projectDetail.notes.placeholder\")}):g.notes?a.jsx(\"div\",{className:\"prose prose-invert prose-sm max-w-none\",dangerouslySetInnerHTML:{__html:hie.sanitize(g.notes)}}):a.jsx(\"p\",{className:\"text-bambu-gray/70 text-sm italic\",children:n(\"projectDetail.notes.empty\")})]})}),a.jsx(Tt,{children:a.jsxs(Mt,{className:\"p-4\",children:[a.jsx(\"div\",{className:\"flex items-center justify-between mb-3\",children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(Qc,{className:\"w-5 h-5\"}),n(\"projectDetail.files.title\")]})}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mb-3\",children:[a.jsx(Do,{to:\"/files\",className:\"text-bambu-green hover:underline\",children:n(\"projectDetail.files.linkFolders\")}),\" \",n(\"projectDetail.files.forQuickAccess\")]}),H&&H.length>0?a.jsx(\"div\",{className:\"space-y-4\",children:H.map(ce=>{const xe=L.get(ce.id)??[],Be=Q;return a.jsxs(\"div\",{children:[a.jsxs(Do,{to:`/files?folder=${ce.id}`,className:\"flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors mb-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 min-w-0\",children:[a.jsx(Qc,{className:\"w-5 h-5 text-bambu-green shrink-0\"}),a.jsxs(\"div\",{className:\"min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white truncate\",children:ce.name}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"projectDetail.files.fileCount\",{count:ce.file_count})})]})]}),a.jsx(ti,{className:\"w-4 h-4 text-bambu-gray shrink-0\"})]}),Be?a.jsx(\"div\",{className:\"flex items-center gap-2 px-3 py-2 text-bambu-gray text-sm\",children:a.jsx(ft,{className:\"w-4 h-4 animate-spin\"})}):xe.length===0?a.jsx(\"p\",{className:\"text-bambu-gray/60 text-xs italic px-3\",children:n(\"projectDetail.files.noFiles\")}):a.jsx(\"div\",{className:\"space-y-1 pl-3\",children:xe.map(Qe=>{const ht=nrt(Qe.filename);return a.jsxs(\"div\",{className:\"flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors\",children:[a.jsx(\"div\",{className:\"w-10 h-10 shrink-0 rounded bg-bambu-dark overflow-hidden flex items-center justify-center\",children:Qe.thumbnail_path?a.jsx(\"img\",{src:ue.getLibraryFileThumbnailUrl(Qe.id),alt:Qe.print_name||Qe.filename,className:\"w-full h-full object-cover\"}):a.jsx(ep,{className:\"w-5 h-5 text-bambu-gray/40\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white truncate\",title:Qe.print_name||Qe.filename,children:Qe.print_name||Qe.filename}),a.jsx(\"span\",{className:`text-xs px-1.5 py-0.5 rounded font-medium ${Qe.file_type===\"3mf\"?\"bg-bambu-green/20 text-bambu-green\":Qe.file_type===\"gcode\"?\"bg-blue-500/20 text-blue-400\":\"bg-bambu-gray/20 text-bambu-gray\"}`,children:Qe.file_type.toUpperCase()})]}),ht&&a.jsxs(\"div\",{className:\"flex items-center gap-1 shrink-0\",children:[a.jsx(\"button\",{onClick:()=>f(Qe),title:n(\"projectDetail.files.print\"),className:\"p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors\",children:a.jsx(es,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>v(Qe),title:n(\"projectDetail.files.addToQueue\"),className:\"p-1.5 rounded hover:bg-blue-500/20 text-blue-400 transition-colors\",children:a.jsx($pe,{className:\"w-4 h-4\"})})]})]},Qe.id)})})]},ce.id)})}):a.jsx(\"p\",{className:\"text-bambu-gray/70 text-sm italic\",children:n(\"projectDetail.files.empty\")})]})}),a.jsx(Tt,{children:a.jsxs(Mt,{className:\"p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-4\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(qQ,{className:\"w-5 h-5\"}),n(\"projectDetail.bom.title\"),we&&we.bom_total_items>0&&a.jsxs(\"span\",{className:\"text-sm font-normal text-bambu-gray\",children:[\"(\",n(\"projectDetail.bom.acquired\",{completed:we.bom_completed_items,total:we.bom_total_items}),\")\"]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[A&&A.some(ce=>ce.is_complete)&&a.jsx(\"button\",{onClick:()=>I(!de),className:`text-xs px-2 py-1 rounded transition-colors ${de?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark text-bambu-gray hover:text-white\"}`,children:n(de?\"projectDetail.bom.showAll\":\"projectDetail.bom.hideDone\")}),!K&&a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>U(!0),disabled:!s(\"projects:update\"),title:s(\"projects:update\")?void 0:n(\"projectDetail.bom.noAddPermission\"),children:[a.jsx(sr,{className:\"w-4 h-4 mr-1\"}),n(\"projectDetail.bom.addPart\")]})]})]}),K&&a.jsxs(\"form\",{onSubmit:Le,className:\"bg-bambu-dark rounded-lg p-4 mb-4 space-y-3\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-3\",children:[a.jsx(\"input\",{type:\"text\",value:W,onChange:ce=>ne(ce.target.value),className:\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:n(\"projectDetail.bom.partNamePlaceholder\"),autoFocus:!0}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"input\",{type:\"number\",value:me,onChange:ce=>be(parseInt(ce.target.value)||1),className:\"w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green\",min:\"1\",placeholder:n(\"projectDetail.bom.qty\")}),a.jsx(\"input\",{type:\"number\",step:\"0.01\",value:Ce,onChange:ce=>q(ce.target.value),className:\"flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:n(\"projectDetail.bom.price\",{currency:te})})]})]}),a.jsx(\"input\",{type:\"url\",value:Y,onChange:ce=>E(ce.target.value),className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:n(\"projectDetail.bom.sourcingUrlPlaceholder\")}),a.jsx(\"input\",{type:\"text\",value:j,onChange:ce=>O(ce.target.value),className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:n(\"projectDetail.bom.remarksPlaceholder\")}),a.jsxs(\"div\",{className:\"flex justify-end gap-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",size:\"sm\",onClick:()=>U(!1),children:n(\"common.cancel\")}),a.jsx(De,{type:\"submit\",size:\"sm\",disabled:!W.trim()||Te.isPending,children:Te.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):n(\"projectDetail.bom.addPart\")})]})]}),T?a.jsx(\"div\",{className:\"flex items-center justify-center py-4\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-green\"})}):A&&A.length>0?a.jsxs(\"div\",{className:\"space-y-2\",children:[A.filter(ce=>!de||!ce.is_complete).map(ce=>a.jsx(\"div\",{className:`p-3 rounded-lg transition-colors ${ce.is_complete?\"bg-status-ok/10\":\"bg-bambu-dark\"}`,children:G?.id===ce.id?a.jsxs(\"form\",{onSubmit:$e,className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-3\",children:[a.jsx(\"input\",{type:\"text\",value:V,onChange:xe=>ee(xe.target.value),className:\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:n(\"projectDetail.bom.partName\"),autoFocus:!0}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"input\",{type:\"number\",value:se,onChange:xe=>ge(parseInt(xe.target.value)||1),className:\"w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green\",min:\"1\",placeholder:n(\"projectDetail.bom.qty\")}),a.jsx(\"input\",{type:\"number\",step:\"0.01\",value:he,onChange:xe=>le(xe.target.value),className:\"flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:n(\"projectDetail.bom.price\",{currency:te})})]})]}),a.jsx(\"input\",{type:\"url\",value:B,onChange:xe=>R(xe.target.value),className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:n(\"projectDetail.bom.sourcingUrlPlaceholder\")}),a.jsx(\"input\",{type:\"text\",value:ae,onChange:xe=>_e(xe.target.value),className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:n(\"projectDetail.bom.remarksPlaceholder\")}),a.jsxs(\"div\",{className:\"flex justify-end gap-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",size:\"sm\",onClick:Ye,children:n(\"common.cancel\")}),a.jsx(De,{type:\"submit\",size:\"sm\",disabled:!V.trim()||ye.isPending,children:ye.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):n(\"common.save\")})]})]}):a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(\"button\",{onClick:()=>s(\"projects:update\")&&Me(ce),disabled:ye.isPending||!s(\"projects:update\"),title:s(\"projects:update\")?void 0:n(\"projectDetail.bom.noUpdatePermission\"),className:`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${ce.is_complete?\"bg-status-ok border-status-ok text-white\":s(\"projects:update\")?\"border-bambu-gray hover:border-bambu-green\":\"border-bambu-gray/50 cursor-not-allowed\"}`,children:ce.is_complete&&a.jsx(yr,{className:\"w-3 h-3\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 min-w-0\",children:[a.jsxs(\"p\",{className:`text-sm font-medium ${ce.is_complete?\"text-bambu-gray line-through\":\"text-white\"}`,children:[ce.name,a.jsxs(\"span\",{className:\"text-bambu-gray font-normal ml-2\",children:[\"x\",ce.quantity_needed]})]}),ce.unit_price!==null&&a.jsxs(\"span\",{className:\"text-xs text-bambu-green whitespace-nowrap\",children:[te,(ce.unit_price*ce.quantity_needed).toFixed(2)]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"button\",{onClick:()=>s(\"projects:update\")&&Re(ce),disabled:!s(\"projects:update\"),className:`p-1 rounded transition-colors shrink-0 ${s(\"projects:update\")?\"hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:s(\"projects:update\")?n(\"common.edit\"):n(\"projectDetail.bom.noEditPermission\"),children:a.jsx(ci,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>s(\"projects:update\")&&Oe(ce.id,ce.name),disabled:!s(\"projects:update\"),className:`p-1 rounded transition-colors shrink-0 ${s(\"projects:update\")?\"hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:s(\"projects:update\")?n(\"common.delete\"):n(\"projectDetail.bom.noDeletePermission\"),children:a.jsx(en,{className:\"w-4 h-4\"})})]})]}),ce.sourcing_url&&a.jsxs(\"a\",{href:ce.sourcing_url,target:\"_blank\",rel:\"noopener noreferrer\",className:\"flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors\",onClick:xe=>xe.stopPropagation(),children:[a.jsx(la,{className:\"w-3 h-3 shrink-0\"}),a.jsx(\"span\",{className:\"truncate\",children:(()=>{try{return new URL(ce.sourcing_url).hostname.replace(\"www.\",\"\")}catch{return ce.sourcing_url}})()})]}),ce.remarks&&a.jsx(\"p\",{className:\"mt-1 text-xs text-bambu-gray/80 italic\",children:ce.remarks})]})]})},ce.id)),we&&we.bom_cost>0&&a.jsxs(\"div\",{className:\"pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:n(\"projectDetail.bom.totalCost\")}),a.jsxs(\"span\",{className:\"text-white font-medium\",children:[te,we.bom_cost.toFixed(2)]})]})]}):a.jsx(\"p\",{className:\"text-bambu-gray/70 text-sm italic\",children:n(\"projectDetail.bom.empty\")})]})}),a.jsx(Tt,{children:a.jsxs(Mt,{className:\"p-4\",children:[a.jsx(\"div\",{className:\"flex items-center justify-between mb-3\",children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(iw,{className:\"w-5 h-5\"}),n(\"projectDetail.timeline.title\")]})}),k?a.jsx(\"div\",{className:\"flex items-center justify-center py-4\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-green\"})}):F&&F.length>0?a.jsx(\"div\",{className:\"space-y-3\",children:F.slice(0,10).map((ce,xe)=>a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsxs(\"div\",{className:`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${ce.event_type===\"print_completed\"?\"bg-status-ok/20 text-status-ok\":ce.event_type===\"print_failed\"?\"bg-status-error/20 text-status-error\":ce.event_type===\"print_started\"?\"bg-yellow-500/20 text-yellow-400\":\"bg-bambu-dark-tertiary text-bambu-gray\"}`,children:[ce.event_type===\"print_completed\"&&a.jsx(yr,{className:\"w-4 h-4\"}),ce.event_type===\"print_failed\"&&a.jsx(Ma,{className:\"w-4 h-4\"}),ce.event_type===\"print_started\"&&a.jsx(Er,{className:\"w-4 h-4\"}),ce.event_type===\"queued\"&&a.jsx(sF,{className:\"w-4 h-4\"}),ce.event_type===\"project_created\"&&a.jsx(sr,{className:\"w-4 h-4\"})]}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm text-white\",children:ce.title}),ce.description&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray truncate\",children:ce.description}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray/70\",children:Fe(ce.timestamp)})]})]},xe))}):a.jsx(\"p\",{className:\"text-bambu-gray/70 text-sm italic\",children:n(\"projectDetail.timeline.empty\")})]})}),!g.is_template&&a.jsx(\"div\",{className:\"flex justify-end\",children:a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>pe.mutate(),disabled:pe.isPending||!s(\"projects:create\"),title:s(\"projects:create\")?void 0:n(\"projectDetail.template.noCreatePermission\"),children:[pe.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin mr-2\"}):a.jsx(qs,{className:\"w-4 h-4 mr-2\"}),n(\"projectDetail.template.saveAsTemplate\")]})}),we&&(we.queued_prints>0||we.in_progress_prints>0)&&a.jsx(Tt,{children:a.jsxs(Mt,{className:\"p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(sF,{className:\"w-5 h-5\"}),n(\"projectDetail.queue.title\")]}),a.jsx(Do,{to:`/queue?project=${b}`,className:\"text-sm text-bambu-green hover:underline\",children:n(\"projectDetail.queue.viewAll\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-4 text-sm\",children:[we.in_progress_prints>0&&a.jsx(\"span\",{className:\"text-yellow-400\",children:n(\"projectDetail.queue.printing\",{count:we.in_progress_prints})}),we.queued_prints>0&&a.jsx(\"span\",{className:\"text-bambu-gray\",children:n(\"projectDetail.queue.queued\",{count:we.queued_prints})})]})]})}),a.jsxs(\"div\",{children:[a.jsx(\"div\",{className:\"flex items-center justify-between mb-4\",children:a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(Ra,{className:\"w-5 h-5\"}),n(\"projectDetail.prints.title\",{count:P?.length||0})]})}),N?a.jsx(\"div\",{className:\"flex items-center justify-center py-8\",children:a.jsx(ft,{className:\"w-6 h-6 animate-spin text-bambu-green\"})}):a.jsx(irt,{archives:P||[],t:n})]}),o&&a.jsx(Hle,{t:n,currencySymbol:te,project:{...g,archive_count:we?.total_archives||0,total_items:we?.total_items||0,completed_count:we?.completed_prints||0,failed_count:we?.failed_prints||0,queue_count:we?.queued_prints||0,progress_percent:we?.progress_percent||null,archives:[]},onClose:()=>l(!1),onSave:ce=>J.mutate(ce),isLoading:J.isPending}),Se.isOpen&&a.jsx(yn,{title:Se.title,message:Se.message,confirmText:n(\"common.delete\"),variant:\"danger\",onConfirm:Se.onConfirm,onCancel:()=>ve(ce=>({...ce,isOpen:!1}))}),p&&a.jsx(ic,{mode:\"reprint\",libraryFileId:p.id,archiveName:p.print_name||p.filename,projectId:b,onClose:()=>f(null),onSuccess:()=>{f(null),r.invalidateQueries({queryKey:[\"archives\"]})}}),y&&a.jsx(ic,{mode:\"add-to-queue\",libraryFileId:y.id,archiveName:y.print_name||y.filename,projectId:b,onClose:()=>v(null),onSuccess:()=>{v(null),r.invalidateQueries({queryKey:[\"queue\"]})}})]})}function lrt({parentId:t,onClose:e,onSave:n,isLoading:r,t:i}){const[s,o]=w.useState(\"\"),l=c=>{c.preventDefault(),n({name:s.trim(),parent_id:t})};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary\",children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:i(\"fileManager.newFolder\")})}),a.jsxs(\"form\",{onSubmit:l,className:\"p-4 space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:i(\"fileManager.folderName\")}),a.jsx(\"input\",{type:\"text\",value:s,onChange:c=>o(c.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:i(\"fileManager.folderNamePlaceholder\"),autoFocus:!0,required:!0})]}),a.jsxs(\"div\",{className:\"flex justify-end gap-2 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:e,children:i(\"common.cancel\")}),a.jsx(De,{type:\"submit\",disabled:!s.trim()||r,children:r?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):i(\"common.create\")})]})]})]})})}function crt({onClose:t,onSave:e,isLoading:n,t:r}){const[i,s]=w.useState(\"\"),[o,l]=w.useState(\"\"),[c,d]=w.useState(!0),[u,m]=w.useState(!1),p=f=>{f.preventDefault(),e({name:i.trim(),external_path:o.trim(),readonly:c,show_hidden:u})};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(wN,{className:\"w-5 h-5 text-bambu-green\"}),r(\"fileManager.linkExternalFolder\")]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:r(\"fileManager.linkExternalFolderDescription\")})]}),a.jsxs(\"form\",{onSubmit:p,className:\"p-4 space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:r(\"fileManager.folderName\")}),a.jsx(\"input\",{type:\"text\",value:i,onChange:f=>s(f.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\",placeholder:r(\"fileManager.externalFolderNamePlaceholder\"),autoFocus:!0,required:!0})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:r(\"fileManager.externalPath\")}),a.jsx(\"input\",{type:\"text\",value:o,onChange:f=>l(f.target.value),className:\"w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green font-mono text-sm\",placeholder:\"/mnt/nas/3d-prints\",required:!0}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:r(\"fileManager.externalPathHelp\")})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"label\",{className:\"flex items-center gap-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:c,onChange:f=>d(f.target.checked),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:r(\"fileManager.readOnly\")}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray\",children:[\"(\",r(\"fileManager.readOnlyHelp\"),\")\"]})]}),a.jsxs(\"label\",{className:\"flex items-center gap-2 cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:u,onChange:f=>m(f.target.checked),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:r(\"fileManager.showHiddenFiles\")})]})]}),a.jsxs(\"div\",{className:\"flex justify-end gap-2 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:t,children:r(\"common.cancel\")}),a.jsx(De,{type:\"submit\",disabled:!i.trim()||!o.trim()||n,children:n?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):r(\"fileManager.linkFolder\")})]})]})]})})}function drt({type:t,currentName:e,onClose:n,onSave:r,isLoading:i,t:s}){const o=t===\"file\"?e.match(/(\\.gcode\\.3mf|\\.3mf|\\.gcode)$/i)?.[1]??\"\":\"\",l=t===\"file\"&&o?e.slice(0,-o.length):e,[c,d]=w.useState(l),u=m=>{m.preventDefault();const p=t===\"file\"?c.trim()+o:c.trim();c.trim()&&p!==e&&r(p)};return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary\",children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:s(t===\"file\"?\"fileManager.renameFile\":\"fileManager.renameFolder\")})}),a.jsxs(\"form\",{onSubmit:u,className:\"p-4 space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-1\",children:s(\"common.name\")}),a.jsxs(\"div\",{className:\"flex items-center bg-bambu-dark border border-bambu-dark-tertiary rounded focus-within:border-bambu-green\",children:[a.jsx(\"input\",{type:\"text\",value:c,onChange:m=>d(m.target.value),className:\"flex-1 bg-transparent px-3 py-2 text-white placeholder-bambu-gray focus:outline-none min-w-0\",autoFocus:!0,required:!0}),o&&a.jsx(\"span\",{className:\"pr-3 text-bambu-gray text-sm select-none whitespace-nowrap\",children:o})]})]}),a.jsxs(\"div\",{className:\"flex justify-end gap-2 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:n,children:s(\"common.cancel\")}),a.jsx(De,{type:\"submit\",disabled:!c.trim()||c.trim()===l||i,children:i?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):s(\"common.rename\")})]})]})]})})}function urt({folders:t,selectedFiles:e,currentFolderId:n,onClose:r,onMove:i,isLoading:s,t:o}){const[l,c]=w.useState(null),d=(m,p=0)=>{const f=[];for(const y of m)f.push({id:y.id,name:y.name,depth:p}),y.children.length>0&&f.push(...d(y.children,p+1));return f},u=[{id:null,name:o(\"fileManager.rootNoFolder\"),depth:0},...d(t)];return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary\",children:a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:o(\"fileManager.moveFiles\",{count:e.length})})}),a.jsxs(\"div\",{className:\"p-4 space-y-4\",children:[a.jsx(\"div\",{className:\"max-h-64 overflow-y-auto space-y-1\",children:u.map(m=>a.jsxs(\"button\",{onClick:()=>c(m.id),disabled:m.id===n,className:`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${l===m.id?\"bg-bambu-green/20 text-bambu-green\":m.id===n?\"opacity-50 cursor-not-allowed text-bambu-gray\":\"hover:bg-bambu-dark text-white\"}`,style:{paddingLeft:`${12+m.depth*16}px`},children:[a.jsx(Qc,{className:\"w-4 h-4\"}),m.name,m.id===n&&a.jsxs(\"span\",{className:\"text-xs text-bambu-gray ml-auto\",children:[\"(\",o(\"fileManager.current\"),\")\"]})]},m.id??\"root\"))}),a.jsxs(\"div\",{className:\"flex justify-end gap-2 pt-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",onClick:r,children:o(\"common.cancel\")}),a.jsx(De,{onClick:()=>i(l),disabled:s,children:s?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):o(\"common.move\")})]})]})]})})}function mrt({folder:t,onClose:e,onLink:n,isLoading:r,t:i}){const[s,o]=w.useState(\"project\"),[l,c]=w.useState(t.project_id||t.archive_id||null);w.useState(()=>{t.archive_id&&o(\"archive\")});const{data:d}=Xe({queryKey:[\"projects\"],queryFn:()=>ue.getProjects()}),{data:u}=Xe({queryKey:[\"archives-for-link\"],queryFn:()=>ue.getArchives(void 0,void 0,100)}),m=()=>{n(s===\"project\"?{project_id:l,archive_id:0}:{project_id:0,archive_id:l})},p=()=>{n({project_id:0,archive_id:0})},f=t.project_id||t.archive_id;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"p-4 border-b border-bambu-dark-tertiary flex items-center justify-between\",children:[a.jsxs(\"h2\",{className:\"text-lg font-semibold text-white flex items-center gap-2\",children:[a.jsx(sm,{className:\"w-5 h-5 text-bambu-green\"}),i(\"fileManager.linkFolder\")]}),a.jsx(\"button\",{onClick:e,className:\"p-1 hover:bg-bambu-dark rounded\",children:a.jsx(Ht,{className:\"w-5 h-5 text-bambu-gray\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:i(\"fileManager.linkFolderDescription\",{name:t.name})}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"button\",{onClick:()=>{o(\"project\"),c(null)},className:`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${s===\"project\"?\"border-bambu-green bg-bambu-green/10 text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,children:[a.jsx(FQ,{className:\"w-4 h-4\"}),i(\"fileManager.project\")]}),a.jsxs(\"button\",{onClick:()=>{o(\"archive\"),c(null)},className:`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${s===\"archive\"?\"border-bambu-green bg-bambu-green/10 text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:text-white\"}`,children:[a.jsx(so,{className:\"w-4 h-4\"}),i(\"fileManager.archive\")]})]}),a.jsx(\"div\",{className:\"max-h-64 overflow-y-auto space-y-1 bg-bambu-dark rounded-lg p-2\",children:s===\"project\"?d&&d.length>0?d.map(y=>a.jsxs(\"button\",{onClick:()=>c(y.id),className:`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${l===y.id?\"bg-bambu-green/20 text-bambu-green\":\"hover:bg-bambu-dark-tertiary text-white\"}`,children:[a.jsx(\"div\",{className:\"w-3 h-3 rounded-full flex-shrink-0\",style:{backgroundColor:y.color||\"#00ae42\"}}),a.jsx(\"span\",{className:\"truncate\",children:y.name})]},y.id)):a.jsx(\"p\",{className:\"text-sm text-bambu-gray text-center py-4\",children:i(\"fileManager.noProjectsFound\")}):u&&u.length>0?u.map(y=>a.jsxs(\"button\",{onClick:()=>c(y.id),className:`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${l===y.id?\"bg-bambu-green/20 text-bambu-green\":\"hover:bg-bambu-dark-tertiary text-white\"}`,children:[a.jsx(ep,{className:\"w-4 h-4 text-bambu-gray flex-shrink-0\"}),a.jsx(\"span\",{className:\"truncate\",children:y.print_name||y.filename})]},y.id)):a.jsx(\"p\",{className:\"text-sm text-bambu-gray text-center py-4\",children:i(\"fileManager.noArchivesFound\")})})]}),a.jsxs(\"div\",{className:\"p-4 border-t border-bambu-dark-tertiary flex justify-between\",children:[f&&a.jsxs(De,{variant:\"danger\",onClick:p,disabled:r,children:[a.jsx(gp,{className:\"w-4 h-4 mr-2\"}),i(\"fileManager.unlink\")]}),a.jsxs(\"div\",{className:`flex gap-2 ${f?\"\":\"ml-auto\"}`,children:[a.jsx(De,{variant:\"secondary\",onClick:e,children:i(\"common.cancel\")}),a.jsx(De,{onClick:m,disabled:!l||r,children:r?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):i(\"fileManager.link\")})]})]})]})})}function qle({folder:t,selectedFolderId:e,onSelect:n,onDelete:r,onLink:i,onRename:s,depth:o=0,wrapNames:l=!1,defaultExpanded:c=!0,hasPermission:d,t:u}){const[m,p]=w.useState(c),[f,y]=w.useState(!1),v=t.children.length>0,b=t.project_id||t.archive_id,g=t.is_external;return a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:`group flex items-center gap-1 px-2 py-1.5 rounded cursor-pointer transition-colors ${e===t.id?\"bg-bambu-green/20 text-bambu-green\":\"hover:bg-bambu-dark text-white\"}`,style:{paddingLeft:`${8+o*12}px`},onClick:()=>n(t.id),children:[v?a.jsx(\"button\",{onClick:_=>{_.stopPropagation(),p(!m)},className:\"p-0.5 hover:bg-bambu-dark-tertiary rounded\",children:a.jsx(ti,{className:`w-3.5 h-3.5 transition-transform ${m?\"rotate-90\":\"\"}`})}):a.jsx(\"div\",{className:\"w-4.5\"}),g?a.jsx(wN,{className:\"w-4 h-4 text-purple-400 flex-shrink-0\"}):a.jsx(Qc,{className:\"w-4 h-4 text-bambu-green flex-shrink-0\"}),a.jsx(\"span\",{className:`text-sm flex-1 min-w-0 ${l?\"break-all\":\"truncate\"}`,title:t.name,children:t.name}),b&&a.jsxs(\"button\",{onClick:_=>{_.stopPropagation(),i(t)},className:\"flex-shrink-0 flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors\",title:`${t.project_name?`Project: ${t.project_name}`:`Archive: ${t.archive_name}`} (click to change)`,children:[a.jsx(sm,{className:\"w-3 h-3\"}),t.project_name?a.jsx(FQ,{className:\"w-3 h-3\"}):a.jsx(so,{className:\"w-3 h-3\"})]}),g&&t.external_readonly&&a.jsx(\"span\",{title:u(\"fileManager.readOnly\"),children:a.jsx(kd,{className:\"w-3 h-3 text-amber-400 flex-shrink-0\"})}),t.file_count>0&&a.jsx(\"span\",{className:\"flex-shrink-0 text-xs text-bambu-gray\",children:t.file_count}),!b&&!g&&a.jsx(\"button\",{onClick:_=>{_.stopPropagation(),i(t)},className:\"flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary\",title:u(\"fileManager.linkToProjectOrArchive\"),children:a.jsx(sm,{className:\"w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green\"})}),a.jsx(\"div\",{className:`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${l?\"\":\"opacity-0 group-hover:opacity-100\"}`,onClick:_=>_.stopPropagation(),children:a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"button\",{onClick:()=>y(!f),className:\"p-1 rounded hover:bg-bambu-dark-tertiary\",children:a.jsx(Jh,{className:\"w-3.5 h-3.5 text-bambu-gray\"})}),f&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>y(!1)}),a.jsxs(\"div\",{className:\"absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]\",children:[a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${d(\"library:update_all\")?\"text-white hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{d(\"library:update_all\")&&(s(t),y(!1))},disabled:!d(\"library:update_all\"),title:d(\"library:update_all\")?void 0:u(\"fileManager.noPermissionRenameFolder\"),children:[a.jsx(ci,{className:\"w-3.5 h-3.5\"}),u(\"common.rename\")]}),a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${d(\"library:update_all\")?\"text-white hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{d(\"library:update_all\")&&(i(t),y(!1))},disabled:!d(\"library:update_all\"),title:d(\"library:update_all\")?void 0:u(\"fileManager.noPermissionLinkFolder\"),children:[a.jsx(sm,{className:\"w-3.5 h-3.5\"}),u(b?\"fileManager.changeLink\":\"fileManager.linkTo\")]}),a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${d(\"library:delete_all\")?\"text-red-400 hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{d(\"library:delete_all\")&&(r(t.id),y(!1))},disabled:!d(\"library:delete_all\"),title:d(\"library:delete_all\")?void 0:u(\"fileManager.noPermissionDeleteFolder\"),children:[a.jsx(en,{className:\"w-3.5 h-3.5\"}),u(\"common.delete\")]})]})]})]})})]}),v&&m&&a.jsx(\"div\",{children:t.children.map(_=>a.jsx(qle,{folder:_,selectedFolderId:e,onSelect:n,onDelete:r,onLink:i,onRename:s,depth:o+1,wrapNames:l,defaultExpanded:c,hasPermission:d,t:u},_.id))})]})}function cO(t){const e=t.toLowerCase();return e.endsWith(\".gcode\")||e.endsWith(\".gcode.3mf\")}function hrt({file:t,isSelected:e,isMobile:n,onSelect:r,onDelete:i,onDownload:s,onAddToQueue:o,onPrint:l,onPreview3d:c,onRename:d,onGenerateThumbnail:u,thumbnailVersion:m,hasPermission:p,canModify:f,authEnabled:y,t:v}){const[b,g]=w.useState(!1);return a.jsxs(\"div\",{className:`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${e?\"border-bambu-green ring-1 ring-bambu-green\":\"border-bambu-dark-tertiary hover:border-bambu-green/50\"}`,onClick:()=>r(t.id),children:[a.jsxs(\"div\",{className:\"aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden\",children:[t.thumbnail_path?a.jsx(\"img\",{src:`${ue.getLibraryFileThumbnailUrl(t.id)}${m?(ue.getLibraryFileThumbnailUrl(t.id).includes(\"?\")?\"&\":\"?\")+`v=${m}`:\"\"}`,alt:t.filename,className:\"w-full h-full object-cover\"}):a.jsx(ep,{className:\"w-12 h-12 text-bambu-gray/30\"}),a.jsx(\"div\",{className:`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${t.file_type===\"3mf\"?\"bg-bambu-green/90 text-white\":t.file_type===\"gcode\"?\"bg-blue-500/90 text-white\":t.file_type===\"stl\"?\"bg-purple-500/90 text-white\":\"bg-bambu-gray/90 text-white\"}`,children:t.file_type.toUpperCase()})]}),a.jsxs(\"div\",{className:\"p-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-medium text-white truncate\",title:t.print_name||t.filename,children:t.print_name||t.filename}),a.jsxs(\"div\",{className:\"flex items-center gap-3 mt-1 text-xs text-bambu-gray\",children:[a.jsx(\"span\",{children:Lo(t.file_size)}),t.print_time_seconds&&a.jsxs(\"span\",{className:\"flex items-center gap-1\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),ws(t.print_time_seconds)]})]}),t.sliced_for_model&&a.jsxs(\"div\",{className:\"mt-1 text-xs text-bambu-gray flex items-center gap-1\",children:[a.jsx(Er,{className:\"w-3 h-3\"}),t.sliced_for_model]}),t.print_count>0&&a.jsx(\"div\",{className:\"mt-1 text-xs text-bambu-green\",children:v(\"fileManager.printedCount\",{count:t.print_count})}),y&&t.created_by_username&&a.jsxs(\"div\",{className:\"mt-1 text-xs text-bambu-gray flex items-center gap-1\",children:[a.jsx(ym,{className:\"w-3 h-3\"}),t.created_by_username]})]}),a.jsxs(\"div\",{className:`absolute bottom-2 right-2 transition-opacity ${n?\"opacity-100\":\"opacity-0 group-hover:opacity-100\"}`,onClick:_=>_.stopPropagation(),children:[a.jsx(\"button\",{onClick:()=>g(!b),className:\"p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary\",children:a.jsx(Jh,{className:\"w-4 h-4 text-bambu-gray\"})}),b&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-10\",onClick:()=>g(!1)}),a.jsxs(\"div\",{className:\"absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]\",children:[l&&cO(t.filename)&&a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${p(\"printers:control\")?\"text-bambu-green hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{p(\"printers:control\")&&(l(t),g(!1))},disabled:!p(\"printers:control\"),title:p(\"printers:control\")?void 0:v(\"fileManager.noPermissionPrint\"),children:[a.jsx(Er,{className:\"w-3.5 h-3.5\"}),v(\"common.print\")]}),o&&cO(t.filename)&&a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${p(\"queue:create\")?\"text-white hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{p(\"queue:create\")&&(o(t.id),g(!1))},disabled:!p(\"queue:create\"),title:p(\"queue:create\")?void 0:v(\"fileManager.noPermissionAddToQueue\"),children:[a.jsx(Yn,{className:\"w-3.5 h-3.5\"}),v(\"fileManager.schedulePrint\")]}),c&&(t.file_type===\"3mf\"||t.file_type===\"gcode\"||t.file_type===\"stl\")&&a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${p(\"library:read\")?\"text-white hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{p(\"library:read\")&&(c(t),g(!1))},disabled:!p(\"library:read\"),title:p(\"library:read\")?void 0:\"You do not have permission to preview files\",children:[a.jsx(vi,{className:\"w-3.5 h-3.5\"}),\"3D Preview\"]}),a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${p(\"library:read\")?\"text-white hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{p(\"library:read\")&&(s(t.id),g(!1))},disabled:!p(\"library:read\"),title:p(\"library:read\")?void 0:v(\"fileManager.noPermissionDownload\"),children:[a.jsx(ga,{className:\"w-3.5 h-3.5\"}),v(\"common.download\")]}),d&&a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${f(\"library\",\"update\",t.created_by_id)?\"text-white hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{f(\"library\",\"update\",t.created_by_id)&&(d(t),g(!1))},disabled:!f(\"library\",\"update\",t.created_by_id),title:f(\"library\",\"update\",t.created_by_id)?void 0:v(\"fileManager.noPermissionRenameFile\"),children:[a.jsx(ci,{className:\"w-3.5 h-3.5\"}),v(\"common.rename\")]}),u&&t.file_type===\"stl\"&&a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${f(\"library\",\"update\",t.created_by_id)?\"text-white hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{f(\"library\",\"update\",t.created_by_id)&&(u(t),g(!1))},disabled:!f(\"library\",\"update\",t.created_by_id),title:f(\"library\",\"update\",t.created_by_id)?void 0:v(\"fileManager.noPermissionGenerateThumbnail\"),children:[a.jsx(gm,{className:\"w-3.5 h-3.5\"}),v(\"fileManager.generateThumbnail\")]}),a.jsxs(\"button\",{className:`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${f(\"library\",\"delete\",t.created_by_id)?\"text-red-400 hover:bg-bambu-dark\":\"text-bambu-gray cursor-not-allowed\"}`,onClick:()=>{f(\"library\",\"delete\",t.created_by_id)&&(i(t.id),g(!1))},disabled:!f(\"library\",\"delete\",t.created_by_id),title:f(\"library\",\"delete\",t.created_by_id)?void 0:v(\"fileManager.noPermissionDeleteFile\"),children:[a.jsx(en,{className:\"w-3.5 h-3.5\"}),v(\"common.delete\")]})]})]})]}),a.jsx(\"div\",{className:`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${e?\"bg-bambu-green border-bambu-green\":`border-white/30 bg-black/30 ${n?\"opacity-100\":\"opacity-0 group-hover:opacity-100\"}`}`,children:e&&a.jsx(\"div\",{className:\"w-2 h-2 bg-white rounded-sm\"})})]})}function prt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),{hasPermission:r,hasAnyPermission:i,canModify:s,authEnabled:o}=kr(),[l]=BP(),c=l.get(\"folder\"),d=c?parseInt(c,10):null,[u,m]=w.useState(d),[p,f]=w.useState([]),[y,v]=w.useState(!1),[b,g]=w.useState(!1),[_,C]=w.useState(!1),[P,N]=w.useState(!1),[A,T]=w.useState(null),[F,k]=w.useState(null),[D,H]=w.useState(null),[z,Q]=w.useState(null),[L,te]=w.useState(null),[ie,J]=w.useState(null),[oe,fe]=w.useState({}),[re,W]=w.useState(null),[ne,me]=w.useState(()=>localStorage.getItem(\"library-view-mode\")||\"grid\"),[be,Ce]=w.useState(()=>localStorage.getItem(\"library-wrap-folders\")===\"true\"),[q,Y]=w.useState(()=>localStorage.getItem(\"library-collapse-folders\")===\"true\"),[E,j]=w.useState(()=>{const Ke=localStorage.getItem(\"library-sidebar-width\");return Ke?parseInt(Ke,10):256}),[O,K]=w.useState(!1),U=w.useRef(null);w.useEffect(()=>{if(!O)return;document.body.style.userSelect=\"none\",document.body.style.cursor=\"col-resize\";const Ke=Lt=>{if(!U.current)return;const Et=U.current.parentElement?.getBoundingClientRect();if(!Et)return;const At=Lt.clientX-Et.left,Ie=Math.min(500,Math.max(200,At));j(Ie)},at=()=>{K(!1),document.body.style.userSelect=\"\",document.body.style.cursor=\"\",localStorage.setItem(\"library-sidebar-width\",String(E))};return document.addEventListener(\"mousemove\",Ke),document.addEventListener(\"mouseup\",at),()=>{document.removeEventListener(\"mousemove\",Ke),document.removeEventListener(\"mouseup\",at),document.body.style.userSelect=\"\",document.body.style.cursor=\"\"}},[O,E]);const[de,I]=w.useState(\"\"),[G,X]=w.useState(\"all\"),[V,ee]=w.useState(\"\"),[se,ge]=w.useState(()=>localStorage.getItem(\"library-sort-field\")||\"name\"),[he,le]=w.useState(()=>localStorage.getItem(\"library-sort-direction\")||\"asc\"),B=aie();w.useEffect(()=>{const Ke=l.get(\"folder\");if(Ke){const at=parseInt(Ke,10);m(at)}},[l]);const{data:R}=Xe({queryKey:[\"settings\"],queryFn:()=>ue.getSettings()}),{data:ae,isLoading:_e}=Xe({queryKey:[\"library-folders\"],queryFn:()=>ue.getLibraryFolders()}),{data:Se,isLoading:ve}=Xe({queryKey:[\"library-files\",u],queryFn:()=>ue.getLibraryFiles(u,u===null)}),{data:Te}=Xe({queryKey:[\"library-stats\"],queryFn:()=>ue.getLibraryStats()}),{data:ye}=Xe({queryKey:[\"users\"],queryFn:()=>ue.getUsers()}),je=w.useMemo(()=>{if(!Se)return[];const Ke=new Set(Se.map(at=>at.file_type));return Array.from(Ke).sort()},[Se]),Le=w.useMemo(()=>{if(!Se)return[];let Ke=[...Se];if(de.trim()){const at=de.toLowerCase();Ke=Ke.filter(Lt=>Lt.filename.toLowerCase().includes(at)||Lt.print_name&&Lt.print_name.toLowerCase().includes(at))}if(G!==\"all\"&&(Ke=Ke.filter(at=>at.file_type===G)),V.trim()){const at=V.toLowerCase();Ke=Ke.filter(Lt=>Lt.created_by_username&&Lt.created_by_username.toLowerCase().includes(at))}return Ke.sort((at,Lt)=>{let Et=0;switch(se){case\"name\":Et=(at.print_name||at.filename).localeCompare(Lt.print_name||Lt.filename);break;case\"date\":Et=(Zn(at.created_at)?.getTime()??0)-(Zn(Lt.created_at)?.getTime()??0);break;case\"size\":Et=at.file_size-Lt.file_size;break;case\"type\":Et=at.file_type.localeCompare(Lt.file_type);break;case\"prints\":Et=at.print_count-Lt.print_count;break}return he===\"asc\"?Et:-Et}),Ke},[Se,de,G,V,se,he]),Me=w.useMemo(()=>{if(!Te||!R)return!1;const Ke=(R.library_disk_warning_gb||5)*1024*1024*1024;return Te.disk_free_bytes<Ke},[Te,R]),Oe=it({mutationFn:Ke=>ue.createLibraryFolder(Ke),onSuccess:()=>{e.invalidateQueries({queryKey:[\"library-folders\"]}),v(!1),n(t(\"fileManager.toast.folderCreated\"),\"success\")},onError:Ke=>n(Ke.message,\"error\")}),Re=it({mutationFn:async Ke=>{const at=await ue.createExternalFolder(Ke);return await ue.scanExternalFolder(at.id),at},onSuccess:Ke=>{e.invalidateQueries({queryKey:[\"library-folders\"]}),e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"library-stats\"]}),g(!1),m(Ke.id),n(t(\"fileManager.toast.externalFolderLinked\"),\"success\")},onError:Ke=>n(Ke.message,\"error\")}),$e=it({mutationFn:Ke=>ue.scanExternalFolder(Ke),onSuccess:Ke=>{e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"library-folders\"]}),e.invalidateQueries({queryKey:[\"library-stats\"]}),n(t(\"fileManager.toast.folderScanned\",{added:Ke.added,removed:Ke.removed}),\"success\")},onError:Ke=>n(Ke.message,\"error\")}),Ye=it({mutationFn:Ke=>ue.deleteLibraryFolder(Ke),onSuccess:()=>{e.invalidateQueries({queryKey:[\"library-folders\"]}),e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"library-stats\"]}),u===F?.id&&m(null),k(null),n(t(\"fileManager.toast.folderDeleted\"),\"success\")},onError:Ke=>{k(null),n(Ke.message,\"error\")}}),tt=it({mutationFn:Ke=>ue.deleteLibraryFile(Ke),onSuccess:()=>{e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"library-folders\"]}),e.invalidateQueries({queryKey:[\"library-stats\"]}),f(Ke=>Ke.filter(at=>at!==F?.id)),k(null),n(t(\"fileManager.toast.fileDeleted\"),\"success\")},onError:Ke=>{k(null),n(Ke.message,\"error\")}}),pe=it({mutationFn:Ke=>ue.bulkDeleteLibrary(Ke,[]),onSuccess:(Ke,at)=>{e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"library-folders\"]}),e.invalidateQueries({queryKey:[\"library-stats\"]}),n(t(\"fileManager.toast.filesDeleted\",{count:at.length}),\"success\"),f([]),k(null)},onError:Ke=>{k(null),n(Ke.message,\"error\")}}),Fe=it({mutationFn:({fileIds:Ke,folderId:at})=>ue.moveLibraryFiles(Ke,at),onSuccess:()=>{e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"library-folders\"]}),f([]),C(!1),n(t(\"fileManager.toast.filesMoved\"),\"success\")},onError:Ke=>n(Ke.message,\"error\")}),we=it({mutationFn:({id:Ke,data:at})=>ue.updateLibraryFolder(Ke,at),onSuccess:(Ke,at)=>{e.invalidateQueries({queryKey:[\"library-folders\"]}),e.invalidateQueries({queryKey:[\"project-folders\"]}),e.invalidateQueries({queryKey:[\"archive-folders\"]}),T(null);const Lt=at.data.project_id===0&&at.data.archive_id===0;n(t(Lt?\"fileManager.toast.folderUnlinked\":\"fileManager.toast.folderLinked\"),\"success\")},onError:Ke=>n(Ke.message,\"error\")}),Ve=it({mutationFn:({id:Ke,filename:at})=>ue.updateLibraryFile(Ke,{filename:at}),onSuccess:()=>{e.invalidateQueries({queryKey:[\"library-files\"]}),J(null),n(t(\"fileManager.toast.fileRenamed\"),\"success\")},onError:Ke=>{J(null),n(Ke.message,\"error\")}}),Ae=it({mutationFn:({id:Ke,name:at})=>ue.updateLibraryFolder(Ke,{name:at}),onSuccess:()=>{e.invalidateQueries({queryKey:[\"library-folders\"]}),e.invalidateQueries({queryKey:[\"library-files\"]}),J(null),n(t(\"fileManager.toast.folderRenamed\"),\"success\")},onError:Ke=>{J(null),n(Ke.message,\"error\")}}),ce=it({mutationFn:()=>ue.batchGenerateStlThumbnails({all_missing:!0}),onSuccess:Ke=>{if(e.invalidateQueries({queryKey:[\"library-files\"]}),Ke.succeeded>0){const at=Date.now(),Lt={};Ke.results.forEach(Et=>{Et.success&&(Lt[Et.file_id]=at)}),fe(Et=>({...Et,...Lt}))}Ke.succeeded>0&&Ke.failed===0?n(t(\"fileManager.toast.thumbnailsGenerated\",{count:Ke.succeeded}),\"success\"):Ke.succeeded>0&&Ke.failed>0?n(t(\"fileManager.toast.thumbnailsGeneratedPartial\",{succeeded:Ke.succeeded,failed:Ke.failed}),\"success\"):Ke.processed===0?n(t(\"fileManager.toast.noStlMissingThumbnails\"),\"info\"):n(t(\"fileManager.toast.failedToGenerateThumbnails\",{error:Ke.results[0]?.error||\"Unknown error\"}),\"error\")},onError:Ke=>n(Ke.message,\"error\")}),xe=it({mutationFn:Ke=>ue.batchGenerateStlThumbnails({file_ids:[Ke]}),onSuccess:Ke=>{if(e.invalidateQueries({queryKey:[\"library-files\"]}),Ke.succeeded>0){const at=Ke.results[0]?.file_id;at&&fe(Lt=>({...Lt,[at]:Date.now()})),n(t(\"fileManager.toast.thumbnailGenerated\"),\"success\")}else n(t(\"fileManager.toast.failedToGenerateThumbnail\",{error:Ke.results[0]?.error||\"Unknown error\"}),\"error\")},onError:Ke=>n(Ke.message,\"error\")}),Be=w.useCallback(Ke=>{const at=Ke.toLowerCase();return at.endsWith(\".gcode\")||at.includes(\".gcode.\")},[]),Qe=w.useMemo(()=>Se?Se.filter(Ke=>p.includes(Ke.id)&&Be(Ke.filename)):[],[Se,p,Be]),ht=w.useCallback(Ke=>{f(at=>at.includes(Ke)?at.filter(Lt=>Lt!==Ke):[...at,Ke])},[]),xt=w.useCallback(()=>{Le.length>0&&f(Le.map(Ke=>Ke.id))},[Le]),gt=w.useCallback(()=>{f([])},[]),Ut=()=>{e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"library-folders\"]}),e.invalidateQueries({queryKey:[\"library-stats\"]})},Wt=Ke=>{ue.downloadLibraryFile(Ke).catch(at=>{console.error(\"Library file download failed:\",at)})},Zt=()=>{F&&(F.type===\"file\"?tt.mutate(F.id):F.type===\"folder\"?Ye.mutate(F.id):F.type===\"bulk\"&&pe.mutate(p))},Kt=Ye.isPending||tt.isPending||pe.isPending,Xt=Ke=>{me(Ke),localStorage.setItem(\"library-view-mode\",Ke)},ln=_e||ve,vn=w.useMemo(()=>{if(!u||!ae)return null;const Ke=at=>{for(const Lt of at){if(Lt.id===u)return Lt;const Et=Ke(Lt.children);if(Et)return Et}return null};return Ke(ae)},[u,ae]);return a.jsxs(\"div\",{className:\"p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"h1\",{className:\"text-2xl font-bold text-white flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"p-2.5 bg-bambu-green/10 rounded-xl\",children:a.jsx(Qc,{className:\"w-6 h-6 text-bambu-green\"})}),t(\"fileManager.title\")]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-2 ml-14\",children:t(\"fileManager.subtitle\")})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center bg-bambu-dark rounded-lg p-1\",children:[a.jsx(\"button\",{onClick:()=>Xt(\"grid\"),className:`p-1.5 rounded transition-colors ${ne===\"grid\"?\"bg-bambu-dark-secondary text-white\":\"text-bambu-gray hover:text-white\"}`,title:t(\"fileManager.gridView\"),children:a.jsx(eS,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>Xt(\"list\"),className:`p-1.5 rounded transition-colors ${ne===\"list\"?\"bg-bambu-dark-secondary text-white\":\"text-bambu-gray hover:text-white\"}`,title:t(\"fileManager.listView\"),children:a.jsx(tS,{className:\"w-4 h-4\"})})]}),a.jsxs(De,{variant:\"secondary\",onClick:()=>ce.mutate(),disabled:ce.isPending||!i(\"library:update_own\",\"library:update_all\"),title:i(\"library:update_own\",\"library:update_all\")?t(\"fileManager.generateThumbnailsForMissing\"):t(\"fileManager.noPermissionGenerateThumbnail\"),children:[ce.isPending?a.jsx(ft,{className:\"w-4 h-4 mr-2 animate-spin\"}):a.jsx(gm,{className:\"w-4 h-4 mr-2\"}),t(\"fileManager.generateThumbnails\")]}),a.jsxs(De,{variant:\"secondary\",onClick:()=>g(!0),disabled:!r(\"library:upload\"),title:r(\"library:upload\")?t(\"fileManager.linkExternalFolder\"):t(\"fileManager.noPermissionCreateFolder\"),children:[a.jsx(wN,{className:\"w-4 h-4 mr-2\"}),t(\"fileManager.linkExternal\")]}),a.jsxs(De,{variant:\"secondary\",onClick:()=>v(!0),disabled:!r(\"library:upload\"),title:r(\"library:upload\")?void 0:t(\"fileManager.noPermissionCreateFolder\"),children:[a.jsx(gge,{className:\"w-4 h-4 mr-2\"}),t(\"fileManager.newFolder\")]}),a.jsxs(De,{onClick:()=>N(!0),disabled:!r(\"library:upload\"),title:r(\"library:upload\")?void 0:t(\"fileManager.noPermissionUpload\"),children:[a.jsx(La,{className:\"w-4 h-4 mr-2\"}),t(\"common.upload\")]})]})]}),Me&&Te&&R&&a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg\",children:[a.jsx(Dn,{className:\"w-5 h-5 text-amber-500 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"p\",{className:\"text-sm text-amber-500 font-medium\",children:t(\"fileManager.lowDiskSpaceWarning\")}),a.jsx(\"p\",{className:\"text-xs text-amber-500/80\",children:t(\"fileManager.lowDiskSpaceDetails\",{free:Lo(Te.disk_free_bytes),total:Lo(Te.disk_total_bytes),threshold:R.library_disk_warning_gb})})]})]}),Te&&a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(qP,{className:\"w-4 h-4 text-bambu-green\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[t(\"fileManager.files\"),\":\"]}),a.jsx(\"span\",{className:\"text-white font-medium\",children:Te.total_files})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(Qc,{className:\"w-4 h-4 text-blue-400\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[t(\"fileManager.folders\"),\":\"]}),a.jsx(\"span\",{className:\"text-white font-medium\",children:Te.total_folders})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm\",children:[a.jsx(Ig,{className:\"w-4 h-4 text-amber-400\"}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[t(\"fileManager.size\"),\":\"]}),a.jsx(\"span\",{className:\"text-white font-medium\",children:Lo(Te.total_size_bytes)})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 text-sm sm:ml-auto\",children:[a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[t(\"fileManager.free\"),\":\"]}),a.jsx(\"span\",{className:`font-medium ${Me?\"text-amber-500\":\"text-white\"}`,children:Lo(Te.disk_free_bytes)})]})]}),a.jsxs(\"div\",{className:\"flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0\",children:[a.jsx(\"div\",{className:\"lg:hidden\",children:a.jsxs(\"select\",{value:u??\"\",onChange:Ke=>m(Ke.target.value?parseInt(Ke.target.value,10):null),className:\"w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green\",children:[a.jsxs(\"option\",{value:\"\",children:[\"📁 \",t(\"fileManager.allFiles\")]}),ae&&(()=>{const Ke=(at,Lt=0)=>{const Et=[];for(const At of at)Et.push({id:At.id,name:At.name,fileCount:At.file_count,depth:Lt}),At.children.length>0&&Et.push(...Ke(At.children,Lt+1));return Et};return Ke(ae).map(at=>a.jsxs(\"option\",{value:at.id,children:[\"│ \".repeat(at.depth),\"📂 \",at.name,\" \",at.fileCount>0?`(${at.fileCount})`:\"\"]},at.id))})()]})}),a.jsxs(\"div\",{ref:U,className:\"hidden lg:flex flex-shrink-0 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative\",style:{width:`${E}px`},children:[a.jsx(\"div\",{className:`absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize z-10 group/resize flex items-center justify-center transition-colors ${O?\"bg-bambu-green\":\"hover:bg-bambu-green/50\"}`,onMouseDown:Ke=>{Ke.preventDefault(),K(!0)},onDoubleClick:()=>{j(256),localStorage.setItem(\"library-sidebar-width\",\"256\")},title:t(\"fileManager.dragToResizeTooltip\"),children:a.jsxs(\"div\",{className:`flex flex-col gap-1 opacity-0 group-hover/resize:opacity-100 transition-opacity ${O?\"opacity-100\":\"\"}`,children:[a.jsx(\"div\",{className:\"w-0.5 h-0.5 rounded-full bg-white/70\"}),a.jsx(\"div\",{className:\"w-0.5 h-0.5 rounded-full bg-white/70\"}),a.jsx(\"div\",{className:\"w-0.5 h-0.5 rounded-full bg-white/70\"})]})}),a.jsxs(\"div\",{className:\"p-3 border-b border-bambu-dark-tertiary flex items-center justify-between\",children:[a.jsx(\"h2\",{className:\"text-sm font-medium text-white\",children:t(\"fileManager.folders\")}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"button\",{onClick:()=>{const Ke=!q;Y(Ke),localStorage.setItem(\"library-collapse-folders\",String(Ke))},className:`text-xs px-1.5 py-0.5 rounded transition-colors ${q?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:text-white hover:bg-bambu-dark\"}`,title:t(q?\"fileManager.expandFoldersByDefault\":\"fileManager.collapseFoldersByDefault\"),children:t(\"fileManager.collapse\")}),a.jsx(\"button\",{onClick:()=>{const Ke=!be;Ce(Ke),localStorage.setItem(\"library-wrap-folders\",String(Ke))},className:`text-xs px-1.5 py-0.5 rounded transition-colors ${be?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:text-white hover:bg-bambu-dark\"}`,title:t(be?\"fileManager.disableTextWrapping\":\"fileManager.enableTextWrapping\"),children:t(\"fileManager.wrap\")})]})]}),a.jsxs(\"div\",{className:\"flex-1 overflow-y-auto p-2\",children:[a.jsxs(\"div\",{className:`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${u===null?\"bg-bambu-green/20 text-bambu-green\":\"hover:bg-bambu-dark text-white\"}`,onClick:()=>m(null),children:[a.jsx(ep,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"text-sm\",children:t(\"fileManager.allFiles\")})]}),ae?.map(Ke=>a.jsx(qle,{folder:Ke,selectedFolderId:u,onSelect:m,onDelete:at=>k({type:\"folder\",id:at}),onLink:T,onRename:at=>J({type:\"folder\",id:at.id,name:at.name}),wrapNames:be,defaultExpanded:!q,hasPermission:r,t},`${Ke.id}-${q?\"c\":\"e\"}`))]})]}),a.jsxs(\"div\",{className:\"flex-1 flex flex-col min-w-0 min-h-0\",children:[vn?.is_external&&a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-4 p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg\",children:[a.jsx(wN,{className:\"w-5 h-5 text-purple-400 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-sm font-medium text-purple-300\",children:t(\"fileManager.externalFolder\")}),vn.external_readonly&&a.jsxs(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 flex items-center gap-1\",children:[a.jsx(kd,{className:\"w-3 h-3\"}),t(\"fileManager.readOnly\")]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray truncate font-mono\",title:vn.external_path||\"\",children:vn.external_path})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>u&&$e.mutate(u),disabled:$e.isPending,title:t(\"fileManager.scanFolder\"),children:[$e.isPending?a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}):a.jsx(lr,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"ml-1.5\",children:t(\"fileManager.scanFolder\")})]})]}),Se&&Se.length>0&&a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static\",children:[a.jsxs(\"div\",{className:\"relative w-full sm:w-auto sm:flex-1 sm:max-w-xs\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",placeholder:t(\"fileManager.searchFiles\"),value:de,onChange:Ke=>I(Ke.target.value),className:\"w-full pl-9 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green\"})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx($P,{className:\"w-4 h-4 text-bambu-gray hidden sm:block\"}),a.jsxs(\"select\",{value:G,onChange:Ke=>X(Ke.target.value),className:\"bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green\",children:[a.jsx(\"option\",{value:\"all\",children:t(\"fileManager.allTypes\")}),je.map(Ke=>a.jsx(\"option\",{value:Ke,children:Ke.toUpperCase()},Ke))]})]}),o&&a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"input\",{type:\"text\",placeholder:t(\"fileManager.filterByUser\",{defaultValue:\"Filter by user\"}),value:V,onChange:Ke=>ee(Ke.target.value),list:\"usernames-list\",className:`w-32 sm:w-40 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green ${V?\"pr-7\":\"\"}`,style:V?{WebkitAppearance:\"none\",MozAppearance:\"textfield\"}:void 0}),V&&a.jsx(\"button\",{onClick:()=>ee(\"\"),className:\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white z-10\",children:a.jsx(Ht,{className:\"w-3 h-3\"})}),a.jsx(\"datalist\",{id:\"usernames-list\",children:ye?.map(Ke=>a.jsx(\"option\",{value:Ke.username},Ke.id))})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"select\",{value:se,onChange:Ke=>{const at=Ke.target.value;ge(at),localStorage.setItem(\"library-sort-field\",at)},className:\"bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green\",children:[a.jsx(\"option\",{value:\"name\",children:t(\"common.name\")}),a.jsx(\"option\",{value:\"date\",children:t(\"common.date\")}),a.jsx(\"option\",{value:\"size\",children:t(\"fileManager.size\")}),a.jsx(\"option\",{value:\"type\",children:t(\"common.type\")}),a.jsx(\"option\",{value:\"prints\",children:t(\"fileManager.prints\")})]}),a.jsx(\"button\",{onClick:()=>le(Ke=>{const at=Ke===\"asc\"?\"desc\":\"asc\";return localStorage.setItem(\"library-sort-direction\",at),at}),className:\"p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors\",title:t(he===\"asc\"?\"fileManager.ascending\":\"fileManager.descending\"),children:he===\"asc\"?a.jsx(Spe,{className:\"w-4 h-4 text-white\"}):a.jsx(fpe,{className:\"w-4 h-4 text-white\"})})]}),(de||G!==\"all\"||V)&&a.jsx(\"span\",{className:\"text-sm text-bambu-gray hidden sm:inline\",children:t(\"fileManager.resultsCount\",{showing:Le.length,total:Se.length})})]}),Le.length>0&&a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static\",children:[p.length===Le.length&&p.length>0?a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:gt,children:[a.jsx(uo,{className:\"w-4 h-4 sm:mr-1\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"fileManager.deselectAll\")})]}):a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:xt,children:[a.jsx(Ns,{className:\"w-4 h-4 sm:mr-1\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"fileManager.selectAll\")})]}),p.length>0&&a.jsxs(a.Fragment,{children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray ml-2\",children:t(\"fileManager.selected\",{count:p.length})}),a.jsx(\"div\",{className:\"hidden sm:block flex-1\"}),a.jsxs(\"div\",{className:\"w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0\",children:[Qe.length===1&&a.jsxs(De,{variant:\"primary\",size:\"sm\",onClick:()=>Q(Qe[0]),disabled:!r(\"printers:control\"),title:r(\"printers:control\")?void 0:t(\"fileManager.noPermissionPrint\"),children:[a.jsx(es,{className:\"w-4 h-4 sm:mr-1\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.print\")})]}),Qe.length===1&&a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>te(Qe[0]),disabled:!r(\"queue:create\"),title:r(\"queue:create\")?void 0:t(\"fileManager.noPermissionAddToQueue\"),children:[a.jsx(Yn,{className:\"w-4 h-4 sm:mr-1\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"fileManager.schedulePrint\")})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:()=>C(!0),disabled:!i(\"library:update_own\",\"library:update_all\"),title:i(\"library:update_own\",\"library:update_all\")?void 0:t(\"fileManager.noPermissionMoveFiles\"),children:[a.jsx(Cbe,{className:\"w-4 h-4 sm:mr-1\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.move\")})]}),a.jsxs(De,{variant:\"danger\",size:\"sm\",onClick:()=>{p.length===1?k({type:\"file\",id:p[0]}):k({type:\"bulk\",id:0,count:p.length})},disabled:!i(\"library:delete_own\",\"library:delete_all\"),title:i(\"library:delete_own\",\"library:delete_all\")?void 0:t(\"fileManager.noPermissionDeleteFiles\"),children:[a.jsx(en,{className:\"w-4 h-4 sm:mr-1\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.delete\")})]}),a.jsxs(De,{variant:\"secondary\",size:\"sm\",onClick:gt,children:[a.jsx(Ht,{className:\"w-4 h-4 sm:mr-1\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"common.clear\")})]})]})]})]}),ln?a.jsx(\"div\",{className:\"flex-1 flex items-center justify-center\",children:a.jsxs(\"div\",{className:\"flex flex-col items-center gap-3\",children:[a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green\"}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"fileManager.loadingFiles\")})]})}):Se?.length===0?a.jsxs(\"div\",{className:\"flex-1 flex flex-col items-center justify-center\",children:[a.jsx(\"div\",{className:\"p-4 bg-bambu-dark rounded-2xl mb-4\",children:a.jsx(ep,{className:\"w-12 h-12 text-bambu-gray/50\"})}),a.jsx(\"h3\",{className:\"text-lg font-medium text-white mb-2\",children:t(u!==null?\"fileManager.folderIsEmpty\":\"fileManager.noFilesYet\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-center max-w-md mb-6\",children:t(u!==null?\"fileManager.folderEmptyDescription\":\"fileManager.noFilesDescription\")}),a.jsxs(De,{onClick:()=>N(!0),disabled:!r(\"library:upload\"),title:r(\"library:upload\")?void 0:t(\"fileManager.noPermissionUpload\"),children:[a.jsx(sr,{className:\"w-4 h-4 mr-2\"}),t(\"fileManager.uploadFiles\")]})]}):Le.length===0?a.jsxs(\"div\",{className:\"flex-1 flex flex-col items-center justify-center\",children:[a.jsx(\"div\",{className:\"p-4 bg-bambu-dark rounded-2xl mb-4\",children:a.jsx(Pr,{className:\"w-12 h-12 text-bambu-gray/50\"})}),a.jsx(\"h3\",{className:\"text-lg font-medium text-white mb-2\",children:t(\"fileManager.noMatchingFiles\")}),a.jsx(\"p\",{className:\"text-bambu-gray text-center max-w-md mb-6\",children:t(\"fileManager.noMatchingFilesDescription\")}),a.jsx(De,{variant:\"secondary\",onClick:()=>{I(\"\"),X(\"all\")},children:t(\"fileManager.clearFilters\")})]}):ne===\"grid\"?a.jsx(\"div\",{className:\"flex-1 lg:overflow-y-auto\",children:a.jsx(\"div\",{className:\"grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4\",children:Le.map(Ke=>a.jsx(hrt,{file:Ke,isSelected:p.includes(Ke.id),isMobile:B,t,onSelect:ht,onDelete:at=>k({type:\"file\",id:at}),onDownload:Wt,onAddToQueue:at=>{const Lt=Se?.find(Et=>Et.id===at);Lt&&te(Lt)},onPrint:H,onPreview3d:W,onRename:at=>J({type:\"file\",id:at.id,name:at.filename}),onGenerateThumbnail:at=>xe.mutate(at.id),thumbnailVersion:oe[Ke.id],hasPermission:r,canModify:s,authEnabled:o},Ke.id))})}):a.jsx(\"div\",{className:\"flex-1 lg:overflow-y-auto\",children:a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary overflow-hidden\",children:[a.jsxs(\"div\",{className:`hidden sm:grid ${o?\"grid-cols-[auto_1fr_120px_100px_100px_100px_80px]\":\"grid-cols-[auto_1fr_100px_100px_100px_80px]\"} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`,children:[a.jsx(\"div\",{className:\"w-6\"}),a.jsx(\"div\",{children:t(\"common.name\")}),o&&a.jsx(\"div\",{children:t(\"fileManager.uploadedBy\",{defaultValue:\"Uploaded By\"})}),a.jsx(\"div\",{children:t(\"common.type\")}),a.jsx(\"div\",{children:t(\"fileManager.size\")}),a.jsx(\"div\",{children:t(\"fileManager.prints\")}),a.jsx(\"div\",{})]}),Le.map(Ke=>a.jsxs(\"div\",{className:`grid ${o?\"grid-cols-[auto_1fr_120px_100px_100px_100px_80px]\":\"grid-cols-[auto_1fr_100px_100px_100px_80px]\"} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${p.includes(Ke.id)?\"bg-bambu-green/10\":\"\"}`,onClick:()=>ht(Ke.id),children:[a.jsx(\"div\",{className:`w-5 h-5 rounded border-2 flex items-center justify-center ${p.includes(Ke.id)?\"bg-bambu-green border-bambu-green\":\"border-bambu-gray/50\"}`,children:p.includes(Ke.id)&&a.jsx(\"div\",{className:\"w-2 h-2 bg-white rounded-sm\"})}),a.jsxs(\"div\",{className:\"flex items-center gap-3 min-w-0\",children:[a.jsxs(\"div\",{className:\"relative group/thumb\",children:[a.jsx(\"div\",{className:\"w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden\",children:Ke.thumbnail_path?a.jsx(\"img\",{src:`${ue.getLibraryFileThumbnailUrl(Ke.id)}${oe[Ke.id]?(ue.getLibraryFileThumbnailUrl(Ke.id).includes(\"?\")?\"&\":\"?\")+`v=${oe[Ke.id]}`:\"\"}`,alt:\"\",className:\"w-full h-full object-cover\"}):a.jsx(\"div\",{className:\"w-full h-full flex items-center justify-center\",children:a.jsx(ep,{className:\"w-5 h-5 text-bambu-gray/50\"})})}),Ke.thumbnail_path&&a.jsx(\"div\",{className:\"absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block\",children:a.jsx(\"div\",{className:\"w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden\",children:a.jsx(\"img\",{src:`${ue.getLibraryFileThumbnailUrl(Ke.id)}${oe[Ke.id]?(ue.getLibraryFileThumbnailUrl(Ke.id).includes(\"?\")?\"&\":\"?\")+`v=${oe[Ke.id]}`:\"\"}`,alt:Ke.filename,className:\"w-full h-full object-contain\"})})})]}),a.jsx(\"div\",{className:\"min-w-0\",children:a.jsx(\"div\",{className:\"text-sm text-white truncate\",children:Ke.print_name||Ke.filename})})]}),o&&a.jsx(\"div\",{className:\"text-sm text-bambu-gray flex items-center gap-1\",children:Ke.created_by_username?a.jsxs(a.Fragment,{children:[a.jsx(ym,{className:\"w-3 h-3\"}),a.jsx(\"span\",{className:\"truncate\",children:Ke.created_by_username})]}):\"-\"}),a.jsx(\"div\",{children:a.jsx(\"span\",{className:`text-xs px-1.5 py-0.5 rounded font-medium ${Ke.file_type===\"3mf\"?\"bg-bambu-green/20 text-bambu-green\":Ke.file_type===\"gcode\"?\"bg-blue-500/20 text-blue-400\":Ke.file_type===\"stl\"?\"bg-purple-500/20 text-purple-400\":\"bg-bambu-gray/20 text-bambu-gray\"}`,children:Ke.file_type.toUpperCase()})}),a.jsx(\"div\",{className:\"text-sm text-bambu-gray\",children:Lo(Ke.file_size)}),a.jsx(\"div\",{className:\"text-sm text-bambu-gray\",children:Ke.print_count>0?`${Ke.print_count}x`:\"-\"}),a.jsxs(\"div\",{className:\"flex items-center gap-1\",onClick:at=>at.stopPropagation(),children:[cO(Ke.filename)&&a.jsxs(a.Fragment,{children:[a.jsx(\"button\",{onClick:()=>r(\"printers:control\")&&H(Ke),className:`p-1.5 rounded transition-colors ${r(\"printers:control\")?\"hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:r(\"printers:control\")?t(\"common.print\"):t(\"fileManager.noPermissionPrint\"),disabled:!r(\"printers:control\"),children:a.jsx(Er,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>{r(\"queue:create\")&&te(Ke)},className:`p-1.5 rounded transition-colors ${r(\"queue:create\")?\"hover:bg-bambu-dark text-bambu-gray hover:text-white\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:r(\"queue:create\")?t(\"fileManager.schedulePrint\"):t(\"fileManager.noPermissionAddToQueue\"),disabled:!r(\"queue:create\"),children:a.jsx(Yn,{className:\"w-4 h-4\"})})]}),(Ke.file_type===\"3mf\"||Ke.file_type===\"gcode\"||Ke.file_type===\"stl\")&&a.jsx(\"button\",{onClick:()=>r(\"library:read\")&&W(Ke),className:`p-1.5 rounded transition-colors ${r(\"library:read\")?\"hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:r(\"library:read\")?\"3D Preview\":\"You do not have permission to preview files\",disabled:!r(\"library:read\"),children:a.jsx(vi,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>r(\"library:read\")&&Wt(Ke.id),className:`p-1.5 rounded transition-colors ${r(\"library:read\")?\"hover:bg-bambu-dark text-bambu-gray hover:text-white\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:r(\"library:read\")?t(\"common.download\"):t(\"fileManager.noPermissionDownload\"),disabled:!r(\"library:read\"),children:a.jsx(ga,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>s(\"library\",\"update\",Ke.created_by_id)&&J({type:\"file\",id:Ke.id,name:Ke.filename}),className:`p-1.5 rounded transition-colors ${s(\"library\",\"update\",Ke.created_by_id)?\"hover:bg-bambu-dark text-bambu-gray hover:text-white\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:s(\"library\",\"update\",Ke.created_by_id)?t(\"common.rename\"):t(\"fileManager.noPermissionRenameFile\"),disabled:!s(\"library\",\"update\",Ke.created_by_id),children:a.jsx(ci,{className:\"w-4 h-4\"})}),Ke.file_type===\"stl\"&&a.jsx(\"button\",{onClick:()=>s(\"library\",\"update\",Ke.created_by_id)&&xe.mutate(Ke.id),className:`p-1.5 rounded transition-colors ${s(\"library\",\"update\",Ke.created_by_id)?\"hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:s(\"library\",\"update\",Ke.created_by_id)?t(\"fileManager.generateThumbnail\"):t(\"fileManager.noPermissionGenerateThumbnail\"),disabled:xe.isPending||!s(\"library\",\"update\",Ke.created_by_id),children:a.jsx(gm,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>s(\"library\",\"delete\",Ke.created_by_id)&&k({type:\"file\",id:Ke.id}),className:`p-1.5 rounded transition-colors ${s(\"library\",\"delete\",Ke.created_by_id)?\"hover:bg-bambu-dark text-bambu-gray hover:text-red-400\":\"text-bambu-gray/50 cursor-not-allowed\"}`,title:s(\"library\",\"delete\",Ke.created_by_id)?t(\"common.delete\"):t(\"fileManager.noPermissionDeleteFile\"),disabled:!s(\"library\",\"delete\",Ke.created_by_id),children:a.jsx(en,{className:\"w-4 h-4\"})})]})]},Ke.id))]})})]})]}),y&&a.jsx(lrt,{parentId:u,onClose:()=>v(!1),onSave:Ke=>Oe.mutate(Ke),isLoading:Oe.isPending,t}),b&&a.jsx(crt,{onClose:()=>g(!1),onSave:Ke=>Re.mutate(Ke),isLoading:Re.isPending,t}),_&&ae&&a.jsx(urt,{folders:ae,selectedFiles:p,currentFolderId:u,onClose:()=>C(!1),onMove:Ke=>Fe.mutate({fileIds:p,folderId:Ke}),isLoading:Fe.isPending,t}),P&&a.jsx(Yae,{folderId:u,onClose:()=>N(!1),onUploadComplete:Ut}),A&&a.jsx(mrt,{folder:A,onClose:()=>T(null),onLink:Ke=>we.mutate({id:A.id,data:Ke}),isLoading:we.isPending,t}),F&&a.jsx(yn,{title:F.type===\"folder\"?t(\"fileManager.deleteFolder\"):F.type===\"bulk\"?t(\"fileManager.deleteFilesCount\",{count:F.count}):t(\"fileManager.deleteFile\"),message:F.type===\"folder\"?t(\"fileManager.deleteFolderConfirm\"):F.type===\"bulk\"?t(\"fileManager.deleteFilesConfirm\",{count:F.count}):t(\"fileManager.deleteFileConfirm\"),confirmText:t(\"common.delete\"),variant:\"danger\",isLoading:Kt,loadingText:t(\"fileManager.deleting\"),onConfirm:Zt,onCancel:()=>k(null)}),D&&a.jsx(ic,{mode:\"reprint\",libraryFileId:D.id,archiveName:D.print_name||D.filename,onClose:()=>H(null),onSuccess:()=>{H(null),e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"archives\"]})}}),z&&a.jsx(ic,{mode:\"reprint\",libraryFileId:z.id,archiveName:z.print_name||z.filename,onClose:()=>Q(null),onSuccess:()=>{Q(null),f([]),e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"archives\"]})}}),L&&a.jsx(ic,{mode:\"add-to-queue\",libraryFileId:L.id,archiveName:L.print_name||L.filename,onClose:()=>te(null),onSuccess:()=>{te(null),f([]),e.invalidateQueries({queryKey:[\"library-files\"]}),e.invalidateQueries({queryKey:[\"queue\"]}),e.invalidateQueries({queryKey:[\"archives\"]})}}),re&&a.jsx(_z,{libraryFileId:re.id,title:re.print_name||re.filename,fileType:re.file_type,onClose:()=>W(null)}),ie&&a.jsx(drt,{type:ie.type,currentName:ie.name,onClose:()=>J(null),onSave:Ke=>{ie.type===\"file\"?Ve.mutate({id:ie.id,filename:Ke}):Ae.mutate({id:ie.id,name:Ke})},isLoading:Ve.isPending||Ae.isPending,t})]})}function frt(t,e){const n=`token=${encodeURIComponent(e)}`;let r=0;return t.querySelectorAll('img[src*=\"/api/v1/\"], video[src*=\"/api/v1/\"]').forEach(i=>{const s=i.getAttribute(\"src\")||\"\";if(s.includes(n))return;const o=s.replace(/([?&])token=[^&]*(&|$)/,(c,d,u)=>u===\"&\"?d:\"\"),l=o.includes(\"?\")?\"&\":\"?\";i.src=`${o}${l}${n}`,r+=1}),r}function $le(){const{authEnabled:t,user:e}=kr(),n=nn(),r=w.useRef(!1),{data:i}=Xe({queryKey:[\"camera-stream-token\",e?.id??null],queryFn:()=>ue.getCameraStreamToken(),enabled:t?!!e:!0,staleTime:3e3*1e3,refetchInterval:3e3*1e3});w.useEffect(()=>{const s=i?.token??null;return jH(s),s&&frt(document,s),()=>jH(null)},[i?.token]),w.useEffect(()=>{if(!t)return;const s=o=>{const l=o.target;if(!(l instanceof HTMLImageElement||l instanceof HTMLVideoElement))return;const c=l.src||\"\",d=QQ();!d||!c.includes(`token=${encodeURIComponent(d)}`)||r.current||(r.current=!0,n.invalidateQueries({queryKey:[\"camera-stream-token\"]}),setTimeout(()=>{r.current=!1},5e3))};return document.addEventListener(\"error\",s,!0),()=>document.removeEventListener(\"error\",s,!0)},[t,n])}const Rk=5,grt=2e3,brt=3e4,xrt=5e3;function yrt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),{hasPermission:r,authEnabled:i,user:s}=kr(),{printerId:o}=Yw(),l=parseInt(o||\"0\",10);$le();const{data:c}=Xe({queryKey:[\"camera-stream-token\",s?.id??null],queryFn:()=>ue.getCameraStreamToken(),enabled:i?!!s:!0,staleTime:3e3*1e3}),d=c?.token??QQ(),[u,m]=w.useState(\"stream\"),[p,f]=w.useState(!1),[y,v]=w.useState(!1),[b,g]=w.useState(!0),[_,C]=w.useState(Date.now()),[P,N]=w.useState(!1),[A,T]=w.useState(!1),[F,k]=w.useState(0),[D,H]=w.useState(!1),[z,Q]=w.useState(0),[L,te]=w.useState(1),[ie,J]=w.useState({x:0,y:0}),[oe,fe]=w.useState(!1),[re,W]=w.useState({x:0,y:0}),[ne,me]=w.useState(null),[be,Ce]=w.useState(null),q=w.useRef(null),Y=w.useRef(null),E=w.useRef(null),j=w.useRef(null),O=w.useRef(null),{data:K}=Xe({queryKey:[\"printer\",l],queryFn:()=>ue.getPrinter(l),enabled:l>0}),{data:U}=Xe({queryKey:[\"printerStatus\",l],queryFn:()=>ue.getPrinterStatus(l),refetchInterval:3e4,enabled:l>0}),de=it({mutationFn:Fe=>ue.setChamberLight(l,Fe),onMutate:async Fe=>{await e.cancelQueries({queryKey:[\"printerStatus\",l]});const we=e.getQueryData([\"printerStatus\",l]);return e.setQueryData([\"printerStatus\",l],Ve=>({...Ve,chamber_light:Fe})),{previousStatus:we}},onSuccess:(Fe,we)=>{n(`Chamber light ${we?\"on\":\"off\"}`)},onError:(Fe,we,Ve)=>{Ve?.previousStatus&&e.setQueryData([\"printerStatus\",l],Ve.previousStatus),n(Fe.message||t(\"printers.toast.failedToControlChamberLight\"),\"error\")}}),I=(U?.state===\"RUNNING\"||U?.state===\"PAUSE\")&&(U?.printable_objects_count??0)>=2;w.useEffect(()=>(K&&(document.title=`${K.name} - Camera`),()=>{document.title=\"Bambuddy\"}),[K]);const G=w.useRef(!1);w.useEffect(()=>{const Fe=`/api/v1/printers/${l}/camera/stop`;G.current=!1;const we=()=>{if(l>0&&!G.current){G.current=!0;const ce={},xe=vm();xe&&(ce.Authorization=`Bearer ${xe}`),fetch(Fe,{method:\"POST\",keepalive:!0,headers:ce}).catch(()=>{})}},Ve=()=>{we()};window.addEventListener(\"beforeunload\",Ve);const Ae=q.current;return()=>{window.removeEventListener(\"beforeunload\",Ve),Ae&&(Ae.src=\"\"),we()}},[l]),w.useEffect(()=>{if(b&&!P){const we=setTimeout(()=>{g(!1)},u===\"stream\"?3e3:2e4);return()=>clearTimeout(we)}},[u,b,_,P]),w.useEffect(()=>{const Fe=()=>{const we=!!document.fullscreenElement;T(we),te(1),J({x:0,y:0}),u===\"stream\"&&!P&&(q.current&&(q.current.src=\"\"),setTimeout(()=>{g(!0),C(Date.now())},200))};return document.addEventListener(\"fullscreenchange\",Fe),()=>document.removeEventListener(\"fullscreenchange\",Fe)},[u,P]),w.useEffect(()=>{let Fe;const we=()=>{clearTimeout(Fe),Fe=setTimeout(()=>{localStorage.setItem(\"cameraWindowState\",JSON.stringify({width:window.outerWidth,height:window.outerHeight,left:window.screenX,top:window.screenY}))},500)};return window.addEventListener(\"resize\",we),()=>{clearTimeout(Fe),window.removeEventListener(\"resize\",we)}},[]),w.useEffect(()=>()=>{E.current&&clearTimeout(E.current),j.current&&clearInterval(j.current),O.current&&clearInterval(O.current)},[]);const X=w.useCallback(()=>{if(F>=Rk){H(!1),v(!0);return}const Fe=Math.min(grt*Math.pow(2,F),brt);H(!0),Q(Math.ceil(Fe/1e3)),j.current=setInterval(()=>{Q(we=>we<=1?(j.current&&clearInterval(j.current),0):we-1)},1e3),E.current=setTimeout(()=>{k(we=>we+1),H(!1),g(!0),v(!1),q.current&&(q.current.src=\"\"),C(Date.now())},Fe)},[F]);w.useEffect(()=>{if(u!==\"stream\"||b||D||P){O.current&&(clearInterval(O.current),O.current=null);return}return O.current=setInterval(async()=>{try{const Fe=await ue.getCameraStatus(l);(Fe.stalled||!Fe.active&&!y)&&(console.log(`Stream issue detected: stalled=${Fe.stalled}, active=${Fe.active}, reconnecting...`),O.current&&(clearInterval(O.current),O.current=null),g(!1),X())}catch{}},xrt),()=>{O.current&&(clearInterval(O.current),O.current=null)}},[u,b,y,D,P,l,X]);const V=()=>{g(!1),u===\"stream\"&&F<Rk?X():v(!0)},ee=()=>{if(g(!1),v(!1),k(0),H(!1),E.current&&clearTimeout(E.current),j.current&&clearInterval(j.current),q.current&&!localStorage.getItem(\"cameraWindowState\")){const Fe=q.current,we=Fe.naturalWidth,Ve=Fe.naturalHeight;if(we>0&&Ve>0){const xe=window.outerWidth-window.innerWidth,Be=window.outerHeight-window.innerHeight,Qe=we+16+xe,ht=Ve+45+16+Be;try{window.resizeTo(Qe,ht)}catch{}}}},se=()=>{if(l>0){const Fe={},we=vm();we&&(Fe.Authorization=`Bearer ${we}`),fetch(`/api/v1/printers/${l}/camera/stop`,{method:\"POST\",headers:Fe}).catch(()=>{})}},ge=Fe=>{u===Fe||P||(N(!0),g(!0),v(!1),k(0),H(!1),te(1),J({x:0,y:0}),E.current&&clearTimeout(E.current),j.current&&clearInterval(j.current),q.current&&(q.current.src=\"\"),u===\"stream\"&&se(),setTimeout(()=>{m(Fe),C(Date.now()),N(!1)},100))},he=()=>{P||(N(!0),g(!0),v(!1),k(0),H(!1),E.current&&clearTimeout(E.current),j.current&&clearInterval(j.current),q.current&&(q.current.src=\"\"),u===\"stream\"&&se(),setTimeout(()=>{C(Date.now()),N(!1)},100))},le=()=>{Y.current&&(document.fullscreenElement?document.exitFullscreen():Y.current.requestFullscreen())},B=()=>{te(Fe=>Math.min(Fe+.5,4))},R=()=>{te(Fe=>{const we=Math.max(Fe-.5,1);return we===1&&J({x:0,y:0}),we})},ae=Fe=>{Fe.preventDefault(),Fe.deltaY<0?B():R()},_e=Fe=>{L>1&&(Fe.preventDefault(),fe(!0),W({x:Fe.clientX-ie.x,y:Fe.clientY-ie.y}))},Se=w.useCallback(()=>{if(!Y.current)return{x:300,y:200};const Fe=Y.current.getBoundingClientRect(),we=Fe.width*(L-1)/2,Ve=Fe.height*(L-1)/2;return{x:Math.max(50,we),y:Math.max(50,Ve)}},[L]),ve=Fe=>{if(oe&&L>1){const we=Fe.clientX-re.x,Ve=Fe.clientY-re.y,Ae=Se();J({x:Math.max(-Ae.x,Math.min(Ae.x,we)),y:Math.max(-Ae.y,Math.min(Ae.y,Ve))})}},Te=()=>{fe(!1)},ye=Fe=>{if(Fe.length<2)return 0;const we=Fe[0].clientX-Fe[1].clientX,Ve=Fe[0].clientY-Fe[1].clientY;return Math.sqrt(we*we+Ve*Ve)},je=Fe=>Fe.length<2?{x:Fe[0].clientX,y:Fe[0].clientY}:{x:(Fe[0].clientX+Fe[1].clientX)/2,y:(Fe[0].clientY+Fe[1].clientY)/2},Le=Fe=>{Fe.touches.length===2?(Fe.preventDefault(),me(ye(Fe.touches)),Ce(je(Fe.touches))):Fe.touches.length===1&&L>1&&(Fe.preventDefault(),fe(!0),W({x:Fe.touches[0].clientX-ie.x,y:Fe.touches[0].clientY-ie.y}))},Me=Fe=>{if(Fe.touches.length===2&&ne!==null){Fe.preventDefault();const we=ye(Fe.touches),Ve=we/ne;te(ce=>{const xe=Math.max(1,Math.min(4,ce*Ve));return xe===1&&J({x:0,y:0}),xe}),me(we);const Ae=je(Fe.touches);if(be){const ce=Se();J(xe=>({x:Math.max(-ce.x,Math.min(ce.x,xe.x+(Ae.x-be.x))),y:Math.max(-ce.y,Math.min(ce.y,xe.y+(Ae.y-be.y)))}))}Ce(Ae)}else if(Fe.touches.length===1&&oe&&L>1){Fe.preventDefault();const we=Fe.touches[0].clientX-re.x,Ve=Fe.touches[0].clientY-re.y,Ae=Se();J({x:Math.max(-Ae.x,Math.min(Ae.x,we)),y:Math.max(-Ae.y,Math.min(Ae.y,Ve))})}},Oe=Fe=>{Fe.touches.length<2&&(me(null),Ce(null)),Fe.touches.length===0&&fe(!1)},Re=()=>{te(1),J({x:0,y:0})},$e=i&&!d,Ye=Fe=>d?`${Fe}&token=${encodeURIComponent(d)}`:Ga(Fe),tt=P||$e?\"\":Ye(u===\"stream\"?`/api/v1/printers/${l}/camera/stream?fps=15&t=${_}`:`/api/v1/printers/${l}/camera/snapshot?t=${_}`),pe=b||P||D;return l?a.jsxs(\"div\",{ref:Y,className:\"min-h-screen bg-black flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"h1\",{className:\"text-sm font-medium text-white flex items-center gap-2\",children:[a.jsx(qx,{className:\"w-4 h-4\"}),K?.name||`Printer ${l}`]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"flex bg-bambu-dark rounded p-0.5\",children:[a.jsx(\"button\",{onClick:()=>ge(\"stream\"),disabled:pe,className:`px-3 py-1 text-xs rounded transition-colors ${u===\"stream\"?\"bg-bambu-green text-white\":\"text-bambu-gray hover:text-white disabled:opacity-50\"}`,children:t(\"camera.live\")}),a.jsx(\"button\",{onClick:()=>ge(\"snapshot\"),disabled:pe,className:`px-3 py-1 text-xs rounded transition-colors ${u===\"snapshot\"?\"bg-bambu-green text-white\":\"text-bambu-gray hover:text-white disabled:opacity-50\"}`,children:t(\"camera.snapshot\")})]}),a.jsx(\"button\",{onClick:()=>de.mutate(!U?.chamber_light),disabled:!U?.connected||de.isPending||!r(\"printers:control\"),className:`p-1.5 rounded disabled:opacity-50 ${U?.chamber_light?\"bg-yellow-500/20 hover:bg-yellow-500/30\":\"hover:bg-bambu-dark-tertiary\"}`,title:r(\"printers:control\")?t(\"camera.chamberLight\"):t(\"printers.permission.noControl\"),children:a.jsx(xI,{on:U?.chamber_light??!1,className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>f(!0),disabled:!I||!r(\"printers:control\"),className:`p-1.5 rounded disabled:opacity-50 ${I&&r(\"printers:control\")?\"hover:bg-bambu-dark-tertiary\":\"\"}`,title:r(\"printers:control\")?t(I?\"printers.skipObjects.tooltip\":\"printers.skipObjects.onlyWhilePrinting\"):t(\"printers.permission.noControl\"),children:a.jsx(iT,{className:\"w-4 h-4 text-bambu-gray\"})}),a.jsx(\"button\",{onClick:he,disabled:pe,className:\"p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50\",title:t(u===\"stream\"?\"camera.restartStream\":\"camera.refreshSnapshot\"),children:a.jsx(lr,{className:`w-4 h-4 text-bambu-gray ${pe?\"animate-spin\":\"\"}`})}),a.jsx(\"button\",{onClick:le,className:\"p-1.5 hover:bg-bambu-dark-tertiary rounded\",title:t(A?\"camera.exitFullscreen\":\"camera.fullscreen\"),children:A?a.jsx(zQ,{className:\"w-4 h-4 text-bambu-gray\"}):a.jsx(fbe,{className:\"w-4 h-4 text-bambu-gray\"})})]})]}),a.jsx(\"div\",{className:\"flex-1 flex items-center justify-center p-2 overflow-hidden\",onWheel:ae,onMouseMove:ve,onMouseUp:Te,onMouseLeave:Te,onTouchStart:Le,onTouchMove:Me,onTouchEnd:Oe,style:{touchAction:\"none\"},children:a.jsxs(\"div\",{className:\"relative w-full h-full flex items-center justify-center\",children:[(b||P)&&!D&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-black/50 z-10\",children:a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(lr,{className:\"w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2\"}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(u===\"stream\"?\"camera.connectingToCamera\":\"camera.capturingSnapshot\")})]})}),D&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-black/80 z-10\",children:a.jsxs(\"div\",{className:\"text-center p-4\",children:[a.jsx(Bd,{className:\"w-10 h-10 text-orange-400 mx-auto mb-3\"}),a.jsx(\"p\",{className:\"text-white mb-2\",children:t(\"camera.connectionLost\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mb-3\",children:t(\"camera.reconnecting\",{countdown:z,attempt:Math.min(F+1,Rk),max:Rk})}),a.jsx(\"button\",{onClick:he,className:\"px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors\",children:t(\"camera.reconnectNow\")})]})}),y&&!D&&a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center bg-black z-10\",children:a.jsxs(\"div\",{className:\"text-center p-4\",children:[a.jsx(Dn,{className:\"w-12 h-12 text-orange-400 mx-auto mb-3\"}),a.jsx(\"p\",{className:\"text-white mb-2\",children:t(\"camera.cameraUnavailable\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-4 max-w-md\",children:t(\"camera.cameraUnavailableDesc\")}),a.jsx(\"button\",{onClick:he,className:\"px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors\",children:t(\"camera.retry\")})]})}),a.jsx(\"img\",{ref:q,src:tt,alt:t(\"camera.cameraStream\"),className:\"max-w-full max-h-full object-contain select-none\",style:{transform:`scale(${L}) translate(${ie.x/L}px, ${ie.y/L}px) rotate(${K?.camera_rotation||0}deg)`,...K?.camera_rotation===90||K?.camera_rotation===270?{maxWidth:\"100vh\",maxHeight:\"100vw\"}:{},cursor:L>1?oe?\"grabbing\":\"grab\":\"default\"},onError:tt?V:void 0,onLoad:tt?ee:void 0,onMouseDown:_e,draggable:!1},_),a.jsxs(\"div\",{className:\"absolute bottom-4 left-4 flex items-center gap-1.5 bg-black/60 rounded-lg px-2 py-1.5\",children:[a.jsx(\"button\",{onClick:R,disabled:L<=1,className:\"p-1.5 hover:bg-white/10 rounded disabled:opacity-30\",title:t(\"camera.zoomOut\"),children:a.jsx(zO,{className:\"w-4 h-4 text-white\"})}),a.jsxs(\"button\",{onClick:Re,className:\"px-2 py-1 text-sm text-white hover:bg-white/10 rounded min-w-[48px]\",title:t(\"camera.resetZoom\"),children:[Math.round(L*100),\"%\"]}),a.jsx(\"button\",{onClick:B,disabled:L>=4,className:\"p-1.5 hover:bg-white/10 rounded disabled:opacity-30\",title:t(\"camera.zoomIn\"),children:a.jsx(IO,{className:\"w-4 h-4 text-white\"})})]})]})}),a.jsx(yI,{printerId:l,isOpen:p,onClose:()=>f(!1)})]}):a.jsx(\"div\",{className:\"min-h-screen bg-black flex items-center justify-center\",children:a.jsx(\"p\",{className:\"text-white\",children:t(\"camera.invalidPrinterId\")})})}function vrt(t,e,n){if(!t)return\"\";if(!e)return t;const r=e.match(/plate_(\\d+)\\.gcode/);return r&&parseInt(r[1],10)>1?`${t} — ${n(\"printers.plateNumber\",\"Plate {{number}}\",{number:r[1]})}`:t}function wrt(t){const e=t.get(\"show\")?.split(\",\")||[\"progress\",\"layers\",\"eta\",\"filename\",\"status\"],n=parseInt(t.get(\"fps\")||\"15\",10),r=Math.min(Math.max(isNaN(n)?15:n,1),30),i=t.get(\"camera\"),s=i!==\"false\"&&i!==\"0\";return{size:t.get(\"size\")||\"medium\",fps:r,showCamera:s,showProgress:e.includes(\"progress\"),showLayers:e.includes(\"layers\"),showEta:e.includes(\"eta\"),showFilename:e.includes(\"filename\"),showStatus:e.includes(\"status\"),showPrinter:e.includes(\"printer\")}}function Srt(t,e){if(t.stg_cur_name)return t.stg_cur_name;switch(t.state){case\"RUNNING\":return e(\"streamOverlay.status.printing\");case\"PAUSE\":return e(\"streamOverlay.status.paused\");case\"FINISH\":return e(\"streamOverlay.status.finished\");case\"FAILED\":return e(\"streamOverlay.status.failed\");case\"IDLE\":return e(\"streamOverlay.status.idle\");default:return t.state||e(\"streamOverlay.status.unknown\")}}function _rt(t){switch(t){case\"small\":return{container:\"p-3\",text:\"text-sm\",textLarge:\"text-lg\",progressHeight:\"h-2\",icon:\"w-3 h-3\",gap:\"gap-2\",logoHeight:\"h-12\"};case\"large\":return{container:\"p-6\",text:\"text-xl\",textLarge:\"text-3xl\",progressHeight:\"h-4\",icon:\"w-6 h-6\",gap:\"gap-4\",logoHeight:\"h-24\"};default:return{container:\"p-4\",text:\"text-base\",textLarge:\"text-xl\",progressHeight:\"h-3\",icon:\"w-4 h-4\",gap:\"gap-3\",logoHeight:\"h-16\"}}}function krt(){const{printerId:t}=Yw(),[e]=BP(),{t:n}=Ft(),r=nn(),i=parseInt(t||\"0\",10),[s,o]=w.useState(Date.now()),l=w.useMemo(()=>wrt(e),[e]),c=_rt(l.size),{data:d}=Xe({queryKey:[\"printer\",i],queryFn:()=>ue.getPrinter(i),enabled:i>0}),{data:u}=Xe({queryKey:[\"printerStatus\",i],queryFn:()=>ue.getPrinterStatus(i),enabled:i>0,refetchInterval:2e3}),{data:m}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),p=m?.time_format||\"system\";w.useEffect(()=>{if(!i)return;const _=`${window.location.protocol===\"https:\"?\"wss:\":\"ws:\"}//${window.location.host}/api/v1/ws`,C=new WebSocket(_);return C.onmessage=P=>{try{const N=JSON.parse(P.data);N.type===\"printer_status\"&&N.printer_id===i&&r.setQueryData([\"printerStatus\",i],N.status)}catch{}},C.onerror=()=>{},()=>{C.close()}},[i,r]),w.useEffect(()=>(document.title=d?`${d.name} - ${n(\"streamOverlay.title\")}`:n(\"streamOverlay.title\"),()=>{document.title=\"Bambuddy\"}),[d,n]);const f=()=>{setTimeout(()=>{o(Date.now())},3e3)};if(!i)return a.jsx(\"div\",{className:\"min-h-screen bg-black flex items-center justify-center\",children:a.jsx(\"p\",{className:\"text-white\",children:n(\"streamOverlay.invalidPrinterId\")})});if(!u)return a.jsx(\"div\",{className:\"min-h-screen bg-black flex items-center justify-center\",children:a.jsx(\"p\",{className:\"text-gray-400\",children:n(\"common.loading\")})});const y=u.state===\"RUNNING\"||u.state===\"PAUSE\",v=u.progress||0,b=Ga(`/api/v1/printers/${i}/camera/stream?fps=${l.fps}&t=${s}`);return a.jsxs(\"div\",{className:\"min-h-screen bg-black relative overflow-hidden\",children:[l.showCamera&&a.jsx(\"img\",{src:b,alt:n(\"streamOverlay.cameraStream\"),className:\"absolute inset-0 w-full h-full object-contain\",style:d?.camera_rotation?{transform:`rotate(${d.camera_rotation}deg)`}:void 0,onError:f},s),a.jsx(\"a\",{href:\"https://github.com/maziggy/bambuddy\",target:\"_blank\",rel:\"noopener noreferrer\",className:\"absolute top-4 right-4 z-10\",children:a.jsx(\"img\",{src:\"/img/bambuddy_logo_dark_transparent.png\",alt:\"Bambuddy\",className:`${c.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`})}),a.jsx(\"div\",{className:\"absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent\",children:a.jsxs(\"div\",{className:`${c.container}`,children:[l.showPrinter&&d&&a.jsxs(\"div\",{className:`flex items-center ${c.gap} mb-2`,children:[a.jsx(Er,{className:`${c.icon} text-white/70`}),a.jsx(\"span\",{className:`${c.text} text-white font-medium`,children:d.name})]}),l.showFilename&&u.current_print&&a.jsx(\"div\",{className:`${c.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`,children:vrt(u.current_print.replace(/\\.gcode\\.3mf$|\\.3mf$|\\.gcode$/i,\"\"),u.gcode_file,n)}),l.showStatus&&a.jsx(\"div\",{className:`${c.text} text-white/70 mb-2`,children:Srt(u,n)}),l.showProgress&&y&&a.jsxs(\"div\",{className:\"mb-3\",children:[a.jsxs(\"div\",{className:`flex items-center justify-between mb-1 ${c.text}`,children:[a.jsx(\"span\",{className:\"text-white/70\",children:n(\"streamOverlay.progress\")}),a.jsxs(\"span\",{className:\"text-white font-bold\",children:[Math.round(v),\"%\"]})]}),a.jsx(\"div\",{className:`w-full bg-white/20 rounded-full ${c.progressHeight}`,children:a.jsx(\"div\",{className:`bg-bambu-green ${c.progressHeight} rounded-full transition-all duration-500`,style:{width:`${v}%`}})})]}),y&&(l.showLayers||l.showEta)&&a.jsxs(\"div\",{className:`flex items-center ${c.gap} flex-wrap`,children:[l.showLayers&&u.layer_num!=null&&u.total_layers!=null&&u.total_layers>0&&a.jsxs(\"div\",{className:`flex items-center ${c.gap} text-white/70`,children:[a.jsx(da,{className:c.icon}),a.jsxs(\"span\",{className:c.text,children:[a.jsx(\"span\",{className:\"text-white\",children:u.layer_num}),a.jsx(\"span\",{className:\"mx-1\",children:\"/\"}),a.jsx(\"span\",{children:u.total_layers})]})]}),l.showEta&&u.remaining_time!=null&&u.remaining_time>0&&a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:`flex items-center ${c.gap} text-white/70`,children:[a.jsx(jd,{className:c.icon}),a.jsx(\"span\",{className:`${c.text} text-white`,children:ws(u.remaining_time*60)})]}),a.jsxs(\"div\",{className:`flex items-center ${c.gap} text-white/70`,children:[a.jsx(Yn,{className:c.icon}),a.jsxs(\"span\",{className:`${c.text} text-white`,children:[n(\"streamOverlay.eta\"),\" \",qO(u.remaining_time,p,n)]})]})]})]}),!y&&a.jsx(\"div\",{className:`${c.text} text-white/70 py-2`,children:u.connected?n(\"streamOverlay.printerIdle\"):n(\"streamOverlay.printerOffline\")})]})})]})}function Nrt(){const{t}=Ft(),{id:e}=Yw(),{mode:n}=zg(),{data:r,isLoading:i,error:s}=Xe({queryKey:[\"external-link\",e],queryFn:()=>ue.getExternalLink(Number(e)),enabled:!!e});return i?a.jsx(\"div\",{className:\"flex items-center justify-center h-full\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):s||!r?a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center h-full gap-4 text-bambu-gray\",children:[a.jsx(Dn,{className:\"w-12 h-12\"}),a.jsx(\"p\",{children:t(\"common.linkNotFound\")})]}):a.jsx(\"iframe\",{src:r.url,className:\"h-full w-full border-0\",style:{colorScheme:n},title:r.name,sandbox:\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"})}function NY(){const{id:t}=Yw(),e=qo(),n=nn(),{t:r}=Ft(),{showToast:i}=hn(),s=!!t,[o,l]=w.useState(\"\"),[c,d]=w.useState(\"\"),[u,m]=w.useState([]),[p,f]=w.useState(\"\"),[y,v]=w.useState(!1),{data:b,isLoading:g}=Xe({queryKey:[\"group\",t],queryFn:()=>ue.getGroup(Number(t)),enabled:s}),{data:_,isLoading:C}=Xe({queryKey:[\"permissions\"],queryFn:()=>ue.getPermissions()});s&&b&&!y&&(l(b.name),d(b.description||\"\"),m(b.permissions),v(!0));const P=it({mutationFn:J=>ue.createGroup(J),onSuccess:()=>{n.invalidateQueries({queryKey:[\"groups\"]}),i(r(\"groups.toast.created\")),e(\"/settings?tab=users\")},onError:J=>{i(J.message,\"error\")}}),N=it({mutationFn:J=>ue.updateGroup(Number(t),J),onSuccess:()=>{n.invalidateQueries({queryKey:[\"groups\"]}),i(r(\"groups.toast.updated\")),e(\"/settings?tab=users\")},onError:J=>{i(J.message,\"error\")}}),A=P.isPending||N.isPending,T=()=>{if(!o.trim()){i(r(\"groups.toast.enterGroupName\"),\"error\");return}s?N.mutate({name:o!==b?.name?o:void 0,description:c,permissions:u}):P.mutate({name:o,description:c||void 0,permissions:u})},F=J=>{m(oe=>oe.includes(J)?oe.filter(fe=>fe!==J):[...oe,J])},k=(J,oe)=>{const fe=J.permissions.map(re=>re.value);m(re=>{const W=re.filter(ne=>!fe.includes(ne));return oe?[...W,...fe]:W})},D=J=>J.permissions.every(oe=>u.includes(oe.value)),H=J=>{const oe=J.permissions.filter(fe=>u.includes(fe.value)).length;return oe>0&&oe<J.permissions.length},z=()=>{_&&m(_.all_permissions)},Q=()=>{m([])},L=p.toLowerCase(),te=w.useMemo(()=>_?L?_.categories.map(J=>({...J,permissions:J.permissions.filter(oe=>oe.label.toLowerCase().includes(L))})).filter(J=>J.permissions.length>0):_.categories:[],[_,L]),ie=_?.all_permissions.length??0;return g||C?a.jsx(\"div\",{className:\"flex items-center justify-center py-16\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):a.jsxs(\"div\",{className:\"space-y-6 max-w-5xl mx-auto\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"button\",{onClick:()=>e(\"/settings?tab=users\"),className:\"p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors\",children:a.jsx(DQ,{className:\"w-5 h-5\"})}),a.jsx(\"h1\",{className:\"text-xl font-bold text-white\",children:r(s?\"groups.editor.title\":\"groups.editor.createTitle\")})]}),s&&b?.is_system&&a.jsxs(\"div\",{className:\"flex items-center gap-3 px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-sm\",children:[a.jsx(Dn,{className:\"w-4 h-4 shrink-0\"}),r(\"groups.form.systemGroupWarning\")]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:r(\"groups.form.groupName\")}),a.jsx(\"input\",{type:\"text\",value:o,onChange:J=>l(J.target.value),disabled:s&&b?.is_system,className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50\",placeholder:r(\"groups.form.groupNamePlaceholder\")})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-white mb-2\",children:r(\"groups.form.description\")}),a.jsx(\"input\",{type:\"text\",value:c,onChange:J=>d(J.target.value),className:\"w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:r(\"groups.form.descriptionPlaceholder\")})]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between flex-wrap gap-3\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[r(\"groups.editor.permissionsSelected\",{count:u.length}),\" / \",ie]}),a.jsx(De,{size:\"sm\",variant:\"ghost\",onClick:z,children:r(\"groups.editor.selectAll\")}),a.jsx(De,{size:\"sm\",variant:\"ghost\",onClick:Q,children:r(\"groups.editor.clearAll\")})]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",value:p,onChange:J=>f(J.target.value),placeholder:r(\"groups.editor.search\"),className:\"pl-9 pr-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors w-64\"})]})]}),te.length===0?a.jsx(\"div\",{className:\"text-center py-12 text-bambu-gray\",children:r(\"groups.editor.noResults\")}):a.jsx(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-4\",children:te.map(J=>{const oe=_.categories.find(me=>me.name===J.name),fe=oe.permissions.filter(me=>u.includes(me.value)).length,re=oe.permissions.length,W=D(oe),ne=H(oe);return a.jsxs(Tt,{children:[a.jsxs(\"div\",{className:\"sticky top-0 z-10 flex items-center justify-between px-4 py-3 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary rounded-t-xl\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>k(oe,!W),className:`w-5 h-5 rounded border flex items-center justify-center transition-colors shrink-0 ${W?\"bg-bambu-green border-bambu-green\":ne?\"bg-bambu-green/50 border-bambu-green\":\"border-bambu-gray hover:border-white\"}`,children:[W&&a.jsx(Ur,{className:\"w-3 h-3 text-white\"}),ne&&!W&&a.jsx(_N,{className:\"w-3 h-3 text-white\"})]}),a.jsx(Gu,{className:\"w-4 h-4 text-bambu-gray shrink-0\"}),a.jsx(\"span\",{className:\"text-white font-medium text-sm\",children:J.name})]}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray tabular-nums\",children:[fe,\"/\",re]})]}),a.jsx(\"div\",{className:\"p-3 space-y-1\",children:J.permissions.map(me=>a.jsxs(\"label\",{className:\"flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:u.includes(me.value),onChange:()=>F(me.value),className:\"w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary\"}),a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:me.label})]},me.value))})]},J.name)})}),a.jsx(\"div\",{className:\"h-16\"}),a.jsxs(\"div\",{className:\"fixed bottom-0 left-0 right-0 z-20 px-6 py-3 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-center justify-center gap-3\",children:[a.jsx(De,{variant:\"secondary\",onClick:()=>e(\"/settings?tab=users\"),children:r(\"common.cancel\")}),a.jsx(De,{onClick:T,disabled:A||!o.trim(),children:A?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),r(\"common.saving\")]}):a.jsxs(a.Fragment,{children:[a.jsx(_s,{className:\"w-4 h-4\"}),r(\"common.save\")]})})]})]})}const dO={material:\"\",subtype:\"\",brand:\"\",color_name:\"\",rgba:\"808080FF\",label_weight:1e3,core_weight:250,core_weight_catalog_id:null,weight_used:0,slicer_filament:\"\",note:\"\",cost_per_kg:null};function Vle(t,e=!1){const n={};return e?(t.material||(n.material=\"Material is required\"),{isValid:Object.keys(n).length===0,errors:n}):(t.slicer_filament||(n.slicer_filament=\"Slicer preset is required\"),t.material||(n.material=\"Material is required\"),t.brand||(n.brand=\"Brand is required\"),t.subtype||(n.subtype=\"Subtype is required\"),{isValid:Object.keys(n).length===0,errors:n})}const Gle=[\"PLA\",\"PETG\",\"ABS\",\"TPU\",\"ASA\",\"PC\",\"PA\",\"PVA\",\"HIPS\",\"PA-CF\",\"PETG-CF\",\"PLA-CF\"],Crt=[\"Bambu\",\"PolyLite\",\"PolyTerra\",\"eSUN\",\"Overture\",\"Fiberon\",\"SUNLU\",\"Inland\",\"Hatchbox\",\"Generic\"],J0=[\"Basic\",\"Matte\",\"Silk\",\"Silk+\",\"Tough\",\"Tough+\",\"HF\",\"High Flow\",\"Engineering\",\"Galaxy\",\"Glow\",\"Marble\",\"Metal\",\"Rainbow\",\"Sparkle\",\"Wood\",\"Translucent\",\"Transparent\",\"Clear\",\"Lite\",\"Pro\",\"Plus\",\"Max\",\"Super\",\"Ultra\",\"Flex\",\"Soft\",\"Hard\",\"Strong\",\"Impact\",\"Heat Resistant\",\"UV Resistant\",\"ESD\",\"Conductive\",\"Magnetic\",\"Gradient\",\"Dual Color\",\"Tri Color\",\"Multicolor\"],Wle=[{name:\"Black\",hex:\"000000\"},{name:\"White\",hex:\"FFFFFF\"},{name:\"Gray\",hex:\"808080\"},{name:\"Red\",hex:\"FF0000\"},{name:\"Orange\",hex:\"FFA500\"},{name:\"Yellow\",hex:\"FFFF00\"},{name:\"Green\",hex:\"00AE42\"},{name:\"Blue\",hex:\"0066FF\"},{name:\"Purple\",hex:\"8B00FF\"},{name:\"Pink\",hex:\"FF69B4\"},{name:\"Brown\",hex:\"8B4513\"},{name:\"Silver\",hex:\"C0C0C0\"}],Prt=[{name:\"Dark Red\",hex:\"8B0000\"},{name:\"Crimson\",hex:\"DC143C\"},{name:\"Coral\",hex:\"FF7F50\"},{name:\"Salmon\",hex:\"FA8072\"},{name:\"Dark Orange\",hex:\"FF8C00\"},{name:\"Peach\",hex:\"FFDAB9\"},{name:\"Gold\",hex:\"FFD700\"},{name:\"Khaki\",hex:\"F0E68C\"},{name:\"Lemon\",hex:\"FFF44F\"},{name:\"Lime\",hex:\"32CD32\"},{name:\"Forest Green\",hex:\"228B22\"},{name:\"Olive\",hex:\"808000\"},{name:\"Mint\",hex:\"98FF98\"},{name:\"Teal\",hex:\"008080\"},{name:\"Navy\",hex:\"000080\"},{name:\"Sky Blue\",hex:\"87CEEB\"},{name:\"Royal Blue\",hex:\"4169E1\"},{name:\"Cyan\",hex:\"00FFFF\"},{name:\"Turquoise\",hex:\"40E0D0\"},{name:\"Violet\",hex:\"EE82EE\"},{name:\"Magenta\",hex:\"FF00FF\"},{name:\"Indigo\",hex:\"4B0082\"},{name:\"Lavender\",hex:\"E6E6FA\"},{name:\"Plum\",hex:\"DDA0DD\"},{name:\"Hot Pink\",hex:\"FF69B4\"},{name:\"Rose\",hex:\"FF007F\"},{name:\"Blush\",hex:\"FFB6C1\"},{name:\"Chocolate\",hex:\"D2691E\"},{name:\"Tan\",hex:\"D2B48C\"},{name:\"Beige\",hex:\"F5F5DC\"},{name:\"Maroon\",hex:\"800000\"},{name:\"Dark Gray\",hex:\"404040\"},{name:\"Light Gray\",hex:\"D3D3D3\"},{name:\"Charcoal\",hex:\"36454F\"},{name:\"Ivory\",hex:\"FFFFF0\"},{name:\"Bambu Green\",hex:\"00AE42\"},{name:\"Jade White\",hex:\"E8E8E8\"},{name:\"Titan Gray\",hex:\"5A5A5A\"}],CY=[...Wle,...Prt],Kle=\"bambuddy-recent-colors\",Trt=8,Art=[{code:\"GFL00\",name:\"Bambu PLA Basic\",displayName:\"Bambu PLA Basic\",isCustom:!1,allCodes:[\"GFL00\"]},{code:\"GFL01\",name:\"Bambu PLA Matte\",displayName:\"Bambu PLA Matte\",isCustom:!1,allCodes:[\"GFL01\"]},{code:\"GFL05\",name:\"Generic PLA\",displayName:\"Generic PLA\",isCustom:!1,allCodes:[\"GFL05\"]},{code:\"GFG00\",name:\"Bambu PETG Basic\",displayName:\"Bambu PETG Basic\",isCustom:!1,allCodes:[\"GFG00\"]},{code:\"GFG05\",name:\"Generic PETG\",displayName:\"Generic PETG\",isCustom:!1,allCodes:[\"GFG05\"]},{code:\"GFB00\",name:\"Bambu ABS Basic\",displayName:\"Bambu ABS Basic\",isCustom:!1,allCodes:[\"GFB00\"]},{code:\"GFB05\",name:\"Generic ABS\",displayName:\"Generic ABS\",isCustom:!1,allCodes:[\"GFB05\"]},{code:\"GFA00\",name:\"Bambu ASA Basic\",displayName:\"Bambu ASA Basic\",isCustom:!1,allCodes:[\"GFA00\"]},{code:\"GFU00\",name:\"Bambu TPU 95A\",displayName:\"Bambu TPU 95A\",isCustom:!1,allCodes:[\"GFU00\"]},{code:\"GFU05\",name:\"Generic TPU\",displayName:\"Generic TPU\",isCustom:!1,allCodes:[\"GFU05\"]},{code:\"GFC00\",name:\"Bambu PC Basic\",displayName:\"Bambu PC Basic\",isCustom:!1,allCodes:[\"GFC00\"]},{code:\"GFN00\",name:\"Bambu PA Basic\",displayName:\"Bambu PA Basic\",isCustom:!1,allCodes:[\"GFN00\"]},{code:\"GFN05\",name:\"Generic PA\",displayName:\"Generic PA\",isCustom:!1,allCodes:[\"GFN05\"]},{code:\"GFS00\",name:\"Bambu PLA-CF\",displayName:\"Bambu PLA-CF\",isCustom:!1,allCodes:[\"GFS00\"]},{code:\"GFT00\",name:\"Bambu PETG-CF\",displayName:\"Bambu PETG-CF\",isCustom:!1,allCodes:[\"GFT00\"]},{code:\"GFNC0\",name:\"Bambu PA-CF\",displayName:\"Bambu PA-CF\",isCustom:!1,allCodes:[\"GFNC0\"]},{code:\"GFV00\",name:\"Bambu PVA\",displayName:\"Bambu PVA\",isCustom:!1,allCodes:[\"GFV00\"]}];function Cp(t){let e=t.replace(/@.*$/,\"\").trim();e=e.replace(/\\(Custom\\)/i,\"\").trim(),e=e.replace(/^[#*]+\\s*/,\"\").trim();const n=[\"PLA-CF\",\"PETG-CF\",\"ABS-GF\",\"ASA-CF\",\"PA-CF\",\"PAHT-CF\",\"PA6-CF\",\"PA6-GF\",\"PPA-CF\",\"PPA-GF\",\"PET-CF\",\"PPS-CF\",\"PC-CF\",\"PC-ABS\",\"ABS-GF\",\"PCTG\",\"PETG\",\"PLA\",\"ABS\",\"ASA\",\"PC\",\"PA\",\"TPU\",\"PVA\",\"HIPS\",\"BVOH\",\"PPS\",\"PEEK\",\"PEI\"];let r=\"\",i=-1;for(const c of n){const d=e.toUpperCase().indexOf(c.toUpperCase());if(d!==-1){r=c,i=d;break}}let s=\"\";i>0&&(s=e.substring(0,i).trim(),s=s.replace(/[-_\\s]+$/,\"\"));let o=\"\";i!==-1&&r&&(o=e.substring(i+r.length).trim(),o=o.replace(/^[-_\\s]+/,\"\"));let l=\"\";for(const c of J0)if(o.toLowerCase().includes(c.toLowerCase())){l=c;break}if(!l&&s)for(const c of J0){const d=new RegExp(`\\\\s+${c}$`,\"i\");if(d.test(s)){l=c,s=s.replace(d,\"\").trim();break}}return{brand:s,material:r,variant:l}}function Xle(t,e){const n=new Set(Crt);for(const r of t){const{brand:i}=Cp(r.name);i&&i.length>1&&n.add(i)}if(e)for(const r of e)if(r.filament_vendor&&r.filament_vendor.length>1)n.add(r.filament_vendor);else{const{brand:i}=Cp(r.name);i&&i.length>1&&n.add(i)}return Array.from(n).sort((r,i)=>r.localeCompare(i))}function jrt(t){const e=t.filter(r=>r.preset_type===\"filament\");if(e.length===0)return[];const n=new Map;for(const r of e){const i=r.name.replace(/@.*$/,\"\").trim(),s=n.get(i);if(s)s.allCodes.push(String(r.id));else{const o=r.filament_type||String(r.id);n.set(i,{code:o,name:i,displayName:i,isCustom:!1,allCodes:[o]})}}return Array.from(n.values()).sort((r,i)=>r.displayName.localeCompare(i.displayName))}function Yle(t,e,n){if(t.length>0){const r=[],i=new Map;for(const s of t)if(s.is_custom){const o=s.name.toUpperCase();(e.size===0||Array.from(e).some(c=>o.includes(c))||!o.includes(\"@\"))&&r.push({code:s.setting_id,name:s.name,displayName:`${s.name} (Custom)`,isCustom:!0,allCodes:[s.setting_id]})}else{const o=s.name.replace(/@.*$/,\"\").trim(),l=i.get(o);l?l.allCodes.push(s.setting_id):i.set(o,{code:s.setting_id,name:o,displayName:o,isCustom:!1,allCodes:[s.setting_id]})}return[...r,...Array.from(i.values())].sort((s,o)=>s.displayName.localeCompare(o.displayName))}if(n&&n.length>0){const r=jrt(n);if(r.length>0)return r}return Art}function Qle(t,e){if(!t)return;let n=e.find(r=>r.code===t);if(n||(n=e.find(r=>r.allCodes.includes(t))),!n){const r=t.toLowerCase();n=e.find(i=>i.code.toLowerCase()===r||i.allCodes.some(s=>s.toLowerCase()===r))}return n}function Zle(){try{const t=localStorage.getItem(Kle);if(t)return JSON.parse(t)}catch{}return[]}function Jle(t,e){const n=e.filter(i=>i.hex.toUpperCase()!==t.hex.toUpperCase()),r=[t,...n].slice(0,Trt);try{localStorage.setItem(Kle,JSON.stringify(r))}catch{}return r}function D3(t,e){if(!e.material)return!1;const r=(t.name||\"\").replace(/^High Flow[_\\s]+/i,\"\").replace(/^Standard[_\\s]+/i,\"\").replace(/^HF[_\\s]+/i,\"\").replace(/^S[_\\s]+/i,\"\").trim(),i=Cp(r);return!(!(i.material.toUpperCase()===e.material.toUpperCase())||e.brand&&!(i.brand.toLowerCase().includes(e.brand.toLowerCase())||e.brand.toLowerCase().includes(i.brand.toLowerCase()))||e.subtype&&!(i.variant.toLowerCase().includes(e.subtype.toLowerCase())||e.subtype.toLowerCase().includes(i.variant.toLowerCase())||r.toLowerCase().includes(e.subtype.toLowerCase())))}function ece({formData:t,updateField:e,cloudAuthenticated:n,loadingCloudPresets:r,presetInputValue:i,setPresetInputValue:s,selectedPresetOption:o,filamentOptions:l,availableBrands:c,availableMaterials:d,quickAdd:u,quantity:m,onQuantityChange:p,errors:f}){const{t:y}=Ft(),[v,b]=w.useState(!1),[g,_]=w.useState(!1),[C,P]=w.useState(!1),[N,A]=w.useState(!1),[T,F]=w.useState(\"\"),[k,D]=w.useState(\"\"),[H,z]=w.useState(\"\"),[Q,L]=w.useState(String(t.label_weight)),[te,ie]=w.useState(!1),J=w.useRef(null),oe=w.useRef(null),fe=w.useRef(null),re=w.useRef(null);w.useEffect(()=>{const q=Y=>{J.current&&!J.current.contains(Y.target)&&b(!1),re.current&&!re.current.contains(Y.target)&&A(!1),oe.current&&!oe.current.contains(Y.target)&&_(!1),fe.current&&!fe.current.contains(Y.target)&&P(!1)};return document.addEventListener(\"mousedown\",q),()=>document.removeEventListener(\"mousedown\",q)},[]);const W=w.useMemo(()=>{if(!i)return l;const q=i.toLowerCase();return l.filter(Y=>Y.displayName.toLowerCase().includes(q)||Y.code.toLowerCase().includes(q))},[l,i]),ne=w.useMemo(()=>{if(!T)return c;const q=T.toLowerCase();return c.filter(E=>E.toLowerCase().includes(q)).sort((E,j)=>{const O=E.toLowerCase()===q,K=j.toLowerCase()===q;return O&&!K?-1:!O&&K?1:E.localeCompare(j)})},[c,T]),me=w.useMemo(()=>{if(!k)return J0;const q=k.toLowerCase();return J0.filter(Y=>Y.toLowerCase().includes(q))},[k]),be=w.useMemo(()=>{if(!H)return d;const q=H.toLowerCase();return d.filter(E=>E.toLowerCase().includes(q)).sort((E,j)=>{const O=E.toLowerCase()===q,K=j.toLowerCase()===q;return O&&!K?-1:!O&&K?1:E.localeCompare(j)})},[H,d]);w.useEffect(()=>{te||L(String(t.label_weight))},[t.label_weight,te]);const Ce=q=>{e(\"slicer_filament\",q.code),s(q.displayName),b(!1);const Y=Cp(q.name);Y.material&&e(\"material\",Y.material),Y.brand&&e(\"brand\",Y.brand),Y.variant&&e(\"subtype\",Y.variant)};return a.jsxs(\"div\",{className:\"space-y-4\",children:[!u&&a.jsx(\"div\",{className:\"flex items-center gap-2 text-xs text-bambu-gray\",children:r?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-3 h-3 animate-spin\"}),\" \",y(\"inventory.loadingPresets\")]}):n?a.jsxs(a.Fragment,{children:[a.jsx(vy,{className:\"w-3 h-3 text-bambu-green\"}),\" \",y(\"inventory.cloudConnected\")]}):a.jsxs(a.Fragment,{children:[a.jsx(xfe,{className:\"w-3 h-3\"}),\" \",y(\"inventory.cloudNotConnected\")]})}),!u&&a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:[y(\"inventory.slicerPreset\"),\" *\"]}),a.jsxs(\"div\",{className:\"relative\",ref:J,children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\"}),a.jsx(\"input\",{type:\"text\",className:\"w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\",placeholder:y(\"inventory.searchPresets\"),value:i,onChange:q=>{s(q.target.value),b(!0)},onFocus:()=>{b(!0),s(\"\")}}),v&&a.jsx(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto\",children:W.length===0?a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:y(\"inventory.noPresetsFound\")}):W.map(q=>a.jsx(\"button\",{type:\"button\",className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary truncate ${o?.code===q.code?\"bg-bambu-green/10 text-bambu-green\":\"text-white\"}`,onClick:()=>Ce(q),children:q.displayName},q.code))})]}),o&&a.jsxs(\"div\",{className:\"mt-1 text-xs text-bambu-gray\",children:[y(\"inventory.selectedPreset\"),\": \",a.jsx(\"span\",{className:\"font-mono text-bambu-green\",children:o.code})]}),f?.slicer_filament&&a.jsx(\"p\",{className:\"mt-1 text-xs text-red-400\",children:f.slicer_filament})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:[y(\"inventory.material\"),\" *\"]}),a.jsxs(\"div\",{className:\"relative\",ref:re,children:[a.jsx(\"input\",{type:\"text\",className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\",placeholder:y(\"inventory.selectMaterial\"),value:N?H:t.material,onChange:q=>{z(q.target.value),A(!0)},onFocus:()=>{A(!0),z(\"\")}}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\"}),N&&a.jsxs(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\",children:[be.length===0?a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:y(\"inventory.noResults\")}):be.map(q=>a.jsx(\"button\",{type:\"button\",className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${t.material===q?\"bg-bambu-green/10 text-bambu-green\":\"text-white\"}`,onClick:()=>{e(\"material\",q),A(!1),z(\"\")},children:q},q)),H&&!be.includes(H)&&a.jsx(\"button\",{type:\"button\",className:\"w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary\",onClick:()=>{e(\"material\",H),A(!1),z(\"\")},children:y(\"inventory.useCustomMaterial\",{material:H})})]})]}),f?.material&&a.jsx(\"p\",{className:\"mt-1 text-xs text-red-400\",children:f.material})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:[y(\"inventory.brand\"),!u&&\" *\"]}),a.jsxs(\"div\",{className:\"relative\",ref:oe,children:[a.jsx(\"input\",{type:\"text\",className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\",placeholder:y(\"inventory.searchBrand\"),value:g?T:t.brand,onChange:q=>{F(q.target.value),_(!0)},onFocus:()=>{_(!0),F(\"\")}}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\"}),g&&a.jsxs(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\",children:[ne.length===0?a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:y(\"inventory.noResults\")}):ne.map(q=>a.jsx(\"button\",{type:\"button\",className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${t.brand===q?\"bg-bambu-green/10 text-bambu-green\":\"text-white\"}`,onClick:()=>{e(\"brand\",q),_(!1),F(\"\")},children:q},q)),T&&!ne.includes(T)&&a.jsx(\"button\",{type:\"button\",className:\"w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary\",onClick:()=>{e(\"brand\",T),_(!1),F(\"\")},children:y(\"inventory.useCustomBrand\",{brand:T})})]})]}),f?.brand&&a.jsx(\"p\",{className:\"mt-1 text-xs text-red-400\",children:f.brand})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:[y(\"inventory.subtype\"),!u&&\" *\"]}),a.jsxs(\"div\",{className:\"relative\",ref:fe,children:[a.jsx(\"input\",{type:\"text\",value:C?k:t.subtype,onChange:q=>{D(q.target.value),P(!0)},onFocus:()=>{P(!0),D(\"\")},placeholder:\"Basic, Matte, Silk...\",className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"}),a.jsx(On,{className:\"absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\"}),C&&a.jsxs(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto\",children:[me.length===0?a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:y(\"inventory.noResults\")}):me.map(q=>a.jsx(\"button\",{type:\"button\",className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${t.subtype===q?\"bg-bambu-green/10 text-bambu-green\":\"text-white\"}`,onClick:()=>{e(\"subtype\",q),P(!1),D(\"\")},children:q},q)),k&&!J0.some(q=>q.toLowerCase()===k.toLowerCase().trim())&&a.jsx(\"button\",{type:\"button\",className:\"w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary\",onClick:()=>{e(\"subtype\",k),P(!1),D(\"\")},children:y(\"inventory.useCustomBrand\",{brand:k})})]})]}),f?.subtype&&a.jsx(\"p\",{className:\"mt-1 text-xs text-red-400\",children:f.subtype})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:y(\"inventory.labelWeight\")}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"input\",{type:\"number\",className:\"w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\",value:Q,min:0,onFocus:()=>ie(!0),onChange:q=>L(q.target.value),onBlur:()=>{ie(!1);const q=Q.trim(),Y=Number(q);if(!q||!Number.isFinite(Y)||Y<0){L(String(t.label_weight));return}const E=Math.round(Y);e(\"label_weight\",E),L(String(E))}}),a.jsx(\"span\",{className:\"absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray\",children:\"g\"})]})]}),u&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:y(\"inventory.quantity\")}),a.jsx(\"input\",{type:\"number\",className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\",value:m,min:1,max:100,onChange:q=>{const Y=Math.max(1,Math.min(100,parseInt(q.target.value)||1));p(Y)}})]})]})}function tce({formData:t,updateField:e,recentColors:n,onColorUsed:r,catalogColors:i}){const{t:s}=Ft(),[o,l]=w.useState(!1),[c,d]=w.useState(\"\"),u=t.rgba.replace(\"#\",\"\").substring(0,6),m=g=>u.toUpperCase()===g.toUpperCase(),p=(g,_)=>{e(\"rgba\",g.toUpperCase()+\"FF\"),e(\"color_name\",_),r({name:_,hex:g})},f=w.useMemo(()=>{if(i.length===0)return[];const g=t.brand?.trim(),_=t.material?.toLowerCase().trim(),C=t.subtype?.toLowerCase().trim();if(!g&&!_)return[];const P=g?g.toLowerCase().split(/[\\s\\-_]+/).filter(T=>T.length>=2):[],N=T=>{if(P.length===0)return!0;const F=T.toLowerCase();return P.some(k=>F.includes(k))};if(g&&!_){const T=i.filter(F=>N(F.manufacturer));if(T.length>0)return T.map(F=>({name:F.color_name,hex:F.hex_color.replace(\"#\",\"\").substring(0,6),manufacturer:F.manufacturer,material:typeof F.material==\"string\"?F.material:void 0}))}const A=_&&C?`${_} ${C}`:\"\";if(A){const T=i.filter(k=>N(k.manufacturer)&&k.material?.toLowerCase()===A);if(T.length>0)return T.map(k=>({name:k.color_name,hex:k.hex_color.replace(\"#\",\"\").substring(0,6),manufacturer:k.manufacturer,material:typeof k.material==\"string\"?k.material:void 0}));const F=A.replace(/\\+$/,\"\");if(F!==A){const k=i.filter(D=>N(D.manufacturer)&&D.material?.toLowerCase()===F);if(k.length>0)return k.map(D=>({name:D.color_name,hex:D.hex_color.replace(\"#\",\"\").substring(0,6),manufacturer:D.manufacturer,material:typeof D.material==\"string\"?D.material:void 0}))}}if(_){const T=i.filter(F=>N(F.manufacturer)&&(!F.material||F.material.toLowerCase().startsWith(_)));if(T.length>0)return T.map(F=>({name:F.color_name,hex:F.hex_color.replace(\"#\",\"\").substring(0,6),manufacturer:F.manufacturer,material:typeof F.material==\"string\"?F.material:void 0}))}return[]},[i,t.brand,t.material,t.subtype]),y=w.useMemo(()=>{if(!c)return f;if(f.length===0)return[];const g=c.toLowerCase();return f.filter(C=>C.name.toLowerCase().includes(g)||(C.manufacturer?.toLowerCase().includes(g)??!1)||(C.material?.toLowerCase().includes(g)??!1))},[c,f]),v=f.length>0,b=w.useMemo(()=>c?CY.filter(g=>g.name.toLowerCase().includes(c.toLowerCase())):o?CY:Wle,[c,o]);return a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsx(\"div\",{className:\"h-10 rounded-lg border border-bambu-dark-tertiary\",style:{backgroundColor:`#${u}`}}),n.length>0&&a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1.5 text-xs text-bambu-gray shrink-0\",children:[a.jsx(Yn,{className:\"w-3 h-3\"}),a.jsx(\"span\",{children:s(\"inventory.recentColors\")})]}),a.jsx(\"div\",{className:\"flex flex-wrap gap-1.5\",children:n.map(g=>a.jsx(\"button\",{type:\"button\",onClick:()=>p(g.hex,g.name),className:`w-6 h-6 rounded border-2 transition-all hover:scale-110 ${m(g.hex)?\"border-bambu-green ring-1 ring-bambu-green/30 scale-110\":\"border-bambu-dark-tertiary\"}`,style:{backgroundColor:`#${g.hex}`},title:g.name},g.hex))})]}),a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none\"}),a.jsx(\"input\",{type:\"text\",className:\"w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\",placeholder:s(\"inventory.searchColors\"),value:c,onChange:g=>d(g.target.value)})]}),v?a.jsxs(\"div\",{className:\"space-y-1.5\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:c?s(\"inventory.searchResults\"):`${t.brand}${t.material?` ${t.material}`:\"\"}`}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-1.5\",children:[y.map(g=>a.jsx(\"button\",{type:\"button\",onClick:()=>p(g.hex,g.name),className:`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${m(g.hex)?\"border-bambu-green ring-1 ring-bambu-green/30 scale-110\":\"border-bambu-dark-tertiary\"}`,style:{backgroundColor:`#${g.hex}`},title:g.manufacturer&&g.material?`${g.name} (${g.manufacturer} — ${g.material})`:g.manufacturer?`${g.name} (${g.manufacturer})`:g.name,children:a.jsx(\"span\",{className:\"absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white\",children:g.manufacturer&&g.material?`${g.name} (${g.manufacturer} — ${g.material})`:g.manufacturer?`${g.name} (${g.manufacturer})`:g.name})},`${g.hex}-${g.name}-${g.manufacturer??\"\"}`)),y.length===0&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray py-1\",children:s(\"inventory.noColorsFound\")})]})]}):a.jsxs(\"div\",{className:\"space-y-1.5\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between text-xs text-bambu-gray\",children:[a.jsx(\"span\",{children:s(c?\"inventory.searchResults\":o?\"inventory.allColors\":\"inventory.commonColors\")}),!c&&a.jsx(\"button\",{type:\"button\",onClick:()=>l(!o),className:\"flex items-center gap-1 hover:text-white transition-colors\",children:o?a.jsxs(a.Fragment,{children:[s(\"inventory.showLess\"),\" \",a.jsx(cc,{className:\"w-3 h-3\"})]}):a.jsxs(a.Fragment,{children:[s(\"inventory.showAll\"),\" \",a.jsx(On,{className:\"w-3 h-3\"})]})})]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-1.5\",children:[b.map(g=>a.jsx(\"button\",{type:\"button\",onClick:()=>p(g.hex,g.name),className:`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${m(g.hex)?\"border-bambu-green ring-1 ring-bambu-green/30 scale-110\":\"border-bambu-dark-tertiary\"}`,style:{backgroundColor:`#${g.hex}`},title:g.name,children:a.jsx(\"span\",{className:\"absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white\",children:g.name})},g.hex)),b.length===0&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray py-1\",children:s(\"inventory.noColorsFound\")})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-3\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:s(\"inventory.colorName\")}),a.jsx(\"input\",{type:\"text\",className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\",placeholder:s(\"inventory.colorNamePlaceholder\"),value:t.color_name,onChange:g=>e(\"color_name\",g.target.value)})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:s(\"inventory.hexColor\")}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(\"span\",{className:\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray\",children:\"#\"}),a.jsx(\"input\",{type:\"text\",className:\"w-full pl-7 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono uppercase focus:outline-none focus:border-bambu-green\",placeholder:\"RRGGBB\",value:u.toUpperCase(),onChange:g=>{const _=g.target.value.replace(\"#\",\"\").replace(/[^0-9A-Fa-f]/g,\"\").toUpperCase();if(_.length>8)return;const C=_.length===8?_:_.length===7?_.substring(0,6)+\"FF\":_.padEnd(6,\"0\")+\"FF\";e(\"rgba\",C)}})]}),a.jsx(\"input\",{type:\"color\",className:\"w-11 h-[38px] rounded-lg cursor-pointer border border-bambu-dark-tertiary shrink-0 bg-transparent\",value:`#${u}`,onChange:g=>{const _=g.target.value.replace(\"#\",\"\").toUpperCase();e(\"rgba\",_+\"FF\")},title:s(\"inventory.pickColor\")})]})]})]})]})}function Mrt({catalog:t,value:e,onChange:n,catalogId:r,onCatalogIdChange:i}){const{t:s}=Ft(),[o,l]=w.useState(!1),[c,d]=w.useState(\"\"),u=w.useRef(null),m=w.useRef(null);w.useEffect(()=>{const v=b=>{u.current&&!u.current.contains(b.target)&&l(!1)};return document.addEventListener(\"mousedown\",v),()=>document.removeEventListener(\"mousedown\",v)},[]),w.useEffect(()=>{if(t.length===0)return;const v=t.filter(b=>b.weight===e);if(r){const b=t.find(g=>g.id===r);if(b&&b.weight===e)return}v.length===1?i(v[0].id):v.length===0&&r!==null&&i(null)},[e,t,r,i]);const p=w.useMemo(()=>{if(!c)return t;const v=c.toLowerCase();return t.filter(b=>b.name.toLowerCase().includes(v)||b.weight.toString().includes(v))},[t,c]),f=w.useMemo(()=>t.filter(v=>v.weight===e),[t,e]),y=w.useMemo(()=>{if(o)return c;if(r){const v=t.find(b=>b.id===r);if(v)return v.name}return f.length>0?f[0].name:\"\"},[o,c,r,t,f]);return a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:a.jsxs(\"span\",{className:\"flex items-center gap-2\",children:[a.jsx(BQ,{className:\"w-3.5 h-3.5 text-bambu-gray\"}),s(\"inventory.coreWeight\")]})}),a.jsxs(\"div\",{className:\"flex gap-2 items-center\",children:[a.jsxs(\"div\",{className:\"flex-1 min-w-0 relative\",ref:u,children:[a.jsx(\"input\",{ref:m,type:\"text\",className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\",placeholder:s(\"inventory.searchSpoolWeight\"),value:y,onFocus:()=>{l(!0),d(\"\")},onChange:v=>{d(v.target.value),l(!0)}}),o&&a.jsx(\"div\",{className:\"absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto\",children:p.length===0?a.jsx(\"div\",{className:\"px-3 py-2 text-sm text-bambu-gray\",children:s(\"inventory.noResults\")}):p.map(v=>a.jsxs(\"button\",{type:\"button\",className:`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${(r?v.id===r:v.weight===e)?\"bg-bambu-green/10 text-bambu-green\":\"text-white\"}`,onClick:()=>{i(v.id),n(v.weight),l(!1),d(\"\")},children:[a.jsx(\"span\",{className:\"truncate\",children:v.name}),a.jsxs(\"span\",{className:\"font-mono text-xs text-bambu-gray ml-2 shrink-0\",children:[v.weight,\"g\"]})]},v.id))})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 shrink-0\",children:[a.jsx(\"input\",{type:\"number\",className:\"w-16 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm text-center font-mono focus:outline-none focus:border-bambu-green\",value:e,min:0,max:2e3,onChange:v=>{const b=parseInt(v.target.value);!isNaN(b)&&b>=0&&n(b)}}),a.jsx(\"span\",{className:\"text-bambu-gray text-sm\",children:\"g\"})]})]})]})}function nce({formData:t,updateField:e,spoolCatalog:n,currencySymbol:r}){const{t:i}=Ft(),{showToast:s}=hn(),[o,l]=w.useState(\"\"),[c,d]=w.useState(!1),[u,m]=w.useState(\"\"),[p,f]=w.useState(!1),y=Math.max(0,t.label_weight-t.weight_used),v=t.core_weight+y;return w.useEffect(()=>{c||l(String(v))},[c,v]),w.useEffect(()=>{p||m(String(y))},[p,y]),a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(Mrt,{catalog:n,value:t.core_weight,onChange:b=>e(\"core_weight\",b),catalogId:t.core_weight_catalog_id,onCatalogIdChange:b=>e(\"core_weight_catalog_id\",b)}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:i(\"inventory.currentWeight\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(\"input\",{type:\"number\",value:u,min:0,max:t.label_weight,onFocus:()=>f(!0),onChange:b=>{m(b.target.value)},onBlur:()=>{f(!1);const b=u.trim(),g=Number(b);if(!b||!Number.isFinite(g)||g<0||g>t.label_weight){m(String(y));return}const _=Math.round(g);e(\"weight_used\",Math.max(0,t.label_weight-_)),m(String(_))},className:\"w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"}),a.jsx(\"span\",{className:\"absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray\",children:\"g\"})]}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray shrink-0\",children:[\"/ \",t.label_weight,\"g\"]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:i(\"inventory.measuredWeight\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(\"input\",{type:\"number\",value:o,min:0,onFocus:()=>d(!0),onChange:b=>{l(b.target.value)},onBlur:()=>{d(!1);const b=o.trim(),g=Number(b),_=t.core_weight,C=t.core_weight+t.label_weight;if(!b||!Number.isFinite(g)||g<_||g>C){s(i(\"inventory.measuredWeightError\",{min:_,max:C}),\"error\"),l(String(v));return}const P=Math.round(g),N=Math.max(0,Math.min(t.label_weight,P-t.core_weight));e(\"weight_used\",Math.max(0,t.label_weight-N)),l(String(P))},className:\"w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"}),a.jsx(\"span\",{className:\"absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray\",children:\"g\"})]}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray shrink-0\",children:[\"/ \",t.core_weight+t.label_weight,\"g\"]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:i(\"inventory.costPerKg\",\"Cost per kg\")}),a.jsx(\"div\",{className:\"flex items-center gap-2\",children:a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(\"span\",{className:\"absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none\",children:r}),a.jsx(\"input\",{type:\"number\",value:t.cost_per_kg??\"\",min:0,step:.01,placeholder:\"0.00\",onChange:b=>{const g=b.target.value===\"\"?null:parseFloat(b.target.value);e(\"cost_per_kg\",g)},style:{paddingLeft:`${Math.max(2,r.length*.6+1)}rem`},className:\"w-full py-2 pr-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green\"})]})})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-sm font-medium text-bambu-gray mb-1\",children:i(\"inventory.note\")}),a.jsx(\"textarea\",{className:\"w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]\",placeholder:i(\"inventory.notePlaceholder\"),value:t.note,onChange:b=>e(\"note\",b.target.value)})]})]})}function rce({formData:t,printersWithCalibrations:e,selectedProfiles:n,setSelectedProfiles:r,expandedPrinters:i,setExpandedPrinters:s}){const{t:o}=Ft(),l=p=>{s(f=>{const y=new Set(f);return y.has(p)?y.delete(p):y.add(p),y})},c=(p,f,y)=>{const v=`${p}:${f}:${y??\"null\"}`,b=`${p}:${y??\"null\"}`;r(g=>{const _=new Set(g);if(_.has(v))_.delete(v);else{for(const C of Array.from(_)){const P=C.split(\":\");`${P[0]}:${P[2]}`===b&&_.delete(C)}_.add(v)}return _})},d=()=>{const p=new Set;for(const{printer:f,calibrations:y}of e){if(!f.connected)continue;const v=y.filter(g=>D3(g,t)),b=new Map;for(const g of v){const _=`${g.extruder_id??\"null\"}`;b.has(_)||b.set(_,[]),b.get(_).push(g)}for(const[g,_]of b)if(_.length>0){const P=[..._].sort((N,A)=>A.k_value-N.k_value)[0];p.add(`${f.id}:${P.cali_idx}:${g}`)}}r(p)};if(!t.material)return a.jsx(\"div\",{className:\"p-6 bg-bambu-dark rounded-lg text-center\",children:a.jsx(\"p\",{className:\"text-bambu-gray\",children:o(\"inventory.selectMaterialFirst\")})});if(e.length===0)return a.jsx(\"div\",{className:\"p-6 bg-bambu-dark rounded-lg text-center\",children:a.jsx(\"p\",{className:\"text-bambu-gray\",children:o(\"inventory.noPrintersConfigured\")})});const u=e.reduce((p,{printer:f,calibrations:y})=>f.connected?p+y.filter(v=>D3(v,t)).length:p,0),m=(p,f)=>{const y=`${p.id}:${f.cali_idx}:${f.extruder_id??\"null\"}`,v=n.has(y);return a.jsxs(\"label\",{className:`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all border ${v?\"bg-bambu-green/10 border-bambu-green/30\":\"bg-bambu-dark border-transparent hover:bg-bambu-dark/80\"}`,children:[a.jsx(\"input\",{type:\"checkbox\",checked:v,onChange:()=>c(String(p.id),f.cali_idx,f.extruder_id),className:\"w-4 h-4 rounded border-bambu-dark-tertiary text-bambu-green focus:ring-bambu-green\"}),a.jsx(\"div\",{className:\"flex-1 min-w-0\",children:a.jsx(\"span\",{className:`text-sm font-medium ${v?\"text-bambu-green\":\"text-white\"}`,children:f.name||f.filament_id})}),a.jsx(\"div\",{className:\"flex items-center gap-2 shrink-0\",children:a.jsxs(\"span\",{className:\"text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray\",children:[\"K=\",f.k_value.toFixed(3)]})})]},`${f.cali_idx}-${f.extruder_id}`)};return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"p\",{className:\"text-xs text-bambu-gray\",children:[o(\"inventory.matchingFilter\"),\": \",t.brand||o(\"inventory.anyBrand\"),\" / \",t.material,\" / \",t.subtype||o(\"inventory.anyVariant\")]}),u>0&&a.jsxs(\"button\",{type:\"button\",onClick:d,className:\"flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors\",children:[a.jsx($x,{className:\"w-3.5 h-3.5\"}),o(\"inventory.autoSelect\"),\" (\",u,\")\"]})]}),a.jsx(\"div\",{className:\"space-y-3\",children:e.map(({printer:p,calibrations:f})=>{const y=i.has(String(p.id)),v=f.filter(P=>D3(P,t)),b=v.length,g=v.some(P=>P.extruder_id!==void 0&&P.extruder_id!==null&&P.extruder_id>0),_=v.filter(P=>P.extruder_id===1),C=v.filter(P=>P.extruder_id===0||P.extruder_id===void 0||P.extruder_id===null);return a.jsxs(\"div\",{className:\"border border-bambu-dark-tertiary rounded-lg overflow-hidden\",children:[a.jsxs(\"button\",{type:\"button\",onClick:()=>l(String(p.id)),className:\"w-full px-4 py-3 flex items-center justify-between bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary transition-colors\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[y?a.jsx(On,{className:\"w-4 h-4 text-bambu-gray\"}):a.jsx(ti,{className:\"w-4 h-4 text-bambu-gray\"}),a.jsx(\"span\",{className:\"font-medium text-white\",children:p.name}),b>0?a.jsxs(\"span\",{className:\"text-xs px-2 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green\",children:[b,\" \",o(b!==1?\"inventory.matches\":\"inventory.match\")]}):a.jsx(\"span\",{className:\"text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray\",children:o(\"inventory.noMatches\")})]}),a.jsx(\"span\",{className:`text-xs px-2 py-1 rounded-full ${p.connected?\"bg-green-500/20 text-green-500\":\"bg-bambu-gray/20 text-bambu-gray\"}`,children:p.connected?o(\"inventory.connected\"):o(\"inventory.offline\")})]}),y&&a.jsx(\"div\",{className:\"px-4 py-3 space-y-3 bg-bambu-dark border-t border-bambu-dark-tertiary\",children:p.connected?b===0?a.jsx(\"p\",{className:\"text-sm text-bambu-gray italic py-2\",children:o(\"inventory.noKProfilesMatch\")}):g?a.jsxs(a.Fragment,{children:[_.length>0&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs font-medium text-bambu-gray uppercase tracking-wide\",children:o(\"inventory.leftNozzle\")}),a.jsx(\"div\",{className:\"space-y-2\",children:_.map(P=>m(p,P))})]}),C.length>0&&a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs font-medium text-bambu-gray uppercase tracking-wide\",children:o(\"inventory.rightNozzle\")}),a.jsx(\"div\",{className:\"space-y-2\",children:C.map(P=>m(p,P))})]})]}):a.jsx(\"div\",{className:\"space-y-2\",children:v.map(P=>m(p,P))}):a.jsx(\"p\",{className:\"text-sm text-bambu-gray italic py-2\",children:o(\"inventory.printerOffline\")})})]},p.id)})}),n.size>0&&a.jsx(\"div\",{className:\"p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg\",children:a.jsxs(\"p\",{className:\"text-sm text-white\",children:[a.jsx(\"span\",{className:\"font-semibold\",children:n.size}),\" \",o(\"inventory.profilesSelected\")]})})]})}function Ert(t){const e=new Date(t);return e.toLocaleDateString(\"en-GB\",{day:\"2-digit\",month:\"2-digit\",year:\"2-digit\"})+\" \"+e.toLocaleTimeString(\"en-GB\",{hour:\"2-digit\",minute:\"2-digit\"})}const Drt={completed:\"text-bambu-green\",failed:\"text-red-400\",aborted:\"text-yellow-400\"};function Frt({spoolId:t}){const{t:e}=Ft(),n=nn(),{showToast:r}=hn(),{data:i,isLoading:s}=Xe({queryKey:[\"spool-usage\",t],queryFn:()=>ue.getSpoolUsageHistory(t)}),o=it({mutationFn:()=>ue.clearSpoolUsageHistory(t),onSuccess:()=>{n.invalidateQueries({queryKey:[\"spool-usage\",t]}),r(e(\"inventory.historyCleared\"),\"success\")}});return s?a.jsx(\"div\",{className:\"flex justify-center py-4\",children:a.jsx(ft,{className:\"w-5 h-5 animate-spin text-bambu-green\"})}):!i||i.length===0?a.jsxs(\"div\",{className:\"text-center py-4 text-bambu-gray text-sm\",children:[a.jsx(Yn,{className:\"w-5 h-5 mx-auto mb-2 opacity-50\"}),e(\"inventory.noUsageHistory\")]}):a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"h4\",{className:\"text-sm font-medium text-white\",children:e(\"inventory.usageHistory\")}),a.jsxs(De,{variant:\"ghost\",size:\"sm\",onClick:()=>o.mutate(),disabled:o.isPending,className:\"text-xs text-bambu-gray hover:text-red-400\",children:[a.jsx(en,{className:\"w-3 h-3 mr-1\"}),e(\"inventory.clearHistory\")]})]}),a.jsx(\"div\",{className:\"max-h-48 overflow-y-auto space-y-1\",children:i.map(l=>a.jsxs(\"div\",{className:\"flex items-center justify-between p-2 rounded bg-bambu-dark/50 text-xs\",children:[a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:Ert(l.created_at)}),l.print_name&&a.jsx(\"span\",{className:\"text-white ml-2 truncate\",title:l.print_name,children:l.print_name})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-shrink-0 ml-2\",children:[a.jsxs(\"span\",{className:\"text-white font-medium\",children:[l.weight_used.toFixed(1),\"g\"]}),a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"(\",l.percent_used,\"%)\"]}),a.jsx(\"span\",{className:Drt[l.status]||\"text-bambu-gray\",children:l.status})]})]},l.id))})]})}function Rrt({isOpen:t,onClose:e,spool:n,printersWithCalibrations:r=[],currencySymbol:i,onSpoolsCreated:s}){const{t:o}=Ft(),l=nn(),{showToast:c}=hn(),d=!!n,[u,m]=w.useState(dO),[p,f]=w.useState({}),[y,v]=w.useState(\"filament\"),[b,g]=w.useState(!1),[_,C]=w.useState(!1),[P,N]=w.useState(1),[A,T]=w.useState(!1),[F,k]=w.useState(!1),[D,H]=w.useState([]),[z,Q]=w.useState(\"\"),[L,te]=w.useState([]),[ie,J]=w.useState([]),[oe,fe]=w.useState([]),[re,W]=w.useState([]),[ne,me]=w.useState([]),[be,Ce]=w.useState(new Set),[q,Y]=w.useState(new Set),E=r.length>0?r:ne,j=w.useMemo(()=>be.size,[be]);w.useEffect(()=>{W(Zle())},[]),w.useEffect(()=>{t&&((async()=>{k(!0);try{const Le=await ue.getCloudStatus();if(T(Le.is_authenticated),Le.is_authenticated){const Me=await ue.getFilamentPresets();H(Me)}}catch(Le){console.error(\"Failed to fetch cloud presets:\",Le),T(!1)}finally{k(!1)}})(),ue.getSpoolCatalog().then(te).catch(console.error),ue.getColorCatalog().then(fe).catch(console.error),ue.getLocalPresets().then(Le=>J(Le.filament)).catch(console.error),r.length===0&&(async()=>{try{const Le=await ue.getPrinters(),Me=await Promise.all(Le.map(Re=>ue.getPrinterStatus(Re.id).catch(()=>null))),Oe=[];for(let Re=0;Re<Le.length;Re++){const $e=Le[Re],tt=Me[Re]?.connected??!1;let pe=[];if(tt)try{pe=(await ue.getKProfiles($e.id)).profiles.map(we=>({cali_idx:we.slot_id,filament_id:we.filament_id,setting_id:we.setting_id||\"\",name:we.name,k_value:parseFloat(we.k_value)||0,n_coef:parseFloat(we.n_coef)||0,extruder_id:we.extruder_id,nozzle_diameter:we.nozzle_diameter}))}catch{}Oe.push({printer:{...$e,connected:tt},calibrations:pe})}me(Oe)}catch(Le){console.error(\"Failed to fetch printer calibrations:\",Le)}})())},[t,r.length]);const O=w.useMemo(()=>Yle(D,new Set,ie),[D,ie]),K=w.useMemo(()=>{const je=Xle(D,ie),Le=oe.map(Oe=>Oe.manufacturer?.trim()).filter(Oe=>!!Oe),Me=new Set([...je,...Le]);return Array.from(Me).sort((Oe,Re)=>Oe.localeCompare(Re))},[D,ie,oe]),U=w.useMemo(()=>{const je=oe.map(Me=>Me.material?.trim()).filter(Me=>!!Me),Le=new Set([...Gle,...je]);return Array.from(Le).sort((Me,Oe)=>Me.localeCompare(Oe))},[oe]),de=w.useMemo(()=>{const je=[];for(const Le of oe){const Me=Le.manufacturer?.trim(),Oe=Le.material?.trim();Me&&Oe&&je.push({brand:Me,material:Oe})}for(const Le of D){const Me=Cp(Le.name);Me.brand&&Me.material&&je.push({brand:Me.brand,material:Me.material})}for(const Le of ie){const Me=Cp(Le.name),Oe=Le.filament_vendor?.trim()||Me.brand,Re=Me.material;Oe&&Re&&je.push({brand:Oe,material:Re})}return je},[D,oe,ie]),I=w.useMemo(()=>{const je=new Map;for(const Le of de){const Me=Le.brand.toLowerCase(),Oe=Le.material.toLowerCase();je.has(Me)||je.set(Me,new Set),je.get(Me).add(Oe)}return je},[de]),G=w.useMemo(()=>{const je=new Map;for(const Le of de){const Me=Le.brand.toLowerCase(),Oe=Le.material.toLowerCase();je.has(Oe)||je.set(Oe,new Set),je.get(Oe).add(Me)}return je},[de]),X=w.useMemo(()=>{if(!u.material)return K;const je=u.material.toLowerCase(),Le=G.get(je);return!Le||Le.size===0?K:K.filter(Me=>Le.has(Me.toLowerCase()))},[K,u.material,G]),V=w.useMemo(()=>{if(!u.brand)return U;const je=u.brand.toLowerCase(),Le=I.get(je);return!Le||Le.size===0?U:U.filter(Me=>Le.has(Me.toLowerCase()))},[U,u.brand,I]),ee=w.useMemo(()=>Qle(u.slicer_filament,O),[u.slicer_filament,O]);w.useEffect(()=>{if(t){if(n){const je=n.rgba&&/^[0-9A-Fa-f]{8}$/.test(n.rgba)?n.rgba:\"808080FF\";if(m({material:n.material||\"\",subtype:n.subtype||\"\",brand:n.brand||\"\",color_name:n.color_name||\"\",rgba:je,label_weight:n.label_weight||1e3,core_weight:n.core_weight||250,core_weight_catalog_id:n.core_weight_catalog_id??null,weight_used:n.weight_used||0,slicer_filament:n.slicer_filament||\"\",note:n.note||\"\",cost_per_kg:n.cost_per_kg??null}),Q(n.slicer_filament_name||n.slicer_filament||\"\"),n.k_profiles&&n.k_profiles.length>0){const Le=new Set;for(const Me of n.k_profiles)Me.cali_idx!==null&&Me.cali_idx!==void 0&&Le.add(`${Me.printer_id}:${Me.cali_idx}:${Me.extruder??\"null\"}`);Ce(Le)}else Ce(new Set)}else m(dO),Q(\"\"),Ce(new Set),C(!1),N(1);f({}),v(\"filament\"),g(!1)}},[t,n]),w.useEffect(()=>{t&&E.length>0&&Y(new Set(E.map(je=>String(je.printer.id))))},[t,E]);const se=(je,Le)=>{m(Me=>({...Me,[je]:Le})),je===\"weight_used\"&&g(!0),p[je]&&f(Me=>({...Me,[je]:void 0}))},ge=je=>{W(Le=>Jle(je,Le))},he=it({mutationFn:je=>ue.createSpool(je),onSuccess:async je=>{be.size>0&&je?.id&&await ve(je.id),await l.invalidateQueries({queryKey:[\"inventory-spools\"]}),s&&s([je]),c(o(\"inventory.spoolCreated\"),\"success\"),e()},onError:je=>{c(je.message,\"error\")}}),le=it({mutationFn:({data:je,qty:Le})=>ue.bulkCreateSpools(je,Le),onSuccess:async je=>{if(be.size>0)for(const Le of je)await ve(Le.id);await l.invalidateQueries({queryKey:[\"inventory-spools\"]}),s&&s(je),c(o(\"inventory.spoolsCreated\",{count:je.length}),\"success\"),e()},onError:je=>{c(je.message,\"error\")}}),B=it({mutationFn:je=>ue.updateSpool(n.id,je),onSuccess:async()=>{n?.id&&await ve(n.id),await l.invalidateQueries({queryKey:[\"inventory-spools\"]}),c(o(\"inventory.spoolUpdated\"),\"success\"),e()},onError:je=>{c(je.message,\"error\")}}),R=it({mutationFn:()=>ue.updateSpool(n.id,{tag_uid:null,tray_uuid:null,tag_type:null,data_origin:null}),onSuccess:async()=>{await l.invalidateQueries({queryKey:[\"inventory-spools\"]}),c(o(\"inventory.tagDeleted\",\"Tag removed\"),\"success\"),e()},onError:je=>{c(je.message,\"error\")}}),{data:ae}=Xe({queryKey:[\"spool-assignments\"],queryFn:()=>ue.getAssignments(),enabled:t&&d}),_e=n?ae?.find(je=>je.spool_id===n.id):void 0,Se=it({mutationFn:()=>{if(!_e)throw new Error(\"No assignment\");return ue.unassignSpool(_e.printer_id,_e.ams_id,_e.tray_id)},onSuccess:async()=>{await l.invalidateQueries({queryKey:[\"spool-assignments\"]}),c(o(\"inventory.unassignSuccess\",\"Spool unassigned\"),\"success\"),e()},onError:je=>{c(je.message,\"error\")}}),ve=async je=>{if(be.size===0){try{await ue.saveSpoolKProfiles(je,[])}catch{}return}const Le=[];for(const Me of be){const[Oe,Re,$e]=Me.split(\":\"),Ye=parseInt(Oe),tt=parseInt(Re),pe=$e===\"null\"?0:parseInt($e),Fe=E.find(we=>we.printer.id===Ye);if(Fe){const we=Fe.calibrations.find(Ve=>Ve.cali_idx===tt);we&&Le.push({printer_id:Ye,extruder:pe,nozzle_diameter:we.nozzle_diameter||\"0.4\",k_value:we.k_value,name:we.name||null,cali_idx:we.cali_idx,setting_id:we.setting_id||null})}}if(Le.length>0)try{await ue.saveSpoolKProfiles(je,Le)}catch(Me){console.error(\"Failed to save K-profiles:\",Me)}};if(w.useEffect(()=>{if(!t)return;const je=Le=>{Le.key===\"Escape\"&&e()};return document.addEventListener(\"keydown\",je),()=>document.removeEventListener(\"keydown\",je)},[t,e]),!t)return null;const Te=()=>{const je=Vle(u,_);if(!je.isValid){f(je.errors),(je.errors.slicer_filament||je.errors.material||je.errors.brand||je.errors.subtype)&&v(\"filament\");return}const Le=ee?.displayName||z||null,Me={material:u.material,subtype:u.subtype||null,brand:u.brand||null,color_name:u.color_name||null,rgba:u.rgba||null,label_weight:u.label_weight,core_weight:u.core_weight,core_weight_catalog_id:u.core_weight_catalog_id,slicer_filament:u.slicer_filament||null,slicer_filament_name:Le,nozzle_temp_min:null,nozzle_temp_max:null,note:u.note||null,cost_per_kg:u.cost_per_kg};(!d||b)&&(Me.weight_used=u.weight_used),d?B.mutate(Me):P>1?le.mutate({data:Me,qty:P}):he.mutate(Me)},ye=he.isPending||le.isPending||B.isPending||R.isPending||Se.isPending;return a.jsxs(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center\",children:[a.jsx(\"div\",{className:\"absolute inset-0 bg-black/60 backdrop-blur-sm\",onClick:e}),a.jsxs(\"div\",{className:\"relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:o(d?\"inventory.editSpool\":\"inventory.addSpool\")}),a.jsx(\"button\",{onClick:e,className:\"p-1 text-bambu-gray hover:text-white rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),!d&&a.jsxs(\"div\",{className:\"flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(dc,{className:\"w-4 h-4 text-amber-400\"}),a.jsx(\"span\",{className:\"text-sm text-white\",children:o(\"inventory.quickAdd\")})]}),a.jsx(\"button\",{type:\"button\",onClick:()=>{C(!_),_||v(\"filament\")},className:`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${_?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(\"span\",{className:`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${_?\"translate-x-4\":\"translate-x-0.5\"}`})})]}),a.jsxs(\"div\",{className:\"flex border-b border-bambu-dark-tertiary flex-shrink-0\",children:[a.jsxs(\"button\",{onClick:()=>v(\"filament\"),className:`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${y===\"filament\"?\"text-bambu-green border-b-2 border-bambu-green\":\"text-bambu-gray hover:text-white\"}`,children:[a.jsx(nS,{className:\"w-4 h-4\"}),o(\"inventory.filamentInfoTab\")]}),!_&&a.jsxs(\"button\",{onClick:()=>v(\"pa-profile\"),className:`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${y===\"pa-profile\"?\"text-bambu-green border-b-2 border-bambu-green\":\"text-bambu-gray hover:text-white\"}`,children:[a.jsx(Npe,{className:\"w-4 h-4\"}),o(\"inventory.paProfileTab\"),j>0&&a.jsx(\"span\",{className:\"text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green\",children:j})]})]}),a.jsx(\"div\",{className:\"p-4 overflow-y-auto flex-1\",style:{scrollbarGutter:\"stable\"},children:y===\"filament\"?a.jsxs(\"div\",{className:\"space-y-6\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3\",children:o(\"inventory.filamentInfo\")}),a.jsx(ece,{formData:u,updateField:se,cloudAuthenticated:A,loadingCloudPresets:F,presetInputValue:z,setPresetInputValue:Q,selectedPresetOption:ee,filamentOptions:O,availableBrands:X,availableMaterials:V,quickAdd:_,quantity:P,onQuantityChange:N,errors:p})]}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3\",children:o(\"inventory.color\")}),a.jsx(tce,{formData:u,updateField:se,recentColors:re,onColorUsed:ge,catalogColors:oe})]}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3\",children:o(\"inventory.additional\")}),a.jsx(nce,{formData:u,updateField:se,spoolCatalog:L,currencySymbol:i})]}),d&&n&&a.jsx(\"div\",{children:a.jsx(Frt,{spoolId:n.id})})]}):a.jsx(rce,{formData:u,updateField:se,printersWithCalibrations:E,selectedProfiles:be,setSelectedProfiles:Ce,expandedPrinters:q,setExpandedPrinters:Y})}),a.jsxs(\"div\",{className:\"flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0\",children:[d&&a.jsxs(\"div\",{className:\"flex gap-2 mr-auto\",children:[a.jsxs(De,{variant:\"secondary\",onClick:()=>R.mutate(),disabled:ye||!n?.tag_uid,children:[a.jsx(xm,{className:\"w-4 h-4\"}),o(\"inventory.deleteTag\",\"Delete Tag\")]}),a.jsxs(De,{variant:\"secondary\",onClick:()=>Se.mutate(),disabled:ye||!_e,children:[a.jsx(gp,{className:\"w-4 h-4\"}),o(\"inventory.unassignSpool\",\"Unassign\")]})]}),a.jsxs(\"div\",{className:\"flex gap-2 ml-auto\",children:[a.jsx(De,{variant:\"secondary\",onClick:e,children:o(\"common.cancel\")}),a.jsx(De,{onClick:Te,disabled:ye,children:ye?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),o(\"common.saving\")]}):a.jsxs(a.Fragment,{children:[a.jsx(_s,{className:\"w-4 h-4\"}),o(d?\"common.save\":\"inventory.addSpool\")]})})]})]})]})]})}function Lrt({isOpen:t,onClose:e,columns:n,defaultColumns:r,onSave:i}){const{t:s}=Ft(),[o,l]=w.useState(n),[c,d]=w.useState(null),u=w.useRef(null);if(w.useEffect(()=>{t&&l(n.map(P=>({...P})))},[t,n]),w.useEffect(()=>{if(!t)return;const P=N=>{N.key===\"Escape\"&&e()};return window.addEventListener(\"keydown\",P),()=>window.removeEventListener(\"keydown\",P)},[t,e]),!t)return null;const m=P=>{l(N=>N.map((A,T)=>T===P?{...A,visible:!A.visible}:A))},p=(P,N)=>{N<0||N>=o.length||l(A=>{const T=[...A],[F]=T.splice(P,1);return T.splice(N,0,F),T})},f=(P,N)=>{u.current=N,d(N),P.dataTransfer.effectAllowed=\"move\"},y=(P,N)=>{P.preventDefault(),P.dataTransfer.dropEffect=\"move\";const A=u.current;A!==null&&A!==N&&(p(A,N),u.current=N,d(N))},v=P=>{P.preventDefault()},b=()=>{u.current=null,d(null)},g=()=>{l(r.map(P=>({...P})))},_=()=>{i(o),e()},C=o.filter(P=>P.visible).length;return a.jsx(\"div\",{className:\"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4\",onClick:e,children:a.jsx(Tt,{className:\"w-full max-w-md max-h-[80vh] flex flex-col\",onClick:P=>P.stopPropagation(),children:a.jsxs(Mt,{className:\"p-6 flex flex-col min-h-0\",children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-white mb-2\",children:s(\"inventory.configureColumns\")}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray mb-4\",children:[s(\"inventory.configureColumnsDesc\"),a.jsxs(\"span\",{className:\"ml-2 text-bambu-gray/60\",children:[\"(\",C,\" \",s(\"inventory.of\"),\" \",o.length,\" \",s(\"inventory.visible\"),\")\"]})]}),a.jsx(\"div\",{className:\"space-y-1 overflow-y-auto flex-1 min-h-0 pr-1\",children:o.map((P,N)=>a.jsxs(\"div\",{className:`flex items-center gap-2 p-2 rounded-lg border transition-colors ${c===N?\"border-bambu-green bg-bambu-green/10\":\"border-bambu-dark-tertiary bg-bambu-dark-tertiary/50\"} ${P.visible?\"\":\"opacity-50\"}`,draggable:!0,onDragStart:A=>f(A,N),onDragOver:A=>y(A,N),onDrop:v,onDragEnd:b,children:[a.jsx(\"div\",{className:\"cursor-grab text-bambu-gray/50 hover:text-bambu-gray\",children:a.jsx(np,{className:\"w-4 h-4\"})}),a.jsx(\"span\",{className:\"flex-1 font-medium text-sm text-white\",children:P.label}),a.jsxs(\"div\",{className:\"flex items-center gap-0.5\",children:[a.jsx(\"button\",{onClick:()=>p(N,N-1),disabled:N===0,className:\"p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed\",title:s(\"inventory.moveUp\"),children:a.jsx(cc,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>p(N,N+1),disabled:N===o.length-1,className:\"p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed\",title:s(\"inventory.moveDown\"),children:a.jsx(On,{className:\"w-4 h-4\"})})]}),a.jsx(\"button\",{onClick:()=>m(N),className:`p-1.5 rounded transition-colors ${P.visible?\"text-bambu-green hover:bg-bambu-green/10\":\"text-bambu-gray/50 hover:bg-bambu-dark-secondary\"}`,title:P.visible?s(\"inventory.hideColumn\"):s(\"inventory.showColumn\"),children:P.visible?a.jsx(yl,{className:\"w-4 h-4\"}):a.jsx(Og,{className:\"w-4 h-4\"})})]},P.id))}),a.jsxs(\"div\",{className:\"flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary\",children:[a.jsxs(De,{variant:\"secondary\",onClick:g,className:\"mr-auto\",children:[a.jsx(co,{className:\"w-4 h-4\"}),s(\"inventory.reset\")]}),a.jsx(De,{variant:\"secondary\",onClick:e,children:s(\"inventory.cancel\")}),a.jsx(De,{onClick:_,children:s(\"inventory.applyChanges\")})]})]})})})}function PY(t){return`${t.material}|${t.subtype||\"\"}|${t.brand||\"\"}|${t.color_name||\"\"}|${t.rgba||\"\"}|${t.label_weight}`}const ace=\"bambuddy-inventory-columns\",dN=[{id:\"id\",label:\"#\",visible:!0},{id:\"added_time\",label:\"Added\",visible:!0},{id:\"encode_time\",label:\"Encoded\",visible:!1},{id:\"last_used_time\",label:\"Last Used\",visible:!1},{id:\"rgba\",label:\"Color\",visible:!0},{id:\"material\",label:\"Material\",visible:!0},{id:\"subtype\",label:\"Subtype\",visible:!0},{id:\"color_name\",label:\"Color Name\",visible:!1},{id:\"brand\",label:\"Brand\",visible:!0},{id:\"slicer_filament\",label:\"Slicer Filament\",visible:!1},{id:\"location\",label:\"Location\",visible:!0},{id:\"label_weight\",label:\"Label\",visible:!0},{id:\"net\",label:\"Net\",visible:!0},{id:\"gross\",label:\"Gross\",visible:!1},{id:\"added_full\",label:\"Full\",visible:!1},{id:\"used\",label:\"Used\",visible:!1},{id:\"printed_total\",label:\"Printed Total\",visible:!1},{id:\"printed_since_weight\",label:\"Printed Since Weight\",visible:!1},{id:\"note\",label:\"Note\",visible:!1},{id:\"pa_k\",label:\"PA(K)\",visible:!0},{id:\"tag_id\",label:\"Tag ID\",visible:!1},{id:\"data_origin\",label:\"Data Origin\",visible:!1},{id:\"tag_type\",label:\"Linked Tag Type\",visible:!1},{id:\"stock\",label:\"Stock\",visible:!1},{id:\"remaining\",label:\"Remaining\",visible:!0},{id:\"spool_name\",label:\"Spool\",visible:!1},{id:\"cost_per_kg\",label:\"Cost/kg\",visible:!1},{id:\"weight_check\",label:\"Weight Check\",visible:!1}];function Ort(){try{const t=localStorage.getItem(ace);if(t){const e=JSON.parse(t),n=new Set(dN.map(o=>o.id)),r=new Set(e.map(o=>o.id)),i=e.filter(o=>n.has(o.id)),s=dN.filter(o=>!r.has(o.id));return[...i,...s]}}catch{}return dN.map(t=>({...t}))}function Irt(t){try{localStorage.setItem(ace,JSON.stringify(t))}catch{}}function Rd(t,e=!1){return e&&t>=1e3?`${(t/1e3).toFixed(1)}kg`:`${Math.round(t)}g`}const zrt={PLA:\"bg-green-500/20 text-green-400\",ABS:\"bg-red-500/20 text-red-400\",PETG:\"bg-blue-500/20 text-blue-400\",TPU:\"bg-purple-500/20 text-purple-400\",ASA:\"bg-orange-500/20 text-orange-400\",PA:\"bg-yellow-500/20 text-yellow-400\",PC:\"bg-cyan-500/20 text-cyan-400\",PET:\"bg-sky-500/20 text-sky-400\"};function F3(t,e=\"system\"){if(!t)return\"-\";const n=Zn(t);return n?mF(n,e):\"-\"}const Urt={id:()=>\"#\",added_time:()=>\"Added\",encode_time:()=>\"Encoded\",last_used_time:()=>\"Last Used\",rgba:t=>t(\"inventory.color\"),material:t=>t(\"inventory.material\"),subtype:t=>t(\"inventory.subtype\"),color_name:t=>t(\"inventory.colorName\"),brand:t=>t(\"inventory.brand\"),slicer_filament:t=>t(\"inventory.slicerFilament\"),location:()=>\"Location\",label_weight:t=>t(\"inventory.labelWeight\"),net:t=>t(\"inventory.net\"),gross:()=>\"Gross\",added_full:()=>\"Full\",used:t=>t(\"inventory.weightUsed\"),printed_total:()=>\"Printed Total\",printed_since_weight:()=>\"Printed Since Weight\",note:t=>t(\"inventory.note\"),pa_k:()=>\"PA(K)\",tag_id:()=>\"Tag ID\",data_origin:()=>\"Data Origin\",tag_type:()=>\"Linked Tag Type\",stock:t=>t(\"inventory.stock\"),remaining:t=>t(\"inventory.remaining\"),spool_name:t=>t(\"inventory.spoolName\"),cost_per_kg:t=>t(\"inventory.costPerKg\"),weight_check:t=>t(\"inventory.weightCheck\")},uO={id:({spool:t})=>a.jsx(\"span\",{className:\"text-sm font-medium text-white\",children:t.id}),added_time:({spool:t,dateFormat:e})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:F3(t.created_at,e)}),encode_time:({spool:t,dateFormat:e})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:F3(t.encode_time,e)}),last_used_time:({spool:t,dateFormat:e})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t.last_used?F3(t.last_used,e):\"Never\"}),rgba:({spool:t})=>a.jsx(\"div\",{className:\"flex items-center justify-center\",children:a.jsx(\"span\",{className:\"w-5 h-5 rounded-full border border-black/20 flex-shrink-0\",style:{backgroundColor:t.rgba?`#${t.rgba.substring(0,6)}`:\"#808080\"},title:t.rgba?`#${t.rgba.substring(0,6)}`:void 0})}),material:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-white\",children:t.material}),subtype:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t.subtype||\"-\"}),color_name:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:KP(t.color_name,t.rgba)||\"-\"}),brand:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t.brand||\"-\"}),slicer_filament:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",title:t.slicer_filament||void 0,children:t.slicer_filament_name||t.slicer_filament||\"-\"}),location:({spool:t,assignmentMap:e})=>{const n=e[t.id];if(!n)return a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:\"-\"});const r=n.printer_name||`Printer ${n.printer_id}`,i=n.ams_id===254||n.ams_id===255,s=!i&&n.ams_id>=128,o=RS(n.ams_id,n.tray_id,s,i);return a.jsxs(\"span\",{className:\"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400\",children:[r,\" \",o,n.ams_label?` (${n.ams_label})`:\"\"]})},label_weight:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-white\",children:Rd(t.label_weight)}),net:({remaining:t})=>a.jsx(\"span\",{className:\"text-sm text-white\",children:Rd(t)}),gross:({spool:t,remaining:e})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:Rd(e+t.core_weight)}),added_full:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t.added_full==null?\"-\":t.added_full?\"Yes\":\"No\"}),used:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t.weight_used>0?Rd(t.weight_used):\"-\"}),printed_total:()=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray/50\",children:\"-\"}),printed_since_weight:()=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray/50\",children:\"-\"}),note:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray max-w-[150px] truncate block\",title:t.note||void 0,children:t.note||\"-\"}),pa_k:({spool:t})=>(t.k_profiles?.length??0)===0?a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:\"-\"}):a.jsx(\"span\",{className:\"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green\",children:\"K\"}),tag_id:({spool:t})=>{const e=t.tag_uid||t.tray_uuid;return e?a.jsx(\"span\",{className:\"text-sm text-bambu-gray font-mono\",title:e,children:e.length>12?`${e.slice(0,6)}...${e.slice(-4)}`:e}):a.jsx(\"span\",{className:\"text-sm text-bambu-gray/50\",children:\"-\"})},data_origin:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t.data_origin||\"-\"}),tag_type:({spool:t})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t.tag_type||\"-\"}),stock:({spool:t,t:e})=>t.slicer_filament?a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:\"-\"}):a.jsx(\"span\",{className:\"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-500/20 text-amber-400\",children:e(\"inventory.stock\")}),remaining:({remaining:t,pct:e})=>a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"div\",{className:\"flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:`h-full rounded-full ${e>50?\"bg-bambu-green\":e>20?\"bg-yellow-500\":\"bg-red-500\"}`,style:{width:`${Math.min(e,100)}%`}})}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray min-w-[40px] text-right\",children:[Math.round(t),\"g\"]})]}),spool_name:({spool:t,catalogMap:e})=>{const n=t.core_weight_catalog_id!=null?e[t.core_weight_catalog_id]:void 0;return a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:n?.name||\"-\"})},cost_per_kg:({spool:t,currencySymbol:e})=>a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:t.cost_per_kg!=null?`${e}${t.cost_per_kg.toFixed(2)}`:\"-\"}),weight_check:({spool:t,onSyncWeight:e})=>{const n=t.last_scale_weight;if(n==null)return a.jsx(\"span\",{className:\"text-sm text-bambu-gray/50\",title:\"No scale measurement\",children:\"-\"});const r=t.core_weight||0,i=Math.max(0,t.label_weight-t.weight_used)+r;let s,o;n<r?(s=n-r,o=!0):(s=n-i,o=Math.abs(s)<=50);const l=s>0?`+${Math.round(s)}`:`${Math.round(s)}`,c=o?`Scale: ${Math.round(n)}g\nCalculated: ${Math.round(i)}g\nDifference: ${l}g (within tolerance)`:`Scale: ${Math.round(n)}g\nCalculated: ${Math.round(i)}g\nDifference: ${l}g (mismatch!)`;return a.jsxs(\"div\",{className:`flex items-center gap-1 text-sm font-medium ${o?\"text-green-400\":\"text-yellow-400\"}`,title:c,children:[a.jsxs(\"span\",{children:[Math.round(n),\"g\"]}),o?a.jsx(Ur,{className:\"w-3 h-3\"}):a.jsxs(a.Fragment,{children:[a.jsx(Dn,{className:\"w-3 h-3\"}),e&&a.jsx(\"button\",{type:\"button\",onClick:d=>{d.stopPropagation(),d.preventDefault(),e(t)},className:\"p-1 hover:bg-bambu-green/20 rounded transition-colors text-bambu-green\",title:\"Sync: trust scale weight and reset tracking\",children:a.jsx(lr,{className:\"w-3.5 h-3.5\"})})]})]})}},R3={id:t=>t.id,added_time:t=>t.created_at||\"\",encode_time:t=>t.encode_time||\"\",last_used_time:t=>t.last_used||\"\",material:t=>(t.material||\"\").toLowerCase(),subtype:t=>(t.subtype||\"\").toLowerCase(),color_name:t=>(t.color_name||\"\").toLowerCase(),brand:t=>(t.brand||\"\").toLowerCase(),slicer_filament:t=>(t.slicer_filament_name||t.slicer_filament||\"\").toLowerCase(),location:(t,e)=>{const n=e[t.id];if(!n)return\"\";const r=n.ams_id===254||n.ams_id===255,i=!r&&n.ams_id>=128,s=n.ams_label?` (${n.ams_label})`:\"\";return`${n.printer_name||\"\"} ${RS(n.ams_id,n.tray_id,i,r)}${s}`},label_weight:t=>t.label_weight,net:t=>Math.max(0,t.label_weight-t.weight_used),gross:t=>Math.max(0,t.label_weight-t.weight_used)+t.core_weight,used:t=>t.weight_used,remaining:t=>t.label_weight>0?Math.max(0,t.label_weight-t.weight_used)/t.label_weight:0,note:t=>(t.note||\"\").toLowerCase(),data_origin:t=>(t.data_origin||\"\").toLowerCase(),tag_type:t=>(t.tag_type||\"\").toLowerCase(),stock:t=>t.slicer_filament?1:0,spool_name:t=>t.core_weight_catalog_id??0,cost_per_kg:t=>t.cost_per_kg??0,weight_check:t=>{if(t.last_scale_weight==null)return-1;const e=Math.max(0,t.label_weight-t.weight_used)+t.core_weight;return Math.abs(t.last_scale_weight-e)}},mO=\"bambuddy-inventory-sort\";function Brt(){try{const t=localStorage.getItem(mO);if(t)return JSON.parse(t)}catch{}return null}function Hrt(t){try{t?localStorage.setItem(mO,JSON.stringify(t)):localStorage.removeItem(mO)}catch{}}function qrt(){const{data:t}=Xe({queryKey:[\"spoolman-settings\"],queryFn:ue.getSpoolmanSettings,staleTime:3e5});return t?.spoolman_enabled===\"true\"&&t?.spoolman_url?a.jsx(\"iframe\",{src:`${t.spoolman_url.replace(/\\/+$/,\"\")}/spool`,className:\"h-full w-full border-0\",title:\"Spoolman\",sandbox:\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"}):a.jsx($rt,{})}function $rt(){const{t}=Ft(),e=nn(),{showToast:n}=hn(),[r,i]=w.useState(null),[s,o]=w.useState(null),[l,c]=w.useState(\"active\"),[d,u]=w.useState(\"all\"),[m,p]=w.useState(\"\"),[f,y]=w.useState(\"\"),[v,b]=w.useState(\"\"),[g,_]=w.useState(\"all\"),[C,P]=w.useState(\"\"),[N,A]=w.useState(\"table\"),[T,F]=w.useState(Brt),[k,D]=w.useState(Ort),[H,z]=w.useState(!1),[Q,L]=w.useState(()=>{try{return localStorage.getItem(\"bambuddy-inventory-group\")===\"true\"}catch{return!1}}),[te,ie]=w.useState(new Set),[J,oe]=w.useState(0),[fe,re]=w.useState(()=>{try{const ce=localStorage.getItem(\"bambuddy-inventory-pageSize\");if(ce){const xe=Number(ce);if([15,30,50,100,-1].includes(xe))return xe}}catch{}return 15}),{data:W}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),ne=W?.date_format||\"system\",{data:me,isLoading:be}=Xe({queryKey:[\"inventory-spools\"],queryFn:()=>ue.getSpools(!0),refetchInterval:3e4}),{data:Ce}=Xe({queryKey:[\"spool-assignments\"],queryFn:()=>ue.getAssignments(),refetchInterval:3e4}),{data:q}=Xe({queryKey:[\"spool-catalog\"],queryFn:()=>ue.getSpoolCatalog()}),Y=it({mutationFn:ce=>ue.deleteSpool(ce),onSuccess:()=>{e.invalidateQueries({queryKey:[\"inventory-spools\"]}),n(t(\"inventory.spoolDeleted\"),\"success\")}}),E=it({mutationFn:ce=>ue.archiveSpool(ce),onSuccess:()=>{e.invalidateQueries({queryKey:[\"inventory-spools\"]}),n(t(\"inventory.spoolArchived\"),\"success\")}}),j=it({mutationFn:ce=>ue.restoreSpool(ce),onSuccess:()=>{e.invalidateQueries({queryKey:[\"inventory-spools\"]}),n(t(\"inventory.spoolRestored\"),\"success\")}}),O=async ce=>{if(ce.last_scale_weight!=null)try{await Gr.updateSpoolWeight(ce.id,ce.last_scale_weight),e.invalidateQueries({queryKey:[\"inventory-spools\"]});const xe=[ce.brand,ce.material,ce.color_name].filter(Boolean).join(\" \");n(`Synced \"${xe}\" to scale weight`,\"success\")}catch{n(\"Failed to sync weight\",\"error\")}},K=W?.low_stock_threshold??20,[U,de]=w.useState(!1),[I,G]=w.useState(K.toString());w.useEffect(()=>{U||G(K.toString())},[K,U]);const X=it({mutationFn:ce=>ue.updateSettings({low_stock_threshold:ce}),onSuccess:()=>{e.invalidateQueries({queryKey:[\"settings\"]}),n(t(\"common.saved\"),\"success\"),de(!1)},onError:()=>{n(t(\"inventory.lowStockThresholdError\"),\"error\")}}),V=w.useMemo(()=>{if(!me)return null;let ce=0,xe=0,Be=0,Qe=0;const ht={};for(const xt of me){if(xt.archived_at)continue;Qe++;const gt=Math.max(0,xt.label_weight-xt.weight_used);ce+=gt,xe+=xt.weight_used,(xt.label_weight>0?gt/xt.label_weight*100:0)<K&&Be++;const Wt=xt.material||\"Unknown\";ht[Wt]||(ht[Wt]={count:0,weight:0}),ht[Wt].count++,ht[Wt].weight+=gt}return{totalWeight:ce,totalConsumed:xe,lowStock:Be,byMaterial:ht,totalSpools:Qe}},[me,K]),ee=Ce?.length??0,se=Eo(W?.currency||\"USD\"),ge=w.useMemo(()=>{const ce={};for(const xe of Ce||[])ce[xe.spool_id]=xe;return ce},[Ce]),he=w.useMemo(()=>{const ce={};for(const xe of q||[])ce[xe.id]=xe;return ce},[q]),le=w.useMemo(()=>V?Object.entries(V.byMaterial).sort((ce,xe)=>xe[1].weight-ce[1].weight).slice(0,4):[],[V]),B=w.useMemo(()=>{let ce=me||[];if(l===\"active\"?ce=ce.filter(xe=>!xe.archived_at):ce=ce.filter(xe=>!!xe.archived_at),d===\"used\"?ce=ce.filter(xe=>xe.weight_used>0):d===\"new\"?ce=ce.filter(xe=>xe.weight_used===0):d===\"lowstock\"&&(ce=ce.filter(xe=>{const Be=Math.max(0,xe.label_weight-xe.weight_used);return(xe.label_weight>0?Be/xe.label_weight*100:0)<K})),m&&(ce=ce.filter(xe=>xe.material===m)),f&&(ce=ce.filter(xe=>xe.brand===f)),v){const xe=Number(v);ce=ce.filter(Be=>Be.core_weight_catalog_id===xe)}if(g===\"stock\"?ce=ce.filter(xe=>!xe.slicer_filament):g===\"configured\"&&(ce=ce.filter(xe=>!!xe.slicer_filament)),C){const xe=C.toLowerCase();ce=ce.filter(Be=>Be.brand?.toLowerCase().includes(xe)||Be.material.toLowerCase().includes(xe)||Be.color_name?.toLowerCase().includes(xe)||Be.subtype?.toLowerCase().includes(xe)||Be.note?.toLowerCase().includes(xe)||Be.slicer_filament_name?.toLowerCase().includes(xe))}return ce},[me,l,d,m,f,v,g,C,K]),R=()=>oe(0),ae=[...new Set(me?.map(ce=>ce.material)||[])].sort(),_e=[...new Set(me?.map(ce=>ce.brand).filter(Boolean)||[])].sort(),Se=[...new Set(me?.map(ce=>ce.core_weight_catalog_id).filter(ce=>ce!=null)||[])].sort((ce,xe)=>{const Be=(he[ce]?.name||\"\").toLowerCase(),Qe=(he[xe]?.name||\"\").toLowerCase();return Be.localeCompare(Qe)}),ve=l!==\"active\"||d!==\"all\"||!!m||!!f||!!v||g!==\"all\"||!!C,Te=ce=>{D(ce),Irt(ce)},ye=w.useMemo(()=>k.filter(ce=>ce.visible).map(ce=>ce.id),[k]),je=ce=>{R3[ce]&&(F(xe=>{let Be;return xe?.column===ce?Be=xe.direction===\"asc\"?{column:ce,direction:\"desc\"}:null:Be={column:ce,direction:\"asc\"},Hrt(Be),Be}),R())},Le=w.useMemo(()=>{if(!T)return B;const ce=R3[T.column];return ce?[...B].sort((Be,Qe)=>{const ht=ce(Be,ge),xt=ce(Qe,ge);return ht<xt?T.direction===\"asc\"?-1:1:ht>xt?T.direction===\"asc\"?1:-1:0}):B},[B,T,ge]),Me=w.useMemo(()=>{if(!Q)return Le.map(Qe=>({type:\"single\",spool:Qe}));const ce=new Map;for(const Qe of Le)if(!(Qe.weight_used>0||ge[Qe.id])){const ht=PY(Qe),xt=ce.get(ht);xt?xt.push(Qe):ce.set(ht,[Qe])}const xe=[],Be=new Set;for(const Qe of Le){if(Qe.weight_used>0||ge[Qe.id]){xe.push({type:\"single\",spool:Qe});continue}const ht=PY(Qe);if(Be.has(ht))continue;Be.add(ht);const xt=ce.get(ht);xt.length===1?xe.push({type:\"single\",spool:xt[0]}):xe.push({type:\"group\",key:ht,spools:xt,representative:xt[0]})}return xe},[Le,Q,ge]),Oe=fe===-1,Re=Me.length,$e=Oe?Re||1:fe,Ye=Math.max(1,Math.ceil(Re/$e)),tt=Oe?0:Math.min(J,Ye-1),pe=Oe?Me:Me.slice(tt*$e,(tt+1)*$e),Fe=()=>{const ce=!Q;L(ce),ie(new Set),R();try{localStorage.setItem(\"bambuddy-inventory-group\",String(ce))}catch{}},we=ce=>{ie(xe=>{const Be=new Set(xe);return Be.has(ce)?Be.delete(ce):Be.add(ce),Be})},Ve=ce=>{re(ce),oe(0);try{localStorage.setItem(\"bambuddy-inventory-pageSize\",String(ce))}catch{}},Ae=()=>{c(\"active\"),u(\"all\"),p(\"\"),y(\"\"),b(\"\"),_(\"all\"),P(\"\"),R()};return a.jsxs(\"div\",{className:\"p-4 md:p-6 space-y-6\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(Ra,{className:\"w-6 h-6 text-bambu-green\"}),a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:t(\"inventory.title\")})]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1 ml-9\",children:(t(\"inventory.noSpools\").split(\".\")[0],\"\")})]}),a.jsxs(De,{onClick:()=>i({spool:null}),children:[a.jsx(sr,{className:\"w-4 h-4\"}),t(\"inventory.addSpool\")]})]}),V&&!be&&a.jsxs(\"div\",{className:\"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3\",children:[a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsx(Ra,{className:\"w-4 h-4 text-bambu-green\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray font-medium uppercase tracking-wide\",children:t(\"inventory.totalInventory\")})]}),a.jsx(\"div\",{className:\"text-xl font-bold text-white\",children:Rd(V.totalWeight,!0)}),a.jsxs(\"div\",{className:\"text-xs text-bambu-gray mt-1\",children:[V.totalSpools,\" \",V.totalSpools!==1?t(\"inventory.spools\"):t(\"inventory.spool\")]})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsx(LO,{className:\"w-4 h-4 text-blue-400\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray font-medium uppercase tracking-wide\",children:t(\"inventory.totalConsumed\")})]}),a.jsx(\"div\",{className:\"text-xl font-bold text-white\",children:Rd(V.totalConsumed,!0)}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"inventory.sinceTracking\")})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsx(da,{className:\"w-4 h-4 text-green-400\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray font-medium uppercase tracking-wide\",children:t(\"inventory.byMaterial\")})]}),a.jsx(\"div\",{className:\"flex flex-wrap gap-1.5 mt-1\",children:le.map(([ce,xe])=>a.jsxs(\"span\",{className:`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${zrt[ce]||\"bg-bambu-dark-tertiary text-bambu-gray\"}`,children:[ce,\" \",a.jsx(\"span\",{className:\"opacity-70\",children:Rd(xe.weight,!0)})]},ce))})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsx(Er,{className:\"w-4 h-4 text-purple-400\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray font-medium uppercase tracking-wide\",children:t(\"inventory.inPrinter\")})]}),a.jsx(\"div\",{className:\"text-xl font-bold text-white\",children:ee}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray mt-1\",children:t(\"inventory.loadedInAms\")})]}),a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1\",children:[a.jsx(Dn,{className:\"w-4 h-4 text-yellow-400\"}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray font-medium uppercase tracking-wide\",children:t(\"inventory.lowStock\")})]}),a.jsx(\"div\",{className:`text-xl font-bold ${V.lowStock>0?\"text-yellow-400\":\"text-white\"}`,children:V.lowStock}),a.jsx(\"div\",{className:\"text-xs text-bambu-gray mt-1 flex items-center gap-2\",children:U?a.jsxs(\"form\",{onSubmit:ce=>{ce.preventDefault();const xe=parseFloat(I);!isNaN(xe)&&xe>=.1&&xe<=99.9?X.mutate(xe):n(t(\"inventory.lowStockThresholdError\"),\"error\")},className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"<\"}),a.jsx(\"input\",{type:\"text\",inputMode:\"decimal\",pattern:\"^\\\\d{0,2}(\\\\.\\\\d?)?$\",maxLength:4,value:I,onChange:ce=>{const xe=ce.target.value.replace(/[^\\d.]/g,\"\");/^\\d{0,2}(\\.\\d?)?$/.test(xe)&&G(xe)},className:\"px-1.5 py-1 rounded border border-bambu-dark-tertiary text-xs text-white bg-bambu-dark-secondary focus:outline-none focus:border-bambu-green w-14 text-center\",onWheel:ce=>ce.currentTarget.blur(),disabled:X.isPending}),a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:\"%\"}),a.jsx(De,{type:\"submit\",size:\"sm\",disabled:X.isPending,children:t(\"common.save\")}),a.jsx(De,{type:\"button\",size:\"sm\",variant:\"ghost\",onClick:()=>de(!1),disabled:X.isPending,children:t(\"common.cancel\")})]}):a.jsxs(a.Fragment,{children:[a.jsxs(\"span\",{className:\"text-bambu-gray\",children:[\"< \",K,\"%\"]}),a.jsx(\"button\",{className:\"p-1.5 text-bambu-gray hover:text-white rounded transition-colors\",title:t(\"common.edit\"),onClick:()=>{G(K.toString()),de(!0)},children:a.jsx(Qu,{className:\"w-4 h-4\"})})]})})]})]}),a.jsxs(\"div\",{className:\"flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between\",children:[a.jsxs(\"div\",{className:\"relative flex-1 max-w-md\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50\"}),a.jsx(\"input\",{type:\"text\",value:C,onChange:ce=>{P(ce.target.value),R()},placeholder:t(\"inventory.search\"),className:\"w-full pl-10 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green\"}),C&&a.jsx(\"button\",{onClick:()=>{P(\"\"),R()},className:\"absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[N===\"table\"&&a.jsxs(\"button\",{onClick:()=>z(!0),className:\"flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors\",title:t(\"inventory.configureColumns\"),children:[a.jsx(jfe,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"inventory.columns\")})]}),a.jsxs(\"button\",{onClick:Fe,className:`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border rounded-lg transition-colors ${Q?\"bg-bambu-green/20 text-bambu-green border-bambu-green/30\":\"text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary\"}`,title:t(\"inventory.groupSimilar\"),children:[a.jsx(Dge,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"inventory.groupSimilar\")})]}),a.jsxs(\"div\",{className:\"flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden\",children:[a.jsxs(\"button\",{onClick:()=>A(\"table\"),className:`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${N===\"table\"?\"bg-bambu-green text-white\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(Exe,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"inventory.table\")})]}),a.jsxs(\"button\",{onClick:()=>A(\"cards\"),className:`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${N===\"cards\"?\"bg-bambu-green text-white\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(eS,{className:\"w-4 h-4\"}),a.jsx(\"span\",{className:\"hidden sm:inline\",children:t(\"inventory.cards\")})]})]})]})]}),a.jsxs(\"div\",{className:\"flex flex-wrap items-center gap-2\",children:[a.jsxs(\"div\",{className:\"flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden\",children:[a.jsxs(\"button\",{onClick:()=>{c(\"active\"),R()},className:`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${l===\"active\"?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(Ra,{className:\"w-3.5 h-3.5\"}),t(\"inventory.active\")]}),a.jsxs(\"button\",{onClick:()=>{c(\"archived\"),R()},className:`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${l===\"archived\"?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(so,{className:\"w-3.5 h-3.5\"}),t(\"inventory.archived\")]})]}),a.jsx(\"div\",{className:\"w-px h-5 bg-bambu-dark-tertiary\"}),a.jsxs(\"div\",{className:\"flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden\",children:[a.jsx(\"button\",{onClick:()=>{u(\"all\"),R()},className:`px-3 py-1.5 text-xs font-medium transition-colors ${d===\"all\"?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:t(\"inventory.all\")}),a.jsxs(\"button\",{onClick:()=>{u(\"used\"),R()},className:`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${d===\"used\"?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(Yn,{className:\"w-3.5 h-3.5\"}),t(\"inventory.used\")]}),a.jsx(\"button\",{onClick:()=>{u(\"new\"),R()},className:`px-3 py-1.5 text-xs font-medium transition-colors ${d===\"new\"?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:t(\"inventory.new\")}),a.jsxs(\"button\",{onClick:()=>{u(\"lowstock\"),R()},className:`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${d===\"lowstock\"?\"bg-yellow-500/20 text-yellow-400\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(Dn,{className:\"w-3.5 h-3.5\"}),t(\"inventory.lowStock\")]})]}),a.jsxs(\"div\",{className:\"flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden\",children:[a.jsx(\"button\",{onClick:()=>{_(\"all\"),R()},className:`px-3 py-1.5 text-xs font-medium transition-colors ${g===\"all\"?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:t(\"inventory.all\")}),a.jsx(\"button\",{onClick:()=>{_(\"stock\"),R()},className:`px-3 py-1.5 text-xs font-medium transition-colors ${g===\"stock\"?\"bg-amber-500/20 text-amber-400\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:t(\"inventory.stock\")}),a.jsx(\"button\",{onClick:()=>{_(\"configured\"),R()},className:`px-3 py-1.5 text-xs font-medium transition-colors ${g===\"configured\"?\"bg-bambu-green/20 text-bambu-green\":\"text-bambu-gray hover:bg-bambu-dark-tertiary\"}`,children:t(\"inventory.configured\")})]}),a.jsx(\"div\",{className:\"w-px h-5 bg-bambu-dark-tertiary\"}),a.jsxs(\"select\",{value:m,onChange:ce=>{p(ce.target.value),R()},className:`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${m?\"bg-bambu-green/20 text-bambu-green border-bambu-green/30\":\"bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(\"option\",{value:\"\",children:t(\"inventory.material\")}),ae.map(ce=>a.jsx(\"option\",{value:ce,children:ce},ce))]}),a.jsxs(\"select\",{value:f,onChange:ce=>{y(ce.target.value),R()},className:`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${f?\"bg-bambu-green/20 text-bambu-green border-bambu-green/30\":\"bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(\"option\",{value:\"\",children:t(\"inventory.brand\")}),_e.map(ce=>a.jsx(\"option\",{value:ce,children:ce},ce))]}),Se.length>0&&a.jsxs(\"select\",{value:v,onChange:ce=>{b(ce.target.value),R()},className:`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${v?\"bg-bambu-green/20 text-bambu-green border-bambu-green/30\":\"bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary\"}`,children:[a.jsx(\"option\",{value:\"\",children:t(\"inventory.spoolName\")}),Se.map(ce=>a.jsx(\"option\",{value:ce,children:he[ce]?.name||`#${ce}`},ce))]}),ve&&a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"w-px h-5 bg-bambu-dark-tertiary\"}),a.jsxs(\"button\",{onClick:Ae,className:\"flex items-center gap-1 text-xs text-bambu-gray hover:text-bambu-green transition-colors\",children:[a.jsx(Ht,{className:\"w-3.5 h-3.5\"}),t(\"inventory.clearFilters\")]})]}),a.jsxs(\"span\",{className:\"ml-auto text-xs text-bambu-gray\",children:[Le.length,\" \",Le.length!==1?t(\"inventory.spools\"):t(\"inventory.spool\"),Q&&Re<Le.length&&` (${Re} ${t(\"inventory.groupedRows\")})`]})]}),be?a.jsx(\"div\",{className:\"flex justify-center py-16\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})}):N===\"cards\"?pe.length>0?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\",children:pe.map(ce=>{if(ce.type===\"group\"){const{key:xt,spools:gt,representative:Ut}=ce,Wt=Ut.rgba?`#${Ut.rgba.substring(0,6)}`:\"#808080\",Zt=te.has(xt);return a.jsxs(\"div\",{className:\"col-span-full\",children:[a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer\",onClick:()=>we(xt),children:[a.jsx(\"div\",{className:\"h-10 flex items-center px-4 gap-3\",style:{backgroundColor:Wt},children:a.jsx(\"span\",{className:\"bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium\",children:KP(Ut.color_name,Ut.rgba)||\"-\"})}),a.jsxs(\"div\",{className:\"px-4 py-3 flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(On,{className:`w-4 h-4 text-bambu-gray transition-transform ${Zt?\"\":\"-rotate-90\"}`}),a.jsxs(\"div\",{children:[a.jsxs(\"h3\",{className:\"font-semibold text-white\",children:[Ut.material,Ut.subtype?` ${Ut.subtype}`:\"\"]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:Ut.brand||\"-\"})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-sm text-bambu-gray\",children:Rd(Ut.label_weight)}),a.jsx(\"span\",{className:\"text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full\",children:t(\"inventory.groupedSpools\",{count:gt.length})})]})]})]}),Zt&&a.jsx(\"div\",{className:\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-2 ml-4\",children:gt.map(Kt=>{const Xt=Math.max(0,Kt.label_weight-Kt.weight_used),ln=Kt.label_weight>0?Xt/Kt.label_weight*100:0,vn=Kt.rgba?`#${Kt.rgba.substring(0,6)}`:\"#808080\";return a.jsx(TY,{spool:Kt,remaining:Xt,pct:ln,colorStyle:vn,onClick:()=>i({spool:Kt}),t},Kt.id)})})]},`group-${xt}`)}const xe=ce.spool,Be=Math.max(0,xe.label_weight-xe.weight_used),Qe=xe.label_weight>0?Be/xe.label_weight*100:0,ht=xe.rgba?`#${xe.rgba.substring(0,6)}`:\"#808080\";return a.jsx(TY,{spool:xe,remaining:Be,pct:Qe,colorStyle:ht,onClick:()=>i({spool:xe}),t},xe.id)})}),a.jsx(Vrt,{pageIndex:tt,pageSize:fe,totalRows:Re,totalPages:Ye,onPageChange:oe,onPageSizeChange:Ve,t})]}):a.jsx(AY,{hasFilters:ve,onAddSpool:()=>i({spool:null}),t}):pe.length>0?a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary\",children:[a.jsx(\"div\",{className:\"overflow-x-auto\",children:a.jsxs(\"table\",{className:\"w-full\",children:[a.jsx(\"thead\",{children:a.jsxs(\"tr\",{className:\"border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30\",children:[ye.map(ce=>{const xe=!!R3[ce],Be=T?.column===ce;return a.jsx(\"th\",{className:`text-left py-3 px-4 text-xs font-medium uppercase tracking-wide select-none ${ce===\"remaining\"?\"min-w-[150px]\":\"\"} ${xe?\"cursor-pointer hover:text-bambu-green transition-colors\":\"\"} ${Be?\"text-bambu-green\":\"text-bambu-gray\"}`,onClick:xe?()=>je(ce):void 0,children:a.jsxs(\"span\",{className:\"inline-flex items-center gap-1\",children:[Urt[ce]?.(t)??ce,xe&&(Be?T.direction===\"asc\"?a.jsx(fp,{className:\"w-3 h-3\"}):a.jsx(cg,{className:\"w-3 h-3\"}):a.jsx(TO,{className:\"w-3 h-3 opacity-30\"}))]})},ce)}),a.jsx(\"th\",{className:\"text-right py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide\",children:t(\"common.actions\")})]})}),a.jsx(\"tbody\",{children:pe.map(ce=>{if(ce.type===\"group\"){const{key:ht,spools:xt,representative:gt}=ce,Ut=te.has(ht),Wt=Math.max(0,gt.label_weight-gt.weight_used),Zt=gt.label_weight>0?Wt/gt.label_weight*100:0;return a.jsx(Grt,{spools:xt,representative:gt,remaining:Wt,pct:Zt,isExpanded:Ut,onToggle:()=>we(ht),onEdit:Kt=>i({spool:Kt}),onArchive:Kt=>o({type:\"archive\",spoolId:Kt}),onDelete:Kt=>o({type:\"delete\",spoolId:Kt}),visibleColumns:ye,assignmentMap:ge,catalogMap:he,currencySymbol:se,dateFormat:ne,t,onSyncWeight:O},`group-${ht}`)}const xe=ce.spool,Be=Math.max(0,xe.label_weight-xe.weight_used),Qe=xe.label_weight>0?Be/xe.label_weight*100:0;return a.jsx(ice,{spool:xe,remaining:Be,pct:Qe,onEdit:()=>i({spool:xe}),onRestore:()=>j.mutate(xe.id),onArchive:()=>o({type:\"archive\",spoolId:xe.id}),onDelete:()=>o({type:\"delete\",spoolId:xe.id}),visibleColumns:ye,assignmentMap:ge,catalogMap:he,currencySymbol:se,dateFormat:ne,t,onSyncWeight:O},xe.id)})})]})}),a.jsxs(\"div\",{className:\"flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:Oe?`${Re} ${t(Re!==1?\"inventory.spools\":\"inventory.spool\")}`:a.jsxs(a.Fragment,{children:[t(\"inventory.showing\"),\" \",tt*$e+1,\" \",t(\"inventory.to\"),\" \",Math.min((tt+1)*$e,Re),\" \",t(\"inventory.of\"),\" \",Re,\" \",t(\"inventory.spools\")]})}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:t(\"inventory.show\")}),a.jsxs(\"select\",{value:fe,onChange:ce=>Ve(Number(ce.target.value)),className:\"px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green\",children:[[15,30,50,100].map(ce=>a.jsx(\"option\",{value:ce,children:ce},ce)),a.jsx(\"option\",{value:-1,children:t(\"inventory.all\")})]}),!Oe&&a.jsxs(a.Fragment,{children:[a.jsx(\"button\",{onClick:()=>oe(0),disabled:tt===0,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",title:\"First page\",children:a.jsx(jO,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>oe(ce=>Math.max(0,ce-1)),disabled:tt===0,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(xl,{className:\"w-4 h-4\"})}),a.jsxs(\"span\",{className:\"text-bambu-gray px-2 whitespace-nowrap\",children:[t(\"inventory.page\"),\" \",tt+1,\" \",t(\"inventory.of\"),\" \",Ye]}),a.jsx(\"button\",{onClick:()=>oe(ce=>Math.min(Ye-1,ce+1)),disabled:tt>=Ye-1,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(ti,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>oe(Ye-1),disabled:tt>=Ye-1,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",title:\"Last page\",children:a.jsx(MO,{className:\"w-4 h-4\"})})]})]})]})]}):a.jsx(AY,{hasFilters:ve,onAddSpool:()=>i({spool:null}),t}),r!==null&&a.jsx(Rrt,{isOpen:!0,onClose:()=>i(null),spool:r.spool,currencySymbol:se}),s&&a.jsx(yn,{title:s.type===\"delete\"?t(\"common.delete\"):t(\"inventory.archive\"),message:s.type===\"delete\"?t(\"inventory.deleteConfirm\"):t(\"inventory.archiveConfirm\"),confirmText:s.type===\"delete\"?t(\"common.delete\"):t(\"inventory.archive\"),variant:s.type===\"delete\"?\"danger\":\"warning\",onConfirm:()=>{s.type===\"delete\"?Y.mutate(s.spoolId):E.mutate(s.spoolId),o(null)},onCancel:()=>o(null)}),a.jsx(Lrt,{isOpen:H,onClose:()=>z(!1),columns:k,defaultColumns:dN,onSave:Te})]})}function Vrt({pageIndex:t,pageSize:e,totalRows:n,totalPages:r,onPageChange:i,onPageSizeChange:s,t:o}){const l=e===-1;if(r<=1&&!l)return null;const c=l?n||1:e;return a.jsxs(\"div\",{className:\"flex items-center justify-between pt-2 text-sm\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:l?`${n} ${o(n!==1?\"inventory.spools\":\"inventory.spool\")}`:a.jsxs(a.Fragment,{children:[o(\"inventory.showing\"),\" \",t*c+1,\" \",o(\"inventory.to\"),\" \",Math.min((t+1)*c,n),\" \",o(\"inventory.of\"),\" \",n,\" \",o(\"inventory.spools\")]})}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:o(\"inventory.show\")}),a.jsxs(\"select\",{value:e,onChange:d=>s(Number(d.target.value)),className:\"px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green\",children:[[15,30,50,100].map(d=>a.jsx(\"option\",{value:d,children:d},d)),a.jsx(\"option\",{value:-1,children:o(\"inventory.all\")})]}),!l&&a.jsxs(a.Fragment,{children:[a.jsx(\"button\",{onClick:()=>i(0),disabled:t===0,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(jO,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>i(Math.max(0,t-1)),disabled:t===0,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(xl,{className:\"w-4 h-4\"})}),a.jsxs(\"span\",{className:\"text-bambu-gray px-2 whitespace-nowrap\",children:[o(\"inventory.page\"),\" \",t+1,\" \",o(\"inventory.of\"),\" \",r]}),a.jsx(\"button\",{onClick:()=>i(Math.min(r-1,t+1)),disabled:t>=r-1,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(ti,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:()=>i(r-1),disabled:t>=r-1,className:\"p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors\",children:a.jsx(MO,{className:\"w-4 h-4\"})})]})]})]})}function TY({spool:t,remaining:e,pct:n,colorStyle:r,onClick:i,t:s}){return a.jsxs(\"div\",{className:`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${t.archived_at?\"opacity-50\":\"\"}`,onClick:i,children:[a.jsx(\"div\",{className:\"h-14 flex items-center justify-center\",style:{backgroundColor:r},children:a.jsx(\"span\",{className:\"bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium\",children:KP(t.color_name,t.rgba)||\"-\"})}),a.jsxs(\"div\",{className:\"p-4 space-y-3\",children:[a.jsxs(\"div\",{className:\"flex items-start justify-between gap-2\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"h3\",{className:\"font-semibold text-white\",children:[t.material,t.subtype?` ${t.subtype}`:\"\"]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t.brand||\"-\"})]}),a.jsxs(\"span\",{className:\"text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded\",children:[\"#\",t.id]})]}),a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex justify-between text-xs text-bambu-gray mb-1\",children:[a.jsx(\"span\",{children:s(\"inventory.remaining\")}),a.jsxs(\"span\",{children:[Math.round(n),\"%\"]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"div\",{className:\"flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:`h-full rounded-full ${n>50?\"bg-bambu-green\":n>20?\"bg-yellow-500\":\"bg-red-500\"}`,style:{width:`${Math.min(n,100)}%`}})}),a.jsxs(\"span\",{className:\"text-xs text-bambu-gray min-w-[40px] text-right\",children:[Math.round(e),\"g\"]})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2 text-xs\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"span\",{className:\"text-bambu-gray/60\",children:[s(\"inventory.labelWeight\"),\": \"]}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:Rd(t.label_weight)})]}),a.jsxs(\"div\",{children:[a.jsxs(\"span\",{className:\"text-bambu-gray/60\",children:[s(\"inventory.weightUsed\"),\": \"]}),a.jsx(\"span\",{className:\"text-bambu-gray\",children:t.weight_used>0?Rd(t.weight_used):\"-\"})]})]}),t.note&&a.jsx(\"div\",{className:\"text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate\",title:t.note,children:t.note})]})]})}function ice({spool:t,remaining:e,pct:n,onEdit:r,onRestore:i,onArchive:s,onDelete:o,visibleColumns:l,assignmentMap:c,catalogMap:d,currencySymbol:u,dateFormat:m,t:p,onSyncWeight:f}){return a.jsxs(\"tr\",{className:`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${t.archived_at?\"opacity-50\":\"\"}`,onClick:r,children:[l.map(y=>a.jsx(\"td\",{className:\"py-3 px-4\",children:uO[y]?.({spool:t,remaining:e,pct:n,assignmentMap:c,catalogMap:d,currencySymbol:u,dateFormat:m,t:p,onSyncWeight:f})},y)),a.jsx(\"td\",{className:\"py-3 px-4\",children:a.jsxs(\"div\",{className:\"flex items-center justify-end gap-1\",onClick:y=>y.stopPropagation(),children:[a.jsx(\"button\",{onClick:r,className:\"p-1.5 text-bambu-gray hover:text-white rounded transition-colors\",title:p(\"common.edit\"),children:a.jsx(Qu,{className:\"w-4 h-4\"})}),t.archived_at?a.jsx(\"button\",{onClick:i,className:\"p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors\",title:p(\"inventory.restore\"),children:a.jsx(co,{className:\"w-4 h-4\"})}):a.jsx(\"button\",{onClick:s,className:\"p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors\",title:p(\"inventory.archive\"),children:a.jsx(so,{className:\"w-4 h-4\"})}),a.jsx(\"button\",{onClick:o,className:\"p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors\",title:p(\"common.delete\"),children:a.jsx(en,{className:\"w-4 h-4\"})})]})})]})}function Grt({spools:t,representative:e,remaining:n,pct:r,isExpanded:i,onToggle:s,onEdit:o,onArchive:l,onDelete:c,visibleColumns:d,assignmentMap:u,catalogMap:m,currencySymbol:p,dateFormat:f,t:y,onSyncWeight:v}){return a.jsxs(a.Fragment,{children:[a.jsxs(\"tr\",{className:\"border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5\",onClick:s,children:[d.map((b,g)=>a.jsx(\"td\",{className:\"py-3 px-4\",children:g===0?a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(On,{className:`w-4 h-4 text-bambu-gray transition-transform ${i?\"\":\"-rotate-90\"}`}),uO[b]?.({spool:e,remaining:n,pct:r,assignmentMap:u,catalogMap:m,currencySymbol:p,dateFormat:f,t:y,onSyncWeight:v})]}):b===\"id\"?a.jsx(\"span\",{className:\"text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full\",children:y(\"inventory.groupedSpools\",{count:t.length})}):uO[b]?.({spool:e,remaining:n,pct:r,assignmentMap:u,catalogMap:m,currencySymbol:p,dateFormat:f,t:y,onSyncWeight:v})},b)),a.jsx(\"td\",{className:\"py-3 px-4\",children:a.jsx(\"span\",{className:\"text-xs text-bambu-gray\",children:t.map(b=>`#${b.id}`).join(\", \")})})]}),i&&t.map(b=>{const g=Math.max(0,b.label_weight-b.weight_used),_=b.label_weight>0?g/b.label_weight*100:0;return a.jsx(ice,{spool:b,remaining:g,pct:_,onEdit:()=>o(b),onRestore:()=>{},onArchive:()=>l(b.id),onDelete:()=>c(b.id),visibleColumns:d,assignmentMap:u,catalogMap:m,currencySymbol:p,dateFormat:f,t:y,onSyncWeight:v},b.id)})]})}function AY({hasFilters:t,onAddSpool:e,t:n}){return a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center py-16 px-4\",children:[a.jsxs(\"div\",{className:\"relative mb-6\",children:[a.jsx(\"div\",{className:\"absolute inset-0 -m-4 bg-bambu-green/5 rounded-full blur-2xl\"}),a.jsxs(\"div\",{className:\"relative flex items-center justify-center w-24 h-24 rounded-2xl bg-gradient-to-br from-bambu-dark-secondary to-bambu-dark-tertiary border border-bambu-dark-tertiary shadow-lg\",children:[a.jsx(\"div\",{className:\"absolute -top-1 -right-1 w-3 h-3 rounded-full bg-bambu-green/30\"}),a.jsx(\"div\",{className:\"absolute -bottom-2 -left-2 w-2 h-2 rounded-full bg-bambu-green/20\"}),t?a.jsx(Pr,{className:\"w-10 h-10 text-bambu-gray/40\",strokeWidth:1.5}):a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"div\",{className:\"w-14 h-14 rounded-full border-4 border-bambu-gray/20 flex items-center justify-center\",children:a.jsx(\"div\",{className:\"w-6 h-6 rounded-full bg-bambu-gray/10 border-2 border-bambu-gray/20\"})}),a.jsx(\"div\",{className:\"absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-bambu-green flex items-center justify-center shadow-md\",children:a.jsx(\"span\",{className:\"text-white text-lg font-bold leading-none\",children:\"+\"})})]})]})]}),a.jsx(\"h3\",{className:\"text-lg font-semibold text-white mb-2 text-center\",children:t?n(\"inventory.noSpoolsMatch\"):n(\"inventory.noSpools\").split(\".\")[0]}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray text-center max-w-sm mb-6\",children:n(t?\"inventory.noSpoolsMatchDesc\":\"inventory.noSpools\")}),!t&&a.jsxs(De,{onClick:e,children:[a.jsx(Ra,{className:\"w-4 h-4\"}),n(\"inventory.addSpool\")]})]})}const jY=[\"DEBUG\",\"INFO\",\"WARNING\",\"ERROR\"],L3={DEBUG:\"text-gray-400\",INFO:\"text-blue-400\",WARNING:\"text-yellow-400\",ERROR:\"text-red-400\"},Wrt={DEBUG:Hx,INFO:Ss,WARNING:Dn,ERROR:ei};function Krt(){const t=nn(),[e,n]=w.useState(!0),[r,i]=w.useState(new Set),[s,o]=w.useState(\"\"),[l,c]=w.useState(\"ALL\"),[d,u]=w.useState(!1),[m,p]=w.useState(!1),f=w.useRef(null),{data:y,isLoading:v,refetch:b}=Xe({queryKey:[\"application-logs\",l,s],queryFn:()=>Px.getLogs({limit:200,level:l===\"ALL\"?void 0:l,search:s||void 0}),refetchInterval:m?2e3:!1,enabled:d});w.useEffect(()=>{d||p(!1)},[d]);const g=it({mutationFn:()=>Px.clearLogs(),onSuccess:()=>{t.invalidateQueries({queryKey:[\"application-logs\"]})}});w.useEffect(()=>{e&&f.current&&y?.entries&&(f.current.scrollTop=f.current.scrollHeight)},[y?.entries,e]);const _=T=>{i(F=>{const k=new Set(F);return k.has(T)?k.delete(T):k.add(T),k})},C=T=>{const F=T.split(\" \");return F.length>=2?F[1]:T},P=w.useMemo(()=>y?.entries??[],[y?.entries]),N=w.useMemo(()=>[...P].reverse(),[P]),A=({level:T})=>{const F=Wrt[T]||Ss;return a.jsx(F,{className:`w-3.5 h-3.5 ${L3[T]||\"text-gray-400\"}`})};return a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg overflow-hidden\",children:[a.jsxs(\"button\",{onClick:()=>u(!d),className:\"w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/50 transition-colors\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`p-2 rounded-lg ${m?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark-tertiary text-bambu-gray\"}`,children:a.jsx(Hx,{className:\"w-5 h-5\"})}),a.jsxs(\"div\",{className:\"text-left\",children:[a.jsx(\"p\",{className:\"font-medium text-white\",children:\"Application Logs\"}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:m?`Live streaming - ${y?.filtered_count??0} entries`:\"View and filter application logs\"})]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[m&&a.jsxs(\"span\",{className:\"flex items-center gap-1.5 px-2 py-1 bg-bambu-green/20 rounded text-bambu-green text-xs\",children:[a.jsx(\"span\",{className:\"w-1.5 h-1.5 bg-bambu-green rounded-full animate-pulse\"}),\"Live\"]}),d?a.jsx(cc,{className:\"w-5 h-5 text-bambu-gray\"}):a.jsx(On,{className:\"w-5 h-5 text-bambu-gray\"})]})]}),d&&a.jsxs(\"div\",{className:\"border-t border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 flex-wrap\",children:[m?a.jsxs(\"button\",{onClick:T=>{T.stopPropagation(),p(!1)},className:\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors\",children:[a.jsx(uo,{className:\"w-4 h-4\"}),\"Stop\"]}):a.jsxs(\"button\",{onClick:T=>{T.stopPropagation(),p(!0),b()},className:\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 rounded transition-colors\",children:[a.jsx(es,{className:\"w-4 h-4\"}),\"Start\"]}),a.jsxs(\"button\",{onClick:()=>g.mutate(),disabled:g.isPending||P.length===0,className:\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50\",children:[a.jsx(en,{className:\"w-4 h-4\"}),\"Clear\"]}),a.jsx(\"button\",{onClick:()=>b(),disabled:v,className:\"flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50\",children:a.jsx(lr,{className:`w-4 h-4 ${v?\"animate-spin\":\"\"}`})}),a.jsx(\"div\",{className:\"flex-1\"}),a.jsxs(\"label\",{className:\"flex items-center gap-2 text-sm text-bambu-gray cursor-pointer\",children:[a.jsx(\"input\",{type:\"checkbox\",checked:e,onChange:T=>n(T.target.checked),className:\"rounded border-bambu-dark-tertiary bg-bambu-dark-tertiary\"}),\"Auto-scroll\"]}),a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[y?.filtered_count??0,\"/\",y?.total_in_file??0]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"div\",{className:\"relative flex-1\",children:[a.jsx(Pr,{className:\"absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray\"}),a.jsx(\"input\",{type:\"text\",placeholder:\"Search message or logger name...\",value:s,onChange:T=>o(T.target.value),className:\"w-full pl-8 pr-8 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none\"}),s&&a.jsx(\"button\",{onClick:()=>o(\"\"),className:\"absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),a.jsxs(\"div\",{className:\"flex items-center gap-1 bg-bambu-dark-secondary rounded border border-bambu-dark-tertiary\",children:[a.jsx(\"button\",{onClick:()=>c(\"ALL\"),className:`px-2 py-1.5 text-xs rounded-l transition-colors ${l===\"ALL\"?\"bg-bambu-green text-white\":\"text-bambu-gray hover:text-white\"}`,children:\"All\"}),jY.map((T,F)=>a.jsx(\"button\",{onClick:()=>c(T),className:`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${F===jY.length-1?\"rounded-r\":\"\"} ${l===T?`${L3[T]} bg-bambu-dark-tertiary`:\"text-bambu-gray hover:text-white\"}`,children:T},T))]})]})]}),a.jsx(\"div\",{ref:f,className:\"overflow-auto font-mono text-xs bg-black min-h-[300px] max-h-[500px]\",children:P.length===0?a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center h-[300px] text-bambu-gray\",children:[a.jsx(\"p\",{className:\"mb-2\",children:\"No log entries found\"}),a.jsx(\"p\",{className:\"text-sm\",children:\"Log file may be empty or cleared\"})]}):a.jsx(\"div\",{className:\"divide-y divide-bambu-dark-tertiary/30\",children:N.map((T,F)=>{const k=r.has(F),D=T.message.includes(`\n`);return a.jsx(\"div\",{className:`p-2 cursor-pointer hover:bg-bambu-dark-secondary/50 transition-colors ${k?\"bg-bambu-dark-secondary/30\":\"\"}`,onClick:()=>D&&_(F),children:a.jsxs(\"div\",{className:\"flex items-start gap-2\",children:[a.jsx(\"span\",{className:\"text-bambu-gray/70 shrink-0 w-20\",children:C(T.timestamp)}),a.jsx(\"span\",{className:\"shrink-0\",children:a.jsx(A,{level:T.level})}),a.jsxs(\"span\",{className:\"text-purple-400/80 shrink-0 max-w-[200px] truncate\",title:T.logger_name,children:[\"[\",T.logger_name,\"]\"]}),a.jsx(\"span\",{className:`flex-1 ${L3[T.level]||\"text-white/80\"} ${!k&&D?\"truncate\":\"\"}`,children:k?a.jsx(\"pre\",{className:\"whitespace-pre-wrap break-all\",children:T.message}):T.message.split(`\n`)[0]}),D&&a.jsx(\"span\",{className:\"text-bambu-gray/50 shrink-0\",children:k?a.jsx(cc,{className:\"w-3.5 h-3.5\"}):a.jsx(On,{className:\"w-3.5 h-3.5\"})})]})},F)})})}),a.jsx(\"div\",{className:\"flex items-center justify-between p-3 border-t border-bambu-dark-tertiary text-sm text-bambu-gray\",children:m?a.jsxs(\"span\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"w-2 h-2 bg-green-500 rounded-full animate-pulse\"}),\"Auto-refreshing every 2 seconds\"]}):a.jsx(\"span\",{children:\"Click Start to enable live log streaming\"})})]})]})}function Xrt(t){return t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(1)} KB`:t<1024*1024*1024?`${(t/(1024*1024)).toFixed(1)} MB`:`${(t/(1024*1024*1024)).toFixed(2)} GB`}function Pa({icon:t,label:e,value:n,subValue:r,color:i=\"text-bambu-green\"}){return a.jsxs(\"div\",{className:\"flex items-start gap-3 p-4 bg-bambu-dark rounded-lg\",children:[a.jsx(\"div\",{className:`p-2 rounded-lg bg-bambu-dark-tertiary ${i}`,children:a.jsx(t,{className:\"w-5 h-5\"})}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:e}),a.jsx(\"p\",{className:\"text-lg font-semibold text-white truncate\",children:n}),r&&a.jsx(\"p\",{className:\"text-xs text-bambu-gray mt-0.5\",children:r})]})]})}function MY({percent:t,color:e=\"bg-bambu-green\"}){return a.jsx(\"div\",{className:\"w-full h-2 bg-bambu-dark rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:`h-full ${e} transition-all duration-300`,style:{width:`${Math.min(100,t)}%`}})})}function _h({title:t,icon:e,children:n}){return a.jsxs(Tt,{className:\"p-6\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-4\",children:[a.jsx(e,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t})]}),n]})}function Yrt(){const{t}=Ft(),e=nn(),[n,r]=w.useState(null),[i,s]=w.useState(!1),[o,l]=w.useState(!1),{data:c,isLoading:d,refetch:u,isFetching:m}=Xe({queryKey:[\"systemInfo\"],queryFn:ue.getSystemInfo,refetchInterval:3e4}),{data:p}=Xe({queryKey:[\"debugLogging\"],queryFn:Px.getDebugLoggingState,staleTime:10*1e3,refetchInterval:10*1e3}),{data:f}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),{data:y}=Xe({queryKey:[\"library-stats\"],queryFn:ue.getLibraryStats}),v=f?.time_format||\"system\",b=async()=>{l(!0);try{const P=await Px.setDebugLogging(!p?.enabled);e.setQueryData([\"debugLogging\"],P)}catch(P){console.error(\"Failed to toggle debug logging:\",P)}finally{l(!1)}},g=async()=>{r(null),s(!0);try{await Px.downloadSupportBundle()}catch(P){r(P instanceof Error?P.message:\"Failed to download support bundle\")}finally{s(!1)}};if(d)return a.jsx(\"div\",{className:\"flex items-center justify-center h-64\",children:a.jsx(ft,{className:\"w-8 h-8 text-bambu-green animate-spin\"})});if(!c)return a.jsx(\"div\",{className:\"p-6 text-center text-bambu-gray\",children:t(\"system.failedToLoad\",\"Failed to load system information\")});const _=c.storage.disk_percent_used>90?\"bg-red-500\":c.storage.disk_percent_used>75?\"bg-yellow-500\":\"bg-bambu-green\",C=c.memory.percent_used>90?\"bg-red-500\":c.memory.percent_used>75?\"bg-yellow-500\":\"bg-bambu-green\";return a.jsxs(\"div\",{className:\"p-6 space-y-6\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:t(\"system.title\",\"System Information\")}),a.jsx(\"p\",{className:\"text-bambu-gray mt-1\",children:t(\"system.subtitle\",\"Monitor system resources and database statistics\")})]}),a.jsxs(\"button\",{onClick:()=>u(),disabled:m,className:\"flex items-center gap-2 px-4 py-2 bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50\",children:[a.jsx(lr,{className:`w-4 h-4 ${m?\"animate-spin\":\"\"}`}),t(\"common.refresh\",\"Refresh\")]})]}),a.jsx(_h,{title:t(\"system.application\",\"Application\"),icon:Sf,children:a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-3 gap-4\",children:[a.jsx(Pa,{icon:Sf,label:t(\"system.version\",\"Version\"),value:`v${c.app.version}`}),a.jsx(Pa,{icon:Yn,label:t(\"system.uptime\",\"System Uptime\"),value:c.system.uptime_formatted}),a.jsx(Pa,{icon:Sf,label:t(\"system.hostname\",\"Hostname\"),value:c.system.hostname})]})}),a.jsx(_h,{title:t(\"support.title\",\"Support & Troubleshooting\"),icon:zge,children:a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"support.description\",\"Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.\")}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`p-2 rounded-lg ${p?.enabled?\"bg-amber-500/20 text-amber-500\":\"bg-bambu-dark-tertiary text-bambu-gray\"}`,children:a.jsx(Hx,{className:\"w-5 h-5\"})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"font-medium text-white\",children:t(\"support.debugLogging\",\"Debug Logging\")}),a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[p?.enabled?t(\"support.debugLoggingEnabled\",\"Capturing detailed logs\"):t(\"support.debugLoggingDisabled\",\"Normal logging level\"),p?.enabled&&p.duration_seconds!==null&&a.jsxs(\"span\",{className:\"text-amber-400 ml-2\",children:[\"(\",Math.floor(p.duration_seconds/60),\"m \",p.duration_seconds%60,\"s)\"]})]})]})]}),a.jsxs(\"button\",{onClick:b,disabled:o,className:`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${p?.enabled?\"bg-amber-500/20 text-amber-400 hover:bg-amber-500/30\":\"bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30\"} disabled:opacity-50`,children:[o&&a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),p?.enabled?t(\"support.disableDebug\",\"Disable\"):t(\"support.enableDebug\",\"Enable\")]})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green\",children:a.jsx(ga,{className:\"w-5 h-5\"})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"font-medium text-white\",children:t(\"support.supportBundle\",\"Support Bundle\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"support.supportBundleDescription\",\"Download system info and logs as a ZIP file\")})]})]}),a.jsxs(\"button\",{onClick:g,disabled:i||!p?.enabled,className:\"px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed\",title:p?.enabled?void 0:t(\"support.enableDebugFirst\",\"Enable debug logging first\"),children:[i&&a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),t(\"common.download\",\"Download\")]})]}),n&&a.jsx(\"div\",{className:\"p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm\",children:n}),!p?.enabled&&a.jsx(\"div\",{className:\"p-4 bg-bambu-dark-tertiary/50 rounded-lg\",children:a.jsxs(\"p\",{className:\"text-sm text-bambu-gray\",children:[a.jsx(\"span\",{className:\"text-amber-400 font-medium\",children:t(\"support.instructions\",\"To report an issue:\")}),a.jsx(\"br\",{}),\"1. \",t(\"support.step1\",\"Enable debug logging\"),a.jsx(\"br\",{}),\"2. \",t(\"support.step2\",\"Reproduce the issue\"),a.jsx(\"br\",{}),\"3. \",t(\"support.step3\",\"Download the support bundle\"),a.jsx(\"br\",{}),\"4. \",t(\"support.step4\",\"Attach the ZIP file to your issue report\")]})}),a.jsxs(\"div\",{className:\"p-4 bg-bambu-dark rounded-lg space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm font-medium text-white\",children:t(\"support.privacyTitle\",\"What's in the support bundle?\")}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-4 text-sm\",children:[a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-bambu-green font-medium mb-1\",children:t(\"support.collected\",\"Collected:\")}),a.jsxs(\"ul\",{className:\"text-bambu-gray space-y-0.5\",children:[a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem1\",\"App version and debug mode\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem2\",\"OS, architecture, Python version\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem3\",\"Database statistics (counts only)\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem4\",\"Printer models and nozzle counts\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem5\",\"Non-sensitive settings (themes, formats)\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem6\",\"Debug logs (sanitized)\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem7\",\"Printer connectivity and firmware versions\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem8\",\"Integration status (Spoolman, MQTT, HA)\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem9\",\"Network interfaces (subnets only)\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem10\",\"Python package versions\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem11\",\"Database health checks\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.collectItem12\",\"Docker environment details\")]})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-red-400 font-medium mb-1\",children:t(\"support.notCollected\",\"NOT collected:\")}),a.jsxs(\"ul\",{className:\"text-bambu-gray space-y-0.5\",children:[a.jsxs(\"li\",{children:[\"• \",t(\"support.notItem1\",\"Printer names and serial numbers\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.notItem2\",\"Access codes and passwords\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.notItem3\",\"Email addresses\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.notItem4\",\"API keys and tokens\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.notItem5\",\"Webhook URLs\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.notItem6\",\"Your hostname or username\")]}),a.jsxs(\"li\",{children:[\"• \",t(\"support.notItem7\",\"IP addresses\")]})]})]})]}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray/70\",children:t(\"support.privacyNote\",\"Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], serial numbers with [SERIAL], and IP addresses with [IP].\")})]}),a.jsx(Krt,{})]})}),a.jsxs(_h,{title:t(\"system.database\",\"Database\"),icon:Jl,children:[a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-4 mb-4\",children:[a.jsx(Pa,{icon:Jl,label:t(\"system.dbEngine\",\"Database Engine\"),value:c.database.engine||\"SQLite\"}),a.jsx(Pa,{icon:Jl,label:t(\"system.dbVersion\",\"Version\"),value:c.database.version||\"unknown\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 md:grid-cols-4 gap-4 mb-4\",children:[a.jsx(Pa,{icon:so,label:t(\"system.totalArchives\",\"Total Archives\"),value:c.database.archives}),a.jsx(Pa,{icon:Vc,label:t(\"system.completed\",\"Completed\"),value:c.database.archives_completed,color:\"text-green-500\"}),a.jsx(Pa,{icon:Ma,label:t(\"system.failed\",\"Failed\"),value:c.database.archives_failed,color:\"text-red-500\"}),a.jsx(Pa,{icon:ft,label:t(\"system.printing\",\"Printing\"),value:c.database.archives_printing,color:\"text-yellow-500\"})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 md:grid-cols-4 gap-4\",children:[a.jsx(Pa,{icon:Er,label:t(\"system.printers\",\"Printers\"),value:c.database.printers}),a.jsx(Pa,{icon:nS,label:t(\"system.filaments\",\"Filaments\"),value:c.database.filaments}),a.jsx(Pa,{icon:Bs,label:t(\"system.projects\",\"Projects\"),value:c.database.projects}),a.jsx(Pa,{icon:nc,label:t(\"system.smartPlugs\",\"Smart Plugs\"),value:c.database.smart_plugs})]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-4 mt-4\",children:[a.jsx(Pa,{icon:Yn,label:t(\"system.totalPrintTime\",\"Total Print Time\"),value:c.database.total_print_time_formatted}),a.jsx(Pa,{icon:so,label:t(\"system.totalFilament\",\"Total Filament Used\"),value:`${c.database.total_filament_kg} kg`,subValue:`${c.database.total_filament_grams.toLocaleString()} g`})]})]}),a.jsxs(_h,{title:t(\"system.connectedPrinters\",\"Connected Printers\"),icon:Er,children:[a.jsxs(\"div\",{className:\"flex items-center gap-4 mb-4\",children:[a.jsx(\"div\",{className:\"text-3xl font-bold text-bambu-green\",children:c.printers.connected}),a.jsx(\"div\",{className:\"text-bambu-gray\",children:t(\"system.ofTotal\",\"of {{total}} printers connected\",{total:c.printers.total})})]}),c.printers.connected_list.length>0?a.jsx(\"div\",{className:\"space-y-2\",children:c.printers.connected_list.map(P=>a.jsxs(\"div\",{className:\"flex items-center justify-between p-3 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"w-2 h-2 rounded-full bg-bambu-green\"}),a.jsx(\"span\",{className:\"font-medium text-white\",children:P.name})]}),a.jsxs(\"div\",{className:\"flex items-center gap-4 text-sm text-bambu-gray\",children:[a.jsx(\"span\",{children:P.model}),a.jsx(\"span\",{className:`px-2 py-0.5 rounded ${P.state===\"RUNNING\"?\"bg-bambu-green/20 text-bambu-green\":P.state===\"IDLE\"?\"bg-blue-500/20 text-blue-400\":\"bg-bambu-dark-tertiary\"}`,children:P.state})]})]},P.id))}):a.jsx(\"p\",{className:\"text-bambu-gray\",children:t(\"system.noPrintersConnected\",\"No printers connected\")})]}),a.jsx(_h,{title:t(\"system.storage\",\"Storage\"),icon:Ig,children:a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex justify-between text-sm mb-1\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:t(\"system.diskUsage\",\"Disk Usage\")}),a.jsxs(\"span\",{className:\"text-white\",children:[c.storage.disk_used_formatted,\" / \",c.storage.disk_total_formatted]})]}),a.jsx(MY,{percent:c.storage.disk_percent_used,color:_}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:[c.storage.disk_free_formatted,\" \",t(\"system.free\",\"free\"),\" (\",(100-c.storage.disk_percent_used).toFixed(1),\"%)\"]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-3 gap-4\",children:[a.jsx(Pa,{icon:so,label:t(\"system.archiveStorage\",\"Archive Storage\"),value:c.storage.archive_size_formatted}),a.jsx(Pa,{icon:Jl,label:t(\"system.databaseSize\",\"Database Size\"),value:c.storage.database_size_formatted}),y&&a.jsx(Pa,{icon:Qc,label:t(\"system.fileManagerStorage\",\"File Manager\"),value:Xrt(y.total_size_bytes),subValue:`${y.total_files} files, ${y.total_folders} folders`})]})]})}),a.jsxs(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-6\",children:[a.jsx(_h,{title:t(\"system.memory\",\"Memory\"),icon:bbe,children:a.jsx(\"div\",{className:\"space-y-4\",children:a.jsxs(\"div\",{children:[a.jsxs(\"div\",{className:\"flex justify-between text-sm mb-1\",children:[a.jsx(\"span\",{className:\"text-bambu-gray\",children:t(\"system.memoryUsage\",\"Memory Usage\")}),a.jsxs(\"span\",{className:\"text-white\",children:[c.memory.used_formatted,\" / \",c.memory.total_formatted]})]}),a.jsx(MY,{percent:c.memory.percent_used,color:C}),a.jsxs(\"p\",{className:\"text-xs text-bambu-gray mt-1\",children:[c.memory.available_formatted,\" \",t(\"system.available\",\"available\")]})]})})}),a.jsx(_h,{title:t(\"system.cpu\",\"CPU\"),icon:u0,children:a.jsx(\"div\",{className:\"space-y-4\",children:a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-4\",children:[a.jsx(Pa,{icon:u0,label:t(\"system.cores\",\"Cores\"),value:c.cpu.count,subValue:`${c.cpu.count_logical} logical`}),a.jsx(Pa,{icon:u0,label:t(\"system.usage\",\"Usage\"),value:`${c.cpu.percent}%`})]})})})]}),a.jsx(_h,{title:t(\"system.systemDetails\",\"System Details\"),icon:Sf,children:a.jsxs(\"div\",{className:\"grid grid-cols-2 md:grid-cols-4 gap-4\",children:[a.jsx(Pa,{icon:Sf,label:t(\"system.os\",\"Operating System\"),value:c.system.platform,subValue:c.system.platform_release}),a.jsx(Pa,{icon:u0,label:t(\"system.architecture\",\"Architecture\"),value:c.system.architecture}),a.jsx(Pa,{icon:Sf,label:t(\"system.python\",\"Python\"),value:c.system.python_version}),a.jsx(Pa,{icon:Yn,label:t(\"system.bootTime\",\"Boot Time\"),value:pg(c.system.boot_time,v)})]})})]})}function Qrt(){const t=qo(),[e]=BP(),{t:n}=Ft(),{login:r,loginWithToken:i}=kr(),{showToast:s}=hn(),{mode:o}=zg(),[l,c]=w.useState(\"\"),[d,u]=w.useState(\"\"),[m,p]=w.useState(!1),[f,y]=w.useState(\"\"),[v,b]=w.useState(\"credentials\"),[g,_]=w.useState(\"\"),[C,P]=w.useState([]),[N,A]=w.useState(\"totp\"),[T,F]=w.useState(\"\"),[k,D]=w.useState(!1),H=w.useRef(null),[z,Q]=w.useState(\"\"),[L,te]=w.useState(\"\"),[ie,J]=w.useState(\"\"),{data:oe}=Xe({queryKey:[\"advancedAuthStatus\"],queryFn:()=>ue.getAdvancedAuthStatus()}),{data:fe}=Xe({queryKey:[\"oidcProviders\"],queryFn:()=>ue.getOIDCProviders()});w.useEffect(()=>{const O=window.location.hash,K=O.startsWith(\"#reset_token=\")?O.slice(13):null;K&&(Q(K),b(\"reset-password\"),t(\"/login\",{replace:!0}))},[]),w.useEffect(()=>{const O=window.location.hash,K=O.startsWith(\"#oidc_token=\")?O.slice(12):null,U=e.get(\"oidc_error\");if(U){const I={oidc_provider_error:n(\"login.oidcErrors.providerError\"),missing_parameters:n(\"login.oidcErrors.missingParameters\"),invalid_state:n(\"login.oidcErrors.invalidState\"),state_expired:n(\"login.oidcErrors.stateExpired\"),provider_not_found:n(\"login.oidcErrors.providerNotFound\"),discovery_failed:n(\"login.oidcErrors.discoveryFailed\"),invalid_discovery_document:n(\"login.oidcErrors.invalidDiscovery\"),token_exchange_network_error:n(\"login.oidcErrors.networkError\"),token_exchange_bad_response:n(\"login.oidcErrors.badResponse\"),no_id_token:n(\"login.oidcErrors.noIdToken\"),token_validation_failed:n(\"login.oidcErrors.validationFailed\"),nonce_mismatch:n(\"login.oidcErrors.nonceMismatch\"),missing_sub_claim:n(\"login.oidcErrors.missingSubClaim\"),no_linked_account:n(\"login.oidcErrors.noLinkedAccount\"),account_inactive:n(\"login.oidcErrors.accountInactive\"),user_resolution_failed:n(\"login.oidcErrors.userResolutionFailed\"),internal_error:n(\"login.oidcErrors.internalError\")}[U]??(U.startsWith(\"token_exchange_\")?n(\"login.oidcErrors.tokenExchangeFailed\"):n(\"login.oidcLoginFailed\"));s(I,\"error\"),t(\"/login\",{replace:!0});return}K&&ue.exchangeOIDCToken(K).then(de=>{if(de.requires_2fa&&de.pre_auth_token){_(de.pre_auth_token);const I=de.two_fa_methods??[];P(I),I.includes(\"totp\")?A(\"totp\"):I.includes(\"email\")?A(\"email\"):A(\"backup\"),b(\"2fa\"),t(\"/login\",{replace:!0})}else de.access_token&&de.user&&(i(de.access_token,de.user),s(n(\"login.loginSuccess\")),t(\"/\",{replace:!0}))}).catch(de=>{s(de.message||n(\"login.oidcLoginFailed\"),\"error\"),t(\"/login\",{replace:!0})})},[e]);const re=it({mutationFn:()=>r(l,d),onSuccess:O=>{if(O.requires_2fa&&O.pre_auth_token){_(O.pre_auth_token);const K=O.two_fa_methods??[];P(K),K.includes(\"totp\")?A(\"totp\"):K.includes(\"email\")?A(\"email\"):A(\"backup\"),b(\"2fa\")}else O.access_token&&O.user&&(s(n(\"login.loginSuccess\")),t(\"/\"))},onError:O=>{s(O.message||n(\"login.loginFailed\"),\"error\")}}),W=it({mutationFn:O=>ue.forgotPassword({email:O}),onSuccess:O=>{s(O.message,\"success\"),p(!1),y(\"\")},onError:O=>{s(O.message,\"error\")}}),ne=it({mutationFn:()=>ue.forgotPasswordConfirm(z,L),onSuccess:O=>{s(O.message,\"success\"),b(\"credentials\"),Q(\"\"),te(\"\"),J(\"\")},onError:O=>{s(O.message||n(\"login.resetPassword.resetFailed\"),\"error\")}}),me=it({mutationFn:()=>ue.sendEmailOTP(g),onSuccess:O=>{D(!0),O.pre_auth_token&&_(O.pre_auth_token),s(O.message,\"success\")},onError:O=>{s(O.message||n(\"login.twoFA.sendCodeFailed\"),\"error\")}}),be=it({mutationFn:()=>ue.verify2FA({pre_auth_token:g,code:T,method:N}),onSuccess:O=>{O.access_token&&O.user&&(i(O.access_token,O.user),s(n(\"login.loginSuccess\")),t(\"/\"))},onError:O=>{s(O.message||n(\"login.twoFA.invalidCode\"),\"error\"),F(\"\")}}),Ce=it({mutationFn:O=>ue.getOIDCAuthorizeUrl(O),onSuccess:O=>{window.location.href=O.auth_url},onError:O=>{s(O.message||n(\"login.oidcLoginFailed\"),\"error\")}}),q=O=>{if(O.preventDefault(),!l||!d){s(n(\"login.enterCredentials\"),\"error\");return}re.mutate()},Y=O=>{if(O.preventDefault(),!T.trim()){s(n(\"login.twoFA.enterCode\"),\"error\");return}be.mutate()},E=O=>{if(O.preventDefault(),!f){s(n(\"login.enterEmail\"),\"error\");return}W.mutate(f)},j=O=>{A(O),F(\"\"),D(!1),setTimeout(()=>H.current?.focus(),0)};if(v===\"reset-password\"){const O=K=>{if(K.preventDefault(),L!==ie){s(n(\"login.resetPassword.passwordsDoNotMatch\"),\"error\");return}if(L.length<8){s(n(\"login.resetPassword.passwordTooShort\"),\"error\");return}ne.mutate()};return a.jsx(\"div\",{className:\"min-h-screen flex items-center justify-center bg-bambu-dark p-4\",children:a.jsxs(\"div\",{className:\"max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg\",children:[a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(\"div\",{className:\"flex items-center justify-center mb-4\",children:a.jsx(\"div\",{className:\"w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center\",children:a.jsx(no,{className:\"w-7 h-7 text-bambu-green\"})})}),a.jsx(\"h2\",{className:\"text-2xl font-bold text-white\",children:n(\"login.resetPassword.title\")}),a.jsx(\"p\",{className:\"mt-2 text-sm text-bambu-gray\",children:n(\"login.resetPassword.subtitle\")})]}),a.jsxs(\"form\",{onSubmit:O,className:\"space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{htmlFor:\"new-password\",className:\"block text-sm font-medium text-white mb-2\",children:n(\"login.resetPassword.newPassword\")}),a.jsx(\"input\",{id:\"new-password\",type:\"password\",required:!0,value:L,onChange:K=>te(K.target.value),className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:n(\"login.resetPassword.newPasswordPlaceholder\"),autoFocus:!0,autoComplete:\"new-password\",minLength:8})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{htmlFor:\"confirm-password\",className:\"block text-sm font-medium text-white mb-2\",children:n(\"login.resetPassword.confirmPassword\")}),a.jsx(\"input\",{id:\"confirm-password\",type:\"password\",required:!0,value:ie,onChange:K=>J(K.target.value),className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:n(\"login.resetPassword.confirmPasswordPlaceholder\"),autoComplete:\"new-password\"})]}),a.jsx(\"button\",{type:\"submit\",disabled:ne.isPending||!L||!ie,className:\"w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed\",children:ne.isPending?n(\"login.resetPassword.saving\"):n(\"login.resetPassword.submit\")})]}),a.jsx(\"div\",{className:\"text-center\",children:a.jsx(\"button\",{type:\"button\",onClick:()=>{b(\"credentials\"),Q(\"\"),te(\"\"),J(\"\")},className:\"text-sm text-bambu-gray hover:text-bambu-green transition-colors\",children:n(\"login.resetPassword.backToLogin\")})})]})})}return v===\"2fa\"?a.jsx(\"div\",{className:\"min-h-screen flex items-center justify-center bg-bambu-dark p-4\",children:a.jsxs(\"div\",{className:\"max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg\",children:[a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(\"div\",{className:\"flex items-center justify-center mb-4\",children:a.jsx(\"div\",{className:\"w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center\",children:a.jsx(Gu,{className:\"w-7 h-7 text-bambu-green\"})})}),a.jsx(\"h2\",{className:\"text-2xl font-bold text-white\",children:n(\"login.twoFA.title\")}),a.jsx(\"p\",{className:\"mt-2 text-sm text-bambu-gray\",children:n(\"login.twoFA.subtitle\")})]}),C.length>1&&a.jsxs(\"div\",{className:\"flex gap-2\",children:[C.includes(\"totp\")&&a.jsxs(\"button\",{type:\"button\",onClick:()=>j(\"totp\"),className:`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${N===\"totp\"?\"border-bambu-green bg-bambu-green/10 text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50\"}`,children:[a.jsx(cF,{className:\"w-4 h-4\"}),n(\"login.twoFA.methodAuthenticator\")]}),C.includes(\"email\")&&a.jsxs(\"button\",{type:\"button\",onClick:()=>j(\"email\"),className:`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${N===\"email\"?\"border-bambu-green bg-bambu-green/10 text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50\"}`,children:[a.jsx(bm,{className:\"w-4 h-4\"}),n(\"login.twoFA.methodEmail\")]}),C.includes(\"backup\")&&a.jsxs(\"button\",{type:\"button\",onClick:()=>j(\"backup\"),className:`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${N===\"backup\"?\"border-bambu-green bg-bambu-green/10 text-bambu-green\":\"border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50\"}`,children:[a.jsx(no,{className:\"w-4 h-4\"}),n(\"login.twoFA.methodBackup\")]})]}),a.jsxs(\"form\",{onSubmit:Y,className:\"space-y-4\",children:[N===\"totp\"&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"login.twoFA.instructionsTotp\")}),N===\"email\"&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(k?\"login.twoFA.instructionsEmail\":\"login.twoFA.instructionsEmailNotSent\")}),!k&&a.jsx(De,{type:\"button\",variant:\"secondary\",className:\"w-full\",onClick:()=>me.mutate(),disabled:me.isPending,children:me.isPending?n(\"login.twoFA.sendingCode\"):n(\"login.twoFA.sendCodeButton\")}),k&&a.jsx(\"button\",{type:\"button\",onClick:()=>{D(!1),me.mutate()},className:\"text-xs text-bambu-gray hover:text-bambu-green transition-colors\",children:n(\"login.twoFA.resendCode\")})]}),N===\"backup\"&&a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:n(\"login.twoFA.instructionsBackup\")}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{htmlFor:\"twofa-code\",className:\"block text-sm font-medium text-white mb-2\",children:n(N===\"backup\"?\"login.twoFA.backupCodeLabel\":\"login.twoFA.codeLabel\")}),a.jsx(\"input\",{ref:H,id:\"twofa-code\",type:\"text\",inputMode:N===\"backup\"?\"text\":\"numeric\",autoComplete:\"one-time-code\",value:T,onChange:O=>F(O.target.value.trim()),disabled:N===\"email\"&&!k,className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-center tracking-widest text-xl font-mono focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-40\",placeholder:n(N===\"backup\"?\"login.twoFA.backupCodePlaceholder\":\"login.twoFA.codePlaceholder\"),maxLength:N===\"backup\"?8:6,autoFocus:!0})]}),a.jsx(\"button\",{type:\"submit\",disabled:be.isPending||!T.trim()||N===\"email\"&&!k,className:\"w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed\",children:be.isPending?n(\"login.twoFA.verifyingButton\"):n(\"login.twoFA.verifyButton\")})]}),a.jsx(\"div\",{className:\"text-center\",children:a.jsx(\"button\",{type:\"button\",onClick:()=>{b(\"credentials\"),_(\"\"),F(\"\"),D(!1)},className:\"text-sm text-bambu-gray hover:text-bambu-green transition-colors\",children:n(\"login.twoFA.backToLogin\")})})]})}):a.jsxs(\"div\",{className:\"min-h-screen flex items-center justify-center bg-bambu-dark p-4\",children:[a.jsxs(\"div\",{className:\"max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg\",children:[a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(\"div\",{className:\"flex items-center justify-center mb-6\",children:a.jsx(\"img\",{src:o===\"dark\"?\"/img/bambuddy_logo_dark_transparent.png\":\"/img/bambuddy_logo_light.png\",alt:\"Bambuddy\",className:\"h-16\"})}),a.jsx(\"h2\",{className:\"text-3xl font-bold text-white\",children:n(\"login.title\")}),a.jsx(\"p\",{className:\"mt-2 text-sm text-bambu-gray\",children:n(\"login.subtitle\")})]}),a.jsxs(\"form\",{className:\"mt-8 space-y-6\",onSubmit:q,children:[a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{htmlFor:\"username\",className:\"block text-sm font-medium text-white mb-2\",children:oe?.advanced_auth_enabled?n(\"login.usernameOrEmail\"):n(\"login.username\")}),a.jsx(\"input\",{id:\"username\",type:\"text\",required:!0,value:l,onChange:O=>c(O.target.value),className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:oe?.advanced_auth_enabled?n(\"login.usernameOrEmailPlaceholder\"):n(\"login.usernamePlaceholder\"),autoComplete:\"username\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{htmlFor:\"password\",className:\"block text-sm font-medium text-white mb-2\",children:n(\"login.password\")||\"Password\"}),a.jsx(\"input\",{id:\"password\",type:\"password\",required:!0,value:d,onChange:O=>u(O.target.value),className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:n(\"login.passwordPlaceholder\"),autoComplete:\"current-password\"})]})]}),a.jsx(\"div\",{children:a.jsx(\"button\",{type:\"submit\",disabled:re.isPending,className:\"w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green\",children:re.isPending?n(\"login.signingIn\"):n(\"login.signIn\")})}),a.jsx(\"div\",{className:\"text-center\",children:a.jsx(\"button\",{type:\"button\",onClick:()=>p(!0),className:\"text-sm text-bambu-gray hover:text-bambu-green transition-colors\",children:n(\"login.forgotPassword\")})})]}),fe&&fe.length>0&&a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"div\",{className:\"absolute inset-0 flex items-center\",children:a.jsx(\"div\",{className:\"w-full border-t border-bambu-dark-tertiary\"})}),a.jsx(\"div\",{className:\"relative flex justify-center text-sm\",children:a.jsx(\"span\",{className:\"px-2 bg-bambu-dark-secondary text-bambu-gray\",children:n(\"login.twoFA.orContinueWith\")})})]}),a.jsx(\"div\",{className:\"space-y-2\",children:fe.map(O=>a.jsxs(\"button\",{type:\"button\",onClick:()=>Ce.mutate(O.id),disabled:Ce.isPending,className:\"w-full flex items-center justify-center gap-3 py-3 px-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green/50 rounded-lg text-white font-medium transition-colors disabled:opacity-50\",children:[O.icon_url?a.jsx(\"img\",{src:O.icon_url,alt:\"\",className:\"w-5 h-5 object-contain\"}):a.jsx(Gu,{className:\"w-5 h-5 text-bambu-green\"}),n(\"login.twoFA.signInWith\",{provider:O.name})]},O.id))})]})]}),m&&a.jsx(\"div\",{className:\"fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4\",onClick:()=>p(!1),children:a.jsxs(Tt,{className:\"w-full max-w-md\",onClick:O=>O.stopPropagation(),children:[a.jsx(gn,{children:a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(bm,{className:\"w-5 h-5 text-bambu-green\"}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:n(\"login.forgotPasswordTitle\")})]}),a.jsx(De,{variant:\"ghost\",size:\"sm\",onClick:()=>{p(!1),y(\"\")},children:a.jsx(Ht,{className:\"w-5 h-5\"})})]})}),a.jsx(Mt,{children:oe?.advanced_auth_enabled?a.jsxs(\"form\",{onSubmit:E,className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-bambu-gray text-sm\",children:n(\"login.forgotPasswordEmailMessage\")}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{htmlFor:\"forgot-email\",className:\"block text-sm font-medium text-white mb-2\",children:n(\"login.emailAddress\")}),a.jsx(\"input\",{id:\"forgot-email\",type:\"email\",required:!0,value:f,onChange:O=>y(O.target.value),className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:n(\"login.emailPlaceholder\")})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(De,{type:\"button\",variant:\"secondary\",className:\"flex-1\",onClick:()=>{p(!1),y(\"\")},children:n(\"login.cancel\")}),a.jsx(De,{type:\"submit\",className:\"flex-1\",disabled:W.isPending,children:W.isPending?n(\"login.sending\"):n(\"login.sendResetEmail\")})]})]}):a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(\"p\",{className:\"text-bambu-gray\",children:n(\"login.forgotPasswordMessage\")}),a.jsxs(\"div\",{className:\"bg-bambu-dark rounded-lg p-4 space-y-2\",children:[a.jsx(\"p\",{className:\"text-sm text-white font-medium\",children:n(\"login.howToReset\")}),a.jsxs(\"ol\",{className:\"text-sm text-bambu-gray space-y-1 list-decimal list-inside\",children:[a.jsx(\"li\",{children:n(\"login.resetStep1\")}),a.jsx(\"li\",{children:n(\"login.resetStep2\")}),a.jsx(\"li\",{children:n(\"login.resetStep3\")}),a.jsx(\"li\",{children:n(\"login.resetStep4\")})]})]}),a.jsx(De,{variant:\"secondary\",className:\"w-full\",onClick:()=>p(!1),children:n(\"login.gotIt\")})]})})]})})]})}function Zrt(){const t=qo(),{t:e}=Ft(),{showToast:n}=hn(),{mode:r}=zg(),{refreshAuth:i}=kr(),[s,o]=w.useState(!1),[l,c]=w.useState(\"\"),[d,u]=w.useState(\"\"),[m,p]=w.useState(\"\"),f=it({mutationFn:()=>ue.setupAuth({auth_enabled:s,admin_username:s?l:void 0,admin_password:s?d:void 0}),onSuccess:async v=>{await i(),v.auth_enabled?v.admin_created?(n(e(\"setup.toast.authEnabledAdminCreated\")),t(\"/login\")):(n(e(\"setup.toast.authEnabledExistingAdmins\")),t(\"/login\")):(n(e(\"setup.toast.setupCompleted\")),t(\"/\"))},onError:v=>{n(v.message,\"error\")}}),y=v=>{if(v.preventDefault(),s&&(l||d)){if(!l||!d){n(e(\"setup.toast.enterBothCredentials\"),\"error\");return}if(d!==m){n(e(\"setup.toast.passwordsDoNotMatch\"),\"error\");return}if(d.length<6){n(e(\"setup.toast.passwordTooShort\"),\"error\");return}}f.mutate()};return a.jsx(\"div\",{className:\"min-h-screen flex items-center justify-center bg-bambu-dark p-4\",children:a.jsxs(\"div\",{className:\"max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg\",children:[a.jsxs(\"div\",{className:\"text-center\",children:[a.jsx(\"div\",{className:\"flex items-center justify-center mb-6\",children:a.jsx(\"img\",{src:r===\"dark\"?\"/img/bambuddy_logo_dark_transparent.png\":\"/img/bambuddy_logo_light.png\",alt:\"Bambuddy\",className:\"h-16\"})}),a.jsx(\"h2\",{className:\"text-3xl font-bold text-white\",children:e(\"setup.title\")}),a.jsx(\"p\",{className:\"mt-2 text-sm text-bambu-gray\",children:e(\"setup.subtitle\")})]}),a.jsxs(\"form\",{className:\"mt-8 space-y-6\",onSubmit:y,children:[a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"flex items-center p-4 bg-bambu-dark-secondary/50 rounded-lg border border-bambu-dark-tertiary\",children:[a.jsx(\"input\",{id:\"auth-enabled\",type:\"checkbox\",checked:s,onChange:v=>o(v.target.checked),className:\"h-4 w-4 text-bambu-green focus:ring-bambu-green border-bambu-dark-tertiary rounded bg-bambu-dark-secondary\"}),a.jsx(\"label\",{htmlFor:\"auth-enabled\",className:\"ml-3 block text-sm font-medium text-white\",children:e(\"setup.enableAuth\")})]}),s&&a.jsxs(\"div\",{className:\"space-y-4 mt-4\",children:[a.jsx(\"div\",{className:\"p-3 bg-bambu-dark-secondary/50 border border-bambu-dark-tertiary rounded-lg\",children:a.jsxs(\"div\",{className:\"flex items-start gap-2\",children:[a.jsx(Ss,{className:\"w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0\"}),a.jsxs(\"div\",{className:\"text-sm text-bambu-gray\",children:[a.jsx(\"p\",{className:\"text-white font-medium mb-1\",children:e(\"setup.adminAccount\")}),a.jsx(\"p\",{children:e(\"setup.adminAccountDesc\")})]})]})}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{htmlFor:\"admin-username\",className:\"block text-sm font-medium text-white mb-2\",children:[e(\"setup.adminUsername\"),\" \",a.jsx(\"span\",{className:\"text-bambu-gray text-xs\",children:e(\"setup.optionalIfAdminExists\")})]}),a.jsx(\"input\",{id:\"admin-username\",type:\"text\",value:l,onChange:v=>c(v.target.value),className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:e(\"setup.adminUsernamePlaceholder\"),autoComplete:\"username\"})]}),a.jsxs(\"div\",{children:[a.jsxs(\"label\",{htmlFor:\"admin-password\",className:\"block text-sm font-medium text-white mb-2\",children:[e(\"setup.adminPassword\"),\" \",a.jsx(\"span\",{className:\"text-bambu-gray text-xs\",children:e(\"setup.optionalIfAdminExists\")})]}),a.jsx(\"input\",{id:\"admin-password\",type:\"password\",value:d,onChange:v=>u(v.target.value),className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:e(\"setup.adminPasswordPlaceholder\"),minLength:6,autoComplete:\"new-password\"})]}),d&&a.jsxs(\"div\",{children:[a.jsx(\"label\",{htmlFor:\"confirm-password\",className:\"block text-sm font-medium text-white mb-2\",children:e(\"setup.confirmPassword\")}),a.jsx(\"input\",{id:\"confirm-password\",type:\"password\",value:m,onChange:v=>p(v.target.value),className:\"block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors\",placeholder:e(\"setup.confirmPasswordPlaceholder\"),minLength:6,autoComplete:\"new-password\"})]})]})]}),a.jsx(\"div\",{children:a.jsx(\"button\",{type:\"submit\",disabled:f.isPending,className:\"w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green\",children:f.isPending?e(\"setup.settingUp\"):e(\"setup.completeSetup\")})})]})]})})}function Jrt(){const{t}=Ft(),{user:e}=kr(),{showToast:n}=hn(),r=nn(),i=qo(),[s,o]=w.useState(!0),[l,c]=w.useState(!0),[d,u]=w.useState(!0),[m,p]=w.useState(!0),[f,y]=w.useState(!1),{data:v,isLoading:b}=Xe({queryKey:[\"advancedAuthStatus\"],queryFn:ue.getAdvancedAuthStatus,staleTime:300*1e3}),{data:g,isLoading:_}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings,staleTime:300*1e3}),{data:C,isLoading:P}=Xe({queryKey:[\"user-email-preferences\"],queryFn:()=>ue.getUserEmailPreferences()});w.useEffect(()=>{(v&&!v.advanced_auth_enabled||g&&!g.user_notifications_enabled)&&i(\"/settings\",{replace:!0})},[v,g,i]),w.useEffect(()=>{C&&(o(C.notify_print_start),c(C.notify_print_complete),u(C.notify_print_failed),p(C.notify_print_stopped),y(!1))},[C]);const N=it({mutationFn:()=>ue.updateUserEmailPreferences({notify_print_start:s,notify_print_complete:l,notify_print_failed:d,notify_print_stopped:m}),onSuccess:()=>{r.invalidateQueries({queryKey:[\"user-email-preferences\"]}),y(!1),n(t(\"notifications.userEmail.saveSuccess\"),\"success\")},onError:T=>{n(T.message||t(\"notifications.userEmail.saveError\"),\"error\")}}),A=(T,F)=>{T(!F),y(!0)};return P||b||_?a.jsx(\"div\",{className:\"flex items-center justify-center h-64\",children:a.jsx(ft,{className:\"w-8 h-8 animate-spin text-bambu-green\"})}):a.jsxs(\"div\",{className:\"p-4 md:p-6 max-w-2xl mx-auto\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-6\",children:[a.jsx(Zh,{className:\"w-7 h-7 text-bambu-green\"}),a.jsx(\"h1\",{className:\"text-2xl font-bold text-white\",children:t(\"notifications.userEmail.title\")})]}),a.jsx(Tt,{className:\"mb-6 border-blue-500/30 bg-blue-500/5\",children:a.jsx(Mt,{className:\"py-4\",children:a.jsxs(\"div\",{className:\"flex items-start gap-3\",children:[a.jsx(\"div\",{className:\"w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0\",children:a.jsx(bm,{className:\"w-5 h-5 text-blue-400\"})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-white font-medium\",children:t(\"notifications.userEmail.emailNotifications\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:t(\"notifications.userEmail.emailNotificationsDesc\")}),e?.email?a.jsxs(\"p\",{className:\"text-sm text-blue-400 mt-2\",children:[t(\"notifications.userEmail.sendingTo\"),\": \",a.jsx(\"strong\",{children:e.email})]}):a.jsx(\"p\",{className:\"text-sm text-yellow-400 mt-2\",children:t(\"notifications.userEmail.noEmailWarning\")})]})]})})}),a.jsxs(Tt,{className:\"mb-6\",children:[a.jsxs(gn,{children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:t(\"notifications.userEmail.printJobNotifications\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray mt-1\",children:t(\"notifications.userEmail.printJobNotificationsDesc\")})]}),a.jsxs(Mt,{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`w-10 h-10 rounded-full flex items-center justify-center ${s?\"bg-bambu-green/20\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(Vc,{className:`w-5 h-5 ${s?\"text-bambu-green\":\"text-bambu-gray\"}`})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"notifications.userEmail.printJobStarts\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"notifications.userEmail.printJobStartsDesc\")})]})]}),a.jsx(\"button\",{onClick:()=>A(o,s),className:`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${s?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,role:\"switch\",\"aria-checked\":s,children:a.jsx(\"span\",{className:`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${s?\"translate-x-6\":\"translate-x-1\"}`})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`w-10 h-10 rounded-full flex items-center justify-center ${l?\"bg-bambu-green/20\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(Vc,{className:`w-5 h-5 ${l?\"text-bambu-green\":\"text-bambu-gray\"}`})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"notifications.userEmail.printJobFinishes\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"notifications.userEmail.printJobFinishesDesc\")})]})]}),a.jsx(\"button\",{onClick:()=>A(c,l),className:`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${l?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,role:\"switch\",\"aria-checked\":l,children:a.jsx(\"span\",{className:`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${l?\"translate-x-6\":\"translate-x-1\"}`})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`w-10 h-10 rounded-full flex items-center justify-center ${d?\"bg-bambu-green/20\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(Vc,{className:`w-5 h-5 ${d?\"text-bambu-green\":\"text-bambu-gray\"}`})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"notifications.userEmail.printErrors\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"notifications.userEmail.printErrorsDesc\")})]})]}),a.jsx(\"button\",{onClick:()=>A(u,d),className:`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${d?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,role:\"switch\",\"aria-checked\":d,children:a.jsx(\"span\",{className:`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${d?\"translate-x-6\":\"translate-x-1\"}`})})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 bg-bambu-dark rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`w-10 h-10 rounded-full flex items-center justify-center ${m?\"bg-bambu-green/20\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(Vc,{className:`w-5 h-5 ${m?\"text-bambu-green\":\"text-bambu-gray\"}`})}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:t(\"notifications.userEmail.printJobStops\")}),a.jsx(\"p\",{className:\"text-sm text-bambu-gray\",children:t(\"notifications.userEmail.printJobStopsDesc\")})]})]}),a.jsx(\"button\",{onClick:()=>A(p,m),className:`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${m?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,role:\"switch\",\"aria-checked\":m,children:a.jsx(\"span\",{className:`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${m?\"translate-x-6\":\"translate-x-1\"}`})})]})]})]}),a.jsx(\"div\",{className:\"flex justify-end\",children:a.jsx(De,{onClick:()=>N.mutate(),disabled:!f||N.isPending||!e?.email,children:N.isPending?a.jsxs(a.Fragment,{children:[a.jsx(ft,{className:\"w-4 h-4 animate-spin\"}),t(\"common.saving\")]}):a.jsxs(a.Fragment,{children:[a.jsx(_s,{className:\"w-4 h-4\"}),t(\"common.save\")]})})})]})}function eat(){const t=w.useRef(null),e=w.useRef(null),n=nn(),[r,i]=w.useState(!1),s=w.useRef(new Map),{showToast:o}=hn(),{t:l}=Ft(),c=w.useRef(new Set),d=w.useRef(null),u=w.useRef(new Map),m=w.useRef(null),p=w.useRef([]),f=w.useRef(!1),y=w.useRef(()=>{}),v=w.useCallback(()=>{if(f.current||p.current.length===0)return;f.current=!0;const N=()=>{const A=p.current.shift();A?requestAnimationFrame(()=>{y.current(A),p.current.length>0?setTimeout(N,16):f.current=!1}):f.current=!1};N()},[]),b=w.useCallback(()=>{if(t.current?.readyState===WebSocket.OPEN)return;const A=`${window.location.protocol===\"https:\"?\"wss:\":\"ws:\"}//${window.location.host}/api/v1/ws`,T=new WebSocket(A);let F=null;T.onopen=()=>{console.log(\"[WebSocket] Connected\"),i(!0),F=window.setInterval(()=>{T.readyState===WebSocket.OPEN&&T.send(JSON.stringify({type:\"ping\"}))},3e4)},T.onmessage=k=>{try{const D=JSON.parse(k.data);D.type===\"printer_status\"&&D.printer_id!==void 0&&D.data?y.current(D):(p.current.push(D),v())}catch{}},T.onclose=k=>{console.log(\"[WebSocket] Closed\",k.code,k.reason),F&&(clearInterval(F),F=null),i(!1),t.current=null,e.current=window.setTimeout(()=>{b()},3e3)},T.onerror=k=>{console.error(\"[WebSocket] Error\",k),T.close()},t.current=T},[v]),g=w.useCallback((N,A)=>{const T=u.current.get(N)||{};u.current.set(N,{...T,...A}),m.current||(m.current=window.setTimeout(()=>{const F=new Map(u.current);u.current.clear(),m.current=null,requestAnimationFrame(()=>{F.forEach((k,D)=>{n.setQueryData([\"printerStatus\",D],H=>{const z={...H,...k};return z.wifi_signal==null&&H?.wifi_signal!=null&&(z.wifi_signal=H.wifi_signal),z})})})},100))},[n]),_=w.useCallback(N=>{c.current.add(N),d.current&&clearTimeout(d.current),d.current=window.setTimeout(()=>{const A=Array.from(c.current);c.current.clear(),d.current=null;let T=0;A.forEach(F=>{setTimeout(()=>{requestAnimationFrame(()=>{n.invalidateQueries({queryKey:[F]})})},T),T+=500})},3e3)},[n]),C=w.useCallback(N=>{switch(N.type){case\"printer_status\":N.printer_id!==void 0&&N.data&&g(N.printer_id,N.data);break;case\"print_start\":N.printer_id!==void 0&&n.invalidateQueries({queryKey:[\"printerStatus\",N.printer_id]});break;case\"missing_spool_assignment\":{if(N.printer_id===void 0||!Array.isArray(N.missing_slots))break;const A=N.missing_slots.map(D=>D&&typeof D.slot==\"string\"?D.slot:\"Unknown\").filter(D=>D.length>0);if(A.length===0){s.current.delete(N.printer_id);break}const T=A.join(\"|\");if(s.current.get(N.printer_id)===T)break;s.current.set(N.printer_id,T);const F=N.printer_name||`Printer ${N.printer_id}`,k=l(\"printers.toast.missingSpoolAssignment\",{printer:F,slots:A.join(\", \")});o(k,\"warning\");break}case\"print_complete\":_(\"archives\"),_(\"archiveStats\");break;case\"archive_created\":_(\"archives\"),_(\"archiveStats\");break;case\"archive_updated\":_(\"archives\");break;case\"pong\":break;case\"plate_not_empty\":window.dispatchEvent(new CustomEvent(\"plate-not-empty\",{detail:{printer_id:N.printer_id,printer_name:N.printer_name,message:N.message}}));break;case\"inventory_changed\":_(\"inventory-spools\");break;case\"spool_assignment_changed\":_(\"spool-assignments\"),_(\"slotPresets\");break;case\"spool_auto_assigned\":_(\"inventory-spools\"),_(\"spool-assignments\");break;case\"spool_usage_logged\":_(\"inventory-spools\");break;case\"unknown_tag\":window.dispatchEvent(new CustomEvent(\"unknown-tag\",{detail:{printer_id:N.printer_id,ams_id:N.ams_id,tray_id:N.tray_id,tag_uid:N.tag_uid,tray_uuid:N.tray_uuid}}));break;case\"background_dispatch\":window.dispatchEvent(new CustomEvent(\"background-dispatch\",{detail:N.data||{}}));break;case\"spoolbuddy_weight\":window.dispatchEvent(new CustomEvent(\"spoolbuddy-weight\",{detail:N}));break;case\"spoolbuddy_tag_matched\":window.dispatchEvent(new CustomEvent(\"spoolbuddy-tag-matched\",{detail:N})),_(\"inventory-spools\");break;case\"spoolbuddy_unknown_tag\":window.dispatchEvent(new CustomEvent(\"spoolbuddy-unknown-tag\",{detail:N}));break;case\"spoolbuddy_tag_removed\":window.dispatchEvent(new CustomEvent(\"spoolbuddy-tag-removed\",{detail:N}));break;case\"spoolbuddy_tag_written\":window.dispatchEvent(new CustomEvent(\"spoolbuddy-tag-written\",{detail:N})),_(\"inventory-spools\");break;case\"spoolbuddy_tag_write_failed\":window.dispatchEvent(new CustomEvent(\"spoolbuddy-tag-write-failed\",{detail:N}));break;case\"spoolbuddy_online\":window.dispatchEvent(new CustomEvent(\"spoolbuddy-online\",{detail:N})),_(\"spoolbuddy-devices\"),_(\"spoolbuddy-update-check\");break;case\"spoolbuddy_offline\":window.dispatchEvent(new CustomEvent(\"spoolbuddy-offline\",{detail:N})),_(\"spoolbuddy-devices\");break;case\"spoolbuddy_update\":_(\"spoolbuddy-devices\"),_(\"spoolbuddy-update-check\");break}},[n,_,g,o,l]);w.useEffect(()=>{y.current=C},[C]),w.useEffect(()=>(b(),()=>{e.current&&clearTimeout(e.current),d.current&&clearTimeout(d.current),m.current&&clearTimeout(m.current),t.current&&t.current.close()}),[b]);const P=w.useCallback(N=>{t.current?.readyState===WebSocket.OPEN&&t.current.send(JSON.stringify(N))},[]);return{isConnected:r,sendMessage:P}}function tat({children:t}){const{authEnabled:e,user:n,loading:r}=kr(),i=!r&&(!e||n!==null),{data:s}=Xe({queryKey:[\"color-catalog-map\"],queryFn:async()=>(await ue.getColorNameMap()).colors,staleTime:1/0,gcTime:1/0,retry:2,enabled:i});return w.useEffect(()=>{s&&_ye(s)},[s]),a.jsx(a.Fragment,{children:t})}function nat({selectedPrinterId:t,onPrinterChange:e,deviceOnline:n}){const{t:r}=Ft(),[i,s]=w.useState(new Date),{data:o=[]}=Xe({queryKey:[\"printers\"],queryFn:()=>ue.getPrinters()}),l=Pp({queries:o.map(u=>({queryKey:[\"printerStatus\",u.id],queryFn:()=>ue.getPrinterStatus(u.id),refetchInterval:1e4}))}),c=w.useMemo(()=>o.filter((u,m)=>l[m]?.data?.connected),[o,l]);w.useEffect(()=>{const u=c.some(m=>m.id===t);(!t||!u)&&c.length>0&&e(c[0].id)},[c,t,e]);const{data:d}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings});return w.useEffect(()=>{const u=setInterval(()=>s(new Date),1e3);return()=>clearInterval(u)},[]),a.jsxs(\"div\",{className:\"h-12 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0\",children:[a.jsx(\"div\",{className:\"flex items-center shrink-0\",children:a.jsx(\"img\",{src:\"/img/spoolbuddy_logo_dark_small.png\",alt:\"SpoolBuddy\",width:113,height:28,className:\"h-7 w-auto\"})}),a.jsx(\"div\",{className:\"flex-1 flex justify-center\",children:a.jsx(\"select\",{value:t??\"\",onChange:u=>e(Number(u.target.value)),className:\"bg-bambu-dark text-white text-base px-4 py-2 rounded border border-bambu-dark-tertiary focus:outline-none focus:border-bambu-green min-w-[180px]\",children:c.length===0?a.jsx(\"option\",{value:\"\",children:r(\"spoolbuddy.status.noPrinters\",\"No printers online\")}):c.map(u=>a.jsx(\"option\",{value:u.id,children:u.name},u.id))})}),a.jsxs(\"div\",{className:\"flex items-center gap-3 shrink-0\",children:[a.jsx(\"div\",{className:\"flex items-center\",title:n?r(\"spoolbuddy.status.backend\",\"Backend\"):r(\"spoolbuddy.status.offline\",\"Offline\"),children:n?a.jsx(\"div\",{className:\"flex items-end gap-0.5 h-4\",children:[1,2,3,4].map(u=>a.jsx(\"div\",{className:`w-1 rounded-sm ${u<=4?\"bg-white\":\"bg-bambu-dark-tertiary\"}`,style:{height:`${u*4}px`}},u))}):a.jsx(Bd,{className:\"w-5 h-5 text-red-400\"})}),a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(\"div\",{className:`w-3 h-3 rounded-full ${n?\"bg-bambu-green shadow-[0_0_6px_rgba(34,197,94,0.5)]\":\"bg-bambu-gray\"}`}),a.jsx(\"span\",{className:\"text-sm text-white/50\",children:n?r(\"spoolbuddy.status.backend\",\"Backend\"):r(\"spoolbuddy.status.offline\",\"Offline\")})]}),a.jsx(\"span\",{className:\"text-white/50 text-base font-mono min-w-[50px] text-right\",children:sZ(i,d?.time_format||\"system\")})]})]})}const rat=[{to:\"/spoolbuddy\",labelKey:\"spoolbuddy.nav.dashboard\",fallback:\"Dashboard\",icon:a.jsx(\"svg\",{className:\"w-6 h-6\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:2,d:\"M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6\"})})},{to:\"/spoolbuddy/ams\",labelKey:\"spoolbuddy.nav.ams\",fallback:\"AMS\",icon:a.jsx(\"svg\",{className:\"w-6 h-6\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:2,d:\"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10\"})})},{to:\"/spoolbuddy/write-tag\",labelKey:\"spoolbuddy.nav.writeTag\",fallback:\"Write\",icon:a.jsxs(\"svg\",{className:\"w-6 h-6\",viewBox:\"0 0 24 24\",fill:\"none\",stroke:\"currentColor\",strokeWidth:2,children:[a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0\"}),a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z\"})]})},{to:\"/spoolbuddy/inventory\",labelKey:\"spoolbuddy.nav.inventory\",fallback:\"Inventory\",icon:a.jsx(\"svg\",{className:\"w-6 h-6\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:2,d:\"M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4\"})})},{to:\"/spoolbuddy/settings\",labelKey:\"spoolbuddy.nav.settings\",fallback:\"Settings\",icon:a.jsxs(\"svg\",{className:\"w-6 h-6\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:[a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:2,d:\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"}),a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:2,d:\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"})]})}];function aat(){const{t}=Ft();return a.jsx(\"nav\",{className:\"h-14 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-stretch shrink-0\",children:rat.map(e=>a.jsxs(xx,{to:e.to,end:e.to===\"/spoolbuddy\",className:({isActive:n})=>`flex-1 flex flex-col items-center justify-center gap-1 transition-colors ${n?\"text-bambu-green bg-bambu-dark\":\"text-white/50 hover:text-white/70 hover:bg-bambu-dark-tertiary\"}`,children:[e.icon,a.jsx(\"span\",{className:\"text-xs font-medium\",children:t(e.labelKey,e.fallback)})]},e.to))})}function iat({alert:t}){const{t:e}=Ft(),n=t?t.type===\"error\"?\"bg-red-500\":t.type===\"warning\"?\"bg-amber-500\":\"bg-bambu-green\":\"bg-bambu-green\",r=t?t.type===\"error\"?\"border-red-500\":t.type===\"warning\"?\"border-amber-500\":\"border-bambu-dark-tertiary\":\"border-bambu-dark-tertiary\";return a.jsxs(\"div\",{className:`h-9 bg-bambu-dark-secondary border-t-2 ${r} flex items-center px-3 gap-3 shrink-0`,children:[a.jsx(\"div\",{className:`w-3.5 h-3.5 rounded-full ${n}`}),a.jsx(\"div\",{className:\"flex-1 text-sm text-white/50 truncate\",children:t?a.jsx(\"span\",{children:t.message}):a.jsx(\"span\",{className:\"text-bambu-green\",children:e(\"spoolbuddy.status.systemReady\",\"System Ready\")})})]})}function sat({isOpen:t,onClose:e,deviceId:n,deviceOnline:r}){const{t:i}=Ft(),[s,o]=w.useState(null),[l,c]=w.useState(!1),[d,u]=w.useState(new Map),{data:m=[]}=Xe({queryKey:[\"printers\"],queryFn:()=>ue.getPrinters(),enabled:t}),{data:p=[]}=Xe({queryKey:[\"smart-plugs\"],queryFn:()=>ue.getSmartPlugs(),enabled:t}),f=m.map(_=>{const C=p.find(N=>N.printer_id===_.id&&N.plug_type!==\"mqtt\"&&N.enabled);if(!C)return null;const P=d.get(C.id);return{plug:C,printer:_,status:P?{state:P.state,reachable:!0,device_name:null,energy:null}:null,loading:P?.loading??!1}}).filter(Boolean);w.useEffect(()=>{if(!t||p.length===0)return;p.filter(C=>C.printer_id!==null&&C.plug_type!==\"mqtt\"&&C.enabled).forEach(async C=>{try{const P=await ue.getSmartPlugStatus(C.id);u(N=>{const A=new Map(N);return A.set(C.id,{loading:!1,state:P.state}),A})}catch{u(P=>{const N=new Map(P);return N.set(C.id,{loading:!1,state:null}),N})}})},[t,p]),w.useEffect(()=>{t||(o(null),c(!1))},[t]);const y=w.useCallback(async _=>{u(C=>{const P=new Map(C),N=P.get(_.id);return P.set(_.id,{loading:!0,state:N?.state??null}),P});try{await ue.controlSmartPlug(_.id,\"toggle\");const C=await ue.getSmartPlugStatus(_.id);u(P=>{const N=new Map(P);return N.set(_.id,{loading:!1,state:C.state}),N})}catch{u(C=>{const P=new Map(C),N=P.get(_.id);return P.set(_.id,{loading:!1,state:N?.state??null}),P})}},[]),v=w.useCallback(async _=>{if(n){c(!0);try{await Gr.systemCommand(n,_),setTimeout(()=>e(),500)}catch{c(!1)}}},[n,e]),b=w.useCallback(()=>{s&&(s.type===\"system\"?v(s.command):y(s.plug),o(null))},[s,v,y]);if(!t)return null;const g=_=>_===\"ON\"||_===\"on\";return a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-40 bg-black/50\",onPointerDown:e}),a.jsxs(\"div\",{className:\"fixed top-0 left-0 right-0 z-50 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary rounded-b-2xl shadow-2xl animate-slide-down\",children:[a.jsx(\"div\",{className:\"flex justify-center pt-2 pb-1\",children:a.jsx(\"div\",{className:\"w-10 h-1 rounded-full bg-zinc-600\"})}),a.jsxs(\"div\",{className:\"px-4 pb-4 max-h-[80vh] overflow-y-auto\",children:[f.length>0&&a.jsxs(\"div\",{className:\"mb-4\",children:[a.jsx(\"h3\",{className:\"text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2\",children:i(\"spoolbuddy.quickMenu.printerPower\",\"Printer Power\")}),a.jsx(\"div\",{className:\"space-y-1\",children:f.map(({plug:_,printer:C,loading:P})=>{const N=d.get(_.id),A=g(N?.state??null);return a.jsxs(\"button\",{onClick:()=>o({type:\"plug\",plug:_,printer:C,currentState:N?.state??null}),disabled:P,className:\"w-full flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-800/60 hover:bg-zinc-700/60 transition-colors min-h-[36px]\",children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full shrink-0 ${A?\"bg-green-500\":\"bg-zinc-600\"}`}),P&&a.jsx(ft,{className:\"w-3 h-3 animate-spin text-zinc-400 shrink-0\"}),a.jsx(\"span\",{className:\"flex-1 text-sm text-zinc-200 text-left truncate\",children:C.name}),a.jsx(\"span\",{className:`text-xs font-medium ${A?\"text-green-400\":\"text-zinc-500\"}`,children:N?.state??\"—\"})]},_.id)})})]}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2\",children:i(\"spoolbuddy.quickMenu.systemControls\",\"System\")}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[a.jsx(Lk,{icon:a.jsx(sw,{className:\"w-4 h-4\"}),label:i(\"spoolbuddy.quickMenu.restartDaemon\",\"Restart Daemon\"),onClick:()=>o({type:\"system\",command:\"restart_daemon\"}),disabled:!n||!r||l}),a.jsx(Lk,{icon:a.jsx(FO,{className:\"w-4 h-4\"}),label:i(\"spoolbuddy.quickMenu.restartBrowser\",\"Restart Browser\"),onClick:()=>o({type:\"system\",command:\"restart_browser\"}),disabled:!n||!r||l}),a.jsx(Lk,{icon:a.jsx(sw,{className:\"w-4 h-4\"}),label:i(\"spoolbuddy.quickMenu.reboot\",\"Reboot\"),onClick:()=>o({type:\"system\",command:\"reboot\"}),disabled:!n||!r||l,variant:\"warning\"}),a.jsx(Lk,{icon:a.jsx(aS,{className:\"w-4 h-4\"}),label:i(\"spoolbuddy.quickMenu.shutdown\",\"Shutdown\"),onClick:()=>o({type:\"system\",command:\"shutdown\"}),disabled:!n||!r||l,variant:\"danger\"})]})]}),a.jsx(\"div\",{className:\"flex justify-center mt-3\",children:a.jsxs(\"div\",{className:\"flex items-center gap-1 text-xs text-zinc-600\",children:[a.jsx(On,{className:\"w-3 h-3\"}),a.jsx(\"span\",{children:i(\"spoolbuddy.quickMenu.swipeToClose\",\"Swipe down to close\")})]})})]})]}),s&&a.jsx(\"div\",{className:\"fixed inset-0 z-[60] flex items-center justify-center bg-black/60\",children:a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-2xl p-5 mx-4 max-w-sm w-full border border-zinc-700\",children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-zinc-100 mb-2\",children:i(\"spoolbuddy.quickMenu.confirmTitle\",\"Confirm\")}),a.jsx(\"p\",{className:\"text-sm text-zinc-400 mb-5\",children:s.type===\"plug\"?g(s.currentState)?i(\"spoolbuddy.quickMenu.confirmPlugOff\",\"Turn off {{name}}?\",{name:s.printer.name}):i(\"spoolbuddy.quickMenu.confirmPlugOn\",\"Turn on {{name}}?\",{name:s.printer.name}):s.command===\"shutdown\"?i(\"spoolbuddy.quickMenu.confirmShutdown\",\"Are you sure you want to shut down the SpoolBuddy? You will need physical access to turn it back on.\"):s.command===\"reboot\"?i(\"spoolbuddy.quickMenu.confirmReboot\",\"Are you sure you want to reboot the SpoolBuddy?\"):s.command===\"restart_daemon\"?i(\"spoolbuddy.quickMenu.confirmRestartDaemon\",\"Restart the SpoolBuddy daemon? NFC and scale will be temporarily unavailable.\"):i(\"spoolbuddy.quickMenu.confirmRestartBrowser\",\"Restart the kiosk browser? The display will briefly go blank.\")}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(\"button\",{onClick:()=>o(null),className:\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\",children:i(\"common.cancel\",\"Cancel\")}),a.jsx(\"button\",{onClick:b,disabled:l,className:`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white transition-colors min-h-[44px] ${s.type===\"plug\"?g(s.currentState)?\"bg-red-600 hover:bg-red-700\":\"bg-green-600 hover:bg-green-700\":s.command===\"shutdown\"?\"bg-red-600 hover:bg-red-700\":s.command===\"reboot\"?\"bg-amber-600 hover:bg-amber-700\":\"bg-blue-600 hover:bg-blue-700\"} disabled:opacity-50`,children:l?a.jsx(ft,{className:\"w-4 h-4 animate-spin mx-auto\"}):s.type===\"plug\"?g(s.currentState)?i(\"spoolbuddy.quickMenu.turnOff\",\"Turn Off\"):i(\"spoolbuddy.quickMenu.turnOn\",\"Turn On\"):i(\"spoolbuddy.quickMenu.confirm\",\"Confirm\")})]})]})})]})}function Lk({icon:t,label:e,onClick:n,disabled:r,variant:i=\"default\"}){const s={default:\"bg-zinc-800/60 hover:bg-zinc-700/60 text-zinc-300\",warning:\"bg-amber-900/30 hover:bg-amber-900/50 text-amber-400\",danger:\"bg-red-900/30 hover:bg-red-900/50 text-red-400\"};return a.jsxs(\"button\",{onClick:n,disabled:r,className:`flex items-center gap-2.5 p-3 rounded-xl transition-colors min-h-[48px] disabled:opacity-40 ${s[i]}`,children:[t,a.jsx(\"span\",{className:\"text-sm font-medium\",children:e})]})}const oat={weight:null,weightStable:!1,rawAdc:null,matchedSpool:null,unknownTagUid:null,deviceOnline:!1,deviceId:null};function lat(t,e){switch(e.type){case\"WEIGHT_UPDATE\":return{...t,weight:e.weight,weightStable:e.stable,rawAdc:e.rawAdc,deviceId:e.deviceId,deviceOnline:!0};case\"TAG_MATCHED\":return{...t,matchedSpool:e.spool,unknownTagUid:null,deviceId:e.deviceId};case\"UNKNOWN_TAG\":return{...t,matchedSpool:null,unknownTagUid:e.tagUid,deviceId:e.deviceId};case\"TAG_REMOVED\":return{...t,matchedSpool:null,unknownTagUid:null};case\"DEVICE_ONLINE\":return{...t,deviceOnline:!0,deviceId:e.deviceId};case\"DEVICE_OFFLINE\":return{...t,deviceOnline:!1,weight:null,weightStable:!1,rawAdc:null};default:return t}}function cat(){const[t,e]=w.useReducer(lat,oat),n=w.useCallback(u=>{const m=u.detail;e({type:\"WEIGHT_UPDATE\",weight:m.weight_grams??m.data?.weight_grams,stable:m.stable??m.data?.stable??!1,rawAdc:m.raw_adc??m.data?.raw_adc??null,deviceId:m.device_id??m.data?.device_id??\"\"})},[]),r=w.useCallback(u=>{const m=u.detail,p=m.spool??m.data?.spool;p&&e({type:\"TAG_MATCHED\",spool:{id:p.id,tag_uid:m.tag_uid??m.data?.tag_uid??\"\",material:p.material??\"\",subtype:p.subtype??null,color_name:p.color_name??null,rgba:p.rgba??null,brand:p.brand??null,label_weight:p.label_weight??0,core_weight:p.core_weight??0,weight_used:p.weight_used??0},deviceId:m.device_id??m.data?.device_id??\"\"})},[]),i=w.useCallback(u=>{const m=u.detail;e({type:\"UNKNOWN_TAG\",tagUid:m.tag_uid??m.data?.tag_uid??\"\",deviceId:m.device_id??m.data?.device_id??\"\"})},[]),s=w.useCallback(u=>{const m=u.detail;e({type:\"TAG_REMOVED\",deviceId:m.device_id??m.data?.device_id??\"\"})},[]),o=w.useCallback(u=>{const m=u.detail;e({type:\"DEVICE_ONLINE\",deviceId:m.device_id??m.data?.device_id??\"\"})},[]),l=w.useCallback(u=>{const m=u.detail;e({type:\"DEVICE_OFFLINE\",deviceId:m.device_id??m.data?.device_id??\"\"})},[]);w.useEffect(()=>(window.addEventListener(\"spoolbuddy-weight\",n),window.addEventListener(\"spoolbuddy-tag-matched\",r),window.addEventListener(\"spoolbuddy-unknown-tag\",i),window.addEventListener(\"spoolbuddy-tag-removed\",s),window.addEventListener(\"spoolbuddy-online\",o),window.addEventListener(\"spoolbuddy-offline\",l),()=>{window.removeEventListener(\"spoolbuddy-weight\",n),window.removeEventListener(\"spoolbuddy-tag-matched\",r),window.removeEventListener(\"spoolbuddy-unknown-tag\",i),window.removeEventListener(\"spoolbuddy-tag-removed\",s),window.removeEventListener(\"spoolbuddy-online\",o),window.removeEventListener(\"spoolbuddy-offline\",l)}),[n,r,i,s,o,l]);const c=t.matchedSpool?Math.max(0,t.matchedSpool.label_weight-t.matchedSpool.weight_used):null,d=t.weight!==null&&t.matchedSpool?Math.max(0,t.weight-t.matchedSpool.core_weight):null;return{...t,remainingWeight:c,netWeight:d}}var uN={exports:{}};var dat=uN.exports,EY;function uat(){return EY||(EY=1,(function(t,e){(function(n,r){t.exports=r(Rg())})(dat,(function(n){return(function(){var r={981:function(){typeof Element>\"u\"||\"remove\"in Element.prototype||(Element.prototype.remove=function(){this.parentNode&&this.parentNode.removeChild(this)}),typeof self<\"u\"&&\"document\"in self&&((!(\"classList\"in document.createElement(\"_\"))||document.createElementNS&&!(\"classList\"in document.createElementNS(\"http://www.w3.org/2000/svg\",\"g\")))&&(function(l){if(\"Element\"in l){var c=\"classList\",d=\"prototype\",u=l.Element[d],m=Object,p=String[d].trim||function(){return this.replace(/^\\s+|\\s+$/g,\"\")},f=Array[d].indexOf||function(P){for(var N=0,A=this.length;N<A;N++)if(N in this&&this[N]===P)return N;return-1},y=function(P,N){this.name=P,this.code=DOMException[P],this.message=N},v=function(P,N){if(N===\"\")throw new y(\"SYNTAX_ERR\",\"The token must not be empty.\");if(/\\s/.test(N))throw new y(\"INVALID_CHARACTER_ERR\",\"The token must not contain space characters.\");return f.call(P,N)},b=function(P){for(var N=p.call(P.getAttribute(\"class\")||\"\"),A=N?N.split(/\\s+/):[],T=0,F=A.length;T<F;T++)this.push(A[T]);this._updateClassName=function(){P.setAttribute(\"class\",this.toString())}},g=b[d]=[],_=function(){return new b(this)};if(y[d]=Error[d],g.item=function(P){return this[P]||null},g.contains=function(P){return~v(this,P+\"\")},g.add=function(){var P,N=arguments,A=0,T=N.length,F=!1;do~v(this,P=N[A]+\"\")||(this.push(P),F=!0);while(++A<T);F&&this._updateClassName()},g.remove=function(){var P,N,A=arguments,T=0,F=A.length,k=!1;do for(N=v(this,P=A[T]+\"\");~N;)this.splice(N,1),k=!0,N=v(this,P);while(++T<F);k&&this._updateClassName()},g.toggle=function(P,N){var A=this.contains(P),T=A?N!==!0&&\"remove\":N!==!1&&\"add\";return T&&this[T](P),N===!0||N===!1?N:!A},g.replace=function(P,N){var A=v(P+\"\");~A&&(this.splice(A,1,N),this._updateClassName())},g.toString=function(){return this.join(\" \")},m.defineProperty){var C={get:_,enumerable:!0,configurable:!0};try{m.defineProperty(u,c,C)}catch(P){P.number!==void 0&&P.number!==-2146823252||(C.enumerable=!1,m.defineProperty(u,c,C))}}else m[d].__defineGetter__&&u.__defineGetter__(c,_)}})(self),(function(){var l=document.createElement(\"_\");if(l.classList.add(\"c1\",\"c2\"),!l.classList.contains(\"c2\")){var c=function(u){var m=DOMTokenList.prototype[u];DOMTokenList.prototype[u]=function(p){var f,y=arguments.length;for(f=0;f<y;f++)p=arguments[f],m.call(this,p)}};c(\"add\"),c(\"remove\")}if(l.classList.toggle(\"c3\",!1),l.classList.contains(\"c3\")){var d=DOMTokenList.prototype.toggle;DOMTokenList.prototype.toggle=function(u,m){return 1 in arguments&&!this.contains(u)==!m?m:d.call(this,u)}}\"replace\"in document.createElement(\"_\").classList||(DOMTokenList.prototype.replace=function(u,m){var p=this.toString().split(\" \"),f=p.indexOf(u+\"\");~f&&(p=p.slice(f),this.remove.apply(this,p),this.add(m),this.add.apply(this,p.slice(1)))}),l=null})())},548:function(l){l.exports=(function(){var c={9306:function(q,Y,E){var j=E(4901),O=E(6823),K=TypeError;q.exports=function(U){if(j(U))return U;throw new K(O(U)+\" is not a function\")}},5548:function(q,Y,E){var j=E(3517),O=E(6823),K=TypeError;q.exports=function(U){if(j(U))return U;throw new K(O(U)+\" is not a constructor\")}},3506:function(q,Y,E){var j=E(3925),O=String,K=TypeError;q.exports=function(U){if(j(U))return U;throw new K(\"Can't set \"+O(U)+\" as a prototype\")}},6469:function(q,Y,E){var j=E(8227),O=E(2360),K=E(4913).f,U=j(\"unscopables\"),de=Array.prototype;de[U]===void 0&&K(de,U,{configurable:!0,value:O(null)}),q.exports=function(I){de[U][I]=!0}},7829:function(q,Y,E){var j=E(8183).charAt;q.exports=function(O,K,U){return K+(U?j(O,K).length:1)}},8551:function(q,Y,E){var j=E(34),O=String,K=TypeError;q.exports=function(U){if(j(U))return U;throw new K(O(U)+\" is not an object\")}},235:function(q,Y,E){var j=E(9213).forEach,O=E(4598)(\"forEach\");q.exports=O?[].forEach:function(K){return j(this,K,arguments.length>1?arguments[1]:void 0)}},7916:function(q,Y,E){var j=E(6080),O=E(9565),K=E(8981),U=E(6319),de=E(4209),I=E(3517),G=E(6198),X=E(4659),V=E(4527),ee=E(81),se=E(851),ge=Array;q.exports=function(he){var le=K(he),B=I(this),R=arguments.length,ae=R>1?arguments[1]:void 0,_e=ae!==void 0;_e&&(ae=j(ae,R>2?arguments[2]:void 0));var Se,ve,Te,ye,je,Le,Me=se(le),Oe=0;if(!Me||this===ge&&de(Me))for(Se=G(le),ve=B?new this(Se):ge(Se);Se>Oe;Oe++)Le=_e?ae(le[Oe],Oe):le[Oe],X(ve,Oe,Le);else for(ve=B?new this:[],je=(ye=ee(le,Me)).next;!(Te=O(je,ye)).done;Oe++)Le=_e?U(ye,ae,[Te.value,Oe],!0):Te.value,X(ve,Oe,Le);return V(ve,Oe),ve}},9617:function(q,Y,E){var j=E(5397),O=E(5610),K=E(6198),U=function(de){return function(I,G,X){var V=j(I),ee=K(V);if(ee===0)return!de&&-1;var se,ge=O(X,ee);if(de&&G!=G){for(;ee>ge;)if((se=V[ge++])!=se)return!0}else for(;ee>ge;ge++)if((de||ge in V)&&V[ge]===G)return de||ge||0;return!de&&-1}};q.exports={includes:U(!0),indexOf:U(!1)}},9213:function(q,Y,E){var j=E(6080),O=E(7055),K=E(8981),U=E(6198),de=E(1469),I=E(4659),G=function(X){var V=X===1,ee=X===2,se=X===3,ge=X===4,he=X===6,le=X===7,B=X===5||he;return function(R,ae,_e){for(var Se,ve,Te=K(R),ye=O(Te),je=U(ye),Le=j(ae,_e),Me=0,Oe=0,Re=V?de(R,je):ee||le?de(R,0):void 0;je>Me;Me++)if((B||Me in ye)&&(ve=Le(Se=ye[Me],Me,Te),X))if(V)I(Re,Me,ve);else if(ve)switch(X){case 3:return!0;case 5:return Se;case 6:return Me;case 2:I(Re,Oe++,Se)}else switch(X){case 4:return!1;case 7:I(Re,Oe++,Se)}return he?-1:se||ge?ge:Re}};q.exports={forEach:G(0),map:G(1),filter:G(2),some:G(3),every:G(4),find:G(5),findIndex:G(6),filterReject:G(7)}},597:function(q,Y,E){var j=E(9039),O=E(8227),K=E(9519),U=O(\"species\");q.exports=function(de){return K>=51||!j((function(){var I=[];return(I.constructor={})[U]=function(){return{foo:1}},I[de](Boolean).foo!==1}))}},4598:function(q,Y,E){var j=E(9039);q.exports=function(O,K){var U=[][O];return!!U&&j((function(){U.call(null,K||function(){return 1},1)}))}},926:function(q,Y,E){var j=E(9306),O=E(8981),K=E(7055),U=E(6198),de=TypeError,I=\"Reduce of empty array with no initial value\",G=function(X){return function(V,ee,se,ge){var he=O(V),le=K(he),B=U(he);if(j(ee),B===0&&se<2)throw new de(I);var R=X?B-1:0,ae=X?-1:1;if(se<2)for(;;){if(R in le){ge=le[R],R+=ae;break}if(R+=ae,X?R<0:B<=R)throw new de(I)}for(;X?R>=0:B>R;R+=ae)R in le&&(ge=ee(ge,le[R],R,he));return ge}};q.exports={left:G(!1),right:G(!0)}},4527:function(q,Y,E){var j=E(3724),O=E(4376),K=TypeError,U=Object.getOwnPropertyDescriptor,de=j&&!(function(){if(this!==void 0)return!0;try{Object.defineProperty([],\"length\",{writable:!1}).length=1}catch(I){return I instanceof TypeError}})();q.exports=de?function(I,G){if(O(I)&&!U(I,\"length\").writable)throw new K(\"Cannot set read only .length\");return I.length=G}:function(I,G){return I.length=G}},7680:function(q,Y,E){var j=E(9504);q.exports=j([].slice)},4488:function(q,Y,E){var j=E(7680),O=Math.floor,K=function(U,de){var I=U.length;if(I<8)for(var G,X,V=1;V<I;){for(X=V,G=U[V];X&&de(U[X-1],G)>0;)U[X]=U[--X];X!==V++&&(U[X]=G)}else for(var ee=O(I/2),se=K(j(U,0,ee),de),ge=K(j(U,ee),de),he=se.length,le=ge.length,B=0,R=0;B<he||R<le;)U[B+R]=B<he&&R<le?de(se[B],ge[R])<=0?se[B++]:ge[R++]:B<he?se[B++]:ge[R++];return U};q.exports=K},7433:function(q,Y,E){var j=E(4376),O=E(3517),K=E(34),U=E(8227)(\"species\"),de=Array;q.exports=function(I){var G;return j(I)&&(G=I.constructor,(O(G)&&(G===de||j(G.prototype))||K(G)&&(G=G[U])===null)&&(G=void 0)),G===void 0?de:G}},1469:function(q,Y,E){var j=E(7433);q.exports=function(O,K){return new(j(O))(K===0?0:K)}},6319:function(q,Y,E){var j=E(8551),O=E(9539);q.exports=function(K,U,de,I){try{return I?U(j(de)[0],de[1]):U(de)}catch(G){O(K,\"throw\",G)}}},4428:function(q,Y,E){var j=E(8227)(\"iterator\"),O=!1;try{var K=0,U={next:function(){return{done:!!K++}},return:function(){O=!0}};U[j]=function(){return this},Array.from(U,(function(){throw 2}))}catch{}q.exports=function(de,I){try{if(!I&&!O)return!1}catch{return!1}var G=!1;try{var X={};X[j]=function(){return{next:function(){return{done:G=!0}}}},de(X)}catch{}return G}},2195:function(q,Y,E){var j=E(9504),O=j({}.toString),K=j(\"\".slice);q.exports=function(U){return K(O(U),8,-1)}},6955:function(q,Y,E){var j=E(2140),O=E(4901),K=E(2195),U=E(8227)(\"toStringTag\"),de=Object,I=K((function(){return arguments})())===\"Arguments\";q.exports=j?K:function(G){var X,V,ee;return G===void 0?\"Undefined\":G===null?\"Null\":typeof(V=(function(se,ge){try{return se[ge]}catch{}})(X=de(G),U))==\"string\"?V:I?K(X):(ee=K(X))===\"Object\"&&O(X.callee)?\"Arguments\":ee}},7740:function(q,Y,E){var j=E(9297),O=E(5031),K=E(7347),U=E(4913);q.exports=function(de,I,G){for(var X=O(I),V=U.f,ee=K.f,se=0;se<X.length;se++){var ge=X[se];j(de,ge)||G&&j(G,ge)||V(de,ge,ee(I,ge))}}},1436:function(q,Y,E){var j=E(8227)(\"match\");q.exports=function(O){var K=/./;try{\"/./\"[O](K)}catch{try{return K[j]=!1,\"/./\"[O](K)}catch{}}return!1}},2211:function(q,Y,E){var j=E(9039);q.exports=!j((function(){function O(){}return O.prototype.constructor=null,Object.getPrototypeOf(new O)!==O.prototype}))},2529:function(q){q.exports=function(Y,E){return{value:Y,done:E}}},6699:function(q,Y,E){var j=E(3724),O=E(4913),K=E(6980);q.exports=j?function(U,de,I){return O.f(U,de,K(1,I))}:function(U,de,I){return U[de]=I,U}},6980:function(q){q.exports=function(Y,E){return{enumerable:!(1&Y),configurable:!(2&Y),writable:!(4&Y),value:E}}},4659:function(q,Y,E){var j=E(3724),O=E(4913),K=E(6980);q.exports=function(U,de,I){j?O.f(U,de,K(0,I)):U[de]=I}},3640:function(q,Y,E){var j=E(8551),O=E(4270),K=TypeError;q.exports=function(U){if(j(this),U===\"string\"||U===\"default\")U=\"string\";else if(U!==\"number\")throw new K(\"Incorrect hint\");return O(this,U)}},2106:function(q,Y,E){var j=E(283),O=E(4913);q.exports=function(K,U,de){return de.get&&j(de.get,U,{getter:!0}),de.set&&j(de.set,U,{setter:!0}),O.f(K,U,de)}},6840:function(q,Y,E){var j=E(4901),O=E(4913),K=E(283),U=E(9433);q.exports=function(de,I,G,X){X||(X={});var V=X.enumerable,ee=X.name!==void 0?X.name:I;if(j(G)&&K(G,ee,X),X.global)V?de[I]=G:U(I,G);else{try{X.unsafe?de[I]&&(V=!0):delete de[I]}catch{}V?de[I]=G:O.f(de,I,{value:G,enumerable:!1,configurable:!X.nonConfigurable,writable:!X.nonWritable})}return de}},9433:function(q,Y,E){var j=E(4576),O=Object.defineProperty;q.exports=function(K,U){try{O(j,K,{value:U,configurable:!0,writable:!0})}catch{j[K]=U}return U}},4606:function(q,Y,E){var j=E(6823),O=TypeError;q.exports=function(K,U){if(!delete K[U])throw new O(\"Cannot delete property \"+j(U)+\" of \"+j(K))}},3724:function(q,Y,E){var j=E(9039);q.exports=!j((function(){return Object.defineProperty({},1,{get:function(){return 7}})[1]!==7}))},4055:function(q,Y,E){var j=E(4576),O=E(34),K=j.document,U=O(K)&&O(K.createElement);q.exports=function(de){return U?K.createElement(de):{}}},6837:function(q){var Y=TypeError;q.exports=function(E){if(E>9007199254740991)throw Y(\"Maximum allowed index exceeded\");return E}},7400:function(q){q.exports={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0}},9296:function(q,Y,E){var j=E(4055)(\"span\").classList,O=j&&j.constructor&&j.constructor.prototype;q.exports=O===Object.prototype?void 0:O},8727:function(q){q.exports=[\"constructor\",\"hasOwnProperty\",\"isPrototypeOf\",\"propertyIsEnumerable\",\"toLocaleString\",\"toString\",\"valueOf\"]},3709:function(q,Y,E){var j=E(2839).match(/firefox\\/(\\d+)/i);q.exports=!!j&&+j[1]},3763:function(q,Y,E){var j=E(2839);q.exports=/MSIE|Trident/.test(j)},6193:function(q,Y,E){var j=E(4215);q.exports=j===\"NODE\"},2839:function(q,Y,E){var j=E(4576).navigator,O=j&&j.userAgent;q.exports=O?String(O):\"\"},9519:function(q,Y,E){var j,O,K=E(4576),U=E(2839),de=K.process,I=K.Deno,G=de&&de.versions||I&&I.version,X=G&&G.v8;X&&(O=(j=X.split(\".\"))[0]>0&&j[0]<4?1:+(j[0]+j[1])),!O&&U&&(!(j=U.match(/Edge\\/(\\d+)/))||j[1]>=74)&&(j=U.match(/Chrome\\/(\\d+)/))&&(O=+j[1]),q.exports=O},3607:function(q,Y,E){var j=E(2839).match(/AppleWebKit\\/(\\d+)\\./);q.exports=!!j&&+j[1]},4215:function(q,Y,E){var j=E(4576),O=E(2839),K=E(2195),U=function(de){return O.slice(0,de.length)===de};q.exports=U(\"Bun/\")?\"BUN\":U(\"Cloudflare-Workers\")?\"CLOUDFLARE\":U(\"Deno/\")?\"DENO\":U(\"Node.js/\")?\"NODE\":j.Bun&&typeof Bun.version==\"string\"?\"BUN\":j.Deno&&typeof Deno.version==\"object\"?\"DENO\":K(j.process)===\"process\"?\"NODE\":j.window&&j.document?\"BROWSER\":\"REST\"},6518:function(q,Y,E){var j=E(4576),O=E(7347).f,K=E(6699),U=E(6840),de=E(9433),I=E(7740),G=E(2796);q.exports=function(X,V){var ee,se,ge,he,le,B=X.target,R=X.global,ae=X.stat;if(ee=R?j:ae?j[B]||de(B,{}):j[B]&&j[B].prototype)for(se in V){if(he=V[se],ge=X.dontCallGetSet?(le=O(ee,se))&&le.value:ee[se],!G(R?se:B+(ae?\".\":\"#\")+se,X.forced)&&ge!==void 0){if(typeof he==typeof ge)continue;I(he,ge)}(X.sham||ge&&ge.sham)&&K(he,\"sham\",!0),U(ee,se,he,X)}}},9039:function(q){q.exports=function(Y){try{return!!Y()}catch{return!0}}},9228:function(q,Y,E){E(7495);var j=E(9565),O=E(6840),K=E(7323),U=E(9039),de=E(8227),I=E(6699),G=de(\"species\"),X=RegExp.prototype;q.exports=function(V,ee,se,ge){var he=de(V),le=!U((function(){var _e={};return _e[he]=function(){return 7},\"\"[V](_e)!==7})),B=le&&!U((function(){var _e=!1,Se=/a/;if(V===\"split\"){var ve={};ve[G]=function(){return Se},(Se={constructor:ve,flags:\"\"})[he]=/./[he]}return Se.exec=function(){return _e=!0,null},Se[he](\"\"),!_e}));if(!le||!B||se){var R=/./[he],ae=ee(he,\"\"[V],(function(_e,Se,ve,Te,ye){var je=Se.exec;return je===K||je===X.exec?le&&!ye?{done:!0,value:j(R,Se,ve,Te)}:{done:!0,value:j(_e,ve,Se,Te)}:{done:!1}}));O(String.prototype,V,ae[0]),O(X,he,ae[1])}ge&&I(X[he],\"sham\",!0)}},8745:function(q,Y,E){var j=E(616),O=Function.prototype,K=O.apply,U=O.call;q.exports=typeof Reflect==\"object\"&&Reflect.apply||(j?U.bind(K):function(){return U.apply(K,arguments)})},6080:function(q,Y,E){var j=E(7476),O=E(9306),K=E(616),U=j(j.bind);q.exports=function(de,I){return O(de),I===void 0?de:K?U(de,I):function(){return de.apply(I,arguments)}}},616:function(q,Y,E){var j=E(9039);q.exports=!j((function(){var O=(function(){}).bind();return typeof O!=\"function\"||O.hasOwnProperty(\"prototype\")}))},566:function(q,Y,E){var j=E(9504),O=E(9306),K=E(34),U=E(9297),de=E(7680),I=E(616),G=Function,X=j([].concat),V=j([].join),ee={};q.exports=I?G.bind:function(se){var ge=O(this),he=ge.prototype,le=de(arguments,1),B=function(){var R=X(le,de(arguments));return this instanceof B?(function(ae,_e,Se){if(!U(ee,_e)){for(var ve=[],Te=0;Te<_e;Te++)ve[Te]=\"a[\"+Te+\"]\";ee[_e]=G(\"C,a\",\"return new C(\"+V(ve,\",\")+\")\")}return ee[_e](ae,Se)})(ge,R.length,R):ge.apply(se,R)};return K(he)&&(B.prototype=he),B}},9565:function(q,Y,E){var j=E(616),O=Function.prototype.call;q.exports=j?O.bind(O):function(){return O.apply(O,arguments)}},350:function(q,Y,E){var j=E(3724),O=E(9297),K=Function.prototype,U=j&&Object.getOwnPropertyDescriptor,de=O(K,\"name\"),I=de&&(function(){}).name===\"something\",G=de&&(!j||j&&U(K,\"name\").configurable);q.exports={EXISTS:de,PROPER:I,CONFIGURABLE:G}},6706:function(q,Y,E){var j=E(9504),O=E(9306);q.exports=function(K,U,de){try{return j(O(Object.getOwnPropertyDescriptor(K,U)[de]))}catch{}}},7476:function(q,Y,E){var j=E(2195),O=E(9504);q.exports=function(K){if(j(K)===\"Function\")return O(K)}},9504:function(q,Y,E){var j=E(616),O=Function.prototype,K=O.call,U=j&&O.bind.bind(K,K);q.exports=j?U:function(de){return function(){return K.apply(de,arguments)}}},7751:function(q,Y,E){var j=E(4576),O=E(4901);q.exports=function(K,U){return arguments.length<2?(de=j[K],O(de)?de:void 0):j[K]&&j[K][U];var de}},851:function(q,Y,E){var j=E(6955),O=E(5966),K=E(4117),U=E(6269),de=E(8227)(\"iterator\");q.exports=function(I){if(!K(I))return O(I,de)||O(I,\"@@iterator\")||U[j(I)]}},81:function(q,Y,E){var j=E(9565),O=E(9306),K=E(8551),U=E(6823),de=E(851),I=TypeError;q.exports=function(G,X){var V=arguments.length<2?de(G):X;if(O(V))return K(j(V,G));throw new I(U(G)+\" is not iterable\")}},5966:function(q,Y,E){var j=E(9306),O=E(4117);q.exports=function(K,U){var de=K[U];return O(de)?void 0:j(de)}},2478:function(q,Y,E){var j=E(9504),O=E(8981),K=Math.floor,U=j(\"\".charAt),de=j(\"\".replace),I=j(\"\".slice),G=/\\$([$&'`]|\\d{1,2}|<[^>]*>)/g,X=/\\$([$&'`]|\\d{1,2})/g;q.exports=function(V,ee,se,ge,he,le){var B=se+V.length,R=ge.length,ae=X;return he!==void 0&&(he=O(he),ae=G),de(le,ae,(function(_e,Se){var ve;switch(U(Se,0)){case\"$\":return\"$\";case\"&\":return V;case\"`\":return I(ee,0,se);case\"'\":return I(ee,B);case\"<\":ve=he[I(Se,1,-1)];break;default:var Te=+Se;if(Te===0)return _e;if(Te>R){var ye=K(Te/10);return ye===0?_e:ye<=R?ge[ye-1]===void 0?U(Se,1):ge[ye-1]+U(Se,1):_e}ve=ge[Te-1]}return ve===void 0?\"\":ve}))}},4576:function(q,Y,E){var j=function(O){return O&&O.Math===Math&&O};q.exports=j(typeof globalThis==\"object\"&&globalThis)||j(typeof window==\"object\"&&window)||j(typeof self==\"object\"&&self)||j(typeof E.g==\"object\"&&E.g)||j(typeof this==\"object\"&&this)||(function(){return this})()||Function(\"return this\")()},9297:function(q,Y,E){var j=E(9504),O=E(8981),K=j({}.hasOwnProperty);q.exports=Object.hasOwn||function(U,de){return K(O(U),de)}},421:function(q){q.exports={}},397:function(q,Y,E){var j=E(7751);q.exports=j(\"document\",\"documentElement\")},5917:function(q,Y,E){var j=E(3724),O=E(9039),K=E(4055);q.exports=!j&&!O((function(){return Object.defineProperty(K(\"div\"),\"a\",{get:function(){return 7}}).a!==7}))},7055:function(q,Y,E){var j=E(9504),O=E(9039),K=E(2195),U=Object,de=j(\"\".split);q.exports=O((function(){return!U(\"z\").propertyIsEnumerable(0)}))?function(I){return K(I)===\"String\"?de(I,\"\"):U(I)}:U},3167:function(q,Y,E){var j=E(4901),O=E(34),K=E(2967);q.exports=function(U,de,I){var G,X;return K&&j(G=de.constructor)&&G!==I&&O(X=G.prototype)&&X!==I.prototype&&K(U,X),U}},3706:function(q,Y,E){var j=E(9504),O=E(4901),K=E(7629),U=j(Function.toString);O(K.inspectSource)||(K.inspectSource=function(de){return U(de)}),q.exports=K.inspectSource},1181:function(q,Y,E){var j,O,K,U=E(8622),de=E(4576),I=E(34),G=E(6699),X=E(9297),V=E(7629),ee=E(6119),se=E(421),ge=\"Object already initialized\",he=de.TypeError,le=de.WeakMap;if(U||V.state){var B=V.state||(V.state=new le);B.get=B.get,B.has=B.has,B.set=B.set,j=function(ae,_e){if(B.has(ae))throw new he(ge);return _e.facade=ae,B.set(ae,_e),_e},O=function(ae){return B.get(ae)||{}},K=function(ae){return B.has(ae)}}else{var R=ee(\"state\");se[R]=!0,j=function(ae,_e){if(X(ae,R))throw new he(ge);return _e.facade=ae,G(ae,R,_e),_e},O=function(ae){return X(ae,R)?ae[R]:{}},K=function(ae){return X(ae,R)}}q.exports={set:j,get:O,has:K,enforce:function(ae){return K(ae)?O(ae):j(ae,{})},getterFor:function(ae){return function(_e){var Se;if(!I(_e)||(Se=O(_e)).type!==ae)throw new he(\"Incompatible receiver, \"+ae+\" required\");return Se}}}},4209:function(q,Y,E){var j=E(8227),O=E(6269),K=j(\"iterator\"),U=Array.prototype;q.exports=function(de){return de!==void 0&&(O.Array===de||U[K]===de)}},4376:function(q,Y,E){var j=E(2195);q.exports=Array.isArray||function(O){return j(O)===\"Array\"}},4901:function(q){var Y=typeof document==\"object\"&&document.all;q.exports=Y===void 0&&Y!==void 0?function(E){return typeof E==\"function\"||E===Y}:function(E){return typeof E==\"function\"}},3517:function(q,Y,E){var j=E(9504),O=E(9039),K=E(4901),U=E(6955),de=E(7751),I=E(3706),G=function(){},X=de(\"Reflect\",\"construct\"),V=/^\\s*(?:class|function)\\b/,ee=j(V.exec),se=!V.test(G),ge=function(le){if(!K(le))return!1;try{return X(G,[],le),!0}catch{return!1}},he=function(le){if(!K(le))return!1;switch(U(le)){case\"AsyncFunction\":case\"GeneratorFunction\":case\"AsyncGeneratorFunction\":return!1}try{return se||!!ee(V,I(le))}catch{return!0}};he.sham=!0,q.exports=!X||O((function(){var le;return ge(ge.call)||!ge(Object)||!ge((function(){le=!0}))||le}))?he:ge},2796:function(q,Y,E){var j=E(9039),O=E(4901),K=/#|\\.prototype\\./,U=function(V,ee){var se=I[de(V)];return se===X||se!==G&&(O(ee)?j(ee):!!ee)},de=U.normalize=function(V){return String(V).replace(K,\".\").toLowerCase()},I=U.data={},G=U.NATIVE=\"N\",X=U.POLYFILL=\"P\";q.exports=U},2087:function(q,Y,E){var j=E(34),O=Math.floor;q.exports=Number.isInteger||function(K){return!j(K)&&isFinite(K)&&O(K)===K}},4117:function(q){q.exports=function(Y){return Y==null}},34:function(q,Y,E){var j=E(4901);q.exports=function(O){return typeof O==\"object\"?O!==null:j(O)}},3925:function(q,Y,E){var j=E(34);q.exports=function(O){return j(O)||O===null}},6395:function(q){q.exports=!1},5810:function(q,Y,E){var j=E(34),O=E(1181).get;q.exports=function(K){if(!j(K))return!1;var U=O(K);return!!U&&U.type===\"RawJSON\"}},788:function(q,Y,E){var j=E(34),O=E(2195),K=E(8227)(\"match\");q.exports=function(U){var de;return j(U)&&((de=U[K])!==void 0?!!de:O(U)===\"RegExp\")}},757:function(q,Y,E){var j=E(7751),O=E(4901),K=E(1625),U=E(7040),de=Object;q.exports=U?function(I){return typeof I==\"symbol\"}:function(I){var G=j(\"Symbol\");return O(G)&&K(G.prototype,de(I))}},9539:function(q,Y,E){var j=E(9565),O=E(8551),K=E(5966);q.exports=function(U,de,I){var G,X;O(U);try{if(!(G=K(U,\"return\"))){if(de===\"throw\")throw I;return I}G=j(G,U)}catch(V){X=!0,G=V}if(de===\"throw\")throw I;if(X)throw G;return O(G),I}},3994:function(q,Y,E){var j=E(7657).IteratorPrototype,O=E(2360),K=E(6980),U=E(687),de=E(6269),I=function(){return this};q.exports=function(G,X,V,ee){var se=X+\" Iterator\";return G.prototype=O(j,{next:K(+!ee,V)}),U(G,se,!1,!0),de[se]=I,G}},1088:function(q,Y,E){var j=E(6518),O=E(9565),K=E(6395),U=E(350),de=E(4901),I=E(3994),G=E(2787),X=E(2967),V=E(687),ee=E(6699),se=E(6840),ge=E(8227),he=E(6269),le=E(7657),B=U.PROPER,R=U.CONFIGURABLE,ae=le.IteratorPrototype,_e=le.BUGGY_SAFARI_ITERATORS,Se=ge(\"iterator\"),ve=\"keys\",Te=\"values\",ye=\"entries\",je=function(){return this};q.exports=function(Le,Me,Oe,Re,$e,Ye,tt){I(Oe,Me,Re);var pe,Fe,we,Ve=function(xt){if(xt===$e&&Qe)return Qe;if(!_e&&xt&&xt in xe)return xe[xt];switch(xt){case ve:case Te:case ye:return function(){return new Oe(this,xt)}}return function(){return new Oe(this)}},Ae=Me+\" Iterator\",ce=!1,xe=Le.prototype,Be=xe[Se]||xe[\"@@iterator\"]||$e&&xe[$e],Qe=!_e&&Be||Ve($e),ht=Me===\"Array\"&&xe.entries||Be;if(ht&&(pe=G(ht.call(new Le)))!==Object.prototype&&pe.next&&(K||G(pe)===ae||(X?X(pe,ae):de(pe[Se])||se(pe,Se,je)),V(pe,Ae,!0,!0),K&&(he[Ae]=je)),B&&$e===Te&&Be&&Be.name!==Te&&(!K&&R?ee(xe,\"name\",Te):(ce=!0,Qe=function(){return O(Be,this)})),$e)if(Fe={values:Ve(Te),keys:Ye?Qe:Ve(ve),entries:Ve(ye)},tt)for(we in Fe)(_e||ce||!(we in xe))&&se(xe,we,Fe[we]);else j({target:Me,proto:!0,forced:_e||ce},Fe);return K&&!tt||xe[Se]===Qe||se(xe,Se,Qe,{name:$e}),he[Me]=Qe,Fe}},7657:function(q,Y,E){var j,O,K,U=E(9039),de=E(4901),I=E(34),G=E(2360),X=E(2787),V=E(6840),ee=E(8227),se=E(6395),ge=ee(\"iterator\"),he=!1;[].keys&&(\"next\"in(K=[].keys())?(O=X(X(K)))!==Object.prototype&&(j=O):he=!0),!I(j)||U((function(){var le={};return j[ge].call(le)!==le}))?j={}:se&&(j=G(j)),de(j[ge])||V(j,ge,(function(){return this})),q.exports={IteratorPrototype:j,BUGGY_SAFARI_ITERATORS:he}},6269:function(q){q.exports={}},6198:function(q,Y,E){var j=E(8014);q.exports=function(O){return j(O.length)}},283:function(q,Y,E){var j=E(9504),O=E(9039),K=E(4901),U=E(9297),de=E(3724),I=E(350).CONFIGURABLE,G=E(3706),X=E(1181),V=X.enforce,ee=X.get,se=String,ge=Object.defineProperty,he=j(\"\".slice),le=j(\"\".replace),B=j([].join),R=de&&!O((function(){return ge((function(){}),\"length\",{value:8}).length!==8})),ae=String(String).split(\"String\"),_e=q.exports=function(Se,ve,Te){he(se(ve),0,7)===\"Symbol(\"&&(ve=\"[\"+le(se(ve),/^Symbol\\(([^)]*)\\).*$/,\"$1\")+\"]\"),Te&&Te.getter&&(ve=\"get \"+ve),Te&&Te.setter&&(ve=\"set \"+ve),(!U(Se,\"name\")||I&&Se.name!==ve)&&(de?ge(Se,\"name\",{value:ve,configurable:!0}):Se.name=ve),R&&Te&&U(Te,\"arity\")&&Se.length!==Te.arity&&ge(Se,\"length\",{value:Te.arity});try{Te&&U(Te,\"constructor\")&&Te.constructor?de&&ge(Se,\"prototype\",{writable:!1}):Se.prototype&&(Se.prototype=void 0)}catch{}var ye=V(Se);return U(ye,\"source\")||(ye.source=B(ae,typeof ve==\"string\"?ve:\"\")),Se};Function.prototype.toString=_e((function(){return K(this)&&ee(this).source||G(this)}),\"toString\")},741:function(q){var Y=Math.ceil,E=Math.floor;q.exports=Math.trunc||function(j){var O=+j;return(O>0?E:Y)(O)}},7819:function(q,Y,E){var j=E(9039);q.exports=!j((function(){var O=\"9007199254740993\",K=JSON.rawJSON(O);return!JSON.isRawJSON(K)||JSON.stringify(K)!==O}))},5749:function(q,Y,E){var j=E(788),O=TypeError;q.exports=function(K){if(j(K))throw new O(\"The method doesn't accept regular expressions\");return K}},4213:function(q,Y,E){var j=E(3724),O=E(9504),K=E(9565),U=E(9039),de=E(1072),I=E(3717),G=E(8773),X=E(8981),V=E(7055),ee=Object.assign,se=Object.defineProperty,ge=O([].concat);q.exports=!ee||U((function(){if(j&&ee({b:1},ee(se({},\"a\",{enumerable:!0,get:function(){se(this,\"b\",{value:3,enumerable:!1})}}),{b:2})).b!==1)return!0;var he={},le={},B=Symbol(\"assign detection\"),R=\"abcdefghijklmnopqrst\";return he[B]=7,R.split(\"\").forEach((function(ae){le[ae]=ae})),ee({},he)[B]!==7||de(ee({},le)).join(\"\")!==R}))?function(he,le){for(var B=X(he),R=arguments.length,ae=1,_e=I.f,Se=G.f;R>ae;)for(var ve,Te=V(arguments[ae++]),ye=_e?ge(de(Te),_e(Te)):de(Te),je=ye.length,Le=0;je>Le;)ve=ye[Le++],j&&!K(Se,Te,ve)||(B[ve]=Te[ve]);return B}:ee},2360:function(q,Y,E){var j,O=E(8551),K=E(6801),U=E(8727),de=E(421),I=E(397),G=E(4055),X=E(6119),V=\"prototype\",ee=\"script\",se=X(\"IE_PROTO\"),ge=function(){},he=function(R){return\"<\"+ee+\">\"+R+\"</\"+ee+\">\"},le=function(R){R.write(he(\"\")),R.close();var ae=R.parentWindow.Object;return R=null,ae},B=function(){try{j=new ActiveXObject(\"htmlfile\")}catch{}var R,ae,_e;B=typeof document<\"u\"?document.domain&&j?le(j):(ae=G(\"iframe\"),_e=\"java\"+ee+\":\",ae.style.display=\"none\",I.appendChild(ae),ae.src=String(_e),(R=ae.contentWindow.document).open(),R.write(he(\"document.F=Object\")),R.close(),R.F):le(j);for(var Se=U.length;Se--;)delete B[V][U[Se]];return B()};de[se]=!0,q.exports=Object.create||function(R,ae){var _e;return R!==null?(ge[V]=O(R),_e=new ge,ge[V]=null,_e[se]=R):_e=B(),ae===void 0?_e:K.f(_e,ae)}},6801:function(q,Y,E){var j=E(3724),O=E(8686),K=E(4913),U=E(8551),de=E(5397),I=E(1072);Y.f=j&&!O?Object.defineProperties:function(G,X){U(G);for(var V,ee=de(X),se=I(X),ge=se.length,he=0;ge>he;)K.f(G,V=se[he++],ee[V]);return G}},4913:function(q,Y,E){var j=E(3724),O=E(5917),K=E(8686),U=E(8551),de=E(6969),I=TypeError,G=Object.defineProperty,X=Object.getOwnPropertyDescriptor,V=\"enumerable\",ee=\"configurable\",se=\"writable\";Y.f=j?K?function(ge,he,le){if(U(ge),he=de(he),U(le),typeof ge==\"function\"&&he===\"prototype\"&&\"value\"in le&&se in le&&!le[se]){var B=X(ge,he);B&&B[se]&&(ge[he]=le.value,le={configurable:ee in le?le[ee]:B[ee],enumerable:V in le?le[V]:B[V],writable:!1})}return G(ge,he,le)}:G:function(ge,he,le){if(U(ge),he=de(he),U(le),O)try{return G(ge,he,le)}catch{}if(\"get\"in le||\"set\"in le)throw new I(\"Accessors not supported\");return\"value\"in le&&(ge[he]=le.value),ge}},7347:function(q,Y,E){var j=E(3724),O=E(9565),K=E(8773),U=E(6980),de=E(5397),I=E(6969),G=E(9297),X=E(5917),V=Object.getOwnPropertyDescriptor;Y.f=j?V:function(ee,se){if(ee=de(ee),se=I(se),X)try{return V(ee,se)}catch{}if(G(ee,se))return U(!O(K.f,ee,se),ee[se])}},298:function(q,Y,E){var j=E(2195),O=E(5397),K=E(8480).f,U=E(7680),de=typeof window==\"object\"&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[];q.exports.f=function(I){return de&&j(I)===\"Window\"?(function(G){try{return K(G)}catch{return U(de)}})(I):K(O(I))}},8480:function(q,Y,E){var j=E(1828),O=E(8727).concat(\"length\",\"prototype\");Y.f=Object.getOwnPropertyNames||function(K){return j(K,O)}},3717:function(q,Y){Y.f=Object.getOwnPropertySymbols},2787:function(q,Y,E){var j=E(9297),O=E(4901),K=E(8981),U=E(6119),de=E(2211),I=U(\"IE_PROTO\"),G=Object,X=G.prototype;q.exports=de?G.getPrototypeOf:function(V){var ee=K(V);if(j(ee,I))return ee[I];var se=ee.constructor;return O(se)&&ee instanceof se?se.prototype:ee instanceof G?X:null}},1625:function(q,Y,E){var j=E(9504);q.exports=j({}.isPrototypeOf)},1828:function(q,Y,E){var j=E(9504),O=E(9297),K=E(5397),U=E(9617).indexOf,de=E(421),I=j([].push);q.exports=function(G,X){var V,ee=K(G),se=0,ge=[];for(V in ee)!O(de,V)&&O(ee,V)&&I(ge,V);for(;X.length>se;)O(ee,V=X[se++])&&(~U(ge,V)||I(ge,V));return ge}},1072:function(q,Y,E){var j=E(1828),O=E(8727);q.exports=Object.keys||function(K){return j(K,O)}},8773:function(q,Y){var E={}.propertyIsEnumerable,j=Object.getOwnPropertyDescriptor,O=j&&!E.call({1:2},1);Y.f=O?function(K){var U=j(this,K);return!!U&&U.enumerable}:E},2551:function(q,Y,E){var j=E(6395),O=E(4576),K=E(9039),U=E(3607);q.exports=j||!K((function(){if(!(U&&U<535)){var de=Math.random();__defineSetter__.call(null,de,(function(){})),delete O[de]}}))},2967:function(q,Y,E){var j=E(6706),O=E(34),K=E(7750),U=E(3506);q.exports=Object.setPrototypeOf||(\"__proto__\"in{}?(function(){var de,I=!1,G={};try{(de=j(Object.prototype,\"__proto__\",\"set\"))(G,[]),I=G instanceof Array}catch{}return function(X,V){return K(X),U(V),O(X)&&(I?de(X,V):X.__proto__=V),X}})():void 0)},3179:function(q,Y,E){var j=E(2140),O=E(6955);q.exports=j?{}.toString:function(){return\"[object \"+O(this)+\"]\"}},4270:function(q,Y,E){var j=E(9565),O=E(4901),K=E(34),U=TypeError;q.exports=function(de,I){var G,X;if(I===\"string\"&&O(G=de.toString)&&!K(X=j(G,de))||O(G=de.valueOf)&&!K(X=j(G,de))||I!==\"string\"&&O(G=de.toString)&&!K(X=j(G,de)))return X;throw new U(\"Can't convert object to primitive value\")}},5031:function(q,Y,E){var j=E(7751),O=E(9504),K=E(8480),U=E(3717),de=E(8551),I=O([].concat);q.exports=j(\"Reflect\",\"ownKeys\")||function(G){var X=K.f(de(G)),V=U.f;return V?I(X,V(G)):X}},8235:function(q,Y,E){var j=E(9504),O=E(9297),K=SyntaxError,U=parseInt,de=String.fromCharCode,I=j(\"\".charAt),G=j(\"\".slice),X=j(/./.exec),V={'\\\\\"':'\"',\"\\\\\\\\\":\"\\\\\",\"\\\\/\":\"/\",\"\\\\b\":\"\\b\",\"\\\\f\":\"\\f\",\"\\\\n\":`\n`,\"\\\\r\":\"\\r\",\"\\\\t\":\"\t\"},ee=/^[\\da-f]{4}$/i,se=/^[\\u0000-\\u001F]$/;q.exports=function(ge,he){for(var le=!0,B=\"\";he<ge.length;){var R=I(ge,he);if(R===\"\\\\\"){var ae=G(ge,he,he+2);if(O(V,ae))B+=V[ae],he+=2;else{if(ae!==\"\\\\u\")throw new K('Unknown escape sequence: \"'+ae+'\"');var _e=G(ge,he+=2,he+4);if(!X(ee,_e))throw new K(\"Bad Unicode escape at: \"+he);B+=de(U(_e,16)),he+=4}}else{if(R==='\"'){le=!1,he++;break}if(X(se,R))throw new K(\"Bad control character in string literal at: \"+he);B+=R,he++}}if(le)throw new K(\"Unterminated string at: \"+he);return{value:B,end:he}}},9167:function(q,Y,E){var j=E(4576);q.exports=j},1056:function(q,Y,E){var j=E(4913).f;q.exports=function(O,K,U){U in O||j(O,U,{configurable:!0,get:function(){return K[U]},set:function(de){K[U]=de}})}},6682:function(q,Y,E){var j=E(9565),O=E(8551),K=E(4901),U=E(2195),de=E(7323),I=TypeError;q.exports=function(G,X){var V=G.exec;if(K(V)){var ee=j(V,G,X);return ee!==null&&O(ee),ee}if(U(G)===\"RegExp\")return j(de,G,X);throw new I(\"RegExp#exec called on incompatible receiver\")}},7323:function(q,Y,E){var j,O,K=E(9565),U=E(9504),de=E(655),I=E(7979),G=E(8429),X=E(5745),V=E(2360),ee=E(1181).get,se=E(3635),ge=E(8814),he=X(\"native-string-replace\",String.prototype.replace),le=RegExp.prototype.exec,B=le,R=U(\"\".charAt),ae=U(\"\".indexOf),_e=U(\"\".replace),Se=U(\"\".slice),ve=(O=/b*/g,K(le,j=/a/,\"a\"),K(le,O,\"a\"),j.lastIndex!==0||O.lastIndex!==0),Te=G.BROKEN_CARET,ye=/()??/.exec(\"\")[1]!==void 0;(ve||ye||Te||se||ge)&&(B=function(je){var Le,Me,Oe,Re,$e,Ye,tt,pe=this,Fe=ee(pe),we=de(je),Ve=Fe.raw;if(Ve)return Ve.lastIndex=pe.lastIndex,Le=K(B,Ve,we),pe.lastIndex=Ve.lastIndex,Le;var Ae=Fe.groups,ce=Te&&pe.sticky,xe=K(I,pe),Be=pe.source,Qe=0,ht=we;if(ce&&(xe=_e(xe,\"y\",\"\"),ae(xe,\"g\")===-1&&(xe+=\"g\"),ht=Se(we,pe.lastIndex),pe.lastIndex>0&&(!pe.multiline||pe.multiline&&R(we,pe.lastIndex-1)!==`\n`)&&(Be=\"(?: \"+Be+\")\",ht=\" \"+ht,Qe++),Me=new RegExp(\"^(?:\"+Be+\")\",xe)),ye&&(Me=new RegExp(\"^\"+Be+\"$(?!\\\\s)\",xe)),ve&&(Oe=pe.lastIndex),Re=K(le,ce?Me:pe,ht),ce?Re?(Re.input=Se(Re.input,Qe),Re[0]=Se(Re[0],Qe),Re.index=pe.lastIndex,pe.lastIndex+=Re[0].length):pe.lastIndex=0:ve&&Re&&(pe.lastIndex=pe.global?Re.index+Re[0].length:Oe),ye&&Re&&Re.length>1&&K(he,Re[0],Me,(function(){for($e=1;$e<arguments.length-2;$e++)arguments[$e]===void 0&&(Re[$e]=void 0)})),Re&&Ae)for(Re.groups=Ye=V(null),$e=0;$e<Ae.length;$e++)Ye[(tt=Ae[$e])[0]]=Re[tt[1]];return Re}),q.exports=B},5213:function(q,Y,E){var j=E(4576),O=E(9039),K=j.RegExp,U=!O((function(){var de=!0;try{K(\".\",\"d\")}catch{de=!1}var I={},G=\"\",X=de?\"dgimsy\":\"gimsy\",V=function(ge,he){Object.defineProperty(I,ge,{get:function(){return G+=he,!0}})},ee={dotAll:\"s\",global:\"g\",ignoreCase:\"i\",multiline:\"m\",sticky:\"y\"};for(var se in de&&(ee.hasIndices=\"d\"),ee)V(se,ee[se]);return Object.getOwnPropertyDescriptor(K.prototype,\"flags\").get.call(I)!==X||G!==X}));q.exports={correct:U}},7979:function(q,Y,E){var j=E(8551);q.exports=function(){var O=j(this),K=\"\";return O.hasIndices&&(K+=\"d\"),O.global&&(K+=\"g\"),O.ignoreCase&&(K+=\"i\"),O.multiline&&(K+=\"m\"),O.dotAll&&(K+=\"s\"),O.unicode&&(K+=\"u\"),O.unicodeSets&&(K+=\"v\"),O.sticky&&(K+=\"y\"),K}},1034:function(q,Y,E){var j=E(9565),O=E(9297),K=E(1625),U=E(5213),de=E(7979),I=RegExp.prototype;q.exports=U.correct?function(G){return G.flags}:function(G){return U.correct||!K(I,G)||O(G,\"flags\")?G.flags:j(de,G)}},8429:function(q,Y,E){var j=E(9039),O=E(4576).RegExp,K=j((function(){var I=O(\"a\",\"y\");return I.lastIndex=2,I.exec(\"abcd\")!==null})),U=K||j((function(){return!O(\"a\",\"y\").sticky})),de=K||j((function(){var I=O(\"^r\",\"gy\");return I.lastIndex=2,I.exec(\"str\")!==null}));q.exports={BROKEN_CARET:de,MISSED_STICKY:U,UNSUPPORTED_Y:K}},3635:function(q,Y,E){var j=E(9039),O=E(4576).RegExp;q.exports=j((function(){var K=O(\".\",\"s\");return!(K.dotAll&&K.test(`\n`)&&K.flags===\"s\")}))},8814:function(q,Y,E){var j=E(9039),O=E(4576).RegExp;q.exports=j((function(){var K=O(\"(?<a>b)\",\"g\");return K.exec(\"b\").groups.a!==\"b\"||\"b\".replace(K,\"$<a>c\")!==\"bc\"}))},7750:function(q,Y,E){var j=E(4117),O=TypeError;q.exports=function(K){if(j(K))throw new O(\"Can't call method on \"+K);return K}},7633:function(q,Y,E){var j=E(7751),O=E(2106),K=E(8227),U=E(3724),de=K(\"species\");q.exports=function(I){var G=j(I);U&&G&&!G[de]&&O(G,de,{configurable:!0,get:function(){return this}})}},687:function(q,Y,E){var j=E(4913).f,O=E(9297),K=E(8227)(\"toStringTag\");q.exports=function(U,de,I){U&&!I&&(U=U.prototype),U&&!O(U,K)&&j(U,K,{configurable:!0,value:de})}},6119:function(q,Y,E){var j=E(5745),O=E(3392),K=j(\"keys\");q.exports=function(U){return K[U]||(K[U]=O(U))}},7629:function(q,Y,E){var j=E(6395),O=E(4576),K=E(9433),U=\"__core-js_shared__\",de=q.exports=O[U]||K(U,{});(de.versions||(de.versions=[])).push({version:\"3.48.0\",mode:j?\"pure\":\"global\",copyright:\"© 2013–2025 Denis Pushkarev (zloirock.ru), 2025–2026 CoreJS Company (core-js.io). All rights reserved.\",license:\"https://github.com/zloirock/core-js/blob/v3.48.0/LICENSE\",source:\"https://github.com/zloirock/core-js\"})},5745:function(q,Y,E){var j=E(7629);q.exports=function(O,K){return j[O]||(j[O]=K||{})}},2293:function(q,Y,E){var j=E(8551),O=E(5548),K=E(4117),U=E(8227)(\"species\");q.exports=function(de,I){var G,X=j(de).constructor;return X===void 0||K(G=j(X)[U])?I:O(G)}},8183:function(q,Y,E){var j=E(9504),O=E(1291),K=E(655),U=E(7750),de=j(\"\".charAt),I=j(\"\".charCodeAt),G=j(\"\".slice),X=function(V){return function(ee,se){var ge,he,le=K(U(ee)),B=O(se),R=le.length;return B<0||B>=R?V?\"\":void 0:(ge=I(le,B))<55296||ge>56319||B+1===R||(he=I(le,B+1))<56320||he>57343?V?de(le,B):ge:V?G(le,B,B+2):he-56320+(ge-55296<<10)+65536}};q.exports={codeAt:X(!1),charAt:X(!0)}},706:function(q,Y,E){var j=E(350).PROPER,O=E(9039),K=E(7452);q.exports=function(U){return O((function(){return!!K[U]()||\"​᠎\"[U]()!==\"​᠎\"||j&&K[U].name!==U}))}},3802:function(q,Y,E){var j=E(9504),O=E(7750),K=E(655),U=E(7452),de=j(\"\".replace),I=RegExp(\"^[\"+U+\"]+\"),G=RegExp(\"(^|[^\"+U+\"])[\"+U+\"]+$\"),X=function(V){return function(ee){var se=K(O(ee));return 1&V&&(se=de(se,I,\"\")),2&V&&(se=de(se,G,\"$1\")),se}};q.exports={start:X(1),end:X(2),trim:X(3)}},4495:function(q,Y,E){var j=E(9519),O=E(9039),K=E(4576).String;q.exports=!!Object.getOwnPropertySymbols&&!O((function(){var U=Symbol(\"symbol detection\");return!K(U)||!(Object(U)instanceof Symbol)||!Symbol.sham&&j&&j<41}))},8242:function(q,Y,E){var j=E(9565),O=E(7751),K=E(8227),U=E(6840);q.exports=function(){var de=O(\"Symbol\"),I=de&&de.prototype,G=I&&I.valueOf,X=K(\"toPrimitive\");I&&!I[X]&&U(I,X,(function(V){return j(G,this)}),{arity:1})}},1296:function(q,Y,E){var j=E(4495);q.exports=j&&!!Symbol.for&&!!Symbol.keyFor},1240:function(q,Y,E){var j=E(9504);q.exports=j(1.1.valueOf)},5610:function(q,Y,E){var j=E(1291),O=Math.max,K=Math.min;q.exports=function(U,de){var I=j(U);return I<0?O(I+de,0):K(I,de)}},5397:function(q,Y,E){var j=E(7055),O=E(7750);q.exports=function(K){return j(O(K))}},1291:function(q,Y,E){var j=E(741);q.exports=function(O){var K=+O;return K!=K||K===0?0:j(K)}},8014:function(q,Y,E){var j=E(1291),O=Math.min;q.exports=function(K){var U=j(K);return U>0?O(U,9007199254740991):0}},8981:function(q,Y,E){var j=E(7750),O=Object;q.exports=function(K){return O(j(K))}},2777:function(q,Y,E){var j=E(9565),O=E(34),K=E(757),U=E(5966),de=E(4270),I=E(8227),G=TypeError,X=I(\"toPrimitive\");q.exports=function(V,ee){if(!O(V)||K(V))return V;var se,ge=U(V,X);if(ge){if(ee===void 0&&(ee=\"default\"),se=j(ge,V,ee),!O(se)||K(se))return se;throw new G(\"Can't convert object to primitive value\")}return ee===void 0&&(ee=\"number\"),de(V,ee)}},6969:function(q,Y,E){var j=E(2777),O=E(757);q.exports=function(K){var U=j(K,\"string\");return O(U)?U:U+\"\"}},2140:function(q,Y,E){var j={};j[E(8227)(\"toStringTag\")]=\"z\",q.exports=String(j)===\"[object z]\"},655:function(q,Y,E){var j=E(6955),O=String;q.exports=function(K){if(j(K)===\"Symbol\")throw new TypeError(\"Cannot convert a Symbol value to a string\");return O(K)}},6823:function(q){var Y=String;q.exports=function(E){try{return Y(E)}catch{return\"Object\"}}},3392:function(q,Y,E){var j=E(9504),O=0,K=Math.random(),U=j(1.1.toString);q.exports=function(de){return\"Symbol(\"+(de===void 0?\"\":de)+\")_\"+U(++O+K,36)}},7040:function(q,Y,E){var j=E(4495);q.exports=j&&!Symbol.sham&&typeof Symbol.iterator==\"symbol\"},8686:function(q,Y,E){var j=E(3724),O=E(9039);q.exports=j&&O((function(){return Object.defineProperty((function(){}),\"prototype\",{value:42,writable:!1}).prototype!==42}))},8622:function(q,Y,E){var j=E(4576),O=E(4901),K=j.WeakMap;q.exports=O(K)&&/native code/.test(String(K))},511:function(q,Y,E){var j=E(9167),O=E(9297),K=E(1951),U=E(4913).f;q.exports=function(de){var I=j.Symbol||(j.Symbol={});O(I,de)||U(I,de,{value:K.f(de)})}},1951:function(q,Y,E){var j=E(8227);Y.f=j},8227:function(q,Y,E){var j=E(4576),O=E(5745),K=E(9297),U=E(3392),de=E(4495),I=E(7040),G=j.Symbol,X=O(\"wks\"),V=I?G.for||G:G&&G.withoutSetter||U;q.exports=function(ee){return K(X,ee)||(X[ee]=de&&K(G,ee)?G[ee]:V(\"Symbol.\"+ee)),X[ee]}},7452:function(q){q.exports=`\t\n\\v\\f\\r                　\\u2028\\u2029\\uFEFF`},8706:function(q,Y,E){var j=E(6518),O=E(9039),K=E(4376),U=E(34),de=E(8981),I=E(6198),G=E(6837),X=E(4659),V=E(4527),ee=E(1469),se=E(597),ge=E(8227),he=E(9519),le=ge(\"isConcatSpreadable\"),B=he>=51||!O((function(){var ae=[];return ae[le]=!1,ae.concat()[0]!==ae})),R=function(ae){if(!U(ae))return!1;var _e=ae[le];return _e!==void 0?!!_e:K(ae)};j({target:\"Array\",proto:!0,arity:1,forced:!B||!se(\"concat\")},{concat:function(ae){var _e,Se,ve,Te,ye,je=de(this),Le=ee(je,0),Me=0;for(_e=-1,ve=arguments.length;_e<ve;_e++)if(R(ye=_e===-1?je:arguments[_e]))for(Te=I(ye),G(Me+Te),Se=0;Se<Te;Se++,Me++)Se in ye&&X(Le,Me,ye[Se]);else G(Me+1),X(Le,Me++,ye);return V(Le,Me),Le}})},2008:function(q,Y,E){var j=E(6518),O=E(9213).filter;j({target:\"Array\",proto:!0,forced:!E(597)(\"filter\")},{filter:function(K){return O(this,K,arguments.length>1?arguments[1]:void 0)}})},3418:function(q,Y,E){var j=E(6518),O=E(7916);j({target:\"Array\",stat:!0,forced:!E(4428)((function(K){Array.from(K)}))},{from:O})},4423:function(q,Y,E){var j=E(6518),O=E(9617).includes,K=E(9039),U=E(6469);j({target:\"Array\",proto:!0,forced:K((function(){return!Array(1).includes()}))},{includes:function(de){return O(this,de,arguments.length>1?arguments[1]:void 0)}}),U(\"includes\")},5276:function(q,Y,E){var j=E(6518),O=E(7476),K=E(9617).indexOf,U=E(4598),de=O([].indexOf),I=!!de&&1/de([1],1,-0)<0;j({target:\"Array\",proto:!0,forced:I||!U(\"indexOf\")},{indexOf:function(G){var X=arguments.length>1?arguments[1]:void 0;return I?de(this,G,X)||0:K(this,G,X)}})},3792:function(q,Y,E){var j=E(5397),O=E(6469),K=E(6269),U=E(1181),de=E(4913).f,I=E(1088),G=E(2529),X=E(6395),V=E(3724),ee=\"Array Iterator\",se=U.set,ge=U.getterFor(ee);q.exports=I(Array,\"Array\",(function(le,B){se(this,{type:ee,target:j(le),index:0,kind:B})}),(function(){var le=ge(this),B=le.target,R=le.index++;if(!B||R>=B.length)return le.target=null,G(void 0,!0);switch(le.kind){case\"keys\":return G(R,!1);case\"values\":return G(B[R],!1)}return G([R,B[R]],!1)}),\"values\");var he=K.Arguments=K.Array;if(O(\"keys\"),O(\"values\"),O(\"entries\"),!X&&V&&he.name!==\"values\")try{de(he,\"name\",{value:\"values\"})}catch{}},8598:function(q,Y,E){var j=E(6518),O=E(9504),K=E(7055),U=E(5397),de=E(4598),I=O([].join);j({target:\"Array\",proto:!0,forced:K!==Object||!de(\"join\",\",\")},{join:function(G){return I(U(this),G===void 0?\",\":G)}})},2062:function(q,Y,E){var j=E(6518),O=E(9213).map;j({target:\"Array\",proto:!0,forced:!E(597)(\"map\")},{map:function(K){return O(this,K,arguments.length>1?arguments[1]:void 0)}})},2712:function(q,Y,E){var j=E(6518),O=E(926).left,K=E(4598),U=E(9519);j({target:\"Array\",proto:!0,forced:!E(6193)&&U>79&&U<83||!K(\"reduce\")},{reduce:function(de){var I=arguments.length;return O(this,de,I,I>1?arguments[1]:void 0)}})},4782:function(q,Y,E){var j=E(6518),O=E(4376),K=E(3517),U=E(34),de=E(5610),I=E(6198),G=E(5397),X=E(4659),V=E(4527),ee=E(8227),se=E(597),ge=E(7680),he=se(\"slice\"),le=ee(\"species\"),B=Array,R=Math.max;j({target:\"Array\",proto:!0,forced:!he},{slice:function(ae,_e){var Se,ve,Te,ye=G(this),je=I(ye),Le=de(ae,je),Me=de(_e===void 0?je:_e,je);if(O(ye)&&(Se=ye.constructor,(K(Se)&&(Se===B||O(Se.prototype))||U(Se)&&(Se=Se[le])===null)&&(Se=void 0),Se===B||Se===void 0))return ge(ye,Le,Me);for(ve=new(Se===void 0?B:Se)(R(Me-Le,0)),Te=0;Le<Me;Le++,Te++)Le in ye&&X(ve,Te,ye[Le]);return V(ve,Te),ve}})},6910:function(q,Y,E){var j=E(6518),O=E(9504),K=E(9306),U=E(8981),de=E(6198),I=E(4606),G=E(655),X=E(9039),V=E(4488),ee=E(4598),se=E(3709),ge=E(3763),he=E(9519),le=E(3607),B=[],R=O(B.sort),ae=O(B.push),_e=X((function(){B.sort(void 0)})),Se=X((function(){B.sort(null)})),ve=ee(\"sort\"),Te=!X((function(){if(he)return he<70;if(!(se&&se>3)){if(ge)return!0;if(le)return le<603;var ye,je,Le,Me,Oe=\"\";for(ye=65;ye<76;ye++){switch(je=String.fromCharCode(ye),ye){case 66:case 69:case 70:case 72:Le=3;break;case 68:case 71:Le=4;break;default:Le=2}for(Me=0;Me<47;Me++)B.push({k:je+Me,v:Le})}for(B.sort((function(Re,$e){return $e.v-Re.v})),Me=0;Me<B.length;Me++)je=B[Me].k.charAt(0),Oe.charAt(Oe.length-1)!==je&&(Oe+=je);return Oe!==\"DGBEFHACIJK\"}}));j({target:\"Array\",proto:!0,forced:_e||!Se||!ve||!Te},{sort:function(ye){ye!==void 0&&K(ye);var je=U(this);if(Te)return ye===void 0?R(je):R(je,ye);var Le,Me,Oe=[],Re=de(je);for(Me=0;Me<Re;Me++)Me in je&&ae(Oe,je[Me]);for(V(Oe,(function($e){return function(Ye,tt){return tt===void 0?-1:Ye===void 0?1:$e!==void 0?+$e(Ye,tt)||0:G(Ye)>G(tt)?1:-1}})(ye)),Le=de(Oe),Me=0;Me<Le;)je[Me]=Oe[Me++];for(;Me<Re;)I(je,Me++);return je}})},4554:function(q,Y,E){var j=E(6518),O=E(8981),K=E(5610),U=E(1291),de=E(6198),I=E(4527),G=E(6837),X=E(1469),V=E(4659),ee=E(4606),se=E(597)(\"splice\"),ge=Math.max,he=Math.min;j({target:\"Array\",proto:!0,forced:!se},{splice:function(le,B){var R,ae,_e,Se,ve,Te,ye=O(this),je=de(ye),Le=K(le,je),Me=arguments.length;for(Me===0?R=ae=0:Me===1?(R=0,ae=je-Le):(R=Me-2,ae=he(ge(U(B),0),je-Le)),G(je+R-ae),_e=X(ye,ae),Se=0;Se<ae;Se++)(ve=Le+Se)in ye&&V(_e,Se,ye[ve]);if(I(_e,ae),R<ae){for(Se=Le;Se<je-ae;Se++)Te=Se+R,(ve=Se+ae)in ye?ye[Te]=ye[ve]:ee(ye,Te);for(Se=je;Se>je-ae+R;Se--)ee(ye,Se-1)}else if(R>ae)for(Se=je-ae;Se>Le;Se--)Te=Se+R-1,(ve=Se+ae-1)in ye?ye[Te]=ye[ve]:ee(ye,Te);for(Se=0;Se<R;Se++)ye[Se+Le]=arguments[Se+2];return I(ye,je-ae+R),_e}})},739:function(q,Y,E){var j=E(6518),O=E(9039),K=E(8981),U=E(2777);j({target:\"Date\",proto:!0,arity:1,forced:O((function(){return new Date(NaN).toJSON()!==null||Date.prototype.toJSON.call({toISOString:function(){return 1}})!==1}))},{toJSON:function(de){var I=K(this),G=U(I,\"number\");return typeof G!=\"number\"||isFinite(G)?I.toISOString():null}})},9572:function(q,Y,E){var j=E(9297),O=E(6840),K=E(3640),U=E(8227)(\"toPrimitive\"),de=Date.prototype;j(de,U)||O(de,U,K)},2010:function(q,Y,E){var j=E(3724),O=E(350).EXISTS,K=E(9504),U=E(2106),de=Function.prototype,I=K(de.toString),G=/function\\b(?:\\s|\\/\\*[\\S\\s]*?\\*\\/|\\/\\/[^\\n\\r]*[\\n\\r]+)*([^\\s(/]*)/,X=K(G.exec);j&&!O&&U(de,\"name\",{configurable:!0,get:function(){try{return X(G,I(this))[1]}catch{return\"\"}}})},3110:function(q,Y,E){var j=E(6518),O=E(7751),K=E(8745),U=E(9565),de=E(9504),I=E(9039),G=E(4376),X=E(4901),V=E(5810),ee=E(757),se=E(2195),ge=E(655),he=E(7680),le=E(8235),B=E(3392),R=E(4495),ae=E(7819),_e=String,Se=O(\"JSON\",\"stringify\"),ve=de(/./.exec),Te=de(\"\".charAt),ye=de(\"\".charCodeAt),je=de(\"\".replace),Le=de(\"\".slice),Me=de([].push),Oe=de(1.1.toString),Re=/[\\uD800-\\uDFFF]/g,$e=/^[\\uD800-\\uDBFF]$/,Ye=/^[\\uDC00-\\uDFFF]$/,tt=B(),pe=tt.length,Fe=!R||I((function(){var xe=O(\"Symbol\")(\"stringify detection\");return Se([xe])!==\"[null]\"||Se({a:xe})!==\"{}\"||Se(Object(xe))!==\"{}\"})),we=I((function(){return Se(\"\\uDF06\\uD834\")!=='\"\\\\udf06\\\\ud834\"'||Se(\"\\uDEAD\")!=='\"\\\\udead\"'})),Ve=Fe?function(xe,Be){var Qe=he(arguments),ht=ce(Be);if(X(ht)||xe!==void 0&&!ee(xe))return Qe[1]=function(xt,gt){if(X(ht)&&(gt=U(ht,this,_e(xt),gt)),!ee(gt))return gt},K(Se,null,Qe)}:Se,Ae=function(xe,Be,Qe){var ht=Te(Qe,Be-1),xt=Te(Qe,Be+1);return ve($e,xe)&&!ve(Ye,xt)||ve(Ye,xe)&&!ve($e,ht)?\"\\\\u\"+Oe(ye(xe,0),16):xe},ce=function(xe){if(X(xe))return xe;if(G(xe)){for(var Be=xe.length,Qe=[],ht=0;ht<Be;ht++){var xt=xe[ht];typeof xt==\"string\"?Me(Qe,xt):typeof xt!=\"number\"&&se(xt)!==\"Number\"&&se(xt)!==\"String\"||Me(Qe,ge(xt))}var gt=Qe.length,Ut=!0;return function(Wt,Zt){if(Ut)return Ut=!1,Zt;if(G(this))return Zt;for(var Kt=0;Kt<gt;Kt++)if(Qe[Kt]===Wt)return Zt}}};Se&&j({target:\"JSON\",stat:!0,arity:3,forced:Fe||we||!ae},{stringify:function(xe,Be,Qe){var ht=ce(Be),xt=[],gt=Ve(xe,(function(vn,Ke){var at=X(ht)?U(ht,this,_e(vn),Ke):Ke;return!ae&&V(at)?tt+(Me(xt,at.rawJSON)-1):at}),Qe);if(typeof gt!=\"string\"||(we&&(gt=je(gt,Re,Ae)),ae))return gt;for(var Ut=\"\",Wt=gt.length,Zt=0;Zt<Wt;Zt++){var Kt=Te(gt,Zt);if(Kt==='\"'){var Xt=le(gt,++Zt).end-1,ln=Le(gt,Zt,Xt);Ut+=Le(ln,0,pe)===tt?xt[Le(ln,pe)]:'\"'+ln+'\"',Zt=Xt}else Ut+=Kt}return Ut}})},2892:function(q,Y,E){var j=E(6518),O=E(6395),K=E(3724),U=E(4576),de=E(9167),I=E(9504),G=E(2796),X=E(9297),V=E(3167),ee=E(1625),se=E(757),ge=E(2777),he=E(9039),le=E(8480).f,B=E(7347).f,R=E(4913).f,ae=E(1240),_e=E(3802).trim,Se=\"Number\",ve=U[Se],Te=de[Se],ye=ve.prototype,je=U.TypeError,Le=I(\"\".slice),Me=I(\"\".charCodeAt),Oe=function(tt){var pe,Fe,we,Ve,Ae,ce,xe,Be,Qe=ge(tt,\"number\");if(se(Qe))throw new je(\"Cannot convert a Symbol value to a number\");if(typeof Qe==\"string\"&&Qe.length>2){if(Qe=_e(Qe),(pe=Me(Qe,0))===43||pe===45){if((Fe=Me(Qe,2))===88||Fe===120)return NaN}else if(pe===48){switch(Me(Qe,1)){case 66:case 98:we=2,Ve=49;break;case 79:case 111:we=8,Ve=55;break;default:return+Qe}for(ce=(Ae=Le(Qe,2)).length,xe=0;xe<ce;xe++)if((Be=Me(Ae,xe))<48||Be>Ve)return NaN;return parseInt(Ae,we)}}return+Qe},Re=G(Se,!ve(\" 0o1\")||!ve(\"0b1\")||ve(\"+0x1\")),$e=function(tt){var pe,Fe=arguments.length<1?0:ve((function(we){var Ve=ge(we,\"number\");return typeof Ve==\"bigint\"?Ve:Oe(Ve)})(tt));return ee(ye,pe=this)&&he((function(){ae(pe)}))?V(Object(Fe),this,$e):Fe};$e.prototype=ye,Re&&!O&&(ye.constructor=$e),j({global:!0,constructor:!0,wrap:!0,forced:Re},{Number:$e});var Ye=function(tt,pe){for(var Fe,we=K?le(pe):\"MAX_VALUE,MIN_VALUE,NaN,NEGATIVE_INFINITY,POSITIVE_INFINITY,EPSILON,MAX_SAFE_INTEGER,MIN_SAFE_INTEGER,isFinite,isInteger,isNaN,isSafeInteger,parseFloat,parseInt,fromString,range\".split(\",\"),Ve=0;we.length>Ve;Ve++)X(pe,Fe=we[Ve])&&!X(tt,Fe)&&R(tt,Fe,B(pe,Fe))};O&&Te&&Ye(de[Se],Te),(Re||O)&&Ye(de[Se],ve)},2637:function(q,Y,E){E(6518)({target:\"Number\",stat:!0},{isInteger:E(2087)})},9085:function(q,Y,E){var j=E(6518),O=E(4213);j({target:\"Object\",stat:!0,arity:2,forced:Object.assign!==O},{assign:O})},7427:function(q,Y,E){var j=E(6518),O=E(3724),K=E(2551),U=E(9306),de=E(8981),I=E(4913);O&&j({target:\"Object\",proto:!0,forced:K},{__defineGetter__:function(G,X){I.f(de(this),G,{get:U(X),enumerable:!0,configurable:!0})}})},3851:function(q,Y,E){var j=E(6518),O=E(9039),K=E(5397),U=E(7347).f,de=E(3724);j({target:\"Object\",stat:!0,forced:!de||O((function(){U(1)})),sham:!de},{getOwnPropertyDescriptor:function(I,G){return U(K(I),G)}})},1278:function(q,Y,E){var j=E(6518),O=E(3724),K=E(5031),U=E(5397),de=E(7347),I=E(4659);j({target:\"Object\",stat:!0,sham:!O},{getOwnPropertyDescriptors:function(G){for(var X,V,ee=U(G),se=de.f,ge=K(ee),he={},le=0;ge.length>le;)(V=se(ee,X=ge[le++]))!==void 0&&I(he,X,V);return he}})},1480:function(q,Y,E){var j=E(6518),O=E(9039),K=E(298).f;j({target:\"Object\",stat:!0,forced:O((function(){return!Object.getOwnPropertyNames(1)}))},{getOwnPropertyNames:K})},9773:function(q,Y,E){var j=E(6518),O=E(4495),K=E(9039),U=E(3717),de=E(8981);j({target:\"Object\",stat:!0,forced:!O||K((function(){U.f(1)}))},{getOwnPropertySymbols:function(I){var G=U.f;return G?G(de(I)):[]}})},9432:function(q,Y,E){var j=E(6518),O=E(8981),K=E(1072);j({target:\"Object\",stat:!0,forced:E(9039)((function(){K(1)}))},{keys:function(U){return K(O(U))}})},6099:function(q,Y,E){var j=E(2140),O=E(6840),K=E(3179);j||O(Object.prototype,\"toString\",K,{unsafe:!0})},825:function(q,Y,E){var j=E(6518),O=E(7751),K=E(8745),U=E(566),de=E(5548),I=E(8551),G=E(34),X=E(2360),V=E(9039),ee=O(\"Reflect\",\"construct\"),se=Object.prototype,ge=[].push,he=V((function(){function R(){}return!(ee((function(){}),[],R)instanceof R)})),le=!V((function(){ee((function(){}))})),B=he||le;j({target:\"Reflect\",stat:!0,forced:B,sham:B},{construct:function(R,ae){de(R),I(ae);var _e=arguments.length<3?R:de(arguments[2]);if(le&&!he)return ee(R,ae,_e);if(R===_e){switch(ae.length){case 0:return new R;case 1:return new R(ae[0]);case 2:return new R(ae[0],ae[1]);case 3:return new R(ae[0],ae[1],ae[2]);case 4:return new R(ae[0],ae[1],ae[2],ae[3])}var Se=[null];return K(ge,Se,ae),new(K(U,R,Se))}var ve=_e.prototype,Te=X(G(ve)?ve:se),ye=K(R,Te,ae);return G(ye)?ye:Te}})},4864:function(q,Y,E){var j=E(3724),O=E(4576),K=E(9504),U=E(2796),de=E(3167),I=E(6699),G=E(2360),X=E(8480).f,V=E(1625),ee=E(788),se=E(655),ge=E(1034),he=E(8429),le=E(1056),B=E(6840),R=E(9039),ae=E(9297),_e=E(1181).enforce,Se=E(7633),ve=E(8227),Te=E(3635),ye=E(8814),je=ve(\"match\"),Le=O.RegExp,Me=Le.prototype,Oe=O.SyntaxError,Re=K(Me.exec),$e=K(\"\".charAt),Ye=K(\"\".replace),tt=K(\"\".indexOf),pe=K(\"\".slice),Fe=/^\\?<[^\\s\\d!#%&*+<=>@^][^\\s!#%&*+<=>@^]*>/,we=/a/g,Ve=/a/g,Ae=new Le(we)!==we,ce=he.MISSED_STICKY,xe=he.UNSUPPORTED_Y;if(U(\"RegExp\",j&&(!Ae||ce||Te||ye||R((function(){return Ve[je]=!1,Le(we)!==we||Le(Ve)===Ve||String(Le(we,\"i\"))!==\"/a/i\"}))))){for(var Be=function(xt,gt){var Ut,Wt,Zt,Kt,Xt,ln,vn=V(Me,this),Ke=ee(xt),at=gt===void 0,Lt=[],Et=xt;if(!vn&&Ke&&at&&xt.constructor===Be)return xt;if((Ke||V(Me,xt))&&(xt=xt.source,at&&(gt=ge(Et))),xt=xt===void 0?\"\":se(xt),gt=gt===void 0?\"\":se(gt),Et=xt,Te&&\"dotAll\"in we&&(Wt=!!gt&&tt(gt,\"s\")>-1)&&(gt=Ye(gt,/s/g,\"\")),Ut=gt,ce&&\"sticky\"in we&&(Zt=!!gt&&tt(gt,\"y\")>-1)&&xe&&(gt=Ye(gt,/y/g,\"\")),ye&&(Kt=(function(At){for(var Ie,mt=At.length,pt=0,nt=\"\",ze=[],ot=G(null),Pe=!1,Ge=!1,Ze=0,Je=\"\";pt<mt;pt++){if((Ie=$e(At,pt))===\"\\\\\")Ie+=$e(At,++pt);else if(Ie===\"]\")Pe=!1;else if(!Pe)switch(!0){case Ie===\"[\":Pe=!0;break;case Ie===\"(\":if(nt+=Ie,pe(At,pt+1,pt+3)===\"?:\")continue;Re(Fe,pe(At,pt+1))&&(pt+=2,Ge=!0),Ze++;continue;case(Ie===\">\"&&Ge):if(Je===\"\"||ae(ot,Je))throw new Oe(\"Invalid capture group name\");ot[Je]=!0,ze[ze.length]=[Je,Ze],Ge=!1,Je=\"\";continue}Ge?Je+=Ie:nt+=Ie}return[nt,ze]})(xt),xt=Kt[0],Lt=Kt[1]),Xt=de(Le(xt,gt),vn?this:Me,Be),(Wt||Zt||Lt.length)&&(ln=_e(Xt),Wt&&(ln.dotAll=!0,ln.raw=Be((function(At){for(var Ie,mt=At.length,pt=0,nt=\"\",ze=!1;pt<mt;pt++)(Ie=$e(At,pt))!==\"\\\\\"?ze||Ie!==\".\"?(Ie===\"[\"?ze=!0:Ie===\"]\"&&(ze=!1),nt+=Ie):nt+=\"[\\\\s\\\\S]\":nt+=Ie+$e(At,++pt);return nt})(xt),Ut)),Zt&&(ln.sticky=!0),Lt.length&&(ln.groups=Lt)),xt!==Et)try{I(Xt,\"source\",Et===\"\"?\"(?:)\":Et)}catch{}return Xt},Qe=X(Le),ht=0;Qe.length>ht;)le(Be,Le,Qe[ht++]);Me.constructor=Be,Be.prototype=Me,B(O,\"RegExp\",Be,{constructor:!0})}Se(\"RegExp\")},7495:function(q,Y,E){var j=E(6518),O=E(7323);j({target:\"RegExp\",proto:!0,forced:/./.exec!==O},{exec:O})},8781:function(q,Y,E){var j=E(350).PROPER,O=E(6840),K=E(8551),U=E(655),de=E(9039),I=E(1034),G=\"toString\",X=RegExp.prototype,V=X[G],ee=de((function(){return V.call({source:\"a\",flags:\"b\"})!==\"/a/b\"})),se=j&&V.name!==G;(ee||se)&&O(X,G,(function(){var ge=K(this);return\"/\"+U(ge.source)+\"/\"+U(I(ge))}),{unsafe:!0})},1699:function(q,Y,E){var j=E(6518),O=E(9504),K=E(5749),U=E(7750),de=E(655),I=E(1436),G=O(\"\".indexOf);j({target:\"String\",proto:!0,forced:!I(\"includes\")},{includes:function(X){return!!~G(de(U(this)),de(K(X)),arguments.length>1?arguments[1]:void 0)}})},7764:function(q,Y,E){var j=E(8183).charAt,O=E(655),K=E(1181),U=E(1088),de=E(2529),I=\"String Iterator\",G=K.set,X=K.getterFor(I);U(String,\"String\",(function(V){G(this,{type:I,string:O(V),index:0})}),(function(){var V,ee=X(this),se=ee.string,ge=ee.index;return ge>=se.length?de(void 0,!0):(V=j(se,ge),ee.index+=V.length,de(V,!1))}))},8543:function(q,Y,E){var j=E(6518),O=E(9565),K=E(7476),U=E(3994),de=E(2529),I=E(7750),G=E(8014),X=E(655),V=E(8551),ee=E(34),se=E(2195),ge=E(788),he=E(1034),le=E(5966),B=E(6840),R=E(9039),ae=E(8227),_e=E(2293),Se=E(7829),ve=E(6682),Te=E(1181),ye=E(6395),je=ae(\"matchAll\"),Le=\"RegExp String\",Me=Le+\" Iterator\",Oe=Te.set,Re=Te.getterFor(Me),$e=RegExp.prototype,Ye=TypeError,tt=K(\"\".indexOf),pe=K(\"\".matchAll),Fe=!!pe&&!R((function(){pe(\"a\",/./)})),we=U((function(Ae,ce,xe,Be){Oe(this,{type:Me,regexp:Ae,string:ce,global:xe,unicode:Be,done:!1})}),Le,(function(){var Ae=Re(this);if(Ae.done)return de(void 0,!0);var ce=Ae.regexp,xe=Ae.string,Be=ve(ce,xe);return Be===null?(Ae.done=!0,de(void 0,!0)):Ae.global?(X(Be[0])===\"\"&&(ce.lastIndex=Se(xe,G(ce.lastIndex),Ae.unicode)),de(Be,!1)):(Ae.done=!0,de(Be,!1))})),Ve=function(Ae){var ce,xe,Be,Qe=V(this),ht=X(Ae),xt=_e(Qe,RegExp),gt=X(he(Qe));return ce=new xt(xt===RegExp?Qe.source:Qe,gt),xe=!!~tt(gt,\"g\"),Be=!!~tt(gt,\"u\"),ce.lastIndex=G(Qe.lastIndex),new we(ce,ht,xe,Be)};j({target:\"String\",proto:!0,forced:Fe},{matchAll:function(Ae){var ce,xe,Be,Qe,ht=I(this);if(ee(Ae)){if(ge(Ae)&&(ce=X(I(he(Ae))),!~tt(ce,\"g\")))throw new Ye(\"`.matchAll` does not allow non-global regexes\");if(Fe)return pe(ht,Ae);if((Be=le(Ae,je))===void 0&&ye&&se(Ae)===\"RegExp\"&&(Be=Ve),Be)return O(Be,Ae,ht)}else if(Fe)return pe(ht,Ae);return xe=X(ht),Qe=new RegExp(Ae,\"g\"),ye?O(Ve,Qe,xe):Qe[je](xe)}}),ye||je in $e||B($e,je,Ve)},1761:function(q,Y,E){var j=E(9565),O=E(9504),K=E(9228),U=E(8551),de=E(34),I=E(8014),G=E(655),X=E(7750),V=E(5966),ee=E(7829),se=E(1034),ge=E(6682),he=O(\"\".indexOf);K(\"match\",(function(le,B,R){return[function(ae){var _e=X(this),Se=de(ae)?V(ae,le):void 0;return Se?j(Se,ae,_e):new RegExp(ae)[le](G(_e))},function(ae){var _e=U(this),Se=G(ae),ve=R(B,_e,Se);if(ve.done)return ve.value;var Te=G(se(_e));if(he(Te,\"g\")===-1)return ge(_e,Se);var ye=he(Te,\"u\")!==-1;_e.lastIndex=0;for(var je,Le=[],Me=0;(je=ge(_e,Se))!==null;){var Oe=G(je[0]);Le[Me]=Oe,Oe===\"\"&&(_e.lastIndex=ee(Se,I(_e.lastIndex),ye)),Me++}return Me===0?null:Le}]}))},5440:function(q,Y,E){var j=E(8745),O=E(9565),K=E(9504),U=E(9228),de=E(9039),I=E(8551),G=E(4901),X=E(34),V=E(1291),ee=E(8014),se=E(655),ge=E(7750),he=E(7829),le=E(5966),B=E(2478),R=E(1034),ae=E(6682),_e=E(8227)(\"replace\"),Se=Math.max,ve=Math.min,Te=K([].concat),ye=K([].push),je=K(\"\".indexOf),Le=K(\"\".slice),Me=\"a\".replace(/./,\"$0\")===\"$0\",Oe=!!/./[_e]&&/./[_e](\"a\",\"$0\")===\"\";U(\"replace\",(function(Re,$e,Ye){var tt=Oe?\"$\":\"$0\";return[function(pe,Fe){var we=ge(this),Ve=X(pe)?le(pe,_e):void 0;return Ve?O(Ve,pe,we,Fe):O($e,se(we),pe,Fe)},function(pe,Fe){var we=I(this),Ve=se(pe);if(typeof Fe==\"string\"&&je(Fe,tt)===-1&&je(Fe,\"$<\")===-1){var Ae=Ye($e,we,Ve,Fe);if(Ae.done)return Ae.value}var ce=G(Fe);ce||(Fe=se(Fe));var xe,Be=se(R(we)),Qe=je(Be,\"g\")!==-1;Qe&&(xe=je(Be,\"u\")!==-1,we.lastIndex=0);for(var ht,xt=[];(ht=ae(we,Ve))!==null&&(ye(xt,ht),Qe);)se(ht[0])===\"\"&&(we.lastIndex=he(Ve,ee(we.lastIndex),xe));for(var gt,Ut=\"\",Wt=0,Zt=0;Zt<xt.length;Zt++){for(var Kt,Xt=se((ht=xt[Zt])[0]),ln=Se(ve(V(ht.index),Ve.length),0),vn=[],Ke=1;Ke<ht.length;Ke++)ye(vn,(gt=ht[Ke])===void 0?gt:String(gt));var at=ht.groups;if(ce){var Lt=Te([Xt],vn,ln,Ve);at!==void 0&&ye(Lt,at),Kt=se(j(Fe,void 0,Lt))}else Kt=B(Xt,Ve,ln,vn,at,Fe);ln>=Wt&&(Ut+=Le(Ve,Wt,ln)+Kt,Wt=ln+Xt.length)}return Ut+Le(Ve,Wt)}]}),!!de((function(){var Re=/./;return Re.exec=function(){var $e=[];return $e.groups={a:\"7\"},$e},\"\".replace(Re,\"$<a>\")!==\"7\"}))||!Me||Oe)},744:function(q,Y,E){var j=E(9565),O=E(9504),K=E(9228),U=E(8551),de=E(34),I=E(7750),G=E(2293),X=E(7829),V=E(8014),ee=E(655),se=E(5966),ge=E(6682),he=E(8429),le=E(9039),B=he.UNSUPPORTED_Y,R=Math.min,ae=O([].push),_e=O(\"\".slice),Se=!le((function(){var Te=/(?:)/,ye=Te.exec;Te.exec=function(){return ye.apply(this,arguments)};var je=\"ab\".split(Te);return je.length!==2||je[0]!==\"a\"||je[1]!==\"b\"})),ve=\"abbc\".split(/(b)*/)[1]===\"c\"||\"test\".split(/(?:)/,-1).length!==4||\"ab\".split(/(?:ab)*/).length!==2||\".\".split(/(.?)(.?)/).length!==4||\".\".split(/()()/).length>1||\"\".split(/.?/).length;K(\"split\",(function(Te,ye,je){var Le=\"0\".split(void 0,0).length?function(Me,Oe){return Me===void 0&&Oe===0?[]:j(ye,this,Me,Oe)}:ye;return[function(Me,Oe){var Re=I(this),$e=de(Me)?se(Me,Te):void 0;return $e?j($e,Me,Re,Oe):j(Le,ee(Re),Me,Oe)},function(Me,Oe){var Re=U(this),$e=ee(Me);if(!ve){var Ye=je(Le,Re,$e,Oe,Le!==ye);if(Ye.done)return Ye.value}var tt=G(Re,RegExp),pe=Re.unicode,Fe=(Re.ignoreCase?\"i\":\"\")+(Re.multiline?\"m\":\"\")+(Re.unicode?\"u\":\"\")+(B?\"g\":\"y\"),we=new tt(B?\"^(?:\"+Re.source+\")\":Re,Fe),Ve=Oe===void 0?4294967295:Oe>>>0;if(Ve===0)return[];if($e.length===0)return ge(we,$e)===null?[$e]:[];for(var Ae=0,ce=0,xe=[];ce<$e.length;){we.lastIndex=B?0:ce;var Be,Qe=ge(we,B?_e($e,ce):$e);if(Qe===null||(Be=R(V(we.lastIndex+(B?ce:0)),$e.length))===Ae)ce=X($e,ce,pe);else{if(ae(xe,_e($e,Ae,ce)),xe.length===Ve)return xe;for(var ht=1;ht<=Qe.length-1;ht++)if(ae(xe,Qe[ht]),xe.length===Ve)return xe;ce=Ae=Be}}return ae(xe,_e($e,Ae)),xe}]}),ve||!Se,B)},2762:function(q,Y,E){var j=E(6518),O=E(3802).trim;j({target:\"String\",proto:!0,forced:E(706)(\"trim\")},{trim:function(){return O(this)}})},6761:function(q,Y,E){var j=E(6518),O=E(4576),K=E(9565),U=E(9504),de=E(6395),I=E(3724),G=E(4495),X=E(9039),V=E(9297),ee=E(1625),se=E(8551),ge=E(5397),he=E(6969),le=E(655),B=E(6980),R=E(2360),ae=E(1072),_e=E(8480),Se=E(298),ve=E(3717),Te=E(7347),ye=E(4913),je=E(6801),Le=E(8773),Me=E(6840),Oe=E(2106),Re=E(5745),$e=E(6119),Ye=E(421),tt=E(3392),pe=E(8227),Fe=E(1951),we=E(511),Ve=E(8242),Ae=E(687),ce=E(1181),xe=E(9213).forEach,Be=$e(\"hidden\"),Qe=\"Symbol\",ht=\"prototype\",xt=ce.set,gt=ce.getterFor(Qe),Ut=Object[ht],Wt=O.Symbol,Zt=Wt&&Wt[ht],Kt=O.RangeError,Xt=O.TypeError,ln=O.QObject,vn=Te.f,Ke=ye.f,at=Se.f,Lt=Le.f,Et=U([].push),At=Re(\"symbols\"),Ie=Re(\"op-symbols\"),mt=Re(\"wks\"),pt=!ln||!ln[ht]||!ln[ht].findChild,nt=function(et,jt,yt){var qe=vn(Ut,jt);qe&&delete Ut[jt],Ke(et,jt,yt),qe&&et!==Ut&&Ke(Ut,jt,qe)},ze=I&&X((function(){return R(Ke({},\"a\",{get:function(){return Ke(this,\"a\",{value:7}).a}})).a!==7}))?nt:Ke,ot=function(et,jt){var yt=At[et]=R(Zt);return xt(yt,{type:Qe,tag:et,description:jt}),I||(yt.description=jt),yt},Pe=function(et,jt,yt){et===Ut&&Pe(Ie,jt,yt),se(et);var qe=he(jt);return se(yt),V(At,qe)?(yt.enumerable?(V(et,Be)&&et[Be][qe]&&(et[Be][qe]=!1),yt=R(yt,{enumerable:B(0,!1)})):(V(et,Be)||Ke(et,Be,B(1,R(null))),et[Be][qe]=!0),ze(et,qe,yt)):Ke(et,qe,yt)},Ge=function(et,jt){se(et);var yt=ge(jt),qe=ae(yt).concat(Ue(yt));return xe(qe,(function(St){I&&!K(Ze,yt,St)||Pe(et,St,yt[St])})),et},Ze=function(et){var jt=he(et),yt=K(Lt,this,jt);return!(this===Ut&&V(At,jt)&&!V(Ie,jt))&&(!(yt||!V(this,jt)||!V(At,jt)||V(this,Be)&&this[Be][jt])||yt)},Je=function(et,jt){var yt=ge(et),qe=he(jt);if(yt!==Ut||!V(At,qe)||V(Ie,qe)){var St=vn(yt,qe);return!St||!V(At,qe)||V(yt,Be)&&yt[Be][qe]||(St.enumerable=!0),St}},We=function(et){var jt=at(ge(et)),yt=[];return xe(jt,(function(qe){V(At,qe)||V(Ye,qe)||Et(yt,qe)})),yt},Ue=function(et){var jt=et===Ut,yt=at(jt?Ie:ge(et)),qe=[];return xe(yt,(function(St){!V(At,St)||jt&&!V(Ut,St)||Et(qe,At[St])})),qe};G||(Wt=function(){if(ee(Zt,this))throw new Xt(\"Symbol is not a constructor\");var et=arguments.length&&arguments[0]!==void 0?le(arguments[0]):void 0,jt=tt(et),yt=function(qe){var St=this===void 0?O:this;St===Ut&&K(yt,Ie,qe),V(St,Be)&&V(St[Be],jt)&&(St[Be][jt]=!1);var Pt=B(1,qe);try{ze(St,jt,Pt)}catch(qt){if(!(qt instanceof Kt))throw qt;nt(St,jt,Pt)}};return I&&pt&&ze(Ut,jt,{configurable:!0,set:yt}),ot(jt,et)},Me(Zt=Wt[ht],\"toString\",(function(){return gt(this).tag})),Me(Wt,\"withoutSetter\",(function(et){return ot(tt(et),et)})),Le.f=Ze,ye.f=Pe,je.f=Ge,Te.f=Je,_e.f=Se.f=We,ve.f=Ue,Fe.f=function(et){return ot(pe(et),et)},I&&(Oe(Zt,\"description\",{configurable:!0,get:function(){return gt(this).description}}),de||Me(Ut,\"propertyIsEnumerable\",Ze,{unsafe:!0}))),j({global:!0,constructor:!0,wrap:!0,forced:!G,sham:!G},{Symbol:Wt}),xe(ae(mt),(function(et){we(et)})),j({target:Qe,stat:!0,forced:!G},{useSetter:function(){pt=!0},useSimple:function(){pt=!1}}),j({target:\"Object\",stat:!0,forced:!G,sham:!I},{create:function(et,jt){return jt===void 0?R(et):Ge(R(et),jt)},defineProperty:Pe,defineProperties:Ge,getOwnPropertyDescriptor:Je}),j({target:\"Object\",stat:!0,forced:!G},{getOwnPropertyNames:We}),Ve(),Ae(Wt,Qe),Ye[Be]=!0},9463:function(q,Y,E){var j=E(6518),O=E(3724),K=E(4576),U=E(9504),de=E(9297),I=E(4901),G=E(1625),X=E(655),V=E(2106),ee=E(7740),se=K.Symbol,ge=se&&se.prototype;if(O&&I(se)&&(!(\"description\"in ge)||se().description!==void 0)){var he={},le=function(){var Te=arguments.length<1||arguments[0]===void 0?void 0:X(arguments[0]),ye=G(ge,this)?new se(Te):Te===void 0?se():se(Te);return Te===\"\"&&(he[ye]=!0),ye};ee(le,se),le.prototype=ge,ge.constructor=le;var B=String(se(\"description detection\"))===\"Symbol(description detection)\",R=U(ge.valueOf),ae=U(ge.toString),_e=/^Symbol\\((.*)\\)[^)]+$/,Se=U(\"\".replace),ve=U(\"\".slice);V(ge,\"description\",{configurable:!0,get:function(){var Te=R(this);if(de(he,Te))return\"\";var ye=ae(Te),je=B?ve(ye,7,-1):Se(ye,_e,\"$1\");return je===\"\"?void 0:je}}),j({global:!0,constructor:!0,forced:!0},{Symbol:le})}},1510:function(q,Y,E){var j=E(6518),O=E(7751),K=E(9297),U=E(655),de=E(5745),I=E(1296),G=de(\"string-to-symbol-registry\"),X=de(\"symbol-to-string-registry\");j({target:\"Symbol\",stat:!0,forced:!I},{for:function(V){var ee=U(V);if(K(G,ee))return G[ee];var se=O(\"Symbol\")(ee);return G[ee]=se,X[se]=ee,se}})},2259:function(q,Y,E){E(511)(\"iterator\")},2675:function(q,Y,E){E(6761),E(1510),E(7812),E(3110),E(9773)},7812:function(q,Y,E){var j=E(6518),O=E(9297),K=E(757),U=E(6823),de=E(5745),I=E(1296),G=de(\"symbol-to-string-registry\");j({target:\"Symbol\",stat:!0,forced:!I},{keyFor:function(X){if(!K(X))throw new TypeError(U(X)+\" is not a symbol\");if(O(G,X))return G[X]}})},5700:function(q,Y,E){var j=E(511),O=E(8242);j(\"toPrimitive\"),O()},8344:function(q,Y,E){E(8543)},3500:function(q,Y,E){var j=E(4576),O=E(7400),K=E(9296),U=E(235),de=E(6699),I=function(X){if(X&&X.forEach!==U)try{de(X,\"forEach\",U)}catch{X.forEach=U}};for(var G in O)O[G]&&I(j[G]&&j[G].prototype);I(K)},2953:function(q,Y,E){var j=E(4576),O=E(7400),K=E(9296),U=E(3792),de=E(6699),I=E(687),G=E(8227)(\"iterator\"),X=U.values,V=function(se,ge){if(se){if(se[G]!==X)try{de(se,G,X)}catch{se[G]=X}if(I(se,ge,!0),O[ge]){for(var he in U)if(se[he]!==U[he])try{de(se,he,U[he])}catch{se[he]=U[he]}}}};for(var ee in O)V(j[ee]&&j[ee].prototype,ee);V(K,\"DOMTokenList\")}},d={};function u(q){var Y=d[q];if(Y!==void 0)return Y.exports;var E=d[q]={exports:{}};return c[q].call(E.exports,E,E.exports,u),E.exports}u.d=function(q,Y){for(var E in Y)u.o(Y,E)&&!u.o(q,E)&&Object.defineProperty(q,E,{enumerable:!0,get:Y[E]})},u.g=(function(){if(typeof globalThis==\"object\")return globalThis;try{return this||new Function(\"return this\")()}catch{if(typeof window==\"object\")return window}})(),u.o=function(q,Y){return Object.prototype.hasOwnProperty.call(q,Y)},u.r=function(q){typeof Symbol<\"u\"&&Symbol.toStringTag&&Object.defineProperty(q,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(q,\"__esModule\",{value:!0})};var m={};function p(q){return(function(Y){if(Array.isArray(Y))return y(Y)})(q)||(function(Y){if(typeof Symbol<\"u\"&&Y[Symbol.iterator]!=null||Y[\"@@iterator\"]!=null)return Array.from(Y)})(q)||f(q)||(function(){throw new TypeError(`Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)})()}function f(q,Y){if(q){if(typeof q==\"string\")return y(q,Y);var E={}.toString.call(q).slice(8,-1);return E===\"Object\"&&q.constructor&&(E=q.constructor.name),E===\"Map\"||E===\"Set\"?Array.from(q):E===\"Arguments\"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(E)?y(q,Y):void 0}}function y(q,Y){(Y==null||Y>q.length)&&(Y=q.length);for(var E=0,j=Array(Y);E<Y;E++)j[E]=q[E];return j}function v(q){return v=typeof Symbol==\"function\"&&typeof Symbol.iterator==\"symbol\"?function(Y){return typeof Y}:function(Y){return Y&&typeof Symbol==\"function\"&&Y.constructor===Symbol&&Y!==Symbol.prototype?\"symbol\":typeof Y},v(q)}function b(q,Y){for(var E=0;E<Y.length;E++){var j=Y[E];j.enumerable=j.enumerable||!1,j.configurable=!0,\"value\"in j&&(j.writable=!0),Object.defineProperty(q,_(j.key),j)}}function g(q,Y,E){return(Y=_(Y))in q?Object.defineProperty(q,Y,{value:E,enumerable:!0,configurable:!0,writable:!0}):q[Y]=E,q}function _(q){var Y=(function(E,j){if(v(E)!=\"object\"||!E)return E;var O=E[Symbol.toPrimitive];if(O!==void 0){var K=O.call(E,j);if(v(K)!=\"object\")return K;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(j===\"string\"?String:Number)(E)})(q,\"string\");return v(Y)==\"symbol\"?Y:Y+\"\"}u.r(m),u.d(m,{SimpleKeyboard:function(){return be},default:function(){return Ce}}),u(5276),u(8598),u(4782),u(4554),u(2010),u(7427),u(6099),u(7495),u(8781),u(5440),u(744),u(2762),typeof Element>\"u\"||\"remove\"in Element.prototype||(Element.prototype.remove=function(){this.parentNode&&this.parentNode.removeChild(this)}),typeof self<\"u\"&&\"document\"in self&&((!(\"classList\"in document.createElement(\"_\"))||document.createElementNS&&!(\"classList\"in document.createElementNS(\"http://www.w3.org/2000/svg\",\"g\")))&&(function(q){if(\"Element\"in q){var Y=\"classList\",E=\"prototype\",j=q.Element[E],O=Object,K=String[E].trim||function(){return this.replace(/^\\s+|\\s+$/g,\"\")},U=Array[E].indexOf||function(se){for(var ge=0,he=this.length;ge<he;ge++)if(ge in this&&this[ge]===se)return ge;return-1},de=function(se,ge){this.name=se,this.code=DOMException[se],this.message=ge},I=function(se,ge){if(ge===\"\")throw new de(\"SYNTAX_ERR\",\"The token must not be empty.\");if(/\\s/.test(ge))throw new de(\"INVALID_CHARACTER_ERR\",\"The token must not contain space characters.\");return U.call(se,ge)},G=function(se){for(var ge=K.call(se.getAttribute(\"class\")||\"\"),he=ge?ge.split(/\\s+/):[],le=0,B=he.length;le<B;le++)this.push(he[le]);this._updateClassName=function(){se.setAttribute(\"class\",this.toString())}},X=G[E]=[],V=function(){return new G(this)};if(de[E]=Error[E],X.item=function(se){return this[se]||null},X.contains=function(se){return~I(this,se+\"\")},X.add=function(){var se,ge=arguments,he=0,le=ge.length,B=!1;do~I(this,se=ge[he]+\"\")||(this.push(se),B=!0);while(++he<le);B&&this._updateClassName()},X.remove=function(){var se,ge,he=arguments,le=0,B=he.length,R=!1;do for(ge=I(this,se=he[le]+\"\");~ge;)this.splice(ge,1),R=!0,ge=I(this,se);while(++le<B);R&&this._updateClassName()},X.toggle=function(se,ge){var he=this.contains(se),le=he?ge!==!0&&\"remove\":ge!==!1&&\"add\";return le&&this[le](se),ge===!0||ge===!1?ge:!he},X.replace=function(se,ge){var he=I(se+\"\");~he&&(this.splice(he,1,ge),this._updateClassName())},X.toString=function(){return this.join(\" \")},O.defineProperty){var ee={get:V,enumerable:!0,configurable:!0};try{O.defineProperty(j,Y,ee)}catch(se){se.number!==void 0&&se.number!==-2146823252||(ee.enumerable=!1,O.defineProperty(j,Y,ee))}}else O[E].__defineGetter__&&j.__defineGetter__(Y,V)}})(self),(function(){var q=document.createElement(\"_\");if(q.classList.add(\"c1\",\"c2\"),!q.classList.contains(\"c2\")){var Y=function(j){var O=DOMTokenList.prototype[j];DOMTokenList.prototype[j]=function(K){var U,de=arguments.length;for(U=0;U<de;U++)K=arguments[U],O.call(this,K)}};Y(\"add\"),Y(\"remove\")}if(q.classList.toggle(\"c3\",!1),q.classList.contains(\"c3\")){var E=DOMTokenList.prototype.toggle;DOMTokenList.prototype.toggle=function(j,O){return 1 in arguments&&!this.contains(j)==!O?O:E.call(this,j)}}\"replace\"in document.createElement(\"_\").classList||(DOMTokenList.prototype.replace=function(j,O){var K=this.toString().split(\" \"),U=K.indexOf(j+\"\");~U&&(K=K.slice(U),this.remove.apply(this,K),this.add(O),this.add.apply(this,K.slice(1)))}),q=null})()),u(2675),u(9463),u(2259),u(5700),u(8706),u(2008),u(3418),u(4423),u(3792),u(2062),u(6910),u(739),u(9572),u(2892),u(9085),u(3851),u(1278),u(9432),u(4864),u(1699),u(7764),u(8344),u(3500),u(2953),u(2712),u(2637),u(1480),u(825),u(1761);var C=(function(){return q=function j(O){var K=O.getOptions,U=O.getCaretPosition,de=O.getCaretPositionEnd,I=O.dispatch;(function(G,X){if(!(G instanceof X))throw new TypeError(\"Cannot call a class as a function\")})(this,j),g(this,\"getOptions\",void 0),g(this,\"getCaretPosition\",void 0),g(this,\"getCaretPositionEnd\",void 0),g(this,\"dispatch\",void 0),g(this,\"maxLengthReached\",void 0),g(this,\"isStandardButton\",(function(G){return G&&!(G[0]===\"{\"&&G[G.length-1]===\"}\")})),this.getOptions=K,this.getCaretPosition=U,this.getCaretPositionEnd=de,this.dispatch=I,j.bindMethods(j,this)},Y=[{key:\"getButtonType\",value:function(j){return j.includes(\"{\")&&j.includes(\"}\")&&j!==\"{//}\"?\"functionBtn\":\"standardBtn\"}},{key:\"getButtonClass\",value:function(j){var O=this.getButtonType(j),K=j.replace(\"{\",\"\").replace(\"}\",\"\"),U=\"\";return O!==\"standardBtn\"&&(U=\" hg-button-\".concat(K)),\"hg-\".concat(O).concat(U)}},{key:\"getDefaultDiplay\",value:function(){return{\"{bksp}\":\"backspace\",\"{backspace}\":\"backspace\",\"{enter}\":\"< enter\",\"{shift}\":\"shift\",\"{shiftleft}\":\"shift\",\"{shiftright}\":\"shift\",\"{alt}\":\"alt\",\"{s}\":\"shift\",\"{tab}\":\"tab\",\"{lock}\":\"caps\",\"{capslock}\":\"caps\",\"{accept}\":\"Submit\",\"{space}\":\" \",\"{//}\":\" \",\"{esc}\":\"esc\",\"{escape}\":\"esc\",\"{f1}\":\"f1\",\"{f2}\":\"f2\",\"{f3}\":\"f3\",\"{f4}\":\"f4\",\"{f5}\":\"f5\",\"{f6}\":\"f6\",\"{f7}\":\"f7\",\"{f8}\":\"f8\",\"{f9}\":\"f9\",\"{f10}\":\"f10\",\"{f11}\":\"f11\",\"{f12}\":\"f12\",\"{numpaddivide}\":\"/\",\"{numlock}\":\"lock\",\"{arrowup}\":\"↑\",\"{arrowleft}\":\"←\",\"{arrowdown}\":\"↓\",\"{arrowright}\":\"→\",\"{prtscr}\":\"print\",\"{scrolllock}\":\"scroll\",\"{pause}\":\"pause\",\"{insert}\":\"ins\",\"{home}\":\"home\",\"{pageup}\":\"up\",\"{delete}\":\"del\",\"{forwarddelete}\":\"del\",\"{end}\":\"end\",\"{pagedown}\":\"down\",\"{numpadmultiply}\":\"*\",\"{numpadsubtract}\":\"-\",\"{numpadadd}\":\"+\",\"{numpadenter}\":\"enter\",\"{period}\":\".\",\"{numpaddecimal}\":\".\",\"{numpad0}\":\"0\",\"{numpad1}\":\"1\",\"{numpad2}\":\"2\",\"{numpad3}\":\"3\",\"{numpad4}\":\"4\",\"{numpad5}\":\"5\",\"{numpad6}\":\"6\",\"{numpad7}\":\"7\",\"{numpad8}\":\"8\",\"{numpad9}\":\"9\"}}},{key:\"getButtonDisplayName\",value:function(j,O){return(O=arguments.length>2&&arguments[2]!==void 0&&arguments[2]?Object.assign({},this.getDefaultDiplay(),O):O||this.getDefaultDiplay())[j]||j}},{key:\"getUpdatedInput\",value:function(j,O,K){var U=arguments.length>3&&arguments[3]!==void 0?arguments[3]:K,de=arguments.length>4&&arguments[4]!==void 0&&arguments[4],I=this.getOptions(),G=[K,U,de],X=O;return(j===\"{bksp}\"||j===\"{backspace}\")&&X.length>0?X=this.removeAt.apply(this,[X].concat(G)):(j===\"{delete}\"||j===\"{forwarddelete}\")&&X.length>0?X=this.removeForwardsAt.apply(this,[X].concat(G)):j===\"{space}\"?X=this.addStringAt.apply(this,[X,\" \"].concat(G)):j!==\"{tab}\"||typeof I.tabCharOnTab==\"boolean\"&&I.tabCharOnTab===!1?j!==\"{enter}\"&&j!==\"{numpadenter}\"||!I.newLineOnEnter?j.includes(\"numpad\")&&Number.isInteger(Number(j[j.length-2]))?X=this.addStringAt.apply(this,[X,j[j.length-2]].concat(G)):j===\"{numpaddivide}\"?X=this.addStringAt.apply(this,[X,\"/\"].concat(G)):j===\"{numpadmultiply}\"?X=this.addStringAt.apply(this,[X,\"*\"].concat(G)):j===\"{numpadsubtract}\"?X=this.addStringAt.apply(this,[X,\"-\"].concat(G)):j===\"{numpadadd}\"?X=this.addStringAt.apply(this,[X,\"+\"].concat(G)):j===\"{numpaddecimal}\"?X=this.addStringAt.apply(this,[X,\".\"].concat(G)):j===\"{\"||j===\"}\"?X=this.addStringAt.apply(this,[X,j].concat(G)):j.includes(\"{\")||j.includes(\"}\")||(X=this.addStringAt.apply(this,[X,j].concat(G))):X=this.addStringAt.apply(this,[X,`\n`].concat(G)):X=this.addStringAt.apply(this,[X,\"\t\"].concat(G)),I.debug&&console.log(\"Input will be: \"+X),X}},{key:\"updateCaretPos\",value:function(j){var O=arguments.length>1&&arguments[1]!==void 0&&arguments[1],K=this.updateCaretPosAction(j,O);this.dispatch((function(U){U.setCaretPosition(K)}))}},{key:\"updateCaretPosAction\",value:function(j){var O=arguments.length>1&&arguments[1]!==void 0&&arguments[1],K=this.getOptions(),U=this.getCaretPosition();return U!=null&&(O?U>0&&(U-=j):U+=j),K.debug&&console.log(\"Caret at:\",U),U}},{key:\"addStringAt\",value:function(j,O){var K,U=arguments.length>2&&arguments[2]!==void 0?arguments[2]:j.length,de=arguments.length>3&&arguments[3]!==void 0?arguments[3]:j.length,I=arguments.length>4&&arguments[4]!==void 0&&arguments[4];return U||U===0?(K=[j.slice(0,U),O,j.slice(de)].join(\"\"),this.isMaxLengthReached()||I&&this.updateCaretPos(O.length)):K=j+O,K}},{key:\"removeAt\",value:function(j){var O,K=arguments.length>1&&arguments[1]!==void 0?arguments[1]:j.length,U=arguments.length>2&&arguments[2]!==void 0?arguments[2]:j.length,de=arguments.length>3&&arguments[3]!==void 0&&arguments[3];if(K===0&&U===0)return j;if(K===U){var I=/([\\uD800-\\uDBFF][\\uDC00-\\uDFFF])/g;K&&K>=0?j.substring(K-2,K).match(I)?(O=j.substr(0,K-2)+j.substr(K),de&&this.updateCaretPos(2,!0)):(O=j.substr(0,K-1)+j.substr(K),de&&this.updateCaretPos(1,!0)):j.slice(-2).match(I)?(O=j.slice(0,-2),de&&this.updateCaretPos(2,!0)):(O=j.slice(0,-1),de&&this.updateCaretPos(1,!0))}else O=j.slice(0,K)+j.slice(U),de&&this.dispatch((function(G){G.setCaretPosition(K)}));return O}},{key:\"removeForwardsAt\",value:function(j){var O,K=arguments.length>1&&arguments[1]!==void 0?arguments[1]:j.length,U=arguments.length>2&&arguments[2]!==void 0?arguments[2]:j.length,de=arguments.length>3&&arguments[3]!==void 0&&arguments[3];return j!=null&&j.length&&K!==null?(K===U?O=j.substring(K,K+2).match(/([\\uD800-\\uDBFF][\\uDC00-\\uDFFF])/g)?j.substr(0,K)+j.substr(K+2):j.substr(0,K)+j.substr(K+1):(O=j.slice(0,K)+j.slice(U),de&&this.dispatch((function(I){I.setCaretPosition(K)}))),O):j}},{key:\"handleMaxLength\",value:function(j,O){var K=this.getOptions(),U=K.maxLength,de=j[K.inputName||\"default\"],I=O.length-1>=U;if(O.length<=de.length)return!1;if(Number.isInteger(U))return K.debug&&console.log(\"maxLength (num) reached:\",I),I?(this.maxLengthReached=!0,!0):(this.maxLengthReached=!1,!1);if(v(U)===\"object\"){var G=O.length-1>=U[K.inputName||\"default\"];return K.debug&&console.log(\"maxLength (obj) reached:\",G),G?(this.maxLengthReached=!0,!0):(this.maxLengthReached=!1,!1)}}},{key:\"isMaxLengthReached\",value:function(){return!!this.maxLengthReached}},{key:\"isTouchDevice\",value:function(){return\"ontouchstart\"in window||navigator.maxTouchPoints}},{key:\"pointerEventsSupported\",value:function(){return!!window.PointerEvent}},{key:\"camelCase\",value:function(j){return j?j.toLowerCase().trim().split(/[.\\-_\\s]/g).reduce((function(O,K){return K.length?O+K[0].toUpperCase()+K.slice(1):O})):\"\"}},{key:\"chunkArray\",value:function(j,O){return p(Array(Math.ceil(j.length/O))).map((function(K,U){return j.slice(O*U,O+O*U)}))}},{key:\"escapeRegex\",value:function(j){return j.replace(/[-\\/\\\\^$*+?.()|[\\]{}]/g,\"\\\\$&\")}},{key:\"getRtlOffset\",value:function(j,O){var K=j,U=O.indexOf(\"‫\");return U<j&&U!=-1&&K--,O.indexOf(\"‬\")<j&&U!=-1&&K--,K<0?0:K}},{key:\"isConstructor\",value:function(j){try{Reflect.construct(String,[],j)}catch{return!1}return!0}}],E=[{key:\"bindMethods\",value:function(j,O){var K,U=(function(I,G){var X=typeof Symbol<\"u\"&&I[Symbol.iterator]||I[\"@@iterator\"];if(!X){if(Array.isArray(I)||(X=f(I))||G){X&&(I=X);var V=0,ee=function(){};return{s:ee,n:function(){return V>=I.length?{done:!0}:{done:!1,value:I[V++]}},e:function(le){throw le},f:ee}}throw new TypeError(`Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var se,ge=!0,he=!1;return{s:function(){X=X.call(I)},n:function(){var le=X.next();return ge=le.done,le},e:function(le){he=!0,se=le},f:function(){try{ge||X.return==null||X.return()}finally{if(he)throw se}}}})(Object.getOwnPropertyNames(j.prototype));try{for(U.s();!(K=U.n()).done;){var de=K.value;de===\"constructor\"||de===\"bindMethods\"||(O[de]=O[de].bind(O))}}catch(I){U.e(I)}finally{U.f()}}}],Y&&b(q.prototype,Y),E&&b(q,E),Object.defineProperty(q,\"prototype\",{writable:!1}),q;var q,Y,E})();g(C,\"noop\",(function(){}));var P=C;function N(q){return N=typeof Symbol==\"function\"&&typeof Symbol.iterator==\"symbol\"?function(Y){return typeof Y}:function(Y){return Y&&typeof Symbol==\"function\"&&Y.constructor===Symbol&&Y!==Symbol.prototype?\"symbol\":typeof Y},N(q)}function A(q,Y){for(var E=0;E<Y.length;E++){var j=Y[E];j.enumerable=j.enumerable||!1,j.configurable=!0,\"value\"in j&&(j.writable=!0),Object.defineProperty(q,F(j.key),j)}}function T(q,Y,E){return(Y=F(Y))in q?Object.defineProperty(q,Y,{value:E,enumerable:!0,configurable:!0,writable:!0}):q[Y]=E,q}function F(q){var Y=(function(E,j){if(N(E)!=\"object\"||!E)return E;var O=E[Symbol.toPrimitive];if(O!==void 0){var K=O.call(E,j);if(N(K)!=\"object\")return K;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(j===\"string\"?String:Number)(E)})(q,\"string\");return N(Y)==\"symbol\"?Y:Y+\"\"}var k=(function(){return q=function j(O){var K=this,U=O.dispatch,de=O.getOptions;(function(I,G){if(!(I instanceof G))throw new TypeError(\"Cannot call a class as a function\")})(this,j),T(this,\"getOptions\",void 0),T(this,\"dispatch\",void 0),T(this,\"isModifierKey\",(function(I){return I.altKey||I.ctrlKey||I.shiftKey||[\"Tab\",\"CapsLock\",\"Esc\",\"ArrowUp\",\"ArrowDown\",\"ArrowLeft\",\"ArrowRight\"].includes(I.code||I.key||K.keyCodeToKey(I?.keyCode))})),this.dispatch=U,this.getOptions=de,P.bindMethods(j,this)},Y=[{key:\"handleHighlightKeyDown\",value:function(j){var O=this.getOptions();O.physicalKeyboardHighlightPreventDefault&&this.isModifierKey(j)&&(j.preventDefault(),j.stopImmediatePropagation());var K=this.getSimpleKeyboardLayoutKey(j);this.dispatch((function(U){var de,I,G=U.getButtonElement(K),X=U.getButtonElement(\"{\".concat(K,\"}\"));if(G)de=G,I=K;else{if(!X)return;de=X,I=\"{\".concat(K,\"}\")}var V,ee,se,ge,he=function(B){B.style.background=O.physicalKeyboardHighlightBgColor||\"#dadce4\",B.style.color=O.physicalKeyboardHighlightTextColor||\"black\"};if(de)if(Array.isArray(de)){if(de.forEach((function(B){return he(B)})),O.physicalKeyboardHighlightPress)if(O.physicalKeyboardHighlightPressUsePointerEvents)(V=de[0])===null||V===void 0||(ee=V.onpointerdown)===null||ee===void 0||ee.call(V,j);else if(O.physicalKeyboardHighlightPressUseClick){var le;(le=de[0])===null||le===void 0||le.click()}else U.handleButtonClicked(I,j)}else he(de),O.physicalKeyboardHighlightPress&&(O.physicalKeyboardHighlightPressUsePointerEvents?(se=de)===null||se===void 0||(ge=se.onpointerdown)===null||ge===void 0||ge.call(se,j):O.physicalKeyboardHighlightPressUseClick?de.click():U.handleButtonClicked(I,j))}))}},{key:\"handleHighlightKeyUp\",value:function(j){var O=this.getOptions();O.physicalKeyboardHighlightPreventDefault&&this.isModifierKey(j)&&(j.preventDefault(),j.stopImmediatePropagation());var K=this.getSimpleKeyboardLayoutKey(j);this.dispatch((function(U){var de,I,G,X=U.getButtonElement(K)||U.getButtonElement(\"{\".concat(K,\"}\")),V=function(ee){ee.removeAttribute&&ee.removeAttribute(\"style\")};X&&(Array.isArray(X)?(X.forEach((function(ee){return V(ee)})),O.physicalKeyboardHighlightPressUsePointerEvents&&((de=X[0])===null||de===void 0||(I=de.onpointerup)===null||I===void 0||I.call(de,j))):(V(X),O.physicalKeyboardHighlightPressUsePointerEvents&&(X==null||(G=X.onpointerup)===null||G===void 0||G.call(X,j))))}))}},{key:\"getSimpleKeyboardLayoutKey\",value:function(j){var O,K=\"\",U=j.code||j.key||this.keyCodeToKey(j?.keyCode);return(K=U!=null&&U.includes(\"Numpad\")||U!=null&&U.includes(\"Shift\")||U!=null&&U.includes(\"Space\")||U!=null&&U.includes(\"Backspace\")||U!=null&&U.includes(\"Control\")||U!=null&&U.includes(\"Alt\")||U!=null&&U.includes(\"Meta\")?j.code||\"\":j.key||this.keyCodeToKey(j?.keyCode)||\"\").length>1?(O=K)===null||O===void 0?void 0:O.toLowerCase():K}},{key:\"keyCodeToKey\",value:function(j){return{8:\"Backspace\",9:\"Tab\",13:\"Enter\",16:\"Shift\",17:\"Ctrl\",18:\"Alt\",19:\"Pause\",20:\"CapsLock\",27:\"Esc\",32:\"Space\",33:\"PageUp\",34:\"PageDown\",35:\"End\",36:\"Home\",37:\"ArrowLeft\",38:\"ArrowUp\",39:\"ArrowRight\",40:\"ArrowDown\",45:\"Insert\",46:\"Delete\",48:\"0\",49:\"1\",50:\"2\",51:\"3\",52:\"4\",53:\"5\",54:\"6\",55:\"7\",56:\"8\",57:\"9\",65:\"A\",66:\"B\",67:\"C\",68:\"D\",69:\"E\",70:\"F\",71:\"G\",72:\"H\",73:\"I\",74:\"J\",75:\"K\",76:\"L\",77:\"M\",78:\"N\",79:\"O\",80:\"P\",81:\"Q\",82:\"R\",83:\"S\",84:\"T\",85:\"U\",86:\"V\",87:\"W\",88:\"X\",89:\"Y\",90:\"Z\",91:\"Meta\",96:\"Numpad0\",97:\"Numpad1\",98:\"Numpad2\",99:\"Numpad3\",100:\"Numpad4\",101:\"Numpad5\",102:\"Numpad6\",103:\"Numpad7\",104:\"Numpad8\",105:\"Numpad9\",106:\"NumpadMultiply\",107:\"NumpadAdd\",109:\"NumpadSubtract\",110:\"NumpadDecimal\",111:\"NumpadDivide\",112:\"F1\",113:\"F2\",114:\"F3\",115:\"F4\",116:\"F5\",117:\"F6\",118:\"F7\",119:\"F8\",120:\"F9\",121:\"F10\",122:\"F11\",123:\"F12\",144:\"NumLock\",145:\"ScrollLock\",186:\";\",187:\"=\",188:\",\",189:\"-\",190:\".\",191:\"/\",192:\"`\",219:\"[\",220:\"\\\\\",221:\"]\",222:\"'\"}[j]||\"\"}}],Y&&A(q.prototype,Y),E&&A(q,E),Object.defineProperty(q,\"prototype\",{writable:!1}),q;var q,Y,E})();function D(q){return D=typeof Symbol==\"function\"&&typeof Symbol.iterator==\"symbol\"?function(Y){return typeof Y}:function(Y){return Y&&typeof Symbol==\"function\"&&Y.constructor===Symbol&&Y!==Symbol.prototype?\"symbol\":typeof Y},D(q)}function H(q,Y){for(var E=0;E<Y.length;E++){var j=Y[E];j.enumerable=j.enumerable||!1,j.configurable=!0,\"value\"in j&&(j.writable=!0),Object.defineProperty(q,Q(j.key),j)}}function z(q,Y,E){return(Y=Q(Y))in q?Object.defineProperty(q,Y,{value:E,enumerable:!0,configurable:!0,writable:!0}):q[Y]=E,q}function Q(q){var Y=(function(E,j){if(D(E)!=\"object\"||!E)return E;var O=E[Symbol.toPrimitive];if(O!==void 0){var K=O.call(E,j);if(D(K)!=\"object\")return K;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(j===\"string\"?String:Number)(E)})(q,\"string\");return D(Y)==\"symbol\"?Y:Y+\"\"}var L=(function(){return q=function j(O){var K=O.utilities,U=O.options;(function(de,I){if(!(de instanceof I))throw new TypeError(\"Cannot call a class as a function\")})(this,j),z(this,\"utilities\",void 0),z(this,\"options\",void 0),z(this,\"candidateBoxElement\",void 0),z(this,\"pageIndex\",0),z(this,\"pageSize\",void 0),this.utilities=K,this.options=U,P.bindMethods(j,this),this.pageSize=this.utilities.getOptions().layoutCandidatesPageSize||5},Y=[{key:\"destroy\",value:function(){this.candidateBoxElement&&(this.candidateBoxElement.remove(),this.pageIndex=0)}},{key:\"show\",value:function(j){var O=this,K=j.candidateValue,U=j.targetElement,de=j.onSelect;if(K&&K.length){var I=this.utilities.chunkArray(K.split(\" \"),this.pageSize);this.renderPage({candidateListPages:I,targetElement:U,pageIndex:this.pageIndex,nbPages:I.length,onItemSelected:function(G,X){de(G,X),O.destroy()}})}}},{key:\"renderPage\",value:function(j){var O,K=this,U=j.candidateListPages,de=j.targetElement,I=j.pageIndex,G=j.nbPages,X=j.onItemSelected;(O=this.candidateBoxElement)===null||O===void 0||O.remove(),this.candidateBoxElement=document.createElement(\"div\"),this.candidateBoxElement.className=\"hg-candidate-box\";var V=document.createElement(\"ul\");V.className=\"hg-candidate-box-list\",U[I].forEach((function(R){var ae,_e=document.createElement(\"li\"),Se=function(){var ve=new(K.options.useTouchEvents?TouchEvent:MouseEvent)(\"click\");return Object.defineProperty(ve,\"target\",{value:_e}),ve};_e.className=\"hg-candidate-box-list-item\",_e.innerHTML=((ae=K.options.display)===null||ae===void 0?void 0:ae[R])||R,K.options.useTouchEvents?_e.ontouchstart=function(ve){return X(R,ve||Se())}:_e.onclick=function(){var ve=arguments.length>0&&arguments[0]!==void 0?arguments[0]:Se();return X(R,ve)},V.appendChild(_e)}));var ee=I>0,se=document.createElement(\"div\");se.classList.add(\"hg-candidate-box-prev\"),ee&&se.classList.add(\"hg-candidate-box-btn-active\");var ge=function(){ee&&K.renderPage({candidateListPages:U,targetElement:de,pageIndex:I-1,nbPages:G,onItemSelected:X})};this.options.useTouchEvents?se.ontouchstart=ge:se.onclick=ge,this.candidateBoxElement.appendChild(se),this.candidateBoxElement.appendChild(V);var he=I<G-1,le=document.createElement(\"div\");le.classList.add(\"hg-candidate-box-next\"),he&&le.classList.add(\"hg-candidate-box-btn-active\");var B=function(){he&&K.renderPage({candidateListPages:U,targetElement:de,pageIndex:I+1,nbPages:G,onItemSelected:X})};this.options.useTouchEvents?le.ontouchstart=B:le.onclick=B,this.candidateBoxElement.appendChild(le),de.prepend(this.candidateBoxElement)}}],Y&&H(q.prototype,Y),E&&H(q,E),Object.defineProperty(q,\"prototype\",{writable:!1}),q;var q,Y,E})(),te=L;function ie(q){return(function(Y){if(Array.isArray(Y))return J(Y)})(q)||(function(Y){if(typeof Symbol<\"u\"&&Y[Symbol.iterator]!=null||Y[\"@@iterator\"]!=null)return Array.from(Y)})(q)||(function(Y,E){if(Y){if(typeof Y==\"string\")return J(Y,E);var j={}.toString.call(Y).slice(8,-1);return j===\"Object\"&&Y.constructor&&(j=Y.constructor.name),j===\"Map\"||j===\"Set\"?Array.from(Y):j===\"Arguments\"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(j)?J(Y,E):void 0}})(q)||(function(){throw new TypeError(`Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)})()}function J(q,Y){(Y==null||Y>q.length)&&(Y=q.length);for(var E=0,j=Array(Y);E<Y;E++)j[E]=q[E];return j}function oe(q){return oe=typeof Symbol==\"function\"&&typeof Symbol.iterator==\"symbol\"?function(Y){return typeof Y}:function(Y){return Y&&typeof Symbol==\"function\"&&Y.constructor===Symbol&&Y!==Symbol.prototype?\"symbol\":typeof Y},oe(q)}function fe(q,Y){var E=Object.keys(q);if(Object.getOwnPropertySymbols){var j=Object.getOwnPropertySymbols(q);Y&&(j=j.filter((function(O){return Object.getOwnPropertyDescriptor(q,O).enumerable}))),E.push.apply(E,j)}return E}function re(q,Y){for(var E=0;E<Y.length;E++){var j=Y[E];j.enumerable=j.enumerable||!1,j.configurable=!0,\"value\"in j&&(j.writable=!0),Object.defineProperty(q,ne(j.key),j)}}function W(q,Y,E){return(Y=ne(Y))in q?Object.defineProperty(q,Y,{value:E,enumerable:!0,configurable:!0,writable:!0}):q[Y]=E,q}function ne(q){var Y=(function(E,j){if(oe(E)!=\"object\"||!E)return E;var O=E[Symbol.toPrimitive];if(O!==void 0){var K=O.call(E,j);if(oe(K)!=\"object\")return K;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(j===\"string\"?String:Number)(E)})(q,\"string\");return oe(Y)==\"symbol\"?Y:Y+\"\"}var me=(function(){return q=function j(O,K){var U=this;if((function(ge,he){if(!(ge instanceof he))throw new TypeError(\"Cannot call a class as a function\")})(this,j),W(this,\"input\",void 0),W(this,\"options\",void 0),W(this,\"utilities\",void 0),W(this,\"caretPosition\",void 0),W(this,\"caretPositionEnd\",void 0),W(this,\"keyboardDOM\",void 0),W(this,\"keyboardPluginClasses\",void 0),W(this,\"keyboardDOMClass\",void 0),W(this,\"buttonElements\",void 0),W(this,\"currentInstanceName\",void 0),W(this,\"allKeyboardInstances\",void 0),W(this,\"keyboardInstanceNames\",void 0),W(this,\"isFirstKeyboardInstance\",void 0),W(this,\"physicalKeyboard\",void 0),W(this,\"modules\",void 0),W(this,\"activeButtonClass\",void 0),W(this,\"holdInteractionTimeout\",void 0),W(this,\"holdTimeout\",void 0),W(this,\"isMouseHold\",void 0),W(this,\"initialized\",void 0),W(this,\"candidateBox\",void 0),W(this,\"keyboardRowsDOM\",void 0),W(this,\"defaultName\",\"default\"),W(this,\"activeInputElement\",null),W(this,\"handleParams\",(function(ge,he){var le,B,R;if(typeof ge==\"string\")le=ge.split(\".\").join(\"\"),B=document.querySelector(\".\".concat(le)),R=he;else if(ge instanceof HTMLDivElement){if(!ge.className)throw console.warn(\"Any DOM element passed as parameter must have a class.\"),new Error(\"KEYBOARD_DOM_CLASS_ERROR\");le=ge.className.split(\" \")[0],B=ge,R=he}else le=\"simple-keyboard\",B=document.querySelector(\".\".concat(le)),R=ge;return{keyboardDOMClass:le,keyboardDOM:B,options:R}})),W(this,\"getOptions\",(function(){return U.options})),W(this,\"getCaretPosition\",(function(){return U.caretPosition})),W(this,\"getCaretPositionEnd\",(function(){return U.caretPositionEnd})),W(this,\"registerModule\",(function(ge,he){U.modules[ge]||(U.modules[ge]={}),he(U.modules[ge])})),W(this,\"getKeyboardClassString\",(function(){for(var ge=arguments.length,he=new Array(ge),le=0;le<ge;le++)he[le]=arguments[le];return[U.keyboardDOMClass].concat(he).filter((function(B){return!!B})).join(\" \")})),typeof window<\"u\"){var de=this.handleParams(O,K),I=de.keyboardDOMClass,G=de.keyboardDOM,X=de.options,V=X===void 0?{}:X;this.utilities=new P({getOptions:this.getOptions,getCaretPosition:this.getCaretPosition,getCaretPositionEnd:this.getCaretPositionEnd,dispatch:this.dispatch}),this.caretPosition=null,this.caretPositionEnd=null,this.keyboardDOM=G,this.options=(function(ge){for(var he=1;he<arguments.length;he++){var le=arguments[he]!=null?arguments[he]:{};he%2?fe(Object(le),!0).forEach((function(B){W(ge,B,le[B])})):Object.getOwnPropertyDescriptors?Object.defineProperties(ge,Object.getOwnPropertyDescriptors(le)):fe(Object(le)).forEach((function(B){Object.defineProperty(ge,B,Object.getOwnPropertyDescriptor(le,B))}))}return ge})({layoutName:\"default\",theme:\"hg-theme-default\",inputName:\"default\",preventMouseDownDefault:!1,enableLayoutCandidates:!0,excludeFromLayout:{}},V),this.keyboardPluginClasses=\"\",P.bindMethods(j,this);var ee=this.options.inputName,se=ee===void 0?this.defaultName:ee;if(this.input={},this.input[se]=\"\",this.keyboardDOMClass=I,this.buttonElements={},window.SimpleKeyboardInstances||(window.SimpleKeyboardInstances={}),this.currentInstanceName=this.utilities.camelCase(this.keyboardDOMClass),window.SimpleKeyboardInstances[this.currentInstanceName]=this,this.allKeyboardInstances=window.SimpleKeyboardInstances,this.keyboardInstanceNames=Object.keys(window.SimpleKeyboardInstances),this.isFirstKeyboardInstance=this.keyboardInstanceNames[0]===this.currentInstanceName,this.physicalKeyboard=new k({dispatch:this.dispatch,getOptions:this.getOptions}),this.candidateBox=this.options.enableLayoutCandidates?new te({utilities:this.utilities,options:this.options}):null,!this.keyboardDOM)throw console.warn('\".'.concat(I,'\" was not found in the DOM.')),new Error(\"KEYBOARD_DOM_ERROR\");this.render(),this.modules={},this.loadModules()}},Y=[{key:\"setCaretPosition\",value:function(j){var O=arguments.length>1&&arguments[1]!==void 0?arguments[1]:j;this.caretPosition=j,this.caretPositionEnd=O}},{key:\"getInputCandidates\",value:function(j){var O=this,K=this.options,U=K.layoutCandidates,de=K.layoutCandidatesCaseSensitiveMatch;if(!U||oe(U)!==\"object\")return{};var I=Object.keys(U).filter((function(V){var ee=j.substring(0,O.getCaretPositionEnd()||0)||j,se=new RegExp(\"\".concat(O.utilities.escapeRegex(V),\"$\"),de?\"g\":\"gi\");return!!ie(ee.matchAll(se)).length}));if(I.length>1){var G=I.sort((function(V,ee){return ee.length-V.length}))[0];return{candidateKey:G,candidateValue:U[G]}}if(I.length){var X=I[0];return{candidateKey:X,candidateValue:U[X]}}return{}}},{key:\"showCandidatesBox\",value:function(j,O,K){var U=this;this.candidateBox&&this.candidateBox.show({candidateValue:O,targetElement:K,onSelect:function(de,I){var G=U.options,X=G.layoutCandidatesCaseSensitiveMatch,V=G.disableCandidateNormalization,ee=G.enableLayoutCandidatesKeyPress,se=de;V||(se=de.normalize(\"NFD\")),typeof U.options.beforeInputUpdate==\"function\"&&U.options.beforeInputUpdate(U);var ge=U.getInput(U.options.inputName,!0),he=U.getCaretPositionEnd()||0,le=ge.substring(0,he||0)||ge,B=new RegExp(\"\".concat(U.utilities.escapeRegex(j),\"$\"),X?\"g\":\"gi\"),R=le.replace(B,se),ae=ge.replace(le,R),_e=R.length-le.length,Se=(he||ge.length)+_e;Se<0&&(Se=0),U.setInput(ae,U.options.inputName,!0),U.setCaretPosition(Se),ee&&typeof U.options.onKeyPress==\"function\"&&U.options.onKeyPress(de,I),typeof U.options.onChange==\"function\"&&U.options.onChange(U.getInput(U.options.inputName,!0),I),typeof U.options.onChangeAll==\"function\"&&U.options.onChangeAll(U.getAllInputs(),I)}})}},{key:\"handleButtonClicked\",value:function(j,O){var K=this.options,U=K.inputName,de=U===void 0?this.defaultName:U,I=K.debug;if(j!==\"{//}\"){this.input[de]||(this.input[de]=\"\"),typeof this.options.beforeInputUpdate==\"function\"&&this.options.beforeInputUpdate(this);var G=this.utilities.getUpdatedInput(j,this.input[de],this.caretPosition,this.caretPositionEnd);if(this.utilities.isStandardButton(j)&&this.activeInputElement&&this.input[de]&&this.input[de]===G&&this.caretPosition===0&&this.caretPositionEnd===G.length)return this.setInput(\"\",this.options.inputName,!0),this.setCaretPosition(0),this.activeInputElement.value=\"\",this.activeInputElement.setSelectionRange(0,0),void this.handleButtonClicked(j,O);if(typeof this.options.onKeyPress==\"function\"&&this.options.onKeyPress(j,O),this.input[de]!==G&&(!this.options.inputPattern||this.options.inputPattern&&this.inputPatternIsValid(G))){if(this.options.maxLength&&this.utilities.handleMaxLength(this.input,G))return;var X=this.utilities.getUpdatedInput(j,this.input[de],this.caretPosition,this.caretPositionEnd,!0);if(this.setInput(X,this.options.inputName,!0),I&&console.log(\"Input changed:\",this.getAllInputs()),this.options.debug&&console.log(\"Caret at: \",this.getCaretPosition(),this.getCaretPositionEnd(),\"(\".concat(this.keyboardDOMClass,\")\"),O?.type),this.options.syncInstanceInputs&&this.syncInstanceInputs(),typeof this.options.onChange==\"function\"&&this.options.onChange(this.getInput(this.options.inputName,!0),O),typeof this.options.onChangeAll==\"function\"&&this.options.onChangeAll(this.getAllInputs(),O),O!=null&&O.target&&this.options.enableLayoutCandidates){var V,ee=this.getInputCandidates(G),se=ee.candidateKey,ge=ee.candidateValue;se&&ge?this.showCandidatesBox(se,ge,this.keyboardDOM):(V=this.candidateBox)===null||V===void 0||V.destroy()}}this.caretPositionEnd&&this.caretPosition!==this.caretPositionEnd&&(this.setCaretPosition(this.caretPositionEnd,this.caretPositionEnd),this.activeInputElement&&this.activeInputElement.setSelectionRange(this.caretPositionEnd,this.caretPositionEnd),this.options.debug&&console.log(\"Caret position aligned\",this.caretPosition)),I&&console.log(\"Key pressed:\",j)}}},{key:\"getMouseHold\",value:function(){return this.isMouseHold}},{key:\"setMouseHold\",value:function(j){this.options.syncInstanceInputs?this.dispatch((function(O){O.isMouseHold=j})):this.isMouseHold=j}},{key:\"handleButtonMouseDown\",value:function(j,O){var K=this;O&&(this.options.preventMouseDownDefault&&O.preventDefault(),this.options.stopMouseDownPropagation&&O.stopPropagation(),O.target.classList.add(this.activeButtonClass)),this.holdInteractionTimeout&&clearTimeout(this.holdInteractionTimeout),this.holdTimeout&&clearTimeout(this.holdTimeout),this.setMouseHold(!0),this.options.disableButtonHold||(this.holdTimeout=window.setTimeout((function(){(K.getMouseHold()&&(!j.includes(\"{\")&&!j.includes(\"}\")||j===\"{delete}\"||j===\"{backspace}\"||j===\"{bksp}\"||j===\"{space}\"||j===\"{tab}\")||j===\"{arrowright}\"||j===\"{arrowleft}\"||j===\"{arrowup}\"||j===\"{arrowdown}\")&&(K.options.debug&&console.log(\"Button held:\",j),K.handleButtonHold(j)),clearTimeout(K.holdTimeout)}),500))}},{key:\"handleButtonMouseUp\",value:function(j,O){var K=this;O&&(this.options.preventMouseUpDefault&&O.preventDefault&&O.preventDefault(),this.options.stopMouseUpPropagation&&O.stopPropagation&&O.stopPropagation(),!(O.target===this.keyboardDOM||O.target&&this.keyboardDOM.contains(O.target)||this.candidateBox&&this.candidateBox.candidateBoxElement&&(O.target===this.candidateBox.candidateBoxElement||O.target&&this.candidateBox.candidateBoxElement.contains(O.target)))&&this.candidateBox&&this.candidateBox.destroy()),this.recurseButtons((function(U){U.classList.remove(K.activeButtonClass)})),this.setMouseHold(!1),this.holdInteractionTimeout&&clearTimeout(this.holdInteractionTimeout),j&&typeof this.options.onKeyReleased==\"function\"&&this.options.onKeyReleased(j,O)}},{key:\"handleKeyboardContainerMouseDown\",value:function(j){this.options.preventMouseDownDefault&&j.preventDefault()}},{key:\"handleButtonHold\",value:function(j){var O=this;this.holdInteractionTimeout&&clearTimeout(this.holdInteractionTimeout),this.holdInteractionTimeout=window.setTimeout((function(){O.getMouseHold()?(O.handleButtonClicked(j),O.handleButtonHold(j)):clearTimeout(O.holdInteractionTimeout)}),100)}},{key:\"syncInstanceInputs\",value:function(){var j=this;this.dispatch((function(O){O.replaceInput(j.input),O.setCaretPosition(j.caretPosition,j.caretPositionEnd)}))}},{key:\"clearInput\",value:function(){var j=arguments.length>0&&arguments[0]!==void 0?arguments[0]:this.options.inputName||this.defaultName;this.input[j]=\"\",this.setCaretPosition(0),this.options.syncInstanceInputs&&this.syncInstanceInputs()}},{key:\"getInput\",value:function(){var j=arguments.length>0&&arguments[0]!==void 0?arguments[0]:this.options.inputName||this.defaultName,O=arguments.length>1&&arguments[1]!==void 0&&arguments[1];return this.options.syncInstanceInputs&&!O&&this.syncInstanceInputs(),this.options.rtl?\"‫\"+this.input[j].replace(\"‫\",\"\").replace(\"‬\",\"\")+\"‬\":this.input[j]}},{key:\"getAllInputs\",value:function(){var j=this,O={};return Object.keys(this.input).forEach((function(K){O[K]=j.getInput(K,!0)})),O}},{key:\"setInput\",value:function(j){var O=arguments.length>1&&arguments[1]!==void 0?arguments[1]:this.options.inputName||this.defaultName,K=arguments.length>2?arguments[2]:void 0;this.input[O]=j,!K&&this.options.syncInstanceInputs&&this.syncInstanceInputs()}},{key:\"replaceInput\",value:function(j){this.input=j}},{key:\"setOptions\",value:function(){var j=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},O=this.changedOptions(j);this.options=Object.assign(this.options,j),O.length&&(this.options.debug&&console.log(\"changedOptions\",O),this.onSetOptions(O),this.render())}},{key:\"changedOptions\",value:function(j){var O=this;return Object.keys(j).filter((function(K){return JSON.stringify(j[K])!==JSON.stringify(O.options[K])}))}},{key:\"onSetOptions\",value:function(){var j=arguments.length>0&&arguments[0]!==void 0?arguments[0]:[];j.includes(\"layoutName\")&&this.candidateBox&&this.candidateBox.destroy(),(j.includes(\"layoutCandidatesPageSize\")||j.includes(\"layoutCandidates\"))&&this.candidateBox&&(this.candidateBox.destroy(),this.candidateBox=new te({utilities:this.utilities,options:this.options}))}},{key:\"resetRows\",value:function(){this.keyboardRowsDOM&&this.keyboardRowsDOM.remove(),this.keyboardDOM.className=this.keyboardDOMClass,this.keyboardDOM.setAttribute(\"data-skInstance\",this.currentInstanceName),this.buttonElements={}}},{key:\"dispatch\",value:function(j){if(!window.SimpleKeyboardInstances)throw console.warn(\"SimpleKeyboardInstances is not defined. Dispatch cannot be called.\"),new Error(\"INSTANCES_VAR_ERROR\");return Object.keys(window.SimpleKeyboardInstances).forEach((function(O){j(window.SimpleKeyboardInstances[O],O)}))}},{key:\"addButtonTheme\",value:function(j,O){var K=this;O&&j&&(j.split(\" \").forEach((function(U){O.split(\" \").forEach((function(de){K.options.buttonTheme||(K.options.buttonTheme=[]);var I=!1;K.options.buttonTheme.map((function(G){if(G!=null&&G.class.split(\" \").includes(de)){I=!0;var X=G.buttons.split(\" \");X.includes(U)||(I=!0,X.push(U),G.buttons=X.join(\" \"))}return G})),I||K.options.buttonTheme.push({class:de,buttons:j})}))})),this.render())}},{key:\"removeButtonTheme\",value:function(j,O){var K=this;if(!j&&!O)return this.options.buttonTheme=[],void this.render();j&&Array.isArray(this.options.buttonTheme)&&this.options.buttonTheme.length&&(j.split(\" \").forEach((function(U){var de;(de=K.options)===null||de===void 0||(de=de.buttonTheme)===null||de===void 0||de.map((function(I,G){if(I&&O&&O.includes(I.class)||!O){var X,V,ee=(X=I)===null||X===void 0?void 0:X.buttons.split(\" \").filter((function(se){return se!==U}));I&&ee!=null&&ee.length?I.buttons=ee.join(\" \"):((V=K.options.buttonTheme)===null||V===void 0||V.splice(G,1),I=null)}return I}))})),this.render())}},{key:\"getButtonElement\",value:function(j){var O,K=this.buttonElements[j];return K&&(O=K.length>1?K:K[0]),O}},{key:\"inputPatternIsValid\",value:function(j){var O,K=this.options.inputPattern;if((O=K instanceof RegExp?K:K[this.options.inputName||this.defaultName])&&j){var U=O.test(j);return this.options.debug&&console.log('inputPattern (\"'.concat(O,'\"): ').concat(U?\"passed\":\"did not pass!\")),U}return!0}},{key:\"setEventListeners\",value:function(){if(this.isFirstKeyboardInstance||!this.allKeyboardInstances){this.options.debug&&console.log(\"Caret handling started (\".concat(this.keyboardDOMClass,\")\"));var j=this.options.physicalKeyboardHighlightPreventDefault,O=j!==void 0&&j;document.addEventListener(\"keyup\",this.handleKeyUp,O),document.addEventListener(\"keydown\",this.handleKeyDown,O),document.addEventListener(\"mouseup\",this.handleMouseUp),document.addEventListener(\"touchend\",this.handleTouchEnd),this.options.updateCaretOnSelectionChange&&document.addEventListener(\"selectionchange\",this.handleSelectionChange),document.addEventListener(\"select\",this.handleSelect)}}},{key:\"handleKeyUp\",value:function(j){this.caretEventHandler(j),this.options.physicalKeyboardHighlight&&this.physicalKeyboard.handleHighlightKeyUp(j)}},{key:\"handleKeyDown\",value:function(j){this.options.physicalKeyboardHighlight&&this.physicalKeyboard.handleHighlightKeyDown(j)}},{key:\"handleMouseUp\",value:function(j){this.caretEventHandler(j)}},{key:\"handleTouchEnd\",value:function(j){this.caretEventHandler(j)}},{key:\"handleSelect\",value:function(j){this.caretEventHandler(j)}},{key:\"handleSelectionChange\",value:function(j){navigator.userAgent.includes(\"Firefox\")||this.caretEventHandler(j)}},{key:\"caretEventHandler\",value:function(j){var O,K=this;j.target.tagName&&(O=j.target.tagName.toLowerCase()),this.dispatch((function(U){var de=j.target===U.keyboardDOM||j.target&&U.keyboardDOM.contains(j.target);if(K.options.syncInstanceInputs&&Array.isArray(j.path)&&(de=j.path.some((function(X){var V;return X==null||(V=X.hasAttribute)===null||V===void 0?void 0:V.call(X,\"data-skInstance\")}))),(O===\"textarea\"||O===\"input\"&&[\"text\",\"search\",\"url\",\"tel\",\"password\"].includes(j.target.type))&&!U.options.disableCaretPositioning){var I=j.target.selectionStart,G=j.target.selectionEnd;U.options.rtl&&(I=U.utilities.getRtlOffset(I,U.getInput()),G=U.utilities.getRtlOffset(G,U.getInput())),U.setCaretPosition(I,G),U.activeInputElement=j.target,U.options.debug&&console.log(\"Caret at: \",U.getCaretPosition(),U.getCaretPositionEnd(),j&&j.target.tagName.toLowerCase(),\"(\".concat(U.keyboardDOMClass,\")\"),j?.type)}else!U.options.disableCaretPositioning&&de||j?.type===\"selectionchange\"||(U.setCaretPosition(null),U.activeInputElement=null,U.options.debug&&console.log('Caret position reset due to \"'.concat(j?.type,'\" event'),j))}))}},{key:\"recurseButtons\",value:function(j){var O=this;j&&Object.keys(this.buttonElements).forEach((function(K){return O.buttonElements[K].forEach(j)}))}},{key:\"destroy\",value:function(){this.options.debug&&console.log(\"Destroying simple-keyboard instance: \".concat(this.currentInstanceName));var j=this.options.physicalKeyboardHighlightPreventDefault,O=j!==void 0&&j;document.removeEventListener(\"keyup\",this.handleKeyUp,O),document.removeEventListener(\"keydown\",this.handleKeyDown,O),document.removeEventListener(\"mouseup\",this.handleMouseUp),document.removeEventListener(\"touchend\",this.handleTouchEnd),document.removeEventListener(\"select\",this.handleSelect),this.options.updateCaretOnSelectionChange&&document.removeEventListener(\"selectionchange\",this.handleSelectionChange),document.onpointerup=null,document.ontouchend=null,document.ontouchcancel=null,document.onmouseup=null,this.recurseButtons((function(K){K&&(K.onpointerdown=null,K.onpointerup=null,K.onpointercancel=null,K.ontouchstart=null,K.ontouchend=null,K.ontouchcancel=null,K.onclick=null,K.onmousedown=null,K.onmouseup=null,K.remove(),K=null)})),this.keyboardDOM.onpointerdown=null,this.keyboardDOM.ontouchstart=null,this.keyboardDOM.onmousedown=null,this.resetRows(),this.candidateBox&&(this.candidateBox.destroy(),this.candidateBox=null),this.activeInputElement=null,this.keyboardDOM.removeAttribute(\"data-skInstance\"),this.keyboardDOM.innerHTML=\"\",window.SimpleKeyboardInstances[this.currentInstanceName]=null,delete window.SimpleKeyboardInstances[this.currentInstanceName],this.initialized=!1}},{key:\"getButtonThemeClasses\",value:function(j){var O=this.options.buttonTheme,K=[];return Array.isArray(O)&&O.forEach((function(U){if(U&&U.class&&typeof U.class==\"string\"&&U.buttons&&typeof U.buttons==\"string\"){var de=U.class.split(\" \");U.buttons.split(\" \").includes(j)&&(K=[].concat(ie(K),ie(de)))}else console.warn('Incorrect \"buttonTheme\". Please check the documentation.',U)})),K}},{key:\"setDOMButtonAttributes\",value:function(j,O){var K=this.options.buttonAttributes;Array.isArray(K)&&K.forEach((function(U){U.attribute&&typeof U.attribute==\"string\"&&U.value&&typeof U.value==\"string\"?U.buttons&&typeof U.buttons==\"string\"?U.buttons.split(\" \").includes(j)&&O(U.attribute,U.value):O(U.attribute,U.value):console.warn('Incorrect \"buttonAttributes\". Please check the documentation.',U)}))}},{key:\"onTouchDeviceDetected\",value:function(){this.processAutoTouchEvents(),this.disableContextualWindow()}},{key:\"disableContextualWindow\",value:function(){window.oncontextmenu=function(j){var O;if((O=j.target.classList)!==null&&O!==void 0&&O.contains(\"hg-button\"))return j.preventDefault(),j.stopPropagation(),!1}}},{key:\"processAutoTouchEvents\",value:function(){this.options.autoUseTouchEvents&&(this.options.useTouchEvents=!0,this.options.debug&&console.log(\"autoUseTouchEvents: Touch device detected, useTouchEvents enabled.\"))}},{key:\"onInit\",value:function(){this.options.debug&&console.log(\"\".concat(this.keyboardDOMClass,\" Initialized\")),this.setEventListeners(),typeof this.options.onInit==\"function\"&&this.options.onInit(this)}},{key:\"beforeFirstRender\",value:function(){this.utilities.isTouchDevice()&&this.onTouchDeviceDetected(),typeof this.options.beforeFirstRender==\"function\"&&this.options.beforeFirstRender(this),this.isFirstKeyboardInstance&&this.utilities.pointerEventsSupported()&&!this.options.useTouchEvents&&!this.options.useMouseEvents&&this.options.debug&&console.log(\"Using PointerEvents as it is supported by this browser\"),this.options.useTouchEvents&&this.options.debug&&console.log(\"useTouchEvents has been enabled. Only touch events will be used.\")}},{key:\"beforeRender\",value:function(){typeof this.options.beforeRender==\"function\"&&this.options.beforeRender(this)}},{key:\"onRender\",value:function(){typeof this.options.onRender==\"function\"&&this.options.onRender(this)}},{key:\"onModulesLoaded\",value:function(){typeof this.options.onModulesLoaded==\"function\"&&this.options.onModulesLoaded(this)}},{key:\"loadModules\",value:function(){var j=this;Array.isArray(this.options.modules)&&(this.options.modules.forEach((function(O){var K=j.utilities.isConstructor(O)?new O(j):O(j);K.init&&K.init(j)})),this.keyboardPluginClasses=\"modules-loaded\",this.render(),this.onModulesLoaded())}},{key:\"getModuleProp\",value:function(j,O){return!!this.modules[j]&&this.modules[j][O]}},{key:\"getModulesList\",value:function(){return Object.keys(this.modules)}},{key:\"parseRowDOMContainers\",value:function(j,O,K,U){var de=this,I=Array.from(j.children),G=0;return I.length&&K.forEach((function(X,V){var ee=U[V];if(!(ee&&ee>X))return!1;var se=X-G,ge=ee-G,he=document.createElement(\"div\");he.className+=\"hg-button-container\";var le=\"\".concat(de.options.layoutName,\"-r\").concat(O,\"c\").concat(V);he.setAttribute(\"data-skUID\",le);var B=I.splice(se,ge-se+1);G+=ge-se,B.forEach((function(R){return he.appendChild(R)})),I.splice(se,0,he),j.innerHTML=\"\",I.forEach((function(R){return j.appendChild(R)})),de.options.debug&&console.log(\"rowDOMContainer\",B,se,ge,G+1)})),j}},{key:\"render\",value:function(){var j=this;this.resetRows(),this.initialized||this.beforeFirstRender(),this.beforeRender();var O=\"hg-layout-\".concat(this.options.layoutName),K=this.options.layout||{default:[\"` 1 2 3 4 5 6 7 8 9 0 - = {bksp}\",\"{tab} q w e r t y u i o p [ ] \\\\\",\"{lock} a s d f g h j k l ; ' {enter}\",\"{shift} z x c v b n m , . / {shift}\",\".com @ {space}\"],shift:[\"~ ! @ # $ % ^ & * ( ) _ + {bksp}\",\"{tab} Q W E R T Y U I O P { } |\",'{lock} A S D F G H J K L : \" {enter}',\"{shift} Z X C V B N M < > ? {shift}\",\".com @ {space}\"]},U=this.options.useTouchEvents||!1,de=U?\"hg-touch-events\":\"\",I=this.options.useMouseEvents||!1,G=this.options.disableRowButtonContainers;this.keyboardDOM.className=this.getKeyboardClassString(this.options.theme,O,this.keyboardPluginClasses,de),this.keyboardDOM.setAttribute(\"data-skInstance\",this.currentInstanceName),this.keyboardRowsDOM=document.createElement(\"div\"),this.keyboardRowsDOM.className=\"hg-rows\",K[this.options.layoutName||this.defaultName].forEach((function(X,V){var ee=X.split(\" \");j.options.excludeFromLayout&&j.options.excludeFromLayout[j.options.layoutName||j.defaultName]&&(ee=ee.filter((function(le){return j.options.excludeFromLayout&&!j.options.excludeFromLayout[j.options.layoutName||j.defaultName].includes(le)})));var se=document.createElement(\"div\");se.className+=\"hg-row\";var ge=[],he=[];ee.forEach((function(le,B){var R,ae=!G&&typeof le==\"string\"&&le.length>1&&le.indexOf(\"[\")===0,_e=!G&&typeof le==\"string\"&&le.length>1&&le.indexOf(\"]\")===le.length-1;ae&&(ge.push(B),le=le.replace(/\\[/g,\"\")),_e&&(he.push(B),le=le.replace(/\\]/g,\"\"));var Se=j.utilities.getButtonClass(le),ve=j.utilities.getButtonDisplayName(le,j.options.display,j.options.mergeDisplay),Te=j.options.useButtonTag?\"button\":\"div\",ye=document.createElement(Te);ye.className+=\"hg-button \".concat(Se),(R=ye.classList).add.apply(R,ie(j.getButtonThemeClasses(le))),j.setDOMButtonAttributes(le,(function(Me,Oe){ye.setAttribute(Me,Oe)})),j.activeButtonClass=\"hg-activeButton\",!j.utilities.pointerEventsSupported()||U||I?U?(ye.ontouchstart=function(Me){j.handleButtonClicked(le,Me),j.handleButtonMouseDown(le,Me)},ye.ontouchend=function(Me){j.handleButtonMouseUp(le,Me)},ye.ontouchcancel=function(Me){j.handleButtonMouseUp(le,Me)}):(ye.onclick=function(Me){j.setMouseHold(!1),typeof j.options.onKeyReleased==\"function\"||j.options.useMouseEvents&&j.options.clickOnMouseDown||j.handleButtonClicked(le,Me)},ye.onmousedown=function(Me){(typeof j.options.onKeyReleased==\"function\"||j.options.useMouseEvents&&j.options.clickOnMouseDown)&&!j.isMouseHold&&j.handleButtonClicked(le,Me),j.handleButtonMouseDown(le,Me)},ye.onmouseup=function(Me){j.handleButtonMouseUp(le,Me)}):(ye.onpointerdown=function(Me){j.handleButtonClicked(le,Me),j.handleButtonMouseDown(le,Me)},ye.onpointerup=function(Me){j.handleButtonMouseUp(le,Me)},ye.onpointercancel=function(Me){j.handleButtonMouseUp(le,Me)}),ye.setAttribute(\"data-skBtn\",le);var je=\"\".concat(j.options.layoutName,\"-r\").concat(V,\"b\").concat(B);ye.setAttribute(\"data-skBtnUID\",je);var Le=document.createElement(\"span\");Le.innerHTML=ve,ye.appendChild(Le),j.buttonElements[le]||(j.buttonElements[le]=[]),j.buttonElements[le].push(ye),se.appendChild(ye)})),se=j.parseRowDOMContainers(se,V,ge,he),j.keyboardRowsDOM.appendChild(se)})),this.keyboardDOM.appendChild(this.keyboardRowsDOM),this.onRender(),this.initialized||(this.initialized=!0,!this.utilities.pointerEventsSupported()||U||I?U?(document.ontouchend=function(X){return j.handleButtonMouseUp(void 0,X)},document.ontouchcancel=function(X){return j.handleButtonMouseUp(void 0,X)},this.keyboardDOM.ontouchstart=function(X){return j.handleKeyboardContainerMouseDown(X)}):U||(document.onmouseup=function(X){return j.handleButtonMouseUp(void 0,X)},this.keyboardDOM.onmousedown=function(X){return j.handleKeyboardContainerMouseDown(X)}):(document.onpointerup=function(X){return j.handleButtonMouseUp(void 0,X)},this.keyboardDOM.onpointerdown=function(X){return j.handleKeyboardContainerMouseDown(X)}),this.onInit())}}],Y&&re(q.prototype,Y),E&&re(q,E),Object.defineProperty(q,\"prototype\",{writable:!1}),q;var q,Y,E})(),be=me,Ce=be;return m})()},442:function(l){l.exports=n}},i={};function s(l){var c=i[l];if(c!==void 0)return c.exports;var d=i[l]={exports:{}};return r[l].call(d.exports,d,d.exports,s),d.exports}s.n=function(l){var c=l&&l.__esModule?function(){return l.default}:function(){return l};return s.d(c,{a:c}),c},s.d=function(l,c){for(var d in c)s.o(c,d)&&!s.o(l,d)&&Object.defineProperty(l,d,{enumerable:!0,get:c[d]})},s.o=function(l,c){return Object.prototype.hasOwnProperty.call(l,c)},s.r=function(l){typeof Symbol<\"u\"&&Symbol.toStringTag&&Object.defineProperty(l,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(l,\"__esModule\",{value:!0})};var o={};return(function(){s.r(o),s.d(o,{KeyboardReact:function(){return v},default:function(){return b}}),s(981);var l=s(442),c=s(548),d=s.n(c);function u(g){return u=typeof Symbol==\"function\"&&typeof Symbol.iterator==\"symbol\"?function(_){return typeof _}:function(_){return _&&typeof Symbol==\"function\"&&_.constructor===Symbol&&_!==Symbol.prototype?\"symbol\":typeof _},u(g)}function m(g,_){var C=Object.keys(g);if(Object.getOwnPropertySymbols){var P=Object.getOwnPropertySymbols(g);_&&(P=P.filter((function(N){return Object.getOwnPropertyDescriptor(g,N).enumerable}))),C.push.apply(C,P)}return C}function p(g){for(var _=1;_<arguments.length;_++){var C=arguments[_]!=null?arguments[_]:{};_%2?m(Object(C),!0).forEach((function(P){f(g,P,C[P])})):Object.getOwnPropertyDescriptors?Object.defineProperties(g,Object.getOwnPropertyDescriptors(C)):m(Object(C)).forEach((function(P){Object.defineProperty(g,P,Object.getOwnPropertyDescriptor(C,P))}))}return g}function f(g,_,C){return(_=(function(P){var N=(function(A,T){if(u(A)!=\"object\"||!A)return A;var F=A[Symbol.toPrimitive];if(F!==void 0){var k=F.call(A,T);if(u(k)!=\"object\")return k;throw new TypeError(\"@@toPrimitive must return a primitive value.\")}return(T===\"string\"?String:Number)(A)})(P,\"string\");return u(N)==\"symbol\"?N:N+\"\"})(_))in g?Object.defineProperty(g,_,{value:C,enumerable:!0,configurable:!0,writable:!0}):g[_]=C,g}var y=function(g){return p(p({},g),{},{keyboardRef:null})},v=function(g){var _=g.baseClass||\"react-simple-keyboard\",C=l.useRef(null),P=l.useRef(null),N=l.useRef(null),A=l.useRef(g);return l.useEffect((function(){return function(){N.current&&N.current.destroy(),C.current=!1}}),[]),l.useEffect((function(){var T=(function(z){return p(p({},z),{},{theme:\"simple-keyboard \".concat(z.theme||\"hg-theme-default\")})})(g);if(!C.current){C.current=!0,T.debug&&console.log(\"ReactSimpleKeyboard: Init\");var F=P.current,k=\".\".concat(_);N.current=new(d())(F||k,T),T.keyboardRef&&T.keyboardRef(N.current)}var D=(function(z,Q){var L=y(Q),te=y(z);return Object.keys(L).filter((function(ie){return L[ie]!==te[ie]}))})(A.current,T);if(D.length){var H=N.current;A.current=T,H?.setOptions(T),T.debug&&console.log(\"ReactSimpleKeyboard - setOptions called due to updated props:\",D)}}),[C,_,A,g]),l.createElement(\"div\",{className:_,ref:P})},b=v})(),o})()}))})(uN)),uN.exports}var mat=uat();const hat=bc(mat),pat=new Set([\"text\",\"password\",\"email\",\"search\",\"url\",\"number\"]);function O3(t,e){(Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,\"value\")?.set??Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,\"value\")?.set)?.call(t,e),t.dispatchEvent(new Event(\"input\",{bubbles:!0}))}function fat({onVisibilityChange:t}){const[e,n]=w.useState(!1),[r,i]=w.useState(!1),s=w.useRef(!1),[o,l]=w.useState(\"default\"),c=w.useRef(null),d=w.useRef(null),u=w.useRef(null);w.useEffect(()=>{t?.(e)},[e,t]);const m=w.useCallback(v=>{if(s.current)return;const b=v.target;if(!b.closest('[data-vkb=\"false\"]')){if(b instanceof HTMLInputElement){if(!pat.has(b.type))return}else if(!(b instanceof HTMLTextAreaElement))return;c.current=b,n(!0),l(\"default\"),d.current?.setInput?.(c.current.value),setTimeout(()=>{(b.closest(\".bg-zinc-800, .rounded-lg, [data-vkb-group]\")??b).scrollIntoView({behavior:\"smooth\",block:\"nearest\"})},100)}},[]),p=w.useCallback(()=>{setTimeout(()=>{const v=document.activeElement;v&&(u.current?.contains(v)||v===c.current)||(n(!1),c.current=null)},150)},[]);w.useEffect(()=>(document.addEventListener(\"focusin\",m),document.addEventListener(\"focusout\",p),()=>{document.removeEventListener(\"focusin\",m),document.removeEventListener(\"focusout\",p)}),[m,p]);const f=w.useCallback(()=>{s.current=!0,i(!0),c.current?.blur(),c.current=null,setTimeout(()=>{n(!1),i(!1),s.current=!1},400)},[]),y=w.useCallback(v=>{const b=c.current;if(b){if(v===\"{shift}\"){l(g=>g===\"default\"?\"shift\":\"default\");return}if(v===\"{lock}\"){l(g=>g===\"default\"?\"shift\":\"default\");return}if(v===\"{close}\"){f();return}v===\"{bksp}\"?O3(b,b.value.slice(0,-1)):v===\"{space}\"?O3(b,b.value+\" \"):(O3(b,b.value+v),o===\"shift\"&&l(\"default\")),b.focus(),d.current?.setInput?.(b.value)}},[o,f]);return e?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"fixed inset-0 z-[9998] bg-transparent\",onMouseDown:v=>{v.preventDefault(),v.stopPropagation(),r||f()},onTouchStart:v=>{v.preventDefault(),v.stopPropagation(),r||f()},onClick:v=>{v.preventDefault(),v.stopPropagation()}}),!r&&a.jsx(\"div\",{ref:u,className:\"relative z-[9999] shrink-0\",onMouseDown:v=>v.preventDefault(),onTouchStart:v=>{v.target.closest(\".hg-button\")||v.preventDefault()},children:a.jsx(hat,{keyboardRef:v=>{d.current=v},layoutName:o,onKeyPress:y,theme:\"simple-keyboard vkb-theme\",layout:{default:[\"1 2 3 4 5 6 7 8 9 0 {bksp}\",\"q w e r t y u i o p\",\"{lock} a s d f g h j k l\",\"{shift} z x c v b n m . @\",\"{space} {close}\"],shift:[\"! @ # $ % ^ & * ( ) {bksp}\",\"Q W E R T Y U I O P\",\"{lock} A S D F G H J K L\",\"{shift} Z X C V B N M , _\",\"{space} {close}\"]},display:{\"{bksp}\":\"⌫\",\"{close}\":\"✕ Close\",\"{shift}\":\"⇧\",\"{lock}\":\"⇪\",\"{space}\":\" \"}})})]}):null}function gat(){nZ();const[t,e]=w.useState(null),[n,r]=w.useState(null),[i,s]=w.useState(100),{i18n:o}=Ft(),l=qo(),c=td(),d=cat(),{data:u}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings});w.useEffect(()=>{u?.language&&u.language!==o.language&&o.changeLanguage(u.language)},[u?.language,o]);const{data:m=[]}=Xe({queryKey:[\"spoolbuddy-devices\"],queryFn:()=>Gr.getDevices(),refetchInterval:3e4}),p=m[0],f=d.deviceOnline||!!p?.online,y=w.useMemo(()=>({...d,deviceOnline:f}),[d,f]),v=w.useRef(!1);w.useEffect(()=>{p&&!v.current&&(s(p.display_brightness),v.current=!0)},[p]),w.useEffect(()=>{const oe=document.documentElement,fe=oe.classList.contains(\"dark\");return oe.classList.add(\"dark\"),()=>{fe||oe.classList.remove(\"dark\")}},[]);const{data:b}=Xe({queryKey:[\"spoolbuddy-update-check\",p?.device_id],queryFn:()=>p?Gr.checkDaemonUpdate(p.device_id):Promise.resolve(null),enabled:!!p,refetchInterval:300*1e3,staleTime:0}),g=w.useRef(null);w.useEffect(()=>{if(f)if(b?.update_available&&b.latest_version){const oe=`Update available: v${b.latest_version}`;r({type:\"info\",message:oe}),g.current=oe}else g.current&&(r(null),g.current=null);else{const oe=\"SpoolBuddy device disconnected\";r({type:\"warning\",message:oe}),g.current=oe}},[f,b?.update_available,b?.latest_version]);const _=!!(d.matchedSpool||d.unknownTagUid),C=w.useRef(!1);w.useEffect(()=>{_&&!C.current&&c.pathname!==\"/spoolbuddy\"&&l(\"/spoolbuddy\"),C.current=_},[_,c.pathname,l]);const{data:P=[]}=Xe({queryKey:[\"printers\"],queryFn:()=>ue.getPrinters()}),N=Pp({queries:P.map(oe=>({queryKey:[\"printerStatus\",oe.id],queryFn:()=>ue.getPrinterStatus(oe.id),refetchInterval:1e4,select:fe=>({connected:fe?.connected})}))}),A=w.useMemo(()=>P.filter((oe,fe)=>N[fe]?.data?.connected),[P,N]),T=w.useRef(null),F=w.useRef(!1),k=50,D=w.useRef(null),H=w.useCallback(oe=>{T.current={x:oe.touches[0].clientX,y:oe.touches[0].clientY},F.current=!1},[]),z=w.useCallback(oe=>{if(!T.current)return;const fe=oe.changedTouches[0].clientX-T.current.x,re=oe.changedTouches[0].clientY-T.current.y,W=T.current.y;if(T.current=null,F.current=!1,re>=k&&Math.abs(re)>Math.abs(fe)&&W<120){ie(!0);return}if(A.length<2||Math.abs(fe)<k||Math.abs(re)>Math.abs(fe))return;const ne=A.findIndex(be=>be.id===t),me=fe<0?(ne+1)%A.length:(ne-1+A.length)%A.length;e(A[me].id)},[A,t,e]);w.useEffect(()=>{const oe=D.current;if(!oe)return;const fe=re=>{if(!T.current)return;const W=Math.abs(re.touches[0].clientX-T.current.x),ne=Math.abs(re.touches[0].clientY-T.current.y);if(F.current){re.preventDefault();return}W>10&&W>ne&&(F.current=!0,re.preventDefault())};return oe.addEventListener(\"touchmove\",fe,{passive:!1}),()=>oe.removeEventListener(\"touchmove\",fe)},[]);const[Q,L]=w.useState(!1),[te,ie]=w.useState(!1),J=i<100?{filter:`brightness(${i/100})`}:void 0;return a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{ref:D,\"data-spoolbuddy-kiosk\":!0,className:\"w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden\",style:{...J,overscrollBehaviorX:\"none\"},onTouchStart:H,onTouchEnd:z,children:[a.jsx(\"button\",{onClick:()=>ie(!0),className:\"w-full h-1.5 bg-bambu-dark-secondary flex justify-center items-center shrink-0 touch-none\",\"aria-label\":\"Open quick menu\",children:a.jsx(\"div\",{className:\"w-8 h-0.5 rounded-full bg-zinc-600\"})}),a.jsx(nat,{selectedPrinterId:t,onPrinterChange:e,deviceOnline:f}),a.jsx(\"main\",{className:\"flex-1 overflow-y-auto\",children:a.jsx(uQ,{context:{selectedPrinterId:t,setSelectedPrinterId:e,sbState:y,setAlert:r,displayBrightness:i,setDisplayBrightness:s}})}),!Q&&a.jsx(iat,{alert:n}),!Q&&a.jsx(aat,{}),a.jsx(fat,{onVisibilityChange:L})]}),a.jsx(sat,{isOpen:te,onClose:()=>ie(!1),deviceId:p?.device_id??null,deviceOnline:f})]})}function SA({color:t,isEmpty:e,size:n=32}){return e?a.jsx(\"div\",{className:\"rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center\",style:{width:n,height:n},children:a.jsx(\"div\",{className:\"w-2 h-2 rounded-full bg-zinc-600\"})}):a.jsxs(\"svg\",{width:n,height:n,viewBox:\"0 0 32 32\",children:[a.jsx(\"circle\",{cx:\"16\",cy:\"16\",r:\"14\",fill:t,stroke:\"white\",strokeWidth:\"1.5\",strokeOpacity:\"0.7\"}),a.jsx(\"circle\",{cx:\"16\",cy:\"16\",r:\"11\",fill:t,style:{filter:\"brightness(0.85)\"}})]})}const bat=\"spoolbuddy-default-core-weight\";function sce(){try{const t=localStorage.getItem(bat);if(t){const e=parseInt(t,10);if(e>=0&&e<=500)return e}}catch{}return 250}function xat({spool:t,scaleWeight:e,onClose:n,onSyncWeight:r,onAssignToAms:i}){const{t:s}=Ft(),[o,l]=w.useState(!1),[c,d]=w.useState(!1),u=t.rgba?`#${t.rgba.slice(0,6)}`:\"#808080\",m=t.core_weight&&t.core_weight>0?t.core_weight:sce(),p=e!==null?Math.round(Math.max(0,e)):null,f=p!==null?Math.round(Math.max(0,p-m)):null,y=Math.round(t.label_weight||1e3),v=f!==null?Math.min(100,Math.round(f/y*100)):null,b=v!==null?v>50?\"#22c55e\":v>20?\"#eab308\":\"#ef4444\":\"#808080\",_=Math.max(0,(t.label_weight||0)-(t.weight_used||0))+m,C=p!==null?p-_:null,P=C!==null?Math.abs(C)<=50:null,N=async()=>{if(e!==null){l(!0);try{await Gr.updateSpoolWeight(t.id,Math.round(e)),d(!0),r?.(),setTimeout(()=>d(!1),3e3)}catch(A){console.error(\"Failed to sync weight:\",A)}finally{l(!1)}}};return a.jsxs(\"div\",{className:\"flex flex-col items-center space-y-4 max-w-md\",children:[a.jsxs(\"div\",{className:\"flex items-start gap-5\",children:[a.jsxs(\"div\",{className:\"relative shrink-0\",children:[a.jsx(SA,{color:u,isEmpty:!1,size:100}),v!==null&&a.jsxs(\"div\",{className:\"absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg\",style:{backgroundColor:b},children:[v,\"%\"]})]}),a.jsxs(\"div\",{className:\"flex-1 min-w-0 pt-1\",children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-zinc-100\",children:t.color_name||\"Unknown color\"}),a.jsxs(\"p\",{className:\"text-sm text-zinc-400\",children:[t.brand,\" • \",t.material,t.subtype&&` ${t.subtype}`]}),f!==null&&a.jsxs(\"div\",{className:\"mt-3\",children:[a.jsxs(\"div\",{className:\"flex items-baseline gap-2\",children:[a.jsxs(\"span\",{className:\"text-3xl font-bold font-mono text-zinc-100\",children:[f,\"g\"]}),a.jsxs(\"span\",{className:\"text-sm text-zinc-500\",children:[\"/ \",y,\"g\"]})]}),a.jsx(\"p\",{className:\"text-xs text-zinc-500 mt-0.5\",children:s(\"spoolbuddy.spool.remaining\",\"Remaining\")}),a.jsx(\"div\",{className:\"mt-2 max-w-xs\",children:a.jsx(\"div\",{className:\"h-2 bg-zinc-700 rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:\"h-full rounded-full transition-all duration-500\",style:{width:`${v}%`,backgroundColor:b}})})})]})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full\",children:[a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:s(\"spoolbuddy.dashboard.grossWeight\",\"Gross weight\")}),a.jsx(\"span\",{className:\"font-mono text-zinc-300\",children:p!==null?`${p}g`:\"—\"})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:s(\"spoolbuddy.spool.coreWeight\",\"Core\")}),a.jsxs(\"span\",{className:\"font-mono text-zinc-300\",children:[m,\"g\"]})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:s(\"spoolbuddy.dashboard.spoolSize\",\"Spool size\")}),a.jsxs(\"span\",{className:\"font-mono text-zinc-300\",children:[y,\"g\"]})]}),a.jsxs(\"div\",{className:\"flex justify-between items-center\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:s(\"spoolbuddy.spool.scaleWeight\",\"Scale\")}),p!==null?a.jsxs(\"span\",{className:`flex items-center gap-1 font-mono ${P?\"text-green-500\":\"text-yellow-500\"}`,children:[p,\"g\",P?a.jsx(Ur,{className:\"w-3.5 h-3.5\"}):a.jsxs(a.Fragment,{children:[a.jsx(Dn,{className:\"w-3.5 h-3.5\"}),a.jsx(\"button\",{onClick:N,className:\"p-1 hover:bg-green-500/20 rounded transition-colors text-green-500\",title:s(\"spoolbuddy.dashboard.syncWeight\",\"Sync Weight\"),children:a.jsx(lr,{className:\"w-4 h-4\"})})]})]}):a.jsx(\"span\",{className:\"text-zinc-500\",children:\"—\"})]}),a.jsxs(\"div\",{className:\"flex justify-between items-center\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:s(\"spoolbuddy.dashboard.tagId\",\"Tag\")}),a.jsx(\"span\",{className:\"font-mono text-xs text-zinc-400 truncate max-w-[120px]\",title:t.tag_uid||\"\",children:t.tag_uid?t.tag_uid.slice(-8):\"—\"})]})]}),a.jsxs(\"div\",{className:\"flex gap-2 justify-center\",children:[i&&a.jsx(\"button\",{onClick:i,className:\"px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\",children:s(\"spoolbuddy.modal.assignToAms\",\"Assign to AMS\")}),a.jsx(\"button\",{onClick:N,disabled:e===null||o,className:`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${c?\"bg-green-600/20 text-green-400\":i?\"bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed\":\"bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed\"}`,children:o?\"...\":c?s(\"spoolbuddy.dashboard.weightSynced\",\"Synced!\"):s(\"spoolbuddy.dashboard.syncWeight\",\"Sync Weight\")}),n&&a.jsx(\"button\",{onClick:n,className:\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\",children:s(\"spoolbuddy.dashboard.close\",\"Close\")})]})]})}function yat({tagUid:t,scaleWeight:e,coreWeight:n,onLinkSpool:r,onAddToInventory:i,onClose:s}){const{t:o}=Ft(),l=n??sce(),c=e!==null?Math.round(Math.max(0,e)):null,d=c!==null?Math.round(Math.max(0,c-l)):null;return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center space-y-5\",children:[a.jsx(\"div\",{className:\"w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center\",children:a.jsx(\"svg\",{className:\"w-10 h-10 text-green-500\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:\"2\",d:\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z\"})})}),a.jsxs(\"div\",{children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-zinc-100\",children:o(\"spoolbuddy.dashboard.newTag\",\"New Tag Detected\")}),a.jsx(\"p\",{className:\"text-sm text-zinc-500 font-mono mt-1\",children:t})]}),c!==null&&a.jsxs(\"div\",{className:\"text-sm text-zinc-400\",children:[a.jsxs(\"span\",{className:\"font-mono font-semibold\",children:[c,\"g\"]}),\" \",o(\"spoolbuddy.dashboard.onScale\",\"on scale\"),d!==null&&d>0&&a.jsxs(\"span\",{className:\"text-zinc-500\",children:[\" • ~\",d,\"g filament\"]})]}),a.jsxs(\"div\",{className:\"flex flex-wrap gap-2 justify-center\",children:[i&&a.jsx(\"button\",{onClick:i,className:\"px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\",children:o(\"spoolbuddy.modal.addToInventory\",\"Add to Inventory\")}),r&&a.jsxs(\"button\",{onClick:r,className:\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\",children:[a.jsx(\"svg\",{className:\"w-4 h-4 inline-block mr-1.5 -mt-0.5\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:\"2\",d:\"M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1\"})}),o(\"spoolbuddy.dashboard.linkSpool\",\"Link to Spool\")]}),s&&a.jsx(\"button\",{onClick:s,className:\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\",children:o(\"spoolbuddy.dashboard.close\",\"Close\")})]})]})}function vat(t){return t?`#${t.slice(0,6)}`:\"#808080\"}function oce(t){return!t.tray_type||t.tray_type===\"\"}function wat(t){return t<=3?`AMS ${String.fromCharCode(65+t)}`:t>=128&&t<=135?`AMS HT ${String.fromCharCode(65+t-128)}`:`AMS ${t}`}function Sat({className:t}){return a.jsx(\"svg\",{className:t,viewBox:\"0 0 36 54\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:a.jsx(\"path\",{d:\"M17.8131 0.00538C18.4463 -0.15091 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00538ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z\",fill:\"#C3C2C1\"})})}function _at({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 35 53\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"path\",{d:\"M17.3165 0.0038C17.932 -0.14959 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.0038ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z\",fill:\"#C3C2C1\"}),a.jsx(\"path\",{d:\"M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z\",fill:\"#1F8FEB\"})]})}function kat({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 36 54\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"path\",{d:\"M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z\",fill:\"#1F8FEB\"}),a.jsx(\"path\",{d:\"M17.7948 0.00538C18.4273 -0.15091 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0134 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00538ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z\",fill:\"#C3C2C1\"})]})}function Nat({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 12 20\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"path\",{d:\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\",stroke:\"#C3C2C1\",strokeWidth:\"1\",fill:\"none\"}),a.jsx(\"circle\",{cx:\"6\",cy:\"15\",r:\"2.5\",stroke:\"#C3C2C1\",strokeWidth:\"1\",fill:\"none\"})]})}function Cat({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 12 20\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"rect\",{x:\"4.5\",y:\"8\",width:\"3\",height:\"4.5\",fill:\"#d4a017\",rx:\"0.5\"}),a.jsx(\"circle\",{cx:\"6\",cy:\"15\",r:\"2\",fill:\"#d4a017\"}),a.jsx(\"path\",{d:\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\",stroke:\"#C3C2C1\",strokeWidth:\"1\",fill:\"none\"})]})}function Pat({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 12 20\",fill:\"none\",xmlns:\"http://www.w3.org/2000/svg\",children:[a.jsx(\"rect\",{x:\"4.5\",y:\"3\",width:\"3\",height:\"9.5\",fill:\"#c62828\",rx:\"0.5\"}),a.jsx(\"circle\",{cx:\"6\",cy:\"15\",r:\"2\",fill:\"#c62828\"}),a.jsx(\"path\",{d:\"M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z\",stroke:\"#C3C2C1\",strokeWidth:\"1\",fill:\"none\"})]})}function lce({humidity:t,goodThreshold:e=40,fairThreshold:n=60}){let r,i;return t<=e?(r=\"#22a352\",i=Sat):t<=n?(r=\"#d4a017\",i=_at):(r=\"#c62828\",i=kat),a.jsxs(\"div\",{className:\"flex items-center gap-0.5\",children:[a.jsx(i,{className:\"w-3 h-3.5\"}),a.jsxs(\"span\",{className:\"font-medium tabular-nums text-xs\",style:{color:r},children:[t,\"%\"]})]})}function cce({temp:t,goodThreshold:e=28,fairThreshold:n=35}){let r,i;return t<=e?(r=\"#22a352\",i=Nat):t<=n?(r=\"#d4a017\",i=Cat):(r=\"#c62828\",i=Pat),a.jsxs(\"div\",{className:\"flex items-center gap-0.5\",children:[a.jsx(i,{className:\"w-3 h-3.5\"}),a.jsxs(\"span\",{className:\"font-medium tabular-nums text-xs\",style:{color:r},children:[t,\"°C\"]})]})}function x5({side:t}){return a.jsx(\"span\",{className:\"inline-flex items-center justify-center w-4 h-4 text-[9px] font-bold rounded\",style:{backgroundColor:\"#1a4d2e\",color:\"#00ae42\"},children:t})}function Tat({tray:t,slotIndex:e,isActive:n,fillOverride:r,spoolmanFill:i,onClick:s}){const o=oce(t),l=vat(t.tray_color),c=t.remain!==null&&t.remain!==void 0&&t.remain>=0?t.remain:null,d=r===0&&c!==null&&c>0?null:r,u=i??d??c;return a.jsxs(\"div\",{className:`relative flex flex-col items-center p-2.5 rounded-lg transition-all ${n?\"ring-2 ring-bambu-green\":\"\"} ${s?\"cursor-pointer hover:bg-white/5\":\"\"}`,onClick:s,children:[a.jsxs(\"div\",{className:\"relative w-16 h-16 mb-1\",children:[o?a.jsx(\"div\",{className:\"w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center\",children:a.jsx(\"div\",{className:\"w-3 h-3 rounded-full bg-gray-600\"})}):a.jsxs(\"svg\",{viewBox:\"0 0 56 56\",className:\"w-full h-full\",children:[a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"26\",fill:l}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"20\",fill:l,style:{filter:\"brightness(0.85)\"}}),a.jsx(\"ellipse\",{cx:\"20\",cy:\"20\",rx:\"6\",ry:\"4\",fill:\"white\",opacity:\"0.3\"}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"8\",fill:\"#2d2d2d\"}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"5\",fill:\"#1a1a1a\"})]}),n&&a.jsx(\"div\",{className:\"absolute -bottom-1 left-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-bambu-green rounded-full\"})]}),a.jsx(\"span\",{className:\"text-sm text-white/70 truncate max-w-full\",children:o?\"Empty\":t.tray_type||\"Unknown\"}),!o&&u!==null&&u>=0&&a.jsx(\"div\",{className:\"w-full h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden mt-1\",children:a.jsx(\"div\",{className:\"h-full rounded-full transition-all\",style:{width:`${u}%`,backgroundColor:Ox(u)}})}),a.jsx(\"span\",{className:\"absolute top-1 right-1 text-xs text-white/30\",children:e+1})]})}function dce({unit:t,activeSlot:e,onConfigureSlot:n,isDualNozzle:r,nozzleSide:i,thresholds:s,fillOverrides:o,spoolmanFillOverrides:l}){const c=t.tray||[],d=t.is_ams_ht,u=d?1:4;return a.jsxs(\"div\",{className:\"bg-bambu-dark-secondary rounded-lg p-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(\"span\",{className:\"text-white font-medium text-base\",children:wat(t.id)}),r&&i&&a.jsx(x5,{side:i})]}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[t.temp!=null&&a.jsx(cce,{temp:t.temp,goodThreshold:s?.tempGood,fairThreshold:s?.tempFair}),t.humidity!=null&&a.jsx(lce,{humidity:t.humidity,goodThreshold:s?.humidityGood,fairThreshold:s?.humidityFair})]})]}),a.jsx(\"div\",{className:`grid ${d?\"grid-cols-1 max-w-[100px] mx-auto\":\"grid-cols-4\"} gap-2`,children:Array.from({length:u}).map((m,p)=>{const f=c[p]||{id:p,tray_color:null,tray_type:\"\",tray_sub_brands:null,tray_id_name:null,tray_info_idx:null,remain:-1,k:null,cali_idx:null,tag_uid:null,tray_uuid:null,nozzle_temp_min:null,nozzle_temp_max:null};return a.jsx(Tat,{tray:f,slotIndex:p,isActive:e===p,fillOverride:o?.[`${t.id}-${p}`]??null,spoolmanFill:l?.[`${t.id}-${p}`]??null,onClick:n?()=>n(t.id,p,oce(f)?null:f):void 0},p)})})]})}function I3(t){return t<=3?`AMS ${String.fromCharCode(65+t)}`:t>=128&&t<=135?`AMS HT ${String.fromCharCode(65+t-128)}`:`AMS ${t}`}function z3(t){return!t.tray_type||t.tray_type===\"\"}function Aat(t){return t?`#${t.slice(0,6)}`:\"#808080\"}const OP=t=>(t??\"\").trim().toUpperCase();function jat(t,e){const n=OP(t),r=OP(e);return!n||!r?\"none\":n===r?\"exact\":r.includes(n)||n.includes(r)?\"partial\":\"none\"}function Mat(t,e){const n=OP(t),r=OP(e);return!n||!r?!1:n===r}function uce({isOpen:t,onClose:e,spool:n,printerId:r}){const{t:i}=Ft(),s=nn(),[o,l]=w.useState(null),[c,d]=w.useState(null),[u,m]=w.useState(!1),[p,f]=w.useState(null),[y,v]=w.useState(null);w.useEffect(()=>{t&&(l(null),d(null),m(!1),f(null),v(null))},[t]);const b=w.useCallback(Ce=>{Ce.key===\"Escape\"&&e()},[e]);w.useEffect(()=>(t&&document.addEventListener(\"keydown\",b),()=>document.removeEventListener(\"keydown\",b)),[t,b]);const{data:g}=Xe({queryKey:[\"printerStatus\",r],queryFn:()=>ue.getPrinterStatus(r),enabled:t&&r!==null,refetchInterval:5e3}),{data:_}=Xe({queryKey:[\"printer\",r],queryFn:()=>ue.getPrinter(r),enabled:t&&r!==null}),{data:C}=Xe({queryKey:[\"settings\"],queryFn:()=>ue.getSettings(),enabled:t,staleTime:300*1e3}),{data:P}=Xe({queryKey:[\"spool-assignments\",r],queryFn:()=>ue.getAssignments(r),enabled:t&&r!==null,staleTime:30*1e3}),N=w.useMemo(()=>{const Ce={};if(!P)return Ce;for(const q of P){const Y=q.spool;if(Y&&Y.label_weight>0&&Y.weight_used!=null){const E=Math.round(Math.max(0,Y.label_weight-Y.weight_used)/Y.label_weight*100);Ce[`${q.ams_id}-${q.tray_id}`]=E}}return Ce},[P]),A=C?{humidityGood:Number(C.ams_humidity_good)||40,humidityFair:Number(C.ams_humidity_fair)||60,tempGood:Number(C.ams_temp_good)||28,tempFair:Number(C.ams_temp_fair)||35}:void 0,T=g?.connected??!1,F=w.useMemo(()=>g?.ams??[],[g?.ams]),k=w.useMemo(()=>F.filter(Ce=>!Ce.is_ams_ht),[F]),D=w.useMemo(()=>F.filter(Ce=>Ce.is_ams_ht),[F]),H=w.useMemo(()=>[...g?.vt_tray??[]].sort((Ce,q)=>(Ce.id??254)-(q.id??254)),[g?.vt_tray]),z=_?.nozzle_count===2||g?.temperatures?.nozzle_2!==void 0,Q=w.useRef({});w.useEffect(()=>{g?.ams_extruder_map&&Object.keys(g.ams_extruder_map).length>0&&(Q.current=g.ams_extruder_map)},[g?.ams_extruder_map]);const L=g?.ams_extruder_map&&Object.keys(g.ams_extruder_map).length>0?g.ams_extruder_map:Q.current,te=w.useCallback(Ce=>{if(!z)return null;const q=L[String(Ce)],Y=Ce>=128?Ce-128:Ce;return(q!==void 0?q:Y)===1?\"L\":\"R\"},[z,L]),ie=it({mutationFn:async({amsId:Ce,trayId:q})=>{if(!r)throw new Error(\"No printer selected\");await ue.assignSpool({spool_id:n.id,printer_id:r,ams_id:Ce,tray_id:q})},onSuccess:()=>{d(\"success\"),l(i(\"spoolbuddy.modal.assignSuccess\",\"Assigned!\")),s.invalidateQueries({queryKey:[\"slotPresets\"]}),setTimeout(()=>e(),1500)},onError:Ce=>{d(\"error\"),l(Ce instanceof Error?Ce.message:i(\"spoolbuddy.modal.assignError\",\"Failed to assign spool.\"))}}),J=ie.isPending,oe=w.useCallback((Ce,q)=>{if(Ce===254||Ce===255){const E=Ce===254?254:254+q;return H.find(j=>(j.id??254)===E)||null}return F.find(E=>E.id===Ce)?.tray?.find(E=>E.id===q)||null},[F,H]),fe=w.useCallback((Ce,q)=>Ce<=3?`${I3(Ce)} ${i(\"ams.slot\",\"Slot\")} ${q+1}`:Ce>=128&&Ce<=135?I3(Ce):Ce===254?i(\"printers.extL\",\"Ext-L\"):z?i(\"printers.extR\",\"Ext-R\"):i(\"printers.ext\",\"Ext\"),[i,z]),re=w.useCallback((Ce,q)=>{d(\"info\"),l(i(\"spoolbuddy.modal.assigning\",\"Configuring slot...\")),ie.mutate({amsId:Ce,trayId:q})},[ie,i]),W=w.useCallback((Ce,q)=>{if(!J){if(!C?.disable_filament_warnings){const Y=oe(Ce,q);if(Y&&!z3(Y)){const E=Y.tray_sub_brands||Y.tray_type||\"\",j=jat(n.material,E),O=n.slicer_filament_name||n.slicer_filament,K=Y.tray_type||\"\",U=Mat(O,K);if(j!==\"exact\"||!U){let de=\"profile\";j===\"none\"&&!U?de=\"material_profile\":j===\"partial\"&&!U?de=\"partial_profile\":j===\"none\"?de=\"material\":j===\"partial\"&&(de=\"partial\");const I=fe(Ce,q);v({amsId:Ce,trayId:q}),f({type:de,spoolMaterial:n.material||\"\",trayMaterial:E||\"\",spoolProfile:O||void 0,trayProfile:K||void 0,location:I}),m(!0);return}}}re(Ce,q)}},[J,C?.disable_filament_warnings,n,oe,fe,re]),ne=w.useCallback(()=>{y&&(m(!1),f(null),re(y.amsId,y.trayId),v(null))},[y,re]),me=w.useMemo(()=>{const Ce=[];for(const q of D){const Y=q.tray?.[0]||{id:0,tray_color:null,tray_type:\"\",tray_sub_brands:null,tray_id_name:null,tray_info_idx:null,remain:-1,k:null,cali_idx:null,tag_uid:null,tray_uuid:null,nozzle_temp_min:null,nozzle_temp_max:null},E=N[`${q.id}-0`]??null,j=Y.remain!=null&&Y.remain>=0?Y.remain:null,O=E===0&&j!==null&&j>0?null:E;Ce.push({key:`ht-${q.id}`,label:I3(q.id),amsId:q.id,trayId:0,tray:Y,isEmpty:z3(Y),nozzleSide:te(q.id),effectiveFill:O??j})}for(const q of H){const Y=q.id??254,E=Y-254,j=N[`255-${E}`]??null,O=q.remain!=null&&q.remain>=0?q.remain:null,K=j===0&&O!==null&&O>0?null:j;Ce.push({key:`ext-${Y}`,label:z?Y===254?i(\"printers.extL\",\"Ext-L\"):i(\"printers.extR\",\"Ext-R\"):i(\"printers.ext\",\"Ext\"),amsId:255,trayId:E,tray:q,isEmpty:z3(q),nozzleSide:z?Y===254?\"L\":\"R\":null,effectiveFill:K??O})}return Ce},[D,H,z,i,te,N]);if(!t)return null;const be=n.rgba?`#${n.rgba.slice(0,6)}`:\"#808080\";return a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"fixed inset-0 z-[60] bg-bambu-dark flex flex-col\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 min-w-0\",children:[a.jsx(\"div\",{className:\"w-7 h-7 rounded-full shrink-0\",style:{backgroundColor:be}}),a.jsx(\"div\",{className:\"min-w-0\",children:a.jsxs(\"h2\",{className:\"text-sm font-semibold text-zinc-100 truncate\",children:[i(\"spoolbuddy.modal.assignToAmsTitle\",\"Assign to AMS\"),a.jsxs(\"span\",{className:\"font-normal text-zinc-500 ml-2\",children:[n.color_name||\"Unknown\",\" • \",n.brand,\" \",n.material,n.subtype&&` ${n.subtype}`]})]})})]}),a.jsx(\"button\",{onClick:e,disabled:J,className:\"p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors shrink-0 disabled:opacity-50\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),o&&a.jsxs(\"div\",{className:`mx-5 mt-3 p-3 rounded-lg flex items-center gap-3 border shrink-0 ${c===\"info\"?\"bg-blue-500/10 border-blue-500/40\":c===\"success\"?\"bg-green-500/10 border-green-500/40\":\"bg-red-500/10 border-red-500/40\"}`,children:[c===\"info\"&&a.jsx(ft,{className:\"w-4 h-4 text-blue-400 animate-spin shrink-0\"}),c===\"success\"&&a.jsx(yr,{className:\"w-4 h-4 text-green-400 shrink-0\"}),c===\"error\"&&a.jsx(Ma,{className:\"w-4 h-4 text-red-400 shrink-0\"}),a.jsx(\"span\",{className:`text-sm ${c===\"info\"?\"text-blue-300\":c===\"success\"?\"text-green-300\":\"text-red-300\"}`,children:o})]}),a.jsx(\"div\",{className:\"flex-1 flex flex-col gap-3 p-4 min-h-0 overflow-y-auto\",children:!T&&r?a.jsx(\"div\",{className:\"flex-1 flex items-center justify-center\",children:a.jsx(\"div\",{className:\"text-center text-white/50\",children:a.jsx(\"p\",{className:\"text-lg mb-2\",children:i(\"spoolbuddy.ams.printerDisconnected\",\"Printer disconnected\")})})}):F.length===0&&H.length===0?a.jsx(\"div\",{className:\"flex-1 flex items-center justify-center\",children:a.jsxs(\"div\",{className:\"text-center text-white/50\",children:[a.jsx(da,{className:\"w-12 h-12 mx-auto mb-3 opacity-50\"}),a.jsx(\"p\",{className:\"text-lg mb-2\",children:i(\"spoolbuddy.ams.noData\",\"No AMS detected\")}),a.jsx(\"p\",{className:\"text-sm\",children:i(\"spoolbuddy.ams.connectAms\",\"Connect an AMS to see filament slots\")})]})}):a.jsxs(a.Fragment,{children:[k.length>0&&a.jsx(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0\",children:k.map(Ce=>a.jsx(dce,{unit:Ce,activeSlot:null,onConfigureSlot:(q,Y)=>W(Ce.id,Y),isDualNozzle:z,nozzleSide:te(Ce.id),thresholds:A,fillOverrides:N},Ce.id))}),me.length>0&&a.jsx(\"div\",{className:\"flex gap-2 shrink-0\",children:me.map(({key:Ce,label:q,amsId:Y,trayId:E,tray:j,isEmpty:O,nozzleSide:K,effectiveFill:U})=>{const de=Aat(j.tray_color);return a.jsxs(\"div\",{onClick:()=>W(Y,E),className:`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${J?\"opacity-50 pointer-events-none\":\"\"}`,children:[a.jsx(\"div\",{className:\"relative w-10 h-10 shrink-0\",children:O?a.jsx(\"div\",{className:\"w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center\",children:a.jsx(\"div\",{className:\"w-1.5 h-1.5 rounded-full bg-gray-600\"})}):a.jsxs(\"svg\",{viewBox:\"0 0 56 56\",className:\"w-full h-full\",children:[a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"26\",fill:de}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"20\",fill:de,style:{filter:\"brightness(0.85)\"}}),a.jsx(\"ellipse\",{cx:\"20\",cy:\"20\",rx:\"6\",ry:\"4\",fill:\"white\",opacity:\"0.3\"}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"8\",fill:\"#2d2d2d\"}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"5\",fill:\"#1a1a1a\"})]})}),a.jsxs(\"div\",{className:\"min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"span\",{className:\"text-xs text-white/50 font-medium\",children:q}),K&&a.jsx(x5,{side:K})]}),a.jsx(\"div\",{className:\"text-sm text-white/80 truncate\",children:O?\"Empty\":j.tray_type||\"?\"})]}),!O&&U!=null&&U>=0&&a.jsx(\"div\",{className:\"w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse\",children:a.jsx(\"div\",{className:\"w-full rounded-full\",style:{height:`${U}%`,backgroundColor:Ox(U)}})})]},Ce)})})]})}),a.jsx(\"div\",{className:\"flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0\",children:a.jsx(\"button\",{onClick:e,disabled:J,className:\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors min-h-[44px] disabled:opacity-50\",children:c===\"success\"?i(\"spoolbuddy.dashboard.close\",\"Close\"):i(\"spoolbuddy.modal.cancel\",\"Cancel\")})})]}),u&&p&&(()=>{let Ce=\"\";return p.type===\"material\"?Ce=i(\"inventory.assignMismatchMessage\",{spoolMaterial:p.spoolMaterial,trayMaterial:p.trayMaterial,location:p.location}):p.type===\"partial\"?Ce=i(\"inventory.assignPartialMismatchMessage\",{spoolMaterial:p.spoolMaterial,trayMaterial:p.trayMaterial,location:p.location}):p.type===\"material_profile\"?Ce=`${i(\"inventory.assignMismatchMessage\",{spoolMaterial:p.spoolMaterial,trayMaterial:p.trayMaterial,location:p.location})}\n\n${i(\"inventory.assignProfileMismatchMessage\",{spoolProfile:p.spoolProfile||i(\"common.unknown\"),trayProfile:p.trayProfile||i(\"common.unknown\"),location:p.location})}`:p.type===\"partial_profile\"?Ce=`${i(\"inventory.assignPartialMismatchMessage\",{spoolMaterial:p.spoolMaterial,trayMaterial:p.trayMaterial,location:p.location})}\n\n${i(\"inventory.assignProfileMismatchMessage\",{spoolProfile:p.spoolProfile||i(\"common.unknown\"),trayProfile:p.trayProfile||i(\"common.unknown\"),location:p.location})}`:p.type===\"profile\"&&(Ce=i(\"inventory.assignProfileMismatchMessage\",{spoolProfile:p.spoolProfile||i(\"common.unknown\"),trayProfile:p.trayProfile||i(\"common.unknown\"),location:p.location})),a.jsx(yn,{title:i(\"inventory.assignMismatchTitle\"),message:Ce,confirmText:i(\"inventory.assignMismatchConfirm\"),variant:\"warning\",isLoading:ie.isPending,onConfirm:ne,onCancel:()=>{ie.isPending||(m(!1),v(null),f(null))}})})()]})}function Eat({isOpen:t,onClose:e,tagId:n,untaggedSpools:r,onLink:i}){const{t:s}=Ft(),[o,l]=w.useState(null),c=w.useCallback(()=>{l(null),e()},[e]),d=w.useCallback(m=>{m.key===\"Escape\"&&c()},[c]);if(w.useEffect(()=>(t&&(document.addEventListener(\"keydown\",d),document.body.style.overflow=\"hidden\"),()=>{document.removeEventListener(\"keydown\",d),document.body.style.overflow=\"\"}),[t,d]),!t)return null;const u=()=>{o&&(i(o),l(null))};return a.jsx(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in\",onClick:c,children:a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 animate-slide-up\",onClick:m=>m.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-5 py-4 border-b border-zinc-700\",children:[a.jsxs(\"div\",{children:[a.jsx(\"h2\",{className:\"text-base font-semibold text-zinc-100\",children:s(\"spoolbuddy.dashboard.linkTagTitle\",\"Link Tag to Spool\")}),a.jsx(\"p\",{className:\"text-sm text-zinc-500 font-mono\",children:n})]}),a.jsx(\"button\",{onClick:c,className:\"p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"px-5 py-4 space-y-3 max-h-[400px] overflow-y-auto\",children:[a.jsx(\"p\",{className:\"text-sm text-zinc-400\",children:s(\"spoolbuddy.dashboard.selectSpool\",\"Select a spool to link this tag to:\")}),r.length===0?a.jsx(\"div\",{className:\"text-center py-8 text-zinc-500\",children:s(\"spoolbuddy.dashboard.noUntagged\",\"No spools without tags found\")}):a.jsx(\"div\",{className:\"space-y-2\",children:r.map(m=>a.jsxs(\"button\",{type:\"button\",onClick:()=>l(m),className:`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-all text-left ${o?.id===m.id?\"border-green-500 bg-green-500/10\":\"border-zinc-700 hover:border-green-500/50 hover:bg-zinc-700/50\"}`,children:[a.jsx(SA,{color:m.rgba?`#${m.rgba.slice(0,6)}`:\"#808080\",isEmpty:!1,size:40}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"div\",{className:\"font-medium text-zinc-100 truncate\",children:m.color_name||\"Unknown color\"}),a.jsxs(\"div\",{className:\"text-sm text-zinc-400 truncate\",children:[m.brand,\" • \",m.material,m.subtype&&` ${m.subtype}`]})]}),a.jsxs(\"div\",{className:\"text-sm font-mono text-zinc-500\",children:[Math.max(0,m.label_weight-m.weight_used),\"g\"]})]},m.id))})]}),a.jsxs(\"div\",{className:\"flex justify-end gap-2 px-5 py-4 border-t border-zinc-700\",children:[a.jsx(\"button\",{onClick:c,className:\"px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\",children:s(\"common.cancel\",\"Cancel\")}),a.jsx(\"button\",{onClick:u,disabled:!o,className:\"px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors min-h-[44px]\",children:s(\"spoolbuddy.dashboard.linkTag\",\"Link Tag\")})]})]})})}function DY(t){return t?t.replace(/[^0-9a-f]/gi,\"\").toUpperCase():\"\"}function Dat(t,e){const n=DY(t),r=DY(e);return!n||!r?!1:n===r?!0:n.endsWith(r)||r.endsWith(n)}const FY=[\"#00AE42\",\"#FF6B35\",\"#3B82F6\",\"#EF4444\",\"#A855F7\",\"#FBBF24\",\"#14B8A6\",\"#EC4899\",\"#F97316\",\"#22C55E\"];function Fat(){const{t}=Ft(),[e,n]=w.useState(0);w.useEffect(()=>{const i=setInterval(()=>{n(s=>(s+1)%FY.length)},5e3);return()=>clearInterval(i)},[]);const r=FY[e];return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center\",children:[a.jsxs(\"div\",{className:\"relative mb-6 flex items-center justify-center\",style:{width:160,height:160},children:[a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center pointer-events-none\",children:[0,1].map(i=>a.jsx(\"div\",{className:\"absolute rounded-full border spoolbuddy-optimized-ping\",style:{width:80,height:80,borderColor:`${r}4D`,transition:\"border-color 140ms linear\",animationDelay:`${i*.8}s`}},i))}),a.jsxs(\"div\",{className:\"relative overflow-hidden rounded-full\",children:[a.jsx(\"div\",{className:\"absolute inset-0 rounded-full opacity-30 spoolbuddy-spool-glow\",style:{background:`radial-gradient(circle, ${r} 0%, transparent 70%)`}}),a.jsx(\"div\",{className:\"relative\",style:{transition:\"opacity 140ms linear\"},children:a.jsx(SA,{color:r,isEmpty:!1,size:100})})]})]}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-xl font-medium text-zinc-300\",children:t(\"spoolbuddy.dashboard.readyToScan\",\"Ready to scan\")}),a.jsx(\"p\",{className:\"text-sm text-zinc-500\",children:t(\"spoolbuddy.dashboard.idleMessage\",\"Place a spool on the scale to identify it\")})]}),a.jsxs(\"div\",{className:\"mt-6 flex items-center gap-2 text-sm text-zinc-600\",children:[a.jsx(\"svg\",{className:\"w-4 h-4\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:\"2\",d:\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"})}),a.jsx(\"span\",{children:t(\"spoolbuddy.dashboard.nfcHint\",\"NFC tag will be read automatically\")})]})]})}function Rat(){const{t}=Ft();return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center\",children:[a.jsx(\"div\",{className:\"relative mb-6 flex items-center justify-center\",style:{width:160,height:160},children:a.jsx(\"div\",{className:\"w-24 h-24 rounded-full bg-zinc-800 flex items-center justify-center\",children:a.jsx(\"svg\",{className:\"w-12 h-12 text-zinc-600\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:\"1.5\",d:\"M18.364 5.636a9 9 0 010 12.728m0 0l-12.728-12.728m12.728 12.728L5.636 5.636m12.728 0a9 9 0 00-12.728 0m0 12.728a9 9 0 010-12.728\"})})})}),a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsx(\"p\",{className:\"text-lg font-medium text-zinc-500\",children:t(\"spoolbuddy.status.deviceOffline\",\"Device Offline\")}),a.jsx(\"p\",{className:\"text-sm text-zinc-600\",children:t(\"spoolbuddy.status.connectDisplay\",\"Connect the SpoolBuddy display to scan spools\")})]}),a.jsxs(\"div\",{className:\"mt-6 flex items-center gap-2 text-xs text-zinc-600\",children:[a.jsx(\"svg\",{className:\"w-4 h-4\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:\"2\",d:\"M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0\"})}),a.jsx(\"span\",{children:t(\"spoolbuddy.status.waitingConnection\",\"Waiting for device connection...\")})]})]})}function Lat(){const{sbState:t,selectedPrinterId:e}=yy(),{t:n}=Ft(),{showToast:r}=hn(),{data:i=[],refetch:s}=Xe({queryKey:[\"inventory-spools\"],queryFn:()=>ue.getSpools(!1)}),{data:o=[]}=Xe({queryKey:[\"printers\"],queryFn:()=>ue.getPrinters()}),l=Pp({queries:o.map(ne=>({queryKey:[\"printerStatus\",ne.id],queryFn:()=>ue.getPrinterStatus(ne.id),refetchInterval:1e4,select:me=>({connected:me?.connected})}))}),[c,d]=w.useState(null),[u,m]=w.useState(null),[p,f]=w.useState(null),[y,v]=w.useState(!1),[b,g]=w.useState(!1),[_,C]=w.useState(!1),[P,N]=w.useState(!1),A=t.matchedSpool?.tag_uid??t.unknownTagUid??null,T=t.weight,F=t.weightStable,k=w.useRef(null);T===null?k.current=null:(k.current===null||Math.abs(T-k.current)>=3||F)&&(k.current=T);const H=k.current,z=w.useMemo(()=>{if(t.matchedSpool?.id){const ne=i.find(me=>me.id===t.matchedSpool?.id);if(ne)return ne}return c?i.find(ne=>Dat(ne.tag_uid,c))??null:null},[c,t.matchedSpool,i]),Q=w.useMemo(()=>i.filter(ne=>!ne.tag_uid&&!ne.archived_at),[i]);w.useEffect(()=>{if(A){const ne=p===A;(c!==null&&c!==A||!ne&&c!==A)&&(d(A),m(null),f(null)),!ne&&T!==null&&F&&m(Math.round(Math.max(0,T)))}else p&&(d(null),f(null),m(null))},[A,T,F,c,p]);const L=()=>{f(c)},te=async ne=>{if(c)try{await ue.linkTagToSpool(ne.id,{tag_uid:c,tag_type:\"generic\",data_origin:\"nfc_link\"}),v(!1),s()}catch(me){console.error(\"Failed to link tag:\",me)}},ie=async()=>{if(c){N(!0);try{const ne=oe??u;await ue.createSpool({material:\"PLA\",subtype:null,color_name:null,rgba:null,brand:null,label_weight:1e3,core_weight:250,core_weight_catalog_id:null,weight_used:0,slicer_filament:null,slicer_filament_name:null,nozzle_temp_min:null,nozzle_temp_max:null,note:null,added_full:null,last_used:null,encode_time:null,tag_uid:c,tray_uuid:null,data_origin:\"spoolbuddy\",tag_type:\"generic\",cost_per_kg:null,last_scale_weight:ne!==null?Math.round(ne):null,last_weighed_at:ne!==null?new Date().toISOString():null})}catch(ne){const me=ne instanceof Error?ne.message:String(ne);console.error(\"Failed to quick-add spool:\",me),r(me||n(\"spoolbuddy.errors.quickAddFailed\",\"Failed to add spool\"),\"error\")}finally{C(!1),N(!1),s()}}},oe=T!==null&&(A===c||A===null&&c!==null)?T:null,fe=i.length,re=new Set(i.map(ne=>ne.material)).size,W=new Set(i.filter(ne=>ne.brand).map(ne=>ne.brand)).size;return a.jsxs(\"div\",{className:\"h-full flex flex-col p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-6 px-4 py-1.5 bg-zinc-800/50 rounded-xl border border-zinc-700/50 mb-3 shrink-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-xl font-bold text-zinc-100\",children:fe}),a.jsx(\"span\",{className:\"text-sm text-zinc-500\",children:n(\"spoolbuddy.inventory.spools\",\"Spools\")})]}),a.jsx(\"div\",{className:\"w-px h-5 bg-zinc-700\"}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-xl font-bold text-zinc-100\",children:re}),a.jsx(\"span\",{className:\"text-sm text-zinc-500\",children:n(\"spoolbuddy.spool.material\",\"Materials\")})]}),a.jsx(\"div\",{className:\"w-px h-5 bg-zinc-700\"}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-xl font-bold text-zinc-100\",children:W}),a.jsx(\"span\",{className:\"text-sm text-zinc-500\",children:n(\"spoolbuddy.spool.brand\",\"Brands\")})]})]}),a.jsxs(\"div\",{className:\"flex-1 flex gap-4 min-h-0\",children:[a.jsxs(\"div\",{className:\"w-5/12 flex flex-col min-h-0\",children:[a.jsxs(\"div\",{className:\"border border-dashed border-zinc-700/50 rounded-xl p-4\",children:[a.jsx(\"h2\",{className:\"text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-3\",children:n(\"spoolbuddy.dashboard.device\",\"Device\")}),a.jsxs(\"div\",{className:\"space-y-2.5\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:`w-2.5 h-2.5 rounded-full ${t.deviceOnline?\"bg-green-500\":\"bg-red-500\"}`}),a.jsx(\"span\",{className:\"text-base text-zinc-400\",children:t.deviceOnline?n(\"spoolbuddy.status.online\",\"Online\"):n(\"spoolbuddy.status.offline\",\"Disconnected\")})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"svg\",{className:`w-4 h-4 ${t.deviceOnline?\"text-green-500\":\"text-zinc-500\"}`,fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:\"2\",d:\"M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3\"})}),a.jsx(\"span\",{className:\"text-sm text-zinc-500\",children:n(\"spoolbuddy.spool.scaleWeight\",\"Scale\")})]}),a.jsx(\"span\",{className:\"text-lg font-mono font-semibold text-zinc-100\",children:H!==null?`${Math.abs(H)<=20?0:Math.round(Math.max(0,H))}g`:\"—\"})]}),a.jsxs(\"div\",{className:\"flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"svg\",{className:`w-4 h-4 ${t.deviceOnline?\"text-green-500\":\"text-zinc-500\"}`,fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:\"2\",d:\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z\"})}),a.jsx(\"span\",{className:\"text-sm text-zinc-500\",children:\"NFC\"})]}),a.jsx(\"span\",{className:`text-sm font-medium ${A?\"text-green-500\":\"text-zinc-500\"}`,children:A?n(\"spoolbuddy.dashboard.tagDetected\",\"Tag detected\"):n(\"spoolbuddy.dashboard.noTag\",\"No tag\")})]})]})]}),o.length>0&&a.jsxs(\"div\",{className:\"mt-3 border border-dashed border-zinc-700/50 rounded-xl p-4\",children:[a.jsx(\"h2\",{className:\"text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-2.5\",children:n(\"spoolbuddy.dashboard.printers\",\"Printers\")}),a.jsx(\"div\",{className:\"flex flex-wrap gap-2 overflow-hidden\",children:o.map((ne,me)=>{const be=l[me]?.data?.connected??!1;return a.jsxs(\"div\",{className:\"flex items-center gap-1.5 px-2.5 py-1 bg-zinc-800/50 rounded-lg\",title:`${ne.name} — ${be?\"Online\":\"Offline\"}`,children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full shrink-0 ${be?\"bg-green-500\":\"bg-zinc-600\"}`}),a.jsx(\"span\",{className:\"text-xs text-zinc-400 truncate max-w-[100px]\",children:ne.name})]},ne.id)})})]})]}),a.jsx(\"div\",{className:\"w-7/12 min-h-0\",children:a.jsxs(\"div\",{className:\"border border-dashed border-zinc-700/50 rounded-xl p-6 h-full flex flex-col\",children:[a.jsx(\"h2\",{className:\"text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-4 shrink-0\",children:n(\"spoolbuddy.dashboard.currentSpool\",\"Current Spool\")}),a.jsx(\"div\",{className:\"flex-1 flex items-center justify-center min-h-0\",children:t.deviceOnline?(z||t.matchedSpool)&&c&&p!==c?a.jsx(xat,{spool:(()=>{const ne=z??t.matchedSpool;return{id:ne.id,tag_uid:c,material:ne.material,subtype:ne.subtype,color_name:ne.color_name,rgba:ne.rgba,brand:ne.brand,label_weight:ne.label_weight,core_weight:ne.core_weight,weight_used:ne.weight_used}})(),scaleWeight:oe??u,onSyncWeight:()=>s(),onAssignToAms:()=>g(!0),onClose:L}):A&&c&&!z&&!t.matchedSpool&&p!==c?a.jsx(yat,{tagUid:c,scaleWeight:oe??u,onLinkSpool:Q.length>0?()=>v(!0):void 0,onAddToInventory:()=>C(!0),onClose:L}):a.jsx(Fat,{}):a.jsx(Rat,{})})]})})]}),z&&c&&a.jsx(uce,{isOpen:b,onClose:()=>g(!1),spool:z,printerId:e}),c&&a.jsx(Eat,{isOpen:y,onClose:()=>v(!1),tagId:c,untaggedSpools:Q,onLink:te}),_&&c&&a.jsx(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center bg-black/60\",children:a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-2xl p-6 mx-4 max-w-sm w-full border border-zinc-700\",children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-zinc-100 mb-3\",children:n(\"spoolbuddy.modal.addToInventory\",\"Add to Inventory\")}),a.jsxs(\"div\",{className:\"flex gap-2.5 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg mb-4\",children:[a.jsx(\"svg\",{className:\"w-5 h-5 text-amber-500 shrink-0 mt-0.5\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:\"2\",d:\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"})}),a.jsx(\"p\",{className:\"text-sm text-amber-200/80\",children:n(\"spoolbuddy.modal.quickAddHint\",'For best results, add the spool in the Bambuddy web interface first (with material, color, brand), then use \"Link to Spool\" here to assign the NFC tag.')})]}),a.jsx(\"p\",{className:\"text-sm text-zinc-400 mb-1\",children:n(\"spoolbuddy.modal.quickAddDesc\",\"This will create a basic PLA spool entry with this NFC tag. You can edit the details later in Bambuddy.\")}),a.jsx(\"p\",{className:\"text-xs text-zinc-500 font-mono mb-5\",children:c}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(\"button\",{onClick:()=>C(!1),className:\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\",children:n(\"common.cancel\",\"Cancel\")}),a.jsx(\"button\",{onClick:ie,disabled:P,className:\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors min-h-[44px]\",children:P?n(\"common.saving\",\"Saving...\"):n(\"spoolbuddy.modal.addAnyway\",\"Add Anyway\")})]})]})})]})}function RY(t){return t<=3?`AMS ${String.fromCharCode(65+t)}`:t>=128&&t<=135?`AMS HT ${String.fromCharCode(65+t-128)}`:`AMS ${t}`}function Oat(t){return t?{O1D:\"H2D\",O1E:\"H2D Pro\",O2D:\"H2D Pro\",O1C:\"H2C\",O1C2:\"H2C\",O1S:\"H2S\",\"BL-P001\":\"X1C\",\"BL-P002\":\"X1\",\"BL-P003\":\"X1E\",N6:\"X2D\",C11:\"P1S\",C12:\"P1P\",C13:\"P2S\",N2S:\"A1\",N1:\"A1 Mini\",X1C:\"X1C\",X1:\"X1\",X1E:\"X1E\",X2D:\"X2D\",P1S:\"P1S\",P1P:\"P1P\",P2S:\"P2S\",A1:\"A1\",\"A1 Mini\":\"A1 Mini\",H2D:\"H2D\",\"H2D Pro\":\"H2D Pro\",H2C:\"H2C\",H2S:\"H2S\"}[t]||t:\"\"}function wf(t){return!t.tray_type||t.tray_type===\"\"}function Iat(t){return t?`#${t.slice(0,6)}`:\"#808080\"}function zat(){const{selectedPrinterId:t,setAlert:e}=yy(),{t:n}=Ft(),r=nn(),{showToast:i}=hn(),{data:s}=Xe({queryKey:[\"printerStatus\",t],queryFn:()=>ue.getPrinterStatus(t),enabled:t!==null,staleTime:30*1e3}),{data:o}=Xe({queryKey:[\"printer\",t],queryFn:()=>ue.getPrinter(t),enabled:t!==null,staleTime:60*1e3}),{data:l}=Xe({queryKey:[\"slotPresets\",t],queryFn:()=>ue.getSlotPresets(t),enabled:t!==null,staleTime:120*1e3}),{data:c}=Xe({queryKey:[\"settings\"],queryFn:()=>ue.getSettings(),staleTime:300*1e3}),{data:d}=Xe({queryKey:[\"spoolman-status\"],queryFn:ue.getSpoolmanStatus,staleTime:60*1e3}),u=d?.enabled&&d?.connected,{data:m}=Xe({queryKey:[\"linked-spools\"],queryFn:ue.getLinkedSpools,enabled:!!u,staleTime:30*1e3}),p=m?.linked,{data:f}=Xe({queryKey:[\"spool-assignments\",t],queryFn:()=>ue.getAssignments(t),enabled:t!==null,staleTime:30*1e3}),y=w.useMemo(()=>{const X={};if(!f)return X;for(const V of f){const ee=V.spool;if(ee&&ee.label_weight>0&&ee.weight_used!=null){const se=Math.round(Math.max(0,ee.label_weight-ee.weight_used)/ee.label_weight*100);X[`${V.ams_id}-${V.tray_id}`]=se}}return X},[f]),v=o?.serial_number??\"\",b=w.useCallback((X,V,ee)=>{if(!p||!v)return null;const se=(ee?.tray_uuid||ee?.tag_uid||Vu(v,X,V))?.toUpperCase(),ge=se?p[se]:void 0;return tN(ge)},[p,v]),g=s?.connected??!1,_=w.useRef({});w.useEffect(()=>{t&&s?.ams&&s.ams.length>0&&(_.current[t]=s.ams)},[s?.ams,t]);const C=w.useMemo(()=>{const X=s?.ams;return X&&X.length>0?X:(t?_.current[t]:null)??[]},[s?.ams,t]),P=w.useMemo(()=>C.filter(X=>!X.is_ams_ht),[C]),N=w.useMemo(()=>C.filter(X=>X.is_ams_ht),[C]),A=w.useMemo(()=>{const X={};if(!p||!v)return X;for(const V of P)for(let ee=0;ee<(V.tray?.length??0);ee++){const se=V.tray[ee],ge=b(V.id,ee,wf(se)?null:se);ge!==null&&(X[`${V.id}-${ee}`]=ge)}return X},[p,v,P,b]),T=w.useRef(void 0),F=s?.tray_now;F!==void 0&&F!==255?T.current=F:F===255&&(T.current=void 0);const k=F!==void 0&&F!==255?F:T.current,D=o?.nozzle_count===2||s?.temperatures?.nozzle_2!==void 0,H=w.useMemo(()=>[...s?.vt_tray??[]].sort((X,V)=>(X.id??254)-(V.id??254)),[s?.vt_tray]),z=c?{humidityGood:Number(c.ams_humidity_good)||40,humidityFair:Number(c.ams_humidity_fair)||60,tempGood:Number(c.ams_temp_good)||28,tempFair:Number(c.ams_temp_fair)||35}:void 0,Q=w.useRef({});w.useEffect(()=>{s?.ams_extruder_map&&Object.keys(s.ams_extruder_map).length>0&&(Q.current=s.ams_extruder_map)},[s?.ams_extruder_map]);const L=s?.ams_extruder_map&&Object.keys(s.ams_extruder_map).length>0?s.ams_extruder_map:Q.current,te=w.useCallback(X=>{if(!D)return null;const V=L[String(X)],ee=X>=128?X-128:X;return(V!==void 0?V:ee)===1?\"L\":\"R\"},[D,L]),[ie,J]=w.useState(null),[oe,fe]=w.useState(null),[re,W]=w.useState(null),[ne,me]=w.useState(null),be=w.useCallback((X,V)=>f?.find(ee=>ee.ams_id===Number(X)&&ee.tray_id===Number(V)),[f]),Ce=w.useCallback((X,V,ee)=>{if(!p||!v)return;const se=(ee?.tray_uuid||ee?.tag_uid||Vu(v,X,V))?.toUpperCase();return se?p[se]:void 0},[p,v]),q=it({mutationFn:({printerId:X,amsId:V,trayId:ee})=>ue.unassignSpool(X,V,ee),onSuccess:()=>{r.invalidateQueries({queryKey:[\"spool-assignments\",t]}),i(n(\"inventory.unassignSuccess\",\"Spool unassigned\"),\"success\"),fe(null)}}),Y=it({mutationFn:X=>ue.unlinkSpool(X),onSuccess:X=>{i(n(\"spoolman.unlinkSuccess\")||X?.message,\"success\"),r.invalidateQueries({queryKey:[\"linked-spools\"]}),r.invalidateQueries({queryKey:[\"unlinked-spools\"]}),fe(null)},onError:X=>{i(X.message||n(\"spoolman.unlinkFailed\"),\"error\")}}),E=w.useCallback(X=>k===void 0?null:X<=3&&Math.floor(k/4)===X?k%4:X>=128&&X<=135&&k===Ag(X,0,!1)?0:null,[k]),j=w.useCallback((X,V,ee)=>{const se=Ag(X,V,!1),ge=l?.[se],he=L[String(X)],le=X>=128?X-128:X,B=he!==void 0?he:le,R={amsId:X,trayId:V,trayCount:ee&&X>=128?1:4,tray:ee,trayType:ee?.tray_type||void 0,trayColor:ee?.tray_color||void 0,traySubBrands:ee?.tray_sub_brands||void 0,trayInfoIdx:ee?.tray_info_idx||void 0,extruderId:D?B:void 0,caliIdx:ee?.cali_idx,savedPresetId:ge?.preset_id,location:`${RY(X)} Slot ${V+1}`};fe(R)},[l,L,D]),O=w.useCallback(X=>{const V=X.id??254,ee=V-254,se=l?.[1020+ee],ge={amsId:255,trayId:ee,trayCount:1,tray:wf(X)?null:X,trayType:X.tray_type||void 0,trayColor:X.tray_color||void 0,traySubBrands:X.tray_sub_brands||void 0,trayInfoIdx:X.tray_info_idx||void 0,extruderId:D?V===254?1:0:void 0,caliIdx:X.cali_idx,savedPresetId:se?.preset_id,location:D?V===254?\"Ext-L\":\"Ext-R\":\"External\"};fe(ge)},[l,D]),K=w.useCallback(()=>{if(!oe)return;const{tray:X,location:V,...ee}=oe;fe(null),J(ee)},[oe]),U=w.useCallback(()=>{if(!oe||!t)return;const{amsId:X,trayId:V,trayType:ee,trayColor:se,location:ge}=oe;fe(null),W({printerId:t,amsId:X,trayId:V,trayInfo:{type:ee||\"\",material:ee,color:se?.slice(0,6)||\"\",location:ge}})},[oe,t]),de=w.useCallback(()=>{if(!oe||!t)return;const{amsId:X,trayId:V,tray:ee}=oe,se=(ee?.tray_uuid||ee?.tag_uid||Vu(v,X,V))?.toUpperCase()||\"\";fe(null),me({tagUid:ee?.tag_uid||se,trayUuid:ee?.tray_uuid||\"\",printerId:t,amsId:X,trayId:V})},[oe,t,v]),I=w.useCallback(()=>{if(!oe||!t)return;const{amsId:X,trayId:V}=oe;q.mutate({printerId:t,amsId:X,trayId:V})},[oe,t,q]);w.useEffect(()=>{if(!g&&t){e({type:\"warning\",message:n(\"spoolbuddy.ams.printerDisconnected\",\"Printer disconnected\")});return}for(const X of C){const V=X.tray||[];for(let ee=0;ee<V.length;ee++){const se=V[ee];if(se.remain!==null&&se.remain>=0&&se.remain<15&&se.tray_type){const ge=X.id===254||X.id===255,he=!ge&&X.id>=128,le=RS(X.id,ee,he,ge);e({type:\"warning\",message:`Low Filament: ${se.tray_type} (${le}) - ${se.remain}% remaining`});return}}}e(null)},[C,g,t,e,n]);const G=w.useMemo(()=>{const X=[];for(const V of N){const ee=V.tray?.[0]||{id:0,tray_color:null,tray_type:\"\",tray_sub_brands:null,tray_id_name:null,tray_info_idx:null,remain:-1,k:null,cali_idx:null,tag_uid:null,tray_uuid:null,nozzle_temp_min:null,nozzle_temp_max:null},se=b(V.id,0,wf(ee)?null:ee),ge=y[`${V.id}-0`]??null,he=ee.remain!=null&&ee.remain>=0?ee.remain:null,le=ge===0&&he!==null&&he>0?null:ge;X.push({key:`ht-${V.id}`,label:RY(V.id),tray:ee,isEmpty:wf(ee),isActive:E(V.id)===0,temp:V.temp,humidity:V.humidity,nozzleSide:te(V.id),effectiveFill:se??le??he,onClick:()=>j(V.id,0,wf(ee)?null:ee)})}for(const V of H){const ee=V.id??254,se=D&&k===254?ee===254&&s?.active_extruder===1||ee===255&&s?.active_extruder===0:k===ee,ge=ee-254,he=b(255,ge,wf(V)?null:V),le=y[`255-${ge}`]??null,B=V.remain!=null&&V.remain>=0?V.remain:null,R=le===0&&B!==null&&B>0?null:le;X.push({key:`ext-${ee}`,label:D?ee===254?n(\"printers.extL\",\"Ext-L\"):n(\"printers.extR\",\"Ext-R\"):n(\"printers.ext\",\"Ext\"),tray:V,isEmpty:wf(V),isActive:se,nozzleSide:null,effectiveFill:he??R??B,onClick:()=>O(V)})}return X},[N,H,D,k,s?.active_extruder,n,E,te,j,O,y,b]);return a.jsxs(\"div\",{className:\"h-full flex flex-col p-4\",children:[a.jsx(\"div\",{className:\"flex-1 min-h-0\",children:t?g?C.length===0&&H.length===0?a.jsx(\"div\",{className:\"flex-1 flex items-center justify-center h-full\",children:a.jsxs(\"div\",{className:\"text-center text-white/50\",children:[a.jsx(da,{className:\"w-12 h-12 mx-auto mb-3 opacity-50\"}),a.jsx(\"p\",{className:\"text-lg mb-2\",children:n(\"spoolbuddy.ams.noData\",\"No AMS detected\")}),a.jsx(\"p\",{className:\"text-sm\",children:n(\"spoolbuddy.ams.connectAms\",\"Connect an AMS to see filament slots\")})]})}):a.jsxs(\"div\",{className:\"flex flex-col gap-3 h-full\",children:[a.jsx(\"div\",{className:\"grid grid-cols-1 md:grid-cols-2 gap-3\",children:P.map(X=>a.jsx(dce,{unit:X,activeSlot:E(X.id),onConfigureSlot:j,isDualNozzle:D,nozzleSide:te(X.id),thresholds:z,fillOverrides:y,spoolmanFillOverrides:A},X.id))}),G.length>0&&a.jsx(\"div\",{className:\"grid grid-cols-2 md:grid-cols-4 gap-3\",children:G.map(({key:X,label:V,tray:ee,isEmpty:se,isActive:ge,temp:he,humidity:le,nozzleSide:B,effectiveFill:R,onClick:ae})=>{const _e=Iat(ee.tray_color);return a.jsxs(\"div\",{className:`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-3 ${ge?\"ring-2 ring-bambu-green\":\"\"}`,onClick:ae,children:[a.jsxs(\"div\",{className:\"relative w-10 h-10 flex-shrink-0\",children:[se?a.jsx(\"div\",{className:\"w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center\",children:a.jsx(\"div\",{className:\"w-1.5 h-1.5 rounded-full bg-gray-600\"})}):a.jsxs(\"svg\",{viewBox:\"0 0 56 56\",className:\"w-full h-full\",children:[a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"26\",fill:_e}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"20\",fill:_e,style:{filter:\"brightness(0.85)\"}}),a.jsx(\"ellipse\",{cx:\"20\",cy:\"20\",rx:\"6\",ry:\"4\",fill:\"white\",opacity:\"0.3\"}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"8\",fill:\"#2d2d2d\"}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"5\",fill:\"#1a1a1a\"})]}),ge&&a.jsx(\"div\",{className:\"absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-bambu-green rounded-full\"})]}),a.jsxs(\"div\",{className:\"min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-1\",children:[a.jsx(\"span\",{className:\"text-xs text-white/50 font-medium truncate\",children:V}),B&&a.jsx(x5,{side:B})]}),a.jsx(\"div\",{className:\"text-sm text-white/80 truncate\",children:se?\"Empty\":ee.tray_type||\"?\"}),(he!=null||le!=null)&&a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[he!=null&&a.jsx(cce,{temp:he,goodThreshold:z?.tempGood,fairThreshold:z?.tempFair}),le!=null&&a.jsx(lce,{humidity:le,goodThreshold:z?.humidityGood,fairThreshold:z?.humidityFair})]})]}),!se&&R!=null&&R>=0&&a.jsx(\"div\",{className:\"w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden flex-shrink-0 flex flex-col-reverse\",children:a.jsx(\"div\",{className:\"w-full rounded-full\",style:{height:`${R}%`,backgroundColor:Ox(R)}})})]},X)})})]}):a.jsx(\"div\",{className:\"flex-1 flex items-center justify-center h-full\",children:a.jsx(\"div\",{className:\"text-center text-white/50\",children:a.jsx(\"p\",{className:\"text-lg mb-2\",children:n(\"spoolbuddy.ams.printerDisconnected\",\"Printer disconnected\")})})}):a.jsx(\"div\",{className:\"flex-1 flex items-center justify-center h-full\",children:a.jsxs(\"div\",{className:\"text-center text-white/50\",children:[a.jsx(\"p\",{className:\"text-lg mb-2\",children:n(\"spoolbuddy.ams.noPrinter\",\"No printer selected\")}),a.jsx(\"p\",{className:\"text-sm\",children:n(\"spoolbuddy.ams.selectPrinter\",\"Select a printer from the top bar\")})]})})}),ie&&t&&a.jsx(Xae,{isOpen:!!ie,onClose:()=>J(null),printerId:t,slotInfo:ie,printerModel:Oat(o?.model??null)||void 0,fullScreen:!0,onSuccess:()=>{r.invalidateQueries({queryKey:[\"slotPresets\",t]}),r.invalidateQueries({queryKey:[\"printerStatus\",t]})}}),oe&&t&&(()=>{const X=be(oe.amsId,oe.trayId),V=Ce(oe.amsId,oe.trayId,oe.tray);return a.jsxs(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center\",children:[a.jsx(\"div\",{className:\"absolute inset-0 bg-black/60 backdrop-blur-sm\",onClick:()=>fe(null)}),a.jsxs(\"div\",{className:\"relative w-full max-w-sm mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between p-4 border-b border-bambu-dark-tertiary\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[oe.trayColor&&a.jsx(\"span\",{className:\"w-4 h-4 rounded-full border border-black/20\",style:{backgroundColor:`#${oe.trayColor.slice(0,6)}`}}),a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:oe.location}),oe.traySubBrands&&a.jsxs(\"span\",{className:\"text-sm text-bambu-gray\",children:[\"(\",oe.traySubBrands,\")\"]})]}),a.jsx(\"button\",{onClick:()=>fe(null),className:\"p-1 text-bambu-gray hover:text-white rounded transition-colors\",children:a.jsx(Ht,{className:\"w-5 h-5\"})})]}),a.jsxs(\"div\",{className:\"p-4 space-y-2\",children:[!u&&X?.spool&&a.jsxs(\"div\",{className:\"p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-1\",children:n(\"inventory.assignedSpool\",\"Assigned spool\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[X.spool.rgba&&a.jsx(\"span\",{className:\"w-3 h-3 rounded-full border border-black/20 flex-shrink-0\",style:{backgroundColor:`#${X.spool.rgba.substring(0,6)}`}}),a.jsxs(\"span\",{className:\"text-sm text-white\",children:[X.spool.brand?`${X.spool.brand} `:\"\",X.spool.material,X.spool.color_name?` - ${X.spool.color_name}`:\"\"]})]})]}),u&&V&&a.jsxs(\"div\",{className:\"p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3\",children:[a.jsx(\"p\",{className:\"text-xs text-bambu-gray mb-1\",children:n(\"spoolman.linkedSpool\",\"Linked spool\")}),a.jsx(\"div\",{className:\"flex items-center gap-2\",children:a.jsxs(\"span\",{className:\"text-sm text-white\",children:[\"Spoolman #\",V.id,V.remaining_weight!=null?` (${Math.round(V.remaining_weight)}g)`:\"\"]})})]}),a.jsxs(\"button\",{onClick:K,className:\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-blue transition-colors text-left\",children:[a.jsx(Ud,{className:\"w-5 h-5 text-bambu-blue flex-shrink-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:n(\"configureAmsSlot.title\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"spoolbuddy.ams.configureDesc\",\"Set filament preset, K-profile, and color\")})]})]}),!u&&(X?a.jsxs(\"button\",{onClick:I,disabled:q.isPending,className:\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left\",children:[a.jsx(gp,{className:\"w-5 h-5 text-amber-400 flex-shrink-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-amber-400 font-medium\",children:n(\"inventory.unassignSpool\",\"Unassign\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"spoolbuddy.ams.unassignDesc\",\"Remove inventory spool from this slot\")})]})]}):a.jsxs(\"button\",{onClick:U,className:\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left\",children:[a.jsx(Ra,{className:\"w-5 h-5 text-bambu-green flex-shrink-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:n(\"inventory.assignSpool\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"spoolbuddy.ams.assignDesc\",\"Track a spool from your inventory\")})]})]})),u&&(V?.id?a.jsxs(\"button\",{onClick:()=>Y.mutate(V.id),disabled:Y.isPending,className:\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left\",children:[a.jsx(gp,{className:\"w-5 h-5 text-amber-400 flex-shrink-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-amber-400 font-medium\",children:n(\"spoolman.unlinkSpool\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"spoolbuddy.ams.unlinkDesc\",\"Remove Spoolman link from this slot\")})]})]}):a.jsxs(\"button\",{onClick:de,className:\"w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left\",children:[a.jsx(sm,{className:\"w-5 h-5 text-bambu-green flex-shrink-0\"}),a.jsxs(\"div\",{children:[a.jsx(\"p\",{className:\"text-white font-medium\",children:n(\"spoolman.linkToSpoolman\")}),a.jsx(\"p\",{className:\"text-xs text-bambu-gray\",children:n(\"spoolbuddy.ams.linkDesc\",\"Link a Spoolman spool to this slot\")})]})]}))]})]})]})})(),re&&a.jsx(Kae,{isOpen:!!re,onClose:()=>{W(null),r.invalidateQueries({queryKey:[\"spool-assignments\",t]})},printerId:re.printerId,amsId:re.amsId,trayId:re.trayId,trayInfo:re.trayInfo}),ne&&a.jsx(Wae,{isOpen:!!ne,onClose:()=>me(null),tagUid:ne.tagUid,trayUuid:ne.trayUuid,printerId:ne.printerId,amsId:ne.amsId,trayId:ne.trayId})]})}function Uat({type:t,deviceId:e,onClose:n}){const{t:r}=Ft(),[i,s]=w.useState(!1),[o,l]=w.useState(\"\"),[c,d]=w.useState(\"\"),[u,m]=w.useState(!1);w.useEffect(()=>{const y=v=>{v.key===\"Escape\"&&!i&&n()};return window.addEventListener(\"keydown\",y),()=>window.removeEventListener(\"keydown\",y)},[i,n]);const p=w.useCallback(async()=>{s(!0),l(\"\"),d(\"\"),m(!0);try{l(r(\"spoolbuddy.diagnostic.queuing\",`Queuing diagnostic on device...\n`)),await Gr.queueDiagnostics(e,t);let y=null;const v=60;let b=0;for(;b<v&&!y;){await new Promise(g=>setTimeout(g,500));try{y=await Gr.getDiagnosticResult(e,t);break}catch{b++,b%4===0&&l(g=>g+\".\")}}if(!y)throw new Error(\"Diagnostic timed out - device did not report results\");l(y.output),y.success||d(`Exit code: ${y.exit_code}`)}catch(y){d(y instanceof Error?y.message:\"Unknown error\"),l(\"\")}finally{s(!1)}},[t,e,r]),f=t===\"scale\"?r(\"spoolbuddy.diagnostic.scaleTitle\",\"Scale Diagnostic\"):t===\"read_tag\"?r(\"spoolbuddy.diagnostic.readTagTitle\",\"Read Tag Diagnostic\"):r(\"spoolbuddy.diagnostic.nfcTitle\",\"NFC Reader Diagnostic\");return a.jsx(\"div\",{className:\"fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in\",onClick:n,children:a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up\",onClick:y=>y.stopPropagation(),children:[a.jsxs(\"div\",{className:\"flex justify-between items-center p-4 border-b border-zinc-700\",children:[a.jsx(\"h2\",{className:\"text-lg font-semibold text-white\",children:f}),a.jsx(\"button\",{onClick:n,className:\"text-zinc-400 hover:text-white transition-colors\",\"aria-label\":\"Close\",children:a.jsx(Ht,{size:20})})]}),a.jsx(\"div\",{className:\"flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm\",children:i?a.jsxs(\"div\",{className:\"flex items-center gap-2 text-green-400\",children:[a.jsx(\"div\",{className:\"animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full\"}),a.jsx(\"span\",{children:r(\"spoolbuddy.diagnostic.running\",\"Running diagnostic on device...\")})]}):o?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"text-green-400 whitespace-pre-wrap break-words\",children:o}),c&&a.jsxs(\"div\",{className:\"text-red-400 mt-2\",children:[\"❌ \",c]})]}):u?a.jsx(\"div\",{children:c?a.jsxs(\"div\",{className:\"text-red-400\",children:[\"ERROR: \",c]}):a.jsx(\"span\",{className:\"text-green-400\",children:r(\"spoolbuddy.diagnostic.completed\",\"Diagnostic completed successfully.\")})}):a.jsxs(\"div\",{className:\"text-zinc-500\",children:[r(\"spoolbuddy.diagnostic.clickStart\",'Click \"Run Diagnostic\" to start the hardware diagnostic on'),\" \",e,\".\"]})}),a.jsxs(\"div\",{className:\"flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800\",children:[a.jsx(\"button\",{onClick:p,disabled:i,className:\"flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors\",children:i?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full\"}),r(\"spoolbuddy.diagnostic.runningBtn\",\"Running...\")]}):u?a.jsxs(a.Fragment,{children:[a.jsx(sw,{size:16}),r(\"spoolbuddy.diagnostic.runAgain\",\"Run Again\")]}):a.jsxs(a.Fragment,{children:[a.jsx(es,{size:16}),r(\"spoolbuddy.diagnostic.runBtn\",\"Run Diagnostic\")]})}),a.jsx(\"button\",{onClick:n,className:\"px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors\",children:r(\"spoolbuddy.diagnostic.close\",\"Close\")})]})]})})}function Bat(t){if(t<60)return`${t}s`;if(t<3600)return`${Math.floor(t/60)}m`;const e=Math.floor(t/3600),n=Math.floor(t%3600/60);return`${e}h ${n}m`}function Hat(t){if(!t)return\"-\";try{const e=new Date(t);return e.toLocaleDateString(void 0,{month:\"short\",day:\"numeric\"})+\" \"+e.toLocaleTimeString(void 0,{hour:\"2-digit\",minute:\"2-digit\"})}catch{return\"-\"}}const qat=[{label:\"Off\",value:0},{label:\"1m\",value:60},{label:\"2m\",value:120},{label:\"5m\",value:300},{label:\"10m\",value:600},{label:\"30m\",value:1800}];function $at({device:t}){const{t:e}=Ft(),[n,r]=w.useState(null),[i,s]=w.useState(\"\"),[o,l]=w.useState(\"\"),[c,d]=w.useState(!1),[u,m]=w.useState(null);w.useEffect(()=>{!i&&t.backend_url&&s(t.backend_url)},[t.backend_url,i]);const p=async()=>{if(!i.trim()){m({type:\"error\",text:e(\"spoolbuddy.settings.systemFieldsRequired\",\"Backend URL is required.\")});return}d(!0),m(null);try{await Gr.updateSystemConfig(t.device_id,i.trim(),o.trim()||void 0),m({type:\"ok\",text:e(\"spoolbuddy.settings.systemQueued\",\"Config queued.\")})}catch(f){m({type:\"error\",text:f instanceof Error?f.message:e(\"common.error\",\"Error\")})}finally{d(!1)}};return a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300 mb-2\",children:e(\"spoolbuddy.settings.nfcReader\",\"NFC Reader\")}),a.jsxs(\"div\",{className:\"space-y-1.5 text-xs\",children:[a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.type\",\"Type\")}),a.jsx(\"span\",{className:\"text-zinc-300 font-mono\",children:t.nfc_reader_type||\"N/A\"})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.connection\",\"Connection\")}),a.jsx(\"span\",{className:\"text-zinc-300 font-mono\",children:t.nfc_connection||\"N/A\"})]}),a.jsxs(\"div\",{className:\"flex justify-between items-center\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.status.status\",\"Status\")}),a.jsxs(\"div\",{className:\"flex items-center gap-1.5\",children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full ${t.nfc_ok?\"bg-green-500\":t.nfc_reader_type?\"bg-red-500\":\"bg-zinc-600\"}`}),a.jsx(\"span\",{className:t.nfc_ok?\"text-green-400\":t.nfc_reader_type?\"text-red-400\":\"text-zinc-500\",children:t.nfc_ok?e(\"spoolbuddy.status.nfcReady\",\"Ready\"):t.nfc_reader_type?e(\"common.error\",\"Error\"):e(\"spoolbuddy.settings.notConnected\",\"N/A\")})]})]})]})]}),a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300 mb-2\",children:e(\"spoolbuddy.settings.deviceInfo\",\"Device Info\")}),a.jsxs(\"div\",{className:\"space-y-1.5 text-xs\",children:[a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.hostname\",\"Host\")}),a.jsx(\"span\",{className:\"text-zinc-300 truncate ml-2\",children:t.hostname})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:\"IP\"}),a.jsx(\"span\",{className:\"text-zinc-300\",children:t.ip_address})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.uptime\",\"Uptime\")}),a.jsx(\"span\",{className:\"text-zinc-300\",children:Bat(t.uptime_s)})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:\"ID\"}),a.jsx(\"span\",{className:\"text-zinc-400 font-mono truncate ml-2\",children:t.device_id})]})]})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3 space-y-2\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300\",children:e(\"spoolbuddy.settings.systemConfig\",\"Backend & Auth\")}),a.jsx(\"input\",{value:i,onChange:f=>s(f.target.value),placeholder:\"http://192.168.1.100:5000\",className:\"w-full px-2 py-1.5 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-xs\"}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"input\",{type:\"password\",value:o,onChange:f=>l(f.target.value),placeholder:e(\"spoolbuddy.settings.apiTokenPlaceholder\",\"API token\"),className:\"flex-1 px-2 py-1.5 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-xs\"}),a.jsx(\"button\",{onClick:p,disabled:c,className:\"px-3 py-1.5 rounded bg-green-700 hover:bg-green-600 disabled:bg-zinc-700 text-xs font-medium text-zinc-100\",children:e(\"spoolbuddy.settings.saveConfig\",\"Save\")})]}),u&&a.jsx(\"div\",{className:`text-xs ${u.type===\"ok\"?\"text-green-400\":\"text-red-400\"}`,children:u.text})]}),a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3 flex flex-col gap-2\",children:[a.jsxs(\"button\",{onClick:()=>r(\"nfc\"),className:\"flex-1 bg-blue-700 hover:bg-blue-600 transition-colors rounded-lg p-2 flex items-center gap-2\",children:[a.jsx(KQ,{className:\"w-4 h-4 text-blue-300 shrink-0\"}),a.jsx(\"span\",{className:\"text-xs font-medium text-blue-100\",children:e(\"spoolbuddy.settings.nfcDiagnostic\",\"NFC Diagnostic\")})]}),a.jsxs(\"button\",{onClick:()=>r(\"scale\"),className:\"flex-1 bg-yellow-700 hover:bg-yellow-600 transition-colors rounded-lg p-2 flex items-center gap-2\",children:[a.jsx(dc,{className:\"w-4 h-4 text-yellow-300 shrink-0\"}),a.jsx(\"span\",{className:\"text-xs font-medium text-yellow-100\",children:e(\"spoolbuddy.settings.scaleDiagnostic\",\"Scale Diagnostic\")})]}),a.jsxs(\"button\",{onClick:()=>r(\"read_tag\"),className:\"flex-1 bg-emerald-700 hover:bg-emerald-600 transition-colors rounded-lg p-2 flex items-center gap-2\",children:[a.jsx(Us,{className:\"w-4 h-4 text-emerald-300 shrink-0\"}),a.jsx(\"span\",{className:\"text-xs font-medium text-emerald-100\",children:e(\"spoolbuddy.settings.readTagDiagnostic\",\"Read Tag\")})]})]})]}),n&&t&&a.jsx(Uat,{type:n,deviceId:t.device_id,onClose:()=>r(null)})]})}function Vat({device:t,onBrightnessChange:e}){const{t:n}=Ft(),[r,i]=w.useState(t.display_brightness),[s,o]=w.useState(t.display_blank_timeout),[l,c]=w.useState(!1),d=w.useRef(void 0),u=w.useRef(void 0);w.useEffect(()=>{i(t.display_brightness),o(t.display_blank_timeout)},[t.display_brightness,t.display_blank_timeout]);const m=w.useCallback(()=>{c(!0),u.current&&clearTimeout(u.current),u.current=setTimeout(()=>c(!1),1500)},[]),p=w.useCallback((v,b)=>{d.current&&clearTimeout(d.current),d.current=setTimeout(()=>{Gr.updateDisplay(t.device_id,v,b).then(()=>m()).catch(g=>console.error(\"Failed to update display:\",g))},300)},[t.device_id,m]),f=v=>{i(v),e(v),p(v,s)},y=v=>{o(v),p(r,v)};return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between mb-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300\",children:n(\"spoolbuddy.settings.brightness\",\"Brightness\")}),l&&a.jsxs(\"span\",{className:\"text-xs text-green-400 flex items-center gap-1 animate-pulse\",children:[a.jsx(\"svg\",{className:\"w-3 h-3\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",strokeWidth:3,children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M5 13l4 4L19 7\"})}),n(\"spoolbuddy.settings.saved\",\"Saved\")]})]}),a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"svg\",{className:\"w-4 h-4 text-zinc-500 flex-shrink-0\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",strokeWidth:2,children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z\"})}),a.jsx(\"input\",{type:\"range\",min:0,max:100,value:r,onChange:v=>f(parseInt(v.target.value)),className:\"flex-1 h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-green-500\"}),a.jsxs(\"span\",{className:\"text-sm font-mono text-zinc-400 w-10 text-right\",children:[r,\"%\"]})]}),!t.has_backlight&&a.jsx(\"p\",{className:\"text-xs text-zinc-600 mt-2\",children:n(\"spoolbuddy.settings.noBacklight\",\"No DSI backlight detected. Brightness control requires a DSI display.\")})]}),a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-4\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300 mb-1\",children:n(\"spoolbuddy.settings.screenBlank\",\"Screen Blank Timeout\")}),a.jsx(\"p\",{className:\"text-xs text-zinc-500 mb-3\",children:n(\"spoolbuddy.settings.screenBlankDesc\",\"Screen turns off after inactivity. Touch to wake.\")}),a.jsx(\"div\",{className:\"grid grid-cols-3 gap-2\",children:qat.map(v=>a.jsx(\"button\",{onClick:()=>y(v.value),className:`px-3 py-2 rounded-lg text-sm font-medium transition-colors min-h-[40px] ${s===v.value?\"bg-green-600 text-white\":\"bg-zinc-700 text-zinc-300 hover:bg-zinc-600\"}`,children:v.label},v.value))})]}),a.jsx(\"p\",{className:\"text-xs text-zinc-600 text-center\",children:n(\"spoolbuddy.settings.displayNote\",\"Brightness is applied as a software filter.\")})]})}function Gat({step:t,labels:e}){return a.jsxs(\"div\",{className:\"flex flex-col items-center w-16 shrink-0 pt-1\",children:[a.jsx(\"div\",{className:`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${t===\"tare\"?\"bg-green-600 text-white\":\"bg-green-600/20 text-green-400\"}`,children:t===\"weight\"?a.jsx(\"svg\",{className:\"w-3.5 h-3.5\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",strokeWidth:3,children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M5 13l4 4L19 7\"})}):\"1\"}),a.jsx(\"span\",{className:`text-[10px] mt-0.5 ${t===\"tare\"?\"text-green-400 font-medium\":\"text-green-400/60\"}`,children:e.tare}),a.jsx(\"div\",{className:`w-px h-5 my-1 ${t===\"weight\"?\"bg-green-600/40\":\"bg-zinc-700\"}`}),a.jsx(\"div\",{className:`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${t===\"weight\"?\"bg-green-600 text-white\":\"bg-zinc-700 text-zinc-500\"}`,children:\"2\"}),a.jsx(\"span\",{className:`text-[10px] mt-0.5 ${t===\"weight\"?\"text-green-400 font-medium\":\"text-zinc-600\"}`,children:e.weight})]})}function Wat({device:t,weight:e,weightStable:n,rawAdc:r}){const{t:i}=Ft(),[s,o]=w.useState(\"idle\"),[l,c]=w.useState(\"500\"),[d,u]=w.useState(null),[m,p]=w.useState(!1),[f,y]=w.useState(null),v=_=>{_===\"backspace\"?c(C=>C.slice(0,-1)||\"\"):_===\".\"&&!l.includes(\".\")?c(C=>C+\".\"):_>=\"0\"&&_<=\"9\"&&c(C=>C===\"0\"?_:C+_)},b=async()=>{p(!0),y(null);try{await Gr.tare(t.device_id),y({type:\"ok\",msg:i(\"spoolbuddy.settings.tareSet\",\"Tare command sent. Waiting for device...\")})}catch{y({type:\"error\",msg:i(\"spoolbuddy.settings.tareFailed\",\"Failed to send tare command\")})}finally{p(!1)}},g=async()=>{if(s===\"tare\"){p(!0),y(null);try{u(r),await Gr.tare(t.device_id),y({type:\"ok\",msg:i(\"spoolbuddy.settings.zeroSet\",\"Zero point set. Place known weight on scale.\")}),o(\"weight\")}catch{y({type:\"error\",msg:i(\"spoolbuddy.settings.tareFailed\",\"Failed to send tare command\")})}finally{p(!1)}}else if(s===\"weight\"){const _=parseFloat(l);if(r===null||!_||_<=0)return;p(!0),y(null);try{await Gr.setCalibrationFactor(t.device_id,_,r,d??void 0),y({type:\"ok\",msg:i(\"spoolbuddy.settings.calibrationDone\",\"Calibration complete!\")}),o(\"idle\")}catch{y({type:\"error\",msg:i(\"spoolbuddy.settings.calibrationFailed\",\"Calibration failed\")})}finally{p(!1)}}};return s===\"idle\"?a.jsxs(\"div\",{className:\"flex flex-col h-full\",children:[a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3 mb-3\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full ${n?\"bg-green-500\":\"bg-amber-500 animate-pulse\"}`}),a.jsx(\"span\",{className:\"text-lg font-mono text-zinc-200\",children:e!==null?`${e.toFixed(1)} g`:\"-- g\"})]}),a.jsxs(\"div\",{className:\"text-xs text-zinc-500 text-right\",children:[a.jsxs(\"span\",{children:[i(\"spoolbuddy.settings.tareOffset\",\"Tare\"),\": \",t.tare_offset]}),a.jsx(\"span\",{className:\"mx-1.5\",children:\"·\"}),a.jsxs(\"span\",{children:[i(\"spoolbuddy.settings.calFactor\",\"Factor\"),\": \",t.calibration_factor.toFixed(2)]})]})]}),t.last_calibrated_at&&a.jsxs(\"div\",{className:\"text-xs text-zinc-600 mt-1\",children:[i(\"spoolbuddy.settings.lastCalibrated\",\"Last calibrated\"),\": \",Hat(t.last_calibrated_at)]})]}),f&&a.jsx(\"div\",{className:`rounded-lg px-3 py-2 mb-3 text-sm ${f.type===\"ok\"?\"bg-green-900/30 text-green-300 border border-green-800\":\"bg-red-900/30 text-red-300 border border-red-800\"}`,children:f.msg}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsxs(\"button\",{onClick:b,disabled:m,className:\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2\",children:[m&&a.jsxs(\"svg\",{className:\"w-4 h-4 animate-spin\",viewBox:\"0 0 24 24\",fill:\"none\",children:[a.jsx(\"circle\",{className:\"opacity-25\",cx:\"12\",cy:\"12\",r:\"10\",stroke:\"currentColor\",strokeWidth:\"4\"}),a.jsx(\"path\",{className:\"opacity-75\",fill:\"currentColor\",d:\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"})]}),i(\"spoolbuddy.weight.tare\",\"Tare\")]}),a.jsx(\"button\",{onClick:()=>{o(\"tare\"),y(null)},className:\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\",children:i(\"spoolbuddy.weight.calibrate\",\"Calibrate\")})]})]}):a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsx(Gat,{step:s,labels:{tare:i(\"spoolbuddy.weight.tare\",\"Tare\"),weight:i(\"spoolbuddy.settings.knownWeight\",\"Known weight\")}}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 bg-zinc-800 rounded-lg px-3 py-1.5 mb-1.5\",children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full shrink-0 ${n?\"bg-green-500\":\"bg-amber-500 animate-pulse\"}`}),a.jsx(\"span\",{className:\"text-sm font-mono text-zinc-200\",children:e!==null?`${e.toFixed(1)} g`:\"-- g\"}),a.jsx(\"span\",{className:`text-xs ml-auto ${n?\"text-green-400\":\"text-amber-400\"}`,children:n?i(\"spoolbuddy.settings.stable\",\"Stable\"):i(\"spoolbuddy.settings.settling\",\"Settling...\")})]}),f&&a.jsx(\"div\",{className:`rounded-lg px-3 py-1.5 mb-1.5 text-xs ${f.type===\"ok\"?\"bg-green-900/30 text-green-300 border border-green-800\":\"bg-red-900/30 text-red-300 border border-red-800\"}`,children:f.msg}),s===\"tare\"?a.jsx(\"p\",{className:\"text-sm text-zinc-300 mb-3\",children:i(\"spoolbuddy.settings.calStep1\",\"Remove all items from the scale and press Set Zero.\")}):a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"flex items-center gap-2 mb-1.5\",children:[a.jsx(\"span\",{className:\"text-xs text-zinc-400 shrink-0\",children:i(\"spoolbuddy.settings.knownWeight\",\"Known weight\")}),a.jsxs(\"div\",{className:\"flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1 text-right text-lg font-mono text-zinc-100\",children:[l||\"0\",a.jsx(\"span\",{className:\"text-zinc-500 ml-1\",children:\"g\"})]})]}),a.jsx(\"div\",{className:\"grid grid-cols-4 gap-1 mb-1.5\",children:[\"7\",\"8\",\"9\",\"backspace\",\"4\",\"5\",\"6\",\".\",\"1\",\"2\",\"3\",\"0\"].map(_=>a.jsx(\"button\",{onClick:()=>v(_),className:`rounded text-lg font-medium transition-colors h-[48px] active:scale-95 ${_===\"backspace\"?\"bg-zinc-700 text-zinc-300 hover:bg-zinc-600\":\"bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700\"}`,children:_===\"backspace\"?\"⌫\":_},_))})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"button\",{onClick:()=>{o(\"idle\"),y(null)},className:\"flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors h-[40px]\",children:i(\"common.cancel\",\"Cancel\")}),a.jsxs(\"button\",{onClick:g,disabled:m,className:\"flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors h-[40px] flex items-center justify-center gap-2\",children:[m&&a.jsxs(\"svg\",{className:\"w-4 h-4 animate-spin\",viewBox:\"0 0 24 24\",fill:\"none\",children:[a.jsx(\"circle\",{className:\"opacity-25\",cx:\"12\",cy:\"12\",r:\"10\",stroke:\"currentColor\",strokeWidth:\"4\"}),a.jsx(\"path\",{className:\"opacity-75\",fill:\"currentColor\",d:\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"})]}),s===\"tare\"?i(\"spoolbuddy.settings.setZero\",\"Set Zero\"):i(\"spoolbuddy.settings.calibrateNow\",\"Calibrate\")]})]})]})]})}function Kat({device:t}){const{t:e}=Ft(),[n,r]=w.useState(null),[i,s]=w.useState(null),[o,l]=w.useState(!1),[c,d]=w.useState(!1),u=t.update_status===\"pending\"||t.update_status===\"updating\";w.useEffect(()=>{u&&n===\"applying\"&&r(null)},[u,n]),w.useEffect(()=>{const C=()=>{u&&setTimeout(()=>window.location.reload(),1e3)};return window.addEventListener(\"spoolbuddy-online\",C),()=>window.removeEventListener(\"spoolbuddy-online\",C)},[u]);const{data:m,refetch:p}=Xe({queryKey:[\"spoolbuddy-update-check\",t.device_id],queryFn:()=>Gr.checkDaemonUpdate(t.device_id),staleTime:0}),{data:f}=Xe({queryKey:[\"spoolbuddy-ssh-key\"],queryFn:()=>Gr.getSSHPublicKey(),enabled:o,staleTime:1/0}),y=async()=>{r(\"checking\"),s(null);try{await p()}finally{r(null)}},v=async()=>{r(\"applying\"),s(null);try{await Gr.triggerUpdate(t.device_id)}catch(C){s(C instanceof Error?C.message:\"Failed to trigger update\"),r(null)}},b=n!=null||u,g=()=>{f?.public_key&&(navigator.clipboard.writeText(f.public_key),d(!0),setTimeout(()=>d(!1),2e3))},_=t.firmware_version||(m?.current_version&&m.current_version!==\"0.0.0\"?m.current_version:null);return a.jsxs(\"div\",{className:\"space-y-3\",children:[a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3 space-y-3\",children:[a.jsxs(\"div\",{className:\"flex justify-between items-center text-sm\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.currentVersion\",\"Current Version\")}),a.jsx(\"span\",{className:\"text-zinc-200 font-mono\",children:_||a.jsx(\"span\",{className:\"text-zinc-500 italic text-xs\",children:e(\"spoolbuddy.settings.versionPending\",\"Waiting for daemon...\")})})]}),b?a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsxs(\"svg\",{className:\"w-4 h-4 animate-spin text-green-400 flex-shrink-0\",viewBox:\"0 0 24 24\",fill:\"none\",children:[a.jsx(\"circle\",{className:\"opacity-25\",cx:\"12\",cy:\"12\",r:\"10\",stroke:\"currentColor\",strokeWidth:\"4\"}),a.jsx(\"path\",{className:\"opacity-75\",fill:\"currentColor\",d:\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"})]}),a.jsx(\"span\",{className:\"text-green-300 text-xs\",children:n===\"checking\"?e(\"spoolbuddy.settings.checking\",\"Checking for updates...\"):t.update_message||e(\"spoolbuddy.settings.updateWaiting\",\"Updating...\")})]}):t.update_status===\"error\"?a.jsx(\"p\",{className:\"text-xs text-red-300\",children:t.update_message||e(\"spoolbuddy.settings.updateFailed\",\"Update failed\")}):i?a.jsx(\"p\",{className:\"text-xs text-red-300\",children:i}):m?.update_available?a.jsxs(\"p\",{className:\"text-xs text-green-300\",children:[e(\"spoolbuddy.settings.updateAvailable\",\"Update available\"),\": \",_,\" → \",m.latest_version]}):null,!b&&(m?.update_available?a.jsx(\"button\",{onClick:v,disabled:!t.online,className:\"w-full px-3 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors\",children:t.online?e(\"spoolbuddy.settings.applyUpdate\",\"Apply Update\"):e(\"spoolbuddy.settings.deviceOffline\",\"Device Offline\")}):a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"button\",{onClick:y,className:\"flex-1 px-3 py-2 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors\",children:e(\"spoolbuddy.settings.checkUpdates\",\"Check for Updates\")}),a.jsx(\"button\",{onClick:v,disabled:!t.online,className:\"px-3 py-2 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-zinc-200 disabled:opacity-40 transition-colors\",children:e(\"spoolbuddy.settings.forceUpdate\",\"Force Update\")})]}))]}),a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3\",children:[a.jsxs(\"button\",{onClick:()=>l(!o),className:\"w-full flex justify-between items-center text-xs\",children:[a.jsx(\"span\",{className:\"font-medium text-zinc-400\",children:e(\"spoolbuddy.settings.sshSetup\",\"SSH Setup\")}),a.jsx(\"svg\",{className:`w-3 h-3 text-zinc-500 transition-transform ${o?\"rotate-180\":\"\"}`,fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",strokeWidth:2,children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M19 9l-7 7-7-7\"})})]}),o&&a.jsxs(\"div\",{className:\"mt-2 space-y-2\",children:[a.jsx(\"p\",{className:\"text-xs text-zinc-500\",children:e(\"spoolbuddy.settings.sshDescription\",\"SSH key is deployed automatically. For manual setup, add this key to ~/.ssh/authorized_keys on the device.\")}),f?.public_key?a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(\"pre\",{className:\"bg-zinc-900 rounded p-2 text-[10px] text-zinc-400 font-mono break-all whitespace-pre-wrap\",children:f.public_key}),a.jsx(\"button\",{onClick:g,className:\"absolute top-1 right-1 px-1.5 py-0.5 rounded text-[10px] bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors\",children:c?e(\"common.copied\",\"Copied!\"):e(\"common.copy\",\"Copy\")})]}):a.jsx(\"span\",{className:\"text-[10px] text-zinc-500 italic\",children:e(\"spoolbuddy.settings.sshKeyLoading\",\"Loading...\")})]})]})]})}function LY({percent:t,color:e}){return a.jsx(\"div\",{className:\"w-full h-2 bg-zinc-700 rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:`h-full rounded-full transition-all ${e}`,style:{width:`${Math.min(100,Math.max(0,t))}%`}})})}function Xat(t){const e=Math.floor(t/86400),n=Math.floor(t%86400/3600),r=Math.floor(t%3600/60);return e>0?`${e}d ${n}h ${r}m`:n>0?`${n}h ${r}m`:`${r}m`}function Yat({device:t}){const{t:e}=Ft(),n=t.system_stats;if(!n)return a.jsx(\"div\",{className:\"flex items-center justify-center h-32\",children:a.jsx(\"p\",{className:\"text-sm text-zinc-500\",children:e(\"spoolbuddy.settings.systemStatsWaiting\",\"Waiting for system stats...\")})});const r=n.memory,i=n.disk,s=(n.cpu_temp_c??0)>=80?\"text-red-400\":(n.cpu_temp_c??0)>=65?\"text-amber-400\":\"text-green-400\",o=(r?.percent??0)>=90?\"bg-red-500\":(r?.percent??0)>=70?\"bg-amber-500\":\"bg-green-500\",l=(i?.percent??0)>=90?\"bg-red-500\":(i?.percent??0)>=70?\"bg-amber-500\":\"bg-green-500\";return a.jsxs(\"div\",{className:\"space-y-2\",children:[a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300 mb-2\",children:\"CPU\"}),a.jsxs(\"div\",{className:\"space-y-1.5 text-xs\",children:[a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.cores\",\"Cores\")}),a.jsx(\"span\",{className:\"text-zinc-300 font-mono\",children:n.cpu_count??\"-\"})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.loadAvg\",\"Load Avg\")}),a.jsx(\"span\",{className:\"text-zinc-300 font-mono\",children:n.load_avg?n.load_avg.join(\" / \"):\"-\"})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.temp\",\"Temp\")}),a.jsx(\"span\",{className:`font-mono font-medium ${s}`,children:n.cpu_temp_c!=null?`${n.cpu_temp_c}°C`:\"-\"})]})]})]}),a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300 mb-2\",children:e(\"spoolbuddy.settings.memory\",\"Memory\")}),r?a.jsxs(\"div\",{className:\"space-y-1.5\",children:[a.jsx(LY,{percent:r.percent??0,color:o}),a.jsxs(\"div\",{className:\"space-y-1 text-xs\",children:[a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.used\",\"Used\")}),a.jsxs(\"span\",{className:\"text-zinc-300 font-mono\",children:[r.used_mb,\" / \",r.total_mb,\" MB\"]})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.available\",\"Free\")}),a.jsxs(\"span\",{className:\"text-zinc-300 font-mono\",children:[r.available_mb,\" MB\"]})]})]})]}):a.jsx(\"span\",{className:\"text-xs text-zinc-500\",children:\"-\"})]})]}),a.jsx(\"div\",{className:\"bg-zinc-800 rounded-lg px-3 py-2\",children:a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300 shrink-0\",children:e(\"spoolbuddy.settings.disk\",\"Disk\")}),i?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"flex-1\",children:a.jsx(LY,{percent:i.percent??0,color:l})}),a.jsxs(\"span\",{className:\"text-xs text-zinc-300 font-mono shrink-0\",children:[i.used_gb,\" / \",i.total_gb,\" GB\"]})]}):a.jsx(\"span\",{className:\"text-xs text-zinc-500\",children:\"-\"})]})}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-2\",children:[a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300 mb-1.5\",children:e(\"spoolbuddy.settings.osInfo\",\"OS\")}),a.jsxs(\"div\",{className:\"space-y-1 text-xs\",children:[n.os?.os&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.distro\",\"Distro\")}),a.jsx(\"span\",{className:\"text-zinc-300 truncate ml-2\",children:n.os.os})]}),n.os?.kernel&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.kernel\",\"Kernel\")}),a.jsx(\"span\",{className:\"text-zinc-300 font-mono truncate ml-2\",children:n.os.kernel})]}),n.os?.arch&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.arch\",\"Arch\")}),a.jsx(\"span\",{className:\"text-zinc-300 font-mono\",children:n.os.arch})]})]})]}),a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-3\",children:[a.jsx(\"h3\",{className:\"text-sm font-semibold text-zinc-300 mb-1.5\",children:e(\"spoolbuddy.settings.runtime\",\"Runtime\")}),a.jsxs(\"div\",{className:\"space-y-1 text-xs\",children:[n.os?.python&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:\"Python\"}),a.jsx(\"span\",{className:\"text-zinc-300 font-mono\",children:n.os.python})]}),n.system_uptime_s!=null&&a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:e(\"spoolbuddy.settings.systemUptime\",\"Uptime\")}),a.jsx(\"span\",{className:\"text-zinc-300\",children:Xat(n.system_uptime_s)})]})]})]})]})]})}function Qat(){const{sbState:t,setDisplayBrightness:e}=yy(),{t:n}=Ft(),[r,i]=w.useState(\"device\"),{data:s=[]}=Xe({queryKey:[\"spoolbuddy-devices\"],queryFn:()=>Gr.getDevices(),refetchInterval:1e4}),o=t.deviceId?s.find(c=>c.device_id===t.deviceId)??s[0]:s[0],l=[{id:\"device\",label:n(\"spoolbuddy.settings.tabDevice\",\"Device\")},{id:\"display\",label:n(\"spoolbuddy.settings.tabDisplay\",\"Display\")},{id:\"scale\",label:n(\"spoolbuddy.settings.tabScale\",\"Scale\")},{id:\"updates\",label:n(\"spoolbuddy.settings.tabUpdates\",\"Updates\")},{id:\"system\",label:n(\"spoolbuddy.settings.tabSystem\",\"System\")}];return a.jsxs(\"div\",{className:\"h-full flex flex-col p-4\",children:[a.jsx(\"h1\",{className:\"text-xl font-semibold text-zinc-100 mb-3\",children:n(\"spoolbuddy.nav.settings\",\"Settings\")}),a.jsx(\"div\",{className:\"flex gap-1 bg-zinc-800/50 rounded-lg p-1 mb-4\",children:l.map(c=>a.jsx(\"button\",{onClick:()=>i(c.id),className:`flex-1 px-2 py-2 rounded-md text-sm font-medium transition-colors min-h-[36px] ${r===c.id?\"bg-zinc-700 text-zinc-100\":\"text-zinc-500 hover:text-zinc-300\"}`,children:c.label},c.id))}),a.jsx(\"div\",{className:\"flex-1 min-h-0 overflow-y-auto\",children:o?a.jsxs(a.Fragment,{children:[r===\"device\"&&a.jsx($at,{device:o}),r===\"display\"&&a.jsx(Vat,{device:o,onBrightnessChange:e}),r===\"scale\"&&a.jsx(Wat,{device:o,weight:t.weight,weightStable:t.weightStable,rawAdc:t.rawAdc}),r===\"updates\"&&a.jsx(Kat,{device:o}),r===\"system\"&&a.jsx(Yat,{device:o})]}):a.jsx(\"div\",{className:\"flex items-center justify-center h-32\",children:a.jsx(\"div\",{className:\"text-center text-zinc-500\",children:a.jsx(\"p\",{className:\"text-sm\",children:n(\"spoolbuddy.settings.noDevice\",\"No SpoolBuddy device found\")})})})})]})}function Zat({device:t,weight:e,weightStable:n,rawAdc:r}){const{t:i}=Ft(),[s,o]=w.useState(!1),[l,c]=w.useState(\"idle\"),[d,u]=w.useState(\"500\"),[m,p]=w.useState(null),[f,y]=w.useState(!1),v=C=>{C===\"backspace\"?u(P=>P.slice(0,-1)||\"\"):C===\".\"&&!d.includes(\".\")?u(P=>P+\".\"):C>=\"0\"&&C<=\"9\"&&u(P=>P===\"0\"?C:P+C)},b=async()=>{y(!0);try{await Gr.tare(t.device_id)}catch(C){console.error(\"Failed to tare:\",C)}finally{y(!1)}},g=()=>{c(\"tare\")},_=async()=>{if(l===\"tare\"){o(!0);try{p(r),await Gr.tare(t.device_id),c(\"weight\")}catch(C){console.error(\"Failed to tare:\",C)}finally{o(!1)}}else if(l===\"weight\"){const C=parseFloat(d);if(r===null||!C||C<=0)return;o(!0);try{await Gr.setCalibrationFactor(t.device_id,C,r,m??void 0),c(\"idle\")}catch(P){console.error(\"Failed to calibrate:\",P)}finally{o(!1)}}};return a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsxs(\"div\",{className:\"bg-zinc-800 rounded-lg p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between\",children:[a.jsx(\"span\",{className:\"text-sm text-zinc-400\",children:i(\"spoolbuddy.settings.currentWeight\",\"Current weight\")}),a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"div\",{className:`w-2 h-2 rounded-full ${n?\"bg-green-500\":\"bg-amber-500 animate-pulse\"}`}),a.jsx(\"span\",{className:\"text-lg font-mono text-zinc-200\",children:e!==null?`${e.toFixed(1)} g`:\"-- g\"})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-4 mt-3 text-xs\",children:[a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:i(\"spoolbuddy.settings.tareOffset\",\"Tare offset\")}),a.jsx(\"span\",{className:\"text-zinc-400 font-mono\",children:t.tare_offset})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:i(\"spoolbuddy.settings.calFactor\",\"Cal. factor\")}),a.jsx(\"span\",{className:\"text-zinc-400 font-mono\",children:t.calibration_factor.toFixed(2)})]})]})]}),l===\"idle\"?a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"button\",{onClick:b,disabled:f,className:\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px]\",children:f?\"...\":i(\"spoolbuddy.weight.tare\",\"Tare\")}),a.jsx(\"button\",{onClick:g,className:\"flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\",children:i(\"spoolbuddy.weight.calibrate\",\"Calibrate\")})]}):a.jsxs(\"div\",{className:\"bg-zinc-800 border border-zinc-700 rounded-lg p-3 space-y-2\",children:[a.jsx(\"div\",{className:\"text-sm font-medium text-zinc-200\",children:l===\"tare\"?i(\"spoolbuddy.settings.calStep1\",\"Step 1: Remove all items from the scale\"):i(\"spoolbuddy.settings.calStep2\",\"Step 2: Place known weight on scale\")}),l===\"weight\"&&a.jsxs(\"div\",{className:\"space-y-1.5\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-2\",children:[a.jsx(\"span\",{className:\"text-xs text-zinc-400\",children:i(\"spoolbuddy.settings.knownWeight\",\"Known weight (g)\")}),a.jsxs(\"div\",{className:\"flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-right text-base font-mono text-zinc-100\",children:[d||\"0\",a.jsx(\"span\",{className:\"text-zinc-500 ml-1\",children:\"g\"})]})]}),a.jsx(\"div\",{className:\"grid grid-cols-4 gap-1\",children:[\"7\",\"8\",\"9\",\"backspace\",\"4\",\"5\",\"6\",\".\",\"1\",\"2\",\"3\",\"0\"].map(C=>a.jsx(\"button\",{onClick:()=>v(C),className:`py-2 rounded text-sm font-medium transition-colors min-h-[36px] ${C===\"backspace\"?\"bg-zinc-700 text-zinc-300 hover:bg-zinc-600\":\"bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700\"}`,children:C===\"backspace\"?\"⌫\":C},C))})]}),a.jsxs(\"div\",{className:\"flex gap-2\",children:[a.jsx(\"button\",{onClick:()=>c(\"idle\"),className:\"flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[40px]\",children:i(\"common.cancel\",\"Cancel\")}),a.jsx(\"button\",{onClick:_,disabled:s,className:\"flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[40px]\",children:s?\"...\":l===\"tare\"?i(\"spoolbuddy.settings.setZero\",\"Set Zero\"):i(\"spoolbuddy.settings.calibrateNow\",\"Calibrate\")})]})]})]})}function Jat(){const{sbState:t}=yy(),{t:e}=Ft(),n=qo(),{data:r=[]}=Xe({queryKey:[\"spoolbuddy-devices\"],queryFn:()=>Gr.getDevices(),refetchInterval:1e4}),i=t.deviceId?r.find(s=>s.device_id===t.deviceId)??r[0]:r[0];return a.jsxs(\"div\",{className:\"h-full flex flex-col p-4\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3 mb-4\",children:[a.jsx(\"button\",{onClick:()=>n(\"/spoolbuddy/settings\"),className:\"p-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 transition-colors\",children:a.jsx(\"svg\",{className:\"w-5 h-5\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",strokeWidth:2,children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M15 19l-7-7 7-7\"})})}),a.jsx(\"h1\",{className:\"text-xl font-semibold text-zinc-100\",children:e(\"spoolbuddy.settings.scaleCalibration\",\"Scale Calibration\")})]}),a.jsx(\"div\",{className:\"flex-1 min-h-0 overflow-y-auto\",children:i?a.jsx(Zat,{device:i,weight:t.weight,weightStable:t.weightStable,rawAdc:t.rawAdc}):a.jsx(\"div\",{className:\"flex items-center justify-center h-32\",children:a.jsx(\"div\",{className:\"text-center text-zinc-500\",children:a.jsx(\"p\",{className:\"text-sm\",children:e(\"spoolbuddy.settings.noDevice\",\"No SpoolBuddy device found\")})})})})]})}const eit=[\"PLA\",\"PETG\",\"ABS\",\"ASA\",\"TPU\",\"PA\",\"PC\",\"PVA\",\"HIPS\"];function tit(){const{t}=Ft(),{sbState:e}=yy(),[n,r]=w.useState(\"existing\"),[i,s]=w.useState(null),[o,l]=w.useState(\"\"),[c,d]=w.useState(\"idle\"),[u,m]=w.useState(\"\"),[p,f]=w.useState(!1),[y,v]=w.useState(!1),[b,g]=w.useState(null),{data:_=[],refetch:C}=Xe({queryKey:[\"inventory-spools\"],queryFn:()=>ue.getSpools(!1),refetchInterval:1e4}),{data:P=[]}=Xe({queryKey:[\"spoolbuddy-devices\"],queryFn:()=>Gr.getDevices(),refetchInterval:5e3}),{data:N}=Xe({queryKey:[\"settings\"],queryFn:ue.getSettings}),A=P[0],T=e.deviceOnline,F=Eo(N?.currency||\"USD\"),k=w.useMemo(()=>{let re;if(n===\"existing\")re=_.filter(W=>!W.tag_uid&&!W.archived_at);else if(n===\"replace\")re=_.filter(W=>(W.tag_uid||W.tray_uuid)&&!W.archived_at);else return[];if(o){const W=o.toLowerCase();re=re.filter(ne=>ne.material?.toLowerCase().includes(W)||ne.color_name?.toLowerCase().includes(W)||ne.brand?.toLowerCase().includes(W)||ne.subtype?.toLowerCase().includes(W))}return re},[_,n,o]),D=w.useCallback(re=>{const W=re.detail;(W.sak??W.data?.sak)===0&&(v(!0),g(W.tag_uid??W.data?.tag_uid??null))},[]),H=w.useCallback(re=>{const W=re.detail;v(!0),g(W.tag_uid??W.data?.tag_uid??null)},[]),z=w.useCallback(()=>{v(!1),g(null)},[]),Q=w.useCallback(re=>{const W=re.detail;(W.spool_id===i?.id||W.data?.spool_id===i?.id)&&(d(\"success\"),m(t(\"spoolbuddy.writeTag.writeSuccess\",\"Tag written successfully!\")),C(),setTimeout(()=>{d(\"idle\"),s(null),m(\"\")},5e3))},[i,t,C]),L=w.useCallback(re=>{const W=re.detail;(W.spool_id===i?.id||W.data?.spool_id===i?.id)&&(d(\"error\"),m(W.message??W.data?.message??t(\"spoolbuddy.writeTag.writeFailed\",\"Write failed\")))},[i,t]);w.useEffect(()=>(window.addEventListener(\"spoolbuddy-unknown-tag\",D),window.addEventListener(\"spoolbuddy-tag-matched\",H),window.addEventListener(\"spoolbuddy-tag-removed\",z),window.addEventListener(\"spoolbuddy-tag-written\",Q),window.addEventListener(\"spoolbuddy-tag-write-failed\",L),()=>{window.removeEventListener(\"spoolbuddy-unknown-tag\",D),window.removeEventListener(\"spoolbuddy-tag-matched\",H),window.removeEventListener(\"spoolbuddy-tag-removed\",z),window.removeEventListener(\"spoolbuddy-tag-written\",Q),window.removeEventListener(\"spoolbuddy-tag-write-failed\",L)}),[D,H,z,Q,L]),w.useEffect(()=>{s(null),d(\"idle\"),m(\"\"),l(\"\")},[n]);const te=async()=>{if(!(!i||!A)){d(\"writing\"),m(t(\"spoolbuddy.writeTag.waiting\",\"Waiting for SpoolBuddy...\"));try{await Gr.writeTag(A.device_id,i.id)}catch{d(\"error\"),m(t(\"spoolbuddy.writeTag.queueFailed\",\"Failed to queue write command\"))}}},ie=async()=>{if(A){try{await Gr.cancelWrite(A.device_id)}catch{}d(\"idle\"),m(\"\")}},J=async()=>{if(!(!i||!OY(i))){f(!0),d(\"idle\"),m(\"\");try{await ue.linkTagToSpool(i.id,{tag_uid:\"\",tray_uuid:\"\",data_origin:\"manual\"}),await C(),s(null),d(\"success\"),m(t(\"spoolbuddy.writeTag.untagSuccess\",\"Tag removed from spool\")),setTimeout(()=>{d(\"idle\"),m(\"\")},2500)}catch{d(\"error\"),m(t(\"spoolbuddy.writeTag.untagFailed\",\"Failed to remove tag from spool\"))}finally{f(!1)}}},oe=w.useCallback(re=>{s(re),d(\"idle\"),m(\"\"),C()},[C]),fe=i&&T&&c!==\"writing\"&&c!==\"success\";return a.jsxs(\"div\",{className:\"flex flex-col h-full\",children:[a.jsx(\"div\",{className:\"flex border-b border-bambu-dark-tertiary shrink-0\",children:[{key:\"existing\",label:t(\"spoolbuddy.writeTag.tabExisting\",\"Existing Spool\")},{key:\"new\",label:t(\"spoolbuddy.writeTag.tabNew\",\"New Spool\")},{key:\"replace\",label:t(\"spoolbuddy.writeTag.tabReplace\",\"Replace Tag\")}].map(re=>a.jsx(\"button\",{onClick:()=>r(re.key),className:`flex-1 py-3 text-sm font-medium transition-colors ${n===re.key?\"text-bambu-green border-b-2 border-bambu-green bg-bambu-dark\":\"text-zinc-400 hover:text-zinc-200 hover:bg-bambu-dark-tertiary\"}`,children:re.label},re.key))}),a.jsxs(\"div\",{className:\"flex flex-1 overflow-hidden\",children:[a.jsx(\"div\",{className:\"flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary\",children:n===\"new\"?a.jsx(rit,{currencySymbol:F,onCreated:oe,selectedSpool:i,t}):a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"p-3 shrink-0\",children:a.jsx(\"input\",{type:\"text\",value:o,onChange:re=>l(re.target.value),placeholder:t(\"spoolbuddy.writeTag.searchPlaceholder\",\"Search by material, color, brand...\"),className:\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green\"})}),a.jsx(\"div\",{className:\"flex-1 overflow-y-auto px-3 pb-3 space-y-2\",children:k.length===0?a.jsx(\"div\",{className:\"text-center text-zinc-500 py-8 text-sm\",children:n===\"existing\"?t(\"spoolbuddy.writeTag.noUntaggedSpools\",\"No spools without tags\"):t(\"spoolbuddy.writeTag.noTaggedSpools\",\"No spools with tags\")}):k.map(re=>a.jsx(nit,{spool:re,selected:i?.id===re.id,showTag:n===\"replace\",onClick:()=>{s(re),d(\"idle\"),m(\"\")}},re.id))})]})}),a.jsx(\"div\",{className:\"w-[340px] flex flex-col items-center justify-center p-6 shrink-0\",children:a.jsx(ait,{writeStatus:c,writeMessage:u,selectedSpool:i,tagOnReader:y,tagUid:b,deviceOnline:T,canWrite:!!fe,isReplace:n===\"replace\",canUntag:n===\"replace\"&&!!i&&OY(i),untagging:p,onWrite:te,onUntag:J,onCancel:ie,onRetry:()=>{d(\"idle\"),m(\"\")},t})})]})]})}function OY(t){return!!(t.tag_uid||t.tray_uuid)}function nit({spool:t,selected:e,showTag:n,onClick:r}){const i=t.rgba?`#${t.rgba.slice(0,6)}`:\"#666\",s=Math.max(0,t.label_weight-t.weight_used),o=t.label_weight>0?Math.round(s/t.label_weight*100):0;return a.jsxs(\"button\",{onClick:r,className:`w-full flex items-center gap-3 p-3 rounded-lg text-left transition-colors ${e?\"bg-bambu-green/15 border border-bambu-green/50\":\"bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-transparent\"}`,children:[a.jsx(\"div\",{className:\"w-8 h-8 rounded-full shrink-0 border border-white/10\",style:{backgroundColor:i}}),a.jsxs(\"div\",{className:\"flex-1 min-w-0\",children:[a.jsx(\"div\",{className:\"flex items-center gap-2\",children:a.jsxs(\"span\",{className:\"text-sm font-medium text-white truncate\",children:[t.brand?`${t.brand} `:\"\",t.material,t.subtype?` ${t.subtype}`:\"\"]})}),a.jsxs(\"div\",{className:\"flex items-center gap-2 text-xs text-zinc-400\",children:[t.color_name&&a.jsx(\"span\",{children:t.color_name}),a.jsxs(\"span\",{children:[s,\"g / \",t.label_weight,\"g (\",o,\"%)\"]})]}),n&&t.tag_uid&&a.jsx(\"div\",{className:\"text-xs text-zinc-500 mt-0.5 font-mono\",children:t.tag_uid})]}),e&&a.jsx(\"svg\",{className:\"w-5 h-5 text-bambu-green shrink-0\",fill:\"currentColor\",viewBox:\"0 0 20 20\",children:a.jsx(\"path\",{fillRule:\"evenodd\",d:\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\",clipRule:\"evenodd\"})})]})}function rit({currencySymbol:t,onCreated:e,selectedSpool:n,t:r}){const[i,s]=w.useState(\"simple\"),[o,l]=w.useState(\"filament\"),[c,d]=w.useState(dO),[u,m]=w.useState({}),[p,f]=w.useState(!1),[y,v]=w.useState(1),[b,g]=w.useState(!1),[_,C]=w.useState(null),[P,N]=w.useState(!1),[A,T]=w.useState(!1),[F,k]=w.useState([]),[D,H]=w.useState([]),[z,Q]=w.useState([]),[L,te]=w.useState([]),[ie,J]=w.useState(\"\"),[oe,fe]=w.useState([]),[re,W]=w.useState([]),[ne,me]=w.useState(new Set),[be,Ce]=w.useState(new Set);w.useEffect(()=>{fe(Zle())},[]),w.useEffect(()=>{(async()=>{if(i===\"full\"){T(!0);try{const he=await ue.getCloudStatus();if(N(he.is_authenticated),he.is_authenticated){const le=await ue.getFilamentPresets();k(le)}}catch{N(!1)}finally{T(!1)}ue.getSpoolCatalog().then(Q).catch(()=>{}),ue.getColorCatalog().then(te).catch(()=>{}),ue.getLocalPresets().then(he=>H(he.filament)).catch(()=>{});try{const he=await ue.getPrinters(),le=await Promise.all(he.map(R=>ue.getPrinterStatus(R.id).catch(()=>null))),B=[];for(let R=0;R<he.length;R++){const ae=he[R],Se=le[R]?.connected??!1;let ve=[];if(Se)try{ve=(await ue.getKProfiles(ae.id)).profiles.map(ye=>({cali_idx:ye.slot_id,filament_id:ye.filament_id,setting_id:ye.setting_id||\"\",name:ye.name,k_value:parseFloat(ye.k_value)||0,n_coef:parseFloat(ye.n_coef)||0,extruder_id:ye.extruder_id,nozzle_diameter:ye.nozzle_diameter}))}catch{}B.push({printer:{...ae,connected:Se},calibrations:ve})}W(B)}catch{}}})()},[i]),w.useEffect(()=>{re.length>0&&Ce(new Set(re.map(ge=>String(ge.printer.id))))},[re]);const q=w.useMemo(()=>Yle(F,new Set,D),[F,D]),Y=w.useMemo(()=>Qle(c.slicer_filament,q),[c.slicer_filament,q]),E=w.useMemo(()=>{const ge=Xle(F,D),he=L.map(le=>le.manufacturer?.trim()).filter(le=>!!le);return Array.from(new Set([...ge,...he])).sort((le,B)=>le.localeCompare(B))},[F,D,L]),j=w.useMemo(()=>{const ge=L.map(he=>he.material?.trim()).filter(he=>!!he);return Array.from(new Set([...Gle,...ge])).sort((he,le)=>he.localeCompare(le))},[L]),O=w.useMemo(()=>{const ge=[];for(const he of L){const le=he.manufacturer?.trim(),B=he.material?.trim();le&&B&&ge.push({brand:le,material:B})}for(const he of F){const le=Cp(he.name);le.brand&&le.material&&ge.push({brand:le.brand,material:le.material})}for(const he of D){const le=Cp(he.name),B=he.filament_vendor?.trim()||le.brand,R=le.material;B&&R&&ge.push({brand:B,material:R})}return ge},[F,L,D]),K=w.useMemo(()=>{const ge=new Map;for(const he of O){const le=he.brand.toLowerCase(),B=he.material.toLowerCase();ge.has(le)||ge.set(le,new Set),ge.get(le).add(B)}return ge},[O]),U=w.useMemo(()=>{const ge=new Map;for(const he of O){const le=he.brand.toLowerCase(),B=he.material.toLowerCase();ge.has(B)||ge.set(B,new Set),ge.get(B).add(le)}return ge},[O]),de=w.useMemo(()=>{if(!c.material)return E;const ge=c.material.toLowerCase(),he=U.get(ge);return!he||he.size===0?E:E.filter(le=>he.has(le.toLowerCase()))},[E,c.material,U]),I=w.useMemo(()=>{if(!c.brand)return j;const ge=c.brand.toLowerCase(),he=K.get(ge);return!he||he.size===0?j:j.filter(le=>he.has(le.toLowerCase()))},[j,c.brand,K]),G=(ge,he)=>{d(le=>({...le,[ge]:he})),u[ge]&&m(le=>({...le,[ge]:void 0}))},X=ge=>{fe(he=>Jle(ge,he))},V=async ge=>{if(ne.size===0){try{await ue.saveSpoolKProfiles(ge,[])}catch{}return}const he=[];for(const le of ne){const[B,R,ae]=le.split(\":\"),_e=parseInt(B),Se=parseInt(R),ve=ae===\"null\"?0:parseInt(ae),Te=re.find(ye=>ye.printer.id===_e);if(Te){const ye=Te.calibrations.find(je=>je.cali_idx===Se);ye&&he.push({printer_id:_e,extruder:ve,nozzle_diameter:ye.nozzle_diameter||\"0.4\",k_value:ye.k_value,name:ye.name||null,cali_idx:ye.cali_idx,setting_id:ye.setting_id||null})}}he.length>0&&await ue.saveSpoolKProfiles(ge,he)},ee=async()=>{C(null);const ge=Vle(c,i===\"simple\"?!0:p);if(!ge.isValid){m(ge.errors),l(\"filament\");return}const he=Y?.displayName||ie||null,le={material:c.material,subtype:c.subtype||null,brand:c.brand||null,color_name:c.color_name||null,rgba:c.rgba||null,label_weight:c.label_weight,core_weight:c.core_weight,core_weight_catalog_id:c.core_weight_catalog_id,weight_used:c.weight_used,slicer_filament:c.slicer_filament||null,slicer_filament_name:he,nozzle_temp_min:null,nozzle_temp_max:null,note:c.note||null,cost_per_kg:c.cost_per_kg,added_full:null,last_used:null,encode_time:null,tag_uid:null,tray_uuid:null,data_origin:null,tag_type:null,last_scale_weight:null,last_weighed_at:null};g(!0);try{if(y>1){const B=await ue.bulkCreateSpools(le,y);for(const R of B)await V(R.id);B.length>0&&e(B[0])}else{const B=await ue.createSpool(le);await V(B.id),e(B)}}catch{C(r(\"spoolbuddy.writeTag.createFailed\",\"Failed to create spool\"))}finally{g(!1)}},se=`#${(c.rgba||\"808080FF\").slice(0,6)}`;return a.jsxs(\"div\",{className:\"p-3 space-y-3 overflow-y-auto h-full\",children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary\",children:[a.jsx(\"span\",{className:\"text-sm text-zinc-200\",children:r(\"spoolbuddy.writeTag.viewMode\",\"View\")}),a.jsxs(\"div\",{className:\"flex rounded-lg overflow-hidden border border-bambu-dark-tertiary\",children:[a.jsx(\"button\",{type:\"button\",onClick:()=>s(\"simple\"),className:`px-3 py-1.5 text-xs font-medium ${i===\"simple\"?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark text-zinc-400\"}`,children:r(\"spoolbuddy.writeTag.simpleView\",\"Simple\")}),a.jsx(\"button\",{type:\"button\",onClick:()=>s(\"full\"),className:`px-3 py-1.5 text-xs font-medium ${i===\"full\"?\"bg-bambu-green/20 text-bambu-green\":\"bg-bambu-dark text-zinc-400\"}`,children:r(\"spoolbuddy.writeTag.fullView\",\"Full\")})]})]}),i===\"simple\"?n?a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center h-full p-6 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\",children:[a.jsx(\"div\",{className:\"w-12 h-12 rounded-full mb-4 border border-white/10\",style:{backgroundColor:n.rgba?`#${n.rgba.slice(0,6)}`:\"#666\"}}),a.jsxs(\"p\",{className:\"text-white font-medium\",children:[n.brand?`${n.brand} `:\"\",n.material]}),n.color_name&&a.jsx(\"p\",{className:\"text-zinc-400 text-sm\",children:n.color_name}),a.jsxs(\"p\",{className:\"text-zinc-500 text-xs mt-1\",children:[n.label_weight,\"g\"]}),a.jsx(\"p\",{className:\"text-bambu-green text-sm mt-4\",children:r(\"spoolbuddy.writeTag.spoolCreated\",\"Spool created! Ready to write.\")})]}):a.jsxs(\"div\",{className:\"p-4 space-y-4 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\",children:[a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-zinc-400 mb-1\",children:r(\"spoolbuddy.writeTag.material\",\"Material\")}),a.jsx(\"select\",{value:c.material,onChange:ge=>G(\"material\",ge.target.value),className:\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green\",children:eit.map(ge=>a.jsx(\"option\",{value:ge,children:ge},ge))})]}),a.jsxs(\"div\",{className:\"flex gap-3\",children:[a.jsxs(\"div\",{className:\"flex-1\",children:[a.jsx(\"label\",{className:\"block text-xs text-zinc-400 mb-1\",children:r(\"spoolbuddy.writeTag.colorName\",\"Color Name\")}),a.jsx(\"input\",{type:\"text\",value:c.color_name,onChange:ge=>G(\"color_name\",ge.target.value),placeholder:\"Jade White\",className:\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-zinc-400 mb-1\",children:r(\"spoolbuddy.writeTag.color\",\"Color\")}),a.jsx(\"input\",{type:\"color\",value:se,onChange:ge=>G(\"rgba\",ge.target.value.replace(\"#\",\"\").toUpperCase()+\"FF\"),className:\"w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer\"})]})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-zinc-400 mb-1\",children:r(\"spoolbuddy.writeTag.brand\",\"Brand\")}),a.jsx(\"input\",{type:\"text\",value:c.brand,onChange:ge=>G(\"brand\",ge.target.value),placeholder:\"Polymaker\",className:\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green\"})]}),a.jsxs(\"div\",{children:[a.jsx(\"label\",{className:\"block text-xs text-zinc-400 mb-1\",children:r(\"spoolbuddy.writeTag.weight\",\"Weight (g)\")}),a.jsx(\"input\",{type:\"number\",value:c.label_weight,onChange:ge=>G(\"label_weight\",parseInt(ge.target.value)||0),min:0,max:1e4,className:\"w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green\"})]}),a.jsx(\"button\",{onClick:ee,disabled:b||!c.material,className:\"w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors\",children:b?r(\"spoolbuddy.writeTag.creating\",\"Creating...\"):r(\"spoolbuddy.writeTag.createSpool\",\"Create Spool\")})]}):a.jsxs(a.Fragment,{children:[a.jsxs(\"div\",{className:\"flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary\",children:[a.jsx(\"span\",{className:\"text-sm text-zinc-200\",children:r(\"inventory.quickAdd\",\"Quick Add\")}),a.jsx(\"button\",{type:\"button\",onClick:()=>f(ge=>!ge),className:`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${p?\"bg-bambu-green\":\"bg-bambu-dark-tertiary\"}`,children:a.jsx(\"span\",{className:`inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform ${p?\"translate-x-6\":\"translate-x-1\"}`})})]}),a.jsxs(\"div\",{className:\"flex border border-bambu-dark-tertiary rounded-lg overflow-hidden\",children:[a.jsx(\"button\",{onClick:()=>l(\"filament\"),className:`flex-1 py-2.5 text-sm font-medium ${o===\"filament\"?\"bg-bambu-green/15 text-bambu-green\":\"bg-bambu-dark-secondary text-zinc-400\"}`,children:r(\"inventory.filamentInfoTab\",\"Filament\")}),!p&&a.jsx(\"button\",{onClick:()=>l(\"pa-profile\"),className:`flex-1 py-2.5 text-sm font-medium ${o===\"pa-profile\"?\"bg-bambu-green/15 text-bambu-green\":\"bg-bambu-dark-secondary text-zinc-400\"}`,children:r(\"inventory.paProfileTab\",\"PA Profile\")})]}),a.jsx(\"div\",{className:\"bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-3\",children:o===\"filament\"?a.jsxs(\"div\",{className:\"space-y-4\",children:[a.jsx(ece,{formData:c,updateField:G,cloudAuthenticated:P,loadingCloudPresets:A,presetInputValue:ie,setPresetInputValue:J,selectedPresetOption:Y,filamentOptions:q,availableBrands:de,availableMaterials:I,quickAdd:p,quantity:y,onQuantityChange:v,errors:u}),a.jsx(tce,{formData:c,updateField:G,recentColors:oe,onColorUsed:X,catalogColors:L}),a.jsx(nce,{formData:c,updateField:G,spoolCatalog:z,currencySymbol:t})]}):a.jsx(rce,{formData:c,updateField:G,printersWithCalibrations:re,selectedProfiles:ne,setSelectedProfiles:me,expandedPrinters:be,setExpandedPrinters:Ce})})]}),_&&a.jsx(\"div\",{className:\"text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded-lg px-3 py-2\",children:_}),i===\"full\"&&a.jsx(\"button\",{onClick:ee,disabled:b,className:\"w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors\",children:b?r(\"spoolbuddy.writeTag.creating\",\"Creating...\"):r(\"spoolbuddy.writeTag.createSpool\",\"Create Spool\")}),i===\"full\"&&n&&a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center p-4 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg\",children:[a.jsx(\"div\",{className:\"w-12 h-12 rounded-full mb-4 border border-white/10\",style:{backgroundColor:n.rgba?`#${n.rgba.slice(0,6)}`:\"#666\"}}),a.jsxs(\"p\",{className:\"text-white font-medium\",children:[n.brand?`${n.brand} `:\"\",n.material]}),n.color_name&&a.jsx(\"p\",{className:\"text-zinc-400 text-sm\",children:n.color_name}),a.jsxs(\"p\",{className:\"text-zinc-500 text-xs mt-1\",children:[n.label_weight,\"g\"]}),a.jsx(\"p\",{className:\"text-bambu-green text-sm mt-4\",children:r(\"spoolbuddy.writeTag.spoolCreated\",\"Spool created! Ready to write.\")})]})]})}function ait({writeStatus:t,writeMessage:e,selectedSpool:n,tagOnReader:r,tagUid:i,deviceOnline:s,canWrite:o,isReplace:l,canUntag:c,untagging:d,onWrite:u,onUntag:m,onCancel:p,onRetry:f,t:y}){if(t===\"success\")return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center space-y-4\",children:[a.jsx(\"div\",{className:\"w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center\",children:a.jsx(\"svg\",{className:\"w-8 h-8 text-green-400\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:2,d:\"M5 13l4 4L19 7\"})})}),a.jsx(\"p\",{className:\"text-green-400 font-medium\",children:e}),n&&a.jsxs(\"p\",{className:\"text-zinc-400 text-sm\",children:[n.brand?`${n.brand} `:\"\",n.material,n.color_name?` - ${n.color_name}`:\"\"]})]});if(t===\"error\")return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center space-y-4\",children:[a.jsx(\"div\",{className:\"w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center\",children:a.jsx(\"svg\",{className:\"w-8 h-8 text-red-400\",fill:\"none\",viewBox:\"0 0 24 24\",stroke:\"currentColor\",children:a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",strokeWidth:2,d:\"M6 18L18 6M6 6l12 12\"})})}),a.jsx(\"p\",{className:\"text-red-400 font-medium\",children:e}),a.jsx(\"button\",{onClick:f,className:\"px-4 py-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-white text-sm rounded transition-colors\",children:y(\"spoolbuddy.writeTag.tryAgain\",\"Try Again\")})]});if(t===\"writing\")return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center space-y-4\",children:[a.jsxs(\"div\",{className:\"relative w-16 h-16\",children:[a.jsx(\"div\",{className:\"absolute inset-0 rounded-full border-2 border-bambu-green/30 animate-ping\"}),a.jsx(\"div\",{className:\"absolute inset-2 rounded-full border-2 border-bambu-green/50 animate-pulse\"}),a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center\",children:a.jsx(d0,{className:\"w-8 h-8 text-bambu-green\"})})]}),a.jsx(\"p\",{className:\"text-bambu-green font-medium\",children:y(\"spoolbuddy.writeTag.writing\",\"Writing tag...\")}),a.jsx(\"p\",{className:\"text-zinc-500 text-xs\",children:e}),a.jsx(\"button\",{onClick:p,className:\"px-4 py-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-400 text-sm rounded transition-colors\",children:y(\"spoolbuddy.writeTag.cancel\",\"Cancel\")})]});if(!s)return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center space-y-3\",children:[a.jsx(d0,{className:\"w-12 h-12 text-zinc-600\"}),a.jsx(\"p\",{className:\"text-zinc-500 text-sm\",children:y(\"spoolbuddy.writeTag.deviceOffline\",\"SpoolBuddy is offline\")})]});if(!n)return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center space-y-3\",children:[a.jsx(d0,{className:\"w-12 h-12 text-zinc-600\"}),a.jsx(\"p\",{className:\"text-zinc-400 text-sm\",children:y(\"spoolbuddy.writeTag.selectSpool\",\"Select a spool, then place a blank NTAG on the reader\")})]});const v=n.rgba?`#${n.rgba.slice(0,6)}`:\"#666\";return a.jsxs(\"div\",{className:\"flex flex-col items-center text-center space-y-4 w-full\",children:[a.jsx(\"div\",{className:\"relative w-16 h-16\",children:r?a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"absolute inset-0 rounded-full bg-bambu-green/10\"}),a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center\",children:a.jsx(d0,{className:\"w-8 h-8 text-bambu-green\"})})]}):a.jsxs(a.Fragment,{children:[a.jsx(\"div\",{className:\"absolute inset-0 rounded-full border-2 border-zinc-600 animate-pulse\"}),a.jsx(\"div\",{className:\"absolute inset-0 flex items-center justify-center\",children:a.jsx(d0,{className:\"w-8 h-8 text-zinc-500\"})})]})}),r?a.jsxs(\"div\",{className:\"space-y-1\",children:[a.jsx(\"p\",{className:\"text-bambu-green text-sm font-medium\",children:y(\"spoolbuddy.writeTag.tagReady\",\"Tag detected — ready to write\")}),i&&a.jsx(\"p\",{className:\"text-zinc-500 text-xs font-mono\",children:i})]}):a.jsx(\"p\",{className:\"text-zinc-400 text-sm\",children:y(\"spoolbuddy.writeTag.placeTag\",\"Place an NTAG on the reader\")}),a.jsxs(\"div\",{className:\"w-full bg-bambu-dark-secondary rounded-lg p-3 space-y-2\",children:[a.jsxs(\"div\",{className:\"flex items-center gap-3\",children:[a.jsx(\"div\",{className:\"w-8 h-8 rounded-full border border-white/10 shrink-0\",style:{backgroundColor:v}}),a.jsxs(\"div\",{className:\"text-left min-w-0\",children:[a.jsxs(\"p\",{className:\"text-white text-sm font-medium truncate\",children:[n.brand?`${n.brand} `:\"\",n.material]}),n.color_name&&a.jsx(\"p\",{className:\"text-zinc-400 text-xs\",children:n.color_name})]})]}),a.jsxs(\"div\",{className:\"text-xs text-zinc-500\",children:[n.label_weight,\"g\"]})]}),l&&n.tag_uid&&a.jsx(\"p\",{className:\"text-yellow-500/80 text-xs\",children:y(\"spoolbuddy.writeTag.replaceWarning\",\"Old tag will be unlinked. New tag will replace it.\")}),a.jsx(\"button\",{onClick:u,disabled:!o,className:\"w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-40 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors text-sm\",children:l?y(\"spoolbuddy.writeTag.replaceTag\",\"Replace Tag\"):y(\"spoolbuddy.writeTag.writeTag\",\"Write Tag\")}),l&&c&&a.jsx(\"button\",{onClick:m,disabled:d,className:\"w-full py-2.5 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary disabled:opacity-40 disabled:cursor-not-allowed text-zinc-200 rounded-lg transition-colors text-sm\",children:d?y(\"spoolbuddy.writeTag.untagging\",\"Removing tag...\"):y(\"spoolbuddy.writeTag.untagSpool\",\"Untag Selected Spool\")})]})}function d0({className:t}){return a.jsxs(\"svg\",{className:t,viewBox:\"0 0 24 24\",fill:\"none\",stroke:\"currentColor\",strokeWidth:1.5,children:[a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0\"}),a.jsx(\"path\",{strokeLinecap:\"round\",strokeLinejoin:\"round\",d:\"M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z\"})]})}const iit=\"spoolbuddy-default-core-weight\";function sit(){try{const t=localStorage.getItem(iit);if(t){const e=parseInt(t,10);if(e>=0&&e<=500)return e}}catch{}return 250}function oit({spool:t,liveScaleWeight:e,persistedGrossWeight:n,onClose:r,onSyncWeight:i,onAssignToAms:s,className:o}){const{t:l}=Ft(),[c,d]=w.useState(!1),[u,m]=w.useState(!1),[p,f]=w.useState(null),{data:y}=Xe({queryKey:[\"spool-k-profiles\",t.id],queryFn:()=>ue.getSpoolKProfiles(t.id),enabled:!t.k_profiles||t.k_profiles.length===0,staleTime:300*1e3}),v=t.k_profiles&&t.k_profiles.length>0?t.k_profiles:y,b=t.rgba?`#${t.rgba.slice(0,6)}`:\"#808080\",g=t.core_weight&&t.core_weight>0?t.core_weight:sit(),_=e!==null?Math.round(Math.max(0,e)):null,C=p??(n!==void 0?n!==null?Math.round(Math.max(0,n)):null:_),P=Math.round(Math.max(0,(t.label_weight||0)-(t.weight_used||0))),T=_!==null&&_>=10?Math.round(Math.max(0,_-g)):P,F=Math.round(t.label_weight||1e3),k=F>0?Math.min(100,Math.round(T/F*100)):null,D=k!==null?k>50?\"#22c55e\":k>20?\"#eab308\":\"#ef4444\":\"#808080\",z=Math.max(0,(t.label_weight||0)-(t.weight_used||0))+g,Q=_!==null?_-z:null,L=Q!==null?Math.abs(Q)<=50:null,te=Math.round(z),ie=C??te,J=t.nozzle_temp_min!=null&&t.nozzle_temp_max!=null?`${t.nozzle_temp_min}-${t.nozzle_temp_max}°C`:null,oe=t.slicer_filament_name||t.slicer_filament||null,fe=t.note?.trim()||null,re=v&&v.length>0?Array.from(new Set(v.map(ne=>ne.k_value.toFixed(3)))).join(\", \"):null,W=async()=>{if(e===null)return;const ne=Math.round(Math.max(0,e));d(!0);try{await Gr.updateSpoolWeight(t.id,ne),f(ne),m(!0),i?.(),setTimeout(()=>m(!1),3e3)}catch(me){console.error(\"Failed to sync weight:\",me)}finally{d(!1)}};return a.jsxs(\"div\",{className:`flex flex-col items-center space-y-4 max-w-md ${o??\"\"}`,children:[a.jsxs(\"div\",{className:\"flex items-start gap-5\",children:[a.jsxs(\"div\",{className:\"relative shrink-0\",children:[a.jsx(SA,{color:b,isEmpty:!1,size:100}),k!==null&&a.jsxs(\"div\",{className:\"absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg\",style:{backgroundColor:D},children:[k,\"%\"]})]}),a.jsxs(\"div\",{className:\"flex-1 min-w-0 pt-1\",children:[a.jsx(\"h3\",{className:\"text-lg font-semibold text-zinc-100\",children:t.color_name||\"Unknown color\"}),a.jsxs(\"p\",{className:\"text-sm text-zinc-400\",children:[t.brand,\" • \",t.material,t.subtype&&` ${t.subtype}`]}),a.jsxs(\"div\",{className:\"mt-3\",children:[a.jsxs(\"div\",{className:\"flex items-baseline gap-2\",children:[a.jsxs(\"span\",{className:\"text-3xl font-bold font-mono text-zinc-100\",children:[T,\"g\"]}),a.jsxs(\"span\",{className:\"text-sm text-zinc-500\",children:[\"/ \",F,\"g\"]})]}),a.jsx(\"p\",{className:\"text-xs text-zinc-500 mt-0.5\",children:l(\"spoolbuddy.spool.remaining\",\"Remaining\")}),a.jsx(\"div\",{className:\"mt-2 max-w-xs\",children:a.jsx(\"div\",{className:\"h-2 bg-zinc-700 rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:\"h-full rounded-full transition-all duration-500\",style:{width:`${k??0}%`,backgroundColor:D}})})})]})]})]}),a.jsxs(\"div\",{className:\"grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full\",children:[a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:l(\"spoolbuddy.dashboard.grossWeight\",\"Gross weight\")}),a.jsxs(\"span\",{className:\"font-mono text-zinc-300\",children:[ie,\"g\"]})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:l(\"spoolbuddy.spool.coreWeight\",\"Core\")}),a.jsxs(\"span\",{className:\"font-mono text-zinc-300\",children:[g,\"g\"]})]}),a.jsxs(\"div\",{className:\"flex justify-between\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:l(\"spoolbuddy.dashboard.spoolSize\",\"Spool size\")}),a.jsxs(\"span\",{className:\"font-mono text-zinc-300\",children:[F,\"g\"]})]}),a.jsxs(\"div\",{className:\"flex justify-between items-center\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:l(\"spoolbuddy.spool.scaleWeight\",\"Scale\")}),_!==null?a.jsxs(\"span\",{className:`flex items-center gap-1 font-mono ${L?\"text-green-500\":\"text-yellow-500\"}`,children:[_,\"g\",L?a.jsx(Ur,{className:\"w-3.5 h-3.5\"}):a.jsxs(a.Fragment,{children:[a.jsx(Dn,{className:\"w-3.5 h-3.5\"}),a.jsx(\"button\",{onClick:W,className:\"p-1 hover:bg-green-500/20 rounded transition-colors text-green-500\",title:l(\"spoolbuddy.dashboard.syncWeight\",\"Sync Weight\"),children:a.jsx(lr,{className:\"w-4 h-4\"})})]})]}):a.jsx(\"span\",{className:\"text-zinc-500\",children:\"—\"})]}),a.jsxs(\"div\",{className:\"flex justify-between items-center\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:l(\"spoolbuddy.dashboard.tagId\",\"Tag\")}),a.jsx(\"span\",{className:\"font-mono text-xs text-zinc-400 truncate max-w-[120px]\",title:t.tag_uid||\"\",children:t.tag_uid?t.tag_uid.slice(-8):\"—\"})]}),J&&a.jsxs(\"div\",{className:\"flex justify-between items-center\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:l(\"spoolbuddy.inventory.nozzleTemp\",\"Nozzle\")}),a.jsx(\"span\",{className:\"font-mono text-zinc-300\",children:J})]}),t.cost_per_kg!=null&&t.cost_per_kg>0&&a.jsxs(\"div\",{className:\"flex justify-between items-center\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:l(\"spoolbuddy.inventory.costPerKg\",\"Cost/kg\")}),a.jsxs(\"span\",{className:\"font-mono text-zinc-300\",children:[t.cost_per_kg.toFixed(2),\"/kg\"]})]}),re&&a.jsxs(\"div\",{className:\"flex justify-between items-center\",children:[a.jsx(\"span\",{className:\"text-zinc-500\",children:l(\"spoolbuddy.inventory.kProfiles\",\"K-Profile\")}),a.jsx(\"span\",{className:\"font-mono text-zinc-300 truncate max-w-[220px] text-right\",title:re,children:re})]}),oe&&a.jsxs(\"div\",{className:\"min-w-0\",children:[a.jsx(\"p\",{className:\"text-xs text-zinc-500 mb-1\",children:l(\"spoolbuddy.inventory.slicerFilament\",\"Slicer Filament\")}),a.jsx(\"p\",{className:\"text-sm text-zinc-300 whitespace-pre-wrap break-words\",children:oe})]}),fe&&a.jsxs(\"div\",{className:\"col-span-2\",children:[a.jsx(\"p\",{className:\"text-xs text-zinc-500 mb-1\",children:l(\"spoolbuddy.inventory.note\",\"Note\")}),a.jsx(\"p\",{className:\"text-sm leading-5 text-zinc-300 whitespace-pre-wrap break-words max-h-[3.75rem] overflow-y-auto pr-1\",children:fe})]})]}),a.jsxs(\"div\",{className:\"flex gap-2 justify-center\",children:[s&&a.jsx(\"button\",{onClick:s,className:\"px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]\",children:l(\"spoolbuddy.modal.assignToAms\",\"Assign to AMS\")}),a.jsx(\"button\",{onClick:W,disabled:e===null||c,className:`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${u?\"bg-green-600/20 text-green-400\":s?\"bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed\":\"bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed\"}`,children:c?\"...\":u?l(\"spoolbuddy.dashboard.weightSynced\",\"Synced!\"):l(\"spoolbuddy.dashboard.syncWeight\",\"Sync Weight\")}),r&&a.jsx(\"button\",{onClick:r,className:\"px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]\",children:l(\"spoolbuddy.dashboard.close\",\"Close\")})]})]})}function lit(t){return t.rgba?`#${t.rgba.substring(0,6)}`:\"#808080\"}function cit(t){return Math.max(0,t.label_weight-t.weight_used)}function dit(t){return t.label_weight<=0?0:Math.max(0,Math.min(100,(t.label_weight-t.weight_used)/t.label_weight*100))}function uit(t){const e=[t.material];return t.subtype&&e.push(t.subtype),e.join(\" \")}function mce(t){const e=t.ams_id===254||t.ams_id===255,n=!e&&t.ams_id>=128;return RS(t.ams_id,t.tray_id,n,e)}function mit({color:t,size:e=56}){return a.jsxs(\"svg\",{width:e,height:e,viewBox:\"0 0 56 56\",children:[a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"26\",fill:t}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"20\",fill:t,style:{filter:\"brightness(0.85)\"}}),a.jsx(\"ellipse\",{cx:\"20\",cy:\"20\",rx:\"6\",ry:\"4\",fill:\"white\",opacity:\"0.3\"}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"8\",fill:\"#2d2d2d\"}),a.jsx(\"circle\",{cx:\"28\",cy:\"28\",r:\"5\",fill:\"#1a1a1a\"})]})}function hit(){const{sbState:t,selectedPrinterId:e}=yy(),{t:n}=Ft(),[r,i]=w.useState(\"\"),[s,o]=w.useState(\"all\"),[l,c]=w.useState(null),[d,u]=w.useState(!1),{data:m}=Xe({queryKey:[\"spoolman-settings\"],queryFn:ue.getSpoolmanSettings,staleTime:300*1e3}),{data:p=[],isLoading:f,refetch:y}=Xe({queryKey:[\"inventory-spools\"],queryFn:()=>ue.getSpools(!1),refetchInterval:3e4}),{data:v=[]}=Xe({queryKey:[\"spool-assignments\"],queryFn:()=>ue.getAssignments(),refetchInterval:3e4}),b=w.useMemo(()=>{const T={};return v.forEach(F=>{T[F.spool_id]=F}),T},[v]),g=w.useMemo(()=>p.filter(T=>!T.archived_at),[p]),_=w.useMemo(()=>new Set(v.map(T=>T.spool_id)),[v]),C=w.useMemo(()=>g.filter(T=>_.has(T.id)).length,[g,_]),P=w.useMemo(()=>{const T=new Set;return g.forEach(F=>T.add(F.material)),Array.from(T).sort()},[g]),N=w.useMemo(()=>{let T=g;if(s===\"in_ams\"?T=T.filter(F=>_.has(F.id)):s!==\"all\"&&(T=T.filter(F=>F.material===s)),r.trim()){const F=r.toLowerCase().trim();T=T.filter(k=>k.material.toLowerCase().includes(F)||k.subtype&&k.subtype.toLowerCase().includes(F)||k.brand&&k.brand.toLowerCase().includes(F)||k.color_name&&k.color_name.toLowerCase().includes(F)||k.note&&k.note.toLowerCase().includes(F))}return[...T].sort((F,k)=>{const D=_.has(F.id)?0:1,H=_.has(k.id)?0:1;return D!==H?D-H:new Date(k.updated_at).getTime()-new Date(F.updated_at).getTime()})},[g,s,r,_]);return m?.spoolman_enabled===\"true\"&&m?.spoolman_url?a.jsx(\"div\",{className:\"h-full flex flex-col\",children:a.jsx(\"iframe\",{src:`${m.spoolman_url.replace(/\\/+$/,\"\")}/spool`,className:\"flex-1 w-full border-0\",title:\"Spoolman\",sandbox:\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"})}):a.jsxs(\"div\",{className:\"h-full flex flex-col\",children:[a.jsxs(\"div\",{className:\"px-3 pt-3 pb-2 space-y-2.5\",children:[a.jsxs(\"div\",{className:\"relative\",children:[a.jsx(Pr,{className:\"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40\"}),a.jsx(\"input\",{type:\"text\",value:r,onChange:T=>i(T.target.value),placeholder:n(\"spoolbuddy.inventory.searchPlaceholder\",\"Search spools...\"),className:\"w-full pl-9 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-bambu-green\"}),r&&a.jsx(\"button\",{onClick:()=>i(\"\"),className:\"absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60\",children:a.jsx(Ht,{className:\"w-4 h-4\"})})]}),a.jsxs(\"div\",{className:\"flex gap-1.5 overflow-x-auto no-scrollbar\",children:[a.jsx(U3,{active:s===\"all\",onClick:()=>o(\"all\"),label:`${n(\"spoolbuddy.inventory.all\",\"All\")} (${g.length})`,green:!0}),C>0&&a.jsx(U3,{active:s===\"in_ams\",onClick:()=>o(\"in_ams\"),label:`${n(\"spoolbuddy.inventory.inAms\",\"In AMS\")} (${C})`}),P.map(T=>a.jsx(U3,{active:s===T,onClick:()=>o(s===T?\"all\":T),label:T},T))]})]}),a.jsx(\"div\",{className:\"flex-1 overflow-y-auto px-3 pb-3\",children:f?a.jsx(\"div\",{className:\"flex items-center justify-center py-16\",children:a.jsx(\"div\",{className:\"w-8 h-8 border-2 border-bambu-green border-t-transparent rounded-full animate-spin\"})}):N.length===0?a.jsxs(\"div\",{className:\"flex flex-col items-center justify-center py-16 text-white/30\",children:[a.jsx(Ra,{className:\"w-12 h-12 mb-3\"}),a.jsx(\"p\",{className:\"text-sm\",children:r||s!==\"all\"?n(\"spoolbuddy.inventory.noResults\",\"No spools match your filters\"):n(\"spoolbuddy.inventory.empty\",\"No spools in inventory\")})]}):a.jsx(\"div\",{className:\"grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-2\",children:N.map(T=>a.jsx(pit,{spool:T,assignment:b[T.id],onClick:()=>c(T.id)},T.id))})}),l!=null&&(()=>{const T=p.find(k=>k.id===l);if(!T)return null;const F=()=>{c(null),u(!1)};return a.jsxs(a.Fragment,{children:[a.jsx(fit,{spool:T,assignment:b[T.id],sbState:t,onSyncWeight:()=>{y()},onAssignToAms:()=>u(!0),onClose:F}),a.jsx(uce,{isOpen:d,onClose:()=>u(!1),spool:T,printerId:e})]})})()]})}function U3({active:t,onClick:e,label:n,green:r}){return a.jsx(\"button\",{onClick:e,className:`px-4 py-1.5 rounded-full text-sm font-medium border whitespace-nowrap shrink-0 transition-colors ${t?r?\"bg-bambu-green/20 text-bambu-green border-bambu-green/50\":\"bg-white/10 text-white border-white/20\":\"bg-transparent text-white/40 border-bambu-dark-tertiary hover:text-white/60\"}`,children:n})}function pit({spool:t,assignment:e,onClick:n}){const r=lit(t),i=dit(t),s=cit(t),o=KP(t.color_name,t.rgba);return a.jsxs(\"button\",{onClick:n,className:\"bg-bambu-dark-secondary rounded-xl p-3 flex flex-col items-center text-center gap-1.5 border border-transparent hover:border-bambu-green/50 transition-colors\",children:[a.jsx(mit,{color:r,size:56}),a.jsx(\"p\",{className:\"text-xs font-semibold text-white leading-tight truncate w-full\",children:uit(t)}),a.jsxs(\"div\",{className:\"flex items-center gap-1 min-w-0 max-w-full\",children:[a.jsx(\"span\",{className:\"w-2.5 h-2.5 rounded-full shrink-0 border border-white/10\",style:{backgroundColor:r}}),a.jsx(\"span\",{className:\"text-[11px] text-white/50 truncate\",children:o||\"-\"})]}),a.jsxs(\"div\",{className:\"w-full space-y-0.5\",children:[a.jsx(\"div\",{className:\"h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden\",children:a.jsx(\"div\",{className:`h-full rounded-full ${i>50?\"bg-bambu-green\":i>20?\"bg-yellow-500\":\"bg-red-500\"}`,style:{width:`${Math.min(i,100)}%`}})}),a.jsxs(\"p\",{className:\"text-[11px] text-white/40\",children:[Math.round(s),\"g (\",Math.round(i),\"%)\"]})]}),e&&a.jsx(\"span\",{className:\"px-2 py-0.5 rounded text-[10px] font-bold bg-bambu-green/20 text-bambu-green\",children:mce(e)})]})}function fit({spool:t,assignment:e,sbState:n,onSyncWeight:r,onAssignToAms:i,onClose:s}){const l=n.deviceOnline&&n.weight!==null?Math.round(n.weight):null,c=t.last_scale_weight!=null?Math.round(t.last_scale_weight):null;return a.jsx(\"div\",{className:\"fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4\",onClick:s,children:a.jsx(\"div\",{className:\"w-full max-w-md bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-2xl p-4 overflow-y-auto max-h-[90vh]\",onClick:d=>d.stopPropagation(),children:a.jsxs(\"div\",{className:\"space-y-3\",children:[e&&a.jsxs(\"div\",{className:\"flex items-center justify-center gap-2\",children:[a.jsx(\"span\",{className:\"px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green\",children:mce(e)}),e.printer_name&&a.jsx(\"span\",{className:\"text-xs text-zinc-400\",children:e.printer_name})]}),a.jsx(\"div\",{className:\"flex justify-center\",children:a.jsx(oit,{spool:t,liveScaleWeight:l,persistedGrossWeight:c,onSyncWeight:r,onAssignToAms:i,onClose:s,className:\"max-w-md\"})})]})})})}class git extends w.Component{state={error:null,errorInfo:null};static getDerivedStateFromError(e){return{error:e}}componentDidCatch(e,n){this.setState({errorInfo:n}),console.error(\"React crash:\",e,n)}render(){return this.state.error?a.jsxs(\"div\",{style:{padding:24,color:\"#ef4444\",backgroundColor:\"#18181b\",minHeight:\"100vh\",fontFamily:\"monospace\"},children:[a.jsx(\"h1\",{style:{fontSize:20,marginBottom:12},children:\"UI Crash\"}),a.jsx(\"pre\",{style:{whiteSpace:\"pre-wrap\",fontSize:14},children:this.state.error.message}),a.jsx(\"pre\",{style:{whiteSpace:\"pre-wrap\",fontSize:12,color:\"#a1a1aa\",marginTop:12},children:this.state.error.stack}),a.jsx(\"button\",{onClick:()=>{this.setState({error:null,errorInfo:null})},style:{marginTop:16,padding:\"8px 16px\",backgroundColor:\"#3b82f6\",color:\"#fff\",border:\"none\",borderRadius:8,cursor:\"pointer\"},children:\"Retry\"})]}):this.props.children}}const bit=new tpe({defaultOptions:{queries:{staleTime:1e3*60,retry:1}}});function xit(){return $le(),null}function IY({children:t}){return eat(),a.jsx(a.Fragment,{children:t})}function zY({children:t}){const{authEnabled:e,loading:n,user:r}=kr();return n?a.jsx(\"div\",{className:\"min-h-screen flex items-center justify-center\",children:\"Loading...\"}):e&&!r?a.jsx(Bx,{to:\"/login\",replace:!0}):a.jsx(a.Fragment,{children:t})}function B3({children:t}){const{authEnabled:e,loading:n,user:r,isAdmin:i}=kr();return n?a.jsx(\"div\",{className:\"min-h-screen flex items-center justify-center\",children:\"Loading...\"}):e?r?i?a.jsx(a.Fragment,{children:t}):a.jsx(Bx,{to:\"/\",replace:!0}):a.jsx(Bx,{to:\"/login\",replace:!0}):a.jsx(a.Fragment,{children:t})}function yit({children:t}){const{authEnabled:e,loading:n}=kr();return n?a.jsx(\"div\",{className:\"min-h-screen flex items-center justify-center\",children:\"Loading...\"}):e?a.jsx(Bx,{to:\"/login\",replace:!0}):a.jsx(a.Fragment,{children:t})}function vit(){return a.jsx(git,{children:a.jsx(gye,{children:a.jsx(Pye,{children:a.jsx(npe,{client:bit,children:a.jsx(Nye,{children:a.jsxs(tat,{children:[a.jsx(xit,{}),a.jsx(whe,{children:a.jsxs(Yme,{children:[a.jsx(Or,{path:\"/setup\",element:a.jsx(yit,{children:a.jsx(Zrt,{})})}),a.jsx(Or,{path:\"/login\",element:a.jsx(Qrt,{})}),a.jsx(Or,{path:\"/camera/:printerId\",element:a.jsx(yrt,{})}),a.jsx(Or,{path:\"/overlay/:printerId\",element:a.jsx(krt,{})}),a.jsxs(Or,{element:a.jsx(zY,{children:a.jsx(IY,{children:a.jsx(gat,{})})}),children:[a.jsx(Or,{path:\"spoolbuddy\",element:a.jsx(Lat,{})}),a.jsx(Or,{path:\"spoolbuddy/ams\",element:a.jsx(zat,{})}),a.jsx(Or,{path:\"spoolbuddy/write-tag\",element:a.jsx(tit,{})}),a.jsx(Or,{path:\"spoolbuddy/inventory\",element:a.jsx(hit,{})}),a.jsx(Or,{path:\"spoolbuddy/settings\",element:a.jsx(Qat,{})}),a.jsx(Or,{path:\"spoolbuddy/calibration\",element:a.jsx(Jat,{})})]}),a.jsxs(Or,{element:a.jsx(zY,{children:a.jsx(IY,{children:a.jsx(Bye,{})})}),children:[a.jsx(Or,{index:!0,element:a.jsx(q7e,{})}),a.jsx(Or,{path:\"archives\",element:a.jsx(RJe,{})}),a.jsx(Or,{path:\"queue\",element:a.jsx(Mtt,{})}),a.jsx(Or,{path:\"stats\",element:a.jsx(Xtt,{})}),a.jsx(Or,{path:\"profiles\",element:a.jsx(Knt,{})}),a.jsx(Or,{path:\"maintenance\",element:a.jsx(Jnt,{})}),a.jsx(Or,{path:\"projects\",element:a.jsx(trt,{})}),a.jsx(Or,{path:\"projects/:id\",element:a.jsx(ort,{})}),a.jsx(Or,{path:\"inventory\",element:a.jsx(qrt,{})}),a.jsx(Or,{path:\"files\",element:a.jsx(prt,{})}),a.jsx(Or,{path:\"settings\",element:a.jsx(B3,{children:a.jsx(Fnt,{})})}),a.jsx(Or,{path:\"groups/new\",element:a.jsx(B3,{children:a.jsx(NY,{})})}),a.jsx(Or,{path:\"groups/:id/edit\",element:a.jsx(B3,{children:a.jsx(NY,{})})}),a.jsx(Or,{path:\"users\",element:a.jsx(Bx,{to:\"/settings?tab=users\",replace:!0})}),a.jsx(Or,{path:\"groups\",element:a.jsx(Bx,{to:\"/settings?tab=users\",replace:!0})}),a.jsx(Or,{path:\"system\",element:a.jsx(Yrt,{})}),a.jsx(Or,{path:\"notifications\",element:a.jsx(Jrt,{})}),a.jsx(Or,{path:\"external/:id\",element:a.jsx(Nrt,{})})]})]})})]})})})})})})}$de.createRoot(document.getElementById(\"root\")).render(a.jsx(w.StrictMode,{children:a.jsx(vit,{})}));\n"
  },
  {
    "path": "static/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\" />\n    <!-- L-4: Restrict Referer header to origin-only on cross-origin navigation so\n         sensitive tokens in query parameters are not leaked to third-party servers. -->\n    <meta name=\"referrer\" content=\"strict-origin-when-cross-origin\" />\n    <title>Bambuddy</title>\n\n    <!-- PWA Meta Tags -->\n    <meta name=\"description\" content=\"Monitor and manage your Bambu Lab 3D printers\" />\n    <meta name=\"theme-color\" content=\"#00ae42\" />\n    <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n    <meta name=\"apple-mobile-web-app-title\" content=\"Bambuddy\" />\n\n    <!-- Manifest -->\n    <link rel=\"manifest\" href=\"/manifest.json\" />\n\n    <!-- Favicons -->\n    <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/img/favicon-32x32.png\" />\n    <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/img/favicon-16x16.png\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/img/apple-touch-icon.png\" />\n\n    <!-- Splash screens for iOS -->\n    <link rel=\"apple-touch-startup-image\" href=\"/img/android-chrome-512x512.png\" />\n    <script type=\"module\" crossorigin src=\"/assets/index-NbcE7Ots.js\"></script>\n    <link rel=\"stylesheet\" crossorigin href=\"/assets/index-BoxU3Y8Y.css\">\n  </head>\n  <body>\n    <div id=\"root\"></div>\n\n    <!-- Service Worker Registration (skip on SpoolBuddy kiosk).\n         Kept as an external file so the CSP `script-src 'self'` covers it\n         without needing 'unsafe-inline' or per-build hashes. -->\n    <script src=\"/sw-register.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "static/manifest.json",
    "content": "{\n  \"id\": \"/\",\n  \"name\": \"Bambuddy\",\n  \"short_name\": \"Bambuddy\",\n  \"description\": \"Monitor and manage your Bambu Lab 3D printers\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#1a1a1a\",\n  \"theme_color\": \"#00ae42\",\n  \"orientation\": \"any\",\n  \"scope\": \"/\",\n  \"icons\": [\n    {\n      \"src\": \"/img/favicon-16x16.png\",\n      \"sizes\": \"16x16\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/img/favicon-32x32.png\",\n      \"sizes\": \"32x32\",\n      \"type\": \"image/png\"\n    },\n    {\n      \"src\": \"/img/android-chrome-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any\"\n    },\n    {\n      \"src\": \"/img/android-chrome-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"any\"\n    },\n    {\n      \"src\": \"/img/android-chrome-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"/img/android-chrome-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"screenshots\": [\n    {\n      \"src\": \"/img/screenshot-mobile.png\",\n      \"sizes\": \"1080x1920\",\n      \"type\": \"image/png\",\n      \"form_factor\": \"narrow\",\n      \"label\": \"Bambuddy on mobile\"\n    },\n    {\n      \"src\": \"/img/screenshot-desktop.png\",\n      \"sizes\": \"1920x1080\",\n      \"type\": \"image/png\",\n      \"form_factor\": \"wide\",\n      \"label\": \"Bambuddy on desktop\"\n    }\n  ],\n  \"categories\": [\"utilities\", \"productivity\"],\n  \"shortcuts\": [\n    {\n      \"name\": \"Printers\",\n      \"short_name\": \"Printers\",\n      \"description\": \"View your printers\",\n      \"url\": \"/\",\n      \"icons\": [{ \"src\": \"/img/android-chrome-192x192.png\", \"sizes\": \"192x192\" }]\n    },\n    {\n      \"name\": \"Archives\",\n      \"short_name\": \"Archives\",\n      \"description\": \"View print archives\",\n      \"url\": \"/archives\",\n      \"icons\": [{ \"src\": \"/img/android-chrome-192x192.png\", \"sizes\": \"192x192\" }]\n    },\n    {\n      \"name\": \"Queue\",\n      \"short_name\": \"Queue\",\n      \"description\": \"View print queue\",\n      \"url\": \"/queue\",\n      \"icons\": [{ \"src\": \"/img/android-chrome-192x192.png\", \"sizes\": \"192x192\" }]\n    },\n    {\n      \"name\": \"Projects\",\n      \"short_name\": \"Projects\",\n      \"description\": \"View print projects\",\n      \"url\": \"/projects\",\n      \"icons\": [{ \"src\": \"/img/android-chrome-192x192.png\", \"sizes\": \"192x192\" }]\n    }\n  ]\n}\n"
  },
  {
    "path": "static/sw-register.js",
    "content": "if ('serviceWorker' in navigator) {\n  if (location.pathname.startsWith('/spoolbuddy')) {\n    navigator.serviceWorker.getRegistrations().then((regs) => {\n      if (regs.length > 0) {\n        Promise.all([\n          ...regs.map((r) => r.unregister()),\n          caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))),\n        ]).then(() => location.reload());\n      }\n    });\n  } else {\n    window.addEventListener('load', () => {\n      navigator.serviceWorker.register('/sw.js')\n        .then((registration) => {\n          console.log('SW registered:', registration.scope);\n        })\n        .catch((error) => {\n          console.log('SW registration failed:', error);\n        });\n    });\n  }\n}\n"
  },
  {
    "path": "static/sw.js",
    "content": "// Bambuddy Service Worker\nconst CACHE_NAME = 'bambuddy-v25';\nconst STATIC_CACHE = 'bambuddy-static-v25';\n\n// Static assets to cache on install\nconst STATIC_ASSETS = [\n  '/',\n  '/manifest.json',\n  '/img/favicon.png',\n  '/img/favicon-16x16.png',\n  '/img/favicon-32x32.png',\n  '/img/android-chrome-192x192.png',\n  '/img/android-chrome-512x512.png',\n  '/img/apple-touch-icon.png',\n  '/img/bambuddy_logo_dark.png',\n];\n\n// Install event - cache static assets\nself.addEventListener('install', (event) => {\n  console.log('[SW] Installing service worker...');\n  event.waitUntil(\n    caches.open(STATIC_CACHE).then((cache) => {\n      console.log('[SW] Caching static assets');\n      return cache.addAll(STATIC_ASSETS);\n    })\n  );\n  // Activate immediately\n  self.skipWaiting();\n});\n\n// Activate event - clean up old caches\nself.addEventListener('activate', (event) => {\n  console.log('[SW] Activating service worker...');\n  event.waitUntil(\n    caches.keys().then((cacheNames) => {\n      return Promise.all(\n        cacheNames\n          .filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)\n          .map((name) => {\n            console.log('[SW] Deleting old cache:', name);\n            return caches.delete(name);\n          })\n      );\n    })\n  );\n  // Take control immediately\n  self.clients.claim();\n});\n\n// Fetch event - network-first for API, cache-first for static\nself.addEventListener('fetch', (event) => {\n  const { request } = event;\n  const url = new URL(request.url);\n\n  // Skip non-GET requests\n  if (request.method !== 'GET') {\n    return;\n  }\n\n  // Skip WebSocket connections\n  if (url.protocol === 'ws:' || url.protocol === 'wss:') {\n    return;\n  }\n\n  // Skip camera stream/snapshot requests - Safari has issues with streaming through SW\n  if (url.pathname.includes('/camera/stream') || url.pathname.includes('/camera/snapshot')) {\n    return;\n  }\n\n  // API requests - network first, no cache (real-time data is critical)\n  if (url.pathname.startsWith('/api/')) {\n    event.respondWith(\n      fetch(request).catch(() => {\n        // Return offline response for API failures\n        return new Response(\n          JSON.stringify({ error: 'offline', message: 'You are currently offline' }),\n          {\n            status: 503,\n            headers: { 'Content-Type': 'application/json' },\n          }\n        );\n      })\n    );\n    return;\n  }\n\n  // Static assets - cache first, then network\n  if (\n    url.pathname.startsWith('/img/') ||\n    url.pathname.startsWith('/icons/') ||\n    url.pathname.endsWith('.png') ||\n    url.pathname.endsWith('.jpg') ||\n    url.pathname.endsWith('.svg') ||\n    url.pathname.endsWith('.ico')\n  ) {\n    event.respondWith(\n      caches.match(request).then((cached) => {\n        if (cached) {\n          return cached;\n        }\n        return fetch(request).then((response) => {\n          // Cache successful responses\n          if (response.ok) {\n            const clone = response.clone();\n            caches.open(STATIC_CACHE).then((cache) => {\n              cache.put(request, clone);\n            });\n          }\n          return response;\n        });\n      })\n    );\n    return;\n  }\n\n  // JS/CSS assets - network first (Vite content-hashes filenames, so\n  // cache-busting is built in; network-first ensures new builds load immediately)\n  if (\n    url.pathname.startsWith('/assets/') ||\n    url.pathname.endsWith('.js') ||\n    url.pathname.endsWith('.css')\n  ) {\n    event.respondWith(\n      fetch(request)\n        .then((response) => {\n          if (response.ok) {\n            const clone = response.clone();\n            caches.open(CACHE_NAME).then((cache) => {\n              cache.put(request, clone);\n            });\n          }\n          return response;\n        })\n        .catch(() => {\n          return caches.match(request);\n        })\n    );\n    return;\n  }\n\n  // HTML pages - network first, fall back to cache\n  event.respondWith(\n    fetch(request)\n      .then((response) => {\n        if (response.ok) {\n          const clone = response.clone();\n          caches.open(CACHE_NAME).then((cache) => {\n            cache.put(request, clone);\n          });\n        }\n        return response;\n      })\n      .catch(() => {\n        return caches.match(request).then((cached) => {\n          if (cached) {\n            return cached;\n          }\n          // Return cached index for SPA navigation\n          return caches.match('/');\n        });\n      })\n  );\n});\n\n// Handle push notifications (for future use)\nself.addEventListener('push', (event) => {\n  if (!event.data) return;\n\n  const data = event.data.json();\n  const options = {\n    body: data.body || 'New notification from Bambuddy',\n    icon: '/img/android-chrome-192x192.png',\n    badge: '/img/favicon-32x32.png',\n    vibrate: [100, 50, 100],\n    data: {\n      url: data.url || '/',\n    },\n  };\n\n  event.waitUntil(\n    self.registration.showNotification(data.title || 'Bambuddy', options)\n  );\n});\n\n// Handle notification clicks\nself.addEventListener('notificationclick', (event) => {\n  event.notification.close();\n\n  const url = event.notification.data?.url || '/';\n\n  event.waitUntil(\n    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {\n      // Check if there's already a window open\n      for (const client of windowClients) {\n        if (client.url.includes(self.location.origin) && 'focus' in client) {\n          client.navigate(url);\n          return client.focus();\n        }\n      }\n      // Open a new window if none exists\n      if (clients.openWindow) {\n        return clients.openWindow(url);\n      }\n    })\n  );\n});\n"
  },
  {
    "path": "test_all.sh",
    "content": "#!/bin/bash\n\n./test_frontend.sh && ./test_backend.sh --full && ./test_docker.sh && ./test_security.sh --full\n"
  },
  {
    "path": "test_backend.sh",
    "content": "#!/bin/sh\n\ncd backend\nruff check && ruff format --check\n\n#if [ \"$1\" = \"--full\" ]; then\n../venv/bin/python3 -m pytest tests/ -v -n 30\n#else\n#../venv/bin/python3 -m pytest tests/ -v -n 30 --ignore=tests/unit/services/test_bambu_ftp.py\n#fi\n#cd ..\n"
  },
  {
    "path": "test_docker.sh",
    "content": "#!/bin/bash\n#\n# Docker Test Suite for BamBuddy\n# Runs build verification, unit tests, and integration tests in Docker\n#\n\nset -e\n\n# Configuration\nPORT=${PORT:-8000}\n\n# Enable BuildKit for better caching and parallel builds\nexport DOCKER_BUILDKIT=1\nexport COMPOSE_DOCKER_CLI_BUILD=1\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# Track results\nTESTS_PASSED=0\nTESTS_FAILED=0\nFAILED_TESTS=\"\"\n\nprint_header() {\n    echo \"\"\n    echo -e \"${BLUE}========================================${NC}\"\n    echo -e \"${BLUE}  $1${NC}\"\n    echo -e \"${BLUE}========================================${NC}\"\n}\n\nprint_success() {\n    echo -e \"${GREEN}✓ $1${NC}\"\n    TESTS_PASSED=$((TESTS_PASSED + 1))\n}\n\nprint_failure() {\n    echo -e \"${RED}✗ $1${NC}\"\n    TESTS_FAILED=$((TESTS_FAILED + 1))\n    FAILED_TESTS=\"${FAILED_TESTS}\\n  - $1\"\n}\n\nprint_info() {\n    echo -e \"${YELLOW}→ $1${NC}\"\n}\n\ncleanup() {\n    print_info \"Cleaning up test containers...\"\n    sudo docker compose -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true\n    sudo docker compose down -v --remove-orphans 2>/dev/null || true\n}\n\n# Cleanup on exit\ntrap cleanup EXIT\n\n# Parse arguments\nRUN_BUILD=true\nRUN_BACKEND=true\nRUN_FRONTEND=true\nRUN_INTEGRATION=true\nFRESH_BUILD=false\n\nwhile [[ $# -gt 0 ]]; do\n    case $1 in\n        --build-only)\n            RUN_BACKEND=false\n            RUN_FRONTEND=false\n            RUN_INTEGRATION=false\n            shift\n            ;;\n        --backend-only)\n            RUN_BUILD=false\n            RUN_FRONTEND=false\n            RUN_INTEGRATION=false\n            shift\n            ;;\n        --frontend-only)\n            RUN_BUILD=false\n            RUN_BACKEND=false\n            RUN_INTEGRATION=false\n            shift\n            ;;\n        --integration-only)\n            RUN_BUILD=false\n            RUN_BACKEND=false\n            RUN_FRONTEND=false\n            shift\n            ;;\n        --skip-build)\n            RUN_BUILD=false\n            shift\n            ;;\n        --skip-integration)\n            RUN_INTEGRATION=false\n            shift\n            ;;\n        --fresh)\n            FRESH_BUILD=true\n            shift\n            ;;\n        -h|--help)\n            echo \"Usage: $0 [OPTIONS]\"\n            echo \"\"\n            echo \"Options:\"\n            echo \"  --build-only        Only run build test\"\n            echo \"  --backend-only      Only run backend tests\"\n            echo \"  --frontend-only     Only run frontend tests\"\n            echo \"  --integration-only  Only run integration tests\"\n            echo \"  --skip-build        Skip build test\"\n            echo \"  --skip-integration  Skip integration tests\"\n            echo \"  --fresh             Force fresh build (no cache)\"\n            echo \"  -h, --help          Show this help\"\n            exit 0\n            ;;\n        *)\n            echo \"Unknown option: $1\"\n            exit 1\n            ;;\n    esac\ndone\n\n# Set cache flag based on --fresh option\nCACHE_FLAG=\"\"\nif [ \"$FRESH_BUILD\" = true ]; then\n    CACHE_FLAG=\"--no-cache\"\n    print_info \"Fresh build enabled (--no-cache)\"\nfi\n\nprint_header \"BamBuddy Docker Test Suite\"\n\n# ============================================\n# Pre-build: Build all test images in parallel\n# ============================================\nprint_header \"Pre-building Docker Images\"\n\n# Determine which images to build\nIMAGES_TO_BUILD=\"\"\nif [ \"$RUN_BACKEND\" = true ]; then\n    IMAGES_TO_BUILD=\"$IMAGES_TO_BUILD backend-test\"\nfi\nif [ \"$RUN_FRONTEND\" = true ]; then\n    IMAGES_TO_BUILD=\"$IMAGES_TO_BUILD frontend-test\"\nfi\nif [ \"$RUN_INTEGRATION\" = true ]; then\n    IMAGES_TO_BUILD=\"$IMAGES_TO_BUILD integration integration-test-runner\"\nfi\n\nif [ -n \"$IMAGES_TO_BUILD\" ]; then\n    print_info \"Building test images in parallel:$IMAGES_TO_BUILD\"\n    if sudo docker compose -f docker-compose.test.yml build --parallel $CACHE_FLAG $IMAGES_TO_BUILD; then\n        print_success \"Test images built successfully\"\n    else\n        print_failure \"Test image build failed\"\n        exit 1\n    fi\nfi\n\n# ============================================\n# Test 1: Docker Build (Production Image)\n# ============================================\nif [ \"$RUN_BUILD\" = true ]; then\n    print_header \"Test 1: Docker Build (Production)\"\n    print_info \"Building production Docker image...\"\n\n    if sudo docker build -t bambuddy:test . $CACHE_FLAG --progress=plain; then\n        print_success \"Production image builds successfully\"\n\n        # Verify image has expected labels/structure\n        print_info \"Verifying image structure...\"\n        if sudo docker run --rm bambuddy:test python -c \"import backend.app.main; print('Backend imports OK')\"; then\n            print_success \"Backend module imports correctly\"\n        else\n            print_failure \"Backend module import failed\"\n        fi\n\n        if sudo docker run --rm bambuddy:test test -d /app/static; then\n            print_success \"Static files directory exists\"\n        else\n            print_failure \"Static files directory missing\"\n        fi\n    else\n        print_failure \"Production image build failed\"\n    fi\nfi\n\n# ============================================\n# Test 2: Backend Unit Tests\n# ============================================\nif [ \"$RUN_BACKEND\" = true ]; then\n    print_header \"Test 2: Backend Unit Tests\"\n    print_info \"Running backend tests...\"\n    if sudo docker compose -f docker-compose.test.yml run --rm backend-test; then\n        print_success \"Backend unit tests passed\"\n    else\n        print_failure \"Backend unit tests failed\"\n    fi\nfi\n\n# ============================================\n# Test 3: Frontend Unit Tests\n# ============================================\nif [ \"$RUN_FRONTEND\" = true ]; then\n    print_header \"Test 3: Frontend Unit Tests\"\n    print_info \"Running frontend tests...\"\n    if sudo docker compose -f docker-compose.test.yml run --rm frontend-test; then\n        print_success \"Frontend unit tests passed\"\n    else\n        print_failure \"Frontend unit tests failed\"\n    fi\nfi\n\n# ============================================\n# Test 4: Integration Tests\n# ============================================\nif [ \"$RUN_INTEGRATION\" = true ]; then\n    print_header \"Test 4: Integration Tests\"\n    print_info \"Starting application container...\"\n\n    # Start the integration container\n    sudo docker compose -f docker-compose.test.yml up --remove-orphans -d integration\n\n    # Wait for health check\n    print_info \"Waiting for application to be healthy...\"\n    RETRIES=30\n    while [ $RETRIES -gt 0 ]; do\n        if sudo docker compose -f docker-compose.test.yml ps integration | grep -q \"healthy\"; then\n            break\n        fi\n        sleep 2\n        ((RETRIES--))\n    done\n\n    if [ $RETRIES -eq 0 ]; then\n        print_failure \"Application failed to become healthy\"\n        sudo docker compose -f docker-compose.test.yml logs integration\n    else\n        print_success \"Application is healthy\"\n\n        # Run basic health checks\n        print_info \"Running integration tests...\"\n\n        # Test health endpoint\n        HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/health)\n        if echo \"$HEALTH_RESPONSE\" | grep -q \"healthy\"; then\n            print_success \"Health endpoint responds correctly\"\n        else\n            print_failure \"Health endpoint check failed\"\n        fi\n\n        # Test API endpoints\n        API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/api/v1/settings)\n        if echo \"$API_RESPONSE\" | grep -q \"settings\"; then\n            print_success \"Settings API endpoint responds\"\n        else\n            # Settings might return empty, which is OK\n            print_success \"Settings API endpoint accessible\"\n        fi\n\n        # Test static files\n        STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w \"%{http_code}\" http://localhost:${PORT}/)\n        if [ \"$STATIC_RESPONSE\" = \"200\" ]; then\n            print_success \"Static files served correctly\"\n        else\n            print_failure \"Static files not served (HTTP $STATIC_RESPONSE)\"\n        fi\n\n        # Run pytest integration tests if they exist\n        if sudo docker compose -f docker-compose.test.yml run --rm integration-test-runner 2>/dev/null; then\n            print_success \"Integration test suite passed\"\n        else\n            print_info \"No Docker-specific integration tests found (this is OK)\"\n        fi\n    fi\n\n    # Cleanup integration containers\n    sudo docker compose -f docker-compose.test.yml down -v\nfi\n\n# ============================================\n# Summary\n# ============================================\nprint_header \"Test Summary\"\n\necho \"\"\necho -e \"Tests Passed: ${GREEN}${TESTS_PASSED}${NC}\"\necho -e \"Tests Failed: ${RED}${TESTS_FAILED}${NC}\"\n\nif [ $TESTS_FAILED -gt 0 ]; then\n    echo \"\"\n    echo -e \"${RED}Failed tests:${NC}\"\n    echo -e \"$FAILED_TESTS\"\n    echo \"\"\n    exit 1\nelse\n    echo \"\"\n    echo -e \"${GREEN}All tests passed!${NC}\"\n    echo \"\"\n    exit 0\nfi\n"
  },
  {
    "path": "test_frontend.sh",
    "content": "#!/bin/sh\n\ncd frontend\nnpx tsc\nnpm run lint\nnpm run test:run\ncd ..\n"
  },
  {
    "path": "test_security.sh",
    "content": "#!/usr/bin/env bash\n#\n# Local security scanning - mirrors GitHub Actions pipeline\n# Runs all scans in parallel and shows a consolidated summary.\n#\n# Usage:\n#   ./test_security.sh              # Run fast scans (bandit, pip-audit, npm-audit)\n#   ./test_security.sh --full       # Run full pipeline (all scans below)\n#   ./test_security.sh bandit       # Run a specific scan\n#   ./test_security.sh codeql trivy # Run multiple specific scans\n#\n# Available scans:\n#   bandit          Python static security analysis (SAST)\n#   codeql          CodeQL analysis (Actions + JavaScript + Python)\n#   codeql-actions  CodeQL GitHub Actions only\n#   codeql-python   CodeQL Python only\n#   codeql-js       CodeQL JavaScript/TypeScript only\n#   trivy           Trivy container image + Dockerfile/IaC scan\n#   trivy-image     Trivy container image scan only\n#   trivy-config    Trivy Dockerfile/IaC scan only\n#   pip-audit       Python dependency vulnerability audit\n#   npm-audit       Frontend dependency vulnerability audit\n#\n# Prerequisites:\n#   pip install bandit[sarif] pip-audit     # Python tools\n#   gh extension install github/gh-codeql   # CodeQL CLI\n#   curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh  # Trivy\n#\n\nset -uo pipefail\n\n# Navigate to project root\nPROJECT_ROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\ncd \"$PROJECT_ROOT\"\n\n# Colors\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nCYAN='\\033[0;36m'\nBOLD='\\033[1m'\nDIM='\\033[2m'\nNC='\\033[0m'\n\n# ── Temp directory for scan output ───────────────────────────────────────\n\nWORK_DIR=$(mktemp -d)\ntrap 'rm -rf \"$WORK_DIR\"' EXIT\n\n# Parallel job tracking\ndeclare -A PIDS=()       # scan_name -> PID\ndeclare -A RESULTS=()    # scan_name -> PASS|FAIL|SKIP\ndeclare -A DURATIONS=()  # scan_name -> seconds\n\n# Scan display order\nSCAN_ORDER=()\n\n# ── SARIF parser (used for CodeQL result display) ────────────────────────\n\nparse_sarif() {\n    local sarif_file=\"$1\"\n    python3 << PYEOF\nimport json\nfrom collections import defaultdict\n\nwith open(\"$sarif_file\") as f:\n    data = json.load(f)\n\nrule_desc = {}\nfor run in data.get(\"runs\", []):\n    for rule in run.get(\"tool\", {}).get(\"driver\", {}).get(\"rules\", []):\n        rid = rule.get(\"id\", \"\")\n        desc = rule.get(\"shortDescription\", {}).get(\"text\", \"\")\n        rule_desc[rid] = desc\n\nby_rule = defaultdict(list)\nfor run in data.get(\"runs\", []):\n    for result in run.get(\"results\", []):\n        rule_id = result.get(\"ruleId\", \"unknown\")\n        msg = result.get(\"message\", {}).get(\"text\", \"\")\n        locs = result.get(\"locations\", [])\n        loc = \"\"\n        if locs:\n            pl = locs[0].get(\"physicalLocation\", {})\n            uri = pl.get(\"artifactLocation\", {}).get(\"uri\", \"\")\n            line = pl.get(\"region\", {}).get(\"startLine\", \"\")\n            loc = f\"{uri}:{line}\" if line else uri\n        by_rule[rule_id].append((loc, msg))\n\ntotal = sum(len(v) for v in by_rule.values())\nif total == 0:\n    print(\"No findings.\")\nelse:\n    print(f\"{total} findings:\")\n    print()\n    for rule_id, findings in sorted(by_rule.items(), key=lambda x: -len(x[1])):\n        desc = rule_desc.get(rule_id, \"\")\n        print(f\"  {rule_id} ({len(findings)}) -- {desc}\")\n        for loc, msg in findings:\n            short_msg = msg[:100] + \"...\" if len(msg) > 100 else msg\n            print(f\"    {loc:60s} {short_msg}\")\n        print()\nPYEOF\n}\n\n# ── Scan functions (write to stdout, return exit code) ───────────────────\n\ncheck_command() {\n    command -v \"$1\" &>/dev/null\n}\n\nhas_codeql() {\n    check_command gh && gh codeql version &>/dev/null\n}\n\nscan_bandit() {\n    if ! check_command bandit; then\n        echo \"SKIP: 'bandit' not found. Install: pip install bandit[sarif]\"\n        return 2\n    fi\n    bandit -r backend/ --severity-level medium -x backend/tests 2>&1\n}\n\nscan_codeql_python() {\n    local sarif=\"$PROJECT_ROOT/codeql-python-results.sarif\"\n    if ! has_codeql; then\n        echo \"SKIP: CodeQL CLI not installed. Install: gh extension install github/gh-codeql\"\n        return 2\n    fi\n    echo \"Creating database...\"\n    gh codeql database create --overwrite --language=python --threads=0 /tmp/bambuddy-codeql-python &>/dev/null\n    echo \"Analyzing...\"\n    gh codeql database analyze /tmp/bambuddy-codeql-python \\\n        \"$PROJECT_ROOT/.codeql/python-bambuddy.qls\" \\\n        --threads=0 --format=sarifv2.1.0 --output=\"$sarif\" &>/dev/null\n    echo \"\"\n    parse_sarif \"$sarif\"\n}\n\nscan_codeql_js() {\n    local sarif=\"$PROJECT_ROOT/codeql-javascript-results.sarif\"\n    if ! has_codeql; then\n        echo \"SKIP: CodeQL CLI not installed.\"\n        return 2\n    fi\n    echo \"Creating database...\"\n    gh codeql database create --overwrite --language=javascript --source-root=frontend --threads=0 /tmp/bambuddy-codeql-javascript &>/dev/null\n    echo \"Analyzing...\"\n    gh codeql database analyze /tmp/bambuddy-codeql-javascript \\\n        \"$PROJECT_ROOT/.codeql/javascript-bambuddy.qls\" \\\n        --threads=0 --format=sarifv2.1.0 --output=\"$sarif\" &>/dev/null\n    echo \"\"\n    parse_sarif \"$sarif\"\n}\n\nscan_codeql_actions() {\n    local sarif=\"$PROJECT_ROOT/codeql-actions-results.sarif\"\n    if ! has_codeql; then\n        echo \"SKIP: CodeQL CLI not installed.\"\n        return 2\n    fi\n    echo \"Creating database...\"\n    gh codeql database create --overwrite --language=actions --threads=0 /tmp/bambuddy-codeql-actions &>/dev/null\n    echo \"Analyzing...\"\n    gh codeql database analyze /tmp/bambuddy-codeql-actions \\\n        codeql/actions-queries \\\n        --threads=0 --format=sarifv2.1.0 --output=\"$sarif\" &>/dev/null\n    echo \"\"\n    parse_sarif \"$sarif\"\n}\n\nscan_trivy_image() {\n    if ! check_command trivy; then\n        echo \"SKIP: 'trivy' not found. Install: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh\"\n        return 2\n    fi\n    if ! check_command docker; then\n        echo \"SKIP: 'docker' not found.\"\n        return 2\n    fi\n    echo \"Building Docker image...\"\n    docker build -t bambuddy:security-scan . 2>&1\n    echo \"\"\n    trivy image --severity CRITICAL,HIGH,MEDIUM bambuddy:security-scan 2>&1\n}\n\nscan_trivy_config() {\n    if ! check_command trivy; then\n        echo \"SKIP: 'trivy' not found. Install: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh\"\n        return 2\n    fi\n    trivy config --severity CRITICAL,HIGH,MEDIUM . 2>&1\n}\n\nscan_pip_audit() {\n    if ! check_command pip-audit; then\n        echo \"SKIP: 'pip-audit' not found. Install: pip install pip-audit\"\n        return 2\n    fi\n    pip-audit --desc on 2>&1\n}\n\nscan_npm_audit() {\n    if ! check_command npm; then\n        echo \"SKIP: 'npm' not found. Install Node.js\"\n        return 2\n    fi\n    (cd frontend && npm audit --audit-level=high) 2>&1\n}\n\n# ── Job launcher (streams output live with prefix, captures to log) ──────\n\nlaunch_scan() {\n    local name=\"$1\"\n    local func=\"$2\"\n    local prefix\n    prefix=$(printf \"${DIM}[%-14s]${NC} \" \"$name\")\n\n    SCAN_ORDER+=(\"$name\")\n\n    (\n        set -o pipefail\n        local start_time\n        start_time=$(date +%s)\n\n        \"$func\" 2>&1 | tee \"$WORK_DIR/${name}.log\" | sed \"s|^|${prefix}|\"\n        local exit_code=${PIPESTATUS[0]}\n\n        echo $(( $(date +%s) - start_time )) > \"$WORK_DIR/${name}.duration\"\n        exit \"$exit_code\"\n    ) &\n    PIDS[\"$name\"]=$!\n}\n\n# ── Wait for all scans ───────────────────────────────────────────────────\n\nwait_for_scans() {\n    local total=${#PIDS[@]}\n    local completed=0\n\n    while [ \"$completed\" -lt \"$total\" ]; do\n        for name in \"${SCAN_ORDER[@]}\"; do\n            local pid=${PIDS[$name]:-}\n            [ -z \"$pid\" ] && continue\n\n            if ! kill -0 \"$pid\" 2>/dev/null; then\n                wait \"$pid\" 2>/dev/null\n                local exit_code=$?\n\n                if [ \"$exit_code\" -eq 2 ]; then\n                    RESULTS[\"$name\"]=\"SKIP\"\n                elif [ \"$exit_code\" -eq 0 ]; then\n                    RESULTS[\"$name\"]=\"PASS\"\n                else\n                    RESULTS[\"$name\"]=\"FAIL\"\n                fi\n\n                if [ -f \"$WORK_DIR/${name}.duration\" ]; then\n                    DURATIONS[\"$name\"]=$(cat \"$WORK_DIR/${name}.duration\")\n                else\n                    DURATIONS[\"$name\"]=\"?\"\n                fi\n\n                local status_color\n                case \"${RESULTS[$name]}\" in\n                    PASS) status_color=\"$GREEN\" ;;\n                    FAIL) status_color=\"$RED\" ;;\n                    SKIP) status_color=\"$YELLOW\" ;;\n                esac\n                echo -e \"${status_color}${BOLD}[${RESULTS[$name]}]${NC} ${name} ${DIM}(${DURATIONS[$name]}s)${NC}\"\n\n                unset \"PIDS[$name]\"\n                completed=$((completed + 1))\n            fi\n        done\n        sleep 0.5\n    done\n}\n\n# ── Summary ──────────────────────────────────────────────────────────────\n\nprint_summary() {\n    local pass=0 fail=0 skip=0\n\n    for name in \"${SCAN_ORDER[@]}\"; do\n        case \"${RESULTS[$name]}\" in\n            PASS) pass=$((pass + 1)) ;;\n            FAIL) fail=$((fail + 1)) ;;\n            SKIP) skip=$((skip + 1)) ;;\n        esac\n    done\n\n    # ── Results table ────────────────────────────────────────────────────\n\n    echo \"\"\n    echo -e \"${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}\"\n    echo -e \"${CYAN}${BOLD}  Security Scan Results${NC}\"\n    echo -e \"${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}\"\n    echo \"\"\n    printf \"  ${BOLD}%-6s  %-24s  %s${NC}\\n\" \"Status\" \"Scan\" \"Duration\"\n    printf \"  %-6s  %-24s  %s\\n\" \"──────\" \"────────────────────────\" \"────────\"\n\n    for name in \"${SCAN_ORDER[@]}\"; do\n        local status=\"${RESULTS[$name]}\"\n        local duration=\"${DURATIONS[$name]:-?}s\"\n        local status_color\n        case \"$status\" in\n            PASS) status_color=\"$GREEN\" ;;\n            FAIL) status_color=\"$RED\" ;;\n            SKIP) status_color=\"$YELLOW\" ;;\n        esac\n        printf \"  ${status_color}%-6s${NC}  %-24s  ${DIM}%s${NC}\\n\" \"$status\" \"$name\" \"$duration\"\n    done\n\n    echo \"\"\n    echo -e \"  ${GREEN}$pass passed${NC}  ${RED}$fail failed${NC}  ${YELLOW}$skip skipped${NC}\"\n\n    # ── Full output per scan ─────────────────────────────────────────────\n\n    for name in \"${SCAN_ORDER[@]}\"; do\n        local log=\"$WORK_DIR/${name}.log\"\n        [ ! -f \"$log\" ] && continue\n\n        local status=\"${RESULTS[$name]}\"\n        local status_color\n\n        case \"$status\" in\n            PASS) status_color=\"$GREEN\" ;;\n            FAIL) status_color=\"$RED\" ;;\n            SKIP) status_color=\"$YELLOW\" ;;\n        esac\n\n        echo \"\"\n        echo -e \"${CYAN}──────────────────────────────────────────────────────────────${NC}\"\n        echo -e \"${BOLD}  $name${NC}  ${status_color}[$status]${NC}\"\n        echo -e \"${CYAN}──────────────────────────────────────────────────────────────${NC}\"\n\n        sed 's/^/  /' \"$log\"\n    done\n\n    echo \"\"\n    echo -e \"${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}\"\n    echo \"\"\n\n    if [ \"$fail\" -gt 0 ]; then\n        exit 1\n    fi\n}\n\n# ── Main ─────────────────────────────────────────────────────────────────\n\nif [ \"${1:-}\" = \"--help\" ] || [ \"${1:-}\" = \"-h\" ]; then\n    head -29 \"$0\" | tail -27\n    exit 0\nfi\n\necho -e \"${BOLD}Bambuddy Security Scanner${NC}\"\necho -e \"${DIM}$(date '+%Y-%m-%d %H:%M:%S')  •  $(nproc) CPU cores available${NC}\"\necho \"\"\n\nSCANS_TO_RUN=()\n\nif [ $# -eq 0 ]; then\n    SCANS_TO_RUN=(bandit pip-audit npm-audit)\nelif [ \"$1\" = \"--full\" ]; then\n    SCANS_TO_RUN=(bandit pip-audit npm-audit codeql-actions codeql-python codeql-js trivy-image trivy-config)\nelse\n    for scan in \"$@\"; do\n        case \"$scan\" in\n            codeql) SCANS_TO_RUN+=(codeql-actions codeql-python codeql-js) ;;\n            trivy)  SCANS_TO_RUN+=(trivy-image trivy-config) ;;\n            bandit|codeql-actions|codeql-python|codeql-js|trivy-image|trivy-config|pip-audit|npm-audit)\n                SCANS_TO_RUN+=(\"$scan\") ;;\n            *)\n                echo -e \"${RED}Unknown scan: $scan${NC}\"\n                echo \"Run with --help for available scans\"\n                exit 1\n                ;;\n        esac\n    done\nfi\n\n# Launch all scans in parallel\nfor scan in \"${SCANS_TO_RUN[@]}\"; do\n    case \"$scan\" in\n        bandit)         launch_scan \"bandit\"         scan_bandit ;;\n        codeql-actions) launch_scan \"codeql-actions\" scan_codeql_actions ;;\n        codeql-python)  launch_scan \"codeql-python\"  scan_codeql_python ;;\n        codeql-js)      launch_scan \"codeql-js\"      scan_codeql_js ;;\n        trivy-image)    launch_scan \"trivy-image\"    scan_trivy_image ;;\n        trivy-config)   launch_scan \"trivy-config\"   scan_trivy_config ;;\n        pip-audit)      launch_scan \"pip-audit\"      scan_pip_audit ;;\n        npm-audit)      launch_scan \"npm-audit\"      scan_npm_audit ;;\n    esac\ndone\n\necho -e \"${BOLD}Running ${#SCANS_TO_RUN[@]} scan(s) in parallel...${NC}\"\necho \"\"\n\nwait_for_scans\nprint_summary\n"
  },
  {
    "path": "tests/e2e_comprehensive_test.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Comprehensive end-to-end test for Bambuddy application.\"\"\"\n\nimport os\nimport time\n\nfrom playwright.sync_api import expect, sync_playwright\n\nBASE_URL = os.environ.get(\"BAMBUDDY_URL\", \"http://localhost:8000\")\n\n\ndef test_navigation_and_sidebar(page):\n    \"\"\"Test sidebar navigation and all main pages.\"\"\"\n    print(\"\\n=== Testing Navigation & Sidebar ===\")\n\n    # Go to home page\n    page.goto(BASE_URL)\n    page.wait_for_load_state(\"networkidle\")\n\n    # Take initial screenshot\n    page.screenshot(path=\"/tmp/bambuddy_home.png\", full_page=True)\n    print(\"✓ Home page loaded\")\n\n    # Check sidebar is visible\n    sidebar = page.locator(\"nav\").first\n    expect(sidebar).to_be_visible()\n    print(\"✓ Sidebar is visible\")\n\n    # Test navigation to each main page\n    nav_items = [\n        (\"Printers\", \"/\"),\n        (\"Archives\", \"/archives\"),\n        (\"Queue\", \"/queue\"),\n        (\"Statistics\", \"/stats\"),\n        (\"Profiles\", \"/profiles\"),\n        (\"Maintenance\", \"/maintenance\"),\n        (\"Settings\", \"/settings\"),\n    ]\n\n    for name, _path in nav_items:\n        # Click on nav item by text\n        nav_link = page.locator(f'nav >> text=\"{name}\"').first\n        if nav_link.is_visible():\n            nav_link.click()\n            page.wait_for_load_state(\"networkidle\")\n            time.sleep(0.5)  # Brief wait for animations\n            print(f\"✓ Navigated to {name}\")\n        else:\n            print(f\"⚠ Nav item '{name}' not visible\")\n\n    return True\n\n\ndef test_printers_page(page):\n    \"\"\"Test Printers page functionality.\"\"\"\n    print(\"\\n=== Testing Printers Page ===\")\n\n    page.goto(BASE_URL)\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Check for printer cards or \"Add Printer\" button\n    page_content = page.content()\n\n    # Look for printer-related elements\n    if \"Add Printer\" in page_content or \"printer\" in page_content.lower():\n        print(\"✓ Printers page content detected\")\n\n    # Check for AMS display if printers are connected\n    ams_elements = page.locator(\"text=/AMS-[A-Z]/\").all()\n    if ams_elements:\n        print(f\"✓ Found {len(ams_elements)} AMS unit(s) displayed\")\n\n    # Take screenshot\n    page.screenshot(path=\"/tmp/bambuddy_printers.png\", full_page=True)\n    print(\"✓ Printers page screenshot saved\")\n\n    return True\n\n\ndef test_archives_page(page):\n    \"\"\"Test Archives page functionality.\"\"\"\n    print(\"\\n=== Testing Archives Page ===\")\n\n    page.goto(f\"{BASE_URL}/archives\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Check for search input\n    search_input = page.locator('input[placeholder*=\"Search\"]').first\n    if search_input.is_visible():\n        print(\"✓ Search input found\")\n        # Test search functionality\n        search_input.fill(\"test\")\n        time.sleep(0.5)\n        search_input.clear()\n\n    # Check for upload button\n    upload_btn = page.locator('text=\"Upload\"').first\n    if upload_btn.is_visible():\n        print(\"✓ Upload button found\")\n\n    # Take screenshot\n    page.screenshot(path=\"/tmp/bambuddy_archives.png\", full_page=True)\n    print(\"✓ Archives page screenshot saved\")\n\n    return True\n\n\ndef test_queue_page(page):\n    \"\"\"Test Queue page functionality.\"\"\"\n    print(\"\\n=== Testing Queue Page ===\")\n\n    page.goto(f\"{BASE_URL}/queue\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Check for queue-related content\n    page_content = page.content()\n\n    if \"Queue\" in page_content or \"queue\" in page_content.lower():\n        print(\"✓ Queue page content detected\")\n\n    # Take screenshot\n    page.screenshot(path=\"/tmp/bambuddy_queue.png\", full_page=True)\n    print(\"✓ Queue page screenshot saved\")\n\n    return True\n\n\ndef test_statistics_page(page):\n    \"\"\"Test Statistics page functionality.\"\"\"\n    print(\"\\n=== Testing Statistics Page ===\")\n\n    page.goto(f\"{BASE_URL}/stats\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Check for statistics widgets\n    page_content = page.content()\n\n    stats_keywords = [\"Total\", \"Success\", \"Failed\", \"Prints\", \"Filament\", \"Time\"]\n    found_stats = [kw for kw in stats_keywords if kw in page_content]\n\n    if found_stats:\n        print(f\"✓ Statistics found: {', '.join(found_stats)}\")\n\n    # Take screenshot\n    page.screenshot(path=\"/tmp/bambuddy_statistics.png\", full_page=True)\n    print(\"✓ Statistics page screenshot saved\")\n\n    return True\n\n\ndef test_settings_page(page):\n    \"\"\"Test Settings page functionality.\"\"\"\n    print(\"\\n=== Testing Settings Page ===\")\n\n    page.goto(f\"{BASE_URL}/settings\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Check for settings sections\n    settings_sections = [\n        \"Spoolman\",\n        \"Notifications\",\n        \"Smart Plugs\",\n        \"General\",\n    ]\n\n    page_content = page.content()\n    for section in settings_sections:\n        if section in page_content:\n            print(f\"✓ Settings section found: {section}\")\n\n    # Take screenshot\n    page.screenshot(path=\"/tmp/bambuddy_settings.png\", full_page=True)\n    print(\"✓ Settings page screenshot saved\")\n\n    return True\n\n\ndef test_keyboard_shortcuts(page):\n    \"\"\"Test keyboard shortcuts functionality.\"\"\"\n    print(\"\\n=== Testing Keyboard Shortcuts ===\")\n\n    page.goto(BASE_URL)\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(0.5)\n\n    # Press '?' to open shortcuts modal\n    page.keyboard.press(\"?\")\n    time.sleep(0.5)\n\n    # Check if modal opened\n    modal = page.locator('text=\"Keyboard Shortcuts\"').first\n    if modal.is_visible():\n        print(\"✓ Keyboard shortcuts modal opened with '?'\")\n        page.screenshot(path=\"/tmp/bambuddy_shortcuts_modal.png\")\n\n        # Close with Escape\n        page.keyboard.press(\"Escape\")\n        time.sleep(0.3)\n        if not modal.is_visible():\n            print(\"✓ Modal closed with Escape\")\n    else:\n        print(\"⚠ Keyboard shortcuts modal did not open\")\n\n    # Test number key navigation\n    # Press '2' to go to Archives\n    page.keyboard.press(\"2\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(0.5)\n\n    current_url = page.url\n    if \"/archives\" in current_url:\n        print(\"✓ Hotkey '2' navigated to Archives\")\n    else:\n        print(f\"⚠ Hotkey '2' navigation - current URL: {current_url}\")\n\n    # Press '1' to go back to Printers\n    page.keyboard.press(\"1\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(0.5)\n\n    current_url = page.url\n    if current_url == BASE_URL + \"/\" or current_url == BASE_URL:\n        print(\"✓ Hotkey '1' navigated to Printers\")\n\n    return True\n\n\ndef test_theme_toggle(page):\n    \"\"\"Test theme toggle functionality.\"\"\"\n    print(\"\\n=== Testing Theme Toggle ===\")\n\n    page.goto(f\"{BASE_URL}/settings\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Check if dark theme is applied (should be default)\n    html = page.locator(\"html\")\n    classes = html.get_attribute(\"class\") or \"\"\n\n    if \"dark\" in classes:\n        print(\"✓ Dark theme is active (default)\")\n    else:\n        print(\"ℹ Dark theme class not found on HTML element\")\n\n    # Look for theme-related UI elements\n    page_content = page.content()\n    if \"theme\" in page_content.lower() or \"dark\" in page_content.lower():\n        print(\"✓ Theme-related content found on page\")\n\n    return True\n\n\ndef test_responsive_design(page):\n    \"\"\"Test responsive design at different viewport sizes.\"\"\"\n    print(\"\\n=== Testing Responsive Design ===\")\n\n    viewports = [\n        (\"Desktop\", 1920, 1080),\n        (\"Tablet\", 768, 1024),\n        (\"Mobile\", 375, 667),\n    ]\n\n    for name, width, height in viewports:\n        page.set_viewport_size({\"width\": width, \"height\": height})\n        page.goto(BASE_URL)\n        page.wait_for_load_state(\"networkidle\")\n        time.sleep(0.5)\n\n        page.screenshot(path=f\"/tmp/bambuddy_{name.lower()}.png\", full_page=True)\n        print(f\"✓ {name} viewport ({width}x{height}) screenshot saved\")\n\n    # Reset to desktop\n    page.set_viewport_size({\"width\": 1920, \"height\": 1080})\n\n    return True\n\n\ndef test_external_links_sidebar(page):\n    \"\"\"Test external links in sidebar.\"\"\"\n    print(\"\\n=== Testing External Links in Sidebar ===\")\n\n    page.goto(BASE_URL)\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(0.5)\n\n    # Look for external link indicators\n    external_links = page.locator('nav a[target=\"_blank\"], nav >> text=/Spoolman|SpoolEase/i').all()\n\n    if external_links:\n        print(f\"✓ Found {len(external_links)} external link(s) in sidebar\")\n    else:\n        print(\"ℹ No external links configured in sidebar\")\n\n    return True\n\n\ndef test_api_health(page):\n    \"\"\"Test basic API endpoints.\"\"\"\n    print(\"\\n=== Testing API Health ===\")\n\n    # Test printers endpoint\n    response = page.request.get(f\"{BASE_URL}/api/v1/printers/\")\n    if response.ok:\n        data = response.json()\n        print(f\"✓ GET /api/v1/printers/ - {len(data)} printer(s)\")\n    else:\n        print(f\"⚠ GET /api/v1/printers/ - Status: {response.status}\")\n\n    # Test archives endpoint\n    response = page.request.get(f\"{BASE_URL}/api/v1/archives/\")\n    if response.ok:\n        data = response.json()\n        print(f\"✓ GET /api/v1/archives/ - {len(data)} archive(s)\")\n    else:\n        print(f\"⚠ GET /api/v1/archives/ - Status: {response.status}\")\n\n    # Test settings endpoint\n    response = page.request.get(f\"{BASE_URL}/api/v1/settings/\")\n    if response.ok:\n        print(\"✓ GET /api/v1/settings/ - OK\")\n    else:\n        print(f\"⚠ GET /api/v1/settings/ - Status: {response.status}\")\n\n    return True\n\n\ndef run_comprehensive_test():\n    \"\"\"Run all comprehensive tests.\"\"\"\n    print(\"=\" * 60)\n    print(\"BAMBUDDY COMPREHENSIVE E2E TEST\")\n    print(\"=\" * 60)\n    print(f\"Target: {BASE_URL}\")\n\n    results = {}\n\n    with sync_playwright() as p:\n        browser = p.chromium.launch(headless=True)\n        context = browser.new_context(viewport={\"width\": 1920, \"height\": 1080})\n        page = context.new_page()\n\n        # Enable console logging\n        page.on(\"console\", lambda msg: print(f\"  [Browser] {msg.text}\") if msg.type == \"error\" else None)\n\n        tests = [\n            (\"API Health\", test_api_health),\n            (\"Navigation & Sidebar\", test_navigation_and_sidebar),\n            (\"Printers Page\", test_printers_page),\n            (\"Archives Page\", test_archives_page),\n            (\"Queue Page\", test_queue_page),\n            (\"Statistics Page\", test_statistics_page),\n            (\"Settings Page\", test_settings_page),\n            (\"Keyboard Shortcuts\", test_keyboard_shortcuts),\n            (\"Theme Toggle\", test_theme_toggle),\n            (\"External Links\", test_external_links_sidebar),\n            (\"Responsive Design\", test_responsive_design),\n        ]\n\n        for test_name, test_func in tests:\n            try:\n                results[test_name] = test_func(page)\n            except Exception as e:\n                print(f\"\\n❌ {test_name} FAILED: {e}\")\n                results[test_name] = False\n                page.screenshot(path=f\"/tmp/bambuddy_error_{test_name.lower().replace(' ', '_')}.png\")\n\n        browser.close()\n\n    # Summary\n    print(\"\\n\" + \"=\" * 60)\n    print(\"TEST SUMMARY\")\n    print(\"=\" * 60)\n\n    passed = sum(1 for v in results.values() if v)\n    total = len(results)\n\n    for test_name, passed_test in results.items():\n        status = \"✓ PASS\" if passed_test else \"❌ FAIL\"\n        print(f\"  {status} - {test_name}\")\n\n    print(f\"\\nTotal: {passed}/{total} tests passed\")\n    print(\"Screenshots saved to /tmp/bambuddy_*.png\")\n\n    return all(results.values())\n\n\nif __name__ == \"__main__\":\n    success = run_comprehensive_test()\n    exit(0 if success else 1)\n"
  },
  {
    "path": "tests/e2e_toggle_persistence_test.py",
    "content": "#!/usr/bin/env python3\n\"\"\"E2E tests for toggle persistence - critical regression prevention.\n\nThese tests verify that toggle settings (auto_off, notification events, etc.)\nare properly persisted to the database and survive page reloads.\n\"\"\"\n\nimport os\nimport time\n\nfrom playwright.sync_api import sync_playwright\n\nBASE_URL = os.environ.get(\"BAMBUDDY_URL\", \"http://localhost:8000\")\n\n\ndef test_smart_plug_auto_off_toggle_persistence(page):\n    \"\"\"CRITICAL: Test that auto_off toggle persists after page reload.\n\n    This tests the regression where auto_off toggle wasn't being saved.\n    \"\"\"\n    print(\"\\n=== Testing Smart Plug Auto-Off Toggle Persistence ===\")\n\n    # Navigate to Settings page\n    page.goto(f\"{BASE_URL}/settings\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Look for Smart Plugs section\n    smart_plugs_section = page.locator('text=\"Smart Plugs\"').first\n    if not smart_plugs_section.is_visible():\n        print(\"⚠ No Smart Plugs section found - skipping test\")\n        return True\n\n    # Find an Automation Settings toggle to expand\n    automation_toggle = page.locator('text=\"Automation Settings\"').first\n    if not automation_toggle.is_visible():\n        print(\"⚠ No Automation Settings found - skipping test\")\n        return True\n\n    automation_toggle.click()\n    time.sleep(0.5)\n\n    # Find Auto Off toggle\n    auto_off_label = page.locator('text=\"Auto Off\"').first\n    if not auto_off_label.is_visible():\n        print(\"⚠ Auto Off label not found - skipping test\")\n        return True\n\n    # Find the toggle switch near the Auto Off label\n    # The toggle is a sibling element\n    auto_off_section = auto_off_label.locator(\"..\").first\n    toggle = auto_off_section.locator('button[role=\"switch\"]').first\n\n    if not toggle.is_visible():\n        # Try finding any toggle in the section\n        toggle = page.locator('button[role=\"switch\"]').nth(1)  # Skip first (main enabled toggle)\n\n    if not toggle.is_visible():\n        print(\"⚠ Auto Off toggle not found - skipping test\")\n        return True\n\n    # Get initial state\n    initial_state = toggle.get_attribute(\"aria-checked\")\n    print(f\"✓ Initial auto_off state: {initial_state}\")\n\n    # Click to toggle\n    toggle.click()\n    time.sleep(1)  # Wait for API call\n\n    # Verify toggle changed\n    new_state = toggle.get_attribute(\"aria-checked\")\n    assert new_state != initial_state, \"Toggle should have changed state\"\n    print(f\"✓ Toggled auto_off to: {new_state}\")\n\n    # Reload the page\n    page.reload()\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Expand automation settings again\n    automation_toggle = page.locator('text=\"Automation Settings\"').first\n    automation_toggle.click()\n    time.sleep(0.5)\n\n    # Find toggle again and verify state persisted\n    auto_off_section = page.locator('text=\"Auto Off\"').first.locator(\"..\").first\n    toggle = auto_off_section.locator('button[role=\"switch\"]').first\n    if not toggle.is_visible():\n        toggle = page.locator('button[role=\"switch\"]').nth(1)\n\n    persisted_state = toggle.get_attribute(\"aria-checked\")\n    assert persisted_state == new_state, (\n        f\"State should persist after reload. Expected {new_state}, got {persisted_state}\"\n    )\n    print(f\"✓ Toggle state persisted after reload: {persisted_state}\")\n\n    # Restore original state\n    if persisted_state != initial_state:\n        toggle.click()\n        time.sleep(1)\n        print(\"✓ Restored original toggle state\")\n\n    return True\n\n\ndef test_notification_event_toggle_persistence(page):\n    \"\"\"CRITICAL: Test that notification event toggles persist after page reload.\n\n    This tests the regression where notification event toggles weren't being saved.\n    \"\"\"\n    print(\"\\n=== Testing Notification Event Toggle Persistence ===\")\n\n    # Navigate to Settings page\n    page.goto(f\"{BASE_URL}/settings\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Look for Notifications section\n    notifications_section = page.locator('text=\"Notifications\"').first\n    if not notifications_section.is_visible():\n        print(\"⚠ No Notifications section found - skipping test\")\n        return True\n\n    # Find Event Settings toggle to expand\n    event_settings = page.locator('text=\"Event Settings\"').first\n    if not event_settings.is_visible():\n        print(\"⚠ No Event Settings found - skipping test\")\n        return True\n\n    event_settings.click()\n    time.sleep(0.5)\n\n    # Find Print Stopped toggle (this was a regression point)\n    stopped_label = page.locator('text=\"Print Stopped\"').first\n    if not stopped_label.is_visible():\n        print(\"⚠ Print Stopped label not found - skipping test\")\n        return True\n\n    # Find the toggle switch\n    stopped_section = stopped_label.locator(\"..\").first\n    toggle = stopped_section.locator('button[role=\"switch\"]').first\n\n    if not toggle.is_visible():\n        print(\"⚠ Print Stopped toggle not found - skipping test\")\n        return True\n\n    # Get initial state\n    initial_state = toggle.get_attribute(\"aria-checked\")\n    print(f\"✓ Initial on_print_stopped state: {initial_state}\")\n\n    # Click to toggle\n    toggle.click()\n    time.sleep(1)  # Wait for API call\n\n    # Verify toggle changed\n    new_state = toggle.get_attribute(\"aria-checked\")\n    assert new_state != initial_state, \"Toggle should have changed state\"\n    print(f\"✓ Toggled on_print_stopped to: {new_state}\")\n\n    # Reload the page\n    page.reload()\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Expand event settings again\n    event_settings = page.locator('text=\"Event Settings\"').first\n    event_settings.click()\n    time.sleep(0.5)\n\n    # Find toggle again and verify state persisted\n    stopped_section = page.locator('text=\"Print Stopped\"').first.locator(\"..\").first\n    toggle = stopped_section.locator('button[role=\"switch\"]').first\n\n    if toggle.is_visible():\n        persisted_state = toggle.get_attribute(\"aria-checked\")\n        assert persisted_state == new_state, (\n            f\"State should persist after reload. Expected {new_state}, got {persisted_state}\"\n        )\n        print(f\"✓ Toggle state persisted after reload: {persisted_state}\")\n\n        # Restore original state\n        if persisted_state != initial_state:\n            toggle.click()\n            time.sleep(1)\n            print(\"✓ Restored original toggle state\")\n\n    return True\n\n\ndef test_ams_alarm_toggle_persistence(page):\n    \"\"\"Test that AMS alarm toggles persist after page reload.\"\"\"\n    print(\"\\n=== Testing AMS Alarm Toggle Persistence ===\")\n\n    # Navigate to Settings page\n    page.goto(f\"{BASE_URL}/settings\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Look for Event Settings in notification provider\n    event_settings = page.locator('text=\"Event Settings\"').first\n    if not event_settings.is_visible():\n        print(\"⚠ No Event Settings found - skipping test\")\n        return True\n\n    event_settings.click()\n    time.sleep(0.5)\n\n    # Look for AMS Humidity High toggle\n    ams_humidity_label = page.locator('text=\"AMS Humidity High\"').first\n    if not ams_humidity_label.is_visible():\n        print(\"⚠ AMS Humidity High label not found - skipping test\")\n        return True\n\n    print(\"✓ AMS Alarm toggles section found\")\n\n    # Find and test the toggle\n    ams_section = ams_humidity_label.locator(\"..\").first\n    toggle = ams_section.locator('button[role=\"switch\"]').first\n\n    if toggle.is_visible():\n        initial_state = toggle.get_attribute(\"aria-checked\")\n        print(f\"✓ Initial AMS humidity alarm state: {initial_state}\")\n\n        toggle.click()\n        time.sleep(1)\n\n        new_state = toggle.get_attribute(\"aria-checked\")\n        print(f\"✓ Toggled AMS humidity alarm to: {new_state}\")\n\n        # Reload and verify\n        page.reload()\n        page.wait_for_load_state(\"networkidle\")\n        time.sleep(1)\n\n        event_settings = page.locator('text=\"Event Settings\"').first\n        event_settings.click()\n        time.sleep(0.5)\n\n        ams_section = page.locator('text=\"AMS Humidity High\"').first.locator(\"..\").first\n        toggle = ams_section.locator('button[role=\"switch\"]').first\n\n        if toggle.is_visible():\n            persisted_state = toggle.get_attribute(\"aria-checked\")\n            print(f\"✓ AMS alarm state after reload: {persisted_state}\")\n\n            # Restore\n            if persisted_state != initial_state:\n                toggle.click()\n                time.sleep(1)\n\n    return True\n\n\ndef test_smart_plug_power_off_confirmation(page):\n    \"\"\"Test that power off shows confirmation dialog.\"\"\"\n    print(\"\\n=== Testing Smart Plug Power Off Confirmation ===\")\n\n    page.goto(f\"{BASE_URL}/settings\")\n    page.wait_for_load_state(\"networkidle\")\n    time.sleep(1)\n\n    # Find an Off button\n    off_button = page.locator('button:has-text(\"Off\")').first\n    if not off_button.is_visible():\n        print(\"⚠ No Off button found - skipping test\")\n        return True\n\n    off_button.click()\n    time.sleep(0.5)\n\n    # Look for confirmation dialog\n    confirm_dialog = page.locator(\"text=/Turn Off|Confirm|cut power/i\").first\n    if confirm_dialog.is_visible():\n        print(\"✓ Confirmation dialog appeared\")\n\n        # Close dialog by clicking Cancel or outside\n        cancel_btn = page.locator('button:has-text(\"Cancel\")').first\n        if cancel_btn.is_visible():\n            cancel_btn.click()\n            print(\"✓ Cancelled power off\")\n    else:\n        print(\"⚠ No confirmation dialog found\")\n\n    return True\n\n\ndef run_all_toggle_tests():\n    \"\"\"Run all toggle persistence tests.\"\"\"\n    print(\"=\" * 60)\n    print(\"Bambuddy Toggle Persistence E2E Tests\")\n    print(\"=\" * 60)\n\n    with sync_playwright() as p:\n        browser = p.chromium.launch(headless=True)\n        context = browser.new_context(viewport={\"width\": 1280, \"height\": 720})\n        page = context.new_page()\n\n        tests = [\n            (\"Smart Plug Auto-Off Toggle\", test_smart_plug_auto_off_toggle_persistence),\n            (\"Notification Event Toggle\", test_notification_event_toggle_persistence),\n            (\"AMS Alarm Toggle\", test_ams_alarm_toggle_persistence),\n            (\"Power Off Confirmation\", test_smart_plug_power_off_confirmation),\n        ]\n\n        results = []\n        for name, test_func in tests:\n            try:\n                result = test_func(page)\n                results.append((name, \"PASS\" if result else \"FAIL\"))\n            except Exception as e:\n                print(f\"✗ Test failed with error: {e}\")\n                results.append((name, f\"ERROR: {e}\"))\n\n        browser.close()\n\n        # Print summary\n        print(\"\\n\" + \"=\" * 60)\n        print(\"Test Results Summary\")\n        print(\"=\" * 60)\n        for name, result in results:\n            status = \"✓\" if result == \"PASS\" else \"✗\"\n            print(f\"{status} {name}: {result}\")\n\n        passed = sum(1 for _, r in results if r == \"PASS\")\n        total = len(results)\n        print(f\"\\nTotal: {passed}/{total} passed\")\n\n        return passed == total\n\n\nif __name__ == \"__main__\":\n    import sys\n\n    success = run_all_toggle_tests()\n    sys.exit(0 if success else 1)\n"
  },
  {
    "path": "update_website_wiki.sh",
    "content": "#!/bin/bash\n\ncd ../bambuddy-website\ngit add .\ngit commit -m \"Updated website\"\ngit push\n\ncd ../bambuddy-wiki\ngit add .\ngit commit -m \"Updated Wiki\"\ngit push\n\ncd ../bambuddy-telemetry/\ngit add .\ngit commit -m \"Updated Stats\"\ngit push\n"
  }
]